Files
app/docs/code/nodes.md
T
Melvin Strobl 29d3ec467f Device node: a device's ports are wired in flows
- flow/nodes/device.py: `device` node with params `device` and `config`;
  provides the device's `in` ports, requires its `out` ports without
  waking anything, never run (Node.passive), down "Offline" from presence
- DeviceDef.io and DevicePort go; GET /devices/{id} computes `io` and
  `bindings` from the device nodes of published flows, in a fixed order,
  cached by FlowStore.revision; the credential's scope comes from them
- issues `unknown_device` and `device_port` (wired twice; against a report
  the wrong direction or dtype, or unlisted as advisory), rechecked when a
  device is created or deleted; ValidationIssue.advisory is a field
- a device publishing on a bound port is credited to its node by the
  server; the panel endpoint kind is gone from `_endpoints` and the canvas
- a rebuild that moves a device's bindings sends `dashboard_changed` with
  an empty name, which the firmware refetches on
- startup moves each device's `io` rows into device nodes in the flows
  declaring their messages, draft and published, one commit; rows no
  flow declares are logged and left out
- frontend: Device node settings (device, Publishes, Follows, a port
  picker from a report), palette shortcuts per reported port, DeviceDetail
  lists bindings with "Use in flow" in place of the I/O editor
- docs: devices, flow editor, node types, issues, API
2026-10-16 16:40:31 +02:00

