feat: Add local system override and manual entry controls with offline systems caching
CI / lint (push) Failing after 5s
CI / test (push) Successful in 19s
Build edge-node / build (push) Successful in 32s

This commit is contained in:
Logan Cusano
2026-07-12 23:06:05 -04:00
parent 9f026fa262
commit 857325af85
8 changed files with 439 additions and 12 deletions
+17
View File
@@ -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%. | | **Volume Down** | `ArrowDown` | Decreases volume by 10%. |
| **Toggle Hold** | `Enter` or `H` | Toggles the Hold state (Currently a UI mock, backend support planned). | | **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. | | **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. | | **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 ## Building a Portable Unit
If you are taking the node into the field (e.g., running off a battery in the woods without internet): If you are taking the node into the field (e.g., running off a battery in the woods without internet):
@@ -142,6 +142,8 @@ class MQTTManager:
async def _publish_checkin(self): async def _publish_checkin(self):
from app.internal.discord_radio import radio_bot from app.internal.discord_radio import radio_bot
from app.internal.config_manager import load_node_config
config = load_node_config()
payload = { payload = {
"node_id": settings.node_id, "node_id": settings.node_id,
"name": settings.node_name, "name": settings.node_name,
@@ -149,6 +151,10 @@ class MQTTManager:
"lon": settings.node_lon, "lon": settings.node_lon,
"discord_connected": radio_bot.is_connected, "discord_connected": radio_bot.is_connected,
"timestamp": datetime.now(timezone.utc).isoformat(), "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) self._publish(self._t_checkin, payload, qos=1)
@@ -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
+15 -2
View File
@@ -135,6 +135,8 @@ async def on_config_push(payload: dict):
"""C2 pushes a system config — translate it to OP25 format and restart OP25.""" """C2 pushes a system config — translate it to OP25 format and restart OP25."""
hardware_preset = payload.pop("hardware_preset", None) hardware_preset = payload.pop("hardware_preset", None)
ppm_override = payload.pop("ppm_override", 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: try:
config = SystemConfig(**payload) config = SystemConfig(**payload)
except Exception as e: 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.assigned_system_id = config.system_id
node_cfg.system_config = config node_cfg.system_config = config
node_cfg.configured = True node_cfg.configured = True
node_cfg.override_system_id = None
node_cfg.override_config = None
if hardware_preset is not None: if hardware_preset is not None:
node_cfg.hardware_preset = hardware_preset node_cfg.hardware_preset = hardware_preset
if ppm_override is not None: if ppm_override is not None:
node_cfg.ppm_override = float(ppm_override) 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) save_node_config(node_cfg)
from app.internal.op25_client import op25_client from app.internal.op25_client import op25_client
@@ -185,16 +193,21 @@ async def lifespan(app: FastAPI):
await metadata_watcher.start() await metadata_watcher.start()
await call_recorder.start() # persistent Icecast stream buffer 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 # Report initial status and resume OP25 if node was already configured before this restart
node_cfg = load_node_config() node_cfg = load_node_config()
initial_status = "online" if node_cfg.configured else "unconfigured" initial_status = "online" if node_cfg.configured else "unconfigured"
await mqtt_manager.publish_status(initial_status) 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 from app.internal.op25_client import op25_client
logger.info("Node is configured — waiting for OP25 API then generating config.") logger.info("Node is configured — waiting for OP25 API then generating config.")
for attempt in range(10): 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() await op25_client.start()
break break
logger.warning(f"OP25 not ready yet (attempt {attempt + 1}/10), retrying in 3s…") logger.warning(f"OP25 not ready yet (attempt {attempt + 1}/10), retrying in 3s…")
+4
View File
@@ -33,6 +33,10 @@ class NodeConfig(BaseModel):
configured: bool = False configured: bool = False
hardware_preset: str = "rtl-sdr-v3" hardware_preset: str = "rtl-sdr-v3"
ppm_override: Optional[float] = None 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): class CallEvent(BaseModel):
+103 -5
View File
@@ -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.config import settings
from app.models import SystemConfig from app.models import SystemConfig
from app.internal.op25_client import op25_client 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.call_recorder import call_recorder
from app.internal.discord_radio import radio_bot from app.internal.discord_radio import radio_bot
from app.internal.metadata_watcher import metadata_watcher 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"]) router = APIRouter(prefix="/api", tags=["api"])
@@ -19,11 +24,12 @@ async def get_status():
active_tgid_name = metadata_watcher.current_tgid_name active_tgid_name = metadata_watcher.current_tgid_name
system_name = None system_name = None
if 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
system_name = node_cfg.system_config.name if active_config:
system_name = active_config.name
if active_tgid: if active_tgid:
# Reverse engineer the TGID to talkgroup name based on system object # Reverse engineer the TGID to talkgroup name based on active system object
tgs = node_cfg.system_config.config.get("talkgroups", []) tgs = active_config.config.get("talkgroups", [])
for tg in tgs: for tg in tgs:
if str(tg.get("id")) == str(active_tgid): if str(tg.get("id")) == str(active_tgid):
active_tgid_name = tg.get("name") active_tgid_name = tg.get("name")
@@ -34,6 +40,10 @@ async def get_status():
"node_name": settings.node_name, "node_name": settings.node_name,
"lat": settings.node_lat, "lat": settings.node_lat,
"lon": settings.node_lon, "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, "configured": node_cfg.configured,
"assigned_system_id": node_cfg.assigned_system_id, "assigned_system_id": node_cfg.assigned_system_id,
"system_name": system_name, "system_name": system_name,
@@ -85,6 +95,94 @@ async def set_system_config(config: SystemConfig):
return {"ok": True} 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") @router.post("/discord/join")
async def discord_join(guild_id: int, channel_id: int): async def discord_join(guild_id: int, channel_id: int):
ok = await radio_bot.join(guild_id, channel_id) ok = await radio_bot.join(guild_id, channel_id)
+63
View File
@@ -278,6 +278,18 @@
</div> </div>
</div> </div>
<div id="override-banner" class="alert" style="display:none; background: rgba(245, 158, 11, 0.1); border-color: var(--warning); color: #fef08a;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>
<div style="flex: 1;">
<strong>Local Override Active</strong> — System overridden to: <span id="override-system-text"></span>.
<span id="timeout-timer-text">Timeout is active on C2 server.</span>
</div>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<button class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.8rem;" onclick="revertToServerConfig()">Revert</button>
<button id="ack-override-btn" class="btn btn-primary" style="padding: 0.4rem 0.8rem; font-size: 0.8rem; background: var(--warning); box-shadow: none;" onclick="ackOverrideC2()">Ack C2 Timer</button>
</div>
</div>
<div class="grid"> <div class="grid">
<!-- Status Card --> <!-- Status Card -->
<div class="glass-card"> <div class="glass-card">
@@ -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('unconfigured-banner').style.display = d.configured ? 'none' : 'flex';
document.getElementById('last-updated').textContent = new Date().toLocaleTimeString(); document.getElementById('last-updated').textContent = new Date().toLocaleTimeString();
} catch (e) { } 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(); refresh();
setInterval(refresh, 2000); // Polling every 2 seconds setInterval(refresh, 2000); // Polling every 2 seconds
</script> </script>
+181 -5
View File
@@ -155,10 +155,40 @@
<li><span>Down Arrow</span> Volume Down</li> <li><span>Down Arrow</span> Volume Down</li>
<li><span>Enter / H</span> Toggle Hold (UI mock)</li> <li><span>Enter / H</span> Toggle Hold (UI mock)</li>
<li><span>P</span> Play/Pause Stream</li> <li><span>P</span> Play/Pause Stream</li>
<li><span>S</span> Select System</li>
<li><span>R</span> Revert Override</li>
</ul> </ul>
<button class="close-legend" onclick="document.getElementById('legend-modal').style.display='none'">CLOSE</button> <button class="close-legend" onclick="document.getElementById('legend-modal').style.display='none'">CLOSE</button>
</div> </div>
<div class="legend-modal" id="system-modal">
<h2>Select Radio System</h2>
<div id="system-list" style="max-height: 180px; overflow-y: auto; margin-bottom: 12px; border: 1px solid #333; padding: 5px; text-align: left;">
<!-- Systems list items loaded dynamically -->
</div>
<div style="border-top: 1px solid #333; padding-top: 8px; margin-bottom: 10px; text-align: left;">
<h3 style="color: var(--dept-color); margin-bottom: 6px; font-size: 13px;">Or Enter Freq Manually:</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 5px; font-size: 11px; margin-bottom: 8px;">
<div>
<label>Control Freq (MHz/Hz):</label>
<input type="text" id="manual-freq" style="background:#222; color:#fff; border:1px solid #555; padding:3px; width:100%; border-radius:2px;">
</div>
<div>
<label>System Type:</label>
<select id="manual-type" style="background:#222; color:#fff; border:1px solid #555; padding:3px; width:100%; border-radius:2px;">
<option value="P25">P25</option>
<option value="DMR">DMR</option>
<option value="NBFM">NBFM</option>
</select>
</div>
</div>
<button class="legend-btn" style="width:100%; padding:6px; font-weight:bold;" onclick="submitManualSystem()">Submit Manual Entry</button>
</div>
<button class="close-legend" onclick="closeSystemModal()" style="background:#333;">CANCEL</button>
</div>
<div class="main-display" id="main-display"> <div class="main-display" id="main-display">
<div class="row-large system-row" id="disp-system">NO SYSTEM</div> <div class="row-large system-row" id="disp-system">NO SYSTEM</div>
<div class="row-large dept-row" id="disp-dept">Scanning...</div> <div class="row-large dept-row" id="disp-dept">Scanning...</div>
@@ -177,9 +207,9 @@
</div> </div>
<div class="softkeys"> <div class="softkeys">
<div class="softkey">System</div> <div class="softkey">VOL (+/-)</div>
<div class="softkey">Dept</div> <div class="softkey">HOLD (H)</div>
<div class="softkey">Chan</div> <div class="softkey">PLAY (P)</div>
</div> </div>
<div class="overlay" id="overlay">HOLD</div> <div class="overlay" id="overlay">HOLD</div>
@@ -257,8 +287,152 @@
setTimeout(() => { elOverlay.style.display = 'none'; }, timeout); 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 = '<div style="padding: 10px; color: #888;">No cached systems found.</div>';
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 // Keyboard bindings for hardware buttons
window.addEventListener('keydown', (e) => { 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 // Simulate hardware interactions
if (e.key === 'ArrowUp') { if (e.key === 'ArrowUp') {
volume = Math.min(1.0, volume + 0.1); volume = Math.min(1.0, volume + 0.1);
@@ -271,11 +445,9 @@
elVol.textContent = `VOL: ${Math.round(volume * 100)}%`; elVol.textContent = `VOL: ${Math.round(volume * 100)}%`;
showOverlay(`VOL: ${Math.round(volume * 100)}%`); showOverlay(`VOL: ${Math.round(volume * 100)}%`);
} else if (e.key === 'Enter' || e.key === 'h' || e.key === 'H') { } else if (e.key === 'Enter' || e.key === 'h' || e.key === 'H') {
// Toggle Hold (not fully implemented in backend yet, just UI mock)
isHolding = !isHolding; isHolding = !isHolding;
showOverlay(isHolding ? 'HOLD ON' : 'HOLD OFF'); showOverlay(isHolding ? 'HOLD ON' : 'HOLD OFF');
} else if (e.key === 'p' || e.key === 'P') { } else if (e.key === 'p' || e.key === 'P') {
// Play/Pause audio
if (audioPlayer.paused) { if (audioPlayer.paused) {
audioPlayer.play(); audioPlayer.play();
showOverlay('PLAY'); showOverlay('PLAY');
@@ -283,6 +455,10 @@
audioPlayer.pause(); audioPlayer.pause();
showOverlay('PAUSED'); showOverlay('PAUSED');
} }
} else if (e.key === 's' || e.key === 'S') {
openSystemModal();
} else if (e.key === 'r' || e.key === 'R') {
revertConfig();
} }
}); });