Panels for a ten-inch screen, and a motor button that says where it is
Docs / docs (push) Canceled after 0s
Playwright Tests / test-playwright (1, 2) (push) Canceled after 0s
Playwright Tests / test-playwright (2, 2) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Test Backend / test-backend (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
Playwright Tests / merge-reports (push) Canceled after 0s

Both screens the house is looked at on are 1280x800, so that is what the three
dashboards are laid out for: twelve columns of 96px, twelve rows of 51px, and
nothing past the bottom, because a panel does not scroll.

The motors are one control each instead of three buttons. A button could only
publish; a segmented control reads back as well — so the motor writes what it
is doing to the same message the control sets, and the segment that is held is
the direction it actually went. Up, Stop, Down for the shutters; Close/Open for
the window and In/Out for the awning, which is what those two are for.

A run stopped part way now leaves the position unknown rather than claiming the
target it never reached, so the next command in either direction moves it.

The preflight gained the two checks this needed. One runs each sample shape
past the port that would receive it. The other is arithmetic: every tile inside
the panel and none on top of another — both silent failures on a screen with no
scrollbar, and both caught before anything is written.

Sizes were settled by looking. A slider needs three rows or its tick labels
fall off; a status icon needs three or it loses the word under the glyph; a
gauge in two rows has no arc worth reading, so the battery is a bar on Home and
a gauge on Energy where there is height for one. A chart spends eighty pixels
on its chrome whatever it is given, so two of them read on this panel and three
did not — the temperature history is the one that went, and `history` still
answers for it.

`capture-panels.mjs` is how that was checked: the three panels at the screen's
own pixels, in both themes, reporting whether anything spilled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 18:50:03 +02:00
co-authored by Claude Opus 5
parent 13cdfa79b9
commit 4bdc0ea04a
7 changed files with 341 additions and 100 deletions
+16
View File
@@ -12,6 +12,22 @@ Deferring because out of scope is fine, but don't mention deferring than.
### To be sorted ### To be sorted
- CHORE/UI: the house panels are laid out for 1280x800 — twelve columns, twelve
rows — and that is as much as fits: a chart spends about eighty pixels on its
title, range picker and legend whatever height it is given, so two of them
read on that panel and three do not. The temperature history was the one
dropped; `history.climate_*` still answers, so it is a tile away.
- FEAT/UI: a bar's nested reading is captioned by its port name, which is
chosen for the graph rather than for somebody reading it across a room.
`Segment` now takes an optional `label`; the house dashboard sets one
(`Solar`), and it shows on the next frontend build.
- BUG/INFRA: the running `fluksio-frontend` image calls `https://api.localhost`
while `app/.env` says `VITE_API_URL=http://api.localhost` — the image predates
the environment being switched to `local`. Anything served from that bundle
reaches the API only where something terminates TLS for `api.localhost`. The
next frontend build changes the origin, so check how the app is actually
reached before making one.
- BUG/FLOW: **a cancelled request can leave `FlowController._lock` held forever.** - BUG/FLOW: **a cancelled request can leave `FlowController._lock` held forever.**
Seeding nineteen flows over a client that timed out mid-request left the next Seeding nineteen flows over a client that timed out mid-request left the next
`POST /flows/{name}/start` waiting on the lock indefinitely — ten minutes, until `POST /flows/{name}/start` waiting on the lock indefinitely — ten minutes, until
+116
View File
@@ -0,0 +1,116 @@
/**
* The house panels, at the size of the screen they hang on.
*
* `/view/{name}` is the wall-panel route: no sidebar, no editor. Shooting it
* at exactly the panel's pixels is the only way to see whether a tile fell off
* the bottom, because a panel does not scroll.
*
* Env: APP_URL, FIRST_SUPERUSER, FIRST_SUPERUSER_PASSWORD,
* PANELS (comma-separated), PANEL_SIZE ("1280x800"), SCREENSHOT_DIR
*/
import { mkdir } from "node:fs/promises"
import { chromium } from "@playwright/test"
const APP_URL = process.env.APP_URL || "http://app.localhost"
const EMAIL = process.env.FIRST_SUPERUSER
const PASSWORD = process.env.FIRST_SUPERUSER_PASSWORD
const OUT = process.env.SCREENSHOT_DIR || "screenshots/panels"
const NAMES = (process.env.PANELS || "home,comfort,energy").split(",")
const [width, height] = (process.env.PANEL_SIZE || "1280x800")
.split("x")
.map(Number)
if (!EMAIL || !PASSWORD) {
console.error("FIRST_SUPERUSER / FIRST_SUPERUSER_PASSWORD are unset.")
process.exit(1)
}
/** What the bundle asks for, and where it is really answered. */
const API_PUBLIC = process.env.API_PUBLIC || "https://api.localhost"
const API_ORIGIN = process.env.API_ORIGIN || ""
/** Where the bundle is really served from, for fetches outside the browser. */
const APP_ORIGIN = process.env.APP_ORIGIN || ""
const RESOLVER = process.env.HOST_RESOLVER_RULES
const browser = await chromium.launch(
RESOLVER ? { args: [`--host-resolver-rules=${RESOLVER}`] } : {},
)
for (const theme of ["light", "dark"]) {
const dir = `${OUT}/${theme}`
await mkdir(dir, { recursive: true })
const context = await browser.newContext({
viewport: { width, height },
colorScheme: theme,
// A panel is a touch screen, and several controls draw a taller target
// for one. Shooting it as a mouse would misreport the layout.
hasTouch: true,
})
await context.addInitScript((t) => {
localStorage.setItem("fluksio-ui-theme", t)
}, theme)
// The bundle addresses the API at whatever origin it was built for, and
// this stack has no TLS in front of it — so the calls are re-issued at the
// address the container actually answers on. API_ORIGIN is that address;
// without it nothing is intercepted and the page is left alone.
if (API_ORIGIN) {
// The origin is baked into the bundle at build time, and this stack has no
// TLS in front of it. Rewriting it in the JavaScript as it is served fixes
// the websocket too — routing only the HTTP calls would leave every widget
// showing an em dash, since live values arrive over the socket.
const wsPublic = API_PUBLIC.replace(/^http/, "ws")
const wsOrigin = API_ORIGIN.replace(/^http/, "ws")
await context.route("**/*.js", async (route) => {
// route.fetch runs in Playwright's own process, which does not have the
// browser's resolver rules — so it is told the address directly.
const response = await route.fetch(
APP_ORIGIN
? { url: route.request().url().replace(APP_URL, APP_ORIGIN) }
: {},
)
const body = (await response.text())
.split(API_PUBLIC)
.join(API_ORIGIN)
.split(wsPublic)
.join(wsOrigin)
await route.fulfill({ response, body })
})
}
const page = await context.newPage()
page.on("console", (m) => {
if (m.type() === "error") console.log(` console: ${m.text()}`)
})
page.on("requestfailed", (r) =>
console.log(` failed: ${r.url()} ${r.failure()?.errorText}`),
)
await page.goto(`${APP_URL}/login`, { waitUntil: "networkidle" })
await page.getByTestId("email-input").fill(EMAIL)
await page.getByTestId("password-input").fill(PASSWORD)
await page.getByRole("button", { name: /log in/i }).click()
await page.waitForURL(`${APP_URL}/`, { timeout: 20000 })
for (const name of NAMES) {
await page.goto(`${APP_URL}/view/${name}`, { waitUntil: "networkidle" })
// Widgets fetch their own values; the chart draws after its answer lands.
await page.waitForTimeout(3000)
await page.screenshot({ path: `${dir}/${name}.png` })
const overflow = await page.evaluate(() => ({
scrollH: document.documentElement.scrollHeight,
clientH: document.documentElement.clientHeight,
scrollW: document.documentElement.scrollWidth,
clientW: document.documentElement.clientWidth,
}))
const spills =
overflow.scrollH > overflow.clientH + 1 ||
overflow.scrollW > overflow.clientW + 1
console.log(
` ${theme}/${name}: ${spills ? "SPILLS " : "fits "} ` +
`${overflow.scrollW}x${overflow.scrollH} in ${overflow.clientW}x${overflow.clientH}`,
)
}
await context.close()
}
await browser.close()
@@ -34,7 +34,7 @@ export const MAX_SEGMENTS = 3
/** Outer fill left around a segment, as `inset-y-1` leaves it above and below. */ /** Outer fill left around a segment, as `inset-y-1` leaves it above and below. */
const GUTTER = "2px" const GUTTER = "2px"
export type Segment = { message?: string; dtype?: string } export type Segment = { message?: string; dtype?: string; label?: string }
/** A segment as drawn: where it runs on the fill, and what it reads. */ /** A segment as drawn: where it runs on the fill, and what it reads. */
type Band = { start: number; end: number; level: number; name: string } type Band = { start: number; end: number; level: number; name: string }
@@ -48,7 +48,13 @@ export const segmentsOf = (widget: WidgetProps["widget"]): Segment[] => {
if (Array.isArray(cfg.inner)) if (Array.isArray(cfg.inner))
return (cfg.inner as Segment[]).slice(0, MAX_SEGMENTS) return (cfg.inner as Segment[]).slice(0, MAX_SEGMENTS)
return cfg.inner return cfg.inner
? [{ message: text(cfg.inner), dtype: text(cfg.inner_dtype) }] ? [
{
message: text(cfg.inner),
dtype: text(cfg.inner_dtype),
label: text(cfg.inner_label),
},
]
: [] : []
} }
@@ -108,8 +114,10 @@ export function BarWidget({ widget }: WidgetProps) {
end: cursor, end: cursor,
level, level,
// The panel already carries the widget's title, so the caption names the // The panel already carries the widget's title, so the caption names the
// reading by its port rather than repeating the flow it comes from. // reading by its port rather than repeating the flow it comes from — or
name: displayName(flowOf(name), name), // by whatever the author called it, since a port name is chosen for the
// graph and not for somebody reading it across a room.
name: text(segment.label) || displayName(flowOf(name), name),
}) })
} }
const detail = drawn const detail = drawn
+35
View File
@@ -108,6 +108,7 @@ def check(flows: list[Flow]) -> list[str]:
problems += _check_types(flows) problems += _check_types(flows)
problems += _check_cycles(flows, provided) problems += _check_cycles(flows, provided)
problems += _check_widgets(known) problems += _check_widgets(known)
problems += _check_layout()
problems += _check_sources(flows) problems += _check_sources(flows)
problems += _check_schemas(flows) problems += _check_schemas(flows)
problems += _check_injects(flows) problems += _check_injects(flows)
@@ -273,6 +274,40 @@ def _check_types(flows: list[Flow]) -> list[str]:
return problems return problems
def _check_layout() -> list[str]:
"""Every tile inside the panel, and none of them on top of another.
A panel does not scroll: a widget past the last row is simply not on the
screen, and two in one cell draw over each other. Both are silent, and
both are arithmetic.
"""
problems = []
for _, title, _, widgets in dashboards.SCREENS:
taken: dict[tuple[int, int], str] = {}
for widget in widgets:
box = widget["layout"]["lg"]
x, y, w, h = box["x"], box["y"], box["w"], box["h"]
if x + w > dashboards.COLUMNS:
problems.append(
f"{title}: '{widget['id']}' runs to column {x + w} of "
f"{dashboards.COLUMNS}"
)
if y + h > dashboards.ROWS:
problems.append(
f"{title}: '{widget['id']}' runs to row {y + h} of "
f"{dashboards.ROWS} — off the bottom of the panel"
)
for cell in ((cx, cy) for cx in range(x, x + w) for cy in range(y, y + h)):
other = taken.get(cell)
if other:
problems.append(
f"{title}: '{widget['id']}' overlaps '{other}' at {cell}"
)
break
taken[cell] = widget["id"]
return problems
def _check_widgets(known: set[str]) -> list[str]: def _check_widgets(known: set[str]) -> list[str]:
"""A tile bound to a message nothing carries draws nothing, quietly.""" """A tile bound to a message nothing carries draws nothing, quietly."""
problems = [] problems = []
+5 -3
View File
@@ -15,6 +15,8 @@ from typing import Any
import httpx import httpx
from . import dashboards
API = os.environ.get("API_URL", "http://api.localhost") API = os.environ.get("API_URL", "http://api.localhost")
EMAIL = os.environ.get("FIRST_SUPERUSER", "") EMAIL = os.environ.get("FIRST_SUPERUSER", "")
PASSWORD = os.environ.get("FIRST_SUPERUSER_PASSWORD", "") PASSWORD = os.environ.get("FIRST_SUPERUSER_PASSWORD", "")
@@ -112,9 +114,9 @@ class Api:
**current, **current,
"title": title, "title": title,
"icon": icon, "icon": icon,
"columns": 16, "columns": dashboards.COLUMNS,
"canvas_width": 2560, "canvas_width": dashboards.CANVAS[0],
"canvas_height": 1600, "canvas_height": dashboards.CANVAS[1],
"pages": [ "pages": [
{ {
"id": "main", "id": "main",
+147 -91
View File
@@ -13,7 +13,10 @@ from __future__ import annotations
from typing import Any from typing import Any
COLUMNS = 16 #: A ten-inch panel: 1280x800, twelve columns of 96px, twelve rows of 51px.
COLUMNS = 12
CANVAS = (1280, 800)
ROWS = 12
def _at(x: int, y: int, w: int, h: int) -> dict[str, Any]: def _at(x: int, y: int, w: int, h: int) -> dict[str, Any]:
@@ -133,19 +136,34 @@ def dropdown(id_, title, target, options, **at):
} }
def _cover(prefix, title, target, y): def cover(id_, title, target, labels, **at):
"""Three buttons, because a shutter with no position sensor has three states.""" """A motor, as one control that both moves it and says where it is.
return [
button(f"{prefix}_up", f"{title} up", target, "UP", x=0, y=y, w=2, h=2), Three buttons could only ever publish; this reads back as well, because
button(f"{prefix}_stop", "Stop", target, "STOP", x=2, y=y, w=2, h=2), the motor writes what it is doing to the same message the control sets —
button(f"{prefix}_down", f"{title} down", target, "DOWN", x=4, y=y, w=2, h=2), so the segment that is held is the direction it actually went.
] """
up, stop, down = labels
return dropdown(
id_,
title,
target,
[(up, "UP"), (stop, "STOP"), (down, "DOWN")],
**at,
)
# Three screens, each on the 1280x800 of a ten-inch panel: twelve columns of
# 96px and twelve rows of 51px. Nothing runs past the bottom, because a panel
# does not scroll — what does not fit is not on the screen.
HOME = [ HOME = [
stat("indoor", "Inside", "climate.indoor", "°C", 1, x=0, y=0, w=3, h=2), # The top band is three rows because the status icon draws a glyph *and* a
stat("outdoor", "Outside", "weather.outdoor_temp", "°C", 1, x=3, y=0, w=3, h=2), # word, and in two the word falls off the bottom of the tile.
stat("humidity", "Humidity", "weather.indoor_hum", "%", 0, x=6, y=0, w=3, h=2), stat("indoor", "Inside", "climate.indoor", "°C", 1, x=0, y=0, w=3, h=3),
stat("outdoor", "Outside", "weather.outdoor_temp", "°C", 1, x=3, y=0, w=3, h=3),
stat("humidity", "Humidity", "weather.indoor_hum", "%", 0, x=6, y=0, w=3, h=3),
icon( icon(
"power_state", "power_state",
"Power", "Power",
@@ -158,15 +176,8 @@ HOME = [
x=9, x=9,
y=0, y=0,
w=3, w=3,
h=2, h=3,
), ),
{
"id": "alert",
"type": "notification",
"title": "What is happening",
"layout": _at(x=12, y=0, w=4, h=4),
"config": {"message": "power.alert"},
},
dropdown( dropdown(
"scene", "scene",
"Scene", "Scene",
@@ -179,10 +190,11 @@ HOME = [
("Outside", "outside"), ("Outside", "outside"),
], ],
x=0, x=0,
y=2, y=3,
w=8, w=6,
h=2, h=3,
), ),
# Three rows here too: a slider is a number, a track and its tick labels.
slider( slider(
"brightness", "brightness",
"Brightness", "Brightness",
@@ -191,24 +203,23 @@ HOME = [
100, 100,
5, 5,
"%", "%",
x=8, x=6,
y=2, y=3,
w=4, w=6,
h=2, h=3,
), ),
stat("lit", "Lit", "lights.lit", dtype="str", precision=0, x=0, y=4, w=4, h=2),
switch( switch(
"appliances", "Appliances", "appliances.appliances_manual", x=4, y=4, w=3, h=2 "appliances", "Appliances", "appliances.appliances_manual", x=0, y=6, w=3, h=2
), ),
switch("bath", "Bathroom plug", "plugs.bath_manual", x=7, y=4, w=3, h=2), switch("bath", "Bathroom plug", "plugs.bath_manual", x=3, y=6, w=3, h=2),
stat( stat(
"floor", "floor",
"Floor heating", "Floor heating",
"oven.floor_heating", "oven.floor_heating",
dtype="bool", dtype="bool",
precision=0, precision=0,
x=10, x=6,
y=4, y=6,
w=3, w=3,
h=2, h=2,
), ),
@@ -218,28 +229,69 @@ HOME = [
"presence.state", "presence.state",
dtype="str", dtype="str",
precision=0, precision=0,
x=13, x=9,
y=4, y=6,
w=3, w=3,
h=2, h=2,
), ),
*_cover("door", "Door", "shutters.door_shutter_cmd", y=6), cover(
*_cover("bed", "Bed", "shutters.bed_shutter_cmd", y=8), "door",
"Door",
"shutters.door_shutter_cmd",
("Up", "Stop", "Down"),
x=0,
y=8,
w=6,
h=2,
),
cover(
"bed",
"Bed",
"shutters.bed_shutter_cmd",
("Up", "Stop", "Down"),
x=6,
y=8,
w=6,
h=2,
),
{ {
"id": "load", "id": "load",
"type": "bar", "type": "bar",
"title": "House draw", "title": "House draw",
"layout": _at(x=6, y=6, w=6, h=4), "layout": _at(x=0, y=10, w=4, h=2),
"config": { "config": {
"message": "power.out_w", "message": "power.out_w",
"min": 0, "min": 0,
"max": 3000, "max": 3000,
"unit": "W", "unit": "W",
"precision": 0, "precision": 0,
"inner": [{"message": "power.pv_w", "dtype": "float"}], # How much of what the house is drawing came off the roof.
"inner": [{"message": "power.pv_w", "dtype": "float", "label": "Solar"}],
}, },
}, },
gauge("soc", "Battery", "power.soc", 0, 100, "%", x=12, y=6, w=4, h=4), # A bar rather than a gauge: two rows is not enough arc to read across a
# room, and the same number in a bar is. The gauge is on Energy, where it
# has the height for one.
{
"id": "soc",
"type": "bar",
"title": "Battery",
"layout": _at(x=4, y=10, w=4, h=2),
"config": {
"message": "power.soc",
"min": 0,
"max": 100,
"unit": "%",
"precision": 0,
},
},
{
"id": "alert",
"type": "notification",
"title": "What is happening",
"layout": _at(x=8, y=10, w=4, h=2),
"config": {"message": "power.alert"},
},
] ]
COMFORT = [ COMFORT = [
@@ -247,42 +299,65 @@ COMFORT = [
"id": "forecast", "id": "forecast",
"type": "forecast", "type": "forecast",
"title": "The next few days", "title": "The next few days",
"layout": _at(x=0, y=0, w=8, h=3), "layout": _at(x=0, y=0, w=6, h=3),
"config": {"message": "weather.forecast", "count": 4}, "config": {"message": "weather.forecast", "count": 4},
}, },
{ {
"id": "agenda", "id": "agenda",
"type": "agenda", "type": "agenda",
"title": "Coming up", "title": "Coming up",
"layout": _at(x=8, y=0, w=8, h=3), "layout": _at(x=6, y=0, w=6, h=3),
"config": {"message": "calendar.events", "count": 5}, "config": {"message": "calendar.events", "count": 4},
}, },
slider("preset", "Wanted", "climate.preset", 16, 26, 0.5, "°C", x=0, y=3, w=5, h=2), slider("preset", "Wanted", "climate.preset", 16, 26, 0.5, "°C", x=0, y=3, w=4, h=3),
stat("setpoint", "Band", "climate.t_max", "°C", 1, x=5, y=3, w=3, h=2), stat("band", "Up to", "climate.t_max", "°C", 1, x=4, y=3, w=2, h=3),
switch("ac", "Heat pump enabled", "hvac.enabled", x=8, y=3, w=4, h=2), switch("ac", "Heat pump", "hvac.enabled", x=6, y=3, w=3, h=3),
switch("oven", "Pellet stove", "oven.oven_manual", x=9, y=3, w=3, h=3),
cover(
"window",
"Window",
"window.window_manual",
("Close", "Stop", "Open"),
x=0,
y=6,
w=4,
h=3,
),
cover(
"canopy",
"Awning",
"canopy.canopy_manual",
("In", "Stop", "Out"),
x=4,
y=6,
w=4,
h=3,
),
switch("outdoor", "Outdoor plug", "outdoor.outdoor_manual", x=8, y=6, w=4, h=3),
# Why each of them is doing what it is doing. A sentence needs the room,
# and this is the screen where "off" without a reason is the annoying bit.
stat( stat(
"hvac_why", "hvac_why",
"Heat pump", "Heat pump",
"hvac.why", "hvac.why",
dtype="str", dtype="str",
precision=0, precision=0,
x=12, x=0,
y=3, y=9,
w=4, w=3,
h=2, h=3,
), ),
switch("oven", "Pellet stove", "oven.oven_manual", x=0, y=5, w=4, h=2), stat("oven_why", "Stove", "oven.why", dtype="str", precision=0, x=3, y=9, w=3, h=3),
stat("oven_why", "Stove", "oven.why", dtype="str", precision=0, x=4, y=5, w=4, h=2),
stat( stat(
"window_why", "window_why",
"Window", "Window",
"window.why", "window.why",
dtype="str", dtype="str",
precision=0, precision=0,
x=8, x=6,
y=5, y=9,
w=4, w=3,
h=2, h=3,
), ),
stat( stat(
"canopy_why", "canopy_why",
@@ -290,43 +365,24 @@ COMFORT = [
"canopy.why", "canopy.why",
dtype="str", dtype="str",
precision=0, precision=0,
x=12, x=9,
y=5,
w=4,
h=2,
),
*_cover("window", "Window", "window.window_manual", y=7),
*_cover("canopy", "Awning", "canopy.canopy_manual", y=9),
switch("outdoor", "Outdoor plug", "outdoor.outdoor_manual", x=6, y=7, w=4, h=2),
stat(
"outdoor_why",
"Outdoor plug",
"outdoor.why",
dtype="str",
precision=0,
x=10,
y=7,
w=6,
h=2,
),
chart(
"climate_chart",
"Inside and out",
"history.climate_request",
"history.climate_series",
"°C",
x=6,
y=9, y=9,
w=10, w=3,
h=5, h=3,
), ),
] ]
# The three charts share a screen, so the history is read in one place and the
# live figures above it say what "now" is on the same scales.
# A chart spends about eighty pixels on its title, its range picker and its
# legend whatever height it is given, so on an 800px panel two of them read and
# three do not. The temperature history is the one that went; `history` still
# answers for it, so it is a tile away if something else here is worth less.
ENERGY = [ ENERGY = [
gauge("pv", "Solar", "power.pv_w", 0, 3000, "W", x=0, y=0, w=4, h=4), gauge("pv", "Solar", "power.pv_w", 0, 3000, "W", x=0, y=0, w=3, h=3),
gauge("draw", "House", "power.out_w", 0, 3000, "W", x=4, y=0, w=4, h=4), gauge("draw", "House", "power.out_w", 0, 3000, "W", x=3, y=0, w=3, h=3),
gauge("grid", "Mains", "power.in_v", 0, 260, "V", x=8, y=0, w=4, h=4), gauge("grid", "Mains", "power.in_v", 0, 260, "V", x=6, y=0, w=3, h=3),
gauge("charge", "Battery", "power.soc", 0, 100, "%", x=12, y=0, w=4, h=4), gauge("charge", "Battery", "power.soc", 0, 100, "%", x=9, y=0, w=3, h=3),
chart( chart(
"power_chart", "power_chart",
"Where the power went", "Where the power went",
@@ -334,9 +390,9 @@ ENERGY = [
"history.power_series", "history.power_series",
"W", "W",
x=0, x=0,
y=4, y=3,
w=16, w=12,
h=6, h=4,
), ),
chart( chart(
"battery_chart", "battery_chart",
@@ -345,8 +401,8 @@ ENERGY = [
"history.battery_series", "history.battery_series",
"%", "%",
x=0, x=0,
y=10, y=7,
w=16, w=12,
h=5, h=5,
), ),
] ]
+10 -2
View File
@@ -87,13 +87,13 @@ def process(cmd, state=None, up_s=26.0, down_s=28.0):
if cmd == STOP: if cmd == STOP:
if not moving: if not moving:
return None return None
run, run_for, target = STOP, 0.0, position run, run_for, target = STOP, 0.0, ""
elif moving == cmd: elif moving == cmd:
return None return None
elif moving: elif moving:
# Reversing mid-travel drives both relays at once on the way past. # Reversing mid-travel drives both relays at once on the way past.
# Stop; the next command moves it. # Stop; the next command moves it.
run, run_for, target = STOP, 0.0, position run, run_for, target = STOP, 0.0, ""
elif cmd == position: elif cmd == position:
return None return None
else: else:
@@ -104,9 +104,16 @@ def process(cmd, state=None, up_s=26.0, down_s=28.0):
return { return {
"run": run, "run": run,
"run_for": run_for, "run_for": run_for,
# Written back to the message the control publishes on, so one segmented
# button both sets the direction and shows which one it is doing — the
# same trick the arbiter uses. A run that was refused returns nothing at
# all, so the control keeps what the person put there.
"cmd": run,
"state": { "state": {
"moving": "" if run == STOP else run, "moving": "" if run == STOP else run,
"moving_until": 0.0 if run == STOP else now + run_for, "moving_until": 0.0 if run == STOP else now + run_for,
# A run stopped part way is at no position anybody knows, so the
# next command in either direction has to be allowed to move it.
"position": target, "position": target,
}, },
} }
@@ -178,6 +185,7 @@ def motor(
"provides": [ "provides": [
{"name": run, "port": "run", "dtype": "str"}, {"name": run, "port": "run", "dtype": "str"},
{"name": run_for, "port": "run_for", "dtype": "float"}, {"name": run_for, "port": "run_for", "dtype": "float"},
{"name": cmd, "port": "cmd", "dtype": "str"},
{"name": state, "port": "state", "dtype": "record"}, {"name": state, "port": "state", "dtype": "record"},
], ],
} }