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
78 changes: 78 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,84 @@ To add a new integration:
- The repo ships only `config.example.toml`, documenting all fields and defaults.
- CI/CD can inject secrets via environment-variable expansion in config parsing if needed.

## Cloud transport

By default, `BusyBarClient` talks to your BUSY Bar directly over the LAN
(`[device].host`). As of v1.6, it can automatically fall back to BUSY's
cloud relay if the local device becomes unreachable — USB unplugged,
Wi-Fi drop, the device off — and recover back to local on its own once
it's reachable again. This is entirely optional and off by default.

### Setting it up

1. Create a token at [cloud.busy.app](https://cloud.busy.app) → **API
tokens** tab → create a new token with the **"BUSY Bar"** scope. This
scope grants full control of exactly one linked device — there's no
separate device ID to configure; the token itself identifies which
device it talks to.
2. Add it to your `config.toml` (**never** `config.example.toml`, and
never anything committed to the repo — see "Configuration" above):
```toml
[device]
host = "10.0.4.20"
cloud_token = "paste-your-real-token-here"
```
3. Optionally set `transport` (default `"auto"`):
- `"auto"` — local first, cloud fallback when the local device is
unreachable and `cloud_token` is set. Recovers back to local
automatically.
- `"local"` — local only, never falls back (identical to pre-v1.6
behavior; the default if you never set `cloud_token`).
- `"cloud"` — forced cloud only, never attempts local. Mainly useful
for deliberately exercising/debugging the cloud path.
4. `cloud_base_url` defaults to `https://api.busy.app/busybar` and
normally doesn't need to change — see the base-URL note below.

### Rotating or revoking a token

Manage tokens from the same **API tokens** tab on cloud.busy.app.
Revoking a token takes effect **immediately and cannot be undone** — if
you're rotating, create and deploy the replacement token first, then
revoke the old one, rather than revoking first.

### What cloud fallback does NOT cover

Continuous status streaming (`/api/status/ws`) is local-only by design —
the cloud API has no equivalent, so a caller relying on the status
WebSocket will not get a cloud fallback for it. Everything else this
client uses (`draw`, `clear`, `status`, `get_busy`, `set_busy_simple`,
`play_audio`) is a synchronous request/response call and mirrors 1:1
over cloud.

### Post-merge live-probe checklist

This round's tests are entirely mocked — no cloud request has been made
against a real token, since the operator hadn't provisioned one yet.
**Before relying on cloud fallback in practice**, run this checklist
once a real `cloud_token` is in `config.toml`:

1. **Forced-cloud draw probe.** Set `transport = "cloud"` temporarily
and run a `client.draw(...)` (e.g. via either integration's
`--once --dry-run=false` path, or a one-off script) to confirm the
token is valid and a real device draw round-trips over the cloud
relay end to end.
2. **Cadence headroom check.** No rate limit or cadence guidance is
documented anywhere for the cloud API (see
`scratchpad/busy-cloud-api-research.md`'s "Open items"). Run
`calendar_countdown` (10s ambient redraw cadence) forced onto cloud
transport for a few minutes and confirm no throttling/errors show up
before trusting cloud fallback to hold up under sustained polling.
3. **Base-URL ambiguity.** This codebase defaults `cloud_base_url` to
`https://api.busy.app/busybar`, but busylib-py's own hardcoded default
is the differently-hosted `https://proxy.busy.app` — an unresolved
discrepancy in the source research, not something this round's mocked
tests can settle. Confirm which base actually works against a live
token (or whether both do) and update the default/docs here if
`api.busy.app/busybar` turns out to be wrong or non-canonical.

Set `transport` back to `"auto"` (or leave it, since `"auto"` is the
default) once the checklist above passes.

## What's inside

| Integration | Description |
Expand Down
21 changes: 21 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,27 @@
[device]
host = "10.0.4.20" # USB-Ethernet default; set your LAN IP for Wi-Fi

# Cloud transport fallback (v1.6, optional). When cloud_token is set,
# BusyBarClient automatically falls back to BUSY's cloud relay if the
# local device becomes unreachable (USB unplugged, Wi-Fi drop, etc.), and
# recovers back to local automatically once the device is reachable
# again. Leave cloud_token empty (the default) to disable cloud fallback
# entirely -- behavior is then identical to pre-v1.6.
#
# Mint a token at https://cloud.busy.app -> "API tokens" tab -> create a
# token with the "BUSY Bar" scope (full device control, tied to exactly
# one linked device -- no separate device id needed here). Revoking a
# token from the dashboard takes effect immediately and cannot be undone;
# create the replacement token first if you're rotating.
#
# The real token belongs ONLY in your git-ignored config.toml -- never
# here, and never committed anywhere. See README.md's "Cloud transport"
# section for the full walkthrough.
cloud_token = ""
cloud_base_url = "https://api.busy.app/busybar"
transport = "auto" # "auto" (local, fall back to cloud) | "local" | "cloud" (forced --
# mainly for deliberately testing the cloud path)

[calendar_countdown]
poll_seconds = 10 # ambient-tier redraw cadence (default: 10) -- matches the running-CI
# overlay's 10s dwell gap so this app's redraws reliably land inside
Expand Down
207 changes: 207 additions & 0 deletions docs/superpowers/specs/2026-08-03-calendar-ci-integrations-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -1460,3 +1460,210 @@ operator directly ear-verified the corrected `.snd` call as part of
reporting the original bug, which is stronger evidence than a fresh
scripted probe could add (a scripted probe can only confirm HTTP `200`
again, exactly the signal already shown not to prove audibility).

## 2026-08-04 — v1.6 cloud transport fallback

**Status:** Implemented, branch `dev/claude/cloud-transport-v1.6` off
`main` (top of `main` at branch time: PR #13, the chirp-`.snd` fix). Not
pushed. **No live cloud verification performed this round** — the
operator hadn't provisioned a real `cloud_token` yet; see the README's
"Post-merge live-probe checklist" for what runs once one exists.

Operator-approved feature, based on `scratchpad/busy-cloud-api-research.md`
(source citations: `busy-app/busylib-py` on GitHub, docs.busy.app, and the
live cloud OpenAPI spec at `api.busy.app/busybar/openapi.yaml`). BUSY
exposes a cloud relay in addition to the device's local HTTP API — same
endpoint surface, mounted under `/busybar/...` instead of `/api/...`, bearer-
token authenticated, synchronous (the relay bridges the HTTP call over MQTT
to the device and holds the connection until it replies, so callers see the
same 200/409 semantics as local — no async/poll pattern to add). One
confirmed gap: `/api/status/ws` (continuous status streaming) has no cloud
equivalent — local-only by construction, not something this round could
paper over.

### `BusyBarClient` transport layer (`src/busybar/client.py`)

Single-class transport-flag design, mirroring busylib-py's own pattern
(one class, a mode flag branching request construction) rather than a
class hierarchy — matches this codebase's existing single-`BusyBarClient`
shape and needed no changes to `DrawResult`'s enum members or to any
consumer's call to `draw`/`clear`/`status`/`get_busy`/`set_busy_simple`/
`play_audio`; the fallback lives entirely inside `_request`.

New constructor kwargs: `cloud_token` (default `""`, disables cloud
fallback entirely when empty), `cloud_base_url` (default
`"https://api.busy.app/busybar"`), `transport` (`"auto"` | `"local"` |
`"cloud"`, default `"auto"`), `cloud_timeout` (fixed `(5, 15)`, not
config-exposed — longer than local's `(3, 5)` since a cloud round-trip
crosses the public internet and bridges over MQTT to the device rather
than a direct LAN hop).

- **`"local"`**: unchanged pre-v1.6 behavior exactly — `_request` calls
local only, never falls back, regardless of `cloud_token`.
- **`"cloud"`**: forced — `_request` calls cloud only, never attempts
local. For deliberately exercising/debugging the cloud path (e.g. the
live-probe checklist below).
- **`"auto"`** (default): local-first-with-cloud-fallback.
`active_transport` (`"local"` | `"cloud"`) tracks which transport last
succeeded. On a fresh/healthy client, every call tries local first with
the existing `(3, 5)` timeout; on a `requests.RequestException` AND a
non-empty `cloud_token`, the SAME request (same method, path, body) is
retried against `cloud_base_url` with `cloud_timeout` and an
`Authorization: Bearer <cloud_token>` header. A successful cloud call
transitions `active_transport` to `"cloud"` (logged once at INFO,
transition only — not on every request while already degraded).
- **Local-recovery probe** (`LOCAL_RETRY_SECONDS = 60`): while
degraded (`active_transport == "cloud"`), `_request` skips the local
attempt entirely and goes straight to cloud until
`LOCAL_RETRY_SECONDS` have elapsed since the last local failure, at
which point the next call tries local first again as a recovery
probe. Doing this inline per-request (no background prober thread)
is cheap specifically because a down local device fails fast
(connection refused/timeout, well under even the `(3, 5)` local
timeout) — the occasional 60s-interval probe costs little even if
local is still down, and if the probe itself fails, the client falls
through to cloud for that same request and resets the degraded timer
to the probe's own failure time (so the next probe is another full
window out, not immediately retried).
- **Path mapping**: local paths are `/api/<endpoint>`; per the research
doc, cloud mirrors them 1:1 under `/busybar/<endpoint>` relative to
the cloud host. Since `cloud_base_url`'s documented default already
carries that `/busybar` segment, `_cloud_path` simply strips the
local `/api` prefix and lets `cloud_base_url` supply the rest (e.g.
local `/api/display/draw` → cloud tail `/display/draw` → full URL
`https://api.busy.app/busybar/display/draw`).
- **`DrawResult.UNREACHABLE` redefinition**: now means both local AND
cloud (when configured) failed for this call — previously it only
ever meant local failed, since there was no other transport. When
`cloud_token` is empty, behavior is unchanged from pre-v1.6: a local
failure alone is `UNREACHABLE`, no cloud attempt is made at all.

**Base-URL discrepancy, deliberately left unresolved this round.**
busylib-py's own hardcoded default is `https://proxy.busy.app` — a
different host entirely from this codebase's `https://api.busy.app/busybar`
default. The research doc flags this as unresolved; this round follows the
operator's explicit instruction to default to `api.busy.app` here and defer
resolution to the post-merge live probe (README checklist item 3) rather
than guessing which is authoritative without a real token to test against.

**Security: `cloud_token` is never logged, at any level including DEBUG.**
Only transport *transitions* are logged (INFO), and those log lines are
static strings with no header/token interpolation — grep confirms no
f-string, `%s`, or `.format()` call anywhere in `client.py` ever
interpolates `cloud_token` or a header dict into a log call. A dedicated
`caplog`-based test (`test_cloud_token_never_appears_in_log_output`)
exercises both the degrade and the both-transports-fail paths and asserts
the placeholder token string never appears in any captured log record's
formatted message or args.

### Config (`src/busybar/config.py`, `config.example.toml`)

New `[device]` keys, added to `DEFAULTS["device"]` and mirrored in
`config.example.toml`: `cloud_token = ""`, `cloud_base_url =
"https://api.busy.app/busybar"`, `transport = "auto"`. Key names were
chosen to match `BusyBarClient`'s constructor kwargs exactly, so both
integration call sites (`calendar_countdown/main.py`,
`ci_status/main.py`) simplify from `BusyBarClient(host=cfg["device"]
["host"])` to `BusyBarClient(**cfg["device"])` — a single point of
plumbing rather than adding three more explicit keyword arguments at each
call site, and automatically future-proof against a v1.7 adding a fourth
`[device]` key.

### Verification

`TZ=UTC uv run pytest -v`: 337 passed (323 at the start of this branch's
work, after the chirp-`.snd` fix line). New coverage (`tests/test_client.py`):
auto-mode fallback on local failure (asserts both the local call and the
subsequent cloud call's exact URL), no fallback when `cloud_token` is
empty, `UNREACHABLE` requiring both transports to fail, forced `"local"`
never attempting cloud even on failure, forced `"cloud"` always going
straight to cloud, the cloud request's exact shape (Bearer header,
`cloud_timeout`, base URL), the `/api` → `/busybar` path mapping, three
recovery-probe timing tests (skip-local within the window, probe-and-
recover once the window elapses, probe-fails-so-stays-cloud-and-resets-
timer), a fresh-client sanity check that the skip logic never fires before
any degradation, and the token-never-logged `caplog` test. New coverage
(`tests/test_config.py`): the three new `[device]` defaults, and two
config.example.toml parity tests (device section value-for-value matches
`DEFAULTS["device"]`, and `cloud_token` in the shipped example is
literally `""` — not a placeholder that merely looks non-empty).

### Docs

README gained a "Cloud transport" section: token creation walkthrough
(cloud.busy.app → API tokens tab → "BUSY Bar" scope), config placement
(explicitly: the real token lives only in git-ignored `config.toml`,
never `config.example.toml`, never committed), rotation guidance
(revocation is immediate and irreversible — create the replacement before
revoking the old one), the status-WS cloud gap, and the post-merge
live-probe checklist (forced-cloud draw probe, cadence headroom check at
the 10s ambient-redraw cadence, base-URL ambiguity resolution) —
duplicated in condensed form above so both the report and the operator-
facing docs carry it.

### Deferred to the operator (explicit — not an oversight)

No live cloud verification was performed or attempted this round, per
the coordinator's explicit instruction: the operator had not yet inserted
a real `cloud_token`. All 12 new `test_client.py` tests and both new
`test_config.py` tests use `requests` mocks and a literal
placeholder-string token (`"test-placeholder-token-do-not-use"`) — no
network call was made to any `busy.app` host during this round's work.
The three-item live-probe checklist above is the explicit handoff for
the controller/operator pass that runs once a token exists.

### Final-gate review fixes (same branch)

The coordinator's final-gate review found two Important issues and three
Minor issues before merge:

1. **Important — `**cfg["device"]` crash surface.** Both call sites
splatting the raw `[device]` dict meant an unknown/typo'd key (e.g.
`coud_token` for `cloud_token`) went from silently-ignored (pre-v1.6,
when only `host=` was ever passed explicitly) to a `TypeError`
crashing startup — exactly the wrong failure mode for a typo made
while first configuring `cloud_token`. Fixed with a new
`busybar.config.device_kwargs(cfg)` helper: filters `cfg["device"]` to
`BusyBarClient`'s actual constructor kwargs (introspected via
`inspect.signature`, so it can't drift out of sync with the
constructor) and logs a `WARNING` naming each dropped key, restoring
"ignored, not fatal" while adding the observability the pre-v1.6 code
never had. Both call sites now read
`BusyBarClient(**device_kwargs(cfg))`. Five new tests in
`test_config.py`: known keys pass through unchanged, an unknown key is
dropped without crashing (`caplog` confirms the `WARNING` names it),
constructing `BusyBarClient` from the filtered result never raises,
one `WARNING` fires per unknown key (not one combined message), and no
warnings fire when every key is known.
2. **Important — untested highest-risk semantic.** Added
`test_local_http_500_is_error_and_does_not_trigger_cloud_fallback`:
with `cloud_token` configured, a local HTTP `500` (a real response, no
exception) must return `DrawResult.ERROR` and must NOT attempt cloud
at all — locks in that fallback triggers strictly on
`requests.RequestException`, never on a non-2xx/409 response the
device actually returned. This was previously only implied by the
`_request` implementation, not directly asserted.
3. **Minor — inaccurate comment fixed.**
`test_cloud_token_never_appears_in_log_output`'s second `draw()` call
was commented "local fails again, cloud fails too" — wrong: per the
recovery-probe design, a call made well within `LOCAL_RETRY_SECONDS`
of degrading skips the local attempt entirely and goes straight to
cloud, so only one `requests.request` call happens on that second
draw. Corrected the comment and, per the reviewer's suggestion, added
`@patch("busybar.client.time.monotonic")` to control elapsed time
explicitly rather than relying on real wall-clock time staying under
60s between two adjacent test statements.
4. **Minor — cloud-409 coverage added.** Two new tests:
`test_cloud_409_is_rejected_not_error_forced_cloud` (forced
`transport="cloud"`) and `test_cloud_409_is_rejected_not_error_while_degraded`
(auto mode, already degraded to cloud) — both assert a cloud `409`
maps to `DrawResult.REJECTED`, mirroring the existing local-409
coverage.
5. **Minor — transport `ValueError` guard tested.**
`test_invalid_transport_value_raises_value_error` asserts
`BusyBarClient(transport="carrier-pigeon")` raises `ValueError` naming
the bad value — previously implemented but unverified by any test.

