Skip to content

Commit c771421

Browse files
Update victron_mqtt to 2026.7.3 (#458)
## Changes - Fix paho-mqtt minimum version and relax Python requirement @frbuceta (#112) - Add lint, type-check and coverage enforcement to CI @frbuceta (#113) - Lot of lint fixes. - Add PvOnGrid metrics (phases, current, power) @frbuceta (#111) - Mark metrics unavailable when their source stops publishing - Add EV ChargingStarted test ## Contributors @tomer-w, @frbuceta Co-authored-by: tomer-w <57483589+tomer-w@users.noreply.github.com>
1 parent d3f521b commit c771421

13 files changed

Lines changed: 201 additions & 47 deletions

File tree

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.2"
3+
VICTRON_MQTT_VERSION = "2026.7.3"

custom_components/victron_mqtt/_vendor/victron_mqtt/_unwrappers.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"""Functions to unwrap the data from the JSON string."""
22

33
import json
4-
from collections.abc import Iterable
4+
from collections.abc import Callable, Iterable
55
from datetime import UTC, datetime
6+
from typing import Any
67

78
from .constants import BITMASK_SEPARATOR, ValueType, VictronEnum
89

@@ -199,7 +200,7 @@ def wrap_epoch(value: datetime | None) -> str:
199200
return json.dumps({"value": datetime.timestamp(value)})
200201

201202

202-
VALUE_TYPE_UNWRAPPER = {
203+
VALUE_TYPE_UNWRAPPER: dict[ValueType, Callable[..., Any]] = {
203204
ValueType.INT: unwrap_int,
204205
ValueType.INT_DEFAULT_0: unwrap_int_default_0,
205206
ValueType.FLOAT: unwrap_float,
@@ -213,7 +214,7 @@ def wrap_epoch(value: datetime | None) -> str:
213214
ValueType.FLOAT_M3_TO_LITERS: unwrap_float_m3_to_liters,
214215
}
215216

216-
VALUE_TYPE_WRAPPER = {
217+
VALUE_TYPE_WRAPPER: dict[ValueType, Callable[..., str]] = {
217218
ValueType.INT: wrap_int,
218219
ValueType.INT_DEFAULT_0: wrap_int_default_0,
219220
ValueType.FLOAT: wrap_float,

custom_components/victron_mqtt/_vendor/victron_mqtt/_victron_formulas.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ def schedule_charge_enabled(
7373

7474

7575
def schedule_charge_enabled_set(
76-
value: str, depends_on: dict[str, Metric], _transient_state: FormulaTransientState | None
76+
value: str | GenericOnOff, depends_on: dict[str, Metric], _transient_state: FormulaTransientState | None
7777
) -> tuple[GenericOnOff, None]:
7878
"""Set schedule charge enabled state."""
7979

@@ -108,7 +108,7 @@ def dvcc_enabled(
108108
metric = next(iter(depends_on.values()))
109109
if metric.value is None:
110110
return None, None
111-
code = metric.value.code if isinstance(metric.value, DVCCMode) else int(metric.value)
111+
code = int(metric.value.code) if isinstance(metric.value, DVCCMode) else int(metric.value)
112112
return (GenericOnOff.ON if code & 1 else GenericOnOff.OFF), None
113113

114114

@@ -125,7 +125,7 @@ def dvcc_enabled_set(
125125
metric = next(iter(depends_on.values()))
126126
assert isinstance(metric, WritableMetric), "Expected WritableMetric for dvcc_enabled_set"
127127
# Do not override BMS/system-forced state (FORCED_OFF=2, FORCED_ON=3)
128-
current_code = metric.value.code if isinstance(metric.value, DVCCMode) else int(metric.value or 0)
128+
current_code = int(metric.value.code) if isinstance(metric.value, DVCCMode) else int(metric.value or 0)
129129
if current_code & 2:
130130
# Forced by system — return current state without writing
131131
return (GenericOnOff.ON if current_code & 1 else GenericOnOff.OFF), None
@@ -232,7 +232,7 @@ def ess_user_mode(
232232
return ESSUserMode.EXTERNAL_CONTROL, None
233233

234234
state_value = state_metric.value
235-
code = state_value.code if isinstance(state_value, ESSState) else int(state_value)
235+
code = int(state_value.code) if isinstance(state_value, ESSState) else int(state_value)
236236

237237
if code == 9:
238238
return ESSUserMode.KEEP_BATTERIES_CHARGED, None

custom_components/victron_mqtt/_vendor/victron_mqtt/_victron_topics.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3175,6 +3175,28 @@
31753175
name="Grid power {phase}",
31763176
metric_type=MetricType.POWER,
31773177
),
3178+
TopicDescriptor(
3179+
topic="N/{installation_id}/system/{device_id}/Ac/PvOnGrid/NumberOfPhases",
3180+
message_type=MetricKind.SENSOR,
3181+
short_id="system_pv_on_grid_phases",
3182+
name="PV on grid phases",
3183+
value_type=ValueType.INT,
3184+
unit_of_measurement="phases",
3185+
),
3186+
TopicDescriptor(
3187+
topic="N/{installation_id}/system/{device_id}/Ac/PvOnGrid/{phase}/Current",
3188+
message_type=MetricKind.SENSOR,
3189+
short_id="system_pv_on_grid_current_{phase}",
3190+
name="PV on grid current {phase}",
3191+
metric_type=MetricType.CURRENT,
3192+
),
3193+
TopicDescriptor(
3194+
topic="N/{installation_id}/system/{device_id}/Ac/PvOnGrid/{phase}/Power",
3195+
message_type=MetricKind.SENSOR,
3196+
short_id="system_pv_on_grid_power_{phase}",
3197+
name="PV on grid power {phase}",
3198+
metric_type=MetricType.POWER,
3199+
),
31783200
TopicDescriptor(
31793201
topic="N/{installation_id}/system/{device_id}/Ac/PvOnOutput/NumberOfPhases",
31803202
message_type=MetricKind.SENSOR,

custom_components/victron_mqtt/_vendor/victron_mqtt/data_classes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,7 @@ def finalize_topic_fields(self, topic_desc: TopicDescriptor, device_unique_id: s
406406
self._key_values = self.get_key_values(topic_desc)
407407
self._key_values.update(topic_desc.key_values)
408408
self._short_id = self._replace_ids(topic_desc.short_id).lower()
409-
effective_device_id = device_unique_id if device_unique_id else self.get_device_unique_id()
409+
effective_device_id = device_unique_id or self.get_device_unique_id()
410410
self._unique_id = ParsedTopic.make_unique_id(effective_device_id, self._short_id)
411411
assert topic_desc.name is not None, f"TopicDescriptor name is None for topic: {topic_desc.topic}"
412412
self._name = self._replace_ids(topic_desc.name)

custom_components/victron_mqtt/_vendor/victron_mqtt/device.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,11 @@ def __init__(
5252
self._device_type = parsed_topic.device_type
5353
self._device_id = parsed_topic.device_id
5454
self._installation_id = parsed_topic.installation_id
55-
self._model = None
56-
self._manufacturer = None
57-
self._serial_number = None
58-
self._firmware_version = None
59-
self._custom_name = None
55+
self._model: str | None = None
56+
self._manufacturer: str | None = None
57+
self._serial_number: str | None = None
58+
self._firmware_version: str | None = None
59+
self._custom_name: str | None = None
6060
self._parent_device: Device | None = parent_device
6161

6262
_LOGGER.debug(
@@ -128,15 +128,17 @@ def handle_message(
128128

129129
parsed_topic.finalize_topic_fields(topic_desc, device_unique_id=self._unique_id)
130130
if fallback_to_metric_topic:
131-
value = unwrap_bool(payload)
132-
if value is None:
131+
fallback_value = unwrap_bool(payload)
132+
if fallback_value is None:
133133
log_debug(
134134
"Ignoring null fallback_to_metric_topic value for device %s metric %s",
135135
self.unique_id,
136136
topic_desc.short_id,
137137
)
138138
return None
139-
return FallbackPlaceholder(device=self, parsed_topic=parsed_topic, topic_descriptor=topic_desc, value=value)
139+
return FallbackPlaceholder(
140+
device=self, parsed_topic=parsed_topic, topic_descriptor=topic_desc, value=fallback_value
141+
)
140142
value = Device._unwrap_payload(topic_desc, payload)
141143
if value is None:
142144
log_debug("Ignoring null topic value for device %s metric %s", self.unique_id, topic_desc.short_id)
@@ -154,6 +156,7 @@ def _unwrap_payload(topic_desc: TopicDescriptor, payload: str) -> str | float |
154156
assert topic_desc.value_type is not None
155157
unwrapper = VALUE_TYPE_UNWRAPPER[topic_desc.value_type]
156158
if unwrapper in [unwrap_enum, unwrap_bitmask]:
159+
assert topic_desc.enum is not None, f"enum must be set for topic: {topic_desc.topic}"
157160
return unwrapper(payload, topic_desc.enum)
158161
if unwrapper in [
159162
unwrap_float,
@@ -245,6 +248,7 @@ def _create_metric_from_placeholder(
245248
)
246249
assert metric_placeholder.parsed_topic.device_type is not None, "device_type must be set for metric"
247250

251+
metric: Metric
248252
if new_topic_desc.message_type in [
249253
MetricKind.SWITCH,
250254
MetricKind.NUMBER,
@@ -282,6 +286,7 @@ def _add_formula_metric(self, topic_desc: TopicDescriptor, hub: Hub, key_values:
282286
name = ParsedTopic.replace_ids(topic_desc.name, key_values)
283287
short_id = ParsedTopic.replace_ids(topic_desc.short_id, key_values)
284288
unique_id = ParsedTopic.make_unique_id(self.unique_id, short_id)
289+
metric: FormulaMetric
285290
if topic_desc.message_type in [
286291
MetricKind.SWITCH,
287292
MetricKind.NUMBER,

custom_components/victron_mqtt/_vendor/victron_mqtt/hub.py

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@
3535
FULL_PUBLISH_MIN_INTERVAL_SECONDS = 180
3636
FIRST_FULL_PUBLISH_MIN_INTERVAL_SECONDS = 30
3737
MINIMUM_FULLY_SUPPORTED_VERSION = 3.5
38+
# The keepalive loop ticks every 30s. Every Nth tick we send a forced full republish so the
39+
# broker re-sends every current value, refreshing last_seen for otherwise-constant metrics.
40+
FULL_REPUBLISH_STALENESS_INTERVAL_CYCLES = 6
41+
# A metric not seen for this long is considered stale (its source stopped publishing) and is
42+
# marked unavailable. Must be larger than the forced full republish interval
43+
# (FULL_REPUBLISH_STALENESS_INTERVAL_CYCLES * 30s) so healthy but constant metrics are not
44+
# wrongly invalidated between republishes.
45+
STALE_METRIC_TIMEOUT_SECONDS = 360
3846

3947
# Modify the logger to include instance_id without changing the tracing level
4048
# class InstanceIDFilter(logging.Filter):
@@ -187,7 +195,7 @@ def __init__(
187195
self._first_refresh_event: asyncio.Event = asyncio.Event()
188196
self._installation_id_event: asyncio.Event = asyncio.Event()
189197
self._snapshot: dict[str, Any] = {}
190-
self._keepalive_task = None
198+
self._keepalive_task: asyncio.Task[None] | None = None
191199
self._connected_event = asyncio.Event()
192200
self._on_new_metric: CallbackOnNewMetric | None = None
193201
self._on_new_device: CallbackOnNewDevice | None = None
@@ -786,13 +794,10 @@ def _handle_normal_message(self, topic: str, payload: str, log_debug: Callable[.
786794
if desc_list is None:
787795
log_debug("Ignoring message - no descriptor found for topic: %s", topic)
788796
return
789-
if len(desc_list) == 1:
790-
desc = desc_list[0]
791-
else:
792-
desc = parsed_topic.match_from_list(desc_list)
793-
if desc is None:
794-
log_debug("Ignoring message - no matching descriptor found for list of topic: %s", topic)
795-
return
797+
desc = desc_list[0] if len(desc_list) == 1 else parsed_topic.match_from_list(desc_list)
798+
if desc is None:
799+
log_debug("Ignoring message - no matching descriptor found for list of topic: %s", topic)
800+
return
796801

797802
device = self._get_or_create_device(parsed_topic, desc)
798803
placeholder = device.handle_message(fallback_to_metric_topic, topic, parsed_topic, desc, payload, log_debug)
@@ -802,9 +807,9 @@ def _handle_normal_message(self, topic: str, payload: str, log_debug: Callable[.
802807
log_debug("Replacing existing metric placeholder: %s", existing_placeholder)
803808
self._metrics_placeholders[placeholder.parsed_topic.unique_id] = placeholder
804809
elif isinstance(placeholder, FallbackPlaceholder):
805-
existing_placeholder = self._fallback_placeholders.get(placeholder.parsed_topic.unique_id)
806-
if existing_placeholder:
807-
log_debug("Replacing existing fallback placeholder: %s", existing_placeholder)
810+
existing_fallback = self._fallback_placeholders.get(placeholder.parsed_topic.unique_id)
811+
if existing_fallback:
812+
log_debug("Replacing existing fallback placeholder: %s", existing_fallback)
808813
self._fallback_placeholders[placeholder.parsed_topic.unique_id] = placeholder
809814

810815
async def disconnect(self) -> None:
@@ -852,24 +857,30 @@ async def _keepalive_loop(self) -> None:
852857
# We should keep alive all metrics every 60 seconds
853858
count += 1
854859
# Old firmwars dont resend values after the keepalive message so we cant use this logic of invalidation if there is no new value
855-
if self._firmware_version >= MINIMUM_FULLY_SUPPORTED_VERSION and count % 2 == 0:
856-
self._keepalive_metrics()
860+
if self._firmware_version >= MINIMUM_FULLY_SUPPORTED_VERSION:
861+
if count % 2 == 0:
862+
# Republish throttled values and mark long-silent sources unavailable (issue #454).
863+
self._keepalive_metrics(stale_timeout=STALE_METRIC_TIMEOUT_SECONDS)
864+
# Periodically force a full republish so the broker re-sends every current
865+
# value, refreshing last_seen for constant metrics that would otherwise look stale.
866+
if count % FULL_REPUBLISH_STALENESS_INTERVAL_CYCLES == 0:
867+
self._keepalive(force=True)
857868
except asyncio.CancelledError:
858869
_LOGGER.info("Keepalive loop canceled")
859870
raise
860871
except Exception as exc:
861872
_LOGGER.exception("Error in keepalive loop: %s", exc)
862873
await asyncio.sleep(5) # Short delay before retrying
863874

864-
def _keepalive_metrics(self, force_invalidate: bool = False) -> None:
875+
def _keepalive_metrics(self, force_invalidate: bool = False, stale_timeout: float | None = None) -> None:
865876
"""Keep alive all metrics."""
866877
_LOGGER.debug("Keeping alive all metrics")
867878
for metric in self._all_metrics.values():
868879
# Determine log level based on the substring
869880
is_info_level = self._topic_log_info and self._topic_log_info in metric._descriptor.topic
870881
log_debug = _LOGGER.info if is_info_level else _LOGGER.debug
871882

872-
metric._keepalive(force_invalidate, log_debug)
883+
metric._keepalive(force_invalidate, log_debug, stale_timeout=stale_timeout)
873884

874885
def _start_keep_alive_loop(self) -> None:
875886
"""Start the keep_alive loop."""

custom_components/victron_mqtt/_vendor/victron_mqtt/metric.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,12 +217,34 @@ def on_update(self, value: CallbackOnUpdate | None) -> None:
217217
"""Sets the on_update callback."""
218218
self._on_update = value
219219

220-
def _keepalive(self, force_invalidate: bool, log_debug: Callable[..., None]):
221-
"""Reset metrics value if no updates or send last values if they got skipped"""
220+
def _keepalive(
221+
self,
222+
force_invalidate: bool,
223+
log_debug: Callable[..., None],
224+
stale_timeout: float | None = None,
225+
):
226+
"""Reset metrics value if no updates or send last values if they got skipped.
227+
228+
stale_timeout, when provided, is a number of seconds: if the metric has not been seen
229+
for longer than that, its source is considered to have stopped publishing and the
230+
metric is reset to None (unavailable). This relies on the hub periodically forcing a
231+
full republish, so the timeout must be larger than that republish interval.
232+
"""
222233
if force_invalidate and self._value is not None:
223234
log_debug("Metric %s is being forced reset", self.unique_id)
224235
self._handle_message(None, log_debug, update_last_seen=False) # Dont update the last_seen as it wasnt seen
225236
return
237+
if stale_timeout is not None and self._value is not None:
238+
elapsed = time.monotonic() - self._last_seen
239+
if elapsed > stale_timeout:
240+
log_debug(
241+
"Metric %s has been silent for %.2fs (> %.2fs), resetting to unavailable",
242+
self.unique_id,
243+
elapsed,
244+
stale_timeout,
245+
)
246+
self._handle_message(None, log_debug, update_last_seen=False) # Dont update last_seen as it wasnt seen
247+
return
226248
if self._last_seen > self._last_notified:
227249
log_debug(
228250
"Metric %s has been updated at %.2f but not published since %.2fs, re-publishing",

custom_components/victron_mqtt/_vendor/victron_mqtt/testing/hub_helpers.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,6 @@ async def _async_noop(_self: Any) -> None:
9595
# Set the mocked client explicitly to prevent overwriting
9696
hub._client = mocked_client
9797

98-
# Dynamically mock undefined attributes
99-
hub._process_device = MagicMock(name="_process_device")
100-
hub._process_metric = MagicMock(name="_process_metric")
101-
10298
# Mock connect_async to trigger the _on_connect callback
10399
def mock_connect_async(*_args: Any, **_kwargs: Any) -> None:
104100
hub._on_connect(

custom_components/victron_mqtt/_vendor/victron_mqtt/writable_formula_metric.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,24 +22,30 @@ def __init__(self, *, descriptor: TopicDescriptor, **kwargs: Any) -> None:
2222
"""Initialize the FormulaMetric."""
2323
_LOGGER.debug(
2424
"Creating new FormulaMetric: unique_id=%s, type=%s, nature=%s",
25-
descriptor.short_id, descriptor.metric_type, descriptor.metric_nature
25+
descriptor.short_id,
26+
descriptor.metric_type,
27+
descriptor.metric_nature,
2628
)
2729
assert descriptor.topic.startswith("$$func")
28-
func_name = descriptor.topic.split('/')[-1]
30+
func_name = descriptor.topic.split("/")[-1]
2931
assert ":" in func_name
3032
write_func_name = func_name.split(":", 1)[1]
3133
self._write_func = getattr(formulas, write_func_name)
3234

33-
super().__init__(descriptor = descriptor, **kwargs)
34-
35+
super().__init__(descriptor=descriptor, **kwargs)
3536

3637
def __str__(self) -> str:
3738
return f"WritableFormulaMetric({super().__str__()}, transient_state={self.transient_state})"
3839

3940
def __repr__(self) -> str:
4041
return self.__str__()
4142

42-
def _keepalive(self, force_invalidate: bool, log_debug: Callable[..., None]):
43+
def _keepalive(
44+
self,
45+
force_invalidate: bool,
46+
log_debug: Callable[..., None],
47+
stale_timeout: float | None = None,
48+
):
4349
log_debug("Metric is WritableFormulaMetric so no keepalive for now: %s", self.unique_id)
4450

4551
def set(self, value: str | float | int | bool | VictronEnum) -> None:

0 commit comments

Comments
 (0)