Publish the connector contract and authoring guide
Adds reference/connector-contract.md and code/connectors.md to the site, with the node-type reference finally linking the contract it describes. Strict build passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +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.
|
||||
|
||||
## Start from the skeleton
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
git clone https://git.stroblme.de/Fluksio/connectors.git
|
||||
cp -r connectors/skeleton fluksio-connector-mydevice
|
||||
cd fluksio-connector-mydevice
|
||||
```
|
||||
|
||||
Rename the package (`src/fluksio_connector_skeleton/` and the `[project]` name
|
||||
in `pyproject.toml`), then point the entry point at your class:
|
||||
|
||||
```toml
|
||||
[project.entry-points."fluksio.node_types"]
|
||||
my_device = "fluksio_connector_mydevice:MyDevice"
|
||||
```
|
||||
|
||||
## 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 (⌘K),
|
||||
labelled with the package it came from.
|
||||
|
||||
## Write the node
|
||||
|
||||
```python
|
||||
from typing import Any
|
||||
|
||||
from pydantic import Field
|
||||
from fluksio.flow.connector import CONTRACT_VERSION, ConnectorNode
|
||||
|
||||
|
||||
class MyDevice(ConnectorNode):
|
||||
contract = CONTRACT_VERSION
|
||||
title = "My device"
|
||||
description = "Reads temperature and humidity over the device's HTTP API."
|
||||
|
||||
class Params(ConnectorNode.Params):
|
||||
host: str = "192.168.1.50"
|
||||
api_key: str | None = Field(
|
||||
default=None, json_schema_extra={"x-secret": True}
|
||||
)
|
||||
|
||||
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.
|
||||
|
||||
## Try it without a device
|
||||
|
||||
Give `poll` something predictable first and confirm the values reach the canvas
|
||||
before pointing it at hardware:
|
||||
|
||||
```python
|
||||
async def poll(self):
|
||||
return {"temperature": 21.5}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Checklist before sharing it
|
||||
|
||||
- `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.
|
||||
Reference in New Issue
Block a user