Skip to content
Merged
45 changes: 45 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,51 @@ Even as admin (bypassed protection), direct pushes skip CI and break the audit t

---

## Releasing — promoting `dev` → `main`

When promoting `dev` to `main` for a stable release, the `manifest.json`
version **must be bumped to a new minor**, never just published with the
trailing dev patch number. Each merge of `dev` into `main` represents a
batch of user-visible changes accumulated during the dev cycle, and minor
versions are the right granularity for "shipped to all HACS users".

### Versioning rule (semver, minor-on-release)

- While working on `dev`, patch increments (`1.1.x`) are used for each
fix/feature PR — that's what the `Pre-release` workflow tags as
`v1.1.x-dev.<timestamp>` for HACS beta testers.
- **Before** opening the release PR, land a normal `chore/release-…` PR
into `dev` that bumps `custom_components/petkit_ble/manifest.json` to
the next minor (patch reset to `0`):
- `1.1.8` → `1.2.0`
- `1.2.2` → `1.3.0`
- `1.5.7` → `1.6.0`

The release workflow reads the version from `manifest.json`, so the
bump must already be on `dev` HEAD when the release PR is merged into
`main`. Use the existing `chore/*` branch prefix — no new branch
category is introduced for this step.
- **Major version bumps** (`1.x.y` → `2.0.0`) are reserved for breaking
changes to the integration's user-facing config or entity model and
must be discussed with the user first.

### Release PR checklist

1. On `dev`, bump `manifest.json` `version` to the next minor.
2. Commit: `chore(release): bump version to vX.Y.0` and push (via a normal
PR to `dev` — never direct push).
3. Open release PR: `gh pr create --base main --head dev --title
"release: vX.Y.0 — <summary>"`.
4. Wait for CI green and the Copilot review **submitted** (same gate as
feature PRs).
5. Resolve any review comments.
6. **Merge with a merge commit** (`gh pr merge <N> --merge`), **never
squash** — the dev PR history must be preserved on `main`.
7. Verify `release.yml` published a non-prerelease `vX.Y.0` GitHub
Release.

---

## Code Conventions

