Initial commit — DRB client (edge node) stack
Includes edge-node (FastAPI/MQTT/Discord voice), op25-container (SDR decoder), and icecast (audio streaming).
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from app.config import settings
|
||||
from app.models import SystemConfig
|
||||
from app.internal.logger import logger
|
||||
from app.internal.mqtt_manager import mqtt_manager
|
||||
from app.internal import credentials
|
||||
from app.internal.metadata_watcher import metadata_watcher
|
||||
from app.internal.call_recorder import call_recorder
|
||||
from app.internal.discord_radio import radio_bot
|
||||
from app.internal.config_manager import (
|
||||
load_node_config,
|
||||
save_node_config,
|
||||
apply_system_config,
|
||||
)
|
||||
from app.routers import api, ui
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Event handlers wired up at startup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def on_call_start(data: dict):
|
||||
await mqtt_manager.publish_status("recording")
|
||||
await mqtt_manager.publish_metadata("call_start", data)
|
||||
await call_recorder.start_recording(data["call_id"])
|
||||
|
||||
|
||||
async def on_call_end(data: dict):
|
||||
file_path = await call_recorder.stop_recording()
|
||||
if file_path:
|
||||
audio_url = await call_recorder.upload_recording(file_path, data["call_id"])
|
||||
if audio_url:
|
||||
data["audio_url"] = audio_url
|
||||
await mqtt_manager.publish_metadata("call_end", data)
|
||||
await mqtt_manager.publish_status("online")
|
||||
|
||||
|
||||
async def on_command(payload: dict):
|
||||
action = payload.get("action")
|
||||
logger.info(f"Command received: {action}")
|
||||
|
||||
if action == "discord_join":
|
||||
token = payload.get("token")
|
||||
if not token:
|
||||
logger.error("discord_join command missing token — ignoring.")
|
||||
return
|
||||
await radio_bot.join(
|
||||
guild_id=int(payload["guild_id"]),
|
||||
channel_id=int(payload["channel_id"]),
|
||||
token=token,
|
||||
)
|
||||
elif action == "discord_leave":
|
||||
await radio_bot.leave()
|
||||
elif action == "op25_restart":
|
||||
from app.internal.op25_client import op25_client
|
||||
await op25_client.stop()
|
||||
await asyncio.sleep(2)
|
||||
await op25_client.start()
|
||||
else:
|
||||
logger.warning(f"Unknown command: {action}")
|
||||
|
||||
|
||||
async def on_api_key(payload: dict):
|
||||
key = payload.get("api_key")
|
||||
if key:
|
||||
credentials.save_api_key(key)
|
||||
logger.info("Node API key received and saved.")
|
||||
|
||||
|
||||
async def on_config_push(payload: dict):
|
||||
"""C2 pushes a system config — apply it and restart OP25 with the new settings."""
|
||||
try:
|
||||
config = SystemConfig(**payload)
|
||||
except Exception as e:
|
||||
logger.error(f"Invalid config push payload: {e}")
|
||||
return
|
||||
|
||||
node_cfg = load_node_config()
|
||||
node_cfg.assigned_system_id = config.system_id
|
||||
node_cfg.system_config = config
|
||||
node_cfg.configured = True
|
||||
save_node_config(node_cfg)
|
||||
apply_system_config(config)
|
||||
|
||||
# Restart OP25 so it picks up the new config
|
||||
from app.internal.op25_client import op25_client
|
||||
await op25_client.stop()
|
||||
await asyncio.sleep(2)
|
||||
await op25_client.start()
|
||||
|
||||
logger.info(f"Config push applied: {config.name}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info(f"Edge node starting — ID: {settings.node_id}")
|
||||
|
||||
# Load persisted credentials (API key provisioned by C2 after approval)
|
||||
credentials.load()
|
||||
|
||||
# Wire callbacks
|
||||
metadata_watcher.on_call_start = on_call_start
|
||||
metadata_watcher.on_call_end = on_call_end
|
||||
mqtt_manager.on_command = on_command
|
||||
mqtt_manager.on_config_push = on_config_push
|
||||
mqtt_manager.on_api_key = on_api_key
|
||||
|
||||
# Start services (radio_bot starts on-demand when a discord_join command arrives)
|
||||
await mqtt_manager.connect()
|
||||
await metadata_watcher.start()
|
||||
|
||||
# Report initial status and resume OP25 if node was already configured before this restart
|
||||
node_cfg = load_node_config()
|
||||
initial_status = "online" if node_cfg.configured else "unconfigured"
|
||||
await mqtt_manager.publish_status(initial_status)
|
||||
|
||||
if node_cfg.configured:
|
||||
from app.internal.op25_client import op25_client
|
||||
logger.info("Node is configured — starting OP25.")
|
||||
await op25_client.start()
|
||||
|
||||
heartbeat_task = asyncio.create_task(mqtt_manager.heartbeat_loop())
|
||||
|
||||
yield # --- app running ---
|
||||
|
||||
logger.info("Edge node shutting down.")
|
||||
heartbeat_task.cancel()
|
||||
await metadata_watcher.stop()
|
||||
await radio_bot.stop()
|
||||
await mqtt_manager.disconnect()
|
||||
|
||||
|
||||
app = FastAPI(title=f"DRB Edge Node — {settings.node_id}", lifespan=lifespan)
|
||||
app.include_router(api.router)
|
||||
app.include_router(ui.router)
|
||||
Reference in New Issue
Block a user