- 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
659 lines
24 KiB
Python
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()
|