Removes the last caveat. The Benfei (Prolific PL2303) on the bench is physically the cable from the failed office setup, not a substitute picked up later. The chain has no inference left: that cable is a PL2303; the Micromate USB host has no Prolific driver in either firmware line; a laptop on the same cable and modem round-trips perfectly; the Micromate on it never answered. The FTDI cable arriving 2026-09-26 is now a confirmation rather than a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ru8Lg9HkkYvX9VWWo65SmL
2701 lines
120 KiB
Markdown
2701 lines
120 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 NON-ZERO = monitoring, 0x00 = idle
|
||
data[13] day
|
||
data[14] month
|
||
data[15:17] year, uint16 BE
|
||
data[17] ⚠ unidentified — 32 while monitoring, 100 when idle
|
||
data[18] hour ─┐ device clock, verified against the capture's
|
||
data[19] minute │ own wall time (19:12:25 → 19:13:34 EDT)
|
||
data[20] second ─┘
|
||
data[-8:-4] memory total, uint32 BE = 15,000,000 bytes exactly
|
||
data[-4:] memory free, uint32 BE
|
||
```
|
||
|
||
⚠ **Test `data[12]` for non-zero — do NOT compare against a constant.** It read
|
||
`0x0E` on 2026-09-24 and `0x0C` on 2026-09-25, both while monitoring, so it
|
||
carries sub-state in its low bits rather than being a flag. Series III's `0x10`
|
||
does not apply either. `SUB 0x49` `data[11]` read `0x02` in every monitoring
|
||
sample so far, but the same caution applies until more values are seen. Only five bytes differ between the monitoring and idle
|
||
responses: the flag, `data[17]`, the clock, and the memory-free field.
|
||
|
||
Memory free dropped by exactly **4,096 bytes** across the ~70-second monitoring
|
||
session — monitoring allocates as it runs, so free memory is not a stable value
|
||
to compare against.
|
||
|
||
The device clock is free here, on a command Thor already sends. That is worth
|
||
having: nothing else read so far reports the unit's own time.
|
||
|
||
### `SUB 0x49` → `0xB6` — a cheap state check
|
||
|
||
16-byte data, in Thor's preamble before every operation:
|
||
|
||
```
|
||
05 00 00 00 00 00 00 00 00 00 00 [ST] e8 00 0b 00
|
||
↑ data[11]: 0x02 monitoring, 0x00 idle
|
||
```
|
||
|
||
**A second monitoring indicator, in a 21-byte response instead of `0x1C`'s 60.**
|
||
Different encoding from `0x1C`'s flag (`0x02` vs `0x0E`), so they are separate
|
||
fields, not the same byte read twice. For a polling client this is the cheaper
|
||
of the two, and Thor evidently treats it as the routine one.
|
||
|
||
### `SUB 0x3F` / `0x40` — walking the setup-file list ✅
|
||
|
||
The same first/next shape as Series III's `1E`/`1F` event walk, applied to
|
||
setup files:
|
||
|
||
| request | ack | meaning |
|
||
|---|---|---|
|
||
| `0x3F` | `0xC0` | **first** setup record |
|
||
| `0x40` | `0xBF` | **next** setup record; repeat until the name is empty |
|
||
|
||
Every record is 266 bytes of data and carries **nothing but the name**:
|
||
|
||
```
|
||
ff 00 00 00 00 00 00 00 00 00 00 <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.
|
||
|
||
## What THOR's "status check" actually is (2026-09-25)
|
||
|
||
Captured with `bridges/mm_link.py` standing in for the modem, THOR configured
|
||
`Communication: TCP`, **Check connection every 5 s**, **Check status every 5 s**.
|
||
|
||
### It is eleven commands, not one
|
||
|
||
```
|
||
POLL → DEVICE_INFO → 0x49 → 0x5C → MONITOR_STATUS → SETUP_NAME_READ
|
||
→ STORAGE_RANGE → 0x02 → OPERATOR → 0x47 → CALLHOME_CFG
|
||
```
|
||
|
||
Each check opens a **new TCP connection**, runs all eleven exchanges in ~300 ms,
|
||
and closes it. Measured over 21 consecutive checks:
|
||
|
||
| | |
|
||
|---|---|
|
||
| request payload | **236 B** |
|
||
| response payload | **936 B** |
|
||
| TCP connect + teardown | ~1,000 B (fresh handshake every time) |
|
||
| **per check** | **~2.2 KB** |
|
||
|
||
### The cost
|
||
|
||
| cadence | checks/day | per unit |
|
||
|---|---|---|
|
||
| **every 10 s (as observed)** | 8,640 | **18.8 MB/day — 563 MB/month** |
|
||
| every 60 s | 1,440 | 3.1 MB/day — 94 MB/month |
|
||
| every 15 min | 96 | 0.2 MB/day — 6 MB/month |
|
||
|
||
⚠ **On a metered cellular plan this is real money, and most of it is waste.**
|
||
A status check re-reads the call-home config, the operator name, the active setup
|
||
name and the full device info block **every ten seconds** — none of which changes
|
||
between checks. `SETUP_NAME_READ` alone returns 274 bytes each time.
|
||
|
||
A genuine liveness + state check is **`POLL` + `MONITOR_STATUS`**: two commands,
|
||
131 bytes of response. Same information about whether the unit is alive and
|
||
whether it is monitoring, for a ninth of the traffic.
|
||
|
||
### There are TWO checks, and only one of the dials is honoured
|
||
|
||
The eleven-command sequence is the **status check**. There is also a much
|
||
cheaper **connection check** — three commands:
|
||
|
||
```
|
||
connection check POLL → SERIAL → 0x49
|
||
status check POLL → DEVICE_INFO → 0x49 → 0x5C → MONITOR_STATUS
|
||
→ SETUP_NAME_READ → STORAGE_RANGE → 0x02 → OPERATOR
|
||
→ 0x47 → CALLHOME_CFG
|
||
```
|
||
|
||
⚠ **With both dials equal, the connection check never runs separately at all** —
|
||
every connection observed was the full eleven. It only appears once the two
|
||
intervals differ. That alone explains a lot of "changing the settings does
|
||
nothing": at equal values you only ever get the expensive one.
|
||
|
||
With *connection check = 10 s* and *status check = 30 s*, the steady state over
|
||
14 consecutive cycles is:
|
||
|
||
```
|
||
FULL at T
|
||
short at T + 10.1
|
||
short at T + 25.3 (+15.2)
|
||
FULL at T + 30.4 (+5.1)
|
||
```
|
||
|
||
| dial | set to | observed | |
|
||
|---|---|---|---|
|
||
| status check | 30 s | **30.4 s** | ✅ honoured |
|
||
| connection check | 10 s | **15.2 s** | ❌ 52% slow |
|
||
|
||
27 consecutive connection-check gaps, all 15.1–15.3 s. That is systematic, not
|
||
jitter.
|
||
|
||
> ⚠ **A "half the status period" hypothesis was raised here and REFUTED.** It
|
||
> predicted that with status at 60 s the connection check would run at 30 s.
|
||
> Measured: **60.5 s**. Recorded rather than deleted — the prediction was sharp
|
||
> and it was wrong.
|
||
|
||
### Modem settings live in `SysParm.cfg`, not in the setup — and that is deliberate
|
||
|
||
**Settled by experiment (2026-09-25).** The unit's *call modem* setting was
|
||
changed on the keypad from `generic` to `USB to PC`. Then the **active setup was
|
||
switched from `TEST1` to `test2` on the device**, and the setting was checked
|
||
again: **unchanged.**
|
||
|
||
So it does not travel with the setup. It is device-global, in the `SysParm.cfg`
|
||
the firmware strings already hinted at, alongside `SysPref.bMonitorScheduler`.
|
||
|
||
That closes a hypothesis this document was pursuing: the ~102 bytes a `.MMB`
|
||
**file** carries beyond the 2,090-byte config block are **not** where this lives.
|
||
Those bytes remain unexplained, but they are no longer a candidate for it.
|
||
|
||
⚠ It also means the earlier observation "the call-home block (`0x2C`) was
|
||
byte-identical across 218 samples today" **does not bear on this** — the last
|
||
`0x2C` sample was at 13:30:23 and the keypad change came around 13:35. No sample
|
||
exists on the far side of it.
|
||
|
||
#### Why the split is the right design
|
||
|
||
The operator's reading, and it is convincing: **you do not want modem settings
|
||
reachable remotely.** Get them wrong over the air and you lose the connection
|
||
you would need to put them back — the unit must then be visited physically.
|
||
|
||
That explains the shape of the protocol rather than treating it as an oddity:
|
||
|
||
| | reachable over the wire? |
|
||
|---|---|
|
||
| Call Home **enable**, dial string, retries, timings (`0x2C` / `0x7E`) | **yes** — recoverable if wrong |
|
||
| **Modem type** and the rest of `SysParm.cfg` | **no** — a wrong value is unrecoverable remotely |
|
||
|
||
`SUB 0x2C` is not an incomplete view of the modem configuration. It is the
|
||
deliberately-chosen subset that is **safe to change remotely**, and the
|
||
unreachable remainder is unreachable on purpose.
|
||
|
||
#### Design principle for SFM
|
||
|
||
There is a class of setting whose misconfiguration **destroys the channel you
|
||
would use to fix it** — modem type, baud, serial parameters, the call-home
|
||
destination itself. For those:
|
||
|
||
1. **Do not expose them for remote write**, or gate them behind an explicit
|
||
confirmation that names the risk.
|
||
2. If they must be writable, use a **commit/confirm with automatic rollback** —
|
||
apply, require the unit to call back within N minutes, revert if it does not.
|
||
3. **Always allow reading them**, so an operator can diagnose a unit they cannot
|
||
reconfigure.
|
||
|
||
Instantel chose option 1. Given the failure mode, that is defensible, and worth
|
||
copying rather than improving on.
|
||
|
||
### The connection dial sets a COUNT, not a period
|
||
|
||
Three configurations, each measured over many cycles:
|
||
|
||
| status | connection | status observed | connection observed | separate conn checks per status cycle |
|
||
|---|---|---|---|---|
|
||
| 10 s | 10 s | 10.1 s ✅ | — | **0** (none ever ran) |
|
||
| 30 s | 10 s | 30.4 s ✅ | 15.2 s ❌ | **2** |
|
||
| 60 s | 30 s | 60.5 s ✅ | 60.5 s ❌ | **1** |
|
||
|
||
**The status dial is honoured in all three** — within about 1%.
|
||
|
||
**The connection dial is not honoured in any of them.** What actually holds is:
|
||
|
||
```
|
||
separate connection checks per status cycle = (status ÷ connection) − 1
|
||
```
|
||
|
||
1 → 0 checks, 3 → 2 checks, 2 → 1 check. So the field does not set a period; it
|
||
sets how many checks fit inside a status cycle, and it is off by one.
|
||
|
||
Consequences worth naming:
|
||
|
||
- **Setting the two dials equal yields zero connection checks.** Every
|
||
connection is then the expensive eleven-command status check. That is the
|
||
default-looking configuration, and it is the worst one.
|
||
- **"Every 30 s" with status at 60 s gives one check per minute** — half the
|
||
advertised rate.
|
||
- The observed cadences are not multiples of the connection setting at all
|
||
(10 s → 15.2 s; 30 s → 60.5 s), so no simple scale factor describes it either.
|
||
|
||
⚠ The *timing within* a cycle is still not fully explained. At status 30 /
|
||
connection 10 the two short checks landed at T+10.1 and T+25.3, where an evenly
|
||
divided cycle would put them at T+10 and T+20. The count rule holds across all
|
||
three configurations; the phase does not follow from it.
|
||
|
||
**For SFM:** this is the concrete argument against exposing a dial whose
|
||
behaviour is not what it says. If a control sets an interval, it should set that
|
||
interval — and if the effective rate is derived from something else, the UI
|
||
should show the derived value, not the input.
|
||
|
||
### Traffic at each configuration
|
||
|
||
| status / connection | per minute | per day | per month |
|
||
|---|---|---|---|
|
||
| 10 s / 10 s | ~13 KB | **18.8 MB** | **563 MB** |
|
||
| 60 s / 30 s (current) | ~3.4 KB | **4.9 MB** | **147 MB** |
|
||
| a minimal check at 60 s | ~0.2 KB | **0.3 MB** | **9 MB** |
|
||
|
||
The last row is `POLL` + `MONITOR_STATUS` only — enough to answer "alive?" and
|
||
"monitoring?".
|
||
|
||
### ⚠ What this means for the "won't stay connected" failure
|
||
|
||
Because idle polling **opens a fresh connection each time**, a silently-dead link
|
||
is *less* dangerous here than expected — a dead socket fails at connect and the
|
||
next cycle simply tries again. The earlier hypothesis (THOR wedged on a
|
||
half-open socket during polling) is weakened by this.
|
||
|
||
The exposure is during **operations**, not polling: THOR was observed holding one
|
||
connection from **00:30 to 00:47 — seventeen minutes** — while downloading events
|
||
and pushing setups. A link that dies silently mid-operation leaves THOR waiting
|
||
on a socket the OS will not fail for roughly the default keepalive (~2 h). That
|
||
remains the best candidate for a unit that will not come back and where refresh
|
||
does nothing.
|
||
|
||
**Testable with `mm_link.py`:** set `blackhole` *during* a download or config
|
||
push rather than while idle, and watch whether THOR ever gives up, whether
|
||
refresh emits any bytes, and whether it recovers when `pass` is restored.
|
||
|
||
### Design notes for SFM
|
||
|
||
Three things this argues for, all cheap:
|
||
|
||
1. **Separate liveness from inventory.** Poll `POLL` + `MONITOR_STATUS`
|
||
frequently; read config, setup name and call-home settings only when
|
||
something says they changed, or on demand.
|
||
2. **Show the connection attempt.** Every check here has a visible outcome —
|
||
connected, frames exchanged, closed. THOR surfaces none of it, which is why
|
||
a failing unit is undiagnosable from the UI. The log this section is built
|
||
from took one afternoon to produce and answers questions THOR cannot.
|
||
3. **Bound every operation with its own timeout**, independent of TCP's. Do not
|
||
rely on the socket to report a dead peer.
|
||
|
||
## Reproduced: THOR stops polling after a connection drops mid-download
|
||
|
||
**2026-09-25, one trial, on the bench link.** This is the failure the operator
|
||
described in the field on UM12947 — *"just wouldn't stay connected… clicking the
|
||
little refresh button did nothing… no way to view the status of a connection
|
||
attempt."*
|
||
|
||
### What was done
|
||
|
||
THOR was downloading events. Partway through the bulk transfer the link was
|
||
faulted, the connection died, and the link was then fully restored.
|
||
|
||
⚠ **What THOR actually experienced was a TCP close mid-download**, not the silent
|
||
link that was intended — `mm_link.py` had a bug (`socket.timeout` subclasses
|
||
`OSError`, so 200 ms of quiet was mistaken for a closed socket). Fixed, but the
|
||
run stands as a **drop-mid-download** test, which is arguably the more realistic
|
||
case: a modem losing its tower often does produce a real close.
|
||
|
||
### What happened
|
||
|
||
```
|
||
13:02:49.0 THOR connects, bulk download running (165 BULK_DOWNLOAD frames in)
|
||
13:02:50.0 link faulted -> connection dies mid-transfer
|
||
13:03:10.4 THOR reconnects once, 20 s later, sends SUB_1F (advance event
|
||
pointer) — an attempt to resume
|
||
13:03:10.8 that attempt dies too
|
||
13:03:33.3 link fully restored and healthy
|
||
... 48 s of complete silence ...
|
||
13:04:21.4 operator clicks Refresh -> full 11-command check, every response
|
||
correct, unit perfectly healthy
|
||
... 2 min 6 s of complete silence ...
|
||
13:06:27 still nothing. That refresh is the ONLY connection since 13:03:10.
|
||
```
|
||
|
||
Before the fault THOR was connecting every 30 s without a miss for over an hour.
|
||
|
||
### What this establishes
|
||
|
||
1. **One retry, then give up.** THOR retried once after 20 s and stopped. No
|
||
backoff, no further attempts.
|
||
2. **The automatic poll loop dies with it.** Not just the download — the 30 s
|
||
connection check and 60 s status check both stopped entirely.
|
||
3. **It does not recover when the link returns.** Sixteen minutes of healthy
|
||
link produced **no automatic connection attempts at all** — only ones an
|
||
operator initiated by hand.
|
||
4. **Refresh works, but only once.** The manual check succeeded completely —
|
||
all eleven commands, correct responses. It did **not** restart the automatic
|
||
loop; two minutes later there was still nothing.
|
||
|
||
⚠ **Point 4 is the dangerous one.** Refresh makes the UI report a healthy unit
|
||
while nothing is actually watching it. The operator sees a successful check and
|
||
moves on; the background loop stays dead. That is worse than a refresh button
|
||
that plainly fails, because the failure is *silent and looks like success*.
|
||
|
||
### 🔑 "Not polling" and "unreachable" are different states — and THOR conflates them
|
||
|
||
Demonstrated directly. At **13:10:44** an independent `POLL` through the same
|
||
relay succeeded with a correct response while THOR's automatic polling was dead.
|
||
|
||
⚠ **A correction, recorded because the first version of this claim was sloppy.**
|
||
It originally read "THOR connections since 13:04:22 : 0". That count was an
|
||
artifact of a time filter (`13:0[4-9]`) that silently dropped everything from
|
||
13:10 onward. THOR *had* connected four times, at 13:10:03–13:10:37.
|
||
|
||
Inspecting those four shows the conclusion survives — but for a better reason
|
||
than the one first given. They were **user-initiated commands, not polling**:
|
||
|
||
```
|
||
13:10:03 POLL → SUB_96 (start monitoring) 13:10:05 MONITOR_STATUS
|
||
13:10:35 POLL → SUB_97 (stop monitoring) 13:10:37 MONITOR_STATUS
|
||
```
|
||
|
||
Single commands a human clicked, each followed by one status read. Neither the
|
||
eleven-command status check nor the three-command connection check appears.
|
||
|
||
**Automatic polling did not resume**, and by 13:19:53 had been absent for
|
||
**16 minutes**. Every THOR connection in that window was something an operator
|
||
clicked: the refresh at 13:04:21, then start and stop monitoring at 13:10.
|
||
|
||
**The unit is reachable. The link is fine. THOR is simply not asking.**
|
||
|
||
**THOR reports `Connection Status: Connected`.** Captured at 13:15, eleven
|
||
minutes after its last contact with the unit:
|
||
|
||
```
|
||
Connection Status : Connected <- false; nothing checked in 11 min
|
||
Last Updated : 09/25/2026 01:04:22 PM <- true, and that is the REFRESH
|
||
Notification : "Unable to download event(s) from UM12947 due to:
|
||
Did not receive response from unit." (01:03:31 PM)
|
||
```
|
||
|
||
Three things worth separating:
|
||
|
||
1. **The green tile is false.** It reports a live connection that has not been
|
||
exercised for eleven minutes.
|
||
2. **`Last Updated` is true** — and it is the only honest field on the screen.
|
||
THOR *knows* when it last succeeded. It renders that as small grey text under
|
||
the unit name, unhighlighted, with nothing marking it stale, beneath a large
|
||
green **Connected** tile. The operator must read a timestamp and do mental
|
||
arithmetic to discover the headline is wrong.
|
||
3. **The failure THOR did report was the download**, not the poller stopping.
|
||
Those are treated as unrelated events; nothing says "automatic checking has
|
||
ceased."
|
||
|
||
So the state *"I have given up checking this unit"* is not merely undisplayed —
|
||
THOR holds the data that would reveal it and presents a contradicting summary
|
||
instead. So an operator cannot separate **the unit is down** from **the
|
||
poller is asleep** — and those demand completely different responses: a site
|
||
visit versus a mouse click.
|
||
|
||
This also settles a question the previous section left open. It does not much
|
||
matter whether stopping after one retry is an intentional design choice or a
|
||
defect: **the reporting is wrong either way**, because both plausible displays
|
||
misrepresent reality.
|
||
|
||
It explains, too, why the field symptom resisted characterisation. The unit is
|
||
perfectly reachable throughout. Poke it and it answers. Nothing is wrong with
|
||
the unit, the modem, or the link.
|
||
|
||
### The stale display extends to monitoring state — a live unit shown as Idle
|
||
|
||
The operator's own test: start monitoring **from the unit's keypad**, and see
|
||
whether THOR ever notices.
|
||
|
||
At **13:21:15**, verified by an independent probe through the same relay:
|
||
|
||
```
|
||
unit, on the wire SUB 0x49 data[11] = 0x02 MONITORING
|
||
SUB 0x1C data[12] = 0x0c MONITORING
|
||
device clock 13:21:14
|
||
THOR's last contact 13:10:37 (a stop-monitoring command it issued itself)
|
||
THOR's display Connected · Monitoring Mode: Idle · Last Updated 1:04:22 PM
|
||
```
|
||
|
||
**A unit is actively recording and THOR shows it as Idle.** Had an event
|
||
triggered in that window, THOR would not have known and would not have collected
|
||
it. This is the operational consequence of the wedge: not merely a wrong
|
||
indicator, but a monitoring system that has silently stopped monitoring its
|
||
monitor.
|
||
|
||
One further detail. THOR **did** contact the unit at 13:10:37 and read
|
||
`MONITOR_STATUS` — yet `Last Updated` still reads 1:04:22. So a successful
|
||
exchange does not refresh that timestamp; only the full status check does. The
|
||
single honest field on the screen is honest about the wrong thing, which makes it
|
||
useless as a staleness indicator precisely when staleness is the problem.
|
||
|
||
### Design consequences for SFM
|
||
|
||
0. **Connection status must EXPIRE.** The single highest-value fix here. If the
|
||
last successful check is older than a small multiple of the check interval,
|
||
the state is *stale / unknown* — never *Connected*. THOR already has the
|
||
timestamp; it simply does not let it invalidate the summary.
|
||
1. **Retry must be unbounded with backoff**, never one-and-done. A transient
|
||
link fault should not take a unit out of service until a human notices.
|
||
2. **A manual check must restart the automatic loop**, or the UI must state
|
||
clearly that automatic checking is stopped. A green tick that means "it
|
||
worked when you asked" is a trap.
|
||
3. **Surface the poll loop's own state** — last successful check, last attempt,
|
||
next scheduled attempt, consecutive failures. All four were invisible here,
|
||
and every one of them would have made this diagnosable in seconds.
|
||
4. **Distinguish "unit unreachable" from "we stopped checking."** Proven above
|
||
to be different states that THOR cannot tell apart. A unit's displayed state
|
||
should be one of: *checks passing*, *checks failing* (with the attempt count
|
||
and last error), or *not being checked* (with why, and a way to resume).
|
||
Collapsing the last two into one indicator is the root of this whole failure
|
||
being undiagnosable.
|
||
|
||
### Caveats
|
||
|
||
- **One trial.** Not yet repeated, and not yet tried with a true silent link
|
||
(`blackhole` with the fixed tool) or with a clean `drop`.
|
||
- THOR's UI state during the dead window was not recorded — worth capturing on a
|
||
repeat, since what the operator *sees* is half the problem.
|
||
- Whether a THOR restart is required, or whether it eventually recovers on a
|
||
longer timescale, was not tested.
|
||
|
||
## THOR leaks event subscriptions — evidence from its own log
|
||
|
||
Source: `thor.log.2026-09-22` from the office THOR PC, supplied 2026-09-25.
|
||
209 lines covering roughly ten hours.
|
||
|
||
### What the log contains
|
||
|
||
```
|
||
178x UnitOperatingModeViewModel - Start/stop monitoring request timed out: False
|
||
6x UnitOperatingModeViewModel - Callback of start/stop monitor request received
|
||
5x UnitOperatingModeViewModel - Start/stop monitoring request timed out: True
|
||
3x SchedulerViewModel - Retrieving unit setup ...
|
||
2x BWLicenseManager - License State: IsKeyValid 0, IsActivated 0, ...
|
||
```
|
||
|
||
85% of a day's logging is one message. Only **12** lines describe an actual
|
||
operation.
|
||
|
||
### The message is emitted N times for ONE event, and N grows
|
||
|
||
Grouping the repeated message by **exact timestamp to the millisecond** — so
|
||
each group is one logical event, not repeated events:
|
||
|
||
| time | identical lines |
|
||
|---|---|
|
||
| 11:48 | 1–4 |
|
||
| 13:05 – 13:31 | 1–2 |
|
||
| 13:36 – 13:40 | 2–4 |
|
||
| 15:16 | 3 |
|
||
| 16:03 – 16:07 | **6** |
|
||
| 16:14 – 16:16 | 4–6 |
|
||
| **22:00 – 22:06** | **12** |
|
||
|
||
**1 → 12 over ten hours of uptime.** Twelve identical lines sharing one
|
||
millisecond timestamp is not twelve events; it is one event dispatched to twelve
|
||
handlers.
|
||
|
||
### What it is
|
||
|
||
A **subscription leak**, and the class name identifies it:
|
||
`UnitOperatingModeViewModel`. THOR is a .NET/WPF application ("App thread",
|
||
ViewModel naming), where this is the archetypal leak — a view model subscribes to
|
||
an event when its view opens and never unsubscribes when the view closes. Each
|
||
open adds a live handler; every later notification runs them all.
|
||
|
||
Consequences that follow directly:
|
||
|
||
- N grows with **uptime and usage**, without bound. Ten hours of moderate use
|
||
reached 12×; a THOR left running for days across a fleet will be far worse.
|
||
- Every notification performs N× the work — N timers, N callbacks, N state
|
||
updates racing one another.
|
||
- **A restart resets N to 1**, which is consistent with the operator's report
|
||
that only restarting THOR recovers a degraded session.
|
||
- The only `timed out: True` entries in the file — five of them — sit at the very
|
||
top, an episode captured just before log rotation.
|
||
|
||
⚠ **Claim discipline.** *Established* from this log: the handler count grows.
|
||
*Strong inference*: it is a subscription leak. **Not established**: that it
|
||
caused the specific field outage on 2026-09-22. This log does not cover that
|
||
window, and the connection between leaked handlers and a dead TCP path is
|
||
unproven.
|
||
|
||
### How to confirm it in ten minutes
|
||
|
||
Restart THOR, note the burst size for one event, open and close a unit's detail
|
||
view ten times, then trigger another event and re-count. If the burst grows, the
|
||
leak is reproducible and attributable to a specific UI action.
|
||
|
||
### For SFM
|
||
|
||
1. **Unsubscribe on teardown**, or use weak event patterns. This is the single
|
||
most common long-running-UI defect and it is entirely preventable.
|
||
2. **Log once per event, not once per handler.** The duplication here is what
|
||
made the leak visible, which is lucky — but a log that is 85% one repeated
|
||
line is also a log nobody reads, which is why this went unnoticed for a year.
|
||
3. **Log what changed, not what did not.** `timed out: False` 178 times is
|
||
noise; the five `True` entries are the signal, and they are buried.
|
||
|
||
## The Micromate's USB host supports FTDI and CDC-ACM only — not Prolific
|
||
|
||
**Established 2026-09-25**, by isolating a bench modem link layer by layer and
|
||
then confirming against both firmware images.
|
||
|
||
### The USB-A port is a HOST port with a fixed driver set
|
||
|
||
```
|
||
usbHostDelete_CDCACM CDC-ACM — the standard USB-serial class
|
||
usbHostDelete_FTDI FTDI chips (FTDISER handlers, set_line_coding, etc.)
|
||
usbHostDelete_MFS mass storage (thumb drives)
|
||
usbHostDelete_PRINTER printers
|
||
usbHostDelete_HUB / HC / USBH
|
||
```
|
||
|
||
**Identical in `11.0CB` and `11.0BD`** — 11 `FTDISER` strings and 22 `CDCACM`
|
||
strings in each, and **zero** matches for `prolific`, `pl2303`, `cp210`, `ch34`
|
||
or `silabs` in either. The firmware line makes no difference here.
|
||
|
||
⚠ **So a Prolific PL2303 cable (VID `067b`) very probably cannot work with a
|
||
Micromate** — no driver, so the device never enumerates and the serial path does
|
||
not exist. Use an **FTDI** cable (VID `0403`) or a CDC-ACM one.
|
||
|
||
**Calibration.** This rests on *absence of strings*, plus the observation that a
|
||
laptop answers on the same cable where the unit does not. `usbHostDelete_*` reads
|
||
like a complete per-class list, which is good evidence — but driver code can exist
|
||
without a matching string, and the USB enumeration path has not been
|
||
disassembled.
|
||
|
||
**The test that would remove the inference: try an FTDI cable on the same modem.**
|
||
Either it works or it does not, and nothing is left to argue about.
|
||
|
||
✅ **Corroborated by purchasing history (2026-09-25).** TMI has bought **two**
|
||
kinds of USB-to-serial cable:
|
||
|
||
| cable | chipset | works with a Micromate? |
|
||
|---|---|---|
|
||
| **Sabrent** USB 2.0 to serial | **FTDI** | ✅ driver present |
|
||
| **Benfei** USB to RS-232 | **Prolific PL2303** | ❌ no driver |
|
||
|
||
So both types are in circulation and indistinguishable by eye. A unit handed the
|
||
wrong one has no working modem port, and nothing about the cable's appearance
|
||
says so.
|
||
|
||
**The cable on the bench during this investigation was confirmed to be a Benfei**
|
||
— the Prolific one.
|
||
|
||
⚠ **Identify cables by chipset, not by looks.** `lsusb` on any Linux box:
|
||
FTDI is VID `0403`, Prolific is `067b`. Note also that counterfeit FTDI chips
|
||
are common in cheap cables — they carry FTDI's VID but may not behave like one,
|
||
and an embedded host with a single driver is far less forgiving than Linux.
|
||
|
||
### ✅ This accounts for the 2026-09-22 failure
|
||
|
||
An earlier draft of this section said the timeline did not fit, because the unit
|
||
was described as *working at first and then degrading* — which a Prolific cable
|
||
cannot do, since it never enumerates at all.
|
||
|
||
**The timeline was the thing that needed correcting, not the finding.** From the
|
||
operator, 2026-09-25:
|
||
|
||
1. Initial setup — **one** modem, **one** cable. Worked: configs sent, status
|
||
read, no trouble.
|
||
2. Two Micromates then had to be deployed, so a **second cable** was fetched to
|
||
run both at once.
|
||
3. **Only one of the two ever worked.** The other would not talk over its modem
|
||
at any point.
|
||
4. That unit was never deployed — a **MiniMate Plus was put out in its place**,
|
||
and the Micromate came back to the bench.
|
||
5. The cable on that bench unit is a **Benfei (Prolific PL2303)**.
|
||
|
||
So nothing degraded. The "worked at first" was the original single-cable setup;
|
||
the failure began when the pair was split across two cables and one of them was
|
||
the wrong chipset. **One working and one not, set up side by side, is the
|
||
signature of a mixed cable supply** — not of a unit fault.
|
||
|
||
✅ **The cable travelled home with the unit** (confirmed 2026-09-25). So the
|
||
Benfei on the bench is physically the cable from the failed office setup — not a
|
||
substitute picked up later. There is no inference left in the chain:
|
||
|
||
| | |
|
||
|---|---|
|
||
| the cable from the failed unit | **is** a Benfei / Prolific PL2303 |
|
||
| the Micromate's USB host | has **no** Prolific driver, in either firmware line |
|
||
| a laptop on that same cable + modem | round-trips perfectly |
|
||
| the Micromate on it | never answered |
|
||
|
||
The FTDI cable arriving 2026-09-26 is now a confirmation rather than a test.
|
||
|
||
**Corollary worth knowing.** The standing workaround — *"just deploy a Series III
|
||
instead"* — works partly because a **MiniMate Plus has a DB-9 port directly on the
|
||
unit.** No USB-to-serial adapter anywhere in the path, so there is no chipset to
|
||
get wrong. That workaround has been quietly routing around this exact failure
|
||
mode.
|
||
|
||
### How this was isolated — the method is reusable
|
||
|
||
A Micromate on an RX55 was unreachable from THOR. Rather than guess, each layer
|
||
was eliminated in turn with `bridges/mm_probe.py`:
|
||
|
||
| layer | test | result |
|
||
|---|---|---|
|
||
| unit's public IP | static APN `mw01.VZWSTATIC` | ruled out |
|
||
| firewall / trusted IPs | probe from a whitelisted source | **TCP connect ok, 268 ms** |
|
||
| network path | same | ruled out |
|
||
| baud | 115200 both ends | ruled out |
|
||
| unit modem-type setting | set back to `generic` | ruled out |
|
||
| THOR | probe bypasses it entirely and still failed | ruled out |
|
||
| **modem TCP→serial** | swapped the unit for a Linux box on the same cable | **POLL arrived byte-perfect** |
|
||
| **modem serial→TCP** | that box answered with a canned POLL reply | **round trip in 700 ms** |
|
||
|
||
The last two are the decisive pair. With a laptop standing in for the unit on
|
||
the *same cable and modem*, the full round trip works — so the modem, the PAD
|
||
configuration, the firewall and the network are all clean, and the only element
|
||
that changed is what sits at the end of the cable.
|
||
|
||
`scratch/fake_unit.py`-style substitution is worth remembering: **replace the
|
||
device with something known-good and re-run the same test.** It converts "the
|
||
unit is not answering" into "the unit is not receiving", which are very
|
||
different problems.
|
||
|
||
⚠ This is the best explanation for the **bench** setup. Whether it explains the
|
||
2026-09-22 field outage is **not** established — that unit reportedly worked at
|
||
first and degraded, which a wrong cable would not do. Treat them as separate
|
||
until the field unit's cable is identified.
|
||
|
||
## Known-good RV55 config for a Micromate (2026-09-25)
|
||
|
||
Read off a unit **deployed and working for months**. This is the reference to
|
||
diff a misbehaving modem against — the fastest diagnostic available, since TMI
|
||
has seven working units and only one that misbehaves.
|
||
|
||
### Serial interface — Port 1
|
||
|
||
| field | value | note |
|
||
|---|---|---|
|
||
| Mode | **PAD** | |
|
||
| **Baud rate** | **115200** | Micromate rate. Series III units use **38400** — a modem moved between series needs this changed |
|
||
| Data bits / Parity / Stop bits | 8 / None / 1 | |
|
||
| Flow control | **None** | hardware FC blocks TX when the pins are not wired |
|
||
|
||
### PAD settings — Port 1
|
||
|
||
| field | value | note |
|
||
|---|---|---|
|
||
| PAD mode | TCP | |
|
||
| **Listen for connections (auto-answer)** | **On** | this is what lets THOR dial *in*. Off, and only call-home works — which would present exactly as "unreachable but it still calls home" |
|
||
| **Listening port** | **9034** | the port THOR dials |
|
||
| **Destination address** | **50.197.32.91** | the office public IP — where the unit calls home *to* |
|
||
| **Destination port** | **12345** | the office ACH listener |
|
||
| MTU | 1304 | |
|
||
| Data forwarding timeout | 500 ms | |
|
||
| **Idle timeout** | **2 min** | matches the value this project settled on for Series III |
|
||
| Connect timeout | ≈1.08 min | |
|
||
| Start only when WAN available | Off | |
|
||
| TCP reconnect on disconnect | Off (locked) | |
|
||
| **TCP keepalive** | **Off** | ⚠ see below |
|
||
| Keepalive idle / interval / count | 30 s / 10 s / 3 | configured but inactive while keepalive is Off |
|
||
|
||
### Cellular
|
||
|
||
APN `mw01.VZWSTATIC` — Verizon's **static-IP** APN, which confirms the fleet's
|
||
addresses are fixed and rules out any "the IP moved" explanation. IPv4 only,
|
||
MTU 1428, radio firmware VERIZON.
|
||
|
||
### ⚠ Two things worth questioning in this config
|
||
|
||
**TCP keepalive is Off.** Nothing detects a half-open TCP session from the
|
||
modem's side; the 2-minute **idle** timeout is the only reaper.
|
||
|
||
**And an idle timer can be held open indefinitely by a client that keeps
|
||
writing.** THOR polls every 30 s (measured — see the polling section). If it
|
||
holds a session the unit has stopped answering on, and keeps writing into it,
|
||
each write plausibly resets the modem's idle timer. The session then never ages
|
||
out, the PAD's single slot stays occupied, and every new inbound connection
|
||
fails — while ACEmanager keeps answering on its own separate service.
|
||
|
||
⚠ **Hypothesis.** Whether traffic in one direction resets that timer is not
|
||
confirmed, and the single-slot behaviour has not been tested on a real modem.
|
||
`bridges/mm_probe.py --slots N` tests the second part directly.
|
||
|
||
Turning **keepalive On** would give the modem an independent way to notice a
|
||
dead peer, rather than depending on an idle timer a chatty client can hold open.
|
||
That is a change to make deliberately and one modem at a time.
|
||
|
||
### What to diff on a misbehaving modem
|
||
|
||
In rough order of likelihood for "worked at first, then stopped":
|
||
|
||
1. **Idle timeout** — 0/disabled lets a stale session hold the slot forever
|
||
2. **Listen for connections** — Off means inbound never works, but call-home still does
|
||
3. **Baud rate** — 115200 for Micromate, 38400 for Series III
|
||
4. **Listening port** — must match what THOR dials
|
||
5. **Destination address/port** — wrong here breaks call-home but *not* inbound
|
||
6. **Data forwarding timeout** and **MTU** — affect framing, not reachability
|
||
|
||
Note 2 and 5 fail in opposite directions, which is diagnostic in itself: inbound
|
||
broken with call-home working points at the listener; call-home broken with
|
||
inbound working points at the destination.
|
||
|
||
## 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.
|