Followed up the write[118:120] field flagged in the previous commit. A third
sample plus a brute-force sweep rules out most of the obvious explanations.
Samples: 43 23 at 00:50 (idle), still 43 23 at 01:16 twenty-six minutes later,
then 2e 5e after a config write. Thor echoes back whatever it last read --
including a value that no longer matches the config it is sending -- and the
write is accepted regardless.
Ruled out:
* a clock or timer -- identical across 26 minutes of idle; only a write moved it
* a counter -- it decreased, 17187 -> 11870
* computed by Thor -- Thor demonstrably sends a stale value
* a standard CRC16 -- swept all 65,536 polynomials x init {0x0000,0xFFFF} x all
four reflection combinations over four candidate regions.
No match. Recorded so the sweep is not repeated.
It behaves like a unit-computed hash: a one-byte input change scattered the output
(XOR 0x6D7D) where a sum would move by 1. But two samples cannot separate that
from a nonce regenerated per write.
Operationally it does not matter, which is the point: the unit does not validate
the field on input, so read-modify-write with the rest of the block echoed
verbatim is provably safe. Never synthesise or zero it.
Notes what would resolve it -- several config writes with times recorded, cheap to
collect during any future ACH capture.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ru8Lg9HkkYvX9VWWo65SmL
2029 lines
89 KiB
Markdown
2029 lines
89 KiB
Markdown
# 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: <float> 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 <path> → 0x6B open for READ
|
||
0x48 → 0xB7 read next page; repeat until an all-zero response
|
||
0x8D <path> → 0x72 open for WRITE
|
||
0x8E <body> → 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) → <the operation>
|
||
```
|
||
|
||
⚠ **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 0x0E 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
|
||
```
|
||
|
||
⚠ **The monitoring flag is `0x0E`, not Series III's `0x10`.** Do not reuse the
|
||
Series III constant. 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 <null-terminated name> 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:
|
||
|
||
```
|
||
<serial>_<YYYYMMDDHHMMSS>.IDF{W,H} e.g. UM12947_20260923163319.IDFW
|
||
```
|
||
|
||
versus Series III's `<letter><serial3><4-char base-36 stem><AB0T ext>`, 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] = <event key> → 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.
|
||
|
||
## 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\<name>.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:<bridge port>` 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_<ts>.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.
|