diff --git a/docs/UI_GUIDE.md b/docs/UI_GUIDE.md index 80370a4..27922f3 100644 --- a/docs/UI_GUIDE.md +++ b/docs/UI_GUIDE.md @@ -36,10 +36,27 @@ To make the node operate like a real scanner, the UI listens for standard keyboa | **Volume Down** | `ArrowDown` | Decreases volume by 10%. | | **Toggle Hold** | `Enter` or `H` | Toggles the Hold state (Currently a UI mock, backend support planned). | | **Play/Pause** | `P` | Pauses or resumes the audio stream. Useful for temporarily silencing the unit. | +| **Select System**| `S` | Opens the "Select Radio System" modal to switch systems locally. Arrow keys navigate, Enter selects, Escape cancels. | +| **Revert Override**| `R` | Reverts the node's override and reload the default system configuration assigned by the C2 server. | | **Legend Modal** | Mouse Click | Clicking the "KEY LEGEND" button opens an on-screen modal reminding users of these mappings. | --- +## 3. Local System Overrides + +The edge node supports running local system overrides to tune into a system locally. + +### Fixed vs. Portable Nodes +Admin configurations pushed from the C2 server dictate how overrides behave: +- **Portable Nodes**: Handheld units designed to operate in the field. When a portable node changes its system locally, the C2 server simply stores the active system and **never** enforces timeouts or auto-reverts the config. +- **Fixed Nodes**: Standard base-station setups. By default, changing the system locally triggers a **24-hour timeout** on the C2 server. If an admin does not acknowledge/extend this timer, the C2 server will automatically push the original assigned configuration back to the node, force-reverting it. You can disable this timeout globally for a fixed node by toggling off "Enforce Timeout" in the C2 Node Settings. + +### Offline & Manual Entry +- When the node boots online, it caches all available systems in `/configs/systems_cache.json`. When offline, pressing `S` displays this cached list to select from. +- In the `S` selection modal, operators can also use the **Manual Entry** inputs to tune to any frequency/type dynamically on the fly, creating a temporary override config even if no matching system was pre-cached. + +--- + ## Building a Portable Unit If you are taking the node into the field (e.g., running off a battery in the woods without internet): diff --git a/drb-edge-node/app/internal/mqtt_manager.py b/drb-edge-node/app/internal/mqtt_manager.py index 956afad..19cf452 100644 --- a/drb-edge-node/app/internal/mqtt_manager.py +++ b/drb-edge-node/app/internal/mqtt_manager.py @@ -142,6 +142,8 @@ class MQTTManager: async def _publish_checkin(self): from app.internal.discord_radio import radio_bot + from app.internal.config_manager import load_node_config + config = load_node_config() payload = { "node_id": settings.node_id, "name": settings.node_name, @@ -149,6 +151,10 @@ class MQTTManager: "lon": settings.node_lon, "discord_connected": radio_bot.is_connected, "timestamp": datetime.now(timezone.utc).isoformat(), + "node_type": config.node_type, + "is_overridden": config.override_system_id is not None and config.node_type != "portable", + "override_system_id": config.override_system_id, + "enforce_override_timeout": config.enforce_override_timeout, } self._publish(self._t_checkin, payload, qos=1) diff --git a/drb-edge-node/app/internal/system_cacher.py b/drb-edge-node/app/internal/system_cacher.py new file mode 100644 index 0000000..9b198e3 --- /dev/null +++ b/drb-edge-node/app/internal/system_cacher.py @@ -0,0 +1,50 @@ +import json +from pathlib import Path +from typing import List, Dict, Any, Optional +import httpx +from app.config import settings +from app.internal.logger import logger +from app.internal import credentials + +_CACHE_FILE = Path(settings.config_path) / "systems_cache.json" + +async def fetch_and_cache_systems() -> bool: + """Fetch all systems from the C2 server and cache them locally.""" + if not settings.c2_url: + logger.warning("C2_URL not configured. Skipping system caching.") + return False + + url = f"{settings.c2_url}/systems" + api_key = credentials.get_api_key() + headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} + + try: + async with httpx.AsyncClient(timeout=10) as client: + r = await client.get(url, headers=headers) + r.raise_for_status() + systems = r.json() + + _CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) + _CACHE_FILE.write_text(json.dumps(systems, indent=2)) + logger.info(f"Cached {len(systems)} systems from C2.") + return True + except Exception as e: + logger.warning(f"Failed to fetch systems from C2: {e}. Offline cache will be used.") + return False + +def load_cached_systems() -> List[Dict[str, Any]]: + """Load cached systems from disk.""" + if _CACHE_FILE.exists(): + try: + return json.loads(_CACHE_FILE.read_text()) + except Exception as e: + logger.error(f"Failed to read systems cache: {e}") + return [] + +def get_cached_system(system_id: str) -> Optional[Dict[str, Any]]: + """Retrieve a single system config from the cache.""" + systems = load_cached_systems() + for sys in systems: + if sys.get("system_id") == system_id: + return sys + return None diff --git a/drb-edge-node/app/main.py b/drb-edge-node/app/main.py index 69b372a..96310e5 100644 --- a/drb-edge-node/app/main.py +++ b/drb-edge-node/app/main.py @@ -135,6 +135,8 @@ async def on_config_push(payload: dict): """C2 pushes a system config ā translate it to OP25 format and restart OP25.""" hardware_preset = payload.pop("hardware_preset", None) ppm_override = payload.pop("ppm_override", None) + node_type = payload.pop("node_type", None) + enforce_override_timeout = payload.pop("enforce_override_timeout", None) try: config = SystemConfig(**payload) except Exception as e: @@ -145,10 +147,16 @@ async def on_config_push(payload: dict): node_cfg.assigned_system_id = config.system_id node_cfg.system_config = config node_cfg.configured = True + node_cfg.override_system_id = None + node_cfg.override_config = None if hardware_preset is not None: node_cfg.hardware_preset = hardware_preset if ppm_override is not None: node_cfg.ppm_override = float(ppm_override) + if node_type is not None: + node_cfg.node_type = node_type + if enforce_override_timeout is not None: + node_cfg.enforce_override_timeout = bool(enforce_override_timeout) save_node_config(node_cfg) from app.internal.op25_client import op25_client @@ -185,16 +193,21 @@ async def lifespan(app: FastAPI): await metadata_watcher.start() await call_recorder.start() # persistent Icecast stream buffer + # Start system caching in background + from app.internal.system_cacher import fetch_and_cache_systems + asyncio.create_task(fetch_and_cache_systems()) + # 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 and node_cfg.system_config: + active_config = node_cfg.override_config if (node_cfg.override_system_id and node_cfg.override_config) else node_cfg.system_config + if node_cfg.configured and active_config: from app.internal.op25_client import op25_client logger.info("Node is configured ā waiting for OP25 API then generating config.") for attempt in range(10): - if await _generate_op25_config(node_cfg.system_config): + if await _generate_op25_config(active_config): await op25_client.start() break logger.warning(f"OP25 not ready yet (attempt {attempt + 1}/10), retrying in 3sā¦") diff --git a/drb-edge-node/app/models.py b/drb-edge-node/app/models.py index f6a8b31..77f668d 100644 --- a/drb-edge-node/app/models.py +++ b/drb-edge-node/app/models.py @@ -33,6 +33,10 @@ class NodeConfig(BaseModel): configured: bool = False hardware_preset: str = "rtl-sdr-v3" ppm_override: Optional[float] = None + node_type: str = "fixed" # fixed or portable + enforce_override_timeout: bool = True + override_system_id: Optional[str] = None + override_config: Optional[SystemConfig] = None class CallEvent(BaseModel): diff --git a/drb-edge-node/app/routers/api.py b/drb-edge-node/app/routers/api.py index f46075c..deba5ac 100644 --- a/drb-edge-node/app/routers/api.py +++ b/drb-edge-node/app/routers/api.py @@ -1,4 +1,7 @@ -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Body +from typing import Optional +import asyncio +import httpx from app.config import settings from app.models import SystemConfig from app.internal.op25_client import op25_client @@ -6,6 +9,8 @@ from app.internal.config_manager import load_node_config, save_node_config, appl from app.internal.call_recorder import call_recorder from app.internal.discord_radio import radio_bot from app.internal.metadata_watcher import metadata_watcher +from app.internal import credentials +from app.internal.mqtt_manager import mqtt_manager router = APIRouter(prefix="/api", tags=["api"]) @@ -19,11 +24,12 @@ async def get_status(): active_tgid_name = metadata_watcher.current_tgid_name system_name = None - if node_cfg.system_config: - system_name = node_cfg.system_config.name + active_config = node_cfg.override_config if (node_cfg.override_system_id and node_cfg.override_config) else node_cfg.system_config + if active_config: + system_name = active_config.name if active_tgid: - # Reverse engineer the TGID to talkgroup name based on system object - tgs = node_cfg.system_config.config.get("talkgroups", []) + # Reverse engineer the TGID to talkgroup name based on active system object + tgs = active_config.config.get("talkgroups", []) for tg in tgs: if str(tg.get("id")) == str(active_tgid): active_tgid_name = tg.get("name") @@ -34,6 +40,10 @@ async def get_status(): "node_name": settings.node_name, "lat": settings.node_lat, "lon": settings.node_lon, + "node_type": node_cfg.node_type, + "enforce_override_timeout": node_cfg.enforce_override_timeout, + "is_overridden": node_cfg.override_system_id is not None and node_cfg.node_type != "portable", + "override_system_id": node_cfg.override_system_id, "configured": node_cfg.configured, "assigned_system_id": node_cfg.assigned_system_id, "system_name": system_name, @@ -85,6 +95,94 @@ async def set_system_config(config: SystemConfig): return {"ok": True} +@router.get("/systems") +async def get_systems(): + from app.internal.system_cacher import load_cached_systems, fetch_and_cache_systems + asyncio.create_task(fetch_and_cache_systems()) + return load_cached_systems() + + +@router.post("/config/override") +async def set_override( + system_id: Optional[str] = Body(None), + system_config: Optional[dict] = Body(None) +): + node_cfg = load_node_config() + config = None + + if system_id: + from app.internal.system_cacher import get_cached_system + cached = get_cached_system(system_id) + if not cached: + raise HTTPException(404, f"System '{system_id}' not found in cache.") + config = SystemConfig(**cached) + elif system_config: + if not all(k in system_config for k in ("system_id", "name", "type", "config")): + raise HTTPException(400, "Invalid manual system config schema.") + config = SystemConfig(**system_config) + else: + raise HTTPException(400, "Must specify system_id or system_config.") + + node_cfg.override_system_id = config.system_id + node_cfg.override_config = config + save_node_config(node_cfg) + + from app.main import _generate_op25_config + if not await _generate_op25_config(config): + raise HTTPException(500, f"Failed to generate OP25 config for override: {config.name}") + + await op25_client.stop() + await asyncio.sleep(2) + await op25_client.start() + + await mqtt_manager._publish_checkin() + return {"ok": True} + + +@router.post("/config/revert") +async def revert_config(): + node_cfg = load_node_config() + if not node_cfg.override_system_id: + return {"ok": True, "message": "No override active."} + + node_cfg.override_system_id = None + node_cfg.override_config = None + save_node_config(node_cfg) + + if node_cfg.system_config: + from app.main import _generate_op25_config + if not await _generate_op25_config(node_cfg.system_config): + raise HTTPException(500, "Failed to regenerate original OP25 config.") + + await op25_client.stop() + await asyncio.sleep(2) + await op25_client.start() + + await mqtt_manager._publish_checkin() + return {"ok": True} + + +@router.post("/config/override/ack") +async def ack_override(timeout_minutes: int = Body(1440)): + if not settings.c2_url: + raise HTTPException(400, "C2_URL not configured.") + + api_key = credentials.get_api_key() + headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} + + try: + async with httpx.AsyncClient(timeout=10) as client: + r = await client.post( + f"{settings.c2_url}/nodes/{settings.node_id}/override/ack", + json={"timeout_minutes": timeout_minutes}, + headers=headers + ) + r.raise_for_status() + return r.json() + except Exception as e: + raise HTTPException(500, f"Failed to contact C2: {e}") + + @router.post("/discord/join") async def discord_join(guild_id: int, channel_id: int): ok = await radio_bot.join(guild_id, channel_id) diff --git a/drb-edge-node/app/templates/index.html b/drb-edge-node/app/templates/index.html index a21fba9..5ba6174 100644 --- a/drb-edge-node/app/templates/index.html +++ b/drb-edge-node/app/templates/index.html @@ -278,6 +278,18 @@ +
+