diff --git a/dbus-anchor-alarm/.gitignore b/dbus-anchor-alarm/.gitignore new file mode 100644 index 0000000..5923ff5 --- /dev/null +++ b/dbus-anchor-alarm/.gitignore @@ -0,0 +1,24 @@ +# Build artifacts +*.tar.gz +*.sha256 + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg +*.egg-info/ +dist/ +build/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Venus OS runtime (created during installation) +ext/ diff --git a/dbus-anchor-alarm/analysis/analyze_anchor.py b/dbus-anchor-alarm/analysis/analyze_anchor.py new file mode 100644 index 0000000..15239c4 --- /dev/null +++ b/dbus-anchor-alarm/analysis/analyze_anchor.py @@ -0,0 +1,671 @@ +#!/usr/bin/env python3 +""" +Anchor data analysis — generates an interactive HTML report from track.db. + +Reads estimation_log, summary_points, and raw_points tables, merges them +into a unified time series, and produces 7 analyses with Chart.js charts. + +Usage: python3 analyze_anchor.py +Output: anchor_report.html (same directory) +""" + +import json +import math +import os +import sqlite3 +import sys + +import numpy as np +import pandas as pd + +# Allow importing catenary from parent directory +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from catenary import catenary_distance, wind_force_lbs + +DB_PATH = os.path.join(os.path.dirname(__file__), "..", "track.db") +OUT_PATH = os.path.join(os.path.dirname(__file__), "anchor_report.html") + +CHAIN_LENGTH_FT = 150.0 +FREEBOARD_FT = 4.0 +WINDAGE_AREA = 120.0 +DRAG_COEFF = 1.0 +CHAIN_WEIGHT = 2.0 # lb/ft for 5/16" G4 +MS_TO_KTS = 1.94384 # wind speed stored in m/s, catenary math expects knots + +EARTH_RADIUS_FT = 20902231.0 +NM_TO_FT = 6076.12 + + +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 load_data(): + con = sqlite3.connect(DB_PATH) + + est = pd.read_sql("SELECT * FROM estimation_log ORDER BY ts", con) + summary = pd.read_sql("SELECT * FROM summary_points ORDER BY ts", con) + raw = pd.read_sql("SELECT * FROM raw_points ORDER BY ts", con) + + con.close() + return est, summary, raw + + +def build_unified(est, summary, raw): + """Merge sensor data (summary + raw) with estimation_log on nearest timestamp.""" + sensor = pd.concat([ + summary[["ts", "lat", "lon", "hdg", "cog", "spd", "ws", "wd", "dist", "depth"]], + raw[["ts", "lat", "lon", "hdg", "cog", "spd", "ws", "wd", "dist", "depth"]], + ], ignore_index=True).sort_values("ts").drop_duplicates("ts").reset_index(drop=True) + + sensor["ws"] = sensor["ws"] * MS_TO_KTS + + merged = pd.merge_asof( + est.sort_values("ts"), + sensor[["ts", "hdg", "cog", "spd", "ws", "wd", "dist", "depth"]], + on="ts", + direction="nearest", + tolerance=120.0, + ) + return merged, sensor + + +def ts_to_hours(ts_series, t0): + return (ts_series - t0) / 3600.0 + + +def compute_analyses(merged, sensor): + t0 = sensor["ts"].min() + anchor_lat = merged["est_lat"].dropna().iloc[-1] + anchor_lon = merged["est_lon"].dropna().iloc[-1] + + results = {} + + # --- 1. Vessel Track / Swing Circle --- + track = merged.dropna(subset=["vessel_lat", "vessel_lon"]).copy() + xy = track.apply( + lambda r: to_local_xy(r["vessel_lat"], r["vessel_lon"], anchor_lat, anchor_lon), + axis=1, result_type="expand", + ) + track = track.assign(x_ft=xy[0], y_ft=xy[1]) + arc_rows = track[track["arc_valid"] == 1] + arc_radius = arc_rows["arc_radius_ft"].median() if len(arc_rows) else None + arc_center_x, arc_center_y = 0.0, 0.0 + if len(arc_rows): + cxy = arc_rows.apply( + lambda r: to_local_xy(r["arc_center_lat"], r["arc_center_lon"], anchor_lat, anchor_lon), + axis=1, result_type="expand", + ) + arc_center_x = cxy[0].median() + arc_center_y = cxy[1].median() + + n_track = min(len(track), 4000) + step = max(1, len(track) // n_track) + t_sub = track.iloc[::step] + hours_track = ts_to_hours(t_sub["ts"], t0).tolist() + + results["track"] = { + "x": t_sub["x_ft"].round(1).tolist(), + "y": t_sub["y_ft"].round(1).tolist(), + "hours": [round(h, 2) for h in hours_track], + "arc_radius": round(arc_radius, 1) if arc_radius else None, + "arc_cx": round(arc_center_x, 1), + "arc_cy": round(arc_center_y, 1), + } + + # --- 2. Weathervaning: Heading vs Wind Direction --- + wv = merged.dropna(subset=["hdg", "wd"]).copy() + wv["offset"] = (wv["hdg"] - wv["wd"]) % 360 + wv.loc[wv["offset"] > 180, "offset"] = wv["offset"] - 360 + n_wv = min(len(wv), 3000) + step_wv = max(1, len(wv) // n_wv) + wv_sub = wv.iloc[::step_wv] + + offset_vals = wv["offset"].values + mean_offset = float(np.mean(offset_vals)) + std_offset = float(np.std(offset_vals)) + + hist_counts, hist_edges = np.histogram(offset_vals, bins=36, range=(-180, 180)) + hist_labels = [f"{int(hist_edges[i])}°" for i in range(len(hist_counts))] + + results["weathervane"] = { + "hours": [round(h, 2) for h in ts_to_hours(wv_sub["ts"], t0)], + "hdg": wv_sub["hdg"].round(1).tolist(), + "wd": wv_sub["wd"].round(1).tolist(), + "offset_hours": [round(h, 2) for h in ts_to_hours(wv_sub["ts"], t0)], + "offset": wv_sub["offset"].round(1).tolist(), + "hist_labels": hist_labels, + "hist_counts": hist_counts.tolist(), + "mean_offset": round(mean_offset, 1), + "std_offset": round(std_offset, 1), + } + + # --- 3. Catenary Model Validation --- + cat_data = merged.dropna(subset=["depth", "cat_dist_ft", "ws"]).copy() + n_cat = min(len(cat_data), 2000) + step_cat = max(1, len(cat_data) // n_cat) + cat_sub = cat_data.iloc[::step_cat] + + depth_range = np.linspace(cat_data["depth"].min(), cat_data["depth"].max(), 50) + ws_median = float(cat_data["ws"].median()) + force = wind_force_lbs(ws_median, WINDAGE_AREA, DRAG_COEFF) + theory_dist = [] + for d in depth_range: + r = catenary_distance(CHAIN_LENGTH_FT, d, FREEBOARD_FT, force, CHAIN_WEIGHT) + theory_dist.append(round(r.total_distance_ft, 1)) + + results["catenary"] = { + "depth": cat_sub["depth"].round(2).tolist(), + "measured_dist": cat_sub["cat_dist_ft"].round(1).tolist(), + "theory_depth": [round(float(d), 2) for d in depth_range], + "theory_dist": theory_dist, + "ws_median": round(ws_median, 1), + } + + # --- 4. Depth (Tide) Time Series --- + tide = sensor.dropna(subset=["depth"]).copy() + n_tide = min(len(tide), 3000) + step_tide = max(1, len(tide) // n_tide) + tide_sub = tide.iloc[::step_tide] + + dist_ts = merged.dropna(subset=["cat_dist_ft"]).copy() + n_dist = min(len(dist_ts), 3000) + step_dist = max(1, len(dist_ts) // n_dist) + dist_sub = dist_ts.iloc[::step_dist] + + results["tide"] = { + "depth_hours": [round(h, 2) for h in ts_to_hours(tide_sub["ts"], t0)], + "depth": tide_sub["depth"].round(2).tolist(), + "dist_hours": [round(h, 2) for h in ts_to_hours(dist_sub["ts"], t0)], + "dist": dist_sub["cat_dist_ft"].round(1).tolist(), + } + + # --- 5. Wind Effects on Swing Radius --- + wind_fx = merged.dropna(subset=["ws", "cat_dist_ft", "depth"]).copy() + n_wfx = min(len(wind_fx), 2000) + step_wfx = max(1, len(wind_fx) // n_wfx) + wfx_sub = wind_fx.iloc[::step_wfx] + + corr_ws_dist = float(wind_fx[["ws", "cat_dist_ft"]].corr().iloc[0, 1]) + + results["wind_fx"] = { + "ws": wfx_sub["ws"].round(1).tolist(), + "dist": wfx_sub["cat_dist_ft"].round(1).tolist(), + "depth": wfx_sub["depth"].round(1).tolist(), + "corr": round(corr_ws_dist, 3), + } + + # --- 6. Swing Dynamics / Oscillation --- + swing = merged.dropna(subset=["hdg"]).copy() + swing = swing.sort_values("ts") + dt = swing["ts"].diff() + dh = swing["hdg"].diff() + dh = dh.where(dh.abs() < 180, dh - 360 * dh.abs() / dh) + swing = swing.assign(hdg_rate=dh / dt) + swing = swing.dropna(subset=["hdg_rate"]) + + reversals = swing["hdg_rate"].values + sign_changes = np.where(np.diff(np.sign(reversals)))[0] + if len(sign_changes) > 1: + reversal_ts = swing["ts"].iloc[sign_changes].values + periods = np.diff(reversal_ts) + mean_half_period = float(np.median(periods)) + swing_period_min = round(2 * mean_half_period / 60, 1) + else: + swing_period_min = None + + n_sw = min(len(swing), 3000) + step_sw = max(1, len(swing) // n_sw) + sw_sub = swing.iloc[::step_sw] + + results["swing"] = { + "hours": [round(h, 2) for h in ts_to_hours(sw_sub["ts"], t0)], + "hdg": sw_sub["hdg"].round(1).tolist(), + "hdg_rate": sw_sub["hdg_rate"].clip(-5, 5).round(2).tolist(), + "swing_period_min": swing_period_min, + } + + # --- 7. Polar Position Plot --- + polar = merged.dropna(subset=["vessel_lat", "vessel_lon"]).copy() + dx_arr = polar.apply( + lambda r: to_local_xy(r["vessel_lat"], r["vessel_lon"], anchor_lat, anchor_lon), + axis=1, result_type="expand", + ) + polar = polar.assign(x_ft=dx_arr[0], y_ft=dx_arr[1]) + polar["bearing"] = np.degrees(np.arctan2(polar["x_ft"], polar["y_ft"])) % 360 + polar["radius"] = np.sqrt(polar["x_ft"] ** 2 + polar["y_ft"] ** 2) + + n_pol = min(len(polar), 2000) + step_pol = max(1, len(polar) // n_pol) + pol_sub = polar.iloc[::step_pol] + + wd_polar = merged.dropna(subset=["wd"]).copy() + n_wd = min(len(wd_polar), 500) + step_wd = max(1, len(wd_polar) // n_wd) + wd_sub = wd_polar.iloc[::step_wd] + + results["polar"] = { + "bearing": pol_sub["bearing"].round(1).tolist(), + "radius": pol_sub["radius"].round(1).tolist(), + "wd": wd_sub["wd"].round(1).tolist(), + "wd_hours": [round(h, 2) for h in ts_to_hours(wd_sub["ts"], t0)], + } + + # --- Summary statistics --- + results["stats"] = { + "total_hours": round((sensor["ts"].max() - sensor["ts"].min()) / 3600, 1), + "est_rows": len(merged), + "sensor_rows": len(sensor), + "depth_min": round(float(sensor["depth"].min()), 1), + "depth_max": round(float(sensor["depth"].max()), 1), + "ws_min": round(float(sensor["ws"].min()), 1), + "ws_max": round(float(sensor["ws"].max()), 1), + "mean_offset": results["weathervane"]["mean_offset"], + "std_offset": results["weathervane"]["std_offset"], + "corr_ws_dist": results["wind_fx"]["corr"], + "swing_period_min": results["swing"]["swing_period_min"], + "anchor_lat": round(anchor_lat, 7), + "anchor_lon": round(anchor_lon, 7), + } + + return results + + +HTML_TEMPLATE = r""" + + + + +Anchor Data Analysis Report + + + + +