`TZ=UTC uv run pytest -v`: 346 passed (337 before this review round's 9
net-new tests: 5 in `test_config.py` for `device_kwargs`, 4 in
`test_client.py` for the 500/409/ValueError contracts).
4 changes: 2 additions & 2 deletions integrations/calendar_countdown/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from datetime import datetime, timezone

from busybar.client import BusyBarClient, DrawResult
from busybar.config import load_config
from busybar.config import device_kwargs, load_config
from busybar.display import PRIORITY_AMBIENT, ambient_timeout

from .logic import (ascii_safe, build_elements, select_active_event,
Expand Down Expand Up @@ -215,7 +215,7 @@ def main() -> int:
ordering_warning = check_threshold_ordering(cfg["calendar_countdown"])
if ordering_warning is not None:
log.warning(ordering_warning)
client = BusyBarClient(host=cfg["device"]["host"])
client = BusyBarClient(**device_kwargs(cfg))
# Drop any stale elements from a previous process. This also protects a
# restart onto this version against every id change made across the
# v1.3 -> v1.3.1 -> v1.4 line: v1.3.1 replaced the native "countdown"
Expand Down
4 changes: 2 additions & 2 deletions integrations/ci_status/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from datetime import datetime, timedelta, timezone

from busybar.client import BusyBarClient, DrawResult
from busybar.config import load_config
from busybar.config import device_kwargs, load_config
from busybar.display import OVERLAY_DWELL_SECONDS, overlay_gap_elapsed

from .logic import (
Expand Down Expand Up @@ -366,7 +366,7 @@ def main() -> int:
except RuntimeError as exc:
log.error(str(exc))
return 1
client = BusyBarClient(host=cfg["device"]["host"])
client = BusyBarClient(**device_kwargs(cfg))
client.clear(APP) # drop any stale elements from a previous process (type collisions 400)

state_cache: dict[str, RepoState] = {}
Expand Down
Loading
Loading