From 0f478bf720d75530f1a71dce1d27f9d66215368f Mon Sep 17 00:00:00 2001 From: Alec Perkins Date: Thu, 7 May 2026 21:27:41 -0400 Subject: [PATCH] Implement test/control stations, simulator, tests Adds the modjam package: a MeshCore-backed test station service for Pi (IDLE + RUNNING states, cuesheet-driven), a control station REPL for the Mac, and a UDP simulator that swaps in for the radio when SIMULATOR=true (drops cross-config packets and a configurable fraction of test-payload datagrams to mimic real radio loss). docker-compose runs three sim stations + control on a bridge net. TSV log format matches the reference traces. Pytest suite covers protocol codec, cuesheet builder, TSV logger, config loader, and UDP radio packet routing/loss; .forgejo/workflows runs it on push to main and on PRs. Co-Authored-By: Claude Opus 4.7 (1M context) --- .forgejo/workflows/test.yml | 20 +++ .gitignore | 7 + README.md | 93 +++++++++++++ docker-compose.yml | 54 ++++++++ docker/control.Dockerfile | 9 ++ docker/station.Dockerfile | 9 ++ modjam/__init__.py | 0 modjam/cli.py | 44 +++++++ modjam/config.py | 51 ++++++++ modjam/control.py | 108 +++++++++++++++ modjam/cuesheet.py | 99 ++++++++++++++ modjam/log.py | 33 +++++ modjam/protocol.py | 57 ++++++++ modjam/radio/__init__.py | 0 modjam/radio/base.py | 45 +++++++ modjam/radio/factory.py | 15 +++ modjam/radio/meshcore.py | 100 ++++++++++++++ modjam/radio/udp.py | 159 ++++++++++++++++++++++ modjam/station.py | 255 ++++++++++++++++++++++++++++++++++++ notes.md | 46 +++++++ pyproject.toml | 27 ++++ sim/control.json | 14 ++ sim/station-a.json | 21 +++ sim/station-b.json | 21 +++ sim/station-c.json | 21 +++ tests/__init__.py | 0 tests/test_config.py | 63 +++++++++ tests/test_cuesheet.py | 86 ++++++++++++ tests/test_log.py | 37 ++++++ tests/test_protocol.py | 69 ++++++++++ tests/test_udp_radio.py | 145 ++++++++++++++++++++ 31 files changed, 1708 insertions(+) create mode 100644 .forgejo/workflows/test.yml create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 docker/control.Dockerfile create mode 100644 docker/station.Dockerfile create mode 100644 modjam/__init__.py create mode 100644 modjam/cli.py create mode 100644 modjam/config.py create mode 100644 modjam/control.py create mode 100644 modjam/cuesheet.py create mode 100644 modjam/log.py create mode 100644 modjam/protocol.py create mode 100644 modjam/radio/__init__.py create mode 100644 modjam/radio/base.py create mode 100644 modjam/radio/factory.py create mode 100644 modjam/radio/meshcore.py create mode 100644 modjam/radio/udp.py create mode 100644 modjam/station.py create mode 100644 notes.md create mode 100644 pyproject.toml create mode 100644 sim/control.json create mode 100644 sim/station-a.json create mode 100644 sim/station-b.json create mode 100644 sim/station-c.json create mode 100644 tests/__init__.py create mode 100644 tests/test_config.py create mode 100644 tests/test_cuesheet.py create mode 100644 tests/test_log.py create mode 100644 tests/test_protocol.py create mode 100644 tests/test_udp_radio.py diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml new file mode 100644 index 0000000..11acc1d --- /dev/null +++ b/.forgejo/workflows/test.yml @@ -0,0 +1,20 @@ +name: tests + +on: + push: + branches: [main] + pull_request: + +jobs: + pytest: + runs-on: docker + container: + image: python:3.12-slim + steps: + - uses: actions/checkout@v4 + + - name: Install package + test deps + run: pip install --no-cache-dir -e '.[test]' + + - name: Run pytest + run: pytest -v diff --git a/.gitignore b/.gitignore index e43b0f9..960ab24 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,8 @@ .DS_Store +__pycache__/ +*.pyc +*.egg-info/ +.venv/ +sim/logs/ +*.tsv +!reference/*.tsv diff --git a/README.md b/README.md new file mode 100644 index 0000000..5bef963 --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# mesh-control + +Operator quickstart for the LoRa modulation jam test framework. Protocol/spec lives in [reference/concept.md](reference/concept.md). + +## Components + +- `modjam-station` — runs on each Test Station (Raspberry Pi or simulator container) talking to a MeshCore node over USB serial. +- `modjam-control` — interactive REPL on the macbook (or simulator container) that sends START/STOP commands. +- Simulator — `docker compose` with three test stations (A, B, C) and one control container, swapping the radio for UDP. + +## Install (host: Pi or Mac) + +```sh +python3.11 -m venv .venv && source .venv/bin/activate +pip install -e . +``` + +Drop a config at `~/modjam-config.json`. Use [sim/station-a.json](sim/station-a.json) as a template; add `"port": "/dev/ttyUSB0"` (Pi) or `"/dev/tty.usbmodem1301"` (Mac) for the attached MeshCore node. + +Run: + +```sh +modjam-station # on each Pi +modjam-control # on the Mac +``` + +## Simulator + +Three stations + control, no hardware: + +```sh +docker compose build +docker compose up -d station-a station-b station-c +docker compose run --rm control +``` + +Inside the control REPL: + +``` +> start name=sim1 stations=A,B,C bw=500 sf=8 cr=5 pow=22 duration=20 padding=10 spacing=2 size=40 at=1 +> stop +> quit +``` + +TSV logs appear in `./sim/logs/` (one per station per run, format matches [reference/A.tsv](reference/A.tsv) / [reference/B.tsv](reference/B.tsv)). + +`SIMULATOR=true` is what flips the radio backend from MeshCore-USB to UDP. The UDP backend drops cross-config datagrams, so receivers only "hear" senders whose freq/bw/sf/cr/channel match — same behavior as real radios. + +The simulator also drops a fraction of received **test packets** at random to mimic real-world packet loss. Default is 15%; override with `SIM_PACKET_LOSS=0.0` (no loss) up to `1.0`. Protocol traffic (START/STOP, heartbeats, next/done) is never dropped — only payloads matching the test-packet pattern. Each station gets a deterministic per-station RNG seed so reruns are repeatable; set `SIM_SEED=...` to vary. + +```sh +SIM_PACKET_LOSS=0.3 docker compose up -d station-a station-b station-c +``` + +## REPL commands + +| Command | Effect | +|---|---| +| `start [k=v ...]` | encode and broadcast a START command (see [reference/concept.md](reference/concept.md) for keys: `name`, `f`, `bw`, `sf`, `cr`, `pow`, `size`, `stations`, `duration`, `padding`, `spacing`, `at`) | +| `stop` | multicast STOP — running stations return to IDLE | +| `help` | show command list | +| `quit` | exit | + +## Layout + +``` +modjam/ +├── cli.py # entrypoints +├── config.py # ~/modjam-config.json loader +├── protocol.py # `[:]|k:v,…` codec + heartbeat/next/done formatters +├── cuesheet.py # build cuesheet from START params +├── station.py # IDLE/RUNNING state machine +├── control.py # REPL + rx tail +├── log.py # TSV logger +└── radio/ + ├── base.py # Radio ABC + ├── meshcore.py # MeshCore over USB serial + ├── udp.py # simulator radio + └── factory.py # picks backend from SIMULATOR env +``` + +## Tests + +```sh +pip install -e '.[test]' +pytest +``` + +CI runs the same suite via [.forgejo/workflows/test.yml](.forgejo/workflows/test.yml) on push to `main` and on PRs. + +## Scope + +v1: IDLE + RUNNING only. RESULTS / DOWNLINKING is not implemented yet (see [reference/concept.md](reference/concept.md) and [notes.md](notes.md) for the protocol). diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9333926 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,54 @@ +services: + station-a: + build: { context: ., dockerfile: docker/station.Dockerfile } + environment: + SIMULATOR: "true" + PEERS: "station-a,station-b,station-c,control" + MODJAM_CONFIG: "/etc/modjam/config.json" + SIM_PACKET_LOSS: "${SIM_PACKET_LOSS:-0.15}" + volumes: + - ./sim/station-a.json:/etc/modjam/config.json:ro + - ./sim/logs:/var/log/modjam + networks: [modjam] + + station-b: + build: { context: ., dockerfile: docker/station.Dockerfile } + environment: + SIMULATOR: "true" + PEERS: "station-a,station-b,station-c,control" + MODJAM_CONFIG: "/etc/modjam/config.json" + SIM_PACKET_LOSS: "${SIM_PACKET_LOSS:-0.15}" + volumes: + - ./sim/station-b.json:/etc/modjam/config.json:ro + - ./sim/logs:/var/log/modjam + networks: [modjam] + + station-c: + build: { context: ., dockerfile: docker/station.Dockerfile } + environment: + SIMULATOR: "true" + PEERS: "station-a,station-b,station-c,control" + MODJAM_CONFIG: "/etc/modjam/config.json" + SIM_PACKET_LOSS: "${SIM_PACKET_LOSS:-0.15}" + volumes: + - ./sim/station-c.json:/etc/modjam/config.json:ro + - ./sim/logs:/var/log/modjam + networks: [modjam] + + control: + build: { context: ., dockerfile: docker/control.Dockerfile } + environment: + SIMULATOR: "true" + PEERS: "station-a,station-b,station-c,control" + MODJAM_CONFIG: "/etc/modjam/config.json" + SIM_PACKET_LOSS: "${SIM_PACKET_LOSS:-0.15}" + volumes: + - ./sim/control.json:/etc/modjam/config.json:ro + - ./sim/logs:/var/log/modjam + stdin_open: true + tty: true + networks: [modjam] + +networks: + modjam: + driver: bridge diff --git a/docker/control.Dockerfile b/docker/control.Dockerfile new file mode 100644 index 0000000..27414ef --- /dev/null +++ b/docker/control.Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY pyproject.toml requirements.txt /app/ +COPY modjam /app/modjam +RUN pip install --no-cache-dir -e . + +ENV PYTHONUNBUFFERED=1 +CMD ["modjam-control"] diff --git a/docker/station.Dockerfile b/docker/station.Dockerfile new file mode 100644 index 0000000..8119b78 --- /dev/null +++ b/docker/station.Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY pyproject.toml requirements.txt /app/ +COPY modjam /app/modjam +RUN pip install --no-cache-dir -e . + +ENV PYTHONUNBUFFERED=1 +CMD ["modjam-station"] diff --git a/modjam/__init__.py b/modjam/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modjam/cli.py b/modjam/cli.py new file mode 100644 index 0000000..ba521ed --- /dev/null +++ b/modjam/cli.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import argparse +import asyncio +import os +import sys + +from .config import StationConfig +from .control import ControlStation +from .radio.factory import make_radio +from .station import TestStation + + +def station_main() -> int: + parser = argparse.ArgumentParser(prog="modjam-station") + parser.add_argument("--config", help="Path to modjam-config.json (default: $MODJAM_CONFIG or ~/modjam-config.json)") + args = parser.parse_args() + + cfg = StationConfig.load(args.config) + if cfg.data_radio is None: + print("station config must include data_radio", file=sys.stderr) + return 2 + radio = make_radio(cfg.this_station_name, port=cfg.port) + station = TestStation(cfg, radio) + try: + asyncio.run(station.run()) + except KeyboardInterrupt: + pass + return 0 + + +def control_main() -> int: + parser = argparse.ArgumentParser(prog="modjam-control") + parser.add_argument("--config", help="Path to control-config.json (default: $MODJAM_CONFIG or ~/modjam-config.json)") + args = parser.parse_args() + + cfg = StationConfig.load(args.config) + radio = make_radio(cfg.this_station_name, port=cfg.port) + ctrl = ControlStation(cfg, radio) + try: + asyncio.run(ctrl.run()) + except KeyboardInterrupt: + pass + return 0 diff --git a/modjam/config.py b/modjam/config.py new file mode 100644 index 0000000..6348c8f --- /dev/null +++ b/modjam/config.py @@ -0,0 +1,51 @@ +from dataclasses import dataclass +from pathlib import Path +import json +import os + + +@dataclass +class RadioConfig: + frequency_mhz: float + bandwidth_khz: float + spread_factor: int + coding_rate: int + tx_power_dbm: int + + @classmethod + def from_dict(cls, d: dict) -> "RadioConfig": + return cls( + frequency_mhz=float(d["frequency_mhz"]), + bandwidth_khz=float(d["bandwidth_khz"]), + spread_factor=int(d["spread_factor"]), + coding_rate=int(d["coding_rate"]), + tx_power_dbm=int(d["tx_power_dbm"]), + ) + + +@dataclass +class StationConfig: + this_station_name: str + channel_name: str + channel_psk: str + idle_heartbeat_min: int + control_radio: RadioConfig + data_radio: RadioConfig | None + port: str | None = None + log_dir: str = "." + + @classmethod + def load(cls, path: str | Path | None = None) -> "StationConfig": + path = Path(path or os.environ.get("MODJAM_CONFIG") or Path.home() / "modjam-config.json") + with open(path) as f: + d = json.load(f) + return cls( + this_station_name=d["this_station_name"], + channel_name=d["channel_name"], + channel_psk=d["channel_psk"], + idle_heartbeat_min=int(d.get("idle_heartbeat_min", 15)), + control_radio=RadioConfig.from_dict(d["control_radio"]), + data_radio=RadioConfig.from_dict(d["data_radio"]) if "data_radio" in d else None, + port=d.get("port"), + log_dir=d.get("log_dir", "."), + ) diff --git a/modjam/control.py b/modjam/control.py new file mode 100644 index 0000000..5261535 --- /dev/null +++ b/modjam/control.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import asyncio +import shlex +import sys +import time + +from . import protocol +from .config import StationConfig +from .radio.base import Radio, RxPacket + + +HELP = """\ +Commands: + start [name=] [f=] [bw=<...>] [sf=<...>] [cr=<...>] + [pow=<...>] [size=<...>] [stations=A,B,C] + [duration=] [padding=] [spacing=] [at=] + stop multicast STOP to all stations + results (not yet implemented) + help show this + quit exit +""" + + +class ControlStation: + def __init__(self, cfg: StationConfig, radio: Radio): + self.cfg = cfg + self.radio = radio + + async def run(self) -> None: + await self.radio.connect() + self.radio.on_message(self._on_message) + await self.radio.ensure_channel(self.cfg.channel_name, self.cfg.channel_psk) + await self.radio.set_radio( + self.cfg.control_radio.frequency_mhz, + self.cfg.control_radio.bandwidth_khz, + self.cfg.control_radio.spread_factor, + self.cfg.control_radio.coding_rate, + self.cfg.control_radio.tx_power_dbm, + ) + print(f"[ctrl] online on {self.cfg.channel_name}; type `help`") + try: + await self._repl() + finally: + await self.radio.disconnect() + + def _on_message(self, pkt: RxPacket) -> None: + ts = time.strftime("%H:%M:%S") + print(f"\r[{ts}] rx{f' {pkt.sender}' if pkt.sender else ''}: {pkt.text}") + sys.stdout.write("> ") + sys.stdout.flush() + + async def _repl(self) -> None: + loop = asyncio.get_running_loop() + while True: + try: + sys.stdout.write("> ") + sys.stdout.flush() + line = await loop.run_in_executor(None, sys.stdin.readline) + except (EOFError, KeyboardInterrupt): + return + if not line: + return + line = line.strip() + if not line: + continue + try: + if not await self._dispatch(line): + return + except Exception as e: + print(f"[ctrl] error: {e}") + + async def _dispatch(self, line: str) -> bool: + parts = shlex.split(line) + head = parts[0].lower() + if head in ("quit", "exit"): + return False + if head == "help": + print(HELP) + return True + if head == "stop": + await self.radio.send(protocol.encode("STOP")) + print("[ctrl] STOP sent") + return True + if head == "start": + args = self._parse_kv(parts[1:]) + if "name" not in args: + print("start requires name=<...>") + return True + msg = protocol.encode("START", **args) + await self.radio.send(msg) + print(f"[ctrl] sent: {msg}") + return True + if head == "results": + print("[ctrl] results: not implemented in v1") + return True + print(f"unknown command: {head}") + return True + + @staticmethod + def _parse_kv(tokens: list[str]) -> dict[str, str]: + args: dict[str, str] = {} + for t in tokens: + if "=" not in t: + continue + k, v = t.split("=", 1) + args[k.strip()] = v.strip() + return args diff --git a/modjam/cuesheet.py b/modjam/cuesheet.py new file mode 100644 index 0000000..5e94950 --- /dev/null +++ b/modjam/cuesheet.py @@ -0,0 +1,99 @@ +from dataclasses import dataclass + + +VALID_BW = (62.5, 125.0, 250.0, 500.0) +VALID_SF = (6, 7, 8, 9, 10, 11, 12) +VALID_CR = (5, 6, 7, 8) + + +@dataclass +class CueEntry: + start: float # absolute unix ts: tune-and-send start + end: float # absolute unix ts: stop sending + next_at: float # absolute unix ts: send "next" message (start - 10) + done_at: float # absolute unix ts: send "done" message (end + 10) + tune_at: float # absolute unix ts: actually call set_radio (next_at + 10 = start) + spacing: float # seconds between transmissions + freq: float + bw: float + sf: int + cr: int + pow: int + size: int + sender: str + + +@dataclass +class CueParams: + name: str + freq: list[float] + bw: list[float] + sf: list[int] + cr: list[int] + pow: list[int] + size: list[int] + stations: list[str] + duration: int # seconds per case (sender airtime window) + padding: int # seconds between cases + spacing: int # seconds between transmissions + at: int # %nth-minute boundary to start + base_t: float # absolute start unix ts (computed from `at`) + + +def build(params: CueParams) -> list[CueEntry]: + entries: list[CueEntry] = [] + t = params.base_t + for freq in params.freq: + for bw in params.bw: + for sf in params.sf: + for cr in params.cr: + for pw in params.pow: + for size in params.size: + for sender in params.stations: + next_at = t + tune_at = next_at + 10 + start = tune_at + end = start + params.duration + done_at = end + 10 + entries.append(CueEntry( + start=start, + end=end, + next_at=next_at, + done_at=done_at, + tune_at=tune_at, + spacing=float(params.spacing), + freq=freq, + bw=bw, + sf=sf, + cr=cr, + pow=pw, + size=size, + sender=sender, + )) + t = done_at + params.padding + return entries + + +def parse_start_args(args: dict[str, list[str]]) -> dict: + """Convert string arg lists from a START command into typed values + defaults.""" + def floats(key, default): + return [float(x) for x in args.get(key, [])] or default + def ints(key, default): + return [int(x) for x in args.get(key, [])] or default + name = (args.get("name") or [None])[0] + if not name: + raise ValueError("START requires name") + return dict( + name=name, + freq=floats("f", [916.1]), + bw=floats("bw", list(VALID_BW)), + sf=ints("sf", list(VALID_SF)), + cr=ints("cr", list(VALID_CR)), + pow=ints("pow", [22]), + size=ints("size", [40]), + stations=args.get("stations") or ["A", "B"], + duration=int((args.get("duration") or ["300"])[0]), + padding=int((args.get("padding") or ["60"])[0]), + spacing=int((args.get("spacing") or ["2"])[0]), + at=int((args.get("at") or ["5"])[0]), + ) diff --git a/modjam/log.py b/modjam/log.py new file mode 100644 index 0000000..a200ca9 --- /dev/null +++ b/modjam/log.py @@ -0,0 +1,33 @@ +from pathlib import Path +from time import time +from typing import IO + + +class TsvLogger: + """Append-only TSV logger matching reference/A.tsv + reference/B.tsv format. + + queued: queued ,,,, + sent: sent + received: received + """ + + def __init__(self, station: str, log_dir: str | Path = "."): + Path(log_dir).mkdir(parents=True, exist_ok=True) + path = Path(log_dir) / f"{station}-{int(time())}.tsv" + self._fh: IO = open(path, "a", buffering=1) # line-buffered + self.path = path + + def queued(self, packet_id: int | str, freq: float, bw: float, sf: int, cr: int, pw: int) -> None: + self._fh.write(f"{time()}\tqueued\t{packet_id}\t{freq},{bw},{sf},{cr},{pw}\n") + + def sent(self, packet_id: int | str, duration_ms: int, text: str) -> None: + self._fh.write(f"{time()}\tsent\t{packet_id}\t{duration_ms}\t{text}\n") + + def received(self, packet_id: int | str, text: str) -> None: + self._fh.write(f"{time()}\treceived\t{packet_id}\t{text}\n") + + def close(self) -> None: + try: + self._fh.close() + except Exception: + pass diff --git a/modjam/protocol.py b/modjam/protocol.py new file mode 100644 index 0000000..dfe067a --- /dev/null +++ b/modjam/protocol.py @@ -0,0 +1,57 @@ +from dataclasses import dataclass, field +from typing import Iterable + + +@dataclass +class Command: + cmd: str + target: str | None + args: dict[str, list[str]] = field(default_factory=dict) + + def get(self, key: str, default: list[str] | None = None) -> list[str] | None: + return self.args.get(key, default) + + +def parse(text: str) -> Command | None: + text = text.strip() + if not text: + return None + parts = text.split("|") + head = parts[0] + if ":" in head: + cmd, target = head.split(":", 1) + else: + cmd, target = head, None + args: dict[str, list[str]] = {} + for part in parts[1:]: + if ":" not in part: + continue + k, v = part.split(":", 1) + args[k.strip()] = [x.strip() for x in v.split(",") if x.strip()] + return Command(cmd=cmd.strip().upper(), target=target, args=args) + + +def encode(cmd: str, target: str | None = None, **args: Iterable) -> str: + head = f"{cmd}:{target}" if target else cmd + parts = [head] + for k, v in args.items(): + if v is None: + continue + if isinstance(v, (list, tuple, set)): + v = ",".join(str(x) for x in v) + parts.append(f"{k}:{v}") + return "|".join(parts) + + +# Heartbeat / status messages — pipe-separated positional, no key:value structure. + +def encode_heartbeat(ts: int, station: str, state: str, noise_floor: int) -> str: + return f"{ts}|{station}|{state}|{noise_floor}" + + +def encode_next(ts: int, station: str, freq: float, bw: float, sf: int, cr: int, pow_: int, size: int) -> str: + return f"{ts}|{station}|next:{freq}/{bw}/{sf}/{cr}/{pow_}/{size}" + + +def encode_done(ts: int, station: str, freq: float, bw: float, sf: int, cr: int, pow_: int, size: int, n_sent: int, n_rcvd: int) -> str: + return f"{ts}|{station}|done:{freq}/{bw}/{sf}/{cr}/{pow_}/{size} {n_sent}/{n_rcvd}" diff --git a/modjam/radio/__init__.py b/modjam/radio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modjam/radio/base.py b/modjam/radio/base.py new file mode 100644 index 0000000..aa4d23b --- /dev/null +++ b/modjam/radio/base.py @@ -0,0 +1,45 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Awaitable, Callable + + +@dataclass +class RxPacket: + packet_id: int | str + text: str + sender: str | None = None + rssi: int | None = None + snr: float | None = None + + +@dataclass +class SendResult: + packet_id: int | str + duration_ms: int + + +MessageHandler = Callable[[RxPacket], Awaitable[None] | None] + + +class Radio(ABC): + @abstractmethod + async def connect(self) -> None: ... + + @abstractmethod + async def set_radio(self, freq_mhz: float, bw_khz: float, + sf: int, cr: int, tx_power_dbm: int) -> None: ... + + @abstractmethod + async def ensure_channel(self, name: str, psk_b64: str) -> None: ... + + @abstractmethod + async def send(self, text: str) -> SendResult: ... + + @abstractmethod + def on_message(self, cb: MessageHandler) -> None: ... + + @abstractmethod + async def get_noise_floor(self) -> int: ... + + @abstractmethod + async def disconnect(self) -> None: ... diff --git a/modjam/radio/factory.py b/modjam/radio/factory.py new file mode 100644 index 0000000..9596bfc --- /dev/null +++ b/modjam/radio/factory.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import os + +from .base import Radio + + +def make_radio(station: str, port: str | None = None) -> Radio: + if os.environ.get("SIMULATOR", "").lower() in ("1", "true", "yes"): + from .udp import UDPRadio + peers = [p.strip() for p in os.environ.get("PEERS", "").split(",") if p.strip()] + peers = [p for p in peers if p != station and p.lower() != station.lower()] + return UDPRadio(station=station, peers=peers) + from .meshcore import MeshCoreRadio + return MeshCoreRadio(port=port) diff --git a/modjam/radio/meshcore.py b/modjam/radio/meshcore.py new file mode 100644 index 0000000..67fad50 --- /dev/null +++ b/modjam/radio/meshcore.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import time +from itertools import count + +from .base import MessageHandler, Radio, RxPacket, SendResult + + +def _psk_bytes(psk_b64: str) -> bytes: + """Decode the channel PSK from base64; pad/truncate to 16 bytes.""" + try: + raw = base64.b64decode(psk_b64 + "=" * (-len(psk_b64) % 4), validate=False) + except Exception: + raw = psk_b64.encode("utf-8") + if len(raw) >= 16: + return raw[:16] + return hashlib.sha256(raw).digest()[:16] + + +class MeshCoreRadio(Radio): + """Wraps the `meshcore` Python library over USB serial.""" + + def __init__(self, port: str | None = None, baud: int = 115200): + self._port = port + self._baud = baud + self._mc = None + self._handlers: list[MessageHandler] = [] + self._channel_idx = 0 + self._configured_channel: tuple[str, str] | None = None + self._counter = count(1) + + async def connect(self) -> None: + from meshcore import MeshCore, EventType + if self._port: + self._mc = await MeshCore.create_serial(self._port, self._baud) + else: + self._mc = await MeshCore.create_serial() + self._EventType = EventType + self._mc.subscribe(EventType.CHANNEL_MSG_RECV, self._on_chan_msg) + await self._mc.start_auto_message_fetching() + + async def _on_chan_msg(self, event) -> None: + p = event.payload or {} + text = p.get("text") or (p.get("payload", b"").decode("utf-8", errors="replace") if isinstance(p.get("payload"), (bytes, bytearray)) else "") + pkt = RxPacket( + packet_id=p.get("packet_id") or p.get("id") or next(self._counter), + text=text, + sender=str(p.get("pubkey_prefix") or p.get("from") or ""), + rssi=p.get("rssi"), + snr=p.get("snr"), + ) + for cb in self._handlers: + res = cb(pkt) + if asyncio.iscoroutine(res): + await res + + async def set_radio(self, freq_mhz: float, bw_khz: float, + sf: int, cr: int, tx_power_dbm: int) -> None: + assert self._mc + await self._mc.commands.set_radio(freq_mhz, bw_khz, sf, cr) + await self._mc.commands.set_tx_power(tx_power_dbm) + + async def ensure_channel(self, name: str, psk_b64: str) -> None: + assert self._mc + if self._configured_channel == (name, psk_b64): + return + await self._mc.commands.set_channel(self._channel_idx, name, _psk_bytes(psk_b64)) + self._configured_channel = (name, psk_b64) + + async def send(self, text: str) -> SendResult: + assert self._mc + t0 = time.perf_counter() + result = await self._mc.commands.send_chan_msg(self._channel_idx, text) + ms = int((time.perf_counter() - t0) * 1000) + payload = getattr(result, "payload", {}) or {} + pid = payload.get("packet_id") or payload.get("id") or next(self._counter) + return SendResult(packet_id=pid, duration_ms=ms) + + def on_message(self, cb: MessageHandler) -> None: + self._handlers.append(cb) + + async def get_noise_floor(self) -> int: + assert self._mc + try: + ev = await self._mc.commands.get_stats_radio() + payload = getattr(ev, "payload", {}) or {} + return int(payload.get("noise_floor", payload.get("noise", -100))) + except Exception: + return -100 + + async def disconnect(self) -> None: + if self._mc: + try: + await self._mc.disconnect() + except Exception: + pass + self._mc = None diff --git a/modjam/radio/udp.py b/modjam/radio/udp.py new file mode 100644 index 0000000..5af2a2f --- /dev/null +++ b/modjam/radio/udp.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +import random +import re +import socket +import time +from itertools import count + +from .base import MessageHandler, Radio, RxPacket, SendResult + + +SIM_PORT = int(os.environ.get("SIM_PORT", "9000")) + +# Test packet payload format: `,,|`. +# Heartbeat/next/done payloads start with `||...` — no comma +# before the first `|` — so this regex matches test packets only. +_TEST_PACKET_RE = re.compile(r"^\d+(?:\.\d+)?,\d+(?:\.\d+)?,\d+\|") + + +def _psk_hash(psk_b64: str) -> str: + return hashlib.sha256(psk_b64.encode("utf-8")).hexdigest()[:16] + + +def _params_match(a: dict, b: dict) -> bool: + """Real radios only hear if freq/bw/sf/cr/channel agree.""" + return ( + abs(a["freq"] - b["freq"]) < 1e-3 + and abs(a["bw"] - b["bw"]) < 1e-3 + and a["sf"] == b["sf"] + and a["cr"] == b["cr"] + and a["channel"] == b["channel"] + and a["psk_hash"] == b["psk_hash"] + ) + + +class _UdpProtocol(asyncio.DatagramProtocol): + def __init__(self, radio: "UDPRadio"): + self.radio = radio + + def datagram_received(self, data: bytes, addr) -> None: + try: + msg = json.loads(data.decode("utf-8")) + except Exception: + return + self.radio._handle_incoming(msg) + + +class UDPRadio(Radio): + """Simulator radio: broadcasts JSON datagrams to listed peers. + + Drops incoming packets whose sender radio params don't match this radio's + current settings — mimics real radios not hearing off-config transmissions. + """ + + def __init__(self, station: str, peers: list[str], port: int = SIM_PORT): + self._station = station + self._peers = [p for p in peers if p] + self._port = port + self._handlers: list[MessageHandler] = [] + self._counter = count(1) + self._loop: asyncio.AbstractEventLoop | None = None + self._transport: asyncio.DatagramTransport | None = None + self._cur = { + "freq": 0.0, "bw": 0.0, "sf": 0, "cr": 0, "pow": 0, + "channel": "", "psk_hash": "", + } + self._noise_floor = int(os.environ.get("SIM_NOISE", "-100")) + loss = float(os.environ.get("SIM_PACKET_LOSS", "0") or 0) + self._test_packet_loss = max(0.0, min(1.0, loss)) + self._rng = random.Random(os.environ.get("SIM_SEED") or station) + + async def connect(self) -> None: + self._loop = asyncio.get_running_loop() + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + sock.setblocking(False) + sock.bind(("0.0.0.0", self._port)) + self._transport, _ = await self._loop.create_datagram_endpoint( + lambda: _UdpProtocol(self), sock=sock + ) + + async def set_radio(self, freq_mhz: float, bw_khz: float, + sf: int, cr: int, tx_power_dbm: int) -> None: + self._cur["freq"] = float(freq_mhz) + self._cur["bw"] = float(bw_khz) + self._cur["sf"] = int(sf) + self._cur["cr"] = int(cr) + self._cur["pow"] = int(tx_power_dbm) + + async def ensure_channel(self, name: str, psk_b64: str) -> None: + self._cur["channel"] = name + self._cur["psk_hash"] = _psk_hash(psk_b64) + + async def send(self, text: str) -> SendResult: + assert self._transport + pid = next(self._counter) + msg = { + **self._cur, + "sender": self._station, + "packet_id": pid, + "text": text, + "ts": time.time(), + } + data = json.dumps(msg).encode("utf-8") + t0 = time.perf_counter() + for peer in self._peers: + try: + addrs = socket.getaddrinfo(peer, self._port, type=socket.SOCK_DGRAM) + except socket.gaierror: + continue + for *_, sockaddr in addrs: + try: + self._transport.sendto(data, sockaddr) + break + except OSError: + continue + ms = int((time.perf_counter() - t0) * 1000) or 1 + return SendResult(packet_id=pid, duration_ms=ms) + + def _handle_incoming(self, msg: dict) -> None: + if msg.get("sender") == self._station: + return + if not _params_match(msg, self._cur): + return + text = msg.get("text", "") or "" + if ( + self._test_packet_loss > 0 + and _TEST_PACKET_RE.match(text) + and self._rng.random() < self._test_packet_loss + ): + return + pkt = RxPacket( + packet_id=msg.get("packet_id"), + text=text, + sender=msg.get("sender"), + rssi=msg.get("rssi"), + snr=msg.get("snr"), + ) + for cb in self._handlers: + res = cb(pkt) + if asyncio.iscoroutine(res): + assert self._loop + asyncio.run_coroutine_threadsafe(res, self._loop) + + def on_message(self, cb: MessageHandler) -> None: + self._handlers.append(cb) + + async def get_noise_floor(self) -> int: + return self._noise_floor + + async def disconnect(self) -> None: + if self._transport: + self._transport.close() + self._transport = None diff --git a/modjam/station.py b/modjam/station.py new file mode 100644 index 0000000..1c9f468 --- /dev/null +++ b/modjam/station.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +import asyncio +import random +import string +import time +from datetime import datetime, timedelta + +from . import cuesheet, protocol +from .config import RadioConfig, StationConfig +from .log import TsvLogger +from .radio.base import Radio, RxPacket + + +def _now_ts() -> int: + return int(time.time()) + + +def _next_minute_boundary(at: int) -> float: + n = datetime.now() + floor = datetime(n.year, n.month, n.day, n.hour, n.minute) + delta_min = at - n.minute % at + if delta_min == 0: + delta_min = at + return (floor + timedelta(minutes=delta_min)).timestamp() + + +class TestStation: + HEARTBEAT_STARTUP_DELAY_S = 2 + + def __init__(self, cfg: StationConfig, radio: Radio): + self.cfg = cfg + self.radio = radio + self.state: str = "IDLE" + self._cmd_queue: asyncio.Queue = asyncio.Queue() + self._stop_run = asyncio.Event() + self._case_active = False + self._cur_radio_label: tuple[float, float, int, int, int] | None = None + self._rcvd_count = 0 + self._sent_count = 0 + self._logger: TsvLogger | None = None + + # ---- lifecycle ---- + + async def run(self) -> None: + await self.radio.connect() + self.radio.on_message(self._on_message) + await self.radio.ensure_channel(self.cfg.channel_name, self.cfg.channel_psk) + await self._tune(self.cfg.control_radio) + + hb_task = asyncio.create_task(self._heartbeat_loop(), name="heartbeat") + try: + while True: + await self._idle_until_start() + if self._pending_start: + await self._run_test(self._pending_start) + self._pending_start = None + finally: + hb_task.cancel() + await self.radio.disconnect() + if self._logger: + self._logger.close() + + # ---- IDLE ---- + + _pending_start = None + + async def _idle_until_start(self) -> None: + self.state = "IDLE" + await self._tune(self.cfg.control_radio) + while True: + cmd = await self._cmd_queue.get() + if cmd.cmd != "START": + continue + if not self._addressed_to_us(cmd): + continue + stations = cmd.get("stations") or ["A", "B"] + if self.cfg.this_station_name not in stations: + continue + self._pending_start = cmd + return + + def _addressed_to_us(self, cmd: protocol.Command) -> bool: + if cmd.target is None: + return True # multicast + return cmd.target.upper() == self.cfg.this_station_name.upper() + + async def _heartbeat_loop(self) -> None: + await asyncio.sleep(self.HEARTBEAT_STARTUP_DELAY_S) + interval = max(1, self.cfg.idle_heartbeat_min) * 60 + while True: + try: + if self.state == "IDLE": + nf = await self.radio.get_noise_floor() + msg = protocol.encode_heartbeat(_now_ts(), self.cfg.this_station_name, self.state, nf) + await self.radio.send(msg) + except Exception as e: + print(f"[hb] error: {e}") + await asyncio.sleep(interval) + + # ---- RUNNING ---- + + async def _run_test(self, start_cmd: protocol.Command) -> None: + self.state = "RUNNING" + self._stop_run.clear() + try: + params = cuesheet.parse_start_args(start_cmd.args) + except ValueError as e: + print(f"[run] bad START: {e}") + return + if self.cfg.this_station_name not in params["stations"]: + print(f"[run] station {self.cfg.this_station_name} not in {params['stations']}; ignoring") + return + + base_t = _next_minute_boundary(params["at"]) + cue_params = cuesheet.CueParams(base_t=base_t, **params) + cues = cuesheet.build(cue_params) + print(f"[run] {len(cues)} cases, ~{(cues[-1].done_at - base_t) / 3600:.2f}h, base_t={base_t}") + + self._logger = TsvLogger(self.cfg.this_station_name, self.cfg.log_dir) + + try: + for cue in cues: + if self._stop_run.is_set() or self._check_stop_in_queue(): + print("[run] STOP received, returning to IDLE") + return + await self._run_case(cue) + finally: + await self._tune(self.cfg.control_radio) + if self._logger: + self._logger.close() + self._logger = None + self.state = "IDLE" + + async def _run_case(self, cue: cuesheet.CueEntry) -> None: + # T-10: announce next + await self._sleep_until(cue.next_at) + if self._stop_run.is_set() or self._check_stop_in_queue(): + return + await self._tune(self.cfg.control_radio) + await self.radio.send(protocol.encode_next( + _now_ts(), self.cfg.this_station_name, + cue.freq, cue.bw, cue.sf, cue.cr, cue.pow, cue.size, + )) + + # T0: tune to data radio + await self._sleep_until(cue.tune_at) + if self._stop_run.is_set(): + return + await self.radio.set_radio(cue.freq, cue.bw, cue.sf, cue.cr, cue.pow) + self._cur_radio_label = (cue.freq, cue.bw, cue.sf, cue.cr, cue.pow) + self._sent_count = 0 + self._rcvd_count = 0 + self._case_active = True + try: + if cue.sender == self.cfg.this_station_name: + await self._send_loop(cue) + else: + await self._listen_until(cue.end) + finally: + self._case_active = False + + # post-case: control radio + done + await self._tune(self.cfg.control_radio) + await self._sleep_until(cue.done_at) + await self.radio.send(protocol.encode_done( + _now_ts(), self.cfg.this_station_name, + cue.freq, cue.bw, cue.sf, cue.cr, cue.pow, cue.size, + self._sent_count, self._rcvd_count, + )) + + async def _send_loop(self, cue: cuesheet.CueEntry) -> None: + n = 0 + case_start = time.time() + while time.time() < cue.end and not self._stop_run.is_set(): + n += 1 + self._sent_count = n + text = self._make_payload(cue.size, n, time.time() - case_start) + assert self._logger + try: + # cannot know packet_id before send; log queued with placeholder, then sent + result = await self.radio.send(text) + self._logger.queued(result.packet_id, cue.freq, cue.bw, cue.sf, cue.cr, cue.pow) + self._logger.sent(result.packet_id, result.duration_ms, text) + except Exception as e: + print(f"[send] error: {e}") + try: + await asyncio.wait_for(self._stop_run.wait(), timeout=cue.spacing) + return # stop_run set + except asyncio.TimeoutError: + pass + + async def _listen_until(self, until_ts: float) -> None: + while time.time() < until_ts and not self._stop_run.is_set(): + remaining = until_ts - time.time() + try: + await asyncio.wait_for(self._stop_run.wait(), timeout=max(0.1, remaining)) + return + except asyncio.TimeoutError: + pass + + @staticmethod + def _make_payload(size: int, n: int, t: float) -> str: + prefix = f"{time.time()},{t},{n}|" + if len(prefix) >= size: + return prefix + pad = "".join(random.choice(string.ascii_letters) for _ in range(size - len(prefix))) + return prefix + pad + + # ---- helpers ---- + + async def _tune(self, rc: RadioConfig) -> None: + await self.radio.set_radio( + rc.frequency_mhz, rc.bandwidth_khz, rc.spread_factor, rc.coding_rate, rc.tx_power_dbm, + ) + + async def _sleep_until(self, ts: float) -> None: + while True: + now = time.time() + if now >= ts: + return + try: + await asyncio.wait_for(self._stop_run.wait(), timeout=ts - now) + return + except asyncio.TimeoutError: + return + + def _check_stop_in_queue(self) -> bool: + # Drain queue checking for STOP without blocking. + found = False + while not self._cmd_queue.empty(): + cmd = self._cmd_queue.get_nowait() + if cmd.cmd == "STOP": + found = True + self._stop_run.set() + return found + + # ---- rx routing ---- + + def _on_message(self, pkt: RxPacket) -> None: + text = pkt.text or "" + cmd = None + if text: + try: + cmd = protocol.parse(text) + except Exception: + cmd = None + if cmd and cmd.cmd in ("START", "STOP"): + if cmd.cmd == "STOP" and self.state == "RUNNING": + self._stop_run.set() + self._cmd_queue.put_nowait(cmd) + return + if self._case_active and self._logger: + self._rcvd_count += 1 + self._logger.received(pkt.packet_id, text) diff --git a/notes.md b/notes.md new file mode 100644 index 0000000..b1cd4db --- /dev/null +++ b/notes.md @@ -0,0 +1,46 @@ +Review the @README.md and implement a python process for the test stations that can run on a Raspberry Pi and a process for the control station that can run on a macbook. + +Also set up a simulator using docker-compose with Docker containers for each Test Station process and a control station, with a `SIMULATOR=true` env variable. When this is true, the process uses UDP on the Docker network between the containers to "transmit" and "listen" for packets instead of looking for a USB serial attached node device. + +Reference a prototype in @reference/modjam-prototype.py and its output tsv files. + + + + +#### DOWNLINKING + +The control node sends a `RESULTS:|` command on the `control_radio` settings. This is received by all Test Stations in `IDLE` state but only the station addressed by the command acts on the command. + + + + + + +Some arbitrary time after, the control station sends a `RESULTS:A|test1` command. The Control Station and Test Station A tune to the data radio settings and Test Station A enters `DOWNLINKING` state. Each beacons a "ready" message until they hear the other station's "ready", at which point Test Station A + + + + +Simulator using Docker containers for each Test Station process, with a `SIMULATOR=true` env variable. When this is true, the process uses UDP on the Docker network between the containers to "transmit" and "listen" for packets. + + +Commands: +- START +- STOP +- RESULTS + +[:]||… + +Listens for START command from control node +`START|name:|f:915.1|bw:62.5,125,250,500|sf:6,7,8,9,10,11,12|cr:4,5,6,7,8|pow:10,22` + + + +RESULTS command: +`RESULTS:\tname:` +RESULTS:A|name +sends summary of each trial +|||||||||… +A|test1|915.1|500|7|8|22|100|88|84 +B|test1|915.1|500|7|8|22|85|100|80 +C|test1|915.1|500|7|8|22|85|100|80 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b66497b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "modjam" +version = "0.1.0" +description = "LoRa modulation jam — packet delivery rate measurement across MeshCore radio settings" +requires-python = ">=3.11" +dependencies = [ + "meshcore==2.3.7", +] + +[project.optional-dependencies] +test = [ + "pytest>=8", +] + +[project.scripts] +modjam-station = "modjam.cli:station_main" +modjam-control = "modjam.cli:control_main" + +[tool.setuptools.packages.find] +include = ["modjam*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/sim/control.json b/sim/control.json new file mode 100644 index 0000000..b10cd14 --- /dev/null +++ b/sim/control.json @@ -0,0 +1,14 @@ +{ + "this_station_name": "CTRL", + "channel_name": "modjam", + "channel_psk": "MTIzNGFiY2RlZmdoaWprbA==", + "idle_heartbeat_min": 60, + "log_dir": "/var/log/modjam", + "control_radio": { + "frequency_mhz": 915.125, + "bandwidth_khz": 500, + "spread_factor": 12, + "coding_rate": 8, + "tx_power_dbm": 22 + } +} diff --git a/sim/station-a.json b/sim/station-a.json new file mode 100644 index 0000000..4736d42 --- /dev/null +++ b/sim/station-a.json @@ -0,0 +1,21 @@ +{ + "this_station_name": "A", + "channel_name": "modjam", + "channel_psk": "MTIzNGFiY2RlZmdoaWprbA==", + "idle_heartbeat_min": 1, + "log_dir": "/var/log/modjam", + "control_radio": { + "frequency_mhz": 915.125, + "bandwidth_khz": 500, + "spread_factor": 12, + "coding_rate": 8, + "tx_power_dbm": 22 + }, + "data_radio": { + "frequency_mhz": 915.125, + "bandwidth_khz": 500, + "spread_factor": 7, + "coding_rate": 5, + "tx_power_dbm": 1 + } +} diff --git a/sim/station-b.json b/sim/station-b.json new file mode 100644 index 0000000..0f85ed7 --- /dev/null +++ b/sim/station-b.json @@ -0,0 +1,21 @@ +{ + "this_station_name": "B", + "channel_name": "modjam", + "channel_psk": "MTIzNGFiY2RlZmdoaWprbA==", + "idle_heartbeat_min": 1, + "log_dir": "/var/log/modjam", + "control_radio": { + "frequency_mhz": 915.125, + "bandwidth_khz": 500, + "spread_factor": 12, + "coding_rate": 8, + "tx_power_dbm": 22 + }, + "data_radio": { + "frequency_mhz": 915.125, + "bandwidth_khz": 500, + "spread_factor": 7, + "coding_rate": 5, + "tx_power_dbm": 1 + } +} diff --git a/sim/station-c.json b/sim/station-c.json new file mode 100644 index 0000000..c42c843 --- /dev/null +++ b/sim/station-c.json @@ -0,0 +1,21 @@ +{ + "this_station_name": "C", + "channel_name": "modjam", + "channel_psk": "MTIzNGFiY2RlZmdoaWprbA==", + "idle_heartbeat_min": 1, + "log_dir": "/var/log/modjam", + "control_radio": { + "frequency_mhz": 915.125, + "bandwidth_khz": 500, + "spread_factor": 12, + "coding_rate": 8, + "tx_power_dbm": 22 + }, + "data_radio": { + "frequency_mhz": 915.125, + "bandwidth_khz": 500, + "spread_factor": 7, + "coding_rate": 5, + "tx_power_dbm": 1 + } +} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..ba8fa8d --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,63 @@ +import json + +import pytest + +from modjam.config import StationConfig + + +SAMPLE = { + "this_station_name": "A", + "channel_name": "modjam", + "channel_psk": "MTIzNGFiY2RlZmdoaWprbA==", + "idle_heartbeat_min": 2, + "control_radio": { + "frequency_mhz": 915.125, + "bandwidth_khz": 500, + "spread_factor": 12, + "coding_rate": 8, + "tx_power_dbm": 22, + }, + "data_radio": { + "frequency_mhz": 915.125, + "bandwidth_khz": 500, + "spread_factor": 7, + "coding_rate": 5, + "tx_power_dbm": 1, + }, +} + + +def _write(tmp_path, data): + p = tmp_path / "modjam-config.json" + p.write_text(json.dumps(data)) + return p + + +def test_load_full_config(tmp_path): + cfg = StationConfig.load(_write(tmp_path, SAMPLE)) + assert cfg.this_station_name == "A" + assert cfg.idle_heartbeat_min == 2 + assert cfg.control_radio.spread_factor == 12 + assert cfg.data_radio is not None + assert cfg.data_radio.tx_power_dbm == 1 + + +def test_load_defaults_heartbeat(tmp_path): + d = dict(SAMPLE) + d.pop("idle_heartbeat_min") + cfg = StationConfig.load(_write(tmp_path, d)) + assert cfg.idle_heartbeat_min == 15 + + +def test_load_without_data_radio(tmp_path): + d = dict(SAMPLE) + d.pop("data_radio") + cfg = StationConfig.load(_write(tmp_path, d)) + assert cfg.data_radio is None + + +def test_load_missing_required(tmp_path): + d = dict(SAMPLE) + d.pop("channel_name") + with pytest.raises(KeyError): + StationConfig.load(_write(tmp_path, d)) diff --git a/tests/test_cuesheet.py b/tests/test_cuesheet.py new file mode 100644 index 0000000..7ceadec --- /dev/null +++ b/tests/test_cuesheet.py @@ -0,0 +1,86 @@ +import pytest + +from modjam import cuesheet + + +def test_parse_start_args_defaults(): + args = cuesheet.parse_start_args({"name": ["x"]}) + assert args["name"] == "x" + assert args["freq"] == [916.1] + assert args["bw"] == list(cuesheet.VALID_BW) + assert args["sf"] == list(cuesheet.VALID_SF) + assert args["cr"] == list(cuesheet.VALID_CR) + assert args["pow"] == [22] + assert args["size"] == [40] + assert args["stations"] == ["A", "B"] + assert args["duration"] == 300 + assert args["padding"] == 60 + assert args["spacing"] == 2 + assert args["at"] == 5 + + +def test_parse_start_args_overrides(): + args = cuesheet.parse_start_args({ + "name": ["t1"], + "f": ["910.2", "915.1"], + "bw": ["62.5", "500"], + "sf": ["7", "8"], + "cr": ["5"], + "pow": ["10", "22"], + "stations": ["A", "B", "C"], + "duration": ["60"], + "padding": ["5"], + "spacing": ["1"], + "size": ["40", "200"], + "at": ["1"], + }) + assert args["freq"] == [910.2, 915.1] + assert args["bw"] == [62.5, 500.0] + assert args["sf"] == [7, 8] + assert args["pow"] == [10, 22] + assert args["stations"] == ["A", "B", "C"] + assert args["duration"] == 60 + assert args["size"] == [40, 200] + + +def test_parse_start_args_requires_name(): + with pytest.raises(ValueError): + cuesheet.parse_start_args({}) + + +def test_build_case_count(): + args = cuesheet.parse_start_args({ + "name": ["x"], + "f": ["915.1"], + "bw": ["500"], + "sf": ["7", "8"], + "cr": ["5"], + "pow": ["22"], + "size": ["40"], + "stations": ["A", "B"], + "duration": ["10"], + "padding": ["2"], + "spacing": ["1"], + "at": ["1"], + }) + cues = cuesheet.build(cuesheet.CueParams(base_t=0.0, **args)) + # 1 freq * 1 bw * 2 sf * 1 cr * 1 pow * 1 size * 2 stations + assert len(cues) == 4 + + +def test_build_timing_invariants(): + args = cuesheet.parse_start_args({"name": ["x"], "f": ["915.1"], "bw": ["500"], "sf": ["7"], "cr": ["5"], "pow": ["22"], "size": ["40"], "stations": ["A"], "duration": ["20"], "padding": ["5"], "spacing": ["1"], "at": ["1"]}) + cues = cuesheet.build(cuesheet.CueParams(base_t=1000.0, **args)) + c = cues[0] + assert c.next_at == 1000.0 + assert c.tune_at == 1010.0 + assert c.start == c.tune_at + assert c.end == c.start + 20 + assert c.done_at == c.end + 10 + assert c.spacing == 1.0 + + +def test_build_consecutive_cases_separated_by_padding(): + args = cuesheet.parse_start_args({"name": ["x"], "f": ["915.1"], "bw": ["500"], "sf": ["7"], "cr": ["5"], "pow": ["22"], "size": ["40"], "stations": ["A", "B"], "duration": ["10"], "padding": ["3"], "spacing": ["1"], "at": ["1"]}) + cues = cuesheet.build(cuesheet.CueParams(base_t=0.0, **args)) + assert cues[1].next_at == cues[0].done_at + 3 diff --git a/tests/test_log.py b/tests/test_log.py new file mode 100644 index 0000000..33122e7 --- /dev/null +++ b/tests/test_log.py @@ -0,0 +1,37 @@ +from modjam.log import TsvLogger + + +def test_tsv_logger_writes_all_event_types(tmp_path): + logger = TsvLogger("Z", tmp_path) + logger.queued(42, 915.1, 500.0, 7, 5, 22) + logger.sent(42, 13, "1234.5,0.0,1|abc") + logger.received(99, "1234.5,1.0,2|xyz") + logger.close() + + rows = logger.path.read_text().strip().split("\n") + assert len(rows) == 3 + + parts0 = rows[0].split("\t") + assert parts0[1] == "queued" + assert parts0[2] == "42" + assert parts0[3] == "915.1,500.0,7,5,22" + + parts1 = rows[1].split("\t") + assert parts1[1] == "sent" + assert parts1[2] == "42" + assert parts1[3] == "13" + assert parts1[4] == "1234.5,0.0,1|abc" + + parts2 = rows[2].split("\t") + assert parts2[1] == "received" + assert parts2[2] == "99" + assert parts2[3] == "1234.5,1.0,2|xyz" + + +def test_tsv_logger_creates_log_dir(tmp_path): + sub = tmp_path / "nested" / "logs" + logger = TsvLogger("A", sub) + logger.queued(1, 0, 0, 0, 0, 0) + logger.close() + assert sub.is_dir() + assert logger.path.exists() diff --git a/tests/test_protocol.py b/tests/test_protocol.py new file mode 100644 index 0000000..4269ad6 --- /dev/null +++ b/tests/test_protocol.py @@ -0,0 +1,69 @@ +from modjam import protocol + + +def test_parse_simple(): + c = protocol.parse("START|name:foo") + assert c.cmd == "START" + assert c.target is None + assert c.args == {"name": ["foo"]} + + +def test_parse_with_target(): + c = protocol.parse("RESULTS:A|name:t1") + assert c.cmd == "RESULTS" + assert c.target == "A" + assert c.args == {"name": ["t1"]} + + +def test_parse_multi_value(): + c = protocol.parse("START|bw:62.5,500|sf:7,8,9|name:x") + assert c.args["bw"] == ["62.5", "500"] + assert c.args["sf"] == ["7", "8", "9"] + + +def test_parse_empty_returns_none(): + assert protocol.parse("") is None + assert protocol.parse(" \n") is None + + +def test_parse_lowercase_cmd_normalized(): + assert protocol.parse("stop").cmd == "STOP" + + +def test_parse_arg_without_colon_skipped(): + c = protocol.parse("START|name:x|brokenpart|sf:7") + assert c.args == {"name": ["x"], "sf": ["7"]} + + +def test_encode_scalar_and_list(): + out = protocol.encode("START", name="x", sf=[7, 8], bw=500) + assert out == "START|name:x|sf:7,8|bw:500" + + +def test_encode_with_target(): + assert protocol.encode("RESULTS", target="A", name="t1") == "RESULTS:A|name:t1" + + +def test_encode_skips_none(): + assert protocol.encode("STOP", target=None, foo=None) == "STOP" + + +def test_encode_roundtrip(): + src = protocol.encode("START", name="x", stations=["A", "B", "C"], sf=[7, 8]) + parsed = protocol.parse(src) + assert parsed.cmd == "START" + assert parsed.args["name"] == ["x"] + assert parsed.args["stations"] == ["A", "B", "C"] + assert parsed.args["sf"] == ["7", "8"] + + +def test_heartbeat_format(): + assert protocol.encode_heartbeat(123, "A", "IDLE", -100) == "123|A|IDLE|-100" + + +def test_next_format(): + assert protocol.encode_next(123, "A", 915.1, 500, 7, 5, 22, 40) == "123|A|next:915.1/500/7/5/22/40" + + +def test_done_format(): + assert protocol.encode_done(123, "A", 915.1, 500, 7, 5, 22, 40, 100, 88) == "123|A|done:915.1/500/7/5/22/40 100/88" diff --git a/tests/test_udp_radio.py b/tests/test_udp_radio.py new file mode 100644 index 0000000..b4a5eac --- /dev/null +++ b/tests/test_udp_radio.py @@ -0,0 +1,145 @@ +import os + +import pytest + +from modjam.radio.udp import UDPRadio, _params_match, _psk_hash + + +@pytest.fixture +def radio(monkeypatch): + """Construct a UDPRadio without binding sockets.""" + monkeypatch.delenv("SIM_PACKET_LOSS", raising=False) + monkeypatch.delenv("SIM_SEED", raising=False) + return UDPRadio(station="A", peers=["B", "C"]) + + +def _msg(**overrides): + base = { + "sender": "B", + "freq": 915.1, "bw": 500.0, "sf": 7, "cr": 5, "pow": 22, + "channel": "modjam", "psk_hash": "abc123", + "packet_id": 42, + "text": "1234567890.0,1.0,1|payload", + "ts": 0.0, + } + base.update(overrides) + return base + + +def _tune(radio, **overrides): + radio._cur.update({ + "freq": 915.1, "bw": 500.0, "sf": 7, "cr": 5, "pow": 22, + "channel": "modjam", "psk_hash": "abc123", + }) + radio._cur.update(overrides) + + +def test_params_match_exact(): + a = {"freq": 915.1, "bw": 500.0, "sf": 7, "cr": 5, "channel": "x", "psk_hash": "h"} + b = dict(a) + assert _params_match(a, b) is True + + +@pytest.mark.parametrize("field,bad", [ + ("freq", 916.0), + ("bw", 250.0), + ("sf", 8), + ("cr", 6), + ("channel", "other"), + ("psk_hash", "different"), +]) +def test_params_match_rejects_mismatch(field, bad): + a = {"freq": 915.1, "bw": 500.0, "sf": 7, "cr": 5, "channel": "x", "psk_hash": "h"} + b = dict(a) + b[field] = bad + assert _params_match(a, b) is False + + +def test_psk_hash_deterministic_and_short(): + h1 = _psk_hash("hello") + h2 = _psk_hash("hello") + assert h1 == h2 and len(h1) == 16 + assert _psk_hash("hello") != _psk_hash("world") + + +def test_handle_incoming_drops_self(radio): + _tune(radio) + received = [] + radio.on_message(lambda p: received.append(p)) + radio._handle_incoming(_msg(sender="A")) + assert received == [] + + +def test_handle_incoming_drops_param_mismatch(radio): + _tune(radio, sf=8) + received = [] + radio.on_message(lambda p: received.append(p)) + radio._handle_incoming(_msg(sf=7)) + assert received == [] + + +def test_handle_incoming_delivers_match(radio): + _tune(radio) + received = [] + radio.on_message(lambda p: received.append(p)) + radio._handle_incoming(_msg()) + assert len(received) == 1 + assert received[0].sender == "B" + assert received[0].text.startswith("1234567890.0,") + + +def test_packet_loss_drops_test_packets(monkeypatch): + monkeypatch.setenv("SIM_PACKET_LOSS", "1.0") # drop everything + monkeypatch.setenv("SIM_SEED", "fixed") + r = UDPRadio(station="A", peers=["B"]) + _tune(r) + received = [] + r.on_message(lambda p: received.append(p)) + for i in range(20): + r._handle_incoming(_msg(packet_id=i, text=f"1234.5,1.0,{i}|x")) + assert received == [] # 100% drop + + +def test_packet_loss_does_not_drop_protocol_messages(monkeypatch): + monkeypatch.setenv("SIM_PACKET_LOSS", "1.0") + monkeypatch.setenv("SIM_SEED", "fixed") + r = UDPRadio(station="A", peers=["B"]) + _tune(r) + received = [] + r.on_message(lambda p: received.append(p)) + # heartbeat + START + STOP should never drop, regardless of loss. + r._handle_incoming(_msg(text="1234567890|B|IDLE|-100")) + r._handle_incoming(_msg(text="START|name:foo")) + r._handle_incoming(_msg(text="STOP")) + r._handle_incoming(_msg(text="1234567890|B|next:915.1/500/7/5/22/40")) + assert len(received) == 4 + + +def test_packet_loss_partial_with_seed_is_deterministic(monkeypatch): + monkeypatch.setenv("SIM_PACKET_LOSS", "0.5") + monkeypatch.setenv("SIM_SEED", "fixed-seed") + + def run_once(): + r = UDPRadio(station="A", peers=["B"]) + _tune(r) + out = [] + r.on_message(lambda p: out.append(p.packet_id)) + for i in range(50): + r._handle_incoming(_msg(packet_id=i, text=f"100.0,1.0,{i}|x")) + return out + + a = run_once() + b = run_once() + assert a == b # deterministic + assert 0 < len(a) < 50 # some dropped, some kept + + +def test_packet_loss_zero_keeps_all_test_packets(monkeypatch): + monkeypatch.setenv("SIM_PACKET_LOSS", "0") + r = UDPRadio(station="A", peers=["B"]) + _tune(r) + received = [] + r.on_message(lambda p: received.append(p)) + for i in range(10): + r._handle_incoming(_msg(packet_id=i, text=f"100.0,1.0,{i}|y")) + assert len(received) == 10