From c1270599c3fc7dc2aa4476a91f2484773b60d81e Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Thu, 6 Aug 2026 10:24:10 -0500 Subject: [PATCH] Make the parity matrix true, and stop three controls that do nothing The parity doc and the capability model had drifted from the players in both directions, and nothing failed when they did. Auditing all four players against their shipped sources turned up three controls a customer can press today that change nothing, and a set of baselines that were partly too generous and partly too stingy. The three dead controls: - The volume slider works on Android only. The dashboard sends set_volume as { level: 0..1 }; the web player reads payload.value and Tizen reads payload.value ?? payload.volume, so on both the number is undefined and the handler quietly declines. Three complete, working volume implementations that cannot be driven. The fix is one line in each player and belongs to those files; audio.volume is out of the web and brightsign baselines until it lands, held there by a biconditional test that fails the moment a player starts reading `level`. - Every #161 Tier-2 command was refused for the entire fleet. lock_now, power_menu, status_bar, block_uninstall and unblock_uninstall were gated on system.device_owner, which no player declares and no baseline grants, so supports() was false everywhere -- including on the device-owner panels the feature was built for. The dashboard still drew the buttons because it also gates on device.tier === 2. Fixed here: those five now accept system.device_owner OR system.kiosk, which PlayerCapabilities.kt declares under `if (isOwner)` and nothing else, and which no non-Android player declares. Android should declare system.device_owner and retire the stand-in. - enable_system_capture required the capability it creates. It raises the MediaProjection consent dialog -- the way a panel GAINS capture -- and was gated on remote.screenshot, so the only panel that needs it was the one panel that could not be sent it. Now ungated. The dashboard still hides the button behind the same check; that half is a frontend change. The baselines describe what an un-updated fielded display can do, and since v1.9.29 is the first build in which any player declares anything, that means v1.9.28. Every entry is now justified against `git show v1.9.28:`: - android loses display.power (v1.9.28 answers screen_on with a logged no-op, so the ON half is dead on every fielded panel and one capability renders both buttons) and system.reboot (owner-only; off-owner it paints an accessibility power dialog over the signage). Scheduled reboots now skip undeclared Android panels rather than logging a reboot that never happened, which is the reason that gate exists. - tizen gains display.power: v1.9.28 implements both halves with no signing and no panel API, so withholding it hid a working control. - brightsign loses audio.volume, display.power, system.reboot, system.restart_player and offline.cache. All need a host bridge the unit is not known to have, and restart_player without one is the page reload that darkened a panel on 2026-07-28. Also found, not fixed here because the files belong to others: st-bridge.js computeCapabilities() is dead code -- nothing calls BS.capabilities() -- and its 199 lines of passing tests constrain nothing a BrightSign actually declares; the two disagree on six capabilities and the bridge is right about most of them. BrightSign's "Force update" button is dead. PlayerCapabilities.kt under-declares display.brightness. The new test reads the player sources rather than the table: a dead-button rule (every gated command has a branch somewhere), an unreachable-capability rule (which would have caught system.device_owner), and biconditionals so a fix in a player fails the test until the baseline follows. Claims that need hardware -- CEC reaching a display, a widget being allowed a service worker, SyncManager holding frame lock -- are marked unverifiable in the document instead of asserted. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL --- docs/player-parity.md | 308 ++++++++++++++---- server/lib/player-capabilities.js | 205 ++++++++++-- server/test/capability-declaration.test.js | 2 +- .../test/device-capabilities-persist.test.js | 4 +- server/test/device-command-gating.test.js | 17 +- server/test/group-command-mixed-fleet.test.js | 20 +- server/test/player-capabilities.test.js | 35 +- server/test/player-parity-baselines.test.js | 255 +++++++++++++++ 8 files changed, 727 insertions(+), 119 deletions(-) create mode 100644 server/test/player-parity-baselines.test.js diff --git a/docs/player-parity.md b/docs/player-parity.md index 1380573..60e52a7 100644 --- a/docs/player-parity.md +++ b/docs/player-parity.md @@ -7,129 +7,303 @@ it puts a control on the dashboard that cannot work. Capability names come from `server/lib/player-capabilities.js`. Players declare their own set at registration; a player that declares nothing falls back to the per-platform baseline in that file. -**Legend** — ✅ supported · ⚠️ partial/conditional (reason given) · ❌ not supported (reason given) +**Legend** — ✅ verified in source · ⚠️ partial/conditional (reason given) · ❌ not supported (reason +given) · 💀 **dead**: the capability is declared or baselined but the control cannot work · ❓ +**unverifiable from source** — needs hardware, and is marked as such rather than asserted. BrightSign runs the *same* `server/player/index.html` as the browser, so it differs only where the -`autorun.brs` host bridge adds something the browser cannot reach. +`autorun.brs` host bridge adds something the browser cannot reach. The bridge has two halves: the +JS (`brightsign/st-bridge.js`, served by us at `/player/st-bridge.js`, always current) and the +on-device BrightScript that must create the widget with `nodejs_enabled:true`. `BS.hasHost()` is +false unless BOTH are present, and everything host-backed hangs off it. + +Verified at `2237eda`. Where a row cites "the fielded build" it means `v1.9.28` — the last release +before any player declared anything, and therefore the build every baseline is describing. + +--- + +## 🔴 Read this first: three dead controls found by this audit + +These are not gaps in coverage. They are controls a customer can press today that do nothing. + +### 1. The volume slider works on Android only + +`frontend/js/views/device-detail.js` sends `set_volume` as **`{ level: 0..1 }`**: + +```js +el?.addEventListener('change', () => sendCommand(device.id, cmd, { level: parseInt(el.value, 10) / 100 })); +``` + +| player | what the handler reads | result | +|---|---|---| +| Android | `payload.optDouble("level", -1.0)` | ✅ works | +| web | `data.payload?.value ?? data.value` | 💀 `undefined` → `isFinite` fails → silent no-op | +| Tizen | `payload.value ?? payload.volume` | 💀 `undefined` → logs `no usable value in payload` | +| BrightSign | (the web player) | 💀 as web | + +Three of the four players have a complete, working volume implementation that cannot be driven, +because nobody checked the payload key against the sender. **Fix: one line in +`server/player/index.html` and one in `tizen/js/app.js` — accept `level` (0..1) as well.** Until +then `audio.volume` has been removed from the `web` and `brightsign` baselines, and +`test/player-parity-baselines.test.js` holds that as a **biconditional**: fix the player and the +test fails, telling you to put the baseline entry back. + +### 2. Every #161 Tier-2 command was refused for the entire fleet — FIXED here + +`lock_now`, `power_menu`, `status_bar`, `block_uninstall` and `unblock_uninstall` were gated on +`system.device_owner`. **No player declares that name** — not `PlayerCapabilities.kt`, not +`tizen/js/capabilities.js`, not `declaredCapabilities()`, not `st-bridge.js` — and no baseline +granted it. So `supports()` returned false for every device on every platform and all five commands +were refused, *including on the device-owner panels the whole feature was built for*. The dashboard +still drew the buttons, because `device-detail.js` gates that block on `device.tier === 2 ||` too, +so an operator on a real owner panel pressed "Lock now" and got a silent server-side refusal. + +Fixed in `player-capabilities.js`: those five now accept `system.device_owner` **or** +`system.kiosk`. That is an exact stand-in, not a loose one — `PlayerCapabilities.kt` declares +`system.kiosk` under `if (isOwner)` and nothing else, which is precisely when `STPolicy`'s `owned()` +actions do anything, and no non-Android player declares it. + +**Follow-up owned by Android:** `PlayerCapabilities.kt` should declare `system.device_owner` under +`if (isOwner)`, at which point the stand-in becomes redundant. + +### 3. The capture bootstrap required the capability it creates — half FIXED here + +`enable_system_capture` raises Android's MediaProjection consent dialog: it is how a panel *gains* +full-screen capture. It was gated on `remote.screenshot`, so the only panel that needs it — no +accessibility, no projection grant, therefore no declared `remote.screenshot` — was the one panel +that could not be sent it. The command is now ungated. + +**Still broken, and it is a frontend change:** `device-detail.js` renders the button behind +`can('remote.screenshot')`, so it is still hidden on exactly those panels. + +--- ## Playback +No command routes to any `playback.*` capability and no dashboard control is gated on one, so these +describe content rendering. They are informational, and shown to the operator in the Info tab. + | capability | Android | Web | Tizen | BrightSign | |---|---|---|---|---| -| `playback.video` | ✅ ExoPlayer | ✅ `