mirror of
https://github.com/LFManifesto/ReticulumHF
synced 2026-08-14 19:31:48 -04:00
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:
parent
834a380383
commit
c53502dbec
12 changed files with 841 additions and 22 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
13
configs/99-digirig-audio.rules
Normal file
13
configs/99-digirig-audio.rules
Normal 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"
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
72
scripts/set-digirig-audio.sh
Normal file
72
scripts/set-digirig-audio.sh
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
8
setup-portal/static/icon-192.svg
Normal file
8
setup-portal/static/icon-192.svg
Normal 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 |
8
setup-portal/static/icon-512.svg
Normal file
8
setup-portal/static/icon-512.svg
Normal 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 |
25
setup-portal/static/manifest.json
Normal file
25
setup-portal/static/manifest.json
Normal 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
80
setup-portal/static/sw.js
Normal 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'
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue