Commit graph

15 commits

Author SHA1 Message Date
ScreenTinker 5fe55307ba brightsign: declare capabilities from real hardware state
The dashboard offered every control to every display. This makes the
BrightSign player answer for itself, at runtime, rather than from a
per-platform table.

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

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

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

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

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

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

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

Tests cover the storage split, the hostless case, the sync floor, the
never-declared set, and that every declared string is in the server's
vocabulary — a typo there would silently disable a control fleet-wide.
2026-08-05 14:12:44 -05:00
ScreenTinker 1c7d5f1359 Rotation on BrightSign must rotate the output, not the DOM
Audited rotation across all four players after reports it misbehaves.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 12:49:26 -05:00
ScreenTinker 9c04e2c113 Merge: BrightSign offline content caching and package self-update 2026-08-05 10:20:50 -05:00
ScreenTinker 8fd6eb75d5 BrightSign: cache content for offline, and let the package update itself
Two gaps that both end the same way — a panel nobody can fix without a van.

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

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

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

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

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

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

998 tests pass (was 954).
2026-08-05 10:09:00 -05:00
ScreenTinker 5a7277523a Wire BrightSign native sync end to end, chosen per group
st-sync.js wrapped SyncManager but nothing drove it. The player now does: the
leader opens a new sync session on each advance, and every member — the leader
included — binds the video with attachVideo() on a NEW id only.

The leader binds from its own broadcast rather than at announce() time on
purpose. Starting when it announces would put it ahead of its followers by the
width of the network, which is the one desync nobody would think to look for
because the leader always looks correct.

Item selection stays clock-derived under both backends. Native sync replaces
only the seek/nudge drift correction, because setSyncParams has the element hold
its own alignment and correcting it ourselves would fight the platform — every
frame we moved is one it then has to undo. Keeping selection on the shared clock
is also what keeps images and widgets, which have no setSyncParams, advancing
with the videos instead of drifting off alone.

LEADER RULE: reuse the existing election (resolveGroupLeader) rather than adding
a column. It already resolves pinned-if-online, else first online member on the
shared playlist, else first by id — deterministic, stable, and already what the
group-sync payload reports. A second mechanism could only disagree with it.

Added on top: a group whose elected leader is OFFLINE falls back to our
protocol. Ours is leaderless and carries on; native sync has exactly one
broadcaster, so those members would sit waiting for an announcement that never
comes, with the dashboard showing a healthy group throughout.

device_groups.sync_backend ('auto'|'screentinker'|'brightsign') is the operator's
REQUEST; the answer comes from the existing pure resolveSyncBackend() so the
players, the dashboard and the stored setting cannot disagree. The resolved
backend, reason and downgraded flag ride in the group_sync payload and in the
group API, and the dashboard shows the refusal reason instead of a setting that
quietly isn't in force. An unrecognised value is rejected rather than stored,
because the resolver reads anything unknown as 'auto' — a typo would otherwise
return 200 and run a different protocol than the UI displayed.

FIXED WHILE HERE: the player re-entered group sync only when the group ID
changed, with a comment noting the clock protocol has no leader role. Native
sync has one, and neither a protocol switch nor leadership moving alters the
group id — so a player promoted to leader kept behaving as a follower, nobody
announced, and the group sat unsynchronised. The re-enter key now includes the
backend and the leader flag.

971 pass (+17).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 10:06:18 -05:00
ScreenTinker 141deb97a5 screen_off must tear the video down — a DOM overlay cannot cover a hardware plane
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
2026-08-05 09:40:17 -05:00
ScreenTinker c743aa4b81 BrightSign: real display power, reboot and volume — command parity
The web player handles four of the ~20 fleet commands, because a browser tab
genuinely cannot do more. A BrightSign can, and was inheriting the browser's
limits for no reason.

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

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

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

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

954 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 09:26:23 -05:00
ScreenTinker f86195df53 BrightSign: autorun.zip installer, built and shipped with every release
Four loose files that must all land intact, in the right place, is a poor way to
hand someone a player. autorun.zip is one file: drop it on the root of a
player's storage, power-cycle, and autozip.brs unpacks it in place and reboots
into the player. A half-copied set of loose files boots into something broken; a
half-copied zip simply fails to extract and leaves the player as it was.

Two rules the format imposes, both of which fail SILENTLY when broken, so the
build script asserts them instead of trusting them:

  - the archive must expand to files at its root, with no wrapper directory. A
    player extracts to the storage root, so a nested folder puts autorun.brs
    somewhere the player never looks and the card appears to do nothing.
  - autorun.brs must not sit next to autorun.zip on the storage root; its
    presence stops the zip being processed at all.

