diff --git a/drb-c2-core/app/internal/mqtt_handler.py b/drb-c2-core/app/internal/mqtt_handler.py
index 5839385..881dfe9 100644
--- a/drb-c2-core/app/internal/mqtt_handler.py
+++ b/drb-c2-core/app/internal/mqtt_handler.py
@@ -94,6 +94,11 @@ class MQTTHandler:
"last_seen": now.isoformat(),
"assigned_system_id": None,
"approval_status": "pending",
+ "node_type": payload.get("node_type", "fixed"),
+ "enforce_override_timeout": payload.get("enforce_override_timeout", True),
+ "is_overridden": False,
+ "override_system_id": None,
+ "override_timeout_at": None,
}
await fstore.doc_set("nodes", node_id, doc, merge=False)
logger.info(f"New node registered: {node_id} — pending admin approval.")
@@ -111,6 +116,35 @@ class MQTTHandler:
elif existing.get("approval_status") == "approved":
# Approved but not yet configured — restore reachable status after reboot
updates["status"] = "unconfigured"
+
+ node_type = payload.get("node_type", existing.get("node_type", "fixed"))
+ enforce_timeout = payload.get("enforce_override_timeout", existing.get("enforce_override_timeout", True))
+ is_overridden = payload.get("is_overridden", False)
+ override_system_id = payload.get("override_system_id")
+
+ updates["node_type"] = node_type
+ updates["enforce_override_timeout"] = enforce_timeout
+
+ if node_type == "portable":
+ updates["is_overridden"] = False
+ updates["override_system_id"] = None
+ updates["override_timeout_at"] = None
+ else:
+ updates["is_overridden"] = is_overridden
+ updates["override_system_id"] = override_system_id
+
+ if is_overridden:
+ existing_timeout = existing.get("override_timeout_at")
+ existing_override_id = existing.get("override_system_id")
+ if enforce_timeout:
+ if not existing_timeout or existing_override_id != override_system_id:
+ from datetime import timedelta
+ updates["override_timeout_at"] = (now + timedelta(hours=24)).isoformat()
+ else:
+ updates["override_timeout_at"] = None
+ else:
+ updates["override_timeout_at"] = None
+
await fstore.doc_update("nodes", node_id, updates)
# NOTE: discord_connected in checkins is informational only — do NOT release the
diff --git a/drb-c2-core/app/internal/node_sweeper.py b/drb-c2-core/app/internal/node_sweeper.py
index 145c54b..330ece7 100644
--- a/drb-c2-core/app/internal/node_sweeper.py
+++ b/drb-c2-core/app/internal/node_sweeper.py
@@ -55,3 +55,35 @@ async def _sweep():
logger.info(f"Node {node_id} marked offline (last seen: {last_seen.isoformat()})")
from app.routers.tokens import release_token
await release_token(node_id)
+ continue
+
+ # Check for expired system overrides (only for fixed nodes with timeout enforced)
+ override_timeout_raw = node.get("override_timeout_at")
+ enforce_timeout = node.get("enforce_override_timeout", True)
+ node_type = node.get("node_type", "fixed")
+
+ if override_timeout_raw and enforce_timeout and node_type != "portable":
+ if isinstance(override_timeout_raw, str):
+ override_timeout = datetime.fromisoformat(override_timeout_raw)
+ else:
+ override_timeout = override_timeout_raw
+ if override_timeout.tzinfo is None:
+ override_timeout = override_timeout.replace(tzinfo=timezone.utc)
+
+ if datetime.now(timezone.utc) > override_timeout:
+ node_id = node.get("node_id")
+ assigned_system_id = node.get("assigned_system_id")
+ logger.info(f"Node {node_id} override has expired. Reverting to system {assigned_system_id}.")
+
+ # Push the original assigned config if it exists
+ if assigned_system_id:
+ system_doc = await fstore.doc_get("systems", assigned_system_id)
+ if system_doc:
+ from app.internal.mqtt_handler import mqtt_handler
+ mqtt_handler.push_config(node_id, system_doc)
+
+ await fstore.doc_update("nodes", node_id, {
+ "is_overridden": False,
+ "override_system_id": None,
+ "override_timeout_at": None,
+ })
diff --git a/drb-c2-core/app/models.py b/drb-c2-core/app/models.py
index acbbf89..e237008 100644
--- a/drb-c2-core/app/models.py
+++ b/drb-c2-core/app/models.py
@@ -16,6 +16,11 @@ class NodeRecord(BaseModel):
configured: bool = False
last_seen: Optional[datetime] = None
assigned_system_id: Optional[str] = None
+ node_type: str = "fixed" # fixed or portable
+ enforce_override_timeout: bool = True
+ is_overridden: bool = False
+ override_system_id: Optional[str] = None
+ override_timeout_at: Optional[datetime] = None
class CommandPayload(BaseModel):
diff --git a/drb-c2-core/app/routers/nodes.py b/drb-c2-core/app/routers/nodes.py
index a4936a7..99422d3 100644
--- a/drb-c2-core/app/routers/nodes.py
+++ b/drb-c2-core/app/routers/nodes.py
@@ -1,6 +1,7 @@
import secrets
from typing import Optional
from fastapi import APIRouter, HTTPException, Depends, Query
+from pydantic import BaseModel
from app.models import CommandPayload
from app.internal import firestore as fstore
from app.internal.mqtt_handler import mqtt_handler
@@ -126,10 +127,13 @@ async def assign_system(
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}
+ # Include hardware preset, node type, and enforce timeout in the push
+ push_payload = {
+ **system,
+ "hardware_preset": hardware_preset,
+ "node_type": node.get("node_type", "fixed"),
+ "enforce_override_timeout": node.get("enforce_override_timeout", True),
+ }
if ppm_override is not None:
push_payload["ppm_override"] = ppm_override
mqtt_handler.push_config(node_id, push_payload)
@@ -145,3 +149,89 @@ async def assign_system(
await fstore.doc_update("nodes", node_id, node_updates)
return {"ok": True}
+
+
+class NodeUpdateBody(BaseModel):
+ node_type: Optional[str] = None
+ enforce_override_timeout: Optional[bool] = None
+
+
+@router.patch("/{node_id}")
+async def update_node(
+ node_id: str,
+ body: NodeUpdateBody,
+ _: 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.")
+
+ updates = body.model_dump(exclude_unset=True)
+ if not updates:
+ return {"ok": True}
+
+ await fstore.doc_update("nodes", node_id, updates)
+
+ # Re-push config to apply new node settings locally
+ updated_node = await fstore.doc_get("nodes", node_id)
+ assigned_system_id = updated_node.get("assigned_system_id")
+ if assigned_system_id:
+ system = await fstore.doc_get("systems", assigned_system_id)
+ if system:
+ push_payload = {
+ **system,
+ "hardware_preset": updated_node.get("hardware_preset", "rtl-sdr-v3"),
+ "node_type": updated_node.get("node_type", "fixed"),
+ "enforce_override_timeout": updated_node.get("enforce_override_timeout", True),
+ }
+ if updated_node.get("ppm_override") is not None:
+ push_payload["ppm_override"] = updated_node["ppm_override"]
+ mqtt_handler.push_config(node_id, push_payload)
+
+ return {"ok": True}
+
+
+class AckOverrideBody(BaseModel):
+ timeout_minutes: int = 1440
+
+
+@router.post("/{node_id}/override/ack")
+async def ack_override(
+ node_id: str,
+ body: AckOverrideBody,
+ _: 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.")
+
+ from datetime import datetime, timezone, timedelta
+ new_timeout = datetime.now(timezone.utc) + timedelta(minutes=body.timeout_minutes)
+
+ await fstore.doc_update("nodes", node_id, {
+ "override_timeout_at": new_timeout.isoformat()
+ })
+ return {"ok": True, "override_timeout_at": new_timeout.isoformat()}
+
+
+@router.post("/{node_id}/override/reset")
+async def reset_override(
+ node_id: str,
+ _: 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.")
+
+ assigned_system_id = node.get("assigned_system_id")
+ if assigned_system_id:
+ system = await fstore.doc_get("systems", assigned_system_id)
+ if system:
+ mqtt_handler.push_config(node_id, system)
+
+ await fstore.doc_update("nodes", node_id, {
+ "is_overridden": False,
+ "override_system_id": None,
+ "override_timeout_at": None,
+ })
+ return {"ok": True}
diff --git a/drb-frontend/app/nodes/[id]/page.tsx b/drb-frontend/app/nodes/[id]/page.tsx
index 320e098..0e1a740 100644
--- a/drb-frontend/app/nodes/[id]/page.tsx
+++ b/drb-frontend/app/nodes/[id]/page.tsx
@@ -192,6 +192,54 @@ export default function NodeDetailPage() {
+ This node is operating on a local system override. + {node.override_timeout_at ? ( + <> Resets automatically on: {new Date(node.override_timeout_at).toLocaleString()}.> + ) : ( + <> No timeout is currently enforced (permanent override).> + )} +
+{error}
}