Docs / docs (push) Successful in 22s
Playwright Tests / test-playwright (1, 2) (push) Failing after 4m29s
Playwright Tests / test-playwright (2, 2) (push) Successful in 2m21s
pre-commit / pre-commit (push) Failing after 2m53s
Test Backend / test-backend (3.12) (push) Successful in 4m31s
Test Backend / test-backend (3.14) (push) Successful in 4m19s
Compose Smoke Test / test-compose (push) Successful in 36s
Playwright Tests / merge-reports (push) Successful in 1m35s
- A device credential that cannot be checked now (device store not running, nonces unreadable) raises DeviceUnavailable: 503 over HTTP, and the /flows/ws handshake and the tunnel stream accept and close with 1013. 1008 (a 403 before accepting) stays for a revoked or invalid one - Device files are written through a temporary file, so a read mid-save no longer looks like a deleted device - The first report turns Touch friendly on only for a device paired since pairings are noted (DeviceState.paired_at), so devices paired before keep the switch they were given - NodeDef reads `device`/`device_policy` from older clients as the worker label, for a release - Tests: transient vs revoked socket, the touch default, the old worker keys, and a restart with a device node and a node_health rule sending no push Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138N7k5PT4dcR1iEqXaaQ9k
168 lines
5.9 KiB
Python
168 lines
5.9 KiB
Python
"""A device node: never run by the engine, degraded while its device is away."""
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
|
|
from fluksio.flow.alerts import Alert, AlertManager, AlertsConfig, Channel, Rule
|
|
from fluksio.flow.controller import HEALTH_FLOOR, FlowController
|
|
from fluksio.flow.devices import presence
|
|
from fluksio.flow.events import EventBus
|
|
from fluksio.flow.messages import DType, MessageSpec
|
|
from fluksio.flow.schemas import FlowDef, FlowInput, NodeDef
|
|
from fluksio.flow.store import FlowStore
|
|
|
|
|
|
def _controller(tmp_path: Path) -> FlowController:
|
|
store = FlowStore(tmp_path / "flows")
|
|
store.write_flow(
|
|
FlowDef(
|
|
name="hall",
|
|
inputs=[
|
|
FlowInput(spec=MessageSpec(name="level", dtype=DType.INT), initial=50)
|
|
],
|
|
nodes=[
|
|
NodeDef(
|
|
id="panel",
|
|
type="device",
|
|
params={"device": "wall"},
|
|
provides=[
|
|
MessageSpec(name="bell", port="gpio.4", dtype=DType.BOOL)
|
|
],
|
|
requires=[
|
|
MessageSpec(
|
|
name="level",
|
|
port="backlight",
|
|
dtype=DType.INT,
|
|
trigger=False,
|
|
)
|
|
],
|
|
),
|
|
NodeDef(
|
|
id="ring",
|
|
type="change",
|
|
requires=[MessageSpec(name="bell", dtype=DType.BOOL)],
|
|
provides=[MessageSpec(name="rung", dtype=DType.BOOL)],
|
|
),
|
|
],
|
|
)
|
|
)
|
|
return FlowController(store)
|
|
|
|
|
|
def test_the_engine_never_runs_a_device_node(tmp_path: Path) -> None:
|
|
controller = _controller(tmp_path)
|
|
asyncio.run(controller.reload())
|
|
assert controller.issues == []
|
|
node = controller.get_node("hall.panel")
|
|
assert node is not None and node.passive
|
|
ran: list[object] = []
|
|
node.f = lambda **kwargs: ran.append(kwargs) # type: ignore[method-assign]
|
|
|
|
# The message its out port follows changes, and a manual run of the flow.
|
|
controller.publish_message("hall.level", 80)
|
|
controller.run_flow("hall")
|
|
assert ran == []
|
|
|
|
# What the device publishes on its in port runs what reads it.
|
|
controller.publish_message("hall.bell", True)
|
|
assert controller.values("hall")["hall.rung"]["value"] is True
|
|
asyncio.run(controller._teardown())
|
|
|
|
|
|
def test_a_device_node_is_degraded_while_its_device_is_away(tmp_path: Path) -> None:
|
|
controller = _controller(tmp_path)
|
|
|
|
async def scenario() -> list[tuple[str, str | None]]:
|
|
await controller.reload()
|
|
entry = controller.loaded["hall.panel"]
|
|
seen = [(entry.health, entry.health_detail)]
|
|
presence.opened("wall")
|
|
seen.append((entry.health, entry.health_detail))
|
|
presence.closed("wall")
|
|
seen.append((entry.health, entry.health_detail))
|
|
await controller._teardown()
|
|
# Stopped, it no longer follows the device.
|
|
presence.opened("wall")
|
|
seen.append((entry.health, entry.health_detail))
|
|
presence.closed("wall")
|
|
return seen
|
|
|
|
# Degraded, not down: a switched-off panel is a warning, never a fault.
|
|
assert asyncio.run(scenario()) == [
|
|
("degraded", "Offline"),
|
|
("ok", None),
|
|
("degraded", "Offline"),
|
|
("degraded", "Offline"),
|
|
]
|
|
presence.forget("wall")
|
|
|
|
|
|
def test_an_offline_device_is_a_warning_not_a_fault(tmp_path: Path) -> None:
|
|
controller = _controller(tmp_path)
|
|
|
|
async def scenario() -> list:
|
|
await controller.reload()
|
|
# Past the floor that holds back a device rebooting.
|
|
controller.loaded["hall.panel"].health_since -= 3600
|
|
found = (
|
|
controller.unhealthy_nodes(),
|
|
[e.id for e in controller.unhealthy_nodes(health="degraded")],
|
|
[(i.code, i.advisory) for i in controller.flow_issues("hall")],
|
|
)
|
|
await controller._teardown()
|
|
return list(found)
|
|
|
|
assert asyncio.run(scenario()) == [
|
|
[],
|
|
["hall.panel"],
|
|
[("node_degraded", True)],
|
|
]
|
|
|
|
|
|
def test_a_device_away_after_a_restart_alerts_nobody(tmp_path: Path) -> None:
|
|
"""No push, and degraded on screen only once the floor has passed.
|
|
|
|
A restart starts every device node without its device, which comes back
|
|
within seconds, and a rule on every node's health is what sends phones a
|
|
push.
|
|
"""
|
|
controller = _controller(tmp_path)
|
|
events: list[dict] = []
|
|
original = controller._publish
|
|
controller._publish = lambda event: (events.append(event), original(event)) # type: ignore[method-assign]
|
|
sent: list[Alert] = []
|
|
alerts = AlertManager(
|
|
EventBus(),
|
|
AlertsConfig(
|
|
channels=[Channel(name="phone", kind="ntfy", config={"topic": "t"})],
|
|
rules=[Rule(events=["node_health"], channels=["phone"])],
|
|
),
|
|
)
|
|
|
|
async def capture(channel: Channel, alert: Alert) -> None:
|
|
sent.append(alert)
|
|
|
|
alerts.send = capture # type: ignore[method-assign]
|
|
|
|
async def scenario() -> list[list[str]]:
|
|
await controller.reload() # the restart: the device is not back yet
|
|
surfaced = [[e.id for e in controller.unhealthy_nodes(health="degraded")]]
|
|
presence.opened("wall")
|
|
presence.closed("wall") # and it drops again
|
|
surfaced.append([e.id for e in controller.unhealthy_nodes(health="degraded")])
|
|
controller.loaded["hall.panel"].health_since -= HEALTH_FLOOR
|
|
surfaced.append([e.id for e in controller.unhealthy_nodes(health="degraded")])
|
|
for event in events:
|
|
await alerts.handle(event)
|
|
await controller._teardown()
|
|
return surfaced
|
|
|
|
assert asyncio.run(scenario()) == [[], [], ["hall.panel"]]
|
|
assert [e["health"] for e in events if e["type"] == "node_health"] == [
|
|
"degraded",
|
|
"ok",
|
|
"degraded",
|
|
]
|
|
assert sent == []
|
|
presence.forget("wall")
|