This commit is contained in:
Logan Cusano
2025-12-28 02:37:39 -05:00
commit 2e491f8111
6 changed files with 231 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.env
*.log
*.db
*.conf

28
Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
# Use an official Python runtime as a parent image
FROM python:3.13-slim
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
# Set work directory
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy project
COPY ./app .
# Expose FastAPI port
EXPOSE 8000
# Run uvicorn
CMD ["uvicorn", "c2_main:app", "--host", "0.0.0.0", "--port", "8000"]

110
app/c2_main.py Normal file
View File

@@ -0,0 +1,110 @@
import json
import os
import asyncio
from fastapi import FastAPI, HTTPException
import paho.mqtt.client as mqtt
from datetime import datetime
from motor.motor_asyncio import AsyncIOMotorClient
from pydantic import BaseModel
from typing import Any, Dict
app = FastAPI(title="Radio C2 Brain")
# Configuration
MQTT_BROKER = os.getenv("MQTT_BROKER", "mqtt-broker")
MONGO_URI = os.getenv("MONGO_URI", "mongodb://admin:securepassword@db:27017/radio_c2?authSource=admin")
C2_ID = "central-brain-01"
# Database Init
mongo_client = AsyncIOMotorClient(MONGO_URI)
db = mongo_client.get_database()
nodes_col = db.get_collection("nodes")
# Local cache for quick lookups
ACTIVE_NODES_CACHE = {}
MAIN_LOOP = None
# Pydantic Models
class NodeCommand(BaseModel):
command: str
payload: Dict[str, Any]
def on_connect(client, userdata, flags, rc):
print(f"Brain connected to MQTT Broker with result code {rc}")
client.subscribe("nodes/+/checkin")
client.subscribe("nodes/+/status")
def on_message(client, userdata, msg):
if MAIN_LOOP:
asyncio.run_coroutine_threadsafe(handle_message(msg), MAIN_LOOP)
async def handle_message(msg):
topic_parts = msg.topic.split('/')
if len(topic_parts) < 3: return
node_id = topic_parts[1]
event_type = topic_parts[2]
try:
payload = json.loads(msg.payload.decode())
timestamp = datetime.utcnow()
if event_type == "checkin":
data = {
"node_id": node_id,
"last_seen": timestamp,
"status": payload.get("status", "online"),
"active_system": payload.get("active_system"),
"available_systems": payload.get("available_systems", []),
"config": payload,
"radio_state": "active" if payload.get("is_listening") else "idle"
}
await nodes_col.update_one({"node_id": node_id}, {"$set": data}, upsert=True)
ACTIVE_NODES_CACHE[node_id] = data
elif event_type == "status":
status = payload.get("status")
await nodes_col.update_one(
{"node_id": node_id},
{"$set": {"status": status, "last_seen": timestamp}}
)
if node_id in ACTIVE_NODES_CACHE:
ACTIVE_NODES_CACHE[node_id]["status"] = status
except Exception as e:
print(f"Error processing MQTT: {e}")
# MQTT Setup
mqtt_client = mqtt.Client(client_id=C2_ID)
mqtt_client.on_connect = on_connect
mqtt_client.on_message = on_message
@app.on_event("startup")
async def startup_event():
global MAIN_LOOP
MAIN_LOOP = asyncio.get_running_loop()
mqtt_client.connect_async(MQTT_BROKER, 1883, 60)
mqtt_client.loop_start()
@app.get("/nodes")
async def get_nodes():
nodes = await nodes_col.find().to_list(length=100)
# Convert ObjectId to string for JSON serialization
for n in nodes: n["_id"] = str(n["_id"])
return nodes
@app.post("/nodes/{node_id}/command")
async def send_command_to_node(node_id: str, command: NodeCommand):
if node_id not in ACTIVE_NODES_CACHE:
raise HTTPException(status_code=404, detail="Node not found or is offline")
topic = f"nodes/{node_id}/commands"
message_payload = {
"command": command.command,
**command.payload
}
mqtt_client.publish(topic, json.dumps(message_payload), qos=1)
return {"status": "command_sent", "node_id": node_id, "command": command.command}

52
docker-compose.yml Normal file
View File

@@ -0,0 +1,52 @@
services:
# The Brain (FastAPI)
c2-brain:
build: .
container_name: radio-c2-brain
restart: always
environment:
- MONGO_URI=mongodb://${MONGO_INITDB_ROOT_USERNAME}:${MONGO_INITDB_ROOT_PASSWORD}@db:27017/radio_c2?authSource=admin
- MQTT_BROKER=mqtt-broker
- PORT=8000
depends_on:
- db
- mqtt-broker
ports:
- "8000:8000"
networks:
- radio-shared-net
# The Memory (MongoDB)
db:
image: mongo:latest
container_name: radio-db
restart: always
environment:
- MONGO_INITDB_ROOT_USERNAME=${MONGO_INITDB_ROOT_USERNAME}
- MONGO_INITDB_ROOT_PASSWORD=${MONGO_INITDB_ROOT_PASSWORD}
volumes:
- mongo_data:/data/db
networks:
- radio-shared-net
# The Post Office (MQTT Broker)
mqtt-broker:
image: eclipse-mosquitto:latest
container_name: radio-mqtt
restart: always
ports:
- "1883:1883"
- "9001:9001"
volumes:
- ./mosquitto/config/:/mosquitto/config/
- ./mosquitto/data/:/mosquitto/data/
- ./mosquitto/log/:/mosquitto/log/
networks:
- radio-shared-net
networks:
radio-shared-net:
external: true
volumes:
mongo_data:

31
readme.md Normal file
View File

@@ -0,0 +1,31 @@
radio-c2-core
The "Brain" of the project. This central server manages the global state of the radio network, routes commands to nodes, and persists historical data.
1. Components
MQTT Broker (Mosquitto): The post office. Handles all traffic between nodes and the C2.
FastAPI Core: The logic engine. Listens to MQTT events and provides the REST API for the future Web Portal.
PostgreSQL: The memory. Stores node registry, status history, and metadata logs.
2. MQTT Topic Structure
nodes/{node_id}/checkin: Nodes publish their full config here on boot.
nodes/{node_id}/status: LWT (Last Will) and periodic heartbeat.
nodes/{node_id}/command: C2 publishes tuning commands here for nodes to execute.
nodes/{node_id}/metadata: High-resolution talkgroup and signal data.
3. The Handshake
Node connects to MQTT.
Node publishes online to status and full JSON to checkin.
C2 receives checkin, updates the database, and marks the node as "Manageable".
If Node vanishes, Broker publishes offline to status via LWT.

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
fastapi
uvicorn[standard]
paho-mqtt
motor
pydantic
pydantic-settings