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
This commit is contained in:
2026-03-27 01:01:40 +00:00
parent 6a504cdbc1
commit 36a07dacb9
18 changed files with 520 additions and 607 deletions

View File

@@ -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-<name>/
└── 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

View File

@@ -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/"

View File

@@ -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

View File

@@ -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

View File

@@ -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/"

View File

@@ -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

View File

@@ -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)

View File

@@ -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..."

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -153,4 +153,4 @@ LOGGING_CONFIG = {
'include_timestamp': False,
}
VERSION = '1.0.19'
VERSION = '1.0.20'

View File

@@ -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

View File

@@ -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()

View File

@@ -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/"

View File

@@ -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

View File

@@ -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)

353
lib/signal_reader.py Normal file
View File

@@ -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