From 656d39e3ec9d20d90b071de5717fcacb71649c01 Mon Sep 17 00:00:00 2001 From: Phil Cole Date: Fri, 15 Feb 2019 13:35:26 +0000 Subject: [PATCH] Nissan Leaf Integration (Carwings / NissanConnect EV) (#19786) * Added work so far. * Change interval so nobody drains their battery when I put this online * Added the warning notice. * Async setup * Still broken, but we're getting there. * Back to synchronous, moved refresh stuff into DataStore * Functional sensors! * Added working switches, tweaked intervals a bit * Fixed turn off result * Moved plug status to binary_sensor, added smart intervals * Documentation and car nickname stuff * Syntax fixes and coveragerc additions * Style fixes * Fixing the final line length * Fixed an issue with newer models and bad climate data * Forgot to check my line endings. * New icons for most of the components * Hotfix for handling Nissan's awful servers * Merge in fixes made by Phil Cole Remove invalid FIXMEs and update TODOs Fixes for pylint and test for CarwingsError exception rather than Exception Flake8 fixes Add pycarwings2 to requirements_all.txt Add extra configuration documentation. Use pycarwings2 from pip. Check server dates between requests. Add sensor device class for battery. Async conversion fixes flake8 fixes and docstrings Non-async charging is OK Handle multiple cars in the configuration Convert to async. Better imports for platforms Fix scanning interval & prevent extra refreshes. async switchover Check discovery_info to prevent load of platforms Ensure update frequency is always above a minimum interval (1 min). Platforms don't have return values Use values() instead of items() when not using key Use snake_case (LeafCore becomes leaf_core) commit 418b6bbcc49cf2909aac85869440435410abf3fd * Add pycarwings2 to requirements_all.txt * Make stopping charge error an 'info'. Remove TODO. * Request update from car after sending start charging command. * Delay initial (slow) update for 15 seconds and make async * Flake8 line length fixes * Try to fix D401 'imperative mood' git diff tox errors * Try to fix more D401 'imperative mood' tox errors * Default interval of an hour in code, to match comments. * Update to pycarwings2 2.3 * Update to pycarwings2 2.3 in requirements_all.txt * Remove documentation, instead refering to home-assistant.io * Remove unneeded dispatcher_send() * Remove unneeded requirements comments * Combine excess debugging. * Remove single line method signal_components() * Bump to version 2.4 of pycarwings2 * Remove unused dispatcher_send * Simplify logging of LeafEntity registration * Update requirements_all.txt * Multiple changes Increase timeout to 30 seconds Only consider battery_status Fix plugged in status Better attempts at try/exception handling * Fix line length * Use pycarwings 2.5 * Remove pointless 'is True' * Remove unnecessary 'is True/False' * Remove unnecessary 'is True/False' * Use LENGTH_MILES and LENGTH_KILOMETERS * Remove excess logging in setup_platform() * Remove unnecessary 'is True' * Use pycarwings2 version 2.6 * Require pycarwings2 version 2.7. * Increase sleep delay for climate and location reponses. * Remove unnecessary 'is True' * Increase frequent polling warning to _LOGGER.warning() * Use DEVICE_CLASS_BATTERY * Remove extraneous 'is True'. * Move icon strings to constants. * Remove unneeded key. * LeafRangeSensor ac_on property is internal. * Flake8 missing line * Remove homebridge attributes. * Remove round battery % and range to whole numbers * Use pycarwings2 2.8 * Move to embedded component model * Reduce maximum attempts to 10 (5 mins) * Include attempt count in 'waiting' log message * Use await instead of yield. Remove @asyncio.coroutine decorators. * Add @filcole as nissan_leaf codeowner * Fix checking for if not data returned from vehicle. Don't double send signal on location update. * Exposed updated_on, update_in_progress and next_update attributes. * Add nissan_leaf.update service that triggers an update. * Flake8 line fixes * Remove excess and double logging. * Add updated_on attribute for device tracker. * Fix crash if pycarwings2 doesn't provide cruising ranges. * Minor changes * Minor changes * Minor changes * Minor changes * Minor changes --- .coveragerc | 1 + CODEOWNERS | 1 + .../components/nissan_leaf/__init__.py | 514 ++++++++++++++++++ .../components/nissan_leaf/binary_sensor.py | 44 ++ .../components/nissan_leaf/device_tracker.py | 46 ++ .../components/nissan_leaf/sensor.py | 113 ++++ .../components/nissan_leaf/switch.py | 99 ++++ requirements_all.txt | 3 + 8 files changed, 821 insertions(+) create mode 100644 homeassistant/components/nissan_leaf/__init__.py create mode 100644 homeassistant/components/nissan_leaf/binary_sensor.py create mode 100644 homeassistant/components/nissan_leaf/device_tracker.py create mode 100644 homeassistant/components/nissan_leaf/sensor.py create mode 100644 homeassistant/components/nissan_leaf/switch.py diff --git a/.coveragerc b/.coveragerc index 4e06eaaef29..dec0594e7a3 100644 --- a/.coveragerc +++ b/.coveragerc @@ -327,6 +327,7 @@ omit = homeassistant/components/nest/* homeassistant/components/netatmo/* homeassistant/components/netgear_lte/* + homeassistant/components/nissan_leaf/* homeassistant/components/notify/aws_lambda.py homeassistant/components/notify/aws_sns.py homeassistant/components/notify/aws_sqs.py diff --git a/CODEOWNERS b/CODEOWNERS index 64263598121..fc3ba96097c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -224,6 +224,7 @@ homeassistant/components/*/mystrom.py @fabaff # N homeassistant/components/ness_alarm/* @nickw444 homeassistant/components/*/ness_alarm.py @nickw444 +homeassistant/components/nissan_leaf/* @filcole # O homeassistant/components/openuv/* @bachya diff --git a/homeassistant/components/nissan_leaf/__init__.py b/homeassistant/components/nissan_leaf/__init__.py new file mode 100644 index 00000000000..f5a8217242d --- /dev/null +++ b/homeassistant/components/nissan_leaf/__init__.py @@ -0,0 +1,514 @@ +"""Support for the Nissan Leaf Carwings/Nissan Connect API.""" +import asyncio +from datetime import datetime, timedelta +import logging +import sys +import urllib + +import voluptuous as vol + +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import load_platform +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 + +REQUIREMENTS = ['pycarwings2==2.8'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'nissan_leaf' +DATA_LEAF = 'nissan_leaf_data' + +DATA_BATTERY = 'battery' +DATA_LOCATION = 'location' +DATA_CHARGING = 'charging' +DATA_PLUGGED_IN = 'plugged_in' +DATA_CLIMATE = 'climate' +DATA_RANGE_AC = 'range_ac_on' +DATA_RANGE_AC_OFF = 'range_ac_off' + +CONF_NCONNECT = 'nissan_connect' +CONF_INTERVAL = 'update_interval' +CONF_CHARGING_INTERVAL = 'update_interval_charging' +CONF_CLIMATE_INTERVAL = 'update_interval_climate' +CONF_REGION = 'region' +CONF_VALID_REGIONS = ['NNA', 'NE', 'NCI', 'NMA', 'NML'] +CONF_FORCE_MILES = 'force_miles' + +INITIAL_UPDATE = timedelta(seconds=15) +MIN_UPDATE_INTERVAL = timedelta(minutes=2) +DEFAULT_INTERVAL = timedelta(hours=1) +DEFAULT_CHARGING_INTERVAL = timedelta(minutes=15) +DEFAULT_CLIMATE_INTERVAL = timedelta(minutes=5) +RESTRICTED_BATTERY = 2 +RESTRICTED_INTERVAL = timedelta(hours=12) + +MAX_RESPONSE_ATTEMPTS = 10 + +PYCARWINGS2_SLEEP = 30 + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.All(cv.ensure_list, [vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_REGION): vol.In(CONF_VALID_REGIONS), + vol.Optional(CONF_NCONNECT, default=True): cv.boolean, + vol.Optional(CONF_INTERVAL, default=DEFAULT_INTERVAL): ( + vol.All(cv.time_period, vol.Clamp(min=MIN_UPDATE_INTERVAL))), + vol.Optional(CONF_CHARGING_INTERVAL, + default=DEFAULT_CHARGING_INTERVAL): ( + vol.All(cv.time_period, + vol.Clamp(min=MIN_UPDATE_INTERVAL))), + vol.Optional(CONF_CLIMATE_INTERVAL, + default=DEFAULT_CLIMATE_INTERVAL): ( + vol.All(cv.time_period, + vol.Clamp(min=MIN_UPDATE_INTERVAL))), + vol.Optional(CONF_FORCE_MILES, default=False): cv.boolean + })]) +}, extra=vol.ALLOW_EXTRA) + +LEAF_COMPONENTS = [ + 'sensor', 'switch', 'binary_sensor', 'device_tracker' +] + +SIGNAL_UPDATE_LEAF = 'nissan_leaf_update' + +SERVICE_UPDATE_LEAF = 'update' +ATTR_VIN = 'vin' + +UPDATE_LEAF_SCHEMA = vol.Schema({ + vol.Required(ATTR_VIN): cv.string, +}) + + +async def async_setup(hass, config): + """Set up the Nissan Leaf component.""" + import pycarwings2 + + async def handle_update(service): + # It would be better if this was changed to use nickname, or + # an entity name rather than a vin. + vin = service.data.get(ATTR_VIN, '') + + if vin in hass.data[DATA_LEAF]: + data_store = hass.data[DATA_LEAF][vin] + async_track_point_in_utc_time( + hass, data_store.async_update_data, utcnow()) + return True + + _LOGGER.debug("Vin %s not recognised for update", vin) + return False + + async def async_setup_leaf(car_config): + """Set up a car.""" + _LOGGER.debug("Logging into You+Nissan...") + + username = car_config[CONF_USERNAME] + password = car_config[CONF_PASSWORD] + region = car_config[CONF_REGION] + leaf = None + + async def leaf_login(): + nonlocal leaf + sess = pycarwings2.Session(username, password, region) + leaf = sess.get_leaf() + + try: + # This might need to be made async (somehow) causes + # homeassistant to be slow to start + await hass.async_add_job(leaf_login) + except(RuntimeError, urllib.error.HTTPError): + _LOGGER.error( + "Unable to connect to Nissan Connect with " + "username and password") + return False + except KeyError: + _LOGGER.error( + "Unable to fetch car details..." + " do you actually have a Leaf connected to your account?") + return False + except pycarwings2.CarwingsError: + _LOGGER.error( + "An unknown error occurred while connecting to Nissan: %s", + sys.exc_info()[0]) + return False + + _LOGGER.warning( + "WARNING: This may poll your Leaf too often, and drain the 12V" + " battery. If you drain your cars 12V battery it WILL NOT START" + " as the drive train battery won't connect." + " Don't set the intervals too low.") + + data_store = LeafDataStore(leaf, hass, car_config) + hass.data[DATA_LEAF][leaf.vin] = data_store + + for component in LEAF_COMPONENTS: + if component != 'device_tracker' or car_config[CONF_NCONNECT]: + load_platform(hass, component, DOMAIN, {}, car_config) + + async_track_point_in_utc_time(hass, data_store.async_update_data, + utcnow() + INITIAL_UPDATE) + + hass.data[DATA_LEAF] = {} + tasks = [async_setup_leaf(car) for car in config[DOMAIN]] + if tasks: + await asyncio.wait(tasks, loop=hass.loop) + + hass.services.async_register(DOMAIN, SERVICE_UPDATE_LEAF, handle_update, + schema=UPDATE_LEAF_SCHEMA) + + return True + + +class LeafDataStore: + """Nissan Leaf Data Store.""" + + def __init__(self, leaf, hass, car_config): + """Initialise the data store.""" + self.leaf = leaf + self.car_config = car_config + self.nissan_connect = car_config[CONF_NCONNECT] + self.force_miles = car_config[CONF_FORCE_MILES] + self.hass = hass + self.data = {} + self.data[DATA_CLIMATE] = False + self.data[DATA_BATTERY] = 0 + self.data[DATA_CHARGING] = False + self.data[DATA_LOCATION] = False + self.data[DATA_RANGE_AC] = 0 + self.data[DATA_RANGE_AC_OFF] = 0 + self.data[DATA_PLUGGED_IN] = False + self.next_update = None + self.last_check = None + self.request_in_progress = False + # Timestamp of last successful response from battery, + # climate or location. + self.last_battery_response = None + self.last_climate_response = None + self.last_location_response = None + self._remove_listener = None + + async def async_update_data(self, now): + """Update data from nissan leaf.""" + # Prevent against a previously scheduled update and an ad-hoc update + # started from an update from both being triggered. + if self._remove_listener: + self._remove_listener() + self._remove_listener = None + + # Clear next update whilst this update is underway + self.next_update = None + + await self.async_refresh_data(now) + self.next_update = self.get_next_interval() + _LOGGER.debug("Next update=%s", self.next_update) + self._remove_listener = async_track_point_in_utc_time( + self.hass, self.async_update_data, self.next_update) + + def get_next_interval(self): + """Calculate when the next update should occur.""" + base_interval = self.car_config[CONF_INTERVAL] + climate_interval = self.car_config[CONF_CLIMATE_INTERVAL] + charging_interval = self.car_config[CONF_CHARGING_INTERVAL] + + # The 12V battery is used when communicating with Nissan servers. + # The 12V battery is charged from the traction battery when not + # connected and when the traction battery has enough charge. To + # avoid draining the 12V battery we shall restrict the update + # frequency if low battery detected. + if (self.last_battery_response is not None and + self.data[DATA_CHARGING] is False and + self.data[DATA_BATTERY] <= RESTRICTED_BATTERY): + _LOGGER.info("Low battery so restricting refresh frequency (%s)", + self.leaf.nickname) + interval = RESTRICTED_INTERVAL + else: + intervals = [base_interval] + _LOGGER.debug("Could use base interval=%s", base_interval) + + if self.data[DATA_CHARGING]: + intervals.append(charging_interval) + _LOGGER.debug("Could use charging interval=%s", + charging_interval) + + if self.data[DATA_CLIMATE]: + intervals.append(climate_interval) + _LOGGER.debug( + "Could use climate interval=%s", climate_interval) + + interval = min(intervals) + _LOGGER.debug("Resulting interval=%s", interval) + + return utcnow() + interval + + async def async_refresh_data(self, now): + """Refresh the leaf data and update the datastore.""" + from pycarwings2 import CarwingsError + + if self.request_in_progress: + _LOGGER.debug("Refresh currently in progress for %s", + self.leaf.nickname) + return + + _LOGGER.debug("Updating Nissan Leaf Data") + + self.last_check = datetime.today() + self.request_in_progress = True + + server_response = await self.async_get_battery() + + if server_response is not None: + _LOGGER.debug("Server Response: %s", server_response.__dict__) + + if server_response.answer['status'] == 200: + self.data[DATA_BATTERY] = server_response.battery_percent + + # pycarwings2 library doesn't always provide cruising rnages + # so we have to check if they exist before we can use them. + # Root cause: the nissan servers don't always send the data. + if hasattr(server_response, 'cruising_range_ac_on_km'): + self.data[DATA_RANGE_AC] = ( + server_response.cruising_range_ac_on_km + ) + else: + self.data[DATA_RANGE_AC] = None + + if hasattr(server_response, 'cruising_range_ac_off_km'): + self.data[DATA_RANGE_AC_OFF] = ( + server_response.cruising_range_ac_off_km + ) + else: + self.data[DATA_RANGE_AC_OFF] = None + + self.data[DATA_PLUGGED_IN] = ( + server_response.is_connected + ) + async_dispatcher_send(self.hass, SIGNAL_UPDATE_LEAF) + self.last_battery_response = utcnow() + + # Climate response only updated if battery data updated first. + if server_response is not None: + try: + climate_response = await self.async_get_climate() + if climate_response is not None: + _LOGGER.debug("Got climate data for Leaf: %s", + climate_response.__dict__) + self.data[DATA_CLIMATE] = climate_response.is_hvac_running + self.last_climate_response = utcnow() + except CarwingsError: + _LOGGER.error("Error fetching climate info") + + if self.nissan_connect: + try: + location_response = await self.async_get_location() + + if location_response is None: + _LOGGER.debug("Empty Location Response Received") + self.data[DATA_LOCATION] = None + else: + _LOGGER.debug("Got location data for Leaf") + self.data[DATA_LOCATION] = location_response + self.last_location_response = utcnow() + + _LOGGER.debug("Location Response: %s", + location_response.__dict__) + except CarwingsError: + _LOGGER.error("Error fetching location info") + + self.request_in_progress = False + async_dispatcher_send(self.hass, SIGNAL_UPDATE_LEAF) + + @staticmethod + def _extract_start_date(battery_info): + """Extract the server date from the battery response.""" + try: + return battery_info.answer[ + "BatteryStatusRecords"]["OperationDateAndTime"] + except KeyError: + return None + + async def async_get_battery(self): + """Request battery update from Nissan servers.""" + from pycarwings2 import CarwingsError + try: + # First, check nissan servers for the latest data + start_server_info = await self.hass.async_add_job( + self.leaf.get_latest_battery_status + ) + + # Store the date from the nissan servers + start_date = self._extract_start_date(start_server_info) + if start_date is None: + _LOGGER.info("No start date from servers. Aborting") + return None + + _LOGGER.info("Start server date=%s", start_date) + + # Request battery update from the car + _LOGGER.info("Requesting battery update, %s", self.leaf.vin) + request = await self.hass.async_add_job(self.leaf.request_update) + if not request: + _LOGGER.error("Battery update request failed") + return None + + for attempt in range(MAX_RESPONSE_ATTEMPTS): + _LOGGER.info("Waiting %s seconds for battery update (%s) (%s)", + PYCARWINGS2_SLEEP, self.leaf.vin, attempt) + await asyncio.sleep(PYCARWINGS2_SLEEP) + + # Note leaf.get_status_from_update is always returning 0, so + # don't try to use it anymore. + server_info = await self.hass.async_add_job( + self.leaf.get_latest_battery_status + ) + + latest_date = self._extract_start_date(server_info) + _LOGGER.info("Latest server date=%s", latest_date) + if latest_date is not None and latest_date != start_date: + return server_info + + _LOGGER.info("%s attempts exceeded return latest data from server", + MAX_RESPONSE_ATTEMPTS) + return server_info + except CarwingsError: + _LOGGER.error("An error occurred getting battery status.") + return None + + async def async_get_climate(self): + """Request climate data from Nissan servers.""" + from pycarwings2 import CarwingsError + try: + request = await self.hass.async_add_job( + self.leaf.get_latest_hvac_status + ) + return request + except CarwingsError: + _LOGGER.error( + "An error occurred communicating with the car %s", + self.leaf.vin) + return None + + async def async_set_climate(self, toggle): + """Set climate control mode via Nissan servers.""" + climate_result = None + if toggle: + _LOGGER.info("Requesting climate turn on for %s", self.leaf.vin) + request = await self.hass.async_add_job( + self.leaf.start_climate_control + ) + for attempt in range(MAX_RESPONSE_ATTEMPTS): + if attempt > 0: + _LOGGER.info("Climate data not in yet (%s) (%s). " + "Waiting (%s) seconds.", self.leaf.vin, + attempt, PYCARWINGS2_SLEEP) + await asyncio.sleep(PYCARWINGS2_SLEEP) + + climate_result = await self.hass.async_add_job( + self.leaf.get_start_climate_control_result, request + ) + + if climate_result is not None: + break + + else: + _LOGGER.info("Requesting climate turn off for %s", self.leaf.vin) + request = await self.hass.async_add_job( + self.leaf.stop_climate_control + ) + + for attempt in range(MAX_RESPONSE_ATTEMPTS): + if attempt > 0: + _LOGGER.debug("Climate data not in yet. (%s) (%s). " + "Waiting %s seconds", self.leaf.vin, + attempt, PYCARWINGS2_SLEEP) + await asyncio.sleep(PYCARWINGS2_SLEEP) + + climate_result = await self.hass.async_add_job( + self.leaf.get_stop_climate_control_result, request + ) + + if climate_result is not None: + break + + if climate_result is not None: + _LOGGER.debug("Climate result: %s", climate_result.__dict__) + async_dispatcher_send(self.hass, SIGNAL_UPDATE_LEAF) + return climate_result.is_hvac_running == toggle + + _LOGGER.debug("Climate result not returned by Nissan servers") + return False + + async def async_get_location(self): + """Get location from Nissan servers.""" + request = await self.hass.async_add_job(self.leaf.request_location) + for attempt in range(MAX_RESPONSE_ATTEMPTS): + if attempt > 0: + _LOGGER.debug("Location data not in yet. (%s) (%s). " + "Waiting %s seconds", self.leaf.vin, + attempt, PYCARWINGS2_SLEEP) + await asyncio.sleep(PYCARWINGS2_SLEEP) + + location_status = await self.hass.async_add_job( + self.leaf.get_status_from_location, request + ) + + if location_status is not None: + _LOGGER.debug("Location_status=%s", location_status.__dict__) + break + + return location_status + + async def async_start_charging(self): + """Request start charging via Nissan servers.""" + # Send the command to request charging is started to Nissan servers. + # If that completes OK then trigger a fresh update to pull the + # charging status from the car after waiting a minute for the + # charging request to reach the car. + result = await self.hass.async_add_job(self.leaf.start_charging) + if result: + _LOGGER.debug("Start charging sent, " + "request updated data in 1 minute") + check_charge_at = utcnow() + timedelta(minutes=1) + self.next_update = check_charge_at + async_track_point_in_utc_time( + self.hass, self.async_update_data, check_charge_at) + + +class LeafEntity(Entity): + """Base class for Nissan Leaf entity.""" + + def __init__(self, car): + """Store LeafDataStore upon init.""" + self.car = car + + def log_registration(self): + """Log registration.""" + _LOGGER.debug( + "Registered %s component for VIN %s", + self.__class__.__name__, self.car.leaf.vin) + + @property + def device_state_attributes(self): + """Return default attributes for Nissan leaf entities.""" + return { + 'next_update': self.car.next_update, + 'last_attempt': self.car.last_check, + 'updated_on': self.car.last_battery_response, + 'update_in_progress': self.car.request_in_progress, + 'location_updated_on': self.car.last_location_response, + 'vin': self.car.leaf.vin, + } + + async def async_added_to_hass(self): + """Register callbacks.""" + self.log_registration() + async_dispatcher_connect( + self.car.hass, SIGNAL_UPDATE_LEAF, self._update_callback) + + def _update_callback(self): + """Update the state.""" + self.schedule_update_ha_state(True) diff --git a/homeassistant/components/nissan_leaf/binary_sensor.py b/homeassistant/components/nissan_leaf/binary_sensor.py new file mode 100644 index 00000000000..05255d616c4 --- /dev/null +++ b/homeassistant/components/nissan_leaf/binary_sensor.py @@ -0,0 +1,44 @@ +"""Plugged In Status Support for the Nissan Leaf.""" +import logging + +from homeassistant.components.nissan_leaf import ( + DATA_LEAF, DATA_PLUGGED_IN, LeafEntity) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['nissan_leaf'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up of a Nissan Leaf binary sensor.""" + _LOGGER.debug( + "binary_sensor setup_platform, discovery_info=%s", discovery_info) + + devices = [] + for key, value in hass.data[DATA_LEAF].items(): + _LOGGER.debug( + "binary_sensor setup_platform, key=%s, value=%s", key, value) + devices.append(LeafPluggedInSensor(value)) + + add_devices(devices, True) + + +class LeafPluggedInSensor(LeafEntity): + """Plugged In Sensor class.""" + + @property + def name(self): + """Sensor name.""" + return "{} {}".format(self.car.leaf.nickname, "Plug Status") + + @property + def state(self): + """Return true if plugged in.""" + return self.car.data[DATA_PLUGGED_IN] + + @property + def icon(self): + """Icon handling.""" + if self.car.data[DATA_PLUGGED_IN]: + return 'mdi:power-plug' + return 'mdi:power-plug-off' diff --git a/homeassistant/components/nissan_leaf/device_tracker.py b/homeassistant/components/nissan_leaf/device_tracker.py new file mode 100644 index 00000000000..163675319ea --- /dev/null +++ b/homeassistant/components/nissan_leaf/device_tracker.py @@ -0,0 +1,46 @@ +"""Support for tracking a Nissan Leaf.""" +import logging + +from homeassistant.components.nissan_leaf import ( + DATA_LEAF, DATA_LOCATION, SIGNAL_UPDATE_LEAF) +from homeassistant.helpers.dispatcher import dispatcher_connect +from homeassistant.util import slugify + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['nissan_leaf'] + +ICON_CAR = "mdi:car" + + +def setup_scanner(hass, config, see, discovery_info=None): + """Set up the Nissan Leaf tracker.""" + _LOGGER.debug("Setting up Scanner (device_tracker) for Nissan Leaf, " + "discovery_info=%s", discovery_info) + + def see_vehicle(): + """Handle the reporting of the vehicle position.""" + for key, value in hass.data[DATA_LEAF].items(): + host_name = value.leaf.nickname + dev_id = 'nissan_leaf_{}'.format(slugify(host_name)) + if not value.data[DATA_LOCATION]: + _LOGGER.debug("No position found for vehicle %s", key) + return False + _LOGGER.debug("Updating device_tracker for %s with position %s", + value.leaf.nickname, + value.data[DATA_LOCATION].__dict__) + attrs = { + 'updated_on': value.last_location_response, + } + see(dev_id=dev_id, + host_name=host_name, + gps=( + value.data[DATA_LOCATION].latitude, + value.data[DATA_LOCATION].longitude + ), + attributes=attrs, + icon=ICON_CAR) + + dispatcher_connect(hass, SIGNAL_UPDATE_LEAF, see_vehicle) + + return True diff --git a/homeassistant/components/nissan_leaf/sensor.py b/homeassistant/components/nissan_leaf/sensor.py new file mode 100644 index 00000000000..3c8f9ab9ef3 --- /dev/null +++ b/homeassistant/components/nissan_leaf/sensor.py @@ -0,0 +1,113 @@ +"""Battery Charge and Range Support for the Nissan Leaf.""" +import logging + +from homeassistant.components.nissan_leaf import ( + DATA_BATTERY, DATA_CHARGING, DATA_LEAF, DATA_RANGE_AC, DATA_RANGE_AC_OFF, + LeafEntity) +from homeassistant.const import DEVICE_CLASS_BATTERY +from homeassistant.helpers.icon import icon_for_battery_level +from homeassistant.util.distance import LENGTH_KILOMETERS, LENGTH_MILES +from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['nissan_leaf'] + +ICON_RANGE = 'mdi:speedometer' + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Sensors setup.""" + _LOGGER.debug("setup_platform nissan_leaf sensors, discovery_info=%s", + discovery_info) + + devices = [] + for key, value in hass.data[DATA_LEAF].items(): + _LOGGER.debug("adding sensor for item key=%s, value=%s", key, value) + devices.append(LeafBatterySensor(value)) + devices.append(LeafRangeSensor(value, True)) + devices.append(LeafRangeSensor(value, False)) + + add_devices(devices, True) + + +class LeafBatterySensor(LeafEntity): + """Nissan Leaf Battery Sensor.""" + + @property + def name(self): + """Sensor Name.""" + return self.car.leaf.nickname + " Charge" + + @property + def device_class(self): + """Return the device class of the sensor.""" + return DEVICE_CLASS_BATTERY + + @property + def state(self): + """Battery state percentage.""" + return round(self.car.data[DATA_BATTERY]) + + @property + def unit_of_measurement(self): + """Battery state measured in percentage.""" + return '%' + + @property + def icon(self): + """Battery state icon handling.""" + chargestate = self.car.data[DATA_CHARGING] + return icon_for_battery_level( + battery_level=self.state, + charging=chargestate + ) + + +class LeafRangeSensor(LeafEntity): + """Nissan Leaf Range Sensor.""" + + def __init__(self, car, ac_on): + """Set-up range sensor. Store if AC on.""" + self._ac_on = ac_on + super().__init__(car) + + @property + def name(self): + """Update sensor name depending on AC.""" + if self._ac_on is True: + return self.car.leaf.nickname + " Range (AC)" + return self.car.leaf.nickname + " Range" + + def log_registration(self): + """Log registration.""" + _LOGGER.debug( + "Registered LeafRangeSensor component with HASS for VIN %s", + self.car.leaf.vin) + + @property + def state(self): + """Battery range in miles or kms.""" + if self._ac_on: + ret = self.car.data[DATA_RANGE_AC] + else: + ret = self.car.data[DATA_RANGE_AC_OFF] + + if (not self.car.hass.config.units.is_metric or + self.car.force_miles): + ret = IMPERIAL_SYSTEM.length(ret, METRIC_SYSTEM.length_unit) + + return round(ret) + + @property + def unit_of_measurement(self): + """Battery range unit.""" + if (not self.car.hass.config.units.is_metric or + self.car.force_miles): + return LENGTH_MILES + return LENGTH_KILOMETERS + + @property + def icon(self): + """Nice icon for range.""" + return ICON_RANGE diff --git a/homeassistant/components/nissan_leaf/switch.py b/homeassistant/components/nissan_leaf/switch.py new file mode 100644 index 00000000000..914e85b48a6 --- /dev/null +++ b/homeassistant/components/nissan_leaf/switch.py @@ -0,0 +1,99 @@ +"""Charge and Climate Control Support for the Nissan Leaf.""" +import logging + +from homeassistant.components.nissan_leaf import ( + DATA_CHARGING, DATA_CLIMATE, DATA_LEAF, LeafEntity) +from homeassistant.helpers.entity import ToggleEntity + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['nissan_leaf'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Nissan Leaf switch platform setup.""" + _LOGGER.debug( + "In switch setup platform, discovery_info=%s", discovery_info) + + devices = [] + for value in hass.data[DATA_LEAF].values(): + devices.append(LeafChargeSwitch(value)) + devices.append(LeafClimateSwitch(value)) + + add_devices(devices, True) + + +class LeafClimateSwitch(LeafEntity, ToggleEntity): + """Nissan Leaf Climate Control switch.""" + + @property + def name(self): + """Switch name.""" + return "{} {}".format(self.car.leaf.nickname, "Climate Control") + + def log_registration(self): + """Log registration.""" + _LOGGER.debug( + "Registered LeafClimateSwitch component with HASS for VIN %s", + self.car.leaf.vin) + + @property + def device_state_attributes(self): + """Return climate control attributes.""" + attrs = super(LeafClimateSwitch, self).device_state_attributes + attrs["updated_on"] = self.car.last_climate_response + return attrs + + @property + def is_on(self): + """Return true if climate control is on.""" + return self.car.data[DATA_CLIMATE] + + async def async_turn_on(self, **kwargs): + """Turn on climate control.""" + if await self.car.async_set_climate(True): + self.car.data[DATA_CLIMATE] = True + + async def async_turn_off(self, **kwargs): + """Turn off climate control.""" + if await self.car.async_set_climate(False): + self.car.data[DATA_CLIMATE] = False + + @property + def icon(self): + """Climate control icon.""" + if self.car.data[DATA_CLIMATE]: + return 'mdi:fan' + return 'mdi:fan-off' + + +class LeafChargeSwitch(LeafEntity, ToggleEntity): + """Nissan Leaf Charging On switch.""" + + @property + def name(self): + """Switch name.""" + return "{} {}".format(self.car.leaf.nickname, "Charging Status") + + @property + def icon(self): + """Charging switch icon.""" + if self.car.data[DATA_CHARGING]: + return 'mdi:flash' + return 'mdi:flash-off' + + @property + def is_on(self): + """Return true if charging.""" + return self.car.data[DATA_CHARGING] + + async def async_turn_on(self, **kwargs): + """Start car charging.""" + if await self.car.async_start_charging(): + self.car.data[DATA_CHARGING] = True + + def turn_off(self, **kwargs): + """Nissan API doesn't allow stopping of charge remotely.""" + _LOGGER.info( + "Cannot turn off Leaf charging." + " Nissan API does not support stopping charge remotely") diff --git a/requirements_all.txt b/requirements_all.txt index 9061348a41c..eba370c2e4e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -949,6 +949,9 @@ pyblackbird==0.5 # homeassistant.components.neato pybotvac==0.0.13 +# homeassistant.components.nissan_leaf +pycarwings2==2.8 + # homeassistant.components.cloudflare pycfdns==0.0.1