428 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Writing node code
A Function node runs Python in a worker process. Write its source in the
canvas, or declare functions in your project with `@node` and
[`fluksio sync`](cli.md#fluksio-sync).
```python
def process(temperature, setpoint=21.0):
return {"heat": temperature < setpoint}
```
Declare `temperature` as an input port, `heat` as a Boolean output port, and
`setpoint` as a setting. Now you've got a tiny thermostat.
## The rules
- **Define `process`.** Without it, the worker accepts exactly one public
function defined in the source. Multiple candidates produce an error.
- **Arguments arrive by name** from input ports and settings. A setting cannot
share a name with a port. See [Where values come from](../concepts/values.md).
- **Return a dictionary keyed by output port.** Undeclared keys and values of
the wrong type fail validation. With `@node(provides=Port("heat", "bool"))`,
the SDK also accepts a bare value and wraps it for that single port.
- **Use JSON-compatible values.** For files, use artifacts. For persistent
state, use [flow state](../concepts/state.md); worker globals are not a
reliable store.
Inside a node, `import fluksio` exposes reporting and artifact helpers. It
does not expose the engine's internal modules. Other imports come from the
node's Python environment; see [Packages](#packages).
## The header follows the ports
On the canvas, the parameters of `process` and the node's input ports and
settings are kept in step, in both directions.
- Changing ports or settings in the node panel rewrites the
`def process(...)` line and leaves the rest of the code as it is. Input
ports come first, in their order, then the settings. A parameter that stays
keeps its annotation and default.
- Saving the code reads that line back. A name the node already has keeps
its kind. A new parameter becomes a setting when it has a default, which
becomes its value, or when it comes after a setting. Any other new
parameter becomes an input port. Renaming a single one renames the port
(keeping its message) or the setting (keeping its value). Removing a
parameter removes its port or setting once you leave the editor, so one
deleted mid-edit and typed back keeps its message.
- Shared code, code from `fluksio sync`, and a header with a decorator,
`*args`, `**kwargs`, a bare `*` or `/` are left alone in both directions.
## Producing values over time
Yield dictionaries to publish outputs before the node finishes:
```python
import math
def process(lr, steps):
loss = 1.0
for step in range(steps):
loss = math.exp(-lr * (step + 1))
yield {"loss": loss}
return {"final_loss": loss}
```
Declare `loss` as a streaming float port:
```json
{"name": "loss", "dtype": "float", "stream": true}
```
Each yield is type-checked and published immediately. The final return is
the node's result; without a return, the last yield is the result. A
[run](../concepts/runs.md) keeps numeric emissions as metric series and waits
for the result before running downstream nodes. A live flow can also trigger
downstream nodes from emissions.
### `fluksio.emit`
Use `emit` when a callback produces the value:
```python
import fluksio
def on_epoch_end(epoch, logs):
fluksio.emit(loss=logs["loss"])
```
Pass this callback to your training framework. It publishes through the same
declared ports as `yield`. Outside a node, `emit` does nothing, so ordinary
tests can still call your code.
## Bytes: artifacts
Save files or bytes with `save_artifact`, then return the reference through an
`artifact` port:
```python
import fluksio
def process():
return {
"report": fluksio.save_artifact(
b"Experiment complete\n", "report.txt", "text/plain"
)
}
```
Another node can read it with `fluksio.load_artifact(report)`, which returns
a local file path. These two helpers require a running node; for tests,
keep the underlying calculation in ordinary Python functions.
Artifacts are stored by their SHA-256 digest, so identical bytes share storage.
Use [`fluksio artifacts`](cli.md#fluksio-artifacts) to list and download a
run's files.
### Media
`image`, `audio` and `video` ports carry artifact references with a matching
media type. For example, save JPEG bytes with `media_type="image/jpeg"` and
return the reference through an `image` port. See
[Payload types](../reference/payload-types.md#image-audio-video).
For transient frames, `save_artifact(..., volatile=True)` uses an in-memory
ring and streams bytes to connected screens. New frames eventually replace
old ones. A volatile artifact returned as a run result is copied to persistent
storage; emitted frames alone are not recorded as run artifacts.
### Making clips playable on an LVGL panel
A wall panel that renders dashboards natively, such as an ESP32-P4 running
openpanel, decodes only H.264 Constrained Baseline (in software, about 29 fps
at 640×360) and MJPEG. It refuses clips straight from a phone or camera, which
are usually Main or High profile at 1080p or more. Re-encode those with a node
that takes a `video` input port `clip` and has a `video` output port
`panel_clip`:
```python
import os
import subprocess
import tempfile
import fluksio
import imageio_ffmpeg
# H.264 Constrained Baseline, at most 640x360 and 15 fps, mono AAC.
PANEL = [
"-c:v", "libx264", "-profile:v", "baseline", "-level:v", "3.1",
"-pix_fmt", "yuv420p", "-fpsmax", "15",
"-vf", "scale=w=640:h=360:force_original_aspect_ratio=decrease:force_divisible_by=2",
"-preset", "veryfast", "-crf", "23", "-g", "30",
"-c:a", "aac", "-ac", "1", "-b:a", "64k", "-movflags", "+faststart",
]
def process(clip):
source = fluksio.load_artifact(clip)
ffmpeg = imageio_ffmpeg.get_ffmpeg_exe()
with tempfile.TemporaryDirectory() as tmp:
target = os.path.join(tmp, "panel.mp4")
subprocess.run(
[ffmpeg, "-nostdin", "-loglevel", "error", "-i", source, *PANEL, target],
check=True,
timeout=600,
)
name = os.path.splitext(clip.get("name") or "clip")[0] + "-panel.mp4"
return {"panel_clip": fluksio.save_artifact(target, name, "video/mp4")}
```
Use it only for clips headed for such a panel. Browsers play the originals,
and the result plays there too, just smaller. Add `imageio-ffmpeg` on the
**Modules** screen first. It brings a static `ffmpeg` build for Linux on x86
and ARM, so nothing has to be installed into the image.
### Speech to and from a panel
openpanel publishes a 16 kHz mono WAV on its `mic` port after its wake word,
and plays any `audio` reference sent to its `speaker` port (WAV, MP3 or AAC).
Two Function nodes close that loop with local models.
[faster-whisper](https://github.com/SYSTRAN/faster-whisper) turns speech into
text and [piper](https://github.com/OHF-Voice/piper1-gpl) turns text into
speech.
The listening node has an `audio` input port `audio` and a `str` output port
`text`:
```python
from functools import cache
from pathlib import Path
from faster_whisper import WhisperModel
import fluksio
MODELS = Path.home() / ".cache" / "voice"
@cache
def _model(size):
# Downloaded on the first call, then held by the worker between calls.
return WhisperModel(
size, device="cpu", compute_type="int8", download_root=str(MODELS)
)
def process(audio, model="base", language="en"):
# vad_filter skips silence, which Whisper would otherwise hear as "You".
segments, _ = _model(model).transcribe(
fluksio.load_artifact(audio), language=language or None, vad_filter=True
)
return {"text": " ".join(segment.text.strip() for segment in segments)}
```
The speaking node has a `str` input port `text` and an `audio` output port
`speech`:
```python
import io
import wave
from functools import cache
from pathlib import Path
from piper import PiperVoice
from piper.download_voices import download_voice
import fluksio
MODELS = Path.home() / ".cache" / "voice"
@cache
def _voice(name):
# Downloaded on the first call, then held by the worker between calls.
MODELS.mkdir(parents=True, exist_ok=True)
download_voice(name, MODELS)
return PiperVoice.load(MODELS / f"{name}.onnx")
def process(text, voice="en_US-lessac-medium"):
buffer = io.BytesIO()
with wave.open(buffer, "wb") as wav:
_voice(voice).synthesize_wav(text, wav)
speech = fluksio.save_artifact(buffer.getvalue(), "speech.wav", "audio/wav")
return {"speech": speech}
```
`model`, `language` and `voice` are settings. `tiny` is faster than `base` and
less accurate. An empty `language` lets Whisper guess it for each clip. `voice`
takes any name from the [piper voices](https://huggingface.co/rhasspy/piper-voices),
such as `de_DE-thorsten-medium`.
To wire the loop in a flow `home`:
1. Declare a [flow input](../concepts/flows.md#flow-inputs) `heard` of type
`audio` with origin `panel`.
2. Add a [Device node](../interface/dashboards.md#the-devices-own-ports) for
the panel, publishing `mic` to `heard` and following `reply` on `speaker`.
3. Bind the listening node's `audio` to `heard` and its `text` to `said`.
4. Have your own nodes read `said` and publish an answer as a `str`, such as
`answer`.
5. Bind the speaking node's `text` to `answer` and its `speech` to `reply`.
Binding the speaking node's `text` straight to `said` makes the panel repeat
what it heard, which tests the loop before there is any logic in it.
Both packages go into the node's environment, as described in
[Packages](#packages). In a managed environment, list them in **Modules**:
```text
faster-whisper
av<19
piper-tts
```
`av<19` holds PyAV back, since faster-whisper 1.2.1 cannot open files with
PyAV 19. In your own venv or on a remote worker,
`pip install faster-whisper "av<19" piper-tts` does the same.
The models land in `~/.cache/voice` on the machine running the node. The Docker
image keeps its home directory inside the container, so a recreated container
downloads them again. Point `MODELS` at a directory under `/data` to keep them
on the volume. An [attached worker](workers.md) starts a process per call and
loads the model each time.
What it costs, measured on a 2016 server CPU (Xeon E5-2683 v4):
| | Size | Memory | 3 s of speech, one core | All cores |
|---|---|---|---|---|
| Packages | 150 MB download, 460 MB installed | | | |
| Whisper `tiny` | 78 MB | 300 MB | 1.6 s | 0.7 s |
| Whisper `base` | 148 MB | 375 MB | 2.9 s | 1.2 s |
| piper `en_US-lessac-medium` | 63 MB | 250 MB | 0.6 s | 0.3 s |
Each model downloads once, on its node's first call. Loading it takes about a
second for Whisper and four seconds for piper. Memory is the worker's peak while
it holds the model. These figures come from piper's own speech fed back into
Whisper. A room microphone is harder, on `tiny` most of all.
## Printing
`print` and `fluksio.logger.info(...)` are captured in the node logs. The
worker retains the first 16,384 characters per call. Use logs to debug and
output ports for numbers you want to plot or compare.
## Errors
Exceptions appear on the node, with a short message and a traceback. In a live
flow, one failed execution does not stop the whole flow. In a batch run, node
failure makes the run fail. The canvas keeps the last error visible after
recovery until you acknowledge it.
## Timeouts
`timeout` is the allowed number of seconds without an emission. A `yield` or
`emit` resets the deadline; initial compilation and imports are excluded.
There is no timeout by default. Set one on a node where silence means stuck,
or use `FLOW_NODE_TIMEOUT` for the instance default. `timeout=0` explicitly
disables it for a node. A worker disappearing fails its active call without
waiting for that deadline.
## Running a node somewhere else
Set a device label to select an attached worker:
```json
{"id": "train", "device": "gpu", "device_policy": "require", "timeout": 7200}
```
`require` is the default and waits for a worker with that label. `prefer` uses
the local engine when none is attached. Code is compiled on the selected
machine, which needs the packages your node imports. See
[Remote workers](workers.md) for setup and resource requests.
## Sharing code between flows
Promote a node's source to the shared library from its panel. Other flows can
reference that source, and edits apply to all of them. Shared sources live in
`_lib/` in the instance's flow repository.
For code-defined flows, reuse the same decorated function. `use(fn, ...)`
changes its wiring or settings for one flow:
```python
use(fit, wire={"dataset": "augmented"}, epochs=3)
```
## Packages
The startup banner shows the interpreter used for local nodes:
| Environment | How to add packages |
|---|---|
| Fluksio installed in an existing venv | Use pip or uv in that venv, then `fluksio sync` to refresh workers. Modules is read-only. |
| Managed node venv | Edit the manifest in **Modules** and apply it. |
| Remote worker | Install packages in the interpreter selected by `--python`. |
With `NODE_VENV=auto`, Fluksio adopts its existing venv unless this instance
already has a managed one. Without an existing venv, it creates a managed
environment. `NODE_VENV=managed` always requests one; the Docker image uses
this setting.
In managed mode, the Modules manifest is resolved with `uv pip compile`,
installed with `uv pip sync` and versioned with the flows. Listing a package
brings its dependencies along. Applying it refreshes local workers. The API
accepts the same manifest:
```sh
curl -X POST "$FLUKSIO/modules/apply" -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"requirements": "numpy>=2\n"}'
```
Here `$FLUKSIO` is the API base URL ending in `/api/v1`, and `$TOKEN` is an
access token; see [The HTTP API](api.md).
### Your own code as a package
`fluksio sync` generates an import shim pointing at your local project. A
managed environment can also install your project from a Modules manifest:
```text
-e /home/you/my-research
numpy>=2
```
Then a canvas node can import ordinary helpers from it:
```python
from myresearch.training import train_model
def process(lr, epochs):
return {"score": train_model(lr, epochs)}
```
Editable installs expose changed files, but warm local workers still hold
imports in memory. Apply the Modules manifest or run `fluksio sync` after
edits. Attached workers start a process per call and read the files afresh.
Paths are resolved on the machine running the node. A remote worker or
container needs its own copy or installation of your project; syncing a shim
does not ship the package.
## A worked example
[Getting started: data science](../getting-started/data-science.md) contains a
complete runnable flow. The public repository also includes
`examples/myresearch/`, with separate preparation, training and evaluation
modules. From the repository root:
```sh
fluksio sync examples/myresearch
fluksio run train --lr 0.05 --wait
```
Start an instance first, as shown in the getting started guide.
## See also
- [Where a node's values come from](../concepts/values.md)
- [Keeping state in a flow](../concepts/state.md)
- [Runs: pipelines that finish](../concepts/runs.md)
- [Node types](../reference/node-types.md)