mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
The web player sent battery/storage/RAM/CPU as nulls and 'Web Player' in the
wifi_ssid column, and the bs_model / bs_os_version / bs_serial / bs_screen fields
the player already reported at registration were consumed by nothing. A browser
tab genuinely has none of that. A BrightSign has some of it, and was reporting
none.
Telemetry comes from a CACHE the heartbeat reads synchronously. The beat builds
its payload every 15s without awaiting, but the one real sensor here —
deviceInfo.getTemperature() — returns a promise; awaiting inside the beat would
either block it or serialise a pending Promise into the payload, which is exactly
how device_id once became "[object Promise]". The cache starts EMPTY rather than
null-filled and is spread last, so off-platform nothing changes and a null here
can never clobber a value another player family legitimately supplied.
wifi_ssid was actively false on a PoE Ethernet appliance — an operator reading
that column was told an SSID that does not exist. It is null there now, and the
device view shows a real hardware block instead. Android's WiFi display is
untouched.
Hardware identity is a SEPARATE writer from applyDeviceInfo, deliberately. That
function is a blind full-row overwrite, and an empty device_info once nulled
seventeen columns every five minutes because {} is truthy. These fields arrive
only on a full register, so the same shape would wipe them on every lightweight
refresh in between; COALESCE makes "no news" mean "unchanged".
The OS build gets its own column rather than reusing android_version, which is
load-bearing as a TYPE discriminator: device-detail chooses between the Android
and browser layouts with android_version.startsWith('Web/'), so writing
"BrightSign OS 9.0.189" there would have rendered a BrightSign with battery and
WiFi cards — and applyDeviceInfo would have clobbered it on the next refresh.
Storage is labelled "Player Storage", not "Storage": on this family the number is
the widget's cache quota, not the device filesystem, and it lands in the same
column as Android's real disk figures.
Schema: device_telemetry.temperature_c REAL; devices.hardware_model,
hardware_serial, hardware_os_version, output_index. All nullable, all idempotent
in the existing migration array.
973 pass (+19). The temperature tests drive a real socket into a real server,
because changing the arity of the telemetry INSERT would break every player's
heartbeat, not just BrightSign's.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
119 lines
5.1 KiB
JavaScript
119 lines
5.1 KiB
JavaScript
'use strict';
|
|
|
|
// Hardware identity — model, OS build, serial, which output — is reported by the panel and had
|
|
// nowhere to live: the devices row carried only `platform`. A BrightSign knows all four, and an
|
|
// operator looking at a dead screen wants the serial and the model, not a guess.
|
|
//
|
|
// The reason this is a SEPARATE writer rather than four more fields in applyDeviceInfo: that
|
|
// function is a blind full-row overwrite, and an empty device_info once nulled seventeen columns
|
|
// every five minutes because `{}` is truthy. These fields arrive only on a full register, so the
|
|
// same shape would wipe them on every lightweight refresh in between. COALESCE is what makes
|
|
// "no news" mean "unchanged" instead of "gone" — which is the property these tests pin down.
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-hw-identity-'));
|
|
process.env.DATA_DIR = tmp;
|
|
|
|
const { db } = require('../db/database');
|
|
const { __applyHardwareIdentity: applyHardwareIdentity } = require('../ws/deviceSocket');
|
|
|
|
let n = 0;
|
|
function mkDevice() {
|
|
const id = 'dev-hw-' + (++n);
|
|
db.prepare("INSERT INTO devices (id,name,status,created_at) VALUES (?,?,'offline',strftime('%s','now'))")
|
|
.run(id, 'HW ' + n);
|
|
return id;
|
|
}
|
|
const row = (id) => db.prepare('SELECT * FROM devices WHERE id = ?').get(id);
|
|
|
|
test('the schema carries hardware identity and a temperature column', () => {
|
|
const dev = db.prepare('PRAGMA table_info(devices)').all().map((c) => c.name);
|
|
for (const c of ['hardware_model', 'hardware_serial', 'hardware_os_version', 'output_index']) {
|
|
assert.ok(dev.includes(c), `devices.${c} missing`);
|
|
}
|
|
const tel = db.prepare('PRAGMA table_info(device_telemetry)').all().map((c) => c.name);
|
|
assert.ok(tel.includes('temperature_c'), 'device_telemetry.temperature_c missing');
|
|
});
|
|
|
|
test('top-level bs_* fields are persisted — that is how the player reports them today', () => {
|
|
const id = mkDevice();
|
|
applyHardwareIdentity(id, {
|
|
bs_model: 'XT245', bs_serial: 'URD3C6000823', bs_os_version: '9.0.189', bs_screen: 1,
|
|
});
|
|
const r = row(id);
|
|
assert.equal(r.hardware_model, 'XT245');
|
|
assert.equal(r.hardware_serial, 'URD3C6000823');
|
|
assert.equal(r.hardware_os_version, '9.0.189');
|
|
});
|
|
|
|
test('device_info is read too, so the client can move without a flag day', () => {
|
|
const id = mkDevice();
|
|
applyHardwareIdentity(id, {
|
|
device_info: { hardware_model: 'XC4055', hardware_serial: 'SN-XC', hardware_os_version: '9.1.5', output_index: 3 },
|
|
});
|
|
const r = row(id);
|
|
assert.equal(r.hardware_model, 'XC4055');
|
|
assert.equal(r.output_index, 3);
|
|
});
|
|
|
|
test('THE WIPE THIS GUARDS: a later report without the fields leaves them intact', () => {
|
|
const id = mkDevice();
|
|
applyHardwareIdentity(id, { bs_model: 'XT245', bs_serial: 'SN-KEEP', bs_os_version: '9.0.189' });
|
|
// A subsequent register from a client that says nothing about hardware — the shape that nulled
|
|
// seventeen columns when it went through the blind-overwrite path.
|
|
applyHardwareIdentity(id, { device_info: {} });
|
|
applyHardwareIdentity(id, {});
|
|
const r = row(id);
|
|
assert.equal(r.hardware_model, 'XT245', 'silence must not read as "the model is gone"');
|
|
assert.equal(r.hardware_serial, 'SN-KEEP');
|
|
assert.equal(r.hardware_os_version, '9.0.189');
|
|
});
|
|
|
|
test('a genuine change still overwrites — COALESCE must not freeze the value', () => {
|
|
const id = mkDevice();
|
|
applyHardwareIdentity(id, { bs_model: 'XT245' });
|
|
applyHardwareIdentity(id, { bs_model: 'XC2055' });
|
|
assert.equal(row(id).hardware_model, 'XC2055');
|
|
});
|
|
|
|
test('a player that reports none of it is not written at all', () => {
|
|
const id = mkDevice();
|
|
const before = row(id).updated_at;
|
|
applyHardwareIdentity(id, { device_info: { app_version: '1.1.0-web' } });
|
|
const r = row(id);
|
|
assert.equal(r.hardware_model, null);
|
|
assert.equal(r.hardware_serial, null);
|
|
assert.equal(r.updated_at, before, 'an Android/browser register should not touch the row here');
|
|
});
|
|
|
|
test('output_index only accepts a positive integer', () => {
|
|
const id = mkDevice();
|
|
// screen() returns 1 for a single-output player; 0 / negative / "2" are not outputs.
|
|
for (const bad of [0, -1, '2', 1.5, null, undefined]) {
|
|
applyHardwareIdentity(id, { bs_model: 'X', bs_screen: bad });
|
|
assert.equal(row(id).output_index, null, `output_index accepted ${JSON.stringify(bad)}`);
|
|
}
|
|
applyHardwareIdentity(id, { bs_screen: 2 });
|
|
assert.equal(row(id).output_index, 2);
|
|
});
|
|
|
|
test('device-supplied strings are trimmed and capped', () => {
|
|
const id = mkDevice();
|
|
applyHardwareIdentity(id, { bs_model: ' XT245 ', bs_serial: 'S'.repeat(200) });
|
|
const r = row(id);
|
|
assert.equal(r.hardware_model, 'XT245');
|
|
assert.equal(r.hardware_serial.length, 64, 'these render in the dashboard — cap them');
|
|
});
|
|
|
|
test('a whitespace-only value is not an answer', () => {
|
|
const id = mkDevice();
|
|
applyHardwareIdentity(id, { bs_model: 'XT245' });
|
|
applyHardwareIdentity(id, { bs_model: ' ' });
|
|
assert.equal(row(id).hardware_model, 'XT245');
|
|
});
|