Skip to content

Commit 1129c48

Browse files
Update victron_mqtt to 2026.7.4 (#463)
So many improvments in this release!! ## Changes - Add "auto" update frequency with per-metric-type intervals @frbuceta (#114) - Robustness fixes: version parsing, payload handling, and input validation @frbuceta (#115) - Add ssl\_context parameter to Hub for TLS certificate verification @frbuceta (#116) - validate victron_mqtt.json file is up to date in CI pipeline - Make multi Ess/AcPowerSetpoint default to zero: #262 - Harden connect for better cleanup on errors and exposing only CannotConnectError and inherited exceptions: #461 - fix pyright issue and add it as a hook - feat: resolve device-specific max charge current from product table ## Contributors @frbuceta, @tomer-w Co-authored-by: tomer-w <57483589+tomer-w@users.noreply.github.com>
1 parent 031f973 commit 1129c48

18 files changed

Lines changed: 600 additions & 113 deletions
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Vendored third-party packages."""
22

3-
VICTRON_MQTT_VERSION = "2026.7.3"
3+
VICTRON_MQTT_VERSION = "2026.7.4"

custom_components/victron_mqtt/_vendor/victron_mqtt/__init__.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,22 @@
4545
TemperatureStatus,
4646
TemperatureType,
4747
VictronDeviceEnum,
48+
VictronProductId,
4849
VrmPortalMode,
4950
)
50-
from .constants import MetricKind, MetricNature, MetricType, OperationMode, RangeType, VictronEnum
51-
from .data_classes import GpsLocation
51+
from ._victron_products import ProductCapabilities, get_product_capabilities
52+
from .constants import (
53+
AUTO_UPDATE_INTERVALS,
54+
UPDATE_FREQUENCY_AUTO,
55+
UPDATE_FREQUENCY_AUTO_POWER_NONE,
56+
MetricKind,
57+
MetricNature,
58+
MetricType,
59+
OperationMode,
60+
RangeType,
61+
VictronEnum,
62+
)
63+
from .data_classes import GpsLocation, ProductCapabilityRef
5264
from .device import Device
5365
from .formula_metric import FormulaMetric
5466
from .hub import (
@@ -64,6 +76,9 @@
6476
from .writable_metric import WritableMetric
6577

6678
__all__ = [
79+
"AUTO_UPDATE_INTERVALS",
80+
"UPDATE_FREQUENCY_AUTO",
81+
"UPDATE_FREQUENCY_AUTO_POWER_NONE",
6782
"ACActiveInputSource",
6883
"ACSystemMode",
6984
"AcInputTypeEnum",
@@ -113,6 +128,8 @@
113128
"OperationMode",
114129
"PhoenixInverterMode",
115130
"PreferRenewableEnergyEnum",
131+
"ProductCapabilities",
132+
"ProductCapabilityRef",
116133
"ProgrammingError",
117134
"RangeType",
118135
"SolarChargerDeviceOffReason",
@@ -123,6 +140,8 @@
123140
"TopicNotFoundError",
124141
"VictronDeviceEnum",
125142
"VictronEnum",
143+
"VictronProductId",
126144
"VrmPortalMode",
127145
"WritableMetric",
146+
"get_product_capabilities",
128147
]

custom_components/victron_mqtt/_vendor/victron_mqtt/_unwrappers.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,22 +95,22 @@ def unwrap_enum(json_str: str, enum: type[VictronEnum]) -> VictronEnum | None:
9595
"""Unwrap a string value from a JSON string."""
9696
try:
9797
data = json.loads(json_str)
98+
val = data["value"]
9899
except (json.JSONDecodeError, KeyError, ValueError, TypeError):
99100
return None
100-
val = data["value"]
101101
return enum.from_code(val) if val is not None else None
102102

103103

104104
def unwrap_bitmask(json_str: str, enum: type[VictronEnum]) -> str | None:
105105
"""Unwrap a bitmask value from a JSON string."""
106106
try:
107107
data = json.loads(json_str)
108+
val = data["value"]
109+
if val is None:
110+
return None
111+
vals = [2**idx for idx, bit in enumerate(bin(val)[:1:-1]) if int(bit)] if int(val) > 0 else [0]
108112
except (json.JSONDecodeError, KeyError, ValueError, TypeError):
109113
return None
110-
val = data["value"]
111-
if val is None:
112-
return None
113-
vals = [2**idx for idx, bit in enumerate(bin(val)[:1:-1]) if int(bit)] if int(val) > 0 else [0]
114114
enums = [enum.from_code(v) for v in vals]
115115
return str.join(BITMASK_SEPARATOR, [e.string for e in enums if e is not None])
116116

custom_components/victron_mqtt/_vendor/victron_mqtt/_victron_enums.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,53 @@ class DeviceType(VictronDeviceEnum):
5656
DCDC = ("dcdc", "dcdc", "DC/DC charger") # Orion XS 1400 in battery to battery charging mode.
5757

5858

59+
class VictronProductId(VictronEnum):
60+
"""Victron product identifiers, published on the ``.../ProductId`` topic.
61+
62+
- ``code`` : numeric product ID as reported by the GX. Written as a hex
63+
literal so it matches Victron's published VE.Direct / VE.Can PID tables
64+
1:1 while remaining a plain ``int`` at runtime.
65+
- ``id`` : stable snake_case identifier.
66+
- ``string`` : human-readable model name.
67+
68+
This table is seeded from the Victron VE.Direct PID lists and is intentionally
69+
not exhaustive. Unknown product IDs simply resolve to ``None`` (callers then
70+
fall back to their default), so entries can be added incrementally.
71+
"""
72+
73+
# BlueSolar MPPT solar chargers
74+
BLUESOLAR_MPPT_75_15 = (0xA042, "bluesolar_mppt_75_15", "BlueSolar MPPT 75/15")
75+
BLUESOLAR_MPPT_100_15 = (0xA043, "bluesolar_mppt_100_15", "BlueSolar MPPT 100/15")
76+
BLUESOLAR_MPPT_100_30 = (0xA044, "bluesolar_mppt_100_30", "BlueSolar MPPT 100/30")
77+
BLUESOLAR_MPPT_100_50 = (0xA045, "bluesolar_mppt_100_50", "BlueSolar MPPT 100/50")
78+
BLUESOLAR_MPPT_150_70 = (0xA04A, "bluesolar_mppt_150_70", "BlueSolar MPPT 150/70")
79+
BLUESOLAR_MPPT_75_10 = (0xA04C, "bluesolar_mppt_75_10", "BlueSolar MPPT 75/10")
80+
BLUESOLAR_MPPT_150_45 = (0xA04D, "bluesolar_mppt_150_45", "BlueSolar MPPT 150/45")
81+
BLUESOLAR_MPPT_150_60 = (0xA04E, "bluesolar_mppt_150_60", "BlueSolar MPPT 150/60")
82+
BLUESOLAR_MPPT_150_85 = (0xA04F, "bluesolar_mppt_150_85", "BlueSolar MPPT 150/85")
83+
# SmartSolar MPPT solar chargers
84+
SMARTSOLAR_MPPT_250_100 = (0xA050, "smartsolar_mppt_250_100", "SmartSolar MPPT 250/100")
85+
SMARTSOLAR_MPPT_150_100 = (0xA051, "smartsolar_mppt_150_100", "SmartSolar MPPT 150/100")
86+
SMARTSOLAR_MPPT_150_85 = (0xA052, "smartsolar_mppt_150_85", "SmartSolar MPPT 150/85")
87+
SMARTSOLAR_MPPT_75_15 = (0xA053, "smartsolar_mppt_75_15", "SmartSolar MPPT 75/15")
88+
SMARTSOLAR_MPPT_75_10 = (0xA054, "smartsolar_mppt_75_10", "SmartSolar MPPT 75/10")
89+
SMARTSOLAR_MPPT_100_15 = (0xA055, "smartsolar_mppt_100_15", "SmartSolar MPPT 100/15")
90+
SMARTSOLAR_MPPT_100_30 = (0xA056, "smartsolar_mppt_100_30", "SmartSolar MPPT 100/30")
91+
SMARTSOLAR_MPPT_100_50 = (0xA057, "smartsolar_mppt_100_50", "SmartSolar MPPT 100/50")
92+
SMARTSOLAR_MPPT_150_35 = (0xA058, "smartsolar_mppt_150_35", "SmartSolar MPPT 150/35")
93+
SMARTSOLAR_MPPT_150_100_REV2 = (0xA059, "smartsolar_mppt_150_100_rev2", "SmartSolar MPPT 150/100 rev2")
94+
SMARTSOLAR_MPPT_150_85_REV2 = (0xA05A, "smartsolar_mppt_150_85_rev2", "SmartSolar MPPT 150/85 rev2")
95+
SMARTSOLAR_MPPT_250_70 = (0xA05B, "smartsolar_mppt_250_70", "SmartSolar MPPT 250/70")
96+
SMARTSOLAR_MPPT_250_85 = (0xA05C, "smartsolar_mppt_250_85", "SmartSolar MPPT 250/85")
97+
SMARTSOLAR_MPPT_250_60 = (0xA05D, "smartsolar_mppt_250_60", "SmartSolar MPPT 250/60")
98+
SMARTSOLAR_MPPT_250_45 = (0xA05E, "smartsolar_mppt_250_45", "SmartSolar MPPT 250/45")
99+
SMARTSOLAR_MPPT_100_20 = (0xA05F, "smartsolar_mppt_100_20", "SmartSolar MPPT 100/20")
100+
SMARTSOLAR_MPPT_100_20_48V = (0xA060, "smartsolar_mppt_100_20_48v", "SmartSolar MPPT 100/20 48V")
101+
SMARTSOLAR_MPPT_150_45 = (0xA061, "smartsolar_mppt_150_45", "SmartSolar MPPT 150/45")
102+
SMARTSOLAR_MPPT_150_60 = (0xA062, "smartsolar_mppt_150_60", "SmartSolar MPPT 150/60")
103+
SMARTSOLAR_MPPT_150_70 = (0xA063, "smartsolar_mppt_150_70", "SmartSolar MPPT 150/70")
104+
105+
59106
class DVCCMode(VictronEnum):
60107
"""DVCC (Distributed Voltage and Current Control) mode.
61108
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Per-product capability lookup for Victron devices.
2+
3+
Maps a Victron product (identified by the ``.../ProductId`` topic) to a set of
4+
static, model-specific capabilities that the GX does not always publish over
5+
MQTT. The first use case is the maximum charge current of VE.Direct solar
6+
chargers and alternators, whose sliders would otherwise default to an
7+
oversized static range.
8+
9+
The registry is intentionally generic: add fields to :class:`ProductCapabilities`
10+
and rows to ``_PRODUCT_CAPABILITIES`` to expose new model-specific facts, and
11+
reuse :func:`get_product_capabilities` wherever a device-dependent value is
12+
needed.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
from dataclasses import dataclass
18+
19+
from ._victron_enums import VictronProductId
20+
21+
22+
@dataclass(frozen=True)
23+
class ProductCapabilities:
24+
"""Static, model-specific capabilities for a Victron product."""
25+
26+
max_charge_current: float | None = None
27+
# Extend with further model-specific capabilities as needed, e.g.:
28+
# max_discharge_current: float | None = None
29+
30+
31+
# Keyed by the readable VictronProductId constant (never raw hex).
32+
# For MPPT solar chargers the max charge current is the second number in the
33+
# model name (e.g. "100/50" -> 50 A).
34+
_PRODUCT_CAPABILITIES: dict[VictronProductId, ProductCapabilities] = {
35+
# BlueSolar MPPT
36+
VictronProductId.BLUESOLAR_MPPT_75_15: ProductCapabilities(max_charge_current=15),
37+
VictronProductId.BLUESOLAR_MPPT_100_15: ProductCapabilities(max_charge_current=15),
38+
VictronProductId.BLUESOLAR_MPPT_100_30: ProductCapabilities(max_charge_current=30),
39+
VictronProductId.BLUESOLAR_MPPT_100_50: ProductCapabilities(max_charge_current=50),
40+
VictronProductId.BLUESOLAR_MPPT_150_70: ProductCapabilities(max_charge_current=70),
41+
VictronProductId.BLUESOLAR_MPPT_75_10: ProductCapabilities(max_charge_current=10),
42+
VictronProductId.BLUESOLAR_MPPT_150_45: ProductCapabilities(max_charge_current=45),
43+
VictronProductId.BLUESOLAR_MPPT_150_60: ProductCapabilities(max_charge_current=60),
44+
VictronProductId.BLUESOLAR_MPPT_150_85: ProductCapabilities(max_charge_current=85),
45+
# SmartSolar MPPT
46+
VictronProductId.SMARTSOLAR_MPPT_250_100: ProductCapabilities(max_charge_current=100),
47+
VictronProductId.SMARTSOLAR_MPPT_150_100: ProductCapabilities(max_charge_current=100),
48+
VictronProductId.SMARTSOLAR_MPPT_150_85: ProductCapabilities(max_charge_current=85),
49+
VictronProductId.SMARTSOLAR_MPPT_75_15: ProductCapabilities(max_charge_current=15),
50+
VictronProductId.SMARTSOLAR_MPPT_75_10: ProductCapabilities(max_charge_current=10),
51+
VictronProductId.SMARTSOLAR_MPPT_100_15: ProductCapabilities(max_charge_current=15),
52+
VictronProductId.SMARTSOLAR_MPPT_100_30: ProductCapabilities(max_charge_current=30),
53+
VictronProductId.SMARTSOLAR_MPPT_100_50: ProductCapabilities(max_charge_current=50),
54+
VictronProductId.SMARTSOLAR_MPPT_150_35: ProductCapabilities(max_charge_current=35),
55+
VictronProductId.SMARTSOLAR_MPPT_150_100_REV2: ProductCapabilities(max_charge_current=100),
56+
VictronProductId.SMARTSOLAR_MPPT_150_85_REV2: ProductCapabilities(max_charge_current=85),
57+
VictronProductId.SMARTSOLAR_MPPT_250_70: ProductCapabilities(max_charge_current=70),
58+
VictronProductId.SMARTSOLAR_MPPT_250_85: ProductCapabilities(max_charge_current=85),
59+
VictronProductId.SMARTSOLAR_MPPT_250_60: ProductCapabilities(max_charge_current=60),
60+
VictronProductId.SMARTSOLAR_MPPT_250_45: ProductCapabilities(max_charge_current=45),
61+
VictronProductId.SMARTSOLAR_MPPT_100_20: ProductCapabilities(max_charge_current=20),
62+
VictronProductId.SMARTSOLAR_MPPT_100_20_48V: ProductCapabilities(max_charge_current=20),
63+
VictronProductId.SMARTSOLAR_MPPT_150_45: ProductCapabilities(max_charge_current=45),
64+
VictronProductId.SMARTSOLAR_MPPT_150_60: ProductCapabilities(max_charge_current=60),
65+
VictronProductId.SMARTSOLAR_MPPT_150_70: ProductCapabilities(max_charge_current=70),
66+
}
67+
68+
69+
def get_product_capabilities(product_id: int | None) -> ProductCapabilities | None:
70+
"""Return the capabilities for a product ID, or ``None`` if unknown."""
71+
if product_id is None:
72+
return None
73+
member = VictronProductId.from_code(product_id)
74+
if member is None:
75+
return None
76+
return _PRODUCT_CAPABILITIES.get(member)

custom_components/victron_mqtt/_vendor/victron_mqtt/_victron_topics.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
VrmPortalMode,
4747
)
4848
from .constants import MetricKind, MetricNature, MetricType, RangeType, ValueType
49-
from .data_classes import TopicDependency, TopicDescriptor
49+
from .data_classes import ProductCapabilityRef, TopicDependency, TopicDescriptor
5050

