This commit is contained in:
2026-10-13 10:02:20 +02:00
parent a93f865e91
commit 80e595c02e
24 changed files with 2210 additions and 4310 deletions
+128 -179
View File
@@ -1,238 +1,187 @@
# Configuration
Every setting comes from the environment, or from an env file. Which file
depends on how the instance was started:
Most installations can keep the defaults. Settings come from environment
variables and an env file:
| Started with | Reads |
| How you start Fluksio | Env file |
|---|---|
| `fluksio serve` | `env` inside the data directory (`$FLUKSIO_ENV_FILE`) |
| the Docker stack | `.env` beside `docker/` |
| `fluksio serve` | `<data-dir>/env`, unless `FLUKSIO_ENV_FILE` names another file |
| Docker with the Makefile | `.env` in this repository's root |
Anything already exported wins over the file.
Exported variables override the file. `fluksio serve` flags override both.
For CLI installations, select storage with `--data-dir`, `FLUKSIO_HOME` or
`--global`; see [The fluksio command](../code/cli.md).
## Storage
| Variable | Default | Notes |
| Variable | Default | Purpose |
|---|---|---|
| `DATA_DIR` | `flow-data` (`./.fluksio` via the CLI; `~/.fluksio` with `--global`) | everything below derives from this |
| `DATABASE_URL` | SQLite in `DATA_DIR` | any SQLAlchemy URL |
| `FLOWS_DIR` | `$DATA_DIR/flows` | the git repository holding flows |
| `SECRETS_FILE` | `$DATA_DIR/secrets.enc` | encrypted credentials, kept outside the repo |
| `ALERTS_FILE` | `$DATA_DIR/alerts.json` | channels and rules |
| `PROVISIONERS_FILE` | `$DATA_DIR/provisioners.json` | clusters a machine can be started from; absent means none |
| `DATA_DIR` | `flow-data`; CLI uses `./.fluksio` or `~/.fluksio` with `--global` | base directory for instance data |
| `DATABASE_URL` | SQLite at `$DATA_DIR/fluksio.db` | SQLite URL; other database engines are unsupported |
| `FLOWS_DIR` | `$DATA_DIR/flows` | flow git repository |
| `SECRETS_FILE` | `$DATA_DIR/secrets.enc` | encrypted credentials |
| `ALERTS_FILE` | `$DATA_DIR/alerts.json` | alert channels and rules |
| `WEBPUSH_FILE` | `$DATA_DIR/webpush.json` | push keys and browser subscriptions |
| `PROVISIONERS_FILE` | `$DATA_DIR/provisioners.json` | optional worker provisioning configuration |
| `PANELS_FILE` | `$DATA_DIR/panels.json` | wall-panel pairings |
| `OAUTH_PRIVATE_KEY_FILE` | `$DATA_DIR/oauth-key.pem` | signs agent and worker tokens |
| `CLOUD_CONFIG_FILE` | `$DATA_DIR/cloud.json` | the portal enrolment, if any |
| `NODE_VENV` | `auto` | which interpreter node code runs on — see below |
| `OAUTH_PRIVATE_KEY_FILE` | `$DATA_DIR/oauth-key.pem` | agent and worker signing key |
| `CLOUD_CONFIG_FILE` | `$DATA_DIR/cloud.json` | optional portal enrollment |
| `AI_CONFIG_FILE` | `$DATA_DIR/ai.json` | optional assistant provider configuration |
| `CHATS_DIR` | `$DATA_DIR/chats` | assistant transcripts |
| `NODE_VENV` | `auto` | interpreter for node code |
Set `DATA_DIR` and the rest follow. Set one explicitly and it wins, which is
what the container images do to pin everything onto `/data`.
Changing `DATA_DIR` moves the derived paths unless you set them individually.
Docker stores instance data under `/data` on the `app-flow-data` volume.
`NODE_VENV` is the exception, being about an environment rather than a path:
| Value | What node code runs on |
| `NODE_VENV` value | Behavior |
|---|---|
| `auto` (default) | the venv Fluksio was installed into, when it was installed into one and there is no venv of its own already built. `pip install fluksio` beside your own packages is this case, and the packages are then already there — the Modules screen turns read-only, because that environment is not Fluksio's to install into. |
| `managed` | a venv the engine builds under `DATA_DIR` and owns, which the Modules screen installs into with `uv pip sync`. The container images set this: the venv in them holds the app and nothing of anybody else's. |
| a path | that interpreter, or that venv, whatever it is. |
| `auto` | keeps an existing managed venv; otherwise uses the installation's venv when available, or creates a managed one |
| `managed` | creates a venv under `DATA_DIR`; the Modules screen manages its packages |
| interpreter or venv path | uses the specified Python environment |
An instance that already has a managed venv keeps it on upgrade under
`auto`, because it may hold packages somebody installed on purpose.
Docker uses `managed`. When Fluksio adopts your own venv, the Modules screen
is read-only; install packages into that environment yourself.
!!! warning "The four files that must be on persistent storage"
Back up the whole data directory and the persistent `SECRET_KEY`. In Docker,
keep `.env` and `app-flow-data`; back up `app-redis-data` too when preserving
live state. Compose prefixes volume names with the project name `fluksio-app`.
Keep runtime files on persistent storage so rebuilds preserve credentials,
pairings and subscriptions.
`secrets.enc`, `alerts.json`, `panels.json` and `oauth-key.pem` are written
at runtime. In a container, anything not on a volume lands in the writable
layer and is lost on the next rebuild, un-pairing every screen and
revoking every agent.
## Live state
## State
| Variable | Default | Notes |
| Variable | Default | Purpose |
|---|---|---|
| `REDIS_HOST` | unset | without it, flow state lives in memory and does not survive a restart |
| `REDIS_PORT` | `6379` | |
| `REDIS_HOST` | unset | unset uses memory; a Redis host enables persistent flow state |
| `REDIS_PORT` | `6379` | Redis port |
Flow state is the last value of every message, node memory, and the run queue.
Redis here is persistence, not a cache. Run it with append-only persistence on
(`--appendonly yes`, which the compose stack sets).
Live state includes message values, node memory and queued work. In-memory
state is lost on restart. The Docker stack uses Redis with append-only
persistence on its own `app-redis-data` volume. Its default sync policy may
lose about a second of recent writes after an unclean shutdown.
That writes with Redis's default `appendfsync everysec`, so an unclean stop of
Redis or of its host loses up to a second of queued work. `appendfsync always`
closes the window and cuts engine throughput to about a third, so the default
stands.
## Accounts and access
## Identity and access
| Variable | Default | Notes |
| Variable | Default | Purpose |
|---|---|---|
| `SECRET_KEY` | generated | signs sessions and derives the secrets-store key |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | `11520` (8 days) | |
| `FIRST_SUPERUSER` | — | absent means the CLI creates one on first run |
| `FIRST_SUPERUSER_PASSWORD` | — | absent means one is generated and printed once |
| `DOMAIN` | `localhost` | what the API and OAuth issuer are built from |
| `FRONTEND_HOST` | `http://localhost:5173` | used in mails, OAuth metadata and panel pairing links |
| `BACKEND_CORS_ORIGINS` | `[]` | comma-separated; `FRONTEND_HOST` is always allowed |
| `FORWARDED_ALLOW_IPS` | `127.0.0.1` (`127.0.0.1,172.16.0.0/12` in the compose stack) | proxies whose `X-Forwarded-For` is believed, as uvicorn reads it; the sign-in, sign-up and OAuth limits count the rightmost hop none of them added |
| `SECRET_KEY` | generated | signs sessions and encrypts the secrets store; the CLI persists it and `make init` writes it to `.env` |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | `11520` (8 days) | session-token lifetime |
| `FIRST_SUPERUSER` | unset | initial administrator email; the CLI creates an account if needed |
| `FIRST_SUPERUSER_PASSWORD` | unset | initial password; the CLI generates and prints one if needed |
| `DOMAIN` | `localhost` | API hostname and OAuth issuer; Docker also uses it for routing |
| `FRONTEND_HOST` | `http://localhost:5173` | interface URL for links and metadata |
| `BACKEND_CORS_ORIGINS` | `[]` | allowed origins, as comma-separated URLs or a JSON list; `FRONTEND_HOST` is also allowed |
| `FORWARDED_ALLOW_IPS` | `127.0.0.1` | trusted proxies; Docker adds `172.16.0.0/12` |
| `SIGNUP_ENABLED` | `false` | allows account creation from the login page; otherwise use **Admin → Users** |
## Where the interface looks for the API
!!! warning "Keep your secret key"
The dashboard is a static bundle, so this one is a **build** argument of the
`frontend` image rather than a setting the running stack reads.
Changing `SECRET_KEY` signs everyone out and makes existing encrypted
secrets unreadable. Back it up with your data.
| Argument | Used by | Effect |
|---|---|---|
| `VITE_API_URL` | `frontend` at build time | the address the interface calls |
## Interface API address
The compose stack takes it from `.env`, falling back to `https://api.${DOMAIN}`.
`scripts/setup.sh` writes it there with the scheme `ENVIRONMENT` implies, so a
local build calls `http://` and does not fail a certificate check nothing is
there to satisfy.
`VITE_API_URL` is a frontend **build argument**, so changing it requires
rebuilding the frontend image.
Empty is the useful value: the interface then addresses the API relative to
whichever origin served the page, so one image answers on a hostname, on a
`http://<host-ip>:<port>`, and through an ssh tunnel alike, and no origin has
to be added to `BACKEND_CORS_ORIGINS`, because there is only one.
`docker/compose.lan.yml` builds it that way and puts an `/api` proxy in front
of the backend to complete it; see
[getting started](../getting-started/facility-automation.md#on-your-own-network-by-address).
| Setup | Value |
|---|---|
| Production Docker | set `.env` to `https://api.<your-domain>` |
| `make dev-local` | overlay sets `http://api.localhost` by default |
| `make dev-lan` | overlay sets an empty value, using `/api` on the page's own origin |
Set it to an absolute URL only when the API genuinely lives somewhere else, and
remember it is fixed at build time: changing it means rebuilding that image.
Without an explicit value, the production Compose file falls back to
`https://api.${DOMAIN}`. Set `FRONTEND_HOST` and `BACKEND_CORS_ORIGINS` to
match your deployment as well; `make init` generates secrets, not deployment
URLs.
!!! danger "Rotating `SECRET_KEY`"
The secrets store is encrypted with a key derived from it. Change it and
the store stops decrypting, and every session is signed out. Re-enter your
secrets, or plan the rotation properly.
The LAN overlay also configures nginx to proxy `/api` and WebSocket requests
to the backend. An empty value needs that proxy when the frontend and backend
are served separately. See [facility automation](../getting-started/facility-automation.md#run-it-long-term)
for deployment settings.
## Environment
| Variable | Default | Notes |
| Variable | Default | Purpose |
|---|---|---|
| `ENVIRONMENT` | `local` | `local`, `staging` or `production` |
| `PRIVATE_API_ENABLED` | `false` | unauthenticated test-only endpoints; needs `ENVIRONMENT=local` too |
| `API_DOCS_ENABLED` | `false` | serves `/docs`, `/redoc` and the OpenAPI document to any caller; otherwise only to one on this machine. Never in `production` |
| `SIGNUP_ENABLED` | `false` | lets anyone who reaches the login page create an account; otherwise a superuser adds them under **Admin** |
| `TZ` | `UTC` | the timezone every schedule is written in |
| `PRIVATE_API_ENABLED` | `false` | test endpoints; also requires `ENVIRONMENT=local` |
| `API_DOCS_ENABLED` | `false` | opens `/docs`, `/redoc` and `/api/v1/openapi.json` to network callers outside production |
| `TZ` | host timezone; Docker defaults to `UTC` | timezone used by cron schedules |
`TZ` is the container's own, not a setting the code reads: an `inject` or a
`delay` with a cron expression fires on local time. Left at `UTC`, "off at
02:00" means two in the morning UTC, which in most of the world is neither two
o'clock nor the same hour in summer as in winter. Set it to where the
instance is.
API documentation is available to loopback callers outside production.
`API_DOCS_ENABLED=true` opens it to other callers, as the Docker development
stack does. Production disables it entirely. Staging and production also
refuse `changethis` values for the session key or administrator password.
`production` closes `/docs`, `/redoc` and the OpenAPI document, because the
schema enumerates every endpoint the instance serves, including the paths
webhook nodes mounted at runtime. Elsewhere they answer a caller on the
engine's own machine, so `fluksio serve --host 0.0.0.0` does not hand them to
the network; `API_DOCS_ENABLED` opens them to everyone, as the dev stack does. It also turns a `changethis` secret from a
warning into a refusal to start.
Set `TZ` in `.env` for schedules that should follow local time, such as
`Europe/Berlin`.
## The engine
## Engine and resources
| Variable | Default | Notes |
| Variable | Default | Purpose |
|---|---|---|
| `FLOW_MAX_WORKERS` | `4` | node-code subprocesses run in parallel |
| `FLOW_MAX_CASCADES` | `4` | cascades in flight at once; throughput is this over the mean cascade time, so raise it where nodes wait on a network rather than a CPU |
| `FLOW_MAX_RUNS` | `4` | batch runs driven at once. A different limit from the one above: a run drives a whole graph, and its nodes are bounded by `FLOW_MAX_WORKERS`. This is what a sweep queues behind |
| `FLOW_NODE_TIMEOUT` | `0` | seconds a node may be silent, unless it sets its own; 0 is no limit |
| `FLOW_CPUS` | `0` | cores nodes that declare `resources` may be given; 0 works it out as every core but two, which are what keeps the engine answering while the machine is busy |
| `FLOW_GPUS` | `0` | GPUs on this machine, each held by one node at a time. 0 counts NVIDIA's device nodes; say a number for anything they miss |
| `FLOW_RAM` | `0` | megabytes nodes that declare `ram` may be given; 0 works it out as the machine's memory, or the container's limit where that is lower, less a gigabyte for the engine |
| `FLOW_WORKER_MAX_RSS_MB` | `0` | a warm worker holding more resident memory than this when its node returns is retired rather than kept; 0 keeps every worker warm |
| `OBS_RETENTION_DAYS` | `30` | how long metrics, events and run records are kept |
| `ARTIFACT_GC_INTERVAL_S` | `3600` | how often artifact bytes nothing refers to are swept away; 0 never sweeps |
| `ARTIFACT_GC_GRACE_S` | `3600` | how long a freshly written artifact is spared, whatever refers to it |
| `MAX_ARTIFACT_BYTES` | `2147483648` | the largest body `PUT /artifacts` will take; 0 removes the limit |
| `ARTIFACT_VOLATILE_DIR` | worked out | where frames a flow only shows live are held; empty picks a directory under `/dev/shm` named for the data directory, and falls back to the temporary directory |
| `ARTIFACT_VOLATILE_BYTES` | `50331648` | how much that ring holds before the oldest frames fall out; 0 turns it off and volatile saves land in the store |
| `FLOW_MAX_WORKERS` | `4` | local node-code subprocesses |
| `FLOW_MAX_CASCADES` | `4` | concurrent live message cascades |
| `FLOW_MAX_RUNS` | `4` | concurrent batch runs |
| `FLOW_NODE_TIMEOUT` | `0` | maximum node silence in seconds, unless the node overrides it; `0` disables |
| `FLOW_CPUS` | `0` | CPU budget; `0` detects cores and reserves two for the engine |
| `FLOW_GPUS` | `0` | local GPU count; `0` detects NVIDIA device nodes |
| `FLOW_RAM` | `0` | memory budget in MB; `0` detects host/container memory and reserves 1 GiB |
| `FLOW_WORKER_MAX_RSS_MB` | `0` | retire a local worker above this memory use after a call; `0` disables |
| `OBS_RETENTION_DAYS` | `30` | retention for metrics, events and run records |
The three concurrency limits are also flags on `fluksio serve`
(`--max-workers`, `--max-cascades`, `--max-runs`), as are the card count and
memory, `--gpus` and `--ram`, and the worker ceiling, `--worker-max-rss`. The flags
outrank the file, and the engine says which numbers it started with in its
first lines. Each pool size must be at least 1 and the others at least 0:
a number below that is refused as a flag error naming it, rather than read as
the default. Leave one empty (or unset) to get the default.
The three concurrency limits must be at least `1`. They also have CLI flags:
`--max-workers`, `--max-cascades` and `--max-runs`. Resource flags are `--gpus`,
`--ram` and `--worker-max-rss`.
An artifact is referred to by a run that recorded it or by a message currently
holding it; anything else is what a camera published four hours ago, and the
sweep is what keeps a flow streaming media from filling the disk. It stands
aside entirely while a run is in flight, since a node may store a checkpoint
long before it returns the reference to it.
Nodes with declared resources are scheduled against the available budget.
Undeclared nodes use the shared pool and receive thread caps. Attached workers
report their own inventory; the engine's resource settings describe only its
own machine. See [Remote workers](../code/workers.md) and the **Workers** screen.
Frames saved with `volatile=True` skip all of that. They go to a ring in memory
instead of the volume, the oldest falling out once the newest need the room,
and the engine pushes them down the websocket to whichever screens are drawing
them — which is what a camera at ten frames a second needs and the store cannot
give it. A frame a run *records* is copied into the store on the way, so
returned media is kept and emitted media is not. Under Docker the ring lives in
the container's `/dev/shm`, whose default is 64 MB: raise `shm_size` alongside
`ARTIFACT_VOLATILE_BYTES`.
## Artifacts
A node that declares nothing is not accounted against `FLOW_CPUS`; it runs on
the shared pool and is given `FLOW_CPUS / FLOW_MAX_WORKERS` as a thread cap, so
several at once cannot each size themselves to the whole machine. Setting
`OMP_NUM_THREADS` (or any of its siblings) on the engine yourself overrides
that default.
| Variable | Default | Purpose |
|---|---|---|
| `MAX_ARTIFACT_BYTES` | `2147483648` (2 GiB) | upload limit; `0` removes it |
| `ARTIFACT_GC_INTERVAL_S` | `3600` | cleanup interval; `0` disables |
| `ARTIFACT_GC_GRACE_S` | `3600` | minimum age before cleanup |
| `ARTIFACT_VOLATILE_DIR` | auto-detected | live-media directory under `/dev/shm`, or the temporary directory |
| `ARTIFACT_VOLATILE_BYTES` | `50331648` (48 MiB) | live-media ring size; `0` stores volatile saves durably |
Workers are kept warm between calls, and a library like JAX keeps what it
allocated for as long as its process lives — so a worker that fitted a model
can sit on gigabytes after its run is over, on the shared pool as much as on a
declared one. `FLOW_WORKER_MAX_RSS_MB` retires such a worker when its node
returns, and the next call pays its imports again. That bounds idle memory
only; what a node holds while it runs is accounted, and only if it declared
`ram`.
`FLOW_CPUS`, `FLOW_GPUS` and `FLOW_RAM` are this machine's figures. An attached worker reports its own when
it dials in, and a node goes to whichever machine can grant what it asked for,
so a GPU on a worker needs no `FLOW_GPUS` here. What every machine has free, and
which nodes are queued, is `GET /api/v1/workers/resources` and the Workers
screen. Named sizes live in the database and are `GET /api/v1/flavors`;
`PROVISIONERS_FILE` is where machines can be started from. See
[declaring resources](../getting-started/data-science.md#declaring-what-a-node-needs).
Cleanup removes durable artifacts no longer referenced by recorded runs or
current messages, and pauses while a run is active. Volatile artifacts use a
bounded ring; the oldest disappear as it fills. Recorded run outputs are
copied into durable storage. If you increase the ring size in Docker, increase
`shm_size` too.
## Agents
| Variable | Default | Notes |
| Variable | Default | Purpose |
|---|---|---|
| `MCP_ENABLED` | `false` | opens the `/mcp` endpoint **and** OAuth client registration |
| `MCP_TOKEN_EXPIRE_MINUTES` | `60` | an agent's token is a bearer secret held by a program |
| `MCP_REFRESH_EXPIRE_DAYS` | `30` | |
| `OAUTH_CODE_EXPIRE_SECONDS` | `60` | |
| `MCP_ENABLED` | `false` | enables `/mcp` and OAuth client registration; `.env.example` sets it to `true` |
| `MCP_TOKEN_EXPIRE_MINUTES` | `60` | access-token lifetime |
| `MCP_REFRESH_EXPIRE_DAYS` | `30` | refresh-token lifetime |
| `OAUTH_CODE_EXPIRE_SECONDS` | `60` | authorization-code lifetime |
See [Agents over MCP](../code/agents.md).
## Mail
## Mail and monitoring
Needed for password-reset mails. Without `SMTP_HOST` and `EMAILS_FROM_EMAIL`,
mail is simply off.
Password-reset mail requires `SMTP_HOST` and `EMAILS_FROM_EMAIL`.
| Variable | Default |
|---|---|
| `SMTP_HOST` | — |
| `SMTP_HOST` | unset |
| `SMTP_PORT` | `587` |
| `SMTP_USER` / `SMTP_PASSWORD` | — |
| `SMTP_USER` / `SMTP_PASSWORD` | unset |
| `SMTP_TLS` / `SMTP_SSL` | `true` / `false` |
| `EMAILS_FROM_EMAIL` | — |
| `EMAILS_FROM_NAME` | `Fluksio` |
| `EMAILS_FROM_EMAIL` | unset |
| `EMAILS_FROM_NAME` | `PROJECT_NAME`, normally `Fluksio` |
| `EMAIL_RESET_TOKEN_EXPIRE_HOURS` | `48` |
| `SENTRY_DSN` | unset; enables error reporting when configured |
## Monitoring
| Variable | Default | Notes |
|---|---|---|
| `SENTRY_DSN` | — | error reporting, if you want it |
## Health check
`GET /api/v1/utils/health/` is a *deep* check: it fails when the event loop is
wedged or the state backend is gone, not just when the process is up. That is
what the container healthcheck probes, and what an autoheal sidecar restarts
on.
## See also
- [The `fluksio` command](../code/cli.md) — what the data directory holds
- [Getting started: facility automation](../getting-started/facility-automation.md)
`GET /api/v1/utils/health/` checks the event loop and state backend. Docker
uses it as a health check; `make up` also starts the autoheal service to restart
an unhealthy backend.
+133 -208
View File
@@ -1,180 +1,130 @@
# The connector contract
A connector is the device-facing node class. It talks to something outside the
engine (a device, a service, a protocol) and publishes what it finds as
ordinary typed messages, so the rest of a flow cannot tell the difference
between a reading from a heat pump and one from a `print` statement.
This document is normative: everything here is what the engine relies on and
what a connector may rely on in return. The authoring walkthrough is in
[Writing a connector](../code/connectors.md).
Connectors integrate devices and services through typed messages.
This reference covers the contract the engine supports; start with
[Writing a connector](../code/connectors.md) for a working package.
## Contract version
```python
from fluksio.flow.connector import CONTRACT_VERSION, ConnectorNode
class MyConnector(ConnectorNode):
contract = CONTRACT_VERSION
title = "My connector"
description = "Reads a device."
```
`contract` must be declared **on the connector class itself**. The loader reads
it from the class `__dict__` rather than through inheritance, so a connector
written against an older base cannot pass itself off as current. A mismatch is
logged and the connector is skipped; nothing else in the engine changes.
Declare `contract` on the class itself: inheritance does not count.
The current contract version is `1`. A mismatch is logged and the connector
is skipped. Breaking changes bump the version; compatible additions do not.
The version is bumped only when a change would break connectors written against
the old surface. Additions that leave existing connectors working do not bump
it.
`title` defaults to the entry point name; `description` may be empty.
## Packaging and discovery
A connector is an ordinary Python package. It advertises its node class through
an entry point in the `fluksio.node_types` group:
Register the class in your package's `pyproject.toml`:
```toml
[project]
name = "fluksio-connector-skeleton"
version = "0.1.0"
[project.entry-points."fluksio.node_types"]
random_sensor = "fluksio_connector_skeleton:RandomSensor"
my_device = "fluksio_connector_mydevice:MyConnector"
```
- The **entry point name** is the node type as it appears on the canvas and in
a stored flow. It must not collide with a built-in type (`python`, `mqtt`,
`http`, `influxdb`, `delay`, `mlp`); a collision is logged and skipped.
- The **package name and version** are the manifest. There is no second
metadata file to keep in step, and the editor shows the provenance
(`fluksio-connector-skeleton 0.1.0`) on the node type.
The entry point name is the stored node type. Names already registered,
including built-in types, are skipped. The editor shows the package name
and version from its installed metadata.
Discovery happens once, when the engine starts. A connector's code is imported,
and Python does not re-import a changed module, so installing or upgrading one
means restarting the engine.
## Declaring what a connector is
```python
class RandomSensor(ConnectorNode):
contract = CONTRACT_VERSION
title = "Random sensor" # shown in the add-node palette
description = "Emits a random reading."
```
`title` falls back to the entry point name; `description` may be empty.
Discovery runs at engine startup. Install connectors in the engine's
environment and restart after installation or code changes.
## Parameters
Settings are a pydantic model, and the editor builds a form from its JSON
schema. Subclass the base so `poll_interval` stays available:
Subclass `ConnectorNode.Params` to keep `poll_interval` and add your settings:
```python
from pydantic import Field
class Params(ConnectorNode.Params):
host: str = "localhost"
api_key: str | None = Field(default=None, json_schema_extra={"x-secret": True})
api_key: str | None = Field(
default=None, json_schema_extra={"x-secret": True}
)
```
- Booleans render as switches, numbers and strings as inputs. Objects and
arrays are not rendered, so keep settings flat.
- A field carrying **`x-secret`** renders as a picker over the stored secrets
and writes a reference, `{"$secret": "name"}`, rather than the value. The
engine resolves it when the node is built, so a credential never lands in
`flow.json`. Always mark credentials this way.
- The parsed model is available as `self.config`.
The node panel builds a form from this model's JSON schema: switches for
booleans, inputs for numbers and strings, and JSON fields for objects and
arrays. Parsed settings are available as `self.config`.
Mark credentials with `x-secret`. The editor stores a reference such as
`{"$secret": "device-key"}`, and the engine resolves it while building the node.
## Ports and messages
Ports are declared by whoever places the node, not by the connector, and they
are `MessageSpec`s like any other node's: a message name, a `DType`, and an
optional `interval`.
The flow author declares ports as `MessageSpec` values. Each has a message
name, a `DType`, and optional delivery settings.
- `poll()` returns a dict **keyed by output port**, and the base class maps it
onto message names; `write()` receives a dict **keyed by input port** the
same way.
- Every value is checked against the port's declared `DType` on the way out. A
wrong type is an error, not a hint.
- `interval` on a port is enforced by the engine, not by the connector: an
output port publishes at most every *n* seconds, an input port wakes its node
at most that often. A connector should poll at the rate the device is
comfortable with and leave delivery rates to whoever wires it up.
- A key no port declares is an error, and it fails the whole reading rather
than the one value. A mistyped metric name is how a training curve goes
missing.
- `poll()` returns values keyed by **output port**.
- `write()` receives values keyed by **input port**.
- The base class maps local port names to message names and validates types.
- Unknown output keys fail the reading. Return only declared ports.
- Port `interval` limits are enforced by the engine; the connector controls
how often it reads the device.
### When the device decides what the ports are
A connector for a device whose readings vary by model (which components a
relay has, which entities were flashed onto a board) cannot know its ports in
advance, and returning everything the device reports would fail on the first
value nobody bound. Narrow the reading to the ports that were declared:
For devices with variable readings, filter to the configured outputs:
```python
declared = {spec.port for spec in self.output_ports if spec.name}
return {port: value for port, value in reading.items() if port in declared}
```
`spec.port` is the local, unqualified name and stays that way for the node's
whole life, so the set can be taken fresh each time. Say something in the log
when a declared port is not one the device has, once rather than once per poll: from
the canvas a renamed entity and a typo look the same, and both leave a port
silent forever.
Log missing device fields once so a typo does not leave a port silently empty.
## Polling
Set `poll_interval` and implement `poll()`; the base class runs the loop:
```python
async def poll(self) -> dict[str, Any] | None:
async def poll(self):
return {"reading": await self._read_device()}
```
- Return `None` when there is nothing new.
- **Only changed values are published.** A device polled every few seconds
usually says the same thing, and every publication wakes everything
downstream, so the loop compares against what it last published, meaning what it
actually published, so a publication that failed is retried next tick rather
than counting as said.
- Raising is not fatal: it is reported as a health problem and retried on the
next tick.
- The loop calls `inject`, which runs the graph, on a worker thread. `poll()`
itself runs on the event loop and must not block it.
Set `poll_interval` in seconds; `0` disables polling. Return `None` when
there is nothing new. The base loop publishes only changed values and retries
a failed reading or publication on the next tick. It reports connection health
around each poll.
A connector that is pushed to rather than polled leaves `poll_interval` at 0,
overrides `start`/`stop` to open and close its own subscription, and calls
`self.inject({...})` when something arrives.
`poll()` runs on the event loop. Use `asyncio.to_thread` for blocking reads.
The base loop already calls `inject` on a worker thread.
For pushed readings, leave `poll_interval=0`, manage the subscription in
`start()` and `stop()`, and call `self.inject({...})` when values arrive.
Offload `inject` too when calling it from the event loop.
## Writing
A connector that commands something rather than only reading it implements
`write()`. It is called when the node's inputs are satisfied, with the values
keyed by input port:
```python
def write(self, **ports: Any) -> dict[str, Any] | None:
def write(self, **ports):
self._device.set(ports["level"])
return None
```
- Settings are already parsed on `self.config`; `write()` is handed ports only.
- Return `None` unless the device answers something worth publishing, in which
case return it keyed by output port, exactly as `poll()` does.
- It runs on a worker thread rather than the event loop, but the thread is the
scheduler's, so a long block holds up the cascade. Queue the work if the
device is slow.
- A connector that only reads leaves it alone.
- `poll()` and `write()` are independent: a connector may have both, and a
node with inputs and no `poll_interval` never polls.
Settings stay on `self.config`; arguments contain input ports only.
Return `None`, or a dictionary keyed by output port if the device responds
with useful values.
A write is a command, not a value: set `idempotent = False` on the class so a
redelivery after a crash does not undo a newer command that already landed.
Writes run on a scheduler thread. Return promptly or queue slow work.
Polling and writing are independent; a connector may support both.
Set `idempotent = False` for commands that must not be redelivered after a
crash. Leave `write()` unchanged on a read-only connector.
## Devices whose readings are bytes
A camera frame or a recorded clip is far too big to be a message, so a
connector publishes a reference to it instead:
```python
async def poll(self) -> dict[str, Any] | None:
import asyncio
async def poll(self):
jpeg = await asyncio.to_thread(self._grab)
return {
"frame": self.save_artifact(
@@ -183,135 +133,110 @@ async def poll(self) -> dict[str, Any] | None:
}
```
`save_artifact` stores the bytes and returns what an `image`, `audio` or
`video` port carries: the media type has to match the port's type. It only
works once the node has started, since the store is the engine's and is handed
over then.
`save_artifact(data, name="", media_type="application/octet-stream",
volatile=False)` returns a reference for an artifact or media port.
Match the media type to an `image`, `audio`, or `video` port.
`volatile=True` is what a camera publishes with. The frame goes to a ring in
memory rather than the data volume and is pushed down the websocket to
whichever screens are drawing it, so a wall panel sees ten frames a second and
the SD card under it is never written to. Frames last until newer ones need the
room; leave the flag off for a reading somebody asked to keep.
The artifact store is available after `start()`. Use `volatile=True`
for disposable live frames: they go to a bounded memory ring and expire as
new frames arrive. Use durable storage for recordings.
Each reading is a new artifact, which the poll loop publishes because its
digest differs from the last. Set `poll_interval` to what somebody actually
wants to look at. Through a portal the bytes are fetched rather than pushed, so
a remote panel wants a frame every second or two; higher rates than that are
for the local network, and full-rate video still belongs on the camera's own
stream.
Local screens receive pushed live frames; portal screens fetch the bytes.
Choose a modest poll rate for remote viewing.
## Keeping state across restarts
A connection, a session or a device handle lives in the connector's own
attributes and goes with the process. The few things that must not, such as
the certificate a device presented on first contact or a token it paired with,
go in `self.store`:
Keep connections and device handles in node attributes. Store short strings
such as pairing tokens or pinned certificates in `self.store`:
```python
pem = await asyncio.to_thread(self.store.get, f"cert:{self.config.host}")
await asyncio.to_thread(self.store.set, f"cert:{self.config.host}", pem)
await asyncio.to_thread(self.store.delete, f"cert:{self.config.host}")
value = await asyncio.to_thread(self.store.get, "token:device")
await asyncio.to_thread(self.store.set, "token:device", "value")
await asyncio.to_thread(self.store.delete, "token:device")
```
- Keys and values are strings; `get` returns `None` for a key that is not
there, and `delete` returns whether there was one.
- The store belongs to the connector's **top-level package**, not to a node:
every node of the package sees the same keys, so two nodes on one bridge
share what was stored for it. Put the device in the key.
- It lives in the engine's database, so it survives a restart and needs
nothing `fluksio serve` does not have. It is available before `start`.
- Limits: a key is 1 to 200 characters without `/` or whitespace, a value at
most 64 kB, and a package at most 256 keys. Past any of them, `set` raises
`ValueError`. Bytes worth keeping belong in `save_artifact`.
- Every call is a database round trip, so on the event loop it goes through
`asyncio.to_thread`.
- Keys and values are strings. `get` returns `None` for a missing key;
`delete` returns whether it existed.
- All nodes from the connector's top-level package share the namespace.
Include a device identifier in each key.
- Storage is in the engine's database and is available before `start()`.
- Keys allow 1–200 characters, without `/` or whitespace. Values allow
64 KiB and each package allows 256 keys; exceeding a limit raises `ValueError`.
- Calls block on the database: use `asyncio.to_thread` on the event loop.
A superuser can see the keys (never the values) and clear them, all of them or
one at a time, under *Stored state* in a connector node's settings, or through
the API, which names the connector by any node type it installs:
Superusers can list keys and clear them under **Stored state** in the node
panel. Values are never returned. The equivalent API paths are:
```sh
curl -s $FLUKSIO/connectors/hue/state -H "Authorization: Bearer $TOKEN"
curl -X DELETE $FLUKSIO/connectors/hue/state/cert:10.0.0.2 -H "Authorization: Bearer $TOKEN"
curl -X DELETE $FLUKSIO/connectors/hue/state -H "Authorization: Bearer $TOKEN"
```
| Method | Path |
|---|---|
| `GET` | `/api/v1/connectors/{node_type}/state` |
| `DELETE` | `/api/v1/connectors/{node_type}/state/{key}` |
| `DELETE` | `/api/v1/connectors/{node_type}/state` |
So a connector has to cope with a key disappearing: clearing is how somebody
tells it to start over.
Handle missing keys: clearing one asks the connector to pair or learn again.
### Pinning a device's certificate
A device on the local network presents a certificate no system trusts and that
names a serial or a MAC rather than its address, so `verify=True` fails and
`verify=False` trusts anyone. `fluksio.flow.connector_tls` trusts the
certificate the device presented on first contact instead, kept in `self.store`
under `cert:<address>`:
`fluksio.flow.connector_tls` supports trusting a local device's certificate
on first contact:
```python
from fluksio.flow.connector_tls import context_for, pinned_certificate, raise_if_changed
import httpx
from fluksio.flow.connector_tls import (
context_for,
pinned_certificate,
raise_if_changed,
)
address = f"{self.config.host}:{self.config.port}"
pem = pinned_certificate(self.store, address, self.config.timeout) # blocking
pem = pinned_certificate(self.store, address, self.config.timeout)
try:
with httpx.Client(verify=context_for(pem)) as client:
...
response = client.get(f"https://{address}/status")
response.raise_for_status()
except httpx.ConnectError as exc:
raise_if_changed(exc, pem, address, self.config.timeout, "my_node_type")
raise_if_changed(exc, pem, address, self.config.timeout, "my_device")
raise
```
- First contact sends a TLS hello and nothing else; a device that does not
complete one raises `OSError` and nothing is pinned.
- Any other certificate is refused in the handshake, before a key or a command
is sent. `raise_if_changed` turns that refusal into `CertificateChanged`,
which names the new SHA-256 and the key to clear, under the node type given.
- The pinned certificate's dates are not checked: a pin trusts that exact
certificate, so one that expired, or an engine clock that is off, does not
lock the device out.
- Clearing the key accepts whatever the device presents next.
Run this blocking work off the event loop. First contact pins the certificate
under `cert:<address>`; a changed certificate is refused before sending a
credential or command. `raise_if_changed` reports `CertificateChanged`
with the fingerprint and key to clear.
## Lifecycle
Pins trust the exact certificate, including after its expiry. Clearing the
key accepts the certificate presented on the next connection.
## Lifecycle and health
```python
async def start(self, app=None) -> None: ...
async def stop(self, app=None) -> None: ...
async def start(self, app=None):
await super().start(app)
async def stop(self, app=None):
await super().stop(app)
```
- `start` is called when the connector's flow starts, and after every rebuild.
- `stop` is called before a rebuild and when the flow is stopped. **It must be
idempotent**: it is called whether or not `start` succeeded.
- Overriding either means calling `super()` if the polling loop is also wanted.
- The `app` argument is the FastAPI application, for the rare connector that
needs to mount a route. Most ignore it.
`start` runs when a flow starts or rebuilds. `stop` runs before rebuilding
or stopping, even after a failed start; make it idempotent. Call `super()`
when overriding these hooks if you want the base polling loop.
A stopped flow gets no `start` at all: that is what stopping it means.
The optional `app` is the FastAPI application, for connectors that mount
routes. Most connectors can ignore it.
## Health
Report connection changes if you manage your own subscription:
```python
self.report_health("ok")
self.report_health("degraded", "3 of 5 registers timed out")
self.report_health("down", str(exc))
self.report_health("degraded", "Some readings timed out")
self.report_health("down", "Connection lost")
```
Three values, `ok`, `degraded` and `down`, plus an optional detail string.
Reporting the same status twice is free, since only changes are published. A node
reporting `down` is named among its flow's issues and counted on the health
summary on Home; `degraded` means still working, and is not. The polling loop
already reports around `poll()`; a connector managing its own connection should
report when it connects and when it loses the connection.
Repeated statuses do not emit another event. `down` appears among flow issues
and on Home; `degraded` means the connection still works. Loading failures
are reported separately.
Health is about the connection, not about loading: a connector that fails to
load is reported separately and never runs.
## What a connector must not do
- **Hold state that belongs in the flow.** Logic nodes are stateless and run in
parallel; a connector may hold a connection, a session, or a device handle,
and keep what has to outlive a restart in `self.store`, but a value another
node needs belongs in a message.
- **Block the event loop.** `poll`, `start` and `stop` are async and run on the
loop. Use `asyncio.to_thread` for blocking I/O.
- **Read credentials from the environment.** They come through parameters, so
the person deploying a flow can change them without touching the host.
Keep shared flow values in messages, credentials in secret parameters, and
blocking I/O off the event loop.
+49 -75
View File
@@ -1,17 +1,18 @@
# Node types
Every type the canvas can place. Each is configured by filling in a form the
editor generates from its parameter schema, so they all behave the same way.
Anything here can also be written as a **Function** node. These types cover the
shapes that come up most often, as a form to fill in rather than repeated code.
Built-in nodes cover common integrations, timing, and logic. Configure their
settings in the node panel; use a **Function** node for your own Python.
`GET /flows/node-types` returns this list with each type's full parameter
schema, including any [connectors](#connectors) installed on your instance.
MQTT and InfluxDB need `pip install 'fluksio[server]'` on a Python installation.
The Docker image includes these dependencies. Run the broker and database
separately, or use the development stack's test services.
## Function
**`python`** — your own code, run on every incoming message.
**`python`** — run your own code when its triggering inputs arrive.
The one type with a source file. Its arguments are its input ports and its own
settings; its return value is a dict keyed by output ports. See
@@ -46,9 +47,11 @@ A topic may be a filter: `+` matches one level, `#` the rest. Everything a
filter matches lands on the same port, so use one port per topic where the
difference matters.
`json_key` is for a device that wraps its reading: Victron publishes
`{"value": 47}` on every path. Without it, a payload object is unwrapped only
when it happens to carry the port's own name as a key.
Set an explicit topic: the default `*` is a literal topic, not MQTT's
match-all filter.
For `{"value": 47}`, set `json_key` to `value`. Without it, an object is
unwrapped only if it contains the port's name as a key.
### HTTP
@@ -81,6 +84,7 @@ Outputs only makes it a **webhook**: the engine mounts a route at
| `url`, `token`, `org`, `bucket` | connection; `token` takes a secret reference |
| `write_precision` | `ns`, `us`, `ms` (default) or `s` |
| `query_range` | default window for queries, e.g. `-1h` |
| `timeout` | operation timeout in seconds; default `10.0` |
| `writes` | per-input: `measurement`, `field`, `tags` |
| `queries` | per-output: `measurement`, `field`, `tags`, `range`, `aggregation` |
@@ -96,12 +100,10 @@ Outputs only makes it a **webhook**: the engine mounts a route at
}
```
**Query passthrough.** An incoming message holding a `flux` key is run as
written, and the rows come back on the first output port as
`{"rows": [{ts, value, field, measurement, tags}], ...}`.
A database node holds the credentials and the connection, nothing else.
Building a query and shaping its rows are Function nodes on either side.
**Query passthrough.** An incoming object with a `flux` key supplies the query.
The first output receives an object with `rows`, each containing `ts`, `value`,
`field`, `measurement`, and `tags`. Use Function nodes to build queries and
shape results for charts.
### Notification
@@ -115,10 +117,10 @@ Building a query and shaping its rows are Function nodes on either side.
| `priority` | `default` | `min`, `low`, `default`, `high`, `urgent` |
| `tags` | — | comma-separated ntfy tags |
| `token` | — | for a protected topic; takes a secret reference |
| `timeout` | `10.0` | request timeout in seconds |
This is a *flow* deciding something is worth saying. The engine reporting that
it broke is [Alerts](../interface/operations.md#alerts), which is a different
thing configured elsewhere.
For engine failures rather than flow messages, configure
[Alerts](../interface/operations.md#alerts).
## Timing
@@ -135,9 +137,7 @@ thing configured elsewhere.
| `at_start` | `false` | emit once when the flow starts |
| `start_delay` | `1.0` | how long to wait before that first emission |
The scheduler: a `cron` expression here is what makes a flow run by the clock.
It is also the most-placed node in a real instance, mostly as a button someone
presses.
Use `cron` for scheduled emissions, or trigger the node by hand for testing.
### Delay & schedule
@@ -154,17 +154,12 @@ Order of operations: rate check → delay → forward. With a `cron` and no inpu
it emits the current time on each tick; with inputs it emits the last value it
received.
The engine sleeps until a delay is due rather than polling for it, so a `delay`
fires within a few milliseconds of its deadline on an idle engine, and a due
timer is taken off the queue before work that is merely waiting. It is not a
real-time guarantee: the wait ends on a normal thread, and a node that then
needs a busy cascade slot waits for one.
Timers wait until their deadline; busy workers can delay execution.
!!! note "Not in a batch flow"
A rate limit holds a value back for a timer to release, and a run has no
timer, since the value would be dropped rather than delayed. Submitting a batch
flow with a rate-limited port is refused instead.
Batch flows reject rate-limited non-streaming ports. Runs have no live
timer to release a held value.
### Trigger
@@ -181,14 +176,11 @@ starts from a value arriving. For a cron tick, see [`inject`](#inject).
| `passthrough` | `false` | send the incoming value instead of `first` |
| `wait_port` | — | an input carrying the wait, when it differs per message |
The shape this exists for: *the door opened, so turn the light on, and off
again in two minutes unless it opens again.*
For example: turn a light on when the door opens, then off after two quiet
minutes.
`wait_port` covers the case where how long to wait is itself a value: a
rollershutter takes 26 seconds up and 28 down, so the node that decides the
direction says how long to run for as well. A wait of zero or less sends
nothing afterwards, and still cancels whatever the last message scheduled,
which is how a *stop* is commanded exactly once.
Use `wait_port` when the delay comes from an input. A wait of zero or less
cancels the pending second value and schedules nothing new.
## Logic
@@ -243,8 +235,8 @@ The cheapest fix for a sensor that reports every second and changes every hour.
### Command
**`exec`** — run a command in the engine's container and read its output.
Adding one or changing its settings is a superuser's, since the command is code.
**`exec`** — run a command where the engine runs and read its output.
Adding one or changing its settings requires a superuser.
| Setting | Default | Notes |
|---|---|---|
@@ -255,11 +247,8 @@ Adding one or changing its settings is a superuser's, since the command is code.
Outputs the command's stdout, stderr and exit code.
!!! warning "Inside the container, not on the host"
A flow ported from something that read the host's journal, or poked a host
script, needs either a mount or a small listener on the host side. This
node cannot see the host.
In Docker, the command runs inside the backend container. Mount host files
explicitly if the command needs them.
### File
@@ -272,8 +261,7 @@ Outputs the command's stdout, stderr and exit code.
| `format` | `text` | `text` or `json` |
| `newline` | `true` | end each written record with a newline |
Confined to a directory the engine owns. A flow that could name any path would
be a way to read the secrets store or overwrite a node's source.
Paths stay inside the engine's files directory.
## Numeric
@@ -297,17 +285,15 @@ use a [batch flow](../concepts/runs.md) and a Function node.
**`wren`** — turn a typed command into values for the devices wired to it.
[Wren](https://github.com/stroblme/wren) is a small language model that runs
on the engine's CPU: about 40 MB, a fifth of a second per command. It reads the
house as text, the areas, the devices in each and their states, and answers a
command with calls on those devices. It is a separate package, installed into
the engine with `pip install wren`; without it the node says so on the canvas.
[Wren](https://github.com/stroblme/wren) is an optional local language model
for device commands. Install it in the engine's environment with
`pip install wren`; the node reports a missing dependency if it is absent.
| Input | Type | Notes |
|---|---|---|
| `prompt` | `str` | the command, and the only input that runs the node |
| `presence` | `str` | the area the user is in; anything else reads as away |
| `outdoor` | `float` | °C; 20 without it |
| `outdoor` | `float` or `int` | °C; 20 without it, clamped to −5…30 |
| `dark` | `bool` | without it, dark before 07:00 and from 18:00 |
The outputs are `actions`, a list of records, and any number of **targets**. A
@@ -346,43 +332,31 @@ reads.
| `apply` | `false` | write to the targets; off, the node only reports |
| `min_confidence` | `0.93` | below this nothing is written |
Each record in `actions` is `{message, value, applied, delay_s, call,
confidence}`, one per call and target. A call nothing is wired for, such as
`sensors.temperature` or a light in a room without one, gets a record with an
empty `message`. The confidence is the lowest probability the model gave any
part of its answer: at the default threshold, nine in ten right answers pass,
and 38 of the 40 wrong ones in the model's own evaluation do not.
Each record in `actions` contains `message`, `value`, `applied`, `delay_s`,
`call`, and `confidence`. Unwired calls have an empty `message` and are not
applied. Keep `apply=false` while checking how the model interprets commands.
A call with a delay ("in 10m") is held by the engine's work queue, like the
Trigger node's second value: it survives a restart where the queue is in
Redis, and not otherwise.
Every house the model was trained on had a living room with a light, media
and heating, and a light in every other area. The node fills in what is not
wired, so the model reads a house it knows; a call on one of those is
reported, never applied.
The node adds default devices to the model's view for missing expected devices.
They are reported but cannot receive writes unless wired.
!!! note "What the current model cannot do"
No levels: a light is on or off, never dimmed, coloured or half open.
Delays are whole minutes. Only one device per kind and area. Only the ten
areas above: one it was not trained on is answered confidently and wrongly,
which is why such a port is refused rather than tried. Commands are English,
phrased roughly like "turn on the lights in the kitchen".
Commands are English, such as "turn on the lights in the kitchen".
The model uses the areas above, one device per kind and area, on/off
actions, and whole-minute delays. It does not dim lights or set colors.
The chat's [Control tab](../code/agents.md#control) sends commands by hand.
## Connectors
Anything else in the palette came from an installed **connector** package: a
node type written against a published contract and discovered through the
`fluksio.node_types` entry point group. The editor shows which package supplied
it.
A connector declares its contract version, and one written for a version this
engine does not speak is ignored rather than half-loaded. Installing or
upgrading one takes effect on the next engine restart, because Python does not
re-import a changed module and a rescan would promise more than it delivers.
Installed connector packages add types through the `fluksio.node_types`
entry point group. The editor shows the source package. Incompatible contract
versions are skipped; installing or upgrading a connector requires an engine
restart.
The contract itself is in [The connector contract](connector-contract.md), and
[Writing a connector](../code/connectors.md) walks through building one.
+82 -119
View File
@@ -1,207 +1,170 @@
# Payload types
Every port declares a `dtype`, and every value that passes through it is
checked against that declaration.
Every port declares a `dtype`. Fluksio checks values against it and offers
compatible messages when you bind dashboard widgets.
This is not decoration. It is what lets the dashboard editor offer you only the
messages a gauge can actually draw, what lets the canvas refuse a binding before
anything runs, and what lets a downstream node know the shape of what it is
getting before the flow starts.
Messages carry JSON. Binary data lives in the artifact store; messages carry
references to it.
Everything on the wire is JSON. That is what lets the same value pass through
the state backend, the work queue and the worker protocol unchanged.
## The scalars
## Scalars
| `dtype` | Accepts |
|---|---|
| `float` | any number — `int` or `float`, but not `bool` |
| `int` | a whole number, not `bool` |
| `str` | a string |
| `bool` | exactly `true` or `false` |
| `float` | integer or floating-point number, excluding booleans |
| `int` | integer, excluding booleans |
| `str` | string |
| `bool` | `true` or `false` |
`bool` is an `int` subclass in Python but is not a number here, so a switch
cannot bind to a temperature.
`NaN` and infinity are rejected, including in structured payloads. For an
unavailable numeric result, omit that output or return `None` to publish
nothing. A numeric port does not accept `None` as its value.
`NaN` and infinity are refused, wherever they sit, including inside a `json`,
`record`, `series` or `list`. JSON cannot spell either, so one that travelled
would come back as a response nobody can parse and a row the database rejects,
a long way from the node that made it. An empty subset or a division with no
denominator is what usually produces one; publish `None` instead.
## The structured ones
These are *declared shapes* rather than "some JSON", which is what makes a
widget binding checkable.
## Structured payloads
### `record`
Flat named scalars.
An object with string keys and scalar or null values. Nested objects and
lists are not allowed.
```json
{"title": "Boiler", "body": "Pressure low", "severity": "warning"}
```
A record cannot contain a record, so its shape is readable from the declaration
alone.
Read by the **Notification** widget. It is also what an alert channel of kind
*dashboard* writes.
Notification and Player widgets read records. Dashboard alert channels also
publish them.
### `list`
Ordered items of one declared shape. The port also declares `item`:
An array with a declared item type:
| `item` | Meaning |
| `item` | Accepts |
|---|---|
| unset | `record` — what the agenda and forecast widgets read |
| `float`, `int`, `str`, `bool` | a list of scalars |
| `json` | anything |
| unset or `record` | flat records; used by Agenda and Forecast |
| `float`, `int`, `str`, `bool` | scalars of that type |
| `json` | arbitrary JSON values |
A list of lists, or a list of series, is refused. One declared level is the
point.
`list` and `series` cannot be declared as item types. Use `item: "json"` if
items need arbitrary nested structures.
### `series`
Labelled lines of `(timestamp, value)` pairs, which is what a chart draws.
Labelled lines of numeric `[timestamp, value]` pairs:
```json
{
"lines": [
{"label": "living", "points": [[1717000000, 21.4], [1717000060, 21.5]]}
],
"range": "-24h"
"range_s": 86400,
"interval_s": 60
}
```
Keys beside `lines` are carried through untouched, which is how a querying
chart puts the window and resolution it asked for on the request and reads them
back off the answer. That is what stops an answer to a *different* question
from overwriting the picture.
Additional top-level keys are preserved. A Query chart expects the
`range_s` and `interval_s` from its request in the response; this prevents
an answer for another window from replacing its chart.
`GET /runs/series/compare` answers in this shape, which is why comparing three
training curves is a widget binding rather than a screen of its own.
The run-series comparison endpoint also returns this shape.
### `artifact`
A reference to stored bytes.
A reference to bytes in the content-addressed artifact store:
```json
{"digest": "sha256:…", "size": 4194304, "media_type": "application/octet-stream", "name": "weights.pt"}
```
Binary payloads (tensors, checkpoints, images) never travel as a message. The
bytes go to a content-addressed store and the message carries this. A
thirty-megabyte checkpoint never sits in the state backend, and the reference
stays valid wherever the store is reachable from, including on another machine.
`digest` and `size` identify the content. `media_type` and `name` describe it.
The reference works wherever the store is reachable.
Node code produces one with `fluksio.save_artifact` and opens one with
`fluksio.load_artifact`. See [Writing node code](../code/nodes.md#bytes-artifacts).
Create one with `fluksio.save_artifact` and read its bytes with
`fluksio.load_artifact`. See [Artifacts](../code/nodes.md#bytes-artifacts).
### `image`, `audio`, `video`
The same reference, narrowed to a kind of media by its `media_type`.
Artifact references restricted to the matching `media_type` family:
```json
{"digest": "sha256:…", "size": 61344, "media_type": "image/jpeg", "name": "frame.jpg",
"meta": {"width": 1280, "height": 720, "seq": 41}}
{
"digest": "sha256:…",
"size": 61344,
"media_type": "image/jpeg",
"name": "frame.jpg",
"meta": {"width": 1280, "height": 720, "seq": 41}
}
```
An `audio` port takes `audio/*` and refuses anything else, so a speech
recogniser declares what it eats rather than taking any bytes at all and
finding out. An `artifact` port still accepts all three: media narrows
artifact, not the other way round.
An `audio` port accepts `audio/*`; `image` and `video` work the same way.
An `artifact` port accepts all three. Optional `meta` fields are for the
consumer; Fluksio does not interpret them.
`meta` is optional and nothing here reads it: sample rates, dimensions and
sequence numbers are for whoever consumes the media.
A media stream is a [streaming port](../concepts/flows.md#streaming-ports)
publishing one reference per frame or chunk. Saving with
[`volatile=True`](../code/nodes.md#media) keeps bytes in memory and lets local
websocket clients receive them with the value. Stored media and portal views
fetch the bytes separately.
Bytes still never travel as a message. A camera publishes one reference per
frame and a microphone one per chunk, which makes a media stream an ordinary
[streaming port](../concepts/flows.md#streaming-ports). What that costs
depends on how the bytes reach the screen:
| Rate | Where it works |
|---|---|
| A clip a second (speech) | anywhere, including through the portal |
| Ten frames a second (a camera worth watching) | on the local network, with `volatile=True` |
| A frame every second or two (a glance at a door) | anywhere, including through the portal |
| Full-rate video, 30 fps and up | not here — see below |
The difference is one flag. A frame saved with
[`volatile=True`](../code/nodes.md#media) is held in memory and pushed down the
websocket in front of the value naming it, so a screen draws it without asking
for anything. A stored one is fetched instead: a round trip per frame, which is
a glance rather than a view. Through a portal every frame is fetched, so a
remote panel is in the second row whatever the flag says.
Above that, video is not a message-plane problem — every frame would still be
an event. Point a media widget's **stream URL** at whatever the camera already
serves and the browser plays it from source; the messages then carry the
occasional still, and the flow reacts to those.
Use occasional snapshots through a portal. For continuous video, point a
Media widget's **Live stream** at a browser-compatible source URL reachable
by the screen.
### `json`
Anything JSON-serializable. The escape hatch, and the right answer when a
payload genuinely has no fixed shape.
Any JSON-serializable value. Use it for payloads without a fixed shape.
Prefer a specific type when one fits; it gives widgets and other authors a
clearer contract.
Reach for it last. A `json` port tells the canvas, the widget picker and the
next author nothing.
## Referencing a run's output
## Naming a run's output
A run parameter can name an existing value instead of repeating it:
Any *run parameter* is also accepted as text, since nobody wants to paste an
object into a shell. `@run:<id>.<output>` names what an earlier run produced,
whatever its type, an `artifact` reference or a `json` config alike. A bare
`sha256:…` digest names content in the artifact store. Both resolve before the
run starts, so the CLI, the run dialog and a python caller all mean the same
thing by the same string.
- `@run:<id>.<output>` resolves an earlier run's output.
- `sha256:…` resolves an artifact digest.
Both spellings are reserved on every input, `str` included: an input that has to
carry one of them literally is asking for a value this engine reads as a name.
These strings resolve before the run starts, regardless of the input's type.
Both prefixes are reserved even for `str` inputs; they cannot be passed as
literal text in run parameters.
## What a widget will bind to
## Widget bindings
| Widget | Accepts |
|---|---|
| Gauge, Chart, Slider, Bar | `float`, `int` |
| Gauge, live Chart, Slider, Bar | `float`, `int` |
| Query Chart | `series` response, `record` request |
| Switch | `bool` |
| Agenda, Forecast | `list` |
| Notification | `record` |
| Value | anything |
| Icon | weather strings, booleans and numbers alike |
| Notification, Player | `record` |
| Colour | `list` for HSV/RGB, `str` for hex |
| Media | `image`, `audio`, `video`, `artifact` |
| Clock, Text | nothing — they bind to no message |
| Value, Icon, Button, Buttons, Input, Selector | no fixed type restriction |
| Clock, Text, Website | no message binding |
Enforced on the server as well as in the editor.
Player transport commands publish to a separate `str` message.
Binding checks apply in the editor and on the server.
## Icon and colour names
A forecast is a `list` of records, one per column, each shaped
`{label, icon, value}` with an optional `color`. The flow decides what a
column is called and which glyph it gets; the widget only draws it.
Forecast reads a list of records with `label`, `icon` and `value`, plus
optional `color`:
```json
[{"label": "Today", "icon": "cloud-rain", "color": "primary", "value": "14°"}]
```
`icon` and `color` are names from a fixed vocabulary, the same one the icon
widget's picker offers:
The Icon widget uses the same vocabulary:
| | Names |
|---|---|
| Icons | `sun`, `moon`, `cloudy`, `cloud-rain`, `cloud-drizzle`, `cloud-snow`, `cloud-lightning`, `cloud-fog`, `wind`, `umbrella`, `snowflake`, `droplets`, `thermometer`, `thermometer-sun`, `flame`, `fan`, `sun-snow`, `door-open`, `house`, `bed`, `lightbulb`, `plug`, `music`, `volume-2`, `volume-x`, `zap`, `battery-charging`, `arrow-up`, `arrow-down`, `triangle-alert`, `check`, `circle-check` |
| Icons | `sun`, `moon`, `cloudy`, `cloud-rain`, `cloud-drizzle`, `cloud-snow`, `cloud-lightning`, `cloud-fog`, `wind`, `umbrella`, `snowflake`, `droplets`, `thermometer`, `thermometer-sun`, `flame`, `fan`, `sun-snow`, `door-open`, `house`, `bed`, `lightbulb`, `plug`, `cctv`, `music`, `volume-2`, `volume-x`, `zap`, `battery-charging`, `arrow-up`, `arrow-down`, `triangle-alert`, `check`, `circle-check` |
| Colours | `default`, `muted`, `primary`, `success`, `danger` |
An empty icon draws nothing. An unknown one draws a placeholder glyph and an
unknown colour draws as `default`; either warns once in the browser console,
listing the names it expected.
An empty icon draws nothing. Unknown icons use a placeholder; unknown colours
use `default`. Each warns once in the browser console.
## Type failures
A value that does not match its port's declaration raises on the node that
published it, naming the port and what arrived. It does not get published, and
it does not reach anything downstream. A wrong value stopping at its source is
easier to diagnose than one propagating.
A mismatched value raises an error on the node that produced it, naming the
port and received value. It is not published downstream.
## See also