Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 72 additions & 18 deletions custom_components/hilo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,18 +68,24 @@
HILO_ENERGY_TOTAL,
LOG,
MIN_SCAN_INTERVAL,
SIGNAL_WEBSOCKET_STATUS,
)
from .oauth2 import AuthCodeWithPKCEImplementation

DISPATCHER_TOPIC_SIGNALR_EVENT = "pyhilo_signalr_event"
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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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."""
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
78 changes: 78 additions & 0 deletions custom_components/hilo/binary_sensor.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions custom_components/hilo/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down