From 79132d07a76de153949aed1eb0056d60bbb67252 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 31 Jul 2026 12:12:30 +0200 Subject: [PATCH] test(drivers): let a driver report metrics, and a topic carry a configurable prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two catalog conventions only held because every driver that breaks them is byte-identical to its FTW baseline and therefore skipped. Editing one for any reason fails on a rule it never had to meet, and neither rule was right. test_calls_emit_in_poll required host.emit, which carries a DER reading and needs a DER type the host understands. VALID_DERS has had `heatpump` since the FTW promotion, but no host has a heat-pump reading type, so a heat-pump driver has nothing to emit and reports through host.emit_metric. The rule left an author two options: emit nothing, or claim to be a battery. test_subscription_topics_are_strings required a literal, while heishamon and ctek_hybrid both build the topic from a file-local set in driver_init — the broker prefix is the operator's to choose. The check now asks for what it actually wants: a topic decidable without running the driver. Literals and file-local names concatenate; an index, a field or a call does not, which is how a driver ends up subscribing to whatever a message told it to. Co-Authored-By: Claude Opus 5 Signed-off-by: Fredrik Ahlgren --- drivers/tests/test_driver_contract.py | 16 ++++++++++++--- drivers/tests/test_mqtt_drivers.py | 28 ++++++++++++++++++++------- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/drivers/tests/test_driver_contract.py b/drivers/tests/test_driver_contract.py index 5e42ff6..5c00fcb 100644 --- a/drivers/tests/test_driver_contract.py +++ b/drivers/tests/test_driver_contract.py @@ -72,12 +72,22 @@ def test_calls_set_make_in_init(self, driver_name): f"{driver_name}: should call host.set_make() in driver_init" def test_calls_emit_in_poll(self, driver_name): - """driver_poll should call host.emit().""" + """driver_poll should report something: a DER reading or metrics. + + `host.emit` carries a DER reading and needs a DER type the host + understands. `heatpump` is in VALID_DERS, but no host has a heat-pump + reading type yet, so a heat-pump driver has nothing to emit and + reports temperatures and power through `host.emit_metric` instead. + Requiring `host.emit` of it would leave the author two options: emit + nothing, or claim to be a battery. Both are worse than a driver that + says what it measures. + """ if driver_name == "hello": pytest.skip("hello driver is a demo-only driver") code = read_driver(driver_name) - assert 'host.emit(' in code, \ - f"{driver_name}: should call host.emit() in driver_poll" + assert 'host.emit(' in code or 'host.emit_metric(' in code, \ + f"{driver_name}: should call host.emit() or host.emit_metric() " \ + f"in driver_poll" def test_no_forbidden_globals(self, driver_name): """Driver must not use forbidden sandbox-escaping functions.""" diff --git a/drivers/tests/test_mqtt_drivers.py b/drivers/tests/test_mqtt_drivers.py index c9e78e0..167dfda 100644 --- a/drivers/tests/test_mqtt_drivers.py +++ b/drivers/tests/test_mqtt_drivers.py @@ -37,22 +37,36 @@ def test_subscribes_in_driver_init(self, driver_name): f"not elsewhere" ) - def test_subscription_topics_are_strings(self, driver_name): - """All mqtt_subscribe calls should use string literal topics.""" + def test_subscription_topics_are_static(self, driver_name): + """A subscription topic must be decidable without running the driver. + + A literal is the common case. A configurable prefix is the other one: + heishamon and ctek_hybrid both build the topic from a file-local set + in driver_init, because the broker prefix is the operator's to choose. + That stays reviewable — every part is in the file. + + What this rejects is a topic taken from runtime data: an index, a + field or a call, which is how a driver ends up subscribing to whatever + a message told it to. + """ code = read_driver(driver_name) clean = strip_lua_comments(code) - # Find all subscribe calls and verify they use string literals sub_calls = re.findall( r'host\.mqtt_subscribe\s*\(\s*(.+?)\s*\)', clean, ) for call_arg in sub_calls: - # Should be a string literal (starts with ") - assert call_arg.strip().startswith('"'), ( - f"{driver_name}: mqtt_subscribe should use string literal " - f"topic, found: {call_arg}" + arg = call_arg.strip() + static = re.fullmatch( + r'(?:"[^"]*"|[A-Za-z_]\w*)' + r'(?:\s*\.\.\s*(?:"[^"]*"|[A-Za-z_]\w*))*', + arg, + ) + assert static, ( + f"{driver_name}: mqtt_subscribe topic must be a literal or a " + f"concatenation of literals and file-local names, found: {arg}" )