Files
venus/dbus-lightning/lightning.py
Paul G 36a07dacb9 Extract shared signal-based D-Bus readers into lib/signal_reader.py
- Added lib/signal_reader.py with SignalGpsReader, SignalMeteoReader, and
  SignalDepthReader that use PropertiesChanged signal subscriptions instead
  of polling via GetValue(), reducing D-Bus overhead at steady state.
- Each reader discovers its service dynamically, seeds its cache with a
  one-shot GetValue, then relies on signals for all subsequent updates.
- Refactored dbus-tides, dbus-windy-station, dbus-no-foreign-land,
  dbus-lightning, and dbus-meteoblue-forecast to import from the shared
  library, removing ~600 lines of duplicated _unwrap() helpers and
  per-service GPS/meteo/depth reader classes.
- Updated install.sh for all five services to deploy signal_reader.py
  to /data/lib/ on the target device.
- Updated build-package.sh for all five services to bundle
  signal_reader.py into the .tar.gz package.
- Updated README.md with the new lib/ entry in the project table and
  documented the shared D-Bus readers pattern.
- Bumped version numbers in affected services (e.g. nfl_tracking 2.0.1).

Made-with: Cursor
2026-03-27 01:03:16 +00:00

360 lines
12 KiB
Python

#!/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()