diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js
index 9e30533..6a39bce 100644
--- a/frontend/js/i18n/en.js
+++ b/frontend/js/i18n/en.js
@@ -246,6 +246,7 @@ export default {
'dashboard.toast.playlist_assigned_other': 'Playlist assigned to {n} devices',
'dashboard.toast.command_sent': '{cmd} sent to {sent}/{total} devices',
'dashboard.toast.command_sent_with_offline': '{cmd} sent to {sent}/{total} devices ({offline} offline)',
+ 'dashboard.toast.command_unsupported_n': '{n} skipped — their players do not support it.',
// Content library
'content.title': 'Content Library',
@@ -669,6 +670,11 @@ export default {
'device.toast.command_queued': '{cmd} — device offline, will deliver on reconnect',
'device.toast.command_undeliverable': '{cmd} — device offline and queue unavailable',
'device.toast.command_no_ack': '{cmd} — no server response',
+ 'device.toast.command_unsupported': '{cmd} — this player does not support it ({cap}). Reload the page to refresh the controls.',
+ 'device.caps.title': 'Player capabilities',
+ 'device.caps.declared': 'Reported by the player itself. Controls this display cannot honour are hidden.',
+ 'device.caps.assumed': 'This player has not reported its capabilities, so the defaults for its platform are assumed. They update the next time it connects.',
+ 'device.caps.none': 'The player reports it can do nothing.',
// Settings
'settings.title': 'Settings',
diff --git a/frontend/js/views/dashboard.js b/frontend/js/views/dashboard.js
index 1369532..e79ecfc 100644
--- a/frontend/js/views/dashboard.js
+++ b/frontend/js/views/dashboard.js
@@ -927,10 +927,17 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
try {
const result = await api.sendGroupCommand(groupId, type);
- const msg = result.offline > 0
+ // A group is routinely mixed-platform, so these buttons stay visible — "reboot" is
+ // meaningful for the Android panels in the group even when the web players in it can
+ // never honour it. What must not happen is the toast counting those as sent: the
+ // operator would walk away believing the whole group rebooted.
+ let msg = result.offline > 0
? t('dashboard.toast.command_sent_with_offline', { cmd: cmdLabel, sent: result.sent, total: result.total, offline: result.offline })
: t('dashboard.toast.command_sent', { cmd: cmdLabel, sent: result.sent, total: result.total });
- showToast(msg, result.offline > 0 ? 'warning' : 'success');
+ if (result.unsupported > 0) {
+ msg += ' ' + t('dashboard.toast.command_unsupported_n', { n: result.unsupported });
+ }
+ showToast(msg, (result.offline > 0 || result.unsupported > 0) ? 'warning' : 'success');
} catch (err) {
showToast(err.message, 'error');
}
diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js
index 655636b..974c787 100644
--- a/frontend/js/views/device-detail.js
+++ b/frontend/js/views/device-detail.js
@@ -192,6 +192,22 @@ async function loadDevice(deviceId, activeTab = null) {
try {
const device = await api.getDevice(deviceId);
currentDevice = device;
+
+ /*
+ * Does this display support `cap`? Drives which controls render at all.
+ *
+ * Every control used to be offered to every display: a browser tab was shown "Reboot device",
+ * a Tizen TV was shown screen power. They did nothing, silently, and read as bugs. Hidden
+ * rather than disabled — a greyed-out button on a panel that will NEVER gain the capability is
+ * a permanent question ("what do I have to do to enable this?") with no answer. The capability
+ * list is shown in the Info tab so a missing control is explainable.
+ *
+ * The server resolves the baseline for the ~440 displays that declare nothing, so this sees a
+ * populated list either way and never has to know the difference.
+ */
+ const caps = Array.isArray(device.capabilities) ? device.capabilities : null;
+ const can = (cap) => (caps ? caps.includes(cap) : true); // no list at all => pre-capability server, show everything
+
const latestTelemetry = device.telemetry?.[0] || {};
const diagWidget = (device.assignments || []).find(a => a && a.widget_type === 'diag-smoothness');
@@ -205,13 +221,14 @@ async function loadDevice(deviceId, activeTab = null) {
- ${device.tier === 2 ? `
+ ${/* tier===2 is kept alongside the capability: it is already an accurate RUNTIME signal from
+ the panel, and a device-owner display that has not yet shipped a capability declaration
+ would otherwise lose these buttons the day this deploys. */
+ (device.tier === 2 || can('system.device_owner')) ? `
+ ${can('audio.volume') ? `
-
+ ` : ''}
+ ${can('display.brightness') ? `
-
- ${(device.can_write_settings || device.tier === 2) ? `
+ ` : ''}
+ ${(device.can_write_settings || device.tier === 2 || can('system.brightness') || can('system.screen_timeout')) ? `
@@ -755,9 +805,14 @@ async function loadDevice(deviceId, activeTab = null) {
if (activeTab) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
- const tab = document.querySelector(`.tab[data-tab="${activeTab}"]`);
+ // Both loops above just cleared every tab, so a requested tab that no longer renders (its
+ // capability went away, or the page was reloaded against a player that has since declared a
+ // smaller set) would leave NO tab selected and the page blank. Fall back to Info, which is
+ // never gated.
+ const wanted = document.getElementById(`tab-${activeTab}`) ? activeTab : 'info';
+ const tab = document.querySelector(`.tab[data-tab="${wanted}"]`);
if (tab) tab.classList.add('active');
- const content = document.getElementById(`tab-${activeTab}`);
+ const content = document.getElementById(`tab-${wanted}`);
if (content) content.classList.add('active');
}
@@ -1230,13 +1285,18 @@ function setupActions(device) {
}, 3000);
});
- // Send a command and surface the three-state ack as a toast.
+ // Send a command and surface the ack as a toast.
// - delivered: device received it (green/success)
// - queued: device is offline, will deliver on reconnect (amber/warning)
+ // - unsupported: the player cannot do this at all (red/error, names the capability)
// - no_ack / fallback: server didn't respond or queue unavailable (red/error)
function sendWithFeedback(type, cmdLabel, successKey) {
sendCommand(device.id, type, {}, (ack) => {
if (ack?.delivered) showToast(t(successKey), 'success');
+ // Reachable from a stale tab rendered before the panel declared its capabilities: the
+ // button was there when the page loaded and is gone on reload. Say why rather than
+ // showing the generic "undeliverable", which reads as a network problem.
+ else if (ack?.reason === 'unsupported') showToast(t('device.toast.command_unsupported', { cmd: cmdLabel, cap: ack.capability || '' }), 'error');
else if (ack?.queued) showToast(t('device.toast.command_queued', { cmd: cmdLabel }), 'warning');
else if (ack?.reason === 'no_ack') showToast(t('device.toast.command_no_ack', { cmd: cmdLabel }), 'error');
else showToast(t('device.toast.command_undeliverable', { cmd: cmdLabel }), 'error');
diff --git a/server/db/database.js b/server/db/database.js
index 4de3c8b..0f1d8a0 100644
--- a/server/db/database.js
+++ b/server/db/database.js
@@ -398,6 +398,12 @@ const migrations = [
// Which physical output this row paints. A dual-output player runs one player per connector and
// registers as two devices; without this they are indistinguishable in the dashboard.
"ALTER TABLE devices ADD COLUMN output_index INTEGER",
+ // What the player says it can do, as a JSON array (see lib/player-capabilities.js). NULL means
+ // the panel has never declared — the overwhelming majority of the fleet on the day this ships —
+ // and resolves to a per-platform baseline. That NULL is load bearing: an empty array is a player
+ // genuinely reporting it can do nothing, and collapsing the two would either strip the UI from
+ // every existing display or ignore a player that told us the truth.
+ "ALTER TABLE devices ADD COLUMN capabilities TEXT",
// Backfill a unique 6-digit PIN for already-paired devices that predate the
// settings_pin column (their next reconnect re-sends device:paired with it, so
// the existing fleet isn't locked out of the on-device menu). Idempotent: the
diff --git a/server/lib/player-capabilities.js b/server/lib/player-capabilities.js
index 5bcfad8..d338b14 100644
--- a/server/lib/player-capabilities.js
+++ b/server/lib/player-capabilities.js
@@ -31,7 +31,7 @@ const CAPABILITIES = [
// audio
'audio.mute', 'audio.volume',
// display
- 'display.rotation', 'display.power', 'display.resolution',
+ 'display.rotation', 'display.power', 'display.resolution', 'display.brightness',
// remote view / control
'remote.screenshot', 'remote.stream', 'remote.input',
// lifecycle
@@ -39,6 +39,12 @@ const CAPABILITIES = [
// device management (Android device-owner territory)
'system.kiosk', 'system.brightness', 'system.screen_timeout',
'system.install_apk', 'system.shell', 'system.time',
+ // The rest of the Tier-2 surface: lock the screen now, show the power menu, hide the status
+ // bar, block uninstall. Separate from 'system.kiosk' because kiosk means lock-task specifically
+ // and a panel can hold one without the other — and separate from the individual names above
+ // because these four are only ever available together, gated by the same device-owner check.
+ // Runtime state, not a platform fact: a panel that loses device owner loses all of them.
+ 'system.device_owner',
// synchronisation
'sync.clock', 'sync.native',
// resilience
@@ -60,7 +66,7 @@ const BASELINE = {
'playback.video', 'playback.image', 'playback.widget', 'playback.youtube',
'playback.zones', 'playback.transitions', 'playback.pip',
'audio.mute', 'audio.volume',
- 'display.rotation', 'display.power',
+ 'display.rotation', 'display.power', 'display.brightness',
'remote.screenshot', 'remote.stream', 'remote.input',
'system.reboot', 'system.restart_player', 'system.self_update',
'sync.clock', 'offline.cache',
@@ -155,4 +161,82 @@ function parseDeclared(raw) {
return list.filter((c) => CAP_SET.has(c));
}
-module.exports = { CAPABILITIES, CAP_SET, BASELINE, capabilitiesFor, supports, platformFamily, parseDeclared };
+/*
+ * Which capability a fleet command needs.
+ *
+ * The dashboard and the socket layer both dispatch commands by string name, so the check has to
+ * happen against that name or it does not happen at all. Kept here rather than in the socket
+ * handler because two call sites dispatch commands — dashboardSocket for a single device and the
+ * group route for many — and a map that lives in one of them protects only that one.
+ *
+ * A command mapped to null needs no capability: it is a diagnostic every player understands, and
+ * refusing it would remove the tool you use to work out why a panel is misbehaving.
+ */
+const COMMAND_CAPABILITY = {
+ // lifecycle
+ reboot: 'system.reboot',
+ // Power-off shares the reboot capability: it is the same "device power lifecycle" privilege, and
+ // no platform we ship implements one without the other. Split it if that ever stops being true.
+ shutdown: 'system.reboot',
+ launch: 'system.restart_player',
+ refresh: 'system.restart_player',
+ update: 'system.self_update',
+
+ // display
+ screen_on: 'display.power',
+ screen_off: 'display.power',
+
+ // audio
+ set_volume: 'audio.volume',
+
+ // system control (#160 Track-A)
+ set_brightness: 'display.brightness', // per-window overlay dim (Tier 0)
+ set_system_brightness: 'system.brightness',
+ set_screen_timeout: 'system.screen_timeout',
+
+ // device-owner surface (#161 Tier-2)
+ kiosk_lock: 'system.kiosk',
+ kiosk_unlock: 'system.kiosk',
+ lock_now: 'system.device_owner',
+ power_menu: 'system.device_owner',
+ status_bar: 'system.device_owner',
+ block_uninstall: 'system.device_owner',
+ unblock_uninstall: 'system.device_owner',
+ set_time: 'system.time',
+ set_timezone: 'system.time',
+ shell: 'system.shell',
+ install_apk: 'system.install_apk',
+
+ // remote view
+ enable_system_capture: 'remote.screenshot',
+
+ // Diagnostics: deliberately unrestricted. set_debug turns on the log stream you need precisely
+ // when a panel is behaving in a way its capability declaration did not predict.
+ set_debug: null,
+};
+
+/**
+ * The capability a command requires, or null when it needs none.
+ * Unknown commands also return null — this map gates, it does not authorise: the allow-list of
+ * valid command names lives with the routes, and duplicating it here would mean a new command
+ * silently stops working until someone remembers to add it in two places.
+ */
+function capabilityForCommand(type) {
+ return Object.prototype.hasOwnProperty.call(COMMAND_CAPABILITY, type) ? COMMAND_CAPABILITY[type] : null;
+}
+
+/**
+ * Can this device be sent this command?
+ * @returns {{ok: true} | {ok: false, capability: string}}
+ */
+function commandAllowed(device, type) {
+ const cap = capabilityForCommand(type);
+ if (!cap) return { ok: true };
+ if (supports(device, cap)) return { ok: true };
+ return { ok: false, capability: cap };
+}
+
+module.exports = {
+ CAPABILITIES, CAP_SET, BASELINE, capabilitiesFor, supports, platformFamily, parseDeclared,
+ COMMAND_CAPABILITY, capabilityForCommand, commandAllowed,
+};
diff --git a/server/routes/device-groups.js b/server/routes/device-groups.js
index 18feff2..b2ccb8f 100644
--- a/server/routes/device-groups.js
+++ b/server/routes/device-groups.js
@@ -9,6 +9,7 @@ const { accessContext } = require('../lib/tenancy');
// scope. No-op for JWT sessions; for tokens a read/write scope is rejected.
const { requireScope } = require('../middleware/apiToken');
const { resolveSyncBackend, BACKENDS } = require('../lib/sync-backend');
+const playerCapabilities = require('../lib/player-capabilities');
const VALID_COLOR = /^#[0-9A-Fa-f]{6}$/;
const ALLOWED_COMMANDS = [
@@ -385,8 +386,10 @@ router.post('/:id/command', requireScope('full'), requireGroupWrite, (req, res)
if (!type) return res.status(400).json({ error: 'command type required' });
if (!ALLOWED_COMMANDS.includes(type)) return res.status(400).json({ error: 'invalid command type' });
+ // SELECT * because the capability check needs the platform/declaration columns, not just the
+ // three fields the response uses.
const devices = db.prepare(`
- SELECT d.id, d.name, d.status FROM devices d
+ SELECT d.* FROM devices d
JOIN device_group_members dgm ON d.id = dgm.device_id
WHERE dgm.group_id = ?
`).all(req.params.id);
@@ -395,6 +398,16 @@ router.post('/:id/command', requireScope('full'), requireGroupWrite, (req, res)
const results = [];
for (const device of devices) {
+ // A group is the mixed-platform case by definition — a lobby group holding two Android panels
+ // and a BrightSign gets "reboot" sent to all three, and the one that cannot honour it used to
+ // report 'sent'. Reporting per-device rather than refusing the whole command: the operator's
+ // intent is valid for the members that can do it, and failing the lot because one member is a
+ // browser tab would be its own bug.
+ const verdict = playerCapabilities.commandAllowed(device, type);
+ if (!verdict.ok) {
+ results.push({ device_id: device.id, name: device.name, status: 'unsupported', capability: verdict.capability });
+ continue;
+ }
const room = deviceNs.adapter.rooms.get(device.id);
if (room && room.size > 0) {
deviceNs.to(device.id).emit('device:command', { type, payload: payload || {} });
@@ -406,8 +419,9 @@ router.post('/:id/command', requireScope('full'), requireGroupWrite, (req, res)
const sent = results.filter(r => r.status === 'sent').length;
const offline = results.filter(r => r.status === 'offline').length;
- console.log(`Group command '${type}' sent to group '${req.group.name}': ${sent} sent, ${offline} offline`);
- res.json({ success: true, sent, offline, total: devices.length, results });
+ const unsupported = results.filter(r => r.status === 'unsupported').length;
+ console.log(`Group command '${type}' sent to group '${req.group.name}': ${sent} sent, ${offline} offline, ${unsupported} unsupported`);
+ res.json({ success: true, sent, offline, unsupported, total: devices.length, results });
});
module.exports = router;
diff --git a/server/routes/devices.js b/server/routes/devices.js
index 88ca81e..e98dcc7 100644
--- a/server/routes/devices.js
+++ b/server/routes/devices.js
@@ -8,6 +8,7 @@ const { accessContext } = require('../lib/tenancy');
const { stripDeviceSecrets, stripDeviceSecretsForList } = require('../lib/device-sanitize');
const { layoutZones, orphanCountsByDevice } = require('../lib/zone-validate');
const deviceSettings = require('../lib/device-settings'); // #150 delete+re-pair settings preservation
+const playerCapabilities = require('../lib/player-capabilities');
// List devices in the caller's current workspace.
// Phase 2.2a: filter by workspace_id instead of user_id. The caller's current
@@ -171,7 +172,13 @@ router.get('/:id', (req, res) => {
'SELECT reported_at FROM device_telemetry WHERE device_id = ? AND reported_at > ? ORDER BY reported_at ASC'
).all(req.params.id, dayAgo).map(r => r.reported_at);
- res.json({ ...stripDeviceSecrets(device), telemetry, screenshot, assignments, active_layout_zones, playlist_status, playlist_has_published, uptimeData, statusLog, deviceEvents });
+ // The RESOLVED capability set, not the raw column. The dashboard hides controls a panel cannot
+ // honour, and it must not have to know about the baseline fallback — a legacy device declaring
+ // nothing has to arrive at the dashboard looking exactly like one that declared its baseline,
+ // or ~440 existing displays lose their controls the moment this ships.
+ const capabilities = playerCapabilities.capabilitiesFor(device);
+
+ res.json({ ...stripDeviceSecrets(device), capabilities, telemetry, screenshot, assignments, active_layout_zones, playlist_status, playlist_has_published, uptimeData, statusLog, deviceEvents });
});
// Helper: check device write access via the workspace the device belongs to.
diff --git a/server/services/scheduler.js b/server/services/scheduler.js
index 3e2003a..8a77ef3 100644
--- a/server/services/scheduler.js
+++ b/server/services/scheduler.js
@@ -1,5 +1,6 @@
const { db } = require('../db/database');
const { _localParts } = require('../lib/schedule-eval');
+const playerCapabilities = require('../lib/player-capabilities');
let io = null;
@@ -120,6 +121,11 @@ function rebootDue(schedule, tz, now, lastDate) {
function maybeRebootDevice(device, now, deviceNs) {
const { due, today } = rebootDue(effectiveRebootSchedule(device), deviceTz(device), now, device.reboot_last_date);
if (!due) return;
+ // A nightly reboot can be scheduled on a group, and a group holds browser tabs. Sending it
+ // anyway was harmless in itself, but the log line below then claimed a reboot had fired every
+ // night for a display that cannot reboot — which is what someone reads when they are trying to
+ // work out why a panel never came back.
+ if (!playerCapabilities.supports(device, 'system.reboot')) return;
db.prepare('UPDATE devices SET reboot_last_date = ? WHERE id = ?').run(today, device.id);
deviceNs.to(device.id).emit('device:command', { type: 'reboot', payload: { scheduled: true } });
console.log(`[reboot] scheduled reboot fired for device ${device.id} (${device.name || 'unnamed'}) at local ${today}`);
diff --git a/server/test/capability-declaration.test.js b/server/test/capability-declaration.test.js
new file mode 100644
index 0000000..0ec187c
--- /dev/null
+++ b/server/test/capability-declaration.test.js
@@ -0,0 +1,172 @@
+'use strict';
+
+// End-to-end for the one thing the whole capability model rests on: what the player says at
+// registration is what the dashboard renders from.
+//
+// The trap this guards is a three-state column read as two. NULL means "this display has never
+// told us anything" and must fall back to its platform baseline, because several hundred displays
+// in the field will not update before the next dashboard deploy and blanking their controls is a
+// far worse bug than the one being fixed. '[]' means "I genuinely can do nothing" and must be
+// honoured. Anything that collapses those two — COALESCE, a falsy check, `caps || baseline` —
+// looks correct in review and takes out either the legacy fleet or the honest players.
+//
+// Capabilities are also re-read on EVERY register, not once: an Android panel gains real
+// screenshots the moment accessibility is switched on and loses Tier-2 when device owner is
+// revoked. A first-registration-only write would pin the display to whatever was true at pairing.
+
+const { test, before, after } = require('node:test');
+const assert = require('node:assert/strict');
+const { spawn } = require('node:child_process');
+const path = require('node:path');
+const os = require('node:os');
+const fs = require('node:fs');
+const crypto = require('node:crypto');
+const Database = require('better-sqlite3');
+const ioClient = require('../node_modules/socket.io-client');
+
+const { freePort } = require('./helpers/free-port');
+let PORT, BASE, proc, db;
+const DATA_DIR = path.join(os.tmpdir(), 'st-caps-' + crypto.randomBytes(4).toString('hex'));
+const LOG = path.join(os.tmpdir(), 'st-caps-' + crypto.randomBytes(4).toString('hex') + '.log');
+const S = {};
+
+const jfetch = async (p, opts = {}) => {
+ const res = await fetch(BASE + p, opts);
+ let body = null; try { body = await res.json(); } catch { /* */ }
+ return { status: res.status, body };
+};
+const auth = () => ({ Authorization: 'Bearer ' + S.token, 'Content-Type': 'application/json' });
+
+before(async () => {
+ PORT = await freePort();
+ BASE = `http://127.0.0.1:${PORT}`;
+ const logFd = fs.openSync(LOG, 'w');
+ proc = spawn('node', ['server.js'], {
+ cwd: path.join(__dirname, '..'),
+ env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' },
+ stdio: ['ignore', logFd, logFd],
+ });
+ let up = false;
+ for (let i = 0; i < 80; i++) {
+ try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* */ }
+ await new Promise(r => setTimeout(r, 250));
+ }
+ if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
+ db = new Database(path.join(DATA_DIR, 'db', 'remote_display.db'));
+
+ const email = 'u' + crypto.randomBytes(5).toString('hex') + '@x.local';
+ const reg = await jfetch('/api/auth/register', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email, password: 'Passw0rd123' }),
+ });
+ S.token = reg.body.token;
+ const me = await jfetch('/api/auth/me', { headers: auth() });
+ S.wsId = me.body.accessible_workspaces[0].id;
+});
+after(() => { try { db && db.close(); } catch { /* */ } try { proc.kill('SIGKILL'); } catch { /* */ } });
+
+function makeDevice() {
+ const id = crypto.randomUUID();
+ const token = crypto.randomBytes(32).toString('hex');
+ db.prepare(`INSERT INTO devices (id, name, status, workspace_id, device_token, client_type, created_at)
+ VALUES (?, 'CAPS', 'online', ?, ?, 'apk', strftime('%s','now'))`)
+ .run(id, S.wsId, token);
+ return { id, token };
+}
+
+function register(dev, payload = {}) {
+ return new Promise((resolve, reject) => {
+ const s = ioClient(BASE + '/device', { transports: ['websocket'], reconnection: false });
+ s.on('connect', () => s.emit('device:register', { device_id: dev.id, device_token: dev.token, ...payload }));
+ s.on('device:registered', () => resolve(s));
+ s.on('device:auth-error', (e) => reject(new Error(e && e.error)));
+ setTimeout(() => reject(new Error('register timeout')), 10000);
+ });
+}
+const wait = (ms) => new Promise(r => setTimeout(r, ms));
+const stored = (id) => db.prepare('SELECT capabilities FROM devices WHERE id = ?').get(id).capabilities;
+
+test('a player that declares its capabilities has them persisted', async () => {
+ const dev = makeDevice();
+ const s = await register(dev, { capabilities: ['playback.video', 'system.reboot'] });
+ await wait(400);
+ assert.deepEqual(JSON.parse(stored(dev.id)), ['playback.video', 'system.reboot']);
+ s.close();
+});
+
+test('THE DISTINCTION: a player that declares nothing leaves the column NULL', async () => {
+ // Not '[]'. The legacy fleet lands here, and NULL is what routes them to their platform
+ // baseline instead of to an empty dashboard.
+ const dev = makeDevice();
+ const s = await register(dev);
+ await wait(400);
+ assert.equal(stored(dev.id), null,
+ 'an absent field must stay distinguishable from an empty declaration');
+ s.close();
+});
+
+test('...while an EMPTY declaration is stored as an empty array and honoured', async () => {
+ const dev = makeDevice();
+ const s = await register(dev, { capabilities: [] });
+ await wait(400);
+ assert.equal(stored(dev.id), '[]', 'a player saying "I can do nothing" is a real answer');
+ s.close();
+});
+
+test('capabilities are re-read on every register, not frozen at pairing', async () => {
+ // Accessibility switched on between boots is the concrete case: the panel gains real
+ // screenshots and the Remote tab has to appear without a re-pair.
+ const dev = makeDevice();
+ let s = await register(dev, { capabilities: ['playback.video'] });
+ await wait(400);
+ s.close();
+ await wait(200);
+
+ s = await register(dev, { capabilities: ['playback.video', 'remote.screenshot'] });
+ await wait(400);
+ assert.deepEqual(JSON.parse(stored(dev.id)), ['playback.video', 'remote.screenshot'],
+ 'a stale set would keep a working control hidden until someone re-paired the display');
+ s.close();
+});
+
+test('a capability the server has never heard of is dropped, not stored', async () => {
+ // The column feeds a UI gate and a server-side command check. Letting arbitrary strings through
+ // would let a player invent its own permissions by naming them.
+ const dev = makeDevice();
+ const s = await register(dev, { capabilities: ['playback.video', 'system.root_shell_lol'] });
+ await wait(400);
+ assert.deepEqual(JSON.parse(stored(dev.id)), ['playback.video']);
+ s.close();
+});
+
+test('a garbage capabilities field does not stop the display registering', async () => {
+ // A player mid-rollout with a bug in its declaration must still come online; a screen that
+ // refuses to connect is worse than one with the wrong buttons.
+ const dev = makeDevice();
+ const s = await register(dev, { capabilities: 'not-an-array' });
+ await wait(400);
+ assert.equal(s.connected, true, 'the register still succeeded');
+ s.close();
+});
+
+test('the device API returns the RESOLVED list, so the dashboard never re-derives it', async () => {
+ // Two implementations of "what can this display do" drift apart, and the one in the browser is
+ // the one nobody runs tests against. The server answers; the dashboard only renders.
+ const declared = makeDevice();
+ const s = await register(declared, { capabilities: ['playback.video'] });
+ await wait(400);
+ s.close();
+
+ const legacy = makeDevice(); // never registered: NULL column, baseline expected
+
+ const a = await jfetch(`/api/devices/${declared.id}`, { headers: auth() });
+ assert.equal(a.status, 200);
+ assert.deepEqual(a.body.capabilities, ['playback.video']);
+
+ const b = await jfetch(`/api/devices/${legacy.id}`, { headers: auth() });
+ assert.equal(b.status, 200);
+ assert.ok(Array.isArray(b.body.capabilities) && b.body.capabilities.length > 0,
+ 'an undeclared Android panel must come back with its baseline, not an empty list');
+ assert.ok(b.body.capabilities.includes('system.reboot'),
+ 'and that baseline is what keeps the existing fleet\'s controls on screen');
+});
diff --git a/server/test/device-command-gating.test.js b/server/test/device-command-gating.test.js
new file mode 100644
index 0000000..70cbe3c
--- /dev/null
+++ b/server/test/device-command-gating.test.js
@@ -0,0 +1,87 @@
+'use strict';
+
+// Hiding a button is not enforcement, and the dashboard is not the only way to send a command.
+// The socket is reachable directly, a group send fans out to a mixed-platform fleet, and an
+// operator with a tab open from before the panel declared anything still has the old controls on
+// screen. In every one of those paths a command the player cannot honour used to be DELIVERED and
+// silently dropped — the "reports success and changes nothing" shape again, one layer down.
+//
+// So the refusal lives on the server and names the capability, and the group route reports the
+// skipped devices separately from the ones it actually reached. A group toast that counts an
+// unreachable web player as "sent" is how an operator walks away believing the whole group
+// rebooted.
+
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const caps = require('../lib/player-capabilities');
+
+test('a browser tab is refused reboot, and the refusal says which capability was missing', () => {
+ const web = { android_version: 'Web/Chrome' };
+ const verdict = caps.commandAllowed(web, 'reboot');
+ assert.equal(verdict.ok, false);
+ assert.equal(verdict.capability, 'system.reboot', 'the operator has to be told WHY, not just "no"');
+});
+
+test('the legacy fleet is not locked out of the commands it has always accepted', () => {
+ // The failure mode that would be worse than the bug: several hundred Android displays declare
+ // nothing, and a refusal keyed off "declared nothing => supports nothing" bricks every control
+ // in the product at once.
+ const legacy = { client_type: 'apk', android_version: '9' };
+ for (const cmd of ['reboot', 'launch', 'refresh', 'update', 'screen_on', 'screen_off', 'set_volume']) {
+ assert.equal(caps.commandAllowed(legacy, cmd).ok, true, `${cmd} must still reach a legacy Android panel`);
+ }
+});
+
+test('a command with no capability requirement is never refused', () => {
+ // set_debug is diagnostics. Gating it would take away the tool you reach for precisely when a
+ // panel is misreporting what it can do.
+ assert.equal(caps.capabilityForCommand('set_debug'), null);
+ assert.equal(caps.commandAllowed({ android_version: 'Web/Chrome' }, 'set_debug').ok, true);
+});
+
+test('an unrecognised command type is passed through, not silently swallowed', () => {
+ // New player features ship before the server learns their names. Refusing by default would make
+ // every such command fail with a confusing "unsupported" instead of reaching the panel.
+ assert.equal(caps.capabilityForCommand('some_future_command'), null);
+ assert.equal(caps.commandAllowed({ client_type: 'apk' }, 'some_future_command').ok, true);
+});
+
+test('shutdown and reboot share one privilege, so a panel cannot be half-refused', () => {
+ // They are the same "device power lifecycle" authority. Splitting them produced a UI with
+ // Shutdown present and Reboot missing on the same display, which reads as a broken dashboard.
+ assert.equal(caps.capabilityForCommand('shutdown'), caps.capabilityForCommand('reboot'));
+});
+
+test('the per-window dim is NOT the backlight — conflating them hides a working slider', () => {
+ // set_brightness is the player's own overlay (Android Tier 0, no device owner);
+ // set_system_brightness writes the real backlight and needs settings-write. Mapping both to
+ // system.brightness — which is deliberately absent from every baseline because it is
+ // conditional — would have removed the overlay slider from the entire undeclared Android fleet.
+ const legacy = { client_type: 'apk', android_version: '11' };
+ assert.equal(caps.commandAllowed(legacy, 'set_brightness').ok, true, 'overlay dim has always worked here');
+ assert.equal(caps.commandAllowed(legacy, 'set_system_brightness').ok, false, 'backlight is conditional');
+ assert.notEqual(caps.capabilityForCommand('set_brightness'), caps.capabilityForCommand('set_system_brightness'));
+});
+
+test('every command in the map points at a capability that actually exists', () => {
+ // A typo here does not fail loudly: supports() returns false for an unknown name, so the command
+ // is refused for EVERY device on every platform, forever.
+ for (const [cmd, cap] of Object.entries(caps.COMMAND_CAPABILITY)) {
+ if (cap === null) continue;
+ assert.ok(caps.CAP_SET.has(cap), `${cmd} maps to unknown capability ${cap}`);
+ }
+});
+
+test('a device row that failed to load refuses everything rather than guessing', () => {
+ // A missing row would otherwise fall through platformFamily() to the web baseline and cheerfully
+ // authorise commands against a device that does not exist.
+ assert.equal(caps.commandAllowed(null, 'reboot').ok, false);
+ assert.equal(caps.commandAllowed(undefined, 'set_volume').ok, false);
+});
+
+test('a player declaring nothing at all is refused every gated command', () => {
+ const mute = { client_type: 'apk', capabilities: '[]' };
+ assert.equal(caps.commandAllowed(mute, 'reboot').ok, false);
+ assert.equal(caps.commandAllowed(mute, 'set_volume').ok, false);
+ assert.equal(caps.commandAllowed(mute, 'set_debug').ok, true, 'ungated commands still pass');
+});
diff --git a/server/test/device-controls-hidden.test.js b/server/test/device-controls-hidden.test.js
new file mode 100644
index 0000000..549e6d0
--- /dev/null
+++ b/server/test/device-controls-hidden.test.js
@@ -0,0 +1,190 @@
+'use strict';
+
+// The dashboard offered every control to every display. "Reboot device" on a browser tab, screen
+// power on a Tizen TV, a Remote tab whose live view is a permanently black canvas on a player with
+// no framebuffer read. Every one of them looked like a working button and did nothing — the
+// "reports success and changes nothing" shape that keeps costing people days.
+//
+// Controls are now HIDDEN, not disabled: a greyed-out button on a panel that will never gain the
+// capability is a permanent unanswerable question. Which makes the opposite failure the dangerous
+// one — a gate that is slightly too strict strips controls from the several hundred displays
+// already in the field, none of which declare anything. That case gets its own test below, and it
+// is the one to read first if this file ever goes red.
+//
+// This renders the real device-detail template out of the source file rather than asserting on a
+// copy of it, so a control added later without a gate shows up here instead of in production.
+
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const vm = require('node:vm');
+
+const SRC = fs.readFileSync(
+ path.join(__dirname, '..', '..', 'frontend', 'js', 'views', 'device-detail.js'), 'utf8');
+
+// The template is one tagged region inside loadDevice(). Pull it out and evaluate it against
+// stubbed helpers — the point is which controls appear, not how they are styled.
+const START = 'contentEl.innerHTML = `';
+const template = (() => {
+ const i = SRC.indexOf(START);
+ assert.ok(i > 0, 'device-detail.js no longer has the innerHTML template this test renders');
+ const j = SRC.indexOf('\n `;', i);
+ assert.ok(j > i, 'could not find the end of the template');
+ return SRC.slice(i + START.length, j);
+})();
+
+function render(device) {
+ const caps = Array.isArray(device.capabilities) ? device.capabilities : null;
+ const sandbox = {
+ device,
+ caps,
+ can: (cap) => (caps ? caps.includes(cap) : true),
+ latestTelemetry: {},
+ diagWidget: null,
+ // Stubs. Each returns something recognisable so a control cannot be "found" by accident.
+ t: (key) => key,
+ esc: (s) => String(s == null ? '' : s),
+ formatBytes: () => '0 MB',
+ formatUptime: () => '0m',
+ ssidLabel: () => 'ssid',
+ livenessBadge: () => ({ state: 'online', label: 'online', title: '' }),
+ renderDiagPanel: () => '',
+ renderDeviceClock: () => '',
+ renderPlaylist: () => '',
+ isBrightSignDevice: (d) => String(d.platform || '').toLowerCase().includes('brightsign'),
+ TERMINAL_PRESETS: [],
+ localStorage: { getItem: () => null, setItem: () => {} },
+ Math, Date, JSON, String, Array, Object,
+ };
+ return vm.runInNewContext('`' + template + '`', sandbox);
+}
+
+const ANDROID_FULL = {
+ client_type: 'apk', android_version: '13',
+ capabilities: ['playback.video', 'audio.volume', 'display.power', 'display.brightness',
+ 'remote.screenshot', 'remote.stream', 'remote.input',
+ 'system.reboot', 'system.restart_player', 'system.self_update'],
+};
+const WEB = {
+ android_version: 'Web/Chrome',
+ capabilities: ['playback.video', 'audio.volume', 'remote.screenshot', 'remote.stream',
+ 'remote.input', 'system.restart_player'],
+};
+const TIZEN = {
+ platform: 'Tizen 6.5',
+ capabilities: ['playback.video', 'audio.volume', 'display.rotation', 'remote.input',
+ 'system.restart_player'],
+};
+const BRIGHTSIGN = {
+ platform: 'brightsign', hardware_model: 'XT245',
+ capabilities: ['playback.video', 'audio.volume', 'display.power', 'display.rotation',
+ 'remote.input', 'system.reboot', 'system.restart_player'],
+};
+
+const has = (html, id) => html.includes(`id="${id}"`);
+
+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');
+ assert.equal(has(html, 'shutdownBtn'), false);
+ assert.equal(has(html, 'screenOffBtn'), false, 'nor switch off the monitor');
+ assert.equal(has(html, 'screenOnBtn'), false);
+ assert.equal(has(html, 'forceUpdateBtn'), false, 'nor update itself — the page reloads instead');
+ assert.ok(has(html, 'launchAppBtn'), 'but reloading the player IS something it can do');
+});
+
+test('a Tizen TV is not offered screen power or the reboot it has no API for', () => {
+ const html = render(TIZEN);
+ assert.equal(has(html, 'screenOffBtn'), false);
+ assert.equal(has(html, 'screenOnBtn'), false);
+ assert.equal(has(html, 'rebootBtn'), false);
+ assert.equal(has(html, 'forceUpdateBtn'), false);
+});
+
+test('a BrightSign IS offered the screen power and reboot it genuinely has', () => {
+ // The check that catches gating written as "hide everything that is not Android", which would
+ // read as correct on every other test in this file.
+ const html = render(BRIGHTSIGN);
+ assert.ok(has(html, 'screenOffBtn'));
+ assert.ok(has(html, 'screenOnBtn'));
+ assert.ok(has(html, 'rebootBtn'));
+});
+
+test('an Android panel keeps the full control set', () => {
+ const html = render(ANDROID_FULL);
+ for (const id of ['rebootBtn', 'screenOffBtn', 'screenOnBtn', 'launchAppBtn', 'forceUpdateBtn',
+ 'screenshotBtn', 'startRemoteBtn', 'sysVolume', 'sysWinBrightness']) {
+ assert.ok(has(html, id), `${id} must survive`);
+ }
+});
+
+test('THE REGRESSION THAT MATTERS: an undeclared legacy display loses nothing', () => {
+ // ~440 real displays declare nothing. If the gate reads "no declaration => supports nothing",
+ // every one of them loses its entire control panel the moment this deploys — a far worse bug
+ // than the one being fixed. The server resolves a per-platform baseline for them, and this
+ // asserts the client renders whatever it is handed rather than second-guessing it.
+ const legacyAndroid = { client_type: 'apk', android_version: '9' }; // no capabilities field
+ const html = render(legacyAndroid);
+ for (const id of ['rebootBtn', 'screenOffBtn', 'screenOnBtn', 'launchAppBtn', 'forceUpdateBtn',
+ 'screenshotBtn', 'startRemoteBtn']) {
+ assert.ok(has(html, id), `${id} disappeared for a display that never declared anything`);
+ }
+});
+
+test('the live view is hidden on a player that cannot capture, and the key pad is not', () => {
+ // Start used to produce a canvas that stayed black forever, which reads as a dead panel rather
+ // than as an unsupported feature. The D-pad still works there — it is a different mechanism.
+ const html = render(TIZEN);
+ assert.equal(has(html, 'startRemoteBtn'), false, 'no screenshot stream to start');
+ assert.equal(has(html, 'remoteCanvas'), false, 'and no permanently black canvas');
+ assert.ok(html.includes('KEYCODE_DPAD_CENTER'), 'key input is unaffected');
+});
+
+test('a player with no remote surface at all loses the whole Remote tab', () => {
+ const blind = { platform: 'brightsign', capabilities: ['playback.video', 'audio.volume'] };
+ const html = render(blind);
+ assert.equal(html.includes('data-tab="remote"'), false, 'no tab');
+ assert.equal(has(html, 'tab-remote'), false, 'and no orphaned tab body behind it');
+});
+
+test('a tab trigger is never rendered without its content, or the click blanks the page', () => {
+ // setupTabs() does getElementById(`tab-${dataset.tab}`).classList.add(...) with no null check,
+ // so a trigger whose body was gated away throws on click and leaves every tab deselected.
+ for (const device of [WEB, TIZEN, BRIGHTSIGN, ANDROID_FULL, { client_type: 'apk' }]) {
+ const html = render(device);
+ for (const m of html.matchAll(/data-tab="([\w-]+)"/g)) {
+ assert.ok(has(html, `tab-${m[1]}`),
+ `tab "${m[1]}" has a trigger but no content for ${device.platform || device.android_version || 'apk'}`);
+ }
+ }
+});
+
+test('the capability list is shown, so a missing control is explainable', () => {
+ // Hiding controls with no explanation just moves the confusion: "the reboot button vanished"
+ // is a support ticket unless the page says what the panel reported.
+ const html = render(TIZEN);
+ assert.ok(html.includes('device.caps.title'));
+ assert.ok(html.includes('remote.input'), 'the actual declared names are listed');
+ assert.ok(html.includes('device.caps.declared'));
+
+ const legacy = render({ client_type: 'apk' });
+ assert.ok(legacy.includes('device.caps.assumed'),
+ 'and an undeclared display says so rather than presenting a guess as fact');
+});
+
+test('every gated control still renders balanced markup', () => {
+ // A gate placed around an opening tag but not its close leaves the rest of the page inside a
+ // stray element, which does not throw and does not show up in any assertion above.
+ for (const device of [WEB, TIZEN, BRIGHTSIGN, ANDROID_FULL, { client_type: 'apk' },
+ { platform: 'brightsign', capabilities: [] }]) {
+ const html = render(device);
+ const open = (html.match(/