Commit graph

221 commits

Author SHA1 Message Date
ScreenTinker 3d9ef039e5 Make the volume slider real, and stop an empty playlist wiping the cache
Three faults in the web player, each found by driving the shipped code in a
browser against the real server rather than by reading it.

set_volume did nothing at all. The dashboard sends `{ level: 0..1 }`
(device-detail.js: slider/100) and the Android player reads exactly that; this
player read `payload.value` and divided it by 100. Nothing in the product sends
`value`, so every browser panel acked the command and ignored it — the quietest
possible failure. Correcting only the key would have been worse than leaving it
broken: `level: 0.5` would have become 0.5%, which is inaudible and looks fixed.
The fraction is now canonical, `value` is still read as a percentage for
anything written against the old handler, and the scale is chosen by WHICH KEY
arrived rather than by the size of the number — 1 is legal in both conventions,
so a magnitude guess is guaranteed to be wrong for somebody. Parsing moved into
volumeLevelFromCommand() so it can be asserted without a socket.

setMediaVolume() also wrote `el.muted = (v === 0)`, so any non-zero volume
un-muted whatever was playing. An item an operator had deliberately silenced
started making noise the moment anyone touched the slider — reproduced live:
item flagged muted, one set_volume, muted went false. Mute has four inputs and
a fixed order (lib/media-mute.js), it is resolved when the element is mounted,
and a level is not entitled to overrule it — least of all the autoplay rule,
where unmuting without a gesture costs the video rather than winning the audio.
Volume 0 is silence on its own.

And the service worker pruned its content cache to an EMPTY keep-set.
`assignments: []` is what the server sends for a device between playlists, for a
playlist never published, and inside the `catch` when a published_snapshot fails
to parse — none of which mean "delete the media". Reproduced: three cached
assets, one empty payload, cache emptied. That is only survivable while the
uplink is up, which is precisely when the offline cache is worthless. A cache
kept too long costs disk the quota reclaims anyway.

Verified in Chrome against a live server: volume 0.42/0.8/0/0.25 land on the
element and survive an item change, a muted item stays muted through a volume
command, and three cached assets survive an empty push.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 16:18:25 -05:00
ScreenTinker 2237edab12 Merge #236/#235: portrait video walls, and a wall status view
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
2026-08-06 09:52:20 -05:00
ScreenTinker 4d86a75196 Merge #239: let the playlist preview skip to any item
# Conflicts:
#	frontend/js/views/playlists.js
2026-08-06 09:52:20 -05:00
ScreenTinker 97f53a5b72 Merge #238: preview a rotated display the way the wall shows it 2026-08-06 09:50:27 -05:00
Claude e4c25c39df Describe a portrait video wall as portrait, and stop a wall hiding its screens
#236: the wall canvas was secretly framebuffer space rather than the wall as
the audience sees it. Invisible while every panel is the normal way up, and
actively misleading the moment one isn't — two portrait-mounted panels standing
side by side had to be STACKED VERTICALLY in the editor, with a pre-rotated copy
of every video, before the output came out right. It worked, but only after
trial and error, and it meant a portrait wall could never reuse content as-is.

Each panel now carries a mounting rotation (0/90/180/270 clockwise, the same
convention as the per-device orientation setting), the canvas means the physical
wall, and the player works out the mapping. The geometry lives in one place,
server/lib/wall-geometry.js, because four players have to agree on it to the
pixel across a seam.

Existing walls need no migration and do not move. Every wall in the field is
rotation 0, and that case takes the original expression verbatim on all three
players rather than the algebraically-equal centre-based one — the two differ in
the last float bit, and a float's worth of disagreement between two panels is a
hairline seam down a wall that was aligned yesterday. Pinned by the first test
in wall-geometry.test.js and by wall-payload.test.js.

While a display is in a wall its panel rotation replaces its own orientation:
both describe the same physical fact, so honouring both turned the content twice.

#235: a wall replaced its members' cards, so one dead panel of a four-panel wall
was invisible from the dashboard, and inspecting a single screen meant pulling it
out of the live wall and putting it back. The wall screen now lists its panels
with live online state, a per-panel screenshot request, and a link to each
device's page; the dashboard wall card carries per-member status chips that track
socket updates.

Tests: wall-geometry.test.js re-simulates the CSS box independently and asserts
each panel's viewport maps onto exactly its own rect of wall space, for every
rotation, plus a mixed wall and the Tizen player's hand-ported copy executed
against the canonical rule. Full server suite green (1260).

Not verified here: the Android and Tizen renders on real hardware. Kotlin
compiles clean; the maths is shared/tested, the view plumbing is not.
2026-08-06 09:46:31 -05:00
ScreenTinker 52ab04204a Preview a rotated display the way people see it, not the way its framebuffer is
#238: the dashboard preview of a 90/270 display was sideways while the panel on the
wall was right — the split that makes a preview useless, because a designer checking
portrait content can no longer tell a real fault from an artefact of the tool.

A portrait panel is a landscape framebuffer that the player rotates content INSIDE
(+90), hung turned the other way (-90); the two cancel and the viewer sees upright
portrait. The dashboard modelled only the first half. It iframed the player into a
box it had already given the finished 9/16 shape, so the player rotated a second time
inside a box that was pretending to be the finished picture, and nothing anywhere
stood in for the mount. Screenshots had the opposite half missing: they are the raw
framebuffer, shown untouched, so every portrait screen looked wrong on the cards and
in Now Playing too.

