Add type hints to nissan_leaf integration (#62967)

This commit is contained in:
Phil Cole 2021-12-29 11:23:54 +00:00 committed by GitHub
parent 2df0adfbc7
commit 54d1e20948
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 100 additions and 64 deletions

View file

@ -93,6 +93,7 @@ homeassistant.components.nest.*
homeassistant.components.netatmo.* homeassistant.components.netatmo.*
homeassistant.components.network.* homeassistant.components.network.*
homeassistant.components.nfandroidtv.* homeassistant.components.nfandroidtv.*
homeassistant.components.nissan_leaf.*
homeassistant.components.no_ip.* homeassistant.components.no_ip.*
homeassistant.components.notify.* homeassistant.components.notify.*
homeassistant.components.notion.* homeassistant.components.notion.*

View file

@ -1,15 +1,22 @@
"""Support for the Nissan Leaf Carwings/Nissan Connect API.""" """Support for the Nissan Leaf Carwings/Nissan Connect API."""
from __future__ import annotations
import asyncio import asyncio
from datetime import datetime, timedelta from datetime import datetime, timedelta
from http import HTTPStatus from http import HTTPStatus
import logging import logging
import sys import sys
from typing import Any, cast
from pycarwings2 import CarwingsError, Session from pycarwings2 import CarwingsError, Leaf, Session
from pycarwings2.responses import (
CarwingsLatestBatteryStatusResponse,
CarwingsLatestClimateControlStatusResponse,
)
import voluptuous as vol import voluptuous as vol
from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME, Platform from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME, Platform
from homeassistant.core import callback from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall, callback
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.discovery import load_platform
from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.dispatcher import (
@ -18,6 +25,7 @@ from homeassistant.helpers.dispatcher import (
) )
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.dt import utcnow from homeassistant.util.dt import utcnow
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -94,10 +102,10 @@ UPDATE_LEAF_SCHEMA = vol.Schema({vol.Required(ATTR_VIN): cv.string})
START_CHARGE_LEAF_SCHEMA = vol.Schema({vol.Required(ATTR_VIN): cv.string}) START_CHARGE_LEAF_SCHEMA = vol.Schema({vol.Required(ATTR_VIN): cv.string})
def setup(hass, config): def setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Nissan Leaf integration.""" """Set up the Nissan Leaf integration."""
async def async_handle_update(service): async def async_handle_update(service: ServiceCall) -> None:
"""Handle service to update leaf data from Nissan servers.""" """Handle service to update leaf data from Nissan servers."""
# It would be better if this was changed to use nickname, or # It would be better if this was changed to use nickname, or
# an entity name rather than a vin. # an entity name rather than a vin.
@ -109,7 +117,7 @@ def setup(hass, config):
else: else:
_LOGGER.debug("Vin %s not recognised for update", vin) _LOGGER.debug("Vin %s not recognised for update", vin)
async def async_handle_start_charge(service): async def async_handle_start_charge(service: ServiceCall) -> None:
"""Handle service to start charging.""" """Handle service to start charging."""
# It would be better if this was changed to use nickname, or # It would be better if this was changed to use nickname, or
# an entity name rather than a vin. # an entity name rather than a vin.
@ -134,14 +142,13 @@ def setup(hass, config):
else: else:
_LOGGER.debug("Vin %s not recognised for update", vin) _LOGGER.debug("Vin %s not recognised for update", vin)
def setup_leaf(car_config): def setup_leaf(car_config: dict[str, Any]) -> None:
"""Set up a car.""" """Set up a car."""
_LOGGER.debug("Logging into You+Nissan") _LOGGER.debug("Logging into You+Nissan")
username = car_config[CONF_USERNAME] username: str = car_config[CONF_USERNAME]
password = car_config[CONF_PASSWORD] password: str = car_config[CONF_PASSWORD]
region = car_config[CONF_REGION] region: str = car_config[CONF_REGION]
leaf = None
try: try:
# This might need to be made async (somehow) causes # This might need to be made async (somehow) causes
@ -153,13 +160,13 @@ def setup(hass, config):
"Unable to fetch car details..." "Unable to fetch car details..."
" do you actually have a Leaf connected to your account?" " do you actually have a Leaf connected to your account?"
) )
return False return
except CarwingsError: except CarwingsError:
_LOGGER.error( _LOGGER.error(
"An unknown error occurred while connecting to Nissan: %s", "An unknown error occurred while connecting to Nissan: %s",
sys.exc_info()[0], sys.exc_info()[0],
) )
return False return
_LOGGER.warning( _LOGGER.warning(
"WARNING: This may poll your Leaf too often, and drain the 12V" "WARNING: This may poll your Leaf too often, and drain the 12V"
@ -195,10 +202,15 @@ def setup(hass, config):
return True return True
def _extract_start_date(battery_info): def _extract_start_date(
battery_info: CarwingsLatestBatteryStatusResponse,
) -> datetime | None:
"""Extract the server date from the battery response.""" """Extract the server date from the battery response."""
try: try:
return battery_info.answer["BatteryStatusRecords"]["OperationDateAndTime"] return cast(
datetime,
battery_info.answer["BatteryStatusRecords"]["OperationDateAndTime"],
)
except KeyError: except KeyError:
return None return None
@ -206,28 +218,30 @@ def _extract_start_date(battery_info):
class LeafDataStore: class LeafDataStore:
"""Nissan Leaf Data Store.""" """Nissan Leaf Data Store."""
def __init__(self, hass, leaf, car_config): def __init__(
self, hass: HomeAssistant, leaf: Leaf, car_config: dict[str, Any]
) -> None:
"""Initialise the data store.""" """Initialise the data store."""
self.hass = hass self.hass = hass
self.leaf = leaf self.leaf = leaf
self.car_config = car_config self.car_config = car_config
self.force_miles = car_config[CONF_FORCE_MILES] self.force_miles = car_config[CONF_FORCE_MILES]
self.data = {} self.data: dict[str, Any] = {}
self.data[DATA_CLIMATE] = None self.data[DATA_CLIMATE] = None
self.data[DATA_BATTERY] = None self.data[DATA_BATTERY] = None
self.data[DATA_CHARGING] = None self.data[DATA_CHARGING] = None
self.data[DATA_RANGE_AC] = None self.data[DATA_RANGE_AC] = None
self.data[DATA_RANGE_AC_OFF] = None self.data[DATA_RANGE_AC_OFF] = None
self.data[DATA_PLUGGED_IN] = None self.data[DATA_PLUGGED_IN] = None
self.next_update = None self.next_update: datetime | None = None
self.last_check = None self.last_check: datetime | None = None
self.request_in_progress = False self.request_in_progress: bool = False
# Timestamp of last successful response from battery or climate. # Timestamp of last successful response from battery or climate.
self.last_battery_response = None self.last_battery_response: datetime | None = None
self.last_climate_response = None self.last_climate_response: datetime | None = None
self._remove_listener = None self._remove_listener: CALLBACK_TYPE | None = None
async def async_update_data(self, now): async def async_update_data(self, now: datetime) -> None:
"""Update data from nissan leaf.""" """Update data from nissan leaf."""
# Prevent against a previously scheduled update and an ad-hoc update # Prevent against a previously scheduled update and an ad-hoc update
# started from an update from both being triggered. # started from an update from both being triggered.
@ -241,11 +255,13 @@ class LeafDataStore:
await self.async_refresh_data(now) await self.async_refresh_data(now)
self.next_update = self.get_next_interval() self.next_update = self.get_next_interval()
_LOGGER.debug("Next update=%s", self.next_update) _LOGGER.debug("Next update=%s", self.next_update)
if self.next_update is not None:
self._remove_listener = async_track_point_in_utc_time( self._remove_listener = async_track_point_in_utc_time(
self.hass, self.async_update_data, self.next_update self.hass, self.async_update_data, self.next_update
) )
def get_next_interval(self): def get_next_interval(self) -> datetime:
"""Calculate when the next update should occur.""" """Calculate when the next update should occur."""
base_interval = self.car_config[CONF_INTERVAL] base_interval = self.car_config[CONF_INTERVAL]
climate_interval = self.car_config[CONF_CLIMATE_INTERVAL] climate_interval = self.car_config[CONF_CLIMATE_INTERVAL]
@ -278,7 +294,7 @@ class LeafDataStore:
return utcnow() + interval return utcnow() + interval
async def async_refresh_data(self, now): async def async_refresh_data(self, now: datetime) -> None:
"""Refresh the leaf data and update the datastore.""" """Refresh the leaf data and update the datastore."""
if self.request_in_progress: if self.request_in_progress:
_LOGGER.debug("Refresh currently in progress for %s", self.leaf.nickname) _LOGGER.debug("Refresh currently in progress for %s", self.leaf.nickname)
@ -333,12 +349,14 @@ class LeafDataStore:
self.request_in_progress = False self.request_in_progress = False
async_dispatcher_send(self.hass, SIGNAL_UPDATE_LEAF) async_dispatcher_send(self.hass, SIGNAL_UPDATE_LEAF)
async def async_get_battery(self): async def async_get_battery(
self,
) -> CarwingsLatestBatteryStatusResponse:
"""Request battery update from Nissan servers.""" """Request battery update from Nissan servers."""
try: try:
# Request battery update from the car # Request battery update from the car
_LOGGER.debug("Requesting battery update, %s", self.leaf.vin) _LOGGER.debug("Requesting battery update, %s", self.leaf.vin)
start_date = None start_date: datetime | None = None
try: try:
start_server_info = await self.hass.async_add_executor_job( start_server_info = await self.hass.async_add_executor_job(
self.leaf.get_latest_battery_status self.leaf.get_latest_battery_status
@ -409,7 +427,9 @@ class LeafDataStore:
_LOGGER.error("An error occurred parsing response from server") _LOGGER.error("An error occurred parsing response from server")
return None return None
async def async_get_climate(self): async def async_get_climate(
self,
) -> CarwingsLatestClimateControlStatusResponse:
"""Request climate data from Nissan servers.""" """Request climate data from Nissan servers."""
try: try:
return await self.hass.async_add_executor_job( return await self.hass.async_add_executor_job(
@ -421,7 +441,7 @@ class LeafDataStore:
) )
return None return None
async def async_set_climate(self, toggle): async def async_set_climate(self, toggle: bool) -> bool:
"""Set climate control mode via Nissan servers.""" """Set climate control mode via Nissan servers."""
climate_result = None climate_result = None
if toggle: if toggle:
@ -454,7 +474,7 @@ class LeafDataStore:
if climate_result is not None: if climate_result is not None:
_LOGGER.debug("Climate result: %s", climate_result.__dict__) _LOGGER.debug("Climate result: %s", climate_result.__dict__)
async_dispatcher_send(self.hass, SIGNAL_UPDATE_LEAF) async_dispatcher_send(self.hass, SIGNAL_UPDATE_LEAF)
return climate_result.is_hvac_running == toggle return bool(climate_result.is_hvac_running) == toggle
_LOGGER.debug("Climate result not returned by Nissan servers") _LOGGER.debug("Climate result not returned by Nissan servers")
return False return False
@ -463,11 +483,11 @@ class LeafDataStore:
class LeafEntity(Entity): class LeafEntity(Entity):
"""Base class for Nissan Leaf entity.""" """Base class for Nissan Leaf entity."""
def __init__(self, car): def __init__(self, car: Leaf) -> None:
"""Store LeafDataStore upon init.""" """Store LeafDataStore upon init."""
self.car = car self.car = car
def log_registration(self): def log_registration(self) -> None:
"""Log registration.""" """Log registration."""
_LOGGER.debug( _LOGGER.debug(
"Registered %s integration for VIN %s", "Registered %s integration for VIN %s",
@ -476,7 +496,7 @@ class LeafEntity(Entity):
) )
@property @property
def extra_state_attributes(self): def extra_state_attributes(self) -> dict[str, Any]:
"""Return default attributes for Nissan leaf entities.""" """Return default attributes for Nissan leaf entities."""
return { return {
"next_update": self.car.next_update, "next_update": self.car.next_update,
@ -486,7 +506,7 @@ class LeafEntity(Entity):
"vin": self.car.leaf.vin, "vin": self.car.leaf.vin,
} }
async def async_added_to_hass(self): async def async_added_to_hass(self) -> None:
"""Register callbacks.""" """Register callbacks."""
self.log_registration() self.log_registration()
self.async_on_remove( self.async_on_remove(
@ -496,6 +516,6 @@ class LeafEntity(Entity):
) )
@callback @callback
def _update_callback(self): def _update_callback(self) -> None:
"""Update the state.""" """Update the state."""
self.async_schedule_update_ha_state(True) self.async_schedule_update_ha_state(True)

View file

@ -41,19 +41,19 @@ class LeafPluggedInSensor(LeafEntity, BinarySensorEntity):
_attr_device_class = BinarySensorDeviceClass.PLUG _attr_device_class = BinarySensorDeviceClass.PLUG
@property @property
def name(self): def name(self) -> str:
"""Sensor name.""" """Sensor name."""
return f"{self.car.leaf.nickname} Plug Status" return f"{self.car.leaf.nickname} Plug Status"
@property @property
def available(self): def available(self) -> bool:
"""Sensor availability.""" """Sensor availability."""
return self.car.data[DATA_PLUGGED_IN] is not None return self.car.data[DATA_PLUGGED_IN] is not None
@property @property
def is_on(self): def is_on(self) -> bool:
"""Return true if plugged in.""" """Return true if plugged in."""
return self.car.data[DATA_PLUGGED_IN] return bool(self.car.data[DATA_PLUGGED_IN])
class LeafChargingSensor(LeafEntity, BinarySensorEntity): class LeafChargingSensor(LeafEntity, BinarySensorEntity):
@ -62,16 +62,16 @@ class LeafChargingSensor(LeafEntity, BinarySensorEntity):
_attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING _attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING
@property @property
def name(self): def name(self) -> str:
"""Sensor name.""" """Sensor name."""
return f"{self.car.leaf.nickname} Charging Status" return f"{self.car.leaf.nickname} Charging Status"
@property @property
def available(self): def available(self) -> bool:
"""Sensor availability.""" """Sensor availability."""
return self.car.data[DATA_CHARGING] is not None return self.car.data[DATA_CHARGING] is not None
@property @property
def is_on(self): def is_on(self) -> bool:
"""Return true if charging.""" """Return true if charging."""
return self.car.data[DATA_CHARGING] return bool(self.car.data[DATA_CHARGING])

View file

@ -3,6 +3,9 @@ from __future__ import annotations
import logging import logging
from pycarwings2.pycarwings2 import Leaf
from voluptuous.validators import Number
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.const import PERCENTAGE from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -50,29 +53,29 @@ class LeafBatterySensor(LeafEntity, SensorEntity):
"""Nissan Leaf Battery Sensor.""" """Nissan Leaf Battery Sensor."""
@property @property
def name(self): def name(self) -> str:
"""Sensor Name.""" """Sensor Name."""
return f"{self.car.leaf.nickname} Charge" return f"{self.car.leaf.nickname} Charge"
@property @property
def device_class(self): def device_class(self) -> str:
"""Return the device class of the sensor.""" """Return the device class of the sensor."""
return SensorDeviceClass.BATTERY return SensorDeviceClass.BATTERY
@property @property
def native_value(self): def native_value(self) -> Number | None:
"""Battery state percentage.""" """Battery state percentage."""
if self.car.data[DATA_BATTERY] is None: if self.car.data[DATA_BATTERY] is None:
return None return None
return round(self.car.data[DATA_BATTERY]) return round(self.car.data[DATA_BATTERY])
@property @property
def native_unit_of_measurement(self): def native_unit_of_measurement(self) -> str:
"""Battery state measured in percentage.""" """Battery state measured in percentage."""
return PERCENTAGE return PERCENTAGE
@property @property
def icon(self): def icon(self) -> str:
"""Battery state icon handling.""" """Battery state icon handling."""
chargestate = self.car.data[DATA_CHARGING] chargestate = self.car.data[DATA_CHARGING]
return icon_for_battery_level(battery_level=self.state, charging=chargestate) return icon_for_battery_level(battery_level=self.state, charging=chargestate)
@ -81,19 +84,19 @@ class LeafBatterySensor(LeafEntity, SensorEntity):
class LeafRangeSensor(LeafEntity, SensorEntity): class LeafRangeSensor(LeafEntity, SensorEntity):
"""Nissan Leaf Range Sensor.""" """Nissan Leaf Range Sensor."""
def __init__(self, car, ac_on): def __init__(self, car: Leaf, ac_on: bool) -> None:
"""Set up range sensor. Store if AC on.""" """Set up range sensor. Store if AC on."""
self._ac_on = ac_on self._ac_on = ac_on
super().__init__(car) super().__init__(car)
@property @property
def name(self): def name(self) -> str:
"""Update sensor name depending on AC.""" """Update sensor name depending on AC."""
if self._ac_on is True: if self._ac_on is True:
return f"{self.car.leaf.nickname} Range (AC)" return f"{self.car.leaf.nickname} Range (AC)"
return f"{self.car.leaf.nickname} Range" return f"{self.car.leaf.nickname} Range"
def log_registration(self): def log_registration(self) -> None:
"""Log registration.""" """Log registration."""
_LOGGER.debug( _LOGGER.debug(
"Registered LeafRangeSensor integration with Home Assistant for VIN %s", "Registered LeafRangeSensor integration with Home Assistant for VIN %s",
@ -101,7 +104,7 @@ class LeafRangeSensor(LeafEntity, SensorEntity):
) )
@property @property
def native_value(self): def native_value(self) -> float | None:
"""Battery range in miles or kms.""" """Battery range in miles or kms."""
if self._ac_on: if self._ac_on:
ret = self.car.data[DATA_RANGE_AC] ret = self.car.data[DATA_RANGE_AC]
@ -117,13 +120,13 @@ class LeafRangeSensor(LeafEntity, SensorEntity):
return round(ret) return round(ret)
@property @property
def native_unit_of_measurement(self): def native_unit_of_measurement(self) -> str:
"""Battery range unit.""" """Battery range unit."""
if not self.car.hass.config.units.is_metric or self.car.force_miles: if not self.car.hass.config.units.is_metric or self.car.force_miles:
return LENGTH_MILES return LENGTH_MILES
return LENGTH_KILOMETERS return LENGTH_KILOMETERS
@property @property
def icon(self): def icon(self) -> str:
"""Nice icon for range.""" """Nice icon for range."""
return ICON_RANGE return ICON_RANGE

View file

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity import ToggleEntity
@ -35,11 +36,11 @@ class LeafClimateSwitch(LeafEntity, ToggleEntity):
"""Nissan Leaf Climate Control switch.""" """Nissan Leaf Climate Control switch."""
@property @property
def name(self): def name(self) -> str:
"""Switch name.""" """Switch name."""
return f"{self.car.leaf.nickname} Climate Control" return f"{self.car.leaf.nickname} Climate Control"
def log_registration(self): def log_registration(self) -> None:
"""Log registration.""" """Log registration."""
_LOGGER.debug( _LOGGER.debug(
"Registered LeafClimateSwitch integration with Home Assistant for VIN %s", "Registered LeafClimateSwitch integration with Home Assistant for VIN %s",
@ -47,23 +48,23 @@ class LeafClimateSwitch(LeafEntity, ToggleEntity):
) )
@property @property
def extra_state_attributes(self): def extra_state_attributes(self) -> dict[str, Any]:
"""Return climate control attributes.""" """Return climate control attributes."""
attrs = super().extra_state_attributes attrs = super().extra_state_attributes
attrs["updated_on"] = self.car.last_climate_response attrs["updated_on"] = self.car.last_climate_response
return attrs return attrs
@property @property
def is_on(self): def is_on(self) -> bool:
"""Return true if climate control is on.""" """Return true if climate control is on."""
return self.car.data[DATA_CLIMATE] return bool(self.car.data[DATA_CLIMATE])
async def async_turn_on(self, **kwargs): async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on climate control.""" """Turn on climate control."""
if await self.car.async_set_climate(True): if await self.car.async_set_climate(True):
self.car.data[DATA_CLIMATE] = True self.car.data[DATA_CLIMATE] = True
async def async_turn_off(self, **kwargs): async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off climate control.""" """Turn off climate control."""
if await self.car.async_set_climate(False): if await self.car.async_set_climate(False):
self.car.data[DATA_CLIMATE] = False self.car.data[DATA_CLIMATE] = False

View file

@ -1034,6 +1034,17 @@ no_implicit_optional = true
warn_return_any = true warn_return_any = true
warn_unreachable = true warn_unreachable = true
[mypy-homeassistant.components.nissan_leaf.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
no_implicit_optional = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.no_ip.*] [mypy-homeassistant.components.no_ip.*]
check_untyped_defs = true check_untyped_defs = true
disallow_incomplete_defs = true disallow_incomplete_defs = true