From 5fe55307ba08101c046128cbae59b19b0969c3f2 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Wed, 5 Aug 2026 14:12:44 -0500 Subject: [PATCH] brightsign: declare capabilities from real hardware state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard offered every control to every display. This makes the BrightSign player answer for itself, at runtime, rather than from a per-platform table. The table cannot work here: the same XT245 supports remote screenshots with an SSD fitted and not without, because the DWS snapshot endpoint writes the full-size capture to disk before returning a thumbnail and answers "No primary storage found" on a flash-booted unit. So the bridge asks the host. - autorun.brs gains StorageProbe()/SendProbeResult(): walks SSD:, SD:, USB1: via roStorageHotplug.GetStorageStatus().mounted and reads real capacity through roStorageInfo. FLASH: is excluded deliberately — it is where the player boots from, not a volume the DWS accepts. Neither API has a JS equivalent, which is why this has to cross the bridge. - st-bridge.js posts the probe during boot and folds the answer into the existing readiness gate, with its own 3s timeout so a widget built without nodejs_enabled still becomes ready. computeCapabilities() then gates remote.screenshot/remote.stream/system.self_update on a mounted volume, the lifecycle and display commands on a live host, sync.native on the module AND OS >= 8.2.10, and display.power on CEC module presence. Unknown is treated as NO throughout: an unanswered probe declares nothing storage-gated. A control that appears once a disk is fitted is a smaller problem than one that silently fails. Never declared: kiosk, brightness, screen_timeout, install_apk, shell (no BrightSign equivalent) and time (BrightScript can, this host does not implement it — the same lie in the other direction). - Telemetry now reports the real drive from the probe instead of the widget's storage_quota, which it had been presenting as if it were the disk. Two declarations are knowingly optimistic and documented as such: transitions/pip composite DOM over a hardware plane and may be invisible over video (the roVideoMode.SetGraphicsZOrder("front") fix wants a hardware experiment, not a guess), and display.power rides module presence on a unit whose kernel logs "failed to get cec clock". Neither is load-bearing — transitions degrade to a hard cut, blanking works by tearing the media down. Tests cover the storage split, the hostless case, the sync floor, the never-declared set, and that every declared string is in the server's vocabulary — a typo there would silently disable a control fleet-wide. --- brightsign/README.md | 107 +++++++- brightsign/autorun.brs | 71 ++++++ brightsign/st-bridge.js | 258 ++++++++++++++++++-- server/test/brightsign-capabilities.test.js | 199 +++++++++++++++ 4 files changed, 608 insertions(+), 27 deletions(-) create mode 100644 server/test/brightsign-capabilities.test.js diff --git a/brightsign/README.md b/brightsign/README.md index 3739e4b..29f3a1e 100644 --- a/brightsign/README.md +++ b/brightsign/README.md @@ -214,6 +214,99 @@ Still Android-only, and correctly inert here: the Tier-2 device-owner commands ( `install_apk`, `shell`, `block_uninstall`, …) and `set_brightness` / `set_screen_timeout`, which have no BrightSign equivalent — a signage player has no per-window brightness or screen timeout. +## Declared capabilities + +The table above says what a BrightSign *can* do. What the dashboard actually offers comes from +`BS.capabilities()`, computed fresh on every call and sent with the device registration, where +`server/lib/player-capabilities.js` turns it into rendered controls. + +It is computed rather than tabulated because **the same model differs from unit to unit**. Our +XT245 supports remote screenshots with an SSD fitted and not without — the DWS snapshot endpoint +writes the full-size capture to disk before returning a thumbnail, so a unit booting from internal +flash is answered `No primary storage found`. No static per-platform table can know that, and a +table that guessed would put a button in the dashboard that cannot work. + +### How each one is decided + +| capability | condition | why | +|---|---|---| +| `playback.video` `.image` `.widget` `.youtube` `.zones` | always | properties of the renderer, not the hardware | +| `audio.mute` `audio.volume` | always | media-element level, re-applied per `play` | +| `sync.clock` | always | pure JS, needs no host | +| `remote.input` | always | synthesised DOM events; needs no `mouse_enabled` | +| `playback.transitions` `playback.pip` | always, **with a caveat** | see below | +| `offline.cache` | `navigator.serviceWorker` exists | no SW, no offline story | +| `system.restart_player` `system.reboot` `display.rotation` `display.resolution` | host bridge is live | each is a BrightScript call | +| `remote.screenshot` `remote.stream` `system.self_update` | host reports a mounted volume | DWS needs primary storage; the updater needs somewhere to stage `autorun.zip` | +| `display.power` | `@brightsign/cec` resolves | weak signal — see below | +| `sync.native` | `@brightsign/syncmanager` **and** OS ≥ 8.2.10 | below the floor the module can exist and silently do nothing | + +The storage answer comes from a `probe` message the bridge posts to the host during boot, before +the player registers. `StorageProbe()` in `autorun.brs` walks `SSD:`, `SD:` and `USB1:` through +`roStorageHotplug.GetStorageStatus().mounted` and reads real capacity via `roStorageInfo`. There is +no JS equivalent for either, which is also why device telemetry now reports the **disk** rather than +the widget's cache quota — the previous numbers were the `storage_quota` from `autorun.brs` +presented as if they were the drive. + +`FLASH:` is deliberately excluded from that walk. Internal flash is where the player boots from, not +a volume the DWS will accept a snapshot on; counting it would re-introduce exactly the button that +does nothing. + +**Unknown is treated as NO.** If the probe never answers — a widget built without `nodejs_enabled` +has no host at all — nothing storage-gated is declared. A control that appears later, once a disk is +fitted and the player reconnects, is a much smaller problem than one that silently fails today. + +### Never declared + +| | | +|---|---| +| `system.kiosk` | no lock-task or device-owner concept. The player is the only application on the box, so kiosk is not a mode to enter — it is the permanent state | +| `system.brightness` | no per-window or system brightness control | +| `system.screen_timeout` | no OS screen timeout; blanking is scheduled content, not a setting | +| `system.install_apk` | not Android | +| `system.shell` | no remote shell exposed to the player | +| `system.time` | BrightScript **can** set time and timezone — this host does not implement it. Declaring an unimplemented capability is the same lie in the other direction | + +Only the last one is a gap rather than a platform limit. The other five have no BrightSign +equivalent and should stay undeclared permanently. + +### The two caveated declarations + +**`playback.transitions` / `playback.pip`** both composite DOM content over video, and with `hwz` +the video is on a hardware plane the DOM sits *behind* (see above). They work over images and +widgets and may be invisible over video. Declared anyway: the failure is benign — a transition +degrades to a hard cut, which the engine already does on any failure — and withholding them would +remove a feature that genuinely works for the non-video majority of content. + +The likely fix is `roVideoMode.SetGraphicsZOrder("front")`, **deliberately not applied**. Changing +the z-order blind risks hiding video entirely on a player that currently works, and the trade is not +obvious: putting graphics in front may mean video is only visible through a colour key. This wants a +hardware experiment on a unit that is not in service — set the z-order in `autorun.brs` before +`FullScreenRect()`, play a video, and check that (a) video is still visible and (b) a DOM overlay +now covers it. Until someone runs it, the honest state is "transitions work except over video". + +**`display.power`** is declared on module presence, which we know is a weak signal: our XT245 +resolves `@brightsign/cec` perfectly while the kernel logs `failed to get cec clock` and the display +never responds. There is no way to distinguish "sent" from "received" without a cooperating display. +Blanking does not depend on it — the player tears the media down, which is what actually works — so +a display that ignores CEC still goes dark. The capability being optimistic here costs an +already-working feature nothing. + +### Needs hardware to verify + +Everything below was implemented against the documented APIs and the dev-cookbook, and reasoned +through, but has not run on a unit in the state that exercises it: + +- **The storage probe returning `present: true`.** Our XT245 has a dead microSD interface and boots + from flash, so it has only ever been observed answering `false`. The false path is verified on + hardware; the true path is verified only in tests. +- **`remote.screenshot` / `remote.stream` end to end** with a disk fitted — the DWS snapshot call + has never succeeded on our unit for that reason. +- **`system.self_update`** staging `autorun.zip` onto a real volume. +- **`sync.native`** on two or more units on one L2 network. Requires `networking/ptp_domain="0"` + and a reboot. +- **The `SetGraphicsZOrder` experiment** above. + ## Offline playback Content bytes are cached by the service worker (`server/player/sw.js`) into a dedicated @@ -297,8 +390,9 @@ needs its rotation done at the output, and this is the second one we have found. Stated plainly so nobody reads this as finished: -- **Nothing consumes the `bs_model` / `bs_serial` / `bs_screen` fields** the player reports. Device - telemetry (temperature, storage) also has no schema to land in yet. +- **Nothing consumes the `bs_model` / `bs_serial` / `bs_screen` fields** the player reports. + Temperature telemetry likewise has no schema to land in yet. Storage does now report the real + drive (via the capability probe) rather than the widget's cache quota. - **Native sync is wired but UNPROVEN on hardware.** The player drives it end to end — the leader announces on each advance, every member (leader included) binds via `attachVideo()` on a new id, and the resolved backend is chosen per group and pushed down. It cannot be verified with one @@ -335,9 +429,12 @@ Stated plainly so nobody reads this as finished: - **Registry from a remote origin is still unproven** — the original probe question. If injection turns out to be origin-dependent, identity moves to a local shim page that owns the registry and passes it to the hosted player in an iframe via `postMessage`. -- **Nothing here has run on hardware.** It is written against the BrightDeveloper docs and - checked line-by-line against the `brightsign/dev-cookbook` examples, which corrected four - config keys, the registry API and a hard SyncManager requirement (see below). +- **Written against the docs first, then corrected by hardware.** The port was checked + line-by-line against the `brightsign/dev-cookbook` examples, which corrected four config keys, + the registry API and a hard SyncManager requirement (see below). It has since run on a real + XT245 booting `FLASH:/autorun.brs` — playback, identity, blanking, rotation and the storage + probe's *negative* answer are all confirmed there. What that one unit cannot exercise is listed + under "Needs hardware to verify" above: it has no working storage and there is only one of it. ## Verified against the dev-cookbook diff --git a/brightsign/autorun.brs b/brightsign/autorun.brs index 4e3c4f9..d447ef1 100644 --- a/brightsign/autorun.brs +++ b/brightsign/autorun.brs @@ -249,6 +249,72 @@ Sub SetOrientation(widget As Object, o As String) widget.PostJSMessage({ type: "orientation-result", ok: ok, transform: transform$ }) End Sub +'=== capability probe ======================================================================= +' What this unit can actually do, answered by the only component that can see it. +' +' The page cannot determine any of this. There is no JavaScript API for device storage — +' @brightsign/storage exposes format/eject and nothing that enumerates volumes — so a player asked +' "do you have a disk?" could only guess. It matters because the DWS snapshot endpoint writes the +' full-size capture to disk before returning a thumbnail: with no card or SSD fitted it answers +' "No primary storage found", which is exactly what our XT245 does today. Declaring +' remote.screenshot on such a unit puts a button in the dashboard that cannot work. +' +' FLASH: is deliberately NOT counted. This player boots from internal flash because its card +' interface is physically dead, and the DWS still refuses the capture — internal flash is not +' "primary storage" as that endpoint means it. Counting it would re-create the exact lie this +' probe exists to prevent. +Function StorageProbe() As Object + result = { present: false, volume: "", free_mb: 0, total_mb: 0 } + + volumes = ["SSD:", "SD:", "USB1:"] + for each v in volumes + mounted = false + hp = CreateObject("roStorageHotplug") + if hp <> invalid then + st = hp.GetStorageStatus(v) + if st <> invalid and st.mounted then mounted = true + end if + + if mounted then + result.present = true + result.volume = v + si = CreateObject("roStorageInfo", v) + if si <> invalid then + ' Real device capacity. The widget's storage quota — all the page can see via + ' navigator.storage.estimate() — is the cache budget, not the disk. + result.free_mb = si.GetFreeInMegabytes() + result.total_mb = si.GetSizeInMegabytes() + end if + return result + end if + end for + + return result +End Function + +' Everything the page cannot ask the hardware directly. +Sub SendProbeResult(widget As Object) + di = CreateObject("roDeviceInfo") + storage = StorageProbe() + + osVer$ = "" + model$ = "" + if di <> invalid then + osVer$ = di.GetVersion() + model$ = di.GetModel() + end if + + widget.PostJSMessage({ + type: "probe-result" + storage_present: storage.present + storage_volume: storage.volume + storage_free_mb: storage.free_mb + storage_total_mb: storage.total_mb + os_version: osVer$ + model: model$ + }) +End Sub + Function FullScreenRect() As Object vm = CreateObject("roVideoMode") return CreateObject("roRectangle", 0, 0, vm.GetResX(), vm.GetResY()) @@ -569,6 +635,11 @@ Sub Main() cfg.server_url = m.server_url end if + else if m.type = "probe" then + ' Asked once during boot, before the player registers: the answer decides which + ' controls the dashboard is allowed to offer for this display. + SendProbeResult(widget) + else if m.type = "set-orientation" then if m.orientation <> invalid then SetOrientation(widget, m.orientation) diff --git a/brightsign/st-bridge.js b/brightsign/st-bridge.js index 72be96f..c2940ea 100644 --- a/brightsign/st-bridge.js +++ b/brightsign/st-bridge.js @@ -102,6 +102,13 @@ * synchronously still works rather than caching a Promise object as if it were a device id, * which would register a "[object Promise]" display. */ + /* + * What the HOST told us about the hardware. Empty until the probe answers, and it may never + * answer — a widget built without nodejs_enabled has no host at all. Every consumer treats + * absence as "unknown", never as "no". + */ + var probe = null; + var SECTION = 'screentinker'; // device_token belongs here as much as device_id: the server authenticates the claim to an // existing display with the token, so an id presented without one reads as a NEW display and @@ -126,11 +133,44 @@ return (v === undefined || v === null || v === '') ? null : String(v); } + /* + * Ask the host what the hardware can do. Folded into the SAME readiness gate as the registry + * prefetch, because the player declares its capabilities at registration — and registration + * happens once readiness fires. A probe that resolved afterwards would mean the first + * registration of every boot carried the wrong capability set, and the dashboard would show + * controls for a disk that is not there until the display happened to re-register. + * + * Never blocks: settle() runs on the answer, and the 5s cap in the boot path fires markReady + * regardless, so a host that says nothing costs a slower boot rather than a dead player. + */ + function probeHost(settle) { + if (!port) { settle(); return; } + var answered = false; + listeners.push(function (msg) { + if (answered || !msg || msg.type !== 'probe-result') return; + answered = true; + probe = msg; + settle(); + }); + if (!post({ type: 'probe' })) { settle(); return; } + // Independent of the global cap: if the host is alive but this one message is lost, readiness + // must not wait the full 5s for it. + if (global.setTimeout) global.setTimeout(function () { + if (answered) return; + answered = true; + settle(); + }, 3000); + } + function prefetch() { - if (!registry) { markReady(); return; } - var pending = CACHED_KEYS.length; + // The probe still runs without a registry: a widget can have a host bridge and no registry + // module, and the capability set matters more than the identity cache in that case. + var pending = (registry ? CACHED_KEYS.length : 0) + 1; // +1 = the host probe var settle = function () { if (--pending <= 0) markReady(); }; + probeHost(settle); + if (!registry) return; + for (var i = 0; i < CACHED_KEYS.length; i++) { (function (name) { var result; @@ -204,6 +244,142 @@ } catch (e) { return null; } } + /* + * Compare dotted versions. Returns -1/0/1. Missing or unparseable reads as OLDEST, so a feature + * with a firmware floor is withheld when we cannot prove the floor is met — the safe direction + * for a capability declaration. + */ + function compareVersions(a, b) { + var pa = String(a || '').split('.'); + var pb = String(b || '').split('.'); + for (var i = 0; i < Math.max(pa.length, pb.length); i++) { + var na = parseInt(pa[i], 10); if (isNaN(na)) na = -1; + var nb = parseInt(pb[i], 10); if (isNaN(nb)) nb = -1; + if (na > nb) return 1; + if (na < nb) return -1; + } + return 0; + } + + // SyncManager is documented from BrightSignOS 8.2.10. Below it the module may resolve and do + // nothing, which is the worst outcome for a video wall: every panel reports healthy and drifts. + var SYNCMANAGER_MIN_OS = '8.2.10'; + + /* + * WHAT THIS PLAYER CAN ACTUALLY DO — computed, never assumed. + * + * Declared to the server at registration and used by the dashboard to decide which controls to + * offer. The whole point is that a static per-platform table cannot know any of this: the same + * XT245 supports remote.screenshot with an SSD fitted and not without, and native sync only + * above a firmware floor. + * + * The bias is deliberate. A capability is declared only when the thing it gates will actually + * work; anything uncertain is withheld. A control that appears later, when a disk is fitted, is + * a far smaller problem than a button that silently does nothing — which is the bug this whole + * mechanism exists to remove. + */ + function computeCapabilities() { + var caps = []; + var add = function (c) { caps.push(c); }; + + // ---- always true on this platform ----------------------------------------------------- + // The player IS the web player; these are properties of the renderer, not of the hardware. + add('playback.video'); add('playback.image'); add('playback.widget'); add('playback.youtube'); + add('playback.zones'); + add('audio.mute'); add('audio.volume'); + add('sync.clock'); // clock-derived group sync is pure JS and needs nothing + add('remote.input'); // synthesised DOM events; needs no host and no mouse_enabled + + /* + * ⚠️ Both of these composite DOM content over video, and with hwz the video is on a hardware + * plane the DOM sits behind. They work over images and widgets and may be INVISIBLE over + * video. Declared anyway because the failure is benign — a transition degrades to a hard cut, + * which the engine already does on any failure — and withholding them would remove a feature + * that genuinely works for the non-video majority of content. + * + * The likely fix is roVideoMode.SetGraphicsZOrder("front"), deliberately NOT applied here: + * changing the z-order blind risks hiding video entirely on a player that currently works. + * See the README — it wants a hardware experiment, not a guess. + */ + add('playback.transitions'); add('playback.pip'); + + // Service-worker content caching. The quota is configured in autorun.brs (storage_path + + // storage_quota); without a service worker there is no offline story at all. + try { + if (global.navigator && global.navigator.serviceWorker) add('offline.cache'); + } catch (e) { /* no SW in this widget */ } + + // ---- needs the host bridge -------------------------------------------------------------- + // Each of these is a BrightScript call. Without a host the page can only reload itself, and a + // page-initiated reload does not reliably bring an roHtmlWidget back — the failure that + // darkened a customer's panel on 2026-07-28. So none of them are declared without one. + if (port) { + add('system.restart_player'); // host rebuilds the widget + add('system.reboot'); // RebootSystem + add('display.rotation'); // roVideoMode transform — the ONLY way video rotates here + add('display.resolution'); // roVideoMode SetMode + } else if (VideoOutputClass) { + // No host, but the JS video-output module resolved: resolution alone is still reachable. + add('display.resolution'); + } + + /* + * Storage-gated. The DWS snapshot endpoint writes the full-size capture to disk before + * returning a thumbnail, so with no card or SSD it answers "No primary storage found" — + * verified on our XT245, which boots from internal flash and is refused. Self-update needs a + * volume to stage autorun.zip onto for the same reason. + * + * Unknown (no probe answer) is treated as NO. Claiming a disk we could not confirm is exactly + * the button-that-does-nothing case. + */ + if (port && probe && probe.storage_present) { + add('remote.screenshot'); + add('remote.stream'); + add('system.self_update'); + } + + /* + * CEC. Module presence is a weak signal and we know it: our XT245 resolves @brightsign/cec + * perfectly while the kernel logs "failed to get cec clock" and the display never responds. + * There is no reliable way to distinguish "sent" from "received" without a cooperating + * display, so this is declared on module presence and the README states the limitation. + * + * Blanking does NOT depend on this — the player tears the media down, which is what actually + * works — so a display that ignores CEC still goes dark. + */ + if (CecClass) add('display.power'); + + /* + * Native sync needs the module AND the firmware floor. Below 8.2.10 the module may exist and + * silently do nothing, which on a video wall means every panel reports healthy while drifting + * apart — strictly worse than falling back to our own clock-derived protocol. + */ + var osVer = probe && probe.os_version ? probe.os_version : null; + if (!osVer && deviceInfo) { + try { osVer = deviceInfo.osVersion ? String(deviceInfo.osVersion) : null; } catch (e) { osVer = null; } + } + var syncManagerPresent = !!tryRequire('@brightsign/syncmanager'); + if (syncManagerPresent && osVer && compareVersions(osVer, SYNCMANAGER_MIN_OS) >= 0) { + add('sync.native'); + } + + /* + * NEVER declared, because BrightSign has no equivalent — this is the half of parity that is + * about removing controls rather than adding features: + * + * system.kiosk there is no lock-task or device-owner concept; the player is the + * only application on the box, so "kiosk" is not a mode to enter + * system.brightness no per-window or system brightness control + * system.screen_timeout no OS screen timeout; blanking is scheduled content, not a setting + * system.install_apk not Android + * system.shell no remote shell exposed to the player + * system.time BrightScript CAN set time and timezone, but this host does not + * implement it — declaring an unimplemented capability is the same + * lie in the opposite direction + */ + return caps; + } + var API = { /* True only when this really is a BrightSign — either module access or the UA. */ isBrightSign: function () { @@ -431,6 +607,22 @@ }); }, + /* + * The capability list to send at registration. + * + * Call AFTER onReady() — the host probe resolves inside the same readiness gate, and calling + * earlier returns a set computed without it, which would under-report a display that does + * have a disk. Cheap enough to call every registration rather than caching, so a display that + * gains an SSD declares it at its next reconnect instead of at its next reboot. + */ + capabilities: computeCapabilities, + + /* + * The raw host probe, for diagnostics. Null until the host answers, and null forever on a + * widget with no bridge — callers must treat that as "unknown", not as "nothing". + */ + hostProbe: function () { return probe; }, + onHostMessage: function (fn) { if (typeof fn === 'function') listeners.push(fn); }, /* @@ -467,28 +659,50 @@ } /* - * Storage. This is the WIDGET'S storage quota (storage_path/storage_quota in autorun.brs), - * NOT the device's filesystem — there is no documented JS API for the latter, and reporting - * eMMC/SD capacity would need the host. It is still the number that matters operationally, - * because it is the budget the player actually has for cached content, and it is what fills - * up. The dashboard labels it distinctly for this family so it is never read as "the disk". + * REAL device storage, when the host could see a volume. + * + * There is no JavaScript API for this — @brightsign/storage formats and ejects but does not + * enumerate — which is why this previously reported the widget's cache quota instead. The + * host has roStorageInfo and answers with the actual free/total of the mounted volume, so + * "storage" in the dashboard now means the disk rather than a browser budget. + * + * Set BEFORE the quota estimate below so the real numbers win: the estimate only fills in + * when the host had nothing to report. */ - try { - var s = global.navigator && global.navigator.storage; - if (s && typeof s.estimate === 'function') { - var e = s.estimate(); - if (e && typeof e.then === 'function') { - e.then(function (est) { - if (!est) return; - var quota = Number(est.quota), usage = Number(est.usage); - if (isFinite(quota) && quota > 0) { - telemetry.storage_total_mb = Math.round(quota / 1048576); - if (isFinite(usage)) telemetry.storage_free_mb = Math.round((quota - usage) / 1048576); - } - }, function () { /* estimate refused */ }); + if (probe && probe.storage_present) { + var total = Number(probe.storage_total_mb); + var free = Number(probe.storage_free_mb); + if (isFinite(total) && total > 0) telemetry.storage_total_mb = Math.round(total); + if (isFinite(free) && free >= 0) telemetry.storage_free_mb = Math.round(free); + } + + /* + * Fallback: the WIDGET'S storage quota (storage_path/storage_quota in autorun.brs), used + * only when the host reported no volume. It is the budget the player has for cached content + * and it is what fills up, so it is worth reporting — but it is not the disk, and it must + * never overwrite a real figure from the host. + */ + if (!telemetry.storage_total_mb) { + try { + var s = global.navigator && global.navigator.storage; + if (s && typeof s.estimate === 'function') { + var e = s.estimate(); + if (e && typeof e.then === 'function') { + e.then(function (est) { + if (!est) return; + // Re-checked inside the callback: a host probe can land while this is in flight, + // and the disk figure must not be overwritten by the cache budget afterwards. + if (telemetry.storage_total_mb) return; + var quota = Number(est.quota), usage = Number(est.usage); + if (isFinite(quota) && quota > 0) { + telemetry.storage_total_mb = Math.round(quota / 1048576); + if (isFinite(usage)) telemetry.storage_free_mb = Math.round((quota - usage) / 1048576); + } + }, function () { /* estimate refused */ }); + } } - } - } catch (e) { /* no storage manager */ } + } catch (e) { /* no storage manager */ } + } }, /* diff --git a/server/test/brightsign-capabilities.test.js b/server/test/brightsign-capabilities.test.js new file mode 100644 index 0000000..1843f5b --- /dev/null +++ b/server/test/brightsign-capabilities.test.js @@ -0,0 +1,199 @@ +'use strict'; + +// What a BrightSign declares it can do, computed at RUNTIME rather than assumed from a table. +// +// The same XT245 supports remote.screenshot with an SSD fitted and not without: the DWS snapshot +// endpoint writes the full-size capture to disk before returning a thumbnail, so a unit booting +// from internal flash is answered "No primary storage found". A static per-platform table could +// never know that, and declaring the capability anyway puts a button in the dashboard that cannot +// work — the exact failure the capability model exists to remove. +// +// The bias under test is one-directional: withhold when uncertain. A control that appears later, +// once a disk is fitted, is a much smaller problem than one that silently does nothing. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); +const fs = require('node:fs'); +const path = require('node:path'); + +const SRC = fs.readFileSync(path.join(__dirname, '..', '..', 'brightsign', 'st-bridge.js'), 'utf8'); +const { CAP_SET } = require('../lib/player-capabilities'); + +/** + * Load the real bridge against a fake BrightSign. + * @param {object} o + * host - a message port exists (nodejs_enabled widget with a live autorun.brs) + * probeRes - what the host answers the capability probe with (null = never answers) + * modules - which @brightsign/* modules resolve + * sw - navigator.serviceWorker present + */ +function load(o = {}) { + const opts = Object.assign( + { host: true, probeRes: null, modules: ['messageport', 'registry', 'deviceinfo', 'cec', 'syncmanager'], sw: true }, + o + ); + const posted = []; + const inbound = []; + + const sandbox = { + console: { log() {}, warn() {}, error() {} }, + navigator: Object.assign({ userAgent: 'BrightSign/9.0.189 (XT245) Chrome/120' }, opts.sw ? { serviceWorker: {} } : {}), + location: { search: '' }, + setInterval: () => 1, + setTimeout: (fn) => { inbound.push(fn); return 1; }, // deterministic: fired manually + clearTimeout: () => {}, + Promise, Object, Array, Uint8Array, Math, Date, RegExp, String, Number, + parseInt, isNaN, isFinite, decodeURIComponent, Error, + localStorage: { getItem: () => null, setItem() {} }, + __posted: posted, + }; + sandbox.window = sandbox; + + const handlers = []; + sandbox.require = (name) => { + const short = name.replace('@brightsign/', ''); + if (!opts.modules.includes(short)) throw new Error('no module ' + name); + if (short === 'messageport') { + if (!opts.host) throw new Error('no host'); + return function () { + return { + PostBSMessage: (m) => { + posted.push(m); + // The host answers the probe synchronously, which is the realistic ordering: the + // BrightScript handler replies on the same message loop turn. + if (m.type === 'probe' && opts.probeRes) { + handlers.forEach((h) => h(Object.assign({ type: 'probe-result' }, opts.probeRes))); + } + }, + addEventListener: (evt, fn) => { if (evt === 'bsmessage') handlers.push(fn); }, + }; + }; + } + if (short === 'registry') { + return function () { + return { read: () => Promise.resolve(''), write: () => Promise.resolve() }; + }; + } + if (short === 'deviceinfo') { + // `in`, not `||` — an empty osVersion is a case under test (a unit that will not say), and + // defaulting it would quietly turn the "unknown firmware" test into the happy path. + const osVersion = 'osVersion' in opts ? opts.osVersion : '9.0.189'; + return function () { + return { model: 'XT245', osVersion, serialNumber: 'SN1' }; + }; + } + if (short === 'cec') return function () { return { send: () => Promise.resolve(), addEventListener() {} }; }; + if (short === 'syncmanager') return function () { return { addEventListener() {}, synchronize() {} }; }; + if (short === 'videooutput') return function () { return { setMode: () => true }; }; + throw new Error('no module ' + name); + }; + + vm.createContext(sandbox); + vm.runInContext(SRC, sandbox); + return { api: sandbox.ScreenTinkerBS, posted, caps: sandbox.ScreenTinkerBS.capabilities() }; +} + +const WITH_DISK = { storage_present: true, storage_volume: 'SSD:', storage_free_mb: 90000, storage_total_mb: 120000, os_version: '9.0.189' }; +const NO_DISK = { storage_present: false, storage_volume: '', storage_free_mb: 0, storage_total_mb: 0, os_version: '9.0.189' }; + +test('EVERY declared capability is a name the server knows', () => { + // A typo here does not fail loudly — it silently disables a control for the whole platform, + // because the server drops unknown names rather than rejecting the declaration. + const { caps } = load({ probeRes: WITH_DISK }); + for (const c of caps) assert.ok(CAP_SET.has(c), `"${c}" is not in the capability vocabulary`); +}); + +test('THE STORAGE CASE: no disk means no screenshot, no stream, no self-update', () => { + // Our XT245 today: boots from internal flash, card interface dead, DWS refuses the capture. + const { caps } = load({ probeRes: NO_DISK }); + assert.ok(!caps.includes('remote.screenshot'), 'the DWS answers "No primary storage found"'); + assert.ok(!caps.includes('remote.stream')); + assert.ok(!caps.includes('system.self_update'), 'nowhere to stage autorun.zip'); +}); + +test('the same unit declares all three once a disk is fitted', () => { + const { caps } = load({ probeRes: WITH_DISK }); + assert.ok(caps.includes('remote.screenshot')); + assert.ok(caps.includes('remote.stream')); + assert.ok(caps.includes('system.self_update')); +}); + +test('an unanswered probe is treated as NO, not as yes', () => { + // Claiming a disk we could not confirm is precisely the button-that-does-nothing case. + const { caps } = load({ probeRes: null }); + assert.ok(!caps.includes('remote.screenshot')); +}); + +test('without a host bridge, nothing that needs BrightScript is declared', () => { + // A widget built without nodejs_enabled. The page can only reload itself, and a page-initiated + // reload does not reliably bring an roHtmlWidget back — the 2026-07-28 failure. + const { caps } = load({ host: false, modules: [] }); + for (const c of ['system.reboot', 'system.restart_player', 'display.rotation']) { + assert.ok(!caps.includes(c), `${c} needs the host`); + } + assert.ok(caps.includes('playback.video'), 'rendering still works'); + assert.ok(caps.includes('sync.clock'), 'clock sync is pure JS'); +}); + +test('native sync needs the module AND the firmware floor', () => { + // Below 8.2.10 the module may resolve and do nothing, which on a wall means every panel reports + // healthy while drifting — strictly worse than falling back to the clock protocol. + const ok = load({ probeRes: Object.assign({}, WITH_DISK, { os_version: '9.0.189' }) }); + assert.ok(ok.caps.includes('sync.native')); + + const tooOld = load({ probeRes: Object.assign({}, WITH_DISK, { os_version: '8.2.9' }) }); + assert.ok(!tooOld.caps.includes('sync.native'), '8.2.9 is below the 8.2.10 floor'); + + const noModule = load({ probeRes: WITH_DISK, modules: ['messageport', 'registry', 'deviceinfo', 'cec'] }); + assert.ok(!noModule.caps.includes('sync.native')); +}); + +test('an unknown firmware version withholds the floor-gated capability', () => { + const { caps } = load({ probeRes: Object.assign({}, WITH_DISK, { os_version: '' }), osVersion: '' }); + assert.ok(!caps.includes('sync.native'), 'unprovable floor must withhold, not assume'); +}); + +test('display.power tracks the CEC module', () => { + const withCec = load({ probeRes: WITH_DISK }); + assert.ok(withCec.caps.includes('display.power')); + + const without = load({ probeRes: WITH_DISK, modules: ['messageport', 'registry', 'deviceinfo', 'syncmanager'] }); + assert.ok(!without.caps.includes('display.power')); +}); + +test('NEVER declared: the things BrightSign genuinely has no equivalent for', () => { + // This is the half of parity that removes controls rather than adding features. + const { caps } = load({ probeRes: WITH_DISK }); + for (const c of ['system.kiosk', 'system.brightness', 'system.screen_timeout', + 'system.install_apk', 'system.shell', 'system.time']) { + assert.ok(!caps.includes(c), `${c} must never be declared on BrightSign`); + } +}); + +test('offline.cache follows the service worker, not the platform', () => { + assert.ok(load({ probeRes: WITH_DISK }).caps.includes('offline.cache')); + assert.ok(!load({ probeRes: WITH_DISK, sw: false }).caps.includes('offline.cache')); +}); + +test('the probe is actually sent to the host', () => { + const { posted } = load({ probeRes: WITH_DISK }); + assert.ok(posted.some((m) => m.type === 'probe'), 'nothing would ever populate the disk answer'); +}); + +test('real device storage from the host beats the widget cache quota', () => { + // There is no JS API for device storage, so this previously reported the widget's cache budget + // as if it were the disk. The host has roStorageInfo; its numbers must win. + const { api } = load({ probeRes: WITH_DISK }); + api.refreshTelemetry(); + const t = api.telemetrySnapshot(); + assert.equal(t.storage_total_mb, 120000); + assert.equal(t.storage_free_mb, 90000); +}); + +test('capabilities are recomputed per call, so a fitted disk lands on reconnect', () => { + // Not cached: a display that gains an SSD declares it at its next registration rather than + // waiting for a reboot. + const { api } = load({ probeRes: WITH_DISK }); + assert.deepEqual(api.capabilities(), api.capabilities()); +});