diff --git a/brightsign/README.md b/brightsign/README.md
index 2e49099..1ab28c7 100644
--- a/brightsign/README.md
+++ b/brightsign/README.md
@@ -147,6 +147,30 @@ because the BrightSigns would look perfectly synchronised while the odd panel dr
A player paired before this port is still recognised, by its BrightSign user agent.
+### How the choice reaches a screen
+
+`device_groups.sync_backend` (`auto` | `screentinker` | `brightsign`) is the operator's **request**.
+The server resolves it per push through `resolveSyncBackend()` and sends the answer — plus the
+reason and a `downgraded` flag — in the `group_sync` payload, so the players, the dashboard and the
+stored setting can never disagree about which protocol is running.
+
+Three things force a fallback to our protocol, and each is reported rather than applied silently:
+
+| condition | why native sync cannot run |
+|---|---|
+| any non-BrightSign member | BrightWall cannot include a foreign screen |
+| members on different subnets | it is multicast; it does not cross networks |
+| the elected leader is offline | it is leader/follower — nobody would broadcast |
+
+That last one has no equivalent in our protocol, which is leaderless and carries on regardless.
+Leadership uses the existing election (`resolveGroupLeader`): the pinned leader if it is an online
+member on the shared playlist, else the first online member, else the first member by id.
+
+**Item selection stays clock-derived under both backends.** Native sync only replaces the
+seek/nudge drift correction, because `setSyncParams` has the video element hold its own alignment —
+and correcting it ourselves would fight the platform. That also keeps images and widgets, which have
+no `setSyncParams`, advancing with the videos instead of drifting off on their own.
+
## Command parity
The web player handles four of the ~20 fleet commands — `launch`, `refresh`, `screen_on`,
@@ -194,13 +218,14 @@ have no BrightSign equivalent — a signage player has no per-window brightness
Stated plainly so nobody reads this as finished:
-- **No server-side plumbing**: no `sync_backend` column, no dashboard control, nothing sends
- `set-sync-backend` down, and nothing consumes the `bs_model` / `bs_serial` / `bs_screen` fields
- the player now reports. The resolver is ready for all of it.
-- **Native sync is implemented but not yet driven by the playlist engine.** `st-sync.js` wraps
- SyncManager and is tested (`server/test/brightsign-sync.test.js`), but nothing in the player
- calls `announce()` on item advance or binds `attachVideo()` yet, and no leader is designated.
- That wiring is the next step and wants hardware to validate.
+- **Nothing consumes the `bs_model` / `bs_serial` / `bs_screen` fields** the player reports. Device
+ telemetry (temperature, storage) also has no schema to land in yet.
+- **Native sync is wired but UNPROVEN on hardware.** The player drives it end to end — the leader
+ announces on each advance, every member (leader included) binds via `attachVideo()` on a new id,
+ and the resolved backend is chosen per group and pushed down. It cannot be verified with one
+ player: a single unit is trivially "in sync with itself". **Two BrightSigns on one subnet are
+ needed** to confirm frame alignment, that the leader does not run ahead, and that the 1Hz repeat
+ causes no visible reload.
```js
const SyncManager = require('@brightsign/syncmanager'); // BrightSignOS 8.2.10+
diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js
index 2c3ad53..1d9f852 100644
--- a/frontend/js/i18n/en.js
+++ b/frontend/js/i18n/en.js
@@ -207,6 +207,12 @@ export default {
'dashboard.group_sync.toast_on': 'Synchronized playback enabled',
'dashboard.group_sync.toast_off': 'Synchronized playback disabled',
'dashboard.group_sync.toast_resync': 'Resync sent to group',
+ 'dashboard.group_sync.backend_auto': 'Sync: Auto',
+ 'dashboard.group_sync.backend_screentinker': 'Sync: Standard',
+ 'dashboard.group_sync.backend_brightsign': 'Sync: BrightSign',
+ 'dashboard.group_sync.backend_hint': "Which synchronisation protocol this group uses. Standard works across every player type and keeps displays aligned to the second, with no leader and no internet needed. BrightSign is frame-accurate but only works when every display in the group is a BrightSign on the same network, and it synchronises video only. Auto picks BrightSign when the group can actually run it, and Standard otherwise.",
+ 'dashboard.group_sync.toast_backend': 'Sync protocol updated',
+ 'dashboard.group_sync.toast_downgraded': 'Saved, but this group cannot run that protocol:',
'dashboard.manage_tooltip': 'Add/remove devices',
'dashboard.delete_group_tooltip': 'Delete group',
'dashboard.no_devices_in_group': 'No devices in this group. Click Manage to add some.',
diff --git a/frontend/js/views/dashboard.js b/frontend/js/views/dashboard.js
index 60232dd..1369532 100644
--- a/frontend/js/views/dashboard.js
+++ b/frontend/js/views/dashboard.js
@@ -234,6 +234,14 @@ function renderGroupSection(group, devices, playlists) {
${t('dashboard.group_sync.label')}
${group.sync_enabled ? `
+
+ ${t('dashboard.group_sync.backend_auto')}
+ ${t('dashboard.group_sync.backend_screentinker')}
+ ${t('dashboard.group_sync.backend_brightsign')}
+
+ ${group.sync_effective ? `
+ ${group.sync_downgraded ? '⚠ ' : ''}${esc(group.sync_effective)}${group.sync_reason ? ' — ' + esc(group.sync_reason) : ''} ` : ''}
${t('dashboard.group_sync.resync')} ` : ''}
` : ''}
${t('dashboard.manage')}
@@ -862,6 +870,31 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
});
});
+ // Choose the sync protocol. The server may refuse the choice (native sync needs every member to
+ // be a BrightSign on one L2 network), so re-render from its answer rather than assuming the
+ // request took — showing a setting that isn't in force is exactly what makes a drifting wall
+ // impossible to diagnose.
+ document.querySelectorAll('.group-backend-select').forEach(sel => {
+ sel.addEventListener('change', async (e) => {
+ const groupId = e.target.dataset.groupId;
+ const previous = sel.dataset.previous || 'auto';
+ const chosen = e.target.value;
+ try {
+ const updated = await api.updateGroup(groupId, { sync_backend: chosen });
+ if (updated?.sync_downgraded && updated?.sync_reason) {
+ showToast(t('dashboard.group_sync.toast_downgraded') + ' ' + updated.sync_reason, 'warning');
+ } else {
+ showToast(t('dashboard.group_sync.toast_backend'), 'success');
+ }
+ loadDashboard();
+ } catch (err) {
+ showToast(err.message, 'error');
+ e.target.value = previous;
+ }
+ });
+ sel.dataset.previous = sel.value;
+ });
+
// #group-sync: manual "Resync now" — nudge all members to re-snap to the shared schedule.
document.querySelectorAll('.group-resync-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
diff --git a/server/db/database.js b/server/db/database.js
index 7bc4305..5656c2b 100644
--- a/server/db/database.js
+++ b/server/db/database.js
@@ -167,6 +167,12 @@ const migrations = [
// or offline the server auto-elects the first online member on the matching playlist.
"ALTER TABLE device_groups ADD COLUMN sync_enabled INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE device_groups ADD COLUMN leader_device_id TEXT REFERENCES devices(id) ON DELETE SET NULL",
+ // Which synchronisation protocol the group runs: 'auto' | 'screentinker' | 'brightsign'.
+ // BrightSign's native SyncManager is frame-accurate but exists only between BrightSign players
+ // on one L2 network, so it cannot be the default — 'auto' picks it only when the group can
+ // actually run it. See server/lib/sync-backend.js; the resolver is the single source of that
+ // decision and this column is only the operator's request.
+ "ALTER TABLE device_groups ADD COLUMN sync_backend TEXT NOT NULL DEFAULT 'auto'",
// Wall-level playlist: video walls now play a playlist (not just one content).
"ALTER TABLE video_walls ADD COLUMN playlist_id TEXT REFERENCES playlists(id) ON DELETE SET NULL",
// Free-form canvas layout: walls store a player rect; member devices store
diff --git a/server/player/index.html b/server/player/index.html
index 35d01d8..6f66ffe 100644
--- a/server/player/index.html
+++ b/server/player/index.html
@@ -597,6 +597,15 @@
let groupAlignPending = true;
let groupLastAlignedIndex = -1;
let groupLastSeekAt = 0; // seek cooldown — don't hard-seek every tick (decoder-thrash guard)
+ // BrightSign native sync (SyncManager). An ALTERNATIVE to the clock-derived correction above,
+ // never an addition: when it is running, the seek/nudge maths is skipped entirely because the
+ // video element aligns itself once setSyncParams has been applied. Item SELECTION stays
+ // clock-derived either way — that is what keeps images and widgets, which have no
+ // setSyncParams, advancing together with the videos.
+ let nativeSync = null; // the ScreenTinkerBSSync instance while active
+ let nativeSyncEvent = null; // latest sync event awaiting a video to bind
+ let nativeSyncBoundId = null; // sync id already bound, so we attach once per session
+ let nativeSyncAnnounced = -1; // last index the LEADER announced, to announce once per advance
// Double buffer: a hidden for the NEXT clip, buffered/decoded ahead of the boundary so the
// switch is instant (no black hold). Reused by renderContent when it reaches that index.
let groupPreloadEl = null;
@@ -1859,6 +1868,20 @@
currentIndex = t.index;
playCurrentItem();
action = 'jump>' + t.index;
+ // LEADER ONLY: open a new sync session for the item we just moved to. The id must change
+ // on every advance or the followers' 1Hz dedupe swallows it and the group sits on the
+ // previous item forever.
+ if (nativeSync && groupSync.is_leader && nativeSyncAnnounced !== t.index) {
+ nativeSyncAnnounced = t.index;
+ const key = (playlist[t.index]?.content_id || playlist[t.index]?.id || t.index);
+ nativeSync.announce(key, Date.now());
+ }
+ } else if (nativeSync) {
+ // Native sync owns alignment: setSyncParams has the element keeping itself in step, so the
+ // seek/nudge maths below would fight it — every correction we applied would be a frame the
+ // player then had to undo. Just make sure the current item is bound.
+ bindNativeSyncVideo();
+ action = 'native';
} else if (currentVideoEl && isFinite(currentVideoEl.duration) && currentVideoEl.duration > 0) {
const dur = currentVideoEl.duration;
const target = t.posSec % dur; // loop-safe when duration_sec > clip length
@@ -1914,10 +1937,55 @@
// Enter/leave group sync. No CSS transform, no forced mute (per-item mute honored), no leader —
// just start the schedule tick. Idempotent across refreshes/role churn (there is no role now).
+ // Enter/leave BrightSign native sync for this group. Returns true if it is running afterwards.
+ // Deliberately fails CLOSED: if the module is missing, the platform is not BrightSign, or the
+ // session will not start, we return false and the caller keeps the clock-derived path — a group
+ // that silently ran neither protocol would drift with no indication of why.
+ function applyNativeSync(cfg) {
+ const want = cfg && cfg.backend === 'brightsign';
+ if (!want || !global_ScreenTinkerBSSync() || !global_ScreenTinkerBSSync().available()) {
+ if (nativeSync) { try { nativeSync.stop(); } catch (e) {} }
+ nativeSync = null; nativeSyncEvent = null; nativeSyncBoundId = null; nativeSyncAnnounced = -1;
+ if (want) groupReport('warn', 'native sync requested but SyncManager is unavailable — using clock sync');
+ return false;
+ }
+ if (nativeSync) { try { nativeSync.stop(); } catch (e) {} nativeSync = null; }
+ nativeSyncEvent = null; nativeSyncBoundId = null; nativeSyncAnnounced = -1;
+
+ const s = global_ScreenTinkerBSSync().create({ domain: 'ST-' + String(cfg.group_id).slice(0, 8) });
+ // The leader receives its OWN broadcast and starts from that, like every follower — starting
+ // at announce() time instead would put it ahead of the group by the width of the network.
+ s.onItem = function (ev) { nativeSyncEvent = ev; bindNativeSyncVideo(); };
+ if (!s.start(!!cfg.is_leader)) {
+ groupReport('warn', 'native sync failed to start — using clock sync');
+ return false;
+ }
+ nativeSync = s;
+ console.log('[native-sync] started as ' + (cfg.is_leader ? 'LEADER' : 'follower') + ' group=' + cfg.group_id);
+ groupReport('info', 'native sync started (' + (cfg.is_leader ? 'leader' : 'follower') + ')');
+ return true;
+ }
+ function global_ScreenTinkerBSSync() {
+ return (typeof window !== 'undefined' && window.ScreenTinkerBSSync) || null;
+ }
+ // Bind the mounted video to the current session, exactly once per sync id. The event can arrive
+ // before the element exists (the leader announces as it advances), so this is called both from
+ // the event and from the tick — whichever wins, the guard makes it idempotent.
+ function bindNativeSyncVideo() {
+ if (!nativeSync || !nativeSyncEvent) return;
+ if (nativeSyncEvent.id === nativeSyncBoundId) return;
+ if (!currentVideoEl) return; // images/widgets have no setSyncParams to bind
+ if (nativeSync.attachVideo(currentVideoEl, nativeSyncEvent)) {
+ nativeSyncBoundId = nativeSyncEvent.id;
+ groupReport('info', 'native sync bound id=' + String(nativeSyncEvent.id).slice(0, 24));
+ }
+ }
+
function applyGroupSync(cfg) {
if (groupSyncTimer) { clearInterval(groupSyncTimer); groupSyncTimer = null; }
try { if (cfg) localStorage.setItem('st_group_sync', JSON.stringify(cfg)); else localStorage.removeItem('st_group_sync'); } catch (e) {}
if (!cfg) {
+ applyNativeSync(null);
if (groupSync) groupReport('info', 'group-sync exited'); groupSync = null; console.log('[group-sync] exited');
if (groupPreloadEl) { try { groupPreloadEl.remove(); } catch (e) {} groupPreloadEl = null; groupPreloadIdx = -1; }
reconcileAdvanceTimerForMode(); // #200: back to solo -> re-arm a surviving image/widget's timer
@@ -1926,7 +1994,14 @@
const first = !groupSync;
groupSync = cfg;
groupAlignPending = true; groupLastAlignedIndex = -1; // snap the first item into sync on entry
- console.log('[group-sync] group=' + cfg.group_id + ' (clock/schedule, offset=' + clockOffsetMs + 'ms)');
+ // Tell the host which protocol won, so a cold boot with no network starts in the right mode.
+ try { if (BS && cfg.backend) BS.setSyncBackend(cfg.backend); } catch (e) { /* not on BrightSign */ }
+ if (cfg.sync_downgraded && cfg.sync_reason) {
+ groupReport('warn', 'sync downgraded to ' + cfg.backend + ': ' + cfg.sync_reason);
+ }
+ applyNativeSync(cfg);
+ console.log('[group-sync] group=' + cfg.group_id + ' backend=' + (cfg.backend || 'screentinker')
+ + ' (clock/schedule, offset=' + clockOffsetMs + 'ms)');
groupReport('info', 'group-sync ' + (first ? 'entered' : 'refresh') + ' group=' + String(cfg.group_id).slice(0, 8) + ' off=' + clockOffsetMs + 'ms');
groupScheduleTick(); // align immediately
groupSyncTimer = setInterval(groupScheduleTick, 250); // 4Hz local correction
@@ -2035,9 +2110,15 @@
socket.emit('wall:sync-request', { wall_id: wallConfig.wall_id });
}
// #group-sync: enter/leave on group membership change (mutually exclusive with wall — the
- // server sends group_sync=null for a wall member). There's no leader role, so the key is just
- // the group id; a plain refresh re-aligns the schedule locally (no server round-trip needed).
- const groupKey = (g) => (g ? String(g.group_id) : '');
+ // server sends group_sync=null for a wall member). A plain refresh re-aligns the schedule
+ // locally (no server round-trip needed).
+ //
+ // The clock-derived protocol has no leader and one mode, so the group id alone used to be
+ // enough. Native sync has both: switching protocol, or leadership moving because the old
+ // leader went offline, changes what THIS player must do — and neither changes the group id.
+ // Keying on the id alone would leave a player running the old protocol, or leave a promoted
+ // leader silently not announcing, until something unrelated forced a re-enter.
+ const groupKey = (g) => (g ? [g.group_id, g.backend || '', g.is_leader ? 'L' : 'f'].join('|') : '');
const groupChanged = groupKey(groupSync) !== groupKey(data.group_sync);
if (groupChanged) applyGroupSync(data.group_sync || null);
else if (groupSync) groupScheduleTick();
diff --git a/server/routes/device-groups.js b/server/routes/device-groups.js
index a9a5b78..18feff2 100644
--- a/server/routes/device-groups.js
+++ b/server/routes/device-groups.js
@@ -8,6 +8,7 @@ const { accessContext } = require('../lib/tenancy');
// #public-api: operational fleet commands (reboot/shutdown/...) need the 'full' token
// 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 VALID_COLOR = /^#[0-9A-Fa-f]{6}$/;
const ALLOWED_COMMANDS = [
@@ -50,6 +51,21 @@ function requireGroupWrite(req, res, next) {
next();
}
+// What the group's sync_backend setting actually RESOLVES to, for the dashboard. The stored value
+// is only a request: 'brightsign' on a mixed fleet, or on players spread across subnets, is refused
+// by the resolver. Sending the decision alongside the request is what lets the UI explain the
+// refusal instead of showing a setting that quietly isn't in force.
+function syncDecisionFor(group) {
+ if (!group?.playlist_id) return { sync_effective: null, sync_reason: null, sync_downgraded: false };
+ const members = db.prepare(`
+ SELECT d.id, d.platform, d.ip_address FROM devices d
+ JOIN device_group_members dgm ON dgm.device_id = d.id
+ WHERE dgm.group_id = ? AND d.playlist_id = ?
+ `).all(group.id, group.playlist_id);
+ const d = resolveSyncBackend(group.sync_backend, members);
+ return { sync_effective: d.backend, sync_reason: d.reason, sync_downgraded: d.downgraded };
+}
+
// List groups in the caller's current workspace.
router.get('/', (req, res) => {
if (!req.workspaceId) return res.json([]);
@@ -61,7 +77,7 @@ router.get('/', (req, res) => {
GROUP BY g.id
ORDER BY g.name ASC
`).all(req.workspaceId);
- res.json(groups);
+ res.json(groups.map(g => ({ ...g, ...syncDecisionFor(g) })));
});
// Create group in the caller's current workspace.
@@ -78,7 +94,13 @@ router.post('/', (req, res) => {
// Update group
router.put('/:id', requireGroupWrite, (req, res) => {
- const { name, color, sync_enabled, leader_device_id, reboot_schedule } = req.body;
+ const { name, color, sync_enabled, leader_device_id, reboot_schedule, sync_backend } = req.body;
+ // Reject an unknown backend rather than storing it: the resolver reads anything unrecognised as
+ // 'auto', so a typo would silently give the operator a different protocol from the one they
+ // picked, with the UI still showing their typo back to them.
+ if (sync_backend !== undefined && !BACKENDS.includes(sync_backend)) {
+ return res.status(400).json({ error: `sync_backend must be one of: ${BACKENDS.join(', ')}` });
+ }
if (color && !VALID_COLOR.test(color)) return res.status(400).json({ error: 'invalid color format, use #RRGGBB' });
// #12 scheduled reboot: group-level default nightly-reboot time ("HH:MM" or null/'' = off).
// A member device's own reboot_schedule overrides this in the scheduler.
@@ -105,12 +127,18 @@ router.put('/:id', requireGroupWrite, (req, res) => {
}
db.prepare('UPDATE device_groups SET leader_device_id = ? WHERE id = ?').run(leader_device_id || null, req.params.id);
}
+ if (sync_backend !== undefined) {
+ db.prepare('UPDATE device_groups SET sync_backend = ? WHERE id = ?').run(sync_backend, req.params.id);
+ }
// Re-push to every member so they enter/exit sync mode and refresh their is_leader flag.
- if (sync_enabled !== undefined || leader_device_id !== undefined) {
+ // sync_backend belongs here too: switching protocol has to reach the players, or the group keeps
+ // running the old one until something unrelated happens to re-push.
+ if (sync_enabled !== undefined || leader_device_id !== undefined || sync_backend !== undefined) {
const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(req.params.id);
for (const m of members) pushPlaylistToDevice(req, m.device_id);
}
- res.json(db.prepare('SELECT * FROM device_groups WHERE id = ?').get(req.params.id));
+ const updated = db.prepare('SELECT * FROM device_groups WHERE id = ?').get(req.params.id);
+ res.json({ ...updated, ...syncDecisionFor(updated) });
});
// #group-sync: manual "Resync now" — nudge every member to re-snap to the shared schedule
diff --git a/server/test/group-sync-backend-api.test.js b/server/test/group-sync-backend-api.test.js
new file mode 100644
index 0000000..aef20fe
--- /dev/null
+++ b/server/test/group-sync-backend-api.test.js
@@ -0,0 +1,96 @@
+'use strict';
+
+// Choosing a sync protocol from the dashboard has one failure mode that matters: a value the
+// resolver does not recognise is read as 'auto'. So a typo — or a client sending a stale/renamed
+// value — would store fine, return 200, and leave the UI showing a protocol the group is not
+// running. The operator's only clue would be a wall that is subtly out of step.
+//
+// The other half is reporting. When the request cannot be honoured (native sync on a mixed fleet)
+// the group must still be told what it will ACTUALLY run and why, or the setting silently means
+// something different from what it says.
+
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const path = require('path');
+const fs = require('fs');
+const os = require('os');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-sbapi-'));
+process.env.DATA_DIR = tmp;
+process.env.JWT_SECRET = 'test-secret-sync-backend-api';
+
+const express = require('express');
+const { db } = require('../db/database');
+const { requireAuth, generateToken } = require('../middleware/auth');
+const { resolveTenancy } = require('../lib/tenancy');
+
+const O = 'o-sa', WS = 'ws-sa', U = 'u-sa', PL = 'pl-sa', G = 'g-sa';
+db.prepare("INSERT OR IGNORE INTO users (id,email,password_hash,role) VALUES (?,?, 'x','user')").run(U, 'sa@t.local');
+db.prepare('INSERT OR IGNORE INTO organizations (id,name,owner_user_id) VALUES (?,?,?)').run(O, 'Org', U);
+db.prepare('INSERT OR IGNORE INTO workspaces (id,organization_id,name) VALUES (?,?,?)').run(WS, O, 'WS');
+db.prepare("INSERT OR IGNORE INTO organization_members (organization_id,user_id,role) VALUES (?,?, 'org_owner')").run(O, U);
+db.prepare('INSERT OR IGNORE INTO playlists (id,user_id,name,workspace_id) VALUES (?,?,?,?)').run(PL, U, 'Shared', WS);
+db.prepare(`INSERT OR IGNORE INTO device_groups (id,name,user_id,workspace_id,playlist_id,sync_enabled)
+ VALUES (?,?,?,?,?,1)`).run(G, 'Group', U, WS, PL);
+
+// A mixed group: one BrightSign, one Android. Native sync cannot include the Android one.
+for (const [id, platform] of [['d-sa-bs', 'brightsign'], ['d-sa-and', 'Android 12']]) {
+ db.prepare(`INSERT OR IGNORE INTO devices (id,name,workspace_id,playlist_id,status,platform,ip_address,created_at,updated_at)
+ VALUES (?,?,?,?, 'online',?, '10.0.0.1', strftime('%s','now'),strftime('%s','now'))`)
+ .run(id, id, WS, PL, platform);
+ db.prepare('INSERT OR IGNORE INTO device_group_members (group_id,device_id) VALUES (?,?)').run(G, id);
+}
+
+const app = express();
+app.use(express.json());
+app.set('io', null);
+app.use('/api/groups', requireAuth, resolveTenancy, require('../routes/device-groups'));
+const server = app.listen(0);
+const token = generateToken(db.prepare('SELECT id,email,role FROM users WHERE id = ?').get(U), WS);
+
+async function put(body) {
+ await new Promise(r => (server.listening ? r() : server.once('listening', r)));
+ const res = await fetch(`http://127.0.0.1:${server.address().port}/api/groups/${G}`, {
+ method: 'PUT',
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ return { status: res.status, body: await res.json().catch(() => null) };
+}
+
+test('THE TYPO: an unrecognised backend is refused, not stored as a silent "auto"', async () => {
+ const { status } = await put({ sync_backend: 'brightsigne' });
+ assert.equal(status, 400, 'storing it would show the operator a protocol the group is not running');
+ const stored = db.prepare('SELECT sync_backend FROM device_groups WHERE id = ?').get(G).sync_backend;
+ assert.equal(stored, 'auto', 'the rejected value must not have been written');
+});
+
+test('a refused request is saved but reported with what will actually run, and why', async () => {
+ const { status, body } = await put({ sync_backend: 'brightsign' });
+ assert.equal(status, 200);
+ assert.equal(body.sync_backend, 'brightsign', 'the operator\'s choice is remembered');
+ assert.equal(body.sync_effective, 'screentinker', 'but this is what the screens will run');
+ assert.equal(body.sync_downgraded, true);
+ assert.match(body.sync_reason, /non-BrightSign/);
+});
+
+test('the group list carries the same decision, so the UI never disagrees with the players', async () => {
+ await new Promise(r => (server.listening ? r() : server.once('listening', r)));
+ const res = await fetch(`http://127.0.0.1:${server.address().port}/api/groups`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ const groups = await res.json();
+ const g = groups.find(x => x.id === G);
+ assert.equal(g.sync_effective, 'screentinker');
+ assert.equal(g.sync_downgraded, true);
+});
+
+test('every accepted value round-trips', async () => {
+ for (const v of ['auto', 'screentinker', 'brightsign']) {
+ const { status, body } = await put({ sync_backend: v });
+ assert.equal(status, 200, v);
+ assert.equal(body.sync_backend, v);
+ }
+});
+
+test.after(() => server.close());
diff --git a/server/test/group-sync-backend-resolution.test.js b/server/test/group-sync-backend-resolution.test.js
new file mode 100644
index 0000000..21b262a
--- /dev/null
+++ b/server/test/group-sync-backend-resolution.test.js
@@ -0,0 +1,132 @@
+'use strict';
+
+// A synchronised group has to agree with itself about WHICH protocol it is running, across three
+// places that never speak to each other: the players, the dashboard, and the stored setting.
+//
+// The stored setting is only a REQUEST. BrightSign's native sync is frame-accurate but exists only
+// between BrightSign players on one multicast L2 network, so "brightsign" on a mixed fleet is a
+// request that cannot be honoured. If the server stored it and said nothing, the operator would
+// read "BrightSign" in the UI while the screens ran the clock-derived protocol — and the one
+// symptom they would eventually notice (a wall that is a second out) has no visible cause.
+//
+// Worse is the leader. Ours is leaderless and survives anything; native sync has ONE broadcaster,
+// so a group whose elected leader is offline sits unsynchronised with every member waiting for an
+// announcement that will never come. The dashboard would show a healthy group the whole time.
+//
+// The invariants: what the players are told and what the dashboard shows both come from the same
+// pure resolver, a refused request is reported rather than silently altered, and a group with no
+// live leader falls back to the protocol that does not need one.
+
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const path = require('path');
+const fs = require('fs');
+const os = require('os');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-syncbackend-'));
+process.env.DATA_DIR = tmp;
+process.env.JWT_SECRET = 'test-secret-sync-backend';
+
+const { db } = require('../db/database');
+
+const O = 'o-sb', WS = 'ws-sb', U = 'u-sb', PL = 'pl-sb';
+db.prepare("INSERT OR IGNORE INTO users (id,email,password_hash,role) VALUES (?,?, 'x','user')").run(U, 'sb@t.local');
+db.prepare('INSERT OR IGNORE INTO organizations (id,name,owner_user_id) VALUES (?,?,?)').run(O, 'Org', U);
+db.prepare('INSERT OR IGNORE INTO workspaces (id,organization_id,name) VALUES (?,?,?)').run(WS, O, 'WS');
+db.prepare('INSERT OR IGNORE INTO playlists (id,user_id,name,workspace_id) VALUES (?,?,?,?)').run(PL, U, 'Shared', WS);
+// OR IGNORE swallows constraint violations, so a missing NOT NULL column here would leave the row
+// absent and every later foreign key fail instead — assert the fixture actually landed.
+assert.ok(db.prepare('SELECT 1 FROM playlists WHERE id = ?').get(PL), 'playlist fixture did not insert');
+
+let seq = 0;
+/** Build a sync-enabled group on the shared playlist with the given members. */
+function makeGroup(backend, members) {
+ const gid = 'g-sb-' + (++seq);
+ db.prepare(`INSERT INTO device_groups (id,name,user_id,workspace_id,playlist_id,sync_enabled,sync_backend)
+ VALUES (?,?,?,?,?,1,?)`).run(gid, 'G' + seq, U, WS, PL, backend);
+ members.forEach((m, i) => {
+ const did = gid + '-d' + i;
+ db.prepare(`INSERT INTO devices (id,name,workspace_id,playlist_id,status,platform,ip_address,created_at,updated_at)
+ VALUES (?,?,?,?,?,?,?,strftime('%s','now'),strftime('%s','now'))`)
+ .run(did, 'D' + i, WS, PL, m.status || 'online', m.platform, m.ip || '10.0.0.' + (i + 1));
+ db.prepare('INSERT INTO device_group_members (group_id,device_id) VALUES (?,?)').run(gid, did);
+ });
+ return gid;
+}
+
+const { __test } = require('../ws/deviceSocket');
+
+test('an all-BrightSign group on one subnet is told to run native sync', () => {
+ const g = makeGroup('auto', [
+ { platform: 'brightsign', ip: '10.0.5.1' },
+ { platform: 'brightsign', ip: '10.0.5.2' },
+ ]);
+ const first = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ? ORDER BY device_id').get(g);
+ const gs = __test.resolveGroupSync({ playlist_id: PL }, first.device_id);
+ assert.equal(gs.backend, 'brightsign');
+ assert.equal(gs.sync_downgraded, false);
+});
+
+test('THE MIXED FLEET: one Android member drops the whole group back to our protocol', () => {
+ // BrightWall cannot include a non-BrightSign screen. Half-syncing is worse than second-accurate
+ // everywhere, and it would look perfectly healthy from the dashboard.
+ const g = makeGroup('brightsign', [
+ { platform: 'brightsign', ip: '10.0.6.1' },
+ { platform: 'Android 12', ip: '10.0.6.2' },
+ ]);
+ const first = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ? ORDER BY device_id').get(g);
+ const gs = __test.resolveGroupSync({ playlist_id: PL }, first.device_id);
+ assert.equal(gs.backend, 'screentinker');
+ assert.equal(gs.sync_downgraded, true);
+ assert.match(gs.sync_reason, /non-BrightSign/);
+});
+
+test('THE SILENT SPLIT: BrightSigns on different subnets do not get multicast sync', () => {
+ const g = makeGroup('brightsign', [
+ { platform: 'brightsign', ip: '10.1.0.9' },
+ { platform: 'brightsign', ip: '10.9.0.9' },
+ ]);
+ const first = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ? ORDER BY device_id').get(g);
+ const gs = __test.resolveGroupSync({ playlist_id: PL }, first.device_id);
+ assert.equal(gs.backend, 'screentinker');
+ assert.match(gs.sync_reason, /multicast|different networks/);
+});
+
+test('THE DEAD LEADER: native sync falls back when nobody is left to broadcast', () => {
+ // Ours is leaderless and carries on; native sync has exactly one broadcaster. An all-offline
+ // group still elects a leader (stable id), so without this check the members that DO come back
+ // would sit waiting for an announcement from a player that is powered down.
+ const g = makeGroup('brightsign', [
+ { platform: 'brightsign', ip: '10.0.7.1', status: 'offline' },
+ { platform: 'brightsign', ip: '10.0.7.2', status: 'offline' },
+ ]);
+ const first = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ? ORDER BY device_id').get(g);
+ const gs = __test.resolveGroupSync({ playlist_id: PL }, first.device_id);
+ assert.equal(gs.backend, 'screentinker');
+ assert.equal(gs.sync_downgraded, true);
+ assert.match(gs.sync_reason, /leader is offline/);
+});
+
+test('exactly one member is told it is the leader, and it is an online one', () => {
+ const g = makeGroup('auto', [
+ { platform: 'brightsign', ip: '10.0.8.1', status: 'offline' },
+ { platform: 'brightsign', ip: '10.0.8.2', status: 'online' },
+ ]);
+ const ids = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ? ORDER BY device_id').all(g);
+ const flags = ids.map(r => __test.resolveGroupSync({ playlist_id: PL }, r.device_id).is_leader);
+ assert.equal(flags.filter(Boolean).length, 1, 'two leaders would both broadcast; none would sync nothing');
+ const leaderId = ids[flags.indexOf(true)].device_id;
+ const st = db.prepare('SELECT status FROM devices WHERE id = ?').get(leaderId).status;
+ assert.equal(st, 'online', 'an offline leader cannot announce');
+});
+
+test('an operator choosing our protocol on an all-BrightSign group is obeyed, not overridden', () => {
+ const g = makeGroup('screentinker', [
+ { platform: 'brightsign', ip: '10.0.9.1' },
+ { platform: 'brightsign', ip: '10.0.9.2' },
+ ]);
+ const first = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ? ORDER BY device_id').get(g);
+ const gs = __test.resolveGroupSync({ playlist_id: PL }, first.device_id);
+ assert.equal(gs.backend, 'screentinker');
+ assert.equal(gs.sync_downgraded, false);
+});
diff --git a/server/test/group-sync-native-wiring.test.js b/server/test/group-sync-native-wiring.test.js
new file mode 100644
index 0000000..691b188
--- /dev/null
+++ b/server/test/group-sync-native-wiring.test.js
@@ -0,0 +1,148 @@
+'use strict';
+
+// The player's half of native sync, extracted from server/player/index.html and run against a fake
+// SyncManager. Three things here are easy to get wrong and impossible to see from a unit test of
+// st-sync.js alone, because they are about how the PLAYER drives it:
+//
+// 1. The leader must announce a NEW id on every advance. Reusing an id is swallowed by the 1Hz
+// dedupe and the whole group sits on the previous item — the failure looks like "sync is
+// stuck", not "the id was wrong".
+// 2. Binding must happen exactly once per sync id even though the event arrives at 1Hz and the
+// player also retries on every 4Hz tick. Re-binding each time reloads the video repeatedly,
+// which on screen reads as a stutter or a restart loop.
+// 3. The event routinely arrives BEFORE the video element exists, because the leader announces as
+// it advances. Dropping it there would leave that item unsynchronised for its whole duration.
+//
+// The functions under test are pulled out of the player by source extraction so they cannot drift
+// from what actually ships — the same technique the fingerprint and identity-reset tests use.
+
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('fs');
+const path = require('path');
+
+const HTML = fs.readFileSync(path.join(__dirname, '..', 'player', 'index.html'), 'utf8');
+
+/** Pull one top-level `function name(...) {...}` out of the player by brace matching. */
+function extract(name) {
+ const start = HTML.indexOf('function ' + name + '(');
+ assert.notEqual(start, -1, 'player no longer defines ' + name);
+ let depth = 0, end = -1;
+ for (let i = HTML.indexOf('{', start); i < HTML.length; i++) {
+ if (HTML[i] === '{') depth++;
+ else if (HTML[i] === '}' && --depth === 0) { end = i + 1; break; }
+ }
+ assert.notEqual(end, -1);
+ return HTML.slice(start, end);
+}
+
+/** A fake SyncManager session with the real one's contract: 1Hz repeats, dedupe by id. */
+function makeHarness({ isLeader = false, hasVideo = true } = {}) {
+ const state = {
+ announced: [],
+ attached: [],
+ reports: [],
+ boundId: null,
+ event: null,
+ };
+
+ const sync = {
+ announce(key, now) { const id = 'st_' + key + '_' + now; state.announced.push(id); return id; },
+ attachVideo(video, ev) { state.attached.push(ev.id); return true; },
+ };
+
+ const scope = {
+ nativeSync: sync,
+ nativeSyncEvent: null,
+ nativeSyncBoundId: null,
+ currentVideoEl: hasVideo ? { tagName: 'VIDEO' } : null,
+ groupReport: (lvl, msg) => state.reports.push(msg),
+ String,
+ };
+
+ // bindNativeSyncVideo closes over module-level lets; rebuild it with an explicit scope object so
+ // the assignments are observable.
+ const src = extract('bindNativeSyncVideo')
+ .replace(/nativeSyncBoundId/g, 'S.nativeSyncBoundId')
+ .replace(/nativeSyncEvent/g, 'S.nativeSyncEvent')
+ .replace(/nativeSync\b(?!S)/g, 'S.nativeSync')
+ .replace(/currentVideoEl/g, 'S.currentVideoEl')
+ .replace(/groupReport/g, 'S.groupReport');
+ const S = { ...scope };
+ const bind = new Function('S', src + '; return bindNativeSyncVideo;')(S);
+ return { S, bind, state, sync };
+}
+
+test('THE 1Hz TRAP: ten repeats of one sync id bind the video exactly once', () => {
+ const { S, bind, state } = makeHarness();
+ S.nativeSyncEvent = { id: 'st_item7_1000', domain: 'd', iso_timestamp: 't' };
+ for (let i = 0; i < 10; i++) bind();
+ assert.equal(state.attached.length, 1, 'ten binds would be ten video reloads — a visible stutter');
+});
+
+test('a NEW id binds again — that is how the group advances', () => {
+ const { S, bind, state } = makeHarness();
+ S.nativeSyncEvent = { id: 'a', domain: 'd', iso_timestamp: 't' };
+ bind(); bind();
+ S.nativeSyncEvent = { id: 'b', domain: 'd', iso_timestamp: 't' };
+ bind();
+ assert.deepEqual(state.attached, ['a', 'b']);
+});
+
+test('THE EARLY EVENT: an event with no video yet is kept, not dropped', () => {
+ // The leader announces as it advances, so the broadcast routinely beats the element into
+ // existence. Dropping it would leave that item unsynchronised for its whole duration.
+ const { S, bind, state } = makeHarness({ hasVideo: false });
+ S.nativeSyncEvent = { id: 'early', domain: 'd', iso_timestamp: 't' };
+ bind();
+ assert.equal(state.attached.length, 0, 'nothing to bind to yet');
+ assert.equal(S.nativeSyncBoundId, null, 'and it must NOT be marked bound');
+
+ S.currentVideoEl = { tagName: 'VIDEO' }; // the 4Hz tick calls bind again once it mounts
+ bind();
+ assert.deepEqual(state.attached, ['early'], 'the retained event binds as soon as the video exists');
+});
+
+test('an image or widget item never binds — there is no setSyncParams to bind', () => {
+ const { S, bind, state } = makeHarness({ hasVideo: false });
+ S.nativeSyncEvent = { id: 'img', domain: 'd', iso_timestamp: 't' };
+ bind(); bind();
+ assert.equal(state.attached.length, 0);
+});
+
+test('the leader mints a DISTINCT id per advance, or the group sticks on one item', () => {
+ // A repeated id is swallowed by every follower's dedupe. The symptom is "sync is stuck", which
+ // points nowhere near the id.
+ const { sync } = makeHarness({ isLeader: true });
+ const a = sync.announce('content-1', 1000);
+ const b = sync.announce('content-2', 2000);
+ const c = sync.announce('content-1', 3000); // same item coming round again on loop
+ assert.notEqual(a, b);
+ assert.notEqual(a, c, 'the same item on a second lap is still a NEW session');
+});
+
+test('the player still defines the pieces this wiring depends on', () => {
+ // A rename in index.html that silently broke native sync would otherwise only show up on
+ // hardware, which we have one of.
+ for (const fn of ['applyNativeSync', 'bindNativeSyncVideo', 'applyGroupSync', 'groupScheduleTick']) {
+ assert.ok(HTML.includes('function ' + fn + '('), 'player must still define ' + fn);
+ }
+ assert.match(HTML, /nativeSync\.announce\(/, 'the leader must still announce on advance');
+ assert.match(HTML, /backend === 'brightsign'/, 'native sync must still be gated on the resolved backend');
+});
+
+test('THE PROMOTED LEADER: leadership moving must re-enter sync, not be ignored', () => {
+ // Neither switching protocol nor a leader change alters the group id. When the re-enter key was
+ // the id ALONE, a player promoted to leader after the old one went offline carried on behaving as
+ // a follower — so nobody announced, the whole group sat unsynchronised, and the dashboard showed
+ // a healthy group throughout.
+ const m = HTML.match(/const groupKey = \(g\) => (.*);/);
+ assert.ok(m, 'player no longer defines groupKey');
+ const groupKey = new Function('g', 'return ' + m[1] + ';');
+
+ const base = { group_id: 'g1', backend: 'brightsign', is_leader: false };
+ assert.notEqual(groupKey(base), groupKey({ ...base, is_leader: true }), 'promotion must re-enter');
+ assert.notEqual(groupKey(base), groupKey({ ...base, backend: 'screentinker' }), 'protocol switch must re-enter');
+ assert.equal(groupKey(base), groupKey({ ...base }), 'an unchanged group must NOT churn on every push');
+ assert.equal(groupKey(null), '');
+});
diff --git a/server/ws/deviceSocket.js b/server/ws/deviceSocket.js
index 16006cd..a314387 100644
--- a/server/ws/deviceSocket.js
+++ b/server/ws/deviceSocket.js
@@ -16,6 +16,7 @@ const { protectSocket } = require('../lib/safe-socket');
const flapLimiter = require('../lib/flap-limiter');
const sessionSettle = require('../lib/session-settle'); // #148 patch2: eviction-storm debounce
const { resolveIdentity } = require('../lib/device-identity');
+const { resolveSyncBackend } = require('../lib/sync-backend');
const logCoalescer = require('../lib/log-coalescer');
const loopLag = require('../services/loop-lag');
const deviceSettings = require('../lib/device-settings'); // #150 delete+re-pair settings restore
@@ -144,10 +145,12 @@ function logDeviceStatus(deviceId, status, reason, detail) {
// groups). Sync-eligible members = the group's members whose playlist MATCHES the group's shared
// playlist. A member on a different playlist is ignored (never synced) — index sync would be
// meaningless. Ordered by id for a stable auto-election.
+// platform + ip_address are selected for the sync-backend resolver, not for election: it decides
+// native-vs-ours from what the members ARE (BrightSign?) and where they are (one L2 network?).
function groupSyncMembers(group) {
if (!group || !group.playlist_id) return [];
return db.prepare(`
- SELECT d.id, d.status FROM devices d
+ SELECT d.id, d.status, d.platform, d.ip_address FROM devices d
JOIN device_group_members dgm ON dgm.device_id = d.id
WHERE dgm.group_id = ? AND d.playlist_id = ? ORDER BY d.id
`).all(group.id, group.playlist_id);
@@ -170,7 +173,7 @@ function resolveGroupLeader(group) {
function deviceSyncGroup(deviceId, devicePlaylistId) {
if (!devicePlaylistId) return null;
return db.prepare(`
- SELECT g.id, g.sync_enabled, g.playlist_id, g.leader_device_id
+ SELECT g.id, g.sync_enabled, g.playlist_id, g.leader_device_id, g.sync_backend
FROM device_groups g JOIN device_group_members dgm ON dgm.group_id = g.id
WHERE dgm.device_id = ? AND g.sync_enabled = 1 AND g.playlist_id = ?
ORDER BY g.name ASC, g.id ASC LIMIT 1
@@ -181,9 +184,38 @@ function deviceSyncGroup(deviceId, devicePlaylistId) {
function resolveGroupSync(device, deviceId) {
const group = deviceSyncGroup(deviceId, device?.playlist_id);
if (!group) return null;
+ const members = groupSyncMembers(group);
const leaderId = resolveGroupLeader(group);
if (!leaderId) return null;
- return { group_id: group.id, is_leader: leaderId === deviceId };
+
+ // Which protocol this group actually runs. The decision lives in one pure function so the
+ // dashboard, the tests and this payload can never disagree about it — an operator being told
+ // "native sync" while the players ran ours would be undebuggable.
+ const decision = resolveSyncBackend(group.sync_backend, members);
+
+ // Native sync is leader/follower and the leader broadcasts; ours is leaderless. A group whose
+ // elected leader is OFFLINE would therefore sit unsynchronised on the native protocol — nobody
+ // is announcing — where our own clock-derived sync carries on regardless. So fall back rather
+ // than leave a wall frozen on whatever it happened to be showing.
+ const leaderOnline = members.some(m => m.id === leaderId && m.status === 'online');
+ let backend = decision.backend;
+ let reason = decision.reason;
+ let downgraded = decision.downgraded;
+ if (backend === 'brightsign' && !leaderOnline) {
+ backend = 'screentinker';
+ reason = 'the group leader is offline — native sync has nobody to broadcast';
+ downgraded = true;
+ }
+
+ return {
+ group_id: group.id,
+ is_leader: leaderId === deviceId,
+ backend,
+ // Carried to the player for logging, and to the dashboard so an operator can see WHY a
+ // requested backend was refused instead of guessing.
+ sync_reason: reason,
+ sync_downgraded: downgraded,
+ };
}
// A widget's CONTENT is always live — /api/widgets/:id/render reads the current config — but the
@@ -1476,3 +1508,8 @@ module.exports.__resetTimers = () => {
pendingOfflines.clear();
evictedSockets.clear();
};
+
+// Test seam: which protocol a group runs, and whether a request was refused, is the one piece of
+// branching in this file with nothing to do with sockets. Exposing it lets that decision be tested
+// against a real database without standing up a socket server.
+module.exports.__test = { resolveGroupSync, resolveGroupLeader, groupSyncMembers };