Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 101 additions & 10 deletions .github/mutations/plan.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
{
Expand Down Expand Up @@ -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"
},
{
Expand All @@ -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"
},
{
Expand Down Expand Up @@ -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"
},
{
Expand Down Expand Up @@ -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"
},
{
Expand Down Expand Up @@ -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"
}
]
7 changes: 4 additions & 3 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`_<postfix>`, such as `weishaupt_modbus_powermap_2.svg`.

## Web interface (experimental)

The controller's local web interface, *WEM Lokal*, shows values Modbus does
Expand Down
42 changes: 36 additions & 6 deletions custom_components/weishaupt_modbus/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
18 changes: 14 additions & 4 deletions custom_components/weishaupt_modbus/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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": {
Expand Down
11 changes: 7 additions & 4 deletions custom_components/weishaupt_modbus/outage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 3 additions & 1 deletion custom_components/weishaupt_modbus/webif_coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 4 additions & 7 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
13 changes: 5 additions & 8 deletions tests/test_budgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = {
Expand All @@ -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] = {}


Expand Down
Loading
Loading