So each surface now has a stage (the panel's face) and a frame (its framebuffer),
with the frame turned by the INVERSE of the player's angle. Turning it the same way
is the tempting mistake and the worst kind of wrong: 90+90 lands upside-down, which
reads as nearly-right. The dimension swap is not cosmetic either — composing into the
real framebuffer shape is what makes the player lay content out in the same portrait
box the panel uses; hand it a portrait viewport instead and every zone and object-fit
decision is computed for a canvas no panel has.

The geometry is the players' own rule (server/lib/orientation-style.js), served to the
dashboard rather than re-derived, since a second copy of a rotation rule is exactly how
the two came to disagree. Covers the device preview modal, the playlist preview's
portrait toggle (same fault), Now Playing and the device cards. The Remote canvas stays
raw on purpose: taps are sent as fractions of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 09:38:31 -05:00
ScreenTinker 6471c503ab Default a video playlist item to the clip's own length (#237)
Adding a 32s video gave it the flat 10s default, so it was cut off mid-play
unless the operator looked up the runtime and typed it — per item, every time.
The content row already carries the probed duration; it now becomes the default.

The rule lives in one place (lib/item-duration.js) because the operator sees one
product, not six insert paths: playlist add, assign-to-display, group assign,
agency portal, content-only schedule, and the public API all share it. Only the
playlist route defaulted before, and it stored the raw probe (31.7) which the
Android player's optInt read silently truncated back to 31.

Explicit values always win. Content with no trustworthy duration (image, widget,
YouTube, remote URL, failed probe) keeps the 10s default, and a duration that is
0/negative/NaN or absurd (> 12h, i.e. a broken probe) falls back rather than
reaching a device — a 0 makes the players schedule a 0ms advance, which self-loops
and black-screens the TV.

Dashboard: the add-item picker shows a clip's length, the assign-to-display modal
pre-fills the duration field from the selected clip (never overwriting a value the
operator typed), and onboarding stops hardcoding 10 on the first assignment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 09:36:53 -05:00
ScreenTinker aa77332c0d Let the playlist preview skip, so reviewing item 8 does not cost seven durations
The preview shipped without the skip control #104 asked for, so checking a late item meant
watching every item before it in real time — the thing operators do most when ordering a
playlist with a client on the phone.

The preview is already the real player in device-free mode (an iframe of /player?preview=1),
so this drives that instance rather than growing a second playback implementation: the
dashboard posts next/prev to the one contentWindow, the player steps its own currentIndex and
re-renders through the same path a natural advance uses, and posts back index/total so the
modal can say "3 of 7".

Nothing here can reach a live screen. A real display is driven over its server socket and holds
no window handle this page could address; the message listener is installed only by the preview
boot path, previewNavigate refuses outside PREVIEW_MODE, and both ends pin the origin.

Stepping is schedule-aware in the direction of travel — falling forward past a dayparted item
would make "previous" walk forwards — and a multi-zone playlist reports itself as such, because
all zones play at once and a counter there would be a lie.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 09:35:16 -05:00
ScreenTinker c2240288a7 Serve the service worker from the root, so its scope needs no header to survive
Found deploying 1.9.29 to production. A worker's scope defaults to its own
directory, so /player/sw.js could only control /player/ and below; the fix was to
request a wider scope and permit it with Service-Worker-Allowed. That works right
up until something between the origin and the browser does not pass the header
on. Cloudflare served a CACHED response for that path across the deploy —
headers and all — and the registration failed outright.

A rejected registration is worse than a narrow one: the player runs with no
worker at all, on every URL, and nothing about it is visible from the server. The
origin was sending the header correctly the whole time; a cache-busted request
proved it. It self-heals when the edge entry expires, which is precisely the kind
of fix nobody should have to know about.

Served from /, the default scope is already the whole origin and no header has to
survive the trip — through Cloudflare, through whatever a self-hoster puts in
front of it, or through a corporate proxy we will never see. /player/sw.js keeps
serving for players still asking for it, and the header is still sent where it
does survive.

Verified in a real browser: all three of /player, /player/ and /player/index.html
are controlled from root scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 09:05:06 -05:00
ScreenTinker db8846a139 BrightSign: report what the host knows, through the channels the other players use
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
A BrightSign could see things the page cannot ask for — the uptime, the wired IP,
the video mode in force, which volume it booted from, whether a staged package
applied — and it printed all of it to a serial console. On a panel on a wall that
is the same as reporting nothing.

The cost was concrete and recent. A single bad string literal stopped the host
script compiling; the only evidence anywhere was one line on a cable, and from
the server the display looked identical to one that had never started. Diagnosing
it needed someone physically present with a serial adapter. Every other player
reports its own failures.

Three hops, each thin: the host posts, the bridge carries, the player emits on
the channels it already uses (device:log, device:event, and the telemetry the
heartbeat has carried for releases).

The pre-widget phase is the part that matters and the part that was hardest to
reach — the storage probe, a pending package being applied, the video mode being
set, all happen before there is a page to talk to. Those lines accumulate in a
buffer and flush the moment the widget exists, so the boot story arrives even
though it happened before anyone could listen. BrightScript has no global store
here (no GetGlobalAA), so the buffer is threaded explicitly; losing the boot
entirely was the worse option.

Two things become incidents rather than console lines: the watchdog rebuilding a
wedged widget, which is the most important thing a player does unattended and
previously healed in silence — a panel rebuilding itself every two minutes looked
exactly like a healthy one — and a load-error, which now names the resource that
failed. Both use event types the server actually accepts; an invented one is
dropped silently and would have been just as invisible.

Host telemetry merges into the existing snapshot rather than opening a channel,
and the host's numbers win where they overlap: navigator.storage.estimate()
describes the widget's cache quota, not the disk, so a panel can report gigabytes
free while the volume holding them is full.

Two API traps caught in my own new code before it shipped, both the same shape as
the ones being fixed: Str() applied to a value already documented as a String
(it is for numbers, and would abort the event loop while reporting a diagnostic),
and Stri() handed a float from an inline division. The checker now pins the first.

Verified on the XT245: boots clean, plays, online. The bridge and player halves
are served BY the server, so they take effect on the next deploy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 00:02:33 -05:00
ScreenTinker c2effc9f5f BrightSign: use the platform's own file-existence idiom, and don't re-fetch a staged package
Both found while watching a real self-update run end to end on the XT245.

FileExists now uses roReadFile + type(), which is what BrightSign's own published
autozip.brs does (their CheckFile). MatchFiles is for LISTING a directory; as an
existence check it has already burned this codebase once, passing a full path as
both arguments so it could never return true for anything. Correcting it to a
directory plus a bare name did work — I misread a mid-cycle inspection as a
second failure and it was not — but roReadFile takes the full path every call
site naturally has, needs no reasoning about volume-root semantics, and is the
form the vendor ships. The narrower idiom is worth having here precisely because
nothing in CI can tell us when this is wrong.

CheckPackageUpdate now returns early when a package is already staged. Observed
on hardware: the periodic check fired in the gap between staging an archive and
the reboot that applies it, and pulled the whole thing down a second time.
Harmless on a desk; on a metered or marginal link it is exactly the waste the
rest of this release exists to remove.

The self-update chain is now proven on hardware, twice: check, download, sha256
and size verify, stage, reboot, staged unpack, move into place without touching
screentinker.json, mark done, reboot into it. The player reports 1.9.29-rc5 and
its autorun.brs carries the archive's timestamp rather than a hand-copied one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 23:22:09 -05:00
ScreenTinker 647a2bcc56 BrightSign: replace the Roku APIs, and a literal that stopped the script loading
The host scripts were written against the wrong reference. BrightScript is
Roku's language, the two API references read almost identically, and nothing
here can run either — so a call to an object that does not exist looked exactly
like a call to one that does. Verified on an XT245 and against BrightSign's
published reference; every item below was confirmed, not guessed.

THE ONE THAT COST A BOOT. `body$ = "{""width"":"` is not an escaped quote —
BrightScript has no escape sequences, so that is three adjacent literals with no
operator, and the compiler rejects the WHOLE FILE:

    ScriptLoadError: Syntax Error. (compile error &h02) in SSD:/autorun.brs(196)

Not a broken feature — no player at all, on a display showing nothing. Built
with Chr(34) now.

THE ONE IN THE FIELD. MatchFiles takes a DIRECTORY plus a pattern and returns
nothing when the pattern contains a separator; we passed a full path as both
arguments. FileExists() could never return true, for any file, on any player.
That is exactly what a consultant hit: "[st-autozip] no autorun.zip on any
volume" printed while `dir SD:` listed autorun.zip. It also silently disabled
the entire self-update path. (Related: `autorun.zip_invalid` on his card is not
an accusation — it is the rename BrightSign's own example performs AFTER a
successful unpack. Our STORED-only insistence fixed a problem that was never
there; deflate32 is supported.)

Roku objects that do not exist here, each of which disabled a feature quietly:
roFileSystem (~20 sites — the update path could never mark a package applied),
roMessageDigest (verification returned false unconditionally and burned an
attempt counter), PostFromStringWithRetry (a snapshot request raised "member
function not found" from inside the event loop and took the player down).
Replaced with MoveFile/DeleteFile, roHashGenerator, and an async POST on a
message port, which is the only documented way to read a POST body.

Unpack() returns Void, so `if not package.Unpack(...)` was a type error dressed
as an error check; success is now proven by looking for the extracted file. And
Unpack() DELETES everything already in its target — unpacking an update to the
volume root would have erased the player's provisioning and its whole content
pool as a side effect of a routine upgrade. It stages to a directory of its own
and moves files into place, deliberately never overwriting screentinker.json.

Also: SetMode() takes one argument (rotation belongs to SetScreenModes, which
REBOOTS, so it only fires on a real change); GetStorageStatus is unreliable with
"USBn:"; a load-error names its resource in `uri`, not `url`.

server/test/brightscript-api-surface.test.js is the cheap thing that would have
caught all of it: a deny-list of Roku APIs plus the argument shapes and literal
forms that compile and then do nothing. It cannot prove the scripts are right;
it stops these specific mistakes coming back. It has already earned its keep —
it caught a comment I had broken while writing this change.

Verified on hardware: the player loads clean from the NVMe, restores its cached
playlist and plays BEFORE the server connects, fetches media with the new
?rev= revision, and registers against alpha rc4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 22:57:36 -05:00
ScreenTinker 0a888910dc Stop claiming offline cache on a runtime that refuses to run a service worker
Found on alpha after deploying rc4, by comparing what a device advertised
against what it actually requested.

A real BrightSign XT245 has navigator.serviceWorker, passes an
`'serviceWorker' in navigator` check, and then never even fetches sw.js — its
widget runtime refuses the registration. It was declaring offline.cache to the
fleet while unable to cache a single byte, which is precisely the lie the
capability model exists to prevent. The claim is now made on a worker that is
actually IN CONTROL, and a refused registration sets a flag so the negative
sticks on a runtime where it will never succeed.

That failure previously went to console.warn, on a display nobody has a console
for, so a panel that could cache nothing looked identical to one that could. It
now reports app_error/sw_unavailable — as an allow-listed event type, since an
unknown one is dropped by the server and would have been just as invisible.

The cost is that the first load under-reports, before the worker claims the
page. That is the right direction to be wrong in, and it self-corrects: the next
register sends the true set.

Also corrects docs/player-parity.md, which claimed BrightSign simply inherits
the web player's service worker. The failing unit runs BSN's Supervisor rather
than our brightsign/autorun.brs, and Supervisor's widget has no storage_path —
the setting our own host script does configure and the precondition for a widget
having persistent storage. So this is likely a widget config issue rather than a
platform limit, but it is UNVERIFIED on hardware and the doc now says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 19:34:52 -05:00
ScreenTinker 75c1940821 Fix the worker scope that made web offline playback silently inert, and prune superseded assets
Found by QA against a real browser, not by any test in the suite: the bug lived
entirely in the relationship between a URL and a header.

A service worker's default scope is its own directory, so /player/sw.js could
only ever control /player/ and below — which does not include /player itself.
The player is served at all three of /player, /player/ and /player/index.html,
and /player is the one that gets used: it is what the dashboard shows and what
gets typed into a panel. On that URL registration SUCCEEDED, logged "Service
Worker registered", and then controlled nothing. No shell cache, no content
cache, no offline playback, no error. Every web and BrightSign panel served at
/player has been running with its offline story switched off.

Registration now asks for scope '/' and the server sends Service-Worker-Allowed
to permit it. Both halves are required — without the header the registration
does not narrow, it fails outright.

Also: revision-keyed sweeping could not reclaim a replaced asset's predecessor.
A replace writes a NEW randomly-named file, so the superseded copy lives at a
different path entirely and nothing keyed on the asset path can find it; it
would sit there until the quota evicted it. The player now declares the complete
set of media it needs — the raw assignments, so multi-zone items are included
and a prune cannot delete something a zone is still playing — and the worker
drops everything else.

QA results this pass: web player 18/18 against a real browser (cold start with
no network renders a cached video at readyState 4); Android 12/12 on a device
including a replace round-trip that re-fetched 6MB and then dropped it for the
new bytes, and a cold start with the server stopped that played from disk;
Tizen 11/11 for the no-storage path, which must degrade to streaming and must
not claim a capability it cannot honour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 16:30:12 -05:00
ScreenTinker 684e60fc55 Offline media on every player, and a revision so the cache can still be updated
Two halves of the same problem. A screen has to keep playing when the link is
gone, and it must not keep playing the wrong thing once the link is back.

CACHING FOR OFFLINE, on the players that could not:

- Tizen cached nothing but the playlist, so a panel came back from a reboot
  knowing exactly what to show and fetched every frame of it from a server that
  was not there. tizen/js/media-cache.js caches the media itself to wgt-private
  (the store Tizen documents as surviving reboots), resumable via Range and
  If-Range, with the transfer async so a stalled chunk cannot freeze the player.
  offline.cache moves from "absent" to a runtime claim: a build with no writable
  private storage still says nothing.

- The web player's worker stored only what a single fetch() happened to
  complete, which on a marginal link is nothing at all — a 200MB asset never
  finishes in one go and every retry starts from zero. It now accumulates in
  resumable chunks, driven by the player's playlist rather than by playback, so
  the prefetch is not competing with the video that is currently on screen for
  the same scarce bandwidth. BrightSign inherits this.

STILL UPDATING, which caching quietly breaks:

PUT /api/content/:id/replace changes an asset's bytes under a stable id. Every
cache keys on that id, so before this the new bytes could not reach a panel that
already held the old ones — not until the next refresh, but never. Content now
carries a revision, stamped onto each item at send time like widget revs, and
every player keys its cache on it. The same send-time refresh fixes a second
bug: a replace writes a new randomly-named file and unlinks the old one, so the
filepath in a published snapshot pointed at a deleted file and web panels 404'd
on the item until somebody republished the playlist. The route now also pushes
to affected devices, which it never did.

Bytes are kept only where they can be built upon: no validator means no safe
resume, so the partial is discarded and the attempt backs off as the failure it
is rather than re-fetching the same prefix forever.

Server needed no new transfer support — res.sendFile already does Range,
If-Range and 416. The Tizen cache and the service worker are both driven in Node
against fakes, because neither can be exercised without hardware and "the chunks
assemble correctly" is not something to discover from a panel showing a corrupt
video.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 15:27:36 -05:00
ScreenTinker cf3b2e62af Resume interrupted content downloads instead of restarting from zero
A site on a marginal link (the report came from a one-bar 5G install) could
never fill its cache. Every attempt started at byte 0 and the .part was deleted
on any interruption, so an asset larger than one call's worth of transfer was
discarded and re-fetched forever — five minutes of progress thrown away, back
off, five more minutes, thrown away. With nothing cached, the player showed the
waiting state, which is what got reported as "the screens go black instead of
playing cached content". The offline playback path was never the problem; the
cache simply could not be filled.

An interrupted download now keeps its .part and the next attempt asks for the
rest with Range. Two ways that could corrupt the cache, both closed: If-Range
with a stored validator makes a changed asset come back as a full 200 (restart)
rather than a spliceable tail, and a partial longer than the asset gets a 416
and is discarded. Bytes are kept only when they can be built upon — with no
validator there is no safe resume, so the partial is dropped and the attempt
backs off as the failure it is, rather than re-fetching the same prefix forever.

DownloadCoordinator now distinguishes progress from failure: attempts chain
while bytes are landing (bounded, single-flight held throughout) and only a
no-progress attempt escalates the exponential backoff or acks "failed" — an
advancing download is not a failed one and should not be shown as such.

Server side is unchanged; res.sendFile already serves Range/If-Range, and
content-range-resume.test.js pins that since it is load-bearing and a future
middleware could silently remove it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 14:54:09 -05:00
ScreenTinker 3e6c97ba10 Merge: web player capability declaration and the cross-player parity matrix
# Conflicts:
#	server/ws/deviceSocket.js
2026-08-05 14:36:28 -05:00
ScreenTinker 4153448c55 Merge: capability persistence, dashboard gating, server-side refusal 2026-08-05 14:29:09 -05:00
ScreenTinker 6310f48590 Merge: platform-native capability declaration 2026-08-05 14:25:46 -05:00
ScreenTinker 0082191f9b Show only the controls a display can actually honour
Every device control was offered to every display. A browser tab was shown
"Reboot device", a Tizen TV was shown screen power, a player with no
framebuffer read was shown a live view that stayed black. They all looked
like working buttons and did nothing — the "reports success and changes
nothing" shape that keeps costing people days.

Players now declare what they can do at registration, because only the
player knows at runtime: an Android panel gains real screenshots when
accessibility is switched on and loses Tier-2 when device owner is revoked.
The dashboard hides what is not supported rather than disabling it, and the
Info tab lists the capability set so a missing control is explainable.

The declaration is three-state and the middle state is load bearing: NULL
means "has never told us anything" and falls back to a per-platform
baseline, because several hundred displays in the field will not update
before this deploys and blanking their controls would be a far worse bug.
An empty array means "I genuinely can do nothing" and is honoured.

Hiding a button is not enforcement, so unsupported commands are also
refused server-side — the socket is reachable directly and a stale tab
still renders the old controls. Group sends report skipped devices
separately from sent ones; counting an unreachable member as "sent" is how
an operator walks away believing the whole group rebooted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 14:24:52 -05:00
ScreenTinker e5583e529e The Tizen baseline describes a fielded panel, not the one we are shipping
Two more corrections from the cross-player audit, both mine.

audio.volume removed: a fielded Tizen panel has NO set_volume handler — the
command falls through to "unknown command" and the dashboard slider does
nothing. One of the platform branches adds a handler, and those panels will
declare the capability for themselves once they run it; the baseline exists to
describe an un-updated display, so it must not borrow credit from a build that
has not shipped.

remote.screenshot and remote.stream added: both really are implemented in the
shipped player (captureAndSend, startStreaming). Omitting them would have hidden
working controls on every legacy Tizen display the moment gating went live —
the opposite failure, and the more damaging one.

That asymmetry is the thing to hold on to: over-claiming shows a dead button,
under-claiming removes a working one, and only reading the shipped code tells
you which you are doing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 14:21:19 -05:00
ScreenTinker c4ee7d008f web player: declare capabilities at runtime, persist them, and audit all four players
The dashboard offered every control to every display, so a browser tab showed a
reboot button that could never work. server/lib/player-capabilities.js defines the
vocabulary; this makes the web player actually speak it.

The declaration is computed, not constant, because the same index.html is BOTH the
browser player and the BrightSign player. system.reboot / display.power /
display.resolution / system.self_update are claimed only when BS.hasHost() answers —
deliberately hasHost() and not isBrightSign(), since the UA check is also true for a
widget built without node integration, which can reach none of them. Screenshots,
offline cache, transitions and native sync are each probed the same way.

Capabilities were never persisted: the column and the handler did not exist, so a
declaration would have been sent and silently dropped. Added the migration and
applyCapabilities(). An ABSENT declaration leaves the column NULL so the baseline
still applies — several hundred fielded displays declare nothing and would otherwise
lose every control at once — while an EMPTY declaration is stored as '[]' and honoured.

docs/player-parity.md records every capability against all four players with a reason
for each "no", and flags three Tizen baseline errors found while verifying it.

Tests: 1109/1109. Both inline <script> blocks in index.html parse clean.
2026-08-05 14:17:40 -05:00
ScreenTinker a08e6c3e06 Tizen does not have offline caching — correct the baseline
The platform audit caught my own contract lying. I gave the Tizen baseline
offline.cache; Tizen caches only the playlist JSON (st_payload_cache, in
localStorage) and has no service worker and no media cache, so the bytes still
come from the network and an outage leaves a panel holding a playlist it cannot
play.

That is exactly the claim this model exists to prevent, made by the model itself,
and it would have applied to every legacy Tizen panel — the ones that declare
nothing and depend entirely on the baseline being honest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 14:15:34 -05:00
ScreenTinker 5fe55307ba brightsign: declare capabilities from real hardware state
The dashboard offered every control to every display. This makes the
BrightSign player answer for itself, at runtime, rather than from a
per-platform table.

The table cannot work here: the same XT245 supports remote screenshots
with an SSD fitted and not without, because the DWS snapshot endpoint
writes the full-size capture to disk before returning a thumbnail and
answers "No primary storage found" on a flash-booted unit. So the bridge
asks the host.

- autorun.brs gains StorageProbe()/SendProbeResult(): walks SSD:, SD:,
  USB1: via roStorageHotplug.GetStorageStatus().mounted and reads real
  capacity through roStorageInfo. FLASH: is excluded deliberately — it is
  where the player boots from, not a volume the DWS accepts. Neither API
  has a JS equivalent, which is why this has to cross the bridge.

- st-bridge.js posts the probe during boot and folds the answer into the
  existing readiness gate, with its own 3s timeout so a widget built
  without nodejs_enabled still becomes ready. computeCapabilities() then
  gates remote.screenshot/remote.stream/system.self_update on a mounted
  volume, the lifecycle and display commands on a live host, sync.native
  on the module AND OS >= 8.2.10, and display.power on CEC module
  presence.

  Unknown is treated as NO throughout: an unanswered probe declares
  nothing storage-gated. A control that appears once a disk is fitted is
  a smaller problem than one that silently fails.

  Never declared: kiosk, brightness, screen_timeout, install_apk, shell
  (no BrightSign equivalent) and time (BrightScript can, this host does
  not implement it — the same lie in the other direction).

- Telemetry now reports the real drive from the probe instead of the
  widget's storage_quota, which it had been presenting as if it were the
  disk.

Two declarations are knowingly optimistic and documented as such:
transitions/pip composite DOM over a hardware plane and may be invisible
over video (the roVideoMode.SetGraphicsZOrder("front") fix wants a
hardware experiment, not a guess), and display.power rides module
presence on a unit whose kernel logs "failed to get cec clock". Neither
is load-bearing — transitions degrade to a hard cut, blanking works by
tearing the media down.

Tests cover the storage split, the hostless case, the sync floor, the
never-declared set, and that every declared string is in the server's
vocabulary — a typo there would silently disable a control fleet-wide.
2026-08-05 14:12:44 -05:00
ScreenTinker 6bc709d2f7 The capability contract: what each player can actually do
Foundation for platform-native parity. The dashboard offered every control to
every display — a browser tab cannot reboot its host, a Tizen TV has no
device-owner concept, a BrightSign has no per-window brightness — so those
buttons did nothing, silently, and read as bugs. "UI that reports success and
changes nothing" is a recurring shape here; this ends it by letting the frontend
hide what a display cannot do.

The player DECLARES its capabilities at registration rather than the server
inferring them from a table, because only the player knows at runtime: an Android
device gains real screenshots when accessibility is switched on and loses Tier-2
commands when it is not device owner.

The trap this had to avoid is the opposite failure. Several hundred displays are
in the field declaring nothing, and none will update before the next dashboard
deploy — treating absence as "supports nothing" would strip the UI for the entire
fleet at once. So an ABSENT declaration falls back to a per-platform baseline,
while an EMPTY one is honoured as a player genuinely saying it can do nothing.
Those two cases are trivial to conflate and the difference is a dark dashboard.

Baselines carry only what has always worked on that platform. Anything
conditional — screenshots needing accessibility, kiosk needing device owner,
native sync needing one L2 network — is omitted, so a legacy display shows those
controls only once it declares them. A control that appears late beats one that
lies now.

Capability names are stable strings because they are persisted per device and
sent over the wire; renaming one silently disables a control on every display
still reporting the old name. An unknown name from a NEWER player is dropped
rather than invalidating the whole declaration.

1094 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 13:59:04 -05:00
ScreenTinker d205a49dfa The settings PIN can be rotated and set from the dashboard
It was generated once at pairing and never changed. On a fleet that makes it a
shared secret with no expiry: anyone who watches it typed once — an installer, a
contractor, someone filming a screen — keeps it for the life of the panel, and
the only way to take it back was to unpair and re-pair every affected display. A
customer asked whether it rotates, which was the right question.

POST /api/devices/:id/settings-pin takes { rotate: true } or { pin: "123456" },
and pushes the result to the panel over its socket immediately. The live push is
the part that matters: without it a new PIN would only take effect at the next
pairing, so an operator revoking a leaked PIN would believe access was closed
while the old one still opened the menu. The response reports whether the panel
actually took it, so an offline display is stated rather than assumed.

Validation is the security-relevant half and is pure and tested: six digits,
digits only, and a blocklist of the PINs people actually pick (repeats and
sequences) refused on explicit set and never produced by the generator. A PIN
that can be set to "0000" or left empty is a gate that is not there.

Generation uses crypto.randomInt rather than Math.random — this is a credential,
and a rotation requested BECAUSE a PIN leaked must not be predictable from
anything else. Leading zeros are padded, or roughly one PIN in ten would be five
digits and rejected by the on-device prompt.

Android applies it live via device:settings-pin instead of only at pairing. The
PIN is never written to a log on either side, and it stays out of device list
responses as before.

1084 pass; Android compiles.

Asked for by chris@chris-pc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 13:44:15 -05:00
ScreenTinker 604c390a55 Portrait on the web player was 420px off-screen — rotating a box does not move it
Reported as "rotation doesn't work correctly". It is a geometry bug, not a
rendering one, which is why it reads as mysterious.

#playerContainer is pinned `inset: 0`. Rotation set width:100vh, height:100vw and
rotate(90deg) — leaving the box in the TOP-LEFT corner and spinning it about its
own centre rather than the viewport's. On a 1920x1080 panel the content landed at
x -420..1500, y 420..1500 against a viewport of 0..1920, 0..1080: correctly
rotated, wrongly placed, cropped on two edges.

Tizen already did this correctly — top/left 50% plus translate(-50%,-50%) — and
Android does the equivalent with translationX/Y of (w-h)/2. The web player was
the odd one out, and BrightSign inherited it on top of its own hardware-plane
problem.

The rule now lives in server/lib/orientation-style.js, served to the player from
its single source, with the arithmetic pinned by tests that compute where the
rotated box actually lands on 16:9 and 5:4 panels. Three things those tests hold
that are easy to get wrong: the translate must come BEFORE the rotate (transforms
apply right-to-left, so reversing them rotates the correction too), 180 must NOT
swap dimensions (the box already fits; swapping letterboxes it), and landscape
must clear EVERY property the rotated state set (a half-reset leaves the
container stuck at 100vh wide, so rotation appears to persist after switching
back).

1074 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 12:58:50 -05:00
ScreenTinker 1c7d5f1359 Rotation on BrightSign must rotate the output, not the DOM
Audited rotation across all four players after reports it misbehaves.

  Android    native rootView.rotation + layout swap — the ExoPlayer surface is
             inside the rotated view, so video turns with it.
  Tizen      CSS for graphics AND AVPlay hardware-plane rotation for video. The
             code says why: a CSS-rotated <video> "blacks out" on Tizen.
  Web        CSS transform. Correct — a browser composites video in the DOM.
  BrightSign CSS transform only, inherited from the web player. BROKEN: with hwz
             enabled the video decodes onto a hardware plane the DOM cannot
             transform, so the images and widgets rotate and the video does not.
             A portrait panel plays sideways video.

BrightSign is the platform that does not rotate correctly, and Tizen had already
found the same wall from the other side — any platform compositing video below
the DOM needs rotation done at the output.

roVideoMode takes a transform (normal/90/180/270) and rotating the screen rotates
EVERY layer, because it happens below the compositor rather than above it. The
player now asks the host first and, when the host succeeds, clears its own CSS
transform — otherwise the graphics rotate twice while the video rotates once.

The host reports success rather than assuming it: if it cannot rotate, the CSS
path stands, which turns most of the content instead of none of it, and the
promise resolves false rather than never settling. A portrait panel showing
landscape content with no clue why is the outcome worth avoiding.

1066 pass. The BrightScript needs hardware to verify; the decision path does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 12:49:26 -05:00
ScreenTinker df3a2879fa Remote screenshots use the framebuffer, and an opted-in tester can move forward
Two things reviewed against the hardware.

REMOTE CAPTURE. An in-page canvas cannot read the hardware plane, so a
screenshot from a BrightSign is a composite with the video missing. The player
now asks the HOST, which uses the unit's own Diagnostic Web Server to capture the
real framebuffer, video included.

It has to run in BrightScript rather than the page for two reasons: the DWS is
http on localhost while the player is served over https, so the page would be
blocked as mixed content; and BrightScript is subject to neither CORS nor
mixed-content rules. Credentials are the documented default — user "admin",
password = the unit serial — which the host reads directly.

It requires PRIMARY STORAGE: the endpoint writes the full-size capture to disk
before returning a thumbnail, so a unit with no card or SSD answers "No primary
storage found." That message is passed through verbatim rather than swallowed,
and the canvas path still runs as a fallback, so a player with no disk keeps
producing the partial screenshot it can rather than nothing at all. Verified
against the real unit: the endpoint is reachable and blocked solely on storage.

THE STUCK TESTER. An opted-in player on 1.9.29-rc1 was told "holding prerelease
of the same core" when offered rc3 — so it would never move forward through
rc1 -> rc2 -> rc3, which is the opposite of what opting in is for, and would have
stopped our own XT245 ever receiving the next candidate.

The hold rule exists to stop a test build being dragged BACK to its release. It
now applies only when the advertised version IS that release: a newer prerelease
of the same core is offered normally, the release still cannot claw a tester
back, a newer core still lands, and a player that never opted in is still refused
a prerelease.

Also verified end to end on alpha: the advertised sha256 matches the served bytes
exactly, size matches, and every member of the package is stored.

1063 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 12:33:34 -05:00
ScreenTinker 30a71c1319 autorun.zip must be STORED and opened with roBrightPackage
A BrightSign consultant ran our v1.9.29-rc2 autorun.zip through BSN.cloud's
automated deployment. The archive reached the player and then could not be
opened — reported as invalid. Two causes, both ours.

1. COMPRESSION. We built with default deflate. The player bootstrap extracts
   autozip.brs by itself before any script runs, and roBrightPackage supports a
   specific set of methods, of which "no compression" is the universally safe
   one. Both builders now store: scripts/build-autorun-zip.sh passes -0, and the
   server-side package builder used archiver level 9 — maximum deflate — so
   EVERY self-update package it produced would have failed the same way, silently
   and in the field.

2. THE UNPACK API. We used roUnzip; BrightSign's own tooling uses
   roBrightPackage. Converted in autozip.brs and in the self-update path.

This is the failure mode worth naming: a compressed package uploads, downloads
and deploys perfectly, then fails to open on the player. It reads as a broken
deployment rather than a broken zip, so it gets debugged everywhere except where
the bug is. Both builders now ASSERT the property rather than trusting the flag —
the build script walks `unzip -v` and refuses a compressed member, and a test
walks the local file headers of the server-built package checking method 0.
Verified by negative control: re-enabling compression fails the test.

Also adopted the shipped volume-discovery pattern in autozip.brs — probe
USB1:/SD:/SSD:/FLASH: for the archive instead of guessing two volumes. The unit
that drove this port boots from FLASH because its card interface is dead, and
extracting to a volume that does not exist fails silently.

1056 pass.

Reported by giyokun, who was right about both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 11:36:18 -05:00
ScreenTinker cf1124d687 Mute reaches YouTube items — it never did, and failed opposite ways per player
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Muting was implemented three times and agreed nowhere. A YouTube item is a
cross-origin iframe, so `el.muted` reaches nothing; only the IFrame API can
touch it. Both browser-family players got this wrong, in opposite directions:

  web    playerVars.mute was `userHasInteracted ? 0 : 1` — autoplay policy and
         NOTHING else. An item an operator muted in the admin console played
         WITH SOUND, a wall follower blared alongside its leader, and the
         real-time device:mute-changed toggle only ever touched `<video>`.
         onReady then unmuted unconditionally, and the click-to-unmute overlay
         appeared on deliberately-muted items and undid the operator's setting.

  tizen  the embed URL hardcoded `mute=1`, so YouTube there was PERMANENTLY
         silent: the per-item flag was never read and nothing could unmute it.
         device:mute-changed did nothing at all, because it dereferenced a
         <video> that is null for a YouTube item.

Android was already correct and is unchanged — it is the reference here.

The rule now lives once, in server/lib/media-mute.js, served to the web player
from its single source the same way schedule-eval.js is, and mirrored in Tizen
(which ships inside the .wgt and cannot import it). The ORDER is the substance:
a wall follower is always silent (one wall, one audio source) > autoplay policy,
which is a hard constraint rather than a preference because unmuted playback
without a gesture is refused outright and costs the VIDEO > a live operator
toggle, who is looking at the screen > the item's stored flag.

shouldOfferUnmute() exists so the prompt only appears when a gesture is the ONLY
thing in the way. Prompting on a muted item trains viewers to click a button
that undoes an operator's decision.

Tizen gains enablejsapi + a postMessage bridge so a live toggle flips the embed
without reloading it — reloading would restart the video from zero every time
someone touched the control.

11 new tests pinning each precedence step separately; 1055 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 10:50:59 -05:00
ScreenTinker 4ed7954f84 Drop the user-agent fallback — it could never fire
isBrightSignDevice() fell back to device.user_agent to catch panels paired
before this port existed, which registered as "Chrome 120" with a BrightSign
user agent. `devices` has no user_agent column, so the field is always undefined
on a row read from the database. The branch was unreachable in production and
passed only in a test that fabricated the field — which is precisely how dead
code survives review.

Two agents flagged it independently while working on unrelated areas, and the
schema confirms it: zero matches for user_agent in the devices table.

Those pre-port panels are recognised the moment they re-register on a build
carrying the host, which every one of them gets on its next update. Identifying
them sooner would mean persisting the user agent, and a column added solely to
track a population that disappears on its own is not worth carrying.

The test now asserts the honest behaviour: a fabricated user_agent does NOT
create a match, and a group containing such a panel reads as mixed until it
re-registers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 10:30:10 -05:00
ScreenTinker 039511b988 Merge: prove screenshot pixels arrived instead of assuming the draw worked 2026-08-05 10:23:42 -05:00
ScreenTinker 9c04e2c113 Merge: BrightSign offline content caching and package self-update 2026-08-05 10:20:50 -05:00
ScreenTinker 16b3dd949c Merge: BrightSign real telemetry and hardware identity 2026-08-05 10:18:06 -05:00
ScreenTinker 8fd6eb75d5 BrightSign: cache content for offline, and let the package update itself
Two gaps that both end the same way — a panel nobody can fix without a van.

OFFLINE. Content bytes were never persistently cached. The service worker
skipped /uploads/content/ and leaned on the browser's HTTP cache, which is
reasonable on a desktop and is not a documented-persistent store here:
BrightSign guarantees survival across reboots for IndexedDB, localStorage and
SQLite, and their own answer for offline video is to cache the bytes explicitly.
A panel could come back from a power cut with its playlist intact — that lives in
localStorage — and no media to play it with.

The reason content was skipped is real, and player-cache-policy.js is what makes
intercepting it safe. Seeking video issues range requests, and naive caching is
worse than none: storing a 206 as the whole file means every later full request
gets a fragment, and answering a range request with a 200 makes some media stacks
fail outright. So only complete 200s are stored, and ranges are served by slicing
the stored body into a correct 206. The content cache survives shell
re-versioning, or every deploy would re-download the playlist over a link that may
be exactly what is broken.

SELF-UPDATE. The package can replace autorun.brs, so a truncated file is a dark
panel with no app underneath. The safety is the ordering: download to .part,
verify sha256 AND size, then delete the .done marker, rename, reboot. Marker
first is not stylistic — leaving it makes the next boot skip the archive and the
update silently never happens. A failed extract parks the zip as .bad instead of
retrying every boot, which would be a loop indistinguishable from a hardware
fault. sha256 because that is what roMessageDigest can compute; a checksum the
player cannot verify is an unverifiable package.

The decision lives on the server and is unit-tested, and the host only executes
it — re-implementing the version comparison in BrightScript would put the
prerelease trap somewhere untestable. That trap is honoured directly: a player on
1.9.29-rc1 is running something semver-OLDER than 1.9.29, so an opted-in player
HOLDS a prerelease of its own core rather than being pulled off the build it was
given to test. Narrowly — a newer core still lands, so opting in never means
never updating again.

Both loop conditions are closed by construction. The manifest and the download
come from one buffer hashed once, so a checksum cannot describe bytes we are not
serving. And the version is stamped into autorun.brs at build time by both
builders, so the script reports the version it actually is — otherwise the player
applies the update, still reports the old version, and is offered the same
package forever.

Failure always degrades to "keep running the old version": an unreachable
manifest, a missing checksum, a failed verification, a full attempt counter and
an unbuildable package all resolve to skip.

998 tests pass (was 954).
2026-08-05 10:09:00 -05:00
ScreenTinker 5a7277523a Wire BrightSign native sync end to end, chosen per group
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 10:06:18 -05:00
ScreenTinker 46b2227dfd BrightSign: real telemetry and hardware identity, not a block of nulls
The web player sent battery/storage/RAM/CPU as nulls and 'Web Player' in the
wifi_ssid column, and the bs_model / bs_os_version / bs_serial / bs_screen fields
the player already reported at registration were consumed by nothing. A browser
tab genuinely has none of that. A BrightSign has some of it, and was reporting
none.

Telemetry comes from a CACHE the heartbeat reads synchronously. The beat builds
its payload every 15s without awaiting, but the one real sensor here —
deviceInfo.getTemperature() — returns a promise; awaiting inside the beat would
either block it or serialise a pending Promise into the payload, which is exactly
how device_id once became "[object Promise]". The cache starts EMPTY rather than
null-filled and is spread last, so off-platform nothing changes and a null here
can never clobber a value another player family legitimately supplied.

wifi_ssid was actively false on a PoE Ethernet appliance — an operator reading
that column was told an SSID that does not exist. It is null there now, and the
device view shows a real hardware block instead. Android's WiFi display is
untouched.

Hardware identity is a SEPARATE writer from applyDeviceInfo, deliberately. That
function is a blind full-row overwrite, and an empty device_info once nulled
seventeen columns every five minutes because {} is truthy. These fields arrive
only on a full register, so the same shape would wipe them on every lightweight
refresh in between; COALESCE makes "no news" mean "unchanged".

The OS build gets its own column rather than reusing android_version, which is
load-bearing as a TYPE discriminator: device-detail chooses between the Android
and browser layouts with android_version.startsWith('Web/'), so writing
"BrightSign OS 9.0.189" there would have rendered a BrightSign with battery and
WiFi cards — and applyDeviceInfo would have clobbered it on the next refresh.

Storage is labelled "Player Storage", not "Storage": on this family the number is
the widget's cache quota, not the device filesystem, and it lands in the same
column as Android's real disk figures.

Schema: device_telemetry.temperature_c REAL; devices.hardware_model,
hardware_serial, hardware_os_version, output_index. All nullable, all idempotent
in the existing migration array.

973 pass (+19). The temperature tests drive a real socket into a real server,
because changing the arity of the telemetry INSERT would break every player's
heartbeat, not just BrightSign's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 10:03:33 -05:00
ScreenTinker e606cc83d1 Screenshots: prove pixels arrived instead of assuming the draw worked
A BrightSign emitted BLANK screenshots and logged "Screenshot sent". With hwz
enabled the video decodes onto a hardware plane outside the browser compositor
— BrightSign's docs say the HTML/JS layer "doesn't see the pixels" — so
drawImage(video) produces a fully TRANSPARENT image and throws nothing.
Chromium 87, which this XT245 reports, fails the same way.

Both capture paths set captured/drawn = true purely because drawMediaFit() had
not thrown. So the dashboard showed a dead screen while the panel played
perfectly, and the zone path painted a black rectangle in place of the labelled
placeholder drawZonePlaceholder() exists to guarantee ("never a transparent
hole"). Success reported, nothing done.

isMediaReadable() does not catch this. It answers "am I ALLOWED to read this"
(same-origin / CORS), which is a different question from "did any pixels
arrive".

videoFrameIsCapturable() probes a 16x16 scratch canvas before committing to a
full-size draw. ALPHA is the discriminator, not colour: a scratch canvas starts
transparent and a real decoded frame writes alpha=255 even when the frame is
pure black, so a legitimate fade-to-black still reads as captured while
"nothing arrived" does not. A tainted canvas counts as captured, because
tainting only happens once cross-origin pixels have actually been drawn.

Probing BEFORE the draw matters twice: it avoids a wasted full-size drawImage on
every frame of a 1fps stream, and in the zone path it stops a black rectangle
being painted underneath the placeholder.

When a video is on screen but unreadable the status card now says so, because
that card is also what shows for "no content" — without the line an operator
would reasonably conclude the screen was blank.

Not gated on BrightSign: the same silent failure exists for any stalled decoder
or engine that declines to hand back frames.

10 tests, 964 pass.
2026-08-05 10:01:03 -05:00
ScreenTinker c743aa4b81 BrightSign: real display power, reboot and volume — command parity
The web player handles four of the ~20 fleet commands, because a browser tab
genuinely cannot do more. A BrightSign can, and was inheriting the browser's
limits for no reason.

screen_on/screen_off now send CEC Image View On (0x0D) / Standby (0x36) so the
display actually sleeps. The overlay only painted the screen black: the panel
stayed lit, drawing power and at risk of burn-in. Best effort by design — some
displays ignore broadcast CEC and need direct addressing — so displayPower()
returns false when unavailable and the overlay is applied either way, meaning
something visible always happens.

reboot was silently ignored: the dashboard button did nothing on a web player.
It now goes through the host to RebootSystem, and still logs a clear "not
supported" off-platform rather than failing quietly.

set_volume applies to whatever is playing AND is re-applied on every subsequent
'play' event, caught in the capture phase because media events do not bubble.
Media elements are created per item across fullscreen, zone and preload paths,
so setting volume once would otherwise last only until the playlist advanced.
Wall followers stay silent throughout — that is deliberate, not an oversight.

A dual-output player addresses HDMI-N for the screen it actually paints, so
output 2 sleeps its own display rather than output 1's.

954 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 09:26:23 -05:00
ScreenTinker 58641e7bbe Persist the device token, not just the device id
The bridge stored device_id in the registry and the display still came back as a
NEW device on the next boot. The id is not an identity on its own: the server
authenticates a claim to an existing display with the token, so an id presented
without one reads as a brand-new player and gets a fresh row.

device_token now sits alongside device_id in the registry, getConfig adopts both,
and clearIdentity forgets both — a stale token must not outlive the identity it
belongs to.

Found on an XT245, not in a test, which is why the three new cases name the
symptom rather than the mechanism. 951 pass.

Also worth recording from the same session: the duplicate rows had a second
cause. The widget's storage_path was pointing nowhere useful, so localStorage
had no persistent home and the per-install fingerprint salt was regenerated on
every boot. With storage_path set correctly the cache directory now exists on
the player and the fingerprint is stable, which is what stopped the churn; the
registry identity is the belt to that pair of braces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-04 23:57:04 -05:00
ScreenTinker 5901067d8a Finish the BrightSign port: native sync, offline fallback, multicast guard
st-sync.js wraps SyncManager, the native protocol. Three properties drove the
shape of it. It repeats the sync broadcast at 1Hz so a player powered on late
still joins, which means acting on every repeat would reload the video once a
second forever — on screen that reads as a stutter, not as a sync fault, so the
id dedupe is mandatory rather than an optimisation. The leader starts from its
OWN broadcast rather than at announce() time, or it runs ahead of the group by
the width of the network. And attachVideo refuses an element with no
setSyncParams instead of half-syncing it.

offline.html is the local fallback the host falls back to after three failed
loads. It names the server, keeps probing with capped backoff so a site full of
panels cannot storm a server that is coming back, and asks the HOST to restart
the player when it answers — never navigating itself, for the same reason the
player never reloads itself here.

The resolver now models multicast reach. All-BrightSign groups spread across
subnets no longer get native sync: each subnet would sync neatly within itself
while drifting from the others, and the dashboard would show a healthy group
throughout. The IP comparison is a heuristic so it is used in one direction
only — differing networks are evidence against, matching ones are never proof
for, and unknown addresses block nothing.

st-sync.js is served from its single source like the bridge, and the SD card
deliberately carries neither: the player pulls both from the server so a stale
copy on a card can never skew from the player using it.

948 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-04 21:43:42 -05:00
ScreenTinker 7fb94fbf70 Correct the BrightSign port against the dev-cookbook examples
Reviewed autorun.brs and st-bridge.js line-by-line against the real examples
instead of the prose docs. Five defects, three of which would have been silent.

The registry API is asynchronous and section-oriented: read(section, key)
returns a Promise and writes take an object, write(section, {k: v}). The bridge
treated both as synchronous, so deviceId() returned a Promise object — truthy
and non-empty — and a panel would have registered as "[object Promise]" while
its real row sat unclaimed. It now prefetches into a cache behind onReady(), and
connect() waits for that before registering.

brightsign_js_objects_enabled: true is required alongside nodejs_enabled for
require("@brightsign/*"). Without it the bridge degrades to no-ops and the
player loses identity and restart delegation — which would have read as
"BrightSign doesn't work" rather than as one missing flag.

storage_path is a directory name, not a volume, and storage_quota is a string;
the local fallback URL needs its volume (file:/SD:/offline.html). Added
security_params and hwz_default to match the examples.

SyncManager does not work unless networking/ptp_domain is "0", which needs a
reboot to apply. Done only when this player is configured for native sync, and
read-before-write so it reboots once rather than on every boot.

Confirmed correct as written: messageport, the roHtmlWidgetEvent loop, and
RebootSystem(). The notes also state a widget URL may be an externally hosted
page with the same JS API access — the favourable answer to the question the
original probe was built to ask.

Bridge tests now model the async section-oriented registry, so a synchronous
stand-in can never hide this class of bug again. 931 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-04 21:27:53 -05:00
ScreenTinker ce854ff2d8 Wire the BrightSign bridge into the web player
The bridge and the host existed but nothing loaded them. Now the player does.

restartPlayer() replaces every location.reload() call site. On BrightSign a
page-initiated reload does not reliably bring the roHtmlWidget back, so the page
asks the host to rebuild it and only falls back to reload() when no host is
there to take the request. That covers the deploy path, the operator refresh,
the service-worker activation and the manual reset.

Identity now round-trips through the registry, which outlives localStorage on
this platform: getConfig() adopts a registry identity when local storage comes
back empty, instead of re-pairing and spawning a second row for a panel that is
already provisioned. The operator reset clears the registry too — otherwise it
would clear localStorage, get the same identity straight back on the next boot,
and reset nothing.

Registration reports platform 'brightsign' rather than "Chrome 120", which is
what sync-backend.js resolves native-vs-ours from, plus model, OS, serial and
which output this widget paints.

Dual output needed a collision fix: autorun.brs gives the second HDMI output its
own widget, and both widgets share an origin, a registry and one SD
storage_path. Un-namespaced, output 2 would read output 1's config, install salt
and device id and the two would collapse into a single device row. Storage keys
and registry keys are now suffixed per output; screen 1 keeps the bare names so
nothing existing moves.

The bridge is served from its single source so the copy the player loads can
never skew from the one on the SD card next to autorun.brs, and it is served to
every player rather than gated on a user agent — a panel reporting an unexpected
UA would otherwise silently lose restart-instead-of-reload.

Two test harnesses extract player functions and run them in an isolated scope,
so they now supply SCREEN_SUFFIX; one gained a case proving two outputs of one
player get distinct identities. 927 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-04 21:07:47 -05:00
ScreenTinker 6f5907a1d4 BrightSign: supervised player host, JS bridge, and per-group sync backend
The player is the unmodified web player in an roHtmlWidget — that already runs
on real hardware. What was missing is everything a page cannot do for itself.

autorun.brs becomes a host rather than a URL wrapper. It owns the widget
lifecycle, because a page-initiated location.reload() does not reliably bring an
roHtmlWidget back: a deploy on 2026-07-28 reloaded every connected player and
the BrightSign was the only one that never returned. The page now posts
{type:"restart"} and the host rebuilds the widget. It also retries load-error
with backoff, falls back to a local page, and runs a heartbeat watchdog that
catches the case load-error never reports — a page that loaded fine and then
wedged on a dead socket or a stalled decoder.

st-bridge.js is the page's half over @brightsign/messageport: registry-backed
identity (localStorage is origin- and quota-bound, the registry is not),
restart-instead-of-reload, heartbeat, and sync-backend reporting. Every method
degrades to a no-op off-platform, so it is safe to load unconditionally.

sync-backend.js decides whose synchronisation a group runs. Ours is
clock-derived and spans any mix of Android, web, Tizen and BrightSign; BrightWall
is frame-accurate and BrightSign-only. auto picks native when every member is a
BrightSign. The refusal that matters: native sync selected for a mixed group
downgrades and says why, because a half-synced group would look perfectly
synchronised on the dashboard while one panel drifted alone.

Dual output via output_mode single|dual|clone — a second widget loads the same
player with &screen=2 so the server can give it its own playlist.

Written against the BrightDeveloper docs; not yet run on hardware. The README
lists what is unimplemented, including the BrightWall runtime API, which that
doc set does not cover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-04 20:26:12 -05:00
Claude a310d7d5b6 Stop the onboarding checklist counting a field no player reads
"Default Content" is persisted by the device route, snapshotted and restored by the settings layer,
offered in the device form in five languages — and read by nothing. Grep the whole tree and it
appears only in those places, the schema, and this checklist. It is absent from assemblePayload,
from every socket payload, and from all four players.

Counting it as "content assigned" therefore told the operator their screen was set up while the
screen itself went on showing "waiting for content" — the checklist confirming the one thing it
exists to confirm, incorrectly. It now counts only a playlist or a layout, both of which really do
put something on a display.

An existing test asserted the opposite ("any of the three ways of assigning counts"). It encoded the
same false premise, so it is replaced by one that pins the corrected behaviour along with the
evidence for it. The column and the form field are left alone — whether to implement or remove the
feature is a product decision, and this change only stops the checklist making a claim on its
behalf.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 22:41:06 -05:00
Claude e3b01a5c7f Make a content-only schedule actually put that content on the screen
The schedule dialog offers "Content (single item, optional)". The value was cross-tenancy validated
and stored faithfully, and then read by nothing. services/scheduler.js acts on exactly two columns,
layout_id and playlist_id; content_id is consulted nowhere in the codebase. So picking a file and
saving produced a schedule that fired and changed nothing — while the calendar drew a block labelled
with that filename, as confirmation that it would.

Rather than thread a third override type through the engine and every player, the schedule now gets
a playlist containing that one item. That is the shape the entire pipeline already understands:
publish, assign, push, snapshot, offline cache and all four players work on it unchanged.

It is published through the shared publishPlaylist path rather than by hand-rolling the snapshot,
because players read denormalized fields out of published_snapshot (filename, mime_type, filepath,
remote_url, per-item schedules) and a second copy of that shape here would rot the first time it
changed.

An explicit playlist override still wins and no throwaway playlist is created; a schedule with
neither content nor playlist is untouched.

5 tests covering all of those, including that the generated playlist lands in the right workspace and
that its snapshot carries the fields the players need rather than just the id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 22:26:58 -05:00
Claude 3c1b2f62ee Make a recurring schedule respect its start and end dates
A recurring schedule ran forever. The engine compared weekday and HH:MM and dropped the date
component entirely, so recurrence_end was never read: a campaign set to finish on the 1st was still
switching screens weeks later. The same omission made a recurring schedule live before its start
date.

The calendar does read recurrence_end, so it drew the campaign as finished while the screens kept
obeying it — the two views disagreeing is what made this hard to see from the dashboard. The end
date is offered on the form, so it has to mean something.

The date window is inclusive at both ends: an end date of the 5th means the 5th runs to its normal
end time, which is what someone choosing that date means. An open-ended recurring schedule is
untouched and still runs indefinitely.

NOTE, because this one really does change live screens: any recurring schedule that has been running
past its end date will now stop. That is the intended behaviour and was confirmed before making the
change, but it is the difference between this commit and the calendar fix alongside it, which
changes only what is drawn.

6 tests: stops after the end date, the final day still runs in full, does not run before the start
date, unchanged inside the window, open-ended schedules unaffected, one-offs unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 22:22:59 -05:00
Claude 416984d56b Draw a recurring schedule on every day it actually fires
The calendar is the operator's only view of what is scheduled, and it disagreed with the engine in
both directions for the two most-used repeat presets.

The expansion stepped by the recurrence unit from the schedule's original start:

  - WEEKLY advanced a whole week at a time, so dayOfWeek never changed and a
    FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR rule could only ever match its start day. Created on a Monday
    it drew one event a week; created on a Saturday it drew nothing at all.
  - The walk began at the original start under a 366-iteration cap, so a schedule begun more than a
    year ago never reached the current week and drew nothing.

The engine evaluates day-of-week directly, so those schedules were running Mon-Fri the whole time.
Screens switched content the calendar said was not scheduled.

The expansion now walks the visible range day by day and applies the same rule the engine does, so
the drawing follows what actually happens. Cost is bounded by the window being displayed rather than
by how long ago the schedule was created, and the loop re-anchors the time of day on each step so a
DST boundary does not drift the instances.

Overlap is left to resolve as it already does: a shorter, higher-priority schedule takes over while
it is active and the recurring one resumes underneath when it ends. Nothing here changes what fires
— only what is shown — so this cannot alter live screens.

8 tests: five events for a Mon-Fri rule whichever day it was created on, a two-year-old daily
schedule drawing again, WEEKLY-without-byDay still meaning the start's weekday, INTERVAL honoured,
recurrence_end stopping the drawing, one-offs unaffected, and durations preserved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 22:18:29 -05:00
Claude a3b668d32f Treat an empty device_info as "nothing new", not as "forget what you know"
Every web and BrightSign player nulled seventeen of its own device columns every five minutes.

The browser player's refresh-register sends `device_info: {}` on a 300-second timer — it has nothing
new to report, it just wants a fresh playlist. But `{}` is truthy, and applyDeviceInfo is a blind
full-row overwrite with no per-field presence check, so it bound undefined for every column.
better-sqlite3 stores undefined as NULL rather than throwing, so the write succeeded and the row was
quietly emptied: android_version, app_version, screen_width/height, render_*, ota_status and
attempts, tier, the four capability flags and the four volume/brightness columns.

Android never hit it, because it always sends the full object. So this degraded exactly the client
family that cannot be inspected any other way — a browser player has no adb, and the dashboard row
is all there is. Fleet view, resolution diagnostics and any version-based logic read blank for them,
which also makes evaluating a browser-based platform look worse than it is.

The surrounding code already anticipates the refresh shape: recordReconnect and persistIdentity are
both gated behind `if (!isPlaylistRefresh)`. This call was the one that was not.

5 tests, including one pinning the driver behaviour the bug depended on — undefined binds as NULL
rather than throwing, which is why this was a silent five-minutely wipe instead of a loud error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 21:09:11 -05:00