5151
# Good sources for topics is:
5252
# https://github.com/victronenergy/venus/wiki/dbus
@@ -111,7 +111,7 @@
111111
topic="N/{installation_id}/{device_type}/{device_id}/ProductId",
112112
message_type=MetricKind.ATTRIBUTE,
113113
short_id="victron_productid",
114-
value_type=ValueType.STRING,
114+
value_type=ValueType.INT,
115115
),
116116
TopicDescriptor(
117117
topic="N/{installation_id}/{device_type}/{device_id}/ProductName",
@@ -259,8 +259,9 @@
259259
short_id="alternator_charge_current_limit",
260260
name="Charge current limit",
261261
metric_type=MetricType.CURRENT,
262+
min_max_range=RangeType.DYNAMIC, # prefer the GX-reported max, then the product table, then 200
262263
min=0,
263-
max=200,
264+
max=ProductCapabilityRef("max_charge_current", 200),
264265
),
265266
TopicDescriptor(
266267
topic="N/{installation_id}/alternator/{device_id}/State",
@@ -2020,6 +2021,7 @@
20202021
short_id="multi_ess_ac_power_setpoint",
20212022
name="ESS AC power setpoint",
20222023
metric_type=MetricType.POWER,
2024+
value_type=ValueType.INT_DEFAULT_0,
20232025
),
20242026
TopicDescriptor(
20252027
topic="N/{installation_id}/multi/{device_id}/Ess/DisableCharge",
@@ -2970,8 +2972,9 @@
29702972
short_id="solarcharger_charge_current_limit",
29712973
name="Charge current limit",
29722974
metric_type=MetricType.CURRENT,
2975+
min_max_range=RangeType.DYNAMIC, # prefer the GX-reported max, then the product table, then 200
29732976
min=0,
2974-
max=200,
2977+
max=ProductCapabilityRef("max_charge_current", 200),
29752978
),
29762979
TopicDescriptor(
29772980
topic="N/{installation_id}/solarcharger/{device_id}/State",

custom_components/victron_mqtt/_vendor/victron_mqtt/constants.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from dataclasses import dataclass
44
from enum import Enum
5-
from typing import Self
5+
from typing import Final, Self
66

77
TOPIC_INSTALLATION_ID = "N/+/system/0/Serial"
88

@@ -70,6 +70,22 @@ class MetricType(Enum):
7070
LOW_BATTERY = "low_battery"
7171

7272

73+
UPDATE_FREQUENCY_AUTO: Final = "auto"
74+
UPDATE_FREQUENCY_AUTO_POWER_NONE: Final = "auto_power_none"
75+
76+
# Fast-changing metric types that users typically watch live.
77+
_FAST_METRIC_TYPES: Final = (MetricType.POWER, MetricType.APPARENT_POWER, MetricType.CURRENT)
78+
79+
# Per-metric-type update intervals for each auto profile. None means no time
80+
# limit (update on every value change); metric types not listed fall back to
81+
# AUTO_UPDATE_INTERVAL_DEFAULT.
82+
AUTO_UPDATE_INTERVALS: dict[str, dict[MetricType, int | None]] = {
83+
UPDATE_FREQUENCY_AUTO: dict.fromkeys(_FAST_METRIC_TYPES, 5),
84+
UPDATE_FREQUENCY_AUTO_POWER_NONE: dict.fromkeys(_FAST_METRIC_TYPES, None),
85+
}
86+
AUTO_UPDATE_INTERVAL_DEFAULT = 30
87+
88+
7389
class ValueType(Enum):
7490
"""Value types."""
7591

custom_components/victron_mqtt/_vendor/victron_mqtt/data_classes.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,19 @@ class TopicDependency:
3535
required: bool = True
3636

3737

38+
@dataclass(frozen=True)
39+
class ProductCapabilityRef:
40+
"""Reference resolving a range value (min/max/step) from a product capability.
41+
42+
When used as a ``min``/``max``/``step`` on a :class:`TopicDescriptor`, the
43+
value is looked up per-device from the product capability table (keyed by the
44+
device's product ID). If the product is unknown, ``default`` is used.
45+
"""
46+
47+
capability: str
48+
default: float | int | None = None
49+
50+
3851
def topic_to_device_type(topic_parts: list[str]) -> DeviceType | None:
3952
"""Extract the device type from the topic."""
4053
if topic_parts[0] == "$$func":
@@ -67,15 +80,15 @@ class TopicDescriptor:
6780
precision: int | None = None
6881
enum: type[VictronEnum] | None = None
6982
min_max_range: RangeType = RangeType.STATIC
70-
min: float | int | str | None = None
71-
max: float | int | str | None = None
72-
step: float | int | str | None = None
83+
min: float | int | str | ProductCapabilityRef | None = None
84+
max: float | int | str | ProductCapabilityRef | None = None
85+
step: float | int | str | ProductCapabilityRef | None = None
7386
is_adjustable_suffix: str | None = None
7487
output_type: int | str | None = (
7588
None # SwitchableOutput type (static or metric reference). When 6 (dropdown), labels are used.
7689
)
7790
labels: str | None = None # JSON labels metric reference (format: 'metric_id:default')
78-
key_values: dict[str, str] = field(default_factory=dict)
91+
key_values: dict[str, str] = field(default_factory=dict[str, str])
7992
experimental: bool = False
8093
# Depends on format is different for regular and formula topics:
8194
# For regular topics, the depends_on list contains the {device_id}_{metric_short_id} of the metric it depends on

custom_components/victron_mqtt/_vendor/victron_mqtt/device.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ def __init__(
5757
self._serial_number: str | None = None
5858
self._firmware_version: str | None = None
5959
self._custom_name: str | None = None
60+
self._productid: int | None = None
6061
self._parent_device: Device | None = parent_device
6162

6263
_LOGGER.debug(
@@ -90,13 +91,16 @@ def _set_device_property_from_topic(
9091
if value is None:
9192
_LOGGER.debug("Ignoring empty/None payload for device %s property %s", self.unique_id, short_id)
9293
return
94+
95+
if short_id == "victron_productid":
96+
assert isinstance(value, int), f"ProductId must be an int, got {value!r}"
97+
self._productid = value
98+
return
99+
93100
value = str(value)
94101

95102
_LOGGER.debug("Setting device %s property %s = %s", self.unique_id, short_id, value)
96103

97-
if short_id == "victron_productid":
98-
return # ignore for now
99-
100104
if short_id == "model":
101105
self._model = value
102106
elif short_id == "serial_number":
@@ -359,6 +363,11 @@ def serial_number(self) -> str | None:
359363
"""Return the serial number of the device."""
360364
return self._serial_number
361365

366+
@property
367+
def product_id(self) -> int | None:
368+
"""Return the Victron product ID of the device, if known."""
369+
return self._productid
370+
362371
@property
363372
def device_type(self) -> DeviceType:
364373
"""Return the device type."""

0 commit comments

Comments
 (0)