Every one of these fields existed in the schema, the API and the dashboard,
and every one was NULL or misleading on a BrightSign. The XT245 had 6000
consecutive telemetry rows with local_ip NULL while sitting at a perfectly
reachable 192.168.1.46, and reported "1026 MB" of storage for a 119 GB NVMe.
The host half (autorun.brs) does collect an address, but nothing the host
sends was arriving at all — proven by the storage figure, which was the
browser's cache quota rather than any disk. So the page has to read this
itself, which is also the half that can be delivered: st-bridge.js is served
per page load, while autorun.brs needs a release bump to reach a player.
It is Node's standard library, not a @brightsign module. The widget is created
with nodejs_enabled, so os and fs are simply there — this is what BrightSign's
own dev-cookbook does in html5-app-template (both the .ts and .js variants).
Looking for a platform module is the trap, and it cost most of a day:
@brightsign/networkconfiguration EXISTS but exposes only callback,
getNeighborInformation and enableLeds — no config reader. hostconfiguration
has getConfig()/applyConfig() but returns host settings (forwardingEnabled,
hostName, loginPassword, nameServers) with no address in them. Both enumerated
on the live player, because the JavaScript API doc pages 404 and BrightSign's
own roNetworkConfiguration page links to one of the dead URLs.
getCurrentConfig() is BrightScript-only.
local_ip os.networkInterfaces(), skipping internal and 169.254
ram_total/free os.totalmem() / os.freemem()
cpu_usage 1-min load average / core count, as a clamped percentage
uptime_seconds os.uptime() — the MACHINE, overriding the page's own
performance.now(), so a widget rebuilt by the watchdog no
longer hides weeks of real uptime
storage_* fs.statfsSync over the mounts under /storage, largest wins
(ours boots from NVMe with a dead card slot; others from SD)
Dashboard: the RAM and CPU cards were gated on "is this Android?", which was
right when Android was the only family that could measure them. They now
render for any player that reports the value, so a BrightSign gets them and
Android is untouched — including keeping its "--" cards when no reading has
arrived, since an empty card is a known state and a missing one reads as
"cannot". The BrightSign storage card loses its "player storage" caveat,
because the number is now the disk it always claimed to be.
Verified on the real XT245 (FW 9.1.93.2): 116.8 GB free of 116.8 GB, 2.68 GB
of 3.57 GB RAM, 3% CPU, uptime tracking the machine, local_ip 192.168.1.46 —
matching the address found independently by MAC-vendor scan, and a disk figure
matching the kernel's own block count.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
The dashboard has a "Local IP" field, the server stores it, the bridge relays
it and autorun.brs collects it — the whole path has existed since 1.9.29. It
has never once produced a value for a BrightSign. Our XT245 has 6000 telemetry
rows with local_ip NULL while sitting on a healthy PoE network at
192.168.1.46, and every other field in the same payload arrives.
Confirmed against the device itself over its DWS: the installed autorun.brs is
ours (61055 bytes vs 61058 in tree) and contains this exact code, so it runs
and yields nothing. Interface 0 alone is not enough.
Now walks every interface the platform documents — 0/"eth0", "eth1",
1/"wlan0" — instead of assuming the first answers. The string forms are the
point: per the Object Reference an INTEGER interface "must currently exist on
the player; otherwise the object-creation function will return Invalid", while
the string names carry no such condition.
And when nothing answers it now says so on the host log. Silence is what made
this invisible for a whole fleet: the column stayed NULL and read as a
server-side gap rather than a player that never sent anything.
Not fixed blind — the first attempt at this used roDeviceInfo.GetIPAddrs(),
which is ROKU's API. BrightSign's roDeviceInfo has no network method of any
kind; the string does not occur once in the published Object Reference. It
would have raised "Member function not found" from inside SendHostTelemetry,
once a minute, forever — while ostensibly fixing telemetry. Caught by checking
the docs before shipping, and now added to the deny-list in
brightscript-api-surface.test.js so the next person cannot repeat it. Verified
the entry bites: injecting the call fails that suite.
⚠️ Untested on hardware. BrightScript has no interpreter outside a player, so
this is docs plus block-balance checking. The XT245 is reachable at
192.168.1.46 (DWS on 8080, not 80) to confirm once the package updates.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
Three reports from the same operator, two of them the script describing a
state it never reached — the same shape as the first round of #245.
Guide missing sudo: frontend/guides/raspberry-pi-digital-signage.html said
`curl -sL … | bash` while the script's own header and its root check both say
`| sudo bash`. The script fails loudly with the right command, so nothing is
half-installed, but the guide should not have to be corrected by an error
message. Also documents the --player-only form, which the guide never showed.
MOTD advertised commands that mode did not create: section 11 creates
screentinker-status/update/logs only when PLAYER_ONLY is false, while section
12 wrote an /etc/motd listing all three unconditionally. A Player-Only Pi
therefore greeted its operator with three commands that were not on it, at
every SSH login. The command list is now appended per-mode.
The cheap fix would have been to print nothing on a player. That trades a
wrong banner for a machine nobody can inspect over SSH, so Player-Only now
gets its own screentinker-status (kiosk state, which server it points at, and
whether that server is actually reachable) and screentinker-logs (kiosk).
screentinker-update is genuinely not applicable — there is no local server to
update — and is not offered.
Wayland cursor never hidden: the launcher stated the compositor cursor config
was written "below when wayfire.ini exists". It never was — wayfire.ini and
hide_cursor each appeared exactly once in the whole script, both inside that
comment. unclutter is installed but only runs on the X11 branch, so a Wayland
Pi kept a mouse pointer on the sign while the install looked complete. Now
configures wayfire's hide-cursor plugin at install time, idempotently and
after backing the file up, and says plainly that labwc has no equivalent
rather than failing silently.
Tests: raspberry-pi-setup.test.js gains a check that no MOTD advertises a
command its mode does not install (both modes, extracted from the script
rather than re-typed), that a player is not left with zero diagnostics, and
that the Wayland cursor claim is backed by code outside a comment. Both
mutations verified to fail: putting screentinker-update back in the player
MOTD, and removing the hide-cursor write.
NOT fixed, and not guessed at: the ALT+F4-on-first-pairing symptom and the
reconnect storm. The crash-restore fix those would need is already in 1.9.33
and targets a different symptom, and `observed=6/5 per 10000ms` is six
reconnects in ten seconds, which matches neither the solo-widget cycle nor the
kiosk RestartSec=10. Both need the kiosk-side log — which, until this commit,
a Player-Only Pi had no command to read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
A declared capability set REPLACES the per-platform baseline rather than
merging with it, so anything the baseline grants and the player omits is a
control the operator loses by updating. Three were being lost.
- display.brightness: the per-window dim (setWindowBrightness) is Tier 0 —
no permission, no owner, no WRITE_SETTINGS — and MainActivity applies it
unconditionally. It was simply never declared.
- remote.screenshot / remote.stream: gated on the accessibility service,
while captureScreen() falls through to ScreenshotCapture.captureView,
a plain view draw with no permission check. A Tier-0 panel lost live view
and screenshots by updating, and a GRANTED MediaProjection never became a
capability either — consent given, capture working, server still refusing,
because nothing re-declared.
- system.device_owner: no player declared it, so the server accepted
system.kiosk as a stand-in for every Tier-2 command. Declaring the
canonical name makes refusals say what they mean; the stand-in can retire
one release after this reaches displays.
display.power stays conditional on purpose: screen_on works anywhere via a
wake lock but screen_off needs owner/admin/accessibility, and a control that
sleeps a panel it cannot wake is worse than no control. It is the sole entry
in the DELIBERATE set in player-parity-baselines.test.js.
Also fixes the capture-bootstrap gate in device-detail.js. It hung off
can('remote.screenshot'), which hid the button from exactly the panels that
need it. The gate is now Android-and-nothing-else, NOT "Android that lacks
capture": /api/devices/:id ships capabilitiesFor(), which flattens declared
and baseline into one array, and the android baseline contains
remote.screenshot — so a "lacks capture" test hides the button from all ~440
undeclared panels in the field. isAndroidDevice() mirrors platformFamily()
with all four signals in order; an Android-test-only helper classified every
Tizen TV as Android, since Tizen registers android_version 'Tizen 6.5'.
Tests: the suite could not see any of this. Mutation testing showed deleting
either capability line, or reverting isAndroidDevice to its buggy form, left
all tests green. Added an update-invariant test (declared set vs baseline,
with an argued exception list), a test that executes the shipped helper
rather than the harness stub, and a legacy-panel test using the shape the API
actually returns instead of one it never produces. All four mutations now
fail.
Verified on a real Android 16 device across all three tiers: Tier 0 captures
live video (no accessibility, no MediaProjection, no owner), Tier 1 gains
display.power via accessibility, Tier 2 declares system.device_owner and every
Tier-2 command delivers. An in-place upgrade from the pre-change build lost
nothing and gained exactly these three.
Baselines deliberately NOT moved — a baseline entry moves in the release
AFTER the one carrying the player fix, once it has reached displays.
Parity gaps 3 and 4 were implemented, audited and reverted; docs/player-parity.md
records why so the next attempt starts from the traps. Gap 3 (wiring "Force
update") meets an unbounded synchronous download against a 120s watchdog and a
3-attempt counter with no version binding, so three presses refuse a panel every
future version. Gap 4 (deferring to BS.capabilities()) removes working
screenshot/stream from diskless BrightSigns that capture to RAM, over-declares
transitions, and rides a probe timeout that discards a late answer permanently.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
A BrightSign XT245 on shipped 1.9.32 went dark and STAYED dark. The exit beacon:
crashed: Cannot access '_videoCompositingOk' before initialization @ player:3730:12
Boot restores the CACHED playlist and renders item 0 immediately, from a call site
~2300 lines above where `_videoCompositingOk` was declared. When that item was a
video carrying a transition, `isVideoBufferable` read the binding while it was still
in the temporal dead zone. A TDZ read is a THROW, not a `null`, so the player died
during boot.
The nasty part is the loop. The offending playlist came from the device's own
localStorage cache, so the player never stayed up long enough to receive a corrected
one -- every boot re-read the same poisoned cache and died the same way. Rebooting
the player, the one remedy an operator has, did nothing. Recovery took editing the
served player; nothing reachable from the dashboard would have helped.
Fixed by declaring the cache in State, above Boot, where no call path can reach it
early. Left a comment at the old site saying why it must not move back -- next to its
function is exactly where it looks like it belongs.
Not BrightSign-specific: any web-based player could hit it. Prod is not currently
triggering it -- the one exposed playlist starts on an image, and the video check
short-circuits before the read -- but that is luck, not safety. Reordering that
playlist, or a daypart making a video the first active item at boot, arms it for
those displays.
Found while testing hwz routing for video transitions; the crash is unrelated to
that work and reproduced on the unmodified released file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
Three small things off the device page.
The control row had margin-top but no margin-bottom, so the buttons sat flush on
top of the STATUS card and the destructive ones read as part of the status panel.
Freeze is the one with a decision in it: it holds the VIEW still and keeps
buffering underneath rather than pausing the stream. The moment you freeze a log to
read something is the exact moment the lines that explain it are still arriving, so
dropping them would throw away the part you were about to want. Resume replays them
in order. The held buffer is capped at the same 500 as the panel, and the status
text says how many are waiting -- otherwise a frozen panel is indistinguishable from
a device that went quiet, and silence reads as a symptom. Overflow says so too.
Copy takes what is on screen (not the held lines -- the paste must agree with the
panel) and stamps it with the device and an ISO timestamp, because a pasted log with
no device in it is a log nobody can act on. It falls back to execCommand when
navigator.clipboard is absent, which is every self-hosted dashboard on plain http:
that is not a secure context, and the other copy buttons in this app quietly do
nothing there.
Clear earns its place next to Copy: without it you always copy 500 lines of history
instead of the capture you just made.
The hint promised the stream "turns off on its own when the device reconnects",
which was never true and is not what happens now -- it turns off when you leave the
screen, and on the device after 30 minutes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
Found the day the live debug log started working, which is the only reason anyone
saw it. A BrightSign XT245 playing a 40s clip as a SINGLE-item playlist logged four
`Video error` events at every loop boundary and then three back-to-back "Playing:"
lines, with `play() rejected AbortError` and `muted-fallback play() also failed` in
between as the second mount aborted the first. On a one-item playlist that just
re-plays the same file, so it looked like nothing.
On a real playlist the identical storm skips one item per surplus event. Silently.
The operator sees a playlist that drops content and nothing says why. Same family as
234.
Two independent defects produced it:
1. `video.onerror` had no once-guard — its sibling in the buffered path has
`if (done) return`, this one didn't — so every event scheduled its own nextItem.
2. Every call site wrote `advanceTimer = setTimeout(...)` DIRECTLY. A second write
before the first fired ORPHANED the earlier timer instead of cancelling it: still
pending, no longer referenced, so renderContent's clearTimeout could only ever
cancel the last one. All the others fired. That made a dozen sites capable of
leaking a timer, not just the error handlers — so the fix is a scheduleAdvance()
helper that clears before it arms, and a test asserting nothing assigns the timer
directly ever again.
The four error handlers (buffered/non-buffered x video/image) had drifted apart
because they were four copies; they now share one mediaFailureSkip(), which also
reports the actual MediaError code. The old line logged the DOM event
({"isTrusted":true}) and never touched el.error, so the log could say a video failed
but never why.
Third guard: an element that is still playable is not a failure. `error` fires with
el.error set; an event carrying no MediaError against an element with frames buffered
ahead of it did not fail at anything, and discarding a healthy item on that basis is
worse than the event being reacted to. Anything genuinely unplayable (no MediaError
AND nothing decoded) is still skipped, so a broken clip can never stall the playlist.
Verified on the XT245: 150s of playback went from 2-3 advances and an AbortError pair
per loop boundary to exactly one advance and zero AbortErrors, and the surviving
diagnostic now names the real cause -- `code=3 DECODE`, four raw error events
collapsing to one reported failure.
All three guards are mutation-tested: removing any one of them fails a test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
The dashboard's per-device "Debug logging" checkbox has always sent a `set_debug`
command. The Android player honours it — DebugLog.* mirrors its tagged lines over
the device socket while the box is ticked. The web player never implemented the
command at all, so the panel opened, revealed itself, and streamed nothing but the
three unconditional reporters (sync, pip, zone). A display could be failing loudly
in its own console and look mute from the dashboard.
In a browser that is a nuisance — press F12. On BrightSign it is the whole
diagnostic surface: no console, no adb, no logcat, a panel on a wall.
Rather than hand-instrument eighty-seven call sites to match Android's tag by tag,
this streams the ring buffer the error trap at the top of <head> has always filled:
every console.log/warn/error, every uncaught error with file:line and stack, every
unhandled rejection, every failed resource load. Turning the stream on also REPLAYS
that backlog, so the operator sees the failure that happened before they opened the
screen — the case they actually came to investigate, and one no log tail gives them.
Replayed lines carry their real age, because the dashboard stamps on arrival and 200
lines would otherwise all claim to have happened this second.
The bracket prefixes the player already uses ([wall], [bs], [group-sync]) become the
tag column, so the panel reads the same shape as Android's, and the panel now colours
by level — all four rendered identically before, so the one line explaining the fault
sat in a wall of grey.
Bounded three ways, because this sink is fed by console.*:
- 40 lines/sec, over which lines are COUNTED and reported, not queued
- auto-off after 30 min, for the checkbox nobody unticks
- the dashboard also switches it off when the operator leaves the screen
The reentrancy guard in pushLog is not theoretical: the sink runs inside the console
wrapper, so a subscriber that logs anything would recurse until the stack gave out
and the player would die of its own diagnostics.
BrightSign host lines stand their direct emit down while the stream is on (the
console path already carries them) but still go out unconditionally when it is off —
the boot report is the one diagnostic nobody can ask for in advance, because it is
over before the operator has a device to open.
Verified on the XT245 on alpha: 34 lines across 7 tags, backlog replayed with real
ages, levels intact, platform line reporting BOS 9.1.93.2 / XT245 / 1920x1200.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
Reboot, screen off/on, launch player, force update and shutdown sat at the
bottom of the Info tab, below the info grid, the uptime timeline, the incident
list, the reboot schedule and the debug log panel. They are the actions someone
opens a device page to take, and reaching them meant scrolling past everything
that merely describes the display — worst on a phone, which is where an operator
standing in front of a dark screen actually is.
Moved to the top of the tab, directly under the diagnostics panel and above the
info grid. Still one wrapping row, so a narrow screen reflows instead of
clipping, and each button still renders only where the display can honour it —
the capability gating is untouched, so a panel that cannot reboot still shows no
reboot button.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
This platform has never been able to screenshot itself. Video decodes onto a
hardware plane the DOM cannot read, so an in-page canvas composite comes back
with the content missing — the panel reported "Video is playing on the hardware
plane and cannot be captured" while playing perfectly.
@brightsign/screenshot composites the video and graphics layers, which is
exactly the thing a canvas cannot do. It is reached through the same Node
require() the widget already exposes — the one that also makes `module` visible
to classic scripts, which is what broke the shared UMD modules on this platform.
The same quirk caused that bug and enables this fix.
WHY THIS WORKS WHERE THE LONG WAY ROUND DID NOT.
The obvious route was to ask the HOST to capture through the player's own DWS,
because BrightScript can reach it. That is a dead end here: page->host messaging
stops working after page load, so the request never arrives — instrumenting the
host to echo the reason of EVERY roHtmlWidgetEvent produced nothing at all while
the page was posting. This API needs no host, no messageport and no DWS, so none
of that is in the path. The host route stays as a fallback for firmware without
the module, but it is no longer how this works.
The API writes a FILE rather than returning bytes, so it is read straight back
with Node's fs and sent over the socket the player already has.
TO RAM, NOT TO FLASH. The remote-control view drives this once a second, and a
screenshot per second written to the boot flash is a wear-out mechanism with
nothing to show for it: the file is read back and deleted microseconds later, so
it never needs to be durable. tmp is tried first and real storage only as a
fallback for a unit that does not present it. The directory must already exist
or the capture fails, so each candidate is checked rather than assumed.
Ordering is part of the fix: the native API is tried BEFORE the host route,
because trying the dead end first would spend an operator's patience on a 15s
timeout before reaching the path that works. Every failure still falls through
to the canvas, so a capture never comes back blank.
Remote streaming inherits all of it — startStreaming already drives the same
captureAndSend — so the live view now shows real video rather than a card
explaining why it cannot.
Verified on the hardware: a real 960x540 frame of the playing video, captured by
the player, delivered to the dashboard over its own socket.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
Server side of the inverted capture path. The host half is not here — see the
end of this message.
Every other player is TOLD to capture: the server emits device:screenshot-request
over the device socket and the page photographs itself. A BrightSign cannot
photograph itself. Video decodes onto a hardware plane the DOM cannot read, so
an in-page canvas returns a frame with the content missing — which is why that
platform has been answering screenshot requests with a card explaining that the
video is uncapturable. Only the host, through the player's own DWS, can get a
real frame.
The obvious way to ask the host is through the page, and it does not work. On an
XT245 (BOS 9.1.93.2) page->host messaging is dead after load: instrumenting the
host to echo the `reason` of EVERY roHtmlWidgetEvent produced nothing at all
while the page was posting, though the boot-time probe round-trips. The registry
is not an alternative either — a running BrightScript does not observe registry
writes made by anyone else, proven by writing the key externally through the DWS
and watching the host ignore it.
What the host CAN do is HTTP; it already fetches its own package updates that
way. So the direction is inverted: the request waits here and the host collects
it. The image comes back over a plain POST, which means a capture will work even
when the page is wedged — exactly when an operator most wants to see the screen.
Held in memory on purpose. A capture request is worthless a minute after it was
made — someone clicked a button and is watching for the result — so persisting
it would only add a way to deliver a stale screenshot after a restart. Bounded
and TTL'd so a fleet going offline mid-request cannot grow it, and a repeat
request REPLACES rather than queues so a 1fps stream builds no backlog.
Authenticated with the same device_id + device_token pair the socket uses.
/api/brightsign/package is public because a player fetches it before it has any
identity; a screenshot is a picture of a customer's screen and belongs to one
display.
deviceSocket now exposes ONE ingestScreenshot() used by both the socket handler
and the HTTP route, so a BrightSign screenshot reaches the dashboard by exactly
the route every other player's does rather than becoming a second, subtly
different feature. Note those exports must be attached AFTER
`module.exports = function setupDeviceSocket`, which reassigns the object —
attaching above it silently wipes them, which cost a debugging round.
NOT INCLUDED, deliberately: the host-side poll. Adding it to autorun.brs's main
loop kills the BrightScript script within seconds of boot — the page keeps
playing, because the widget outlives the script, so from the dashboard it looks
healthy. Cause unidentified; BrightScript runtime faults do not reach
/api/v1/logs, so there is no error text to read. Half a feature that silently
takes down the host is worse than none, so the server waits for a host that can
safely ask.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
A BrightSign screenshot showed a card reading "Video is playing on the
hardware plane and cannot be captured" while the very same capture worked
perfectly from the player's own DWS Snapshots tab. The player was asking the
wrong port.
autorun.brs hardcoded http://localhost/api/v1/snapshot/ — port 80. The DWS port
is configurable and BSN/Supervisor-provisioned players are commonly moved off
it: the unit this was found on serves DWS on 8080 with nothing listening on 80
at all. Every host capture therefore failed to connect and fell through to the
in-page canvas, which cannot read the hardware video plane — so the fallback
produced an honest-sounding message about the video, and the actual fault (a
port) never appeared anywhere.
The port lives in the networking registry section as http_server, which is the
same place the DWS itself is configured from, so that is where this reads it.
80 remains the default when the key is absent.
Also 127.0.0.1 rather than "localhost": a name has to be resolved, and if that
resolution answers ::1 first the connection goes to an address the DWS is not
listening on. A literal cannot be resolved wrongly.
This is necessary but NOT sufficient — the capture still does not work on that
hardware, for an unrelated reason recorded in brightsign/README.md: the page
cannot reach the host at all after load, so the Sub that would use this URL is
never entered. Fixing the port anyway, because it would have broken the capture
a second time the moment the messaging problem is solved.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
Transitions have never run on BrightSign, and it was never a GPU problem.
`transitionRuntimeReady()` is a presence check on three globals and touches no
WebGL at all. A BrightSign roHtmlWidget is created with `nodejs_enabled: true`,
which puts Node's `module` into classic-script scope — so every shared module
that exported with an `else` took the CommonJS branch and never assigned its
browser global. The runtime was absent before WebGL was ever asked a question.
This is deducible from the fleet without touching the hardware: the player
pushes system.reboot / display.power / display.resolution / system.self_update
only behind BS.hasHost(), which needs require('@brightsign/messageport') to
resolve. Our XT245's stored capability row carries all four, so Node
integration was live in that page, so the CommonJS branch was taken.
Transitions are the least of it. schedule-eval.js had the same shape, and the
player falls back to "always active" when ScheduleEval is missing — so per-item
DAYPARTING silently stopped applying on that platform and scheduled content
played outside its window with nothing in any log. player-media-health.js the
same. Four files, all fixed by exporting to BOTH targets rather than either/or.
media-mute.js, orientation-style.js and wall-geometry.js already assigned their
globals in a separate unconditional block and were never affected; the audit
that reached me claimed all seven, and reading them is what separated the four
from the three.
THE GUARD, WITHOUT WHICH THE ABOVE IS A REGRESSION.
Restore the globals alone and BrightSign starts attempting video wipes it
cannot supply. On a hardware video plane drawImage(video) succeeds, throws
nothing, and paints a fully TRANSPARENT frame — so the wipe fades from nothing,
behind a video plane that is still lit. Worse than the hard cut it replaces.
The discriminator already existed: videoFrameIsCapturable() probes ALPHA, so a
genuine fade-to-black still reads as captured. It was wired into the screenshot
path and not this one, which asked isMediaReadable() — a CORS question, "am I
allowed to read this", not "did any pixels arrive". Both the outgoing frame and
the incoming warm-play snapshot now consult it, cached per platform, defaulting
to available while undetermined so a cold start is not crippled.
Net effect on BrightSign: image-to-image transitions light up, anything
involving video hard-cuts honestly, and dayparting starts working.
Full video transitions are reachable later — BrightSign documents that video
"captured as a canvas for WebGL processing must be routed to the GPU" via a
per-element hwz="off", which keeps hardware decode at an 8-bit/1080p ceiling.
That needs the hardware to validate and is not in this change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
Five defects from #245, all found by a user on a Pi 5 because nothing in this
repo has ever executed either of these scripts.
THE MENU IGNORED THE OPERATOR.
The documented install is `curl … | sudo bash`, which makes stdin the SCRIPT.
bash has consumed it by the time any `read` runs, so every prompt got EOF
instantly: the mode menu "chose" All-in-One without anyone touching it, and
Player-Only could not be reached that way at all. Reported as the menu being
skipped, because it was. Prompts now read the controlling terminal, and when
there genuinely is no terminal the script SAYS which way it went instead of
letting an empty answer look like a decision.
X11 TOOLS ON A WAYLAND PI.
Pi 5 on Bookworm defaults to Wayland, where xset, unclutter and xrandr are
no-ops that log an error and do nothing. The Pi therefore got no blanking
suppression and no cursor hiding while looking configured. The launcher now
detects the session and branches: X11 keeps what it had, Wayland gets wlopm and
--ozone-platform=wayland, and the compositor-side alternatives are documented
rather than silently assumed.
THE KEYRING PROMPT.
"Choose password for keyring" on every boot is Chromium reaching for
gnome-keyring. A kiosk has nobody to answer it. --password-store=basic.
THE WHITE PAGE ON EVERY BOOT BUT THE FIRST.
Chromium restoring a session it believes crashed — a kiosk is killed by
shutdown and never exits cleanly, so it returns with a restore surface over the
player. That is why ALT+F4 "fixed" it: it closed the surface, not the player.
Rewriting exited_cleanly was never enough on its own because the previous window
set is replayed from Sessions/, so that goes too.
THE BANNER SPELLED THE PRODUCT WRONG.
The ASCII art read "Scree Tinker" — the n was missing, and it is the first thing
anyone sees over SSH.
Also answered the reporter's Overlay FS question in the README, including the
part that bites: an All-in-One Pi IS the server, so an overlay discards the
database, uploads and JWT secret at every reboot. Safe for Player-Only; needs
DATA_DIR moved off the overlay otherwise.
The new test generates the kiosk launcher exactly as the installer writes it and
runs bash -n over it, because `bash -n` on the outer script cannot see inside a
heredoc — a syntax error in there is just text until it reaches a screen.
Reported-by: carloblu74
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
The backfill is right, and it lands on a path that could not carry it yet.
deriveMediaMetadata spawned ffprobe and ffmpeg with execFileSync, each with a
15s timeout. Synchronously, those two calls stop the whole server for their
duration: no heartbeats, no socket traffic, no HTTP. That was survivable while
the only caller was a human-initiated upload — one file, someone waiting on it,
bounded by their patience.
The boot-time sweep removes every one of those mitigations. It walks the entire
library, unattended, on a server with live panels, once per boot. A library of
video rows therefore becomes a per-file event-loop stall, which is #240's
failure mode — blocked loop, missed heartbeats, panels marked offline,
reconnect churn — arriving from our own maintenance instead of from a
checkpoint. We spent yesterday removing one of those; this would have added
another, on a schedule.
So both spawns are awaited instead of blocked on. Both callers already awaited
deriveMediaMetadata, so this is invisible to them, and the ingest path stops
freezing the server for the length of an upload's probe as a side benefit —
that sync ffprobe has been known tech debt for a while.
Timeouts are unchanged and still asserted: async is not a licence to hang, or
one wedged file stops the sweep dead instead of moving on.
Also applied the PR's own phantom-path discipline to the video branch, which
still named its thumbnail before the encode: a failed ffmpeg left the row
claiming a file that was never written, which is the exact bug the image branch
was fixed for two commits earlier.
The new test measures the property rather than grepping for it — a timer keeps
ticking across a real spawn — so a future edit that reintroduces a sync call
fails here rather than in a customer's fleet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
Two field-reported gaps, unrelated except that both are about being able to
read something off a screen.
A PANEL'S IPv6 WAS NEVER COLLECTED, LET ALONE SHOWN.
DeviceInfo.getLocalIp() filters to Inet4Address, so a v6-only panel reported no
address at all and the dashboard rendered a dash for a screen that was perfectly
reachable. It now reports both stacks in their own fields: a dual-stack panel
genuinely has two addresses and either may be the one you need, so collapsing
them into one column would make it mean "whichever interface enumerated first".
Link-local (fe80::/10) is deliberately excluded. Every interface has one, they
tend to enumerate first, and none can be dialled without also knowing the zone
index — so admitting them would fill the field with a string nobody can paste
anywhere and hide the address that works. Any %iface suffix is trimmed for the
same reason. The 45-char cap the writer already applied is exactly the longest
legitimate IPv6 text form, so it needed no change.
The dashboard card renders only when a panel actually has a v6 address, rather
than showing an empty row to the overwhelmingly v4 fleet.
THE PAIRING CODE DID NOT SCALE, WHICH IS WORST WHERE IT MATTERS MOST.
Every size on the pre-playback screens was a hard-coded pixel value. A CSS pixel
covers a quarter of the screen area on a 4K panel that it does on 1080p, and a
sixteenth on 8K — so the 72px code that fills a 1080p screen is a smudge on the
4K wall it was installed on, which is where signage actually goes.
What has to stay constant is ANGULAR size, so the root font size is now
viewport-proportional and everything on those screens is a rem against it. The
code holds 6.67% of screen height at every resolution: 72px at 1080p — bit for
bit what it renders today, so nothing changes for the existing fleet — 144px at
4K, 288px at 8K. Verified in a browser rather than by arithmetic: at a 1409px
viewport the root computes to 13.0473px, which is 0.926vmin to four decimals.
vmin, not vw, because portrait-mounted panels are common here and vw would
render a 1080x1920 screen at half size. Clamped at both ends so the dashboard's
preview iframe stays legible instead of microscopic and an ultrawide does not
get silly. Applied to the web player (which BrightSign also runs) and to Tizen,
where a 1920x1080 logical viewport makes it arithmetically identical to the
values it replaces — the point being the panels where it is not.
A test asserts the scaling cannot reach playback content: the whole safety
argument is that only the chrome uses rem, and a stage or zone rule adopting it
would start resizing CONTENT, which is a worse bug than the one being fixed.
Android is untouched — its pairing code already autosizes within a dp-scaled
layout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
The runtime image never had ffmpeg, so every Docker deployment silently
lost video thumbnails and durations; the README never mentioned it for
bare-metal installs either.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0131RYmVh8ePEhparD3mXBhU
Ingest-time thumbnail generation is best-effort by contract, so a row that
misses it stays bare forever: video uploads on a host without ffmpeg (a
SYSTEM dependency nothing surfaced), or content from before thumbnails
existed. Operators read that as "thumbnails don't work".
Two additions. A [MEDIA] startup diagnostic (async probe, cached) states
loudly whether ffmpeg/ffprobe were found, mirroring the [EMAIL] block. And
a once-per-boot sweep re-derives metadata for local image/video rows with
no thumbnail — serial, paced, delayed past boot, unref'd. The sweep's row
UPDATE re-checks that thumbnail_path is still empty so it never clobbers a
thumbnail written concurrently by the replace flow, removes its just-written
file when the row vanished mid-derive, salvages probed dims/duration even
when the thumbnail itself failed, and stops after 25 failures per boot so a
library of undecodable clips can't turn every restart into subprocess churn.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0131RYmVh8ePEhparD3mXBhU
deriveMediaMetadata assigned thumbnailPath before sharp wrote the file, so
a failed write (corrupt image, disk error) returned a name for a file that
was never created. Ingest then stored that phantom thumbnail_path and the
dashboard requested it forever as a broken image. Assign only after the
write succeeds; the video branch already nulled its path on failure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0131RYmVh8ePEhparD3mXBhU
helmet already sets X-Frame-Options, HSTS, CSP, etc., and manages them
per route (widget/kiosk renders and the device preview remove or relax
X-Frame-Options so they can be framed). A proxy-level header block adds
a second copy, and browsers treat conflicting duplicate X-Frame-Options
values as deny - which blanks the same-origin /player iframe behind the
dashboard's Preview button. Seen in the wild behind a Caddy config that
added X-Frame-Options: DENY on top of the app's SAMEORIGIN.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0131RYmVh8ePEhparD3mXBhU
The server already acks dashboard:request-screenshot with
{ delivered, reason } (offline / unsupported via the capability
registry), but no dashboard sender passed a callback, so clicking
Screenshot on an offline device or an unsupporting player type showed
"Screenshot requested" and then silently did nothing.
requestScreenshot() now takes an optional callback using the same
.timeout(5000) pattern as sendCommand(); the device-detail Screenshot
button passes one and toasts the verdict (requested / unsupported /
offline / no response). The dashboard grid and the 5s Now Playing poll
keep firing-and-forgetting - no behavior change there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0131RYmVh8ePEhparD3mXBhU
Two changes that are really one idea: the parity model treated all four
players as if they update the same way, and they do not.
WEB AND BRIGHTSIGN GET audio.volume BACK.
The audit removed it because v1.9.28's index.html contained the string
set_volume zero times, and because the handler read payload.value while the
dashboard sends { level }. The second reason 1.9.31 fixed. The first was
reasoning from the wrong artifact: this player is SERVED BY THE SERVER, so a
browser panel runs whatever build is answering it, not the release its row was
created under. There is no browser panel stuck on the v1.9.28 player once the
server moves — and prod moved tonight. The slider works on those displays right
now while the baseline says it does not, so the dashboard is hiding a working
control from every display that declares nothing.
BrightSign comes with it, on the same served player. The unit-specific doubt is
whether a hwz player's media element is reachable at all — and that is already
answered by audio.mute, which this baseline has always claimed: set_volume
reaches setMediaVolume() and device:mute-changed reaches currentVideoEl.muted,
same element, same path. If hwz swallowed one it would swallow both.
TIZEN DOES NOT COME WITH THEM, AND THE TEST NOW KNOWS WHY.
A .wgt sits on the panel until somebody updates it. Cutting 1.9.31 put nothing
on any screen, so an un-updated Tizen panel still has the broken handler and
moving its baseline would resurrect the dead slider on real hardware.
The test could not express that. It judged every family against "shipped
source", resolved as the newest tag — which is HEAD on a release commit, so
tagging 1.9.31 flipped all four biconditionals at once and demanded a baseline
change for displays that cannot have the fix yet. Green tree, red build, naming
a baseline, with nothing in the diff to explain it. main would have gone red on
the next commit whatever it contained; #242 just got there first.
So the two families are now modelled separately. Server-served: judged against
the working tree, both directions, because both are decidable from the build we
are about to serve. Device artifact: judged against the previous release, and
only in the over-claim direction — "the baseline claims it, so the shipped
player had better implement it" is always true and worth failing on, while
"HEAD gained the handler, so add it" is a guess about how many panels have
updated. The cost is that a stale entry can outlive the artifact reaching the
fleet; that is a judgement about screens, so a person makes it in
player-capabilities.js and records why.
player-capabilities.test.js carried the same stale reasoning hardcoded, and
docs/player-parity.md stated the old facts in four places — a parity matrix
that lies being the exact failure this whole model exists to stop.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
It still read "escalate >16MB or 3 growing runs" after #240 added a size floor
and a cooldown to that second rule. Growth-across-three-runs on its own is
exactly the half that no longer holds, so the line described a checkpointer
that does not exist — and it is the line an operator reads to learn the policy.
During an incident it would send you hunting for a blocking checkpoint that the
new gates had in fact suppressed.
[wal-checkpoint] off-thread checkpointer started (PASSIVE every 15000ms;
blocking TRUNCATE when the WAL exceeds 16MB, or after 3 growing runs but only
at >=8MB and at most once per 300s; respawn max 5/60000ms)
A test now asserts the line reports every knob that governs the decision, since
nothing else keeps a log string and the rule it describes in step. Writing it
caught its own bug first: anchoring the slice back to `return worker;` matched
the idempotence guard at the top of startWalCheckpointer(), not the log below
it, so the window was empty and every assertion passed vacuously.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
Cutting 1.9.31 turned a green tree red, and the failing assertion named a
baseline rather than the tag that caused it.
"Shipped source" was resolved as the newest v* tag. That is wrong at exactly
one moment, and it is a moment that arrives at every release: on the release
commit the newest tag IS HEAD, so shipped source becomes the working tree,
every biconditional inverts, and the build demands BASELINE.web gain
audio.volume — for displays that cannot have the fix until this very release
reaches them. Tagging a release should not be able to change what the release
is allowed to contain.
A baseline describes an UN-UPDATED display, so the source it is judged against
is the release BEFORE the one being cut. Skip any tag pointing at HEAD and use
its predecessor: v1.9.30 here, and the newest tag as before during ordinary
development.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
main has been red since 4f7b4e3 for a reason visible nowhere in its diff.
The player-parity baselines describe what an UN-UPDATED display can do, so
they are judged against the SHIPPED source — `git show <latest tag>:…` —
rather than the working tree. actions/checkout defaults to a shallow clone
with no tags, so that lookup found nothing and the suite fell back to the
working tree. In a tree where today's QA had just fixed the players'
set_volume payload bug, the biconditional then demanded that
BASELINE.web/tizen gain audio.volume — for displays that cannot possibly
have the fix yet. Green locally, red in CI, and the failing assertion names
a baseline rather than the checkout that caused it.
So fetch the tags in the test job, and make the biconditionals SKIP when
there are none instead of asserting against the wrong source. That fallback
was never a slightly-early assertion; it was an inverted one. A skipped
assertion announces itself, a wrong one does not.
Verified both ways: with tags 15/15 pass, in a tagless shallow clone 13
pass + 2 skip + 0 fail (previously 1 fail).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
Bold reported loop lag that grew with uptime and reset on restart. The
signature they saw — mean = p50 = p99 = max, identical to two decimals —
is not a fixed cost paid on every cycle. It is what an IntervalHistogram
window reports when it recorded exactly ONE delay: the mean is the raw
value, and every percentile returns the bucket ceiling above it.
Reproduced against their exact numbers (1329.07 / 1329.59). So the loop
took one long turn that swallowed the sampling second, episodically —
which is what they later confirmed independently.
The turn is ours, and it is now measured rather than theorised. Probing
the real worker against a real WAL with one reader mid-transaction: a
single main-thread write blocked for 4,936ms behind the worker's
wal_checkpoint(TRUNCATE), which then reported WAL 8.8MB -> 8.8MB. TRUNCATE
is the blocking form and its locks are held ACROSS connections, so moving
it to a worker kept the fsync off the loop but not the lock; and it does
not throw when it cannot get those locks, it returns busy=1 having sat on
SQLite's 5s busy timeout and reclaimed nothing. Five seconds of stalled
loop for zero benefit, and silent.
It was reached far too easily. The rule was "escalate if the WAL grew
across three consecutive 15s runs" — which any sustained 45-second write
burst satisfies. A customer's fleet powering on in the morning does it
daily.
Two gates, because either alone leaves the hole open. A size FLOOR, so a
WAL in the lower half of its budget can't buy a blocking checkpoint it has
nothing to reclaim from. And a COOLDOWN, because the floor alone fixes
nothing for Bold — their WAL already sits at 6.2MB against a 16MB
high-water, above any sane floor, so every burst would still escalate.
However long the pressure lasts, our own maintenance may now stall the
loop at most once per window. The high-water rule bypasses both and is
untouched: a runaway WAL is the one case worth blocking for, so the
"WAL cannot grow forever" invariant is exactly as strong as before.
A busy TRUNCATE now says so in the log instead of reading like a success.
Also softened the adjacent path: when the worker is declared
unrecoverable, engageFallback() re-arms inline autocheckpoint on the main
connection — a state that is STICKY for the life of the process, i.e.
exactly the shape of "degrades with uptime, a restart fixes it". It used
to also run an unconditional main-thread TRUNCATE on the way in; that now
happens only when the WAL is genuinely over high-water, and the fallback
state is served on /api/status rather than being inferable only from a log
line that may have rolled.
Telemetry, so the next report is self-explanatory: loop_lag carries
`samples` (~50 when healthy, 1 when a single turn swallowed the second),
`tick_gap_ms` measured on the WALL CLOCK independently of the histogram,
and `worst_tick_gap_ms`/`worst_tick_at` — monotone, so five-minute polling
can no longer miss an episode.
Band semantics are deliberately unchanged. A one-sample window during a
real stall is the correct trigger for the shed valve; suppressing it would
blind the protection at exactly the moment it is needed.
Separately, device_telemetry gets the age sweep it never had. The
per-heartbeat row cap only ever trims the device whose heartbeat is being
handled, so a device that STOPS reporting leaves its rows behind forever.
The new sweep is per-device (rides idx_telemetry_device rather than
scanning), chunked and yielding like the device_status_log one, and
defaults to 30 days to match the uptime report's own default window — so
it cannot remove rows that report would have shown.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
GetStorageStatus() is documented for SD:/SSD:/USB: only, so it can never confirm
internal flash, and roStorageHotplug may be absent entirely. Gating the probe on
it made 'cannot say' read as 'no disk': a player with an NVMe reported 1025 MB,
which is the widget's cache quota arriving through the page-side fallback.
roStorageInfo is asked directly as a second pass, with the mount check kept first
so a removable volume still wins over internal flash.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
screen_off blanks a fielded panel for real (owner/admin FORCE_LOCK, else the
accessibility lock); screen_on is a logged no-op. One capability renders both
dashboard buttons, so withholding the pair to hide the dead ON button also takes
blank-at-night — the half that gets scheduled — away from every panel that has
not updated. Panels that have updated declare for themselves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Two tests disagreed after the QA merges, and both were right about their own
half — which is what made the disagreement worth resolving rather than silencing.
A baseline describes what an UN-UPDATED display can do. The baselines were
justified against `git show v1.9.28:<source>` and then asserted against the
working tree, so the moment a player's payload bug was fixed the biconditional
demanded a baseline change for displays that cannot possibly have the fix yet. A
baseline entry moves when a fix SHIPS. It now reads the newest release tag, and
falls back to the tree when tags are unavailable (a shallow CI clone), because a
missing tag is a worse reason to fail a build than a slightly-early assertion.
While fixing it the helper threw a ReferenceError — the require was missing — and
its own broad catch swallowed it and quietly compared against the working tree
anyway. The catch now rethrows ReferenceError and TypeError. A fallback that
hides a programming error is the same failure shape as everything else this QA
pass found.
The BrightSign assertion encoded the older, more generous baseline: reboot needs
the BrightScript host bridge, and an undeclared unit is precisely the one we
cannot know has it. What that test is really pinning is that the row still
classifies as brightsign rather than decaying to `web` — so it now asserts that,
plus the video playback that is genuinely safe to assume.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL