Files
venus/dbus-meteoblue-forecast/meteoblue_forecast.py
Paul G 36a07dacb9 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
2026-03-27 01:03:16 +00:00

659 lines
24 KiB
Python

#!/usr/bin/env python3
"""
Meteoblue Forecast for Venus OS
Fetches 7-day weather forecasts from the Meteoblue Forecast API and
publishes them on D-Bus for display in the venus-html5-app dashboard.
Uses a single API call combining four packages:
- basic-1h: windspeed, winddirection, precipitation, temperature
- wind-1h: gust (wind gusts), windspeed_80m, winddirection_80m
- sea-1h: wave height, period, direction
- sunmoon: sunrise/sunset, moonrise/moonset, moon phase
GPS position is read from Venus OS D-Bus. Movement detection determines
the refresh interval:
- Stationary vessel: every 6 hours
- Moving vessel: every 3 hours
"""
import json
import logging
import math
import os
import signal
import sys
import time
import threading
from collections import deque
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
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
except ImportError:
print("ERROR: GLib not available. This script must run on Venus OS.")
sys.exit(1)
try:
import dbus
from dbus.mainloop.glib import DBusGMainLoop
from vedbus import VeDbusService
from settingsdevice import SettingsDevice
except ImportError as e:
print(f"ERROR: Required module not available: {e}")
print("This script must run on Venus OS.")
sys.exit(1)
from config import (
SERVICE_NAME, API_URL, API_PARAMS,
REFRESH_INTERVAL_STATIONARY, REFRESH_INTERVAL_MOVING,
GPS_SAMPLE_INTERVAL, GPS_HISTORY_SIZE, GPS_HISTORY_SAMPLE_INTERVAL,
MOVEMENT_THRESHOLD_METERS, FORECAST_HOURS,
CONFIG_FILE, LOGGING_CONFIG,
)
from signal_reader import SignalGpsReader
VERSION = '2.1.1'
def haversine_distance(lat1, lon1, lat2, lon2):
"""Great-circle distance in meters between two GPS coordinates."""
R = 6371000
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlam = math.radians(lon2 - lon1)
a = (math.sin(dphi / 2) ** 2 +
math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2)
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
class MovementDetector:
"""Determines whether the vessel is moving based on GPS history."""
def __init__(self, history_size, threshold_meters):
self.history_size = history_size
self.threshold_meters = threshold_meters
self.positions = deque(maxlen=history_size)
self.last_sample_time = 0
def add_position(self, lat, lon, now=None):
if now is None:
now = time.time()
self.positions.append((now, lat, lon))
self.last_sample_time = now
def is_moving(self, current_lat, current_lon):
"""Check if vessel has moved beyond threshold from any stored position."""
if not self.positions:
return False
for _, hist_lat, hist_lon in self.positions:
dist = haversine_distance(hist_lat, hist_lon, current_lat, current_lon)
if dist >= self.threshold_meters:
return True
return False
class MeteoblueApiClient:
"""Fetches forecast data from the Meteoblue Forecast API."""
def __init__(self, logger):
self.logger = logger
def fetch_forecast(self, api_key, lat, lon):
"""Fetch combined basic + wind + sea + sunmoon forecast. Returns normalized dict or None."""
if not api_key:
self.logger.warning("No API key configured")
return None
rounded_lat = round(lat, 2)
rounded_lon = round(lon, 2)
params = dict(API_PARAMS)
params['lat'] = str(rounded_lat)
params['lon'] = str(rounded_lon)
params['apikey'] = api_key
url = f"{API_URL}?{urlencode(params)}"
try:
request = Request(url, method='GET')
with urlopen(request, timeout=30) as response:
body = response.read().decode('utf-8')
data = json.loads(body)
return self._normalize(data)
except HTTPError as e:
body = ""
try:
body = e.read().decode('utf-8')
except Exception:
pass
self.logger.error(f"Meteoblue HTTP {e.code}: {e.reason} {body}")
return None
except (URLError, Exception) as e:
self.logger.error(f"Meteoblue request error: {e}")
return None
def _normalize(self, data):
"""Extract and normalize meteoblue response into forecast JSON.
Meteoblue returns data grouped by time resolution, e.g. data_1h for
hourly packages. When combining basic-1h, wind-1h, sea-1h, and
sunmoon, each package may appear in its own section or be merged.
Hourly variables are searched across all data_1h sections. The
sunmoon package produces daily data in a data_day section with
rise/set times and moon phase information.
"""
hourly_sections = []
for key in sorted(data.keys()):
if key.startswith('data_1h') and isinstance(data[key], dict):
hourly_sections.append((key, data[key]))
if not hourly_sections:
self.logger.warning("No hourly data sections in response")
return None
self.logger.info(
f"Response sections: {[k for k in sorted(data.keys()) if k.startswith('data_')]}")
hourly = hourly_sections[0][1]
ts = hourly.get('time', [])
if not ts:
self.logger.warning("No hourly timestamps in response")
return None
now_ms = int(time.time() * 1000)
horizon_ms = now_ms + FORECAST_HOURS * 3600 * 1000
if isinstance(ts[0], (int, float)) and ts[0] > 1e12:
indices = [i for i, t in enumerate(ts) if t <= horizon_ms]
else:
indices = list(range(len(ts)))
if not indices:
indices = list(range(len(ts)))
def pick(arr):
if not arr:
return []
return [arr[i] if i < len(arr) else None for i in indices]
def find_var(key):
"""Search all hourly sections for a variable, skipping sea sections
which may have different timestamps."""
for section_key, section in hourly_sections:
if 'sea' in section_key:
continue
arr = section.get(key, [])
if arr:
return arr
return []
result = {
'ts': pick(ts),
'windspeed': pick(find_var('windspeed')),
'winddirection': pick(find_var('winddirection')),
'gust': pick(find_var('gust')),
'precip': pick(find_var('precipitation')),
'temperature': pick(find_var('temperature')),
}
def find_sea_section():
"""Find the sea data section, which may have different timestamps."""
for section_key, section in hourly_sections:
if 'sea' in section_key:
return section
if section.get('sigwaveheight'):
return section
return hourly
sea = find_sea_section()
sea_ts = sea.get('time', ts)
if sea is not hourly and sea_ts:
if isinstance(sea_ts[0], (int, float)) and sea_ts[0] > 1e12:
sea_indices = [i for i, t in enumerate(sea_ts) if t <= horizon_ms]
else:
sea_indices = list(range(len(sea_ts)))
if not sea_indices:
sea_indices = list(range(len(sea_ts)))
else:
sea_indices = indices
def pick_sea(key):
arr = sea.get(key, [])
if not arr:
return []
return [arr[i] if i < len(arr) else None for i in sea_indices]
result['waves_height'] = pick_sea('sigwaveheight')
result['waves_period'] = pick_sea('meanwaveperiod')
result['waves_direction'] = pick_sea('meanwavedirection')
sunmoon = self._extract_sunmoon(data)
if sunmoon:
result['sunmoon'] = sunmoon
gust_count = sum(1 for v in result['gust'] if v is not None)
ts_count = len(result['ts'])
sunmoon_days = len(sunmoon.get('time', [])) if sunmoon else 0
self.logger.info(
f"Normalized forecast: {ts_count} timestamps, "
f"{gust_count} gust values, {sunmoon_days} sunmoon days")
return result
def _extract_sunmoon(self, data):
"""Extract sun/moon daily data from the sunmoon package.
The sunmoon package returns a data_day section with rise/set times
as "hh:mm" strings and moon phase information. Special values:
"---" means the body does not rise or set that day; "00:00"/"24:00"
means it is visible the entire day.
Multiple data_day sections may exist (e.g. data_day from basic,
data_day_sunmoon from sunmoon package), so we merge across all of them.
"""
SUNMOON_KEYS = [
'sunrise', 'sunset',
'moonrise', 'moonset',
'moonphaseangle', 'moonage', 'moonphasename',
'moonphasetransit', 'moonillumination',
]
daily_sections = []
for key in sorted(data.keys()):
if key.startswith('data_day') and isinstance(data[key], dict):
section = data[key]
if any(section.get(k) for k in SUNMOON_KEYS):
daily_sections.append(section)
if not daily_sections:
self.logger.debug("No sun/moon daily data in response")
return None
result = {}
for section in daily_sections:
daily_time = section.get('time', [])
if daily_time and 'time' not in result:
result['time'] = list(daily_time)
for var_key in SUNMOON_KEYS:
if var_key in result:
continue
arr = section.get(var_key)
if arr:
result[var_key] = list(arr)
return result if len(result) > 1 else None
class MeteoblueForecastController:
"""Coordinates GPS, movement detection, API calls, and D-Bus publishing."""
def __init__(self):
self._setup_logging()
self.logger = logging.getLogger('MeteoblueForecast')
self.logger.info(f"Initializing Meteoblue Forecast v{VERSION}")
self.bus = dbus.SystemBus()
self._create_dbus_service()
self._setup_settings()
self.gps = SignalGpsReader(self.bus)
self.movement = MovementDetector(
GPS_HISTORY_SIZE, MOVEMENT_THRESHOLD_METERS)
self.api_client = MeteoblueApiClient(self.logger)
self.last_fetch_time = 0
self.last_gps_check = 0
self.last_gps_history_sample = 0
self.current_lat = None
self.current_lon = None
self._fetch_in_progress = False
GLib.timeout_add(1000, self._main_loop)
self.logger.info(
"Initialized. Checking GPS every 60s, "
f"forecast refresh: {REFRESH_INTERVAL_STATIONARY}s "
f"(stationary) / {REFRESH_INTERVAL_MOVING}s (moving)")
def _setup_logging(self):
level = getattr(logging, LOGGING_CONFIG['level'], logging.INFO)
fmt = ('%(asctime)s %(levelname)s %(name)s: %(message)s'
if LOGGING_CONFIG['include_timestamp']
else '%(levelname)s %(name)s: %(message)s')
logging.basicConfig(level=level, format=fmt, stream=sys.stdout)
def _create_dbus_service(self):
self.logger.info(f"Creating D-Bus service: {SERVICE_NAME}")
max_retries = 5
retry_delay = 1.0
for attempt in range(max_retries):
try:
self.dbus_service = VeDbusService(
SERVICE_NAME, self.bus, register=False)
break
except dbus.exceptions.NameExistsException:
if attempt < max_retries - 1:
self.logger.warning(
f"D-Bus name exists, retrying in {retry_delay}s "
f"(attempt {attempt + 1}/{max_retries})")
time.sleep(retry_delay)
retry_delay *= 2
else:
raise
self.dbus_service.add_path('/Mgmt/ProcessName', 'dbus-meteoblue-forecast')
self.dbus_service.add_path('/Mgmt/ProcessVersion', VERSION)
self.dbus_service.add_path('/Mgmt/Connection', 'local')
self.dbus_service.add_path('/DeviceInstance', 0)
self.dbus_service.add_path('/ProductId', 0xA161)
self.dbus_service.add_path('/ProductName', 'Meteoblue Forecast')
self.dbus_service.add_path('/FirmwareVersion', VERSION)
self.dbus_service.add_path('/Connected', 1)
def _status_text(p, v):
labels = {0: 'Idle', 1: 'Fetching', 2: 'Ready', 3: 'Error'}
return labels.get(v, 'Unknown') if v is not None else 'Unknown'
self.dbus_service.add_path('/Status', 0, gettextcallback=_status_text)
self.dbus_service.add_path('/ErrorMessage', '')
self.dbus_service.add_path('/LastUpdate', 0,
gettextcallback=self._time_ago_text)
self.dbus_service.add_path('/NextUpdate', 0,
gettextcallback=self._time_until_text)
self.dbus_service.add_path('/IsMoving', 0)
self.dbus_service.add_path('/Forecast/Latitude', None)
self.dbus_service.add_path('/Forecast/Longitude', None)
self.dbus_service.add_path('/Forecast/Json', '')
self.dbus_service.add_path('/Settings/Enabled', 1,
writeable=True,
onchangecallback=self._on_setting_changed)
self.dbus_service.add_path('/Settings/ApiKey', '',
writeable=True,
onchangecallback=self._on_setting_changed)
self.dbus_service.add_path('/Settings/Units', 0,
writeable=True,
onchangecallback=self._on_setting_changed)
self.dbus_service.register()
self.logger.info("D-Bus service created")
def _setup_settings(self):
self.settings = None
try:
path = '/Settings/MeteoblueForecast'
settings_def = {
'Enabled': [path + '/Enabled', 1, 0, 1],
'ApiKey': [path + '/ApiKey', '', 0, 0],
'Units': [path + '/Units', 0, 0, 1],
}
self.settings = SettingsDevice(
self.bus, settings_def,
self._on_persistent_setting_changed)
if self.settings:
self._load_settings()
self.logger.info("Persistent settings initialized")
except Exception as e:
self.logger.warning(
f"Could not initialize persistent settings: {e}")
self._set_defaults()
self._load_config_file()
def _set_defaults(self):
self.enabled = True
self.api_key = ''
self.units = 0
def _load_config_file(self):
"""Load forecast_config.json if it exists."""
try:
if not os.path.exists(CONFIG_FILE):
return
with open(CONFIG_FILE, 'r') as f:
data = json.load(f)
self.logger.info(f"Loading config from {CONFIG_FILE}")
field_map = {
'api_key': ('ApiKey', 'api_key', str),
'apiKey': ('ApiKey', 'api_key', str),
'key': ('ApiKey', 'api_key', str),
}
for json_key, value in data.items():
if json_key not in field_map:
continue
setting_name, attr_name, cast = field_map[json_key]
try:
typed_value = cast(value) if value is not None else None
if typed_value is None:
continue
setattr(self, attr_name, typed_value)
self._save_setting(setting_name, typed_value)
dbus_path = f'/Settings/{setting_name}'
self.dbus_service[dbus_path] = typed_value
except (ValueError, TypeError):
self.logger.warning(
f"Invalid value for {json_key}: {value}")
self.logger.info(
f"Config applied: api_key={'(set)' if self.api_key else '(empty)'}")
except json.JSONDecodeError as e:
self.logger.error(f"Invalid JSON in {CONFIG_FILE}: {e}")
except Exception as e:
self.logger.error(f"Error reading {CONFIG_FILE}: {e}")
def _load_settings(self):
if not self.settings:
return
try:
self.enabled = bool(self.settings['Enabled'])
self.api_key = str(self.settings['ApiKey'] or '')
self.units = int(self.settings['Units'] or 0)
self.dbus_service['/Settings/Enabled'] = 1 if self.enabled else 0
self.dbus_service['/Settings/ApiKey'] = self.api_key
self.dbus_service['/Settings/Units'] = self.units
self.logger.info(
f"Loaded settings: api_key={'(set)' if self.api_key else '(empty)'}")
except Exception as e:
self.logger.warning(f"Error loading settings: {e}")
self._set_defaults()
def _on_persistent_setting_changed(self, setting, old_value, new_value):
self.logger.info(f"Persistent setting changed: {setting} = {new_value}")
self._load_settings()
def _on_setting_changed(self, path, value):
self.logger.info(f"Setting changed: {path} = {value}")
if path == '/Settings/Enabled':
self.enabled = bool(value)
self._save_setting('Enabled', 1 if self.enabled else 0)
elif path == '/Settings/ApiKey':
self.api_key = str(value) if value else ''
self._save_setting('ApiKey', self.api_key)
elif path == '/Settings/Units':
self.units = int(value) if value is not None else 0
self._save_setting('Units', self.units)
return True
def _save_setting(self, name, value):
if self.settings:
try:
self.settings[name] = value
except Exception as e:
self.logger.warning(f"Failed to save setting {name}: {e}")
def _time_ago_text(self, path, value):
try:
if value is None or (isinstance(value, (int, float)) and value <= 0):
return "Never"
diff = time.time() - float(value)
if diff < 60:
return "Just now"
if diff < 3600:
return f"{int(diff / 60)}m ago"
h = int(diff / 3600)
m = int((diff % 3600) / 60)
return f"{h}h {m}m ago"
except (TypeError, ValueError):
return "Never"
def _time_until_text(self, path, value):
try:
if value is None or (isinstance(value, (int, float)) and value <= 0):
return "--"
diff = float(value) - time.time()
if diff <= 0:
return "Now"
if diff < 3600:
return f"in {int(diff / 60)}m"
h = int(diff / 3600)
m = int((diff % 3600) / 60)
return f"in {h}h {m}m"
except (TypeError, ValueError):
return "--"
def _do_fetch(self, lat, lon):
"""Run in a background thread to avoid blocking D-Bus."""
try:
self.logger.info(f"Fetching forecast for {lat:.2f}, {lon:.2f}")
self.dbus_service['/Status'] = 1
forecast = self.api_client.fetch_forecast(self.api_key, lat, lon)
if forecast is None:
self.dbus_service['/Status'] = 3
self.dbus_service['/ErrorMessage'] = 'API returned no data'
return
forecast_json = json.dumps(forecast)
self.dbus_service['/Forecast/Json'] = forecast_json
self.dbus_service['/Forecast/Latitude'] = lat
self.dbus_service['/Forecast/Longitude'] = lon
self.dbus_service['/LastUpdate'] = int(time.time())
self.dbus_service['/ErrorMessage'] = ''
self.dbus_service['/Status'] = 2
ts_count = len(forecast.get('ts', []))
self.logger.info(
f"Forecast updated: {ts_count} timestamps, "
f"{len(forecast_json)} bytes")
except Exception as e:
self.logger.exception(f"Fetch error: {e}")
self.dbus_service['/Status'] = 3
self.dbus_service['/ErrorMessage'] = str(e)[:200]
finally:
self.last_fetch_time = time.time()
self._fetch_in_progress = False
self._update_next_time()
def _update_next_time(self):
moving = self.dbus_service['/IsMoving']
interval = (REFRESH_INTERVAL_MOVING if moving
else REFRESH_INTERVAL_STATIONARY)
next_time = self.last_fetch_time + interval
self.dbus_service['/NextUpdate'] = int(next_time)
def _main_loop(self):
try:
if not self.enabled:
self.dbus_service['/Status'] = 0
return True
now = time.time()
if now - self.last_gps_check >= GPS_SAMPLE_INTERVAL:
pos = self.gps.get_position()
if pos:
self.current_lat, self.current_lon = pos
if now - self.last_gps_history_sample >= GPS_HISTORY_SAMPLE_INTERVAL:
self.movement.add_position(
self.current_lat, self.current_lon, now)
self.last_gps_history_sample = now
is_moving = self.movement.is_moving(
self.current_lat, self.current_lon)
self.dbus_service['/IsMoving'] = 1 if is_moving else 0
self.last_gps_check = now
if (self.current_lat is not None
and self.current_lon is not None
and self.api_key
and not self._fetch_in_progress):
is_moving = self.dbus_service['/IsMoving']
interval = (REFRESH_INTERVAL_MOVING if is_moving
else REFRESH_INTERVAL_STATIONARY)
if self.last_fetch_time == 0 or now - self.last_fetch_time >= interval:
self._fetch_in_progress = True
thread = threading.Thread(
target=self._do_fetch,
args=(self.current_lat, self.current_lon),
daemon=True)
thread.start()
if not self.api_key:
self.dbus_service['/Status'] = 0
self.dbus_service['/ErrorMessage'] = 'API key not configured'
elif self.current_lat is None:
if self.dbus_service['/Status'] != 2:
self.dbus_service['/Status'] = 0
self.dbus_service['/ErrorMessage'] = 'Waiting for GPS fix'
except dbus.exceptions.DBusException as e:
self.logger.warning(f"D-Bus error: {e}")
except Exception as e:
self.logger.exception(f"Unexpected error: {e}")
return True
def main():
DBusGMainLoop(set_as_default=True)
print("=" * 60)
print(f"Meteoblue Forecast v{VERSION}")
print("=" * 60)
mainloop = None
def signal_handler(signum, frame):
try:
sig_name = signal.Signals(signum).name
except ValueError:
sig_name = str(signum)
logging.info(f"Received {sig_name}, shutting down...")
if mainloop is not None:
mainloop.quit()
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
try:
controller = MeteoblueForecastController()
mainloop = GLib.MainLoop()
mainloop.run()
except KeyboardInterrupt:
print("\nShutdown requested")
except Exception as e:
logging.error(f"Fatal error: {e}", exc_info=True)
sys.exit(1)
finally:
logging.info("Service stopped")
if __name__ == '__main__':
main()