diff --git a/.gitignore b/.gitignore index 9ac7fc1..9e25951 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ __pycache__ tmp -debug/[0-9]* \ No newline at end of file +debug/[0-9]* +debug/sync \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 4445530..02f367b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,17 @@ { - "python.formatting.provider": "black", - "python.pythonPath": "/usr/local/bin/python3", - "python.linting.enabled": true, - "python.linting.pylintEnabled": true + "python.linting.pylintEnabled": true, + "python.linting.enabled": true, + "python.linting.pylintArgs": [ + "--disable", + "import-error" + ], + "python.formatting.provider": "black", + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "python.analysis.autoImportCompletions": false, + "python.analysis.diagnosticSeverityOverrides": { + "reportMissingImports": "none" + } + } diff --git a/custom_components/bhyve/__init__.py b/custom_components/bhyve/__init__.py index 1125979..992c954 100644 --- a/custom_components/bhyve/__init__.py +++ b/custom_components/bhyve/__init__.py @@ -1,27 +1,28 @@ """Support for Orbit BHyve irrigation devices.""" -import json import logging -import voluptuous as vol - +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP, + Platform, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, ) -from homeassistant.core import callback -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError -from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import ( - async_dispatcher_send, async_dispatcher_connect, + async_dispatcher_send, ) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import DeviceInfo, Entity from .const import ( - CONF_ATTRIBUTION, - DATA_BHYVE, + CONF_DEVICES, DOMAIN, EVENT_PROGRAM_CHANGED, EVENT_RAIN_DELAY, @@ -30,31 +31,16 @@ SIGNAL_UPDATE_DEVICE, SIGNAL_UPDATE_PROGRAM, ) -from .util import anonymize from .pybhyve import Client -from .pybhyve.errors import BHyveError, WebsocketError +from .pybhyve.errors import AuthenticationError, BHyveError _LOGGER = logging.getLogger(__name__) -DATA_CONFIG = "config" - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] -async def async_setup(hass, config): - """Set up the BHyve component.""" - conf = config[DOMAIN] +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up BHyve from a config entry.""" async def async_update_callback(data): event = data.get("event") @@ -76,45 +62,52 @@ async def async_update_callback(data): hass, SIGNAL_UPDATE_PROGRAM.format(program_id), program_id, data ) - session = aiohttp_client.async_get_clientsession(hass) + client = Client( + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + session=async_get_clientsession(hass), + ) try: - bhyve = Client( - conf[CONF_USERNAME], - conf[CONF_PASSWORD], - loop=hass.loop, - session=session, - async_callback=async_update_callback, - ) + if await client.login() is False: + raise ConfigEntryAuthFailed() - await bhyve.login() + client.listen(hass.loop, async_update_callback) + all_devices = await client.devices + programs = await client.timer_programs + except AuthenticationError as err: + raise ConfigEntryAuthFailed() from err + except BHyveError as err: + raise ConfigEntryNotReady() from err - devices = [anonymize(device) for device in await bhyve.devices] - programs = await bhyve.timer_programs + # Filter the device list to those that are enabled in options + devices = [d for d in all_devices if str(d["id"]) in entry.options[CONF_DEVICES]] - _LOGGER.debug("Devices: {}".format(json.dumps(devices))) - _LOGGER.debug("Programs: {}".format(json.dumps(programs))) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + "client": client, + "devices": devices, + "programs": programs, + } - hass.data[DATA_BHYVE] = bhyve - except WebsocketError as err: - _LOGGER.error("Config entry failed: %s", err) - raise ConfigEntryNotReady + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, bhyve.stop()) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, client.stop()) return True -def get_entity_from_domain(hass, domains, entity_id): - domains = domains if isinstance(domains, list) else [domains] - for domain in domains: - component = hass.data.get(domain) - if component is None: - raise HomeAssistantError("{} component not set up".format(domain)) - entity = component.get_entity(entity_id) - if entity is not None: - return entity - raise HomeAssistantError("{} not found in {}".format(entity_id, ",".join(domains))) +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Reload to 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.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok class BHyveEntity(Entity): @@ -124,6 +117,7 @@ def __init__( self, hass, bhyve, + device, name, icon, device_class=None, @@ -134,11 +128,25 @@ def __init__( self._device_class = device_class self._name = name - self._icon = "mdi:{}".format(icon) + self._icon = f"mdi:{icon}" self._state = None self._available = False self._attrs = {} + self._device_id = device.get("id") + self._device_type = device.get("type") + self._device_name = device.get("name") + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + manufacturer=MANUFACTURER, + configuration_url=f"https://techsupport.orbitbhyve.com/dashboard/support/device/{self._device_id}", + name=self._device_name, + model=device.get("hardware_version"), + hw_version=device.get("hardware_version"), + sw_version=device.get("firmware_version"), + ) + @property def available(self): """Return True if entity is available.""" @@ -170,13 +178,9 @@ def should_poll(self): return False @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device registry information for this entity.""" - return { - "name": self._name, - "manufacturer": MANUFACTURER, - ATTR_ATTRIBUTION: CONF_ATTRIBUTION, - } + return self._attr_device_info class BHyveWebsocketEntity(BHyveEntity): @@ -186,19 +190,20 @@ def __init__( self, hass, bhyve, + device, name, icon, device_class=None, ): self._async_unsub_dispatcher_connect = None self._ws_unprocessed_events = [] - super().__init__(hass, bhyve, name, icon, device_class) + super().__init__(hass, bhyve, device, name, icon, device_class) def _on_ws_data(self, data): pass def _should_handle_event(self, event_name, data): - """True if the websocket event should be handled""" + """True if the websocket event should be handled.""" return True async def async_update(self): @@ -225,11 +230,8 @@ def __init__( """Initialize the sensor.""" self._mac_address = device.get("mac_address") - self._device_id = device.get("id") - self._device_type = device.get("type") - self._device_name = device.get("name") - super().__init__(hass, bhyve, name, icon, device_class) + super().__init__(hass, bhyve, device, name, icon, device_class) self._setup(device) @@ -247,7 +249,7 @@ async def _refetch_device(self, force_update=False): self._setup(device) except BHyveError as err: - _LOGGER.warning(f"Unable to retreive data for {self.name}: {err}") + _LOGGER.warning("Unable to retreive data for %s: %s", self.name, err) async def _fetch_device_history(self, force_update=False): try: @@ -256,21 +258,11 @@ async def _fetch_device_history(self, force_update=False): except BHyveError as err: raise (err) - @property - def device_info(self): - """Return device registry information for this entity.""" - return { - "identifiers": {(DOMAIN, self._mac_address)}, - "name": self._device_name, - "manufacturer": MANUFACTURER, - ATTR_ATTRIBUTION: CONF_ATTRIBUTION, - } - @property def unique_id(self): """Return a unique, unchanging string that represents this sensor.""" raise HomeAssistantError( - "{} does not define a unique_id".format(self.__class__.__name__) + f"{self.__class__.__name__} does not define a unique_id" ) async def async_added_to_hass(self): @@ -282,21 +274,18 @@ def update(device_id, data): event = data.get("event") if event == "device_disconnected": _LOGGER.warning( - "Device {} disconnected and is no longer available".format( - self.name - ) + "Device %s disconnected and is no longer available", self.name ) self._available = False elif event == "device_connected": - _LOGGER.info( - "Device {} reconnected and is now available".format(self.name) - ) + _LOGGER.info("Device %s reconnected and is now available", self.name) self._available = True if self._should_handle_event(event, data): _LOGGER.info( - "Message received: {} - {} - {}".format( - self.name, self._device_id, str(data) - ) + "Message received: %s - %s - %s", + self.name, + self._device_id, + str(data), ) self._ws_unprocessed_events.append(data) self.async_schedule_update_ha_state(True) @@ -311,21 +300,22 @@ async def async_will_remove_from_hass(self): self._async_unsub_dispatcher_connect() async def set_manual_preset_runtime(self, minutes: int): + """Sets the default watering runtime for the device.""" # {event: "set_manual_preset_runtime", device_id: "abc", seconds: 900} payload = { "event": EVENT_SET_MANUAL_PRESET_TIME, "device_id": self._device_id, "seconds": minutes * 60, } - _LOGGER.info("Setting manual preset runtime: {}".format(payload)) + _LOGGER.info("Setting manual preset runtime: %s", payload) await self._bhyve.send_message(payload) async def enable_rain_delay(self, hours: int = 24): - """Enable rain delay""" + """Enable rain delay.""" await self._set_rain_delay(hours) async def disable_rain_delay(self): - """Disable rain delay""" + """Disable rain delay.""" await self._set_rain_delay(0) async def _set_rain_delay(self, hours: int): @@ -336,7 +326,7 @@ async def _set_rain_delay(self, hours: int): "device_id": self._device_id, "delay": hours, } - _LOGGER.info("Setting rain delay: {}".format(payload)) + _LOGGER.info("Setting rain delay: %s", payload) await self._bhyve.send_message(payload) except BHyveError as err: diff --git a/custom_components/bhyve/binary_sensor.py b/custom_components/bhyve/binary_sensor.py index 8336031..561fcb8 100644 --- a/custom_components/bhyve/binary_sensor.py +++ b/custom_components/bhyve/binary_sensor.py @@ -1,24 +1,30 @@ """Support for Orbit BHyve sensors.""" import logging -from . import BHyveDeviceEntity -from .const import ( - DATA_BHYVE, - DEVICE_FLOOD, - EVENT_FS_ALARM, -) +from homeassistant.components.bhyve.util import filter_configured_devices from homeassistant.components.binary_sensor import DEVICE_CLASS_MOISTURE -from .pybhyve.errors import BHyveError +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import BHyveDeviceEntity +from .const import CONF_CLIENT, DEVICE_FLOOD, DOMAIN, EVENT_FS_ALARM _LOGGER = logging.getLogger(__name__) -async def async_setup_platform(hass, config, async_add_entities, _discovery_info=None): - """Set up BHyve sensors based on a config entry.""" - bhyve = hass.data[DATA_BHYVE] +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the BHyve binary sensor platform from a config entry.""" + + bhyve = hass.data[DOMAIN][entry.entry_id][CONF_CLIENT] sensors = [] - devices = await bhyve.devices + + # Filter the device list to those that are enabled in options + devices = filter_configured_devices(entry, await bhyve.devices) + for device in devices: if device.get("type") == DEVICE_FLOOD: sensors.append(BHyveFloodSensor(hass, bhyve, device)) @@ -26,12 +32,25 @@ async def async_setup_platform(hass, config, async_add_entities, _discovery_info async_add_entities(sensors, True) +# async def async_setup_platform(hass, config, async_add_entities, _discovery_info=None): +# """Set up BHyve sensors based on a config entry.""" +# bhyve = hass.data[DATA_BHYVE] + +# sensors = [] +# devices = await bhyve.devices +# for device in devices: +# if device.get("type") == DEVICE_FLOOD: +# sensors.append(BHyveFloodSensor(hass, bhyve, device)) + +# async_add_entities(sensors, True) + + class BHyveFloodSensor(BHyveDeviceEntity): """Define a BHyve sensor.""" def __init__(self, hass, bhyve, device): """Initialize the sensor.""" - name = "{0} flood sensor".format(device.get("name")) + name = "{} flood sensor".format(device.get("name")) _LOGGER.info("Creating flood sensor: %s", name) super().__init__(hass, bhyve, device, name, "water", DEVICE_CLASS_MOISTURE) @@ -44,7 +63,10 @@ def _setup(self, device): "rssi": device.get("status", {}).get("rssi"), } _LOGGER.debug( - f"Flood sensor {self._name} setup: State: {self._state} | Available: {self._available}" + "Flood sensor %s setup: State: %s | Available: %s", + self._name, + self._state, + self._available, ) def _parse_status(self, status): @@ -62,13 +84,14 @@ def unique_id(self): @property def is_on(self): + """Reports state of the flood sensor""" return self._state == "on" def _on_ws_data(self, data): """ {"last_flood_alarm_at":"2021-08-29T16:32:35.585Z","rssi":-60,"onboard_complete":true,"temp_f":75.2,"provisioned":true,"phy":"le_1m_1000","event":"fs_status_update","temp_alarm_status":"ok","status_updated_at":"2021-08-29T16:33:17.089Z","identify_enabled":false,"device_id":"612ad9134f0c6c9c9faddbba","timestamp":"2021-08-29T16:33:17.089Z","flood_alarm_status":"ok","last_temp_alarm_at":null} """ - _LOGGER.info("Received program data update {}".format(data)) + _LOGGER.info("Received program data update %s", data) event = data.get("event") if event == EVENT_FS_ALARM: self._state = self._parse_status(data) diff --git a/custom_components/bhyve/config_flow.py b/custom_components/bhyve/config_flow.py new file mode 100644 index 0000000..945234d --- /dev/null +++ b/custom_components/bhyve/config_flow.py @@ -0,0 +1,212 @@ +"""Config flow for BHyve integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +from .const import CONF_DEVICES, DEVICE_BRIDGE, DEVICES, DOMAIN, PROGRAMS +from .pybhyve import Client +from .pybhyve.errors import AuthenticationError, BHyveError + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for BHyve.""" + + VERSION = 1 + + def __init__(self): + """Initialize the config flow.""" + self.data: dict = {} + self.client: Client | None = None + self.devices: list[Any] | None = None + self.programs: list[Any] | None = None + self._reauth_username: str | None = None + + async def async_auth(self, user_input: dict[str, str]) -> dict[str, str] | None: + """Reusable Auth Helper.""" + self.client = Client( + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + session=async_get_clientsession(self.hass), + ) + + try: + result = await self.client.login() + if result is False: + return {"base": "invalid_auth"} + return None + except AuthenticationError: + return {"base": "invalid_auth"} + except Exception: # pylint: disable=broad-except + return {"base": "cannot_connect"} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + + errors: dict[str, str] | None = None + + if user_input is not None: + if not (errors := await self.async_auth(user_input)): + await self.async_set_unique_id(user_input[CONF_USERNAME].lower()) + self._abort_if_unique_id_configured() + + self.data = user_input + self.devices = await self.client.devices # type: ignore[union-attr] + self.programs = await self.client.timer_programs # type: ignore[union-attr] + + if len(self.devices) == 1: + return self.async_create_entry( + title=self.data[CONF_USERNAME], + data=self.data, + options={ + DEVICES: [str(self.devices[0]["id"])], + PROGRAMS: self.programs, + }, + ) + + # Account has more than one device, select devices to add + return await self.async_step_device() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) + + async def async_step_device( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the optional device selection step.""" + if user_input is not None: + return self.async_create_entry( + title=self.data[CONF_USERNAME], data=self.data, options=user_input + ) + + device_options = { + str(d["id"]): f'{d["name"]}' + for d in self.devices + if d["type"] != DEVICE_BRIDGE + } + return self.async_show_form( + step_id="device", + data_schema=vol.Schema( + { + vol.Required( + CONF_DEVICES, default=list(device_options.keys()) + ): cv.multi_select(device_options) + } + ), + errors=None, + ) + + async def async_step_reauth( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle reauth.""" + errors: dict[str, str] | None = None + if user_input and user_input.get(CONF_USERNAME): + self._reauth_username = user_input[CONF_USERNAME] + + elif self._reauth_username and user_input and user_input.get(CONF_PASSWORD): + data = { + CONF_USERNAME: self._reauth_username, + CONF_PASSWORD: user_input[CONF_PASSWORD], + } + + if not (errors := await self.async_auth(data)): + entry = await self.async_set_unique_id(self._reauth_username.lower()) + if entry: + self.hass.config_entries.async_update_entry( + entry, + data=data, + ) + await self.hass.config_entries.async_reload(entry.entry_id) + return self.async_abort(reason="reauth_successful") + return self.async_create_entry(title=self._reauth_username, data=data) + + return self.async_show_form( + step_id="reauth", + description_placeholders={"username": self._reauth_username}, + data_schema=vol.Schema( + { + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Options flow for picking devices.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + if self.config_entry.state != config_entries.ConfigEntryState.LOADED: + return self.async_abort(reason="unknown") + + data = self.hass.data[DOMAIN][self.config_entry.entry_id] + try: + client = data["client"] + result = await client.login() + if result is False: + return self.async_abort(reason="invalid_auth") + + devices = await client.devices + except AuthenticationError: + return self.async_abort(reason="invalid_auth") + except BHyveError: + return self.async_abort(reason="cannot_connect") + + # _LOGGER.debug("Devices: %s", json.dumps(devices)) + # _LOGGER.debug("Programs: %s", json.dumps(programs)) + + _LOGGER.debug("ALL DEVICES") + _LOGGER.debug(self.config_entry.options.get(CONF_DEVICES)) + + device_options = { + str(d["id"]): f'{d["name"]}' for d in devices if d["type"] != DEVICE_BRIDGE + } + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required( + CONF_DEVICES, + default=self.config_entry.options.get(CONF_DEVICES), + ): cv.multi_select(device_options), + } + ), + ) diff --git a/custom_components/bhyve/const.py b/custom_components/bhyve/const.py index 3c27081..34f63cc 100644 --- a/custom_components/bhyve/const.py +++ b/custom_components/bhyve/const.py @@ -2,10 +2,15 @@ DOMAIN = "bhyve" MANUFACTURER = "Orbit BHyve" +DEVICES = "devices" +PROGRAMS = "programs" + DATA_BHYVE = "bhyve_data" -CONF_ATTRIBUTION = "Data provided by api.orbitbhyve.com" +CONF_CLIENT = "client" +CONF_DEVICES = "devices" +DEVICE_BRIDGE = "bridge" DEVICE_SPRINKLER = "sprinkler_timer" DEVICE_FLOOD = "flood_sensor" diff --git a/custom_components/bhyve/diagnostics.py b/custom_components/bhyve/diagnostics.py new file mode 100644 index 0000000..00e3e72 --- /dev/null +++ b/custom_components/bhyve/diagnostics.py @@ -0,0 +1,35 @@ +"""Diagnostics support for BHyve.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import CONF_CLIENT, DOMAIN +from .pybhyve.client import Client + +CONF_ALTITUDE = "altitude" +CONF_UUID = "uuid" + +TO_REDACT = { + "address", + "full_location", + "location", + "weather_forecast_location_id", + "weather_station_id", + "image_url", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + bhyve: Client = hass.data[DOMAIN][entry.entry_id][CONF_CLIENT] + + devices = await bhyve.devices + programs = await bhyve.timer_programs + + return async_redact_data({"devices": devices, "programs": programs}, TO_REDACT) diff --git a/custom_components/bhyve/manifest.json b/custom_components/bhyve/manifest.json index 1926147..31d898f 100644 --- a/custom_components/bhyve/manifest.json +++ b/custom_components/bhyve/manifest.json @@ -1,7 +1,8 @@ { "domain": "bhyve", - "version": "2.2.3", - "name": "Orbit BHyve Integration", + "version": "2.3.0", + "name": "Orbit BHyve", + "config_flow": true, "documentation": "https://github.com/sebr/bhyve-home-assistant/blob/master/README.md", "dependencies": [], "codeowners": ["@sebr"], diff --git a/custom_components/bhyve/pybhyve/client.py b/custom_components/bhyve/pybhyve/client.py index 7f64826..948703e 100644 --- a/custom_components/bhyve/pybhyve/client.py +++ b/custom_components/bhyve/pybhyve/client.py @@ -1,22 +1,23 @@ """Define an object to interact with the REST API.""" +from asyncio import ensure_future import logging import re import time +from typing import Any -from asyncio import ensure_future +from aiohttp import ClientResponseError from .const import ( API_HOST, API_POLL_PERIOD, - DEVICES_PATH, DEVICE_HISTORY_PATH, - TIMER_PROGRAMS_PATH, - LANDSCAPE_DESCRIPTIONS_PATH, + DEVICES_PATH, LOGIN_PATH, + TIMER_PROGRAMS_PATH, WS_HOST, ) -from .errors import RequestError +from .errors import AuthenticationError, BHyveError, RequestError from .websocket import OrbitWebsocket _LOGGER = logging.getLogger(__name__) @@ -25,31 +26,24 @@ class Client: """Define the API object.""" - def __init__( - self, username: str, password: str, loop, session, async_callback - ) -> None: + def __init__(self, username: str, password: str, session) -> None: """Initialize.""" self._username: str = username - self._password: int = password + self._password: str = password self._ws_url: str = WS_HOST self._token: str = None self._websocket = None - self._loop = loop self._session = session - self._async_callback = async_callback - self._devices = [] + self._devices = list[Any] self._last_poll_devices = 0 - self._timer_programs = [] + self._timer_programs = list[Any] self._last_poll_programs = 0 - self._device_histories = dict() + self._device_histories = {} self._last_poll_device_histories = 0 - - self._landscapes = [] - self._last_poll_landscapes = 0 async def _request( self, method: str, endpoint: str, params: dict = None, json: dict = None @@ -79,7 +73,7 @@ async def _request( resp.raise_for_status() return await resp.json(content_type=None) except Exception as err: - raise RequestError(f"Error requesting data from {url}: {err}") + raise RequestError(f"Error requesting data from {url}: {err}") from err async def _refresh_devices(self, force_update=False): now = time.time() @@ -116,32 +110,20 @@ async def _refresh_device_history(self, device_id, force_update=False): device_history = await self._request( "get", DEVICE_HISTORY_PATH.format(device_id), - params={"t": str(time.time()), "page": str(1), "per-page": str(10),}, + params={ + "t": str(time.time()), + "page": str(1), + "per-page": str(10), + }, ) self._device_histories.update({device_id: device_history}) self._last_poll_device_histories = now - async def _refresh_landscapes(self, device_id, force_update=False): - now = time.time() - if force_update: - _LOGGER.debug("Forcing landscape refresh") - elif now - self._last_poll_landscapes < API_POLL_PERIOD: - return - - self._landscapes = await self._request( - "get", - f"{LANDSCAPE_DESCRIPTIONS_PATH}/{device_id}", - params={"t": str(time.time())} - ) - - self._last_poll_landscapes = now - - async def _async_ws_handler(self, data): + async def _async_ws_handler(self, async_callback, data): """Process incoming websocket message.""" - if self._async_callback: - ensure_future(self._async_callback(data)) + ensure_future(async_callback(data)) async def login(self) -> bool: """Log in with username & password and save the token.""" @@ -155,21 +137,31 @@ async def login(self) -> bool: _LOGGER.debug("Logged in") self._token = response["orbit_session_token"] + except ClientResponseError as response_err: + if response_err.status == 400: + raise AuthenticationError from response_err + raise RequestError from response_err except Exception as err: - raise RequestError(f"Error requesting data from {url}: {err}") + raise RequestError(f"Error requesting data from {url}: {err}") from err if self._token is None: return False + return True + + def listen(self, loop, async_callback): + """Starts listening to the Orbit event stream.""" + if self._token is None: + raise BHyveError("Client is not logged in") + self._websocket = OrbitWebsocket( token=self._token, - loop=self._loop, + loop=loop, session=self._session, url=self._ws_url, - async_callback=self._async_ws_handler, + async_callback=async_callback, ) self._websocket.start() - return True async def stop(self): """Stop the websocket.""" @@ -201,27 +193,12 @@ async def get_device_history(self, device_id, force_update=False): await self._refresh_device_history(device_id, force_update=force_update) return self._device_histories.get(device_id) - async def get_landscape(self, device_id, zone_id, force_update=False): - """Get landscape by zone id.""" - await self._refresh_landscapes(device_id, force_update) - for zone in self._landscapes: - if zone.get("station") == zone_id: - return zone - return None - - async def update_landscape(self, landscape): - """Update the state of a zone landscape""" - id = landscape.get("id") - path = f"{LANDSCAPE_DESCRIPTIONS_PATH}/{id}" - json = {"landscape_description": landscape} - await self._request("put", path, json=json) - async def update_program(self, program_id, program): - """Update the state of a program""" - path = "{0}/{1}".format(TIMER_PROGRAMS_PATH, program_id) + """Update the state of a program.""" + path = f"{TIMER_PROGRAMS_PATH}/{program_id}" json = {"sprinkler_timer_program": program} await self._request("put", path, json=json) async def send_message(self, payload): - """Send a message via the websocket""" + """Send a message via the websocket.""" await self._websocket.send(payload) diff --git a/custom_components/bhyve/pybhyve/errors.py b/custom_components/bhyve/pybhyve/errors.py index 701de54..6480a10 100644 --- a/custom_components/bhyve/pybhyve/errors.py +++ b/custom_components/bhyve/pybhyve/errors.py @@ -4,16 +4,14 @@ class BHyveError(Exception): """Define a base error.""" - pass - class RequestError(BHyveError): """Define an error related to invalid requests.""" - pass + +class AuthenticationError(RequestError): + """Define an error related to invalid authentication.""" class WebsocketError(BHyveError): """Define an error related to generic websocket errors.""" - - pass diff --git a/custom_components/bhyve/sensor.py b/custom_components/bhyve/sensor.py index 1f3a94c..ae25b2a 100644 --- a/custom_components/bhyve/sensor.py +++ b/custom_components/bhyve/sensor.py @@ -1,30 +1,29 @@ """Support for Orbit BHyve sensors.""" import logging +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_BATTERY_LEVEL, DEVICE_CLASS_BATTERY, DEVICE_CLASS_TEMPERATURE, - TEMP_FAHRENHEIT, ) +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level -from homeassistant.components.sensor import ( - DEVICE_CLASS_TEMPERATURE, -) - from . import BHyveDeviceEntity from .const import ( - DATA_BHYVE, - DEVICE_SPRINKLER, + CONF_CLIENT, DEVICE_FLOOD, + DEVICE_SPRINKLER, + DOMAIN, EVENT_CHANGE_MODE, - EVENT_FS_ALARM, EVENT_DEVICE_IDLE, + EVENT_FS_ALARM, ) from .pybhyve.errors import BHyveError -from .util import orbit_time_to_local_time +from .util import filter_configured_devices, orbit_time_to_local_time _LOGGER = logging.getLogger(__name__) @@ -39,12 +38,16 @@ ATTR_STATUS = "status" -async def async_setup_platform(hass, config, async_add_entities, _discovery_info=None): - """Set up BHyve sensors based on a config entry.""" - bhyve = hass.data[DATA_BHYVE] +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the BHyve sensor platform from a config entry.""" + + bhyve = hass.data[DOMAIN][entry.entry_id][CONF_CLIENT] sensors = [] - devices = await bhyve.devices + devices = filter_configured_devices(entry, await bhyve.devices) + for device in devices: if device.get("type") == DEVICE_SPRINKLER: sensors.append(BHyveStateSensor(hass, bhyve, device)) @@ -57,7 +60,28 @@ async def async_setup_platform(hass, config, async_add_entities, _discovery_info sensors.append(BHyveTemperatureSensor(hass, bhyve, device)) sensors.append(BHyveBatterySensor(hass, bhyve, device)) - async_add_entities(sensors, True) + async_add_entities(sensors) + + +# async def async_setup_platform(hass, config, async_add_entities, _discovery_info=None): +# """Set up BHyve sensors based on a config entry.""" +# bhyve = hass.data[DATA_BHYVE] + +# sensors = [] +# devices = await bhyve.devices +# for device in devices: +# if device.get("type") == DEVICE_SPRINKLER: +# sensors.append(BHyveStateSensor(hass, bhyve, device)) +# for zone in device.get("zones"): +# sensors.append(BHyveZoneHistorySensor(hass, bhyve, device, zone)) + +# if device.get("battery", None) is not None: +# sensors.append(BHyveBatterySensor(hass, bhyve, device)) +# if device.get("type") == DEVICE_FLOOD: +# sensors.append(BHyveTemperatureSensor(hass, bhyve, device)) +# sensors.append(BHyveBatterySensor(hass, bhyve, device)) + +# async_add_entities(sensors, True) class BHyveBatterySensor(BHyveDeviceEntity): @@ -91,7 +115,7 @@ def _setup(self, device): @property def state(self): - """Return the state of the entity""" + """Return the state of the entity.""" return self._state @property @@ -120,7 +144,7 @@ def unique_id(self): @property def entity_category(self): - """Battery is a diagnostic category""" + """Battery is a diagnostic category.""" return EntityCategory.DIAGNOSTIC def _should_handle_event(self, event_name, data): @@ -142,7 +166,7 @@ def __init__(self, hass, bhyve, device, zone): self._zone = zone self._zone_id = zone.get("station") - name = "{0} zone history".format(zone.get("name", "Unknown")) + name = "{} zone history".format(zone.get("name", "Unknown")) _LOGGER.info("Creating history sensor: %s", name) super().__init__( @@ -160,7 +184,7 @@ def _setup(self, device): @property def state(self): - """Return the state of the entity""" + """Return the state of the entity.""" return self._state @property @@ -175,7 +199,7 @@ def unique_id(self): @property def entity_category(self): - """History is a diagnostic category""" + """History is a diagnostic category.""" return EntityCategory.DIAGNOSTIC def _should_handle_event(self, event_name, data): @@ -183,7 +207,7 @@ def _should_handle_event(self, event_name, data): async def async_update(self): """Retrieve latest state.""" - force_update = True if list(self._ws_unprocessed_events) else False + force_update = bool(list(self._ws_unprocessed_events)) self._ws_unprocessed_events[:] = [] # We don't care about these try: @@ -221,7 +245,7 @@ async def async_update(self): break except BHyveError as err: - _LOGGER.warning(f"Unable to retreive data for {self._name}: {err}") + _LOGGER.warning("Unable to retreive data for %s: %s", self._name, err) class BHyveStateSensor(BHyveDeviceEntity): @@ -229,7 +253,7 @@ class BHyveStateSensor(BHyveDeviceEntity): def __init__(self, hass, bhyve, device): """Initialize the sensor.""" - name = "{0} state".format(device.get("name")) + name = "{} state".format(device.get("name")) _LOGGER.info("Creating state sensor: %s", name) super().__init__(hass, bhyve, device, name, "information") @@ -238,12 +262,15 @@ def _setup(self, device): self._state = device.get("status", {}).get("run_mode") self._available = device.get("is_connected", False) _LOGGER.debug( - f"State sensor {self._name} setup: State: {self._state} | Available: {self._available}" + "State sensor %s setup: State: %s | Available: %s", + self._name, + self._state, + self._available, ) @property def state(self): - """Return the state of the entity""" + """Return the state of the entity.""" return self._state @property @@ -253,13 +280,13 @@ def unique_id(self): @property def entity_category(self): - """Run state is a diagnostic category""" + """Run state is a diagnostic category.""" return EntityCategory.DIAGNOSTIC def _on_ws_data(self, data): - """ - {'event': 'change_mode', 'mode': 'auto', 'device_id': 'id', 'timestamp': '2020-01-09T20:30:00.000Z'} - """ + # + # {'event': 'change_mode', 'mode': 'auto', 'device_id': 'id', 'timestamp': '2020-01-09T20:30:00.000Z'} + # event = data.get("event") if event == EVENT_CHANGE_MODE: self._state = data.get("mode") @@ -273,7 +300,7 @@ class BHyveTemperatureSensor(BHyveDeviceEntity): def __init__(self, hass, bhyve, device): """Initialize the sensor.""" - name = "{0} temperature sensor".format(device.get("name")) + name = "{} temperature sensor".format(device.get("name")) _LOGGER.info("Creating temperature sensor: %s", name) super().__init__( hass, bhyve, device, name, "thermometer", DEVICE_CLASS_TEMPERATURE @@ -289,12 +316,15 @@ def _setup(self, device): "temperature_alarm": device.get("status", {}).get("temp_alarm_status"), } _LOGGER.debug( - f"Temperature sensor {self._name} setup: State: {self._state} | Available: {self._available}" + "Temperature sensor %s setup: State: %s | Available: %s", + self._name, + self._state, + self._available, ) @property def state(self): - """Return the state of the entity""" + """Return the state of the entity.""" return self._state @property @@ -308,10 +338,10 @@ def unique_id(self): return f"{self._mac_address}:{self._device_id}:temp" def _on_ws_data(self, data): - """ - {"last_flood_alarm_at":"2021-08-29T16:32:35.585Z","rssi":-60,"onboard_complete":true,"temp_f":75.2,"provisioned":true,"phy":"le_1m_1000","event":"fs_status_update","temp_alarm_status":"ok","status_updated_at":"2021-08-29T16:33:17.089Z","identify_enabled":false,"device_id":"612ad9134f0c6c9c9faddbba","timestamp":"2021-08-29T16:33:17.089Z","flood_alarm_status":"ok","last_temp_alarm_at":null} - """ - _LOGGER.info("Received program data update {}".format(data)) + # + # {"last_flood_alarm_at":"2021-08-29T16:32:35.585Z","rssi":-60,"onboard_complete":true,"temp_f":75.2,"provisioned":true,"phy":"le_1m_1000","event":"fs_status_update","temp_alarm_status":"ok","status_updated_at":"2021-08-29T16:33:17.089Z","identify_enabled":false,"device_id":"612ad9134f0c6c9c9faddbba","timestamp":"2021-08-29T16:33:17.089Z","flood_alarm_status":"ok","last_temp_alarm_at":null} + # + _LOGGER.info("Received program data update %s", data) event = data.get("event") if event == EVENT_FS_ALARM: self._state = data.get("temp_f") diff --git a/custom_components/bhyve/services.yaml b/custom_components/bhyve/services.yaml index f0cf369..8cd9d65 100644 --- a/custom_components/bhyve/services.yaml +++ b/custom_components/bhyve/services.yaml @@ -43,13 +43,3 @@ set_manual_preset_runtime: minutes: description: Number of minutes to set the preset runtime example: 15 - -set_smart_watering_soil_moisture: - description: Set the smart watering soil moisture level for a zone - fields: - entity_id: - description: Zone - example: "switch.backyard_zone" - percentage: - description: Moisture level between 0 - 100 (percent) - example: 50 diff --git a/custom_components/bhyve/strings.json b/custom_components/bhyve/strings.json new file mode 100644 index 0000000..b45fe13 --- /dev/null +++ b/custom_components/bhyve/strings.json @@ -0,0 +1,50 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "device": { + "title": "Select Devices", + "data": { + "devices": "Devices" + } + }, + "reauth": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Update password for {username}", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "no_devices_found": "No devices were found for this account", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + }, + "options": { + "step": { + "init": { + "title": "Select Devices", + "data": { + "devices": "Devices" + } + } + }, + "abort": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/custom_components/bhyve/switch.py b/custom_components/bhyve/switch.py index 7f3d3c0..8c05989 100644 --- a/custom_components/bhyve/switch.py +++ b/custom_components/bhyve/switch.py @@ -1,8 +1,9 @@ """Support for Orbit BHyve switch (toggle zone).""" import datetime +from datetime import timedelta import logging +from typing import Any -from datetime import timedelta import voluptuous as vol from homeassistant.components.switch import ( @@ -10,19 +11,19 @@ DOMAIN as SWITCH_DOMAIN, SwitchEntity, ) - -from homeassistant.const import ATTR_ENTITY_ID -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.util import dt - -from . import BHyveWebsocketEntity, BHyveDeviceEntity +from . import BHyveDeviceEntity, BHyveWebsocketEntity from .const import ( - DATA_BHYVE, + CONF_CLIENT, DEVICE_SPRINKLER, DOMAIN, EVENT_CHANGE_MODE, @@ -35,7 +36,7 @@ SIGNAL_UPDATE_PROGRAM, ) from .pybhyve.errors import BHyveError -from .util import orbit_time_to_local_time +from .util import filter_configured_devices, orbit_time_to_local_time _LOGGER = logging.getLogger(__name__) @@ -54,7 +55,6 @@ # Service Attributes ATTR_MINUTES = "minutes" ATTR_HOURS = "hours" -ATTR_PERCENTAGE = "percentage" # Rain Delay Attributes ATTR_CAUSE = "cause" @@ -88,18 +88,11 @@ } ) -SET_SMART_WATERING_SOIL_MOISTURE_SCHEMA = SERVICE_BASE_SCHEMA.extend( - { - vol.Required(ATTR_PERCENTAGE): cv.positive_int, - } -) - SERVICE_ENABLE_RAIN_DELAY = "enable_rain_delay" SERVICE_DISABLE_RAIN_DELAY = "disable_rain_delay" SERVICE_START_WATERING = "start_watering" SERVICE_STOP_WATERING = "stop_watering" SERVICE_SET_MANUAL_PRESET_RUNTIME = "set_manual_preset_runtime" -SERVICE_SET_SMART_WATERING_SOIL_MOISTURE = "set_smart_watering_soil_moisture" SERVICE_TO_METHOD = { SERVICE_ENABLE_RAIN_DELAY: { @@ -119,19 +112,18 @@ "method": "set_manual_preset_runtime", "schema": SET_PRESET_RUNTIME_SCHEMA, }, - SERVICE_SET_SMART_WATERING_SOIL_MOISTURE: { - "method": "set_smart_watering_soil_moisture", - "schema": SET_SMART_WATERING_SOIL_MOISTURE_SCHEMA, - }, } -async def async_setup_platform(hass, config, async_add_entities, _discovery_info=None): - """Set up BHyve binary sensors based on a config entry.""" - bhyve = hass.data[DATA_BHYVE] +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the BHyve switch platform from a config entry.""" + + bhyve = hass.data[DOMAIN][entry.entry_id][CONF_CLIENT] switches = [] - devices = await bhyve.devices + devices = filter_configured_devices(entry, await bhyve.devices) programs = await bhyve.timer_programs device_by_id = {} @@ -168,9 +160,10 @@ async def async_setup_platform(hass, config, async_add_entities, _discovery_info program_device = device_by_id.get(program.get("device_id")) program_id = program.get("program") if program_id is not None: + _LOGGER.info("Creating switch: Program %s", program.get("name")) switches.append( BHyveProgramSwitch( - hass, bhyve, program, program_device, "bulletin-board" + hass, bhyve, program_device, program, "bulletin-board" ) ) @@ -195,7 +188,7 @@ async def async_service_handler(service): return method_name = method["method"] - _LOGGER.debug("Service handler: %s, %s", method_name, params) + _LOGGER.debug("Service handler: %s %s", method_name, params) for entity in target_switches: if not hasattr(entity, method_name): @@ -203,7 +196,7 @@ async def async_service_handler(service): return await getattr(entity, method_name)(**params) - for service in SERVICE_TO_METHOD.keys(): + for service in SERVICE_TO_METHOD: schema = SERVICE_TO_METHOD[service]["schema"] hass.services.async_register( DOMAIN, service, async_service_handler, schema=schema @@ -213,15 +206,14 @@ async def async_service_handler(service): class BHyveProgramSwitch(BHyveWebsocketEntity, SwitchEntity): """Define a BHyve program switch.""" - def __init__(self, hass, bhyve, program, device, icon): + def __init__(self, hass, bhyve, device, program, icon): """Initialize the switch.""" device_name = device.get("name") program_name = program.get("name") name = f"{device_name} {program_name} program" - _LOGGER.info("Creating switch: %s", name) - super().__init__(hass, bhyve, name, icon, DEVICE_CLASS_SWITCH) + super().__init__(hass, bhyve, device, name, icon, DEVICE_CLASS_SWITCH) self._program = program self._device_id = program.get("device_id") @@ -251,23 +243,23 @@ def is_on(self): @property def unique_id(self): - """Return a unique id for the entity. Changing this results in a backwards incompatible change.""" + """Return the unique id for the switch program.""" return f"bhyve:program:{self._program_id}" @property def entity_category(self): - """Zone program is a configuration category""" + """Zone program is a configuration category.""" return EntityCategory.CONFIG async def _set_state(self, is_on): self._program.update({"enabled": is_on}) await self._bhyve.update_program(self._program_id, self._program) - async def async_turn_on(self): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" await self._set_state(True) - async def async_turn_off(self): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" await self._set_state(False) @@ -295,16 +287,17 @@ async def async_will_remove_from_hass(self): self._async_unsub_dispatcher_connect() def _on_ws_data(self, data): - """ - {'event': 'program_changed' } - """ + # + # {'event': 'program_changed' } + # _LOGGER.info("Received program data update %s", data) event = data.get("event") if event is None: _LOGGER.warning("No event on ws data %s", data) return - elif event == EVENT_PROGRAM_CHANGED: + + if event == EVENT_PROGRAM_CHANGED: program = data.get("program") if program is not None: self._program = program @@ -323,7 +316,6 @@ def __init__(self, hass, bhyve, device, zone, device_programs, icon): self._zone_id = zone.get("station") self._entity_picture = zone.get("image_url") self._zone_name = zone.get("name") - self._smart_watering_enabled = zone.get("smart_watering_enabled") self._manual_preset_runtime = device.get( "manual_preset_runtime_sec", DEFAULT_MANUAL_RUNTIME.seconds ) @@ -341,7 +333,7 @@ def _setup(self, device): "device_name": self._device_name, "device_id": self._device_id, "zone_name": self._zone_name, - ATTR_SMART_WATERING_ENABLED: self._smart_watering_enabled, + ATTR_SMART_WATERING_ENABLED: False, } self._available = device.get("is_connected", False) @@ -352,11 +344,7 @@ def _setup(self, device): zones = device.get("zones", []) - zone = None - for z in zones: - if z.get("station") == self._zone_id: - zone = z - break + zone = next(filter(lambda z: z.get("station") == self._zone_id, zones), None) if zone is not None: is_watering = ( @@ -419,6 +407,9 @@ def _set_watering_program(self, program): "is_smart_program": is_smart_program, } + if is_smart_program: + self._attrs[ATTR_SMART_WATERING_ENABLED] = program_enabled + if not program_enabled or not active_program_run_times: _LOGGER.info( "%s Zone: Watering program %s (%s) is not enabled, skipping", @@ -430,15 +421,15 @@ def _set_watering_program(self, program): return # - # "name": "Backyard", - # "frequency": { "type": "days", "days": [1, 4] }, - # "start_times": ["07:30"], - # "budget": 100, - # "program": "a", - # "run_times": [{ "run_time": 20, "station": 1 }], + # "name": "Backyard", + # "frequency": { "type": "days", "days": [1, 4] }, + # "start_times": ["07:30"], + # "budget": 100, + # "program": "a", + # "run_times": [{ "run_time": 20, "station": 1 }], # - if is_smart_program: + if is_smart_program is True: upcoming_run_times = [] for plan in program.get("watering_plan", []): run_times = plan.get("run_times") @@ -449,9 +440,13 @@ def _set_watering_program(self, program): if zone_times: plan_date = orbit_time_to_local_time(plan.get("date")) for time in plan.get("start_times", []): - t = dt.parse_time(time) + upcoming_time = dt.parse_time(time) upcoming_run_times.append( - plan_date + timedelta(hours=t.hour, minutes=t.minute) + plan_date + + timedelta( + hours=upcoming_time.hour, + minutes=upcoming_time.minute, + ) ) self._attrs[program_attr].update( {ATTR_SMART_WATERING_PLAN: upcoming_run_times} @@ -476,11 +471,12 @@ def _should_handle_event(self, event_name, data): ] def _on_ws_data(self, data): + # # {'event': 'watering_in_progress_notification', 'program': 'e', 'current_station': 1, 'run_time': 14, 'started_watering_station_at': '2020-01-09T20:29:59.000Z', 'rain_sensor_hold': False, 'device_id': 'id', 'timestamp': '2020-01-09T20:29:59.000Z'} # {'event': 'device_idle', 'device_id': 'id', 'timestamp': '2020-01-10T12:32:06.000Z'} # {'event': 'set_manual_preset_runtime', 'device_id': 'id', 'seconds': 480, 'timestamp': '2020-01-18T17:00:35.000Z'} # {'event': 'program_changed' } - + # event = data.get("event") if event in (EVENT_DEVICE_IDLE, EVENT_WATERING_COMPLETE) or ( event == EVENT_CHANGE_MODE and data.get("mode") in ("off", "auto") @@ -521,11 +517,11 @@ async def _send_station_message(self, station_payload): except BHyveError as err: _LOGGER.warning("Failed to send to BHyve websocket message %s", err) - raise err + raise (err) @property def entity_picture(self): - """Return picture of the entity""" + """Return picture of the entity.""" return self._entity_picture @property @@ -538,73 +534,19 @@ def is_on(self): """Return the status of the sensor.""" return self._is_on - async def set_smart_watering_soil_moisture(self, percentage): - """Set the soil moisture percentage for the zone.""" - if self._smart_watering_enabled: - landscape = None - try: - landscape = await self._bhyve.get_landscape( - self._device_id, self._zone_id - ) - - except BHyveError as err: - _LOGGER.warning( - "Unable to retreive current soil data for %s: %s", self.name, err - ) - - if landscape is not None: - _LOGGER.debug("Landscape data %s", landscape) - - # Define the minimum landscape update json payload - landscape_update = { - "current_water_level": 0, - "device_id": self._device_id, - "id": landscape.get("id"), - "station": self._zone_id, - } - - landscape_moisture_level_0 = landscape[ - "replenishment_point" - ] # B-hyve computed value for 0% moisture - landscape_moisture_level_100 = landscape[ - "field_capacity_depth" - ] # B-hyve computed value for 100% moisture - # Set property to computed user desired soil moisture level - landscape_update["current_water_level"] = landscape_moisture_level_0 + ( - ( - percentage - * (landscape_moisture_level_100 - landscape_moisture_level_0) - ) - / 100.0 - ) - - try: - _LOGGER.debug("Landscape update %s", landscape_update) - await self._bhyve.update_landscape(landscape_update) - - except BHyveError as err: - _LOGGER.warning( - "Unable to set soil moisture level for %s: %s", self.name, err - ) - else: - _LOGGER.info( - "Zone %s isn't smart watering enabled, cannot set soil moisture.", - self._zone_name, - ) - async def start_watering(self, minutes): - """Start watering program""" + """Turns on the switch and starts watering.""" station_payload = [{"station": self._zone_id, "run_time": minutes}] self._is_on = True await self._send_station_message(station_payload) async def stop_watering(self): - """Stop watering program""" + """Turns off the switch and stops watering.""" station_payload = [] self._is_on = False await self._send_station_message(station_payload) - async def async_turn_on(self): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" run_time = self._manual_preset_runtime / 60 if run_time == 0: @@ -617,7 +559,7 @@ async def async_turn_on(self): await self.start_watering(run_time) - async def async_turn_off(self): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" await self.stop_watering() @@ -649,7 +591,9 @@ def _setup(self, device): self._extract_rain_delay(rain_delay, device_status) def _on_ws_data(self, data): + # # {'event': 'rain_delay', 'device_id': 'id', 'delay': 0, 'timestamp': '2020-01-14T12:10:10.000Z'} + # event = data.get("event") if event is None: _LOGGER.warning("No event on ws data %s", data) @@ -704,15 +648,15 @@ def unique_id(self): @property def entity_category(self): - """Rain delay is a configuration category""" + """Rain delay is a configuration category.""" return EntityCategory.CONFIG - async def async_turn_on(self): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" self._is_on = True await self.enable_rain_delay() - async def async_turn_off(self): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" self._is_on = False await self.disable_rain_delay() diff --git a/custom_components/bhyve/translations/en.json b/custom_components/bhyve/translations/en.json new file mode 100644 index 0000000..f8928b5 --- /dev/null +++ b/custom_components/bhyve/translations/en.json @@ -0,0 +1,50 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured", + "no_devices_found": "No devices were found for this account", + "reauth_successful": "Re-authentication was successful" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "device": { + "data": { + "devices": "Devices" + }, + "title": "Select Devices" + }, + "reauth": { + "data": { + "password": "Password" + }, + "description": "Update password for {username}", + "title": "Reauthenticate Integration" + }, + "user": { + "data": { + "password": "Password", + "username": "Username" + } + } + } + }, + "options": { + "abort": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "init": { + "data": { + "devices": "Devices" + }, + "title": "Select Devices" + } + } + } +} diff --git a/custom_components/bhyve/util.py b/custom_components/bhyve/util.py index 4423a42..a12d90a 100755 --- a/custom_components/bhyve/util.py +++ b/custom_components/bhyve/util.py @@ -1,14 +1,16 @@ +from homeassistant.config_entries import ConfigEntry from homeassistant.util import dt +from .const import CONF_DEVICES + def orbit_time_to_local_time(timestamp: str): + """Converts the Orbit API timestamp to local time.""" if timestamp is not None: return dt.as_local(dt.parse_datetime(timestamp)) return None -def anonymize(device): - device["address"] = "REDACTED" - device["full_location"] = "REDACTED" - device["location"] = "REDACTED" - return device +def filter_configured_devices(entry: ConfigEntry, all_devices): + """Filter the device list to those that are enabled in options.""" + return [d for d in all_devices if str(d["id"]) in entry.options[CONF_DEVICES]] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c1a08a3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,156 @@ +[build-system] +requires = ["setuptools~=60.5", "wheel~=0.37.1"] +build-backend = "setuptools.build_meta" + +[tool.black] +target-version = ["py39", "py310"] +exclude = 'generated' + +[tool.isort] +# https://github.com/PyCQA/isort/wiki/isort-Settings +profile = "black" +# will group `import x` and `from x import` of the same module. +force_sort_within_sections = true +known_first_party = [ + "homeassistant", + "tests", +] +forced_separate = [ + "tests", +] +combine_as_imports = true + +[tool.pylint.MASTER] +py-version = "3.9" +ignore = [ + "tests", +] +# Use a conservative default here; 2 should speed up most setups and not hurt +# any too bad. Override on command line as appropriate. +jobs = 2 +init-hook = """\ + from pathlib import Path; \ + import sys; \ + + from pylint.config import find_default_config_files; \ + + sys.path.append( \ + str(Path(next(find_default_config_files())).parent.joinpath('pylint/plugins')) + ) \ + """ +load-plugins = [ + "pylint.extensions.code_style", + "pylint.extensions.typing", + "pylint_strict_informational", + "hass_constructor", + "hass_enforce_type_hints", + "hass_imports", + "hass_logger", +] +persistent = false +extension-pkg-allow-list = [ + "av.audio.stream", + "av.stream", + "ciso8601", + "cv2", +] + +[tool.pylint.BASIC] +class-const-naming-style = "any" +good-names = [ + "_", + "ev", + "ex", + "fp", + "i", + "id", + "j", + "k", + "Run", + "T", + "ip", +] + +[tool.pylint."MESSAGES CONTROL"] +# Reasons disabled: +# format - handled by black +# locally-disabled - it spams too much +# duplicate-code - unavoidable +# cyclic-import - doesn't test if both import on load +# abstract-class-little-used - prevents from setting right foundation +# unused-argument - generic callbacks and setup methods create a lot of warnings +# too-many-* - are not enforced for the sake of readability +# too-few-* - same as too-many-* +# abstract-method - with intro of async there are always methods missing +# inconsistent-return-statements - doesn't handle raise +# too-many-ancestors - it's too strict. +# wrong-import-order - isort guards this +# consider-using-f-string - str.format sometimes more readable +# --- +# Enable once current issues are fixed: +# consider-using-namedtuple-or-dataclass (Pylint CodeStyle extension) +# consider-using-assignment-expr (Pylint CodeStyle extension) +disable = [ + "format", + "abstract-class-little-used", + "abstract-method", + "cyclic-import", + "duplicate-code", + "inconsistent-return-statements", + "locally-disabled", + "not-context-manager", + "too-few-public-methods", + "too-many-ancestors", + "too-many-arguments", + "too-many-branches", + "too-many-instance-attributes", + "too-many-lines", + "too-many-locals", + "too-many-public-methods", + "too-many-return-statements", + "too-many-statements", + "too-many-boolean-expressions", + "unused-argument", + "wrong-import-order", + "consider-using-f-string", + "consider-using-namedtuple-or-dataclass", + "consider-using-assignment-expr", +] +enable = [ + #"useless-suppression", # temporarily every now and then to clean them up + "use-symbolic-message-instead", +] + +[tool.pylint.REPORTS] +score = false + +[tool.pylint.TYPECHECK] +ignored-classes = [ + "_CountingAttr", # for attrs +] +mixin-class-rgx = ".*[Mm]ix[Ii]n" + +[tool.pylint.FORMAT] +expected-line-ending-format = "LF" + +[tool.pylint.EXCEPTIONS] +overgeneral-exceptions = [ + "BaseException", + "Exception", + "HomeAssistantError", +] + +[tool.pylint.TYPING] +runtime-typing = false + +[tool.pylint.CODE_STYLE] +max-line-length-suggestions = 72 + +[tool.pytest.ini_options] +testpaths = [ + "tests", +] +norecursedirs = [ + ".git", + "testing_config", +]