init
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.env
|
||||||
|
*.log
|
||||||
|
*.db
|
||||||
|
*.conf
|
||||||
28
Dockerfile
Normal file
28
Dockerfile
Normal 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
110
app/c2_main.py
Normal 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
52
docker-compose.yml
Normal 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
31
readme.md
Normal 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
6
requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
paho-mqtt
|
||||||
|
motor
|
||||||
|
pydantic
|
||||||
|
pydantic-settings
|
||||||
Reference in New Issue
Block a user