From 00fcf090d4e9ae77596d37d8536df12f934f773e Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:55:53 +0200 Subject: [PATCH 1/2] fix(config_flow): inject the HA websession into OWNd discovery and correct the OWNd licence OWNd.discovery.find_gateways() and get_gateway() fetch the gateway's UPnP/SOAP description over HTTP and accept an optional aiohttp session. The config flow called them without one, so OWNd opened its own ClientSession per call. Pass async_get_clientsession(self.hass) and mark inject-websession done in quality_scale.yaml. quality_scale.yaml also described OWNd as MIT/GPL licensed; it is Apache-2.0 (OWNd LICENSE and pyproject.toml). Rebuilt on v2-phase1-architecture: the typing changes this branch carried before are in #765, and the OWNd pin stays at 2.0.0b11. Co-Authored-By: Claude Opus 5.5 --- custom_components/myhome/config_flow.py | 10 +++++-- custom_components/myhome/quality_scale.yaml | 6 ++--- docs/quality_scale_plan.md | 2 +- tests/test_config_flow.py | 30 +++++++++++++++++++++ 4 files changed, 42 insertions(+), 6 deletions(-) diff --git a/custom_components/myhome/config_flow.py b/custom_components/myhome/config_flow.py index 08d995bc2..0f65b9c77 100644 --- a/custom_components/myhome/config_flow.py +++ b/custom_components/myhome/config_flow.py @@ -20,6 +20,7 @@ from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession from OWNd.connection import OWNGateway, OWNSession from OWNd.discovery import find_gateways, get_gateway from voluptuous import In, Required @@ -93,7 +94,9 @@ async def async_step_user(self, user_input=None): # type: ignore try: async with asyncio.timeout(5): - local_gateways = await find_gateways() + local_gateways = await find_gateways( + session=async_get_clientsession(self.hass) + ) except asyncio.TimeoutError: return self.async_abort(reason="discovery_timeout") @@ -200,7 +203,10 @@ async def async_step_custom(self, user_input=None, errors=None): # type: ignore if not errors: try: async with asyncio.timeout(5): - discovered = await get_gateway(user_input["address"]) + discovered = await get_gateway( + user_input["address"], + session=async_get_clientsession(self.hass), + ) except (asyncio.TimeoutError, Exception): # pylint: disable=broad-except discovered = None diff --git a/custom_components/myhome/quality_scale.yaml b/custom_components/myhome/quality_scale.yaml index 0758baad7..d8461013f 100644 --- a/custom_components/myhome/quality_scale.yaml +++ b/custom_components/myhome/quality_scale.yaml @@ -28,7 +28,7 @@ rules: comment: ConfigFlow with SSDP discovery, manual entry, serial transport, reauth and reconfigure. dependency-transparency: status: done - comment: OWNd is published on PyPI from the public OpenWebNet-HA/OWNd repository with CI; MIT/GPL licensed. + comment: OWNd is published on PyPI from the public OpenWebNet-HA/OWNd repository with CI; Apache-2.0 licensed. docs-actions: status: done comment: docs/configuration/services.md documents all eleven services and their fields (services.yaml, strings.json and icons.json list the same eleven). @@ -171,8 +171,8 @@ rules: status: done comment: OWNd client library is 100% native non-blocking asyncio protocols and streams. inject-websession: - status: exempt - comment: No HTTP is used; communication is via raw TCP OpenWebNet sockets. + status: done + comment: async_get_clientsession(self.hass) is passed to OWNd.discovery.find_gateways and get_gateway for UPnP/SOAP HTTP calls; OpenWebNet bus communication uses raw TCP sockets. strict-typing: status: done comment: mypy --strict is ratcheted per module by scripts/typing_ratchet.py against mypy_baseline.json; 0 errors remain in all modules. OWNd ships py.typed (PEP 561) as of 2.0.0b7. diff --git a/docs/quality_scale_plan.md b/docs/quality_scale_plan.md index f66ae6bbb..3c26ab8ec 100644 --- a/docs/quality_scale_plan.md +++ b/docs/quality_scale_plan.md @@ -51,7 +51,7 @@ Add a parametrised test that asserts the table so it cannot regress. - ✅ Ratchet in place: `[tool.mypy]` (strict) in `pyproject.toml`, `mypy_baseline.json` with a per-module ceiling, `scripts/typing_ratchet.py` (fails on any regression, `--update` only lowers), workflow `strict-typing.yml`. 15 of 28 modules are clean (`const`, `data`, `gateway`, `myhome_device`, `services`, `device_trigger`, `decoder_pool`, transports, diagnostics, repairs, bus monitor); **504 errors remain in 13 modules** — biggest first: `config_flow` 105, `cover` 64, `validate` 57, `binary_sensor` 43, `light` 37, `sensor` 32, `websocket` 30, `__init__` 27, `button` 27, `climate` 25, `media_player` 25, `alarm_control_panel` 18, `switch` 14 (`discovery` and `router` are clean). Each PR that touches a module should leave it lower and run `--update`. - ✅ OWNd ships `py.typed` (PEP 561). The `OWNd.*` `ignore_missing_imports` override is gone and `strict-typing.yml` installs the `manifest.json` `OWNd==` pin, so the ratchet checks the integration against the OWNd types users install. -- `inject-websession` is correctly `exempt` (raw TCP, no HTTP); `async-dependency` is done. +- ✅ `inject-websession`: config-flow discovery passes `async_get_clientsession(hass)` to `OWNd.discovery.find_gateways` / `get_gateway` for the UPnP/SOAP HTTP calls (the bus itself is raw TCP); `async-dependency` is done. ## Maintainability (not a scale rule, keeps the rules honest) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 7612a6ab3..8d5e34bfd 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -2654,3 +2654,33 @@ async def test_options_flow_ignored_addresses(hass: HomeAssistant) -> None: assert result_comma["type"] == FlowResultType.CREATE_ENTRY assert entry.options[CONF_IGNORED_ADDRESSES] == [] + +@pytest.mark.asyncio +async def test_config_flow_injects_ha_websession_into_discovery(hass: HomeAssistant): + """Verify async_get_clientsession(hass) is passed to find_gateways and get_gateway.""" + from homeassistant.helpers.aiohttp_client import async_get_clientsession + + expected_session = async_get_clientsession(hass) + with patch( + "custom_components.myhome.config_flow.find_gateways", return_value=[] + ) as mock_find, patch( + "custom_components.myhome.config_flow.get_gateway", return_value=None + ) as mock_get: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + mock_find.assert_awaited_once_with(session=expected_session) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"serial": "00:00:00:00:00:00"} + ) + assert result2["step_id"] == "custom" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "192.168.1.99", "port": 20000}, + ) + assert result3["step_id"] == "custom_manual" + mock_get.assert_awaited_once_with("192.168.1.99", session=expected_session) + From 0fa54c46aebaf593fa7a9b591e5f71c2a4adaec2 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:45:20 +0200 Subject: [PATCH 2/2] fix(config_flow): log the swallowed UPnP lookup failure and refresh the typing docs Review follow-ups on #757: - async_step_custom still falls back to manual MAC entry on any UPnP lookup failure, but logs it at debug level; the redundant (asyncio.TimeoutError, Exception) tuple becomes Exception. - The websession test patches find_gateways/get_gateway with an explicit new_callable=AsyncMock, and the timeout test asserts the new debug line. - docs/quality_scale_plan.md: replace the stale "504 errors remain in 13 modules" with the actual state (71 modules clean, baseline 0). Co-Authored-By: Claude Opus 5.5 --- custom_components/myhome/config_flow.py | 9 ++++++++- docs/quality_scale_plan.md | 2 +- tests/test_config_flow.py | 16 +++++++++++++--- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/custom_components/myhome/config_flow.py b/custom_components/myhome/config_flow.py index 0f65b9c77..818d16e1c 100644 --- a/custom_components/myhome/config_flow.py +++ b/custom_components/myhome/config_flow.py @@ -207,7 +207,14 @@ async def async_step_custom(self, user_input=None, errors=None): # type: ignore user_input["address"], session=async_get_clientsession(self.hass), ) - except (asyncio.TimeoutError, Exception): # pylint: disable=broad-except + except Exception as err: # pylint: disable=broad-except + # UPnP lookup is optional: any failure (timeout, HTTP, + # malformed XML) falls back to manual MAC entry. + LOGGER.debug( + "UPnP lookup of %s failed, asking for the MAC: %r", + user_input["address"], + err, + ) discovered = None if discovered is not None: diff --git a/docs/quality_scale_plan.md b/docs/quality_scale_plan.md index 3c26ab8ec..72ee4e969 100644 --- a/docs/quality_scale_plan.md +++ b/docs/quality_scale_plan.md @@ -49,7 +49,7 @@ Add a parametrised test that asserts the table so it cannot regress. ## Phase 5 — 🏆 Platinum: `strict-typing` -- ✅ Ratchet in place: `[tool.mypy]` (strict) in `pyproject.toml`, `mypy_baseline.json` with a per-module ceiling, `scripts/typing_ratchet.py` (fails on any regression, `--update` only lowers), workflow `strict-typing.yml`. 15 of 28 modules are clean (`const`, `data`, `gateway`, `myhome_device`, `services`, `device_trigger`, `decoder_pool`, transports, diagnostics, repairs, bus monitor); **504 errors remain in 13 modules** — biggest first: `config_flow` 105, `cover` 64, `validate` 57, `binary_sensor` 43, `light` 37, `sensor` 32, `websocket` 30, `__init__` 27, `button` 27, `climate` 25, `media_player` 25, `alarm_control_panel` 18, `switch` 14 (`discovery` and `router` are clean). Each PR that touches a module should leave it lower and run `--update`. +- ✅ Ratchet in place: `[tool.mypy]` (strict) in `pyproject.toml`, `mypy_baseline.json` with a per-module ceiling, `scripts/typing_ratchet.py` (fails on any regression, `--update` only lowers), workflow `strict-typing.yml`. ✅ All 71 modules are clean: `mypy_baseline.json` holds `"total": 0` with no per-module ceilings, so any new `mypy --strict` error fails the job. - ✅ OWNd ships `py.typed` (PEP 561). The `OWNd.*` `ignore_missing_imports` override is gone and `strict-typing.yml` installs the `manifest.json` `OWNd==` pin, so the ratchet checks the integration against the OWNd types users install. - ✅ `inject-websession`: config-flow discovery passes `async_get_clientsession(hass)` to `OWNd.discovery.find_gateways` / `get_gateway` for the UPnP/SOAP HTTP calls (the bus itself is raw TCP); `async-dependency` is done. diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 8d5e34bfd..c92f51f89 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -594,9 +594,14 @@ async def test_user_step_discovery_timeout(hass: HomeAssistant) -> None: assert result["reason"] == "discovery_timeout" -async def test_custom_step_validation_and_timeout(hass: HomeAssistant) -> None: +async def test_custom_step_validation_and_timeout( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test custom step error on invalid IP and timeout fallback to custom_manual.""" import asyncio + import logging + + caplog.set_level(logging.DEBUG, logger="custom_components.myhome") with patch("custom_components.myhome.config_flow.find_gateways", return_value=[]): result = await hass.config_entries.flow.async_init( @@ -626,6 +631,7 @@ async def test_custom_step_validation_and_timeout(hass: HomeAssistant) -> None: ) assert result_manual["type"] == FlowResultType.FORM assert result_manual["step_id"] == "custom_manual" + assert "UPnP lookup of 192.168.1.50 failed, asking for the MAC: TimeoutError()" in caplog.text async def test_custom_manual_step_invalid_inputs(hass: HomeAssistant) -> None: @@ -2662,9 +2668,13 @@ async def test_config_flow_injects_ha_websession_into_discovery(hass: HomeAssist expected_session = async_get_clientsession(hass) with patch( - "custom_components.myhome.config_flow.find_gateways", return_value=[] + "custom_components.myhome.config_flow.find_gateways", + new_callable=AsyncMock, + return_value=[], ) as mock_find, patch( - "custom_components.myhome.config_flow.get_gateway", return_value=None + "custom_components.myhome.config_flow.get_gateway", + new_callable=AsyncMock, + return_value=None, ) as mock_get: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}