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:
353
lib/signal_reader.py
Normal file
353
lib/signal_reader.py
Normal 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
|
||||
Reference in New Issue
Block a user