From def30e6d39a9d8cdf10e8346afec0f5ae94b9a95 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Mon, 10 Aug 2026 15:05:06 -0500 Subject: [PATCH] BrightSign: report the LAN address, real disk, memory and load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every one of these fields existed in the schema, the API and the dashboard, and every one was NULL or misleading on a BrightSign. The XT245 had 6000 consecutive telemetry rows with local_ip NULL while sitting at a perfectly reachable 192.168.1.46, and reported "1026 MB" of storage for a 119 GB NVMe. The host half (autorun.brs) does collect an address, but nothing the host sends was arriving at all — proven by the storage figure, which was the browser's cache quota rather than any disk. So the page has to read this itself, which is also the half that can be delivered: st-bridge.js is served per page load, while autorun.brs needs a release bump to reach a player. It is Node's standard library, not a @brightsign module. The widget is created with nodejs_enabled, so os and fs are simply there — this is what BrightSign's own dev-cookbook does in html5-app-template (both the .ts and .js variants). Looking for a platform module is the trap, and it cost most of a day: @brightsign/networkconfiguration EXISTS but exposes only callback, getNeighborInformation and enableLeds — no config reader. hostconfiguration has getConfig()/applyConfig() but returns host settings (forwardingEnabled, hostName, loginPassword, nameServers) with no address in them. Both enumerated on the live player, because the JavaScript API doc pages 404 and BrightSign's own roNetworkConfiguration page links to one of the dead URLs. getCurrentConfig() is BrightScript-only. local_ip os.networkInterfaces(), skipping internal and 169.254 ram_total/free os.totalmem() / os.freemem() cpu_usage 1-min load average / core count, as a clamped percentage uptime_seconds os.uptime() — the MACHINE, overriding the page's own performance.now(), so a widget rebuilt by the watchdog no longer hides weeks of real uptime storage_* fs.statfsSync over the mounts under /storage, largest wins (ours boots from NVMe with a dead card slot; others from SD) Dashboard: the RAM and CPU cards were gated on "is this Android?", which was right when Android was the only family that could measure them. They now render for any player that reports the value, so a BrightSign gets them and Android is untouched — including keeping its "--" cards when no reading has arrived, since an empty card is a known state and a missing one reads as "cannot". The BrightSign storage card loses its "player storage" caveat, because the number is now the disk it always claimed to be. Verified on the real XT245 (FW 9.1.93.2): 116.8 GB free of 116.8 GB, 2.68 GB of 3.57 GB RAM, 3% CPU, uptime tracking the machine, local_ip 192.168.1.46 — matching the address found independently by MAC-vendor scan, and a disk figure matching the kernel's own block count. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A --- brightsign/st-bridge.js | 127 +++++++++++++++++++ frontend/js/views/device-detail.js | 24 +++- server/test/brightsign-bridge.test.js | 135 ++++++++++++++++++++- server/test/device-controls-hidden.test.js | 54 ++++++++- 4 files changed, 331 insertions(+), 9 deletions(-) diff --git a/brightsign/st-bridge.js b/brightsign/st-bridge.js index e9659af..3bc1698 100644 --- a/brightsign/st-bridge.js +++ b/brightsign/st-bridge.js @@ -47,6 +47,14 @@ */ var VideoModeConfigClass = tryRequire('@brightsign/videomodeconfiguration'); var CecClass = tryRequire('@brightsign/cec'); + /* + * 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 +889,125 @@ } 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 && !telemetry.local_ip; ni++) { + var addrs = ifaces[names[ni]] || []; + for (var ai = 0; ai < addrs.length; ai++) { + var a = addrs[ai]; + if (!a || a.internal) continue; + if (a.family !== 'IPv4' && a.family !== 4) continue; + var ip = String(a.address || ''); + if (!ip || ip.indexOf('169.254.') === 0) continue; + telemetry.local_ip = ip; + break; + } + } + } catch (e) { /* no networking yet, or a firmware without it — stay silent */ } + } + + /* + * 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. * diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js index d8371da..3738f2c 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -544,10 +544,15 @@ async function loadDevice(deviceId, activeTab = null) { ` : ''} ${latestTelemetry.storage_total_mb ? `
- -
${t('device.info.player_storage')}
+ +
${t('device.info.storage')}
${latestTelemetry.storage_free_mb != null ? t('device.info.size_free', { size: formatBytes(latestTelemetry.storage_free_mb) }) : '--'}
${t('device.clock.label')}
${renderDeviceClock(device)}
- ${device.android_version && !device.android_version.startsWith('Web/') ? ` + + ${(device.android_version && !device.android_version.startsWith('Web/')) || latestTelemetry.ram_free_mb != null ? `
${t('device.info.ram')}
${latestTelemetry.ram_free_mb ? t('device.info.size_free', { size: formatBytes(latestTelemetry.ram_free_mb) }) : '--'}
-
+
` : ''} + ${(device.android_version && !device.android_version.startsWith('Web/')) || latestTelemetry.cpu_usage != null ? `
${t('device.info.cpu_usage')}
${latestTelemetry.cpu_usage != null ? latestTelemetry.cpu_usage.toFixed(1) + '%' : '--'}
diff --git a/server/test/brightsign-bridge.test.js b/server/test/brightsign-bridge.test.js index 3ed4881..73126e5 100644 --- a/server/test/brightsign-bridge.test.js +++ b/server/test/brightsign-bridge.test.js @@ -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 } = {}) { const posted = []; const registryStore = new Map(Object.entries(seed)); const cec = { sent: [] }; @@ -66,6 +66,10 @@ 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; } if (name === '@brightsign/messageport') { return function () { return { @@ -424,3 +428,132 @@ 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'); +}); diff --git a/server/test/device-controls-hidden.test.js b/server/test/device-controls-hidden.test.js index 549e6d0..fb7593d 100644 --- a/server/test/device-controls-hidden.test.js +++ b/server/test/device-controls-hidden.test.js @@ -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, @@ -84,6 +84,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'); @@ -188,3 +195,46 @@ test('every gated control still renders balanced markup', () => { assert.equal(bopen, bclose, 'unbalanced