Chain: CHAIN_LEN ft • Depth: DEPTH_RANGE ft • Wind: WIND_RANGE kts • Duration: DURATION hrs
+
+
+
Estimated Anchor
ANCHOR_POS
+
Heading Offset (mean±std)
OFFSET_STATS°
+
Wind↔Distance Corr
CORR_WS
+
Swing Period
SWING_PERIOD
+
Estimation Samples
EST_ROWS
+
Sensor Samples
SENSOR_ROWS
+
+
+
+
1. Vessel Track / Swing Circle
+
+
+
+
Vessel positions in local X/Y (ft) relative to estimated anchor. Color encodes time progression (blue→yellow). Dashed circle = arc-fit radius.
+
+
+
2. Weathervaning: Heading vs Wind Direction
+
+
+
+
+
Left: heading and wind direction over time. Right: distribution of heading offset (hdg − wind_dir). Mean offset reveals systematic bias from hull/keel asymmetry.
+
+
+
3. Catenary Model Validation
+
+
+
+
Measured catenary distance vs depth, overlaid with theoretical curve at median wind speed (WS_MED kts). Scatter shows tidal variation.
+
+
+
4. Depth (Tide) & Distance Time Series
+
+
+
+
Depth and distance from anchor over the full observation window. Inverse correlation shows tide pulling the vessel closer at high water (more chain hangs vertically).
+
+
+
5. Wind Effects on Swing Radius
+
+
+
+
Wind speed vs distance from anchor. Color encodes depth (darker = deeper). Correlation r = CORR_WS_2.
+
+
+
6. Swing Dynamics / Oscillation
+
+
+
+
+
Left: heading over time. Right: heading rate of change (°/s, clipped ±5). Estimated swing period: SWING_PERIOD_2.
+
+
+
7. Polar Position Plot
+
+
+
+
Vessel bearing and distance from anchor in polar coordinates. Red markers show wind direction samples to compare occupied sectors with wind.
+
+
+
+"""
+
+
+def render_html(results):
+ s = results["stats"]
+ html = HTML_TEMPLATE
+ html = html.replace("DATA_JSON", json.dumps(results))
+ html = html.replace("CHAIN_LEN", str(int(CHAIN_LENGTH_FT)))
+ html = html.replace("DEPTH_RANGE", f'{s["depth_min"]}–{s["depth_max"]}')
+ html = html.replace("WIND_RANGE", f'{s["ws_min"]}–{s["ws_max"]}')
+ html = html.replace("DURATION", str(s["total_hours"]))
+ html = html.replace("ANCHOR_POS", f'{s["anchor_lat"]}°N, {s["anchor_lon"]}°W')
+ html = html.replace("OFFSET_STATS", f'{s["mean_offset"]}±{s["std_offset"]}')
+ html = html.replace("CORR_WS_2", str(s["corr_ws_dist"]))
+ html = html.replace("CORR_WS", str(s["corr_ws_dist"]))
+ sp = s["swing_period_min"]
+ html = html.replace("SWING_PERIOD_2", f"{sp} min" if sp else "N/A")
+ html = html.replace("SWING_PERIOD", f"{sp} min" if sp else "N/A")
+ html = html.replace("WS_MED", str(results["catenary"]["ws_median"]))
+ html = html.replace("EST_ROWS", f'{s["est_rows"]:,}')
+ html = html.replace("SENSOR_ROWS", f'{s["sensor_rows"]:,}')
+ return html
+
+
+def main():
+ print("Loading data from track.db ...")
+ est, summary, raw = load_data()
+ print(f" estimation_log: {len(est)} rows")
+ print(f" summary_points: {len(summary)} rows")
+ print(f" raw_points: {len(raw)} rows")
+
+ print("Building unified time series ...")
+ merged, sensor = build_unified(est, summary, raw)
+ print(f" merged: {len(merged)} rows, sensor: {len(sensor)} rows")
+
+ print("Computing analyses ...")
+ results = compute_analyses(merged, sensor)
+
+ print("Rendering HTML report ...")
+ html = render_html(results)
+ with open(OUT_PATH, "w") as f:
+ f.write(html)
+ print(f"Report written to {OUT_PATH}")
+ print(f" ({len(html):,} bytes)")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/dbus-anchor-alarm/analysis/anchor_debug.html b/dbus-anchor-alarm/analysis/anchor_debug.html
new file mode 100644
index 0000000..3710fe9
--- /dev/null
+++ b/dbus-anchor-alarm/analysis/anchor_debug.html
@@ -0,0 +1,414 @@
+
+
+
+
+
+Anchor Estimation Debug
+
+
+
+
+
+ Reliable = wind ≥ 7.0 kts AND distance from marked anchor ≥ 0.6 × chain length (90.0 ft).
+ In light winds the chain coils on the seabed in a spiral; the vessel drifts randomly and heading does not point toward the anchor.
+ Only when the rode is taut (≈60% of chain length) under steady wind can the vessel heading reliably indicate anchor bearing.
+
+
+
+
1. Track Map — Reliability Overlay
+
+
+
+
Vessel positions in local X/Y (ft) relative to known anchor position. Green = reliable, yellow = marginal, red = unreliable. Dashed circles show the distance threshold and chain length radius.
+
+
+
2. Distance & Wind vs Time
+
+
+
+
Distance from marked anchor (blue) and chain-length distance threshold (dashed). Only points above the threshold with sufficient wind are considered reliable.
+
+
+
3. Distance Ratio Distribution
+
+
+
+
+
Left: histogram of distance-from-anchor / chain-length ratios. The vertical line marks the tension threshold. Right: overall reliability breakdown.
+
+
+
4. Estimated Anchor Position History
+
+
+
+
History of estimated anchor positions (X/Y). Spread shows estimation uncertainty over time. Tight clusters indicate confidence; scattered points indicate the estimator is chasing noise.
+
+
+
5. Estimation Algorithm Comparison
+
+
+
+
Algorithm
Error (ft)
Service estimate (current)
69.6
Circle Fit (all points)
15.7
Boundary Circle Fit (top 25%)
22.0
Tension-Gated Circle Fit (>60%)
23.3
Tension-Gated Centroid (>60%)
39.7
Distance-Weighted Centroid
51.2
Simple Centroid
56.5
Distance²-Weighted Centroid
58.6
+
+ Each algorithm estimates the anchor position using only the GPS track geometry.
+ Distance from center acts as a proxy for chain tension (farther = more wind = taut rode).
+ Algorithms that gate or weight by distance effectively filter for wind conditions, even without
+ per-point wind data. Lower error = closer to known anchor position.
+
+
+
+
6. System Parameters
+
+
+
+
Chain length
150.0 ft
+
Vessel depth
11.9 ft
+
Freeboard
4.0 ft
+
Windage area
200.0 sqft
+
Drag coefficient
1.1
+
Chain weight
2.25 lb/ft
+
+
+
+
+
Wind threshold
7.0 kts
+
Tension ratio
0.6
+
Distance threshold
90.0 ft
+
Current wind
2.0 kts
+
Wind direction
89.2°
+
Vessel heading
72.1°
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/dbus-anchor-alarm/analysis/anchor_report.html b/dbus-anchor-alarm/analysis/anchor_report.html
new file mode 100644
index 0000000..9d8000a
--- /dev/null
+++ b/dbus-anchor-alarm/analysis/anchor_report.html
@@ -0,0 +1,348 @@
+
+
+
+
+
+Anchor Data Analysis Report
+
+
+
+
+
Anchor Analysis Report
+
Chain: 150 ft • Depth: 10.1–14.1 ft • Wind: 10.6–23.7 kts • Duration: 18.9 hrs
+
+
+
Estimated Anchor
25.5452976°N, -76.6436951°W
+
Heading Offset (mean±std)
25.9±58.2°
+
Wind↔Distance Corr
0.609
+
Swing Period
0.3 min
+
Estimation Samples
42,261
+
Sensor Samples
7,970
+
+
+
+
1. Vessel Track / Swing Circle
+
+
+
+
Vessel positions in local X/Y (ft) relative to estimated anchor. Color encodes time progression (blue→yellow). Dashed circle = arc-fit radius.
+
+
+
2. Weathervaning: Heading vs Wind Direction
+
+
+
+
+
Left: heading and wind direction over time. Right: distribution of heading offset (hdg − wind_dir). Mean offset reveals systematic bias from hull/keel asymmetry.
+
+
+
3. Catenary Model Validation
+
+
+
+
Measured catenary distance vs depth, overlaid with theoretical curve at median wind speed (17.5 kts). Scatter shows tidal variation.
+
+
+
4. Depth (Tide) & Distance Time Series
+
+
+
+
Depth and distance from anchor over the full observation window. Inverse correlation shows tide pulling the vessel closer at high water (more chain hangs vertically).
+
+
+
5. Wind Effects on Swing Radius
+
+
+
+
Wind speed vs distance from anchor. Color encodes depth (darker = deeper). Correlation r = 0.609.
+
+
+
6. Swing Dynamics / Oscillation
+
+
+
+
+
Left: heading over time. Right: heading rate of change (°/s, clipped ±5). Estimated swing period: 0.3 min.
+
+
+
7. Polar Position Plot
+
+
+
+
Vessel bearing and distance from anchor in polar coordinates. Red markers show wind direction samples to compare occupied sectors with wind.
+
+
+
+
\ No newline at end of file
diff --git a/dbus-anchor-alarm/analysis/live_track_debug.py b/dbus-anchor-alarm/analysis/live_track_debug.py
new file mode 100644
index 0000000..6d1800d
--- /dev/null
+++ b/dbus-anchor-alarm/analysis/live_track_debug.py
@@ -0,0 +1,1208 @@
+#!/usr/bin/env python3
+"""
+Live anchor-estimation debug tool.
+
+Connects to a Venus OS device (cerbo) via MQTT, pulls current anchor alarm
+state (GPS track, wind, chain, estimations), and generates an HTML report
+showing which track segments are reliable for anchor position estimation.
+
+Reliability is based on two criteria:
+ 1. Wind speed >= threshold (default 7 kts) — chain is under tension
+ 2. Vessel distance from marked anchor >= ratio * chain_length (default 0.60)
+ — the rode is taut enough to point toward the anchor
+
+Usage:
+ python3 live_track_debug.py --host cerbo
+ python3 live_track_debug.py --host 192.168.1.100 --wind-threshold 8 --tension-ratio 0.55
+"""
+
+import argparse
+import json
+import math
+import os
+import sys
+import threading
+import time
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
+from catenary import catenary_distance, wind_force_lbs
+
+try:
+ import paho.mqtt.client as mqtt
+except ImportError:
+ sys.exit("paho-mqtt is required: pip install paho-mqtt")
+
+EARTH_RADIUS_FT = 20_902_231.0
+NM_TO_FT = 6076.12
+
+PATHS = [
+ "/Anchor/Set",
+ "/Anchor/Marked/Latitude",
+ "/Anchor/Marked/Longitude",
+ "/Anchor/Estimated/Latitude",
+ "/Anchor/Estimated/Longitude",
+ "/Anchor/UncertaintyRadius",
+ "/Anchor/Drift",
+ "/Anchor/EstimatedHistory/Json",
+ "/Anchor/ChainLength",
+ "/Anchor/EstimatedDistance",
+ "/Vessel/Latitude",
+ "/Vessel/Longitude",
+ "/Vessel/Speed",
+ "/Vessel/Course",
+ "/Vessel/Heading",
+ "/Vessel/Depth",
+ "/Vessel/WindSpeed",
+ "/Vessel/WindDirection",
+ "/Track/Json",
+ "/Track/PointCount",
+ "/Settings/ChainLength",
+ "/Settings/FreeboardHeight",
+ "/Settings/WindageArea",
+ "/Settings/DragCoefficient",
+ "/Settings/ChainWeight",
+ "/Settings/Anchor/Latitude",
+ "/Settings/Anchor/Longitude",
+]
+
+
+# ---------------------------------------------------------------------------
+# Geo helpers (same as anchor_tracker.py)
+# ---------------------------------------------------------------------------
+
+def haversine_ft(lat1, lon1, lat2, lon2):
+ rlat1, rlat2 = math.radians(lat1), math.radians(lat2)
+ dlat = math.radians(lat2 - lat1)
+ dlon = math.radians(lon2 - lon1)
+ a = (math.sin(dlat / 2) ** 2
+ + math.cos(rlat1) * math.cos(rlat2) * math.sin(dlon / 2) ** 2)
+ return EARTH_RADIUS_FT * 2.0 * math.atan2(math.sqrt(a), math.sqrt(1.0 - a))
+
+
+def project_position(lat, lon, distance_ft, bearing_deg):
+ d = distance_ft / EARTH_RADIUS_FT
+ brng = math.radians(bearing_deg)
+ rlat = math.radians(lat)
+ rlon = math.radians(lon)
+ new_lat = math.asin(
+ math.sin(rlat) * math.cos(d)
+ + math.cos(rlat) * math.sin(d) * math.cos(brng)
+ )
+ new_lon = rlon + math.atan2(
+ math.sin(brng) * math.sin(d) * math.cos(rlat),
+ math.cos(d) - math.sin(rlat) * math.sin(new_lat),
+ )
+ return math.degrees(new_lat), math.degrees(new_lon)
+
+
+def to_local_xy(lat, lon, ref_lat, ref_lon):
+ lat_rad = math.radians(ref_lat)
+ dx = (lon - ref_lon) * 60.0 * NM_TO_FT * math.cos(lat_rad)
+ dy = (lat - ref_lat) * 60.0 * NM_TO_FT
+ return dx, dy
+
+
+def from_local_xy(x_ft, y_ft, ref_lat, ref_lon):
+ lat_rad = math.radians(ref_lat)
+ cos_lat = math.cos(lat_rad)
+ if abs(cos_lat) < 1e-12:
+ cos_lat = 1e-12
+ dlon = x_ft / (60.0 * NM_TO_FT * cos_lat)
+ dlat = y_ft / (60.0 * NM_TO_FT)
+ return ref_lat + dlat, ref_lon + dlon
+
+
+def circle_fit(points_xy):
+ """Algebraic circle fit (Kasa method). Returns (cx, cy, radius) or None."""
+ n = len(points_xy)
+ if n < 3:
+ return None
+
+ sx = sy = sxx = syy = sxy = 0.0
+ sxxx = syyy = sxyy = syxx = 0.0
+ for x, y in points_xy:
+ x2, y2 = x * x, y * y
+ sx += x; sy += y
+ sxx += x2; syy += y2; sxy += x * y
+ sxxx += x2 * x; syyy += y2 * y
+ sxyy += x * y2; syxx += y * x2
+
+ a11 = 2.0 * (sx * sx - n * sxx)
+ a12 = 2.0 * (sx * sy - n * sxy)
+ a22 = 2.0 * (sy * sy - n * syy)
+
+ b1 = (sx * sxx - n * sxxx) + (sx * syy - n * sxyy)
+ b2 = (sy * sxx - n * syxx) + (sy * syy - n * syyy)
+
+ det = a11 * a22 - a12 * a12
+ if abs(det) < 1e-12:
+ return None
+
+ cx = (b1 * a22 - b2 * a12) / det
+ cy = (a11 * b2 - a12 * b1) / det
+
+ radius_sq = (sxx + syy - 2.0 * cx * sx - 2.0 * cy * sy) / n + cx * cx + cy * cy
+ if radius_sq < 0:
+ return None
+ return cx, cy, math.sqrt(radius_sq)
+
+
+# ---------------------------------------------------------------------------
+# Estimation algorithms
+# ---------------------------------------------------------------------------
+
+def run_estimators(track, chain_length_ft, tension_ratio, ref_lat, ref_lon):
+ """Run competing anchor estimation algorithms on track data.
+
+ Each algorithm produces an (x, y) estimate in local coordinates relative
+ to ref_lat/ref_lon, converted back to lat/lon. Returns a list of dicts.
+
+ The track has no per-point wind data, so algorithms use distance-from-
+ centroid as a proxy for chain tension (farther = more wind = more taut).
+ """
+ if len(track) < 3:
+ return []
+
+ pts_ll = [(p["lat"], p["lon"]) for p in track]
+ pts_xy = [to_local_xy(la, lo, ref_lat, ref_lon) for la, lo in pts_ll]
+
+ results = []
+
+ # ---- A. Simple centroid ----
+ cx = sum(x for x, _ in pts_xy) / len(pts_xy)
+ cy = sum(y for _, y in pts_xy) / len(pts_xy)
+ results.append(_make_result(
+ "centroid", "Simple Centroid",
+ "Unweighted average of all track positions.",
+ cx, cy, ref_lat, ref_lon,
+ ))
+
+ # ---- B. Distance-weighted centroid (iterative) ----
+ wcx, wcy = cx, cy
+ for _ in range(5):
+ total_w = 0.0
+ wx = wy = 0.0
+ for x, y in pts_xy:
+ d = math.sqrt((x - wcx) ** 2 + (y - wcy) ** 2) + 1.0
+ wx += x * d
+ wy += y * d
+ total_w += d
+ if total_w > 0:
+ wcx = wx / total_w
+ wcy = wy / total_w
+ results.append(_make_result(
+ "dist_weighted", "Distance-Weighted Centroid",
+ "Points farther from center get higher weight (proxy for chain "
+ "tension / wind). Iterates 5x.",
+ wcx, wcy, ref_lat, ref_lon,
+ ))
+
+ # ---- C. Distance-squared-weighted centroid ----
+ wcx2, wcy2 = cx, cy
+ for _ in range(5):
+ total_w = 0.0
+ wx = wy = 0.0
+ for x, y in pts_xy:
+ d2 = (x - wcx2) ** 2 + (y - wcy2) ** 2 + 1.0
+ wx += x * d2
+ wy += y * d2
+ total_w += d2
+ if total_w > 0:
+ wcx2 = wx / total_w
+ wcy2 = wy / total_w
+ results.append(_make_result(
+ "dist2_weighted", "Distance²-Weighted Centroid",
+ "Squared distance weighting — strongly favors outermost points "
+ "where chain is taut.",
+ wcx2, wcy2, ref_lat, ref_lon,
+ ))
+
+ # ---- D. Circle fit (all points) ----
+ fit = circle_fit(pts_xy)
+ if fit:
+ fcx, fcy, frad = fit
+ r = _make_result(
+ "circle_fit", "Circle Fit (all points)",
+ f"Kasa algebraic circle fit on all {len(pts_xy)} points. "
+ f"Fitted radius: {frad:.0f} ft.",
+ fcx, fcy, ref_lat, ref_lon,
+ )
+ r["fit_radius"] = round(frad, 1)
+ results.append(r)
+
+ # ---- E. Tension-gated centroid ----
+ dists_from_centroid = [
+ math.sqrt((x - cx) ** 2 + (y - cy) ** 2) for x, y in pts_xy
+ ]
+ max_dist = max(dists_from_centroid) if dists_from_centroid else 1.0
+ gate = tension_ratio * max_dist
+ gated = [(x, y) for (x, y), d in zip(pts_xy, dists_from_centroid) if d >= gate]
+ if len(gated) >= 3:
+ gcx = sum(x for x, _ in gated) / len(gated)
+ gcy = sum(y for _, y in gated) / len(gated)
+ results.append(_make_result(
+ "tension_gated", f"Tension-Gated Centroid (>{tension_ratio:.0%})",
+ f"Only uses {len(gated)}/{len(pts_xy)} points beyond "
+ f"{tension_ratio:.0%} of max swing radius ({gate:.0f} ft). "
+ f"These points had taut chain.",
+ gcx, gcy, ref_lat, ref_lon,
+ ))
+
+ # ---- F. Boundary circle fit ----
+ if len(pts_xy) >= 10:
+ sorted_by_dist = sorted(
+ zip(pts_xy, dists_from_centroid), key=lambda t: t[1], reverse=True,
+ )
+ boundary_n = max(5, len(pts_xy) // 4)
+ boundary_pts = [p for p, _ in sorted_by_dist[:boundary_n]]
+ bfit = circle_fit(boundary_pts)
+ if bfit:
+ bcx, bcy, brad = bfit
+ r = _make_result(
+ "boundary_fit", f"Boundary Circle Fit (top 25%)",
+ f"Circle fit on outermost {boundary_n} points only. "
+ f"These had the highest chain tension. "
+ f"Fitted radius: {brad:.0f} ft.",
+ bcx, bcy, ref_lat, ref_lon,
+ )
+ r["fit_radius"] = round(brad, 1)
+ results.append(r)
+
+ # ---- G. Tension-gated circle fit ----
+ if len(gated) >= 5:
+ tfit = circle_fit(gated)
+ if tfit:
+ tcx, tcy, trad = tfit
+ r = _make_result(
+ "tension_circle", f"Tension-Gated Circle Fit (>{tension_ratio:.0%})",
+ f"Circle fit on {len(gated)} points beyond tension threshold. "
+ f"Fitted radius: {trad:.0f} ft.",
+ tcx, tcy, ref_lat, ref_lon,
+ )
+ r["fit_radius"] = round(trad, 1)
+ results.append(r)
+
+ return results
+
+
+def _make_result(algo_id, name, description, x, y, ref_lat, ref_lon):
+ lat, lon = from_local_xy(x, y, ref_lat, ref_lon)
+ error_ft = round(haversine_ft(ref_lat, ref_lon, lat, lon), 1)
+ return {
+ "id": algo_id,
+ "name": name,
+ "description": description,
+ "x": round(x, 1),
+ "y": round(y, 1),
+ "lat": round(lat, 7),
+ "lon": round(lon, 7),
+ "error_ft": error_ft,
+ }
+
+
+# ---------------------------------------------------------------------------
+# MQTT data collector
+# ---------------------------------------------------------------------------
+
+class AnchorDataCollector:
+ """Connects to Venus MQTT and collects anchor alarm state."""
+
+ def __init__(self, host, port=1883):
+ self.host = host
+ self.port = port
+ self.portal_id = None
+ self.values = {}
+ self._ready = threading.Event()
+ self._got_portal = threading.Event()
+ self._received_count = 0
+
+ def collect(self, timeout=10):
+ client = mqtt.Client(client_id="anchor-debug", protocol=mqtt.MQTTv311)
+ client.on_connect = self._on_connect
+ client.on_message = self._on_message
+
+ print(f"Connecting to {self.host}:{self.port} ...")
+ client.connect(self.host, self.port, keepalive=60)
+ client.loop_start()
+
+ if not self._got_portal.wait(timeout=timeout):
+ client.loop_stop()
+ client.disconnect()
+ sys.exit(f"Timed out waiting for portal ID from {self.host}")
+
+ print(f"Portal ID: {self.portal_id}")
+ prefix = f"N/{self.portal_id}/anchoralarm/0"
+ client.subscribe(f"{prefix}/#")
+
+ time.sleep(0.5)
+
+ for path in PATHS:
+ topic = f"R/{self.portal_id}/anchoralarm/0{path}"
+ client.publish(topic, "")
+
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ time.sleep(0.5)
+ if self._received_count >= len(PATHS) - 2:
+ time.sleep(1.0)
+ break
+
+ client.loop_stop()
+ client.disconnect()
+ print(f"Collected {self._received_count} values")
+ return self.values
+
+ def _on_connect(self, client, _userdata, _flags, rc):
+ if rc != 0:
+ sys.exit(f"MQTT connection failed: rc={rc}")
+ client.subscribe("N/+/anchoralarm/0/#")
+
+ def _on_message(self, _client, _userdata, msg):
+ parts = msg.topic.split("/")
+ if len(parts) < 4:
+ return
+
+ if self.portal_id is None:
+ self.portal_id = parts[1]
+ self._got_portal.set()
+
+ aa_idx = None
+ for i, p in enumerate(parts):
+ if p == "anchoralarm":
+ aa_idx = i
+ break
+ if aa_idx is None or aa_idx + 1 >= len(parts):
+ return
+
+ path = "/" + "/".join(parts[aa_idx + 2:])
+
+ try:
+ payload = json.loads(msg.payload.decode())
+ value = payload.get("value", payload)
+ except (json.JSONDecodeError, UnicodeDecodeError):
+ return
+
+ self.values[path] = value
+ self._received_count += 1
+
+
+# ---------------------------------------------------------------------------
+# Analysis
+# ---------------------------------------------------------------------------
+
+def analyze(values, wind_threshold, tension_ratio,
+ known_anchor_lat=None, known_anchor_lon=None):
+ """Run reliability analysis on collected data.
+
+ If known_anchor_lat/lon are provided, they are used as the true anchor
+ position for all distance and XY calculations. The service's marked
+ and estimated positions are still reported for comparison.
+ """
+
+ track_raw = values.get("/Track/Json", "[]")
+ if isinstance(track_raw, str):
+ try:
+ track = json.loads(track_raw)
+ except json.JSONDecodeError:
+ track = []
+ else:
+ track = track_raw if isinstance(track_raw, list) else []
+
+ marked_lat = _float(values.get("/Anchor/Marked/Latitude"))
+ marked_lon = _float(values.get("/Anchor/Marked/Longitude"))
+ est_lat = _float(values.get("/Anchor/Estimated/Latitude"))
+ est_lon = _float(values.get("/Anchor/Estimated/Longitude"))
+ chain_length = _float(values.get("/Anchor/ChainLength")) or _float(values.get("/Settings/ChainLength")) or 150.0
+ est_distance = _float(values.get("/Anchor/EstimatedDistance")) or 0.0
+ vessel_wind = _float(values.get("/Vessel/WindSpeed")) or 0.0
+ vessel_wind_dir = _float(values.get("/Vessel/WindDirection"))
+ vessel_heading = _float(values.get("/Vessel/Heading"))
+ vessel_depth = _float(values.get("/Vessel/Depth")) or 0.0
+ freeboard = _float(values.get("/Settings/FreeboardHeight")) or 4.0
+ windage_area = _float(values.get("/Settings/WindageArea")) or 200.0
+ drag_coeff = _float(values.get("/Settings/DragCoefficient")) or 1.1
+ chain_weight = _float(values.get("/Settings/ChainWeight")) or 2.25
+ uncertainty = _float(values.get("/Anchor/UncertaintyRadius")) or 0.0
+ drift = _float(values.get("/Anchor/Drift")) or 0.0
+ anchor_set = values.get("/Anchor/Set", 0)
+
+ est_history_raw = values.get("/Anchor/EstimatedHistory/Json", "[]")
+ if isinstance(est_history_raw, str):
+ try:
+ est_history = json.loads(est_history_raw)
+ except json.JSONDecodeError:
+ est_history = []
+ else:
+ est_history = est_history_raw if isinstance(est_history_raw, list) else []
+
+ if not track:
+ print("WARNING: No track data received. Is the anchor set?")
+
+ using_known = known_anchor_lat is not None and known_anchor_lon is not None
+
+ if using_known:
+ ref_lat = known_anchor_lat
+ ref_lon = known_anchor_lon
+ print(f"Using known anchor position: {ref_lat:.6f}, {ref_lon:.6f}")
+ elif marked_lat is not None and marked_lon is not None:
+ ref_lat = marked_lat
+ ref_lon = marked_lon
+ else:
+ print("WARNING: No marked anchor position. Using estimated position as reference.")
+ if est_lat is not None and est_lon is not None:
+ ref_lat, ref_lon = est_lat, est_lon
+ elif track:
+ ref_lat, ref_lon = track[0]["lat"], track[0]["lon"]
+ else:
+ ref_lat, ref_lon = 0.0, 0.0
+
+ distance_threshold = tension_ratio * chain_length
+
+ classified = []
+ for pt in track:
+ lat, lon = pt["lat"], pt["lon"]
+ ts = pt.get("ts", 0)
+ dist = haversine_ft(ref_lat, ref_lon, lat, lon)
+ ratio = dist / chain_length if chain_length > 0 else 0.0
+
+ wind_ok = vessel_wind >= wind_threshold
+ tension_ok = dist >= distance_threshold
+
+ if wind_ok and tension_ok:
+ reliability = "reliable"
+ elif wind_ok or tension_ok:
+ reliability = "marginal"
+ else:
+ reliability = "unreliable"
+
+ x, y = to_local_xy(lat, lon, ref_lat, ref_lon)
+ classified.append({
+ "ts": ts, "lat": lat, "lon": lon,
+ "x": round(x, 1), "y": round(y, 1),
+ "dist": round(dist, 1), "ratio": round(ratio, 3),
+ "reliability": reliability,
+ })
+
+ est_history_xy = []
+ for eh in est_history:
+ x, y = to_local_xy(eh["lat"], eh["lon"], ref_lat, ref_lon)
+ est_history_xy.append({
+ "x": round(x, 1), "y": round(y, 1),
+ "ts": eh.get("ts", 0),
+ "uncertainty_ft": eh.get("uncertainty_ft", 0),
+ })
+
+ n_reliable = sum(1 for c in classified if c["reliability"] == "reliable")
+ n_marginal = sum(1 for c in classified if c["reliability"] == "marginal")
+ n_unreliable = sum(1 for c in classified if c["reliability"] == "unreliable")
+ n_total = len(classified)
+ pct_reliable = round(100.0 * n_reliable / n_total, 1) if n_total else 0.0
+
+ est_x, est_y = (0.0, 0.0)
+ if est_lat is not None and est_lon is not None:
+ est_x, est_y = to_local_xy(est_lat, est_lon, ref_lat, ref_lon)
+
+ force = wind_force_lbs(vessel_wind, windage_area, drag_coeff)
+ cat = catenary_distance(chain_length, vessel_depth, freeboard, force, chain_weight)
+
+ estimators = run_estimators(track, chain_length, tension_ratio, ref_lat, ref_lon)
+
+ marked_x, marked_y = (0.0, 0.0)
+ if marked_lat is not None and marked_lon is not None:
+ marked_x, marked_y = to_local_xy(marked_lat, marked_lon, ref_lat, ref_lon)
+
+ est_error_ft = None
+ marked_error_ft = None
+ if using_known:
+ if est_lat is not None and est_lon is not None:
+ est_error_ft = round(haversine_ft(ref_lat, ref_lon, est_lat, est_lon), 1)
+ if marked_lat is not None and marked_lon is not None:
+ marked_error_ft = round(haversine_ft(ref_lat, ref_lon, marked_lat, marked_lon), 1)
+
+ return {
+ "track": classified,
+ "estimators": estimators,
+ "est_history": est_history_xy,
+ "ref": {"lat": ref_lat, "lon": ref_lon, "using_known": using_known},
+ "marked": {
+ "lat": marked_lat, "lon": marked_lon,
+ "x": round(marked_x, 1), "y": round(marked_y, 1),
+ "error_ft": marked_error_ft,
+ },
+ "estimated": {
+ "lat": est_lat, "lon": est_lon,
+ "x": round(est_x, 1), "y": round(est_y, 1),
+ "error_ft": est_error_ft,
+ },
+ "params": {
+ "chain_length": chain_length,
+ "wind_threshold": wind_threshold,
+ "tension_ratio": tension_ratio,
+ "distance_threshold": round(distance_threshold, 1),
+ "vessel_wind": round(vessel_wind, 1),
+ "vessel_wind_dir": vessel_wind_dir,
+ "vessel_heading": vessel_heading,
+ "vessel_depth": round(vessel_depth, 1),
+ "freeboard": freeboard,
+ "windage_area": windage_area,
+ "drag_coeff": drag_coeff,
+ "chain_weight": chain_weight,
+ "est_distance": round(est_distance, 1),
+ "catenary_distance": round(cat.total_distance_ft, 1),
+ "chain_on_bottom": round(cat.chain_on_bottom_ft, 1),
+ "uncertainty": round(uncertainty, 1),
+ "drift": round(drift, 1),
+ "anchor_set": bool(anchor_set),
+ },
+ "stats": {
+ "total_points": n_total,
+ "reliable": n_reliable,
+ "marginal": n_marginal,
+ "unreliable": n_unreliable,
+ "pct_reliable": pct_reliable,
+ },
+ }
+
+
+def _float(v):
+ if v is None:
+ return None
+ try:
+ return float(v)
+ except (TypeError, ValueError):
+ return None
+
+
+# ---------------------------------------------------------------------------
+# HTML report
+# ---------------------------------------------------------------------------
+
+HTML_TEMPLATE = r"""
+
+
+
+
+Anchor Estimation Debug
+
+
+
+
+
+ Reliable = wind ≥ WIND_THR kts AND distance from marked anchor ≥ TENSION_RATIO × chain length (DIST_THR ft).
+ In light winds the chain coils on the seabed in a spiral; the vessel drifts randomly and heading does not point toward the anchor.
+ Only when the rode is taut (≈60% of chain length) under steady wind can the vessel heading reliably indicate anchor bearing.
+
+
+
+
1. Track Map — Reliability Overlay
+
+
+
+
Vessel positions in local X/Y (ft) relative to ANCHOR_REF_LABEL. Green = reliable, yellow = marginal, red = unreliable. Dashed circles show the distance threshold and chain length radius.
+
+
+
2. Distance & Wind vs Time
+
+
+
+
Distance from marked anchor (blue) and chain-length distance threshold (dashed). Only points above the threshold with sufficient wind are considered reliable.
+
+
+
3. Distance Ratio Distribution
+
+
+
+
+
Left: histogram of distance-from-anchor / chain-length ratios. The vertical line marks the tension threshold. Right: overall reliability breakdown.
+
+
+
4. Estimated Anchor Position History
+
+
+
+
History of estimated anchor positions (X/Y). Spread shows estimation uncertainty over time. Tight clusters indicate confidence; scattered points indicate the estimator is chasing noise.
+
+
+
5. Estimation Algorithm Comparison
+
+
+
+ALGO_TABLE
+
+ Each algorithm estimates the anchor position using only the GPS track geometry.
+ Distance from center acts as a proxy for chain tension (farther = more wind = taut rode).
+ Algorithms that gate or weight by distance effectively filter for wind conditions, even without
+ per-point wind data. Lower error = closer to ALGO_REF_LABEL.
+
+
+
+
6. System Parameters
+
+
+
+
Chain length
CHAIN_LEN ft
+
Vessel depth
DEPTH ft
+
Freeboard
FREEBOARD ft
+
Windage area
WINDAGE sqft
+
Drag coefficient
DRAG_COEFF
+
Chain weight
CHAIN_WT lb/ft
+
+
+
+
+
Wind threshold
WIND_THR kts
+
Tension ratio
TENSION_RATIO
+
Distance threshold
DIST_THR ft
+
Current wind
WIND_NOW kts
+
Wind direction
WIND_DIR°
+
Vessel heading
HEADING°
+
+
+
+
+
+
+"""
+
+
+def render_report(results, host, wind_threshold, tension_ratio):
+ html = HTML_TEMPLATE
+ p = results["params"]
+ s = results["stats"]
+
+ html = html.replace("DATA_JSON", json.dumps(results))
+ html = html.replace("TIMESTAMP", time.strftime("%Y-%m-%d %H:%M:%S"))
+ html = html.replace("HOST", host)
+
+ ref = results["ref"]
+ mk = results["marked"]
+ est = results["estimated"]
+
+ if ref["using_known"]:
+ known_section = (
+ '
Known Anchor vs Service
'
+ '
'
+ f'
Known Anchor
'
+ f'
{ref["lat"]:.6f}, {ref["lon"]:.6f}
'
+ )
+ if mk["lat"] is not None:
+ known_section += (
+ f'
Marked Anchor
'
+ f'
{mk["lat"]:.6f}, {mk["lon"]:.6f}
'
+ f'
Marked Error
'
+ f'
{mk["error_ft"]} ft
'
+ )
+ if est["lat"] is not None:
+ known_section += (
+ f'
Service Estimate
'
+ f'
{est["lat"]:.6f}, {est["lon"]:.6f}
'
+ f'
Estimate Error
'
+ f'
{est["error_ft"]} ft
'
+ )
+ known_section += '
'
+ else:
+ known_section = ''
+
+ html = html.replace("KNOWN_ANCHOR_SECTION", known_section)
+
+ html = html.replace("ANCHOR_SET", "Yes" if p["anchor_set"] else "No")
+ html = html.replace("CHAIN_LEN", str(p["chain_length"]))
+ html = html.replace("CHAIN_BOTTOM", str(p["chain_on_bottom"]))
+ html = html.replace("CAT_DIST", str(p["catenary_distance"]))
+ html = html.replace("EST_DIST", str(p["est_distance"]))
+ html = html.replace("UNCERTAINTY", str(p["uncertainty"]))
+ html = html.replace("DRIFT", str(p["drift"]))
+ html = html.replace("DEPTH", str(p["vessel_depth"]))
+ html = html.replace("FREEBOARD", str(p["freeboard"]))
+ html = html.replace("WINDAGE", str(p["windage_area"] if p.get("windage_area") else "—"))
+ html = html.replace("DRAG_COEFF", str(p.get("drag_coeff", "—")))
+ html = html.replace("CHAIN_WT", str(p.get("chain_weight", "—")))
+
+ wind_class = "val-green" if p["vessel_wind"] >= wind_threshold else "val-red"
+ html = html.replace("WIND_CLASS", wind_class)
+ html = html.replace("WIND_NOW", str(p["vessel_wind"]))
+ html = html.replace("WIND_DIR", str(p["vessel_wind_dir"] if p["vessel_wind_dir"] is not None else "—"))
+ html = html.replace("HEADING", str(p["vessel_heading"] if p["vessel_heading"] is not None else "—"))
+
+ html = html.replace("WIND_THR", str(wind_threshold))
+ html = html.replace("TENSION_RATIO", str(tension_ratio))
+ html = html.replace("DIST_THR", str(p["distance_threshold"]))
+
+ anchor_ref_label = "known anchor position" if ref["using_known"] else "marked anchor"
+ html = html.replace("ANCHOR_REF_LABEL", anchor_ref_label)
+ html = html.replace("ALGO_REF_LABEL", anchor_ref_label)
+
+ algos = results.get("estimators", [])
+ if algos:
+ algo_table = (
+ '
'
+ '
'
+ '
Algorithm
'
+ '
Error (ft)
'
+ )
+ if est["lat"] is not None and est["error_ft"] is not None:
+ algo_table += (
+ f'
Service estimate (current)
'
+ f'
{est["error_ft"]}
'
+ )
+ for a in sorted(algos, key=lambda a: a["error_ft"]):
+ algo_table += (
+ f'
{a["name"]}
'
+ f'
{a["error_ft"]}
'
+ )
+ algo_table += '
'
+ else:
+ algo_table = '
Not enough track points for estimation algorithms.
'
+ html = html.replace("ALGO_TABLE", algo_table)
+
+ html = html.replace("N_TOTAL", str(s["total_points"]))
+ html = html.replace("N_RELIABLE", str(s["reliable"]))
+ html = html.replace("N_MARGINAL", str(s["marginal"]))
+ html = html.replace("N_UNRELIABLE", str(s["unreliable"]))
+ html = html.replace("PCT_REL", str(s["pct_reliable"]))
+
+ return html
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Pull anchor alarm data from Venus MQTT and analyze estimation reliability.",
+ )
+ parser.add_argument("--host", default="cerbo",
+ help="MQTT broker hostname (default: cerbo)")
+ parser.add_argument("--port", type=int, default=1883,
+ help="MQTT broker port (default: 1883)")
+ parser.add_argument("--wind-threshold", type=float, default=7.0,
+ help="Min wind speed (kts) for reliable classification (default: 7)")
+ parser.add_argument("--tension-ratio", type=float, default=0.60,
+ help="Min distance/chain_length ratio for reliable (default: 0.60)")
+ parser.add_argument("--anchor-lat", type=float, default=None,
+ help="Known anchor latitude (overrides service marked position)")
+ parser.add_argument("--anchor-lon", type=float, default=None,
+ help="Known anchor longitude (overrides service marked position)")
+ parser.add_argument("--chain-length", type=float, default=None,
+ help="Override chain length in feet (default: read from service)")
+ parser.add_argument("--timeout", type=float, default=10.0,
+ help="Seconds to wait for MQTT data (default: 10)")
+ parser.add_argument("--output", default=None,
+ help="Output HTML path (default: anchor_debug.html in script dir)")
+ args = parser.parse_args()
+
+ out_path = args.output or os.path.join(os.path.dirname(__file__), "anchor_debug.html")
+
+ collector = AnchorDataCollector(args.host, args.port)
+ values = collector.collect(timeout=args.timeout)
+
+ if not values:
+ sys.exit("No data received from MQTT. Check host and that anchor alarm service is running.")
+
+ if args.chain_length is not None:
+ values["/Anchor/ChainLength"] = args.chain_length
+
+ print("Analyzing ...")
+ results = analyze(values, args.wind_threshold, args.tension_ratio,
+ args.anchor_lat, args.anchor_lon)
+
+ print("Rendering HTML report ...")
+ html = render_report(results, args.host, args.wind_threshold, args.tension_ratio)
+ with open(out_path, "w") as f:
+ f.write(html)
+
+ s = results["stats"]
+ p = results["params"]
+ est = results["estimated"]
+ mk = results["marked"]
+ print(f"\nReport: {out_path}")
+ print(f" Track points: {s['total_points']}")
+ print(f" Reliable: {s['reliable']} ({s['pct_reliable']}%)")
+ print(f" Marginal: {s['marginal']}")
+ print(f" Unreliable: {s['unreliable']}")
+ print(f" Chain length: {p['chain_length']} ft")
+ print(f" Current wind: {p['vessel_wind']} kts")
+ print(f" Threshold: wind >= {args.wind_threshold} kts, dist >= {p['distance_threshold']} ft")
+ if args.anchor_lat is not None:
+ print(f" Known anchor: {args.anchor_lat:.6f}, {args.anchor_lon:.6f}")
+ if est["error_ft"] is not None:
+ print(f" Estimate err: {est['error_ft']} ft from known position")
+ if mk["error_ft"] is not None:
+ print(f" Marked error: {mk['error_ft']} ft from known position")
+
+ algos = results.get("estimators", [])
+ if algos:
+ print("\n Algorithm comparison (sorted by error):")
+ for a in sorted(algos, key=lambda a: a["error_ft"]):
+ print(f" {a['error_ft']:7.1f} ft {a['name']}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/dbus-anchor-alarm/anchor_alarm.py b/dbus-anchor-alarm/anchor_alarm.py
new file mode 100755
index 0000000..69eced3
--- /dev/null
+++ b/dbus-anchor-alarm/anchor_alarm.py
@@ -0,0 +1,752 @@
+#!/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()
diff --git a/dbus-anchor-alarm/anchor_tracker.py b/dbus-anchor-alarm/anchor_tracker.py
new file mode 100644
index 0000000..df8d7b5
--- /dev/null
+++ b/dbus-anchor-alarm/anchor_tracker.py
@@ -0,0 +1,431 @@
+"""
+Anchor position estimation engine.
+
+Combines heading-based projection and arc-based circle fitting to estimate
+where the anchor sits on the seabed, with a 2-sigma uncertainty circle.
+"""
+
+import math
+import time
+import logging
+from collections import deque
+
+from catenary import catenary_distance, wind_force_lbs
+
+logger = logging.getLogger('dbus-anchor-alarm.tracker')
+
+EARTH_RADIUS_FT = 20902231.0
+NM_TO_FT = 6076.12
+
+
+def _haversine_ft(lat1, lon1, lat2, lon2):
+ """Distance between two GPS points in feet."""
+ rlat1, rlat2 = math.radians(lat1), math.radians(lat2)
+ dlat = math.radians(lat2 - lat1)
+ dlon = math.radians(lon2 - lon1)
+ a = (math.sin(dlat / 2) ** 2
+ + math.cos(rlat1) * math.cos(rlat2) * math.sin(dlon / 2) ** 2)
+ return EARTH_RADIUS_FT * 2.0 * math.atan2(math.sqrt(a), math.sqrt(1.0 - a))
+
+
+def _project_position(lat, lon, distance_ft, bearing_deg):
+ """Project a point from (lat, lon) by distance_ft along bearing_deg."""
+ d = distance_ft / EARTH_RADIUS_FT
+ brng = math.radians(bearing_deg)
+ rlat = math.radians(lat)
+ rlon = math.radians(lon)
+
+ new_lat = math.asin(
+ math.sin(rlat) * math.cos(d)
+ + math.cos(rlat) * math.sin(d) * math.cos(brng)
+ )
+ new_lon = rlon + math.atan2(
+ math.sin(brng) * math.sin(d) * math.cos(rlat),
+ math.cos(d) - math.sin(rlat) * math.sin(new_lat),
+ )
+ return math.degrees(new_lat), math.degrees(new_lon)
+
+
+def _to_local_xy(lat, lon, ref_lat, ref_lon):
+ """Convert (lat, lon) to local (x_ft, y_ft) relative to a reference."""
+ lat_rad = math.radians(ref_lat)
+ dx = (lon - ref_lon) * 60.0 * NM_TO_FT * math.cos(lat_rad)
+ dy = (lat - ref_lat) * 60.0 * NM_TO_FT
+ return dx, dy
+
+
+def _from_local_xy(x_ft, y_ft, ref_lat, ref_lon):
+ """Convert local (x_ft, y_ft) back to (lat, lon)."""
+ lat_rad = math.radians(ref_lat)
+ cos_lat = math.cos(lat_rad)
+ if abs(cos_lat) < 1e-12:
+ cos_lat = 1e-12
+ dlon = x_ft / (60.0 * NM_TO_FT * cos_lat)
+ dlat = y_ft / (60.0 * NM_TO_FT)
+ return ref_lat + dlat, ref_lon + dlon
+
+
+def _circle_fit(points_xy):
+ """Algebraic circle fit (Kasa method) for a list of (x, y) points.
+
+ Returns (cx, cy, radius, residual) or None if singular.
+ """
+ n = len(points_xy)
+ if n < 3:
+ return None
+
+ sx = sy = sxx = syy = sxy = 0.0
+ sxxx = syyy = sxyy = syxx = 0.0
+ for x, y in points_xy:
+ x2, y2 = x * x, y * y
+ sx += x
+ sy += y
+ sxx += x2
+ syy += y2
+ sxy += x * y
+ sxxx += x2 * x
+ syyy += y2 * y
+ sxyy += x * y2
+ syxx += y * x2
+
+ a11 = 2.0 * (sx * sx - n * sxx)
+ a12 = 2.0 * (sx * sy - n * sxy)
+ a21 = a12
+ a22 = 2.0 * (sy * sy - n * syy)
+
+ b1 = (sx * sxx - n * sxxx) + (sx * syy - n * sxyy)
+ b2 = (sy * sxx - n * syxx) + (sy * syy - n * syyy)
+
+ det = a11 * a22 - a12 * a21
+ if abs(det) < 1e-12:
+ return None
+
+ cx = (b1 * a22 - b2 * a12) / det
+ cy = (a11 * b2 - a21 * b1) / det
+
+ radius_sq = (sxx + syy - 2.0 * cx * sx - 2.0 * cy * sy) / n + cx * cx + cy * cy
+ if radius_sq < 0:
+ return None
+ radius = math.sqrt(radius_sq)
+
+ residual = 0.0
+ for x, y in points_xy:
+ d = math.sqrt((x - cx) ** 2 + (y - cy) ** 2) - radius
+ residual += d * d
+ residual = math.sqrt(residual / n)
+
+ return cx, cy, radius, residual
+
+
+def _angular_span(headings_deg):
+ """Angular span of a list of headings, handling 0/360 wraparound."""
+ if len(headings_deg) < 2:
+ return 0.0
+
+ sorted_h = sorted(h % 360.0 for h in headings_deg)
+ max_gap = 0.0
+ for i in range(1, len(sorted_h)):
+ gap = sorted_h[i] - sorted_h[i - 1]
+ if gap > max_gap:
+ max_gap = gap
+ wrap_gap = (360.0 - sorted_h[-1]) + sorted_h[0]
+ if wrap_gap > max_gap:
+ max_gap = wrap_gap
+
+ return 360.0 - max_gap
+
+
+class AnchorTracker:
+ """Estimates anchor position from GPS track, heading, and catenary geometry."""
+
+ def __init__(self, bow_offset_ft=0.0):
+ self.anchor_set = False
+ self.marked_lat = None
+ self.marked_lon = None
+ self.estimated_lat = None
+ self.estimated_lon = None
+ self.uncertainty_radius_ft = 0.0
+ self.drift_ft = 0.0
+ self.estimated_distance_ft = 0.0
+ self.bow_offset_ft = bow_offset_ft
+
+ self._heading_estimates = deque(maxlen=300)
+ self._arc_points = deque(maxlen=1800)
+ self._estimated_history = deque(maxlen=100)
+
+ self.last_heading_est_lat = None
+ self.last_heading_est_lon = None
+ self.last_arc_center_lat = None
+ self.last_arc_center_lon = None
+ self.last_arc_radius_ft = None
+ self.last_arc_residual = None
+ self.last_arc_coverage = 0.0
+ self.last_arc_valid = False
+ self.last_weight_arc = 0.0
+ self.last_weight_heading = 0.0
+ self.last_arc_point_count = 0
+
+ # ------------------------------------------------------------------
+ # Public API
+ # ------------------------------------------------------------------
+
+ def drop_anchor(self, lat, lon, alarm_radius_ft=None):
+ """Mark anchor position at the vessel's current GPS location."""
+ self.marked_lat = lat
+ self.marked_lon = lon
+ self.estimated_lat = lat
+ self.estimated_lon = lon
+ self.anchor_set = True
+ self.drift_ft = 0.0
+ self.estimated_distance_ft = 0.0
+
+ default = 100.0
+ if alarm_radius_ft is not None:
+ default = max(alarm_radius_ft / 2.0, 100.0)
+ self.uncertainty_radius_ft = default
+
+ self._heading_estimates.clear()
+ self._arc_points.clear()
+ self._estimated_history.clear()
+ logger.info(
+ 'Anchor dropped at %.6f, %.6f uncertainty=%.0f ft',
+ lat, lon, self.uncertainty_radius_ft,
+ )
+
+ def after_drop(self, vessel_lat, vessel_lon, heading_deg,
+ chain_length_ft, depth_ft, freeboard_ft,
+ wind_speed_kts, windage_area, drag_coeff, chain_weight):
+ """Estimate anchor position when vessel has already backed away."""
+ force = wind_force_lbs(wind_speed_kts, windage_area, drag_coeff)
+ result = catenary_distance(
+ chain_length_ft, depth_ft, freeboard_ft, force, chain_weight,
+ )
+ self.estimated_distance_ft = result.total_distance_ft
+
+ proj_dist = self.estimated_distance_ft + self.bow_offset_ft
+ anchor_lat, anchor_lon = _project_position(
+ vessel_lat, vessel_lon, proj_dist, heading_deg,
+ )
+
+ self.marked_lat = anchor_lat
+ self.marked_lon = anchor_lon
+ self.estimated_lat = anchor_lat
+ self.estimated_lon = anchor_lon
+ self.anchor_set = True
+ self.drift_ft = 0.0
+ self.uncertainty_radius_ft = 0.50 * chain_length_ft
+
+ self._heading_estimates.clear()
+ self._arc_points.clear()
+ self._estimated_history.clear()
+ logger.info(
+ 'Anchor after-drop at %.6f, %.6f dist=%.0f ft uncertainty=%.0f ft',
+ anchor_lat, anchor_lon,
+ self.estimated_distance_ft, self.uncertainty_radius_ft,
+ )
+
+ def update(self, snapshot, chain_length_ft, depth_ft, freeboard_ft,
+ windage_area, drag_coeff, chain_weight):
+ """Main loop tick -- refine anchor position estimate."""
+ if not self.anchor_set:
+ return
+ if snapshot.latitude is None or snapshot.longitude is None:
+ return
+
+ now = snapshot.timestamp or time.time()
+ vlat, vlon = snapshot.latitude, snapshot.longitude
+
+ # Step 1: catenary distance
+ wind = snapshot.wind_speed if snapshot.wind_speed is not None else 0.0
+ force = wind_force_lbs(wind, windage_area, drag_coeff)
+ cat = catenary_distance(
+ chain_length_ft, depth_ft, freeboard_ft, force, chain_weight,
+ )
+ self.estimated_distance_ft = cat.total_distance_ft
+
+ # Step 2: heading-based estimate (project from bow, not GPS)
+ if snapshot.heading is not None:
+ bow_lat, bow_lon = _project_position(
+ vlat, vlon, self.bow_offset_ft, snapshot.heading,
+ )
+ a_lat, a_lon = _project_position(
+ bow_lat, bow_lon, self.estimated_distance_ft, snapshot.heading,
+ )
+ weight = 1.5 if wind > 5.0 else 1.0
+ self._heading_estimates.append((a_lat, a_lon, now, weight))
+
+ # Step 3: arc-based estimation
+ if snapshot.heading is not None:
+ self._arc_points.append((vlat, vlon, snapshot.heading, now))
+
+ cutoff_10min = now - 600.0
+ recent_headings = [
+ h for _, _, h, t in self._arc_points if t >= cutoff_10min
+ ]
+ arc_coverage = (
+ _angular_span(recent_headings) if len(recent_headings) >= 2 else 0.0
+ )
+
+ arc_center_lat = None
+ arc_center_lon = None
+ arc_valid = False
+ arc_radius = None
+ arc_residual = None
+
+ if arc_coverage > 30.0 and len(self._arc_points) >= 5:
+ ref_lat, ref_lon = self._arc_points[0][0], self._arc_points[0][1]
+ pts_xy = [
+ _to_local_xy(
+ *_project_position(la, lo, self.bow_offset_ft, h),
+ ref_lat, ref_lon,
+ )
+ for la, lo, h, _ in self._arc_points
+ ]
+ fit = _circle_fit(pts_xy)
+ if fit is not None:
+ cx, cy, arc_radius, arc_residual = fit
+ if arc_residual < 0.5 * chain_length_ft:
+ arc_center_lat, arc_center_lon = _from_local_xy(
+ cx, cy, ref_lat, ref_lon,
+ )
+ arc_valid = True
+
+ # Step 4: combine estimates
+ heading_lat, heading_lon = self._weighted_heading_average()
+
+ wa, wh = 0.0, 0.0
+
+ if heading_lat is None and not arc_valid:
+ self._store_estimation_state(
+ heading_lat, heading_lon,
+ arc_center_lat, arc_center_lon, arc_radius, arc_residual,
+ arc_coverage, arc_valid, wa, wh,
+ )
+ return
+
+ if arc_valid and heading_lat is not None:
+ if arc_coverage > 180.0:
+ wa, wh = 0.85, 0.15
+ elif arc_coverage > 120.0:
+ wa, wh = 0.65, 0.35
+ elif arc_coverage > 60.0:
+ wa, wh = 0.30, 0.70
+ else:
+ wa, wh = 0.0, 1.0
+
+ if wa > 0:
+ self.estimated_lat = wa * arc_center_lat + wh * heading_lat
+ self.estimated_lon = wa * arc_center_lon + wh * heading_lon
+ else:
+ self.estimated_lat = heading_lat
+ self.estimated_lon = heading_lon
+ elif arc_valid:
+ wa, wh = 1.0, 0.0
+ self.estimated_lat = arc_center_lat
+ self.estimated_lon = arc_center_lon
+ else:
+ wa, wh = 0.0, 1.0
+ self.estimated_lat = heading_lat
+ self.estimated_lon = heading_lon
+
+ self._store_estimation_state(
+ heading_lat, heading_lon,
+ arc_center_lat, arc_center_lon, arc_radius, arc_residual,
+ arc_coverage, arc_valid, wa, wh,
+ )
+
+ # Step 5: uncertainty radius
+ if len(self._estimated_history) >= 10:
+ recent = list(self._estimated_history)[-50:]
+ mean_lat = sum(e[0] for e in recent) / len(recent)
+ mean_lon = sum(e[1] for e in recent) / len(recent)
+ var_ft = 0.0
+ for la, lo, _t, _u in recent:
+ dx, dy = _to_local_xy(la, lo, mean_lat, mean_lon)
+ var_ft += dx * dx + dy * dy
+ std_ft = math.sqrt(var_ft / len(recent))
+ self.uncertainty_radius_ft = max(
+ 10.0, min(2.0 * std_ft, chain_length_ft),
+ )
+
+ # Step 6: drift
+ if self.marked_lat is not None and self.estimated_lat is not None:
+ self.drift_ft = _haversine_ft(
+ self.marked_lat, self.marked_lon,
+ self.estimated_lat, self.estimated_lon,
+ )
+
+ # Step 7: history
+ self._estimated_history.append(
+ (self.estimated_lat, self.estimated_lon, now,
+ self.uncertainty_radius_ft),
+ )
+
+ def weigh_anchor(self):
+ """Reset all state."""
+ self.anchor_set = False
+ self.marked_lat = None
+ self.marked_lon = None
+ self.estimated_lat = None
+ self.estimated_lon = None
+ self.uncertainty_radius_ft = 0.0
+ self.drift_ft = 0.0
+ self.estimated_distance_ft = 0.0
+ self._heading_estimates.clear()
+ self._arc_points.clear()
+ self._estimated_history.clear()
+ self.last_heading_est_lat = None
+ self.last_heading_est_lon = None
+ self.last_arc_center_lat = None
+ self.last_arc_center_lon = None
+ self.last_arc_radius_ft = None
+ self.last_arc_residual = None
+ self.last_arc_coverage = 0.0
+ self.last_arc_valid = False
+ self.last_weight_arc = 0.0
+ self.last_weight_heading = 0.0
+ self.last_arc_point_count = 0
+ logger.info('Anchor weighed -- state reset')
+
+ def get_estimated_history_json(self):
+ """Return JSON-serializable list of recent estimated positions."""
+ return [
+ {'lat': lat, 'lon': lon, 'ts': ts, 'uncertainty_ft': unc}
+ for lat, lon, ts, unc in self._estimated_history
+ ]
+
+ # ------------------------------------------------------------------
+ # Internal
+ # ------------------------------------------------------------------
+
+ def _store_estimation_state(self, hdg_lat, hdg_lon, arc_lat, arc_lon,
+ arc_radius, arc_residual, arc_coverage,
+ arc_valid, wa, wh):
+ self.last_heading_est_lat = hdg_lat
+ self.last_heading_est_lon = hdg_lon
+ self.last_arc_center_lat = arc_lat
+ self.last_arc_center_lon = arc_lon
+ self.last_arc_radius_ft = arc_radius
+ self.last_arc_residual = arc_residual
+ self.last_arc_coverage = arc_coverage
+ self.last_arc_valid = arc_valid
+ self.last_weight_arc = wa
+ self.last_weight_heading = wh
+ self.last_arc_point_count = len(self._arc_points)
+
+ def _weighted_heading_average(self):
+ """Weighted average of heading-based anchor position estimates."""
+ if not self._heading_estimates:
+ return None, None
+
+ total_w = 0.0
+ wlat = 0.0
+ wlon = 0.0
+ for lat, lon, _ts, w in self._heading_estimates:
+ wlat += lat * w
+ wlon += lon * w
+ total_w += w
+
+ if total_w == 0:
+ return None, None
+ return wlat / total_w, wlon / total_w
diff --git a/dbus-anchor-alarm/build-package.sh b/dbus-anchor-alarm/build-package.sh
new file mode 100755
index 0000000..7050280
--- /dev/null
+++ b/dbus-anchor-alarm/build-package.sh
@@ -0,0 +1,80 @@
+#!/bin/bash
+#
+# Build script for dbus-anchor-alarm package
+#
+# Usage:
+# ./build-package.sh
+# ./build-package.sh --version 1.0.0
+#
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+VERSION="2.0.0"
+OUTPUT_DIR="$SCRIPT_DIR"
+PACKAGE_NAME="dbus-anchor-alarm"
+
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ --version|-v) VERSION="$2"; shift 2 ;;
+ --output|-o) OUTPUT_DIR="$2"; shift 2 ;;
+ --help|-h)
+ echo "Usage: $0 [--version VERSION] [--output PATH]"
+ exit 0
+ ;;
+ *) echo "Unknown option: $1"; exit 1 ;;
+ esac
+done
+
+BUILD_DIR=$(mktemp -d)
+PACKAGE_DIR="$BUILD_DIR/$PACKAGE_NAME"
+
+echo "Building $PACKAGE_NAME v$VERSION..."
+
+mkdir -p "$PACKAGE_DIR/service/log"
+
+# Copy application files
+cp "$SCRIPT_DIR/anchor_alarm.py" "$PACKAGE_DIR/"
+cp "$SCRIPT_DIR/config.py" "$PACKAGE_DIR/"
+
+for module in sensor_reader.py catenary.py anchor_tracker.py drag_detector.py track_buffer.py debug_logger.py; do
+ if [ -f "$SCRIPT_DIR/$module" ]; then
+ cp "$SCRIPT_DIR/$module" "$PACKAGE_DIR/"
+ fi
+done
+
+# Copy service and install files
+cp "$SCRIPT_DIR/service/run" "$PACKAGE_DIR/service/"
+cp "$SCRIPT_DIR/service/log/run" "$PACKAGE_DIR/service/log/"
+cp "$SCRIPT_DIR/install.sh" "$PACKAGE_DIR/"
+cp "$SCRIPT_DIR/uninstall.sh" "$PACKAGE_DIR/"
+
+# Set permissions
+chmod +x "$PACKAGE_DIR/anchor_alarm.py"
+chmod +x "$PACKAGE_DIR/install.sh"
+chmod +x "$PACKAGE_DIR/uninstall.sh"
+chmod +x "$PACKAGE_DIR/service/run"
+chmod +x "$PACKAGE_DIR/service/log/run"
+
+# Create archive
+mkdir -p "$OUTPUT_DIR"
+TARBALL="$PACKAGE_NAME-$VERSION.tar.gz"
+OUTPUT_ABS="$(cd "$OUTPUT_DIR" && pwd)"
+cd "$BUILD_DIR"
+tar --format=ustar -czf "$OUTPUT_ABS/$TARBALL" "$PACKAGE_NAME"
+
+# SHA256 checksum
+cd "$OUTPUT_ABS"
+sha256sum "$TARBALL" > "$TARBALL.sha256"
+
+rm -rf "$BUILD_DIR"
+
+echo "Package: $OUTPUT_ABS/$TARBALL"
+echo "Checksum: $OUTPUT_ABS/$TARBALL.sha256"
+echo ""
+echo "Install on Venus OS:"
+echo " scp $OUTPUT_ABS/$TARBALL root@:/data/"
+echo " ssh root@"
+echo " cd /data && tar -xzf $TARBALL"
+echo " bash /data/$PACKAGE_NAME/install.sh"
diff --git a/dbus-anchor-alarm/catenary.py b/dbus-anchor-alarm/catenary.py
new file mode 100644
index 0000000..53426a9
--- /dev/null
+++ b/dbus-anchor-alarm/catenary.py
@@ -0,0 +1,157 @@
+"""
+Catenary math for anchor rode calculations.
+
+All functions are pure — no state, no D-Bus. They accept numeric inputs
+and return plain values or lightweight dataclasses.
+"""
+
+import math
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class CatenaryResult:
+ total_distance_ft: float
+ horizontal_catenary_ft: float
+ chain_on_bottom_ft: float
+ cl_suspended_ft: float
+ all_chain_airborne: bool
+ pull_angle_deg: float
+
+
+@dataclass(frozen=True)
+class DynamicLoadResult:
+ total_force_lbs: float
+ wind_force_lbs: float
+ centripetal_force_lbs: float
+ snatch_factor: float
+ is_snatch: bool
+
+
+# ---------------------------------------------------------------------------
+# Public API
+# ---------------------------------------------------------------------------
+
+def wind_force_lbs(
+ wind_speed_kts: float,
+ windage_area_sqft: float,
+ drag_coefficient: float,
+) -> float:
+ """Steady-state aerodynamic force on the vessel (lbs)."""
+ return 0.0034 * wind_speed_kts ** 2 * windage_area_sqft * drag_coefficient
+
+
+def catenary_distance(
+ chain_length_ft: float,
+ depth_ft: float,
+ freeboard_ft: float,
+ force_lbs: float,
+ chain_weight_lb_per_ft: float,
+) -> CatenaryResult:
+ """Horizontal distance from vessel bow-roller to anchor via catenary geometry."""
+ if chain_length_ft <= 0:
+ return CatenaryResult(0.0, 0.0, 0.0, 0.0, False, 0.0)
+
+ h = depth_ft + freeboard_ft
+ if h <= 0:
+ return CatenaryResult(chain_length_ft, 0.0, chain_length_ft, 0.0, False, 0.0)
+
+ if force_lbs <= 0 or chain_weight_lb_per_ft <= 0:
+ cl_suspended = h
+ chain_on_bottom = max(chain_length_ft - h, 0.0)
+ return CatenaryResult(
+ total_distance_ft=chain_on_bottom,
+ horizontal_catenary_ft=0.0,
+ chain_on_bottom_ft=chain_on_bottom,
+ cl_suspended_ft=min(cl_suspended, chain_length_ft),
+ all_chain_airborne=chain_length_ft <= h,
+ pull_angle_deg=90.0 if chain_length_ft <= h else 0.0,
+ )
+
+ cl_suspended = math.sqrt(
+ 2.0 * h * force_lbs / chain_weight_lb_per_ft + h * h
+ )
+
+ all_chain_airborne = cl_suspended >= chain_length_ft
+ if all_chain_airborne:
+ cl_suspended = chain_length_ft
+
+ chain_on_bottom = max(chain_length_ft - cl_suspended, 0.0)
+ horizontal_catenary = force_lbs / chain_weight_lb_per_ft
+ total_distance = horizontal_catenary + chain_on_bottom
+
+ if all_chain_airborne:
+ pull_angle = math.degrees(math.atan2(h, horizontal_catenary))
+ else:
+ pull_angle = 0.0
+
+ return CatenaryResult(
+ total_distance_ft=total_distance,
+ horizontal_catenary_ft=horizontal_catenary,
+ chain_on_bottom_ft=chain_on_bottom,
+ cl_suspended_ft=cl_suspended,
+ all_chain_airborne=all_chain_airborne,
+ pull_angle_deg=pull_angle,
+ )
+
+
+def scope_ratio(
+ chain_length_ft: float,
+ depth_ft: float,
+ freeboard_ft: float,
+) -> float:
+ """Simple scope ratio: chain length / total height."""
+ h = depth_ft + freeboard_ft
+ if h <= 0:
+ return 0.0
+ return chain_length_ft / h
+
+
+def recommended_chain_length(
+ depth_ft: float,
+ freeboard_ft: float,
+ is_all_chain: bool = True,
+) -> float:
+ """NauticEd recommended rode length (feet).
+
+ All-chain: 50 ft + 2 × depth_ft
+ Chain + rope: 50 ft + 4 × depth_ft
+
+ *depth_ft* is water depth only (freeboard excluded from formula).
+ *freeboard_ft* accepted for API symmetry but unused.
+ """
+ _ = freeboard_ft
+ multiplier = 2.0 if is_all_chain else 4.0
+ return 50.0 + multiplier * depth_ft
+
+
+def dynamic_load(
+ wind_force_lbs: float,
+ vessel_weight_lbs: float,
+ lateral_speed_kts: float,
+ chain_length_ft: float,
+ is_swing_reversal: bool = False,
+) -> DynamicLoadResult:
+ """Total anchor load including swing / snatch dynamics."""
+ lateral_speed_ft_s = lateral_speed_kts * 1.687
+ vessel_mass_slugs = vessel_weight_lbs / 32.174
+
+ if chain_length_ft > 0:
+ f_centripetal = vessel_mass_slugs * lateral_speed_ft_s ** 2 / chain_length_ft
+ else:
+ f_centripetal = 0.0
+
+ f_total = wind_force_lbs + f_centripetal
+ snatch_factor = 1.0
+
+ if is_swing_reversal:
+ snatch_factor = 2.0 + 2.0 * min(lateral_speed_kts / 2.0, 1.0)
+ f_total *= snatch_factor
+
+ return DynamicLoadResult(
+ total_force_lbs=f_total,
+ wind_force_lbs=wind_force_lbs,
+ centripetal_force_lbs=f_centripetal,
+ snatch_factor=snatch_factor,
+ is_snatch=is_swing_reversal,
+ )
diff --git a/dbus-anchor-alarm/config.py b/dbus-anchor-alarm/config.py
new file mode 100644
index 0000000..e8db28b
--- /dev/null
+++ b/dbus-anchor-alarm/config.py
@@ -0,0 +1,42 @@
+"""
+Configuration for dbus-anchor-alarm.
+"""
+
+# Service identity
+SERVICE_NAME = 'com.victronenergy.anchoralarm'
+DEVICE_INSTANCE = 0
+PRODUCT_NAME = 'Anchor Alarm'
+PRODUCT_ID = 0xA170
+FIRMWARE_VERSION = 0
+CONNECTED = 1
+
+# Version
+VERSION = '2.0.0'
+
+# Timing
+MAIN_LOOP_INTERVAL_MS = 1000
+
+# Vessel constants (defaults -- overridden via Settings)
+CHAIN_WEIGHT_LB_PER_FT = 2.25
+ANCHOR_WEIGHT_LB = 120
+VESSEL_WEIGHT_LB = 60000
+FREEBOARD_HEIGHT_FT = 4.0
+WINDAGE_AREA_SQFT = 200.0
+DRAG_COEFFICIENT = 1.1
+
+# Bow offset (GPS antenna to bow, feet)
+GPS_TO_BOW_FT = 55.0
+
+# Alarm defaults
+DEFAULT_ALARM_RADIUS_FT = 200
+DRAG_SPEED_MIN_KTS = 0.1
+DRAG_SPEED_MAX_KTS = 4.0
+DRAG_RADIAL_MIN_KTS = 0.15
+DRAG_SPEED_SUSTAIN_SEC = 60
+DRAG_SWING_SUPPRESS_DEG_SEC = 0.5
+
+# Track / logging
+DATA_DIR = '/data/dbus-anchor-alarm'
+
+# Logging
+LOG_LEVEL = 'INFO'
diff --git a/dbus-anchor-alarm/debug_logger.py b/dbus-anchor-alarm/debug_logger.py
new file mode 100644
index 0000000..380fd89
--- /dev/null
+++ b/dbus-anchor-alarm/debug_logger.py
@@ -0,0 +1,166 @@
+"""
+Optional SQLite debug logger — default OFF.
+
+Controlled at runtime via /Settings/DebugLogging D-Bus path.
+When enabled, logs raw_points and estimation_log at the tracker rate (1/15s)
+with buffered commits (flush every FLUSH_INTERVAL_SEC).
+
+Schema is identical to the original TrackLogger so analysis scripts still work.
+"""
+
+import logging
+import os
+import sqlite3
+import time
+
+from config import DATA_DIR
+
+logger = logging.getLogger('dbus-anchor-alarm.debug_logger')
+
+DB_FILE = 'track.db'
+FLUSH_INTERVAL_SEC = 30.0
+ESTIMATION_LOG_MAX_AGE_SEC = 7 * 86400
+
+_SCHEMA = """
+CREATE TABLE IF NOT EXISTS raw_points (
+ ts REAL PRIMARY KEY,
+ lat REAL,
+ lon REAL,
+ hdg REAL,
+ cog REAL,
+ spd REAL,
+ ws REAL,
+ wd REAL,
+ dist REAL,
+ depth REAL
+);
+
+CREATE TABLE IF NOT EXISTS estimation_log (
+ ts REAL PRIMARY KEY,
+ marked_lat REAL,
+ marked_lon REAL,
+ est_lat REAL,
+ est_lon REAL,
+ uncertainty_ft REAL,
+ drift_ft REAL,
+ hdg_est_lat REAL,
+ hdg_est_lon REAL,
+ arc_center_lat REAL,
+ arc_center_lon REAL,
+ arc_radius_ft REAL,
+ arc_residual REAL,
+ arc_coverage REAL,
+ arc_valid INTEGER,
+ arc_point_count INTEGER,
+ weight_arc REAL,
+ weight_heading REAL,
+ cat_dist_ft REAL,
+ vessel_lat REAL,
+ vessel_lon REAL
+);
+"""
+
+
+class DebugLogger:
+ """SQLite debug logger, only active when explicitly enabled."""
+
+ def __init__(self, data_dir=DATA_DIR):
+ self._data_dir = data_dir
+ self._conn = None
+ self._last_flush = 0.0
+ self._pending = 0
+
+ @property
+ def active(self):
+ return self._conn is not None
+
+ def enable(self):
+ """Open SQLite connection and create tables."""
+ if self._conn is not None:
+ return
+ os.makedirs(self._data_dir, exist_ok=True)
+ db_path = os.path.join(self._data_dir, DB_FILE)
+ self._conn = sqlite3.connect(db_path, check_same_thread=False)
+ self._conn.execute('PRAGMA journal_mode=WAL')
+ self._conn.execute('PRAGMA synchronous=NORMAL')
+ self._conn.executescript(_SCHEMA)
+ self._conn.commit()
+ self._last_flush = time.time()
+ self._pending = 0
+ logger.info('Debug logging enabled -> %s', db_path)
+
+ def disable(self):
+ """Flush and close the SQLite connection."""
+ if self._conn is None:
+ return
+ try:
+ self._conn.commit()
+ self._conn.close()
+ except sqlite3.Error:
+ logger.exception('Error closing debug logger')
+ self._conn = None
+ self._pending = 0
+ logger.info('Debug logging disabled')
+
+ def log(self, snapshot, tracker):
+ """Log both raw point and estimation state in a single call."""
+ if self._conn is None:
+ return
+
+ ts = snapshot.timestamp or time.time()
+
+ try:
+ if snapshot.latitude is not None and snapshot.longitude is not None:
+ self._conn.execute(
+ 'INSERT OR REPLACE INTO raw_points '
+ '(ts, lat, lon, hdg, cog, spd, ws, wd, dist, depth) '
+ 'VALUES (?,?,?,?,?,?,?,?,?,?)',
+ (ts, snapshot.latitude, snapshot.longitude,
+ snapshot.heading, snapshot.course, snapshot.speed,
+ snapshot.wind_speed, snapshot.wind_direction,
+ tracker.estimated_distance_ft, snapshot.depth),
+ )
+ self._pending += 1
+
+ if tracker.anchor_set:
+ self._conn.execute(
+ 'INSERT OR REPLACE INTO estimation_log '
+ '(ts, marked_lat, marked_lon, est_lat, est_lon, '
+ 'uncertainty_ft, drift_ft, hdg_est_lat, hdg_est_lon, '
+ 'arc_center_lat, arc_center_lon, arc_radius_ft, '
+ 'arc_residual, arc_coverage, arc_valid, arc_point_count, '
+ 'weight_arc, weight_heading, cat_dist_ft, '
+ 'vessel_lat, vessel_lon) '
+ 'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
+ (ts,
+ tracker.marked_lat, tracker.marked_lon,
+ tracker.estimated_lat, tracker.estimated_lon,
+ tracker.uncertainty_radius_ft, tracker.drift_ft,
+ tracker.last_heading_est_lat, tracker.last_heading_est_lon,
+ tracker.last_arc_center_lat, tracker.last_arc_center_lon,
+ tracker.last_arc_radius_ft, tracker.last_arc_residual,
+ tracker.last_arc_coverage,
+ int(tracker.last_arc_valid),
+ tracker.last_arc_point_count,
+ tracker.last_weight_arc, tracker.last_weight_heading,
+ tracker.estimated_distance_ft,
+ snapshot.latitude, snapshot.longitude),
+ )
+ self._pending += 1
+ except sqlite3.Error:
+ logger.exception('Debug log write failed')
+
+ now = time.time()
+ if now - self._last_flush >= FLUSH_INTERVAL_SEC and self._pending > 0:
+ self._flush(now)
+
+ def _flush(self, now):
+ try:
+ self._conn.commit()
+ cutoff = now - ESTIMATION_LOG_MAX_AGE_SEC
+ self._conn.execute('DELETE FROM estimation_log WHERE ts < ?', (cutoff,))
+ self._conn.commit()
+ except sqlite3.Error:
+ logger.exception('Debug logger flush failed')
+ self._pending = 0
+ self._last_flush = now
diff --git a/dbus-anchor-alarm/drag_detector.py b/dbus-anchor-alarm/drag_detector.py
new file mode 100644
index 0000000..2f54d38
--- /dev/null
+++ b/dbus-anchor-alarm/drag_detector.py
@@ -0,0 +1,269 @@
+"""
+Drag detection and alarm logic for anchor alarm.
+
+Pure math -- no D-Bus. Takes sensor data and tracker state as parameters.
+"""
+
+import math
+import time
+import logging
+from collections import deque
+
+from config import (
+ DRAG_SPEED_MIN_KTS,
+ DRAG_SPEED_MAX_KTS,
+ DRAG_RADIAL_MIN_KTS,
+ DRAG_SPEED_SUSTAIN_SEC,
+ DRAG_SWING_SUPPRESS_DEG_SEC,
+)
+
+logger = logging.getLogger('dbus-anchor-alarm.drag')
+
+EARTH_RADIUS_FT = 20902231.0
+
+ALARM_NONE = "none"
+ALARM_RADIUS = "radius"
+ALARM_DRAG = "drag"
+ALARM_SHIFT = "shift"
+
+_DEPTH_HISTORY_MAX = 600
+_HEADING_HISTORY_MAX = 30
+_DEPTH_RATE_WINDOW_SEC = 300.0
+_SWING_RATE_WINDOW_SEC = 10.0
+
+
+def _haversine_ft(lat1, lon1, lat2, lon2):
+ """Distance between two GPS points in feet."""
+ rlat1, rlat2 = math.radians(lat1), math.radians(lat2)
+ dlat = math.radians(lat2 - lat1)
+ dlon = math.radians(lon2 - lon1)
+ a = (math.sin(dlat / 2) ** 2
+ + math.cos(rlat1) * math.cos(rlat2) * math.sin(dlon / 2) ** 2)
+ return EARTH_RADIUS_FT * 2.0 * math.atan2(math.sqrt(a), math.sqrt(1.0 - a))
+
+
+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 _angle_diff(a, b):
+ """Smallest angle between two bearings (0-180), handling wraparound."""
+ d = abs((a % 360.0) - (b % 360.0))
+ return d if d <= 180.0 else 360.0 - d
+
+
+def _depth_rate_ft_per_min(depth_history):
+ """Rate of depth change in ft/min via simple linear regression.
+
+ depth_history is a list of (depth_ft, timestamp).
+ Returns 0.0 if insufficient data.
+ """
+ if len(depth_history) < 2:
+ return 0.0
+
+ t0 = depth_history[0][1]
+ n = len(depth_history)
+ sum_t = sum_d = sum_tt = sum_td = 0.0
+ for depth, ts in depth_history:
+ t = (ts - t0) / 60.0 # minutes
+ sum_t += t
+ sum_d += depth
+ sum_tt += t * t
+ sum_td += t * depth
+
+ denom = n * sum_tt - sum_t * sum_t
+ if abs(denom) < 1e-12:
+ return 0.0
+ return (n * sum_td - sum_t * sum_d) / denom
+
+
+class DragDetector:
+ """Detects anchor drag and manages alarm state."""
+
+ def __init__(self):
+ self.alarm_active = False
+ self.alarm_type = ALARM_NONE
+ self.alarm_message = ""
+ self.tide_influence_active = False
+ self.swing_rate_deg_per_sec = 0.0
+
+ self._drag_speed_start_time = None
+ self._last_estimated_anchor = None # (lat, lon, timestamp)
+ self._depth_history = deque(maxlen=_DEPTH_HISTORY_MAX)
+ self._heading_history = deque(maxlen=_HEADING_HISTORY_MAX)
+
+ def update(self, snapshot, tracker, alarm_radius_ft, chain_length_ft):
+ """Run drag / alarm checks for a single tick."""
+ now = snapshot.timestamp or time.time()
+
+ self._update_swing_rate(snapshot, now)
+ self._update_depth_history(snapshot, now)
+ self._update_tide_influence(snapshot)
+
+ alarm_set = False
+
+ # 1. Radius check (highest priority)
+ if (not alarm_set
+ and tracker.marked_lat is not None
+ and snapshot.latitude is not None
+ and snapshot.longitude is not None):
+ dist = _haversine_ft(
+ snapshot.latitude, snapshot.longitude,
+ tracker.marked_lat, tracker.marked_lon,
+ )
+ if dist > alarm_radius_ft:
+ self._set_alarm(
+ ALARM_RADIUS,
+ "Vessel is {:.0f}ft from anchor, exceeds {:.0f}ft alarm radius"
+ .format(dist, alarm_radius_ft),
+ )
+ alarm_set = True
+
+ # 2. Radial-speed drag check
+ # Decompose vessel SOG into radial component (away from anchor).
+ # Swinging on the rode produces tangential motion with near-zero
+ # radial speed; actual drag produces sustained outward radial speed.
+ if (not alarm_set
+ and tracker.marked_lat is not None
+ and snapshot.latitude is not None
+ and snapshot.longitude is not None
+ and snapshot.speed is not None
+ and snapshot.course is not None):
+ if DRAG_SPEED_MIN_KTS <= snapshot.speed <= DRAG_SPEED_MAX_KTS:
+ bearing_to_anchor = _bearing_between(
+ snapshot.latitude, snapshot.longitude,
+ tracker.marked_lat, tracker.marked_lon,
+ )
+ bearing_away = (bearing_to_anchor + 180.0) % 360.0
+ angle_from_away = _angle_diff(snapshot.course, bearing_away)
+ radial_kts = snapshot.speed * math.cos(
+ math.radians(angle_from_away))
+
+ swinging = (self.swing_rate_deg_per_sec
+ >= DRAG_SWING_SUPPRESS_DEG_SEC)
+
+ if radial_kts >= DRAG_RADIAL_MIN_KTS and not swinging:
+ if self._drag_speed_start_time is None:
+ self._drag_speed_start_time = now
+ elapsed = now - self._drag_speed_start_time
+ if elapsed >= DRAG_SPEED_SUSTAIN_SEC:
+ self._set_alarm(
+ ALARM_DRAG,
+ "Vessel dragging at {:.2f} kts radial for {:.0f}s"
+ .format(radial_kts, elapsed),
+ )
+ alarm_set = True
+ else:
+ self._drag_speed_start_time = None
+ else:
+ self._drag_speed_start_time = None
+
+ # 3. Anchor shift check
+ if (not alarm_set
+ and tracker.estimated_lat is not None
+ and tracker.estimated_lon is not None
+ and tracker.drift_ft is not None
+ and tracker.marked_lat is not None):
+ threshold = 0.25 * alarm_radius_ft
+ if tracker.drift_ft > threshold:
+ wind = snapshot.wind_speed
+ if wind is not None and wind > 10.0:
+ self._set_alarm(
+ ALARM_SHIFT,
+ "Estimated anchor position shifted {:.0f}ft from marked position"
+ .format(tracker.drift_ft),
+ )
+ alarm_set = True
+
+ # 4. Tide-based drag suppression
+ if self.tide_influence_active and self.alarm_type == ALARM_DRAG:
+ self.alarm_active = False
+ self.alarm_type = ALARM_NONE
+ self.alarm_message = ""
+ alarm_set = False
+
+ # 5. Clear alarm if nothing triggered
+ if not alarm_set:
+ self.alarm_active = False
+ self.alarm_type = ALARM_NONE
+ self.alarm_message = ""
+
+ def reset(self):
+ """Clear all state. Called when anchor is weighed."""
+ self.alarm_active = False
+ self.alarm_type = ALARM_NONE
+ self.alarm_message = ""
+ self.tide_influence_active = False
+ self.swing_rate_deg_per_sec = 0.0
+ self._drag_speed_start_time = None
+ self._last_estimated_anchor = None
+ self._depth_history.clear()
+ self._heading_history.clear()
+
+ # ------------------------------------------------------------------
+ # Internal helpers
+ # ------------------------------------------------------------------
+
+ def _set_alarm(self, alarm_type, message):
+ self.alarm_active = True
+ self.alarm_type = alarm_type
+ self.alarm_message = message
+ logger.warning('ALARM %s: %s', alarm_type, message)
+
+ def _update_depth_history(self, snapshot, now):
+ if snapshot.depth is not None:
+ self._depth_history.append((snapshot.depth, now))
+
+ def _update_tide_influence(self, snapshot):
+ """Flag tide influence when depth is changing and wind is calm."""
+ if len(self._depth_history) < 10:
+ self.tide_influence_active = False
+ return
+
+ cutoff = self._depth_history[-1][1] - _DEPTH_RATE_WINDOW_SEC
+ window = [(d, t) for d, t in self._depth_history if t >= cutoff]
+ if len(window) < 2:
+ self.tide_influence_active = False
+ return
+
+ rate = _depth_rate_ft_per_min(window)
+ wind = snapshot.wind_speed if snapshot.wind_speed is not None else 0.0
+ self.tide_influence_active = abs(rate) > 0.1 and wind < 5.0
+
+ def _update_swing_rate(self, snapshot, now):
+ """Track heading rate of change (deg/sec)."""
+ if snapshot.heading is None:
+ return
+
+ self._heading_history.append((snapshot.heading, now))
+
+ if len(self._heading_history) < 2:
+ self.swing_rate_deg_per_sec = 0.0
+ return
+
+ # Use oldest sample within the swing-rate window
+ cutoff = now - _SWING_RATE_WINDOW_SEC
+ oldest = None
+ for h, t in self._heading_history:
+ if t >= cutoff:
+ oldest = (h, t)
+ break
+
+ if oldest is None or oldest[1] >= now - 0.5:
+ self.swing_rate_deg_per_sec = 0.0
+ return
+
+ newest_h, newest_t = self._heading_history[-1]
+ dt = newest_t - oldest[1]
+ if dt < 0.5:
+ return
+
+ dh = _angle_diff(newest_h, oldest[0])
+ self.swing_rate_deg_per_sec = dh / dt
diff --git a/dbus-anchor-alarm/install.sh b/dbus-anchor-alarm/install.sh
new file mode 100755
index 0000000..fa4b79f
--- /dev/null
+++ b/dbus-anchor-alarm/install.sh
@@ -0,0 +1,119 @@
+#!/bin/bash
+#
+# Installation script for dbus-anchor-alarm
+#
+# Usage:
+# chmod +x install.sh
+# ./install.sh
+#
+
+set -e
+
+SERVICE_NAME="dbus-anchor-alarm"
+INSTALL_DIR="/data/$SERVICE_NAME"
+MAIN_SCRIPT="anchor_alarm.py"
+DATA_DIR="/data/dbus-anchor-alarm"
+
+# Find velib_python
+VELIB_DIR=""
+if [ -d "/opt/victronenergy/velib_python" ]; then
+ VELIB_DIR="/opt/victronenergy/velib_python"
+else
+ for candidate in \
+ "/opt/victronenergy/dbus-systemcalc-py/ext/velib_python" \
+ "/opt/victronenergy/dbus-generator/ext/velib_python" \
+ "/opt/victronenergy/dbus-mqtt/ext/velib_python" \
+ "/opt/victronenergy/dbus-digitalinputs/ext/velib_python" \
+ "/opt/victronenergy/vrmlogger/ext/velib_python"
+ do
+ if [ -d "$candidate" ] && [ -f "$candidate/vedbus.py" ]; then
+ VELIB_DIR="$candidate"
+ break
+ fi
+ done
+fi
+
+if [ -z "$VELIB_DIR" ]; then
+ VEDBUS_PATH=$(find /opt/victronenergy -name "vedbus.py" -path "*/velib_python/*" 2>/dev/null | head -1)
+ if [ -n "$VEDBUS_PATH" ]; then
+ VELIB_DIR=$(dirname "$VEDBUS_PATH")
+ fi
+fi
+
+# Determine service directory
+if [ -d "/service" ] && [ ! -L "/service" ]; then
+ SERVICE_DIR="/service"
+elif [ -d "/opt/victronenergy/service" ]; then
+ SERVICE_DIR="/opt/victronenergy/service"
+elif [ -L "/service" ]; then
+ SERVICE_DIR=$(readlink -f /service)
+else
+ SERVICE_DIR="/opt/victronenergy/service"
+fi
+
+echo "=================================================="
+echo "$SERVICE_NAME - Installation"
+echo "=================================================="
+
+if [ ! -d "$SERVICE_DIR" ]; then
+ echo "ERROR: This doesn't appear to be a Venus OS device."
+ exit 1
+fi
+
+if [ ! -f "$INSTALL_DIR/$MAIN_SCRIPT" ]; then
+ echo "ERROR: Installation files not found in $INSTALL_DIR"
+ exit 1
+fi
+
+echo "1. Making scripts executable..."
+chmod +x "$INSTALL_DIR/service/run"
+chmod +x "$INSTALL_DIR/service/log/run"
+chmod +x "$INSTALL_DIR/$MAIN_SCRIPT"
+
+echo "2. Creating velib_python symlink..."
+if [ -z "$VELIB_DIR" ]; then
+ echo "ERROR: Could not find velib_python on this system."
+ exit 1
+fi
+echo " Found velib_python at: $VELIB_DIR"
+mkdir -p "$INSTALL_DIR/ext"
+if [ -L "$INSTALL_DIR/ext/velib_python" ]; then
+ rm "$INSTALL_DIR/ext/velib_python"
+fi
+ln -s "$VELIB_DIR" "$INSTALL_DIR/ext/velib_python"
+
+echo "3. Creating service symlink..."
+if [ -L "$SERVICE_DIR/$SERVICE_NAME" ] || [ -e "$SERVICE_DIR/$SERVICE_NAME" ]; then
+ rm -rf "$SERVICE_DIR/$SERVICE_NAME"
+fi
+ln -s "$INSTALL_DIR/service" "$SERVICE_DIR/$SERVICE_NAME"
+
+echo "4. Creating log directory..."
+mkdir -p "/var/log/$SERVICE_NAME"
+
+echo "5. Creating data directory..."
+mkdir -p "$DATA_DIR"
+
+echo "6. Setting up rc.local for persistence..."
+RC_LOCAL="/data/rc.local"
+if [ ! -f "$RC_LOCAL" ]; then
+ echo "#!/bin/bash" > "$RC_LOCAL"
+ chmod +x "$RC_LOCAL"
+fi
+
+if ! grep -q "$SERVICE_NAME" "$RC_LOCAL"; then
+ echo "" >> "$RC_LOCAL"
+ echo "# $SERVICE_NAME" >> "$RC_LOCAL"
+ echo "if [ ! -L $SERVICE_DIR/$SERVICE_NAME ]; then" >> "$RC_LOCAL"
+ echo " ln -s /data/$SERVICE_NAME/service $SERVICE_DIR/$SERVICE_NAME" >> "$RC_LOCAL"
+ echo "fi" >> "$RC_LOCAL"
+fi
+
+echo ""
+echo "=================================================="
+echo "Installation complete!"
+echo "=================================================="
+echo ""
+echo "To check status: svstat $SERVICE_DIR/$SERVICE_NAME"
+echo "To view logs: tail -F /var/log/$SERVICE_NAME/current | tai64nlocal"
+echo ""
diff --git a/dbus-anchor-alarm/sensor_reader.py b/dbus-anchor-alarm/sensor_reader.py
new file mode 100644
index 0000000..b57c85b
--- /dev/null
+++ b/dbus-anchor-alarm/sensor_reader.py
@@ -0,0 +1,136 @@
+"""
+Read GPS, wind, heading, depth, speed, and course from Venus OS D-Bus services.
+"""
+
+import logging
+import time
+from collections import namedtuple
+
+import dbus
+
+logger = logging.getLogger('dbus-anchor-alarm.sensors')
+
+BUS_ITEM = 'com.victronenergy.BusItem'
+
+GPS_SERVICE = 'com.victronenergy.gps.raymarine_0'
+METEO_SERVICE = 'com.victronenergy.meteo.raymarine_0'
+NAVIGATION_SERVICE = 'com.victronenergy.navigation.raymarine_0'
+
+MS_TO_KNOTS = 1.94384
+METERS_TO_FEET = 3.28084
+
+SensorSnapshot = namedtuple('SensorSnapshot', [
+ 'latitude',
+ 'longitude',
+ 'speed',
+ 'course',
+ 'heading',
+ 'depth',
+ 'wind_speed',
+ 'wind_direction',
+ 'timestamp',
+])
+
+
+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 SensorReader:
+ """Reads navigation sensor data from Venus OS D-Bus services."""
+
+ def __init__(self, bus):
+ self._bus = bus
+ self._gps_available = False
+ self._proxy_cache = {}
+
+ def _get_proxy(self, service_name, path):
+ """Return a cached D-Bus proxy, creating it only once per (service, path)."""
+ key = (service_name, path)
+ proxy = self._proxy_cache.get(key)
+ if proxy is not None:
+ return proxy
+ obj = self._bus.get_object(service_name, path, introspect=False)
+ proxy = dbus.Interface(obj, BUS_ITEM)
+ self._proxy_cache[key] = proxy
+ return proxy
+
+ def _read_dbus_value(self, service_name, path):
+ """Read a single value from D-Bus. Returns None on any failure."""
+ try:
+ proxy = self._get_proxy(service_name, path)
+ return _unwrap(proxy.GetValue())
+ except dbus.exceptions.DBusException as e:
+ self._proxy_cache.pop((service_name, path), None)
+ logger.debug('D-Bus read failed: %s %s -- %s', service_name, path, e)
+ return None
+
+ @property
+ def connected(self):
+ """True if GPS service is reachable and has a fix."""
+ return self._gps_available
+
+ def read(self):
+ """Read all sensors and return a SensorSnapshot.
+
+ Each field is None if the corresponding D-Bus read fails.
+ Speed is converted from m/s to knots; depth from meters to feet.
+ """
+ lat = self._read_dbus_value(GPS_SERVICE, '/Position/Latitude')
+ lon = self._read_dbus_value(GPS_SERVICE, '/Position/Longitude')
+ fix = self._read_dbus_value(GPS_SERVICE, '/Fix')
+
+ self._gps_available = (
+ lat is not None and lon is not None
+ and fix is not None and int(fix) >= 1
+ )
+
+ speed_ms = self._read_dbus_value(GPS_SERVICE, '/Speed')
+ speed = float(speed_ms) * MS_TO_KNOTS if speed_ms is not None else None
+
+ course = self._read_dbus_value(GPS_SERVICE, '/Course')
+ if course is not None:
+ course = float(course)
+
+ heading = self._read_dbus_value(NAVIGATION_SERVICE, '/Heading')
+ if heading is not None:
+ heading = float(heading)
+
+ depth_m = self._read_dbus_value(NAVIGATION_SERVICE, '/Depth')
+ depth = float(depth_m) * METERS_TO_FEET if depth_m is not None else None
+
+ wind_speed = self._read_dbus_value(METEO_SERVICE, '/WindSpeed')
+ if wind_speed is not None:
+ wind_speed = float(wind_speed)
+
+ wind_direction = self._read_dbus_value(METEO_SERVICE, '/WindDirection')
+ if wind_direction is not None:
+ wind_direction = float(wind_direction)
+
+ return SensorSnapshot(
+ latitude=float(lat) if lat is not None else None,
+ longitude=float(lon) if lon is not None else None,
+ speed=speed,
+ course=course,
+ heading=heading,
+ depth=depth,
+ wind_speed=wind_speed,
+ wind_direction=wind_direction,
+ timestamp=time.time(),
+ )
diff --git a/dbus-anchor-alarm/service/log/run b/dbus-anchor-alarm/service/log/run
new file mode 100755
index 0000000..a71f795
--- /dev/null
+++ b/dbus-anchor-alarm/service/log/run
@@ -0,0 +1,2 @@
+#!/bin/sh
+exec multilog t s25000 n4 /var/log/dbus-anchor-alarm
diff --git a/dbus-anchor-alarm/service/run b/dbus-anchor-alarm/service/run
new file mode 100755
index 0000000..6851585
--- /dev/null
+++ b/dbus-anchor-alarm/service/run
@@ -0,0 +1,5 @@
+#!/bin/sh
+exec 2>&1
+cd /data/dbus-anchor-alarm
+export PYTHONPATH="/data/dbus-anchor-alarm/ext/velib_python:$PYTHONPATH"
+exec python3 /data/dbus-anchor-alarm/anchor_alarm.py
diff --git a/dbus-anchor-alarm/track_buffer.py b/dbus-anchor-alarm/track_buffer.py
new file mode 100644
index 0000000..6ef5447
--- /dev/null
+++ b/dbus-anchor-alarm/track_buffer.py
@@ -0,0 +1,66 @@
+"""
+In-memory track buffer with spatial deduplication.
+
+Replaces SQLite-backed TrackLogger for normal operation — zero SD card writes.
+Points are stored as (ts, lat, lon) tuples and only appended when the vessel
+has moved more than DEDUP_THRESHOLD_FT from the last stored point.
+"""
+
+import math
+import logging
+
+logger = logging.getLogger('dbus-anchor-alarm.track_buffer')
+
+EARTH_RADIUS_FT = 20902231.0
+DEDUP_THRESHOLD_FT = 7.0
+MAX_POINTS = 5000
+TRIM_FRACTION = 0.20
+
+
+def _haversine_ft(lat1, lon1, lat2, lon2):
+ rlat1, rlat2 = math.radians(lat1), math.radians(lat2)
+ dlat = math.radians(lat2 - lat1)
+ dlon = math.radians(lon2 - lon1)
+ a = (math.sin(dlat / 2) ** 2
+ + math.cos(rlat1) * math.cos(rlat2) * math.sin(dlon / 2) ** 2)
+ return EARTH_RADIUS_FT * 2.0 * math.atan2(math.sqrt(a), math.sqrt(1.0 - a))
+
+
+class TrackBuffer:
+ """In-memory GPS track with spatial deduplication."""
+
+ def __init__(self):
+ self._points = [] # [(ts, lat, lon)]
+
+ def add_if_moved(self, snapshot):
+ """Append point only if vessel moved > DEDUP_THRESHOLD_FT from last stored point."""
+ if snapshot.latitude is None or snapshot.longitude is None:
+ return
+
+ ts = snapshot.timestamp
+ lat, lon = snapshot.latitude, snapshot.longitude
+
+ if self._points:
+ _, last_lat, last_lon = self._points[-1]
+ if _haversine_ft(last_lat, last_lon, lat, lon) < DEDUP_THRESHOLD_FT:
+ return
+
+ self._points.append((ts, lat, lon))
+
+ if len(self._points) > MAX_POINTS:
+ trim = int(MAX_POINTS * TRIM_FRACTION)
+ self._points = self._points[trim:]
+ logger.info('Track buffer trimmed to %d points', len(self._points))
+
+ def get_display_points_json(self):
+ """Return JSON-serialisable list of all stored points."""
+ return [
+ {'ts': ts, 'lat': lat, 'lon': lon}
+ for ts, lat, lon in self._points
+ ]
+
+ def get_point_count(self):
+ return len(self._points)
+
+ def clear(self):
+ self._points.clear()
diff --git a/dbus-anchor-alarm/track_logger.py b/dbus-anchor-alarm/track_logger.py
new file mode 100644
index 0000000..643cdf4
--- /dev/null
+++ b/dbus-anchor-alarm/track_logger.py
@@ -0,0 +1,397 @@
+"""
+SQLite-backed track logging with automatic compaction.
+
+Replaces the previous JSONL flat-file approach. Two tables:
+ - raw_points : full-resolution, rolling 2-hour window
+ - summary_points : compacted older data (5-min buckets 2-24h, 30-min 24h+)
+
+Hard cap: summary_points older than 7 days are deleted.
+Estimated max DB size ~500 KB at 1 Hz logging.
+"""
+
+import json
+import logging
+import os
+import sqlite3
+import time
+
+from config import DATA_DIR
+
+logger = logging.getLogger(__name__)
+
+DB_FILE = 'track.db'
+JSONL_FILE = 'track.jsonl'
+
+RAW_WINDOW_SEC = 2 * 3600 # keep 2h of full-res data
+COMPACT_INTERVAL_SEC = 10 * 60 # run compaction every 10 min
+SUMMARY_BUCKET_RECENT_MIN = 5 # 2-24h: 5-min buckets
+SUMMARY_BUCKET_OLD_MIN = 30 # 24h+: 30-min buckets
+MAX_SUMMARY_AGE_SEC = 7 * 86400 # drop summaries older than 7 days
+
+DISPLAY_FULL_RES_SEC = 30 * 60
+DISPLAY_1H_BUCKET_MIN = 5
+DISPLAY_6H_BUCKET_MIN = 15
+DISPLAY_OLD_BUCKET_MIN = 30
+
+ESTIMATION_LOG_MAX_AGE_SEC = 7 * 86400
+
+_SCHEMA = """
+CREATE TABLE IF NOT EXISTS raw_points (
+ ts REAL PRIMARY KEY,
+ lat REAL,
+ lon REAL,
+ hdg REAL,
+ cog REAL,
+ spd REAL,
+ ws REAL,
+ wd REAL,
+ dist REAL,
+ depth REAL
+);
+
+CREATE TABLE IF NOT EXISTS summary_points (
+ ts REAL PRIMARY KEY,
+ lat REAL,
+ lon REAL,
+ hdg REAL,
+ hdg_min REAL,
+ hdg_max REAL,
+ cog REAL,
+ spd REAL,
+ ws REAL,
+ ws_min REAL,
+ ws_max REAL,
+ wd REAL,
+ dist REAL,
+ depth REAL,
+ count INTEGER
+);
+
+CREATE TABLE IF NOT EXISTS estimation_log (
+ ts REAL PRIMARY KEY,
+ marked_lat REAL,
+ marked_lon REAL,
+ est_lat REAL,
+ est_lon REAL,
+ uncertainty_ft REAL,
+ drift_ft REAL,
+ hdg_est_lat REAL,
+ hdg_est_lon REAL,
+ arc_center_lat REAL,
+ arc_center_lon REAL,
+ arc_radius_ft REAL,
+ arc_residual REAL,
+ arc_coverage REAL,
+ arc_valid INTEGER,
+ arc_point_count INTEGER,
+ weight_arc REAL,
+ weight_heading REAL,
+ cat_dist_ft REAL,
+ vessel_lat REAL,
+ vessel_lon REAL
+);
+"""
+
+
+class TrackLogger:
+
+ def __init__(self, data_dir=DATA_DIR):
+ self._data_dir = data_dir
+ os.makedirs(data_dir, exist_ok=True)
+
+ db_path = os.path.join(data_dir, DB_FILE)
+ self._conn = sqlite3.connect(db_path, check_same_thread=False)
+ self._conn.execute('PRAGMA journal_mode=WAL')
+ self._conn.execute('PRAGMA synchronous=NORMAL')
+ self._conn.executescript(_SCHEMA)
+ self._conn.commit()
+
+ self._last_compact_ts = time.time()
+ self._display_cache = None
+ self._display_dirty = True
+
+ self._maybe_migrate_jsonl()
+
+ # -- public API ----------------------------------------------------------
+
+ def log_point(self, snapshot, estimated_distance_ft):
+ """Append a track point from *snapshot* if GPS is valid."""
+ if snapshot.latitude is None or snapshot.longitude is None:
+ return
+
+ ts = snapshot.timestamp or time.time()
+ try:
+ self._conn.execute(
+ 'INSERT OR REPLACE INTO raw_points '
+ '(ts, lat, lon, hdg, cog, spd, ws, wd, dist, depth) '
+ 'VALUES (?,?,?,?,?,?,?,?,?,?)',
+ (ts, snapshot.latitude, snapshot.longitude,
+ snapshot.heading, snapshot.course, snapshot.speed,
+ snapshot.wind_speed, snapshot.wind_direction,
+ estimated_distance_ft, snapshot.depth),
+ )
+ self._conn.commit()
+ except sqlite3.Error:
+ logger.exception('Failed to insert track point')
+
+ self._display_dirty = True
+
+ now = time.time()
+ if now - self._last_compact_ts >= COMPACT_INTERVAL_SEC:
+ self._compact(now)
+ self._last_compact_ts = now
+
+ def log_estimation(self, tracker, vessel_lat, vessel_lon):
+ """Persist the tracker's current estimation state for offline analysis."""
+ if not tracker.anchor_set:
+ return
+
+ ts = time.time()
+ try:
+ self._conn.execute(
+ 'INSERT OR REPLACE INTO estimation_log '
+ '(ts, marked_lat, marked_lon, est_lat, est_lon, '
+ 'uncertainty_ft, drift_ft, hdg_est_lat, hdg_est_lon, '
+ 'arc_center_lat, arc_center_lon, arc_radius_ft, '
+ 'arc_residual, arc_coverage, arc_valid, arc_point_count, '
+ 'weight_arc, weight_heading, cat_dist_ft, '
+ 'vessel_lat, vessel_lon) '
+ 'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
+ (ts,
+ tracker.marked_lat, tracker.marked_lon,
+ tracker.estimated_lat, tracker.estimated_lon,
+ tracker.uncertainty_radius_ft, tracker.drift_ft,
+ tracker.last_heading_est_lat, tracker.last_heading_est_lon,
+ tracker.last_arc_center_lat, tracker.last_arc_center_lon,
+ tracker.last_arc_radius_ft, tracker.last_arc_residual,
+ tracker.last_arc_coverage,
+ int(tracker.last_arc_valid),
+ tracker.last_arc_point_count,
+ tracker.last_weight_arc, tracker.last_weight_heading,
+ tracker.estimated_distance_ft,
+ vessel_lat, vessel_lon),
+ )
+ self._conn.commit()
+ except sqlite3.Error:
+ logger.exception('Failed to insert estimation log')
+
+ cutoff = ts - ESTIMATION_LOG_MAX_AGE_SEC
+ try:
+ self._conn.execute(
+ 'DELETE FROM estimation_log WHERE ts < ?', (cutoff,))
+ self._conn.commit()
+ except sqlite3.Error:
+ pass
+
+ def get_display_points_json(self):
+ """Return a JSON-serializable list of merged display points."""
+ if self._display_dirty or self._display_cache is None:
+ self._build_display_cache()
+ return self._display_cache
+
+ def get_point_count(self):
+ try:
+ row = self._conn.execute(
+ 'SELECT (SELECT COUNT(*) FROM raw_points) + '
+ '(SELECT COUNT(*) FROM summary_points)',
+ ).fetchone()
+ return row[0] if row else 0
+ except sqlite3.Error:
+ return 0
+
+ def clear(self):
+ """Remove all track data."""
+ try:
+ self._conn.execute('DELETE FROM raw_points')
+ self._conn.execute('DELETE FROM summary_points')
+ self._conn.execute('DELETE FROM estimation_log')
+ self._conn.commit()
+ except sqlite3.Error:
+ logger.exception('Failed to clear track tables')
+ self._display_cache = []
+ self._display_dirty = False
+
+ # -- internals -----------------------------------------------------------
+
+ def _compact(self, now):
+ """Move raw_points older than RAW_WINDOW_SEC into summary_points,
+ and prune summaries older than MAX_SUMMARY_AGE_SEC."""
+ cutoff = now - RAW_WINDOW_SEC
+ try:
+ rows = self._conn.execute(
+ 'SELECT ts, lat, lon, hdg, cog, spd, ws, wd, dist, depth '
+ 'FROM raw_points WHERE ts < ? ORDER BY ts',
+ (cutoff,),
+ ).fetchall()
+
+ if rows:
+ summaries = self._aggregate_rows(rows, now)
+ self._conn.executemany(
+ 'INSERT OR REPLACE INTO summary_points '
+ '(ts, lat, lon, hdg, hdg_min, hdg_max, cog, spd, '
+ 'ws, ws_min, ws_max, wd, dist, depth, count) '
+ 'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
+ summaries,
+ )
+ self._conn.execute('DELETE FROM raw_points WHERE ts < ?', (cutoff,))
+
+ old_cutoff = now - MAX_SUMMARY_AGE_SEC
+ self._conn.execute('DELETE FROM summary_points WHERE ts < ?', (old_cutoff,))
+ self._conn.commit()
+ self._display_dirty = True
+
+ if rows:
+ logger.info(
+ 'Compacted %d raw points into %d summaries',
+ len(rows), len(summaries),
+ )
+ except sqlite3.Error:
+ logger.exception('Compaction failed')
+
+ def _aggregate_rows(self, rows, now):
+ """Group rows into time buckets and return summary tuples."""
+ twenty_four_h_ago = now - 24 * 3600
+
+ groups = {}
+ for row in rows:
+ ts = row[0]
+ if ts >= twenty_four_h_ago:
+ bucket_sec = SUMMARY_BUCKET_RECENT_MIN * 60
+ else:
+ bucket_sec = SUMMARY_BUCKET_OLD_MIN * 60
+ bucket_key = int(ts // bucket_sec) * bucket_sec
+ groups.setdefault(bucket_key, []).append(row)
+
+ summaries = []
+ for _bk, pts in groups.items():
+ n = len(pts)
+ def _avg(idx):
+ vals = [p[idx] for p in pts if p[idx] is not None]
+ return sum(vals) / len(vals) if vals else None
+ def _min_val(idx):
+ vals = [p[idx] for p in pts if p[idx] is not None]
+ return min(vals) if vals else None
+ def _max_val(idx):
+ vals = [p[idx] for p in pts if p[idx] is not None]
+ return max(vals) if vals else None
+
+ timestamps = sorted(p[0] for p in pts)
+ mid_ts = timestamps[len(timestamps) // 2]
+
+ summaries.append((
+ mid_ts,
+ _avg(1), # lat
+ _avg(2), # lon
+ _avg(3), # hdg
+ _min_val(3), # hdg_min
+ _max_val(3), # hdg_max
+ _avg(4), # cog
+ _avg(5), # spd
+ _avg(6), # ws
+ _min_val(6), # ws_min
+ _max_val(6), # ws_max
+ _avg(7), # wd
+ _avg(8), # dist
+ _avg(9), # depth
+ n,
+ ))
+ return summaries
+
+ def _build_display_cache(self):
+ """Query both tables and merge for display."""
+ now = time.time()
+ full_res_cutoff = now - DISPLAY_FULL_RES_SEC
+ one_hour_cutoff = now - 3600
+ six_hour_cutoff = now - 6 * 3600
+
+ try:
+ raw = self._conn.execute(
+ 'SELECT ts, lat, lon FROM raw_points '
+ 'WHERE ts >= ? ORDER BY ts',
+ (full_res_cutoff,),
+ ).fetchall()
+
+ summaries = self._conn.execute(
+ 'SELECT ts, lat, lon FROM summary_points ORDER BY ts',
+ ).fetchall()
+ except sqlite3.Error:
+ logger.exception('Failed to query display points')
+ self._display_cache = []
+ self._display_dirty = False
+ return
+
+ recent_raw = [
+ {'ts': r[0], 'lat': r[1], 'lon': r[2]}
+ for r in raw
+ ]
+
+ bucket_1h = []
+ bucket_6h = []
+ bucket_old = []
+ for s in summaries:
+ ts = s[0]
+ pt = {'ts': ts, 'lat': s[1], 'lon': s[2], 'merged': True}
+ if ts >= one_hour_cutoff:
+ bucket_1h.append(pt)
+ elif ts >= six_hour_cutoff:
+ bucket_6h.append(pt)
+ else:
+ bucket_old.append(pt)
+
+ merged = []
+ merged.extend(_downsample(bucket_old, DISPLAY_OLD_BUCKET_MIN))
+ merged.extend(_downsample(bucket_6h, DISPLAY_6H_BUCKET_MIN))
+ merged.extend(_downsample(bucket_1h, DISPLAY_1H_BUCKET_MIN))
+ merged.extend(recent_raw)
+
+ self._display_cache = merged
+ self._display_dirty = False
+
+ def _maybe_migrate_jsonl(self):
+ """One-time migration from legacy JSONL file."""
+ jsonl_path = os.path.join(self._data_dir, JSONL_FILE)
+ if not os.path.isfile(jsonl_path):
+ return
+
+ count = 0
+ try:
+ with open(jsonl_path, 'r') as f:
+ for line in f:
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ pt = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ self._conn.execute(
+ 'INSERT OR IGNORE INTO raw_points '
+ '(ts, lat, lon, hdg, cog, spd, ws, wd, dist, depth) '
+ 'VALUES (?,?,?,?,?,?,?,?,?,?)',
+ (pt.get('ts'), pt.get('lat'), pt.get('lon'),
+ pt.get('hdg'), pt.get('cog'), pt.get('spd'),
+ pt.get('ws'), pt.get('wd'), pt.get('dist'),
+ pt.get('depth')),
+ )
+ count += 1
+ self._conn.commit()
+ backup = jsonl_path + '.migrated'
+ os.rename(jsonl_path, backup)
+ logger.info('Migrated %d points from JSONL -> SQLite (backup: %s)', count, backup)
+ except (OSError, sqlite3.Error):
+ logger.exception('JSONL migration failed')
+
+
+def _downsample(points, interval_min):
+ """Thin a list of dicts by keeping one per interval_min bucket."""
+ if not points:
+ return []
+ interval_sec = interval_min * 60
+ result = []
+ last_bucket = None
+ for pt in points:
+ bucket = int(pt['ts'] // interval_sec)
+ if bucket != last_bucket:
+ result.append(pt)
+ last_bucket = bucket
+ return result
diff --git a/dbus-anchor-alarm/uninstall.sh b/dbus-anchor-alarm/uninstall.sh
new file mode 100755
index 0000000..806e9d7
--- /dev/null
+++ b/dbus-anchor-alarm/uninstall.sh
@@ -0,0 +1,32 @@
+#!/bin/bash
+#
+# Uninstall script for dbus-anchor-alarm
+#
+
+set -e
+
+SERVICE_NAME="dbus-anchor-alarm"
+
+if [ -d "/service" ] && [ ! -L "/service" ]; then
+ SERVICE_DIR="/service"
+elif [ -d "/opt/victronenergy/service" ]; then
+ SERVICE_DIR="/opt/victronenergy/service"
+elif [ -L "/service" ]; then
+ SERVICE_DIR=$(readlink -f /service)
+else
+ SERVICE_DIR="/opt/victronenergy/service"
+fi
+
+echo "Uninstalling $SERVICE_NAME..."
+
+if [ -L "$SERVICE_DIR/$SERVICE_NAME" ]; then
+ svc -d "$SERVICE_DIR/$SERVICE_NAME" 2>/dev/null || true
+ sleep 2
+ rm "$SERVICE_DIR/$SERVICE_NAME"
+ echo "Service symlink removed"
+fi
+
+echo ""
+echo "Uninstall complete."
+echo "Files remain in /data/$SERVICE_NAME/ -- remove manually if desired."
+echo ""
diff --git a/dbus-generator-ramp/README.md b/dbus-generator-ramp/README.md
index 4b12b2a..d709a03 100644
--- a/dbus-generator-ramp/README.md
+++ b/dbus-generator-ramp/README.md
@@ -327,15 +327,6 @@ This adds **Settings → Generator start/stop → Dynamic current ramp** with:
**Note**: GUI changes are lost on firmware update. Run `./install_gui.sh` again after updating.
-## Web UI (Optional)
-
-A browser-based interface at `http://:8088`:
-
-```bash
-# Install with web UI
-./install.sh --webui
-```
-
## Tuning
### Overload Detection
diff --git a/dbus-generator-ramp/build-package.sh b/dbus-generator-ramp/build-package.sh
index e5cb4e5..4333a15 100755
--- a/dbus-generator-ramp/build-package.sh
+++ b/dbus-generator-ramp/build-package.sh
@@ -20,7 +20,7 @@
# cd /data
# tar -xzf dbus-generator-ramp-*.tar.gz
# cd dbus-generator-ramp
-# ./install.sh [--webui]
+# ./install.sh
#
set -e
@@ -85,7 +85,6 @@ echo ""
echo "1. Creating package structure..."
mkdir -p "$PACKAGE_DIR"
mkdir -p "$PACKAGE_DIR/service/log"
-mkdir -p "$PACKAGE_DIR/service-webui/log"
mkdir -p "$PACKAGE_DIR/qml"
# Copy main Python files
@@ -94,14 +93,11 @@ cp "$SCRIPT_DIR/dbus-generator-ramp.py" "$PACKAGE_DIR/"
cp "$SCRIPT_DIR/config.py" "$PACKAGE_DIR/"
cp "$SCRIPT_DIR/ramp_controller.py" "$PACKAGE_DIR/"
cp "$SCRIPT_DIR/overload_detector.py" "$PACKAGE_DIR/"
-cp "$SCRIPT_DIR/web_ui.py" "$PACKAGE_DIR/"
# Copy service files
echo "3. Copying service files..."
cp "$SCRIPT_DIR/service/run" "$PACKAGE_DIR/service/"
cp "$SCRIPT_DIR/service/log/run" "$PACKAGE_DIR/service/log/"
-cp "$SCRIPT_DIR/service-webui/run" "$PACKAGE_DIR/service-webui/"
-cp "$SCRIPT_DIR/service-webui/log/run" "$PACKAGE_DIR/service-webui/log/"
# Copy QML files
echo "4. Copying GUI files..."
@@ -129,7 +125,7 @@ Installation:
1. Copy this package to CerboGX: scp $PACKAGE_NAME-$VERSION.tar.gz root@:/data/
2. SSH to CerboGX: ssh root@
3. Extract: cd /data && tar -xzf $PACKAGE_NAME-$VERSION.tar.gz
- 4. Install: cd $PACKAGE_NAME && ./install.sh [--webui]
+ 4. Install: cd $PACKAGE_NAME && ./install.sh
For more information, see README.md
EOF
@@ -137,14 +133,11 @@ EOF
# Set executable permissions
echo "8. Setting permissions..."
chmod +x "$PACKAGE_DIR/dbus-generator-ramp.py"
-chmod +x "$PACKAGE_DIR/web_ui.py"
chmod +x "$PACKAGE_DIR/install.sh"
chmod +x "$PACKAGE_DIR/uninstall.sh"
chmod +x "$PACKAGE_DIR/install_gui.sh"
chmod +x "$PACKAGE_DIR/service/run"
chmod +x "$PACKAGE_DIR/service/log/run"
-chmod +x "$PACKAGE_DIR/service-webui/run"
-chmod +x "$PACKAGE_DIR/service-webui/log/run"
# Create output directory if needed
mkdir -p "$OUTPUT_DIR"
@@ -185,6 +178,5 @@ echo " ssh root@"
echo " cd /data"
echo " tar -xzf $TARBALL_NAME"
echo " cd $PACKAGE_NAME"
-echo " ./install.sh # Main service only"
-echo " ./install.sh --webui # With web UI"
+echo " ./install.sh"
echo ""
diff --git a/dbus-generator-ramp/dbus-generator-ramp.py b/dbus-generator-ramp/dbus-generator-ramp.py
index be39a9d..b255e68 100755
--- a/dbus-generator-ramp/dbus-generator-ramp.py
+++ b/dbus-generator-ramp/dbus-generator-ramp.py
@@ -55,7 +55,7 @@ from ramp_controller import RampController
# Version
-VERSION = '1.2.0'
+VERSION = '2.0.0'
# D-Bus service name for our addon
SERVICE_NAME = 'com.victronenergy.generatorramp'
@@ -100,6 +100,9 @@ class GeneratorRampController:
self.state = self.STATE_IDLE
self.state_enter_time = time()
+ # D-Bus proxy cache (avoid per-tick get_object leak)
+ self._proxy_cache = {}
+
# Cached values from D-Bus
self.generator_state = GENERATOR_STATE['STOPPED']
self.ac_connected = False
@@ -530,11 +533,10 @@ class GeneratorRampController:
self.dbus_service['/Settings/Enabled'] = 1 if self.enabled else 0
self.dbus_service['/TargetLimit'] = RAMP_CONFIG['target_current']
- # Return to stable after overload (optional keys for older installs)
RAMP_CONFIG['return_to_stable_after_overload'] = bool(
- self.settings.get('ReturnToStableAfterOverload', 1))
+ self.settings['ReturnToStableAfterOverload'])
RAMP_CONFIG['return_to_stable_min_duration'] = int(
- self.settings.get('ReturnToStableMinMinutes', 30)) * 60
+ self.settings['ReturnToStableMinMinutes']) * 60
self.dbus_service['/Settings/ReturnToStableAfterOverload'] = 1 if RAMP_CONFIG['return_to_stable_after_overload'] else 0
self.dbus_service['/Settings/ReturnToStableMinMinutes'] = RAMP_CONFIG['return_to_stable_min_duration'] // 60
@@ -597,20 +599,30 @@ class GeneratorRampController:
self.logger.error(f"D-Bus initialization failed: {e}")
raise
+ def _get_proxy(self, service, path):
+ """Return a cached D-Bus proxy, creating it only once per (service, path)."""
+ key = (service, path)
+ proxy = self._proxy_cache.get(key)
+ if proxy is not None:
+ return proxy
+ obj = self.bus.get_object(service, path, introspect=False)
+ self._proxy_cache[key] = obj
+ return obj
+
def _get_dbus_value(self, service, path):
"""Get a value from D-Bus service"""
try:
- obj = self.bus.get_object(service, path, introspect=False)
+ obj = self._get_proxy(service, path)
return obj.GetValue(dbus_interface='com.victronenergy.BusItem')
except dbus.exceptions.DBusException as e:
+ self._proxy_cache.pop((service, path), None)
self.logger.debug(f"Failed to get {service}{path}: {e}")
return None
def _set_dbus_value(self, service, path, value):
"""Set a value on D-Bus service"""
try:
- obj = self.bus.get_object(service, path, introspect=False)
- # Wrap value in appropriate D-Bus type (variant)
+ obj = self._get_proxy(service, path)
if isinstance(value, float):
dbus_value = dbus.Double(value, variant_level=1)
elif isinstance(value, int):
@@ -621,6 +633,7 @@ class GeneratorRampController:
self.logger.debug(f"Set {path} = {value}")
return True
except dbus.exceptions.DBusException as e:
+ self._proxy_cache.pop((service, path), None)
self.logger.error(f"Failed to set {path}: {e}")
return False
diff --git a/dbus-generator-ramp/debug_input_tracker.py b/dbus-generator-ramp/debug_input_tracker.py
index 22ea541..13e6405 100755
--- a/dbus-generator-ramp/debug_input_tracker.py
+++ b/dbus-generator-ramp/debug_input_tracker.py
@@ -53,6 +53,8 @@ class InputTracker:
raise RuntimeError("dbus module not available")
self.bus = dbus.SystemBus()
+ self._proxy_cache = {}
+
# Services
self.vebus_service = DBUS_CONFIG['vebus_service']
self.generator_service = DBUS_CONFIG['generator_service']
@@ -79,14 +81,24 @@ class InputTracker:
self.interval_power_sum = 0
self.interval_overload_triggers = 0
+ def _get_proxy(self, service, path):
+ key = (service, path)
+ proxy = self._proxy_cache.get(key)
+ if proxy is not None:
+ return proxy
+ obj = self.bus.get_object(service, path, introspect=False)
+ self._proxy_cache[key] = obj
+ return obj
+
def _get_dbus_value(self, service, path):
"""Get a value from D-Bus service"""
if self.use_mock:
return self._get_mock_value(service, path)
try:
- obj = self.bus.get_object(service, path, introspect=False)
+ obj = self._get_proxy(service, path)
return obj.GetValue(dbus_interface='com.victronenergy.BusItem')
except Exception:
+ self._proxy_cache.pop((service, path), None)
return None
def _get_mock_value(self, service, path):
diff --git a/dbus-generator-ramp/deploy.sh b/dbus-generator-ramp/deploy.sh
index 5fe4b9b..3706500 100755
--- a/dbus-generator-ramp/deploy.sh
+++ b/dbus-generator-ramp/deploy.sh
@@ -8,7 +8,6 @@
# ./deploy.sh [package.tar.gz]
# ./deploy.sh 192.168.1.100 # Uses latest package in current dir
# ./deploy.sh 192.168.1.100 dbus-generator-ramp-1.0.0.tar.gz
-# ./deploy.sh 192.168.1.100 --webui # Also install web UI
#
# Prerequisites:
# - SSH access enabled on CerboGX (Settings > General > SSH)
@@ -29,7 +28,6 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Default values
CERBO_IP=""
PACKAGE=""
-INSTALL_WEBUI=""
SSH_USER="root"
REMOTE_DIR="/data"
@@ -42,13 +40,11 @@ usage() {
echo " package.tar.gz Package file (default: latest in current directory)"
echo ""
echo "Options:"
- echo " --webui Also install the web UI service"
echo " --user USER SSH user (default: root)"
echo " -h, --help Show this help message"
echo ""
echo "Examples:"
echo " $0 192.168.1.100"
- echo " $0 192.168.1.100 --webui"
echo " $0 192.168.1.100 dbus-generator-ramp-1.2.0.tar.gz"
echo ""
echo "Prerequisites:"
@@ -60,10 +56,6 @@ usage() {
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
- --webui)
- INSTALL_WEBUI="--webui"
- shift
- ;;
--user)
SSH_USER="$2"
shift 2
@@ -124,7 +116,6 @@ echo "Deploying to CerboGX"
echo "=================================================="
echo "Target: $SSH_USER@$CERBO_IP"
echo "Package: $PACKAGE_NAME"
-echo "Web UI: ${INSTALL_WEBUI:-no}"
echo ""
# Test SSH connection
@@ -186,7 +177,7 @@ fi
# Run install script
echo " Running install.sh..."
cd $REMOTE_DIR/dbus-generator-ramp
-./install.sh $INSTALL_WEBUI
+./install.sh
# Clean up package file
rm -f $REMOTE_DIR/$PACKAGE_NAME
diff --git a/dbus-generator-ramp/install.sh b/dbus-generator-ramp/install.sh
index c9e5da0..368197a 100755
--- a/dbus-generator-ramp/install.sh
+++ b/dbus-generator-ramp/install.sh
@@ -6,15 +6,12 @@
#
# Usage:
# chmod +x install.sh
-# ./install.sh # Install main service only
-# ./install.sh --webui # Install main service + web UI
+# ./install.sh
#
set -e
INSTALL_DIR="/data/dbus-generator-ramp"
-INSTALL_WEBUI=false
-
# Find velib_python - check standard location first, then search existing services
VELIB_DIR=""
if [ -d "/opt/victronenergy/velib_python" ]; then
@@ -57,11 +54,6 @@ else
SERVICE_DIR="/opt/victronenergy/service"
fi
-# Parse arguments
-if [ "$1" = "--webui" ]; then
- INSTALL_WEBUI=true
-fi
-
echo "=================================================="
echo "Generator Current Ramp Controller - Installation"
echo "=================================================="
@@ -87,11 +79,6 @@ echo "1. Making scripts executable..."
chmod +x "$INSTALL_DIR/service/run"
chmod +x "$INSTALL_DIR/service/log/run"
chmod +x "$INSTALL_DIR/dbus-generator-ramp.py"
-if [ -f "$INSTALL_DIR/service-webui/run" ]; then
- chmod +x "$INSTALL_DIR/service-webui/run"
- chmod +x "$INSTALL_DIR/service-webui/log/run"
- chmod +x "$INSTALL_DIR/web_ui.py"
-fi
echo "2. Creating velib_python symlink..."
if [ -z "$VELIB_DIR" ]; then
@@ -138,18 +125,7 @@ fi
echo "4. Creating log directory..."
mkdir -p /var/log/dbus-generator-ramp
-# Install Web UI if requested
-if [ "$INSTALL_WEBUI" = true ]; then
- echo "5. Installing Web UI service..."
- if [ -L "$SERVICE_DIR/dbus-generator-ramp-webui" ]; then
- rm "$SERVICE_DIR/dbus-generator-ramp-webui"
- fi
- ln -s "$INSTALL_DIR/service-webui" "$SERVICE_DIR/dbus-generator-ramp-webui"
- mkdir -p /var/log/dbus-generator-ramp-webui
- echo " Web UI will be available at http://:8088"
-fi
-
-echo "6. Setting up rc.local for persistence..."
+echo "5. Setting up rc.local for persistence..."
RC_LOCAL="/data/rc.local"
if [ ! -f "$RC_LOCAL" ]; then
echo "#!/bin/bash" > "$RC_LOCAL"
@@ -163,17 +139,12 @@ if ! grep -q "dbus-generator-ramp" "$RC_LOCAL"; then
echo "if [ ! -L $SERVICE_DIR/dbus-generator-ramp ]; then" >> "$RC_LOCAL"
echo " ln -s /data/dbus-generator-ramp/service $SERVICE_DIR/dbus-generator-ramp" >> "$RC_LOCAL"
echo "fi" >> "$RC_LOCAL"
- if [ "$INSTALL_WEBUI" = true ]; then
- echo "if [ ! -L $SERVICE_DIR/dbus-generator-ramp-webui ]; then" >> "$RC_LOCAL"
- echo " ln -s /data/dbus-generator-ramp/service-webui $SERVICE_DIR/dbus-generator-ramp-webui" >> "$RC_LOCAL"
- echo "fi" >> "$RC_LOCAL"
- fi
echo " Added to rc.local for persistence across firmware updates"
else
echo " Already in rc.local"
fi
-echo "7. Activating service..."
+echo "6. Activating service..."
# Give svscan a moment to detect the new service
sleep 2
# Check if service is being supervised
@@ -207,12 +178,6 @@ echo ""
echo "To view logs:"
echo " tail -F /var/log/dbus-generator-ramp/current | tai64nlocal"
echo ""
-if [ "$INSTALL_WEBUI" = true ]; then
- echo "Web UI:"
- echo " http://:8088"
- echo " svstat $SERVICE_DIR/dbus-generator-ramp-webui"
- echo ""
-fi
echo "MQTT Paths:"
echo " N//generatorramp/0/State"
echo " N//generatorramp/0/CurrentLimit"
diff --git a/dbus-generator-ramp/service-webui/._log b/dbus-generator-ramp/service-webui/._log
deleted file mode 100755
index 57fc954..0000000
Binary files a/dbus-generator-ramp/service-webui/._log and /dev/null differ
diff --git a/dbus-generator-ramp/service-webui/._run b/dbus-generator-ramp/service-webui/._run
deleted file mode 100755
index 57fc954..0000000
Binary files a/dbus-generator-ramp/service-webui/._run and /dev/null differ
diff --git a/dbus-generator-ramp/service-webui/log/._run b/dbus-generator-ramp/service-webui/log/._run
deleted file mode 100755
index 57fc954..0000000
Binary files a/dbus-generator-ramp/service-webui/log/._run and /dev/null differ
diff --git a/dbus-generator-ramp/service-webui/log/run b/dbus-generator-ramp/service-webui/log/run
deleted file mode 100755
index d58ac90..0000000
--- a/dbus-generator-ramp/service-webui/log/run
+++ /dev/null
@@ -1,2 +0,0 @@
-#!/bin/sh
-exec multilog t s25000 n4 /var/log/dbus-generator-ramp-webui
diff --git a/dbus-generator-ramp/service-webui/run b/dbus-generator-ramp/service-webui/run
deleted file mode 100755
index b935273..0000000
--- a/dbus-generator-ramp/service-webui/run
+++ /dev/null
@@ -1,9 +0,0 @@
-#!/bin/sh
-#
-# daemontools run script for Generator Ramp Controller Web UI
-#
-
-exec 2>&1
-cd /data/dbus-generator-ramp
-export PYTHONPATH="/opt/victronenergy/velib_python:$PYTHONPATH"
-exec python3 /data/dbus-generator-ramp/web_ui.py
diff --git a/dbus-generator-ramp/uninstall.sh b/dbus-generator-ramp/uninstall.sh
index e0bb1d8..d042add 100755
--- a/dbus-generator-ramp/uninstall.sh
+++ b/dbus-generator-ramp/uninstall.sh
@@ -56,25 +56,10 @@ else
echo " Service link not found"
fi
-echo "3. Stopping and removing web UI service (if installed)..."
-if [ -d "$SERVICE_DIR/dbus-generator-ramp-webui" ] || [ -L "$SERVICE_DIR/dbus-generator-ramp-webui" ]; then
- svc -dx "$SERVICE_DIR/dbus-generator-ramp-webui" 2>/dev/null || true
- sleep 1
- if [ -L "$SERVICE_DIR/dbus-generator-ramp-webui" ]; then
- rm "$SERVICE_DIR/dbus-generator-ramp-webui"
- elif [ -d "$SERVICE_DIR/dbus-generator-ramp-webui" ]; then
- rm -rf "$SERVICE_DIR/dbus-generator-ramp-webui"
- fi
- echo " Web UI service removed"
-fi
-# Kill any remaining webui processes
-pkill -f "python.*web_ui.py" 2>/dev/null || true
-pkill -f "supervise dbus-generator-ramp-webui" 2>/dev/null || true
-
-echo "4. Note: Not removing files from $INSTALL_DIR"
+echo "3. Note: Not removing files from $INSTALL_DIR"
echo " To fully remove, run: rm -rf $INSTALL_DIR"
-echo "5. Note: rc.local entry not removed"
+echo "4. Note: rc.local entry not removed"
echo " Edit /data/rc.local manually if desired"
echo ""
diff --git a/dbus-generator-ramp/web_ui.py b/dbus-generator-ramp/web_ui.py
deleted file mode 100755
index 51b80f7..0000000
--- a/dbus-generator-ramp/web_ui.py
+++ /dev/null
@@ -1,645 +0,0 @@
-#!/usr/bin/env python3
-"""
-Simple Web UI for Generator Ramp Controller
-
-Provides a browser-based interface for:
-- Viewing current status
-- Adjusting settings
-- Monitoring power and ramp progress
-
-Access at: http://:8088
-
-This runs as a separate service alongside the main controller.
-It communicates via D-Bus to read/write settings.
-"""
-
-import sys
-import os
-import json
-import logging
-from http.server import HTTPServer, BaseHTTPRequestHandler
-from urllib.parse import parse_qs, urlparse
-import threading
-
-# Add velib_python to path
-sys.path.insert(1, os.path.join(os.path.dirname(__file__), 'ext', 'velib_python'))
-sys.path.insert(1, '/opt/victronenergy/velib_python')
-
-try:
- import dbus
-except ImportError:
- print("ERROR: dbus-python not available")
- sys.exit(1)
-
-# Configuration
-WEB_PORT = 8088
-SERVICE_NAME = 'com.victronenergy.generatorramp'
-
-logger = logging.getLogger('WebUI')
-
-
-class DBusClient:
- """Client to read/write from our D-Bus service"""
-
- def __init__(self):
- self.bus = dbus.SystemBus()
-
- def get_value(self, path):
- """Get a value from our service"""
- try:
- obj = self.bus.get_object(SERVICE_NAME, path, introspect=False)
- return obj.GetValue(dbus_interface='com.victronenergy.BusItem')
- except Exception as e:
- logger.warning(f"Failed to get {path}: {e}")
- return None
-
- def set_value(self, path, value):
- """Set a value on our service"""
- try:
- obj = self.bus.get_object(SERVICE_NAME, path, introspect=False)
- obj.SetValue(value, dbus_interface='com.victronenergy.BusItem')
- return True
- except Exception as e:
- logger.error(f"Failed to set {path}: {e}")
- return False
-
- def get_all_status(self):
- """Get all status values"""
- paths = [
- '/State',
- '/CurrentLimit',
- '/TargetLimit',
- '/RecoveryTarget',
- '/OverloadCount',
- '/LastStableCurrent',
- '/Power/L1',
- '/Power/L2',
- '/Power/Total',
- '/Ramp/Progress',
- '/Ramp/TimeRemaining',
- '/Detection/Reversals',
- '/Detection/StdDev',
- '/Detection/IsOverload',
- '/Generator/State',
- '/AcInput/Connected',
- '/Settings/InitialCurrent',
- '/Settings/TargetCurrent',
- '/Settings/RampDuration',
- '/Settings/CooldownDuration',
- '/Settings/Enabled',
- ]
-
- result = {}
- for path in paths:
- value = self.get_value(path)
- # Convert path to key (remove leading /)
- key = path[1:].replace('/', '_')
- result[key] = value
-
- return result
-
-
-# HTML Template
-HTML_TEMPLATE = '''
-
-
-
-
- Generator Ramp Controller
-
-
-
-
-
Generator Ramp Controller
-
-
-
-
Current State
-
- Idle
-
- Controller:
-
- Enabled
-
-
-
-
-
-
Current Limit
-
--
-
-
-
Target
-
--
-
-
-
Overload Count
-
0
-
-
-
Generator
-
--
-
-
-
-
-
-
- Ramp Progress
- 0%
-
-
-
-
-
- Time remaining: --
-
-
-
-
-
-
-
Power Monitoring
-
-
-
0
-
L1 (W)
-
-
-
0
-
L2 (W)
-
-
-
0
-
Total (W)
-
-
-
-
-
-
-
Overload Detection: Normal
-
- Reversals: 0 |
- Std Dev: 0W
-
-
-
-
-
-
-
-
Settings
-
-
-
-
- Auto-refreshes every 2 seconds | Last update: --
-