updates and fixes

This commit is contained in:
2026-03-26 14:15:02 +00:00
parent b3ceddcdfa
commit 0ef05034b0
49 changed files with 7485 additions and 749 deletions

24
dbus-anchor-alarm/.gitignore vendored Normal file
View File

@@ -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/

View File

@@ -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"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Anchor Data Analysis Report</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<style>
:root {
--bg: #0f1117; --surface: #1a1d27; --border: #2a2d3a;
--text: #e0e0e8; --muted: #8a8d9a; --accent: #4fc3f7;
--accent2: #f4845f; --accent3: #7ec8a0; --accent4: #c39af4;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg); color: var(--text);
font-family: 'SF Mono', 'Fira Code', monospace;
padding: 2rem; line-height: 1.6;
}
h1 { color: var(--accent); font-size: 1.6rem; margin-bottom: 0.5rem; }
h2 { color: var(--accent); font-size: 1.1rem; margin: 2rem 0 0.75rem; border-bottom: 1px solid var(--border); padding-bottom: 0.3rem; }
.stats-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.75rem; margin: 1rem 0;
}
.stat-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: 6px; padding: 0.75rem;
}
.stat-card .label { color: var(--muted); font-size: 0.75rem; text-transform: uppercase; }
.stat-card .value { color: var(--accent); font-size: 1.3rem; font-weight: 700; }
.chart-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin: 1rem 0; }
.chart-box {
background: var(--surface); border: 1px solid var(--border);
border-radius: 8px; padding: 1rem;
}
.chart-box.full { grid-column: 1 / -1; }
.chart-box canvas { width: 100% !important; }
.insight { color: var(--muted); font-size: 0.85rem; margin-top: 0.5rem; font-style: italic; }
@media (max-width: 900px) { .chart-row { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<h1>Anchor Analysis Report</h1>
<p style="color:var(--muted); margin-bottom:1rem;">Chain: CHAIN_LEN ft &bull; Depth: DEPTH_RANGE ft &bull; Wind: WIND_RANGE kts &bull; Duration: DURATION hrs</p>
<div class="stats-grid">
<div class="stat-card"><div class="label">Estimated Anchor</div><div class="value" style="font-size:0.95rem">ANCHOR_POS</div></div>
<div class="stat-card"><div class="label">Heading Offset (mean&plusmn;std)</div><div class="value">OFFSET_STATS&deg;</div></div>
<div class="stat-card"><div class="label">Wind&harr;Distance Corr</div><div class="value">CORR_WS</div></div>
<div class="stat-card"><div class="label">Swing Period</div><div class="value">SWING_PERIOD</div></div>
<div class="stat-card"><div class="label">Estimation Samples</div><div class="value">EST_ROWS</div></div>
<div class="stat-card"><div class="label">Sensor Samples</div><div class="value">SENSOR_ROWS</div></div>
</div>
<!-- 1. Track -->
<h2>1. Vessel Track / Swing Circle</h2>
<div class="chart-row">
<div class="chart-box full"><canvas id="trackChart" height="400"></canvas></div>
</div>
<p class="insight">Vessel positions in local X/Y (ft) relative to estimated anchor. Color encodes time progression (blue&rarr;yellow). Dashed circle = arc-fit radius.</p>
<!-- 2. Weathervane -->
<h2>2. Weathervaning: Heading vs Wind Direction</h2>
<div class="chart-row">
<div class="chart-box"><canvas id="wvTimeSeries" height="250"></canvas></div>
<div class="chart-box"><canvas id="wvHist" height="250"></canvas></div>
</div>
<p class="insight">Left: heading and wind direction over time. Right: distribution of heading offset (hdg &minus; wind_dir). Mean offset reveals systematic bias from hull/keel asymmetry.</p>
<!-- 3. Catenary -->
<h2>3. Catenary Model Validation</h2>
<div class="chart-row">
<div class="chart-box full"><canvas id="catChart" height="300"></canvas></div>
</div>
<p class="insight">Measured catenary distance vs depth, overlaid with theoretical curve at median wind speed (WS_MED kts). Scatter shows tidal variation.</p>
<!-- 4. Tide -->
<h2>4. Depth (Tide) &amp; Distance Time Series</h2>
<div class="chart-row">
<div class="chart-box full"><canvas id="tideChart" height="300"></canvas></div>
</div>
<p class="insight">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).</p>
<!-- 5. Wind Effects -->
<h2>5. Wind Effects on Swing Radius</h2>
<div class="chart-row">
<div class="chart-box full"><canvas id="windChart" height="300"></canvas></div>
</div>
<p class="insight">Wind speed vs distance from anchor. Color encodes depth (darker = deeper). Correlation r = CORR_WS_2.</p>
<!-- 6. Swing Dynamics -->
<h2>6. Swing Dynamics / Oscillation</h2>
<div class="chart-row">
<div class="chart-box"><canvas id="swingHdg" height="250"></canvas></div>
<div class="chart-box"><canvas id="swingRate" height="250"></canvas></div>
</div>
<p class="insight">Left: heading over time. Right: heading rate of change (°/s, clipped ±5). Estimated swing period: SWING_PERIOD_2.</p>
<!-- 7. Polar -->
<h2>7. Polar Position Plot</h2>
<div class="chart-row">
<div class="chart-box full"><canvas id="polarChart" height="450"></canvas></div>
</div>
<p class="insight">Vessel bearing and distance from anchor in polar coordinates. Red markers show wind direction samples to compare occupied sectors with wind.</p>
<script>
const D = DATA_JSON;
// Color helpers
function hourColor(h, maxH) {
const t = h / maxH;
const r = Math.round(30 + 225 * t);
const g = Math.round(100 + 155 * t);
const b = Math.round(247 - 200 * t);
return `rgba(${r},${g},${b},0.55)`;
}
function depthColor(d, minD, maxD) {
const t = (d - minD) / (maxD - minD + 0.01);
const r = Math.round(79 + 176 * t);
const g = Math.round(195 - 100 * t);
const b = Math.round(247 - 200 * t);
return `rgba(${r},${g},${b},0.6)`;
}
const commonOpts = {
responsive: true,
plugins: { legend: { labels: { color: '#8a8d9a' } } },
scales: {
x: { ticks: { color: '#8a8d9a' }, grid: { color: '#2a2d3a' } },
y: { ticks: { color: '#8a8d9a' }, grid: { color: '#2a2d3a' } },
},
};
// 1. Track scatter
(function() {
const maxH = Math.max(...D.track.hours);
const colors = D.track.hours.map(h => hourColor(h, maxH));
const data = D.track.x.map((x, i) => ({ x, y: D.track.y[i] }));
const datasets = [{
label: 'Vessel position (ft)',
data, backgroundColor: colors,
pointRadius: 1.5, showLine: false,
}];
if (D.track.arc_radius) {
const pts = [];
for (let a = 0; a <= 360; a += 2) {
const rad = a * Math.PI / 180;
pts.push({ x: D.track.arc_cx + D.track.arc_radius * Math.cos(rad),
y: D.track.arc_cy + D.track.arc_radius * Math.sin(rad) });
}
datasets.push({
label: `Arc fit r=${D.track.arc_radius} ft`,
data: pts, borderColor: '#f4845f', borderDash: [6, 3],
pointRadius: 0, showLine: true, fill: false, borderWidth: 1.5,
});
}
datasets.push({
label: 'Anchor', data: [{ x: 0, y: 0 }],
backgroundColor: '#ff5252', pointRadius: 7, pointStyle: 'crossRot',
showLine: false,
});
new Chart('trackChart', {
type: 'scatter', data: { datasets },
options: {
...commonOpts, aspectRatio: 1.2,
scales: {
x: { title: { display: true, text: 'East-West (ft)', color: '#8a8d9a' }, ticks: { color: '#8a8d9a' }, grid: { color: '#2a2d3a' } },
y: { title: { display: true, text: 'North-South (ft)', color: '#8a8d9a' }, ticks: { color: '#8a8d9a' }, grid: { color: '#2a2d3a' } },
},
},
});
})();
// 2a. Weathervane time series
new Chart('wvTimeSeries', {
type: 'scatter',
data: {
datasets: [
{ label: 'Heading', data: D.weathervane.hours.map((h, i) => ({ x: h, y: D.weathervane.hdg[i] })),
backgroundColor: 'rgba(79,195,247,0.3)', pointRadius: 1, showLine: false },
{ label: 'Wind Dir', data: D.weathervane.hours.map((h, i) => ({ x: h, y: D.weathervane.wd[i] })),
backgroundColor: 'rgba(244,132,95,0.3)', pointRadius: 1, showLine: false },
],
},
options: {
...commonOpts,
scales: {
x: { title: { display: true, text: 'Hours', color: '#8a8d9a' }, ticks: { color: '#8a8d9a' }, grid: { color: '#2a2d3a' } },
y: { title: { display: true, text: 'Degrees', color: '#8a8d9a' }, min: 0, max: 360, ticks: { color: '#8a8d9a' }, grid: { color: '#2a2d3a' } },
},
},
});
// 2b. Heading offset histogram
new Chart('wvHist', {
type: 'bar',
data: {
labels: D.weathervane.hist_labels,
datasets: [{ label: 'Offset count', data: D.weathervane.hist_counts,
backgroundColor: 'rgba(79,195,247,0.6)', borderColor: 'rgba(79,195,247,0.9)', borderWidth: 1 }],
},
options: {
...commonOpts,
scales: {
x: { title: { display: true, text: 'Heading Wind (°)', color: '#8a8d9a' }, ticks: { color: '#8a8d9a', maxRotation: 45 }, grid: { color: '#2a2d3a' } },
y: { title: { display: true, text: 'Count', color: '#8a8d9a' }, ticks: { color: '#8a8d9a' }, grid: { color: '#2a2d3a' } },
},
},
});
// 3. Catenary validation
new Chart('catChart', {
type: 'scatter',
data: {
datasets: [
{ label: 'Measured', data: D.catenary.depth.map((d, i) => ({ x: d, y: D.catenary.measured_dist[i] })),
backgroundColor: 'rgba(79,195,247,0.25)', pointRadius: 1.5, showLine: false },
{ label: `Theory (ws=${D.catenary.ws_median} kts)`,
data: D.catenary.theory_depth.map((d, i) => ({ x: d, y: D.catenary.theory_dist[i] })),
borderColor: '#f4845f', pointRadius: 0, showLine: true, fill: false, borderWidth: 2 },
],
},
options: {
...commonOpts,
scales: {
x: { title: { display: true, text: 'Depth (ft)', color: '#8a8d9a' }, ticks: { color: '#8a8d9a' }, grid: { color: '#2a2d3a' } },
y: { title: { display: true, text: 'Distance from anchor (ft)', color: '#8a8d9a' }, ticks: { color: '#8a8d9a' }, grid: { color: '#2a2d3a' } },
},
},
});
// 4. Tide + distance
new Chart('tideChart', {
type: 'scatter',
data: {
datasets: [
{ label: 'Depth (ft)', data: D.tide.depth_hours.map((h, i) => ({ x: h, y: D.tide.depth[i] })),
borderColor: 'rgba(79,195,247,0.7)', backgroundColor: 'rgba(79,195,247,0.15)', pointRadius: 0, showLine: true, fill: false, borderWidth: 1.5, yAxisID: 'y' },
{ label: 'Distance (ft)', data: D.tide.dist_hours.map((h, i) => ({ x: h, y: D.tide.dist[i] })),
borderColor: 'rgba(244,132,95,0.7)', backgroundColor: 'rgba(244,132,95,0.15)', pointRadius: 0, showLine: true, fill: false, borderWidth: 1.5, yAxisID: 'y1' },
],
},
options: {
...commonOpts,
scales: {
x: { title: { display: true, text: 'Hours', color: '#8a8d9a' }, ticks: { color: '#8a8d9a' }, grid: { color: '#2a2d3a' } },
y: { position: 'left', title: { display: true, text: 'Depth (ft)', color: '#4fc3f7' }, ticks: { color: '#4fc3f7' }, grid: { color: '#2a2d3a' } },
y1: { position: 'right', title: { display: true, text: 'Distance (ft)', color: '#f4845f' }, ticks: { color: '#f4845f' }, grid: { drawOnChartArea: false } },
},
},
});
// 5. Wind effects
(function() {
const minD = Math.min(...D.wind_fx.depth);
const maxD = Math.max(...D.wind_fx.depth);
const colors = D.wind_fx.depth.map(d => depthColor(d, minD, maxD));
new Chart('windChart', {
type: 'scatter',
data: {
datasets: [{ label: 'Wind vs Distance', data: D.wind_fx.ws.map((w, i) => ({ x: w, y: D.wind_fx.dist[i] })),
backgroundColor: colors, pointRadius: 2, showLine: false }],
},
options: {
...commonOpts,
scales: {
x: { title: { display: true, text: 'Wind speed (kts)', color: '#8a8d9a' }, ticks: { color: '#8a8d9a' }, grid: { color: '#2a2d3a' } },
y: { title: { display: true, text: 'Distance from anchor (ft)', color: '#8a8d9a' }, ticks: { color: '#8a8d9a' }, grid: { color: '#2a2d3a' } },
},
},
});
})();
// 6a. Heading over time
new Chart('swingHdg', {
type: 'scatter',
data: {
datasets: [{ label: 'Heading', data: D.swing.hours.map((h, i) => ({ x: h, y: D.swing.hdg[i] })),
backgroundColor: 'rgba(195,154,244,0.3)', pointRadius: 1, showLine: false }],
},
options: {
...commonOpts,
scales: {
x: { title: { display: true, text: 'Hours', color: '#8a8d9a' }, ticks: { color: '#8a8d9a' }, grid: { color: '#2a2d3a' } },
y: { title: { display: true, text: 'Heading (°)', color: '#8a8d9a' }, min: 0, max: 360, ticks: { color: '#8a8d9a' }, grid: { color: '#2a2d3a' } },
},
},
});
// 6b. Heading rate
new Chart('swingRate', {
type: 'scatter',
data: {
datasets: [{ label: 'Heading rate (°/s)', data: D.swing.hours.map((h, i) => ({ x: h, y: D.swing.hdg_rate[i] })),
backgroundColor: 'rgba(126,200,160,0.3)', pointRadius: 1, showLine: false }],
},
options: {
...commonOpts,
scales: {
x: { title: { display: true, text: 'Hours', color: '#8a8d9a' }, ticks: { color: '#8a8d9a' }, grid: { color: '#2a2d3a' } },
y: { title: { display: true, text: '°/s', color: '#8a8d9a' }, ticks: { color: '#8a8d9a' }, grid: { color: '#2a2d3a' } },
},
},
});
// 7. Polar as XY scatter with polar feel
(function() {
const data = D.polar.bearing.map((b, i) => {
const rad = (90 - b) * Math.PI / 180;
return { x: D.polar.radius[i] * Math.cos(rad), y: D.polar.radius[i] * Math.sin(rad) };
});
const wdData = D.polar.wd.map(b => {
const rad = (90 - b) * Math.PI / 180;
return { x: 170 * Math.cos(rad), y: 170 * Math.sin(rad) };
});
const ringPts = (r) => {
const pts = [];
for (let a = 0; a <= 360; a += 2) { const rad = a * Math.PI / 180; pts.push({ x: r * Math.cos(rad), y: r * Math.sin(rad) }); }
return pts;
};
new Chart('polarChart', {
type: 'scatter',
data: {
datasets: [
{ label: 'Vessel position', data, backgroundColor: 'rgba(79,195,247,0.3)', pointRadius: 1.5, showLine: false },
{ label: 'Wind direction', data: wdData, backgroundColor: 'rgba(244,132,95,0.5)', pointRadius: 3, pointStyle: 'triangle', showLine: false },
{ label: '50 ft', data: ringPts(50), borderColor: '#2a2d3a', pointRadius: 0, showLine: true, fill: false, borderWidth: 0.7, borderDash: [3, 3] },
{ label: '100 ft', data: ringPts(100), borderColor: '#2a2d3a', pointRadius: 0, showLine: true, fill: false, borderWidth: 0.7, borderDash: [3, 3] },
{ label: '150 ft', data: ringPts(150), borderColor: '#2a2d3a', pointRadius: 0, showLine: true, fill: false, borderWidth: 0.7, borderDash: [3, 3] },
{ label: 'Anchor', data: [{ x: 0, y: 0 }], backgroundColor: '#ff5252', pointRadius: 7, pointStyle: 'crossRot', showLine: false },
],
},
options: {
...commonOpts, aspectRatio: 1,
plugins: {
legend: { labels: { color: '#8a8d9a', filter: (item) => !['50 ft', '100 ft', '150 ft'].includes(item.text) } },
},
scales: {
x: { title: { display: true, text: 'East-West (ft)', color: '#8a8d9a' }, ticks: { color: '#8a8d9a' }, grid: { color: '#1a1d27' } },
y: { title: { display: true, text: 'North-South (ft)', color: '#8a8d9a' }, ticks: { color: '#8a8d9a' }, grid: { color: '#1a1d27' } },
},
},
});
})();
</script>
</body>
</html>"""
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()

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

752
dbus-anchor-alarm/anchor_alarm.py Executable file
View File

@@ -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()

View File

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

View File

@@ -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@<device-ip>:/data/"
echo " ssh root@<device-ip>"
echo " cd /data && tar -xzf $TARBALL"
echo " bash /data/$PACKAGE_NAME/install.sh"

View File

@@ -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,
)

View File

@@ -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'

View File

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

View File

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

119
dbus-anchor-alarm/install.sh Executable file
View File

@@ -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 ""

View File

@@ -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(),
)

View File

@@ -0,0 +1,2 @@
#!/bin/sh
exec multilog t s25000 n4 /var/log/dbus-anchor-alarm

5
dbus-anchor-alarm/service/run Executable file
View File

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

View File

@@ -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()

View File

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

32
dbus-anchor-alarm/uninstall.sh Executable file
View File

@@ -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 ""

View File

@@ -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://<cerbo-ip>:8088`:
```bash
# Install with web UI
./install.sh --webui
```
## Tuning
### Overload Detection

View File

@@ -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@<cerbo-ip>:/data/
2. SSH to CerboGX: ssh root@<cerbo-ip>
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@<cerbo-ip>"
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 ""

View File

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

View File

@@ -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):

View File

@@ -8,7 +8,6 @@
# ./deploy.sh <cerbo-ip> [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

View File

@@ -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://<cerbo-ip>: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://<cerbo-ip>:8088"
echo " svstat $SERVICE_DIR/dbus-generator-ramp-webui"
echo ""
fi
echo "MQTT Paths:"
echo " N/<vrm-id>/generatorramp/0/State"
echo " N/<vrm-id>/generatorramp/0/CurrentLimit"

View File

@@ -1,2 +0,0 @@
#!/bin/sh
exec multilog t s25000 n4 /var/log/dbus-generator-ramp-webui

View File

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

View File

@@ -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 ""

View File

@@ -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://<cerbo-ip>: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 = '''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Generator Ramp Controller</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #eee;
padding: 20px;
min-height: 100vh;
}
.container { max-width: 800px; margin: 0 auto; }
h1 {
color: #4fc3f7;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
h1::before { content: ""; }
.card {
background: #16213e;
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
}
.card h2 {
color: #4fc3f7;
margin-bottom: 15px;
font-size: 1.1em;
text-transform: uppercase;
letter-spacing: 1px;
}
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
}
.status-item {
background: #0f3460;
padding: 15px;
border-radius: 8px;
text-align: center;
}
.status-item .label {
font-size: 0.8em;
color: #888;
margin-bottom: 5px;
}
.status-item .value {
font-size: 1.5em;
font-weight: bold;
color: #4fc3f7;
}
.status-item.warning .value { color: #ffa726; }
.status-item.error .value { color: #ef5350; }
.status-item.success .value { color: #66bb6a; }
.state-badge {
display: inline-block;
padding: 8px 16px;
border-radius: 20px;
font-weight: bold;
font-size: 1.2em;
}
.state-idle { background: #455a64; }
.state-warmup { background: #ff9800; color: #000; }
.state-ramping { background: #2196f3; }
.state-cooldown { background: #9c27b0; }
.state-recovery { background: #ff5722; }
.state-stable { background: #4caf50; }
.progress-bar {
height: 20px;
background: #0f3460;
border-radius: 10px;
overflow: hidden;
margin-top: 10px;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #4fc3f7, #66bb6a);
transition: width 0.5s ease;
}
.settings-form {
display: grid;
gap: 15px;
}
.form-group {
display: grid;
grid-template-columns: 1fr 120px;
align-items: center;
gap: 10px;
}
.form-group label {
color: #aaa;
}
.form-group input, .form-group select {
background: #0f3460;
border: 1px solid #4fc3f7;
color: #fff;
padding: 10px;
border-radius: 6px;
font-size: 1em;
}
.form-group input:focus {
outline: none;
border-color: #66bb6a;
}
.btn {
background: #4fc3f7;
color: #000;
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 1em;
font-weight: bold;
cursor: pointer;
transition: background 0.2s;
}
.btn:hover { background: #81d4fa; }
.btn:disabled { background: #455a64; cursor: not-allowed; }
.btn-danger { background: #ef5350; color: #fff; }
.btn-danger:hover { background: #e57373; }
.btn-success { background: #66bb6a; }
.btn-success:hover { background: #81c784; }
.power-display {
display: flex;
justify-content: space-around;
text-align: center;
padding: 10px 0;
}
.power-item .value {
font-size: 2em;
font-weight: bold;
color: #ffa726;
}
.power-item .label {
font-size: 0.9em;
color: #888;
}
.detection-status {
display: flex;
align-items: center;
gap: 20px;
padding: 10px;
background: #0f3460;
border-radius: 8px;
}
.detection-indicator {
width: 20px;
height: 20px;
border-radius: 50%;
background: #66bb6a;
}
.detection-indicator.overload {
background: #ef5350;
animation: pulse 0.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.refresh-info {
text-align: center;
color: #666;
font-size: 0.8em;
margin-top: 20px;
}
.enabled-toggle {
display: flex;
align-items: center;
gap: 10px;
}
.toggle-switch {
position: relative;
width: 60px;
height: 30px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0; left: 0; right: 0; bottom: 0;
background: #455a64;
border-radius: 30px;
transition: 0.3s;
}
.toggle-slider::before {
position: absolute;
content: "";
height: 22px;
width: 22px;
left: 4px;
bottom: 4px;
background: white;
border-radius: 50%;
transition: 0.3s;
}
.toggle-switch input:checked + .toggle-slider {
background: #66bb6a;
}
.toggle-switch input:checked + .toggle-slider::before {
transform: translateX(30px);
}
</style>
</head>
<body>
<div class="container">
<h1>Generator Ramp Controller</h1>
<!-- Current State -->
<div class="card">
<h2>Current State</h2>
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px;">
<span id="state-badge" class="state-badge state-idle">Idle</span>
<div class="enabled-toggle">
<span>Controller:</span>
<label class="toggle-switch">
<input type="checkbox" id="enabled-toggle" onchange="toggleEnabled()">
<span class="toggle-slider"></span>
</label>
<span id="enabled-text">Enabled</span>
</div>
</div>
<div class="status-grid" style="margin-top: 20px;">
<div class="status-item">
<div class="label">Current Limit</div>
<div class="value" id="current-limit">--</div>
</div>
<div class="status-item">
<div class="label">Target</div>
<div class="value" id="target-limit">--</div>
</div>
<div class="status-item">
<div class="label">Overload Count</div>
<div class="value" id="overload-count">0</div>
</div>
<div class="status-item">
<div class="label">Generator</div>
<div class="value" id="generator-state">--</div>
</div>
</div>
<!-- Progress Bar -->
<div style="margin-top: 20px;">
<div style="display: flex; justify-content: space-between;">
<span>Ramp Progress</span>
<span id="progress-text">0%</span>
</div>
<div class="progress-bar">
<div class="progress-fill" id="progress-bar" style="width: 0%"></div>
</div>
<div style="text-align: right; color: #888; font-size: 0.9em; margin-top: 5px;">
Time remaining: <span id="time-remaining">--</span>
</div>
</div>
</div>
<!-- Power Monitoring -->
<div class="card">
<h2>Power Monitoring</h2>
<div class="power-display">
<div class="power-item">
<div class="value" id="power-l1">0</div>
<div class="label">L1 (W)</div>
</div>
<div class="power-item">
<div class="value" id="power-l2">0</div>
<div class="label">L2 (W)</div>
</div>
<div class="power-item">
<div class="value" id="power-total">0</div>
<div class="label">Total (W)</div>
</div>
</div>
<div class="detection-status" style="margin-top: 15px;">
<div class="detection-indicator" id="overload-indicator"></div>
<div>
<div>Overload Detection: <span id="detection-status">Normal</span></div>
<div style="color: #888; font-size: 0.9em;">
Reversals: <span id="reversals">0</span> |
Std Dev: <span id="std-dev">0</span>W
</div>
</div>
</div>
</div>
<!-- Settings -->
<div class="card">
<h2>Settings</h2>
<form class="settings-form" onsubmit="saveSettings(event)">
<div class="form-group">
<label>Initial Current (A)</label>
<input type="number" id="initial-current" min="10" max="100" step="1" value="40">
</div>
<div class="form-group">
<label>Target Current (A)</label>
<input type="number" id="target-current" min="10" max="100" step="1" value="50">
</div>
<div class="form-group">
<label>Ramp Duration (min)</label>
<input type="number" id="ramp-duration" min="1" max="120" step="1" value="30">
</div>
<div class="form-group">
<label>Cooldown Duration (min)</label>
<input type="number" id="cooldown-duration" min="1" max="30" step="1" value="5">
</div>
<div style="display: flex; gap: 10px; margin-top: 10px;">
<button type="submit" class="btn btn-success">Save Settings</button>
<button type="button" class="btn" onclick="loadStatus()">Refresh</button>
</div>
</form>
</div>
<div class="refresh-info">
Auto-refreshes every 2 seconds | Last update: <span id="last-update">--</span>
</div>
</div>
<script>
const STATE_NAMES = ['Idle', 'Warm-up', 'Ramping', 'Cooldown', 'Recovery', 'Stable'];
const STATE_CLASSES = ['idle', 'warmup', 'ramping', 'cooldown', 'recovery', 'stable'];
const GEN_STATES = {0: 'Stopped', 1: 'Running', 2: 'Warm-up', 3: 'Cool-down', 10: 'Error'};
async function loadStatus() {
try {
const resp = await fetch('/api/status');
const data = await resp.json();
updateUI(data);
} catch (e) {
console.error('Failed to load status:', e);
}
}
function updateUI(data) {
// State
const state = data.State || 0;
const stateBadge = document.getElementById('state-badge');
stateBadge.textContent = STATE_NAMES[state] || 'Unknown';
stateBadge.className = 'state-badge state-' + (STATE_CLASSES[state] || 'idle');
// Enabled
const enabled = data.Settings_Enabled;
document.getElementById('enabled-toggle').checked = enabled;
document.getElementById('enabled-text').textContent = enabled ? 'Enabled' : 'Disabled';
// Status values
document.getElementById('current-limit').textContent =
(data.CurrentLimit || 0).toFixed(1) + 'A';
document.getElementById('target-limit').textContent =
(data.TargetLimit || 0).toFixed(1) + 'A';
document.getElementById('overload-count').textContent = data.OverloadCount || 0;
document.getElementById('generator-state').textContent =
GEN_STATES[data.Generator_State] || 'Unknown';
// Progress
const progress = data.Ramp_Progress || 0;
document.getElementById('progress-bar').style.width = progress + '%';
document.getElementById('progress-text').textContent = progress + '%';
const remaining = data.Ramp_TimeRemaining || 0;
const mins = Math.floor(remaining / 60);
const secs = remaining % 60;
document.getElementById('time-remaining').textContent =
remaining > 0 ? `${mins}m ${secs}s` : '--';
// Power
document.getElementById('power-l1').textContent = Math.round(data.Power_L1 || 0);
document.getElementById('power-l2').textContent = Math.round(data.Power_L2 || 0);
document.getElementById('power-total').textContent = Math.round(data.Power_Total || 0);
// Detection
const isOverload = data.Detection_IsOverload;
const indicator = document.getElementById('overload-indicator');
indicator.className = 'detection-indicator' + (isOverload ? ' overload' : '');
document.getElementById('detection-status').textContent = isOverload ? 'OVERLOAD!' : 'Normal';
document.getElementById('reversals').textContent = data.Detection_Reversals || 0;
document.getElementById('std-dev').textContent = (data.Detection_StdDev || 0).toFixed(1);
// Settings
document.getElementById('initial-current').value = data.Settings_InitialCurrent || 40;
document.getElementById('target-current').value = data.Settings_TargetCurrent || 50;
document.getElementById('ramp-duration').value = data.Settings_RampDuration || 30;
document.getElementById('cooldown-duration').value = data.Settings_CooldownDuration || 5;
// Timestamp
document.getElementById('last-update').textContent = new Date().toLocaleTimeString();
}
async function toggleEnabled() {
const enabled = document.getElementById('enabled-toggle').checked;
try {
await fetch('/api/settings', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({'/Settings/Enabled': enabled ? 1 : 0})
});
} catch (e) {
console.error('Failed to toggle enabled:', e);
}
}
async function saveSettings(event) {
event.preventDefault();
const settings = {
'/Settings/InitialCurrent': parseFloat(document.getElementById('initial-current').value),
'/Settings/TargetCurrent': parseFloat(document.getElementById('target-current').value),
'/Settings/RampDuration': parseInt(document.getElementById('ramp-duration').value),
'/Settings/CooldownDuration': parseInt(document.getElementById('cooldown-duration').value),
};
try {
const resp = await fetch('/api/settings', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(settings)
});
if (resp.ok) {
alert('Settings saved!');
loadStatus();
}
} catch (e) {
console.error('Failed to save settings:', e);
alert('Failed to save settings');
}
}
// Initial load and auto-refresh
loadStatus();
setInterval(loadStatus, 2000);
</script>
</body>
</html>
'''
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()

Binary file not shown.

View File

@@ -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",

View File

@@ -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",

View File

@@ -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) => {
<AlarmView />
</React.Suspense>
)
case AppViews.CUSTOM_ANCHOR_ALARM:
return (
<React.Suspense fallback={<Connecting />}>
<AnchorAlarmView />
</React.Suspense>
)
default:
return <RootView />
}

View File

@@ -205,6 +205,17 @@ const SettingsMenu = () => {
>
Generator Ramp
</Button>
<Button
onClick={() => {
appViewsStore.setView(AppViews.CUSTOM_ANCHOR_ALARM)
setIsModalOpen(false)
}}
className="w-full"
size="md"
variant="transparent"
>
Anchor Alarm
</Button>
</div>
</div>
</Modal.Body>

View File

@@ -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 (
<MainLayout title="Anchor Alarm">
<div className="flex items-center justify-center h-full">
<span className="text-content-secondary text-sm">Connecting to MQTT...</span>
</div>
</MainLayout>
)
}
return (
<MainLayout title="Anchor Alarm">
<div className="h-full w-full overflow-hidden flex flex-col">
{/* Map — fills available space */}
<div className="flex-1 min-h-0">
<AnchorMap
vesselLat={aa.vesselLat}
vesselLon={aa.vesselLon}
vesselHeading={aa.vesselHeading}
vesselSpeed={aa.vesselSpeed}
vesselCourse={aa.vesselCourse}
anchorMarkedLat={aa.anchorMarkedLat}
anchorMarkedLon={aa.anchorMarkedLon}
anchorEstimatedLat={aa.anchorEstimatedLat}
anchorEstimatedLon={aa.anchorEstimatedLon}
uncertaintyRadius={aa.uncertaintyRadius}
alarmRadius={aa.settingsAlarmRadius}
alarmActive={aa.alarmActive}
anchorSet={aa.anchorSet}
drift={aa.drift}
track={aa.track}
estimatedHistory={aa.estimatedHistory}
/>
</div>
{/* Bottom panel — joystick + stats */}
<div className="flex flex-col md:flex-row gap-2 p-2">
<div className="w-full md:w-48 shrink-0">
<JoystickPad
anchorSet={aa.anchorSet}
anchorMarkedLat={aa.anchorMarkedLat}
anchorMarkedLon={aa.anchorMarkedLon}
onDrop={() => aa.dropAnchor()}
onAfterDrop={(chainLength) => aa.afterDrop(chainLength)}
onWeigh={() => aa.weighAnchor()}
onMoveAnchor={handleMoveAnchor}
vesselLat={aa.vesselLat}
vesselHeading={aa.vesselHeading}
/>
</div>
<div className="flex-1 min-h-0">
<AnchorStats
anchorSet={aa.anchorSet}
depth={aa.vesselDepth}
scopeRatio={aa.scopeRatio}
chainLength={aa.chainLength}
recommendedScope={aa.recommendedScope}
drift={aa.drift}
uncertaintyRadius={aa.uncertaintyRadius}
windSpeed={aa.vesselWindSpeed}
windDirection={aa.vesselWindDirection}
vesselSpeed={aa.vesselSpeed}
steadyStateLoad={aa.steadyStateLoad}
peakLoad={aa.peakLoad}
swingRate={aa.swingRate}
alarmActive={aa.alarmActive}
alarmType={aa.alarmType as string | null}
alarmMessage={aa.alarmMessage}
estimatedDistance={aa.estimatedDistance}
trackPointCount={aa.trackPointCount}
alarmRadius={aa.settingsAlarmRadius}
onAlarmRadiusChange={aa.setAlarmRadius}
/>
</div>
</div>
</div>
</MainLayout>
)
}
export default AnchorAlarmView

View File

@@ -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 = `
<svg width="28" height="28" viewBox="0 0 28 28" xmlns="http://www.w3.org/2000/svg">
<path d="M14 2 L10 12 L6 24 L14 20 L22 24 L18 12 Z"
fill="white" stroke="#1e293b" stroke-width="1.5" stroke-linejoin="round"/>
</svg>`
function makeVesselIcon(heading: number | null): L.DivIcon {
const rotation = heading ?? 0
return L.divIcon({
className: "",
iconSize: [28, 28],
iconAnchor: [14, 14],
html: `<div style="transform:rotate(${rotation}deg);width:28px;height:28px">${boatSvg}</div>`,
})
}
function makeAnchorIcon(): L.DivIcon {
return L.divIcon({
className: "",
iconSize: [24, 24],
iconAnchor: [12, 12],
html: `<div style="font-size:20px;line-height:24px;text-align:center;filter:drop-shadow(0 1px 2px rgba(0,0,0,.6))">⚓</div>`,
})
}
function makeEstimatedDotIcon(): L.DivIcon {
return L.divIcon({
className: "",
iconSize: [10, 10],
iconAnchor: [5, 5],
html: `<div style="width:10px;height:10px;border-radius:50%;background:#38bdf8;border:1.5px solid #0284c7"></div>`,
})
}
function makeSpeedLabel(speed: number): L.DivIcon {
return L.divIcon({
className: "",
iconSize: [80, 24],
iconAnchor: [40, 12],
html: `<div style="font-size:22px;color:#fff;text-shadow:0 1px 4px rgba(0,0,0,.8);white-space:nowrap;text-align:center;font-weight:600">${speed.toFixed(1)} kts</div>`,
})
}
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 (
<div className="absolute top-2 left-2 z-[1000] flex flex-col gap-1">
<button
type="button"
onClick={() => map.zoomIn()}
className="w-8 h-8 rounded bg-black/60 text-white text-lg font-bold leading-none flex items-center justify-center active:bg-black/80"
>
+
</button>
<button
type="button"
onClick={() => map.zoomOut()}
className="w-8 h-8 rounded bg-black/60 text-white text-lg font-bold leading-none flex items-center justify-center active:bg-black/80"
>
</button>
</div>
)
}
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: `<div style="font-size:20px;color:${color};text-shadow:0 1px 4px rgba(0,0,0,.9);white-space:nowrap;text-align:center;font-weight:600">${label}</div>`,
})
}
const AnchorMap: React.FC<AnchorMapProps> = ({
vesselLat,
vesselLon,
vesselHeading,
vesselSpeed,
vesselCourse,
anchorMarkedLat,
anchorMarkedLon,
anchorEstimatedLat,
anchorEstimatedLon,
uncertaintyRadius,
alarmRadius,
alarmActive,
anchorSet,
drift,
track,
estimatedHistory,
}) => {
const [layer, setLayer] = useState<MapLayer>("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<Array<{ ts: number; val: number }>>([])
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 (
<div className="w-full h-full relative">
<MapContainer
center={center}
zoom={19}
maxZoom={20}
style={{ height: "100%", width: "100%" }}
zoomControl={false}
>
<MapUpdater lat={anchorMarkedLat ?? vesselLat} lon={anchorMarkedLon ?? vesselLon} />
<ZoomControls />
{layer === "street" ? (
<TileLayer
key="osm"
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>'
maxZoom={19}
/>
) : (
<TileLayer
key="esri"
url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
attribution="&copy; Esri"
maxZoom={20}
/>
)}
<TileLayer url="https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png" maxZoom={20} />
{/* Track polyline */}
{trackPositions.length > 1 && (
<Polyline positions={trackPositions} pathOptions={{ color: "#93c5fd", weight: 2, opacity: 0.6 }} />
)}
{/* Estimated history dots */}
{estimatedHistory.map((p, i) => {
const age = (nowMs - p.ts) / 1000
const opacity = Math.max(0.08, 0.5 - age / 7200)
return (
<Circle
key={i}
center={[p.lat, p.lon]}
radius={0.6}
pathOptions={{
color: "#22d3ee",
fillColor: "#22d3ee",
fillOpacity: opacity,
opacity: opacity,
weight: 1,
}}
/>
)
})}
{/* Uncertainty circle */}
{anchorEstimatedLat != null && anchorEstimatedLon != null && uncertaintyRadius != null && (
<Circle
center={[anchorEstimatedLat, anchorEstimatedLon]}
radius={feetToMeters(uncertaintyRadius)}
pathOptions={{ color: "#3b82f6", fillColor: "#3b82f6", fillOpacity: 0.15, opacity: 0.5, weight: 1.5 }}
/>
)}
{/* Alarm radius circle */}
{anchorSet && anchorMarkedLat != null && anchorMarkedLon != null && alarmRadius != null && (
<Circle
center={[anchorMarkedLat, anchorMarkedLon]}
radius={feetToMeters(alarmRadius)}
pathOptions={{
color: alarmActive ? "#ef4444" : "#9ca3af",
fillOpacity: 0,
weight: 2,
dashArray: "8 6",
opacity: 0.8,
}}
/>
)}
{/* Drift line */}
{driftLine && (
<Polyline
positions={driftLine}
pathOptions={{ color: "#f97316", weight: 2, dashArray: "6 4", opacity: 0.9 }}
/>
)}
{/* Heading arrow */}
{headingLine && (
<Polyline positions={headingLine} pathOptions={{ color: "#ffffff", weight: 1.5, opacity: 0.7 }} />
)}
{/* COG arrow */}
{cogLine && cog && (
<Polyline positions={cogLine} pathOptions={{ color: cog.color, weight: cog.weight, opacity: 0.9 }} />
)}
{/* Radial drift arrow (toward/away from anchor) */}
{driftArrow && vesselLat != null && vesselLon != null && (
<Polyline
positions={[[vesselLat, vesselLon], driftArrow.endPoint]}
pathOptions={{ color: driftArrow.color, weight: 3, dashArray: "8 4", opacity: 0.85 }}
/>
)}
{/* Drift label at end of drift arrow */}
{driftArrow && driftLabel && <Marker position={driftArrow.endPoint} icon={driftLabel} interactive={false} />}
{/* Vessel marker */}
{vesselLat != null && vesselLon != null && <Marker position={[vesselLat, vesselLon]} icon={vesselIcon} />}
{/* Speed label at end of COG arrow */}
{cogEndPoint && speedLabel && <Marker position={cogEndPoint} icon={speedLabel} interactive={false} />}
{/* Marked anchor icon */}
{anchorMarkedLat != null && anchorMarkedLon != null && (
<Marker position={[anchorMarkedLat, anchorMarkedLon]} icon={anchorIcon} />
)}
{/* Estimated anchor position dot */}
{anchorEstimatedLat != null && anchorEstimatedLon != null && (
<Marker position={[anchorEstimatedLat, anchorEstimatedLon]} icon={estimatedDotIcon} />
)}
</MapContainer>
{/* Layer toggle */}
<button
type="button"
onClick={toggleLayer}
className="absolute top-2 right-2 z-[1000] rounded bg-black/60 text-white text-xs font-semibold px-2.5 py-1.5 active:bg-black/80 select-none"
>
{layer === "satellite" ? "Map" : "Satellite"}
</button>
</div>
)
}
export default React.memo(AnchorMap)

View File

@@ -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 (
<div className="flex flex-col">
<span className={LABEL}>{label}</span>
<span className={VALUE}>{children}</span>
</div>
)
}
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<AnchorStatsProps> = ({
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 (
<div className="bg-surface-secondary rounded-md px-3 py-2">
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
{/* Row 1 */}
<StatCell label="Depth">{formatNum(depth)} ft</StatCell>
<StatCell label="Scope">{scopeRatio != null ? `${formatNum(scopeRatio)}:1` : "--"}</StatCell>
{/* Row 2 */}
<StatCell label="Chain Out">{formatNum(chainLength)} ft</StatCell>
<StatCell label="Recommended">{formatNum(recommendedScope)} ft</StatCell>
{/* Row 3 */}
<div className="flex flex-col">
<span className={LABEL}>Drift</span>
<span className={`text-sm font-mono ${driftColor(drift)}`}>{formatNum(drift)} ft</span>
</div>
<StatCell label="Uncertainty">±{formatNum(uncertaintyRadius)} ft</StatCell>
{/* Row 4 */}
<StatCell label="Wind">
{windSpeed != null
? `${formatNum(windSpeed * MS_TO_KTS)} kts${windDirection != null ? ` ${compassDirection(windDirection)}` : ""}`
: "--"}
</StatCell>
<StatCell label="Speed">{formatNum(vesselSpeed)} kts</StatCell>
{/* Row 5 */}
<StatCell label="Steady Load">{formatNum(steadyStateLoad, 0)} lbs</StatCell>
<div className="flex flex-col">
<span className={LABEL}>Peak Load</span>
<span className={`text-sm font-mono ${peakOverloaded ? "text-red-500" : "text-content-primary"}`}>
{formatNum(peakLoad, 0)} lbs
</span>
</div>
{/* Row 6 */}
<StatCell label="Est. Distance">{formatNum(estimatedDistance)} ft</StatCell>
<div className="flex flex-col">
<span className={LABEL}>Alarm Radius</span>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() =>
onAlarmRadiusChange(Math.max(ALARM_RADIUS_MIN, (alarmRadius ?? ALARM_RADIUS_MIN) - ALARM_RADIUS_STEP))
}
className="rounded bg-surface-primary px-1.5 py-0.5 text-content-primary text-xs font-bold active:opacity-70 leading-none"
>
</button>
<span className="text-sm font-mono text-content-primary min-w-[3ch] text-center">
{alarmRadius != null ? alarmRadius : "--"}
</span>
<button
type="button"
onClick={() =>
onAlarmRadiusChange(Math.min(ALARM_RADIUS_MAX, (alarmRadius ?? ALARM_RADIUS_MIN) + ALARM_RADIUS_STEP))
}
className="rounded bg-surface-primary px-1.5 py-0.5 text-content-primary text-xs font-bold active:opacity-70 leading-none"
>
+
</button>
<span className="text-content-secondary text-2xs ml-0.5">ft</span>
</div>
</div>
</div>
{/* Alarm banner */}
{alarmActive && (
<div className="mt-1 rounded bg-red-600 text-white px-3 py-1.5 text-sm font-bold animate-pulse">
{alarmType && <span className="uppercase mr-2">{alarmType}</span>}
{alarmMessage && <span>{alarmMessage}</span>}
</div>
)}
{/* Track points */}
<div className="mt-1">
<span className="text-content-tertiary text-2xs">Track Points: </span>
<span className="text-content-secondary text-2xs font-mono">{trackPointCount ?? "--"}</span>
</div>
</div>
)
}
export default React.memo(AnchorStats)

View File

@@ -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<JoystickPadProps> = ({
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<ReturnType<typeof setTimeout> | null>(null)
const repeatRef = useRef<ReturnType<typeof setTimeout> | 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) => (
<button
type="button"
disabled={moveDisabled}
onPointerDown={() => !moveDisabled && startRepeat(dir)}
onPointerUp={stopRepeat}
onPointerLeave={stopRepeat}
onPointerCancel={stopRepeat}
className={`flex items-center justify-center rounded-md text-sm font-bold select-none
${moveDisabled ? "bg-content-secondary/20 text-content-secondary/40" : "bg-surface-primary text-content-primary active:bg-content-secondary/30"}
${extraClass}`}
style={{ touchAction: "none" }}
>
{label}
</button>
)
return (
<div className="flex flex-col items-center gap-3">
{/* Directional pad */}
<div className="grid grid-cols-3 grid-rows-3 gap-1" style={{ width: 120, height: 120 }}>
<div />
{dirBtn("N", "N", "col-start-2")}
<div />
{dirBtn("W", "W", "")}
<div className="flex items-center justify-center text-content-secondary/30 text-2xs select-none">+</div>
{dirBtn("E", "E", "")}
<div />
{dirBtn("S", "S", "col-start-2")}
<div />
</div>
{/* Buttons */}
<div className="w-full flex flex-col gap-2">
{!anchorSet && (
<>
<button
type="button"
onClick={onDrop}
className="w-full rounded-md py-2 text-sm font-medium text-white bg-green-600 active:bg-green-700"
>
Drop
</button>
{!showAfterDropForm ? (
<button
type="button"
onClick={() => setShowAfterDropForm(true)}
className="w-full rounded-md py-2 text-sm font-medium text-gray-900 bg-amber-400 active:bg-amber-500"
>
After Drop
</button>
) : (
<div className="bg-surface-secondary rounded-md p-3 flex flex-col gap-2">
<label className="text-xs text-content-secondary">Chain length (ft)</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setChainLength((v) => Math.max(0, v - CHAIN_STEP))}
className="rounded-md bg-surface-primary px-3 py-1 text-content-primary text-sm font-bold active:opacity-70"
>
</button>
<input
type="number"
value={chainLength}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => setChainLength((v) => v + CHAIN_STEP)}
className="rounded-md bg-surface-primary px-3 py-1 text-content-primary text-sm font-bold active:opacity-70"
>
+
</button>
</div>
<p className="text-2xs text-content-secondary">Verify bow is pointed at anchor</p>
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleAfterDropSet}
className="rounded-md py-1.5 px-4 text-sm font-medium text-gray-900 bg-amber-400 active:bg-amber-500"
>
Set
</button>
<button
type="button"
onClick={() => setShowAfterDropForm(false)}
className="text-sm text-content-secondary underline"
>
Cancel
</button>
</div>
</div>
)}
</>
)}
{showWeigh && (
<button
type="button"
onClick={handleWeigh}
className={`w-full rounded-md py-2 text-sm font-medium text-white ${
weighConfirmPending ? "bg-red-700 animate-pulse" : "bg-red-600 active:bg-red-700"
}`}
>
{weighConfirmPending ? "Confirm?" : "Weigh Anchor"}
</button>
)}
</div>
</div>
)
}
export default JoystickPad

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

View File

@@ -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;
}
}

View File

@@ -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<AppViews, string>([
@@ -38,6 +39,7 @@ export const AppViewTitleKeys = new Map<AppViews, string>([
[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 {

View File

@@ -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,
}
}