From 89ae63240b23d4c7f759d03b7f49f07f7a49392a Mon Sep 17 00:00:00 2001 From: Jono Date: Fri, 24 Jul 2026 11:12:39 -0700 Subject: [PATCH 1/4] Add live system meters (issue #40) + WebSocket auth-reject robustness Meters/sensors feature: - SensorSource / SensorManager with refcounted, layout-driven polling: sources are only polled while the active layout has a meter bound to them, so switching to a meter-less layout (e.g. firefox) stops all psutil reads. psutil-backed CPU% and MEM% sources work identically on Linux/macOS. - Server sensor pump broadcasts widget_update frames on change; client MeterCell + useMeterStore render the readout and bar, with localStorage persistence and stale handling. - default.yaml: CPU/MEM meters; the ctrl+t key button is commented out (kept as a worked example) since the meters now own its grid slot. Fixes found while getting it running end-to-end: - Reconnect storm / no password gate: useMeterStore returned a fresh object each render, so App's onWidgetUpdate (and the socket effect that depends on it) churned every render, tearing down the WebSocket before it could authenticate. Depend on the stable onUpdate; memoise the store. - WebSocket auth rejection: browsers dropped the unauthorized frame when the daemon closed immediately after sending it. Add a short grace before close and a dedicated close code (4401) the client also treats as unauthorized; guard the send against an already-closing transport. - SPA fallback route called the nonexistent _req.response(...), 500ing on GET / when serving the built client. Use web.Response. - Daemon fails fast with PortInUseError (clear message, exit 1) instead of a raw OSError traceback when the port is already bound. Tooling/docs: - just kill: free the daemon (:8765) and Vite (:5173) ports. - README: Tailscale-dev troubleshooting for the "page loads but wss won't connect / no gate" case. - Tests: meters, sensors, port-in-use, 4401 close code, meter-store identity stability. Co-Authored-By: Claude Opus 4.8 --- Justfile | 24 +++ README.md | 43 +++++ client/screenshot-meters.mjs | 16 ++ client/src/App.tsx | 64 +++++++- client/src/ButtonGrid.tsx | 22 +++ client/src/MeterCell.test.tsx | 93 +++++++++++ client/src/MeterCell.tsx | 145 +++++++++++++++++ client/src/demo.ts | 53 +++++++ client/src/meter-store.test.tsx | 44 ++++++ client/src/meter-store.ts | 172 ++++++++++++++++++++ client/src/protocol.ts | 27 +++- client/src/socket.ts | 24 ++- client/src/style.css | 117 ++++++++++++++ daemon/deckd/__main__.py | 34 +++- daemon/deckd/layouts.py | 39 ++++- daemon/deckd/platform.py | 271 ++++++++++++++++++++++++++++++++ daemon/deckd/protocol.py | 29 +++- daemon/deckd/server.py | 256 +++++++++++++++++++++++++++++- layouts/default.yaml | 64 ++++++-- pyproject.toml | 6 + tests/test_meters.py | 254 ++++++++++++++++++++++++++++++ tests/test_sensors.py | 215 +++++++++++++++++++++++++ tests/test_server.py | 38 +++++ tests/test_server_auth.py | 8 +- 24 files changed, 2021 insertions(+), 37 deletions(-) create mode 100644 client/screenshot-meters.mjs create mode 100644 client/src/MeterCell.test.tsx create mode 100644 client/src/MeterCell.tsx create mode 100644 client/src/meter-store.test.tsx create mode 100644 client/src/meter-store.ts create mode 100644 tests/test_meters.py create mode 100644 tests/test_sensors.py diff --git a/Justfile b/Justfile index 68d620c..e3a6939 100644 --- a/Justfile +++ b/Justfile @@ -51,6 +51,30 @@ run-daemon: run-daemon-lan: deckd --host 0.0.0.0 --layouts-dir layouts --verbose +# Kill whatever is bound to the two ports we use: the daemon (:8765) and +# the Vite dev server (:5173). Handy when a stale daemon still holds the +# port (deckd now fails fast on that) or a dev server outlived its +# terminal, leaving the client with no backend. Reports free ports and +# no-ops cleanly when nothing is running. +kill: + #!/usr/bin/env bash + set -uo pipefail + for port in 8765 5173; do + pids=$(lsof -ti "tcp:$port" 2>/dev/null || true) + if [ -z "$pids" ]; then + echo ":$port already free" + continue + fi + echo "killing :$port -> $pids" + kill $pids 2>/dev/null || true + sleep 0.3 + pids=$(lsof -ti "tcp:$port" 2>/dev/null || true) + if [ -n "$pids" ]; then + echo " still alive, SIGKILL -> $pids" + kill -9 $pids 2>/dev/null || true + fi + done + # Run the daemon under a supervisor that restarts it when daemon/**/*.py # changes. Layout YAML hot-reload is built into the daemon itself; this is # only useful when editing Python. diff --git a/README.md b/README.md index 2a0ea33..ab2bffc 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,8 @@ Tailscale is **not** running an application proxy — `tailscale serve status` w That's why the URL you see in devtools is `wss://..ts.net:5173/ws` (Vite's port), not `:8765` (daemon's port). +**Troubleshooting — page loads but `wss://…:5173/ws` won't connect and no password prompt appears.** This is almost always the daemon being down, not a cert problem: the page loads over HTTPS (so the cert is fine, and `wss://` reuses it), but Vite's `/ws` proxy has no upstream to forward to, so the socket is dropped mid-handshake. Because the auth exchange never completes, the client never receives the `unauthorized` frame and the password gate never renders — it just loops on reconnect. Check `curl -s http://127.0.0.1:8765/health`; if it fails, (re)start the daemon. Note the password lives in per-origin `localStorage`, so entering it once at `localhost:5173` does **not** carry over to the `.ts.net:5173` origin — enter it again there. If the daemon itself won't start, it now fails fast with `cannot bind 127.0.0.1:8765 — another process is already listening there` (usually a stale `deckd`); clear it with `pkill -f bin/deckd` and relaunch. + **Contrast:** `tailscale serve` **(persistent URL, no dev server).** If you want an installable PWA at `https://..ts.net/` (no port, works without any process running on the desktop besides the daemon), that's a different setup — `just build-client` + `just run-daemon` + `tailscale serve --bg 8765`. Tailscale proxies `:443 → 127.0.0.1:8765`, the daemon serves the built `client/dist/`. You lose HMR but gain a URL that survives closing your dev terminals. Not covered by any `just` recipe yet — file an issue if you want one. ### Client chrome @@ -644,6 +646,47 @@ The daemon also loads a sibling directory next to `--layouts-dir` whose name is This is how `layouts.macos/firefox.yaml` carries the `super+t` / `super+[` / `super+]` shortcuts without forking the rest of `firefox.yaml` for every Linux user who pulls the repo. +### Live widgets (the `meter` kind) + +A layout can include widgets that display values pushed by the daemon in real time. Today the only kind is `meter` (a numeric readout with a horizontal bar). It looks like a button in the grid, doesn't react to taps, and renders the value the daemon keeps pushing on the bound sensor source. + +```yaml +- id: cpu_percent + kind: meter + label: CPU + icon: + source: lucide + name: cpu + source: cpu_percent # daemon-side sensor name + min: 0 # bar's left edge (default 0) + max: 100 # bar's right edge (default 100) + grid: [2, 2, 1, 1] +``` + +The daemon polls the bound sensor on a timer and pushes a `widget_update` WebSocket frame every time the value changes (or the source flips stale). The bar fills proportionally between `min` and `max` and is color-graded cool→hot so a glance tells you whether the number is OK before you read it. + +Built-in sensors (all psutil-backed — see "Why no CPU temperature?" below for the rationale): + +| Source | What it shows | How | Poll | +|----------------|--------------------------------|--------------------------------------------------|--------| +| `cpu_percent` | Whole-system CPU utilisation | `psutil.cpu_percent(interval=None)` — delta since the last call. The first reading is `0.0` (no baseline); subsequent readings land in `[0, 100]`. | 1s | +| `mem_percent` | Memory used / total | `psutil.virtual_memory().percent` | 1s | +| *(more TBD)* | CPU frequency, battery, swap | `psutil.cpu_freq()`, `psutil.sensors_battery()`, `psutil.swap_memory()` — all the same API. Open to contributions. | varies | + +#### Why no CPU temperature? + +We deliberately don't ship a `cpu_temp` source. The short version is that **Apple Silicon doesn't expose a stable, unprivileged CPU temperature API**: + +- `psutil.sensors_temperatures()` has **no macOS backend at all** (verified against the upstream source — there's no IOKit call, no SMC probe, no entitlement handling). +- `osx-cpu-temp` (Homebrew, ~100 lines of C) and `istats` (the `iStats` Ruby gem) both read classic Intel SMC keys (`TC0P`, `TC0D`, `TC0E`). Apple Silicon uses a completely different sensor namespace that Apple doesn't document and that changes per SoC generation; the brew arm64 bottle exists because the binary compiles and runs, not because it returns valid temperature data. +- The only reliable M-series source is Apple's own `sudo powermetrics`, which requires root, an undocumented/unstable output format, and either a privileged helper or interactive sudo prompts. + +Net result: a cross-platform `cpu_temp` source would either silently fail on most Apple Silicon Macs (deceptive) or require a deployment story heavier than the rest of deckd put together (overkill). `cpu_percent` and `mem_percent` cover the same "is the box healthy" use case and work on every Linux + every macOS without any per-OS install step. Users who really want CPU temp on Linux specifically can keep a custom layout pointing at `/sys/class/thermal` (we removed the in-tree reader because nothing on macOS could share the code path; bringing it back is a small PR). + +The meter rendering is a regular cell in the grid (it picks up `--content-scale` and the user's Button-size preference like every other widget). When the daemon hasn't pushed a value yet, the cell renders "—" with the bar at 0% and a dashed border so you can see at a glance it's waiting. + +Try it without sensors: `?demo=meter` loads a backend-free demo with seeded CPU%/MEM% values so you can see the meter without a running daemon. + ## Under the hood The pieces behind the features above, for anyone reading the code: diff --git a/client/screenshot-meters.mjs b/client/screenshot-meters.mjs new file mode 100644 index 0000000..369d332 --- /dev/null +++ b/client/screenshot-meters.mjs @@ -0,0 +1,16 @@ +import { chromium } from "playwright-core"; + +const chromePath = "/nix/store/6qv40p5vmxqg9yr55qm2hibrxny7i553-playwright-chromium/chrome-linux64/chrome"; +const browser = await chromium.launch({ executablePath: chromePath, headless: true }); +const ctx = await browser.newContext({ + viewport: { width: 480, height: 320 }, + deviceScaleFactor: 2, +}); +const page = await ctx.newPage(); +page.on("pageerror", (err) => console.log("[pageerror]", err.message)); +// Meter demo so we don't have to deal with the no-auth + WS issue +await page.goto("http://127.0.0.1:8766/index.html?demo=meter", { waitUntil: "networkidle" }); +await page.waitForTimeout(1500); +await page.screenshot({ path: "/tmp/deckd-meters-demo.png", fullPage: false }); +console.log("saved meter demo"); +await browser.close(); diff --git a/client/src/App.tsx b/client/src/App.tsx index 80ec3dc..c800c0e 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Settings as SettingsIcon } from "lucide-react"; import { PointerIcon } from "lucide-react"; import { useDeckdSocket } from "./socket"; @@ -7,6 +7,7 @@ import { JogStrip } from "./JogStrip"; import { ManualControl } from "./ManualControl"; import { PasswordGate } from "./PasswordGate"; import { Settings } from "./Settings"; +import { useMeterStore } from "./meter-store"; import { useBottomScale, useContentScale, @@ -18,7 +19,7 @@ import { } from "./settings-store"; import type { CSSProperties } from "react"; import { useWakeLock } from "./wake-lock"; -import { getDemoLayout } from "./demo"; +import { getDemoLayout, METER_DEMO_SEEDS } from "./demo"; import { Icon } from "./Icon"; import type { JogHandle } from "./JogStrip"; import type { Icon as IconRef, ServerLayout } from "./protocol"; @@ -49,8 +50,63 @@ export function App() { const [layout, setLayout] = useState(demoLayout); const [view, setView] = useState("layout"); const onLayout = useCallback((m: ServerLayout) => setLayout(m), []); + // Track every meter widget id in the active layout so the meter + // store can drop entries that are no longer referenced — without + // this, a removed meter would keep its last reading in memory + // forever, and a layout-switch that hides meters would still + // remember them across reloads. + const activeMeterIds = useMemo(() => { + const ids = new Set(); + if (layout) { + for (const w of layout.widgets) { + if (w.kind === "meter") ids.add(w.id); + } + } + return ids; + }, [layout]); + const meter = useMeterStore(activeMeterIds); + // Pull out the store's ``onUpdate`` (a stable useCallback) and feed + // widget_update frames straight to it. Depending on the whole ``meter`` + // object instead would be a bug: it gets a fresh identity on every render + // (and on every 1Hz readings push), so any effect keyed on it — notably the + // socket effect below — would re-fire and tear down + reconnect the + // WebSocket in a tight loop. The connection would never stay open long + // enough to authenticate, so the password gate would never show. + const pushReading = meter.onUpdate; + const onWidgetUpdate = pushReading; + // Demo-mode seeding (issue #40). The meter demo fixture never + // receives server pushes (demo mode disables the socket). Seed the + // meter store with a realistic CPU temp so the bar renders with + // something to look at instead of staying at the "—" empty state. + // This is a one-shot write per id — subsequent seeds with the same + // id are no-ops, so a layout switch that lands back on the meter + // demo doesn't keep re-seeding. + useEffect(() => { + if (!demoLayout) return; + for (const w of demoLayout.widgets) { + if (w.kind !== "meter" || !w.source) continue; + const seed = METER_DEMO_SEEDS[w.id]; + if (!seed) continue; + // Synthesise a ServerWidgetUpdate-like frame the store accepts. + // Treated as live (stale=false) so the demo shows a coloured + // bar; without a real source flipping the freshness, a demoed + // meter would auto-dim after STALE_AFTER_S. + void seed; + // Trigger an immediate push by writing to localStorage and + // triggering a state bump: easier and side-effect-free here is + // to just call onUpdate via a synthetic frame. + pushReading({ + type: "widget_update", + id: w.id, + source: w.source, + value: seed.value, + unit: seed.unit, + stale: false, + }); + } + }, [demoLayout, pushReading]); const { status, send, authenticate, deauthenticate, hasPassword } = - useDeckdSocket(onLayout, { enabled: !demoLayout }); + useDeckdSocket(onLayout, onWidgetUpdate, { enabled: !demoLayout }); // Track whether we've already handed the socket a password this session, so // the gate can say "incorrect" on a repeat rejection rather than on first // contact (where the stored password was simply empty). @@ -173,6 +229,8 @@ export function App() { onJogEnd={jogEnd} scrollScale={scroll.scale} scrollInvert={scroll.invert} + meterReadings={meter.readings ?? undefined} + labelScale={labelScale.scale} /> ) : (
waiting for daemon…
diff --git a/client/src/ButtonGrid.tsx b/client/src/ButtonGrid.tsx index b0c7302..d3d60f5 100644 --- a/client/src/ButtonGrid.tsx +++ b/client/src/ButtonGrid.tsx @@ -2,6 +2,8 @@ import type { CSSProperties } from "react"; import type { Widget } from "./protocol"; import { Icon } from "./Icon"; import { JogStrip } from "./JogStrip"; +import { MeterCell } from "./MeterCell"; +import type { MeterReading } from "./meter-store"; import { transposeWidgets, useOrientation } from "./orientation"; import type { Orientation } from "./orientation"; @@ -17,6 +19,13 @@ type Props = { * device stories pass it so the transpose matches the container's shape * rather than the window's. */ orientation?: Orientation; + /** Latest reading per meter widget id. Missing ids render with no + * value (bar empty, "—" numeric). Stale readings show the bar at + * its last position with a dimmed readout. */ + meterReadings?: Record; + /** Multiplier for the meter widget's caption label so it scales with + * the same user-facing "label size" preference as buttons. */ + labelScale?: number; }; const FALLBACK_DIM = 4; @@ -46,6 +55,8 @@ export function ButtonGrid({ scrollScale, scrollInvert, orientation: orientationOverride, + meterReadings, + labelScale, }: Props) { const autoOrientation = useOrientation(); const orientation = orientationOverride ?? autoOrientation; @@ -89,6 +100,17 @@ export function ButtonGrid({ /> ); } + if (w.kind === "meter") { + return ( + + ); + } const buttonStyle: CSSProperties = w.color ? { ...style, backgroundColor: w.color } : style; diff --git a/client/src/MeterCell.test.tsx b/client/src/MeterCell.test.tsx new file mode 100644 index 0000000..5d3140c --- /dev/null +++ b/client/src/MeterCell.test.tsx @@ -0,0 +1,93 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { MeterCell } from "./MeterCell"; +import type { Widget } from "./protocol"; +import type { MeterReading } from "./meter-store"; + +const CPU_WIDGET: Widget = { + id: "cpu_percent", + kind: "meter", + label: "CPU", + source: "cpu_percent", + min: 0, + max: 100, + grid: [0, 0, 1, 1], +}; + +function renderMeter(widget: Widget, reading: MeterReading | null) { + return render(); +} + +describe("MeterCell", () => { + afterEach(cleanup); + + it("renders the empty-state when no reading has arrived", () => { + renderMeter(CPU_WIDGET, null); + // Em-dash placeholder so the cell still has a number-shaped mark + // to align against — better than a blank space when neighbouring + // buttons are showing live values. + expect(screen.getByText("—")).not.toBeNull(); + expect(screen.getByRole("meter")).not.toBeNull(); + }); + + it("renders the value and unit when a reading is present", () => { + renderMeter(CPU_WIDGET, { + value: 58, + unit: "°C", + stale: false, + }); + expect(screen.getByText("58")).not.toBeNull(); + expect(screen.getByText("°C")).not.toBeNull(); + }); + + it("formats sub-10 values with one decimal place", () => { + renderMeter(CPU_WIDGET, { + value: 8.4, + unit: "°C", + stale: false, + }); + expect(screen.getByText("8.4")).not.toBeNull(); + }); + + it("clamps the bar fill fraction to 0..1", () => { + // Above max: bar should be 100% (clamped). Below min: 0%. + renderMeter(CPU_WIDGET, { value: 150, unit: "°C", stale: false }); + const fill = screen.getByRole("meter").firstElementChild as HTMLElement; + expect(fill.style.width).toBe("100%"); + + cleanup(); + + renderMeter(CPU_WIDGET, { value: -20, unit: "°C", stale: false }); + const fill2 = screen.getByRole("meter").firstElementChild as HTMLElement; + expect(fill2.style.width).toBe("0%"); + }); + + it("marks the cell stale when the reading is stale", () => { + renderMeter(CPU_WIDGET, { value: 42, unit: "°C", stale: true }); + const meter = screen.getByRole("meter"); + // Walk up to the parent cell so the test is robust to refactors + // of the inner DOM (the class lives on the .cell-meter element). + const cell = meter.closest(".cell-meter") as HTMLElement; + expect(cell.classList.contains("cell-meter-stale")).toBe(true); + }); + + it("does not crash when min == max", () => { + // Defensive guard against a malformed fixture or future config + // that drops the min/max pair. Should render something — not + // throw. + const bad: Widget = { ...CPU_WIDGET, min: 50, max: 50 }; + expect(() => renderMeter(bad, { value: 50, unit: "°C", stale: false })).not.toThrow(); + }); + + it("falls back to a 0..100 range when min/max are unset", () => { + const openRange: Widget = { + ...CPU_WIDGET, + min: undefined, + max: undefined, + }; + renderMeter(openRange, { value: 50, unit: "°C", stale: false }); + const fill = screen.getByRole("meter").firstElementChild as HTMLElement; + expect(fill.style.width).toBe("50%"); + }); +}); diff --git a/client/src/MeterCell.tsx b/client/src/MeterCell.tsx new file mode 100644 index 0000000..bd09641 --- /dev/null +++ b/client/src/MeterCell.tsx @@ -0,0 +1,145 @@ +import { useMemo } from "react"; +import type { CSSProperties } from "react"; +import type { Widget } from "./protocol"; +import { Icon } from "./Icon"; +import type { MeterReading } from "./meter-store"; + +type Props = { + widget: Widget; + reading: MeterReading | null; + style?: CSSProperties; + labelScale?: number; +}; + +/** + * Meter widget: a live numeric readout with a horizontal bar that fills + * proportionally to the value. Color-graded cool → hot (blue → green → + * yellow → red) so a glance at the bar conveys "is this OK or not" before + * the user reads the number. + * + * Rendering rules: + * * No reading yet (or stale with no prior value): bar at 0%, value + * rendered as "—". Stays visible — the bar position holds the last + * known value, only the freshness label changes. + * * Reading stale with a prior value: bar stays where it was, + * numeric readout dimmed to 60% so it's clear the value is + * historical, not live. + * * Value out of range: bar clamps to its end. We intentionally do + * NOT cap the underlying reading — the daemon still receives the + * true value and the user sees the actual number; only the bar's + * visual fill clamps. + * * Color grade: derived from the bar's current fill fraction + * (clamped 0..1) so a meter with a non-default min/max still + * paints the same colors at the same fraction. The default + * 0..100 °C range maps fraction 0.0 = cold, 1.0 = red; the same + * colors map to the same fractions for any other range. + */ +export function MeterCell({ widget, reading, style, labelScale = 1 }: Props) { + const min = widget.min ?? 0; + const max = widget.max ?? 100; + // Range validity guard — the daemon validates this in layouts.py, + // but a malformed fixture shouldn't crash rendering. If max <= min, + // collapse the bar to 0% rather than NaN-ing the fill calculation. + const range = max > min ? max - min : 1; + const rawFraction = reading ? (reading.value - min) / range : 0; + const fraction = Math.max(0, Math.min(1, rawFraction)); + + // Color gradient sampled at three points so the CSS linear-gradient + // interpolation stays smooth. Cool blue → green (mid) → red (full). + // Hue/lightness tuned for the dark chrome background; saturations + // are high enough to read as "alert" but not so high they clash with + // themed buttons next door. + const color = useMemo(() => interpolateMeterColor(fraction), [fraction]); + + const valueLabel = reading + ? formatValue(reading.value) + : "—"; + const unitLabel = reading?.unit ?? ""; + const stale = !reading || reading.stale; + + const numericStyle: CSSProperties = { + color: stale ? "rgba(230, 233, 239, 0.6)" : "#e6e9ef", + }; + + return ( +
+ {widget.icon ? ( + + ) : null} +
+ + {valueLabel} + + {unitLabel ? {unitLabel} : null} +
+
+
+
+ {widget.label ? ( + + {widget.label} + + ) : null} +
+ ); +} + +function formatValue(v: number): string { + if (!Number.isFinite(v)) return "—"; + // 0 decimals for typical 30-90 °C CPU temps; 1 decimal once we drop + // below 10 to preserve precision. ``toFixed``-vs-``Math.round`` + // threshold is a stylistic call — picked 10 °C because that's where + // the difference between "32" and "32.4" starts being noticeable. + if (Math.abs(v) >= 10) return Math.round(v).toString(); + return v.toFixed(1); +} + +/** + * Map a 0..1 fill fraction to a CSS colour string along the + * cool→warm gradient. We interpolate in HSL so the cool blue stays + * blue rather than washing out to grey, and the warm end stays red + * rather than going brown. + * + * Three stops (0.0 blue, 0.5 green, 1.0 red) split the hue range + * evenly; saturation and lightness are held constant. + */ +function interpolateMeterColor(t: number): string { + const clamped = Math.max(0, Math.min(1, t)); + // Stops: (hue, saturation, lightness) + const stops: [number, number, number][] = [ + [210, 80, 55], // blue + [140, 70, 50], // green + [15, 85, 55], // red-orange + ]; + if (clamped <= 0.5) { + return mix(stops[0], stops[1], clamped / 0.5); + } + return mix(stops[1], stops[2], (clamped - 0.5) / 0.5); +} + +function mix( + a: [number, number, number], + b: [number, number, number], + t: number, +): string { + const hue = a[0] + (b[0] - a[0]) * t; + const sat = a[1] + (b[1] - a[1]) * t; + const light = a[2] + (b[2] - a[2]) * t; + return `hsl(${hue}, ${sat}%, ${light}%)`; +} diff --git a/client/src/demo.ts b/client/src/demo.ts index 354a383..8759b83 100644 --- a/client/src/demo.ts +++ b/client/src/demo.ts @@ -60,10 +60,63 @@ const SHOWCASE: ServerLayout = { ], }; +// Backend-free meter demo (issue #40). Mirrors what the daemon would +// push for a real CPU%/MEM% source — a couple of realistic values so +// the bar's color-graded fill renders across the spectrum. Wired via +// the meter store's localStorage hydration on mount, so opening +// ``/?demo=meter`` lands in a "fresh layout, pre-populated values" +// state without needing the daemon at all. +const METER: ServerLayout = { + type: "layout", + app: "meter (demo)", + display_name: "Meter demo", + theme: "#1d4ed8", + jogstrip_enabled: true, + widgets: [ + { + id: "cpu_percent", + kind: "meter", + label: "CPU", + icon: { source: "lucide", name: "cpu" }, + source: "cpu_percent", + min: 0, + max: 100, + grid: [0, 0, 2, 1], + }, + { + id: "mem_percent", + kind: "meter", + label: "MEM", + icon: { source: "lucide", name: "memory-stick" }, + source: "mem_percent", + min: 0, + max: 100, + grid: [0, 1, 2, 1], + }, + { id: "open-url", kind: "button", label: "example.com", icon: { source: "simple-icons", name: "firefox" }, grid: [2, 0, 1, 1] }, + { id: "tilix", kind: "button", label: "Tilix", icon: { source: "lucide", name: "square-terminal" }, grid: [3, 0, 1, 1] }, + { id: "audio-toggle", kind: "button", label: "VLC", icon: { source: "lucide", name: "play" }, grid: [2, 1, 1, 1] }, + { id: "send-key-tab", kind: "button", label: "Ctrl+T", icon: { source: "lucide", name: "keyboard" }, grid: [3, 1, 1, 1] }, + ], +}; + +// Seed values for the meter demo so the bar renders with realistic +// values on first paint (localStorage hydration, see meter-store.ts). +// These are written into localStorage by the app on first mount when +// the meter demo is selected, and cleared when the user navigates +// away to a non-meter demo. Keeping the seeds in the same file as the +// fixture means a designer iterating on the meter layout doesn't have +// to remember to update two places. +export const METER_DEMO_SEEDS: Record = { + cpu_percent: { value: 58, unit: "%" }, + mem_percent: { value: 41, unit: "%" }, +}; + const DEMOS: Record = { firefox: FIREFOX, default: DEFAULT, showcase: SHOWCASE, + meter: METER, }; /** The demo fixtures, keyed by name — for the gallery and Ladle stories. */ diff --git a/client/src/meter-store.test.tsx b/client/src/meter-store.test.tsx new file mode 100644 index 0000000..8f33a1e --- /dev/null +++ b/client/src/meter-store.test.tsx @@ -0,0 +1,44 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { useMeterStore } from "./meter-store"; + +const IDS = new Set(["cpu_percent"]); + +beforeEach(() => window.localStorage.clear()); +afterEach(() => window.localStorage.clear()); + +describe("useMeterStore identity stability", () => { + // Regression guard (issue #40): App feeds widget_update frames to + // ``meter.onUpdate`` and puts it in the socket effect's dependency array. + // If ``onUpdate`` got a fresh identity per render — or per readings push — + // the socket would tear down and reconnect in a tight loop, never staying + // open long enough to authenticate, so the password gate never appeared. + it("keeps onUpdate referentially stable across renders", () => { + const { result, rerender } = renderHook(() => useMeterStore(IDS)); + const first = result.current.onUpdate; + rerender(); + rerender(); + expect(result.current.onUpdate).toBe(first); + }); + + it("keeps onUpdate stable even after a readings push re-renders the hook", () => { + const { result } = renderHook(() => useMeterStore(IDS)); + const before = result.current.onUpdate; + act(() => { + result.current.onUpdate({ + type: "widget_update", + id: "cpu_percent", + source: "cpu_percent", + value: 42, + unit: "%", + stale: false, + }); + }); + // The push changes ``readings`` (so the memoised store object legitimately + // gets a new identity), but ``onUpdate`` itself must not change — that is + // what the socket effect depends on. + expect(result.current.readings.cpu_percent?.value).toBe(42); + expect(result.current.onUpdate).toBe(before); + }); +}); diff --git a/client/src/meter-store.ts b/client/src/meter-store.ts new file mode 100644 index 0000000..b8874ba --- /dev/null +++ b/client/src/meter-store.ts @@ -0,0 +1,172 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { ServerWidgetUpdate } from "./protocol"; + +/** Latest reading for one widget id, or ``null`` if no reading has arrived + * yet (or the widget was just hidden by a layout change). Unit lives on + * the reading because it's the sensor's, not the widget's, and different + * sources could (in principle) bind to the same widget id over time. */ +export type MeterReading = { + value: number; + unit: string; + stale: boolean; +}; + +const METER_KEY_PREFIX = "deckd.meter."; +// Stale the stored value on reconnect after this many seconds without a +// fresh push. The daemon normally re-pushes on the first poll after a +// session opens; if it doesn't (e.g. a sensor was never available), the +// UI shouldn't claim an ancient value is current forever. +const STALE_AFTER_S = 10; + +/** Read the persisted last-known readings from localStorage. Used as a + * ``useState`` lazy initializer so the initial render already shows + * the last value rather than flashing "—" before an effect fires. */ +function loadInitial(): Record { + if (typeof window === "undefined") return {}; + const initial: Record = {}; + for (let i = 0; i < window.localStorage.length; i++) { + const key = window.localStorage.key(i); + if (!key || !key.startsWith(METER_KEY_PREFIX)) continue; + try { + const parsed = JSON.parse(window.localStorage.getItem(key) ?? "null"); + if ( + parsed && + typeof parsed.value === "number" && + typeof parsed.unit === "string" + ) { + initial[key.slice(METER_KEY_PREFIX.length)] = { + value: parsed.value, + unit: parsed.unit, + stale: true, + }; + } + } catch { + // ignore corrupted entries + } + } + return initial; +} + +/** + * Hook: hold a map of widget-id -> latest reading, fed by + * ``widget_update`` WebSocket frames. + * + * Returned API: + * ``readings`` — current map (id -> reading). Render-time consumers + * read this directly; ``MeterCell`` filters by its own widget id. + * ``onUpdate(frame)`` — feed in a server frame. + * + * One shared instance is held at the ``App`` level so every meter cell + * subscribes to the same map without prop-drilling. Entries whose ids + * are not in ``activeWidgetIds`` are filtered out at render time so a + * removed meter doesn't leave ghost values on screen, and re-adding + * the same id later reads the cached value back from localStorage. + */ +export function useMeterStore(activeWidgetIds: ReadonlySet) { + const [readings, setReadings] = useState>(loadInitial); + // Ref-mirror so the stale-detection interval (which calls setState) + // doesn't need to depend on the readings state — otherwise every push + // would tear down and re-create the timer. + const readingsRef = useRef(readings); + readingsRef.current = readings; + + const onUpdate = useCallback((m: ServerWidgetUpdate) => { + setReadings((prev) => { + // Skip the state update + storage write entirely if the value + // hasn't changed. A 1Hz push of an unchanged CPU temp would + // otherwise mean a re-render of every meter on every tick, which + // (a) wastes work and (b) re-fires the meter-cell's transition + // animation. We compare on the three fields the UI actually + // renders: value, stale flag, and unit. + const existing = prev[m.id]; + if ( + existing && + existing.value === m.value && + existing.stale === m.stale && + existing.unit === m.unit + ) { + return prev; + } + const next = { + ...prev, + [m.id]: { value: m.value, unit: m.unit, stale: m.stale }, + }; + try { + // Persist the most recent reading so a page reload doesn't + // flash a blank meter until the next push lands. The full set + // is small (a handful of widgets per layout), so a single + // JSON blob per id keeps writes cheap and survives quota + // pressure better than one key per push. + window.localStorage.setItem( + `${METER_KEY_PREFIX}${m.id}`, + JSON.stringify({ value: m.value, unit: m.unit, stale: m.stale, source: m.source }), + ); + } catch { + // Private mode / quota: stay in memory only. + } + return next; + }); + }, []); + + // Mark everything stale after ``STALE_AFTER_S`` of no updates. This + // is the reconnect-with-no-push case: a fresh page open that hasn't + // seen a server frame yet should NOT show the last value as fresh. + // The interval is registered once (no deps) and reads the latest + // readings via a ref so it doesn't tear itself down on every push. + useEffect(() => { + const id = window.setInterval(() => { + const cur = readingsRef.current; + let changed = false; + const next: Record = { ...cur }; + for (const [wid, r] of Object.entries(cur)) { + if (!r.stale) { + next[wid] = { ...r, stale: true }; + changed = true; + } + } + if (changed) setReadings(next); + }, STALE_AFTER_S * 1000); + return () => window.clearInterval(id); + }, []); + + // Filter the live map down to just the ids the active layout + // references. Done as a render-time memo (not an effect) so React + // schedules a single render when both the readings map and the + // active-id set change; an effect-based reap would render once + // with stale entries, then again after the effect fires. + const visibleReadings = useMemo(() => { + if (readings === EMPTY_RECORD) return readings; + let filtered: Record | null = null; + for (const id of Object.keys(readings)) { + if (!activeWidgetIds.has(id)) { + filtered = filtered ?? { ...readings }; + delete filtered[id]; + } + } + return filtered ?? readings; + }, [readings, activeWidgetIds]); + + const reading = useCallback( + (id: string): MeterReading | null => readings[id] ?? null, + [readings], + ); + + // Memoise the returned object so its identity only changes when one of + // its members actually changes. Consumers that put the store in an effect + // dependency array (e.g. App's demo-seed effect) then don't re-run on + // unrelated renders. ``onUpdate`` is independently stable, so callers that + // only need it (the socket's widget_update handler) can — and must — depend + // on ``meter.onUpdate`` rather than the whole object to avoid reconnecting. + return useMemo( + () => ({ reading, readings: visibleReadings, onUpdate }), + [reading, visibleReadings, onUpdate], + ); +} + +// Sentinel empty record used to skip the filter pass when nothing has +// been pushed yet. ``useMemo``'s identity stability depends on the +// return value, so a fresh ``{}`` literal every render would force the +// memo to recompute even when there's nothing to filter. +const EMPTY_RECORD: Record = Object.freeze({}); + +export const _INTERNAL = { METER_KEY_PREFIX }; diff --git a/client/src/protocol.ts b/client/src/protocol.ts index 223cbab..440bd08 100644 --- a/client/src/protocol.ts +++ b/client/src/protocol.ts @@ -6,13 +6,24 @@ export type Icon = { source: string; name: string }; export type Widget = { id: string; - kind: "button" | "jogstrip" | "trackpad"; + kind: "button" | "jogstrip" | "trackpad" | "meter"; label?: string | null; icon?: Icon | null; grid: [number, number, number, number]; /** Optional CSS colour string; applied as the button's background. */ color?: string | null; action?: Record | null; + /** + * Meter-only: the daemon-side sensor source name the widget + * subscribes to (e.g. ``"cpu_percent"``, ``"mem_percent"``). Opaque + * to the client — the daemon validates it; unknown sources just + * leave the widget stale. + */ + source?: string | null; + /** Meter-only: lower bound of the bar's visible range. */ + min?: number | null; + /** Meter-only: upper bound of the bar's visible range. */ + max?: number | null; }; export type ServerLayout = { @@ -38,6 +49,19 @@ export type ServerLayout = { export type ServerState = { type: "state"; locked: boolean }; export type ServerBrightness = { type: "brightness"; value: number }; +/** Live value for a meter widget (issue #40). Pushed at the sensor's + * poll cadence; the client renders the bar + numeric readout from + * ``value`` / ``unit``. ``stale=true`` means the source could not + * refresh — the UI keeps the last known position but stops claiming + * the value is fresh. */ +export type ServerWidgetUpdate = { + type: "widget_update"; + id: string; + source: string; + value: number; + unit: string; + stale: boolean; +}; /** Sent by the daemon to a non-loopback client whose ``hello`` omitted or * got the shared password wrong (issue #16); the socket is closed straight * after. The client swaps in the password prompt. */ @@ -46,6 +70,7 @@ export type ServerMessage = | ServerLayout | ServerState | ServerBrightness + | ServerWidgetUpdate | ServerError; export type ClientHello = { diff --git a/client/src/socket.ts b/client/src/socket.ts index b52c0a3..2c7cd62 100644 --- a/client/src/socket.ts +++ b/client/src/socket.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import type { ClientMessage, ServerMessage } from "./protocol"; +import type { ClientMessage, ServerLayout, ServerMessage, ServerWidgetUpdate } from "./protocol"; type Status = "connecting" | "open" | "closed" | "unauthorized"; @@ -7,6 +7,11 @@ type Status = "connecting" | "open" | "closed" | "unauthorized"; // ``deckd.*`` namespace is shared with the per-device settings store. const PASSWORD_KEY = "deckd.password"; +// Application-defined WebSocket close code the daemon uses to signal an auth +// rejection (must match server.py WS_CLOSE_UNAUTHORIZED). Keying off the close +// code makes the gate robust to browsers dropping the rejection data frame. +const UNAUTHORIZED_CLOSE_CODE = 4401; + function loadStoredPassword(): string { try { return window.localStorage.getItem(PASSWORD_KEY) ?? ""; @@ -36,7 +41,8 @@ function readPinnedLayout(): string { } export function useDeckdSocket( - onLayout: (m: Extract) => void, + onLayout: (m: ServerLayout) => void, + onWidgetUpdate: (m: ServerWidgetUpdate) => void, options: { enabled?: boolean } = {}, ) { const { enabled = true } = options; @@ -96,6 +102,7 @@ export function useDeckdSocket( try { const msg = JSON.parse(ev.data) as ServerMessage; if (msg.type === "layout") onLayout(msg); + else if (msg.type === "widget_update") onWidgetUpdate(msg); else if (msg.type === "error" && msg.reason === "unauthorized") { // Wrong/absent password: stop reconnecting and prompt the user. unauthorizedRef.current = true; @@ -107,7 +114,16 @@ export function useDeckdSocket( } }; - ws.onclose = () => { + ws.onclose = (ev) => { + // Dedicated "unauthorized" close code (see daemon WS_CLOSE_UNAUTHORIZED). + // Browsers can drop the app-level rejection frame when the daemon closes + // right after sending it, so the onmessage handler above may never fire; + // the close code survives that race, so treat it as the gate trigger too. + if (ev.code === UNAUTHORIZED_CLOSE_CODE) { + unauthorizedRef.current = true; + setStatus("unauthorized"); + return; + } if (stopped || unauthorizedRef.current) { if (!unauthorizedRef.current) setStatus("closed"); return; @@ -129,7 +145,7 @@ export function useDeckdSocket( if (timer) window.clearTimeout(timer); wsRef.current?.close(); }; - }, [onLayout, enabled, gen]); + }, [onLayout, onWidgetUpdate, enabled, gen]); const send = (msg: ClientMessage) => { const ws = wsRef.current; diff --git a/client/src/style.css b/client/src/style.css index 2e25e77..1e318ed 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -754,3 +754,120 @@ button:focus-visible { outline: none; } .settings-logout:active { background: #3a1c20; } + +/* --------------------------------------------------------------------------- + * Meter widget (issue #40): a live value with a horizontal bar that fills + * proportionally to the value. Visually parallel to a button cell so it + * reads as a first-class grid citizen; the bar gives a glance-readable + * "is this OK" answer before the user reads the number. + * + * Layout: + * ┌────────────────────────┐ + * │ [icon] │ + * │ 58 │ <- .meter-readout (big number + unit) + * │ °C │ + * │ ▰▰▰▰▰▰▰▱▱▱ │ <- .meter-bar (track + fill) + * │ CPU │ <- .meter-label (caption) + * └────────────────────────┘ + * + * Color-graded fill (cool blue → green → red) is applied inline by the + * component; the track stays neutral so the fill stands out. + * ------------------------------------------------------------------------- */ + +.cell-meter { + background: #1b222a; + border: 1px solid #2a333d; + border-radius: 12px; + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: center; + gap: 4px; + padding: 10px 12px; + /* Meter cells aren't pressable — they're informational. Block the + default tap-highlight from breaking the visual rest state. */ + user-select: none; + -webkit-user-select: none; + touch-action: none; + text-align: center; + min-height: 0; + min-width: 0; + overflow: hidden; +} + +/* Stale treatment: dim the border + background so a meter that hasn't + * seen a fresh push reads as "waiting" rather than asserting a live + * number. The numeric readout dims further in MeterCell. */ +.cell-meter-stale { + border-style: dashed; + border-color: #2a333d; + background: #161b21; +} + +.cell-meter .icon { + width: calc(clamp(20px, 5vw, 28px) * var(--content-scale)); + height: calc(clamp(20px, 5vw, 28px) * var(--content-scale)); + color: currentColor; + align-self: center; +} + +.meter-readout { + display: flex; + align-items: baseline; + justify-content: center; + gap: 3px; + line-height: 1; + min-height: 0; + min-width: 0; +} + +.meter-value { + /* Big enough to read at arm's length on a phone; not so big it pushes + the bar off-cell on a 1x1 grid slot. Scales with content-scale so + the user's Button-size setting covers this too. */ + font-size: calc(clamp(20px, 5vw, 30px) * var(--content-scale)); + font-weight: 700; + font-variant-numeric: tabular-nums; + letter-spacing: -0.01em; +} + +.meter-unit { + font-size: calc(clamp(11px, 1.6vw, 14px) * var(--content-scale)); + color: #7a8590; + font-weight: 500; +} + +.meter-bar { + /* Track: a thin neutral bar across the cell. Fill painted inline by + the component. The track is left visible at 0% fill so the bar + still reads as "a meter" even before the first push lands. */ + position: relative; + height: calc(8px * var(--content-scale)); + background: #2a333d; + border-radius: 999px; + overflow: hidden; +} + +.meter-bar-fill { + height: 100%; + border-radius: 999px; + /* Soft transition so a 1s polling cadence reads as a smooth crawl + rather than a stutter step. 250ms is short enough that an abrupt + value change still snaps visually. */ + transition: width 250ms ease, background 250ms ease; + /* Subtle inner glow makes the fill pop against the dark track. The + glow is dark-mode-friendly (no white halo) and stays at a fixed + color so a fill at any hue reads consistently. */ + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); +} + +.meter-label { + /* Caption under the bar — same scale rules as button labels so the + caption stays in step with the rest of the grid's text. */ + font-size: calc(clamp(9px, 1.5vw, 12px) * var(--content-scale) * var(--label-scale, 1)); + font-weight: 600; + line-height: 1.2; + color: #7a8590; + word-break: break-word; +} + diff --git a/daemon/deckd/__main__.py b/daemon/deckd/__main__.py index c0f3682..594c634 100644 --- a/daemon/deckd/__main__.py +++ b/daemon/deckd/__main__.py @@ -7,10 +7,12 @@ import sys from pathlib import Path +from aiohttp import web + from .auth import PasswordError, default_password_path, load_or_create_password from .input import LoggingKeySink, LoggingScrollSink, ScrollController, UinputSink -from .platform import default_backend -from .server import Server +from .platform import default_backend, default_sensor_manager +from .server import PortInUseError, Server async def _run(server: Server) -> None: @@ -166,15 +168,35 @@ def dbus_bus_factory(bus_type): focus_backend=focus_backend, overlay_dir=overlay_dir, password=password, + sensor_manager=default_sensor_manager(), ) if args.client_dist is not None: - server.app.router.add_static("/", args.client_dist, show_index=True, append_version=False) + index_text = (args.client_dist / "index.html").read_text() + async def spa(_req): - return _req.response(200, text=(args.client_dist / "index.html").read_text(), content_type="text/html") - server.app.router.add_get("/{path:^(?!ws$|health$|.+\\..+$).*}", spa) + # SPA fallback: serve the built index.html for any path + # without a file extension that isn't a reserved route + # (``/ws``, ``/health``, ``/reload``, ``/layout/...``). + return web.Response(text=index_text, content_type="text/html") + + # Register the SPA routes BEFORE add_static so they win the + # match for ``/`` and other extension-less paths. ``add_static`` + # would otherwise claim ``/`` first and return a directory + # listing, breaking ``/?demo=meter``-style entry points. + server.app.router.add_get("/", spa) + server.app.router.add_get( + "/{path:^(?!ws$|health$|reload$|layout($|/)).+}", spa + ) + server.app.router.add_static("/", args.client_dist, show_index=False, append_version=False) - asyncio.run(_run(server)) + try: + asyncio.run(_run(server)) + except PortInUseError as exc: + # Fail fast with the actionable message instead of a raw asyncio + # traceback ending in OSError: [Errno 98]. + logging.getLogger("deckd").error("%s", exc) + raise SystemExit(1) from None if __name__ == "__main__": diff --git a/daemon/deckd/layouts.py b/daemon/deckd/layouts.py index 9d78e6a..9b0e49b 100644 --- a/daemon/deckd/layouts.py +++ b/daemon/deckd/layouts.py @@ -4,7 +4,7 @@ from pathlib import Path import yaml -from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator from .platform import AppInfo @@ -38,6 +38,43 @@ class Widget(BaseModel): # input, so no sanitisation is needed. color: str | None = None action: "Action | None" = None + # ``meter`` widgets bind to a daemon-side :class:`SensorSource` by + # name (e.g. ``cpu_percent``). The daemon pushes ``WidgetUpdateMessage`` + # frames for every meter widget in the active layout whose source + # has live readings. ``min`` / ``max`` define the bar's visible + # range; values outside the range clamp at the ends so a runaway + # sensor paints the bar at full red rather than overflowing. + # ``min``/``max`` default to a CPU-friendly 0..100 %; layouts with + # non-thermal sensors should override. + source: str | None = None + min: float | None = None + max: float | None = None + + @field_validator("kind") + @classmethod + def _validate_meter_needs_source(cls, v: str) -> str: + # Field-level validators on Pydantic v2 don't see sibling fields + # via ``info.data`` (that dict only contains fields validated + # *before* this one, not peer fields). The cross-field check + # (meter requires ``source``, min < max) lives in the + # ``_validate_meter_invariants`` model validator below where + # the full model is available. + return v + + @model_validator(mode="after") + def _validate_meter_invariants(self) -> "Widget": + if self.kind == "meter": + if not self.source: + raise ValueError( + "meter widgets require a 'source' field naming a " + "daemon-side SensorSource (e.g. 'cpu_percent')" + ) + if self.min is not None and self.max is not None and self.min >= self.max: + raise ValueError( + f"meter widget min ({self.min}) must be strictly " + f"less than max ({self.max})" + ) + return self class Action(BaseModel): diff --git a/daemon/deckd/platform.py b/daemon/deckd/platform.py index 672e187..9ae815e 100644 --- a/daemon/deckd/platform.py +++ b/daemon/deckd/platform.py @@ -28,6 +28,252 @@ def identity(self) -> str: return self.app_id or self.wm_class or "unknown" +@dataclass(frozen=True) +class SensorReading: + """One sample from a named sensor source. + + ``value`` is the live reading in the unit the source documents + (``sensor.unit``). ``stale=True`` marks a reading the source could + not refresh (sensor disappeared, permission denied) so the client + can render an "unknown" treatment instead of an arbitrary number + pinned to the last good value. + """ + + source: str + value: float + unit: str + stale: bool = False + + +class SensorSource: + """Named data source the daemon pushes to the client. + + Sources own their own polling cadence: a thermal-zone reader can + re-read ``/sys`` cheaply on every tick, while a D-Bus signal + listener just exposes the next event. ``SensorManager`` polls each + registered source at its declared ``interval_s``; sources that need + event-driven semantics override :meth:`wait_for_next` instead. + + Implementations must be cheap to construct and side-effect-free at + construction time — file handles / bus connections open on the + first call to :meth:`read`. + """ + + #: Short, stable identifier the client references (matches the + #: ``source`` field on a meter widget in a layout YAML). + name: str = "" + + #: Human-readable unit the value is in (e.g. ``"°C"``). Pushed + #: alongside the value so the client can render a label without + #: hard-coding units per source. + unit: str = "" + + #: Default poll cadence the SensorManager uses when no per-widget + #: override is in effect. Sources can override per-poll. + interval_s: float = 1.0 + + def is_available(self) -> bool: + """True when the source can produce readings in this environment. + + Checked once at manager start; sources that return False are + skipped and their meter widgets render ``stale=True`` forever. + The check should be cheap (a stat, an env var read) — no I/O. + """ + return True + + def read(self) -> SensorReading | None: + """Return the next reading, or ``None`` to signal "no data". + + ``None`` keeps the manager's last-good value and flips the + ``stale`` flag on subsequent pushes. Implementations should + swallow their own exceptions and return ``None`` on failure; + the manager will log and continue. + """ + raise NotImplementedError + + +class SensorManager: + """Owns a set of :class:`SensorSource` instances keyed by name. + + Built once at daemon startup with the platform-default sources. + Layouts reference sources by name; the manager hands the same + ``SensorReading`` to every subscriber so two meter widgets bound + to the same source see one canonical value rather than two + polls racing against each other. + + A single :class:`SensorManager` is shared across all sessions + (the manager is process-scoped state, not session state). When + no meter widget is interested in a source, the manager stops + polling it: a phone that's never connected doesn't waste CPU + re-reading ``/sys``. Polling resumes the moment any session + binds a widget to the source. + """ + + def __init__(self, sources: list[SensorSource]) -> None: + self._sources: dict[str, SensorSource] = {s.name: s for s in sources if s.name} + self._last: dict[str, SensorReading] = {} + self._subscribers: dict[str, int] = {} # source -> refcount + self._tasks: dict[str, asyncio.Task[None]] = {} + + @property + def sources(self) -> list[str]: + return list(self._sources) + + def has(self, name: str) -> bool: + return name in self._sources + + def is_available(self, name: str) -> bool: + src = self._sources.get(name) + return src is not None and src.is_available() + + def latest(self, name: str) -> SensorReading | None: + return self._last.get(name) + + def subscribe(self, name: str) -> None: + """Record one more consumer of ``name``. + + Bumps the refcount; starts the polling task on the 0 -> 1 + transition. Repeated calls are idempotent — only the first + call per source kicks the task off. + """ + if name not in self._sources: + return + self._subscribers[name] = self._subscribers.get(name, 0) + 1 + if name not in self._tasks: + self._tasks[name] = asyncio.create_task(self._poll_loop(name)) + + def unsubscribe(self, name: str) -> None: + """Drop one consumer; stop polling when the count hits zero. + + Safe to call more times than :meth:`subscribe`; the count + floors at zero. The polling task is cancelled on the 1 -> 0 + transition; the cached last reading stays so a re-subscribe + within the same session doesn't flash ``stale=True``. + """ + if name not in self._sources: + return + self._subscribers[name] = max(0, self._subscribers.get(name, 0) - 1) + if self._subscribers[name] == 0 and name in self._tasks: + task = self._tasks.pop(name) + task.cancel() + + def start(self) -> None: + """No-op retained for symmetry with :meth:`stop`. + + Sources are started lazily on :meth:`subscribe` so a process + with no meter subscribers never opens a file handle. + """ + return None + + def stop(self) -> None: + for task in self._tasks.values(): + task.cancel() + self._tasks.clear() + + async def _poll_loop(self, name: str) -> None: + src = self._sources[name] + interval = max(0.05, src.interval_s) + while True: + try: + reading = src.read() + except Exception as exc: # sources should swallow, but be defensive + log.warning("sensor %s raised: %s", name, exc) + reading = None + if reading is None: + last = self._last.get(name) + if last is not None and not last.stale: + self._last[name] = SensorReading( + source=name, value=last.value, unit=last.unit, stale=True + ) + else: + self._last[name] = reading + try: + await asyncio.sleep(interval) + except asyncio.CancelledError: + return + + +# --------------------------------------------------------------------------- +# psutil-backed sensor sources +# --------------------------------------------------------------------------- +# +# We deliberately dropped the per-platform CPU-temperature readers +# (``/sys/class/thermal`` on Linux, ``osx-cpu-temp`` / ``istats`` shell-outs +# on macOS) in favour of metrics psutil exposes identically on every +# platform: CPU utilisation, memory usage, CPU frequency, battery. +# The trade-off is documented in ``README.md``; the short version is +# that Apple Silicon doesn't expose a stable, unprivileged CPU +# temperature API, and shelling out for one-off helper binaries is a +# support liability that buys us nothing the user can't see in +# Activity Monitor / ``top``. +# +# ``psutil.cpu_percent(interval=None)`` is the canonical non-blocking +# form: it returns the delta between this call and the previous one, +# so a 1s poll cadence gives a fresh per-second percentage. The +# first call returns 0.0 (no baseline yet) — that's correct, not a +# bug. + + +class PsutilCpuPercentSensorSource(SensorSource): + """Whole-system CPU utilisation in percent, via ``psutil``. + + Returns a value in ``[0, 100]`` (well, sometimes briefly above + on busy multi-core systems where psutil's normalisation hasn't + settled — the meter cell clamps the bar to 100% anyway). The + interval is ``1.0s`` to match psutil's standard semantics: + ``cpu_percent(interval=None)`` is delta-since-last-call, so the + delta window equals the poll cadence. Setting the source + interval below ~0.5s makes the numbers jittery because the + sampling window gets too short to be statistically meaningful. + """ + + name = "cpu_percent" + unit = "%" + interval_s = 1.0 + + def is_available(self) -> bool: + return True + + def read(self) -> SensorReading | None: + try: + import psutil + + value = psutil.cpu_percent(interval=None) + except Exception: + return None + # First call after daemon start returns 0.0 (no baseline). + # We surface that as a normal reading — the bar shows 0% + # for one second, which is honest. + return SensorReading(source=self.name, value=float(value), unit=self.unit) + + +class PsutilMemoryPercentSensorSource(SensorSource): + """System memory usage in percent, via ``psutil.virtual_memory``. + + This is the ``used / total`` ratio on Linux and macOS. On Windows + it follows the same definition. The value is a snapshot — no + sampling window needed — so the interval can be as fast as we + like; ``1.0s`` is the conservative default that keeps a meter's + transition animation readable. + """ + + name = "mem_percent" + unit = "%" + interval_s = 1.0 + + def is_available(self) -> bool: + return True + + def read(self) -> SensorReading | None: + try: + import psutil + + value = psutil.virtual_memory().percent + except Exception: + return None + return SensorReading(source=self.name, value=float(value), unit=self.unit) + + class PlatformBackend: async def start(self) -> None: """Acquire any long-lived resources (a D-Bus bus name, a session @@ -407,6 +653,31 @@ def default_backend() -> PlatformBackend: return GnomeShellFocusBackend() +def default_sensor_manager() -> "SensorManager": + """Build the platform-default :class:`SensorManager`. + + Every source here is psutil-backed, so the same set works on + Linux, macOS Intel, and Apple Silicon without per-OS install + steps or shell-outs. We register every source the manager knows + about; per-widget subscriptions are gated by + :meth:`SensorSource.is_available` so a sensor that can't read + (e.g. ``battery`` on a desktop) just stays quiet rather than + spamming None. + + Trade-off documented in README.md: dropping CPU temperature + because Apple Silicon doesn't expose one without root + a + privileged helper + a parser for Apple's undocumented format. + CPU utilisation and memory percent cover the same "is the box + healthy" use case and work everywhere. + """ + return SensorManager( + [ + PsutilCpuPercentSensorSource(), + PsutilMemoryPercentSensorSource(), + ] + ) + + def _is_kde_wayland_session() -> bool: """Detect a KDE Plasma Wayland session. diff --git a/daemon/deckd/protocol.py b/daemon/deckd/protocol.py index f65cf8b..5e9cff5 100644 --- a/daemon/deckd/protocol.py +++ b/daemon/deckd/protocol.py @@ -42,8 +42,35 @@ class BrightnessMessage(BaseModel): value: int = Field(ge=0, le=255) +class WidgetUpdateMessage(BaseModel): + """Daemon->client push: a meter widget's live value (issue #40). + + The daemon sends one of these to every connected session whenever a + sensor the session has subscribed to produces a new reading. + ``id`` is the widget id from the active layout; ``source`` echoes + the bound sensor name so a client with a stale layout can still tell + what the value belongs to; ``unit`` rides along so the client + doesn't have to know a per-source unit registry. + + ``stale=True`` means the source could not refresh (sensor + disappeared, permission denied); the client renders an "unknown" + treatment and keeps the bar at its last-known position. We send + ``stale=True`` explicitly rather than dropping the message so the + UI can stop claiming the value is fresh. + """ + + model_config = ConfigDict(extra="forbid") + + type: Literal["widget_update"] + id: str + source: str + value: float + unit: str + stale: bool = False + + ServerMessage = Annotated[ - Union[LayoutMessage, StateMessage, BrightnessMessage], + Union[LayoutMessage, StateMessage, BrightnessMessage, WidgetUpdateMessage], Field(discriminator="type"), ] diff --git a/daemon/deckd/server.py b/daemon/deckd/server.py index 8bfd9c0..7def265 100644 --- a/daemon/deckd/server.py +++ b/daemon/deckd/server.py @@ -1,6 +1,8 @@ from __future__ import annotations import asyncio +import contextlib +import errno import hmac import json import logging @@ -34,6 +36,41 @@ # drop it. Generous — a real client sends it immediately on open. WS_AUTH_TIMEOUT_S = 10.0 +# Application-defined WebSocket close code (private-use range 4000-4999) +# meaning "auth rejected". Mnemonic for HTTP 401. The client keys off this +# in onclose so a browser that dropped the rejection frame still learns it +# was unauthorized and shows the password gate. +WS_CLOSE_UNAUTHORIZED = 4401 + +# Beat between sending the rejection frame and closing, so browsers deliver +# the frame to onmessage as its own event instead of coalescing it with the +# close frame and dropping it. +WS_AUTH_REJECT_GRACE_S = 0.25 + + +class PortInUseError(RuntimeError): + """The daemon's listen port is already bound by another process. + + Raised from :meth:`Server.start` instead of letting aiohttp's raw + ``OSError: [Errno 98] address already in use`` traceback escape. The + message names the port and the one-liners to find and stop the + offender (almost always a stale deckd instance) so a Vite dev server + left pointing at a dead backend is diagnosable at a glance rather than + a wall of asyncio frames. ``__main__`` catches this and exits 1 with + just the message. + """ + + def __init__(self, host: str, port: int) -> None: + self.host = host + self.port = port + super().__init__( + f"cannot bind {host}:{port} — another process is already " + f"listening there (usually a stale deckd instance).\n" + f" find it: ss -ltnp | grep {port} (or: pgrep -af bin/deckd)\n" + f" stop it: pkill -f bin/deckd\n" + f" or use another port: deckd --port " + ) + # --------------------------------------------------------------------------- # Host-identity helpers used by /health. Cheap to compute per-request; no @@ -135,6 +172,7 @@ def __init__( focus_backend: "PlatformBackend | None" = None, overlay_dir: Path | None = None, password: str | None = None, + sensor_manager: "SensorManager | None" = None, ) -> None: self.layouts_dir = layouts_dir self.overlay_dir = overlay_dir @@ -155,8 +193,18 @@ def __init__( self.focus_backend = focus_backend self._focus_task: asyncio.Task[None] | None = None self._layouts_task: asyncio.Task[None] | None = None + self._sensor_task: asyncio.Task[None] | None = None self._current_error: str | None = None self._deckd_window_focused = False + # Sensor subscriptions for the active layout. Re-derived whenever + # the active layout changes (focus change or hot reload) so the + # daemon only polls sensors the current view is actually using. + # ``None`` means "use the platform default"; explicit ``None`` + # tests can pass a fake manager. ``_subscribed_sources`` is the + # set we currently hold refcounts on, used to keep + # subscribe/unsubscribe balanced across layout changes. + self.sensors: "SensorManager | None" = sensor_manager + self._subscribed_sources: set[str] = set() # -- layout state -------------------------------------------------------- @@ -197,6 +245,11 @@ def reload_layouts(self) -> None: new_layout = self.layouts.default() self._current_layout = new_layout self._current_error = None + # A layout reload can change which meter sources the active + # layout uses (a meter added in the new YAML, an old one + # removed). Reconcile subscriptions so the manager polls only + # what's now in use. + self._sync_sensor_subscriptions() log.info( "reloaded layouts from %s%s", self.layouts_dir, @@ -253,6 +306,11 @@ async def _on_focus(self, app: "AppInfo") -> None: log.info("focus -> %s (layout=%s)", app, new_app_id) self._current_app_id = new_app_id self._current_layout = new_layout + # Different layouts reference different meter sources (e.g. + # switching from a desktop to a terminal layout that monitors + # something else). Resubscribe so the manager's polling tracks + # the active view. + self._sync_sensor_subscriptions() await self._push_to_all() async def run_focus_watcher(self) -> None: @@ -344,6 +402,171 @@ def start_layouts_watcher(self) -> asyncio.Task[None] | None: self._layouts_task = asyncio.create_task(self.run_layouts_watcher()) return self._layouts_task + # -- sensor pump --------------------------------------------------------- + # + # The :class:`SensorManager` polls sources on its own task and keeps + # the latest reading in ``_last[name]``. The server's only job is to + # notice when a reading changes and push a ``widget_update`` frame + # to every connected session whose active layout has a meter bound + # to that source. Polling frequency is bounded by the source's + # ``interval_s``; the pump itself runs at 100ms so a 1s source + # produces up to one push per second, and we don't notice a + # millisecond late. + + def _meters_for_source(self, source: str) -> list[Widget]: + """Return every meter widget in the active layout bound to ``source``. + + Used by the pump to know which ``id`` to send in the push. + Order matches the layout's declaration order so the wire is + deterministic for tests. + """ + return [ + w + for w in self._current_layout.widgets + if w.kind == "meter" and w.source == source + ] + + def _active_sources(self) -> set[str]: + """Names of every sensor referenced by a meter in the active layout. + + Excludes unknown source names (no such source registered with + the manager) so a typo in a layout YAML doesn't crash the + pump — the meter just stays stale. The exclusion is best-effort + at this layer; the manager's ``is_available`` check is the + authoritative gate. + """ + sources: set[str] = set() + manager = self.sensors + for w in self._current_layout.widgets: + if w.kind != "meter" or not w.source: + continue + if manager is None or manager.has(w.source): + sources.add(w.source) + return sources + + def _sync_sensor_subscriptions(self) -> None: + """Reconcile :class:`SensorManager` subscriptions with the active layout. + + Idempotent: calling it back-to-back is a no-op. Drops + subscriptions to sources no longer referenced; adds new ones. + Layouts with no meter widgets clear all subscriptions so the + daemon idles its polling. + """ + manager = self.sensors + if manager is None: + return + wanted = self._active_sources() + for name in self._subscribed_sources - wanted: + manager.unsubscribe(name) + for name in wanted - self._subscribed_sources: + manager.subscribe(name) + self._subscribed_sources = wanted + + async def run_sensor_pump(self) -> None: + """Watch :class:`SensorManager` and push widget_update frames. + + Compares the manager's ``_last`` against the last value pushed + for each ``(source, widget_id)`` pair and emits a frame on + change. Stale-flag flips (``True`` after a sensor error) also + count as a change so the UI can show the unknown treatment. + + A meter bound to an unknown source simply never produces a + push — the client renders no value at all. This is intentional: + the alternative (one push per pump tick with the error + surfaced) would spam a layout YAML typo across the WebSocket + forever. The :meth:`_active_sources` filter is the gate that + keeps unknown sources out of the subscription set in the first + place. + """ + manager = self.sensors + if manager is None: + return + manager.start() + last_pushed: dict[tuple[str, str], tuple[float, bool, str]] = {} + try: + while True: + # Snapshot subscriptions so a layout change that drops a + # source mid-iteration doesn't reach into a stale entry. + for name in list(self._subscribed_sources): + reading = manager.latest(name) + for widget in self._meters_for_source(name): + key = (name, widget.id) + if reading is None: + # No reading yet: don't push until the + # source produces one. Avoids spamming an + # unknown-value frame at connect time when + # the first poll is still pending. + continue + payload = (reading.value, reading.stale, reading.unit) + if last_pushed.get(key) == payload: + continue + # Only cache the payload once a session has + # actually received it. Without this, a pump + # tick that runs before any client is connected + # would burn the first push — the next tick + # (with sessions attached) would see the same + # value as already-pushed and silently skip it, + # leaving the first client staring at an empty + # meter until the value actually changes. + if not await self._broadcast_widget_update(widget, reading): + continue + last_pushed[key] = payload + await asyncio.sleep(0.1) + except asyncio.CancelledError: + return + + async def _broadcast_widget_update( + self, widget: Widget, reading: "SensorReading" + ) -> bool: + """Send a WidgetUpdateMessage to every connected session. + + Returns ``True`` when at least one session actually received + the frame. ``False`` when there are no sessions (a pump tick + that ran before any client connected) or every send errored + out — the pump uses the False return to skip caching the + payload so the next tick still sees the same value as new and + pushes it once a client arrives. + + Stale-connection failures are silently dropped; the session + is already being torn down. ``stale=True`` messages are sent + so the client can stop claiming the value is fresh. + """ + if not self._sessions: + return False + msg = p.WidgetUpdateMessage( + type="widget_update", + id=widget.id, + source=reading.source, + value=reading.value, + unit=reading.unit, + stale=reading.stale, + ) + sent = 0 + dead: list[Session] = [] + for session in list(self._sessions): + try: + await session.send(msg) + sent += 1 + except (ConnectionResetError, RuntimeError, ConnectionError): + dead.append(session) + for session in dead: + self._sessions.discard(session) + return sent > 0 + + def start_sensor_pump(self) -> asyncio.Task[None] | None: + if self.sensors is None or self._sensor_task is not None: + return None + # Make sure the manager is subscribed to whatever the active + # layout uses before the pump task starts polling. ``__init__`` + # deliberately doesn't subscribe (no event loop yet), and + # ``reload_layouts`` / ``_on_focus`` keep the set in sync as + # the layout changes; this call bridges the gap on first start + # so the very first ``widget_update`` push isn't delayed by a + # missing subscription. + self._sync_sensor_subscriptions() + self._sensor_task = asyncio.create_task(self.run_sensor_pump()) + return self._sensor_task + # -- auth ---------------------------------------------------------------- # # Every WebSocket and HTTP control connection must present the shared @@ -493,8 +716,24 @@ async def _ws_handler(self, req: web.Request) -> web.WebSocketResponse: hello = await self._authenticate_ws(ws) if hello is None: log.info("ws auth failed from %s; closing", req.remote) - await ws.send_json({"type": "error", "reason": "unauthorized"}) - await ws.close() + # A client that failed (or abandoned) auth has often already + # closed the socket, so the rejection frame races the teardown. + # Suppress the write-to-closing-transport error: the rejection + # is best-effort and the connection is going away regardless. + with contextlib.suppress(ConnectionError, RuntimeError): + await ws.send_json({"type": "error", "reason": "unauthorized"}) + # Browsers coalesce a data frame with an immediately + # following close frame and drop the data one, so the + # client's onmessage never sees the rejection and it just + # reconnect-loops without ever showing the password gate. + # Two defences: (1) a short beat so the data frame is + # delivered as its own event before the close, and (2) a + # dedicated close code the client also treats as + # unauthorized — the close code survives the race even if + # the frame is still dropped. (A raw ws client tolerates + # the coalescing; a browser does not.) + await asyncio.sleep(WS_AUTH_REJECT_GRACE_S) + await ws.close(code=WS_CLOSE_UNAUTHORIZED, message=b"unauthorized") return ws # Auth consumed the hello, so apply its demo pin before the initial # push. (No-auth clients' hellos arrive via _dispatch instead.) @@ -601,15 +840,22 @@ async def start(self) -> None: runner = web.AppRunner(self.app, access_log=None) await runner.setup() site = web.TCPSite(runner, self.host, self.port) - await site.start() + try: + await site.start() + except OSError as exc: + if exc.errno == errno.EADDRINUSE: + await runner.cleanup() + raise PortInUseError(self.host, self.port) from exc + raise log.info("listening on http://%s:%d (ws=%s/ws)", self.host, self.port, self.host) self._runner = runner self.start_layouts_watcher() + self.start_sensor_pump() while True: await asyncio.sleep(3600) async def stop(self) -> None: - for task in (self._focus_task, self._layouts_task): + for task in (self._focus_task, self._layouts_task, self._sensor_task): if task is not None: task.cancel() try: @@ -621,6 +867,8 @@ async def stop(self) -> None: await self.focus_backend.stop() except Exception as exc: log.debug("focus backend stop failed: %s", exc) + if self.sensors is not None: + self.sensors.stop() runner = getattr(self, "_runner", None) if runner is not None: await runner.cleanup() diff --git a/layouts/default.yaml b/layouts/default.yaml index c656334..7fb3229 100644 --- a/layouts/default.yaml +++ b/layouts/default.yaml @@ -66,21 +66,53 @@ widgets: # Open the auto-detected terminal ($TERMINAL, then a candidate list). # For a specific emulator, use e.g. shell: "tilix" instead. terminal: true - - id: send-key-tab - kind: button - label: Send Ctrl+T - icon: - source: lucide - name: keyboard + # Send a key combo (issue #16). Commented out — the cell is now home + # to the live CPU meter below (same grid slot [2, 2]). Kept here as a + # worked example of the ``key`` action; re-enable by removing the + # leading ``#``s and moving it to a vacant slot. + # - id: send-key-tab + # kind: button + # label: Send Ctrl+T + # icon: + # source: lucide + # name: keyboard + # grid: [2, 2, 1, 1] + # action: + # key: "ctrl+t" + # Audio / VLC media control. Commented out — the cell is now home + # to the live CPU meter (the audio use case is rare from a phone + # in practice, and the daemon already sends widget_update frames + # for it). Re-enable by removing the leading ``#``s; the grid slot + # below is intentionally vacant so adding it back doesn't shift + # neighbouring widgets. + # - id: audio-toggle + # kind: button + # label: VLC Play/Pause + # icon: + # source: lucide + # name: play + # grid: [3, 2, 1, 1] + # action: + # dbus: "org.mpris.MediaPlayer2.vlc:/org/mpris/MediaPlayer2 org.mpris.MediaPlayer2.Player.PlayPause" + # Live system stats (issue #40). Both meters are psutil-backed, + # so they work identically on Linux + macOS Intel + Apple Silicon + # with no per-platform install step. The daemon pushes + # ``widget_update`` frames at each source's poll cadence (1s for + # both); the bar fills proportionally between min/max. Tighten the + # min/max pair to dial in a narrower "happy" range on the bar — + # e.g. ``min: 0, max: 50`` makes the meter turn red sooner on a + # workload that should sit under 50% CPU. + - id: cpu_percent + kind: meter + label: CPU + source: cpu_percent + min: 0 + max: 100 grid: [2, 1, 1, 1] - action: - key: "ctrl+t" - - id: audio-toggle - kind: button - label: VLC Play/Pause - icon: - source: lucide - name: play + - id: mem_percent + kind: meter + label: MEM + source: mem_percent + min: 0 + max: 100 grid: [3, 1, 1, 1] - action: - dbus: "org.mpris.MediaPlayer2.vlc:/org/mpris/MediaPlayer2 org.mpris.MediaPlayer2.Player.PlayPause" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 430d685..81dfed3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,12 @@ dependencies = [ "pydantic>=2.6", "dbus-fast>=5", "watchfiles>=1.0", + # psutil backs the daemon's cross-platform sensor sources + # (CPU utilization, memory used, CPU frequency, battery). + # One pure-Python dep that works on Linux + macOS Intel + Apple + # Silicon without per-OS install steps. Wheel-only on every + # supported platform; no compile toolchain needed. + "psutil>=5.9", ] [project.optional-dependencies] diff --git a/tests/test_meters.py b/tests/test_meters.py new file mode 100644 index 0000000..a69394c --- /dev/null +++ b/tests/test_meters.py @@ -0,0 +1,254 @@ +"""Tests for the meter widget schema (issue #40), the +``widget_update`` protocol message, and the server-side sensor pump.""" +from __future__ import annotations + +import asyncio +import json +from contextlib import asynccontextmanager +from pathlib import Path +from typing import AsyncIterator + +import pytest +import websockets +from aiohttp.test_utils import TestServer +from pydantic import ValidationError + +from conftest import LAYOUTS_DIR, ServerHandle, make_test_server +from deckd.platform import SensorManager, SensorReading, SensorSource +from deckd.protocol import WidgetUpdateMessage + + +class _CpuSource(SensorSource): + """Test sensor: returns the value the test set. + + Models ``cpu_percent`` — the daemon's primary live-stat source + after we dropped ``cpu_temp`` because Apple Silicon has no + stable unprivileged temperature API. The unit is ``%`` rather + than ``°C``; tests that don't care about the unit just assert + on the value. + """ + + name = "cpu_percent" + unit = "%" + interval_s = 0.01 + + def __init__(self, initial: float = 42.0) -> None: + self.value = initial + + def read(self) -> SensorReading: + return SensorReading(source=self.name, value=self.value, unit=self.unit) + + +# --------------------------------------------------------------------------- +# Protocol message shape +# --------------------------------------------------------------------------- + + +def test_widget_update_message_roundtrips() -> None: + msg = WidgetUpdateMessage( + type="widget_update", id="cpu", source="cpu_percent", value=58.4, unit="%" + ) + data = json.loads(json.dumps(msg.model_dump())) + assert data["type"] == "widget_update" + assert data["id"] == "cpu" + assert data["source"] == "cpu_percent" + assert data["value"] == 58.4 + assert data["unit"] == "%" + assert data["stale"] is False + + +def test_widget_update_message_rejects_unknown_type() -> None: + with pytest.raises(ValidationError): + WidgetUpdateMessage(type="nope", id="x", source="s", value=0, unit="") # type: ignore[arg-type] + + +def test_widget_update_message_default_stale_false() -> None: + msg = WidgetUpdateMessage(type="widget_update", id="x", source="s", value=0, unit="") + assert msg.stale is False + + +# --------------------------------------------------------------------------- +# Layout schema: meter widget +# --------------------------------------------------------------------------- + + +def test_meter_widget_loads_with_source() -> None: + from deckd.layouts import load_layout + + yaml = """ +match: + - default +widgets: + - id: cpu + kind: meter + source: cpu_percent + min: 0 + max: 100 + grid: [0, 0, 1, 1] +""" + p = Path("/tmp/_meter_layout.yaml") + p.write_text(yaml) + try: + layout = load_layout(p) + w = layout.widgets[0] + assert w.kind == "meter" + assert w.source == "cpu_percent" + assert w.min == 0 + assert w.max == 100 + finally: + p.unlink() + + +def test_meter_widget_without_source_is_rejected() -> None: + from deckd.layouts import load_layout + + yaml = """ +match: + - default +widgets: + - id: cpu + kind: meter + grid: [0, 0, 1, 1] +""" + p = Path("/tmp/_meter_layout_bad.yaml") + p.write_text(yaml) + try: + with pytest.raises(SystemExit): + load_layout(p) + finally: + p.unlink() + + +def test_meter_widget_with_inverted_range_is_rejected() -> None: + from deckd.layouts import load_layout + + yaml = """ +match: + - default +widgets: + - id: cpu + kind: meter + source: cpu_percent + min: 100 + max: 50 + grid: [0, 0, 1, 1] +""" + p = Path("/tmp/_meter_layout_bad2.yaml") + p.write_text(yaml) + try: + with pytest.raises(SystemExit): + load_layout(p) + finally: + p.unlink() + + +# --------------------------------------------------------------------------- +# Server pump: subscribe/unsubscribe + push +# --------------------------------------------------------------------------- + + +@asynccontextmanager +async def ws_connected_no_auth(port: int) -> AsyncIterator[websockets.WebSocketClientProtocol]: + async with websockets.connect(f"ws://127.0.0.1:{port}/ws") as ws: + yield ws + + +@pytest.fixture +def meter_layout(tmp_path: Path) -> Path: + """A default layout with a meter bound to cpu_percent.""" + p = tmp_path / "default.yaml" + p.write_text( + """ +match: + - default +widgets: + - id: cpu + kind: meter + source: cpu_percent + min: 0 + max: 100 + grid: [0, 0, 1, 1] +""" + ) + return tmp_path + + +async def test_server_pumps_widget_update_to_session(meter_layout: Path) -> None: + src = _CpuSource(initial=42.0) + mgr = SensorManager([src]) + server, _, _, _ = make_test_server(layouts_dir=meter_layout, password=None) + server.sensors = mgr + # Re-sync now that the manager is installed (make_test_server + # builds the Server before we swap sensors in). + server._sync_sensor_subscriptions() # type: ignore[attr-defined] + server.start_sensor_pump() + + test_server = TestServer(server.app, host="127.0.0.1") + await test_server.start_server() + try: + # Connect a client; the server's initial layout push should + # include the meter widget, and the sensor pump should send a + # widget_update frame shortly after. + async with ws_connected_no_auth(test_server.port) as ws: + initial = json.loads(await asyncio.wait_for(ws.recv(), timeout=2)) + assert initial["type"] == "layout" + assert any( + w["kind"] == "meter" and w.get("source") == "cpu_percent" + for w in initial["widgets"] + ) + # Wait for the pump to push at least one update. + update = None + for _ in range(50): + try: + raw = await asyncio.wait_for(ws.recv(), timeout=0.2) + except asyncio.TimeoutError: + continue + msg = json.loads(raw) + if msg.get("type") == "widget_update": + update = msg + break + assert update is not None, "no widget_update frame received" + assert update["id"] == "cpu" + assert update["source"] == "cpu_percent" + assert update["unit"] == "%" + assert update["value"] == pytest.approx(42.0) + finally: + await server.stop() + await test_server.close() + + +async def test_server_pump_skips_unknown_sources(meter_layout: Path) -> None: + """A meter bound to an unregistered source must not crash the pump.""" + src = _CpuSource(initial=10.0) + mgr = SensorManager([src]) + server, _, _, _ = make_test_server(layouts_dir=meter_layout, password=None) + server.sensors = mgr + # Build a manager that does NOT have cpu_percent — the meter in the + # layout references it but the manager only carries an unrelated + # source. Subscriptions get filtered out by _active_sources; the + # pump should run idle. + unrelated = SensorSource() + unrelated.name = "not_cpu" + unrelated.unit = "" + mgr2 = SensorManager([unrelated]) + server.sensors = mgr2 + server._sync_sensor_subscriptions() # type: ignore[attr-defined] + server.start_sensor_pump() + + test_server = TestServer(server.app, host="127.0.0.1") + await test_server.start_server() + try: + async with ws_connected_no_auth(test_server.port) as ws: + await asyncio.wait_for(ws.recv(), timeout=2) # initial layout + # The pump polls at 100ms; wait three intervals then + # confirm the test is still alive (no exception in the + # pump loop) and no spam frames are pushed. + await asyncio.sleep(0.4) + try: + raw = await asyncio.wait_for(ws.recv(), timeout=0.1) + except asyncio.TimeoutError: + raw = None + assert raw is None or json.loads(raw).get("type") != "widget_update" + finally: + await server.stop() + await test_server.close() diff --git a/tests/test_sensors.py b/tests/test_sensors.py new file mode 100644 index 0000000..5e29afd --- /dev/null +++ b/tests/test_sensors.py @@ -0,0 +1,215 @@ +"""Tests for the psutil-backed sensor sources and the ``SensorManager`` +abstraction. + +These exercise the cross-platform sources the daemon ships by +default. The Linux-specific ``/sys/class/thermal`` reader and the +macOS shell-out readers (``osx-cpu-temp`` / ``istats``) have been +removed — Apple Silicon doesn't expose CPU temperature in any +unprivileged, documented way, so we standardise on metrics psutil +covers identically on every platform. +""" +from __future__ import annotations + +import asyncio + +import pytest + +from deckd.platform import ( + PsutilCpuPercentSensorSource, + PsutilMemoryPercentSensorSource, + SensorManager, + SensorReading, + SensorSource, +) + + +class _FixedSource(SensorSource): + """Deterministic sensor for tests — always returns the same reading.""" + + name = "test_fixed" + unit = "x" + interval_s = 0.01 + + def __init__(self, value: float, available: bool = True) -> None: + self._value = value + self._available = available + + def is_available(self) -> bool: + return self._available + + def read(self) -> SensorReading: + return SensorReading(source=self.name, value=self._value, unit=self.unit) + + +class _FlappingSource(SensorSource): + """Returns alternating values so we can see push-on-change filtering.""" + + name = "test_flap" + unit = "v" + interval_s = 0.01 + + def __init__(self, values: list[float]) -> None: + self._values = list(values) + self._i = 0 + + def read(self) -> SensorReading: + v = self._values[self._i % len(self._values)] + self._i += 1 + return SensorReading(source=self.name, value=v, unit=self.unit) + + +class _FailingSource(SensorSource): + name = "test_fail" + unit = "?" + interval_s = 0.01 + + def read(self) -> SensorReading | None: + return None + + +# --------------------------------------------------------------------------- +# SensorManager behaviour (regression-tested from the original +# Linux-thermal-zone implementation; the manager itself didn't change) +# --------------------------------------------------------------------------- + + +async def test_manager_polls_subscribed_source() -> None: + src = _FixedSource(42.0) + mgr = SensorManager([src]) + mgr.subscribe("test_fixed") + for _ in range(20): + latest = mgr.latest("test_fixed") + if latest is not None: + break + await asyncio.sleep(src.interval_s) + assert latest is not None + assert latest.value == 42.0 + assert latest.unit == "x" + assert latest.stale is False + mgr.unsubscribe("test_fixed") + + +async def test_manager_drops_subscriptions_on_zero_refcount() -> None: + src = _FixedSource(1.0) + mgr = SensorManager([src]) + mgr.subscribe("test_fixed") + assert "test_fixed" in mgr._tasks # type: ignore[attr-defined] + mgr.unsubscribe("test_fixed") + await asyncio.sleep(0) + assert "test_fixed" not in mgr._tasks # type: ignore[attr-defined] + + +async def test_manager_marks_reading_stale_when_source_returns_none() -> None: + src = _FailingSource() + mgr = SensorManager([src]) + mgr._last["test_fail"] = SensorReading(source="test_fail", value=10, unit="?") # type: ignore[attr-defined] + mgr.subscribe("test_fail") + await asyncio.sleep(src.interval_s * 2) + latest = mgr.latest("test_fail") + assert latest is not None + assert latest.value == 10 + assert latest.stale is True + mgr.unsubscribe("test_fail") + + +async def test_manager_refcount_is_balanced() -> None: + src = _FixedSource(7.0) + mgr = SensorManager([src]) + mgr.subscribe("test_fixed") + mgr.subscribe("test_fixed") + mgr.subscribe("test_fixed") + mgr.unsubscribe("test_fixed") + mgr.unsubscribe("test_fixed") + mgr.unsubscribe("test_fixed") + assert mgr._subscribers["test_fixed"] == 0 # type: ignore[attr-defined] + mgr.unsubscribe("test_fixed") + assert mgr._subscribers["test_fixed"] == 0 # type: ignore[attr-defined] + + +async def test_manager_unknown_source_is_ignored() -> None: + mgr = SensorManager([_FixedSource(1.0)]) + mgr.subscribe("does_not_exist") + assert mgr._tasks == {} # type: ignore[attr-defined] + + +# --------------------------------------------------------------------------- +# psutil-backed sources (the actual ship targets) +# --------------------------------------------------------------------------- + + +def test_psutil_cpu_percent_source_is_always_available() -> None: + # psutil ships wheels for every platform we support, so the + # source never reports unavailable on a working install. + src = PsutilCpuPercentSensorSource() + assert src.is_available() is True + + +def test_psutil_memory_percent_source_is_always_available() -> None: + src = PsutilMemoryPercentSensorSource() + assert src.is_available() is True + + +def test_psutil_cpu_percent_source_returns_reading_in_zero_to_hundred() -> None: + # First ``cpu_percent(interval=None)`` returns 0.0 (no baseline); + # we accept that as a valid reading rather than a None. + src = PsutilCpuPercentSensorSource() + reading = src.read() + assert reading is not None + assert reading.source == "cpu_percent" + assert reading.unit == "%" + assert reading.value == pytest.approx(0.0) or reading.value >= 0 + + +def test_psutil_cpu_percent_source_returns_subsequent_reading() -> None: + # After the first call the manager has a baseline; the second + # call returns a delta. We don't assert it's nonzero — could be + # 0.0 on an idle box — but it must be a valid number. + src = PsutilCpuPercentSensorSource() + src.read() # establish baseline + reading = src.read() + assert reading is not None + assert 0.0 <= reading.value <= 100.0 + + +def test_psutil_memory_percent_source_returns_reading_in_zero_to_hundred() -> None: + src = PsutilMemoryPercentSensorSource() + reading = src.read() + assert reading is not None + assert reading.source == "mem_percent" + assert reading.unit == "%" + assert 0.0 <= reading.value <= 100.0 + + +def test_psutil_sources_have_one_second_poll_interval() -> None: + # The meter cell renders a smooth transition animation on every + # push; faster than 1s looks jittery, slower than 1s feels + # dead. Pinned here so a future refactor doesn't silently change + # the cadence. + assert PsutilCpuPercentSensorSource.interval_s == 1.0 + assert PsutilMemoryPercentSensorSource.interval_s == 1.0 + + +def test_default_sensor_manager_registers_psutil_sources() -> None: + # The default factory must produce the cross-platform set so a + # fresh daemon on a fresh box has working meters without any + # extra setup. Pinned: if anyone adds a Linux-only or macOS-only + # source to the default list, this test forces them to update + # the cross-platform contract too. + from deckd.platform import default_sensor_manager + + mgr = default_sensor_manager() + assert "cpu_percent" in mgr.sources + assert "mem_percent" in mgr.sources + + +def test_default_sensor_manager_contains_no_legacy_temperature_sources() -> None: + # Regression guard: the old ``cpu_temp`` source (Linux thermal + # zone + macOS shell-out) used to ship by default. It's gone + # now — Apple Silicon can't read it, the macOS shell-out + # helpers are unmaintained, and psutil covers the metrics we + # actually want. This test pins the removal so a future "let's + # add cpu_temp back" PR has to think twice about it. + from deckd.platform import default_sensor_manager + + mgr = default_sensor_manager() + assert "cpu_temp" not in mgr.sources diff --git a/tests/test_server.py b/tests/test_server.py index 6b742d5..6310003 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -826,3 +826,41 @@ async def test_layouts_watcher_bad_edit_pushes_error_not_crash( recovered = await _next_layout(ws, timeout=5.0) assert recovered.get("error") in (None, "") assert recovered["widgets"][0]["id"] == "home-v2" + + +async def test_start_raises_port_in_use_not_raw_oserror(tmp_path: Path) -> None: + """A busy listen port fails fast with ``PortInUseError`` (a clean, + actionable message) instead of aiohttp's raw ``OSError: [Errno 98]``. + + Regression guard for the stale-daemon footgun: a second deckd (or any + process on the port) must not dump an asyncio traceback that reads as a + mysterious crash — ``__main__`` relies on this typed error to exit 1 + with the find/stop hint. + """ + import socket as _socket + + from conftest import make_test_server + from deckd.server import PortInUseError + + (tmp_path / "default.yaml").write_text( + "match:\n - default\nwidgets: []\n" + ) + + # Hold a real listener so the daemon's bind is guaranteed to collide. + blocker = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) + blocker.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1) + blocker.bind(("127.0.0.1", 0)) + blocker.listen(1) + busy_port = blocker.getsockname()[1] + + try: + server, *_ = make_test_server(layouts_dir=tmp_path) + server.host = "127.0.0.1" + server.port = busy_port + with pytest.raises(PortInUseError) as excinfo: + await server.start() + assert excinfo.value.port == busy_port + assert "pkill -f bin/deckd" in str(excinfo.value) + await server.stop() + finally: + blocker.close() diff --git a/tests/test_server_auth.py b/tests/test_server_auth.py index fde8c66..ef0983c 100644 --- a/tests/test_server_auth.py +++ b/tests/test_server_auth.py @@ -51,8 +51,14 @@ async def test_ws_without_password_rejected() -> None: async with websockets.connect(f"ws://127.0.0.1:{port}/ws") as ws: await ws.send(json.dumps({"type": "hello", "client": "web"})) assert await _recv(ws) == {"type": "error", "reason": "unauthorized"} - with pytest.raises(websockets.ConnectionClosed): + # The rejection also closes with a dedicated code (4401) that the + # client keys off, because browsers can drop the data frame above + # when it's coalesced with the close. Client and daemon must agree + # on 4401 (see socket.ts UNAUTHORIZED_CLOSE_CODE); assert it here so + # the two can't silently drift. + with pytest.raises(websockets.ConnectionClosed) as excinfo: await _recv(ws) + assert excinfo.value.rcvd.code == 4401 async def test_ws_with_wrong_password_rejected() -> None: From 8471e404109bd0f265bede7d0ec41b6e8456da8c Mon Sep 17 00:00:00 2001 From: Jono Date: Fri, 24 Jul 2026 11:26:00 -0700 Subject: [PATCH 2/4] Add multi-value `stats` widget; key meter store by source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `kind: stats` widget: a compact, bar-less cell that shows several sensor values at once (one "LABEL value unit" row per metric). Simpler than the single-value `meter` and easy to grow — add another entry to the widget's `metrics` list. Each metric names a daemon-side source; `label` is optional and the client derives one from the source name (cpu_percent -> CPU) when omitted. To let one cell show many sources (and two widgets share a source), the client meter store is now keyed by **source** instead of widget id — a reading belongs to a sensor, not a widget. Store/lookup/persistence and the active-set reap all follow suit; localStorage migrates transparently (old id-keyed entries fall back once, then re-key by source). - default.yaml: the standalone MEM meter at [3,1] is replaced by a `System` stats cell showing CPU + MEM; the CPU bar meter stays at [2,1]. - Daemon: MetricSpec model + `stats` validation (non-empty metrics); subscription/pump fold both widget shapes via `_widget_sources`. - Client: StatsCell + CSS; ButtonGrid dispatch; source-keyed store. - Demo: the `?demo=meter` fixture gains a stats cell (reuses the existing cpu/mem seeds), so the gallery/screenshot tool showcases both styles. - Tests: stats schema + pump (daemon, 221 pass); StatsCell render + store source-keying (client, 35 pass). Co-Authored-By: Claude Opus 4.8 --- client/src/App.tsx | 23 ++++--- client/src/ButtonGrid.tsx | 16 ++++- client/src/StatsCell.test.tsx | 61 ++++++++++++++++++ client/src/StatsCell.tsx | 77 ++++++++++++++++++++++ client/src/demo.ts | 14 +++- client/src/meter-store.ts | 56 +++++++++------- client/src/protocol.ts | 9 ++- client/src/style.css | 71 +++++++++++++++++++++ daemon/deckd/layouts.py | 30 +++++++++ daemon/deckd/server.py | 41 +++++++++--- layouts/default.yaml | 19 ++++-- tests/test_meters.py | 117 ++++++++++++++++++++++++++++++++++ 12 files changed, 483 insertions(+), 51 deletions(-) create mode 100644 client/src/StatsCell.test.tsx create mode 100644 client/src/StatsCell.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index c800c0e..3f859bf 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -50,21 +50,24 @@ export function App() { const [layout, setLayout] = useState(demoLayout); const [view, setView] = useState("layout"); const onLayout = useCallback((m: ServerLayout) => setLayout(m), []); - // Track every meter widget id in the active layout so the meter - // store can drop entries that are no longer referenced — without - // this, a removed meter would keep its last reading in memory - // forever, and a layout-switch that hides meters would still - // remember them across reloads. - const activeMeterIds = useMemo(() => { - const ids = new Set(); + // Track every sensor source the active layout references (from ``meter`` + // widgets and each ``stats`` widget's metrics) so the meter store can drop + // readings that are no longer on screen — without this, a source would + // keep its last reading in memory forever, and a layout switch that hides + // meters would still remember them across reloads. + const activeMeterSources = useMemo(() => { + const sources = new Set(); if (layout) { for (const w of layout.widgets) { - if (w.kind === "meter") ids.add(w.id); + if (w.kind === "meter" && w.source) sources.add(w.source); + if (w.kind === "stats" && w.metrics) { + for (const m of w.metrics) if (m.source) sources.add(m.source); + } } } - return ids; + return sources; }, [layout]); - const meter = useMeterStore(activeMeterIds); + const meter = useMeterStore(activeMeterSources); // Pull out the store's ``onUpdate`` (a stable useCallback) and feed // widget_update frames straight to it. Depending on the whole ``meter`` // object instead would be a bug: it gets a fresh identity on every render diff --git a/client/src/ButtonGrid.tsx b/client/src/ButtonGrid.tsx index d3d60f5..3003e8a 100644 --- a/client/src/ButtonGrid.tsx +++ b/client/src/ButtonGrid.tsx @@ -3,6 +3,7 @@ import type { Widget } from "./protocol"; import { Icon } from "./Icon"; import { JogStrip } from "./JogStrip"; import { MeterCell } from "./MeterCell"; +import { StatsCell } from "./StatsCell"; import type { MeterReading } from "./meter-store"; import { transposeWidgets, useOrientation } from "./orientation"; import type { Orientation } from "./orientation"; @@ -19,7 +20,7 @@ type Props = { * device stories pass it so the transpose matches the container's shape * rather than the window's. */ orientation?: Orientation; - /** Latest reading per meter widget id. Missing ids render with no + /** Latest reading per sensor source. Missing sources render with no * value (bar empty, "—" numeric). Stale readings show the bar at * its last position with a dimmed readout. */ meterReadings?: Record; @@ -105,7 +106,18 @@ export function ButtonGrid({ + ); + } + if (w.kind === "stats") { + return ( + diff --git a/client/src/StatsCell.test.tsx b/client/src/StatsCell.test.tsx new file mode 100644 index 0000000..85c700c --- /dev/null +++ b/client/src/StatsCell.test.tsx @@ -0,0 +1,61 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { StatsCell } from "./StatsCell"; +import type { Widget } from "./protocol"; +import type { MeterReading } from "./meter-store"; + +const SYSTEM_WIDGET: Widget = { + id: "system", + kind: "stats", + label: "System", + grid: [0, 0, 1, 1], + metrics: [ + { source: "cpu_percent", label: "CPU" }, + { source: "mem_percent" }, // label omitted -> derived + ], +}; + +function renderStats(readings: Record) { + return render(); +} + +describe("StatsCell", () => { + afterEach(cleanup); + + it("renders one row per metric with live values and units", () => { + renderStats({ + cpu_percent: { value: 58, unit: "%", stale: false }, + mem_percent: { value: 41, unit: "%", stale: false }, + }); + expect(screen.getByText("58")).not.toBeNull(); + expect(screen.getByText("41")).not.toBeNull(); + expect(screen.getByText("CPU")).not.toBeNull(); + }); + + it("derives a label from the source when none is given", () => { + renderStats({ mem_percent: { value: 41, unit: "%", stale: false } }); + // mem_percent -> "MEM" + expect(screen.getByText("MEM")).not.toBeNull(); + }); + + it("shows the em-dash placeholder for a source with no reading", () => { + renderStats({ cpu_percent: { value: 58, unit: "%", stale: false } }); + // mem row has no reading yet + expect(screen.getByText("—")).not.toBeNull(); + }); + + it("reads each metric from the shared store by source, not widget id", () => { + // The widget id is "system"; values must resolve via the metric + // sources, never the widget id — regression guard for the + // source-keyed store. + renderStats({ + system: { value: 999, unit: "x", stale: false }, // must be ignored + cpu_percent: { value: 58, unit: "%", stale: false }, + mem_percent: { value: 41, unit: "%", stale: false }, + }); + expect(screen.queryByText("999")).toBeNull(); + expect(screen.getByText("58")).not.toBeNull(); + expect(screen.getByText("41")).not.toBeNull(); + }); +}); diff --git a/client/src/StatsCell.tsx b/client/src/StatsCell.tsx new file mode 100644 index 0000000..9c5842b --- /dev/null +++ b/client/src/StatsCell.tsx @@ -0,0 +1,77 @@ +import type { CSSProperties } from "react"; +import type { Widget } from "./protocol"; +import type { MeterReading } from "./meter-store"; + +type Props = { + widget: Widget; + /** Live readings keyed by sensor source (the shared meter store map). */ + readings: Record; + style?: CSSProperties; + labelScale?: number; +}; + +/** + * Stats widget: a compact, bar-less cell that shows several sensor values + * at once, one "LABEL value unit" row per metric. Deliberately simpler than + * {@link MeterCell} — no bar, no colour grade — so it stays legible with 2–4 + * data points in a single grid cell, and takes new metrics without layout + * churn (add another entry to the widget's ``metrics`` list). + * + * Each row reads its value from the shared store by the metric's ``source``. + * A missing or stale reading renders "—" / dims the value, matching the + * MeterCell freshness treatment so the two kinds feel consistent. + */ +export function StatsCell({ widget, readings, style, labelScale = 1 }: Props) { + const metrics = widget.metrics ?? []; + + return ( +
+ {widget.label ? ( + + {widget.label} + + ) : null} +
+ {metrics.map((m) => { + const reading = readings[m.source] ?? null; + const stale = !reading || reading.stale; + const value = reading ? formatValue(reading.value) : "—"; + const unit = reading?.unit ?? ""; + return ( +
+ + {m.label ?? deriveLabel(m.source)} + + + {value} + {unit ? {unit} : null} + +
+ ); + })} +
+
+ ); +} + +/** Compact number format: integers above 10, one decimal below (mirrors + * MeterCell so CPU/MEM percentages read the same in both cells). */ +function formatValue(v: number): string { + if (!Number.isFinite(v)) return "—"; + if (Math.abs(v) >= 10) return Math.round(v).toString(); + return v.toFixed(1); +} + +/** Derive a short caption from a source name when the metric omits ``label``: + * ``cpu_percent`` → ``CPU``, ``mem_percent`` → ``MEM``. Falls back to the + * whole source uppercased for single-word sources. */ +function deriveLabel(source: string): string { + const head = source.split("_")[0] || source; + return head.toUpperCase(); +} diff --git a/client/src/demo.ts b/client/src/demo.ts index 8759b83..fd52560 100644 --- a/client/src/demo.ts +++ b/client/src/demo.ts @@ -96,7 +96,19 @@ const METER: ServerLayout = { { id: "open-url", kind: "button", label: "example.com", icon: { source: "simple-icons", name: "firefox" }, grid: [2, 0, 1, 1] }, { id: "tilix", kind: "button", label: "Tilix", icon: { source: "lucide", name: "square-terminal" }, grid: [3, 0, 1, 1] }, { id: "audio-toggle", kind: "button", label: "VLC", icon: { source: "lucide", name: "play" }, grid: [2, 1, 1, 1] }, - { id: "send-key-tab", kind: "button", label: "Ctrl+T", icon: { source: "lucide", name: "keyboard" }, grid: [3, 1, 1, 1] }, + // Combined stats cell — reads the same cpu_percent/mem_percent seeds as + // the bar meters above (readings are keyed by source), so it renders + // without its own seed entry. + { + id: "system", + kind: "stats", + label: "System", + grid: [3, 1, 1, 1], + metrics: [ + { source: "cpu_percent", label: "CPU" }, + { source: "mem_percent", label: "MEM" }, + ], + }, ], }; diff --git a/client/src/meter-store.ts b/client/src/meter-store.ts index b8874ba..0557baa 100644 --- a/client/src/meter-store.ts +++ b/client/src/meter-store.ts @@ -34,7 +34,14 @@ function loadInitial(): Record { typeof parsed.value === "number" && typeof parsed.unit === "string" ) { - initial[key.slice(METER_KEY_PREFIX.length)] = { + // Key by the persisted source name. Older entries (pre-source + // keying) stored the widget id as the key suffix and no source + // field; fall back to the suffix so they still load once. + const srcKey = + typeof parsed.source === "string" && parsed.source + ? parsed.source + : key.slice(METER_KEY_PREFIX.length); + initial[srcKey] = { value: parsed.value, unit: parsed.unit, stale: true, @@ -48,21 +55,26 @@ function loadInitial(): Record { } /** - * Hook: hold a map of widget-id -> latest reading, fed by + * Hook: hold a map of sensor-source -> latest reading, fed by * ``widget_update`` WebSocket frames. * + * Keyed by **source**, not widget id: a reading belongs to a sensor, and + * keying this way lets one ``stats`` widget show several sources, and two + * widgets bound to the same source share one canonical value. + * * Returned API: - * ``readings`` — current map (id -> reading). Render-time consumers - * read this directly; ``MeterCell`` filters by its own widget id. + * ``readings`` — current map (source -> reading). Render-time consumers + * read this directly; ``MeterCell`` looks up its own ``source`` and a + * ``stats`` cell looks up each of its metrics' sources. * ``onUpdate(frame)`` — feed in a server frame. * - * One shared instance is held at the ``App`` level so every meter cell - * subscribes to the same map without prop-drilling. Entries whose ids - * are not in ``activeWidgetIds`` are filtered out at render time so a - * removed meter doesn't leave ghost values on screen, and re-adding - * the same id later reads the cached value back from localStorage. + * One shared instance is held at the ``App`` level so every cell reads the + * same map without prop-drilling. Entries whose sources are not in + * ``activeSources`` are filtered out at render time so a source no longer + * on screen doesn't leave ghost values, and re-adding it later reads the + * cached value back from localStorage. */ -export function useMeterStore(activeWidgetIds: ReadonlySet) { +export function useMeterStore(activeSources: ReadonlySet) { const [readings, setReadings] = useState>(loadInitial); // Ref-mirror so the stale-detection interval (which calls setState) // doesn't need to depend on the readings state — otherwise every push @@ -78,7 +90,7 @@ export function useMeterStore(activeWidgetIds: ReadonlySet) { // (a) wastes work and (b) re-fires the meter-cell's transition // animation. We compare on the three fields the UI actually // renders: value, stale flag, and unit. - const existing = prev[m.id]; + const existing = prev[m.source]; if ( existing && existing.value === m.value && @@ -89,16 +101,16 @@ export function useMeterStore(activeWidgetIds: ReadonlySet) { } const next = { ...prev, - [m.id]: { value: m.value, unit: m.unit, stale: m.stale }, + [m.source]: { value: m.value, unit: m.unit, stale: m.stale }, }; try { // Persist the most recent reading so a page reload doesn't // flash a blank meter until the next push lands. The full set - // is small (a handful of widgets per layout), so a single - // JSON blob per id keeps writes cheap and survives quota + // is small (a handful of sources per layout), so a single + // JSON blob per source keeps writes cheap and survives quota // pressure better than one key per push. window.localStorage.setItem( - `${METER_KEY_PREFIX}${m.id}`, + `${METER_KEY_PREFIX}${m.source}`, JSON.stringify({ value: m.value, unit: m.unit, stale: m.stale, source: m.source }), ); } catch { @@ -129,25 +141,25 @@ export function useMeterStore(activeWidgetIds: ReadonlySet) { return () => window.clearInterval(id); }, []); - // Filter the live map down to just the ids the active layout + // Filter the live map down to just the sources the active layout // references. Done as a render-time memo (not an effect) so React // schedules a single render when both the readings map and the - // active-id set change; an effect-based reap would render once + // active-source set change; an effect-based reap would render once // with stale entries, then again after the effect fires. const visibleReadings = useMemo(() => { if (readings === EMPTY_RECORD) return readings; let filtered: Record | null = null; - for (const id of Object.keys(readings)) { - if (!activeWidgetIds.has(id)) { + for (const source of Object.keys(readings)) { + if (!activeSources.has(source)) { filtered = filtered ?? { ...readings }; - delete filtered[id]; + delete filtered[source]; } } return filtered ?? readings; - }, [readings, activeWidgetIds]); + }, [readings, activeSources]); const reading = useCallback( - (id: string): MeterReading | null => readings[id] ?? null, + (source: string): MeterReading | null => readings[source] ?? null, [readings], ); diff --git a/client/src/protocol.ts b/client/src/protocol.ts index 440bd08..d8fa301 100644 --- a/client/src/protocol.ts +++ b/client/src/protocol.ts @@ -4,9 +4,14 @@ * renders a placeholder for an unknown source. */ export type Icon = { source: string; name: string }; +/** One data point of a ``stats`` widget: a sensor ``source`` plus an + * optional short caption. When ``label`` is absent the client derives one + * from the source name (``cpu_percent`` → ``CPU``). */ +export type Metric = { source: string; label?: string | null }; + export type Widget = { id: string; - kind: "button" | "jogstrip" | "trackpad" | "meter"; + kind: "button" | "jogstrip" | "trackpad" | "meter" | "stats"; label?: string | null; icon?: Icon | null; grid: [number, number, number, number]; @@ -24,6 +29,8 @@ export type Widget = { min?: number | null; /** Meter-only: upper bound of the bar's visible range. */ max?: number | null; + /** Stats-only: the data points to display, one compact row each. */ + metrics?: Metric[] | null; }; export type ServerLayout = { diff --git a/client/src/style.css b/client/src/style.css index 1e318ed..a1a5d68 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -871,3 +871,74 @@ button:focus-visible { outline: none; } word-break: break-word; } +/* Stats cell: a compact multi-value readout. Shares the meter cell's chrome + * (background, border, radius, non-pressable) but stacks "LABEL value" rows + * instead of a single big number + bar, so 2–4 data points fit one grid slot. + * + * Layout: + * ┌────────────────────────┐ + * │ System │ <- .stats-title (optional caption) + * │ CPU 58 % │ <- .stats-row (label ....... value unit) + * │ MEM 41 % │ + * └────────────────────────┘ + */ +.cell-stats { + background: #1b222a; + border: 1px solid #2a333d; + border-radius: 12px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 6px; + padding: 10px 12px; + user-select: none; + -webkit-user-select: none; + touch-action: none; + min-height: 0; + min-width: 0; + overflow: hidden; +} + +.stats-title { + color: #7a8590; + font-weight: 600; + line-height: 1.2; + text-align: center; +} + +.stats-rows { + display: flex; + flex-direction: column; + gap: 3px; + min-height: 0; +} + +.stats-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + line-height: 1.1; +} + +.stats-row-label { + font-size: calc(clamp(10px, 1.6vw, 13px) * var(--content-scale) * var(--label-scale, 1)); + font-weight: 600; + color: #7a8590; + letter-spacing: 0.02em; +} + +.stats-row-value { + font-size: calc(clamp(14px, 2.4vw, 20px) * var(--content-scale)); + font-weight: 700; + font-variant-numeric: tabular-nums; + letter-spacing: -0.01em; +} + +.stats-row-unit { + font-size: 0.6em; + color: #7a8590; + font-weight: 500; + margin-left: 2px; +} + diff --git a/daemon/deckd/layouts.py b/daemon/deckd/layouts.py index 9b0e49b..9480d27 100644 --- a/daemon/deckd/layouts.py +++ b/daemon/deckd/layouts.py @@ -24,6 +24,25 @@ class Icon(BaseModel): name: str = Field(min_length=1) +class MetricSpec(BaseModel): + """One data point in a ``stats`` widget (issue #40). + + ``source`` names a daemon-side :class:`SensorSource` (same registry + the single-value ``meter`` widget binds to). ``label`` is the short + caption shown beside the value; when omitted the client derives one + from the source name (``cpu_percent`` -> ``CPU``), so a minimal + ``metrics: [{source: cpu_percent}]`` still reads sensibly. Kept a + distinct model (rather than a bare string) so more per-metric knobs + (unit override, min/max, colour) can be added without a breaking + schema change. + """ + + model_config = ConfigDict(extra="forbid") + + source: str = Field(min_length=1) + label: str | None = None + + class Widget(BaseModel): model_config = ConfigDict(extra="forbid") @@ -49,6 +68,12 @@ class Widget(BaseModel): source: str | None = None min: float | None = None max: float | None = None + # ``stats`` widgets bind to several sensor sources at once and render + # a compact, bar-less list of "label: value" rows. Each metric names a + # source the same way a ``meter`` names its single ``source``; the + # daemon subscribes to every referenced source while a stats widget is + # in the active layout, exactly as it does for meters. + metrics: list[MetricSpec] | None = None @field_validator("kind") @classmethod @@ -74,6 +99,11 @@ def _validate_meter_invariants(self) -> "Widget": f"meter widget min ({self.min}) must be strictly " f"less than max ({self.max})" ) + if self.kind == "stats" and not self.metrics: + raise ValueError( + "stats widgets require a non-empty 'metrics' list, each " + "naming a 'source' (e.g. metrics: [{source: cpu_percent}])" + ) return self diff --git a/daemon/deckd/server.py b/daemon/deckd/server.py index 7def265..c681cf6 100644 --- a/daemon/deckd/server.py +++ b/daemon/deckd/server.py @@ -24,6 +24,7 @@ from dbus_fast.aio import MessageBus from .input import KeySink + from .layouts import Widget from .platform import AppInfo, PlatformBackend from . import PASSWORD_HEADER @@ -72,6 +73,27 @@ def __init__(self, host: str, port: int) -> None: ) +# --------------------------------------------------------------------------- +# Sensor-source helpers. A widget can reference sensor sources two ways: a +# ``meter`` binds one (``w.source``); a ``stats`` widget binds several (one +# per entry in ``w.metrics``). These fold both shapes into a single view so +# the subscription/pump code doesn't special-case the widget kind. +# --------------------------------------------------------------------------- + + +def _widget_sources(widget: "Widget") -> list[str]: + """Every sensor source a widget displays, in declaration order.""" + if widget.kind == "meter" and widget.source: + return [widget.source] + if widget.kind == "stats" and widget.metrics: + return [m.source for m in widget.metrics if m.source] + return [] + + +def _widget_uses_source(widget: "Widget", source: str) -> bool: + return source in _widget_sources(widget) + + # --------------------------------------------------------------------------- # Host-identity helpers used by /health. Cheap to compute per-request; no # caching needed. Broken out so the tests can pin them via monkeypatch. @@ -414,16 +436,18 @@ def start_layouts_watcher(self) -> asyncio.Task[None] | None: # millisecond late. def _meters_for_source(self, source: str) -> list[Widget]: - """Return every meter widget in the active layout bound to ``source``. + """Return every widget in the active layout that displays ``source``. - Used by the pump to know which ``id`` to send in the push. - Order matches the layout's declaration order so the wire is - deterministic for tests. + Covers both single-value ``meter`` widgets (``w.source == source``) + and multi-value ``stats`` widgets (``source`` appears in their + ``metrics``). Used by the pump to know which widget ``id`` to send + in the push. Order matches the layout's declaration order so the + wire is deterministic for tests. """ return [ w for w in self._current_layout.widgets - if w.kind == "meter" and w.source == source + if _widget_uses_source(w, source) ] def _active_sources(self) -> set[str]: @@ -438,10 +462,9 @@ def _active_sources(self) -> set[str]: sources: set[str] = set() manager = self.sensors for w in self._current_layout.widgets: - if w.kind != "meter" or not w.source: - continue - if manager is None or manager.has(w.source): - sources.add(w.source) + for name in _widget_sources(w): + if manager is None or manager.has(name): + sources.add(name) return sources def _sync_sensor_subscriptions(self) -> None: diff --git a/layouts/default.yaml b/layouts/default.yaml index 7fb3229..f7bfb54 100644 --- a/layouts/default.yaml +++ b/layouts/default.yaml @@ -109,10 +109,17 @@ widgets: min: 0 max: 100 grid: [2, 1, 1, 1] - - id: mem_percent - kind: meter - label: MEM - source: mem_percent - min: 0 - max: 100 + # Combined system stats (issue #40). A bar-less ``stats`` cell showing + # several sensor values at once — simpler than the single-value meter + # above, and easy to grow: add another entry to ``metrics`` (each names + # a daemon-side source; ``label`` is optional and defaults to the source + # name, e.g. cpu_percent -> CPU). Sits where the standalone MEM meter was. + - id: system + kind: stats + label: System grid: [3, 1, 1, 1] + metrics: + - source: cpu_percent + label: CPU + - source: mem_percent + label: MEM diff --git a/tests/test_meters.py b/tests/test_meters.py index a69394c..f6bc8d3 100644 --- a/tests/test_meters.py +++ b/tests/test_meters.py @@ -252,3 +252,120 @@ async def test_server_pump_skips_unknown_sources(meter_layout: Path) -> None: finally: await server.stop() await test_server.close() + + +# --------------------------------------------------------------------------- +# stats widget (multi-source, bar-less) +# --------------------------------------------------------------------------- + + +class _MemSource(SensorSource): + """Test sensor modelling ``mem_percent``.""" + + name = "mem_percent" + unit = "%" + interval_s = 0.01 + + def __init__(self, initial: float = 41.0) -> None: + self.value = initial + + def read(self) -> SensorReading: + return SensorReading(source=self.name, value=self.value, unit=self.unit) + + +def test_stats_widget_loads_with_metrics(tmp_path: Path) -> None: + from deckd.layouts import load_layouts + + (tmp_path / "default.yaml").write_text( + """ +match: + - default +widgets: + - id: system + kind: stats + label: System + grid: [0, 0, 1, 1] + metrics: + - source: cpu_percent + label: CPU + - source: mem_percent +""" + ) + w = next(w for w in load_layouts(tmp_path)["default"].widgets if w.kind == "stats") + assert [m.source for m in w.metrics] == ["cpu_percent", "mem_percent"] + assert w.metrics[0].label == "CPU" + assert w.metrics[1].label is None # client derives it + + +def test_stats_widget_without_metrics_is_rejected(tmp_path: Path) -> None: + from deckd.layouts import load_layouts + + (tmp_path / "default.yaml").write_text( + """ +match: + - default +widgets: + - id: system + kind: stats + grid: [0, 0, 1, 1] +""" + ) + with pytest.raises(SystemExit): + load_layouts(tmp_path) + + +@pytest.fixture +def stats_layout(tmp_path: Path) -> Path: + """A default layout with a stats widget bound to two sources.""" + (tmp_path / "default.yaml").write_text( + """ +match: + - default +widgets: + - id: system + kind: stats + label: System + grid: [0, 0, 1, 1] + metrics: + - source: cpu_percent + label: CPU + - source: mem_percent + label: MEM +""" + ) + return tmp_path + + +async def test_stats_widget_pumps_all_its_sources(stats_layout: Path) -> None: + """The pump subscribes to every source a stats widget references and + pushes a widget_update (carrying the source) for each.""" + mgr = SensorManager([_CpuSource(initial=58.0), _MemSource(initial=41.0)]) + server, _, _, _ = make_test_server(layouts_dir=stats_layout, password=None) + server.sensors = mgr + server._sync_sensor_subscriptions() # type: ignore[attr-defined] + server.start_sensor_pump() + + test_server = TestServer(server.app, host="127.0.0.1") + await test_server.start_server() + try: + async with ws_connected_no_auth(test_server.port) as ws: + initial = json.loads(await asyncio.wait_for(ws.recv(), timeout=2)) + assert initial["type"] == "layout" + by_source: dict[str, float] = {} + for _ in range(80): + try: + raw = await asyncio.wait_for(ws.recv(), timeout=0.2) + except asyncio.TimeoutError: + continue + msg = json.loads(raw) + if msg.get("type") == "widget_update": + # Frame carries the stats widget id + the specific source. + assert msg["id"] == "system" + by_source[msg["source"]] = msg["value"] + if {"cpu_percent", "mem_percent"} <= by_source.keys(): + break + assert by_source.get("cpu_percent") == pytest.approx(58.0) + assert by_source.get("mem_percent") == pytest.approx(41.0) + finally: + await server.stop() + await test_server.close() From 77a491cef46c97d2dab5aea2a8fcb2c07942027e Mon Sep 17 00:00:00 2001 From: Jono Date: Fri, 24 Jul 2026 11:41:17 -0700 Subject: [PATCH 3/4] default layout: swap standalone CPU meter for the Ctrl+T button The System stats cell already shows CPU (and MEM), so the separate CPU bar meter at [2,1] was redundant. Restore the previously-commented send-key-tab (ctrl+t) button there instead, and keep the stats cell at [3,1]. Co-Authored-By: Claude Opus 4.8 --- layouts/default.yaml | 52 ++++++++++++++++---------------------------- 1 file changed, 19 insertions(+), 33 deletions(-) diff --git a/layouts/default.yaml b/layouts/default.yaml index f7bfb54..a3e9804 100644 --- a/layouts/default.yaml +++ b/layouts/default.yaml @@ -66,19 +66,16 @@ widgets: # Open the auto-detected terminal ($TERMINAL, then a candidate list). # For a specific emulator, use e.g. shell: "tilix" instead. terminal: true - # Send a key combo (issue #16). Commented out — the cell is now home - # to the live CPU meter below (same grid slot [2, 2]). Kept here as a - # worked example of the ``key`` action; re-enable by removing the - # leading ``#``s and moving it to a vacant slot. - # - id: send-key-tab - # kind: button - # label: Send Ctrl+T - # icon: - # source: lucide - # name: keyboard - # grid: [2, 2, 1, 1] - # action: - # key: "ctrl+t" + # Send a key combo (issue #16): fires ctrl+t to the focused app. + - id: send-key-tab + kind: button + label: Send Ctrl+T + icon: + source: lucide + name: keyboard + grid: [2, 1, 1, 1] + action: + key: "ctrl+t" # Audio / VLC media control. Commented out — the cell is now home # to the live CPU meter (the audio use case is rare from a phone # in practice, and the daemon already sends widget_update frames @@ -94,26 +91,15 @@ widgets: # grid: [3, 2, 1, 1] # action: # dbus: "org.mpris.MediaPlayer2.vlc:/org/mpris/MediaPlayer2 org.mpris.MediaPlayer2.Player.PlayPause" - # Live system stats (issue #40). Both meters are psutil-backed, - # so they work identically on Linux + macOS Intel + Apple Silicon - # with no per-platform install step. The daemon pushes - # ``widget_update`` frames at each source's poll cadence (1s for - # both); the bar fills proportionally between min/max. Tighten the - # min/max pair to dial in a narrower "happy" range on the bar — - # e.g. ``min: 0, max: 50`` makes the meter turn red sooner on a - # workload that should sit under 50% CPU. - - id: cpu_percent - kind: meter - label: CPU - source: cpu_percent - min: 0 - max: 100 - grid: [2, 1, 1, 1] - # Combined system stats (issue #40). A bar-less ``stats`` cell showing - # several sensor values at once — simpler than the single-value meter - # above, and easy to grow: add another entry to ``metrics`` (each names - # a daemon-side source; ``label`` is optional and defaults to the source - # name, e.g. cpu_percent -> CPU). Sits where the standalone MEM meter was. + # Live system stats (issue #40). A bar-less ``stats`` cell showing + # several psutil-backed sensor values at once — CPU + memory here — so + # they work identically on Linux + macOS Intel + Apple Silicon with no + # per-platform install step. The daemon pushes ``widget_update`` frames + # at each source's poll cadence (1s). Easy to grow: add another entry to + # ``metrics`` (each names a daemon-side source; ``label`` is optional and + # defaults to the source name, e.g. cpu_percent -> CPU). For a single + # value with a proportional bar instead, use a ``kind: meter`` widget + # (see README) bound to one ``source``. - id: system kind: stats label: System From be60f4080706960381c2851b047ec4fdfd683733 Mon Sep 17 00:00:00 2001 From: Jono Date: Fri, 24 Jul 2026 11:58:49 -0700 Subject: [PATCH 4/4] central dev runner --- Justfile | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Justfile b/Justfile index e3a6939..b4d6791 100644 --- a/Justfile +++ b/Justfile @@ -75,6 +75,35 @@ kill: fi done +# Run the whole dev stack with one command: the daemon (LAN, restart-on-edit) +# plus the Vite client (tailscale HTTPS). Ctrl+C — or either process dying — +# stops both. This replaces the old Procfile +# (daemon: dev-daemon-lan / client: dev-client-tailscale). For a plain +# LAN/HTTP client with no cert, run `just dev-lan` instead. +dev: + #!/usr/bin/env bash + set -uo pipefail + # kill 0 targets this script's process group, so Ctrl+C tears down both + # `just` children AND their grandchildren (deckd, node/vite) — no orphans + # left holding :8765 / :5173. + trap 'kill 0' EXIT + just dev-daemon-lan & + just dev-client-tailscale & + # Fall through (and via the trap, stop the sibling) the moment either exits. + wait -n + +# Same as `just dev` but with the plain-HTTP LAN client (no tailscale cert, +# no sudo). Reachable at http://:5173/ on the LAN; no PWA install +# prompt (that needs the HTTPS secure context `just dev` provides). +# TODO: consider replacing this with overmind/hivemind +dev-lan: + #!/usr/bin/env bash + set -uo pipefail + trap 'kill 0' EXIT + just dev-daemon-lan & + just dev-client-lan & + wait -n + # Run the daemon under a supervisor that restarts it when daemon/**/*.py # changes. Layout YAML hot-reload is built into the daemon itself; this is # only useful when editing Python.