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