# Micromate Protocol Reference — Thor / Micromate Series IV, live wire protocol Sibling to [instantel_protocol_reference.md](instantel_protocol_reference.md) (Series III, "the Rosetta Stone") and [idf_protocol_reference.md](idf_protocol_reference.md) (Series IV *file* format). This document covers the Series IV **live device protocol** — what the unit says over the wire, as opposed to what it writes into a `.IDFW`. **Status (2026-09-23): opening session.** Everything below was established in a single bench session against one unit. Treat it as a strong start, not a settled spec — in particular, everything here comes from **one unit, over USB, with no events stored**. --- ## The headline **A Micromate running the *Blastware* firmware answers Series III command frames.** An unmodified Series III `POLL` (`SUB 0x5B`), built by `minimateplus.framing.build_bw_frame` with no changes at all, produced a complete two-step probe/data cycle. Ten Series III read commands were then tried and **all ten answered**, every one obeying the Series III response-SUB rule. ⚠ **That qualifier is load-bearing, and it was discovered after the fact.** Instantel ships the Micromate in two firmware lines: | firmware | Instantel's own description | |---|---| | `11.0CB` | "Utilize with **Blastware**" | | `11.0BD` | "Utilize with **THOR, Vision, Vision II**" | The bench unit reports **`11.0CB`** — the Blastware build. So the clean Series III behaviour above is very likely *because the unit is in Blastware mode*, not because the Micromate natively speaks Series III. **Nothing here should be assumed to hold on a `11.0BD` unit until tested.** This reframes the project. The question is no longer only "what is the Series IV protocol" but **"which firmware line do we target, and does one of them let the existing Series III stack drive the whole fleet?"** --- ## Firmware — the variable nobody knew was a variable This explains a production problem TMI has lived with: two ACH servers, two machines, and units that will not cross over. It is not a misconfiguration. **Instantel ships different firmware for different host software**, and the wire protocol differs with it. ### Fleet audit (physical, 2026-09-22/23) | unit | firmware | line | location | |---|---|---|---| | UM11719 | `11.0CB` | Blastware | Ped Bridge Loc 2 | | UM6047 | `11.0CB` | Blastware | Brookville Loc 9 | | UM12947 | `11.0CB` | Blastware | **bench** | | UM14133 | `11.0CB` | Blastware | Pitt-Music Bldg Loc 1 | | UM11402 | `11.0BD` | Thor | Ped Bridge Loc 1 | | UM20147 | `11.0BD` | Thor | **bench** | | UM13981 | `11.0AK` | pre-split | RKM Loc 1 | | UM20146 | `11.0AK` | pre-split | Karns Loc 2 | | UM12420 | `10.90GC` | pre-split | RKM Loc 2 | **4 Blastware / 2 Thor / 3 pre-split.** The Blastware line is already the plurality, which makes "standardise on Blastware" less disruptive than it first appeared. A store-derived audit (firmware is recorded in every `.sfm.json` sidecar as `extensions.idf_report.version`) agreed with the physical audit on **7 of 9**. The two that differed — UM6047 and UM14133 — are the most recently deployed units, reflashed after their last stored event. Useful technique: the fleet's firmware history is reconstructable from the store without touching a unit, but it lags reality by one deployment. Firmware is **not stable per-unit over time** — five of nine have been reflashed at least once. Any fleet-wide claim needs a fresh audit. ### ⚠ Retraction: firmware line does NOT determine Thor compatibility An earlier draft of this document suggested that UM12947's trouble with Thor was explained by its being on the Blastware build. **That is not supported.** Ped Bridge runs UM11402 (`11.0BD`) and UM11719 (`11.0CB`) side by side, both deployed 2026-04-20, and **both call Thor successfully** — UM11719 has 331 Thor-collected events in the store while on `11.0CB`, through 2026-08-23. So a Blastware-line unit does feed Thor. Whatever the CB/BD split changes, it is not "which host software can collect from it", and UM12947's specific problem remains unexplained. **What is actually established:** a `11.0CB` unit answers Series III command frames. Whether a `11.0BD` unit does is **untested** — and UM20147 (`11.0BD`) is on the bench, which makes that a direct A/B away. The version is readable over the wire from `SUB 0x01` as two separate ASCII runs — `"0CB"` then `"11"` — with no single concatenated string. ### The strategic fork - **Option A — standardise the fleet on the Blastware line.** Every unit, MiniMate and Micromate alike, then speaks Series III, and the existing `minimateplus/` stack drives all of it. One protocol, one call-home receiver. Dramatically cheaper *if* it holds up. - **Option B — reverse-engineer the Thor line (`11.0BD`) and support both.** Option A is the shortcut, but it is unproven and carries real unknowns, all of which are cheap to answer on the bench and expensive to discover later: 1. **What file format does a `11.0CB` unit produce?** If it emits Blastware binaries rather than `.IDFW`/`.IDFH`, the (now exact) Series III decoder applies and the IDF codec becomes a legacy path. Not a loss — we have both — but it changes what the ingest pipeline sees. 2. **Does Thor still work with a `11.0CB` unit?** If not, flashing a production unit breaks data collection until the replacement path exists. 3. **Are any Micromate-specific capabilities lost** on the Blastware line? 4. **Is the flash reversible in the field**, and what does it cost in downtime? ## How this was obtained No Thor, no modem, no Windows machine. The Micromate exposes its protocol on a **USB CDC-ACM virtual serial port**: ``` ID 2504:0300 Instantel Inc. MICROMATE COM PORT driver: cdc_acm ATTRS{serial}=="V1.00" → /dev/ttyACM0 ``` Plain CDC ACM, so no vendor driver and no proprietary USB layer — any host that can open a serial port can talk to the unit. **Baud is irrelevant over USB.** Identical byte-for-byte responses at 38400 and 115200; CDC-ACM ignores the line rate. TMI provisions Micromate *modem* links at **115200** (Series III uses 38400) — that matters for the cellular path, not for USB. **The device never speaks first.** 20 s of passive listening on an idle open port produced zero bytes. It is strictly request/response. --- ## Physical + framing layer ### Requests — Series III format, unmodified Every frame in this session was produced by `build_bw_frame(sub, offset)` with no Series IV changes, and the device accepted all of them: ``` [ACK 0x41] [STX 0x02] [10 10] [flags 00] [SUB] [00] [00] [offset] [params×10] [chk] [ETX 0x03] ``` ⚠ Only the doubled `BW_CMD` (`10 10`) form has been exercised. Whether other literal `0x10` bytes inside params require stuffing is **untested** — none of the probes sent carried one. ### Responses — Series III *minus the DLE prefix* ``` Series III: [DLE 0x10] [STX 0x02] … [chk] [ETX 0x03] Micromate: [STX 0x02] … [chk] [ETX 0x03] ← no leading DLE ``` This single byte matters operationally: Blastware's parser locates frames by scanning for `DLE+STX`, so it will **never find a frame boundary** in Micromate traffic no matter what else is correct. That is a structural reason a Micromate cannot call into a Blastware ACH server, independent of any baud mismatch. ### Response payload header ``` [0] CMD 0x00 same as Series III [1] flags 0xC5 ← Series III uses 0x10. Constant across all 10 SUBs. [2] SUB 0xFF − request_SUB [3] PAGE_HI [4] PAGE_LO [5+] data ``` ### Checksum — the DLE-aware variant ```python chk = sum(b for b in payload if b != 0x10) & 0xFF ``` Confirmed on every frame captured. The `POLL` probe response contains no `0x10` and so cannot distinguish plain SUM8 from the DLE-aware form; the `POLL` **data** response contains a `0x10` at payload offset 42, and only the DLE-aware rule matches there. This is the same checksum Series III uses for its `5A` bulk-stream and write frames — not the plain SUM8 of ordinary Series III reads. ### The probe response carries the data length Series III hardcodes `DATA_LENGTHS` per SUB. On the Micromate the **probe response tells you**, as a **uint16 BE at `payload[8:10]`**: ⚠ **Corrected 2026-09-23.** An earlier draft read this as a single byte at `payload[9]`. That is right only while the high byte is zero, and it is catastrophically wrong for `SUB 0x1A`, whose real length is `0x082C` = **2092** — read as a byte it gives **44**, a 47× under-read. Always read the pair. | SUB | command | `payload[9]` | Series III constant | |---|---|---|---| | `0x15` | serial number | `0x0A` | `0x0A` ✓ | | `0x01` | device info | `0x98` | `0x98` ✓ | | `0x1C` | monitor status | `0x2C` | `0x2C` ✓ | | `0x06` | storage range | `0x24` | `0x24` ✓ | | `0x2C` | call-home config | `0x7E` | `0x7C` ✗ **differs by 2** | | `0x08` | event index | `0x5A` | — | | `0x1E` | event header | `0x08` | — | | `0x1A` | compliance config | `0x082C` (2092) | — | | `0x0A` | waveform header | `0x00` | — (no event context) | | `0xFE` | full config | `0x00` | — (see note) | Four of four known Series III lengths match exactly. **Read the length from the probe rather than hardcoding it** — it is free, and it already caught the call-home divergence. --- ## Confirmed commands (read-only) All ten below answered with a correct `0xFF − SUB` response. Nothing that writes, erases, or changes monitoring state has been sent to a unit. | SUB | RSP | Command | Data proven | |---|---|---|---| | `0x5B` | `0xA4` | POLL / handshake | yes — ID block | | `0x15` | `0xEA` | Serial number | yes — `UM12947` | | `0x01` | `0xFE` | Device info | yes — 152 B | | `0x2C` | `0xD3` | Call-home config | yes — 126 B | | `0x1C` | `0xE3` | Monitor status | yes — 44 B | | `0x06` | `0xF9` | Event storage range | yes — 36 B | | `0x08` | `0xF7` | Event index | probe only | | `0x1E` | `0xE1` | Event header / first key | probe only | | `0x0A` | `0xF5` | Waveform header | probe only | | `0x1A` | `0xE5` | Compliance config | probe only | ### Decoded so far **`SUB 0x15` — serial.** ASCII, null-terminated: `UM12947`. **`SUB 0x5B` / `0x01` — identification strings.** `Instantel\0` and `MM/ISEE/S/IO` (MicroMate / ISEE standard). `0x01` also carries eight consecutive `3f 80 00 00` float32 values (= `1.0f`) — almost certainly per-channel calibration/scale factors, by analogy with Series III's `geo_hardware_constant`. **Unverified.** **`SUB 0x1C` — monitor status. Series III field offsets apply unchanged:** | field | offset | read | |---|---|---| | battery × 100 | `payload[-10:-8]` uint16 BE | `0x017D` → **3.81 V** | | memory total | `payload[-8:-4]` uint32 BE | 15,000,000 | | memory free | `payload[-4:]` uint32 BE | 15,000,000 (empty) | | date | `payload[18:22]` | day 23, month 9, year `0x07EA` = 2026 | The battery reading independently corroborates: Thor's own event reports for these units print `BatteryLevel : 3.8 volts`. **`SUB 0x2C` — call-home config.** Contains the ASCII string `RADIO RING`. Worth flagging: that is the *exact* string seen in the RV50 `ALEOS_SERIAL` debug during the BE12599 incident — `'ATQ1^MATE0^MATS0=2^M^MRADIO RING^M'`. So this block holds the modem dial / answer strings, and it is the most directly relevant command to the call-home-receiver goal. Field layout **not yet mapped**; Series III's map (`raw[5]` enabled, `raw[6:46]` dial string) is a starting hypothesis only, and the length already differs (`0x7E` vs `0x7C`). **`SUB 0x06` — storage range.** All zeros on this unit, consistent with `memory free == memory total`. Series III reads first/last event keys from the final 8 bytes; untestable until the unit holds events. --- ## The event chain — walked end to end (2026-09-23, 5 events) With 5 events on the bench unit (4 waveform + 1 histogram), the Series III browse walk works unmodified: ``` 1E (all-zero params) -> first key + size 0A (key) -> partial record, histogram only 0C (key) -> 210-byte waveform record 1F (all-zero params/browse) -> next key + size ... repeat ... 1F -> all-zero key = NULL SENTINEL, chain ends ``` The sentinel terminated correctly after exactly 5 events. ### Event keys are sequential, not addresses ``` 055d4a81 055d4a82 055d4a83 055d4a84 055d4a85 ``` **This is a real divergence.** Series III keys are flash-buffer *addresses* (`01110000`, `011121F2`, …) that advance by the event's byte length, which is why its 5A chunk walk is address-arithmetic. Micromate keys are a plain incrementing counter. Any port of the Series III download walk must not assume key arithmetic means anything. ### The 4 bytes after the key are the event's size `1E`/`1F` return `[key 4B][size 4B]`. Series III uses that slot as an offset to the next key; here it is a byte count: | key | size | kind | |---|---|---| | `055d4a81` | 4,076 | histogram | | `055d4a82` | 11,032 | waveform | | `055d4a83` | 11,502 | waveform | | `055d4a84` | 13,424 | waveform | | `055d4a85` | 8,746 | waveform | Consistent with real file sizes (corpus `.IDFH` ≈ 3.7–25 KB, `.IDFW` ≈ 8.6–15.8 KB), and the histogram is unmistakably the small one. ⚠ Inferred, not proven: the sizes sum to 48,780 while monitor status reports 57,344 bytes used, so ~8.5 KB of overhead is unaccounted for. ### `SUB 0x0C` — waveform record, and it carries the job metadata **Length `0xD2` = 210 bytes — identical to Series III.** Contents confirmed across all 5 events: - the event key, echoed - date + time (`17 09 07 ea` → 23 Sep 2026, then `10 21` → 16:33 — matching the actual bench recording time) - title note `"Location"` - **the project string** — `"Univ of Pitt-1st Yr Housing-Loc1 Ruskin"` - serial `"UM12947"` - channel labels `Tran` / `Vert` / `Long` / `Mic` — the same labels Series III uses, and the same label-relative float32 layout - per-event float32 peaks: 3.5152, 1.3720, 2.3542, 3.5152, 0.4227 in/s across the five events (varied deliberately during recording) **This closes the biggest open question for the call-home-receiver goal.** The job identity strings (`project` / `client` / `operator` / `setup`) that today arrive only via Thor's `.txt` sidecar — and which no amount of sample decoding can reconstruct — are **available over the wire from `0x0C`**. A direct-to-SFM event need not arrive with blank metadata. ### `SUB 0x0A` — partial record, histogram only `0x0A` returned `len = 0x1E` (30 B) for the histogram and `len = 0x00` for all four waveforms. The histogram payload carries **two timestamps** and the ASCII string `"\r Vert: 0.300 in/s"` — structurally the Series III **monitor-log partial record** (`0x2C` type), which likewise holds a start/stop pair and a `"Geo: in/s"` trigger string. So on the Micromate the division of labour is: `0x0A` describes interval-style records, `0x0C` describes triggered events. Series III uses `0x0A`'s *response length* (`0x46` vs `0x2C`) to tell real events from boundaries; that discriminator does not apply here. ### DLE stuffing in responses — confirmed present Earlier marked untested. The `0x0C` timestamp field contains `10 10`, which destuffs to a single `0x10` and yields a sensible clock reading. **Responses are DLE-stuffed**, so a parser must destuff before applying field offsets. --- ## A/B: Blastware build vs Thor build (2026-09-23) UM12947 (`11.0CB`) and UM20147 (`11.0BD`) were each put on the bench and given the identical read-only sweep. **Both answer Series III command frames.** | | UM12947 `11.0CB` | UM20147 `11.0BD` | |---|---|---| | POLL answers | ✓ | ✓ | | All 10 read SUBs answer | ✓ | ✓ | | `response_SUB = 0xFF − req` | ✓ | ✓ | | DLE-aware checksum valid | ✓ | ✓ | | Two-step probe/data read | ✓ | ✓ | | ID string | `MM/ISEE/S/IO` | `MM/ISEE/S` | | **flags byte** | **`0xC5`** | **`0x03`** | | **`0x1C` length** | **`0x2C`** | **`0x30`** | **The firmware line does not change the wire protocol.** One protocol stack can drive the whole fleet regardless of which build a unit is on. This is the single most consequential finding so far: "standardise the fleet on one firmware" becomes an *optional* convenience rather than a prerequisite for building a call-home receiver. ### The two differences that do exist **1. The flags byte identifies the build.** Response `payload[1]` is `0xC5` on the Blastware line and `0x03` on the Thor line, constant across all ten SUBs on both units. That makes firmware line detectable from *any* response, without reading device info. ⚠ Two units, one each — treat as a strong hypothesis, not a proven encoding. Note `0x03` is ETX, so on Thor-line units it arrives DLE-escaped as `10 03`. A parser that fails to destuff will mis-locate every field by one byte on exactly half your fleet. **2. `SUB 0x1C` (monitor status) is 4 bytes longer on the Thor line** — `0x30` vs `0x2C` — with four extra trailing bytes (`0f a0 00 00`, purpose unknown). ⚠ **This breaks relative-to-end parsing.** Series III reads battery and memory from the *end* of the `0x1C` block (`[-10:-8]`, `[-8:-4]`, `[-4:]`). Those offsets are correct on `11.0CB` and wrong on `11.0BD` — applying them blindly to UM20147 yields a battery reading of **577.92 V**. Parse forward from the declared length instead of backward from the end. With the offsets shifted by 4, UM20147 reads correctly: battery **3.81 V**, memory 15,000,000 total and free (no events stored). ## Divergences from Series III (running list) 1. **No `DLE` prefix on responses** — bare `STX`. 2. **Response flags byte is `0xC5`**, not `0x10`. 3. **Call-home config is 126 bytes**, not 124. 4. **Data lengths are discoverable** from the probe response at `payload[9]`. 4b. **Event keys are a sequential counter**, not flash addresses. 4c. **`1E`/`1F` return the event size**, where Series III returns an offset. 4d. **`0x0A` vs `0x0C` split by record type**, not by the `0x46`/`0x2C` length discriminator Series III uses. 4e. **Response `payload[1]` (flags) encodes the firmware line** — `0xC5` Blastware, `0x03` Thor — where Series III has a constant `0x10`. 5. **Modem serial rate is 115200**, not 38400 (per TMI provisioning practice; not independently verified here). 6. **Reads are single-step** — Thor asks for `offset = 0xFFFF` and gets the whole block; Series III always probes first. (2026-09-24) 7. **Setup writes are preceded by `SUB 0xDA`**, which names the target `.MMB` file. Series III has no equivalent — it has one config, not named files. 8. **The compliance block is written in ONE `0x71` frame**, not Series III's three chunks. 9. **`0x69` / `0x74` (waveform data write) are absent** from a setup push. 10. **Six channel blocks** (`Tran`/`Vert`/`Long`/`Mic`/`LMic`/`SMic`) on a 48-byte stride, against Series III's four. 11. **The notes block is four fixed-width `[label:22][value:42]` entries** on a 64-byte stride, with different labels — not Series III's `label: value` scan targets. 12. **The geophone scale factor is `3.10308`**, exactly half Series III's `6.206053`, with an ADC of 10,000 counts per volt. --- ## The firmware images are unencrypted — and they document themselves `ref-stuff/micromate-firmware/MICROMATE(CB).BIN` and `MICROMATE(BD).BIN`, ~2.77 MB each and within 192 bytes of one another. - **Entropy 6.08 bits/byte** — neither encrypted nor compressed. Plain code and data. - Header is a **big-endian vector table**, handlers at `0x4010_30xx`. - **~16,700 extractable strings**, including the developers' own debug `printf` format strings with function names intact. This is a legitimate interoperability reference for hardware TMI owns, and it short-circuits work I had scoped as "only answerable from a live modem capture". ### The call-home state machine, verbatim ``` ACH_NOT_STARTED → ACH_IDLE → ACH_INITIALIZING → ACH_CONNECTING → ACH_CONNECTED → ACH_TRANSFER_DATA → ACH_RETRY / ACH_QUITTING (also ACH_STARTED) ``` Supporting strings: ``` ACH: Entry StartCallHome() ACH: CallHome_task ; CheckAliveTime CANCEL ; TimeBetweenRetries = %d ACH: CallHome_task ; !ExpectedCommunicationsDetected() CANCEL ; TimeBetweenRetries = %d ACH: CallHomeCommectionCompleteProcessing() ACH=%s EAMWC=%s ACH: %s() three attempts and it's over ACH: %s() Send CMD_START_MONITOR ACH: %s() Send CMD_STOP_MONITOR ACH: Start Ignore request, Call Home is in progress ``` What this tells us without a single captured packet: 1. **Retry limit is three** — "three attempts and it's over". 2. **`ExpectedCommunicationsDetected()` gates the session.** If the host does not say something the unit recognises, the call is *cancelled* and rescheduled after `TimeBetweenRetries`. A homebrew receiver must satisfy this check or units will retry forever — which is exactly the failure mode seen on BE12599. 3. **The unit stops monitoring to call home and restarts afterwards** (`Send CMD_STOP_MONITOR` / `CMD_START_MONITOR`). Relevant to any wedged-unit rescue: the monitoring state around a call is the device's own doing, not ours. 4. **Calls are not re-entrant** — "Call Home is in progress" is ignored. ### Internal command table `CMD_CALLHOME`, `CMD_CALLHOME_CANCEL`, **`CMD_CALLHOME_CONNECTION_CONFIRMED`**, `CMD_CALLHOME_CONNECTION_COMPLETE`, `CMD_CALLHOME_SET_SCHEDULE`, `CMD_CALLHOME_CLEAR_SCHEDULE`, `CMD_STOP_CALLHOME_FILETRANSFER`, `CMD_DUTYCYCLE_AUTOCALLHOME`, `CMD_PURGE_EVENT_FLASH`. `CONNECTION_CONFIRMED` as a distinct state from `CONNECTION_COMPLETE` implies a **handshake the host must complete before data flows** — the concrete shape of `ExpectedCommunicationsDetected()`. ### Event delivery — the mechanism, probably ``` All Events Uploaded Mark/Unmark File Delete Marked Events Marked Events were Deleted MONITOR::MESG PURGE_EVENT_FLASH BEGIN / END ``` A **marking** mechanism exists, alongside a distinct "all uploaded" terminal state. 🔶 **Inferred:** events are *marked* as transferred rather than deleted on send, and purging is a separate explicit act. If so, a receiver that fails to mark would see the same events re-offered every call — the question that gates a safe homebrew receiver. **Not yet confirmed**; needs either a live call-home capture or disassembly around these strings. ### A full user manual is embedded The firmware carries its own HTML help, which documents configuration we would otherwise have to infer: - **Modem mode**: `Generic` (through a modem) vs `USB to PC`. - **Modem baud**: 9600 / 19200 / 38400 / 57600 / 115200 / 230400 — "must match the expected rate of the PC or modem". Confirms 115200 is a *setting*, not a fixed rate. - **Modem relay + warmup** (0–300 s), auxiliary mode, warning/alarm hold. - **Record modes**: Waveform, Waveform Manual, Histogram, Histogram-Combo; sample rates 1024 / 2048 / 4096. - **A scheduler downloaded from THOR** that can start/stop monitoring, change record mode, trigger a call home, or run a self check on a daily/weekly schedule. Pairs with `CMD_CALLHOME_SET_SCHEDULE`. ### Still worth doing Diffing the two images should isolate exactly what the CB/BD split changes — we know the wire protocol is not it, and the flags byte (`0xC5` vs `0x03`) gives a concrete anchor to search for. ## `SUB 0x5A` — bulk download. It streams the `.IDFW` file verbatim. **The complete read path works with no Instantel software in the loop.** ### It needs no arming sequence Series III ignores a `5A` probe unless preceded by `1E → 0A → 1E(token 0xFE) → 0C → 1F(token 0xFE) → POLL × 3`. The Micromate answers a **bare `5A` request** with nothing before it. That whole ritual is gone. ### The offset word is a LENGTH, not a position This is the key divergence. Series III walks chunks by absolute flash address, stepping `0x0200` per request. On the Micromate the offset word requests *how much to send*: ``` offset_word = 0x1000 + 2 × pages pages = ceil(event_size / 512) ``` | `offset_word` | pages | destuffed file bytes | |---|---|---| | `0x1002` | 1 | 518 | | `0x1004` | 2 | 1,030 | | `0x1006` | 3 | 1,541 | | `0x102C` | 22 | **11,033 — the whole event** | `event_size` comes from the chain walk (the 4 bytes after the key in `1E`/`1F`). **One request returns the entire event**; there is no chunk loop, no `STRT` end-offset parsing, and no `TERM` frame. Over-requesting is safe — `0x1030` (24 pages) returned exactly the same bytes as `0x102C`, so the device caps at the real size. Params are the Series III *probe* form: `[0x00][key4][6 × 0x00]`. ### The payload is the `.IDFW` file, byte for byte ``` [18-byte frame header] [ .IDFW file ] [chk] [ETX] ← raw wire ^ destuffed offset 16 ``` The file begins `00 12 01 00 00 00 "Instantel\0"` — `_THOR_PREFIX` + `_INSTANTEL_TAG` from `micromate/idf_file.py`. The first 32 bytes are **identical to a production `.IDFW`** pulled from the store. ⚠ Responses are DLE-stuffed. Destuff before locating the file, or the raw byte count overshoots (11,781 raw → 11,049 destuffed for an 11,032-byte event). ### End-to-end proof Event `055d4a82` downloaded over USB and fed straight to `read_idf_file()`: ``` serial UM12947 timestamp 2026-09-23 16:33:19 samples Tran 3072 Vert 3072 Long 3072 MicL 3072 peaks Tran 0.2433 Vert 1.3706 Long 0.2672 in/s ``` All four channels equal length, and the timestamp matches the `0x0C` record for the same key. **Independent cross-check:** `0x0C` reports a stored peak of **1.3720** for this event; the decoded samples give **1.3706** — two unrelated paths agreeing to 0.1%. **Consequence:** no new codec work is needed. The bytes off the wire are the same bytes `thor-watcher` forwards today, so `/db/import/idf_file` ingests a directly-downloaded event unchanged. Everything the IDF decoder already does per-sample-exact applies. ### What a full read now looks like ``` 1E → first key + size 0C(key) → project/client/operator, timestamp, peaks 5A(key, 0x1000+2×ceil(size/512)) → the whole .IDFW 1F → next key + size (until null sentinel) ``` ## Setups are FILES, not a config block Series III has one compliance config you overwrite. Series IV keeps **named setup files on an on-device filesystem**, with a pointer to the current one. From the firmware: ``` csetup.MMB the current setup factory.MMB Factory Default Setup File callhome.MMB call-home config is a file too "Current Setup File: " "Can Not Delete Active Setup File" GetSelectedSetupFilePathName() CSelectSetupFiles CSaveSetupFile ``` Names are up to 20 characters and may contain spaces, hyphens, underscores. The unit's help text describes selecting, renaming and deleting them, and the event list records which setup file produced each event. Filesystem primitives exist internally (`NS_ReadFile_internal`, `NS_WriteFile_internal`, `NS_SeekFile_internal`), but **no generic file-transfer command is exposed on the wire** — the only file-transfer string is `CMD_STOP_CALLHOME_FILETRANSFER`. So setups are unlikely to be pushed as raw `.MMB` blobs over the protocol. ### `SUB 0x1A` reads the whole active setup — 2,092 bytes Structurally close to Series III's ~2,126-byte compliance block, and it carries everything a setup consists of: - **the setup file name** — `Univ of Pitt-1st Yr. Housing-Loc1 Ruskin.MMB` - all four title note/value pairs — `Location`, `Client`, `Company`, `General Notes`, with their strings - the sensor location string (`Loc 1`) - per-channel labels *and units*: `Tran in./s.`, `Vert in./s.`, `Long in./s.`, `Mic psi (L)`, `LMic psi (L)`, `SMic (A)` Note `LMic` / `SMic` — linear and sound-level microphone variants that Series III does not have. This is the **read half of setup management**, and it means a setup can be round-tripped: read the active config, modify, write it back. ✅ **The write half was observed on 2026-09-24** — see *The write path* below, which confirms the round-trip: the written block is the read block, 91% byte-identical. ## The write path — observed end to end (2026-09-24) ⚠ **We have still never sent a write command to a unit.** Thor did every write below; seismo_lab's TCP bridge sat between Thor and the unit and recorded both directions. Capture: `bridges/captures/be12599-diag/9-24-26 - micromate2/` (`raw_bw_*` = Thor, `raw_s3_*` = unit), UM12947 on the USB **PC** port, relayed to TCP by `socat` on mint-mac. Operation: push a setup named `TEST1.mmb`. **All 12 device responses checksum-validate and every write is acked.** ### The sequence ``` Thor: 5B │ 41 │ 08 │ 2E │ 1A │ DA │ 68 → 73 │ 82 → 83 │ 71 → 72 unit: A4 │ BE │ F7 │ D1 │ E5 │ 25 │ 97 8C │ 7D 7C │ 8E 8D └──────── reads ────────┘ └──────────── writes ────────────┘ ``` Series III, for comparison: `68→73 │ 71×3→72 │ 82→83 │ 69→74→72`. Same write SUBs, and **every response SUB matches the Series III table exactly** (`68`→`97`, `73`→`8C`, `82`→`7D`, `83`→`7C`, `71`→`8E`, `72`→`8D`). Three differences: - **`SUB 0xDA` is new** and comes first — see below. - **`0x71` is a single write, not three chunks.** Series III splits the compliance block into 1027 + 1055 + remainder; the Micromate takes all 2,090 bytes in one frame. - **`0x69` / `0x74` (waveform data write) do not appear at all.** Every write ack is a 16-byte zero-data frame, same shape as Series III's. ### Three new read commands | SUB | rsp | payload | what it carries | |---|---|---|---| | `0x41` | `0xBE` | 271 B | **the active setup's file name**, e.g. `TEST1.mmb` | | `0x2E` | `0xD1` | 44 B | trigger-config block; mirrors what `0x82` writes | | `0xDA` | `0x25` | 272 B (write) | **declares the target setup file** — see below | ### `SUB 0xDA` — name the setup file you are about to write Data is exactly **256 bytes: the file name, null-padded, nothing else.** ``` 54 45 53 54 31 2e 6d 6d 62 00 00 … "TEST1.mmb" + 247 × 0x00 ``` This is the missing link in *setups are files*: there is no generic file-transfer command because there does not need to be one. Thor names the target file, then writes the ordinary config block into it. The unit acks with `0x25` before any config bytes are sent. ### Reads are single-step — no probe Series III sends every read twice (probe at `offset=0x00` to learn the length, then a data step). Thor **skips the probe** and asks for **`offset = 0xFFFF`**, getting the whole block in one response: ``` 41 02 10 10 00 1a 00 ff ff 00 … SUB 0x1A, offset 0xFFFF → 2,103-byte response ``` `POLL` is the exception — it uses `offset = 0x0030` (48), its data length. This does not contradict the probe response documented above; the probe still works and still reports its length at `payload[8:10]`. Thor simply does not need it. ### Write-frame destuffing — `10 XX` → `XX`, uniformly Only the leading `BW_CMD` is doubled (`10 10`); after that **every `10 XX` pair on the wire destuffs to `XX`**, including `10 03`. This was settled by checksum, not by assumption. Four candidate rules were tested against all four data-carrying write frames; **only this one makes all four checksums validate**: | rule | `0xDA` | `0x68` | `0x82` | `0x71` | |---|---|---|---|---| | **`10 XX` → `XX`** | ok | **ok** | ok | **ok** | | only `10 03` → `03` | ok | BAD | ok | BAD | | `10 10`→`10`, `10 03`→`03` | ok | BAD | ok | BAD | | nothing collapses | ok | BAD | ok | BAD | `0x71`'s data contains **4 literal `0x03` bytes**, escaped as `10 03` on the wire. A writer that does not escape `0x03` will emit a frame the device terminates early — this is the same defensive ETX escaping Blastware does, and it is mandatory, not optional. Checksum is plain SUM8 of the destuffed payload; the DLE-aware form gives the same answer once destuffing is correct, so it does not discriminate. ### The `offset` field is a per-command constant | SUB | data bytes | `offset` | note | |---|---|---|---| | `0xDA` | 256 | `0x0100` = 256 | = the name-field size | | `0x68` | 88 | `0x005A` = 90 | **identical to Series III's documented value** | | `0x82` | 28 | `0x001C` = 28 | **identical to Series III's documented value** | | `0x71` | 2090 | `0x082C` = 2092 | = the length `SUB 0x1A` reports | ⚠ **There is no single length formula** — two are `len`, two are `len + 2`, and Series III's `data[1] + 2` rule does not reproduce either. Treat these as observed constants the device accepted. Pinning the actual rule needs a second capture whose payloads differ in size. (This document has already been wrong once by inferring a length field; do not infer this one.) ### The write body **is** the read body The `0x71` write payload and the `0xE5` read response align at a fixed **11-byte shift** with **1902/2090 bytes equal (91.0%)** — the remaining 9% is exactly what was edited. **So a setup is read-modify-write**, the same shape as Series III, and a write client does not need to synthesise a config block from scratch. ### Field map, from the read/write diff Twelve differing regions, all accounted for. Offsets are into the `0x71` **data** section (= the read response's data + 11). | offset | size | field | this capture | |---|---|---|---| | `0x0000` | 3 | block length echo | `08 2a` (=2090) → zeroed on write | | `0x0009` | 1 | unidentified | `3c` → `02` | | `0x0013` | 1 | unidentified | `00` → `40` | | `0x002A` | 44 | **setup file name** | → `TEST1.mmb` | | `0x0090` | 42 | note 1 value — `Location` | → `Test Location 1 - 1234 electric boogaloo` | | `0x00D0` | 42 | note 2 value — `Client` | → `TMI` | | `0x0110` | 42 | note 3 value — `Company` | → `ServersDownLabs` | | `0x0150` | 42 | note 4 value — `General Notes` | → `Hopefully this works!` | | `0x0428` | 16 | sensor location | → `Test Config Send` | | `0x06CA` | 4 | **Tran trigger level** float32 BE | `0.3` → `0.5` in/s | | `0x06FA` | 4 | **Vert trigger level** | `0.3` → `0.5` in/s | | `0x072A` | 4 | **Long trigger level** | `0.3` → `0.5` in/s | **Notes block: four entries on a 64-byte stride**, each `[label: 22][value: 42]`, labels at `0x008A + 64n`. The labels are `Location`, `Client`, `Company`, `General Notes` — **not** Series III's `Project:` / `Client:` / `User Name:` / `Seis Loc:`, and they are fixed-width fields, not `label: value` pairs. **Channel blocks: six, 48 bytes each**, from `0x06BC` — `Tran`, `Vert`, `Long`, `Mic`, `LMic`, `SMic`. Trigger level sits at **label + 30**. ### The geophone scale factor is in the config block — and it confirms our LSB Each geo channel block carries a float32 BE at **label + 24**: ``` 40 46 98 dd = 3.10308003… ``` Which closes a loop from the file-decode work: ``` 3.10308003… / 10000 = 0.000310308 = _GEO_LSB_IPS, to 8 figures 10.0 / 3.10308003… × 10000 = 32226.046 = the 32226.05 full scale ``` `_GEO_LSB_IPS` was derived statistically — intersecting 991,415 rounding constraints from Thor's own CSV exports (see `idf_protocol_reference.md`). **The unit reports the constant directly**, and it agrees. That upgrades the value from a fit to a reading, and explains the odd full-scale count: the Micromate's ADC is **10,000 counts per volt**, and 3.10308 in/s per volt is **exactly half** Series III's documented `6.206053` (ratio 1.99997). Do **not** retune `_GEO_LSB_IPS` — this is corroboration, not a correction. ### Still unknown on the write path - **What `0x68` and `0x82` actually contain.** Both were written with near-zero payloads here and nothing in them changed, so no field is located. Series III maps backlight/power-save/LCD-cycle into `0x68`; unverified here. - **The three header bytes** at data `0x0000`/`0x0009`/`0x0013`. - **Whether a *new* setup file can be created**, or only an existing one overwritten. `0xDA` named a file that did not previously exist and the unit accepted it — but we did not confirm on the unit's screen that `TEST1` now exists as a selectable setup. Worth checking on the device. - **Scheduler / call-home writes.** `callhome.MMB` is a file too, so it may go through the same `0xDA` + block-write shape with a different target name. ## Static analysis of the firmware (2026-09-23, solo session) ### Architecture **ColdFire / 68K, big-endian, Freescale MQX RTOS** — not ARM as the vector table first suggested. The giveaway is the function epilogue/prologue `4E 5E 4E 75 4E 56` = `UNLK A6` / `RTS` / `LINK A6`, littered through both images, plus an `MQX_OK` assertion string. ### CB vs BD: the same source, ~10 lines apart A byte diff is useless — **68% of bytes differ** because the two are separately linked builds with everything relocated. A *string-set* diff is position-independent and tells the real story: **17,128 strings shared**, and almost every "unique" string is the same message with a different source line number: ``` CB: MONITOR[3268]: STATUS_BATTERY_LOW BD: MONITOR[3258]: STATUS_BATTERY_LOW ← consistently 10 lines apart ``` The offset is exactly 10 across `STATUS_BATTERY_LOW`, `STATUS_BATTERY_CRITICAL`, `Battery Critical Exit Monitor`, `histogram interval size of 0` and `Offsets by channel` — so one ~10-line block differs in the monitor module and essentially nothing else. The only functional string unique to either build is `CITIZEN` (a receipt-printer brand) in BD. **This corroborates the bench A/B from the other direction:** the CB/BD split is a tiny code delta, not two protocol stacks. Whatever drives Instantel to ship two downloads, it is not a different wire protocol. ⚠ The `SUB` dispatch is a 68K switch jump table (`CMPI.L` bounds check → `MOVE.W (table,PC,Dn)` → `JMP (d8,PC,Xn)`). Byte-pattern hunting will not isolate the write opcodes — that needs a real disassembler. ### Call-home config field names, from the firmware's own debug dump ``` CallHome.Enable = %s CallHome.DialString = "%s" CallHome.Retries = %d CallHome.SessionTimeout = %d CallHome.WaitForConnection = %d CallHome.WarmupTime = %d CallHome.PowerSave = %s ``` Seven fields, which is what the 126-byte `SUB 0x2C` block has to encode. Note `SessionTimeout` and `PowerSave` have no Series III equivalent, and Series III's scheduled-time fields (`time1/time2 hour/min`) are absent here — consistent with Series IV moving scheduling into the THOR-downloaded scheduler instead. Also present: `AT+CSQ` (signal quality), so the firmware talks AT to the modem directly. ## All five bench events, downloaded and decoded Read-only, over USB, no Instantel software: | key | declared size | got | decoded | |---|---|---|---| | `055d4a81` | 4,076 | 4,076 | histogram, 1 interval, 16:33:16 | | `055d4a82` | 11,032 | 11,032 | waveform, 3072 × 4 ch, 16:33:19 | | `055d4a83` | 11,502 | 11,502 | waveform, 3072 × 4 ch, 16:33:27 | | `055d4a84` | 13,424 | 13,424 | waveform, 3072 × 4 ch, 16:33:34 | | `055d4a85` | 8,746 | 8,746 | waveform, 2048 × 4 ch, 16:33:36 | Every event arrived at exactly its declared size, every channel came out equal length, and the timestamps are sequential across the recording session. ### Record type + filename: generate it, don't detect it `read_idf_file()` decides waveform vs histogram from the **filename suffix** — and there is no filename when downloading over the wire. ⚠ Worth correcting a natural assumption: **Series III does not detect this from content either.** `event_file_io.derive_record_type_from_filename()` reads the last character of the extension (`M529LKIQ.G10H` → `H` → Histogram). Nothing in the codebase infers record type from file content, for either family. And there is no obvious type field to find. The first 64 bytes of a histogram and a waveform are byte-identical; they diverge at ~`0x0947` into wholly different structures rather than differing by a flag. **The answer is the Series III pattern — generate the name.** Series III has `blastware_filename()`, which builds a name from serial + timestamp + type. Series IV needs the same thing, and its convention is far simpler: ``` _.IDF{W,H} e.g. UM12947_20260923163319.IDFW ``` versus Series III's `<4-char base-36 stem>`, where the stem is base-36 of seconds-since-1985 ÷ 1296. All three inputs are already available on a direct download: | input | source | |---|---| | serial | `extract_binary_metadata()` — decoded from the IDF header | | timestamp | `extract_binary_metadata()` — same | | **type** | **the chain walk** — `SUB 0x0A` length `0x1E` = histogram, `0x00` = waveform | Verified against all five bench events: the generated names match the convention of real files in the production store byte for byte. A directly downloaded event can therefore be filed under exactly the name Thor would have given it, and `/db/import/idf_file` needs no change at all. ⚠ The type still comes from the *protocol*, not the payload — so a downloader must carry it out of the chain walk. Losing it means losing the ability to name the file correctly. ### ⚠ Unresolved: the `0x0C` peak float The float32 extracted from `0x0C` runs 2–5% above `max(Tran, Vert, Long)` from the decoded samples: | key | `0x0C` float | max channel | |---|---|---| | `…81` | 3.5152 | 3.4419 | | `…82` | 1.3720 | 1.3706 | | `…83` | 2.3542 | 2.2227 | | `…84` | 3.5152 | 3.4419 | | `…85` | 0.4227 | 0.4198 | It is not peak vector sum either (computed PVS runs *higher* than both). The field may not be the peak at all — its offset was inferred from a byte marker, not established. **Do not rely on it** until it is pinned properly. Worth noting the histogram (`…81`) and the loudest waveform (`…84`) report *identical* peaks to four decimals, in both measures. That is self-consistent: the histogram's single 1-minute interval spans the whole thumping session, so its maximum should equal the loudest event in it. ## ⚠ Untested and unsafe-until-agreed **Nothing below has been sent to a unit by us, and nothing should be without an explicit decision.** Note the distinction introduced on 2026-09-24: the setup-write sequence (`0xDA`, `0x68`/`0x73`, `0x82`/`0x83`, `0x71`/`0x72`) has now been **observed**, because Thor performed it while we recorded. Observed is not the same as exercised — **we have still never originated a write frame.** The wire format is known; our encoder is unwritten and unproven. - **Writes** (`0x68`–`0x83`) — format now known, never sent by us - **Call-home write** (`0x7E` / `0x7F`) — not observed at all - **Erase** (`0xA3` / `0xA2`) - **Start / stop monitoring** (`0x96` / `0x97`) - `0x1F` (advance event pointer) — non-destructive on Series III but it does move device state, so it is parked with the rest Also unknown: - Whether `0x10` bytes inside **request params** need stuffing. (Write-frame **data** stuffing *is* now settled — `10 XX` → `XX`; see *The write path*.) - Everything about the **call-home session** — the device-initiated direction has not been observed at all. Specifically: how a unit announces itself, and **how it learns an event was accepted so it stops re-sending it.** That last question gates any homebrew receiver and cannot be answered over USB. --- ## Session provenance Unit **UM12947**, firmware **`11.0CB`** (Blastware line), on the bench via USB. Every response in this document was checksum-validated. Three sittings, all on the same unit: | date | state | what was exercised | |---|---|---| | 2026-09-23 | zero events stored | read commands, direct from Python | | 2026-09-23 | 5 events (4 waveform, 1 histogram) | the event chain + `0x5A` | | 2026-09-24 | 5 events | **Thor pushing a setup**, via a recording relay | The 2026-09-24 sitting used a different topology worth noting, because it is reusable: `socat` on mint-mac shares `/dev/ttyACM0` on TCP, seismo_lab's TCP bridge relays Thor to it and records both directions. Thor is configured with the unit at `127.0.0.1:` exactly as if it were a field modem. No modem, no SIM, and **nothing on the production Thor box is touched.** ⚠ Firmware is the single biggest caveat on this document. Every finding here is from one unit on the Blastware build. A `11.0BD` unit has not been touched. ✅ The original "empty unit" limitation is gone — `0x08`, `0x1E`, `0x0A` and `0x06` were all exercised against 5 stored events on 2026-09-23.