The operator supplied the ground truth: entry 1 is "start monitoring TEST1" at
07:30, entry 2 is "Auto Call Home" at 19:30. That decodes the file completely.
The body is two 260-byte records plus four zero bytes = 524 exactly, and every
non-zero byte falls inside them:
[0] Action (0 = Auto Call Home, 3 = start monitoring with setup)
[1:4] padding
[4] 1/2h half-hour slot, 0-47
[5] Day
[6] ?? the one unidentified field
[7] name length
[8:260] setup name, null-padded
record @ 0: Action=3 1/2h=15 -> 07:30 Day=3 [6]=2 namelen=9 "TEST1.mmb"
record @260: Action=0 1/2h=39 -> 19:30 Day=3 [6]=16 namelen=0 (no setup)
Five independent confirmations, no fitting:
1. Slots 15 and 39 match the stated 07:30 and 19:30 on a 30-minute grid, and
39-15 = 24 slots = exactly 12 hours.
2. The length byte reads 9 for TEST1.mmb, 40 for the long name in the read
capture, and 0 for the Auto Call Home entry.
3. Auto Call Home carries NO setup name -- direct proof that a schedule entry
can exist with no setup attached, which is what the earlier
DUTYCYCLE_START_MONITOR finding predicted from the firmware side.
4. The 260-byte stride lands record 2's 1/2h exactly at [264].
5. 2 x 260 + 4 = 524, the whole body, nothing left over.
CORRECTION: `27 03 10` at [264] was recorded in the previous commit as a possible
trailer. It is record 2's 1/2h, Day and [6] fields. I had assumed the file held
one record and read the second one as padding -- the non-zero bytes were sitting
there the whole time.
Still open: [6] (2 on the start entry, 16 on the ACH entry -- a Repeat or Day/Week
capture would isolate it), the four remaining action codes, and whether the file
can hold unused record slots.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ru8Lg9HkkYvX9VWWo65SmL
1729 lines
76 KiB
Markdown
1729 lines
76 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.
|
||
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.
|
||
|
||
## 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] Action 1 (or uint32 LE at [0:4] — both are zero-extended here)
|
||
[1:4] padding 3 zero in both records
|
||
[4] 1/2h 1 half-hour slot, 0–47
|
||
[5] Day 1 3 in both records
|
||
[6] ?? 1 the one unidentified field
|
||
[7] name length 1
|
||
[8:260] Setup name 252 null-padded
|
||
```
|
||
|
||
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** |
|
||
|
||
⚠ `[6]` is the one field still unidentified — `2` on the start entry, `16` on the
|
||
Auto Call Home entry. It is not the name length and not the time. A capture
|
||
that changes only the **Repeat** checkbox, or switches Day↔Week, would isolate it.
|
||
|
||
⚠ Also still unknown: whether the file can hold more than the records in use
|
||
(both captures had every used record contiguous from offset 0), and the remaining
|
||
four action codes.
|
||
|
||
### 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 now known, never sent by us
|
||
- **Call-home write** (`0x7E` / `0x7F`) — not observed at all
|
||
- **Erase** (`0xA3` / `0xA2`) — **the only genuinely untouched destructive path**
|
||
- **Start / stop monitoring** (`0x96` / `0x97`) — observed via Thor 2026-09-24,
|
||
acks `0x69` / `0x68`; still never originated by us
|
||
- `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 sitting used a different topology worth noting, because it is
|
||
reusable: `socat` on mint-mac shares `/dev/ttyACM0` on TCP, seismo_lab's TCP
|
||
bridge relays Thor to it and records both directions. Thor is configured with
|
||
the unit at `127.0.0.1:<bridge port>` exactly as if it were a field modem. No
|
||
modem, no SIM, and **nothing on the production Thor box is touched.**
|
||
|
||
⚠ Firmware is the single biggest caveat on this document. Every finding here
|
||
is from one unit on the Blastware build. A `11.0BD` unit has not been
|
||
touched.
|
||
|
||
✅ The original "empty unit" limitation is gone — `0x08`, `0x1E`, `0x0A` and
|
||
`0x06` were all exercised against 5 stored events on 2026-09-23.
|