diff --git a/README.md b/README.md index 93915b6..2cd0e81 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ All D-Bus services follow the same deployment pattern: build a `.tar.gz` package | --------------------------------------------------- | ------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | [axiom-nmea](axiom-nmea/) | Library + Service | Python | Decodes Raymarine LightHouse protobuf multicast into NMEA 0183 sentences and Venus OS D-Bus services. Includes protocol documentation, debug tools, and a deployable D-Bus publisher. | | [dbus-generator-ramp](dbus-generator-ramp/) | D-Bus Service | Python | Gradually ramps inverter/charger input current when running on generator to avoid overload. Features warm-up hold, overload detection with fast recovery, and a persistent power correlation learning model. | +| [lib](lib/) | Shared Library | Python | Signal-based D-Bus readers for GPS, meteo, and depth data. Uses `PropertiesChanged` subscriptions instead of polling, shared across all D-Bus services that consume sensor data. | | [dbus-lightning](dbus-lightning/) | D-Bus Service | Python | Monitors real-time lightning strikes from the Blitzortung network via WebSocket. Filters by distance, analyzes storm approach speed, and estimates ETA. | | [dbus-meteoblue-forecast](dbus-meteoblue-forecast/) | D-Bus Service | Python | Fetches 7-day weather forecasts from the Meteoblue API (wind, waves, precipitation, temperature). Adjusts refresh rate based on boat movement. | | [dbus-no-foreign-land](dbus-no-foreign-land/) | D-Bus Service | Python | Sends GPS position and track data to noforeignland.com. Includes a QML settings page for the Venus OS GUI. | @@ -39,7 +40,9 @@ dbus-/ └── README.md ``` -At install time, `install.sh` symlinks `velib_python` from `/opt/victronenergy/`, registers the service with daemontools, and adds an `rc.local` entry for persistence across firmware updates. +At install time, `install.sh` symlinks `velib_python` from `/opt/victronenergy/`, installs the shared `lib/signal_reader.py` to `/data/lib/`, registers the service with daemontools, and adds an `rc.local` entry for persistence across firmware updates. + +**Shared D-Bus Readers** -- Services that read GPS, meteo, or depth data from D-Bus should use the signal-based readers in `lib/signal_reader.py` (`SignalGpsReader`, `SignalMeteoReader`, `SignalDepthReader`) instead of polling with `GetValue()`. These readers bootstrap with a one-shot read, then subscribe to `PropertiesChanged` signals for zero-cost steady-state updates. ## Deployment diff --git a/dbus-lightning/build-package.sh b/dbus-lightning/build-package.sh index 6ab252f..d30ef3c 100755 --- a/dbus-lightning/build-package.sh +++ b/dbus-lightning/build-package.sh @@ -74,6 +74,10 @@ cp "$SCRIPT_DIR/config.py" "$PACKAGE_DIR/" cp "$SCRIPT_DIR/blitzortung_client.py" "$PACKAGE_DIR/" cp "$SCRIPT_DIR/strike_buffer.py" "$PACKAGE_DIR/" cp "$SCRIPT_DIR/analysis_engine.py" "$PACKAGE_DIR/" +if [ -f "$SCRIPT_DIR/../lib/signal_reader.py" ]; then + cp "$SCRIPT_DIR/../lib/signal_reader.py" "$PACKAGE_DIR/" + echo " Bundled shared library: signal_reader.py" +fi echo "3. Copying service files..." cp "$SCRIPT_DIR/service/run" "$PACKAGE_DIR/service/" diff --git a/dbus-lightning/install.sh b/dbus-lightning/install.sh index 0686d23..b183d0e 100755 --- a/dbus-lightning/install.sh +++ b/dbus-lightning/install.sh @@ -96,7 +96,26 @@ else echo " Symlink already exists" fi -echo "3. Creating service symlink..." +echo "3. Installing shared library..." +SHARED_LIB_DIR="/data/lib" +mkdir -p "$SHARED_LIB_DIR" +SIGNAL_READER="" +if [ -f "$INSTALL_DIR/signal_reader.py" ]; then + SIGNAL_READER="$INSTALL_DIR/signal_reader.py" +elif [ -f "$(dirname "$INSTALL_DIR")/lib/signal_reader.py" ]; then + SIGNAL_READER="$(dirname "$INSTALL_DIR")/lib/signal_reader.py" +fi +if [ -n "$SIGNAL_READER" ]; then + cp "$SIGNAL_READER" "$SHARED_LIB_DIR/" + echo " Installed signal_reader.py to $SHARED_LIB_DIR" +elif [ -f "$SHARED_LIB_DIR/signal_reader.py" ]; then + echo " signal_reader.py already installed" +else + echo "WARNING: signal_reader.py not found." + echo " Copy lib/signal_reader.py to $SHARED_LIB_DIR/ manually." +fi + +echo "4. Creating service symlink..." if [ -L "$SERVICE_DIR/dbus-lightning" ]; then echo " Service link already exists, removing old link..." rm "$SERVICE_DIR/dbus-lightning" @@ -113,10 +132,10 @@ else exit 1 fi -echo "4. Creating log directory..." +echo "5. Creating log directory..." mkdir -p /var/log/dbus-lightning -echo "5. Setting up rc.local for persistence..." +echo "6. Setting up rc.local for persistence..." RC_LOCAL="/data/rc.local" if [ ! -f "$RC_LOCAL" ]; then echo "#!/bin/bash" > "$RC_LOCAL" @@ -134,7 +153,7 @@ else echo " Already in rc.local" fi -echo "6. Activating service..." +echo "7. Activating service..." sleep 2 if command -v svstat >/dev/null 2>&1; then if svstat "$SERVICE_DIR/dbus-lightning" 2>/dev/null | grep -q "up"; then diff --git a/dbus-lightning/lightning.py b/dbus-lightning/lightning.py index c8f9d88..9b692ba 100644 --- a/dbus-lightning/lightning.py +++ b/dbus-lightning/lightning.py @@ -17,6 +17,8 @@ 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 @@ -44,89 +46,12 @@ from config import ( 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.0' - -BUS_ITEM = "com.victronenergy.BusItem" -SYSTEM_SERVICE = "com.victronenergy.system" - - -def _unwrap(v): - """Minimal dbus value unwrap.""" - if v is None: - return None - if isinstance(v, (dbus.Int16, dbus.Int32, dbus.Int64, - dbus.UInt16, dbus.UInt32, dbus.UInt64, dbus.Byte)): - return int(v) - if isinstance(v, dbus.Double): - return float(v) - if isinstance(v, (dbus.String, dbus.Signature)): - return str(v) - if isinstance(v, dbus.Boolean): - return bool(v) - if isinstance(v, dbus.Array): - return [_unwrap(x) for x in v] if len(v) > 0 else None - if isinstance(v, (dbus.Dictionary, dict)): - return {k: _unwrap(x) for k, x in v.items()} - return v - - -class GpsReader: - """Read GPS position from Venus OS D-Bus.""" - - def __init__(self, bus): - self.bus = bus - self._gps_service = None - self._proxy_lat = None - self._proxy_lon = None - - def _get_proxy(self, service, path): - try: - obj = self.bus.get_object(service, path, introspect=False) - return dbus.Interface(obj, BUS_ITEM) - except dbus.exceptions.DBusException: - return None - - def _refresh_gps_service(self): - if self._gps_service: - return True - try: - proxy = self._get_proxy(SYSTEM_SERVICE, "/GpsService") - if proxy: - svc = _unwrap(proxy.GetValue()) - if svc and isinstance(svc, str): - self._gps_service = svc - self._proxy_lat = self._get_proxy(svc, "/Position/Latitude") - self._proxy_lon = self._get_proxy(svc, "/Position/Longitude") - if not self._proxy_lat: - self._proxy_lat = self._get_proxy(svc, "/Latitude") - if not self._proxy_lon: - self._proxy_lon = self._get_proxy(svc, "/Longitude") - return True - except dbus.exceptions.DBusException: - pass - return False - - def get_position(self): - """Return (lat, lon) or None if no fix.""" - if not self._refresh_gps_service(): - return None - lat, lon = None, None - try: - if self._proxy_lat: - lat = _unwrap(self._proxy_lat.GetValue()) - if self._proxy_lon: - lon = _unwrap(self._proxy_lon.GetValue()) - except dbus.exceptions.DBusException: - self._gps_service = None - return None - if (lat is not None and lon is not None and - -90 <= float(lat) <= 90 and -180 <= float(lon) <= 180): - return (float(lat), float(lon)) - return None +VERSION = '1.0.1' class LightningController: @@ -142,7 +67,7 @@ class LightningController: self._create_dbus_service() self._setup_settings() - self.gps = GpsReader(self.bus) + self.gps = SignalGpsReader(self.bus) self.current_lat = None self.current_lon = None self.last_gps_check = 0 diff --git a/dbus-meteoblue-forecast/build-package.sh b/dbus-meteoblue-forecast/build-package.sh index 288728d..1a52826 100755 --- a/dbus-meteoblue-forecast/build-package.sh +++ b/dbus-meteoblue-forecast/build-package.sh @@ -79,6 +79,10 @@ echo "2. Copying application files..." cp "$SCRIPT_DIR/meteoblue_forecast.py" "$PACKAGE_DIR/" cp "$SCRIPT_DIR/config.py" "$PACKAGE_DIR/" cp "$SCRIPT_DIR/forecast_config.json" "$PACKAGE_DIR/" +if [ -f "$SCRIPT_DIR/../lib/signal_reader.py" ]; then + cp "$SCRIPT_DIR/../lib/signal_reader.py" "$PACKAGE_DIR/" + echo " Bundled shared library: signal_reader.py" +fi echo "3. Copying service files..." cp "$SCRIPT_DIR/service/run" "$PACKAGE_DIR/service/" diff --git a/dbus-meteoblue-forecast/install.sh b/dbus-meteoblue-forecast/install.sh index e64051f..f6f2693 100644 --- a/dbus-meteoblue-forecast/install.sh +++ b/dbus-meteoblue-forecast/install.sh @@ -98,7 +98,26 @@ else echo " Symlink already exists" fi -echo "3. Creating service symlink..." +echo "3. Installing shared library..." +SHARED_LIB_DIR="/data/lib" +mkdir -p "$SHARED_LIB_DIR" +SIGNAL_READER="" +if [ -f "$INSTALL_DIR/signal_reader.py" ]; then + SIGNAL_READER="$INSTALL_DIR/signal_reader.py" +elif [ -f "$(dirname "$INSTALL_DIR")/lib/signal_reader.py" ]; then + SIGNAL_READER="$(dirname "$INSTALL_DIR")/lib/signal_reader.py" +fi +if [ -n "$SIGNAL_READER" ]; then + cp "$SIGNAL_READER" "$SHARED_LIB_DIR/" + echo " Installed signal_reader.py to $SHARED_LIB_DIR" +elif [ -f "$SHARED_LIB_DIR/signal_reader.py" ]; then + echo " signal_reader.py already installed" +else + echo "WARNING: signal_reader.py not found." + echo " Copy lib/signal_reader.py to $SHARED_LIB_DIR/ manually." +fi + +echo "4. Creating service symlink..." if [ -L "$SERVICE_DIR/dbus-meteoblue-forecast" ]; then echo " Service link already exists, removing old link..." rm "$SERVICE_DIR/dbus-meteoblue-forecast" @@ -115,10 +134,10 @@ else exit 1 fi -echo "4. Creating log directory..." +echo "5. Creating log directory..." mkdir -p /var/log/dbus-meteoblue-forecast -echo "5. Setting up rc.local for persistence..." +echo "6. Setting up rc.local for persistence..." RC_LOCAL="/data/rc.local" if [ ! -f "$RC_LOCAL" ]; then echo "#!/bin/bash" > "$RC_LOCAL" @@ -136,7 +155,7 @@ else echo " Already in rc.local" fi -echo "6. Activating service..." +echo "7. Activating service..." sleep 2 if command -v svstat >/dev/null 2>&1; then if svstat "$SERVICE_DIR/dbus-meteoblue-forecast" 2>/dev/null | grep -q "up"; then diff --git a/dbus-meteoblue-forecast/meteoblue_forecast.py b/dbus-meteoblue-forecast/meteoblue_forecast.py index c687b05..3b90dc0 100644 --- a/dbus-meteoblue-forecast/meteoblue_forecast.py +++ b/dbus-meteoblue-forecast/meteoblue_forecast.py @@ -32,6 +32,8 @@ from urllib.request import Request, urlopen 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 @@ -57,30 +59,9 @@ from config import ( CONFIG_FILE, LOGGING_CONFIG, ) -VERSION = '2.1.0' +from signal_reader import SignalGpsReader -BUS_ITEM = "com.victronenergy.BusItem" -SYSTEM_SERVICE = "com.victronenergy.system" - - -def _unwrap(v): - """Minimal dbus value unwrap.""" - if v is None: - return None - if isinstance(v, (dbus.Int16, dbus.Int32, dbus.Int64, - dbus.UInt16, dbus.UInt32, dbus.UInt64, dbus.Byte)): - return int(v) - if isinstance(v, dbus.Double): - return float(v) - if isinstance(v, (dbus.String, dbus.Signature)): - return str(v) - if isinstance(v, dbus.Boolean): - return bool(v) - if isinstance(v, dbus.Array): - return [_unwrap(x) for x in v] if len(v) > 0 else None - if isinstance(v, (dbus.Dictionary, dict)): - return {k: _unwrap(x) for k, x in v.items()} - return v +VERSION = '2.1.1' def haversine_distance(lat1, lon1, lat2, lon2): @@ -94,61 +75,6 @@ def haversine_distance(lat1, lon1, lat2, lon2): return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) -class GpsReader: - """Read GPS position from Venus OS D-Bus.""" - - def __init__(self, bus): - self.bus = bus - self._gps_service = None - self._proxy_lat = None - self._proxy_lon = None - - def _get_proxy(self, service, path): - try: - obj = self.bus.get_object(service, path, introspect=False) - return dbus.Interface(obj, BUS_ITEM) - except dbus.exceptions.DBusException: - return None - - def _refresh_gps_service(self): - if self._gps_service: - return True - try: - proxy = self._get_proxy(SYSTEM_SERVICE, "/GpsService") - if proxy: - svc = _unwrap(proxy.GetValue()) - if svc and isinstance(svc, str): - self._gps_service = svc - self._proxy_lat = self._get_proxy(svc, "/Position/Latitude") - self._proxy_lon = self._get_proxy(svc, "/Position/Longitude") - if not self._proxy_lat: - self._proxy_lat = self._get_proxy(svc, "/Latitude") - if not self._proxy_lon: - self._proxy_lon = self._get_proxy(svc, "/Longitude") - return True - except dbus.exceptions.DBusException: - pass - return False - - def get_position(self): - """Return (lat, lon) or None if no fix.""" - if not self._refresh_gps_service(): - return None - lat, lon = None, None - try: - if self._proxy_lat: - lat = _unwrap(self._proxy_lat.GetValue()) - if self._proxy_lon: - lon = _unwrap(self._proxy_lon.GetValue()) - except dbus.exceptions.DBusException: - self._gps_service = None - return None - if (lat is not None and lon is not None and - -90 <= float(lat) <= 90 and -180 <= float(lon) <= 180): - return (float(lat), float(lon)) - return None - - class MovementDetector: """Determines whether the vessel is moving based on GPS history.""" @@ -381,7 +307,7 @@ class MeteoblueForecastController: self._create_dbus_service() self._setup_settings() - self.gps = GpsReader(self.bus) + self.gps = SignalGpsReader(self.bus) self.movement = MovementDetector( GPS_HISTORY_SIZE, MOVEMENT_THRESHOLD_METERS) self.api_client = MeteoblueApiClient(self.logger) diff --git a/dbus-no-foreign-land/build-package.sh b/dbus-no-foreign-land/build-package.sh index 674154f..0e51408 100755 --- a/dbus-no-foreign-land/build-package.sh +++ b/dbus-no-foreign-land/build-package.sh @@ -96,6 +96,10 @@ echo "2. Copying application files..." cp "$SCRIPT_DIR/nfl_tracking.py" "$PACKAGE_DIR/" cp "$SCRIPT_DIR/config.py" "$PACKAGE_DIR/" cp "$SCRIPT_DIR/config.sample.ini" "$PACKAGE_DIR/" +if [ -f "$SCRIPT_DIR/../lib/signal_reader.py" ]; then + cp "$SCRIPT_DIR/../lib/signal_reader.py" "$PACKAGE_DIR/" + echo " Bundled shared library: signal_reader.py" +fi # Copy service files echo "3. Copying service files..." diff --git a/dbus-no-foreign-land/install.sh b/dbus-no-foreign-land/install.sh index c43e741..ae20840 100644 --- a/dbus-no-foreign-land/install.sh +++ b/dbus-no-foreign-land/install.sh @@ -107,7 +107,26 @@ else echo " Symlink already exists" fi -echo "3. Creating main service symlink..." +echo "3. Installing shared library..." +SHARED_LIB_DIR="/data/lib" +mkdir -p "$SHARED_LIB_DIR" +SIGNAL_READER="" +if [ -f "$INSTALL_DIR/signal_reader.py" ]; then + SIGNAL_READER="$INSTALL_DIR/signal_reader.py" +elif [ -f "$(dirname "$INSTALL_DIR")/lib/signal_reader.py" ]; then + SIGNAL_READER="$(dirname "$INSTALL_DIR")/lib/signal_reader.py" +fi +if [ -n "$SIGNAL_READER" ]; then + cp "$SIGNAL_READER" "$SHARED_LIB_DIR/" + echo " Installed signal_reader.py to $SHARED_LIB_DIR" +elif [ -f "$SHARED_LIB_DIR/signal_reader.py" ]; then + echo " signal_reader.py already installed" +else + echo "WARNING: signal_reader.py not found." + echo " Copy lib/signal_reader.py to $SHARED_LIB_DIR/ manually." +fi + +echo "4. Creating main service symlink..." if [ -L "$SERVICE_DIR/dbus-no-foreign-land" ]; then echo " Service link already exists, removing old link..." rm "$SERVICE_DIR/dbus-no-foreign-land" @@ -126,11 +145,11 @@ else exit 1 fi -echo "4. Creating data and log directories..." +echo "5. Creating data and log directories..." mkdir -p "$INSTALL_DIR" mkdir -p /var/log/dbus-no-foreign-land -echo "5. Setting up rc.local for persistence..." +echo "6. Setting up rc.local for persistence..." RC_LOCAL="/data/rc.local" if [ ! -f "$RC_LOCAL" ]; then echo "#!/bin/bash" > "$RC_LOCAL" @@ -148,7 +167,7 @@ else echo " Already in rc.local" fi -echo "6. Activating service..." +echo "7. Activating service..." sleep 2 if command -v svstat >/dev/null 2>&1; then if svstat "$SERVICE_DIR/dbus-no-foreign-land" 2>/dev/null | grep -q "up"; then diff --git a/dbus-no-foreign-land/nfl_tracking.py b/dbus-no-foreign-land/nfl_tracking.py index 7a23edb..b07231e 100644 --- a/dbus-no-foreign-land/nfl_tracking.py +++ b/dbus-no-foreign-land/nfl_tracking.py @@ -30,6 +30,8 @@ from pathlib import Path # Add velib_python to path (Venus OS standard location) 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 @@ -51,34 +53,11 @@ from config import ( SERVICE_NAME, NFL_API_URL, NFL_PLUGIN_API_KEY, TRACKING_CONFIG, CONFIG_DIR, TRACK_FILE, LOGGING_CONFIG, ) +from signal_reader import SignalGpsReader import threading # Version -VERSION = '2.0.0' - -BUS_ITEM = "com.victronenergy.BusItem" -SYSTEM_SERVICE = "com.victronenergy.system" - - -def _unwrap(v): - """Minimal dbus value unwrap.""" - if v is None: - return None - if isinstance(v, (dbus.Int16, dbus.Int32, dbus.Int64, dbus.UInt16, dbus.UInt32, dbus.UInt64, dbus.Byte)): - return int(v) - if isinstance(v, dbus.Double): - return float(v) - if isinstance(v, (dbus.String, dbus.Signature)): - return str(v) - if isinstance(v, dbus.Boolean): - return bool(v) - if isinstance(v, dbus.Array): - if len(v) == 0: - return None - return [_unwrap(x) for x in v] - if isinstance(v, (dbus.Dictionary, dict)): - return {k: _unwrap(x) for k, x in v.items()} - return v +VERSION = '2.0.1' def validate_position(lat, lon): @@ -107,113 +86,6 @@ def equirectangular_distance(lat1, lon1, lat2, lon2): return math.sqrt(x * x + y * y) * R -class GpsReader: - """Read GPS position from Venus OS D-Bus.""" - - def __init__(self, bus): - self.bus = bus - self._gps_service = None - self._proxy_gps_svc = None - self._proxy_lat = None - self._proxy_lon = None - self._proxy_pos = None - self._proxy_fix = None - self._proxy_lat_nested = None - self._proxy_lon_nested = None - - def _get_proxy(self, service, path): - try: - obj = self.bus.get_object(service, path, introspect=False) - return dbus.Interface(obj, BUS_ITEM) - except dbus.exceptions.DBusException: - return None - - def _refresh_gps_service(self): - """Resolve GpsService to actual service name.""" - if self._gps_service: - return True - try: - proxy = self._get_proxy(SYSTEM_SERVICE, "/GpsService") - if proxy: - v = proxy.GetValue() - svc = _unwrap(v) - if svc and isinstance(svc, str): - self._gps_service = svc - self._proxy_lat = self._get_proxy(svc, "/Latitude") - self._proxy_lon = self._get_proxy(svc, "/Longitude") - self._proxy_pos = self._get_proxy(svc, "/Position") - self._proxy_fix = self._get_proxy(svc, "/Fix") - self._proxy_lat_nested = self._get_proxy(svc, "/Position/Latitude") - self._proxy_lon_nested = self._get_proxy(svc, "/Position/Longitude") - return True - except dbus.exceptions.DBusException: - pass - return False - - def get_position(self): - """Return (lat, lon) or None if no fix.""" - if not self._refresh_gps_service(): - return None - - if self._proxy_fix: - try: - v = self._proxy_fix.GetValue() - f = _unwrap(v) - if f is not None and not f: - return None - except dbus.exceptions.DBusException: - pass - - lat, lon = None, None - - if self._proxy_lat: - try: - lat = _unwrap(self._proxy_lat.GetValue()) - except dbus.exceptions.DBusException: - pass - if lat is None and self._proxy_lat_nested: - try: - lat = _unwrap(self._proxy_lat_nested.GetValue()) - except dbus.exceptions.DBusException: - pass - - if self._proxy_lon: - try: - lon = _unwrap(self._proxy_lon.GetValue()) - except dbus.exceptions.DBusException: - pass - if lon is None and self._proxy_lon_nested: - try: - lon = _unwrap(self._proxy_lon_nested.GetValue()) - except dbus.exceptions.DBusException: - pass - - if lat is not None and lon is not None: - try: - lat_f = float(lat) - lon_f = float(lon) - if -90 <= lat_f <= 90 and -180 <= lon_f <= 180: - return (lat_f, lon_f) - except (TypeError, ValueError): - pass - - if self._proxy_pos: - try: - v = self._proxy_pos.GetValue() - pos = _unwrap(v) - if isinstance(pos, str): - parts = pos.split(",") - if len(parts) >= 2: - lat_f = float(parts[0].strip()) - lon_f = float(parts[1].strip()) - if -90 <= lat_f <= 90 and -180 <= lon_f <= 180: - return (lat_f, lon_f) - except (dbus.exceptions.DBusException, TypeError, ValueError): - pass - - return None - - def send_track_to_api(track, boat_api_key, log): """POST track to NFL API. Returns True on success.""" if not track: @@ -353,7 +225,7 @@ class NflTrackingController: self._setup_settings() # GPS reader - self.gps = GpsReader(self.bus) + self.gps = SignalGpsReader(self.bus) # Load existing track (capped to prevent OOM) self.track = load_track_from_file(TRACK_FILE) diff --git a/dbus-tides/build-package.sh b/dbus-tides/build-package.sh index 9560a1a..86e481a 100755 --- a/dbus-tides/build-package.sh +++ b/dbus-tides/build-package.sh @@ -84,6 +84,10 @@ cp "$SCRIPT_DIR/tide_harmonics.py" "$PACKAGE_DIR/" cp "$SCRIPT_DIR/tide_predictor.py" "$PACKAGE_DIR/" cp "$SCRIPT_DIR/tide_merger.py" "$PACKAGE_DIR/" cp "$SCRIPT_DIR/tide_adapter.py" "$PACKAGE_DIR/" +if [ -f "$SCRIPT_DIR/../lib/signal_reader.py" ]; then + cp "$SCRIPT_DIR/../lib/signal_reader.py" "$PACKAGE_DIR/" + echo " Bundled shared library: signal_reader.py" +fi echo "3. Copying constituent data..." if [ -f "$SCRIPT_DIR/constituents/noaa_stations.json.gz" ]; then diff --git a/dbus-tides/config.py b/dbus-tides/config.py index d01e669..22d9619 100644 --- a/dbus-tides/config.py +++ b/dbus-tides/config.py @@ -153,4 +153,4 @@ LOGGING_CONFIG = { 'include_timestamp': False, } -VERSION = '1.0.19' +VERSION = '1.0.20' diff --git a/dbus-tides/install.sh b/dbus-tides/install.sh index f708b76..313e95950 100644 --- a/dbus-tides/install.sh +++ b/dbus-tides/install.sh @@ -98,10 +98,29 @@ else echo " Symlink already exists" fi -echo "3. Creating data directories..." +echo "3. Installing shared library..." +SHARED_LIB_DIR="/data/lib" +mkdir -p "$SHARED_LIB_DIR" +SIGNAL_READER="" +if [ -f "$INSTALL_DIR/signal_reader.py" ]; then + SIGNAL_READER="$INSTALL_DIR/signal_reader.py" +elif [ -f "$(dirname "$INSTALL_DIR")/lib/signal_reader.py" ]; then + SIGNAL_READER="$(dirname "$INSTALL_DIR")/lib/signal_reader.py" +fi +if [ -n "$SIGNAL_READER" ]; then + cp "$SIGNAL_READER" "$SHARED_LIB_DIR/" + echo " Installed signal_reader.py to $SHARED_LIB_DIR" +elif [ -f "$SHARED_LIB_DIR/signal_reader.py" ]; then + echo " signal_reader.py already installed" +else + echo "WARNING: signal_reader.py not found." + echo " Copy lib/signal_reader.py to $SHARED_LIB_DIR/ manually." +fi + +echo "4. Creating data directories..." mkdir -p "$INSTALL_DIR/constituents" -echo "4. Creating service symlink..." +echo "5. Creating service symlink..." if [ -L "$SERVICE_DIR/dbus-tides" ]; then echo " Service link already exists, removing old link..." rm "$SERVICE_DIR/dbus-tides" @@ -118,10 +137,10 @@ else exit 1 fi -echo "5. Creating log directory..." +echo "6. Creating log directory..." mkdir -p /var/log/dbus-tides -echo "6. Setting up rc.local for persistence..." +echo "7. Setting up rc.local for persistence..." RC_LOCAL="/data/rc.local" if [ ! -f "$RC_LOCAL" ]; then echo "#!/bin/bash" > "$RC_LOCAL" @@ -139,7 +158,7 @@ else echo " Already in rc.local" fi -echo "7. Activating service..." +echo "8. Activating service..." sleep 2 if command -v svstat >/dev/null 2>&1; then if svstat "$SERVICE_DIR/dbus-tides" 2>/dev/null | grep -q "up"; then diff --git a/dbus-tides/tides.py b/dbus-tides/tides.py index e60bbf7..7dbca0c 100644 --- a/dbus-tides/tides.py +++ b/dbus-tides/tides.py @@ -19,6 +19,8 @@ from collections import deque 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 @@ -45,35 +47,13 @@ from config import ( MODEL_RERUN_INTERVAL, LOGGING_CONFIG, ) +from signal_reader import SignalGpsReader, SignalDepthReader from depth_recorder import DepthRecorder from tide_detector import TideDetector from tide_predictor import TidePredictor from tide_merger import TideMerger from tide_adapter import TideAdapter -BUS_ITEM = "com.victronenergy.BusItem" -SYSTEM_SERVICE = "com.victronenergy.system" - - -def _unwrap(v): - """Minimal dbus value unwrap.""" - if v is None: - return None - if isinstance(v, (dbus.Int16, dbus.Int32, dbus.Int64, - dbus.UInt16, dbus.UInt32, dbus.UInt64, dbus.Byte)): - return int(v) - if isinstance(v, dbus.Double): - return float(v) - if isinstance(v, (dbus.String, dbus.Signature)): - return str(v) - if isinstance(v, dbus.Boolean): - return bool(v) - if isinstance(v, dbus.Array): - return [_unwrap(x) for x in v] if len(v) > 0 else None - if isinstance(v, (dbus.Dictionary, dict)): - return {k: _unwrap(x) for k, x in v.items()} - return v - def haversine_distance(lat1, lon1, lat2, lon2): """Great-circle distance in meters.""" @@ -86,123 +66,6 @@ def haversine_distance(lat1, lon1, lat2, lon2): return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) -class GpsReader: - """Read GPS position and speed from Venus OS D-Bus.""" - - def __init__(self, bus): - self.bus = bus - self._gps_service = None - self._proxy_lat = None - self._proxy_lon = None - self._proxy_speed = None - - def _get_proxy(self, service, path): - try: - obj = self.bus.get_object(service, path, introspect=False) - return dbus.Interface(obj, BUS_ITEM) - except dbus.exceptions.DBusException: - return None - - def _refresh_gps_service(self): - if self._gps_service: - return True - try: - proxy = self._get_proxy(SYSTEM_SERVICE, "/GpsService") - if proxy: - svc = _unwrap(proxy.GetValue()) - if svc and isinstance(svc, str): - self._gps_service = svc - self._proxy_lat = self._get_proxy(svc, "/Position/Latitude") - self._proxy_lon = self._get_proxy(svc, "/Position/Longitude") - self._proxy_speed = self._get_proxy(svc, "/Speed") - if not self._proxy_lat: - self._proxy_lat = self._get_proxy(svc, "/Latitude") - if not self._proxy_lon: - self._proxy_lon = self._get_proxy(svc, "/Longitude") - return True - except dbus.exceptions.DBusException: - pass - return False - - def get_position(self): - """Return (lat, lon) or None.""" - if not self._refresh_gps_service(): - return None - lat, lon = None, None - try: - if self._proxy_lat: - lat = _unwrap(self._proxy_lat.GetValue()) - if self._proxy_lon: - lon = _unwrap(self._proxy_lon.GetValue()) - except dbus.exceptions.DBusException: - self._gps_service = None - return None - if (lat is not None and lon is not None and - -90 <= float(lat) <= 90 and -180 <= float(lon) <= 180): - return (float(lat), float(lon)) - return None - - def get_speed(self): - """Return speed in m/s, or None.""" - if not self._refresh_gps_service(): - return None - try: - if self._proxy_speed: - v = _unwrap(self._proxy_speed.GetValue()) - if v is not None: - return float(v) - except dbus.exceptions.DBusException: - self._gps_service = None - return None - - -class DepthDbusReader: - """Read depth from Venus OS D-Bus navigation service.""" - - def __init__(self, bus): - self.bus = bus - self._nav_service = None - self._proxy_depth = None - - def _get_proxy(self, service, path): - try: - obj = self.bus.get_object(service, path, introspect=False) - return dbus.Interface(obj, BUS_ITEM) - except dbus.exceptions.DBusException: - return None - - def _refresh_nav_service(self): - if self._nav_service: - return True - try: - bus_obj = self.bus.get_object( - 'org.freedesktop.DBus', '/org/freedesktop/DBus') - iface = dbus.Interface(bus_obj, 'org.freedesktop.DBus') - names = iface.ListNames() - for name in names: - name_str = str(name) - if name_str.startswith('com.victronenergy.navigation.'): - self._nav_service = name_str - self._proxy_depth = self._get_proxy(name_str, "/Depth") - return True - except dbus.exceptions.DBusException: - pass - return False - - def get_depth(self): - """Return depth in meters, or None.""" - if not self._refresh_nav_service(): - return None - try: - if self._proxy_depth: - v = _unwrap(self._proxy_depth.GetValue()) - if v is not None and float(v) > 0: - return float(v) - except dbus.exceptions.DBusException: - self._nav_service = None - return None - - class MovementDetector: """Determines whether the vessel has moved beyond a threshold.""" @@ -241,8 +104,8 @@ class TidesController: self._create_dbus_service() self._setup_settings() - self.gps = GpsReader(self.bus) - self.depth_reader = DepthDbusReader(self.bus) + self.gps = SignalGpsReader(self.bus) + self.depth_reader = SignalDepthReader(self.bus) self.depth_recorder = DepthRecorder() self.tide_detector = TideDetector() self.tide_predictor = TidePredictor() diff --git a/dbus-windy-station/build-package.sh b/dbus-windy-station/build-package.sh index 7c64fcb..5d5b80a 100755 --- a/dbus-windy-station/build-package.sh +++ b/dbus-windy-station/build-package.sh @@ -82,6 +82,10 @@ mkdir -p "$PACKAGE_DIR/gui-v2-source" echo "2. Copying application files..." cp "$SCRIPT_DIR/windy_station.py" "$PACKAGE_DIR/" cp "$SCRIPT_DIR/config.py" "$PACKAGE_DIR/" +if [ -f "$SCRIPT_DIR/../lib/signal_reader.py" ]; then + cp "$SCRIPT_DIR/../lib/signal_reader.py" "$PACKAGE_DIR/" + echo " Bundled shared library: signal_reader.py" +fi echo "3. Copying service files..." cp "$SCRIPT_DIR/service/run" "$PACKAGE_DIR/service/" diff --git a/dbus-windy-station/install.sh b/dbus-windy-station/install.sh index 2b5fda9..bb0e606 100755 --- a/dbus-windy-station/install.sh +++ b/dbus-windy-station/install.sh @@ -101,7 +101,26 @@ else echo " Symlink already exists" fi -echo "3. Creating service symlink..." +echo "3. Installing shared library..." +SHARED_LIB_DIR="/data/lib" +mkdir -p "$SHARED_LIB_DIR" +SIGNAL_READER="" +if [ -f "$INSTALL_DIR/signal_reader.py" ]; then + SIGNAL_READER="$INSTALL_DIR/signal_reader.py" +elif [ -f "$(dirname "$INSTALL_DIR")/lib/signal_reader.py" ]; then + SIGNAL_READER="$(dirname "$INSTALL_DIR")/lib/signal_reader.py" +fi +if [ -n "$SIGNAL_READER" ]; then + cp "$SIGNAL_READER" "$SHARED_LIB_DIR/" + echo " Installed signal_reader.py to $SHARED_LIB_DIR" +elif [ -f "$SHARED_LIB_DIR/signal_reader.py" ]; then + echo " signal_reader.py already installed" +else + echo "WARNING: signal_reader.py not found." + echo " Copy lib/signal_reader.py to $SHARED_LIB_DIR/ manually." +fi + +echo "4. Creating service symlink..." if [ -L "$SERVICE_DIR/dbus-windy-station" ]; then echo " Service link already exists, removing old link..." rm "$SERVICE_DIR/dbus-windy-station" @@ -118,10 +137,10 @@ else exit 1 fi -echo "4. Creating log directory..." +echo "5. Creating log directory..." mkdir -p /var/log/dbus-windy-station -echo "5. Setting up rc.local for persistence..." +echo "6. Setting up rc.local for persistence..." RC_LOCAL="/data/rc.local" if [ ! -f "$RC_LOCAL" ]; then echo "#!/bin/bash" > "$RC_LOCAL" @@ -139,7 +158,7 @@ else echo " Already in rc.local" fi -echo "6. Activating service..." +echo "7. Activating service..." sleep 2 if command -v svstat >/dev/null 2>&1; then if svstat "$SERVICE_DIR/dbus-windy-station" 2>/dev/null | grep -q "up"; then diff --git a/dbus-windy-station/windy_station.py b/dbus-windy-station/windy_station.py index c031eac..120ad90 100755 --- a/dbus-windy-station/windy_station.py +++ b/dbus-windy-station/windy_station.py @@ -32,6 +32,8 @@ from urllib.request import Request, urlopen 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 @@ -58,30 +60,9 @@ from config import ( LOGGING_CONFIG, ) -VERSION = '1.0.0' +from signal_reader import SignalGpsReader, SignalMeteoReader -BUS_ITEM = "com.victronenergy.BusItem" -SYSTEM_SERVICE = "com.victronenergy.system" - - -def _unwrap(v): - """Minimal dbus value unwrap.""" - if v is None: - return None - if isinstance(v, (dbus.Int16, dbus.Int32, dbus.Int64, - dbus.UInt16, dbus.UInt32, dbus.UInt64, dbus.Byte)): - return int(v) - if isinstance(v, dbus.Double): - return float(v) - if isinstance(v, (dbus.String, dbus.Signature)): - return str(v) - if isinstance(v, dbus.Boolean): - return bool(v) - if isinstance(v, dbus.Array): - return [_unwrap(x) for x in v] if len(v) > 0 else None - if isinstance(v, (dbus.Dictionary, dict)): - return {k: _unwrap(x) for k, x in v.items()} - return v +VERSION = '1.0.1' def haversine_distance(lat1, lon1, lat2, lon2): @@ -95,131 +76,6 @@ def haversine_distance(lat1, lon1, lat2, lon2): return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) -# --------------------------------------------------------------------------- -# D-Bus readers for meteo and GPS data -# --------------------------------------------------------------------------- - -class MeteoReader: - """Read weather data from Venus OS D-Bus meteo service.""" - - def __init__(self, bus): - self.bus = bus - self._service = None - self._proxies = {} - - def _get_proxy(self, service, path): - try: - obj = self.bus.get_object(service, path, introspect=False) - return dbus.Interface(obj, BUS_ITEM) - except dbus.exceptions.DBusException: - return None - - def _discover_service(self): - """Find the meteo service on D-Bus.""" - if self._service: - return True - try: - bus_obj = self.bus.get_object('org.freedesktop.DBus', - '/org/freedesktop/DBus') - iface = dbus.Interface(bus_obj, 'org.freedesktop.DBus') - names = iface.ListNames() - for name in names: - name_str = str(name) - if name_str.startswith('com.victronenergy.meteo.'): - self._service = name_str - self._proxies = {} - for p in ('/WindDirection', '/WindSpeed', - '/ExternalTemperature', '/Pressure'): - self._proxies[p] = self._get_proxy(name_str, p) - return True - except dbus.exceptions.DBusException: - pass - return False - - def _read_path(self, path): - if not self._discover_service(): - return None - proxy = self._proxies.get(path) - if proxy is None: - return None - try: - return _unwrap(proxy.GetValue()) - except dbus.exceptions.DBusException: - self._service = None - return None - - def get_wind_direction(self): - """Wind direction in degrees (0-360).""" - return self._read_path('/WindDirection') - - def get_wind_speed(self): - """Wind speed in m/s.""" - return self._read_path('/WindSpeed') - - def get_temperature(self): - """Air temperature in Celsius.""" - return self._read_path('/ExternalTemperature') - - def get_pressure(self): - """Barometric pressure in hPa (mbar).""" - return self._read_path('/Pressure') - - -class GpsReader: - """Read GPS position from Venus OS D-Bus.""" - - def __init__(self, bus): - self.bus = bus - self._gps_service = None - self._proxy_lat = None - self._proxy_lon = None - - def _get_proxy(self, service, path): - try: - obj = self.bus.get_object(service, path, introspect=False) - return dbus.Interface(obj, BUS_ITEM) - except dbus.exceptions.DBusException: - return None - - def _refresh_gps_service(self): - if self._gps_service: - return True - try: - proxy = self._get_proxy(SYSTEM_SERVICE, "/GpsService") - if proxy: - svc = _unwrap(proxy.GetValue()) - if svc and isinstance(svc, str): - self._gps_service = svc - self._proxy_lat = self._get_proxy(svc, "/Position/Latitude") - self._proxy_lon = self._get_proxy(svc, "/Position/Longitude") - if not self._proxy_lat: - self._proxy_lat = self._get_proxy(svc, "/Latitude") - if not self._proxy_lon: - self._proxy_lon = self._get_proxy(svc, "/Longitude") - return True - except dbus.exceptions.DBusException: - pass - return False - - def get_position(self): - """Return (lat, lon) or None if no fix.""" - if not self._refresh_gps_service(): - return None - lat, lon = None, None - try: - if self._proxy_lat: - lat = _unwrap(self._proxy_lat.GetValue()) - if self._proxy_lon: - lon = _unwrap(self._proxy_lon.GetValue()) - except dbus.exceptions.DBusException: - self._gps_service = None - return None - if (lat is not None and lon is not None and - -90 <= float(lat) <= 90 and -180 <= float(lon) <= 180): - return (float(lat), float(lon)) - return None - - # --------------------------------------------------------------------------- # Wind calculations # --------------------------------------------------------------------------- @@ -373,8 +229,8 @@ class WindyStationController: self._create_dbus_service() self._setup_settings() - self.meteo = MeteoReader(self.bus) - self.gps = GpsReader(self.bus) + self.meteo = SignalMeteoReader(self.bus) + self.gps = SignalGpsReader(self.bus) self.wind_averager = WindSpeedAverager(WIND_AVG_WINDOW) self.gust_tracker = WindGustTracker(GUST_WINDOW) diff --git a/lib/signal_reader.py b/lib/signal_reader.py new file mode 100644 index 0000000..c924deb --- /dev/null +++ b/lib/signal_reader.py @@ -0,0 +1,353 @@ +""" +Shared signal-based D-Bus readers for Venus OS services. + +Provides GPS, meteo, and depth readers that use PropertiesChanged signal +subscriptions instead of polling via GetValue(). Each reader discovers its +service dynamically, seeds its cache with a one-shot GetValue, then relies +on signals for all subsequent updates. + +Usage: + from signal_reader import SignalGpsReader, SignalMeteoReader, SignalDepthReader + + bus = dbus.SystemBus() + gps = SignalGpsReader(bus) + pos = gps.get_position() # (lat, lon) or None -- reads from cache +""" + +import logging +import time + +import dbus + +logger = logging.getLogger(__name__) + +BUS_ITEM = 'com.victronenergy.BusItem' +SYSTEM_SERVICE = 'com.victronenergy.system' + + +def _unwrap(v): + """Convert D-Bus value types to Python native types.""" + if v is None: + return None + if isinstance(v, (dbus.Int16, dbus.Int32, dbus.Int64, + dbus.UInt16, dbus.UInt32, dbus.UInt64, dbus.Byte)): + return int(v) + if isinstance(v, dbus.Double): + return float(v) + if isinstance(v, (dbus.String, dbus.Signature)): + return str(v) + if isinstance(v, dbus.Boolean): + return bool(v) + if isinstance(v, dbus.Array): + return [_unwrap(x) for x in v] if len(v) > 0 else None + if isinstance(v, (dbus.Dictionary, dict)): + return {k: _unwrap(x) for k, x in v.items()} + return v + + +class _BaseSignalReader: + """Base for D-Bus readers that bootstrap with GetValue then use signals.""" + + def __init__(self, bus): + self._bus = bus + self._proxy_cache = {} + self._subscribed = False + + def _get_proxy(self, service, path): + key = (service, path) + proxy = self._proxy_cache.get(key) + if proxy is not None: + return proxy + try: + obj = self._bus.get_object(service, path, introspect=False) + proxy = dbus.Interface(obj, BUS_ITEM) + self._proxy_cache[key] = proxy + return proxy + except dbus.exceptions.DBusException: + return None + + def _read_value(self, service, path): + proxy = self._get_proxy(service, path) + if proxy is None: + return None + try: + return _unwrap(proxy.GetValue()) + except dbus.exceptions.DBusException: + self._proxy_cache.pop((service, path), None) + return None + + def _subscribe(self, service, path, handler): + try: + self._bus.add_signal_receiver( + handler, + signal_name='PropertiesChanged', + dbus_interface=BUS_ITEM, + bus_name=service, + path=path, + ) + except dbus.exceptions.DBusException as e: + logger.debug('Failed to subscribe to %s%s: %s', service, path, e) + + def _list_service_names(self, prefix): + """Return the first D-Bus service name matching *prefix*, or None.""" + try: + bus_obj = self._bus.get_object( + 'org.freedesktop.DBus', '/org/freedesktop/DBus') + iface = dbus.Interface(bus_obj, 'org.freedesktop.DBus') + for name in iface.ListNames(): + name_str = str(name) + if name_str.startswith(prefix): + return name_str + except dbus.exceptions.DBusException: + pass + return None + + +class SignalGpsReader(_BaseSignalReader): + """Signal-driven GPS reader with dynamic service discovery. + + Resolves the GPS service via com.victronenergy.system /GpsService, + bootstraps lat/lon/speed/fix with GetValue, then subscribes to + PropertiesChanged for zero-cost steady-state reads. + """ + + def __init__(self, bus): + super().__init__(bus) + self._service = None + self._lat_path = None + self._lon_path = None + self._latitude = None + self._longitude = None + self._speed = None + self._fix = None + self._last_update = 0 + self._discover_and_subscribe() + + def _discover_and_subscribe(self): + try: + proxy = self._get_proxy(SYSTEM_SERVICE, '/GpsService') + if proxy: + svc = _unwrap(proxy.GetValue()) + if svc and isinstance(svc, str): + self._service = svc + self._detect_paths() + self._bootstrap() + self._setup_subscriptions() + return + except dbus.exceptions.DBusException: + pass + logger.debug('GPS service not yet available') + + def _detect_paths(self): + """Determine whether GPS exposes /Position/Latitude or /Latitude.""" + proxy = self._get_proxy(self._service, '/Position/Latitude') + if proxy: + try: + proxy.GetValue() + self._lat_path = '/Position/Latitude' + self._lon_path = '/Position/Longitude' + return + except dbus.exceptions.DBusException: + pass + self._lat_path = '/Latitude' + self._lon_path = '/Longitude' + + def _bootstrap(self): + self._latitude = self._read_value(self._service, self._lat_path) + self._longitude = self._read_value(self._service, self._lon_path) + self._speed = self._read_value(self._service, '/Speed') + self._fix = self._read_value(self._service, '/Fix') + if self._latitude is not None: + self._last_update = time.time() + + def _setup_subscriptions(self): + if self._subscribed: + return + svc = self._service + self._subscribe(svc, self._lat_path, self._on_lat) + self._subscribe(svc, self._lon_path, self._on_lon) + self._subscribe(svc, '/Speed', self._on_speed) + self._subscribe(svc, '/Fix', self._on_fix) + self._subscribed = True + logger.debug('GPS signal subscriptions active on %s', svc) + + def _on_lat(self, changes): + if 'Value' in changes: + self._latitude = _unwrap(changes['Value']) + self._last_update = time.time() + + def _on_lon(self, changes): + if 'Value' in changes: + self._longitude = _unwrap(changes['Value']) + self._last_update = time.time() + + def _on_speed(self, changes): + if 'Value' in changes: + self._speed = _unwrap(changes['Value']) + + def _on_fix(self, changes): + if 'Value' in changes: + self._fix = _unwrap(changes['Value']) + + def _ensure_service(self): + if self._service: + return True + self._discover_and_subscribe() + return self._service is not None + + def get_position(self): + """Return (lat, lon) or None.""" + if not self._ensure_service(): + return None + lat, lon = self._latitude, self._longitude + if lat is None or lon is None: + return None + try: + lat_f, lon_f = float(lat), float(lon) + except (TypeError, ValueError): + return None + if -90 <= lat_f <= 90 and -180 <= lon_f <= 180: + return (lat_f, lon_f) + return None + + def get_speed(self): + """Return speed in m/s, or None.""" + if not self._ensure_service(): + return None + if self._speed is not None: + try: + return float(self._speed) + except (TypeError, ValueError): + pass + return None + + @property + def last_update(self): + return self._last_update + + +class SignalMeteoReader(_BaseSignalReader): + """Signal-driven meteo reader with dynamic service discovery. + + Discovers the first com.victronenergy.meteo.* service via ListNames, + bootstraps wind/temp/pressure with GetValue, then subscribes to + PropertiesChanged. + """ + + PATHS = ('/WindDirection', '/WindSpeed', '/ExternalTemperature', '/Pressure') + + def __init__(self, bus): + super().__init__(bus) + self._service = None + self._values = {p: None for p in self.PATHS} + self._discover_and_subscribe() + + def _discover_and_subscribe(self): + svc = self._list_service_names('com.victronenergy.meteo.') + if svc: + self._service = svc + self._bootstrap() + self._setup_subscriptions() + else: + logger.debug('Meteo service not yet available') + + def _bootstrap(self): + for path in self.PATHS: + self._values[path] = self._read_value(self._service, path) + + def _setup_subscriptions(self): + if self._subscribed: + return + for path in self.PATHS: + self._subscribe( + self._service, path, + lambda changes, p=path: self._on_changed(p, changes)) + self._subscribed = True + logger.debug('Meteo signal subscriptions active on %s', self._service) + + def _on_changed(self, path, changes): + if 'Value' in changes: + self._values[path] = _unwrap(changes['Value']) + + def _ensure_service(self): + if self._service: + return True + self._discover_and_subscribe() + return self._service is not None + + def _get(self, path): + if not self._ensure_service(): + return None + return self._values.get(path) + + def get_wind_direction(self): + """Wind direction in degrees (0-360).""" + return self._get('/WindDirection') + + def get_wind_speed(self): + """Wind speed in m/s.""" + return self._get('/WindSpeed') + + def get_temperature(self): + """Air temperature in Celsius.""" + return self._get('/ExternalTemperature') + + def get_pressure(self): + """Barometric pressure in hPa (mbar).""" + return self._get('/Pressure') + + +class SignalDepthReader(_BaseSignalReader): + """Signal-driven depth reader with dynamic service discovery. + + Discovers the first com.victronenergy.navigation.* service via ListNames, + bootstraps depth with GetValue, then subscribes to PropertiesChanged. + """ + + def __init__(self, bus): + super().__init__(bus) + self._service = None + self._depth = None + self._discover_and_subscribe() + + def _discover_and_subscribe(self): + svc = self._list_service_names('com.victronenergy.navigation.') + if svc: + self._service = svc + self._bootstrap() + self._setup_subscriptions() + else: + logger.debug('Navigation service not yet available') + + def _bootstrap(self): + self._depth = self._read_value(self._service, '/Depth') + + def _setup_subscriptions(self): + if self._subscribed: + return + self._subscribe(self._service, '/Depth', self._on_depth) + self._subscribed = True + logger.debug('Depth signal subscription active on %s', self._service) + + def _on_depth(self, changes): + if 'Value' in changes: + self._depth = _unwrap(changes['Value']) + + def _ensure_service(self): + if self._service: + return True + self._discover_and_subscribe() + return self._service is not None + + def get_depth(self): + """Return depth in meters, or None.""" + if not self._ensure_service(): + return None + if self._depth is not None: + try: + d = float(self._depth) + if d > 0: + return d + except (TypeError, ValueError): + pass + return None