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