From 5b199c2436661445e28bf8c9d24ab95fed7ae843 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Sun, 4 Oct 2026 02:27:58 +0200 Subject: [PATCH] sd put: copy a file to the SD card over USB serial scripts/sd_put.sh [card path] sends a file (by default into /updates, for Update from SD) without taking the card out. The serial driver drops bytes once its receive buffer is full, so the transfer is stop-and-wait: 1 KB chunks, each acknowledged once the Storage Service has written it, into a 2 KB receive buffer. The device checks the SHA-256 before renaming .part into place, and gives up after 5 s of silence or a card job that never returns. FileReceiver holds the logic, with 12 host tests. About 55 KB/s: 1.6 MB in under 30 s. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT --- README.md | 2 +- lib/ota/src/file_receiver.cpp | 114 ++++++++++ lib/ota/src/file_receiver.h | 66 ++++++ scripts/sd_put.py | 79 +++++++ scripts/sd_put.sh | 10 + src/main.cpp | 103 +++++++++ .../test_file_receiver/test_file_receiver.cpp | 202 ++++++++++++++++++ 7 files changed, 575 insertions(+), 1 deletion(-) create mode 100644 lib/ota/src/file_receiver.cpp create mode 100644 lib/ota/src/file_receiver.h create mode 100755 scripts/sd_put.py create mode 100755 scripts/sd_put.sh create mode 100644 test/test_file_receiver/test_file_receiver.cpp diff --git a/README.md b/README.md index b81fc26..2061bab 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ scripts/flash.sh --ota 10.39.39.12 # build, sign and push; or set RORO_OTA_H The device shows the push address in **Settings → Firmware**. It installs a correctly signed update right away, restarts (waiting up to 60 s if you're typing), and runs the new firmware on **Probation**. If the new firmware crashes, or can't reconnect Wi-Fi within 3 minutes, it rolls back to the previous one and says so. -To install from the SD card instead, copy the `.ota` file from `.pio/build/cardputer-adv/` into `/updates` on the card, then use **Settings → Firmware**. +To install from the SD card instead, copy the `.ota` file from `.pio/build/cardputer-adv/` into `/updates` on the card, then use **Settings → Firmware**. With the Cardputer on USB, the card can stay in: `scripts/sd_put.sh ` sends it over the serial console into `/updates` (about 30 s for 1.6 MB, checked with SHA-256 before it's renamed into place; `SD_PUT_DEBUG=1` shows the console while it runs). **The private key** lives in `~/.config/roro9stack/ota-key.pem` and must never be committed. If it's lost, generate a new pair and flash once over USB. diff --git a/lib/ota/src/file_receiver.cpp b/lib/ota/src/file_receiver.cpp new file mode 100644 index 0000000..ec28acb --- /dev/null +++ b/lib/ota/src/file_receiver.cpp @@ -0,0 +1,114 @@ +#include "file_receiver.h" + +#include +#include +#include + +namespace roro { + +namespace { + +int hexDigit(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +// Splits on spaces (no iostreams: they cost about 200 KB of flash on the device). +std::vector words(const std::string& s) { + std::vector out; + size_t i = 0; + while (i < s.size()) { + if (s[i] == ' ') { + i++; + continue; + } + size_t end = s.find(' ', i); + if (end == std::string::npos) end = s.size(); + out.push_back(s.substr(i, end - i)); + i = end; + } + return out; +} + +} // namespace + +std::string FileReceiver::begin(const std::string& args, uint32_t nowMs) { + reset(); + std::vector w = words(args); + if (w.size() != 3) return "usage: sd put "; + const std::string &path = w[0], &size = w[1], &sha = w[2]; + if (path.empty() || path[0] != '/' || path.size() > 128 || path.find("..") != std::string::npos) + return "the path must be absolute, without .."; + if (size.empty() || size.size() > 9 || size.find_first_not_of("0123456789") != std::string::npos) + return "bad size"; + uint32_t bytes = static_cast(std::strtoul(size.c_str(), nullptr, 10)); + if (bytes == 0 || bytes > kMaxBytes) return "bad size"; + if (sha.size() != 64) return "bad sha256"; + for (size_t i = 0; i < 32; i++) { + int hi = hexDigit(sha[2 * i]), lo = hexDigit(sha[2 * i + 1]); + if (hi < 0 || lo < 0) return "bad sha256"; + expected_[i] = static_cast(hi << 4 | lo); + } + path_ = path; + size_ = bytes; + lastActivityMs_ = nowMs; + chunk_.reserve(kChunk); + state_ = State::Receiving; + return ""; +} + +size_t FileReceiver::wanted() const { + if (state_ != State::Receiving) return 0; + return std::min(kChunk, size_ - received_) - chunk_.size(); +} + +size_t FileReceiver::feed(const uint8_t* data, size_t len, uint32_t nowMs) { + if (state_ != State::Receiving || len == 0) return 0; + size_t take = std::min(len, wanted()); + chunk_.insert(chunk_.end(), data, data + take); + sha_.update(data, take); + lastActivityMs_ = nowMs; + if (wanted() > 0) return take; + + if (received_ + chunk_.size() == size_) { + // The last chunk: refuse it before writing if the file arrived damaged. + uint8_t got[32]; + sha_.finish(got); + if (std::memcmp(got, expected_, sizeof got) != 0) { + fail("checksum mismatch"); + return take; + } + } + state_ = State::Writing; + return take; +} + +void FileReceiver::chunkWritten(bool ok, uint32_t nowMs) { + if (state_ != State::Writing) return; + if (!ok) return fail("write failed"); + received_ += static_cast(chunk_.size()); + chunk_.clear(); + lastActivityMs_ = nowMs; + state_ = received_ == size_ ? State::Finishing : State::Receiving; +} + +void FileReceiver::finished(bool ok) { + if (state_ != State::Finishing) return; + if (!ok) return fail("rename failed"); + state_ = State::Done; +} + +void FileReceiver::tick(uint32_t nowMs) { + if (state_ != State::Receiving && state_ != State::Writing && state_ != State::Finishing) return; + if (nowMs - lastActivityMs_ >= kTimeoutMs) fail(state_ == State::Receiving ? "timed out" : "card not writable"); +} + +void FileReceiver::fail(const char* why) { + error_ = why; + chunk_.clear(); + state_ = State::Failed; +} + +} // namespace roro diff --git a/lib/ota/src/file_receiver.h b/lib/ota/src/file_receiver.h new file mode 100644 index 0000000..49502d1 --- /dev/null +++ b/lib/ota/src/file_receiver.h @@ -0,0 +1,66 @@ +#pragma once + +#include +#include +#include +#include + +#include "sha256.h" + +namespace roro { + +// One file sent over the USB serial console (scripts/sd_put.py), to put an Update File on the SD +// card without taking the card out. The sender writes `sd put `, then the raw +// bytes, one chunk at a time, and waits for each chunk to be written before sending the next: the +// serial driver drops bytes once its receive buffer is full. The file lands as `.part` and is +// renamed to `` only once every byte has arrived and the checksum matches. +class FileReceiver { + public: + static constexpr size_t kChunk = 1024; // must stay below the serial receive buffer + static constexpr uint32_t kTimeoutMs = 5000; // silence, or a card job that never completes + static constexpr uint32_t kMaxBytes = 8u << 20; // larger than any app partition + + enum class State { + Idle, + Receiving, // waiting for bytes of the current chunk + Writing, // chunk() is complete: write it to partPath(), then call chunkWritten() + Finishing, // everything written and checked: rename partPath() to path(), then finished() + Done, + Failed, // error() says why; partPath() should be removed + }; + + // Parses " ". Returns "" when the transfer starts, or what's wrong. + std::string begin(const std::string& args, uint32_t nowMs); + // Takes bytes for the current chunk; returns how many were used (none while a chunk waits). + size_t feed(const uint8_t* data, size_t len, uint32_t nowMs); + void chunkWritten(bool ok, uint32_t nowMs); + void finished(bool ok); + void tick(uint32_t nowMs); + void reset() { *this = FileReceiver(); } + + State state() const { return state_; } + bool active() const { return state_ != State::Idle; } + // Bytes still missing from the current chunk: read no more than this from the serial port. + size_t wanted() const; + const std::vector& chunk() const { return chunk_; } + const std::string& path() const { return path_; } + std::string partPath() const { return path_ + ".part"; } + uint32_t size() const { return size_; } + uint32_t received() const { return received_; } + const std::string& error() const { return error_; } + + private: + void fail(const char* why); + + State state_ = State::Idle; + std::string path_; + uint32_t size_ = 0; + uint32_t received_ = 0; // bytes in chunks already written + uint8_t expected_[32] = {}; + Sha256 sha_; + std::vector chunk_; + uint32_t lastActivityMs_ = 0; + std::string error_; +}; + +} // namespace roro diff --git a/scripts/sd_put.py b/scripts/sd_put.py new file mode 100755 index 0000000..c979f42 --- /dev/null +++ b/scripts/sd_put.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Copies a file to the Cardputer's SD card over the USB serial console (the `sd put` command). + +Usage: scripts/sd_put.sh [card path] +The card path defaults to /updates/, where Settings > Firmware finds Update Files. +The device acknowledges each chunk once it's on the card, checks the SHA-256 of the whole file, +and only then renames .part to . +""" +import glob +import hashlib +import os +import sys +import time + +import serial + +DEBUG = bool(os.environ.get("SD_PUT_DEBUG")) # also show the console lines in between + + +def find_port(): + ports = sorted(glob.glob("/dev/serial/by-id/*Espressif*")) + return os.path.realpath(ports[0]) if ports else None + + +def reply(port, timeout): + """The next `sd put:` line from the device; everything else on the console is skipped.""" + deadline = time.time() + timeout + while time.time() < deadline: + line = port.readline().decode(errors="replace").strip() + if line.startswith("sd put:"): + return line[len("sd put:"):].strip() + if line and DEBUG: + print(f"\n console: {line}", file=sys.stderr) + return "error no answer from the device" + + +def fail(answer): + print() + sys.exit(f"device: {answer}") + + +def main(): + if len(sys.argv) < 2: + sys.exit(__doc__) + path = sys.argv[1] + dest = sys.argv[2] if len(sys.argv) > 2 else "/updates/" + os.path.basename(path) + port_name = find_port() + if not port_name: + sys.exit("No Cardputer found on USB") + data = open(path, "rb").read() + sha = hashlib.sha256(data).hexdigest() + + with serial.Serial(port_name, 115200, timeout=0.5) as port: + port.reset_input_buffer() + # The leading newline ends any half-typed command. + port.write(f"\nsd put {dest} {len(data)} {sha}\n".encode()) + answer = reply(port, 10) + if not answer.startswith("ready "): + fail(answer) + chunk = int(answer.split()[1]) + + start, sent = time.time(), 0 + while sent < len(data): + port.write(data[sent:sent + chunk]) + sent = min(sent + chunk, len(data)) + print(f"\rsending {sent * 100 // len(data):3d}%", end="", flush=True) + if sent < len(data): + answer = reply(port, 10) + if answer != f"ok {sent}": + fail(answer) + answer = reply(port, 15) + if not answer.startswith("done "): + fail(answer) + seconds = time.time() - start + print(f"\rdevice: {answer}, {seconds:.1f} s ({len(data) / 1024 / seconds:.0f} KB/s)") + + +if __name__ == "__main__": + main() diff --git a/scripts/sd_put.sh b/scripts/sd_put.sh new file mode 100755 index 0000000..602bdb4 --- /dev/null +++ b/scripts/sd_put.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Copy a file to the Cardputer's SD card over USB serial, e.g. an Update File into /updates. +# Usage: scripts/sd_put.sh [card path] (default card path: /updates/) +set -euo pipefail +source "$(dirname "$0")/_docker.sh" +[ -f "${1:-}" ] || { echo "Usage: scripts/sd_put.sh [card path]" >&2; exit 1; } +docker rm -f roro9stack-serial >/dev/null 2>&1 || true # a serial log would hold the port +FILE="$(realpath "$1")" +DOCKER_EXTRA=(-e SD_PUT_DEBUG="${SD_PUT_DEBUG:-}" --group-add "$(getent group dialout | cut -d: -f3)" --privileged -v /dev:/dev -v "$(dirname "$FILE"):/in:ro") +run_in_container /pio/penv/bin/python scripts/sd_put.py "/in/$(basename "$FILE")" "${@:2}" diff --git a/src/main.cpp b/src/main.cpp index f11cdc0..d976cf7 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2,6 +2,9 @@ #include #include +#include +#include + #include "app_manager.h" #include "apps/demo_app.h" #include "apps/irc_app.h" @@ -10,6 +13,7 @@ #include "apps/wifi_tools_app.h" #include "apps/setup_app.h" #include "event_bus.h" +#include "file_receiver.h" #include "key_mapper.h" #include "platform/nvs_store.h" #include "service_manager.h" @@ -97,6 +101,7 @@ extern "C" bool verifyRollbackLater() { return true; } // C linkage, or the wea void setup() { nvs.begin(); UpdateService::bootGuard(nvs); // first: before anything that could crash on new firmware + Serial.setRxBufferSize(2 * FileReceiver::kChunk); // before the port opens; sd put sends 1 chunk at a time auto cfg = M5.config(); M5Cardputer.begin(cfg, true); @@ -158,7 +163,103 @@ static void printListingWhenReady() { } } +// sd put: a file sent over serial by scripts/sd_put.py. The main loop reads the bytes; every card +// access runs as a Storage Service job, which reports back through uploadResult. +static FileReceiver upload; +static std::atomic uploadResult{-1}; // set by a job: 1 ok, 0 failed; -1 nothing new +static bool uploadJobRunning = false; + +static void uploadJob(std::function job) { + uploadJobRunning = true; + storageService->runJob([job]() { uploadResult = job() ? 1 : 0; }); +} + +static void startUpload(const String& args) { + if (upload.active() || uploadJobRunning) return (void)Serial.println("sd put: error busy"); + std::string error = upload.begin(args.c_str(), millis()); + StorageState card = storageService->state(); + if (error.empty() && !card.present) error = "no SD card"; + if (error.empty() && card.totalBytes - card.usedBytes < upload.size() + 64 * 1024) error = "not enough space"; + if (!error.empty()) { + upload.reset(); + return (void)Serial.printf("sd put: error %s\n", error.c_str()); + } + std::string part = upload.partPath(); + uploadJob([part]() { + for (size_t slash = part.find('/', 1); slash != std::string::npos; slash = part.find('/', slash + 1)) { + std::string dir = part.substr(0, slash); + if (!SD.exists(dir.c_str()) && !SD.mkdir(dir.c_str())) return false; + } + if (SD.exists(part.c_str())) SD.remove(part.c_str()); + return true; + }); +} + +static void readUploadBytes() { + uint8_t buf[256]; + size_t want; + while (!uploadJobRunning && (want = std::min(upload.wanted(), sizeof buf)) > 0 && Serial.available() > 0) { + size_t n = Serial.read(buf, std::min(want, Serial.available())); + upload.feed(buf, n, millis()); + } +} + +static void uploadStep() { + using S = FileReceiver::State; + uint32_t now = millis(); + int result = uploadResult.exchange(-1); + if (result >= 0) { + uploadJobRunning = false; + bool ok = result == 1; + switch (upload.state()) { + case S::Receiving: // the folder job from startUpload: the sender may start + if (ok) Serial.printf("sd put: ready %u\n", (unsigned)FileReceiver::kChunk); + else { + upload.reset(); + return (void)Serial.println("sd put: error card not writable"); + } + break; + case S::Writing: + upload.chunkWritten(ok, now); + if (upload.state() == S::Receiving) Serial.printf("sd put: ok %u\n", (unsigned)upload.received()); + break; + case S::Finishing: upload.finished(ok); break; + default: break; // a job that finished after the transfer failed + } + } + if (!upload.active()) return; + upload.tick(now); + std::string path = upload.path(), part = upload.partPath(); + if (upload.state() == S::Failed) { + // Reported even if a job never came back (no card: the storage task drops it). + Serial.printf("sd put: error %s\n", upload.error().c_str()); + storageService->runJob([part]() { SD.remove(part.c_str()); }); + upload.reset(); + uploadJobRunning = false; + return; + } + if (uploadJobRunning) return; + if (upload.state() == S::Writing) { + auto chunk = std::make_shared>(upload.chunk()); + uploadJob([chunk, part]() { + File f = SD.open(part.c_str(), FILE_APPEND); + bool ok = f && f.write(chunk->data(), chunk->size()) == chunk->size(); + if (f) f.close(); + return ok; + }); + } else if (upload.state() == S::Finishing) { + uploadJob([path, part]() { + if (SD.exists(path.c_str())) SD.remove(path.c_str()); + return SD.rename(part.c_str(), path.c_str()); + }); + } else if (upload.state() == S::Done) { + Serial.printf("sd put: done %s %u B\n", path.c_str(), (unsigned)upload.size()); + upload.reset(); + } +} + static void serialCommands() { + if (upload.active()) return readUploadBytes(); // raw file bytes, not commands static String line; while (Serial.available()) { char c = Serial.read(); @@ -202,6 +303,7 @@ static void serialCommands() { f.close(); } } + if (line.startsWith("sd put ")) startUpload(line.substring(7)); // then raw bytes: see uploadStep() if (line == "sd list") { storageService->requestListing(); listingWanted = true; @@ -260,6 +362,7 @@ void loop() { uint32_t now = millis(); serialCommands(); + uploadStep(); printListingWhenReady(); M5Cardputer.update(); if (M5Cardputer.Keyboard.isChange()) { diff --git a/test/test_file_receiver/test_file_receiver.cpp b/test/test_file_receiver/test_file_receiver.cpp new file mode 100644 index 0000000..69208ce --- /dev/null +++ b/test/test_file_receiver/test_file_receiver.cpp @@ -0,0 +1,202 @@ +#include + +#include +#include + +#include "file_receiver.h" +#include "sha256.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +static std::string hexSha(const std::vector& data) { + uint8_t d[32]; + Sha256::hash(data.data(), data.size(), d); + static const char* hex = "0123456789abcdef"; + std::string s; + for (uint8_t b : d) { + s += hex[b >> 4]; + s += hex[b & 15]; + } + return s; +} + +static std::vector bytes(size_t n) { + std::vector v(n); + for (size_t i = 0; i < n; i++) v[i] = static_cast(i * 7 + 3); + return v; +} + +static std::string args(const std::string& path, const std::vector& data) { + return path + " " + std::to_string(data.size()) + " " + hexSha(data); +} + +void test_begin_accepts_path_size_and_checksum() { + FileReceiver r; + auto data = bytes(10); + TEST_ASSERT_EQUAL_STRING("", r.begin(args("/updates/a.ota", data), 0).c_str()); + TEST_ASSERT_TRUE(r.state() == FileReceiver::State::Receiving); + TEST_ASSERT_EQUAL_STRING("/updates/a.ota", r.path().c_str()); + TEST_ASSERT_EQUAL_STRING("/updates/a.ota.part", r.partPath().c_str()); + TEST_ASSERT_EQUAL_UINT32(10, r.size()); +} + +void test_begin_refuses_bad_arguments() { + FileReceiver r; + std::string sha(64, 'a'); + TEST_ASSERT_NOT_EQUAL(0, r.begin("updates/a.ota 10 " + sha, 0).size()); // not absolute + TEST_ASSERT_NOT_EQUAL(0, r.begin("/a/../b.ota 10 " + sha, 0).size()); // climbs out + TEST_ASSERT_NOT_EQUAL(0, r.begin("/a.ota 0 " + sha, 0).size()); // empty + TEST_ASSERT_NOT_EQUAL(0, r.begin("/a.ota 99999999 " + sha, 0).size()); // too big + TEST_ASSERT_NOT_EQUAL(0, r.begin("/a.ota 1x " + sha, 0).size()); // not a number + TEST_ASSERT_NOT_EQUAL(0, r.begin("/a.ota 10 abc", 0).size()); // short checksum + TEST_ASSERT_NOT_EQUAL(0, r.begin("/a.ota 10 " + std::string(64, 'g'), 0).size()); + TEST_ASSERT_NOT_EQUAL(0, r.begin("/a.ota 10", 0).size()); + TEST_ASSERT_TRUE(r.state() == FileReceiver::State::Idle); +} + +void test_bytes_are_handed_out_one_chunk_at_a_time() { + FileReceiver r; + auto data = bytes(FileReceiver::kChunk * 2 + 100); + r.begin(args("/f.bin", data), 0); + + // Everything arrives at once: only the first chunk is taken. + TEST_ASSERT_EQUAL(FileReceiver::kChunk, r.feed(data.data(), data.size(), 1)); + TEST_ASSERT_TRUE(r.state() == FileReceiver::State::Writing); + TEST_ASSERT_EQUAL(FileReceiver::kChunk, r.chunk().size()); + TEST_ASSERT_EQUAL(0, r.feed(data.data() + FileReceiver::kChunk, 10, 2)); // nothing more while writing + + r.chunkWritten(true, 3); + TEST_ASSERT_TRUE(r.state() == FileReceiver::State::Receiving); + TEST_ASSERT_EQUAL_UINT32(FileReceiver::kChunk, r.received()); + + r.feed(data.data() + FileReceiver::kChunk, FileReceiver::kChunk, 4); + r.chunkWritten(true, 5); + // The last, short chunk is complete as soon as the announced size is reached. + TEST_ASSERT_EQUAL(100, r.feed(data.data() + 2 * FileReceiver::kChunk, 100, 6)); + TEST_ASSERT_TRUE(r.state() == FileReceiver::State::Writing); + TEST_ASSERT_EQUAL(100, r.chunk().size()); + TEST_ASSERT_EQUAL_UINT8(data[2 * FileReceiver::kChunk], r.chunk()[0]); + + r.chunkWritten(true, 7); + TEST_ASSERT_TRUE(r.state() == FileReceiver::State::Finishing); + r.finished(true); + TEST_ASSERT_TRUE(r.state() == FileReceiver::State::Done); +} + +void test_chunk_split_across_reads() { + FileReceiver r; + auto data = bytes(30); + r.begin(args("/f.bin", data), 0); + TEST_ASSERT_EQUAL(10, r.feed(data.data(), 10, 1)); + TEST_ASSERT_TRUE(r.state() == FileReceiver::State::Receiving); + TEST_ASSERT_EQUAL(20, r.feed(data.data() + 10, 20, 2)); + TEST_ASSERT_TRUE(r.state() == FileReceiver::State::Writing); + TEST_ASSERT_EQUAL(30, r.chunk().size()); +} + +void test_wrong_checksum_fails_before_the_last_write() { + FileReceiver r; + auto data = bytes(50); + r.begin(args("/f.bin", data), 0); + data[20] ^= 1; // corrupted on the way + r.feed(data.data(), data.size(), 1); + TEST_ASSERT_TRUE(r.state() == FileReceiver::State::Failed); + TEST_ASSERT_EQUAL_STRING("checksum mismatch", r.error().c_str()); +} + +void test_failed_write_fails_the_transfer() { + FileReceiver r; + auto data = bytes(50); + r.begin(args("/f.bin", data), 0); + r.feed(data.data(), data.size(), 1); + r.chunkWritten(false, 2); + TEST_ASSERT_TRUE(r.state() == FileReceiver::State::Failed); + TEST_ASSERT_EQUAL_STRING("write failed", r.error().c_str()); +} + +void test_failed_rename_fails_the_transfer() { + FileReceiver r; + auto data = bytes(5); + r.begin(args("/f.bin", data), 0); + r.feed(data.data(), data.size(), 1); + r.chunkWritten(true, 2); + r.finished(false); + TEST_ASSERT_TRUE(r.state() == FileReceiver::State::Failed); +} + +void test_silence_times_out() { + FileReceiver r; + auto data = bytes(FileReceiver::kChunk * 2); + r.begin(args("/f.bin", data), 1000); + r.tick(1000 + FileReceiver::kTimeoutMs - 1); + TEST_ASSERT_TRUE(r.state() == FileReceiver::State::Receiving); + r.feed(data.data(), 10, 5000); // bytes reset the clock + r.tick(5000 + FileReceiver::kTimeoutMs - 1); + TEST_ASSERT_TRUE(r.state() == FileReceiver::State::Receiving); + r.tick(5000 + FileReceiver::kTimeoutMs); + TEST_ASSERT_TRUE(r.state() == FileReceiver::State::Failed); + TEST_ASSERT_EQUAL_STRING("timed out", r.error().c_str()); +} + +void test_a_write_that_never_completes_times_out() { + // With no card mounted, the storage task drops the job and never answers. + FileReceiver r; + auto data = bytes(10); + r.begin(args("/f.bin", data), 0); + r.feed(data.data(), data.size(), 100); + r.tick(100 + FileReceiver::kTimeoutMs); + TEST_ASSERT_TRUE(r.state() == FileReceiver::State::Failed); +} + +void test_wanted_counts_down_within_a_chunk() { + FileReceiver r; + auto data = bytes(FileReceiver::kChunk + 10); + TEST_ASSERT_EQUAL(0, r.wanted()); + r.begin(args("/f.bin", data), 0); + TEST_ASSERT_EQUAL(FileReceiver::kChunk, r.wanted()); + r.feed(data.data(), 24, 1); + TEST_ASSERT_EQUAL(FileReceiver::kChunk - 24, r.wanted()); + r.feed(data.data() + 24, FileReceiver::kChunk - 24, 2); + TEST_ASSERT_EQUAL(0, r.wanted()); // writing + r.chunkWritten(true, 3); + TEST_ASSERT_EQUAL(10, r.wanted()); // the short last chunk +} + +void test_a_rename_that_never_completes_times_out() { + FileReceiver r; + auto data = bytes(10); + r.begin(args("/f.bin", data), 0); + r.feed(data.data(), data.size(), 1); + r.chunkWritten(true, 2); + r.tick(2 + FileReceiver::kTimeoutMs); + TEST_ASSERT_TRUE(r.state() == FileReceiver::State::Failed); +} + +void test_reset_returns_to_idle() { + FileReceiver r; + auto data = bytes(10); + r.begin(args("/f.bin", data), 0); + r.reset(); + TEST_ASSERT_TRUE(r.state() == FileReceiver::State::Idle); + TEST_ASSERT_FALSE(r.active()); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_begin_accepts_path_size_and_checksum); + RUN_TEST(test_begin_refuses_bad_arguments); + RUN_TEST(test_bytes_are_handed_out_one_chunk_at_a_time); + RUN_TEST(test_chunk_split_across_reads); + RUN_TEST(test_wrong_checksum_fails_before_the_last_write); + RUN_TEST(test_failed_write_fails_the_transfer); + RUN_TEST(test_failed_rename_fails_the_transfer); + RUN_TEST(test_silence_times_out); + RUN_TEST(test_a_write_that_never_completes_times_out); + RUN_TEST(test_wanted_counts_down_within_a_chunk); + RUN_TEST(test_a_rename_that_never_completes_times_out); + RUN_TEST(test_reset_returns_to_idle); + return UNITY_END(); +}