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>
An inject left to itself emits the current time. That went into a port
declared `str`, the node raised on every tick, and after five the supervisor
quarantined the whole `power` flow — which is every Victron reading in the
house. It was invisible because Node-RED is still sending its own keepalive,
so the Cerbo kept publishing anyway; the first thing to notice would have
been the data stopping some minutes after Node-RED did.
The preflight now checks what an inject emits against the port that receives
it. Nothing else could: an inject has no source to run, and both halves of
the declaration agreed with each other while disagreeing with reality.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Watching it decide — which is what `commands: false` is for — caught two
things a type check never would.
It wanted to cool the house to 21 degrees in August. The comfort band is what
*heating* aims at; the reference cooled above 26 and at night above 24.5, and
this port had collapsed the two into one number. A compressor running every
summer afternoon to reach a heating setpoint is the most expensive kind of
correct-looking bug.
And it was willing to heat while the pellet stove was doing the same job. The
reference only ever let the heat pump heat when the stove reported itself
faulty, which is the right way round: the stove is what heats this house and
the pump is what covers for it.
Both tables now have a check behind them, along with the boilers' — the three
places in the house where a wrong threshold is a bill rather than a mistake.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things the installation found that the checks did not.
A script with three `from()` statements in it produces three results all
called `_result`, and InfluxDB refuses that outright — so the measurements go
into one filter and the rows come back tagged with which one they are.
And the answer a database node hands back holds a *list* of rows, which a
record may not: a record is flat scalars. It was declared one, so every chart
failed on the type check the moment a real answer arrived.
The second one is now caught before anything is pushed: the preflight runs
each sample shape past the port that would receive it, which is what turns
"expected record, got dict" from a runtime surprise into a line of output.
Also records the two engine faults this seeding session surfaced.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sharing after the flows were created left every flow between the owner and
the end of the list pointing at a library entry that did not exist yet. A node
that fails to load provides nothing, so one missing entry reported as inputs
nothing carries, in eighteen places.
The library is written *from* a node, so this is one throwaway flow that
carries both sources in and is deleted again — the entries outlive it. Order
stops mattering, which also covers the case that would have broken the
previous fix: two instances of one shared node inside a single flow.
Verified against the installation: nineteen flows, zero validation issues.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A node type accepts a setting it does not know and ignores it, which is right
for a node someone is editing and wrong here: seeded against the engine this
installation is running today, every rollershutter would take the trigger's
default minute instead of the twenty-six seconds it actually takes, and every
Victron reading would arrive as an object where a number was declared. It
would look like it had worked.
So the seed asks what the node types know before it writes anything, and says
which rebuild is missing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Node-RED installation this replaces is 865 nodes across three tabs, and
roughly a fifth of it is unreachable — the pellet stove's controller, the
scene engine and the awning's logic were all disconnected from the heartbeat
they ran on. What is here is the intent rather than the wiring: nineteen named
flows, 109 nodes, and no heartbeat at all. A sensor value is the event.
The device layer moves with it. `actor/*` and `light/*` were never a device
interface — Node-RED subscribed to its own topics, stamped a DMX channel on
each and encoded one Art-Net universe — so those topics retire with it and the
encoders are five nodes in the `dmx` flow.
Two shared library nodes carry what every actuator needs.
`arbiter` answers the thing this design was missing: a value someone sets on a
screen is not undone by the next evaluation. A manual value wins for a hold,
the house takes over when it expires, and a schedule can force past both — so
"off at two in the morning" still means off. The control binds to the message
the arbiter writes back, so one tile shows what reached the fixture and
setting it is the override.
`motor` is why a stop is now commanded once. A rollershutter has no position
sensor, so time is the only feedback: it says how long to run and a trigger
sends the single STOP that ends it. The reference sent STOP forever.
Everything is seeded stopped, the Art-Net node does not transmit and the heat
pump does not accept commands until house.json says so.
`--dry` checks the whole set without an installation: names nothing provides,
loops, type disagreements, widgets bound to nothing, and every Python node run
once on values of the shape it declared — including whether what it returns
goes anywhere. That last one has already caught a typo that would have
published into silence.
house.json holds this installation's addresses, MAC addresses and DMX map and
is git-ignored, as the Node-RED inventory is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four small things, each with a device behind it.
An MQTT filter now routes what it subscribed to. `+` and `#` reached the
broker and were then looked up in an exact-match dict, so every message a
wildcard subscription received was dropped in silence.
`json_key` lifts a value out of the object a device wraps it in — Victron
publishes `{"value": 47}` on every path, which was otherwise a Python node
per port.
The trigger node learned `passthrough` and `wait_port`, because how long to
wait can be a value rather than a constant: a rollershutter takes 26 seconds
up and 28 down. A wait of zero sends nothing afterwards and still cancels
what the last message scheduled, which is how a stop is commanded once
instead of forever.
The HTTP sender takes fixed `query` parameters, so an API key is a secret
reference rather than a message on the canvas, and `send_inputs` off for a
request whose inputs are only a trigger.
Also: `delay` accepts fractional seconds, and `TZ` reaches the container, so
a cron expression means local time. Left unset it is UTC, as before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A dashboard could only ever receive as a set of tiles. This adds the dashboard
itself as a receiver: `settings` maps a name to a value plus an optional
binding. Unbound, the setting is simply its value — a wall panel that is always
dark costs no flow. Bound, a flow drives it live and the value is the fallback.
Two settings are wired: `theme` (system/light/dark) and `locked` (read-only).
There is no schedule field on purpose — a node publishing to the bound message
on a cron is what a schedule is here, which is the point of a channel.
- `messages_for()` now walks a dashboard's bound settings as well as its
widgets' bindings. Without this a paired screen is refused its own theme
message, on the one surface the setting exists for; it bounds the socket too.
- `locked` is gated in `usePublish`, so every control inherits it, and each
control also draws itself disabled — a dead button reads as broken otherwise.
The panel surface says Read-only in the corner.
- The theme is a class on the dashboard's own surface, never the root: inside
the app shell it must not flip the chrome. `.light` gains the tokens `.dark`
already had (mirrored in the index repo) so both directions work on a subtree.
- Settings bindings are type-checked from the document alone, the rule widget
bindings follow, and mirrored on the server.
- A bound setting is drawn on the flow canvas as a dashboard-level endpoint.
- The demo's house flow now publishes `home.panel_theme`, which the demo
dashboard's theme binds to: the panel goes dark after sunset, at no tile cost.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu
A custom hue ring — a conic gradient, not a canvas — with saturation and
brightness sliders beside or under it depending on the tile's shape, sized
for a wall panel and reachable from a keyboard. It publishes [h, s, v] by
default, which is what the reference installation's DMX encoders read, and
`format` switches that to [r, g, b] or "#rrggbb".
`usePublish` moves to its own module so a widget in a file of its own can
reach it without importing `widgets.tsx` back.
The panel now carries one wfrac node per unit, polling as well as commanding,
so a command can be checked against the unit's own answer rather than assumed.
Three things kept a panel from driving the old unit. The seeded commands catch
is off by default, which is the usual reason a fresh panel looks dead;
AIRCON_COMMANDS=1 arms it at seed time. A unit that is off names no mode, and
a flow redelivers every bound port on each run, so that rejected value blocked
every command including power-on. And the old firmware serves one connection
at a time.
The editor only ever arranged the first section, so the demo's Home
dashboard lost 16 of its 25 widgets the moment it was edited. A page's
sections are now read as one arrangement — each pushed below the one
before it — and written back as one, which is the shape a dashboard was
already heading for: one dashboard, one canvas, and the panel rail for
the several-dashboards story. The page tabs that story made dead are
gone; PageDef/SectionDef stay in the schema and a page the editor does
not show round-trips untouched.
The row height derives from the canvas as the column width already did,
so the same arrangement is the same picture on a 7" panel as on a 4K
one. The uPlot rules move beside UplotChart, where a chart on Health is
styled without a dashboard having been visited first, and the bar's
readout travels on one property instead of jumping sides at 30%.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu
- IconPicker replaces the three icon selects (rail icon, icon-widget rule,
"Otherwise"): the glyphs in a grid, and a button that clears back to none —
which a Radix SelectItem could never offer.
- ModePicker/StylePicker drop out in favour of a shared ui/Segmented, the same
sliding-thumb shape RangePicker and the widget-side control already wear.
- PanelRail draws no scrollbars at all: hiding them also takes back the gutter
a vertical bar claimed from a column exactly as wide as its buttons, which is
what pushed a horizontal bar under them.
- The panels dialog can re-pair one screen (POST /panels/{id}/unpair) without
deleting the panel it hangs on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu
- Home puts the dashboards beside the flows: two equal-height columns,
capped and scrollable, most recently worked on first. Each tile is a
schematic footprint built from the stored widget placements.
- Flows and dashboards can be picked by long press or ctrl-click; the
create button becomes a trash and one dialog covers the batch.
- The offline banner is drawn on the body so it centres on the viewport,
and the live socket now releases the offline latch a stray 503 set.
- A boot spinner before React's first commit, a router pending screen for
code-split pages, and skeletons where an empty list used to flash.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu
Three things a paired wall panel needed.
The scope check now walks the panel's widgets instead of allowing the
`/messages/` prefix wholesale: a screen may publish what its own controls and
querying charts point at, read the history of what its tiles draw, and nothing
else — the catalogue of every message in the installation included. The same
walk that already bounds its socket, so both surfaces agree.
Pending pairing codes moved out of the per-process dictionary into Redis, keyed
per code with the code's own TTL and indexed in a zset so the fifty-code cap
means the same thing to every worker. Without a Redis there is one process by
definition, and the dictionary stays.
And a per-panel nonce in the token, bumped by `POST /panels/{id}/unpair`: that
refuses the screen hanging there without touching the panel, its dashboards or
their arrangement. A save cannot write the nonce back, so a stale client cannot
undo a revocation. Only for a credential this installation signed — one the
portal minted carries no nonce and is revoked at the hub.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu
An input widget published over HTTP and read the result back over the socket,
so between the two it drew the pre-publish value — a slider handle let go of
visibly snapped back. usePublish now holds the sent value until the echo
matches, the publish is refused, or 3 s pass, and every input widget (button,
switch, slider, input, dropdown) draws that instead of the live value.
A publish in flight pulses a primary ring over the tile: an absolutely
positioned, inert overlay, so nothing resizes or shifts. A refusal drops the
hold and toasts, naming the message. Success stays silent.
The slider also draws its own scale — min, max and a few labelled stops that
land on steps — replacing the unlabelled datalist marks that dropped out past
fifty steps.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu
"What it can import is what the Modules screen installed" was true and
read as a wall: every example is a self-contained file, and a function
that imports half a repository looks unsupported. It is not — the
manifest is handed to `uv pip sync` verbatim, so `-e /home/you/repo`
installs the project you already have and a node becomes a three-line
wrapper over it. The code stays in your own repository, under your own
version control, importing its own siblings.
Two caveats that are easy to lose an afternoon to, so both are written
down: an editable install reaches the venv without a reinstall but not
into a worker that already imported it — the engine's workers are
long-lived, and Apply is what retires them, while an attached worker
starts a process per call and is always fresh. And the path is resolved
on whichever machine runs the node, while the manifest is committed to
the flow repository, so a laptop path means nothing in a container.
Verified rather than assumed: `uv pip sync` takes `-e`, the shim body
loads through the real `load_function`, an edit is live in a fresh
process and stale in a reused one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The guide asked for a hostname and served the interface on
`app.${DOMAIN}` with the API on `api.${DOMAIN}` — which is not what
someone with a box in a cupboard sets up, and could not be made to work
by typing an address into a browser. `compose.lan.yml` already solves it
for the dev stack; the same file layers onto the production one, so the
guide now opens with that and keeps the hostname as the other option.
The reference gained the setting behind it. `VITE_API_URL` is a build
argument of the frontend image rather than something the stack reads,
which is worth saying once: empty means the interface addresses the API
relative to whatever origin served it, so one image answers on an
address, a hostname and an ssh tunnel alike, and no CORS list has to be
kept in step.
Verified against the production compose layered with `compose.lan.yml`:
the page, `/api` on the same port, a cross-origin-free login, the flow
websocket upgrading through nginx, and `/docs` still refused there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An enrolment outlives the database it was made in. Restore a backup, or
move to a different one, and the same operator is a different row —
`local_user_id` then names nobody, every portal session resolves to no
local user, and the machine answers 401 to the only route into it. That
is the lockout the welcome frame's owner exists to prevent, and it was
prevented only for the case where the row still existed.
One superuser is not a guess: it is the account enrolment would have
used, so it is adopted and written back. Several is a guess, and this
says so instead. Writing it back matters beyond this: a screen paired
through the portal borrows the same field, so it was refused for the
same reason with no way to say so.
Found on the production instance after the move to SQLite, which is
exactly this case — DEPLOY.md said to enrol again, and the machine
should not need telling.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A zensical site under docs/, served by a new `docs` compose service behind
Traefik, built with --strict in CI. Same pattern the sibling n3xd workspace
uses.
Getting started splits the way the landing page does — one path is
`pip install fluksio` and a training script, the other is a Docker stack and
an afternoon in the browser — because the two audiences will not spend the same
amount of time. Everything after that is shared: the concepts, the web
interface (app and portal), the CLI and the API, and a reference for node types,
payload types and configuration.
The three flow guides move here from the docs submodule rather than being
copied, so there is one version of them.
Styling mirrors DESIGN-GUIDELINES.md: the app's token palette remapped onto
Material's variables in both schemes, Inter, the 16px panel radius, and the one
terracotta accent spent on the facility lane of the audience split.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M7Xv3cJEW5c8AXxn2hoojV
A `pip install` on a locked-down host — the case the CLI exists for —
may have no git, and the store shelled out to it while building the flow
repository, so the engine refused to start at all. The store is files;
git is their history. Missing it is now one warning and no commits
rather than a stack trace, which is the difference between a machine
that runs your experiments and one that does not.
Found by installing the wheels into a bare python:3.12-slim and pairing
it with the portal: `fluksio enroll` took the code, `fluksio serve`
dialled out, and the hub was proxying requests through the tunnel.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`pip install fluksio && fluksio serve` on a machine with no Docker, no
database and no configuration — which is the case this is for: a node on
a cluster where ports cannot be opened. It makes its data directory, its
key and an admin account, prints the password once, and serves. Pairing
is `fluksio enroll <code> --portal …`, doing what the Settings screen
does through the same function, before the engine starts and without one
running — a machine nobody can route to has no browser pointed at it
either. The portal serves the dashboard, so nothing is served here.
Two things had to give way. `fastapi[standard]` pulls a cloud CLI that
wants sentry-sdk 2.x while we pinned below it — no pip resolution
existed, so the pin is lifted, which the comment beside it had been
waiting for and which also lets the Python cap go. And `uv` is now a
dependency rather than something to find on PATH: the Modules screen is
how a data scientist installs torch, and it was quietly falling back to
the engine's own interpreter.
The CLI imports nothing from the engine before it has set DATA_DIR — the
settings are built on the first import of core.config, and reaching it
early put the database in the working directory. There is a test for
that now, because the failure is silent.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One process owns this database — the image has run a single uvicorn
worker for that reason since the four-engines bug — so a file beside the
flows is the honest shape for it, and it is what lets `fluksio serve`
need no infrastructure at all. Live values, node execution and the work
queue never came here anyway; what does is a rollup a minute at a time,
a row per cascade and the run history, and WAL keeps the readers going
while that one writer works.
DATA_DIR is now the one setting that moves everything an installation
keeps; the rest derive from it and the images still spell theirs out.
The schema is prepared in-process at startup, so the prestart service is
gone, and the ten Postgres-only revisions collapse into one portable
baseline.
Three things only worked because psycopg was casting for us: a token's
subject arriving as a string where the column is a UUID, `greatest`, and
`date_bin`. The timestamps needed a column type of their own — SQLite
stores no offset, and a naive datetime read back either raises against an
aware `now` or serialises as local time.
Postgres stays in the stack only for Umami, behind the analytics profile.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A cluster or GPU host installs `pip install fluksio-worker` and gets the
agent and the runner, not psycopg, numpy and the MCP SDK. The engine
depends on it as a workspace member, so the file it launches node code
with is the same file a remote worker runs — which is what keeps a node
unable to tell the difference. Copying the two files by hand still works.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A wheel whose top-level module is `app` collides with anything else in a
user's venv, so the package that is about to be published takes the name
it is published under. Only the Python package moves; the repo, the
Docker WORKDIR and the compose project keep theirs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`cut -d: -f2` inside a plain YAML scalar reads as a mapping key, so
test-backend.yml was not valid YAML and would have been rejected the first time
a runner picked it up. A block scalar takes the whole command as text.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>