#!/usr/bin/env python3 """ GNSS Guard - Main orchestrator Coordinates data collection from multiple GPS sources and validation """ import asyncio import json import logging import os import signal import sys import threading import time from datetime import datetime, timezone from typing import Dict, Any, Optional from config import Config from sources.tm_ais_gps import TMAISGPSFetcher from sources.starlink_gps import StarlinkGPSFetcher from sources.nmea_gps import NMEAGPSCollector from storage.database import Database from storage.logger import StructuredLogger from storage.cleanup import CleanupManager from validation.coordinate_validator import CoordinateValidator from web.server import WebServer from services.server_sync import ServerSync from services.buzzer import get_buzzer_service logger = logging.getLogger("gnss_guard.main") class GNSSGuard: """Main orchestrator for GNSS Guard system""" def __init__(self, config: Config): """Initialize GNSS Guard""" self.config = config self.running = False # Initialize components self.database = Database(config.database_path) self.structured_logger = StructuredLogger( config.logs_base_path, config.log_retention_days ) # Path to injected positions file (in same directory as main.py) script_dir = os.path.dirname(os.path.abspath(__file__)) self.injected_positions_path = os.path.join(script_dir, "injected_positions.json") # Initialize data sources self.tm_ais_fetcher = TMAISGPSFetcher(config) if config.tm_ais_enabled else None self.starlink_fetcher = StarlinkGPSFetcher(config) if config.starlink_enabled else None # Initialize NMEA collectors self.nmea_primary_collector = None if config.nmea_primary_enabled and config.nmea_primary_ip and config.nmea_primary_port > 0: self.nmea_primary_collector = NMEAGPSCollector( config, "nmea_primary", config.nmea_primary_ip, config.nmea_primary_port, structured_logger=self.structured_logger ) self.nmea_secondary_collector = None if config.nmea_secondary_enabled and config.nmea_secondary_ip and config.nmea_secondary_port > 0: self.nmea_secondary_collector = NMEAGPSCollector( config, "nmea_secondary", config.nmea_secondary_ip, config.nmea_secondary_port, structured_logger=self.structured_logger ) # Initialize validator expected_sources = config.get_enabled_sources() self.validator = CoordinateValidator( config.validation_threshold_meters, config.stale_threshold_seconds, expected_sources ) # Initialize buzzer service for hardware alarm (must be before web server) # Buzzer sounds with 1 second on / 1 second off pattern during GNSS alerts self.buzzer_service = get_buzzer_service(on_duration=1.0, off_duration=1.0) # Track previous alert level to detect status changes # Alert levels: "healthy", "degraded", "at_risk" self._previous_alert_level = "healthy" # Initialize web server (if enabled) self.web_server = None self.web_thread = None if config.web_enabled: try: self.web_server = WebServer(config, self.database, self.buzzer_service) logger.info("Web server initialized") except Exception as e: logger.warning(f"Failed to initialize web server: {e}") self.web_server = None # Initialize cleanup manager # In demo mode, skip database cleanup since data isn't growing # (demo mode creates and deletes records, maintaining a fixed dataset) self.cleanup_manager = CleanupManager( database_path=config.database_path, logs_base_path=config.logs_base_path, positions_raw_retention_days=config.positions_raw_retention_days, positions_validation_retention_days=config.positions_validation_retention_days, logs_retention_days=config.log_retention_days, demo_mode=config.demo_unit ) if config.demo_unit: logger.info( f"Cleanup manager initialized in DEMO mode (logs only: {config.log_retention_days}d)" ) else: logger.info( f"Cleanup manager initialized (raw: {config.positions_raw_retention_days}d, " f"validation: {config.positions_validation_retention_days}d, logs: {config.log_retention_days}d)" ) # Initialize server sync (if enabled) self.server_sync = None if config.server_enabled and config.server_url and config.server_token: try: self.server_sync = ServerSync( database_path=config.database_path, server_url=config.server_url, server_token=config.server_token, asset_name=config.asset_name, batch_size=config.server_sync_batch_size, max_queue_size=config.server_sync_max_queue ) logger.info(f"Server sync enabled -> {config.server_url}") except Exception as e: logger.warning(f"Failed to initialize server sync: {e}") self.server_sync = None # Setup signal handlers signal.signal(signal.SIGINT, self._signal_handler) signal.signal(signal.SIGTERM, self._signal_handler) def _signal_handler(self, signum, frame): """Handle shutdown signals""" logger.info(f"Received signal {signum}, shutting down gracefully...") self.running = False def _load_injected_positions(self) -> Optional[Dict[str, Dict[str, Any]]]: """ Load injected positions from JSON file if it exists Returns: Dictionary mapping source names to position dictionaries, or None if file doesn't exist """ if not os.path.exists(self.injected_positions_path): return None try: with open(self.injected_positions_path, 'r') as f: data = json.load(f) # Validate and normalize positions injected = {} for source, position in data.items(): # Skip metadata fields (those starting with underscore) if source.startswith("_"): continue # Skip commented-out sources (those starting with //) if source.startswith("//"): continue if position is None: # Null value means this source should be absent # Store it as None so we know to skip fetching for this source injected[source] = None continue # Ensure required fields are present if not isinstance(position, dict): logger.warning(f"Invalid position format for {source} in injected_positions.json") continue # Ensure source field matches the key position["source"] = source # Ensure timestamp_unix is set if timestamp is provided if "timestamp" in position and "timestamp_unix" not in position: try: ts = datetime.fromisoformat(position["timestamp"].replace("Z", "+00:00")) if ts.tzinfo is None: ts = ts.replace(tzinfo=timezone.utc) position["timestamp_unix"] = ts.timestamp() except Exception as e: logger.warning(f"Failed to parse timestamp for {source}: {e}") # Use current time as fallback now = datetime.now(timezone.utc) position["timestamp"] = now.isoformat() position["timestamp_unix"] = now.timestamp() # Ensure timestamp is set if timestamp_unix is provided if "timestamp_unix" in position and "timestamp" not in position: try: ts = datetime.fromtimestamp(position["timestamp_unix"], tz=timezone.utc) position["timestamp"] = ts.isoformat() except Exception as e: logger.warning(f"Failed to convert timestamp_unix for {source}: {e}") position["timestamp"] = datetime.now(timezone.utc).isoformat() # Ensure both exist (use current time if neither provided) if "timestamp_unix" not in position: now = datetime.now(timezone.utc) position["timestamp"] = now.isoformat() position["timestamp_unix"] = now.timestamp() injected[source] = position logger.info(f"Loaded {len(injected)} injected position(s) from {self.injected_positions_path}") return injected except json.JSONDecodeError as e: logger.error(f"Failed to parse injected_positions.json: {e}") return None except Exception as e: logger.error(f"Error loading injected positions: {e}") return None def _store_demo_validation(self, validation_result: Dict[str, Any]): """ Store validation in DEMO_UNIT mode. Keeps only the latest validation record to show live status on dashboard, while preserving historical data for route display. Deletes any validation records from the last 5 minutes before inserting new one. """ import sqlite3 as sqlite3_module try: conn = sqlite3_module.connect(str(self.database.database_path), timeout=5.0) cursor = conn.cursor() # Delete recent "live" records (last 5 minutes) to prevent accumulation # This keeps historical data intact while allowing fresh dashboard display five_minutes_ago = time.time() - 300 cursor.execute( "DELETE FROM positions_validation WHERE validation_timestamp_unix > ?", (five_minutes_ago,) ) conn.commit() conn.close() # Now store the new validation record self.database.store_validation(validation_result) except Exception as e: logger.error(f"Error storing demo validation: {e}") def _handle_buzzer_alarm(self, is_valid: bool, missing_sources: list, stale_sources: list, distance_exceeded: bool): """ Handle buzzer alarm based on validation status. Buzzer triggers when GNSS status is: - "at risk" (GPS jamming/spoofing detected - distance exceeds threshold) - "degraded" (sources missing or stale) - "no connection" (all sources missing) Buzzer stops when: - Status returns to healthy (validation passes) - User acknowledges the alarm via the dashboard button Buzzer restarts when: - Alert level changes (e.g., degraded → at_risk or vice versa) Args: is_valid: Whether validation passed missing_sources: List of missing source names stale_sources: List of stale source names distance_exceeded: Whether coordinate distance exceeded threshold """ try: # Determine current alert level # "at_risk" = GPS spoofing/jamming (distance exceeded) # "degraded" = sources missing or stale but no distance issue # "healthy" = validation passed if is_valid: current_alert_level = "healthy" elif distance_exceeded: current_alert_level = "at_risk" else: current_alert_level = "degraded" # Check if alert level changed alert_level_changed = current_alert_level != self._previous_alert_level if alert_level_changed: logger.info(f"Alert level changed: {self._previous_alert_level} → {current_alert_level}") # Reset acknowledged state when alert level changes # This allows buzzer to restart even if previously acknowledged if self.buzzer_service.is_alarm_acknowledged(): logger.info("Resetting alarm acknowledged state (alert level changed)") self.buzzer_service.reset_acknowledged() # Stop current alarm if running (will restart below if needed) if self.buzzer_service.is_alarm_active(): self.buzzer_service.stop_alarm() # Handle alarm based on current alert level if current_alert_level != "healthy": # Status is degraded or at risk # Start alarm if not already active and not acknowledged if not self.buzzer_service.is_alarm_active(): if not self.buzzer_service.is_alarm_acknowledged(): # Determine alarm reason for logging if current_alert_level == "at_risk": reason = "GPS jamming/spoofing detected (distance exceeded threshold)" elif missing_sources: reason = f"Sources missing: {', '.join(missing_sources)}" elif stale_sources: reason = f"Sources stale: {', '.join(stale_sources)}" else: reason = "Validation failed" logger.warning(f"Starting buzzer alarm: {reason}") self.structured_logger.warning("buzzer", f"Alarm started: {reason}") self.buzzer_service.start_alarm() else: logger.debug("Alarm acknowledged, not restarting until alert level changes") else: # Status is healthy # Stop alarm if active if self.buzzer_service.is_alarm_active(): logger.info("Status returned to healthy, stopping buzzer alarm") self.structured_logger.info("buzzer", "Alarm stopped (status healthy)") self.buzzer_service.stop_alarm() # Reset acknowledged state when healthy if self.buzzer_service.is_alarm_acknowledged(): logger.debug("Resetting alarm acknowledged state (status healthy)") self.buzzer_service.reset_acknowledged() # Track alert level for next iteration self._previous_alert_level = current_alert_level except Exception as e: logger.error(f"Error handling buzzer alarm: {e}") async def start(self): """Start GNSS Guard system""" logger.info("Starting GNSS Guard system") self.structured_logger.info("system", "GNSS Guard starting", {"config": self.config.to_dict()}) # Start web server in separate thread if self.web_server: self.web_thread = threading.Thread( target=self.web_server.run, kwargs={ 'host': self.config.web_host, 'port': self.config.web_port, 'debug': False }, daemon=True ) self.web_thread.start() logger.info(f"Web server started on {self.config.web_host}:{self.config.web_port}") # Log DEMO_UNIT mode if enabled if self.config.demo_unit: logger.info("DEMO_UNIT mode enabled - data collection active but database writes disabled") self.structured_logger.info("system", "DEMO_UNIT mode - no database writes") # Start NMEA collectors if self.nmea_primary_collector: await self.nmea_primary_collector.start() logger.info("Started NMEA primary collector") if self.nmea_secondary_collector: await self.nmea_secondary_collector.start() logger.info("Started NMEA secondary collector") # Startup warm-up period: wait for data sources to connect and receive initial data # This prevents false "missing" alerts on first validation after restart/deploy if self.config.startup_warmup_seconds > 0: logger.info(f"Waiting {self.config.startup_warmup_seconds}s for data sources to initialize...") self.structured_logger.info( "system", "Startup warm-up period", {"warmup_seconds": self.config.startup_warmup_seconds} ) await asyncio.sleep(self.config.startup_warmup_seconds) logger.info("Warm-up complete, starting validation cycle") self.running = True # Main collection loop - ensure iterations start at regular intervals while self.running: iteration_start = time.time() try: await self._iteration() except Exception as e: logger.error(f"Error in main loop: {e}") self.structured_logger.error("system", f"Error in main loop: {e}") # Calculate how long the iteration took iteration_duration = time.time() - iteration_start # Sleep for the remaining time to maintain the iteration period sleep_time = self.config.iteration_period_seconds - iteration_duration if sleep_time > 0: logger.debug(f"Iteration took {iteration_duration:.2f}s, sleeping for {sleep_time:.2f}s") await asyncio.sleep(sleep_time) else: logger.warning( f"Iteration took {iteration_duration:.2f}s, which exceeds the configured period " f"of {self.config.iteration_period_seconds}s. Starting next iteration immediately." ) # No sleep, start next iteration immediately async def _iteration(self): """Execute one iteration of data collection and validation""" # Run daily cleanup if needed (runs once per day) self.cleanup_manager.run_cleanup_if_needed() logger.info("Starting data collection iteration") positions = {} # Check for injected positions (per-source injection) injected_positions = self._load_injected_positions() or {} # Add injected positions (if any) if injected_positions: injected_sources = [s for s, p in injected_positions.items() if p is not None] if injected_sources: logger.info(f"Using injected positions for: {', '.join(injected_sources)}") # DEMO_UNIT mode: skip database writes skip_db_writes = self.config.demo_unit # Fetch from TM AIS GPS (skip if injected) if "tm_ais" not in injected_positions and self.tm_ais_fetcher: try: position = self.tm_ais_fetcher.fetch() if position: positions[position["source"]] = position if not skip_db_writes: self.database.store_position(position) self.structured_logger.info("tm_ais", "Fetched position", {"position": position}) except Exception as e: logger.error(f"Error fetching TM AIS GPS: {e}") self.structured_logger.error("tm_ais", f"Fetch error: {e}") elif "tm_ais" in injected_positions: # Use injected position for tm_ais if injected_positions["tm_ais"] is not None: position = injected_positions["tm_ais"] positions[position["source"]] = position if not skip_db_writes: self.database.store_position(position) self.structured_logger.info("tm_ais", "Injected position", {"position": position}) # Fetch from Starlink GPS (always fetch, then override with injected if present) if self.starlink_fetcher: # Only fetch if at least one Starlink source is not injected if "starlink_location" not in injected_positions or "starlink_gps" not in injected_positions: logger.info("Fetching from Starlink GPS...") try: starlink_positions = self.starlink_fetcher.fetch() for position in starlink_positions: # Only use fetched position if this source is not injected if position["source"] not in injected_positions: positions[position["source"]] = position if not skip_db_writes: self.database.store_position(position) self.structured_logger.info( position["source"], "Fetched position", {"position": position} ) except Exception as e: logger.error(f"Error fetching Starlink GPS: {e}") self.structured_logger.error("starlink", f"Fetch error: {e}") # Use injected positions for Starlink sources (if any) for starlink_source in ["starlink_location", "starlink_gps"]: if starlink_source in injected_positions and injected_positions[starlink_source] is not None: position = injected_positions[starlink_source] positions[position["source"]] = position if not skip_db_writes: self.database.store_position(position) self.structured_logger.info(starlink_source, "Injected position", {"position": position}) # Get latest positions from NMEA collectors (skip if injected) if "nmea_primary" not in injected_positions and self.nmea_primary_collector: try: position = await self.nmea_primary_collector.get_latest_position() if position: positions[position["source"]] = position if not skip_db_writes: self.database.store_position(position) self.structured_logger.info("nmea_primary", "Updated position", {"position": position}) except Exception as e: logger.error(f"Error getting NMEA primary position: {e}") self.structured_logger.error("nmea_primary", f"Position error: {e}") elif "nmea_primary" in injected_positions: # Use injected position for nmea_primary if injected_positions["nmea_primary"] is not None: position = injected_positions["nmea_primary"] positions[position["source"]] = position if not skip_db_writes: self.database.store_position(position) self.structured_logger.info("nmea_primary", "Injected position", {"position": position}) if "nmea_secondary" not in injected_positions and self.nmea_secondary_collector: try: position = await self.nmea_secondary_collector.get_latest_position() if position: positions[position["source"]] = position if not skip_db_writes: self.database.store_position(position) self.structured_logger.info("nmea_secondary", "Updated position", {"position": position}) except Exception as e: logger.error(f"Error getting NMEA secondary position: {e}") self.structured_logger.error("nmea_secondary", f"Position error: {e}") elif "nmea_secondary" in injected_positions: # Use injected position for nmea_secondary if injected_positions["nmea_secondary"] is not None: position = injected_positions["nmea_secondary"] positions[position["source"]] = position if not skip_db_writes: self.database.store_position(position) self.structured_logger.info("nmea_secondary", "Injected position", {"position": position}) # Run validation logger.info(f"Collected {len(positions)} positions, running validation") try: validation_result = self.validator.validate_positions(positions) if skip_db_writes: # DEMO_UNIT mode: store validation for live dashboard display # but delete recent "live" records to prevent accumulation # (keeps only last few minutes of live data, historical data untouched) self._store_demo_validation(validation_result) else: self.database.store_validation(validation_result) # Sync to server if enabled (only when not in DEMO_UNIT mode) if self.server_sync: try: if self.server_sync.sync_validation(validation_result): logger.debug("Validation synced to server") else: logger.debug("Validation queued for later sync") except Exception as e: logger.warning(f"Server sync error: {e}") # Log validation result to terminal is_valid = validation_result["is_valid"] missing_sources = validation_result.get("sources_missing", []) stale_sources = validation_result.get("sources_stale", []) coordinate_differences = validation_result.get("coordinate_differences", {}) validation_details = validation_result.get("validation_details", {}) max_distance = validation_details.get("max_distance_meters", 0.0) if is_valid: logger.info("✓ Validation PASSED") if missing_sources: logger.info(f" Missing sources: {', '.join(missing_sources)}") if stale_sources: logger.info(f" Stale sources: {', '.join(stale_sources)}") if coordinate_differences: logger.info(f" Max distance difference: {max_distance:.2f}m") else: logger.info(" All sources within threshold") else: logger.warning("✗ Validation FAILED") # Check if failure is due to distance (GPS jamming/spoofing alert) threshold = validation_details.get('threshold_meters', 0) if max_distance > threshold: distance_km = max_distance / 1000.0 logger.warning("") logger.warning("=" * 60) logger.warning("🚨 GPS Jamming or Spoofing Alert! 🚨") logger.warning(f" Location Distance: {distance_km:.1f} km") logger.warning("=" * 60) logger.warning("") if missing_sources: logger.warning(f" Missing sources: {', '.join(missing_sources)}") if stale_sources: logger.warning(f" Stale sources: {', '.join(stale_sources)}") if coordinate_differences: logger.warning(f" Max distance difference: {max_distance:.2f}m (threshold: {threshold}m)") # Log individual differences if there are any for pair, diff_info in coordinate_differences.items(): logger.warning(f" {pair}: {diff_info.get('distance_meters', 0):.2f}m") # Log to structured logger if is_valid: self.structured_logger.info( "validation", "Validation passed", {"validation": validation_result} ) else: self.structured_logger.warning( "validation", "Validation failed", {"validation": validation_result} ) # Handle buzzer alarm based on validation status # Alarm triggers when: degraded, at risk, or no connection (any validation failure) # Status changes: # - "at risk" (crit): has_alert AND distance exceeds threshold # - "degraded" (warn): validation failed but no distance alert # - "healthy": validation passed self._handle_buzzer_alarm(is_valid, missing_sources, stale_sources, max_distance > validation_details.get('threshold_meters', 0)) except Exception as e: logger.error(f"Error during validation: {e}") self.structured_logger.error("validation", f"Validation error: {e}") logger.info("Iteration complete") async def stop(self): """Stop GNSS Guard system""" logger.info("Stopping GNSS Guard system") self.running = False # Stop buzzer service if self.buzzer_service: self.buzzer_service.shutdown() # Stop NMEA collectors if self.nmea_primary_collector: await self.nmea_primary_collector.stop() if self.nmea_secondary_collector: await self.nmea_secondary_collector.stop() # Log shutdown before closing logger self.structured_logger.info("system", "GNSS Guard stopped") # Close logger self.structured_logger.close() async def main(): """Main entry point""" # Setup logging logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) # Load configuration config = Config() # Create and start GNSS Guard guard = GNSSGuard(config) try: await guard.start() except KeyboardInterrupt: logger.info("Received keyboard interrupt") finally: await guard.stop() if __name__ == "__main__": asyncio.run(main())