From 08b3d7d404b24caa450a22f3529cdb22a68a4f08 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Mon, 10 Aug 2026 15:30:40 -0500 Subject: [PATCH] BrightSign: report IPv6, the attached display and the active video mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-on from the Node-stdlib work, guided by BrightSign's own dev-cookbook rather than by guessing at module names. IPv6 costs nothing extra — it comes from the same os.networkInterfaces() call the v4 address does. The column, the API field and the dashboard card have all existed since 1.9.29 and no player has ever filled them; the card is written to appear ONLY when 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. A ULA is kept, because that one is reachable. The attached display and video mode are new columns, and they answer the first question anyone asks about a dark sign: which panel is that, and is the player outputting at all. screen_width/height could not answer it — they are what the PAGE believes it has, i.e. the widget's own geometry. Our XT245 drives a CX101 at 1920x1200@60 while the page reports its own canvas. Per telemetry row rather than on `devices`, because a display can be swapped, unplugged or renegotiated without the player re-registering. MULTI-OUTPUT: the output is chosen by screen number, not hard-coded. A dual-output player registers ONE DEVICE ROW PER OUTPUT (?screen=N → output_index), so each row must report its own panel — otherwise a box driving a lobby TV and a menu board shows the lobby TV twice. Both naming forms are tried: probed on hardware, "hdmi" and "HDMI-1" both resolve to output 1, while a second output that does not exist fails cleanly ("hdmi2" throws from the constructor, "HDMI-2" rejects), so a single-output player reports nothing rather than inventing a screen. That case has its own test. Dashboard: two cards, shown only when the player reports them, like every other card in that block. Verified end to end on the real XT245 (FW 9.1.93.2) — attached_display=CX101, video_mode=1920x1200@60, alongside local_ip 192.168.1.46, 119616 MB disk, 3656 MB RAM and live CPU. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A --- brightsign/st-bridge.js | 86 ++++++++++++++++++++-- frontend/js/i18n/en.js | 2 + frontend/js/views/device-detail.js | 14 ++++ server/db/database.js | 10 +++ server/routes/devices.js | 2 +- server/test/brightsign-bridge.test.js | 85 ++++++++++++++++++++- server/test/device-controls-hidden.test.js | 13 ++++ server/ws/deviceSocket.js | 12 ++- 8 files changed, 214 insertions(+), 10 deletions(-) diff --git a/brightsign/st-bridge.js b/brightsign/st-bridge.js index 3bc1698..20572d4 100644 --- a/brightsign/st-bridge.js +++ b/brightsign/st-bridge.js @@ -47,6 +47,8 @@ */ 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. @@ -918,21 +920,95 @@ try { var ifaces = osModule.networkInterfaces() || {}; var names = Object.keys(ifaces); - for (var ni = 0; ni < names.length && !telemetry.local_ip; ni++) { + 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; - 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; + 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. diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 20d2763..ab0d8f5 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -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', diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js index 3738f2c..c721816 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -565,6 +565,20 @@ async function loadDevice(deviceId, activeTab = null) {
${t('device.info.temperature')}
${latestTelemetry.temperature_c}°C
` : ''} + + ${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);