Skip to content

Commit 69d36c3

Browse files
committed
fix: read-compare-write in automation to stop redundant inverter writes
Automations were writing the target value on every snapshot cycle even when the inverter was already at that value, spamming the dongle and the automation log (e.g. writing 0 A every 10 s when it had been 0 A for hours). - _do_write now reads the register's current raw value first and skips the write (silently) when it already matches the target. - Removed the in-memory _last_written_value dedup guard, which only tracked what this process wrote (reset on restart) and could not detect external changes. Read-compare-write is now the single source of truth. - Fail-safe: if the read fails, the write still proceeds (a failed read should not block the automation from doing its job). - Added 3 tests covering match-skip, differ-write, and read-fail-write.
1 parent cacc06f commit 69d36c3

2 files changed

Lines changed: 80 additions & 30 deletions

File tree

‎collector/automation.py‎

Lines changed: 29 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -631,7 +631,6 @@ def __init__(
631631
self._notifiers = notifiers
632632
self._last_eval: Dict[str, bool] = {}
633633
self._last_notify: Dict[str, float] = {}
634-
self._last_written_value: Dict[str, Any] = {}
635634

636635
def _db(self):
637636
import pymysql
@@ -797,26 +796,20 @@ def _evaluate_one(
797796
_, rng = match
798797
if rng.action_value is not None:
799798
value = float(rng.action_value)
800-
last = self._last_written_value.get(auto.id)
801-
if last != value:
802-
self._do_write(
803-
auto.id, auto.name, auto.setting, value,
804-
dongle_host, dongle_port, datalog_serial, inverter_serial,
805-
dry_run,
806-
)
807-
self._last_written_value[auto.id] = value
808-
self._last_eval[auto.id] = True
809-
return
810-
if was_active and auto.restore_value is not None and auto.setting is not None:
811-
value = float(auto.restore_value)
812-
last = self._last_written_value.get(auto.id)
813-
if last != value:
814799
self._do_write(
815800
auto.id, auto.name, auto.setting, value,
816801
dongle_host, dongle_port, datalog_serial, inverter_serial,
817802
dry_run,
818803
)
819-
self._last_written_value[auto.id] = value
804+
self._last_eval[auto.id] = True
805+
return
806+
if was_active and auto.restore_value is not None and auto.setting is not None:
807+
value = float(auto.restore_value)
808+
self._do_write(
809+
auto.id, auto.name, auto.setting, value,
810+
dongle_host, dongle_port, datalog_serial, inverter_serial,
811+
dry_run,
812+
)
820813
self._last_eval[auto.id] = False
821814

822815
def _evaluate_battery_protection(
@@ -840,24 +833,18 @@ def _evaluate_battery_protection(
840833
if matched:
841834
if auto.action_value is not None:
842835
value = float(auto.action_value)
843-
last = self._last_written_value.get(auto.id)
844-
if last != value:
845-
self._do_write(
846-
auto.id, auto.name, "shutdown_battery_voltage", value,
847-
dongle_host, dongle_port, datalog_serial, inverter_serial, dry_run,
848-
)
849-
self._last_written_value[auto.id] = value
836+
self._do_write(
837+
auto.id, auto.name, "shutdown_battery_voltage", value,
838+
dongle_host, dongle_port, datalog_serial, inverter_serial, dry_run,
839+
)
850840
self._last_eval[auto.id] = True
851841
else:
852842
if was_active and auto.restore_value is not None:
853843
value = float(auto.restore_value)
854-
last = self._last_written_value.get(auto.id)
855-
if last != value:
856-
self._do_write(
857-
auto.id, auto.name, "shutdown_battery_voltage", value,
858-
dongle_host, dongle_port, datalog_serial, inverter_serial, dry_run,
859-
)
860-
self._last_written_value[auto.id] = value
844+
self._do_write(
845+
auto.id, auto.name, "shutdown_battery_voltage", value,
846+
dongle_host, dongle_port, datalog_serial, inverter_serial, dry_run,
847+
)
861848
self._last_eval[auto.id] = False
862849

863850
def _evaluate_battery_soc(
@@ -944,6 +931,18 @@ def _do_write(
944931
logger.info("[DRY-RUN] Automation %s: %s=%s", automation_id, setting_name, clamped)
945932
return
946933

934+
# Read-compare-write: only send a Modbus write when the inverter's
935+
# current value actually differs from the target. This avoids spamming
936+
# the dongle (and the automation log) with redundant writes every
937+
# snapshot when the value is already correct (e.g. writing 0 A every
938+
# 10 s when it has been 0 A for hours). The steady-state "no change"
939+
# case is silent — only actual writes (or failures) are logged.
940+
read_ok, current_raw, _ = _read_holding_register(
941+
dongle_host, dongle_port, datalog_serial, inverter_serial, reg_addr
942+
)
943+
if read_ok and current_raw == raw:
944+
return
945+
947946
ok, msg = _write_holding_register(
948947
dongle_host, dongle_port, datalog_serial, inverter_serial, reg_addr, raw
949948
)

‎tests/test_automation.py‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,5 +300,56 @@ def test_single_group_single_range(self):
300300
self.assertTrue(self.engine._last_eval[auto.id])
301301

302302

303+
class TestReadCompareWrite(unittest.TestCase):
304+
"""Read-compare-write: only write when the inverter value actually differs."""
305+
306+
def setUp(self):
307+
self.engine = AutomationEngine(
308+
db_host="localhost", db_port=3306, db_user="x",
309+
db_password="x", db_name="x", table_prefix="lux_",
310+
)
311+
312+
def _do_write(self, value, read_result, dry_run=False):
313+
with patch("collector.automation._read_holding_register", return_value=read_result) as mock_read:
314+
with patch("collector.automation._write_holding_register", return_value=(True, "ok")) as mock_write:
315+
with patch("collector.automation.AutomationEngine._log"):
316+
self.engine._do_write(
317+
"a1", "Test", "grid_charge_current", value,
318+
"192.168.1.100", 8000, "BJ123", "INV123", dry_run,
319+
)
320+
return mock_read, mock_write
321+
322+
def test_skips_write_when_value_matches(self):
323+
# Inverter already at the target raw value -> no write.
324+
# grid_charge_current maps to a register; use a value whose raw form
325+
# we can predict. We patch the read to return the SAME raw the write
326+
# would produce by reading the register map.
327+
from collector.automation import SETTING_NAME_TO_REGISTER, HOLDING_BY_NAME, HOLDING_REGISTERS, _engineering_to_raw, _clamp_to_register
328+
reg_name = SETTING_NAME_TO_REGISTER["grid_charge_current"]
329+
reg_addr = HOLDING_BY_NAME[reg_name]
330+
meta = HOLDING_REGISTERS[reg_addr]
331+
target_raw = _engineering_to_raw(_clamp_to_register(10.0, meta), meta)
332+
333+
mock_read, mock_write = self._do_write(10.0, (True, target_raw, "read"))
334+
mock_write.assert_not_called()
335+
336+
def test_writes_when_value_differs(self):
337+
from collector.automation import SETTING_NAME_TO_REGISTER, HOLDING_BY_NAME, HOLDING_REGISTERS, _engineering_to_raw, _clamp_to_register
338+
reg_name = SETTING_NAME_TO_REGISTER["grid_charge_current"]
339+
reg_addr = HOLDING_BY_NAME[reg_name]
340+
meta = HOLDING_REGISTERS[reg_addr]
341+
target_raw = _engineering_to_raw(_clamp_to_register(10.0, meta), meta)
342+
# Inverter currently at a DIFFERENT raw value.
343+
different_raw = target_raw + 1
344+
345+
mock_read, mock_write = self._do_write(10.0, (True, different_raw, "read"))
346+
mock_write.assert_called_once()
347+
348+
def test_writes_when_read_fails(self):
349+
# If the read fails, fail-safe: still write (don't block the automation).
350+
mock_read, mock_write = self._do_write(10.0, (False, None, "read failed"))
351+
mock_write.assert_called_once()
352+
353+
303354
if __name__ == "__main__":
304355
unittest.main()

0 commit comments

Comments
 (0)