diff --git a/.github/mutations/plan.json b/.github/mutations/plan.json index 962c190a..f86a63a8 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" }, { @@ -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" }, { @@ -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,96 @@ "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" + }, + { + "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" + }, + { + "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": "setup: the 1.x web-interface devices stay", + "path": "custom_components/weishaupt_modbus/__init__.py", + "old": " _remove_legacy_web_interface_devices(hass, entry)\n", + "new": "", + "tests": "devices_of_1_x" + }, + { + "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(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" + }, + { + "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" + }, + { + "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" + }, + { + "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" + }, + { + "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", + "tests": "devices_of_1_x" } ] 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/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 diff --git a/custom_components/weishaupt_modbus/__init__.py b/custom_components/weishaupt_modbus/__init__.py index c536314e..dbb4facb 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 @@ -172,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() @@ -305,6 +318,21 @@ 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 +) -> None: + """Left behind, they stayed empty, and the UI offers no delete for them.""" + registry = dr.async_get(hass) + 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 + ) + 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,12 +367,14 @@ 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 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 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/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/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/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..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 @@ -26,9 +25,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 +38,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] = {} 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_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") 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_e2e.py b/tests/test_e2e.py index 461dea03..72d52e3b 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,79 @@ 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"]) +# 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=version + ) + other = _entry(hass, data={**BASE_DATA, CONF.DEVICE_POSTFIX: "other"}) + registry = dr.async_get(hass) + suffix = f"_{postfix}" if postfix else "" + 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) + + assert removed == legacy + + @pytest.mark.parametrize( ("postfix", "web_interface_id", "pump_id"), [ 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 = {} 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/tests/test_webif_coordinator.py b/tests/test_webif_coordinator.py index e6c73c63..5e2c841b 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( @@ -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 ): 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