diff --git a/README.md b/README.md index 0c772b0..426eb90 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,35 @@ A ready-made dashboard YAML is included in [`docs/dashboard.yaml`](docs/dashboar --- +## Troubleshooting + +### "This device is already initialized with a secret" / re-pair loop + +Each fountain stores a pairing **secret**. The first time you add a device, the +integration registers a random secret with it; on later connections it +authenticates with that stored secret. A device that was previously paired — +with the PetKit app, or with an earlier Home Assistant setup whose secret was +lost — already holds a secret. + +When this happens the config flow shows a **"Device Already Paired"** step +instead of failing, offering **Re-pair now** or **Cancel**. Choosing +**Re-pair now** registers a fresh secret with the device. This overwrites the +existing pairing, so: + +- The PetKit app will no longer connect to the fountain until you factory reset it. +- Only one controller (the app **or** Home Assistant) owns the device at a time. + +If re-pairing keeps failing, bring the fountain closer to the Bluetooth adapter +(or proxy) and retry. As a last resort, factory reset the device: detach the +control module, hold the power button for ~3 seconds until the LEDs flash, then +add it again. + +> Older releases dead-ended here with an "already initialized, factory reset +> first" error even though a reset often did not clear the binding. The re-pair +> step replaces that loop. + +--- + ## Protocol Notes Communication uses a proprietary BLE protocol over two GATT characteristics (notify + write-without-response). Authentication is required on every connection. diff --git a/custom_components/petkit_ble/ble_client.py b/custom_components/petkit_ble/ble_client.py index 89823cc..5e371ed 100644 --- a/custom_components/petkit_ble/ble_client.py +++ b/custom_components/petkit_ble/ble_client.py @@ -42,7 +42,7 @@ KNOWN_ALIASES, POWER_COEFF_W, ) -from .protocol import build_init_payload, build_time_sync_payload +from .protocol import build_init_payload, build_time_sync_payload, parse_device_id _LOGGER = logging.getLogger(__name__) @@ -507,7 +507,7 @@ async def async_check_initialized(self) -> tuple[bool, int]: payload = await self._send_and_wait(CMD_GET_DEVICE_INFO, FRAME_TYPE_SEND, []) if payload is None or len(payload) < 8: return False, 0 - device_id = int.from_bytes(payload[:8], "little") + device_id = parse_device_id(payload) return device_id != 0, device_id finally: await self.disconnect() diff --git a/custom_components/petkit_ble/config_flow.py b/custom_components/petkit_ble/config_flow.py index aca61b7..98cb6ad 100644 --- a/custom_components/petkit_ble/config_flow.py +++ b/custom_components/petkit_ble/config_flow.py @@ -77,6 +77,7 @@ def __init__(self) -> None: self._discovered_devices: dict[str, str] = {} # address -> name self._bluetooth_info: BluetoothServiceInfoBleak | None = None self._pending_data: dict[str, Any] = {} # data waiting for init + self._repair_device_id: int = 0 # existing id of a device awaiting re-pair @staticmethod @callback @@ -174,10 +175,9 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Con # ------------------------------------------------------------------ async def async_step_init_device(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: - """Check if device needs initialization and handle it.""" + """Check init status, then register a secret or offer re-pair recovery.""" address = self._pending_data[CONF_ADDRESS] name = self._pending_data[CONF_NAME] - errors: dict[str, str] = {} ble_device = async_ble_device_from_address(self.hass, address, connectable=True) if ble_device is None: @@ -194,42 +194,60 @@ async def async_step_init_device(self, user_input: dict[str, Any] | None = None) return self.async_create_entry(title=name, data=self._pending_data) if initialized: - # Device already has a secret — cannot use it without factory reset. - return self.async_abort(reason="device_already_initialized") - - # Device is uninitialized — generate secret and init - secret = secrets.token_bytes(8) - try: - ble_device2 = async_ble_device_from_address(self.hass, address, connectable=True) - if ble_device2 is None: - errors["base"] = "cannot_connect" - return self.async_show_form( - step_id="init_device", - description_placeholders={"name": name}, - errors=errors, - ) - client2 = PetkitBleClient(ble_device2) - success = await client2.async_init_device(device_id, secret) - - if not success: - errors["base"] = "init_failed" - return self.async_show_form( - step_id="init_device", - description_placeholders={"name": name}, - errors=errors, - ) - except Exception: - _LOGGER.exception("Failed to initialize device %s", name) - errors["base"] = "init_failed" + # Device already bound (typically by the Petkit app). The firmware + # accepts a fresh CMD 73 init without a factory reset, so offer a + # re-pair recovery step instead of dead-ending the flow (issue #75). + self._repair_device_id = device_id + return await self.async_step_confirm_repair() + + # Uninitialised — register a fresh secret straight away. + secret_hex = await self._async_init_with_secret(address, device_id) + if secret_hex is None: return self.async_show_form( step_id="init_device", description_placeholders={"name": name}, - errors=errors, + errors={"base": "init_failed"}, ) + return self.async_create_entry(title=name, data={**self._pending_data, CONF_DEVICE_SECRET: secret_hex}) + + async def async_step_confirm_repair(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: + """Let the user re-pair an already-bound device or cancel.""" + return self.async_show_menu( + step_id="confirm_repair", + menu_options=["repair_confirm", "repair_cancel"], + description_placeholders={"name": self._pending_data[CONF_NAME]}, + ) + + async def async_step_repair_confirm(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: + """Re-pair the device with a fresh secret, overwriting the old pairing.""" + name = self._pending_data[CONF_NAME] + secret_hex = await self._async_init_with_secret(self._pending_data[CONF_ADDRESS], self._repair_device_id) + if secret_hex is None: + return self.async_abort(reason="repair_failed") + return self.async_create_entry(title=name, data={**self._pending_data, CONF_DEVICE_SECRET: secret_hex}) + + async def async_step_repair_cancel(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: + """Abort the flow, leaving the already-paired device untouched.""" + return self.async_abort(reason="repair_cancelled") + + async def _async_init_with_secret(self, address: str, device_id: int) -> str | None: + """Register a fresh secret with the device. + + Returns the secret as hex on success, or ``None`` if the device is + unreachable or initialization fails. + """ + secret = secrets.token_bytes(8) + ble_device = async_ble_device_from_address(self.hass, address, connectable=True) + if ble_device is None: + return None + + try: + success = await PetkitBleClient(ble_device).async_init_device(device_id, secret) + except Exception: + _LOGGER.exception("Failed to initialize device %s", address) + return None - # Store the secret in config data - entry_data = {**self._pending_data, CONF_DEVICE_SECRET: secret.hex()} - return self.async_create_entry(title=name, data=entry_data) + return secret.hex() if success else None class PetkitBleOptionsFlow(OptionsFlow): diff --git a/custom_components/petkit_ble/protocol.py b/custom_components/petkit_ble/protocol.py index 369cd6a..bcd95eb 100644 --- a/custom_components/petkit_ble/protocol.py +++ b/custom_components/petkit_ble/protocol.py @@ -29,6 +29,18 @@ def build_time_sync_payload() -> list[int]: ] +def parse_device_id(payload: bytes) -> int: + """Read the 8-byte device id from a CMD 213 response (big-endian). + + Must use the same byte order as ``build_init_payload`` so a device's id + round-trips when re-pairing an already-bound device (issue #75). Returns 0 + (uninitialised) when the payload is too short. + """ + if len(payload) < 8: + return 0 + return int.from_bytes(payload[:8], "big") + + def build_init_payload(device_id: int, secret: bytes) -> list[int]: """Build the 16-byte payload for CMD 73 (device init). diff --git a/custom_components/petkit_ble/strings.json b/custom_components/petkit_ble/strings.json index 530efa3..e4862c8 100644 --- a/custom_components/petkit_ble/strings.json +++ b/custom_components/petkit_ble/strings.json @@ -16,6 +16,14 @@ "init_device": { "title": "Initialize Device", "description": "The device **{name}** needs to be initialized. This will register a secret with the device. **Warning:** After initialization, the PetKit app will no longer be able to connect to this device. You can factory reset the device to restore app access." + }, + "confirm_repair": { + "title": "Device Already Paired", + "description": "**{name}** is already paired — most likely with the PetKit app, or with a previous Home Assistant setup whose secret was lost. To control it from Home Assistant you must re-pair it with a new secret. This **overwrites the existing pairing**: the PetKit app won't connect until you factory reset the fountain, and the two can't control it at the same time.", + "menu_options": { + "repair_confirm": "Re-pair now (replaces the existing pairing)", + "repair_cancel": "Cancel — leave the device untouched" + } } }, "error": { @@ -28,7 +36,9 @@ "abort": { "already_configured": "This device is already configured.", "no_devices_found": "No Petkit fountains were found.", - "device_already_initialized": "This device is already initialized with a secret. Please factory reset it first (detach control module, hold power button for 3 seconds until LEDs flash), then try again." + "device_already_initialized": "This device is already initialized with a secret. Please factory reset it first (detach control module, hold power button for 3 seconds until LEDs flash), then try again.", + "repair_failed": "Could not re-pair the device. Bring it closer and try again; if it keeps failing, factory reset it (detach control module, hold power button 3 seconds until LEDs flash), then re-add it.", + "repair_cancelled": "Re-pairing cancelled. The device was left untouched." } } } diff --git a/custom_components/petkit_ble/translations/en.json b/custom_components/petkit_ble/translations/en.json index 377349b..de00b5d 100644 --- a/custom_components/petkit_ble/translations/en.json +++ b/custom_components/petkit_ble/translations/en.json @@ -16,6 +16,14 @@ "init_device": { "title": "Initialize Device", "description": "The device **{name}** needs to be initialized. This will register a secret with the device. **Warning:** After initialization, the PetKit app will no longer be able to connect to this device. You can factory reset the device to restore app access." + }, + "confirm_repair": { + "title": "Device Already Paired", + "description": "**{name}** is already paired — most likely with the PetKit app, or with a previous Home Assistant setup whose secret was lost. To control it from Home Assistant you must re-pair it with a new secret. This **overwrites the existing pairing**: the PetKit app won't connect until you factory reset the fountain, and the two can't control it at the same time.", + "menu_options": { + "repair_confirm": "Re-pair now (replaces the existing pairing)", + "repair_cancel": "Cancel — leave the device untouched" + } } }, "error": { @@ -28,7 +36,9 @@ "abort": { "already_configured": "This device is already configured.", "no_devices_found": "No Petkit fountains were found.", - "device_already_initialized": "This device is already initialized with a secret. Please factory reset it first (detach control module, hold power button for 3 seconds until LEDs flash), then try again." + "device_already_initialized": "This device is already initialized with a secret. Please factory reset it first (detach control module, hold power button for 3 seconds until LEDs flash), then try again.", + "repair_failed": "Could not re-pair the device. Bring it closer and try again; if it keeps failing, factory reset it (detach control module, hold power button 3 seconds until LEDs flash), then re-add it.", + "repair_cancelled": "Re-pairing cancelled. The device was left untouched." } }, "options": { diff --git a/custom_components/petkit_ble/translations/nl.json b/custom_components/petkit_ble/translations/nl.json index ecaa650..ac3b783 100644 --- a/custom_components/petkit_ble/translations/nl.json +++ b/custom_components/petkit_ble/translations/nl.json @@ -16,6 +16,14 @@ "init_device": { "title": "Apparaat initialiseren", "description": "Het apparaat **{name}** moet worden geïnitialiseerd. Dit registreert een geheim bij het apparaat. **Waarschuwing:** Na initialisatie kan de PetKit-app niet meer verbinden met dit apparaat. Je kunt het apparaat terugzetten naar fabrieksinstellingen om app-toegang te herstellen." + }, + "confirm_repair": { + "title": "Apparaat al gekoppeld", + "description": "**{name}** is al gekoppeld — waarschijnlijk met de PetKit-app, of met een eerdere Home Assistant-installatie waarvan het geheim verloren is gegaan. Om het vanuit Home Assistant te bedienen moet je het opnieuw koppelen met een nieuw geheim. Dit **overschrijft de bestaande koppeling**: de PetKit-app kan niet meer verbinden totdat je het apparaat terugzet naar fabrieksinstellingen, en beide kunnen het niet tegelijk bedienen.", + "menu_options": { + "repair_confirm": "Nu opnieuw koppelen (vervangt de bestaande koppeling)", + "repair_cancel": "Annuleren — apparaat ongemoeid laten" + } } }, "error": { @@ -28,7 +36,9 @@ "abort": { "already_configured": "Dit apparaat is al geconfigureerd.", "no_devices_found": "Er zijn geen Petkit-fonteintjes gevonden.", - "device_already_initialized": "Dit apparaat is al geïnitialiseerd met een geheim. Reset het apparaat eerst naar fabrieksinstellingen (verwijder de bedieningsmodule, houd de aan/uit-knop 3 seconden ingedrukt totdat de LED's knipperen) en probeer het opnieuw." + "device_already_initialized": "Dit apparaat is al geïnitialiseerd met een geheim. Reset het apparaat eerst naar fabrieksinstellingen (verwijder de bedieningsmodule, houd de aan/uit-knop 3 seconden ingedrukt totdat de LED's knipperen) en probeer het opnieuw.", + "repair_failed": "Opnieuw koppelen is mislukt. Breng het apparaat dichterbij en probeer het opnieuw; als het blijft mislukken, zet het terug naar fabrieksinstellingen (verwijder de besturingsmodule, houd de aan/uit-knop 3 seconden ingedrukt tot de LED's knipperen) en voeg het opnieuw toe.", + "repair_cancelled": "Opnieuw koppelen geannuleerd. Het apparaat is ongemoeid gelaten." } }, "options": { diff --git a/custom_components/petkit_ble/translations/uk.json b/custom_components/petkit_ble/translations/uk.json index 5257675..7c8b159 100644 --- a/custom_components/petkit_ble/translations/uk.json +++ b/custom_components/petkit_ble/translations/uk.json @@ -16,6 +16,14 @@ "init_device": { "title": "Ініціалізація пристрою", "description": "Пристрій **{name}** потребує ініціалізації. Це зареєструє секрет на пристрої. **Увага:** Після ініціалізації додаток PetKit більше не зможе підключатися до цього пристрою. Ви можете скинути пристрій до заводських налаштувань, щоб відновити доступ через додаток." + }, + "confirm_repair": { + "title": "Пристрій вже спарено", + "description": "**{name}** вже спарено — найімовірніше з додатком PetKit або з попередньою інсталяцією Home Assistant, секрет якої втрачено. Щоб керувати ним із Home Assistant, потрібно повторно спарувати його з новим секретом. Це **перезапише наявне спарювання**: додаток PetKit не зможе підключатися, доки ви не скинете фонтан до заводських налаштувань, і вони не зможуть керувати пристроєм одночасно.", + "menu_options": { + "repair_confirm": "Повторно спарувати зараз (замінює наявне спарювання)", + "repair_cancel": "Скасувати — залишити пристрій без змін" + } } }, "error": { @@ -28,7 +36,9 @@ "abort": { "already_configured": "Цей пристрій вже налаштовано.", "no_devices_found": "Фонтанчики Petkit не знайдено.", - "device_already_initialized": "Цей пристрій вже ініціалізовано з секретом. Спочатку скиньте пристрій до заводських налаштувань (від'єднайте модуль керування, утримуйте кнопку живлення 3 секунди, поки не замигають світлодіоди), а потім спробуйте знову." + "device_already_initialized": "Цей пристрій вже ініціалізовано з секретом. Спочатку скиньте пристрій до заводських налаштувань (від'єднайте модуль керування, утримуйте кнопку живлення 3 секунди, поки не замигають світлодіоди), а потім спробуйте знову.", + "repair_failed": "Не вдалося повторно спарувати пристрій. Піднесіть його ближче та спробуйте ще раз; якщо помилка повторюється, скиньте пристрій до заводських налаштувань (від'єднайте модуль керування, утримуйте кнопку живлення 3 секунди, поки не замиготять світлодіоди) і додайте його знову.", + "repair_cancelled": "Повторне спарювання скасовано. Пристрій залишено без змін." } }, "options": { diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 6da12e3..2b2401f 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -18,6 +18,7 @@ build_settings_payload_ctw3, build_settings_payload_generic, build_time_sync_payload, + parse_device_id, ) @@ -78,6 +79,34 @@ def test_short_secret_padded(self) -> None: assert bytes(result[8:16]) == b"\x01\x02\x03\x00\x00\x00\x00\x00" +class TestParseDeviceId: + """Tests for parse_device_id and its round-trip with build_init_payload.""" + + def test_zero_for_short_payload(self) -> None: + """A payload shorter than 8 bytes reads as uninitialised (0).""" + assert parse_device_id(b"\x01\x02\x03") == 0 + + def test_zero_device_id(self) -> None: + """All-zero id bytes mean the device is uninitialised.""" + assert parse_device_id(bytes(8) + b"SERIAL") == 0 + + def test_big_endian_read(self) -> None: + """The 8 id bytes are read big-endian.""" + assert parse_device_id(b"\x00\x00\x00\x00\x00\x00\x30\x39") == 0x3039 + + def test_roundtrip_with_build_init_payload(self) -> None: + """Re-pairing must send back the device's own id bytes verbatim. + + parse_device_id and build_init_payload must agree on byte order, else a + non-zero device id would be byte-swapped on re-init (issue #75). + """ + raw_id = b"\x12\x34\x56\x78\x9a\xbc\xde\xf0" + payload = raw_id + b"SN-12345" + device_id = parse_device_id(payload) + rebuilt = build_init_payload(device_id, b"\xaa" * 8) + assert bytes(rebuilt[:8]) == raw_id + + class TestBuildSettingsPayloadGeneric: """Tests for build_settings_payload_generic."""