BrightSign: ask the volumes for their size instead of trusting the mount check

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
This commit is contained in:
ScreenTinker 2026-08-06 18:40:12 -05:00
parent b4363d8d26
commit 60deefd992
2 changed files with 66 additions and 11 deletions

View file

@ -390,7 +390,13 @@ Function StorageProbe() As Object
hp = CreateObject("roStorageHotplug")
' Ask the platform which volumes exist rather than guessing; the static list is the fallback for
' an OS without the enumerator. Same shape BrightSign's own boilerplate uses.
volumes = ["SSD:", "SD:", "SD2:", "USB:"]
'
' FLASH: is on this list and is NOT on GetStorageStatus()'s. The documented drive strings for
' that method are "SD:", "SSD:" and "USB:" — internal flash is simply not one of the things it
' can answer about, so a player booting from flash (which is how the XT245 in this office ran
' until an NVMe went in) can never be reported as mounted no matter what is actually there.
' Hence the roStorageInfo pass below: the mount check is treated as a hint, not a gate.
volumes = ["SSD:", "SD:", "SD2:", "USB:", "FLASH:"]
' Feature-gated (see HasFindMember). A player that cannot be probed simply keeps the static list,
' which is the answer the enumerator would have given anyway on every model we ship.
if hp <> invalid and HasFindMember() then
@ -415,22 +421,45 @@ Function StorageProbe() As Object
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
if FillStorage(result, v) then return result
end if
end for
' Nothing claimed to be mounted — which is not the same as nothing being there.
'
' GetStorageStatus() cannot answer for FLASH:, roStorageHotplug may not exist at all on an older
' build, and either way the whole probe hung on one call whose "no" was indistinguishable from
' "cannot say". roStorageInfo is the direct question: a volume that reports a non-zero size IS
' the disk, whatever the hotplug object thinks. The dashboard was showing 1025 MB for a player
' with an NVMe in it — the widget's own cache quota, reported through the fallback in
' st-bridge.js — because this function returned present:false and the page had nothing better.
'
' The mount check still runs FIRST: it is the more meaningful answer where it works, and it
' picks the removable volume ahead of internal flash on a player that has both.
for each raw in volumes
if FillStorage(result, TrimDrive(raw)) then return result
end for
return result
End Function
' Real device capacity for [drive], into [result]. True when the volume answered.
'
' The widget's storage quota — all the page can see via navigator.storage.estimate() — is the cache
' budget, not the disk, which is the entire reason the host is asked at all.
Function FillStorage(result As Object, drive As String) As Boolean
si = CreateObject("roStorageInfo", drive)
if si = invalid then return false
total = si.GetSizeInMegabytes()
if total = invalid or total <= 0 then return false
result.present = true
result.volume = drive
result.total_mb = total
free = si.GetFreeInMegabytes()
if free <> invalid and free >= 0 then result.free_mb = free
return true
End Function
' Everything the page cannot ask the hardware directly.
Sub SendProbeResult(widget As Object)
di = CreateObject("roDeviceInfo")

View file

@ -381,3 +381,29 @@ test('a second widget is never given the first screen\'s rectangle', () => {
assert.notEqual(args[1], 'rect',
'the second widget must be positioned at the second output\'s canvas offset, not at rect');
});
test('storage is probed directly, not only through the mount check', () => {
// The mount check is roStorageHotplug.GetStorageStatus(), whose documented drive strings are
// "SD:", "SSD:" and "USB:" — internal FLASH: is not one of them, and the object may not exist on
// an older build at all. Gating the whole probe on it made "cannot say" indistinguishable from
// "no disk", and a player with an NVMe in it reported 1025 MB: the widget's own cache quota,
// reported through the navigator.storage.estimate() fallback in st-bridge.js, because the host
// had answered present:false.
//
// roStorageInfo is the direct question and needs no hotplug object. A volume that reports a
// non-zero size IS the disk.
const src = code('autorun.brs');
const probe = /Function StorageProbe\(\)[\s\S]*?\nEnd Function/.exec(src);
assert.ok(probe, 'StorageProbe went missing');
const body = probe[0];
// A second pass that does not consult `mounted` at all.
const passes = body.split(/for each raw in volumes/).length - 1;
assert.ok(passes >= 2, 'the probe must fall back to asking the volumes directly');
assert.ok(/FLASH:/i.test(body),
'internal flash must be on the list the direct pass walks — it is how a card-less player boots');
// And the direct pass must reject a volume that answers zero, or every player "has" every drive.
const fill = /Function FillStorage\([\s\S]*?\nEnd Function/.exec(src);
assert.ok(fill, 'FillStorage went missing');
assert.match(fill[0], /total\s*=\s*invalid\s+or\s+total\s*<=\s*0/,
'a volume that reports no size is not a disk');
});