commit 14719a6307c308c5c2d0db3cca79938a7bad2ad9 parent 0d75e4da5586314d01a76d63085585df3df17054 Author: StΓ©phan Kochen <git@stephank.nl> Date: Thu, 03 Sep 2026 08:23:42 +0200 Add local clock sync and teach-in service
diff --git a/README.md b/README.md index c4d902189fc7276d04b6c8edc0915a81ac7f3482..aa78bf28ffb42a394f665577171d8f79e82f0a30 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ - π Local-only factory-reset onboarding; no in-lite account is used - π Automatic BLE discovery of the in-lite hub - π Reliable command delivery with retry and reconnect logic - π Persistent BLE connection with idle disconnect to save resources +- π Automatic daily local clock and timezone-offset synchronization ## Requirements @@ -89,6 +90,26 @@ ### Entities Created For each transformer zone, the integration creates a **Light** entity: - `light.inlite_<hub_name>_zone_<N>` β supports on/off control + +### Clock synchronization and smart-lamp teach-in + +The integration synchronizes every hub with Home Assistant's clock and local +timezone offset after setup and daily at 03:00. Older hub firmware receives its +supported UTC clock format; RTC-capable firmware also receives the local offset. + +Smart lamps and accessories are not added automatically. To explicitly start +the official app-compatible teach-in flow, call the following service: + +```yaml +service: inlite.start_teach_in +data: + device_id: 4660 + confirm: true +``` + +Teach-in re-applies the local mesh password and removes group assignments below +ten. During the process, Home Assistant emits `inlite_teach_in_progress` events +with `device_id`, `step`, and `step_code`; the final `step` is `done`. ## Troubleshooting diff --git a/custom_components/inlite/__init__.py b/custom_components/inlite/__init__.py index 436f0cd9cd86b05d71ddbe23ea558e99294c0e16..545d5a31929617141b71e3ed44f9bc76ed66297c 100644 --- a/custom_components/inlite/__init__.py +++ b/custom_components/inlite/__init__.py @@ -6,6 +6,8 @@ import logging import sys from pathlib import Path +import voluptuous as vol + # Make the bundled inlite_ble package importable as a top-level module. # This allows `from inlite_ble.hub import ...` to work without pip-installing. _LIB_DIR = str(Path(__file__).parent / "lib") @@ -16,9 +18,11 @@ from homeassistant.components import bluetooth from homeassistant.components.bluetooth import BluetoothScanningMode from homeassistant.components.bluetooth.match import BluetoothCallbackMatcher from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.const import ATTR_DEVICE_ID, CONF_CONFIRM, Platform +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ConfigEntryNotReady, ServiceValidationError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.event import async_track_time_change from homeassistant.helpers.update_coordinator import UpdateFailed from .const import BLE_LOCAL_NAME, DOMAIN @@ -28,6 +32,10 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.LIGHT] +SERVICE_START_TEACH_IN = "start_teach_in" +EVENT_TEACH_IN_PROGRESS = f"{DOMAIN}_teach_in_progress" +DATA_COORDINATORS = "coordinators" + type InliteConfigEntry = ConfigEntry[InliteCoordinator] @@ -61,6 +69,32 @@ except UpdateFailed as err: raise ConfigEntryNotReady("Hub not reachable") from err entry.runtime_data = coordinator + + coordinators: dict[str, InliteCoordinator] = hass.data.setdefault( + DOMAIN, {} + ).setdefault(DATA_COORDINATORS, {}) + coordinators[entry.entry_id] = coordinator + _async_register_services(hass) + + def _async_sync_clock_daily(_: datetime) -> None: + """Start the daily local clock synchronization without blocking HA.""" + hass.async_create_task(coordinator.async_sync_clock()) + + # Synchronize immediately after a successful local setup and then at + # 03:00 in Home Assistant's configured local timezone every day. + hass.async_create_task(coordinator.async_sync_clock()) + entry.async_on_unload( + async_track_time_change(hass, _async_sync_clock_daily, hour=3, minute=0, second=0) + ) + + @callback + def _async_remove_coordinator() -> None: + """Remove this entry's coordinator and its last shared service.""" + coordinators.pop(entry.entry_id, None) + if not coordinators: + hass.services.async_remove(DOMAIN, SERVICE_START_TEACH_IN) + + entry.async_on_unload(_async_remove_coordinator) # Disconnect hubs when the config entry is unloaded entry.async_on_unload(coordinator.async_shutdown) @@ -82,3 +116,43 @@ async def async_unload_entry(hass: HomeAssistant, entry: InliteConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +@callback +def _async_register_services(hass: HomeAssistant) -> None: + """Register the explicit, confirmation-gated teach-in service once.""" + if hass.services.has_service(DOMAIN, SERVICE_START_TEACH_IN): + return + + schema = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): vol.Coerce(int), + vol.Required(CONF_CONFIRM): cv.boolean, + } + ) + + async def _async_start_teach_in(call: ServiceCall) -> None: + if not call.data[CONF_CONFIRM]: + raise ServiceValidationError( + "Set confirm: true to start teach-in; it changes mesh group assignments." + ) + + device_id = call.data[ATTR_DEVICE_ID] + coordinators: dict[str, InliteCoordinator] = hass.data[DOMAIN][ + DATA_COORDINATORS + ] + matches = [ + coordinator + for coordinator in coordinators.values() + if device_id in coordinator.hubs + ] + if len(matches) != 1: + raise ServiceValidationError( + f"Expected exactly one configured in-lite hub with device_id {device_id}." + ) + if not await matches[0].async_start_teach_in(device_id): + raise ServiceValidationError("The hub did not acknowledge the teach-in request.") + + hass.services.async_register( + DOMAIN, SERVICE_START_TEACH_IN, _async_start_teach_in, schema=schema + ) diff --git a/custom_components/inlite/coordinator.py b/custom_components/inlite/coordinator.py index c5b17fec4b88b0db184f2537eef64dcd9df88ad5..be333f9cd70b4afd540933e8f21a2f099b6e841f 100644 --- a/custom_components/inlite/coordinator.py +++ b/custom_components/inlite/coordinator.py @@ -10,7 +10,8 @@ from __future__ import annotations import asyncio import logging -from datetime import timedelta +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo from bleak_retry_connector import BleakClientWithServiceCache, establish_connection from homeassistant.components import bluetooth @@ -36,6 +37,8 @@ MAX_COMMAND_ATTEMPTS = 3 MAX_POLL_ATTEMPTS = 2 RETRY_BACKOFF_SECONDS = 0.5 +MAX_TIMEZONE_TRANSITIONS = 4 +TIMEZONE_TRANSITION_LOOKAHEAD_DAYS = 365 * 3 class InliteCoordinator(DataUpdateCoordinator[dict[int, dict[int, ZoneState]]]): @@ -79,6 +82,7 @@ hub = InliteHub( device_id=device_id, passphrase=password, on_state_update=self._handle_oob_state_update, + on_teach_in_progress=self._handle_teach_in_progress, ) self._hubs[device_id] = hub @@ -117,6 +121,25 @@ if all_states: _LOGGER.debug("OOB state update received, pushing to HA entities") self._available = True self.async_set_updated_data(all_states) + + def _handle_teach_in_progress(self, device_id: int, step: int) -> None: + """Publish the hub's local teach-in progress TLV as a HA event.""" + steps = { + 0: "start_up", + 10: "continue_to_next", + 20: "teach_in_lines", + 100: "wait", + 250: "done", + } + self.hass.bus.async_fire( + f"{DOMAIN}_teach_in_progress", + { + "config_entry_id": self.entry.entry_id, + "device_id": device_id, + "step": steps.get(step, "unknown"), + "step_code": step, + }, + ) def _find_ble_device(self) -> bluetooth.BluetoothServiceInfoBleak | None: """Find the in-lite hub, preferring the cached reference.""" @@ -277,9 +300,89 @@ ) self._schedule_idle_disconnect() return False + async def async_sync_clock(self) -> None: + """Synchronize all configured hubs to HA's current local time.""" + timezone = ZoneInfo(self.hass.config.time_zone) + now = datetime.now(timezone) + transitions = _find_timezone_transitions(now, timezone) + async with self._ble_lock: + self._cancel_idle_disconnect() + for device_id, hub in self._hubs.items(): + try: + await self._ensure_connected(hub) + self._active_hub = hub + if not await hub.sync_clock(now, transitions): + _LOGGER.warning("Clock sync was not acknowledged by hub 0x%04X", device_id) + except Exception as err: + _LOGGER.warning("Clock sync for hub 0x%04X failed: %s", device_id, err) + await self._disconnect_shared() + self._schedule_idle_disconnect() + + async def async_start_teach_in(self, device_id: int) -> bool: + """Start teach-in on one hub after the caller has confirmed it.""" + hub = self._hubs.get(device_id) + if hub is None: + return False + async with self._ble_lock: + self._cancel_idle_disconnect() + try: + await self._ensure_connected(hub) + self._active_hub = hub + result = await hub.start_teach_in() + except Exception as err: + _LOGGER.warning("Teach-in for hub 0x%04X failed: %s", device_id, err) + await self._disconnect_shared() + return False + self._schedule_idle_disconnect() + return result + async def async_shutdown(self) -> None: """Disconnect all hubs (called on unload).""" self._cancel_idle_disconnect() async with self._ble_lock: await self._disconnect_shared() _LOGGER.debug("All hubs disconnected") + + +def _find_timezone_transitions( + now: datetime, timezone: ZoneInfo +) -> list[tuple[int, int]]: + """Return up to four exact future offset changes for the hub RTC protocol.""" + current_timestamp = int(now.timestamp()) + current_offset = int((now.utcoffset() or timedelta()).total_seconds()) + transitions: list[tuple[int, int]] = [] + previous_timestamp = current_timestamp + previous_offset = current_offset + + # IANA timezone changes are sparse. Daily probes make this inexpensive and + # a binary search below finds the precise UTC second of each change. + for day in range(1, TIMEZONE_TRANSITION_LOOKAHEAD_DAYS + 1): + candidate_timestamp = current_timestamp + day * 24 * 60 * 60 + candidate = datetime.fromtimestamp(candidate_timestamp, timezone) + candidate_offset = int((candidate.utcoffset() or timedelta()).total_seconds()) + if candidate_offset == previous_offset: + previous_timestamp = candidate_timestamp + continue + + lower = previous_timestamp + upper = candidate_timestamp + while upper - lower > 1: + midpoint = (lower + upper) // 2 + midpoint_offset = int( + (datetime.fromtimestamp(midpoint, timezone).utcoffset() or timedelta()).total_seconds() + ) + if midpoint_offset == previous_offset: + lower = midpoint + else: + upper = midpoint + + transition_offset = int( + (datetime.fromtimestamp(upper, timezone).utcoffset() or timedelta()).total_seconds() + ) + transitions.append((upper, transition_offset)) + if len(transitions) == MAX_TIMEZONE_TRANSITIONS: + return transitions + previous_timestamp = candidate_timestamp + previous_offset = candidate_offset + + return transitions diff --git a/custom_components/inlite/lib/inlite_ble/hub.py b/custom_components/inlite/lib/inlite_ble/hub.py index ffd55c7e53e773f5fd1d678f7276540362afd610..b5461bc9a0ae51f1d3dfd0bb9aed886124796fc6 100644 --- a/custom_components/inlite/lib/inlite_ble/hub.py +++ b/custom_components/inlite/lib/inlite_ble/hub.py @@ -4,7 +4,8 @@ from __future__ import annotations import asyncio import logging -from collections.abc import Callable +from collections.abc import Callable, Iterable +from datetime import datetime from typing import Any from bleak import BleakClient, BleakScanner @@ -22,6 +23,9 @@ PKT_BLOCK_DATA_BLK, PKT_BLOCK_STREAM, OPCODE_SET_OUTLET_MODE, OPCODE_IDENTIFY, + OPCODE_SET_CLOCK, + OPCODE_SET_MODULE_DATE_TIME, + OPCODE_START_GARDEN_TEACH_IN, OPCODE_GET_INFO_DEVICES, OPCODE_OOB_ALL_OUTLETS, build_outlet_mode_data, @@ -30,6 +34,10 @@ build_flush_payload, build_ack_payload, build_discovery_payload, build_association_payload, + build_clock_payload, + build_legacy_datetime_payload, + CMD_TYPE_TLV, + TLV_TYPE_GARDEN_TEACH_IN, ) _LOGGER = logging.getLogger(__name__) @@ -92,6 +100,7 @@ device_id: int, passphrase: str, ble_address: str = "inlitebt", on_state_update: Callable[[], None] | None = None, + on_teach_in_progress: Callable[[int, int], None] | None = None, ) -> None: self._device_id = device_id self._crypto = CsrMeshCrypto(passphrase) @@ -110,6 +119,7 @@ self._discovered_device_ids: set[int] = set() self._discovery_event = asyncio.Event() self._notification_callback: Callable[[dict[str, Any]], None] | None = None self._on_state_update = on_state_update + self._on_teach_in_progress = on_teach_in_progress @property def device_id(self) -> int: @@ -239,6 +249,7 @@ self._discovered_device_ids.add(decrypted["src_id"]) loop.call_soon_threadsafe(self._discovery_event.set) if decrypted["src_id"] == self._device_id: loop.call_soon_threadsafe(self._parse_oob_broadcast, payload) + self._parse_tlv_notification(decrypted["src_id"], payload) if self._notification_callback: loop.call_soon_threadsafe(self._notification_callback, decrypted) @@ -294,6 +305,33 @@ if changed and self._on_state_update is not None: self._on_state_update() + def _parse_tlv_notification(self, source_id: int, payload: bytes) -> None: + """Forward documented garden teach-in progress TLVs to the caller.""" + # TLV block format: [0x54, type, length, value, ...]. A block can + # contain more than one TLV, so validate and walk it defensively. + if not payload or payload[0] != CMD_TYPE_TLV: + return + pos = 1 + while pos + 2 <= len(payload): + tlv_type = payload[pos] + length = payload[pos + 1] + pos += 2 + if pos + length > len(payload): + _LOGGER.warning("Discarding truncated TLV notification") + return + value = payload[pos : pos + length] + pos += length + if ( + tlv_type == TLV_TYPE_GARDEN_TEACH_IN + and value + and self._on_teach_in_progress is not None + ): + loop = self._loop + if loop is not None and not loop.is_closed(): + loop.call_soon_threadsafe( + self._on_teach_in_progress, source_id, value[0] + ) + async def _write(self, packet: bytes) -> None: """Write an encrypted packet to the hub.""" if not self._client or not self._client.is_connected: @@ -566,3 +604,34 @@ async def identify(self) -> bool: """Ask a hub to briefly blink its identify light.""" return await self._send_command(OPCODE_IDENTIFY, b"") + + async def sync_clock( + self, now: datetime, transitions: Iterable[tuple[int, int]] = () + ) -> bool: + """Synchronize the hub clock and current local UTC offset. + + Firmware supporting the RTC protocol receives SET_CLOCK (0x0064). + Older firmware receives its documented UTC calendar command instead. + """ + if now.tzinfo is None: + raise ValueError("clock synchronization requires an aware datetime") + timestamp = int(now.timestamp()) + firmware = self._firmware_version or 0 + if (32 <= firmware < 100) or firmware > 132: + offset = now.utcoffset() + offset_seconds = int(offset.total_seconds()) if offset else 0 + return await self._send_command( + OPCODE_SET_CLOCK, + build_clock_payload(timestamp, offset_seconds, transitions), + ) + return await self._send_command( + OPCODE_SET_MODULE_DATE_TIME, build_legacy_datetime_payload(timestamp) + ) + + async def start_teach_in(self) -> bool: + """Start the official app's garden teach-in operation. + + Its 0x0a options reapply the mesh password and remove group assignments + below ten, matching the app's smart-lamp onboarding flow. + """ + return await self._send_command(OPCODE_START_GARDEN_TEACH_IN, b"\x0a") diff --git a/custom_components/inlite/lib/inlite_ble/protocol.py b/custom_components/inlite/lib/inlite_ble/protocol.py index a43d55223e72e4cf6bb437858bc65862aad9fa5a..d0f27e699d5cf5cfc323ccae35dd51220fc242a6 100644 --- a/custom_components/inlite/lib/inlite_ble/protocol.py +++ b/custom_components/inlite/lib/inlite_ble/protocol.py @@ -2,6 +2,8 @@ """Protocol constants and command builders for in-lite mesh.""" from __future__ import annotations +from collections.abc import Iterable + # Packet types (block streaming layer) PKT_BLOCK_FLUSH = 0x70 # 112 PKT_BLOCK_DATA = 0x71 # 113 @@ -14,8 +16,15 @@ OPCODE_DISCOVER = 0x000C OPCODE_GET_INFO_DEVICES = 0x0005 OPCODE_GET_OUTPUT_NAMES = 0x0019 OPCODE_IDENTIFY = 0x0014 +OPCODE_SET_CLOCK = 0x0064 +OPCODE_SET_MODULE_DATE_TIME = 0x100C OPCODE_SET_OUTLET_MODE = 0x1007 # 4103 +OPCODE_START_GARDEN_TEACH_IN = 0x002C OPCODE_OOB_ALL_OUTLETS = 0x0021 # broadcast after state changes + +# TLV notifications carried in a PKT_BLOCK_DATA_BLK payload. +CMD_TYPE_TLV = 0x54 +TLV_TYPE_GARDEN_TEACH_IN = 0x04 # Output states OUTPUT_OFF = 0x00 @@ -79,3 +88,48 @@ """ if len(network_key) != 16: raise ValueError("network_key must be 16 bytes") return bytes([0x01, 0x24, 0x00]) + network_key + + +def build_clock_payload( + timestamp: int, + utc_offset_seconds: int, + transitions: Iterable[tuple[int, int]] = (), +) -> bytes: + """Build SET_CLOCK's current and future timezone-offset entries. + + The hub stores UTC transition instants followed by the corresponding UTC + offset in quarter-hours. Sending the current entry every day keeps both + the time and offset current without requiring a location or cloud service. + """ + def _entry(entry_timestamp: int, entry_offset_seconds: int) -> bytes: + if entry_timestamp < 0 or entry_timestamp > 0xFFFFFFFF: + raise ValueError("timestamp must fit in an unsigned 32-bit integer") + offset_quarters = round(entry_offset_seconds / (15 * 60)) + if not -128 <= offset_quarters <= 127: + raise ValueError("UTC offset must fit in a signed byte of quarter-hours") + return entry_timestamp.to_bytes(4, "little") + offset_quarters.to_bytes( + 1, "little", signed=True + ) + + return _entry(timestamp, utc_offset_seconds) + b"".join( + _entry(transition_timestamp, transition_offset_seconds) + for transition_timestamp, transition_offset_seconds in transitions + ) + + +def build_legacy_datetime_payload(timestamp: int) -> bytes: + """Build SET_MODULE_DATE_TIME's UTC calendar payload.""" + from datetime import UTC, datetime + + value = datetime.fromtimestamp(timestamp, UTC) + return bytes( + [ + value.second, + value.minute, + value.hour, + value.day, + value.month, + value.year & 0xFF, + value.year >> 8, + ] + ) diff --git a/custom_components/inlite/services.yaml b/custom_components/inlite/services.yaml new file mode 100644 index 0000000000000000000000000000000000000000..287b5d19d8bb449a6935c28a7afdd16df943753d --- /dev/null +++ b/custom_components/inlite/services.yaml @@ -0,0 +1,18 @@ +start_teach_in: + name: Start teach-in + description: >- + Starts the in-lite smart-lamp teach-in flow on one hub. This re-applies the + local mesh password and removes group assignments below ten, matching the + official app. Progress is emitted as the inlite_teach_in_progress event. + fields: + device_id: + required: true + selector: + number: + min: 1 + max: 65535 + mode: box + confirm: + required: true + selector: + boolean: diff --git a/tests/test_hub.py b/tests/test_hub.py index 3ed4befa6afa626422b52291f89551620ffd530a..dbc422ecb96f3db3f296ffe98cc320639fe4422a 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -64,3 +64,20 @@ hub = InliteHub(device_id=1, passphrase="test") assert hub._accept_stream_segment(b"\x00\x00abc") assert not hub._accept_stream_segment(b"\x04\x00de") assert hub._stream_invalid is True + + def test_teach_in_tlv_is_forwarded(self) -> None: + received: list[tuple[int, int]] = [] + hub = InliteHub( + device_id=1, + passphrase="test", + on_teach_in_progress=lambda device_id, step: received.append((device_id, step)), + ) + loop = asyncio.new_event_loop() + try: + hub._loop = loop + hub._parse_tlv_notification(0x1234, b"\x54\x04\x01\xfa") + loop.call_soon(loop.stop) + loop.run_forever() + finally: + loop.close() + assert received == [(0x1234, 250)] diff --git a/tests/test_protocol.py b/tests/test_protocol.py index b417f05572d1657995f7258d88b4e51524d8fd4b..6fcb6284eb9335d1ceb0dbbda017328eaf0b1128 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -10,7 +10,10 @@ build_outlet_mode_data, OPCODE_SET_OUTLET_MODE, OPCODE_DISCOVER, OPCODE_IDENTIFY, + build_clock_payload, + build_legacy_datetime_payload, ) +from datetime import UTC, datetime class TestBuildOutletModeData: @@ -98,3 +101,18 @@ def test_identify_opcode_matches_official_app() -> None: assert OPCODE_IDENTIFY == 0x0014 + + +class TestClockPayloads: + def test_clock_payload_is_little_endian_with_quarter_hour_offset(self) -> None: + assert build_clock_payload(0x01020304, 3600) == b"\x04\x03\x02\x01\x04" + + def test_clock_payload_supports_negative_offsets(self) -> None: + assert build_clock_payload(0, -5 * 3600) == b"\x00\x00\x00\x00\xec" + + def test_clock_payload_appends_future_transitions(self) -> None: + assert build_clock_payload(1, 0, [(2, 3600)]) == b"\x01\x00\x00\x00\x00\x02\x00\x00\x00\x04" + + def test_legacy_datetime_payload_is_utc(self) -> None: + timestamp = int(datetime(2026, 9, 3, 13, 14, 15, tzinfo=UTC).timestamp()) + assert build_legacy_datetime_payload(timestamp) == bytes([15, 14, 13, 3, 9, 0xEA, 0x07])