diff --git a/CHANGELOG.md b/CHANGELOG.md index f8b94fad0..d41f5196d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.5b1] - Unreleased ### Fixed +- **Add Smart Plug (HA mode) — search dropdown let users pick entities the schema would reject, surfacing as a cryptic regex error on Save (#1388)** — Reporter MartinNYHC opened the Add Smart Plug dialog, typed a search prefix matching a multi-entity HA device (a Shelly-style outlet exposing one `switch.*` and several `sensor.*` / `binary_sensor.*` siblings under the same friendly-name prefix), clicked one of the entities, filled in the optional power/energy sensors, and clicked Save. The backend returned 422 with the raw Pydantic message `String should match pattern '^(switch|light|input_boolean|script)\.[a-z0-9_]+$'`. After the dropdown closed and the search cleared, the entity-list refetch (with no search param) returned the default-domain-filtered list — which didn't include the user's pick — so `selectedEntity = haEntities.find(...)` was undefined, the field rendered as visually empty (placeholder shown), but `haEntityId` still held the bad value the user had selected. Root cause was at `backend/app/services/homeassistant.py::list_entities`: when a search query was present, the function bypassed the domain filter entirely and returned matches across every HA domain — including ones the `SmartPlugBase.ha_entity_id` regex at `backend/app/schemas/smart_plug.py:17` could never accept. Offering a clickable choice the user can't save is broken UX; the fact that the error message then said `switch|light|input_boolean|script` made it look like a schema problem rather than a search-permissiveness problem. Fix: the allowed-domains filter (`{"switch", "light", "input_boolean", "script"}`, kept in sync with the schema regex) now always runs, and search composes on top of it as an additional substring match against `entity_id` or `friendly_name`. Whitespace-only search strings are treated as no search. Verified the smart-plug code path is unchanged between 0.2.4 and 0.2.4.1 — this bug was latent since the script-domain commit in February 2026 and was only noticed now because the reporter hadn't reopened the modal in months. 5 new regression tests in `backend/tests/unit/services/test_homeassistant_list_entities.py` cover the no-search baseline, the search-still-domain-filters case (the actual #1388 reproduction), the entity_id-or-friendly_name substring match, case-insensitivity, and the whitespace-only edge case. - **H2S with no AMS could not start a print — firmware rejected the dispatch with `07FF_8012` "Failed to get AMS mapping table" (#1386)** — Reporter krootstijn (H2S + no AMS) clicked Print and got an immediate firmware error. Two stacked misclassifications had quietly added H2S to the dual-nozzle code paths over time. The first was in `start_print_job` at `backend/app/services/bambu_mqtt.py:3168` — the `is_h2d` flag was set true for `("H2D", "H2D PRO", "H2DPRO", "H2C", "H2S", "X2D")`. That single flag controlled both the firmware bool→int format (legitimately needed for the whole H-family) *and* the external-spool routing branch (`ext_ams_id = tray_id if is_h2d else 255`) which is only correct for actual dual-nozzle printers. With no AMS, the external-spool sentinel is `254`; the dual-nozzle branch wrote `ams_id=254` into `ams_mapping2` instead of the canonical `255`. The exact failure shape (`07FF_8012`) is even called out in the comment six lines above the bad line — H2S was getting routed straight into the path the comment warned against. The second misclassification was the use_ams=False fallback at `bambu_mqtt.py:3213` (`if ams_mapping and use_ams and not is_h2d`) — meant to skip the safety drop on dual-nozzle printers where `use_ams` controls nozzle routing — also skipped H2S, so the firmware never got a chance to fall back to external-spool mode. A third site at `bambu_mqtt.py:3987` (and its sibling at `backend/app/api/routes/kprofiles.py:119`) classified dual-nozzle by serial prefix `("094", "20P9", "31B8B")`, which is wrong because H2S shares prefix `094` with H2D. Fix splits the conflated flag into two: `is_h_family` (firmware-format gate, includes H2S) and `is_dual_nozzle` (routing/use_ams gate, excludes H2S; prefers the runtime `_is_dual_nozzle` flag set from `device.extruder.info` and falls back to model name for the brief window right after connect). The K-profile delete and the edit route now use the same two-source check instead of the serial prefix. Empirically verified across 9+ stored H2S support bundles (`nozzle_count: 1` in every one) and the reporter's bug log (`07FF_8012` immediately after dispatch). Four new regression tests: `test_h2s_single_external_spool_uses_main_id`, `test_h2s_no_ams_forces_use_ams_false`, `test_h2s_keeps_integer_format_for_calibration_fields`, plus a new `test_h2s_uses_single_nozzle_format` in the K-profile suite. The K-profile detection tests were also updated to set both model name and runtime flag rather than relying on serial prefix, since the source-of-truth has shifted. ## [0.2.4.1] - 2026-05-16 diff --git a/backend/app/services/homeassistant.py b/backend/app/services/homeassistant.py index 4f7065ffd..57ccfe5d6 100644 --- a/backend/app/services/homeassistant.py +++ b/backend/app/services/homeassistant.py @@ -237,8 +237,15 @@ class HomeAssistantService: async def list_entities(self, url: str, token: str, search: str | None = None) -> list[dict]: """List available entities from HA. - By default, returns switch/light/input_boolean domains. - When search is provided, searches ALL entities by entity_id or friendly_name. + Always filters to switch/light/input_boolean/script — the only domains + the SmartPlugBase.ha_entity_id pattern accepts. When a search query is + provided it narrows the same domain-filtered list by entity_id or + friendly_name substring (case-insensitive). + + Previously search bypassed the domain filter, which let users pick a + sensor.* or binary_sensor.* entity from the dropdown that the backend + schema would then reject with the cryptic Pydantic pattern error + (#1388). Picking what you can't save isn't a useful UX. Returns list of entity dicts with: - entity_id: str @@ -246,8 +253,9 @@ class HomeAssistantService: - state: str - domain: str """ - # Default domains for smart plug control - default_domains = {"switch", "light", "input_boolean", "script"} + # Allowed domains for smart plug control — must mirror the regex in + # backend/app/schemas/smart_plug.py:17 (SmartPlugBase.ha_entity_id). + allowed_domains = {"switch", "light", "input_boolean", "script"} try: async with httpx.AsyncClient(timeout=self.timeout) as client: @@ -265,14 +273,13 @@ class HomeAssistantService: domain = entity_id.split(".")[0] if "." in entity_id else "" friendly_name = entity.get("attributes", {}).get("friendly_name", entity_id) - # If searching, match against entity_id or friendly_name - if search_lower: - if search_lower not in entity_id.lower() and search_lower not in friendly_name.lower(): - continue - else: - # No search: filter to default domains only - if domain not in default_domains: - continue + if domain not in allowed_domains: + continue + + if search_lower and ( + search_lower not in entity_id.lower() and search_lower not in friendly_name.lower() + ): + continue entities.append( { diff --git a/backend/tests/unit/services/test_homeassistant_list_entities.py b/backend/tests/unit/services/test_homeassistant_list_entities.py new file mode 100644 index 000000000..6d695f486 --- /dev/null +++ b/backend/tests/unit/services/test_homeassistant_list_entities.py @@ -0,0 +1,148 @@ +"""Regression tests for HomeAssistantService.list_entities domain filtering (#1388). + +Reporter MartinNYHC opened the Add Smart Plug modal in HA mode, typed a search +matching a multi-entity device (one Shelly outlet exposed as switch + several +sensor.* and binary_sensor.* siblings), and clicked a non-switch entity. The +schema regex for ha_entity_id only accepts switch/light/input_boolean/script, +so the Save round-trip came back 422 with the raw Pydantic pattern string — +the same regex shown in the bug report screenshot. + +Root cause: before this fix, the search path bypassed the domain filter +entirely, so the dropdown showed every entity whose entity_id or friendly_name +matched the query, regardless of whether the schema would later accept it. +Users could click an entity they had no way to actually save. + +Fix: always apply the allowed-domains filter, and apply the search filter on +top of it. The two filters now compose instead of branching. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from backend.app.services.homeassistant import HomeAssistantService + + +def _ha_response(entities: list[dict]) -> MagicMock: + response = MagicMock() + response.raise_for_status = MagicMock() + response.json = MagicMock(return_value=entities) + return response + + +def _mock_get(entities: list[dict]): + async_client = MagicMock() + async_client.get = AsyncMock(return_value=_ha_response(entities)) + async_client.__aenter__ = AsyncMock(return_value=async_client) + async_client.__aexit__ = AsyncMock(return_value=None) + return async_client + + +@pytest.mark.asyncio +async def test_no_search_returns_only_allowed_domains(): + """Without a search query, only switch/light/input_boolean/script appear.""" + entities = [ + {"entity_id": "switch.printer", "attributes": {"friendly_name": "Printer"}, "state": "on"}, + {"entity_id": "light.lamp", "attributes": {"friendly_name": "Lamp"}, "state": "off"}, + {"entity_id": "input_boolean.flag", "attributes": {"friendly_name": "Flag"}, "state": "on"}, + {"entity_id": "script.morning", "attributes": {"friendly_name": "Morning"}, "state": "off"}, + {"entity_id": "sensor.power", "attributes": {"friendly_name": "Power"}, "state": "12.3"}, + {"entity_id": "binary_sensor.status", "attributes": {"friendly_name": "Status"}, "state": "on"}, + {"entity_id": "media_player.tv", "attributes": {"friendly_name": "TV"}, "state": "idle"}, + ] + service = HomeAssistantService() + + with patch("httpx.AsyncClient", return_value=_mock_get(entities)): + result = await service.list_entities("http://ha.local", "tok") + + domains = sorted({e["domain"] for e in result}) + assert domains == ["input_boolean", "light", "script", "switch"] + assert len(result) == 4 + + +@pytest.mark.asyncio +async def test_search_still_filters_to_allowed_domains(): + """#1388: search must compose with the domain filter, not replace it. + + Reporter's setup: a Shelly outlet device generates one switch.* entity + and several sensor.*/binary_sensor.* siblings, all sharing a common + friendly-name prefix. The user searched the prefix and was offered the + non-switch siblings as clickable options — picking one led to the 422 + pattern error. After the fix, the search-narrowed list excludes them. + """ + entities = [ + { + "entity_id": "switch.prise_imprimante_3d_bambu_output_1", + "attributes": {"friendly_name": "Prise imprimante 3D Bambu Output 1"}, + "state": "on", + }, + { + "entity_id": "sensor.prise_imprimante_3d_bambu_output_1_power", + "attributes": {"friendly_name": "Prise imprimante 3D Bambu Output 1 Puissance"}, + "state": "12.5", + }, + { + "entity_id": "binary_sensor.prise_imprimante_3d_bambu_output_1_status", + "attributes": {"friendly_name": "Prise imprimante 3D Bambu Output 1 Status"}, + "state": "on", + }, + { + "entity_id": "sensor.prise_imprimante_3d_bambu_output_1_energy", + "attributes": {"friendly_name": "Prise imprimante 3D Bambu Output 1 Énergie"}, + "state": "0.42", + }, + ] + service = HomeAssistantService() + + with patch("httpx.AsyncClient", return_value=_mock_get(entities)): + result = await service.list_entities("http://ha.local", "tok", search="Prise imprimante") + + assert len(result) == 1 + assert result[0]["entity_id"] == "switch.prise_imprimante_3d_bambu_output_1" + + +@pytest.mark.asyncio +async def test_search_matches_by_entity_id_or_friendly_name(): + """Search still matches across both fields, just within the allowed set.""" + entities = [ + {"entity_id": "switch.printer_a", "attributes": {"friendly_name": "Living Room Plug"}, "state": "on"}, + {"entity_id": "switch.printer_b", "attributes": {"friendly_name": "Office Plug"}, "state": "off"}, + {"entity_id": "light.living_room", "attributes": {"friendly_name": "Ceiling"}, "state": "off"}, + ] + service = HomeAssistantService() + + with patch("httpx.AsyncClient", return_value=_mock_get(entities)): + result = await service.list_entities("http://ha.local", "tok", search="living") + + ids = sorted(e["entity_id"] for e in result) + assert ids == ["light.living_room", "switch.printer_a"] + + +@pytest.mark.asyncio +async def test_search_is_case_insensitive(): + entities = [ + {"entity_id": "switch.PRINTER", "attributes": {"friendly_name": "Printer"}, "state": "on"}, + ] + service = HomeAssistantService() + + with patch("httpx.AsyncClient", return_value=_mock_get(entities)): + result = await service.list_entities("http://ha.local", "tok", search="PRINTER") + + assert len(result) == 1 + + +@pytest.mark.asyncio +async def test_empty_search_treated_as_no_search(): + """A whitespace-only search string should fall back to the full allowed- + domain list rather than matching everything that contains an empty string.""" + entities = [ + {"entity_id": "switch.foo", "attributes": {"friendly_name": "Foo"}, "state": "on"}, + {"entity_id": "sensor.bar", "attributes": {"friendly_name": "Bar"}, "state": "1"}, + ] + service = HomeAssistantService() + + with patch("httpx.AsyncClient", return_value=_mock_get(entities)): + result = await service.list_entities("http://ha.local", "tok", search=" ") + + assert len(result) == 1 + assert result[0]["entity_id"] == "switch.foo"