Merge: platform-native capability declaration

This commit is contained in:
ScreenTinker 2026-08-05 14:25:46 -05:00
commit 6310f48590
4 changed files with 608 additions and 27 deletions

View file

@ -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

View file

@ -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)

View file

@ -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 */ }
}
},
/*

View file

@ -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());
});