# 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. 13. **The monitoring flag is `0x0E`**, where Series III uses `0x10` — and it sits at `SUB 0x1C` `data[12]`. A second indicator lives at `SUB 0x49` `data[11]` (`0x02` monitoring), which Series III has no equivalent of. 14. **Setup files are enumerated** with a `0x3F`/`0x40` first/next walk. Series III has one config and nothing to enumerate. 15. **`SUB 0x1C` carries the device clock** (day/month/year/h/m/s at `data[13:21]`). Nothing else read so far reports the unit's own time. 18. **Deletion is per-event** (`0xA8` + a key, then `0xAA`), where Series III can only erase everything (`0xA3`/`0xA2`). 19. **`SUB 0x93` arms each event** before `1E`/`1F`, replacing Series III's `1E(token=0xFE)` — no token, no params. 20. **Event keys are a sequential counter** (`055D4A81…86`), not flash addresses. 21. **The ACH enable is `0x05`/`0x04`**, not Series III's `0x01`/`0x00` — bit 0 is the flag, bit 2 is set in both states. 16. **There is a generic file transfer addressed by full path** — `0x94`/`0x48` read, `0x8D`/`0x8E` write. Series III has nothing comparable; its config is reachable only through dedicated commands. 17. **The scheduler is a separate file**, `\system\schedule\schedule.dat`, and a schedule entry names a **setup file** to load. --- ## 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`). > ⚠ **RETRACTED 2026-09-24.** This section originally concluded "**no generic > file-transfer command is exposed on the wire**", reasoning from the absence of > firmware strings. **That was wrong.** `SUB 0x94` / `0x48` / `0x8D` / `0x8E` > are exactly that — a read/write file transfer addressed by **full filesystem > path** — and they were caught in the open on the first capture that touched > the scheduler. See *The scheduler, and a generic file transfer* below. > > The lesson is the usual one: absence of a firmware string is not absence of a > command. The dispatch is a 68K jump table and the commands carry no strings > of their own. > > The *setups* half of the original claim survives: setups are **not** pushed as > raw `.MMB` blobs. They go through `0xDA` + the ordinary config block. ### `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*: Thor names the target file, then writes the ordinary config block into it. The unit acks with `0x25` before any config bytes are sent. Note `0xDA` takes a **bare filename** null-padded to 256 bytes. That is a different mechanism from the path-addressed file transfer (`0x94` / `0x8D`, which carry `\system\schedule\schedule.dat` unpadded) — setups do not go through the file transfer, and the file transfer is not how setups are written. ### 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`. - **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. ### ✅ `0xDA` CREATES setup files (confirmed on the device, 2026-09-24) `TEST1.mmb` did not exist on the unit before the push. Afterwards it is **present in the unit's setup list and selected as the active config** — verified on the Micromate's own screen, not inferred from the ack. So the four commands below are the complete setup-management path, and a homebrew client needs no file-transfer primitive and no pre-existing target: ``` 0x41 read the active setup's name 0x1A read its config block ─┐ read 0xDA name the target .MMB ─┘ modify 0x71 → 0x72 write the block back write (creates the file if absent) ``` That closes the last open question about whether Series IV setup management is reachable from outside Thor. It is. ### ✅ Overwriting is protocol-identical to creating (2026-09-24) The firmware carries strings that suggest an overwrite handshake: ``` Overwrite File MFS FILE EXISTS Cannot be Overwritten Can Not Delete Active Setup File ``` **There is no such handshake on the wire.** A second push to `TEST1.mmb` — a name that now existed, and which `SUB 0x41` confirmed was the *active* setup — produced a byte-for-byte identical command sequence: | | create | overwrite | |---|---|---| | SUBs, and their order | `5B 41 08 2E 1A DA 68 73 82 83 71 72` | **identical** | | every `offset` field | — | **identical** | | `0xDA` / `0x68` / `0x82` data | — | **0 bytes differ** | | `0x71` data | — | 18 bytes differ = the one edited note | | all seven write acks | 11 zero bytes | **identical, still all-zero** | No extra command, no confirm step, no error status, and **no dialog on Thor**. Those firmware strings belong to the on-device Save screen (the `CSaveSetupFile` UI class), not to the protocol. **The overwrite was verified on the unit itself** — the edited `General Notes` string is present in the setup on the device, so the write applied, silently and in place. This is the case that mattered most, and it landed the right way round: the target was the **active** setup, which is what a real remote config push would hit. Being active bought it **no protection** — it was overwritten directly. A writer therefore needs no exists-check and no overwrite negotiation. ⚠ That cuts both ways. A remote push to the active setup of a **monitoring** unit changes the config it is recording with, with no prompt, no warning and no distinguishable ack. Whatever SFM eventually exposes should gate this on the operator, not on the protocol — the device will not stop anyone. ⚠ Two overwrite cases remain untested: a **non-active** setup file, and `factory.MMB`, which `Cannot be Overwritten` probably guards. Neither blocks a writer — the active setup is the one worth pushing to. ⚠ Note also that the write acks are **all-zero in every case observed**, across a create and an overwrite. We have never seen this protocol report a *failed* write, so **do not treat a zero ack as proof a write was applied.** Read the config back and compare; `SUB 0x41` plus `SUB 0x1A` make that cheap. ## The scheduler, and a generic file transfer (2026-09-24) Capture: `bridges/captures/9-24-26 - micromate2/*_read_scheduler.bin`. 25 request frames, 25 responses, every checksum valid. Four operations in one capture, segmented by Thor's `POLL` preamble (marks are not written into the raw `.bin` — in TCP mode seismo_lab logs them to the on-screen log only): | frames | operation | |---|---| | 0–4 | read the schedule off the unit | | 5–18 | push a setup — the sequence already documented above, unchanged | | 19–21 | write the schedule back | | 22–24 | enable the scheduler | ### 🔑 `SUB 0x94` / `0x48` / `0x8D` / `0x8E` — file transfer by path **The unit will read and write files addressed by full filesystem path.** ``` 0x94 → 0x6B open for READ 0x48 → 0xB7 read next page; repeat until an all-zero response 0x8D → 0x72 open for WRITE 0x8E → 0x71 write the file body ``` The path is plain ASCII, **unpadded**, with `offset` = its exact length: ``` 5c 73 79 73 74 65 6d 5c 73 63 68 65 64 75 6c 65 5c 73 63 68 65 64 75 6c 65 2e 64 61 74 \system\schedule\schedule.dat 29 bytes, offset = 0x001D ``` `0x94` and `0x8D` sent **byte-identical** 29-byte payloads — the same path, only the command distinguishing read from write. The `0x48` read is paged, and the page number comes back in the response header at `payload[3:5]`: | call | page | payload | content | |---|---|---|---| | 1st | `0x0000` | 15 B | descriptor — `04 00 … 01 00 00 00 00 00 01` | | 2nd | `0x0002` | 535 B | the file body | | 3rd | `0x0000` | 11 B | all zeros = **end of file** | ⚠ This is the single most consequential find of the session, because it is not specific to the scheduler. **`callhome.MMB` is a file too**, and the call-home config is the last unsolved piece of the project. Reading it should be a matter of pointing `0x94` at the right path. That is a *lead*, not a result — no path other than `schedule.dat` has been tried. ### The schedule file itself The write body is the read body minus an 11-byte response prefix (524 vs 535), so both describe the same structure: ``` 03 00 00 00 0f 03 02 [namelen] [setup-file name] …zeros… 27 03 10 ``` - **`[namelen]` is a length prefix**: `0x28` = 40 for the 40-character name read off the unit, `0x09` = 9 for `TEST1.mmb` written back. Confirmed both ways. - The trailing `27 03 10` sits at `0x108` in the write body and `0x113` in the read — the same offset once the 11-byte prefix is accounted for. **A schedule entry names a setup file.** That is how the help text's "change the record mode" works: an entry says *at this time, load this setup*. It also means the scheduler and the setup list are coupled — deleting a setup that a schedule references is a foot-gun worth checking before we ever expose either. ✅ **The entry internals ARE decoded** — see *The schedule record format* below. The file turned out to hold **two** 260-byte records, not one: `27 03 10` is the second record's time/day fields, not a trailer. `1/2h` is a half-hour slot and both times check out against what the operator entered. ### `SUB 0x47` — the scheduler enable, probably Step 4 is two bare `0x47` frames with no data, differing only in `params[7]`: ``` req params = 00 00 00 00 00 00 00 [01] 00 00 → rsp 04 00 … 00 [01] 00 00 00 00 00 01 req params = 00 00 00 00 00 00 00 [03] 00 00 → rsp 04 00 … 00 [03] 00 00 00 00 00 01 ``` The response echoes the selector and ends `01`. Its 15-byte shape is identical to the descriptor `0x48` returns on page 0. ⚠ **Whether `0x47` sets or merely reads is genuinely undetermined.** Both calls returned the same trailing `01`, and there is no before/after to compare — the scheduler was enabled in this same capture, so no "disabled" reading exists. `params[7]` is also the token position Series III uses, which is suggestive but not evidence. **Do not implement an enable against this until a disable-then-enable capture settles it.** ### The scheduler enable is NOT in the setup config block A prediction made before this capture — that the `Scheduler On/Off` switch would appear as a byte in the `0x71` block, because the unit's help text lists it in the same edit screen as Record Mode and Sample Rate — **did not hold**. The `0x71`, `0x68` and `0x82` payloads are **byte-identical** to the previous capture's, all three, zero differences. The scheduler was enabled without any of them changing. ⚠ The test is weaker than it looks: the operator re-sent the *same* config, so a byte-identical block is also what "nothing changed" looks like. What it does establish is that enabling the scheduler did **not** require a config write — whatever `0x47` does, it does alone. ## Monitoring control and the setup list (2026-09-24) Capture: `bridges/captures/9-24-26 - micromate2/*_turn_on_monitormode_*`. Thor started monitoring, listed the unit's setups, and stopped monitoring. **40 request frames and 40 responses, every checksum valid.** ### Thor's per-operation preamble Thor re-runs this before *every* operation — three times in this one capture: ``` POLL (0x5B) → SERIAL (0x15) → 0x49 → POLL (0x5B) → ``` ⚠ **Whether the unit requires this is untested** — see *Thor's conventions vs the protocol's requirements*. Thor sends it before trivial reads too, so it may be habit rather than handshake. Do not assume it is mandatory. Note `POLL` here carries `offset = 0x0030` (its data length), not `0xFFFF` — `POLL` is the one read Thor still addresses by length. ### `SUB 0x96` / `0x97` — start and stop monitoring ✅ Identical to Series III, including the acks: | request | ack | effect | |---|---|---| | `0x96` | `0x69` | **start monitoring** | | `0x97` | `0x68` | **stop monitoring** | Both are bare frames — no params, no data — and both ack with the usual 16-byte zero-data response. Thor follows each with a `SUB 0x1C` status read to confirm. ### `SUB 0x1C` — monitor status, 55-byte data ``` data[12] monitoring flag NON-ZERO = monitoring, 0x00 = idle data[13] day data[14] month data[15:17] year, uint16 BE data[17] ⚠ unidentified — 32 while monitoring, 100 when idle data[18] hour ─┐ device clock, verified against the capture's data[19] minute │ own wall time (19:12:25 → 19:13:34 EDT) data[20] second ─┘ data[-8:-4] memory total, uint32 BE = 15,000,000 bytes exactly data[-4:] memory free, uint32 BE ``` ⚠ **Test `data[12]` for non-zero — do NOT compare against a constant.** It read `0x0E` on 2026-09-24 and `0x0C` on 2026-09-25, both while monitoring, so it carries sub-state in its low bits rather than being a flag. Series III's `0x10` does not apply either. `SUB 0x49` `data[11]` read `0x02` in every monitoring sample so far, but the same caution applies until more values are seen. Only five bytes differ between the monitoring and idle responses: the flag, `data[17]`, the clock, and the memory-free field. Memory free dropped by exactly **4,096 bytes** across the ~70-second monitoring session — monitoring allocates as it runs, so free memory is not a stable value to compare against. The device clock is free here, on a command Thor already sends. That is worth having: nothing else read so far reports the unit's own time. ### `SUB 0x49` → `0xB6` — a cheap state check 16-byte data, in Thor's preamble before every operation: ``` 05 00 00 00 00 00 00 00 00 00 00 [ST] e8 00 0b 00 ↑ data[11]: 0x02 monitoring, 0x00 idle ``` **A second monitoring indicator, in a 21-byte response instead of `0x1C`'s 60.** Different encoding from `0x1C`'s flag (`0x02` vs `0x0E`), so they are separate fields, not the same byte read twice. For a polling client this is the cheaper of the two, and Thor evidently treats it as the routine one. ### `SUB 0x3F` / `0x40` — walking the setup-file list ✅ The same first/next shape as Series III's `1E`/`1F` event walk, applied to setup files: | request | ack | meaning | |---|---|---| | `0x3F` | `0xC0` | **first** setup record | | `0x40` | `0xBF` | **next** setup record; repeat until the name is empty | Every record is 266 bytes of data and carries **nothing but the name**: ``` ff 00 00 00 00 00 00 00 00 00 00 00 … └──────────── 11-byte header ─────┘ ``` The walk ends on a record whose name is empty — 24 records for 23 setups. `factory.MMB` comes first, from `0x3F`. **There is no active-setup flag in the list.** The header is byte-identical on every record including the terminator, and the tail is all zeros. The active setup is identified only by `SUB 0x41`, whose response uses this exact record format. (On the unit's own screen the active setup is marked with a trailing asterisk — that is a UI decoration, not a field.) `TEST1.mmb`, created over the wire earlier the same day, appears in the list and is what `0x41` reports as active — independent confirmation that a written setup becomes a real, enumerable file. ### Thor refuses to send a setup while a unit is monitoring Send-to-unit is **greyed out in Thor's UI** when the unit is monitoring, and nothing is transmitted — this capture contains no `0xDA` or `0x71` at all. That is a Thor-side policy, not a device refusal: nothing observed suggests the Micromate would reject the write. It squares with the earlier finding that a push to the **active** setup overwrites silently — Thor is preventing exactly the footgun the protocol leaves open. The *reason* behind the rule is sound and SFM should honour it — but not necessarily by copying the greyed-out button. Stop → push → restart as a single operation is what an operator usually wants, and `0x49` data[11] (or `0x1C` data[12]) makes the state check cheap either way. ## 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. ## Event download, per-event delete, and ACH config (2026-09-25) Three captures with operator-supplied ground truth, including Thor screenshots of the event list and the schedule. All Thor-originated; we still send nothing. ### ✅ `Day` is the day of week — and `[0]` is the schedule type A **weekly** schedule settles both fields. Thor's screen: *"multiday, Repeat Weekly: Disabled, Start Monitoring at 8:00 AM every day, alternating TEST1 / test2."* The file is **7 × 260 + 4 = 1,824 bytes**, and every record matches: | rec | `1/2h` | time | `Day` | | `[6]` | name | |---|---|---|---|---|---|---| | @0 | 16 | 08:00 | **0** | Sun | 2 | `TEST1.mmb` | | @260 | 16 | 08:00 | **1** | Mon | 2 | `test2.mmb` | | @520 | 16 | 08:00 | **2** | Tue | 2 | `TEST1.mmb` | | @780 | 16 | 08:00 | **3** | Wed | 2 | `test2.mmb` | | @1040 | 16 | 08:00 | **4** | Thu | 2 | `TEST1.mmb` | | @1300 | 16 | 08:00 | **5** | Fri | 2 | `test2.mmb` | | @1560 | 16 | 08:00 | **6** | Sat | 2 | `TEST1.mmb` | **`Day` = 0 Sunday … 6 Saturday**, and the alternating setup names line up with the screen row for row. `[0]` on record 0 read **4** here, against `2` and `3` in the daily schedules — so it carries the schedule *type* as well as Repeat: | `[0]` | meaning | |---|---| | `2` | Daily, repeat off | | `3` | Daily, repeat on | | `4` | **Weekly, repeat off** | | `5` | Weekly, repeat on — ⚠ predicted, not observed | Bit 0 is Repeat; `2` and `4` are the Day/Week bases. Same bitmask style as `[6]`. ⚠ In *daily* schedules `Day` reads `3` in every record of every capture. With `[0]` already saying "daily", the field is presumably ignored there — but that is inference, and `3` is unexplained. ### Event download — `SUB 0x93` arms each event Thor's download of six events: ``` POLL 0x15 0x49 POLL 0x1C 0x15 0x49 0x06 0x93 0x1E 0x0C 0x5A×4 ← event 1 0x93 0x1F 0x0C 0x5A×11 ← event 2 0x93 0x1F 0x0C 0x5A×12 ← event 3 0x93 0x1F 0x0C 0x5A×14 ← event 4 0x93 0x1F 0x0C 0x5A×9 ← event 5 0x93 0x1F 0x0C 0x5A×6 ← event 6 0x93 0x06 0x93 0x1E 0x0A×9 ← re-walk the list ``` **`SUB 0x93` → ack `0x6C`** is sent before every event, with empty params and an all-zero ack. It is the Series IV analogue of Series III's `1E(token=0xFE)` arm step, and it is simpler: no token, no params. **Event keys are a plain sequential counter.** The six events walked as `055D4A81 … 055D4A86`, consecutive, at `data[11:15]` of the `0x1E`/`0x1F` response, with the event's byte size at `data[17:19]`. Nothing like Series III's flash addresses. `SUB 0x0A` walks the event list returning **30-byte records** carrying start and stop timestamps (`[11]` day, `[12]` month, `[13:15]` year BE …), terminating on an all-zero record. The dates match Thor's event list exactly (five events on 09/23/2026, one on 09/24/2026). ### 🔑 Delete is PER-EVENT — `SUB 0xA8` + `0xAA` ``` 0xA8 params[0:4] = → ack 0x57 0xAA params = zeros → ack 0x55 ``` The operator deleted the **top row of Thor's list** — the newest event — and `0xA8` carried `05 5d 4a 86`, the **highest** key from the walk. Thor lists newest-first, so newest = highest key. Confirmed end to end. **This is the last piece a homebrew ACH receiver was missing.** The manual established that collection state is server-side bookkeeping and that "delete events from unit" is a separate server-issued action; this is that action's opcodes. Two things worth noting against Series III: - Series III erases **everything** (`0xA3 → 0x1C → 0x06 → 0xA2`). Series IV deletes **one named event**, which is strictly safer — a receiver can delete exactly what it has confirmed it stored. - The opcodes are different. Do not reach for `0xA3`/`0xA2` here. ⚠ `SUB 0x06` (storage range) read **identically before and after** the delete in this capture, so it is not a quick way to confirm a deletion landed. Re-walk the event list instead. ### ✅ ACH config — `0x2C` / `0x7E` / `0x7F`, exactly Series III Thor enabling then disabling Auto Call Home, twice through the same sequence: ``` POLL → 0x15 → 0x49 → POLL → 0x2C (read) → 0x7E (write, 126 B) → 0x7F (confirm) ``` Same SUBs and the same acks as Series III — `0x2C`→`0xD3`, `0x7E`→`0x81`, `0x7F`→`0x80`. The write payload is **126 bytes** with `offset = 0x007E`, and the `0x2C` read returns those same 126 bytes behind an **11-byte prefix**, so `read[n + 11]` is `write[n]`. **The enable flag is `write[5]`** (= `read[16]`): | value | state | |---|---| | `0x05` | Auto Call Home **enabled** | | `0x04` | **disabled** | Bit 0 is the enable — the same bit position Series III uses at `raw[5]`, but with bit 2 set as well in both states. Do not test for `0x01`/`0x00`. `write[6:]` holds the dial string, null-padded — `"RADIO RING"` on this unit, matching Series III's `raw[6:46]`. #### ⚠ `write[118:120]` — a field the UNIT maintains Three samples: | when | enable | field | |---|---|---| | 00:50, idle (schedule session) | `0x04` | `43 23` | | 01:16, ACH read #1 | `0x04` | `43 23` — **unchanged after 26 min** | | 01:16, ACH read #2, after a config write | `0x05` | `2e 5e` | Thor **echoes back whatever it last read**, including a value that no longer matches the config it is sending — and the write is accepted anyway. What that rules out: - **Not a clock or timer** — identical across 26 minutes of idle. Only a config write moved it. - **Not a counter** — it went *down*, 17187 → 11870. - **Not computed by Thor** — Thor demonstrably sends a stale value. - **Not a standard CRC16.** Brute-forced all 65,536 polynomials × init {`0x0000`, `0xFFFF`} × all four reflection combinations, over four candidate regions (`[0:118]`, `[0:118]+[120:]`, `[5:118]`, and the payload with the field zeroed). **No match.** Recorded so nobody repeats the sweep. It behaves like a hash or checksum the unit computes over the stored config: a one-byte input change (`0x04`→`0x05`) scattered the output completely, XOR `0x6D7D`, where a sum would have moved by 1. But two samples cannot separate that from a nonce regenerated on each write. ✅ **Operationally this does not matter.** The unit does not validate the field on input — Thor proved that twice in one capture. **Read the config, change your field, send everything else back byte-for-byte.** Never synthesise or zero it. Same read-modify-write shape as the setup block. Resolving it properly needs more samples: several config writes with the times noted, which is cheap to collect during any future ACH capture. Neighbouring bytes: `write[117]` is `0x03`, lining up with Series III's `num_retries` of 3, and `write[120:122]` is `00 3c` = 60. ⚠ Beyond the enable byte and the dial string, **the field map is not established** — only one setting was varied. Series III's offsets are a starting hypothesis, not a transfer. ## What the THOR manual settles about the ACH session (2026-09-24) Source: `manuals/723U0201 THOR Operator Manual Rev 08.pdf` §6.2, §3.6. ⚠ **This is vendor documentation, not observed bytes.** It tells us the shape of the session and the vocabulary; it does not give opcodes. Treated as a strong prior, not as confirmed protocol. ### 🔑 A unit does NOT learn that an event was accepted — the server decides This document previously listed, as the thing *gating any homebrew receiver*: > how a unit announces itself, and **how it learns an event was accepted so it > stops re-sending it.** **That was the wrong question.** There is no acknowledgement mechanism to discover, because the unit is not tracking what has been collected. Per §6.2.2.2 the ACH session is a list of **server-chosen actions**, and two of them are independent: | ACH action | what it does | |---|---| | **Copy events** | downloads events — *"Only applies to events not previously downloaded"* | | **Copy monitor log** | downloads the monitor log | | **Delete events and Logs from Unit** | **explicitly** deletes them from the unit | | **Set Date/Time** | unit synchronises its clock **from the computer** | | Send Events / Schedule to Vision | Instantel cloud, not relevant to us | "Not previously downloaded" is **computer-side bookkeeping**. The unit keeps its events until a server tells it to erase them, and the manual's own warning proves the two are decoupled: > ⚠ *"If you enable 'Delete Events and Logs from Unit' but disable 'Copy Events'. > The events and logs will be deleted without being uploaded."* A server that never issues the delete simply re-reads the same events forever. **This is exactly the model our Series III ACH server already implements** — `ach_state.json` with `downloaded_keys` / `max_downloaded_key`, and erase as a separate deliberate step. No new mechanism is needed for Series IV. So a homebrew receiver needs: accept the connection, identify the unit, walk the events (already solved — `0x08`/`0x1E`/`0x0A`/`0x5A`), keep our own high-water mark, and *optionally* erase. **The erase opcodes are the only genuinely missing piece**, and they remain on the unsafe list. ### The session is server-driven Which matches the firmware's state machine and its `CMD_EXIT_CALL_HOME_DELETE_EVENTS_START_MONITORING` / `Call Home Deleting Events` strings: the unit dials in, then waits to be told what to do, and returns to monitoring when the server is finished. Scheduled ACH and event-triggered ACH behave differently (§3.6.1): - **Event-triggered** (ACH enabled on the unit, an event occurred) — *"Records events + transfer data, no stopping to monitor."* Governed by **Monitoring While Calling Home**; with MWCH enabled *"the monitor log will not be copied, events will not be deleted, and time will not synchronize"*. - **Scheduled** — *"Stop monitoring + transfer data (or delete events, or synchronize)."* Monitoring stops and in-progress events complete first. ⚠ Worth noting for SFM: with MWCH on, an event-triggered session **cannot** delete or sync. A receiver that relies on erase to avoid re-reading will silently never erase on those units. Our high-water mark must be the primary mechanism, with erase as an optimisation — which is how Series III already does it. ### Session Time Out is unit-side only §6.2 is explicit that THOR configures ACH on both ends *"with one exception; the Session Time Out. This must be configured directly on the unit."* That matches the firmware's `CallHome.SessionTimeout` field, and means it lives in `callhome.MMB` rather than anywhere THOR reaches — one more reason to read that file with `0x94`. ### Unit identification is by serial number, with wildcards §6.2.2.2: filters match against the serial number the unit presents, `*` wildcards allowed, best-match wins — `UM*` all Micromates, `MP*` all Minimate Pros, `BE`/`BC` Minimate Plus variants. So **the unit announces its serial early enough for the server to route on it.** `SUB 0x15` returns the serial and is in Thor's standard preamble, which is consistent, though the inbound direction has still never been observed. ⚠ The manual's worked example contradicts its own table — it says *"To filter Micromate units type MP*"* and *"To filter Minimate Pro units type UM*"*, which is backwards. The table is right. Noted so nobody copies the error. ### Thor requires Idle for configuration §6.2.1 step 3: *"The Monitoring Mode must be 'Idle'."* Independent confirmation that the greyed-out send observed on the bench is deliberate Thor policy, applied to ACH setup as well as compliance setup — and still not evidence the device refuses. ### The schedule's actions, from the UI side §3.6 lists exactly **four** actions THOR exposes: Start monitoring, Stop monitoring, Self-check, Auto Call Home. The firmware has **five** — it also has a setup-less `DUTYCYCLE_START_MONITOR`. §3.6.2 step 8 confirms the coupling is Thor's own requirement: *"Select the appropriate Action and the Unit Setup. (A Unit Setup must exist…)"* — while §3.6.2 step 1 notes *"The unit will execute any actions in a schedule using its current settings."* The two statements sit a paragraph apart and pull in opposite directions, which is consistent with `START_MONITOR` existing and THOR never emitting it. Also from §3.6: schedules are **Day or Week** ("Select Day… Select Week"), have a **Repeat Daily / Repeat Weekly** flag, carry a Name, Description and Unit Type, and *"Saving a schedule will only store it on the computer, it must still be sent to the unit and enabled"* — which is why the capture shows `0x8E` (send) and `0x47` (enable) as separate steps. Those are the fields to look for when the schedule entry is finally decoded: **action, setup name, time, day-or-week, day selection, repeat.** The one captured entry (`03 00 00 00 0f 03 02 …`) has seven bytes before the name, which is the right order of magnitude for that field list. ## What THOR's "status check" actually is (2026-09-25) Captured with `bridges/mm_link.py` standing in for the modem, THOR configured `Communication: TCP`, **Check connection every 5 s**, **Check status every 5 s**. ### It is eleven commands, not one ``` POLL → DEVICE_INFO → 0x49 → 0x5C → MONITOR_STATUS → SETUP_NAME_READ → STORAGE_RANGE → 0x02 → OPERATOR → 0x47 → CALLHOME_CFG ``` Each check opens a **new TCP connection**, runs all eleven exchanges in ~300 ms, and closes it. Measured over 21 consecutive checks: | | | |---|---| | request payload | **236 B** | | response payload | **936 B** | | TCP connect + teardown | ~1,000 B (fresh handshake every time) | | **per check** | **~2.2 KB** | ### The cost | cadence | checks/day | per unit | |---|---|---| | **every 10 s (as observed)** | 8,640 | **18.8 MB/day — 563 MB/month** | | every 60 s | 1,440 | 3.1 MB/day — 94 MB/month | | every 15 min | 96 | 0.2 MB/day — 6 MB/month | ⚠ **On a metered cellular plan this is real money, and most of it is waste.** A status check re-reads the call-home config, the operator name, the active setup name and the full device info block **every ten seconds** — none of which changes between checks. `SETUP_NAME_READ` alone returns 274 bytes each time. A genuine liveness + state check is **`POLL` + `MONITOR_STATUS`**: two commands, 131 bytes of response. Same information about whether the unit is alive and whether it is monitoring, for a ninth of the traffic. ### There are TWO checks, and only one of the dials is honoured The eleven-command sequence is the **status check**. There is also a much cheaper **connection check** — three commands: ``` connection check POLL → SERIAL → 0x49 status check POLL → DEVICE_INFO → 0x49 → 0x5C → MONITOR_STATUS → SETUP_NAME_READ → STORAGE_RANGE → 0x02 → OPERATOR → 0x47 → CALLHOME_CFG ``` ⚠ **With both dials equal, the connection check never runs separately at all** — every connection observed was the full eleven. It only appears once the two intervals differ. That alone explains a lot of "changing the settings does nothing": at equal values you only ever get the expensive one. With *connection check = 10 s* and *status check = 30 s*, the steady state over 14 consecutive cycles is: ``` FULL at T short at T + 10.1 short at T + 25.3 (+15.2) FULL at T + 30.4 (+5.1) ``` | dial | set to | observed | | |---|---|---|---| | status check | 30 s | **30.4 s** | ✅ honoured | | connection check | 10 s | **15.2 s** | ❌ 52% slow | 27 consecutive connection-check gaps, all 15.1–15.3 s. That is systematic, not jitter. > ⚠ **A "half the status period" hypothesis was raised here and REFUTED.** It > predicted that with status at 60 s the connection check would run at 30 s. > Measured: **60.5 s**. Recorded rather than deleted — the prediction was sharp > and it was wrong. ### Modem settings live in `SysParm.cfg`, not in the setup — and that is deliberate **Settled by experiment (2026-09-25).** The unit's *call modem* setting was changed on the keypad from `generic` to `USB to PC`. Then the **active setup was switched from `TEST1` to `test2` on the device**, and the setting was checked again: **unchanged.** So it does not travel with the setup. It is device-global, in the `SysParm.cfg` the firmware strings already hinted at, alongside `SysPref.bMonitorScheduler`. That closes a hypothesis this document was pursuing: the ~102 bytes a `.MMB` **file** carries beyond the 2,090-byte config block are **not** where this lives. Those bytes remain unexplained, but they are no longer a candidate for it. ⚠ It also means the earlier observation "the call-home block (`0x2C`) was byte-identical across 218 samples today" **does not bear on this** — the last `0x2C` sample was at 13:30:23 and the keypad change came around 13:35. No sample exists on the far side of it. #### Why the split is the right design The operator's reading, and it is convincing: **you do not want modem settings reachable remotely.** Get them wrong over the air and you lose the connection you would need to put them back — the unit must then be visited physically. That explains the shape of the protocol rather than treating it as an oddity: | | reachable over the wire? | |---|---| | Call Home **enable**, dial string, retries, timings (`0x2C` / `0x7E`) | **yes** — recoverable if wrong | | **Modem type** and the rest of `SysParm.cfg` | **no** — a wrong value is unrecoverable remotely | `SUB 0x2C` is not an incomplete view of the modem configuration. It is the deliberately-chosen subset that is **safe to change remotely**, and the unreachable remainder is unreachable on purpose. #### Design principle for SFM There is a class of setting whose misconfiguration **destroys the channel you would use to fix it** — modem type, baud, serial parameters, the call-home destination itself. For those: 1. **Do not expose them for remote write**, or gate them behind an explicit confirmation that names the risk. 2. If they must be writable, use a **commit/confirm with automatic rollback** — apply, require the unit to call back within N minutes, revert if it does not. 3. **Always allow reading them**, so an operator can diagnose a unit they cannot reconfigure. Instantel chose option 1. Given the failure mode, that is defensible, and worth copying rather than improving on. ### The connection dial sets a COUNT, not a period Three configurations, each measured over many cycles: | status | connection | status observed | connection observed | separate conn checks per status cycle | |---|---|---|---|---| | 10 s | 10 s | 10.1 s ✅ | — | **0** (none ever ran) | | 30 s | 10 s | 30.4 s ✅ | 15.2 s ❌ | **2** | | 60 s | 30 s | 60.5 s ✅ | 60.5 s ❌ | **1** | **The status dial is honoured in all three** — within about 1%. **The connection dial is not honoured in any of them.** What actually holds is: ``` separate connection checks per status cycle = (status ÷ connection) − 1 ``` 1 → 0 checks, 3 → 2 checks, 2 → 1 check. So the field does not set a period; it sets how many checks fit inside a status cycle, and it is off by one. Consequences worth naming: - **Setting the two dials equal yields zero connection checks.** Every connection is then the expensive eleven-command status check. That is the default-looking configuration, and it is the worst one. - **"Every 30 s" with status at 60 s gives one check per minute** — half the advertised rate. - The observed cadences are not multiples of the connection setting at all (10 s → 15.2 s; 30 s → 60.5 s), so no simple scale factor describes it either. ⚠ The *timing within* a cycle is still not fully explained. At status 30 / connection 10 the two short checks landed at T+10.1 and T+25.3, where an evenly divided cycle would put them at T+10 and T+20. The count rule holds across all three configurations; the phase does not follow from it. **For SFM:** this is the concrete argument against exposing a dial whose behaviour is not what it says. If a control sets an interval, it should set that interval — and if the effective rate is derived from something else, the UI should show the derived value, not the input. ### Traffic at each configuration | status / connection | per minute | per day | per month | |---|---|---|---| | 10 s / 10 s | ~13 KB | **18.8 MB** | **563 MB** | | 60 s / 30 s (current) | ~3.4 KB | **4.9 MB** | **147 MB** | | a minimal check at 60 s | ~0.2 KB | **0.3 MB** | **9 MB** | The last row is `POLL` + `MONITOR_STATUS` only — enough to answer "alive?" and "monitoring?". ### ⚠ What this means for the "won't stay connected" failure Because idle polling **opens a fresh connection each time**, a silently-dead link is *less* dangerous here than expected — a dead socket fails at connect and the next cycle simply tries again. The earlier hypothesis (THOR wedged on a half-open socket during polling) is weakened by this. The exposure is during **operations**, not polling: THOR was observed holding one connection from **00:30 to 00:47 — seventeen minutes** — while downloading events and pushing setups. A link that dies silently mid-operation leaves THOR waiting on a socket the OS will not fail for roughly the default keepalive (~2 h). That remains the best candidate for a unit that will not come back and where refresh does nothing. **Testable with `mm_link.py`:** set `blackhole` *during* a download or config push rather than while idle, and watch whether THOR ever gives up, whether refresh emits any bytes, and whether it recovers when `pass` is restored. ### Design notes for SFM Three things this argues for, all cheap: 1. **Separate liveness from inventory.** Poll `POLL` + `MONITOR_STATUS` frequently; read config, setup name and call-home settings only when something says they changed, or on demand. 2. **Show the connection attempt.** Every check here has a visible outcome — connected, frames exchanged, closed. THOR surfaces none of it, which is why a failing unit is undiagnosable from the UI. The log this section is built from took one afternoon to produce and answers questions THOR cannot. 3. **Bound every operation with its own timeout**, independent of TCP's. Do not rely on the socket to report a dead peer. ## Reproduced: THOR stops polling after a connection drops mid-download **2026-09-25, one trial, on the bench link.** This is the failure the operator described in the field on UM12947 — *"just wouldn't stay connected… clicking the little refresh button did nothing… no way to view the status of a connection attempt."* ### What was done THOR was downloading events. Partway through the bulk transfer the link was faulted, the connection died, and the link was then fully restored. ⚠ **What THOR actually experienced was a TCP close mid-download**, not the silent link that was intended — `mm_link.py` had a bug (`socket.timeout` subclasses `OSError`, so 200 ms of quiet was mistaken for a closed socket). Fixed, but the run stands as a **drop-mid-download** test, which is arguably the more realistic case: a modem losing its tower often does produce a real close. ### What happened ``` 13:02:49.0 THOR connects, bulk download running (165 BULK_DOWNLOAD frames in) 13:02:50.0 link faulted -> connection dies mid-transfer 13:03:10.4 THOR reconnects once, 20 s later, sends SUB_1F (advance event pointer) — an attempt to resume 13:03:10.8 that attempt dies too 13:03:33.3 link fully restored and healthy ... 48 s of complete silence ... 13:04:21.4 operator clicks Refresh -> full 11-command check, every response correct, unit perfectly healthy ... 2 min 6 s of complete silence ... 13:06:27 still nothing. That refresh is the ONLY connection since 13:03:10. ``` Before the fault THOR was connecting every 30 s without a miss for over an hour. ### What this establishes 1. **One retry, then give up.** THOR retried once after 20 s and stopped. No backoff, no further attempts. 2. **The automatic poll loop dies with it.** Not just the download — the 30 s connection check and 60 s status check both stopped entirely. 3. **It does not recover when the link returns.** Sixteen minutes of healthy link produced **no automatic connection attempts at all** — only ones an operator initiated by hand. 4. **Refresh works, but only once.** The manual check succeeded completely — all eleven commands, correct responses. It did **not** restart the automatic loop; two minutes later there was still nothing. ⚠ **Point 4 is the dangerous one.** Refresh makes the UI report a healthy unit while nothing is actually watching it. The operator sees a successful check and moves on; the background loop stays dead. That is worse than a refresh button that plainly fails, because the failure is *silent and looks like success*. ### 🔑 "Not polling" and "unreachable" are different states — and THOR conflates them Demonstrated directly. At **13:10:44** an independent `POLL` through the same relay succeeded with a correct response while THOR's automatic polling was dead. ⚠ **A correction, recorded because the first version of this claim was sloppy.** It originally read "THOR connections since 13:04:22 : 0". That count was an artifact of a time filter (`13:0[4-9]`) that silently dropped everything from 13:10 onward. THOR *had* connected four times, at 13:10:03–13:10:37. Inspecting those four shows the conclusion survives — but for a better reason than the one first given. They were **user-initiated commands, not polling**: ``` 13:10:03 POLL → SUB_96 (start monitoring) 13:10:05 MONITOR_STATUS 13:10:35 POLL → SUB_97 (stop monitoring) 13:10:37 MONITOR_STATUS ``` Single commands a human clicked, each followed by one status read. Neither the eleven-command status check nor the three-command connection check appears. **Automatic polling did not resume**, and by 13:19:53 had been absent for **16 minutes**. Every THOR connection in that window was something an operator clicked: the refresh at 13:04:21, then start and stop monitoring at 13:10. **The unit is reachable. The link is fine. THOR is simply not asking.** **THOR reports `Connection Status: Connected`.** Captured at 13:15, eleven minutes after its last contact with the unit: ``` Connection Status : Connected <- false; nothing checked in 11 min Last Updated : 09/25/2026 01:04:22 PM <- true, and that is the REFRESH Notification : "Unable to download event(s) from UM12947 due to: Did not receive response from unit." (01:03:31 PM) ``` Three things worth separating: 1. **The green tile is false.** It reports a live connection that has not been exercised for eleven minutes. 2. **`Last Updated` is true** — and it is the only honest field on the screen. THOR *knows* when it last succeeded. It renders that as small grey text under the unit name, unhighlighted, with nothing marking it stale, beneath a large green **Connected** tile. The operator must read a timestamp and do mental arithmetic to discover the headline is wrong. 3. **The failure THOR did report was the download**, not the poller stopping. Those are treated as unrelated events; nothing says "automatic checking has ceased." So the state *"I have given up checking this unit"* is not merely undisplayed — THOR holds the data that would reveal it and presents a contradicting summary instead. So an operator cannot separate **the unit is down** from **the poller is asleep** — and those demand completely different responses: a site visit versus a mouse click. This also settles a question the previous section left open. It does not much matter whether stopping after one retry is an intentional design choice or a defect: **the reporting is wrong either way**, because both plausible displays misrepresent reality. It explains, too, why the field symptom resisted characterisation. The unit is perfectly reachable throughout. Poke it and it answers. Nothing is wrong with the unit, the modem, or the link. ### The stale display extends to monitoring state — a live unit shown as Idle The operator's own test: start monitoring **from the unit's keypad**, and see whether THOR ever notices. At **13:21:15**, verified by an independent probe through the same relay: ``` unit, on the wire SUB 0x49 data[11] = 0x02 MONITORING SUB 0x1C data[12] = 0x0c MONITORING device clock 13:21:14 THOR's last contact 13:10:37 (a stop-monitoring command it issued itself) THOR's display Connected · Monitoring Mode: Idle · Last Updated 1:04:22 PM ``` **A unit is actively recording and THOR shows it as Idle.** Had an event triggered in that window, THOR would not have known and would not have collected it. This is the operational consequence of the wedge: not merely a wrong indicator, but a monitoring system that has silently stopped monitoring its monitor. One further detail. THOR **did** contact the unit at 13:10:37 and read `MONITOR_STATUS` — yet `Last Updated` still reads 1:04:22. So a successful exchange does not refresh that timestamp; only the full status check does. The single honest field on the screen is honest about the wrong thing, which makes it useless as a staleness indicator precisely when staleness is the problem. ### Design consequences for SFM 0. **Connection status must EXPIRE.** The single highest-value fix here. If the last successful check is older than a small multiple of the check interval, the state is *stale / unknown* — never *Connected*. THOR already has the timestamp; it simply does not let it invalidate the summary. 1. **Retry must be unbounded with backoff**, never one-and-done. A transient link fault should not take a unit out of service until a human notices. 2. **A manual check must restart the automatic loop**, or the UI must state clearly that automatic checking is stopped. A green tick that means "it worked when you asked" is a trap. 3. **Surface the poll loop's own state** — last successful check, last attempt, next scheduled attempt, consecutive failures. All four were invisible here, and every one of them would have made this diagnosable in seconds. 4. **Distinguish "unit unreachable" from "we stopped checking."** Proven above to be different states that THOR cannot tell apart. A unit's displayed state should be one of: *checks passing*, *checks failing* (with the attempt count and last error), or *not being checked* (with why, and a way to resume). Collapsing the last two into one indicator is the root of this whole failure being undiagnosable. ### Caveats - **One trial.** Not yet repeated, and not yet tried with a true silent link (`blackhole` with the fixed tool) or with a clean `drop`. - THOR's UI state during the dead window was not recorded — worth capturing on a repeat, since what the operator *sees* is half the problem. - Whether a THOR restart is required, or whether it eventually recovers on a longer timescale, was not tested. ## THOR leaks event subscriptions — evidence from its own log Source: `thor.log.2026-09-22` from the office THOR PC, supplied 2026-09-25. 209 lines covering roughly ten hours. ### What the log contains ``` 178x UnitOperatingModeViewModel - Start/stop monitoring request timed out: False 6x UnitOperatingModeViewModel - Callback of start/stop monitor request received 5x UnitOperatingModeViewModel - Start/stop monitoring request timed out: True 3x SchedulerViewModel - Retrieving unit setup ... 2x BWLicenseManager - License State: IsKeyValid 0, IsActivated 0, ... ``` 85% of a day's logging is one message. Only **12** lines describe an actual operation. ### The message is emitted N times for ONE event, and N grows Grouping the repeated message by **exact timestamp to the millisecond** — so each group is one logical event, not repeated events: | time | identical lines | |---|---| | 11:48 | 1–4 | | 13:05 – 13:31 | 1–2 | | 13:36 – 13:40 | 2–4 | | 15:16 | 3 | | 16:03 – 16:07 | **6** | | 16:14 – 16:16 | 4–6 | | **22:00 – 22:06** | **12** | **1 → 12 over ten hours of uptime.** Twelve identical lines sharing one millisecond timestamp is not twelve events; it is one event dispatched to twelve handlers. ### What it is A **subscription leak**, and the class name identifies it: `UnitOperatingModeViewModel`. THOR is a .NET/WPF application ("App thread", ViewModel naming), where this is the archetypal leak — a view model subscribes to an event when its view opens and never unsubscribes when the view closes. Each open adds a live handler; every later notification runs them all. Consequences that follow directly: - N grows with **uptime and usage**, without bound. Ten hours of moderate use reached 12×; a THOR left running for days across a fleet will be far worse. - Every notification performs N× the work — N timers, N callbacks, N state updates racing one another. - **A restart resets N to 1**, which is consistent with the operator's report that only restarting THOR recovers a degraded session. - The only `timed out: True` entries in the file — five of them — sit at the very top, an episode captured just before log rotation. ⚠ **Claim discipline.** *Established* from this log: the handler count grows. *Strong inference*: it is a subscription leak. **Not established**: that it caused the specific field outage on 2026-09-22. This log does not cover that window, and the connection between leaked handlers and a dead TCP path is unproven. ### How to confirm it in ten minutes Restart THOR, note the burst size for one event, open and close a unit's detail view ten times, then trigger another event and re-count. If the burst grows, the leak is reproducible and attributable to a specific UI action. ### For SFM 1. **Unsubscribe on teardown**, or use weak event patterns. This is the single most common long-running-UI defect and it is entirely preventable. 2. **Log once per event, not once per handler.** The duplication here is what made the leak visible, which is lucky — but a log that is 85% one repeated line is also a log nobody reads, which is why this went unnoticed for a year. 3. **Log what changed, not what did not.** `timed out: False` 178 times is noise; the five `True` entries are the signal, and they are buried. ## Thor's conventions vs the protocol's requirements **SFM is not meant to reimplement Thor.** Thor is the only available teacher of the wire protocol, but almost nothing about *how* it sequences its work has been shown to be required by the device. Those are two different things and this document should not blur them. The distinction matters because several observations above were written as "a client should do X" when the honest statement is "Thor does X, and we have not checked whether the unit cares." | Thor does this | required? | what it means for SFM | |---|---|---| | `POLL → 0x15 → 0x49 → POLL` before **every** operation | **unknown** | Series III needed `POLL`×3 before `5A` *specifically*, so a preamble requirement is plausible — but Thor sends this before trivial reads too. **Testable:** issue one operation cold and see if it answers. | | Reads with `offset = 0xFFFF`, skipping the probe | **no — the probe works** | We have a genuine choice, and the probe is arguably better: it reports the length instead of making us trust a fixed one. This is the one place Thor's shortcut is probably worse. | | Sends `0x68` + `0x82` in every setup push | **unknown** | In both setup captures these carried near-zero payloads and **changed nothing**. If they are optional, a setup write is 3 frames instead of 7, with less to get wrong. **Worth testing before we build the writer.** | | Rewrites the whole 2,090-byte config for a one-field change | **unknown** | No narrower write has been observed. Read-modify-write is safe and known; a targeted write would be nicer but is unevidenced. | | Reads `0x41` twice in a row (scheduler capture, frames 6 and 8) | **no** | Plainly redundant. A reminder that Thor's sequence is not a minimal one. | | Greys out send-to-unit while monitoring | **Thor policy** | The *reason* is real — a push to the active setup overwrites silently. But "the button is grey and you figure it out" is a UX choice, not the only answer. SFM could offer stop → push → restart as one operation, which is what an operator actually wants. | | Names the target with `0xDA` before a config write | **almost certainly required** | The unit has to know which file to write. Closest thing here to a genuine protocol requirement. | ### The schedule↔config coupling is Thor's, not the protocol's In Thor, putting "start monitoring" in a schedule forces you to attach a setup, and sending the schedule pushes that setup at the same time — overwriting whatever already has that name. **The protocol does not require any of this.** The evidence, from the scheduler capture: - **The two writes are separate operations**, not one transaction. The config write ends at frame 18 (`0x72`), then Thor sends a fresh `POLL` preamble, and only then opens the schedule for writing at frame 20. Different commands, different paths, no shared state: ``` config 0xDA → 0x68/0x73 → 0x82/0x83 → 0x71/0x72 schedule 0x8D → 0x8E ``` - **The schedule stores a NAME, not a config.** A length-prefixed filename is all an entry carries. It is a *reference*, and references do not require the referent to be rewritten. - **The config Thor pushed was already on the unit, unchanged.** Its 2,090-byte `0x71` payload is **byte-identical** to the previous capture's — zero differences. Thor spent a full block write re-sending a setup the device already had, purely to satisfy its own coupling. So the sequencing is a Thor UI decision, and a costly one. **What SFM can do instead, with today's protocol and nothing new:** 1. Enumerate the unit's setups with `0x3F` / `0x40` — cheap, read-only, and it yields the exact names a schedule may legally reference. 2. Write **only** the schedule (`0x8D` / `0x8E`) when the referenced setup is already present. 3. Push a config only when it is actually missing, or when the operator deliberately edited it — and say so explicitly rather than silently. That removes the write entirely from the common case: scheduling against a setup that already exists becomes a 524-byte schedule write instead of 524 bytes plus a 2,090-byte config overwrite. ⚠ **And it removes a real hazard.** Because the reference is by name, and because a same-name write overwrites silently with an indistinguishable ack, Thor's pattern means *scheduling* something can quietly rewrite a setup that other schedules — or the operator's own saved work — depend on. Nothing in the protocol or the ack reports that this happened. Validating the reference instead of rewriting the referent avoids the whole class of problem. ### ✅ The schedule record format — DECODED (2026-09-24) A debug printf in the scheduler names the fields outright: ``` SCHEDULER : _ReadRecord(%d) -> %s (Action=%u, 1/2h=%u, Day=%u, Setup="%s") [WDAY=%d] ``` The captured file is **two 260-byte records plus four zero bytes** — 524 bytes exactly. Every non-zero byte in the file falls inside those two records. ``` offset field size notes [0] schedule flags 1 record 0 ONLY — 3 = repeat on, 2 = repeat off [1:4] padding 3 zero in every record observed [4] 1/2h 1 half-hour slot, 0–47 [5] Day 1 3 in every record observed — undetermined [6] Action 1 bitmask: 2 start, 4 stop, 8 self-check, 16 ACH [7] name length 1 [8:260] Setup name 252 null-padded; empty for actions that take no setup ``` ⚠ `[0]` and `[6]` were **mislabelled twice** before a five-entry schedule separated them — see *CORRECTED* below. Decoded against the operator's own description of what they entered — *"start monitoring TEST1 at 7:30 AM, Auto Call Home at 7:30 PM"*: | | record @0 | record @260 | |---|---|---| | `Action` | **3** | **0** | | `1/2h` | 15 → **07:30** ✅ | 39 → **19:30** ✅ | | `Day` | 3 | 3 | | `[6]` | 2 | 16 | | name length | 9 | **0** | | Setup | `TEST1.mmb` | *(none)* ✅ | **Five independent confirmations**, no fitting required: 1. Slot 15 = 07:30 and slot 39 = 19:30, both matching the operator's stated times, on a 30-minute grid — and 39 − 15 = 24 slots = exactly 12 hours. 2. The name-length byte reads 9 for `TEST1.mmb`, 40 for the 40-character name in the read capture, and **0** for the Auto Call Home entry. 3. **Auto Call Home carries no setup name** — which is what it should do, and direct proof that a schedule entry can exist with no setup attached. 4. The 260-byte stride lands the second record's `1/2h` exactly at `[264]`. 5. `2 × 260 + 4 = 524`, the whole body, with nothing left over. ⚠ **Correction:** the `27 03 10` bytes at `[264]` were previously recorded here as a possible trailer. They are **record 2's `1/2h`, `Day` and `[6]` fields**. Not a trailer — a second record hiding behind an assumption that the file held one. Action codes, so far: | value | action | |---|---| | `0` | **Auto Call Home** | | `3` | **Start monitoring, with setup** | ### ⚠ CORRECTED — `[6]` is the Action, and it is a bitmask (2026-09-25) A five-entry schedule settled this, and it **overturns the two previous readings of this record.** The operator's Thor screen, captured alongside: ``` actioncheck — Micromate — Repeat Daily: Disabled 7:30 AM Start Monitoring TEST1 8:00 AM Stop Monitoring 8:30 AM Self Check 9:00 AM Start Monitoring test2 7:30 PM Auto Call Home ``` Five entries, and the file holds exactly five 260-byte records: | rec | `[0]` | `1/2h` | `Day` | **`[6]`** | len | name | |---|---|---|---|---|---|---| | @0 | **2** | 15 = 07:30 | 3 | **2** | 9 | `TEST1.mmb` | | @260 | 0 | 16 = 08:00 | 3 | **4** | 0 | | | @520 | 0 | 17 = 08:30 | 3 | **8** | 0 | | | @780 | 0 | 18 = 09:00 | 3 | **2** | 9 | `test2.mmb` | | @1040 | 0 | 39 = 19:30 | 3 | **16** | 0 | | **`[6]` tracks the action exactly, and the values are powers of two:** | `[6]` | action | |---|---| | `2` | Start Monitoring | | `4` | Stop Monitoring | | `8` | Self Check | | `16` | Auto Call Home | Bits 1–4 of a bitmask. **Bit 0 (value `1`) is unobserved** — a natural home for the setup-less `DUTYCYCLE_START_MONITOR`, but that is a guess, not a finding. **Two retractions:** 1. `[6]` was recorded as *"the one unidentified field"* and predicted to be the Repeat flag. It is the **Action**. 2. `[0]` was labelled **Action**, then *"Action with repeat folded in"*. Both wrong. `[0]` is **non-zero only on record 0** — including here, where record 0 and record 3 are the same action with different `[0]` values. It is a **schedule-level field carried in the first record**, and it holds the Repeat flag: `3` repeat on, `2` repeat off, matching *"Repeat Daily: Disabled"* on the screen. The earlier repeat capture was consistent with both readings because it had one start entry and changed one byte. A single-variable test is not always enough — it took a schedule with four distinct actions to separate the two fields. `Day` is `3` on all five records across every capture. Still undetermined. ### ✅ `SUB 0x47` is the scheduler enable — confirmed by Thor's own notifications Previously recorded as *"genuinely undetermined"* whether `0x47` sets or reads. Thor's notification pane timestamps it: ``` 00:51:29 schedule write (0x8E) completes 00:51:31 Thor: "actioncheck has been successfully SENT to ... UM12947" 00:51:31 → 0x47 params[7] = 0x01 00:51:33 → 0x47 params[7] = 0x03 (on the wire as 10 03 — DLE-escaped) 00:51:35 Thor: "actioncheck was successfully ENABLED on ... UM12947" ``` The only frames between *sent* and *enabled* are the `0x47` pair. **`0x47` performs the enable.** ⚠ The meaning of `params[7]` ∈ {1, 3} is still open — a single `0x47` with `params[7] = 3` also appears at *session start*, where nothing is being enabled, so the byte is more likely a selector than a value. Note it must be **DLE-escaped**: a bare `0x03` in params truncates the frame at what the device reads as ETX. ### 🔑 Setups ARE written as raw `.MMB` files — second retraction This document twice stated that setups are *not* pushed as raw `.MMB` blobs and go only through `0xDA` + the config block. **Wrong.** In this session Thor did both, choosing by whether the setup is the active one: | setup | how it was written | |---|---| | `TEST1.mmb` (**active**) | `0xDA` → `0x68`/`0x73` → `0x82`/`0x83` → `0x71`/`0x72` | | `test2.mmb` (not active) | `0x8D` `\system\setups\test2.mmb` → `0x8E` (2,192 B) | So **`\system\setups\.mmb`** is the setup directory, and the generic file transfer writes there directly. The `.MMB` file is *nearly* the compliance block: aligning the two gives **1,968 / 2,086 bytes equal (94.3%) at a 4-byte shift**. The file is 102 bytes longer (2,192 vs 2,090) and puts the setup name at offset 38 where the `0x71` block has it at 42. So the on-disk setup and the wire compliance block are the same structure with different framing — not two formats. **This is the cleaner path for SFM.** Writing a setup as a file needs two frames and no `0xDA`/`0x68`/`0x82` ritual, and it does not disturb the active setup. ### File writes are chunked The schedule's 1,304 bytes went as **two `0x8E` frames, 1,024 + 280**, each with `offset` = that chunk's own length. The 2,192-byte setup file went in **one** frame, so 1,024 is not a hard ceiling — the chunking rule is unexplained and recorded as observed. ### Six duty-cycle actions, not five The full `_PSA()` dispatch — the scheduler's own action processor, reading records via `_ReadRecord`: ``` DUTYCYCLE_START_MONITOR ← no setup DUTYCYCLE_START_MONITOR_WITH_SETUP DUTYCYCLE_START_MONITOR_WITH_SETUP_STOP_COMPLETE ← a third start variant DUTYCYCLE_STOP_MONITOR DUTYCYCLE_CALLHOME DUTYCYCLE_SELF_CHECK ``` **THOR exposes four.** Two start variants it never offers, one of which needs no setup file at all. `_PSA() send ->> CMD_DUTYCYCLE_START_MONITOR` shows the setup-less action is live code that the scheduler genuinely dispatches, not a dead case — it sends a real message. And since every one of these cases sits in the function that consumes `_ReadRecord`'s `Action` field, **a schedule record can carry it.** An alternative explanation was checked and ruled out: the Micromate does have a separate *Timer Mode* (`MODE_TIMER`, `MODE_MONITOR_TIMER`, `Monitor Once Only`, under Special Setup), so `START_MONITOR` could have belonged to that path instead. It does not — it is in `_PSA()`, the scheduler's dispatcher. ### The scheduler enable lives in SysPref, not in the setup ``` SysPref.bMonitorScheduler = %s _Task() CMD_SET_SCHEDULE - MonitorScheduler ENABLED _Task() CMD_SET_SCHEDULE - ! MonitorScheduler NOT Enabled ``` This **confirms the failed prediction above from the other side**: the `Scheduler On/Off` switch is a *system preference*, which is why the `0x71` compliance block was byte-identical when the scheduler was turned on. It was never going to be in there. It also suggests `0x47` — the two bare frames at the end of the capture — is a SysPref get/set rather than anything scheduler-specific, which would explain its `params[7]` selector and its 15-byte response shape being shared with `0x48`'s page-0 descriptor. ⚠ Still a hypothesis; the disable/enable capture settles it. #### The schedule has a setup-less start action — Thor just never uses it The coupling above looked like it might be a workaround for the unit crashing on a missing setup. The firmware says otherwise: **there are two distinct start-monitoring actions**, and only one of them involves a setup file. ``` _PSA() case DUTYCYCLE_START_MONITOR _PSA() case DUTYCYCLE_START_MONITOR_WITH_SETUP ``` The full duty-cycle action set, from the scheduler task's own dispatch strings: | action | notes | |---|---| | `DUTYCYCLE_START_MONITOR` | **no setup involved** — uses whatever config is loaded | | `DUTYCYCLE_START_MONITOR_WITH_SETUP` | the variant Thor always emits | | `DUTYCYCLE_STOP_MONITOR` | | | `DUTYCYCLE_CALLHOME` | `send ==> CMD_SCHEDULE_CALL_HOME` | | `DUTYCYCLE_SELF_CHECK` | `call DailySelfCheck()` | | `DUTYCYCLE_ON` / `OFF` / `NEXT` | scheduler state, not per-entry actions | So the schedule↔config coupling is not a crash workaround — **Thor picks the more demanding of two available actions, every time.** SFM can emit `START_MONITOR` and skip the config entirely. On whether a missing setup *would* break the unit: the firmware suggests graceful degradation rather than a crash — `Setup File Not Found` exists, and so does `Invalid parameters reset to factory default - please review setup`, which is a deliberate fallback. ⚠ **Untested**, and not worth relying on until it is. ⚠ **Hypothesis, not a finding:** the schedule entry's leading byte may be the action code — the one captured entry reads `03 00 00 00 0f 03 02 [namelen] [name]`, and `03` would fit `START_MONITOR_WITH_SETUP` in an enum of the above. Unverified; there is one entry and nothing to diff against. **The capture that would settle it** (more valuable than the `0x47` disable/enable test): build a schedule whose entry is **stop monitoring** or **call home** — an action with no setup attached. That would (a) confirm or kill the action-code hypothesis, since a setup-less action should change the leading byte and drop the name, and (b) prove from the other direction that a schedule can be sent with no config push at all. Note also `DUTYCYCLE_CALLHOME` → `CMD_SCHEDULE_CALL_HOME`: **a scheduled call-home is a way to make a unit dial out on demand**, which is the one remaining lever on the unsolved call-home direction. ### What this implies for the build order The read path is fully known and needs no Thor-shaped decisions, so a read-only client can be written now with confidence. **The write path should not be built by transcribing Thor's sequence** — the `0x68`/`0x82` question above decides whether our setup write is 3 frames or 7, and it is answerable with one capture plus one careful experiment. Two reliability problems worth designing *against*, both observed rather than assumed: 1. **A zero ack does not mean a write applied.** Every ack seen is 11 zero bytes, across creates and overwrites alike, and no failing write has ever been observed. Whatever SFM does, it should read back and compare rather than trust the ack. `0x41` + `0x1A` makes that cheap. 2. **Nothing warns before clobbering a monitoring unit's active setup.** The device will not stop it and the ack will not distinguish it. That guard has to live in SFM. ## ⚠ 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 known, never sent by us - **Call-home write** (`0x7E` / `0x7F`) — ✅ observed 2026-09-25, acks `0x81` / `0x80`; enable flag isolated - **Per-event delete** (`0xA8` / `0xAA`) — ✅ observed 2026-09-25, acks `0x57` / `0x55`. **Destructive.** Series III's `0xA3`/`0xA2` erase-all has *not* been seen on Series IV and may not exist - **Start / stop monitoring** (`0x96` / `0x97`) — observed via Thor 2026-09-24, acks `0x69` / `0x68` - **Scheduler enable** (`0x47`) — observed; `params[7]` semantics still unclear Every one of these is now *observed*. **None has ever been originated by us**, and that is the line that still matters: the wire format is known, our encoder is unwritten and unproven. - `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*.) - The **call-home session's bytes** — the device-initiated direction has still not been observed at all, and cannot be over USB. ✅ **The second half of this item is resolved** (2026-09-24, from the THOR manual): "how it learns an event was accepted so it stops re-sending it" was the wrong question — **it does not learn.** Collection state is server-side bookkeeping and erase is a separate, optional, server-issued action. See *What the THOR manual settles about the ACH session*. What remains unknown is the framing of the inbound session and the **erase opcodes**, not any acknowledgement mechanism. --- ## 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/25 sittings used a topology worth keeping, 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.** **Run the relay with `-x` and keep its log.** It hex-dumps every forwarded byte in both directions, which makes it a complete second copy of every capture, independent of whether seismo_lab was recording: ``` socat -d -d -x TCP-LISTEN:12345,reuseaddr,fork /dev/ttyACM0,raw,echo=0,b115200 \ > ~/mm-captures/socat_.log 2>&1 ``` That paid off on 2026-09-25, when a capture's `.bin` files never left the Windows machine — the session was rebuilt from the relay log with `scratch/socat_log_split.py`. When the real bins arrived later the reconstruction was **byte-for-byte identical in both directions** (3,595 and 4,004 bytes), so the log is a validated fallback rather than an approximation. Sessions concatenate in one log; split them on the `accepting connection` markers. ⚠ 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.