diff --git a/.github/workflows/firmware_build.yml b/.github/workflows/firmware_build.yml index 656bd548..c8c428cf 100644 --- a/.github/workflows/firmware_build.yml +++ b/.github/workflows/firmware_build.yml @@ -12,6 +12,11 @@ jobs: name: Build firmware on Ubuntu runs-on: ubuntu-latest + # A pull request build checks out the merge commit, so stamp artifacts with + # the PR head sha - the one a reporter can actually look up. + env: + DIST_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + steps: - name: Check out code from GitHub uses: actions/checkout@v6 @@ -32,6 +37,17 @@ jobs: authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} continue-on-error: true + - name: Download front panel firmware + env: + GH_TOKEN: ${{ github.token }} + run: | + cd BlueSCSI + PANEL_TAG=$(cat utils/frontpanel_version.txt) + mkdir -p panel-fw + gh release download "$PANEL_TAG" --repo polpo/open-retro-storage-frontpanel \ + -p 'bluescsi-frontpanel.bin' -D panel-fw + ls -la panel-fw/ + - name: Build firmware run: | cd BlueSCSI @@ -79,21 +95,56 @@ jobs: gh api repos/${GITHUB_REPOSITORY}/releases/tags/latest | jq -r '.assets[] | [.url] | @tsv' | xargs -n 1 gh api -X DELETE || true gh release upload --repo ${GITHUB_REPOSITORY} --clobber latest * - - name: Upload to newly created release + - name: Upload to tagged release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} if: ${{ startsWith(github.ref, 'refs/tags/') && github.repository == 'BlueSCSI/BlueSCSI-v2' }} run: | - set +e - RELEASE=$(basename ${{github.ref}}) + RELEASE="${GITHUB_REF#refs/tags/}" if [[ "$RELEASE" =~ ^v[0-9]{4}\.[0-9]{2}\.[0-9]{2}$ ]]; then - RELEASE_FLAGS="--latest" + # Dated release: land as a draft so the changelog can be written + # before it is published. + DATED=1 + CREATE_FLAGS="--draft --latest" else - RELEASE_FLAGS="--prerelease" + # Beta or test tag: publish it, and let a re-push of the same tag + # update that release rather than adding another one. + DATED=0 + CREATE_FLAGS="--prerelease" fi - gh release create --repo ${GITHUB_REPOSITORY} --draft -t "$RELEASE" $RELEASE_FLAGS "$RELEASE" BlueSCSI/dist/* - status=$? - set -e - if [ $status -ne 0 ]; then - gh release upload --repo ${GITHUB_REPOSITORY} --clobber "$RELEASE" BlueSCSI/dist/* + + # A draft release is not attached to its tag, so `gh release create` + # succeeds on a tag that already has a release and leaves a second, + # untagged one behind - which is what users then never see. Look the + # tag up first and replace the assets on the release it already has. + ID=$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \ + --jq "[.[] | select(.tag_name == \"$RELEASE\")] | sort_by(.draft) | .[0].id // empty") + + if [ -z "$ID" ]; then + gh release create "$RELEASE" --repo "${GITHUB_REPOSITORY}" -t "$RELEASE" \ + $CREATE_FLAGS BlueSCSI/dist/* + exit 0 + fi + + echo "Updating release $ID for tag $RELEASE" + if [ "$DATED" = "0" ]; then + gh api -X PATCH "repos/${GITHUB_REPOSITORY}/releases/${ID}" \ + -F draft=false -F prerelease=true >/dev/null fi + + # Asset names carry the build date and sha, so --clobber alone would + # leave the previous build sitting next to the new one. + gh api "repos/${GITHUB_REPOSITORY}/releases/${ID}" --jq '.assets[].url' \ + | xargs -r -n 1 gh api -X DELETE + + # Upload by release id: a draft cannot be addressed by tag. + UPLOAD=$(gh api "repos/${GITHUB_REPOSITORY}/releases/${ID}" --jq '.upload_url' | sed 's/{.*}//') + for f in BlueSCSI/dist/*; do + name=$(basename "$f") + curl -sSf -X POST "${UPLOAD}?name=$(printf '%s' "$name" | jq -sRr @uri)" \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @"$f" >/dev/null + echo "uploaded $name" + done diff --git a/.gitignore b/.gitignore index 8cdc429c..3c649c52 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ test/test_report.json .direnv/ build/ test/ +panel-fw/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 0200dd58..1225f5a9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -137,6 +137,15 @@ set(AUDIO_SPDIF_SOURCES lib/BlueSCSI_platform_RP2MCU/audio_spdif.cpp ) +# Front panel sources (SPI slave for Ultra/Ultra Wide, I2C slave for v2; +# panel_spi.cpp / panel_i2c.cpp self-gate on ENABLE_PANEL_SPI / ENABLE_PANEL_I2C). +set(PANEL_SOURCES + lib/BlueSCSI_platform_RP2MCU/panel_spi.cpp + lib/BlueSCSI_platform_RP2MCU/panel_i2c.cpp + lib/BlueSCSI_platform_RP2MCU/panel_protocol.cpp + lib/BlueSCSI_platform_RP2MCU/panel_sha256_sw.cpp +) + # SCSI2SD library sources set(SCSI2SD_SOURCES lib/SCSI2SD/src/firmware/scsi.c @@ -396,41 +405,65 @@ elseif(BLUESCSI_TARGET STREQUAL "Pico_2_Audio_SPDIF") set_target_properties(BlueSCSI PROPERTIES OUTPUT_NAME "BlueSCSI_Pico_2_Audio_SPDIF") elseif(BLUESCSI_TARGET STREQUAL "Ultra") - # BlueSCSI Ultra (RP2350B with I2S audio + network) - target_sources(BlueSCSI PRIVATE ${NETWORK_SOURCES} ${AUDIO_I2S_SOURCES}) + # BlueSCSI Ultra (RP2350B with I2S audio + network + front panel) + target_sources(BlueSCSI PRIVATE ${NETWORK_SOURCES} ${AUDIO_I2S_SOURCES} ${PANEL_SOURCES}) target_compile_definitions(BlueSCSI PRIVATE BLUESCSI_ULTRA BLUESCSI_MCU_RP23XX ENABLE_AUDIO_OUTPUT ENABLE_AUDIO_OUTPUT_I2S + ENABLE_PANEL_SPI BLUESCSI_NETWORK BLUESCSI_DAYNAPORT BLUESCSI_RM2 CYW43_PIO_CLOCK_DIV_DYNAMIC=1 LOGBUFSIZE=65536 ) - target_link_libraries(BlueSCSI PRIVATE pico_cyw43_arch_poll pico_async_context_poll) + target_link_libraries(BlueSCSI PRIVATE pico_cyw43_arch_poll pico_async_context_poll pico_sha256) set_target_properties(BlueSCSI PROPERTIES OUTPUT_NAME "BlueSCSI_Ultra") elseif(BLUESCSI_TARGET STREQUAL "Ultra_Wide") - # BlueSCSI Ultra Wide (RP2350B with wide SCSI + I2S audio) - target_sources(BlueSCSI PRIVATE ${AUDIO_I2S_SOURCES}) + # BlueSCSI Ultra Wide (RP2350B with wide SCSI + I2S audio + front panel) + target_sources(BlueSCSI PRIVATE ${AUDIO_I2S_SOURCES} ${PANEL_SOURCES}) target_compile_definitions(BlueSCSI PRIVATE BLUESCSI_ULTRA_WIDE BLUESCSI_MCU_RP23XX ENABLE_AUDIO_OUTPUT ENABLE_AUDIO_OUTPUT_I2S + ENABLE_PANEL_SPI RP2MCU_USE_CPU_PARITY RP2MCU_SCSI_ACCEL_WIDE CYW43_PIO_CLOCK_DIV_DYNAMIC=1 LOGBUFSIZE=32768 ) + target_link_libraries(BlueSCSI PRIVATE pico_sha256) set_target_properties(BlueSCSI PROPERTIES OUTPUT_NAME "BlueSCSI_Ultra_Wide") else() message(FATAL_ERROR "Unknown BLUESCSI_TARGET: ${BLUESCSI_TARGET}") endif() +# Front panel is available as a runtime option (the [SCSI] EnableFrontPanel ini +# key, default off) on the BlueSCSI v2 profiles that have the SRAM for it: +# Pico (RP2040 base) and all three RP2350 profiles (Pico_2 / _DaynaPORT / +# _Audio_SPDIF). The panel needs ~16 KB of private static buffers (full-size, so +# the async I2C ISR never shares memory with a live SCSI transfer); on RP2040 + +# WiFi the CYW43 stack leaves too little SRAM, so Pico_DaynaPORT and +# Pico_Audio_SPDIF are excluded. (Pico_Audio_SPDIF also routes SPDIF out on +# GPIO17 = the panel's SCL, so they couldn't coexist there anyway.) +# Ultra/Ultra Wide use the SPI panel (ENABLE_PANEL_SPI) instead. +if(BLUESCSI_TARGET MATCHES "^Pico" + AND NOT BLUESCSI_TARGET STREQUAL "Pico_DaynaPORT" + AND NOT BLUESCSI_TARGET STREQUAL "Pico_Audio_SPDIF") + target_sources(BlueSCSI PRIVATE ${PANEL_SOURCES}) + target_compile_definitions(BlueSCSI PRIVATE ENABLE_PANEL_I2C) + target_link_libraries(BlueSCSI PRIVATE pico_i2c_slave) + if(BLUESCSI_TARGET MATCHES "^Pico_2") + # RP2350 uses the hardware SHA-256 block for the firmware-relay hash path. + target_link_libraries(BlueSCSI PRIVATE pico_sha256) + endif() +endif() + # ============================================================================= # Linker Script Configuration # ============================================================================= @@ -490,17 +523,21 @@ if(PICO_PLATFORM STREQUAL "rp2040") target_compile_definitions(BlueSCSI PRIVATE PICO_CRT0_ALLOCATE_SPACERS=0) endif() -# RP2350 SCRATCH_Y is 4KB but the SDK default stack is only 2KB (0x800). -# The image-opening call chain (scsiDiskOpenHDDImage + SdFat + SDIO) uses ~1.5KB -# alone, and RP2350's hardware MSPLIM catches the overflow that RP2040 silently -# ignores. Increase to 3KB — not 4KB because Audio SPDIF targets place ~768 bytes -# of lookup tables in .scratch_y. Keep core 1's stack at default in SCRATCH_X. -if(PICO_PLATFORM STREQUAL "rp2350-arm-s") - target_compile_definitions(BlueSCSI PRIVATE - PICO_STACK_SIZE=0xC00 - PICO_CORE1_STACK_SIZE=0x800 - ) -endif() +# The SDK default stack is only 2KB (0x800), and the image-opening call chain +# (switchNextImage -> scsiDiskOpenHDDImage -> cdromValidateCueSheet, plus SdFat +# and CUEParser below them) measures ~1.7KB on its own — reachable from a plain +# host-driven eject, with no front panel involved. RP2350's hardware MSPLIM +# faults on overflow; RP2040 silently corrupts .bss instead, so it needs the +# headroom at least as much. Increase to 3KB on both — not 4KB because Audio +# SPDIF targets place ~768 bytes of lookup tables in .scratch_y. Keep core 1's +# stack at default in SCRATCH_X. +# Core 1's stack must stay pinned at the 2KB default: it lives in SCRATCH_X, and +# PICO_CORE1_STACK_SIZE otherwise inherits PICO_STACK_SIZE, which overflows +# SCRATCH_X on the Audio SPDIF and DaynaPORT targets. +target_compile_definitions(BlueSCSI PRIVATE + PICO_STACK_SIZE=0xC00 + PICO_CORE1_STACK_SIZE=0x800 +) # ============================================================================= # Bootloader Build diff --git a/flake.nix b/flake.nix index 5494df59..e7014244 100644 --- a/flake.nix +++ b/flake.nix @@ -17,9 +17,9 @@ pico-sdk = pkgs.fetchFromGitHub { owner = "bluescsi"; repo = "pico-sdk-internal"; - rev = "v2.2.0-UltraSupport-rel3"; + rev = "v2.2.0-UltraSupport-rel4"; fetchSubmodules = true; - hash = "sha256-C4ZCVNMlRJkDwh9h90YVmnwqFCT4ldcdHnIMskRFXhM="; + hash = "sha256-1tcaowoFu0n6ixrkALBwYrvjfnXfgEkQqUm7H7NpyGc="; }; pico-extras = pkgs.fetchFromGitHub { diff --git a/lib/BlueSCSI_platform_RP2MCU/BlueSCSI_platform.cpp b/lib/BlueSCSI_platform_RP2MCU/BlueSCSI_platform.cpp index 48e04bc8..0740416a 100644 --- a/lib/BlueSCSI_platform_RP2MCU/BlueSCSI_platform.cpp +++ b/lib/BlueSCSI_platform_RP2MCU/BlueSCSI_platform.cpp @@ -51,6 +51,12 @@ extern "C" { #include #include "scsi_accel_target.h" #include "custom_timings.h" +#ifdef ENABLE_PANEL_SPI +#include "panel_spi.h" +#endif +#ifdef ENABLE_PANEL_I2C +#include "panel_i2c.h" +#endif #include #include @@ -1189,6 +1195,26 @@ void platform_post_sd_card_init() // one-time control setup for DMA channels and second core audio_setup(); #endif // ENABLE_AUDIO_OUTPUT + +#ifdef ENABLE_PANEL_SPI + // Initialize front panel SPI interface after SD card is ready + panel_spi_init(); +#endif // ENABLE_PANEL_SPI +#ifdef ENABLE_PANEL_I2C + // Initialize front panel I2C slave (v2) only when enabled in the INI; it + // claims GPIO16/17 exclusively (no buttons / SPDIF on those pins). + if (g_scsi_settings.getSystem()->enableFrontPanel) { + panel_i2c_init(); + } +#elif !defined(ENABLE_PANEL_SPI) + // No panel support in this build (RP2040 network/SPDIF: the panel's + // buffers don't fit alongside CYW43, and SPDIF out shares the panel's SCL + // pin). Say so instead of silently ignoring the setting. + if (g_scsi_settings.getSystem()->enableFrontPanel) { + logmsg("EnableFrontPanel is set, but this firmware has no front panel support"); + logmsg("-- the front panel needs the Pico (non-network) build, or any Pico 2 board"); + } +#endif // ENABLE_PANEL_I2C } bool platform_is_initiator_mode_enabled() @@ -1768,6 +1794,13 @@ void platform_poll() } #endif +#ifdef ENABLE_PANEL_SPI + panel_spi_poll(); +#endif +#ifdef ENABLE_PANEL_I2C + panel_i2c_poll(); // no-op until panel_i2c_init() runs (front panel enabled) +#endif + #if defined(ENABLE_AUDIO_OUTPUT_SPDIF) || defined(ENABLE_AUDIO_OUTPUT_I2S) audio_poll(); #endif // ENABLE_AUDIO_OUTPUT_SPDIF @@ -1777,9 +1810,6 @@ void platform_reset_mcu() { watchdog_reboot(0, 0, 2000); } -bool platform_has_i2c() { - return is2023a; -} bool disable_i2c = false; void platform_disable_i2c() { gpio_conf(GPIO_I2C_SCL, GPIO_FUNC_SIO, true, false, false, false, false); diff --git a/lib/BlueSCSI_platform_RP2MCU/BlueSCSI_platform_gpio_ultra.h b/lib/BlueSCSI_platform_RP2MCU/BlueSCSI_platform_gpio_ultra.h index 4bfee8d8..a0a77152 100644 --- a/lib/BlueSCSI_platform_RP2MCU/BlueSCSI_platform_gpio_ultra.h +++ b/lib/BlueSCSI_platform_RP2MCU/BlueSCSI_platform_gpio_ultra.h @@ -200,3 +200,14 @@ // Read SCSI data bus #define SCSI_IN_DATA() \ (~sio_hw->gpio_in & SCSI_IO_DATA_MASK) >> SCSI_IO_SHIFT + +// Front panel SPI interface (SPI0 on expansion header) +#ifdef ENABLE_PANEL_SPI + #define PANEL_SPI spi0 + #define PANEL_SPI_RX 32 + #define PANEL_SPI_CS 33 + #define PANEL_SPI_SCK 34 + #define PANEL_SPI_TX 35 + #define PANEL_DMA_IRQ_IDX 3 + #define PANEL_DMA_IRQ_NUM DMA_IRQ_3 +#endif diff --git a/lib/BlueSCSI_platform_RP2MCU/BlueSCSI_platform_gpio_ultra_wide.h b/lib/BlueSCSI_platform_RP2MCU/BlueSCSI_platform_gpio_ultra_wide.h index cd77c716..f0012c4e 100644 --- a/lib/BlueSCSI_platform_RP2MCU/BlueSCSI_platform_gpio_ultra_wide.h +++ b/lib/BlueSCSI_platform_RP2MCU/BlueSCSI_platform_gpio_ultra_wide.h @@ -278,4 +278,13 @@ static inline bool scsi_check_parity_16bit(uint32_t w) #define SCSI_IN_DATA() \ (~sio_hw->gpio_in & SCSI_IO_DATA_MASK) >> SCSI_IO_SHIFT - +// Front panel SPI interface (SPI1 on GPIO 44-47) +#ifdef ENABLE_PANEL_SPI + #define PANEL_SPI spi1 + #define PANEL_SPI_RX 44 + #define PANEL_SPI_CS 41 + #define PANEL_SPI_SCK 46 + #define PANEL_SPI_TX 47 + #define PANEL_DMA_IRQ_IDX 0 + #define PANEL_DMA_IRQ_NUM DMA_IRQ_0 +#endif diff --git a/lib/BlueSCSI_platform_RP2MCU/panel_i2c.cpp b/lib/BlueSCSI_platform_RP2MCU/panel_i2c.cpp new file mode 100644 index 00000000..f7b1a451 --- /dev/null +++ b/lib/BlueSCSI_platform_RP2MCU/panel_i2c.cpp @@ -0,0 +1,444 @@ +/** + * BlueSCSI - Copyright (c) 2026 Eric Helgeson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * ---- + * + * Panel I2C Slave Driver Implementation + * + * Interrupt-driven I2C slave (pico_i2c_slave) for communication with the + * ESP32-C3 front panel on BlueSCSI v2. Drives the same transport-agnostic + * panel_protocol handlers as the SPI slave (panel_spi.cpp). + * + * Wire model (see panel_protocol_defs.h): + * - The master writes a 5-byte header (+ payload for write commands) in one + * I2C write transaction, then reads the response in a separate read + * transaction (the ESP32 inserts a short inter-phase delay between them). + * - Read responses are prepared in the FINISH ISR of the header-write + * transaction (the SPI slave likewise prepares reads in IRQ context), so + * the data is ready when the master's read transaction arrives. + * - Write commands run their (possibly multi-ms) SD I/O in the main loop and + * are deferred while the SCSI bus is active, mirroring the SPI slave. + */ + +#include "panel_i2c.h" +#include "panel_protocol_defs.h" +#include "panel_protocol.h" +#include "BlueSCSI_platform.h" +#include "BlueSCSI_log.h" +#include "BlueSCSI_initiator.h" + +#ifdef ENABLE_PANEL_I2C + +extern "C" { +#include +} + +#include +#include +#include +#include +#include + +// I2C wiring on BlueSCSI v2: GPIO16/17 are the i2c0 SDA/SCL pins. When the +// front panel is enabled these pins are the panel link exclusively (no IO +// expander / buttons / SPDIF), so claiming them here is safe. +#define PANEL_I2C_INST i2c0 +#define PANEL_I2C_IRQ I2C0_IRQ +#define PANEL_I2C_SDA GPIO_I2C_SDA // GPIO16 on v2 +#define PANEL_I2C_SCL GPIO_I2C_SCL // GPIO17 on v2 +#define PANEL_I2C_ADDR 0x50 // matches the ESP32 master (HOST_DEVICE_ADDR) +#define PANEL_I2C_BAUD 1000000 // 1 MHz (fast-mode-plus); master drives the clock + +// Size of the dedicated synchronous-read response buffer. Must hold the largest +// IRQ-context read response (panel_playback_status_t = 76 bytes); 128 leaves +// margin. Kept separate from tx_payload so a small sync read can never clobber +// an async result staged in tx_payload still awaiting its POLL_OP_READY drain. +#define PANEL_SYNC_RESPONSE_SIZE 128 + +// What the TX buffer currently staged for a read transaction represents, so the +// FINISH after a read can sequence the POLL_OP_READY -> result follow-up read. +typedef enum { + TX_NONE, + TX_SYNC, // a normal synchronous read response + TX_STATUS, // a POLL_OP_READY 3-byte status response + TX_RESULT, // an async result payload (the read following a READY status) +} panel_tx_purpose_t; + +static struct { + bool initialized; + i2c_inst_t* i2c; + + // --- RX assembly (ISR context) --- + // Header bytes of the in-progress write transaction. + uint8_t rx_header_bytes[PANEL_PROTOCOL_HEADER_SIZE]; + volatile uint16_t rx_count; // bytes seen in the current write transaction + panel_protocol_header_t cur_header; // parsed once the 5th header byte lands + uint8_t* payload_dest; // where post-header bytes land this transaction + volatile uint16_t payload_idx; // payload bytes received this transaction + + // --- TX serving (ISR context) --- + const uint8_t* tx_src; + volatile uint16_t tx_len; + volatile uint16_t tx_idx; + volatile panel_tx_purpose_t tx_purpose; + volatile bool serve_result_next; // a READY status will be followed by a result read + + // Buffers + uint8_t tx_payload[PANEL_PROTOCOL_MAX_PAYLOAD] __attribute__((aligned(4))); + uint8_t sync_response[PANEL_SYNC_RESPONSE_SIZE] __attribute__((aligned(4))); + panel_status_response_t status_response; + + // Async operation state (same model as the SPI slave) + volatile panel_async_state_t async_state; + volatile uint16_t async_response_size; + uint8_t pending_async_command; + + // IRQ suspend during initiator SCSI bus operations + bool irq_suspended; + + // Logging + bool first_transaction_logged; +} g_panel; + +// Receive buffer for a write command, handed from the ISR to the main loop. +// The ISR lands the payload straight in .payload and flips .ready; the main +// loop dispatches once the SCSI bus is idle. This is the live receive +// destination, not a snapshot: only the protocol's POLL_OP_READY rule keeps a +// second write from overwriting a queued or in-flight one. +static struct { + volatile bool ready; + uint8_t command; + uint16_t argument; + uint16_t payload_size; + uint8_t payload[PANEL_PROTOCOL_MAX_PAYLOAD] __attribute__((aligned(4))); +} g_deferred_write; + +// CRC-16-CCITT (poly 0x1021, init 0xFFFF, no reflection) — matches the RP2040 +// DMA sniffer config the SPI slave uses, so chunk CRCs validate identically. +static uint16_t panel_i2c_crc16(const uint8_t* data, size_t len) { + uint16_t crc = 0xFFFF; + for (size_t i = 0; i < len; i++) { + crc ^= (uint16_t)data[i] << 8; + for (int b = 0; b < 8; b++) { + crc = (crc & 0x8000) ? (uint16_t)((crc << 1) ^ 0x1021) : (uint16_t)(crc << 1); + } + } + return crc; +} + +// ============================================================================ +// I2C slave event handler (runs in the I2C ISR) +// ============================================================================ + +// Prepare the response for the read transaction that follows a header write. +// Called from the FINISH ISR; reads are serviced here (not the main loop) so +// the data is ready when the master's separate read transaction arrives. +static void panel_i2c_prepare_read(void) { + uint8_t cmd = g_panel.cur_header.command; + + if (cmd == PANEL_CMD_POLL_OP_READY) { + g_panel.status_response.ready_flag = g_panel.async_state; + g_panel.status_response.response_size = + (g_panel.async_state == PANEL_ASYNC_READY) ? g_panel.async_response_size : 0; + g_panel.tx_src = (const uint8_t*)&g_panel.status_response; + g_panel.tx_len = sizeof(g_panel.status_response); + g_panel.tx_idx = 0; + g_panel.tx_purpose = TX_STATUS; + g_panel.serve_result_next = + (g_panel.async_state == PANEL_ASYNC_READY && g_panel.async_response_size > 0); + return; + } + + // Normal synchronous read. Write the response into the dedicated + // sync_response buffer so it cannot clobber an async result staged in + // tx_payload that is still awaiting POLL_OP_READY. Oversized requests (none + // are legitimate) fall back to tx_payload so the source is always large + // enough for payload_size bytes. + uint16_t want = g_panel.cur_header.payload_size; + uint8_t* buf = (want <= PANEL_SYNC_RESPONSE_SIZE) ? g_panel.sync_response : g_panel.tx_payload; + size_t cap = (want <= PANEL_SYNC_RESPONSE_SIZE) ? PANEL_SYNC_RESPONSE_SIZE : PANEL_PROTOCOL_MAX_PAYLOAD; + panel_protocol_handle_read(cmd, g_panel.cur_header.argument, buf, cap); + g_panel.tx_src = buf; + // Clamp to the staging buffer: payload_size is master-supplied and can name + // up to 64KB, which the REQUEST handler would happily serve off the end of + // SRAM from inside the ISR. Past the staged length it pads with zero. + g_panel.tx_len = (want <= cap) ? want : (uint16_t)cap; + g_panel.tx_idx = 0; + g_panel.tx_purpose = TX_SYNC; +} + +// Process a completed header-write transaction (header, plus payload for write +// commands). Reads are staged for the follow-up read; writes are shadowed for +// the main loop. +static void panel_i2c_process_write_txn(void) { + if (g_panel.rx_count < PANEL_PROTOCOL_HEADER_SIZE) { + return; // short/malformed header — ignore + } + + uint8_t cmd = g_panel.cur_header.command; + + if (PANEL_CMD_IS_READ(cmd)) { + panel_i2c_prepare_read(); + return; + } + + // A write is already staged. Drop this one so the queued command keeps its + // own length and CRC. Its payload bytes were already overwritten during + // RECEIVE, so this does not make them safe. + if (g_deferred_write.ready) { + return; + } + + // Write command: shadow it for the main loop. Async state goes PROCESSING + // now so the ESP32's POLL_OP_READY sees the command was received even while + // dispatch is deferred. + if (PANEL_CMD_IS_ASYNC(cmd)) { + g_panel.pending_async_command = cmd; + g_panel.async_state = PANEL_ASYNC_PROCESSING; + } + g_deferred_write.command = cmd; + g_deferred_write.argument = g_panel.cur_header.argument; + // Payload was received straight into g_deferred_write.payload; trust the + // bytes actually received over a header that claims more than arrived. + // Copy out of the packed header / volatile field before comparing. + uint16_t hdr_size = g_panel.cur_header.payload_size; + uint16_t got = g_panel.payload_idx; + g_deferred_write.payload_size = (got < hdr_size) ? got : hdr_size; + g_deferred_write.ready = true; +} + +static void panel_i2c_handler(i2c_inst_t* i2c, i2c_slave_event_t event) { + switch (event) { + case I2C_SLAVE_RECEIVE: { + uint8_t b = i2c_read_byte_raw(i2c); + if (g_panel.rx_count < PANEL_PROTOCOL_HEADER_SIZE) { + g_panel.rx_header_bytes[g_panel.rx_count] = b; + g_panel.rx_count++; + if (g_panel.rx_count == PANEL_PROTOCOL_HEADER_SIZE) { + // Header complete — parse and pick the payload destination. + memcpy(&g_panel.cur_header, g_panel.rx_header_bytes, + sizeof(g_panel.cur_header)); + g_panel.payload_idx = 0; + // Only write commands carry a payload after the header; for + // read commands the master sends no further bytes, so drop + // anything unexpected (NULL dest) instead of buffering it. + // Also drop when the buffer still holds a write the main + // loop has not finished with, so a retransmit cannot + // overwrite it. process_write_txn drops the command too. + bool own = PANEL_CMD_IS_WRITE(g_panel.cur_header.command) + && !g_deferred_write.ready; + g_panel.payload_dest = own ? g_deferred_write.payload : NULL; + } + } else { + // Post-header payload byte (write commands only in practice). + // Saturate rather than count past the buffer: payload_idx is the + // length handed to the main loop, so letting it run past the end + // would CRC and strnlen() beyond g_deferred_write.payload. + if (g_panel.payload_dest && g_panel.payload_idx < PANEL_PROTOCOL_MAX_PAYLOAD) { + g_panel.payload_dest[g_panel.payload_idx] = b; + g_panel.payload_idx++; + } + } + break; + } + + case I2C_SLAVE_REQUEST: { + // Master is reading. Serve staged bytes, pad with zero past the end. + uint8_t b = (g_panel.tx_idx < g_panel.tx_len) ? g_panel.tx_src[g_panel.tx_idx] : 0x00; + if (g_panel.tx_idx < g_panel.tx_len) { + g_panel.tx_idx++; + } + i2c_write_byte_raw(i2c, b); + break; + } + + case I2C_SLAVE_FINISH: { + if (g_panel.rx_count > 0) { + // This was a write/header transaction. + panel_i2c_process_write_txn(); + g_panel.rx_count = 0; + g_panel.payload_idx = 0; + } else { + // This was a read transaction; the master consumed our TX. + switch (g_panel.tx_purpose) { + case TX_STATUS: + if (g_panel.serve_result_next) { + // Stage the async result for the follow-up read. + g_panel.tx_src = g_panel.tx_payload; + g_panel.tx_len = g_panel.async_response_size; + g_panel.tx_idx = 0; + g_panel.tx_purpose = TX_RESULT; + g_panel.serve_result_next = false; + } else { + g_panel.tx_purpose = TX_NONE; + } + break; + case TX_RESULT: + // Async result delivered — clear state (mirrors the SPI + // slave's PHASE_PAYLOAD cleanup). + g_panel.async_state = PANEL_ASYNC_IDLE; + g_panel.async_response_size = 0; + g_panel.tx_purpose = TX_NONE; + break; + default: + g_panel.tx_purpose = TX_NONE; + break; + } + } + break; + } + } +} + +// ============================================================================ +// Public API +// ============================================================================ + +bool panel_i2c_init(void) { + if (g_panel.initialized) { + return true; + } + + memset(&g_panel, 0, sizeof(g_panel)); + g_panel.i2c = PANEL_I2C_INST; + g_panel.tx_purpose = TX_NONE; + g_deferred_write.ready = false; + + logmsg("Panel I2C: Initializing slave (7-bit addr=", (int)PANEL_I2C_ADDR, + " i2c0, SDA=", (int)PANEL_I2C_SDA, " SCL=", (int)PANEL_I2C_SCL, ")"); + + gpio_set_function(PANEL_I2C_SDA, GPIO_FUNC_I2C); + gpio_set_function(PANEL_I2C_SCL, GPIO_FUNC_I2C); + gpio_pull_up(PANEL_I2C_SDA); + gpio_pull_up(PANEL_I2C_SCL); + + i2c_init(g_panel.i2c, PANEL_I2C_BAUD); + i2c_slave_init(g_panel.i2c, PANEL_I2C_ADDR, &panel_i2c_handler); + + panel_protocol_init(); + + g_panel.initialized = true; + logmsg("Panel I2C: Initialized successfully"); + return true; +} + +void panel_i2c_deinit(void) { + if (!g_panel.initialized) { + return; + } + i2c_slave_deinit(g_panel.i2c); + i2c_deinit(g_panel.i2c); + g_panel.initialized = false; + logmsg("Panel I2C: Deinitialized"); +} + +static void panel_i2c_dispatch_write(void) { + uint8_t cmd = g_deferred_write.command; + uint16_t arg = g_deferred_write.argument; + uint16_t size = g_deferred_write.payload_size; + + if (size > 0) { + uint16_t crc = panel_i2c_crc16(g_deferred_write.payload, size); + panel_protocol_handle_write(cmd, arg, g_deferred_write.payload, size, crc); + } else { + panel_protocol_handle_write(cmd, arg, NULL, 0, 0); + } +} + +void panel_i2c_poll(void) { + if (!g_panel.initialized) { + return; + } + + // During initiator SCSI bus operations, suspend the I2C IRQ; resume cleanly + // when the bus is free. + if (scsiInitiatorBusBusy()) { + if (!g_panel.irq_suspended) { + irq_set_enabled(PANEL_I2C_IRQ, false); + g_panel.irq_suspended = true; + } + return; + } + if (g_panel.irq_suspended) { + // Drain any partial transaction state and resume. + g_panel.rx_count = 0; + g_panel.payload_idx = 0; + g_panel.tx_purpose = TX_NONE; + g_panel.serve_result_next = false; + while (i2c_get_read_available(g_panel.i2c)) { + (void)i2c_read_byte_raw(g_panel.i2c); + } + g_panel.irq_suspended = false; + irq_set_enabled(PANEL_I2C_IRQ, true); + } + + // Refresh the device-status snapshot from the main loop so the IRQ-context + // read handlers never touch img->file (which switchNextImage reassigns). + // Only while the bus is idle: platform_poll() is called from inside the SCSI + // transfer loops, and the periodic name refresh calls getName(), which can + // miss the FAT cache and block on an SD read mid-transfer. + if (!panel_scsi_bus_busy()) { + panel_protocol_refresh_device_snapshot(); + } + + if (!g_deferred_write.ready) { + return; + } + + // Defer write dispatch (and its SD I/O) until the SCSI bus is idle. + if (panel_scsi_bus_busy()) { + return; + } + + if (!g_panel.first_transaction_logged) { + g_panel.first_transaction_logged = true; + logmsg("Panel I2C: first write command dispatched (cmd=0x", (int)g_deferred_write.command, + ") - front panel connected"); + } + panel_protocol_drain_irq_log(); + + panel_i2c_dispatch_write(); + + // Released only now. Holding it across dispatch keeps the ISR off the + // payload while the handler reads it and runs its SD I/O. + g_deferred_write.ready = false; +} + +bool panel_i2c_is_initialized(void) { + return g_panel.initialized; +} + +void panel_i2c_set_async_result(const uint8_t* data, size_t size) { + if (size > PANEL_PROTOCOL_MAX_PAYLOAD) { + size = PANEL_PROTOCOL_MAX_PAYLOAD; + } + if (size > 0 && data != NULL && data != g_panel.tx_payload) { + memcpy(g_panel.tx_payload, data, size); + } + g_panel.async_response_size = size; + g_panel.async_state = PANEL_ASYNC_READY; +} + +void panel_i2c_set_async_error(void) { + g_panel.async_response_size = 0; + g_panel.async_state = PANEL_ASYNC_ERROR; +} + +uint8_t* panel_i2c_get_tx_buffer(void) { + return g_panel.tx_payload; +} + +#endif // ENABLE_PANEL_I2C diff --git a/lib/BlueSCSI_platform_RP2MCU/panel_i2c.h b/lib/BlueSCSI_platform_RP2MCU/panel_i2c.h new file mode 100644 index 00000000..07808703 --- /dev/null +++ b/lib/BlueSCSI_platform_RP2MCU/panel_i2c.h @@ -0,0 +1,74 @@ +/** + * BlueSCSI - Copyright (c) 2026 Eric Helgeson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * ---- + * + * Panel I2C Slave Driver + * + * Handles I2C communication with the ESP32-C3 front panel on BlueSCSI v2. + * BlueSCSI acts as the I2C slave; the ESP32 is the master. It drives the same + * transport-agnostic panel_protocol handlers as the SPI slave (panel_spi.cpp), + * so only the wire mechanics differ. + * + * Protocol (matches the SPI slave / panel_protocol_defs.h): + * Phase 1: master writes a 5-byte header (+ payload for write commands) + * Phase 2: master reads the response in a separate transaction + * Async results are drained via POLL_OP_READY then a follow-up read. + */ + +#pragma once + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Initialize the panel I2C slave interface (i2c0 @ 0x50, 1 MHz on the v2 + * GPIO16/17 pins). Call only when the front panel is enabled in the INI; + * it claims those pins exclusively (no buttons / SPDIF on them). + * + * @return true on success, false on failure + */ +bool panel_i2c_init(void); + +/** Deinitialize the panel I2C interface and release the pins. */ +void panel_i2c_deinit(void); + +/** + * Poll the panel I2C interface from the main loop. Refreshes the device + * snapshot and dispatches deferred write commands once the SCSI bus is idle. + */ +void panel_i2c_poll(void); + +/** @return true if the I2C slave is initialized and operational. */ +bool panel_i2c_is_initialized(void); + +/** Stage an async operation result (called by protocol handlers). */ +void panel_i2c_set_async_result(const uint8_t* data, size_t size); + +/** Signal that an async operation completed with error. */ +void panel_i2c_set_async_error(void); + +/** @return the TX payload buffer protocol handlers stage async results into. */ +uint8_t* panel_i2c_get_tx_buffer(void); + +#ifdef __cplusplus +} +#endif diff --git a/lib/BlueSCSI_platform_RP2MCU/panel_protocol.cpp b/lib/BlueSCSI_platform_RP2MCU/panel_protocol.cpp new file mode 100644 index 00000000..79c67fa1 --- /dev/null +++ b/lib/BlueSCSI_platform_RP2MCU/panel_protocol.cpp @@ -0,0 +1,2233 @@ +/** + * BlueSCSI - Copyright (c) 2026 Eric Helgeson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * ---- + * + * Panel Protocol Handler Implementation + * + * Translates panel protocol commands into BlueSCSI operations. + */ + +#include "panel_protocol.h" +#include "panel_protocol_defs.h" +#include "panel_protocol_defs_initiator.h" +#include "panel_transport.h" +#include +#include +#include + +#if defined(ENABLE_PANEL_SPI) || defined(ENABLE_PANEL_I2C) + +#include +#include +#include +#include "panel_sha256.h" // pico_sha256 on RP2350, software SHA-256 on RP2040 (v2) +#include + +// Include BlueSCSI headers for image access +#include "BlueSCSI_disk.h" +#include "BlueSCSI_cdrom.h" +#include "BlueSCSI_initiator.h" +#include + +// External SD card +extern SdFs SD; + +// External disk images array +extern image_config_t g_DiskImages[S2S_MAX_TARGETS]; + +// True when the SCSI target bus is active OR a host selection is latched but +// not yet serviced. Panel write commands run their (potentially multi-ms) SD +// I/O in the main loop and block scsiPoll() while doing so, so the transports +// defer them until the bus is genuinely idle — not just during DATA phases. +// (DATA_IN/DATA_OUT are a subset of phase != BUS_FREE.) +bool panel_scsi_bus_busy(void) { + return scsiDev.phase != BUS_FREE || scsiDev.selFlag; +} + +// Panel firmware path on SD card (populated by the zip updater in +// firmware_update(); may also be placed there manually) +static const char* PANEL_FW_PATH = PANEL_FIRMWARE_PATH; + +// Maximum cached directory entries. Sized to fit the Ultra (RP2350) RAM +// budget: each entry is ~65 bytes, so this cache dominates panel RAM. 128 +// entries keeps the static cost ~8.3KB while still listing large directories; +// directories with more image entries are truncated (logged in scan()). +#define MAX_DIR_ENTRIES 128 + +// ESP32 firmware version location in binary (esp_app_desc_t.version at offset 0x30) +#define ESP32_VERSION_OFFSET 0x30 +#define ESP32_VERSION_MAX_LEN 32 + +// Case-insensitive extension match (hasExtension() in BlueSCSI_disk.cpp is static). +static bool panel_has_extension(const char* name, const char* ext) { + const char* dot = strrchr(name, '.'); + return dot && strcasecmp(dot, ext) == 0; +} + +// Reject a path with any ".." component. The front panel is a trusted local +// device, but a ".." lets a panel-supplied filename escape its intended +// directory, so we refuse it defensively on every path the panel provides. +static bool panel_path_has_traversal(const char* name) { + if (!name) return true; + for (const char* p = name; *p; p++) { + if (p[0] == '.' && p[1] == '.' && + (p[2] == '\0' || p[2] == '/' || p[2] == '\\') && + (p == name || p[-1] == '/' || p[-1] == '\\')) { + return true; + } + } + return false; +} + +// ============================================================================ +// Device lookup helpers for panel commands +// ============================================================================ + +// Get device by index (SCSI ID for BlueSCSI) +static image_config_t* get_device_by_index(uint16_t index) { + if (index >= S2S_MAX_TARGETS) return nullptr; + image_config_t& img = g_DiskImages[index]; + if (img.scsiId & S2S_CFG_TARGET_ENABLED) { + return &img; + } + return nullptr; +} + +// The device types the physical eject button handles - see diskEjectAction() in +// BlueSCSI_disk.cpp. The panel menu and the web UI build their eject +// affordances from the same set, so all three agree on what can be ejected. +static bool device_type_is_ejectable(uint8_t device_type) { + switch (device_type) { + case S2S_CFG_OPTICAL: + case S2S_CFG_REMOVABLE: + case S2S_CFG_ZIP100: + case S2S_CFG_FLOPPY_14MB: + case S2S_CFG_MO: + case S2S_CFG_SEQUENTIAL: + return true; + default: + return false; + } +} + +static bool device_is_ejectable(image_config_t* img) { + return img && device_type_is_ejectable(img->deviceType); +} + +// Name of the image loaded on a target, for panel display. A directly-loaded +// .cue keeps its filename in current_image while img.file points at the cue's +// parent directory, so getFilename() alone would show the directory name. +// MAIN LOOP ONLY - getFilename() races switchNextImage() in the IRQ. +static size_t panel_loaded_image_name(image_config_t& img, char* buf, size_t buflen) { + if (img.cue_loaded_directly) { + strncpy(buf, img.current_image, buflen - 1); + buf[buflen - 1] = '\0'; + return strlen(buf); + } + size_t n = img.file.getFilename(buf, buflen); + if (n >= buflen) n = buflen - 1; + buf[n] = '\0'; + return n; +} + +// Snapshot of per-device status, refreshed from the MAIN LOOP and read by the +// IRQ-context read handlers (GET_DEVICE_STATUS / GET_PLAYBACK_STATUS). +// +// The IRQ must NOT touch img->file directly: switchNextImage() reassigns the +// whole ImageBackingStore (img.file = ImageBackingStore(...)) in the main loop, +// and an IRQ read of img->file.isOpen() mid-reassignment is a data race (torn +// read of the struct, with a small chance of faulting on m_fsfile internals). +// The snapshot is plain bytes the IRQ can read safely. +struct panel_device_snapshot_t { + uint8_t present; // device configured/enabled + uint8_t loaded; // image file open + uint8_t ejected; // optical tray open (img.ejected) + uint8_t device_type; // S2S_CFG_* + char image_name[64]; // cached filename; getFilename() is unsafe in the IRQ +}; +static volatile panel_device_snapshot_t g_device_snapshot[S2S_MAX_TARGETS]; + +// getName() reconstructs the long filename and is too costly to run for every +// target on every poll, so the cached names are refreshed on this interval +// (present/loaded/device_type stay exact every call). A device that newly +// loads is refreshed immediately regardless, so inserts show without lag. +#define PANEL_SNAPSHOT_NAME_REFRESH_MS 250 + +// How long a transfer may sit with no START/chunk activity before the firmware +// reclaims its file handle (and, for uploads, the SHA-256 lock). Generous: a +// slow SD card plus a retrying panel must never trip it mid-transfer. +#define PANEL_TRANSFER_IDLE_TIMEOUT_MS 60000 + +// Defined with the transfer state below; called from the periodic snapshot hook. +static void panel_upload_reclaim_if_idle(uint32_t now); +static void panel_download_reclaim_if_idle(uint32_t now); +static uint32_t g_snapshot_name_refresh_ms = 0; + +// Refresh the device snapshot. MUST be called only from the main loop — it +// reads img->file.isOpen()/getFilename(), which race switchNextImage() if +// called from the IRQ. The main loop is single-threaded w.r.t. switchNextImage, +// so the reads here are consistent. +void panel_protocol_refresh_device_snapshot(void) { + uint32_t now = millis(); + + panel_upload_reclaim_if_idle(now); + panel_download_reclaim_if_idle(now); + bool refresh_names = (now - g_snapshot_name_refresh_ms) >= PANEL_SNAPSHOT_NAME_REFRESH_MS; + + for (int i = 0; i < S2S_MAX_TARGETS; i++) { + image_config_t& img = g_DiskImages[i]; + bool present = (img.scsiId & S2S_CFG_TARGET_ENABLED) != 0; + bool loaded = present && img.file.isOpen(); + + // Cache the filename so the IRQ never calls getFilename(). Re-read on + // the interval, and immediately whenever a device transitions to loaded. + if (loaded && (refresh_names || !g_device_snapshot[i].loaded)) { + char name[sizeof(g_device_snapshot[i].image_name)]; + panel_loaded_image_name(img, name, sizeof(name)); + memcpy((void *)g_device_snapshot[i].image_name, name, sizeof(name)); + } else if (!loaded) { + g_device_snapshot[i].image_name[0] = '\0'; + } + + // Write device_type/name/present/ejected before loaded so an IRQ that + // observes loaded == 1 also sees consistent fields. + g_device_snapshot[i].device_type = img.deviceType; + g_device_snapshot[i].ejected = (present && img.ejected) ? 1 : 0; + g_device_snapshot[i].present = present ? 1 : 0; + g_device_snapshot[i].loaded = loaded ? 1 : 0; + } + + if (refresh_names) { + g_snapshot_name_refresh_ms = now; + } +} + +// The firmware's own files live on the same card as the images. List them so +// the web file manager can still download a log or fetch the ini, but flag +// them so neither browser offers to load one as a disc image. +static bool panel_is_firmware_file(const char* name) { + static const char* const names[] = { CONFIGFILE, LOGFILE, LASTLOGFILE, CRASHFILE }; + for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); i++) { + if (strcasecmp(name, names[i]) == 0) return true; + } + return false; +} + +// Directory browsing state +static struct DirState { + char current_path[128] = "/"; + uint32_t entry_count = 0; + bool scanned = false; + + // Cached entry info for current directory + struct CachedEntry { + char name[64]; + uint8_t entry_type; + uint8_t flags; // PANEL_ENTRY_FLAG_* + } entries[MAX_DIR_ENTRIES]; + + void reset() { + strcpy(current_path, "/"); + entry_count = 0; + scanned = false; + } + + // Scan current directory and populate entry cache + bool scan() { + FsFile dir; + FsFile entry; + + entry_count = 0; + scanned = false; + + if (!dir.open(current_path)) { + logmsg("Panel: Failed to open dir ", current_path); + return false; + } + + if (!dir.isDir()) { + dir.close(); + return false; + } + + // Cue-first browsing: when the directory holds a cue sheet, list the + // .cue entries and hide the .bin files they reference (matching + // findNextImageAfter()). A directory without any cue sheet keeps its + // .bin files visible so plain data-.bin CDs remain loadable. + bool dir_has_cue = scsiDiskFolderContainsCueSheet(&dir); + dir.rewind(); + + // Add parent directory entry if not at root + if (strcmp(current_path, "/") != 0) { + strncpy(entries[entry_count].name, "..", sizeof(entries[0].name) - 1); + entries[entry_count].entry_type = PANEL_ENTRY_TYPE_DIRECTORY; + entries[entry_count].flags = 0; + entry_count++; + } + + // Scan directory entries + while (entry.openNext(&dir, O_RDONLY) && entry_count < MAX_DIR_ENTRIES) { + char name[MAX_FILE_PATH]; + // The wire entry carries at most 63 chars; a longer name would be + // selected by a truncated path, so skip what can't round-trip + if (!entry.getName(name, sizeof(name)) || + strlen(name) >= sizeof(entries[0].name)) { + entry.close(); + continue; + } + + // Skip hidden files (starting with .) + if (name[0] == '.') { + entry.close(); + continue; + } + + if (entry.isDir()) { + // Add directory + strncpy(entries[entry_count].name, name, sizeof(entries[0].name) - 1); + entries[entry_count].name[sizeof(entries[0].name) - 1] = '\0'; + entries[entry_count].entry_type = PANEL_ENTRY_TYPE_DIRECTORY; + entries[entry_count].flags = 0; + entry_count++; + } else if (dir_has_cue && panel_has_extension(name, ".bin")) { + // A cue sheet in this directory references the .bin files: + // hide them so the disc is selected by its .cue. Loading one + // .bin of a multi-bin disc would present a broken disc, and + // the firmware cycles cue-first anyway (see BlueSCSI_disk.h). + entry.close(); + continue; + } else { + // Add every other (non-hidden) file. The panel browser lists all + // files and lets the firmware accept or reject them on load, + // rather than second-guessing which extensions are mountable. + strncpy(entries[entry_count].name, name, sizeof(entries[0].name) - 1); + entries[entry_count].name[sizeof(entries[0].name) - 1] = '\0'; + entries[entry_count].entry_type = PANEL_ENTRY_TYPE_FILE; + entries[entry_count].flags = panel_is_firmware_file(name) + ? PANEL_ENTRY_FLAG_NOT_LOADABLE : 0; + entry_count++; + } + + entry.close(); + } + + dir.close(); + scanned = true; + if (entry_count >= MAX_DIR_ENTRIES) { + logmsg("Panel: Directory '", current_path, "' listing truncated to ", + (int)MAX_DIR_ENTRIES, " entries"); + } + logmsg("Panel: Scanned ", current_path, ", found ", (int)entry_count, " entries"); + return true; + } + + // Change to a subdirectory or parent + bool change_dir(const char* name) { + char new_path[128]; + + if (strcmp(name, "..") == 0) { + // Go to parent + if (strcmp(current_path, "/") == 0) { + return true; // Already at root + } + + // Find last slash and truncate + strncpy(new_path, current_path, sizeof(new_path) - 1); + new_path[sizeof(new_path) - 1] = '\0'; + char* last_slash = strrchr(new_path, '/'); + if (last_slash && last_slash != new_path) { + *last_slash = '\0'; + } else { + strcpy(new_path, "/"); + } + } else { + // Go to subdirectory + if (strcmp(current_path, "/") == 0) { + snprintf(new_path, sizeof(new_path), "/%s", name); + } else { + snprintf(new_path, sizeof(new_path), "%s/%s", current_path, name); + } + } + + // Verify new path is a directory + FsFile dir; + if (!dir.open(new_path) || !dir.isDir()) { + if (dir.isOpen()) dir.close(); + return false; + } + dir.close(); + + strncpy(current_path, new_path, sizeof(current_path) - 1); + current_path[sizeof(current_path) - 1] = '\0'; + scanned = false; // Need to rescan + return true; + } +} g_dir; + +#ifdef UNIT_TEST +/* Test accessor - reset the directory browser to the root (panel_dir_test) */ +void panel_protocol_test_reset_dir(void) { + g_dir.reset(); +} + +/* Test accessor - is the cached listing still considered valid? Every command + * that changes a directory's contents must clear this (panel_dir_test) */ +bool panel_protocol_test_dir_scanned(void) { + return g_dir.scanned; +} +#endif + +// Async operation state +static struct AsyncState { + uint8_t current_command = 0; + panel_async_state_t state = PANEL_ASYNC_IDLE; + + // Firmware read state + FsFile fw_file; + uint32_t fw_size = 0; + uint32_t fw_offset = 0; + panel_firmware_info_t fw_info = {}; + + void reset() { + current_command = 0; + state = PANEL_ASYNC_IDLE; + fw_size = 0; + fw_offset = 0; + memset(&fw_info, 0, sizeof(fw_info)); + if (fw_file.isOpen()) { + fw_file.close(); + } + g_dir.reset(); + } +} g_async; + +// File upload state +static struct FileUploadState { + FsFile upload_file; + char filename[64]; + uint32_t total_size; + uint32_t bytes_written; + bool upload_active; + uint16_t last_chunk_crc16; + sha256_result_t calculated_hash; + pico_sha256_state_t* sha256_ctx; + uint32_t last_activity_ms; + + void reset() { + if (upload_file.isOpen()) { + platform_reset_watchdog(); + upload_file.close(); + } + memset(filename, 0, sizeof(filename)); + total_size = 0; + bytes_written = 0; + upload_active = false; + last_chunk_crc16 = 0; + last_activity_ms = 0; + memset(&calculated_hash, 0, sizeof(calculated_hash)); + if (sha256_ctx) { + pico_sha256_cleanup(sha256_ctx); + delete sha256_ctx; + sha256_ctx = nullptr; + } + } +} g_upload; + +// An upload that is started and never finished (panel unplugged, ESP32 reset +// mid-transfer) holds the SHA-256 hardware lock and an open file handle +// forever. Every later CHECK_FIRMWARE then fails pico_sha256_try_start and +// returns an all-zero hash, so the panel can never self-update again. +static void panel_upload_reclaim_if_idle(uint32_t now) { + if (g_upload.upload_active && + (now - g_upload.last_activity_ms) >= PANEL_TRANSFER_IDLE_TIMEOUT_MS) { + logmsg("Panel: Abandoned file upload timed out, releasing SHA-256 and file handle"); + g_upload.reset(); + } +} + +// File download state +static struct FileDownloadState { + FsFile download_file; + uint32_t file_size; + bool download_active; + uint32_t last_activity_ms; + + void reset() { + if (download_file.isOpen()) { + download_file.close(); + } + file_size = 0; + download_active = false; + last_activity_ms = 0; + } +} g_download; + +// A download holds its file handle open after the last chunk: nothing in the +// protocol marks the end, so only the next START_FILE_DOWNLOAD used to close +// it. Until then panel_path_is_loaded() reports the file as in use and the +// panel cannot delete or rename it. +static void panel_download_reclaim_if_idle(uint32_t now) { + if (g_download.download_active && + (now - g_download.last_activity_ms) >= PANEL_TRANSFER_IDLE_TIMEOUT_MS) { + logmsg("Panel: Idle file download timed out, releasing file handle"); + g_download.reset(); + } +} + +// ============================================================================ +// Helper functions +// ============================================================================ + +// Parse version string "YYYY.MM.DD" into 0x00YYMMdd format +static uint32_t parse_version_string(const char* ver) { + uint32_t year = 0, month = 0, day = 0; + + const char* p = ver; + while (*p >= '0' && *p <= '9') { + year = year * 10 + (*p - '0'); + p++; + } + if (*p == '.') p++; + while (*p >= '0' && *p <= '9') { + month = month * 10 + (*p - '0'); + p++; + } + if (*p == '.') p++; + while (*p >= '0' && *p <= '9') { + day = day * 10 + (*p - '0'); + p++; + } + + uint32_t major = (year >= 2000) ? (year - 2000) : year; + return (major << 16) | (month << 8) | day; +} + +// Get info about first configured image +static bool get_first_image_info(loaded_image_status_t* status) { + memset(status, 0, sizeof(*status)); + + // Scan through SCSI IDs to find first configured image + for (int id = 0; id < 8; id++) { + image_config_t& img = scsiDiskGetImageConfig(id); + + // Check if this target has an open image file + if (img.file.isOpen()) { + status->image_loaded = 1; + status->device_type = PANEL_DEVICE_TYPE_SCSI; + panel_loaded_image_name(img, status->image_name, sizeof(status->image_name)); + strncpy(status->directory_path, "/", sizeof(status->directory_path) - 1); + return true; + } + } + + // Fallback: return basic info without detailed filename + status->image_loaded = scsiDiskCheckAnyImagesConfigured() ? 1 : 0; + status->device_type = PANEL_DEVICE_TYPE_SCSI; + + if (status->image_loaded) { + strncpy(status->image_name, "[SCSI Image]", sizeof(status->image_name) - 1); + strncpy(status->directory_path, "/", sizeof(status->directory_path) - 1); + } + + return status->image_loaded; +} + +// ============================================================================ +// Image-list iteration helpers +// +// scsiDiskGetNextImageName() is a CYCLIC iterator: for INI images (IMG0..N) it +// walks img.image_index and wraps; for an image_directory it advances +// img.current_image to the next entry (via findNextImageAfter) and wraps. It +// only returns 0 when the target has no images at all. Driving it with a plain +// `while (scsiDiskGetNextImageName(...))` therefore spins forever once any +// image exists, blocking the main loop until the watchdog resets the board. +// These helpers walk exactly one full cycle (detected when the first result +// repeats) with a hard safety cap, and restore the iterator state they perturb. +// ============================================================================ + +// Basename portion of a path (after the last '/'). +static const char* panel_path_basename(const char* p) { + const char* slash = strrchr(p, '/'); + return slash ? slash + 1 : p; +} + +// Count the images available for this target, bounded to a single cycle. +static __attribute__((noinline)) uint16_t panel_count_target_images(image_config_t &img) { + char saved_current_image[sizeof(img.current_image)]; + strncpy(saved_current_image, img.current_image, sizeof(saved_current_image)); + saved_current_image[sizeof(saved_current_image) - 1] = '\0'; + int saved_index = img.image_index; + + img.image_index = 0; + + char filename[MAX_FILE_PATH]; + char first_filename[MAX_FILE_PATH] = {0}; + uint16_t count = 0; + + for (int guard = 0; guard <= MAX_DIR_ENTRIES; guard++) { + if (!scsiDiskGetNextImageName(img, filename, sizeof(filename), true)) { + break; // no images for this target + } + if (first_filename[0] == '\0') { + strncpy(first_filename, filename, sizeof(first_filename) - 1); + first_filename[sizeof(first_filename) - 1] = '\0'; + } else if (strcmp(filename, first_filename) == 0) { + break; // completed one full cycle + } + count++; + } + + img.image_index = saved_index; + strncpy(img.current_image, saved_current_image, sizeof(img.current_image)); + img.current_image[sizeof(img.current_image) - 1] = '\0'; + return count; +} + +// Find the image immediately before img.current_image in its ring, writing the +// selectable name into out. Returns false if the target has no images. Walks +// one full cycle and restores the iterator state it perturbs. +static __attribute__((noinline)) bool panel_find_prev_image(image_config_t &img, char *out, size_t outlen) { + char saved_current_image[sizeof(img.current_image)]; + strncpy(saved_current_image, img.current_image, sizeof(saved_current_image)); + saved_current_image[sizeof(saved_current_image) - 1] = '\0'; + int saved_index = img.image_index; + + const char *orig_base = panel_path_basename(saved_current_image); + + char filename[MAX_FILE_PATH]; + char first_filename[MAX_FILE_PATH] = {0}; + char before[MAX_FILE_PATH] = {0}; // entry returned on the previous step + char last_filename[MAX_FILE_PATH] = {0}; + char prev_filename[MAX_FILE_PATH] = {0}; + bool found = false; + + for (int guard = 0; guard <= MAX_DIR_ENTRIES; guard++) { + if (!scsiDiskGetNextImageName(img, filename, sizeof(filename), true)) { + break; // no images + } + if (first_filename[0] == '\0') { + strncpy(first_filename, filename, sizeof(first_filename) - 1); + first_filename[sizeof(first_filename) - 1] = '\0'; + } else if (strcmp(filename, first_filename) == 0) { + break; // completed one full cycle + } + // When the walk returns the current image, its predecessor is `before`. + if (!found && before[0] != '\0' && + strcasecmp(panel_path_basename(filename), orig_base) == 0) { + strncpy(prev_filename, before, sizeof(prev_filename) - 1); + prev_filename[sizeof(prev_filename) - 1] = '\0'; + found = true; + } + strncpy(before, filename, sizeof(before) - 1); + before[sizeof(before) - 1] = '\0'; + strncpy(last_filename, filename, sizeof(last_filename) - 1); + last_filename[sizeof(last_filename) - 1] = '\0'; + } + + img.image_index = saved_index; + strncpy(img.current_image, saved_current_image, sizeof(img.current_image)); + img.current_image[sizeof(img.current_image) - 1] = '\0'; + + if (!found) { + // Current image not encountered (e.g. single image): wrap to the last. + if (last_filename[0] == '\0') { + return false; // empty directory + } + strncpy(prev_filename, last_filename, sizeof(prev_filename) - 1); + prev_filename[sizeof(prev_filename) - 1] = '\0'; + } + + strncpy(out, prev_filename, outlen - 1); + out[outlen - 1] = '\0'; + return true; +} + +// ============================================================================ +// Read command handlers (synchronous) +// ============================================================================ + +static size_t handle_poll_status(uint8_t* response) { + response[0] = PANEL_STATUS_OK; + return 1; +} + +static size_t handle_get_device_status(uint16_t device_index, uint8_t* response) { + // IRQ context: read the main-loop-maintained snapshot, never img->file. + if (device_index < S2S_MAX_TARGETS && g_device_snapshot[device_index].present) { + response[0] = g_device_snapshot[device_index].loaded + ? PANEL_DEVICE_STATUS_LOADED : PANEL_DEVICE_STATUS_NO_IMAGE; + return 1; + } + + // Backward-compat fallback for an unconfigured device 0: LOADED if any + // device has an open image. Derived from the snapshot (snapshot.loaded == + // enabled && file.isOpen()), matching scsiDiskCheckAnyImagesConfigured() + // without touching img->file in the IRQ. + bool any_loaded = false; + if (device_index == 0) { + for (int i = 0; i < S2S_MAX_TARGETS; i++) { + if (g_device_snapshot[i].loaded) { any_loaded = true; break; } + } + } + response[0] = any_loaded ? PANEL_DEVICE_STATUS_LOADED : PANEL_DEVICE_STATUS_NO_IMAGE; + return 1; +} + +static size_t handle_get_firmware_info(uint8_t* response, size_t max_size) { + if (max_size < sizeof(panel_firmware_info_t)) { + return 0; + } + + memcpy(response, &g_async.fw_info, sizeof(panel_firmware_info_t)); + return sizeof(panel_firmware_info_t); +} + +static size_t handle_get_command_status(uint8_t* response, size_t max_size) { + if (max_size < sizeof(panel_command_status_t)) { + return 0; + } + + panel_command_status_t* status = (panel_command_status_t*)response; + status->command = g_async.current_command; + status->state = g_async.state; + status->progress = 0; + status->last_result = 0; + + return sizeof(panel_command_status_t); +} + +static size_t handle_get_playback_status(uint16_t device_index, uint8_t* response, size_t max_size) { + if (max_size < sizeof(panel_playback_status_t)) { + return 0; + } + + panel_playback_status_t* status = (panel_playback_status_t*)response; + memset(status, 0, sizeof(panel_playback_status_t)); + status->audio_status = PANEL_AUDIO_STATUS_NONE; + + // Stamp the liveness magic so the ESP32 can tell a live board apart from a + // dead/disconnected bus (which reads back uniformly 0x00 or 0xFF). + status->alive_magic = PANEL_ALIVE_MAGIC; + + // Tell the panel which mode we are in on the one command it can always get + // an answer to. GET_DEVICE_LIST carries this too, but it is async and an + // imaging board does not reach the main loop to complete it, so the panel + // would never learn it is talking to an initiator. + status->protocol_version = PANEL_PROTOCOL_VERSION; + status->operating_mode = scsiInitiatorIsActive() ? PANEL_MODE_INITIATOR + : PANEL_MODE_TARGET; + + // IRQ context: read the main-loop snapshot, never img->file (see + // panel_protocol_refresh_device_snapshot). The snapshot also caches the + // filename so we can return it here without calling getFilename() in IRQ. + if (device_index < S2S_MAX_TARGETS && g_device_snapshot[device_index].present) { + // Optical tray open (disc ejected): the next disc is already loaded but + // presented as ejected until the tray is closed. Flag it so the panel can + // tell the user to load a disc or close the tray. + if (device_type_is_ejectable(g_device_snapshot[device_index].device_type) && + g_device_snapshot[device_index].ejected) { + status->flags |= PANEL_PB_TRAY_OPEN; + } + } + + if (device_index < S2S_MAX_TARGETS && + g_device_snapshot[device_index].present && + g_device_snapshot[device_index].loaded) { + status->flags |= PANEL_PB_DISC_INSERTED; + status->disc_type = (g_device_snapshot[device_index].device_type == S2S_CFG_OPTICAL) + ? PANEL_DISC_TYPE_DATA : PANEL_DISC_TYPE_HDD; + status->device_status = g_device_snapshot[device_index].ejected + ? PANEL_DEVICE_STATUS_TRAY_OPEN : PANEL_DEVICE_STATUS_LOADED; + memcpy(status->disc_name, (const void *)g_device_snapshot[device_index].image_name, + sizeof(status->disc_name)); + status->disc_name[sizeof(status->disc_name) - 1] = '\0'; + } else { + status->device_status = PANEL_DEVICE_STATUS_NO_IMAGE; + } + + return sizeof(panel_playback_status_t); +} + +// Synchronous counterpart to handle_get_initiator_status_async(). Everything +// here reads g_initiator_state - no bus access, no blocking - so it is safe in +// IRQ context, which is exactly what makes it answerable while imaging. +static size_t handle_get_initiator_summary(uint8_t* response, size_t max_size) { + if (max_size < sizeof(panel_initiator_summary_t)) { + return 0; + } + + panel_initiator_summary_t* sum = (panel_initiator_summary_t*)response; + memset(sum, 0, sizeof(panel_initiator_summary_t)); + sum->alive_magic = PANEL_ALIVE_MAGIC; + sum->protocol_version = PANEL_PROTOCOL_VERSION; + sum->current_target = 0xFF; + + if (!scsiInitiatorIsActive()) { + sum->operating_mode = PANEL_MODE_TARGET; + return sizeof(panel_initiator_summary_t); + } + sum->operating_mode = PANEL_MODE_INITIATOR; + + uint8_t phase, current_target, initiator_id, drives_mask; + uint16_t speed_kbps; + scsiInitiatorGetStatus(&phase, ¤t_target, &initiator_id, &drives_mask, + &speed_kbps, NULL, 0); + sum->phase = phase; + sum->current_target = current_target; + sum->speed_kbps = speed_kbps; + + for (int id = 0; id < NUM_SCSIID; id++) { + uint8_t tstatus = 0; + uint32_t sectorcount = 0, sectors_done = 0; + if (!scsiInitiatorGetTargetInfo(id, &tstatus, NULL, NULL, §orcount, + NULL, §ors_done, NULL, NULL, NULL, + NULL, NULL, NULL, NULL)) { + continue; + } + sum->targets_found++; + if (tstatus == PANEL_INITIATOR_TARGET_DONE) { + sum->targets_imaged++; + } + if (id == current_target) { + if (sectorcount > 0) { + sum->progress = (uint8_t)(100ULL * sectors_done / sectorcount); + } + scsiInitiatorGetTargetInfo(id, NULL, &sum->device_type, NULL, + &sum->sectorcount, &sum->sectorsize, NULL, + NULL, sum->vendor, sum->product, + NULL, NULL, NULL, NULL); + sum->vendor[sizeof(sum->vendor) - 1] = '\0'; + sum->product[sizeof(sum->product) - 1] = '\0'; + } + } + + return sizeof(panel_initiator_summary_t); +} + +// ============================================================================ +// Write/Async command handlers +// ============================================================================ + +static __attribute__((noinline)) void handle_get_loaded_image_status_async(uint16_t device_index) { + uint8_t* buf = panel_transport_get_tx_buffer(); + loaded_image_status_t* status = (loaded_image_status_t*)buf; + memset(status, 0, sizeof(loaded_image_status_t)); + + if (scsiInitiatorIsActive()) { + // Nothing is emulated while imaging. Without this the device-0 fallback + // below reports the first image on the card as "loaded", which lights up + // the web UI's prev/next buttons and blocks renames on a file that is + // not in use. + status->image_loaded = 0; + status->device_type = PANEL_DEVICE_TYPE_SCSI; + panel_transport_set_async_result(buf, sizeof(loaded_image_status_t)); + return; + } + + image_config_t* img = get_device_by_index(device_index); + if (img && img->file.isOpen()) { + status->image_loaded = 1; + status->device_type = PANEL_DEVICE_TYPE_SCSI; + panel_loaded_image_name(*img, status->image_name, sizeof(status->image_name)); + strncpy(status->directory_path, "/", sizeof(status->directory_path) - 1); + + status->image_index = (img->image_index >= 0) ? img->image_index : 0; + + if (!img->image_directory && !img->use_prefix) { + // INI-based images (IMG0..IMG9): count via the bounded cyclic walk. + uint16_t count = panel_count_target_images(*img); + status->total_images = (count > 0) ? count : 1; + } else { + // image_directory / use_prefix: counting requires SD directory + // scan (findNextImageAfter) which is not safe here + status->total_images = status->image_index + 1; + } + } else if (device_index == 0) { + // Backward compat: fallback to first-image scan for device 0 + get_first_image_info(status); + } else { + status->image_loaded = 0; + status->device_type = PANEL_DEVICE_TYPE_SCSI; + } + + panel_transport_set_async_result(buf, sizeof(loaded_image_status_t)); +} + +static __attribute__((noinline)) void handle_get_device_list_async(void) { + uint8_t* buf = panel_transport_get_tx_buffer(); + device_list_response_t* list = (device_list_response_t*)buf; + + memset(list, 0, sizeof(device_list_response_t)); + list->max_devices = S2S_MAX_TARGETS; + + // Set operating mode + if (scsiInitiatorIsActive()) { + PANEL_DEVLIST_MODE(list) = PANEL_MODE_INITIATOR; + list->device_count = 0; + dbgmsg("Panel: Device list: initiator mode active"); + panel_transport_set_async_result(buf, sizeof(device_list_response_t)); + return; + } + PANEL_DEVLIST_MODE(list) = PANEL_MODE_TARGET; + + size_t offset = sizeof(device_list_response_t); + uint8_t count = 0; + + for (int i = 0; i < S2S_MAX_TARGETS; i++) { + image_config_t& img = g_DiskImages[i]; + if (!(img.scsiId & S2S_CFG_TARGET_ENABLED)) { + continue; + } + + device_summary_t* dev = (device_summary_t*)(buf + offset); + memset(dev, 0, sizeof(device_summary_t)); + + dev->device_index = i; + dev->device_type = img.deviceType; + + if (img.file.isOpen()) { + // An ejected optical drive still has the next disc open; report the + // tray-open state so the panel/web can prompt to load or close. + dev->device_status = (device_is_ejectable(&img) && img.ejected) + ? PANEL_DEVICE_STATUS_TRAY_OPEN : PANEL_DEVICE_STATUS_LOADED; + panel_loaded_image_name(img, dev->image_name, sizeof(dev->image_name)); + } else { + dev->device_status = PANEL_DEVICE_STATUS_NO_IMAGE; + } + + // Build device label from SCSI device type + const char* type_str = "HD"; + switch (img.deviceType) { + case S2S_CFG_OPTICAL: type_str = "CD"; break; + case S2S_CFG_REMOVABLE: type_str = "REM"; break; + case S2S_CFG_SEQUENTIAL: type_str = "TAPE"; break; + case S2S_CFG_MO: type_str = "MO"; break; + case S2S_CFG_FLOPPY_14MB: type_str = "FD"; break; + case S2S_CFG_NETWORK: type_str = "NET"; break; + case S2S_CFG_ZIP100: type_str = "ZIP"; break; + case S2S_CFG_AMIGAWIFI: type_str = "WiFi"; break; + case S2S_CFG_PRINTER: type_str = "Printer"; break; + default: type_str = "HD"; break; + } + snprintf(dev->device_label, sizeof(dev->device_label), "SCSI %d (%s)", i, type_str); + + offset += sizeof(device_summary_t); + count++; + } + + list->device_count = count; + dbgmsg("Panel: Device list: ", (int)count, " devices"); + panel_transport_set_async_result(buf, offset); +} + +static __attribute__((noinline)) void handle_get_initiator_status_async(void) { + uint8_t* buf = panel_transport_get_tx_buffer(); + initiator_status_response_t* resp = (initiator_status_response_t*)buf; + + memset(resp, 0, sizeof(initiator_status_response_t)); + + uint8_t phase, current_target, initiator_id, drives_mask; + uint16_t speed_kbps; + scsiInitiatorGetStatus(&phase, ¤t_target, &initiator_id, &drives_mask, + &speed_kbps, resp->current_filename, + sizeof(resp->current_filename)); + + resp->phase = phase; + resp->current_target_id = current_target; + resp->initiator_id = initiator_id; + resp->drives_imaged_mask = drives_mask; + resp->speed_kbps = speed_kbps; + + // Populate per-target info + size_t offset = sizeof(initiator_status_response_t); + uint8_t targets_found = 0; + uint8_t targets_imaged = 0; + + // NUM_SCSIID is 16 on a wide bus; a hardcoded 8 would report half the bus + for (int id = 0; id < NUM_SCSIID; id++) { + if (id == initiator_id) continue; + + initiator_target_info_t* ti = (initiator_target_info_t*)(buf + offset); + memset(ti, 0, sizeof(initiator_target_info_t)); + + ti->scsi_id = id; + if (!scsiInitiatorGetTargetInfo(id, &ti->status, &ti->device_type, &ti->ansi_version, + &ti->sectorcount, &ti->sectorsize, &ti->sectors_done, + &ti->bad_sector_count, ti->vendor, ti->product, + &ti->sense_key, &ti->asc, &ti->ascq, + &ti->skip_reason)) { + continue; + } + + targets_found++; + if (ti->status == 3) targets_imaged++; // PANEL_INITIATOR_TARGET_DONE + + offset += sizeof(initiator_target_info_t); + } + + resp->targets_found = targets_found; + resp->targets_imaged = targets_imaged; + + dbgmsg("Panel: Initiator status: phase=", (int)phase, " targets=", (int)targets_found); + panel_transport_set_async_result(buf, offset); +} + +static __attribute__((noinline)) void handle_get_dir_entry_count_async(void) { + uint8_t* buf = panel_transport_get_tx_buffer(); + + // Scan directory if not already done + if (!g_dir.scanned) { + g_dir.scan(); + } + + // Send count in big-endian format (ESP32 expects big-endian) + uint32_t count = g_dir.entry_count; + buf[0] = (count >> 24) & 0xFF; + buf[1] = (count >> 16) & 0xFF; + buf[2] = (count >> 8) & 0xFF; + buf[3] = count & 0xFF; + panel_transport_set_async_result(buf, sizeof(uint32_t)); +} + +static __attribute__((noinline)) void handle_get_entry_info_async(uint16_t index) { + uint8_t* buf = panel_transport_get_tx_buffer(); + dir_entry_info_t* info = (dir_entry_info_t*)buf; + memset(info, 0, sizeof(dir_entry_info_t)); + + // Scan directory if not already done + if (!g_dir.scanned) { + g_dir.scan(); + } + + if (index < g_dir.entry_count) { + strncpy(info->name, g_dir.entries[index].name, sizeof(info->name) - 1); + info->entry_type = g_dir.entries[index].entry_type; + info->flags = g_dir.entries[index].flags; + } else { + strncpy(info->name, "(invalid index)", sizeof(info->name) - 1); + info->entry_type = PANEL_ENTRY_TYPE_FILE; + } + + panel_transport_set_async_result(buf, sizeof(dir_entry_info_t)); +} + +static __attribute__((noinline)) void handle_get_current_path_async(void) { + uint8_t* buf = panel_transport_get_tx_buffer(); + size_t len = strlen(g_dir.current_path) + 1; + strncpy((char*)buf, g_dir.current_path, PANEL_PROTOCOL_MAX_PAYLOAD - 1); + panel_transport_set_async_result(buf, len); +} + +static __attribute__((noinline)) void handle_select_entry_async(uint16_t device_index, int16_t entry_index) { + uint8_t* buf = panel_transport_get_tx_buffer(); + + // Scan directory if not already done + if (!g_dir.scanned) { + g_dir.scan(); + } + + // Special case: -1 means go to parent directory + if (entry_index == -1) { + g_dir.change_dir(".."); + buf[0] = 0; // Success + panel_transport_set_async_result(buf, 1); + return; + } + + if ((uint16_t)entry_index >= g_dir.entry_count) { + buf[0] = 1; // Error: invalid index + panel_transport_set_async_result(buf, 1); + return; + } + + DirState::CachedEntry& entry = g_dir.entries[entry_index]; + + if (entry.entry_type == PANEL_ENTRY_TYPE_DIRECTORY) { + // Navigate into directory + if (g_dir.change_dir(entry.name)) { + buf[0] = 0; // Success + } else { + buf[0] = 2; // Error: failed to change directory + } + panel_transport_set_async_result(buf, 1); + } else if (entry.flags & PANEL_ENTRY_FLAG_NOT_LOADABLE) { + logmsg("Panel: ", entry.name, " is not a disc image"); + buf[0] = 5; // Error: not loadable + panel_transport_set_async_result(buf, 1); + return; + } else { + // Select image file - build full path + char full_path[192]; + if (strcmp(g_dir.current_path, "/") == 0) { + snprintf(full_path, sizeof(full_path), "/%s", entry.name); + } else { + snprintf(full_path, sizeof(full_path), "%s/%s", g_dir.current_path, entry.name); + } + + // Get target device by index from argument + image_config_t* img = get_device_by_index(device_index); + if (!img) { + logmsg("Panel: Invalid device index ", (int)device_index); + buf[0] = 3; // Error: invalid device index + panel_transport_set_async_result(buf, 1); + return; + } + + logmsg("Panel: Loading image ", full_path, " to SCSI ID ", (int)device_index); + + // If the optical tray was open (disc ejected), the user is loading a disc + // into the open tray. switchNextImage() leaves it ejected awaiting a host + // GET EVENT STATUS poll, which a classic Mac may not do after a manual + // eject - so close the tray ourselves to present the selected disc (same + // path as the working "close" action, which posts UNIT ATTENTION). + bool was_ejected = (img->deviceType == S2S_CFG_OPTICAL) && img->ejected; + + if (switchNextImage(*img, full_path)) { + buf[0] = 0; // Success + if (was_ejected) { + cdromCloseTray(*img); + } + logmsg("Panel: Image loaded successfully"); + } else { + buf[0] = 4; // Error: failed to load image + logmsg("Panel: Failed to load image"); + } + panel_transport_set_async_result(buf, 1); + } +} + +// Cache for the most recently computed firmware SHA256. +// CHECK_FIRMWARE may be called repeatedly (every ESP32 update poll); without +// caching we'd re-stream the entire ~1.5MB firmware off the SD card every +// time and stall the main loop for ~150ms each call. +static struct { + bool valid; + uint32_t size; + uint32_t mtime_fingerprint; // (FAT date << 16) | FAT time + uint8_t hash[32]; +} g_fw_sha_cache; + +// Build a cheap "has the file changed" key from FAT date+time + size. +static uint32_t firmware_mtime_fingerprint(FsFile& file) { + uint16_t fdate = 0, ftime = 0; + file.getModifyDateTime(&fdate, &ftime); + return ((uint32_t)fdate << 16) | (uint32_t)ftime; +} + +// Calculate SHA256 hash of firmware file using hardware acceleration. +// Pumps the watchdog periodically — the file can be large enough that the +// SD-bound read loop runs long against the 15s watchdog window on slow cards. +static bool calculate_firmware_sha256(FsFile& file, uint8_t* hash) { + if (!file.isOpen() || !hash) { + return false; + } + + file.seekSet(0); + + pico_sha256_state_t* ctx = new pico_sha256_state_t; + // try_start (not start_blocking): the SHA-256 hardware lock is held for the + // whole duration of a file upload, so a blocking acquire here would wait + // forever if a CHECK_FIRMWARE landed mid-upload. Fail gracefully instead. + if (pico_sha256_try_start(ctx, SHA256_BIG_ENDIAN, true /* use_dma */) != PICO_OK) { + logmsg("Panel: SHA256 hardware busy, skipping firmware hash"); + delete ctx; + return false; + } + + uint8_t buffer[512]; + size_t bytesRead; + uint32_t chunks_since_kick = 0; + + while ((bytesRead = file.read(buffer, sizeof(buffer))) > 0) { + pico_sha256_update(ctx, buffer, bytesRead); + // Kick watchdog every ~64KB. Cheap, and keeps slow SD cards inside + // the 15s watchdog window. + if (++chunks_since_kick >= 128) { + platform_reset_watchdog(); + chunks_since_kick = 0; + } + } + + sha256_result_t result; + pico_sha256_finish(ctx, &result); + pico_sha256_cleanup(ctx); + delete ctx; + + memcpy(hash, &result, 32); + + file.seekSet(0); + return true; +} + +// Compute (or return cached) SHA256 for the firmware file. +// out_recomputed is set to true when we actually re-read the file, false +// when the cache was a hit. Tests use this to assert cache behavior. +static bool firmware_sha256_with_cache(FsFile& file, uint8_t* hash, + bool* out_recomputed) { + if (!file.isOpen() || !hash) { + return false; + } + + uint32_t size = (uint32_t)file.fileSize(); + uint32_t mtime = firmware_mtime_fingerprint(file); + + if (g_fw_sha_cache.valid && + g_fw_sha_cache.size == size && + g_fw_sha_cache.mtime_fingerprint == mtime) { + memcpy(hash, g_fw_sha_cache.hash, 32); + if (out_recomputed) *out_recomputed = false; + return true; + } + + if (!calculate_firmware_sha256(file, hash)) { + g_fw_sha_cache.valid = false; + if (out_recomputed) *out_recomputed = true; + return false; + } + + memcpy(g_fw_sha_cache.hash, hash, 32); + g_fw_sha_cache.size = size; + g_fw_sha_cache.mtime_fingerprint = mtime; + g_fw_sha_cache.valid = true; + if (out_recomputed) *out_recomputed = true; + return true; +} + +#ifdef UNIT_TEST +// Test accessors so unit tests can drive cache behavior without going through +// the firmware-update glue. +extern "C" { + void panel_protocol_test_reset_fw_cache(void) { + memset(&g_fw_sha_cache, 0, sizeof(g_fw_sha_cache)); + } + bool panel_protocol_test_firmware_sha256(FsFile& file, uint8_t* hash, + bool* out_recomputed) { + return firmware_sha256_with_cache(file, hash, out_recomputed); + } +} +#endif + +static __attribute__((noinline)) void handle_check_firmware_async(void) { + memset(&g_async.fw_info, 0, sizeof(g_async.fw_info)); + + if (g_async.fw_file.isOpen()) { + g_async.fw_file.close(); + } + + if (g_async.fw_file.open(PANEL_FW_PATH, O_RDONLY)) { + g_async.fw_info.size = g_async.fw_file.fileSize(); + g_async.fw_info.available = 1; + g_async.fw_size = g_async.fw_info.size; + g_async.fw_offset = 0; + + // Parse version string from ESP32 binary header (esp_app_desc_t at + // offset 0x30). Packed as 0xMMmmppPP to match the panel: the low byte + // is 0xFF for a final release, or N for a "-preN" prerelease so it + // sorts below the matching final. + g_async.fw_info.version = 0; + if (g_async.fw_file.seekSet(ESP32_VERSION_OFFSET)) { + char ver_str[ESP32_VERSION_MAX_LEN]; + // read() returns int (-1 on error); keep it signed so an error + // isn't reinterpreted as a huge size_t and parsed as a version. + int n = g_async.fw_file.read(ver_str, ESP32_VERSION_MAX_LEN); + if (n > 0) { + ver_str[ESP32_VERSION_MAX_LEN - 1] = '\0'; + const char* p = ver_str; + if (*p == 'v' || *p == 'V') p++; + uint32_t maj = 0, min = 0, pat = 0, pre = 0xFF; + while (*p >= '0' && *p <= '9') { maj = maj * 10 + (*p - '0'); p++; } + if (*p == '.') p++; + while (*p >= '0' && *p <= '9') { min = min * 10 + (*p - '0'); p++; } + if (*p == '.') p++; + while (*p >= '0' && *p <= '9') { pat = pat * 10 + (*p - '0'); p++; } + if (strncmp(p, "-pre", 4) == 0) { + p += 4; + uint32_t num = 0; + bool have_num = false; + while (*p >= '0' && *p <= '9') { num = num * 10 + (*p - '0'); p++; have_num = true; } + if (have_num && num <= 254) { + pre = num; + } + } + if (maj <= 255 && min <= 255 && pat <= 255) { + g_async.fw_info.version = (maj << 24) | (min << 16) | (pat << 8) | pre; + } + } + g_async.fw_file.seekSet(0); + } + + bool recomputed = false; + if (!firmware_sha256_with_cache(g_async.fw_file, g_async.fw_info.sha256, &recomputed)) { + logmsg("Panel: SHA256 calculation failed"); + memset(g_async.fw_info.sha256, 0, sizeof(g_async.fw_info.sha256)); + } else if (recomputed) { + logmsg("Panel: Calculated SHA256 for ", PANEL_FW_PATH); + } else { + dbgmsg("Panel: SHA256 cache hit for ", PANEL_FW_PATH); + } + + logmsg("Panel: Found firmware ", PANEL_FW_PATH, ", size=", g_async.fw_info.size); + } else { + g_async.fw_info.available = 0; + logmsg("Panel: No firmware at ", PANEL_FW_PATH); + } + + uint8_t* buf = panel_transport_get_tx_buffer(); + memcpy(buf, &g_async.fw_info, sizeof(panel_firmware_info_t)); + panel_transport_set_async_result(buf, sizeof(panel_firmware_info_t)); +} + +static __attribute__((noinline)) void handle_start_firmware_read_async(uint32_t offset) { + uint8_t* buf = panel_transport_get_tx_buffer(); + + if (!g_async.fw_file.isOpen()) { + if (!g_async.fw_file.open(PANEL_FW_PATH, O_RDONLY)) { + panel_transport_set_async_error(); + return; + } + g_async.fw_size = g_async.fw_file.fileSize(); + } + + // offset is panel-supplied: past EOF it would underflow `remaining` to ~4GB + // and hand a huge to_read to read(). Only SdFat rejecting the seek stops + // that today, which is a guarantee this code should not be relying on. + if (offset > g_async.fw_size || !g_async.fw_file.seek(offset)) { + panel_transport_set_async_error(); + return; + } + + size_t remaining = g_async.fw_size - offset; + size_t to_read = (remaining > PANEL_FIRMWARE_CHUNK_SIZE) ? + PANEL_FIRMWARE_CHUNK_SIZE : remaining; + + ssize_t bytes_read = g_async.fw_file.read(buf, to_read); + if (bytes_read < 0) { + panel_transport_set_async_error(); + return; + } + + panel_transport_set_async_result(buf, bytes_read); +} + +static __attribute__((noinline)) void handle_get_host_fw_status_async(void) { + uint8_t* buf = panel_transport_get_tx_buffer(); + rp2350_fw_status_t* status = (rp2350_fw_status_t*)buf; + + memset(status, 0, sizeof(rp2350_fw_status_t)); + + status->current_version = parse_version_string(FW_VER_NUM); + status->available_version = 0; + status->update_progress = 0; + status->last_update_result = 0; + + panel_transport_set_async_result(buf, sizeof(rp2350_fw_status_t)); +} + +static __attribute__((noinline)) void handle_eject_image_async(uint16_t device_index) { + image_config_t* img = get_device_by_index(device_index); + if (!img) { + logmsg("Panel: Invalid device index for eject ", (int)device_index); + panel_transport_set_async_error(); + return; + } + + if (!device_is_ejectable(img)) { + logmsg("Panel: Device ", (int)device_index, " is not ejectable"); + panel_transport_set_async_error(); + return; + } + + if (img->file.isOpen()) { + logmsg("Panel: Ejecting image from SCSI ID ", (int)device_index); + if (img->deviceType == S2S_CFG_OPTICAL) { + // Cue-first: eject advances to the next .cue when the directory + // has cue sheets (a directly-loaded .cue cycles by its own + // filename - see cue_loaded_directly in BlueSCSI_disk.h). + cdromPerformEject(*img, true); + } else { + // Toggles: a second press closes the tray again, matching the + // physical eject button and the web UI's Eject/Close pair. + diskPerformEject(*img); + } + panel_transport_set_async_result(nullptr, 0); + } else { + logmsg("Panel: No image loaded on SCSI ID ", (int)device_index); + panel_transport_set_async_error(); + } +} + +static __attribute__((noinline)) void handle_select_next_image_async(uint16_t device_index) { + image_config_t* img = get_device_by_index(device_index); + if (!img) { + logmsg("Panel: Invalid device index for next image ", (int)device_index); + panel_transport_set_async_error(); + return; + } + + if (!img->image_directory) { + logmsg("Panel: Device ", (int)device_index, " not in image_directory mode"); + panel_transport_set_async_error(); + return; + } + + logmsg("Panel: Selecting next image for SCSI ID ", (int)device_index); + + // Cue-first: optical drives cycle by .cue when the directory has cue sheets. + if (switchNextImage(*img, nullptr, true)) { + logmsg("Panel: Switched to next image successfully"); + panel_transport_set_async_result(nullptr, 0); + } else { + logmsg("Panel: Failed to switch to next image"); + panel_transport_set_async_error(); + } +} + +static __attribute__((noinline)) void handle_select_prev_image_async(uint16_t device_index) { + image_config_t* img = get_device_by_index(device_index); + if (!img) { + logmsg("Panel: Invalid device index for prev image ", (int)device_index); + panel_transport_set_async_error(); + return; + } + + if (!img->image_directory) { + logmsg("Panel: Device ", (int)device_index, " not in image_directory mode"); + panel_transport_set_async_error(); + return; + } + + char prev_filename[MAX_FILE_PATH]; + if (!panel_find_prev_image(*img, prev_filename, sizeof(prev_filename))) { + logmsg("Panel: No images found for prev on SCSI ID ", (int)device_index); + panel_transport_set_async_error(); + return; + } + + logmsg("Panel: Selecting prev image '", prev_filename, + "' for SCSI ID ", (int)device_index); + + if (switchNextImage(*img, prev_filename)) { + logmsg("Panel: Switched to prev image successfully"); + panel_transport_set_async_result(nullptr, 0); + } else { + logmsg("Panel: Failed to switch to prev image"); + panel_transport_set_async_error(); + } +} + +static __attribute__((noinline)) void handle_select_image_by_name_async(const uint8_t* payload, size_t payload_size) { + if (!payload || payload_size == 0) { + logmsg("Panel: SELECT_IMAGE_BY_NAME with empty payload"); + panel_transport_set_async_error(); + return; + } + + // Payload is a null-terminated filename + const char* filename = (const char*)payload; + + // Ensure null termination within bounds + size_t name_len = strnlen(filename, payload_size); + if (name_len >= payload_size) { + logmsg("Panel: SELECT_IMAGE_BY_NAME filename not null-terminated"); + panel_transport_set_async_error(); + return; + } + + if (panel_path_has_traversal(filename)) { + logmsg("Panel: SELECT_IMAGE_BY_NAME rejected path traversal: ", filename); + panel_transport_set_async_error(); + return; + } + + if (panel_is_firmware_file(panel_path_basename(filename))) { + logmsg("Panel: SELECT_IMAGE_BY_NAME rejected ", filename, ": not a disc image"); + panel_transport_set_async_error(); + return; + } + + // Find the first configured device to load the image on + image_config_t* img = nullptr; + uint16_t device_index = 0; + for (int i = 0; i < S2S_MAX_TARGETS; i++) { + if (g_DiskImages[i].scsiId & S2S_CFG_TARGET_ENABLED) { + img = &g_DiskImages[i]; + device_index = i; + break; + } + } + + if (!img) { + logmsg("Panel: No configured SCSI device for SELECT_IMAGE_BY_NAME"); + panel_transport_set_async_error(); + return; + } + + logmsg("Panel: SELECT_IMAGE_BY_NAME '", filename, "' on SCSI ID ", (int)device_index); + + // Present the disc immediately if the optical tray was open (see + // handle_select_entry_async for why we close the tray here). + bool was_ejected = (img->deviceType == S2S_CFG_OPTICAL) && img->ejected; + + if (switchNextImage(*img, filename)) { + if (was_ejected) { + cdromCloseTray(*img); + } + logmsg("Panel: Image loaded by name successfully"); + panel_transport_set_async_result(nullptr, 0); + } else { + logmsg("Panel: Failed to load image by name"); + panel_transport_set_async_error(); + } +} + +// ============================================================================ +// File download handlers +// ============================================================================ + +static __attribute__((noinline)) void handle_start_file_download_async(const uint8_t* payload, size_t payload_size) { + uint8_t* buf = panel_transport_get_tx_buffer(); + panel_file_download_start_result_t* result = (panel_file_download_start_result_t*)buf; + memset(result, 0, sizeof(panel_file_download_start_result_t)); + + if (!payload || payload_size == 0) { + logmsg("Panel: START_FILE_DOWNLOAD with empty payload"); + result->result_code = PANEL_DOWNLOAD_ERROR_NOT_FOUND; + panel_transport_set_async_result(buf, sizeof(panel_file_download_start_result_t)); + return; + } + + // Payload is a null-terminated filename + const char* filename = (const char*)payload; + size_t name_len = strnlen(filename, payload_size); + if (name_len >= payload_size) { + logmsg("Panel: START_FILE_DOWNLOAD filename not null-terminated"); + result->result_code = PANEL_DOWNLOAD_ERROR_NOT_FOUND; + panel_transport_set_async_result(buf, sizeof(panel_file_download_start_result_t)); + return; + } + + if (panel_path_has_traversal(filename)) { + logmsg("Panel: START_FILE_DOWNLOAD rejected path traversal: ", filename); + result->result_code = PANEL_DOWNLOAD_ERROR_NOT_FOUND; + panel_transport_set_async_result(buf, sizeof(panel_file_download_start_result_t)); + return; + } + + // Close any previous download + g_download.reset(); + + if (!g_download.download_file.open(filename, O_RDONLY)) { + logmsg("Panel: Failed to open download file: ", filename); + result->result_code = PANEL_DOWNLOAD_ERROR_NOT_FOUND; + panel_transport_set_async_result(buf, sizeof(panel_file_download_start_result_t)); + return; + } + + g_download.file_size = g_download.download_file.fileSize(); + g_download.download_active = true; + g_download.last_activity_ms = millis(); + + result->result_code = PANEL_DOWNLOAD_OK; + result->file_size = g_download.file_size; + + logmsg("Panel: File download started: ", filename, " (", (int)g_download.file_size, " bytes)"); + panel_transport_set_async_result(buf, sizeof(panel_file_download_start_result_t)); +} + +static __attribute__((noinline)) void handle_read_file_chunk_async(uint32_t chunk_index) { + uint8_t* buf = panel_transport_get_tx_buffer(); + + if (!g_download.download_active || !g_download.download_file.isOpen()) { + logmsg("Panel: READ_FILE_CHUNK with no active download"); + panel_transport_set_async_error(); + return; + } + + uint64_t offset = (uint64_t)chunk_index * PANEL_FILE_CHUNK_SIZE; + if (offset >= g_download.file_size) { + logmsg("Panel: READ_FILE_CHUNK offset beyond file end"); + panel_transport_set_async_error(); + return; + } + + if (!g_download.download_file.seek(offset)) { + logmsg("Panel: Failed to seek in download file"); + panel_transport_set_async_error(); + return; + } + + size_t remaining = g_download.file_size - offset; + size_t to_read = (remaining > PANEL_FILE_CHUNK_SIZE) ? + PANEL_FILE_CHUNK_SIZE : remaining; + + ssize_t bytes_read = g_download.download_file.read(buf, to_read); + if (bytes_read < 0) { + logmsg("Panel: Failed to read download chunk"); + panel_transport_set_async_error(); + return; + } + + g_download.last_activity_ms = millis(); + dbgmsg("Panel: Download chunk ", (int)chunk_index, ": ", (int)bytes_read, " bytes"); + panel_transport_set_async_result(buf, bytes_read); + + // Last chunk. The panel sends no end-of-download command, it just stops + // asking, so release here or the file stays open and undeletable until the + // next download. A panel that re-reads after this restarts the download. + if (offset + (uint64_t)bytes_read >= g_download.file_size) { + g_download.reset(); + } +} + +// ============================================================================ +// File upload handlers +// ============================================================================ + +// Defined below with the file-management handlers; needed here so an upload +// cannot truncate an image the host has mounted. +static bool panel_path_is_loaded(const char* path); + +static __attribute__((noinline)) void handle_start_file_upload_async(const uint8_t* payload, size_t payload_size) { + if (payload_size < sizeof(panel_file_upload_start_t)) { + logmsg("Panel: Invalid upload start payload size"); + panel_transport_set_async_error(); + return; + } + + const panel_file_upload_start_t* upload_start = + reinterpret_cast(payload); + + if (upload_start->filename_len == 0 || + upload_start->filename_len >= sizeof(g_upload.filename) || + payload_size < sizeof(panel_file_upload_start_t) + upload_start->filename_len) { + logmsg("Panel: Invalid filename length"); + panel_transport_set_async_error(); + return; + } + + g_upload.reset(); + + const char* filename_ptr = reinterpret_cast(payload + sizeof(panel_file_upload_start_t)); + strncpy(g_upload.filename, filename_ptr, upload_start->filename_len); + g_upload.filename[upload_start->filename_len] = '\0'; + g_upload.total_size = upload_start->file_size; + + if (panel_path_has_traversal(g_upload.filename)) { + logmsg("Panel: START_FILE_UPLOAD rejected path traversal: ", g_upload.filename); + panel_transport_set_async_error(); + return; + } + + // Build full path + char full_path[128]; + if (g_upload.filename[0] == '/') { + strncpy(full_path, g_upload.filename, sizeof(full_path) - 1); + full_path[sizeof(full_path) - 1] = '\0'; + } else { + FsFile uploads_dir; + if (!uploads_dir.open("/shared", O_RDONLY)) { + if (!SD.mkdir("/shared")) { + logmsg("Panel: Failed to create shared directory"); + panel_transport_set_async_error(); + return; + } + } else { + uploads_dir.close(); + } + snprintf(full_path, sizeof(full_path), "/shared/%s", g_upload.filename); + } + + // An absolute upload path can name a live image, and O_TRUNC would zero it + // under the running SCSI target. DELETE and RENAME already refuse this. + if (panel_path_is_loaded(full_path)) { + logmsg("Panel: START_FILE_UPLOAD refused - file is loaded: ", full_path); + panel_transport_set_async_error(); + return; + } + + platform_reset_watchdog(); + if (!g_upload.upload_file.open(full_path, O_CREAT | O_WRITE | O_TRUNC)) { + logmsg("Panel: Failed to create upload file: ", full_path); + panel_transport_set_async_error(); + return; + } + + // try_start (not start_blocking): never wait forever for the SHA-256 + // hardware. If it is busy the upload proceeds without an incremental hash. + g_upload.sha256_ctx = new pico_sha256_state_t; + if (pico_sha256_try_start(g_upload.sha256_ctx, SHA256_BIG_ENDIAN, true) != PICO_OK) { + logmsg("Panel: SHA256 hardware busy, upload will not be hashed"); + delete g_upload.sha256_ctx; + g_upload.sha256_ctx = nullptr; + } + + g_upload.upload_active = true; + g_upload.bytes_written = 0; + g_upload.last_activity_ms = millis(); + + logmsg("Panel: File upload started: ", full_path, " (", (int)g_upload.total_size, " bytes)"); + panel_transport_set_async_result(nullptr, 0); +} + +static __attribute__((noinline)) void handle_write_file_chunk_async(uint16_t expected_crc16, + uint16_t computed_crc16, + const uint8_t* payload, size_t payload_size) { + if (!g_upload.upload_active) { + logmsg("Panel: Chunk write failed - no active upload"); + panel_transport_set_async_error(); + return; + } + + if (payload_size == 0 || payload_size > PANEL_FILE_CHUNK_SIZE) { + logmsg("Panel: Invalid chunk size: ", (int)payload_size); + panel_transport_set_async_error(); + return; + } + + if (expected_crc16 != computed_crc16) { + logmsg("Panel: Chunk CRC mismatch expected=", (int)expected_crc16, + " computed=", (int)computed_crc16, " size=", (int)payload_size); + panel_transport_set_async_error(); + return; + } + + platform_reset_watchdog(); + size_t bytes_written = g_upload.upload_file.write(payload, payload_size); + if (bytes_written != payload_size) { + logmsg("Panel: Failed to write chunk: ", (int)bytes_written, "/", (int)payload_size); + panel_transport_set_async_error(); + return; + } + + g_upload.bytes_written += bytes_written; + g_upload.last_activity_ms = millis(); + + if (g_upload.sha256_ctx) { + pico_sha256_update(g_upload.sha256_ctx, payload, payload_size); + } + + g_upload.last_chunk_crc16 = computed_crc16; + + dbgmsg("Panel: Chunk written: ", (int)bytes_written, " bytes (total: ", + (int)g_upload.bytes_written, "/", (int)g_upload.total_size, ")"); + + panel_transport_set_async_result(nullptr, 0); +} + +static __attribute__((noinline)) void handle_finish_file_upload_async(void) { + uint8_t* buf = panel_transport_get_tx_buffer(); + uint8_t result_code = PANEL_UPLOAD_OK; + + if (!g_upload.upload_active) { + logmsg("Panel: Finish failed - no active upload"); + result_code = PANEL_UPLOAD_ERROR_WRITE; + } else { + platform_reset_watchdog(); + g_upload.upload_file.sync(); + g_upload.upload_file.close(); + + bool hash_valid = false; + if (g_upload.sha256_ctx) { + pico_sha256_finish(g_upload.sha256_ctx, &g_upload.calculated_hash); + pico_sha256_cleanup(g_upload.sha256_ctx); + delete g_upload.sha256_ctx; + g_upload.sha256_ctx = nullptr; + hash_valid = true; + } + + if (g_upload.bytes_written != g_upload.total_size) { + logmsg("Panel: Upload size mismatch: ", (int)g_upload.bytes_written, + "/", (int)g_upload.total_size); + result_code = PANEL_UPLOAD_ERROR_WRITE; + } else if (!hash_valid) { + // File written OK but never hashed (SHA hardware was busy at start), + // so we have no valid checksum. Report an error rather than a bogus + // all-zero hash the ESP32 would accept as a match. + logmsg("Panel: Upload completed but hash unavailable"); + result_code = PANEL_UPLOAD_ERROR_WRITE; + } else { + logmsg("Panel: Upload completed: ", g_upload.filename, + " (", (int)g_upload.bytes_written, " bytes)"); + // The new file is in whatever directory the browser is showing, so + // drop the cached listing like the other mutating handlers do. + g_dir.scanned = false; + } + } + + g_upload.upload_active = false; + + // Return result code + 32-byte SHA256 hash. Zero the hash on any error so a + // stale/meaningless value is never reported as a valid checksum. + if (result_code != PANEL_UPLOAD_OK) { + memset(&g_upload.calculated_hash, 0, sizeof(g_upload.calculated_hash)); + } + buf[0] = result_code; + memcpy(&buf[1], g_upload.calculated_hash.bytes, 32); + + panel_transport_set_async_result(buf, 33); +} + +// True if `path` is a file currently open as an image on any target, so the +// destructive handlers can refuse to pull it out from under a mounted device. +// +// Matches on the file's first data sector, which uniquely identifies a file +// regardless of its name or directory — so a same-named file in a different +// directory is NOT mistaken for the loaded one. SdFat exposes no absolute path +// for an open file, so a literal path-string compare isn't possible here. When +// the sector is unavailable (empty target, or a non-contiguous mounted image) +// it falls back to a basename match, which over-blocks rather than under-blocks. +static bool panel_path_is_loaded(const char* path) { + FsFile target = SD.open(path, O_RDONLY); + uint32_t target_sector = target.isOpen() ? (uint32_t)target.firstSector() : 0; + if (target.isOpen()) target.close(); + + const char* target_name = panel_path_basename(path); + char name[MAX_FILE_PATH]; + + // The panel's own long-lived handles are invisible to g_DiskImages: + // g_async.fw_file stays open after CHECK_FIRMWARE until the next one, and an + // in-flight upload/download holds one too. Removing or renaming the file + // under them frees clusters SdFat still references, so a later read lands on + // reallocated sectors. + FsFile* const panel_held[] = { &g_async.fw_file, &g_upload.upload_file, + &g_download.download_file }; + for (FsFile* held : panel_held) { + if (!held->isOpen()) continue; + uint32_t hbgn = 0, hend = 0; + if (target_sector != 0 && held->contiguousRange(&hbgn, &hend) && hbgn != 0) { + if (hbgn == target_sector) return true; + } else { + name[0] = '\0'; + held->getName(name, sizeof(name)); + if (name[0] && strcasecmp(panel_path_basename(name), target_name) == 0) return true; + } + } + + for (int id = 0; id < S2S_MAX_TARGETS; id++) { + image_config_t& img = scsiDiskGetImageConfig(id); + if (!img.file.isOpen()) continue; + + // A directly-loaded .cue keeps its filename in current_image while + // img.file is the parent directory; match the cue's basename too so + // the loaded cue sheet can't be deleted out from under the host. + if (img.cue_loaded_directly && + strcasecmp(panel_path_basename(img.current_image), target_name) == 0) { + return true; + } + + uint32_t bgn = 0, end = 0; + if (target_sector != 0 && img.file.contiguousRange(&bgn, &end) && bgn != 0) { + if (bgn == target_sector) return true; + } else { + name[0] = '\0'; + img.file.getFilename(name, sizeof(name)); + if (strcasecmp(panel_path_basename(name), target_name) == 0) return true; + } + } + return false; +} + +// Delete a file from the SD card. Payload is a null-terminated path. Returns a +// single PANEL_DELETE_* result byte. +static __attribute__((noinline)) void handle_delete_file_async(const uint8_t* payload, size_t payload_size) { + uint8_t* buf = panel_transport_get_tx_buffer(); + uint8_t result = PANEL_DELETE_OK; + + if (!payload || payload_size == 0) { + logmsg("Panel: DELETE_FILE with empty payload"); + result = PANEL_DELETE_ERROR_PATH; + } else { + const char* path = (const char*)payload; + size_t name_len = strnlen(path, payload_size); + if (name_len == 0 || name_len >= payload_size) { + logmsg("Panel: DELETE_FILE path empty or not null-terminated"); + result = PANEL_DELETE_ERROR_PATH; + } else if (panel_path_has_traversal(path)) { + logmsg("Panel: DELETE_FILE rejected path traversal: ", path); + result = PANEL_DELETE_ERROR_PATH; + } else if (panel_path_is_loaded(path)) { + logmsg("Panel: DELETE_FILE refused - file is loaded: ", path); + result = PANEL_DELETE_ERROR_IN_USE; + } else if (!SD.exists(path)) { + logmsg("Panel: DELETE_FILE not found: ", path); + result = PANEL_DELETE_ERROR_NOT_FOUND; + } else if (!SD.remove(path)) { + logmsg("Panel: DELETE_FILE failed to remove: ", path); + result = PANEL_DELETE_ERROR_IO; + } else { + logmsg("Panel: File deleted: ", path); + g_dir.scanned = false; + } + } + + buf[0] = result; + panel_transport_set_async_result(buf, 1); +} + +// Rename a file on the SD card. Payload is two back-to-back null-terminated +// strings: oldpath\0newpath\0. Returns a single PANEL_RENAME_* result byte. +static __attribute__((noinline)) void handle_rename_file_async(const uint8_t* payload, size_t payload_size) { + uint8_t* buf = panel_transport_get_tx_buffer(); + uint8_t result = PANEL_RENAME_OK; + + if (!payload || payload_size == 0) { + logmsg("Panel: RENAME_FILE with empty payload"); + result = PANEL_RENAME_ERROR_PATH; + } else { + const char* old_path = (const char*)payload; + size_t old_len = strnlen(old_path, payload_size); + if (old_len == 0 || old_len >= payload_size) { + logmsg("Panel: RENAME_FILE old path empty or not null-terminated"); + result = PANEL_RENAME_ERROR_PATH; + } else { + const char* new_path = old_path + old_len + 1; + size_t remaining = payload_size - (old_len + 1); + size_t new_len = strnlen(new_path, remaining); + if (new_len == 0 || new_len >= remaining) { + logmsg("Panel: RENAME_FILE new path empty or not null-terminated"); + result = PANEL_RENAME_ERROR_PATH; + } else if (panel_path_has_traversal(old_path) || panel_path_has_traversal(new_path)) { + logmsg("Panel: RENAME_FILE rejected path traversal"); + result = PANEL_RENAME_ERROR_PATH; + } else if (panel_path_is_loaded(old_path)) { + logmsg("Panel: RENAME_FILE refused - file is loaded: ", old_path); + result = PANEL_RENAME_ERROR_IN_USE; + } else if (!SD.exists(old_path)) { + logmsg("Panel: RENAME_FILE source not found: ", old_path); + result = PANEL_RENAME_ERROR_NOT_FOUND; + } else if (SD.exists(new_path)) { + logmsg("Panel: RENAME_FILE destination exists: ", new_path); + result = PANEL_RENAME_ERROR_EXISTS; + } else if (!SD.rename(old_path, new_path)) { + logmsg("Panel: RENAME_FILE failed: ", old_path, " -> ", new_path); + result = PANEL_RENAME_ERROR_IO; + } else { + logmsg("Panel: File renamed: ", old_path, " -> ", new_path); + g_dir.scanned = false; + } + } + } + + buf[0] = result; + panel_transport_set_async_result(buf, 1); +} + +// Create an empty file (touch). Payload is a null-terminated path. Returns a +// single PANEL_TOUCH_* result byte. +static __attribute__((noinline)) void handle_touch_file_async(const uint8_t* payload, size_t payload_size) { + uint8_t* buf = panel_transport_get_tx_buffer(); + uint8_t result = PANEL_TOUCH_OK; + + if (!payload || payload_size == 0) { + logmsg("Panel: TOUCH_FILE with empty payload"); + result = PANEL_TOUCH_ERROR_PATH; + } else { + const char* path = (const char*)payload; + size_t name_len = strnlen(path, payload_size); + if (name_len == 0 || name_len >= payload_size) { + logmsg("Panel: TOUCH_FILE path empty or not null-terminated"); + result = PANEL_TOUCH_ERROR_PATH; + } else if (panel_path_has_traversal(path)) { + logmsg("Panel: TOUCH_FILE rejected path traversal: ", path); + result = PANEL_TOUCH_ERROR_PATH; + } else if (SD.exists(path)) { + logmsg("Panel: TOUCH_FILE already exists: ", path); + result = PANEL_TOUCH_ERROR_EXISTS; + } else { + FsFile f = SD.open(path, O_WRONLY | O_CREAT); + if (!f.isOpen()) { + logmsg("Panel: TOUCH_FILE failed to create: ", path); + result = PANEL_TOUCH_ERROR_IO; + } else { + f.close(); + logmsg("Panel: File created: ", path); + g_dir.scanned = false; + } + } + } + + buf[0] = result; + panel_transport_set_async_result(buf, 1); +} + +// Create a directory. Payload is a null-terminated path. Returns a single +// PANEL_MKDIR_* result byte. +static __attribute__((noinline)) void handle_mkdir_async(const uint8_t* payload, size_t payload_size) { + uint8_t* buf = panel_transport_get_tx_buffer(); + uint8_t result = PANEL_MKDIR_OK; + + if (!payload || payload_size == 0) { + logmsg("Panel: MKDIR with empty payload"); + result = PANEL_MKDIR_ERROR_PATH; + } else { + const char* path = (const char*)payload; + size_t name_len = strnlen(path, payload_size); + if (name_len == 0 || name_len >= payload_size) { + logmsg("Panel: MKDIR path empty or not null-terminated"); + result = PANEL_MKDIR_ERROR_PATH; + } else if (panel_path_has_traversal(path)) { + logmsg("Panel: MKDIR rejected path traversal: ", path); + result = PANEL_MKDIR_ERROR_PATH; + } else if (SD.exists(path)) { + logmsg("Panel: MKDIR already exists: ", path); + result = PANEL_MKDIR_ERROR_EXISTS; + } else if (!SD.mkdir(path)) { + logmsg("Panel: MKDIR failed: ", path); + result = PANEL_MKDIR_ERROR_IO; + } else { + logmsg("Panel: Directory created: ", path); + g_dir.scanned = false; + } + } + + buf[0] = result; + panel_transport_set_async_result(buf, 1); +} + +static void handle_reset(void) { + g_async.reset(); + g_upload.reset(); + g_download.reset(); + // Drop the cached device snapshot; it repopulates on the next main-loop + // refresh (which re-caches filenames for any loaded device). + memset((void *)g_device_snapshot, 0, sizeof(g_device_snapshot)); + g_snapshot_name_refresh_ms = 0; + logmsg("Panel: Reset"); +} + +// ============================================================================ +// Public API +// ============================================================================ + +// Deferred IRQ-side telemetry. panel_protocol_handle_read() is called from +// the DMA IRQ; logmsg writes a lock-free shared buffer, so we record events +// here and let the main loop drain them via panel_protocol_drain_irq_log(). +static volatile uint32_t g_unknown_read_cmd_count = 0; +static volatile uint8_t g_last_unknown_read_cmd = 0; + +void panel_protocol_init(void) { + g_async.reset(); + g_unknown_read_cmd_count = 0; + g_last_unknown_read_cmd = 0; + logmsg("Panel protocol initialized"); +} + +size_t panel_protocol_handle_read(uint8_t cmd, uint16_t arg, + uint8_t* response, size_t max_size) { + switch (cmd) { + case PANEL_CMD_POLL_STATUS: + return handle_poll_status(response); + + case PANEL_CMD_GET_DEVICE_STATUS: + return handle_get_device_status(arg, response); + + case PANEL_CMD_GET_FIRMWARE_INFO: + return handle_get_firmware_info(response, max_size); + + case PANEL_CMD_GET_INITIATOR_SUMMARY: + return handle_get_initiator_summary(response, max_size); + + case PANEL_CMD_GET_COMMAND_STATUS: + return handle_get_command_status(response, max_size); + + case PANEL_CMD_GET_PLAYBACK_STATUS: + return handle_get_playback_status(arg, response, max_size); + + default: + // IRQ context - do not call logmsg here. The main loop will + // surface this via panel_protocol_drain_irq_log(). + g_unknown_read_cmd_count++; + g_last_unknown_read_cmd = cmd; + return 0; + } +} + +void panel_protocol_drain_irq_log(void) { + // Snapshot+clear the counter under interrupts-disabled to avoid races + // with the IRQ handler. Cheap, and only runs from the main loop. + uint32_t status = save_and_disable_interrupts(); + uint32_t count = g_unknown_read_cmd_count; + uint8_t last = g_last_unknown_read_cmd; + g_unknown_read_cmd_count = 0; + restore_interrupts(status); + + if (count > 0) { + logmsg("Panel: Unknown read cmd 0x", (uint8_t)last, " (", (int)count, " occurrences)"); + } +} + +void panel_protocol_handle_write(uint8_t cmd, uint16_t arg, + const uint8_t* payload, size_t payload_size, + uint16_t payload_crc16) { + // Track current async command + if (PANEL_CMD_IS_ASYNC(cmd)) { + g_async.current_command = cmd; + g_async.state = PANEL_ASYNC_PROCESSING; + } + + switch (cmd) { + case PANEL_CMD_GET_DIR_ENTRY_COUNT: + handle_get_dir_entry_count_async(); + break; + + case PANEL_CMD_GET_ENTRY_INFO: + handle_get_entry_info_async(arg); + break; + + case PANEL_CMD_GET_CURRENT_PATH: + handle_get_current_path_async(); + break; + + case PANEL_CMD_SELECT_ENTRY: { + // arg contains entry index (signed 16-bit: -1 for parent, 0-N for entry) + // payload optionally contains device index (first byte) + uint16_t device_index = 0; + if (payload && payload_size >= 1) { + device_index = payload[0]; + } else { + // Default: find first configured device + for (int i = 0; i < S2S_MAX_TARGETS; i++) { + if (g_DiskImages[i].scsiId & S2S_CFG_TARGET_ENABLED) { + device_index = i; + break; + } + } + } + handle_select_entry_async(device_index, (int16_t)arg); + break; + } + + case PANEL_CMD_EJECT_IMAGE: + handle_eject_image_async(arg); + break; + + case PANEL_CMD_SELECT_PREV_IMAGE: + handle_select_prev_image_async(arg); + break; + + case PANEL_CMD_SELECT_NEXT_IMAGE: + handle_select_next_image_async(arg); + break; + + case PANEL_CMD_SELECT_IMAGE_BY_NAME: + handle_select_image_by_name_async(payload, payload_size); + break; + + case PANEL_CMD_GET_LOADED_IMAGE_STATUS: + handle_get_loaded_image_status_async(arg); + break; + + case PANEL_CMD_GET_DEVICE_LIST: + handle_get_device_list_async(); + break; + + case PANEL_CMD_GET_INITIATOR_STATUS: + handle_get_initiator_status_async(); + break; + + case PANEL_CMD_CHECK_FIRMWARE: + handle_check_firmware_async(); + break; + + case PANEL_CMD_START_FIRMWARE_READ: { + uint32_t offset = 0; + if (payload && payload_size >= 4) { + offset = payload[0] | ((uint32_t)payload[1] << 8) | + ((uint32_t)payload[2] << 16) | ((uint32_t)payload[3] << 24); + } else { + offset = arg * PANEL_FIRMWARE_CHUNK_SIZE; + } + handle_start_firmware_read_async(offset); + break; + } + + case PANEL_CMD_GET_RP2350_FW_STATUS: + handle_get_host_fw_status_async(); + break; + + case PANEL_CMD_START_RP2350_UPDATE: + logmsg("Panel: Firmware update requested, rebooting..."); + panel_transport_set_async_result(nullptr, 0); + platform_reset_mcu(); + break; + + case PANEL_CMD_START_FILE_DOWNLOAD: + handle_start_file_download_async(payload, payload_size); + break; + + case PANEL_CMD_READ_FILE_CHUNK: { + // chunk_index is 32-bit; the 16-bit header arg truncates it past + // 256MB, so prefer the 4-byte little-endian payload the device + // also sends, falling back to arg for small files. + uint32_t chunk_index = arg; + if (payload && payload_size >= 4) { + chunk_index = payload[0] | ((uint32_t)payload[1] << 8) | + ((uint32_t)payload[2] << 16) | ((uint32_t)payload[3] << 24); + } + handle_read_file_chunk_async(chunk_index); + break; + } + + case PANEL_CMD_START_FILE_UPLOAD: + handle_start_file_upload_async(payload, payload_size); + break; + + case PANEL_CMD_WRITE_FILE_CHUNK: + // arg = expected CRC16 (sent by ESP32 in header) + // payload_crc16 = CRC16 calculated by DMA sniffer over received payload + handle_write_file_chunk_async(arg, payload_crc16, payload, payload_size); + break; + + case PANEL_CMD_FINISH_FILE_UPLOAD: + handle_finish_file_upload_async(); + break; + + case PANEL_CMD_DELETE_FILE: + handle_delete_file_async(payload, payload_size); + break; + + case PANEL_CMD_RENAME_FILE: + handle_rename_file_async(payload, payload_size); + break; + + case PANEL_CMD_TOUCH_FILE: + handle_touch_file_async(payload, payload_size); + break; + + case PANEL_CMD_MKDIR: + handle_mkdir_async(payload, payload_size); + break; + + case PANEL_CMD_RESET: + handle_reset(); + break; + + default: + logmsg("Panel: Unknown write cmd ", cmd); + if (PANEL_CMD_IS_ASYNC(cmd)) { + panel_transport_set_async_error(); + } + break; + } +} + +void panel_protocol_poll(void) { + // For MVP, async operations complete immediately in their handlers + // Future: could add background processing here +} + +#endif // ENABLE_PANEL_SPI || ENABLE_PANEL_I2C diff --git a/lib/BlueSCSI_platform_RP2MCU/panel_protocol.h b/lib/BlueSCSI_platform_RP2MCU/panel_protocol.h new file mode 100644 index 00000000..4b94965e --- /dev/null +++ b/lib/BlueSCSI_platform_RP2MCU/panel_protocol.h @@ -0,0 +1,95 @@ +/** + * BlueSCSI - Copyright (c) 2026 Eric Helgeson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * ---- + * + * Panel Protocol Handler + * + * Translates panel protocol commands into BlueSCSI operations. + */ + +#pragma once + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Initialize the protocol handler. + */ +void panel_protocol_init(void); + +/** + * Handle a read command (bit 7 = 1). + * These are synchronous and return data immediately. + * + * @param cmd Command code + * @param arg Command argument + * @param response Buffer for response data + * @param max_size Maximum response size + * @return Size of response data + */ +size_t panel_protocol_handle_read(uint8_t cmd, uint16_t arg, + uint8_t* response, size_t max_size); + +/** + * Handle a write command (bit 7 = 0). + * May be synchronous or start an async operation. + * + * @param cmd Command code + * @param arg Command argument + * @param payload Payload data (may be NULL) + * @param payload_size Size of payload + * @param payload_crc16 CRC16 of payload (for validation) + */ +void panel_protocol_handle_write(uint8_t cmd, uint16_t arg, + const uint8_t* payload, size_t payload_size, + uint16_t payload_crc16); + +/** + * Poll async operations. + * Should be called periodically to process background operations. + */ +void panel_protocol_poll(void); + +/** + * Refresh the per-device status snapshot read by the IRQ-context read handlers. + * MUST be called only from the main loop (it reads img->file.isOpen(), which + * races switchNextImage()). Call regularly from panel_spi_poll(). + */ +void panel_protocol_refresh_device_snapshot(void); + +/** + * Drain IRQ-context telemetry into the log. + * Some events (e.g. unknown read cmd) are recorded by the DMA IRQ handler + * and emitted by the main loop here, since logmsg is not IRQ-safe. + */ +void panel_protocol_drain_irq_log(void); + +/** + * True when the SCSI target bus is active or a host selection is latched but + * not yet serviced. Transports defer panel write commands (multi-ms SD I/O in + * the main loop) until this returns false. + */ +bool panel_scsi_bus_busy(void); + +#ifdef __cplusplus +} +#endif diff --git a/lib/BlueSCSI_platform_RP2MCU/panel_protocol_defs.h b/lib/BlueSCSI_platform_RP2MCU/panel_protocol_defs.h new file mode 100644 index 00000000..0ae65225 --- /dev/null +++ b/lib/BlueSCSI_platform_RP2MCU/panel_protocol_defs.h @@ -0,0 +1,316 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// +// Copyright (C) 2025-2026 Ian Scott +// Copyright (C) 2026 Eric Helgeson +// +// NOTE: This file alone is licensed GPL-2.0-or-later. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation; either version 2 of the License, or (at your +// option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, see . + +#pragma once + +#include + +// Panel communication protocol definitions +// Shared between ESP32 front panel and RP2350 main board + +// Protocol constants +#define PANEL_PROTOCOL_HEADER_SIZE 5 +#define PANEL_PROTOCOL_MAX_PAYLOAD 4096 +// Bumped whenever a shared struct changes shape. Reported in every status +// poll so a mismatched panel and main board can say so instead of quietly +// reading each other's fields at the wrong offsets. +#define PANEL_PROTOCOL_VERSION 1 + +// Liveness magic: a live main board running this protocol stamps +// PANEL_ALIVE_MAGIC into panel_playback_status_t.alive_magic on every reply. A +// dead/disconnected bus instead reads back uniformly 0x00 or 0xFF, so 0xA5 is +// deliberately distinct from 0x00/0xFF +#define PANEL_ALIVE_MAGIC 0xA5 + +// Command bit masks +#define PANEL_CMD_DIR_WRITE 0x00 +#define PANEL_CMD_DIR_READ 0x80 +#define PANEL_CMD_DIR_MASK 0x80 +#define PANEL_CMD_ASYNC_FLAG 0x40 // Bit 6 indicates async operation + +// Write commands (bit 7 = 0, bit 6 = 1 for async) +#define PANEL_CMD_GET_DIR_ENTRY_COUNT 0x42 // Get count of entries in current directory (async) +#define PANEL_CMD_GET_ENTRY_INFO 0x43 // Get info for entry at index (async, arg: index) +#define PANEL_CMD_SELECT_ENTRY 0x44 // Select entry by index: -1 (0xFFFF)=parent dir, >=0=select entry (async, arg: signed 16-bit index) +#define PANEL_CMD_GET_CURRENT_PATH 0x45 // Get current directory path (async) +#define PANEL_CMD_GET_DEVICE_LIST 0x46 // Get list of all configured devices (async) +#define PANEL_CMD_EJECT_IMAGE 0x47 // Unload current image (async) +#define PANEL_CMD_GET_LOADED_IMAGE_STATUS 0x48 // Get status of currently loaded image (async) +#define PANEL_CMD_SELECT_PREV_IMAGE 0x49 // Load previous image in current directory (async) +#define PANEL_CMD_SELECT_NEXT_IMAGE 0x4A // Load next image in current directory (async) +#define PANEL_CMD_SELECT_IMAGE_BY_NAME 0x4B // Load image by filename (async, payload: null-terminated filename) +#define PANEL_CMD_CHECK_FIRMWARE 0x50 // Check for firmware update (async) +#define PANEL_CMD_START_FIRMWARE_READ 0x51 // Start firmware read (async, arg: chunk index) +#define PANEL_CMD_START_FILE_UPLOAD 0x52 // Start file upload (async, payload: file_upload_start_t) +#define PANEL_CMD_WRITE_FILE_CHUNK 0x53 // Write file chunk (async, arg: chunk crc16, payload: chunk data) +#define PANEL_CMD_FINISH_FILE_UPLOAD 0x54 // Finish file upload (async) +#define PANEL_CMD_GET_RP2350_FW_STATUS 0x55 // Get RP2350 firmware status (async, returns rp2350_fw_status_t) +#define PANEL_CMD_START_RP2350_UPDATE 0x56 // Start RP2350 firmware update from SD card (async, reboots on success) +#define PANEL_CMD_START_FILE_DOWNLOAD 0x57 // Start file download (async, payload: null-terminated filename) +#define PANEL_CMD_READ_FILE_CHUNK 0x58 // Read file chunk (async, arg: chunk index, returns chunk data) +// 0x59 reserved for PANEL_CMD_GET_INITIATOR_STATUS on the main board (not used by this panel build) +#define PANEL_CMD_DELETE_FILE 0x5A // Delete file (async, payload: null-terminated path) +#define PANEL_CMD_RENAME_FILE 0x5B // Rename file (async, payload: oldpath\0newpath\0) +#define PANEL_CMD_TOUCH_FILE 0x5C // Create empty file (async, payload: null-terminated path) +#define PANEL_CMD_MKDIR 0x5D // Create directory (async, payload: null-terminated path) +#define PANEL_CMD_RESET 0x7F // Reset system + +// Read commands (bit 7 = 1) +#define PANEL_CMD_POLL_STATUS 0x80 // Get general status (1 byte response) +#define PANEL_CMD_POLL_OP_READY 0x81 // Check if async operation ready (3 bytes: ready, size low, size high) +#define PANEL_CMD_GET_DEVICE_STATUS 0x83 // Get device status (1 byte response) +#define PANEL_CMD_GET_FIRMWARE_INFO 0x84 // Get firmware info after CHECK_FIRMWARE (45 bytes) +#define PANEL_CMD_GET_PLAYBACK_STATUS 0x85 // Get current playback status (panel_playback_status_t) +#define PANEL_CMD_GET_COMMAND_STATUS 0x86 // Get detailed async command status (panel_command_status_t) +#define PANEL_CMD_GET_INITIATOR_SUMMARY 0x87 // Initiator progress summary (panel_initiator_summary_t) + +// Status codes for POLL_STATUS +#define PANEL_STATUS_OK 0x00 // System OK +#define PANEL_STATUS_BUSY 0x01 // Operation in progress +#define PANEL_STATUS_ERROR 0x02 // Last operation failed +#define PANEL_STATUS_NO_OPERATION 0x03 // No operation pending + +// Device status codes for GET_DEVICE_STATUS +#define PANEL_DEVICE_STATUS_NO_IMAGE 0x00 // No image loaded +#define PANEL_DEVICE_STATUS_LOADED 0x01 // Image loaded and ready +#define PANEL_DEVICE_STATUS_LOADING 0x02 // Image loading in progress +#define PANEL_DEVICE_STATUS_ERROR 0x03 // Image error +#define PANEL_DEVICE_STATUS_NO_CARD 0x04 // SD card not present +#define PANEL_DEVICE_STATUS_WRONG_MODE 0x05 // SD card configured for the other device type +#define PANEL_DEVICE_STATUS_TRAY_OPEN 0x06 // Optical tray open (disc ejected), awaiting load/close + +// Special argument values +#define PANEL_ARG_EXTENDED 0xFFFF // Use payload for extended data +#define PANEL_ARG_IGNORED 0x0000 // Argument not used + +// Protocol header structure (5 bytes) +typedef struct __attribute__((packed)) { + uint8_t command; // Command code with direction bit + uint16_t argument; // Optional argument halfword + uint16_t payload_size; // Size of phase 2 transfer (little-endian) +} panel_protocol_header_t; + +// Status response structure for POLL_OP_READY (3 bytes) +typedef struct __attribute__((packed)) { + uint8_t ready_flag; // 1 if ready, 0 if not + uint16_t response_size; // Size of result data (little-endian) +} panel_status_response_t; + +// Detailed command status for GET_COMMAND_STATUS (4 bytes) +typedef struct __attribute__((packed)) { + uint8_t command; // Current/last async command + uint8_t state; // PANEL_ASYNC_* state + uint8_t progress; // 0-100 progress (command-specific) + uint8_t last_result; // Result of last completed operation +} panel_command_status_t; + +// Helper macros +#define PANEL_CMD_IS_READ(cmd) ((cmd) & PANEL_CMD_DIR_MASK) +#define PANEL_CMD_IS_WRITE(cmd) (!PANEL_CMD_IS_READ(cmd)) +#define PANEL_CMD_IS_ASYNC(cmd) ((cmd) & PANEL_CMD_ASYNC_FLAG) + +// Async operation states (internal to main board) +typedef enum { + PANEL_ASYNC_IDLE = 0, + PANEL_ASYNC_PROCESSING, + PANEL_ASYNC_READY, + PANEL_ASYNC_ERROR +} panel_async_state_t; + +// Firmware info structure (45 bytes) +typedef struct __attribute__((packed)) { + uint32_t size; // Firmware size in bytes + uint32_t version; // Packed version number + uint8_t sha256[32]; // SHA256 hash (32 bytes) + uint8_t available; // 1 if update available, 0 if not + uint8_t reserved[8]; // Reserved for alignment +} panel_firmware_info_t; + +// RP2350 firmware status structure +typedef struct __attribute__((packed)) { + uint32_t current_version; // Running version + uint32_t available_version; // Available update version (0 if none) + uint8_t update_progress; // Update progress (0-100), 0 if not updating + uint8_t last_update_result; // Result of last update attempt +} rp2350_fw_status_t; + +// Firmware chunk size (must fit within PANEL_PROTOCOL_MAX_PAYLOAD) +#define PANEL_FIRMWARE_CHUNK_SIZE PANEL_PROTOCOL_MAX_PAYLOAD + +// File upload chunk size (must fit within PANEL_PROTOCOL_MAX_PAYLOAD) +#define PANEL_FILE_CHUNK_SIZE PANEL_PROTOCOL_MAX_PAYLOAD + +// File upload start structure (variable length with filename and hash) +typedef struct __attribute__((packed)) { + uint32_t file_size; // Total file size in bytes + uint16_t filename_len; // Length of filename string + // Filename follows (null-terminated string) +} panel_file_upload_start_t; + +// File upload result codes +#define PANEL_UPLOAD_OK 0x00 +#define PANEL_UPLOAD_ERROR_DISK 0x01 +#define PANEL_UPLOAD_ERROR_SPACE 0x02 +#define PANEL_UPLOAD_ERROR_WRITE 0x03 +#define PANEL_UPLOAD_ERROR_PATH 0x04 + +// File download start structure (5 bytes) +typedef struct __attribute__((packed)) { + uint8_t result_code; // PANEL_DOWNLOAD_* result code + uint32_t file_size; // Total file size in bytes (valid if result_code == 0) +} panel_file_download_start_result_t; + +// File download result codes +#define PANEL_DOWNLOAD_OK 0x00 +#define PANEL_DOWNLOAD_ERROR_NOT_FOUND 0x01 +#define PANEL_DOWNLOAD_ERROR_READ 0x02 + +// File delete result codes (DELETE_FILE returns a single result_code byte) +#define PANEL_DELETE_OK 0x00 +#define PANEL_DELETE_ERROR_NOT_FOUND 0x01 +#define PANEL_DELETE_ERROR_IN_USE 0x02 // target is a currently-loaded image +#define PANEL_DELETE_ERROR_IO 0x03 +#define PANEL_DELETE_ERROR_PATH 0x04 // empty path or ".." traversal + +// File rename result codes (RENAME_FILE returns a single result_code byte) +#define PANEL_RENAME_OK 0x00 +#define PANEL_RENAME_ERROR_NOT_FOUND 0x01 +#define PANEL_RENAME_ERROR_EXISTS 0x02 // destination already exists +#define PANEL_RENAME_ERROR_IN_USE 0x03 // source is a currently-loaded image +#define PANEL_RENAME_ERROR_IO 0x04 +#define PANEL_RENAME_ERROR_PATH 0x05 // empty/invalid path or ".." traversal + +// File touch (create empty file) result codes (single result_code byte) +#define PANEL_TOUCH_OK 0x00 +#define PANEL_TOUCH_ERROR_EXISTS 0x01 // a file/dir with that name already exists +#define PANEL_TOUCH_ERROR_PATH 0x02 // empty/invalid path or ".." traversal +#define PANEL_TOUCH_ERROR_IO 0x03 + +// Make-directory result codes (single result_code byte) +#define PANEL_MKDIR_OK 0x00 +#define PANEL_MKDIR_ERROR_EXISTS 0x01 // a file/dir with that name already exists +#define PANEL_MKDIR_ERROR_PATH 0x02 // empty/invalid path or ".." traversal +#define PANEL_MKDIR_ERROR_IO 0x03 + +// Disc type codes for playback status +#define PANEL_DISC_TYPE_NO_DISC 0x00 +#define PANEL_DISC_TYPE_DATA 0x01 // Data CD-ROM +#define PANEL_DISC_TYPE_AUDIO 0x02 +#define PANEL_DISC_TYPE_MIXED 0x03 +#define PANEL_DISC_TYPE_HDD 0x04 // IDE hard disk + +// Audio playback status codes (matches CDRomAudioStatus) +#define PANEL_AUDIO_STATUS_DATA_ONLY 0x00 +#define PANEL_AUDIO_STATUS_PLAYING 0x11 +#define PANEL_AUDIO_STATUS_PAUSED 0x12 +#define PANEL_AUDIO_STATUS_PLAYING_COMPLETED 0x13 +#define PANEL_AUDIO_STATUS_PLAY_ERROR 0x14 +#define PANEL_AUDIO_STATUS_NONE 0x15 + +// Playback status structure (76 bytes) +// panel_playback_status_t.flags. Three one-bit states that were a byte each. +#define PANEL_PB_DISC_INSERTED 0x01 // A disc/image is loaded +#define PANEL_PB_PLAYING 0x02 // Audio is playing +#define PANEL_PB_TRAY_OPEN 0x04 // Ejected, awaiting load or close +#define PANEL_PB_FLAGS_KNOWN 0x07 // Bits defined so far; anything else is garbage + +// Target-mode status, polled constantly by the panel. Answered straight from +// the main board's ISR, so it arrives whatever the board is doing - which is +// why the three fields that are not about a target live here too: they are +// panel-protocol metadata (is this board alive, what does it speak, which mode +// is it in), and the mode is what tells the panel which status to ask for next. +// An initiator's own state is PANEL_CMD_GET_INITIATOR_SUMMARY, not this. +typedef struct __attribute__((packed)) { + uint8_t alive_magic; // PANEL_ALIVE_MAGIC. First so a reader built against + // a different layout still finds it. + uint8_t protocol_version; // PANEL_PROTOCOL_VERSION the board was built with + uint8_t flags; // PANEL_PB_* + uint8_t disc_type; // PANEL_DISC_TYPE_* + uint8_t audio_status; // PANEL_AUDIO_STATUS_* + uint8_t device_status; // PANEL_DEVICE_STATUS_* + uint8_t operating_mode; // PANEL_MODE_* (panel_protocol_defs_initiator.h) + uint8_t current_track; // Current track number (1-99) + uint8_t track_position_m; // Track position: minutes + uint8_t track_position_s; // Track position: seconds + uint8_t track_position_f; // Track position: frames + char disc_name[64]; // Current disc name (null-terminated) +} panel_playback_status_t; + +// Entry type for directory listings +#define PANEL_ENTRY_TYPE_DIRECTORY 0x00 // Subdirectory (navigate into it) +#define PANEL_ENTRY_TYPE_FILE 0x01 // Image file (load it) + +// Entry flags for directory listings (dir_entry_info_t.flags) +#define PANEL_ENTRY_FLAG_NOT_LOADABLE 0x01 // List it, but refuse to load it + +// Directory entry information structure (68 bytes) +typedef struct __attribute__((packed)) { + char name[64]; // Filename or directory name (null-terminated) + uint8_t entry_type; // PANEL_ENTRY_TYPE_* + uint8_t flags; // PANEL_ENTRY_FLAG_* + uint8_t reserved[2]; // Padding for alignment +} dir_entry_info_t; + +// Device type codes (loaded_image_status_t.device_type) +#define PANEL_DEVICE_TYPE_ATAPI 0x00 // CD-ROM drive +#define PANEL_DEVICE_TYPE_IDE 0x01 // Hard disk drive +#define PANEL_DEVICE_TYPE_SCSI 0x02 // SCSI device (BlueSCSI) + +// Device category codes (device_summary_t.device_type), numbered to match +// values from BlueSCSI's img.deviceType +#define PANEL_DEV_CATEGORY_FIXED 0x00 +#define PANEL_DEV_CATEGORY_REMOVABLE 0x01 +#define PANEL_DEV_CATEGORY_OPTICAL 0x02 +#define PANEL_DEV_CATEGORY_FLOPPY 0x03 +#define PANEL_DEV_CATEGORY_MO 0x04 +#define PANEL_DEV_CATEGORY_ZIP 0x07 + +// Device summary (68 bytes) +typedef struct __attribute__((packed)) { + uint16_t device_index; + uint8_t device_type; // PANEL_DEV_CATEGORY_* (matches S2S_CFG_TYPE) + uint8_t device_status; // PANEL_DEVICE_STATUS_* + char device_label[32]; // e.g. "SCSI ID 3", "CD-ROM" + char image_name[32]; // Current image name or empty +} device_summary_t; + +// Device list response (variable length) +typedef struct __attribute__((packed)) { + uint8_t device_count; + uint8_t max_devices; + uint8_t reserved[2]; + device_summary_t devices[]; +} device_list_response_t; + +// Currently loaded image status structure (212 bytes) +typedef struct __attribute__((packed)) { + uint8_t image_loaded; // 1 if image loaded, 0 if not + uint8_t device_type; // PANEL_DEVICE_TYPE_* + uint8_t reserved1[2]; // Padding + char image_name[64]; // Name of loaded image (null-terminated) + char directory_path[128]; // Directory containing the image (null-terminated) + uint32_t image_index; // Index in current directory (0-based, only files counted) + uint32_t total_images; // Total number of images in directory + // IDE-specific (only when device_type == PANEL_DEVICE_TYPE_IDE) + uint16_t cylinders; + uint8_t heads; + uint8_t sectors; + uint8_t reserved2[8]; // Reserved for future use +} loaded_image_status_t; diff --git a/lib/BlueSCSI_platform_RP2MCU/panel_protocol_defs_initiator.h b/lib/BlueSCSI_platform_RP2MCU/panel_protocol_defs_initiator.h new file mode 100644 index 00000000..d9c335bb --- /dev/null +++ b/lib/BlueSCSI_platform_RP2MCU/panel_protocol_defs_initiator.h @@ -0,0 +1,133 @@ +/** + * BlueSCSI - Copyright (c) 2026 Eric Helgeson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +// Shared verbatim with the front panel firmware at +// open-retro-storage-frontpanel/sw-frontpanel/main/panel_protocol_defs_initiator.h +// Keep the two byte-identical; the panel compiles this as C11. + +#include "panel_protocol_defs.h" +#include // offsetof +#include // static_assert, when compiled as C11 + +// Get initiator mode status (async, returns initiator_status_response_t). +// The shared header reserves command 0x59 for this BlueSCSI-only command. +#define PANEL_CMD_GET_INITIATOR_STATUS 0x59 + +// Operating mode, reported in the device list response. The shared +// device_list_response_t exposes this byte as reserved[0]; 0x00 (target) is the +// default and is backward-compatible with firmware that leaves it zeroed. +#define PANEL_MODE_TARGET 0x00 +#define PANEL_MODE_INITIATOR 0x01 +#define PANEL_DEVLIST_MODE(list) ((list)->reserved[0]) + +// Answer to PANEL_CMD_GET_INITIATOR_SUMMARY: everything the imaging screen +// draws on every frame, and nothing else. +// +// This is a synchronous read served from the main board's ISR. That is the +// whole point of it existing alongside PANEL_CMD_GET_INITIATOR_STATUS: the +// latter is async, so the main loop completes it, and a board that is imaging +// is busy driving the SCSI bus and does not get there. The per-target detail +// is worth waiting for and tolerates being stale; the progress of the run is +// not and does not. +typedef struct __attribute__((packed)) { + uint8_t alive_magic; // PANEL_ALIVE_MAGIC, as in the playback status + uint8_t protocol_version; // PANEL_PROTOCOL_VERSION + uint8_t operating_mode; // PANEL_MODE_*, so the panel can see a mode change + // without going back to the playback status + uint8_t phase; // PANEL_INITIATOR_PHASE_* + uint8_t current_target; // SCSI ID being worked on, 0xFF when none + uint8_t progress; // 0-100, percent of the current target + uint8_t targets_found; + uint8_t targets_imaged; + uint16_t speed_kbps; + // Identity and size of the target being worked on. Static once scanned, but + // carried here because the async per-target table is the thing an imaging + // board cannot deliver - and "Imaging SCSI ID 5" with nothing else is not + // much of an answer to "what is it doing". + uint8_t device_type; // SCSI peripheral type of the current target + uint32_t sectorcount; + uint32_t sectorsize; + char vendor[9]; // null-terminated + char product[17]; // null-terminated +} panel_initiator_summary_t; + +static_assert(offsetof(device_list_response_t, reserved) == 2, + "device_list mode byte offset drifted from the shared header"); + +// Initiator mode phase codes +#define PANEL_INITIATOR_PHASE_IDLE 0x00 +#define PANEL_INITIATOR_PHASE_SCANNING 0x01 +#define PANEL_INITIATOR_PHASE_IMAGING 0x02 +#define PANEL_INITIATOR_PHASE_COMPLETE 0x03 +#define PANEL_INITIATOR_PHASE_ERROR 0x04 + +// Initiator target status codes +#define PANEL_INITIATOR_TARGET_NOT_FOUND 0x00 +#define PANEL_INITIATOR_TARGET_FOUND 0x01 +#define PANEL_INITIATOR_TARGET_IMAGING 0x02 +#define PANEL_INITIATOR_TARGET_DONE 0x03 +#define PANEL_INITIATOR_TARGET_ERROR 0x04 + +// Why a target was skipped (initiator_target_info_t.skip_reason). Only +// meaningful when status is PANEL_INITIATOR_TARGET_ERROR. +#define PANEL_INITIATOR_SKIP_NONE 0x00 +#define PANEL_INITIATOR_SKIP_TOO_LARGE_FAT32 0x01 // >= 4 GiB, card is not exFAT +#define PANEL_INITIATOR_SKIP_UNSUPPORTED 0x02 // not a block device +#define PANEL_INITIATOR_SKIP_FILE_EXISTS 0x03 // InitiatorImageHandling = skip +#define PANEL_INITIATOR_SKIP_TOO_MANY 0x04 // ran out of -NNN suffixes +#define PANEL_INITIATOR_SKIP_NO_SPACE 0x05 // SD card full + +// Per-target info reported during initiator mode (50 bytes) +typedef struct __attribute__((packed)) { + uint8_t scsi_id; + uint8_t device_type; // 0=HD, 5=CD, 7=MO + uint8_t ansi_version; + uint8_t status; // PANEL_INITIATOR_TARGET_* + uint32_t sectorcount; + uint32_t sectorsize; + uint32_t sectors_done; + uint32_t bad_sector_count; + char vendor[9]; // INQUIRY vendor (null-terminated) + char product[17]; // INQUIRY product (null-terminated) + uint8_t sense_key; + uint8_t asc; + uint8_t ascq; + uint8_t skip_reason; // PANEL_INITIATOR_SKIP_* +} initiator_target_info_t; // 50 bytes + +// Initiator status response (variable length: 42 byte header + targets[]). +// current_filename and speed_kbps describe the target being imaged now, so +// they live in the header rather than being repeated for all eight targets. +typedef struct __attribute__((packed)) { + uint8_t phase; // PANEL_INITIATOR_PHASE_* + uint8_t current_target_id; // 0-7, or 0xFF if none + uint8_t initiator_id; + uint8_t targets_found; + uint8_t targets_imaged; + uint8_t drives_imaged_mask; // bitmask of IDs that have been imaged + uint16_t speed_kbps; // last measured read speed, 0 when not imaging + char current_filename[32]; // image being written, empty when not imaging + uint8_t reserved[2]; + initiator_target_info_t targets[]; // variable-length array +} initiator_status_response_t; + +static_assert(sizeof(initiator_status_response_t) == 42, + "initiator status header size drifted from the panel's copy"); +static_assert(sizeof(initiator_target_info_t) == 50, + "initiator target info size drifted from the panel's copy"); diff --git a/lib/BlueSCSI_platform_RP2MCU/panel_sha256.h b/lib/BlueSCSI_platform_RP2MCU/panel_sha256.h new file mode 100644 index 00000000..dc39f6a0 --- /dev/null +++ b/lib/BlueSCSI_platform_RP2MCU/panel_sha256.h @@ -0,0 +1,74 @@ +/** + * BlueSCSI - Copyright (c) 2026 Eric Helgeson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * ---- + * + * SHA-256 compatibility shim for the panel firmware-relay path. + * + * The RP2350 (Ultra/Ultra Wide) has a hardware SHA-256 block exposed by the + * pico-sdk's pico_sha256 library. The RP2040 (v2) has neither the hardware nor + * pico/sha256.h, so it gets a drop-in software implementation exposing the same + * pico_sha256_* API subset panel_protocol.cpp uses. Callers include this header + * instead of and are otherwise unchanged. + */ + +#pragma once + +#if defined(BLUESCSI_MCU_RP20XX) + +// RP2040: software SHA-256 with a pico_sha256-compatible front end. +#include +#include +#include +#include // PICO_OK + +#ifdef __cplusplus +extern "C" { +#endif + +enum sha256_endianness { + SHA256_LITTLE_ENDIAN = 0, + SHA256_BIG_ENDIAN = 1, +}; + +typedef struct { + uint8_t bytes[32]; +} sha256_result_t; + +typedef struct pico_sha256_state { + uint32_t state[8]; + uint64_t bitlen; + uint32_t datalen; + uint8_t data[64]; +} pico_sha256_state_t; + +// Endianness/use_dma are accepted for API compatibility; the software path +// always produces the standard big-endian digest. Always returns PICO_OK. +int pico_sha256_try_start(pico_sha256_state_t *state, enum sha256_endianness endianness, bool use_dma); +void pico_sha256_update(pico_sha256_state_t *state, const uint8_t *data, size_t data_size_bytes); +void pico_sha256_finish(pico_sha256_state_t *state, sha256_result_t *out); +void pico_sha256_cleanup(pico_sha256_state_t *state); + +#ifdef __cplusplus +} +#endif + +#else + +// RP2350 (and host unit tests, which stub it): hardware SHA-256 via the pico-sdk. +#include + +#endif // BLUESCSI_MCU_RP20XX diff --git a/lib/BlueSCSI_platform_RP2MCU/panel_sha256_sw.cpp b/lib/BlueSCSI_platform_RP2MCU/panel_sha256_sw.cpp new file mode 100644 index 00000000..d0c04e8b --- /dev/null +++ b/lib/BlueSCSI_platform_RP2MCU/panel_sha256_sw.cpp @@ -0,0 +1,135 @@ +/** + * BlueSCSI - Copyright (c) 2026 Eric Helgeson + * + * This work incorporates the SHA-256 implementation by Brad Conte + * (https://github.com/B-Con/crypto-algorithms), released into the + * public domain by its author. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * ---- + * + * Software SHA-256 for RP2040 (v2), exposed through the pico_sha256_* shim in + * panel_sha256.h. The Ultra/Ultra Wide (RP2350) targets use the hardware SHA + * block instead, so this file compiles to nothing there. The pico_sha256_* + * wrappers are BlueSCSI's; the transform/update/padding core is Brad Conte's. + */ + +#include "panel_sha256.h" + +// Compiled only where panel_sha256.h selects the software path (RP2040). +#if defined(BLUESCSI_MCU_RP20XX) + +#include + +#define ROTR32(x, n) (((x) >> (n)) | ((x) << (32 - (n)))) +#define SHA_CH(x, y, z) (((x) & (y)) ^ (~(x) & (z))) +#define SHA_MAJ(x, y, z) (((x) & (y)) ^ ((x) & (z)) ^ ((y) & (z))) +#define SHA_EP0(x) (ROTR32(x, 2) ^ ROTR32(x, 13) ^ ROTR32(x, 22)) +#define SHA_EP1(x) (ROTR32(x, 6) ^ ROTR32(x, 11) ^ ROTR32(x, 25)) +#define SHA_SIG0(x) (ROTR32(x, 7) ^ ROTR32(x, 18) ^ ((x) >> 3)) +#define SHA_SIG1(x) (ROTR32(x, 17) ^ ROTR32(x, 19) ^ ((x) >> 10)) + +static const uint32_t k[64] = { + 0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5, + 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174, + 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da, + 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967, + 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85, + 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070, + 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3, + 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2 +}; + +static void sha256_transform(pico_sha256_state_t *ctx, const uint8_t data[]) { + uint32_t a, b, c, d, e, f, g, h, t1, t2, m[64]; + + for (int i = 0, j = 0; i < 16; i++, j += 4) { + m[i] = ((uint32_t)data[j] << 24) | ((uint32_t)data[j + 1] << 16) | + ((uint32_t)data[j + 2] << 8) | ((uint32_t)data[j + 3]); + } + for (int i = 16; i < 64; i++) { + m[i] = SHA_SIG1(m[i - 2]) + m[i - 7] + SHA_SIG0(m[i - 15]) + m[i - 16]; + } + + a = ctx->state[0]; b = ctx->state[1]; c = ctx->state[2]; d = ctx->state[3]; + e = ctx->state[4]; f = ctx->state[5]; g = ctx->state[6]; h = ctx->state[7]; + + for (int i = 0; i < 64; i++) { + t1 = h + SHA_EP1(e) + SHA_CH(e, f, g) + k[i] + m[i]; + t2 = SHA_EP0(a) + SHA_MAJ(a, b, c); + h = g; g = f; f = e; e = d + t1; d = c; c = b; b = a; a = t1 + t2; + } + + ctx->state[0] += a; ctx->state[1] += b; ctx->state[2] += c; ctx->state[3] += d; + ctx->state[4] += e; ctx->state[5] += f; ctx->state[6] += g; ctx->state[7] += h; +} + +extern "C" int pico_sha256_try_start(pico_sha256_state_t *ctx, enum sha256_endianness endianness, bool use_dma) { + (void)endianness; + (void)use_dma; + ctx->datalen = 0; + ctx->bitlen = 0; + ctx->state[0] = 0x6a09e667; ctx->state[1] = 0xbb67ae85; + ctx->state[2] = 0x3c6ef372; ctx->state[3] = 0xa54ff53a; + ctx->state[4] = 0x510e527f; ctx->state[5] = 0x9b05688c; + ctx->state[6] = 0x1f83d9ab; ctx->state[7] = 0x5be0cd19; + return PICO_OK; +} + +extern "C" void pico_sha256_update(pico_sha256_state_t *ctx, const uint8_t *data, size_t len) { + for (size_t i = 0; i < len; i++) { + ctx->data[ctx->datalen] = data[i]; + ctx->datalen++; + if (ctx->datalen == 64) { + sha256_transform(ctx, ctx->data); + ctx->bitlen += 512; + ctx->datalen = 0; + } + } +} + +extern "C" void pico_sha256_finish(pico_sha256_state_t *ctx, sha256_result_t *out) { + uint32_t i = ctx->datalen; + + // Pad: append 0x80 then zeros, leaving room for the 64-bit length. + if (ctx->datalen < 56) { + ctx->data[i++] = 0x80; + while (i < 56) ctx->data[i++] = 0x00; + } else { + ctx->data[i++] = 0x80; + while (i < 64) ctx->data[i++] = 0x00; + sha256_transform(ctx, ctx->data); + memset(ctx->data, 0, 56); + } + + ctx->bitlen += (uint64_t)ctx->datalen * 8; + for (int b = 0; b < 8; b++) { + ctx->data[63 - b] = (uint8_t)(ctx->bitlen >> (8 * b)); + } + sha256_transform(ctx, ctx->data); + + // Output the digest big-endian (standard SHA-256 byte order). + for (int j = 0; j < 4; j++) { + for (int s = 0; s < 8; s++) { + out->bytes[j + (s * 4)] = (uint8_t)(ctx->state[s] >> (24 - j * 8)); + } + } +} + +extern "C" void pico_sha256_cleanup(pico_sha256_state_t *ctx) { + (void)ctx; // nothing to release in the software path +} + +#endif // BLUESCSI_MCU_RP20XX diff --git a/lib/BlueSCSI_platform_RP2MCU/panel_spi.cpp b/lib/BlueSCSI_platform_RP2MCU/panel_spi.cpp new file mode 100644 index 00000000..ba4e8141 --- /dev/null +++ b/lib/BlueSCSI_platform_RP2MCU/panel_spi.cpp @@ -0,0 +1,662 @@ +/** + * BlueSCSI - Copyright (c) 2026 Eric Helgeson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * ---- + * + * Panel SPI Slave Driver Implementation + * + * DMA-based SPI slave for communication with ESP32-C3 front panel. + */ + +#include "panel_spi.h" +#include "panel_protocol_defs.h" +#include "panel_protocol_defs_initiator.h" +#include "panel_protocol.h" +#include "BlueSCSI_platform.h" +#include "BlueSCSI_log.h" +#include "BlueSCSI_initiator.h" + +#ifdef ENABLE_PANEL_SPI + +extern "C" { +#include +} + +#include +#include +#include +#include +#include +#include + +// Transaction phases +typedef enum { + PHASE_IDLE, // Waiting for transaction + PHASE_HEADER, // Receiving/sending header + PHASE_PAYLOAD, // Receiving/sending payload + PHASE_PENDING // Async operation in progress +} panel_phase_t; + +// Size of the dedicated synchronous-read response buffer. Must hold the +// largest IRQ-context read response (panel_playback_status_t = 76 bytes); +// 128 leaves margin and keeps it cheap. +#define PANEL_SYNC_RESPONSE_SIZE 128 + +// Static state +static struct { + bool initialized; + spi_inst_t* spi; + int dma_rx_channel; + int dma_tx_channel; + + volatile panel_phase_t phase; + volatile bool dma_complete; + bool drop_payload; // current payload sunk to rx_payload, discard it + + // Header buffers + panel_protocol_header_t rx_header; + panel_protocol_header_t tx_header; + + // Payload buffers (4KB each) + uint8_t rx_payload[PANEL_PROTOCOL_MAX_PAYLOAD] __attribute__((aligned(4))); + uint8_t tx_payload[PANEL_PROTOCOL_MAX_PAYLOAD] __attribute__((aligned(4))); + + // Dedicated buffer for synchronous (IRQ-context) read responses, kept + // separate from tx_payload so a small sync read (e.g. GET_PLAYBACK_STATUS) + // can never overwrite a larger async result staged in tx_payload that is + // still awaiting its POLL_OP_READY drain. + uint8_t sync_response[PANEL_SYNC_RESPONSE_SIZE] __attribute__((aligned(4))); + + // Status response for POLL_OP_READY + panel_status_response_t status_response; + + // Async operation state + volatile panel_async_state_t async_state; + volatile uint16_t async_response_size; + uint8_t pending_async_command; + + // Saved header for main loop — ISR copies rx_header here before starting + // new DMA, because setup_header_dma() makes rx_header a live DMA target + // that gets overwritten by subsequent ESP32 transactions (POLL_OP_READY etc.) + panel_protocol_header_t saved_header; + + // IRQ suspend state — disabled during initiator SCSI bus operations + + // Transaction tracking for non-debug logging + bool first_transaction_logged; + uint32_t transaction_count; + uint32_t last_log_time; +} g_panel; + +// Forward declarations +static void setup_header_dma(void); +static bool setup_payload_dma(size_t size, uint8_t *rx_buf, uint8_t *tx_buf); +static void setup_status_dma(void); +static void dma_irq_handler(void); + +bool panel_spi_init(void) { + if (g_panel.initialized) { + return true; + } + + memset(&g_panel, 0, sizeof(g_panel)); + g_panel.spi = PANEL_SPI; + g_panel.dma_rx_channel = -1; + g_panel.dma_tx_channel = -1; + + logmsg("Panel SPI: Initializing on GPIO RX=", PANEL_SPI_RX, + " TX=", PANEL_SPI_TX, " SCK=", PANEL_SPI_SCK, " CS=", PANEL_SPI_CS); + + // Configure GPIO pins for SPI function + gpio_set_function(PANEL_SPI_RX, GPIO_FUNC_SPI); // MOSI (RX for slave) + gpio_set_function(PANEL_SPI_TX, GPIO_FUNC_SPI); // MISO (TX for slave) + gpio_set_function(PANEL_SPI_SCK, GPIO_FUNC_SPI); // SCK + gpio_set_function(PANEL_SPI_CS, GPIO_FUNC_SPI); // CS + + // Disable pulls except CS (pull-up for idle high) + gpio_disable_pulls(PANEL_SPI_RX); + gpio_disable_pulls(PANEL_SPI_TX); + gpio_disable_pulls(PANEL_SPI_SCK); + gpio_pull_up(PANEL_SPI_CS); + + // Initialize SPI in slave mode + // Mode 1: CPOL=0, CPHA=1 (matches PicoIDE/ESP32 master) + spi_init(g_panel.spi, 10000000); // 10 MHz (actual speed set by master) + spi_set_format(g_panel.spi, 8, SPI_CPOL_0, SPI_CPHA_1, SPI_MSB_FIRST); + spi_set_slave(g_panel.spi, true); + + // Claim DMA channels + g_panel.dma_rx_channel = dma_claim_unused_channel(true); + g_panel.dma_tx_channel = dma_claim_unused_channel(true); + + if (g_panel.dma_rx_channel < 0 || g_panel.dma_tx_channel < 0) { + logmsg("Panel SPI: Failed to claim DMA channels"); + panel_spi_deinit(); + return false; + } + + logmsg("Panel SPI: DMA channels RX=", g_panel.dma_rx_channel, + " TX=", g_panel.dma_tx_channel); + + // Configure RX DMA channel + dma_channel_config rx_config = dma_channel_get_default_config(g_panel.dma_rx_channel); + channel_config_set_transfer_data_size(&rx_config, DMA_SIZE_8); + channel_config_set_read_increment(&rx_config, false); // Read from fixed SPI DR + channel_config_set_write_increment(&rx_config, true); // Write to buffer with increment + channel_config_set_dreq(&rx_config, spi_get_dreq(g_panel.spi, false)); // RX dreq + dma_channel_set_config(g_panel.dma_rx_channel, &rx_config, false); + dma_channel_set_read_addr(g_panel.dma_rx_channel, &spi_get_hw(g_panel.spi)->dr, false); + + // Enable CRC16 sniffer on RX channel for payload validation + dma_sniffer_enable(g_panel.dma_rx_channel, 0x2 /* CRC-16-CCITT */, true); + + // Configure TX DMA channel + dma_channel_config tx_config = dma_channel_get_default_config(g_panel.dma_tx_channel); + channel_config_set_transfer_data_size(&tx_config, DMA_SIZE_8); + channel_config_set_read_increment(&tx_config, true); // Read from buffer with increment + channel_config_set_write_increment(&tx_config, false); // Write to fixed SPI DR + channel_config_set_dreq(&tx_config, spi_get_dreq(g_panel.spi, true)); // TX dreq + dma_channel_set_config(g_panel.dma_tx_channel, &tx_config, false); + dma_channel_set_write_addr(g_panel.dma_tx_channel, &spi_get_hw(g_panel.spi)->dr, false); + + // Setup DMA completion interrupt on RX channel + dma_irqn_set_channel_enabled(PANEL_DMA_IRQ_IDX, g_panel.dma_rx_channel, true); + irq_set_exclusive_handler(PANEL_DMA_IRQ_NUM, dma_irq_handler); + irq_set_enabled(PANEL_DMA_IRQ_NUM, true); + + // Initialize protocol handler + panel_protocol_init(); + + // Start listening for header + g_panel.phase = PHASE_HEADER; + setup_header_dma(); + + g_panel.initialized = true; + logmsg("Panel SPI: Initialized successfully"); + return true; +} + +void panel_spi_deinit(void) { + if (g_panel.dma_rx_channel >= 0) { + dma_irqn_set_channel_enabled(PANEL_DMA_IRQ_IDX, g_panel.dma_rx_channel, false); + dma_channel_abort(g_panel.dma_rx_channel); + dma_channel_unclaim(g_panel.dma_rx_channel); + g_panel.dma_rx_channel = -1; + } + + if (g_panel.dma_tx_channel >= 0) { + dma_channel_abort(g_panel.dma_tx_channel); + dma_channel_unclaim(g_panel.dma_tx_channel); + g_panel.dma_tx_channel = -1; + } + + if (g_panel.spi) { + spi_deinit(g_panel.spi); + g_panel.spi = NULL; + } + + g_panel.initialized = false; + logmsg("Panel SPI: Deinitialized"); +} + +// Helper to get command name for logging +static const char* panel_cmd_name(uint8_t cmd) { + switch (cmd) { + case PANEL_CMD_GET_DIR_ENTRY_COUNT: return "GET_DIR_ENTRY_COUNT"; + case PANEL_CMD_GET_ENTRY_INFO: return "GET_ENTRY_INFO"; + case PANEL_CMD_SELECT_ENTRY: return "SELECT_ENTRY"; + case PANEL_CMD_GET_CURRENT_PATH: return "GET_CURRENT_PATH"; + case PANEL_CMD_EJECT_IMAGE: return "EJECT_IMAGE"; + case PANEL_CMD_GET_LOADED_IMAGE_STATUS: return "GET_LOADED_IMAGE_STATUS"; + case PANEL_CMD_SELECT_PREV_IMAGE: return "SELECT_PREV_IMAGE"; + case PANEL_CMD_SELECT_NEXT_IMAGE: return "SELECT_NEXT_IMAGE"; + case PANEL_CMD_SELECT_IMAGE_BY_NAME: return "SELECT_IMAGE_BY_NAME"; + case PANEL_CMD_CHECK_FIRMWARE: return "CHECK_FIRMWARE"; + case PANEL_CMD_START_FIRMWARE_READ: return "START_FIRMWARE_READ"; + case PANEL_CMD_START_FILE_UPLOAD: return "START_FILE_UPLOAD"; + case PANEL_CMD_WRITE_FILE_CHUNK: return "WRITE_FILE_CHUNK"; + case PANEL_CMD_FINISH_FILE_UPLOAD: return "FINISH_FILE_UPLOAD"; + case PANEL_CMD_GET_RP2350_FW_STATUS: return "GET_RP2350_FW_STATUS"; + case PANEL_CMD_START_RP2350_UPDATE: return "START_RP2350_UPDATE"; + case PANEL_CMD_START_FILE_DOWNLOAD: return "START_FILE_DOWNLOAD"; + case PANEL_CMD_READ_FILE_CHUNK: return "READ_FILE_CHUNK"; + case PANEL_CMD_GET_INITIATOR_STATUS: return "GET_INITIATOR_STATUS"; + case PANEL_CMD_RESET: return "RESET"; + case PANEL_CMD_POLL_STATUS: return "POLL_STATUS"; + case PANEL_CMD_POLL_OP_READY: return "POLL_OP_READY"; + case PANEL_CMD_GET_DEVICE_STATUS: return "GET_DEVICE_STATUS"; + case PANEL_CMD_GET_FIRMWARE_INFO: return "GET_FIRMWARE_INFO"; + case PANEL_CMD_GET_PLAYBACK_STATUS: return "GET_PLAYBACK_STATUS"; + case PANEL_CMD_GET_COMMAND_STATUS: return "GET_COMMAND_STATUS"; + default: return "UNKNOWN"; + } +} + +// Receive buffer for the most recently received write payload, read by the +// main loop on both the immediate-dispatch and deferred (SCSI-busy) paths. +// The write-phase RX DMA targets .payload directly, so there is no memcpy; +// the IRQ only samples the sniffer CRC into .crc16. Being the live DMA +// destination this is not a snapshot: only the protocol's POLL_OP_READY rule +// keeps the next transaction from overwriting a queued or in-flight write. +// +// `pending` flips on when SCSI bus is busy and we have to wait to dispatch. +static struct { + bool pending; + volatile bool busy; // payload buffer owned until dispatch completes + uint8_t command; + uint16_t argument; + uint16_t payload_size; + uint16_t crc16; + uint8_t payload[PANEL_PROTOCOL_MAX_PAYLOAD] __attribute__((aligned(4))); +} g_deferred_write; + +static void panel_spi_dispatch_write(uint8_t cmd, uint16_t arg, + uint8_t *payload, uint16_t payload_size, + uint16_t crc16) { + if (payload_size > 0) { + if (PANEL_CMD_IS_ASYNC(cmd)) { + g_panel.pending_async_command = cmd; + g_panel.async_state = PANEL_ASYNC_PROCESSING; + } + panel_protocol_handle_write(cmd, arg, payload, payload_size, crc16); + } else { + // Note: async state already set in IRQ for no-payload commands + panel_protocol_handle_write(cmd, arg, NULL, 0, 0); + } +} + +void panel_spi_poll(void) { + if (!g_panel.initialized) { + return; + } + + // The panel ISR is deliberately left running while the initiator holds the + // bus. It used to be suspended here, out of caution rather than in response + // to a measured problem: on Ultra the panel owns DMA_IRQ_3 and its own + // channels, and scsi_accel_host.cpp uses no DMA and no interrupts at all, + // so the two never contend. Target mode has always coexisted with it. + // + // Suspending it made the panel and web UI dead for the whole of an imaging + // run - the ESP32 could not complete a single transaction between commands, + // so it could not even discover the board was imaging. Measured cost of + // leaving it enabled: about 1% of imaging throughput, no bus errors. + + // Refresh the device-status snapshot from the main loop so the IRQ-context + // read handlers never touch img->file (which switchNextImage reassigns). + // Only while the bus is idle: platform_poll() is called from inside the SCSI + // transfer loops, and the periodic name refresh calls getName(), which can + // miss the FAT cache and block on an SD read mid-transfer. + if (!panel_scsi_bus_busy()) { + panel_protocol_refresh_device_snapshot(); + } + + // Process deferred write only when the SCSI bus is genuinely idle + if (g_deferred_write.pending) { + if (panel_scsi_bus_busy()) { + return; + } + g_deferred_write.pending = false; + dbgmsg("Panel: processing deferred cmd=", g_deferred_write.command, + " (", panel_cmd_name(g_deferred_write.command), ")"); + panel_spi_dispatch_write(g_deferred_write.command, + g_deferred_write.argument, + g_deferred_write.payload, + g_deferred_write.payload_size, + g_deferred_write.crc16); + g_deferred_write.busy = false; + return; + } + + if (!g_panel.dma_complete) { + return; + } + + // Read from saved_header — rx_header is a live DMA target and may already + // be overwritten by POLL_OP_READY transactions from the ESP32. + uint8_t cmd = g_panel.saved_header.command; + uint16_t arg = g_panel.saved_header.argument; + uint16_t payload_size = g_panel.saved_header.payload_size; + + // Defer write commands whenever the SCSI bus is active (or a selection is + // pending), not just during DATA phases, to avoid blocking scsiPoll() and + // SD-card contention. ISR already set PROCESSING so ESP32 polls see the + // command was received. The ISR has already snapshotted the payload + CRC + // into g_deferred_write.payload / .crc16, so we only need to record the + // command metadata and flip the pending flag. + if (PANEL_CMD_IS_WRITE(cmd) && panel_scsi_bus_busy()) { + g_deferred_write.pending = true; + g_deferred_write.command = cmd; + g_deferred_write.argument = arg; + g_deferred_write.payload_size = payload_size; + g_panel.dma_complete = false; // Let ISR continue handling commands + return; + } + + g_panel.dma_complete = false; + + // Track transactions + g_panel.transaction_count++; + + // Log first transaction to confirm connection + if (!g_panel.first_transaction_logged) { + g_panel.first_transaction_logged = true; + logmsg("Panel SPI: First transaction received - front panel connected"); + dbgmsg("Panel SPI: cmd=", cmd, " (", panel_cmd_name(cmd), ") arg=", (int)arg, " payload=", (int)payload_size); + g_panel.last_log_time = platform_millis(); + } + + // Periodic transaction summary (every 10 seconds), and drain any + // events the IRQ recorded since logmsg is not safe in IRQ context. + uint32_t now = platform_millis(); + if (now - g_panel.last_log_time >= 10000) { + dbgmsg("Panel SPI: ", g_panel.transaction_count, " transactions processed"); + g_panel.last_log_time = now; + } + panel_protocol_drain_irq_log(); + + // Log the command (DMA setup already done in IRQ) + dbgmsg("Panel RX: cmd=", cmd, " (", panel_cmd_name(cmd), ") arg=", (int)arg, " payload=", (int)payload_size); + + // Handle write commands - call protocol handler + // Read commands are fully handled in IRQ (response prepared and DMA started) + // POLL_OP_READY is fully handled in IRQ + if (PANEL_CMD_IS_WRITE(cmd)) { + // Reads the live DMA buffer; safe only because the protocol forbids + // back-to-back async writes without an intervening poll. + panel_spi_dispatch_write(cmd, arg, + g_deferred_write.payload, + payload_size, + g_deferred_write.crc16); + g_deferred_write.busy = false; + } + // Read commands: response already prepared and sent in IRQ +} + +bool panel_spi_is_initialized(void) { + return g_panel.initialized; +} + +void panel_spi_set_async_result(const uint8_t* data, size_t size) { + // Never let a handler stage more than the TX buffer holds. + if (size > PANEL_PROTOCOL_MAX_PAYLOAD) { + size = PANEL_PROTOCOL_MAX_PAYLOAD; + } + if (size > 0 && data != NULL) { + // Copy to TX buffer if not already there + if (data != g_panel.tx_payload) { + memcpy(g_panel.tx_payload, data, size); + } + } + g_panel.async_response_size = size; + g_panel.async_state = PANEL_ASYNC_READY; + dbgmsg("Panel SPI: Async cmd ", g_panel.pending_async_command, + " (", panel_cmd_name(g_panel.pending_async_command), ") completed, ", (int)size, " bytes"); +} + +void panel_spi_set_async_error(void) { + g_panel.async_response_size = 0; + g_panel.async_state = PANEL_ASYNC_ERROR; + dbgmsg("Panel SPI: Async cmd ", g_panel.pending_async_command, + " (", panel_cmd_name(g_panel.pending_async_command), ") failed"); +} + +uint8_t* panel_spi_get_rx_buffer(void) { + return g_panel.rx_payload; +} + +uint8_t* panel_spi_get_tx_buffer(void) { + return g_panel.tx_payload; +} + +// ============================================================================ +// Internal functions +// ============================================================================ + +static void setup_header_dma(void) { + // Clear TX header (we send zeros during header phase) + memset(&g_panel.tx_header, 0, sizeof(g_panel.tx_header)); + + // Setup RX DMA for header + dma_channel_set_write_addr(g_panel.dma_rx_channel, &g_panel.rx_header, false); + dma_channel_set_trans_count(g_panel.dma_rx_channel, PANEL_PROTOCOL_HEADER_SIZE, false); + + // Setup TX DMA for header (send zeros) + dma_channel_set_read_addr(g_panel.dma_tx_channel, &g_panel.tx_header, false); + dma_channel_set_trans_count(g_panel.dma_tx_channel, PANEL_PROTOCOL_HEADER_SIZE, false); + + // Start both channels simultaneously + uint32_t channel_mask = (1u << g_panel.dma_rx_channel) | (1u << g_panel.dma_tx_channel); + dma_start_channel_mask(channel_mask); +} + +// Returns false (without arming any DMA) if size is out of range, so callers +// can recover to the header phase instead of wedging the state machine with no +// DMA armed (a malformed/oversized payload_size from the master must not be +// able to permanently stall the panel link). +static bool setup_payload_dma(size_t size, uint8_t *rx_buf, uint8_t *tx_buf) { + if (size == 0 || size > PANEL_PROTOCOL_MAX_PAYLOAD) { + return false; + } + + // Reset CRC sniffer for payload validation + dma_sniffer_set_data_accumulator(0xFFFF); + + // Setup RX DMA for payload. Caller picks the destination: + // - For read commands the master is reading from us; the bytes the + // master sends back are dummy and we land them in rx_payload. + // - For write commands, point straight at g_deferred_write.payload so + // the main loop reads from a stable buffer with zero IRQ memcpy. + dma_channel_set_write_addr(g_panel.dma_rx_channel, rx_buf, false); + dma_channel_set_trans_count(g_panel.dma_rx_channel, size, false); + + // Setup TX DMA for payload. Caller picks the source: sync read responses + // come from g_panel.sync_response, async results from g_panel.tx_payload. + dma_channel_set_read_addr(g_panel.dma_tx_channel, tx_buf, false); + dma_channel_set_trans_count(g_panel.dma_tx_channel, size, false); + + // Start both channels + uint32_t channel_mask = (1u << g_panel.dma_rx_channel) | (1u << g_panel.dma_tx_channel); + dma_start_channel_mask(channel_mask); + return true; +} + +// Separate dummy buffer for status DMA receive — must not alias rx_payload, +// which may still hold write payload data being processed by the main loop. +static uint8_t status_rx_dummy[sizeof(panel_status_response_t)] __attribute__((aligned(4))); + +static void setup_status_dma(void) { + // Setup RX DMA for dummy bytes (into dedicated buffer, NOT rx_payload) + dma_channel_set_write_addr(g_panel.dma_rx_channel, status_rx_dummy, false); + dma_channel_set_trans_count(g_panel.dma_rx_channel, sizeof(panel_status_response_t), false); + + // Setup TX DMA for status response + dma_channel_set_read_addr(g_panel.dma_tx_channel, &g_panel.status_response, false); + dma_channel_set_trans_count(g_panel.dma_tx_channel, sizeof(panel_status_response_t), false); + + // Start both channels + uint32_t channel_mask = (1u << g_panel.dma_rx_channel) | (1u << g_panel.dma_tx_channel); + dma_start_channel_mask(channel_mask); +} + +// DMA IRQ handler - handles ALL phase transitions and DMA setup in IRQ context +// This is critical for timing: ESP32 starts clocking immediately after sending header +static void dma_irq_handler(void) { + if (!dma_irqn_get_channel_status(PANEL_DMA_IRQ_IDX, g_panel.dma_rx_channel)) return; + dma_irqn_acknowledge_channel(PANEL_DMA_IRQ_IDX, g_panel.dma_rx_channel); + + switch (g_panel.phase) { + case PHASE_HEADER: { + // Header received - process IN IRQ and set up next DMA immediately + uint8_t cmd = g_panel.rx_header.command; + uint16_t payload_size = g_panel.rx_header.payload_size; + + if (payload_size > 0) { + if (PANEL_CMD_IS_READ(cmd)) { + if (cmd == PANEL_CMD_POLL_OP_READY) { + // POLL_OP_READY: prepare status and send immediately + g_panel.status_response.ready_flag = g_panel.async_state; + g_panel.status_response.response_size = + (g_panel.async_state == PANEL_ASYNC_READY) ? g_panel.async_response_size : 0; + g_panel.phase = PHASE_PENDING; + setup_status_dma(); + // Don't set dma_complete - status handled entirely in IRQ + } else { + // Other read command - prepare response and send it. + // Must call handler HERE (in IRQ) because DMA starts + // immediately. Write the response into the dedicated + // sync_response buffer (NOT tx_payload) so it cannot + // clobber an async result staged in tx_payload that is + // still awaiting POLL_OP_READY. Any legitimate sync read + // fits in sync_response; an oversized request (none are + // legitimate) falls back to tx_payload so the DMA source + // is always at least payload_size bytes. + uint8_t *resp_buf = (payload_size <= PANEL_SYNC_RESPONSE_SIZE) + ? g_panel.sync_response : g_panel.tx_payload; + size_t resp_max = (payload_size <= PANEL_SYNC_RESPONSE_SIZE) + ? PANEL_SYNC_RESPONSE_SIZE : PANEL_PROTOCOL_MAX_PAYLOAD; + panel_protocol_handle_read(cmd, g_panel.rx_header.argument, + resp_buf, resp_max); + g_panel.phase = PHASE_PAYLOAD; + // Master is reading from us; the bytes coming back from + // the master are dummy. Land them in rx_payload (we'll + // ignore them). On a bad payload_size, recover to header + // rather than wedging with no DMA armed. + if (!setup_payload_dma(payload_size, g_panel.rx_payload, resp_buf)) { + g_panel.phase = PHASE_HEADER; + setup_header_dma(); + } + // No dma_complete - response already prepared and sending + } + } else { + // Write command with payload - receive it directly into the + // buffer the main loop reads from, so no memcpy is needed + // when the payload completes. If that buffer still holds a + // write the main loop has not finished with, land this one + // in the rx_payload sink and drop it at completion; the + // panel retries. + g_panel.phase = PHASE_PAYLOAD; + g_panel.drop_payload = g_deferred_write.busy; + uint8_t* dest = g_panel.drop_payload ? g_panel.rx_payload + : g_deferred_write.payload; + // Write phase: master sends data (RX -> dest); our TX bytes + // are ignored by the master, so tx_payload is fine as the + // (unused) TX source. On a bad payload_size, recover to + // header rather than wedging. + if (!setup_payload_dma(payload_size, dest, g_panel.tx_payload)) { + g_panel.drop_payload = false; + g_panel.phase = PHASE_HEADER; + setup_header_dma(); + } + // Don't set dma_complete yet - wait for payload + } + } else { + // No payload command + if (PANEL_CMD_IS_ASYNC(cmd)) { + g_panel.pending_async_command = cmd; + g_panel.async_state = PANEL_ASYNC_PROCESSING; + } + g_panel.saved_header = g_panel.rx_header; // Save before DMA overwrites + g_panel.phase = PHASE_HEADER; + setup_header_dma(); + g_panel.dma_complete = true; // Main loop handles command + } + break; + } + + case PHASE_PENDING: + // Status response just sent - check if payload follows + if (g_panel.rx_header.command == PANEL_CMD_POLL_OP_READY && + g_panel.status_response.ready_flag == PANEL_ASYNC_READY && + g_panel.status_response.response_size > 0) { + // Ready with data - send payload immediately. We're transmitting + // (master reading from us); RX bytes are dummy, land in rx_payload. + // The async result lives in tx_payload (staged by the handler). + g_panel.phase = PHASE_PAYLOAD; + if (!setup_payload_dma(g_panel.status_response.response_size, + g_panel.rx_payload, g_panel.tx_payload)) { + g_panel.phase = PHASE_HEADER; + setup_header_dma(); + } + // Don't set dma_complete - wait for payload transfer + } else { + // Not ready or no data - back to header + g_panel.phase = PHASE_HEADER; + setup_header_dma(); + // Don't set dma_complete - nothing for main loop to do + } + break; + + case PHASE_PAYLOAD: { + // Payload transfer complete + uint8_t cmd = g_panel.rx_header.command; + + // Clean up async state if we just sent async result + if (g_panel.async_state == PANEL_ASYNC_READY) { + g_panel.async_state = PANEL_ASYNC_IDLE; + g_panel.async_response_size = 0; + } + + // Signal main loop to process the transaction + // (for write commands with payload, or read commands that need handling) + if (PANEL_CMD_IS_WRITE(cmd) && g_panel.drop_payload) { + // Sunk into rx_payload because a write was still in flight. + // Drop it rather than signalling the main loop. + g_panel.drop_payload = false; + } else if (PANEL_CMD_IS_WRITE(cmd)) { + // Set PROCESSING immediately so ESP32 polls see command was received + // (matches PicoIDE pattern: ISR acknowledges, main loop executes) + if (PANEL_CMD_IS_ASYNC(cmd)) { + g_panel.pending_async_command = cmd; + g_panel.async_state = PANEL_ASYNC_PROCESSING; + } + g_panel.saved_header = g_panel.rx_header; // Save before DMA overwrites + + // The RX DMA wrote the payload directly into g_deferred_write. + // payload, so no memcpy is needed here - we only sample the + // sniffer accumulator before any subsequent transaction can + // reset it. Keeping IRQ work minimal protects the SCSI hot + // path: this handler shares the default Cortex-M priority + // with the SCSI buffer-swap IRQ, and any time spent here + // delays SCSI buffer swaps. + if (g_panel.rx_header.payload_size > 0) { + g_deferred_write.crc16 = + (uint16_t)dma_sniffer_get_data_accumulator(); + } else { + g_deferred_write.crc16 = 0; + } + + // Owns the payload buffer until the main loop finishes dispatch. + if (g_panel.rx_header.payload_size > 0) { + g_deferred_write.busy = true; + } + g_panel.dma_complete = true; // Main loop processes write payload + } + + // Set up for next header (after saving, so DMA doesn't overwrite saved data) + g_panel.phase = PHASE_HEADER; + setup_header_dma(); + break; + } + + default: + // Shouldn't happen - reset to header + g_panel.phase = PHASE_HEADER; + setup_header_dma(); + break; + } +} + +#endif // ENABLE_PANEL_SPI diff --git a/lib/BlueSCSI_platform_RP2MCU/panel_spi.h b/lib/BlueSCSI_platform_RP2MCU/panel_spi.h new file mode 100644 index 00000000..9511af34 --- /dev/null +++ b/lib/BlueSCSI_platform_RP2MCU/panel_spi.h @@ -0,0 +1,97 @@ +/** + * BlueSCSI - Copyright (c) 2026 Eric Helgeson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * ---- + * + * Panel SPI Slave Driver + * + * Handles SPI communication with ESP32-C3 front panel. + * BlueSCSI acts as SPI slave, ESP32 is master. + * + * Protocol: + * Phase 1: 5-byte header exchange + * Phase 2: Variable payload exchange (0-4096 bytes) + */ + +#pragma once + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Initialize the panel SPI slave interface. + * Sets up GPIO, SPI peripheral in slave mode, and DMA channels. + * + * @return true on success, false on failure + */ +bool panel_spi_init(void); + +/** + * Deinitialize the panel SPI interface. + * Releases DMA channels and resets GPIO. + */ +void panel_spi_deinit(void); + +/** + * Poll the panel SPI interface. + * Should be called from platform_poll() regularly. + * Handles completed DMA transfers and dispatches commands. + */ +void panel_spi_poll(void); + +/** + * Check if panel SPI is initialized and operational. + * + * @return true if initialized + */ +bool panel_spi_is_initialized(void); + +/** + * Set the async operation result. + * Called by protocol handlers when async operation completes. + * + * @param data Response data buffer + * @param size Size of response data + */ +void panel_spi_set_async_result(const uint8_t* data, size_t size); + +/** + * Signal that an async operation has completed with error. + */ +void panel_spi_set_async_error(void); + +/** + * Get the RX payload buffer for reading incoming data. + * + * @return Pointer to RX payload buffer + */ +uint8_t* panel_spi_get_rx_buffer(void); + +/** + * Get the TX payload buffer for writing outgoing data. + * + * @return Pointer to TX payload buffer + */ +uint8_t* panel_spi_get_tx_buffer(void); + +#ifdef __cplusplus +} +#endif diff --git a/lib/BlueSCSI_platform_RP2MCU/panel_transport.h b/lib/BlueSCSI_platform_RP2MCU/panel_transport.h new file mode 100644 index 00000000..457d4504 --- /dev/null +++ b/lib/BlueSCSI_platform_RP2MCU/panel_transport.h @@ -0,0 +1,41 @@ +/** + * BlueSCSI - Copyright (c) 2026 Eric Helgeson + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * ---- + * + * Panel transport abstraction. + * + * The protocol handler (panel_protocol.cpp) is transport-agnostic: it stages + * async results and reads/writes payload buffers through the panel_transport_* + * names below. Exactly one physical transport is compiled in per build and + * provides the implementation: + * ENABLE_PANEL_SPI -> panel_spi.cpp (BlueSCSI Ultra / Ultra Wide, SPI slave) + * ENABLE_PANEL_I2C -> panel_i2c.cpp (BlueSCSI v2, I2C slave) + */ + +#pragma once + +#if defined(ENABLE_PANEL_I2C) +#include "panel_i2c.h" +#define panel_transport_get_tx_buffer panel_i2c_get_tx_buffer +#define panel_transport_set_async_result panel_i2c_set_async_result +#define panel_transport_set_async_error panel_i2c_set_async_error +#elif defined(ENABLE_PANEL_SPI) +#include "panel_spi.h" +#define panel_transport_get_tx_buffer panel_spi_get_tx_buffer +#define panel_transport_set_async_result panel_spi_set_async_result +#define panel_transport_set_async_error panel_spi_set_async_error +#endif diff --git a/lib/BlueSCSI_platform_RP2MCU/rp2040-template.ld b/lib/BlueSCSI_platform_RP2MCU/rp2040-template.ld index 2039b271..8f26af17 100644 --- a/lib/BlueSCSI_platform_RP2MCU/rp2040-template.ld +++ b/lib/BlueSCSI_platform_RP2MCU/rp2040-template.ld @@ -185,6 +185,13 @@ SECTIONS *(.text*platform_network_wifi_join*) *(.text*wifi_security_name*) *(.text*wifi_security_auth*) + /* Cold diagnostics-only dump of the accelerator state (timeout and + * crash paths, which already run flash-resident logmsg) */ + *(.text*scsi_accel_log_state*) + /* VERIFY is rare and never back to back with a transfer. */ + *(.text*scsiDiskHandleVerify*) + /* Wi-Fi bring-up, once at boot. The packet path stays in RAM. */ + *(.text*platform_network_init*) /* SCSI commands that don't require high performance */ *(.text*scsiToolboxCommand*) @@ -231,6 +238,14 @@ SECTIONS *scsi_accel_host*(.text .text*) *scsiHostPhy*(.text .text*) + /* Front panel (not performance-critical). panel_i2c is the transport + * that actually builds on RP2040 (ENABLE_PANEL_I2C), so leaving it out + * of this list put the only live panel transport in RAM. */ + *panel_spi.cpp.o(.text .text*) + *panel_i2c.cpp.o(.text .text*) + *panel_protocol.cpp.o(.text .text*) + *panel_sha256_sw.cpp.o(.text .text*) + /* SdFat filesystem functions that are not performance-critical. * Only init/management functions go to flash. The hot-path * (read/write/seekSet/readSectorsDirect/sectorMapLookup/buildSectorMap diff --git a/lib/BlueSCSI_platform_RP2MCU/rp2040_btldr.ld b/lib/BlueSCSI_platform_RP2MCU/rp2040_btldr.ld index f88f6cfc..21cffe49 100644 --- a/lib/BlueSCSI_platform_RP2MCU/rp2040_btldr.ld +++ b/lib/BlueSCSI_platform_RP2MCU/rp2040_btldr.ld @@ -58,6 +58,12 @@ SECTIONS *audio_spdif*(.text .text* .rodata .rodata*) *BlueI2S*(.text .text* .rodata .rodata*) + /* Front panel code not needed in bootloader */ + *panel_spi*(.text .text* .rodata .rodata*) + *panel_i2c*(.text .text* .rodata .rodata*) + *panel_protocol*(.text .text* .rodata .rodata*) + *panel_sha256*(.text .text* .rodata .rodata*) + /* Network code not needed in bootloader */ *BlueSCSI_platform_network*(.text .text* .rodata .rodata*) *cyw43*(.text .text* .rodata .rodata*) diff --git a/lib/BlueSCSI_platform_RP2MCU/rp23xx-template.ld b/lib/BlueSCSI_platform_RP2MCU/rp23xx-template.ld index 1e05566f..f7573b14 100644 --- a/lib/BlueSCSI_platform_RP2MCU/rp23xx-template.ld +++ b/lib/BlueSCSI_platform_RP2MCU/rp23xx-template.ld @@ -77,6 +77,22 @@ SECTIONS /* =============================================================== */ /* Exclude as much code from flash as possible as the RP2350 series has twice as much SRAM */ + /* Front panel code goes in flash (not hot-path) */ + *panel_spi.cpp.o(.text .text*) + *panel_i2c.cpp.o(.text .text*) + *panel_protocol.cpp.o(.text .text*) + *panel_sha256_sw.cpp.o(.text .text*) + /* Startup and SD-hotplug paths. None run per-command, and each one + * already waits on the SD card, so flash latency is lost in the I/O. + * Kept in flash to leave the heap for lwip on network builds. */ + *(.text*logNonDefaultDeviceSettings*) + *(.text*initSystem*) + *(.text*findHDDImages*) + *(.text*bluescsi_setup_sd_card*) + *(.text*mountSDCard*) + *(.text*print_sd_info*) + *(.text*s2s_configInit*) + *(.text*kiosk_restore_images*) *(EXCLUDE_FILE(*libgcc.a: *libc.a:*lib_a-mem*.o *libm.a: *SCSI2SD* *sdfat* *minIni* *CUEParser* *.cpp.o) .text*) /* --------------------------------------------------------- @@ -153,6 +169,11 @@ SECTIONS .rodata : { /* Exclude as many constants as possible from flash as corresponding to the code that has been removed from flash to keep the MCU from having to hit flash while it is executing in SRAM */ + /* Front panel constants in flash */ + *panel_spi.cpp.o(.rodata .rodata*) + *panel_i2c.cpp.o(.rodata .rodata*) + *panel_protocol.cpp.o(.rodata .rodata*) + *panel_sha256_sw.cpp.o(.rodata .rodata*) *(EXCLUDE_FILE(*libgcc.a: *libc.a:*lib_a-mem*.o *libm.a: *SCSI2SD* *sdfat* *minIni* *CUEParser* *.cpp.o) .rodata*) /* Uncomment below and comment above lines for debugging */ /* *(EXCLUDE_FILE(*libgcc.a: *libc.a:*lib_a-mem*.o *libm.a:) .rodata*) */ diff --git a/lib/BlueSCSI_platform_RP2MCU/rp23xx_btldr.ld b/lib/BlueSCSI_platform_RP2MCU/rp23xx_btldr.ld index 47e4bfe0..4c0cd171 100644 --- a/lib/BlueSCSI_platform_RP2MCU/rp23xx_btldr.ld +++ b/lib/BlueSCSI_platform_RP2MCU/rp23xx_btldr.ld @@ -59,6 +59,12 @@ SECTIONS *audio_spdif*(.text .text* .rodata .rodata*) *BlueI2S*(.text .text* .rodata .rodata*) + /* Front panel code not needed in bootloader */ + *panel_spi*(.text .text* .rodata .rodata*) + *panel_i2c*(.text .text* .rodata .rodata*) + *panel_protocol*(.text .text* .rodata .rodata*) + *panel_sha256*(.text .text* .rodata .rodata*) + /* Network code not needed in bootloader */ *BlueSCSI_platform_network*(.text .text* .rodata .rodata*) *cyw43*(.text .text* .rodata .rodata*) diff --git a/lib/ZipParser/zip_parser.cpp b/lib/ZipParser/zip_parser.cpp index a1c8c96d..98391b00 100644 --- a/lib/ZipParser/zip_parser.cpp +++ b/lib/ZipParser/zip_parser.cpp @@ -1,5 +1,6 @@ /** * ZuluSCSI™ - Copyright (c) 2024-2025 Rabbit Hole Computing™ + * Copyright (c) 2026 Eric Helgeson * * ZuluSCSI™ firmware is licensed under the GPL version 3 or any later version.  * @@ -45,6 +46,9 @@ namespace zipparser position = 0; filename_match = false; crc = 0; + matching = true; + central_dir = false; + local_file_header = false; } void Parser::SetMatchingFilename(char const *filename, const size_t length, const size_t target_total_length) @@ -65,9 +69,6 @@ namespace zipparser if (filename_len == 0) return PARSE_ERROR; - static bool matching = true; - static bool central_dir = false; - static bool local_file_header = false; for (size_t idx = 0; idx < size; idx++) { switch (target) @@ -121,7 +122,7 @@ namespace zipparser if (++position == 1) { // Currently only uncompresseed files in the zip package are supported - if (!buf[idx] == ZIP_PARSER_METHOD_UNCOMPRESSED_BYTE) + if (buf[idx] != ZIP_PARSER_METHOD_UNCOMPRESSED_BYTE) { return PARSE_UNSUPPORTED_COMPRESSION; } diff --git a/lib/ZipParser/zip_parser.h b/lib/ZipParser/zip_parser.h index 5b6415d2..09f73f12 100644 --- a/lib/ZipParser/zip_parser.h +++ b/lib/ZipParser/zip_parser.h @@ -62,6 +62,11 @@ namespace zipparser parsing_target target; size_t position; uint32_t crc; + // Per-instance parse state (was function-local static in Parse(), + // which leaked state across Parser instances) + bool matching; + bool central_dir; + bool local_file_header; }; } \ No newline at end of file diff --git a/src/BlueSCSI.cpp b/src/BlueSCSI.cpp index 8a5ca4ad..db900012 100644 --- a/src/BlueSCSI.cpp +++ b/src/BlueSCSI.cpp @@ -135,8 +135,8 @@ void init_logfile() bool truncate = first_open_after_boot; if (truncate) { - SD.remove("lastlog.txt"); - SD.rename(LOGFILE, "lastlog.txt"); + SD.remove(LASTLOGFILE); + SD.rename(LOGFILE, LASTLOGFILE); } int flags = O_WRONLY | O_CREAT | (truncate ? O_TRUNC : O_APPEND); g_logfile = SD.open(LOGFILE, flags); @@ -1142,15 +1142,102 @@ static bool verify_extracted_firmware(FsFile &check, uint32_t expected_size, return true; } +// Scan the zip from the start for a stored entry whose filename begins with +// name_prefix and is exactly total_name_len characters, then stream its bytes +// into dest. Returns false when no entry matched or the copy came up short. +__attribute__((optimize("Os"))) +STATIC_TESTABLE bool firmware_update_extract(FsFile &zip, const char *name_prefix, + size_t prefix_len, size_t total_name_len, + FsFile &dest) +{ + zipparser::Parser parser = zipparser::Parser(name_prefix, prefix_len, total_name_len); + uint8_t buf[512]; + int32_t parsed_length; + int bytes_read = 0; + if (!zip.seekSet(0)) + return false; + while ((bytes_read = zip.read(buf, sizeof(buf))) > 0) + { + parsed_length = parser.Parse(buf, bytes_read); + if (parsed_length == bytes_read) + continue; + if (parsed_length < 0) + return false; // central directory reached or parse error: no match + if (parser.FoundMatch()) + { + // seek to start of data in matching file + zip.seekSet(zip.position() - (bytes_read - parsed_length)); + break; + } + parser.Reset(); + zip.seekSet(zip.position() - (bytes_read - parsed_length) + parser.GetCompressedSize()); + } + + if (!parser.FoundMatch()) + return false; + + uint32_t remaining = parser.GetCompressedSize(); + while (remaining > 0 && (bytes_read = zip.read(buf, sizeof(buf))) > 0) + { + uint32_t chunk = bytes_read; + if (chunk > remaining) + chunk = remaining; + if (dest.write(buf, chunk) != chunk) + return false; + remaining -= chunk; + } + // Stored data is always followed by the next local header or the central + // directory, so a valid zip can never end exactly at the entry payload. + return remaining == 0; +} + +#if defined(ENABLE_PANEL_I2C) || defined(ENABLE_PANEL_SPI) +// Extract the front panel firmware from the update package to the +// SD card. The panel protocol serves it to the panel, which compares +// SHA-256 and decides for itself whether to reflash. +__attribute__((optimize("Os"))) +STATIC_TESTABLE void firmware_update_panel(FsFile &zip) +{ + const char *panel_name = PANEL_FIRMWARE_ZIP_NAME; + size_t name_len = strlen(panel_name); + + SD.mkdir(PANEL_FIRMWARE_DIR); // returns false when it already exists + + FsFile dest; + if (!dest.open(PANEL_FIRMWARE_TMP_PATH, O_BINARY | O_WRONLY | O_CREAT | O_TRUNC)) + { + logmsg("Failed to open ", PANEL_FIRMWARE_TMP_PATH, " for front panel firmware"); + return; + } + // Write to a temp file and rename so a failed extraction can never leave + // a torn frontpanel.bin for the panel to hash. + bool extracted = firmware_update_extract(zip, panel_name, name_len, name_len, dest); + dest.close(); + if (!extracted) + { + SD.remove(PANEL_FIRMWARE_TMP_PATH); + logmsg("No front panel firmware (", panel_name, ") in package"); + return; + } + SD.remove(PANEL_FIRMWARE_PATH); // rename fails if the destination exists + if (SD.rename(PANEL_FIRMWARE_TMP_PATH, PANEL_FIRMWARE_PATH)) + logmsg("Extracted front panel firmware to ", PANEL_FIRMWARE_PATH); + else + logmsg("Failed to move front panel firmware to ", PANEL_FIRMWARE_PATH); +} +#endif + // Update firmware by unzipping the firmware package __attribute__((optimize("Os"))) -static void firmware_update() +STATIC_TESTABLE void firmware_update() { const char package_prefix[] = FIRMWARE_PACKAGE_PREFIX; const char zip_ext[] = ".zip"; FsFile root = SD.open("/"); FsFile file; - char name[MAX_FILE_PATH + 1]; + // Sized for firmware package names rather than MAX_FILE_PATH: a root entry + // that doesn't fit can't be a firmware zip, and getName() skips it + char name[72]; while (1) { if (!file.openNext(&root, O_RDONLY)) @@ -1178,6 +1265,18 @@ static void firmware_update() } logmsg("Found firmware package ", name); + +#if defined(ENABLE_PANEL_I2C) || defined(ENABLE_PANEL_SPI) + // Panel firmware first: the MCU update below ends in a reboot. If the + // package has no MCU image for this board the zip stays on the card and + // this rewrites the panel file each boot; the panel's SHA cache makes + // that a no-op beyond one hash. + firmware_update_panel(file); + // firmware_update_extract() leaves the position where it stopped; the scan + // below reads from the current offset. + file.seekSet(0); +#endif + // example fixed length at the end of the filename const uint32_t postfix_filename_length = sizeof("_2025-02-21_e4be9ed.bin") - 1; const uint32_t target_filename_length = sizeof(FIRMWARE_NAME_PREFIX) - 1 + postfix_filename_length; diff --git a/src/BlueSCSI_cdrom.cpp b/src/BlueSCSI_cdrom.cpp index 2f69f25c..d2d715b1 100644 --- a/src/BlueSCSI_cdrom.cpp +++ b/src/BlueSCSI_cdrom.cpp @@ -1390,7 +1390,7 @@ void cdromCloseTray(image_config_t &img) // Eject CDROM tray if closed, close if open // Switch image on ejection. -void cdromPerformEject(image_config_t &img) +void cdromPerformEject(image_config_t &img, bool prefer_cue) { uint8_t target = img.getTargetId(); #if ENABLE_AUDIO_OUTPUT @@ -1404,7 +1404,7 @@ void cdromPerformEject(image_config_t &img) dbgmsg("------ CDROM open tray on ID ", (int)target); img.ejected = true; img.cdrom_events = 3; // Media removal - switchNextImage(img); // Switch media for next time + switchNextImage(img, nullptr, prefer_cue); // Switch media for next time } else { diff --git a/src/BlueSCSI_cdrom.h b/src/BlueSCSI_cdrom.h index 2b21d3d1..bbe736b2 100644 --- a/src/BlueSCSI_cdrom.h +++ b/src/BlueSCSI_cdrom.h @@ -19,8 +19,9 @@ extern "C" int scsiCDRomCommand(void); void cdromCloseTray(image_config_t &img); // Eject CDROM tray if closed, close if open -// Switch image on ejection. -void cdromPerformEject(image_config_t &img); +// Switch image on ejection. prefer_cue is forwarded to switchNextImage() +// (see findNextImageAfter()); the front panel passes false to cycle by .bin. +void cdromPerformEject(image_config_t &img, bool prefer_cue = true); // Reinsert ejected CD-ROM and restart from first image void cdromReinsertFirstImage(image_config_t &img); diff --git a/src/BlueSCSI_config.h b/src/BlueSCSI_config.h index 02cdce9e..7a739b08 100644 --- a/src/BlueSCSI_config.h +++ b/src/BlueSCSI_config.h @@ -3,7 +3,7 @@ * * ZuluSCSI™ - Copyright (c) 2022-2025 Rabbit Hole Computing™ * Portions copyright (c) 2023 joshua stein - * Copyright (c) 2026 Eric Helgeson + * Copyright (c) 2026 Eric Helgeson * * ZuluSCSI™ firmware is licensed under the GPL version 3 or any later version. * @@ -44,9 +44,20 @@ #define INQUIRY_NAME PLATFORM_NAME "v" FW_VER_NUM #define TOOLBOX_API 0 +// Front panel firmware: extracted from the update package to the SD card, +// where the panel protocol serves it to the panel for self-update. +#define PANEL_FIRMWARE_DIR "/firmware" +#define PANEL_FIRMWARE_PATH PANEL_FIRMWARE_DIR "/frontpanel.bin" +#define PANEL_FIRMWARE_TMP_PATH PANEL_FIRMWARE_DIR "/frontpanel.tmp" +// In-zip name matches the open-retro-storage-frontpanel release asset name. +// One image covers v2 (I2C) and Ultra/Ultra Wide (SPI); the panel detects the +// transport at runtime. +#define PANEL_FIRMWARE_ZIP_NAME "bluescsi-frontpanel.bin" + // Configuration and log file paths #define CONFIGFILE "bluescsi.ini" #define LOGFILE "log.txt" +#define LASTLOGFILE "lastlog.txt" #define CRASHFILE "err.txt" // Prefix for command file to create new image (case-insensitive) @@ -79,7 +90,7 @@ #define HDIMG_ID_POS 2 // Position to embed ID number #define HDIMG_LUN_POS 3 // Position to embed LUN numbers #define HDIMG_BLK_POS 5 // Position to embed block size numbers -#define MAX_FILE_PATH 64 // Maximum file name length +#define MAX_FILE_PATH 128 // Maximum file name length // Image definition options #define IMAGE_INDEX_MAX 9 // Maximum number of 'IMG0' style statements parsed diff --git a/src/BlueSCSI_disk.cpp b/src/BlueSCSI_disk.cpp index 98d11a3f..694c0daf 100644 --- a/src/BlueSCSI_disk.cpp +++ b/src/BlueSCSI_disk.cpp @@ -44,6 +44,7 @@ #include #include #include +#include #include extern "C" { @@ -231,8 +232,10 @@ void scsiDiskResetImages() void image_config_t::clear() { - static const image_config_t empty; // Statically zero-initialized - *this = empty; + // Re-run the default constructor in place; a static blank instance for + // "*this = empty" would keep a whole image_config_t of RAM just for this + this->~image_config_t(); + new (this) image_config_t(); } uint32_t image_config_t::get_capacity_lba() @@ -507,6 +510,7 @@ bool scsiDiskOpenHDDImage(int target_idx, const char *filename, int scsi_lun, in image_config_t &img = g_DiskImages[target_idx]; img.cuesheetfile.close(); img.bin_container.close(); + img.cue_loaded_directly = false; img.cdrom_binfile_index = -1; img.cdrom_track_end_lba = 0; scsiDiskSetImageConfig(target_idx); @@ -531,18 +535,23 @@ bool scsiDiskOpenHDDImage(int target_idx, const char *filename, int scsi_lun, in char parentdir[MAX_FILE_PATH + 1] = {0}; strncpy(parentdir, filename, sizeof(parentdir) - 1); char *lastslash = strrchr(parentdir, '/'); - if (lastslash) + if (lastslash && lastslash != parentdir) { *lastslash = '\0'; // Truncate to parent directory } else { - strcpy(parentdir, "/"); // Root directory + // No slash, or a leading slash only ("/disc.cue"): parent is root + strcpy(parentdir, "/"); } - // Open parent directory as folder for multi-bin file selection + // Open parent directory as folder for multi-bin file selection. + // This is only for resolving the cue's .bin tracks - the image still + // cycles by its own .cue filename, not by the directory (see + // cue_loaded_directly in BlueSCSI_disk.h). img.file = ImageBackingStore(parentdir, blocksize); img.bin_container.open(parentdir); + img.cue_loaded_directly = true; // Validate the cue sheet now (before device type setup) // We need to set deviceType temporarily for validation @@ -553,6 +562,7 @@ bool scsiDiskOpenHDDImage(int target_idx, const char *filename, int scsi_lun, in img.cuesheetfile.close(); img.bin_container.close(); img.file.close(); + img.cue_loaded_directly = false; return false; } } @@ -868,6 +878,18 @@ bool scsiDiskOpenHDDImage(int target_idx, const char *filename, int scsi_lun, in img.use_prefix = use_prefix; img.file.getFilename(img.current_image, sizeof(img.current_image)); + if (img.cue_loaded_directly) + { + // img.file points at the cue's parent directory, so getFilename() + // stored the directory name. The cycling identity of this image is + // the .cue file itself: keep its basename as the iteration cursor + // so scsiDiskGetNextImageName() advances among the sibling .cue + // files instead of restarting from the directory name every time. + const char *cue_basename = strrchr(filename, '/'); + cue_basename = cue_basename ? cue_basename + 1 : filename; + strncpy(img.current_image, cue_basename, sizeof(img.current_image) - 1); + img.current_image[sizeof(img.current_image) - 1] = '\0'; + } return true; } else @@ -1099,7 +1121,7 @@ static void doCloseTray(image_config_t &img) // Eject and switch image // This is really press eject button for close and open. -static void doPerformEject(image_config_t &img) +void diskPerformEject(image_config_t &img) { const uint8_t target = img.getTargetId(); // Now that we have a request from a button or an explicit start SCSI command to close the drive, @@ -1121,7 +1143,7 @@ static void doPerformEject(image_config_t &img) int findNextImageAfter(image_config_t &img, const char* dirname, const char* filename, - char* nextname, size_t nextname_len, bool ignore_prefix) + char* nextname, size_t nextname_len, bool ignore_prefix, bool prefer_cue) { FsFile dir; if (dirname[0] == '\0') @@ -1165,8 +1187,9 @@ int findNextImageAfter(image_config_t &img, } // For optical devices, check if directory contains .cue files - // If so, skip .bin files (they're referenced by the .cue files) - bool dir_has_cue = (img.deviceType == S2S_CFG_OPTICAL) && scsiDiskFolderContainsCueSheet(&dir); + // If so, skip .bin files (they're referenced by the .cue files). + // prefer_cue=false cycles by the underlying .bin files instead - see header. + bool dir_has_cue = prefer_cue && (img.deviceType == S2S_CFG_OPTICAL) && scsiDiskFolderContainsCueSheet(&dir); if (dir_has_cue) { dbgmsg("-- Directory '", dirname, "' contains .cue file(s), will select .cue instead of .bin"); @@ -1250,7 +1273,7 @@ int findNextImageAfter(image_config_t &img, } } -int scsiDiskGetNextImageName(image_config_t &img, char *buf, size_t buflen) +int scsiDiskGetNextImageName(image_config_t &img, char *buf, size_t buflen, bool prefer_cue) { int target_idx = img.getTargetId(); @@ -1260,13 +1283,19 @@ int scsiDiskGetNextImageName(image_config_t &img, char *buf, size_t buflen) // sanity check: is provided buffer is long enough to store a filename? assert(buflen >= MAX_FILE_PATH); + // callers check buf on a zero return; never hand back stale/uninitialized data + buf[0] = '\0'; + // find the next filename char nextname[MAX_FILE_PATH]; int nextlen; char currentname[MAX_FILE_PATH]; - // Test to see if we have a multi bin/cue file in a directory. Use the directory name instead - if (img.is_multi_bin_cue()) + // A folder-image (folder holding a cue + bins) cycles at the parent level + // by its directory name. A directly-loaded loose .cue also has a directory + // as bin_container (the cue's parent, for track resolution) but cycles by + // its own filename, which scsiDiskOpenHDDImage() kept in current_image. + if (img.is_multi_bin_cue() && !img.cue_loaded_directly) { img.bin_container.getName(currentname, sizeof(currentname)); } @@ -1317,32 +1346,50 @@ int scsiDiskGetNextImageName(image_config_t &img, char *buf, size_t buflen) return 0; } } + // ini_gets() returned 0 when the directory was derived from the + // device type above; the concatenation below needs the real length + dirlen = strlen(dirname); - // find the next filename - nextlen = findNextImageAfter(img, dirname, currentname, nextname, sizeof(nextname)); - - if (nextlen == 0) - { - logmsg("Image directory was empty for ID", target_idx); - return 0; - } - else if (buflen < nextlen + dirlen + 2) + // find the next filename that fits the caller's buffer; entries too + // long are skipped so they can't wedge the cycle + char first_skipped[MAX_FILE_PATH] = {'\0'}; + while (true) { - logmsg("Directory '", dirname, "' and file '", nextname, "' exceed allowed length"); - return 0; - } - else - { - // construct a return value - strncpy(buf, dirname, buflen); - if (buf[strlen(buf) - 1] != '/') strcat(buf, "/"); - strcat(buf, nextname); - return dirlen + nextlen; + nextlen = findNextImageAfter(img, dirname, currentname, nextname, sizeof(nextname), false, prefer_cue); + + if (nextlen == 0) + { + logmsg("Image directory was empty for ID", target_idx); + return 0; + } + if (buflen >= (size_t)(nextlen + dirlen + 2)) + { + break; + } + + logmsg("Image name '", dirname, "/", nextname, "' exceeds ", (int)(buflen - dirlen - 2), " characters, skipping"); + if (first_skipped[0] == '\0') + { + strncpy(first_skipped, nextname, sizeof(first_skipped) - 1); + } + else if (strcasecmp(nextname, first_skipped) == 0) + { + // wrapped all the way around without finding a usable name + return 0; + } + strncpy(currentname, nextname, sizeof(currentname) - 1); + currentname[sizeof(currentname) - 1] = '\0'; } + + // construct a return value + strncpy(buf, dirname, buflen); + if (buf[strlen(buf) - 1] != '/') strcat(buf, "/"); + strcat(buf, nextname); + return dirlen + nextlen; } else if (img.use_prefix) { - nextlen = findNextImageAfter(img, "/", currentname, nextname, sizeof(nextname)); + nextlen = findNextImageAfter(img, "/", currentname, nextname, sizeof(nextname), false, prefer_cue); if (nextlen == 0) { logmsg("Next file with the same prefix as ", currentname," not found for ID", target_idx); @@ -1446,18 +1493,19 @@ void setEjectButton(uint8_t idx, int8_t eject_button) } // Check if we have multiple drive images to cycle when drive is ejected. -bool switchNextImage(image_config_t &img, const char* next_filename) +bool switchNextImage(image_config_t &img, const char* next_filename, bool prefer_cue) { // Check if we have a next image to load, so that drive is closed next time the host asks. int target_idx = img.getTargetId(); char filename[MAX_FILE_PATH]; if (next_filename == nullptr) { - scsiDiskGetNextImageName(img, filename, sizeof(filename)); + scsiDiskGetNextImageName(img, filename, sizeof(filename), prefer_cue); } else { - strncpy(filename, next_filename, MAX_FILE_PATH); + strncpy(filename, next_filename, sizeof(filename) - 1); + filename[sizeof(filename) - 1] = '\0'; } #ifdef ENABLE_AUDIO_OUTPUT @@ -1541,7 +1589,7 @@ static void diskEjectAction(uint8_t buttonId) { found = true; logmsg("Eject button ", (int)buttonId, " pressed, passing to SCSI ID: ", (int)i); - doPerformEject(img); + diskPerformEject(img); } } } @@ -3061,7 +3109,7 @@ int scsiDiskCommand() else { // Eject and switch image - doPerformEject(img); + diskPerformEject(img); } } else if (start) diff --git a/src/BlueSCSI_disk.h b/src/BlueSCSI_disk.h index 3e5b4ce8..5f731f69 100644 --- a/src/BlueSCSI_disk.h +++ b/src/BlueSCSI_disk.h @@ -63,6 +63,14 @@ struct image_config_t: public S2S_TargetCfg // default option of '0' disables this functionality uint8_t ejectButton; + // True when a loose .cue file was loaded directly (not a folder-image). + // bin_container is then the cue's parent directory so its .bin tracks can + // be resolved, but the image's cycling identity is the .cue file itself + // (kept in current_image), not the directory. Lives in this byte-sized + // field cluster so it fits existing struct padding (the RP2040 SPDIF + // target is at its RAM limit). + bool cue_loaded_directly; + // For tape drive emulation uint32_t tape_pos; // current position in blocks uint32_t tape_mark_index; // a direct relationship to the file in a multi image file tape @@ -127,6 +135,10 @@ struct image_config_t: public S2S_TargetCfg // Returns a mask of the buttons that registered an 'eject' action. uint8_t diskEjectButtonUpdate(bool immediate); +// Toggle a non-optical removable device between ejected and loaded, the same +// way the physical eject button does. Optical drives use cdromPerformEject(). +void diskPerformEject(image_config_t &img); + // Reset all image configuration to empty reset state, close all images. void scsiDiskResetImages(); @@ -174,13 +186,18 @@ bool scsiDiskCheckAnyImagesConfigured(); // Finds filename with the lowest lexical order _after_ the given filename in // the given folder. If there is no file after the given one, or if there is // no current file, this will return the lowest filename encountered. -int findNextImageAfter(image_config_t &img, const char* dirname, const char* filename, char* nextname, size_t nextname_len, bool ignore_prefix = false); +// prefer_cue (optical only): when true (the default, used by all cycling +// callers), a folder containing a .cue lists the .cue and hides the .bin +// files it references. When false, cycle by the underlying image files +// (.bin) instead. A directory with no cue sheet always cycles by the image +// files, so plain data-.bin discs keep working either way. +int findNextImageAfter(image_config_t &img, const char* dirname, const char* filename, char* nextname, size_t nextname_len, bool ignore_prefix = false, bool prefer_cue = true); // Gets the next image filename for the target, if configured for multiple // images. As a side effect this advances image tracking to the next image. // Returns the length of the new image filename, or 0 if the target is not -// configured for multiple images. -int scsiDiskGetNextImageName(image_config_t &img, char *buf, size_t buflen); +// configured for multiple images. See findNextImageAfter() for prefer_cue. +int scsiDiskGetNextImageName(image_config_t &img, char *buf, size_t buflen, bool prefer_cue = true); // Get pointer to extended image configuration based on target idx image_config_t &scsiDiskGetImageConfig(int target_idx); @@ -196,8 +213,10 @@ void scsiDiskStartWrite(uint32_t lba, uint32_t blocks); bool scsiDiskCheckAnyNetworkDevicesConfigured(); -// Switch to next Drive image if multiple have been configured -bool switchNextImage(image_config_t &img, const char* next_filename = nullptr); +// Switch to next Drive image if multiple have been configured. +// See findNextImageAfter() for prefer_cue (only consulted when next_filename +// is null, i.e. the next image is chosen by the cyclic iterator). +bool switchNextImage(image_config_t &img, const char* next_filename = nullptr, bool prefer_cue = true); // Encode a SCSI ID (0..15) as a single filename character: '0'..'9' or 'A'..'F'. // Returns '\0' for out-of-range inputs. diff --git a/src/BlueSCSI_initiator.cpp b/src/BlueSCSI_initiator.cpp index ce196b1f..9fb68fd5 100644 --- a/src/BlueSCSI_initiator.cpp +++ b/src/BlueSCSI_initiator.cpp @@ -38,6 +38,10 @@ #include #include "SdFat.h" #include "BlueSCSI_disk.h" +#include "BlueSCSI_blink.h" +// Only defines and POD structs; the PANEL_INITIATOR_SKIP_* codes are named at +// every skip site whether or not a panel is built in. +#include "panel_protocol_defs_initiator.h" #include extern "C" { @@ -66,6 +70,38 @@ bool scsiInitiatorReadCapacity(int target_id, uint32_t *sectorcount, uint32_t *s return false; } +bool scsiInitiatorIsActive() +{ + return false; +} + +bool scsiInitiatorBusBusy() +{ + return false; +} + +void scsiInitiatorGetStatus(uint8_t *phase, uint8_t *current_target, uint8_t *initiator_id, + uint8_t *drives_mask, uint16_t *speed_kbps, char *filename, + size_t filename_size) +{ + if (phase) *phase = 0; + if (current_target) *current_target = 0xFF; + if (initiator_id) *initiator_id = 7; + if (drives_mask) *drives_mask = 0; + if (speed_kbps) *speed_kbps = 0; + if (filename && filename_size > 0) filename[0] = '\0'; +} + +bool scsiInitiatorGetTargetInfo(int scsi_id, uint8_t *status, uint8_t *device_type, + uint8_t *ansi_version, uint32_t *sectorcount, + uint32_t *sectorsize, uint32_t *sectors_done, + uint32_t *bad_sector_count, char *vendor, + char *product, uint8_t *sense_key, + uint8_t *asc, uint8_t *ascq, uint8_t *skip_reason) +{ + return false; +} + #else // From BlueSCSI.cpp @@ -78,6 +114,26 @@ extern bool g_sdcard_present; // Not in the SCSI_MESSAGE enum in scsi.h #define MSG_NO_OPERATION 0x08 +// Per-target summary persisted across scans (for panel reporting). +// Only compiled in when a panel interface exists to consume it. +#if defined(ENABLE_PANEL_I2C) || defined(ENABLE_PANEL_SPI) +struct initiator_target_summary_t { + uint8_t status; // 0=not found, 1=found, 2=imaging, 3=done, 4=error + uint8_t device_type; // SCSI device type (0=HD, 5=CD, 7=MO) + uint8_t ansi_version; + uint8_t sense_key; + uint8_t asc; + uint8_t ascq; + uint8_t skip_reason; // PANEL_INITIATOR_SKIP_*, fits the existing pad + uint32_t sectorcount; + uint32_t sectorsize; + uint32_t sectors_done; + uint32_t bad_sector_count; + char vendor[9]; // null-terminated + char product[17]; // null-terminated +}; +#endif + static struct { // Bitmap of all drives that have been imaged uint32_t drives_imaged; @@ -92,6 +148,13 @@ static struct { // Is imaging a drive in progress, or are we scanning? bool imaging; + // True when SCSI bus is actively in use (selection through bus free). + // Panel SPI defers async commands while this is set. + volatile bool scsi_bus_active; + + // Overall phase for panel reporting + bool all_done; // true when all IDs have been scanned/imaged + // Information about currently selected drive int target_id; uint32_t sectorsize; @@ -119,11 +182,107 @@ static struct { int targetBusWidth[NUM_SCSIID]; uint32_t start_sector[NUM_SCSIID]; +#if defined(ENABLE_PANEL_I2C) || defined(ENABLE_PANEL_SPI) + // Per-target summary for panel status reporting + initiator_target_summary_t target_summary[NUM_SCSIID]; + + // The image being written now, and the last measured read speed. Only one + // target images at a time, so these are not per-target. + char current_filename[32]; + uint16_t speed_kbps; +#endif + FsFile target_file; } g_initiator_state; extern SdFs SD; +// Mirror initiator progress into the per-target summary for panel status +// reporting. Compiled to no-ops when no panel interface is enabled, which +// also lets the linker drop the target_summary storage on non-panel builds. +#if defined(ENABLE_PANEL_I2C) || defined(ENABLE_PANEL_SPI) +static void initiatorSummaryClear() +{ + memset(g_initiator_state.target_summary, 0, sizeof(g_initiator_state.target_summary)); +} + +static void initiatorSummaryTargetFound(int target_id, const char *vendor, const char *product) +{ + initiator_target_summary_t &ts = g_initiator_state.target_summary[target_id]; + ts.status = 1; // found + ts.device_type = g_initiator_state.device_type; + ts.ansi_version = g_initiator_state.ansi_version; + ts.sectorcount = g_initiator_state.sectorcount; + ts.sectorsize = g_initiator_state.sectorsize; + ts.sectors_done = 0; + ts.bad_sector_count = 0; + // Clear any sense codes from a previous scan of this ID so a + // freshly-found (e.g. removable) target doesn't report stale + // error codes to the panel. + ts.sense_key = 0; + ts.asc = 0; + ts.ascq = 0; + ts.skip_reason = 0; + memcpy(ts.vendor, vendor, 9); + memcpy(ts.product, product, 17); +} + +static void initiatorSummarySetStatus(int target_id, uint8_t status) +{ + g_initiator_state.target_summary[target_id].status = status; +} + +static void initiatorSummaryUpdateProgress(int target_id) +{ + initiator_target_summary_t &ts = g_initiator_state.target_summary[target_id]; + ts.sectors_done = g_initiator_state.sectors_done; + ts.bad_sector_count = g_initiator_state.bad_sector_count; +} + +static void initiatorSummarySetSense(int target_id, uint8_t sense_key, uint8_t asc, uint8_t ascq) +{ + if (target_id < 0 || target_id >= NUM_SCSIID) return; + initiator_target_summary_t &ts = g_initiator_state.target_summary[target_id]; + ts.sense_key = sense_key; + ts.asc = asc; + ts.ascq = ascq; +} + +static void initiatorSummarySetSkipped(int target_id, uint8_t reason) +{ + if (target_id < 0 || target_id >= NUM_SCSIID) return; + initiator_target_summary_t &ts = g_initiator_state.target_summary[target_id]; + ts.status = 4; // error + ts.skip_reason = reason; +} + +static void initiatorSummarySetFilename(const char *filename) +{ + if (!filename) + { + g_initiator_state.current_filename[0] = '\0'; + return; + } + strncpy(g_initiator_state.current_filename, filename, + sizeof(g_initiator_state.current_filename) - 1); + g_initiator_state.current_filename[sizeof(g_initiator_state.current_filename) - 1] = '\0'; +} + +static void initiatorSummarySetSpeed(uint16_t speed_kbps) +{ + g_initiator_state.speed_kbps = speed_kbps; +} +#else +static inline void initiatorSummaryClear() {} +static inline void initiatorSummaryTargetFound(int, const char *, const char *) {} +static inline void initiatorSummarySetStatus(int, uint8_t) {} +static inline void initiatorSummaryUpdateProgress(int) {} +static inline void initiatorSummarySetSense(int, uint8_t, uint8_t, uint8_t) {} +static inline void initiatorSummarySetSkipped(int, uint8_t) {} +static inline void initiatorSummarySetFilename(const char *) {} +static inline void initiatorSummarySetSpeed(uint16_t) {} +#endif + // Initialization of initiator mode void scsiInitiatorInit() { @@ -143,10 +302,13 @@ void scsiInitiatorInit() g_initiator_state.use_read10 = ini_getbool("SCSI", "InitiatorUseRead10", false, CONFIGFILE); g_initiator_state.use_identify = ini_getbool("SCSI", "InitiatorIdentify", true, CONFIGFILE); g_initiator_state.use_vhd_format = ini_getbool("SCSI", "InitiatorVHD", false, CONFIGFILE); + g_initiator_state.all_done = false; + initiatorSummaryClear(); // treat initiator id as already imaged drive so it gets skipped g_initiator_state.drives_imaged = 1 << g_initiator_state.initiator_id; + g_initiator_state.scsi_bus_active = false; g_initiator_state.imaging = false; g_initiator_state.target_id = -1; g_initiator_state.sectorsize = 0; @@ -228,6 +390,10 @@ void delay_with_poll(uint32_t ms) while ((uint32_t)(platform_millis() - start) < ms) { platform_poll(); + // platform_write_led() drops every write while a blink is running, and + // only blink_poll() ends one. Without this an initiator step that + // outlasts a blink latches the LED until the main loop comes back. + blink_poll(); platform_delay_ms(1); } } @@ -313,6 +479,22 @@ void scsiInitiatorMainLoop() if (!g_initiator_state.imaging) { + // Check if all drives have been scanned/imaged. This only latches when + // every non-initiator ID has been imaged (an empty ID or an + // eject_when_done drive never sets its bit, so the scan keeps running); + // when it does latch, imaging is genuinely complete and there is no + // point re-probing imaged IDs forever. Log the transition once so the + // terminal state isn't silent. + if ((g_initiator_state.drives_imaged & 0xFF) == 0xFF) + { + if (!g_initiator_state.all_done) + { + logmsg("Initiator: all SCSI IDs imaged - imaging complete"); + } + g_initiator_state.all_done = true; + return; + } + // Scan for SCSI drives one at a time g_initiator_state.target_id = (g_initiator_state.target_id + 1) % S2S_MAX_TARGETS; g_initiator_state.sectorsize = 0; @@ -388,6 +570,12 @@ void scsiInitiatorMainLoop() logmsg("Target SCSI ID ", g_initiator_state.target_id, " image size is equal or larger than 4 GiB."); logmsg("This is larger than the max filesize supported by SD card's filesystem"); logmsg("Please reformat the SD card with exFAT format to image this target"); + // Reached before INQUIRY is parsed, so the panel would not + // otherwise know this ID exists. Record the capacity we do + // have, with an empty vendor/product. + initiatorSummaryTargetFound(g_initiator_state.target_id, "\0\0\0\0\0\0\0\0\0", + "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"); + initiatorSummarySetSkipped(g_initiator_state.target_id, PANEL_INITIATOR_SKIP_TOO_LARGE_FAT32); g_initiator_state.drives_imaged |= 1 << g_initiator_state.target_id; return; } @@ -425,6 +613,9 @@ void scsiInitiatorMainLoop() memcpy(revision, &inquiry_data[32], 4); revision[4]=0; + // Save to target summary for panel reporting + initiatorSummaryTargetFound(g_initiator_state.target_id, vendor, product); + g_initiator_state.use_read10 = scsiInitiatorTestSupportsRead10(g_initiator_state.target_id, g_initiator_state.sectorsize); if(!g_initiator_state.use_read10) { @@ -450,6 +641,7 @@ void scsiInitiatorMainLoop() if (typeName == nullptr) { logmsg(" SCSI Peripheral device type id ", g_initiator_state.device_type, " unsupported. Skipping this device"); + initiatorSummarySetSkipped(g_initiator_state.target_id, PANEL_INITIATOR_SKIP_UNSUPPORTED); g_initiator_state.drives_imaged |= 1 << g_initiator_state.target_id; return; } @@ -524,6 +716,7 @@ void scsiInitiatorMainLoop() if (SD.exists(filename)) { logmsg("File, ", filename, ", already exists, InitiatorImageHandling set to stop if file exists."); + initiatorSummarySetSkipped(g_initiator_state.target_id, PANEL_INITIATOR_SKIP_FILE_EXISTS); g_initiator_state.drives_imaged |= (1 << g_initiator_state.target_id); return; } @@ -542,6 +735,7 @@ void scsiInitiatorMainLoop() else if(i >= 1000) { logmsg("Max images created from SCSI ID ", g_initiator_state.target_id, ", skipping image creation"); + initiatorSummarySetSkipped(g_initiator_state.target_id, PANEL_INITIATOR_SKIP_TOO_MANY); g_initiator_state.drives_imaged |= (1 << g_initiator_state.target_id); return; } @@ -589,6 +783,7 @@ void scsiInitiatorMainLoop() { logmsg("SD Card only has ", (int)(sd_card_free_bytes / (1024 * 1024)), " MiB - not enough free space to image SCSI ID ", g_initiator_state.target_id); + initiatorSummarySetSkipped(g_initiator_state.target_id, PANEL_INITIATOR_SKIP_NO_SPACE); g_initiator_state.drives_imaged |= 1 << g_initiator_state.target_id; return; } @@ -611,6 +806,9 @@ void scsiInitiatorMainLoop() logmsg("Starting to copy drive data to ", filename); g_initiator_state.imaging = true; + initiatorSummarySetStatus(g_initiator_state.target_id, 2); // imaging + initiatorSummarySetFilename(filename); + initiatorSummarySetSpeed(0); // Initiator start sector override if (g_initiator_state.start_sector[g_initiator_state.target_id] != 0) { @@ -629,6 +827,8 @@ void scsiInitiatorMainLoop() scsiStartStopUnit(g_initiator_state.target_id, false); logmsg("Finished imaging drive with id ", g_initiator_state.target_id); LED_OFF(); + initiatorSummarySetFilename(nullptr); + initiatorSummarySetSpeed(0); if (g_initiator_state.sectorcount != g_initiator_state.sectorcount_all) { @@ -668,6 +868,10 @@ void scsiInitiatorMainLoop() g_initiator_state.imaging = false; g_initiator_state.target_file.close(); + + // Update target summary on completion + initiatorSummarySetStatus(g_initiator_state.target_id, 3); // done + initiatorSummaryUpdateProgress(g_initiator_state.target_id); return; } @@ -714,6 +918,7 @@ void scsiInitiatorMainLoop() g_initiator_state.retrycount = 0; g_initiator_state.sectors_done++; g_initiator_state.bad_sector_count++; + initiatorSummaryUpdateProgress(g_initiator_state.target_id); g_initiator_state.target_file.seek((uint64_t)g_initiator_state.sectors_done * g_initiator_state.sectorsize); } } @@ -723,7 +928,13 @@ void scsiInitiatorMainLoop() g_initiator_state.sectors_done += numtoread; g_initiator_state.target_file.flush(); - int speed_kbps = numtoread * g_initiator_state.sectorsize / (platform_millis() - time_start); + // Update target summary progress + initiatorSummaryUpdateProgress(g_initiator_state.target_id); + + // A one-sector retry batch can finish inside a single millisecond tick + uint32_t elapsed = platform_millis() - time_start; + int speed_kbps = elapsed ? (int)(numtoread * g_initiator_state.sectorsize / elapsed) : 0; + if (speed_kbps > 0) initiatorSummarySetSpeed((speed_kbps > 0xFFFF) ? 0xFFFF : (uint16_t)speed_kbps); logmsg("SCSI read succeeded, sectors done: ", (int)g_initiator_state.sectors_done, " / ", (int)g_initiator_state.sectorcount, " speed ", speed_kbps, " kB/s - ", @@ -732,6 +943,84 @@ void scsiInitiatorMainLoop() } } +/************************************* + * Panel status accessor functions * + *************************************/ + +#if defined(ENABLE_PANEL_I2C) || defined(ENABLE_PANEL_SPI) + +bool scsiInitiatorIsActive() +{ + // PLATFORM_HAS_INITIATOR_MODE is compile-time; actual mode is runtime-gated. + return platform_is_initiator_mode_enabled(); +} + +bool scsiInitiatorBusBusy() +{ + return g_initiator_state.scsi_bus_active; +} + +void scsiInitiatorGetStatus(uint8_t *phase, uint8_t *current_target, uint8_t *initiator_id, + uint8_t *drives_mask, uint16_t *speed_kbps, char *filename, + size_t filename_size) +{ + if (initiator_id) *initiator_id = g_initiator_state.initiator_id; + if (drives_mask) *drives_mask = (uint8_t)(g_initiator_state.drives_imaged & 0xFF); + if (speed_kbps) *speed_kbps = g_initiator_state.speed_kbps; + if (filename && filename_size > 0) + { + strncpy(filename, g_initiator_state.current_filename, filename_size - 1); + filename[filename_size - 1] = '\0'; + } + + if (g_initiator_state.all_done) + { + if (phase) *phase = 3; // PANEL_INITIATOR_PHASE_COMPLETE + if (current_target) *current_target = 0xFF; + } + else if (g_initiator_state.imaging) + { + if (phase) *phase = 2; // PANEL_INITIATOR_PHASE_IMAGING + if (current_target) *current_target = (uint8_t)g_initiator_state.target_id; + } + else + { + if (phase) *phase = 1; // PANEL_INITIATOR_PHASE_SCANNING + if (current_target) *current_target = (uint8_t)g_initiator_state.target_id; + } +} + +bool scsiInitiatorGetTargetInfo(int scsi_id, uint8_t *status, uint8_t *device_type, + uint8_t *ansi_version, uint32_t *sectorcount, + uint32_t *sectorsize, uint32_t *sectors_done, + uint32_t *bad_sector_count, char *vendor, + char *product, uint8_t *sense_key, + uint8_t *asc, uint8_t *ascq, uint8_t *skip_reason) +{ + if (scsi_id < 0 || scsi_id >= NUM_SCSIID) return false; + + const initiator_target_summary_t &ts = g_initiator_state.target_summary[scsi_id]; + if (ts.status == 0) return false; // not found + + if (status) *status = ts.status; + if (device_type) *device_type = ts.device_type; + if (ansi_version) *ansi_version = ts.ansi_version; + if (sectorcount) *sectorcount = ts.sectorcount; + if (sectorsize) *sectorsize = ts.sectorsize; + if (sectors_done) *sectors_done = ts.sectors_done; + if (bad_sector_count) *bad_sector_count = ts.bad_sector_count; + if (vendor) memcpy(vendor, ts.vendor, 9); + if (product) memcpy(product, ts.product, 17); + if (sense_key) *sense_key = ts.sense_key; + if (asc) *asc = ts.asc; + if (ascq) *ascq = ts.ascq; + if (skip_reason) *skip_reason = ts.skip_reason; + + return true; +} + +#endif // ENABLE_PANEL_I2C || ENABLE_PANEL_SPI + /************************************* * Low level command implementations * *************************************/ @@ -753,11 +1042,15 @@ int scsiInitiatorRunCommand(int target_id, scsiHostPhySetATN(true); } + g_initiator_state.scsi_bus_active = true; + platform_poll(); // Suspend panel SPI IRQ before SCSI bus operations + if (!scsiHostPhySelect(target_id, g_initiator_state.initiator_id)) { scsiHostPhySetATN(false); dbgmsg("------ Target ", target_id, " did not respond"); scsiHostPhyRelease(); + g_initiator_state.scsi_bus_active = false; return -1; } @@ -856,6 +1149,8 @@ int scsiInitiatorRunCommand(int target_id, scsiHostPhySetATN(false); scsiHostWaitBusFree(); + g_initiator_state.scsi_bus_active = false; + platform_poll(); // Process any deferred panel commands while bus is idle return status; } @@ -1487,6 +1782,8 @@ bool scsiInitiatorReadDataToFile(int target_id, uint32_t start_sector, uint32_t } scsiHostWaitBusFree(); + g_initiator_state.scsi_bus_active = false; + platform_poll(); // Process any deferred panel commands while bus is idle if (!g_initiator_transfer.all_ok) { @@ -1495,8 +1792,11 @@ bool scsiInitiatorReadDataToFile(int target_id, uint32_t start_sector, uint32_t } else if (status == 2) { - uint8_t sense_key; - scsiRequestSense(target_id, &sense_key); + uint8_t sense_key, sense_asc = 0, sense_ascq = 0; + scsiRequestSense(target_id, &sense_key, &sense_asc, &sense_ascq); + + // Save sense codes to target summary for panel reporting + initiatorSummarySetSense(target_id, sense_key, sense_asc, sense_ascq); if (sense_key == RECOVERED_ERROR) { diff --git a/src/BlueSCSI_initiator.h b/src/BlueSCSI_initiator.h index fdf36e5c..1b4e0bdd 100644 --- a/src/BlueSCSI_initiator.h +++ b/src/BlueSCSI_initiator.h @@ -96,3 +96,19 @@ bool scsiInitiatorResetBusConfig(int target_id); // Negotiate bus width with target bool scsiInitiatorSetBusWidth(int target_id, int busWidth); + +// Returns true when the initiator is actively using the SCSI bus. +// Panel SPI should defer async command processing while this is true. +bool scsiInitiatorBusBusy(); + +// Panel status reporting functions +bool scsiInitiatorIsActive(); +void scsiInitiatorGetStatus(uint8_t *phase, uint8_t *current_target, uint8_t *initiator_id, + uint8_t *drives_mask, uint16_t *speed_kbps, char *filename, + size_t filename_size); +bool scsiInitiatorGetTargetInfo(int scsi_id, uint8_t *status, uint8_t *device_type, + uint8_t *ansi_version, uint32_t *sectorcount, + uint32_t *sectorsize, uint32_t *sectors_done, + uint32_t *bad_sector_count, char *vendor, + char *product, uint8_t *sense_key, + uint8_t *asc, uint8_t *ascq, uint8_t *skip_reason); diff --git a/src/BlueSCSI_settings.cpp b/src/BlueSCSI_settings.cpp index 77c10429..04c0d637 100644 --- a/src/BlueSCSI_settings.cpp +++ b/src/BlueSCSI_settings.cpp @@ -359,6 +359,8 @@ static void logNonDefaultSystemSettings(const scsi_system_settings_t &defaults, logmsg("-- USBMassStoragePresentImages = ", current.usbMassStoragePresentImages ? "Yes" : "No"); if (current.invertStatusLed != defaults.invertStatusLed) logmsg("-- InvertStatusLED = ", current.invertStatusLed ? "Yes" : "No"); + if (current.enableFrontPanel != defaults.enableFrontPanel) + logmsg("-- EnableFrontPanel = ", current.enableFrontPanel ? "Yes" : "No"); if (current.speedGrade != defaults.speedGrade) logmsg("-- SpeedGrade = ", speed_grade_strings[current.speedGrade]); if (current.maxBusWidth != defaults.maxBusWidth) @@ -460,6 +462,7 @@ scsi_system_settings_t *BlueSCSISettings::initSystem(const char *presetName) cfgSys.usbMassStorageWaitPeriod = 1000; cfgSys.usbMassStoragePresentImages = false; cfgSys.invertStatusLed = false; + cfgSys.enableFrontPanel = false; cfgSys.speedGrade = bluescsi_speed_grade_t::SPEED_GRADE_DEFAULT; @@ -621,6 +624,8 @@ scsi_system_settings_t *BlueSCSISettings::initSystem(const char *presetName) cfgSys.invertStatusLed = ini_getbool("SCSI", "InvertStatusLED", cfgSys.invertStatusLed, CONFIGFILE); + cfgSys.enableFrontPanel = ini_getbool("SCSI", "EnableFrontPanel", cfgSys.enableFrontPanel, CONFIGFILE); + cfgSys.phaseChangeDelayUs = ini_getl("SCSI", "PhaseChangeDelay", cfgSys.phaseChangeDelayUs, CONFIGFILE); cfgSys.dataPhaseDelayUs = ini_getl("SCSI", "DataPhaseDelay", cfgSys.dataPhaseDelayUs, CONFIGFILE); cfgSys.busFreeDelayUs = ini_getl("SCSI", "BusFreeDelay", cfgSys.busFreeDelayUs, CONFIGFILE); diff --git a/src/BlueSCSI_settings.h b/src/BlueSCSI_settings.h index 2a64ffec..703e65cf 100644 --- a/src/BlueSCSI_settings.h +++ b/src/BlueSCSI_settings.h @@ -120,6 +120,10 @@ typedef struct __attribute__((__packed__)) scsi_system_settings_t uint16_t phaseChangeDelayUs; // Phase change delay (EMU EMAX needs 100) uint16_t dataPhaseDelayUs; // Data phase entry delay (Akai S1000/S3000 needs 400) uint8_t busFreeDelayUs; // Bus free delay + + // v2: run the I2C front-panel slave on GPIO16/17 (mutually exclusive with + // DisableI2C simple-buttons / SPDIF on those pins). + bool enableFrontPanel; } scsi_system_settings_t; // This struct should only have new setting added to the end diff --git a/utils/create_firmware_zip.sh b/utils/create_firmware_zip.sh index c1fe8b15..3c8518b5 100755 --- a/utils/create_firmware_zip.sh +++ b/utils/create_firmware_zip.sh @@ -20,10 +20,17 @@ # The zip contains one .bin per target, named: # BlueSCSI___.bin # +# If the front panel firmware binary is present in PANEL_BIN_DIR (default: +# /panel-fw, populated by CI from the open-retro-storage-frontpanel +# releases), it is included under its original name so the on-device updater +# can extract it to the SD card: +# bluescsi-frontpanel.bin (one image for V2 and Ultra / Ultra Wide; +# the panel auto-detects I2C vs SPI) +# # The zip itself is named: # BlueSCSI_v_.zip # -# Usage: utils/create_firmware_zip.sh +# Usage: [PANEL_BIN_DIR=] utils/create_firmware_zip.sh set -euo pipefail @@ -35,7 +42,10 @@ OUTPUT_DIR="${2:?Usage: $0 }" # Extract version from BlueSCSI_config.h FW_VER=$(grep 'FW_VER_NUM' "${PROJECT_DIR}/src/BlueSCSI_config.h" | head -1 | sed 's/.*"\(.*\)".*/\1/') -SHORT_HASH=$(git -C "${PROJECT_DIR}" rev-parse --short=7 HEAD 2>/dev/null || echo "unknown") +# DIST_SHA overrides the built sha on CI pull request builds - see make_dist.sh. +SHORT_HASH="${DIST_SHA:-}" +SHORT_HASH="${SHORT_HASH:0:7}" +SHORT_HASH="${SHORT_HASH:-$(git -C "${PROJECT_DIR}" rev-parse --short=7 HEAD 2>/dev/null || echo "unknown")}" DATE=$(TZ=America/Chicago date +%Y-%m-%d) ZIP_NAME="BlueSCSI_v${FW_VER}_${SHORT_HASH}.zip" @@ -67,6 +77,28 @@ if [ "${BIN_COUNT}" -eq 0 ]; then exit 1 fi +# Include front panel firmware binaries if present (see header comment). +PANEL_BIN_DIR="${PANEL_BIN_DIR:-${PROJECT_DIR}/panel-fw}" +PANEL_BINS=(bluescsi-frontpanel.bin) +PANEL_COUNT=0 +for panel_bin in "${PANEL_BINS[@]}"; do + panel_path="${PANEL_BIN_DIR}/${panel_bin}" + [ -f "${panel_path}" ] || continue + # ESP32 app images start with magic byte 0xE9; anything else is a + # truncated or bogus download and must not ship. + magic=$(head -c1 "${panel_path}" | od -An -tx1 | tr -d ' ') + if [ "${magic}" != "e9" ]; then + echo "ERROR: ${panel_path} has bad magic 0x${magic} (expected 0xe9)" >&2 + exit 1 + fi + cp "${panel_path}" "${TMPDIR}/${panel_bin}" + echo " Added: ${panel_bin} (front panel firmware)" + PANEL_COUNT=$((PANEL_COUNT + 1)) +done +if [ "${PANEL_COUNT}" -eq 0 ]; then + echo "Note: no front panel firmware in ${PANEL_BIN_DIR}; zip will not carry a panel update" +fi + # Included in the zip so users who accidentally extract it understand why the # raw .bin files inside are not meant to be flashed by hand. README_NAME="DONT EXTRACT - PLACE ZIP ON SD.txt" @@ -92,6 +124,10 @@ The .bin files inside this archive are raw firmware images intended only for the on-device updater. They cannot be flashed with drag-and-drop or UF2 tools. +If a front panel is connected, the updater also extracts the matching +front panel firmware (bluescsi-*-frontpanel.bin) to /firmware/ on the SD +card; the panel then updates itself automatically. + For the full update guide, including USB/UF2 flashing and troubleshooting, see: diff --git a/utils/frontpanel_version.txt b/utils/frontpanel_version.txt new file mode 100644 index 00000000..b043aa64 --- /dev/null +++ b/utils/frontpanel_version.txt @@ -0,0 +1 @@ +v0.5.0 diff --git a/utils/make_dist.sh b/utils/make_dist.sh index 140a8930..e2eafa35 100755 --- a/utils/make_dist.sh +++ b/utils/make_dist.sh @@ -37,7 +37,12 @@ OUT_DIR=./dist mkdir -p "$OUT_DIR" DATE=$(TZ=America/Chicago date +%Y-%m-%d) -VERSION=$(git rev-parse --short=7 HEAD) +# On a pull request CI builds the merge commit, whose sha exists on no branch. +# DIST_SHA lets the workflow stamp the PR head instead, so a filename a user +# reports can be looked up. Unset locally, where HEAD is what was built. +VERSION="${DIST_SHA:-}" +VERSION="${VERSION:0:7}" +VERSION="${VERSION:-$(git rev-parse --short=7 HEAD)}" # --- Copy firmware zip (for SD card update) --- # build.sh already creates this via utils/create_firmware_zip.sh