mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-06-13 01:34:45 +02:00
@@ -126,6 +126,87 @@ repeater:
|
||||
# Controls how long users stay logged in before needing to re-authenticate
|
||||
jwt_expiry_minutes: 60
|
||||
|
||||
# Local GPS receiver. When enabled, the daemon reads NMEA sentences from the
|
||||
# configured source and exposes parsed data at /api/gps.
|
||||
gps:
|
||||
enabled: false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Source type:
|
||||
# serial = read directly from an attached GPS module
|
||||
# file = read NMEA lines from source_path (useful for gpsd/sidecar bridges)
|
||||
source: serial
|
||||
|
||||
# Serial source settings (used when source: serial)
|
||||
device: "/dev/serial0"
|
||||
baud_rate: 9600
|
||||
read_timeout_seconds: 1.0
|
||||
reconnect_interval_seconds: 5.0
|
||||
|
||||
# File source settings (used when source: file)
|
||||
# The file may contain raw NMEA lines or JSON with a "sentences" list /
|
||||
# "last_sentence" field.
|
||||
source_path: "/var/lib/pymc_repeater/gps_nmea.txt"
|
||||
poll_interval_seconds: 2.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Location behaviour
|
||||
# Three independent controls — read the comments carefully, they do
|
||||
# different things:
|
||||
#
|
||||
# api_fallback_to_config_location — what the API *displays* before a fix
|
||||
# advertise_gps_location — what the mesh *advertises* in adverts
|
||||
# persist_gps_fix_to_config — whether the fix is *written* to config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# API display: while GPS has no valid fix, show repeater.latitude/longitude
|
||||
# from config in the /api/gps effective position instead of null/0,0.
|
||||
# The default 0,0 repeater location is treated as unset (no fallback shown).
|
||||
# Has no effect on mesh adverts or config persistence.
|
||||
api_fallback_to_config_location: true
|
||||
|
||||
# Mesh adverts: use GPS coordinates in repeater-originated location fields
|
||||
# (flood adverts, etc.). When false, repeater.latitude/longitude from config
|
||||
# are always used for outgoing mesh packets.
|
||||
advertise_gps_location: false
|
||||
|
||||
# Config persistence: write a valid GPS fix back into repeater.latitude/
|
||||
# repeater.longitude so adverts location details follow the
|
||||
# receiver across restarts. Updates are throttled to avoid rewriting config
|
||||
# on every NMEA sentence. location_precision_digits is applied before saving.
|
||||
persist_gps_fix_to_config: false
|
||||
persist_gps_fix_interval_seconds: 600.0
|
||||
|
||||
# Optional privacy/obfuscation: round coordinates to this many decimal places
|
||||
# before they are used for advertising or persisted to config (0–8).
|
||||
# Leave null for full precision. Affects both advertise_gps_location and
|
||||
# persist_gps_fix_to_config.
|
||||
location_precision_digits: null
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Diagnostics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
stale_after_seconds: 10.0
|
||||
retain_sentences: 25
|
||||
validate_checksum: true
|
||||
require_checksum: false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Time sync
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Automatically set the Linux system clock from GPS UTC time once the
|
||||
# receiver has a valid non-stale fix. The systemd service grants
|
||||
# CAP_SYS_TIME for this.
|
||||
time_sync_enabled: true
|
||||
time_sync_interval_seconds: 3600.0
|
||||
time_sync_min_offset_seconds: 1.0
|
||||
time_sync_min_valid_year: 2020
|
||||
|
||||
# Mesh Network Configuration
|
||||
mesh:
|
||||
# Unscoped flood policy - controls whether the repeater allows or denies unscoped flooding
|
||||
|
||||
Vendored
+4
@@ -11,5 +11,9 @@ ExecStart=/usr/bin/pymc-repeater
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
# Allow GPS time sync to update CLOCK_REALTIME without running as root
|
||||
CapabilityBoundingSet=CAP_SYS_TIME
|
||||
AmbientCapabilities=CAP_SYS_TIME
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -34,5 +34,9 @@ SyslogIdentifier=pymc-repeater
|
||||
ReadWritePaths=/var/log/pymc_repeater /var/lib/pymc_repeater /etc/pymc_repeater
|
||||
SupplementaryGroups=plugdev dialout
|
||||
|
||||
# Allow GPS time sync to update CLOCK_REALTIME without running as root
|
||||
CapabilityBoundingSet=CAP_SYS_TIME
|
||||
AmbientCapabilities=CAP_SYS_TIME
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -36,6 +36,7 @@ dependencies = [
|
||||
"paho-mqtt>=1.6.0",
|
||||
"cherrypy-cors==1.7.0",
|
||||
"psutil>=5.9.0",
|
||||
"pyserial>=3.5",
|
||||
"pyjwt>=2.8.0",
|
||||
"ws4py>=0.6.0",
|
||||
]
|
||||
|
||||
@@ -79,6 +79,29 @@ def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
|
||||
"cert_store_dir": "/etc/pymc_repeater/glass",
|
||||
}
|
||||
|
||||
if "gps" not in config:
|
||||
config["gps"] = {
|
||||
"enabled": False,
|
||||
"api_fallback_to_config_location": True,
|
||||
"advertise_gps_location": False,
|
||||
"location_precision_digits": None,
|
||||
"source": "serial",
|
||||
"device": "/dev/serial0",
|
||||
"baud_rate": 9600,
|
||||
"read_timeout_seconds": 1.0,
|
||||
"reconnect_interval_seconds": 5.0,
|
||||
"stale_after_seconds": 10.0,
|
||||
"retain_sentences": 25,
|
||||
"validate_checksum": True,
|
||||
"require_checksum": False,
|
||||
"time_sync_enabled": True,
|
||||
"time_sync_interval_seconds": 3600.0,
|
||||
"time_sync_min_offset_seconds": 1.0,
|
||||
"time_sync_min_valid_year": 2020,
|
||||
"persist_gps_fix_to_config": False,
|
||||
"persist_gps_fix_interval_seconds": 600.0,
|
||||
}
|
||||
|
||||
# Ensure repeater.security exists with defaults for upgrades from older configs
|
||||
if "repeater" not in config:
|
||||
config["repeater"] = {}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from .glass_handler import GlassHandler
|
||||
from .gps_service import GPSService
|
||||
from .rrdtool_handler import RRDToolHandler
|
||||
from .sqlite_handler import SQLiteHandler
|
||||
from .storage_collector import StorageCollector
|
||||
__all__ = ["SQLiteHandler", "RRDToolHandler", "StorageCollector", "GlassHandler"]
|
||||
__all__ = ["SQLiteHandler", "RRDToolHandler", "StorageCollector", "GlassHandler", "GPSService"]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+85
-1
@@ -11,6 +11,7 @@ from repeater.companion.utils import validate_companion_node_name, normalize_com
|
||||
from repeater.config import get_radio_for_board, load_config, save_config
|
||||
from repeater.config_manager import ConfigManager
|
||||
from repeater.data_acquisition.glass_handler import GlassHandler
|
||||
from repeater.data_acquisition.gps_service import GPSService
|
||||
from repeater.engine import RepeaterHandler
|
||||
from repeater.handler_helpers import (
|
||||
AdvertHelper,
|
||||
@@ -49,6 +50,7 @@ class RepeaterDaemon:
|
||||
self.path_helper = None
|
||||
self.protocol_request_helper = None
|
||||
self.glass_handler = None
|
||||
self.gps_service = None
|
||||
self.acl = None
|
||||
self.router = None
|
||||
self.companion_bridges: dict[int, object] = {}
|
||||
@@ -262,6 +264,16 @@ class RepeaterDaemon:
|
||||
)
|
||||
logger.info("Config manager initialized")
|
||||
|
||||
self.gps_service = GPSService(
|
||||
self.config,
|
||||
location_update_callback=self._update_repeater_location_from_gps,
|
||||
)
|
||||
self.gps_service.start()
|
||||
if self.config.get("gps", {}).get("enabled", False):
|
||||
logger.info("GPS diagnostics initialized")
|
||||
else:
|
||||
logger.info("GPS diagnostics disabled")
|
||||
|
||||
# Initialize text message helper with per-identity ACLs
|
||||
self.text_helper = TextHelper(
|
||||
identity_manager=self.identity_manager,
|
||||
@@ -917,6 +929,9 @@ class RepeaterDaemon:
|
||||
except Exception:
|
||||
stats["public_key"] = None
|
||||
|
||||
if self.gps_service:
|
||||
stats["gps"] = self.gps_service.get_summary()
|
||||
|
||||
return stats
|
||||
|
||||
async def _get_companion_stats(self, stats_type: int) -> dict:
|
||||
@@ -992,6 +1007,13 @@ class RepeaterDaemon:
|
||||
node_name = repeater_config.get("node_name", "Repeater")
|
||||
latitude = repeater_config.get("latitude", 0.0)
|
||||
longitude = repeater_config.get("longitude", 0.0)
|
||||
location_source = "config"
|
||||
|
||||
if self.gps_service:
|
||||
location = self.gps_service.get_repeater_location()
|
||||
latitude = location.get("latitude", latitude)
|
||||
longitude = location.get("longitude", longitude)
|
||||
location_source = str(location.get("source", location_source))
|
||||
|
||||
flags = ADVERT_FLAG_IS_REPEATER | ADVERT_FLAG_HAS_NAME
|
||||
|
||||
@@ -1014,13 +1036,68 @@ class RepeaterDaemon:
|
||||
self.repeater_handler.mark_seen(packet)
|
||||
logger.debug("Marked own advert as seen in duplicate cache")
|
||||
|
||||
logger.info(f"Sent flood advert '{node_name}' at ({latitude: .6f}, {longitude: .6f})")
|
||||
logger.info(
|
||||
"Sent flood advert '%s' at (% .6f, % .6f) source=%s",
|
||||
node_name,
|
||||
latitude,
|
||||
longitude,
|
||||
location_source,
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send advert: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
def _update_repeater_location_from_gps(self, location: dict) -> bool:
|
||||
"""Persist the latest valid GPS fix as the repeater's advertised location."""
|
||||
latitude = location.get("latitude")
|
||||
longitude = location.get("longitude")
|
||||
if latitude is None or longitude is None:
|
||||
return False
|
||||
|
||||
repeater_config = self.config.setdefault("repeater", {})
|
||||
current_latitude = repeater_config.get("latitude")
|
||||
current_longitude = repeater_config.get("longitude")
|
||||
try:
|
||||
if (
|
||||
current_latitude is not None
|
||||
and current_longitude is not None
|
||||
and abs(float(current_latitude) - float(latitude)) < 0.000001
|
||||
and abs(float(current_longitude) - float(longitude)) < 0.000001
|
||||
):
|
||||
return False
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
updates = {
|
||||
"repeater": {
|
||||
"latitude": float(latitude),
|
||||
"longitude": float(longitude),
|
||||
}
|
||||
}
|
||||
if self.config_manager:
|
||||
result = self.config_manager.update_and_save(
|
||||
updates=updates,
|
||||
live_update=True,
|
||||
live_update_sections=["repeater"],
|
||||
)
|
||||
if not result.get("success"):
|
||||
logger.warning(
|
||||
"GPS location fix could not update repeater config: %s",
|
||||
result.get("error", "unknown error"),
|
||||
)
|
||||
return False
|
||||
else:
|
||||
repeater_config.update(updates["repeater"])
|
||||
|
||||
logger.info(
|
||||
"Updated repeater location from GPS fix: latitude=%.6f longitude=%.6f",
|
||||
latitude,
|
||||
longitude,
|
||||
)
|
||||
return True
|
||||
|
||||
def _signal_shutdown(self, sig, loop):
|
||||
"""Handle SIGTERM/SIGINT by scheduling async shutdown."""
|
||||
if self._shutdown_started:
|
||||
@@ -1077,6 +1154,13 @@ class RepeaterDaemon:
|
||||
except Exception as e:
|
||||
logger.warning(f"Error stopping Glass handler: {e}")
|
||||
|
||||
# Stop GPS diagnostics.
|
||||
if self.gps_service:
|
||||
try:
|
||||
self.gps_service.stop()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error stopping GPS diagnostics: {e}")
|
||||
|
||||
# Close storage publishers (MQTT/LetsMesh) to stop their worker threads.
|
||||
try:
|
||||
if self.repeater_handler and self.repeater_handler.storage:
|
||||
|
||||
@@ -42,6 +42,8 @@ logger = logging.getLogger("HTTPServer")
|
||||
|
||||
# System
|
||||
# GET /api/stats - Get system statistics
|
||||
# GET /api/gps - Get local GPS diagnostics and parsed NMEA attributes
|
||||
# GET /api/gps_stream - GPS diagnostics SSE stream
|
||||
# GET /api/logs - Get system logs
|
||||
# GET /api/hardware_stats - Get hardware statistics
|
||||
# GET /api/hardware_processes - Get process information
|
||||
@@ -637,6 +639,134 @@ class APIEndpoints:
|
||||
logger.error(f"Error serving stats: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
def gps(self):
|
||||
"""Get full local GPS diagnostics and parsed NMEA attributes."""
|
||||
try:
|
||||
gps_service = getattr(self.daemon_instance, "gps_service", None)
|
||||
if gps_service:
|
||||
return self._success(gps_service.get_snapshot())
|
||||
|
||||
return self._success(
|
||||
{
|
||||
"enabled": False,
|
||||
"running": False,
|
||||
"source": self.config.get("gps", {}),
|
||||
"status": {
|
||||
"state": "disabled",
|
||||
"fix_valid": False,
|
||||
"stale": True,
|
||||
"age_seconds": None,
|
||||
"last_update": None,
|
||||
"last_error": "GPS service is not initialized",
|
||||
},
|
||||
"fix": {
|
||||
"valid": False,
|
||||
"status": None,
|
||||
"quality": None,
|
||||
"quality_label": "no fix",
|
||||
"gsa_fix_type": None,
|
||||
"gsa_fix_type_label": None,
|
||||
},
|
||||
"position": {
|
||||
"latitude": None,
|
||||
"longitude": None,
|
||||
"altitude_m": None,
|
||||
"geoid_separation_m": None,
|
||||
},
|
||||
"motion": {
|
||||
"speed_knots": None,
|
||||
"speed_kmh": None,
|
||||
"course_degrees": None,
|
||||
"magnetic_variation_degrees": None,
|
||||
},
|
||||
"accuracy": {"hdop": None, "pdop": None, "vdop": None},
|
||||
"time": {"utc_time": None, "date": None, "datetime_utc": None},
|
||||
"location_update": {
|
||||
"enabled": False,
|
||||
"state": "disabled",
|
||||
"last_attempt": None,
|
||||
"last_success": None,
|
||||
"last_error": None,
|
||||
"last_latitude": None,
|
||||
"last_longitude": None,
|
||||
"interval_seconds": None,
|
||||
},
|
||||
"satellites": {
|
||||
"used_count": None,
|
||||
"used_prns": [],
|
||||
"in_view_count": None,
|
||||
"in_view": [],
|
||||
"snr": {"min": None, "max": None, "avg": None},
|
||||
},
|
||||
"nmea": {
|
||||
"last_sentence": None,
|
||||
"last_sentence_type": None,
|
||||
"last_talker": None,
|
||||
"seen_sentence_types": [],
|
||||
"sentence_counters": {},
|
||||
"valid_checksum_count": 0,
|
||||
"invalid_checksum_count": 0,
|
||||
"missing_checksum_count": 0,
|
||||
"recent_sentences": [],
|
||||
},
|
||||
"raw_attributes": {},
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error serving GPS diagnostics: {e}", exc_info=True)
|
||||
return self._error(e)
|
||||
|
||||
@cherrypy.expose
|
||||
def gps_stream(self):
|
||||
"""Server-Sent Events stream for GPS diagnostics snapshots."""
|
||||
cherrypy.response.headers["Content-Type"] = "text/event-stream"
|
||||
cherrypy.response.headers["Cache-Control"] = "no-cache"
|
||||
cherrypy.response.headers["Connection"] = "keep-alive"
|
||||
|
||||
def generate():
|
||||
last_snapshot_json: Optional[str] = None
|
||||
last_keepalive = time.time()
|
||||
|
||||
try:
|
||||
yield (
|
||||
f"data: {json.dumps({'type': 'connected', 'message': 'Connected to GPS stream'})}"
|
||||
"\n\n"
|
||||
)
|
||||
|
||||
while True:
|
||||
response = self.gps()
|
||||
if response.get("success"):
|
||||
snapshot = response.get("data")
|
||||
snapshot_json = json.dumps(snapshot, sort_keys=True, default=str)
|
||||
|
||||
if snapshot_json != last_snapshot_json:
|
||||
yield (
|
||||
f"data: {json.dumps({'type': 'snapshot', 'data': snapshot})}"
|
||||
"\n\n"
|
||||
)
|
||||
last_snapshot_json = snapshot_json
|
||||
last_keepalive = time.time()
|
||||
elif (time.time() - last_keepalive) >= 15:
|
||||
yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
|
||||
last_keepalive = time.time()
|
||||
else:
|
||||
yield (
|
||||
f"data: {json.dumps({'type': 'error', 'error': response.get('error', 'GPS stream error')})}"
|
||||
"\n\n"
|
||||
)
|
||||
|
||||
time.sleep(1.0)
|
||||
except GeneratorExit:
|
||||
logger.debug("GPS SSE stream closed by client")
|
||||
except Exception as exc:
|
||||
logger.error(f"GPS SSE stream error: {exc}", exc_info=True)
|
||||
|
||||
return generate()
|
||||
|
||||
gps_stream._cp_config = {"response.stream": True}
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
def send_advert(self):
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{dt as e,g as t,l as n,pt as r,s as i,u as a,w as o}from"./runtime-core.esm-bundler-HnidnMFy.js";import{h as s}from"./index-BFltqMtv.js";var c={class:`flex items-center justify-between mb-4`},l={class:`text-xl font-semibold text-content-primary dark:text-content-primary`},u={class:`mb-6`},d={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},f={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},p={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},m={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},h={class:`flex gap-3`},g=t({__name:`ConfirmDialog`,props:{show:{type:Boolean},title:{default:`Confirm Action`},message:{},confirmText:{default:`Confirm`},cancelText:{default:`Cancel`},variant:{default:`warning`}},emits:[`close`,`confirm`],setup(t,{emit:g}){let _=t,v=g,y=e=>{e.target===e.currentTarget&&v(`close`)},b={danger:`bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400`,warning:`bg-yellow-100 dark:bg-yellow-500/20 border-yellow-500/30 text-yellow-600 dark:text-yellow-400`,info:`bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400`},x={danger:`bg-red-500 hover:bg-red-600`,warning:`bg-yellow-500 hover:bg-yellow-600`,info:`bg-blue-500 hover:bg-blue-600`};return(t,g)=>_.show?(o(),a(`div`,{key:0,onClick:y,class:`fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4`,style:{"backdrop-filter":`blur(8px) saturate(180%)`,position:`fixed`,top:`0`,left:`0`,right:`0`,bottom:`0`}},[i(`div`,{class:`bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10`,onClick:g[3]||=s(()=>{},[`stop`])},[i(`div`,c,[i(`h3`,l,r(_.title),1),i(`button`,{onClick:g[0]||=e=>v(`close`),class:`text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors`},[...g[4]||=[i(`svg`,{class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])]),i(`div`,u,[i(`div`,{class:e([`inline-flex p-3 rounded-xl mb-4`,b[_.variant]])},[_.variant===`danger`?(o(),a(`svg`,d,[...g[5]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z`},null,-1)]])):_.variant===`warning`?(o(),a(`svg`,f,[...g[6]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z`},null,-1)]])):(o(),a(`svg`,p,[...g[7]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`},null,-1)]]))],2),i(`p`,m,r(_.message),1)]),i(`div`,h,[i(`button`,{onClick:g[1]||=e=>v(`close`),class:`flex-1 px-4 py-3 rounded-xl bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary transition-all duration-200 border border-stroke-subtle dark:border-stroke/10`},r(_.cancelText),1),i(`button`,{onClick:g[2]||=e=>v(`confirm`),class:e([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,x[_.variant]])},r(_.confirmText),3)])])])):n(``,!0)}});export{g as t};
|
||||
import{dt as e,g as t,l as n,pt as r,s as i,u as a,w as o}from"./runtime-core.esm-bundler-HnidnMFy.js";import{h as s}from"./index-ZOJT60Eu.js";var c={class:`flex items-center justify-between mb-4`},l={class:`text-xl font-semibold text-content-primary dark:text-content-primary`},u={class:`mb-6`},d={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},f={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},p={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},m={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},h={class:`flex gap-3`},g=t({__name:`ConfirmDialog`,props:{show:{type:Boolean},title:{default:`Confirm Action`},message:{},confirmText:{default:`Confirm`},cancelText:{default:`Cancel`},variant:{default:`warning`}},emits:[`close`,`confirm`],setup(t,{emit:g}){let _=t,v=g,y=e=>{e.target===e.currentTarget&&v(`close`)},b={danger:`bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400`,warning:`bg-yellow-100 dark:bg-yellow-500/20 border-yellow-500/30 text-yellow-600 dark:text-yellow-400`,info:`bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400`},x={danger:`bg-red-500 hover:bg-red-600`,warning:`bg-yellow-500 hover:bg-yellow-600`,info:`bg-blue-500 hover:bg-blue-600`};return(t,g)=>_.show?(o(),a(`div`,{key:0,onClick:y,class:`fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4`,style:{"backdrop-filter":`blur(8px) saturate(180%)`,position:`fixed`,top:`0`,left:`0`,right:`0`,bottom:`0`}},[i(`div`,{class:`bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10`,onClick:g[3]||=s(()=>{},[`stop`])},[i(`div`,c,[i(`h3`,l,r(_.title),1),i(`button`,{onClick:g[0]||=e=>v(`close`),class:`text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors`},[...g[4]||=[i(`svg`,{class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])]),i(`div`,u,[i(`div`,{class:e([`inline-flex p-3 rounded-xl mb-4`,b[_.variant]])},[_.variant===`danger`?(o(),a(`svg`,d,[...g[5]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z`},null,-1)]])):_.variant===`warning`?(o(),a(`svg`,f,[...g[6]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z`},null,-1)]])):(o(),a(`svg`,p,[...g[7]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`},null,-1)]]))],2),i(`p`,m,r(_.message),1)]),i(`div`,h,[i(`button`,{onClick:g[1]||=e=>v(`close`),class:`flex-1 px-4 py-3 rounded-xl bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary transition-all duration-200 border border-stroke-subtle dark:border-stroke/10`},r(_.cancelText),1),i(`button`,{onClick:g[2]||=e=>v(`confirm`),class:e([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,x[_.variant]])},r(_.confirmText),3)])])])):n(``,!0)}});export{g as t};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.globe-stage[data-v-ee1ed5b6]{border:1px solid var(--color-border-subtle);background:radial-gradient(circle at 50% 46%, color-mix(in srgb, var(--color-primary) 24%, transparent), transparent 34%), radial-gradient(circle at 48% 50%, #ffffff24, transparent 20%), linear-gradient(145deg, var(--color-background-soft), var(--color-background));cursor:grab;touch-action:none;border-radius:10px;min-height:330px;position:relative;overflow:hidden}.globe-stage[data-v-ee1ed5b6]:active{cursor:grabbing}.globe-stage canvas[data-v-ee1ed5b6]{width:100%;height:330px;display:block}.globe-tooltip[data-v-ee1ed5b6]{z-index:2;color:#fff;pointer-events:none;background:#050a0ceb;border:1px solid #ffffff2e;border-radius:12px;min-width:154px;padding:10px 11px;position:absolute;transform:translate(-50%,calc(-100% - 22px));box-shadow:0 14px 40px #0000005c}.tooltip-title[data-v-ee1ed5b6]{color:var(--color-accent-green);letter-spacing:.06em;text-transform:uppercase;font-size:.82rem;font-weight:800}.tooltip-grid[data-v-ee1ed5b6]{grid-template-columns:auto 1fr;gap:4px 10px;margin-top:7px;font-size:.78rem;display:grid}.tooltip-key[data-v-ee1ed5b6]{color:#ffffffa8}.tooltip-value[data-v-ee1ed5b6]{color:#fff;text-align:right;font-weight:700}.globe-fallback[data-v-ee1ed5b6]{padding:18px;position:absolute;inset:0}.fallback-sky[data-v-ee1ed5b6]{aspect-ratio:1;border:1px solid color-mix(in srgb, var(--color-primary) 34%, var(--color-border-subtle));background:radial-gradient(circle, color-mix(in srgb, var(--color-primary) 18%, transparent) 0 2px, transparent 3px), repeating-radial-gradient(circle, transparent 0 31%, color-mix(in srgb, var(--color-border) 70%, transparent) 31.5% 32%, transparent 32.5% 49%), linear-gradient(90deg, transparent 49.7%, color-mix(in srgb, var(--color-border) 78%, transparent) 49.7% 50.3%, transparent 50.3%), linear-gradient(0deg, transparent 49.7%, color-mix(in srgb, var(--color-border) 78%, transparent) 49.7% 50.3%, transparent 50.3%);border-radius:50%;width:min(250px,72vw);margin:0 auto;position:relative}.fallback-sat[data-v-ee1ed5b6]{width:var(--size);height:var(--size);background:var(--color-primary);box-shadow:0 0 16px color-mix(in srgb, var(--color-primary) 70%, transparent);border-radius:999px;position:absolute;transform:translate(-50%,-50%)}.fallback-sat-used[data-v-ee1ed5b6]{background:var(--color-accent-green);box-shadow:0 0 16px color-mix(in srgb, var(--color-accent-green) 70%, transparent)}.fallback-sat span[data-v-ee1ed5b6]{color:var(--color-text-primary);white-space:nowrap;font-size:.65rem;font-weight:700;position:absolute;top:calc(100% + 4px);left:50%;transform:translate(-50%)}.sky-empty[data-v-ee1ed5b6]{color:var(--color-text-muted);pointer-events:none;place-items:center;font-size:.875rem;display:grid;position:absolute;inset:0}.sat-row[data-v-ee1ed5b6]{opacity:1;transition:opacity .6s,color .4s}.sat-row-stale[data-v-ee1ed5b6]{opacity:.35}.page-tabs[data-v-ee1ed5b6]{border-bottom:1px solid var(--color-border-subtle,#0000001a);gap:2px;padding-bottom:0;display:flex}.page-tab[data-v-ee1ed5b6]{color:var(--color-text-muted,#888);cursor:pointer;background:0 0;border:none;border-bottom:2px solid #0000;margin-bottom:-1px;padding:8px 18px;font-size:.875rem;font-weight:600;transition:color .18s,border-color .18s}.page-tab[data-v-ee1ed5b6]:hover{color:var(--color-text-primary,#fff)}.page-tab-active[data-v-ee1ed5b6]{color:var(--color-primary,#aae8e8);border-bottom-color:var(--color-primary,#aae8e8)}.inner-tabs[data-v-ee1ed5b6]{border:1px solid var(--color-border-subtle,#ffffff1a);background:#ffffff0a;border-radius:8px;gap:2px;padding:2px;display:flex}.inner-tab[data-v-ee1ed5b6]{color:var(--color-text-muted,#888);cursor:pointer;background:0 0;border:none;border-radius:6px;padding:4px 12px;font-size:.75rem;font-weight:600;transition:background .15s,color .15s}.inner-tab[data-v-ee1ed5b6]:hover{color:var(--color-text-primary,#fff)}.inner-tab-active[data-v-ee1ed5b6]{background:var(--color-primary,#aae8e8);color:#000}.accordion-header[data-v-ee1ed5b6]{cursor:pointer;text-align:left;background:0 0;border:none;justify-content:space-between;align-items:center;width:100%;padding:14px 20px;font-size:.9rem;transition:background .15s;display:flex}.accordion-header[data-v-ee1ed5b6]:hover{background:#ffffff0a}.accordion-chevron[data-v-ee1ed5b6]{color:var(--color-text-muted,#888);flex-shrink:0;transition:transform .2s}.accordion-chevron-open[data-v-ee1ed5b6]{transform:rotate(180deg)}.accordion-body[data-v-ee1ed5b6]{padding:0 20px 16px}
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{dt as e,g as t,l as n,pt as r,s as i,u as a,w as o}from"./runtime-core.esm-bundler-HnidnMFy.js";import{h as s}from"./index-BFltqMtv.js";var c={class:`mb-6`},l={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},u={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},d={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},f={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},p={class:`flex`},m=t({__name:`MessageDialog`,props:{show:{type:Boolean},message:{},variant:{default:`success`}},emits:[`close`],setup(t,{emit:m}){let h=t,g=m,_=e=>{e.target===e.currentTarget&&g(`close`)},v={success:`bg-green-100 dark:bg-green-500/20 border-green-600/40 dark:border-green-500/30 text-green-600 dark:text-green-400`,error:`bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400`,info:`bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400`},y={success:`bg-green-500 hover:bg-green-600`,error:`bg-red-500 hover:bg-red-600`,info:`bg-blue-500 hover:bg-blue-600`};return(t,m)=>h.show?(o(),a(`div`,{key:0,onClick:_,class:`fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4`,style:{"backdrop-filter":`blur(8px) saturate(180%)`,position:`fixed`,top:`0`,left:`0`,right:`0`,bottom:`0`}},[i(`div`,{class:`bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10`,onClick:m[1]||=s(()=>{},[`stop`])},[i(`div`,c,[i(`div`,{class:e([`inline-flex p-3 rounded-xl mb-4`,v[h.variant]])},[h.variant===`success`?(o(),a(`svg`,l,[...m[2]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M5 13l4 4L19 7`},null,-1)]])):h.variant===`error`?(o(),a(`svg`,u,[...m[3]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`},null,-1)]])):(o(),a(`svg`,d,[...m[4]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`},null,-1)]]))],2),i(`p`,f,r(h.message),1)]),i(`div`,p,[i(`button`,{onClick:m[0]||=e=>g(`close`),class:e([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,y[h.variant]])},` OK `,2)])])])):n(``,!0)}});export{m as t};
|
||||
import{dt as e,g as t,l as n,pt as r,s as i,u as a,w as o}from"./runtime-core.esm-bundler-HnidnMFy.js";import{h as s}from"./index-ZOJT60Eu.js";var c={class:`mb-6`},l={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},u={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},d={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},f={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},p={class:`flex`},m=t({__name:`MessageDialog`,props:{show:{type:Boolean},message:{},variant:{default:`success`}},emits:[`close`],setup(t,{emit:m}){let h=t,g=m,_=e=>{e.target===e.currentTarget&&g(`close`)},v={success:`bg-green-100 dark:bg-green-500/20 border-green-600/40 dark:border-green-500/30 text-green-600 dark:text-green-400`,error:`bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400`,info:`bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400`},y={success:`bg-green-500 hover:bg-green-600`,error:`bg-red-500 hover:bg-red-600`,info:`bg-blue-500 hover:bg-blue-600`};return(t,m)=>h.show?(o(),a(`div`,{key:0,onClick:_,class:`fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4`,style:{"backdrop-filter":`blur(8px) saturate(180%)`,position:`fixed`,top:`0`,left:`0`,right:`0`,bottom:`0`}},[i(`div`,{class:`bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10`,onClick:m[1]||=s(()=>{},[`stop`])},[i(`div`,c,[i(`div`,{class:e([`inline-flex p-3 rounded-xl mb-4`,v[h.variant]])},[h.variant===`success`?(o(),a(`svg`,l,[...m[2]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M5 13l4 4L19 7`},null,-1)]])):h.variant===`error`?(o(),a(`svg`,u,[...m[3]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`},null,-1)]])):(o(),a(`svg`,d,[...m[4]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`},null,-1)]]))],2),i(`p`,f,r(h.message),1)]),i(`div`,p,[i(`button`,{onClick:m[0]||=e=>g(`close`),class:e([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,y[h.variant]])},` OK `,2)])])])):n(``,!0)}});export{m as t};
|
||||
+3
-3
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{n as e}from"./index-ZOJT60Eu.js";export{e as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{n as e}from"./index-BFltqMtv.js";export{e as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{t as e}from"./packets-C-dzvp0W.js";export{e as usePacketStore};
|
||||
@@ -0,0 +1 @@
|
||||
import{t as e}from"./packets-Dk_NHrT7.js";export{e as usePacketStore};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{t as e}from"./system-BH4r-ii6.js";export{e as useSystemStore};
|
||||
@@ -0,0 +1 @@
|
||||
import{t as e}from"./system-qmDsA3-W.js";export{e as useSystemStore};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as e,z as t}from"./runtime-core.esm-bundler-HnidnMFy.js";import{o as n}from"./vue-router-Cr0wB7EX.js";import{t as r}from"./api-CbM6k1ZB.js";var i=n(`system`,()=>{let n=t(null),i=t(!1),a=t(null),o=t(null),s=t(`forward`),c=t(!0),l=t(0),u=t(10),d=t(!1),f=e(()=>n.value?.config?.node_name??`Unknown`),p=e(()=>{let e=n.value?.public_key;return!e||e===`Unknown`?`Unknown`:e.length>=16?`${e.slice(0,8)} ... ${e.slice(-8)}`:`${e}`}),m=e(()=>n.value!==null),h=e(()=>n.value?.version??`Unknown`),g=e(()=>n.value?.core_version??`Unknown`),_=e(()=>n.value?.noise_floor_dbm??null),v=e(()=>u.value>0?Math.min(l.value/u.value*100,100):0),y=e(()=>s.value===`no_tx`?{text:`No TX`,title:`No repeat, no local TX; adverts skipped`}:s.value===`monitor`?{text:`Monitor Mode`,title:`Monitoring only - not forwarding packets`}:c.value?{text:`Active`,title:`Forwarding with duty cycle enforcement`}:{text:`No Limits`,title:`Forwarding without duty cycle enforcement`}),b=e(()=>({mode:s.value})),x=e(()=>c.value?{active:!0,warning:!1}:{active:!1,warning:!0}),S=e=>{d.value=e};async function C(){try{i.value=!0,a.value=null;let e=await r.get(`/stats`);if(e.success&&e.data)return n.value=e.data,o.value=new Date,w(e.data),e.data;if(e&&`version`in e){let t=e;return n.value=t,o.value=new Date,w(t),t}else throw Error(e.error||`Failed to fetch stats`)}catch(e){throw a.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error fetching stats:`,e),e}finally{i.value=!1}}function w(e){if(e.config){let t=e.config.repeater?.mode;t===`forward`||t===`monitor`||t===`no_tx`?s.value=t:t!==void 0&&(s.value=`forward`);let n=e.config.duty_cycle;if(n){c.value=n.enforcement_enabled!==!1;let e=n.max_airtime_percent;typeof e==`number`?u.value=e:e&&typeof e==`object`&&`parsedValue`in e&&(u.value=e.parsedValue||10)}}let t=e.utilization_percent;typeof t==`number`?l.value=t:t&&typeof t==`object`&&`parsedValue`in t&&(l.value=t.parsedValue||0)}async function T(e){try{let t=await r.post(`/set_mode`,{mode:e});if(t.success)return s.value=e,!0;throw Error(t.error||`Failed to set mode`)}catch(e){throw a.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error setting mode:`,e),e}}async function E(e){try{let t=await r.post(`/set_duty_cycle`,{enabled:e});if(t.success)return c.value=e,!0;throw Error(t.error||`Failed to set duty cycle`)}catch(e){throw a.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error setting duty cycle:`,e),e}}async function D(){try{let e=await r.post(`/send_advert`,{},{timeout:1e4});if(e.success)return!0;throw Error(e.error||`Failed to send advert`)}catch(e){throw a.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error sending advert:`,e),e}}async function O(){return await E(!c.value)}function k(e){n.value?(e.uptime_seconds!==void 0&&(n.value.uptime_seconds=e.uptime_seconds),e.noise_floor_dbm!==void 0&&(n.value.noise_floor_dbm=e.noise_floor_dbm)):n.value=e,o.value=new Date,w(e)}async function A(e=5e3,t=!1){t||await C();let n=null;return t||(n=setInterval(async()=>{try{await C()}catch(e){console.error(`Auto-refresh error:`,e)}},e)),()=>{n&&clearInterval(n)}}function j(){n.value=null,a.value=null,o.value=null,i.value=!1,s.value=`forward`,c.value=!0,l.value=0,u.value=10}return{stats:n,isLoading:i,error:a,lastUpdated:o,currentMode:s,dutyCycleEnabled:c,dutyCycleUtilization:l,dutyCycleMax:u,cadCalibrationRunning:d,nodeName:f,pubKey:p,hasStats:m,version:h,coreVersion:g,noiseFloorDbm:_,dutyCyclePercentage:v,statusBadge:y,modeButtonState:b,dutyCycleButtonState:x,fetchStats:C,setMode:T,setDutyCycle:E,sendAdvert:D,toggleDutyCycle:O,startAutoRefresh:A,updateRealtimeStats:k,reset:j,setCadCalibrationRunning:S}});export{i as t};
|
||||
import{o as e,z as t}from"./runtime-core.esm-bundler-HnidnMFy.js";import{o as n}from"./vue-router-Cr0wB7EX.js";import{t as r}from"./api-DdIgU01d.js";var i=n(`system`,()=>{let n=t(null),i=t(!1),a=t(null),o=t(null),s=t(`forward`),c=t(!0),l=t(0),u=t(10),d=t(!1),f=e(()=>n.value?.config?.node_name??`Unknown`),p=e(()=>{let e=n.value?.public_key;return!e||e===`Unknown`?`Unknown`:e.length>=16?`${e.slice(0,8)} ... ${e.slice(-8)}`:`${e}`}),m=e(()=>n.value!==null),h=e(()=>n.value?.version??`Unknown`),g=e(()=>n.value?.core_version??`Unknown`),_=e(()=>n.value?.noise_floor_dbm??null),v=e(()=>u.value>0?Math.min(l.value/u.value*100,100):0),y=e(()=>s.value===`no_tx`?{text:`No TX`,title:`No repeat, no local TX; adverts skipped`}:s.value===`monitor`?{text:`Monitor Mode`,title:`Monitoring only - not forwarding packets`}:c.value?{text:`Active`,title:`Forwarding with duty cycle enforcement`}:{text:`No Limits`,title:`Forwarding without duty cycle enforcement`}),b=e(()=>({mode:s.value})),x=e(()=>c.value?{active:!0,warning:!1}:{active:!1,warning:!0}),S=e=>{d.value=e};async function C(){try{i.value=!0,a.value=null;let e=await r.get(`/stats`);if(e.success&&e.data)return n.value=e.data,o.value=new Date,w(e.data),e.data;if(e&&`version`in e){let t=e;return n.value=t,o.value=new Date,w(t),t}else throw Error(e.error||`Failed to fetch stats`)}catch(e){throw a.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error fetching stats:`,e),e}finally{i.value=!1}}function w(e){if(e.config){let t=e.config.repeater?.mode;t===`forward`||t===`monitor`||t===`no_tx`?s.value=t:t!==void 0&&(s.value=`forward`);let n=e.config.duty_cycle;if(n){c.value=n.enforcement_enabled!==!1;let e=n.max_airtime_percent;typeof e==`number`?u.value=e:e&&typeof e==`object`&&`parsedValue`in e&&(u.value=e.parsedValue||10)}}let t=e.utilization_percent;typeof t==`number`?l.value=t:t&&typeof t==`object`&&`parsedValue`in t&&(l.value=t.parsedValue||0)}async function T(e){try{let t=await r.post(`/set_mode`,{mode:e});if(t.success)return s.value=e,!0;throw Error(t.error||`Failed to set mode`)}catch(e){throw a.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error setting mode:`,e),e}}async function E(e){try{let t=await r.post(`/set_duty_cycle`,{enabled:e});if(t.success)return c.value=e,!0;throw Error(t.error||`Failed to set duty cycle`)}catch(e){throw a.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error setting duty cycle:`,e),e}}async function D(){try{let e=await r.post(`/send_advert`,{},{timeout:1e4});if(e.success)return!0;throw Error(e.error||`Failed to send advert`)}catch(e){throw a.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error sending advert:`,e),e}}async function O(){return await E(!c.value)}function k(e){n.value?(e.uptime_seconds!==void 0&&(n.value.uptime_seconds=e.uptime_seconds),e.noise_floor_dbm!==void 0&&(n.value.noise_floor_dbm=e.noise_floor_dbm)):n.value=e,o.value=new Date,w(e)}async function A(e=5e3,t=!1){t||await C();let n=null;return t||(n=setInterval(async()=>{try{await C()}catch(e){console.error(`Auto-refresh error:`,e)}},e)),()=>{n&&clearInterval(n)}}function j(){n.value=null,a.value=null,o.value=null,i.value=!1,s.value=`forward`,c.value=!0,l.value=0,u.value=10}return{stats:n,isLoading:i,error:a,lastUpdated:o,currentMode:s,dutyCycleEnabled:c,dutyCycleUtilization:l,dutyCycleMax:u,cadCalibrationRunning:d,nodeName:f,pubKey:p,hasStats:m,version:h,coreVersion:g,noiseFloorDbm:_,dutyCyclePercentage:v,statusBadge:y,modeButtonState:b,dutyCycleButtonState:x,fetchStats:C,setMode:T,setDutyCycle:E,sendAdvert:D,toggleDutyCycle:O,startAutoRefresh:A,updateRealtimeStats:k,reset:j,setCadCalibrationRunning:S}});export{i as t};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as e}from"./runtime-core.esm-bundler-HnidnMFy.js";import{t}from"./system-BH4r-ii6.js";var n={7:-7.5,8:-10,9:-12.5,10:-15,11:-17.5,12:-20},r=-116,i=8,a=5;function o(e,t){return e-t}function s(e){return n[e]??n[i]}function c(e,t){let n=t+a;if(e<=t){let n=e<=t-5?0:1;return{bars:n,color:`text-red-600 dark:text-red-400`,snr:e,quality:n===0?`none`:`poor`}}if(e<n){let n=(e-t)/a<.5?2:3;return{bars:n,color:n===2?`text-orange-600 dark:text-orange-400`:`text-yellow-600 dark:text-yellow-400`,snr:e,quality:`fair`}}let r=e-n>=10?5:4;return{bars:r,color:r===5?`text-green-600 dark:text-green-400`:`text-green-600 dark:text-green-300`,snr:e,quality:r===5?`excellent`:`good`}}function l(){let n=t(),a=e(()=>n.noiseFloorDbm??r),l=e(()=>n.stats?.config?.radio?.spreading_factor??i),u=e(()=>s(l.value));return{getSignalQuality:e=>{if(!e||e>0||e<-120)return{bars:0,color:`text-gray-400 dark:text-gray-500`,snr:-999,quality:`none`};let t=o(e,a.value);return c(Math.max(-30,Math.min(20,t)),u.value)},noiseFloor:a,spreadingFactor:l,minSNR:u}}export{l as t};
|
||||
import{o as e}from"./runtime-core.esm-bundler-HnidnMFy.js";import{t}from"./system-qmDsA3-W.js";var n={7:-7.5,8:-10,9:-12.5,10:-15,11:-17.5,12:-20},r=-116,i=8,a=5;function o(e,t){return e-t}function s(e){return n[e]??n[i]}function c(e,t){let n=t+a;if(e<=t){let n=e<=t-5?0:1;return{bars:n,color:`text-red-600 dark:text-red-400`,snr:e,quality:n===0?`none`:`poor`}}if(e<n){let n=(e-t)/a<.5?2:3;return{bars:n,color:n===2?`text-orange-600 dark:text-orange-400`:`text-yellow-600 dark:text-yellow-400`,snr:e,quality:`fair`}}let r=e-n>=10?5:4;return{bars:r,color:r===5?`text-green-600 dark:text-green-400`:`text-green-600 dark:text-green-300`,snr:e,quality:r===5?`excellent`:`good`}}function l(){let n=t(),a=e(()=>n.noiseFloorDbm??r),l=e(()=>n.stats?.config?.radio?.spreading_factor??i),u=e(()=>s(l.value));return{getSignalQuality:e=>{if(!e||e>0||e<-120)return{bars:0,color:`text-gray-400 dark:text-gray-500`,snr:-999,quality:`none`};let t=o(e,a.value);return c(Math.max(-30,Math.min(20,t)),u.value)},noiseFloor:a,spreadingFactor:l,minSNR:u}}export{l as t};
|
||||
@@ -0,0 +1 @@
|
||||
import{t as e}from"./websocket-jaT0AMJh.js";export{e as useWebSocketStore};
|
||||
@@ -1 +0,0 @@
|
||||
import{t as e}from"./websocket-nXR7EYbj.js";export{e as useWebSocketStore};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as e,z as t}from"./runtime-core.esm-bundler-HnidnMFy.js";import{o as n}from"./vue-router-Cr0wB7EX.js";import{c as r,d as i,r as a,s as o}from"./api-CbM6k1ZB.js";import{t as s}from"./system-BH4r-ii6.js";import{t as c}from"./packets-C-dzvp0W.js";var l=n(`websocket`,()=>{let n=t(null),l=t(`idle`),u=t(0),d=t(Date.now()),f=t(null),p=t(null),m=t(!1),h=t(!1),g=t(!1),_=t({visible:!1,message:``,variant:`info`}),v=null,y=c(),b=s(),x=a(),S=e(()=>l.value===`open`);function C(e,t,n=0){v!==null&&(clearTimeout(v),v=null),_.value={visible:!0,message:e,variant:t},n>0&&(v=window.setTimeout(()=>{w()},n))}function w(){v!==null&&(clearTimeout(v),v=null),_.value.visible=!1}function T(){f.value!==null&&(clearTimeout(f.value),f.value=null)}function E(){p.value!==null&&(clearInterval(p.value),p.value=null)}function D(){C(`Reconnecting...`,`info`)}function O(){let e=r();return!m.value&&!h.value&&!!e&&!i()&&x.canMaintainConnections}function k(){let e,t=r(),n=o(),i=new URLSearchParams;return t&&i.set(`token`,t),n&&i.set(`client_id`,n),e=`${window.location.protocol===`https:`?`wss:`:`ws:`}//${``?.trim()?new URL(``).host:window.location.host}/ws/packets?${i.toString()}`,e}async function A(){await Promise.allSettled([b.fetchStats(),y.fetchSystemStats(),y.fetchPacketStats({hours:24}),y.fetchRecentPackets({limit:100}),y.initializeSparklineHistory()])}function j(e=!1){E(),n.value&&e&&(n.value.onopen=null,n.value.onmessage=null,n.value.onerror=null,n.value.onclose=null)}function M(){if(T(),!O()){l.value=`closed`;return}if(u.value>=6){l.value=`closed`,C(`Connection lost`,`error`,5e3);return}l.value=`reconnecting`,D();let e=Math.min(1e3*2**u.value,3e4);u.value+=1,f.value=window.setTimeout(()=>{f.value=null,N(!0)},e)}function N(e=!1){if(!O()||n.value?.readyState===WebSocket.OPEN||n.value?.readyState===WebSocket.CONNECTING)return;T(),j(!0),l.value=e||u.value>0||g.value?`reconnecting`:`connecting`,g.value&&D();let t=new WebSocket(k());n.value=t,t.onopen=()=>{l.value=`open`,d.value=Date.now();let e=u.value>0||g.value;u.value=0,g.value=!1,E(),p.value=window.setInterval(()=>{n.value?.readyState===WebSocket.OPEN&&(n.value.send(JSON.stringify({type:`ping`})),Date.now()-d.value>6e4&&(j(!0),n.value?.close()))},3e4),A(),e?C(`Back online`,`success`,2500):w()},t.onmessage=e=>{try{let t=JSON.parse(e.data);t.type===`packet`?y.addRealtimePacket(t.data):t.type===`stats`?(t.data?.packet_stats&&y.updateRealtimeStats({packet_stats:t.data.packet_stats}),t.data?.system_stats&&b.updateRealtimeStats(t.data.system_stats)):t.type===`packet_stats`?y.updateRealtimeStats(t.data):t.type===`system_stats`?b.updateRealtimeStats(t.data):(t.type===`pong`||t.type===`ping`)&&(d.value=Date.now(),t.type===`ping`&&n.value?.readyState===WebSocket.OPEN&&n.value.send(JSON.stringify({type:`pong`})))}catch(e){console.error(`[WebSocket] Parse error:`,e)}},t.onerror=()=>{l.value=u.value>0?`reconnecting`:`closed`},t.onclose=e=>{let t=n.value;if(j(),t===n.value&&(n.value=null),m.value||h.value){l.value=`closed`;return}if(e.code===1008||e.code===4001||e.code===4003){x.handleAuthFailure(`expired`);return}M()}}function P(e=`lifecycle`){if(h.value=!0,T(),l.value=`closed`,e===`offline`?(g.value=!0,C(`Connection lost`,`error`,4e3)):e===`hidden`?(g.value=!0,w()):e===`logout`&&(g.value=!1,w()),n.value){let e=n.value;n.value=null,j(!0),e.close()}}function F(){m.value=!1,h.value=!1}function I(e={}){m.value=e.preventReconnect??m.value,e.silent||w(),P(e.preventReconnect?`logout`:`lifecycle`),u.value=0}return{isConnected:S,connectionState:l,reconnectAttempts:u,snackbar:_,connect:N,disconnect:I,pause:P,allowReconnect:F,hideSnackbar:w,resyncData:A}});export{l as t};
|
||||
import{o as e,z as t}from"./runtime-core.esm-bundler-HnidnMFy.js";import{o as n}from"./vue-router-Cr0wB7EX.js";import{c as r,d as i,r as a,s as o}from"./api-DdIgU01d.js";import{t as s}from"./system-qmDsA3-W.js";import{t as c}from"./packets-Dk_NHrT7.js";var l=n(`websocket`,()=>{let n=t(null),l=t(`idle`),u=t(0),d=t(Date.now()),f=t(null),p=t(null),m=t(!1),h=t(!1),g=t(!1),_=t({visible:!1,message:``,variant:`info`}),v=null,y=c(),b=s(),x=a(),S=e(()=>l.value===`open`);function C(e,t,n=0){v!==null&&(clearTimeout(v),v=null),_.value={visible:!0,message:e,variant:t},n>0&&(v=window.setTimeout(()=>{w()},n))}function w(){v!==null&&(clearTimeout(v),v=null),_.value.visible=!1}function T(){f.value!==null&&(clearTimeout(f.value),f.value=null)}function E(){p.value!==null&&(clearInterval(p.value),p.value=null)}function D(){C(`Reconnecting...`,`info`)}function O(){let e=r();return!m.value&&!h.value&&!!e&&!i()&&x.canMaintainConnections}function k(){let e,t=r(),n=o(),i=new URLSearchParams;return t&&i.set(`token`,t),n&&i.set(`client_id`,n),e=`${window.location.protocol===`https:`?`wss:`:`ws:`}//${``?.trim()?new URL(``).host:window.location.host}/ws/packets?${i.toString()}`,e}async function A(){await Promise.allSettled([b.fetchStats(),y.fetchSystemStats(),y.fetchPacketStats({hours:24}),y.fetchRecentPackets({limit:100}),y.initializeSparklineHistory()])}function j(e=!1){E(),n.value&&e&&(n.value.onopen=null,n.value.onmessage=null,n.value.onerror=null,n.value.onclose=null)}function M(){if(T(),!O()){l.value=`closed`;return}if(u.value>=6){l.value=`closed`,C(`Connection lost`,`error`,5e3);return}l.value=`reconnecting`,D();let e=Math.min(1e3*2**u.value,3e4);u.value+=1,f.value=window.setTimeout(()=>{f.value=null,N(!0)},e)}function N(e=!1){if(!O()||n.value?.readyState===WebSocket.OPEN||n.value?.readyState===WebSocket.CONNECTING)return;T(),j(!0),l.value=e||u.value>0||g.value?`reconnecting`:`connecting`,g.value&&D();let t=new WebSocket(k());n.value=t,t.onopen=()=>{l.value=`open`,d.value=Date.now();let e=u.value>0||g.value;u.value=0,g.value=!1,E(),p.value=window.setInterval(()=>{n.value?.readyState===WebSocket.OPEN&&(n.value.send(JSON.stringify({type:`ping`})),Date.now()-d.value>6e4&&(j(!0),n.value?.close()))},3e4),A(),e?C(`Back online`,`success`,2500):w()},t.onmessage=e=>{try{let t=JSON.parse(e.data);t.type===`packet`?y.addRealtimePacket(t.data):t.type===`stats`?(t.data?.packet_stats&&y.updateRealtimeStats({packet_stats:t.data.packet_stats}),t.data?.system_stats&&b.updateRealtimeStats(t.data.system_stats)):t.type===`packet_stats`?y.updateRealtimeStats(t.data):t.type===`system_stats`?b.updateRealtimeStats(t.data):(t.type===`pong`||t.type===`ping`)&&(d.value=Date.now(),t.type===`ping`&&n.value?.readyState===WebSocket.OPEN&&n.value.send(JSON.stringify({type:`pong`})))}catch(e){console.error(`[WebSocket] Parse error:`,e)}},t.onerror=()=>{l.value=u.value>0?`reconnecting`:`closed`},t.onclose=e=>{let t=n.value;if(j(),t===n.value&&(n.value=null),m.value||h.value){l.value=`closed`;return}if(e.code===1008||e.code===4001||e.code===4003){x.handleAuthFailure(`expired`);return}M()}}function P(e=`lifecycle`){if(h.value=!0,T(),l.value=`closed`,e===`offline`?(g.value=!0,C(`Connection lost`,`error`,4e3)):e===`hidden`?(g.value=!0,w()):e===`logout`&&(g.value=!1,w()),n.value){let e=n.value;n.value=null,j(!0),e.close()}}function F(){m.value=!1,h.value=!1}function I(e={}){m.value=e.preventReconnect??m.value,e.silent||w(),P(e.preventReconnect?`logout`:`lifecycle`),u.value=0}return{isConnected:S,connectionState:l,reconnectAttempts:u,snackbar:_,connect:N,disconnect:I,pause:P,allowReconnect:F,hideSnackbar:w,resyncData:A}});export{l as t};
|
||||
@@ -8,17 +8,17 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script type="module" crossorigin src="/assets/index-BFltqMtv.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-ZOJT60Eu.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-B7aGp3iI.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/chunk-DECur_0Z.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/runtime-core.esm-bundler-HnidnMFy.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vue-router-Cr0wB7EX.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/api-CbM6k1ZB.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/api-DdIgU01d.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/useTheme-DMOVV09x.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/packets-C-dzvp0W.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/system-BH4r-ii6.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/websocket-nXR7EYbj.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Crl6CjFg.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/packets-Dk_NHrT7.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/system-qmDsA3-W.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/websocket-jaT0AMJh.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-yMtlm8FU.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -36,6 +36,8 @@ tags:
|
||||
description: User authentication and API token management
|
||||
- name: System
|
||||
description: System statistics and control
|
||||
- name: GPS
|
||||
description: Local GPS receiver diagnostics
|
||||
- name: Packets
|
||||
description: Packet history and statistics
|
||||
- name: Charts
|
||||
@@ -318,6 +320,134 @@ paths:
|
||||
type: string
|
||||
example: "0.5.0"
|
||||
|
||||
/gps:
|
||||
get:
|
||||
tags: [GPS]
|
||||
summary: Get local GPS diagnostics
|
||||
description: Returns parsed NMEA fix, position, motion, accuracy, satellites, and raw sentence health.
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- ApiKeyAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: GPS diagnostics snapshot
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
running:
|
||||
type: boolean
|
||||
status:
|
||||
type: object
|
||||
fix:
|
||||
type: object
|
||||
position:
|
||||
type: object
|
||||
description: Effective receiver position for API clients
|
||||
gps_position:
|
||||
type: object
|
||||
description: Raw position reported by the GPS receiver, even before a valid fix
|
||||
manual_position:
|
||||
type: object
|
||||
nullable: true
|
||||
description: Configured repeater latitude/longitude, when set to a non-zero coordinate
|
||||
position_meta:
|
||||
type: object
|
||||
properties:
|
||||
source:
|
||||
type: string
|
||||
enum: [gps, manual_config]
|
||||
source_label:
|
||||
type: string
|
||||
policy:
|
||||
type: string
|
||||
enum: [manual_until_gps_fix, gps_only]
|
||||
manual_config_available:
|
||||
type: boolean
|
||||
gps_fix_valid:
|
||||
type: boolean
|
||||
motion:
|
||||
type: object
|
||||
accuracy:
|
||||
type: object
|
||||
time:
|
||||
type: object
|
||||
time_sync:
|
||||
type: object
|
||||
description: GPS-to-system-clock sync status
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
state:
|
||||
type: string
|
||||
enum: [disabled, waiting_for_fix, waiting_for_time, ready, in_sync, synced, error, ignored]
|
||||
last_attempt:
|
||||
type: string
|
||||
nullable: true
|
||||
last_success:
|
||||
type: string
|
||||
nullable: true
|
||||
last_error:
|
||||
type: string
|
||||
nullable: true
|
||||
last_gps_time:
|
||||
type: string
|
||||
nullable: true
|
||||
last_offset_seconds:
|
||||
type: number
|
||||
nullable: true
|
||||
location_update:
|
||||
type: object
|
||||
description: GPS-fix-to-repeater-location update status
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
state:
|
||||
type: string
|
||||
enum: [disabled, unconfigured, waiting_for_fix, waiting_for_position, ready, updated, skipped, error]
|
||||
last_attempt:
|
||||
type: string
|
||||
nullable: true
|
||||
last_success:
|
||||
type: string
|
||||
nullable: true
|
||||
last_error:
|
||||
type: string
|
||||
nullable: true
|
||||
last_latitude:
|
||||
type: number
|
||||
nullable: true
|
||||
last_longitude:
|
||||
type: number
|
||||
nullable: true
|
||||
satellites:
|
||||
type: object
|
||||
nmea:
|
||||
type: object
|
||||
raw_attributes:
|
||||
type: object
|
||||
|
||||
/gps_stream:
|
||||
get:
|
||||
tags: [GPS]
|
||||
summary: GPS diagnostics SSE stream
|
||||
description: Server-Sent Events stream of live GPS diagnostics snapshots.
|
||||
responses:
|
||||
'200':
|
||||
description: SSE stream
|
||||
content:
|
||||
text/event-stream:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/send_advert:
|
||||
post:
|
||||
tags: [System]
|
||||
|
||||
@@ -0,0 +1,565 @@
|
||||
import importlib.util
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
_MODULE_PATH = Path(__file__).resolve().parents[1] / "repeater" / "data_acquisition" / "gps_service.py"
|
||||
_SPEC = importlib.util.spec_from_file_location("repeater_gps_service", _MODULE_PATH)
|
||||
_MODULE = importlib.util.module_from_spec(_SPEC)
|
||||
assert _SPEC and _SPEC.loader
|
||||
_SPEC.loader.exec_module(_MODULE)
|
||||
GPSService = _MODULE.GPSService
|
||||
NMEAParser = _MODULE.NMEAParser
|
||||
|
||||
|
||||
def _sentence(payload: str) -> str:
|
||||
checksum = 0
|
||||
for char in payload:
|
||||
checksum ^= ord(char)
|
||||
return f"${payload}*{checksum:02X}"
|
||||
|
||||
|
||||
def test_nmea_parser_combines_rmc_gga_gsa_gsv_attributes():
|
||||
parser = NMEAParser()
|
||||
|
||||
assert parser.ingest_sentence(
|
||||
_sentence("GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W")
|
||||
)
|
||||
assert parser.ingest_sentence(
|
||||
_sentence("GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,")
|
||||
)
|
||||
assert parser.ingest_sentence(
|
||||
_sentence("GPGSA,A,3,04,05,09,12,24,25,29,,,,,,1.8,1.0,1.5")
|
||||
)
|
||||
assert parser.ingest_sentence(
|
||||
_sentence("GPGSV,1,1,03,04,77,045,42,05,13,180,35,09,07,095,29")
|
||||
)
|
||||
|
||||
snapshot = parser.snapshot()
|
||||
|
||||
assert snapshot["status"]["state"] == "valid_fix"
|
||||
assert snapshot["position"]["latitude"] == 48.1173
|
||||
assert snapshot["position"]["longitude"] == 11.51666667
|
||||
assert snapshot["position"]["altitude_m"] == 545.4
|
||||
assert snapshot["motion"]["speed_knots"] == 22.4
|
||||
assert snapshot["motion"]["speed_kmh"] == 41.485
|
||||
assert snapshot["motion"]["course_degrees"] == 84.4
|
||||
assert snapshot["motion"]["magnetic_variation_degrees"] == -3.1
|
||||
assert snapshot["accuracy"]["hdop"] == 1.0
|
||||
assert snapshot["accuracy"]["pdop"] == 1.8
|
||||
assert snapshot["accuracy"]["vdop"] == 1.5
|
||||
assert snapshot["fix"]["quality"] == 1
|
||||
assert snapshot["fix"]["quality_label"] == "GPS"
|
||||
assert snapshot["fix"]["gsa_fix_type_label"] == "3D fix"
|
||||
assert snapshot["satellites"]["used_count"] == 7
|
||||
assert snapshot["satellites"]["in_view_count"] == 3
|
||||
assert snapshot["satellites"]["snr"]["max"] == 42.0
|
||||
assert snapshot["time"]["date"] == "1994-03-23"
|
||||
assert snapshot["time"]["datetime_utc"] == "1994-03-23T12:35:19.000000+00:00"
|
||||
assert set(snapshot["nmea"]["seen_sentence_types"]) == {"GGA", "GSA", "GSV", "RMC"}
|
||||
|
||||
|
||||
def test_nmea_parser_rejects_bad_checksum_when_validation_enabled():
|
||||
parser = NMEAParser(validate_checksum=True)
|
||||
|
||||
accepted = parser.ingest_sentence(
|
||||
"$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*00"
|
||||
)
|
||||
|
||||
snapshot = parser.snapshot()
|
||||
assert accepted is False
|
||||
assert snapshot["status"]["state"] == "error"
|
||||
assert snapshot["nmea"]["invalid_checksum_count"] == 1
|
||||
assert snapshot["status"]["last_error"] == "NMEA checksum mismatch"
|
||||
|
||||
|
||||
def test_gps_service_file_source_reads_nmea_lines(tmp_path):
|
||||
path = tmp_path / "gps_nmea.txt"
|
||||
path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
_sentence("GPRMC,010203,A,4250.123,N,07106.456,W,000.0,180.0,230426,,"),
|
||||
_sentence("GPGGA,010203,4250.123,N,07106.456,W,1,05,1.4,32.0,M,0.0,M,,"),
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
service = GPSService(
|
||||
{
|
||||
"gps": {
|
||||
"enabled": True,
|
||||
"source": "file",
|
||||
"source_path": str(path),
|
||||
"poll_interval_seconds": 0.05,
|
||||
"stale_after_seconds": 5.0,
|
||||
"time_sync_enabled": False,
|
||||
}
|
||||
}
|
||||
)
|
||||
service.start()
|
||||
try:
|
||||
deadline = time.time() + 1.0
|
||||
snapshot = service.get_snapshot()
|
||||
while snapshot["status"]["state"] == "no_data" and time.time() < deadline:
|
||||
time.sleep(0.05)
|
||||
snapshot = service.get_snapshot()
|
||||
finally:
|
||||
service.stop()
|
||||
|
||||
assert snapshot["status"]["state"] == "valid_fix"
|
||||
assert snapshot["position"]["latitude"] == 42.83538333
|
||||
assert snapshot["position"]["longitude"] == -71.1076
|
||||
assert snapshot["satellites"]["used_count"] == 5
|
||||
|
||||
|
||||
def test_rmc_only_fix_has_non_conflicting_quality_label():
|
||||
parser = NMEAParser()
|
||||
|
||||
assert parser.ingest_sentence(
|
||||
_sentence("GPRMC,010203,A,4250.123,N,07106.456,W,000.0,180.0,230426,,")
|
||||
)
|
||||
|
||||
snapshot = parser.snapshot()
|
||||
|
||||
assert snapshot["status"]["state"] == "valid_fix"
|
||||
assert snapshot["fix"]["valid"] is True
|
||||
assert snapshot["fix"]["quality"] is None
|
||||
assert snapshot["fix"]["quality_label"] == "RMC valid"
|
||||
assert snapshot["position"]["latitude"] == 42.83538333
|
||||
assert snapshot["position"]["longitude"] == -71.1076
|
||||
|
||||
|
||||
def test_snapshot_clamps_negative_age_after_system_clock_step():
|
||||
parser = NMEAParser()
|
||||
|
||||
assert parser.ingest_sentence(
|
||||
_sentence("GPRMC,010203,A,4250.123,N,07106.456,W,000.0,180.0,230426,,")
|
||||
)
|
||||
parser.last_update = time.time() + 120
|
||||
|
||||
snapshot = parser.snapshot()
|
||||
|
||||
assert snapshot["status"]["state"] == "valid_fix"
|
||||
assert snapshot["status"]["age_seconds"] == 0.0
|
||||
|
||||
|
||||
def test_gps_service_uses_manual_location_until_gps_fix():
|
||||
service = GPSService(
|
||||
{
|
||||
"repeater": {
|
||||
"latitude": 42.123456,
|
||||
"longitude": -71.654321,
|
||||
},
|
||||
"gps": {
|
||||
"enabled": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert service.ingest_sentence(
|
||||
_sentence("GPGGA,010203,4250.123,N,07106.456,W,0,00,8.8,32.0,M,0.0,M,,")
|
||||
)
|
||||
|
||||
snapshot = service.get_snapshot()
|
||||
|
||||
assert snapshot["status"]["state"] == "invalid_fix"
|
||||
assert snapshot["position"]["latitude"] == 42.123456
|
||||
assert snapshot["position"]["longitude"] == -71.654321
|
||||
assert snapshot["gps_position"]["latitude"] == 42.83538333
|
||||
assert snapshot["gps_position"]["longitude"] == -71.1076
|
||||
assert snapshot["manual_position"]["latitude"] == 42.123456
|
||||
assert snapshot["manual_position"]["longitude"] == -71.654321
|
||||
assert snapshot["position_meta"]["source"] == "manual_config"
|
||||
assert snapshot["position_meta"]["source_label"] == "manual config until GPS fix"
|
||||
assert snapshot["position_meta"]["manual_config_available"] is True
|
||||
assert snapshot["position_meta"]["gps_fix_valid"] is False
|
||||
|
||||
|
||||
def test_gps_service_sets_system_time_from_valid_gps_datetime():
|
||||
clock_calls = []
|
||||
system_now = datetime(2026, 4, 23, 1, 1, 0, tzinfo=timezone.utc).timestamp()
|
||||
service = GPSService(
|
||||
{
|
||||
"gps": {
|
||||
"enabled": True,
|
||||
"time_sync_enabled": True,
|
||||
"time_sync_interval_seconds": 60.0,
|
||||
"time_sync_min_offset_seconds": 1.0,
|
||||
},
|
||||
},
|
||||
clock_setter=clock_calls.append,
|
||||
time_provider=lambda: system_now,
|
||||
)
|
||||
|
||||
assert service.ingest_sentence(
|
||||
_sentence("GPRMC,010203,A,4250.123,N,07106.456,W,000.0,180.0,230426,,")
|
||||
)
|
||||
|
||||
snapshot = service.get_snapshot()
|
||||
|
||||
assert len(clock_calls) == 1
|
||||
assert clock_calls[0] == datetime(2026, 4, 23, 1, 2, 3, tzinfo=timezone.utc)
|
||||
assert snapshot["time_sync"]["state"] == "synced"
|
||||
assert snapshot["time_sync"]["last_error"] is None
|
||||
assert snapshot["time_sync"]["last_gps_time"] == "2026-04-23T01:02:03+00:00"
|
||||
assert snapshot["time_sync"]["last_offset_seconds"] == 63.0
|
||||
|
||||
|
||||
def test_gps_service_does_not_set_system_time_without_valid_fix():
|
||||
clock_calls = []
|
||||
service = GPSService(
|
||||
{
|
||||
"gps": {
|
||||
"enabled": True,
|
||||
"time_sync_enabled": True,
|
||||
},
|
||||
},
|
||||
clock_setter=clock_calls.append,
|
||||
)
|
||||
|
||||
assert service.ingest_sentence(
|
||||
_sentence("GPRMC,010203,V,4250.123,N,07106.456,W,000.0,180.0,230426,,")
|
||||
)
|
||||
|
||||
snapshot = service.get_snapshot()
|
||||
|
||||
assert clock_calls == []
|
||||
assert snapshot["status"]["state"] == "invalid_fix"
|
||||
assert snapshot["time_sync"]["state"] == "waiting_for_fix"
|
||||
|
||||
|
||||
def test_gps_service_ignores_old_gps_time_without_throttling_valid_time():
|
||||
clock_calls = []
|
||||
system_now = datetime(2026, 4, 23, 1, 0, 0, tzinfo=timezone.utc).timestamp()
|
||||
service = GPSService(
|
||||
{
|
||||
"gps": {
|
||||
"enabled": True,
|
||||
"time_sync_enabled": True,
|
||||
"time_sync_min_valid_year": 2020,
|
||||
},
|
||||
},
|
||||
clock_setter=clock_calls.append,
|
||||
time_provider=lambda: system_now,
|
||||
)
|
||||
|
||||
assert service.ingest_sentence(
|
||||
_sentence("GPRMC,010203,A,4250.123,N,07106.456,W,000.0,180.0,230394,,")
|
||||
)
|
||||
assert clock_calls == []
|
||||
assert service.get_snapshot()["time_sync"]["state"] == "ignored"
|
||||
|
||||
assert service.ingest_sentence(
|
||||
_sentence("GPRMC,010203,A,4250.123,N,07106.456,W,000.0,180.0,230426,,")
|
||||
)
|
||||
|
||||
assert clock_calls == [datetime(2026, 4, 23, 1, 2, 3, tzinfo=timezone.utc)]
|
||||
assert service.get_snapshot()["time_sync"]["state"] == "synced"
|
||||
|
||||
|
||||
def test_gps_service_file_source_uses_time_sync_hook(tmp_path):
|
||||
path = tmp_path / "gps_nmea.txt"
|
||||
path.write_text(
|
||||
_sentence("GPRMC,010203,A,4250.123,N,07106.456,W,000.0,180.0,230426,,"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
clock_calls = []
|
||||
system_now = datetime(2026, 4, 23, 1, 0, 0, tzinfo=timezone.utc).timestamp()
|
||||
|
||||
service = GPSService(
|
||||
{
|
||||
"gps": {
|
||||
"enabled": True,
|
||||
"source": "file",
|
||||
"source_path": str(path),
|
||||
"poll_interval_seconds": 0.05,
|
||||
"stale_after_seconds": 5.0,
|
||||
"time_sync_enabled": True,
|
||||
}
|
||||
},
|
||||
clock_setter=clock_calls.append,
|
||||
time_provider=lambda: system_now,
|
||||
)
|
||||
service.start()
|
||||
try:
|
||||
deadline = time.time() + 1.0
|
||||
while not clock_calls and time.time() < deadline:
|
||||
time.sleep(0.05)
|
||||
finally:
|
||||
service.stop()
|
||||
|
||||
assert clock_calls == [datetime(2026, 4, 23, 1, 2, 3, tzinfo=timezone.utc)]
|
||||
|
||||
|
||||
def test_gps_service_uses_gps_location_after_valid_fix():
|
||||
service = GPSService(
|
||||
{
|
||||
"repeater": {
|
||||
"latitude": 42.123456,
|
||||
"longitude": -71.654321,
|
||||
},
|
||||
"gps": {
|
||||
"enabled": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert service.ingest_sentence(
|
||||
_sentence("GPGGA,010203,4250.123,N,07106.456,W,1,05,1.4,32.0,M,0.0,M,,")
|
||||
)
|
||||
|
||||
snapshot = service.get_snapshot()
|
||||
|
||||
assert snapshot["status"]["state"] == "valid_fix"
|
||||
assert snapshot["position"]["latitude"] == 42.83538333
|
||||
assert snapshot["position"]["longitude"] == -71.1076
|
||||
assert snapshot["manual_position"]["latitude"] == 42.123456
|
||||
assert snapshot["manual_position"]["longitude"] == -71.654321
|
||||
assert snapshot["position_meta"]["source"] == "gps"
|
||||
assert snapshot["position_meta"]["source_label"] == "GPS fix"
|
||||
assert snapshot["position_meta"]["manual_config_available"] is True
|
||||
assert snapshot["position_meta"]["gps_fix_valid"] is True
|
||||
|
||||
|
||||
def test_gps_service_treats_zero_zero_manual_location_as_unset():
|
||||
service = GPSService(
|
||||
{
|
||||
"repeater": {
|
||||
"latitude": 0.0,
|
||||
"longitude": 0.0,
|
||||
},
|
||||
"gps": {
|
||||
"enabled": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert service.ingest_sentence(
|
||||
_sentence("GPGGA,010203,4250.123,N,07106.456,W,0,00,8.8,32.0,M,0.0,M,,")
|
||||
)
|
||||
|
||||
snapshot = service.get_snapshot()
|
||||
|
||||
assert snapshot["status"]["state"] == "invalid_fix"
|
||||
assert snapshot["position"]["latitude"] == 42.83538333
|
||||
assert snapshot["position"]["longitude"] == -71.1076
|
||||
assert snapshot["manual_position"] is None
|
||||
assert snapshot["position_meta"]["source"] == "gps"
|
||||
assert snapshot["position_meta"]["source_label"] == "GPS estimate"
|
||||
assert snapshot["position_meta"]["manual_config_available"] is False
|
||||
assert snapshot["position_meta"]["gps_fix_valid"] is False
|
||||
|
||||
|
||||
def test_gps_service_reflects_runtime_manual_location_updates():
|
||||
config = {
|
||||
"repeater": {
|
||||
"latitude": 0.0,
|
||||
"longitude": 0.0,
|
||||
},
|
||||
"gps": {
|
||||
"enabled": True,
|
||||
},
|
||||
}
|
||||
service = GPSService(config)
|
||||
|
||||
assert service.ingest_sentence(
|
||||
_sentence("GPGGA,010203,4250.123,N,07106.456,W,0,00,8.8,32.0,M,0.0,M,,")
|
||||
)
|
||||
assert service.get_snapshot()["position_meta"]["source"] == "gps"
|
||||
|
||||
config["repeater"]["latitude"] = 42.123456
|
||||
config["repeater"]["longitude"] = -71.654321
|
||||
snapshot = service.get_snapshot()
|
||||
|
||||
assert snapshot["position"]["latitude"] == 42.123456
|
||||
assert snapshot["position"]["longitude"] == -71.654321
|
||||
assert snapshot["gps_position"]["latitude"] == 42.83538333
|
||||
assert snapshot["gps_position"]["longitude"] == -71.1076
|
||||
assert snapshot["position_meta"]["source"] == "manual_config"
|
||||
|
||||
def test_repeater_location_uses_config_when_gps_opt_in_disabled():
|
||||
service = GPSService(
|
||||
{
|
||||
"repeater": {
|
||||
"latitude": 42.123456,
|
||||
"longitude": -71.654321,
|
||||
},
|
||||
"gps": {
|
||||
"enabled": True,
|
||||
"use_gps_for_repeater_location": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert service.ingest_sentence(
|
||||
_sentence("GPGGA,010203,4250.123,N,07106.456,W,1,05,1.4,32.0,M,0.0,M,,")
|
||||
)
|
||||
|
||||
location = service.get_repeater_location()
|
||||
|
||||
assert location["source"] == "config"
|
||||
assert location["latitude"] == 42.123456
|
||||
assert location["longitude"] == -71.654321
|
||||
|
||||
|
||||
def test_repeater_location_uses_gps_with_optional_precision_rounding():
|
||||
service = GPSService(
|
||||
{
|
||||
"repeater": {
|
||||
"latitude": 42.123456,
|
||||
"longitude": -71.654321,
|
||||
},
|
||||
"gps": {
|
||||
"enabled": True,
|
||||
"use_gps_for_repeater_location": True,
|
||||
"repeater_location_precision_digits": 3,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert service.ingest_sentence(
|
||||
_sentence("GPGGA,010203,4250.123,N,07106.456,W,1,05,1.4,32.0,M,0.0,M,,")
|
||||
)
|
||||
|
||||
location = service.get_repeater_location()
|
||||
|
||||
assert location["source"] == "gps"
|
||||
assert location["latitude"] == 42.835
|
||||
assert location["longitude"] == -71.108
|
||||
assert location["precision_digits"] == 3
|
||||
|
||||
|
||||
def test_repeater_location_falls_back_to_config_without_valid_gps_fix():
|
||||
service = GPSService(
|
||||
{
|
||||
"repeater": {
|
||||
"latitude": 42.123456,
|
||||
"longitude": -71.654321,
|
||||
},
|
||||
"gps": {
|
||||
"enabled": True,
|
||||
"use_gps_for_repeater_location": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert service.ingest_sentence(
|
||||
_sentence("GPGGA,010203,4250.123,N,07106.456,W,0,00,8.8,32.0,M,0.0,M,,")
|
||||
)
|
||||
|
||||
location = service.get_repeater_location()
|
||||
|
||||
assert location["source"] == "config_fallback_no_valid_gps_fix"
|
||||
assert location["latitude"] == 42.123456
|
||||
assert location["longitude"] == -71.654321
|
||||
|
||||
|
||||
def test_gps_service_updates_repeater_location_from_valid_fix(monkeypatch):
|
||||
monotonic_now = [1000.0]
|
||||
monkeypatch.setattr(_MODULE.time, "monotonic", lambda: monotonic_now[0])
|
||||
location_updates = []
|
||||
service = GPSService(
|
||||
{
|
||||
"gps": {
|
||||
"enabled": True,
|
||||
"time_sync_enabled": False,
|
||||
"update_repeater_location_from_fix": True,
|
||||
"location_update_interval_seconds": 600.0,
|
||||
}
|
||||
},
|
||||
location_update_callback=lambda payload: location_updates.append(payload) or True,
|
||||
)
|
||||
|
||||
assert service.ingest_sentence(
|
||||
_sentence("GPGGA,010203,4250.123,N,07106.456,W,1,05,1.4,32.0,M,0.0,M,,")
|
||||
)
|
||||
|
||||
assert len(location_updates) == 1
|
||||
assert location_updates[0]["latitude"] == 42.83538333
|
||||
assert location_updates[0]["longitude"] == -71.1076
|
||||
snapshot = service.get_snapshot()
|
||||
assert snapshot["location_update"]["state"] == "updated"
|
||||
assert snapshot["location_update"]["last_latitude"] == 42.83538333
|
||||
assert snapshot["location_update"]["last_longitude"] == -71.1076
|
||||
|
||||
assert service.ingest_sentence(
|
||||
_sentence("GPGGA,010204,4251.000,N,07107.000,W,1,05,1.4,33.0,M,0.0,M,,")
|
||||
)
|
||||
assert len(location_updates) == 1
|
||||
|
||||
monotonic_now[0] += 601.0
|
||||
assert service.ingest_sentence(
|
||||
_sentence("GPGGA,010205,4251.000,N,07107.000,W,1,05,1.4,33.0,M,0.0,M,,")
|
||||
)
|
||||
assert len(location_updates) == 2
|
||||
assert location_updates[1]["latitude"] == 42.85
|
||||
assert location_updates[1]["longitude"] == -71.11666667
|
||||
|
||||
|
||||
def test_gps_service_does_not_update_repeater_location_without_valid_fix():
|
||||
location_updates = []
|
||||
service = GPSService(
|
||||
{
|
||||
"gps": {
|
||||
"enabled": True,
|
||||
"time_sync_enabled": False,
|
||||
"update_repeater_location_from_fix": True,
|
||||
}
|
||||
},
|
||||
location_update_callback=lambda payload: location_updates.append(payload) or True,
|
||||
)
|
||||
|
||||
assert service.ingest_sentence(
|
||||
_sentence("GPGGA,010203,4250.123,N,07106.456,W,0,00,8.8,32.0,M,0.0,M,,")
|
||||
)
|
||||
|
||||
assert location_updates == []
|
||||
assert service.get_snapshot()["location_update"]["state"] == "waiting_for_fix"
|
||||
|
||||
|
||||
def test_gps_service_location_update_is_opt_in_by_default():
|
||||
location_updates = []
|
||||
service = GPSService(
|
||||
{
|
||||
"gps": {
|
||||
"enabled": True,
|
||||
"time_sync_enabled": False,
|
||||
}
|
||||
},
|
||||
location_update_callback=lambda payload: location_updates.append(payload) or True,
|
||||
)
|
||||
|
||||
assert service.ingest_sentence(
|
||||
_sentence("GPRMC,010203,A,4250.123,N,07106.456,W,000.0,180.0,230426,,")
|
||||
)
|
||||
|
||||
assert location_updates == []
|
||||
assert service.get_snapshot()["location_update"]["state"] == "disabled"
|
||||
|
||||
|
||||
def test_gps_service_fuzzes_persisted_repeater_location():
|
||||
location_updates = []
|
||||
service = GPSService(
|
||||
{
|
||||
"gps": {
|
||||
"enabled": True,
|
||||
"time_sync_enabled": False,
|
||||
"update_repeater_location_from_fix": True,
|
||||
"repeater_location_precision_digits": 3,
|
||||
}
|
||||
},
|
||||
location_update_callback=lambda payload: location_updates.append(payload) or True,
|
||||
)
|
||||
|
||||
assert service.ingest_sentence(
|
||||
_sentence("GPGGA,010203,4250.123,N,07106.456,W,1,05,1.4,32.0,M,0.0,M,,")
|
||||
)
|
||||
|
||||
assert len(location_updates) == 1
|
||||
assert location_updates[0]["latitude"] == 42.835
|
||||
assert location_updates[0]["longitude"] == -71.108
|
||||
assert location_updates[0]["precision_digits"] == 3
|
||||
snapshot = service.get_snapshot()
|
||||
assert snapshot["location_update"]["last_latitude"] == 42.835
|
||||
assert snapshot["location_update"]["last_longitude"] == -71.108
|
||||
Reference in New Issue
Block a user