From def30e6d39a9d8cdf10e8346afec0f5ae94b9a95 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Mon, 10 Aug 2026 15:05:06 -0500 Subject: [PATCH 1/2] 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
` : ''} + + ${latestTelemetry.attached_display ? ` +
+
${t('device.info.attached_display')}
+
${esc(latestTelemetry.attached_display)}
+
` : ''} + ${latestTelemetry.video_mode ? ` +
+
${t('device.info.video_mode')}
+
${esc(latestTelemetry.video_mode)}
+
` : ''} ${device.android_version && !device.android_version.startsWith('Web/') ? `
${t('device.info.wifi')}
diff --git a/server/db/database.js b/server/db/database.js index f0b4dde..3db48e2 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -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 diff --git a/server/routes/devices.js b/server/routes/devices.js index 4367d70..7a1c703 100644 --- a/server/routes/devices.js +++ b/server/routes/devices.js @@ -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 diff --git a/server/test/brightsign-bridge.test.js b/server/test/brightsign-bridge.test.js index 73126e5..157f241 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, os = null, fs = 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: [] }; @@ -70,6 +70,23 @@ function load({ search = '', mods = null, ua = 'Mozilla/5.0 Chrome/150', seed = // 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 { @@ -557,3 +574,69 @@ test('a firmware without statfs degrades instead of throwing', () => { 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'); +}); diff --git a/server/test/device-controls-hidden.test.js b/server/test/device-controls-hidden.test.js index fb7593d..f469e24 100644 --- a/server/test/device-controls-hidden.test.js +++ b/server/test/device-controls-hidden.test.js @@ -238,3 +238,16 @@ test('a browser tab gains nothing — it measures none of this', () => { 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); +}); diff --git a/server/ws/deviceSocket.js b/server/ws/deviceSocket.js index 702df65..22a47de 100644 --- a/server/ws/deviceSocket.js +++ b/server/ws/deviceSocket.js @@ -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);