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