From 763d2ada2ed0a53849f69fea3c1591f9e276af9f Mon Sep 17 00:00:00 2001 From: Jason Cheatham Date: Sat, 13 Jun 2020 19:28:28 -0400 Subject: [PATCH] Create hub ID, use in events Use the first 8 characters of the hub access token as the hub ID. Use this in the config entry title (update existing titles) and as a device identifier, and and include it in hubitat_events. resolves #50 --- custom_components/hubitat/__init__.py | 6 ++++ custom_components/hubitat/config_flow.py | 5 ++- custom_components/hubitat/const.py | 1 + custom_components/hubitat/device.py | 34 ++++++++------------- custom_components/hubitat/device_trigger.py | 30 +++++++++++------- custom_components/hubitat/util.py | 17 +++++++++++ stubs/homeassistant/config_entries.pyi | 13 +++++++- 7 files changed, 70 insertions(+), 36 deletions(-) create mode 100644 custom_components/hubitat/util.py diff --git a/custom_components/hubitat/__init__.py b/custom_components/hubitat/__init__.py index 7faee46..a33dee6 100644 --- a/custom_components/hubitat/__init__.py +++ b/custom_components/hubitat/__init__.py @@ -1,6 +1,7 @@ """The Hubitat integration.""" from asyncio import gather from logging import getLogger +import re from typing import Any, Dict import voluptuous as vol @@ -41,6 +42,11 @@ def stop_hub(event: Event) -> None: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_hub) + # If this config entry's title uses a MAC address, rename it to use the hub + # ID + if re.match(r"Hubitat \(\w{2}(:\w{2}){5}\)", entry.title): + hass.config_entries.async_update_entry(entry, title=f"Hubitat ({hub.id})") + hass.bus.fire(CONF_HUBITAT_EVENT, {"name": "ready"}) _LOGGER.info("Hubitat is ready") diff --git a/custom_components/hubitat/config_flow.py b/custom_components/hubitat/config_flow.py index 536b757..ee4b5f5 100644 --- a/custom_components/hubitat/config_flow.py +++ b/custom_components/hubitat/config_flow.py @@ -22,6 +22,7 @@ from homeassistant.core import callback from .const import CONF_APP_ID, CONF_SERVER_PORT, DOMAIN, TEMP_C, TEMP_F +from .util import get_hub_short_id _LOGGER = logging.getLogger(__name__) @@ -47,9 +48,7 @@ async def validate_input(data: Dict[str, Any]) -> Dict[str, Any]: hub = HubitatHub(host, app_id, token) await hub.check_config() - return { - "label": f"Hubitat ({hub.mac})", - } + return {"label": f"Hubitat ({get_hub_short_id(hub)})"} class HubitatConfigFlow(ConfigFlow, domain=DOMAIN): # type: ignore diff --git a/custom_components/hubitat/const.py b/custom_components/hubitat/const.py index 2edb7cb..c76e1ab 100644 --- a/custom_components/hubitat/const.py +++ b/custom_components/hubitat/const.py @@ -24,6 +24,7 @@ ATTR_DOUBLE_TAPPED = "double_tapped" ATTR_ENTRY_DELAY = "entry_delay" ATTR_EXIT_DELAY = "exit_delay" +ATTR_HUB = "hub" ATTR_LAST_CODE_NAME = "last_code_name" ATTR_LENGTH = "length" ATTR_MAX_CODES = "max_codes" diff --git a/custom_components/hubitat/device.py b/custom_components/hubitat/device.py index fe81c7c..81f78d3 100644 --- a/custom_components/hubitat/device.py +++ b/custom_components/hubitat/device.py @@ -1,6 +1,5 @@ """Classes for managing Hubitat devices.""" -from hashlib import sha256 from json import loads from logging import getLogger from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Union, cast @@ -22,6 +21,7 @@ from .const import ( ATTR_ATTRIBUTE, + ATTR_HUB, CONF_APP_ID, CONF_HUBITAT_EVENT, CONF_SERVER_PORT, @@ -30,6 +30,7 @@ TEMP_F, TRIGGER_CAPABILITIES, ) +from .util import get_hub_short_id, get_token_hash # Hubitat attributes that should be emitted as HA events _TRIGGER_ATTRS = tuple([v.attr for v in TRIGGER_CAPABILITIES.values()]) @@ -98,6 +99,11 @@ def host(self) -> str: ), ) + @property + def id(self) -> str: + """A unique ID for this hub instance.""" + return get_hub_short_id(self._hub) + @property def mac(self) -> Optional[str]: """The MAC address of the associated Hubitat hub.""" @@ -113,16 +119,6 @@ def token(self) -> str: """The token used to access the Maker API.""" return cast(str, self.config_entry.data.get(CONF_ACCESS_TOKEN)) - @property - def token_hash(self) -> str: - """The token used to access the Maker API.""" - if not hasattr(self, "_token_hash"): - token = self.config_entry.data[CONF_ACCESS_TOKEN] - hasher = sha256() - hasher.update(token.encode("utf-8")) - self._token_hash = hasher.hexdigest() - return self._token_hash - @property def temperature_unit(self) -> str: """The units used for temperature values.""" @@ -200,7 +196,7 @@ async def async_update_device_registry(self) -> None: dreg.async_get_or_create( config_entry_id=self.config_entry.entry_id, connections={(device_registry.CONNECTION_NETWORK_MAC, self._hub.mac)}, - identifiers={(DOMAIN, self._hub.mac)}, + identifiers={(DOMAIN, self.id)}, manufacturer="Hubitat", name="Hubitat Elevation", ) @@ -274,7 +270,7 @@ def __init__(self, hub: Hub, device: Device) -> None: """Initialize a device.""" self._hub = hub self._device: Device = device - self._id = f"{self._hub.token_hash}::{self._device.id}" + self._id = f"{get_token_hash(hub.token)}::{self._device.id}" self._old_ids = [ f"{self._hub.host}::{self._hub.app_id}::{self._device.id}", f"{self._hub.mac}::{self._hub.app_id}::{self._device.id}", @@ -294,7 +290,7 @@ def device_info(self) -> Dict[str, Any]: "name": self._device.name, "manufacturer": "Hubitat", "model": self.type, - "via_device": (DOMAIN, self._hub.mac), + "via_device": (DOMAIN, self._hub.id), } @property @@ -363,7 +359,9 @@ def get_str_attr(self, attr: str) -> Optional[str]: def handle_event(self, event: Event) -> None: """Handle an event received from the Hubitat hub.""" if event.attribute in _TRIGGER_ATTRS: - evt = to_event_dict(event) + evt = dict(event) + evt[ATTR_ATTRIBUTE] = _TRIGGER_ATTR_MAP[event.attribute] + evt[ATTR_HUB] = self._hub.id self._hub.hass.bus.async_fire(CONF_HUBITAT_EVENT, evt) _LOGGER.debug("Emitted event %s", evt) @@ -423,9 +421,3 @@ def get_hub(hass: HomeAssistant, entry_id: str) -> Hub: """Get the Hub device associated with a given config entry.""" hub: Hub = hass.data[DOMAIN][entry_id] return hub - - -def to_event_dict(event: Event) -> Dict[str, Any]: - evt = dict(event) - evt[ATTR_ATTRIBUTE] = _TRIGGER_ATTR_MAP[event.attribute] - return evt diff --git a/custom_components/hubitat/device_trigger.py b/custom_components/hubitat/device_trigger.py index 4466682..86272c4 100644 --- a/custom_components/hubitat/device_trigger.py +++ b/custom_components/hubitat/device_trigger.py @@ -2,7 +2,7 @@ from itertools import chain from json import loads import logging -from typing import Any, Callable, Dict, List, Optional, Sequence, cast +from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, cast from hubitatmaker import ( ATTR_DEVICE_ID, @@ -29,6 +29,7 @@ from .const import ( ATTR_ATTRIBUTE, + ATTR_HUB, CONF_BUTTONS, CONF_DOUBLE_TAPPED, CONF_HELD, @@ -39,7 +40,7 @@ DOMAIN, TRIGGER_CAPABILITIES, ) -from .device import get_hub +from .device import Hub, get_hub TRIGGER_TYPES = tuple([v.conf for v in TRIGGER_CAPABILITIES.values()]) TRIGGER_SUBTYPES = set( @@ -67,7 +68,7 @@ async def async_validate_trigger_config( raise InvalidDeviceAutomationConfig if DOMAIN in hass.config.components: - hubitat_device = await get_hubitat_device(hass, device.id) + hubitat_device, _ = await get_hubitat_device(hass, device.id) if hubitat_device is None: _LOGGER.warning("Invalid Hubitat device") raise InvalidDeviceAutomationConfig @@ -91,7 +92,7 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> Sequence[Dict[str, Any]]: """List device triggers for Hubitat devices.""" - device = await get_hubitat_device(hass, device_id) + device, _ = await get_hubitat_device(hass, device_id) if device is None: return [] @@ -133,17 +134,22 @@ async def async_attach_trigger( automation_info: Dict[str, Any], ) -> Callable[[], None]: """Attach a trigger.""" - hubitat_device = await get_hubitat_device(hass, config[CONF_DEVICE_ID]) - if hubitat_device is None: + result = await get_hubitat_device(hass, config[CONF_DEVICE_ID]) + + if result[0] is None or result[1] is None: _LOGGER.warning( "Could not find Hubitat device for ID %s", config[CONF_DEVICE_ID] ) raise InvalidDeviceAutomationConfig + hubitat_device: Device = result[0] + hub: Hub = result[1] + # Event data should match up to the data a hubitat_event event would # contain event_data = { ATTR_DEVICE_ID: hubitat_device.id, + ATTR_HUB: hub.id, ATTR_ATTRIBUTE: config[CONF_TYPE], } if CONF_SUBTYPE in config: @@ -170,11 +176,13 @@ async def get_device(hass: HomeAssistant, device_id: str) -> Optional[DeviceEntr return device_registry.async_get(device_id) -async def get_hubitat_device(hass: HomeAssistant, device_id: str) -> Optional[Device]: +async def get_hubitat_device( + hass: HomeAssistant, device_id: str +) -> Tuple[Optional[Device], Optional[Hub]]: """Return a Hubitat device for a given Home Assistant device ID.""" device = await get_device(hass, device_id) if device is None: - return None + return None, None hubitat_id = None for identifier in device.identifiers: @@ -184,15 +192,15 @@ async def get_hubitat_device(hass: HomeAssistant, device_id: str) -> Optional[De if hubitat_id is None: _LOGGER.debug("Couldn't find Hubitat ID for device %s", device_id) - return None + return None, None for entry_id in device.config_entries: hub = get_hub(hass, entry_id) if hubitat_id in hub.devices: - return hub.devices[hubitat_id] + return hub.devices[hubitat_id], hub _LOGGER.debug("Couldn't find Hubitat device for ID %s", hubitat_id) - return None + return None, None def get_trigger_types(device: Device) -> Sequence[str]: diff --git a/custom_components/hubitat/util.py b/custom_components/hubitat/util.py new file mode 100644 index 0000000..39dcfbd --- /dev/null +++ b/custom_components/hubitat/util.py @@ -0,0 +1,17 @@ +from hashlib import sha256 + +from hubitatmaker import Hub + +_token_hashes = {} + + +def get_token_hash(token: str) -> str: + if token not in _token_hashes: + hasher = sha256() + hasher.update(token.encode("utf-8")) + _token_hashes[token] = hasher.hexdigest() + return _token_hashes[token] + + +def get_hub_short_id(hub: Hub) -> str: + return hub.token[:8] diff --git a/stubs/homeassistant/config_entries.pyi b/stubs/homeassistant/config_entries.pyi index 0d6b3ec..2d88ae3 100644 --- a/stubs/homeassistant/config_entries.pyi +++ b/stubs/homeassistant/config_entries.pyi @@ -1,4 +1,4 @@ -from typing import Any, Callable, Coroutine, Dict, Mapping, Optional +from typing import Any, Callable, Coroutine, Dict, Mapping, Optional, Union import voluptuous as vol @@ -10,6 +10,7 @@ class ConfigEntry: options: Mapping[str, Any] data: Dict[str, Any] entry_id: str + title: str def add_update_listener( self, listener: Callable[[HomeAssistant, ConfigEntry], Coroutine[Any, Any, None]], @@ -22,6 +23,16 @@ class ConfigEntries: async def async_forward_entry_unload( self, entry: ConfigEntry, domain: str ) -> bool: ... + def async_update_entry( + self, + entry: ConfigEntry, + *, + unique_id: Union[str, Dict[str, Any], None] = ..., + title: Union[str, Dict[str, Any]] = ..., + data: Dict[str, Any] = ..., + options: Dict[str, Any] = ..., + system_options: Dict[str, Any] = ..., + ) -> None: ... class _FlowHandler: def async_create_entry(