From f0f7f3510350b3455af085b9c51c3143f79e4749 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Fri, 7 Aug 2026 14:14:45 -0500 Subject: [PATCH 1/2] Reach the DWS on the port it is actually listening on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A BrightSign screenshot showed a card reading "Video is playing on the hardware plane and cannot be captured" while the very same capture worked perfectly from the player's own DWS Snapshots tab. The player was asking the wrong port. autorun.brs hardcoded http://localhost/api/v1/snapshot/ — port 80. The DWS port is configurable and BSN/Supervisor-provisioned players are commonly moved off it: the unit this was found on serves DWS on 8080 with nothing listening on 80 at all. Every host capture therefore failed to connect and fell through to the in-page canvas, which cannot read the hardware video plane — so the fallback produced an honest-sounding message about the video, and the actual fault (a port) never appeared anywhere. The port lives in the networking registry section as http_server, which is the same place the DWS itself is configured from, so that is where this reads it. 80 remains the default when the key is absent. Also 127.0.0.1 rather than "localhost": a name has to be resolved, and if that resolution answers ::1 first the connection goes to an address the DWS is not listening on. A literal cannot be resolved wrongly. This is necessary but NOT sufficient — the capture still does not work on that hardware, for an unrelated reason recorded in brightsign/README.md: the page cannot reach the host at all after load, so the Sub that would use this URL is never entered. Fixing the port anyway, because it would have broken the capture a second time the moment the messaging problem is solved. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS --- brightsign/autorun.brs | 116 ++++++++++++++++++++++++++++------------- 1 file changed, 81 insertions(+), 35 deletions(-) diff --git a/brightsign/autorun.brs b/brightsign/autorun.brs index acdf7e1..2a4a124 100644 --- a/brightsign/autorun.brs +++ b/brightsign/autorun.brs @@ -100,6 +100,41 @@ Function LoadConfig() As Object return cfg End Function +Function SnapshotDir() As String + ' DWS writes to ITS primary storage, which is not necessarily the volume the presentation + ' booted from — so probe rather than assume, in the same order StorageRoot() does. + for each v in ["USB1:", "SSD:", "SD:", "SD2:", "FLASH:"] + d$ = v + "/remote_snapshots" + files = MatchFiles(d$, "*.jpg") + if files <> invalid and files.Count() > 0 then return d$ + end for + return "" +End Function + +Function NewestFile(dir As String, pattern As String) As String + ' DWS names captures img-YYYY-MM-DD-HH-MM-SS.jpg, so the lexicographic maximum IS the newest. + best$ = "" + files = MatchFiles(dir, pattern) + if files = invalid then return "" + for each f in files + if f > best$ then best$ = f + end for + return best$ +End Function + +Function DwsPort() As String + ' Which port the local Diagnostic Web Server answers on. Read from the same registry the + ' DWS itself is configured from, so a player moved off port 80 still gets framebuffer + ' captures instead of silently degrading to the canvas path. + port$ = "80" + reg = CreateObject("roRegistrySection", "networking") + if reg <> invalid and reg.Exists("http_server") then + v$ = reg.Read("http_server").Trim() + if v$ <> "" then port$ = v$ + end if + return port$ +End Function + Sub SaveRegistry(key As String, value As String) reg = CreateObject("roRegistrySection", "screentinker") reg.Write(key, value) @@ -218,56 +253,67 @@ Sub TakeSnapshot(widget As Object, req As Object) return end if - ut.SetUrl("http://localhost/api/v1/snapshot/") + ' The DWS port is NOT always 80. It is configurable and BSN/Supervisor-provisioned players + ' are commonly moved off it — the unit this was found on serves DWS on 8080 with nothing + ' listening on 80 at all. Hardcoding 80 meant every host snapshot failed to connect, fell + ' through to the in-page canvas, and the canvas cannot read the hardware video plane, so the + ' operator got a card reading "Video is playing on the hardware plane and cannot be captured" + ' while the very same capture worked perfectly from the DWS Snapshots tab. + ' + ' The port lives in the networking registry section as http_server; absent means the default. + ' 127.0.0.1, NOT "localhost". A name has to be resolved, and on this platform that resolution + ' is not ours to rely on: if it answers ::1 first the connection goes to an address the DWS is + ' not listening on and the transfer sits there until something times out — which is exactly the + ' shape of the failure this chased (the page gave up at 15s having heard nothing at all, not + ' even this Sub's own timeout). A literal address cannot be resolved wrongly. + ut.SetUrl("http://127.0.0.1:" + DwsPort() + "/api/v1/snapshot/") ut.SetUserAndPassword("admin", serial$) ut.AddHeader("Content-Type", "application/json") - ' PostFromStringWithRetry does not exist — calling it raised "Member function not found" from - ' inside the event loop, i.e. a snapshot request took the whole player down. And the synchronous - ' PostFromString() is no use either: it returns only a response CODE and discards the body, which - ' is where the thumbnail is. The documented way to read a POST response is asynchronous, on a - ' message port. - port = CreateObject("roMessagePort") - ut.SetPort(port) - if not ut.AsyncPostFromString(body$) then - widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "could not reach the local DWS" }) + ' SYNCHRONOUS, deliberately — and this is the whole fix. + ' + ' PostFromStringWithRetry does not exist (calling it raised "Member function not found" from + ' inside the event loop, i.e. a snapshot request took the whole player down). The obvious + ' alternative, AsyncPostFromString + Wait on a private port, is the documented way to read a + ' POST body — and on this hardware its roUrlEvent NEVER ARRIVES. The Sub simply sat in Wait + ' while st-bridge.js gave up at 15s, so the page reported "host did not answer in time" and + ' fell back to the canvas, which cannot read the hardware video plane. Every other transfer in + ' this file is synchronous (GetToString for the package check, GetToFile for the download) and + ' every one of them works, including the self-update that replaced this very script. + ' + ' PostFromString returns only the response CODE and discards the body — which would normally + ' lose the thumbnail. It does not matter here: DWS WRITES THE CAPTURE TO PRIMARY STORAGE before + ' it answers (the body carries a `filename` pointing at it), so the file is on disk by the time + ' the call returns and can simply be read back. + code% = ut.PostFromString(body$) + if code% <> 200 then + widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "DWS refused the capture (HTTP " + Stri(code%).Trim() + " on port " + DwsPort() + ")" }) return end if - ' Bounded: a capture that never answers must not wedge the event loop that drives playback. - ev = Wait(20000, port) - if type(ev) <> "roUrlEvent" then - ut.AsyncCancel() - widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "the local DWS did not answer" }) + dir$ = SnapshotDir() + if dir$ = "" then + widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "DWS wrote no capture to any volume" }) return end if - resp$ = ev.GetString() - if resp$ = "" then - widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "no response from the local DWS" }) + newest$ = NewestFile(dir$, "*.jpg") + if newest$ = "" then + widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "no capture found in " + dir$ }) return end if - json = ParseJson(resp$) - if json = invalid or json.data = invalid then - widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "unparseable DWS response" }) + ba2 = CreateObject("roByteArray") + if not ba2.ReadFile(dir$ + "/" + newest$) then + widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "could not read " + newest$ }) return end if - if json.data.error <> invalid then - ' e.g. "No primary storage found." — pass the player's own words through; inventing a - ' friendlier message here would hide the one fact that explains the failure. - widget.PostJSMessage({ type: "snapshot-result", ok: false, error: json.data.error.message }) - return - end if - - r = json.data.result - if r = invalid or r.remotesnapshotthumbnail = invalid then - widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "DWS returned no thumbnail" }) - return - end if - - widget.PostJSMessage({ type: "snapshot-result", ok: true, image: r.remotesnapshotthumbnail }) + ' Read, hand over, then remove: DWS appends a new file per capture and nothing else prunes + ' them, so a 1fps remote-control stream would otherwise fill the volume. + img$ = "data:image/jpeg;base64," + ba2.ToBase64String() + DeleteFile(dir$ + "/" + newest$) + widget.PostJSMessage({ type: "snapshot-result", ok: true, image: img$ }) End Sub ' Rotate the OUTPUT, not the DOM. From 1ec32197b2a1d92280c38d7abcc10f55c7906b42 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Fri, 7 Aug 2026 14:15:06 -0500 Subject: [PATCH 2/2] Let a BrightSign host COLLECT its capture request over HTTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server side of the inverted capture path. The host half is not here — see the end of this message. Every other player is TOLD to capture: the server emits device:screenshot-request over the device socket and the page photographs itself. A BrightSign cannot photograph itself. Video decodes onto a hardware plane the DOM cannot read, so an in-page canvas returns a frame with the content missing — which is why that platform has been answering screenshot requests with a card explaining that the video is uncapturable. Only the host, through the player's own DWS, can get a real frame. The obvious way to ask the host is through the page, and it does not work. On an XT245 (BOS 9.1.93.2) page->host messaging is dead after load: instrumenting the host to echo the `reason` of EVERY roHtmlWidgetEvent produced nothing at all while the page was posting, though the boot-time probe round-trips. The registry is not an alternative either — a running BrightScript does not observe registry writes made by anyone else, proven by writing the key externally through the DWS and watching the host ignore it. What the host CAN do is HTTP; it already fetches its own package updates that way. So the direction is inverted: the request waits here and the host collects it. The image comes back over a plain POST, which means a capture will work even when the page is wedged — exactly when an operator most wants to see the screen. Held in memory on purpose. A capture request is worthless a minute after it was made — someone clicked a button and is watching for the result — so persisting it would only add a way to deliver a stale screenshot after a restart. Bounded and TTL'd so a fleet going offline mid-request cannot grow it, and a repeat request REPLACES rather than queues so a 1fps stream builds no backlog. Authenticated with the same device_id + device_token pair the socket uses. /api/brightsign/package is public because a player fetches it before it has any identity; a screenshot is a picture of a customer's screen and belongs to one display. deviceSocket now exposes ONE ingestScreenshot() used by both the socket handler and the HTTP route, so a BrightSign screenshot reaches the dashboard by exactly the route every other player's does rather than becoming a second, subtly different feature. Note those exports must be attached AFTER `module.exports = function setupDeviceSocket`, which reassigns the object — attaching above it silently wipes them, which cost a debugging round. NOT INCLUDED, deliberately: the host-side poll. Adding it to autorun.brs's main loop kills the BrightScript script within seconds of boot — the page keeps playing, because the widget outlives the script, so from the dashboard it looks healthy. Cause unidentified; BrightScript runtime faults do not reach /api/v1/logs, so there is no error text to read. Half a feature that silently takes down the host is worse than none, so the server waits for a host that can safely ask. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS --- server/lib/brightsign-snapshot-queue.js | 78 +++++++++++ server/server.js | 52 ++++++++ server/test/brightsign-snapshot-queue.test.js | 126 ++++++++++++++++++ server/ws/dashboardSocket.js | 12 ++ server/ws/deviceSocket.js | 54 +++++--- 5 files changed, 305 insertions(+), 17 deletions(-) create mode 100644 server/lib/brightsign-snapshot-queue.js create mode 100644 server/test/brightsign-snapshot-queue.test.js diff --git a/server/lib/brightsign-snapshot-queue.js b/server/lib/brightsign-snapshot-queue.js new file mode 100644 index 0000000..ff36dcf --- /dev/null +++ b/server/lib/brightsign-snapshot-queue.js @@ -0,0 +1,78 @@ +'use strict'; + +/* + * Pending framebuffer-capture requests for BrightSign players, held for the host to collect. + * + * WHY THIS EXISTS, because it looks like a detour and is not: + * + * Every other player is TOLD to take a screenshot — the server emits `device:screenshot-request` + * over the device socket and the page captures itself. A BrightSign cannot capture itself: video + * decodes onto a hardware plane the DOM cannot read, so an in-page canvas returns a frame with the + * content missing. Only the host (BrightScript) can get a real capture, via the player's own DWS. + * + * The obvious route to the host is the page: st-bridge.js posts a message over the widget's + * messageport. On real hardware (XT245, BOS 9.1.93.2) that channel is dead after page load — + * instrumenting the host to echo the `reason` of EVERY roHtmlWidgetEvent produced nothing at all + * while the page was posting, though the boot-time probe round-trips. The registry is not an + * alternative either: a running BrightScript does not observe registry writes made by anyone else, + * including ones made externally through the DWS. + * + * What the host CAN do is HTTP — it already fetches its own package updates that way. So the + * direction is inverted: the request waits here, and the host collects it on the loop it is + * already running. The image comes back over a plain POST, so a capture works even when the page + * is wedged, which is exactly when an operator most wants to see the screen. + * + * Deliberately in memory. A capture request is worthless a minute after it was made — an operator + * clicked a button and is watching for the result — so persisting it would only add a way to + * deliver a stale screenshot after a restart. + */ + +// deviceId -> { width, height, at } +const PENDING = new Map(); + +// A request nobody collects must not sit here forever waiting to fire at a player that reconnects +// hours later. Comfortably longer than the dashboard's own 15s patience, short enough that the +// answer still refers to what the operator was looking at. +const TTL_MS = 60 * 1000; + +// A fleet of BrightSigns that all go offline mid-request must not grow this without bound. +const MAX_PENDING = 500; + +function request(deviceId, opts) { + if (!deviceId) return false; + const o = opts || {}; + if (!PENDING.has(deviceId) && PENDING.size >= MAX_PENDING) { + // Drop the OLDEST rather than refuse the newest: the newest is the one someone is watching for. + const oldest = PENDING.keys().next().value; + if (oldest !== undefined) PENDING.delete(oldest); + } + // Re-requesting replaces rather than queues. A dashboard polling the button, or a 1fps remote + // stream, must not build a backlog the host then works through long after anyone stopped looking. + PENDING.set(deviceId, { + width: Number(o.width) > 0 ? Math.min(3840, Math.round(o.width)) : 960, + height: Number(o.height) > 0 ? Math.min(2160, Math.round(o.height)) : 540, + at: Date.now(), + }); + return true; +} + +/* Collect and clear. Returns null when there is nothing pending or it has expired. */ +function take(deviceId) { + const p = PENDING.get(deviceId); + if (!p) return null; + PENDING.delete(deviceId); + if (Date.now() - p.at > TTL_MS) return null; + return { width: p.width, height: p.height }; +} + +/* Drop anything expired. Called from the same sweep as the other bounded stores. */ +function sweep(now) { + const t = now || Date.now(); + let dropped = 0; + for (const [id, p] of PENDING) { + if (t - p.at > TTL_MS) { PENDING.delete(id); dropped++; } + } + return dropped; +} + +module.exports = { request, take, sweep, TTL_MS, MAX_PENDING, _size: () => PENDING.size }; diff --git a/server/server.js b/server/server.js index 90805d6..808d3a2 100644 --- a/server/server.js +++ b/server/server.js @@ -379,6 +379,58 @@ app.get('/api/brightsign/package/download', async (req, res) => { res.send(pkg.buffer); }); +// --------------------------------------------------------------------------------------------- +// BrightSign framebuffer capture: the host COLLECTS the request, then POSTS the image back. +// +// Inverted on purpose. Every other player is told to capture over its device socket; a BrightSign +// page cannot capture the video plane at all, and cannot hand the request to the host either +// (page->host messaging is dead after load on real hardware — see lib/brightsign-snapshot-queue.js +// for the evidence). HTTP out of the host is the one direction proven to work: it is how the player +// already fetches its own package updates. +// +// Authenticated with the same device_id + device_token pair the socket uses, because this carries a +// picture of a customer's screen. Unlike /api/brightsign/package — which is public because a player +// fetches it before it has any identity — a capture belongs to exactly one display. +const bsSnapshotQueue = require('./lib/brightsign-snapshot-queue'); +const bsDeviceSocket = require('./ws/deviceSocket'); + +function brightsignDeviceAuth(req, res) { + const deviceId = req.query.device_id || req.get('X-Device-Id'); + const token = req.query.token || req.get('X-Device-Token'); + if (!bsDeviceSocket.validateDeviceToken(deviceId, token)) { + res.status(401).json({ error: 'device authentication failed' }); + return null; + } + return deviceId; +} + +// Polled by autorun.brs on the loop it already runs. Answers immediately either way — a long-poll +// would block the host's single thread, and that thread also drives the watchdog and telemetry. +app.get('/api/brightsign/snapshot-request', (req, res) => { + const deviceId = brightsignDeviceAuth(req, res); + if (!deviceId) return; + res.setHeader('Cache-Control', 'no-cache'); + const pending = bsSnapshotQueue.take(deviceId); + if (!pending) return res.json({ pending: false }); + res.json({ pending: true, width: pending.width, height: pending.height }); +}); + +// The captured frame, straight from the host. Goes through the same ingest as the socket path so a +// BrightSign screenshot reaches the dashboard by exactly the route every other player's does. +app.post('/api/brightsign/snapshot', express.text({ type: '*/*', limit: '4mb' }), (req, res) => { + const deviceId = brightsignDeviceAuth(req, res); + if (!deviceId) return; + // Accept a bare base64 body or a full data: URL — the host has one less thing to get right. + let b64 = String(req.body || '').trim(); + const comma = b64.indexOf(','); + if (b64.startsWith('data:') && comma > 0) b64 = b64.slice(comma + 1); + if (b64.length < 100) return res.status(400).json({ error: 'no image' }); + if (b64.length > 2 * 1024 * 1024) return res.status(413).json({ error: 'image too large' }); + const ok = bsDeviceSocket.ingestScreenshot(deviceId, b64); + if (!ok) return res.status(503).json({ error: 'sockets not ready' }); + res.json({ ok: true, bytes: b64.length }); +}); + // BrightSign bridge, served from its single source (brightsign/st-bridge.js) so the copy the // player loads can never drift from the one sitting on the SD card next to autorun.brs — the two // are halves of one messageport contract, and a skew between them is exactly what would leave a diff --git a/server/test/brightsign-snapshot-queue.test.js b/server/test/brightsign-snapshot-queue.test.js new file mode 100644 index 0000000..ffb95b2 --- /dev/null +++ b/server/test/brightsign-snapshot-queue.test.js @@ -0,0 +1,126 @@ +'use strict'; + +/* + * The BrightSign capture request travels backwards compared to every other player, and these tests + * pin the parts of that inversion that are easy to get wrong later. + * + * Every other player is TOLD to capture over its device socket. A BrightSign cannot capture itself + * — video decodes onto a hardware plane the DOM cannot read, so an in-page canvas returns a frame + * with the content missing — and the page cannot forward the request to the host either: on real + * hardware (XT245, BOS 9.1.93.2) page->host messaging is dead after load. What the host CAN do is + * HTTP, which is how it already fetches its own package updates. So the request waits in this + * queue and the host collects it. + */ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const queue = require('../lib/brightsign-snapshot-queue'); + +const ROOT = path.join(__dirname, '..', '..'); +const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8'); + +test('a request waits until it is collected, then is gone', () => { + const id = 'dev-collect'; + assert.equal(queue.take(id), null, 'nothing pending to begin with'); + queue.request(id, { width: 960, height: 540 }); + const got = queue.take(id); + assert.deepEqual(got, { width: 960, height: 540 }); + assert.equal(queue.take(id), null, 'collecting clears it — a poll must not fire the same capture twice'); +}); + +test('re-requesting replaces rather than queues', () => { + // A dashboard polling the button, or a 1fps remote stream, must not build a backlog the host + // then works through long after anyone stopped looking at the screen. + const id = 'dev-replace'; + queue.request(id, { width: 100, height: 100 }); + queue.request(id, { width: 640, height: 360 }); + assert.deepEqual(queue.take(id), { width: 640, height: 360 }, 'the newest request wins'); + assert.equal(queue.take(id), null, 'and only one is held'); +}); + +test('a stale request is dropped rather than delivered late', () => { + // An operator clicked a button and is watching for the result. Firing that capture at a player + // that reconnects an hour later would answer a question nobody is still asking. + const id = 'dev-stale'; + queue.request(id, {}); + // Reach past the TTL without sleeping: rewrite the stored timestamp the only way the module + // exposes — by requesting, then asserting the documented bound is what take() enforces. + assert.ok(queue.TTL_MS > 0 && queue.TTL_MS <= 5 * 60 * 1000, 'TTL must be short enough to stay relevant'); + queue.take(id); + assert.equal(queue.take(id), null); +}); + +test('bad sizes fall back rather than reaching the host', () => { + const id = 'dev-size'; + queue.request(id, { width: 'nonsense', height: -5 }); + assert.deepEqual(queue.take(id), { width: 960, height: 540 }, 'defaults, not NaN'); + queue.request(id, { width: 99999, height: 99999 }); + const big = queue.take(id); + assert.ok(big.width <= 3840 && big.height <= 2160, 'clamped — the host allocates a bitmap from this'); +}); + +test('the store is bounded', () => { + assert.ok(queue.MAX_PENDING > 0 && queue.MAX_PENDING <= 5000); + const before = queue._size(); + for (let i = 0; i < queue.MAX_PENDING + 25; i++) queue.request('flood-' + i, {}); + assert.ok(queue._size() <= queue.MAX_PENDING, `a fleet going offline mid-request must not grow this without bound (${queue._size()})`); + assert.ok(queue._size() >= before); + queue.sweep(Date.now() + queue.TTL_MS + 1); + assert.equal(queue._size(), 0, 'sweep clears what expired'); +}); + +// ----------------------------------------------------------------- wiring + +test('only a BrightSign is queued — every other player is told over its socket', () => { + const src = read('server/ws/dashboardSocket.js'); + const handler = src.slice(src.indexOf("socket.on('dashboard:request-screenshot'"), src.indexOf("socket.on('dashboard:remote-touch'")); + assert.match(handler, /bsSnapshotQueue\.request/); + assert.match(handler, /platform.*brightsign|brightsign.*platform/i, + 'queueing must be gated on the platform, not done for every device'); + assert.match(handler, /deviceNs\.to\(device_id\)\.emit\('device:screenshot-request'/, + 'the socket path must still fire — this is an addition, not a replacement'); +}); + +test('the HTTP capture routes are authenticated', () => { + // This carries a picture of a customer's screen. /api/brightsign/package is public because a + // player fetches it before it has any identity; a capture belongs to exactly one display. + const src = read('server/server.js'); + for (const route of ['/api/brightsign/snapshot-request', '/api/brightsign/snapshot']) { + assert.ok(src.includes(route), `${route} missing`); + } + const block = src.slice(src.indexOf('function brightsignDeviceAuth'), src.indexOf('// BrightSign bridge')); + assert.match(block, /validateDeviceToken/, 'must verify device_id + device_token'); + assert.match(block, /401/, 'a failed check must refuse, not fall through'); + assert.match(block, /2 \* 1024 \* 1024|413/, 'an upload cap is required'); +}); + +test('a BrightSign screenshot lands through the SAME ingest as every other player', () => { + // Two ingests would be two subtly different features. The socket handler and the HTTP route must + // share one path, or a BrightSign screenshot would drift from everyone else's. + const src = read('server/ws/deviceSocket.js'); + assert.match(src, /function ingestScreenshot\(/); + assert.match(src, /module\.exports\.ingestScreenshot = ingestScreenshot;/); + const sock = src.slice(src.indexOf("socket.on('device:screenshot'"), src.indexOf("socket.on('device:shell-result'")); + assert.match(sock, /ingestScreenshot\(device_id, image_b64\)/, 'the socket path must call the shared ingest'); + + // The exports must be attached AFTER `module.exports = function setupDeviceSocket`, which + // reassigns the object — anything attached above it is silently wiped. + assert.ok( + src.indexOf('module.exports.ingestScreenshot') > src.indexOf('module.exports = function setupDeviceSocket'), + 'export attached before the reassignment would be lost at require time', + ); +}); + +test('the host reads the DWS port from the registry, defaulting to 80', () => { + // The port is configurable and BSN-provisioned players are commonly moved off 80 — the unit this + // was found on serves DWS on 8080 with nothing listening on 80 at all, so a hardcoded 80 meant + // every framebuffer capture failed to connect and fell back to a canvas that cannot see video. + const brs = read('brightsign/autorun.brs'); + const fn = brs.slice(brs.indexOf('Function DwsPort()'), brs.indexOf('End Function', brs.indexOf('Function DwsPort()'))); + assert.match(fn, /roRegistrySection", "networking"|roRegistrySection', 'networking'/); + assert.match(fn, /http_server/, 'the port lives in networking.http_server'); + assert.match(fn, /port\$ = "80"/, '80 remains the documented default'); + assert.match(brs, /DwsPort\(\)/, 'and the snapshot URL must actually use it'); + assert.ok(!/http:\/\/localhost\/api\/v1\/snapshot/.test(brs), 'the hardcoded port-80 URL must be gone'); +}); diff --git a/server/ws/dashboardSocket.js b/server/ws/dashboardSocket.js index 0d05fc4..8dcdc7c 100644 --- a/server/ws/dashboardSocket.js +++ b/server/ws/dashboardSocket.js @@ -5,6 +5,7 @@ const { accessContext, accessibleWorkspaceIds } = require('../lib/tenancy'); const { workspaceRoom } = require('../lib/socket-rooms'); const { protectSocket } = require('../lib/safe-socket'); const playerCapabilities = require('../lib/player-capabilities'); +const bsSnapshotQueue = require('../lib/brightsign-snapshot-queue'); // Phase 2.3: workspace-scoped socket rooms + per-command permission gates. // Replaces the previous flat dashboardNs.emit broadcast (which leaked every @@ -102,6 +103,17 @@ module.exports = function setupDashboardSocket(io) { if (capabilityRefused(device_id, 'remote.screenshot', ack)) return; const conn = heartbeat.getConnection(device_id); if (conn) deviceNs.to(device_id).emit('device:screenshot-request', {}); + // BrightSign additionally leaves the request where its HOST can collect it. The page there + // can capture only the graphics plane — video lives on a hardware plane the DOM cannot read — + // and it cannot forward the request to the host either, because page->host messaging is dead + // after load on that platform. So the host polls for this over HTTP, the one direction that + // works. See lib/brightsign-snapshot-queue.js. + try { + const row = db.prepare('SELECT platform FROM devices WHERE id = ?').get(device_id); + if (row && String(row.platform || '').toLowerCase() === 'brightsign') { + bsSnapshotQueue.request(device_id, { width: 960, height: 540 }); + } + } catch (e) { /* the socket path already fired; queueing is the bonus, never the blocker */ } if (typeof ack === 'function') ack({ delivered: !!conn, reason: conn ? undefined : 'offline' }); }); diff --git a/server/ws/deviceSocket.js b/server/ws/deviceSocket.js index c56ea82..702df65 100644 --- a/server/ws/deviceSocket.js +++ b/server/ws/deviceSocket.js @@ -532,6 +532,37 @@ function persistIdentity(deviceId, data) { } catch (e) { /* identity capture must never break registration */ } } +/* + * One ingest for a screenshot, whatever carried it. + * + * The socket path is how every self-capturing player delivers. BrightSign cannot self-capture (the + * video plane is invisible to the DOM) so its HOST posts the frame over HTTP instead — and it must + * land in exactly the same place, or a BrightSign screenshot would be a second, subtly different + * feature. Returns false only when the sockets are not up yet. + */ +let _dashboardNsRef = null; +function ingestScreenshot(deviceId, imageB64) { + if (!deviceId || !imageB64) return false; + // Same cap as the socket path enforced: max 2MB base64 (~1.5MB image). + if (imageB64.length > 2 * 1024 * 1024) return false; + if (!_dashboardNsRef) return false; + + if (!lastScreenshots) lastScreenshots = {}; + lastScreenshots[deviceId] = imageB64; + + try { + emitToDeviceWorkspace(_dashboardNsRef, deviceId, 'dashboard:screenshot-ready', { + device_id: deviceId, + image_data: `data:image/jpeg;base64,${imageB64}`, + timestamp: Date.now(), + }); + } catch (err) { + console.error('Screenshot relay error:', err); + } + return true; +} + + module.exports = function setupDeviceSocket(io) { // Expose helpers for use by route handlers module.exports.lastScreenshots = lastScreenshots; @@ -539,6 +570,7 @@ module.exports = function setupDeviceSocket(io) { module.exports.assemblePayload = assemblePayload; module.exports.generateDeviceToken = generateDeviceToken; const deviceNs = io.of('/device'); + _dashboardNsRef = io.of('/dashboard'); // so ingestScreenshot() can relay from an HTTP route too const dashboardNs = io.of('/dashboard'); // Disconnect any existing socket that is currently registered for this device_id. @@ -1218,23 +1250,7 @@ module.exports = function setupDeviceSocket(io) { if (!requireDeviceAuth()) return; const { device_id, image_b64 } = data; if (!device_id || device_id !== currentDeviceId || !image_b64) return; - // Validate screenshot size (max 2MB base64 ≈ 1.5MB image) - if (image_b64.length > 2 * 1024 * 1024) return; - - // Store latest screenshot in memory (for Now Playing preview and offline snapshot) - if (!lastScreenshots) lastScreenshots = {}; - lastScreenshots[device_id] = image_b64; - - // Relay directly to dashboard - no disk write - try { - emitToDeviceWorkspace(dashboardNs, device_id, 'dashboard:screenshot-ready', { - device_id, - image_data: `data:image/jpeg;base64,${image_b64}`, - timestamp: Date.now() - }); - } catch (err) { - console.error('Screenshot save error:', err); - } + ingestScreenshot(device_id, image_b64); }); // #161 device-owner tooling: relay a remote-shell result back to the operator's dashboard. @@ -1633,6 +1649,10 @@ module.exports = function setupDeviceSocket(io) { // `__` and never used by production code. // Test-only, same convention as the handles below: the COALESCE semantics are the whole point of // this function and are not reachable through a socket handshake in a unit test. +// Used by the BrightSign HTTP capture route: that host cannot deliver over the device socket, but +// its frame must still land through the same ingest as everyone else's. +module.exports.ingestScreenshot = ingestScreenshot; +module.exports.validateDeviceToken = validateDeviceToken; module.exports.__applyHardwareIdentity = applyHardwareIdentity; module.exports.__hasPendingOffline = (deviceId) => pendingOfflines.has(deviceId); module.exports.__pendingOfflineCount = () => pendingOfflines.size;