sd put: copy a file to the SD card over USB serial

scripts/sd_put.sh <file> [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 <path>.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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-04 02:31:19 +02:00
co-authored by Claude Opus 5.5
parent 8ec4e9e449
commit 5b199c2436
7 changed files with 575 additions and 1 deletions
+1 -1
View File
@@ -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. 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 <file.ota>` 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. **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.
+114
View File
@@ -0,0 +1,114 @@
#include "file_receiver.h"
#include <algorithm>
#include <cstdlib>
#include <cstring>
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<std::string> words(const std::string& s) {
std::vector<std::string> 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<std::string> w = words(args);
if (w.size() != 3) return "usage: sd put <path> <size> <sha256>";
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<uint32_t>(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<uint8_t>(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<size_t>(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<uint32_t>(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
+66
View File
@@ -0,0 +1,66 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
#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 <path> <size> <sha256>`, 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 `<path>.part` and is
// renamed to `<path>` 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 "<path> <size> <sha256 hex>". 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<uint8_t>& 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<uint8_t> chunk_;
uint32_t lastActivityMs_ = 0;
std::string error_;
};
} // namespace roro
+79
View File
@@ -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 <file> [card path]
The card path defaults to /updates/<file name>, 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 <card path>.part to <card path>.
"""
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()
+10
View File
@@ -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 <file> [card path] (default card path: /updates/<file name>)
set -euo pipefail
source "$(dirname "$0")/_docker.sh"
[ -f "${1:-}" ] || { echo "Usage: scripts/sd_put.sh <file> [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}"
+103
View File
@@ -2,6 +2,9 @@
#include <M5Cardputer.h> #include <M5Cardputer.h>
#include <SD.h> #include <SD.h>
#include <atomic>
#include <memory>
#include "app_manager.h" #include "app_manager.h"
#include "apps/demo_app.h" #include "apps/demo_app.h"
#include "apps/irc_app.h" #include "apps/irc_app.h"
@@ -10,6 +13,7 @@
#include "apps/wifi_tools_app.h" #include "apps/wifi_tools_app.h"
#include "apps/setup_app.h" #include "apps/setup_app.h"
#include "event_bus.h" #include "event_bus.h"
#include "file_receiver.h"
#include "key_mapper.h" #include "key_mapper.h"
#include "platform/nvs_store.h" #include "platform/nvs_store.h"
#include "service_manager.h" #include "service_manager.h"
@@ -97,6 +101,7 @@ extern "C" bool verifyRollbackLater() { return true; } // C linkage, or the wea
void setup() { void setup() {
nvs.begin(); nvs.begin();
UpdateService::bootGuard(nvs); // first: before anything that could crash on new firmware 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(); auto cfg = M5.config();
M5Cardputer.begin(cfg, true); 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<int> uploadResult{-1}; // set by a job: 1 ok, 0 failed; -1 nothing new
static bool uploadJobRunning = false;
static void uploadJob(std::function<bool()> 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<size_t>(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<std::vector<uint8_t>>(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() { static void serialCommands() {
if (upload.active()) return readUploadBytes(); // raw file bytes, not commands
static String line; static String line;
while (Serial.available()) { while (Serial.available()) {
char c = Serial.read(); char c = Serial.read();
@@ -202,6 +303,7 @@ static void serialCommands() {
f.close(); f.close();
} }
} }
if (line.startsWith("sd put ")) startUpload(line.substring(7)); // then raw bytes: see uploadStep()
if (line == "sd list") { if (line == "sd list") {
storageService->requestListing(); storageService->requestListing();
listingWanted = true; listingWanted = true;
@@ -260,6 +362,7 @@ void loop() {
uint32_t now = millis(); uint32_t now = millis();
serialCommands(); serialCommands();
uploadStep();
printListingWhenReady(); printListingWhenReady();
M5Cardputer.update(); M5Cardputer.update();
if (M5Cardputer.Keyboard.isChange()) { if (M5Cardputer.Keyboard.isChange()) {
@@ -0,0 +1,202 @@
#include <unity.h>
#include <string>
#include <vector>
#include "file_receiver.h"
#include "sha256.h"
using namespace roro;
void setUp() {}
void tearDown() {}
static std::string hexSha(const std::vector<uint8_t>& 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<uint8_t> bytes(size_t n) {
std::vector<uint8_t> v(n);
for (size_t i = 0; i < n; i++) v[i] = static_cast<uint8_t>(i * 7 + 3);
return v;
}
static std::string args(const std::string& path, const std::vector<uint8_t>& 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();
}