Files
venus/axiom-nmea/raymarine_nmea/venus_dbus/tank.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

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