diff --git a/custom_components/hilo/__init__.py b/custom_components/hilo/__init__.py index a56a8b47..7fb1d9a3 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,11 +76,16 @@ SIGNAL_UPDATE_ENTITY = "pyhilo_device_update_{}" COORDINATOR_AWARE_PLATFORMS = [Platform.SENSOR] PLATFORMS = COORDINATOR_AWARE_PLATFORMS + [ + Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.LIGHT, 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: @@ -209,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: @@ -328,6 +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 ) + # 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 @@ -336,14 +349,47 @@ 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 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.""" - 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.""" - 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.""" @@ -511,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) @@ -717,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 @@ -774,46 +824,50 @@ 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) + 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(hub_id, False) + if not self.should_signalr_reconnect: return try: await asyncio.sleep(backoff) except asyncio.CancelledError: - LOG.debug("SignalRHub[%s]: sleep cancelled — stopping", id) + LOG.debug("SignalRHub[%s]: sleep cancelled — stopping", hub_id) + self._set_hub_connected(hub_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..cc514c45 --- /dev/null +++ b/custom_components/hilo/binary_sensor.py @@ -0,0 +1,78 @@ +"""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 HUB_CHALLENGES, HUB_DEVICES, 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 any(self._hilo._hub_connected.values()) + + @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._hub_connected[HUB_DEVICES], + "challenges_hub": self._hilo._hub_connected[HUB_CHALLENGES], + } + + 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"