From a2eb30226f1653813dddbc373c801e38aaa9ca49 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:28:15 +0100 Subject: [PATCH 01/10] Hide an outage line below the logger's level The filter lowers the record after the logger checked it against ERROR, so at Home Assistant's default WARNING the outage showed while its recovery did not. --- .github/mutations/plan.json | 18 ++++++++++-- custom_components/weishaupt_modbus/outage.py | 11 ++++--- tests/test_coordinator.py | 31 ++++++++++++++++++-- tests/test_webif_coordinator.py | 4 +-- 4 files changed, 54 insertions(+), 10 deletions(-) diff --git a/.github/mutations/plan.json b/.github/mutations/plan.json index 962c190a..bcca8184 100644 --- a/.github/mutations/plan.json +++ b/.github/mutations/plan.json @@ -4776,8 +4776,8 @@ { "label": "outage log: the filter leaves the level as it is", "path": "custom_components/weishaupt_modbus/outage.py", - "old": " record.levelno = logging.INFO\n", - "new": " pass\n", + "old": " record.levelno = logging.INFO\n", + "new": "", "tests": "logged_as_info or stop_is_logged_at_info" }, { @@ -4870,5 +4870,19 @@ "old": " _drop_pick(walk, step.parent)\n", "new": "", "tests": "picked_menu_with_nothing" + }, + { + "label": "outage log: the lowered record keeps the name ERROR", + "path": "custom_components/weishaupt_modbus/outage.py", + "old": " record.levelname = logging.getLevelName(logging.INFO)\n", + "new": "", + "tests": "logged_as_info or stop_is_logged_at_info" + }, + { + "label": "outage log: the lowered record skips the level check", + "path": "custom_components/weishaupt_modbus/outage.py", + "old": " return logging.getLogger(record.name).isEnabledFor(logging.INFO)\n", + "new": " return True\n", + "tests": "not_logged_where_info" } ] diff --git a/custom_components/weishaupt_modbus/outage.py b/custom_components/weishaupt_modbus/outage.py index 09c2f01a..aad5d575 100644 --- a/custom_components/weishaupt_modbus/outage.py +++ b/custom_components/weishaupt_modbus/outage.py @@ -13,16 +13,19 @@ class _ExpectedOutageIsNoError(logging.Filter): """An expected outage is logged at INFO, like the return after it. Home Assistant logs every failed refresh at ERROR, with no switch for it. + The logger checked the level against ERROR before the filter ran, so the + lowered record is checked against INFO here. """ def filter(self, record: logging.LogRecord) -> bool: expected = record.levelno == logging.ERROR and any( isinstance(argument, ExpectedOutage) for argument in (record.args or ()) ) - if expected: - record.levelno = logging.INFO - record.levelname = logging.getLevelName(logging.INFO) - return True + if not expected: + return True + record.levelno = logging.INFO + record.levelname = logging.getLevelName(logging.INFO) + return logging.getLogger(record.name).isEnabledFor(logging.INFO) _EXPECTED_OUTAGE_IS_NO_ERROR = _ExpectedOutageIsNoError() diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index c0a63e55..22a48d6f 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -282,11 +282,38 @@ async def test_a_pump_that_stops_answering_is_logged_as_info( assert not coordinator.last_update_success outages = [ - record.levelno + (record.levelno, record.levelname) for record in caplog.records if record.getMessage().startswith("Error fetching") ] - assert outages == [level] + assert outages == [(level, logging.getLevelName(level))] + + +async def test_an_outage_is_not_logged_where_info_is_not(hass, caplog): + """Home Assistant logs at WARNING unless told otherwise. The outage, lowered + to INFO after that level was checked, still reached the log while the + recovery at INFO did not: the log showed an outage that never ended.""" + integration = logging.getLogger("custom_components.weishaupt_modbus") + integration.setLevel(logging.WARNING) + try: + coordinator, device, _ = _polled_once(hass) + await coordinator.async_refresh() + device.fail = ModbusConnectionError("down") + for _ in range(4): + await coordinator.async_refresh() + assert not coordinator.last_update_success + device.fail = None + await coordinator.async_refresh() + assert coordinator.last_update_success + finally: + integration.setLevel(logging.NOTSET) + + assert not [ + record + for record in caplog.records + if record.name.startswith("custom_components.weishaupt_modbus") + and record.levelno < logging.WARNING + ] async def test_a_value_is_looked_up_by_translation_key(hass): diff --git a/tests/test_webif_coordinator.py b/tests/test_webif_coordinator.py index e6c73c63..f72ab772 100644 --- a/tests/test_webif_coordinator.py +++ b/tests/test_webif_coordinator.py @@ -520,11 +520,11 @@ async def test_the_stop_is_logged_at_info(coordinator, client, clock, caplog): assert not coordinator.last_update_success stops = [ - record.levelno + (record.levelno, record.levelname) for record in caplog.records if record.getMessage().startswith("Error fetching") ] - assert stops == [logging.INFO] + assert stops == [(logging.INFO, "INFO")] async def test_a_stop_asks_the_failed_page_alone_again_after_a_quarter_hour( From 054cbfabd1cb759fcfb1a547c8477b28ffcff23b Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:28:21 +0100 Subject: [PATCH 02/10] Ask every page once polling resumes after a stop The stop wipes all values, but a page with an hourly interval waited out the hour from its last request, unavailable although polling had resumed. --- .github/mutations/plan.json | 11 +++++++-- .../weishaupt_modbus/webif_coordinator.py | 4 +++- tests/test_webif_coordinator.py | 24 +++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/.github/mutations/plan.json b/.github/mutations/plan.json index bcca8184..6154c514 100644 --- a/.github/mutations/plan.json +++ b/.github/mutations/plan.json @@ -4699,8 +4699,8 @@ { "label": "webif coordinator: a stop keeps the other pages' old values", "path": "custom_components/weishaupt_modbus/webif_coordinator.py", - "old": " for reading in self._readings.values():\n reading.values = None\n", - "new": "", + "old": " reading.values = None\n reading.asked_at = None\n", + "new": " reading.asked_at = None\n", "tests": "other_pages_show_nothing_until_read_again" }, { @@ -4884,5 +4884,12 @@ "old": " return logging.getLogger(record.name).isEnabledFor(logging.INFO)\n", "new": " return True\n", "tests": "not_logged_where_info" + }, + { + "label": "web interface: a stop keeps when each page was last asked", + "path": "custom_components/weishaupt_modbus/webif_coordinator.py", + "old": " reading.asked_at = None\n", + "new": "", + "tests": "long_interval_is_read_when" } ] diff --git a/custom_components/weishaupt_modbus/webif_coordinator.py b/custom_components/weishaupt_modbus/webif_coordinator.py index 015bbbf8..bc39e9f1 100644 --- a/custom_components/weishaupt_modbus/webif_coordinator.py +++ b/custom_components/weishaupt_modbus/webif_coordinator.py @@ -736,9 +736,11 @@ def _stop(self, page: Page, reason: str) -> None: # The stopping round rests the shortest interval anyway. wait = max(FIRST_RETRY_SECONDS, self._shortest_interval.total_seconds()) # Hours may pass until polling resumes: the other pages' values - # from before the stop showed again as current. + # from before the stop showed again as current. Each page is asked + # once polling resumes, not after its interval, an hour maybe. for reading in self._readings.values(): reading.values = None + reading.asked_at = None else: wait = min(2 * self._stopped.wait, LONGEST_RETRY_SECONDS) self._stopped = _Stopped(page, reason, wait, self._clock() + wait) diff --git a/tests/test_webif_coordinator.py b/tests/test_webif_coordinator.py index f72ab772..5e2c841b 100644 --- a/tests/test_webif_coordinator.py +++ b/tests/test_webif_coordinator.py @@ -615,6 +615,30 @@ async def test_a_retry_that_reads_the_page_resumes_polling_and_takes_the_notice( assert client.asked == [STATISTICS.path, HEATING.path, HEAT_PUMP.path] +async def test_after_a_stop_a_page_with_a_long_interval_is_read_when_polling_resumes( + hass, entry, client, clock +): + """The stop wipes every page's values, but an hourly page waited out the + hour from its last request: polling resumed, and its sensors stayed + unavailable until then.""" + polled = [HEAT_PUMP, replace(STATISTICS, interval=HOUR), HEATING] + coordinator = WebifCoordinator( + hass, entry, client, polled, budget=WriteBudget(0, 10), clock=clock + ) + client.answer(HEAT_PUMP, BLANK_VALUE) + for quarter in range(3): + await round_at(coordinator, clock, quarter * 15 * 60) + client.answer(HEAT_PUMP, WHOLE[HEAT_PUMP.path]) + await round_at(coordinator, clock, 45 * 60) + assert coordinator.diagnostics()["stopped_by"] is None + client.asked.clear() + + await round_at(coordinator, clock, 45 * 60 + 1) + + assert STATISTICS.path in client.asked + assert coordinator.data["statistics"] is not None + + async def test_after_a_stop_the_other_pages_show_nothing_until_read_again( coordinator, client, clock ): From 5c3c6ab43a24ef7e825804ee1ef885a73b113784 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:28:26 +0100 Subject: [PATCH 03/10] Strip legacy login keys from any entry version The upstream 2.0 line wrote version 9 with the web-interface login and a MAC address, which survived migration and showed in diagnostics. Diagnostics now show only known keys, and the empty 1.x web-interface devices are removed. --- .github/mutations/plan.json | 47 +++++++++++-- .../weishaupt_modbus/__init__.py | 39 +++++++++-- .../weishaupt_modbus/diagnostics.py | 18 +++-- tests/test_e2e.py | 66 +++++++++++++++++++ 4 files changed, 154 insertions(+), 16 deletions(-) diff --git a/.github/mutations/plan.json b/.github/mutations/plan.json index 6154c514..054f07e2 100644 --- a/.github/mutations/plan.json +++ b/.github/mutations/plan.json @@ -72,8 +72,8 @@ { "label": "setup: the migration must strip the web-interface keys", "path": "custom_components/weishaupt_modbus/__init__.py", - "old": " for key in LEGACY_WEBIF_KEYS:\n new_data.pop(key, None)\n", - "new": " for key in LEGACY_WEBIF_KEYS:\n pass\n", + "old": " for key in LEGACY_WEBIF_KEYS:\n new_data.pop(key, None)\n", + "new": " for key in LEGACY_WEBIF_KEYS:\n pass\n", "tests": "stripped_of_its_settings" }, { @@ -814,8 +814,8 @@ { "label": "diagnostics: the pump's address is redacted", "path": "custom_components/weishaupt_modbus/diagnostics.py", - "old": "TO_REDACT = {CONF.HOST, CONF.PREFIX, CONF.DEVICE_POSTFIX, CONF.KENNFELD_FILE}\n", - "new": "TO_REDACT = {CONF.PREFIX, CONF.DEVICE_POSTFIX, CONF.KENNFELD_FILE}\n", + "old": "SHOWN = {\n CONF.PORT,\n", + "new": "SHOWN = {\n CONF.HOST,\n CONF.PORT,\n", "tests": "diagnostics_show_what_the_pump_answered" }, { @@ -842,8 +842,8 @@ { "label": "diagnostics: a postfix the user typed is redacted", "path": "custom_components/weishaupt_modbus/diagnostics.py", - "old": "TO_REDACT = {CONF.HOST, CONF.PREFIX, CONF.DEVICE_POSTFIX, CONF.KENNFELD_FILE}\n", - "new": "TO_REDACT = {CONF.HOST, CONF.PREFIX, CONF.KENNFELD_FILE}\n", + "old": "SHOWN = {\n CONF.PORT,\n", + "new": "SHOWN = {\n CONF.DEVICE_POSTFIX,\n CONF.PORT,\n", "tests": "diagnostics_carry_none_of_the_names_a_user_typed" }, { @@ -4891,5 +4891,40 @@ "old": " reading.asked_at = None\n", "new": "", "tests": "long_interval_is_read_when" + }, + { + "label": "migration: legacy keys are removed below version 9 only", + "path": "custom_components/weishaupt_modbus/__init__.py", + "old": " for key in LEGACY_WEBIF_KEYS:\n new_data.pop(key, None)\n if config_entry.version < 9:\n", + "new": " if config_entry.version < 9:\n for key in LEGACY_WEBIF_KEYS:\n new_data.pop(key, None)\n", + "tests": "upstream_2_0_line" + }, + { + "label": "migration: the upstream MAC address stays", + "path": "custom_components/weishaupt_modbus/__init__.py", + "old": " \"mac\",\n", + "new": "", + "tests": "upstream_2_0_line" + }, + { + "label": "migration: the 1.x web-interface devices stay", + "path": "custom_components/weishaupt_modbus/__init__.py", + "old": " _remove_legacy_web_interface_devices(hass, config_entry, new_data)\n", + "new": "", + "tests": "devices_of_1_x" + }, + { + "label": "migration: the 1.x web-interface devices are looked up without the postfix", + "path": "custom_components/weishaupt_modbus/__init__.py", + "old": " postfix = device_postfix(entry_data)\n for name in LEGACY_WEBIF_DEVICES:\n", + "new": " postfix = \"\"\n for name in LEGACY_WEBIF_DEVICES:\n", + "tests": "devices_of_1_x" + }, + { + "label": "diagnostics: only the named keys are hidden", + "path": "custom_components/weishaupt_modbus/diagnostics.py", + "old": "set(entry.data) - SHOWN", + "new": "{CONF.HOST, CONF.PREFIX, CONF.DEVICE_POSTFIX, CONF.KENNFELD_FILE}", + "tests": "does_not_know or what_the_pump_answered or names_a_user_typed" } ] diff --git a/custom_components/weishaupt_modbus/__init__.py b/custom_components/weishaupt_modbus/__init__.py index c536314e..16e1aab7 100644 --- a/custom_components/weishaupt_modbus/__init__.py +++ b/custom_components/weishaupt_modbus/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import UNDEFINED @@ -46,7 +46,7 @@ ) from .items import ModbusItem from .kennfeld import PowerMap -from .migrate_helpers import entry_unique_id, unique_id_from_parts +from .migrate_helpers import device_postfix, entry_unique_id, unique_id_from_parts from .webif.client import Client from .webif_coordinator import WebifCoordinator, polled_pages from .webif_sensor import ENGLISH_TITLES, POWER_LIMIT, REQUIRED_TITLES @@ -75,6 +75,16 @@ "Poll Wärmepumpe", "Poll 2. Wärmeerzeuger", "Poll Statistik", + # The upstream 2.0 line stored the pump's MAC address, beside the login + # above, and called that version 9 as well. + "mac", +) +# The devices 1.x registered the web-interface entities under. +LEGACY_WEBIF_DEVICES = ( + "Webif Info Heizkreis", + "Webif Info Wärmepumpe", + "Webif 2. Wärmeerzeuger", + "Webif Statistik", ) LEGACY_WEBIF_UNIQUE_ID_PREFIX = "webif_" # 1.0.18 and 1.0.19 named them prefix, "_", name, postfix and this. A pump's @@ -305,6 +315,20 @@ def _is_legacy_web_interface(unique_id: str, entry_data: Mapping[str, Any]) -> b ) +def _remove_legacy_web_interface_devices( + hass: HomeAssistant, config_entry: MyConfigEntry, entry_data: Mapping[str, Any] +) -> None: + """Left behind, they stayed empty, and the UI offers no delete for them.""" + registry = dr.async_get(hass) + postfix = device_postfix(entry_data) + for name in LEGACY_WEBIF_DEVICES: + found = registry.async_get_device_by_identifier( + (CONST.DOMAIN, name + postfix), config_entry.entry_id + ) + if found is not None: + registry.async_remove_device(found.id) + + async def async_migrate_entry(hass: HomeAssistant, config_entry: MyConfigEntry) -> bool: """Migrate old entry.""" if is_web_interface(config_entry): @@ -339,18 +363,21 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: MyConfigEntry) new_data[CONF.NAME_DEVICE_PREFIX] = False new_data[CONF.NAME_TOPIC_PREFIX] = False + # Whatever version wrote them: the upstream 2.0 line's version 9 holds + # the login too. + for key in LEGACY_WEBIF_KEYS: + new_data.pop(key, None) if config_entry.version < 9: _LOGGER.debug("Version <9 detected") - # Versions 5 to 8 added the web-interface settings; the web interface - # is gone, and so are its keys and the entities it registered. - for key in LEGACY_WEBIF_KEYS: - new_data.pop(key, None) + # Versions 5 to 8 added the web interface; it is gone, and so are the + # entities and devices it registered. registry = er.async_get(hass) for registry_entry in er.async_entries_for_config_entry( registry, config_entry.entry_id ): if _is_legacy_web_interface(registry_entry.unique_id, new_data): registry.async_remove(registry_entry.entity_id) + _remove_legacy_web_interface_devices(hass, config_entry, new_data) if config_entry.version < 10: _LOGGER.debug("Version <10 detected") diff --git a/custom_components/weishaupt_modbus/diagnostics.py b/custom_components/weishaupt_modbus/diagnostics.py index cad61981..67b2e483 100644 --- a/custom_components/weishaupt_modbus/diagnostics.py +++ b/custom_components/weishaupt_modbus/diagnostics.py @@ -10,9 +10,19 @@ from .configentry import MyConfigEntry, WebifConfigEntry, is_web_interface from .const import CONF -# Free text the user typed or chose: a prefix or postfix is often a family -# name or a room, and the download is meant for a public issue. -TO_REDACT = {CONF.HOST, CONF.PREFIX, CONF.DEVICE_POSTFIX, CONF.KENNFELD_FILE} +# Shown as stored; every other key is hidden. Host, prefix, postfix and the +# map's file name are free text the user typed or chose, a family name or a +# room maybe, and the download is meant for a public issue. A key another +# version left in the entry may be a password. +SHOWN = { + CONF.PORT, + CONF.HK2, + CONF.HK3, + CONF.HK4, + CONF.HK5, + CONF.NAME_DEVICE_PREFIX, + CONF.NAME_TOPIC_PREFIX, +} # The page addresses are menu codes of the controller firmware, with a field # the title capture treats as the device's: of no use in an issue either way. # The pump title is free text again. @@ -31,7 +41,7 @@ async def async_get_config_entry_diagnostics( "entry": { "version": entry.version, "minor_version": entry.minor_version, - "data": async_redact_data(dict(entry.data), TO_REDACT), + "data": async_redact_data(dict(entry.data), set(entry.data) - SHOWN), "options": dict(entry.options), }, "coordinator": { diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 461dea03..78de6a42 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -1058,6 +1058,20 @@ async def test_diagnostics_carry_none_of_the_names_a_user_typed(hass, hass_clien assert BASE_DATA[CONF.HOST] not in str(diagnostics) +async def test_diagnostics_hide_a_key_this_integration_does_not_know(hass, hass_client): + """The download hid the keys it was told to; one another version left in + the entry, a password or a MAC address, went out as stored.""" + entry = await _setup( + hass, _entry(hass, data={**BASE_DATA, "mac": "00:00:5e:00:53:01"}) + ) + + diagnostics = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert diagnostics["entry"]["data"]["mac"] == "**REDACTED**" + assert diagnostics["entry"]["data"][CONF.PORT] == BASE_DATA[CONF.PORT] + assert diagnostics["entry"]["data"][CONF.HK2] is False + + async def test_setup_creates_all_three_platforms(hass): await _setup(hass, _entry(hass)) @@ -1193,6 +1207,58 @@ async def test_a_web_interface_entry_is_stripped_of_its_settings_and_entities(ha ), "the web-interface entity was left in the registry" +async def test_an_entry_of_the_upstream_2_0_line_is_stripped_of_its_login_and_mac( + hass, +): + """The upstream 2.0 line wrote version 9 too, with the web-interface login + and the pump's MAC address. Only an entry below version 9 was cleaned up: + the password stayed on disk and went out in the diagnostics download.""" + upstream = { + **BASE_DATA, + "enable-webif": True, + "username": "user", + "password": "secret", + "Web-IF-Token": "token", + "mac": "00:00:5e:00:53:01", + } + entry = _entry(hass, data=upstream, version=9) + + await _setup(hass, entry) + + assert entry.version == 11 + for key in ("enable-webif", "username", "password", "Web-IF-Token", "mac"): + assert key not in entry.data, f"{key!r} survived the migration" + + +@pytest.mark.parametrize("postfix", ["", "2"]) +async def test_the_web_interface_devices_of_1_x_go_with_their_entities(hass, postfix): + """The migration removed the web-interface entities of 1.x but left their + devices, empty and with no way to delete them.""" + entry = _entry(hass, data={**BASE_DATA, CONF.DEVICE_POSTFIX: postfix}, version=8) + registry = dr.async_get(hass) + suffix = f"_{postfix}" if postfix else "" + for name in ( + "Webif Info Heizkreis", + "Webif Info Wärmepumpe", + "Webif 2. Wärmeerzeuger", + "Webif Statistik", + ): + registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(CONST.DOMAIN, name + suffix)}, + ) + + await _setup(hass, entry) + + left = [ + identifier + for device in dr.async_entries_for_config_entry(registry, entry.entry_id) + for _, identifier in device.identifiers + if identifier.startswith("Webif") + ] + assert left == [] + + @pytest.mark.parametrize( ("postfix", "web_interface_id", "pump_id"), [ From f622f4cd45fde50686349ac2cb98bafd906e6db8 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:28:30 +0100 Subject: [PATCH 04/10] Run the CI mutations by a matrix key, not a name Renaming the job silently skipped every mutation case. --- .github/mutations/plan.json | 7 +++++++ .github/workflows/test.yaml | 7 ++++--- tests/test_ci_matrix.py | 11 +++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.github/mutations/plan.json b/.github/mutations/plan.json index 054f07e2..2d1136a2 100644 --- a/.github/mutations/plan.json +++ b/.github/mutations/plan.json @@ -4926,5 +4926,12 @@ "old": "set(entry.data) - SHOWN", "new": "{CONF.HOST, CONF.PREFIX, CONF.DEVICE_POSTFIX, CONF.KENNFELD_FILE}", "tests": "does_not_know or what_the_pump_answered or names_a_user_typed" + }, + { + "label": "ci: the mutation step is tied to the matrix name", + "path": ".github/workflows/test.yaml", + "old": " if: matrix.run-mutations\n", + "new": " if: matrix.name == 'current HA'\n", + "tests": "opts_in_by_a_key" } ] diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 78c91638..7d127812 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -158,6 +158,9 @@ jobs: phcc: "pytest-homeassistant-custom-component==0.13.363" - name: "current HA" python-version: "3.14" + # The answer does not depend on the Home Assistant version, and + # the mutations edit files in the workspace: one job is enough. + run-mutations: true # Resolved by the step below, not a literal requirement. phcc: "latest-stable-ha" steps: @@ -218,8 +221,6 @@ jobs: - name: Run test suite run: pytest tests/ -q -m "" --cov=custom_components/weishaupt_modbus --cov-report=term:skip-covered --cov-fail-under=99 # A green suite says the tests pass, not that they would catch anything. - # Only on one job: the answer does not depend on the Home Assistant - # version, and the mutations edit files in the workspace. - name: Verify the tests actually catch a broken fix - if: matrix.name == 'current HA' + if: matrix.run-mutations run: python .github/scripts/mutate.py .github/mutations/plan.json diff --git a/tests/test_ci_matrix.py b/tests/test_ci_matrix.py index b8a6367c..d62d4cab 100644 --- a/tests/test_ci_matrix.py +++ b/tests/test_ci_matrix.py @@ -195,6 +195,17 @@ def test_the_test_job_runs_the_whole_suite_and_the_mutations(): ) +def test_the_mutations_run_on_a_job_that_opts_in_by_a_key_not_by_its_name(): + """The step ran where the matrix entry was called "current HA": renamed, + the mutations were skipped on every job, and the run stayed green.""" + workflow = WORKFLOW.read_text(encoding="utf-8") + + assert re.search(r"if:\s*matrix\.run-mutations\s*$", workflow, re.MULTILINE) + assert workflow.count("run-mutations: true") == 1, ( + "the mutations run on no job, or edit the workspace of two at once" + ) + + def test_the_type_check_runs_the_same_home_assistant_as_the_test_job(): """A checker that vouches for another release vouches for nothing.""" steps = _steps_of("mypy") From 8fe4a6559c9a54764671b4638371838f84b3706c Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:28:31 +0100 Subject: [PATCH 05/10] Trim the address in the capture and scan tools --- .github/mutations/plan.json | 21 +++++++++++++++++++++ tests/test_capture_tool.py | 28 ++++++++++++++++++++++++++++ tests/test_scan_tool.py | 14 ++++++++++++++ tools/webif_capture.py | 8 +++++++- tools/weishaupt_scan.bat | 2 ++ 5 files changed, 72 insertions(+), 1 deletion(-) diff --git a/.github/mutations/plan.json b/.github/mutations/plan.json index 2d1136a2..679db4bf 100644 --- a/.github/mutations/plan.json +++ b/.github/mutations/plan.json @@ -4933,5 +4933,26 @@ "old": " if: matrix.run-mutations\n", "new": " if: matrix.name == 'current HA'\n", "tests": "opts_in_by_a_key" + }, + { + "label": "capture: an address keeps a pasted space", + "path": "tools/webif_capture.py", + "old": " address = text.strip()\n", + "new": " address = text\n", + "tests": "with_a_space" + }, + { + "label": "capture: an address with a space inside is taken", + "path": "tools/webif_capture.py", + "old": " if not address or any(character.isspace() for character in address):\n", + "new": " if not address:\n", + "tests": "cannot_be_dialled" + }, + { + "label": "scan tool: an address keeps a pasted space", + "path": "tools/weishaupt_scan.bat", + "old": "$HostName = $HostName.Trim()", + "new": "$HostName = $HostName", + "tests": "scanned_without_it" } ] diff --git a/tests/test_capture_tool.py b/tests/test_capture_tool.py index e10b5f65..39fb7cf9 100644 --- a/tests/test_capture_tool.py +++ b/tests/test_capture_tool.py @@ -751,6 +751,34 @@ def test_a_web_address_is_refused_before_anything_is_asked(monkeypatch): capture_tool.main(["http://192.0.2.1/"]) +def test_an_address_typed_with_a_space_is_read_without_it(monkeypatch): + """The prompt keeps what was pasted: a trailing space ended in a traceback + after the user name and the password, where the integration strips it.""" + monkeypatch.setattr("builtins.input", lambda _prompt: USER) + monkeypatch.setattr(capture_tool.getpass, "getpass", lambda _prompt: PASSWORD) + hosts = [] + + def captured(host, *_arguments): + hosts.append(host) + + monkeypatch.setattr(capture_tool, "capture", captured) + + capture_tool.main([" 192.0.2.10 "]) + + assert hosts == ["192.0.2.10"] + + +@pytest.mark.parametrize("typed", ["", " ", "192.0.2. 10"]) +def test_an_address_that_cannot_be_dialled_is_refused_before_anything_is_asked( + monkeypatch, typed +): + monkeypatch.setattr("builtins.input", lambda _prompt: pytest.fail("asked")) + monkeypatch.setattr(capture_tool, "capture", lambda *_arguments: pytest.fail("ran")) + + with pytest.raises(SystemExit): + capture_tool.main([typed]) + + def test_ctrl_c_ends_the_run_without_a_traceback(monkeypatch): monkeypatch.setattr("builtins.input", lambda _prompt: USER) monkeypatch.setattr(capture_tool.getpass, "getpass", lambda _prompt: PASSWORD) diff --git a/tests/test_scan_tool.py b/tests/test_scan_tool.py index 75a9963f..744a77fd 100644 --- a/tests/test_scan_tool.py +++ b/tests/test_scan_tool.py @@ -214,6 +214,20 @@ def test_only_read_requests_reach_the_pump(tmp_path, serve): assert _values(rows, "holding") == {41101: 3} +def test_an_address_typed_with_a_space_is_scanned_without_it(tmp_path, serve): + """The prompt keeps what was pasted: with a trailing space the address + went to a name lookup, and the scan ended on "No such host is known".""" + pump = _StandInPump(registers={(READ_INPUT, 30002): 195}) + port = serve(pump) + + process, rows = _scan( + tmp_path, port, host=" 127.0.0.1 ", InputRegisters=[30002], HoldingRegisters=[] + ) + + assert process.returncode == 0, process.stderr + assert _values(rows, "input") == {30002: 195} + + def test_a_register_reading_zero_counts_as_answered(tmp_path, serve): """PowerShell's `0 -ne ''` is false: the closing line counted 23 of 41 answered registers on a real pump, every one reading 0 left out.""" diff --git a/tools/webif_capture.py b/tools/webif_capture.py index d9aabe84..463c0988 100644 --- a/tools/webif_capture.py +++ b/tools/webif_capture.py @@ -586,7 +586,13 @@ def _address(text: str) -> str: raise argparse.ArgumentTypeError( "give the address alone, like 192.0.2.10 or a name, not a web address" ) - return text + # The prompt keeps a pasted space; the integration strips it too. + address = text.strip() + if not address or any(character.isspace() for character in address): + raise argparse.ArgumentTypeError( + "give the address of the heat pump, like 192.0.2.10 or a name" + ) + return address def main(argv: list[str] | None = None) -> int: diff --git a/tools/weishaupt_scan.bat b/tools/weishaupt_scan.bat index ec8cbb85..61436860 100644 --- a/tools/weishaupt_scan.bat +++ b/tools/weishaupt_scan.bat @@ -40,6 +40,8 @@ param( Set-StrictMode -Version 3 $ErrorActionPreference = 'Stop' +# The prompt keeps a pasted space; the integration strips it too. +$HostName = $HostName.Trim() $FunctionReadHolding = 0x03 $FunctionReadInput = 0x04 From 955df4fc24a29390bb11a890e28566733ddabdb7 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:28:31 +0100 Subject: [PATCH 06/10] Drop stale statements from guard comments --- pyproject.toml | 11 ++++------- tests/test_budgets.py | 10 ++++------ 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a590ac9f..cac56132 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -272,11 +272,9 @@ property-decorators = ["propcache.api.cached_property"] [tool.mypy] python_version = "3.14" -# The type-check scope. Frozen at the modules that were clean when the gate -# was adopted; the nine that were not are listed with their error count in -# tests/test_mypy_scope.py, and widening this list is how that list empties. -# A module in neither place fails that guard - leaving one out is a decision -# somebody writes down, not something that happens. +# The type-check scope. A module left out has to be listed with its error +# count in tests/test_mypy_scope.py, or that guard fails - leaving one out is +# a decision somebody writes down, not something that happens. files = [ "custom_components/weishaupt_modbus/__init__.py", "custom_components/weishaupt_modbus/config_flow.py", @@ -299,8 +297,7 @@ files = [ "custom_components/weishaupt_modbus/entity_helpers.py", # Named so mypy resolves the MODULE: kennfeld.py sits next to a kennfeld/ # data directory, and a followed import lands on the directory instead, - # leaving every attribute of the module "undefined". Its own errors are - # switched off below until it is typed. + # leaving every attribute of the module "undefined". "custom_components/weishaupt_modbus/kennfeld.py", "custom_components/weishaupt_modbus/migrate_helpers.py", "custom_components/weishaupt_modbus/number.py", diff --git a/tests/test_budgets.py b/tests/test_budgets.py index 2aa436bf..9115a877 100644 --- a/tests/test_budgets.py +++ b/tests/test_budgets.py @@ -26,9 +26,8 @@ / "weishaupt_modbus" ) -# No module may pass this without an entry below. It sits in today's gap -# between the ordinary modules (the largest is under 600 lines) and the -# register table. +# No module may pass this without an entry below; only the register table +# has one. LINE_LIMIT = 900 LINE_BUDGETS = { @@ -40,9 +39,8 @@ # re-read from the top to change safely. COMPLEXITY_LIMIT = 15 -# What is over the limit today, exactly. None of these was simplified on -# adoption - the point of the guard is that nothing gets worse and every -# improvement is written down. +# A function over the limit needs an entry here, at its score: nothing gets +# worse unnoticed, and every improvement is written down. COMPLEXITY_BUDGETS: dict[str, int] = {} From 5da37e2f939417c50f5452992ac6ff0b8c1e7db6 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:56:36 +0100 Subject: [PATCH 07/10] Check the 1.x device cleanup removes nothing else Setup recreates a removed pump device under its old id, so the test now watches the registry's removal events. --- .github/mutations/plan.json | 7 ++++++ tests/test_e2e.py | 43 +++++++++++++++++++++++++------------ 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/.github/mutations/plan.json b/.github/mutations/plan.json index 679db4bf..47b322fd 100644 --- a/.github/mutations/plan.json +++ b/.github/mutations/plan.json @@ -4954,5 +4954,12 @@ "old": "$HostName = $HostName.Trim()", "new": "$HostName = $HostName", "tests": "scanned_without_it" + }, + { + "label": "migration: the 1.x cleanup removes every device of the entry", + "path": "custom_components/weishaupt_modbus/__init__.py", + "old": " for name in LEGACY_WEBIF_DEVICES:\n", + "new": " for device in dr.async_entries_for_config_entry(registry, config_entry.entry_id):\n registry.async_remove_device(device.id)\n for name in LEGACY_WEBIF_DEVICES:\n", + "tests": "devices_of_1_x" } ] diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 78de6a42..95b91296 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -1233,30 +1233,45 @@ async def test_an_entry_of_the_upstream_2_0_line_is_stripped_of_its_login_and_ma @pytest.mark.parametrize("postfix", ["", "2"]) async def test_the_web_interface_devices_of_1_x_go_with_their_entities(hass, postfix): """The migration removed the web-interface entities of 1.x but left their - devices, empty and with no way to delete them.""" + devices, empty and with no way to delete them. The pump's own devices, + and those of another entry, are not touched.""" entry = _entry(hass, data={**BASE_DATA, CONF.DEVICE_POSTFIX: postfix}, version=8) + other = _entry(hass, data={**BASE_DATA, CONF.DEVICE_POSTFIX: "other"}) registry = dr.async_get(hass) suffix = f"_{postfix}" if postfix else "" - for name in ( - "Webif Info Heizkreis", - "Webif Info Wärmepumpe", - "Webif 2. Wärmeerzeuger", - "Webif Statistik", - ): + registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(CONST.DOMAIN, DeviceConstants.SYS + suffix)}, + ) + registry.async_get_or_create( + config_entry_id=other.entry_id, + identifiers={(CONST.DOMAIN, "Webif Info Heizkreis" + suffix)}, + ) + legacy = { registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(CONST.DOMAIN, name + suffix)}, + ).id + for name in ( + "Webif Info Heizkreis", + "Webif Info Wärmepumpe", + "Webif 2. Wärmeerzeuger", + "Webif Statistik", ) + } + # Removed and added again by the setup, a device is back at once with + # its id; the removal is what shows. + removed = set() + + def note_removal(event): + if event.data["action"] == "remove": + removed.add(event.data["device_id"]) + + hass.bus.async_listen(dr.EVENT_DEVICE_REGISTRY_UPDATED, note_removal) await _setup(hass, entry) - left = [ - identifier - for device in dr.async_entries_for_config_entry(registry, entry.entry_id) - for _, identifier in device.identifiers - if identifier.startswith("Webif") - ] - assert left == [] + assert removed == legacy @pytest.mark.parametrize( From f9f0300a3666c4bdc679be1b558060e438cdd170 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:56:36 +0100 Subject: [PATCH 08/10] Drop adoption history from guard docstrings --- tests/test_budgets.py | 3 +-- tests/test_mypy_scope.py | 12 +++++------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/tests/test_budgets.py b/tests/test_budgets.py index 9115a877..836aed38 100644 --- a/tests/test_budgets.py +++ b/tests/test_budgets.py @@ -11,8 +11,7 @@ headroom for the next person to spend. Adding an entry is allowed. It is meant to be a visible, deliberate act in a -diff. Every number below was frozen at what the code weighed when the guard -was adopted - none of them is a target, and each is a place to shrink. +diff. No number below is a target; each is a place to shrink. """ import ast diff --git a/tests/test_mypy_scope.py b/tests/test_mypy_scope.py index 963aa2d2..befbbb77 100644 --- a/tests/test_mypy_scope.py +++ b/tests/test_mypy_scope.py @@ -1,9 +1,8 @@ """Every module is type-checked, or says in one line why it is not yet. -The mypy scope in pyproject.toml was frozen at the modules that were clean -when the gate was adopted. Nothing would otherwise stop a module added to the -package from simply not being checked - the job stays green because the file -is never named, which is the same shape as a guard that goes blind. +Nothing would otherwise stop a module added to the package from simply not +being checked - the job stays green because the file is never named, which +is the same shape as a guard that goes blind. This does not demand that everything be typed. It demands that leaving a module out be a decision somebody wrote down. @@ -18,9 +17,8 @@ REPO = pathlib.Path(__file__).resolve().parents[1] PACKAGE = REPO / "custom_components" / "weishaupt_modbus" -# What is deliberately still outside, and why - the error count mypy reported -# on adoption. Emptying this list is the point; every entry is a promise, not -# a permanent exemption. +# What is deliberately still outside, with the error count mypy reports for +# it. Every entry is a promise, not a permanent exemption. NOT_TYPE_CHECKED_YET = {} From b9b748bbb5dece383c5b30b7d160ada5105bd520 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:20:04 +0100 Subject: [PATCH 09/10] Remove the 1.x web-interface devices at every setup Releases before migrated the entry and left them behind; the migration alone never reached such an entry again. --- .github/mutations/plan.json | 10 +++++----- custom_components/weishaupt_modbus/__init__.py | 11 +++++++---- tests/test_e2e.py | 10 ++++++++-- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/.github/mutations/plan.json b/.github/mutations/plan.json index 47b322fd..f86a63a8 100644 --- a/.github/mutations/plan.json +++ b/.github/mutations/plan.json @@ -4907,16 +4907,16 @@ "tests": "upstream_2_0_line" }, { - "label": "migration: the 1.x web-interface devices stay", + "label": "setup: the 1.x web-interface devices stay", "path": "custom_components/weishaupt_modbus/__init__.py", - "old": " _remove_legacy_web_interface_devices(hass, config_entry, new_data)\n", + "old": " _remove_legacy_web_interface_devices(hass, entry)\n", "new": "", "tests": "devices_of_1_x" }, { - "label": "migration: the 1.x web-interface devices are looked up without the postfix", + "label": "setup: the 1.x web-interface devices are looked up without the postfix", "path": "custom_components/weishaupt_modbus/__init__.py", - "old": " postfix = device_postfix(entry_data)\n for name in LEGACY_WEBIF_DEVICES:\n", + "old": " postfix = device_postfix(config_entry.data)\n for name in LEGACY_WEBIF_DEVICES:\n", "new": " postfix = \"\"\n for name in LEGACY_WEBIF_DEVICES:\n", "tests": "devices_of_1_x" }, @@ -4956,7 +4956,7 @@ "tests": "scanned_without_it" }, { - "label": "migration: the 1.x cleanup removes every device of the entry", + "label": "setup: the 1.x cleanup removes every device of the entry", "path": "custom_components/weishaupt_modbus/__init__.py", "old": " for name in LEGACY_WEBIF_DEVICES:\n", "new": " for device in dr.async_entries_for_config_entry(registry, config_entry.entry_id):\n registry.async_remove_device(device.id)\n for name in LEGACY_WEBIF_DEVICES:\n", diff --git a/custom_components/weishaupt_modbus/__init__.py b/custom_components/weishaupt_modbus/__init__.py index 16e1aab7..dbb4facb 100644 --- a/custom_components/weishaupt_modbus/__init__.py +++ b/custom_components/weishaupt_modbus/__init__.py @@ -182,6 +182,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: MyConfigEntry) -> bool: configurations = {} report_circuits(hass, entry, configurations) remove_circuits_left_off(hass, entry) + # On every setup, not in the migration: releases before 2.1.2 migrated + # the entry and left them behind. + _remove_legacy_web_interface_devices(hass, entry) powermap = PowerMap(entry, hass) await powermap.initialize() @@ -315,12 +318,13 @@ def _is_legacy_web_interface(unique_id: str, entry_data: Mapping[str, Any]) -> b ) +@callback def _remove_legacy_web_interface_devices( - hass: HomeAssistant, config_entry: MyConfigEntry, entry_data: Mapping[str, Any] + hass: HomeAssistant, config_entry: MyConfigEntry ) -> None: """Left behind, they stayed empty, and the UI offers no delete for them.""" registry = dr.async_get(hass) - postfix = device_postfix(entry_data) + postfix = device_postfix(config_entry.data) for name in LEGACY_WEBIF_DEVICES: found = registry.async_get_device_by_identifier( (CONST.DOMAIN, name + postfix), config_entry.entry_id @@ -370,14 +374,13 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: MyConfigEntry) if config_entry.version < 9: _LOGGER.debug("Version <9 detected") # Versions 5 to 8 added the web interface; it is gone, and so are the - # entities and devices it registered. + # entities it registered. Their devices go at setup. registry = er.async_get(hass) for registry_entry in er.async_entries_for_config_entry( registry, config_entry.entry_id ): if _is_legacy_web_interface(registry_entry.unique_id, new_data): registry.async_remove(registry_entry.entity_id) - _remove_legacy_web_interface_devices(hass, config_entry, new_data) if config_entry.version < 10: _LOGGER.debug("Version <10 detected") diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 95b91296..72d52e3b 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -1231,11 +1231,17 @@ async def test_an_entry_of_the_upstream_2_0_line_is_stripped_of_its_login_and_ma @pytest.mark.parametrize("postfix", ["", "2"]) -async def test_the_web_interface_devices_of_1_x_go_with_their_entities(hass, postfix): +# 11: an entry an earlier release had migrated already, devices left behind. +@pytest.mark.parametrize("version", [8, 11]) +async def test_the_web_interface_devices_of_1_x_go_with_their_entities( + hass, postfix, version +): """The migration removed the web-interface entities of 1.x but left their devices, empty and with no way to delete them. The pump's own devices, and those of another entry, are not touched.""" - entry = _entry(hass, data={**BASE_DATA, CONF.DEVICE_POSTFIX: postfix}, version=8) + entry = _entry( + hass, data={**BASE_DATA, CONF.DEVICE_POSTFIX: postfix}, version=version + ) other = _entry(hass, data={**BASE_DATA, CONF.DEVICE_POSTFIX: "other"}) registry = dr.async_get(hass) suffix = f"_{postfix}" if postfix else "" From 5c861cb5413c0746343d3fdc155c8d3f39a5cd02 Mon Sep 17 00:00:00 2001 From: Varitras <86969187+Varitras@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:20:04 +0100 Subject: [PATCH 10/10] Note the device postfix in the power map file name --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index d9ae8f7a..5cb615f9 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,9 @@ type: picture image: /local/local/weishaupt_modbus_powermap.svg ``` +For a further heat pump with a device postfix, the file name ends in +`_`, such as `weishaupt_modbus_powermap_2.svg`. + ## Web interface (experimental) The controller's local web interface, *WEM Lokal*, shows values Modbus does