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 @@ + +
@@ -418,6 +430,26 @@ } } + // Override Banner + const overrideBanner = document.getElementById('override-banner'); + if (d.is_overridden) { + overrideBanner.style.display = 'flex'; + document.getElementById('override-system-text').textContent = d.system_name || 'Manual'; + + const ackBtn = document.getElementById('ack-override-btn'); + const timerText = document.getElementById('timeout-timer-text'); + + if (d.enforce_override_timeout) { + timerText.textContent = "C2 reset timer active (24h default)."; + ackBtn.style.display = 'inline-flex'; + } else { + timerText.textContent = "No C2 timeout is enforced."; + ackBtn.style.display = 'none'; + } + } else { + overrideBanner.style.display = 'none'; + } + document.getElementById('unconfigured-banner').style.display = d.configured ? 'none' : 'flex'; document.getElementById('last-updated').textContent = new Date().toLocaleTimeString(); } catch (e) { @@ -425,6 +457,37 @@ } } + async function revertToServerConfig() { + try { + const r = await fetch('/api/config/revert', { method: 'POST' }); + if (r.ok) { + alert('Config reverted successfully.'); + refresh(); + } else { + alert('Failed to revert config.'); + } + } catch (e) { + console.error('Revert failed:', e); + } + } + + async function ackOverrideC2() { + try { + const r = await fetch('/api/config/override/ack', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ timeout_minutes: 1440 }) + }); + if (r.ok) { + alert('Override acknowledged on C2.'); + } else { + alert('Failed to send acknowledgement.'); + } + } catch (e) { + console.error('Ack failed:', e); + } + } + refresh(); setInterval(refresh, 2000); // Polling every 2 seconds diff --git a/drb-edge-node/app/templates/scanner.html b/drb-edge-node/app/templates/scanner.html index e1b65e9..4d9f196 100644 --- a/drb-edge-node/app/templates/scanner.html +++ b/drb-edge-node/app/templates/scanner.html @@ -155,10 +155,40 @@
  • Down Arrow Volume Down
  • Enter / H Toggle Hold (UI mock)
  • P Play/Pause Stream
  • +
  • S Select System
  • +
  • R Revert Override
  • +
    +

    Select Radio System

    +
    + +
    + +
    +

    Or Enter Freq Manually:

    +
    +
    + + +
    +
    + + +
    +
    + +
    + + +
    +
    NO SYSTEM
    Scanning...
    @@ -177,9 +207,9 @@
    -
    System
    -
    Dept
    -
    Chan
    +
    VOL (+/-)
    +
    HOLD (H)
    +
    PLAY (P)
    HOLD
    @@ -257,8 +287,152 @@ setTimeout(() => { elOverlay.style.display = 'none'; }, timeout); } + let systems = []; + let selectedSystemIndex = 0; + let isSystemModalOpen = false; + + async function openSystemModal() { + try { + const res = await fetch('/api/systems'); + if (!res.ok) return; + systems = await res.json(); + isSystemModalOpen = true; + selectedSystemIndex = 0; + renderSystemList(); + document.getElementById('system-modal').style.display = 'block'; + } catch (err) { + console.error('Failed to load systems', err); + } + } + + function renderSystemList() { + const container = document.getElementById('system-list'); + container.innerHTML = ''; + if (systems.length === 0) { + container.innerHTML = '
    No cached systems found.
    '; + return; + } + systems.forEach((sys, index) => { + const item = document.createElement('div'); + item.style.padding = '8px 12px'; + item.style.margin = '4px 0'; + item.style.border = '1px solid #444'; + item.style.cursor = 'pointer'; + item.style.borderRadius = '4px'; + + if (index === selectedSystemIndex) { + item.style.background = 'var(--active-bg)'; + item.style.borderColor = 'var(--sys-color)'; + item.style.color = '#fff'; + item.style.fontWeight = 'bold'; + } else { + item.style.background = '#151515'; + item.style.color = '#ccc'; + } + item.textContent = `${sys.name} (${sys.type})`; + item.onclick = () => { + selectedSystemIndex = index; + selectActiveSystem(); + }; + container.appendChild(item); + }); + } + + function closeSystemModal() { + isSystemModalOpen = false; + document.getElementById('system-modal').style.display = 'none'; + } + + async function selectActiveSystem() { + const selected = systems[selectedSystemIndex]; + if (!selected) return; + closeSystemModal(); + showOverlay(`APPLYING: ${selected.name.toUpperCase()}`, 3000); + try { + const res = await fetch('/api/config/override', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ system_id: selected.system_id }) + }); + if (res.ok) { + showOverlay('SYSTEM OVERRIDDEN', 1500); + } else { + showOverlay('OVERRIDE FAILED', 2000); + } + } catch (err) { + showOverlay('ERROR APPLYING', 2000); + } + } + + async function submitManualSystem() { + const freqVal = document.getElementById('manual-freq').value.trim(); + const typeVal = document.getElementById('manual-type').value; + if (!freqVal) { + alert("Please enter a frequency."); + return; + } + closeSystemModal(); + showOverlay("APPLYING MANUAL", 3000); + const manualConfig = { + system_id: "manual-override", + name: `Freq ${freqVal} (${typeVal})`, + type: typeVal, + config: { + control_channels: [freqVal], + talkgroups: [] + } + }; + try { + const res = await fetch('/api/config/override', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ system_config: manualConfig }) + }); + if (res.ok) { + showOverlay('MANUAL OVERRIDE OK', 1500); + } else { + showOverlay('MANUAL OVERRIDE FAIL', 2000); + } + } catch (err) { + showOverlay('ERROR APPLYING', 2000); + } + } + + async function revertConfig() { + showOverlay("REVERTING TO SERVER", 2000); + try { + const res = await fetch('/api/config/revert', { method: 'POST' }); + if (res.ok) { + showOverlay('SERVER CONFIG LOADED', 1500); + } else { + showOverlay('REVERT FAILED', 2000); + } + } catch (err) { + showOverlay('REVERT ERROR', 2000); + } + } + // Keyboard bindings for hardware buttons window.addEventListener('keydown', (e) => { + if (isSystemModalOpen) { + if (e.key === 'ArrowUp') { + selectedSystemIndex = (selectedSystemIndex - 1 + systems.length) % systems.length; + renderSystemList(); + e.preventDefault(); + } else if (e.key === 'ArrowDown') { + selectedSystemIndex = (selectedSystemIndex + 1) % systems.length; + renderSystemList(); + e.preventDefault(); + } else if (e.key === 'Enter') { + selectActiveSystem(); + e.preventDefault(); + } else if (e.key === 'Escape') { + closeSystemModal(); + e.preventDefault(); + } + return; + } + // Simulate hardware interactions if (e.key === 'ArrowUp') { volume = Math.min(1.0, volume + 0.1); @@ -271,11 +445,9 @@ elVol.textContent = `VOL: ${Math.round(volume * 100)}%`; showOverlay(`VOL: ${Math.round(volume * 100)}%`); } else if (e.key === 'Enter' || e.key === 'h' || e.key === 'H') { - // Toggle Hold (not fully implemented in backend yet, just UI mock) isHolding = !isHolding; showOverlay(isHolding ? 'HOLD ON' : 'HOLD OFF'); } else if (e.key === 'p' || e.key === 'P') { - // Play/Pause audio if (audioPlayer.paused) { audioPlayer.play(); showOverlay('PLAY'); @@ -283,6 +455,10 @@ audioPlayer.pause(); showOverlay('PAUSED'); } + } else if (e.key === 's' || e.key === 'S') { + openSystemModal(); + } else if (e.key === 'r' || e.key === 'R') { + revertConfig(); } });