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 changespull/21094/head
parent
7b19428279
commit
656d39e3ec
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
|
@ -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'
|
|
@ -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
|
|
@ -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
|
|
@ -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")
|
|
@ -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
|
||||
|
||||
|
|
Loading…
Reference in New Issue