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
This commit is contained in:
Light-Fighter-Manifesto 2026-02-02 17:50:24 -05:00
parent 834a380383
commit c53502dbec
12 changed files with 841 additions and 22 deletions

View file

@ -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 |

View file

@ -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"

View file

@ -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",

View file

@ -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"

View file

@ -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 <card_number>
#
# 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

View file

@ -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 <hash> 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 <hash> 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.

View file

@ -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"

View file

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
<rect width="192" height="192" fill="#0a0a0a"/>
<circle cx="96" cy="96" r="60" fill="none" stroke="#c92a2a" stroke-width="8"/>
<circle cx="96" cy="96" r="40" fill="none" stroke="#c92a2a" stroke-width="6"/>
<circle cx="96" cy="96" r="20" fill="none" stroke="#c92a2a" stroke-width="4"/>
<circle cx="96" cy="96" r="8" fill="#c92a2a"/>
<text x="96" y="170" text-anchor="middle" fill="#f0f0f0" font-family="Arial" font-size="16" font-weight="bold">RHF</text>
</svg>

After

Width:  |  Height:  |  Size: 561 B

View file

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#0a0a0a"/>
<circle cx="256" cy="256" r="160" fill="none" stroke="#c92a2a" stroke-width="20"/>
<circle cx="256" cy="256" r="110" fill="none" stroke="#c92a2a" stroke-width="16"/>
<circle cx="256" cy="256" r="60" fill="none" stroke="#c92a2a" stroke-width="12"/>
<circle cx="256" cy="256" r="20" fill="#c92a2a"/>
<text x="256" y="450" text-anchor="middle" fill="#f0f0f0" font-family="Arial" font-size="42" font-weight="bold">ReticulumHF</text>
</svg>

After

Width:  |  Height:  |  Size: 584 B

View file

@ -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"]
}

80
setup-portal/static/sw.js Normal file
View file

@ -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'
});
});
})
);
});

View file

