diff --git a/README.md b/README.md index b2adf0f..2efb7de 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Per-integration extras (e.g., macOS Calendar access for `calendar_countdown`) ar 1. Clone the repo: ```bash - git clone https://github.com/your-org/busybar-integrations.git + git clone https://github.com/sumitake/busybar-integrations.git cd busybar-integrations ``` @@ -44,6 +44,105 @@ Per-integration extras (e.g., macOS Calendar access for `calendar_countdown`) ar uv run python -m ci_status.main --once --dry-run ``` +## Firmware 1.2.3 support + +The local API is capability-probed through `GET /api/version` (`api_semver`). +API **27.5.0+** enables selective element cleanup, explicit drawing order +(`z_index`), and small inline XPM2 CI icons. Older/unknown firmware and cloud +relay retain the ordinary text/shape payloads. No new configuration is needed +for these display improvements; `[ci_status] bitmap_icons = false` disables +the cosmetic icons. + +Calendar and CI still send complete, expiring frames on their established +cadence. At a same-priority layout change, selective cleanup removes obsolete +IDs while keeping common content visible. Type changes and priority reductions +use an app-scoped full clear. Neither path is an atomic frame transaction; +timeouts and subsequent full redraws provide recovery after preemption or a +failed request. Firmware 1.2.3 has an application-name parsing bug in the +selective DELETE body, so this client always puts ownership in the query. + +The large calendar countdown and device-native Nyan animation remain in use. +The firmware's native countdown font is too small for the existing calendar +layout; Nyan already plays its uploaded animation on the bar. Firmware fixes +for Wi-Fi status streaming and networking benefit the existing local API +without adding another background listener. + +### Inspect the device + +```bash +uv run python -m busybar diagnose +uv run python -m busybar diagnose --host 10.0.4.20 --screen screen.bmp +``` + +If a macOS editable install reports `No module named busybar` (Python can +ignore a `.pth` file marked hidden), run from the repository root with +`PYTHONPATH=src uv run python -m busybar diagnose`. This uses the same source +modules without relying on the editable-install file. + +Diagnostics read firmware/API versions, local transport, power and BUSY +snapshot availability. They never dump tokens/configuration, play audio, +start timers, or write device logs. An incomplete report exits nonzero. +Screen capture converts firmware 1.2.3's base64 BGR framebuffer into a standard +BMP; the endpoint's `image/bmp` header does not describe its actual wire data. + +### Local tokens and USB/Wi-Fi recovery + +```toml +[device] +host = "10.0.4.20" +fallback_hosts = ["192.0.2.20"] # replace with your bar's Wi-Fi address +local_token = "" # preferably supply BUSYBAR_LOCAL_TOKEN instead +transport = "auto" +discover = false +# device_id = "001122aabbcc" # USB MAC with colons removed, for opt-in discovery +``` + +Local tokens use `X-API-Token`; cloud tokens use `Authorization: Bearer`. +Redirects are disabled and credentials are kept separate. Token creation or +revocation is not automatic. Supply only addresses for the same trusted device; +mDNS and local HTTP are not a cryptographic device identity check. + +Explicit local alternatives work without extra dependencies. Optional discovery +uses the firmware's actual HTTP service registration, not a proprietary service: +`busybar-._http._tcp.local.` on port 80. + +```bash +uv sync --extra discovery +uv run python -m busybar discover --timeout 3 +``` + +Use the returned bare `device_id` with `discover = true`. Discovery scans are +short, close their resources, and refresh no more than once per minute. The +client tries at most four local addresses per operation, then the configured +cloud route for supported operations. A working fallback remains preferred +between recovery probes. Missing discovery support or a failed scan leaves +explicit hosts usable; the CLI distinguishes an unavailable scan from a +successful scan that found no devices. + +HTTP rejections (including authentication errors and a higher-priority canvas) +do not trigger failover. Reads and display updates can use bounded fallback. +Audio and timer starts are not replayed after an uncertain send/read failure; +only a definite connection timeout permits another route. Calendar chirps are +attempted once per event edge, and `auto_busy` requires a positively observed +nested `NOT_STARTED` snapshot rather than treating unavailable state as idle. + +### Platform examples + +- [Home Assistant](examples/home_assistant/README.md): built-in REST sensors + for the nested BUSY snapshot and an expiring notification command with a + secret placeholder. Notices stay below urgent calendar and BUSY-session + priority and expire within 1–60 seconds. This is optional YAML, not a custom + integration or an automatic HA installation. +- [On-device JavaScript](examples/javascript/README.md): a finite 30-second + health demo using fetch, timers, and one persisted run counter. Scripts can + be uploaded into app asset subdirectories with `upload_asset(app, + "scripts/main.js", data)`. The firmware runner is experimental; this example + does not replace the host integrations or install persistent autostart. + +Protocol references: [firmware 1.2.3 release](https://github.com/busy-app/busybar-firmware/releases/tag/1.2.3), +[display API](https://github.com/busy-app/busybar-firmware/blob/2cd7ec8abf8479ba3398241e99d291ec24f2a96f/applications/services/web_server/openapi/assets.yaml), +[HTTP service registration](https://github.com/busy-app/busybar-firmware/blob/2cd7ec8abf8479ba3398241e99d291ec24f2a96f/applications/services/web_server/web_server.c). + ## How it works The display is a shared 72×16 canvas. Each integration publishes text, shapes, or status via the `busybar.client.BusyBarClient` API (see [`src/busybar/client.py`](src/busybar/client.py)). The display arbitrates by **priority**, through the shared ladder in [`src/busybar/display.py`](src/busybar/display.py): @@ -127,10 +226,13 @@ revoke the old one, rather than revoking first. 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. +WebSocket will not get a cloud fallback for it. Selective deletion, asset +uploads, capability probes, and the diagnostic screen are also local-only. +Ordinary `draw`, `clear`, `status`, and `get_busy` calls retain cloud support; +new bitmap/layer fields are omitted from cloud drawings. Bitmap-only frames +require verified modern local firmware and never fall back to an empty cloud +draw. `set_busy_simple` and `play_audio` can use cloud directly, but an uncertain +local send is not replayed through the relay. ### Verified against the live cloud API diff --git a/config.example.toml b/config.example.toml index 1f8f368..6f5d0e2 100644 --- a/config.example.toml +++ b/config.example.toml @@ -24,6 +24,14 @@ 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) +# Local API-token and route discovery are opt-in. The local token is sent only +# to the device as X-API-Token; it is never sent to BUSY's cloud relay. Prefer +# BUSYBAR_LOCAL_TOKEN in your environment over writing a token in config.toml. +local_token = "" +fallback_hosts = [] # at most three explicit LAN fallback hosts +discover = false # mDNS convenience on a trusted LAN; not authentication +device_id = "" # exact 12-hex USB MAC without colons when discover = true + [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 @@ -63,6 +71,7 @@ running_poll_seconds = 20 # poll interval while a run is active (shortened fr show_quota = true # GraphQL/REST quota frames join the overlay rotation while a run # is active (no effect if show_running is false) running_spinner = true # animated 8x8 spinner on the running badge +bitmap_icons = true # use small XPM2 accents on current local firmware; text layout remains the fallback # Account-wide watching (v1.5.1) -- off by default. When on, the watch list # becomes auto-discovered account repos UNION `repos` above, MINUS diff --git a/docs/firmware-1.2.3-validation.md b/docs/firmware-1.2.3-validation.md new file mode 100644 index 0000000..4cc417f --- /dev/null +++ b/docs/firmware-1.2.3-validation.md @@ -0,0 +1,102 @@ +# Firmware 1.2.3 qualification + +Qualification date: 2026-09-08. Source baseline: `097e1bb`. +Device: firmware **1.2.3**, API **27.5.0**, build **2026-09-03**, firmware +commit `2cd7ec8abf8479ba3398241e99d291ec24f2a96f`. + +## Automated checks + +- Existing baseline: 374 passing tests. +- Implementation: 433 passing tests, including existing calendar/CI/Nyan + behavior and new transport, discovery, presentation and diagnostic cases. +- Source distribution and wheel built successfully with `uv build`. +- Python compilation and `git diff --check` passed. +- Optional discovery dependency installed and exercised; ordinary client + operation does not require it. +- Home Assistant YAML parsed with a `!secret` placeholder constructor. Jinja + rendering checked malformed/unknown/valid snapshots, quoted/multiline + notification text, and TTL inputs `0`, negative, malformed, normal and + oversized. The notice is bounded to 1–60 seconds. + +Tests cover local/cloud credential separation, redirect suppression, no +failover on HTTP rejection, no replay of uncertain audio/timer requests, +priority step-down, selective-delete fallback, expired/preempted elements, +bitmap-only local drawing, legacy/cloud cosmetic fallback, unknown BUSY +state, discovery cleanup/failure reporting, and screen conversion. + +## Live device checks + +| Capability | Positive observation | +|---|---| +| API and firmware detection | Local diagnostic report returned 1.2.3 / 27.5.0 with `complete: true`. | +| Explicit z-order | Overlapping green/red rectangles rendered the higher `z_index` layer despite reverse payload order. Pixel readback was `(0, 255, 0)`. | +| Selective cleanup | Deleting only the green rectangle exposed the remaining red rectangle: `(255, 0, 0)`. | +| Ownership guard | A different app name could not delete the remaining element; the red pixel remained. | +| Inline XPM2 icon | The fixed CI icon rendered; its expected white pixel read `(255, 255, 255)`. | +| Cleanup and BUSY state | Temporary probes used unique ownership, priority 80, five-second TTLs and scoped cleanup. Cleanup succeeded; BUSY remained `NOT_STARTED` before and after. | +| Discovery | The bar advertised one persistent USB-MAC-derived HTTP service with both USB and Wi-Fi IPv4 addresses. | +| Local route recovery | An unavailable primary address fell back to the explicit Wi-Fi route; `/api/transport` positively reported `wifi`. | +| Screen capture | Base64 BGR24 response decoded into a valid standard 72×16 BMP. | +| Asset subdirectory | `scripts/main.js` uploaded and exact file bytes matched an HTTP readback. | +| JavaScript runtime | USB-network TCP CLI ran the demo: three successful API-version polls, a 30-second stop message, and return to the shell prompt. | +| JavaScript persistence | The demo's own localStorage file retained `run_count: "1"`; read back through the CLI. | + +No BUSY timer was started, no audio was played, and no authentication, +brightness, charging, Wi-Fi or Home Assistant configuration was changed by +these checks. The example JS file and its counter are the only retained demo +assets; it has no autostart and is no longer running. + +## Corrections established during qualification + +- Firmware advertises `busybar-._http._tcp.local.` on port 80. + The earlier `_busybar._tcp` assessment was incorrect. +- The API version field is `api_semver`. +- Selective DELETE uses `/api/display/draw`, with ownership in the query; + the 1.2.3 body parser has an app-name shadowing bug. +- `/api/screen` claims `image/bmp` but actually sends base64 BGR24 pixels. +- The stock CLI is TCP port 23 over USB Ethernet, not a USB serial modem. +- The storage HTTP path buffer permits at most 63 characters. The demo's + localStorage filename is longer; direct HTTP read returned 400 even though + the file existed. CLI read positively confirmed its stored contents. + +## Review and remaining activation boundary + +Terra implemented and tested connectivity; Luna implemented diagnostics and +platform examples. Astra reviewed the combined code. Its concrete findings +were fixed: modern bitmap-only support without empty cloud fallback, honest +unavailable discovery reporting, and positive finite HA notification TTLs. +Primary review also corrected firmware wire contracts against source and live +responses and rejected a broker/transaction layer as unnecessary. + +The first complete GitHub review batch on PR #22 (head `db05fce`, inventory +cutoff 2026-09-08 21:56 UTC) contained two actionable P2 findings. Both were +fixed together: explicit diagnostic `--host` now forces that sole local target, +and discovery may append newly found addresses during the current operation +without exceeding four total attempts or replaying an uncertain write. Tests +and live reads verified both corrections. Formal reviews, all inline threads, +issue comments and applicable check annotations were inventoried before the +patch; CodeRabbit's skipped review was not counted as approval. + +The second complete GitHub batch (head `15acd5e`, inventory cutoff +2026-09-08 22:07 UTC) identified two further route-selection defects. The +primary recovery interval now starts at the actual primary attempt, so an +immediate one-shot operation uses the known-working fallback. A full four-route +configuration reserves its last attempt for an untried discovery candidate, +while preserving the fourth configured route when discovery has no candidate. +The preferred address uses the existing successful endpoint rather than a list +index. Astra reviewed this bounded design; no new retry service or state +machine was added. The four-attempt limit and uncertain-write stop remain. + +A logical Gemini final repository advisory returned **PROCEED**. The earlier +follow-up design call was unavailable because of nested host sandbox failure; +that failed call was not counted as approval or replayed. A fresh final review +used approved host execution with native sandboxing preserved. The runtime +returned no native model identity, so the advisory is recorded as such rather +than claimed as independently attested model lineage. + +This report qualifies the implementation and the bounded device probes. The +three long-running host integrations have not yet been switched to this branch. +Cloud and local-token behavior is covered by tests, not a new live credential +rotation. The HA example has not been loaded into a running HA instance. The +firmware JS runner remains experimental and this demo is not a replacement for +the host integrations. diff --git a/examples/home_assistant/README.md b/examples/home_assistant/README.md new file mode 100644 index 0000000..30e8864 --- /dev/null +++ b/examples/home_assistant/README.md @@ -0,0 +1,40 @@ +# Home Assistant example + +`busybar.yaml` uses Home Assistant's built-in REST sensor and `rest_command` +facilities. Replace `BUSY_BAR_IP` with your device address. Add the local device token to `secrets.yaml` as +`busybar_api_token`; the YAML sends it in the firmware's `X-API-Token` header. +The cloud relay uses a different `Authorization: Bearer` contract and is not +used here. + +The sensor preserves the firmware 1.2.3 BUSY snapshot shape: + +```json +{ + "snapshot": { + "type": "SIMPLE", + "card_id": "...", + "time_left_ms": 90000, + "is_paused": false, + "busy_bar_settings": {} + }, + "snapshot_timestamp_ms": 1700000000000 +} +``` + +This is the nested snapshot contract verified on firmware 1.2.3; nesting is +not claimed as a new 1.2.3 feature. Unknown responses should not be converted +to an idle timer by guessing alternative field paths. + +Malformed snapshots and snapshots without the expected fields become +`unknown` or unavailable. They are never rendered as `NOT_STARTED` or an +idle state. The commented automation is a light cue on a state change; it +does not start a timer. Notice TTLs are clamped to 1-60 seconds so a bad +automation value cannot create a permanent canvas element. The example does not claim that an official Home +Assistant core BUSY Bar integration exists. + +If local authentication is disabled, remove the `X-API-Token` header lines +instead of creating an unnecessary token. Merge the YAML into your existing +REST/REST-command configuration, or include it as an HA package; do not add +duplicate top-level keys. Validate the configuration in Home Assistant before +reloading it. References: [REST sensors](https://www.home-assistant.io/integrations/sensor.rest/) +and [REST commands](https://www.home-assistant.io/integrations/rest_command/). diff --git a/examples/home_assistant/busybar.yaml b/examples/home_assistant/busybar.yaml new file mode 100644 index 0000000..20f7f99 --- /dev/null +++ b/examples/home_assistant/busybar.yaml @@ -0,0 +1,93 @@ +# Ordinary Home Assistant REST YAML; this is not a custom integration. +# Store the local device token in secrets.yaml as busybar_api_token. + +rest: + - resource: "http://BUSY_BAR_IP/api/busy/snapshot" + method: GET + headers: + X-API-Token: !secret busybar_api_token + Accept: application/json + timeout: 5 + scan_interval: 10 + sensor: + - name: BUSY Bar Timer + unique_id: busybar_timer + # Firmware 1.2.3 returns {snapshot: {...}, snapshot_timestamp_ms}. + # Keep an unknown type unknown; it must not look like an idle timer. + value_template: >- + {% if value_json is mapping %} + {% set snapshot = value_json.get('snapshot', {}) %} + {{ snapshot.get('type', 'unknown') if snapshot is mapping else 'unknown' }} + {% else %} + unknown + {% endif %} + availability: >- + {{ value_json is mapping and value_json.get('snapshot') is mapping + and value_json['snapshot'].get('type') is not none }} + json_attributes_path: "$.snapshot" + json_attributes: + - type + - card_id + - time_left_ms + - is_paused + - busy_bar_settings + + - name: BUSY Bar Time Left + unique_id: busybar_time_left_ms + unit_of_measurement: ms + value_template: >- + {% if value_json is mapping %} + {% set snapshot = value_json.get('snapshot', {}) %} + {% if snapshot is mapping and snapshot.get('time_left_ms') is number %} + {{ snapshot['time_left_ms'] }} + {% else %} + unknown + {% endif %} + {% else %} + unknown + {% endif %} + availability: >- + {{ value_json is mapping and value_json.get('snapshot') is mapping + and value_json['snapshot'].get('time_left_ms') is number }} + +rest_command: + busybar_draw_expiring_notice: + url: "http://BUSY_BAR_IP/api/display/draw" + method: POST + headers: + X-API-Token: !secret busybar_api_token + Accept: application/json + content_type: application/json + timeout: 5 + # Priority 21 is the calm overlay tier: below calendar urgent (65) and a + # firmware BUSY/session (90), while still above the calendar ambient tier. + payload: >- + {% set ttl = timeout_seconds | default(20) | int %} + {% if ttl < 1 %}{% set ttl = 1 %}{% elif ttl > 60 %}{% set ttl = 60 %}{% endif %} + { + "application_name": "ha_busybar", + "priority": 21, + "elements": [{ + "id": "ha-expiring-notice", + "type": "text", + "x": 36, + "y": 8, + "align": "center", + "font": "small", + "text": {{ message | default('HA notice') | tojson }}, + "timeout": {{ ttl }} + }] + } + +# Optional cue for an existing HA state change. It draws a notice only; it +# does not start, stop, or alter a BUSY timer. +# automation: +# - alias: BUSY Bar cue when timer changes +# trigger: +# - platform: state +# entity_id: sensor.busybar_timer +# action: +# - service: rest_command.busybar_draw_expiring_notice +# data: +# message: "BUSY: {{ states('sensor.busybar_timer') }}" +# timeout_seconds: 20 diff --git a/examples/javascript/README.md b/examples/javascript/README.md new file mode 100644 index 0000000..e2ab1bb --- /dev/null +++ b/examples/javascript/README.md @@ -0,0 +1,56 @@ +# Finite firmware JavaScript example + +`main.js` is a short health check for firmware 1.2.3. It polls the +credential-free `GET /api/version` endpoint for 30 seconds, allows only one +request at a time, and increments one `localStorage` run counter at startup. +It does not draw, create a manifest, install an app, or configure persistent +autostart. The example uses `app.busy.integrations_demo`, an application ID +accepted by the firmware validator. + +The firmware's raw user script path is: + +```text +/ext/user_assets/app.busy.integrations_demo/scripts/main.js +``` + +From the repository root, upload through the existing client: + +```python +from pathlib import Path +from busybar.client import BusyBarClient + +client = BusyBarClient(host="10.0.4.20", transport="local") +assert client.upload_asset( + "app.busy.integrations_demo", "scripts/main.js", + Path("examples/javascript/main.js").read_bytes(), +) +``` + +Connect to the stock CLI over **USB Ethernet TCP port 23**, not a serial +modem node. The firmware's normal USB function is a network interface: + +```bash +nc 10.0.4.20 23 +``` + +At the `>: ` prompt, run the script (the file argument is the raw device path): + +```text +js -i app.busy.integrations_demo /ext/user_assets/app.busy.integrations_demo/scripts/main.js +``` + +There is no browser-style `AbortController` in the embedded runtime, and a +request that is already in progress cannot be hard-cancelled by this script. +The stopped flag prevents late responses from taking further action after the +30-second window. If an explicit operator stop is needed, the firmware CLI is: + +```text +js -k +``` + +`js -k` aborts **all** running JavaScript scripts on the device. Use it only +as a manual, explicit stop; this example never invokes it automatically. + +The CLI is a local development surface; this example uses the USB interface +and does not enable Wi-Fi CLI access. Closing the terminal is not a script +stop command. The script clears its own polling timer at 30 seconds. diff --git a/examples/javascript/main.js b/examples/javascript/main.js new file mode 100644 index 0000000..f07a99c --- /dev/null +++ b/examples/javascript/main.js @@ -0,0 +1,41 @@ +// Finite BUSY Bar firmware/API health example. +// This uses only the credential-free /api/version endpoint. +const APP_ID = "app.busy.integrations_demo"; +const VERSION_URL = "http://10.0.4.20/api/version"; +const POLL_MS = 10000; +const RUN_MS = 30000; + +let stopped = false; +let inFlight = false; +let pollNumber = 0; + +// localStorage is synchronous on the device. Increment once per run rather +// than once per poll so the flash-backed value does not churn every 10 sec. +const previousRuns = Number(localStorage.getItem("run_count") || "0"); +const runNumber = Number.isFinite(previousRuns) ? previousRuns + 1 : 1; +localStorage.setItem("run_count", String(runNumber)); + +async function pollVersion() { + if (stopped || inFlight) return; + inFlight = true; + try { + const response = await fetch(VERSION_URL); + const body = await response.json(); + if (!stopped) { + // BUSY's embedded Response does not provide browser .ok/.status. + console.log(APP_ID + " run=" + runNumber + " poll=" + (++pollNumber), body.api_semver); + } + } catch (error) { + if (!stopped) console.error(APP_ID + " version request failed", error); + } finally { + inFlight = false; + } +} + +pollVersion(); +const intervalId = setInterval(pollVersion, POLL_MS); +setTimeout(function stopDemo() { + stopped = true; + clearInterval(intervalId); + console.log(APP_ID + " stopped after " + RUN_MS + " ms"); +}, RUN_MS); diff --git a/integrations/calendar_countdown/main.py b/integrations/calendar_countdown/main.py index b95a457..6e068ca 100644 --- a/integrations/calendar_countdown/main.py +++ b/integrations/calendar_countdown/main.py @@ -14,6 +14,7 @@ from busybar.client import BusyBarClient, DrawResult from busybar.config import device_kwargs, load_config from busybar.display import PRIORITY_AMBIENT, ambient_timeout +from busybar.presentation import modern_display, prepare_frame, commit_frame from .logic import (ascii_safe, build_elements, select_active_event, select_next_event, _minutes_left, select_priority, @@ -28,47 +29,13 @@ def run_once(client, fetch, cfg: dict, now: datetime, dry_run: bool, state: dict | None = None) -> str: - """Run one poll cycle. `state`, when passed, is a caller-owned dict this - function uses to remember the last drawn element-id set (`last_shape`, - v1.6 -- see below) across calls (main() passes one shared dict across - loop iterations; tests calling run_once standalone can omit it), plus - (v1.5.2) the next known event's start time (`next_start`, for the T-0 - sleep-shortening in main()'s loop), the chirp edge-detection - bookkeeping (`seen_upcoming`/`chirped`, maintained by - should_chirp/commit_chirped), and `led_on` -- whether the LED is - believed to currently be lit, committed only after a confirmed - successful send (see resolve_led_value's docstring). This last one - matters on EVERY path that can draw or otherwise signal the device, - including the "no upcoming event" path below: an event that vanishes - without ever passing through `in_progress=True` (filtered out, or - shorter than one poll interval) must still resolve its LED to an - explicit off, not silently strand it lit. See calendar_countdown.logic - for the full escalation-ladder, LED, chirp, and start-takeover design. + """Render one complete expiring frame and remember confirmed display state. - The upcoming, in-progress, and (v1.6) start-takeover layouts each use a - different element id set (`time` vs `ends` vs the takeover's `bg`+ - `cal_start_anim` alone -- and the upcoming layout's own id set already - varies further with the escalation-icon sub-states, see - build_elements) and the device's draw endpoint upserts by id rather - than replacing an app's whole element set -- confirmed on-device that - switching id sets without an explicit clear leaves the previous set's - elements rendered on top of the new ones until their own timeout - expires (originally found with the v1.3 `time_card`+`time` vs `ends` - id sets; the same upsert-by-id model applies regardless of which ids - are in play). `state["last_shape"]` (v1.6 -- replaces the earlier - boolean `state["in_progress"]` transition check, which only caught the - upcoming<->in-progress edge and missed every other id-set change the - escalation icons and start-takeover introduce) is a frozenset of the - ids in the most recently DRAWN payload; comparing it against the ids - about to be drawn THIS poll lets run_once clear only when the id set - actually changed, not on every poll -- mirrors ci_status's own unified - shape tracker (see ci_status.main.run_once's docstring). Priority - changes (v1.5.2's escalation ladder) do NOT need this same - clear-on-change treatment: they're the same app_name upserting the - same element ids at a new priority number, not a shape change -- see - busybar.display's PRIORITY_AMBIENT_URGENT docstring for why a - strictly-higher same-app_name draw always succeeds regardless of - priority. + Firmware 1.2.3 can remove obsolete IDs while preserving common content at + the same priority. Older firmware, type changes, and priority reductions + use an app-scoped clear. Full scheduled redraws still renew timeouts and + reclaim a canvas evicted by another app. Audio is attempted once per event + edge, because a missing response does not prove it failed to play. """ c = cfg["calendar_countdown"] timeout_s = ambient_timeout(c["poll_seconds"]) @@ -77,8 +44,9 @@ def run_once(client, fetch, cfg: dict, now: datetime, dry_run: bool, if c["auto_busy"] and not dry_run and active is not None: remaining_ms = int((active.end - now).total_seconds() * 1000) - busy = client.get_busy() or {} - if busy.get("type") in (None, "NOT_STARTED"): + busy = client.get_busy() + snapshot = busy.get("snapshot", {}) if isinstance(busy, dict) else {} + if isinstance(snapshot, dict) and snapshot.get("type") == "NOT_STARTED": client.set_busy_simple(remaining_ms) # An in-progress event takes display priority over a later upcoming one. @@ -123,33 +91,15 @@ def run_once(client, fetch, cfg: dict, now: datetime, dry_run: bool, if dry_run: return f"DRY-RUN would draw: {label} (in_progress={in_progress})" - # Event-start chirp (v1.5.2): fires on the upcoming -> in_progress - # transition edge only -- see should_chirp's docstring for the full - # restart-safety and once-per-event reasoning. Placed after the - # dry_run return so a dry run never plays real audio or touches the - # chirp bookkeeping. - # - # Observability (v1.5.2.1): log EVERY attempt at INFO, success or - # failure -- not just failures (client.play_audio already logs those - # at WARNING). This is what the silent-.wav bug needed and didn't - # have: a 200/True response from play_audio does NOT prove audible - # playback (see play_audio's docstring for the full deferred-open/ - # swallowed-failure explanation), so a log line is the only trace - # this process can leave for later correlation against an operator's - # own ear-test -- a "successful" chirp that nobody heard was - # previously doubly silent (no sound AND no log line), which is why - # the root cause took device storage forensics to find instead of a - # five-second log check. if state is not None: if should_chirp(event, in_progress, now, state, c["chirp"]): + # Record the attempt before sending. A timeout can follow playback; + # repeating it on the next calendar poll would duplicate the sound. + commit_chirped(event, state) played = client.play_audio(APP, stock_path=CHIRP_STOCK_PATH) log.info("chirp played (%s) -> %s", CHIRP_STOCK_PATH, played) - if played: - commit_chirped(event, state) - # else: leaving "chirped" uncommitted means the next poll - # (still in_progress, same event) retries rather than - # silently skipping the chirp forever. + modern = modern_display(client) # v1.6 start-takeover: True for the first start_window_seconds after an # event begins (see is_just_started's docstring) -- holds the display # at PRIORITY_AMBIENT_URGENT and swaps in the full-panel takeover @@ -164,80 +114,25 @@ def run_once(client, fetch, cfg: dict, now: datetime, dry_run: bool, led_was_on = state.get("led_on", False) if state is not None else False led = resolve_led_value(led_should_be_on, led_was_on) - # Unified shape-tracker clear (v1.6, replaces the old boolean - # state["in_progress"]-transition check -- see this function's - # docstring for why that check alone can no longer catch every id-set - # change once escalation icons and the start-takeover are in play). - # `new_shape` must be computed from THIS poll's elements before the - # draw call below. - new_shape = frozenset(e["id"] for e in elements) - if state is not None: - last_shape = state.get("last_shape") - if last_shape is not None and last_shape != new_shape: - # clear()'s own success/failure is intentionally not checked here -- - # only draw()'s result (below) gates whether `state` commits. If - # clear() silently fails but draw() then succeeds, the new element - # set is still correctly installed via the id-upsert; any leftover - # stale ids from before the failed clear are bounded by their own - # original timeout, a one-off gap that self-heals, not a reason to - # re-clear on every subsequent poll. Gating on clear() too would mean - # a persistently-failing clear() retries forever even once draw() - # keeps succeeding, since `state` would never converge. - client.clear(APP) + elements = prepare_frame(client, APP, elements, priority, state, modern=modern) result = client.draw(APP, elements=elements, priority=priority, led_notification_color=led) - # v1.6.1 start-takeover graceful degradation: the just_started takeover - # is a FULL-PANEL swap -- its only two elements are `bg` + the stock - # animation named by start_animation (see build_elements). If that name - # doesn't match a stock animation on the device (an operator typo; the - # default meeting_72x16 is valid), the live device rejects the draw with - # DrawResult.ERROR every poll, `state` never commits, and run_once - # re-clears + re-fails each poll -- leaving the panel DARK for the whole - # start_window_seconds (~60s) instead of showing anything. Nothing else - # is on screen to mask it, because the takeover IS the whole screen. - # - # Fall back to the normal in-progress ("ENDS") layout for THIS poll so a - # mistyped start_animation degrades to a live countdown rather than a - # blank panel. Only ERROR triggers this, deliberately: - # - REJECTED means a strictly-higher-priority app already owns the - # screen; the in-progress layout draws at a LOWER priority - # (PRIORITY_AMBIENT vs the takeover's PRIORITY_AMBIENT_URGENT) and - # would be rejected too -- a pointless second draw. - # - UNREACHABLE means the device is down; the loop's backoff (main()) - # handles that, and a retry would just be unreachable again. - # Per-poll, not latched: if the operator fixes the config value or the - # stock animation later appears, the very next poll's takeover draw - # succeeds with no restart -- the same retry-not-assume discipline the - # led_on/chirp commits follow (commit only on confirmed success). No - # extra clear() is needed before the fallback draw: the failed takeover - # draw landed nothing, and the shape-tracker clear above already fired - # for any real id-set change (last_shape is never the takeover shape, - # since a takeover ERROR never commits it), so the device is already - # blank whenever it mattered -- see this function's docstring on the - # upsert-by-id model and why only draw() gates the state commit. + # A missing stock animation may reject the takeover. Fall back to the + # ordinary countdown on ERROR; a busy owner or unreachable device does not + # benefit from another lower-priority request. if just_started and result == DrawResult.ERROR: log.warning("start-takeover animation %r not drawable; falling back to " "in-progress layout for this poll", c["start_animation"]) elements = build_elements(event, now, c, timeout_s, in_progress, just_started=False) priority = select_priority(minutes_left, c["approach_minutes"], c["notice_minutes"], in_progress, just_started=False) - new_shape = frozenset(e["id"] for e in elements) + elements = prepare_frame(client, APP, elements, priority, state, modern=modern) result = client.draw(APP, elements=elements, priority=priority, led_notification_color=led) label = f"{label} [start-anim fallback]" + commit_frame(state, elements, priority, result) if state is not None and result == DrawResult.DRAWN: - # Only commit the transition once it actually lands on the device. - # If draw() failed (UNREACHABLE/REJECTED/ERROR), leave `state` - # unchanged so the next poll still sees the same mismatch and - # retries the clear+draw pair, rather than assuming a transition - # happened that never actually reached the device -- which would - # otherwise let stale elements from the old layout persist - # unbounded (no further poll would ever re-attempt the clear). - state["last_shape"] = new_shape - # Same discipline for the LED: only believe it's in the intended - # state once this exact draw (carrying that exact led value) is - # confirmed to have landed. state["led_on"] = led_should_be_on return f"drew {label} -> {result.value}" @@ -292,7 +187,8 @@ def main() -> int: # clear plus the transition-state clear above are sufficient, since a # deploy always restarts the process (fresh client.clear(APP) here) and # build_elements() simply never emits those ids again afterward. - client.clear(APP) + if not args.dry_run: + client.clear(APP) fetch = lambda hours: eventkit.fetch_events(hours, cfg["calendar_countdown"]["calendars"]) backoff = 5 diff --git a/integrations/ci_status/main.py b/integrations/ci_status/main.py index 5a7f909..e918b7e 100644 --- a/integrations/ci_status/main.py +++ b/integrations/ci_status/main.py @@ -14,6 +14,7 @@ from busybar.client import BusyBarClient, DrawResult from busybar.config import device_kwargs, load_config from busybar.display import OVERLAY_DWELL_SECONDS, PRIORITY_OVERLAY, overlay_gap_elapsed +from busybar.presentation import modern_display, prepare_frame, commit_frame, ci_bitmap_accent from .logic import ( RepoState, RunningInfo, QuotaInfo, @@ -125,29 +126,12 @@ def run_once(client, poller, cfg: dict, now: datetime, failed draw must not be mistaken for a completed dwell, or the rotation would silently skip frames / wait a dwell for nothing. - `overlay_state["last_shape"]` is a *unified* shape tracker, not - overlay-specific despite living in this dict: it records the element-id - set of whatever payload was last actually drawn to `APP`, across every - tier that can draw here -- an alert badge, the quiet-green text, or - either overlay frame kind -- and every draw path below checks it before - drawing and commits to it after DRAWN. The firmware upserts by element - id within an `application_name`, and each of these payload shapes has a - different id set (`{bg, ci}` for an alert, `{ci}` alone for quiet - green, `{bg, title, track, track_fill, eta}` for the running badge, - `{bg, title, track, track_fill, pct, reset}` for a quota frame) -- - switching shapes without a clear() first leaves the previous shape's - now-orphaned ids rendered until their own timeout elapses (up to 1.5x - `poll_seconds` for an alert/green draw), the same upsert-by-id bug - class the v1.3.1 calendar transition-clear fix addressed, recurring at - every seam a different payload shape can follow another -- not just - between the two overlay-frame shapes. Critically, resetting the - rotation bookkeeping (`frame_index`/`last_dwell_end`, e.g. when an - alert preempts the overlay or a run ends) must NOT also reset - `last_shape`: that field describes what is physically on the device - right now, which a bookkeeping reset does not change, and clearing it - prematurely was the root cause of a real bug where the clear-gate saw - "no shape on record" and wrongly concluded no clear was needed on the - next transition. + The shared presentation helper remembers the last accepted shape, types + and priority. Modern local firmware removes obsolete IDs at same-priority + transitions; legacy or incompatible transitions clear this app's canvas. + Rotation bookkeeping resets must retain that display state until the + next successful draw or clear. Complete payloads still renew all TTLs and + recover evicted content. CI bitmap icons are optional cosmetic additions. Failure/stuck/green rotation (v1.6, Request B): the overlay tier's rotation is no longer gated on a run being active -- `build_overlay_sequence` @@ -243,7 +227,7 @@ class the v1.3.1 calendar transition-clear fix addressed, recurring at led_notification_color=LED_OFF_COLOR) if result == DrawResult.DRAWN and overlay_state is not None: overlay_state["led_was_on"] = False - overlay_state["last_shape"] = frozenset(e["id"] for e in LED_OFF_ELEMENTS) + commit_frame(overlay_state, LED_OFF_ELEMENTS, PRIORITY_OVERLAY, result) return f"led off; {result.value}" client.clear(APP) if overlay_state is not None: @@ -265,19 +249,18 @@ class the v1.3.1 calendar transition-clear fix addressed, recurring at if dry_run: return f"DRY-RUN payload: {payload!r} led={led_value}" - # Unified shape-clear gate (see the original docstring): the firmware upserts - # by element id within an application_name, so a shape change needs a clear - # first. Spans every frame kind that can draw here. - shape = frozenset(e["id"] for e in payload["elements"]) - if overlay_state is not None: - last_shape = overlay_state.get("last_shape") - if last_shape is not None and last_shape != shape: - client.clear(APP) + modern = modern_display(client) + elements = payload["elements"] + if modern and c.get("bitmap_icons", True): + elements = ci_bitmap_accent(elements, sequence[frame_index]["kind"], + OVERLAY_DWELL_SECONDS) + payload["elements"] = prepare_frame(client, APP, elements, payload["priority"], + overlay_state, modern=modern) result = client.draw(APP, payload["elements"], priority=payload["priority"], led_notification_color=led_value) if result == DrawResult.DRAWN and overlay_state is not None: - overlay_state["last_shape"] = shape + commit_frame(overlay_state, payload["elements"], payload["priority"], result) overlay_state["led_was_on"] = led_should_be_on overlay_state["frame_index"] = frame_index + 1 overlay_state["last_dwell_end"] = now + timedelta(seconds=OVERLAY_DWELL_SECONDS) @@ -336,7 +319,8 @@ def main() -> int: log.error(str(exc)) return 1 client = BusyBarClient(**device_kwargs(cfg)) - client.clear(APP) # drop any stale elements from a previous process (type collisions 400) + if not args.dry_run: + client.clear(APP) # drop stale elements from a previous process state_cache: dict[str, RepoState] = {} running_cache: dict[str, list[dict]] = {} diff --git a/pyproject.toml b/pyproject.toml index a300969..b26f598 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,9 @@ dependencies = [ "pyobjc-framework-EventKit>=10.3; sys_platform == 'darwin'", ] +[project.optional-dependencies] +discovery = ["zeroconf>=0.147"] + [dependency-groups] dev = ["pytest>=8.3", "pillow>=10.0"] diff --git a/src/busybar/__main__.py b/src/busybar/__main__.py new file mode 100644 index 0000000..1cb9c16 --- /dev/null +++ b/src/busybar/__main__.py @@ -0,0 +1,78 @@ +"""Command-line entry points for bounded, read-only BUSY Bar operations.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +from busybar.client import BusyBarClient +from busybar.config import device_kwargs, load_config +from busybar.diagnostics import diagnose, save_screen + + +def _client(config_path: str | None, host: str | None) -> BusyBarClient: + config = load_config(Path(config_path) if config_path else None) + kwargs = device_kwargs(config) + if host: + # An explicit diagnostic target must not silently select another bar + # or inherit a forced-cloud mode from the integration configuration. + kwargs.update(host=host, transport="local", fallback_hosts=[], discover=False) + return BusyBarClient(**kwargs) + + +def _safe_device(value: Any) -> dict[str, Any]: + allowed = ("name", "host", "hosts", "port", "device_id", "model", "service") + if isinstance(value, dict): + source = value + else: + source = {key: getattr(value, key) for key in allowed if hasattr(value, key)} + return {key: source[key] for key in allowed if key in source} + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="python -m busybar") + subparsers = parser.add_subparsers(dest="command", required=True) + diagnostic = subparsers.add_parser("diagnose", help="read safe device diagnostics") + diagnostic.add_argument("--host") + diagnostic.add_argument("--config", help="path to a BUSY Bar config.toml") + diagnostic.add_argument("--screen", help="save the local display-0 BMP when supported") + discovery = subparsers.add_parser("discover", help="discover BUSY Bars on the local network") + discovery.add_argument("--timeout", type=float, default=3.0) + return parser + + +def _run_discover(timeout: float) -> int: + try: + from busybar.discovery import discover_devices + devices = discover_devices(timeout) + except Exception: + # The discovery layer owns the bounded scan and should propagate a + # scan/dependency failure. Do not add a second network health probe. + devices = None + if devices is None: + print(json.dumps({"devices": [], "available": False}, indent=2, sort_keys=True)) + return 2 + print(json.dumps({"devices": [_safe_device(item) for item in devices], "available": True}, + indent=2, sort_keys=True)) + return 0 + + +def main(argv: list[str] | None = None) -> int: + args = _parser().parse_args(argv) + if args.command == "discover": + return _run_discover(args.timeout) + + client = _client(args.config, args.host) + report = diagnose(client) + if args.screen: + saved = save_screen(client, args.screen) + report["features"]["screen"] = {"available": saved, "path": args.screen if saved else None} + report["complete"] = report["complete"] and saved + print(json.dumps(report, indent=2, sort_keys=True)) + return 0 if report["complete"] else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/busybar/client.py b/src/busybar/client.py index df536c1..f5bf3ed 100644 --- a/src/busybar/client.py +++ b/src/busybar/client.py @@ -1,283 +1,381 @@ +"""Bounded HTTP client for a BUSY Bar device. + +The local route is preferred. Cloud relay support remains optional, and local +credentials are deliberately never sent to that relay. Discovery is a +trusted-LAN convenience only; the configured service identifier prevents us +from selecting an arbitrary discovered bar, but does not authenticate a LAN +advertisement. +""" + +from __future__ import annotations + import logging import time from enum import Enum +from pathlib import PurePosixPath +from typing import Any import requests log = logging.getLogger(__name__) NULL_CARD_ID = "00000000-0000-0000-0000-000000000000" - -# v1.6 cloud transport fallback -- while degraded to cloud, `_request` skips -# the (known-failing) local attempt and goes straight to cloud until this -# many seconds have elapsed since the last local failure, then tries local -# first again as a recovery probe. Doing this inline (no background thread) -# is cheap because a down local device fails fast (connection refused/ -# timeout well under `timeout`), so the occasional probe costs little. LOCAL_RETRY_SECONDS = 60 +MAX_FALLBACK_HOSTS = 3 class DrawResult(Enum): DRAWN = "drawn" - REJECTED = "rejected" # 409: higher-priority app on screen — expected - UNREACHABLE = "unreachable" # local AND cloud (if configured) both failed — caller backs off - ERROR = "error" # non-200/409 from a live device — no backoff; retried next poll + REJECTED = "rejected" + UNREACHABLE = "unreachable" + ERROR = "error" + + +def _version_at_least(value: object, minimum: tuple[int, int, int]) -> bool: + """Accept ordinary API version strings without adding a version package.""" + if not isinstance(value, str): + return False + try: + parts = tuple(int(part) for part in value.split(".")[:3]) + except ValueError: + return False + return len(parts) == 3 and parts >= minimum class BusyBarClient: - """Talks to a single BUSY Bar device, over the LAN (local transport, - the default and preferred path) and, when configured, via BUSY's cloud - relay as an automatic fallback when the local device is unreachable. - - Transport selection (`transport`, mirroring busylib-py's single-class - transport-flag pattern rather than a class hierarchy -- see - scratchpad/busy-cloud-api-research.md for the source citations): - - - `"auto"` (default): every call tries local first with `timeout`. On - a `requests.RequestException` AND a non-empty `cloud_token`, the - SAME request is retried against `cloud_base_url` with - `cloud_timeout` and an `Authorization: Bearer` header. ` - active_transport` tracks which transport last succeeded. While - degraded (`active_transport == "cloud"`), subsequent calls skip the - local attempt and go straight to cloud until `LOCAL_RETRY_SECONDS` - have elapsed since the last local failure, at which point local is - retried first again as a recovery probe (see module docstring - constant above). `cloud_token = ""` (the shipped default) disables - cloud fallback entirely regardless of `transport="auto"` -- calls - behave exactly as they did before v1.6. - - `"local"`: local only, never falls back. Pre-v1.6 behavior exactly. - - `"cloud"`: cloud only, forced -- never attempts local. For - deliberately testing/debugging the cloud path. - - Local endpoints are mounted at `/api/...`; the cloud API mirrors them - 1:1 under `/busybar/...` relative to the cloud host. `cloud_base_url`'s - documented default (`https://api.busy.app/busybar`) already carries - that `/busybar` segment, so cloud requests are built by stripping the - local `/api` prefix and appending the remainder to `cloud_base_url`. - - SECURITY: `cloud_token` is never logged or included in any log - statement, at any level including DEBUG -- only transport - *transitions* (local->cloud degradation, cloud->local recovery) are - logged, at INFO, and those log lines never include header values. + """Synchronously talk to one configured BUSY Bar. + + ``fallback_hosts`` is intentionally a small, explicit list. Optional mDNS + discovery runs only when ``discover`` and an exact ``device_id`` are + supplied. Both paths are finite and have no background listener or retry + worker. """ - def __init__(self, host: str = "10.0.4.20", timeout: tuple = (3, 5), *, - cloud_token: str = "", cloud_base_url: str = "https://api.busy.app/busybar", - transport: str = "auto", cloud_timeout: tuple = (5, 15)): + def __init__( + self, + host: str = "10.0.4.20", + timeout: tuple = (3, 5), + *, + cloud_token: str = "", + cloud_base_url: str = "https://api.busy.app/busybar", + transport: str = "auto", + cloud_timeout: tuple = (5, 15), + local_token: str = "", + fallback_hosts: list[str] | tuple[str, ...] = (), + discover: bool = False, + device_id: str = "", + ): if transport not in ("auto", "local", "cloud"): raise ValueError(f"transport must be 'auto', 'local', or 'cloud', got {transport!r}") - self.base = f"http://{host}" + if len(fallback_hosts) > MAX_FALLBACK_HOSTS: + raise ValueError(f"fallback_hosts supports at most {MAX_FALLBACK_HOSTS} hosts") + if discover and not device_id: + raise ValueError("discover requires an exact device_id") + self.timeout = timeout self.cloud_token = cloud_token + self.local_token = local_token self.cloud_base = cloud_base_url.rstrip("/") self.cloud_timeout = cloud_timeout self.transport = transport - # Cloud fallback is only "configured" with a non-empty token; an - # empty string (the shipped default) disables it in "auto" mode - # regardless of anything else. Forced transport="cloud" is exempt - # from this gate deliberately -- it's the caller's explicit, - # non-"auto" choice, not a fallback decision this client makes. + self.device_id = device_id.lower() + self.discover = discover self._cloud_configured = bool(cloud_token) self.active_transport = "cloud" if transport == "cloud" else "local" - self._degraded_since: float | None = None # time.monotonic() of the last local - # failure while in "auto" mode; None - # whenever active_transport == "local" + self._degraded_since: float | None = None + self._last_primary_probe = 0.0 + self._last_discovery: float | None = None + self._capabilities_checked_at: float | None = None + self.supports_display_v2 = False + + routes: list[str] = [] + for candidate in (host, *fallback_hosts): + if candidate and candidate not in routes: + routes.append(candidate) + self._static_hosts = routes + self._discovered_hosts: list[str] = [] + self.base = self._base_for(self._static_hosts[0]) + if self.discover: + self._refresh_discovery() + + @staticmethod + def _base_for(host: str) -> str: + return host if host.startswith(("http://", "https://")) else f"http://{host}" + + def _all_local_hosts(self) -> list[str]: + return self._static_hosts + [h for h in self._discovered_hosts if h not in self._static_hosts] def _mark_degraded(self) -> None: if self.active_transport != "cloud": - log.info("busybar transport: local -> cloud (local device unreachable; falling back)") + log.info("busybar transport: local -> cloud (local route unavailable)") self.active_transport = "cloud" self._degraded_since = time.monotonic() def _mark_recovered(self) -> None: if self.active_transport != "local": - log.info("busybar transport: cloud -> local (local device reachable again)") + log.info("busybar transport: cloud -> local (local route recovered)") self.active_transport = "local" self._degraded_since = None def _should_probe_local(self) -> bool: - return (self._degraded_since is not None - and (time.monotonic() - self._degraded_since) >= LOCAL_RETRY_SECONDS) + return self._degraded_since is not None and time.monotonic() - self._degraded_since >= LOCAL_RETRY_SECONDS def _cloud_path(self, path: str) -> str: return path[len("/api"):] if path.startswith("/api") else path - def _try_local(self, method: str, path: str, **kwargs) -> requests.Response | None: + def _refresh_discovery(self) -> None: + now = time.monotonic() + if not self.discover or (self._last_discovery is not None and now - self._last_discovery < LOCAL_RETRY_SECONDS): + return + self._last_discovery = now try: - return requests.request(method, f"{self.base}{path}", timeout=self.timeout, **kwargs) - except requests.RequestException as exc: - log.debug("device unreachable: %s", exc) - return None - - def _try_cloud(self, method: str, path: str, **kwargs) -> requests.Response | None: - headers = {**(kwargs.pop("headers", None) or {}), "Authorization": f"Bearer {self.cloud_token}"} + from busybar.discovery import discover_devices + + discovered: list[str] = [] + for record in discover_devices(device_id=self.device_id): + for host in record.hosts: + endpoint = f"{host}:{record.port}" if record.port != 80 else host + if endpoint not in discovered: + discovered.append(endpoint) + if len(discovered) == 4: + break + if len(discovered) == 4: + break + self._discovered_hosts = discovered + except Exception: + # Optional discovery must never prevent configured routes. + log.warning("busybar discovery unavailable; using configured local routes") + + def _local_order(self) -> list[str]: + hosts = self._all_local_hosts() + if not hosts: + return [] + now = time.monotonic() + preferred = next((h for h in hosts if self._base_for(h) == self.base), hosts[0]) + ordered = [preferred, *[h for h in hosts if h != preferred]] + if now - self._last_primary_probe >= LOCAL_RETRY_SECONDS: + ordered = [hosts[0], *[h for h in ordered if h != hosts[0]]] + return ordered[:4] + + def _try_local( + self, method: str, path: str, *, replay_safe: bool, **kwargs: Any + ) -> tuple[requests.Response | None, BaseException | None]: + routes = self._local_order() + if not routes: + return None, None + headers = dict(kwargs.pop("headers", None) or {}) + if self.local_token: + headers["X-API-Token"] = self.local_token + refreshed = False + for attempt, route in enumerate(routes): + if route == self._static_hosts[0]: + self._last_primary_probe = time.monotonic() + try: + response = requests.request( + method, f"{self._base_for(route)}{path}", timeout=self.timeout, + headers=headers or None, allow_redirects=False, **kwargs, + ) + except requests.ConnectTimeout as exc: + failure: BaseException = exc + except requests.RequestException as exc: + log.debug("local route unavailable (%s)", type(exc).__name__) + if not replay_safe: + return None, exc + failure = exc + else: + self.base = self._base_for(route) + return response, None + if not replay_safe and not isinstance(failure, requests.ConnectTimeout): + return None, failure + if not refreshed and (attempt == len(routes) - 1 or attempt == 2): + # Reserve the last attempt for discovery when configured routes + # fill the budget. Keep the original remaining routes if no + # discovery candidate is available; never retry an attempted + # address. Uncertain writes return above, before discovery. + self._refresh_discovery() + refreshed = True + remaining: list[str] = [] + for candidate in [*self._discovered_hosts, *routes[attempt + 1:]]: + if candidate not in routes[:attempt + 1] and candidate not in remaining: + remaining.append(candidate) + routes[attempt + 1:] = remaining[:3 - attempt] + return None, failure + + def _try_cloud(self, method: str, path: str, **kwargs: Any) -> requests.Response | None: + headers = dict(kwargs.pop("headers", None) or {}) + headers["Authorization"] = f"Bearer {self.cloud_token}" try: - return requests.request(method, f"{self.cloud_base}{self._cloud_path(path)}", - timeout=self.cloud_timeout, headers=headers, **kwargs) + return requests.request( + method, f"{self.cloud_base}{self._cloud_path(path)}", timeout=self.cloud_timeout, + headers=headers, allow_redirects=False, **kwargs, + ) except requests.RequestException as exc: - log.debug("cloud unreachable: %s", exc) + log.debug("cloud route unavailable (%s)", type(exc).__name__) return None - def _request(self, method: str, path: str, **kwargs) -> requests.Response | None: - if self.transport == "local": - return self._try_local(method, path, **kwargs) - + def _request( + self, method: str, path: str, *, replay_safe: bool = True, + local_only: bool = False, cloud_kwargs: dict[str, Any] | None = None, **kwargs: Any, + ) -> requests.Response | None: if self.transport == "cloud": - return self._try_cloud(method, path, **kwargs) + return None if local_only else self._try_cloud(method, path, **(cloud_kwargs or kwargs)) + if self.transport == "local" or local_only: + return self._try_local(method, path, replay_safe=replay_safe, **kwargs)[0] - # transport == "auto": local-first-with-cloud-fallback, with the - # LOCAL_RETRY_SECONDS recovery probe described in the class - # docstring. if self.active_transport == "local" or self._should_probe_local(): - resp = self._try_local(method, path, **kwargs) - if resp is not None: + response, failure = self._try_local(method, path, replay_safe=replay_safe, **kwargs) + if response is not None: self._mark_recovered() - return resp - if not self._cloud_configured: + return response + if not self._cloud_configured or (not replay_safe and not isinstance(failure, requests.ConnectTimeout)): return None self._mark_degraded() - - return self._try_cloud(method, path, **kwargs) + return self._try_cloud(method, path, **(cloud_kwargs or kwargs)) + + def _display_payload(self, elements: list[dict], modern: bool) -> list[dict]: + payload: list[dict] = [] + for index, raw in enumerate(elements): + item = dict(raw) + if not modern and item.get("type") == "xpmbitmap": + continue + if modern: + item.setdefault("z_index", index) + else: + item.pop("z_index", None) + item.pop("xpmbitmap", None) + payload.append(item) + return payload def draw(self, application_name: str, elements: list[dict], priority: int = 50, led_notification_color: str | None = None) -> DrawResult: - body: dict = {"application_name": application_name, "priority": priority, - "elements": elements} - if led_notification_color is not None: - body["led_notification_color"] = led_notification_color - resp = self._request("POST", "/api/display/draw", json=body) - if resp is None: + local_modern = self.transport != "cloud" and self.active_transport == "local" and self.supports_display_v2 + safe_elements = self._display_payload(elements, modern=False) + modern_elements = self._display_payload(elements, modern=True) + bitmap_only = bool(elements) and not safe_elements + if bitmap_only and not local_modern: + return DrawResult.ERROR + + def body_for(items: list[dict]) -> dict[str, Any]: + body: dict[str, Any] = {"application_name": application_name, "priority": priority, "elements": items} + if led_notification_color is not None: + body["led_notification_color"] = led_notification_color + return body + + response = self._request( + "POST", "/api/display/draw", json=body_for(modern_elements if local_modern else safe_elements), + cloud_kwargs={"json": body_for(safe_elements)}, local_only=bitmap_only, + ) + if response is None: return DrawResult.UNREACHABLE - if resp.status_code == 409: + if response.status_code == 409: return DrawResult.REJECTED - if resp.status_code == 200: + if response.status_code == 200: return DrawResult.DRAWN - log.warning("draw failed: HTTP %s %s", resp.status_code, resp.text[:200]) + log.warning("draw failed: HTTP %s", response.status_code) return DrawResult.ERROR - def play_audio(self, application_name: str, stock_path: str | None = None, - path: str | None = None) -> bool: - """POST /api/audio/play (v1.5.2, added for calendar_countdown's - event-start chirp). Exactly one of `stock_path` (a firmware-shipped - sound, e.g. "shared/calendar_event_starts.snd" -- pattern - `shared/[a-z0-9_.]+$`, no further subdirectories) or `path` (a file - previously uploaded into this app's own assets directory) must be - given, matching the device's own PlayAudio schema. Never touches - `/api/audio/volume` -- this method has no volume parameter at all, - deliberately, so a caller can't accidentally change the operator's - own volume setting; playback always uses whatever volume is - currently configured on the device. - - **Stock sound filenames are `.snd` at runtime, not `.wav`, even - though the source assets in the firmware repo are `.wav` files.** - The build pipeline converts `.wav` sources to `.snd` at packaging - time; the source tree and the OpenAPI spec never reveal this -- - the only way to find the real runtime filename is a live `GET - /api/storage/list` of the target directory (e.g. - `/ext/apps_assets/shared/sounds`) against the actual device. - Always verify a stock filename against that listing before - shipping it in a `stock_path`, not against the source repo or the - API docs. - - **A `True` return does NOT prove audible playback.** This - endpoint returns `200` BEFORE the actual file open -- playback is - queued behind a short amp holdoff (~100ms), and an open failure - at holdoff-fire (e.g. because the filename is wrong) is logged - device-side only and otherwise swallowed; nothing comes back over - this HTTP response either way. A wrong filename (confirmed with - the original, incorrect `.wav` stock_path used here before this - was diagnosed) is therefore indistinguishable from a correct one - at every layer this codebase can observe -- the request succeeds, - the response is `200`, and `play_audio` returns `True`, with no - actual sound. The only way to confirm real audibility is a human - listening on the actual hardware; log every attempt's outcome - (both `True` and `False`) at the call site so a silent-but- - "successful" chirp is at least visible in the log for later - correlation against an operator report, rather than doubly silent - (no sound AND no log line) the way the original bug was. - - Returns True on a confirmed 200, False on anything else (network - unreachable, 400 invalid path, 404 file not found, or any other - non-200) -- best-effort, non-fatal by design: a caller should log - the outcome but never let an audio failure block or crash the - display loop (the same "audio failure may occur after display - content is visible" tolerance the device's own client libraries - document for this endpoint). + def play_audio(self, application_name: str, stock_path: str | None = None, path: str | None = None) -> bool: + """Queue one sound without changing the device's volume setting. + + A 200 acknowledges the queued request, not audible playback. In + particular, firmware stock paths are runtime ``.snd`` assets rather + than their source-tree ``.wav`` names. A read or connection failure is + treated as uncertain and is never replayed to another route. """ - body: dict = {"application_name": application_name} - if stock_path is not None: - body["stock_path"] = stock_path - elif path is not None: - body["path"] = path - else: + if (stock_path is None) == (path is None): raise ValueError("play_audio requires exactly one of stock_path or path") - resp = self._request("POST", "/api/audio/play", json=body) - if resp is None: - log.debug("play_audio: device unreachable") - return False - if resp.status_code == 200: - return True - log.warning("play_audio failed: HTTP %s %s", resp.status_code, resp.text[:200]) - return False + body: dict[str, str] = {"application_name": application_name} + body["stock_path" if stock_path is not None else "path"] = stock_path if stock_path is not None else path # type: ignore[assignment] + response = self._request("POST", "/api/audio/play", json=body, replay_safe=False) + return response is not None and response.status_code == 200 def clear(self, application_name: str) -> bool: - resp = self._request("DELETE", "/api/display/draw", - params={"application_name": application_name}) - return resp is not None and resp.status_code == 200 + if not application_name: + raise ValueError("clear requires an application_name") + response = self._request("DELETE", "/api/display/draw", params={"application_name": application_name}) + return response is not None and response.status_code == 200 + + def remove_elements(self, application_name: str, ids: list[str]) -> bool: + """Delete named elements without sending the firmware-buggy body owner.""" + if not application_name: + raise ValueError("remove_elements requires an application_name") + if not ids: + return True + if self.transport == "cloud" or self.active_transport == "cloud" or not self.supports_display_v2: + return False + response = self._request( + "DELETE", "/api/display/draw", local_only=True, + params={"application_name": application_name}, json={"element_ids": ids}, + ) + return response is not None and response.status_code == 200 def upload_asset(self, application_name: str, filename: str, data: bytes) -> bool: - """Upload a raw asset (e.g. a compiled .anim) to the device's app asset - store. Local-only: assets live on the physical device, so this never - uses the cloud transport. Returns True on HTTP 200.""" - resp = self._try_local( - "POST", - f"/api/assets/upload?application_name={application_name}&file={filename}", - data=data, headers={"Content-Type": "application/octet-stream"}) - if resp is None: - log.warning("asset upload unreachable: %s/%s", application_name, filename) + path = PurePosixPath(filename) + if path.is_absolute() or ".." in path.parts or filename in ("", "."): + return False + response = self._request( + "POST", "/api/assets/upload", local_only=True, + params={"application_name": application_name, "file": filename}, data=data, + headers={"Content-Type": "application/octet-stream"}, + ) + return response is not None and response.status_code == 200 + + @staticmethod + def _json_dict(response: requests.Response | None) -> dict | None: + if response is None or response.status_code != 200: + return None + try: + value = response.json() + except ValueError: + return None + return value if isinstance(value, dict) else None + + def get_json(self, path: str, *, local_only: bool = False) -> dict | None: + if path not in { + "/api/status", "/api/version", "/api/busy/snapshot", + "/api/transport", "/api/status/firmware", "/api/status/power", + }: + raise ValueError("unsupported diagnostic endpoint") + return self._json_dict(self._request("GET", path, local_only=local_only)) + + def get_bytes(self, path: str, *, local_only: bool = True) -> bytes | None: + """Read a fixed diagnostic byte stream, currently the front/back screen.""" + if path not in {"/api/screen?display=0", "/api/screen?display=1"}: + raise ValueError("unsupported diagnostic endpoint") + if not local_only: + raise ValueError("screen diagnostics are local only") + response = self._request("GET", path, local_only=local_only) + return response.content if response is not None and response.status_code == 200 else None + + def refresh_capabilities(self) -> bool: + now = time.monotonic() + if self._capabilities_checked_at is not None and now - self._capabilities_checked_at < LOCAL_RETRY_SECONDS: + return self.supports_display_v2 + self._capabilities_checked_at = now + self.supports_display_v2 = False + if self.transport == "cloud" or self.active_transport == "cloud": return False - if resp.status_code != 200: - log.warning("asset upload failed: HTTP %s %s", resp.status_code, resp.text[:200]) - return resp.status_code == 200 + version = self.get_json("/api/version", local_only=True) + if version is not None: + self.supports_display_v2 = _version_at_least(version.get("api_semver"), (27, 5, 0)) + return self.supports_display_v2 def status(self) -> dict | None: - resp = self._request("GET", "/api/status") - return resp.json() if resp is not None and resp.status_code == 200 else None + return self.get_json("/api/status") def get_busy(self) -> dict | None: - resp = self._request("GET", "/api/busy/snapshot") - return resp.json() if resp is not None and resp.status_code == 200 else None + return self.get_json("/api/busy/snapshot") def set_busy_simple(self, time_left_ms: int) -> bool: - """PUT /api/busy/snapshot to start a SIMPLE BUSY session (used by - calendar_countdown's auto_busy=true feature). - - The device's /openapi.yaml documents BusySnapshot as the - discriminated snapshot variant merged (via allOf) with a required - top-level `busy_bar_settings`, sent flat -- that is the shape this - method sent before this fix. Empirically, against a live device, - that flat body gets HTTP 400 "Failed to parse snapshot" every - time. The shape the firmware actually accepts mirrors what - get_busy() (GET, unaffected by this bug) returns: the snapshot - variant nested under a "snapshot" key, sibling to a top-level - "snapshot_timestamp_ms" -- and, on this write path, WITHOUT - `busy_bar_settings` at all, despite the spec marking it required. - Confirmed on-device: the nested body with no `busy_bar_settings` - returns 200 and the session actually starts (visible in a - subsequent get_busy() snapshot). - - `snapshot_timestamp_ms` must be a genuinely current timestamp, not - a stale or placeholder value -- also confirmed on-device: PUTting - this same nested body with a stale `snapshot_timestamp_ms` (e.g. - one copied from a prior GET) still returns HTTP 200, but the - write silently no-ops and the busy state does not actually - change. Always send `time.time()`-derived "now", never a fixed - or cached value. + """Start a SIMPLE BUSY session using the firmware's nested snapshot. + + The timestamp must be current: the device can return 200 for a stale + timestamp while applying no state change. Because this writes device + state, uncertain send/read failures are not replayed. """ body = { - "snapshot": {"type": "SIMPLE", "card_id": NULL_CARD_ID, - "time_left_ms": time_left_ms, "is_paused": False}, + "snapshot": {"type": "SIMPLE", "card_id": NULL_CARD_ID, "time_left_ms": time_left_ms, "is_paused": False}, "snapshot_timestamp_ms": int(time.time() * 1000), } - resp = self._request("PUT", "/api/busy/snapshot", json=body) - return resp is not None and resp.status_code == 200 + response = self._request("PUT", "/api/busy/snapshot", json=body, replay_safe=False) + return response is not None and response.status_code == 200 diff --git a/src/busybar/config.py b/src/busybar/config.py index c3b617d..b32f2a1 100644 --- a/src/busybar/config.py +++ b/src/busybar/config.py @@ -20,6 +20,10 @@ "cloud_token": "", "cloud_base_url": "https://api.busy.app/busybar", "transport": "auto", # "auto" | "local" | "cloud" (forced) + "local_token": "", + "fallback_hosts": [], + "discover": False, + "device_id": "", }, "calendar_countdown": { # 10s matches busybar.display.AMBIENT_REDRAW_SECONDS -- the ambient @@ -75,6 +79,7 @@ "active_within_days": 30, # only repos pushed within this window are polled "repo_refresh_minutes": 60, # how often the repo list itself is re-enumerated "running_spinner": True, # animated 8x8 spinner on the running badge + "bitmap_icons": True, # v1.2.3 cosmetic XPM2 accents when locally supported }, "nyan_filler": { "enabled": True, @@ -140,4 +145,7 @@ def load_config(path: Path | None = None) -> dict: env_host = os.environ.get("BUSYBAR_HOST") if env_host: cfg = _merge(cfg, {"device": {"host": env_host}}) + env_local_token = os.environ.get("BUSYBAR_LOCAL_TOKEN") + if env_local_token: + cfg = _merge(cfg, {"device": {"local_token": env_local_token}}) return cfg diff --git a/src/busybar/diagnostics.py b/src/busybar/diagnostics.py new file mode 100644 index 0000000..069a362 --- /dev/null +++ b/src/busybar/diagnostics.py @@ -0,0 +1,208 @@ +"""Small, read-only diagnostics for a BUSY Bar. + +The diagnostic path deliberately uses the client's existing request surface. +It projects responses onto a small allowlist so a status endpoint cannot turn +the CLI into a configuration, token, or arbitrary firmware-dump printer. +""" + +from __future__ import annotations + +import base64 +import binascii +from collections.abc import Mapping +import struct +from typing import Any + + +ENDPOINTS = { + "api": "/api/version", + "transport": "/api/transport", + "firmware": "/api/status/firmware", + "power": "/api/status/power", + "busy_snapshot": "/api/busy/snapshot", +} + +_FIELDS = { + "api": ("api_semver",), + "transport": ("type",), + "firmware": ( + "version", + "target", + "branch", + "build_date", + "commit_hash", + "intercom_version", + "nwp_version", + "matter_version", + ), + "power": ( + "state", + "battery_charge", + "battery_voltage", + "battery_current", + "usb_voltage", + ), +} + +_REQUIRED = { + "api": ("api_semver",), + "transport": ("type",), + "firmware": ("version", "target", "branch", "build_date", "commit_hash", "intercom_version"), + "power": ("state", "battery_charge", "battery_voltage", "battery_current", "usb_voltage"), +} + + +def _mapping(value: Any) -> Mapping[str, Any] | None: + return value if isinstance(value, Mapping) else None + + +def _safe_fields(payload: Any, fields: tuple[str, ...]) -> dict[str, Any]: + """Return only scalar allowlisted fields from a response mapping. + + The direct endpoint objects are deliberately not recursively unwrapped. + """ + root = _mapping(payload) + if root is None: + return {} + values: dict[str, Any] = {} + for key in fields: + value = root.get(key) + if isinstance(value, (str, int, float, bool)) or value is None: + if value is not None: + values[key] = value + return values + + +def _read_json(client: Any, path: str) -> tuple[Any | None, str | None]: + """Read one allowlisted GET through the shared client.""" + try: + value = client.get_json(path, local_only=True) + return (value, None) if value is not None else (None, "unavailable") + except Exception: + # Diagnostics must remain useful when an optional endpoint is absent + # or a transport cannot reach the device. Do not print exception text: + # it can contain URLs, headers, or other caller-controlled material. + return None, "unavailable" + + +def diagnose(client: Any) -> dict[str, Any]: + """Collect bounded, allowlisted, read-only evidence from *client*.""" + result: dict[str, Any] = { + "reachable": False, + "complete": False, + "api": {"available": False}, + "transport": {"available": False}, + "firmware": {"available": False}, + "power": {"available": False}, + "features": { + "busy_snapshot": {"available": False}, + "screen": {"available": None}, + }, + } + errors: dict[str, str] = {} + + for section in ("api", "transport", "firmware", "power"): + payload, error = _read_json(client, ENDPOINTS[section]) + fields = _safe_fields(payload, _FIELDS[section]) + result[section] = {"available": payload is not None, **fields} + if payload is not None: + result["reachable"] = True + if not _valid(section, payload, fields): + errors[section] = error or "invalid" + + payload, error = _read_json(client, ENDPOINTS["busy_snapshot"]) + snapshot = _mapping(payload) + snapshot_body = snapshot.get("snapshot") if snapshot else None + result["features"]["busy_snapshot"]["available"] = ( + isinstance(snapshot_body, Mapping) and isinstance(snapshot_body.get("type"), str) + ) + if payload is not None: + result["reachable"] = True + if not result["features"]["busy_snapshot"]["available"]: + errors["busy_snapshot"] = error or "invalid" + + api_version = result["api"].get("api_semver") + result["features"]["display_v2"] = {"available": _version_at_least(api_version, (27, 5, 0))} + result["complete"] = not errors + + if errors: + result["unavailable"] = sorted(errors) + return result + + +def _valid(section: str, payload: Any, fields: dict[str, Any]) -> bool: + if not isinstance(payload, Mapping): + return False + if not all(key in fields for key in _REQUIRED[section]): + return False + if section == "api": + return _version_tuple(payload.get("api_semver")) is not None + if section == "transport": + return payload.get("type") in {"usb", "wifi"} + return True + + +def _version_at_least(value: Any, minimum: tuple[int, int, int]) -> bool: + numbers = _version_tuple(value) + return numbers is not None and numbers >= minimum + + +def _version_tuple(value: Any) -> tuple[int, int, int] | None: + if not isinstance(value, str): + return None + try: + numbers = tuple(int(part) for part in value.split(".")[:3]) + except ValueError: + return None + if len(numbers) != 3: + return None + return numbers[0], numbers[1], numbers[2] + + +def save_screen(client: Any, destination: str) -> bool: + """Save display-0 as a standard 72x16 24-bit BMP. + + Firmware labels the response ``image/bmp`` but returns a base64-encoded + BGR24 framebuffer in the 1.2.3 path (the tag's ``api_streaming.c`` uses + ``MG_REPLY_IMAGE`` over the display buffer). Accept an already-formed BMP + too so a corrected server response remains readable without another HTTP + stack. + """ + try: + data = client.get_bytes("/api/screen?display=0", local_only=True) + if not isinstance(data, (bytes, bytearray)): + return False + data = bytes(data).strip() + if data.startswith(b"BM"): + bitmap = data + else: + try: + bgr = base64.b64decode(data, validate=True) + except (ValueError, binascii.Error): + return False + bitmap = _bgr_to_bmp(bgr) + if bitmap is None: + return False + with open(destination, "wb") as handle: + handle.write(bitmap) + return True + except (OSError, ValueError, TypeError): + return False + + +def _bgr_to_bmp(bgr: bytes, width: int = 72, height: int = 16) -> bytes | None: + """Wrap the firmware's row-major BGR24 framebuffer in a BMP header.""" + if len(bgr) != width * height * 3: + return None + row_bytes = width * 3 + stride = (row_bytes + 3) & ~3 + pixels = bytearray() + for row in range(height - 1, -1, -1): + source = bgr[row * row_bytes:(row + 1) * row_bytes] + pixels.extend(source) + pixels.extend(b"\x00" * (stride - row_bytes)) + header = struct.pack( + "<2sIHHIIIIHHIIIIII", b"BM", 54 + len(pixels), 0, 0, 54, 40, + width, height, 1, 24, 0, len(pixels), 2835, 2835, 0, 0, + ) + return header + pixels diff --git a/src/busybar/discovery.py b/src/busybar/discovery.py new file mode 100644 index 0000000..6719c15 --- /dev/null +++ b/src/busybar/discovery.py @@ -0,0 +1,161 @@ +"""Short-lived, opt-in BUSY Bar mDNS discovery. + +mDNS advertising is useful for a trusted LAN, but it is not authentication. +Callers that choose a device must pass its configured USB-MAC-derived ID; this +module never silently chooses the first service it sees. +""" + +from __future__ import annotations + +import ipaddress +import logging +import re +import time +from dataclasses import dataclass +from threading import Lock +from typing import Any + +log = logging.getLogger(__name__) + +SERVICE_TYPE = "_http._tcp.local." +MAX_TIMEOUT_SECONDS = 3.0 +MAX_HOSTS = 4 +MAX_RECORDS = 16 +_DEVICE_ID = re.compile(r"^[0-9a-f]{12}$") +_INSTANCE_PREFIX = "busybar-" + + +class DiscoveryUnavailable(RuntimeError): + """The optional discovery scan could not produce trustworthy scan results.""" + + +@dataclass(frozen=True) +class DiscoveredDevice: + """A parsed BUSY Bar service advertisement, without TXT trust claims.""" + + device_id: str + name: str + hosts: tuple[str, ...] + port: int + + +def _service_device_id(name: str) -> str | None: + suffix = SERVICE_TYPE.lower() + normalized = name.lower() + if not normalized.endswith(suffix): + return None + instance = normalized[: -len(suffix)].rstrip(".") + if not instance.startswith(_INSTANCE_PREFIX): + return None + device_id = instance.removeprefix(_INSTANCE_PREFIX) + return device_id if _DEVICE_ID.fullmatch(device_id) else None + + +class _NamesOnlyListener: + def __init__(self) -> None: + self.names: set[str] = set() + self._lock = Lock() + + def add_service(self, _zc: Any, _service_type: str, name: str) -> None: + with self._lock: + self.names.add(name) + + def update_service(self, _zc: Any, _service_type: str, name: str) -> None: + with self._lock: + self.names.add(name) + + def remove_service(self, _zc: Any, _service_type: str, _name: str) -> None: + return + + def snapshot(self) -> list[str]: + with self._lock: + return sorted(self.names) + + +def _ipv4_hosts(info: Any) -> tuple[str, ...]: + candidates: list[str] = [] + try: + addresses = info.parsed_addresses() + except (AttributeError, OSError, ValueError): + addresses = [] + for address in addresses: + try: + parsed = ipaddress.ip_address(address) + except ValueError: + continue + if parsed.version == 4 and address not in candidates: + candidates.append(address) + if len(candidates) == MAX_HOSTS: + break + return tuple(candidates) + + +def discover_devices(timeout: float = MAX_TIMEOUT_SECONDS, *, device_id: str = "") -> list[DiscoveredDevice]: + """Scan for at most three seconds and return parsed matching advertisements. + + An empty ``device_id`` lists valid BUSY Bar records for diagnostics. A + nonempty value must be the exact lower/uppercase USB-MAC-derived 12-hex + ID. Firmware advertises it as ``busybar-._http._tcp.local.``. This + function creates no listener that outlives the call, and resolves services + only after browsing has finished. + """ + expected = device_id.lower() + if expected and not _DEVICE_ID.fullmatch(expected): + log.warning("busybar discovery needs a 12-hex device_id") + return [] + try: + from zeroconf import ServiceBrowser, Zeroconf + except ImportError as exc: + raise DiscoveryUnavailable("Install the optional 'discovery' dependency") from exc + + wait_seconds = min(max(float(timeout), 0.0), MAX_TIMEOUT_SECONDS) + deadline = time.monotonic() + wait_seconds + # Leave a small part of the bounded scan for synchronous resolution after + # the browser has been stopped. A full three-second browse would leave no + # time to turn collected names into usable IPv4 candidates. + browse_seconds = max(0.0, wait_seconds - min(0.5, wait_seconds / 2)) + listener = _NamesOnlyListener() + zc = None + browser = None + try: + zc = Zeroconf() + browser = ServiceBrowser(zc, SERVICE_TYPE, listener) + time.sleep(browse_seconds) + browser.cancel() + browser = None + records: list[DiscoveredDevice] = [] + unresolved = False + for name in listener.snapshot(): + found_id = _service_device_id(name) + if found_id is None or (expected and found_id != expected): + continue + remaining = deadline - time.monotonic() + if remaining <= 0: + unresolved = True + break + info = zc.get_service_info(SERVICE_TYPE, name, timeout=max(1, int(remaining * 1000))) + if info is None: + unresolved = True + continue + hosts = _ipv4_hosts(info) + if not hosts: + continue + advertised_port = int(getattr(info, "port", 0) or 0) + port = advertised_port if advertised_port > 0 else 80 + records.append(DiscoveredDevice(found_id, name, hosts, port)) + if len(records) == MAX_RECORDS: + break + if unresolved and not records: + raise DiscoveryUnavailable("Matching service could not be resolved within scan deadline") + return records + except DiscoveryUnavailable: + raise + except Exception as exc: + raise DiscoveryUnavailable("Discovery scan failed") from exc + finally: + try: + if browser is not None: + browser.cancel() + finally: + if zc is not None: + zc.close() diff --git a/src/busybar/presentation.py b/src/busybar/presentation.py new file mode 100644 index 0000000..bb909a3 --- /dev/null +++ b/src/busybar/presentation.py @@ -0,0 +1,87 @@ +"""Small compatibility layer for complete, expiring display frames. + +The firmware still has one canvas owner. Selective cleanup preserves common +elements at layout transitions; it does not replace the scheduled full redraws +that reclaim an evicted canvas and renew element timeouts. +""" + +from busybar.client import DrawResult + + +def modern_display(client) -> bool: + """Require positive capability evidence; older client adapters remain usable.""" + refresh = getattr(client, "refresh_capabilities", None) + if refresh is not None: + refresh() + return getattr(client, "supports_display_v2", False) is True + + +def prepare_frame(client, application_name: str, elements: list[dict], + priority: int, state: dict | None, *, modern: bool) -> list[dict]: + """Clean up a layout transition, then return the complete drawing payload. + +Never mutate the caller's elements or commit state before the draw succeeds. +Expired/preempted IDs can reject a selective delete; one app-scoped full clear +is sufficient recovery. Its failure leaves other owners untouched and the +ordinary draw result/element TTLs govern recovery, with no retry loop. +""" + frame = [{**element, "z_index": index * 10} if modern else dict(element) + for index, element in enumerate(elements)] + if state is None or state.get("last_shape") is None: + return frame + + old_shape = state["last_shape"] + new_shape = frozenset(element["id"] for element in frame) + old_priority = state.get("last_priority") + old_types = state.get("last_types", {}) + type_changed = any(element["id"] in old_types + and old_types[element["id"]] != element["type"] + for element in frame) + priority_lowered = old_priority is not None and priority < old_priority + + if priority_lowered or type_changed: + client.clear(application_name) + elif old_shape != new_shape: + # Partial cleanup is only useful while some common content survives. + # An unknown prior priority uses the proven whole-app transition path. + selective = modern and old_priority == priority and bool(old_shape & new_shape) + removed = sorted(old_shape - new_shape) + if not selective: + client.clear(application_name) + elif removed and client.remove_elements(application_name, removed) is not True: + client.clear(application_name) + return frame + + +def commit_frame(state: dict | None, elements: list[dict], priority: int, + result: DrawResult) -> None: + """Remember only a positively accepted frame for the next transition.""" + if state is not None and result == DrawResult.DRAWN: + state["last_shape"] = frozenset(element["id"] for element in elements) + state["last_types"] = {element["id"]: element["type"] for element in elements} + state["last_priority"] = priority + + +_ICON_ROWS = { + "fail": ("X.....X", ".X...X.", "..X.X..", "...X...", "..X.X..", ".X...X.", "X.....X"), + "stuck": ("XXXXXXX", ".X...X.", "..X.X..", "...X...", "..X.X..", ".X...X.", "XXXXXXX"), + "green": (".......", "......X", ".....X.", "X...X..", ".X.X...", "..X....", "......."), +} + + +def ci_bitmap_accent(elements: list[dict], kind: str, timeout_s: int) -> list[dict]: + """Add an inline 7x7 status symbol beside the existing scrolling CI text. + +These are fixed local assets, not a general image parser. The text remains the +meaningful fallback if the client must use an older/cloud endpoint mid-draw. +""" + rows = _ICON_ROWS.get(kind) + if rows is None: + return elements + colors = {"fail": "#FFFFFF", "stuck": "#0B0B0B", "green": "#00FF00"} + data = "! XPM2\n7 7 2 1\n. c None\nX c " + colors[kind] + "\n" + "\n".join(rows) + "\n" + frame = [{**element, "x": 11, "width": 61} if element["id"] == "ci" else dict(element) + for element in elements] + frame.append({"id": "ci_status_icon", "type": "xpmbitmap", "data": data, + "x": 1, "y": 4, "timeout": timeout_s}) + return frame diff --git a/tests/test_calendar_loop.py b/tests/test_calendar_loop.py index 60b5c0c..6d209a2 100644 --- a/tests/test_calendar_loop.py +++ b/tests/test_calendar_loop.py @@ -430,9 +430,9 @@ def test_run_once_restart_mid_event_does_not_chirp(): run_once(client, lambda hours: [active], CFG, NOW, dry_run=False, state=state) client.play_audio.assert_not_called() -def test_run_once_chirp_retries_next_poll_if_play_fails(): +def test_run_once_chirp_does_not_replay_after_unconfirmed_play(): client = Mock(); client.draw.return_value = DrawResult.DRAWN - client.play_audio.return_value = False # transient failure + client.play_audio.return_value = False # response may be lost after playback state: dict = {} upcoming = make_event(0.2) run_once(client, lambda hours: [upcoming], CFG, NOW, dry_run=False, state=state) @@ -441,10 +441,10 @@ def test_run_once_chirp_retries_next_poll_if_play_fails(): run_once(client, lambda hours: [started], CFG, later, dry_run=False, state=state) assert client.play_audio.call_count == 1 - # Retries on the next poll since the failure wasn't committed. + # A missing response is not evidence that the sound did not play. client.play_audio.return_value = True run_once(client, lambda hours: [started], CFG, later + timedelta(seconds=5), dry_run=False, state=state) - assert client.play_audio.call_count == 2 + assert client.play_audio.call_count == 1 def test_run_once_dry_run_never_chirps(): client = Mock() @@ -626,3 +626,33 @@ def test_start_takeover_fallback_is_per_poll_not_latched(): assert len(c.draws) == 1 # takeover, drawn straight away assert any(e["id"] == START_ANIM_ID for e in c.draws[0][0]) assert st["last_shape"] == frozenset({"bg", START_ANIM_ID}) + + +def test_modern_calendar_layers_and_same_shape_priority_stepdown(): + client = Mock() + client.supports_display_v2 = True + client.draw.return_value = DrawResult.DRAWN + state = {} + run_once(client, lambda _: [make_event(3)], CFG, NOW, False, state) + assert state["last_priority"] == 65 + # A rescheduled event has the same IDs but a lower priority. Without a + # scoped clear, the firmware rejects this same-app priority reduction. + run_once(client, lambda _: [make_event(40)], CFG, NOW, False, state) + client.clear.assert_called_once_with("calendar_countdown") + assert state["last_priority"] == 20 + elements = client.draw.call_args.kwargs["elements"] + assert [e["z_index"] for e in elements] == list(range(0, len(elements) * 10, 10)) + + +def test_auto_busy_requires_confirmed_nested_idle_snapshot(): + cfg = {"calendar_countdown": {**CFG["calendar_countdown"], "auto_busy": True}} + client = Mock() + client.draw.return_value = DrawResult.DRAWN + event = make_event(-5) + for response in (None, {}, {"snapshot": None}, {"snapshot": {"type": "SIMPLE"}}): + client.get_busy.return_value = response + run_once(client, lambda _: [event], cfg, NOW, False) + client.set_busy_simple.assert_not_called() + client.get_busy.return_value = {"snapshot": {"type": "NOT_STARTED"}, "timestamp": 1} + run_once(client, lambda _: [event], cfg, NOW, False) + client.set_busy_simple.assert_called_once_with(25 * 60 * 1000) diff --git a/tests/test_ci_loop.py b/tests/test_ci_loop.py index 8d98990..e8971e3 100644 --- a/tests/test_ci_loop.py +++ b/tests/test_ci_loop.py @@ -713,3 +713,25 @@ def test_overlay_dwell_rejected_during_calendar_elevation_resumes_after(): assert overlay_state.get("last_dwell_end") is not None assert overlay_state["last_shape"] == frozenset({"bg", "title", "track", "track_fill", "eta"}) assert client.draw.call_count == 2 # both attempts drew (1st rejected, 2nd landed) -- no crash anywhere + + +def test_modern_ci_preserves_text_and_icon_when_failure_background_is_removed(): + client = Mock() + client.supports_display_v2 = True + client.remove_elements.return_value = True + client.draw.return_value = DrawResult.DRAWN + poller = Mock() + poller.fetch_runs.return_value = [_run("failure")] + state = {} + cfg = {"ci_status": {**CFG["ci_status"], "show_green": True}} + run_once(client, poller, cfg, NOW, {}, False, overlay_state=state) + first = client.draw.call_args.args[1] + assert any(e["type"] == "xpmbitmap" for e in first) + assert state["last_priority"] == PRIORITY_OVERLAY + poller.fetch_runs.return_value = [_run("success")] + run_once(client, poller, cfg, NOW + timedelta(seconds=21), {}, False, overlay_state=state) + client.remove_elements.assert_called_once_with("ci_status", ["bg"]) + client.clear.assert_not_called() + frame = client.draw.call_args.args[1] + assert {e["id"] for e in frame} == {"ci", "ci_status_icon"} + assert all(e["timeout"] == 10 for e in frame) diff --git a/tests/test_client.py b/tests/test_client.py index 12c0dbe..7ebb05a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,4 +1,5 @@ import logging +import pytest from unittest.mock import Mock, patch import requests from busybar.client import BusyBarClient, DrawResult @@ -321,7 +322,10 @@ def test_upload_asset_success(mock_request): assert BusyBarClient().upload_asset("nyan_filler", "nyan_72x16.anim", data) is True method, url = mock_request.call_args.args assert method == "POST" - assert "application_name=nyan_filler" in url and "file=nyan_72x16.anim" in url + assert url == "http://10.0.4.20/api/assets/upload" + assert mock_request.call_args.kwargs["params"] == { + "application_name": "nyan_filler", "file": "nyan_72x16.anim" + } assert mock_request.call_args.kwargs["headers"] == {"Content-Type": "application/octet-stream"} assert mock_request.call_args.kwargs["data"] == data @@ -407,3 +411,284 @@ def test_invalid_transport_value_raises_value_error(): raise AssertionError("expected ValueError") except ValueError as exc: assert "carrier-pigeon" in str(exc) + + +# --- firmware 1.2.3 local transport boundaries -------------------------------- + +@patch("busybar.client.requests.request") +def test_local_token_never_reaches_cloud_and_redirects_are_disabled(mock_request): + mock_request.side_effect = [requests.ConnectionError(), _response(200)] + client = BusyBarClient(host="192.0.2.1", local_token="local-only-token", + cloud_token=FAKE_TOKEN, cloud_base_url="https://cloud.example.test/busybar") + assert client.draw("app", ELEMENTS) == DrawResult.DRAWN + local_call, cloud_call = mock_request.call_args_list + assert local_call.kwargs["headers"] == {"X-API-Token": "local-only-token"} + assert cloud_call.kwargs["headers"] == {"Authorization": f"Bearer {FAKE_TOKEN}"} + assert local_call.kwargs["allow_redirects"] is False + assert cloud_call.kwargs["allow_redirects"] is False + + +@patch("busybar.client.requests.request") +def test_unsafe_audio_is_not_replayed_after_read_or_connection_uncertainty(mock_request): + client = _cloud_client() + mock_request.side_effect = requests.ReadTimeout() + assert client.play_audio("app", stock_path="shared/x.snd") is False + assert mock_request.call_count == 1 + mock_request.reset_mock() + mock_request.side_effect = requests.ConnectionError() + assert client.set_busy_simple(1_000) is False + assert mock_request.call_count == 1 + + +@patch("busybar.client.requests.request") +def test_unsafe_audio_may_fail_over_after_definite_connect_timeout(mock_request): + mock_request.side_effect = [requests.ConnectTimeout(), _response(200)] + assert _cloud_client().play_audio("app", stock_path="shared/x.snd") is True + assert mock_request.call_count == 2 + assert mock_request.call_args_list[1].args[1].startswith("https://cloud.example.test/") + + +@patch("busybar.client.requests.request") +def test_auth_and_conflict_responses_never_trigger_failover(mock_request): + for status in (401, 403, 409): + mock_request.reset_mock() + mock_request.return_value = _response(status) + assert _cloud_client().draw("app", ELEMENTS) in (DrawResult.ERROR, DrawResult.REJECTED) + assert mock_request.call_count == 1 + + +@patch("busybar.client.requests.request") +def test_configured_fallback_host_is_tried_before_cloud(mock_request): + mock_request.side_effect = [requests.ConnectionError(), _response(200)] + client = _cloud_client(fallback_hosts=["192.0.2.2"]) + assert client.status() == {} + assert [call.args[1] for call in mock_request.call_args_list] == [ + "http://192.0.2.1/api/status", "http://192.0.2.2/api/status" + ] + + +@patch("busybar.client.time.monotonic") +@patch("busybar.client.requests.request") +def test_capabilities_are_local_only_and_cached(mock_request, mock_time): + mock_time.return_value = 0.0 + mock_request.return_value = _response(200, {"api_semver": "27.5.0"}) + client = BusyBarClient() + assert client.refresh_capabilities() is True + assert client.supports_display_v2 is True + mock_time.return_value = 30.0 + assert client.refresh_capabilities() is True + assert mock_request.call_count == 1 + cloud = _cloud_client(transport="cloud") + assert cloud.refresh_capabilities() is False + assert cloud.supports_display_v2 is False + + +@patch("busybar.client.requests.request") +def test_read_only_diagnostics_support_documented_json_and_screen_paths(mock_request): + response = _response(200, {"version": "ok"}) + response.content = b"screen-bytes" + mock_request.return_value = response + client = BusyBarClient(local_token="local-token") + assert client.get_json("/api/status/firmware", local_only=True) == {"version": "ok"} + assert client.get_bytes("/api/screen?display=0") == b"screen-bytes" + assert all(call.kwargs["headers"] == {"X-API-Token": "local-token"} for call in mock_request.call_args_list) + + +@patch("busybar.client.requests.request") +def test_remove_elements_uses_query_owner_and_never_duplicates_it_in_json(mock_request): + mock_request.return_value = _response(200) + client = BusyBarClient() + client.supports_display_v2 = True + assert client.remove_elements("app", ["obsolete", "other"]) + assert mock_request.call_args.args == ("DELETE", "http://10.0.4.20/api/display/draw") + assert mock_request.call_args.kwargs["params"] == {"application_name": "app"} + assert mock_request.call_args.kwargs["json"] == {"element_ids": ["obsolete", "other"]} + mock_request.reset_mock() + assert BusyBarClient().remove_elements("app", []) is True + assert mock_request.call_count == 0 + + +@patch("busybar.client.requests.request") +def test_remove_elements_requires_verified_current_local_capability(mock_request): + client = BusyBarClient() + assert client.remove_elements("app", ["obsolete"]) is False + assert mock_request.call_count == 0 + + +@patch("busybar.client.requests.request") +def test_subdirectory_asset_upload_and_traversal_rejection(mock_request): + mock_request.return_value = _response(200) + client = BusyBarClient() + assert client.upload_asset("app", "icons/ok.xpm", b"x") is True + assert mock_request.call_args.kwargs["params"]["file"] == "icons/ok.xpm" + mock_request.reset_mock() + assert client.upload_asset("app", "../nope.xpm", b"x") is False + assert client.upload_asset("app", "/nope.xpm", b"x") is False + assert mock_request.call_count == 0 + + +@patch("busybar.client.requests.request") +def test_bitmaps_and_z_index_degrade_for_legacy_and_cloud(mock_request): + mixed = [ + {"id": "icon", "type": "xpmbitmap", "xpmbitmap": "xpm"}, + {"id": "text", "type": "text", "text": "ok", "z_index": 99}, + ] + mock_request.return_value = _response(200) + assert BusyBarClient().draw("app", mixed) == DrawResult.DRAWN + legacy = mock_request.call_args.kwargs["json"]["elements"] + assert legacy == [{"id": "text", "type": "text", "text": "ok"}] + mock_request.reset_mock() + mock_request.side_effect = [requests.ConnectionError(), _response(200)] + client = _cloud_client() + client.supports_display_v2 = True + assert client.draw("app", mixed) == DrawResult.DRAWN + local, cloud = mock_request.call_args_list + assert local.kwargs["json"]["elements"][0]["z_index"] == 0 + assert cloud.kwargs["json"]["elements"] == [{"id": "text", "type": "text", "text": "ok"}] + + +@patch("busybar.client.requests.request") +def test_all_bitmap_payload_is_rejected_before_an_empty_legacy_or_cloud_draw(mock_request): + assert BusyBarClient().draw("app", [{"id": "icon", "type": "xpmbitmap", "xpmbitmap": "xpm"}]) == DrawResult.ERROR + assert mock_request.call_count == 0 + + +@patch("busybar.client.requests.request") +def test_malformed_snapshot_json_is_unknown(mock_request): + response = _response(200) + response.json.side_effect = ValueError("bad json") + mock_request.return_value = response + assert BusyBarClient().get_busy() is None + + +@patch("busybar.client.requests.request") +def test_bitmap_only_is_supported_locally_without_empty_cloud_fallback(mock_request): + client = _cloud_client() + client.supports_display_v2 = True + bitmap = [{"id": "icon", "type": "xpmbitmap", "data": "! XPM2\n1 1 1 1\nX c #FFFFFF\nX\n", "timeout": 5}] + mock_request.return_value = _response(200) + assert client.draw("app", bitmap) == DrawResult.DRAWN + assert mock_request.call_args.kwargs["json"]["elements"][0]["type"] == "xpmbitmap" + mock_request.reset_mock() + mock_request.side_effect = requests.ReadTimeout() + assert client.draw("app", bitmap) == DrawResult.UNREACHABLE + assert mock_request.call_count == 1 + assert mock_request.call_args.args[1].startswith("http://192.0.2.1/") + + +def test_discovered_routes_cannot_expand_total_route_budget(): + client = BusyBarClient(fallback_hosts=["192.0.2.1", "192.0.2.2", "192.0.2.3"]) + client._discovered_hosts = ["192.0.2.4", "192.0.2.5"] + assert len(client._local_order()) == 4 + + +@patch("busybar.client.requests.request") +def test_discovery_failure_preserves_explicit_host(mock_request): + from busybar.discovery import DiscoveryUnavailable + mock_request.return_value = _response(200, {"api_semver": "27.5.0"}) + with patch("busybar.discovery.discover_devices", side_effect=DiscoveryUnavailable("scan unavailable")): + client = BusyBarClient(host="192.0.2.8", discover=True, device_id="aabbccddeeff") + assert client.get_json("/api/version") == {"api_semver": "27.5.0"} + assert mock_request.call_args.args[1] == "http://192.0.2.8/api/version" + + +@patch("busybar.client.requests.request") +def test_empty_owner_cannot_turn_cleanup_into_global_delete(mock_request): + client = BusyBarClient() + client.supports_display_v2 = True + with pytest.raises(ValueError): + client.clear("") + with pytest.raises(ValueError): + client.remove_elements("", ["old"]) + mock_request.assert_not_called() + + +@patch("busybar.client.requests.request") +@pytest.mark.parametrize("audio", [False, True]) +def test_newly_discovered_address_is_tried_in_current_operation(mock_request, audio): + from busybar.discovery import DiscoveredDevice + client = BusyBarClient() + client.discover = True + client.device_id = "aabbccddeeff" + record = DiscoveredDevice(client.device_id, "busybar-aabbccddeeff._http._tcp.local.", ("192.0.2.9",), 80) + # Only a definite connection timeout permits retrying the audio variant. + mock_request.side_effect = [requests.ConnectTimeout(), _response(200, {"api_semver": "27.5.0"})] + with patch("busybar.discovery.discover_devices", return_value=[record]) as scan: + result = client.play_audio("app", stock_path="sound.snd") if audio else client.get_json("/api/version") + assert result + assert mock_request.call_count == 2 + assert mock_request.call_args.args[1].startswith("http://192.0.2.9/") + scan.assert_called_once() + + +@patch("busybar.client.requests.request") +def test_discovery_refresh_cannot_exceed_four_attempts_or_replay_uncertain_audio(mock_request): + client = BusyBarClient(fallback_hosts=["192.0.2.1", "192.0.2.2", "192.0.2.3"]) + mock_request.side_effect = requests.ConnectTimeout() + with patch.object(client, "_refresh_discovery") as scan: + assert client.get_json("/api/version") is None + assert mock_request.call_count == 4 + scan.assert_called_once() + mock_request.reset_mock() + mock_request.side_effect = requests.ReadTimeout() + with patch.object(client, "_refresh_discovery") as scan: + assert client.play_audio("app", stock_path="sound.snd") is False + assert mock_request.call_count == 1 + scan.assert_not_called() + + +@patch("busybar.client.time.monotonic", return_value=1000.0) +@patch("busybar.client.requests.request") +def test_working_fallback_is_retained_until_primary_recovery_interval(mock_request, clock): + client = BusyBarClient(host="192.0.2.1", fallback_hosts=["192.0.2.2"]) + mock_request.side_effect = [requests.ReadTimeout(), _response(200), _response(200)] + assert client.status() == {} + clock.return_value = 1001.0 + assert client.play_audio("app", stock_path="sound.snd") is True + assert mock_request.call_args.args[1].startswith("http://192.0.2.2/") + clock.return_value = 1060.0 + mock_request.side_effect = None + mock_request.return_value = _response(200) + assert client.status() == {} + assert mock_request.call_args.args[1].startswith("http://192.0.2.1/") + + +@patch("busybar.client.requests.request") +@pytest.mark.parametrize("cached", [False, True]) +@pytest.mark.parametrize("audio", [False, True]) +def test_full_static_routes_reserve_last_attempt_for_discovery(mock_request, cached, audio): + from busybar.discovery import DiscoveredDevice + client = BusyBarClient(host="192.0.2.1", fallback_hosts=["192.0.2.2", "192.0.2.3", "192.0.2.4"]) + client.discover = True + client.device_id = "aabbccddeeff" + record = DiscoveredDevice(client.device_id, "busybar-aabbccddeeff._http._tcp.local.", ("192.0.2.9",), 80) + if cached: + client._discovered_hosts = ["192.0.2.9"] + client._last_discovery = __import__("time").monotonic() + mock_request.side_effect = [requests.ConnectTimeout(), requests.ConnectTimeout(), requests.ConnectTimeout(), _response(200)] + with patch("busybar.discovery.discover_devices", return_value=[record]): + result = client.play_audio("app", stock_path="sound.snd") if audio else client.status() + assert result == (True if audio else {}) + assert [call.args[1].split("/")[2] for call in mock_request.call_args_list] == [ + "192.0.2.1", "192.0.2.2", "192.0.2.3", "192.0.2.9"] + # A successful discovered route remains preferred despite full static config. + mock_request.side_effect = None + mock_request.return_value = _response(200) + assert client.play_audio("app", stock_path="sound.snd") is True + assert mock_request.call_args.args[1].startswith("http://192.0.2.9/") + + +@patch("busybar.client.time.monotonic", return_value=1060.0) +@patch("busybar.client.requests.request") +def test_primary_recovery_probe_preserves_successful_discovered_route(mock_request, clock): + client = BusyBarClient(host="192.0.2.1", fallback_hosts=["192.0.2.2", "192.0.2.3", "192.0.2.4"]) + client._discovered_hosts = ["192.0.2.8", "192.0.2.9"] + client.base = "http://192.0.2.9" + client._last_primary_probe = 1000.0 + # Computing order does not consume the recovery interval. + assert client._local_order()[:2] == ["192.0.2.1", "192.0.2.9"] + assert client._last_primary_probe == 1000.0 + mock_request.side_effect = [requests.ConnectTimeout(), _response(200)] + assert client.play_audio("app", stock_path="sound.snd") is True + assert mock_request.call_count == 2 + assert mock_request.call_args.args[1].startswith("http://192.0.2.9/") diff --git a/tests/test_config.py b/tests/test_config.py index a2116d8..4dfae0d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -37,6 +37,12 @@ def test_env_overrides_file(tmp_path, monkeypatch): monkeypatch.setenv("BUSYBAR_HOST", "192.0.2.99") assert load_config(p)["device"]["host"] == "192.0.2.99" +def test_local_token_env_overrides_file(tmp_path, monkeypatch): + p = tmp_path / "config.toml" + p.write_text('[device]\nlocal_token = "file-token"\n') + monkeypatch.setenv("BUSYBAR_LOCAL_TOKEN", "env-token") + assert load_config(p)["device"]["local_token"] == "env-token" + def test_returned_config_mutation_does_not_corrupt_defaults(tmp_path): # Mutate the returned config in place cfg1 = load_config(tmp_path / "missing.toml") @@ -130,3 +136,4 @@ def test_animation_accent_defaults(tmp_path): assert cal["start_animation"] == "meeting_72x16" assert cal["start_window_seconds"] == 60 assert cfg["ci_status"]["running_spinner"] is True + assert cfg["ci_status"]["bitmap_icons"] is True diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py new file mode 100644 index 0000000..a84c0e1 --- /dev/null +++ b/tests/test_diagnostics.py @@ -0,0 +1,181 @@ +import base64 +import json + +from busybar import diagnostics +from busybar.__main__ import main + + +class FakeClient: + def __init__(self, responses): + self.responses = responses + self.paths = [] + + def get_json(self, path, *, local_only=False): + self.paths.append((path, local_only)) + value = self.responses.get(path) + if isinstance(value, Exception): + raise value + return value + + +def test_diagnose_projects_allowlisted_fields_and_never_prints_token(): + token = "super-secret-token" + client = FakeClient({ + "/api/version": {"api_semver": "27.5.0", "token": token}, + "/api/transport": {"type": "wifi", "headers": {"X-API-Token": token}}, + "/api/status/firmware": {"version": "1.2.3", "config": token}, + "/api/status/power": {"battery_charge": 88, "raw": {"token": token}}, + "/api/busy/snapshot": {"snapshot": {"type": "SIMPLE", "token": token}}, + }) + + result = diagnostics.diagnose(client) + + assert result["reachable"] is True + assert result["api"] == {"available": True, "api_semver": "27.5.0"} + assert result["transport"] == {"available": True, "type": "wifi"} + assert result["firmware"] == {"available": True, "version": "1.2.3"} + assert result["power"] == {"available": True, "battery_charge": 88} + assert token not in json.dumps(result) + assert all(local_only for _, local_only in client.paths) + + +def test_diagnose_keeps_partial_evidence_and_marks_bad_json_unknown(): + client = FakeClient({ + "/api/version": {"api_semver": "27.5.0"}, + "/api/transport": ValueError("malformed response"), + "/api/status/firmware": None, + "/api/status/power": {"state": "usb"}, + "/api/busy/snapshot": None, + }) + + result = diagnostics.diagnose(client) + + assert result["reachable"] is True + assert result["api"]["available"] is True + assert result["transport"] == {"available": False} + assert result["power"] == {"available": True, "state": "usb"} + assert result["features"]["busy_snapshot"]["available"] is False + assert result["unavailable"] == ["busy_snapshot", "firmware", "power", "transport"] + + +def test_diagnose_requires_complete_direct_sections_for_success(): + client = FakeClient({ + "/api/version": {"api_semver": "27.5.0"}, + "/api/transport": {"type": "wifi"}, + "/api/status/firmware": { + "version": "1.2.3", "target": 22, "branch": "main", + "build_date": "2026-01-01", "commit_hash": "abc", + "intercom_version": "1", + }, + "/api/status/power": { + "state": "charging", "battery_charge": 88, + "battery_voltage": 4100, "battery_current": 100, "usb_voltage": 5000, + }, + "/api/busy/snapshot": {"snapshot": {"type": "SIMPLE"}}, + }) + + result = diagnostics.diagnose(client) + + assert result["complete"] is True + assert result["features"]["display_v2"]["available"] is True + assert result["api"] == {"available": True, "api_semver": "27.5.0"} + assert result["firmware"]["version"] == "1.2.3" + assert result["power"]["battery_charge"] == 88 + assert result["features"]["busy_snapshot"]["available"] is True + + +def test_cli_unreachable_is_nonzero_without_mutating_calls(monkeypatch, capsys): + client = FakeClient({path: None for path in diagnostics.ENDPOINTS.values()}) + monkeypatch.setattr("busybar.__main__._client", lambda config, host: client) + + assert main(["diagnose", "--host", "192.0.2.7"]) == 2 + + output = json.loads(capsys.readouterr().out) + assert output["reachable"] is False + assert output["features"]["screen"]["available"] is None + assert all(path.startswith("/api/") for path, _ in client.paths) + assert "/api/log_dump" not in [path for path, _ in client.paths] + + +def test_cli_discover_uses_bounded_discovery_contract(monkeypatch, capsys): + import sys + import types + + seen = [] + module = types.ModuleType("busybar.discovery") + module.discover_devices = lambda timeout: seen.append(timeout) or [ + {"name": "BUSY", "host": "192.0.2.10", "port": 80, "token": "omit"} + ] + monkeypatch.setitem(sys.modules, "busybar.discovery", module) + + assert main(["discover", "--timeout", "3"]) == 0 + + output = json.loads(capsys.readouterr().out) + assert seen == [3.0] + assert output["devices"] == [{"host": "192.0.2.10", "name": "BUSY", "port": 80}] + assert "token" not in json.dumps(output) + + +def test_cli_discover_reports_propagated_scan_failure(monkeypatch, capsys): + import sys + import types + + module = types.ModuleType("busybar.discovery") + module.discover_devices = lambda timeout: (_ for _ in ()).throw(RuntimeError("scan")) + monkeypatch.setitem(sys.modules, "busybar.discovery", module) + + assert main(["discover"]) == 2 + assert json.loads(capsys.readouterr().out) == {"available": False, "devices": []} + + +def test_save_screen_requires_client_byte_helper_and_raw_bmp(tmp_path): + class ScreenClient: + def get_bytes(self, path, *, local_only=False): + assert path == "/api/screen?display=0" + assert local_only is True + return b"BMdemo" + + destination = tmp_path / "front.bmp" + assert diagnostics.save_screen(ScreenClient(), str(destination)) is True + assert destination.read_bytes() == b"BMdemo" + + +def test_save_screen_decodes_firmware_base64_bgr_to_bmp(tmp_path): + bgr = bytes((0, 0, 255)) * (72 * 16) + + class ScreenClient: + def get_bytes(self, path, *, local_only=False): + return base64.b64encode(bgr) + + destination = tmp_path / "front.bmp" + assert diagnostics.save_screen(ScreenClient(), str(destination)) is True + bitmap = destination.read_bytes() + assert bitmap[:2] == b"BM" + assert int.from_bytes(bitmap[18:22], "little") == 72 + assert int.from_bytes(bitmap[22:26], "little") == 16 + assert bitmap[54:57] == bytes((0, 0, 255)) + + +def test_requested_screen_failure_makes_cli_incomplete(monkeypatch, capsys, tmp_path): + monkeypatch.setattr("busybar.__main__._client", lambda config, host: object()) + monkeypatch.setattr("busybar.__main__.diagnose", lambda client: {"reachable": True, "complete": True, "features": {}}) + monkeypatch.setattr("busybar.__main__.save_screen", lambda client, path: False) + assert main(["diagnose", "--screen", str(tmp_path / "screen.bmp")]) == 2 + report = json.loads(capsys.readouterr().out) + assert report["complete"] is False + assert report["features"]["screen"]["available"] is False + + +def test_explicit_host_is_local_and_disables_configured_alternatives(monkeypatch): + from busybar.__main__ import _client + from busybar.config import DEFAULTS + from copy import deepcopy + config = deepcopy(DEFAULTS) + config["device"].update(transport="cloud", fallback_hosts=["192.0.2.8"], + discover=True, device_id="aabbccddeeff", local_token="local-only") + monkeypatch.setattr("busybar.__main__.load_config", lambda path: config) + client = _client(None, "192.0.2.9") + assert client.transport == "local" + assert client._all_local_hosts() == ["192.0.2.9"] + assert client.discover is False + assert client.local_token == "local-only" diff --git a/tests/test_discovery.py b/tests/test_discovery.py new file mode 100644 index 0000000..b51f66d --- /dev/null +++ b/tests/test_discovery.py @@ -0,0 +1,84 @@ +import sys +import pytest +from types import SimpleNamespace + +from busybar.discovery import DiscoveredDevice, DiscoveryUnavailable, discover_devices + + +class _Info: + def __init__(self, addresses, port=80): + self._addresses = addresses + self.port = port + + def parsed_addresses(self): + return self._addresses + + +def _fake_zeroconf(monkeypatch, names, infos): + state = SimpleNamespace(cancelled=False, closed=False) + + class Zeroconf: + def get_service_info(self, _service_type, name, timeout): + assert timeout >= 1 + return infos.get(name) + + def close(self): + state.closed = True + + class ServiceBrowser: + def __init__(self, _zc, service_type, listener): + assert service_type == "_http._tcp.local." + for name in names: + listener.add_service(None, service_type, name) + + def cancel(self): + state.cancelled = True + + monkeypatch.setitem(sys.modules, "zeroconf", SimpleNamespace(Zeroconf=Zeroconf, ServiceBrowser=ServiceBrowser)) + return state + + +def test_discovers_only_exact_device_identity_and_deduplicates_ipv4(monkeypatch): + wanted = "busybar-aabbccddeeff._http._tcp.local." + state = _fake_zeroconf( + monkeypatch, + ["other._http._tcp.local.", "aabbccddeeff._http._tcp.local.", wanted, "busybar-112233445566._http._tcp.local."], + {wanted: _Info(["192.0.2.2", "2001:db8::1", "192.0.2.2", "192.0.2.3"], 8123)}, + ) + monkeypatch.setattr("busybar.discovery.time.sleep", lambda _seconds: None) + records = discover_devices(device_id="AABBCCDDEEFF") + assert records == [DiscoveredDevice("aabbccddeeff", wanted, ("192.0.2.2", "192.0.2.3"), 8123)] + assert state.cancelled and state.closed + + +def test_discovery_uses_port_80_for_logical_zero_and_ignores_malformed(monkeypatch): + name = "busybar-aabbccddeeff._http._tcp.local." + _fake_zeroconf(monkeypatch, ["busybar-not-a-mac._http._tcp.local.", name], {name: _Info(["192.0.2.4"], 0)}) + waited = [] + monkeypatch.setattr("busybar.discovery.time.sleep", waited.append) + records = discover_devices(timeout=99) + assert len(records) == 1 + assert records[0].port == 80 + assert waited == [2.5] + + +def test_discovery_closes_resources_when_service_resolution_fails(monkeypatch): + name = "busybar-aabbccddeeff._http._tcp.local." + state = _fake_zeroconf(monkeypatch, [name], {}) + monkeypatch.setattr("busybar.discovery.time.sleep", lambda _seconds: None) + with pytest.raises(DiscoveryUnavailable): + discover_devices() + assert state.cancelled and state.closed + + +def test_missing_optional_dependency_is_reported_as_unavailable(monkeypatch): + monkeypatch.setitem(sys.modules, "zeroconf", None) + with pytest.raises(DiscoveryUnavailable): + discover_devices(device_id="aabbccddeeff") + + +def test_successful_empty_scan_is_distinct_from_unavailable(monkeypatch): + state = _fake_zeroconf(monkeypatch, [], {}) + monkeypatch.setattr("busybar.discovery.time.sleep", lambda _: None) + assert discover_devices() == [] + assert state.cancelled and state.closed diff --git a/tests/test_presentation.py b/tests/test_presentation.py new file mode 100644 index 0000000..94d4f92 --- /dev/null +++ b/tests/test_presentation.py @@ -0,0 +1,146 @@ +from copy import deepcopy +from unittest.mock import Mock + +import pytest + +from busybar.client import DrawResult +from busybar.presentation import ci_bitmap_accent, commit_frame, modern_display, prepare_frame + + +def text_element(name): + return {"id": name, "type": "text", "text": name, "x": 0, "y": 0, "timeout": 10} + + +def previous(*names, priority=21): + state = {} + commit_frame(state, [text_element(n) for n in names], priority, DrawResult.DRAWN) + return state + + +class Canvas: + """Relevant firmware ownership, priority and missing-ID behavior.""" + supports_display_v2 = True + + def __init__(self, owner="app", priority=21, ids=("common", "old")): + self.owner, self.priority = owner, priority + self.elements = {n: text_element(n) for n in ids} + self.removals, self.clears = [], [] + + def clear(self, app): + self.clears.append(app) + if self.owner != app: + return False + self.elements.clear() + self.owner = None + return True + + def remove_elements(self, app, ids): + self.removals.append((app, ids)) + if app != self.owner or not set(ids) <= self.elements.keys(): + return False + for name in ids: + del self.elements[name] + return True + + def draw(self, app, elements, priority): + if self.owner and (priority < self.priority or (priority == self.priority and app != self.owner)): + return DrawResult.REJECTED + if self.owner != app: + self.elements.clear() + self.elements.update({e["id"]: e for e in elements}) + self.owner, self.priority = app, priority + return DrawResult.DRAWN + + +def test_selective_transition_preserves_common_content_until_full_upsert(): + canvas = Canvas() + state = previous("common", "old") + original = [text_element("common"), text_element("new")] + frame = prepare_frame(canvas, "app", original, 21, state, modern=True) + assert canvas.removals == [("app", ["old"])] + assert canvas.clears == [] + assert set(canvas.elements) == {"common"} + assert canvas.draw("app", frame, 21) == DrawResult.DRAWN + assert set(canvas.elements) == {"common", "new"} + assert [e["z_index"] for e in frame] == [0, 10] + assert all(e["timeout"] == 10 for e in frame) + assert all("z_index" not in e for e in original) + + +def test_adding_only_does_not_clear_modern_canvas(): + c = Canvas(ids=("common",)) + prepare_frame(c, "app", [text_element("common"), text_element("new")], + 21, previous("common"), modern=True) + assert not c.removals and not c.clears + + +@pytest.mark.parametrize("modern", [False, True]) +def test_priority_reduction_clears_even_when_ids_are_unchanged(modern): + c = Canvas(priority=65, ids=("common",)) + frame = prepare_frame(c, "app", [text_element("common")], 20, + previous("common", priority=65), modern=modern) + assert c.clears == ["app"] + assert c.draw("app", frame, 20) == DrawResult.DRAWN + + +def test_expired_ids_fall_back_to_one_scoped_clear_then_recover(): + c = Canvas(ids=("common",)) + frame = prepare_frame(c, "app", [text_element("common"), text_element("new")], + 21, previous("common", "old"), modern=True) + assert c.removals == [("app", ["old"])] + assert c.clears == ["app"] + assert c.draw("app", frame, 21) == DrawResult.DRAWN + + +def test_preemption_never_clears_other_owner_or_commits_rejected_transition(): + c = Canvas(owner="work-session", priority=90, ids=("busy",)) + state = previous("common", "old") + before = deepcopy(state) + frame = prepare_frame(c, "app", [text_element("common"), text_element("new")], + 21, state, modern=True) + result = c.draw("app", frame, 21) + commit_frame(state, frame, 21, result) + assert result == DrawResult.REJECTED + assert state == before + assert c.owner == "work-session" and set(c.elements) == {"busy"} + + +def test_legacy_layout_change_and_modern_type_change_clear(): + c = Canvas() + prepare_frame(c, "app", [text_element("common"), text_element("new")], + 21, previous("common", "old"), modern=False) + assert c.clears == ["app"] and not c.removals + c = Canvas(ids=("common",)) + prepare_frame(c, "app", [{"id": "common", "type": "rectangle", "timeout": 10}], + 21, previous("common"), modern=True) + assert c.clears == ["app"] and not c.removals + + +@pytest.mark.parametrize("result", [DrawResult.ERROR, DrawResult.REJECTED, DrawResult.UNREACHABLE]) +def test_failed_draw_never_advances_state(result): + state = previous("old") + before = deepcopy(state) + commit_frame(state, [text_element("new")], 65, result) + assert state == before + + +def test_capability_needs_positive_evidence(): + assert modern_display(object()) is False + c = Mock() + assert modern_display(c) is False + c.supports_display_v2 = True + assert modern_display(c) is True + + +@pytest.mark.parametrize("kind", ["fail", "stuck", "green"]) +def test_bitmap_has_transparent_margin_and_does_not_obscure_fallback_text(kind): + original = [text_element("ci")] + frame = ci_bitmap_accent(original, kind, 10) + icon = frame[-1] + lines = icon["data"].splitlines() + assert lines[:3] == ["! XPM2", "7 7 2 1", ". c None"] + assert len(lines[4:]) == 7 and all(len(row) == 7 for row in lines[4:]) + assert icon["x"] + 7 <= frame[0]["x"] + assert frame[0]["x"] + frame[0]["width"] == 72 + assert icon["timeout"] == 10 + assert original == [text_element("ci")]