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:
@@ -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
|
||||
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -153,4 +153,4 @@ LOGGING_CONFIG = {
|
||||
'include_timestamp': False,
|
||||
}
|
||||
|
||||
VERSION = '1.0.19'
|
||||
VERSION = '1.0.20'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
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