From 4e3d4a5f93370eda7ae228d25a2a004ecf7d62d5 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 30 Jul 2026 16:09:30 +0200 Subject: [PATCH] docs: make the driver page a host reference, not a second authoring guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FTW and srcfl/device-drivers both carried a "how to write a driver" guide, 220 and 268 lines. They overlap on the entry points, the DRIVER block, the sign convention and the build-and-test loop, and there is no mechanism that keeps them agreeing. The upstream copy is built around blueprint/BLUEPRINT.lua and is the one a driver author reaches for, so the FTW copy is the one that quietly goes stale — and #720 is currently moving the website's "Write a driver" link upstream, which makes that worse. Split by what each repository actually owns. Authoring moves upstream, and this page keeps what only FTW can answer: what its host registers, how a capability is granted, when the host calls driver_default_mode, where FTW resolves a driver file from, and how to test one against a running instance. The host API is now a table generated from what lua.go registers, including http_patch and its allow_write gate from #716. Every name in it was checked against host.RawSetString in lua.go; the two Blixt L1 aliases stay in the prose below the table rather than the table itself. Removed content is covered elsewhere: entry points, sign convention, manifest and make targets upstream, and the drivers-beta promotion paragraph was already a duplicate of device-repository.md, which this page links to. README is untouched — #720 is editing the same section and has right of way. Co-authored-by: Claude Opus 5 --- docs/writing-a-driver.md | 255 +++++++++++++++------------------------ 1 file changed, 97 insertions(+), 158 deletions(-) diff --git a/docs/writing-a-driver.md b/docs/writing-a-driver.md index 7a2628b3..5b07c194 100644 --- a/docs/writing-a-driver.md +++ b/docs/writing-a-driver.md @@ -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. + +`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`: ```yaml drivers: @@ -113,84 +71,66 @@ 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 `. -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 \ @@ -198,23 +138,22 @@ curl -sS -X POST http://127.0.0.1:8080/api/drivers/test \ -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.