753 lines
29 KiB
Python
Executable File
753 lines
29 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
dbus-anchor-alarm -- Anchor Watch & Drag Detection for Venus OS
|
|
|
|
v2: Memory-safe, SD-safe, calibrated.
|
|
- Zero SD writes in normal operation (in-memory TrackBuffer)
|
|
- Optional DebugLogger (SQLite, default off)
|
|
- Decimated tracker (1/15s slow path), 1 Hz fast path for sensors/drag
|
|
- GPS-to-bow offset geometry
|
|
- Anchor state persisted to localsettings
|
|
- RSS memory watchdog
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import math
|
|
import os
|
|
import signal
|
|
import sys
|
|
import time
|
|
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')
|
|
|
|
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 ve_utils import exit_on_error
|
|
from settingsdevice import SettingsDevice
|
|
except ImportError as e:
|
|
print("ERROR: Required module not available: %s" % e)
|
|
print("This script must run on Venus OS.")
|
|
sys.exit(1)
|
|
|
|
from config import (
|
|
SERVICE_NAME,
|
|
DEVICE_INSTANCE,
|
|
PRODUCT_NAME,
|
|
PRODUCT_ID,
|
|
FIRMWARE_VERSION,
|
|
CONNECTED,
|
|
MAIN_LOOP_INTERVAL_MS,
|
|
LOG_LEVEL,
|
|
VERSION,
|
|
CHAIN_WEIGHT_LB_PER_FT,
|
|
ANCHOR_WEIGHT_LB,
|
|
VESSEL_WEIGHT_LB,
|
|
FREEBOARD_HEIGHT_FT,
|
|
WINDAGE_AREA_SQFT,
|
|
DRAG_COEFFICIENT,
|
|
DEFAULT_ALARM_RADIUS_FT,
|
|
GPS_TO_BOW_FT,
|
|
)
|
|
from sensor_reader import SensorReader
|
|
from catenary import (
|
|
wind_force_lbs,
|
|
catenary_distance,
|
|
scope_ratio,
|
|
recommended_chain_length,
|
|
dynamic_load,
|
|
)
|
|
from anchor_tracker import AnchorTracker
|
|
from drag_detector import DragDetector
|
|
from track_buffer import TrackBuffer
|
|
from debug_logger import DebugLogger
|
|
|
|
logger = logging.getLogger('dbus-anchor-alarm')
|
|
|
|
_PEAK_LOAD_WINDOW_SEC = 60.0
|
|
_JSON_UPDATE_INTERVAL_SEC = 5.0
|
|
_SLOW_TICK_DIVISOR = 15
|
|
_WATCHDOG_INTERVAL_SEC = 60.0
|
|
_WATCHDOG_WARN_MB = 25
|
|
_WATCHDOG_KILL_MB = 40
|
|
|
|
|
|
def _bearing_between(lat1, lon1, lat2, lon2):
|
|
"""Bearing from point 1 to point 2 in degrees (0-360)."""
|
|
rlat1 = math.radians(lat1)
|
|
rlat2 = math.radians(lat2)
|
|
dlon = math.radians(lon2 - lon1)
|
|
x = math.sin(dlon) * math.cos(rlat2)
|
|
y = (math.cos(rlat1) * math.sin(rlat2)
|
|
- math.sin(rlat1) * math.cos(rlat2) * math.cos(dlon))
|
|
return math.degrees(math.atan2(x, y)) % 360.0
|
|
|
|
|
|
def _signed_angle_diff(a, b):
|
|
"""Signed angle from a to b in degrees, normalised to -180..180."""
|
|
d = (b - a) % 360.0
|
|
return d if d <= 180.0 else d - 360.0
|
|
|
|
|
|
def _get_rss_mb():
|
|
"""Read VmRSS from /proc/self/status, return MB or None."""
|
|
try:
|
|
with open('/proc/self/status', 'r') as f:
|
|
for line in f:
|
|
if line.startswith('VmRSS:'):
|
|
return int(line.split()[1]) / 1024.0
|
|
except (OSError, ValueError, IndexError):
|
|
pass
|
|
return None
|
|
|
|
|
|
class AnchorAlarmService:
|
|
"""Main anchor alarm service with full D-Bus path publishing."""
|
|
|
|
def __init__(self):
|
|
self._running = False
|
|
self._dbusservice = None
|
|
self._settings = None
|
|
self._reader = None
|
|
self._tracker = None
|
|
self._detector = None
|
|
self._track_buffer = None
|
|
self._debug_logger = None
|
|
|
|
self._tick_count = 0
|
|
self._peak_load = 0.0
|
|
self._peak_load_history = deque()
|
|
self._last_json_update = 0.0
|
|
self._last_watchdog = 0.0
|
|
self._prev_heading = None
|
|
self._prev_heading_delta = None
|
|
self._last_persisted = (None, None, None, None, None)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Lifecycle
|
|
# ------------------------------------------------------------------
|
|
|
|
def start(self):
|
|
"""Initialize D-Bus service and start the main loop."""
|
|
bus = dbus.SystemBus()
|
|
|
|
max_retries = 5
|
|
retry_delay = 1.0
|
|
for attempt in range(max_retries):
|
|
try:
|
|
self._dbusservice = VeDbusService(
|
|
SERVICE_NAME, bus=bus, register=False)
|
|
break
|
|
except dbus.exceptions.NameExistsException:
|
|
if attempt < max_retries - 1:
|
|
logger.warning(
|
|
"D-Bus name exists, retrying in %.1fs (%d/%d)",
|
|
retry_delay, attempt + 1, max_retries)
|
|
time.sleep(retry_delay)
|
|
retry_delay *= 2
|
|
else:
|
|
raise
|
|
|
|
svc = self._dbusservice
|
|
cb = self._on_path_changed
|
|
|
|
# Mandatory management paths
|
|
svc.add_path('/Mgmt/ProcessName', __file__)
|
|
svc.add_path('/Mgmt/ProcessVersion', VERSION)
|
|
svc.add_path('/Mgmt/Connection', 'local')
|
|
svc.add_path('/DeviceInstance', DEVICE_INSTANCE)
|
|
svc.add_path('/ProductId', PRODUCT_ID)
|
|
svc.add_path('/ProductName', PRODUCT_NAME)
|
|
svc.add_path('/FirmwareVersion', FIRMWARE_VERSION)
|
|
svc.add_path('/Connected', CONNECTED)
|
|
|
|
# -- Anchor state --
|
|
svc.add_path('/Anchor/Set', 0)
|
|
svc.add_path('/Anchor/Marked/Latitude', None)
|
|
svc.add_path('/Anchor/Marked/Longitude', None)
|
|
svc.add_path('/Anchor/Estimated/Latitude', None)
|
|
svc.add_path('/Anchor/Estimated/Longitude', None)
|
|
svc.add_path('/Anchor/UncertaintyRadius', 0.0)
|
|
svc.add_path('/Anchor/Drift', 0.0)
|
|
svc.add_path('/Anchor/EstimatedHistory/Json', '[]')
|
|
|
|
# -- Chain & scope --
|
|
svc.add_path('/Anchor/ChainLength', 0.0)
|
|
svc.add_path('/Anchor/ScopeRatio', 0.0)
|
|
svc.add_path('/Anchor/RecommendedScope', 0.0)
|
|
svc.add_path('/Anchor/EstimatedDistance', 0.0)
|
|
svc.add_path('/Anchor/SteadyStateLoad', 0.0)
|
|
svc.add_path('/Anchor/PeakLoad', 0.0)
|
|
svc.add_path('/Anchor/SwingRate', 0.0)
|
|
|
|
# -- Alarm --
|
|
svc.add_path('/Alarm/Active', 0)
|
|
svc.add_path('/Alarm/Type', '')
|
|
svc.add_path('/Alarm/Radius', 0.0)
|
|
svc.add_path('/Alarm/Message', '')
|
|
|
|
# -- Vessel (republished sensor data) --
|
|
svc.add_path('/Vessel/Latitude', 0.0)
|
|
svc.add_path('/Vessel/Longitude', 0.0)
|
|
svc.add_path('/Vessel/Speed', 0.0)
|
|
svc.add_path('/Vessel/Course', 0.0)
|
|
svc.add_path('/Vessel/Heading', 0.0)
|
|
svc.add_path('/Vessel/Depth', 0.0)
|
|
svc.add_path('/Vessel/WindSpeed', 0.0)
|
|
svc.add_path('/Vessel/WindDirection', 0.0)
|
|
|
|
# -- Track --
|
|
svc.add_path('/Track/Json', '[]')
|
|
svc.add_path('/Track/PointCount', 0)
|
|
|
|
# -- Writable settings (frontend <-> service) --
|
|
svc.add_path('/Settings/Enabled', 1,
|
|
writeable=True, onchangecallback=cb)
|
|
svc.add_path('/Settings/ChainLength', 0.0,
|
|
writeable=True, onchangecallback=cb)
|
|
svc.add_path('/Settings/AlarmRadius', DEFAULT_ALARM_RADIUS_FT,
|
|
writeable=True, onchangecallback=cb)
|
|
svc.add_path('/Settings/ChainWeight', CHAIN_WEIGHT_LB_PER_FT,
|
|
writeable=True, onchangecallback=cb)
|
|
svc.add_path('/Settings/AnchorWeight', ANCHOR_WEIGHT_LB,
|
|
writeable=True, onchangecallback=cb)
|
|
svc.add_path('/Settings/VesselWeight', VESSEL_WEIGHT_LB,
|
|
writeable=True, onchangecallback=cb)
|
|
svc.add_path('/Settings/FreeboardHeight', FREEBOARD_HEIGHT_FT,
|
|
writeable=True, onchangecallback=cb)
|
|
svc.add_path('/Settings/WindageArea', WINDAGE_AREA_SQFT,
|
|
writeable=True, onchangecallback=cb)
|
|
svc.add_path('/Settings/DragCoefficient', DRAG_COEFFICIENT,
|
|
writeable=True, onchangecallback=cb)
|
|
svc.add_path('/Settings/BowOffset', GPS_TO_BOW_FT,
|
|
writeable=True, onchangecallback=cb)
|
|
svc.add_path('/Settings/DebugLogging', 0,
|
|
writeable=True, onchangecallback=cb)
|
|
svc.add_path('/Settings/Anchor/Latitude', None,
|
|
writeable=True, onchangecallback=cb)
|
|
svc.add_path('/Settings/Anchor/Longitude', None,
|
|
writeable=True, onchangecallback=cb)
|
|
|
|
# -- Action paths (frontend commands) --
|
|
svc.add_path('/Action/DropAnchor', 0,
|
|
writeable=True, onchangecallback=cb)
|
|
svc.add_path('/Action/AfterDrop', '',
|
|
writeable=True, onchangecallback=cb)
|
|
svc.add_path('/Action/WeighAnchor', 0,
|
|
writeable=True, onchangecallback=cb)
|
|
|
|
# Persistent settings via Venus OS localsettings
|
|
settings_path = '/Settings/AnchorAlarm'
|
|
supported_settings = {
|
|
'Enabled': [settings_path + '/Enabled', 1, 0, 1],
|
|
'ChainLength': [settings_path + '/ChainLength', 150, 0, 1000],
|
|
'AlarmRadius': [settings_path + '/AlarmRadius',
|
|
DEFAULT_ALARM_RADIUS_FT, 0, 5000],
|
|
'ChainWeight': [settings_path + '/ChainWeight',
|
|
CHAIN_WEIGHT_LB_PER_FT, 0, 50],
|
|
'AnchorWeight': [settings_path + '/AnchorWeight',
|
|
ANCHOR_WEIGHT_LB, 0, 2000],
|
|
'VesselWeight': [settings_path + '/VesselWeight',
|
|
VESSEL_WEIGHT_LB, 0, 500000],
|
|
'FreeboardHeight': [settings_path + '/FreeboardHeight',
|
|
FREEBOARD_HEIGHT_FT, 0, 50],
|
|
'WindageArea': [settings_path + '/WindageArea',
|
|
WINDAGE_AREA_SQFT, 0, 2000],
|
|
'DragCoefficient': [settings_path + '/DragCoefficient',
|
|
DRAG_COEFFICIENT, 0, 5],
|
|
'BowOffset': [settings_path + '/BowOffset',
|
|
GPS_TO_BOW_FT, 0, 200],
|
|
'DebugLogging': [settings_path + '/DebugLogging', 0, 0, 1],
|
|
'AnchorSet': [settings_path + '/AnchorSet', 0, 0, 1],
|
|
'EstimatedLat': [settings_path + '/EstimatedLat', 0, -90, 90],
|
|
'EstimatedLon': [settings_path + '/EstimatedLon', 0, -180, 180],
|
|
'MarkedLat': [settings_path + '/MarkedLat', 0, -90, 90],
|
|
'MarkedLon': [settings_path + '/MarkedLon', 0, -180, 180],
|
|
}
|
|
self._settings = SettingsDevice(
|
|
bus, supported_settings, self._on_setting_changed,
|
|
)
|
|
|
|
# Seed D-Bus paths with persisted values
|
|
for key in ('Enabled', 'ChainLength', 'AlarmRadius', 'ChainWeight',
|
|
'AnchorWeight', 'VesselWeight', 'FreeboardHeight',
|
|
'WindageArea', 'DragCoefficient', 'BowOffset',
|
|
'DebugLogging'):
|
|
try:
|
|
svc['/Settings/' + key] = self._settings[key]
|
|
except Exception:
|
|
pass
|
|
|
|
# Create subsystem modules
|
|
bow_offset = float(self._settings['BowOffset'])
|
|
self._reader = SensorReader(bus)
|
|
self._tracker = AnchorTracker(bow_offset_ft=bow_offset)
|
|
self._detector = DragDetector()
|
|
self._track_buffer = TrackBuffer()
|
|
self._debug_logger = DebugLogger()
|
|
|
|
# Restore debug logging state
|
|
if int(self._settings['DebugLogging']):
|
|
self._debug_logger.enable()
|
|
|
|
# Restore persisted anchor state
|
|
self._restore_anchor_state()
|
|
|
|
svc.register()
|
|
logger.info('Service registered on D-Bus as %s', SERVICE_NAME)
|
|
|
|
self._running = True
|
|
GLib.timeout_add(MAIN_LOOP_INTERVAL_MS, exit_on_error, self._update)
|
|
|
|
def stop(self):
|
|
"""Clean shutdown."""
|
|
self._running = False
|
|
if self._debug_logger and self._debug_logger.active:
|
|
self._debug_logger.disable()
|
|
logger.info('Service stopped')
|
|
|
|
# ------------------------------------------------------------------
|
|
# State persistence
|
|
# ------------------------------------------------------------------
|
|
|
|
def _persist_anchor_state(self):
|
|
"""Write current anchor state to localsettings, only if changed."""
|
|
current = (
|
|
int(self._tracker.anchor_set),
|
|
self._tracker.estimated_lat,
|
|
self._tracker.estimated_lon,
|
|
self._tracker.marked_lat,
|
|
self._tracker.marked_lon,
|
|
)
|
|
if current == self._last_persisted:
|
|
return
|
|
try:
|
|
self._settings['AnchorSet'] = current[0]
|
|
if self._tracker.anchor_set:
|
|
if current[1] is not None:
|
|
self._settings['EstimatedLat'] = current[1]
|
|
self._settings['EstimatedLon'] = current[2]
|
|
if current[3] is not None:
|
|
self._settings['MarkedLat'] = current[3]
|
|
self._settings['MarkedLon'] = current[4]
|
|
self._last_persisted = current
|
|
except Exception:
|
|
logger.exception('Failed to persist anchor state')
|
|
|
|
def _restore_anchor_state(self):
|
|
"""Restore anchor state from localsettings on startup."""
|
|
try:
|
|
if int(self._settings['AnchorSet']):
|
|
marked_lat = float(self._settings['MarkedLat'])
|
|
marked_lon = float(self._settings['MarkedLon'])
|
|
est_lat = float(self._settings['EstimatedLat'])
|
|
est_lon = float(self._settings['EstimatedLon'])
|
|
if marked_lat != 0.0 and marked_lon != 0.0:
|
|
self._tracker.marked_lat = marked_lat
|
|
self._tracker.marked_lon = marked_lon
|
|
self._tracker.estimated_lat = est_lat
|
|
self._tracker.estimated_lon = est_lon
|
|
self._tracker.anchor_set = True
|
|
self._tracker.uncertainty_radius_ft = 100.0
|
|
logger.info(
|
|
'Restored anchor state: marked=%.6f,%.6f est=%.6f,%.6f',
|
|
marked_lat, marked_lon, est_lat, est_lon,
|
|
)
|
|
except Exception:
|
|
logger.exception('Failed to restore anchor state')
|
|
|
|
# ------------------------------------------------------------------
|
|
# Main loop
|
|
# ------------------------------------------------------------------
|
|
|
|
def _update(self):
|
|
"""Called every MAIN_LOOP_INTERVAL_MS. Return True to keep running."""
|
|
if not self._running:
|
|
return False
|
|
try:
|
|
self._do_update()
|
|
except Exception:
|
|
logger.exception('Error in update loop')
|
|
return True
|
|
|
|
def _do_update(self):
|
|
snapshot = self._reader.read()
|
|
svc = self._dbusservice
|
|
now = time.time()
|
|
|
|
self._tick_count += 1
|
|
is_slow_tick = (self._tick_count % _SLOW_TICK_DIVISOR == 0)
|
|
|
|
chain_length = float(self._settings['ChainLength'])
|
|
alarm_radius = float(self._settings['AlarmRadius'])
|
|
chain_weight = float(self._settings['ChainWeight'])
|
|
vessel_weight = float(self._settings['VesselWeight'])
|
|
freeboard = float(self._settings['FreeboardHeight'])
|
|
windage_area = float(self._settings['WindageArea'])
|
|
drag_coeff = float(self._settings['DragCoefficient'])
|
|
|
|
svc['/Anchor/ChainLength'] = chain_length
|
|
svc['/Alarm/Radius'] = alarm_radius
|
|
|
|
if snapshot.latitude is not None and snapshot.longitude is not None:
|
|
# Republish vessel state (every tick — 1 Hz)
|
|
svc['/Vessel/Latitude'] = snapshot.latitude
|
|
svc['/Vessel/Longitude'] = snapshot.longitude
|
|
svc['/Vessel/Speed'] = snapshot.speed or 0.0
|
|
svc['/Vessel/Course'] = snapshot.course or 0.0
|
|
svc['/Vessel/Heading'] = snapshot.heading or 0.0
|
|
svc['/Vessel/Depth'] = snapshot.depth or 0.0
|
|
svc['/Vessel/WindSpeed'] = snapshot.wind_speed or 0.0
|
|
svc['/Vessel/WindDirection'] = snapshot.wind_direction or 0.0
|
|
|
|
depth = snapshot.depth or 0.0
|
|
wind_spd = snapshot.wind_speed or 0.0
|
|
|
|
# Steady-state wind force
|
|
f_wind = wind_force_lbs(wind_spd, windage_area, drag_coeff)
|
|
|
|
# Catenary geometry
|
|
cat = catenary_distance(
|
|
chain_length, depth, freeboard, f_wind, chain_weight,
|
|
)
|
|
|
|
# Swing-reversal detection (signed heading rate) — 1 Hz
|
|
is_reversal = False
|
|
if snapshot.heading is not None:
|
|
if self._prev_heading is not None:
|
|
delta = _signed_angle_diff(
|
|
self._prev_heading, snapshot.heading,
|
|
)
|
|
if self._prev_heading_delta is not None:
|
|
if ((self._prev_heading_delta > 1.0 and delta < -1.0)
|
|
or (self._prev_heading_delta < -1.0
|
|
and delta > 1.0)):
|
|
is_reversal = True
|
|
self._prev_heading_delta = delta
|
|
self._prev_heading = snapshot.heading
|
|
|
|
# Lateral speed component for dynamic-load calculation
|
|
lateral_speed = 0.0
|
|
if (self._tracker.marked_lat is not None
|
|
and snapshot.course is not None
|
|
and snapshot.speed is not None):
|
|
bearing = _bearing_between(
|
|
snapshot.latitude, snapshot.longitude,
|
|
self._tracker.marked_lat, self._tracker.marked_lon,
|
|
)
|
|
angle = abs(_signed_angle_diff(snapshot.course, bearing))
|
|
lateral_speed = snapshot.speed * math.sin(math.radians(angle))
|
|
|
|
# Dynamic anchor load
|
|
dyn = dynamic_load(
|
|
f_wind, vessel_weight, lateral_speed,
|
|
chain_length, is_reversal,
|
|
)
|
|
|
|
# Peak load over rolling 60-second window
|
|
self._peak_load_history.append((dyn.total_force_lbs, now))
|
|
cutoff = now - _PEAK_LOAD_WINDOW_SEC
|
|
while (self._peak_load_history
|
|
and self._peak_load_history[0][1] < cutoff):
|
|
self._peak_load_history.popleft()
|
|
self._peak_load = max(
|
|
(ld for ld, _ in self._peak_load_history), default=0.0)
|
|
|
|
# Scope & recommended chain
|
|
scp = scope_ratio(chain_length, depth, freeboard)
|
|
rec = recommended_chain_length(depth, freeboard)
|
|
|
|
# Drag detector tick — 1 Hz (safety-critical)
|
|
self._detector.update(
|
|
snapshot, self._tracker, alarm_radius, chain_length,
|
|
)
|
|
|
|
# --- Slow path (1/15s) ---
|
|
if is_slow_tick:
|
|
# Tracker tick
|
|
self._tracker.update(
|
|
snapshot, chain_length, depth, freeboard,
|
|
windage_area, drag_coeff, chain_weight,
|
|
)
|
|
|
|
# Track buffer tick
|
|
self._track_buffer.add_if_moved(snapshot)
|
|
|
|
# Debug logger tick (if enabled)
|
|
if self._debug_logger.active:
|
|
self._debug_logger.log(snapshot, self._tracker)
|
|
|
|
# Persist anchor state
|
|
self._persist_anchor_state()
|
|
|
|
# Publish computed values
|
|
svc['/Anchor/ScopeRatio'] = round(scp, 2)
|
|
svc['/Anchor/RecommendedScope'] = round(rec, 1)
|
|
svc['/Anchor/EstimatedDistance'] = round(cat.total_distance_ft, 1)
|
|
svc['/Anchor/SteadyStateLoad'] = round(f_wind, 1)
|
|
svc['/Anchor/PeakLoad'] = round(self._peak_load, 1)
|
|
svc['/Anchor/SwingRate'] = round(
|
|
self._detector.swing_rate_deg_per_sec, 3,
|
|
)
|
|
|
|
# Anchor & alarm state (always update)
|
|
svc['/Anchor/Set'] = int(self._tracker.anchor_set)
|
|
svc['/Anchor/Marked/Latitude'] = self._tracker.marked_lat
|
|
svc['/Anchor/Marked/Longitude'] = self._tracker.marked_lon
|
|
svc['/Anchor/Estimated/Latitude'] = self._tracker.estimated_lat
|
|
svc['/Anchor/Estimated/Longitude'] = self._tracker.estimated_lon
|
|
svc['/Anchor/UncertaintyRadius'] = round(
|
|
self._tracker.uncertainty_radius_ft, 1,
|
|
)
|
|
svc['/Anchor/Drift'] = round(self._tracker.drift_ft, 1)
|
|
|
|
svc['/Alarm/Active'] = int(self._detector.alarm_active)
|
|
svc['/Alarm/Type'] = self._detector.alarm_type
|
|
svc['/Alarm/Message'] = self._detector.alarm_message
|
|
|
|
svc['/Track/PointCount'] = self._track_buffer.get_point_count()
|
|
|
|
# Throttled JSON serialisation (every 5 s)
|
|
if now - self._last_json_update >= _JSON_UPDATE_INTERVAL_SEC:
|
|
self._last_json_update = now
|
|
try:
|
|
svc['/Track/Json'] = json.dumps(
|
|
self._track_buffer.get_display_points_json(),
|
|
)
|
|
except (TypeError, ValueError):
|
|
svc['/Track/Json'] = '[]'
|
|
try:
|
|
svc['/Anchor/EstimatedHistory/Json'] = json.dumps(
|
|
self._tracker.get_estimated_history_json(),
|
|
)
|
|
except (TypeError, ValueError):
|
|
svc['/Anchor/EstimatedHistory/Json'] = '[]'
|
|
|
|
# Memory watchdog (every 60 s)
|
|
if now - self._last_watchdog >= _WATCHDOG_INTERVAL_SEC:
|
|
self._last_watchdog = now
|
|
self._check_memory()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Memory watchdog
|
|
# ------------------------------------------------------------------
|
|
|
|
def _check_memory(self):
|
|
rss = _get_rss_mb()
|
|
if rss is None:
|
|
return
|
|
uptime_min = self._tick_count / 60.0
|
|
track_pts = self._track_buffer.get_point_count()
|
|
arc_pts = len(self._tracker._arc_points)
|
|
hdg_ests = len(self._tracker._heading_estimates)
|
|
proxies = len(self._reader._proxy_cache)
|
|
logger.info(
|
|
'WATCHDOG rss=%.1fMB up=%.0fm track=%d arc=%d hdg=%d proxies=%d',
|
|
rss, uptime_min, track_pts, arc_pts, hdg_ests, proxies,
|
|
)
|
|
if rss > _WATCHDOG_KILL_MB:
|
|
logger.error(
|
|
'RSS %.1f MB exceeds %d MB — clearing buffers and proxy cache',
|
|
rss, _WATCHDOG_KILL_MB,
|
|
)
|
|
self._track_buffer.clear()
|
|
self._tracker._arc_points.clear()
|
|
self._reader._proxy_cache.clear()
|
|
elif rss > _WATCHDOG_WARN_MB:
|
|
logger.warning('RSS %.1f MB exceeds %d MB warning threshold',
|
|
rss, _WATCHDOG_WARN_MB)
|
|
|
|
# ------------------------------------------------------------------
|
|
# D-Bus path-change callbacks
|
|
# ------------------------------------------------------------------
|
|
|
|
_SETTINGS_KEY_MAP = {
|
|
'/Settings/Enabled': 'Enabled',
|
|
'/Settings/ChainLength': 'ChainLength',
|
|
'/Settings/AlarmRadius': 'AlarmRadius',
|
|
'/Settings/ChainWeight': 'ChainWeight',
|
|
'/Settings/AnchorWeight': 'AnchorWeight',
|
|
'/Settings/VesselWeight': 'VesselWeight',
|
|
'/Settings/FreeboardHeight': 'FreeboardHeight',
|
|
'/Settings/WindageArea': 'WindageArea',
|
|
'/Settings/DragCoefficient': 'DragCoefficient',
|
|
'/Settings/BowOffset': 'BowOffset',
|
|
}
|
|
|
|
def _on_path_changed(self, path, value):
|
|
"""Handle writable D-Bus path changes from the frontend."""
|
|
logger.info('Path %s written: %s', path, value)
|
|
|
|
# --- Action: Drop Anchor ---
|
|
if path == '/Action/DropAnchor':
|
|
if value == 1:
|
|
lat = self._dbusservice['/Vessel/Latitude']
|
|
lon = self._dbusservice['/Vessel/Longitude']
|
|
if lat and lon:
|
|
self._tracker.drop_anchor(lat, lon)
|
|
logger.info('Action: drop anchor at %.6f, %.6f', lat, lon)
|
|
self._dbusservice['/Action/DropAnchor'] = 0
|
|
return True
|
|
|
|
# --- Action: After Drop ---
|
|
if path == '/Action/AfterDrop':
|
|
if value:
|
|
try:
|
|
params = json.loads(str(value))
|
|
except (json.JSONDecodeError, TypeError):
|
|
logger.warning('Invalid AfterDrop JSON: %s', value)
|
|
self._dbusservice['/Action/AfterDrop'] = ''
|
|
return True
|
|
|
|
snapshot = self._reader.read()
|
|
if snapshot.latitude is None or snapshot.longitude is None:
|
|
logger.warning('AfterDrop ignored — no GPS fix')
|
|
self._dbusservice['/Action/AfterDrop'] = ''
|
|
return True
|
|
|
|
cl = float(params.get(
|
|
'chain_length', self._settings['ChainLength'],
|
|
))
|
|
self._tracker.after_drop(
|
|
snapshot.latitude, snapshot.longitude,
|
|
snapshot.heading or 0.0,
|
|
cl,
|
|
snapshot.depth or 0.0,
|
|
float(self._settings['FreeboardHeight']),
|
|
snapshot.wind_speed or 0.0,
|
|
float(self._settings['WindageArea']),
|
|
float(self._settings['DragCoefficient']),
|
|
float(self._settings['ChainWeight']),
|
|
)
|
|
if 'chain_length' in params:
|
|
try:
|
|
self._settings['ChainLength'] = cl
|
|
except Exception:
|
|
pass
|
|
self._dbusservice['/Action/AfterDrop'] = ''
|
|
return True
|
|
|
|
# --- Action: Weigh Anchor ---
|
|
if path == '/Action/WeighAnchor':
|
|
if value == 1:
|
|
self._tracker.weigh_anchor()
|
|
self._detector.reset()
|
|
self._track_buffer.clear()
|
|
self._peak_load_history.clear()
|
|
self._peak_load = 0.0
|
|
self._prev_heading = None
|
|
self._prev_heading_delta = None
|
|
self._persist_anchor_state()
|
|
logger.info('Action: anchor weighed — all state reset')
|
|
self._dbusservice['/Action/WeighAnchor'] = 0
|
|
return True
|
|
|
|
# --- Joystick anchor-position adjustment ---
|
|
if path == '/Settings/Anchor/Latitude':
|
|
if self._tracker.anchor_set and value is not None:
|
|
self._tracker.marked_lat = float(value)
|
|
return True
|
|
|
|
if path == '/Settings/Anchor/Longitude':
|
|
if self._tracker.anchor_set and value is not None:
|
|
self._tracker.marked_lon = float(value)
|
|
return True
|
|
|
|
# --- Bow offset live update ---
|
|
if path == '/Settings/BowOffset':
|
|
try:
|
|
self._tracker.bow_offset_ft = float(value)
|
|
self._settings['BowOffset'] = float(value)
|
|
except Exception:
|
|
logger.exception('Failed to update bow offset')
|
|
return True
|
|
|
|
# --- Debug logging toggle ---
|
|
if path == '/Settings/DebugLogging':
|
|
try:
|
|
self._settings['DebugLogging'] = int(value)
|
|
except Exception:
|
|
pass
|
|
if int(value):
|
|
self._debug_logger.enable()
|
|
else:
|
|
self._debug_logger.disable()
|
|
return True
|
|
|
|
# --- Settings -> persist to localsettings ---
|
|
key = self._SETTINGS_KEY_MAP.get(path)
|
|
if key is not None:
|
|
try:
|
|
self._settings[key] = value
|
|
except Exception:
|
|
logger.exception('Failed to persist setting %s', path)
|
|
return True
|
|
|
|
return True
|
|
|
|
def _on_setting_changed(self, setting, old, new):
|
|
"""Called when a Venus OS localsetting changes (external write)."""
|
|
logger.info('Setting %s changed: %s -> %s', setting, old, new)
|
|
try:
|
|
self._dbusservice['/Settings/' + setting] = new
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Entry point
|
|
# ----------------------------------------------------------------------
|
|
|
|
def main():
|
|
DBusGMainLoop(set_as_default=True)
|
|
|
|
logging.basicConfig(
|
|
level=getattr(logging, LOG_LEVEL, logging.INFO),
|
|
format='%(asctime)s %(name)s %(levelname)s %(message)s',
|
|
datefmt='%Y-%m-%d %H:%M:%S',
|
|
)
|
|
logger.info('Starting dbus-anchor-alarm v%s', VERSION)
|
|
|
|
service = AnchorAlarmService()
|
|
mainloop = None
|
|
|
|
def shutdown(signum, _frame):
|
|
try:
|
|
sig_name = signal.Signals(signum).name
|
|
except ValueError:
|
|
sig_name = str(signum)
|
|
logger.info('Received %s, shutting down...', sig_name)
|
|
service.stop()
|
|
if mainloop is not None:
|
|
mainloop.quit()
|
|
|
|
signal.signal(signal.SIGTERM, shutdown)
|
|
signal.signal(signal.SIGINT, shutdown)
|
|
|
|
try:
|
|
service.start()
|
|
mainloop = GLib.MainLoop()
|
|
mainloop.run()
|
|
except KeyboardInterrupt:
|
|
print("\nShutdown requested")
|
|
service.stop()
|
|
except Exception as e:
|
|
logging.error("Fatal error: %s", e, exc_info=True)
|
|
sys.exit(1)
|
|
finally:
|
|
logging.info("Service stopped")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|