mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
Merge branch 'feat/brightsign-ip-from-js'
# Conflicts: # server/test/device-controls-hidden.test.js
This commit is contained in:
commit
28885d1a13
|
|
@ -47,6 +47,16 @@
|
|||
*/
|
||||
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.
|
||||
* 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 +891,199 @@
|
|||
} 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; ni++) {
|
||||
var addrs = ifaces[names[ni]] || [];
|
||||
for (var ai = 0; ai < addrs.length; ai++) {
|
||||
var a = addrs[ai];
|
||||
if (!a || a.internal) continue;
|
||||
var ip = String(a.address || '');
|
||||
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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -577,10 +577,15 @@ async function loadDevice(deviceId, activeTab = null) {
|
|||
</div>` : ''}
|
||||
${latestTelemetry.storage_total_mb ? `
|
||||
<div class="info-card">
|
||||
<!-- Labelled "player storage", not "storage": on this family the number is the
|
||||
widget's cache quota, not the device filesystem. Same column as Android's real
|
||||
disk figures, so the label is what stops it being read as "the disk is 1 GB". -->
|
||||
<div class="info-card-label">${t('device.info.player_storage')}</div>
|
||||
<!-- This used to be labelled "player storage" because the number WAS the widget's
|
||||
cache quota rather than the disk — a real XT245 with a 119 GB NVMe reported
|
||||
"1026 MB", and the label was the only thing stopping that being read as the disk
|
||||
size. The bridge now reads the actual filesystem (statfs over the mounts under
|
||||
/storage, largest wins), so it means the same thing as Android's figure and is
|
||||
labelled the same. ⚠️ The bridge is served per page load, so a player that has not
|
||||
re-fetched it yet still reports the quota — see the CDN caching note in
|
||||
docs/player-parity.md before trusting a suspiciously round ~1 GB here. -->
|
||||
<div class="info-card-label">${t('device.info.storage')}</div>
|
||||
<div class="info-card-value small" id="telStorage">${latestTelemetry.storage_free_mb != null ? t('device.info.size_free', { size: formatBytes(latestTelemetry.storage_free_mb) }) : '--'}</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar-fill ${((latestTelemetry.storage_total_mb - latestTelemetry.storage_free_mb) / latestTelemetry.storage_total_mb) < 0.8 ? 'success' : 'warning'}"
|
||||
|
|
@ -593,6 +598,20 @@ async function loadDevice(deviceId, activeTab = null) {
|
|||
<div class="info-card-label">${t('device.info.temperature')}</div>
|
||||
<div class="info-card-value small" id="telTemp">${latestTelemetry.temperature_c}°C</div>
|
||||
</div>` : ''}
|
||||
<!-- The physical panel, from its EDID, and the mode the output is negotiated to. Shown
|
||||
only when the player reports them, like every other card here: a family that cannot
|
||||
read its own output must not grow an empty row. On a dual-output player each device
|
||||
row is one output, so this is THAT output's screen — not the box's first. -->
|
||||
${latestTelemetry.attached_display ? `
|
||||
<div class="info-card">
|
||||
<div class="info-card-label">${t('device.info.attached_display')}</div>
|
||||
<div class="info-card-value small" id="telDisplay">${esc(latestTelemetry.attached_display)}</div>
|
||||
</div>` : ''}
|
||||
${latestTelemetry.video_mode ? `
|
||||
<div class="info-card">
|
||||
<div class="info-card-label">${t('device.info.video_mode')}</div>
|
||||
<div class="info-card-value small" id="telVideoMode">${esc(latestTelemetry.video_mode)}</div>
|
||||
</div>` : ''}
|
||||
${device.android_version && !device.android_version.startsWith('Web/') ? `
|
||||
<div class="info-card">
|
||||
<div class="info-card-label">${t('device.info.wifi')}</div>
|
||||
|
|
@ -638,11 +657,18 @@ async function loadDevice(deviceId, activeTab = null) {
|
|||
<div class="info-card-label">${t('device.clock.label')}</div>
|
||||
<div class="info-card-value small">${renderDeviceClock(device)}</div>
|
||||
</div>
|
||||
${device.android_version && !device.android_version.startsWith('Web/') ? `
|
||||
<!-- Shown for Android as before, and now for ANY player that actually reports the value.
|
||||
These were platform-gated when Android was the only family that could measure them;
|
||||
a BrightSign widget runs with nodejs_enabled and the bridge reads os.totalmem/freemem
|
||||
and the load average, so the numbers exist and were being thrown away by a gate that
|
||||
asked what the device IS instead of what it SENT. Keeping the Android arm means a
|
||||
panel that reports nothing still shows "--" there rather than losing its cards. -->
|
||||
${(device.android_version && !device.android_version.startsWith('Web/')) || latestTelemetry.ram_free_mb != null ? `
|
||||
<div class="info-card">
|
||||
<div class="info-card-label">${t('device.info.ram')}</div>
|
||||
<div class="info-card-value small" id="telRam">${latestTelemetry.ram_free_mb ? t('device.info.size_free', { size: formatBytes(latestTelemetry.ram_free_mb) }) : '--'}</div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
${(device.android_version && !device.android_version.startsWith('Web/')) || latestTelemetry.cpu_usage != null ? `
|
||||
<div class="info-card">
|
||||
<div class="info-card-label">${t('device.info.cpu_usage')}</div>
|
||||
<div class="info-card-value small" id="telCpu">${latestTelemetry.cpu_usage != null ? latestTelemetry.cpu_usage.toFixed(1) + '%' : '--'}</div>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, edid = null, activeMode = null } = {}) {
|
||||
const posted = [];
|
||||
const registryStore = new Map(Object.entries(seed));
|
||||
const cec = { sent: [] };
|
||||
|
|
@ -66,6 +66,27 @@ 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; }
|
||||
// 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 {
|
||||
|
|
@ -424,3 +445,198 @@ 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');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -102,6 +102,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');
|
||||
|
|
@ -306,3 +313,58 @@ test('the shipped isAndroidDevice short-circuits brightsign, tizen and wgt BEFOR
|
|||
assert.equal(real({ android_version: '9' }), true, 'an Android panel paired before client_type existed');
|
||||
assert.equal(real(null), false, 'and it never throws on a missing device');
|
||||
});
|
||||
|
||||
// Info cards follow the DATA, not the platform.
|
||||
//
|
||||
// RAM and CPU were gated on "is this an Android panel?", which was right when Android was the only
|
||||
// family that could measure them. A BrightSign widget runs with nodejs_enabled, so the bridge now
|
||||
// reads os.totalmem/freemem and the load average — the numbers arrive and the old gate threw them
|
||||
// away. Storage on that family was worse than absent: it reported the browser's cache quota, so a
|
||||
// 119 GB player displayed "1026 MB".
|
||||
|
||||
const BS_WITH_DATA = {
|
||||
platform: 'brightsign', hardware_model: 'XT245', hardware_os_version: '9.1.93.2',
|
||||
android_version: 'Web/Safari/537.36', local_ip: '192.168.1.46',
|
||||
capabilities: ['playback.video', 'audio.volume', 'remote.input'],
|
||||
};
|
||||
const REAL_TELEMETRY = {
|
||||
storage_free_mb: 119563, storage_total_mb: 119616,
|
||||
ram_free_mb: 2773, ram_total_mb: 3656, cpu_usage: 5, uptime_seconds: 149,
|
||||
};
|
||||
|
||||
test('a BrightSign that reports memory and load gets cards for them', () => {
|
||||
const html = renderWith(BS_WITH_DATA, REAL_TELEMETRY);
|
||||
assert.ok(has(html, 'telRam'), 'RAM card missing on a player that reports RAM');
|
||||
assert.ok(has(html, 'telCpu'), 'CPU card missing on a player that reports load');
|
||||
assert.ok(has(html, 'telStorage'), 'and the disk it now measures for real');
|
||||
});
|
||||
|
||||
test('Android keeps its cards whether or not a reading has arrived yet', () => {
|
||||
// The old gate was platform-based, so an Android panel with no telemetry still showed "--".
|
||||
// Switching to data-presence must not take that away — an empty card is a known state, a missing
|
||||
// one reads as "this panel cannot do that".
|
||||
for (const tel of [REAL_TELEMETRY, {}]) {
|
||||
const html = renderWith({ client_type: 'apk', android_version: '13', capabilities: ['playback.video'] }, tel);
|
||||
assert.ok(has(html, 'telRam'), 'Android must keep its RAM card');
|
||||
assert.ok(has(html, 'telCpu'), 'Android must keep its CPU card');
|
||||
}
|
||||
});
|
||||
|
||||
test('a browser tab gains nothing — it measures none of this', () => {
|
||||
const html = renderWith({ android_version: 'Web/Chrome', capabilities: ['playback.video'] }, {});
|
||||
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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue