#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.
#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
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
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
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
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
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
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
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
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
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
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
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
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
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.
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
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.
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
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
Three related pieces. Zones were already stored as percentages and layouts
already carried their own width/height, so this is mostly design work rather
than plumbing.
SIX PORTRAIT TEMPLATES at 1080x1920. Deliberately not the landscape set turned
sideways: "Three Column" at 33% each becomes three tall slivers, and a 15% ticker
that reads well across 1080px is a 288px band on a 1920px-tall panel, so the
portrait ticker is 12% and the PiP window is wider than tall (a 30x30 box is
square on 16:9 and 324x576 in portrait). Seeded in schema.sql for fresh installs
AND as a migration, because schema.sql never runs on an existing database — and
upgraded instances are exactly the ones with portrait panels already deployed.
THE EDITOR CANVAS followed a hardcoded padding-top:56.25% — the 16:9 ratio trick.
Authoring a portrait layout meant dragging zones on a landscape canvas: the
percentages landed correctly on the panel and looked wrong everywhere you
designed them. It now derives from the layout's own height/width, clamped so a
pathological row cannot produce an unusable editor.
THE PLAYLIST PAGE now draws where content actually lands. A playlist has no
intrinsic layout, so the server reuses #104's derivation from the items' own zone
bindings and returns it. Previously an item could be tagged "Bottom Ticker" with
nothing to say the ticker is a thin strip along the bottom — people assigned by
zone name and found out by looking at a screen. Empty zones are dimmed, because
an empty zone shows its background colour on a real panel and that is worth
seeing before publishing rather than after.
Verified against a copy of prod: 6 templates and 12 zones created, the 7
landscape templates untouched, no errors at boot, and a second boot changes
nothing. Each stacked template's zone heights sum to exactly 100%.
1074 pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
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
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
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
Caught on hardware immediately after deploying rc3 to alpha: the player kept
playing content while reporting nothing at all, throwing every 15 seconds.
Uncaught TypeError: BS.telemetrySnapshot is not a function
The page was rc3 and the bridge it ran was older. Two causes, both fixed.
CACHE_NAME stayed at rd-player-v19 across a release that changed both the
service worker's fetch strategy and the shipped /player assets. The activate
handler deletes every cache whose name does not match, so keeping the name kept
the previous shell cache alive — including a stale st-bridge.js. Bumped to v20.
Content lives in its own cache, so this costs a small shell re-download and never
re-fetches a playlist.
The deeper defect is that the call site treated an optional bridge method as
guaranteed. It was the ONLY unguarded BS.* call in the player; every other one
checks or wraps. The bridge and the page are halves of one contract but are
fetched separately, so version skew is a normal condition, not an anomaly — it
must degrade, not throw. Now guarded on typeof, so a skewed pair reports the
fields it can and keeps heartbeating.
Worth naming the failure shape: the display looked perfectly healthy. Content
played, the socket connected, the device showed online — and telemetry silently
stopped. Anything that reports health through the same path it is breaking will
fail this way.
1056 pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
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
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
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
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).
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
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
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.
Blanking the screen took three attempts on real hardware, and each failure was
the same lesson from a different angle:
1. black overlay -> the video played straight THROUGH it. With hwz
enabled the widget decodes onto a hardware plane and
the graphics plane sits behind it; z-index is
irrelevant across planes.
2. pause + hide element -> playback stopped and the LAST DECODED FRAME stayed
on screen. Hiding a DOM element does nothing to the
plane, which is not part of the DOM.
3. pause + remove src -> releases the plane. Black.
+ load()
Coming back re-mounts through nextItem(), because a torn-down element cannot be
resurrected. The playlist keeps advancing while the screen is off, so each newly
started item is torn down as well — caught on 'play' in the capture phase, or the
next video lights the panel back up a few seconds later.
CEC is now explicitly not load-bearing. Our XT245 logs "failed to get cec clock"
and does not respond to it at all, which is precisely why blanking cannot depend
on a cooperative display: plenty ignore broadcast CEC or need direct addressing.
displayPower() stays as opportunistic best-effort alongside the teardown.
Verified on hardware: not black, then frozen frame, then black.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
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
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
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
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
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
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
device:playback-state was the only relay that forwarded the client's payload verbatim. The workspace
lookup correctly used currentDeviceId — the socket's authenticated device — but the object passed on
to the dashboard was whatever the player sent, including any device_id it chose to put there. So one
device could report playback progress attributed to a different screen in the same workspace, and
the dashboard had no reason to doubt it.
Every other relay in this file stamps the authenticated id. This one now matches.
882 server tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
"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
Give every item on a sync-group playlist a daypart — "menu boards 06:00-22:00" — and at 22:00 the
whole group kept displaying, or looping, whatever had been in-window last. An identical ungrouped
screen showed "Nothing scheduled right now" correctly.
The group schedule tick filters items by the same scheduleAllows check as solo playback. With
everything filtered out the period is zero, so the target is null and the tick simply returned.
Nothing else was watching: group members are schedule-driven, so renderContent arms no advanceTimer,
and a group-rendered video is created with loop = !!groupSync. Solo playback routes this exact
condition into the idle card; group playback had no equivalent, on either player.
Both ticks now tear down and show the idle card when the schedule has nothing live, and pick up
again when the daypart re-opens.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Taking a display out of a sync group, or deleting the wall it belonged to, froze it on whatever was
playing. The clip looped forever and every later refresh took the "unchanged" branch, because the
element was attached, playing and un-errored — healthy by every check the player makes. Only a
reboot cleared it.
On the web player, reconcileAdvanceTimerForMode re-arms a solo timer for widgets and images but
skips video and YouTube, on the grounds that they "self-advance via their own end handlers". The
handler that is live at that moment, though, was built for the mode being left: a group-rendered
video was created with `loop = !!groupSync`, a wall-follower video with `isFollower` true, and both
are captured in the closure at render time. A looping element never fires `ended`, and a follower's
handler declines to advance — so nothing self-advances and nothing re-renders. It now re-renders
whenever the element on screen is still looping, rather than guessing which media types can look
after themselves.
Tizen had the same freeze by a different route. GroupSyncController.exit and WallController.exit
both call player.invalidate() for exactly this purpose, but invalidate only cleared the change
signature — and load() returns at the continuity check ("current item survives, just retarget the
index") before reaching any render, so the invalidate was a no-op. It now forces the next load to
re-render, which is what those call sites always intended. On Tizen this froze every item type, not
just video, because `single` skips the timer in all of the renderers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
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
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
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
Creating a schedule validates every reference it carries against the caller's workspace — content,
widget, layout, playlist all go through checkRefInWorkspace. zone_id was the one polymorphic
reference left out of that list, so a schedule could be pointed at a zone belonging to another
workspace's layout.
It needed its own check rather than a sixth entry in the table: layout_zones has no workspace_id
column of its own. A zone belongs to a layout, and the layout carries the workspace, so the
ownership question has to be answered through that join. A zone on a platform-template layout
(workspace_id IS NULL) is allowed, matching how the other references treat templates.
882 server tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
devices.playlist_id is ON DELETE SET NULL, so the database detached correctly — but the handler
emitted nothing, so a screen kept displaying the deleted playlist until it happened to reconnect or
was restarted. You delete a playlist to take content off the wall; the wall carried on showing it.
Every sibling mutation in this file already pushes (publish, assign), and DELETE
/devices/:id/playlist was given a push for precisely this reason: "so the screen stops, rather than
leaving the old content up until something else happens to update it".
The affected devices are read before the delete, since the association is gone the moment it runs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Replacing the single item of a one-item playlist did nothing. The old promo, board or clip kept
playing while the dashboard showed the new playlist published and the device perfectly healthy —
only a reboot or a manual refresh cleared it.
#157 defers a rotation so a live item is not yanked mid-play, and applies it "on the next natural
advance". For a one-item playlist there is no such thing, by design: single-item rendering
deliberately never advances. A video gets `loop = (playlist.length === 1)` and so never fires
`ended`; a YouTube embed loops for the same reason and skips its safety net; a solo widget is "held"
on a self-re-arming refresh that never calls nextItem, because reloading it would reset a directory
board's scroll. Tizen is worse still — `single` makes every renderer skip its timer, so images
freeze too.
Two guards, the same pair already applied to the Android controller:
- A one-item playlist is never deferred. There is nothing to protect from being cut off, since
nothing was going to advance anyway.
- Any deferral that does happen gets a 60-second deadline. The deferral is a bet that an advance is
coming; if the bet loses, the change must still land rather than strand the screen on content the
operator has already replaced.
Verified in headless Chrome: a one-item playlist holding a solo widget (the "held" case that never
advances), its only item replaced with a different widget — the screen followed, with no reload and
no restart. Before the change it stayed on the replaced item indefinitely.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
In a multi-zone layout a zone's video advanced only on `ended`. On the web there was no error
handler and — alone among the zone branches, which all arm a timer — no timer either. On Android the
zone player listened for STATE_ENDED with no error listener and no fallback.
A playback error lands in STATE_IDLE, never STATE_ENDED, so nothing advanced. A 404, an unreachable
remote_url, a clip the device cannot decode, or content not yet cached while the device is offline
(the zone then falls back to the server URL, which fails with no network) all had the same result:
that region of the screen went black and stayed black for days, while every other zone kept rotating
normally. It reads as a rendering bug rather than a bad file, and nothing self-heals — the layout has
to change or the app has to restart.
Both fixes already existed elsewhere and were simply not carried across. MediaPlayerManager treats a
playback error as a completion for exactly this reason ("Root-2: a corrupt/undecodable video used to
freeze the playlist forever"), the fullscreen web path has both an onerror and a timer, and Tizen's
ZoneRenderer has an onerror plus a duration+5s safety net. The multi-zone paths were the gap.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
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
The suspended branch replaces the whole status overlay with its own markup, and that markup does not
contain #statusText. showStatus then did:
document.getElementById('statusText').textContent = msg;
so every later call threw a TypeError for the life of the page. The consequences got worse the
further down they went:
- Each refresh beat re-emits device:paired, whose handler calls showStatus('Waiting for content...')
— so the player raised an uncaught error and sent itself a "crashed" exit beacon every few minutes
while suspended. This is very likely the "Cannot set properties of null (setting 'textContent')"
the comment near the exit-signal contract says could never be traced.
- showNothingScheduled() calls showStatus BEFORE arming its 30-second re-check. So once the account
was restored, a playlist whose dayparts had all closed left the screen on the stale orange
"Account Suspended / Please upgrade your plan" card with no retry timer at all — it never
re-checked the schedule and never recovered without a reload.
showStatus now rebuilds the element if it is missing rather than bailing, so the message the caller
asked for is actually displayed and the recovery path continues.
Verified in headless Chrome against the real player: destroy the overlay exactly as the suspended
branch does, then call showStatus — no throw, no uncaught page error, and "Waiting for content..."
on screen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Editing a layout did nothing on a web screen that was already showing one. Add a zone, move an item
between zones, resize a zone, switch layouts, clear the layout — all silent, for as long as the item
list itself stayed the same.
Two reasons, and both had to be fixed:
- The change fingerprint covered item identity, order, revision, schedules and transition, but not
zone_id — so moving an item from one zone to another produced a byte-identical fingerprint
(published_snapshot is ordered by sort_order, so the order did not move either).
- The layout is not part of the item list at all, so a change to it could never appear in an
item-derived fingerprint. `layout` was assigned and then the function returned "Playlist
unchanged", and in multi-zone mode nothing else re-renders: each zone runs its own timers and
renderContent is never called again. The no-change health check does not help either, because the
old zone divs still hold media so the surface looks attached.
zone_id now sits in the item fingerprint, and the layout gets its own signature covering the layout
id and every zone's geometry, stacking, type and fit. Tizen's ZoneRenderer has always compared a
zone signature — this is the web equivalent, and it is the same defect that was fixed on Android
this week.
Verified in headless Chrome against the real player: a third zone added IN PLACE (same layout id,
same item list, no reload, no restart) re-rendered the screen to three zones. Before the change that
update was discarded as unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Per-device settings are saved against the hardware fingerprint so a panel that is deleted and paired
again comes back configured — name, orientation, playlist, blocked flag — without anyone visiting
it. That is deliberate and worth keeping.
A fingerprint is hardware-derived, so the same physical panel presents the same one whoever pairs
it. applyToDevice looked the snapshot up on fingerprint alone with no workspace comparison, and its
per-field guards only check that the referenced row still EXISTS, never who it belongs to:
if (s.playlist_id && db.prepare('SELECT 1 FROM playlists WHERE id = ?').get(s.playlist_id))
So a screen removed from one workspace and paired into another inherited the first workspace's
playlist and displayed its content, and `blocked` crossed the same way — a device arriving blocked
with nothing the new owner could see to explain it. The manual restore route already compares
workspaces before calling this, so the automatic re-pair path was the only place the check was
missing.
A mismatch is a quiet no-op rather than an error: re-pairing a second-hand panel into a different
workspace is a legitimate thing to do, it just must not carry the previous configuration along. A
snapshot with no workspace recorded still applies, so rows predating the column keep working.
5 tests: neither playlist nor block crosses, a mismatch does not throw, restore still works in full
inside the owning workspace (including a genuine block surviving a re-pair), and legacy rows are
unaffected. 882 server tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Deleting a device group converts its group schedules into per-device ones so the screens keep their
programming. That INSERT omitted workspace_id, which is nullable with no default, so every converted
row landed with workspace_id = NULL.
A null workspace does not merely look untidy — it makes the row unreachable in three directions at
once, and they compound into the worst possible combination:
invisible the schedule list and the all-screens calendar both filter on workspace_id
undeletable PUT and DELETE refuse a row with no workspace (403)
still live services/scheduler.js has no workspace filter, so it keeps firing every 60 seconds
"I deleted the group but the screens still switch content at 9am, and there is nothing in the
calendar to remove." The only way out was direct database access.
The conversion now carries the workspace, preferring the schedule's own and falling back to the
group's so a legacy group schedule that itself predates workspace_id still converts into a reachable
row. A boot migration repairs rows already orphaned in the field by recovering the workspace from
the device each one targets; anything still unresolvable is left alone rather than guessed at.
4 tests: the converted row keeps its workspace, is visible to the query the list and calendar use,
preserves the actual programming rather than just the ownership, and the repair recovers a row
orphaned before this fix existed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Nudging one zone in the layout editor and pressing Save destroyed unrelated tenant data across the
whole workspace, and returned 200.
The handler deleted every zone and re-inserted the same ids. Its comment claimed that was safe —
"Reuse each zone's id when supplied so device->zone assignments survive an edit (a fresh uuid per
save would orphan them)" — but reusing the id does not help, because SQLite runs the referential
actions on the DELETE and re-inserting the same primary key afterwards resurrects nothing. Two
things point at those rows:
playlist_items.zone_id ON DELETE SET NULL -> every multi-zone playlist item un-assigned, so
those playlists silently fell back to fullscreen
schedules.zone_id ON DELETE CASCADE -> every zone-bound schedule permanently deleted
No warning, no undo, and nothing in the UI to suggest a geometry tweak had touched schedules at all.
Zones are now updated in place, inserted when new, and deleted only when the editor actually removed
them. An update touches no foreign key, so nothing pointing at a surviving zone is affected. The
cascades are left exactly as they are: on a genuinely removed zone they are the correct behaviour,
and the tests pin that too.
4 tests: a moved zone keeps item assignments and zone-bound schedules, the geometry change is really
applied, adding a zone disturbs nothing, and removing a zone still un-assigns its items and removes
its schedules.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
A PiP overlay renders across a live screen — an arbitrary web page, at full resolution, for as long
as the operator wants. That is a fleet-affecting write, but the three routes that perform it carried
only requireScope('full'), which gates API tokens and is a deliberate pass-through for dashboard
sessions. The file's own comment says so ("No-op for JWT sessions"), on the assumption that
something else covered that case. Nothing did.
Every sibling route pairs the two checks — device-groups.js gates POST /:id/command with
`requireScope('full'), requireGroupWrite`. These had only the half that does nothing for a logged-in
user, so a member who is refused on every other device mutation was accepted here.
requireFleetWrite restores the pairing on POST /, POST /clear and DELETE /, resolving the caller's
context against the workspace the same way the rest of the codebase does.
5 tests pin both directions: refused for a read-only member on all three routes and for an
unauthenticated caller, still allowed for a workspace_editor and for an org owner acting into the
workspace (actingAs, whose workspaceRole is null and must not read as a viewer).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
A person typing font-size:16px into the Text/HTML widget got 0.15vw — 2.8px on a 1080p screen,
1.9px at 1280 wide, smaller again on anything narrower. Not clipped, not hidden: rendered at a size
nobody can read, in the one widget whose entire purpose is hand-written HTML.
renderText converted every px font size to vw (px/108). That conversion exists to rescue LEGACY
Content Designer output, which used to publish absolute sizes as fontSize*10.8 px — dividing by 108
recovers the author's intended size and lets those widgets scale to any screen. Today's designer
emits cqw and no px at all (frontend/js/views/designer.js), so the conversion only ever needed to
apply to that legacy output. It was applied to everything.
Now it runs only on designer-authored markup, identified by its absolutely-positioned elements —
the same signal the dashboard already uses to decide whether a text widget can be reopened in the
designer. Hand-written markup keeps its px exactly as typed, and legacy designer widgets are
unchanged.
Found by looking at the screen. The rendered HTML and the widget URL both looked correct in every
check I ran; only a screenshot showed the text was microscopic.
5 tests covering both directions, including that a hand-written absolutely-positioned element
without the designer's left-first shape keeps its px. Verified on an Android screen: a 60px heading
and 24px body now render at their authored sizes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
The signature fix was necessary but not sufficient, and only a browser showed it. The update arrived
and was applied — the console logged "Playlist changed, updating" and playlist[0].widget_rev held the
NEW revision — but the iframe on screen still carried the old one.
Two guards were swallowing it. Continuity keeps a surviving item playing and deliberately does not
re-render ("Just retarget the index pointer - no re-render, no interrupt"), and identity is
content/widget ID, which does not change when a widget is EDITED. So the edited widget counted as
surviving. And the fallback that would eventually notice does not apply either: a solo widget is
deliberately never re-rendered on a timer, because that would reset a directory board's scroll.
Between them the new revision sat in the playlist, unused, indefinitely.
Now a surviving WIDGET whose rev changed is re-rendered through the buffered swap — which builds the
new iframe hidden and reveals it on load, so it is flash-free by design and this costs nothing
visually. Non-widget items and unedited widgets are untouched, so the continuity behaviour that
guard exists for is intact.
Verified in headless Chrome driving the real player: paired, widget assigned, then edited with no
page reload and no restart. rev 1785460578 -> 1785460589 on the live iframe.
Also caught here: my first attempt called renderItem(), which does not exist — the console.log fired
and the exception ate the rest of the handler, which looked exactly like the fix not working. The
function is renderContent(item).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Same fault as Android, in both other players, and my earlier read of them was wrong: I assumed they
rebuilt the iframe each cycle so could not go stale. They do rebuild — but only after the update
survives a change check, and both change checks key on IDENTITY:
web content_id|widget_id|remote_url|filepath|filename|schedules|transition
tizen [content_id, widget_id, remote_url, mime_type, schedules, transition]
A widget's identity does not change when it is edited, so an edit produced an identical signature,
the update was discarded as "unchanged", and the old render stayed up. widget_rev now sits in both,
alongside schedules and transition, which are there for exactly this reason.
The render URL carries the rev on both players as well. In the zone path the web player was picking
up `item.widget_rev` inside a loop whose variable is `a` — that would have been undefined on every
zone; it now reads the zone assignment's own rev.
Caching, which is the reason this is worth doing properly rather than just busting the URL: a URL
carrying ?rev=<updated_at> is content-addressed, so those bytes cannot change without the URL
changing. The render endpoint now returns immutable caching for a pinned URL and keeps no-store for
a bare one, and the service worker serves pinned renders cache-first (CACHE_NAME v18).
That closes a real gap. no-store meant widgets were the ONE thing the player's offline cache could
never hold, so a display that lost its uplink lost its widgets — while its images and video kept
playing. Offline resilience is the point of that cache. Old players sending no rev are unaffected:
they still get no-store, because without a rev nothing distinguishes one render from the next.
Verified live: bare URL -> no-store; ?rev=123 -> public, max-age=31536000, immutable. 859 server
tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Editing a layout notified nothing at all — no push to the displays using it — so a zone change
waited for the next heartbeat refresh at best. Combined with the Android rebuild being keyed on the
layout ID (which does not change when you edit a layout in place), that is why adding a fourth zone
took a force-stop to appear. The player-side fix makes the rebuild happen; this makes it prompt.
Renaming: duplicating a template produces "<template> (Copy)" and there was nowhere to change it.
The server has always accepted a name on PUT /layouts/:id; no UI ever sent one. The only name field
in the editor belongs to the selected ZONE, which is easy to mistake for the layout's own — zones
could always be renamed, layouts never could. The heading is now an input and its value rides along
with the Save the user already presses.
Verified on an Android 12 emulator, app left running throughout:
3-zone layout assigned -> "Multi-zone layout with 3 zones (was=null)"
4th zone added in place -> "Multi-zone layout with 4 zones (layout=a96c39ab, was=a96c39ab)"
The ids match, so the old id-only condition would have skipped the rebuild entirely. Applied ~1s
after the PUT, with no restart and no force-stop.
Also verified the background-audio fix on the same device: 1 started audio player with the video in
the foreground, 0 once another app was brought to the front. (First attempt was invalid — HOME
re-shows this player because it is the default launcher, so it never backgrounds.)
859 server tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Two separate faults in the same widget, both reported on #234.
1. Text taller than the screen vanished in silence. renderText set overflow:hidden on the document
with nothing able to scroll it, so anything past the bottom edge was simply gone: "Text goes to
bottom and disappears. It dont fit."
The content now gets a wrapper and an overflow mode:
fit (default) shrink until it fits — a NO-OP when the content already fits, so it rescues
widgets that are currently losing text without changing ones that are fine
scroll pan through it on a loop with a pause at each end, for content genuinely longer than a
screen where shrinking would make it unreadable
clip the old behaviour, kept because a designer-positioned layout may deliberately run past
the edge and must not be rescaled underneath its author
Measuring runs after layout, after web fonts settle, and on resize — a rotation or a resized zone
changes the answer, and fonts arriving late is the classic cause of a fit computed against the
wrong height.
2. Editing a widget did not reach the screen until the app was restarted. The render endpoint serves
live config, but the player deliberately keeps a widget's WebView while its URL is unchanged
(re-navigating every duration is a visible flash and destroys widget state — a half-typed
directory search, scroll position). Editing changes the content, not the id, so the URL never
changed and the reuse check always hit.
The widget's updated_at now travels to the player as widget_rev and goes into the render URL, so
the URL differs exactly when the content differs — and only then, so the anti-flash reuse still
holds for untouched widgets. The rev is refreshed at send time rather than read from the
published snapshot, because a widget edit does not republish the playlist. Editing a widget also
now pushes to the displays showing it, instead of notifying nothing at all.
859 server tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
1.9.26's opt-in was passive: it stopped a sideloaded build being reverted, but there was still one
APK slot and latest_version was the server's own VERSION, so a beta had to be installed by hand on
every display. This makes it a real channel.
- apk-cache tracks two slots. ScreenTinker-beta.apk is optional and reaches only displays with
ota_beta = 1.
- A beta must DECLARE its version in a sidecar ScreenTinker-beta.apk.version. The server cannot
infer it — stable's version is the server's own constant because the two ship together, and
reading it from the APK means parsing binary AndroidManifest.xml on the request path. If the
sidecar is missing or unparseable the channel does not activate at all and opted-in displays keep
getting stable. Failing closed matters: advertising a version that does not match the bytes served
is the OTA-loop condition this fleet has been bitten by before.
- The check and the download resolve the channel identically and fall back to stable identically, so
apk_size always describes the bytes actually delivered. No APK change was needed — the client
already fetches whatever download_url it is handed, so displays in the field can be moved between
channels from the dashboard today.
Switching back needed care. Stable is semver-OLDER than the beta it replaces, so the ordinary
"never offer a downgrade" rule stranded the display and unticking the box would have been another
silent no-op. The first attempt returned any non-opted-in display running a pre-release — which
broke a #144 test, correctly: that would have dragged every existing pre-release tester back to
stable the moment their server upgraded, the exact harm the opt-in exists to prevent. So the return
now requires evidence we actually served that display the beta channel (devices.ota_channel_served,
written once on change, not per check). A tester ahead of the server on their own build is left
alone exactly as before.
Documented in the README, including the constraint that makes the switch-back physically possible:
beta builds must carry a versionCode no higher than the stable they branch from, because Android
refuses to install a lower one. Equal numbers install in both directions.
Verified end to end against a live server with two real signed APKs: stable serves 1.9.26, beta
serves 1.9.27-rc1, an unknown channel falls back to stable, removing the version file deactivates
the channel, and the full opt-in -> serve -> switch-back lifecycle produces offer / up-to-date /
channel-return in order. 859 server tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Handing someone a test build was a trap. A prerelease sorts BELOW its own release — 1.9.25-fix234d
is semver-older than 1.9.25 — so a sideloaded display asked "anything newer?", was correctly told
yes, and updated itself straight back off the build we had asked someone to test. Same versionCode,
so Android installed it without complaint. Silent, and within minutes.
That is what happened on #234: the reporter installed the fix, tested for an evening, and reported
nothing had changed. They were right. Their tablet was running the old code again by then, and I had
told them it was fixed without ever checking what the device reported.
Adds a per-display opt-in (devices.ota_beta, default 0, checkbox next to the OTA toggle). When set,
the display keeps a prerelease of the CURRENT core instead of being pulled back to its release.
Deliberately narrow in one direction and deliberately wide in the other:
- Narrow: it only holds a prerelease of the core already installed. A plain release, a -patchN
build, an upgrade to a newer core, and a display ahead of the server all behave exactly as before,
and the flag defaults off so a fleet that never sets it is unaffected.
- Wide: an opted-in display is exempted from the superseded-prerelease guard. That guard would
otherwise pin a tester on an old test build permanently — an older-core prerelease is never
offered anything, so they would have to notice and sideload their way out. Writing the test is
what surfaced that; opting in must never mean never updating again.
9 tests covering both directions, including that shipping a newer release pulls a beta display back
onto the release line. 845 server tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
"No playlist" was an option you could select that did nothing. The picker offered it, and the change
handler opened with `if (!newPlaylistId) return; // Don't allow deselecting for now` — so choosing it
sent no request, changed nothing, and said nothing. The guard was honest about why: there was no way
to do it. PUT /devices/:id has never read playlist_id (200, ignored), and POST /playlists/:id/assign
can only ever set one.
Reported on #234 as "I also selected No playlist ... it still showed the same video". It did, and my
first explanation blamed the playlist-swap deferral. The deferral would have stranded it too — that
is fixed separately and tested — but on this path nothing was ever sent, so the deferral never got
the chance.
DELETE /api/devices/:id/playlist, device-scoped rather than playlist-scoped because there is no
playlist to authorize against when clearing. Ownership goes through checkDeviceOwnership like every
other device mutation, so a viewer and a stranger are refused. Clearing an already-clear display is
a no-op success, since it lives in a dropdown someone can pick twice. The now-empty playlist is
pushed to the device so the screen stops, rather than leaving the old content up until something
else happens to refresh it.
Validated on an Android 12 emulator against the reporter's shape: cleared while a YouTube item was
on screen, zero plays afterwards, device row cleared.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
The published API reference (frontend/api-docs.html renders docs/openapi.yaml through Redoc) said
version 1.9.0 while 1.9.25 was shipping. bump-version.sh updates VERSION, server/package.json,
android versionName/versionCode and tizen/config.xml — the spec was simply never added to it, so it
had been frozen since the public API landed and integrators were reading a version identity that no
longer existed.
Spec changes:
- info.version -> 1.9.25.
- Device gains its two network addresses, which are easy to confuse and are now described so they
cannot be: ip_address is the PUBLIC/WAN address the server observed on connect (X-Forwarded-For
aware, normally shared by every device at a site), local_ip is the device's OWN LAN address as
reported by the player, which is the one that reaches a panel on site. local_ip is new; both were
returned by GET /devices and neither was documented.
- Device gains its flattened latest-telemetry block (wifi_ssid, wifi_rssi, battery, storage, ram,
cpu_usage, uptime_seconds) — all returned already, none documented, all nullable because a web
player does not report what Android does.
- wifi_ssid's "permission" value is called out as a sentinel, not a network name: Android 10+
withholds the SSID without a location permission ScreenTinker only requests if an operator opts
in. An integrator who does not know that renders "permission" to an end user as their Wi-Fi name.
Drift prevention, because a wrong version number is silent and nobody re-reads one they trust:
- bump-version.sh now writes the spec version too, anchored to info.version (operation- and
schema-level version keys are indented deeper and untouched; openapi: 3.1.0 is unaffected).
- Three contract tests: the spec version tracks package.json, the two addresses stay documented
and distinct, and the SSID sentinel stays explained.
No new endpoints — audited every public router's routes against the spec and all are documented.
830 server tests + the 5 contract tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
A customer read the device page's IP as their screen's address and reported it as wrong.
It was not wrong, it was a different thing: devices.ip_address is the PUBLIC address the
server sees the connection arrive from. Both are useful — you want the public one to
recognise a site, and the local one to actually reach the panel — so the page now shows
each, labelled.
The player already computed its own address for the connectivity report; it just never
reported it. Read straight off the interfaces, so Ethernet panels get it too, and it needs
no permission. Stored on device_telemetry beside wifi_ssid/wifi_rssi, where the
per-heartbeat network facts already live, rather than as another devices column.
The same customer saw "Unknown" for the Wi-Fi name and assumed it needed device-owner
access. It needs LOCATION: Android 8.1+ returns the literal "<unknown ssid>" to an app
without it. So "Unknown" was us reporting a permission gap as if the network had no name.
The player now distinguishes not-allowed-to-know from genuinely-no-Wi-Fi, and the page says
"Needs location permission" instead of a blank. The permission is declared but NEVER
requested at startup and nothing else uses it — a signage player demanding location to
display a network name is a bad trade. It is an opt-in row on the setup screen, using the
same Enable/Manage pattern, and refusing it changes that one field and nothing else.
Also caught by the test suite, and worth recording: the first version of this dropped the
comma in the device SELECT list ("t.uptime_seconds t.local_ip"), which 500'd the endpoint
and failed seven tests that never mention telemetry. Verified end to end afterwards —
public and local addresses both returned, distinct, from a real request.
The admin plan table read /api/subscription/plans, which filters `active = 1` because
that endpoint feeds the public pricing page. So the one screen meant to show the
operator what plans exist could not show a hidden one — a comped or beta tier was
invisible to us as well as to customers, with no way to see it existed or who was on it.
Found immediately after creating exactly such a plan.
GET /api/admin/plans (platform-admin only) returns every plan plus, per plan, the number
of accounts, organisations and screens on it. Visible plans sort first so the list still
reads like the pricing ladder, with hidden ones after and badged.
The public endpoint is deliberately untouched: hiding a plan has to keep working, and
the test pins BOTH directions because they pull against each other — the admin list must
include an inactive plan, and the public list must never leak one.
Counts are the point, not decoration: "how many people are on what plan" is the question
you actually ask of this screen, and it was answerable only by hand in SQLite.
Also carries a warning for accounts whose plan no longer resolves. Both users.plan_id and
organizations.plan_id are FK-enforced to plans.id and there is no delete-plan route, so
this should be unreachable — but migrations here do rebuild tables with foreign keys off
(the tenant-cascade one rebuilt thirteen), and that is exactly how a row would be
orphaned. Six lines for a state that would otherwise be silent.
Strings added to en/de/es/fr/it/pt. Not hi: it has no admin translations at all, lookup
falls back to English, and four Hindi strings among forty English ones would read worse
than consistent English.
A customer blocked a screen once to see what the button did, then spent an evening
unable to get it back. Three separate faults stacked up.
1. Unblock did not stick. applyToDevice() restores `blocked` on re-pair — deliberately,
so a block cannot be shrugged off by deleting the device — which makes the SAVED copy
the real authority. Unblock only ever wrote `devices`, so the saved row stayed 1 and the
next delete + re-pair silently re-blocked. There was no way out from the dashboard at
all: unblock, re-pair, refused, repeat. Block and unblock now both mirror to the saved
copy, so the survives-a-re-pair property is deliberate rather than a leftover.
2. The refusal was invisible. handleServerRejection() clears credentials and calls
onUnpaired, but only ProvisioningActivity ever assigned that callback — and it is long
gone by the time playback is running. So the screen sat on "Connecting to server" and
the player eventually blamed the URL, sending the operator off checking their network
while the server had already said exactly what was wrong. MainActivity now handles it.
(This half was mine: clearing those leaked callbacks to stop the relaunch loop removed
the only thing that surfaced a rejection. It was a broken path — it fired into a
destroyed Activity — but it was the only one, and MainActivity should have owned it.)
3. The reason was thrown away. The server sends device:auth-error {error: "Device
blocked"} and the client discarded it. It is kept now, and a blocked screen says so
instead of implying a network fault. Localised in all six languages, matching the other
on-screen status strings.
Also ran on prod: one stale saved block cleared (fingerprint ef6540376599, the reporter's
tablet), DB backed up first. It was the only such row.
Tests pin both directions, because the two are easy to confuse: unblock must clear the
saved copy, AND a genuine block must still survive a delete + re-pair.
A player stands down from self-updating when another device owner manages the panel,
on the assumption that the MDM distributes packages instead. That assumption does not
always hold: an operator may run an MDM for policy alone and still want ScreenTinker's
OTA to own the player. Until now there was no way to say so — the stand-down was a
client-side decision with no operator input.
OTA_ALLOW_MANAGED_DEVICES=1 makes the server advertise `allow_managed: true` in
/api/update/check, and players skip the stand-down. Default off: the safe behaviour
stays the default, and only an explicit opt-in changes it.
Absence is not consent. The client parses the field with a false default, so a newer
player against an older server that has never heard of it still stands down; and the
server always emits the key, so a player can tell "the operator said no" from "this
server has no opinion". Config parsing is strict for the same reason — only 1/true
enable it, and anything else, including a plausible typo like "ture" or "yes", lands
on the safe side rather than riding JavaScript truthiness.
This deliberately does NOT grant silent install. Off device-owner, and without
DELEGATION_PACKAGE_INSTALLATION delegated by the MDM, Android still raises a confirm
dialog somebody has to accept, so the override alone will not fix a fleet whose
installs are failing at that dialog — delegating the scope is the real fix there. The
README says so at the point of use, because reaching for this flag is the natural
mistake.
Only reachable because the stand-down now runs after the version check rather than
before it; it needs the server's answer in hand to consult.
Follow-up to #233, which made the upload ceiling configurable — the right call,
500MB is genuinely too low for video.
An environment variable is a string, so the value reached multer's
limits.fileSize as text where a number is expected. That survives some
comparisons through coercion and misbehaves in others, which is the worst kind
of bug to find later; the line directly above it already used parseInt for the
same reason. It is parsed properly now, and a suffix is accepted — someone
raising a limit for video is choosing "about 2GB", and 2147483648 is easy to
mistype by a factor of ten.
An unparseable value falls back to the default rather than becoming NaN or
zero. Either would reject every upload on the instance, from a typo in an env
file, with nothing on screen to explain it.
The documentation matters as much as the code here. MAX_FILE_SIZE is the LAST
limit in the chain: nginx caps the request body with client_max_body_size and
returns 413 before the app is reached — our own deployment sets 500M — and
Cloudflare caps uploads per plan at the edge. Raising the variable alone often
changes nothing, so the README now says so, with the nginx directive and a note
that an upload failing with nothing in the server log never reached the server.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Thanks — the hard-coded 500MB cap was genuinely too low for video, and making it configurable is the right call.
Merging as-is for the credit; a follow-up commit fixes two things this needs to actually work:
1. `process.env.MAX_FILE_SIZE` is a string, so the value reached multer as text rather than a number — the line directly above uses `parseInt()` for the same reason.
2. Raising it alone is not enough behind a reverse proxy. nginx caps request bodies at `client_max_body_size` (500M on our own deployment) and returns 413 before the app sees the upload, and Cloudflare's own cap applies too. That is now documented in the README alongside the variable.
The follow-up also accepts a suffix (`MAX_FILE_SIZE=2GB`) since typing the byte count is easy to get wrong.
Two mistakes in the previous commit, both of which broke CI.
The lockfile was not regenerated after adding puppeteer-core to
devDependencies, and `npm ci` requires the two to agree — so every job that
installs dependencies failed before running anything.
The smoke test was also placed in test/, which I described as keeping it out of
`npm test`. It does not: `node --test` globs that directory, so the runner
picked it up regardless of intent, tried to drive a browser as a unit test, and
failed. It now lives beside the server as smoke-ui.js, with a note saying why,
so the next person does not put it back.
Verified the way it should have been the first time: npm ci succeeds, native
modules still load, npm test is 807/807 with no browser involved, and
`npm run smoke` is 32/32 on its own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
A whole class of defect found today was invisible to the unit suite, to a
syntax check and to review, and appeared only in front of a browser: a context
menu whose only item read "schedule.ctx_new", pointer handlers stacking on every
calendar render so one drop fired five PUTs, and a week grid that scrolled
sideways on a phone. Nothing in the repo could have caught any of them.
This keeps the checks that earned their place and throws away the scratch
scripts around them. It boots a server, drives every view, and asserts each view
renders, none raises an uncaught error, no untranslated key reaches the screen,
the calendar binds its handlers once however many times it re-renders, and
nothing overflows horizontally at phone width.
Deliberately NOT part of `npm test`. It needs a real browser, which CI does not
have, so it is `npm run smoke` and exits 0 with an explanation when puppeteer or
Chrome is missing — a test that fails for want of tooling teaches people to
ignore failures. puppeteer-core rather than puppeteer, so installing it does not
pull down a private copy of Chrome; it drives whichever one is already there.
Verified both ways: 32/32 against current main, and it fails on the listener
stacking when that fix is reverted. The missing-key case is covered by the unit
guard instead, since a context menu only exists once it has been opened.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Three loose ends from the interface review.
Inviting a colleague is a core action and had no entry in the navigation at
all. The only route was an unlabelled icon beside the workspace name, or typing
the URL. There is now a Members item, translated, which resolves to the active
workspace so the static link needs no id. The Teams entry it sits near stays
hidden, since that feature is still switched off.
A native title= is hover-only, so the icon-only buttons — rename a wall, remove
a device from one, manage members — explained themselves on a desktop and said
nothing on a touchscreen. Long-pressing one now shows its label. The text was
already there and already translated; it simply had no way to reach a finger.
The last one is the bug that took a real screen dark. A device row can vanish
while its socket is still heartbeating, and the telemetry insert then failed a
foreign key. That throw was fatal in a way that is hard to guess: the
safe-socket wrapper reads a throwing handler as a broken one and disconnects
the socket server-side, and socket.io deliberately does not retry that kind of
disconnect — so the player sat doing nothing until a person reloaded it. A
heartbeat for a device that no longer exists is an ordinary race, not a fault
worth ending a connection over; the write is skipped and the register path
answers unpaired, which is the reply that actually helps the client recover.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
A title= is a tooltip the user reads and an aria-label is what a screen reader
says, but fourteen of them were hardcoded English. They were invisible to the
key checks added earlier precisely because they never call t() — so a French
user hovering the only route to workspace members read "Manage members", and a
German screen reader announced every modal's close button as "Close".
The user-visible ones matter most: the workspace switcher's Manage members and
Rename, the video wall's rename and remove, and the dashboard's select-for-wall.
All are translated into every active locale, along with the close buttons.
A test now rejects a capitalised literal in a title or aria-label, since that is
the shape this takes and nothing else catches it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
An audit of every view turned up two problems with the in-product help.
The tips only appeared on :hover. On a tablet or a phone there is no hover, so
the entire explanation layer was invisible to touch users — a large share of
the people administering signage — and unreachable from a keyboard. Tapping a
marker now opens it, Escape or a tap elsewhere closes it, and the marker is
focusable so Tab reaches it and a screen reader announces it. Bound once at the
document level and applied by observing the DOM, because views render from
about twenty call sites and modals appear later still; hooking each one would
have left the next new route silently unreachable again.
Four views had no tip at all. Playlists is the important one: a playlist is the
concept the reported confusion was actually about, and the page said nothing
about what one is or how it reaches a screen. Activity and Settings now have
one too. Help does not, because it is the help.
The schedule tip described a product that no longer exists — it said to click
Add Schedule, predating the drag, resize and right-click gestures. Rewritten.
All four are translated into every active locale rather than left to fall back
to English, since a tip falling back is a non-English user being handed an
English paragraph at the moment they are confused. hi.js stays deliberately
empty per the note in that file. Tests now check that every tip is translated
everywhere, and that a tip marker never names a string that does not exist.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
10pm to 4am is an ordinary signage schedule and the playback engine has always
understood it — schedule-eval treats an end before a start as a wrap. The
calendar did not. It computed four minus twenty-two, got negative eighteen
hours, and drew an eighteen-pixel sliver at 10pm with nothing at all after
midnight. The schedule played correctly while appearing broken.
An overnight window is now split into the pieces a week grid can draw: the part
before midnight on its own day, the part after it on the next, squared off
where they meet so they read as one window rather than two schedules. The
tooltip names the whole span, since neither half shows it alone. A Saturday
night spill is simply not drawn rather than wrapped round to Sunday, where it
would appear to have played six days early.
Dragging one is refused. A drag describes a window inside a single day, so
applying it to a wrap would clamp it into that day and silently destroy the
schedule — the same reason a recurring schedule's day cannot be dragged.
Verified in a browser against a real 22:00 to 04:00 schedule: 88px on Tuesday
night, 176px on Wednesday morning, alongside an ordinary daytime block.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
A widget playlist item carries its id in widget_id and has no content_id at
all. The player sent only content_id, so a widget play arrived with nothing
identifiable and was written with both columns null — and play_end bound
content_id to BOTH columns, so that row could never match itself and was never
closed or given a duration.
Nothing looked broken: a row existed for every play. It just named neither what
had played nor which widget, and never ended. Reports read empty for any screen
showing a widget, which is most of the interesting ones. Seen on a live screen
playing a single widget: one open row, both columns null.
The player now sends widget_id alongside content_id, and a name falling back
through the fields a widget item actually has, so the event records what played
even when neither id resolves. The server prefers an explicit widget_id and
keeps the old content_id sniff as the fallback for players that predate this,
so an older client that puts a widget id in content_id still attributes
correctly.
Found by reading a real screen's proof-of-play rather than the code. The first
attempt at the fix broke the statement outright — the explanatory comment was
placed inside the SQL template literal, where a JS comment becomes SQL, and the
server logged `near "/": syntax error` on every play_end. Comments now sit
above db.prepare(), with a note saying why.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Driving the app in a real browser showed a context menu whose only item read
"schedule.ctx_new". t() returns the KEY when a string is missing — it never
returns undefined — so a missing key renders literally, and the common
`t('x') || 'A readable default'` guard is dead code: the key is truthy, the
default can never fire, and the pattern hides the problem instead of covering
it. Every occurrence of it in the app was doing exactly that.
Nineteen strings were affected, most of them predating this work: fifteen in
the self-hosted update panel and four in video walls, all of which have been
showing raw keys to users. The intended text was recovered from the dead
defaults, so the wording is the authors' own, and the defaults are removed
rather than left to imply a safety net that does not exist.
A test now walks the views for the keys they actually ask for and fails on any
that English does not define, and separately rejects the `|| default` pattern.
Neither problem is visible to a syntax check, a unit test, or review — only to
someone looking at the screen — so the guard is the only thing that keeps them
from coming back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
A user reported not knowing how to get content onto a screen. There was already
onboarding — a modal wizard — but it is gated on a localStorage flag: skip it
once and it never comes back, and it never knew whether you succeeded at
anything. Someone who closed it was left with no thread to pull, which is
exactly what was described.
A second tour would repeat that mistake. Tours are dismissed and forgotten, and
they describe the product rather than the account. This is a checklist on the
dashboard that reads real state, so it cannot claim you have done something you
have not, it is still there tomorrow, and it names the one thing to do next
rather than everything the product can do.
The steps are the shortest true path to a screen showing something: connect a
screen, add content, put it in a playlist, send it to the screen. Only the last
one cannot be satisfied by creating an object and walking away — a screen has to
actually be pointed at something — so an account full of playlists with nothing
playing is correctly reported as unfinished, which is the failure that was
reported. Steps stay in dependency order, so nobody is sent to a page they
cannot use yet.
It disappears on its own once the first screen is live and can be hidden before
then, so it never nags someone who already knows the product. Once hidden or
finished it costs no extra request at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL