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
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions custom_components/petkit_ble/ble_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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()
Expand Down
84 changes: 51 additions & 33 deletions custom_components/petkit_ble/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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):
Expand Down
12 changes: 12 additions & 0 deletions custom_components/petkit_ble/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
12 changes: 11 additions & 1 deletion custom_components/petkit_ble/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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."
}
}
}
12 changes: 11 additions & 1 deletion custom_components/petkit_ble/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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": {
Expand Down
12 changes: 11 additions & 1 deletion custom_components/petkit_ble/translations/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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": {
Expand Down
12 changes: 11 additions & 1 deletion custom_components/petkit_ble/translations/uk.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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": {
Expand Down
29 changes: 29 additions & 0 deletions tests/test_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
build_settings_payload_ctw3,
build_settings_payload_generic,
build_time_sync_payload,
parse_device_id,
)


Expand Down Expand Up @@ -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."""

Expand Down