velxio/backend/tests/test_esp32_sd_slave.py

164 lines
5.5 KiB
Python

"""Unit tests for the ESP32 SD-over-SPI slave (esp32_sd_slave.SdSpiSlave).
Mirrors the browser part's tests (protocol-parts.test.ts) — same protocol that
is validated end-to-end against real Arduino SD.h firmware in
frontend/src/__tests__/microsd-real-firmware.test.ts. Confirms the Python port
is faithful: reply-first 1-byte latency, SDSC byte addressing, write capture.
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
_here = Path(__file__).resolve().parent.parent / "app" / "services" / "esp32_sd_slave.py"
_spec = importlib.util.spec_from_file_location("esp32_sd_slave", _here)
_mod = importlib.util.module_from_spec(_spec)
sys.modules["esp32_sd_slave"] = _mod
_spec.loader.exec_module(_mod)
SdSpiSlave = _mod.SdSpiSlave
def xfer(s, data):
return [s.transfer(b) for b in data]
def cmd(idx, arg=0):
return [0x40 | idx, (arg >> 24) & 0xFF, (arg >> 16) & 0xFF, (arg >> 8) & 0xFF, arg & 0xFF, 0x95]
def FF(n):
return [0xFF] * n
def at(block):
return block * 512
def test_init_handshake():
s = SdSpiSlave()
# 1-byte Ncr latency: R1 on the first 0xFF clock after the 6 command bytes.
r = xfer(s, cmd(0) + FF(1))
assert r[6] == 0x01 # idle
r = xfer(s, cmd(8, 0x1AA) + FF(5))
assert r[6:11] == [0x01, 0x00, 0x00, 0x01, 0xAA] # R7
r = xfer(s, cmd(55) + FF(1))
assert r[6] == 0x01
r = xfer(s, cmd(41) + FF(1))
assert r[6] == 0x00 # ACMD41 ready
r = xfer(s, cmd(58) + FF(5))
assert r[6:11] == [0x00, 0x80, 0xFF, 0x80, 0x00] # OCR, SDSC (CCS=0)
def test_write_then_read_roundtrip():
s = SdSpiSlave()
data = [(i * 7 + 3) & 0xFF for i in range(512)]
xfer(s, cmd(24, at(5)))
r = xfer(s, [0xFF, 0xFE] + data + [0xFF, 0xFF, 0xFF])
assert 0x05 in r # data accepted
r = xfer(s, cmd(17, at(5)) + FF(520))
t = r.index(0xFE)
assert r[t + 1 : t + 1 + 512] == data
def test_unwritten_block_is_zeros():
s = SdSpiSlave()
r = xfer(s, cmd(17, at(123)) + FF(520))
t = r.index(0xFE)
assert r[t + 1 : t + 1 + 512] == [0] * 512
def test_byte_addressing_translates_to_block():
# Two different byte offsets that map to distinct blocks must not collide.
s = SdSpiSlave()
a = [0xAA] * 512
b = [0xBB] * 512
xfer(s, cmd(24, at(2)))
xfer(s, [0xFF, 0xFE] + a + [0xFF, 0xFF, 0xFF])
xfer(s, cmd(24, at(3)))
xfer(s, [0xFF, 0xFE] + b + [0xFF, 0xFF, 0xFF])
r2 = xfer(s, cmd(17, at(2)) + FF(520))
r3 = xfer(s, cmd(17, at(3)) + FF(520))
assert r2[r2.index(0xFE) + 1 : r2.index(0xFE) + 513] == a
assert r3[r3.index(0xFE) + 1 : r3.index(0xFE) + 513] == b
def test_multi_block_write():
s = SdSpiSlave()
a = [(i + 1) & 0xFF for i in range(512)]
b = [(i + 2) & 0xFF for i in range(512)]
xfer(s, cmd(25, at(10)))
xfer(s, [0xFC] + a + [0xFF, 0xFF])
xfer(s, [0xFC] + b + [0xFF, 0xFF])
xfer(s, [0xFD]) # stop token
r10 = xfer(s, cmd(17, at(10)) + FF(520))
r11 = xfer(s, cmd(17, at(11)) + FF(520))
assert r10[r10.index(0xFE) + 1 : r10.index(0xFE) + 513] == a
assert r11[r11.index(0xFE) + 1 : r11.index(0xFE) + 513] == b
def test_csd_reflects_capacity():
s = SdSpiSlave()
r = xfer(s, cmd(9) + FF(20))
t = r.index(0xFE)
csd = r[t + 1 : t + 1 + 16]
assert len(csd) == 16
assert csd[0] & 0xC0 == 0x40 # CSD structure v2
def _crc16(data):
crc = 0
for b in data:
crc ^= b << 8
for _ in range(8):
crc = ((crc << 1) ^ 0x1021) if (crc & 0x8000) else (crc << 1)
crc &= 0xFFFF
return crc
def test_espidf_init_sequence_idle_bit():
# Mirrors what ESP-IDF sdspi sends: CMD0, CMD59 (CRC on), CMD8, ACMD41,
# CMD58. Every R1 before ACMD41 must carry the idle bit (0x01); the
# hardcoded-0x00 bug stalled real ESP-IDF init right after CMD59.
s = SdSpiSlave()
assert xfer(s, cmd(0) + FF(1))[6] == 0x01 # idle
assert xfer(s, [0x40 | 59, 0, 0, 0, 1, 0x83] + FF(1))[6] == 0x01 # CMD59 still idle
assert xfer(s, cmd(8, 0x1AA) + FF(5))[6] == 0x01 # CMD8 R7, idle bit set
assert xfer(s, cmd(55) + FF(1))[6] == 0x01
assert xfer(s, cmd(41) + FF(1))[6] == 0x00 # ACMD41 -> ready, leaves idle
assert xfer(s, cmd(58) + FF(5))[6] == 0x00 # CMD58 R1 now 0x00
def test_data_block_crc16_valid_when_enabled():
# With CRC enabled (CMD59 arg=1), the 2 bytes trailing a read block must be
# the real CRC16 of the block — ESP-IDF rejects the read otherwise.
s = SdSpiSlave()
payload = [(i * 13 + 7) & 0xFF for i in range(512)]
xfer(s, cmd(24, at(8)))
xfer(s, [0xFF, 0xFE] + payload + [0xFF, 0xFF, 0xFF])
xfer(s, [0x40 | 59, 0, 0, 0, 1, 0x83] + FF(1)) # enable CRC
r = xfer(s, cmd(17, at(8)) + FF(520))
t = r.index(0xFE)
block = r[t + 1 : t + 1 + 512]
crc = (r[t + 513] << 8) | r[t + 514]
assert block == payload
assert crc == _crc16(bytes(block))
def test_data_block_crc_is_ff_when_disabled():
# Default (no CMD59): CRC bytes are 0xFFFF — Arduino AVR SD.h ignores them.
s = SdSpiSlave()
r = xfer(s, cmd(17, at(0)) + FF(520))
t = r.index(0xFE)
assert r[t + 513] == 0xFF and r[t + 514] == 0xFF
def test_loads_a_prebuilt_image():
block0 = bytes((i ^ 0x5A) & 0xFF for i in range(512))
block1 = bytes((i + 200) & 0xFF for i in range(512))
s = SdSpiSlave(block0 + block1)
r0 = xfer(s, cmd(17, at(0)) + FF(520))
r1 = xfer(s, cmd(17, at(1)) + FF(520))
assert bytes(r0[r0.index(0xFE) + 1 : r0.index(0xFE) + 513]) == block0
assert bytes(r1[r1.index(0xFE) + 1 : r1.index(0xFE) + 513]) == block1