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
350 lines
13 KiB
Python
350 lines
13 KiB
Python
"""
|
|
Tank D-Bus service for Venus OS.
|
|
|
|
Publishes tank level data to the Venus OS D-Bus using the
|
|
com.victronenergy.tank service type.
|
|
|
|
Each physical tank requires a separate D-Bus service instance.
|
|
|
|
D-Bus paths:
|
|
/Level - Tank level 0-100%
|
|
/Remaining - Remaining volume in m3
|
|
/Status - 0=Ok; 1=Disconnected; 2=Short circuited; 3=Reverse polarity; 4=Unknown
|
|
/Capacity - Tank capacity in m3
|
|
/FluidType - 0=Fuel; 1=Fresh water; 2=Waste water; etc.
|
|
/Standard - 2 (Not applicable for voltage/current sensors)
|
|
|
|
Water tanks (tank_type "water") also publish:
|
|
/Alarms/LowLevel - 0=OK, 2=Alarm (level < 10% for 60s)
|
|
/Alarms/LowLevelAck - Writable. 0=none, 1=acknowledged, 2=snoozed
|
|
/Alarms/LowLevelSnoozeUntil - Writable. Unix timestamp when snooze expires
|
|
|
|
Black water tanks (tank_type "blackwater") also publish:
|
|
/Alarms/HighLevel - 0=OK, 2=Alarm (level > 75% for 60s)
|
|
/Alarms/HighLevelAck - Writable. 0=none, 1=acknowledged, 2=snoozed
|
|
/Alarms/HighLevelSnoozeUntil - Writable. Unix timestamp when snooze expires
|
|
"""
|
|
|
|
import logging
|
|
import time
|
|
from typing import Any, Dict, Optional, List, Tuple
|
|
|
|
from .service import VeDbusServiceBase
|
|
from ..data.store import SensorData
|
|
from ..sensors import TANK_CONFIG, TankInfo, get_tank_name, get_tank_capacity
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Alarm thresholds
|
|
WATER_LOW_LEVEL_THRESHOLD = 10.0 # Alarm if water tank below this percentage
|
|
BLACK_WATER_HIGH_LEVEL_THRESHOLD = 75.0 # Alarm if black water above this percentage
|
|
TANK_ALARM_DELAY = 60.0 # Seconds before alarm triggers (avoids brief dips)
|
|
|
|
# Fuel tank IDs - these get cached when engines are off
|
|
FUEL_TANK_IDS = {1, 2} # Fuel Starboard and Fuel Port
|
|
|
|
# Memory cache for fuel tank levels (persists while service runs)
|
|
# Key: tank_id, Value: (level_percent, timestamp)
|
|
_fuel_tank_cache: Dict[int, Tuple[float, float]] = {}
|
|
|
|
# Fluid type mapping from tank_type string to Victron enum
|
|
FLUID_TYPE_MAP = {
|
|
'fuel': 0, # Fuel
|
|
'water': 1, # Fresh water
|
|
'waste': 2, # Waste water
|
|
'livewell': 3, # Live well
|
|
'oil': 4, # Oil
|
|
'blackwater': 5, # Black water (sewage)
|
|
'gasoline': 6, # Gasoline
|
|
'diesel': 7, # Diesel
|
|
'lpg': 8, # Liquid Petroleum Gas
|
|
'lng': 9, # Liquid Natural Gas
|
|
'hydraulic': 10, # Hydraulic oil
|
|
'rawwater': 11, # Raw water
|
|
}
|
|
|
|
# Gallons to cubic meters
|
|
GALLONS_TO_M3 = 0.00378541
|
|
|
|
|
|
class TankService(VeDbusServiceBase):
|
|
"""Tank D-Bus service for Venus OS.
|
|
|
|
Publishes a single tank's level data to the Venus OS D-Bus.
|
|
Create one instance per physical tank.
|
|
|
|
Example:
|
|
sensor_data = SensorData()
|
|
|
|
# Create service for tank ID 1
|
|
tank_service = TankService(
|
|
sensor_data=sensor_data,
|
|
tank_id=1,
|
|
device_instance=0,
|
|
)
|
|
tank_service.register()
|
|
|
|
# In update loop:
|
|
tank_service.update()
|
|
"""
|
|
|
|
service_type = "tank"
|
|
product_name = "Raymarine Tank Sensor"
|
|
product_id = 0xA142 # Custom product ID for Raymarine Tank
|
|
|
|
# Maximum age in seconds before data is considered stale
|
|
MAX_DATA_AGE = 30.0
|
|
|
|
def __init__(
|
|
self,
|
|
sensor_data: SensorData,
|
|
tank_id: int,
|
|
device_instance: int = 0,
|
|
tank_config: Optional[TankInfo] = None,
|
|
custom_name: Optional[str] = None,
|
|
):
|
|
"""Initialize Tank service.
|
|
|
|
Args:
|
|
sensor_data: SensorData instance to read values from
|
|
tank_id: The Raymarine tank ID
|
|
device_instance: Unique instance number for this tank
|
|
tank_config: Optional TankInfo override (otherwise uses TANK_CONFIG)
|
|
custom_name: Optional custom display name
|
|
"""
|
|
# Get tank configuration
|
|
if tank_config:
|
|
self._tank_config = tank_config
|
|
elif tank_id in TANK_CONFIG:
|
|
self._tank_config = TANK_CONFIG[tank_id]
|
|
else:
|
|
self._tank_config = TankInfo(f"Tank #{tank_id}", None, "fuel")
|
|
|
|
# Use tank name as product name
|
|
self.product_name = self._tank_config.name
|
|
|
|
super().__init__(
|
|
device_instance=device_instance,
|
|
connection=f"Raymarine Tank {tank_id}",
|
|
custom_name=custom_name or self._tank_config.name,
|
|
)
|
|
self._sensor_data = sensor_data
|
|
self._tank_id = tank_id
|
|
|
|
# Check if this is a fuel tank (should use caching)
|
|
self._is_fuel_tank = tank_id in FUEL_TANK_IDS
|
|
|
|
# Alarm tracking -- only for water and blackwater tanks
|
|
tank_type = self._tank_config.tank_type.lower()
|
|
self._has_low_alarm = tank_type == 'water'
|
|
self._has_high_alarm = tank_type == 'blackwater'
|
|
self._alarm_since: Optional[float] = None
|
|
self._alarm_logged = False
|
|
|
|
@property
|
|
def service_name(self) -> str:
|
|
"""Get the full D-Bus service name."""
|
|
return f"com.victronenergy.tank.raymarine_tank{self._tank_id}_{self.device_instance}"
|
|
|
|
def _get_fluid_type(self) -> int:
|
|
"""Get the Victron fluid type enum from tank config."""
|
|
tank_type = self._tank_config.tank_type.lower()
|
|
return FLUID_TYPE_MAP.get(tank_type, 0) # Default to fuel
|
|
|
|
def _get_capacity_m3(self) -> Optional[float]:
|
|
"""Get tank capacity in cubic meters."""
|
|
if self._tank_config.capacity_gallons is None:
|
|
return None
|
|
return self._tank_config.capacity_gallons * GALLONS_TO_M3
|
|
|
|
def _get_paths(self) -> Dict[str, Dict[str, Any]]:
|
|
"""Return tank-specific D-Bus paths."""
|
|
capacity_m3 = self._get_capacity_m3()
|
|
|
|
paths: Dict[str, Dict[str, Any]] = {
|
|
'/Level': {'initial': None},
|
|
'/Remaining': {'initial': None},
|
|
'/Status': {'initial': 4}, # Unknown until data received
|
|
'/Capacity': {'initial': capacity_m3},
|
|
'/FluidType': {'initial': self._get_fluid_type()},
|
|
'/Standard': {'initial': 2}, # Not applicable
|
|
}
|
|
|
|
if self._has_low_alarm:
|
|
paths['/Alarms/LowLevel'] = {'initial': 0}
|
|
paths['/Alarms/LowLevelAck'] = {'initial': 0, 'writeable': True}
|
|
paths['/Alarms/LowLevelSnoozeUntil'] = {'initial': 0, 'writeable': True}
|
|
|
|
if self._has_high_alarm:
|
|
paths['/Alarms/HighLevel'] = {'initial': 0}
|
|
paths['/Alarms/HighLevelAck'] = {'initial': 0, 'writeable': True}
|
|
paths['/Alarms/HighLevelSnoozeUntil'] = {'initial': 0, 'writeable': True}
|
|
|
|
return paths
|
|
|
|
def _update(self) -> None:
|
|
"""Update tank values from sensor data."""
|
|
global _fuel_tank_cache
|
|
|
|
data = self._sensor_data
|
|
now = time.time()
|
|
|
|
# Check data freshness
|
|
is_stale = data.is_stale('tank', self.MAX_DATA_AGE)
|
|
|
|
# Get tank level from live data
|
|
level = data.tanks.get(self._tank_id)
|
|
using_cached = False
|
|
|
|
# For fuel tanks: cache when present, use cache when absent
|
|
# Fuel doesn't change when engines are off
|
|
if self._is_fuel_tank:
|
|
if level is not None and not is_stale:
|
|
# Live data available - cache it
|
|
_fuel_tank_cache[self._tank_id] = (level, now)
|
|
elif self._tank_id in _fuel_tank_cache:
|
|
# No live data - use cached value
|
|
cached_level, cached_time = _fuel_tank_cache[self._tank_id]
|
|
level = cached_level
|
|
using_cached = True
|
|
# Don't consider cached data as stale for fuel tanks
|
|
is_stale = False
|
|
|
|
if level is not None and not is_stale:
|
|
# Valid tank data (live or cached)
|
|
self._set_value('/Level', round(level, 1))
|
|
self._set_value('/Status', 0) # OK
|
|
|
|
# Calculate remaining volume
|
|
capacity_m3 = self._get_capacity_m3()
|
|
if capacity_m3 is not None:
|
|
remaining = capacity_m3 * (level / 100.0)
|
|
self._set_value('/Remaining', round(remaining, 4))
|
|
else:
|
|
self._set_value('/Remaining', None)
|
|
|
|
# Check tank-level alarms (skip for cached values)
|
|
if not using_cached:
|
|
self._check_level_alarm(level, now)
|
|
|
|
else:
|
|
# No data or stale
|
|
self._set_value('/Level', None)
|
|
self._set_value('/Remaining', None)
|
|
|
|
if is_stale and self._tank_id in data.tanks:
|
|
# Had data but now stale
|
|
self._set_value('/Status', 1) # Disconnected
|
|
else:
|
|
# Never had data
|
|
self._set_value('/Status', 4) # Unknown
|
|
|
|
# Update connection status (cached values count as connected for fuel tanks)
|
|
self.set_connected(level is not None and not is_stale)
|
|
|
|
def _check_level_alarm(self, level: float, now: float) -> None:
|
|
"""Check tank level against alarm thresholds and manage alarm state.
|
|
|
|
For water tanks: alarms when level < WATER_LOW_LEVEL_THRESHOLD.
|
|
For black water: alarms when level > BLACK_WATER_HIGH_LEVEL_THRESHOLD.
|
|
Both use TANK_ALARM_DELAY before triggering.
|
|
|
|
Also handles snooze expiry: if snoozed (Ack=2) and the snooze time
|
|
has passed, resets Ack to 0 so the UI re-alerts.
|
|
"""
|
|
if self._has_low_alarm:
|
|
alarm_path = '/Alarms/LowLevel'
|
|
ack_path = '/Alarms/LowLevelAck'
|
|
snooze_path = '/Alarms/LowLevelSnoozeUntil'
|
|
condition_met = level < WATER_LOW_LEVEL_THRESHOLD
|
|
threshold_desc = f"below {WATER_LOW_LEVEL_THRESHOLD}%"
|
|
elif self._has_high_alarm:
|
|
alarm_path = '/Alarms/HighLevel'
|
|
ack_path = '/Alarms/HighLevelAck'
|
|
snooze_path = '/Alarms/HighLevelSnoozeUntil'
|
|
condition_met = level > BLACK_WATER_HIGH_LEVEL_THRESHOLD
|
|
threshold_desc = f"above {BLACK_WATER_HIGH_LEVEL_THRESHOLD}%"
|
|
else:
|
|
return
|
|
|
|
if condition_met:
|
|
if self._alarm_since is None:
|
|
self._alarm_since = now
|
|
|
|
elapsed = now - self._alarm_since
|
|
|
|
if elapsed >= TANK_ALARM_DELAY:
|
|
self._set_value(alarm_path, 2)
|
|
if not self._alarm_logged:
|
|
logger.warning(
|
|
f"ALARM: {self._tank_config.name} at {level:.1f}% - "
|
|
f"{threshold_desc} for {elapsed:.0f}s"
|
|
)
|
|
self._alarm_logged = True
|
|
|
|
# Check snooze expiry: if snoozed and past expiry, re-trigger
|
|
ack_val = self._get_value(ack_path)
|
|
snooze_until = self._get_value(snooze_path)
|
|
if ack_val == 2 and snooze_until and now > snooze_until:
|
|
logger.info(
|
|
f"Snooze expired for {self._tank_config.name}, "
|
|
f"re-triggering alarm"
|
|
)
|
|
self._set_value(ack_path, 0)
|
|
self._set_value(snooze_path, 0)
|
|
else:
|
|
# Condition cleared
|
|
if self._alarm_since is not None:
|
|
if self._alarm_logged:
|
|
logger.info(
|
|
f"{self._tank_config.name} recovered to {level:.1f}%"
|
|
)
|
|
self._alarm_since = None
|
|
self._alarm_logged = False
|
|
|
|
self._set_value(alarm_path, 0)
|
|
self._set_value(ack_path, 0)
|
|
self._set_value(snooze_path, 0)
|
|
|
|
def _handle_changed_value(self, path: str, value: Any) -> bool:
|
|
"""Accept external writes for alarm acknowledgement and snooze paths."""
|
|
writable = {
|
|
'/Alarms/LowLevelAck', '/Alarms/LowLevelSnoozeUntil',
|
|
'/Alarms/HighLevelAck', '/Alarms/HighLevelSnoozeUntil',
|
|
}
|
|
if path in writable:
|
|
logger.info(f"External write: {self._tank_config.name} {path} = {value}")
|
|
return True
|
|
return super()._handle_changed_value(path, value)
|
|
|
|
|
|
def create_tank_services(
|
|
sensor_data: SensorData,
|
|
tank_ids: Optional[List[int]] = None,
|
|
start_instance: int = 0,
|
|
) -> List[TankService]:
|
|
"""Create TankService instances for multiple tanks.
|
|
|
|
Args:
|
|
sensor_data: SensorData instance to read values from
|
|
tank_ids: List of tank IDs to create services for.
|
|
If None, creates services for all configured tanks.
|
|
start_instance: Starting device instance number
|
|
|
|
Returns:
|
|
List of TankService instances
|
|
"""
|
|
if tank_ids is None:
|
|
tank_ids = list(TANK_CONFIG.keys())
|
|
|
|
services = []
|
|
for i, tank_id in enumerate(tank_ids):
|
|
service = TankService(
|
|
sensor_data=sensor_data,
|
|
tank_id=tank_id,
|
|
device_instance=start_instance + i,
|
|
)
|
|
services.append(service)
|
|
|
|
return services
|