From 17f60ad454c44be369af556e73323e23bc92ac86 Mon Sep 17 00:00:00 2001 From: JFC-Dev <44585847+JFC-Dev@users.noreply.github.com> Date: Sat, 11 Apr 2026 15:41:09 -0400 Subject: [PATCH 1/5] Add files via upload --- custom_components/hilo/__init__.py | 19 ++++++ custom_components/hilo/binary_sensor.py | 81 +++++++++++++++++++++++++ custom_components/hilo/const.py | 1 + 3 files changed, 101 insertions(+) create mode 100644 custom_components/hilo/binary_sensor.py diff --git a/custom_components/hilo/__init__.py b/custom_components/hilo/__init__.py index a56a8b47..cee9bc91 100644 --- a/custom_components/hilo/__init__.py +++ b/custom_components/hilo/__init__.py @@ -68,6 +68,7 @@ HILO_ENERGY_TOTAL, LOG, MIN_SCAN_INTERVAL, + SIGNAL_WEBSOCKET_STATUS, ) from .oauth2 import AuthCodeWithPKCEImplementation @@ -75,6 +76,7 @@ SIGNAL_UPDATE_ENTITY = "pyhilo_device_update_{}" COORDINATOR_AWARE_PLATFORMS = [Platform.SENSOR] PLATFORMS = COORDINATOR_AWARE_PLATFORMS + [ + Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.LIGHT, Platform.SWITCH, @@ -328,6 +330,8 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry, api: API) -> None: self.generate_energy_meters = entry.options.get( CONF_GENERATE_ENERGY_METERS, DEFAULT_GENERATE_ENERGY_METERS ) + self._device_hub_connected: bool = False + self._challenge_hub_connected: bool = False # This will get filled in by async_init: self.coordinator: DataUpdateCoordinator | None = None self.unknown_tracker_device: HiloDevice | None = None @@ -336,12 +340,23 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry, api: API) -> None: self._api._get_device_callbacks = [self._get_unknown_source_tracker] self._signalr_listeners = [] + def _set_hub_connected(self, hub_id: int, connected: bool) -> None: + """Update the connectivity state of a SignalR hub and notify listeners.""" + if hub_id == 0: + self._device_hub_connected = connected + elif hub_id == 1: + self._challenge_hub_connected = connected + async_dispatcher_send(self._hass, SIGNAL_WEBSOCKET_STATUS) + async def _on_devices_connected(self) -> None: """Trigger device subscriptions after the device hub connects.""" + self._set_hub_connected(0, True) await self.subscribe_to_location() + async def _on_challenges_connected(self) -> None: """Trigger challenge subscriptions after the challenge hub connects.""" + self._set_hub_connected(1, True) await self.subscribe_to_challenge() await self.subscribe_to_challengelist() @@ -791,6 +806,7 @@ async def start_signalr_loop(self, hub, id) -> None: backoff = 5 except asyncio.CancelledError: LOG.debug("SignalRHub[%s]: loop cancelled — stopping", id) + self._set_hub_connected(id, False) return except SignalRServerError as err: LOG.warning( @@ -807,6 +823,8 @@ async def start_signalr_loop(self, hub, id) -> None: err, ) + self._set_hub_connected(id, False) + if not self.should_signalr_reconnect: return @@ -814,6 +832,7 @@ async def start_signalr_loop(self, hub, id) -> None: await asyncio.sleep(backoff) except asyncio.CancelledError: LOG.debug("SignalRHub[%s]: sleep cancelled — stopping", id) + self._set_hub_connected(id, False) return backoff = min(backoff * 2, 300) diff --git a/custom_components/hilo/binary_sensor.py b/custom_components/hilo/binary_sensor.py new file mode 100644 index 00000000..83d97374 --- /dev/null +++ b/custom_components/hilo/binary_sensor.py @@ -0,0 +1,81 @@ +"""Support for Hilo WebSocket connectivity binary sensor.""" + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import slugify + +from . import Hilo +from .const import DOMAIN, LOG, SIGNAL_WEBSOCKET_STATUS +from .entity import HiloEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Hilo binary sensor entities.""" + hilo = hass.data[DOMAIN][entry.entry_id] + entities = [] + + for d in hilo.devices.all: + if d.type == "Gateway": + entities.append(HiloWebSocketStatusSensor(hilo, d)) + async_add_entities(entities) + + +class HiloWebSocketStatusSensor(HiloEntity, BinarySensorEntity): + """Binary sensor representing the overall WebSocket connectivity.""" + + _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + + def __init__(self, hilo: Hilo, device): + """Initialize the WebSocket status binary sensor.""" + self._attr_name = "WebSocket Status" + super().__init__(hilo, name=self._attr_name, device=device) + self._attr_unique_id = f"{slugify(device.identifier)}-websocket-status" + LOG.debug("Setting up WebSocket status binary sensor: %s", self._attr_name) + + @property + def is_on(self) -> bool: + """Return True if any WebSocket hub is connected.""" + return ( + self._hilo._device_hub_connected + or self._hilo._challenge_hub_connected + ) + + @property + def icon(self) -> str: + """Return icon based on connectivity state.""" + return "mdi:lan-connect" if self.is_on else "mdi:lan-disconnect" + + @property + def extra_state_attributes(self) -> dict: + """Return individual hub connectivity details.""" + return { + "devices_hub": self._hilo._device_hub_connected, + "challenges_hub": self._hilo._challenge_hub_connected, + } + + async def async_added_to_hass(self) -> None: + """Register dispatcher listener when added to hass.""" + await super().async_added_to_hass() + self._unsub_status = async_dispatcher_connect( + self._hilo._hass, + SIGNAL_WEBSOCKET_STATUS, + self._handle_status_update, + ) + + async def async_will_remove_from_hass(self) -> None: + """Unregister dispatcher listener when removed.""" + await super().async_will_remove_from_hass() + self._unsub_status() + + @callback + def _handle_status_update(self) -> None: + """Handle connectivity status change from dispatcher.""" + self.async_write_ha_state() diff --git a/custom_components/hilo/const.py b/custom_components/hilo/const.py index 70538690..72367f96 100755 --- a/custom_components/hilo/const.py +++ b/custom_components/hilo/const.py @@ -7,6 +7,7 @@ LOG = logging.getLogger(__package__) DOMAIN = "hilo" HILO_ENERGY_TOTAL = "hilo_energy_total" +SIGNAL_WEBSOCKET_STATUS = "pyhilo_websocket_status" # Configurations CONF_APPRECIATION_PHASE = "appreciation_phase" From e9e9c5dca5f2694d9607d664ea814dd86843ee46 Mon Sep 17 00:00:00 2001 From: JFC-Dev <44585847+JFC-Dev@users.noreply.github.com> Date: Sat, 11 Apr 2026 15:42:45 -0400 Subject: [PATCH 2/5] Update __init__.py --- custom_components/hilo/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/custom_components/hilo/__init__.py b/custom_components/hilo/__init__.py index cee9bc91..e2b1e4d5 100644 --- a/custom_components/hilo/__init__.py +++ b/custom_components/hilo/__init__.py @@ -353,7 +353,6 @@ async def _on_devices_connected(self) -> None: self._set_hub_connected(0, True) await self.subscribe_to_location() - async def _on_challenges_connected(self) -> None: """Trigger challenge subscriptions after the challenge hub connects.""" self._set_hub_connected(1, True) From 9d492504a39d671c1aedc31787747c749837b9ae Mon Sep 17 00:00:00 2001 From: "Ian C." <108159253+ic-dev21@users.noreply.github.com> Date: Sat, 11 Apr 2026 21:20:46 -0400 Subject: [PATCH 3/5] Linting --- custom_components/hilo/binary_sensor.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/custom_components/hilo/binary_sensor.py b/custom_components/hilo/binary_sensor.py index 83d97374..376aeabc 100644 --- a/custom_components/hilo/binary_sensor.py +++ b/custom_components/hilo/binary_sensor.py @@ -43,10 +43,7 @@ def __init__(self, hilo: Hilo, device): @property def is_on(self) -> bool: """Return True if any WebSocket hub is connected.""" - return ( - self._hilo._device_hub_connected - or self._hilo._challenge_hub_connected - ) + return self._hilo._device_hub_connected or self._hilo._challenge_hub_connected @property def icon(self) -> str: From 9094d827442b41aa56b87e26d3ed7e68fa876cc2 Mon Sep 17 00:00:00 2001 From: JFC-Dev <44585847+JFC-Dev@users.noreply.github.com> Date: Sun, 26 Apr 2026 14:15:56 -0400 Subject: [PATCH 4/5] Update __init__.py New callbacks --- custom_components/hilo/__init__.py | 94 +++++++++++++++++++++--------- 1 file changed, 65 insertions(+), 29 deletions(-) diff --git a/custom_components/hilo/__init__.py b/custom_components/hilo/__init__.py index e2b1e4d5..7fb1d9a3 100644 --- a/custom_components/hilo/__init__.py +++ b/custom_components/hilo/__init__.py @@ -82,6 +82,10 @@ Platform.SWITCH, ] +# SignalR hub identifiers (used as indices into per-hub state dicts/lists). +HUB_DEVICES = 0 +HUB_CHALLENGES = 1 + @callback def _async_standardize_config_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: @@ -211,7 +215,9 @@ async def handle_debug_event(event: Event): LOG.debug("HILO_DEBUG: log_traces is %s", log_traces) signalr_event = signalr_event_from_payload(event.data) LOG.debug("HILO_DEBUG: SignalR event parsed: %s", signalr_event) - await hilo.on_signalr_event(signalr_event) + # Debug events have no real hub origin; attribute them to the + # devices hub by convention. + await hilo.on_signalr_event(signalr_event, HUB_DEVICES) log_traces = current_options.get(CONF_LOG_TRACES) if log_traces: @@ -330,8 +336,11 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry, api: API) -> None: self.generate_energy_meters = entry.options.get( CONF_GENERATE_ENERGY_METERS, DEFAULT_GENERATE_ENERGY_METERS ) - self._device_hub_connected: bool = False - self._challenge_hub_connected: bool = False + # Per-hub connectivity state. Indexed by HUB_DEVICES / HUB_CHALLENGES. + self._hub_connected: dict[int, bool] = { + HUB_DEVICES: False, + HUB_CHALLENGES: False, + } # This will get filled in by async_init: self.coordinator: DataUpdateCoordinator | None = None self.unknown_tracker_device: HiloDevice | None = None @@ -342,22 +351,45 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry, api: API) -> None: def _set_hub_connected(self, hub_id: int, connected: bool) -> None: """Update the connectivity state of a SignalR hub and notify listeners.""" - if hub_id == 0: - self._device_hub_connected = connected - elif hub_id == 1: - self._challenge_hub_connected = connected + if self._hub_connected.get(hub_id) == connected: + return + self._hub_connected[hub_id] = connected async_dispatcher_send(self._hass, SIGNAL_WEBSOCKET_STATUS) async def _on_devices_connected(self) -> None: """Trigger device subscriptions after the device hub connects.""" - self._set_hub_connected(0, True) - await self.subscribe_to_location() + try: + await self.subscribe_to_location() + except Exception: + self._set_hub_connected(HUB_DEVICES, False) + raise + self._set_hub_connected(HUB_DEVICES, True) async def _on_challenges_connected(self) -> None: """Trigger challenge subscriptions after the challenge hub connects.""" - self._set_hub_connected(1, True) - await self.subscribe_to_challenge() - await self.subscribe_to_challengelist() + try: + await self.subscribe_to_challenge() + await self.subscribe_to_challengelist() + except Exception: + self._set_hub_connected(HUB_CHALLENGES, False) + raise + self._set_hub_connected(HUB_CHALLENGES, True) + + async def _on_devices_disconnected(self) -> None: + """Mark the device hub as disconnected.""" + self._set_hub_connected(HUB_DEVICES, False) + + async def _on_challenges_disconnected(self) -> None: + """Mark the challenge hub as disconnected.""" + self._set_hub_connected(HUB_CHALLENGES, False) + + async def _on_devices_event(self, event: SignalREvent) -> None: + """Dispatch a SignalR event from the device hub.""" + await self.on_signalr_event(event, HUB_DEVICES) + + async def _on_challenges_event(self, event: SignalREvent) -> None: + """Dispatch a SignalR event from the challenge hub.""" + await self.on_signalr_event(event, HUB_CHALLENGES) def validate_heartbeat(self, event: SignalREvent) -> None: """Validate heartbeat messages from SignalR.""" @@ -525,7 +557,7 @@ async def _handle_device_events(self, event: SignalREvent) -> None: ) @callback - async def on_signalr_event(self, event: SignalREvent) -> None: + async def on_signalr_event(self, event: SignalREvent, hub_id: int) -> None: """Define a callback for receiving a SignalR event.""" async_dispatcher_send(self._hass, DISPATCHER_TOPIC_SIGNALR_EVENT, event) @@ -731,14 +763,18 @@ async def async_init(self, scan_interval: int) -> None: # Step 2: Register SignalR callbacks and start connections self._api.signalr_devices.add_connect_callback(self._on_devices_connected) + self._api.signalr_devices.add_disconnect_callback(self._on_devices_disconnected) + self._api.signalr_devices.add_event_callback(self._on_devices_event) self._api.signalr_challenges.add_connect_callback(self._on_challenges_connected) - self._api.signalr_devices.add_event_callback(self.on_signalr_event) - self._api.signalr_challenges.add_event_callback(self.on_signalr_event) - self._signalr_reconnect_tasks[0] = asyncio.create_task( - self.start_signalr_loop(self._api.signalr_devices, 0) + self._api.signalr_challenges.add_disconnect_callback( + self._on_challenges_disconnected + ) + self._api.signalr_challenges.add_event_callback(self._on_challenges_event) + self._signalr_reconnect_tasks[HUB_DEVICES] = asyncio.create_task( + self.start_signalr_loop(self._api.signalr_devices, HUB_DEVICES) ) - self._signalr_reconnect_tasks[1] = asyncio.create_task( - self.start_signalr_loop(self._api.signalr_challenges, 1) + self._signalr_reconnect_tasks[HUB_CHALLENGES] = asyncio.create_task( + self.start_signalr_loop(self._api.signalr_challenges, HUB_CHALLENGES) ) # Step 3: Wait for DeviceListInitialValuesReceived from SignalR @@ -788,41 +824,41 @@ async def signalr_disconnect_listener(_: Event) -> None: update_method=self.async_update, ) - async def start_signalr_loop(self, hub, id) -> None: + async def start_signalr_loop(self, hub, hub_id: int) -> None: """Start a SignalR reconnection loop that retries forever until HA stops.""" backoff = 5 # seconds; doubles on each error, reset to 5 after a clean run while self.should_signalr_reconnect: try: - LOG.info("SignalRHub[%s]: connecting", id) + LOG.info("SignalRHub[%s]: connecting", hub_id) await hub.run() # hub.run() returned without raising — server closed the connection. # That's a normal disconnect; reset backoff and reconnect quickly. LOG.warning( "SignalRHub[%s]: connection closed by server; reconnecting in %ss", - id, + hub_id, backoff, ) backoff = 5 except asyncio.CancelledError: - LOG.debug("SignalRHub[%s]: loop cancelled — stopping", id) - self._set_hub_connected(id, False) + LOG.debug("SignalRHub[%s]: loop cancelled — stopping", hub_id) + self._set_hub_connected(hub_id, False) return except SignalRServerError as err: LOG.warning( "SignalRHub[%s]: server-initiated close; reconnecting in %ss — %s", - id, + hub_id, backoff, err, ) except Exception as err: # pylint: disable=broad-except LOG.warning( "SignalRHub[%s]: connection error; reconnecting in %ss — %s", - id, + hub_id, backoff, err, ) - self._set_hub_connected(id, False) + self._set_hub_connected(hub_id, False) if not self.should_signalr_reconnect: return @@ -830,8 +866,8 @@ async def start_signalr_loop(self, hub, id) -> None: try: await asyncio.sleep(backoff) except asyncio.CancelledError: - LOG.debug("SignalRHub[%s]: sleep cancelled — stopping", id) - self._set_hub_connected(id, False) + LOG.debug("SignalRHub[%s]: sleep cancelled — stopping", hub_id) + self._set_hub_connected(hub_id, False) return backoff = min(backoff * 2, 300) From ead53da9176cd6bb486d203da9bd4c3667f211a2 Mon Sep 17 00:00:00 2001 From: JFC-Dev <44585847+JFC-Dev@users.noreply.github.com> Date: Sun, 26 Apr 2026 14:16:40 -0400 Subject: [PATCH 5/5] Update binary_sensor.py Use hub index --- custom_components/hilo/binary_sensor.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/custom_components/hilo/binary_sensor.py b/custom_components/hilo/binary_sensor.py index 376aeabc..cc514c45 100644 --- a/custom_components/hilo/binary_sensor.py +++ b/custom_components/hilo/binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify -from . import Hilo +from . import HUB_CHALLENGES, HUB_DEVICES, Hilo from .const import DOMAIN, LOG, SIGNAL_WEBSOCKET_STATUS from .entity import HiloEntity @@ -43,7 +43,7 @@ def __init__(self, hilo: Hilo, device): @property def is_on(self) -> bool: """Return True if any WebSocket hub is connected.""" - return self._hilo._device_hub_connected or self._hilo._challenge_hub_connected + return any(self._hilo._hub_connected.values()) @property def icon(self) -> str: @@ -54,8 +54,8 @@ def icon(self) -> str: def extra_state_attributes(self) -> dict: """Return individual hub connectivity details.""" return { - "devices_hub": self._hilo._device_hub_connected, - "challenges_hub": self._hilo._challenge_hub_connected, + "devices_hub": self._hilo._hub_connected[HUB_DEVICES], + "challenges_hub": self._hilo._hub_connected[HUB_CHALLENGES], } async def async_added_to_hass(self) -> None: