Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 174 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,28 @@
# tracker-host

Multi-instance tracker manager for [retina-tracker](https://github.com/offworldlabs/retina-tracker). Fetches detection data from multiple radar endpoints and manages tracker subprocesses.
Multi-instance tracker manager for [retina-tracker](https://github.com/offworldlabs/retina-tracker). Supports two operating modes: **manager mode** (polls remote detection endpoints centrally) and **server mode** (receives track events pushed from remote nodes).

## Overview

tracker-host is a Python service that manages multiple retina-tracker instances concurrently. For each configured detection endpoint, it:
tracker-host is a Python service that manages multiple retina-tracker instances. It operates in one of two modes:

### Manager Mode (default)

The server polls detection endpoints on remote radar nodes and manages tracker subprocesses locally. For each configured detection endpoint, it:

1. Fetches detection data from the endpoint at ~2 Hz
2. Feeds data to a dedicated retina-tracker subprocess via TCP
3. Reads tracker output (streaming JSONL) and saves to daily files
4. Optionally forwards track events to a configurable API in real-time

### Server Mode (`--server`)

Remote radar nodes run retina-tracker locally and POST track events to the central server via HTTP. The server receives tracks, writes them to JSONL files, and optionally runs geolocator instances to convert tracks to geographic solutions. Nodes self-register by POSTing their config -- no pre-configuration is needed on the server side.

## Architecture

### Manager Mode

```
┌─────────────────────────────────────────────────────────────┐
│ tracker-host │
Expand All @@ -38,6 +48,30 @@ tracker-host is a Python service that manages multiple retina-tracker instances
└─────────────────────────────────────────────────────────────┘
```

### Server Mode

```
Node (Raspberry Pi) Central Server
┌───────────────────────────┐ ┌──────────────────────────────────┐
│ │ │ tracker-host --server │
│ blah2 (radar) │ │ │
│ │ │ │ ┌────────────┐ │
│ ▼ TCP │ │ │ HTTP Server │ :8080 │
│ retina-tracker │ │ │ (aiohttp) │ │
│ │ │ HTTP POST │ └─────┬──────┘ │
│ ▼ stdout JSONL │ ──────────── │ │ │
│ node_agent │ /api/node/ │ ▼ │
│ (POST tracks + config) │ {name}/... │ ┌──────────────┐ │
│ │ │ │ NodeRegistry │ │
└───────────────────────────┘ │ └──┬───────┬───┘ │
│ │ │ │
│ ▼ ▼ │
│ Output GeolocatorInstance │
│ Handler (optional lat/lon/alt) │
│ (JSONL) │
└──────────────────────────────────┘
```

## Installation

```bash
Expand All @@ -53,6 +87,8 @@ pip install -r requirements.txt

## Usage

### Manager Mode (default)

```bash
# Run with default config
python -m tracker_host
Expand All @@ -64,6 +100,132 @@ python -m tracker_host -c /path/to/config.yaml
python -m tracker_host -v
```

### Server Mode

Start the server to receive track events from remote nodes:

```bash
# Run in server mode (listens on 0.0.0.0:8080 by default)
python -m tracker_host --server

# Server mode with verbose logging
python -m tracker_host --server -v

# Server mode with custom config
python -m tracker_host --server -c /path/to/config.yaml
```

Server mode configuration is specified in the `server` section of `config.yaml`:

```yaml
server:
host: "0.0.0.0"
port: 8080
geolocator_enabled: false
geolocator_tcp_port_base: 31000
```

| Setting | Default | Description |
|---------|---------|-------------|
| `host` | `0.0.0.0` | Bind address for the HTTP server |
| `port` | `8080` | Port for the HTTP server |
| `geolocator_enabled` | `false` | Start geolocator instances for nodes that provide location data |
| `geolocator_tcp_port_base` | `31000` | Starting port number for geolocator TCP connections (auto-increments per node) |

#### API Endpoints

| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/node/{name}/config` | Register a node and push its radar config (JSON body) |
| `POST` | `/api/node/{name}/tracks` | Push track events from a node (JSONL body, one event per line) |
| `GET` | `/api/nodes` | List all registered nodes with status info |

The `{name}` parameter must match `[a-zA-Z0-9_-]+`.

Example: register a node and push tracks:

```bash
# Push radar config for a node
curl -X POST http://server:8080/api/node/radar3/config \
-H "Content-Type: application/json" \
-d '{"location": {"rx_latitude": -36.8, "rx_longitude": 174.7, "rx_altitude": 50}}'

# Push a track event
curl -X POST http://server:8080/api/node/radar3/tracks \
-H "Content-Type: application/x-ndjson" \
-d '{"track_id":"260120-000000","timestamp":1768932173636,"length":3,"detections":[...]}'

# List registered nodes
curl http://server:8080/api/nodes
```

### Node Agent

The `node_agent/` package runs on Raspberry Pi 5 nodes (or any machine with blah2 and retina-tracker). It is a **stdlib-only** Python package with no external dependencies, designed for easy deployment to resource-constrained devices.

The node agent:

1. Fetches the radar config from the local blah2 instance and pushes it to the central server
2. Starts retina-tracker as a subprocess, listening for detections on a TCP port
3. Reads track events from retina-tracker stdout (streaming JSONL) and POSTs each event to the central server

#### Deploying to a Raspberry Pi

Copy the `node_agent/` directory and ensure `retina-tracker` is available on the node:

```bash
# On the Pi (assuming retina-tracker is at ../retina-tracker)
python3 -m node_agent \
--node-name radar3 \
--server-url http://central-server:8080 \
--blah2-url http://localhost:3000 \
--tracker-path ../retina-tracker \
--tcp-port 3012
```

| Flag | Default | Description |
|------|---------|-------------|
| `--node-name` | (required) | Unique name for this node (used in API paths and output filenames) |
| `--server-url` | (required) | URL of the central tracker-host server |
| `--blah2-url` | `http://localhost:3000` | URL of the local blah2 radar API |
| `--tracker-path` | `../retina-tracker` | Path to the retina-tracker directory |
| `--tcp-port` | `3012` | TCP port for the retina-tracker subprocess |
| `--tracker-config` | (none) | Optional path to a retina-tracker config.yaml |

The node agent uses only the Python standard library (`urllib`, `subprocess`, `socket`, `json`), so no `pip install` step is needed on the Pi.

### Detection Relay (staging/testing)

The detection relay (`node_agent/relay.py`) is for staging and testing when blah2 is not running locally on the node. It polls a remote HTTP detection endpoint and forwards the data to retina-tracker over TCP, simulating the blah2-to-retina-tracker connection.

```bash
# Start retina-tracker first (in another terminal), then run the relay:
python3 -m node_agent.relay \
--detection-url https://radar3.retnode.com/api/detection \
--tcp-host 127.0.0.1 \
--tcp-port 3012 \
--interval 0.5
```

| Flag | Default | Description |
|------|---------|-------------|
| `--detection-url` | `https://radar3.retnode.com/api/detection` | HTTP detection endpoint to poll |
| `--tcp-host` | `127.0.0.1` | retina-tracker TCP host |
| `--tcp-port` | `3012` | retina-tracker TCP port |
| `--interval` | `0.5` | Poll interval in seconds |

The relay retries the TCP connection up to 30 times (1 second apart), so you can start it before retina-tracker is ready.

### Architecture Decision: Dynamic Node Registration

Nodes self-register with the central server by POSTing their config to `/api/node/{name}/config`. This means:

- **No pre-configuration needed on the server.** The server does not need a list of nodes in its config file. Nodes appear automatically when they connect.
- **Adding or removing nodes requires no server restart.** Deploy a new Pi, point it at the server, and it registers itself.
- **Each node controls its own identity** via the `--node-name` flag.

If a node POSTs config again, the existing registration is updated rather than duplicated.

## Configuration

Edit `config.yaml` to configure tracker instances:
Expand Down Expand Up @@ -141,12 +303,21 @@ python -m tracker_host.plotter output/radar3_2026-01-20.jsonl --min-length 10

## Features

### Manager Mode
- **Multi-instance management**: Run multiple tracker instances from a single process
- **Resilience**: Exponential backoff on failures, auto-restart after extended outages
- **Port collision detection**: Checks port availability before starting trackers
- **Daily output files**: Automatic file rotation at midnight
- **Real-time status**: Periodic console output showing track counts and lengths
- **API forwarding**: Optional real-time forwarding of track events to external APIs

### Server Mode
- **Dynamic node registration**: Nodes self-register by POSTing config; no server-side pre-configuration
- **HTTP track ingestion**: Receives JSONL track events via POST from remote nodes
- **Automatic geolocator**: Optionally starts geolocator instances for nodes with location data
- **Node listing API**: Query registered nodes and their status via REST

### Shared
- **Daily output files**: Automatic file rotation at midnight
- **Graceful shutdown**: Clean termination on SIGINT/SIGTERM

## Requirements
Expand Down
7 changes: 7 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# tracker-host configuration

# Server mode settings (used with --server flag)
server:
host: "0.0.0.0"
port: 8080
geolocator_enabled: false
geolocator_tcp_port_base: 31000

# Global settings
output_dir: "./output"
poll_interval_sec: 0.5
Expand Down
3 changes: 3 additions & 0 deletions node_agent/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Node agent: runs retina-tracker locally and POSTs track events to central server."""

from .agent import fetch_blah2_config, push_config, post_track_event
108 changes: 108 additions & 0 deletions node_agent/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""CLI entry point: python -m node_agent"""

import argparse
import signal
import subprocess
import sys
import time

from .agent import fetch_blah2_config, post_track_event, push_config


def main() -> None:
parser = argparse.ArgumentParser(
description="Node agent: run retina-tracker and POST tracks to central server"
)
parser.add_argument("--node-name", required=True, help="Unique name for this node")
parser.add_argument("--server-url", required=True, help="Central server URL")
parser.add_argument(
"--blah2-url",
default="http://localhost:3000",
help="Local blah2 API URL (default: http://localhost:3000)",
)
parser.add_argument(
"--tracker-path",
default="../retina-tracker",
help="Path to retina-tracker directory (default: ../retina-tracker)",
)
parser.add_argument(
"--tcp-port",
type=int,
default=3012,
help="TCP port for retina-tracker (default: 3012)",
)
parser.add_argument(
"--tracker-config",
help="Optional path to retina-tracker config.yaml",
)

args = parser.parse_args()

# Step 1: Fetch config from blah2 and push to server
print(f"Fetching radar config from {args.blah2_url}...")
try:
config = fetch_blah2_config(args.blah2_url)
print(f"Pushing config to {args.server_url}...")
push_config(args.server_url, args.node_name, config)
print("Config registered with server.")
except Exception as e:
print(f"Warning: Config push failed: {e}", file=sys.stderr)
print("Continuing without config push...", file=sys.stderr)

# Step 2: Start retina-tracker subprocess
cmd = [
sys.executable,
"-m",
"tracker.track_detections",
"--tcp",
"--tcp-host",
"0.0.0.0",
"--tcp-port",
str(args.tcp_port),
"-s",
"-", # Stream events to stdout
]

if args.tracker_config:
cmd.extend(["-c", args.tracker_config])

print(f"Starting retina-tracker on TCP port {args.tcp_port}...")
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=sys.stderr,
cwd=args.tracker_path,
)

# Handle shutdown gracefully
def shutdown(signum, frame):
print("\nShutting down...")
proc.terminate()
proc.wait(timeout=5)
sys.exit(0)

signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)

