esp-hub75 0.17.0

A Rust driver for HUB75 LED matrix displays on ESP32 microcontrollers
Documentation
//! Per-backend DMA transfer plumbing, shared by both refresh modes.
//!
//! Owns the per-backend driver/transfer types ([`TxDriver`], [`TxXfer`]) and
//! [`Transfer`], which holds the driver handle, its BCM buffer, and the
//! transfer lifecycle ([`TransferPhase`]). This is the single place that knows
//! how to (re)start a transfer, consume a completed one, and clear the active
//! backend's frame-boundary flag, so the mode- and backend-specific parameters
//! live here rather than at their call sites in [`super`].

use esp_hal::Blocking;
#[cfg(feature = "iram")]
use esp_hal::ram;

use super::BcmBuf;
use crate::Hub75Error;
#[cfg(hub75_use_lcd_cam)]
use crate::framebuffer::WordSize;

// ---------------------------------------------------------------------------
// Per-backend driver and transfer types
// ---------------------------------------------------------------------------

// Generic over the DMA buffer type: only the transfer types are
// buffer-parameterised; the driver handles are not. [`TxTransfer`] fixes `B`
// to the active-mode [`BcmBuf`].
cfg_select! {
    hub75_use_i2s_parallel => {
        use esp_hal::i2s::parallel::{I2sParallel, I2sParallelTransfer};
        pub(crate) type TxDriver = I2sParallel<'static, Blocking>;
        pub(crate) type TxXfer<B> = I2sParallelTransfer<'static, B, Blocking>;
    }
    hub75_use_parl_io => {
        use esp_hal::parl_io::{ParlIoTx, ParlIoTxTransfer};
        pub(crate) type TxDriver = ParlIoTx<'static, Blocking>;
        pub(crate) type TxXfer<B> = ParlIoTxTransfer<'static, B, Blocking>;
    }
    hub75_use_lcd_cam => {
        use esp_hal::lcd_cam::lcd::i8080::{I8080, I8080Transfer};

        pub(crate) type TxDriver = I8080<'static, Blocking>;
        pub(crate) type TxXfer<B> = I8080Transfer<'static, B, Blocking>;
    }
    _ => {
        compile_error!("no HUB75 backend selected: enable exactly one chip feature");
    }
}

/// Transfer type for the active refresh mode's buffer.
pub(crate) type TxTransfer = TxXfer<BcmBuf>;

/// On ESP32-C5, the GDMA EOF signal is generated by the DMA channel rather
/// than the `PARL_IO` byte counter, so the transfer-length field is unused.
/// Circular `PARL_IO` (ESP32-C5 only — the `DmaEof` EOF source does not
/// exist on the C6) always uses it; linear mode uses it only on the C5.
#[cfg(all(hub75_use_parl_io, esp32c5))]
pub(crate) const PARL_IO_DUMMY_TRANSFER_LEN: usize = 0;

// ---------------------------------------------------------------------------
// Transfer handle (shared by both refresh modes)
// ---------------------------------------------------------------------------

/// Transfer lifecycle, shared by both refresh modes.
///
/// - **Linear**: `Idle` until `start_internal()` kicks off the first transfer;
///   `Error` parks the driver and buffer until `restart()`.
/// - **Circular**: `Idle` until `start_internal()` builds the descriptor ring
///   and starts the free-running chain; `Error` records a failed
///   initial/restart transfer (theoretically impossible; surfaced to swap
///   waiters).
enum TransferPhase {
    /// Parked: the driver and buffer are owned, but no transfer is running.
    Idle(TxDriver, BcmBuf),
    /// A transfer is in flight.
    InFlight(TxTransfer),
    /// Parked after a failed start/finish, keeping the driver and buffer so
    /// the caller can retry.
    Error(Hub75Error, TxDriver, BcmBuf),
    /// Transient state used while the driver/buffer are taken out of the enum.
    Transitioning,
}

