-
Notifications
You must be signed in to change notification settings - Fork 8
docs: make the driver page a host reference, not a second authoring guide #727
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. | ||
|
|
||
| `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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a driver that intentionally needs no external I/O capability, this exhaustive list hides the valid Useful? React with 👍 / 👎. |
||
|
|
||
| ```yaml | ||
| drivers: | ||
|
|
@@ -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. | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When authors follow the Blixt naming reference,
writeandwrite_registersare the canonical names, whilemodbus_writeandmodbus_write_multiare the older FTW spellings retained for compatibility;TestCanonicalHostFunctionAliasesExistand 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 👍 / 👎.