screentinker/brightsign/autorun.brs
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

704 lines
32 KiB
Plaintext

' ScreenTinker — BrightSign player host
'
' The ScreenTinker player itself is the ordinary web player (server/player/index.html) running
' in an roHtmlWidget. This script is the HOST around it, and it exists for the things a page
' cannot do for itself:
'
' 1. OWN THE WIDGET LIFECYCLE. A page that calls location.reload() on a BrightSign does not
' reliably come back — observed in the field on 2026-07-28, where a ScreenTinker deploy
' reloaded every connected player and the BrightSign was the only one that never returned.
' So the page NEVER reloads itself here: it posts {type:"restart"} and this script tears the
' widget down and builds a new one. That is a restart the OS actually performs.
' 2. SURVIVE A DEAD SERVER. load-error retries with backoff and falls back to a local page,
' instead of leaving a white screen until someone power-cycles the box.
' 3. PERSIST IDENTITY across reboots and content changes, in the registry rather than in
' localStorage (which is tied to the page's origin and its storage quota).
' 4. REACH BRIGHTSCRIPT-ONLY CAPABILITIES on the page's behalf — video mode, a second output,
' and native BrightWall synchronisation — over the messageport bridge.
'
' Pair it with st-bridge.js, which is the JavaScript half of the same contract.
'
' SD card layout: autorun.brs st-bridge.js offline.html [screentinker.json]
'=== storage volume ==========================================================================
' WHERE we are running from is not a given. The obvious answer is the SD card, and every
' BrightSign example assumes it — but this player also boots FLASH:/autorun.brs straight out of
' internal eMMC, which is the ONLY path on a unit whose microSD interface is dead (ours has
' liquid corrosion on the card lines; the host controller probes at 400kHz and no card ever
' answers). Hard-coding "SD:" there means the script loads and then cannot find its own files.
'
' So ask the filesystem instead of assuming: whichever volume holds this script is the volume
' that holds everything else beside it.
Function StorageRoot() As String
ba = CreateObject("roByteArray")
if ba.ReadFile("FLASH:/autorun.brs") then return "FLASH:"
return "SD:"
End Function
'=== configuration ==========================================================================
' Provisioning order: screentinker.json on the card (imaging a batch) > registry (set once at
' pairing, survives content updates) > the built-in default.
Function LoadConfig() As Object
cfg = {
server_url: "https://screentinker.com"
device_id: ""
sync_backend: "auto" ' auto | screentinker | brightsign
output_mode: "single" ' single | dual | clone
inspector: false
' Self-update of the host package. Defaults ON: a fleet that cannot be updated remotely is
' a fleet that needs a van. The DECISION is still the server's, and it refuses anything it
' cannot verify, so "on" does not mean "will apply whatever it is handed".
self_update: true
' Mirrors the Android beta channel. Off by default; an opted-in player also HOLDS a
' prerelease of its own core instead of being pulled back to the release.
allow_prerelease: false
}
' 1) registry
reg = CreateObject("roRegistrySection", "screentinker")
if reg.Exists("server_url") then cfg.server_url = reg.Read("server_url")
if reg.Exists("device_id") then cfg.device_id = reg.Read("device_id")
if reg.Exists("sync_backend") then cfg.sync_backend = reg.Read("sync_backend")
if reg.Exists("output_mode") then cfg.output_mode = reg.Read("output_mode")
if reg.Exists("self_update") then cfg.self_update = (reg.Read("self_update") = "1")
if reg.Exists("allow_prerelease") then cfg.allow_prerelease = (reg.Read("allow_prerelease") = "1")
' 2) a JSON file on the card wins — that is how a batch gets imaged without touching each box
ba = CreateObject("roByteArray")
if ba.ReadFile(StorageRoot() + "/screentinker.json") then
json = ParseJson(ba.ToAsciiString())
if json <> invalid then
if json.server_url <> invalid then cfg.server_url = json.server_url
if json.device_id <> invalid then cfg.device_id = json.device_id
if json.sync_backend <> invalid then cfg.sync_backend = json.sync_backend
if json.output_mode <> invalid then cfg.output_mode = json.output_mode
if json.inspector <> invalid then cfg.inspector = json.inspector
if json.self_update <> invalid then cfg.self_update = json.self_update
if json.allow_prerelease <> invalid then cfg.allow_prerelease = json.allow_prerelease
end if
end if
return cfg
End Function
Sub SaveRegistry(key As String, value As String)
reg = CreateObject("roRegistrySection", "screentinker")
reg.Write(key, value)
reg.Flush()
End Sub
'=== player URL =============================================================================
' Identity is carried in the URL so the page knows who it is before it has any storage of its
' own. serial is the stable hardware id; device_id is what ScreenTinker assigned at pairing.
Function PlayerUrl(cfg As Object, screen As Integer) As String
di = CreateObject("roDeviceInfo")
url = cfg.server_url + "/player?platform=brightsign"
url = url + "&serial=" + di.GetDeviceUniqueId()
url = url + "&model=" + di.GetModel()
url = url + "&sync_backend=" + cfg.sync_backend
if cfg.device_id <> "" then url = url + "&device_id=" + cfg.device_id
if screen > 1 then url = url + "&screen=" + Stri(screen).Trim()
return url
End Function
'=== widget construction ====================================================================
Function MakeWidget(url As String, rect As Object, port As Object, cfg As Object) As Object
config = {
url: url
nodejs_enabled: true ' Node runtime inside the widget
brightsign_js_objects_enabled: true ' REQUIRED for require("@brightsign/*") — without
' this the bridge silently degrades to no-ops and
' the player loses identity AND restart delegation
javascript_enabled: true
security_params: { websecurity: true }
hwz_default: "on" ' hardware z-order — video on its own plane
storage_path: "/cache" ' DIRECTORY NAME for the local storage cache
storage_quota: "1073741824" ' 1GB, as a STRING — service-worker offline cache
port: port
mouse_enabled: false
}
if cfg.inspector then config.inspector_server = { port: 2999 }
w = CreateObject("roHtmlWidget", rect, config)
return w
End Function
' SyncManager will not work unless the PTP domain is set, and applying it needs a reboot. Done
' ONLY when this player is actually configured for native sync — a reboot on every boot would be
' a boot loop, and a player using our own protocol has no use for it.
'
' The read-before-write is what makes it safe: it reboots at most once, on the first boot after
' the mode is selected, and is a no-op forever after.
Sub EnsurePtpDomain(cfg As Object)
if cfg.sync_backend <> "brightsign" then return
regSec = CreateObject("roRegistrySection", "networking")
if regSec.Read("ptp_domain") = "0" then
print "[st] ptp_domain already 0"
else
print "[st] setting ptp_domain=0 for SyncManager — rebooting once to apply"
regSec.Write("ptp_domain", "0")
regSec.Flush()
RebootSystem()
end if
End Sub
' Capture what is ACTUALLY on screen, using the player's own Diagnostic Web Server.
'
' The page cannot do this itself. With hwz enabled, video decodes onto a hardware plane the DOM
' cannot see: drawImage(video) on a canvas returns a fully transparent image and throws nothing,
' so an in-page screenshot silently produces a blank frame. The DWS captures the real framebuffer,
' video included.
'
' It has to happen HERE rather than in the page for two reasons: the DWS is http on localhost and
' 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. The credentials are the documented default —
' user "admin", password = the unit serial — which this script can read directly.
'
' ⚠️ Requires PRIMARY STORAGE. With no card or SSD fitted the endpoint answers
' "No primary storage found", because it writes the full-size capture to disk before returning the
' thumbnail. Reported back as-is rather than swallowed, so the dashboard can say why.
Sub TakeSnapshot(widget As Object, req As Object)
di = CreateObject("roDeviceInfo")
serial$ = di.GetDeviceUniqueId()
w% = 640
h% = 360
if req <> invalid and req.width <> invalid then w% = req.width
if req <> invalid and req.height <> invalid then h% = req.height
body$ = "{""width"":" + Stri(w%).Trim() + ",""height"":" + Stri(h%).Trim() + "}"
ut = CreateObject("roUrlTransfer")
if ut = invalid then
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "no roUrlTransfer" })
return
end if
ut.SetUrl("http://localhost/api/v1/snapshot/")
ut.SetUserAndPassword("admin", serial$)
ut.AddHeader("Content-Type", "application/json")
resp$ = ut.PostFromStringWithRetry(body$, 1)
if resp$ = invalid or resp$ = "" then
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "no response from the local DWS" })
return
end if
json = ParseJson(resp$)
if json = invalid or json.data = invalid then
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "unparseable DWS response" })
return
end if
if json.data.error <> invalid then
' e.g. "No primary storage found." — pass the player's own words through; inventing a
' friendlier message here would hide the one fact that explains the failure.
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: json.data.error.message })
return
end if
r = json.data.result
if r = invalid or r.remotesnapshotthumbnail = invalid then
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "DWS returned no thumbnail" })
return
end if
widget.PostJSMessage({ type: "snapshot-result", ok: true, image: r.remotesnapshotthumbnail })
End Sub
' Rotate the OUTPUT, not the DOM.
'
' The web player rotates with a CSS transform, which is correct in a browser and wrong here: with
' hwz enabled the video decodes onto a hardware plane the DOM cannot transform, so a CSS rotation
' turns the images and widgets and leaves the video unrotated. Tizen hit this same wall and routes
' portrait video through AVPlay for exactly this reason.
'
' roVideoMode takes a transform — normal/90/180/270 — and rotating the screen rotates EVERYTHING,
' video included, because it happens below the compositor rather than above it.
'
' Reports success back to the page: if this fails, the page falls back to its CSS transform, which
' rotates most of the content rather than none of it. Silently doing neither would leave a portrait
' panel showing landscape content with no clue why.
Sub SetOrientation(widget As Object, o As String)
transform$ = "normal"
if o = "portrait" then transform$ = "90"
if o = "portrait-flipped" then transform$ = "270"
if o = "landscape-flipped" then transform$ = "180"
vm = CreateObject("roVideoMode")
if vm = invalid then
widget.PostJSMessage({ type: "orientation-result", ok: false, error: "no roVideoMode" })
return
end if
mode$ = vm.GetMode()
if mode$ = invalid or mode$ = "" then mode$ = "1920x1080x60p"
ok = vm.SetMode(mode$, transform$)
if ok = invalid then ok = false
if ok then
print "[st] orientation "; o; " -> transform "; transform$
else
print "[st] orientation "; o; ": SetMode refused transform "; transform$
end if
widget.PostJSMessage({ type: "orientation-result", ok: ok, transform: transform$ })
End Sub
'=== capability probe =======================================================================
' What this unit can actually do, answered by the only component that can see it.
'
' The page cannot determine any of this. There is no JavaScript API for device storage —
' @brightsign/storage exposes format/eject and nothing that enumerates volumes — so a player asked
' "do you have a disk?" could only guess. It matters because the DWS snapshot endpoint writes the
' full-size capture to disk before returning a thumbnail: with no card or SSD fitted it answers
' "No primary storage found", which is exactly what our XT245 does today. Declaring
' remote.screenshot on such a unit puts a button in the dashboard that cannot work.
'
' FLASH: is deliberately NOT counted. This player boots from internal flash because its card
' interface is physically dead, and the DWS still refuses the capture — internal flash is not
' "primary storage" as that endpoint means it. Counting it would re-create the exact lie this
' probe exists to prevent.
Function StorageProbe() As Object
result = { present: false, volume: "", free_mb: 0, total_mb: 0 }
volumes = ["SSD:", "SD:", "USB1:"]
for each v in volumes
mounted = false
hp = CreateObject("roStorageHotplug")
if hp <> invalid then
st = hp.GetStorageStatus(v)
if st <> invalid and st.mounted then mounted = true
end if
if mounted then
result.present = true
result.volume = v
si = CreateObject("roStorageInfo", v)
if si <> invalid then
' Real device capacity. The widget's storage quota — all the page can see via
' navigator.storage.estimate() — is the cache budget, not the disk.
result.free_mb = si.GetFreeInMegabytes()
result.total_mb = si.GetSizeInMegabytes()
end if
return result
end if
end for
return result
End Function
' Everything the page cannot ask the hardware directly.
Sub SendProbeResult(widget As Object)
di = CreateObject("roDeviceInfo")
storage = StorageProbe()
osVer$ = ""
model$ = ""
if di <> invalid then
osVer$ = di.GetVersion()
model$ = di.GetModel()
end if
widget.PostJSMessage({
type: "probe-result"
storage_present: storage.present
storage_volume: storage.volume
storage_free_mb: storage.free_mb
storage_total_mb: storage.total_mb
os_version: osVer$
model: model$
})
End Sub
Function FullScreenRect() As Object
vm = CreateObject("roVideoMode")
return CreateObject("roRectangle", 0, 0, vm.GetResX(), vm.GetResY())
End Function
'=== self-update ============================================================================
'
' The package (autorun.zip) can replace THIS SCRIPT. That makes it the most dangerous thing the
' player does: a truncated or half-applied autorun.brs is a dark panel and a site visit, because
' there is no app underneath to fall back to.
'
' The ordering below is the safety, and it is deliberate at every step:
'
' 1. Download to autorun.zip.part — never straight to autorun.zip. A file that is still
' downloading, or that stopped halfway, must never be a candidate for extraction.
' 2. Verify sha256 AND size before promoting. A captive portal that answers with a login page
' produces a perfectly well-formed small file; the size floor catches it, the hash catches
' everything else.
' 3. Only then promote: delete the .done marker, rename .part -> autorun.zip, reboot.
' The marker MUST go first — leaving it would make ApplyPendingPackage skip the new archive
' on the next boot and the update would silently never happen.
' 4. Extraction failure renames the archive to .bad rather than retrying forever. A zip that
' cannot be unpacked will not unpack on the tenth attempt either, and retrying it on every
' boot is a loop that looks exactly like a hardware fault.
'
' THE VERSION IS BAKED IN, not stored in a side file. A version record that can disagree with the
' code actually running is the OTA-loop condition in another guise: the player applies an update,
' reports the old version, is offered it again, forever. Stamped at build time by both
' scripts/build-autorun-zip.sh and server/lib/brightsign-package.js.
Function PackageVersion() As String
return "0.0.0-dev" ' ST_PACKAGE_VERSION (stamped at build time — do not edit by hand)
End Function
Function DoesFileExist(filePath$ As String) As Boolean
files = MatchFiles(filePath$, filePath$)
return files.Count() > 0
End Function
' Unpack a package that is sitting on storage waiting to be applied. Runs BEFORE the widget so a
' pending update lands before the player starts, not halfway through a playlist.
'
' Note this duplicates autozip.brs on purpose. autozip.brs handles the FIRST install, where a bare
' card holds nothing but autorun.zip and the OS processes it. Once autorun.brs exists at the
' storage root the OS no longer auto-processes the archive — so from then on the host has to do it
' itself, or self-update would work exactly once.
Sub ApplyPendingPackage(root As String)
zipPath$ = root + "/autorun.zip"
donePath$ = root + "/autorun.zip.done"
badPath$ = root + "/autorun.zip.bad"
if not DoesFileExist(zipPath$) then return
if DoesFileExist(donePath$) then return ' already unpacked; extracting again is the boot loop
print "[st-update] unpacking pending package"
package = CreateObject("roBrightPackage", zipPath$)
if package = invalid then
print "[st-update] ERROR: archive unreadable (is it STORED?) — parking it as .bad"
fs = CreateObject("roFileSystem")
if fs <> invalid then fs.Rename(zipPath$, badPath$)
return
end if
if not package.Unpack(root + "/") then
print "[st-update] ERROR: extract failed — parking it as .bad so we do not retry forever"
fs = CreateObject("roFileSystem")
if fs <> invalid then fs.Rename(zipPath$, badPath$)
return
end if
fs = CreateObject("roFileSystem")
if fs = invalid then return
if not fs.Rename(zipPath$, donePath$) then
' Refusing to reboot without the marker: we would extract and reboot forever.
print "[st-update] ERROR: could not mark done — not rebooting"
return
end if
print "[st-update] package applied — rebooting into it"
sleep(2000)
RebootSystem()
End Sub
' Ask the server what to do, and do exactly that. The DECISION lives on the server
' (server/lib/brightsign-update.js, which is unit-tested); this only executes it. Re-implementing
' the version comparison here would put the prerelease trap somewhere it cannot be tested.
Sub CheckPackageUpdate(cfg As Object, root As String)
if cfg.server_url = "" then return
partPath$ = root + "/autorun.zip.part"
reg = CreateObject("roRegistrySection", "screentinker")
attempts% = 0
if reg.Exists("pkg_attempts") then attempts% = Val(reg.Read("pkg_attempts"))
url$ = cfg.server_url + "/api/brightsign/package?version=" + PackageVersion()
url$ = url$ + "&attempts=" + Stri(attempts%).Trim()
if cfg.allow_prerelease then url$ = url$ + "&allow_prerelease=1"
xfer = CreateObject("roUrlTransfer")
if xfer = invalid then return
xfer.SetUrl(url$)
xfer.EnablePeerVerification(true)
body$ = xfer.GetToString()
if body$ = "" then return ' unreachable: keep running what works
manifest = ParseJson(body$)
if manifest = invalid then return
if manifest.action = invalid then return
if manifest.action <> "download" then
if manifest.reason <> invalid then print "[st-update] no action: "; manifest.reason
return
end if
print "[st-update] downloading package "; manifest.version
' Any earlier partial is deleted first: resuming into an existing file would concatenate two
' downloads into something that hashes to neither.
fs = CreateObject("roFileSystem")
if fs <> invalid and DoesFileExist(partPath$) then fs.Delete(partPath$)
dl = CreateObject("roUrlTransfer")
if dl = invalid then return
dl.SetUrl(cfg.server_url + manifest.url)
dl.EnablePeerVerification(true)
if dl.GetToFile(partPath$) <> 200 then
print "[st-update] download failed"
RecordPackageAttempt(reg, attempts% + 1)
if fs <> invalid then fs.Delete(partPath$)
return
end if
' Verify before promoting. This is the gate that stops a truncated file becoming the boot script.
if not VerifyPackage(partPath$, manifest.sha256, manifest.size) then
print "[st-update] VERIFICATION FAILED — discarding, staying on "; PackageVersion()
RecordPackageAttempt(reg, attempts% + 1)
if fs <> invalid then fs.Delete(partPath$)
return
end if
' Promote. Marker first — see the ordering note above.
if fs = invalid then return
if DoesFileExist(root + "/autorun.zip.done") then fs.Delete(root + "/autorun.zip.done")
if DoesFileExist(root + "/autorun.zip") then fs.Delete(root + "/autorun.zip")
if not fs.Rename(partPath$, root + "/autorun.zip") then
print "[st-update] ERROR: could not stage the package — staying put"
RecordPackageAttempt(reg, attempts% + 1)
return
end if
' A clean attempt counter, so the next version starts from zero rather than inheriting this
' version's failures and being refused before it is ever tried.
RecordPackageAttempt(reg, 0)
print "[st-update] staged "; manifest.version; " — rebooting to apply"
sleep(2000)
RebootSystem()
End Sub
Sub RecordPackageAttempt(reg As Object, n As Integer)
if reg = invalid then return
reg.Write("pkg_attempts", Stri(n).Trim())
reg.Flush()
End Sub
' sha256 + size. Both matter: the hash proves the bytes are the ones we were promised, the size
' floor catches an error page or captive-portal login saved under the package's name.
Function VerifyPackage(path As String, expected As String, expectedSize As Integer) As Boolean
if expected = invalid or expected = "" then return false
fs = CreateObject("roFileSystem")
if fs = invalid then return false
info = fs.Stat(path)
if info = invalid then return false
if info.size < 1024 then
print "[st-update] package is implausibly small ("; info.size; " bytes)"
return false
end if
if expectedSize > 0 and info.size <> expectedSize then
print "[st-update] size mismatch: got "; info.size; " expected "; expectedSize
return false
end if
digest = CreateObject("roMessageDigest")
if digest = invalid then return false
digest.SetAlgorithm("sha256")
file = fs.OpenInputFile(path)
if file = invalid then return false
while true
chunk = file.Read(65536)
if chunk.Count() = 0 then exit while
digest.Update(chunk)
end while
return LCase(digest.Final()) = LCase(expected)
End Function
'=== main ===================================================================================
Sub Main()
cfg = LoadConfig()
' Crash dumps land here if the widget ever falls over — cheap, and the only forensic trail
' available on a panel nobody can reach.
dir = CreateDirectory(StorageRoot() + "/brightsign-dumps")
' Must happen BEFORE the widget starts: it can reboot.
EnsurePtpDomain(cfg)
' A package staged by a previous run lands here, before anything is on screen. Doing it after
' the widget started would mean rebooting out of a playing playlist, and the panel would blink
' mid-content for a reason nobody watching could explain.
ApplyPendingPackage(StorageRoot())
port = CreateObject("roMessagePort")
' Second output. The XC5 family exposes more than one HDMI connector (XC2055 dual, XC4055
' quad). Do NOT trust the series-level spec blurb here: it credits the whole XT5 family with
' "dual HDMI outputs", but an XT245 in hand is single-output — that phrase appears to cover
' HDMI in + out. Check the individual model, not the family.
'
' Every single-output model must fall through this cleanly. GetResX/GetResY only ever
' describe output 1, so a second widget is created ONLY when the config asks for it — an
' unsupported model then keeps working as a normal single-screen player rather than failing
' to start. Screen 2 loads the SAME player with &screen=2, so the server can hand it its own
' playlist ("dual" = independent) or the same one ("clone").
dual = (cfg.output_mode = "dual" or cfg.output_mode = "clone")
rect = FullScreenRect()
widget = MakeWidget(PlayerUrl(cfg, 1), rect, port, cfg)
widget.Show()
widget2 = invalid
if dual then
screen2 = 2
if cfg.output_mode = "clone" then screen2 = 1
widget2 = MakeWidget(PlayerUrl(cfg, screen2), rect, port, cfg)
if widget2 <> invalid then widget2.Show()
end if
retries = 0
lastBeat = CreateObject("roTimespan")
lastBeat.Mark()
' Update check runs AFTER the widget is up, deliberately. A slow or unreachable server must
' never delay first frame — content on screen is the job, updating is housekeeping. It also
' runs on a timer rather than only at boot, because a panel that is never power-cycled would
' otherwise never see an update at all.
lastPkgCheck = CreateObject("roTimespan")
lastPkgCheck.Mark()
PKG_CHECK_MS = 6 * 60 * 60 * 1000 ' 6h: this replaces the boot script, so rarely is right
if cfg.self_update then CheckPackageUpdate(cfg, StorageRoot())
' A watchdog on TOP of load-error: a page can load fine and then wedge (dead socket, JS
' exception, decoder stall) without the OS ever reporting an error. st-bridge.js posts a
' heartbeat every 30s; three missed beats and we rebuild the widget. This is the difference
' between a panel that recovers on its own and one that needs a site visit.
WATCHDOG_MS = 120000
while true
msg = wait(5000, port)
if type(msg) = "roHtmlWidgetEvent" then
data = msg.GetData()
if data.reason = "load-finished" then
retries = 0
lastBeat.Mark()
print "[st] player loaded"
else if data.reason = "load-error" then
' Back off, then fall back to the local page so the screen says something
' truthful instead of showing white. The local page keeps retrying the server.
retries = retries + 1
print "[st] load-error ("; retries; "): "; data.url
sleep(ChooseBackoff(retries))
if retries >= 3 then
' The server URL rides along so the fallback page can name it on screen and
' keep probing it — the page has no other way to learn where home is.
widget = RebuildWidget(widget, "file:/" + StorageRoot() + "/offline.html?server=" + cfg.server_url, rect, port, cfg)
else
widget = RebuildWidget(widget, PlayerUrl(cfg, 1), rect, port, cfg)
end if
else if data.reason = "message" then
m = data.message
lastBeat.Mark()
' A missing member is `invalid`, and comparing invalid to a literal is a TYPE
' MISMATCH that aborts the script — taking the whole player down with it. Every
' field is existence-checked before it is compared.
if m = invalid or m.type = invalid then
' nothing addressable in this message
else if m.type = "heartbeat" then
' nothing to do — marking the timespan above IS the handling
else if m.type = "restart" then
' The page asks to be restarted (deploy, version change, unrecoverable
' error). NEVER let the page do this with location.reload().
print "[st] restart requested: "; m.reason
widget = RebuildWidget(widget, PlayerUrl(cfg, 1), rect, port, cfg)
else if m.type = "identity" then
' Pairing completed in the page — persist it where a reboot can find it.
' clear:true is the operator reset; the registry must forget the display or
' the next boot re-adopts it and the reset silently does nothing.
if m.clear <> invalid and m.clear = true then
SaveRegistry("device_id", "")
cfg.device_id = ""
end if
if m.device_id <> invalid then
SaveRegistry("device_id", m.device_id)
cfg.device_id = m.device_id
end if
if m.server_url <> invalid then
SaveRegistry("server_url", m.server_url)
cfg.server_url = m.server_url
end if
else if m.type = "probe" then
' Asked once during boot, before the player registers: the answer decides which
' controls the dashboard is allowed to offer for this display.
SendProbeResult(widget)
else if m.type = "set-orientation" then
if m.orientation <> invalid then SetOrientation(widget, m.orientation)
else if m.type = "snapshot" then
TakeSnapshot(widget, m)
else if m.type = "set-video-mode" then
vm = CreateObject("roVideoMode")
if m.mode <> invalid then vm.SetMode(m.mode)
else if m.type = "set-sync-backend" then
' The server decided which protocol this deployment uses (see
' server/lib/sync-backend.js). Persist it so a cold boot with no network
' still starts in the right mode.
if m.backend <> invalid then
SaveRegistry("sync_backend", m.backend)
cfg.sync_backend = m.backend
end if
else if m.type = "reboot" then
print "[st] reboot requested"
RebootSystem()
end if
end if
end if
' watchdog
if lastBeat.TotalMilliseconds() > WATCHDOG_MS then
print "[st] watchdog: no heartbeat in "; WATCHDOG_MS; "ms — rebuilding widget"
widget = RebuildWidget(widget, PlayerUrl(cfg, 1), rect, port, cfg)
lastBeat.Mark()
end if
' Periodic package check. Marked BEFORE the call, not after: a check that blocks on a slow
' server would otherwise be retried immediately on the next tick and hammer it.
if cfg.self_update and lastPkgCheck.TotalMilliseconds() > PKG_CHECK_MS then
lastPkgCheck.Mark()
CheckPackageUpdate(cfg, StorageRoot())
end if
end while
End Sub
Function ChooseBackoff(retries As Integer) As Integer
if retries <= 1 then return 5000
if retries = 2 then return 15000
if retries = 3 then return 30000
return 60000
End Function
' Tear the old widget down explicitly before building the new one. Dropping the reference alone
' leaves the old widget composited and holding its decoder until GC gets to it, which shows up
' as two players fighting over the screen.
Function RebuildWidget(old As Object, url As String, rect As Object, port As Object, cfg As Object) As Object
if old <> invalid then
old.Hide()
old = invalid
end if
w = MakeWidget(url, rect, port, cfg)
w.Show()
return w
End Function