- **Language**: All code, comments, docstrings, commit messages, PR titles & descriptions, and GitHub issues MUST be in **English**. This applies even when the user/contributor communicates in another language — only the chat reply to the user may be in their language; everything that lands in the repository or on GitHub is English.
Expand Down
5 changes: 5 additions & 0 deletions .github/instructions/petkit-ble.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ Apply this knowledge when reading, writing, or reviewing code in `custom_compone
- **Branching:** feature/* and fix/* → PR to `dev`; never push directly to `dev` or `main`
- **PR review gate:** Always wait for the Copilot code reviewer to *actually submit* its review (not just the workflow run to finish) before merging. The review comments arrive asynchronously after the "Request Copilot Code Review" workflow turns green. Verify with `gh pr view <N> --json reviews` and address every comment before merge.
- **Post-merge:** After merging to `dev`, verify the `Pre-release` workflow produced a new `v<version>-dev.<timestamp>` GitHub release; this is what HACS beta-testers install.
- **Releasing dev → main:** Each `dev` → `main` promotion is a stable release. See `.github/copilot-instructions.md` → *Releasing — promoting `dev` → `main`* for the full checklist. Quick reference:
- Bump `manifest.json` to the next **minor** before the release PR (`1.1.8` → `1.2.0`, `1.2.2` → `1.3.0`, `1.5.7` → `1.6.0`; reset patch to `0`). Land that bump via a normal `chore/release-…` PR into `dev` first.
- Open the release PR titled `release: vX.Y.0 — <summary>` against `main`; wait for CI + Copilot review submitted, resolve comments.
- **Merge with a merge commit (`--merge`, NOT squash)** so the dev PR history is preserved on `main`. `release.yml` then publishes the stable `vX.Y.0` GitHub Release.
- Major bumps (`1.x.y` → `2.0.0`) require explicit user approval.

## BLE Frame Format

Expand Down
1 change: 1 addition & 0 deletions custom_components/petkit_ble/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry.async_on_unload(entry.add_update_listener(_async_update_listener))

coordinator = PetkitBleCoordinator(hass, entry)
await coordinator.async_load_persistent_state()
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
Expand Down
70 changes: 62 additions & 8 deletions custom_components/petkit_ble/ble_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,28 @@ class PetkitFountainData:
battery_work_time: int = 0
battery_sleep_time: int = 0

# Raw CMD 210 payload as last received. Kept so the coordinator can log
# a byte-by-byte diff between consecutive polls — a diagnostic aid for
# reverse-engineering CTW3 fields whose offsets are not yet known
# (notably ``detect_status``; see issue #65).
raw_state: bytes = b""

# Bytes 26..29 of the CTW3 30-byte CMD 210 payload. Currently unparsed
# — exposed via a hidden diagnostic sensor so users can graph their
# behaviour while we narrow down which byte carries the real
# ``detect_status``. Always empty for non-CTW3 devices and for older
# CTW3 firmware that returns only 26 bytes.
state_tail: bytes = b""

# True once a CMD 211 (read settings) response has been parsed at least
# once for this entry. Some firmware revisions never reply to CMD 211
# (observed on CTW3 fw 111). When this flag is False, the cached
# settings fields are still at dataclass defaults — writing CMD 221
# would zero out smart-cycle / battery / DND / lock values on the
# device. protocol.build_full_settings_payload logs a one-shot warning
# in that case.
Comment on lines +134 to +135
config_loaded: bool = False

@property
def is_ctw3(self) -> bool:
"""Return True if device uses the CTW3 extended state format."""
Expand Down Expand Up @@ -223,11 +245,18 @@ async def _send_and_wait(
type_: int,
data: list[int],
timeout: float = 5.0,
*,
quiet: bool = False,
) -> bytes | None:
"""Send a command frame and wait for the matching response.

Unsolicited notifications with a different cmd byte (e.g. CTW3 CMD 230
extended state pushes) are discarded while waiting for the expected reply.

``quiet=True`` demotes the per-poll timeout warning to DEBUG. Use it for
commands that are known to never reply on certain firmware revisions
(e.g. CMD 211 on CTW3 fw 111) so the log is not flooded; the higher
coordinator layer is responsible for surfacing the user-visible warning.
"""
assert self._client is not None
seq = self._next_seq()
Expand All @@ -236,15 +265,16 @@ async def _send_and_wait(
_LOGGER.debug("TX CMD %d: %s", cmd, frame.hex())
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
log_timeout = _LOGGER.debug if quiet else _LOGGER.warning
while True:
remaining = deadline - loop.time()
if remaining <= 0:
_LOGGER.warning("Timeout waiting for response to CMD %d", cmd)
log_timeout("Timeout waiting for response to CMD %d", cmd)
return None
try:
raw = await asyncio.wait_for(self._rx_queue.get(), remaining)
except TimeoutError:
_LOGGER.warning("Timeout waiting for response to CMD %d", cmd)
log_timeout("Timeout waiting for response to CMD %d", cmd)
return None
parsed = self._parse_frame(raw)
if parsed is None:
Expand Down Expand Up @@ -341,6 +371,11 @@ def _parse_state_ctw3(data: PetkitFountainData, payload: bytes) -> None:
if len(payload) < 26:
_LOGGER.warning("CTW3 state payload too short: %d bytes", len(payload))
return
data.raw_state = bytes(payload)
# Bytes 26..29 are not yet decoded by this parser — see issue #65.
# Captured raw so the user (and future maintainers) can correlate
# them with observed pet-detection events via the diagnostic sensor.
data.state_tail = bytes(payload[26:30]) if len(payload) >= 30 else b""
data.power_status = payload[0]
data.suspend_status = payload[1]
# The CTW3 firmware has been observed to transiently report mode=0 during
Expand Down Expand Up @@ -369,7 +404,11 @@ def _parse_state_ctw3(data: PetkitFountainData, payload: bytes) -> None:
data.filter_percent = payload[13]
data.running_status = payload[14]
data.pump_runtime_today = struct.unpack_from(">I", payload, 15)[0]
data.detect_status = payload[19]
# Normalize to 0/1: CTW3 firmware 111 has been observed to use
# value 2 (not 1) when the proximity sensor sees a pet. Treating any
# non-zero value as "detected" makes the field future-proof against
# other bit patterns (issue #65).
data.detect_status = 1 if payload[19] else 0
data.supply_voltage_mv = struct.unpack_from(">h", payload, 20)[0]
data.battery_voltage_mv = struct.unpack_from(">h", payload, 22)[0]
data.battery_percent = payload[24]
Expand All @@ -395,6 +434,7 @@ def _parse_state_generic(data: PetkitFountainData, payload: bytes) -> None:
if len(payload) < 12:
_LOGGER.warning("State payload too short: %d bytes", len(payload))
return
data.raw_state = bytes(payload)
data.power_status = payload[0]
data.mode = payload[1]
data.dnd_state = payload[2]
Expand All @@ -413,18 +453,25 @@ 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

@staticmethod
def _parse_config_generic(data: PetkitFountainData, payload: bytes) -> None:
Expand All @@ -444,6 +491,7 @@ def _parse_config_generic(data: PetkitFountainData, payload: bytes) -> None:
data.dnd_end_minutes = struct.unpack_from(">H", payload, 11)[0]
if len(payload) >= 14:
data.is_locked = payload[13]
data.config_loaded = True

# ------------------------------------------------------------------
# Device initialization (first-time setup only)
Expand Down Expand Up @@ -531,7 +579,13 @@ async def async_poll(self, alias: str, secret: bytes | None = None) -> PetkitFou
self._parse_state_generic(data, payload_210)

# CMD 211 — device config (settings)
payload_211 = await self._send_and_wait(CMD_GET_CONFIG, FRAME_TYPE_SEND, [])
# CTW3 firmware 111 is known to never reply to CMD 211, so we
# demote the timeout to DEBUG for that alias to avoid flooding
# the log every minute. The coordinator still emits a single
# WARNING via _reconcile_settings_into() the first time, and
# the settings cache keeps user-set values intact across polls.
quiet_211 = data.alias in CTW3_ALIASES
payload_211 = await self._send_and_wait(CMD_GET_CONFIG, FRAME_TYPE_SEND, [], quiet=quiet_211)
if payload_211 is not None:
if data.alias in CTW3_ALIASES:
self._parse_config_ctw3(data, payload_211)
Expand Down
Loading
Loading