From c53502dbec55765ae2eadefa09c6e054b71a4800 Mon Sep 17 00:00:00 2001 From: Light-Fighter-Manifesto Date: Mon, 2 Feb 2026 17:50:24 -0500 Subject: [PATCH] v0.3.1-alpha: Connection health monitor, RNS stats, PWA support New features: - Connection health monitor with live TCP checks for rigctld and modem - RNS interface statistics display (TX/RX byte counts per interface) - PWA support (installable app with service worker for offline caching) - Auto-configure ALSA levels when Digirig/CM108 is plugged in (udev rule) - CM108 GPIO PTT detection for hardware PTT support - Added Kenwood TS-890S radio configuration Bug fixes: - Copy buttons now work over HTTP (not just HTTPS) - CLI commands section shows correct audio card number New endpoints: - /api/rigctld-health - TCP health check with frequency display - /api/rns-stats - Parsed interface statistics from rnstatus --- CLAUDE.md | 3 +- configs/99-digirig-audio.rules | 13 ++ configs/radios.json | 46 +++++ image/build.sh | 3 + scripts/set-digirig-audio.sh | 72 +++++++ setup-portal/app.py | 249 +++++++++++++++++++++++- setup-portal/hardware.py | 61 +++++- setup-portal/static/icon-192.svg | 8 + setup-portal/static/icon-512.svg | 8 + setup-portal/static/manifest.json | 25 +++ setup-portal/static/sw.js | 80 ++++++++ setup-portal/templates/status.html | 295 +++++++++++++++++++++++++++-- 12 files changed, 841 insertions(+), 22 deletions(-) create mode 100644 configs/99-digirig-audio.rules create mode 100644 scripts/set-digirig-audio.sh create mode 100644 setup-portal/static/icon-192.svg create mode 100644 setup-portal/static/icon-512.svg create mode 100644 setup-portal/static/manifest.json create mode 100644 setup-portal/static/sw.js diff --git a/CLAUDE.md b/CLAUDE.md index bd76f32..495940b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # ReticulumHF -**Current Version:** v0.3.0-alpha +**Current Version:** v0.3.1-alpha Reticulum mesh networking over HF radio via FreeDV modem. Raspberry Pi image that creates a WiFi gateway for Sideband/MeshChat to communicate over HF. @@ -141,6 +141,7 @@ All clients (Sideband, MeshChat, Columba) use the same settings: | Version | Changes | |---------|---------| +| v0.3.1-alpha | Connection health monitor (rigctld TCP health check, modem status). RNS interface statistics with TX/RX byte counts. PWA support (installable app, service worker). New endpoints: /api/rigctld-health, /api/rns-stats | | v0.3.0-alpha | freedvtnc2-lfm fork with TCP command interface (port 8002). Mode/volume changes instant without restart. New endpoints: /api/modem-status, /api/modem-levels | | v0.2.3-alpha | Auto-set ALSA defaults on setup (Speaker 80%, Mic Capture 75%, AGC off), CLI commands section in status UI | | v0.2.2-alpha | ALSA control fixes (Mic Capture vs Playback), radio-specific ALC guidance, improved UI | diff --git a/configs/99-digirig-audio.rules b/configs/99-digirig-audio.rules new file mode 100644 index 0000000..40064ea --- /dev/null +++ b/configs/99-digirig-audio.rules @@ -0,0 +1,13 @@ +# ReticulumHF: Auto-configure CM108/Digirig audio when USB device is plugged in +# +# This rule triggers when a C-Media CM108 USB audio device (used by Digirig) +# is detected. It runs a script to set optimal audio levels for digital modes. +# +# Vendor ID 0d8c = C-Media Electronics +# Product IDs: 013c (CM108), 0012, 000c, etc. + +# When CM108 USB audio device is added, run the audio setup script +ACTION=="add", SUBSYSTEM=="sound", ATTRS{idVendor}=="0d8c", RUN+="/opt/reticulumhf/scripts/set-digirig-audio.sh %n" + +# Also match "USB PnP Sound Device" description +ACTION=="add", SUBSYSTEM=="sound", ATTR{id}=="Device", RUN+="/opt/reticulumhf/scripts/set-digirig-audio.sh %n" diff --git a/configs/radios.json b/configs/radios.json index 9cabad3..7e59e01 100644 --- a/configs/radios.json +++ b/configs/radios.json @@ -674,6 +674,52 @@ "tested": false, "power_watts": 100 }, + { + "id": "kenwood_ts890", + "manufacturer": "Kenwood", + "model": "TS-890S", + "hamlib_id": 2040, + "baud_rate": 115200, + "ptt_method": "CAT", + "ptt_on_delay_ms": 200, + "ptt_off_delay_ms": 150, + "audio_interface": "builtin", + "audio_settings": { + "type": "builtin_usb", + "alsa_device": "USB Audio CODEC", + "rx_control": "Radio Menu 7-05 only (no ALSA mixer)", + "tx_control": "Radio Menu 7-04 only (no ALSA mixer)", + "radio_rx_menu": "Menu 7-05 (USB Audio Output Level): 5-7 (0-9 range)", + "radio_tx_menu": "Menu 7-04 (USB Audio Input Level): 3-5 (0-9 range)", + "alc_target": "Zero", + "alc_reversed": false, + "alc_guidance": "Kenwood: Target ZERO ALC. Control power via software audio level, not by driving ALC.", + "freedv_notes": "Built-in USB audio has NO useful ALSA mixer. All levels via radio Menu 7-04/7-05." + }, + "serial_settings": { + "data_bits": 8, + "stop_bits": 1, + "parity": "none", + "handshake": "none", + "rts_state": "OFF", + "dtr_state": "OFF" + }, + "setup_guide": { + "steps": [ + {"setting": "Mode", "value": "USB-DATA", "how": "Press MODE → USB, then press DATA key"}, + {"setting": "Menu 7-00 (USB Baud)", "value": "115200", "how": "Menu 7-00"}, + {"setting": "Menu 7-01 (USB RTS)", "value": "OFF", "how": "Menu 7-01 (OFF for USB)"}, + {"setting": "Menu 7-04 (USB Audio In)", "value": "4", "how": "Menu 7-04 (TX audio from Pi)"}, + {"setting": "Menu 7-05 (USB Audio Out)", "value": "6", "how": "Menu 7-05 (RX audio to Pi)"}, + {"setting": "Menu 4-09 (Data VOX)", "value": "OFF", "how": "Menu 4-09 (use CAT PTT)"} + ], + "note": "Built-in USB audio. High-end SDR transceiver with excellent filtering.", + "sources": ["https://www.kenwood.com/i/products/info/amateur/ts_890s/"] + }, + "notes": "Built-in USB audio - no external interface needed. Flagship SDR transceiver.", + "tested": false, + "power_watts": 100 + }, { "id": "kenwood_ts480", "manufacturer": "Kenwood", diff --git a/image/build.sh b/image/build.sh index dfd398f..5ba1ed2 100755 --- a/image/build.sh +++ b/image/build.sh @@ -108,6 +108,9 @@ sudo cp "$PROJECT_DIR/services/"*.service "$MOUNT_DIR/etc/systemd/system/" # Install ALSA configuration for USB audio (fixes freedvtnc2 "Unknown PCM" errors) sudo cp "$PROJECT_DIR/configs/asound.conf" "$MOUNT_DIR/etc/asound.conf" +# Install udev rule for auto-configuring Digirig/CM108 audio levels +sudo cp "$PROJECT_DIR/configs/99-digirig-audio.rules" "$MOUNT_DIR/etc/udev/rules.d/" + sudo ln -sf /etc/systemd/system/reticulumhf-firstboot.service \ "$MOUNT_DIR/etc/systemd/system/multi-user.target.wants/reticulumhf-firstboot.service" diff --git a/scripts/set-digirig-audio.sh b/scripts/set-digirig-audio.sh new file mode 100644 index 0000000..e76b458 --- /dev/null +++ b/scripts/set-digirig-audio.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# ReticulumHF: Set ALSA audio levels for digital modes +# +# Called by udev when CM108/Digirig USB audio device is plugged in. +# Also can be run manually: ./set-digirig-audio.sh +# +# Sets optimal defaults for FreeDV digital modes: +# - Speaker (TX output): 80% +# - Mic Capture (RX input): 75% +# - Mic Playback (monitoring): Muted +# - Auto Gain Control: Off + +CARD="${1:-}" +LOG_FILE="/var/log/reticulumhf-audio.log" + +log() { + echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" >> "$LOG_FILE" +} + +# If no card number provided, try to find CM108 device +if [ -z "$CARD" ]; then + CARD=$(arecord -l 2>/dev/null | grep -i "USB PnP Sound Device\|C-Media\|CM108" | head -1 | sed -n 's/^card \([0-9]*\):.*/\1/p') +fi + +if [ -z "$CARD" ]; then + log "ERROR: No USB audio card found" + exit 1 +fi + +log "Setting audio levels for card $CARD" + +# Wait for device to fully initialize +sleep 2 + +# Set TX output (Speaker) to 80% +if amixer -c "$CARD" sset 'Speaker' 80% unmute 2>/dev/null; then + log "Set Speaker to 80%" +else + log "Speaker control not found" +fi + +# Set RX input (Mic Capture) to 75% +# Try specific "Mic Capture" first, then generic "Mic" +if amixer -c "$CARD" sset 'Mic Capture' 75% 2>/dev/null; then + log "Set Mic Capture to 75%" +elif amixer -c "$CARD" cset name='Mic Capture Volume' 75% 2>/dev/null; then + log "Set Mic Capture Volume to 75%" +elif amixer -c "$CARD" sset 'Mic' 75% 2>/dev/null; then + log "Set Mic to 75%" +else + log "Mic Capture control not found" +fi + +# Mute monitoring (Mic Playback) - prevents feedback/sidetone +if amixer -c "$CARD" sset 'Mic Playback' 0% mute 2>/dev/null; then + log "Muted Mic Playback (sidetone)" +elif amixer -c "$CARD" cset name='Mic Playback Switch' off 2>/dev/null; then + log "Disabled Mic Playback Switch" +fi + +# Disable Auto Gain Control - critical for digital modes +if amixer -c "$CARD" sset 'Auto Gain Control' off 2>/dev/null; then + log "Disabled AGC" +elif amixer -c "$CARD" sset 'AGC' off 2>/dev/null; then + log "Disabled AGC" +fi + +# Save settings persistently +alsactl store 2>/dev/null +log "Audio levels saved" + +exit 0 diff --git a/setup-portal/app.py b/setup-portal/app.py index d881f1f..5770f00 100644 --- a/setup-portal/app.py +++ b/setup-portal/app.py @@ -30,6 +30,8 @@ FREEDVTNC2_STARTUP_TIMEOUT_SECS = 15 # Wait for freedvtnc2 to start listening FREEDVTNC2_POLL_INTERVAL_SECS = 0.5 # Check interval during startup FREEDVTNC2_CMD_PORT = 8002 # Command interface port (freedvtnc2-lfm) FREEDVTNC2_CMD_TIMEOUT = 5 # Timeout for command interface +RIGCTLD_PORT = 4532 # Hamlib rigctld TCP port +RIGCTLD_TIMEOUT = 3 # Timeout for rigctld health check def freedvtnc2_command(command: str, timeout: float = FREEDVTNC2_CMD_TIMEOUT) -> Tuple[bool, str]: @@ -57,6 +59,59 @@ def freedvtnc2_command(command: str, timeout: float = FREEDVTNC2_CMD_TIMEOUT) -> except Exception as e: return False, f"ERROR {str(e)}" + +def rigctld_health_check(timeout: float = RIGCTLD_TIMEOUT) -> Tuple[bool, dict]: + """ + Check rigctld health by sending a frequency query command. + + Returns (success, details) tuple where details contains: + - connected: bool - TCP connection succeeded + - responding: bool - rigctld returned valid response + - frequency: int - current frequency in Hz (if available) + - error: str - error message (if failed) + """ + result = { + "connected": False, + "responding": False, + "frequency": None, + "error": None + } + + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + sock.connect(('127.0.0.1', RIGCTLD_PORT)) + result["connected"] = True + + # Send 'f' command to get frequency (simplest rigctld command) + sock.send(b"f\n") + response = sock.recv(256).decode('utf-8').strip() + sock.close() + + # rigctld returns frequency in Hz or error code + if response.startswith("RPRT"): + # Error response like "RPRT -1" + result["error"] = f"rigctld error: {response}" + else: + try: + freq = int(response) + result["responding"] = True + result["frequency"] = freq + except ValueError: + result["error"] = f"Unexpected response: {response[:50]}" + + return result["responding"], result + + except socket.timeout: + result["error"] = "Connection timeout" + return False, result + except ConnectionRefusedError: + result["error"] = "rigctld not running" + return False, result + except Exception as e: + result["error"] = str(e) + return False, result + app = Flask(__name__) @@ -378,8 +433,25 @@ def index(): @app.route("/status") def status(): """System status page (shown after setup).""" + # Get audio card from config for CLI commands display + audio_card = None + env_file = Path("/etc/reticulumhf/config.env") + if env_file.exists(): + try: + content = env_file.read_text() + for line in content.split('\n'): + if line.startswith("AUDIO_CARD="): + try: + audio_card = int(line.split("=", 1)[1].strip()) + except ValueError: + pass + break + except Exception: + pass + return render_template("status.html", - system_info=get_system_info()) + system_info=get_system_info(), + audio_card=audio_card) @app.route("/api/detect-hardware") @@ -728,6 +800,181 @@ def api_modem_levels(): return jsonify(levels) +@app.route("/api/rigctld-health") +def api_rigctld_health(): + """ + Check rigctld health via TCP connection test. + + Returns connection status, whether rigctld is responding to commands, + and current frequency if available. + """ + success, details = rigctld_health_check() + return jsonify({ + "success": success, + "healthy": success, + "connected": details.get("connected", False), + "responding": details.get("responding", False), + "frequency": details.get("frequency"), + "error": details.get("error") + }) + + +def parse_rnstatus_output(output: str) -> dict: + """ + Parse rnstatus output to extract interface statistics. + + rnstatus -a output format: + ``` + Reticulum Transport Instance running + + Shared Instance: Yes + ... + + [AutoInterface[Default Interface]] + Status : Online + Mode : Access Point + RX : 1234 bytes + TX : 5678 bytes + + [TCPServerInterface[TCP Gateway]] + Status : Online + Mode : Boundary + RX : 456 bytes + TX : 789 bytes + ``` + + Returns structured data with interface info, byte counts, and status. + """ + result = { + "interfaces": [], + "transport_enabled": False, + "transport_id": None + } + + lines = output.split('\n') + current_interface = None + + for line in lines: + stripped = line.strip() + + # Check for transport status + if "Transport Instance" in stripped: + result["transport_enabled"] = True + # Extract transport ID if present: "Transport Instance running" + if "<" in stripped and ">" in stripped: + start = stripped.find("<") + 1 + end = stripped.find(">") + result["transport_id"] = stripped[start:end] + + # Detect interface block start: "[InterfaceType[Name]]" + # Line format: " [TCPServerInterface[TCP Gateway]]" + if stripped.startswith("[") and "]" in stripped: + # Save previous interface if exists + if current_interface and current_interface.get("name"): + result["interfaces"].append(current_interface) + + current_interface = { + "name": None, + "type": None, + "status": "unknown", + "mode": None, + "rx_bytes": 0, + "tx_bytes": 0 + } + + # Parse: [InterfaceType[Name]] + # Find the interface type (text before first [) + inner = stripped[1:] # Remove leading [ + if "[" in inner: + type_end = inner.find("[") + current_interface["type"] = inner[:type_end] + # Extract name between inner brackets + name_start = type_end + 1 + name_end = inner.find("]", name_start) + if name_end > name_start: + current_interface["name"] = inner[name_start:name_end] + + # Parse interface properties (indented lines after interface header) + elif current_interface and ":" in stripped: + key, _, value = stripped.partition(":") + key = key.strip().lower() + value = value.strip() + + if key == "status": + current_interface["status"] = "online" if value.lower() == "online" else "offline" + elif key == "mode": + current_interface["mode"] = value + elif key == "rx": + # Parse "1234 bytes" or "1.2 KB" etc + try: + # Extract numeric part + parts = value.split() + if parts: + num_str = parts[0].replace(",", "") + current_interface["rx_bytes"] = int(float(num_str)) + except (ValueError, IndexError): + pass + elif key == "tx": + try: + parts = value.split() + if parts: + num_str = parts[0].replace(",", "") + current_interface["tx_bytes"] = int(float(num_str)) + except (ValueError, IndexError): + pass + + # Don't forget the last interface + if current_interface and current_interface.get("name"): + result["interfaces"].append(current_interface) + + return result + + +@app.route("/api/rns-stats") +def api_rns_stats(): + """ + Get RNS interface statistics in structured format. + + Returns interface list with TX/RX byte counts, packet counts, and status. + """ + try: + result = subprocess.run( + ["su", "-", "pi", "-c", "/home/pi/.local/bin/rnstatus -a"], + capture_output=True, text=True, timeout=10 + ) + + if result.returncode == 0 and result.stdout: + stats = parse_rnstatus_output(result.stdout) + stats["success"] = True + stats["raw_output"] = result.stdout # Include for debugging + return jsonify(stats) + elif "No shared RNS instance" in (result.stdout + result.stderr): + return jsonify({ + "success": False, + "error": "RNS not running", + "interfaces": [] + }) + else: + return jsonify({ + "success": False, + "error": result.stderr.strip() or "rnstatus failed", + "interfaces": [] + }) + + except subprocess.TimeoutExpired: + return jsonify({ + "success": False, + "error": "Timeout getting RNS stats", + "interfaces": [] + }) + except Exception as e: + return jsonify({ + "success": False, + "error": str(e), + "interfaces": [] + }) + + def validate_wifi_settings(ssid: str, password: str) -> Tuple[bool, str]: """ Validate WiFi SSID and password. diff --git a/setup-portal/hardware.py b/setup-portal/hardware.py index da62c0a..8810d10 100644 --- a/setup-portal/hardware.py +++ b/setup-portal/hardware.py @@ -448,24 +448,65 @@ def detect_audio_devices() -> list: return devices +def detect_cm108_gpio() -> Optional[dict]: + """ + Detect CM108 GPIO device for hardware PTT. + CM108 chip (used in Digirig) has GPIO pins that can be used for PTT. + GPIO 3 (pin 13) is the standard PTT pin. + + Returns dict with hidraw device path if found. + """ + result = { + "found": False, + "hidraw_device": None, + "message": "CM108 GPIO not detected" + } + + try: + # Look for hidraw devices + for hidraw_path in Path("/dev").glob("hidraw*"): + try: + # Check if this is a CM108 device using udevadm + udev_result = subprocess.run( + ["udevadm", "info", "--query=property", f"--name={hidraw_path}"], + capture_output=True, text=True, timeout=5 + ) + if udev_result.returncode == 0: + output = udev_result.stdout + # C-Media vendor ID is 0d8c + if "ID_VENDOR_ID=0d8c" in output: + result["found"] = True + result["hidraw_device"] = str(hidraw_path) + result["message"] = f"CM108 GPIO found at {hidraw_path}" + break + except Exception: + continue + except Exception: + pass + + return result + + def find_digirig() -> Optional[dict]: """ Find Digirig Mobile device. - Returns dict with serial port, audio card, and detection status. + Returns dict with serial port, audio card, hidraw device, and detection status. Detection status can be: "full", "audio_only", "serial_only", or "none" """ if MOCK_MODE: return { "serial_port": "/dev/ttyUSB0", "audio_card": 3, + "hidraw_device": "/dev/hidraw0", "found": True, "status": "full", - "message": "Digirig detected (audio + CAT)" + "message": "Digirig detected (audio + CAT + GPIO)" } result = { "serial_port": None, "audio_card": None, + "hidraw_device": None, "found": False, "status": "none", "message": "Not detected" @@ -487,19 +528,31 @@ def find_digirig() -> Optional[dict]: result["audio_card"] = dev["card"] break + # Look for CM108 GPIO (hidraw device for PTT) + cm108_gpio = detect_cm108_gpio() + if cm108_gpio["found"]: + result["hidraw_device"] = cm108_gpio["hidraw_device"] + # Determine detection status has_serial = result["serial_port"] is not None has_audio = result["audio_card"] is not None + has_gpio = result["hidraw_device"] is not None if has_serial and has_audio: result["found"] = True result["status"] = "full" - result["message"] = "Digirig detected (audio + CAT)" + if has_gpio: + result["message"] = "Digirig detected (audio + CAT + GPIO PTT)" + else: + result["message"] = "Digirig detected (audio + CAT)" elif has_audio: # Audio found but no serial - common if CAT cable not connected result["found"] = True # Consider partial detection as "found" for usability result["status"] = "audio_only" - result["message"] = "Audio detected (CAT port not found - check USB cable)" + if has_gpio: + result["message"] = "Audio + GPIO detected (CAT port not found)" + else: + result["message"] = "Audio detected (CAT port not found - check USB cable)" elif has_serial: result["found"] = True result["status"] = "serial_only" diff --git a/setup-portal/static/icon-192.svg b/setup-portal/static/icon-192.svg new file mode 100644 index 0000000..aa60750 --- /dev/null +++ b/setup-portal/static/icon-192.svg @@ -0,0 +1,8 @@ + + + + + + + RHF + diff --git a/setup-portal/static/icon-512.svg b/setup-portal/static/icon-512.svg new file mode 100644 index 0000000..547bfd1 --- /dev/null +++ b/setup-portal/static/icon-512.svg @@ -0,0 +1,8 @@ + + + + + + + ReticulumHF + diff --git a/setup-portal/static/manifest.json b/setup-portal/static/manifest.json new file mode 100644 index 0000000..2e4dbe3 --- /dev/null +++ b/setup-portal/static/manifest.json @@ -0,0 +1,25 @@ +{ + "name": "ReticulumHF Gateway", + "short_name": "ReticulumHF", + "description": "Reticulum mesh networking over HF radio", + "start_url": "/status", + "display": "standalone", + "background_color": "#0a0a0a", + "theme_color": "#c92a2a", + "icons": [ + { + "src": "/static/icon-192.svg", + "sizes": "192x192", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "/static/icon-512.svg", + "sizes": "512x512", + "type": "image/svg+xml", + "purpose": "any" + } + ], + "orientation": "portrait", + "categories": ["utilities", "communication"] +} diff --git a/setup-portal/static/sw.js b/setup-portal/static/sw.js new file mode 100644 index 0000000..411d299 --- /dev/null +++ b/setup-portal/static/sw.js @@ -0,0 +1,80 @@ +// ReticulumHF Service Worker +// Provides basic offline caching for the status page + +const CACHE_NAME = 'reticulumhf-v1'; +const OFFLINE_URL = '/status'; + +// Assets to cache on install +const PRECACHE_ASSETS = [ + '/status', + '/static/manifest.json' +]; + +// Install event - cache essential assets +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + return cache.addAll(PRECACHE_ASSETS); + }) + ); + // Activate immediately + self.skipWaiting(); +}); + +// Activate event - clean up old caches +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames + .filter((name) => name !== CACHE_NAME) + .map((name) => caches.delete(name)) + ); + }) + ); + // Take control of all pages immediately + self.clients.claim(); +}); + +// Fetch event - network first, fall back to cache +self.addEventListener('fetch', (event) => { + // Skip non-GET requests + if (event.request.method !== 'GET') { + return; + } + + // Skip API requests - always go to network + if (event.request.url.includes('/api/')) { + return; + } + + event.respondWith( + fetch(event.request) + .then((response) => { + // Clone and cache successful responses + if (response.ok) { + const responseClone = response.clone(); + caches.open(CACHE_NAME).then((cache) => { + cache.put(event.request, responseClone); + }); + } + return response; + }) + .catch(() => { + // Network failed, try cache + return caches.match(event.request).then((cachedResponse) => { + if (cachedResponse) { + return cachedResponse; + } + // For navigation requests, return the offline page + if (event.request.mode === 'navigate') { + return caches.match(OFFLINE_URL); + } + return new Response('Offline', { + status: 503, + statusText: 'Service Unavailable' + }); + }); + }) + ); +}); diff --git a/setup-portal/templates/status.html b/setup-portal/templates/status.html index dc3b044..20fece0 100644 --- a/setup-portal/templates/status.html +++ b/setup-portal/templates/status.html @@ -4,6 +4,12 @@ + + + + + + ReticulumHF Gateway