- 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
360 lines
12 KiB
Python
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()
|