docs
This commit is contained in:
+97
-113
@@ -1,146 +1,130 @@
|
||||
# Writing a connector
|
||||
|
||||
A walkthrough from an empty directory to a node on the canvas. The normative
|
||||
surface is in [the connector contract](../reference/connector-contract.md);
|
||||
this is how to use it.
|
||||
A connector adds a device or service integration to the node palette. It is
|
||||
a Python package with a `ConnectorNode` subclass and an entry point.
|
||||
This example works without hardware or another repository.
|
||||
|
||||
## Start from the skeleton
|
||||
## Create the package
|
||||
|
||||
A working connector, kept as small as a correct one can be, lives at
|
||||
`skeleton/` in the [connectors repository](https://git.stroblme.de/Fluksio/connectors).
|
||||
Copy it:
|
||||
Make this directory beside your project:
|
||||
|
||||
```bash
|
||||
git clone https://git.stroblme.de/Fluksio/connectors.git
|
||||
cp -r connectors/skeleton fluksio-connector-mydevice
|
||||
cd fluksio-connector-mydevice
|
||||
```text
|
||||
fluksio-connector-random/
|
||||
pyproject.toml
|
||||
src/
|
||||
fluksio_connector_random/
|
||||
__init__.py
|
||||
```
|
||||
|
||||
Rename the package (`src/fluksio_connector_skeleton/` and the `[project]` name
|
||||
in `pyproject.toml`), then point the entry point at your class:
|
||||
Put this in `pyproject.toml`:
|
||||
|
||||
```toml
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "fluksio-connector-random"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["fluksio>=0.1,<0.2"]
|
||||
|
||||
[project.entry-points."fluksio.node_types"]
|
||||
my_device = "fluksio_connector_mydevice:MyDevice"
|
||||
random_sensor = "fluksio_connector_random:RandomSensor"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/fluksio_connector_random"]
|
||||
```
|
||||
|
||||
## Install it into the engine
|
||||
|
||||
Connectors are found through installed package metadata, so the engine has to
|
||||
have yours installed. During development, install it as an editable path
|
||||
dependency of the backend:
|
||||
|
||||
```bash
|
||||
cd app/backend
|
||||
uv add --editable ../../fluksio-connector-mydevice
|
||||
```
|
||||
|
||||
Editing your connector's **code** then takes effect on the next engine restart,
|
||||
with no reinstall. Changing its **metadata** (the entry point, the version,
|
||||
the package name) needs `uv sync` to regenerate the installed metadata before
|
||||
a restart picks it up.
|
||||
|
||||
Two things to know about this:
|
||||
|
||||
- `uv add` writes the path dependency into `backend/pyproject.toml` and
|
||||
`uv.lock`. That path does not exist inside the backend's Docker image, so
|
||||
**revert both files before committing**; the dependency belongs on your
|
||||
machine, not in the repo. Distribution is a later milestone.
|
||||
- For the same reason, develop against the **standalone backend** rather than
|
||||
the Docker stack:
|
||||
|
||||
```bash
|
||||
cd app && make dev-backend # FastAPI on :8000
|
||||
make dev-frontend # Vite on :5173
|
||||
```
|
||||
|
||||
Restart the backend and your node type appears in the add-node palette (⌘P),
|
||||
labelled with the package it came from.
|
||||
|
||||
## Write the node
|
||||
Put this in `src/fluksio_connector_random/__init__.py`:
|
||||
|
||||
```python
|
||||
from typing import Any
|
||||
import random
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from fluksio.flow.connector import CONTRACT_VERSION, ConnectorNode
|
||||
|
||||
|
||||
class MyDevice(ConnectorNode):
|
||||
class RandomSensor(ConnectorNode):
|
||||
contract = CONTRACT_VERSION
|
||||
title = "My device"
|
||||
description = "Reads temperature and humidity over the device's HTTP API."
|
||||
title = "Random sensor"
|
||||
description = "Emits a random reading. No sensor required."
|
||||
|
||||
class Params(ConnectorNode.Params):
|
||||
host: str = "192.168.1.50"
|
||||
api_key: str | None = Field(
|
||||
default=None, json_schema_extra={"x-secret": True}
|
||||
)
|
||||
ceiling: float = Field(default=1.0, gt=0)
|
||||
|
||||
async def poll(self) -> dict[str, Any] | None:
|
||||
reading = await self._read()
|
||||
return {"temperature": reading.temp, "humidity": reading.humidity}
|
||||
```
|
||||
|
||||
Then, on the canvas: place the node, set `poll_interval` to how often the
|
||||
device should be read, pick the API key from the secrets store, and add output
|
||||
ports named `temperature` and `humidity`. The port names are the wiring: any
|
||||
node consuming those messages is downstream of this one.
|
||||
|
||||
## If the device is told rather than asked
|
||||
|
||||
A connector that commands something implements `write()` instead of, or
|
||||
alongside, `poll()`. Input ports carry the values in:
|
||||
|
||||
```python
|
||||
def write(self, **ports: Any) -> None:
|
||||
self._device.set(ports["level"])
|
||||
```
|
||||
|
||||
Place the node with *input* ports named `level`, and whatever publishes that
|
||||
message drives the device. Set `idempotent = False` on the class: a write is a
|
||||
command, and a redelivery after a crash should not undo a newer one.
|
||||
|
||||
Keep the first version off the wire. A boolean setting the code checks before
|
||||
it sends (`artnet`'s `transmit` is the example) lets a flow be built and
|
||||
watched in the logs before anything physically moves.
|
||||
|
||||
## If the reading is bytes
|
||||
|
||||
A camera or a microphone publishes a reference rather than the bytes:
|
||||
|
||||
```python
|
||||
async def poll(self):
|
||||
jpeg = await asyncio.to_thread(self._grab)
|
||||
return {"frame": self.save_artifact(jpeg, "f.jpg", media_type="image/jpeg")}
|
||||
return {"reading": random.random() * self.config.ceiling}
|
||||
```
|
||||
|
||||
Place the node with an `image`-typed output port and a Media widget draws each
|
||||
frame as it lands. `fluksio-connector-test-media` publishes test frames and
|
||||
tones this way, so the whole path can be wired up with no camera in the room.
|
||||
Copy it if yours is a media device.
|
||||
The entry point name, `random_sensor`, becomes the stored node type.
|
||||
Choose a unique name; the engine skips names that are already registered.
|
||||
|
||||
## Try it without a device
|
||||
## Install and try it
|
||||
|
||||
Give `poll` something predictable first and confirm the values reach the canvas
|
||||
before pointing it at hardware:
|
||||
Use the **engine's Python environment**, the one where you installed Fluksio:
|
||||
|
||||
```python
|
||||
async def poll(self):
|
||||
return {"temperature": 21.5}
|
||||
```sh
|
||||
pip install -e /path/to/fluksio-connector-random
|
||||
```
|
||||
|
||||
Press Run, or wait for the poll interval, and the value appears on the edge.
|
||||
Anything the connector prints shows up in the logs panel in the dock, and a
|
||||
raised exception shows there with its traceback and marks the node down.
|
||||
Restart the engine from its original project directory so it keeps using
|
||||
the same instance data:
|
||||
|
||||
## Checklist before sharing it
|
||||
```sh
|
||||
fluksio serve
|
||||
```
|
||||
|
||||
- `contract = CONTRACT_VERSION` declared on the class itself.
|
||||
- Credentials marked `x-secret`, never read from the environment.
|
||||
- `stop()` is safe to call twice, and after a failed `start()`.
|
||||
- Blocking I/O wrapped in `asyncio.to_thread`.
|
||||
- `poll()` returns `None` rather than raising when there is simply nothing new.
|
||||
- `write()` returns quickly; the thread it runs on is the scheduler's.
|
||||
- Health reported when the connection comes and goes.
|
||||
- Anything that has to outlive a restart in `self.store`, keyed by device.
|
||||
Restart an already running engine after installing. In the browser:
|
||||
|
||||
1. Add a **Random sensor** node to a live flow.
|
||||
2. Set `poll_interval` to `5` seconds.
|
||||
3. Add an output port named `reading`, type `float`.
|
||||
4. Publish the flow and watch the value change.
|
||||
|
||||
The palette shows the package name and version. Code edits need an engine
|
||||
restart; metadata changes also need another editable install.
|
||||
|
||||
For Docker, install the package in the backend image and rebuild it.
|
||||
The Modules screen installs packages for **node code**, so it does not
|
||||
install connectors into the engine.
|
||||
|
||||
## Connect a real device
|
||||
|
||||
Replace `poll()` with your device read. Return a dictionary keyed by declared
|
||||
output port, or `None` when there is nothing new. The base class polls at
|
||||
`poll_interval`, publishes changed values, and reports failures.
|
||||
|
||||
Keep blocking I/O off the event loop with `asyncio.to_thread`. For pushed
|
||||
updates, manage the connection in `start()` and `stop()`, leave
|
||||
`poll_interval=0`, and call `self.inject({...})` when data arrives.
|
||||
|
||||
Mark credential parameters with
|
||||
`Field(default=None, json_schema_extra={"x-secret": True})`. The editor
|
||||
then stores a secret reference rather than the credential in the flow.
|
||||
|
||||
To send commands, implement `write(self, **ports)`: it receives values keyed
|
||||
by input port. Set `idempotent = False` for commands that must not be replayed
|
||||
after a crash.
|
||||
|
||||
## Media and persistent state
|
||||
|
||||
Use `self.save_artifact(data, name, media_type=...)` for bytes such as camera
|
||||
frames. Return the reference through an `image`, `audio`, or `video` port.
|
||||
For disposable live frames, pass `volatile=True`.
|
||||
|
||||
Use `self.store` for short strings that must survive a restart, such as a
|
||||
pairing token. Include the device address in the key: all nodes from the
|
||||
same top-level package share this store.
|
||||
|
||||
## Before sharing
|
||||
|
||||
- Declare `contract = CONTRACT_VERSION` on the class itself.
|
||||
- Return only declared output ports, with values matching their types.
|
||||
- Mark credentials as secrets.
|
||||
- Keep `stop()` safe after a failed start and when called twice.
|
||||
- Report connection health if you manage your own subscription.
|
||||
|
||||
See [the connector contract](../reference/connector-contract.md) for lifecycle,
|
||||
storage limits, and certificate pinning.
|
||||
|
||||
Reference in New Issue
Block a user