screentinker/brightsign/st-bridge.js
ScreenTinker 46b2227dfd BrightSign: real telemetry and hardware identity, not a block of nulls
The web player sent battery/storage/RAM/CPU as nulls and 'Web Player' in the
wifi_ssid column, and the bs_model / bs_os_version / bs_serial / bs_screen fields
the player already reported at registration were consumed by nothing. A browser
tab genuinely has none of that. A BrightSign has some of it, and was reporting
none.

Telemetry comes from a CACHE the heartbeat reads synchronously. The beat builds
its payload every 15s without awaiting, but the one real sensor here —
deviceInfo.getTemperature() — returns a promise; awaiting inside the beat would
either block it or serialise a pending Promise into the payload, which is exactly
how device_id once became "[object Promise]". The cache starts EMPTY rather than
null-filled and is spread last, so off-platform nothing changes and a null here
can never clobber a value another player family legitimately supplied.

wifi_ssid was actively false on a PoE Ethernet appliance — an operator reading
that column was told an SSID that does not exist. It is null there now, and the
device view shows a real hardware block instead. Android's WiFi display is
untouched.

Hardware identity is a SEPARATE writer from applyDeviceInfo, deliberately. That
function is a blind full-row overwrite, and an empty device_info once nulled
seventeen columns every five minutes because {} is truthy. These fields arrive
only on a full register, so the same shape would wipe them on every lightweight
refresh in between; COALESCE makes "no news" mean "unchanged".

The OS build gets its own column rather than reusing android_version, which is
load-bearing as a TYPE discriminator: device-detail chooses between the Android
and browser layouts with android_version.startsWith('Web/'), so writing
"BrightSign OS 9.0.189" there would have rendered a BrightSign with battery and
WiFi cards — and applyDeviceInfo would have clobbered it on the next refresh.

Storage is labelled "Player Storage", not "Storage": on this family the number is
the widget's cache quota, not the device filesystem, and it lands in the same
column as Android's real disk figures.

Schema: device_telemetry.temperature_c REAL; devices.hardware_model,
hardware_serial, hardware_os_version, output_index. All nullable, all idempotent
in the existing migration array.

973 pass (+19). The temperature tests drive a real socket into a real server,
because changing the arity of the telemetry INSERT would break every player's
heartbeat, not just BrightSign's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 10:03:33 -05:00

458 lines
18 KiB
JavaScript

