From bd2ecb80a7157a7a760d544cf615b36babdd1d7e Mon Sep 17 00:00:00 2001 From: Valentin Kivachuk Burda Date: Thu, 2 Jul 2026 08:32:00 +0200 Subject: [PATCH] ota: seeder storage protocol (pull-to-folder) + motatool serve storage handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the device->host WRITE half of the mota-seeder link so a device can capture a .mota it is fetching off-mesh into the host folder (the same --dir used for serving), stored as .mota — e.g. to grab an exact copy of a device's firmware to build a delta against firmware you don't otherwise have. - MotaSeederProto: OP_STAT / OP_BEGIN / OP_WRITE / OP_SREAD / OP_FIN (keyed by mid). Resume needs no host bookkeeping: BEGIN 0xFF-fills the file; on reconnect the device SREADs the leaves and re-requests only missing blocks (same as flash resume). Partial = .mota.part, published to .mota on FIN. - motatool `serve --dir ` now also handles the storage ops on the same folder/connection (SeederCore gains the store dir; serve_loop frames the variable-length WRITE). Host round-trip test added. Firmware side (FolderMotaStore + `ota pull <#> ` + pause/resume) follows. --- src/helpers/ota/MotaSeederProto.h | 22 ++++++- tools/motatool/src/main.cpp | 2 +- tools/motatool/src/mota_format.h | 6 ++ tools/motatool/src/serve.cpp | 91 ++++++++++++++++++++++++-- tools/motatool/src/serve.h | 8 ++- tools/motatool/tests/test_motatool.cpp | 32 +++++++++ 6 files changed, 153 insertions(+), 8 deletions(-) diff --git a/src/helpers/ota/MotaSeederProto.h b/src/helpers/ota/MotaSeederProto.h index 51767c9c..e534075b 100644 --- a/src/helpers/ota/MotaSeederProto.h +++ b/src/helpers/ota/MotaSeederProto.h @@ -21,8 +21,19 @@ // MotaDesc wire (38 B): mid[4] target_id(4) fw_version(4) codec(1) flags(1) total_size(4) // leaves_off(4) block_count(4) payload_off(4) payload_size(4) // -// status: 0 = OK, non-zero = error (idx out of range, read past EOF, ...). On error the response carries -// no payload (just magic+op+status+xsum). +// --- STORAGE ops (device -> host WRITE): "pull to folder". The device is fetching a `.mota` off the mesh +// (e.g. a neighbour's self-served firmware it has no local copy of) and streams it into the host folder as +// `.mota`, so the exact image can be captured for later delta-building. The device still initiates. +// Resume needs NO host bookkeeping: OP_BEGIN 0xFF-fills the file and, on reconnect, the device OP_SREADs the +// merkle leaves and re-requests only the missing blocks (identical to flash-resume). All keyed by mid[4]. +// OP_STAT 0x04 args: mid(4) resp payload: present(1) total_size(4) +// OP_BEGIN 0x05 args: mid(4) total_size(4) resp: status OK (create/truncate, 0xFF-filled) +// OP_WRITE 0x06 args: mid(4) off(4) len(2) data(len) resp: status OK +// OP_SREAD 0x07 args: mid(4) off(4) len(2) resp payload: len bytes (0xFF = never written) +// OP_FIN 0x08 args: mid(4) resp: status OK (validate + make servable) +// +// status: 0 = OK, non-zero = error (idx out of range, read past EOF, no such stored file, ...). On error the +// response carries no payload (just magic+op+status+xsum). namespace mesh { namespace ota { @@ -35,10 +46,17 @@ static const uint8_t MOTA_SEEDER_RSP_MAGIC1 = 's'; static const uint8_t MS_OP_COUNT = 0x01; static const uint8_t MS_OP_DESCRIBE = 0x02; static const uint8_t MS_OP_READ = 0x03; +static const uint8_t MS_OP_STAT = 0x04; // storage: does .mota exist on the host? + its size +static const uint8_t MS_OP_BEGIN = 0x05; // storage: create/truncate .mota (0xFF-filled) +static const uint8_t MS_OP_WRITE = 0x06; // storage: write bytes at offset +static const uint8_t MS_OP_SREAD = 0x07; // storage: read bytes back (resume: recompute missing blocks) +static const uint8_t MS_OP_FIN = 0x08; // storage: transfer complete — validate + make servable static const uint8_t MS_STATUS_OK = 0x00; +static const uint8_t MS_STATUS_ERR = 0x01; static const uint16_t MOTA_DESC_WIRE = 38; // bytes of a MotaDesc on the wire (see layout above) +static const uint16_t MOTA_SEEDER_WRITE_MAX = 512; // max data bytes per OP_WRITE/OP_SREAD (bounds frames) } // namespace ota } // namespace mesh diff --git a/tools/motatool/src/main.cpp b/tools/motatool/src/main.cpp index 8596b9f3..c367c487 100644 --- a/tools/motatool/src/main.cpp +++ b/tools/motatool/src/main.cpp @@ -499,7 +499,7 @@ static int cmd_serve(const Args& a) { if (enable) { usleep(500000); t->write_str("ota folder on\r\n"); std::cout << "sent `ota folder on`\n"; } std::cout << "serving on " << target << " — Ctrl-C to stop\n"; - SeederCore core(folder); + SeederCore core(folder, a.get("dir")); // same folder doubles as "pull to folder" storage serve_loop(*t, core, verbose, [](const std::string& l) { std::cout << " [dev] " << l << "\n"; }, &g_stop); diff --git a/tools/motatool/src/mota_format.h b/tools/motatool/src/mota_format.h index d26ac64e..8a46c10f 100644 --- a/tools/motatool/src/mota_format.h +++ b/tools/motatool/src/mota_format.h @@ -84,9 +84,15 @@ static constexpr uint8_t MS_RSP_MAGIC0 = 'm', MS_RSP_MAGIC1 = 's'; static constexpr uint8_t MS_OP_COUNT = 0x01; // -> count(1) static constexpr uint8_t MS_OP_DESCRIBE = 0x02; // idx(1) -> MotaDesc(38) static constexpr uint8_t MS_OP_READ = 0x03; // idx(1) off(4) len(2) -> bytes +static constexpr uint8_t MS_OP_STAT = 0x04; // storage: mid(4) -> present(1) total(4) +static constexpr uint8_t MS_OP_BEGIN = 0x05; // storage: mid(4) total(4) -> OK (create 0xFF-filled) +static constexpr uint8_t MS_OP_WRITE = 0x06; // storage: mid(4) off(4) len(2) data(len) -> OK +static constexpr uint8_t MS_OP_SREAD = 0x07; // storage: mid(4) off(4) len(2) -> bytes (0xFF=unwritten) +static constexpr uint8_t MS_OP_FIN = 0x08; // storage: mid(4) -> OK (validate + make servable) static constexpr uint8_t MS_STATUS_OK = 0x00; static constexpr uint8_t MS_STATUS_ERR = 0x01; static constexpr uint16_t MOTA_DESC_WIRE = 38; +static constexpr uint16_t MOTA_SEEDER_WRITE_MAX = 512; // max data bytes per OP_WRITE/OP_SREAD // MotaDesc wire (38 B): mid[4] target_id(4) fw_version(4) codec(1) flags(1) total_size(4) // leaves_off(4) block_count(4) payload_off(4) payload_size(4) [+2 reserved] diff --git a/tools/motatool/src/serve.cpp b/tools/motatool/src/serve.cpp index ef89ed9f..0331e4c6 100644 --- a/tools/motatool/src/serve.cpp +++ b/tools/motatool/src/serve.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -64,6 +65,14 @@ std::array SeederCore::describe(const ServedMota& s) { return w; } +std::string SeederCore::store_path(const uint8_t mid[4], bool part) const { + static const char* H = "0123456789abcdef"; + std::string name; + for (int i = 0; i < 4; i++) { name += H[mid[i] >> 4]; name += H[mid[i] & 0xF]; } + name += part ? ".mota.part" : ".mota"; + return store_dir_ + "/" + name; +} + bool SeederCore::handle(uint8_t op, const uint8_t* args, size_t arglen, uint8_t& status, std::vector& payload) const { payload.clear(); @@ -89,6 +98,70 @@ bool SeederCore::handle(uint8_t op, const uint8_t* args, size_t arglen, payload.assign(s->bytes.begin() + off, s->bytes.begin() + off + len); return true; } + // --- STORAGE ops: "pull to folder" — capture a .mota the device is fetching off-mesh into . + // All keyed by mid[4]; a partial pull is .mota.part, published to .mota on OP_FIN. --- + if (op == MS_OP_STAT || op == MS_OP_BEGIN || op == MS_OP_WRITE || op == MS_OP_SREAD || op == MS_OP_FIN) { + if (store_dir_.empty() || arglen < 4) { status = MS_STATUS_ERR; return true; } + const std::string part = store_path(args, true), done = store_path(args, false); + + if (op == MS_OP_STAT) { // present + size (completed wins over partial) + struct stat sx{}; uint8_t present = 0; uint32_t total = 0; + if (::stat(done.c_str(), &sx) == 0) { present = 1; total = (uint32_t)sx.st_size; } + else if (::stat(part.c_str(), &sx) == 0) { present = 1; total = (uint32_t)sx.st_size; } + payload.push_back(present); + uint8_t tb[4]; wr_u32(tb, total); payload.insert(payload.end(), tb, tb + 4); + return true; + } + if (op == MS_OP_BEGIN) { // fresh 0xFF-filled partial (start from 0) + if (arglen < 8) { status = MS_STATUS_ERR; return true; } + uint32_t total = rd_u32(args + 4); + int fd = ::open(part.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (fd < 0) { status = MS_STATUS_ERR; return true; } + uint8_t ff[4096]; memset(ff, 0xFF, sizeof ff); + uint32_t left = total; bool ok = true; + while (left && ok) { uint32_t c = left < sizeof ff ? left : (uint32_t)sizeof ff; ok = ::write(fd, ff, c) == (ssize_t)c; left -= c; } + ::close(fd); + status = ok ? MS_STATUS_OK : MS_STATUS_ERR; + return true; + } + if (op == MS_OP_WRITE) { + if (arglen < 10) { status = MS_STATUS_ERR; return true; } + uint32_t off = rd_u32(args + 4); + uint16_t len = (uint16_t)(args[8] | (args[9] << 8)); + if (arglen < (size_t)10 + len) { status = MS_STATUS_ERR; return true; } + int fd = ::open(part.c_str(), O_WRONLY); + if (fd < 0) { status = MS_STATUS_ERR; return true; } + bool ok = ::pwrite(fd, args + 10, len, off) == (ssize_t)len; + ::close(fd); + status = ok ? MS_STATUS_OK : MS_STATUS_ERR; + return true; + } + if (op == MS_OP_SREAD) { // read back (resume: recompute missing blocks) + if (arglen < 10) { status = MS_STATUS_ERR; return true; } + uint32_t off = rd_u32(args + 4); + uint16_t len = (uint16_t)(args[8] | (args[9] << 8)); + const std::string src = (::access(part.c_str(), F_OK) == 0) ? part : done; + int fd = ::open(src.c_str(), O_RDONLY); + if (fd < 0) { status = MS_STATUS_ERR; return true; } + payload.resize(len); + bool ok = ::pread(fd, payload.data(), len, off) == (ssize_t)len; + ::close(fd); + if (!ok) { payload.clear(); status = MS_STATUS_ERR; } + return true; + } + if (op == MS_OP_FIN) { // light-validate (MAGIC + size) then publish + struct stat sx{}; + if (::stat(part.c_str(), &sx) != 0) { status = MS_STATUS_ERR; return true; } + uint8_t hd[8] = {0}; int fd = ::open(part.c_str(), O_RDONLY); + bool got = fd >= 0 && ::read(fd, hd, 8) == 8; + if (fd >= 0) ::close(fd); + bool magic = got && hd[0] == 'm' && hd[1] == 'O' && hd[2] == 'T' && hd[3] == 'A'; + if (!magic || rd_u32(hd + 4) != (uint32_t)sx.st_size) { status = MS_STATUS_ERR; return true; } + std::error_code ec; fs::rename(part, done, ec); + status = ec ? MS_STATUS_ERR : MS_STATUS_OK; + return true; + } + } return false; // unknown op -> ignore (device retries) } @@ -222,10 +295,20 @@ void serve_loop(Transport& t, const SeederCore& core, bool verbose, prev = -1; uint8_t op; if (!read_exact(t, &op, 1)) continue; - size_t arglen = (op == MS_OP_DESCRIBE) ? 1 : (op == MS_OP_READ ? 7 : (op == MS_OP_COUNT ? 0 : SIZE_MAX)); - if (arglen == SIZE_MAX) continue; // unknown op - uint8_t args[7]; - if (arglen && !read_exact(t, args, arglen)) continue; + // fixed header bytes per op; OP_WRITE additionally reads `len` data bytes after its 10-byte header + size_t hdr = (op == MS_OP_COUNT) ? 0 : (op == MS_OP_DESCRIBE) ? 1 : (op == MS_OP_READ) ? 7 + : (op == MS_OP_STAT || op == MS_OP_FIN) ? 4 : (op == MS_OP_BEGIN) ? 8 + : (op == MS_OP_SREAD || op == MS_OP_WRITE) ? 10 : SIZE_MAX; + if (hdr == SIZE_MAX) continue; // unknown op + uint8_t args[10 + MOTA_SEEDER_WRITE_MAX]; + if (hdr && !read_exact(t, args, hdr)) continue; + size_t arglen = hdr; + if (op == MS_OP_WRITE) { // variable payload: header(10) + data(len) + uint16_t dlen = (uint16_t)(args[8] | (args[9] << 8)); + if (dlen > MOTA_SEEDER_WRITE_MAX) continue; // guard a runaway frame + if (dlen && !read_exact(t, args + 10, dlen)) continue; + arglen = (size_t)10 + dlen; + } uint8_t xs; if (!read_exact(t, &xs, 1)) continue; if (xs != xor_bytes(args, arglen, op)) continue; // bad checksum -> ignore; device retries diff --git a/tools/motatool/src/serve.h b/tools/motatool/src/serve.h index 51771724..ac10fb5e 100644 --- a/tools/motatool/src/serve.h +++ b/tools/motatool/src/serve.h @@ -33,12 +33,18 @@ private: // would call this directly from a characteristic-write handler and notify the reply — no framing needed. class SeederCore { public: - explicit SeederCore(const Folder& f) : folder_(f) {} + // `store_dir` (optional): folder where the "pull to folder" storage ops capture a `.mota` a device is + // fetching off-mesh — the SAME --dir as serving. A partial pull is `.mota.part`; OP_FIN publishes + // it as `.mota`. Empty store_dir = storage ops are refused (serve-only). + explicit SeederCore(const Folder& f, std::string store_dir = "") + : folder_(f), store_dir_(std::move(store_dir)) {} bool handle(uint8_t op, const uint8_t* args, size_t arglen, uint8_t& status, std::vector& payload) const; static std::array describe(const ServedMota& s); private: + std::string store_path(const uint8_t mid[4], bool part) const; // /.mota[.part] const Folder& folder_; + std::string store_dir_; }; // A bidirectional byte link (the serial seeder needs this; BLE would reuse SeederCore directly). diff --git a/tools/motatool/tests/test_motatool.cpp b/tools/motatool/tests/test_motatool.cpp index 87b7bd97..371a5f63 100644 --- a/tools/motatool/tests/test_motatool.cpp +++ b/tools/motatool/tests/test_motatool.cpp @@ -282,6 +282,38 @@ static void t_folder_and_seeder() { uint8_t reof[7]; reof[0] = 0; wr_u32(reof + 1, (uint32_t)s0->bytes.size()); reof[5] = 16; reof[6] = 0; CHECK(core.handle(MS_OP_READ, reof, 7, status, pl) && status == MS_STATUS_ERR); + // --- STORAGE ops ("pull to folder"): STAT -> BEGIN -> WRITE -> SREAD -> FIN round-trip --- + fs::path sdir = dir / "store"; fs::create_directories(sdir); + SeederCore store(folder, sdir.string()); + uint8_t mid[4] = {0xDE, 0xAD, 0xBE, 0xEF}; + auto midpath = [&](bool part) { + static const char* H = "0123456789abcdef"; std::string nm; + for (int i = 0; i < 4; i++) { nm += H[mid[i] >> 4]; nm += H[mid[i] & 0xF]; } + nm += part ? ".mota.part" : ".mota"; return (sdir / nm).string(); + }; + // STAT before anything -> present=0 + CHECK(store.handle(MS_OP_STAT, mid, 4, status, pl) && status == MS_STATUS_OK && pl.size() == 5 && pl[0] == 0); + // BEGIN total=64 -> 0xFF-filled partial + uint8_t beg[8]; std::memcpy(beg, mid, 4); wr_u32(beg + 4, 64); + CHECK(store.handle(MS_OP_BEGIN, beg, 8, status, pl) && status == MS_STATUS_OK); + CHECK(store.handle(MS_OP_STAT, mid, 4, status, pl) && pl[0] == 1 && rd_u32(pl.data() + 1) == 64); + // WRITE the header (MAGIC + total) + a blob at offset 0 + const uint8_t blob[16] = {'m','O','T','A', 64,0,0,0, 1,2,3,4,5,6,7,8}; + uint8_t wr[10 + 16]; std::memcpy(wr, mid, 4); wr_u32(wr + 4, 0); wr[8] = 16; wr[9] = 0; std::memcpy(wr + 10, blob, 16); + CHECK(store.handle(MS_OP_WRITE, wr, 10 + 16, status, pl) && status == MS_STATUS_OK); + // SREAD back the 16 written bytes; an unwritten region reads 0xFF + uint8_t srd[10]; std::memcpy(srd, mid, 4); wr_u32(srd + 4, 0); srd[8] = 16; srd[9] = 0; + CHECK(store.handle(MS_OP_SREAD, srd, 10, status, pl) && pl.size() == 16 && std::memcmp(pl.data(), blob, 16) == 0); + wr_u32(srd + 4, 32); srd[8] = 8; srd[9] = 0; + CHECK(store.handle(MS_OP_SREAD, srd, 10, status, pl) && pl.size() == 8 && pl[0] == 0xFF && pl[7] == 0xFF); + // FIN -> validates + publishes .mota + CHECK(fs::exists(midpath(true)) && !fs::exists(midpath(false))); + CHECK(store.handle(MS_OP_FIN, mid, 4, status, pl) && status == MS_STATUS_OK); + CHECK(!fs::exists(midpath(true)) && fs::exists(midpath(false)) && fs::file_size(midpath(false)) == 64); + CHECK(store.handle(MS_OP_STAT, mid, 4, status, pl) && pl[0] == 1 && rd_u32(pl.data() + 1) == 64); + // storage refused on a serve-only core (empty store_dir) + CHECK(core.handle(MS_OP_STAT, mid, 4, status, pl) && status == MS_STATUS_ERR); + fs::remove_all(dir); }