mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 14:23:14 -06:00
Merge: platform-native capability declaration
This commit is contained in:
commit
6310f48590
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */ }
|
||||
}
|
||||
},
|
||||
|
||||
/*
|
||||
|
|
|
|||
199
server/test/brightsign-capabilities.test.js
Normal file
199
server/test/brightsign-capabilities.test.js
Normal 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());
|
||||
});
|
||||
Loading…
Reference in a new issue