wren node writes the house brightness from lights.set/dim/brighten (wren s11)
Docs / docs (push) Successful in 25s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m0s
Playwright Tests / test-playwright (2, 2) (push) Failing after 21s
pre-commit / pre-commit (push) Failing after 2m27s
Test Backend / test-backend (3.12) (push) Failing after 4m51s
Test Backend / test-backend (3.14) (push) Failing after 4m31s
Compose Smoke Test / test-compose (push) Failing after 23s
Playwright Tests / merge-reports (push) Failing after 2m51s

This commit is contained in:
Melvin Strobl
2026-10-22 22:43:33 +02:00
parent 0d4ff89302
commit 3c057cf75f
3 changed files with 83 additions and 5 deletions
+51 -3
View File
@@ -79,6 +79,9 @@ REPLY = "reply"
UNHANDLED = "unhandled"
#: The reply when nothing was done and nothing reads ``unhandled``.
CANT = "Sorry, I can't do that yet."
#: The output for the house's brightness, 0 to 100, which the model's
#: brightness calls write whatever area they name.
BRIGHTNESS = "brightness"
_TARGET_TYPES = {DType.BOOL, DType.INT, DType.FLOAT, DType.STR}
@@ -111,6 +114,12 @@ _VALUES: dict[str, tuple[bool | None, float | None, str | None]] = {
"switches.off": (False, 0, "off"),
}
#: The brightness calls, and what "dim" and "brighter" do: a step from the
#: brightness now, or from a usual one when none is known. Dimmed lights
#: stay on.
_LEVELS = ("lights.set", "lights.dim", "lights.brighten")
_STEP, _DIMMEST, _BRIGHTEST, _USUAL = 30, 10, 100, 70
#: An applied call, said: "kitchen light on". `heating.set` says its number.
_SAID = {
"lights.on": "light on",
@@ -264,6 +273,13 @@ def _value(call: Any, dtype: DType) -> Any:
temp if isinstance(temp, (int, float)) else None,
None,
)
elif call.tool == "lights.set":
level = call.args.get("level")
flag, number, text = (
None,
level if isinstance(level, (int, float)) else None,
None,
)
else:
flag, number, text = _VALUES.get(call.tool, (None, None, None))
if dtype is DType.BOOL:
@@ -279,12 +295,15 @@ def _said(call: Any, area: str) -> str:
"""An applied call as the reply says it: "garage light on in 5 minutes"."""
if call.tool == "heating.set":
said = f"heating to {_value(call, DType.FLOAT):g} degrees"
elif call.tool == "lights.set":
said = f"brightness at {_value(call, DType.FLOAT):g} percent"
else:
said = _SAID.get(call.tool, call.tool)
if call.delay_s:
minutes = round(call.delay_s / 60)
said += f" in {minutes} minute{'s' * (minutes != 1)}"
return f"{area} {said}"
# The brightness is the house's, not the area's the call names.
return said if call.tool == "lights.set" else f"{area} {said}"
def _plain(command: str) -> str:
@@ -364,7 +383,8 @@ class WrenNode(Node):
Outputs: ``actions``, a list of records, one per call and target,
``reply``, a sentence saying what was applied ("Kitchen light on."), or
that nothing was when no node reads ``unhandled``, ``unhandled``, the
prompt when nothing was, and any number of targets
prompt when nothing was, ``brightness``, the house's, for "dim the
lights" or "set the brightness to 30%", and any number of targets
named ``<area>_<kind>``. Targets of one area and kind are one device to
the model; each takes what the call means for its type.
A delayed call is held by the engine's work queue, like the Trigger
@@ -439,6 +459,10 @@ class WrenNode(Node):
if spec.dtype is not DType.STR:
raise ValueError(f"'{spec.port}' is a str.")
continue
if spec.port == BRIGHTNESS:
if spec.dtype not in (DType.FLOAT, DType.INT):
raise ValueError(f"'{BRIGHTNESS}' is a float or an int.")
continue
device = _device(spec.port, areas)
if device is None:
raise ValueError(
@@ -556,6 +580,11 @@ class WrenNode(Node):
ports = self.devices.get((area, kind), [])
if call.tool.split(".")[0] != _TOOLS.get(kind) or area not in asked:
ports = []
if call.tool in _LEVELS:
ports = []
if BRIGHTNESS in specs:
ports = [BRIGHTNESS]
call = self._leveled(call, specs[BRIGHTNESS].name)
hits = [
(specs[port], value)
for port in ports
@@ -593,7 +622,7 @@ class WrenNode(Node):
if ACTIONS in wired:
writes[ACTIONS] = actions
if said:
reply = ", ".join(said)
reply = ", ".join(dict.fromkeys(said))
answers.insert(0, reply[0].upper() + reply[1:] + ".")
if answers and REPLY in wired:
writes[REPLY] = " ".join(answers)
@@ -603,6 +632,25 @@ class WrenNode(Node):
writes[REPLY] = CANT
return writes or None
def _leveled(self, call: Any, name: str) -> _Call:
"""A brightness call as the level it sets: "dim" is 30 below now."""
level = call.args.get("level")
if call.tool != "lights.set":
now = (
self._pipeline.state.get_present([name]).get(name)
if self._pipeline is not None
else None
)
if not isinstance(now, (int, float)) or isinstance(now, bool):
now = _USUAL
if call.tool == "lights.dim":
level = max(now - _STEP, min(now, _DIMMEST))
else:
level = min(now + _STEP, _BRIGHTEST)
elif isinstance(level, (int, float)):
level = min(max(level, 0), _BRIGHTEST)
return _Call("lights.set", {"level": level}, call.delay_s)
def _group(self, command: str, areas: Iterable[str]) -> list[_Call] | None:
"""The calls a command for a whole group means, one per wired device,
or None when it is not one: "turn off all lights" is every light.
+22
View File
@@ -307,3 +307,25 @@ def test_a_question_for_a_temperature_is_answered_from_its_reading(fake_wren):
command = f"how warm is it in the {area}"
out = node.execute({"house.command": command, **readings})
assert out == {"house.unhandled": command, "house.reply": CANT}
def test_a_brightness_call_sets_the_house_brightness(fake_wren):
node = _speaking(("brightness", DType.INT))
# Whatever area the call names: the brightness is the house's.
for call, level in [
(FakeCall("lights.set", {"id": "kitchen:light", "level": 30}), 30),
(FakeCall("lights.dim", {"id": "kitchen:light"}), 40),
(FakeCall("lights.brighten", {"id": "bedroom:light"}), 100),
]:
fake_wren.answer = ([call], 0.95)
out = node.execute({"house.command": "dim the lights in the kitchen"})
assert out == {
"house.brightness": level,
"house.reply": f"Brightness at {level} percent.",
}
# Nothing wired for it: the assistant's.
node = _speaking(("kitchen_light", DType.BOOL))
fake_wren.answer = ([FakeCall("lights.dim", {"id": "kitchen:light"})], 0.95)
out = node.execute({"house.command": "dim the lights"})
assert out == {"house.unhandled": "dim the lights", "house.reply": CANT}
+10 -2
View File
@@ -356,8 +356,8 @@ reports a missing dependency.
| `dark` | `bool` | without it, dark before 07:00 and from 18:00 |
| `<area>_temperature` | `float` or `int` | °C, read when the command asks how warm the area is |
The outputs are `actions`, a list of records, `reply`, `unhandled`, and any
number of **targets**. A
The outputs are `actions`, a list of records, `reply`, `unhandled`,
`brightness`, and any number of **targets**. A
target is an output named `<area>_<kind>` and bound to the message its device
listens on: `living_room_setpoint` bound to `home.setpoint` is the living
room's heating. The wiring is the allowlist, so the model can write nothing
@@ -390,6 +390,7 @@ reads.
| `shutters.stop` | — | — | `stop` |
| `watering.start` / `stop` | `true` / `false` | — | `on` / `off` |
| `switches.on` / `off` | `true` / `false` | `1` / `0` | `on` / `off` |
| `lights.set` / `dim` / `brighten` | — | the level, on `brightness` only | — |
| Setting | Default | Notes |
|---|---|---|
@@ -407,6 +408,13 @@ carries the prompt instead, for something else to answer, such as an
[Assistant](#assistant). When no node reads `unhandled`, `reply` says "Sorry,
I can't do that yet." instead, so a spoken command never goes unanswered.
`brightness` is the house's, a `float` or `int` from 0 to 100. Only wren's
s11 model makes brightness calls, and the node writes them to `brightness`
whatever area they name: "set the brightness in the kitchen to 30%" sets the
house to 30. "Dim" and "brighter" move it 30 from its current value (70
when nothing has set it), and a dim stops at 10. The reply is "Brightness at
40 percent.". Without the output these calls go on to `unhandled`.
A call with a delay ("in 10m") is held by the engine's work queue, like the
Debounce node's second value: it survives a restart where the queue is in
Redis, and not otherwise.