/// The DMA driver handle, its BCM buffer, and the transfer lifecycle.
///
/// This is the single place that knows how to (re)start a transfer, how to
/// consume a completed one, and how to clear the active backend's
/// frame-boundary flag. It absorbs every per-backend `cfg_select!` and the
/// backend-specific parameters (`word_size` on `LCD_CAM`; the `PARL_IO`
/// transfer length, derived from the buffer) so callers — [`start_internal`]
/// and the refresh [`isr`] — neither see nor pass them.
///
/// All access is serialized by the [`STATE`] lock, and the driver is
/// initialized once, so exactly one `Transfer` exists for the driver's
/// lifetime.
pub(crate) struct Transfer {
    phase: TransferPhase,
    /// `LCD_CAM` only: transfer word width. Needed to reconstruct the
    /// `I8080::send()` call on every (re)start.
    #[cfg(hub75_use_lcd_cam)]
    word_size: WordSize,
}

impl Transfer {
    /// Parks a fresh `(driver, buffer)` pair as `Idle`.
    pub(crate) fn new(
        tx: TxDriver,
        buf: BcmBuf,
        #[cfg(hub75_use_lcd_cam)] word_size: WordSize,
    ) -> Self {
        Self {
            phase: TransferPhase::Idle(tx, buf),
            #[cfg(hub75_use_lcd_cam)]
            word_size,
        }
    }

    /// `true` while a transfer is in flight.
    pub(crate) fn is_in_flight(&self) -> bool {
        matches!(self.phase, TransferPhase::InFlight(..))
    }

    /// `true` when the engine is parked (`Idle` or `Error`) and can accept
    /// [`start`](Self::start).
    pub(crate) fn is_idle(&self) -> bool {
        matches!(
            self.phase,
            TransferPhase::Idle(..) | TransferPhase::Error(..)
        )
    }

    /// The error the engine is parked on, if any.
    pub(crate) fn error(&self) -> Option<Hub75Error> {
        match &self.phase {
            TransferPhase::Error(err, _, _) => Some(*err),
            _ => None,
        }
    }

    /// Mutable access to the parked buffer, for the mode-specific bind and
    /// advance steps that happen between transfers.
    ///
    /// # Panics
    ///
    /// Panics if a transfer is in flight. Callers guard with
    /// [`is_idle`](Self::is_idle), or call this right after a successful
    /// [`finish`](Self::finish).
    pub(crate) fn buf_mut(&mut self) -> &mut BcmBuf {
        match &mut self.phase {
            TransferPhase::Idle(_, buf) | TransferPhase::Error(_, _, buf) => buf,
            TransferPhase::InFlight(_) | TransferPhase::Transitioning => {
                panic!("Transfer::buf_mut() called while a transfer is in flight")
            }
        }
    }

