import secrets from typing import Optional from fastapi import APIRouter, HTTPException, Depends, Query from app.models import CommandPayload from app.internal import firestore as fstore from app.internal.mqtt_handler import mqtt_handler from app.internal.auth import require_admin_token, require_service_key_or_admin from app.routers.tokens import assign_token, release_token router = APIRouter(prefix="/nodes", tags=["nodes"]) @router.get("") async def list_nodes(): return await fstore.collection_list("nodes") @router.get("/{node_id}") async def get_node(node_id: str): node = await fstore.doc_get("nodes", node_id) if not node: raise HTTPException(404, f"Node '{node_id}' not found.") return node @router.post("/{node_id}/approve") async def approve_node(node_id: str, _: dict = Depends(require_admin_token)): node = await fstore.doc_get("nodes", node_id) if not node: raise HTTPException(404, f"Node '{node_id}' not found.") api_key = secrets.token_hex(32) await fstore.doc_set("node_keys", node_id, {"node_id": node_id, "api_key": api_key}, merge=False) await fstore.doc_update("nodes", node_id, {"approval_status": "approved"}) mqtt_handler.publish_node_key(node_id, api_key) return {"ok": True} @router.delete("/{node_id}", status_code=204) async def delete_node(node_id: str, _: dict = Depends(require_admin_token)): node = await fstore.doc_get("nodes", node_id) if not node: raise HTTPException(404, f"Node '{node_id}' not found.") await fstore.doc_delete("node_keys", node_id) await fstore.doc_delete("nodes", node_id) @router.post("/{node_id}/reject") async def reject_node(node_id: str, _: dict = Depends(require_admin_token)): node = await fstore.doc_get("nodes", node_id) if not node: raise HTTPException(404, f"Node '{node_id}' not found.") await fstore.doc_update("nodes", node_id, {"approval_status": "rejected"}) return {"ok": True} @router.post("/{node_id}/command") async def send_command( node_id: str, cmd: CommandPayload, _: dict = Depends(require_service_key_or_admin), ): node = await fstore.doc_get("nodes", node_id) if not node: raise HTTPException(404, f"Node '{node_id}' not found.") payload = cmd.model_dump(exclude_none=True) if cmd.action == "discord_join": # Resolve system doc once — used for preferred token and presence name. system_doc = None system_id = node.get("assigned_system_id") if system_id: system_doc = await fstore.doc_get_cached("systems", system_id) # Explicit preferred_token_id in the request beats the system-level preference. preferred = payload.pop("preferred_token_id", None) or (system_doc or {}).get("preferred_token_id") token = await assign_token(node_id, preferred_token_id=preferred) if not token: raise HTTPException(503, "No Discord bot tokens available in the pool.") payload["token"] = token # Pass system name so the bot can set its Discord presence on join. system_name = (system_doc or {}).get("name") if system_name: payload["system_name"] = system_name elif cmd.action == "discord_leave": await release_token(node_id) if not mqtt_handler.send_command(node_id, payload): raise HTTPException(503, "MQTT broker unavailable — command not delivered.") return {"ok": True} @router.post("/{node_id}/reissue-key") async def reissue_node_key(node_id: str, _: dict = Depends(require_admin_token)): """Generate a new API key for the node and push it via MQTT (retained). Use this to rotate a key or recover a node whose key was lost.""" node = await fstore.doc_get("nodes", node_id) if not node: raise HTTPException(404, f"Node '{node_id}' not found.") api_key = secrets.token_hex(32) await fstore.doc_set("node_keys", node_id, {"node_id": node_id, "api_key": api_key}, merge=False) mqtt_handler.publish_node_key(node_id, api_key) return {"ok": True} @router.post("/{node_id}/config/{system_id}") async def assign_system( node_id: str, system_id: str, hardware_preset: str = Query("rtl-sdr-v3"), ppm_override: Optional[float] = Query(None), _: dict = Depends(require_service_key_or_admin), ): """ Assign a system to a node. Fetches the system config from Firestore and pushes it to the node via MQTT, then marks the node as configured. """ node = await fstore.doc_get("nodes", node_id) if not node: raise HTTPException(404, f"Node '{node_id}' not found.") system = await fstore.doc_get("systems", system_id) if not system: raise HTTPException(404, f"System '{system_id}' not found.") # Include hardware preset in the push so the edge node applies it when # generating the OP25 config. Strip it from the system doc first so it # doesn't collide with SystemConfig field validation on the node side. push_payload = {**system, "hardware_preset": hardware_preset} if ppm_override is not None: push_payload["ppm_override"] = ppm_override mqtt_handler.push_config(node_id, push_payload) # Update Firestore node_updates = { "assigned_system_id": system_id, "configured": True, "hardware_preset": hardware_preset, } if ppm_override is not None: node_updates["ppm_override"] = ppm_override await fstore.doc_update("nodes", node_id, node_updates) return {"ok": True}