Revert "Remove JS8Call and TAK integrations, add N0NBH band conditions"

This reverts commit 300a778d87.
This commit is contained in:
Light-Fighter-Manifesto 2026-01-31 00:16:21 -05:00
parent 300a778d87
commit 2a80ad0b0a
5 changed files with 1407 additions and 397 deletions

View file

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

View file

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

View file

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

View file

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