updates and fixes
This commit is contained in:
24
dbus-anchor-alarm/.gitignore
vendored
Normal file
24
dbus-anchor-alarm/.gitignore
vendored
Normal 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/
|
||||
671
dbus-anchor-alarm/analysis/analyze_anchor.py
Normal file
671
dbus-anchor-alarm/analysis/analyze_anchor.py
Normal 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 • Depth: DEPTH_RANGE ft • Wind: WIND_RANGE kts • 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±std)</div><div class="value">OFFSET_STATS°</div></div>
|
||||
<div class="stat-card"><div class="label">Wind↔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→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 − 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) & 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()
|
||||
414
dbus-anchor-alarm/analysis/anchor_debug.html
Normal file
414
dbus-anchor-alarm/analysis/anchor_debug.html
Normal file
File diff suppressed because one or more lines are too long
348
dbus-anchor-alarm/analysis/anchor_report.html
Normal file
348
dbus-anchor-alarm/analysis/anchor_report.html
Normal file
File diff suppressed because one or more lines are too long
1208
dbus-anchor-alarm/analysis/live_track_debug.py
Normal file
1208
dbus-anchor-alarm/analysis/live_track_debug.py
Normal file
File diff suppressed because it is too large
Load Diff
752
dbus-anchor-alarm/anchor_alarm.py
Executable file
752
dbus-anchor-alarm/anchor_alarm.py
Executable 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()
|
||||
431
dbus-anchor-alarm/anchor_tracker.py
Normal file
431
dbus-anchor-alarm/anchor_tracker.py
Normal 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
|
||||
80
dbus-anchor-alarm/build-package.sh
Executable file
80
dbus-anchor-alarm/build-package.sh
Executable 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"
|
||||
157
dbus-anchor-alarm/catenary.py
Normal file
157
dbus-anchor-alarm/catenary.py
Normal 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,
|
||||
)
|
||||
42
dbus-anchor-alarm/config.py
Normal file
42
dbus-anchor-alarm/config.py
Normal 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'
|
||||
166
dbus-anchor-alarm/debug_logger.py
Normal file
166
dbus-anchor-alarm/debug_logger.py
Normal 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
|
||||
269
dbus-anchor-alarm/drag_detector.py
Normal file
269
dbus-anchor-alarm/drag_detector.py
Normal 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
119
dbus-anchor-alarm/install.sh
Executable 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 ""
|
||||
136
dbus-anchor-alarm/sensor_reader.py
Normal file
136
dbus-anchor-alarm/sensor_reader.py
Normal 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(),
|
||||
)
|
||||
2
dbus-anchor-alarm/service/log/run
Executable file
2
dbus-anchor-alarm/service/log/run
Executable 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
5
dbus-anchor-alarm/service/run
Executable 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
|
||||
66
dbus-anchor-alarm/track_buffer.py
Normal file
66
dbus-anchor-alarm/track_buffer.py
Normal 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()
|
||||
397
dbus-anchor-alarm/track_logger.py
Normal file
397
dbus-anchor-alarm/track_logger.py
Normal 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
32
dbus-anchor-alarm/uninstall.sh
Executable 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 ""
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,2 +0,0 @@
|
||||
#!/bin/sh
|
||||
exec multilog t s25000 n4 /var/log/dbus-generator-ramp-webui
|
||||
@@ -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
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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()
|
||||
BIN
venus-data.zip
BIN
venus-data.zip
Binary file not shown.
308
venus-html5-app/package-lock.json
generated
308
venus-html5-app/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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='© <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="© 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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
BIN
venus-html5-app/src/app/Marine2/css/images/layers-2x.png
Normal file
BIN
venus-html5-app/src/app/Marine2/css/images/layers-2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
venus-html5-app/src/app/Marine2/css/images/layers.png
Normal file
BIN
venus-html5-app/src/app/Marine2/css/images/layers.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 696 B |
BIN
venus-html5-app/src/app/Marine2/css/images/marker-icon-2x.png
Normal file
BIN
venus-html5-app/src/app/Marine2/css/images/marker-icon-2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
venus-html5-app/src/app/Marine2/css/images/marker-icon.png
Normal file
BIN
venus-html5-app/src/app/Marine2/css/images/marker-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
venus-html5-app/src/app/Marine2/css/images/marker-shadow.png
Normal file
BIN
venus-html5-app/src/app/Marine2/css/images/marker-shadow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 618 B |
661
venus-html5-app/src/app/Marine2/css/leaflet.css
Normal file
661
venus-html5-app/src/app/Marine2/css/leaflet.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user