@ -4,6 +4,12 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta name="theme-color" content="#c92a2a">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="ReticulumHF">
<link rel="manifest" href="/static/manifest.json">
<link rel="apple-touch-icon" href="/static/icon-192.svg">
<title>ReticulumHF Gateway</title>
<style>
:root {
@ -402,7 +408,7 @@
<header>
<h1>ReticulumHF Gateway</h1>
<p class="subtitle" id="radio-config">Loading...</p>
<p style="font-size: 0.7rem; color: var(--text-muted); margin-top: 4px;">v0.3.0-alpha</p>
<p style="font-size: 0.7rem; color: var(--text-muted); margin-top: 4px;">v0.3.1-alpha</p>
</header>
<!-- Gateway Status -->
@ -510,19 +516,35 @@
<!-- FreeDV Mode -->
<div class="card">
<div class="card-title">FreeDV Mode</div>
<p style="color: var(--text-muted); font-size: 0.8rem; margin-bottom: 10px;">
Start with DATAC3 (recommended). Use DATAC1 for strong signals, DATAC4 for weak/noisy paths.
</p>
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
<select id="freedv-mode-select" style="flex: 1; padding: 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; color: var(--text); font-size: 0.9rem;">
<option value="DATAC1">DATAC1 (290 bps) - Best speed, needs strong signal</option>
<option value="DATAC3">DATAC3 (124 bps) - Balanced, works in moderate conditions</option>
<option value="DATAC4">DATAC4 (87 bps) - Most robust, works in poor conditions</option>
<option value="DATAC1">DATAC1 - 980 bps, 1700 Hz (good conditions, SNR > 5 dB)</option>
<option value="DATAC3">DATAC3 - 321 bps, 500 Hz (marginal conditions, SNR > 0 dB)</option>
<option value="DATAC4">DATAC4 - 87 bps, 250 Hz (poor conditions, SNR > -4 dB)</option>
</select>
<button class="btn btn-small" onclick="changeFreeDVMode()">Apply</button>
</div>
<!-- Mode Selection Guide -->
<div style="background: var(--bg); border-radius: 6px; padding: 10px; margin-bottom: 10px;">
<div style="font-size: 0.8rem; color: var(--text-muted); margin-bottom: 6px; font-weight: 600;">Mode Selection Guide</div>
<table class="config-table" style="font-size: 0.75rem;">
<tr>
<td style="width: 25%; color: var(--success); font-weight: 500;">DATAC1</td>
<td>Strong signals, clear channel. Best throughput for local/regional NVIS.</td>
</tr>
<tr>
<td style="color: var(--warning); font-weight: 500;">DATAC3</td>
<td>Default choice. Works in moderate noise. Good for unknown conditions.</td>
</tr>
<tr>
<td style="color: var(--error); font-weight: 500;">DATAC4</td>
<td>Weak signals, high noise, long-distance DX. Slowest but most robust.</td>
</tr>
</table>
</div>
<div style="background: rgba(201, 42, 42, 0.1); border-left: 3px solid var(--accent); padding: 10px; border-radius: 4px;">
<p style="font-size: 0.8rem; color: var(--text-secondary); margin: 0;">
<strong style="color: var(--accent);">Important:</strong> All stations must use the same mode to communicate.
@ -533,6 +555,46 @@
<div id="freedv-mode-status" style="font-size: 0.8rem; color: var(--text-muted); margin-top: 8px;"></div>
</div>
<!-- Connection Health -->
<div class="card">
<div class="card-title">Connection Health</div>
<p style="font-size: 0.75rem; color: var(--text-muted); margin-bottom: 12px;">
Live TCP connection tests (not just process checks)
</p>
<div class="health-grid" style="grid-template-columns: repeat(3, 1fr);">
<div class="health-item" id="health-cat">
<div class="health-value" style="font-size: 1rem;">
<span class="indicator indicator-pending" id="cat-health-indicator"></span>
</div>
<div class="health-label">CAT Control</div>
<div style="font-size: 0.65rem; color: var(--text-muted);" id="cat-health-detail">Checking...</div>
</div>
<div class="health-item" id="health-modem">
<div class="health-value" style="font-size: 1rem;">
<span class="indicator indicator-pending" id="modem-health-indicator"></span>
</div>
<div class="health-label">Modem</div>
<div style="font-size: 0.65rem; color: var(--text-muted);" id="modem-health-detail">Checking...</div>
</div>
<div class="health-item" id="health-rns">
<div class="health-value" style="font-size: 1rem;">
<span class="indicator indicator-pending" id="rns-health-indicator"></span>
</div>
<div class="health-label">RNS Transport</div>
<div style="font-size: 0.65rem; color: var(--text-muted);" id="rns-health-detail">Checking...</div>
</div>
</div>
</div>
<!-- RNS Interface Statistics -->
<div class="card">
<div class="card-title">Interface Statistics</div>
<div id="rns-interface-stats" style="font-size: 0.85rem;">
<div style="color: var(--text-muted);">Loading...</div>
</div>
</div>
<!-- Services -->
<div class="card">
<div class="card-title">Services</div>
@ -704,6 +766,16 @@
<button class="btn btn-danger" onclick="shutdownPi()">Shutdown</button>
</div>
<!-- PWA Install -->
<div id="pwa-install-container" style="display: none; margin-top: 14px;">
<button class="btn" id="pwa-install-btn" style="width: 100%; background: var(--accent);">
Install App
</button>
<p style="font-size: 0.75rem; color: var(--text-muted); text-align: center; margin-top: 6px;">
Add to home screen for quick access
</p>
</div>
<footer>
<p>ReticulumHF by <a href="https://lightfightermanifesto.org" target="_blank">Light Fighter Manifesto</a></p>
<p style="margin-top: 4px;"><a href="https://github.com/LFManifesto/ReticulumHF" target="_blank">Documentation & Source</a></p>
@ -729,12 +801,128 @@
<script>
let rxLevelInterval = null;
let healthCheckInterval = null;
async function apiFetch(url, options = {}) {
const bustUrl = url + (url.includes('?') ? '&' : '?') + '_t=' + Date.now();
return fetch(bustUrl, { ...options, cache: 'no-store' });
}
// Format bytes to human readable
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
// Format frequency in MHz
function formatFrequency(hz) {
if (!hz) return '--';
return (hz / 1000000).toFixed(3) + ' MHz';
}
// Connection health checks
async function checkConnectionHealth() {
// Check CAT (rigctld) health
try {
const catResponse = await apiFetch('/api/rigctld-health');
const catData = await catResponse.json();
const catIndicator = document.getElementById('cat-health-indicator');
const catDetail = document.getElementById('cat-health-detail');
if (catData.healthy) {
catIndicator.className = 'indicator indicator-active';
catDetail.textContent = catData.frequency ? formatFrequency(catData.frequency) : 'Connected';
catDetail.style.color = 'var(--success)';
} else if (catData.connected) {
catIndicator.className = 'indicator indicator-pending';
catDetail.textContent = 'Connected but not responding';
catDetail.style.color = 'var(--warning)';
} else {
catIndicator.className = 'indicator indicator-inactive';
catDetail.textContent = catData.error || 'Not connected';
catDetail.style.color = 'var(--text-muted)';
}
} catch (err) {
document.getElementById('cat-health-indicator').className = 'indicator indicator-inactive';
document.getElementById('cat-health-detail').textContent = 'Check failed';
}
// Check modem (freedvtnc2) health
try {
const modemResponse = await apiFetch('/api/modem-status');
const modemData = await modemResponse.json();
const modemIndicator = document.getElementById('modem-health-indicator');
const modemDetail = document.getElementById('modem-health-detail');
if (modemData.online) {
modemIndicator.className = 'indicator indicator-active';
modemDetail.textContent = modemData.mode || 'Online';
modemDetail.style.color = 'var(--success)';
} else {
modemIndicator.className = 'indicator indicator-inactive';
modemDetail.textContent = modemData.error || 'Not responding';
modemDetail.style.color = 'var(--text-muted)';
}
} catch (err) {
document.getElementById('modem-health-indicator').className = 'indicator indicator-inactive';
document.getElementById('modem-health-detail').textContent = 'Check failed';
}
// Check RNS and get interface stats
try {
const rnsResponse = await apiFetch('/api/rns-stats');
const rnsData = await rnsResponse.json();
const rnsIndicator = document.getElementById('rns-health-indicator');
const rnsDetail = document.getElementById('rns-health-detail');
const statsContainer = document.getElementById('rns-interface-stats');
if (rnsData.success) {
rnsIndicator.className = 'indicator indicator-active';
rnsDetail.textContent = rnsData.transport_enabled ? 'Transport active' : 'Local only';
rnsDetail.style.color = 'var(--success)';
// Build interface stats display
if (rnsData.interfaces && rnsData.interfaces.length > 0) {
let html = '<table class="config-table" style="font-size: 0.8rem;">';
html += '<tr style="border-bottom: 1px solid var(--border);"><td style="font-weight: 600;">Interface</td><td style="font-weight: 600;">RX</td><td style="font-weight: 600;">TX</td><td style="font-weight: 600;">Status</td></tr>';
for (const iface of rnsData.interfaces) {
const statusColor = iface.status === 'online' ? 'var(--success)' : 'var(--text-muted)';
html += `<tr>
<td style="color: var(--text-muted);">${iface.name || iface.type || 'Unknown'}</td>
<td>${formatBytes(iface.rx_bytes || 0)}</td>
<td>${formatBytes(iface.tx_bytes || 0)}</td>
<td style="color: ${statusColor};">${iface.status || '--'}</td>
</tr>`;
}
html += '</table>';
statsContainer.innerHTML = html;
} else {
statsContainer.innerHTML = '<div style="color: var(--text-muted); font-size: 0.8rem;">No interfaces detected</div>';
}
} else {
rnsIndicator.className = 'indicator indicator-inactive';
rnsDetail.textContent = rnsData.error || 'Not running';
rnsDetail.style.color = 'var(--text-muted)';
statsContainer.innerHTML = '<div style="color: var(--text-muted); font-size: 0.8rem;">RNS not running</div>';
}
} catch (err) {
document.getElementById('rns-health-indicator').className = 'indicator indicator-inactive';
document.getElementById('rns-health-detail').textContent = 'Check failed';
document.getElementById('rns-interface-stats').innerHTML = '<div style="color: var(--error); font-size: 0.8rem;">Failed to fetch stats</div>';
}
}
function startHealthCheckPolling() {
if (!healthCheckInterval) {
checkConnectionHealth(); // immediate first check
healthCheckInterval = setInterval(checkConnectionHealth, 5000); // every 5 seconds
}
}
// Load config info
async function fetchConfigInfo() {
try {
@ -1117,21 +1305,51 @@
}
function copyText(elementId) {
const text = document.getElementById(elementId).textContent;
const btn = document.getElementById(elementId).nextElementSibling;
const element = document.getElementById(elementId);
const text = element.textContent;
const btn = element.nextElementSibling;
if (navigator.clipboard) {
// Try modern clipboard API first (requires HTTPS or localhost)
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(() => {
btn.textContent = 'Copied';
btn.classList.add('copied');
setTimeout(() => {
btn.textContent = 'Copy';
btn.classList.remove('copied');
}, 2000);
showCopySuccess(btn);
}).catch(() => {
// Fallback if clipboard API fails
fallbackCopy(text, btn);
});
} else {
// Fallback for HTTP connections
fallbackCopy(text, btn);
}
}
function fallbackCopy(text, btn) {
// Create a temporary textarea to copy from
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
showCopySuccess(btn);
} catch (err) {
btn.textContent = 'Failed';
setTimeout(() => { btn.textContent = 'Copy'; }, 2000);
}
document.body.removeChild(textarea);
}
function showCopySuccess(btn) {
btn.textContent = 'Copied';
btn.classList.add('copied');
setTimeout(() => {
btn.textContent = 'Copy';
btn.classList.remove('copied');
}, 2000);
}
function refreshStatus() {
checkServiceStatus();
fetchRnstatus();
@ -1140,9 +1358,54 @@
fetchConnectedClients();
}
// PWA Install handling
let deferredPrompt = null;
window.addEventListener('beforeinstallprompt', (e) => {
// Prevent Chrome from showing its own install prompt
e.preventDefault();
deferredPrompt = e;
// Show our install button
document.getElementById('pwa-install-container').style.display = 'block';
});
document.getElementById('pwa-install-btn').addEventListener('click', async () => {
if (!deferredPrompt) return;
// Show the install prompt
deferredPrompt.prompt();
// Wait for the user's response
const { outcome } = await deferredPrompt.userChoice;
console.log('PWA install outcome:', outcome);
// Clear the deferred prompt
deferredPrompt = null;
document.getElementById('pwa-install-container').style.display = 'none';
});
window.addEventListener('appinstalled', () => {
console.log('ReticulumHF PWA installed');
document.getElementById('pwa-install-container').style.display = 'none';
});
// Service Worker registration
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/static/sw.js')
.then((registration) => {
console.log('Service Worker registered:', registration.scope);
})
.catch((error) => {
console.log('Service Worker registration failed:', error);
});
});
}
// Initial load
refreshStatus();
startRxLevelPolling();
startHealthCheckPolling();
// Auto-refresh
setInterval(refreshStatus, 30000);