Files
istrain 559caead36 istrain-public: from-scratch build of a passive RF train detector
Public community release. The complete working system — DSP + decoders (scripts/),
web dashboard + API (dashboard/), container stack (docker/), config templates (config/) —
plus APOCALYPSE-EDITION.md, the full build guide for a human or a coding agent. GPLv3
(PyEOT dependency). Scrubbed of all secrets, internal IPs, hostnames, and location detail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 23:19:16 -04:00

144 lines
6.0 KiB
YAML

# istrain — THE MILL stack (parallel stand-up started 2026-07-18; MILL-MIGRATION.md is the tracker).
# Deployed by mill/deploy.sh into the Dockge stack dir (stacks/istrain) — Dockge owns the lifecycle,
# the repo owns the truth. Web runs from day one on dual-pushed data (istrain-push-mill on the VM).
#
# airband + worker sit behind the "radio" profile until dongle day:
# docker compose --profile radio up -d ← run this when 1002 is plugged into the Mill
# (plain `up -d` starts web only; `--profile radio build` pre-builds the radio images now.)
#
# ONE CONF HOME: the capture dir mounts at /tmp/airband inside airband+worker, so the VM's canonical
# airband/istrain.conf and transcribe-worker defaults run VERBATIM — no Mill fork of the channel plan.
# Clips land in ./data/captures = web's CAPTURES_DIR, so the captures panel reads Mill-captured clips
# the same way it reads VM-pushed ones. (Unpruned local growth: a DATA.md question for dongle day.)
services:
web:
build: { context: ., dockerfile: web.Dockerfile }
image: istrain-web:mill
container_name: istrain-web
restart: unless-stopped
ports:
- "3456:3456"
volumes:
- ./app:/app:ro
- ./data:/data
environment:
- BIND=0.0.0.0
- PORT=3456
# NO PUSH_KEY (security pass 2026-07-24): the Mill AUTHORS its own data (push retired 07-18),
# so /api/push + /api/push-clip must stay DISABLED — an unset key makes both return 403. Setting
# a key here would re-open a public, key-in-repo data-overwrite path behind the Cloudflare name.
- EOT_LOG=/data/eot.jsonl
- BOT_LOG=/data/bot.jsonl
- MID_LOG=/data/midtrain.jsonl
- HEARD_LOG=/data/heard.jsonl
- LEVEL_LOG=/data/level.json
- SUPERVISOR_SNAPSHOT=/data/supervisor.json
- HITS_LOG=/data/hits.json
- CAPTURES_DIR=/data/captures
- TRANSCRIPTS_LOG=/data/transcripts.jsonl
# NO CTL_FORWARD (2026-07-18): the old target was the VM dash, whose buttons now control
# disabled units. Control is parked (403s gracefully; supervisor rows carry can_ctl=false via
# supervisor-probe.py) until a docker control relay exists — MILL-MIGRATION.md open item.
airband:
build: { context: ., dockerfile: airband.Dockerfile }
image: istrain-airband:mill
container_name: istrain-airband
restart: unless-stopped
profiles: ["radio"]
# whole-bus bind + cgroup rule (189 = usb) so the dongle survives replug/re-enumeration without
# a container restart; librtlsdr is built with DETACH_KERNEL_DRIVER=ON as the second line of
# defense behind the host dvb blacklist. rtl_airband exits nonzero with no dongle — the restart
# policy does the waiting (the 2026-07-18 boot-race lesson, baked in from day one).
volumes:
- /dev/bus/usb:/dev/bus/usb
- ./conf/istrain.conf:/etc/rtl_airband/istrain.conf:ro
- ./data/captures:/tmp/airband
device_cgroup_rules:
- "c 189:* rwm"
environment:
# the conf's localtime=true honors TZ; without it clip filenames stamp UTC (caught 2026-07-18,
# first Mill clips named _1454xx against 10:54 local — breaks eyeball-parity with VM-era names)
- TZ=America/New_York
worker:
build: { context: ., dockerfile: worker.Dockerfile }
image: istrain-worker:mill
container_name: istrain-worker
restart: unless-stopped
profiles: ["radio"]
volumes:
- ./app:/app:ro
- ./data:/data
- ./data/captures:/tmp/airband
environment:
# whisper is the istrain-whisper stack on this same box; host tailnet IP reaches it (:9000)
- MILL_ASR=http://MILL-IP:9000
- TRANSCRIPTS_LOG=/data/transcripts.jsonl
- TZ=America/New_York
command: ["python3", "/app/scripts/transcribe-worker.py"]
trim:
image: istrain-worker:mill
container_name: istrain-trim
restart: unless-stopped
volumes:
- ./data/captures:/tmp/airband
environment:
# same policy as the VM era: voice audio >24h pruned AFTER heard.jsonl ingest (the script
# aborts if the dash is unreachable — an unlogged clip can never be deleted); transcripts +
# detections are the durable record. Keeps ./data/captures bounded on flash.
- DASH_URL=http://web:3456
- CAP_KEEP_HOURS=24
- TZ=America/New_York
command: ["sh", "-c", "while :; do python3 /app/scripts/trim-captures.py; sleep 3600; done"]
depends_on: [web]
# ---- the 1001 set (profile radio1001) — start ONLY once 1001 is plugged into the Mill: ---------
# docker compose --profile radio --profile radio1001 up -d
# Separate profile from 1002's so a compose cycle can never point rtl_tcp at the wrong dongle
# while only one radio has moved. The chain writes under ~/istrain (hardcoded in iq_channelize),
# so ./data mounts at /root/istrain — the SAME files web serves; push-seeded history continues
# in place. ⚠ istrain-push-mill on the VM must be DISABLED before first start (its 60s full-file
# replace would fight the appends — the PUSH_SKIP lesson, now for every log).
scanhop:
build: { context: ., dockerfile: scanhop.Dockerfile }
image: istrain-scanhop:mill
container_name: istrain-scanhop
restart: unless-stopped
profiles: ["radio1001"]
volumes:
- /dev/bus/usb:/dev/bus/usb
- ./app:/app:ro
- ./data:/root/istrain
device_cgroup_rules:
- "c 189:* rwm"
environment:
- HOP_DWELL_452=28
- HOP_DWELL_457=28
- TZ=America/New_York
eot-decode:
image: istrain-worker:mill
container_name: istrain-eot-decode
restart: unless-stopped
profiles: ["radio1001"]
volumes:
- ./app:/app:ro
- ./data:/root/istrain
environment:
- TZ=America/New_York
command: ["python3", "/app/scripts/eot-decode.py"]
bot-recover:
image: istrain-worker:mill
container_name: istrain-bot-recover
restart: unless-stopped
profiles: ["radio1001"]
volumes:
- ./app:/app:ro
- ./data:/root/istrain
environment:
- TZ=America/New_York
command: ["python3", "/app/scripts/bot-recover.py"]