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
383 lines
15 KiB
Python
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
|