Files
seismo-relay/bridges/mm_ach_server.py
T
serversdownandClaude Opus 5 bdf4909d2a docs(series4): no Micromate has ever been observed calling home
Correcting an implication I made.  The 72 ach_inbound_* sessions in
bridges/captures/ are MiniMate Plus, and I framed the Series III ACH setup as
though it transfers to a Micromate.  It is not known to.

What we actually have for Series IV is prior, not observation:

- THOR manual 6.2 and the firmware's own strings
  (CMD_EXIT_CALL_HOME_DELETE_EVENTS_START_MONITORING, "Call Home Deleting
  Events") say the unit dials in and waits to be told what to do -- server-driven,
  the same shape as Series III's pull model.
- UM12947's 0x2C dial string reads literally "RADIO RING", which is modem ANSWER
  vocabulary rather than a destination.  With the RV50 debug string
  ATQ1 ATE0 ATS0=2 ... RADIO RING -- ATS0=2 being auto-answer-after-2-rings --
  that reads as the unit arranging to ACCEPT calls, i.e. the call-UP path.  Which
  argues the ACH destination lives in the modem, as it does for series III.

If both priors hold, mm_ach_server.py is just MicromateClient on an inbound
socket and the unknown evaporates.  But there is a specific failure mode worth
naming in advance:

THE MICROMATE DEMONSTRABLY TALKS AT TO ITS MODEM.  If it drives an AT dial for
call-home, that exchange happens on the SERIAL LINK and a TCP listener sees none
of it.  So the first experiment has an AMBIGUOUS NULL -- no connection could mean
the trigger did not fire, or that the interesting part is invisible from the
network.  The escalation is a serial-level capture between unit and modem, not
more fiddling with the server.

Recorded in both the protocol reference and the server's own docstring, so
whoever runs the experiment reads the caveat before being puzzled by a silence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ru8Lg9HkkYvX9VWWo65SmL
2026-10-03 01:54:31 -04:00

336 lines
14 KiB
Python

#!/usr/bin/env python3
"""
mm_ach_server.py — inbound Auto Call Home server for Micromate (Series IV).
**Read-only.** It identifies the unit, reads its state and setups, and walks the
event chain. It does not erase, does not write, does not start or stop
monitoring, and downloads nothing unless asked. Series III's `ach_server.py`
has "rescue actions" that quiet a runaway unit; those are deliberately absent
here, because no command has ever been originated against a unit by this project
and an inbound session is the worst place to break that.
What this is for
----------------
The inbound call-home session is the last unmapped part of the Series IV
protocol.
⚠ **No Micromate has ever been observed calling home.** The 72 `ach_inbound_*`
sessions in `bridges/captures/` are **MiniMate Plus**, and the Series III setup
does not automatically transfer. What we have for Series IV is prior, not
observation:
* The THOR manual §6.2 and the firmware's own strings
(`CMD_EXIT_CALL_HOME_DELETE_EVENTS_START_MONITORING`, `Call Home Deleting
Events`) say the unit dials in and then **waits to be told what to do** —
server-driven, the same shape as Series III's pull model.
* UM12947's `0x2C` dial string reads literally `"RADIO RING"`, which is modem
*answer* vocabulary rather than a destination. With the RV50 debug string
`ATQ1 ATE0 ATS0=2 … RADIO RING` — `ATS0=2` being auto-answer-after-2-rings —
that reads as the unit configuring the modem to **accept** calls, i.e. the
call-*up* path. Which suggests the ACH destination lives in the **modem**,
as it does for Series III.
**If both priors hold, this file is just `MicromateClient` on an inbound socket
and the unknown evaporates.** But there is a specific way it can fail:
⚠ **Those AT strings prove the Micromate talks to the modem over serial.** If it
drives an AT dial for call-home, that exchange happens on the **serial link**,
where a TCP listener sees nothing whatsoever. So a null result here is
informative but ambiguous — it means *either* the trigger did not fire *or* the
interesting part is happening somewhere this tool cannot watch. **If nothing
arrives, the next step is a serial-level capture between the unit and the modem,
not more fiddling with this server.**
**The one question that matters is push vs pull**, and it cannot be answered by
sniffing — only by connecting and keeping quiet. So this server **says nothing
for `--grace` seconds** after accepting. If bytes arrive in that window the unit
speaks first (push) and the server reports exactly what it sent. If the window
passes in silence, it is pull, and the normal handshake follows.
Setup
-----
1. Run this where the unit can reach it:
python3 bridges/mm_ach_server.py --port 12345
2. Point the call-home destination at this machine. **For Series III that
lived in the MODEM's config (ACEmanager), not in the seismograph** — so try
AirLink OS first. If the RX55 can be told to open an outbound TCP session
to `<your IP>:12345` on serial activity, nothing on the instrument needs to
change, which is much the better outcome.
If it has to come from the unit instead, its `SUB 0x2C` call-home block
holds a 40-byte dial string — but **writing that is THOR's job, not ours.**
3. Trigger a call. A scheduled time is the controllable option: repeatable,
needs no monitoring and no thumping. An event-triggered call is the more
interesting session, because it shows how an event gets offered.
4. Watch. Every frame is decoded as it arrives.
⚠ Use a bench unit. Changing a call-home destination means a unit stops
reporting to THOR until it is changed back.
Output per session, in the layout `scratch/mm_frame_parse.py` reads directly:
bridges/captures/mm_ach_<ts>/
raw_bw_<ts>.bin us → unit
raw_s3_<ts>.bin unit → us
session_<ts>.log
session.json what we learned, including push-vs-pull
"""
from __future__ import annotations
import argparse
import datetime
import json
import logging
import socket
import sys
import threading
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from micromate.client import MicromateClient # noqa: E402
from micromate.framing import MicromateFrameParser # noqa: E402
from micromate.protocol import ProtocolError # noqa: E402
from minimateplus.transport import SocketTransport # noqa: E402
log = logging.getLogger("mm_ach")
DEPTHS = ("identify", "state", "events", "download")
class Session:
"""One inbound call. Captures every byte; decides push vs pull first."""
def __init__(self, sock: socket.socket, peer: str, args) -> None:
self.sock, self.peer, self.args = sock, peer, args
self.ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
self.found: dict = {"peer": peer, "started": self.ts}
self._rx: list[bytes] = []
self._tx: list[bytes] = []
self._rx_fh = self._tx_fh = None
# ── byte taps ─────────────────────────────────────────────────────────────
def _tap(self, transport) -> None:
"""Mirror every byte, buffered until we know this is a real unit."""
orig_read, orig_write = transport.read, transport.write
def read(n: int) -> bytes:
data = orig_read(n)
if data:
(self._rx_fh.write(data), self._rx_fh.flush()) if self._rx_fh \
else self._rx.append(data)
return data
def write(data: bytes) -> None:
orig_write(data)
if data:
(self._tx_fh.write(data), self._tx_fh.flush()) if self._tx_fh \
else self._tx.append(data)
transport.read, transport.write = read, write
def _open_capture(self) -> Path:
"""Create the session directory — only once we trust the peer.
A port scanner should leave nothing on disk.
"""
d = Path(self.args.output) / f"mm_ach_{self.ts}"
d.mkdir(parents=True, exist_ok=True)
# Named raw_bw/raw_s3 so scratch/mm_frame_parse.py reads the pair as-is.
self._tx_fh = open(d / f"raw_bw_{self.ts}.bin", "wb")
self._rx_fh = open(d / f"raw_s3_{self.ts}.bin", "wb")
for c in self._tx:
self._tx_fh.write(c)
for c in self._rx:
self._rx_fh.write(c)
self._tx_fh.flush()
self._rx_fh.flush()
self._tx.clear()
self._rx.clear()
return d
# ── the experiment ────────────────────────────────────────────────────────
def _listen_first(self, transport) -> bytes:
"""Say nothing for `--grace` seconds. Does the unit speak first?
This is the whole reason the server exists. A packet capture cannot
answer it: you have to connect, keep quiet, and see.
"""
print(f" listening in silence for {self.args.grace:.1f} s "
f"(push-vs-pull test)...")
deadline = time.monotonic() + self.args.grace
got = bytearray()
while time.monotonic() < deadline:
chunk = transport.read(4096)
if chunk:
got += chunk
else:
time.sleep(0.02)
return bytes(got)
def run(self) -> None:
print(f"\n=== inbound from {self.peer} at {self.ts} ===")
transport = SocketTransport(self.sock, peer=self.peer)
self._tap(transport)
unsolicited = self._listen_first(transport)
self.found["push"] = bool(unsolicited)
self.found["unsolicited_bytes"] = len(unsolicited)
if unsolicited:
print(f" ** PUSH ** — the unit sent {len(unsolicited)} B unprompted")
print(f" hex : {unsolicited[:64].hex(' ')}")
txt = "".join(chr(c) if 32 <= c < 127 else "." for c in unsolicited[:64])
print(f" ascii : |{txt}|")
frames = MicromateFrameParser().feed(unsolicited)
if frames:
for f in frames:
print(f" frame : SUB=0x{f.sub:02x} "
f"(answers 0x{f.request_sub:02x}) {len(f.data)} B data, "
f"flags=0x{f.flags:02x}, chk "
f"{'ok' if f.checksum_valid else 'BAD'}")
self.found["unsolicited_subs"] = [f"0x{f.sub:02x}" for f in frames]
else:
print(" frame : none parsed — not a response frame. Could be")
print(" a banner (series III sends 'Operating System'")
print(" on cold boot) or a greeting we have not seen.")
else:
print(" ** PULL ** — silence. The unit is waiting to be driven,")
print(" same as series III. Running the normal handshake.")
# From here on it is the ordinary client, which is the point.
mm = MicromateClient(transport, recv_timeout=self.args.timeout)
try:
info = mm.connect()
except ProtocolError as e:
print(f" handshake FAILED: {type(e).__name__}: {e}")
if not unsolicited:
print(" Nothing unprompted AND no answer to POLL — so this is")
print(" neither push nor pull as we understand it. The raw")
print(" capture is the thing to look at; keep it.")
self._finish(self._open_capture())
return
d = self._open_capture()
print(f" {info}")
self.found["device"] = {
"serial": info.serial, "model": info.model,
"firmware_line": info.firmware_line, "monitoring": info.monitoring,
"active_setup": info.active_setup,
}
try:
if DEPTHS.index(self.args.depth) >= DEPTHS.index("state"):
st = mm.get_state()
print(f" {st}")
self.found["state"] = {
"monitoring": st.monitoring,
"device_time": st.device_time.isoformat() if st.device_time else None,
"battery_volts": st.battery_volts,
"memory_free_bytes": st.memory_free_bytes,
}
self.found["setups"] = mm.list_setups()
print(f" {len(self.found['setups'])} setups")
if DEPTHS.index(self.args.depth) >= DEPTHS.index("events"):
refs = mm.list_events()
print(f" {len(refs)} events")
self.found["events"] = []
for r in refs:
print(f" {r}")
self.found["events"].append({
"uid": r.uid, "key": r.key_hex, "size": r.size,
"type": r.record_type, "filename": r.filename,
"timestamp": r.timestamp.isoformat() if r.timestamp else None,
"pvs_ips": r.peak_vector_sum_ips,
})
if self.args.depth == "download":
# Bounded on purpose: an unknown session is a poor place to
# discover that a 70 KB pull takes 30 s and the unit hung up.
for r in refs[: self.args.max_events]:
blob = mm.download_event(r)
out = d / (r.filename or f"{r.key_hex}.bin")
out.write_bytes(blob)
print(f" saved {out.name} ({len(blob)} B)")
except ProtocolError as e:
print(f" session ended early: {type(e).__name__}: {e}")
self.found["error"] = f"{type(e).__name__}: {e}"
self._finish(d)
def _finish(self, d: Path) -> None:
for fh in (self._rx_fh, self._tx_fh):
if fh:
fh.close()
(d / "session.json").write_text(json.dumps(self.found, indent=2))
try:
self.sock.close()
except OSError:
pass
print(f" saved {d}")
print(f" parse with: python3 scratch/mm_frame_parse.py {d}")
def main() -> int:
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
ap.add_argument("--port", type=int, default=12345)
ap.add_argument("--bind", default="0.0.0.0")
ap.add_argument("--output", default="bridges/captures")
ap.add_argument("--timeout", type=float, default=30.0,
help="per-command receive timeout; generous, because an "
"inbound cellular session is the slow case")
ap.add_argument("--grace", type=float, default=5.0,
help="seconds to stay SILENT after accepting, to see whether "
"the unit speaks first. This is the experiment; do not "
"set it to 0 on a first run.")
ap.add_argument("--depth", choices=DEPTHS, default="events",
help="how far to take the session. 'events' walks the chain "
"without downloading (default); 'download' also fetches "
"events, which on cellular is ~0.21 s + bytes/2350 each")
ap.add_argument("--max-events", type=int, default=3,
help="with --depth download, how many to fetch")
ap.add_argument("--once", action="store_true",
help="exit after the first session instead of listening on")
a = ap.parse_args()
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)-7s %(message)s")
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind((a.bind, a.port))
srv.listen(5)
print(f"\nMicromate ACH server on {a.bind}:{a.port} (READ-ONLY)")
print(f" depth={a.depth} grace={a.grace}s timeout={a.timeout}s")
print(" waiting for a unit to call in. Ctrl-C to stop.\n")
try:
while True:
sock, addr = srv.accept()
peer = f"{addr[0]}:{addr[1]}"
s = Session(sock, peer, a)
if a.once:
s.run()
return 0
threading.Thread(target=s.run, daemon=False).start()
except KeyboardInterrupt:
print("\nstopped")
return 0
finally:
srv.close()
if __name__ == "__main__":
raise SystemExit(main())