From 146904802301aa0b0008e2bdb3a88ed10ff50acf Mon Sep 17 00:00:00 2001 From: Richard Mitchell Date: Sun, 5 Apr 2020 15:48:35 +0100 Subject: [PATCH] Home assistant (#1) * Tentative homeassistant support. --- kamereon/__init__.py | 1429 ++++-------------------------------- kamereon/binary_sensor.py | 149 ++++ kamereon/climate.py | 91 +++ kamereon/device_tracker.py | 39 + kamereon/kamereon.py | 1401 +++++++++++++++++++++++++++++++++++ kamereon/lock.py | 54 ++ kamereon/manifest.json | 8 + kamereon/sensor.py | 326 ++++++++ kamereon/switch.py | 36 + 9 files changed, 2245 insertions(+), 1288 deletions(-) create mode 100644 kamereon/binary_sensor.py create mode 100644 kamereon/climate.py create mode 100644 kamereon/device_tracker.py create mode 100644 kamereon/kamereon.py create mode 100644 kamereon/lock.py create mode 100644 kamereon/manifest.json create mode 100644 kamereon/sensor.py create mode 100644 kamereon/switch.py diff --git a/kamereon/__init__.py b/kamereon/__init__.py index efd16b6..1375dd9 100644 --- a/kamereon/__init__.py +++ b/kamereon/__init__.py @@ -1,1332 +1,185 @@ -# Copyright 2020 Richard Mitchell +"""Support for Kamereon-supporting cars.""" +import asyncio +from datetime import timedelta +import logging -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at +import voluptuous as vol -# http://www.apache.org/licenses/LICENSE-2.0 +from homeassistant.const import ( + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_USERNAME, +) +from homeassistant.helpers import discovery +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.util.dt import utcnow -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. +from .kamereon import NCISession -import collections -import datetime -import enum -import json -import os -from typing import List -from urllib.parse import urljoin, urlparse, parse_qs +DOMAIN = "kamereon" -from oauthlib.common import generate_nonce -import pytz -import requests -from requests_oauthlib import OAuth2Session +DATA_KEY = DOMAIN +_LOGGER = logging.getLogger(__name__) -API_VERSION = 'protocol=1.0,resource=2.1' -SRP_KEY = 'D5AF0E14718E662D12DBB4FE42304DF5A8E48359E22261138B40AA16CC85C76A11B43200A1EECB3C9546A262D1FBD51ACE6FCDE558C00665BBF93FF86B9F8F76AA7A53CA74F5B4DFF9A4B847295E7D82450A2078B5A28814A7A07F8BBDD34F8EEB42B0E70499087A242AA2C5BA9513C8F9D35A81B33A121EEF0A71F3F9071CCD' +MIN_UPDATE_INTERVAL = timedelta(minutes=1) +DEFAULT_UPDATE_INTERVAL = timedelta(minutes=5) +CONF_MANUFACTURER = 'manufacturer' +CONF_REGION = "region" -settings_map = { - 'nissan': { - 'EU': { - 'client_id': 'a-ncb-prod-android', - 'client_secret': '3LBs0yOx2XO-3m4mMRW27rKeJzskhfWF0A8KUtnim8i/qYQPl8ZItp3IaqJXaYj_', - 'scope': 'openid profile vehicles', - 'auth_base_url': 'https://prod.eu.auth.kamereon.org/kauth/', - 'realm': 'a-ncb-prod', - 'redirect_uri': 'org.kamereon.service.nci:/oauth2redirect', - 'car_adapter_base_url': 'https://alliance-platform-caradapter-prod.apps.eu.kamereon.io/car-adapter/', - 'notifications_base_url': 'https://alliance-platform-notifications-prod.apps.eu.kamereon.io/notifications/', - 'user_adapter_base_url': 'https://alliance-platform-usersadapter-prod.apps.eu.kamereon.io/user-adapter/', - 'user_base_url': 'https://nci-bff-web-prod.apps.eu.kamereon.io/bff-web/', - }, - 'JP': {}, - 'RU': {}, - }, - 'mitsubishi': {}, - 'renault': {}, -} - +SIGNAL_STATE_UPDATED = f"{DOMAIN}.updated" -USERS = 'users' -VEHICLES = 'vehicles' -CATEGORIES = 'categories' -NOTIFICATION_RULES = 'notification_rules' -NOTIFICATION_TYPES = 'notification_types' -NOTIFICATION_CATEGORIES = 'notification_categories' -_registry = { - USERS: {}, - VEHICLES: {}, - CATEGORIES: {}, - NOTIFICATION_RULES: {}, - NOTIFICATION_TYPES: {}, - NOTIFICATION_CATEGORIES: {}, +MANUFACTURERS = { + 'nissan': NCISession, } +SUB_SCHEMA = vol.Schema({ + vol.Required(CONF_MANUFACTURER): vol.All(cv.string, vol.In(MANUFACTURERS)), + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_UPDATE_INTERVAL + ): vol.All(cv.time_period, vol.Clamp(min=MIN_UPDATE_INTERVAL)), + vol.Optional(CONF_REGION): cv.string, +}) + +CONFIG_SCHEMA = vol.Schema( + { + vol.Optional(DOMAIN): vol.All(cv.ensure_list, [SUB_SCHEMA]) + }, + extra=vol.ALLOW_EXTRA, +) -class HVACAction(enum.Enum): - # Start or schedule start - START = 'start' - # Stop active HVAC - STOP = 'stop' - # Cancel scheduled HVAC - CANCEL = 'cancel' - - -class HVACStatus(enum.Enum): - OFF = 'off' - ON = 'on' - - -class LockStatus(enum.Enum): - CLOSED = 'closed' - LOCKED = 'locked' - OPEN = 'open' - UNLOCKED = 'unlocked' - - -class Door(enum.Enum): - HATCH = 'hatch' - FRONT_LEFT = 'front-left' - FRONT_RIGHT = 'front-right' - REAR_LEFT = 'rear-left' - REAR_RIGHT = 'rear-right' - - -class LockableDoorGroup(enum.Enum): - DOORS_AND_HATCH = 'doors_hatch' - DRIVERS_DOOR = 'driver_s_door' - HATCH = 'hatch' - - -class ChargingSpeed(enum.Enum): - NONE = None - SLOW = 1 - NORMAL = 2 - FAST = 3 - - -class ChargingStatus(enum.Enum): - ERROR = -1 - NOT_CHARGING = 0 - CHARGING = 1 - - -class PluggedStatus(enum.Enum): - ERROR = -1 - NOT_PLUGGED = 0 - PLUGGED = 1 - - -class Period(enum.Enum): - DAILY = 0 - MONTHLY = 1 - YEARLY = 2 - - -class Feature(enum.Enum): - BREAKDOWN_ASSISTANCE_CALL = '1' - SVT_WITH_VEHICLE_BLOCKAGE = '10' - MAINTENANCE_ALERT = '101' - VEHICLE_SOFTWARE_UPDATES = '107' - MY_CAR_FINDER = '12' - MIL_ON_NOTIFICATION = '15' - VEHICLE_HEALTH_REPORT = '18' - ADVANCED_CAN = '201' - VEHICLE_STATUS_CHECK = '202' - LOCK_STATUS_CHECK = '2021' - NAVIGATION_FACTORY_RESET = '208' - MESSAGES_TO_THE_VEHICLE = '21' - VEHICLE_DATA = '2121' - VEHICLE_DATA_2 = '2122' - VEHICLE_WIFI = '213' - ADVANCED_VEHICLE_DIAGNOSTICS = '215' - NAVIGATION_MAP_UPDATES = '217' - VEHICLE_SETTINGS_TRANSFER = '221' - LAST_MILE_NAVIGATION = '227' - GOOGLE_STREET_VIEW = '229' - GOOGLE_SATELITE_VIEW = '230' - DYNAMIC_EV_ICE_RANGE = '232' - ECO_ROUTE_CALCULATION = '233' - CO_PILOT = '234' - DRIVING_JOURNEY_HISTORY = '235' - NISSAN_RENAULT_BROADCASTS = '241' - ONLINE_PARKING_INFO = '243' - ONLINE_RESTAURANT_INFO = '244' - ONLINE_SPEED_RESTRICTION_INFO = '245' - WEATHER_INFO = '246' - VEHICLE_ACCESS_TO_EMAIL = '248' - VEHICLE_ACCESS_TO_MUSIC = '249' - VEHICLE_ACCESS_TO_CONTACTS = '262' - APP_DOOR_LOCKING = '27' - GLONASS = '276' - ZONE_ALERT = '281' - SPEEDING_ALERT = '282' - SERVICE_SUBSCRIPTION = '284' - PAY_HOW_YOU_DRIVE = '286' - CHARGING_SPOT_INFO = '288' - FLEET_ASSET_INFORMATION = '29' - CHARGING_SPOT_INFO_COLLECTION = '292' - CHARGING_START = '299' - CHARGING_STOP = '303' - INTERIOR_TEMP_SETTINGS = '307' - CLIMATE_ON_OFF_NOTIFICATION = '311' - CHARGING_SPOT_SEARCH = '312' - PLUG_IN_REMINDER = '314' - CHARGING_STOP_NOTIFICATION = '317' - BATTERY_STATUS = '319' - BATTERY_HEATING_NOTIFICATION = '320' - VEHICLE_STATE_OF_CHARGE_PERCENT = '322' - BATTERY_STATE_OF_HEALTH_PERCENT = '323' - PAY_AS_YOU_DRIVE = '34' - DRIVING_ANALYSIS = '340' - CO2_GAS_SAVINGS = '341' - ELECTRICITY_FEE_CALCULATION = '342' - CHARGING_CONSUMPTION_HISTORY = '344' - BATTERY_MONITORING = '345' - BATTERY_DATA = '347' - APP_BASED_NAVIGATION = '35' - CHARGING_SPOT_UPDATES = '354' - RECHARGEABLE_AREA = '358' - NO_CHARGING_SPOT_INFO = '359' - EV_RANGE = '360' - CLIMATE_ON_OFF = '366' - ONLINE_FUEL_STATION_INFO = '367' - DESTINATION_SEND_TO_CAR = '37' - ECALL = '4' - GOOGLE_PLACES_SEARCH = '40' - PREMIUM_TRAFFIC = '43' - AUTO_COLLISION_NOTIFICATION_ACN = '6' - THEFT_BURGLAR_NOTIFICATION_VEHICLE = '7' - ECO_CHALLENGE = '721' - ECO_CHALLENGE_FLEET = '722' - MOBILE_INFORMATION = '74' - URL_PRESET_ON_VEHICLE = '77' - ASSISTED_DESTINATION_SETTING = '78' - CONCIERGE = '79' - PERSONAL_DATA_SYNC = '80' - THEFT_BURGLAR_NOTIFICATION_APP = '87' - STOLEN_VEHICLE_TRACKING_SVT = '9' - REMOTE_ENGINE_START = '96' - HORN_AND_LIGHTS = '97' - CURFEW_ALERT = '98' - TEMPERATURE = '2042' - VALET_PARKING_CALL = '401' - PANIC_CALL = '406' - - -class Language(enum.Enum): - """The service requires ISO 639-1 language codes to be mapped back - to ISO 3166-1 country codes. Of course. - """ - - # Bulgarian = Bulgaria - BG = 'BG' - # Czech = Czech Republic - CS = 'CZ' - # Danish = Denmark - DA = 'DK' - # German = Germany - DE = 'DE' - # Greek = Greece - EL = 'GR' - # Spanish = Spain - ES = 'ES' - # Finnish = Finland - FI = 'FI' - # French = France - FR = 'FR' - # Hebrew = Israel - HE = 'IL' - # Croatian = Croatia - HR = 'HR' - # Hungarian = Hungary - HU = 'HU' - # Italian = Italy - IT = 'IT' - # Formal Norwegian = Norway - NB = 'NO' - # Dutch = Netherlands - NL = 'NL' - # Polish = Poland - PL = 'PL' - # Portuguese = Portugal - PT = 'PT' - # Romanian = Romania - RO = 'RO' - # Russian = Russia - RU = 'RU' - # Slovakian = Slovakia - SK = 'SK' - # Slovenian = Slovenia - SI = 'SL' - # Serbian = Serbia - SR = 'RS' - # Swedish = Sweden - SV = 'SE' - # Ukranian = Ukraine - UK = 'UA' - # Default - EN = 'EN' - - -class Order(enum.Enum): - DESC = 'DESC' - ASC = 'ASC' - - -class NotificationCategoryKey(enum.Enum): - ASSISTANCE = 'assistance' - CHARGE_EV = 'chargeev' - CUSTOM = 'custom' - EV_BATTERY = 'EVBattery' - FOTA = 'fota' - GEO_FENCING = 'geofencing' - MAINTENANCE = 'maintenance' - NAVIGATION = 'navigation' - PRIVACY_MODE = 'privacymode' - REMOTE_CONTROL = 'remotecontrol' - RESET = 'RESET' - RGDC = 'rgdcmyze' - SAFETY_AND_SECURITY = 'Safety&Security' - SVT = 'SVT' +async def async_setup(hass, config): + """Set up the Kamereon component.""" + session = async_get_clientsession(hass) + entry_setup = [] + for config_entry in config[DOMAIN]: + entry_setup.append(_async_setup_entry(hass, config_entry, session)) -class NotificationStatus(enum.Enum): - READ = 'READ' - UNREAD = 'UNREAD' + return all(await asyncio.gather(*entry_setup)) +async def _async_setup_entry(hass, config, session): -class NotificationChannelType(enum.Enum): - PUSH_APP = 'PUSH_APP' - MAIL = 'MAIL' - OFF = '' - SMS = 'SMS' + mfr_session_class = MANUFACTURERS[config.get(CONF_MANUFACTURER)] + kamereon_session = mfr_session_class( + region=config.get(CONF_REGION) + #session=session, + ) + interval = config[CONF_SCAN_INTERVAL] -NotificationType = collections.namedtuple('NotificationType', ['key', 'title', 'message', 'category']) -NotificationCategory = collections.namedtuple('Category', ['key', 'title']) + data = hass.data[DATA_KEY] = {} + def discover_vehicle(vehicle): + """Load relevant platforms.""" -class NotificationTypeKey(enum.Enum): - ABS_ALERT = 'abs.alert' - AVAILABLE_CHARGING = 'available.charging' - BADGE_BATTERY_ALERT = 'badge.battery.alert' - BATTERY_BLOWING_REQUEST = 'battery.blowing.request' - BATTERY_CHARGE_AVAILABLE = 'battery.charge.available' - BATTERY_CHARGE_IN_PROGRESS = 'battery.charge.in.progress' - BATTERY_CHARGE_UNAVAILABLE = 'battery.charge.unavailable' - BATTERY_COOLING_CONDITIONNING_REQUEST = 'battery.cooling.conditionning.request' - BATTERY_ENDED_CHARGE = 'battery.ended.charge' - BATTERY_FLAP_OPENED = 'battery.flap.opened' - BATTERY_FULL_EXCEPTION = 'battery.full.exception' - BATTERY_HEATING_CONDITIONNING_REQUEST = 'battery.heating.conditionning.request' - BATTERY_HEATING_START = 'battery.heating.start' - BATTERY_HEATING_STOP = 'battery.heating.stop' - BATTERY_PREHEATING_START = 'battery.preheating.start' - BATTERY_PREHEATING_STOP = 'battery.preheating.stop' - BATTERY_SCHEDULE_ISSUE = 'battery.schedule.issue' - BATTERY_TEMPERATURE_ALERT = 'battery.temperature.alert' - BATTERY_WAITING_CURRENT_CHARGE = 'battery.waiting.current.charge' - BATTERY_WAITING_PLANNED_CHARGE = 'battery.waiting.planned.charge' - BRAKE_ALERT = 'brake.alert' - BRAKE_SYSTEM_MALFUNCTION = 'brake.system.malfunction' - BURGLAR_ALARM_LOST = 'burglar.alarm.lost' - BURGLAR_CAR_STOLEN = 'burglar.car.stolen' - BURGLAR_TOW_INFO = 'burglar.tow.info' - CHARGE_FAILURE = 'charge.failure' - CHARGE_NOT_PROHIBITED = 'charge.not.prohibited' - CHARGE_PROHIBITED = 'charge.prohibited' - CHARGING_STOP_GEN3 = 'charging.stop.gen3' - COOLANT_ALERT = 'coolant.alert' - CRASH_DETECTION_ALERT = 'crash.detection.alert' - CURFEW_INFRINGEMENT = 'curfew.infringement' - CURFEW_RECOVERY = 'curfew.recovery' - CUSTOM = 'custom' - DURING_INHIBITED_CHARGING = 'during.inhibited.charging' - ENGINE_WATER_TEMP_ALERT = 'engine.water.temp.alert' - EPS_ALERT = 'eps.alert' - FOTA_CAMPAIGN_AVAILABLE = 'fota.campaign.available' - FOTA_CAMPAIGN_STATUS_ACTIVATION_COMPLETED = 'fota.campaign.status.activation.completed' - FOTA_CAMPAIGN_STATUS_ACTIVATION_FAILED = 'fota.campaign.status.activation.failed' - FOTA_CAMPAIGN_STATUS_ACTIVATION_POSTPONED = 'fota.campaign.status.activation.postponed' - FOTA_CAMPAIGN_STATUS_ACTIVATION_PROGRESS = 'fota.campaign.status.activation.progress' - FOTA_CAMPAIGN_STATUS_ACTIVATION_SCHEDULED = 'fota.campaign.status.activation.scheduled' - FOTA_CAMPAIGN_STATUS_CANCELLED = 'fota.campaign.status.cancelled' - FOTA_CAMPAIGN_STATUS_CANCELLING = 'fota.campaign.status.cancelling' - FOTA_CAMPAIGN_STATUS_DOWNLOAD_COMPLETED = 'fota.campaign.status.download.completed' - FOTA_CAMPAIGN_STATUS_DOWNLOAD_PAUSED = 'fota.campaign.status.download.paused' - FOTA_CAMPAIGN_STATUS_DOWNLOAD_PROGRESS = 'fota.campaign.status.download.progress' - FOTA_CAMPAIGN_STATUS_INSTALLATION_COMPLETED = 'fota.campaign.status.installation.completed' - FOTA_CAMPAIGN_STATUS_INSTALLATION_PROGRESS = 'fota.campaign.status.installation.progress' - FUEL_ALERT = 'fuel.alert' - HVAC_AUTOSTART = 'hvac.autostart' - HVAC_AUTOSTOP = 'hvac.autostop' - HVAC_TECHNICAL_ISSUE = 'hvac.technical.issue' - HVAC_TRACTION_BATTERY_LOW = 'hvac.traction.battery.low' - HVAC_VEHICLE_IN_USE = 'hvac.vehicle.in.use' - HVAC_VEHICLE_NOT_CONNECTED_POWER = 'hvac.vehicle.not.connected.power' - LAST_MILE_DESTINATION_ADDRESS = 'last.mile.destination.address' - LOCK_STATUS_REMINDER = 'lock.status.reminder' - MAINTENANCE_DISTANCE_PREALERT = 'maintenance.distance.prealert' - MAINTENANCE_TIME_PREALERT = 'maintenance.time.prealert' - MIL_LAMP_AUTO_TEST = 'mil.lamp.auto.test' - MIL_LAMP_FLASH_REQUEST = 'mil.lamp.flash.request' - MIL_LAMP_OFF_REQUEST = 'mil.lamp.off.request' - MIL_LAMP_ON_REQUEST = 'mil.lamp.on.request' - NEXT_CHARGING_INHIBITED = 'next.charging.inhibited' - OIL_LEVEL_ALERT = 'oil.level.alert' - OIL_PRESSURE_ALERT = 'oil.pressure.alert' - OUT_OF_PARK_POSITION_CHARGE_INTERRUPTION = 'out.of.park.position.charge.interruption' - PLUG_CONNECTION_ISSUE = 'plug.connection.issue' - PLUG_CONNECTION_SUCCESS = 'plug.connection.success' - PLUG_UNLOCKING = 'plug.unlocking' - PREREMINDER_ALERT_DEFAULT = 'prereminder.alert.default' - PRIVACY_MODE_OFF = 'privacy.mode.off' - PRIVACY_MODE_ON = 'privacy.mode.on' - PROGRAMMED_CHARGE_INTERRUPTION = 'programmed.charge.interruption' - PROHIBITION_BATTERY_RENTAL = 'prohibition.battery.rental' - PWT_START_IMPOSSIBLE = 'pwt.start.impossible' - REMOTE_LEFT_TIME_CYCLE = 'remote.left.time.cycle' - REMOTE_START_CUSTOMER = 'remote.start.customer' - REMOTE_START_ENGINE = 'remote.start.engine' - REMOTE_START_NORMAL_ONLY = 'remote.start.normal.only' - REMOTE_START_PHONE_ERROR = 'remote.start.phone.error' - REMOTE_START_UNAVAILABLE = 'remote.start.unavailable' - REMOTE_START_WAIT_PRESOAK = 'remote.start.wait.presoak' - SERV_WARNING_ALERT = 'serv.warning.alert' - SPEED_INFRINGEMENT = 'speed.infringement' - SPEED_RECOVERY = 'speed.recovery' - START_DRIVING_CHARGE_INTERRUPTION = 'start.driving.charge.interruption' - START_IN_PROGRESS = 'start.in.progress' - STATUS_OIL_PRESSURE_SWITCH_CLOSED = 'status.oil.pressure.switch.closed' - STATUS_OIL_PRESSURE_SWITCH_OPEN = 'status.oil.pressure.switch.open' - STOP_WARNING_ALERT = 'stop.warning.alert' - UNPLUG_CHARGE = 'unplug.charge' - WAITING_PLANNED_CHARGE = 'waiting.planned.charge' - WHEEL_ALERT = 'wheel.alert' - ZONE_INFRINGEMENT = 'zone.infringement' - ZONE_RECOVERY = 'zone.recovery' + for component in ('binary_sensor', 'climate', 'device_tracker', 'lock', 'sensor', 'switch'): + hass.async_create_task( + discovery.async_load_platform( + hass, + component, + DOMAIN, + vehicle, + config, + ) + ) + + data[vehicle.vin] = vehicle + async def update(now): + """Update status from the online service.""" + try: -class NotificationRuleKey(enum.Enum): - ABS_ALERT = 'abs.alert' - AVAILABLE_CHARGING = 'available.charging' - BADGE_BATTERY_ALERT = 'badge.battery.alert' - BATTERY_BLOWING_REQUEST = 'battery.blowing.request' - BATTERY_CHARGE_AVAILABLE = 'battery.charge.available' - BATTERY_CHARGE_IN_PROGRESS = 'battery.charge.in.progress' - BATTERY_CHARGE_UNAVAILABLE = 'battery.charge.unavailable' - BATTERY_COOLING_CONDITIONNING_REQUEST = 'battery.cooling.conditionning.request' - BATTERY_ENDED_CHARGE = 'battery.ended.charge' - BATTERY_FLAP_OPENED = 'battery.flap.opened' - BATTERY_FULL_EXCEPTION = 'battery.full.exception' - BATTERY_HEATING_CONDITIONNING_REQUEST = 'battery.heating.conditionning.request' - BATTERY_HEATING_START = 'battery.heating.start' - BATTERY_HEATING_STOP = 'battery.heating.stop' - BATTERY_PREHEATING_START = 'battery.preheating.start' - BATTERY_PREHEATING_STOP = 'battery.preheating.stop' - BATTERY_SCHEDULE_ISSUE = 'battery.schedule.issue' - BATTERY_TEMPERATURE_ALERT = 'battery.temperature.alert' - BATTERY_WAITING_CURRENT_CHARGE = 'battery.waiting.current.charge' - BATTERY_WAITING_PLANNED_CHARGE = 'battery.waiting.planned.charge' - BRAKE_ALERT = 'brake.alert' - BRAKE_SYSTEM_MALFUNCTION = 'brake.system.malfunction' - BURGLAR_ALARM_LOST = 'burglar.alarm.lost' - BURGLAR_CAR_STOLEN = 'burglar.car.stolen' - BURGLAR_TOW_INFO = 'burglar.tow.info' - BURGLAR_TOW_SYSTEM_FAILURE = 'burglar.tow.system.failure' - CHARGE_FAILURE = 'charge.failure' - CHARGE_NOT_PROHIBITED = 'charge.not.prohibited' - CHARGE_PROHIBITED = 'charge.prohibited' - CHARGING_STOP_GEN3 = 'charging.stop.gen3' - COOLANT_ALERT = 'coolant.alert' - CRASH_DETECTION_ALERT = 'crash.detection.alert' - CURFEW_INFRINGEMENT = 'curfew.infringement' - CURFEW_RECOVERY = 'curfew.recovery' - CUSTOM = 'custom' - DURING_INHIBITED_CHARGING = 'during.inhibited.charging' - ENGINE_WATER_TEMP_ALERT = 'engine.water.temp.alert' - EPS_ALERT = 'eps.alert' - FOTA_CAMPAIGN_AVAILABLE = 'fota.campaign.available' - FOTA_CAMPAIGN_STATUS_ACTIVATION_COMPLETED = 'fota.campaign.status.activation.completed' - FOTA_CAMPAIGN_STATUS_ACTIVATION_FAILED = 'fota.campaign.status.activation.failed' - FOTA_CAMPAIGN_STATUS_ACTIVATION_POSTPONED = 'fota.campaign.status.activation.postponed' - FOTA_CAMPAIGN_STATUS_ACTIVATION_PROGRESS = 'fota.campaign.status.activation.progress' - FOTA_CAMPAIGN_STATUS_ACTIVATION_SCHEDULED = 'fota.campaign.status.activation.scheduled' - FOTA_CAMPAIGN_STATUS_CANCELLED = 'fota.campaign.status.cancelled' - FOTA_CAMPAIGN_STATUS_CANCELLING = 'fota.campaign.status.cancelling' - FOTA_CAMPAIGN_STATUS_DOWNLOAD_COMPLETED = 'fota.campaign.status.download.completed' - FOTA_CAMPAIGN_STATUS_DOWNLOAD_PAUSED = 'fota.campaign.status.download.paused' - FOTA_CAMPAIGN_STATUS_DOWNLOAD_PROGRESS = 'fota.campaign.status.download.progress' - FOTA_CAMPAIGN_STATUS_INSTALLATION_COMPLETED = 'fota.campaign.status.installation.completed' - FOTA_CAMPAIGN_STATUS_INSTALLATION_PROGRESS = 'fota.campaign.status.installation.progress' - FUEL_ALERT = 'fuel.alert' - HVAC_AUTOSTART = 'hvac.autostart' - HVAC_AUTOSTOP = 'hvac.autostop' - HVAC_TECHNICAL_ISSUE = 'hvac.technical.issue' - HVAC_TRACTION_BATTERY_LOW = 'hvac.traction.battery.low' - HVAC_VEHICLE_IN_USE = 'hvac.vehicle.in.use' - HVAC_VEHICLE_NOT_CONNECTED_POWER = 'hvac.vehicle.not.connected.power' - LAST_MILE_DESTINATION_ADDRESS = 'last.mile.destination.address' - LOCK_STATUS_REMINDER = 'lock.status.reminder' - MAINTENANCE_DISTANCE_PREALERT = 'maintenance.distance.prealert' - MAINTENANCE_TIME_PREALERT = 'maintenance.time.prealert' - MIL_LAMP_AUTO_TEST = 'mil.lamp.auto.test' - MIL_LAMP_FLASH_REQUEST = 'mil.lamp.flash.request' - MIL_LAMP_OFF_REQUEST = 'mil.lamp.off.request' - MIL_LAMP_ON_REQUEST = 'mil.lamp.on.request' - NEXT_CHARGING_INHIBITED = 'next.charging.inhibited' - OIL_LEVEL_ALERT = 'oil.level.alert' - OIL_PRESSURE_ALERT = 'oil.pressure.alert' - OUT_OF_PARK_POSITION_CHARGE_INTERRUPTION = 'out.of.park.position.charge.interruption' - PLUG_CONNECTION_ISSUE = 'plug.connection.issue' - PLUG_CONNECTION_SUCCESS = 'plug.connection.success' - PLUG_UNLOCKING = 'plug.unlocking' - PREREMINDER_ALERT_DEFAULT = 'prereminder.alert.default' - PRIVACY_MODE_OFF = 'privacy.mode.off' - PRIVACY_MODE_ON = 'privacy.mode.on' - PROGRAMMED_CHARGE_INTERRUPTION = 'programmed.charge.interruption' - PROHIBITION_BATTERY_RENTAL = 'prohibition.battery.rental' - PWT_START_IMPOSSIBLE = 'pwt.start.impossible' - REMOTE_LEFT_TIME_CYCLE = 'remote.left.time.cycle' - REMOTE_START_CUSTOMER = 'remote.start.customer' - REMOTE_START_ENGINE = 'remote.start.engine' - REMOTE_START_NORMAL_ONLY = 'remote.start.normal.only' - REMOTE_START_PHONE_ERROR = 'remote.start.phone.error' - REMOTE_START_UNAVAILABLE = 'remote.start.unavailable' - REMOTE_START_WAIT_PRESOAK = 'remote.start.wait.presoak' - RENAULT_RESET_FACTORY = 'renault.reset.factory' - RGDC_CHARGE_COMPLETE = 'rgdc.charge.complete' - RGDC_CHARGE_ERROR = 'rgdc.charge.error' - RGDC_CHARGE_ON = 'rgdc.charge.on' - RGDC_CHARGE_STATUS = 'rgdc.charge.status' - RGDC_LOW_BATTERY_ALERT = 'rgdc.low.battery.alert' - RGDC_LOW_BATTERY_REMINDER = 'rgdc.low.battery.reminder' - SERV_WARNING_ALERT = 'serv.warning.alert' - SPEED_INFRINGEMENT = 'speed.infringement' - SPEED_RECOVERY = 'speed.recovery' - SRP_PINCODE_ACKNOWLEDGEMENT = 'srp.pincode.acknowledgement' - SRP_PINCODE_DELETION = 'srp.pincode.deletion' - SRP_PINCODE_STATUS = 'srp.pincode.status' - SRP_SALT_REQUEST = 'srp.salt.request' - START_DRIVING_CHARGE_INTERRUPTION = 'start.driving.charge.interruption' - START_IN_PROGRESS = 'start.in.progress' - STATUS_OIL_PRESSURE_SWITCH_CLOSED = 'status.oil.pressure.switch.closed' - STATUS_OIL_PRESSURE_SWITCH_OPEN = 'status.oil.pressure.switch.open' - STOLEN_VEHICLE_TRACKING = 'stolen.vehicle.tracking' - STOLEN_VEHICLE_TRACKING_BLOCKING = 'stolen.vehicle.tracking.blocking' - STOP_WARNING_ALERT = 'stop.warning.alert' - SVT_SERVICE_ACTIVATION = 'svt.service.activation' - UNPLUG_CHARGE = 'unplug.charge' - WAITING_PLANNED_CHARGE = 'waiting.planned.charge' - WHEEL_ALERT = 'wheel.alert' - ZONE_INFRINGEMENT = 'zone.infringement' - ZONE_RECOVERY = 'zone.recovery' + for vehicle in kamereon_session.fetch_vehicles(): + vehicle.refresh() + if vehicle.vin not in data: + discover_vehicle(vehicle) + async_dispatcher_send(hass, SIGNAL_STATE_UPDATED) -class NotificationPriority(enum.Enum): + return True + finally: + async_track_point_in_utc_time(hass, update, utcnow() + interval) - NONE = 'null' - P0 = '0' - P1 = '1' - P2 = '2' - P3 = '3' + _LOGGER.info("Logging in to service") + kamereon_session.login( + username=config.get(CONF_USERNAME), + password=config.get(CONF_PASSWORD) + ) + return await update(utcnow()) -class NotificationRuleStatus(enum.Enum): - ACTIVATED = 'ACTIVATED' - ACTIVATION_IN_PROGRESS = 'STATUS_ACTIVATION_IN_PROGRESS' - DELETION_IN_PROGRESS = 'STATUS_DELETION_IN_PROGRESS' +class KamereonEntity(Entity): + """Base class for all Kamereon car entities.""" + def __init__(self, vehicle): + """Initialize the entity.""" + self.vehicle = vehicle -class Notification: + async def async_added_to_hass(self): + """Register update dispatcher.""" + async_dispatcher_connect( + self.hass, SIGNAL_STATE_UPDATED, self.async_schedule_update_ha_state + ) @property - def vehicle(self): - return _registry[VEHICLES][self.vin] + def icon(self): + """Return the icon.""" + return 'mdi:car' @property - def user_id(self): - return self.vehicle.user_id + def _entity_name(self): + return None @property - def session(self): - return self.vehicle.session - - def __init__(self, data, language, vin): - self.language = language - self.vin = vin - self.id = data['notificationId'] - self.title = data['messageTitle'] - self.subtitle = data['messageSubtitle'] - self.description = data['messageDescription'] - self.category = NotificationCategoryKey(data['categoryKey']) - self.rule_key = NotificationRuleKey(data['ruleKey']) - self.notification_key = NotificationTypeKey(data['notificationKey']) - self.priority = NotificationPriority(data['priority']) - self.state = NotificationStatus(data['status']) - t = datetime.datetime.strptime(data['timestamp'].split('.')[0], '%Y-%m-%dT%H:%M:%S') - if '.' in data['timestamp']: - fraction = data['timestamp'][20:-1] - t = t.replace(microsecond=int(fraction) * 10**(6-len(fraction))) - self.time = t - # List of {'name': 'N', 'type': 'T', 'value': 'V'} - self.data = data['data'] - # future use maybe? empty dict - self.metadata = data['metadata'] - - def __str__(self): - # title is kinda useless, subtitle has better content - return '{}: {}'.format(self.time, self.subtitle) - - def fetch_details(self, language: Language=None): - if language is None: - language = self.language - resp = self.session.oauth.get( - '{}v2/notifications/users/{}/vehicles/{}/notifications/{}'.format( - self.session.settings['notifications_base_url'], - self.user_id, self.vin, self.id - ), - params={'langCode': language.value} - ) - return resp - - -class KamereonSession: - - tenant = None - copy_realm = None - - def __init__(self, territory): - self.settings = settings_map[self.tenant][territory] - self._oauth = None - # ugly hack - os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1' - - def login(self, username, password): - # grab an auth ID to use as part of the username/password login request, - # then move to the regular OAuth2 process - session = requests.session() - auth_url = '{}json/realms/root/realms/{}/authenticate'.format( - self.settings['auth_base_url'], - self.settings['realm'], - ) - resp = session.post( - auth_url, - headers={ - 'Accept-Api-Version': API_VERSION, - 'X-Username': 'anonymous', - 'X-Password': 'anonymous', - 'Accept': 'application/json', - }) - next_body = resp.json() - - # insert the username, and password - for c in next_body['callbacks']: - input_type = c['type'] - if input_type == 'NameCallback': - c['input'][0]['value'] = username - elif input_type == 'PasswordCallback': - c['input'][0]['value'] = password - - resp = session.post( - auth_url, - headers={ - 'Accept-Api-Version': API_VERSION, - 'X-Username': 'anonymous', - 'X-Password': 'anonymous', - 'Accept': 'application/json', - 'Content-Type': 'application/json', - }, - data=json.dumps(next_body)) - - oauth_data = resp.json() - - oauth_authorize_url = '{}oauth2{}/authorize'.format( - self.settings['auth_base_url'], - oauth_data['realm'] - ) - nonce = generate_nonce() - resp = session.get( - oauth_authorize_url, - params={ - 'client_id': self.settings['client_id'], - 'redirect_uri': self.settings['redirect_uri'], - 'response_type': 'code', - 'scope': self.settings['scope'], - 'nonce': nonce, - }, - allow_redirects=False) - oauth_authorize_url = resp.headers['location'] - - oauth_token_url = '{}oauth2{}/access_token'.format( - self.settings['auth_base_url'], - oauth_data['realm'] - ) - self._oauth = OAuth2Session( - client_id=self.settings['client_id'], - redirect_uri=self.settings['redirect_uri'], - scope=self.settings['scope']) - self._oauth._client.nonce = nonce - self._oauth.fetch_token( - oauth_token_url, - authorization_response=oauth_authorize_url, - client_secret=self.settings['client_secret'], - include_client_id=True) + def _vehicle_name(self): + return self.vehicle.nickname or self.vehicle.model_name @property - def oauth(self): - if self._oauth is None: - raise RuntimeError('No access token set, you need to log in first.') - return self._oauth - - def get_user_id(self): - resp = self.oauth.get( - '{}v1/users/current'.format(self.settings['user_adapter_base_url']) - ) - user_id = resp.json()['userId'] - self.user_id = user_id - _registry[USERS][user_id] = self - return user_id - - def fetch_vehicles(self): - resp = self.oauth.get( - '{}v2/users/{}/cars'.format(self.settings['user_base_url'], self.user_id) - ) - vehicles = [] - for vehicle_data in resp.json()['data']: - vehicle = Vehicle(vehicle_data, self.user_id) - vehicles.append(vehicle) - _registry[VEHICLES][vehicle.vin] = vehicle - return vehicles - - -class NCISession(KamereonSession): - - tenant = 'nissan' - copy_realm = 'P_NCB' - - -class Vehicle: - - def __repr__(self): - return '<{} {}>'.format(self.__class__.__name__, self.vin) - - def __str__(self): - return self.nickname or self.vin + def name(self): + """Return full name of the entity.""" + if not self._entity_name: + return self._vehicle_name + return f"{self._vehicle_name} {self._entity_name}" @property - def session(self): - return _registry[USERS][self.user_id] - - def __init__(self, data, user_id): - self.user_id = user_id - self.vin = data['vin'].upper() - self.features = [ - Feature(u['name']) - for u in data.get('uids', []) - if u['enabled']] - self.can_generation = data.get('canGeneration') - self.color = data.get('color') - self.energy = data.get('energy') - self.vehicle_gateway = data.get('carGateway') - self.battery_code = data.get('batteryCode') - self.engine_type = data.get('engineType') - self.first_registration_date = data.get('firstRegistrationDate') - self.ice_or_ev = data.get('iceEvFlag') - self.model_name = data.get('modelName') - self.nickname = data.get('nickname') - self.phase = data.get('phase') - self.picture_url = data.get('pictureURL') - self.privacy_mode = data.get('privacyMode') - self.registration_number = data.get('registrationNumber') - self.battery_capacity = None - self.battery_level = None - self.battery_temperature = None - self.battery_bar_level = None - self.instantaneous_power = None - self.charging_speed = None - self.charge_time_required_to_full = { - ChargingSpeed.FAST: None, - ChargingSpeed.NORMAL: None, - ChargingSpeed.SLOW: None, - } - self.range_hvac_off = None - self.range_hvac_on = None - self.charging = ChargingStatus.NOT_CHARGING - self.plugged_in = PluggedStatus.NOT_PLUGGED - self.plugged_in_time = None - self.unplugged_time = None - self.battery_status_last_updated = None - self.location = None - self.location_last_updated = None - self.combustion_fuel_unit_cost = None - self.electricity_unit_cost = None - self.external_temperature = None - self.internal_temperature = None - self.hvac_status = None - self.next_hvac_start_date = None - self.next_target_temperature = None - self.hvac_status_last_updated = None - self.door_status = { - Door.FRONT_LEFT: None, - Door.FRONT_RIGHT: None, - Door.REAR_LEFT: None, - Door.REAR_RIGHT: None, - Door.HATCH: None - } - self.lock_status = None - self.lock_status_last_updated = None - self.eco_score = None - self.fuel_autonomy = None - self.fuel_consumption = None - self.fuel_economy = None - self.fuel_level = None - self.fuel_low_warning = None - self.fuel_quantity = None - self.mileage = None - self.total_mileage = None - - def refresh(self): - self.refresh_location() - self.refresh_battery_status() + def should_poll(self): + """Return the polling state.""" + return False - def fetch_all(self): - self.fetch_cockpit() - self.fetch_location() - self.fetch_battery_status() - self.fetch_energy_unit_cost() - self.fetch_hvac_status() - self.fetch_lock_status() - - def refresh_location(self): - resp = self.session.oauth.post( - '{}v1/cars/{}/actions/refresh-location'.format(self.session.settings['car_adapter_base_url'], self.vin), - data=json.dumps({ - 'data': {'type': 'RefreshLocation'} - }), - headers={'Content-Type': 'application/vnd.api+json'} - ) - return resp.json() - - def fetch_location(self): - resp = self.session.oauth.get( - '{}v1/cars/{}/location'.format(self.session.settings['car_adapter_base_url'], self.vin), - headers={'Content-Type': 'application/vnd.api+json'} - ) - location_data = resp.json()['data']['attributes'] - self.location = (location_data['gpsLatitude'], location_data['gpsLongitude']) - self.location_last_updated = datetime.datetime.fromisoformat(location_data['lastUpdateTime'].replace('Z','+00:00')) - - def refresh_lock_status(self): - resp = self.session.oauth.post( - '{}v1/cars/{}/actions/refresh-lock-status'.format(self.session.settings['car_adapter_base_url'], self.vin), - data=json.dumps({ - 'data': {'type': 'RefreshLockStatus'} - }), - headers={'Content-Type': 'application/vnd.api+json'} - ) - return resp.json() - - def fetch_lock_status(self): - if Feature.LOCK_STATUS_CHECK not in self.features: - return - resp = self.session.oauth.get( - '{}v1/cars/{}/lock-status'.format(self.session.settings['car_adapter_base_url'], self.vin), - headers={'Content-Type': 'application/vnd.api+json'} - ) - lock_data = resp.json()['data']['attributes'] - self.door_status[Door.FRONT_LEFT] = LockStatus(lock_data['doorStatusFrontLeft']) - self.door_status[Door.FRONT_RIGHT] = LockStatus(lock_data['doorStatusFrontRight']) - self.door_status[Door.REAR_LEFT] = LockStatus(lock_data['doorStatusRearLeft']) - self.door_status[Door.REAR_RIGHT] = LockStatus(lock_data['doorStatusRearRight']) - self.door_status[Door.HATCH] = LockStatus(lock_data['hatchStatus']) - self.lock_status = LockStatus(lock_data['lockStatus']) - self.lock_status_last_updated = datetime.datetime.fromisoformat(location_data['lastUpdateTime'].replace('Z','+00:00')) - - def refresh_hvac_status(self): - resp = self.session.oauth.post( - '{}v1/cars/{}/actions/refresh-hvac-status'.format(self.session.settings['car_adapter_base_url'], self.vin), - data=json.dumps({ - 'data': {'type': 'RefreshHvacStatus'} - }), - headers={'Content-Type': 'application/vnd.api+json'} - ) - return resp.json() - - def initiate_srp(self): - (salt, verifier) = SRP.enroll(self.user_id, self.vin) - resp = self.session.oauth.post( - '{}v1/cars/{}/actions/srp-initiates'.format(self.session.settings['car_adapter_base_url'], self.vin), - data=json.dumps({ - "data": { - "type": "SrpInitiates", - "attributes": { - "s": salt, - "i": self.user_id, - "v": verifier - } - } - }), - headers={'Content-Type': 'application/vnd.api+json'} - ) - return resp.json() - - def validate_srp(self): - a = SRP.generate_a() - resp = self.session.oauth.post( - '{}v1/cars/{}/actions/srp-sets'.format(self.session.settings['car_adapter_base_url'], self.vin), - data=json.dumps({ - "data": { - "type": "SrpSets", - "attributes": { - "i": self.user_id, - "a": a - } - } - }), - headers={'Content-Type': 'application/vnd.api+json'} - ) - return resp.json() - - """ - Other vehicle controls to implement / investigate: - DataReset - DeleteCurfewRestrictions - CreateCurfewRestrictions - CreateSpeedRestrictions - SrpInitiates - DeleteAreaRestrictions - SrpDelete - SrpSets - OpenClose - EngineStart - LockUnlock - CreateAreaRestrictions - DeleteSpeedRestrictions - """ - - def control_charging(self, action: str, srp: str=None): - assert action in ('stop', 'start') - if action == 'start' and Feature.CHARGING_START not in self.features: - return - if action == 'stop' and Feature.CHARGING_STOP not in self.features: - return - attributes = { - 'action': action, - } - if srp is not None: - attributes['srp'] = srp - resp = self.session.oauth.post( - '{}v1/cars/{}/actions/charging-start'.format(self.session.settings['car_adapter_base_url'], self.vin), - data=json.dumps({ - 'data': { - 'type': 'ChargingStart', - 'attributes': attributes - } - }), - headers={'Content-Type': 'application/vnd.api+json'} - ) - return resp.json() - - def control_horn_lights(self, action: str, target: str, duration: int=5, srp: str=None): - if Feature.HORN_AND_LIGHTS not in self.features: - return - assert target in ('horn_lights', 'lights', 'horn') - assert action in ('stop', 'start', 'double_start') - attributes = { - 'action': action, - 'duration': duration, - 'target': target, - } - if srp is not None: - attributes['srp'] = srp - resp = self.session.oauth.post( - '{}v1/cars/{}/actions/horn-lights'.format(self.session.settings['car_adapter_base_url'], self.vin), - data=json.dumps({ - 'data': { - 'type': 'HornLights', - 'attributes': attributes - } - }), - headers={'Content-Type': 'application/vnd.api+json'} - ) - return resp.json() - - def set_hvac_status(self, action: HVACAction, target_temperature: int=21, start: datetime.datetime=None, srp: str=None): - if Feature.CLIMATE_ON_OFF not in self.features: - return - - if target_temperature < 16 or target_temperature > 26: - raise ValueError('Temperature must be between 16 & 26 degrees') - - attributes = { - 'action': action.value - } - if action == HVACAction.START: - attributes['targetTemperature'] = target_temperature - if start is not None: - attributes['startDateTime'] = start.isoformat(timespec='seconds') - if srp is not None: - attributes['srp'] = srp - - resp = self.session.oauth.post( - '{}v1/cars/{}/actions/hvac-start'.format(self.session.settings['car_adapter_base_url'], self.vin), - data=json.dumps({ - 'data': { - 'type': 'HvacStart', - 'attributes': attributes - } - }), - headers={'Content-Type': 'application/vnd.api+json'} - ) - return resp.json() - - def lock_unlock(self, srp: str, action: str, group: LockableDoorGroup=None): - if Feature.APP_DOOR_LOCKING not in self.features: - return - assert action in ('lock', 'unlock') - if group is None: - group = LockableDoorGroup.DOORS_AND_HATCH - resp = self.session.oauth.post( - '{}v1/cars/{}/actions/lock-unlock"'.format(self.session.settings['car_adapter_base_url'], self.vin), - data=json.dumps({ - 'data': { - 'type': 'LockUnlock', - 'attributes': { - 'lock': action, - 'doorType': group.value, - 'srp': srp - } - } - }), - headers={'Content-Type': 'application/vnd.api+json'} - ) - return resp.json() - - def lock(self, srp: str, group: LockableDoorGroup=None): - return lock_unlock(srp, 'lock', group) - - def unlock(self, srp: str, group: LockableDoorGroup=None): - return lock_unlock(srp, 'unlock', group) - - def fetch_hvac_status(self): - resp = self.session.oauth.get( - '{}v1/cars/{}/hvac-status'.format(self.session.settings['car_adapter_base_url'], self.vin), - headers={'Content-Type': 'application/vnd.api+json'} - ) - hvac_data = resp.json()['data']['attributes'] - self.external_temperature = hvac_data.get('externalTemperature') - self.internal_temperature = hvac_data.get('internalTemperature') - if 'hvacStatus' in hvac_data: - self.hvac_status = HVACStatus(hvac_data['hvacStatus']) - if 'nextHvacStartDate' in hvac_data: - self.next_hvac_start_date = datetime.datetime.fromisoformat(hvac_data['nextHvacStartDate'].replace('Z','+00:00')) - self.next_target_temperature = hvac_data.get('nextTargetTemperature') - self.hvac_status_last_updated = datetime.datetime.fromisoformat(hvac_data['lastUpdateTime'].replace('Z','+00:00')) - - def refresh_battery_status(self): - resp = self.session.oauth.post( - '{}v1/cars/{}/actions/refresh-battery-status'.format(self.session.settings['car_adapter_base_url'], self.vin), - data=json.dumps({ - 'data': {'type': 'RefreshBatteryStatus'} - }), - headers={'Content-Type': 'application/vnd.api+json'} - ) - return resp.json() - - def fetch_battery_status(self): - if Feature.BATTERY_STATUS not in self.features: - return - resp = self.session.oauth.get( - '{}v1/cars/{}/battery-status'.format(self.session.settings['car_adapter_base_url'], self.vin), - headers={'Content-Type': 'application/vnd.api+json'} - ) - battery_data = resp.json()['data']['attributes'] - self.battery_capacity = battery_data['batteryCapacity'] # kWh - self.battery_level = battery_data['batteryLevel'] # % - self.battery_temperature = battery_data.get('batteryTemperature') # Fahrenheit? - # same meaning as battery level, different scale. 240 = 100% - self.battery_bar_level = battery_data['batteryBarLevel'] - self.instantaneous_power = battery_data.get('instantaneousPower') # kW - self.charging_speed = ChargingSpeed(battery_data.get('chargePower')) - self.charge_time_required_to_full = { - ChargingSpeed.FAST: battery_data['timeRequiredToFullFast'], - ChargingSpeed.NORMAL: battery_data['timeRequiredToFullNormal'], - ChargingSpeed.SLOW: battery_data['timeRequiredToFullSlow'], - } - self.range_hvac_off = battery_data['rangeHvacOff'] - self.range_hvac_on = battery_data['rangeHvacOn'] - self.charging = ChargingStatus(battery_data['chargeStatus']) - self.plugged_in = PluggedStatus(battery_data['plugStatus']) - if 'vehiclePlugTimestamp' in battery_data: - self.plugged_in_time = datetime.datetime.fromisoformat(battery_data['vehiclePlugTimestamp'].replace('Z','+00:00')) - if 'vehicleUnplugTimestamp' in battery_data: - self.unplugged_time = datetime.datetime.fromisoformat(battery_data['vehicleUnplugTimestamp'].replace('Z','+00:00')) - self.battery_status_last_updated = datetime.datetime.fromisoformat(battery_data['lastUpdateTime'].replace('Z','+00:00')) - - def fetch_energy_unit_cost(self): - resp = self.session.oauth.get( - '{}v1/cars/{}/energy-unit-cost'.format(self.session.settings['car_adapter_base_url'], self.vin) - ) - energy_cost_data = resp.json()['data']['attributes'] - self.electricity_unit_cost = energy_cost_data.get('electricityUnitCost') - self.combustion_fuel_unit_cost = energy_cost_data.get('fuelUnitCost') - return resp.json() - - def set_energy_unit_cost(self, cost): - resp = self.session.oauth.post( - '{}v1/cars/{}/energy-unit-cost'.format(self.session.settings['car_adapter_base_url'], self.vin), - data=json.dumps({ - 'data': { - 'type': {} - } - }) - ) - - def fetch_trip_histories(self, period: Period=None, start: datetime.date=None, end: datetime.date=None): - if period is None: - period = Period.DAILY - if start is None: - start = datetime.date.today() - if end is None: - end = start - resp = self.session.oauth.get( - '{}v1/cars/{}/trip-history'.format(self.session.settings['car_adapter_base_url'], self.vin), - params={ - 'type': period.value, - 'start': start.isoformat(), - 'end': end.isoformat() - } - ) - return [TripSummary(s, self.vin) for s in resp.json()['data']['attributes']['summaries']] - - def fetch_notifications( - self, - language: Language=None, - category_key: NotificationCategoryKey=None, - status: NotificationStatus=None, - start: datetime.datetime=None, - end: datetime.datetime=None, - # offset - from_: int=1, - # limit - to: int=20, - order: Order=None - ): - - if language is None: - language = Language.EN - params = { - 'realm': self.session.copy_realm, - 'langCode': language.value, - } - if category is not None: - params['categoryKey'] = category.value - if status is not None: - params['status'] = status.value - if start is not None: - params['start'] = start.isoformat(timespec='seconds') - if start.tzinfo is None: - # Assume UTC - params['start'] += 'Z' - if end is not None: - params['end'] = start.isoformat(timespec='seconds') - if end.tzinfo is None: - # Assume UTC - params['end'] += 'Z' - resp = self.session.oauth.get( - '{}v2/notifications/users/{}/vehicles/{}'.format(self.session.settings['notifications_base_url'], self.user_id, self.vin), - params=params - ) - return [Notification(m, language, self.vin) for m in resp.json()['data']['attributes']['messages']] - - def mark_notifications(self, messages: List[Notification]): - """Take a list of notifications and set their status remotely - to the one held locally (read / unread).""" - - resp = self.session.oauth.post( - '{}v2/notifications/users/{}/vehicles/{}'.format(self.session.settings['notifications_base_url'], self.user_id, self.vin), - data=json.dumps([ - {'notificationId': m.id, 'status': m.status.value} - for m in messages - ]) - ) - return resp.json() + @property + def assumed_state(self): + """Return true if unable to access real state of entity.""" + return True - def fetch_notification_settings(self, language: Language=None): - if language is None: - language = Language.EN - params = { - 'langCode': language.value, + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + return { + 'manufacturer': self.vehicle.session.tenant, + 'vin': self.vehicle.vin, + 'name': self.vehicle.nickname or self.vehicle.model_name, + 'model': self.vehicle.model_name, + 'color': self.vehicle.color, + 'registration_number': self.vehicle.registration_number, + 'device_picture': self.vehicle.picture_url, + 'first_registration_date': self.vehicle.first_registration_date, } - resp = self.session.oauth.get( - '{}v1/rules/settings/users/{}/vehicles/{}'.format(self.session.settings['notifications_base_url'], self.user_id, self.vin), - params=params - ) - return [ - NotificationRule(r, language, self.vin) - for r in resp.json()['settings'] - ] - - def update_notification_settings(self): - # TODO - pass - - def fetch_cockpit(self): - resp = self.session.oauth.get( - "{}v1/cars/{}/cockpit".format(self.session.settings['car_adapter_base_url'], self.vin) - ) - import pdb; pdb.set_trace() - cockpit_data = resp.json()['data']['attributes'] - self.eco_score = cockpit_data.get('ecoScore') - self.fuel_autonomy = cockpit_data.get('fuelAutonomy') - self.fuel_consumption = cockpit_data.get('fuelConsumption') - self.fuel_economy = cockpit_data.get('fuelEconomy') - self.fuel_level = cockpit_data.get('fuelLevel') - if 'fuelLowWarning' in cockpit_data: - self.fuel_low_warning = bool(cockpit_data['fuelLowWarning']) - self.fuel_quantity = cockpit_data.get('fuelQuantity') # litres - self.mileage = cockpit_data.get('mileage') - self.total_mileage = cockpit_data['totalMileage'] - - -class TripSummary: - - def __init__(self, data, vin): - self.vin = vin - self.trip_count = data['tripsNumber'] - self.total_distance = data['distance'] # km - self.total_duration = data['duration'] # minutes - self.first_trip_start = datetime.datetime.fromisoformat(data['firstTripStart'].replace('Z','+00:00')) - self.last_trip_end = datetime.datetime.fromisoformat(data['lastTripEnd'].replace('Z','+00:00')) - self.consumed_fuel = data['consumedFuel'] # litres - self.consumed_electricity = data['consumedElectricity'] # W - self.saved_electricity = data['savedElectricity'] # W - if 'day' in data: - self.start = self.end = datetime.date(int(data['day'][:4]), int(data['day'][4:6]), int(data['day'][6:])) - elif 'month' in data: - start_year = int(data['month'][:4]) - start_month = int(data['month'][4:]) - end_month = start_month + 1 - end_year = start_year - if end_month > 12: - end_month = 1 - end_year = end_year + 1 - self.start = datetime.date(start_year, start_month, 1) - self.end = datetime.date(end_year, end_month) - datetime.timedelta(days=1) - elif 'year' in data: - self.start = datetime.date(int(data['year']), 1, 1) - self.end = datetime.date(int(data['year']) + 1, 1, 1) - datetime.timedelta(days=1) - def __str__(self): - return '{} trips covering {} kilometres over {} minutes using {} litres fuel and {} kilowatt-hours electricity'.format( - self.trip_count, self.total_distance, self.total_duration, self.consumed_fule, self.consumed_electricity - ) - - -class NotificationRule: - - def __init__(self, data, language, vin): - self.vin = vin - self.language = language - self.key = NotificationRuleKey(data['ruleKey']) - self.title = data['ruleTitle'] - self.description = data['ruleDescription'] - self.priority = NotificationPriority(data['priority']) - self.status = NotificationRuleStatus(data['status']) - self.channels = [ - NotificationChannelType(c['channelType']) - for c in data['channels'] - ] - self.category = NotificationCategory(NotificationCategoryKey(data['categoryKey']), data['categoryTitle']) - self.notification_type = None - if 'notificationKey' in data: - self.notification_type = NotificationType( - NotificationTypeKey(data['notificationKey']), - data['notificationTitle'], - data['notificationMessage'], - self.category, - ) - - def __str__(self): - return '{}: {} ({})'.format( - self.title or self.key, - self.status.value, - ', '.join(c.value for c in self.channels) - ) - - -class SRP: - - @classmethod - def enroll(cls, user_id, vin): - salt, verifier = '0'*20, 'ABCDEFGH'*64 - # salt = 20 hex chars, verifier = 512 hex chars - return (salt, verifier) - - @classmethod - def generate_a(cls): - # 512 hex chars - return '' - - @classmethod - def generate_proof(cls, salt, b, user_id, confirm_code, order): - """Required for remote lock / unlock.""" - # order = '/' - # where PERMISSIONS is one of: - # * "BCI/Block" - # * "BCI/Unblock" - # * "RC/Delayed" - # * "RC/Start" - # * "RC/Stop" - # * "RES/DoubleStart" - # * "RES/Start" - # * "RES/Stop" - # * "RHL/Start/HornOnly" - # * "RHL/Start/HornLight" - # * "RHL/Start/LightOnly" - # * "RHL/Stop" - # * "RLU/Lock" - # * "RLU/Unlock" - # * "RPC_ICE/Start" - # * "RPC_ICE/Stop" - # * "RPU_CCS/Disable" - # * "RPU_CCS/Enable" - # * "RPU_SVTB/Disable" - # * "RPU_SVTB/Enable" - pass - - - -if __name__ == '__main__': - import pprint - import sys - - region = sys.argv[1] - username = sys.argv[2] - password = sys.argv[3] - if len(sys.argv) > 4: - srp = sys.argv[4] - - nci = NCISession(region) - nci.login(username, password) - user_id = nci.get_user_id() - vehicles = nci.fetch_vehicles() - for vehicle in vehicles: - vehicle.fetch_cockpit() - vehicle.fetch_all() - pprint.pprint(vars(vehicle)) - print('today trip summary') - trip_summaries = vehicle.fetch_trip_histories() - print('\n'.join(map(str, trip_summaries))) - print('last notifications') - notifications = vehicle.fetch_notifications() - print('\n'.join(map(str, notifications))) - notifications[0].fetch_details() - + @property + def device_info(self): + return { + 'identifiers': (DOMAIN, self.vehicle.session.tenant, self.vehicle.vin), + 'manufacturer': self.vehicle.session.tenant, + 'vin': self.vehicle.vin, + } \ No newline at end of file diff --git a/kamereon/binary_sensor.py b/kamereon/binary_sensor.py new file mode 100644 index 0000000..ba9253f --- /dev/null +++ b/kamereon/binary_sensor.py @@ -0,0 +1,149 @@ +"""Support for Kamereon cars.""" +import logging + +from homeassistant.components.binary_sensor import DEVICE_CLASSES, BinarySensorDevice +from homeassistant.const import STATE_UNKNOWN + +from . import KamereonEntity +from .kamereon import ChargingStatus, Door, LockStatus, PluggedStatus + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, vehicle=None): + """Set up the Kamereon sensors.""" + if vehicle is None: + return + async_add_entities([ + ChargingStatusEntity(vehicle), + PluggedStatusEntity(vehicle), + FuelLowWarningEntity(vehicle), + DoorEntity(vehicle, Door.FRONT_LEFT), + DoorEntity(vehicle, Door.FRONT_RIGHT), + DoorEntity(vehicle, Door.REAR_LEFT), + DoorEntity(vehicle, Door.REAR_RIGHT), + DoorEntity(vehicle, Door.HATCH), + ]) + + +class ChargingStatusEntity(KamereonEntity, BinarySensorDevice): + """Representation of charging status.""" + + @property + def _entity_name(self): + return 'charging' + + @property + def icon(self): + """Return the icon.""" + return 'mdi:{}'.format('battery-charging' if self.is_on else 'battery-off') + + @property + def is_on(self): + """Return True if the binary sensor is on.""" + if self.vehicle.charging is None: + return STATE_UNKNOWN + return self.vehicle.charging is ChargingStatus.CHARGING + + @property + def device_class(self): + """Return the class of this sensor.""" + return 'power' + + @property + def device_state_attributes(self): + a = KamereonEntity.device_state_attributes.fget(self) + a.update({ + 'charging_speed': self.vehicle.charging_speed.value, + 'last_updated': self.vehicle.battery_status_last_updated, + }) + return a + + +class PluggedStatusEntity(KamereonEntity, BinarySensorDevice): + """Representation of plugged status.""" + + @property + def _entity_name(self): + return 'plugged_in' + + @property + def icon(self): + """Return the icon.""" + return 'mdi:{}'.format('power-plug' if self.is_on else 'power-plug-off') + + @property + def is_on(self): + """Return True if the binary sensor is on.""" + if self.vehicle.plugged_in is None: + return STATE_UNKNOWN + return self.vehicle.plugged_in is PluggedStatus.PLUGGED + + @property + def device_class(self): + """Return the class of this sensor.""" + return 'plug' + + @property + def device_state_attributes(self): + a = KamereonEntity.device_state_attributes.fget(self) + a.update({ + 'plugged_in_time': self.vehicle.plugged_in_time, + 'unplugged_time': self.vehicle.unplugged_time, + 'last_updated': self.vehicle.battery_status_last_updated, + }) + return a + + +class FuelLowWarningEntity(KamereonEntity, BinarySensorDevice): + """Representation of fuel low warning status.""" + + @property + def _entity_name(self): + return 'fuel_low' + + @property + def icon(self): + """Return the icon.""" + return 'mdi:fuel' + + @property + def is_on(self): + """Return True if the binary sensor is on.""" + if self.vehicle.fuel_low_warning is None: + return STATE_UNKNOWN + return self.vehicle.fuel_low_warning + + @property + def device_class(self): + """Return the class of this sensor.""" + return 'safety' + + +class DoorEntity(KamereonEntity, BinarySensorDevice): + """Representation of a door (or hatch).""" + + def __init__(self, vehicle, door): + KamereonEntity.__init__(self, vehicle) + self.door = door + + @property + def icon(self): + """Return the icon.""" + return 'mdi:car-door' + + @property + def _entity_name(self): + return '{}_door'.format(self.door.value) + + @property + def is_on(self): + """Return True if the binary sensor is open.""" + if self.door not in self.vehicle.door_status or self.vehicle.door_status[self.door] is None: + return STATE_UNKNOWN + return self.vehicle.door_status[self.door] == LockStatus.OPEN + + @property + def device_class(self): + """Return the class of this sensor.""" + return 'door' \ No newline at end of file diff --git a/kamereon/climate.py b/kamereon/climate.py new file mode 100644 index 0000000..da08213 --- /dev/null +++ b/kamereon/climate.py @@ -0,0 +1,91 @@ +""" +Support for Kamereon Platform +""" +import logging + +from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate.const import (HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF, SUPPORT_TARGET_TEMPERATURE) +from homeassistant.const import ATTR_TEMPERATURE, STATE_UNKNOWN, TEMP_CELSIUS + +SUPPORT_HVAC = [HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF] + +from . import KamereonEntity +from .kamereon import Feature, HVACAction, HVACStatus + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, vehicle=None): + """ Setup the volkswagen climate.""" + if vehicle is None: + return + if Feature.TEMPERATURE in vehicle.features or Feature.INTERIOR_TEMP_SETTINGS in vehicle.features: + add_devices([KamereonClimate(vehicle)]) + + +class KamereonClimate(KamereonEntity, ClimateDevice): + """Representation of a Kamereon Climate.""" + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_TARGET_TEMPERATURE + + @property + def hvac_mode(self): + """Return hvac operation ie. heat, cool mode. + Need to be one of HVAC_MODE_*. + """ + if self.vehicle.hvac_status is None: + return STATE_UNKNOWN + elif self.vehicle.hvac_status is HVACStatus.ON: + return HVAC_MODE_HEAT_COOL + return HVAC_MODE_OFF + + + @property + def hvac_modes(self): + """Return the list of available hvac operation modes. + Need to be a subset of HVAC_MODES. + """ + return SUPPORT_HVAC + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + if self.vehicle.internal_temperature: + return float(self.vehicle.internal_temperature) + return None + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + if self.vehicle.next_target_temperature is not None: + return float(self.vehicle.next_target_temperature) + return None + + def set_temperature(self, **kwargs): + """Set new target temperatures.""" + if Feature.TEMPERATURE not in self.vehicle.features: + raise NotImplementedError() + + _LOGGER.debug("Setting temperature for: %s", self.instrument.attr) + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature: + self.vehicle.set_hvac_status(HVACAction.START, temperature) + + def set_hvac_mode(self, hvac_mode): + """Set new target hvac mode.""" + if Feature.CLIMATE_ON_OFF not in self.vehicle.features: + raise NotImplementedError() + + _LOGGER.debug("Setting mode for: %s", self.instrument.attr) + if hvac_mode == HVAC_MODE_OFF: + self.vehicle.set_hvac_status(HVACAction.STOP) + elif hvac_mode == HVAC_MODE_HEAT_COOL: + self.vehicle.set_hvac_status(HVACAction.START) \ No newline at end of file diff --git a/kamereon/device_tracker.py b/kamereon/device_tracker.py new file mode 100644 index 0000000..de66b54 --- /dev/null +++ b/kamereon/device_tracker.py @@ -0,0 +1,39 @@ +"""Support for tracking a Kamereon car.""" +import logging + +from homeassistant.components.device_tracker import SOURCE_TYPE_GPS +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.util import slugify + +from . import SIGNAL_STATE_UPDATED + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_scanner(hass, config, async_see, vehicle=None): + """Set up the Kamereon tracker.""" + if vehicle is None: + return + + async def see_vehicle(): + """Handle the reporting of the vehicle position.""" + host_name = slugify(vehicle.nickname or vehicle.model_name) + await async_see( + dev_id=host_name, + host_name=host_name, + source_type=SOURCE_TYPE_GPS, + gps=vehicle.location, + attributes={ + 'last_updated': vehicle.location_last_updated.isoformat(), + 'manufacturer': vehicle.session.tenant, + 'vin': vehicle.vin, + 'name': vehicle.nickname or vehicle.model_name, + 'model': vehicle.model_name, + 'registration_number': vehicle.registration_number, + }, + icon="mdi:car", + ) + + async_dispatcher_connect(hass, SIGNAL_STATE_UPDATED, see_vehicle) + + return True \ No newline at end of file diff --git a/kamereon/kamereon.py b/kamereon/kamereon.py new file mode 100644 index 0000000..336d0b3 --- /dev/null +++ b/kamereon/kamereon.py @@ -0,0 +1,1401 @@ +# Copyright 2020 Richard Mitchell + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import collections +import datetime +import enum +import json +import os +from typing import List +from urllib.parse import urljoin, urlparse, parse_qs + +from oauthlib.common import generate_nonce +import pytz +import requests +from requests_oauthlib import OAuth2Session + + +API_VERSION = 'protocol=1.0,resource=2.1' +SRP_KEY = 'D5AF0E14718E662D12DBB4FE42304DF5A8E48359E22261138B40AA16CC85C76A11B43200A1EECB3C9546A262D1FBD51ACE6FCDE558C00665BBF93FF86B9F8F76AA7A53CA74F5B4DFF9A4B847295E7D82450A2078B5A28814A7A07F8BBDD34F8EEB42B0E70499087A242AA2C5BA9513C8F9D35A81B33A121EEF0A71F3F9071CCD' + + +settings_map = { + 'nissan': { + 'EU': { + 'client_id': 'a-ncb-prod-android', + 'client_secret': '3LBs0yOx2XO-3m4mMRW27rKeJzskhfWF0A8KUtnim8i/qYQPl8ZItp3IaqJXaYj_', + 'scope': 'openid profile vehicles', + 'auth_base_url': 'https://prod.eu.auth.kamereon.org/kauth/', + 'realm': 'a-ncb-prod', + 'redirect_uri': 'org.kamereon.service.nci:/oauth2redirect', + 'car_adapter_base_url': 'https://alliance-platform-caradapter-prod.apps.eu.kamereon.io/car-adapter/', + 'notifications_base_url': 'https://alliance-platform-notifications-prod.apps.eu.kamereon.io/notifications/', + 'user_adapter_base_url': 'https://alliance-platform-usersadapter-prod.apps.eu.kamereon.io/user-adapter/', + 'user_base_url': 'https://nci-bff-web-prod.apps.eu.kamereon.io/bff-web/', + }, + 'JP': {}, + 'RU': {}, + }, + 'mitsubishi': {}, + 'renault': {}, +} + + +USERS = 'users' +VEHICLES = 'vehicles' +CATEGORIES = 'categories' +NOTIFICATION_RULES = 'notification_rules' +NOTIFICATION_TYPES = 'notification_types' +NOTIFICATION_CATEGORIES = 'notification_categories' +_registry = { + USERS: {}, + VEHICLES: {}, + CATEGORIES: {}, + NOTIFICATION_RULES: {}, + NOTIFICATION_TYPES: {}, + NOTIFICATION_CATEGORIES: {}, +} + + +class HVACAction(enum.Enum): + # Start or schedule start + START = 'start' + # Stop active HVAC + STOP = 'stop' + # Cancel scheduled HVAC + CANCEL = 'cancel' + + +class HVACStatus(enum.Enum): + OFF = 'off' + ON = 'on' + + +class LockStatus(enum.Enum): + CLOSED = 'closed' + LOCKED = 'locked' + OPEN = 'open' + UNLOCKED = 'unlocked' + + +class Door(enum.Enum): + HATCH = 'hatch' + FRONT_LEFT = 'front_left' + FRONT_RIGHT = 'front_right' + REAR_LEFT = 'rear_left' + REAR_RIGHT = 'rear_right' + + +class LockableDoorGroup(enum.Enum): + DOORS_AND_HATCH = 'doors_hatch' + DRIVERS_DOOR = 'driver_s_door' + HATCH = 'hatch' + + +class ChargingSpeed(enum.Enum): + NONE = None + SLOW = 1 + NORMAL = 2 + FAST = 3 + + +class ChargingStatus(enum.Enum): + ERROR = -1 + NOT_CHARGING = 0 + CHARGING = 1 + + +class PluggedStatus(enum.Enum): + ERROR = -1 + NOT_PLUGGED = 0 + PLUGGED = 1 + + +class Period(enum.Enum): + DAILY = 0 + MONTHLY = 1 + YEARLY = 2 + + +class Feature(enum.Enum): + BREAKDOWN_ASSISTANCE_CALL = '1' + SVT_WITH_VEHICLE_BLOCKAGE = '10' + MAINTENANCE_ALERT = '101' + VEHICLE_SOFTWARE_UPDATES = '107' + MY_CAR_FINDER = '12' + MIL_ON_NOTIFICATION = '15' + VEHICLE_HEALTH_REPORT = '18' + ADVANCED_CAN = '201' + VEHICLE_STATUS_CHECK = '202' + LOCK_STATUS_CHECK = '2021' + NAVIGATION_FACTORY_RESET = '208' + MESSAGES_TO_THE_VEHICLE = '21' + VEHICLE_DATA = '2121' + VEHICLE_DATA_2 = '2122' + VEHICLE_WIFI = '213' + ADVANCED_VEHICLE_DIAGNOSTICS = '215' + NAVIGATION_MAP_UPDATES = '217' + VEHICLE_SETTINGS_TRANSFER = '221' + LAST_MILE_NAVIGATION = '227' + GOOGLE_STREET_VIEW = '229' + GOOGLE_SATELITE_VIEW = '230' + DYNAMIC_EV_ICE_RANGE = '232' + ECO_ROUTE_CALCULATION = '233' + CO_PILOT = '234' + DRIVING_JOURNEY_HISTORY = '235' + NISSAN_RENAULT_BROADCASTS = '241' + ONLINE_PARKING_INFO = '243' + ONLINE_RESTAURANT_INFO = '244' + ONLINE_SPEED_RESTRICTION_INFO = '245' + WEATHER_INFO = '246' + VEHICLE_ACCESS_TO_EMAIL = '248' + VEHICLE_ACCESS_TO_MUSIC = '249' + VEHICLE_ACCESS_TO_CONTACTS = '262' + APP_DOOR_LOCKING = '27' + GLONASS = '276' + ZONE_ALERT = '281' + SPEEDING_ALERT = '282' + SERVICE_SUBSCRIPTION = '284' + PAY_HOW_YOU_DRIVE = '286' + CHARGING_SPOT_INFO = '288' + FLEET_ASSET_INFORMATION = '29' + CHARGING_SPOT_INFO_COLLECTION = '292' + CHARGING_START = '299' + CHARGING_STOP = '303' + INTERIOR_TEMP_SETTINGS = '307' + CLIMATE_ON_OFF_NOTIFICATION = '311' + CHARGING_SPOT_SEARCH = '312' + PLUG_IN_REMINDER = '314' + CHARGING_STOP_NOTIFICATION = '317' + BATTERY_STATUS = '319' + BATTERY_HEATING_NOTIFICATION = '320' + VEHICLE_STATE_OF_CHARGE_PERCENT = '322' + BATTERY_STATE_OF_HEALTH_PERCENT = '323' + PAY_AS_YOU_DRIVE = '34' + DRIVING_ANALYSIS = '340' + CO2_GAS_SAVINGS = '341' + ELECTRICITY_FEE_CALCULATION = '342' + CHARGING_CONSUMPTION_HISTORY = '344' + BATTERY_MONITORING = '345' + BATTERY_DATA = '347' + APP_BASED_NAVIGATION = '35' + CHARGING_SPOT_UPDATES = '354' + RECHARGEABLE_AREA = '358' + NO_CHARGING_SPOT_INFO = '359' + EV_RANGE = '360' + CLIMATE_ON_OFF = '366' + ONLINE_FUEL_STATION_INFO = '367' + DESTINATION_SEND_TO_CAR = '37' + ECALL = '4' + GOOGLE_PLACES_SEARCH = '40' + PREMIUM_TRAFFIC = '43' + AUTO_COLLISION_NOTIFICATION_ACN = '6' + THEFT_BURGLAR_NOTIFICATION_VEHICLE = '7' + ECO_CHALLENGE = '721' + ECO_CHALLENGE_FLEET = '722' + MOBILE_INFORMATION = '74' + URL_PRESET_ON_VEHICLE = '77' + ASSISTED_DESTINATION_SETTING = '78' + CONCIERGE = '79' + PERSONAL_DATA_SYNC = '80' + THEFT_BURGLAR_NOTIFICATION_APP = '87' + STOLEN_VEHICLE_TRACKING_SVT = '9' + REMOTE_ENGINE_START = '96' + HORN_AND_LIGHTS = '97' + CURFEW_ALERT = '98' + TEMPERATURE = '2042' + VALET_PARKING_CALL = '401' + PANIC_CALL = '406' + + +class Language(enum.Enum): + """The service requires ISO 639-1 language codes to be mapped back + to ISO 3166-1 country codes. Of course. + """ + + # Bulgarian = Bulgaria + BG = 'BG' + # Czech = Czech Republic + CS = 'CZ' + # Danish = Denmark + DA = 'DK' + # German = Germany + DE = 'DE' + # Greek = Greece + EL = 'GR' + # Spanish = Spain + ES = 'ES' + # Finnish = Finland + FI = 'FI' + # French = France + FR = 'FR' + # Hebrew = Israel + HE = 'IL' + # Croatian = Croatia + HR = 'HR' + # Hungarian = Hungary + HU = 'HU' + # Italian = Italy + IT = 'IT' + # Formal Norwegian = Norway + NB = 'NO' + # Dutch = Netherlands + NL = 'NL' + # Polish = Poland + PL = 'PL' + # Portuguese = Portugal + PT = 'PT' + # Romanian = Romania + RO = 'RO' + # Russian = Russia + RU = 'RU' + # Slovakian = Slovakia + SK = 'SK' + # Slovenian = Slovenia + SI = 'SL' + # Serbian = Serbia + SR = 'RS' + # Swedish = Sweden + SV = 'SE' + # Ukranian = Ukraine + UK = 'UA' + # Default + EN = 'EN' + + +class Order(enum.Enum): + DESC = 'DESC' + ASC = 'ASC' + + +class NotificationCategoryKey(enum.Enum): + ASSISTANCE = 'assistance' + CHARGE_EV = 'chargeev' + CUSTOM = 'custom' + EV_BATTERY = 'EVBattery' + FOTA = 'fota' + GEO_FENCING = 'geofencing' + MAINTENANCE = 'maintenance' + NAVIGATION = 'navigation' + PRIVACY_MODE = 'privacymode' + REMOTE_CONTROL = 'remotecontrol' + RESET = 'RESET' + RGDC = 'rgdcmyze' + SAFETY_AND_SECURITY = 'Safety&Security' + SVT = 'SVT' + + +class NotificationStatus(enum.Enum): + READ = 'READ' + UNREAD = 'UNREAD' + + +class NotificationChannelType(enum.Enum): + PUSH_APP = 'PUSH_APP' + MAIL = 'MAIL' + OFF = '' + SMS = 'SMS' + + +NotificationType = collections.namedtuple('NotificationType', ['key', 'title', 'message', 'category']) +NotificationCategory = collections.namedtuple('Category', ['key', 'title']) + + +class NotificationTypeKey(enum.Enum): + ABS_ALERT = 'abs.alert' + AVAILABLE_CHARGING = 'available.charging' + BADGE_BATTERY_ALERT = 'badge.battery.alert' + BATTERY_BLOWING_REQUEST = 'battery.blowing.request' + BATTERY_CHARGE_AVAILABLE = 'battery.charge.available' + BATTERY_CHARGE_IN_PROGRESS = 'battery.charge.in.progress' + BATTERY_CHARGE_UNAVAILABLE = 'battery.charge.unavailable' + BATTERY_COOLING_CONDITIONNING_REQUEST = 'battery.cooling.conditionning.request' + BATTERY_ENDED_CHARGE = 'battery.ended.charge' + BATTERY_FLAP_OPENED = 'battery.flap.opened' + BATTERY_FULL_EXCEPTION = 'battery.full.exception' + BATTERY_HEATING_CONDITIONNING_REQUEST = 'battery.heating.conditionning.request' + BATTERY_HEATING_START = 'battery.heating.start' + BATTERY_HEATING_STOP = 'battery.heating.stop' + BATTERY_PREHEATING_START = 'battery.preheating.start' + BATTERY_PREHEATING_STOP = 'battery.preheating.stop' + BATTERY_SCHEDULE_ISSUE = 'battery.schedule.issue' + BATTERY_TEMPERATURE_ALERT = 'battery.temperature.alert' + BATTERY_WAITING_CURRENT_CHARGE = 'battery.waiting.current.charge' + BATTERY_WAITING_PLANNED_CHARGE = 'battery.waiting.planned.charge' + BRAKE_ALERT = 'brake.alert' + BRAKE_SYSTEM_MALFUNCTION = 'brake.system.malfunction' + BURGLAR_ALARM_LOST = 'burglar.alarm.lost' + BURGLAR_CAR_STOLEN = 'burglar.car.stolen' + BURGLAR_TOW_INFO = 'burglar.tow.info' + CHARGE_FAILURE = 'charge.failure' + CHARGE_NOT_PROHIBITED = 'charge.not.prohibited' + CHARGE_PROHIBITED = 'charge.prohibited' + CHARGING_STOP_GEN3 = 'charging.stop.gen3' + COOLANT_ALERT = 'coolant.alert' + CRASH_DETECTION_ALERT = 'crash.detection.alert' + CURFEW_INFRINGEMENT = 'curfew.infringement' + CURFEW_RECOVERY = 'curfew.recovery' + CUSTOM = 'custom' + DURING_INHIBITED_CHARGING = 'during.inhibited.charging' + ENGINE_WATER_TEMP_ALERT = 'engine.water.temp.alert' + EPS_ALERT = 'eps.alert' + FOTA_CAMPAIGN_AVAILABLE = 'fota.campaign.available' + FOTA_CAMPAIGN_STATUS_ACTIVATION_COMPLETED = 'fota.campaign.status.activation.completed' + FOTA_CAMPAIGN_STATUS_ACTIVATION_FAILED = 'fota.campaign.status.activation.failed' + FOTA_CAMPAIGN_STATUS_ACTIVATION_POSTPONED = 'fota.campaign.status.activation.postponed' + FOTA_CAMPAIGN_STATUS_ACTIVATION_PROGRESS = 'fota.campaign.status.activation.progress' + FOTA_CAMPAIGN_STATUS_ACTIVATION_SCHEDULED = 'fota.campaign.status.activation.scheduled' + FOTA_CAMPAIGN_STATUS_CANCELLED = 'fota.campaign.status.cancelled' + FOTA_CAMPAIGN_STATUS_CANCELLING = 'fota.campaign.status.cancelling' + FOTA_CAMPAIGN_STATUS_DOWNLOAD_COMPLETED = 'fota.campaign.status.download.completed' + FOTA_CAMPAIGN_STATUS_DOWNLOAD_PAUSED = 'fota.campaign.status.download.paused' + FOTA_CAMPAIGN_STATUS_DOWNLOAD_PROGRESS = 'fota.campaign.status.download.progress' + FOTA_CAMPAIGN_STATUS_INSTALLATION_COMPLETED = 'fota.campaign.status.installation.completed' + FOTA_CAMPAIGN_STATUS_INSTALLATION_PROGRESS = 'fota.campaign.status.installation.progress' + FUEL_ALERT = 'fuel.alert' + HVAC_AUTOSTART = 'hvac.autostart' + HVAC_AUTOSTOP = 'hvac.autostop' + HVAC_TECHNICAL_ISSUE = 'hvac.technical.issue' + HVAC_TRACTION_BATTERY_LOW = 'hvac.traction.battery.low' + HVAC_VEHICLE_IN_USE = 'hvac.vehicle.in.use' + HVAC_VEHICLE_NOT_CONNECTED_POWER = 'hvac.vehicle.not.connected.power' + LAST_MILE_DESTINATION_ADDRESS = 'last.mile.destination.address' + LOCK_STATUS_REMINDER = 'lock.status.reminder' + MAINTENANCE_DISTANCE_PREALERT = 'maintenance.distance.prealert' + MAINTENANCE_TIME_PREALERT = 'maintenance.time.prealert' + MIL_LAMP_AUTO_TEST = 'mil.lamp.auto.test' + MIL_LAMP_FLASH_REQUEST = 'mil.lamp.flash.request' + MIL_LAMP_OFF_REQUEST = 'mil.lamp.off.request' + MIL_LAMP_ON_REQUEST = 'mil.lamp.on.request' + NEXT_CHARGING_INHIBITED = 'next.charging.inhibited' + OIL_LEVEL_ALERT = 'oil.level.alert' + OIL_PRESSURE_ALERT = 'oil.pressure.alert' + OUT_OF_PARK_POSITION_CHARGE_INTERRUPTION = 'out.of.park.position.charge.interruption' + PLUG_CONNECTION_ISSUE = 'plug.connection.issue' + PLUG_CONNECTION_SUCCESS = 'plug.connection.success' + PLUG_UNLOCKING = 'plug.unlocking' + PREREMINDER_ALERT_DEFAULT = 'prereminder.alert.default' + PRIVACY_MODE_OFF = 'privacy.mode.off' + PRIVACY_MODE_ON = 'privacy.mode.on' + PROGRAMMED_CHARGE_INTERRUPTION = 'programmed.charge.interruption' + PROHIBITION_BATTERY_RENTAL = 'prohibition.battery.rental' + PWT_START_IMPOSSIBLE = 'pwt.start.impossible' + REMOTE_LEFT_TIME_CYCLE = 'remote.left.time.cycle' + REMOTE_START_CUSTOMER = 'remote.start.customer' + REMOTE_START_ENGINE = 'remote.start.engine' + REMOTE_START_NORMAL_ONLY = 'remote.start.normal.only' + REMOTE_START_PHONE_ERROR = 'remote.start.phone.error' + REMOTE_START_UNAVAILABLE = 'remote.start.unavailable' + REMOTE_START_WAIT_PRESOAK = 'remote.start.wait.presoak' + SERV_WARNING_ALERT = 'serv.warning.alert' + SPEED_INFRINGEMENT = 'speed.infringement' + SPEED_RECOVERY = 'speed.recovery' + START_DRIVING_CHARGE_INTERRUPTION = 'start.driving.charge.interruption' + START_IN_PROGRESS = 'start.in.progress' + STATUS_OIL_PRESSURE_SWITCH_CLOSED = 'status.oil.pressure.switch.closed' + STATUS_OIL_PRESSURE_SWITCH_OPEN = 'status.oil.pressure.switch.open' + STOP_WARNING_ALERT = 'stop.warning.alert' + UNPLUG_CHARGE = 'unplug.charge' + WAITING_PLANNED_CHARGE = 'waiting.planned.charge' + WHEEL_ALERT = 'wheel.alert' + ZONE_INFRINGEMENT = 'zone.infringement' + ZONE_RECOVERY = 'zone.recovery' + + +class NotificationRuleKey(enum.Enum): + ABS_ALERT = 'abs.alert' + AVAILABLE_CHARGING = 'available.charging' + BADGE_BATTERY_ALERT = 'badge.battery.alert' + BATTERY_BLOWING_REQUEST = 'battery.blowing.request' + BATTERY_CHARGE_AVAILABLE = 'battery.charge.available' + BATTERY_CHARGE_IN_PROGRESS = 'battery.charge.in.progress' + BATTERY_CHARGE_UNAVAILABLE = 'battery.charge.unavailable' + BATTERY_COOLING_CONDITIONNING_REQUEST = 'battery.cooling.conditionning.request' + BATTERY_ENDED_CHARGE = 'battery.ended.charge' + BATTERY_FLAP_OPENED = 'battery.flap.opened' + BATTERY_FULL_EXCEPTION = 'battery.full.exception' + BATTERY_HEATING_CONDITIONNING_REQUEST = 'battery.heating.conditionning.request' + BATTERY_HEATING_START = 'battery.heating.start' + BATTERY_HEATING_STOP = 'battery.heating.stop' + BATTERY_PREHEATING_START = 'battery.preheating.start' + BATTERY_PREHEATING_STOP = 'battery.preheating.stop' + BATTERY_SCHEDULE_ISSUE = 'battery.schedule.issue' + BATTERY_TEMPERATURE_ALERT = 'battery.temperature.alert' + BATTERY_WAITING_CURRENT_CHARGE = 'battery.waiting.current.charge' + BATTERY_WAITING_PLANNED_CHARGE = 'battery.waiting.planned.charge' + BRAKE_ALERT = 'brake.alert' + BRAKE_SYSTEM_MALFUNCTION = 'brake.system.malfunction' + BURGLAR_ALARM_LOST = 'burglar.alarm.lost' + BURGLAR_CAR_STOLEN = 'burglar.car.stolen' + BURGLAR_TOW_INFO = 'burglar.tow.info' + BURGLAR_TOW_SYSTEM_FAILURE = 'burglar.tow.system.failure' + CHARGE_FAILURE = 'charge.failure' + CHARGE_NOT_PROHIBITED = 'charge.not.prohibited' + CHARGE_PROHIBITED = 'charge.prohibited' + CHARGING_STOP_GEN3 = 'charging.stop.gen3' + COOLANT_ALERT = 'coolant.alert' + CRASH_DETECTION_ALERT = 'crash.detection.alert' + CURFEW_INFRINGEMENT = 'curfew.infringement' + CURFEW_RECOVERY = 'curfew.recovery' + CUSTOM = 'custom' + DURING_INHIBITED_CHARGING = 'during.inhibited.charging' + ENGINE_WATER_TEMP_ALERT = 'engine.water.temp.alert' + EPS_ALERT = 'eps.alert' + FOTA_CAMPAIGN_AVAILABLE = 'fota.campaign.available' + FOTA_CAMPAIGN_STATUS_ACTIVATION_COMPLETED = 'fota.campaign.status.activation.completed' + FOTA_CAMPAIGN_STATUS_ACTIVATION_FAILED = 'fota.campaign.status.activation.failed' + FOTA_CAMPAIGN_STATUS_ACTIVATION_POSTPONED = 'fota.campaign.status.activation.postponed' + FOTA_CAMPAIGN_STATUS_ACTIVATION_PROGRESS = 'fota.campaign.status.activation.progress' + FOTA_CAMPAIGN_STATUS_ACTIVATION_SCHEDULED = 'fota.campaign.status.activation.scheduled' + FOTA_CAMPAIGN_STATUS_CANCELLED = 'fota.campaign.status.cancelled' + FOTA_CAMPAIGN_STATUS_CANCELLING = 'fota.campaign.status.cancelling' + FOTA_CAMPAIGN_STATUS_DOWNLOAD_COMPLETED = 'fota.campaign.status.download.completed' + FOTA_CAMPAIGN_STATUS_DOWNLOAD_PAUSED = 'fota.campaign.status.download.paused' + FOTA_CAMPAIGN_STATUS_DOWNLOAD_PROGRESS = 'fota.campaign.status.download.progress' + FOTA_CAMPAIGN_STATUS_INSTALLATION_COMPLETED = 'fota.campaign.status.installation.completed' + FOTA_CAMPAIGN_STATUS_INSTALLATION_PROGRESS = 'fota.campaign.status.installation.progress' + FUEL_ALERT = 'fuel.alert' + HVAC_AUTOSTART = 'hvac.autostart' + HVAC_AUTOSTOP = 'hvac.autostop' + HVAC_TECHNICAL_ISSUE = 'hvac.technical.issue' + HVAC_TRACTION_BATTERY_LOW = 'hvac.traction.battery.low' + HVAC_VEHICLE_IN_USE = 'hvac.vehicle.in.use' + HVAC_VEHICLE_NOT_CONNECTED_POWER = 'hvac.vehicle.not.connected.power' + LAST_MILE_DESTINATION_ADDRESS = 'last.mile.destination.address' + LOCK_STATUS_REMINDER = 'lock.status.reminder' + MAINTENANCE_DISTANCE_PREALERT = 'maintenance.distance.prealert' + MAINTENANCE_TIME_PREALERT = 'maintenance.time.prealert' + MIL_LAMP_AUTO_TEST = 'mil.lamp.auto.test' + MIL_LAMP_FLASH_REQUEST = 'mil.lamp.flash.request' + MIL_LAMP_OFF_REQUEST = 'mil.lamp.off.request' + MIL_LAMP_ON_REQUEST = 'mil.lamp.on.request' + NEXT_CHARGING_INHIBITED = 'next.charging.inhibited' + OIL_LEVEL_ALERT = 'oil.level.alert' + OIL_PRESSURE_ALERT = 'oil.pressure.alert' + OUT_OF_PARK_POSITION_CHARGE_INTERRUPTION = 'out.of.park.position.charge.interruption' + PLUG_CONNECTION_ISSUE = 'plug.connection.issue' + PLUG_CONNECTION_SUCCESS = 'plug.connection.success' + PLUG_UNLOCKING = 'plug.unlocking' + PREREMINDER_ALERT_DEFAULT = 'prereminder.alert.default' + PRIVACY_MODE_OFF = 'privacy.mode.off' + PRIVACY_MODE_ON = 'privacy.mode.on' + PROGRAMMED_CHARGE_INTERRUPTION = 'programmed.charge.interruption' + PROHIBITION_BATTERY_RENTAL = 'prohibition.battery.rental' + PWT_START_IMPOSSIBLE = 'pwt.start.impossible' + REMOTE_LEFT_TIME_CYCLE = 'remote.left.time.cycle' + REMOTE_START_CUSTOMER = 'remote.start.customer' + REMOTE_START_ENGINE = 'remote.start.engine' + REMOTE_START_NORMAL_ONLY = 'remote.start.normal.only' + REMOTE_START_PHONE_ERROR = 'remote.start.phone.error' + REMOTE_START_UNAVAILABLE = 'remote.start.unavailable' + REMOTE_START_WAIT_PRESOAK = 'remote.start.wait.presoak' + RENAULT_RESET_FACTORY = 'renault.reset.factory' + RGDC_CHARGE_COMPLETE = 'rgdc.charge.complete' + RGDC_CHARGE_ERROR = 'rgdc.charge.error' + RGDC_CHARGE_ON = 'rgdc.charge.on' + RGDC_CHARGE_STATUS = 'rgdc.charge.status' + RGDC_LOW_BATTERY_ALERT = 'rgdc.low.battery.alert' + RGDC_LOW_BATTERY_REMINDER = 'rgdc.low.battery.reminder' + SERV_WARNING_ALERT = 'serv.warning.alert' + SPEED_INFRINGEMENT = 'speed.infringement' + SPEED_RECOVERY = 'speed.recovery' + SRP_PINCODE_ACKNOWLEDGEMENT = 'srp.pincode.acknowledgement' + SRP_PINCODE_DELETION = 'srp.pincode.deletion' + SRP_PINCODE_STATUS = 'srp.pincode.status' + SRP_SALT_REQUEST = 'srp.salt.request' + START_DRIVING_CHARGE_INTERRUPTION = 'start.driving.charge.interruption' + START_IN_PROGRESS = 'start.in.progress' + STATUS_OIL_PRESSURE_SWITCH_CLOSED = 'status.oil.pressure.switch.closed' + STATUS_OIL_PRESSURE_SWITCH_OPEN = 'status.oil.pressure.switch.open' + STOLEN_VEHICLE_TRACKING = 'stolen.vehicle.tracking' + STOLEN_VEHICLE_TRACKING_BLOCKING = 'stolen.vehicle.tracking.blocking' + STOP_WARNING_ALERT = 'stop.warning.alert' + SVT_SERVICE_ACTIVATION = 'svt.service.activation' + UNPLUG_CHARGE = 'unplug.charge' + WAITING_PLANNED_CHARGE = 'waiting.planned.charge' + WHEEL_ALERT = 'wheel.alert' + ZONE_INFRINGEMENT = 'zone.infringement' + ZONE_RECOVERY = 'zone.recovery' + + +class NotificationPriority(enum.Enum): + + NONE = None + P0 = 0 + P1 = 1 + P2 = 2 + P3 = 3 + + +class NotificationRuleStatus(enum.Enum): + ACTIVATED = 'ACTIVATED' + ACTIVATION_IN_PROGRESS = 'STATUS_ACTIVATION_IN_PROGRESS' + DELETION_IN_PROGRESS = 'STATUS_DELETION_IN_PROGRESS' + + +class Notification: + + @property + def vehicle(self): + return _registry[VEHICLES][self.vin] + + @property + def user_id(self): + return self.vehicle.user_id + + @property + def session(self): + return self.vehicle.session + + def __init__(self, data, language, vin): + self.language = language + self.vin = vin + self.id = data['notificationId'] + self.title = data['messageTitle'] + self.subtitle = data['messageSubtitle'] + self.description = data['messageDescription'] + self.category = NotificationCategoryKey(data['categoryKey']) + self.rule_key = NotificationRuleKey(data['ruleKey']) + self.notification_key = NotificationTypeKey(data['notificationKey']) + self.priority = NotificationPriority(data['priority']) + self.state = NotificationStatus(data['status']) + t = datetime.datetime.strptime(data['timestamp'].split('.')[0], '%Y-%m-%dT%H:%M:%S') + if '.' in data['timestamp']: + fraction = data['timestamp'][20:-1] + t = t.replace(microsecond=int(fraction) * 10**(6-len(fraction))) + self.time = t + # List of {'name': 'N', 'type': 'T', 'value': 'V'} + self.data = data['data'] + # future use maybe? empty dict + self.metadata = data['metadata'] + + def __str__(self): + # title is kinda useless, subtitle has better content + return '{}: {}'.format(self.time, self.subtitle) + + def fetch_details(self, language: Language=None): + if language is None: + language = self.language + resp = self.session.oauth.get( + '{}v2/notifications/users/{}/vehicles/{}/notifications/{}'.format( + self.session.settings['notifications_base_url'], + self.user_id, self.vin, self.id + ), + params={'langCode': language.value} + ) + return resp + + +class KamereonSession: + + tenant = None + copy_realm = None + + def __init__(self, region, session=None): + self.settings = settings_map[self.tenant][region] + if session is None: + session = requests.session() + self.session = session + self._oauth = None + self._user_id = None + # ugly hack + os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1' + + def login(self, username, password): + # grab an auth ID to use as part of the username/password login request, + # then move to the regular OAuth2 process + + auth_url = '{}json/realms/root/realms/{}/authenticate'.format( + self.settings['auth_base_url'], + self.settings['realm'], + ) + resp = self.session.post( + auth_url, + headers={ + 'Accept-Api-Version': API_VERSION, + 'X-Username': 'anonymous', + 'X-Password': 'anonymous', + 'Accept': 'application/json', + }) + next_body = resp.json() + + # insert the username, and password + for c in next_body['callbacks']: + input_type = c['type'] + if input_type == 'NameCallback': + c['input'][0]['value'] = username + elif input_type == 'PasswordCallback': + c['input'][0]['value'] = password + + resp = self.session.post( + auth_url, + headers={ + 'Accept-Api-Version': API_VERSION, + 'X-Username': 'anonymous', + 'X-Password': 'anonymous', + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + data=json.dumps(next_body)) + + oauth_data = resp.json() + + oauth_authorize_url = '{}oauth2{}/authorize'.format( + self.settings['auth_base_url'], + oauth_data['realm'] + ) + nonce = generate_nonce() + resp = self.session.get( + oauth_authorize_url, + params={ + 'client_id': self.settings['client_id'], + 'redirect_uri': self.settings['redirect_uri'], + 'response_type': 'code', + 'scope': self.settings['scope'], + 'nonce': nonce, + }, + allow_redirects=False) + oauth_authorize_url = resp.headers['location'] + + oauth_token_url = '{}oauth2{}/access_token'.format( + self.settings['auth_base_url'], + oauth_data['realm'] + ) + self._oauth = OAuth2Session( + client_id=self.settings['client_id'], + redirect_uri=self.settings['redirect_uri'], + scope=self.settings['scope']) + self._oauth._client.nonce = nonce + self._oauth.fetch_token( + oauth_token_url, + authorization_response=oauth_authorize_url, + client_secret=self.settings['client_secret'], + include_client_id=True) + + @property + def oauth(self): + if self._oauth is None: + raise RuntimeError('No access token set, you need to log in first.') + return self._oauth + + @property + def user_id(self): + if not self._user_id: + resp = self.oauth.get( + '{}v1/users/current'.format(self.settings['user_adapter_base_url']) + ) + self._user_id = resp.json()['userId'] + _registry[USERS][self._user_id] = self + return self._user_id + + def fetch_vehicles(self): + resp = self.oauth.get( + '{}v2/users/{}/cars'.format(self.settings['user_base_url'], self.user_id) + ) + vehicles = [] + for vehicle_data in resp.json()['data']: + vehicle = Vehicle(vehicle_data, self.user_id) + vehicles.append(vehicle) + _registry[VEHICLES][vehicle.vin] = vehicle + return vehicles + + +class NCISession(KamereonSession): + + tenant = 'nissan' + copy_realm = 'P_NCB' + + +class Vehicle: + + def __repr__(self): + return '<{} {}>'.format(self.__class__.__name__, self.vin) + + def __str__(self): + return self.nickname or self.vin + + @property + def session(self): + return _registry[USERS][self.user_id] + + def __init__(self, data, user_id): + self.user_id = user_id + self.vin = data['vin'].upper() + self.features = [ + Feature(u['name']) + for u in data.get('uids', []) + if u['enabled']] + self.can_generation = data.get('canGeneration') + self.color = data.get('color') + self.energy = data.get('energy') + self.vehicle_gateway = data.get('carGateway') + self.battery_code = data.get('batteryCode') + self.engine_type = data.get('engineType') + self.first_registration_date = data.get('firstRegistrationDate') + self.ice_or_ev = data.get('iceEvFlag') + self.model_name = data.get('modelName') + self.nickname = data.get('nickname') + self.phase = data.get('phase') + self.picture_url = data.get('pictureURL') + self.privacy_mode = data.get('privacyMode') + self.registration_number = data.get('registrationNumber') + self.battery_capacity = None + self.battery_level = None + self.battery_temperature = None + self.battery_bar_level = None + self.instantaneous_power = None + self.charging_speed = None + self.charge_time_required_to_full = { + ChargingSpeed.FAST: None, + ChargingSpeed.NORMAL: None, + ChargingSpeed.SLOW: None, + } + self.range_hvac_off = None + self.range_hvac_on = None + self.charging = ChargingStatus.NOT_CHARGING + self.plugged_in = PluggedStatus.NOT_PLUGGED + self.plugged_in_time = None + self.unplugged_time = None + self.battery_status_last_updated = None + self.location = None + self.location_last_updated = None + self.combustion_fuel_unit_cost = None + self.electricity_unit_cost = None + self.external_temperature = None + self.internal_temperature = None + self.hvac_status = None + self.next_hvac_start_date = None + self.next_target_temperature = None + self.hvac_status_last_updated = None + self.door_status = { + Door.FRONT_LEFT: None, + Door.FRONT_RIGHT: None, + Door.REAR_LEFT: None, + Door.REAR_RIGHT: None, + Door.HATCH: None + } + self.lock_status = None + self.lock_status_last_updated = None + self.eco_score = None + self.fuel_autonomy = None + self.fuel_consumption = None + self.fuel_economy = None + self.fuel_level = None + self.fuel_low_warning = None + self.fuel_quantity = None + self.mileage = None + self.total_mileage = None + + def refresh(self): + self.refresh_location() + self.refresh_battery_status() + self.fetch_all() + + def fetch_all(self): + self.fetch_cockpit() + self.fetch_location() + self.fetch_battery_status() + self.fetch_energy_unit_cost() + self.fetch_hvac_status() + self.fetch_lock_status() + + def refresh_location(self): + resp = self.session.oauth.post( + '{}v1/cars/{}/actions/refresh-location'.format(self.session.settings['car_adapter_base_url'], self.vin), + data=json.dumps({ + 'data': {'type': 'RefreshLocation'} + }), + headers={'Content-Type': 'application/vnd.api+json'} + ) + body = resp.json() + if 'errors' in body: + raise ValueError(body['errors']) + return body + + def fetch_location(self): + resp = self.session.oauth.get( + '{}v1/cars/{}/location'.format(self.session.settings['car_adapter_base_url'], self.vin), + headers={'Content-Type': 'application/vnd.api+json'} + ) + body = resp.json() + if 'errors' in body: + raise ValueError(body['errors']) + location_data = body['data']['attributes'] + self.location = (location_data['gpsLatitude'], location_data['gpsLongitude']) + self.location_last_updated = datetime.datetime.fromisoformat(location_data['lastUpdateTime'].replace('Z','+00:00')) + + def refresh_lock_status(self): + resp = self.session.oauth.post( + '{}v1/cars/{}/actions/refresh-lock-status'.format(self.session.settings['car_adapter_base_url'], self.vin), + data=json.dumps({ + 'data': {'type': 'RefreshLockStatus'} + }), + headers={'Content-Type': 'application/vnd.api+json'} + ) + body = resp.json() + if 'errors' in body: + raise ValueError(body['errors']) + return body + + def fetch_lock_status(self): + if Feature.LOCK_STATUS_CHECK not in self.features: + return + resp = self.session.oauth.get( + '{}v1/cars/{}/lock-status'.format(self.session.settings['car_adapter_base_url'], self.vin), + headers={'Content-Type': 'application/vnd.api+json'} + ) + body = resp.json() + if 'errors' in body: + raise ValueError(body['errors']) + lock_data = body['data']['attributes'] + self.door_status[Door.FRONT_LEFT] = LockStatus(lock_data['doorStatusFrontLeft']) + self.door_status[Door.FRONT_RIGHT] = LockStatus(lock_data['doorStatusFrontRight']) + self.door_status[Door.REAR_LEFT] = LockStatus(lock_data['doorStatusRearLeft']) + self.door_status[Door.REAR_RIGHT] = LockStatus(lock_data['doorStatusRearRight']) + self.door_status[Door.HATCH] = LockStatus(lock_data['hatchStatus']) + self.lock_status = LockStatus(lock_data['lockStatus']) + self.lock_status_last_updated = datetime.datetime.fromisoformat(lock_data['lastUpdateTime'].replace('Z','+00:00')) + + def refresh_hvac_status(self): + resp = self.session.oauth.post( + '{}v1/cars/{}/actions/refresh-hvac-status'.format(self.session.settings['car_adapter_base_url'], self.vin), + data=json.dumps({ + 'data': {'type': 'RefreshHvacStatus'} + }), + headers={'Content-Type': 'application/vnd.api+json'} + ) + body = resp.json() + if 'errors' in body: + raise ValueError(body['errors']) + return body + + def initiate_srp(self): + (salt, verifier) = SRP.enroll(self.user_id, self.vin) + resp = self.session.oauth.post( + '{}v1/cars/{}/actions/srp-initiates'.format(self.session.settings['car_adapter_base_url'], self.vin), + data=json.dumps({ + "data": { + "type": "SrpInitiates", + "attributes": { + "s": salt, + "i": self.user_id, + "v": verifier + } + } + }), + headers={'Content-Type': 'application/vnd.api+json'} + ) + body = resp.json() + if 'errors' in body: + raise ValueError(body['errors']) + return body + + def validate_srp(self): + a = SRP.generate_a() + resp = self.session.oauth.post( + '{}v1/cars/{}/actions/srp-sets'.format(self.session.settings['car_adapter_base_url'], self.vin), + data=json.dumps({ + "data": { + "type": "SrpSets", + "attributes": { + "i": self.user_id, + "a": a + } + } + }), + headers={'Content-Type': 'application/vnd.api+json'} + ) + body = resp.json() + if 'errors' in body: + raise ValueError(body['errors']) + return body + + """ + Other vehicle controls to implement / investigate: + DataReset + DeleteCurfewRestrictions + CreateCurfewRestrictions + CreateSpeedRestrictions + SrpInitiates + DeleteAreaRestrictions + SrpDelete + SrpSets + OpenClose + EngineStart + LockUnlock + CreateAreaRestrictions + DeleteSpeedRestrictions + """ + + def control_charging(self, action: str, srp: str=None): + assert action in ('stop', 'start') + if action == 'start' and Feature.CHARGING_START not in self.features: + return + if action == 'stop' and Feature.CHARGING_STOP not in self.features: + return + attributes = { + 'action': action, + } + if srp is not None: + attributes['srp'] = srp + resp = self.session.oauth.post( + '{}v1/cars/{}/actions/charging-start'.format(self.session.settings['car_adapter_base_url'], self.vin), + data=json.dumps({ + 'data': { + 'type': 'ChargingStart', + 'attributes': attributes + } + }), + headers={'Content-Type': 'application/vnd.api+json'} + ) + body = resp.json() + if 'errors' in body: + raise ValueError(body['errors']) + return body + + def control_horn_lights(self, action: str, target: str, duration: int=5, srp: str=None): + if Feature.HORN_AND_LIGHTS not in self.features: + return + assert target in ('horn_lights', 'lights', 'horn') + assert action in ('stop', 'start', 'double_start') + attributes = { + 'action': action, + 'duration': duration, + 'target': target, + } + if srp is not None: + attributes['srp'] = srp + resp = self.session.oauth.post( + '{}v1/cars/{}/actions/horn-lights'.format(self.session.settings['car_adapter_base_url'], self.vin), + data=json.dumps({ + 'data': { + 'type': 'HornLights', + 'attributes': attributes + } + }), + headers={'Content-Type': 'application/vnd.api+json'} + ) + body = resp.json() + if 'errors' in body: + raise ValueError(body['errors']) + return body + + def set_hvac_status(self, action: HVACAction, target_temperature: int=21, start: datetime.datetime=None, srp: str=None): + if Feature.CLIMATE_ON_OFF not in self.features: + return + + if target_temperature < 16 or target_temperature > 26: + raise ValueError('Temperature must be between 16 & 26 degrees') + + attributes = { + 'action': action.value + } + if action == HVACAction.START: + attributes['targetTemperature'] = target_temperature + if start is not None: + attributes['startDateTime'] = start.isoformat(timespec='seconds') + if srp is not None: + attributes['srp'] = srp + + resp = self.session.oauth.post( + '{}v1/cars/{}/actions/hvac-start'.format(self.session.settings['car_adapter_base_url'], self.vin), + data=json.dumps({ + 'data': { + 'type': 'HvacStart', + 'attributes': attributes + } + }), + headers={'Content-Type': 'application/vnd.api+json'} + ) + body = resp.json() + if 'errors' in body: + raise ValueError(body['errors']) + return body + + def lock_unlock(self, srp: str, action: str, group: LockableDoorGroup=None): + if Feature.APP_DOOR_LOCKING not in self.features: + return + assert action in ('lock', 'unlock') + if group is None: + group = LockableDoorGroup.DOORS_AND_HATCH + resp = self.session.oauth.post( + '{}v1/cars/{}/actions/lock-unlock"'.format(self.session.settings['car_adapter_base_url'], self.vin), + data=json.dumps({ + 'data': { + 'type': 'LockUnlock', + 'attributes': { + 'lock': action, + 'doorType': group.value, + 'srp': srp + } + } + }), + headers={'Content-Type': 'application/vnd.api+json'} + ) + body = resp.json() + if 'errors' in body: + raise ValueError(body['errors']) + return body + + def lock(self, srp: str, group: LockableDoorGroup=None): + return self.lock_unlock(srp, 'lock', group) + + def unlock(self, srp: str, group: LockableDoorGroup=None): + return self.lock_unlock(srp, 'unlock', group) + + def fetch_hvac_status(self): + resp = self.session.oauth.get( + '{}v1/cars/{}/hvac-status'.format(self.session.settings['car_adapter_base_url'], self.vin), + headers={'Content-Type': 'application/vnd.api+json'} + ) + body = resp.json() + if 'errors' in body: + raise ValueError(body['errors']) + hvac_data = body['data']['attributes'] + self.external_temperature = hvac_data.get('externalTemperature') + self.internal_temperature = hvac_data.get('internalTemperature') + if 'hvacStatus' in hvac_data: + self.hvac_status = HVACStatus(hvac_data['hvacStatus']) + if 'nextHvacStartDate' in hvac_data: + self.next_hvac_start_date = datetime.datetime.fromisoformat(hvac_data['nextHvacStartDate'].replace('Z','+00:00')) + self.next_target_temperature = hvac_data.get('nextTargetTemperature') + self.hvac_status_last_updated = datetime.datetime.fromisoformat(hvac_data['lastUpdateTime'].replace('Z','+00:00')) + + def refresh_battery_status(self): + resp = self.session.oauth.post( + '{}v1/cars/{}/actions/refresh-battery-status'.format(self.session.settings['car_adapter_base_url'], self.vin), + data=json.dumps({ + 'data': {'type': 'RefreshBatteryStatus'} + }), + headers={'Content-Type': 'application/vnd.api+json'} + ) + body = resp.json() + if 'errors' in body: + raise ValueError(body['errors']) + return body + + def fetch_battery_status(self): + if Feature.BATTERY_STATUS not in self.features: + return + resp = self.session.oauth.get( + '{}v1/cars/{}/battery-status'.format(self.session.settings['car_adapter_base_url'], self.vin), + headers={'Content-Type': 'application/vnd.api+json'} + ) + body = resp.json() + if 'errors' in body: + raise ValueError(body['errors']) + battery_data = body['data']['attributes'] + self.battery_capacity = battery_data['batteryCapacity'] # kWh + self.battery_level = battery_data['batteryLevel'] # % + self.battery_temperature = battery_data.get('batteryTemperature') # Fahrenheit? + # same meaning as battery level, different scale. 240 = 100% + self.battery_bar_level = battery_data['batteryBarLevel'] + self.instantaneous_power = battery_data.get('instantaneousPower') # kW + self.charging_speed = ChargingSpeed(battery_data.get('chargePower')) + self.charge_time_required_to_full = { + ChargingSpeed.FAST: battery_data['timeRequiredToFullFast'], + ChargingSpeed.NORMAL: battery_data['timeRequiredToFullNormal'], + ChargingSpeed.SLOW: battery_data['timeRequiredToFullSlow'], + } + self.range_hvac_off = battery_data['rangeHvacOff'] + self.range_hvac_on = battery_data['rangeHvacOn'] + self.charging = ChargingStatus(battery_data['chargeStatus']) + self.plugged_in = PluggedStatus(battery_data['plugStatus']) + if 'vehiclePlugTimestamp' in battery_data: + self.plugged_in_time = datetime.datetime.fromisoformat(battery_data['vehiclePlugTimestamp'].replace('Z','+00:00')) + if 'vehicleUnplugTimestamp' in battery_data: + self.unplugged_time = datetime.datetime.fromisoformat(battery_data['vehicleUnplugTimestamp'].replace('Z','+00:00')) + self.battery_status_last_updated = datetime.datetime.fromisoformat(battery_data['lastUpdateTime'].replace('Z','+00:00')) + + def fetch_energy_unit_cost(self): + resp = self.session.oauth.get( + '{}v1/cars/{}/energy-unit-cost'.format(self.session.settings['car_adapter_base_url'], self.vin) + ) + body = resp.json() + if 'errors' in body: + raise ValueError(body['errors']) + energy_cost_data = body['data']['attributes'] + self.electricity_unit_cost = energy_cost_data.get('electricityUnitCost') + self.combustion_fuel_unit_cost = energy_cost_data.get('fuelUnitCost') + return body + + def set_energy_unit_cost(self, cost): + resp = self.session.oauth.post( + '{}v1/cars/{}/energy-unit-cost'.format(self.session.settings['car_adapter_base_url'], self.vin), + data=json.dumps({ + 'data': { + 'type': {} + } + }) + ) + body = resp.json() + if 'errors' in body: + raise ValueError(body['errors']) + + def fetch_trip_histories(self, period: Period=None, start: datetime.date=None, end: datetime.date=None): + if period is None: + period = Period.DAILY + if start is None: + start = datetime.date.today() + if end is None: + end = start + resp = self.session.oauth.get( + '{}v1/cars/{}/trip-history'.format(self.session.settings['car_adapter_base_url'], self.vin), + params={ + 'type': period.value, + 'start': start.isoformat(), + 'end': end.isoformat() + } + ) + body = resp.json() + if 'errors' in body: + raise ValueError(body['errors']) + return [TripSummary(s, self.vin) for s in body['data']['attributes']['summaries']] + + def fetch_notifications( + self, + language: Language=None, + category_key: NotificationCategoryKey=None, + status: NotificationStatus=None, + start: datetime.datetime=None, + end: datetime.datetime=None, + # offset + from_: int=1, + # limit + to: int=20, + order: Order=None + ): + + if language is None: + language = Language.EN + params = { + 'realm': self.session.copy_realm, + 'langCode': language.value, + } + if category_key is not None: + params['categoryKey'] = category_key.value + if status is not None: + params['status'] = status.value + if start is not None: + params['start'] = start.isoformat(timespec='seconds') + if start.tzinfo is None: + # Assume UTC + params['start'] += 'Z' + if end is not None: + params['end'] = start.isoformat(timespec='seconds') + if end.tzinfo is None: + # Assume UTC + params['end'] += 'Z' + resp = self.session.oauth.get( + '{}v2/notifications/users/{}/vehicles/{}'.format(self.session.settings['notifications_base_url'], self.user_id, self.vin), + params=params + ) + body = resp.json() + if 'errors' in body: + raise ValueError(body['errors']) + return [Notification(m, language, self.vin) for m in body['data']['attributes']['messages']] + + def mark_notifications(self, messages: List[Notification]): + """Take a list of notifications and set their status remotely + to the one held locally (read / unread).""" + + resp = self.session.oauth.post( + '{}v2/notifications/users/{}/vehicles/{}'.format(self.session.settings['notifications_base_url'], self.user_id, self.vin), + data=json.dumps([ + {'notificationId': m.id, 'status': m.status.value} + for m in messages + ]) + ) + body = resp.json() + if 'errors' in body: + raise ValueError(body['errors']) + return body + + def fetch_notification_settings(self, language: Language=None): + if language is None: + language = Language.EN + params = { + 'langCode': language.value, + } + resp = self.session.oauth.get( + '{}v1/rules/settings/users/{}/vehicles/{}'.format(self.session.settings['notifications_base_url'], self.user_id, self.vin), + params=params + ) + body = resp.json() + if 'errors' in body: + raise ValueError(body['errors']) + return [ + NotificationRule(r, language, self.vin) + for r in body['settings'] + ] + + def update_notification_settings(self): + # TODO + pass + + def fetch_cockpit(self): + resp = self.session.oauth.get( + "{}v1/cars/{}/cockpit".format(self.session.settings['car_adapter_base_url'], self.vin) + ) + body = resp.json() + if 'errors' in body: + raise ValueError(body['errors']) + + cockpit_data = body['data']['attributes'] + self.eco_score = cockpit_data.get('ecoScore') + self.fuel_autonomy = cockpit_data.get('fuelAutonomy') + self.fuel_consumption = cockpit_data.get('fuelConsumption') + self.fuel_economy = cockpit_data.get('fuelEconomy') + self.fuel_level = cockpit_data.get('fuelLevel') + if 'fuelLowWarning' in cockpit_data: + self.fuel_low_warning = bool(cockpit_data['fuelLowWarning']) + self.fuel_quantity = cockpit_data.get('fuelQuantity') # litres + self.mileage = cockpit_data.get('mileage') + self.total_mileage = cockpit_data['totalMileage'] + + +class TripSummary: + + def __init__(self, data, vin): + self.vin = vin + self.trip_count = data['tripsNumber'] + self.total_distance = data['distance'] # km + self.total_duration = data['duration'] # minutes + self.first_trip_start = datetime.datetime.fromisoformat(data['firstTripStart'].replace('Z','+00:00')) + self.last_trip_end = datetime.datetime.fromisoformat(data['lastTripEnd'].replace('Z','+00:00')) + self.consumed_fuel = data['consumedFuel'] # litres + self.consumed_electricity = data['consumedElectricity'] # W + self.saved_electricity = data['savedElectricity'] # W + if 'day' in data: + self.start = self.end = datetime.date(int(data['day'][:4]), int(data['day'][4:6]), int(data['day'][6:])) + elif 'month' in data: + start_year = int(data['month'][:4]) + start_month = int(data['month'][4:]) + end_month = start_month + 1 + end_year = start_year + if end_month > 12: + end_month = 1 + end_year = end_year + 1 + self.start = datetime.date(start_year, start_month, 1) + self.end = datetime.date(end_year, end_month) - datetime.timedelta(days=1) + elif 'year' in data: + self.start = datetime.date(int(data['year']), 1, 1) + self.end = datetime.date(int(data['year']) + 1, 1, 1) - datetime.timedelta(days=1) + + def __str__(self): + return '{} trips covering {} kilometres over {} minutes using {} litres fuel and {} kilowatt-hours electricity'.format( + self.trip_count, self.total_distance, self.total_duration, self.consumed_fuel, self.consumed_electricity + ) + + +class NotificationRule: + + def __init__(self, data, language, vin): + self.vin = vin + self.language = language + self.key = NotificationRuleKey(data['ruleKey']) + self.title = data['ruleTitle'] + self.description = data['ruleDescription'] + self.priority = NotificationPriority(data['priority']) + self.status = NotificationRuleStatus(data['status']) + self.channels = [ + NotificationChannelType(c['channelType']) + for c in data['channels'] + ] + self.category = NotificationCategory(NotificationCategoryKey(data['categoryKey']), data['categoryTitle']) + self.notification_type = None + if 'notificationKey' in data: + self.notification_type = NotificationType( + NotificationTypeKey(data['notificationKey']), + data['notificationTitle'], + data['notificationMessage'], + self.category, + ) + + def __str__(self): + return '{}: {} ({})'.format( + self.title or self.key, + self.status.value, + ', '.join(c.value for c in self.channels) + ) + + +class SRP: + + @classmethod + def enroll(cls, user_id, vin): + salt, verifier = '0'*20, 'ABCDEFGH'*64 + # salt = 20 hex chars, verifier = 512 hex chars + return (salt, verifier) + + @classmethod + def generate_a(cls): + # 512 hex chars + return '' + + @classmethod + def generate_proof(cls, salt, b, user_id, confirm_code, order): + """Required for remote lock / unlock.""" + # order = '/' + # where PERMISSIONS is one of: + # * "BCI/Block" + # * "BCI/Unblock" + # * "RC/Delayed" + # * "RC/Start" + # * "RC/Stop" + # * "RES/DoubleStart" + # * "RES/Start" + # * "RES/Stop" + # * "RHL/Start/HornOnly" + # * "RHL/Start/HornLight" + # * "RHL/Start/LightOnly" + # * "RHL/Stop" + # * "RLU/Lock" + # * "RLU/Unlock" + # * "RPC_ICE/Start" + # * "RPC_ICE/Stop" + # * "RPU_CCS/Disable" + # * "RPU_CCS/Enable" + # * "RPU_SVTB/Disable" + # * "RPU_SVTB/Enable" + pass + + + +if __name__ == '__main__': + import pprint + import sys + + region = sys.argv[1] + username = sys.argv[2] + password = sys.argv[3] + if len(sys.argv) > 4: + srp = sys.argv[4] + + nci = NCISession(region) + nci.login(username, password) + user_id = nci.get_user_id() + vehicles = nci.fetch_vehicles() + for vehicle in vehicles: + vehicle.fetch_cockpit() + vehicle.fetch_all() + pprint.pprint(vars(vehicle)) + print('today trip summary') + trip_summaries = vehicle.fetch_trip_histories() + print('\n'.join(map(str, trip_summaries))) + print('last notifications') + notifications = vehicle.fetch_notifications() + print('\n'.join(map(str, notifications))) + notifications[0].fetch_details() + diff --git a/kamereon/lock.py b/kamereon/lock.py new file mode 100644 index 0000000..e49c703 --- /dev/null +++ b/kamereon/lock.py @@ -0,0 +1,54 @@ +"""Support for Kamereon car locks.""" +import logging + +from homeassistant.components.lock import LockDevice +from homeassistant.const import STATE_UNKNOWN + +from . import KamereonEntity +from .kamereon import Door, LockStatus + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, vehicle=None): + """Set up the Kamereon lock.""" + if vehicle is None: + return + + async_add_entities([KamereonLock(vehicle)]) + + +class KamereonLock(KamereonEntity, LockDevice): + """Represents a car lock.""" + + @property + def _entity_name(self): + return 'lock' + + @property + def is_locked(self): + """Return true if lock is locked.""" + if self.vehicle.lock_status is None: + return STATE_UNKNOWN + return self.vehicle.lock_status is LockStatus.LOCKED + + async def async_lock(self, **kwargs): + """Lock the car.""" + self.vehicle.lock() + + async def async_unlock(self, **kwargs): + """Unlock the car.""" + self.vehicle.unlock() + + @property + def device_state_attributes(self): + a = KamereonEntity.device_state_attributes.fget(self) + lock_status = self.vehicle.lock_status + if lock_status is None: + lock_status = STATE_UNKNOWN + else: + lock_status = lock_status.value + a.update({ + 'last_updated': self.vehicle.lock_status_last_updated, + 'lock_status': lock_status, + }) \ No newline at end of file diff --git a/kamereon/manifest.json b/kamereon/manifest.json new file mode 100644 index 0000000..0dfd0f8 --- /dev/null +++ b/kamereon/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "kamereon", + "name": "Kamereon", + "documentation": "https://github.com/mitchellrj/kamereon-python", + "requirements": ["requests", "requests_oauthlib", "pytz"], + "dependencies": [], + "codeowners": [] + } \ No newline at end of file diff --git a/kamereon/sensor.py b/kamereon/sensor.py new file mode 100644 index 0000000..dccf0a5 --- /dev/null +++ b/kamereon/sensor.py @@ -0,0 +1,326 @@ +"""Support for Kamereon car sensors.""" +import logging + +from homeassistant.const import ( + DEVICE_CLASS_BATTERY, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_TIMESTAMP, LENGTH_KILOMETERS, POWER_WATT, STATE_UNKNOWN, + TEMP_CELSIUS, TIME_MINUTES, UNIT_PERCENTAGE, VOLUME_LITERS) + +from . import DATA_KEY, KamereonEntity +from .kamereon import ChargingSpeed + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, vehicle=None): + """Set up the Kamereon sensors.""" + if vehicle is None: + return + async_add_entities([ + BatteryLevelSensor(vehicle), + BatteryTemperatureSensor(vehicle), + ChargingPowerSensor(vehicle), + ChargingSpeedSensor(vehicle), + ChargeTimeRequiredSensor(vehicle, ChargingSpeed.FAST), + ChargeTimeRequiredSensor(vehicle, ChargingSpeed.NORMAL), + ChargeTimeRequiredSensor(vehicle, ChargingSpeed.SLOW), + ExternalTemperatureSensor(vehicle), + RangeSensor(vehicle, hvac=True), + RangeSensor(vehicle, hvac=False), + TimestampSensor(vehicle, 'plugged_in_time', 'plugged in time'), + TimestampSensor(vehicle, 'unplugged_time', 'unplugged time'), + TimestampSensor(vehicle, 'battery_status_last_updated', 'battery status last updated time'), + TimestampSensor(vehicle, 'location_last_updated', 'location last updated time'), + TimestampSensor(vehicle, 'lock_status_last_updated', 'lock status last updated time'), + FuelLevelSensor(vehicle), + FuelQuantitySensor(vehicle), + MileageSensor(vehicle, total=False), + MileageSensor(vehicle, total=True), + ]) + + +class BatteryLevelSensor(KamereonEntity): + """Representation of a Kamereon car sensor.""" + + @property + def state(self): + """Return the state.""" + return self.vehicle.battery_bar_level + + @property + def _entity_name(self): + return 'battery level' + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return UNIT_PERCENTAGE + + @property + def device_class(self): + """Return the class of this sensor.""" + return DEVICE_CLASS_BATTERY + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + a = KamereonEntity.device_state_attributes.fget(self) + a.update({ + 'battery_capacity': self.vehicle.battery_capacity, + 'battery_level': self.vehicle.battery_level, + }) + + +class FuelLevelSensor(KamereonEntity): + """Representation of a Kamereon car sensor.""" + + @property + def state(self): + """Return the state.""" + if self.vehicle.fuel_level is None: + return STATE_UNKNOWN + return self.vehicle.fuel_level + + @property + def _entity_name(self): + return 'fuel level' + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return UNIT_PERCENTAGE + + +class FuelQuantitySensor(KamereonEntity): + """Representation of a Kamereon car sensor.""" + + @property + def state(self): + """Return the state.""" + if self.vehicle.fuel_quantity is None: + return STATE_UNKNOWN + return self.vehicle.fuel_quantity + + @property + def _entity_name(self): + return 'fuel quantity' + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return VOLUME_LITERS + + +class BatteryTemperatureSensor(KamereonEntity): + """Representation of a Kamereon car sensor.""" + + @property + def state(self): + """Return the state.""" + if self.vehicle.battery_temperature is None: + return STATE_UNKNOWN + return self.vehicle.battery_temperature + + @property + def _entity_name(self): + return 'battery temperature' + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def device_class(self): + """Return the class of this sensor.""" + return DEVICE_CLASS_TEMPERATURE + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + a = KamereonEntity.device_state_attributes.fget(self) + a.update({ + 'battery_capacity': self.vehicle.battery_capacity, + 'battery_bar_level': self.vehicle.battery_bar_level, + }) + return a + + +class ExternalTemperatureSensor(KamereonEntity): + """Representation of a Kamereon car sensor.""" + + @property + def state(self): + """Return the state.""" + if self.vehicle.external_temperature is None: + return STATE_UNKNOWN + return self.vehicle.external_temperature + + @property + def _entity_name(self): + return 'external temperature' + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def device_class(self): + """Return the class of this sensor.""" + return DEVICE_CLASS_TEMPERATURE + + +class ChargingPowerSensor(KamereonEntity): + """Representation of a Kamereon car sensor.""" + + @property + def state(self): + """Return the state.""" + if self.vehicle.instantaneous_power is None: + return STATE_UNKNOWN + return self.vehicle.instantaneous_power * 1000 + + @property + def _entity_name(self): + return 'charging power' + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return POWER_WATT + + @property + def device_class(self): + """Return the class of this sensor.""" + return DEVICE_CLASS_POWER + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + a = KamereonEntity.device_state_attributes.fget(self) + a.update({ + 'battery_capacity': self.vehicle.battery_capacity, + 'battery_bar_level': self.vehicle.battery_bar_level, + }) + + +class ChargingSpeedSensor(KamereonEntity): + """Representation of a Kamereon car sensor.""" + + @property + def _entity_name(self): + return 'charging speed' + + @property + def state(self): + """Return the state.""" + if self.vehicle.charging_speed is None: + return STATE_UNKNOWN + return self.vehicle.charging_speed.value + + +class ChargeTimeRequiredSensor(KamereonEntity): + """Representation of a Kamereon car sensor.""" + + CHARGING_SPEED_NAME = { + ChargingSpeed.FAST: 'fast', + ChargingSpeed.NORMAL: 'normal', + ChargingSpeed.SLOW: 'slow', + } + + def __init__(self, vehicle, charging_speed): + KamereonEntity.__init__(self, vehicle) + self.charging_speed = charging_speed + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return TIME_MINUTES + + @property + def _entity_name(self): + return 'charging time required to full ({})'.format(self.CHARGING_SPEED_NAME[self.charging_speed]) + + @property + def state(self): + """Return the state.""" + if self.vehicle.charge_time_required_to_full[self.charging_speed] is None: + return STATE_UNKNOWN + return self.vehicle.charge_time_required_to_full[self.charging_speed] + + +class RangeSensor(KamereonEntity): + """Representation of a Kamereon car sensor.""" + + def __init__(self, vehicle, hvac): + KamereonEntity.__init__(self, vehicle) + self.hvac = hvac + + @property + def _entity_name(self): + return 'range (HVAC {})'.format('on' if self.hvac else 'off') + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return LENGTH_KILOMETERS + + @property + def state(self): + """Return the state.""" + val = getattr(self.vehicle, 'range_hvac_{}'.format('on' if self.hvac else 'off')) + if val is None: + return STATE_UNKNOWN + return val + + +class MileageSensor(KamereonEntity): + """Representation of a Kamereon car sensor.""" + + def __init__(self, vehicle, total=False): + KamereonEntity.__init__(self, vehicle) + self.total = total + + @property + def _entity_name(self): + return '{}mileage'.format('total ' if self.total else '') + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return LENGTH_KILOMETERS + + @property + def state(self): + """Return the state.""" + val = getattr(self.vehicle, '{}mileage'.format('total_' if self.total else '')) + if val is None: + return STATE_UNKNOWN + return val + + +class TimestampSensor(KamereonEntity): + """Representation of a Kamereon car sensor.""" + + def __init__(self, vehicle, attribute, name): + KamereonEntity.__init__(self, vehicle) + self.attribute = attribute + self.__entity_name = name + + @property + def _entity_name(self): + return self.__entity_name + + @property + def device_class(self): + """Return the device class.""" + return DEVICE_CLASS_TIMESTAMP + + @property + def state(self): + """Return the state.""" + val = getattr(self.vehicle, self.attribute) + if val is None: + return STATE_UNKNOWN + return val.isoformat() \ No newline at end of file diff --git a/kamereon/switch.py b/kamereon/switch.py new file mode 100644 index 0000000..3f4543a --- /dev/null +++ b/kamereon/switch.py @@ -0,0 +1,36 @@ +"""Support for Kamereon switches.""" +import logging + +from homeassistant.helpers.entity import ToggleEntity + +from . import DATA_KEY, KamereonEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up a Kamereon car switch.""" + if discovery_info is None: + return + #async_add_entities([KamereonSwitch(discovery_info)]) + + +class KamereonSwitch(KamereonEntity, ToggleEntity): + """Representation of a Kamereon car switch.""" + + @property + def _switch(self): + return NotImplemented + + @property + def is_on(self): + """Return true if switch is on.""" + return self._state + + async def async_turn_on(self, **kwargs): + """Turn the switch on.""" + await self.instrument.turn_on() + + async def async_turn_off(self, **kwargs): + """Turn the switch off.""" + await self.instrument.turn_off() \ No newline at end of file