Organizes 11 projects for Cerbo GX/Venus OS into a single repository: - axiom-nmea: Raymarine LightHouse protocol decoder - dbus-generator-ramp: Generator current ramp controller - dbus-lightning: Blitzortung lightning monitor - dbus-meteoblue-forecast: Meteoblue weather forecast - dbus-no-foreign-land: noforeignland.com tracking - dbus-tides: Tide prediction from depth + harmonics - dbus-vrm-history: VRM cloud history proxy - dbus-windy-station: Windy.com weather upload - mfd-custom-app: MFD app deployment package - venus-html5-app: Custom Victron HTML5 app fork - watermaker: Watermaker PLC control UI Adds root README, .gitignore, project template, and per-project .gitignore files. Sensitive config files excluded via .gitignore with .example templates provided. Made-with: Cursor
328 lines
11 KiB
Python
328 lines
11 KiB
Python
"""
|
|
Thread-safe sensor data storage.
|
|
|
|
This module provides a thread-safe container for aggregating sensor data
|
|
from multiple decoded packets over time.
|
|
"""
|
|
|
|
import threading
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from typing import Dict, Any, Optional
|
|
|
|
from ..sensors import (
|
|
get_tank_name,
|
|
get_tank_capacity,
|
|
get_battery_name,
|
|
)
|
|
from ..protocol.constants import FEET_TO_M
|
|
|
|
|
|
@dataclass
|
|
class SensorData:
|
|
"""Thread-safe container for current sensor readings.
|
|
|
|
This class aggregates data from multiple decoded packets and provides
|
|
thread-safe access for concurrent updates and reads.
|
|
|
|
Example:
|
|
data = SensorData()
|
|
decoder = RaymarineDecoder()
|
|
|
|
# Update from decoded packet
|
|
result = decoder.decode(packet)
|
|
data.update(result)
|
|
|
|
# Read current values
|
|
print(f"GPS: {data.latitude}, {data.longitude}")
|
|
print(f"Heading: {data.heading_deg}°")
|
|
"""
|
|
|
|
# Position
|
|
latitude: Optional[float] = None
|
|
longitude: Optional[float] = None
|
|
|
|
# Navigation
|
|
heading_deg: Optional[float] = None
|
|
cog_deg: Optional[float] = None
|
|
sog_kts: Optional[float] = None
|
|
|
|
# Wind
|
|
twd_deg: Optional[float] = None # True Wind Direction
|
|
tws_kts: Optional[float] = None # True Wind Speed
|
|
awa_deg: Optional[float] = None # Apparent Wind Angle
|
|
aws_kts: Optional[float] = None # Apparent Wind Speed
|
|
|
|
# Depth (stored in meters internally)
|
|
depth_m: Optional[float] = None
|
|
|
|
# Temperature
|
|
water_temp_c: Optional[float] = None
|
|
air_temp_c: Optional[float] = None
|
|
|
|
# Barometric pressure (stored in mbar internally)
|
|
pressure_mbar: Optional[float] = None
|
|
|
|
# Tanks: dict of tank_id -> level percentage
|
|
tanks: Dict[int, float] = field(default_factory=dict)
|
|
|
|
# Batteries: dict of battery_id -> voltage
|
|
batteries: Dict[int, float] = field(default_factory=dict)
|
|
|
|
# Timestamps for each data type (Unix timestamp)
|
|
gps_time: float = 0
|
|
heading_time: float = 0
|
|
wind_time: float = 0
|
|
depth_time: float = 0
|
|
temp_time: float = 0
|
|
pressure_time: float = 0
|
|
tank_time: float = 0
|
|
battery_time: float = 0
|
|
|
|
# Statistics
|
|
packet_count: int = 0
|
|
decode_count: int = 0
|
|
start_time: float = field(default_factory=time.time)
|
|
|
|
# Thread safety
|
|
_lock: threading.Lock = field(default_factory=threading.Lock)
|
|
|
|
@property
|
|
def depth_ft(self) -> Optional[float]:
|
|
"""Get depth in feet."""
|
|
if self.depth_m is None:
|
|
return None
|
|
return self.depth_m / FEET_TO_M
|
|
|
|
@property
|
|
def water_temp_f(self) -> Optional[float]:
|
|
"""Get water temperature in Fahrenheit."""
|
|
if self.water_temp_c is None:
|
|
return None
|
|
return self.water_temp_c * 9/5 + 32
|
|
|
|
@property
|
|
def air_temp_f(self) -> Optional[float]:
|
|
"""Get air temperature in Fahrenheit."""
|
|
if self.air_temp_c is None:
|
|
return None
|
|
return self.air_temp_c * 9/5 + 32
|
|
|
|
@property
|
|
def pressure_inhg(self) -> Optional[float]:
|
|
"""Get barometric pressure in inches of mercury."""
|
|
if self.pressure_mbar is None:
|
|
return None
|
|
return self.pressure_mbar * 0.02953
|
|
|
|
@property
|
|
def uptime(self) -> float:
|
|
"""Get uptime in seconds."""
|
|
return time.time() - self.start_time
|
|
|
|
def update(self, decoded: 'DecodedData') -> None:
|
|
"""Update sensor data from a decoded packet.
|
|
|
|
Args:
|
|
decoded: DecodedData object from RaymarineDecoder.decode()
|
|
"""
|
|
# Import here to avoid circular import
|
|
from ..protocol.decoder import DecodedData
|
|
|
|
now = time.time()
|
|
|
|
with self._lock:
|
|
self.packet_count += 1
|
|
|
|
if decoded.has_data():
|
|
self.decode_count += 1
|
|
|
|
# Update GPS
|
|
if decoded.latitude is not None and decoded.longitude is not None:
|
|
self.latitude = decoded.latitude
|
|
self.longitude = decoded.longitude
|
|
self.gps_time = now
|
|
|
|
# Update heading
|
|
if decoded.heading_deg is not None:
|
|
self.heading_deg = decoded.heading_deg
|
|
self.heading_time = now
|
|
|
|
# Update COG/SOG
|
|
if decoded.cog_deg is not None:
|
|
self.cog_deg = decoded.cog_deg
|
|
if decoded.sog_kts is not None:
|
|
self.sog_kts = decoded.sog_kts
|
|
|
|
# Update wind
|
|
if (decoded.twd_deg is not None or decoded.tws_kts is not None or
|
|
decoded.aws_kts is not None):
|
|
self.wind_time = now
|
|
if decoded.twd_deg is not None:
|
|
self.twd_deg = decoded.twd_deg
|
|
if decoded.tws_kts is not None:
|
|
self.tws_kts = decoded.tws_kts
|
|
if decoded.awa_deg is not None:
|
|
self.awa_deg = decoded.awa_deg
|
|
if decoded.aws_kts is not None:
|
|
self.aws_kts = decoded.aws_kts
|
|
|
|
# Update depth
|
|
if decoded.depth_m is not None:
|
|
self.depth_m = decoded.depth_m
|
|
self.depth_time = now
|
|
|
|
# Update temperature
|
|
if decoded.water_temp_c is not None or decoded.air_temp_c is not None:
|
|
self.temp_time = now
|
|
if decoded.water_temp_c is not None:
|
|
self.water_temp_c = decoded.water_temp_c
|
|
if decoded.air_temp_c is not None:
|
|
self.air_temp_c = decoded.air_temp_c
|
|
|
|
# Update pressure
|
|
if decoded.pressure_mbar is not None:
|
|
self.pressure_mbar = decoded.pressure_mbar
|
|
self.pressure_time = now
|
|
|
|
# Update tanks
|
|
if decoded.tanks:
|
|
self.tanks.update(decoded.tanks)
|
|
self.tank_time = now
|
|
|
|
# Update batteries
|
|
if decoded.batteries:
|
|
self.batteries.update(decoded.batteries)
|
|
self.battery_time = now
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
"""Convert to dictionary for JSON serialization.
|
|
|
|
Returns:
|
|
Dictionary with all sensor data
|
|
"""
|
|
with self._lock:
|
|
return {
|
|
"timestamp": datetime.now().isoformat(),
|
|
"position": {
|
|
"latitude": self.latitude,
|
|
"longitude": self.longitude,
|
|
},
|
|
"navigation": {
|
|
"heading_deg": round(self.heading_deg, 1) if self.heading_deg else None,
|
|
"cog_deg": round(self.cog_deg, 1) if self.cog_deg else None,
|
|
"sog_kts": round(self.sog_kts, 1) if self.sog_kts else None,
|
|
},
|
|
"wind": {
|
|
"true_direction_deg": round(self.twd_deg, 1) if self.twd_deg else None,
|
|
"true_speed_kts": round(self.tws_kts, 1) if self.tws_kts else None,
|
|
"apparent_angle_deg": round(self.awa_deg, 1) if self.awa_deg else None,
|
|
"apparent_speed_kts": round(self.aws_kts, 1) if self.aws_kts else None,
|
|
},
|
|
"depth": {
|
|
"meters": round(self.depth_m, 1) if self.depth_m else None,
|
|
"feet": round(self.depth_ft, 1) if self.depth_ft else None,
|
|
},
|
|
"temperature": {
|
|
"water_c": round(self.water_temp_c, 1) if self.water_temp_c else None,
|
|
"water_f": round(self.water_temp_f, 1) if self.water_temp_f else None,
|
|
"air_c": round(self.air_temp_c, 1) if self.air_temp_c else None,
|
|
"air_f": round(self.air_temp_f, 1) if self.air_temp_f else None,
|
|
},
|
|
"pressure": {
|
|
"mbar": round(self.pressure_mbar, 1) if self.pressure_mbar else None,
|
|
"inhg": round(self.pressure_inhg, 2) if self.pressure_inhg else None,
|
|
},
|
|
"tanks": {
|
|
str(tank_id): {
|
|
"name": get_tank_name(tank_id),
|
|
"level_pct": round(level, 1),
|
|
"capacity_gal": get_tank_capacity(tank_id),
|
|
} for tank_id, level in self.tanks.items()
|
|
},
|
|
"batteries": {
|
|
str(battery_id): {
|
|
"name": get_battery_name(battery_id),
|
|
"voltage_v": round(voltage, 2),
|
|
} for battery_id, voltage in self.batteries.items()
|
|
},
|
|
"stats": {
|
|
"packets": self.packet_count,
|
|
"decoded": self.decode_count,
|
|
"uptime_s": round(self.uptime, 1),
|
|
}
|
|
}
|
|
|
|
def get_age(self, data_type: str) -> Optional[float]:
|
|
"""Get the age of a data type in seconds.
|
|
|
|
Args:
|
|
data_type: One of 'gps', 'heading', 'wind', 'depth', 'temp',
|
|
'tank', 'battery'
|
|
|
|
Returns:
|
|
Age in seconds, or None if no data has been received
|
|
"""
|
|
time_map = {
|
|
'gps': self.gps_time,
|
|
'heading': self.heading_time,
|
|
'wind': self.wind_time,
|
|
'depth': self.depth_time,
|
|
'temp': self.temp_time,
|
|
'pressure': self.pressure_time,
|
|
'tank': self.tank_time,
|
|
'battery': self.battery_time,
|
|
}
|
|
with self._lock:
|
|
ts = time_map.get(data_type, 0)
|
|
if ts == 0:
|
|
return None
|
|
return time.time() - ts
|
|
|
|
def is_stale(self, data_type: str, max_age: float = 10.0) -> bool:
|
|
"""Check if a data type is stale.
|
|
|
|
Args:
|
|
data_type: One of 'gps', 'heading', 'wind', 'depth', 'temp',
|
|
'tank', 'battery'
|
|
max_age: Maximum age in seconds before data is considered stale
|
|
|
|
Returns:
|
|
True if data is stale or missing
|
|
"""
|
|
age = self.get_age(data_type)
|
|
if age is None:
|
|
return True
|
|
return age > max_age
|
|
|
|
def reset(self) -> None:
|
|
"""Reset all data and statistics."""
|
|
with self._lock:
|
|
self.latitude = None
|
|
self.longitude = None
|
|
self.heading_deg = None
|
|
self.cog_deg = None
|
|
self.sog_kts = None
|
|
self.twd_deg = None
|
|
self.tws_kts = None
|
|
self.awa_deg = None
|
|
self.aws_kts = None
|
|
self.depth_m = None
|
|
self.water_temp_c = None
|
|
self.air_temp_c = None
|
|
self.pressure_mbar = None
|
|
self.tanks.clear()
|
|
self.batteries.clear()
|
|
self.gps_time = 0
|
|
self.heading_time = 0
|
|
self.wind_time = 0
|
|
self.depth_time = 0
|
|
self.temp_time = 0
|
|
self.pressure_time = 0
|
|
self.tank_time = 0
|
|
self.battery_time = 0
|
|
self.packet_count = 0
|
|
self.decode_count = 0
|
|
self.start_time = time.time()
|