fix: BusyBarClient.set_busy_simple sends a body the device rejects - #11
Conversation
The device's /openapi.yaml documents PUT /api/busy/snapshot's BusySnapshot body as the discriminated snapshot variant merged (via allOf) with a required busy_bar_settings, sent flat -- the shape set_busy_simple sent. Against a live device this flat body gets HTTP 400 "Failed to parse snapshot" on every call, silently breaking calendar_countdown's auto_busy=true feature (it never surfaces the failure -- set_busy_simple's bool return is not checked by the caller). Empirically the firmware instead wants the snapshot variant nested under a "snapshot" key, sibling to a top-level "snapshot_timestamp_ms" -- mirroring get_busy()'s own GET response shape -- and does NOT want busy_bar_settings on this write path at all. Also found on-device: snapshot_timestamp_ms must be a genuinely current timestamp; a stale/placeholder value still returns 200 but silently no-ops the write, so set_busy_simple now sends time.time()-derived "now" rather than a fixed value. Verified on-device: the corrected body returns 200 and a subsequent GET /api/busy/snapshot shows a real active SIMPLE session; ran calendar_countdown.main.run_once with auto_busy=true and a synthetic in-progress event against the real device (draw/clear no-op'd to avoid touching the display) and confirmed the session actually starts. Device left in NOT_STARTED afterward. Public signature of set_busy_simple is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: df82e512e9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| "snapshot": {"type": "SIMPLE", "card_id": NULL_CARD_ID, | ||
| "time_left_ms": time_left_ms, "is_paused": False}, | ||
| "snapshot_timestamp_ms": int(time.time() * 1000), |
There was a problem hiding this comment.
Normalize the nested busy snapshot before writing
When auto_busy is enabled during an active event, the live GET shape documented here is {"snapshot": {"type": ...}, ...}, but calendar_countdown.run_once still checks busy.get("type"). It therefore treats every existing SIMPLE or other active session as absent, and now that this PUT payload is accepted, overwrites that session on every poll. Update get_busy() to return the nested snapshot or change the caller to inspect busy["snapshot"]["type"] before enabling these successful writes.
Useful? React with 👍 / 👎.
Root cause
BusyBarClient.set_busy_simple(src/busybar/client.py) sentPUT /api/busy/snapshotwith theBusySnapshotSimplefields flat at the top level -- the literal shape described by the device's own/openapi.yaml(BusySnapshot= the discriminated snapshot variant merged viaallOfwith a requiredbusy_bar_settings, all flattened). Against a live device this body gets HTTP 400"Failed to parse snapshot"on every call. The caller (calendar_countdown/main.py'srun_once, theauto_busy=truebranch) never checksset_busy_simple's bool return, so the feature silently did nothing.Corrected shape (verified empirically against the live device, not guessed)
{ "snapshot": { "type": "SIMPLE", "card_id": "00000000-0000-0000-0000-000000000000", "time_left_ms": 90000, "is_paused": false }, "snapshot_timestamp_ms": 1785829020000 }The snapshot variant is nested under a
"snapshot"key, sibling to a top-level"snapshot_timestamp_ms"-- mirroringget_busy()'s own GET response shape -- andbusy_bar_settingsis not sent at all on this write path, despite the spec marking it required (the device fills its own defaultbusy_bar_settingsin, visible on the next GET).A second finding surfaced during probing:
snapshot_timestamp_msmust be a genuinely current timestamp. Reusing a stale/placeholder value (e.g. a timestamp copied from a prior GET) still returns HTTP 200 but silently no-ops the write -- the busy state does not actually change.set_busy_simplenow sendsint(time.time() * 1000).Evidence
Direct on-device probing (device at
10.0.4.20, firmware1.1.1):Then ran the real
calendar_countdown.main.run_onceagainst the real device withauto_busy=trueand a synthetic in-progressCalEvent(fakefetch;draw/clearno-op'd via a thin proxy to avoid touching the display, per this task's device-phase constraints):Device was returned to
NOT_STARTEDafterward (confirmed via GET) and left idle.Tests
tests/test_client.py::test_set_busy_simple_payloadrewritten to assert the nested body shape (mockingtime.timefor a deterministicsnapshot_timestamp_ms), plus a newtest_set_busy_simple_false_on_400regression guard for the failure path.set_busy_simple's public signature is unchanged.