Merge pull request #250 from screentinker/fix/brightsign-capture-port-and-queue

BrightSign capture: reach the right DWS port, and let the host collect its request
This commit is contained in:
screentinker 2026-08-07 14:21:38 -05:00 committed by GitHub
commit 77e3081675
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 386 additions and 52 deletions

View file

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

View file

@ -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 };

View file

@ -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

View file

@ -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');
});

View file

@ -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' });
});

View file

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