mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
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
299 lines
14 KiB
Plaintext
299 lines
14 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
|
|
}
|
|
|
|
' 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")
|
|
|
|
' 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
|
|
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
|
|
|
|
Function FullScreenRect() As Object
|
|
vm = CreateObject("roVideoMode")
|
|
return CreateObject("roRectangle", 0, 0, vm.GetResX(), vm.GetResY())
|
|
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)
|
|
|
|
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()
|
|
|
|
' 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 = "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
|
|
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
|