From 613eb8a7448981efc384c1edb0e45255ec9c9074 Mon Sep 17 00:00:00 2001 From: Melvin Strobl Date: Thu, 22 Oct 2026 17:48:41 +0200 Subject: [PATCH] wren node takes switches (plug, socket, appliances), refused when the model lacks the kind --- backend/fluksio/flow/nodes/wren.py | 26 +++++++++++++++++++++++++- backend/tests/flow/conftest.py | 2 +- backend/tests/flow/test_wren.py | 9 +++++++++ docs/reference/node-types.md | 13 ++++++++----- 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/backend/fluksio/flow/nodes/wren.py b/backend/fluksio/flow/nodes/wren.py index af6354e..d211d50 100644 --- a/backend/fluksio/flow/nodes/wren.py +++ b/backend/fluksio/flow/nodes/wren.py @@ -57,6 +57,10 @@ KIND_WORDS = { "water": "water", "watering": "water", "sprinkler": "water", + "switch": "switch", + "plug": "switch", + "socket": "switch", + "appliances": "switch", } #: What the node reads, and the types each may be. Only the prompt wakes it. @@ -85,6 +89,7 @@ _TOOLS = { "heat": "heating", "shutter": "shutters", "water": "watering", + "switch": "switches", } #: What a call sets a target to, by the target's type: (bool, number, text). @@ -102,6 +107,8 @@ _VALUES: dict[str, tuple[bool | None, float | None, str | None]] = { "shutters.stop": (None, None, "stop"), "watering.start": (True, None, "on"), "watering.stop": (False, None, "off"), + "switches.on": (True, 1, "on"), + "switches.off": (False, 0, "off"), } #: An applied call, said: "kitchen light on". `heating.set` says its number. @@ -117,6 +124,8 @@ _SAID = { "shutters.stop": "shutters stopped", "watering.start": "watering on", "watering.stop": "watering off", + "switches.on": "switch on", + "switches.off": "switch off", } #: What every area held in training: a light, and in the living room, which @@ -160,15 +169,24 @@ _GROUP_KINDS = { "heating": "heat", "watering": "water", "sprinklers": "water", + "switches": "switch", + "plugs": "switch", + "appliances": "switch", } #: The call a group command makes on each kind, by its action word. _GROUP_TOOLS = { - "on": {"light": "lights.on", "media": "media.play", "water": "watering.start"}, + "on": { + "light": "lights.on", + "media": "media.play", + "water": "watering.start", + "switch": "switches.on", + }, "off": { "light": "lights.off", "media": "media.stop", "heat": "heating.off", "water": "watering.stop", + "switch": "switches.off", }, "open": {"shutter": "shutters.up"}, "up": {"shutter": "shutters.up"}, @@ -194,6 +212,8 @@ _GROUP_SAID = { "shutters.stop": "shutters stopped", "watering.start": "watering on", "watering.stop": "watering off", + "switches.on": "switches on", + "switches.off": "switches off", } @@ -426,6 +446,10 @@ class WrenNode(Node): f"_, the area one of {', '.join(areas)}, the kind " f"one of {', '.join(KIND_WORDS)}." ) + if device[1] not in wren.KINDS: # switches came with wren's s10 + raise ValueError( + f"'{spec.port}' is a {device[1]}, which this wren does not know." + ) if spec.dtype not in _TARGET_TYPES: raise ValueError( f"'{spec.port}' is {spec.dtype.value}; a target is bool, int, " diff --git a/backend/tests/flow/conftest.py b/backend/tests/flow/conftest.py index 5af4465..7d6ab63 100644 --- a/backend/tests/flow/conftest.py +++ b/backend/tests/flow/conftest.py @@ -34,7 +34,7 @@ class FakeWren: *("living room", "kitchen", "bathroom", "bedroom", "office"), *("garden", "patio", "garage", "driveway", "balcony"), ] - KINDS = ["light", "media", "heat", "shutter", "water"] + KINDS = ["light", "media", "heat", "shutter", "water", "switch"] Device = _Device House = _House diff --git a/backend/tests/flow/test_wren.py b/backend/tests/flow/test_wren.py index 0c4babf..00de77f 100644 --- a/backend/tests/flow/test_wren.py +++ b/backend/tests/flow/test_wren.py @@ -40,13 +40,21 @@ def test_targets_of_one_area_and_kind_are_one_device(fake_wren): ("living_room_setpoint", DType.FLOAT), ("living_room_boost", DType.BOOL), ("kitchen_lamp", DType.BOOL), + ("office_plug", DType.BOOL), ) assert node.devices == { ("living room", "heat"): ["living_room_setpoint", "living_room_boost"], ("kitchen", "light"): ["kitchen_lamp"], + ("office", "switch"): ["office_plug"], } +def test_a_kind_the_model_does_not_know_is_refused(fake_wren, monkeypatch): + monkeypatch.setattr(fake_wren, "KINDS", ["light"]) + with pytest.raises(ValueError, match="switch"): + _node(("office_plug", DType.BOOL)) + + @pytest.mark.parametrize("port", ["hallway_light", "kitchen_fridge", "light"]) def test_a_port_naming_no_trained_device_is_refused(fake_wren, port): with pytest.raises(ValueError, match="living_room") as refused: @@ -87,6 +95,7 @@ def test_the_model_reads_the_command_plain(): ("heating.set", {"temp": 22}, DType.BOOL, None), ("heating.boost", {}, DType.BOOL, True), ("heating.boost", {}, DType.INT, None), + ("switches.off", {}, DType.INT, 0), ("sensors.temperature", {"area": "kitchen"}, DType.FLOAT, None), ], ) diff --git a/docs/reference/node-types.md b/docs/reference/node-types.md index 02bf09a..3b396a3 100644 --- a/docs/reference/node-types.md +++ b/docs/reference/node-types.md @@ -367,9 +367,11 @@ that is not wired to the node. `garden`, `patio`, `garage`, `driveway`, `balcony` - **kind**: `light`, `lamp`, `brightness` for a light; `setpoint`, `heating`, `heat`, `boost` for heating; `media`, `music`, `tv`; `shutter`, `blind`, - `cover`; `water`, `watering`, `sprinkler` + `cover`; `water`, `watering`, `sprinkler`; `switch`, `plug`, `socket`, + `appliances` for a switch, which only wren's s10 model knows -A port naming anything else is refused when the flow is built. A port is +A port naming anything else, or a kind the installed model lacks, is refused +when the flow is built. A port is named after its message unless it is given a name of its own, in the node panel's port name field or as `{"name": "setpoint", "port": "living_room_setpoint"}` through the flow API. @@ -387,11 +389,12 @@ reads. | `shutters.up` / `down` | `true` / `false` | `100` / `0` | `up` / `down` | | `shutters.stop` | — | — | `stop` | | `watering.start` / `stop` | `true` / `false` | — | `on` / `off` | +| `switches.on` / `off` | `true` / `false` | `1` / `0` | `on` / `off` | | Setting | Default | Notes | |---|---|---| | `apply` | `false` | write to the targets; off, the node only reports | -| `min_confidence` | `0.93` | below this nothing is written | +| `min_confidence` | `0.93` | below this nothing is written. wren's s10 model is less sure of itself, use `0.8` with it | | `aliases` | empty | the house's own words, or words the recogniser hears wrong, in the model's: `{"traverse": "living room light", "page too": "patio"}` | Each record in `actions` contains `message`, `value`, `applied`, `delay_s`, @@ -412,8 +415,8 @@ The model sees every area, wired or not, each with at least a light, so "the bathroom light" is not taken for a light that is wired elsewhere. Such a call is reported but not applied, and goes on to `unhandled`. So is a call on an area the command does not name, unless it is the user's area or, with no area -named, the living room: "switch on the appliances" is a word the model never -learned, and it lands on whichever light it was shown. The command is read +named, the living room: "switch on the appliances" is a word the pinned model +never learned, and it lands on whichever light it was shown. The command is read in lower case without sentence marks and with number words as digits ("in five minutes" as "in 5 minutes"), as the model was trained. `aliases` are applied before, whole words in any case, and `unhandled` carries the prompt