From 5a7277523a70ed46ce3a818f8175cced9a18faf8 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Wed, 5 Aug 2026 10:06:18 -0500 Subject: [PATCH] Wire BrightSign native sync end to end, chosen per group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit st-sync.js wrapped SyncManager but nothing drove it. The player now does: the leader opens a new sync session on each advance, and every member — the leader included — binds the video with attachVideo() on a NEW id only. The leader binds from its own broadcast rather than at announce() time on purpose. Starting when it announces would put it ahead of its followers by the width of the network, which is the one desync nobody would think to look for because the leader always looks correct. Item selection stays clock-derived under both backends. Native sync replaces only the seek/nudge drift correction, because setSyncParams has the element hold its own alignment and correcting it ourselves would fight the platform — every frame we moved is one it then has to undo. Keeping selection on the shared clock is also what keeps images and widgets, which have no setSyncParams, advancing with the videos instead of drifting off alone. LEADER RULE: reuse the existing election (resolveGroupLeader) rather than adding a column. It already resolves pinned-if-online, else first online member on the shared playlist, else first by id — deterministic, stable, and already what the group-sync payload reports. A second mechanism could only disagree with it. Added on top: a group whose elected leader is OFFLINE falls back to our protocol. Ours is leaderless and carries on; native sync has exactly one broadcaster, so those members would sit waiting for an announcement that never comes, with the dashboard showing a healthy group throughout. device_groups.sync_backend ('auto'|'screentinker'|'brightsign') is the operator's REQUEST; the answer comes from the existing pure resolveSyncBackend() so the players, the dashboard and the stored setting cannot disagree. The resolved backend, reason and downgraded flag ride in the group_sync payload and in the group API, and the dashboard shows the refusal reason instead of a setting that quietly isn't in force. An unrecognised value is rejected rather than stored, because the resolver reads anything unknown as 'auto' — a typo would otherwise return 200 and run a different protocol than the UI displayed. FIXED WHILE HERE: the player re-entered group sync only when the group ID changed, with a comment noting the clock protocol has no leader role. Native sync has one, and neither a protocol switch nor leadership moving alters the group id — so a player promoted to leader kept behaving as a follower, nobody announced, and the group sat unsynchronised. The re-enter key now includes the backend and the leader flag. 971 pass (+17). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL --- brightsign/README.md | 39 ++++- frontend/js/i18n/en.js | 6 + frontend/js/views/dashboard.js | 33 ++++ server/db/database.js | 6 + server/player/index.html | 89 ++++++++++- server/routes/device-groups.js | 36 ++++- server/test/group-sync-backend-api.test.js | 96 ++++++++++++ .../group-sync-backend-resolution.test.js | 132 ++++++++++++++++ server/test/group-sync-native-wiring.test.js | 148 ++++++++++++++++++ server/ws/deviceSocket.js | 43 ++++- 10 files changed, 610 insertions(+), 18 deletions(-) create mode 100644 server/test/group-sync-backend-api.test.js create mode 100644 server/test/group-sync-backend-resolution.test.js create mode 100644 server/test/group-sync-native-wiring.test.js 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 ? ` + + ${group.sync_effective ? ` + ${group.sync_downgraded ? '⚠ ' : ''}${esc(group.sync_effective)}${group.sync_reason ? ' — ' + esc(group.sync_reason) : ''}` : ''} ` : ''} ` : ''} @@ -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