Files
venus/axiom-nmea/raymarine_nmea/data/store.py
dev 9756538f16 Initial commit: Venus OS boat addons monorepo
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
2026-03-16 17:04:16 +00:00

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