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.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);