mirror of
https://github.com/LFManifesto/ReticulumHF
synced 2026-08-14 19:31:48 -04:00
Revert "Remove JS8Call and TAK integrations, add N0NBH band conditions"
This reverts commit 300a778d87.
This commit is contained in:
parent
300a778d87
commit
2a80ad0b0a
5 changed files with 1407 additions and 397 deletions
|
|
@ -29,6 +29,7 @@ from hardware import (
|
|||
get_single_audio_control
|
||||
)
|
||||
from dashboard import dashboard_bp, state as dashboard_state, start_rx_monitor
|
||||
from js8call import js8call_bp, startup_from_config as js8call_startup
|
||||
|
||||
# Configuration constants
|
||||
FREEDVTNC2_STARTUP_TIMEOUT_SECS = 15 # Wait for freedvtnc2 to start listening
|
||||
|
|
@ -66,6 +67,7 @@ app = Flask(__name__)
|
|||
|
||||
# Register blueprints
|
||||
app.register_blueprint(dashboard_bp)
|
||||
app.register_blueprint(js8call_bp)
|
||||
|
||||
|
||||
@app.after_request
|
||||
|
|
@ -244,21 +246,21 @@ def generate_reticulum_config(radio_id: str, serial_port: str, audio_card: int,
|
|||
# I2P Interface - full transport for internet connectivity
|
||||
config_lines.extend([
|
||||
"",
|
||||
" # I2P outbound connection to LFNet (pi1)",
|
||||
" # The 'Peer' connection is what matters - 'Listener' will show Down (normal)",
|
||||
" [[I2P to LFNet]]",
|
||||
" # I2P transport - connects to Lightfighter Reticulum network",
|
||||
" # Handles announcements and bulk traffic over internet",
|
||||
" [[Lightfighter I2P]]",
|
||||
" type = I2PInterface",
|
||||
f" enabled = {'yes' if i2p_enabled else 'no'}",
|
||||
" connectable = no", # We don't need incoming connections
|
||||
" connectable = yes",
|
||||
f" peers = {i2p_peer}",
|
||||
])
|
||||
|
||||
# HF Interface - boundary mode with TX gating in freedvtnc2
|
||||
# HF Interface - boundary mode with TX gating
|
||||
config_lines.extend([
|
||||
"",
|
||||
" # FreeDV HF Interface (via freedvtnc2)",
|
||||
" # Boundary mode prevents transport traffic, announce_cap limits announce floods",
|
||||
" # TX control is handled by freedvtnc2 TX gate (hybrid mode = TX disabled except beacons)",
|
||||
" # Boundary mode + announce_cap=0 prevents automatic TX",
|
||||
" # TX gating enforced by modem (beacon windows only in hybrid mode)",
|
||||
" [[FreeDV HF]]",
|
||||
" type = TCPClientInterface",
|
||||
" enabled = yes",
|
||||
|
|
@ -266,7 +268,8 @@ def generate_reticulum_config(radio_id: str, serial_port: str, audio_card: int,
|
|||
" target_port = 8001",
|
||||
" kiss_framing = yes",
|
||||
" mode = boundary",
|
||||
" announce_cap = 1",
|
||||
" # Never automatically announce on HF - use beacon scheduler instead",
|
||||
" announce_cap = 0",
|
||||
"",
|
||||
])
|
||||
|
||||
|
|
@ -1170,6 +1173,32 @@ RETICULUMHF_AP_PASS={wifi_password}
|
|||
with open(beacon_config_path, "w") as f:
|
||||
json.dump(beacon_config, f, indent=2)
|
||||
|
||||
# Create js8call.json config
|
||||
js8_config_path = env_dir / "js8call.json"
|
||||
js8_enabled = data.get("js8_enabled", False)
|
||||
js8_config = {
|
||||
"enabled": js8_enabled,
|
||||
"host": data.get("js8_host", "127.0.0.1"),
|
||||
"port": data.get("js8_port", 2442),
|
||||
"auto_heartbeat": False,
|
||||
"heartbeat_with_beacon": True,
|
||||
"bridge_messages": False,
|
||||
}
|
||||
with open(js8_config_path, "w") as f:
|
||||
json.dump(js8_config, f, indent=2)
|
||||
|
||||
# Create tak.json config
|
||||
tak_config_path = env_dir / "tak.json"
|
||||
tak_enabled = data.get("tak_enabled", False)
|
||||
tak_config = {
|
||||
"enabled": tak_enabled,
|
||||
"host": data.get("tak_host", ""),
|
||||
"port": data.get("tak_port", 8087),
|
||||
"protocol": "udp",
|
||||
}
|
||||
with open(tak_config_path, "w") as f:
|
||||
json.dump(tak_config, f, indent=2)
|
||||
|
||||
# Enable beacon scheduler service (starts on next boot or manual start)
|
||||
subprocess.run(["systemctl", "enable", "reticulumhf-beacon"], capture_output=True)
|
||||
|
||||
|
|
@ -1184,6 +1213,8 @@ RETICULUMHF_AP_PASS={wifi_password}
|
|||
"beacon_enabled": True,
|
||||
"beacon_message": beacon_message,
|
||||
"tx_beacon": tx_beacon,
|
||||
"js8_enabled": js8_enabled,
|
||||
"tak_enabled": tak_enabled,
|
||||
"reboot_required": False
|
||||
})
|
||||
|
||||
|
|
@ -2189,6 +2220,27 @@ def startup_integrations():
|
|||
|
||||
threading.Thread(target=apply_mode_delayed, daemon=True).start()
|
||||
|
||||
# Auto-connect to JS8Call gateway if configured
|
||||
try:
|
||||
js8call_startup()
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to start JS8Call gateway: {e}")
|
||||
|
||||
# Load TAK config into dashboard state
|
||||
tak_config_path = Path("/etc/reticulumhf/tak.json")
|
||||
if tak_config_path.exists():
|
||||
try:
|
||||
with open(tak_config_path) as f:
|
||||
tak_config = json.load(f)
|
||||
dashboard_state.tak_enabled = tak_config.get("enabled", False)
|
||||
dashboard_state.tak_host = tak_config.get("host", "")
|
||||
dashboard_state.tak_port = tak_config.get("port", 8087)
|
||||
dashboard_state.tak_protocol = tak_config.get("protocol", "udp")
|
||||
if dashboard_state.tak_enabled:
|
||||
log.info(f"TAK integration enabled: {dashboard_state.tak_host}:{dashboard_state.tak_port}")
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to load TAK config: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
startup_integrations()
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ Provides API endpoints for the enhanced dashboard:
|
|||
- RX level history
|
||||
- Interface status
|
||||
- Network health metrics
|
||||
- TAK CoT integration
|
||||
"""
|
||||
|
||||
import json
|
||||
|
|
@ -23,6 +24,7 @@ from dataclasses import dataclass, field
|
|||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
|
|
@ -75,6 +77,12 @@ class DashboardState:
|
|||
# Operating mode: hybrid (default), hf_only, internet_only
|
||||
self.operating_mode = "hybrid"
|
||||
|
||||
# TAK settings
|
||||
self.tak_enabled = False
|
||||
self.tak_host = ""
|
||||
self.tak_port = 8087
|
||||
self.tak_protocol = "udp"
|
||||
|
||||
def add_rx_reading(self, level_db: float, mode: str = ""):
|
||||
"""Add RX level reading to history."""
|
||||
with self.lock:
|
||||
|
|
@ -676,6 +684,118 @@ def get_band_conditions() -> List[Dict]:
|
|||
return conditions
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TAK CoT Integration
|
||||
# ============================================================================
|
||||
|
||||
def generate_cot_event(peer: Dict, stale_minutes: int = 60) -> str:
|
||||
"""
|
||||
Generate Cursor on Target (CoT) XML for a beacon peer.
|
||||
|
||||
TAK uses CoT events to display markers on the map.
|
||||
"""
|
||||
now = datetime.utcnow()
|
||||
stale = datetime.utcfromtimestamp(time.time() + stale_minutes * 60)
|
||||
|
||||
# Get lat/lon from grid
|
||||
lat, lon = 0.0, 0.0
|
||||
if peer.get("grid"):
|
||||
coords = grid_to_latlon(peer["grid"])
|
||||
if coords:
|
||||
lat, lon = coords
|
||||
|
||||
# CoT type: a-f-G-U-C (atom, friend, ground, unit, combat)
|
||||
# For ham radio, we'll use a-f-G-E-S (atom, friend, ground, equipment, sensor)
|
||||
cot_type = "a-f-G-E-S"
|
||||
|
||||
# Unique ID
|
||||
uid = f"reticulumhf-{peer['identity_short']}"
|
||||
|
||||
# Build XML
|
||||
event = ET.Element("event")
|
||||
event.set("version", "2.0")
|
||||
event.set("type", cot_type)
|
||||
event.set("uid", uid)
|
||||
event.set("how", "m-g") # machine-generated
|
||||
event.set("time", now.strftime("%Y-%m-%dT%H:%M:%SZ"))
|
||||
event.set("start", now.strftime("%Y-%m-%dT%H:%M:%SZ"))
|
||||
event.set("stale", stale.strftime("%Y-%m-%dT%H:%M:%SZ"))
|
||||
|
||||
# Point (location)
|
||||
point = ET.SubElement(event, "point")
|
||||
point.set("lat", str(lat))
|
||||
point.set("lon", str(lon))
|
||||
point.set("hae", "0") # height above ellipsoid
|
||||
point.set("ce", "5000") # circular error (meters) - grid square accuracy
|
||||
point.set("le", "9999999") # linear error
|
||||
|
||||
# Detail
|
||||
detail = ET.SubElement(event, "detail")
|
||||
|
||||
# Contact info
|
||||
contact = ET.SubElement(detail, "contact")
|
||||
callsign = peer.get("callsign", peer["identity_short"])
|
||||
contact.set("callsign", callsign)
|
||||
|
||||
# Remarks
|
||||
remarks = ET.SubElement(detail, "remarks")
|
||||
remarks_text = f"ReticulumHF Beacon\n"
|
||||
remarks_text += f"Grid: {peer.get('grid', 'Unknown')}\n"
|
||||
remarks_text += f"RX: {peer.get('rx_level_db', -99):.1f} dB\n"
|
||||
remarks_text += f"Count: {peer.get('rx_count', 0)}\n"
|
||||
remarks_text += f"Interface: {peer.get('interface', 'HF')}\n"
|
||||
remarks_text += f"ID: {peer.get('identity', '')[:32]}"
|
||||
remarks.text = remarks_text
|
||||
|
||||
# Custom fields
|
||||
reticulumhf = ET.SubElement(detail, "reticulumhf")
|
||||
reticulumhf.set("identity", peer.get("identity", ""))
|
||||
reticulumhf.set("grid", peer.get("grid", ""))
|
||||
reticulumhf.set("rx_db", str(peer.get("rx_level_db", -99)))
|
||||
reticulumhf.set("rx_count", str(peer.get("rx_count", 0)))
|
||||
reticulumhf.set("is_prop_node", str(peer.get("is_prop_node", False)).lower())
|
||||
|
||||
return ET.tostring(event, encoding="unicode")
|
||||
|
||||
|
||||
def push_to_tak(peer: Dict) -> bool:
|
||||
"""Push a peer as CoT event to TAK server."""
|
||||
if not state.tak_enabled or not state.tak_host:
|
||||
return False
|
||||
|
||||
try:
|
||||
cot_xml = generate_cot_event(peer)
|
||||
|
||||
if state.tak_protocol == "udp":
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
||||
sock.sendto(cot_xml.encode('utf-8'), (state.tak_host, state.tak_port))
|
||||
else: # TCP
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(5)
|
||||
sock.connect((state.tak_host, state.tak_port))
|
||||
sock.send(cot_xml.encode('utf-8'))
|
||||
|
||||
log.debug(f"Pushed {peer.get('callsign', peer['identity_short'])} to TAK")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"TAK push failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def push_all_peers_to_tak() -> int:
|
||||
"""Push all current peers to TAK server."""
|
||||
if not state.tak_enabled:
|
||||
return 0
|
||||
|
||||
count = 0
|
||||
for peer in state.get_peers(max_age_hours=2):
|
||||
if push_to_tak(peer):
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Flask API Routes
|
||||
# ============================================================================
|
||||
|
|
@ -717,6 +837,20 @@ def api_add_peer():
|
|||
flags=data.get('flags', 0)
|
||||
)
|
||||
|
||||
# Push to TAK if enabled
|
||||
if state.tak_enabled:
|
||||
peer_dict = {
|
||||
"identity": peer.identity,
|
||||
"identity_short": peer.identity[:16],
|
||||
"callsign": peer.callsign,
|
||||
"grid": peer.grid,
|
||||
"rx_level_db": peer.rx_level_db,
|
||||
"rx_count": peer.rx_count,
|
||||
"interface": peer.interface,
|
||||
"is_prop_node": bool(peer.flags & 0x02),
|
||||
}
|
||||
push_to_tak(peer_dict)
|
||||
|
||||
return jsonify({"status": "ok", "rx_count": peer.rx_count})
|
||||
|
||||
|
||||
|
|
@ -773,6 +907,92 @@ def api_get_band_conditions():
|
|||
})
|
||||
|
||||
|
||||
@dashboard_bp.route('/tak/config', methods=['GET'])
|
||||
def api_get_tak_config():
|
||||
"""Get TAK integration configuration."""
|
||||
return jsonify({
|
||||
"enabled": state.tak_enabled,
|
||||
"host": state.tak_host,
|
||||
"port": state.tak_port,
|
||||
"protocol": state.tak_protocol
|
||||
})
|
||||
|
||||
|
||||
@dashboard_bp.route('/tak/config', methods=['POST'])
|
||||
def api_set_tak_config():
|
||||
"""Set TAK integration configuration."""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"error": "No data provided"}), 400
|
||||
|
||||
state.tak_enabled = bool(data.get('enabled', False))
|
||||
state.tak_host = str(data.get('host', ''))
|
||||
|
||||
# Validate port
|
||||
try:
|
||||
port = int(data.get('port', 8087))
|
||||
if not 1 <= port <= 65535:
|
||||
return jsonify({"error": "Port must be 1-65535"}), 400
|
||||
state.tak_port = port
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": "Invalid port number"}), 400
|
||||
|
||||
protocol = data.get('protocol', 'udp')
|
||||
if protocol not in ('udp', 'tcp'):
|
||||
return jsonify({"error": "Protocol must be 'udp' or 'tcp'"}), 400
|
||||
state.tak_protocol = protocol
|
||||
|
||||
# Persist to config file
|
||||
config_path = Path("/etc/reticulumhf/tak.json")
|
||||
try:
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(config_path, "w") as f:
|
||||
json.dump({
|
||||
"enabled": state.tak_enabled,
|
||||
"host": state.tak_host,
|
||||
"port": state.tak_port,
|
||||
"protocol": state.tak_protocol,
|
||||
}, f, indent=2)
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to persist TAK config: {e}")
|
||||
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@dashboard_bp.route('/tak/push', methods=['POST'])
|
||||
def api_tak_push():
|
||||
"""Push all peers to TAK server."""
|
||||
count = push_all_peers_to_tak()
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"pushed": count
|
||||
})
|
||||
|
||||
|
||||
@dashboard_bp.route('/tak/test', methods=['POST'])
|
||||
def api_tak_test():
|
||||
"""Send test CoT event to TAK server."""
|
||||
if not state.tak_enabled or not state.tak_host:
|
||||
return jsonify({"error": "TAK not configured"}), 400
|
||||
|
||||
# Create test peer
|
||||
test_peer = {
|
||||
"identity": "0" * 32,
|
||||
"identity_short": "0" * 16,
|
||||
"callsign": "TEST-RETICULUMHF",
|
||||
"grid": request.args.get('grid', 'FM29'),
|
||||
"rx_level_db": -15,
|
||||
"rx_count": 1,
|
||||
"interface": "TEST",
|
||||
"is_prop_node": False,
|
||||
}
|
||||
|
||||
if push_to_tak(test_peer):
|
||||
return jsonify({"status": "ok", "message": "Test event sent"})
|
||||
else:
|
||||
return jsonify({"error": "Failed to send test event"}), 500
|
||||
|
||||
|
||||
@dashboard_bp.route('/grid/convert', methods=['GET'])
|
||||
def api_grid_convert():
|
||||
"""Convert between grid square and lat/lon."""
|
||||
|
|
@ -1019,267 +1239,6 @@ def control_i2p():
|
|||
return jsonify({"success": result.returncode == 0})
|
||||
|
||||
|
||||
@dashboard_bp.route('/i2p-status', methods=['GET'])
|
||||
def get_i2p_status_detailed():
|
||||
"""Get detailed I2P status for dashboard display."""
|
||||
try:
|
||||
# Check if i2pd is running
|
||||
result = subprocess.run(
|
||||
["systemctl", "is-active", "i2pd"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
running = result.stdout.strip() == "active"
|
||||
|
||||
tunnel_status = "Offline"
|
||||
tunnel_active = False
|
||||
peer_count = 0
|
||||
b32_address = "--"
|
||||
|
||||
if running:
|
||||
tunnel_status = "Starting..."
|
||||
# Try to get status from RNS (run as pi user to access RNS config)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["sudo", "-u", "pi", "/home/pi/.local/bin/rnstatus", "-a"],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
output = result.stdout
|
||||
|
||||
# Check for tunnel status (look for I2PInterfacePeer with Tunnel Active)
|
||||
if "Tunnel Active" in output:
|
||||
tunnel_status = "Active"
|
||||
tunnel_active = True
|
||||
elif "Creating Tunnel" in output:
|
||||
tunnel_status = "Creating..."
|
||||
|
||||
# Extract B32 address and traffic info
|
||||
traffic_info = ""
|
||||
in_peer_section = False
|
||||
for line in output.split("\n"):
|
||||
if "I2P B32" in line:
|
||||
parts = line.split(":")
|
||||
if len(parts) > 1:
|
||||
b32_address = parts[-1].strip()
|
||||
# Track when we're in the I2PInterfacePeer section for traffic
|
||||
if "I2PInterfacePeer" in line:
|
||||
in_peer_section = True
|
||||
elif line.strip().startswith("I2PInterface["):
|
||||
in_peer_section = False # Left peer section
|
||||
# Get traffic from I2PInterfacePeer section only
|
||||
if "Traffic" in line and in_peer_section and not traffic_info:
|
||||
match = re.search(r'↑([\d.]+\s*\w+)', line)
|
||||
if match:
|
||||
traffic_info = f"↑{match.group(1)}"
|
||||
match2 = re.search(r'↓([\d.]+\s*\w+)', line)
|
||||
if match2:
|
||||
traffic_info += f" ↓{match2.group(1)}"
|
||||
|
||||
except Exception as e:
|
||||
log.warning(f"Could not get RNS I2P status: {e}")
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"running": running,
|
||||
"tunnel_active": tunnel_active,
|
||||
"tunnel_status": tunnel_status,
|
||||
"traffic": traffic_info if traffic_info else "--",
|
||||
"b32_address": b32_address
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"tunnel_active": False,
|
||||
"tunnel_status": "Error",
|
||||
"peer_count": 0,
|
||||
"b32_address": "--"
|
||||
})
|
||||
|
||||
|
||||
# Propagation data caches
|
||||
_propagation_cache = {"data": None, "timestamp": 0, "ttl": 300} # 5 minute cache
|
||||
|
||||
|
||||
def _fetch_n0nbh_conditions():
|
||||
"""Fetch band conditions from N0NBH solar data API."""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import re
|
||||
|
||||
try:
|
||||
url = "https://www.hamqsl.com/solarxml.php"
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'ReticulumHF/0.3'})
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
data = response.read().decode('utf-8')
|
||||
|
||||
# Parse band conditions from XML
|
||||
# Format: <band name="80m-40m" time="day">Fair</band>
|
||||
conditions = {}
|
||||
band_pattern = r'<band name="([^"]+)" time="([^"]+)">([^<]+)</band>'
|
||||
matches = re.findall(band_pattern, data)
|
||||
for band_name, time_of_day, condition in matches:
|
||||
# Store both day and night conditions
|
||||
key = f"{band_name}_{time_of_day}"
|
||||
conditions[key] = condition.strip()
|
||||
|
||||
# Also get solar indices for context
|
||||
sfi_match = re.search(r'<solarflux>(\d+)</solarflux>', data)
|
||||
a_match = re.search(r'<aindex>(\d+)</aindex>', data)
|
||||
k_match = re.search(r'<kindex>(\d+)</kindex>', data)
|
||||
|
||||
solar = {
|
||||
"sfi": int(sfi_match.group(1)) if sfi_match else None,
|
||||
"a_index": int(a_match.group(1)) if a_match else None,
|
||||
"k_index": int(k_match.group(1)) if k_match else None
|
||||
}
|
||||
|
||||
return {"conditions": conditions, "solar": solar}
|
||||
|
||||
except Exception as e:
|
||||
log.warning(f"N0NBH fetch error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_psk_spots():
|
||||
"""Fetch SSB spot counts from PSKReporter."""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import re
|
||||
|
||||
try:
|
||||
# SSB spots from last 30 minutes
|
||||
url = "https://retrieve.pskreporter.info/query?flowStartSeconds=-1800&mode=SSB&rronly=1&appcontact=reticulumhf@lfmanifesto.org"
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'ReticulumHF/0.3'})
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
data = response.read().decode('utf-8')
|
||||
|
||||
# Count spots per band
|
||||
band_counts = {
|
||||
'160m': 0, '80m': 0, '40m': 0, '30m': 0,
|
||||
'20m': 0, '17m': 0, '15m': 0, '12m': 0, '10m': 0
|
||||
}
|
||||
|
||||
freqs = re.findall(r'frequency="(\d+)"', data)
|
||||
for freq_str in freqs[:200]: # Limit processing
|
||||
freq = int(freq_str)
|
||||
if 1800000 <= freq <= 2000000:
|
||||
band_counts['160m'] += 1
|
||||
elif 3500000 <= freq <= 4000000:
|
||||
band_counts['80m'] += 1
|
||||
elif 7000000 <= freq <= 7300000:
|
||||
band_counts['40m'] += 1
|
||||
elif 10100000 <= freq <= 10150000:
|
||||
band_counts['30m'] += 1
|
||||
elif 14000000 <= freq <= 14350000:
|
||||
band_counts['20m'] += 1
|
||||
elif 18068000 <= freq <= 18168000:
|
||||
band_counts['17m'] += 1
|
||||
elif 21000000 <= freq <= 21450000:
|
||||
band_counts['15m'] += 1
|
||||
elif 24890000 <= freq <= 24990000:
|
||||
band_counts['12m'] += 1
|
||||
elif 28000000 <= freq <= 29700000:
|
||||
band_counts['10m'] += 1
|
||||
|
||||
return band_counts
|
||||
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 503:
|
||||
log.info("PSKReporter rate limited (503)")
|
||||
else:
|
||||
log.warning(f"PSKReporter HTTP error {e.code}")
|
||||
return None
|
||||
except Exception as e:
|
||||
log.warning(f"PSKReporter fetch error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@dashboard_bp.route('/propagation', methods=['GET'])
|
||||
def get_propagation():
|
||||
"""
|
||||
Get propagation data from N0NBH (band conditions) and PSKReporter (SSB spots).
|
||||
|
||||
N0NBH provides calculated band conditions (Good/Fair/Poor) based on solar data.
|
||||
PSKReporter provides actual SSB spot counts per band.
|
||||
Results are cached for 5 minutes.
|
||||
"""
|
||||
global _propagation_cache
|
||||
|
||||
# Return cached data if fresh
|
||||
if _propagation_cache["data"] and (time.time() - _propagation_cache["timestamp"]) < _propagation_cache["ttl"]:
|
||||
return jsonify(_propagation_cache["data"])
|
||||
|
||||
try:
|
||||
# Fetch both data sources
|
||||
n0nbh_data = _fetch_n0nbh_conditions()
|
||||
psk_spots = _fetch_psk_spots()
|
||||
|
||||
# Determine current time of day (simplified - use "day" for now)
|
||||
# Could be enhanced to detect based on local time
|
||||
time_of_day = "day"
|
||||
|
||||
# Build band data combining both sources
|
||||
bands = []
|
||||
band_groups = [
|
||||
("80m-40m", ["80m", "40m"]),
|
||||
("30m-20m", ["30m", "20m"]),
|
||||
("17m-15m", ["17m", "15m"]),
|
||||
("12m-10m", ["12m", "10m"])
|
||||
]
|
||||
|
||||
for group_name, group_bands in band_groups:
|
||||
# Get N0NBH condition for this band group
|
||||
condition = "Unknown"
|
||||
if n0nbh_data and n0nbh_data["conditions"]:
|
||||
key = f"{group_name}_{time_of_day}"
|
||||
condition = n0nbh_data["conditions"].get(key, "Unknown")
|
||||
|
||||
# Sum PSKReporter spots for bands in this group
|
||||
spot_count = 0
|
||||
if psk_spots:
|
||||
for band in group_bands:
|
||||
spot_count += psk_spots.get(band, 0)
|
||||
|
||||
bands.append({
|
||||
"name": group_name,
|
||||
"condition": condition,
|
||||
"spots": spot_count
|
||||
})
|
||||
|
||||
# Get solar indices
|
||||
solar = {}
|
||||
if n0nbh_data and n0nbh_data["solar"]:
|
||||
solar = n0nbh_data["solar"]
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"bands": bands,
|
||||
"solar": solar,
|
||||
"sources": {
|
||||
"conditions": "N0NBH" if n0nbh_data else None,
|
||||
"spots": "PSKReporter" if psk_spots else None
|
||||
}
|
||||
}
|
||||
|
||||
# Cache result
|
||||
_propagation_cache["data"] = result
|
||||
_propagation_cache["timestamp"] = time.time()
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Propagation endpoint error: {e}")
|
||||
# Return cached data if available
|
||||
if _propagation_cache["data"]:
|
||||
_propagation_cache["data"]["cached"] = True
|
||||
return jsonify(_propagation_cache["data"])
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"bands": []
|
||||
})
|
||||
|
||||
|
||||
@dashboard_bp.route('/ethernet', methods=['GET'])
|
||||
def get_ethernet_status():
|
||||
"""Get ethernet (eth0) status including IP, link state, and NAT info."""
|
||||
|
|
|
|||
811
setup-portal/js8call.py
Normal file
811
setup-portal/js8call.py
Normal file
|
|
@ -0,0 +1,811 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
JS8Call Gateway for ReticulumHF
|
||||
|
||||
Connects to an external JS8Call instance's TCP API to:
|
||||
- Monitor @LFNET group messages and relay them to LXMF
|
||||
- Allow LXMF messages to be sent out via JS8Call @LFNET
|
||||
- Track JS8Call stations for dashboard display
|
||||
|
||||
This is a GATEWAY model - the Pi doesn't run JS8Call, it connects to
|
||||
an existing JS8Call instance on the operator's network.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
log = logging.getLogger('js8call')
|
||||
|
||||
# Flask blueprint
|
||||
js8call_bp = Blueprint('js8call', __name__, url_prefix='/api/js8call')
|
||||
|
||||
# Group call for LFM network
|
||||
LFNET_GROUP = "@LFNET"
|
||||
|
||||
# ============================================================================
|
||||
# Data Classes
|
||||
# ============================================================================
|
||||
|
||||
@dataclass
|
||||
class JS8Station:
|
||||
"""A station heard via JS8Call."""
|
||||
callsign: str
|
||||
grid: str = ""
|
||||
snr: int = -99
|
||||
frequency: int = 0 # Dial freq in Hz
|
||||
offset: int = 0 # Audio offset in Hz
|
||||
first_seen: float = 0
|
||||
last_seen: float = 0
|
||||
rx_count: int = 0
|
||||
last_message: str = ""
|
||||
speed: int = 0 # JS8 speed mode (0=normal, 1=fast, 2=turbo, 4=slow)
|
||||
|
||||
@property
|
||||
def freq_khz(self) -> float:
|
||||
"""Get frequency in kHz including offset."""
|
||||
return (self.frequency + self.offset) / 1000.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class JS8Message:
|
||||
"""A message received or sent via JS8Call."""
|
||||
timestamp: float
|
||||
direction: str # 'rx' or 'tx'
|
||||
from_call: str
|
||||
to_call: str
|
||||
text: str
|
||||
snr: int = -99
|
||||
grid: str = ""
|
||||
frequency: int = 0
|
||||
relayed_to_lxmf: bool = False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# LXMF Integration
|
||||
# ============================================================================
|
||||
|
||||
class LXMFRelay:
|
||||
"""
|
||||
Relay messages between JS8Call and LXMF.
|
||||
|
||||
Uses the lxmf command-line tool or direct RNS API to send messages.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.identity_path = "/home/pi/.reticulum/identities/default"
|
||||
self.lxmf_available = self._check_lxmf()
|
||||
|
||||
def _check_lxmf(self) -> bool:
|
||||
"""Check if LXMF tools are available."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["which", "lxmd"],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
return result.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def send_announcement(self, message: str, source_call: str) -> bool:
|
||||
"""
|
||||
Send an LXMF announcement (broadcast) with a JS8Call message.
|
||||
|
||||
This creates a propagated announcement that other Reticulum nodes
|
||||
will receive.
|
||||
"""
|
||||
if not self.lxmf_available:
|
||||
log.warning("LXMF not available - cannot relay message")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Format: [JS8] CALLSIGN: message
|
||||
formatted = f"[JS8] {source_call}: {message}"
|
||||
|
||||
# Use nomadnet to send announcement if available
|
||||
# For now, log and return - full LXMF integration requires lxmf router
|
||||
log.info(f"LXMF relay: {formatted}")
|
||||
|
||||
# TODO: Implement actual LXMF broadcast
|
||||
# This requires running an LXMF router or using the RNS announce API
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
log.error(f"LXMF relay failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# JS8Call Client
|
||||
# ============================================================================
|
||||
|
||||
class JS8CallClient:
|
||||
"""
|
||||
Client for JS8Call's TCP API.
|
||||
|
||||
JS8Call API runs on port 2442 by default.
|
||||
Messages are newline-delimited JSON.
|
||||
|
||||
This client specifically monitors for @LFNET group messages
|
||||
and relays them to the Reticulum network via LXMF.
|
||||
"""
|
||||
|
||||
DEFAULT_PORT = 2442
|
||||
RECONNECT_DELAY = 10.0
|
||||
|
||||
def __init__(self, host: str = "127.0.0.1", port: int = DEFAULT_PORT):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.socket: Optional[socket.socket] = None
|
||||
self.connected = False
|
||||
self.running = False
|
||||
|
||||
# Threading
|
||||
self.rx_thread: Optional[threading.Thread] = None
|
||||
self.tx_queue: queue.Queue = queue.Queue()
|
||||
self.lock = threading.Lock()
|
||||
|
||||
# State
|
||||
self.stations: Dict[str, JS8Station] = {}
|
||||
self.messages: List[JS8Message] = []
|
||||
self.lfnet_messages: List[JS8Message] = [] # @LFNET messages only
|
||||
self.max_messages = 100
|
||||
self.my_callsign = ""
|
||||
self.my_grid = ""
|
||||
self.dial_freq = 0
|
||||
self.offset = 0
|
||||
|
||||
# Gateway settings
|
||||
self.relay_enabled = True # Relay @LFNET to LXMF
|
||||
self.group_filter = LFNET_GROUP # Which group to monitor
|
||||
|
||||
# LXMF relay
|
||||
self.lxmf = LXMFRelay()
|
||||
|
||||
# Callbacks
|
||||
self.on_spot: Optional[Callable] = None
|
||||
self.on_message: Optional[Callable] = None
|
||||
self.on_lfnet_message: Optional[Callable] = None # @LFNET specific
|
||||
self.on_activity: Optional[Callable] = None
|
||||
self.on_connect: Optional[Callable] = None
|
||||
self.on_disconnect: Optional[Callable] = None
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""Connect to JS8Call API."""
|
||||
try:
|
||||
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self.socket.settimeout(10.0)
|
||||
self.socket.connect((self.host, self.port))
|
||||
self.socket.settimeout(None)
|
||||
self.connected = True
|
||||
log.info(f"Connected to JS8Call gateway at {self.host}:{self.port}")
|
||||
|
||||
if self.on_connect:
|
||||
self.on_connect()
|
||||
|
||||
# Request initial state
|
||||
self._send({"type": "STATION.GET_INFO"})
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
log.warning(f"JS8Call gateway not available at {self.host}:{self.port}: {e}")
|
||||
self.connected = False
|
||||
return False
|
||||
|
||||
def disconnect(self):
|
||||
"""Disconnect from JS8Call API."""
|
||||
self.connected = False
|
||||
if self.socket:
|
||||
try:
|
||||
self.socket.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.socket = None
|
||||
|
||||
if self.on_disconnect:
|
||||
self.on_disconnect()
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start the client (connect and begin receiving)."""
|
||||
self.running = True
|
||||
self.rx_thread = threading.Thread(target=self._rx_loop, daemon=True, name="js8call-gateway")
|
||||
self.rx_thread.start()
|
||||
|
||||
# Try initial connection
|
||||
return self.connect()
|
||||
|
||||
def stop(self):
|
||||
"""Stop the client."""
|
||||
self.running = False
|
||||
self.disconnect()
|
||||
if self.rx_thread:
|
||||
self.rx_thread.join(timeout=2.0)
|
||||
|
||||
def _send(self, msg: Dict) -> bool:
|
||||
"""Send a message to JS8Call."""
|
||||
if not self.connected or not self.socket:
|
||||
return False
|
||||
|
||||
try:
|
||||
data = json.dumps(msg) + "\n"
|
||||
self.socket.send(data.encode('utf-8'))
|
||||
return True
|
||||
except Exception as e:
|
||||
log.error(f"Failed to send to JS8Call: {e}")
|
||||
self.disconnect()
|
||||
return False
|
||||
|
||||
def _rx_loop(self):
|
||||
"""Receive loop - runs in background thread with auto-reconnect."""
|
||||
buffer = ""
|
||||
|
||||
while self.running:
|
||||
if not self.connected:
|
||||
time.sleep(self.RECONNECT_DELAY)
|
||||
self.connect()
|
||||
continue
|
||||
|
||||
try:
|
||||
self.socket.settimeout(30.0) # Periodic timeout to check running flag
|
||||
data = self.socket.recv(4096)
|
||||
if not data:
|
||||
log.warning("JS8Call gateway connection closed")
|
||||
self.disconnect()
|
||||
continue
|
||||
|
||||
buffer += data.decode('utf-8')
|
||||
|
||||
# Process complete messages (newline-delimited)
|
||||
while '\n' in buffer:
|
||||
line, buffer = buffer.split('\n', 1)
|
||||
if line.strip():
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
self._handle_message(msg)
|
||||
except json.JSONDecodeError:
|
||||
log.warning(f"Invalid JSON from JS8Call: {line[:100]}")
|
||||
|
||||
except socket.timeout:
|
||||
continue
|
||||
except Exception as e:
|
||||
if self.running:
|
||||
log.error(f"JS8Call gateway RX error: {e}")
|
||||
self.disconnect()
|
||||
|
||||
def _handle_message(self, msg: Dict):
|
||||
"""Handle an incoming message from JS8Call."""
|
||||
msg_type = msg.get("type", "")
|
||||
params = msg.get("params", {})
|
||||
|
||||
if msg_type == "STATION.INFO":
|
||||
# Our station info
|
||||
self.my_callsign = params.get("CALL", "")
|
||||
self.my_grid = params.get("GRID", "")
|
||||
self.dial_freq = params.get("DIAL", 0)
|
||||
self.offset = params.get("OFFSET", 0)
|
||||
log.info(f"JS8Call station: {self.my_callsign} {self.my_grid}")
|
||||
|
||||
elif msg_type == "RX.SPOT":
|
||||
# A station was spotted (heartbeat, CQ, etc.)
|
||||
self._handle_spot(params)
|
||||
|
||||
elif msg_type == "RX.ACTIVITY":
|
||||
# General activity on the waterfall
|
||||
if self.on_activity:
|
||||
self.on_activity(params)
|
||||
|
||||
elif msg_type == "RX.DIRECTED":
|
||||
# A message directed to us or heard
|
||||
self._handle_directed(params)
|
||||
|
||||
elif msg_type == "RIG.FREQ":
|
||||
# Frequency changed
|
||||
self.dial_freq = params.get("DIAL", self.dial_freq)
|
||||
self.offset = params.get("OFFSET", self.offset)
|
||||
|
||||
def _handle_spot(self, params: Dict):
|
||||
"""Handle a spot (station heard)."""
|
||||
callsign = params.get("CALL", "").strip()
|
||||
if not callsign:
|
||||
return
|
||||
|
||||
grid = params.get("GRID", "")
|
||||
snr = params.get("SNR", -99)
|
||||
freq = params.get("DIAL", self.dial_freq)
|
||||
offset = params.get("OFFSET", 0)
|
||||
speed = params.get("SPEED", 0)
|
||||
|
||||
now = time.time()
|
||||
|
||||
with self.lock:
|
||||
if callsign in self.stations:
|
||||
station = self.stations[callsign]
|
||||
station.last_seen = now
|
||||
station.rx_count += 1
|
||||
station.snr = snr
|
||||
if grid:
|
||||
station.grid = grid
|
||||
station.frequency = freq
|
||||
station.offset = offset
|
||||
station.speed = speed
|
||||
else:
|
||||
station = JS8Station(
|
||||
callsign=callsign,
|
||||
grid=grid,
|
||||
snr=snr,
|
||||
frequency=freq,
|
||||
offset=offset,
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
rx_count=1,
|
||||
speed=speed
|
||||
)
|
||||
self.stations[callsign] = station
|
||||
log.info(f"New JS8Call station: {callsign} {grid} SNR:{snr}")
|
||||
|
||||
if self.on_spot:
|
||||
self.on_spot(station)
|
||||
|
||||
def _handle_directed(self, params: Dict):
|
||||
"""Handle a directed message - check for @LFNET group."""
|
||||
from_call = params.get("FROM", "")
|
||||
to_call = params.get("TO", "")
|
||||
text = params.get("TEXT", "")
|
||||
grid = params.get("GRID", "")
|
||||
snr = params.get("SNR", -99)
|
||||
freq = params.get("DIAL", self.dial_freq)
|
||||
|
||||
msg = JS8Message(
|
||||
timestamp=time.time(),
|
||||
direction="rx",
|
||||
from_call=from_call,
|
||||
to_call=to_call,
|
||||
text=text,
|
||||
snr=snr,
|
||||
grid=grid,
|
||||
frequency=freq
|
||||
)
|
||||
|
||||
with self.lock:
|
||||
self.messages.append(msg)
|
||||
if len(self.messages) > self.max_messages:
|
||||
self.messages = self.messages[-self.max_messages:]
|
||||
|
||||
# Update station info
|
||||
if from_call and from_call in self.stations:
|
||||
self.stations[from_call].last_message = text
|
||||
self.stations[from_call].last_seen = time.time()
|
||||
|
||||
# Check if this is an @LFNET group message
|
||||
is_lfnet = (
|
||||
to_call.upper() == self.group_filter.upper() or
|
||||
self.group_filter.upper() in text.upper()
|
||||
)
|
||||
|
||||
if is_lfnet:
|
||||
log.info(f"@LFNET MSG: {from_call}: {text}")
|
||||
msg.relayed_to_lxmf = False
|
||||
|
||||
with self.lock:
|
||||
self.lfnet_messages.append(msg)
|
||||
if len(self.lfnet_messages) > self.max_messages:
|
||||
self.lfnet_messages = self.lfnet_messages[-self.max_messages:]
|
||||
|
||||
# Relay to LXMF if enabled
|
||||
if self.relay_enabled:
|
||||
if self.lxmf.send_announcement(text, from_call):
|
||||
msg.relayed_to_lxmf = True
|
||||
log.info(f"Relayed to LXMF: {from_call}: {text[:50]}")
|
||||
|
||||
if self.on_lfnet_message:
|
||||
self.on_lfnet_message(msg)
|
||||
|
||||
if self.on_message:
|
||||
self.on_message(msg)
|
||||
|
||||
log.debug(f"JS8 MSG: {from_call} -> {to_call}: {text[:50]}")
|
||||
|
||||
# ========================================================================
|
||||
# Public API
|
||||
# ========================================================================
|
||||
|
||||
def send_lfnet_message(self, text: str) -> bool:
|
||||
"""
|
||||
Send a message to @LFNET group via JS8Call.
|
||||
|
||||
This allows LXMF messages to be forwarded to the JS8Call network.
|
||||
"""
|
||||
return self._send({
|
||||
"type": "TX.SEND_MESSAGE",
|
||||
"value": LFNET_GROUP,
|
||||
"params": {"TEXT": text}
|
||||
})
|
||||
|
||||
def send_message(self, to_call: str, text: str) -> bool:
|
||||
"""Send a directed message to a station."""
|
||||
return self._send({
|
||||
"type": "TX.SEND_MESSAGE",
|
||||
"value": to_call,
|
||||
"params": {"TEXT": text}
|
||||
})
|
||||
|
||||
def send_heartbeat(self, grid: Optional[str] = None) -> bool:
|
||||
"""Send a heartbeat."""
|
||||
params = {}
|
||||
if grid:
|
||||
params["GRID"] = grid
|
||||
return self._send({
|
||||
"type": "TX.SEND_MESSAGE",
|
||||
"value": "@HB",
|
||||
"params": params
|
||||
})
|
||||
|
||||
def get_stations(self, max_age_hours: float = 2.0) -> List[Dict]:
|
||||
"""Get all stations heard within max_age_hours."""
|
||||
cutoff = time.time() - (max_age_hours * 3600)
|
||||
|
||||
with self.lock:
|
||||
stations = []
|
||||
for s in self.stations.values():
|
||||
if s.last_seen > cutoff:
|
||||
stations.append({
|
||||
"callsign": s.callsign,
|
||||
"grid": s.grid,
|
||||
"snr": s.snr,
|
||||
"frequency_khz": s.freq_khz,
|
||||
"first_seen": s.first_seen,
|
||||
"last_seen": s.last_seen,
|
||||
"age_seconds": int(time.time() - s.last_seen),
|
||||
"rx_count": s.rx_count,
|
||||
"speed": s.speed,
|
||||
"last_message": s.last_message,
|
||||
})
|
||||
return sorted(stations, key=lambda x: x["last_seen"], reverse=True)
|
||||
|
||||
def get_lfnet_messages(self, limit: int = 50) -> List[Dict]:
|
||||
"""Get recent @LFNET messages only."""
|
||||
with self.lock:
|
||||
return [
|
||||
{
|
||||
"timestamp": m.timestamp,
|
||||
"from": m.from_call,
|
||||
"text": m.text,
|
||||
"snr": m.snr,
|
||||
"grid": m.grid,
|
||||
"relayed": m.relayed_to_lxmf,
|
||||
}
|
||||
for m in self.lfnet_messages[-limit:]
|
||||
]
|
||||
|
||||
def get_messages(self, limit: int = 50) -> List[Dict]:
|
||||
"""Get recent messages (all types)."""
|
||||
with self.lock:
|
||||
return [
|
||||
{
|
||||
"timestamp": m.timestamp,
|
||||
"direction": m.direction,
|
||||
"from": m.from_call,
|
||||
"to": m.to_call,
|
||||
"text": m.text,
|
||||
"snr": m.snr,
|
||||
"grid": m.grid,
|
||||
}
|
||||
for m in self.messages[-limit:]
|
||||
]
|
||||
|
||||
def get_status(self) -> Dict:
|
||||
"""Get client status."""
|
||||
with self.lock:
|
||||
lfnet_count = len(self.lfnet_messages)
|
||||
|
||||
return {
|
||||
"connected": self.connected,
|
||||
"host": self.host,
|
||||
"port": self.port,
|
||||
"my_callsign": self.my_callsign,
|
||||
"my_grid": self.my_grid,
|
||||
"dial_freq_khz": self.dial_freq / 1000.0 if self.dial_freq else 0,
|
||||
"offset": self.offset,
|
||||
"station_count": len(self.stations),
|
||||
"lfnet_message_count": lfnet_count,
|
||||
"relay_enabled": self.relay_enabled,
|
||||
"group_filter": self.group_filter,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Global Client Instance
|
||||
# ============================================================================
|
||||
|
||||
client: Optional[JS8CallClient] = None
|
||||
|
||||
|
||||
def get_client() -> Optional[JS8CallClient]:
|
||||
"""Get the global JS8Call client."""
|
||||
return client
|
||||
|
||||
|
||||
def init_client(host: str = "127.0.0.1", port: int = 2442) -> JS8CallClient:
|
||||
"""Initialize and start the global JS8Call client."""
|
||||
global client
|
||||
if client:
|
||||
client.stop()
|
||||
|
||||
client = JS8CallClient(host=host, port=port)
|
||||
client.start()
|
||||
return client
|
||||
|
||||
|
||||
def stop_client():
|
||||
"""Stop the global JS8Call client."""
|
||||
global client
|
||||
if client:
|
||||
client.stop()
|
||||
client = None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Flask API Routes
|
||||
# ============================================================================
|
||||
|
||||
@js8call_bp.route('/status', methods=['GET'])
|
||||
def api_status():
|
||||
"""Get JS8Call gateway status."""
|
||||
if not client:
|
||||
return jsonify({
|
||||
"connected": False,
|
||||
"configured": False,
|
||||
"error": "Gateway not configured"
|
||||
})
|
||||
|
||||
status = client.get_status()
|
||||
status["configured"] = True
|
||||
return jsonify(status)
|
||||
|
||||
|
||||
@js8call_bp.route('/connect', methods=['POST'])
|
||||
def api_connect():
|
||||
"""Connect to JS8Call gateway."""
|
||||
data = request.get_json() or {}
|
||||
host = str(data.get('host', '127.0.0.1'))
|
||||
|
||||
# Validate port
|
||||
try:
|
||||
port = int(data.get('port', 2442))
|
||||
if not 1 <= port <= 65535:
|
||||
return jsonify({"status": "error", "error": "Port must be 1-65535"}), 400
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"status": "error", "error": "Invalid port number"}), 400
|
||||
|
||||
try:
|
||||
init_client(host=host, port=port)
|
||||
|
||||
# Save config
|
||||
_save_config({"host": host, "port": port, "enabled": True})
|
||||
|
||||
return jsonify({"status": "ok", "connected": client.connected if client else False})
|
||||
except Exception as e:
|
||||
return jsonify({"status": "error", "error": str(e)}), 500
|
||||
|
||||
|
||||
@js8call_bp.route('/disconnect', methods=['POST'])
|
||||
def api_disconnect():
|
||||
"""Disconnect from JS8Call gateway."""
|
||||
stop_client()
|
||||
_save_config({"enabled": False})
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@js8call_bp.route('/stations', methods=['GET'])
|
||||
def api_stations():
|
||||
"""Get heard stations."""
|
||||
if not client:
|
||||
return jsonify({"stations": [], "count": 0})
|
||||
|
||||
max_age = request.args.get('max_age_hours', 2.0, type=float)
|
||||
stations = client.get_stations(max_age_hours=max_age)
|
||||
|
||||
# Add lat/lon from grid for map display
|
||||
try:
|
||||
from dashboard import grid_to_latlon
|
||||
for s in stations:
|
||||
if s.get("grid"):
|
||||
coords = grid_to_latlon(s["grid"])
|
||||
if coords:
|
||||
s["lat"], s["lon"] = coords
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return jsonify({
|
||||
"stations": stations,
|
||||
"count": len(stations)
|
||||
})
|
||||
|
||||
|
||||
@js8call_bp.route('/lfnet', methods=['GET'])
|
||||
def api_lfnet_messages():
|
||||
"""Get @LFNET group messages."""
|
||||
if not client:
|
||||
return jsonify({"messages": [], "count": 0})
|
||||
|
||||
limit = request.args.get('limit', 50, type=int)
|
||||
messages = client.get_lfnet_messages(limit=limit)
|
||||
|
||||
return jsonify({
|
||||
"messages": messages,
|
||||
"count": len(messages),
|
||||
"group": LFNET_GROUP
|
||||
})
|
||||
|
||||
|
||||
@js8call_bp.route('/messages', methods=['GET'])
|
||||
def api_messages():
|
||||
"""Get recent messages (all types)."""
|
||||
if not client:
|
||||
return jsonify({"messages": [], "count": 0})
|
||||
|
||||
limit = request.args.get('limit', 50, type=int)
|
||||
messages = client.get_messages(limit=limit)
|
||||
|
||||
return jsonify({
|
||||
"messages": messages,
|
||||
"count": len(messages)
|
||||
})
|
||||
|
||||
|
||||
@js8call_bp.route('/send', methods=['POST'])
|
||||
def api_send():
|
||||
"""Send a message via JS8Call."""
|
||||
if not client or not client.connected:
|
||||
return jsonify({"error": "Not connected to JS8Call gateway"}), 400
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"error": "No data provided"}), 400
|
||||
|
||||
msg_type = data.get('type', 'lfnet')
|
||||
|
||||
if msg_type == 'lfnet':
|
||||
# Send to @LFNET group
|
||||
text = data.get('text', '')
|
||||
if not text:
|
||||
return jsonify({"error": "text required"}), 400
|
||||
|
||||
if client.send_lfnet_message(text):
|
||||
return jsonify({"status": "ok", "message": f"Sent to {LFNET_GROUP}"})
|
||||
else:
|
||||
return jsonify({"error": "Failed to send"}), 500
|
||||
|
||||
elif msg_type == 'message':
|
||||
to_call = data.get('to', '')
|
||||
text = data.get('text', '')
|
||||
if not to_call or not text:
|
||||
return jsonify({"error": "to and text required"}), 400
|
||||
|
||||
if client.send_message(to_call, text):
|
||||
return jsonify({"status": "ok", "message": f"Sent to {to_call}"})
|
||||
else:
|
||||
return jsonify({"error": "Failed to send"}), 500
|
||||
|
||||
elif msg_type == 'heartbeat':
|
||||
grid = data.get('grid')
|
||||
if client.send_heartbeat(grid):
|
||||
return jsonify({"status": "ok", "message": "Heartbeat queued"})
|
||||
else:
|
||||
return jsonify({"error": "Failed to send"}), 500
|
||||
|
||||
else:
|
||||
return jsonify({"error": f"Unknown message type: {msg_type}"}), 400
|
||||
|
||||
|
||||
@js8call_bp.route('/relay', methods=['POST'])
|
||||
def api_relay_settings():
|
||||
"""Configure relay settings."""
|
||||
if not client:
|
||||
return jsonify({"error": "Gateway not configured"}), 400
|
||||
|
||||
data = request.get_json() or {}
|
||||
|
||||
if 'enabled' in data:
|
||||
client.relay_enabled = bool(data['enabled'])
|
||||
|
||||
if 'group' in data:
|
||||
group = str(data['group']).upper()
|
||||
if not group.startswith('@'):
|
||||
group = '@' + group
|
||||
client.group_filter = group
|
||||
|
||||
_save_config({
|
||||
"relay_enabled": client.relay_enabled,
|
||||
"group_filter": client.group_filter
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"relay_enabled": client.relay_enabled,
|
||||
"group_filter": client.group_filter
|
||||
})
|
||||
|
||||
|
||||
@js8call_bp.route('/config', methods=['GET'])
|
||||
def api_config_get():
|
||||
"""Get JS8Call gateway config."""
|
||||
return jsonify(_load_config())
|
||||
|
||||
|
||||
@js8call_bp.route('/config', methods=['POST'])
|
||||
def api_config_set():
|
||||
"""Set JS8Call gateway config."""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"error": "No data provided"}), 400
|
||||
|
||||
config = _save_config(data)
|
||||
return jsonify({"status": "ok", "config": config})
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Config Helpers
|
||||
# ============================================================================
|
||||
|
||||
CONFIG_PATH = "/etc/reticulumhf/js8call.json"
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"enabled": False,
|
||||
"host": "127.0.0.1",
|
||||
"port": 2442,
|
||||
"relay_enabled": True,
|
||||
"group_filter": "@LFNET",
|
||||
}
|
||||
|
||||
|
||||
def _load_config() -> Dict:
|
||||
"""Load JS8Call gateway config."""
|
||||
try:
|
||||
with open(CONFIG_PATH) as f:
|
||||
config = json.load(f)
|
||||
return {**DEFAULT_CONFIG, **config}
|
||||
except FileNotFoundError:
|
||||
return DEFAULT_CONFIG.copy()
|
||||
except Exception as e:
|
||||
log.error(f"Failed to load JS8Call config: {e}")
|
||||
return DEFAULT_CONFIG.copy()
|
||||
|
||||
|
||||
def _save_config(updates: Dict) -> Dict:
|
||||
"""Save JS8Call gateway config (merge with existing)."""
|
||||
config = _load_config()
|
||||
config.update(updates)
|
||||
|
||||
try:
|
||||
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
|
||||
with open(CONFIG_PATH, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
except Exception as e:
|
||||
log.error(f"Failed to save JS8Call config: {e}")
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def startup_from_config():
|
||||
"""Start JS8Call gateway from saved config (called on portal startup)."""
|
||||
config = _load_config()
|
||||
|
||||
if config.get("enabled"):
|
||||
log.info(f"Starting JS8Call gateway to {config['host']}:{config['port']}")
|
||||
c = init_client(host=config["host"], port=config["port"])
|
||||
|
||||
# Apply relay settings
|
||||
c.relay_enabled = config.get("relay_enabled", True)
|
||||
c.group_filter = config.get("group_filter", "@LFNET")
|
||||
|
|
@ -3,9 +3,6 @@
|
|||
<head>
|
||||
<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 http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<title>ReticulumHF Dashboard</title>
|
||||
<link rel="stylesheet" href="/static/css/leaflet.css" />
|
||||
<script src="/static/js/chart.js"></script>
|
||||
|
|
@ -26,8 +23,8 @@
|
|||
--danger: #f87171;
|
||||
--info: #38bdf8;
|
||||
--border: #2a2a2a;
|
||||
--js8-color: #22d3ee;
|
||||
--beacon-color: #c92a2a;
|
||||
--i2p-color: #22d3ee;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
|
@ -273,6 +270,7 @@
|
|||
}
|
||||
|
||||
.stat-box.beacon .stat-value { color: var(--beacon-color); }
|
||||
.stat-box.js8 .stat-value { color: var(--js8-color); }
|
||||
|
||||
/* RX Level mini chart */
|
||||
.rx-mini {
|
||||
|
|
@ -351,6 +349,7 @@
|
|||
.btn:hover { background: var(--border); }
|
||||
.btn-primary { background: var(--accent); border-color: var(--accent); }
|
||||
.btn-primary:hover { background: var(--accent-dim); }
|
||||
.btn-js8 { background: var(--js8-color); border-color: var(--js8-color); color: #000; }
|
||||
.btn-sm { padding: 0.2rem 0.4rem; font-size: 0.65rem; }
|
||||
|
||||
/* Activity feed */
|
||||
|
|
@ -376,6 +375,7 @@
|
|||
color: var(--text-primary);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
.feed-tab.active.js8 { border-bottom-color: var(--js8-color); }
|
||||
|
||||
.feed-content {
|
||||
flex: 1;
|
||||
|
|
@ -402,6 +402,7 @@
|
|||
}
|
||||
|
||||
.feed-callsign.beacon { color: var(--beacon-color); }
|
||||
.feed-callsign.js8 { color: var(--js8-color); }
|
||||
|
||||
.feed-time {
|
||||
color: var(--text-muted);
|
||||
|
|
@ -448,57 +449,8 @@
|
|||
}
|
||||
|
||||
.source-badge.beacon { background: var(--beacon-color); }
|
||||
|
||||
/* I2P Status */
|
||||
.i2p-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.i2p-detail {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.i2p-detail span:first-child {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Propagation list */
|
||||
.propagation-list {
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.prop-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.3rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.prop-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.prop-band {
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
width: 35px;
|
||||
}
|
||||
|
||||
.prop-count {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.prop-distance {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
.source-badge.js8 { background: var(--js8-color); color: #000; }
|
||||
.source-badge.both { background: linear-gradient(90deg, var(--beacon-color), var(--js8-color)); }
|
||||
|
||||
/* Integration cards */
|
||||
.integration-row {
|
||||
|
|
@ -651,6 +603,10 @@
|
|||
<span class="status-dot" id="beacon-status-dot"></span>
|
||||
<span>Beacon</span>
|
||||
</div>
|
||||
<div class="status-indicator">
|
||||
<span class="status-dot" id="js8-status-dot"></span>
|
||||
<span>JS8Call</span>
|
||||
</div>
|
||||
<div class="status-indicator">
|
||||
<span class="status-dot" id="modem-status-dot"></span>
|
||||
<span>Modem</span>
|
||||
|
|
@ -694,6 +650,10 @@
|
|||
<div class="stat-value" id="beacon-count">0</div>
|
||||
<div class="stat-label">Beacon</div>
|
||||
</div>
|
||||
<div class="stat-box js8">
|
||||
<div class="stat-value" id="js8-count">0</div>
|
||||
<div class="stat-label">JS8Call</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -712,30 +672,22 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- I2P Status -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>I2P Network</h3>
|
||||
<span class="status-dot" id="i2p-status-dot"></span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="i2p-status">
|
||||
<div class="i2p-detail"><span>Peer Tunnel</span><span id="i2p-tunnel">--</span></div>
|
||||
<div class="i2p-detail"><span>Traffic</span><span id="i2p-traffic">--</span></div>
|
||||
</div>
|
||||
<div class="i2p-address" id="i2p-b32" style="font-size:0.7rem; color:var(--text-muted); word-break:break-all; margin-top:0.5rem;">--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Band Conditions & Propagation -->
|
||||
<!-- Band Conditions -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Band Conditions</h3>
|
||||
<span style="font-size:0.7rem; color:var(--text-muted);" id="solar-indices">--</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="propagation-list" id="propagation-list">
|
||||
<div style="color:var(--text-muted); font-size:0.8rem;">Loading...</div>
|
||||
<div class="band-grid" id="band-grid">
|
||||
<div class="band-item">160m</div>
|
||||
<div class="band-item">80m</div>
|
||||
<div class="band-item">40m</div>
|
||||
<div class="band-item">30m</div>
|
||||
<div class="band-item">20m</div>
|
||||
<div class="band-item">17m</div>
|
||||
<div class="band-item">15m</div>
|
||||
<div class="band-item">12m</div>
|
||||
<div class="band-item">10m</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -758,6 +710,62 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JS8Call -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>JS8Call</h3>
|
||||
<span class="status-dot" id="js8-card-status"></span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="integration-row">
|
||||
<label>Connect</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="js8-enabled" onchange="toggleJS8()">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="integration-row">
|
||||
<label>Host</label>
|
||||
<input type="text" class="integration-input" id="js8-host" value="127.0.0.1" style="width:80px">
|
||||
</div>
|
||||
<div class="integration-row">
|
||||
<label>Port</label>
|
||||
<input type="number" class="integration-input" id="js8-port" value="2442" style="width:60px">
|
||||
</div>
|
||||
<div class="control-btns" style="margin-top:0.5rem">
|
||||
<button class="btn btn-js8 btn-sm" onclick="js8SendHB()">Send HB</button>
|
||||
<button class="btn btn-sm" onclick="js8SendCQ()">CQ</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TAK -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>TAK Integration</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="integration-row">
|
||||
<label>Enable</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="tak-enabled" onchange="updateTakConfig()">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="integration-row">
|
||||
<label>Host</label>
|
||||
<input type="text" class="integration-input" id="tak-host" placeholder="192.168.1.x" style="width:100px">
|
||||
</div>
|
||||
<div class="integration-row">
|
||||
<label>Port</label>
|
||||
<input type="number" class="integration-input" id="tak-port" value="8087" style="width:60px">
|
||||
</div>
|
||||
<div class="control-btns" style="margin-top:0.5rem">
|
||||
<button class="btn btn-sm" onclick="takPushAll()">Push All</button>
|
||||
<button class="btn btn-sm" onclick="takTest()">Test</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Center - Map -->
|
||||
|
|
@ -768,6 +776,10 @@
|
|||
<span class="legend-dot" style="background:var(--beacon-color)"></span>
|
||||
<span>Beacon</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-dot" style="background:var(--js8-color)"></span>
|
||||
<span>JS8Call</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-dot" style="background:var(--success)"></span>
|
||||
<span>Strong (>-20dB)</span>
|
||||
|
|
@ -791,6 +803,7 @@
|
|||
<div class="feed-tabs">
|
||||
<button class="feed-tab active" onclick="showFeed('all')">All</button>
|
||||
<button class="feed-tab" onclick="showFeed('beacon')">Beacon</button>
|
||||
<button class="feed-tab js8" onclick="showFeed('js8')">JS8Call</button>
|
||||
<button class="feed-tab" onclick="showFeed('messages')">Messages</button>
|
||||
</div>
|
||||
<div class="feed-content" id="feed-content">
|
||||
|
|
@ -827,6 +840,7 @@
|
|||
let rxChart = null;
|
||||
let currentFeed = 'all';
|
||||
let beaconPeers = [];
|
||||
let js8Stations = [];
|
||||
let allActivity = [];
|
||||
|
||||
// Initialize
|
||||
|
|
@ -887,11 +901,12 @@
|
|||
await Promise.all([
|
||||
fetchHealth(),
|
||||
fetchBeaconPeers(),
|
||||
fetchJS8Stations(),
|
||||
fetchRxHistory(),
|
||||
fetchI2PStatus(),
|
||||
fetchPropagation(),
|
||||
fetchBandConditions(),
|
||||
fetchSchedulerStatus(),
|
||||
fetchModemStatus(),
|
||||
fetchJS8Status(),
|
||||
]);
|
||||
updateMap();
|
||||
updateStationTable();
|
||||
|
|
@ -947,6 +962,33 @@
|
|||
}
|
||||
}
|
||||
|
||||
// JS8Call stations
|
||||
async function fetchJS8Stations() {
|
||||
try {
|
||||
const res = await fetch('/api/js8call/stations');
|
||||
const data = await res.json();
|
||||
js8Stations = data.stations || [];
|
||||
document.getElementById('js8-count').textContent = js8Stations.length;
|
||||
|
||||
// Add to activity
|
||||
js8Stations.forEach(s => {
|
||||
if (!allActivity.find(a => a.id === 'js8-' + s.callsign)) {
|
||||
allActivity.unshift({
|
||||
id: 'js8-' + s.callsign,
|
||||
type: 'js8',
|
||||
callsign: s.callsign,
|
||||
grid: s.grid,
|
||||
snr: s.snr,
|
||||
time: s.last_seen,
|
||||
detail: `Grid: ${s.grid || 'Unknown'} | SNR: ${s.snr} dB | ${s.frequency_khz?.toFixed(1)} kHz`
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('JS8 fetch error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// RX history
|
||||
async function fetchRxHistory() {
|
||||
try {
|
||||
|
|
@ -968,61 +1010,19 @@
|
|||
}
|
||||
}
|
||||
|
||||
// I2P Status
|
||||
async function fetchI2PStatus() {
|
||||
// Band conditions
|
||||
async function fetchBandConditions() {
|
||||
try {
|
||||
const res = await fetch('/api/dashboard/i2p-status');
|
||||
const res = await fetch('/api/dashboard/band-conditions');
|
||||
const data = await res.json();
|
||||
const conditions = data.conditions || [];
|
||||
|
||||
const dot = document.getElementById('i2p-status-dot');
|
||||
dot.className = 'status-dot ' + (data.tunnel_active ? 'online' : 'offline');
|
||||
|
||||
document.getElementById('i2p-tunnel').textContent = data.tunnel_status || '--';
|
||||
document.getElementById('i2p-traffic').textContent = data.traffic || '--';
|
||||
document.getElementById('i2p-b32').textContent = data.b32_address || '--';
|
||||
const grid = document.getElementById('band-grid');
|
||||
grid.innerHTML = conditions.map(c =>
|
||||
`<div class="band-item ${c.quality}">${c.band}</div>`
|
||||
).join('');
|
||||
} catch (e) {
|
||||
console.error('I2P status error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Band Conditions (N0NBH + PSKReporter)
|
||||
async function fetchPropagation() {
|
||||
try {
|
||||
const res = await fetch('/api/dashboard/propagation');
|
||||
const data = await res.json();
|
||||
const bands = data.bands || [];
|
||||
|
||||
// Update solar indices display
|
||||
const solarEl = document.getElementById('solar-indices');
|
||||
if (data.solar && data.solar.sfi) {
|
||||
solarEl.textContent = `SFI ${data.solar.sfi} | A ${data.solar.a_index || '--'} | K ${data.solar.k_index || '--'}`;
|
||||
} else {
|
||||
solarEl.textContent = '--';
|
||||
}
|
||||
|
||||
const list = document.getElementById('propagation-list');
|
||||
if (bands.length === 0) {
|
||||
list.innerHTML = '<div style="color:var(--text-muted); font-size:0.8rem;">No data</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Color code conditions
|
||||
const condColor = (cond) => {
|
||||
if (cond === 'Good') return 'var(--success)';
|
||||
if (cond === 'Fair') return 'var(--warning)';
|
||||
if (cond === 'Poor') return 'var(--danger)';
|
||||
return 'var(--text-muted)';
|
||||
};
|
||||
|
||||
list.innerHTML = bands.map(b => `
|
||||
<div class="prop-item">
|
||||
<span class="prop-band">${b.name}</span>
|
||||
<span style="color:${condColor(b.condition)}">${b.condition}</span>
|
||||
<span class="prop-count">${b.spots > 0 ? b.spots + ' spots' : '--'}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (e) {
|
||||
console.error('Propagation error:', e);
|
||||
console.error('Band conditions error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1067,6 +1067,25 @@
|
|||
}
|
||||
}
|
||||
|
||||
// JS8 status
|
||||
async function fetchJS8Status() {
|
||||
try {
|
||||
const res = await fetch('/api/js8call/status');
|
||||
const data = await res.json();
|
||||
|
||||
const dot = document.getElementById('js8-status-dot');
|
||||
const cardDot = document.getElementById('js8-card-status');
|
||||
const connected = data.connected || false;
|
||||
|
||||
dot.className = 'status-dot ' + (connected ? 'online' : 'offline');
|
||||
cardDot.className = 'status-dot ' + (connected ? 'online' : 'offline');
|
||||
|
||||
document.getElementById('js8-enabled').checked = connected;
|
||||
} catch (e) {
|
||||
document.getElementById('js8-status-dot').className = 'status-dot offline';
|
||||
}
|
||||
}
|
||||
|
||||
// Update map with all stations
|
||||
function updateMap() {
|
||||
// Clear old markers
|
||||
|
|
@ -1100,6 +1119,31 @@
|
|||
}
|
||||
});
|
||||
|
||||
// Add JS8 stations
|
||||
js8Stations.forEach(s => {
|
||||
if (s.lat && s.lon) {
|
||||
const color = getSignalColor(s.snr);
|
||||
const marker = L.circleMarker([s.lat, s.lon], {
|
||||
radius: 8,
|
||||
fillColor: '#22d3ee',
|
||||
color: color,
|
||||
weight: 3,
|
||||
fillOpacity: 0.8
|
||||
}).addTo(map);
|
||||
|
||||
marker.bindPopup(`
|
||||
<b>${s.callsign}</b><br>
|
||||
<span style="color:#22d3ee">JS8Call</span><br>
|
||||
Grid: ${s.grid}<br>
|
||||
SNR: ${s.snr} dB<br>
|
||||
Freq: ${s.frequency_khz?.toFixed(1)} kHz
|
||||
`);
|
||||
|
||||
markers['js8-' + s.callsign] = marker;
|
||||
count++;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('map-station-count').textContent = count;
|
||||
|
||||
// Fit bounds if we have markers
|
||||
|
|
@ -1129,6 +1173,14 @@
|
|||
time: p.last_seen
|
||||
}));
|
||||
|
||||
js8Stations.forEach(s => allStations.push({
|
||||
call: s.callsign,
|
||||
grid: s.grid,
|
||||
snr: s.snr,
|
||||
source: 'js8',
|
||||
time: s.last_seen
|
||||
}));
|
||||
|
||||
// Sort by most recent
|
||||
allStations.sort((a, b) => b.time - a.time);
|
||||
|
||||
|
|
@ -1137,7 +1189,7 @@
|
|||
<td>${s.call}</td>
|
||||
<td>${s.grid || '--'}</td>
|
||||
<td>${s.snr?.toFixed?.(0) || s.snr || '--'}</td>
|
||||
<td><span class="source-badge ${s.source}">BCN</span></td>
|
||||
<td><span class="source-badge ${s.source}">${s.source === 'beacon' ? 'BCN' : 'JS8'}</span></td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
|
@ -1149,6 +1201,8 @@
|
|||
|
||||
if (currentFeed === 'beacon') {
|
||||
items = items.filter(a => a.type === 'beacon');
|
||||
} else if (currentFeed === 'js8') {
|
||||
items = items.filter(a => a.type === 'js8');
|
||||
} else if (currentFeed === 'messages') {
|
||||
items = items.filter(a => a.type === 'message');
|
||||
}
|
||||
|
|
@ -1220,6 +1274,89 @@
|
|||
console.error('Scheduler control error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// JS8Call controls
|
||||
async function toggleJS8() {
|
||||
const enabled = document.getElementById('js8-enabled').checked;
|
||||
const host = document.getElementById('js8-host').value;
|
||||
const port = parseInt(document.getElementById('js8-port').value);
|
||||
|
||||
try {
|
||||
if (enabled) {
|
||||
await fetch('/api/js8call/connect', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({host, port})
|
||||
});
|
||||
} else {
|
||||
await fetch('/api/js8call/disconnect', {method: 'POST'});
|
||||
}
|
||||
setTimeout(fetchJS8Status, 1000);
|
||||
} catch (e) {
|
||||
console.error('JS8 toggle error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function js8SendHB() {
|
||||
try {
|
||||
await fetch('/api/js8call/send', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({type: 'heartbeat'})
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('JS8 HB error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function js8SendCQ() {
|
||||
try {
|
||||
await fetch('/api/js8call/send', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({type: 'cq'})
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('JS8 CQ error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// TAK controls
|
||||
async function updateTakConfig() {
|
||||
const enabled = document.getElementById('tak-enabled').checked;
|
||||
const host = document.getElementById('tak-host').value;
|
||||
const port = parseInt(document.getElementById('tak-port').value);
|
||||
|
||||
try {
|
||||
await fetch('/api/dashboard/tak/config', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({enabled, host, port, protocol: 'udp'})
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('TAK config error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function takPushAll() {
|
||||
try {
|
||||
const res = await fetch('/api/dashboard/tak/push', {method: 'POST'});
|
||||
const data = await res.json();
|
||||
alert(`Pushed ${data.pushed} stations to TAK`);
|
||||
} catch (e) {
|
||||
console.error('TAK push error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function takTest() {
|
||||
try {
|
||||
const res = await fetch('/api/dashboard/tak/test', {method: 'POST'});
|
||||
const data = await res.json();
|
||||
alert(data.message || data.error);
|
||||
} catch (e) {
|
||||
console.error('TAK test error:', e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -437,6 +437,49 @@
|
|||
Connect to other applications running on your network. Configure now or later from the dashboard.
|
||||
</p>
|
||||
|
||||
<!-- JS8Call -->
|
||||
<div style="border: 1px solid var(--border); border-radius: 6px; padding: 12px; margin-bottom: 12px;">
|
||||
<div class="checkbox-row" style="margin-bottom: 8px;">
|
||||
<input type="checkbox" id="js8-enable">
|
||||
<label for="js8-enable"><strong>JS8Call Integration</strong></label>
|
||||
</div>
|
||||
<p style="font-size: 0.8rem; color: var(--text-muted); margin-bottom: 8px;">
|
||||
Connect to JS8Call's API to see JS8 stations on the dashboard map.
|
||||
JS8Call must have TCP API enabled (File → Settings → Reporting → Enable TCP Server API).
|
||||
</p>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<div style="flex: 2;">
|
||||
<label for="js8-host">Host</label>
|
||||
<input type="text" id="js8-host" value="127.0.0.1" placeholder="127.0.0.1">
|
||||
</div>
|
||||
<div style="flex: 1;">
|
||||
<label for="js8-port">Port</label>
|
||||
<input type="text" id="js8-port" value="2442" placeholder="2442">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TAK -->
|
||||
<div style="border: 1px solid var(--border); border-radius: 6px; padding: 12px; margin-bottom: 12px;">
|
||||
<div class="checkbox-row" style="margin-bottom: 8px;">
|
||||
<input type="checkbox" id="tak-enable">
|
||||
<label for="tak-enable"><strong>TAK Integration</strong></label>
|
||||
</div>
|
||||
<p style="font-size: 0.8rem; color: var(--text-muted); margin-bottom: 8px;">
|
||||
Push discovered stations to TAK/ATAK as Cursor-on-Target events.
|
||||
</p>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<div style="flex: 2;">
|
||||
<label for="tak-host">TAK Server Host</label>
|
||||
<input type="text" id="tak-host" placeholder="192.168.1.100">
|
||||
</div>
|
||||
<div style="flex: 1;">
|
||||
<label for="tak-port">Port</label>
|
||||
<input type="text" id="tak-port" value="8087" placeholder="8087">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- I2P Transport -->
|
||||
<div style="border: 1px solid var(--border); border-radius: 6px; padding: 12px; margin-bottom: 12px;">
|
||||
<div class="checkbox-row" style="margin-bottom: 8px;">
|
||||
|
|
@ -732,6 +775,14 @@
|
|||
// Beacon settings
|
||||
beacon_message: callsign && grid ? `${callsign} ${grid}` : (callsign || ''),
|
||||
tx_beacon: document.getElementById('tx-beacon').checked,
|
||||
// JS8Call settings
|
||||
js8_enabled: document.getElementById('js8-enable').checked,
|
||||
js8_host: document.getElementById('js8-host').value || '127.0.0.1',
|
||||
js8_port: parseInt(document.getElementById('js8-port').value) || 2442,
|
||||
// TAK settings
|
||||
tak_enabled: document.getElementById('tak-enable').checked,
|
||||
tak_host: document.getElementById('tak-host').value || '',
|
||||
tak_port: parseInt(document.getElementById('tak-port').value) || 8087,
|
||||
// I2P settings
|
||||
i2p_enabled: document.getElementById('i2p-enable').checked,
|
||||
i2p_peer: document.getElementById('i2p-peer').value || 'kfamlmwnlw3acqfxip4x6kt53i2tr4ksp5h4qxwvxhoq7mchpolq.b32.i2p',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue