ota: seeder storage protocol (pull-to-folder) + motatool serve storage handlers

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 <mid>.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 = <midhex>.mota.part, published to <midhex>.mota on FIN.
- motatool `serve --dir <folder>` 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 <#> <dest>` + pause/resume) follows.
This commit is contained in:
Valentin Kivachuk Burda
2026-07-02 08:32:00 +02:00
parent 259802b706
commit bd2ecb80a7
6 changed files with 153 additions and 8 deletions
+20 -2
View File
@@ -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
// `<mid>.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 <mid>.mota exist on the host? + its size
static const uint8_t MS_OP_BEGIN = 0x05; // storage: create/truncate <mid>.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
+1 -1
View File
@@ -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);
+6
View File
@@ -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]
+87 -4
View File
@@ -4,6 +4,7 @@
#include <termios.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <netdb.h>
@@ -64,6 +65,14 @@ std::array<uint8_t, MOTA_DESC_WIRE> 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<uint8_t>& 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 <store_dir>.
// All keyed by mid[4]; a partial pull is <midhex>.mota.part, published to <midhex>.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
+7 -1
View File
@@ -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 `<midhex>.mota.part`; OP_FIN publishes
// it as `<midhex>.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<uint8_t>& payload) const;
static std::array<uint8_t, MOTA_DESC_WIRE> describe(const ServedMota& s);
private:
std::string store_path(const uint8_t mid[4], bool part) const; // <store_dir>/<midhex>.mota[.part]
const Folder& folder_;
std::string store_dir_;
};
// A bidirectional byte link (the serial seeder needs this; BLE would reuse SeederCore directly).
+32
View File
@@ -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 <mid>.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);
}