#!/usr/bin/env python3 """ Lightning Monitor for Venus OS Connects to the Blitzortung community lightning detection network via WebSocket, buffers nearby strikes within a configurable radius, detects approaching storms, and publishes a summary to D-Bus for display in the venus-html5-app dashboard. """ import json import logging import os import signal import sys import time sys.path.insert(1, os.path.join(os.path.dirname(__file__), 'ext', 'velib_python')) sys.path.insert(1, '/opt/victronenergy/velib_python') sys.path.insert(1, os.path.join(os.path.dirname(__file__), '..', 'lib')) sys.path.insert(1, '/data/lib') try: from gi.repository import GLib except ImportError: print("ERROR: GLib not available. This script must run on Venus OS.") sys.exit(1) try: import dbus from dbus.mainloop.glib import DBusGMainLoop from vedbus import VeDbusService from settingsdevice import SettingsDevice except ImportError as e: print(f"ERROR: Required module not available: {e}") sys.exit(1) from config import ( SERVICE_NAME, BLITZORTUNG_SERVERS, BLITZORTUNG_INIT_MSG, STRIKE_RADIUS_MILES, STRIKE_MAX_AGE_SECONDS, MIN_STRIKES_ACTIVE, ANALYSIS_INTERVAL_SECONDS, WINDOW_SIZE_SECONDS, NUM_WINDOWS, MIN_WINDOWS_FOR_APPROACH, R_SQUARED_APPROACHING, R_SQUARED_DOWNGRADE, MAX_BEARING_STDDEV, MAX_ETA_MINUTES, RECONNECT_BASE_DELAY, RECONNECT_MAX_DELAY, GPS_SAMPLE_INTERVAL, STALE_THRESHOLD_SECONDS, LOGGING_CONFIG, ) from signal_reader import SignalGpsReader from blitzortung_client import BlitzortungClient, STATUS_CONNECTED from strike_buffer import StrikeBuffer from analysis_engine import AnalysisEngine VERSION = '1.0.1' class LightningController: """Coordinates GPS, Blitzortung client, strike buffer, analysis, and D-Bus.""" def __init__(self): self._setup_logging() self.logger = logging.getLogger('Lightning') self.logger.info(f"Initializing Lightning Monitor v{VERSION}") self.bus = dbus.SystemBus() self._create_dbus_service() self._setup_settings() self.gps = SignalGpsReader(self.bus) self.current_lat = None self.current_lon = None self.last_gps_check = 0 self.strike_buffer = StrikeBuffer(STRIKE_RADIUS_MILES, STRIKE_MAX_AGE_SECONDS) self.analysis = AnalysisEngine({ 'window_size': WINDOW_SIZE_SECONDS, 'num_windows': NUM_WINDOWS, 'min_strikes_active': MIN_STRIKES_ACTIVE, 'min_windows_approach': MIN_WINDOWS_FOR_APPROACH, 'r2_approaching': R_SQUARED_APPROACHING, 'r2_downgrade': R_SQUARED_DOWNGRADE, 'max_bearing_stddev': MAX_BEARING_STDDEV, 'max_eta_minutes': MAX_ETA_MINUTES, }) self.ws_client = BlitzortungClient( servers=BLITZORTUNG_SERVERS, init_msg=BLITZORTUNG_INIT_MSG, on_strike=self._on_strike, base_delay=RECONNECT_BASE_DELAY, max_delay=RECONNECT_MAX_DELAY, ) self.last_analysis_time = 0 self._last_status = None self._last_disconnect_time = None if self.enabled: self.ws_client.start() GLib.timeout_add(1000, self._main_loop) self.logger.info("Initialized. Analysis every %ds, strike radius %.0f mi", ANALYSIS_INTERVAL_SECONDS, STRIKE_RADIUS_MILES) def _setup_logging(self): level = getattr(logging, LOGGING_CONFIG['level'], logging.INFO) fmt = ('%(asctime)s %(levelname)s %(name)s: %(message)s' if LOGGING_CONFIG['include_timestamp'] else '%(levelname)s %(name)s: %(message)s') logging.basicConfig(level=level, format=fmt, stream=sys.stdout) def _create_dbus_service(self): self.logger.info(f"Creating D-Bus service: {SERVICE_NAME}") max_retries = 5 retry_delay = 1.0 for attempt in range(max_retries): try: self.dbus_service = VeDbusService( SERVICE_NAME, self.bus, register=False) break except dbus.exceptions.NameExistsException: if attempt < max_retries - 1: self.logger.warning( f"D-Bus name exists, retrying in {retry_delay}s " f"(attempt {attempt + 1}/{max_retries})") time.sleep(retry_delay) retry_delay *= 2 else: raise svc = self.dbus_service svc.add_path('/Mgmt/ProcessName', 'dbus-lightning') svc.add_path('/Mgmt/ProcessVersion', VERSION) svc.add_path('/Mgmt/Connection', 'local') svc.add_path('/DeviceInstance', 0) svc.add_path('/ProductId', 0xA162) svc.add_path('/ProductName', 'Lightning Monitor') svc.add_path('/FirmwareVersion', VERSION) svc.add_path('/Connected', 1) svc.add_path('/ConnectionStatus', 'disconnected') svc.add_path('/Active', 0) svc.add_path('/StrikeCount15m', 0) svc.add_path('/NearestDistance', None) svc.add_path('/CentroidBearing', None) svc.add_path('/CentroidDistance', None) svc.add_path('/Cardinal', None) svc.add_path('/Approaching', 0) svc.add_path('/ApproachSpeed', None) svc.add_path('/EtaMinutes', None) svc.add_path('/Confidence', None) svc.add_path('/LastUpdate', 0) svc.add_path('/Summary/Json', '') svc.add_path('/Settings/Enabled', 1, writeable=True, onchangecallback=self._on_setting_changed) svc.register() self.logger.info("D-Bus service created") def _setup_settings(self): self.settings = None try: path = '/Settings/Lightning' settings_def = { 'Enabled': [path + '/Enabled', 1, 0, 1], } self.settings = SettingsDevice( self.bus, settings_def, self._on_persistent_setting_changed) if self.settings: self._load_settings() self.logger.info("Persistent settings initialized") except Exception as e: self.logger.warning(f"Could not initialize persistent settings: {e}") self.enabled = True def _load_settings(self): if not self.settings: return try: self.enabled = bool(self.settings['Enabled']) self.dbus_service['/Settings/Enabled'] = 1 if self.enabled else 0 self.logger.info(f"Loaded settings: enabled={self.enabled}") except Exception as e: self.logger.warning(f"Error loading settings: {e}") self.enabled = True def _on_persistent_setting_changed(self, setting, old_value, new_value): self.logger.info(f"Persistent setting changed: {setting} = {new_value}") self._load_settings() def _on_setting_changed(self, path, value): self.logger.info(f"Setting changed: {path} = {value}") if path == '/Settings/Enabled': self.enabled = bool(value) if self.settings: try: self.settings['Enabled'] = 1 if self.enabled else 0 except Exception: pass if self.enabled: self.ws_client.start() else: self.ws_client.stop() self._clear_summary() return True def _on_strike(self, lat, lon, timestamp_ms): """Called from WebSocket thread for each decoded strike.""" if self.current_lat is None or self.current_lon is None: return kept = self.strike_buffer.add( lat, lon, timestamp_ms, self.current_lat, self.current_lon) if kept: self.logger.debug(f"Strike kept: ({lat:.2f}, {lon:.2f})") def _run_analysis(self): """Run the analysis engine and publish results to D-Bus.""" if self.current_lat is None or self.current_lon is None: return strikes = self.strike_buffer.get_strikes() summary = self.analysis.analyze(strikes, self.current_lat, self.current_lon) summary['connection_status'] = self.ws_client.status summary['last_updated'] = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()) svc = self.dbus_service svc['/Active'] = 1 if summary['active'] else 0 svc['/StrikeCount15m'] = summary['strike_count_15m'] svc['/NearestDistance'] = summary.get('nearest_distance') svc['/CentroidBearing'] = summary.get('centroid_bearing') svc['/CentroidDistance'] = summary.get('centroid_distance') svc['/Cardinal'] = summary.get('cardinal') svc['/Approaching'] = 1 if summary.get('approaching') else 0 svc['/ApproachSpeed'] = summary.get('approach_speed') svc['/EtaMinutes'] = summary.get('eta_minutes') svc['/Confidence'] = summary.get('confidence') svc['/LastUpdate'] = int(time.time()) svc['/Summary/Json'] = json.dumps(summary) if summary['active']: self.logger.info( f"Analysis: {summary['strike_count_15m']} strikes, " f"nearest {summary.get('nearest_distance')}mi " f"{summary.get('cardinal', '?')}, " f"approaching={summary.get('approaching')}" + (f", ETA {summary.get('eta_minutes')}min" if summary.get('eta_minutes') else "")) def _clear_summary(self): """Clear all summary fields on D-Bus.""" svc = self.dbus_service svc['/Active'] = 0 svc['/StrikeCount15m'] = 0 svc['/NearestDistance'] = None svc['/CentroidBearing'] = None svc['/CentroidDistance'] = None svc['/Cardinal'] = None svc['/Approaching'] = 0 svc['/ApproachSpeed'] = None svc['/EtaMinutes'] = None svc['/Confidence'] = None svc['/Summary/Json'] = '' def _main_loop(self): try: if not self.enabled: self.dbus_service['/ConnectionStatus'] = 'disabled' return True now = time.time() if now - self.last_gps_check >= GPS_SAMPLE_INTERVAL: pos = self.gps.get_position() if pos: self.current_lat, self.current_lon = pos self.last_gps_check = now current_status = self.ws_client.status if current_status != self._last_status: self.dbus_service['/ConnectionStatus'] = current_status self._last_status = current_status if current_status != STATUS_CONNECTED: self._last_disconnect_time = now else: self._last_disconnect_time = None if (self._last_disconnect_time and now - self._last_disconnect_time > STALE_THRESHOLD_SECONDS): self._clear_summary() self._last_disconnect_time = None if now - self.last_analysis_time >= ANALYSIS_INTERVAL_SECONDS: self._run_analysis() self.last_analysis_time = now except dbus.exceptions.DBusException as e: self.logger.warning(f"D-Bus error: {e}") except Exception as e: self.logger.exception(f"Unexpected error: {e}") return True def shutdown(self): self.ws_client.stop() self.logger.info("Shutdown complete") def main(): DBusGMainLoop(set_as_default=True) print("=" * 60) print(f"Lightning Monitor v{VERSION}") print("=" * 60) mainloop = None controller = None def signal_handler(signum, frame): try: sig_name = signal.Signals(signum).name except ValueError: sig_name = str(signum) logging.info(f"Received {sig_name}, shutting down...") if controller: controller.shutdown() if mainloop is not None: mainloop.quit() signal.signal(signal.SIGTERM, signal_handler) signal.signal(signal.SIGINT, signal_handler) try: controller = LightningController() mainloop = GLib.MainLoop() mainloop.run() except KeyboardInterrupt: print("\nShutdown requested") if controller: controller.shutdown() except Exception as e: logging.error(f"Fatal error: {e}", exc_info=True) sys.exit(1) finally: logging.info("Service stopped") if __name__ == '__main__': main()