autozip.brs renames the archive to autorun.zip.done after a successful extract,
which is what makes it idempotent — without that the player extracts, reboots,
extracts, reboots, a loop indistinguishable from a hardware fault. A FAILED
extract deliberately does not rename, so a truncated copy gets retried once
someone replaces it rather than being skipped forever.

It is volume-aware for the same reason autorun.brs is: a player may be booting
from internal flash because its card interface is dead, and extracting to "SD:/"
on such a unit writes to a volume that does not exist.

--server rewrites screentinker.json in the staging copy so a batch can be imaged
for a specific instance without hand-editing anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 00:06:00 -05:00
ScreenTinker 4e625abf50 BrightSign: boot from internal flash, proven on hardware
An XT245 with liquid-corroded microSD lines could not read any card, in any
format, with known-good code — the kernel log shows the mmc1 host probing at
400kHz and no card ever answering, while mmc0 (eMMC) is healthy. That unit
turned out to be fully deployable anyway: the player boots FLASH:/autorun.brs
straight from internal storage.

    Loading 'FLASH:/autorun.brs'
    BSPLAY: https://screentinker.com/player?platform=brightsign&model=XT245

So the card is not the only path, and a dead slot is not the end of a player.
Files go to /storage/flash over SFTP and the player runs them on the next boot.

The first attempt failed because the script hard-coded SD: for its own assets:
it loaded from flash and then could not find index.html. StorageRoot() now
probes for FLASH:/autorun.brs and falls back to SD:, and every path that reads a
sibling file — screentinker.json, offline.html, the crash-dump directory — goes
through it.

selftest/ is the bisect that settled the hardware fault: the dev-cookbook's own
html-starter pattern, so the script is not a variable. When known-good code
failed identically, the medium was proven at fault rather than our port.

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

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

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

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

948 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-04 21:43:42 -05:00
ScreenTinker bc68cd2752 Drop a stale README claim — the bridge is wired into the player
The "not done yet" list still said the player does not load st-bridge.js or
honour ?platform=brightsign. Both landed in ce854ff. Replaced with what is
actually outstanding: nothing server-side consumes the bs_* fields the player
now reports.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-04 21:27:53 -05:00
ScreenTinker fa68c8b7e3 Record the SyncManager API — the brightsign backend is no longer a blank
The README claimed the runtime sync API was undocumented. It is not in the MCP
doc set, but docs.brightsign.biz/developers/syncmanager and the dev-cookbook
syncmanager-js example document it fully, so that claim was wrong and is now
replaced with the actual contract.

The useful discovery is that it is pure JavaScript on the standard <video>
element: setSyncParams(domain, id, iso_timestamp) followed by load()/play(),
after which the element handles ongoing synchronisation itself. No BrightScript
round-trip, so it drops into the existing player.

Three constraints worth having written down before anyone implements it: it is
leader/follower where ours is leaderless, it synchronises video only so images
and widgets get item-boundary alignment at best, and it is multicast so a group
spanning sites or VLANs cannot use it — a criterion the resolver does not model.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-04 20:26:12 -05:00
ScreenTinker 41ebb02368 Add a BrightSign capability probe
Not a port — a way to answer on real hardware the questions that decide what a port
looks like, instead of guessing them from documentation.

The one that matters is persistence. ScreenTinker's device identity (deviceId,
deviceToken, paired, serverUrl) lives in localStorage, and on BrightSign that behaves
like sessionStorage: without a durable store every panel re-pairs on every boot and
spawns a new device row. The registry is the alternative, so the probe reports whether
it resolves and whether either store survives a power cycle.

It runs LOCALLY first on purpose. That establishes whether the @brightsign/* modules
resolve at all, separately from whether a remotely-served page can reach them — the
question that decides between reusing the hosted web player and building a local shim
that owns the registry and passes identity to an iframe via postMessage. Without that
split a failure is ambiguous: an origin restriction and nodejs_enabled not taking look
identical. Once local works, changing one line points it at a hosted copy and the
delta is the answer.

Also reports the web-platform features the player leans on — service workers and the
Cache API for content caching, h264 in <video>, CSS clamp() for the directory-search
keyboard — plus model, OS and Chromium version, since Series 4 is pinned to Chromium
87 and would give a misleadingly pessimistic result.

SD-card ready: FAT32, both files at the root, empty card. Remote devtools on :2999.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 22:49:50 -05:00