diff --git a/.travis.yml b/.travis.yml index 95dd0d5..ab4dd09 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,6 @@ language: python python: - - "3.8" + - "3.9" install: # Attempt to work around Home Assistant not being declared through PEP 561 # Details: https://github.com/home-assistant/core/pull/28866#pullrequestreview-319309922 diff --git a/custom_components/hildebrandglow/__init__.py b/custom_components/hildebrandglow/__init__.py index a6bcbe4..47965c2 100644 --- a/custom_components/hildebrandglow/__init__.py +++ b/custom_components/hildebrandglow/__init__.py @@ -1,20 +1,24 @@ """The Hildebrand Glow integration.""" import asyncio -import logging from typing import Any, Dict +import async_timeout import voluptuous as vol +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + InvalidStateError, +) -from .const import APP_ID, DOMAIN -from .glow import Glow, InvalidAuth, NoCADAvailable - -_LOGGER = logging.getLogger(__name__) +from .const import APP_ID, DOMAIN, GLOW_SESSION, LOGGER +from .glow import CannotConnect, Glow, InvalidAuth, NoCADAvailable CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) -PLATFORMS = ["sensor"] +PLATFORMS = (SENSOR_DOMAIN,) async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool: @@ -26,47 +30,74 @@ async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Hildebrand Glow from a config entry.""" - glow = Glow(APP_ID, entry.data["username"], entry.data["password"]) + glow = Glow( + APP_ID, + entry.data["username"], + entry.data["password"], + ) try: - await hass.async_add_executor_job(glow.authenticate) - await hass.async_add_executor_job(glow.retrieve_cad_hardwareId) - await hass.async_add_executor_job(glow.connect_mqtt) + if not await async_connect_or_timeout(hass, glow): + return False + except CannotConnect as err: + raise ConfigEntryNotReady from err + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = {GLOW_SESSION: glow} + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + if not entry.update_listeners: + entry.add_update_listener(async_update_options) + + return True - while not glow.broker_active: - continue - except InvalidAuth: - _LOGGER.error("Couldn't login with the provided username/password.") +async def async_connect_or_timeout(hass: HomeAssistant, glow: Glow) -> bool: + """Connect from Glow.""" + try: + async with async_timeout.timeout(10): + LOGGER.debug("Initialize connection from Glow") + + await hass.async_add_executor_job(glow.authenticate) + await hass.async_add_executor_job(glow.retrieve_cad_hardwareId) + await hass.async_add_executor_job(glow.connect_mqtt) - return False + while not glow.broker_active: + await asyncio.sleep(1) + except InvalidAuth as err: + LOGGER.error("Couldn't login with the provided username/password") + raise ConfigEntryAuthFailed from err - except NoCADAvailable: - _LOGGER.error("Couldn't find any CAD devices (e.g. Glow Stick)") + except NoCADAvailable as err: + LOGGER.error("Couldn't find any CAD devices (e.g. Glow Stick)") + raise InvalidStateError from err - return False + except asyncio.TimeoutError as err: + await async_disconnect_or_timeout(hass, glow) + LOGGER.debug("Timeout expired: %s", err) + raise CannotConnect from err - hass.data[DOMAIN][entry.entry_id] = glow + return True - for component in PLATFORMS: - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, component) - ) +async def async_disconnect_or_timeout(hass: HomeAssistant, glow: Glow) -> bool: + """Disconnect from Glow.""" + LOGGER.debug("Disconnect from Glow") + async with async_timeout.timeout(3): + await hass.async_add_executor_job(glow.disconnect) return True +async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update options.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" - unload_ok = all( - await asyncio.gather( - *[ - hass.config_entries.async_forward_entry_unload(entry, component) - for component in PLATFORMS - ] - ) - ) + """Unload Hildebrand Glow config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - + coordinator = hass.data[DOMAIN].pop(entry.entry_id) + await coordinator[GLOW_SESSION].disconnect() return unload_ok diff --git a/custom_components/hildebrandglow/const.py b/custom_components/hildebrandglow/const.py index 1d2b3e3..060afd3 100644 --- a/custom_components/hildebrandglow/const.py +++ b/custom_components/hildebrandglow/const.py @@ -1,4 +1,10 @@ """Constants for the Hildebrand Glow integration.""" +import logging +from typing import Final -DOMAIN = "hildebrandglow" -APP_ID = "b0f1b774-a586-4f72-9edd-27ead8aa7a8d" +DOMAIN: Final = "hildebrandglow" +APP_ID: Final = "b0f1b774-a586-4f72-9edd-27ead8aa7a8d" + +LOGGER = logging.getLogger(__package__) + +GLOW_SESSION: Final = "glow_session" diff --git a/custom_components/hildebrandglow/glow.py b/custom_components/hildebrandglow/glow/__init__.py similarity index 90% rename from custom_components/hildebrandglow/glow.py rename to custom_components/hildebrandglow/glow/__init__.py index 371f9a4..df87a41 100644 --- a/custom_components/hildebrandglow/glow.py +++ b/custom_components/hildebrandglow/glow/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations from pprint import pprint -from typing import TYPE_CHECKING, Any, Dict, List +from typing import Any, Callable, Dict, List import paho.mqtt.client as mqtt import requests @@ -10,8 +10,7 @@ from .mqttpayload import MQTTPayload -if TYPE_CHECKING: - from .sensor import GlowConsumptionCurrent +from .glowdata import SmartMeter # isort:skip class Glow: @@ -29,7 +28,9 @@ class Glow: hardwareId: str broker: mqtt.Client - sensors: Dict[str, GlowConsumptionCurrent] = {} + data: SmartMeter = SmartMeter(None, None, None) + + callbacks: List[Callable] = [] def __init__(self, app_id: str, username: str, password: str): """Create an authenticated Glow object.""" @@ -110,6 +111,10 @@ def connect_mqtt(self) -> None: self.broker.loop_start() + async def disconnect(self) -> None: + """Disconnect the internal MQTT client""" + return self.broker.loop_stop() + def _cb_on_connect( self, client: mqtt, userdata: Any, flags: Dict[str, Any], rc: int ) -> None: @@ -129,12 +134,10 @@ def _cb_on_message( ) -> None: """Receive a PUBLISH message from the server.""" payload = MQTTPayload(msg.payload) + self.data = SmartMeter.from_mqtt_payload(payload) - if "electricity.consumption" in self.sensors: - self.sensors["electricity.consumption"].update_state(payload) - - if "gas.consumption" in self.sensors: - self.sensors["gas.consumption"].update_state(payload) + for callback in self.callbacks: + callback(payload) def retrieve_resources(self) -> List[Dict[str, Any]]: """Retrieve the resources known to Glowmarkt for the authenticated user.""" @@ -168,11 +171,9 @@ def current_usage(self, resource: Dict[str, Any]) -> Dict[str, Any]: data = response.json() return data - def register_sensor( - self, sensor: GlowConsumptionCurrent, resource: Dict[str, Any] - ) -> None: + def register_on_message_callback(self, callback: Callable) -> None: """Register a live sensor for dispatching MQTT messages.""" - self.sensors[resource["classifier"]] = sensor + self.callbacks.append(callback) class CannotConnect(exceptions.HomeAssistantError): diff --git a/custom_components/hildebrandglow/glow/glowdata.py b/custom_components/hildebrandglow/glow/glowdata.py new file mode 100644 index 0000000..2953394 --- /dev/null +++ b/custom_components/hildebrandglow/glow/glowdata.py @@ -0,0 +1,44 @@ +"""Data classes for interpreted Glow structures.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from . import MQTTPayload + + +@dataclass +class SmartMeter: + """Data object for platform agnostic smart metering information.""" + + gas_consumption: float | None + + power_consumption: int | None + energy_consumption: float | None + + @staticmethod + def from_mqtt_payload(data: MQTTPayload) -> SmartMeter: + """Populate SmartMeter object from an MQTTPayload object.""" + meter = SmartMeter( + gas_consumption=None, + power_consumption=None, + energy_consumption=None, + ) + + if data.gas: + ahc = data.gas.alternative_historical_consumption + meter.gas_consumption = ahc.current_day_consumption_delivered + + if data.electricity: + meter.power_consumption = ( + data.electricity.historical_consumption.instantaneous_demand + ) + + if data.electricity.reading_information_set.current_summation_delivered: + meter.energy_consumption = ( + data.electricity.reading_information_set.current_summation_delivered + * data.electricity.formatting.multiplier + / data.electricity.formatting.divisor + ) + + return meter diff --git a/custom_components/hildebrandglow/mqttpayload.py b/custom_components/hildebrandglow/glow/mqttpayload.py similarity index 98% rename from custom_components/hildebrandglow/mqttpayload.py rename to custom_components/hildebrandglow/glow/mqttpayload.py index 10f99c3..de7b18f 100644 --- a/custom_components/hildebrandglow/mqttpayload.py +++ b/custom_components/hildebrandglow/glow/mqttpayload.py @@ -93,10 +93,10 @@ class MeteringDeviceType(Enum): unit_of_measure: Optional[UnitofMeasure] """Unit for the measured value.""" - multiplier: Optional[int] + multiplier: int """Multiplier value for smart meter readings.""" - divisor: Optional[int] + divisor: int """Divisor value for smart meter readings.""" summation_formatting: Optional[str] @@ -122,8 +122,8 @@ def __init__(self, payload: Dict[str, Any]): formatting = payload["0702"]["03"] if "03" in payload["0702"] else {} self.unit_of_measure = self.UnitofMeasure(formatting.get("00", "00")) - self.multiplier = int(formatting["01"], 16) if "01" in formatting else None - self.divisor = int(formatting["02"], 16) if "02" in formatting else None + self.multiplier = int(formatting["01"], 16) if "01" in formatting else 1 + self.divisor = int(formatting["02"], 16) if "02" in formatting else 1 self.summation_formatting = formatting.get("03") self.demand_formatting = formatting.get("04") self.metering_device_type = ( diff --git a/custom_components/hildebrandglow/sensor.py b/custom_components/hildebrandglow/sensor.py index 2f83785..3dd08aa 100644 --- a/custom_components/hildebrandglow/sensor.py +++ b/custom_components/hildebrandglow/sensor.py @@ -1,121 +1,109 @@ """Platform for sensor integration.""" -from typing import Any, Callable, Dict, Optional - +from typing import Any, Callable + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DEVICE_CLASS_POWER, POWER_WATT, VOLUME_CUBIC_METERS +from homeassistant.const import ( + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_GAS, + DEVICE_CLASS_POWER, + ENERGY_KILO_WATT_HOUR, + POWER_WATT, + VOLUME_CUBIC_METERS, +) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo, Entity - -from .const import DOMAIN -from .glow import Glow, InvalidAuth -from .mqttpayload import Meter, MQTTPayload +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.typing import StateType + +from .const import DOMAIN, GLOW_SESSION +from .glow import Glow + +SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="gas_consumption", + name="Gas Consumption", + entity_registry_enabled_default=False, + native_unit_of_measurement=VOLUME_CUBIC_METERS, + device_class=DEVICE_CLASS_GAS, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key="power_consumption", + name="Power Consumption", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="energy_consumption", + name="Energy Consumption", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), +) async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, async_add_entities: Callable -) -> bool: +) -> None: """Set up the sensor platform.""" - new_entities = [] - - for entry in hass.data[DOMAIN]: - glow = hass.data[DOMAIN][entry] - - resources: dict = {} - - try: - resources = await hass.async_add_executor_job(glow.retrieve_resources) - except InvalidAuth: - return False - - for resource in resources: - if resource["classifier"] in GlowConsumptionCurrent.knownClassifiers: - sensor = GlowConsumptionCurrent(glow, resource) - glow.register_sensor(sensor, resource) - new_entities.append(sensor) + async_add_entities( + GlowSensorEntity( + glow=hass.data[DOMAIN][config.entry_id][GLOW_SESSION], + description=description, + ) + for description in SENSORS + ) - async_add_entities(new_entities) - return True - - -class GlowConsumptionCurrent(Entity): +class GlowSensorEntity(SensorEntity): """Sensor object for the Glowmarkt resource's current consumption.""" - hass: HomeAssistant - - knownClassifiers = ["gas.consumption", "electricity.consumption"] - - _state: Optional[Meter] - available = True should_poll = False - def __init__(self, glow: Glow, resource: Dict[str, Any]): + glow: Glow + + def __init__( + self, + *, + glow: Glow, + description: SensorEntityDescription, + ) -> None: """Initialize the sensor.""" - self._state = None + super().__init__() self.glow = glow - self.resource = resource - - @property - def unique_id(self) -> str: - """Return a unique identifier string for the sensor.""" - return self.resource["resourceId"] - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return self.resource["label"] - @property - def icon(self) -> Optional[str]: - """Icon to use in the frontend, if any.""" - if self.resource["dataSourceResourceTypeInfo"]["type"] == "ELEC": - return "mdi:flash" - elif self.resource["dataSourceResourceTypeInfo"]["type"] == "GAS": - return "mdi:fire" - else: - return None - - @property - def device_info(self) -> Optional[DeviceInfo]: - """Return information about the sensor data source.""" - if self.resource["dataSourceResourceTypeInfo"]["type"] == "ELEC": - human_type = "electricity" - elif self.resource["dataSourceResourceTypeInfo"]["type"] == "GAS": - human_type = "gas" + self.entity_id = f"{SENSOR_DOMAIN}.glow{glow.hardwareId}_{description.key}" + self.entity_description = description + self._attr_unique_id = f"glow{glow.hardwareId}_{description.key}" - return { - "identifiers": {(DOMAIN, self.resource["resourceId"])}, - "name": f"Smart Meter, {human_type}", - } + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, f"glow{glow.hardwareId}")}, + manufacturer="Hildebrand", + name="Smart Meter", + ) - @property - def state(self) -> Optional[int]: - """Return the state of the sensor.""" - if self._state: - if self.resource["dataSourceResourceTypeInfo"]["type"] == "ELEC": - return self._state.historical_consumption.instantaneous_demand - elif self.resource["dataSourceResourceTypeInfo"]["type"] == "GAS": - alt = self._state.alternative_historical_consumption - return alt.current_day_consumption_delivered - return None + async def async_added_to_hass(self) -> None: + """Register callback function.""" + self.glow.register_on_message_callback(self.on_message) - def update_state(self, meter: MQTTPayload) -> None: - """Receive an MQTT update from Glow and update the internal state.""" - self._state = meter.electricity + def on_message(self, message: Any) -> None: + """Callback for incoming MQTT payloads""" self.hass.add_job(self.async_write_ha_state) @property - def device_class(self) -> str: - """Return the device class (always DEVICE_CLASS_POWER).""" - return DEVICE_CLASS_POWER - - @property - def unit_of_measurement(self) -> Optional[str]: - """Return the unit of measurement.""" - if self._state is not None: - if self.resource["dataSourceResourceTypeInfo"]["type"] == "ELEC": - return POWER_WATT - elif self.resource["dataSourceResourceTypeInfo"]["type"] == "GAS": - return VOLUME_CUBIC_METERS - - return None + def native_value(self) -> StateType: + """Return the state of the sensor.""" + value = getattr(self.glow.data, self.entity_description.key) + if isinstance(value, str): + return value.lower() + return value diff --git a/setup.cfg b/setup.cfg index 2adbb5a..cbbf7a5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,13 +1,9 @@ [flake8] -extend-ignore = E203, E231, max-line-length = 88 +extend-ignore = E203 [isort] -combine_as_imports = True -force_grid_wrap = 0 -include_trailing_comma = True -line_length = 88 -multi_line_output = 3 +profile = black known_third_party = homeassistant,voluptuous [mypy]