Skip to content

feat(daplink_flash): Add file-IO driver on top of the DaplinkBridge.#167

Open
nedseb wants to merge 22 commits into
mainfrom
feat/daplink-flash-driver
Open

feat(daplink_flash): Add file-IO driver on top of the DaplinkBridge.#167
nedseb wants to merge 22 commits into
mainfrom
feat/daplink-flash-driver

Conversation

@nedseb
Copy link
Copy Markdown
Contributor

@nedseb nedseb commented Apr 29, 2026

Goal

Add the high-level file-IO driver layered on DaplinkBridge (merged in #161). The DAPLink firmware exposes the on-board SPI flash as a FAT-style file system over I²C; this PR wraps that protocol into an Arduino driver matching the MicroPython sister API.

Closes #12.
Closes #32.
Partial #44 — the non-destructive hardware smoke is included; the destructive integration suite is deferred (see Known limitation below).

What's in the PR

  • lib/daplink_flash/ — the driver itself (DaplinkFlash class), register constants, README with destructive-ops guidance, library metadata, keywords.
  • lib/daplink_bridge/ — two cross-driver primitives (sendCommand + readResponse) and a dedicated response-stream register (REG_RESPONSE = 0x82) so command writes and response reads use distinct I²C addresses.
  • tests/native/test_daplink_flash/ — 19 host-side unit tests against the TwoWire mock. Covers happy paths plus regression locks for the three review-found bugs (multi-chunk read past the 0xFF wrap, partial-write offset semantics, setFilename bool surface).
  • tests/hardware/test_daplink_flash/ — 2 non-destructive smoke tests (begin() + printable-ASCII getFilename()); runs as part of make test-hardware without touching flash content.
  • src/main.cpp — driver wired into the smoke-test sketch alongside the other on-board drivers (per tooling: Extend smoke-test sketch to cover every implemented driver #166).

Public API (highlights)

Method Purpose
bool begin() Re-probes the bridge; false if absent or WHO_AM_I mismatch.
bool setFilename(name, ext) Sets the 8.3 filename; false on null arg or bridge error.
FilenameResult getFilename() Reads the current 8.3 filename, trimmed.
bool clearFlash() Destructive. See the limitation below.
size_t write(data, length) Append-style write; returns bytes actually written (0, partial, or full).
bool readSector(sector, buf) 256-byte sector read.
size_t readUntilSentinel(buf, max) Stream bytes until the first 0xFF.

Every method that talks to the bridge propagates the bridge's busy-timeout and error register: false / 0 on failure, deterministic success otherwise. See the README for the full surface and call patterns.

Known limitation — clearFlash() on real hardware

clearFlash() is known to brick STeaMi boards into DAPLink maintenance mode. The I²C transaction is byte-identical to the working MicroPython clear_flash(), and the F103 firmware is supposed to wipe only the external W25Q64 (not the F103 internal flash that holds the interface firmware), so the bug sits in the firmware itself. Tracked in steamicc/DAPLink#9.

Until that lands:

  • clearFlash() stays in the public API (parity with MicroPython); the README opens with a ⚠️ Destructive operations section and the quick-start does not call it.
  • The destructive integration suite was reverted (initially added in 2bb70f5, removed in 3f522e2); shipping it would mean every make test-integration wipes the interface firmware. Re-add once DAPLink#9 lands.
  • The non-destructive hardware smoke stays in place.

Follow-ups

Test plan

  • make ci (lint + format-check + native) — 101/101 native PASS
  • make test-hardware on a wired STeaMi — daplink_flash smoke PASS
  • make build (steami env) — smoke sketch links and runs daplink_flash.begin() on the board
  • make test-integration — deferred to after DAPLink#9

@github-actions github-actions Bot added the enhancement New feature or request label Apr 29, 2026
@nedseb nedseb force-pushed the feat/daplink-flash-driver branch from 4b56a90 to 5570c49 Compare April 29, 2026 15:17
@nedseb
Copy link
Copy Markdown
Contributor Author

nedseb commented Apr 29, 2026

Aline,
j'ai ouvert cette PR en draft pour qu'on puisse parler du contenu de ta branche feat/daplink-flash-driver avant que tu finalises. Je l'ai rebasée sur main (qui inclut maintenant #161 — ma version réécrite de ton driver bridge), puis j'ai poussé deux commits pour la débloquer côté compilation. Voici une première revue pour t'orienter sur ce qui reste.

Ce qui se passe pendant le rebase

J'ai droppé tes 5 commits sur daplink_bridge (driver, tests, README, fixes) parce qu'ils sont déjà sur main sous une forme différente après #161 — sinon git aurait recréé ta version qui entrait en conflit avec celle de main. Ce qui reste, c'est ton travail pur sur daplink_flash :

  • feat(daplink_flash): Add DaplinkFlash driver.
  • feat(daplink_flash): Add DaplinkFlash native unit tests.
  • docs(daplink_flash): Add DaplinkFlash README.
  • feat(daplink_flash): Add library.properties files.
  • fix(daplink_flash): Declare daplink_bridge as a library dependency.

C'est sur quoi cette revue porte.

J'ai déjà adapté le code pour qu'il compile contre main

Toutes tes méthodes DaplinkFlash appelaient le bridge avec l'ancienne API (la tienne d'avant #161). Sur main, le bridge a maintenant une DaplinkBridge (PascalCase, refactorée) avec une surface publique différente, et toutes les primitives bas-niveau (writeFrame, readBlock, error, waitNotBusy) sont privées.

Plutôt que d'attendre que tu te débloques toute seule, j'ai poussé deux commits sur la branche :

  • a12c090 feat(daplink_bridge): Add sendCommand/readResponse for sibling drivers. — expose une API "frame-level" générique sur le bridge, conçue pour que n'importe quel sibling (flash, futur driver) puisse composer son protocole par-dessus :

    bool sendCommand(uint8_t cmd, const uint8_t* payload = nullptr, size_t payloadLen = 0,
                     uint32_t timeoutMs = DAPLINK_BRIDGE_WRITE_TIMEOUT_MS);
    size_t readResponse(uint8_t cmd, uint8_t* buf, size_t maxLen,
                        uint32_t timeoutMs = DAPLINK_BRIDGE_READ_TIMEOUT_MS);

    sendCommand frame [CMD | payload...], attend que le bridge ne soit plus busy, et retourne false si le device répond une erreur. readResponse fait le write-then-read équivalent et chunke en interne sous MAX_READ_CHUNK. Les méthodes existantes (clearConfig / writeConfig / readConfig) restent comme convenience pour la zone de config.

  • 55eda25 fix(daplink_flash): Adapt to the new DaplinkBridge public API. — migre ton driver vers la nouvelle API. Concrètement :

    Tu avais C'est devenu
    class daplink_bridge (lowercase) class DaplinkBridge
    #include "daplink_bridge.h" #include "DaplinkBridge.h"
    _bridge->writeTo(buf, len) + error() check _bridge->sendCommand(cmd, payload, len)
    _bridge->readBlock(reg, buf, len) _bridge->readResponse(cmd, buf, len)
    _bridge->writeCmd(cmd) + waitBusy() _bridge->sendCommand(cmd)
    library.properties depends=STeaMi daplink bridge depends=STeaMi DAPLink Bridge (matche le name= du bridge)

    Les tests natifs ont été mis à jour pour utiliser les noms DAPLINK_BRIDGE_REG_* / DAPLINK_BRIDGE_STATUS_* au lieu des noms non-préfixés.

65/65 tests natifs passent maintenant, mais j'ai dû désactiver 2 tests avec un TODO explicite (test_read_stops_at_sentinel, test_read_limited_by_maxlen) — ils segfaultent à cause d'un bug protocolaire dans readSector que je détaille plus bas dans Design.

À toi de prendre la suite à partir de là — il reste les conventions, le design API et le rework des tests.

Conventions à appliquer (familier après #143 / #152 / #161)

Les mêmes points que sur tes PR précédentes. Liste à cocher au passage :

  • begin() manque sur DaplinkFlash. L'utilisateur n'a aucun moyen de savoir si le bridge est joignable avant de tenter clearFlash() (qui prend ~secondes à timer out s'il n'est pas là).
  • Constants sans préfixe dans daplink_flash_const.h (CMD_WRITE_DATA, MAX_SECTORS, etc.). Avec le bridge sur main qui a maintenant DAPLINK_BRIDGE_CMD_*, il y a un risque de collision de namespace global si tu importes les deux. À préfixer en DAPLINK_FLASH_CMD_*.
  • README ne mentionne pas begin() et la quick-start utilise encore daplink_bridge lowercase dans les exemples (j'ai corrigé le code mais pas la doc).

Design API à challenger

Quelques points où le shape de l'API se discute :

  • Bug protocolaire dans readSector : la méthode prend un sectorIndex mais ne le passe jamais au bridge. Mes deux tests désactivés taperaient ce path. Il faut soit ajouter un seekSector(uint16_t index) côté firmware DAPLink (et l'envoyer avant le readResponse), soit embarquer l'index dans la commande CMD_READ_SECTOR. À trancher avec le firmware DAPLink.
  • size_t read(uint8_t* result, size_t maxLen, bool limitLen = false) — le limitLen change la sémantique du même appel (avec false, on lit jusqu'au sentinel 0xFF ; avec true, on lit jusqu'à maxLen). Deux méthodes distinctes seraient plus claires : readUntilSentinel(buf, maxLen) et readN(buf, n). Pas un blocker, mais plus prévisible.
  • size_t write(...) retourne 0 sur erreur — mais aussi 0 si length == 0 est passé. Ambiguïté. Pattern HTS221 : signaler l'erreur via une autre voie. Ici je verrais bien bool write(data, len, size_t* bytesWritten = nullptr).
  • writeLine ne propage pas l'échec de write — il retourne toujours len + 1 même si le write sous-jacent a renvoyé 0. À corriger.
  • uint8_t buf[len + 1] dans writeLine — VLA non-standard en C++. Préférer un std::vector<uint8_t> ou un buffer fixe avec borne.

Tests — les mêmes patterns à corriger qu'avant

Tu as la même structure de test que sur le bridge avant qu'on la corrige. Notamment :

  • test_write_sends_data : memcmp(&w.value, data, strlen(data)) est encore l'UB qu'on a vue dans Feat/daplink bridge driver #161. Le test passe en fait sans rien valider.
  • test_clear_flash_sends_cmd : TEST_ASSERT_TRUE(true) — n'assert rien d'utile.
  • test_read_sector_sends_correct_command : pareil, TEST_ASSERT_TRUE(true).
  • Pas de test begin() (puisque pas de begin()).
  • Pas de tests sur les paths d'erreur du bridge (busy timeout, error register != 0 quand on écrit).
  • Les 2 tests désactivés (test_read_stops_at_sentinel, test_read_limited_by_maxlen) à réactiver une fois le bug readSector corrigé.

Le fix #161 te montre le pattern propre — une fois que tu fixes les autres points, les tests pourront utiliser check_begin / check_who_am_i du shared.

Manques pour que ce soit complet

  • Section DaplinkFlash dans src/main.cpp : convention tooling: Extend smoke-test sketch to cover every implemented driver #166 demande qu'un nouveau driver implémenté soit câblé au smoke-test. Comme le driver est destructif (clearFlash), peut-être juste un flash.begin() qui lit le filename actuel sans rien écrire.
  • writeLine documenté nulle part.
  • Méthode getFilename() qui retourne par valeur un FilenameResult — OK pour cette taille (12 bytes), mais à confirmer côté API si tu veux aussi getFilename(char* nameOut, char* extOut) style "fill caller buffer" pour cohérence avec read().

Plan

  1. ✅ Bridge gagne sendCommand() / readResponse() publics (commit a12c090).
  2. ✅ Le flash compile contre la nouvelle API du bridge (commit 55eda25), 65/65 tests natifs passent.
  3. À toi : appliquer les conventions (begin(), préfixage des constants, README, etc.), rework des tests (UB + assertions vides), trancher le bug protocolaire readSector puis réactiver les 2 tests désactivés.
  4. Câbler le smoke-test src/main.cpp.
  5. On bouge la PR de draft à ready-for-review.

Pas de rush — la branche est rebasée, ton code compile, tu peux travailler dessus sans pression de conflit. Et pour les conventions, regarde aussi les retours pédagogiques sur #143, #152, #161 ; les patterns reviennent.

@DumontALINE DumontALINE marked this pull request as ready for review May 4, 2026 09:46
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new daplink_flash driver intended to sit on top of DaplinkBridge to perform flash/file-like operations (8.3 filename handling, raw writes, sector reads) over the DAPLink F103 I2C bridge, along with native tests and documentation.

Changes:

  • Introduces DaplinkFlash (header/impl + protocol constants) and adds a README + Arduino library metadata.
  • Extends DaplinkBridge with frame-level primitives (sendCommand, readResponse) and a REG_RESPONSE constant for sibling drivers.
  • Adds a native unit test suite for daplink_flash and wires the driver into the repo’s smoke-test sketch.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 14 comments.

Show a summary per file
File Description
tests/native/test_daplink_flash/test_main.cpp Adds host-side Unity tests for the new flash driver.
src/main.cpp Instantiates DaplinkFlash in the build smoke-test sketch and probes begin().
lib/daplink_flash/src/daplink_flash.h Declares the DaplinkFlash public API.
lib/daplink_flash/src/daplink_flash.cpp Implements filename management, write helpers, and sector-based reads via the bridge.
lib/daplink_flash/src/daplink_flash_const.h Defines flash-level command IDs and protocol limits.
lib/daplink_flash/README.md Documents hardware context, quick start, and the public API.
lib/daplink_flash/library.properties Registers the Arduino library and declares dependency on the bridge library.
lib/daplink_bridge/src/DaplinkBridge.h Exposes new framing helpers for sibling drivers.
lib/daplink_bridge/src/DaplinkBridge.cpp Implements sendCommand and readResponse.
lib/daplink_bridge/src/daplink_bridge_const.h Adds DAPLINK_BRIDGE_REG_RESPONSE constant.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/daplink_flash/src/daplink_flash.cpp
Comment thread lib/daplink_flash/src/daplink_flash.cpp Outdated
Comment thread lib/daplink_flash/src/daplink_flash.cpp
Comment thread lib/daplink_flash/src/daplink_flash.cpp Outdated
Comment thread lib/daplink_flash/src/daplink_flash.cpp
Comment thread lib/daplink_flash/README.md Outdated
Comment on lines +68 to +78
Wire.clearWrites();
flash.clearFlash();

bool sawCmd = false;
for (const auto& w : Wire.getWrites()) {
if (w.reg == DAPLINK_FLASH_CMD_CLEAR_FLASH) {
sawCmd = true;
}
}

TEST_ASSERT_TRUE(true);
Comment thread tests/native/test_daplink_flash/test_main.cpp
Comment thread lib/daplink_flash/library.properties
Comment thread lib/daplink_flash/README.md
@Charly-sketch
Copy link
Copy Markdown
Contributor

Good work, I only have a few change requests before merge:

  1. write(const char* data) still needs a null guard.
    This overload still calls strlen(data) directly, so flash.write(nullptr) would dereference a null pointer and crash. It should mirror the defensive check already added in write(const uint8_t*, size_t).

  2. Read-path error handling is still too optimistic (readSector() / getFilename()).
    getFilename() now zero-initializes its temporary buffer, which avoids UB, but it still ignores the number of bytes actually returned by readResponse(). Same issue in readSector(): there is no validation of sendCommand() success, no buf == nullptr guard, and no check that a full 256-byte sector was really received. Right now a bridge timeout or partial response silently turns into parsed garbage/default data instead of a detectable failure.

  3. test_clear_flash_sends_cmd is still not asserting anything meaningful.
    The test computes sawCmd and then ends with TEST_ASSERT_TRUE(true), so it passes unconditionally. Either assert sawCmd, or adjust the TwoWire mock if command-only frames are currently invisible there.

  4. Some protocol tests still validate too little.
    A few tests only check that “a command happened” instead of checking the exact payload bytes:

    • setFilename() should assert uppercase + full space padding of the 8.3 payload.
    • readSector(5) should assert that the transmitted payload really contains the sector index bytes.
      Since this library is protocol glue, payload validation matters more than command presence alone.
  5. README still has a small API/doc mismatch.
    In the Lifecycle table, begin() is documented as void begin() while the real implementation is bool begin(). There is also a malformed Markdown row before readUntilSentinel().

Once those are cleaned up, I’m happy with the direction of the PR.

@nedseb
Copy link
Copy Markdown
Contributor Author

nedseb commented May 26, 2026

Quick follow-up after the failed integration-test run on real hardware: clearFlash() does not just wipe the user-data partition — it puts the on-board F103 into DAPLink maintenance mode, requiring a manual re-flash of the interface firmware. Filed as steamicc/DAPLink#9 with reproduction + suggested fix. The destructive integration suite was reverted in commit 7108328 until the firmware contract is tightened. The non-destructive hardware smoke (tests/hardware/test_daplink_flash/) stays in place.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 8 comments.

Comment on lines +188 to +189
readBlock(static_cast<uint8_t>(DAPLINK_BRIDGE_REG_RESPONSE + produced), buf + produced,
want);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fix in 8b3b27f — readResponse now emits the cmd byte once and streams the response through successive requestFrom() calls without ever recomputing REG_RESPONSE+produced, so the 0x82+0x7D=0xFF wraparound disappears. The chunk loop also no longer calls waitNotBusy()/readBlock() between chunks, which were the two things that reset the firmware response cursor.

Comment on lines 24 to 41
uint8_t endTransmission(bool = true) {
if (txBuffer_.size() >= 2) {
// [reg, val0, val1, ...] — I2C auto-increment: each value lands at
// reg + offset, one WriteOp per byte so tests can assert the full
// write sequence.
uint8_t reg = txBuffer_[0];
for (size_t i = 1; i < txBuffer_.size(); ++i) {
uint8_t targetReg = static_cast<uint8_t>(reg + (i - 1));
uint8_t val = txBuffer_[i];
registers_[makeKey(currentAddress_, targetReg)] = val;
writes_.push_back({currentAddress_, targetReg, val});
}
currentRegisterByAddr_[currentAddress_] = reg;
} else if (txBuffer_.size() == 1) {
currentRegisterByAddr_[currentAddress_] = txBuffer_[0];
uint8_t cmd = txBuffer_[0];
commands_.push_back({currentAddress_, cmd});
currentRegisterByAddr_[currentAddress_] = cmd;
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fix in 9c6cc64 — requestFrom() now pops the trailing entry from commands_ when it matches the current address, so a register-pointer-select single-byte transmission followed by a read no longer pollutes getCommands() with a phantom command.

Comment on lines +55 to +62
const auto& writes = Wire.getWrites();
if (writes.size() >= 10) {
payloadCorrect =
(writes[0].value == 'T') && (writes[1].value == 'E') && (writes[2].value == 'S') &&
(writes[3].value == 'T') && (writes[4].value == ' ') && (writes[5].value == ' ') &&
(writes[6].value == ' ') && (writes[7].value == ' ') && (writes[8].value == 'T') &&
(writes[9].value == 'X') && (writes[10].value == 'T');
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fix in 9c6cc64 — bumped the size check from >= 10 to >= 11 so the writes[10] read is bounds-safe.

Comment on lines +25 to +52
void setUp(void) {
// begin() on the flash driver re-probes the bridge, so we can call
// it before every test without any extra plumbing.
flash.begin();
}

void tearDown(void) {}

void test_daplink_flash_begin() {
TEST_ASSERT_TRUE_MESSAGE(flash.begin(), "flash.begin() must succeed on a wired STeaMi");
}

void test_daplink_flash_get_filename_returns_printable_chars() {
// Read the current filename. Whatever it contains, every byte that
// survives the trailing-space trim should be a printable ASCII
// character — non-printable garbage would mean the read framing or
// the readResponse byte count is broken.
auto current = flash.getFilename();

for (size_t i = 0; i < strlen(current.name); ++i) {
unsigned char c = static_cast<unsigned char>(current.name[i]);
TEST_ASSERT_TRUE_MESSAGE(c >= 0x20 && c < 0x7F, "filename contains non-printable byte");
}
for (size_t i = 0; i < strlen(current.ext); ++i) {
unsigned char c = static_cast<unsigned char>(current.ext[i]);
TEST_ASSERT_TRUE_MESSAGE(c >= 0x20 && c < 0x7F, "extension contains non-printable byte");
}
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fix in 9c6cc64 — setUp() now asserts flash.begin() so a missing bridge fails fast instead of letting empty filename strings make the printable-ASCII test pass vacuously.

category=Data Storage
url=https://github.com/steamicc/arduino-steami-lib
architectures=*
includes=daplink_flash.h
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — deferring to a follow-up PR/issue to keep this one focused on the review feedback. Tracking the rename of daplink_flash.{h,cpp,_const.h} (and the matching library.properties / README / #include sites) separately so it can be reviewed on its own.

Comment thread lib/daplink_flash/src/daplink_flash.cpp Outdated
Comment on lines +24 to +47
void DaplinkFlash::setFilename(const char* name, const char* ext) {
// Set 8.3 filename. name: max 8 chars, ext: max 3 chars.
if (name == nullptr || ext == nullptr) {
return;
}
char n[DAPLINK_FLASH_FILENAME_LEN];
size_t nameLen = std::min(strlen(name), (size_t(DAPLINK_FLASH_FILENAME_LEN)));
for (int i = 0; i < nameLen; i++) {
n[i] = toupper((unsigned char)name[i]);
}

char e[DAPLINK_FLASH_EXT_LEN];
size_t extLen = std::min(strlen(ext), (size_t(DAPLINK_FLASH_EXT_LEN)));
for (int j = 0; j < extLen; j++) {
e[j] = toupper((unsigned char)ext[j]);
}

uint8_t padded[DAPLINK_FLASH_FILENAME_LEN + DAPLINK_FLASH_EXT_LEN];
memset(padded, ' ', sizeof(padded));
memcpy(padded, n, nameLen);
memcpy(padded + DAPLINK_FLASH_FILENAME_LEN, e, extLen);

_bridge->sendCommand(DAPLINK_FLASH_CMD_SET_FILENAME, padded, sizeof(padded));
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fix in 9c6cc64 — setFilename() now returns bool, propagating null-pointer rejection and the bridge sendCommand() result. PR body API box updated to match.

Comment on lines +22 to +34
void setup() {
Serial.begin(115200);
internalI2C.begin();
if (!flash.begin()) {
Serial.println("DAPLink Flash not found!");
return;
}

flash.clearFlash();
flash.setFilename("DATA", "CSV");
flash.writeLine("temperature,pressure");
flash.writeLine("23.5,1013.2");
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fix in 9c6cc64 — README quick start now opens with a callout warning that clearFlash() wipes the whole partition, and the snippet itself carries an inline comment flagging the destructive call.

Comment thread lib/daplink_bridge/src/DaplinkBridge.h Outdated
uint32_t timeoutMs = DAPLINK_BRIDGE_WRITE_TIMEOUT_MS);

// Issue a command and read up to `maxLen` bytes back. Returns the
// number of bytes actually read (zero on busy timeout).
DumontALINE and others added 16 commits May 27, 2026 06:53
daplink_flash.h includes <daplink_bridge.h>; without a matching
`depends=` line in library.properties, PlatformIO LDF can't always
chain the include path correctly (its discovery depends on the order
in which libs are processed and on whether the consumer's .cpp ends
up in compile_commands.json), and Arduino Library Manager won't
auto-install daplink_bridge alongside daplink_flash for downstream
users.

The dep value matches the bridge's own `name=` field exactly. Same
pattern documented in CONTRIBUTING.md after the recent
library.properties refresh.
The DAPLink family is split across drivers — daplink_bridge speaks the
I2C protocol to the F103, and siblings (daplink_flash, daplink_config)
compose their own commands on top. Today the bridge only exposes
config-zone helpers publicly; everything else (writeFrame, readBlock,
error, waitNotBusy) is private.

Add two public primitives so siblings can build their own protocol
without re-implementing the framing or the busy/error bookkeeping:

  bool sendCommand(cmd, payload, payloadLen, timeoutMs);
  size_t readResponse(cmd, buf, maxLen, timeoutMs);

sendCommand frames `[cmd | payload...]`, waits for not-busy, sends,
waits again, and reports the device error register status.
readResponse issues the command then reads back up to maxLen bytes,
chunking through the protocol's MAX_READ_CHUNK with the same cursor
assumption readConfig already uses.

clearConfig/writeConfig/readConfig stay as convenience wrappers — they
weren't reimplemented on top of the new primitives in this commit to
keep the diff focused, but a follow-up could.
The bridge driver landed on main as `DaplinkBridge` (PascalCase) with
private framing primitives. This commit migrates DaplinkFlash so it
compiles against the current main:

- daplink_bridge -> DaplinkBridge (type + include).
- _bridge->writeTo / writeCmd / readBlock -> sendCommand / readResponse
  (the new public primitives added to the bridge).
- _bridge->error() / waitBusy() drop out — the new sendCommand already
  bundles them and surfaces the result via its bool return.
- daplink_flash_const.h: re-enable MAX_WRITE_CHUNK and SECTOR_SIZE
  (Aline had commented them out when she moved away from the old bridge
  const header).
- library.properties: fix depends= casing to match the bridge's
  name= ("STeaMi DAPLink Bridge", with the right capitals).
- tests: same type/include update, plus prefixed register names
  (DAPLINK_BRIDGE_REG_STATUS / _REG_ERROR / _STATUS_BUSY /
  _ERROR_CMD_FAILED).

Two read-path tests (test_read_stops_at_sentinel,
test_read_limited_by_maxlen) are temporarily disabled with a TODO:
the underlying readSector never passes the sector index to the bridge,
so they segfault under the chunked readResponse semantics. Aline will
fix the protocol in her follow-up.

Strictly a compilation fix — the convention issues from the orientation
review (begin(), prefixed flash constants, smoke-test wiring, write/
read API shape, test patterns) stay as her pedagogical to-do list.
…isters.

The flash read path previously reused the command opcode as the base register for streamed responses. This caused two protocol issues in native tests: sendCommand() writes were overwriting the mocked read payload, and readResponse() was repeatedly restarting reads from the same register window on each 16-byte chunk.

Introduce a dedicated DAPLINK_BRIDGE_REG_RESPONSE stream area so command writes and response reads no longer collide.

Refactor DaplinkBridge::readResponse() to consume data from the response stream with deterministic chunk offsets, and keep DaplinkFlash::readSector() on a single buffered 256-byte read instead of per-byte pseudo-register polling.

Update native daplink_flash tests to preload mocked read data from the new response register range and fix the clearFlash command assertion so the suite validates the actual command emission.

This restores a coherent I2C framing model between bridge commands, streamed payload reads and host-side TwoWire mocks.
clearFlash() returned void and silently swallowed any bridge-side
failure (busy timeout or error register set), so a wipe that didn't
land would look identical to a successful one to the caller. Return
bool and propagate the result of _bridge->sendCommand(...) — same
shape as write() / readSector() in the rest of the driver.

Also align the README rows for clearFlash() and readSector() with the
real signatures (both were still documented as returning void).

Add a native test that exercises the new error path (CMD_FAILED in
the bridge's error register → clearFlash() returns false).
Mirror what test_hts221 and test_wsen_hids ship for the other on-board
drivers, split along the destructive boundary so `make test-hardware`
stays safe to run on a board with real flash content:

- tests/hardware/test_daplink_flash/: non-destructive smoke checks
  only (begin() round-trips, getFilename() returns printable ASCII).
- tests/integration/test_daplink_flash/: full destructive cycle
  (clearFlash + setFilename + writeLine + readUntilSentinel +
  readSector against a known pattern). Wipes the partition — run
  only when there is nothing to keep on the DAPLink flash.

Confirmed on the board: the two hardware tests pass against the
current smoke-test firmware (8/8 PASS across the hardware suite).
The integration suite is left for the maintainer to run consciously
because of the wipe.
nedseb added 3 commits May 27, 2026 06:53
Running tests/integration/test_daplink_flash/ on the real STeaMi puts
the on-board DAPLink F103 into maintenance mode and the user has to
manually re-flash the interface firmware to recover. Root cause is in
the DAPLink firmware's handling of CMD_CLEAR_FLASH (it clears sectors
beyond the user-data partition), not in the driver itself, but the
upshot is the same: shipping this integration suite means anyone
running make test-integration loses their DAPLink firmware.

Pull the test until the firmware contract is tightened upstream. The
non-destructive hardware smoke (tests/hardware/test_daplink_flash/)
stays in place — it exercises begin() and the getFilename() read
path without touching flash data.

Follow-up tracked in a steamicc/DAPLink issue.
Re-selecting REG_RESPONSE + produced between chunks wrapped at 0xFF
(0x82 + 0x7D = 0xFF, then 0x00) and silently steered later bytes to
the wrong register. Calling waitNotBusy() between chunks reset the
firmware response cursor for the same reason. Emit the cmd byte once,
then stream the response with successive requestFrom() calls,
matching the firmware's TX-buffer cursor semantics.
- setFilename() now returns bool so callers can detect a null pointer
  or a bridge-side error instead of swallowing it silently.
- Native test off-by-one: writes.size() >= 11 (was 10) so writes[10]
  is in range when validating the padded "TEST" + "TXT" payload.
- Hardware setUp() now asserts flash.begin() so a missing bridge
  fails fast instead of letting downstream assertions fire confusing
  messages.
- README quick-start now warns that clearFlash() wipes the whole
  partition before showing the call.
- Native Wire mock: pre-loaded response payloads land in a dedicated
  queue, surviving the sendCommand payload writes that previously
  stomped the register space at the same offset. Pointer-select
  single-byte transmissions no longer pollute the commands log on
  subsequent requestFrom().
nedseb added 2 commits May 27, 2026 08:06
readResponse() previously took a cmd parameter and re-emitted that
cmd byte to "select" the response stream, which the firmware could
legitimately interpret as a fresh command — wiping the response
buffer on a clean cmd, or hitting BAD_PARAM on a payload-required
cmd. The dedicated DAPLINK_BRIDGE_REG_RESPONSE register (0x82) was
defined for exactly this stream selection but was never used.

Drop the cmd parameter and read from REG_RESPONSE instead. Callers
now do sendCommand(cmd, [payload]) followed by readResponse(buf,
len) so command actuation and response streaming use the right
register each. readResponse() also waits for not-busy, checks the
error register, and propagates a non-zero endTransmission() up the
stack instead of returning a silent partial read.

flash side: getFilename() and readSector() now follow the
sendCommand/readResponse split, and write() returns the number of
bytes actually committed to flash on a mid-stream chunk failure
(was 0) so callers can distinguish a clean failure from a partial
write before retrying — relevant because the on-device write is
append-only and not atomic.

Mock side: setResponse() doc updated to reflect that it keys on
a selector register (REG_RESPONSE in practice), not a cmd.
clearFlash() is currently known to brick STeaMi boards into DAPLink
maintenance mode (steamicc/DAPLink#9), so the README quick start
should not invoke it on every setup(). Drop the call from the
quick-start snippet and move the destructive contract into its own
"Destructive operations" section with the bricking warning, the
upstream issue link, and a gated-call example. API table now
cross-links to that section and write() docs the partial-write
semantics introduced in the previous commit.

Also retarget the hardware-test header comment: the integration
suite it pointed at (tests/integration/test_daplink_flash/) was
reverted in 3f522e2 for the same DAPLink#9 reason, so calling it
"lives in tests/integration" sent maintainers chasing a phantom
file.
@nedseb
Copy link
Copy Markdown
Contributor Author

nedseb commented May 27, 2026

@DumontALINE, récap des modifications côté review depuis ton dernier commit (767c845), pour que tu retrouves le fil. Tout est sur feat/daplink-flash-driver, CI verte, 95/95 tests natifs.

Tests + revert intégration

  • 2bb70f5 test(daplink_flash): Add hardware + integration test suites. — j'ai ajouté tests/hardware/test_daplink_flash/ (non-destructif, begin() + getFilename printable ASCII) et tests/integration/test_daplink_flash/ (cycle clearFlash → setFilename → writeLine → readUntilSentinel).
  • 3f522e2 revert(daplink_flash): Drop integration test that bricks the board. — j'ai dû reverter la suite intégration parce que clearFlash() met la carte en mode maintenance DAPLink. Bug firmware tracké dans steamicc/DAPLink#9. On réactivera la suite quand le firmware sera corrigé.
  • 220afcb fix(daplink_flash): Propagate clearFlash() bridge error to caller.clearFlash() retourne maintenant bool (propage _bridge->sendCommand au lieu de l'avaler).

Wave 1 : bug critique de framing I2C

  • 8b3b27f fix(daplink_bridge): Stream readResponse without resetting cursor. — l'ancienne readResponse recalculait REG_RESPONSE + produced à chaque chunk : 0x82 + 0x7D == 0xFF, donc le 6ᵉ chunk wrappait à 0x00 et lisait n'importe quoi. En plus l'appel waitNotBusy() entre les chunks remettait le curseur firmware à zéro. Réécriture : émission de la sélection une fois, puis stream contigu via requestFrom() successifs.

Wave 2 : 7 fixes Copilot (commit 9c6cc64)

  • setFilename retourne maintenant bool (propage null-check et erreur sendCommand).
  • Test test_set_filename_sends_correct_payload : off-by-one writes.size() >= 11 (était >= 10, accès writes[10] hors borne).
  • Hardware setUp() TEST_ASSERT_TRUE_MESSAGE(flash.begin(), ...) au lieu d'ignorer le retour.
  • Wire.h mock : requestFrom() retire l'entrée commands_ correspondante quand il s'agit d'un pointer-select avant lecture (sinon faux positifs dans getCommands()).
  • README quick start : avertissement destructif sur clearFlash().
  • Docstring readResponse mise à jour pour décrire les retours partiels.
  • Le rename daplink_flash.{h,cpp}DaplinkFlash.{h,cpp} (case PascalCase pour matcher DaplinkBridge.h/HTS221.h/...) a été déféré à l'issue #177 pour garder la PR centrée sur les fixes runtime.

Wave 3 : framing protocolaire + README (commits e6842dc + c61116c)

  • readResponse ne prend plus de cmd en paramètre. L'ancienne version ré-émettait le cmd byte pour "sélectionner" le stream de réponse, ce que le firmware peut interpréter comme une nouvelle commande (et écraser la réponse) ou comme un BAD_PARAM sur les cmd à payload obligatoire. Maintenant readResponse(buf, len) lit depuis le registre dédié DAPLINK_BRIDGE_REG_RESPONSE (0x82) qui était défini mais jamais utilisé.
  • Les deux callers font maintenant sendCommand puis readResponse(buf, len) : getFilename() actionne CMD_GET_FILENAME avant de slurper la réponse ; readSector() actionne CMD_READ_SECTOR avec le payload puis slurpe — plus aucun double-write du cmd.
  • readResponse attend not-busy, vérifie error(), et propage un endTransmission() non nul au lieu de retourner un partial silencieux.
  • write() retourne maintenant le nombre d'octets effectivement écrits sur échec en milieu de stream (était 0). L'appelant peut distinguer un échec propre (return == 0) d'une écriture partielle (0 < return < length) — important parce que l'écriture est append-only et non-atomique, et un retry naïf dupliquerait le préfixe.
  • README : clearFlash() est retiré du quick start, déplacé dans une section "Destructive operations" avec l'avertissement de bricking, le lien DAPLink#9 et un exemple gated (bouton GPIO).
  • Commentaire d'en-tête du test hardware corrigé (ne pointe plus vers le test intégration fantôme).

À surveiller

  • Issue #177 — rename PascalCase (séparé pour rester focused).
  • DAPLink#9 — quand le firmware est corrigé, on réactive tests/integration/test_daplink_flash/.

Si tu veux relire un point précis : git show <hash> sur n'importe lequel ci-dessus, ou git log feat/daplink-flash-driver --since "$(git log -1 --format=%cI 767c845)" pour voir tout le delta.

- test_read_sector_streams_full_256_bytes_contiguously: verifies a
  256-byte sector round-trips byte-for-byte. Catches a regression
  to the REG_RESPONSE + produced recompute (which wrapped at 0xFF)
  or any code path that re-selects the read pointer between
  MAX_READ_CHUNK chunks.
- test_write_returns_partial_offset_on_midstream_failure: schedules
  REG_ERROR after the 2nd chunk so the 1st succeeds and the 2nd
  fails, then asserts write() returns one chunk's worth of bytes
  instead of 0.
- test_set_filename_rejects_null_name / null_ext /
  propagates_bridge_error / returns_true_on_success: lock in the
  new bool return surface for setFilename.

Wire mock: new setRegisterAfterNWrites() helper so tests can stage
a device-side state change (e.g. flip REG_ERROR) mid-stream after
a counted number of multi-byte transactions.
@nedseb
Copy link
Copy Markdown
Contributor Author

nedseb commented May 27, 2026

@DumontALINE un complément au récap précédent : pour chaque bug corrigé, j'ai ajouté un test de non-régression natif. Le commit dédié est 639974e test(daplink_flash): Lock in wave-1/2/3 fixes with regression tests..

Couverture par bug :

Bug Commit fix Test de non-régression
readResponse wrap à REG_RESPONSE + produced au 6ᵉ chunk (0x82+0x7D=0xFF) 8b3b27f test_read_sector_streams_full_256_bytes_contiguously — lit un secteur complet de 256 octets avec valeurs distinctes par offset, échoue si un chunk au-delà du wrap renvoie des zéros.
readResponse ré-écrit le cmd byte comme sélecteur de lecture e6842dc Même test que ci-dessus + test_read_stops_at_sentinel / test_read_limited_by_maxlen qui sont configurés pour setResponse(ADDR, REG_RESPONSE, ...) — un retour vers cmd comme sélecteur ne trouve plus la queue et renvoie des zéros.
write() retournait 0 sur échec en milieu de stream e6842dc test_write_returns_partial_offset_on_midstream_failure — schedule REG_ERROR après la 2ᵉ trame multi-octet, vérifie que write() retourne MAX_WRITE_CHUNK (1 chunk écrit), pas 0.
setFilename ne retournait pas bool (null/erreur silencieux) 9c6cc64 4 nouveaux tests : test_set_filename_rejects_null_name, test_set_filename_rejects_null_ext, test_set_filename_propagates_bridge_error, test_set_filename_returns_true_on_success.

J'en ai profité pour ajouter à tests/native/helpers/Wire.h un helper setRegisterAfterNWrites(n, reg, value) : permet d'injecter un changement d'état device-side après la Nᵉ transaction multi-octet (utile pour reproduire un échec en milieu de stream sans toucher au code production). C'est aussi ce qui rend le test partial-write fiable.

Total : 101/101 tests natifs verts, CI verte sur la branche.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

test(daplink_flash): Add mock unit tests. feat(daplink_flash): Implement Arduino driver.

4 participants