Anchor Analysis Report

+

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 + + + + +

Anchor Estimation Debug Report

+

Generated 2026-03-23 16:16:04 • Host: cerbo • Wind threshold: 7.0 kts • Tension ratio: 0.6

+ +

Current State

+
+
Anchor Set
Yes
+
Chain Length
150.0 ft
+
Current Wind
2.0 kts
+
Est. Distance
134.2 ft
+
Catenary Distance
134.2 ft
+
Chain on Bottom
132.8 ft
+
Uncertainty
10.0 ft
+
Drift from Marked
66.5 ft
+
+

Known Anchor vs Service

Known Anchor
25.486030, -76.637120
Marked Anchor
25.486088, -76.636926
Marked Error
67.2 ft
Service Estimate
25.485908, -76.636957
Estimate Error
69.6 ft
+ +

Reliability Analysis

+
+
Track Points
1069
+
Reliable
0 (0.0%)
+
Marginal
13
+
Unreliable
1056
+
Distance Threshold
90.0 ft
+
Depth
11.9 ft
+
+

+ 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

+
+
+
+
AlgorithmError (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 Centroid51.2
Simple Centroid56.5
Distance²-Weighted Centroid58.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 length150.0 ft
Vessel depth11.9 ft
Freeboard4.0 ft
Windage area200.0 sqft
Drag coefficient1.1
Chain weight2.25 lb/ft
+
+
+ + + + + + + +
Wind threshold7.0 kts
Tension ratio0.6
Distance threshold90.0 ft
Current wind2.0 kts
Wind direction89.2°
Vessel heading72.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 + + + + +

Anchor Estimation Debug Report

+

Generated TIMESTAMP • Host: HOST • Wind threshold: WIND_THR kts • Tension ratio: TENSION_RATIO

+ +

Current State

+
+
Anchor Set
ANCHOR_SET
+
Chain Length
CHAIN_LEN ft
+
Current Wind
WIND_NOW kts
+
Est. Distance
EST_DIST ft
+
Catenary Distance
CAT_DIST ft
+
Chain on Bottom
CHAIN_BOTTOM ft
+
Uncertainty
UNCERTAINTY ft
+
Drift from Marked
DRIFT ft
+
+KNOWN_ANCHOR_SECTION + +

Reliability Analysis

+
+
Track Points
N_TOTAL
+
Reliable
N_RELIABLE (PCT_REL%)
+
Marginal
N_MARGINAL
+
Unreliable
N_UNRELIABLE
+
Distance Threshold
DIST_THR ft
+
Depth
DEPTH ft
+
+

+ 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 lengthCHAIN_LEN ft
Vessel depthDEPTH ft
FreeboardFREEBOARD ft
Windage areaWINDAGE sqft
Drag coefficientDRAG_COEFF
Chain weightCHAIN_WT lb/ft
+
+
+ + + + + + + +
Wind thresholdWIND_THR kts
Tension ratioTENSION_RATIO
Distance thresholdDIST_THR ft
Current windWIND_NOW kts
Wind directionWIND_DIR°
Vessel headingHEADING°
+
+
+ + + +""" + + +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 = ( + '
' + '' + '' + '' + ) + if est["lat"] is not None and est["error_ft"] is not None: + algo_table += ( + f'' + f'' + ) + for a in sorted(algos, key=lambda a: a["error_ft"]): + algo_table += ( + f'' + f'' + ) + algo_table += '
AlgorithmError (ft)
Service estimate (current){est["error_ft"]}
{a["name"]}{a["error_ft"]}
' + 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: -- -
-
- - - - -''' - - -class WebHandler(BaseHTTPRequestHandler): - """HTTP request handler""" - - dbus_client = None - - def log_message(self, format, *args): - logger.debug(f"{self.address_string()} - {format % args}") - - def send_json(self, data, status=200): - self.send_response(status) - self.send_header('Content-Type', 'application/json') - self.send_header('Access-Control-Allow-Origin', '*') - self.end_headers() - self.wfile.write(json.dumps(data).encode()) - - def send_html(self, html): - self.send_response(200) - self.send_header('Content-Type', 'text/html') - self.end_headers() - self.wfile.write(html.encode()) - - def do_GET(self): - parsed = urlparse(self.path) - - if parsed.path == '/': - self.send_html(HTML_TEMPLATE) - - elif parsed.path == '/api/status': - if self.dbus_client is None: - self.dbus_client = DBusClient() - status = self.dbus_client.get_all_status() - self.send_json(status) - - else: - self.send_response(404) - self.end_headers() - - def do_POST(self): - parsed = urlparse(self.path) - - if parsed.path == '/api/settings': - content_length = int(self.headers.get('Content-Length', 0)) - body = self.rfile.read(content_length) - - try: - settings = json.loads(body) - if self.dbus_client is None: - self.dbus_client = DBusClient() - - for path, value in settings.items(): - self.dbus_client.set_value(path, value) - - self.send_json({'status': 'ok'}) - except Exception as e: - logger.error(f"Failed to save settings: {e}") - self.send_json({'error': str(e)}, 500) - else: - self.send_response(404) - self.end_headers() - - def do_OPTIONS(self): - self.send_response(200) - self.send_header('Access-Control-Allow-Origin', '*') - self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') - self.send_header('Access-Control-Allow-Headers', 'Content-Type') - self.end_headers() - - -def run_server(): - """Run the web server""" - logging.basicConfig( - level=logging.INFO, - format='%(levelname)s %(name)s: %(message)s' - ) - - server = HTTPServer(('0.0.0.0', WEB_PORT), WebHandler) - logger.info(f"Web UI running at http://0.0.0.0:{WEB_PORT}") - - try: - server.serve_forever() - except KeyboardInterrupt: - logger.info("Shutting down web server") - server.shutdown() - - -if __name__ == '__main__': - run_server() diff --git a/venus-data.zip b/venus-data.zip deleted file mode 100644 index 69dc350..0000000 Binary files a/venus-data.zip and /dev/null differ diff --git a/venus-html5-app/package-lock.json b/venus-html5-app/package-lock.json index a53df6d..4ac47e9 100644 --- a/venus-html5-app/package-lock.json +++ b/venus-html5-app/package-lock.json @@ -13,12 +13,14 @@ "@babel/runtime": "^7.28.4", "@pmmmwh/react-refresh-webpack-plugin": "^0.6.1", "@react-hook/size": "^2.1", + "@react-leaflet/core": "^2.1.0", "@sentry/react": "^6.15.0", "@sentry/tracing": "^6.15.0", "@svgr/webpack": "^8.1.0", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/leaflet": "^1.9.21", "@victronenergy/mfd-modules": "^10.2.0", "axios": "^0.30.2", "babel-eslint": "^10.1.0", @@ -42,6 +44,7 @@ "jest-circus": "^26.6.0", "jest-resolve": "^26.6.0", "jest-watch-typeahead": "^0.6.1", + "leaflet": "^1.9.4", "lodash-es": "^4.17.21", "mini-css-extract-plugin": "^2.9.4", "mobx": "^6.15.0", @@ -60,6 +63,7 @@ "react-dom": "^18.3.1", "react-error-boundary": "^4.0.13", "react-i18nify": "^6.1.3", + "react-leaflet": "^4.2.1", "react-qrcode-logo": "^3.0.0", "react-refresh": "^0.14.2", "react-svg-loader": "^3.0.3", @@ -4346,6 +4350,26 @@ "@parcel/watcher-win32-x64": "2.5.0" } }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.0.tgz", + "integrity": "sha512-qlX4eS28bUcQCdribHkg/herLe+0A9RyYC+mm2PXpncit8z5b3nSqGVzMNR3CmtAOgRutiZ02eIJJgP/b1iEFQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/@parcel/watcher-darwin-arm64": { "version": "2.5.0", "cpu": [ @@ -4364,6 +4388,244 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.0.tgz", + "integrity": "sha512-9rhlwd78saKf18fT869/poydQK8YqlU26TMiNg7AIu7eBp9adqbJZqmdFOsbZ5cnLp5XvRo9wcFmNHgHdWaGYA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.0.tgz", + "integrity": "sha512-syvfhZzyM8kErg3VF0xpV8dixJ+RzbUaaGaeb7uDuz0D3FK97/mZ5AJQ3XNnDsXX7KkFNtyQyFrXZzQIcN49Tw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.0.tgz", + "integrity": "sha512-0VQY1K35DQET3dVYWpOaPFecqOT9dbuCfzjxoQyif1Wc574t3kOSkKevULddcR9znz1TcklCE7Ht6NIxjvTqLA==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.0.tgz", + "integrity": "sha512-6uHywSIzz8+vi2lAzFeltnYbdHsDm3iIB57d4g5oaB9vKwjb6N6dRIgZMujw4nm5r6v9/BQH0noq6DzHrqr2pA==", + "cpu": [ + "arm" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.0.tgz", + "integrity": "sha512-BfNjXwZKxBy4WibDb/LDCriWSKLz+jJRL3cM/DllnHH5QUyoiUNEp3GmL80ZqxeumoADfCCP19+qiYiC8gUBjA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.0.tgz", + "integrity": "sha512-S1qARKOphxfiBEkwLUbHjCY9BWPdWnW9j7f7Hb2jPplu8UZ3nes7zpPOW9bkLbHRvWM0WDTsjdOTUgW0xLBN1Q==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.0.tgz", + "integrity": "sha512-d9AOkusyXARkFD66S6zlGXyzx5RvY+chTP9Jp0ypSTC9d4lzyRs9ovGf/80VCxjKddcUvnsGwCHWuF2EoPgWjw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.0.tgz", + "integrity": "sha512-iqOC+GoTDoFyk/VYSFHwjHhYrk8bljW6zOhPuhi5t9ulqiYq1togGJB5e3PwYVFFfeVgc6pbz3JdQyDoBszVaA==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.0.tgz", + "integrity": "sha512-twtft1d+JRNkM5YbmexfcH/N4znDtjgysFaV9zvZmmJezQsKpkfLYJ+JFV3uygugK6AtIM2oADPkB2AdhBrNig==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.0.tgz", + "integrity": "sha512-+rgpsNRKwo8A53elqbbHXdOMtY/tAtTzManTWShB5Kk54N8Q9mzNWV7tV+IbGueCbcj826MfWGU3mprWtuf1TA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.0.tgz", + "integrity": "sha512-lPrxve92zEHdgeff3aiu4gDOIt4u7sJYha6wbdEZDCDUhtjTsOMiaJzG5lMY4GkWH8p0fMmO2Ppq5G5XXG+DQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/@pkgr/core": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", @@ -4508,6 +4770,17 @@ "react": ">=16.8" } }, + "node_modules/@react-leaflet/core": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz", + "integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/@rollup/plugin-babel": { "version": "5.3.1", "license": "MIT", @@ -5339,6 +5612,12 @@ "@types/range-parser": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "license": "MIT", @@ -5656,6 +5935,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/lodash": { "version": "4.14.191", "dev": true, @@ -17240,6 +17528,12 @@ "shell-quote": "^1.7.3" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/leven": { "version": "3.1.0", "license": "MIT", @@ -21767,6 +22061,20 @@ "version": "17.0.2", "license": "MIT" }, + "node_modules/react-leaflet": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz", + "integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^2.1.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/react-qrcode-logo": { "version": "3.0.0", "license": "MIT", diff --git a/venus-html5-app/package.json b/venus-html5-app/package.json index 752de5b..a5f4c75 100644 --- a/venus-html5-app/package.json +++ b/venus-html5-app/package.json @@ -9,12 +9,14 @@ "@babel/runtime": "^7.28.4", "@pmmmwh/react-refresh-webpack-plugin": "^0.6.1", "@react-hook/size": "^2.1", + "@react-leaflet/core": "^2.1.0", "@sentry/react": "^6.15.0", "@sentry/tracing": "^6.15.0", "@svgr/webpack": "^8.1.0", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/leaflet": "^1.9.21", "@victronenergy/mfd-modules": "^10.2.0", "axios": "^0.30.2", "babel-eslint": "^10.1.0", @@ -38,6 +40,7 @@ "jest-circus": "^26.6.0", "jest-resolve": "^26.6.0", "jest-watch-typeahead": "^0.6.1", + "leaflet": "^1.9.4", "lodash-es": "^4.17.21", "mini-css-extract-plugin": "^2.9.4", "mobx": "^6.15.0", @@ -56,6 +59,7 @@ "react-dom": "^18.3.1", "react-error-boundary": "^4.0.13", "react-i18nify": "^6.1.3", + "react-leaflet": "^4.2.1", "react-qrcode-logo": "^3.0.0", "react-refresh": "^0.14.2", "react-svg-loader": "^3.0.3", diff --git a/venus-html5-app/src/app/Marine2/Marine2.tsx b/venus-html5-app/src/app/Marine2/Marine2.tsx index cbdfdaf..1157d2d 100644 --- a/venus-html5-app/src/app/Marine2/Marine2.tsx +++ b/venus-html5-app/src/app/Marine2/Marine2.tsx @@ -21,6 +21,7 @@ const TideView = React.lazy(() => import("./components/views/custom/TideView")) const TideAnalysisView = React.lazy(() => import("./components/views/custom/TideAnalysisView")) const PowerEnergyView = React.lazy(() => import("./components/views/custom/PowerEnergyView")) const AlarmView = React.lazy(() => import("./components/views/custom/AlarmView")) +const AnchorAlarmView = React.lazy(() => import("./components/views/custom/AnchorAlarmView")) import AlertOverlay, { useHasActiveAlarms } from "./components/ui/AlertOverlay/AlertOverlay" @@ -129,6 +130,12 @@ export const Marine2 = observer((props: AppProps) => { ) + case AppViews.CUSTOM_ANCHOR_ALARM: + return ( + }> + + + ) default: return } diff --git a/venus-html5-app/src/app/Marine2/components/ui/SettingsMenu/SettingsMenu.tsx b/venus-html5-app/src/app/Marine2/components/ui/SettingsMenu/SettingsMenu.tsx index 22cf8d1..14a716b 100644 --- a/venus-html5-app/src/app/Marine2/components/ui/SettingsMenu/SettingsMenu.tsx +++ b/venus-html5-app/src/app/Marine2/components/ui/SettingsMenu/SettingsMenu.tsx @@ -205,6 +205,17 @@ const SettingsMenu = () => { > Generator Ramp + diff --git a/venus-html5-app/src/app/Marine2/components/views/custom/AnchorAlarmView.tsx b/venus-html5-app/src/app/Marine2/components/views/custom/AnchorAlarmView.tsx new file mode 100644 index 0000000..eb33a1d --- /dev/null +++ b/venus-html5-app/src/app/Marine2/components/views/custom/AnchorAlarmView.tsx @@ -0,0 +1,101 @@ +import React, { useCallback } from "react" +import MainLayout from "../../ui/MainLayout" +import { useAnchorAlarm } from "../../../utils/hooks/use-custom-mqtt" +import AnchorMap from "./AnchorMap" +import JoystickPad from "./JoystickPad" +import AnchorStats from "./AnchorStats" + +const AnchorAlarmView = () => { + const aa = useAnchorAlarm() + const { anchorMarkedLat, anchorMarkedLon, setAnchorPosition } = aa + + const handleMoveAnchor = useCallback( + (deltaLat: number, deltaLon: number) => { + if (anchorMarkedLat !== null && anchorMarkedLon !== null) { + setAnchorPosition(anchorMarkedLat + deltaLat, anchorMarkedLon + deltaLon) + } + }, + [anchorMarkedLat, anchorMarkedLon, setAnchorPosition], + ) + + if (!aa.isConnected) { + return ( + +
+ Connecting to MQTT... +
+
+ ) + } + + return ( + +
+ {/* Map — fills available space */} +
+ +
+ + {/* Bottom panel — joystick + stats */} +
+
+ aa.dropAnchor()} + onAfterDrop={(chainLength) => aa.afterDrop(chainLength)} + onWeigh={() => aa.weighAnchor()} + onMoveAnchor={handleMoveAnchor} + vesselLat={aa.vesselLat} + vesselHeading={aa.vesselHeading} + /> +
+
+ +
+
+
+
+ ) +} + +export default AnchorAlarmView diff --git a/venus-html5-app/src/app/Marine2/components/views/custom/AnchorMap.tsx b/venus-html5-app/src/app/Marine2/components/views/custom/AnchorMap.tsx new file mode 100644 index 0000000..01c8c43 --- /dev/null +++ b/venus-html5-app/src/app/Marine2/components/views/custom/AnchorMap.tsx @@ -0,0 +1,488 @@ +import React, { useState, useMemo, useRef, useCallback } from "react" +import { MapContainer, TileLayer, Marker, Circle, Polyline, useMap } from "react-leaflet" +import L from "leaflet" +import "../../../css/leaflet.css" + +delete (L.Icon.Default.prototype as any)._getIconUrl + +const FT_TO_M = 1 / 3.28084 +const DEG_TO_RAD = Math.PI / 180 +const EARTH_RADIUS_FT = 20_902_231 +const RECENTER_THRESHOLD_FT = 50 +const DRIFT_AVG_WINDOW_MS = 30_000 +const DEFAULT_CENTER: [number, number] = [25.0, -77.0] + +function feetToMeters(ft: number): number { + return ft * FT_TO_M +} + +function bearingBetween(lat1: number, lon1: number, lat2: number, lon2: number): number { + const φ1 = lat1 * DEG_TO_RAD + const φ2 = lat2 * DEG_TO_RAD + const Δλ = (lon2 - lon1) * DEG_TO_RAD + const y = Math.sin(Δλ) * Math.cos(φ2) + const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ) + return ((Math.atan2(y, x) * 180) / Math.PI + 360) % 360 +} + +function projectPoint(lat: number, lon: number, distanceFt: number, bearingDeg: number): [number, number] { + const δ = distanceFt / EARTH_RADIUS_FT + const θ = bearingDeg * DEG_TO_RAD + const φ1 = lat * DEG_TO_RAD + const λ1 = lon * DEG_TO_RAD + const φ2 = Math.asin(Math.sin(φ1) * Math.cos(δ) + Math.cos(φ1) * Math.sin(δ) * Math.cos(θ)) + const λ2 = λ1 + Math.atan2(Math.sin(θ) * Math.sin(δ) * Math.cos(φ1), Math.cos(δ) - Math.sin(φ1) * Math.sin(φ2)) + return [(φ2 * 180) / Math.PI, (λ2 * 180) / Math.PI] +} + +function distanceFt(lat1: number, lon1: number, lat2: number, lon2: number): number { + const φ1 = lat1 * DEG_TO_RAD + const φ2 = lat2 * DEG_TO_RAD + const Δφ = (lat2 - lat1) * DEG_TO_RAD + const Δλ = (lon2 - lon1) * DEG_TO_RAD + const a = Math.sin(Δφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) ** 2 + return EARTH_RADIUS_FT * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) +} + +interface AnchorMapProps { + vesselLat: number | null + vesselLon: number | null + vesselHeading: number | null + vesselSpeed: number | null + vesselCourse: number | null + anchorMarkedLat: number | null + anchorMarkedLon: number | null + anchorEstimatedLat: number | null + anchorEstimatedLon: number | null + uncertaintyRadius: number | null + alarmRadius: number | null + alarmActive: boolean + anchorSet: boolean + drift: number | null + track: Array<{ ts: number; lat: number; lon: number }> + estimatedHistory: Array<{ ts: number; lat: number; lon: number }> +} + +const boatSvg = ` + + +` + +function makeVesselIcon(heading: number | null): L.DivIcon { + const rotation = heading ?? 0 + return L.divIcon({ + className: "", + iconSize: [28, 28], + iconAnchor: [14, 14], + html: `
${boatSvg}
`, + }) +} + +function makeAnchorIcon(): L.DivIcon { + return L.divIcon({ + className: "", + iconSize: [24, 24], + iconAnchor: [12, 12], + html: `
`, + }) +} + +function makeEstimatedDotIcon(): L.DivIcon { + return L.divIcon({ + className: "", + iconSize: [10, 10], + iconAnchor: [5, 5], + html: `
`, + }) +} + +function makeSpeedLabel(speed: number): L.DivIcon { + return L.divIcon({ + className: "", + iconSize: [80, 24], + iconAnchor: [40, 12], + html: `
${speed.toFixed(1)} kts
`, + }) +} + +function MapUpdater({ lat, lon }: { lat: number | null; lon: number | null }) { + const map = useMap() + const prevCenter = useRef<[number, number] | null>(null) + + React.useEffect(() => { + if (lat == null || lon == null) return + const center = map.getCenter() + const d = distanceFt(center.lat, center.lng, lat, lon) + if (d > RECENTER_THRESHOLD_FT || prevCenter.current === null) { + map.setView([lat, lon], map.getZoom(), { animate: true, duration: 0.5 }) + prevCenter.current = [lat, lon] + } + }, [lat, lon, map]) + + return null +} + +function ZoomControls() { + const map = useMap() + return ( +
+ + +
+ ) +} + +type MapLayer = "satellite" | "street" + +function cogArrowParams( + speed: number | null, + course: number | null, + anchorSet: boolean, + vesselLat: number | null, + vesselLon: number | null, + anchorMarkedLat: number | null, + anchorMarkedLon: number | null, +): { lengthFt: number; weight: number; color: string } | null { + if (speed == null || course == null || speed < 0.1) return null + + let lengthFt: number + let weight: number + if (speed < 0.5) { + lengthFt = 60 + weight = 3 + } else if (speed < 2) { + lengthFt = 150 + weight = 4 + } else { + lengthFt = 300 + weight = 5 + } + + let color = "#ef4444" + if (anchorSet && anchorMarkedLat != null && anchorMarkedLon != null && vesselLat != null && vesselLon != null) { + const bearingToAnchor = bearingBetween(vesselLat, vesselLon, anchorMarkedLat, anchorMarkedLon) + let angleDiff = Math.abs(course - bearingToAnchor) + if (angleDiff > 180) angleDiff = 360 - angleDiff + if (angleDiff > 90) color = "#22c55e" + else if (angleDiff >= 60) color = "#f59e0b" + else color = "#ef4444" + } else if (!anchorSet && speed > 1) { + color = "#ef4444" + } + + return { lengthFt, weight, color } +} + +function computeRadialKts( + speed: number | null, + course: number | null, + vesselLat: number | null, + vesselLon: number | null, + anchorLat: number | null, + anchorLon: number | null, +): number | null { + if (speed == null || course == null || speed < 0.01) return null + if (vesselLat == null || vesselLon == null || anchorLat == null || anchorLon == null) return null + + const bearingToAnchor = bearingBetween(vesselLat, vesselLon, anchorLat, anchorLon) + const bearingAway = (bearingToAnchor + 180) % 360 + + let angleDiff = course - bearingAway + if (angleDiff > 180) angleDiff -= 360 + if (angleDiff < -180) angleDiff += 360 + + return speed * Math.cos(angleDiff * DEG_TO_RAD) +} + +function driftArrowFromAvg( + avgRadialKts: number, + vesselLat: number, + vesselLon: number, + anchorLat: number, + anchorLon: number, +): { endPoint: [number, number]; radialKts: number; color: string } | null { + if (Math.abs(avgRadialKts) < 0.02) return null + + const bearingToAnchor = bearingBetween(vesselLat, vesselLon, anchorLat, anchorLon) + const bearingAway = (bearingToAnchor + 180) % 360 + + const direction = avgRadialKts > 0 ? bearingAway : bearingToAnchor + const absRadial = Math.abs(avgRadialKts) + let lengthFt: number + if (absRadial < 0.3) lengthFt = 80 + else if (absRadial < 1) lengthFt = 180 + else lengthFt = 350 + + const color = avgRadialKts > 0 ? "#ef4444" : "#22c55e" + const endPoint = projectPoint(vesselLat, vesselLon, lengthFt, direction) + + return { endPoint, radialKts: avgRadialKts, color } +} + +function makeDriftLabel(radialKts: number): L.DivIcon { + const away = radialKts > 0 + const label = away ? `↑${Math.abs(radialKts).toFixed(2)} kt drift` : `↓${Math.abs(radialKts).toFixed(2)} kt closing` + const color = away ? "#fca5a5" : "#86efac" + return L.divIcon({ + className: "", + iconSize: [140, 24], + iconAnchor: [70, 12], + html: `
${label}
`, + }) +} + +const AnchorMap: React.FC = ({ + vesselLat, + vesselLon, + vesselHeading, + vesselSpeed, + vesselCourse, + anchorMarkedLat, + anchorMarkedLon, + anchorEstimatedLat, + anchorEstimatedLon, + uncertaintyRadius, + alarmRadius, + alarmActive, + anchorSet, + drift, + track, + estimatedHistory, +}) => { + const [layer, setLayer] = useState("satellite") + const toggleLayer = useCallback(() => setLayer((l) => (l === "satellite" ? "street" : "satellite")), []) + + const center: [number, number] = + anchorMarkedLat != null && anchorMarkedLon != null + ? [anchorMarkedLat, anchorMarkedLon] + : vesselLat != null && vesselLon != null + ? [vesselLat, vesselLon] + : DEFAULT_CENTER + + const vesselIcon = useMemo(() => makeVesselIcon(vesselHeading), [vesselHeading]) + const anchorIcon = useMemo(() => makeAnchorIcon(), []) + const estimatedDotIcon = useMemo(() => makeEstimatedDotIcon(), []) + + const headingLine = useMemo((): [number, number][] | null => { + if (vesselLat == null || vesselLon == null || vesselHeading == null) return null + const end = projectPoint(vesselLat, vesselLon, 80, vesselHeading) + return [[vesselLat, vesselLon], end] + }, [vesselLat, vesselLon, vesselHeading]) + + const cog = useMemo( + () => cogArrowParams(vesselSpeed, vesselCourse, anchorSet, vesselLat, vesselLon, anchorMarkedLat, anchorMarkedLon), + [vesselSpeed, vesselCourse, anchorSet, vesselLat, vesselLon, anchorMarkedLat, anchorMarkedLon], + ) + + const driftSamplesRef = useRef>([]) + + const driftArrow = useMemo(() => { + if (!anchorSet || vesselLat == null || vesselLon == null || anchorMarkedLat == null || anchorMarkedLon == null) + return null + + const now = Date.now() + const instant = computeRadialKts(vesselSpeed, vesselCourse, vesselLat, vesselLon, anchorMarkedLat, anchorMarkedLon) + if (instant != null) { + driftSamplesRef.current.push({ ts: now, val: instant }) + } + const cutoff = now - DRIFT_AVG_WINDOW_MS + driftSamplesRef.current = driftSamplesRef.current.filter((s) => s.ts > cutoff) + + const samples = driftSamplesRef.current + if (samples.length === 0) return null + + const avgRadial = samples.reduce((sum, s) => sum + s.val, 0) / samples.length + return driftArrowFromAvg(avgRadial, vesselLat, vesselLon, anchorMarkedLat, anchorMarkedLon) + }, [anchorSet, vesselSpeed, vesselCourse, vesselLat, vesselLon, anchorMarkedLat, anchorMarkedLon]) + + const driftLabel = useMemo(() => { + if (!driftArrow) return null + return makeDriftLabel(driftArrow.radialKts) + }, [driftArrow]) + + const cogLine = useMemo((): [number, number][] | null => { + if (!cog || vesselLat == null || vesselLon == null || vesselCourse == null) return null + const end = projectPoint(vesselLat, vesselLon, cog.lengthFt, vesselCourse) + return [[vesselLat, vesselLon], end] + }, [cog, vesselLat, vesselLon, vesselCourse]) + + const cogEndPoint = useMemo((): [number, number] | null => { + if (!cogLine || cogLine.length < 2) return null + return cogLine[1] + }, [cogLine]) + + const speedLabel = useMemo(() => { + if (vesselSpeed == null || vesselSpeed < 0.1) return null + return makeSpeedLabel(vesselSpeed) + }, [vesselSpeed]) + + const trackPositions = useMemo((): [number, number][] => track.map((p) => [p.lat, p.lon]), [track]) + + const showDriftLine = + anchorMarkedLat != null && + anchorMarkedLon != null && + anchorEstimatedLat != null && + anchorEstimatedLon != null && + drift != null && + drift > 10 + + const driftLine = useMemo((): [number, number][] | null => { + if (!showDriftLine) return null + return [ + [anchorMarkedLat!, anchorMarkedLon!], + [anchorEstimatedLat!, anchorEstimatedLon!], + ] + }, [showDriftLine, anchorMarkedLat, anchorMarkedLon, anchorEstimatedLat, anchorEstimatedLon]) + + const nowMs = Date.now() + + return ( +
+ + + + + {layer === "street" ? ( + + ) : ( + + )} + + + {/* Track polyline */} + {trackPositions.length > 1 && ( + + )} + + {/* Estimated history dots */} + {estimatedHistory.map((p, i) => { + const age = (nowMs - p.ts) / 1000 + const opacity = Math.max(0.08, 0.5 - age / 7200) + return ( + + ) + })} + + {/* Uncertainty circle */} + {anchorEstimatedLat != null && anchorEstimatedLon != null && uncertaintyRadius != null && ( + + )} + + {/* Alarm radius circle */} + {anchorSet && anchorMarkedLat != null && anchorMarkedLon != null && alarmRadius != null && ( + + )} + + {/* Drift line */} + {driftLine && ( + + )} + + {/* Heading arrow */} + {headingLine && ( + + )} + + {/* COG arrow */} + {cogLine && cog && ( + + )} + + {/* Radial drift arrow (toward/away from anchor) */} + {driftArrow && vesselLat != null && vesselLon != null && ( + + )} + + {/* Drift label at end of drift arrow */} + {driftArrow && driftLabel && } + + {/* Vessel marker */} + {vesselLat != null && vesselLon != null && } + + {/* Speed label at end of COG arrow */} + {cogEndPoint && speedLabel && } + + {/* Marked anchor icon */} + {anchorMarkedLat != null && anchorMarkedLon != null && ( + + )} + + {/* Estimated anchor position dot */} + {anchorEstimatedLat != null && anchorEstimatedLon != null && ( + + )} + + + {/* Layer toggle */} + +
+ ) +} + +export default React.memo(AnchorMap) diff --git a/venus-html5-app/src/app/Marine2/components/views/custom/AnchorStats.tsx b/venus-html5-app/src/app/Marine2/components/views/custom/AnchorStats.tsx new file mode 100644 index 0000000..b03dfa6 --- /dev/null +++ b/venus-html5-app/src/app/Marine2/components/views/custom/AnchorStats.tsx @@ -0,0 +1,185 @@ +import React from "react" + +interface AnchorStatsProps { + anchorSet: boolean + depth: number | null + scopeRatio: number | null + chainLength: number | null + recommendedScope: number | null + drift: number | null + uncertaintyRadius: number | null + windSpeed: number | null + windDirection: number | null + vesselSpeed: number | null + steadyStateLoad: number | null + peakLoad: number | null + swingRate: number | null + alarmActive: boolean + alarmType: string | null + alarmMessage: string | null + estimatedDistance: number | null + trackPointCount: number | null + alarmRadius: number | null + onAlarmRadiusChange: (ft: number) => void +} + +const ALARM_RADIUS_MIN = 50 +const ALARM_RADIUS_MAX = 500 +const ALARM_RADIUS_STEP = 25 + +const COMPASS_DIRECTIONS = [ + "N", + "NNE", + "NE", + "ENE", + "E", + "ESE", + "SE", + "SSE", + "S", + "SSW", + "SW", + "WSW", + "W", + "WNW", + "NW", + "NNW", +] as const + +function compassDirection(degrees: number): string { + const idx = Math.round((((degrees % 360) + 360) % 360) / 22.5) % 16 + return COMPASS_DIRECTIONS[idx] +} + +const MS_TO_KTS = 1.94384 + +function formatNum(val: number | null, decimals: number = 1): string { + if (val == null) return "--" + return val.toFixed(decimals) +} + +const LABEL = "text-content-secondary text-2xs uppercase tracking-wider" +const VALUE = "text-content-primary text-sm font-mono" + +function StatCell({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ) +} + +function driftColor(drift: number | null): string { + if (drift == null) return "text-content-primary" + if (drift > 50) return "text-red-500" + if (drift > 20) return "text-orange-400" + return "text-content-primary" +} + +const AnchorStats: React.FC = ({ + depth, + scopeRatio, + chainLength, + recommendedScope, + drift, + uncertaintyRadius, + windSpeed, + windDirection, + vesselSpeed, + steadyStateLoad, + peakLoad, + alarmActive, + alarmType, + alarmMessage, + estimatedDistance, + trackPointCount, + alarmRadius, + onAlarmRadiusChange, +}) => { + const peakOverloaded = steadyStateLoad != null && peakLoad != null && peakLoad > 2 * steadyStateLoad + + return ( +
+
+ {/* Row 1 */} + {formatNum(depth)} ft + {scopeRatio != null ? `${formatNum(scopeRatio)}:1` : "--"} + + {/* Row 2 */} + {formatNum(chainLength)} ft + {formatNum(recommendedScope)} ft + + {/* Row 3 */} +
+ Drift + {formatNum(drift)} ft +
+ ±{formatNum(uncertaintyRadius)} ft + + {/* Row 4 */} + + {windSpeed != null + ? `${formatNum(windSpeed * MS_TO_KTS)} kts${windDirection != null ? ` ${compassDirection(windDirection)}` : ""}` + : "--"} + + {formatNum(vesselSpeed)} kts + + {/* Row 5 */} + {formatNum(steadyStateLoad, 0)} lbs +
+ Peak Load + + {formatNum(peakLoad, 0)} lbs + +
+ + {/* Row 6 */} + {formatNum(estimatedDistance)} ft +
+ Alarm Radius +
+ + + {alarmRadius != null ? alarmRadius : "--"} + + + ft +
+
+
+ + {/* Alarm banner */} + {alarmActive && ( +
+ {alarmType && {alarmType}} + {alarmMessage && {alarmMessage}} +
+ )} + + {/* Track points */} +
+ Track Points: + {trackPointCount ?? "--"} +
+
+ ) +} + +export default React.memo(AnchorStats) diff --git a/venus-html5-app/src/app/Marine2/components/views/custom/JoystickPad.tsx b/venus-html5-app/src/app/Marine2/components/views/custom/JoystickPad.tsx new file mode 100644 index 0000000..5902851 --- /dev/null +++ b/venus-html5-app/src/app/Marine2/components/views/custom/JoystickPad.tsx @@ -0,0 +1,221 @@ +import React, { useState, useRef, useCallback, useEffect } from "react" + +interface JoystickPadProps { + anchorSet: boolean + anchorMarkedLat: number | null + anchorMarkedLon: number | null + onDrop: () => void + onAfterDrop: (chainLength: number) => void + onWeigh: () => void + onMoveAnchor: (deltaLat: number, deltaLon: number) => void + vesselLat: number | null + vesselHeading: number | null +} + +const STEP_FT = 3 +const FT_TO_DEG_LAT = 1 / (60 * 6076.12) +const CHAIN_STEP = 5 +const CONFIRM_TIMEOUT_MS = 5000 +const REPEAT_INITIAL_MS = 400 +const REPEAT_MS = 150 + +type Direction = "N" | "S" | "E" | "W" + +const JoystickPad: React.FC = ({ + anchorSet, + anchorMarkedLat, + anchorMarkedLon, + onDrop, + onAfterDrop, + onWeigh, + onMoveAnchor, + vesselLat, + vesselHeading: _vesselHeading, +}) => { + const [showAfterDropForm, setShowAfterDropForm] = useState(false) + const [chainLength, setChainLength] = useState(100) + const [weighConfirmPending, setWeighConfirmPending] = useState(false) + + const confirmTimeoutRef = useRef | null>(null) + const repeatRef = useRef | null>(null) + + const emitStep = useCallback( + (dir: Direction) => { + const cosLat = Math.cos(((vesselLat ?? 0) * Math.PI) / 180) || 1 + const dLatPerFt = FT_TO_DEG_LAT + const dLonPerFt = FT_TO_DEG_LAT / cosLat + switch (dir) { + case "N": + return onMoveAnchor(STEP_FT * dLatPerFt, 0) + case "S": + return onMoveAnchor(-STEP_FT * dLatPerFt, 0) + case "E": + return onMoveAnchor(0, STEP_FT * dLonPerFt) + case "W": + return onMoveAnchor(0, -STEP_FT * dLonPerFt) + } + }, + [onMoveAnchor, vesselLat], + ) + + const startRepeat = useCallback( + (dir: Direction) => { + emitStep(dir) + const id = setTimeout(function tick() { + emitStep(dir) + repeatRef.current = setTimeout(tick, REPEAT_MS) + }, REPEAT_INITIAL_MS) + repeatRef.current = id + }, + [emitStep], + ) + + const stopRepeat = useCallback(() => { + if (repeatRef.current) { + clearTimeout(repeatRef.current) + repeatRef.current = null + } + }, []) + + useEffect(() => { + return () => { + if (repeatRef.current) clearTimeout(repeatRef.current) + if (confirmTimeoutRef.current) clearTimeout(confirmTimeoutRef.current) + } + }, []) + + const handleWeigh = useCallback(() => { + if (!weighConfirmPending) { + setWeighConfirmPending(true) + confirmTimeoutRef.current = setTimeout(() => setWeighConfirmPending(false), CONFIRM_TIMEOUT_MS) + return + } + if (confirmTimeoutRef.current) clearTimeout(confirmTimeoutRef.current) + setWeighConfirmPending(false) + onWeigh() + }, [weighConfirmPending, onWeigh]) + + const handleAfterDropSet = useCallback(() => { + onAfterDrop(chainLength) + setShowAfterDropForm(false) + }, [onAfterDrop, chainLength]) + + const moveDisabled = !anchorSet + const showWeigh = anchorSet || (anchorMarkedLat != null && anchorMarkedLon != null) + + const dirBtn = (dir: Direction, label: string, extraClass: string) => ( + + ) + + return ( +
+ {/* Directional pad */} +
+
+ {dirBtn("N", "N", "col-start-2")} +
+ {dirBtn("W", "W", "")} +
+
+ {dirBtn("E", "E", "")} +
+ {dirBtn("S", "S", "col-start-2")} +
+
+ + {/* Buttons */} +
+ {!anchorSet && ( + <> + + + {!showAfterDropForm ? ( + + ) : ( +
+ +
+ + setChainLength(Math.max(0, Number(e.target.value) || 0))} + className="w-16 text-center rounded-md bg-surface-primary text-content-primary text-sm py-1 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> + +
+

Verify bow is pointed at anchor

+
+ + +
+
+ )} + + )} + + {showWeigh && ( + + )} +
+
+ ) +} + +export default JoystickPad diff --git a/venus-html5-app/src/app/Marine2/css/images/layers-2x.png b/venus-html5-app/src/app/Marine2/css/images/layers-2x.png new file mode 100644 index 0000000..200c333 Binary files /dev/null and b/venus-html5-app/src/app/Marine2/css/images/layers-2x.png differ diff --git a/venus-html5-app/src/app/Marine2/css/images/layers.png b/venus-html5-app/src/app/Marine2/css/images/layers.png new file mode 100644 index 0000000..1a72e57 Binary files /dev/null and b/venus-html5-app/src/app/Marine2/css/images/layers.png differ diff --git a/venus-html5-app/src/app/Marine2/css/images/marker-icon-2x.png b/venus-html5-app/src/app/Marine2/css/images/marker-icon-2x.png new file mode 100644 index 0000000..88f9e50 Binary files /dev/null and b/venus-html5-app/src/app/Marine2/css/images/marker-icon-2x.png differ diff --git a/venus-html5-app/src/app/Marine2/css/images/marker-icon.png b/venus-html5-app/src/app/Marine2/css/images/marker-icon.png new file mode 100644 index 0000000..950edf2 Binary files /dev/null and b/venus-html5-app/src/app/Marine2/css/images/marker-icon.png differ diff --git a/venus-html5-app/src/app/Marine2/css/images/marker-shadow.png b/venus-html5-app/src/app/Marine2/css/images/marker-shadow.png new file mode 100644 index 0000000..9fd2979 Binary files /dev/null and b/venus-html5-app/src/app/Marine2/css/images/marker-shadow.png differ diff --git a/venus-html5-app/src/app/Marine2/css/leaflet.css b/venus-html5-app/src/app/Marine2/css/leaflet.css new file mode 100644 index 0000000..2961b76 --- /dev/null +++ b/venus-html5-app/src/app/Marine2/css/leaflet.css @@ -0,0 +1,661 @@ +/* required styles */ + +.leaflet-pane, +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-tile-container, +.leaflet-pane > svg, +.leaflet-pane > canvas, +.leaflet-zoom-box, +.leaflet-image-layer, +.leaflet-layer { + position: absolute; + left: 0; + top: 0; + } +.leaflet-container { + overflow: hidden; + } +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + -webkit-user-drag: none; + } +/* Prevents IE11 from highlighting tiles in blue */ +.leaflet-tile::selection { + background: transparent; +} +/* Safari renders non-retina tile on retina better with this, but Chrome is worse */ +.leaflet-safari .leaflet-tile { + image-rendering: -webkit-optimize-contrast; + } +/* hack that prevents hw layers "stretching" when loading new tiles */ +.leaflet-safari .leaflet-tile-container { + width: 1600px; + height: 1600px; + -webkit-transform-origin: 0 0; + } +.leaflet-marker-icon, +.leaflet-marker-shadow { + display: block; + } +/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */ +/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */ +.leaflet-container .leaflet-overlay-pane svg { + max-width: none !important; + max-height: none !important; + } +.leaflet-container .leaflet-marker-pane img, +.leaflet-container .leaflet-shadow-pane img, +.leaflet-container .leaflet-tile-pane img, +.leaflet-container img.leaflet-image-layer, +.leaflet-container .leaflet-tile { + max-width: none !important; + max-height: none !important; + width: auto; + padding: 0; + } + +.leaflet-container img.leaflet-tile { + /* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */ + mix-blend-mode: plus-lighter; +} + +.leaflet-container.leaflet-touch-zoom { + -ms-touch-action: pan-x pan-y; + touch-action: pan-x pan-y; + } +.leaflet-container.leaflet-touch-drag { + -ms-touch-action: pinch-zoom; + /* Fallback for FF which doesn't support pinch-zoom */ + touch-action: none; + touch-action: pinch-zoom; +} +.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom { + -ms-touch-action: none; + touch-action: none; +} +.leaflet-container { + -webkit-tap-highlight-color: transparent; +} +.leaflet-container a { + -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4); +} +.leaflet-tile { + filter: inherit; + visibility: hidden; + } +.leaflet-tile-loaded { + visibility: inherit; + } +.leaflet-zoom-box { + width: 0; + height: 0; + -moz-box-sizing: border-box; + box-sizing: border-box; + z-index: 800; + } +/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */ +.leaflet-overlay-pane svg { + -moz-user-select: none; + } + +.leaflet-pane { z-index: 400; } + +.leaflet-tile-pane { z-index: 200; } +.leaflet-overlay-pane { z-index: 400; } +.leaflet-shadow-pane { z-index: 500; } +.leaflet-marker-pane { z-index: 600; } +.leaflet-tooltip-pane { z-index: 650; } +.leaflet-popup-pane { z-index: 700; } + +.leaflet-map-pane canvas { z-index: 100; } +.leaflet-map-pane svg { z-index: 200; } + +.leaflet-vml-shape { + width: 1px; + height: 1px; + } +.lvml { + behavior: url(#default#VML); + display: inline-block; + position: absolute; + } + + +/* control positioning */ + +.leaflet-control { + position: relative; + z-index: 800; + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } +.leaflet-top, +.leaflet-bottom { + position: absolute; + z-index: 1000; + pointer-events: none; + } +.leaflet-top { + top: 0; + } +.leaflet-right { + right: 0; + } +.leaflet-bottom { + bottom: 0; + } +.leaflet-left { + left: 0; + } +.leaflet-control { + float: left; + clear: both; + } +.leaflet-right .leaflet-control { + float: right; + } +.leaflet-top .leaflet-control { + margin-top: 10px; + } +.leaflet-bottom .leaflet-control { + margin-bottom: 10px; + } +.leaflet-left .leaflet-control { + margin-left: 10px; + } +.leaflet-right .leaflet-control { + margin-right: 10px; + } + + +/* zoom and fade animations */ + +.leaflet-fade-anim .leaflet-popup { + opacity: 0; + -webkit-transition: opacity 0.2s linear; + -moz-transition: opacity 0.2s linear; + transition: opacity 0.2s linear; + } +.leaflet-fade-anim .leaflet-map-pane .leaflet-popup { + opacity: 1; + } +.leaflet-zoom-animated { + -webkit-transform-origin: 0 0; + -ms-transform-origin: 0 0; + transform-origin: 0 0; + } +svg.leaflet-zoom-animated { + will-change: transform; +} + +.leaflet-zoom-anim .leaflet-zoom-animated { + -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); + -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1); + transition: transform 0.25s cubic-bezier(0,0,0.25,1); + } +.leaflet-zoom-anim .leaflet-tile, +.leaflet-pan-anim .leaflet-tile { + -webkit-transition: none; + -moz-transition: none; + transition: none; + } + +.leaflet-zoom-anim .leaflet-zoom-hide { + visibility: hidden; + } + + +/* cursors */ + +.leaflet-interactive { + cursor: pointer; + } +.leaflet-grab { + cursor: -webkit-grab; + cursor: -moz-grab; + cursor: grab; + } +.leaflet-crosshair, +.leaflet-crosshair .leaflet-interactive { + cursor: crosshair; + } +.leaflet-popup-pane, +.leaflet-control { + cursor: auto; + } +.leaflet-dragging .leaflet-grab, +.leaflet-dragging .leaflet-grab .leaflet-interactive, +.leaflet-dragging .leaflet-marker-draggable { + cursor: move; + cursor: -webkit-grabbing; + cursor: -moz-grabbing; + cursor: grabbing; + } + +/* marker & overlays interactivity */ +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-image-layer, +.leaflet-pane > svg path, +.leaflet-tile-container { + pointer-events: none; + } + +.leaflet-marker-icon.leaflet-interactive, +.leaflet-image-layer.leaflet-interactive, +.leaflet-pane > svg path.leaflet-interactive, +svg.leaflet-image-layer.leaflet-interactive path { + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } + +/* visual tweaks */ + +.leaflet-container { + background: #ddd; + outline-offset: 1px; + } +.leaflet-container a { + color: #0078A8; + } +.leaflet-zoom-box { + border: 2px dotted #38f; + background: rgba(255,255,255,0.5); + } + + +/* general typography */ +.leaflet-container { + font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; + font-size: 12px; + font-size: 0.75rem; + line-height: 1.5; + } + + +/* general toolbar styles */ + +.leaflet-bar { + box-shadow: 0 1px 5px rgba(0,0,0,0.65); + border-radius: 4px; + } +.leaflet-bar a { + background-color: #fff; + border-bottom: 1px solid #ccc; + width: 26px; + height: 26px; + line-height: 26px; + display: block; + text-align: center; + text-decoration: none; + color: black; + } +.leaflet-bar a, +.leaflet-control-layers-toggle { + background-position: 50% 50%; + background-repeat: no-repeat; + display: block; + } +.leaflet-bar a:hover, +.leaflet-bar a:focus { + background-color: #f4f4f4; + } +.leaflet-bar a:first-child { + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } +.leaflet-bar a:last-child { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + border-bottom: none; + } +.leaflet-bar a.leaflet-disabled { + cursor: default; + background-color: #f4f4f4; + color: #bbb; + } + +.leaflet-touch .leaflet-bar a { + width: 30px; + height: 30px; + line-height: 30px; + } +.leaflet-touch .leaflet-bar a:first-child { + border-top-left-radius: 2px; + border-top-right-radius: 2px; + } +.leaflet-touch .leaflet-bar a:last-child { + border-bottom-left-radius: 2px; + border-bottom-right-radius: 2px; + } + +/* zoom control */ + +.leaflet-control-zoom-in, +.leaflet-control-zoom-out { + font: bold 18px 'Lucida Console', Monaco, monospace; + text-indent: 1px; + } + +.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out { + font-size: 22px; + } + + +/* layers control */ + +.leaflet-control-layers { + box-shadow: 0 1px 5px rgba(0,0,0,0.4); + background: #fff; + border-radius: 5px; + } +.leaflet-control-layers-toggle { + background-image: url(images/layers.png); + width: 36px; + height: 36px; + } +.leaflet-retina .leaflet-control-layers-toggle { + background-image: url(images/layers-2x.png); + background-size: 26px 26px; + } +.leaflet-touch .leaflet-control-layers-toggle { + width: 44px; + height: 44px; + } +.leaflet-control-layers .leaflet-control-layers-list, +.leaflet-control-layers-expanded .leaflet-control-layers-toggle { + display: none; + } +.leaflet-control-layers-expanded .leaflet-control-layers-list { + display: block; + position: relative; + } +.leaflet-control-layers-expanded { + padding: 6px 10px 6px 6px; + color: #333; + background: #fff; + } +.leaflet-control-layers-scrollbar { + overflow-y: scroll; + overflow-x: hidden; + padding-right: 5px; + } +.leaflet-control-layers-selector { + margin-top: 2px; + position: relative; + top: 1px; + } +.leaflet-control-layers label { + display: block; + font-size: 13px; + font-size: 1.08333em; + } +.leaflet-control-layers-separator { + height: 0; + border-top: 1px solid #ddd; + margin: 5px -10px 5px -6px; + } + +/* Default icon URLs */ +.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */ + background-image: url(images/marker-icon.png); + } + + +/* attribution and scale controls */ + +.leaflet-container .leaflet-control-attribution { + background: #fff; + background: rgba(255, 255, 255, 0.8); + margin: 0; + } +.leaflet-control-attribution, +.leaflet-control-scale-line { + padding: 0 5px; + color: #333; + line-height: 1.4; + } +.leaflet-control-attribution a { + text-decoration: none; + } +.leaflet-control-attribution a:hover, +.leaflet-control-attribution a:focus { + text-decoration: underline; + } +.leaflet-attribution-flag { + display: inline !important; + vertical-align: baseline !important; + width: 1em; + height: 0.6669em; + } +.leaflet-left .leaflet-control-scale { + margin-left: 5px; + } +.leaflet-bottom .leaflet-control-scale { + margin-bottom: 5px; + } +.leaflet-control-scale-line { + border: 2px solid #777; + border-top: none; + line-height: 1.1; + padding: 2px 5px 1px; + white-space: nowrap; + -moz-box-sizing: border-box; + box-sizing: border-box; + background: rgba(255, 255, 255, 0.8); + text-shadow: 1px 1px #fff; + } +.leaflet-control-scale-line:not(:first-child) { + border-top: 2px solid #777; + border-bottom: none; + margin-top: -2px; + } +.leaflet-control-scale-line:not(:first-child):not(:last-child) { + border-bottom: 2px solid #777; + } + +.leaflet-touch .leaflet-control-attribution, +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + box-shadow: none; + } +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + border: 2px solid rgba(0,0,0,0.2); + background-clip: padding-box; + } + + +/* popup */ + +.leaflet-popup { + position: absolute; + text-align: center; + margin-bottom: 20px; + } +.leaflet-popup-content-wrapper { + padding: 1px; + text-align: left; + border-radius: 12px; + } +.leaflet-popup-content { + margin: 13px 24px 13px 20px; + line-height: 1.3; + font-size: 13px; + font-size: 1.08333em; + min-height: 1px; + } +.leaflet-popup-content p { + margin: 17px 0; + margin: 1.3em 0; + } +.leaflet-popup-tip-container { + width: 40px; + height: 20px; + position: absolute; + left: 50%; + margin-top: -1px; + margin-left: -20px; + overflow: hidden; + pointer-events: none; + } +.leaflet-popup-tip { + width: 17px; + height: 17px; + padding: 1px; + + margin: -10px auto 0; + pointer-events: auto; + + -webkit-transform: rotate(45deg); + -moz-transform: rotate(45deg); + -ms-transform: rotate(45deg); + transform: rotate(45deg); + } +.leaflet-popup-content-wrapper, +.leaflet-popup-tip { + background: white; + color: #333; + box-shadow: 0 3px 14px rgba(0,0,0,0.4); + } +.leaflet-container a.leaflet-popup-close-button { + position: absolute; + top: 0; + right: 0; + border: none; + text-align: center; + width: 24px; + height: 24px; + font: 16px/24px Tahoma, Verdana, sans-serif; + color: #757575; + text-decoration: none; + background: transparent; + } +.leaflet-container a.leaflet-popup-close-button:hover, +.leaflet-container a.leaflet-popup-close-button:focus { + color: #585858; + } +.leaflet-popup-scrolled { + overflow: auto; + } + +.leaflet-oldie .leaflet-popup-content-wrapper { + -ms-zoom: 1; + } +.leaflet-oldie .leaflet-popup-tip { + width: 24px; + margin: 0 auto; + + -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)"; + filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678); + } + +.leaflet-oldie .leaflet-control-zoom, +.leaflet-oldie .leaflet-control-layers, +.leaflet-oldie .leaflet-popup-content-wrapper, +.leaflet-oldie .leaflet-popup-tip { + border: 1px solid #999; + } + + +/* div icon */ + +.leaflet-div-icon { + background: #fff; + border: 1px solid #666; + } + + +/* Tooltip */ +/* Base styles for the element that has a tooltip */ +.leaflet-tooltip { + position: absolute; + padding: 6px; + background-color: #fff; + border: 1px solid #fff; + border-radius: 3px; + color: #222; + white-space: nowrap; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; + box-shadow: 0 1px 3px rgba(0,0,0,0.4); + } +.leaflet-tooltip.leaflet-interactive { + cursor: pointer; + pointer-events: auto; + } +.leaflet-tooltip-top:before, +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + position: absolute; + pointer-events: none; + border: 6px solid transparent; + background: transparent; + content: ""; + } + +/* Directions */ + +.leaflet-tooltip-bottom { + margin-top: 6px; +} +.leaflet-tooltip-top { + margin-top: -6px; +} +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-top:before { + left: 50%; + margin-left: -6px; + } +.leaflet-tooltip-top:before { + bottom: 0; + margin-bottom: -12px; + border-top-color: #fff; + } +.leaflet-tooltip-bottom:before { + top: 0; + margin-top: -12px; + margin-left: -6px; + border-bottom-color: #fff; + } +.leaflet-tooltip-left { + margin-left: -6px; +} +.leaflet-tooltip-right { + margin-left: 6px; +} +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + top: 50%; + margin-top: -6px; + } +.leaflet-tooltip-left:before { + right: 0; + margin-right: -12px; + border-left-color: #fff; + } +.leaflet-tooltip-right:before { + left: 0; + margin-left: -12px; + border-right-color: #fff; + } + +/* Printing */ + +@media print { + /* Prevent printers from removing background-images of controls. */ + .leaflet-control { + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + } diff --git a/venus-html5-app/src/app/Marine2/modules/AppViews/AppViews.store.ts b/venus-html5-app/src/app/Marine2/modules/AppViews/AppViews.store.ts index b3de3f5..a0f8387 100644 --- a/venus-html5-app/src/app/Marine2/modules/AppViews/AppViews.store.ts +++ b/venus-html5-app/src/app/Marine2/modules/AppViews/AppViews.store.ts @@ -19,6 +19,7 @@ export enum AppViews { CUSTOM_TIDE_ANALYSIS = "custom/tide-analysis", CUSTOM_POWER_ENERGY = "custom/power-energy", CUSTOM_ALARMS = "custom/alarms", + CUSTOM_ANCHOR_ALARM = "custom/anchor-alarm", } export const AppViewTitleKeys = new Map([ @@ -38,6 +39,7 @@ export const AppViewTitleKeys = new Map([ [AppViews.CUSTOM_TIDE_ANALYSIS, "Tide Analysis"], [AppViews.CUSTOM_POWER_ENERGY, "Power / Energy"], [AppViews.CUSTOM_ALARMS, "Alarms"], + [AppViews.CUSTOM_ANCHOR_ALARM, "Anchor Alarm"], ]) export class AppViewsStore { diff --git a/venus-html5-app/src/app/Marine2/utils/hooks/use-custom-mqtt.ts b/venus-html5-app/src/app/Marine2/utils/hooks/use-custom-mqtt.ts index 938a32e..7ecd3aa 100644 --- a/venus-html5-app/src/app/Marine2/utils/hooks/use-custom-mqtt.ts +++ b/venus-html5-app/src/app/Marine2/utils/hooks/use-custom-mqtt.ts @@ -943,3 +943,139 @@ export function useVrmHistory() { isConnected, } } + +const ANCHOR_ALARM_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/ScopeRatio", + "/Anchor/RecommendedScope", + "/Anchor/EstimatedDistance", + "/Anchor/SteadyStateLoad", + "/Anchor/PeakLoad", + "/Anchor/SwingRate", + "/Alarm/Active", + "/Alarm/Type", + "/Alarm/Radius", + "/Alarm/Message", + "/Vessel/Latitude", + "/Vessel/Longitude", + "/Vessel/Speed", + "/Vessel/Course", + "/Vessel/Heading", + "/Vessel/Depth", + "/Vessel/WindSpeed", + "/Vessel/WindDirection", + "/Track/Json", + "/Track/PointCount", + "/Settings/Enabled", + "/Settings/ChainLength", + "/Settings/AlarmRadius", + "/Settings/FreeboardHeight", + "/Settings/WindageArea", + "/Settings/DragCoefficient", + "/Settings/ChainWeight", + "/Settings/AnchorWeight", + "/Settings/VesselWeight", + "/Settings/Anchor/Latitude", + "/Settings/Anchor/Longitude", + "/Action/DropAnchor", + "/Action/AfterDrop", + "/Action/WeighAnchor", +] + +export interface AnchorHistoryPoint { + ts: number + lat: number + lon: number +} + +export interface TrackPoint { + ts: number + lat: number + lon: number +} + +export function useAnchorAlarm() { + const { values, setValue, isConnected } = useCustomService("anchoralarm", ANCHOR_ALARM_PATHS) + + const rawEstimatedHistory = values["/Anchor/EstimatedHistory/Json"] + const estimatedHistory: AnchorHistoryPoint[] = useMemo(() => { + if (!rawEstimatedHistory || typeof rawEstimatedHistory !== "string") return [] + try { + return JSON.parse(rawEstimatedHistory) as AnchorHistoryPoint[] + } catch { + return [] + } + }, [rawEstimatedHistory]) + + const rawTrack = values["/Track/Json"] + const track: TrackPoint[] = useMemo(() => { + if (!rawTrack || typeof rawTrack !== "string") return [] + try { + return JSON.parse(rawTrack) as TrackPoint[] + } catch { + return [] + } + }, [rawTrack]) + + return { + anchorSet: values["/Anchor/Set"] === 1, + anchorMarkedLat: values["/Anchor/Marked/Latitude"] as number | null, + anchorMarkedLon: values["/Anchor/Marked/Longitude"] as number | null, + anchorEstimatedLat: values["/Anchor/Estimated/Latitude"] as number | null, + anchorEstimatedLon: values["/Anchor/Estimated/Longitude"] as number | null, + uncertaintyRadius: values["/Anchor/UncertaintyRadius"] as number | null, + drift: values["/Anchor/Drift"] as number | null, + estimatedHistory, + chainLength: values["/Anchor/ChainLength"] as number | null, + scopeRatio: values["/Anchor/ScopeRatio"] as number | null, + recommendedScope: values["/Anchor/RecommendedScope"] as number | null, + estimatedDistance: values["/Anchor/EstimatedDistance"] as number | null, + steadyStateLoad: values["/Anchor/SteadyStateLoad"] as number | null, + peakLoad: values["/Anchor/PeakLoad"] as number | null, + swingRate: values["/Anchor/SwingRate"] as number | null, + alarmActive: values["/Alarm/Active"] === 1, + alarmType: values["/Alarm/Type"] as number | null, + alarmRadius: values["/Alarm/Radius"] as number | null, + alarmMessage: values["/Alarm/Message"] as string | null, + vesselLat: values["/Vessel/Latitude"] as number | null, + vesselLon: values["/Vessel/Longitude"] as number | null, + vesselSpeed: values["/Vessel/Speed"] as number | null, + vesselCourse: values["/Vessel/Course"] as number | null, + vesselHeading: values["/Vessel/Heading"] as number | null, + vesselDepth: values["/Vessel/Depth"] as number | null, + vesselWindSpeed: values["/Vessel/WindSpeed"] as number | null, + vesselWindDirection: values["/Vessel/WindDirection"] as number | null, + track, + trackPointCount: values["/Track/PointCount"] as number | null, + enabled: values["/Settings/Enabled"] === 1, + settingsChainLength: values["/Settings/ChainLength"] as number | null, + settingsAlarmRadius: values["/Settings/AlarmRadius"] as number | null, + freeboardHeight: values["/Settings/FreeboardHeight"] as number | null, + windageArea: values["/Settings/WindageArea"] as number | null, + dragCoefficient: values["/Settings/DragCoefficient"] as number | null, + chainWeight: values["/Settings/ChainWeight"] as number | null, + anchorWeight: values["/Settings/AnchorWeight"] as number | null, + vesselWeight: values["/Settings/VesselWeight"] as number | null, + settingsAnchorLat: values["/Settings/Anchor/Latitude"] as number | null, + settingsAnchorLon: values["/Settings/Anchor/Longitude"] as number | null, + dropAnchor: () => setValue("/Action/DropAnchor", 1), + weighAnchor: () => setValue("/Action/WeighAnchor", 1), + afterDrop: (chainLength: number) => setValue("/Action/AfterDrop", JSON.stringify({ chainLength })), + setChainLength: (ft: number) => setValue("/Settings/ChainLength", ft), + setAlarmRadius: (ft: number) => setValue("/Settings/AlarmRadius", ft), + setAnchorPosition: (lat: number, lon: number) => { + setValue("/Settings/Anchor/Latitude", lat) + setValue("/Settings/Anchor/Longitude", lon) + }, + setEnabled: (v: boolean) => setValue("/Settings/Enabled", v ? 1 : 0), + isConnected, + } +}