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) <noreply@anthropic.com>
This commit is contained in:
parent
7424416b58
commit
0f478bf720
31 changed files with 1708 additions and 0 deletions
20
.forgejo/workflows/test.yml
Normal file
20
.forgejo/workflows/test.yml
Normal file
|
|
@ -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
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -1 +1,8 @@
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
sim/logs/
|
||||||
|
*.tsv
|
||||||
|
!reference/*.tsv
|
||||||
|
|
|
||||||
93
README.md
Normal file
93
README.md
Normal file
|
|
@ -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 # `<cmd>[:<station>]|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).
|
||||||
54
docker-compose.yml
Normal file
54
docker-compose.yml
Normal file
|
|
@ -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
|
||||||
9
docker/control.Dockerfile
Normal file
9
docker/control.Dockerfile
Normal file
|
|
@ -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"]
|
||||||
9
docker/station.Dockerfile
Normal file
9
docker/station.Dockerfile
Normal file
|
|
@ -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"]
|
||||||
0
modjam/__init__.py
Normal file
0
modjam/__init__.py
Normal file
44
modjam/cli.py
Normal file
44
modjam/cli.py
Normal file
|
|
@ -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
|
||||||
51
modjam/config.py
Normal file
51
modjam/config.py
Normal file
|
|
@ -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", "."),
|
||||||
|
)
|
||||||
108
modjam/control.py
Normal file
108
modjam/control.py
Normal file
|
|
@ -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=<n>] [f=<f1,f2>] [bw=<...>] [sf=<...>] [cr=<...>]
|
||||||
|
[pow=<...>] [size=<...>] [stations=A,B,C]
|
||||||
|
[duration=<s>] [padding=<s>] [spacing=<s>] [at=<min>]
|
||||||
|
stop multicast STOP to all stations
|
||||||
|
results <station> <name> (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
|
||||||
99
modjam/cuesheet.py
Normal file
99
modjam/cuesheet.py
Normal file
|
|
@ -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]),
|
||||||
|
)
|
||||||
33
modjam/log.py
Normal file
33
modjam/log.py
Normal file
|
|
@ -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: <ts> queued <packet_id> <freq>,<bw>,<sf>,<cr>,<pow>
|
||||||
|
sent: <ts> sent <packet_id> <duration_ms> <text>
|
||||||
|
received: <ts> received <packet_id> <text>
|
||||||
|
"""
|
||||||
|
|
||||||
|
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
|
||||||
57
modjam/protocol.py
Normal file
57
modjam/protocol.py
Normal file
|
|
@ -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}"
|
||||||
0
modjam/radio/__init__.py
Normal file
0
modjam/radio/__init__.py
Normal file
45
modjam/radio/base.py
Normal file
45
modjam/radio/base.py
Normal file
|
|
@ -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: ...
|
||||||
15
modjam/radio/factory.py
Normal file
15
modjam/radio/factory.py
Normal file
|
|
@ -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)
|
||||||
100
modjam/radio/meshcore.py
Normal file
100
modjam/radio/meshcore.py
Normal file
|
|
@ -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
|
||||||
159
modjam/radio/udp.py
Normal file
159
modjam/radio/udp.py
Normal file
|
|
@ -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: `<float_ts>,<float_t>,<int_n>|<padding>`.
|
||||||
|
# Heartbeat/next/done payloads start with `<int_ts>|<station>|...` — 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
|
||||||
255
modjam/station.py
Normal file
255
modjam/station.py
Normal file
|
|
@ -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)
|
||||||
46
notes.md
Normal file
46
notes.md
Normal file
|
|
@ -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:<station>|<testname>` 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
|
||||||
|
|
||||||
|
<cmd>[:<station>]|<attr1:val1,val2,…>|<attr2:val1,val2,…>…
|
||||||
|
|
||||||
|
Listens for START command from control node
|
||||||
|
`START|name:<testname>|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:<station>\tname:<testname>`
|
||||||
|
RESULTS:A|name
|
||||||
|
sends summary of each trial
|
||||||
|
<station>|<name>|<freq>|<bw>|<sf>|<cr>|<pow>|<A>|<B>|<C>…
|
||||||
|
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
|
||||||
27
pyproject.toml
Normal file
27
pyproject.toml
Normal file
|
|
@ -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"]
|
||||||
14
sim/control.json
Normal file
14
sim/control.json
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
21
sim/station-a.json
Normal file
21
sim/station-a.json
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
21
sim/station-b.json
Normal file
21
sim/station-b.json
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
21
sim/station-c.json
Normal file
21
sim/station-c.json
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
63
tests/test_config.py
Normal file
63
tests/test_config.py
Normal file
|
|
@ -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))
|
||||||
86
tests/test_cuesheet.py
Normal file
86
tests/test_cuesheet.py
Normal file
|
|
@ -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
|
||||||
37
tests/test_log.py
Normal file
37
tests/test_log.py
Normal file
|
|
@ -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()
|
||||||
69
tests/test_protocol.py
Normal file
69
tests/test_protocol.py
Normal file
|
|
@ -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"
|
||||||
145
tests/test_udp_radio.py
Normal file
145
tests/test_udp_radio.py
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Reference in a new issue