From 916fa43b237f3ca77e163c7fb19353cfb7bd8f08 Mon Sep 17 00:00:00 2001 From: markvader Date: Thu, 16 Jun 2022 09:58:35 +0100 Subject: [PATCH] initial commit --- .idea/.gitignore | 3 + .idea/misc.xml | 6 + .idea/modules.xml | 8 + .idea/sonic_hacs.iml | 9 ++ .idea/vcs.xml | 6 + custom_components/sonic/__init__.py | 59 +++++++ custom_components/sonic/config_flow.py | 67 ++++++++ custom_components/sonic/const.py | 7 + custom_components/sonic/device.py | 156 +++++++++++++++++++ custom_components/sonic/entity.py | 53 +++++++ custom_components/sonic/manifest.json | 10 ++ custom_components/sonic/sensor.py | 125 +++++++++++++++ custom_components/sonic/strings.json | 24 +++ custom_components/sonic/switch.py | 71 +++++++++ custom_components/sonic/translations/en.json | 24 +++ hacs.json | 4 + 16 files changed, 632 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/sonic_hacs.iml create mode 100644 .idea/vcs.xml create mode 100644 custom_components/sonic/__init__.py create mode 100644 custom_components/sonic/config_flow.py create mode 100644 custom_components/sonic/const.py create mode 100644 custom_components/sonic/device.py create mode 100644 custom_components/sonic/entity.py create mode 100644 custom_components/sonic/manifest.json create mode 100644 custom_components/sonic/sensor.py create mode 100644 custom_components/sonic/strings.json create mode 100644 custom_components/sonic/switch.py create mode 100644 custom_components/sonic/translations/en.json create mode 100644 hacs.json diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..3a37236 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..3e7d65b --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/sonic_hacs.iml b/.idea/sonic_hacs.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/sonic_hacs.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/custom_components/sonic/__init__.py b/custom_components/sonic/__init__.py new file mode 100644 index 0000000..18ea4ac --- /dev/null +++ b/custom_components/sonic/__init__.py @@ -0,0 +1,59 @@ +"""The Sonic Water Shut-off Valve integration.""" +import logging +import asyncio + +from herolabsapi import ( + InvalidCredentialsError, + Client, + ServiceUnavailableError, + TooManyRequestsError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CLIENT, DOMAIN +from .device import SonicDeviceDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[str] = ["switch", "sensor"] + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Sonic Water Shut-off Valve from a config entry.""" + session = async_get_clientsession(hass) + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = {} + try: + hass.data[DOMAIN][entry.entry_id][CLIENT] = client = await Client.async_login( + entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], session=session + ) + except InvalidCredentialsError as err: + raise ConfigEntryNotReady from err + + sonic_data = await client.sonic.async_get_all_sonic_details() + + _LOGGER.debug("Sonic device data information: %s", sonic_data) + + hass.data[DOMAIN][entry.entry_id]["devices"] = devices = [ + SonicDeviceDataUpdateCoordinator(hass, client, device["id"]) + for device in sonic_data["data"] + ] + + tasks = [device.async_refresh() for device in devices] + await asyncio.gather(*tasks) + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/custom_components/sonic/config_flow.py b/custom_components/sonic/config_flow.py new file mode 100644 index 0000000..c1df8c0 --- /dev/null +++ b/custom_components/sonic/config_flow.py @@ -0,0 +1,67 @@ +"""Config flow for Sonic Integration.""" +from herolabsapi import Client, InvalidCredentialsError +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, LOGGER + +DATA_SCHEMA = vol.Schema({vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + + session = async_get_clientsession(hass) + try: + api = await Client.async_login( + data[CONF_USERNAME], data[CONF_PASSWORD], session=session + ) + except InvalidCredentialsError as request_error: + LOGGER.error("Error connecting to the Sonic API: %s", request_error) + raise CannotConnect from request_error + + # Use the verified session to discover the first sonic device's name + sonic_data = await api.sonic.async_get_all_sonic_details() + first_sonic_id = sonic_data["data"][0]["id"] + sonic_info = await api.sonic.async_get_sonic_details(first_sonic_id) + return {"title": sonic_info["name"]} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Sonic.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + + return self.async_create_entry(title=info["title"], data=user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidHost: + errors["host"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidHost(exceptions.HomeAssistantError): + """Error to indicate there is an invalid hostname.""" diff --git a/custom_components/sonic/const.py b/custom_components/sonic/const.py new file mode 100644 index 0000000..46b8cd2 --- /dev/null +++ b/custom_components/sonic/const.py @@ -0,0 +1,7 @@ +"""Constants for the Sonic Water Shut-off Valve integration.""" +import logging + +LOGGER = logging.getLogger(__package__) + +CLIENT = "client" +DOMAIN = "sonic" diff --git a/custom_components/sonic/device.py b/custom_components/sonic/device.py new file mode 100644 index 0000000..f37fb4c --- /dev/null +++ b/custom_components/sonic/device.py @@ -0,0 +1,156 @@ +"""Sonic device object.""" +from __future__ import annotations + +import asyncio +from datetime import timedelta +from typing import Any + +from async_timeout import timeout +from herolabsapi.client import Client +from herolabsapi.errors import RequestError + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN as SONIC_DOMAIN, LOGGER + + +class SonicDeviceDataUpdateCoordinator(DataUpdateCoordinator): + """Sonic device object.""" + + def __init__(self, hass: HomeAssistant, api_client: Client, device_id: str) -> None: + """Initialize the device.""" + self.hass: HomeAssistant = hass + self.api_client: Client = api_client + self._sonic_device_id: str = device_id + self._device_information: dict[str, Any] = {} + self._telemetry_information: dict[str, Any] = {} + super().__init__( + hass, + LOGGER, + name=f"{SONIC_DOMAIN}-{device_id}", + update_interval=timedelta(seconds=60), + ) + + async def _async_update_data(self): + """Update data via library.""" + try: + async with timeout(10): + await asyncio.gather( + *[ + self._update_device(), + ] + ) + except (RequestError) as error: + raise UpdateFailed(error) from error + + @property + def id(self) -> str: + """Return Sonic device id.""" + return self._sonic_device_id + + @property + def device_name(self) -> str: + """Return device name.""" + return self._device_information.get("name", f"{self.model}") + + @property + def manufacturer(self) -> str: + """Return manufacturer for device.""" + return "Hero Labs" + + @property + def serial_number(self) -> str: + """Return serial number for device.""" + return self._device_information["serial_no"] + + @property + def model(self) -> str: + """Return model for device.""" + return "Sonic" + + @property + def rssi(self) -> float: + """Return rssi for device.""" + return self._device_information["radio_rssi"] + + @property + def last_heard_from_time(self) -> str: + """Return Unix timestamp in seconds when the sonic took measurements + Will need to do conversion from timestamp to datetime if HomeAssistant doesn't do it automatically""" + return self._telemetry_information["probed_at"] + + @property + def available(self) -> bool: + """Return True if device is available.""" + return ( + self.last_update_success + and self._device_information["radio_connection"] == "connected" + ) + + @property + def current_flow_rate(self) -> float: + """Return current flow rate in ml/min.""" + return self._telemetry_information["water_flow"] + + @property + def current_mbar(self) -> int: + """Return the current pressure in mbar.""" + return self._telemetry_information["pressure"] + + @property + def temperature(self) -> float: + """Return the current temperature in degrees C.""" + return self._telemetry_information["water_temp"] + + @property + def battery_state(self) -> str: + """Return the battery level "high","mid","low" or returns "external_power_supply" """ + return self._device_information["battery"] + + @property + def auto_shut_off_enabled(self) -> bool: + """Return the auto shut off enabled boolean""" + return self._device_information["auto_shut_off_enabled"] + + @property + def auto_shut_off_time_limit(self) -> int: + """Return the Sonic offline auto shut off water usage time limit in seconds[0;integer::max). + When set to 0 usage time check is not performed.""" + return self._device_information["auto_shut_off_time_limit"] + + @property + def auto_shut_off_volume_limit(self) -> int: + """Return the Sonic offline auto shut off used water volume limit in millilitres [0;integer::max). + When set to 0 volume used check is not performed.""" + return self._device_information["auto_shut_off_volume_limit"] + + @property + def signal_id(self) -> str: + """Return the associated signal device id + A Signal device (sometimes called hub) communicates with WiFi and the Sonic device""" + return self._device_information["signal_id"] + + @property + def sonic_status(self) -> str: + """Return the any sonic status message""" + return self._device_information["status"] + + @property + def last_known_valve_state(self) -> str: + """Return the current valve state + Options are: 'open, closed, opening, closing, faulty, pressure_test, requested_open, requested_closed'""" + return self._device_information["valve_state"] + + async def _update_device(self, *_) -> None: + """Update the device information from the API.""" + self._device_information = await self.api_client.sonic.async_get_sonic_details( + self._sonic_device_id + ) + self._telemetry_information = ( + await self.api_client.sonic.async_sonic_telemetry_by_id( + self._sonic_device_id + ) + ) + LOGGER.debug("Sonic device data: %s", self._device_information) + LOGGER.debug("Sonic telemetry data: %s", self._telemetry_information) diff --git a/custom_components/sonic/entity.py b/custom_components/sonic/entity.py new file mode 100644 index 0000000..2cd6eb8 --- /dev/null +++ b/custom_components/sonic/entity.py @@ -0,0 +1,53 @@ +"""Base entity class for Sonic entities.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.helpers.entity import DeviceInfo, Entity + +from .const import DOMAIN as SONIC_DOMAIN +from .device import SonicDeviceDataUpdateCoordinator + + +class SonicEntity(Entity): + """A base class for Sonic entities.""" + + _attr_force_update = False + _attr_should_poll = False + + def __init__( + self, + entity_type: str, + name: str, + device: SonicDeviceDataUpdateCoordinator, + **kwargs, + ) -> None: + """Init Sonic entity.""" + self._attr_name = name + self._attr_unique_id = f"{device.serial_number}_{entity_type}" + + self._device: SonicDeviceDataUpdateCoordinator = device + self._state: Any = None + + @property + def device_info(self) -> DeviceInfo: + """Return a device description for device registry.""" + return DeviceInfo( + identifiers={(SONIC_DOMAIN, self._device.id)}, + manufacturer=self._device.manufacturer, + model=self._device.model, + name=self._device.device_name, + ) + + @property + def available(self) -> bool: + """Return True if device is available.""" + return self._device.available + + async def async_update(self): + """Update Sonic entity.""" + await self._device.async_request_refresh() + + async def async_added_to_hass(self): + """When entity is added to hass.""" + self.async_on_remove(self._device.async_add_listener(self.async_write_ha_state)) diff --git a/custom_components/sonic/manifest.json b/custom_components/sonic/manifest.json new file mode 100644 index 0000000..06411ed --- /dev/null +++ b/custom_components/sonic/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "sonic", + "name": "Sonic by Hero Labs", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/sonic", + "requirements": ["herolabsapi==0.3.6"], + "codeowners": ["@markvader"], + "iot_class": "cloud_polling", + "loggers": ["sonic"] +} diff --git a/custom_components/sonic/sensor.py b/custom_components/sonic/sensor.py new file mode 100644 index 0000000..1544b92 --- /dev/null +++ b/custom_components/sonic/sensor.py @@ -0,0 +1,125 @@ +"""The Sonic Water Shut-off Valve integration.""" +from __future__ import annotations + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PRESSURE_MBAR, + TEMP_CELSIUS, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN as SONIC_DOMAIN +from .device import SonicDeviceDataUpdateCoordinator +from .entity import SonicEntity + +WATER_ICON = "mdi:water" +GAUGE_ICON = "mdi:gauge" +NAME_FLOW_RATE = "Water Flow Rate" +NAME_WATER_TEMPERATURE = "Water Temperature" +NAME_WATER_PRESSURE = "Water Pressure" +NAME_BATTERY = "Battery" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Sonic sensors from config entry.""" + devices: list[SonicDeviceDataUpdateCoordinator] = hass.data[SONIC_DOMAIN][ + config_entry.entry_id + ]["devices"] + entities = [] + for device in devices: + entities.extend( + [ + SonicCurrentFlowRateSensor(device), + SonicTemperatureSensor(NAME_WATER_TEMPERATURE, device), + SonicPressureSensor(device), + SonicBatterySensor(device), + ] + ) + async_add_entities(entities) + + +class SonicCurrentFlowRateSensor(SonicEntity, SensorEntity): + """Monitors the current water flow rate.""" + + _attr_icon = GAUGE_ICON + _attr_native_unit_of_measurement = "millilitres per min" + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + + def __init__(self, device): + """Initialize the flow rate sensor.""" + super().__init__("current_flow_rate", NAME_FLOW_RATE, device) + self._state: float = None + + @property + def native_value(self) -> float | None: + """Return the current flow rate.""" + if self._device.current_flow_rate is None: + return None + return round(self._device.current_flow_rate, 1) + + +class SonicTemperatureSensor(SonicEntity, SensorEntity): + """Monitors the water temperature.""" + + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_native_unit_of_measurement = TEMP_CELSIUS + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + + def __init__(self, name, device): + """Initialize the temperature sensor.""" + super().__init__("temperature", name, device) + self._state: float = None + + @property + def native_value(self) -> float | None: + """Return the current temperature.""" + if self._device.temperature is None: + return None + return round(self._device.temperature, 1) + + +class SonicPressureSensor(SonicEntity, SensorEntity): + """Monitors the water pressure.""" + + _attr_device_class = SensorDeviceClass.PRESSURE + _attr_native_unit_of_measurement = PRESSURE_MBAR + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + + def __init__(self, device): + """Initialize the water pressure sensor.""" + super().__init__("water_pressure", NAME_WATER_PRESSURE, device) + self._state: float = None + + @property + def native_value(self) -> float | None: + """Return the current water pressure.""" + if self._device.current_mbar is None: + return None + return round(self._device.current_mbar, 1) + + +class SonicBatterySensor(SonicEntity, SensorEntity): + """Monitors the battery state for battery-powered devices or returns external_power_supply if externally powered.""" + + _attr_native_unit_of_measurement = "battery" + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + + def __init__(self, device): + """Initialize the battery sensor.""" + super().__init__("battery", NAME_BATTERY, device) + self._state: str = None + + @property + def native_value(self) -> str | None: + """Return the current battery state.""" + return self._device.battery_state diff --git a/custom_components/sonic/strings.json b/custom_components/sonic/strings.json new file mode 100644 index 0000000..41a43a4 --- /dev/null +++ b/custom_components/sonic/strings.json @@ -0,0 +1,24 @@ +{ + "config": { + "step": { + "user": { + "description": "Log into your Hero Labs account.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "Hero Labs username, typically an email address." + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/custom_components/sonic/switch.py b/custom_components/sonic/switch.py new file mode 100644 index 0000000..9d3847b --- /dev/null +++ b/custom_components/sonic/switch.py @@ -0,0 +1,71 @@ +"""Switch representing the Sonic shutoff valve by Hero Labs integration.""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN as SONIC_DOMAIN +from .device import SonicDeviceDataUpdateCoordinator +from .entity import SonicEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Sonic switches from config entry.""" + devices: list[SonicDeviceDataUpdateCoordinator] = hass.data[SONIC_DOMAIN][ + config_entry.entry_id + ]["devices"] + entities = [] + for device in devices: + entities.append(SonicSwitch(device)) + async_add_entities(entities) + + +class SonicSwitch(SonicEntity, SwitchEntity): + """Switch class for the Sonic valve.""" + + def __init__(self, device: SonicDeviceDataUpdateCoordinator) -> None: + """Initialize the Sonic switch.""" + super().__init__("shutoff_valve", "Shutoff Valve", device) + self._state = self._device.last_known_valve_state == "open" + + @property + def is_on(self) -> bool: + """Return True if the valve is open.""" + return self._state + + @property + def icon(self): + """Return the icon to use for the valve.""" + if self.is_on: + return "mdi:valve-open" + return "mdi:valve-closed" + + async def async_turn_on(self, **kwargs) -> None: + """Open the valve.""" + await self._device.api_client.sonic.async_open_sonic_valve(self._device.id) + self._state = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs) -> None: + """Close the valve.""" + await self._device.api_client.sonic.async_close_sonic_valve(self._device.id) + self._state = False + self.async_write_ha_state() + + @callback + def async_update_state(self) -> None: + """Retrieve the latest valve state and update the state machine.""" + self._state = self._device.last_known_valve_state == "open" + self.async_write_ha_state() + + async def async_added_to_hass(self): + """When entity is added to hass.""" + self.async_on_remove(self._device.async_add_listener(self.async_update_state)) diff --git a/custom_components/sonic/translations/en.json b/custom_components/sonic/translations/en.json new file mode 100644 index 0000000..9314592 --- /dev/null +++ b/custom_components/sonic/translations/en.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username" + }, + "data_description": { + "username": "Hero Labs username, typically an email address." + }, + "description": "Log into your Hero Labs account." + } + } + } +} \ No newline at end of file diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..8bf9897 --- /dev/null +++ b/hacs.json @@ -0,0 +1,4 @@ +{ + "name": "Sonic (Beta) by @markvader", + "homeassistant": "2022.2.0" + }