Skip to content
Merged
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
14 changes: 10 additions & 4 deletions custom_components/petkit_ble/ble_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -453,16 +453,22 @@ def _parse_state_generic(data: PetkitFountainData, payload: bytes) -> None:

@staticmethod
def _parse_config_ctw3(data: PetkitFountainData, payload: bytes) -> None:
"""Parse CMD 211 response for CTW3."""
"""Parse CMD 211 response for CTW3.

Layout matches build_settings_payload_ctw3 (idx 6=dnd, 7=led_switch,
8=led_brightness, 9=child_lock). Note: CTW3 firmware 111 never
actually replies to CMD 211, so this parser is currently unreached
on real hardware but kept symmetric with the builder.
"""
if len(payload) < 9:
return
data.smart_time_on = payload[0]
data.smart_time_off = payload[1]
data.battery_work_time = struct.unpack_from(">H", payload, 2)[0]
data.battery_sleep_time = struct.unpack_from(">H", payload, 4)[0]
data.led_switch = payload[6]
data.led_brightness = payload[7]
data.do_not_disturb_switch = payload[8]
data.do_not_disturb_switch = payload[6]
data.led_switch = payload[7]
data.led_brightness = payload[8]
if len(payload) >= 10:
data.is_locked = payload[9]
data.config_loaded = True
Expand Down
2 changes: 1 addition & 1 deletion custom_components/petkit_ble/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"domain": "petkit_ble",
"name": "Petkit BLE",
"version": "1.1.14",
"version": "1.1.15",
"config_flow": true,
"documentation": "https://github.com/aavdberg/ha-petkit",
"issue_tracker": "https://github.com/aavdberg/ha-petkit/issues",
Expand Down
14 changes: 11 additions & 3 deletions custom_components/petkit_ble/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,16 @@ def build_settings_payload_ctw3(
"""Build the payload for CMD 221 (write settings) for CTW3 devices.

Layout: [smart_work, smart_sleep, batt_work_hi, batt_work_lo,
batt_sleep_hi, batt_sleep_lo, led_switch, led_brightness,
dnd_enabled, child_lock]
batt_sleep_hi, batt_sleep_lo, dnd_enabled, led_switch,
led_brightness, child_lock]

Reverse-engineered from real-device CMD 221 captures:
confirmed user actions ``LED on -> brightness 1/8/9 -> LED off`` map
cleanly to ``payload[7] = led_switch`` and ``payload[8] = led_brightness``.
``payload[6]`` is assumed to be ``dnd_enabled`` by analogy with the
generic (W5/CTW2) layout; this could not be exercised by the captures
(always 0) and may be re-validated when a CTW3 firmware response to
CMD 211 becomes available.
"""
return [
smart_work,
Expand All @@ -92,9 +100,9 @@ def build_settings_payload_ctw3(
battery_work_time & 0xFF,
(battery_sleep_time >> 8) & 0xFF,
battery_sleep_time & 0xFF,
dnd_enabled,
led_switch,
led_brightness,
dnd_enabled,
child_lock,
]

Expand Down
6 changes: 3 additions & 3 deletions tests/test_data_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,9 +271,9 @@ def test_parse_config_ctw3(self) -> None:
buf[1] = 7 # smart_sleep
struct.pack_into(">H", buf, 2, 300) # battery_work_time
struct.pack_into(">H", buf, 4, 600) # battery_sleep_time
buf[6] = 1 # led_switch
buf[7] = 5 # led_brightness
buf[8] = 1 # dnd_enabled
buf[6] = 1 # dnd_enabled
buf[7] = 1 # led_switch
buf[8] = 5 # led_brightness
buf[9] = 0 # child_lock

data = PetkitFountainData(alias=ALIAS_CTW3)
Expand Down
108 changes: 105 additions & 3 deletions tests/test_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,11 +148,113 @@ def test_field_positions(self) -> None:
# battery_sleep_time = 600 = 0x0258
assert result[4] == 0x02
assert result[5] == 0x58
assert result[6] == 1 # led_switch
assert result[7] == 8 # led_brightness
assert result[8] == 1 # dnd_enabled
assert result[6] == 1 # dnd_enabled
assert result[7] == 1 # led_switch
assert result[8] == 8 # led_brightness
assert result[9] == 1 # child_lock

def test_real_device_payload_decoding(self) -> None:
"""Regression: payloads captured from a real CTW3 (fw 111).

The user toggled LED on, adjusted brightness, then toggled LED off
between 18:52:13 and 18:52:48 in the 2026-05-03 debug log. After
rotating the byte layout, the led_switch / led_brightness fields
encoded by the integration must match the expected sequence.
"""
cases = [
# (led_switch, led_brightness, expected payload[6..9])
(1, 1, [0, 1, 1, 0]),
(0, 5, [0, 0, 5, 0]),
(1, 8, [0, 1, 8, 0]),
(1, 9, [0, 1, 9, 0]),
(0, 8, [0, 0, 8, 0]),
]
for led_switch, led_brightness, expected_tail in cases:
payload = build_settings_payload_ctw3(
smart_work=0,
smart_sleep=0,
led_switch=led_switch,
led_brightness=led_brightness,
dnd_enabled=0,
child_lock=0,
)
assert payload[6:10] == expected_tail, (
f"led_switch={led_switch}, brightness={led_brightness}: got {payload[6:10]}, expected {expected_tail}"
)


class TestParseConfigCtw3:
"""Round-trip tests for _parse_config_ctw3.

Real-device CMD 211 payloads are not available (CTW3 fw 111 never
replies), so we synthesise payloads identical to what
build_settings_payload_ctw3 emits and verify the parser populates the
matching fields. This pins parser/builder symmetry.
"""

def test_roundtrip_real_device_sequence(self) -> None:
"""Captured user actions (LED on -> brightness changes -> LED off)."""
from custom_components.petkit_ble.ble_client import (
PetkitBleClient,
PetkitFountainData,
)

cases = [
(1, 1),
(0, 5),
(1, 8),
(1, 9),
(0, 8),
]
for led_switch, led_brightness in cases:
payload = bytes(
build_settings_payload_ctw3(
smart_work=0,
smart_sleep=0,
led_switch=led_switch,
led_brightness=led_brightness,
dnd_enabled=0,
child_lock=0,
)
)
data = PetkitFountainData(alias="CTW3")
PetkitBleClient._parse_config_ctw3(data, payload)
assert data.led_switch == led_switch
assert data.led_brightness == led_brightness
assert data.do_not_disturb_switch == 0
assert data.is_locked == 0

def test_roundtrip_with_dnd_and_lock(self) -> None:
"""All four boolean-ish fields round-trip through builder + parser."""
from custom_components.petkit_ble.ble_client import (
PetkitBleClient,
PetkitFountainData,
)

payload = bytes(
build_settings_payload_ctw3(
smart_work=7,
smart_sleep=11,
battery_work_time=300,
battery_sleep_time=600,
led_switch=1,
led_brightness=6,
dnd_enabled=1,
child_lock=1,
)
)
data = PetkitFountainData(alias="CTW3")
PetkitBleClient._parse_config_ctw3(data, payload)
assert data.smart_time_on == 7
assert data.smart_time_off == 11
assert data.battery_work_time == 300
assert data.battery_sleep_time == 600
assert data.do_not_disturb_switch == 1
assert data.led_switch == 1
assert data.led_brightness == 6
assert data.is_locked == 1
assert data.config_loaded is True


class TestBuildModePayload:
"""Tests for mode payload builders."""
Expand Down
6 changes: 3 additions & 3 deletions tests/test_settings_optimistic.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@ def test_ctw3_parser_sets_flag(self) -> None:
data = PetkitFountainData(alias=ALIAS_CTW3)
# CTW3 settings layout (10 bytes):
# [smart_work, smart_sleep, batt_work_hi, batt_work_lo,
# batt_sleep_hi, batt_sleep_lo, led_switch, led_brightness,
# dnd_enabled, child_lock]
payload = bytes([10, 15, 0, 60, 0, 30, 1, 5, 0, 0])
# batt_sleep_hi, batt_sleep_lo, dnd_enabled, led_switch,
# led_brightness, child_lock]
payload = bytes([10, 15, 0, 60, 0, 30, 0, 1, 5, 0])
PetkitBleClient._parse_config_ctw3(data, payload)
assert data.config_loaded is True
assert data.led_switch == 1
Expand Down
Loading