Merge branch 'feat/brightsign-ip-from-js'
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run

# Conflicts:
#	server/test/device-controls-hidden.test.js
This commit is contained in:
ScreenTinker 2026-08-10 15:40:31 -05:00
commit 28885d1a13
8 changed files with 538 additions and 13 deletions

View file

@ -47,6 +47,16 @@
*/
var VideoModeConfigClass = tryRequire('@brightsign/videomodeconfiguration');
var CecClass = tryRequire('@brightsign/cec');
// Reads the attached display's EDID. Read-only; the mode setter is videomodeconfiguration.
var VideoOutputClass = tryRequire('@brightsign/videooutput');
/*
* Node's standard library, present because the widget is created with nodejs_enabled. Used for
* the LAN address (see refreshTelemetry) exactly as BrightSign's own dev-cookbook templates do.
* tryRequire, not a bare require: in a plain browser there is no require at all, and this file
* must load there too.
*/
var osModule = tryRequire('os');
var fsModule = tryRequire('fs');
var port = null;
if (MessagePortClass) {
@ -881,6 +891,199 @@
} catch (e) { /* older OS without the call */ }
}
/*
* The address this player holds on the LAN the one an integrator needs to reach its DWS on
* site, and the field the dashboard has always had a slot for and never been able to fill.
*
* This is Node's own `os.networkInterfaces()`, which is what BrightSign's dev-cookbook does in
* both html5-app-template/src/info.ts and src-js/info.js. The widget is created with
* nodejs_enabled, so the standard library is simply there there is no @brightsign module for
* this, and looking for one is a dead end that cost a whole afternoon:
*
* @brightsign/networkconfiguration EXISTS but exposes only callback,
* getNeighborInformation and enableLeds no config reader at all.
* @brightsign/hostconfiguration has getConfig()/applyConfig(), but it returns HOST settings
* (forwardingEnabled, hostName, loginPassword, nameServers) with no address in them.
*
* Both verified by enumerating the live objects on our XT245 (FW 9.1.93.2), not from docs
* the docs pages for the JavaScript API 404, and their own roNetworkConfiguration page links
* to one of the dead URLs. getCurrentConfig() is BrightScript-only.
*
* `internal` is Node's own loopback flag, which beats string-matching 127.*; the 169.254
* link-local a player assigns itself when DHCP never answered is still filtered by hand,
* because sending an operator to an unreachable address is worse than showing nothing.
*
* family is compared loosely: it is the string "IPv4" on the Node in this firmware (and in
* the cookbook), but became the number 4 in Node 18, and this file outlives firmwares.
*/
if (osModule && typeof osModule.networkInterfaces === 'function') {
try {
var ifaces = osModule.networkInterfaces() || {};
var names = Object.keys(ifaces);
for (var ni = 0; ni < names.length; ni++) {
var addrs = ifaces[names[ni]] || [];
for (var ai = 0; ai < addrs.length; ai++) {
var a = addrs[ai];
if (!a || a.internal) continue;
var ip = String(a.address || '');
if (!ip) continue;
var isV4 = (a.family === 'IPv4' || a.family === 4);
var isV6 = (a.family === 'IPv6' || a.family === 6);
if (isV4 && !telemetry.local_ip && ip.indexOf('169.254.') !== 0) telemetry.local_ip = ip;
/*
* The v6 column has existed as long as the v4 one and has never held anything, on any
* player. The dashboard is already built for it it renders a second card ONLY when
* this is set, precisely so the overwhelmingly v4 fleet does not pay screen space for
* an empty row.
*
* fe80:: is skipped for the same reason 169.254 is: a link-local address is scoped to
* one interface and cannot be dialled from a laptop across the office, so reporting it
* would send someone somewhere they cannot go. A ULA (fd00::/8) is kept that IS
* reachable on the site network, which is the question this field answers.
*/
if (isV6 && !telemetry.local_ip6 && ip.toLowerCase().indexOf('fe80') !== 0) {
// Node appends a zone id to link-locals ("fe80::1%eth0"); strip any that survives.
var pct = ip.indexOf('%');
telemetry.local_ip6 = pct === -1 ? ip : ip.slice(0, pct);
}
}
}
} catch (e) { /* no networking yet, or a firmware without it — stay silent */ }
}
/*
* WHICH SCREEN IS PLUGGED IN, and what the output is actually driving.
*
* The first question about a dark sign is "which panel is that?", and until now the dashboard
* could not answer it: screen_width/height are what the PAGE believes it has, which is the
* widget's own geometry, not what the hardware negotiated with the display.
*
* The output is chosen by SCREEN NUMBER, because a dual-output player registers one device
* row per output (?screen=N, see output_index) and each row must report its OWN panel a box
* driving a lobby TV and a menu board would otherwise show the lobby TV twice.
*
* Both names are tried. Probed on an XT245 (FW 9.1.93.2): "hdmi" and "HDMI-1" both resolve to
* output 1 and answer with the same monitor, while a second output that does not exist fails
* cleanly "hdmi2" throws from the constructor and "HDMI-2" rejects. So a single-output
* player simply reports nothing here rather than inventing a screen.
*/
if (VideoOutputClass) {
var wantScreen = screenNumber();
var outNames = ['HDMI-' + wantScreen];
if (wantScreen === 1) outNames.push('hdmi');
for (var oi = 0; oi < outNames.length; oi++) {
try {
var vo = new VideoOutputClass(outNames[oi]);
if (!vo || typeof vo.getEdidIdentity !== 'function') continue;
var edid = vo.getEdidIdentity();
if (edid && typeof edid.then === 'function') {
edid.then(function (e) {
var mn = e && (e.monitorName || e.monitor_name);
if (typeof mn === 'string' && mn.trim()) telemetry.attached_display = mn.trim();
}, function () { /* no display on this output */ });
}
} catch (e) { /* no such output on this model */ }
}
}
/*
* The mode the output is negotiated to, which is not the same as the widget's size. Reported
* as WxH@Hz so it reads the way an installer would say it out loud. Our XT245 answers
* 1920x1200@60 the panel's native mode, while the page reports its own 1920x1080 canvas.
*/
if (VideoModeConfigClass) {
try {
var vmc = new VideoModeConfigClass();
if (vmc && typeof vmc.getActiveMode === 'function') {
var mode = vmc.getActiveMode();
if (mode && typeof mode.then === 'function') {
mode.then(function (m) {
if (!m) return;
var w = m.graphicsPlaneWidth || m.width;
var h = m.graphicsPlaneHeight || m.height;
var f = m.frequency || m.refreshRate;
if (w && h) telemetry.video_mode = w + 'x' + h + (f ? '@' + f : '');
}, function () { /* mode not readable on this firmware */ });
}
}
} catch (e) { /* older OS without the call */ }
}
/*
* Memory, load and REAL uptime all from the same Node standard library the address above
* came from, and all previously NULL on every BrightSign in the fleet.
*
* uptime deliberately OVERRIDES the page's own figure. index.html sends
* performance.now()/1000, which is how long this PAGE has been up; a widget rebuilt by the
* watchdog resets it while the player has been running for weeks. os.uptime() is the machine,
* which is what an operator reading "uptime" means and what makes a reboot loop visible.
*
* cpu_usage is the 1-minute load average normalised by core count and expressed as a
* percentage, so it is comparable with what the other players report rather than being a raw
* load figure that means nothing next to them. Clamped, because load can exceed core count.
*/
if (osModule) {
try {
if (typeof osModule.totalmem === 'function' && typeof osModule.freemem === 'function') {
var totalB = osModule.totalmem();
var freeB = osModule.freemem();
if (isFinite(totalB) && totalB > 0) telemetry.ram_total_mb = Math.round(totalB / 1048576);
if (isFinite(freeB) && freeB >= 0) telemetry.ram_free_mb = Math.round(freeB / 1048576);
}
if (typeof osModule.uptime === 'function') {
var up = osModule.uptime();
if (isFinite(up) && up > 0) telemetry.uptime_seconds = Math.round(up);
}
if (typeof osModule.loadavg === 'function' && typeof osModule.cpus === 'function') {
var la = osModule.loadavg();
var cores = (osModule.cpus() || []).length || 1;
if (la && isFinite(la[0])) {
var pct = Math.round((la[0] / cores) * 100);
telemetry.cpu_usage = pct < 0 ? 0 : (pct > 100 ? 100 : pct);
}
}
} catch (e) { /* a firmware without part of the stdlib — report what did work */ }
}
/*
* REAL disk, from statfs rather than the browser's storage quota.
*
* The quota is what this file used to report and it is not the disk: our XT245 answered
* "1026 MB total" for a 119 GB NVMe, because navigator.storage.estimate() describes the
* widget's cache budget. An operator reading that has been told something false about the
* machine, which is worse than an empty field.
*
* The volume is DISCOVERED, not assumed. BrightSign mounts storage under /storage (SD, SSD,
* USB), and which one a given player boots from varies ours runs from an NVMe while the
* card slot is dead. So statfs every mount and keep the largest, which is the content volume
* on every shape of player. Falls back to the widget's own working directory.
*/
if (fsModule && typeof fsModule.statfsSync === 'function') {
try {
var candidates = [];
try {
var mounts = fsModule.readdirSync('/storage') || [];
for (var mi = 0; mi < mounts.length; mi++) candidates.push('/storage/' + mounts[mi]);
} catch (e) { /* no /storage on this firmware */ }
candidates.push('/');
var bestTotal = 0, bestFree = 0;
for (var ci = 0; ci < candidates.length; ci++) {
try {
var st = fsModule.statfsSync(candidates[ci]);
if (!st || !isFinite(st.blocks) || !isFinite(st.bsize)) continue;
var tot = st.blocks * st.bsize;
// bavail is space usable by an unprivileged writer; bfree includes the reserve.
var fre = (isFinite(st.bavail) ? st.bavail : st.bfree) * st.bsize;
if (tot > bestTotal) { bestTotal = tot; bestFree = fre; }
} catch (e) { /* not a mount point */ }
}
if (bestTotal > 0) {
telemetry.storage_total_mb = Math.round(bestTotal / 1048576);
telemetry.storage_free_mb = Math.round(bestFree / 1048576);
}
} catch (e) { /* leave the quota estimate below to fill in */ }
}
/*
* REAL device storage, when the host could see a volume.
*

View file

@ -488,6 +488,8 @@ export default {
'device.info.os_version': 'OS Version',
'device.info.serial': 'Serial',
'device.info.temperature': 'Temperature',
'device.info.attached_display': 'Attached display',
'device.info.video_mode': 'Video mode',
'device.info.output_n': '(output {n})',
'device.info.player_type': 'Player Type',
'device.info.web_player': 'Web Player',

View file

@ -577,10 +577,15 @@ async function loadDevice(deviceId, activeTab = null) {
</div>` : ''}
${latestTelemetry.storage_total_mb ? `
<div class="info-card">
<!-- Labelled "player storage", not "storage": on this family the number is the
widget's cache quota, not the device filesystem. Same column as Android's real
disk figures, so the label is what stops it being read as "the disk is 1 GB". -->
<div class="info-card-label">${t('device.info.player_storage')}</div>
<!-- This used to be labelled "player storage" because the number WAS the widget's
cache quota rather than the disk a real XT245 with a 119 GB NVMe reported
"1026 MB", and the label was the only thing stopping that being read as the disk
size. The bridge now reads the actual filesystem (statfs over the mounts under
/storage, largest wins), so it means the same thing as Android's figure and is
labelled the same. The bridge is served per page load, so a player that has not
re-fetched it yet still reports the quota see the CDN caching note in
docs/player-parity.md before trusting a suspiciously round ~1 GB here. -->
<div class="info-card-label">${t('device.info.storage')}</div>
<div class="info-card-value small" id="telStorage">${latestTelemetry.storage_free_mb != null ? t('device.info.size_free', { size: formatBytes(latestTelemetry.storage_free_mb) }) : '--'}</div>
<div class="progress-bar">
<div class="progress-bar-fill ${((latestTelemetry.storage_total_mb - latestTelemetry.storage_free_mb) / latestTelemetry.storage_total_mb) < 0.8 ? 'success' : 'warning'}"
@ -593,6 +598,20 @@ async function loadDevice(deviceId, activeTab = null) {
<div class="info-card-label">${t('device.info.temperature')}</div>
<div class="info-card-value small" id="telTemp">${latestTelemetry.temperature_c}&deg;C</div>
</div>` : ''}
<!-- The physical panel, from its EDID, and the mode the output is negotiated to. Shown
only when the player reports them, like every other card here: a family that cannot
read its own output must not grow an empty row. On a dual-output player each device
row is one output, so this is THAT output's screen — not the box's first. -->
${latestTelemetry.attached_display ? `
<div class="info-card">
<div class="info-card-label">${t('device.info.attached_display')}</div>
<div class="info-card-value small" id="telDisplay">${esc(latestTelemetry.attached_display)}</div>
</div>` : ''}
${latestTelemetry.video_mode ? `
<div class="info-card">
<div class="info-card-label">${t('device.info.video_mode')}</div>
<div class="info-card-value small" id="telVideoMode">${esc(latestTelemetry.video_mode)}</div>
</div>` : ''}
${device.android_version && !device.android_version.startsWith('Web/') ? `
<div class="info-card">
<div class="info-card-label">${t('device.info.wifi')}</div>
@ -638,11 +657,18 @@ async function loadDevice(deviceId, activeTab = null) {
<div class="info-card-label">${t('device.clock.label')}</div>
<div class="info-card-value small">${renderDeviceClock(device)}</div>
</div>
${device.android_version && !device.android_version.startsWith('Web/') ? `
<!-- Shown for Android as before, and now for ANY player that actually reports the value.
These were platform-gated when Android was the only family that could measure them;
a BrightSign widget runs with nodejs_enabled and the bridge reads os.totalmem/freemem
and the load average, so the numbers exist and were being thrown away by a gate that
asked what the device IS instead of what it SENT. Keeping the Android arm means a
panel that reports nothing still shows "--" there rather than losing its cards. -->
${(device.android_version && !device.android_version.startsWith('Web/')) || latestTelemetry.ram_free_mb != null ? `
<div class="info-card">
<div class="info-card-label">${t('device.info.ram')}</div>
<div class="info-card-value small" id="telRam">${latestTelemetry.ram_free_mb ? t('device.info.size_free', { size: formatBytes(latestTelemetry.ram_free_mb) }) : '--'}</div>
</div>
</div>` : ''}
${(device.android_version && !device.android_version.startsWith('Web/')) || latestTelemetry.cpu_usage != null ? `
<div class="info-card">
<div class="info-card-label">${t('device.info.cpu_usage')}</div>
<div class="info-card-value small" id="telCpu">${latestTelemetry.cpu_usage != null ? latestTelemetry.cpu_usage.toFixed(1) + '%' : '--'}</div>

View file

@ -388,6 +388,16 @@ const migrations = [
// dual-stack panel genuinely has both and an operator may need either — collapsing them would
// make the field mean "whichever we happened to enumerate first".
"ALTER TABLE device_telemetry ADD COLUMN local_ip6 TEXT",
// What is physically PLUGGED IN, read from the display's EDID, and the mode actually being
// driven. A signage operator's first question about a dark screen is which panel it is and
// whether the player is outputting at all — the dashboard could say neither, and
// screen_width/height are what the PAGE thinks it has, not what the hardware negotiated.
//
// Per-telemetry-row rather than on `devices` because a display can be swapped, unplugged or
// renegotiated without the player re-registering, and because a dual-output player registers ONE
// ROW PER OUTPUT (see output_index) — each row must carry its own screen, not the box's first.
"ALTER TABLE device_telemetry ADD COLUMN attached_display TEXT",
"ALTER TABLE device_telemetry ADD COLUMN video_mode TEXT",
// Panel temperature in Celsius. REAL because the sensor reports fractions, and nullable because
// only some hardware exposes one — Android and the browser players send nothing and must keep
// reading as "no sensor" rather than "0 degrees", which is why every read site treats null as

View file

@ -23,7 +23,7 @@ router.get('/', (req, res) => {
const devices = db.prepare(`
SELECT d.*,
t.battery_level, t.battery_charging, t.storage_free_mb, t.storage_total_mb,
t.ram_free_mb, t.ram_total_mb, t.wifi_ssid, t.wifi_rssi, t.uptime_seconds, t.local_ip, t.local_ip6,
t.ram_free_mb, t.ram_total_mb, t.wifi_ssid, t.wifi_rssi, t.uptime_seconds, t.local_ip, t.local_ip6, t.attached_display, t.video_mode,
t.cpu_usage,
s.filepath as screenshot_path, s.captured_at as screenshot_at,
u.email as owner_email, u.name as owner_name

View file

@ -22,7 +22,7 @@ const path = require('node:path');
const SRC = fs.readFileSync(path.join(__dirname, '..', '..', 'brightsign', 'st-bridge.js'), 'utf8');
/** Load the bridge into a fake window. `mods` present => pretend we are on a BrightSign. */
function load({ search = '', mods = null, ua = 'Mozilla/5.0 Chrome/150', seed = {}, storageEstimate = null, temperature = null } = {}) {
function load({ search = '', mods = null, ua = 'Mozilla/5.0 Chrome/150', seed = {}, storageEstimate = null, temperature = null, os = null, fs = null, edid = null, activeMode = null } = {}) {
const posted = [];
const registryStore = new Map(Object.entries(seed));
const cec = { sent: [] };
@ -66,6 +66,27 @@ function load({ search = '', mods = null, ua = 'Mozilla/5.0 Chrome/150', seed =
if (mods) {
sandbox.require = (name) => {
// Node's standard library, present because the widget runs with nodejs_enabled.
// Not an @brightsign module, so it is answered before the platform ones.
if (name === 'os') { if (!os) throw new Error("Cannot find module 'os'"); return os; }
if (name === 'fs') { if (!fs) throw new Error("Cannot find module 'fs'"); return fs; }
// The attached panel's EDID, per OUTPUT. `edid` maps an output name to a monitor name;
// anything not in it behaves like a real player asked for an output it does not have —
// "hdmi2" throws from the constructor, "HDMI-2" rejects. Both observed on an XT245.
if (name === '@brightsign/videooutput') {
if (!edid) throw new Error('no videooutput');
return function (outputName) {
if (!(outputName in edid)) {
if (/^hdmi\d/.test(outputName)) throw new Error('no such output');
return { getEdidIdentity: () => Promise.reject(new Error('Output not connected')) };
}
return { getEdidIdentity: () => Promise.resolve({ monitorName: edid[outputName] }) };
};
}
if (name === '@brightsign/videomodeconfiguration') {
if (!activeMode) throw new Error('no videomodeconfiguration');
return function () { return { getActiveMode: () => Promise.resolve(activeMode) }; };
}
if (name === '@brightsign/messageport') {
return function () {
return {
@ -424,3 +445,198 @@ test('off-platform it resolves false immediately rather than hanging the render'
await ready;
assert.equal(await api.setOrientation('portrait'), false);
});
// ---------------------------------------------------------------------------------------------
// The LAN address.
//
// The dashboard has had a "Local IP" field since 1.9.29 and it was NULL for every BrightSign ever
// paired — 6000 consecutive telemetry rows on our XT245 while it sat at a perfectly reachable
// 192.168.1.46. The host half (autorun.brs) does collect it, but nothing the host sends was
// arriving, so the field could only ever be filled from the page.
//
// There is no @brightsign module for this, and looking for one is the trap: on FW 9.1.93.2
// @brightsign/networkconfiguration exists but exposes only callback/getNeighborInformation/
// enableLeds, and @brightsign/hostconfiguration returns host settings with no address in them.
// Both enumerated on the live player. BrightSign's own dev-cookbook (html5-app-template, both the
// .ts and .js variants) uses Node's os.networkInterfaces(), which is available because the widget
// is created with nodejs_enabled.
test('the LAN address comes from os.networkInterfaces(), the way the vendor does it', () => {
const { api } = load({
mods: [],
os: {
networkInterfaces: () => ({
lo: [{ address: '127.0.0.1', family: 'IPv4', internal: true }],
eth0: [{ address: '192.168.1.46', family: 'IPv4', internal: false }],
}),
},
});
api.refreshTelemetry();
assert.equal(api.telemetrySnapshot().local_ip, '192.168.1.46');
});
test('loopback and a DHCP-less link-local are never reported', () => {
// 169.254.x is what a player assigns itself when DHCP never answered. Sending an operator to an
// address that cannot be reached is worse than showing nothing.
for (const bad of ['127.0.0.1', '169.254.10.4']) {
const { api } = load({
mods: [],
os: { networkInterfaces: () => ({ eth0: [{ address: bad, family: 'IPv4', internal: bad.startsWith('127.') }] }) },
});
api.refreshTelemetry();
assert.equal(api.telemetrySnapshot().local_ip, undefined, `${bad} must not be reported`);
}
});
test('family is accepted as the string OR the number', () => {
// "IPv4" on the Node in this firmware and in the cookbook; the number 4 since Node 18. This file
// outlives firmwares, so it must not care which it is handed.
const { api } = load({
mods: [],
os: { networkInterfaces: () => ({ eth0: [{ address: '10.0.0.7', family: 4, internal: false }] }) },
});
api.refreshTelemetry();
assert.equal(api.telemetrySnapshot().local_ip, '10.0.0.7');
});
test('a browser has no os module and simply reports no address', () => {
const { api } = load({ mods: [] });
assert.doesNotThrow(() => api.refreshTelemetry());
assert.equal(api.telemetrySnapshot().local_ip, undefined);
});
// ---------------------------------------------------------------------------------------------
// Memory, load, uptime and REAL disk — all from Node's stdlib, all previously NULL on BrightSign.
//
// The storage numbers are the ones that were actively misleading rather than merely absent: the
// page reported navigator.storage.estimate(), so our XT245 answered "1026 MB total" for a 119 GB
// NVMe. That is the browser's cache budget, not the machine, and an operator reading it has been
// told something false. Verified on the player: 119616 MB, which matches the kernel's block count.
const OS_STUB = {
networkInterfaces: () => ({ eth0: [{ address: '192.168.1.46', family: 'IPv4', internal: false }] }),
totalmem: () => 3656 * 1048576,
freemem: () => 2773 * 1048576,
uptime: () => 149,
loadavg: () => [0.2, 0.3, 0.3],
cpus: () => [{}, {}, {}, {}],
};
test('memory and load are reported from os, not left empty', () => {
const { api } = load({ mods: [], os: OS_STUB });
api.refreshTelemetry();
const t = api.telemetrySnapshot();
assert.equal(t.ram_total_mb, 3656);
assert.equal(t.ram_free_mb, 2773);
assert.equal(t.cpu_usage, 5, '0.2 load over 4 cores = 5%');
});
test('uptime is the MACHINE, which is what makes a reboot loop visible', () => {
// The page sends performance.now()/1000 — how long the PAGE has been up. A widget rebuilt by the
// watchdog resets that while the player has been running for weeks.
const { api } = load({ mods: [], os: OS_STUB });
api.refreshTelemetry();
assert.equal(api.telemetrySnapshot().uptime_seconds, 149);
});
test('THE MISLEADING ONE: storage is the disk, not the browser cache quota', () => {
const fsStub = {
readdirSync: (p) => (p === '/storage' ? ['sd', 'ssd'] : []),
statfsSync: (p) => {
if (p === '/storage/ssd') return { blocks: 31258710, bsize: 4096, bavail: 31245000, bfree: 31245000 };
if (p === '/storage/sd') return { blocks: 1000, bsize: 4096, bavail: 500, bfree: 500 };
throw new Error('not a mount');
},
};
const { api } = load({ mods: [], os: OS_STUB, fs: fsStub, storageEstimate: { quota: 1026 * 1048576, usage: 2 * 1048576 } });
api.refreshTelemetry();
const t = api.telemetrySnapshot();
assert.equal(t.storage_total_mb, 122104, 'the 119 GB volume, not the 1026 MB quota');
assert.ok(t.storage_total_mb > 100000, 'a browser quota would be ~1000');
});
test('the LARGEST mount wins, because which volume a player boots from varies', () => {
// Ours runs from an NVMe with a dead card slot; others boot from SD. Picking the first mount
// would report a 4 MB card as the content volume on exactly those players.
const fsStub = {
readdirSync: () => ['sd', 'ssd'],
statfsSync: (p) => (p === '/storage/sd'
? { blocks: 1024, bsize: 4096, bavail: 1000, bfree: 1000 }
: { blocks: 262144, bsize: 4096, bavail: 200000, bfree: 200000 }),
};
const { api } = load({ mods: [], os: OS_STUB, fs: fsStub });
api.refreshTelemetry();
assert.equal(api.telemetrySnapshot().storage_total_mb, 1024, 'the 1 GiB ssd, not the 4 MiB sd');
});
test('a firmware without statfs degrades instead of throwing', () => {
const { api } = load({ mods: [], os: OS_STUB, fs: { readdirSync: () => [] } });
assert.doesNotThrow(() => api.refreshTelemetry());
assert.equal(api.telemetrySnapshot().local_ip, '192.168.1.46', 'and the rest still reports');
});
// ---------------------------------------------------------------------------------------------
// Which screen is plugged in, and what the output is driving.
//
// screen_width/height are what the PAGE believes it has — the widget's own geometry. They say
// nothing about the panel. Our XT245 drives a CX101 at 1920x1200@60 while the page reports its own
// canvas, so an operator asking "which display is that and is it even outputting?" had no answer.
//
// The output is chosen by SCREEN NUMBER because a dual-output player registers one device row per
// output (?screen=N → output_index), and each row must report its own panel.
const flush = () => new Promise((r) => setTimeout(r, 0));
test('the attached display is read from EDID', async () => {
const { api } = load({ mods: true, os: OS_STUB, edid: { 'HDMI-1': 'CX101' } });
api.refreshTelemetry();
await flush();
assert.equal(api.telemetrySnapshot().attached_display, 'CX101');
});
test('MULTI-SCREEN: each output reports its OWN panel, not the box\'s first', async () => {
// The bug this prevents: a player driving a lobby TV and a menu board showing the lobby TV twice.
const wiring = { edid: { 'HDMI-1': 'Lobby-55', 'HDMI-2': 'MenuBoard-32' } };
const one = load({ mods: true, os: OS_STUB, search: '?screen=1', ...wiring });
const two = load({ mods: true, os: OS_STUB, search: '?screen=2', ...wiring });
one.api.refreshTelemetry();
two.api.refreshTelemetry();
await flush();
assert.equal(one.api.telemetrySnapshot().attached_display, 'Lobby-55');
assert.equal(two.api.telemetrySnapshot().attached_display, 'MenuBoard-32');
});
test('a single-output player reports nothing rather than inventing a second screen', async () => {
// Verified on hardware: "hdmi2" throws from the constructor and "HDMI-2" rejects.
const { api } = load({ mods: true, os: OS_STUB, search: '?screen=2', edid: { 'HDMI-1': 'CX101' } });
assert.doesNotThrow(() => api.refreshTelemetry());
await flush();
assert.equal(api.telemetrySnapshot().attached_display, undefined);
});
test('screen 1 also accepts the lowercase name the vendor cookbook uses', async () => {
const { api } = load({ mods: true, os: OS_STUB, edid: { hdmi: 'CX101' } });
api.refreshTelemetry();
await flush();
assert.equal(api.telemetrySnapshot().attached_display, 'CX101');
});
test('the active mode is reported as WxH@Hz, the way an installer says it', async () => {
const { api } = load({
mods: true, os: OS_STUB,
activeMode: { graphicsPlaneWidth: 1920, graphicsPlaneHeight: 1200, frequency: 60 },
});
api.refreshTelemetry();
await flush();
assert.equal(api.telemetrySnapshot().video_mode, '1920x1200@60');
});
test('a firmware with neither module degrades quietly', async () => {
const { api } = load({ mods: true, os: OS_STUB });
assert.doesNotThrow(() => api.refreshTelemetry());
await flush();
const t = api.telemetrySnapshot();
assert.equal(t.attached_display, undefined);
assert.equal(t.video_mode, undefined);
assert.equal(t.local_ip, '192.168.1.46', 'and everything else still reports');
});

View file

@ -34,13 +34,13 @@ const template = (() => {
return SRC.slice(i + START.length, j);
})();
function render(device) {
function render(device, telemetry) {
const caps = Array.isArray(device.capabilities) ? device.capabilities : null;
const sandbox = {
device,
caps,
can: (cap) => (caps ? caps.includes(cap) : true),
latestTelemetry: {},
latestTelemetry: telemetry || {},
diagWidget: null,
// Stubs. Each returns something recognisable so a control cannot be "found" by accident.
t: (key) => key,
@ -102,6 +102,13 @@ const BRIGHTSIGN = {
const has = (html, id) => html.includes(`id="${id}"`);
// Same harness, but with a telemetry payload — the cards above are driven by it.
function renderWith(device, telemetry) {
const saved = renderWith._tel;
renderWith._tel = telemetry;
try { return render(device, telemetry); } finally { renderWith._tel = saved; }
}
test('a browser tab is no longer offered controls over a machine it cannot touch', () => {
const html = render(WEB);
assert.equal(has(html, 'rebootBtn'), false, 'a tab cannot reboot the PC it is running on');
@ -306,3 +313,58 @@ test('the shipped isAndroidDevice short-circuits brightsign, tizen and wgt BEFOR
assert.equal(real({ android_version: '9' }), true, 'an Android panel paired before client_type existed');
assert.equal(real(null), false, 'and it never throws on a missing device');
});
// Info cards follow the DATA, not the platform.
//
// RAM and CPU were gated on "is this an Android panel?", which was right when Android was the only
// family that could measure them. A BrightSign widget runs with nodejs_enabled, so the bridge now
// reads os.totalmem/freemem and the load average — the numbers arrive and the old gate threw them
// away. Storage on that family was worse than absent: it reported the browser's cache quota, so a
// 119 GB player displayed "1026 MB".
const BS_WITH_DATA = {
platform: 'brightsign', hardware_model: 'XT245', hardware_os_version: '9.1.93.2',
android_version: 'Web/Safari/537.36', local_ip: '192.168.1.46',
capabilities: ['playback.video', 'audio.volume', 'remote.input'],
};
const REAL_TELEMETRY = {
storage_free_mb: 119563, storage_total_mb: 119616,
ram_free_mb: 2773, ram_total_mb: 3656, cpu_usage: 5, uptime_seconds: 149,
};
test('a BrightSign that reports memory and load gets cards for them', () => {
const html = renderWith(BS_WITH_DATA, REAL_TELEMETRY);
assert.ok(has(html, 'telRam'), 'RAM card missing on a player that reports RAM');
assert.ok(has(html, 'telCpu'), 'CPU card missing on a player that reports load');
assert.ok(has(html, 'telStorage'), 'and the disk it now measures for real');
});
test('Android keeps its cards whether or not a reading has arrived yet', () => {
// The old gate was platform-based, so an Android panel with no telemetry still showed "--".
// Switching to data-presence must not take that away — an empty card is a known state, a missing
// one reads as "this panel cannot do that".
for (const tel of [REAL_TELEMETRY, {}]) {
const html = renderWith({ client_type: 'apk', android_version: '13', capabilities: ['playback.video'] }, tel);
assert.ok(has(html, 'telRam'), 'Android must keep its RAM card');
assert.ok(has(html, 'telCpu'), 'Android must keep its CPU card');
}
});
test('a browser tab gains nothing — it measures none of this', () => {
const html = renderWith({ android_version: 'Web/Chrome', capabilities: ['playback.video'] }, {});
assert.equal(has(html, 'telRam'), false);
assert.equal(has(html, 'telCpu'), false);
});
test('the attached display and video mode get cards when reported', () => {
const html = renderWith(BS_WITH_DATA, { ...REAL_TELEMETRY, attached_display: 'CX101', video_mode: '1920x1200@60' });
assert.ok(has(html, 'telDisplay'), 'the panel EDID card');
assert.ok(has(html, 'telVideoMode'), 'the negotiated mode card');
assert.ok(html.includes('CX101'), 'and the monitor name itself');
});
test('a player that cannot read its output grows no empty rows', () => {
const html = renderWith(BS_WITH_DATA, REAL_TELEMETRY);
assert.equal(has(html, 'telDisplay'), false);
assert.equal(has(html, 'telVideoMode'), false);
});

View file

@ -1202,8 +1202,9 @@ module.exports = function setupDeviceSocket(io) {
if (telemetry && deviceExists(device_id)) {
db.prepare(`
INSERT INTO device_telemetry (device_id, battery_level, battery_charging, storage_free_mb, storage_total_mb,
ram_free_mb, ram_total_mb, cpu_usage, wifi_ssid, wifi_rssi, uptime_seconds, local_ip, local_ip6, temperature_c)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ram_free_mb, ram_total_mb, cpu_usage, wifi_ssid, wifi_rssi, uptime_seconds, local_ip, local_ip6, temperature_c,
attached_display, video_mode)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
device_id,
telemetry.battery_level ?? null,
@ -1225,7 +1226,12 @@ module.exports = function setupDeviceSocket(io) {
// Only a finite number is a reading. A panel with no sensor sends nothing, and NaN or
// Infinity from a flaky one must land as "no reading" rather than poisoning the column.
typeof telemetry.temperature_c === 'number' && Number.isFinite(telemetry.temperature_c)
? telemetry.temperature_c : null
? telemetry.temperature_c : null,
// Free text from the panel's EDID and the mode the output is driving. Trimmed and
// bounded like the address fields above: this is a string the DISPLAY chose, not one
// we control, and a monitor with a silly name must not be able to grow the row.
typeof telemetry.attached_display === 'string' ? telemetry.attached_display.trim().slice(0, 64) || null : null,
typeof telemetry.video_mode === 'string' ? telemetry.video_mode.trim().slice(0, 32) || null : null
);
pruneTelemetry(device_id);