Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
255 changes: 97 additions & 158 deletions docs/writing-a-driver.md
Original file line number Diff line number Diff line change
@@ -1,105 +1,63 @@
# Writing a Lua driver
# The FTW Lua driver host

Drivers are the hardware boundary. A driver translates one vendor protocol to
FTW's site convention and runs in its own capability-scoped Lua 5.1 VM. No Go
build is needed.

For a shared device, create or change the source and manifest in the public
[`srcfl/device-drivers`](https://github.com/srcfl/device-drivers) repo. Its
signed channel is FTW's default source. Device Support may later consume an
exact reviewed commit and does not own a second editable source. The `drivers/`
tree here is the bundled FTW recovery snapshot; operator-only drivers may still
live locally.

## Metadata

Every driver declares one authoritative catalog block:

```lua
DRIVER = {
id = "example",
name = "Example inverter",
manufacturer = "Example",
version = "0.1.0",
host_api_min = 1,
host_api_max = 1,
protocols = { "modbus" },
capabilities = { "meter", "pv", "battery" },
description = "Supported product family.",
authors = { "FTW contributors" },
tested_models = {},
verification_status = "experimental",
}
```

The `DRIVER` table feeds target validation and FTW's in-app catalog. Its id,
version, host API and `read_only` value must agree with the signed package. Do
not duplicate the catalog in Markdown. Executable or public metadata changes
require one public package version bump.

## Lifecycle

```lua
function driver_init(config)
-- read driver config, subscribe/connect, report identity
end

function driver_poll()
-- read device and call host.emit / host.emit_metric
end

function driver_command(action, power_w, command)
-- translate a site-convention command to the vendor protocol
return true
end

function driver_default_mode()
-- cancel forced control and restore safe autonomous operation
return true
end

function driver_cleanup() -- optional
end
```

Controllable devices require a real `driver_default_mode`; it is called for
stale telemetry, relevant reloads, removal and shutdown. Polling must not keep
re-emitting an indefinitely cached value as fresh telemetry. Age vendor data
and stop emitting when it is stale so core's watchdog can work.

`driver_fingerprint(target)` is an optional passive setup probe. It must never
reconfigure the device.

## Sign convention
**To write a driver, start upstream.** Source, manifests and the authoring
guide live in the public
[`srcfl/device-drivers`](https://github.com/srcfl/device-drivers) repo, whose
signed channel is FTW's default source:

Translate before calling `host.emit` and translate commands in the opposite
direction:
- [`docs/WRITING-A-DRIVER.md`](https://github.com/srcfl/device-drivers/blob/main/docs/WRITING-A-DRIVER.md)
— entry points, sign convention, what never to fabricate, how to add and test
a driver;
- [`blueprint/BLUEPRINT.lua`](https://github.com/srcfl/device-drivers/blob/main/blueprint/BLUEPRINT.lua)
— a complete working driver, and the specification the guide explains;
- [device driver catalog](https://srcfl.github.io/device-drivers/) — which
devices are already covered, and on what evidence.

- meter import positive, export negative;
- PV generation negative;
- battery/vehicle charge positive, discharge negative;
- SoC telemetry uses the fraction `0..1` unless a field explicitly says
percent.
This page is the other half: what FTW's host gives a driver, how FTW grants it,
where FTW loads it from, and how to test one against a running instance. It is
deliberately not a second authoring guide — two of those drift, and the one
here is the copy that would go stale.

Read [site-convention.md](site-convention.md). Sign conversion anywhere above
the driver is a bug.

## Host capabilities
## Host API

[`go/internal/drivers/lua.go`](../go/internal/drivers/lua.go) is the complete,
current host API. It exposes
telemetry, diagnostic metrics, identity, time/JSON helpers and capability-gated
MQTT, Modbus, HTTP, WebSocket and raw TCP operations.
current host API and the source of truth for it. Today it registers:

| Group | Calls |
|---|---|
| Telemetry | `emit`, `emit_metric` |
| Identity and nameplate | `set_make`, `set_sn`, `set_model`, `set_rated_w` |
| Driver control | `set_poll_interval`, `set_warmup_s`, `set_watchdog_timeout_s`, `set_device_fault` |
| Helpers | `log`, `sleep`, `millis`, `now_ms`, `json_encode`, `json_decode`, `persist_secret` |
| Decoding | `decode_string`, `decode_i16`, `decode_i32_be`, `decode_i32_le`, `decode_u32_be`, `decode_u32_le` |
| Modbus | `modbus_read`, `modbus_write`, `modbus_write_multi` |
| MQTT | `mqtt_pub`, `mqtt_sub`, `mqtt_publish`, `mqtt_subscribe`, `mqtt_messages` |
| HTTP | `http_get`, `http_post`, `http_patch` |
| WebSocket | `ws_open`, `ws_send`, `ws_messages`, `ws_is_open`, `ws_close` |
| Raw TCP | `tcp_open`, `tcp_recv`, `tcp_close`, `tcp_is_open` |
| Serial | `serial_read` |
| Crypto | `aes_gcm_decrypt` |

Some calls answer to two names. `srcfl/device-drivers` treats the Blixt L1 host
API as its naming reference and is converting its catalog to it, so `write`,
`write_registers` and `now_ms` resolve to `modbus_write`, `modbus_write_multi`
and `millis`. `host.emit` likewise reads `W` and `SoC_nom_fract` when `w` and
`soc` are absent; when both are present the lowercase key wins. Prefer the
canonical spelling in a new driver. The older names stay until the catalog has
finished moving.
API as its naming reference, so `write`, `write_registers` and `now_ms` resolve
to `modbus_write`, `modbus_write_multi` and `millis`. `host.emit` likewise
reads `W` and `SoC_nom_fract` when `w` and `soc` are absent; when both are
present the lowercase key wins. Both spellings are correct — prefer the
canonical one in a new driver.
Comment on lines 45 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Label the actual canonical Modbus names

When authors follow the Blixt naming reference, write and write_registers are the canonical names, while modbus_write and modbus_write_multi are the older FTW spellings retained for compatibility; TestCanonicalHostFunctionAliasesExist and the registration comments explicitly document that direction. The new table omits the canonical write names, and this prose says to prefer the canonical spelling without identifying which side of the mapping that is, encouraging new drivers to use names intended for eventual retirement. List the Blixt names in the table or explicitly label them as canonical.

AGENTS.md reference: AGENTS.md:L41-L44

Useful? React with 👍 / 👎.


`http_patch` is the mutating verb and is gated twice: the plain `http` grant is
not enough, it also needs `capabilities.http.allow_write`. It refuses to follow
redirects, because Go re-issues a redirected `PATCH` as a body-less GET and a
device write that never landed would otherwise report success.

A YAML driver entry grants only what the file needs:
## Capability grants

A YAML driver entry grants only what the file needs. The keys are `modbus`,
`mqtt`, `serial`, `http`, `websocket` and `tcp`:
Comment on lines +59 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include the standalone capability in the exhaustive list

For a driver that intentionally needs no external I/O capability, this exhaustive list hides the valid capabilities.standalone key. config.Config.Validate accepts that boolean specifically as the alternative to MQTT, Modbus, serial, HTTP, WebSocket, or TCP and otherwise rejects the driver, so an operator relying on this host reference cannot construct a valid standalone configuration. Include standalone and clarify that it is a boolean rather than a resource block.

Useful? React with 👍 / 👎.


```yaml
drivers:
Expand All @@ -113,108 +71,89 @@ drivers:
unit_id: 1
```

Calls without a granted capability return an error. HTTP destinations are
allowlisted. Never add an unrestricted network escape to solve one driver's
setup problem.
Calls without a granted capability return an error rather than reaching the
network. HTTP destinations are allowlisted per driver and can pin a certificate
with `tls_pin_sha256`. Never add an unrestricted network escape to solve one
driver's setup problem.

## What the host does around a driver

Sign conversion happens only here. Read
[site-convention.md](site-convention.md); a conversion anywhere above the
driver is a bug.

A controllable device needs a real `driver_default_mode`. The host calls it for
stale telemetry, relevant reloads, removal and shutdown, so it is the path that
returns hardware to safe autonomous operation when FTW stops being able to
steer it. Polling must not keep re-emitting an indefinitely cached value as
fresh telemetry: age vendor data and stop emitting when it is stale, or core's
watchdog cannot see the fault.

`driver_fingerprint(target)` is an optional passive setup probe. It must never
reconfigure the device.

Call `host.set_make` and `host.set_sn` as soon as stable identity is known.
Core then keys durable device state by hardware identity rather than the YAML
name. `host.set_model` and `host.set_rated_w` record the rest of the nameplate;
the host repeats both on every emit, so read them once in `driver_init` rather
than every poll. Neither takes part in device-id resolution. Use
`host.emit_metric(name, value, unit)` for scalar diagnostics that do not belong
in structured meter/PV/battery/EV telemetry.
than every poll. Neither takes part in device-id resolution.

A device that answers Modbus before its registers mean anything can call
`host.set_warmup_s(seconds)` in `driver_init` to hold off the first poll.
`host.decode_string(registers, start, count)` reads ASCII from a register block,
two characters per register, high byte first, trailing padding stripped — use it
instead of hand-rolling the byte loop.

## Implementation sequence

1. Add or update the driver and manifest in `srcfl/device-drivers`.
2. Implement read-only polling and verify signs against real vendor values.
3. Build the explicit FTW GopherLua/Lua 5.1 target and run FTW host tests.
4. Add stable identity and stale-cache handling.
5. Add commands only after telemetry is trustworthy.
6. Implement and test default mode, leases and structured command results
before enabling automatic dispatch.
7. Add configuration example only when the integration needs non-obvious
operator input.
8. Add Go-hosted Lua tests beside [`go/internal/drivers`](../go/internal/drivers).

Useful checks:

```bash
cd go
go test ./internal/drivers
go test ./internal/driverrepo
```

For live work, start with telemetry only and a physically supervised device.
Compare FTW, vendor UI and the site meter before sending a non-zero command.
Test charge, discharge, zero, offline/default mode and reconnect. Record
device-specific safety knowledge in the driver next to the code it constrains.
## Where FTW loads a driver from

## Local overrides and release
FTW resolves a driver file as local, then managed signed, then bundled.
Settings and fleet inventory mark the first case `local / unsigned`.

Custom drivers belong in the persistent user-driver directory, not inside a
container layer:
Operator-only drivers belong in the persistent user-driver directory, not
inside a container layer:

- Docker: `/app/data/drivers`, which is the host's `./data/drivers` bind;
- systemd: `/var/lib/ftw/drivers`;
- another native run: pass `-user-drivers <dir>`.

Keep the config path portable:
Local code works offline and never needs GitHub or Device Support. It gets no
auto-update or promotion and cannot claim signed package control. The normal
host capabilities and lifecycle still apply.

```yaml
drivers:
- name: example
lua: drivers/example.lua
capabilities:
modbus:
host: 192.168.1.20
port: 502
unit_id: 1
```
The bundled set under `drivers/` is FTW's offline recovery snapshot, generated
from the commit pinned in
[`drivers/BUNDLED_SOURCE.json`](../drivers/BUNDLED_SOURCE.json) and fetched by
`make drivers`. It is not editable here: the files are gitignored and CI fails
if one is committed. Fix a driver upstream and move the pin. Managed drivers
are signed, installed atomically and rollbackable; see
[device-repository.md](device-repository.md).

FTW resolves that file as local, then managed signed, then bundled. Settings
and fleet inventory mark the first case `local / unsigned`. Local code works
offline and never needs GitHub or Device Support.
It gets no auto-update or promotion and cannot claim signed package control.
The normal host capabilities and lifecycle still apply.
## Testing against a running FTW

Use **Test connection** in Settings, or call `POST /api/drivers/test`. The test
starts a short-lived driver, runs init and poll with the declared hardware
capabilities, and does not save config. Start read-only on a test device. The
endpoint reads the Lua file again for each test. FTW does not watch Lua files:
an active driver needs an FTW restart or a real config change that restarts its
registry entry after the file changes.
endpoint reads the Lua file again for each test.

```bash
curl -sS -X POST http://127.0.0.1:8080/api/drivers/test \
-H 'Content-Type: application/json' \
-d '{"name":"example-test","lua":"drivers/example.lua","capabilities":{"modbus":{"host":"192.168.1.20","port":502,"unit_id":1}}}'
```

Restart Docker with `docker compose restart ftw`, or systemd with
FTW does not watch Lua files. An active driver needs an FTW restart, or a real
config change that restarts its registry entry, after the file changes. Restart
Docker with `docker compose restart ftw`, or systemd with
`sudo systemctl restart ftw`. To roll back a local overlay, move the local file
out of the user-driver directory and restart. FTW then selects the managed or
out of the user-driver directory and restart; FTW then selects the managed or
bundled file without changing its managed cache.

Before a public pull request, clone `srcfl/device-drivers`, place the driver in
its public layout, and run:
Host-side Lua tests live beside [`go/internal/drivers`](../go/internal/drivers):

```bash
make test-driver ID=example
make package-driver ID=example TARGET=ftw-core
make check
cd go
go test ./internal/drivers
go test ./internal/driverrepo
```

Managed drivers are signed, installed atomically and rollbackable; see
[device-repository.md](device-repository.md).

The public repository publishes new drivers to `drivers-beta` first. Promote
the exact signed beta commit after hardware validation. Each changed artifact
needs a higher driver SemVer. FTW does not fork or renumber that release.
For live work, start with telemetry only and a physically supervised device.
Compare FTW, vendor UI and the site meter before sending a non-zero command.
Test charge, discharge, zero, offline/default mode and reconnect. Record
device-specific safety knowledge in the driver next to the code it constrains.