/*
* ScreenTinker — BrightSign bridge (the JavaScript half of autorun.brs).
*
* Loaded by the web player only when it is running on a BrightSign. Everything here is a
* capability the page cannot get on its own, plus one thing it must be STOPPED from doing:
*
* - reload(): a page-initiated location.reload() does not reliably bring an roHtmlWidget
* back (a ScreenTinker deploy darkened a customer's player this way on
* 2026-07-28). Ask the host to rebuild the widget instead.
* - identity: the registry survives reboots, content updates and origin changes;
* localStorage does not. The hardware serial is the stable id, so two panels
* imaged from the same card never collide.
* - sync: exposes which backend this deployment uses, so the player can run its own
* clock-derived group sync or defer to BrightSign's native BrightWall.
*
* Safe to load anywhere: if the @brightsign modules are absent (a desktop browser, or a widget
* built without nodejs_enabled) every method degrades to a no-op or a sane default, and
* isBrightSign() reports false. Nothing here may throw — this file loads before the player.
*/
(function (global) {
'use strict';
var HEARTBEAT_MS = 30000;
function tryRequire(name) {
try {
// `require` exists only inside an roHtmlWidget created with nodejs_enabled:true
if (typeof require !== 'function') return null;
return require(name);
} catch (e) {
return null;
}
}
var MessagePortClass = tryRequire('@brightsign/messageport');
var RegistryClass = tryRequire('@brightsign/registry');
var DeviceInfoClass = tryRequire('@brightsign/deviceinfo');
var VideoOutputClass = tryRequire('@brightsign/videooutput');
var CecClass = tryRequire('@brightsign/cec');
var port = null;
if (MessagePortClass) {
try { port = new MessagePortClass(); } catch (e) { port = null; }
}
// The UA check is the fallback for a widget without node integration: the player still needs
// to know it is on a BrightSign so it can pick the right video and caching behaviour, even
// when it cannot reach the host. Observed UA: "BrightSign/9.1.92.2 (HD1026) ... Chrome/120".
var uaIsBrightSign = typeof navigator !== 'undefined' &&
/BrightSign/i.test(navigator.userAgent || '');
var listeners = [];
if (port && typeof port.addEventListener === 'function') {
try {
port.addEventListener('bsmessage', function (msg) {
for (var i = 0; i < listeners.length; i++) {
try { listeners[i](msg); } catch (e) { /* one bad listener must not kill the rest */ }
}
});
} catch (e) { /* no inbound channel; outbound may still work */ }
}
function post(obj) {
if (!port || typeof port.PostBSMessage !== 'function') return false;
try { port.PostBSMessage(obj); return true; } catch (e) { return false; }
}
var registry = null;
if (RegistryClass) {
try { registry = new RegistryClass(); } catch (e) { registry = null; }
}
function screenNumber() {
try {
var m = new RegExp('[?&]screen=([^&]*)').exec(global.location.search || '');
var n = m ? parseInt(decodeURIComponent(m[1]), 10) : 1;
return (isNaN(n) || n < 1) ? 1 : n;
} catch (e) { return 1; }
}
/*
* Registry keys are namespaced per output. On a dual-output player autorun.brs runs TWO
* widgets against the same registry, the same SD storage_path and the same origin — so an
* un-namespaced "device_id" would have both outputs adopt one identity and collapse into a
* single device row. Screen 1 keeps the bare key so existing single-output panels are
* unaffected.
*/
function key(name) {
var s = screenNumber();
return s > 1 ? name + '_s' + s : name;
}
/*
* The registry API is ASYNCHRONOUS and section-oriented:
* registry.read(section, key) -> Promise<string>
* registry.write(section, {k: v}) -> Promise
* (per @brightsign/registry in the dev-cookbook enable-ldws example and the trace-event docs).
*
* The player needs identity synchronously during boot, so the values are prefetched once into
* a cache and every accessor reads the cache. Callers wait on whenReady() before trusting it.
* Both shapes are tolerated — a Promise or a bare value — so a firmware that returns
* synchronously still works rather than caching a Promise object as if it were a device id,
* which would register a "[object Promise]" display.
*/
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
// gets a fresh row. Persisting the id alone looked correct and still spawned a duplicate on
// every boot — found on hardware, not in a test.
var CACHED_KEYS = ['device_id', 'device_token', 'server_url', 'sync_backend'];
var cache = {};
var ready = false;
var readyWaiters = [];
function markReady() {
if (ready) return;
ready = true;
var waiters = readyWaiters;
readyWaiters = [];
for (var i = 0; i < waiters.length; i++) {
try { waiters[i](); } catch (e) { /* one bad waiter must not block the rest */ }
}
}
function normalise(v) {
return (v === undefined || v === null || v === '') ? null : String(v);
}
function prefetch() {
if (!registry) { markReady(); return; }
var pending = CACHED_KEYS.length;
var settle = function () { if (--pending <= 0) markReady(); };
for (var i = 0; i < CACHED_KEYS.length; i++) {
(function (name) {
var result;
try { result = registry.read(SECTION, key(name)); } catch (e) { settle(); return; }
if (result && typeof result.then === 'function') {
result.then(
function (v) { cache[name] = normalise(v); settle(); },
function () { settle(); }
);
} else {
cache[name] = normalise(result);
settle();
}
})(CACHED_KEYS[i]);
}
}
function regGet(name, fallback) {
var v = cache[name];
return (v === undefined || v === null) ? fallback : v;
}
/* values: { device_id: 'x', ... } using UNPREFIXED names; the screen suffix is applied here. */
function regSet(values) {
var payload = {};
for (var name in values) {
if (!Object.prototype.hasOwnProperty.call(values, name)) continue;
var v = values[name];
payload[key(name)] = v === null || v === undefined ? '' : String(v);
cache[name] = normalise(v);
}
if (!registry) return false;
try {
var r = registry.write(SECTION, payload);
// A rejected write must not surface as an unhandled rejection on a signage player.
if (r && typeof r.catch === 'function') r.catch(function () {});
return true;
} catch (e) { return false; }
}
var cec = null;
var cecTried = false;
function getCec() {
if (cecTried) return cec;
cecTried = true;
if (!CecClass) return null;
try {
// Connector names are HDMI-1..HDMI-4. Screen 2 lives on the second connector, so a
// dual-output player powers the display it actually paints rather than always output 1.
cec = new CecClass('HDMI-' + screenNumber());
} catch (e) { cec = null; }
return cec;
}
// Telemetry cache. Starts EMPTY rather than pre-filled with nulls: the player spreads this over
// its own telemetry object, and a null here would overwrite a value another player family had
// legitimately supplied. Absent means "nothing to say", which is not the same as "zero".
var telemetry = {};
var TELEMETRY_REFRESH_MS = 60000;
var deviceInfo = null;
if (DeviceInfoClass) {
try { deviceInfo = new DeviceInfoClass(); } catch (e) { deviceInfo = null; }
}
function qs(name) {
try {
var m = new RegExp('[?&]' + name + '=([^&]*)').exec(global.location.search || '');
return m ? decodeURIComponent(m[1]) : null;
} catch (e) { return null; }
}
var API = {
/* True only when this really is a BrightSign — either module access or the UA. */
isBrightSign: function () {
return !!(port || registry || deviceInfo || uaIsBrightSign);
},
/* True when the host bridge is live, i.e. restart/identity/sync calls will be honoured. */
hasHost: function () { return !!port; },
/*
* The stable hardware identity. autorun.brs passes it on the URL so it is available even
* before the modules resolve; the module is the authority when both exist.
*/
serial: function () {
if (deviceInfo) {
try {
var s = deviceInfo.serialNumber || (deviceInfo.getDeviceUniqueId && deviceInfo.getDeviceUniqueId());
if (s) return String(s);
} catch (e) { /* fall through to the URL */ }
}
return qs('serial') || null;
},
model: function () {
if (deviceInfo) {
try { if (deviceInfo.model) return String(deviceInfo.model); } catch (e) { /* fall through */ }
}
return qs('model') || null;
},
osVersion: function () {
if (deviceInfo) {
try { if (deviceInfo.osVersion) return String(deviceInfo.osVersion); } catch (e) { /* ignore */ }
}
return null;
},
/* Which physical output this widget is painting. 1 unless autorun.brs made a second one. */
screen: screenNumber,
/*
* Suffix callers should append to any per-display storage key. Two widgets on one player
* share an origin and therefore share localStorage, so the config, playlist cache and
* install salt all need separating or the second output silently becomes the first.
*/
storageSuffix: function () {
var s = screenNumber();
return s > 1 ? '_s' + s : '';
},
/*
* Persisted device id. Registry first (survives a card re-image with the same registry),
* then the URL, then localStorage for the browser case.
*/
deviceId: function () {
var v = regGet('device_id', null) || qs('device_id');
if (v) return v;
try { return global.localStorage.getItem('st_device_id'); } catch (e) { return null; }
},
/* The credential that proves this player IS that display. Useless without deviceId, and
deviceId is useless without it. */
deviceToken: function () { return regGet('device_token', null); },
/* Called once pairing completes, so a reboot comes back as the same display. */
setIdentity: function (deviceId, serverUrl, deviceToken) {
var values = {};
if (deviceId) values.device_id = deviceId;
if (serverUrl) values.server_url = serverUrl;
if (deviceToken) values.device_token = deviceToken;
regSet(values);
post({ type: 'identity', device_id: deviceId || null, server_url: serverUrl || null });
},
/*
* Forget this display. Required for the operator reset to mean anything: the registry
* outlives localStorage, so clearing local storage alone would leave the panel re-adopting
* the same identity on its next boot — a reset that resets nothing.
*/
clearIdentity: function () {
regSet({ device_id: '', device_token: '' });
return post({ type: 'identity', clear: true });
},
/*
* THE reload replacement. Never call location.reload() on this platform.
* Returns false if there is no host, so the caller can decide whether reloading in place
* is better than doing nothing (in a plain browser, it is).
*/
restart: function (reason) {
return post({ type: 'restart', reason: reason || 'unspecified' });
},
reboot: function () { return post({ type: 'reboot' }); },
/*
* Which sync protocol this deployment runs. Resolved by the server
* (server/lib/sync-backend.js) and pushed down; the registry holds the last known value so
* a cold boot with no network still starts in the right mode.
* 'screentinker' — our clock-derived group sync; the only option in a mixed fleet.
* 'brightsign' — native BrightWall; the host drives it over the bridge.
*/
syncBackend: function () {
return qs('sync_backend') || regGet('sync_backend', 'auto');
},
setSyncBackend: function (backend) {
if (!backend) return false;
regSet({ sync_backend: backend });
return post({ type: 'set-sync-backend', backend: backend });
},
/*
* Identity readiness. The registry is async, so a caller that registers with the server
* before this resolves would pair as a NEW display and leave a duplicate row behind. The
* callback always runs — on success, on failure, or off-platform — so nothing can hang the
* player waiting for hardware that isn't there.
*/
isReady: function () { return ready; },
onReady: function (fn) {
if (typeof fn !== 'function') return;
if (ready) { try { fn(); } catch (e) { /* ignore */ } return; }
readyWaiters.push(fn);
},
/*
* Real display power over CEC, which is the difference between a signage player and a browser
* tab: the web player can only paint the screen black, leaving the panel lit, drawing power
* and burning in. This actually tells the display to sleep.
*
* on = Image View On (0x0D)
* off = Standby (0x36)
*
* 0x4f is a broadcast header. Returns false when CEC is unavailable so the caller still
* applies the black overlay and something visible happens either way. Some displays ignore
* broadcast and need direct addressing — hence "best effort", not "guaranteed".
*/
displayPower: function (on) {
var c = getCec();
if (!c || typeof c.send !== 'function') return false;
try {
var packet = new Uint8Array(2);
packet[0] = 0x4f;
packet[1] = on ? 0x0d : 0x36;
var r = c.send(Array.prototype.slice.call(packet));
if (r && typeof r.catch === 'function') r.catch(function () {});
return true;
} catch (e) { return false; }
},
setVideoMode: function (mode) {
if (VideoOutputClass) {
try {
var vo = new VideoOutputClass();
if (vo && typeof vo.setMode === 'function') { vo.setMode(mode); return true; }
} catch (e) { /* fall back to the host */ }
}
return post({ type: 'set-video-mode', mode: mode });
},
onHostMessage: function (fn) { if (typeof fn === 'function') listeners.push(fn); },
/*
* Telemetry, read synchronously from a cache.
*
* The heartbeat builds its payload synchronously every 15s, but the only real number this
* platform exposes — temperature — arrives from a PROMISE (deviceInfo.getTemperature()).
* Awaiting it inside the heartbeat would either block the beat or, worse, serialise a pending
* Promise into the telemetry object, which is exactly how device_id once became
* "[object Promise]". So the values are refreshed on a timer and the beat reads whatever
* landed last.
*
* Returns an EMPTY object off-platform, so the caller can spread it unconditionally and a
* browser's telemetry is unchanged.
*/
telemetrySnapshot: function () { return telemetry; },
/*
* Refresh the cache. Safe to call repeatedly; each source fails independently so one missing
* API cannot take the others down with it.
*/
refreshTelemetry: function () {
// Temperature: documented on @brightsign/deviceinfo, resolves { celsius }.
if (deviceInfo && typeof deviceInfo.getTemperature === 'function') {
try {
var t = deviceInfo.getTemperature();
if (t && typeof t.then === 'function') {
t.then(function (v) {
var c = v && (v.celsius !== undefined ? v.celsius : v.Celsius);
if (typeof c === 'number' && isFinite(c)) telemetry.temperature_c = Math.round(c * 10) / 10;
}, function () { /* sensor unavailable on this model */ });
}
} catch (e) { /* older OS without the call */ }
}
/*
* 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".
*/
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 */ });
}
}
} catch (e) { /* no storage manager */ }
},
/*
* Heartbeat. autorun.brs rebuilds the widget after three missed beats, which is what
* recovers a page that loaded fine and then wedged (dead socket, JS exception, decoder
* stall) — a case load-error never reports.
*/
startHeartbeat: function () {
if (!port) return;
var beat = function () { post({ type: 'heartbeat', t: Date.now() }); };
beat();
return global.setInterval(beat, HEARTBEAT_MS);
}
};
global.ScreenTinkerBS = API;
// Kick the registry prefetch immediately, and never let a silent module hold boot: the player
// stops waiting after this and carries on with whatever identity it has.
prefetch();
if (global.setTimeout) global.setTimeout(markReady, 5000);
// Only worth polling where a sensor exists. A browser has neither the temperature API nor a
// meaningful storage quota to report, and an interval that can only ever produce nothing is
// just a timer burning a wakeup every minute on a device that runs for months.
if (API.isBrightSign()) {
API.refreshTelemetry();
if (global.setInterval) global.setInterval(API.refreshTelemetry, TELEMETRY_REFRESH_MS);
}
if (API.hasHost()) API.startHeartbeat();
})(typeof window !== 'undefined' ? window : this);