    /// Starts a transfer from the parked `(driver, buffer)`.
    ///
    /// The backend `cfg_select!` lives here: `I2S`/`LCD_CAM` `send()`,
    /// `PARL_IO` `write()` with the peripheral's EOF bit length.
    ///
    /// # Errors
    ///
    /// Returns [`Hub75Error::AlreadyRunning`] if the engine is not parked, or
    /// the backend's start error. Either way the engine ends up parked again,
    /// owning the driver and buffer, so the caller can retry.
    #[cfg_attr(feature = "iram", ram)]
    pub(crate) fn start(&mut self) -> Result<(), Hub75Error> {
        // `LCD_CAM` only: reconstructing the command-less `I8080::send()`
        // needs the word width. The `use` lives here (not in the arm) so the
        // `cfg_select!` arm below stays a single expression.
        #[cfg(hub75_use_lcd_cam)]
        use esp_hal::lcd_cam::lcd::i8080::Command;

        let (tx, buf) = match core::mem::replace(&mut self.phase, TransferPhase::Transitioning) {
            TransferPhase::Idle(tx, buf) | TransferPhase::Error(_, tx, buf) => (tx, buf),
            other => {
                self.phase = other;
                return Err(Hub75Error::AlreadyRunning);
            }
        };

        // PARL_IO only: the peripheral's EOF bit-length counter. On the C5 the
        // EOF comes from the DMA channel, so the field is a dummy (both refresh
        // modes; the `DmaEof` EOF source does not exist on the C6, where linear
        // mode computes the real length and full-chain mode computes it in
        // `BcmBuf::build`; circular mode is unsupported).
        #[cfg(hub75_use_parl_io)]
        let transfer_len = cfg_select! {
            any(feature = "circular-dma", esp32c5) => PARL_IO_DUMMY_TRANSFER_LEN,
            _ => buf.current_transfer_len(),
        };

        let xfer_result = cfg_select! {
            hub75_use_i2s_parallel => {
                tx.send(buf)
                    .map_err(|(err, tx, buf)| (Hub75Error::Dma(err), tx, buf))
            }
            hub75_use_parl_io => {
                tx.write(transfer_len, buf)
                    .map_err(|(err, tx, buf)| (Hub75Error::ParlIo(err), tx, buf))
            }
            hub75_use_lcd_cam => {
                match self.word_size {
                    WordSize::Eight => tx.send(Command::<u8>::None, 0, buf),
                    WordSize::Sixteen => tx.send(Command::<u16>::None, 0, buf),
                }
                .map_err(|(err, tx, buf)| (Hub75Error::Dma(err), tx, buf))
            }
            _ => {
                unreachable!()
            }
        };

        match xfer_result {
            Ok(xfer) => {
                self.phase = TransferPhase::InFlight(xfer);
                Ok(())
            }
            Err((err, tx, buf)) => {
                self.phase = TransferPhase::Error(err, tx, buf);
                Err(err)
            }
        }
    }

