docs
This commit is contained in:
+128
-179
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user