ha-inlite

Home Assistant integration for in-lite
git clone https://git.stephank.nl/ha-inlite
Log | Files | Refs | README | LICENSE | ZIP

commit 06ebe123676c932f8c6b93749550b24f99b5b9fa
parent 092bec945996136948bd238eeb7a4df58c05cc8d
Author: Bennet Brünings <bennet@bbruenings.com>
Date: Mon, 18 May 2026 21:48:21 +0200

fix: reflect physical smarthub state changes in HA (#5) (#7)

When the smarthub's physical button is pressed or a zone auto-triggers
(timer/sensor), HA now reflects the change in near real-time via OOB
(Out-Of-Band) broadcast notifications from the hub.

Changes:
- hub.py: Added on_state_update callback; _parse_oob_broadcast now
  detects actual state changes and fires the callback
- coordinator.py: Registers OOB callback that pushes state to HA
  entities immediately via async_set_updated_data
- const.py: Reduced default poll interval (120s → 30s), increased
  idle disconnect (5min → 1hr) to keep connection alive for push updates
- config_flow.py: Added OptionsFlow for configurable scan_interval
  and idle_disconnect timeout
- strings.json: UI labels for new options
- __init__.py: Added options update listener for hot-reload on change

Closes #5

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

diff --git a/custom_components/inlite/__init__.py b/custom_components/inlite/__init__.py index 69d281dbf240f3daaef319f02e5e7e0218ddd5c2..436f0cd9cd86b05d71ddbe23ea558e99294c0e16 100644 --- a/custom_components/inlite/__init__.py +++ b/custom_components/inlite/__init__.py @@ -65,8 +65,18 @@ # Disconnect hubs when the config entry is unloaded entry.async_on_unload(coordinator.async_shutdown) + # Reload integration when options change (scan interval, idle disconnect) + entry.async_on_unload(entry.add_update_listener(_async_options_updated)) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True + + +async def _async_options_updated( + hass: HomeAssistant, entry: InliteConfigEntry +) -> None: + """Reload when options are updated.""" + await hass.config_entries.async_reload(entry.entry_id) async def async_unload_entry(hass: HomeAssistant, entry: InliteConfigEntry) -> bool: diff --git a/custom_components/inlite/config_flow.py b/custom_components/inlite/config_flow.py index d7c919a4d09505e5bf41c2b99a281d32b2c867b3..e3045c89b73662d2a2f1bb8beb49ea77a5e286f0 100644 --- a/custom_components/inlite/config_flow.py +++ b/custom_components/inlite/config_flow.py @@ -4,6 +4,7 @@ Supports three entry points: - User-initiated: email → code → select garden - Bluetooth discovery: HA finds "inlitebt" → user confirms → email flow - Reauth: re-run email/code flow to update credentials +- Options: configure scan interval and idle disconnect timeout """ from __future__ import annotations @@ -14,7 +15,13 @@ import voluptuous as vol from homeassistant.components.bluetooth import BluetoothServiceInfoBleak -from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) +from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from inlite_ble.cloud import ( @@ -28,9 +35,17 @@ from .const import ( CONF_GARDEN_ID, CONF_GARDEN_NAME, + CONF_IDLE_DISCONNECT, CONF_PASSWORD, + CONF_SCAN_INTERVAL, CONF_TRANSFORMERS, + DEFAULT_IDLE_DISCONNECT_SECONDS, + DEFAULT_SCAN_INTERVAL, DOMAIN, + MAX_IDLE_DISCONNECT_SECONDS, + MAX_SCAN_INTERVAL, + MIN_IDLE_DISCONNECT_SECONDS, + MIN_SCAN_INTERVAL, ) _LOGGER = logging.getLogger(__name__) @@ -47,6 +62,12 @@ self._email: str | None = None self._gardens: list[Garden] = [] self._discovery_info: BluetoothServiceInfoBleak | None = None self._reauth_entry: ConfigEntry | None = None + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> InliteOptionsFlow: + """Get the options flow handler.""" + return InliteOptionsFlow(config_entry) # ------------------------------------------------------------------ # Bluetooth discovery entry point @@ -225,3 +246,48 @@ return self.async_show_form( step_id="reauth_confirm", description_placeholders={"email": self._email or ""}, ) + + +class InliteOptionsFlow(OptionsFlow): + """Handle in-lite options.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self._config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + current_scan = self._config_entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ) + current_idle = self._config_entry.options.get( + CONF_IDLE_DISCONNECT, DEFAULT_IDLE_DISCONNECT_SECONDS + ) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required( + CONF_SCAN_INTERVAL, default=current_scan + ): vol.All( + vol.Coerce(int), + vol.Range(min=MIN_SCAN_INTERVAL, max=MAX_SCAN_INTERVAL), + ), + vol.Required( + CONF_IDLE_DISCONNECT, default=current_idle + ): vol.All( + vol.Coerce(int), + vol.Range( + min=MIN_IDLE_DISCONNECT_SECONDS, + max=MAX_IDLE_DISCONNECT_SECONDS, + ), + ), + } + ), + ) diff --git a/custom_components/inlite/const.py b/custom_components/inlite/const.py index 229a5a039e044114959ac6fe71ed6f9c665335e4..0928d17a73127e82dafc02859e71365e23ff0696 100644 --- a/custom_components/inlite/const.py +++ b/custom_components/inlite/const.py @@ -10,11 +10,19 @@ CONF_GARDEN_NAME = "garden_name" CONF_PASSWORD = "password" CONF_TRANSFORMERS = "transformers" -# Coordinator -DEFAULT_SCAN_INTERVAL = 120 # seconds between BLE state polls +# Options flow keys +CONF_SCAN_INTERVAL = "scan_interval" +CONF_IDLE_DISCONNECT = "idle_disconnect" + +# Coordinator defaults +DEFAULT_SCAN_INTERVAL = 30 # seconds between BLE state polls +MIN_SCAN_INTERVAL = 10 +MAX_SCAN_INTERVAL = 300 # BLE BLE_LOCAL_NAME = "inlitebt" -# Connection management -BLE_IDLE_DISCONNECT_SECONDS = 300 # disconnect after 5 min idle +# Connection management defaults +DEFAULT_IDLE_DISCONNECT_SECONDS = 3600 # 1 hour — keeps connection alive for OOB updates +MIN_IDLE_DISCONNECT_SECONDS = 60 +MAX_IDLE_DISCONNECT_SECONDS = 7200 diff --git a/custom_components/inlite/coordinator.py b/custom_components/inlite/coordinator.py index a0877caf6abf0e5c6ba7e5ef463c0a3a2adbcfda..728b582dab808c855eb5715a6cde9962e910bc15 100644 --- a/custom_components/inlite/coordinator.py +++ b/custom_components/inlite/coordinator.py @@ -3,6 +3,7 @@ Manages a persistent BLE connection with a connection lock to serialize all hub communication. Connects once and queries all hubs before disconnecting. Includes retry-with-reconnect for both commands and polling. +Receives OOB broadcast notifications for real-time state updates. """ from __future__ import annotations @@ -10,7 +11,6 @@ import asyncio import logging from datetime import timedelta -from typing import Any from bleak_retry_connector import BleakClientWithServiceCache, establish_connection from homeassistant.components import bluetooth @@ -21,10 +21,12 @@ from inlite_ble.hub import InliteHub, ZoneState from .const import ( - BLE_IDLE_DISCONNECT_SECONDS, BLE_LOCAL_NAME, + CONF_IDLE_DISCONNECT, CONF_PASSWORD, + CONF_SCAN_INTERVAL, CONF_TRANSFORMERS, + DEFAULT_IDLE_DISCONNECT_SECONDS, DEFAULT_SCAN_INTERVAL, DOMAIN, ) @@ -45,16 +47,18 @@ - Persistent connection (connect once, reuse across polls and commands) - Single BLE connection shared across all hubs (they share a gateway) - Retry with disconnect-reconnect on command/poll failure - Cached BLE device reference from advertisement callbacks + - OOB broadcast callback for real-time state push from the hub - Proper cleanup on unload """ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize coordinator.""" + scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) super().__init__( hass, _LOGGER, name=DOMAIN, - update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + update_interval=timedelta(seconds=scan_interval), ) self.entry = entry self._hubs: dict[int, InliteHub] = {} @@ -62,6 +66,9 @@ self._available = False self._ble_lock = asyncio.Lock() self._disconnect_timer: asyncio.TimerHandle | None = None self._ble_service_info: bluetooth.BluetoothServiceInfoBleak | None = None + self._idle_disconnect_seconds = entry.options.get( + CONF_IDLE_DISCONNECT, DEFAULT_IDLE_DISCONNECT_SECONDS + ) password = entry.data[CONF_PASSWORD] for tx_data in entry.data[CONF_TRANSFORMERS]: @@ -69,6 +76,7 @@ device_id = tx_data["device_id"] hub = InliteHub( device_id=device_id, passphrase=password, + on_state_update=self._handle_oob_state_update, ) self._hubs[device_id] = hub @@ -91,6 +99,23 @@ (critical for ESPHome BLE proxy failover). """ self._ble_service_info = service_info + def _handle_oob_state_update(self) -> None: + """Handle an OOB broadcast notification from a hub. + + Called (on the event loop via call_soon_threadsafe) when the hub receives + a state change broadcast (e.g., physical button press, timer trigger). + Builds the full state dict from all hubs and pushes it to HA entities. + """ + all_states: dict[int, dict[int, ZoneState]] = {} + for device_id, hub in self._hubs.items(): + if hub.zone_states: + all_states[device_id] = hub.zone_states + + if all_states: + _LOGGER.debug("OOB state update received, pushing to HA entities") + self._available = True + self.async_set_updated_data(all_states) + def _find_ble_device(self) -> bluetooth.BluetoothServiceInfoBleak | None: """Find the in-lite hub, preferring the cached reference.""" if self._ble_service_info is not None: @@ -125,7 +150,7 @@ def _schedule_idle_disconnect(self) -> None: """Schedule a disconnect after the idle timeout.""" self._cancel_idle_disconnect() self._disconnect_timer = self.hass.loop.call_later( - BLE_IDLE_DISCONNECT_SECONDS, + self._idle_disconnect_seconds, lambda: self.hass.async_create_task(self._idle_disconnect()), ) diff --git a/custom_components/inlite/lib/inlite_ble/hub.py b/custom_components/inlite/lib/inlite_ble/hub.py index 4c8d21d2b31b4ee5acf7a91e63e5a64f99279556..41e0873925c9d1eb5d8ba2bc373e43a82f0197c9 100644 --- a/custom_components/inlite/lib/inlite_ble/hub.py +++ b/custom_components/inlite/lib/inlite_ble/hub.py @@ -81,6 +81,7 @@ Args: device_id: The hub's mesh device ID (from cloud API transformers[].deviceId) passphrase: The garden's network passphrase (from cloud API gardens[].password) ble_address: BLE device address or name (e.g., 'inlitebt' or a MAC/UUID) + on_state_update: Optional callback invoked when OOB broadcast updates zone states. """ def __init__( @@ -88,6 +89,7 @@ self, device_id: int, passphrase: str, ble_address: str = "inlitebt", + on_state_update: Callable[[], None] | None = None, ) -> None: self._device_id = device_id self._crypto = CsrMeshCrypto(passphrase) @@ -100,6 +102,7 @@ self._last_ack_data = b"" self._last_stream_data = b"" self._zone_states: dict[int, ZoneState] = {} self._notification_callback: Callable[[dict[str, Any]], None] | None = None + self._on_state_update = on_state_update @property def device_id(self) -> int: @@ -222,23 +225,31 @@ return data = payload[3:] i = 0 + changed = False while i + 3 < len(data): outlet_id = data[i] output_mode = data[i + 1] output_state = data[i + 2] # data[i + 3] = rtcTimer if outlet_id in self._zone_states: - self._zone_states[outlet_id].output_mode = output_mode - self._zone_states[outlet_id].output_state = output_state + old = self._zone_states[outlet_id] + if old.output_mode != output_mode or old.output_state != output_state: + changed = True + old.output_mode = output_mode + old.output_state = output_state else: self._zone_states[outlet_id] = ZoneState( output_id=outlet_id, output_mode=output_mode, output_state=output_state, ) + changed = True _LOGGER.debug("OOB update: zone %d mode=0x%02X state=0x%02X", outlet_id, output_mode, output_state) i += 4 + + if changed and self._on_state_update is not None: + self._on_state_update() async def _write(self, packet: bytes) -> None: """Write an encrypted packet to the hub.""" diff --git a/custom_components/inlite/strings.json b/custom_components/inlite/strings.json index b4dde70918fec3b493f163c809a2db8e29cfc6c9..dc4a86df7a4a616f2f75e89b5a9e65579ecd669b 100644 --- a/custom_components/inlite/strings.json +++ b/custom_components/inlite/strings.json @@ -41,5 +41,17 @@ "abort": { "already_configured": "This garden is already configured.", "reauth_successful": "Re-authentication successful." } + }, + "options": { + "step": { + "init": { + "title": "in-lite Options", + "description": "Configure polling and connection behavior. A longer idle disconnect keeps the BLE connection alive to receive real-time state updates from the hub (e.g., physical button presses).", + "data": { + "scan_interval": "Poll interval (seconds)", + "idle_disconnect": "Idle disconnect timeout (seconds)" + } + } + } } }