    /// Consumes the in-flight transfer and parks the engine again.
    ///
    /// Per-backend completion/flag semantics:
    ///
    /// | Backend | `is_done()` polls | `wait()` polls & clears |
    /// |---|---|---|
    /// | I2S (ESP32) | `state.tx_idle` (lags `out_total_eof`) | polls `tx_idle`; clears `out_done`/`out_total_eof` in `INT_CLR` |
    /// | `LCD_CAM` (S3) | `lcd_start == 0` | polls `lcd_start`; clears `lcd_trans_done` in `LC_DMA_INT_CLR` |
    /// | `PARL_IO` (C5) | (via `wait`) | polls `INT_RAW.tx_eof` and clears it itself |
    ///
    /// `wait()` may block briefly for the peripheral to drain what the DMA has
    /// already committed (most visibly on ESP32 I2S, where `tx_idle` trails
    /// `out_total_eof` by the FIFO/shift-register drain time), but it only
    /// ever reads peripheral state, so this is safe from ISR context on every
    /// backend.
    ///
    /// Circular mode (compiled only with `circular-dma`) asserts `is_done()` on
    /// the backends where the boundary interrupt *is* the completion signal
    /// that `wait()` polls, i.e. where the assert proves `wait()` cannot block:
    /// `PARL_IO`'s `wait()` polls `INT_RAW.tx_eof`, the very flag that fired
    /// the ISR, and `LCD_CAM` raises `lcd_trans_done` with `lcd_start` already
    /// cleared. I2S is the exception — its `is_done()` polls `state.tx_idle`,
    /// which lags the boundary flag — so the arm below skips the assert and
    /// lets `wait()` block for the FIFO/shift-register drain.
    ///
    /// The boundary flag that fired the interrupt is deliberately **not**
    /// cleared here: every backend's `wait()` clears its own flag before it
    /// returns (`I2S`: `INT_CLR.out_done`/`out_total_eof`; `LCD_CAM`:
    /// `LC_DMA_INT_CLR.lcd_trans_done`; `PARL_IO`: `INT_CLR.tx_eof`), so it is
    /// clean after this returns on every path — including error paths, which
    /// therefore cannot re-fire it. Leaving it latched keeps `PARL_IO`'s
    /// completion poll satisfied and keeps the flag available as evidence that
    /// the boundary really happened. The ISR's stale-flag gate is the only
    /// remaining caller of
    /// [`clear_frame_interrupt`](Self::clear_frame_interrupt).
    ///
    /// # Errors
    ///
    /// Returns the backend's completion error and parks the engine in
    /// [`TransferPhase::Error`], keeping ownership of the driver and buffer.
    /// A no-op returning `Ok(())` if no transfer is in flight.
    #[cfg_attr(feature = "iram", ram)]
    pub(crate) fn finish(&mut self) -> Result<(), Hub75Error> {
        let xfer = match core::mem::replace(&mut self.phase, TransferPhase::Transitioning) {
            TransferPhase::InFlight(xfer) => xfer,
            other => {
                self.phase = other;
                return Ok(());
            }
        };

        // Circular only: assert that `wait()`'s completion poll is already
        // satisfied — i.e. that it cannot block — on the backends where the
        // boundary interrupt *is* that signal: `PARL_IO`'s `wait()` polls
        // `INT_RAW.tx_eof`, the very flag that just fired this ISR, and
        // `LCD_CAM` raises `lcd_trans_done` with `lcd_start` (what
        // `is_done()`/`wait()` poll) already cleared.
        //
        // I2S is the exception: `is_done()` polls `state.tx_idle`, which the
        // DMA's `out_total_eof` leads by the FIFO/shift-register drain time.
        // At the pass boundary the transfer is *ending*, not ended, so
        // `wait()` blocks for that drain — microseconds, once per swap,
        // exactly as it already does at every group boundary in the linear
        // mode. It cannot block forever: the armed chain ended on the boundary
        // descriptor (`suc_eof`, `next = NULL`), so the DMA has stopped
        // feeding the FIFO and `tx_idle` is reached.
        //
        // The boundary flag that fired this interrupt is deliberately left for
        // `wait()` to clear (see the doc comment above).
        #[cfg(feature = "circular-dma")]
        cfg_select! {
            hub75_use_i2s_parallel => {}
            _ => {
                assert!(
                    xfer.is_done(),
                    "circular boundary ISR: transfer not complete at the pass boundary"
                );
            }
        }

        let (result, tx, buf) = Self::wait(xfer);

        match result {
            Ok(()) => {
                self.phase = TransferPhase::Idle(tx, buf);
                Ok(())
            }
            Err(err) => {
                self.phase = TransferPhase::Error(err, tx, buf);
                Err(err)
            }
        }
    }

    /// Backend-specific `wait()`, normalized to `(result, driver, buffer)`.
    ///
    /// `I2S` reports completion through peripheral state registers only (its
    /// `wait()` has no error to report); the other backends return a
    /// `Result`.
    ///
    /// The `cfg_select!` is this function's tail expression on purpose: its
    /// arms contain statements, which only expand correctly there.
    #[cfg_attr(feature = "iram", ram)]
    fn wait(xfer: TxTransfer) -> (Result<(), Hub75Error>, TxDriver, BcmBuf) {
        cfg_select! {
            hub75_use_i2s_parallel => {
                let (tx, buf) = xfer.wait();
                (Ok(()), tx, buf)
            }
            _ => {
                let (result, tx, buf) = xfer.wait();
                (result.map_err(Hub75Error::Dma), tx, buf)
            }
        }
    }

    /// Clears the active backend's frame-boundary flag.
    ///
    /// Circular mode only, and called from exactly one place: the refresh
    /// ISR's stale-flag gate (a boundary flag found latched with no swap
    /// armed). [`finish`](Self::finish) deliberately leaves the flag that
    /// fired the interrupt to the backend's own `wait()`, which clears it. The
    /// flag registers live in the peripherals rather than in the DMA
    /// transfers, so each backend exposes the clear through its own module; on
    /// ESP32 the constructor records which `I2S` instance the driver owns.
    #[cfg(feature = "circular-dma")]
    #[cfg_attr(feature = "iram", ram)]
    pub(crate) fn clear_frame_interrupt() {
        crate::driver::clear_frame_interrupt();
    }
}