print(f"Reading track events, POSTing to {args.server_url}...")
event_count = 0

# Step 3: Read stdout and POST events
try:
for line in proc.stdout:
decoded = line.decode().strip()
if decoded:
post_track_event(args.server_url, args.node_name, decoded)
event_count += 1
if event_count % 50 == 0:
print(f" Posted {event_count} track events")
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
finally:
proc.terminate()
proc.wait(timeout=5)
print(f"Done. Posted {event_count} track events total.")


if __name__ == "__main__":
main()
43 changes: 43 additions & 0 deletions node_agent/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Core node agent logic."""

import json
import sys
import urllib.error
import urllib.request

_HEADERS = {"User-Agent": "retina-node-agent/1.0"}


def fetch_blah2_config(blah2_url: str) -> dict:
"""Fetch radar config from local blah2 API."""
url = f"{blah2_url.rstrip('/')}/api/config"
req = urllib.request.Request(url, headers={**_HEADERS, "Accept": "application/json"})
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read())


def push_config(server_url: str, node_name: str, config_data: dict) -> None:
"""POST radar config to the central server."""
url = f"{server_url.rstrip('/')}/api/node/{node_name}/config"
data = json.dumps(config_data).encode()
req = urllib.request.Request(
url, data=data, headers={**_HEADERS, "Content-Type": "application/json"}
)
with urllib.request.urlopen(req, timeout=10) as resp:
resp.read()


def post_track_event(server_url: str, node_name: str, event_line: str) -> None:
"""POST a single track event to the central server."""
url = f"{server_url.rstrip('/')}/api/node/{node_name}/tracks"
data = event_line.encode()
req = urllib.request.Request(
url,
data=data,
headers={**_HEADERS, "Content-Type": "application/x-ndjson"},
)
try:
with urllib.request.urlopen(req, timeout=5) as resp:
resp.read()
except (urllib.error.URLError, OSError) as e:
print(f"POST failed: {e}", file=sys.stderr)
Loading