commit dce7cd8e1b39aed24972fa2ec5a9fcd05b162265 parent 82f912b0fd5e4a63b7faebddeb7f60f43a287fbb Author: Stéphan Kochen <git@stephank.nl> Date: Thu, 03 Sep 2026 21:11:09 +0200 Add existing mesh setup options
diff --git a/custom_components/inlite/config_flow.py b/custom_components/inlite/config_flow.py index 36a95d13dde00b197060ddcfb20f568a7864d8bd..bf58e5c8865bc225b3cf6580f0f835c3a66b2480 100644 --- a/custom_components/inlite/config_flow.py +++ b/custom_components/inlite/config_flow.py @@ -1,4 +1,4 @@ -"""Local-only setup flow for factory-reset in-lite hubs.""" +"""Local-only setup flow for new and existing in-lite meshes.""" from __future__ import annotations import logging @@ -17,7 +17,7 @@ from inlite_ble.protocol import FACTORY_NETWORK_PASSPHRASE from .const import ( BLE_LOCAL_NAME, CONF_GARDEN_ID, CONF_GARDEN_NAME, CONF_IDLE_DISCONNECT, - CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_TRANSFORMERS, + CONF_NETWORK_KEY, 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, @@ -25,9 +25,18 @@ ) _LOGGER = logging.getLogger(__name__) +SETUP_FACTORY = "factory" +SETUP_PASSPHRASE = "passphrase" +SETUP_NETWORK_KEY = "network_key" +SETUP_MODES = { + SETUP_FACTORY: "Provision a factory-reset hub", + SETUP_PASSPHRASE: "Join an existing mesh with its passphrase", + SETUP_NETWORK_KEY: "Join an existing mesh with its raw key", +} + class InliteConfigFlow(ConfigFlow, domain=DOMAIN): - """Onboard a factory-reset hub entirely over the local BLE mesh.""" + """Onboard one local in-lite mesh entirely over BLE.""" VERSION = 2 @@ -36,6 +45,9 @@ self._service_info: BluetoothServiceInfoBleak | None = None self._candidates: set[int] = set() self._selected_id: int | None = None self._garden_name = "in-lite" + self._setup_mode: str | None = None + self._passphrase: str | None = None + self._network_key: bytes | None = None @staticmethod @callback @@ -48,7 +60,7 @@ ) -> ConfigFlowResult: self._service_info = discovery_info await self.async_set_unique_id(discovery_info.address) self._abort_if_unique_id_configured() - return await self.async_step_bluetooth_confirm() + return await self.async_step_setup() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -61,7 +73,25 @@ if self._service_info is None: return self.async_abort(reason="no_hub_found") await self.async_set_unique_id(self._service_info.address) self._abort_if_unique_id_configured() - return await self.async_step_bluetooth_confirm() + return await self.async_step_setup() + + async def async_step_setup( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Choose whether to provision a new mesh or join an existing one.""" + if user_input is not None: + self._setup_mode = user_input["setup_mode"] + self._garden_name = user_input["garden_name"].strip() or "in-lite" + if self._setup_mode == SETUP_FACTORY: + return await self.async_step_bluetooth_confirm() + return await self.async_step_credentials() + return self.async_show_form( + step_id="setup", + data_schema=vol.Schema({ + vol.Required("setup_mode", default=SETUP_FACTORY): vol.In(SETUP_MODES), + vol.Required("garden_name", default=self._garden_name): str, + }), + ) async def async_step_bluetooth_confirm( self, user_input: dict[str, Any] | None = None @@ -70,7 +100,7 @@ if user_input is None: self._set_confirm_only() return self.async_show_form(step_id="bluetooth_confirm") try: - self._candidates = await self._discover_factory_hubs() + self._candidates = await self._discover_hubs() except Exception: _LOGGER.exception("Factory-network discovery failed") return self.async_show_form( @@ -82,6 +112,50 @@ step_id="bluetooth_confirm", errors={"base": "no_factory_hubs"} ) return await self.async_step_identify() + async def async_step_credentials( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Collect credentials for an existing mesh and discover its hubs.""" + errors: dict[str, str] = {} + if user_input is not None: + if self._setup_mode == SETUP_PASSPHRASE: + passphrase = user_input[CONF_PASSWORD] + if not passphrase: + errors["base"] = "invalid_credentials" + else: + self._passphrase = passphrase + elif self._setup_mode == SETUP_NETWORK_KEY: + try: + network_key = bytes.fromhex(user_input[CONF_NETWORK_KEY].strip()) + except ValueError: + network_key = b"" + if len(network_key) != 16: + errors["base"] = "invalid_network_key" + else: + self._network_key = network_key + else: + return self.async_abort(reason="unknown") + + if not errors: + try: + self._candidates = await self._discover_hubs() + except Exception: + _LOGGER.exception("Existing-mesh discovery failed") + errors["base"] = "cannot_connect" + else: + if self._candidates: + return await self.async_step_identify() + errors["base"] = "invalid_credentials" + + credential_field = ( + CONF_PASSWORD if self._setup_mode == SETUP_PASSPHRASE else CONF_NETWORK_KEY + ) + return self.async_show_form( + step_id="credentials", + data_schema=vol.Schema({vol.Required(credential_field): str}), + errors=errors, + ) + async def async_step_identify( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -89,7 +163,6 @@ choices = {f"0x{id_:04X}": id_ for id_ in sorted(self._candidates)} errors: dict[str, str] = {} if user_input is not None: self._selected_id = choices[user_input["device_id"]] - self._garden_name = user_input["garden_name"].strip() or "in-lite" try: await self._identify(self._selected_id) except Exception: @@ -101,10 +174,10 @@ return self.async_show_form( step_id="identify", data_schema=vol.Schema({ vol.Required("device_id"): vol.In(choices), - vol.Required("garden_name", default=self._garden_name): str, }), errors=errors, ) + async def async_step_confirm_blink( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -118,9 +191,21 @@ return await self.async_step_identify() if self._selected_id is None: return self.async_abort(reason="unknown") try: - password, zones, firmware = await self._associate_and_read(self._selected_id) + if self._setup_mode == SETUP_FACTORY: + password, zones, firmware = await self._associate_and_read(self._selected_id) + credential = {CONF_PASSWORD: password} + transformers = [ + self._transformer_data(self._selected_id, zones, firmware) + ] + else: + credential = self._credential_data() + mesh_hubs = await self._read_existing_mesh() + transformers = [ + self._transformer_data(device_id, zones, firmware) + for device_id, (zones, firmware) in mesh_hubs.items() + ] except Exception: - _LOGGER.exception("Local association failed") + _LOGGER.exception("Could not finish local mesh setup") return self.async_show_form( step_id="confirm_blink", errors={"base": "association_failed"} ) @@ -129,19 +214,26 @@ title=f"in-lite {self._garden_name}", data={ CONF_GARDEN_ID: self._service_info.address, CONF_GARDEN_NAME: self._garden_name, - CONF_PASSWORD: password, - CONF_TRANSFORMERS: [{ - "device_id": self._selected_id, - "name": f"in-lite hub 0x{self._selected_id:04X}", - "firmware_version": firmware if firmware is not None else "unknown", - "zones": [ - {"output_id": zone_id, "name": f"Zone {zone_id + 1}"} - for zone_id in sorted(zones) - ], - }], + **credential, + CONF_TRANSFORMERS: transformers, }, ) + @staticmethod + def _transformer_data( + device_id: int, zones: dict[int, Any], firmware: int | None + ) -> dict[str, Any]: + """Build config-entry data for one discovered mesh hub.""" + return { + "device_id": device_id, + "name": f"in-lite hub 0x{device_id:04X}", + "firmware_version": firmware if firmware is not None else "unknown", + "zones": [ + {"output_id": zone_id, "name": f"Zone {zone_id + 1}"} + for zone_id in sorted(zones) + ], + } + async def _connect(self) -> Any: if self._service_info is None: raise ConnectionError("No in-lite gateway available") @@ -150,23 +242,41 @@ BleakClientWithServiceCache, self._service_info.device, self._service_info.address, max_attempts=3, ) - async def _with_factory_hub(self, device_id: int) -> InliteHub: - hub = InliteHub(device_id, FACTORY_NETWORK_PASSPHRASE) + def _new_hub(self, device_id: int) -> InliteHub: + """Build a hub using the credentials selected during setup.""" + if self._setup_mode == SETUP_FACTORY: + return InliteHub(device_id, FACTORY_NETWORK_PASSPHRASE) + if self._network_key is not None: + return InliteHub(device_id, network_key=self._network_key) + if self._passphrase is not None: + return InliteHub(device_id, self._passphrase) + raise ValueError("mesh credentials have not been selected") + + def _credential_data(self) -> dict[str, str]: + """Return serializable credentials for an existing mesh entry.""" + if self._network_key is not None: + return {CONF_NETWORK_KEY: self._network_key.hex()} + if self._passphrase is not None: + return {CONF_PASSWORD: self._passphrase} + raise ValueError("mesh credentials have not been selected") + + async def _with_hub(self, device_id: int) -> InliteHub: + hub = self._new_hub(device_id) client = await self._connect() if not await hub.connect(client=client): await hub.disconnect() raise ConnectionError("Could not subscribe to hub notifications") return hub - async def _discover_factory_hubs(self) -> set[int]: - hub = await self._with_factory_hub(0) + async def _discover_hubs(self) -> set[int]: + hub = await self._with_hub(0) try: return await hub.discover_hubs() finally: await hub.disconnect() async def _identify(self, device_id: int) -> None: - hub = await self._with_factory_hub(device_id) + hub = await self._with_hub(device_id) try: if not await hub.identify(): raise ConnectionError("Hub did not acknowledge identify") @@ -177,7 +287,7 @@ async def _associate_and_read( self, device_id: int ) -> tuple[str, dict[int, Any], int | None]: password = secrets.token_urlsafe(32) - hub = await self._with_factory_hub(device_id) + hub = await self._with_hub(device_id) try: if not await hub.associate(password): raise ConnectionError("Hub did not acknowledge association") @@ -191,6 +301,23 @@ raise ConnectionError("Could not reconnect after association") return password, await local_hub.query_zone_states(), local_hub.firmware_version finally: await local_hub.disconnect() + + async def _read_existing( + self, device_id: int + ) -> tuple[dict[int, Any], int | None]: + """Verify credentials against a selected mesh hub and read its zones.""" + hub = await self._with_hub(device_id) + try: + return await hub.query_zone_states(), hub.firmware_version + finally: + await hub.disconnect() + + async def _read_existing_mesh(self) -> dict[int, tuple[dict[int, Any], int | None]]: + """Read every hub that responded to authenticated mesh discovery.""" + result: dict[int, tuple[dict[int, Any], int | None]] = {} + for device_id in sorted(self._candidates): + result[device_id] = await self._read_existing(device_id) + return result class InliteOptionsFlow(OptionsFlow): diff --git a/custom_components/inlite/strings.json b/custom_components/inlite/strings.json index 225b28a9007f117eddafc6858edaad0dce1e5ca9..d809ab350f5191e4d4deb7d10c753555584716fa 100644 --- a/custom_components/inlite/strings.json +++ b/custom_components/inlite/strings.json @@ -5,14 +5,24 @@ "user": { "title": "Set up local in-lite control", "description": "Select a nearby in-lite hub. No in-lite account or cloud connection is used." }, + "setup": { + "title": "Set up in-lite mesh", + "description": "Choose whether to provision a factory-reset hub or join an existing local mesh.", + "data": {"setup_mode": "Setup method", "garden_name": "Name"} + }, "bluetooth_confirm": { "title": "Confirm Device", "description": "An in-lite gateway was found. Continue to discover factory-reset hubs on its local mesh." }, "identify": { "title": "Identify hub", - "description": "Choose a factory-reset hub. It will blink briefly before any configuration changes are made.", - "data": {"device_id": "Hub", "garden_name": "Name"} + "description": "Choose a hub on this mesh. It will blink briefly before any configuration changes are made.", + "data": {"device_id": "Hub"} + }, + "credentials": { + "title": "Mesh credentials", + "description": "Enter the existing mesh passphrase or its 16-byte raw key as 32 hexadecimal characters.", + "data": {"password": "Mesh passphrase", "network_key": "Raw mesh key"} }, "confirm_blink": { "title": "Confirm hub", @@ -24,6 +34,8 @@ "error": { "cannot_connect": "Unable to connect to the nearby in-lite hub. Please try again.", "no_factory_hubs": "No factory-reset in-lite hubs responded.", "association_failed": "The selected hub could not be associated. It may not be factory reset.", + "invalid_credentials": "No hubs responded with these mesh credentials.", + "invalid_network_key": "The raw mesh key must contain exactly 32 hexadecimal characters.", "unknown": "An unexpected error occurred. Please try again." }, "abort": { diff --git a/custom_components/inlite/translations/en.json b/custom_components/inlite/translations/en.json index 225b28a9007f117eddafc6858edaad0dce1e5ca9..d809ab350f5191e4d4deb7d10c753555584716fa 100644 --- a/custom_components/inlite/translations/en.json +++ b/custom_components/inlite/translations/en.json @@ -5,14 +5,24 @@ "user": { "title": "Set up local in-lite control", "description": "Select a nearby in-lite hub. No in-lite account or cloud connection is used." }, + "setup": { + "title": "Set up in-lite mesh", + "description": "Choose whether to provision a factory-reset hub or join an existing local mesh.", + "data": {"setup_mode": "Setup method", "garden_name": "Name"} + }, "bluetooth_confirm": { "title": "Confirm Device", "description": "An in-lite gateway was found. Continue to discover factory-reset hubs on its local mesh." }, "identify": { "title": "Identify hub", - "description": "Choose a factory-reset hub. It will blink briefly before any configuration changes are made.", - "data": {"device_id": "Hub", "garden_name": "Name"} + "description": "Choose a hub on this mesh. It will blink briefly before any configuration changes are made.", + "data": {"device_id": "Hub"} + }, + "credentials": { + "title": "Mesh credentials", + "description": "Enter the existing mesh passphrase or its 16-byte raw key as 32 hexadecimal characters.", + "data": {"password": "Mesh passphrase", "network_key": "Raw mesh key"} }, "confirm_blink": { "title": "Confirm hub", @@ -24,6 +34,8 @@ "error": { "cannot_connect": "Unable to connect to the nearby in-lite hub. Please try again.", "no_factory_hubs": "No factory-reset in-lite hubs responded.", "association_failed": "The selected hub could not be associated. It may not be factory reset.", + "invalid_credentials": "No hubs responded with these mesh credentials.", + "invalid_network_key": "The raw mesh key must contain exactly 32 hexadecimal characters.", "unknown": "An unexpected error occurred. Please try again." }, "abort": {