Skip to content

WIP: Front Panel Updates - #424

Open
erichelgeson wants to merge 40 commits into
mainfrom
frontpanel-pr
Open

WIP: Front Panel Updates#424
erichelgeson wants to merge 40 commits into
mainfrom
frontpanel-pr

Conversation

@erichelgeson

@erichelgeson erichelgeson commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Compatible with Front Panel FW v0.5.0

matching/central_dir/local_file_header were function-local statics in
Parse(), leaking state across Parser instances; move them to members
cleared by Reset(). Also fix the !buf[idx] == method precedence wart.
The zip updater now also extracts this board's panel bin
(bluescsi-v2-frontpanel.bin on I2C targets, bluescsi-ultra-frontpanel.bin
on SPI targets) to /firmware/frontpanel.bin before the MCU update reboot,
via temp-file + rename so a failed extraction never leaves a torn image.
The panel's existing SHA-256 handshake decides whether it reflashes.
create_firmware_zip.sh picks up bluescsi-{v2,ultra}-frontpanel.bin from
panel-fw/ (override with PANEL_BIN_DIR) as stored entries, validating the
ESP32 image magic; skips with a note when absent so local builds work.
Downloads the release assets pinned by utils/frontpanel_version.txt from
polpo/open-retro-storage-frontpanel using the FRONTPANEL_GH_TOKEN secret.
Missing token fails main/tag builds but only skips fork/PR builds.
The version parsed out of frontpanel.bin's app descriptor dropped any
-preN suffix and packed as 0x00MMmmpp, so a prerelease compared equal to
its release and the panel never saw same-base-version builds as updates.
Pack as 0xMMmmppPP to match the panel's own encoding: the low byte is
0xFF for a final release or N for -preN, sorting prereleases below the
matching final. Ships with the corresponding front-panel change.
The front panel now ships one image for v2 (I2C) and Ultra/Ultra Wide
(SPI) — it detects the transport at runtime and persists it in NVS — so
the per-board bluescsi-{v2,ultra}-frontpanel.bin names collapse into
bluescsi-frontpanel.bin. The extractor no longer picks a name per
ENABLE_PANEL_* define, the release zip carries the single bin, and CI
downloads one asset. Pin frontpanel_version.txt to v0.4.1-pre1, the
first panel release under the new name: that tag must exist before the
next main/tag build, or the panel-fw download step fails the build.
RP2040 Pico_DaynaPORT / Pico_Audio_SPDIF compile without the panel (the
~16 KB of panel buffers don't fit alongside CYW43, and SPDIF out shares
the panel's SCL pin), but the ini setting still parsed and the log
printed 'EnableFrontPanel = Yes' with nothing behind it — which reads
as a working panel that never connects (the panel side just sees
address NACKs). Log that the build has no panel support and which
builds do, right below the settings dump.
The front panel listed .bin files and hid .cue sheets, and loading a
.cue directly wedged the eject/next iterator: opening a loose .cue set
bin_container (and current_image) to the cue's parent directory, so
is_multi_bin_cue() became true and scsiDiskGetNextImageName() tracked
the directory name instead of the loaded cue, never advancing. This
also made the panel inconsistent with PicoIDE and mishandled a loose
multi-bin cue (it showed the raw track .bins).

Track a directly-loaded loose .cue by its own filename via a new
image_config_t::cue_loaded_directly flag: bin_container stays the
parent directory so the cue's .bin tracks still resolve, but the image
cycles by the .cue. scsiDiskGetNextImageName() uses the directory-name
cursor only for genuine folder-images.

Panel side: DirState lists .cue and hides the .bin files a cue
references (only when a cue sheet is present, so plain data-.bin CDs
stay visible); eject/next/prev use prefer_cue; and the image-name and
delete-guard paths resolve a loose cue by its .cue name.
The per-target initiator_target_summary_t array (52 B x NUM_SCSIID =
416 B) added to g_initiator_state for front-panel status reporting was
linked into every target, including the RAM-maxed Pico_Audio_SPDIF
(DaynaPORT + SPDIF), pushing its RP2040 static RAM 96 bytes past the
budget so it failed to link. Its only consumers are the panel protocol
handlers, which already self-gate on ENABLE_PANEL_SPI/ENABLE_PANEL_I2C.

Gate target_summary and the panel-facing initiator accessors behind
ENABLE_PANEL_I2C/ENABLE_PANEL_SPI, routing the summary updates through
small initiatorSummary* helpers that compile to no-op inline stubs on
non-panel builds. scsiInitiatorMainLoop reads identically either way.
Reclaims the 416 bytes: Pico_Audio_SPDIF links again with ~320 bytes
of margin; panel targets keep full initiator status reporting.
…o 128

Derived-dir paths overflowed the caller's buffer (dirlen accounted as 0) and
too-long names returned 0 mid-ring, so the panel cycled/counted only a subset.
Cycling now skips names over the path budget instead of failing. RAM cost
offset by rebuilding image_config_t::clear() without its static blank copy
and moving scsi_accel_log_state() to flash on RP2040.
…_PATH bump

Root scan buffer sized for package names; firmware_name shrunk with a static_assert.
panel_i2c.cpp and panel_sha256_sw.cpp had no placement rule, so RP2040's
inverted default pulled them into RAM via the .data catch-all. On Pico the
panel builds ENABLE_PANEL_I2C, meaning panel_spi.cpp compiles to nothing
and the only live transport was the unplaced one - ~2.5KB of cold init,
poll and SHA-256 transform code in the tightest target's RAM.
The image-opening chain (switchNextImage -> scsiDiskOpenHDDImage ->
cdromValidateCueSheet) measures ~1.7KB of the 2KB default before SdFat and
CUEParser frames, and it is reachable from a plain host-driven eject with
no front panel involved. RP2350 already had the headroom; RP2040 needs it
more, since it silently corrupts .bss where MSPLIM would fault. Core 1
stays pinned at 2KB - it lives in SCRATCH_X, which PICO_CORE1_STACK_SIZE
would otherwise overflow on the Audio SPDIF and DaynaPORT targets.
payload_size is master-supplied and can name up to 64KB. On reads tx_len
took it unclamped, so a single 5-byte header asking for 0xFFFF bytes made
the REQUEST handler serve off the end of mapped SRAM and fault inside the
ISR. On writes the payload counter incremented past the 4096-byte buffer,
so the CRC and the path handlers' strnlen ran past it too. The SPI
transport already rejects oversize payloads; I2C now matches.

Also drop a write that arrives while one is still staged - the payload
buffer is the live receive destination, not a snapshot, so a retransmit
would commit the new bytes under the queued command's length and CRC - and
refresh the device snapshot only while the SCSI bus is idle, since
platform_poll() runs inside the transfer loops and the periodic name
refresh can block on an SD read.
The refresh was the first statement in panel_spi_poll(), above the
bus-busy gate, and platform_poll() is called from inside the SCSI transfer
loops. Its periodic getName() can miss the FAT cache and block on an SD
read mid-transfer - exactly the CPU-driven delay the hot path forbids.
Every handle_*_async was inlined into one panel_protocol_handle_write
blob, summing full_path[192] and seven MAX_FILE_PATH buffers into a single
968-byte frame that then called switchNextImage -> scsiDiskOpenHDDImage ->
cdromValidateCueSheet - 2744B against RP2040's stack. Marking the handlers
and the name-finding helpers noinline drops the handler to 152B and lets
panel_find_prev_image's buffers pop before the image-switch chain runs.
check_stack_usage.py could not see this: it is per-function against a
1024-byte threshold, and 968 slips under.

Also: refuse an upload whose path is a loaded image - an absolute path
could name a live image and O_TRUNC would zero it under the running target,
which DELETE and RENAME already refuse; teach panel_path_is_loaded about
the panel's own long-lived handles (fw_file, upload, download) so their
clusters cannot be freed underneath SdFat; reclaim an abandoned upload's
SHA-256 lock and file handle after 60s idle, which otherwise left every
later CHECK_FIRMWARE returning an all-zero hash so the panel could never
self-update again; and reject a firmware-read offset past EOF instead of
underflowing remaining to ~4GB.
Device category codes were dropped on this side; restore them so both copies match. Correct comments claiming the deferred-write buffer is a snapshot.
The receive buffer was also the live DMA/ISR destination, so a panel retransmit could overwrite a queued or in-flight write after its CRC was taken. Drop the newcomer instead.
Downloads held the handle until the next one started, so panel_path_is_loaded reported the file in use and it could not be deleted or renamed. Close on the last chunk, with an idle reclaim for abandoned transfers.
Upstream growth left no RAM for the panel's static buffers. None of these run per-command and each already waits on the SD card.
@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown

Test this PR

Flash the firmware built from 8f7f555: BlueSCSI binaries.zip

Must be signed in to GitHub. Build expires after 90 days.

BlueSCSI Memory Report

Compared against release v2026.08.17

Memory Usage

Target FLASH RAM SCRATCH_X SCRATCH_Y
Pico_2_Audio_SPDIF 713.4 KB (+27.8 KB) [35%] 511.7 KB [100%] 3.5 KB [88%] 3.8 KB [94%]
Pico_2_DaynaPORT 705.7 KB (+27.8 KB) [34%] 511.7 KB [100%] 3.5 KB [88%] 3.0 KB [75%]
Pico_Audio_SPDIF 711.0 KB (+2.5 KB) [35%] 251.1 KB (-1.4 KB) [98%] 1.5 KB [38%] 768 B [19%]
Pico_DaynaPORT 702.7 KB (+2.4 KB) [34%] 244.8 KB (-1.5 KB) [96%] 1.5 KB [38%]
Ultra 725.3 KB (+30.0 KB) [35%] 511.7 KB [100%] 3.5 KB [88%] 3.0 KB [75%]
Ultra_Wide 483.9 KB (+29.8 KB) [24%] 511.7 KB [100%] 2.0 KB [50%] 3.0 KB [75%]

Symbol Region Changes

Pico_2_Audio_SPDIF: 8 symbols moved RAM → FLASH (-17.6 KB RAM)
Symbol Size
logNonDefaultDeviceSettings(int, scsi_device_settings_t const&, scsi_device_settings_t const&) 4.1 KB
BlueSCSISettings::initSystem(char const*) 3.3 KB
findHDDImages() 3.1 KB
kiosk_restore_images() 2.4 KB
bluescsi_setup_sd_card(bool) 1.5 KB
s2s_configInit 1.4 KB
print_sd_info() 1.3 KB
mountSDCard() 364 B
Pico_2_DaynaPORT: 8 symbols moved RAM → FLASH (-17.6 KB RAM)
Symbol Size
logNonDefaultDeviceSettings(int, scsi_device_settings_t const&, scsi_device_settings_t const&) 4.1 KB
BlueSCSISettings::initSystem(char const*) 3.3 KB
findHDDImages() 3.1 KB
kiosk_restore_images() 2.4 KB
bluescsi_setup_sd_card(bool) 1.6 KB
s2s_configInit 1.4 KB
print_sd_info() 1.3 KB
mountSDCard() 364 B
Pico_Audio_SPDIF: 7 symbols moved RAM → FLASH (-3.5 KB RAM)
Symbol Size
scsi_accel_log_state() 1.2 KB
scsiDiskHandleVerify(unsigned long long, unsigned long, bool) 1.2 KB
platform_network_init 760 B
void logmsg<char const*, char const*>(char const*, char const*) 80 B
void logmsg<char const*, char*>(char const*, char*) 80 B
void logmsg<char const*, unsigned long>(char const*, unsigned long) 80 B
void logmsg<char const*>(char const*) 72 B
Pico_DaynaPORT: 7 symbols moved RAM → FLASH (-3.5 KB RAM)
Symbol Size
scsi_accel_log_state() 1.2 KB
scsiDiskHandleVerify(unsigned long long, unsigned long, bool) 1.2 KB
platform_network_init 760 B
void logmsg<char const*, char const*>(char const*, char const*) 80 B
void logmsg<char const*, char*>(char const*, char*) 80 B
void logmsg<char const*, unsigned long>(char const*, unsigned long) 80 B
void logmsg<char const*>(char const*) 72 B
Ultra: 8 symbols moved RAM → FLASH (-18.9 KB RAM)
Symbol Size
logNonDefaultDeviceSettings(int, scsi_device_settings_t const&, scsi_device_settings_t const&) 4.1 KB
BlueSCSISettings::initSystem(char const*) 3.3 KB
findHDDImages() 3.1 KB
kiosk_restore_images() 2.4 KB
bluescsi_setup_sd_card(bool) 1.7 KB
mountSDCard() 1.5 KB
s2s_configInit 1.4 KB
print_sd_info() 1.3 KB
Ultra_Wide: 8 symbols moved RAM → FLASH (-19.0 KB RAM)
Symbol Size
logNonDefaultDeviceSettings(int, scsi_device_settings_t const&, scsi_device_settings_t const&) 4.1 KB
BlueSCSISettings::initSystem(char const*) 3.3 KB
findHDDImages() 3.2 KB
kiosk_restore_images() 2.4 KB
bluescsi_setup_sd_card(bool) 1.6 KB
mountSDCard() 1.5 KB
s2s_configInit 1.5 KB
print_sd_info() 1.3 KB

device_is_ejectable() accepted only optical and removable, while the physical
eject button (diskEjectAction) has always handled ZIP, floppy, MO and tape as
well. The front panel builds its Eject affordance from the wider set, so it
offered eject on a ZIP or a floppy and the panel command refused it.

Two things had to follow. The non-optical branch of the panel's eject set
ejected = true and switched the image - one way only - so a device the panel
could now eject could never be closed again. It calls diskPerformEject()
instead, the same toggle the button uses, which required exporting it from
BlueSCSI_disk.cpp. And tray-open status was reported for optical only, so the
web UI's Close button never appeared for any other removable.

Verified on the bench across a full device set: removable, floppy, optical
and ZIP all eject and close again; fixed disks and the network device are
still refused.
The panel learned whether it was talking to an initiator from the mode byte
in the GET_DEVICE_LIST reply. That command is async: the ISR accepts it and
the main loop completes it. An imaging board does not reach the main loop for
long stretches - it is driving the SCSI bus - so the reply never arrives, the
panel's operating_mode stayed TARGET, and the whole initiator screen was
unreachable on real hardware.

GET_PLAYBACK_STATUS is a synchronous read served straight from the ISR, which
is why the liveness probe kept working throughout. Put the mode there too,
in the byte the struct already reserved, so it costs nothing on the wire and
arrives whatever the board is doing. GET_DEVICE_LIST still carries it for
callers that already read it.
The panel could not see an initiator's progress. GET_INITIATOR_STATUS is async
- the ISR takes it, the main loop completes it - and a board that is imaging is
busy driving the SCSI bus and does not get to the main loop. So the one thing
that most needs to be live was carried on the one path that stalls.

Add PANEL_CMD_GET_INITIATOR_SUMMARY, a read command answered from the ISR, with
just what the imaging screen draws on every frame: phase, target, percent,
speed, and the found/imaged counts. Everything it reads comes out of
g_initiator_state with no bus access, so it is safe there. The async command
keeps the per-target detail, which is worth waiting for and tolerates being
stale.

One mode per response. The playback status stays what it was - target-mode
state - and does not learn about imaging; the summary carries no disc or track.
What they share is the three bytes that are neither: alive_magic, protocol
version, and which mode the board is in, so a panel polling either one can tell
it is talking to a live board that has changed mode.

The playback struct is repacked while the contract is open: three one-bit
states that cost a byte each become a flags byte, alive_magic moves to the
front so a reader built against another layout still finds it, and a protocol
version rides along - a silent layout mismatch is exactly the failure this
change would otherwise have introduced.
panel_spi_poll() disabled the panel's DMA IRQ for the whole of every initiator
bus transaction. Imaging issues READ10s back to back, so the interface was dead
for entire runs: the ESP32 could not complete a single transaction, could not
discover the board was even imaging, and could not attach to one that already
was. The panel and the web UI showed nothing at all.

There is no hardware reason for it. On Ultra the panel owns DMA_IRQ_3 and its
own claimed channels, and scsi_accel_host.cpp uses neither DMA nor interrupts,
so the two never contend - and target mode has always run alongside the same
ISR. It came in with the original panel-SPI commit, precautionary rather than
in answer to a measured problem.

Measured on the bench, imaging a 1GB target with the panel polling throughout:
1133 kB/s before, 1123 kB/s after, no parity errors and no retries. About 1%,
for a panel that works instead of one that does not.

panel_i2c.cpp still has the same suspension. The reasoning applies there too,
but the bench cannot image over I2C to prove it, so it stays until it can.
"Imaging SCSI ID 5 at 1337 kB/s" does not answer what it is imaging. The
identity is in the per-target table behind GET_INITIATOR_STATUS, which is async
and so is exactly the thing an imaging board cannot deliver - the panel and the
web UI were left with a rate and nothing else.

Carry the current target's type, vendor, product and size in the summary, which
the ISR answers. It is a few more bytes on a command that already reads the
same target's sector counts for the progress figure.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants