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

383 lines
15 KiB
Python

"""
Battery D-Bus service for Venus OS.
Publishes battery data to the Venus OS D-Bus using the
com.victronenergy.battery service type.
Each physical battery requires a separate D-Bus service instance.
D-Bus paths:
/Dc/0/Voltage - Battery voltage in V DC
/Dc/0/Current - Not available from Raymarine
/Dc/0/Power - Not available from Raymarine
/Dc/0/Temperature - Not available from Raymarine
/Soc - Estimated from voltage for 24V AGM batteries
/Connected - Connection status
"""
import logging
import time
from typing import Any, Dict, Optional, List, Tuple
from .service import VeDbusServiceBase
from ..data.store import SensorData
from ..sensors import BATTERY_CONFIG, BatteryInfo, get_battery_name, get_battery_nominal_voltage
logger = logging.getLogger(__name__)
# Alert thresholds
LOW_VOLTAGE_ALERT_THRESHOLD = 23.0 # Volts - alert if below this
LOW_VOLTAGE_WARNING_DELAY = 60.0 # Seconds - warning (level 1) after this duration
LOW_VOLTAGE_ALARM_DELAY = 300.0 # Seconds - alarm (level 2) after this duration
# High voltage alert thresholds
HIGH_VOLTAGE_ALERT_THRESHOLD = 30.2 # Volts - alert if above this (above normal absorption)
HIGH_VOLTAGE_WARNING_DELAY = 60.0 # Seconds - warning (level 1) after this duration
HIGH_VOLTAGE_ALARM_DELAY = 300.0 # Seconds - alarm (level 2) after this duration
# 24V AGM battery voltage to SOC lookup table
# Based on resting voltage (no load, no charge) for AGM batteries
# Format: (voltage, soc_percent)
# 24V = 2x 12V batteries in series
AGM_24V_SOC_TABLE: List[Tuple[float, int]] = [
(25.50, 100), # 12.75V per battery - fully charged
(25.30, 95),
(25.10, 90),
(24.90, 85),
(24.70, 80),
(24.50, 75),
(24.30, 70),
(24.10, 65),
(23.90, 60),
(23.70, 55),
(23.50, 50),
(23.30, 45),
(23.10, 40),
(22.90, 35),
(22.70, 30),
(22.50, 25),
(22.30, 20),
(22.10, 15),
(21.90, 10),
(21.70, 5),
(21.50, 0), # 10.75V per battery - fully discharged
]
def estimate_soc_24v_agm(voltage: float) -> int:
"""Estimate state of charge for a 24V AGM battery based on voltage.
This is an approximation based on resting voltage. Actual SOC can vary
based on load, temperature, and battery age.
Args:
voltage: Battery voltage in volts
Returns:
Estimated SOC as percentage (0-100)
"""
if voltage >= AGM_24V_SOC_TABLE[0][0]:
return 100
if voltage <= AGM_24V_SOC_TABLE[-1][0]:
return 0
# Find the two points to interpolate between
for i in range(len(AGM_24V_SOC_TABLE) - 1):
v_high, soc_high = AGM_24V_SOC_TABLE[i]
v_low, soc_low = AGM_24V_SOC_TABLE[i + 1]
if v_low <= voltage <= v_high:
# Linear interpolation
ratio = (voltage - v_low) / (v_high - v_low)
soc = soc_low + ratio * (soc_high - soc_low)
return int(round(soc))
return 50 # Fallback
class BatteryService(VeDbusServiceBase):
"""Battery D-Bus service for Venus OS.
Publishes a single battery's voltage data to the Venus OS D-Bus.
Create one instance per physical battery.
Note: Raymarine only provides voltage readings. Current, power,
SOC, and other advanced metrics require a dedicated battery monitor
like a Victron BMV or SmartShunt.
Example:
sensor_data = SensorData()
# Create service for battery ID 11
battery_service = BatteryService(
sensor_data=sensor_data,
battery_id=11,
device_instance=0,
)
battery_service.register()
# In update loop:
battery_service.update()
"""
service_type = "battery"
product_name = "Raymarine Battery Monitor"
product_id = 0xA143 # Custom product ID for Raymarine Battery
# Maximum age in seconds before data is considered stale
MAX_DATA_AGE = 30.0
def __init__(
self,
sensor_data: SensorData,
battery_id: int,
device_instance: int = 0,
battery_config: Optional[BatteryInfo] = None,
custom_name: Optional[str] = None,
):
"""Initialize Battery service.
Args:
sensor_data: SensorData instance to read values from
battery_id: The Raymarine battery ID
device_instance: Unique instance number for this battery
battery_config: Optional BatteryInfo override
custom_name: Optional custom display name
"""
# Get battery configuration
if battery_config:
self._battery_config = battery_config
elif battery_id in BATTERY_CONFIG:
self._battery_config = BATTERY_CONFIG[battery_id]
else:
self._battery_config = BatteryInfo(f"Battery #{battery_id}", 12.0, "house")
# Use battery name as product name
self.product_name = self._battery_config.name
super().__init__(
device_instance=device_instance,
connection=f"Raymarine Battery {battery_id}",
custom_name=custom_name or self._battery_config.name,
)
self._sensor_data = sensor_data
self._battery_id = battery_id
# Track when voltage first dropped below threshold for delayed alert
self._low_voltage_since: Optional[float] = None
self._low_voltage_warned = False # Tracks if warning (level 1) was logged
self._low_voltage_alerted = False # Tracks if alarm (level 2) was logged
# Track when voltage first exceeded threshold for delayed alert
self._high_voltage_since: Optional[float] = None
self._high_voltage_warned = False # Tracks if warning (level 1) was logged
self._high_voltage_alerted = False # Tracks if alarm (level 2) was logged
@property
def service_name(self) -> str:
"""Get the full D-Bus service name."""
return f"com.victronenergy.battery.raymarine_bat{self._battery_id}_{self.device_instance}"
def _get_paths(self) -> Dict[str, Dict[str, Any]]:
"""Return battery-specific D-Bus paths."""
return {
# DC measurements
'/Dc/0/Voltage': {'initial': None},
'/Dc/0/Current': {'initial': None},
'/Dc/0/Power': {'initial': None},
'/Dc/0/Temperature': {'initial': None},
# State of charge (not available from Raymarine)
'/Soc': {'initial': None},
'/TimeToGo': {'initial': None},
'/ConsumedAmphours': {'initial': None},
# Alarms (not monitored via Raymarine)
'/Alarms/LowVoltage': {'initial': 0},
'/Alarms/HighVoltage': {'initial': 0},
'/Alarms/LowSoc': {'initial': None},
'/Alarms/LowTemperature': {'initial': None},
'/Alarms/HighTemperature': {'initial': None},
# Settings
'/Settings/HasTemperature': {'initial': 0},
'/Settings/HasStarterVoltage': {'initial': 0},
'/Settings/HasMidVoltage': {'initial': 0},
}
def _update(self) -> None:
"""Update battery values from sensor data."""
data = self._sensor_data
now = time.time()
# Check data freshness
is_stale = data.is_stale('battery', self.MAX_DATA_AGE)
# Get battery voltage from live data
voltage = data.batteries.get(self._battery_id)
if voltage is not None and not is_stale:
# Valid battery data
self._set_value('/Dc/0/Voltage', round(voltage, 2))
# Estimate SOC for 24V AGM batteries
nominal = self._battery_config.nominal_voltage
if nominal == 24.0:
soc = estimate_soc_24v_agm(voltage)
self._set_value('/Soc', soc)
else:
self._set_value('/Soc', None)
# Low voltage alert with hysteresis
# Tiered alerts prevent false alarms from temporary voltage drops (e.g., engine start)
# - 0-60 seconds: No alarm (monitoring)
# - 60-300 seconds: Warning (level 1)
# - 300+ seconds: Alarm (level 2)
if voltage < LOW_VOLTAGE_ALERT_THRESHOLD:
if self._low_voltage_since is None:
# First time below threshold - start tracking but don't alert yet
self._low_voltage_since = now
logger.info(
f"{self._battery_config.name}: Voltage {voltage:.1f}V dropped below "
f"{LOW_VOLTAGE_ALERT_THRESHOLD}V threshold, monitoring..."
)
elapsed = now - self._low_voltage_since
if elapsed >= LOW_VOLTAGE_ALARM_DELAY:
# Below threshold for 5+ minutes - ALARM (level 2)
self._set_value('/Alarms/LowVoltage', 2)
if not self._low_voltage_alerted:
logger.error(
f"ALARM: {self._battery_config.name} voltage {voltage:.1f}V "
f"has been below {LOW_VOLTAGE_ALERT_THRESHOLD}V for "
f"{elapsed:.0f} seconds!"
)
self._low_voltage_alerted = True
elif elapsed >= LOW_VOLTAGE_WARNING_DELAY:
# Below threshold for 1-5 minutes - WARNING (level 1)
self._set_value('/Alarms/LowVoltage', 1)
if not self._low_voltage_warned:
logger.warning(
f"WARNING: {self._battery_config.name} voltage {voltage:.1f}V "
f"has been below {LOW_VOLTAGE_ALERT_THRESHOLD}V for "
f"{elapsed:.0f} seconds"
)
self._low_voltage_warned = True
else:
# Below threshold but within hysteresis period - no alarm yet
self._set_value('/Alarms/LowVoltage', 0)
else:
# Voltage is OK - reset tracking
if self._low_voltage_since is not None:
logger.info(
f"{self._battery_config.name}: Voltage recovered to {voltage:.1f}V"
)
self._low_voltage_since = None
self._low_voltage_warned = False
self._low_voltage_alerted = False
self._set_value('/Alarms/LowVoltage', 0)
# High voltage alert with hysteresis
# Tiered alerts prevent false alarms from temporary voltage spikes (e.g., charging)
# - 0-60 seconds: No alarm (monitoring)
# - 60-300 seconds: Warning (level 1)
# - 300+ seconds: Alarm (level 2)
if voltage > HIGH_VOLTAGE_ALERT_THRESHOLD:
if self._high_voltage_since is None:
# First time above threshold - start tracking but don't alert yet
self._high_voltage_since = now
logger.info(
f"{self._battery_config.name}: Voltage {voltage:.1f}V exceeded "
f"{HIGH_VOLTAGE_ALERT_THRESHOLD}V threshold, monitoring..."
)
elapsed = now - self._high_voltage_since
if elapsed >= HIGH_VOLTAGE_ALARM_DELAY:
# Above threshold for 5+ minutes - ALARM (level 2)
self._set_value('/Alarms/HighVoltage', 2)
if not self._high_voltage_alerted:
logger.error(
f"ALARM: {self._battery_config.name} voltage {voltage:.1f}V "
f"has been above {HIGH_VOLTAGE_ALERT_THRESHOLD}V for "
f"{elapsed:.0f} seconds!"
)
self._high_voltage_alerted = True
elif elapsed >= HIGH_VOLTAGE_WARNING_DELAY:
# Above threshold for 1-5 minutes - WARNING (level 1)
self._set_value('/Alarms/HighVoltage', 1)
if not self._high_voltage_warned:
logger.warning(
f"WARNING: {self._battery_config.name} voltage {voltage:.1f}V "
f"has been above {HIGH_VOLTAGE_ALERT_THRESHOLD}V for "
f"{elapsed:.0f} seconds"
)
self._high_voltage_warned = True
else:
# Above threshold but within hysteresis period - no alarm yet
self._set_value('/Alarms/HighVoltage', 0)
else:
# Voltage is OK - reset tracking
if self._high_voltage_since is not None:
logger.info(
f"{self._battery_config.name}: Voltage recovered to {voltage:.1f}V"
)
self._high_voltage_since = None
self._high_voltage_warned = False
self._high_voltage_alerted = False
self._set_value('/Alarms/HighVoltage', 0)
else:
# No data or stale - show as unavailable
self._set_value('/Dc/0/Voltage', None)
self._set_value('/Soc', None)
self._set_value('/Alarms/LowVoltage', 0)
self._set_value('/Alarms/HighVoltage', 0)
# Reset voltage tracking when data is stale
self._low_voltage_since = None
self._low_voltage_warned = False
self._low_voltage_alerted = False
self._high_voltage_since = None
self._high_voltage_warned = False
self._high_voltage_alerted = False
# These are not available from Raymarine
self._set_value('/Dc/0/Current', None)
self._set_value('/Dc/0/Power', None)
self._set_value('/Dc/0/Temperature', None)
self._set_value('/TimeToGo', None)
self._set_value('/ConsumedAmphours', None)
# Update connection status
self.set_connected(voltage is not None and not is_stale)
def create_battery_services(
sensor_data: SensorData,
battery_ids: Optional[List[int]] = None,
start_instance: int = 0,
) -> List[BatteryService]:
"""Create BatteryService instances for multiple batteries.
Args:
sensor_data: SensorData instance to read values from
battery_ids: List of battery IDs to create services for.
If None, creates services for all configured batteries.
start_instance: Starting device instance number
Returns:
List of BatteryService instances
"""
if battery_ids is None:
battery_ids = list(BATTERY_CONFIG.keys())
services = []
for i, battery_id in enumerate(battery_ids):
service = BatteryService(
sensor_data=sensor_data,
battery_id=battery_id,
device_instance=start_instance + i,
)
services.append(service)
return services