From 6d51ace93dcc9db73acaf9062494b802205b6750 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 15 Apr 2026 08:34:18 +0200 Subject: [PATCH] docs: Lua-first driver authoring guide Replace lua-drivers.md with writing-a-driver.md as the canonical driver- authoring path for v2.1+. The new guide leads with why Lua beat WASM (contributor-friendly, hot-editable, smaller host surface), documents the DRIVER metadata table plus the five lifecycle functions, enumerates every host.* function against go/internal/drivers/lua.go, and spells out the site sign convention at the driver boundary. - Add docs/writing-a-driver.md (the new canonical guide). - Reduce docs/lua-drivers.md to a redirect stub. - Add a legacy banner to docs/host-api.md pointing at the new guide. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/host-api.md | 3 + docs/lua-drivers.md | 450 +-------------------------------------- docs/writing-a-driver.md | 372 ++++++++++++++++++++++++++++++++ 3 files changed, 376 insertions(+), 449 deletions(-) create mode 100644 docs/writing-a-driver.md diff --git a/docs/host-api.md b/docs/host-api.md index f689fcfa3..bcbeb20bb 100644 --- a/docs/host-api.md +++ b/docs/host-api.md @@ -1,5 +1,8 @@ # Host API Reference +> Legacy WASM ABI reference. For the current Lua driver path, see +> [writing-a-driver.md](writing-a-driver.md). + The `host` table is available to all Lua drivers. It provides functions for logging, device communication, data decoding, JSON handling, and telemetry emission. These are the same APIs available on the Sourceful Zap gateway, so drivers are portable between forty-two-watts and the Zap. ## Core diff --git a/docs/lua-drivers.md b/docs/lua-drivers.md index 4beab66c2..b698e5b60 100644 --- a/docs/lua-drivers.md +++ b/docs/lua-drivers.md @@ -1,449 +1 @@ -# Lua Driver Authoring Guide - -## Overview - -A Lua driver is a self-contained script that communicates with a single physical device (inverter, battery, meter) and translates its proprietary protocol into the standardized telemetry format that forty-two-watts understands. Drivers are the same scripts that run on the Sourceful Zap gateway -- they use the same `host.*` API and follow the same contract, so a driver written for forty-two-watts works on the Zap and vice versa. - -Each driver runs in its own OS thread inside an isolated Lua 5.4 sandbox. The runtime automatically creates protocol connections (Modbus TCP, MQTT) based on the YAML config, so the driver code never deals with sockets or connection management directly. - -``` -+-----------+ +-----------+ +------------------+ -| Physical | | Lua | | forty-two-watts | -| Device |<--->| Driver |---->| TelemetryStore | -| (inverter)| | (sandbox) | | (shared state) | -+-----------+ +-----------+ +------------------+ - | | - host.emit() control loop - host.modbus_* computes targets - host.mqtt_* calls driver_command() -``` - -## Driver Contract - -Every driver must declare one global variable and implement a set of lifecycle functions. - -### Required Global - -#### `PROTOCOL` (string) - -Tells the runtime which transport client to create before `driver_init()` is called. - -| Value | Transport Created | -|----------------|--------------------------------------------------| -| `"modbus"` | Modbus TCP connection (host, port, unit_id) | -| `"mqtt"` | MQTT 3.1.1 client (host, port, credentials) | -| `"http"` | HTTP client for local REST/RPC APIs | -| `"serial"` / `"p1"` | Serial port (P1/HAN smart meter telegrams) | -| `"standalone"` | No client (test/demo drivers) | - -### Required Functions - -#### `driver_init(config)` - -Called once after the Lua VM is created and the protocol client is connected. - -**Parameters:** -- `config` -- JSON string with connection details. Parse it with `host.json_decode(config)`. Contains: - -| Key | Type | Description | -|----------------------|---------|------------------------------------------| -| `name` | string | Driver name from YAML config | -| `protocol` | string | `"modbus"` or `"mqtt"` | -| `is_site_meter` | boolean | Whether this driver provides the site meter | -| `battery_capacity_wh`| number | Configured battery capacity in Wh | -| `ip` / `port` | string/number | Modbus host and port (if modbus) | -| `unit_id` | number | Modbus slave ID (if modbus) | -| `mqtt_host` / `mqtt_port` | string/number | MQTT broker (if mqtt) | - -**Typical usage:** - -```lua -function driver_init(config) - host.set_make("MyBrand") - -- MQTT drivers subscribe to topics here: - host.mqtt_subscribe("device/data/#") -end -``` - -#### `driver_poll()` - -Called repeatedly by the runtime. This is where you read data from the device and emit telemetry. - -**Must:** -1. Read data from the device (via `host.modbus_read`, `host.mqtt_messages`, etc.) -2. Emit telemetry via `host.emit("meter", data)`, `host.emit("pv", data)`, `host.emit("battery", data)` -3. Return the desired poll interval in milliseconds - -**Return value:** -- Number > 0: next poll interval in ms (capped to 100ms minimum, 60s maximum) -- `nil` or 0: use default interval (1000ms) - -**Error handling:** Wrap device reads in `pcall()` so a single failed register read does not crash the entire poll cycle. - -```lua -function driver_poll() - local ok, regs = pcall(host.modbus_read, 100, 10, "input") - if not ok or not regs then return 5000 end - - host.emit("meter", { w = host.decode_i32(regs[1], regs[2]) }) - return 5000 -end -``` - -#### `driver_cleanup()` - -Called when the driver is stopped (shutdown or reload). Release any resources, clear cached state. - -```lua -function driver_cleanup() - cached_data = nil -end -``` - -### Optional Functions (Control) - -#### `driver_command(action, power_w, cmd)` - -Called when the EMS control loop dispatches a battery command. - -**Parameters:** -- `action` (string) -- one of: - - `"init"` -- EMS is taking control (acknowledge and return `true`) - - `"battery"` -- set battery charge/discharge target - - `"curtail"` -- limit PV output - - `"curtail_disable"` -- remove PV curtailment - - `"deinit"` -- EMS is releasing control (revert to autonomous) -- `power_w` (number) -- target power in watts. Signed: - - Positive = charge the battery - - Negative = discharge the battery -- `cmd` (string) -- JSON with metadata: `{"id": "ems"}` - -**Return:** `true` on success, `false` on failure. - -```lua -function driver_command(action, power_w, cmd) - if action == "init" then - return true - elseif action == "battery" then - if power_w > 0 then - -- Set charge mode and power limit - host.modbus_write(REG_MODE, MODE_CHARGE) - host.modbus_write(REG_CHARGE_LIMIT, math.floor(power_w)) - elseif power_w < 0 then - -- Set discharge mode and power limit - host.modbus_write(REG_MODE, MODE_DISCHARGE) - host.modbus_write(REG_DISCHARGE_LIMIT, math.floor(math.abs(power_w))) - else - host.modbus_write(REG_MODE, MODE_AUTO) - end - return true - elseif action == "deinit" then - host.modbus_write(REG_MODE, MODE_AUTO) - return true - end - return false -end -``` - -#### `driver_default_mode()` - -Called when the EMS heartbeat watchdog expires (default 60 seconds without a successful control cycle). The driver should revert the device to safe autonomous operation. - -```lua -function driver_default_mode() - host.modbus_write(REG_MODE, MODE_AUTO) -end -``` - -## Sign Conventions - -Consistent sign conventions are critical. All drivers must follow these rules: - -| DER Type | Field | Positive (+) | Negative (-) | -|----------|-------|----------------------|----------------------| -| Meter | `w` | Importing from grid | Exporting to grid | -| PV | `w` | (never positive) | Generating power | -| Battery | `w` | Charging | Discharging | - -**Key points:** -- PV `w` is always negative or zero. If your device reports PV as a positive number, negate it before emitting. -- Battery `w` positive means the battery is consuming power (charging). Negative means it is producing power (discharging). If your device uses the opposite convention, negate accordingly. -- Meter `w` positive means the site is consuming from the grid (importing). Negative means the site is feeding into the grid (exporting). - -## Telemetry Field Reference - -### Meter - -Emitted via `host.emit("meter", data)`. - -| Field | Unit | Description | -|-------------|------|----------------------------------------| -| `w` | W | Total grid power (+import / -export) | -| `hz` | Hz | Grid frequency | -| `l1_w` | W | Phase 1 power | -| `l2_w` | W | Phase 2 power | -| `l3_w` | W | Phase 3 power | -| `l1_v` | V | Phase 1 voltage | -| `l2_v` | V | Phase 2 voltage | -| `l3_v` | V | Phase 3 voltage | -| `l1_a` | A | Phase 1 current | -| `l2_a` | A | Phase 2 current | -| `l3_a` | A | Phase 3 current | -| `import_wh` | Wh | Lifetime energy imported from grid | -| `export_wh` | Wh | Lifetime energy exported to grid | - -### PV - -Emitted via `host.emit("pv", data)`. - -| Field | Unit | Description | -|----------------|------|--------------------------------------| -| `w` | W | Total PV power (always negative) | -| `rated_w` | W | Rated/nameplate capacity | -| `mppt1_v` | V | MPPT 1 voltage | -| `mppt1_a` | A | MPPT 1 current | -| `mppt2_v` | V | MPPT 2 voltage | -| `mppt2_a` | A | MPPT 2 current | -| `temp_c` | C | Inverter/heatsink temperature | -| `lifetime_wh` | Wh | Lifetime PV energy generated | -| `lower_limit_w`| W | Minimum curtailment limit | -| `upper_limit_w`| W | Maximum curtailment limit | - -### Battery - -Emitted via `host.emit("battery", data)`. - -| Field | Unit | Description | -|----------------|----------|-------------------------------------| -| `w` | W | Battery power (+charge / -discharge)| -| `v` | V | Battery voltage | -| `a` | A | Battery current | -| `soc` | fraction | State of charge, 0.0 to 1.0 | -| `temp_c` | C | Battery temperature | -| `charge_wh` | Wh | Lifetime energy charged | -| `discharge_wh` | Wh | Lifetime energy discharged | -| `upper_limit_w`| W | Max charge power limit | -| `lower_limit_w`| W | Max discharge power limit | - -**SoC note:** The `soc` field must be a fraction between 0.0 and 1.0 (not a percentage). If your device reports 0-100%, divide by 100. - -## How to Add a New Device Driver - -### Step 1: Create the Lua file - -Create `drivers/mydevice.lua` in the project root. - -### Step 2: Declare the protocol - -```lua -PROTOCOL = "modbus" -- or "mqtt" -``` - -### Step 3: Implement driver_init - -```lua -function driver_init(config) - host.set_make("MyDevice") - -- For MQTT: subscribe to topics - -- For Modbus: nothing needed, connection is automatic -end -``` - -### Step 4: Implement driver_poll - -Read device data, convert to standard units, apply sign conventions, and emit telemetry. - -### Step 5: Implement driver_command (if the device supports control) - -Handle `"init"`, `"battery"`, `"curtail"`, `"curtail_disable"`, and `"deinit"` actions. - -### Step 6: Implement driver_default_mode - -Revert the device to autonomous/auto mode. - -### Step 7: Implement driver_cleanup - -Clear any cached state. - -### Step 8: Add config entry - -Add the driver to `config.yaml`: - -```yaml -drivers: - - name: mydevice - lua: drivers/mydevice.lua - battery_capacity_wh: 10000 - modbus: - host: 192.168.1.50 - port: 502 - unit_id: 1 -``` - -If this device provides the site grid meter, add `is_site_meter: true`. - -### Step 9: Test - -Run forty-two-watts and check the logs: - -```bash -RUST_LOG=debug cargo run -- -c config.yaml -``` - -Verify telemetry appears in the API: - -```bash -curl http://localhost:8080/api/status | jq . -``` - -## Minimal Driver Template - -### Modbus Driver - -```lua --- mydevice.lua --- MyDevice Inverter Driver (Modbus TCP) --- Emits: PV, Battery, Meter - -PROTOCOL = "modbus" - -function driver_init(config) - host.set_make("MyDevice") -end - -function driver_poll() - -- Read grid power (example: register 100, signed 32-bit, 2 regs) - local ok, regs = pcall(host.modbus_read, 100, 2, "input") - if ok and regs then - local grid_w = host.decode_i32(regs[1], regs[2]) - host.emit("meter", { w = grid_w }) - end - - -- Read PV power (example: register 200, unsigned 16-bit) - local ok2, pv_regs = pcall(host.modbus_read, 200, 1, "input") - if ok2 and pv_regs then - local pv_w = pv_regs[1] - host.emit("pv", { w = -pv_w }) -- negate: generation is negative - end - - -- Read battery (example: registers 300-303) - local ok3, bat_regs = pcall(host.modbus_read, 300, 4, "input") - if ok3 and bat_regs then - local bat_w = host.decode_i16(bat_regs[1]) -- already signed - local bat_soc = bat_regs[2] * 0.1 / 100 -- percent to fraction - host.emit("battery", { w = bat_w, soc = bat_soc }) - end - - return 5000 -- poll every 5 seconds -end - -function driver_command(action, power_w, cmd) - if action == "init" then return true end - if action == "battery" then - -- Write charge/discharge target to device - -- (implement device-specific register writes here) - return true - end - if action == "deinit" then - -- Revert to auto mode - return true - end - return false -end - -function driver_default_mode() - -- Revert to autonomous operation -end - -function driver_cleanup() -end -``` - -### MQTT Driver - -```lua --- mydevice_mqtt.lua --- MyDevice MQTT Driver --- Emits: PV, Battery, Meter - -PROTOCOL = "mqtt" - -local cached_data = nil - -function driver_init(config) - host.set_make("MyDevice") - host.mqtt_subscribe("mydevice/telemetry/#") -end - -function driver_poll() - local messages = host.mqtt_messages() - if not messages then return 1000 end - - for _, msg in ipairs(messages) do - local ok, data = pcall(host.json_decode, msg.payload) - if ok and data then - cached_data = data - end - end - - if cached_data then - host.emit("meter", { - w = cached_data.grid_power or 0, - }) - host.emit("pv", { - w = -(cached_data.pv_power or 0), - }) - host.emit("battery", { - w = cached_data.battery_power or 0, - soc = (cached_data.battery_soc or 0) / 100, - }) - end - - return 1000 -- poll every 1 second (MQTT is event-driven) -end - -function driver_command(action, power_w, cmd) - if action == "init" then return true end - if action == "battery" then - local payload = string.format('{"power":%d}', math.floor(power_w)) - return host.mqtt_publish("mydevice/control/battery", payload) - end - if action == "deinit" then - return host.mqtt_publish("mydevice/control/mode", '{"mode":"auto"}') - end - return false -end - -function driver_default_mode() - host.mqtt_publish("mydevice/control/mode", '{"mode":"auto"}') -end - -function driver_cleanup() - cached_data = nil -end -``` - -## Testing Tips - -1. **Use `pcall` everywhere.** Every `host.modbus_read` or `host.json_decode` call should be wrapped in `pcall()`. A single communication error should not crash the entire poll cycle. - -2. **Log liberally during development.** Use `host.log("debug", "register 100 = " .. tostring(val))` to trace values. Set `RUST_LOG=lua=debug` to see driver log output. - -3. **Check the REST API.** After starting forty-two-watts, `curl http://localhost:8080/api/status` shows live telemetry for all drivers. This is the fastest way to verify your driver emits correct values. - -4. **Watch sign conventions.** The most common bug is wrong signs. Check that: - - Meter `w` is positive when importing, negative when exporting - - PV `w` is always negative (generation) - - Battery `w` is positive when charging, negative when discharging - -5. **Handle SoC as a fraction.** Many devices report SoC as 0-100 (percent). Divide by 100 before emitting so the value is 0.0-1.0. - -6. **Use decode helpers.** Do not manually combine register bytes. Use `host.decode_u32()`, `host.decode_i32()`, `host.decode_f32()`, etc. They handle byte order and signedness correctly. - -7. **Test the watchdog.** Stop your device (or block the network) and verify that `driver_default_mode()` fires after the configured timeout (default 60s). - -8. **Memory limit.** Each driver VM has a 4 MB memory limit. Avoid accumulating large tables across polls -- clear caches in `driver_cleanup()` and keep only the latest data. - -9. **Instruction limit.** Each `driver_poll()` call is limited to 50 million Lua instructions. This prevents infinite loops from locking up the system. If your driver hits this limit, optimize or split the work across multiple polls. - -10. **Exponential backoff.** The runtime automatically applies exponential backoff on consecutive poll errors (up to 60s). You do not need to implement retry logic yourself -- just return an error and the runtime handles it. +Moved to [writing-a-driver.md](writing-a-driver.md). diff --git a/docs/writing-a-driver.md b/docs/writing-a-driver.md new file mode 100644 index 000000000..997f60864 --- /dev/null +++ b/docs/writing-a-driver.md @@ -0,0 +1,372 @@ +# Writing a Lua driver + +A driver is a single Lua file that translates one physical device (inverter, +battery, meter, gateway) into the EMS's unified telemetry and control +vocabulary. This guide is the authoritative path for new drivers on v2.1+. + +Reference implementations to copy from: + +- `drivers/sungrow.lua` — full Modbus TCP example with SN read, battery + control, curtailment, watchdog fallback. +- `drivers/ferroamp.lua` — full MQTT example with cached topic state, + multi-topic enrichment, JSON command payloads. + +The host side lives in `go/internal/drivers/lua.go` (every `host.*` +function), `go/internal/drivers/host.go` (the env that backs them), and +`go/internal/drivers/registry.go` (spawn / poll / command dispatch). + +## 1. Why Lua + +v2.1 switched the recommended driver path from Rust-compiled WASM to Lua +via [gopher-lua](https://github.com/yuin/gopher-lua). The reasons, in +order of how much they matter: + +- **Contributor-friendly**: no toolchain to install. Edit a `.lua` file + and restart — no `cargo`, no `wasm-opt`, no cross-compile. +- **Hot-editable on the device**: the operator can `ssh` into a Zap, tweak + a register offset, restart, done. This is load-bearing for field work. +- **Smaller host surface**: the Lua host is ~350 LOC in one file + (`go/internal/drivers/lua.go`) vs ~850 for the WASM runtime plus the + Rust scaffolding a WASM driver needs. Less code to trust. +- **Good enough performance**: a poll at 1 Hz that reads a dozen Modbus + registers and emits three telemetry tables is nowhere near the Lua VM + budget. The EMS's hot loop is the controller, not the driver. + +WASM is still supported through the same `Registry` interface +(`registry.go:137-153`) for performance-critical drivers or for anyone +who already shipped a `.wasm` on v2.0. You pick per-driver in +`config.yaml` with `lua:` or `wasm:`. + +## 2. The contract + +Every Lua driver lives in `drivers/` and defines one metadata table plus +five top-level functions. The runtime calls them in this order: + +``` +NewLuaDriver(path) → file loaded, top-level runs once (DRIVER table populated) +driver_init(cfg) → one-shot setup (subscribe, read SN, verify limits) +driver_poll() → called forever; returns next-poll-ms +driver_command(…) → on each EMS control tick +driver_default_mode() → watchdog fallback if EMS goes offline +driver_cleanup() → on shutdown / reload +``` + +### 2.1 The DRIVER metadata table + +The `DRIVER` global is parsed by the catalog loader +(`go/internal/drivers/catalog.go`) via regex — no Lua VM is spun up. That +means the metadata is readable even from a broken driver, and it's what +populates the Settings → Devices "Add from catalog" dropdown. + +```lua +DRIVER = { + id = "my-device", + name = "My Device Brand X", + manufacturer = "BrandCo", + version = "1.0.0", + protocols = { "modbus" }, -- or { "mqtt" } or both + capabilities = { "meter", "pv", "battery" }, + description = "Short blurb describing the device.", + homepage = "https://example.com", + authors = { "Your Name" }, + tested_models = { "BX-5000", "BX-10000" }, +} +``` + +Keep it well-formed: the regex in `catalog.go:95` requires a +`DRIVER = { … \n}` block with plain `key = "value"` string fields and +`key = { "a", "b" }` lists. If the regex can't pick up your metadata, +your driver still runs — it just won't show up in the UI. + +### 2.2 The lifecycle functions + +```lua +-- One-time setup. Subscribe to MQTT topics, read SN over Modbus, etc. +function driver_init(config) + host.set_make("BrandCo") + -- ... read serial number, log ... + -- host.set_sn(serial) +end + +-- Called repeatedly. Return the next-poll-interval in milliseconds. +-- Cadence ≈ 1 Hz is plenty for home batteries. +function driver_poll() + -- Pull MQTT messages OR read Modbus registers + -- Emit telemetry: host.emit("meter"|"pv"|"battery", { … }) + return 5000 -- ms until next call +end + +-- Receive a control command from the EMS. +-- action = "init" | "battery" | "curtail" | "curtail_disable" | "deinit" +-- power_w = positive = charge, negative = discharge, 0 = revert to self-consumption +-- cmd = full decoded command table (extra fields, if any) +function driver_command(action, power_w, cmd) + if action == "battery" then + return set_battery_power(power_w) + end + return false +end + +-- Watchdog fallback when the EMS goes offline. ALWAYS revert to a safe +-- autonomous state so the device doesn't get stuck in a forced mode. +function driver_default_mode() + set_self_consumption() +end + +-- Cleanup at shutdown / reload. Clear cached tables, close anything you own. +function driver_cleanup() + set_self_consumption() +end +``` + +All five are optional — a missing function is a no-op (see +`lua.go:80`, `95`, `119`, `154`). But a real driver defines all of them: +you cannot participate in EMS control without `driver_command`, and +skipping `driver_default_mode` means your device stays in forced mode if +the EMS crashes. + +Dispatch is serialized per driver (`registry.go:188`), so you will never +see two callbacks racing for the same VM. + +## 3. The host API + +Everything below is exposed as a `host.*` global. The authoritative list +is `go/internal/drivers/lua.go` lines 163-403 — grep for `RawSetString` +to see every entry. The function signatures here match that file exactly. + +### 3.1 Logging and identity + +| Call | Purpose | +|---|---| +| `host.log(level, message)` | `level = "debug" \| "info" \| "warn" \| "error"`. Routed to `slog` (`host.go:88`). | +| `host.set_make(name)` | Manufacturer string. Feeds device_id resolution. | +| `host.set_sn(serial)` | Serial number. Promotes device_id to canonical `make:serial`. | +| `host.set_poll_interval(ms)` | Request a different poll cadence. Same effect as returning `ms` from `driver_poll`. | +| `host.millis()` | Monotonic milliseconds since host startup (`host.go:83`). | + +Call `set_make` and `set_sn` as early as you reliably can — before them, +the device shows up under its config name only. + +### 3.2 Telemetry + +The shared telemetry shape is a JSON-flat table keyed on `type`. Extra +fields beyond the standard ones are preserved verbatim in the reading's +`Data` payload for the UI and API (`host.go:117`). + +```lua +host.emit("meter", { + w = 1500, -- required. Positive = importing, negative = exporting. + l1_w = 500, -- per-phase power (W) + l1_v = 230.1, -- per-phase voltage (V) + hz = 50.01, -- grid frequency + import_wh = 12345.6, -- lifetime counter + export_wh = 7890.1, +}) + +host.emit("pv", { + w = -3200, -- required. ALWAYS negative (generation flows out of the array). + mppt1_v = 380.5, + lifetime_wh = 1234567, + rated_w = 6000, +}) + +host.emit("battery", { + w = 2000, -- required. Positive = charging (energy INTO battery), + -- negative = discharging. + soc = 0.65, -- 0.0 to 1.0 fraction (NOT percent) + v = 48.2, + a = 41.5, + charge_wh = 98765, + discharge_wh = 54321, +}) +``` + +For anything that doesn't fit the pv/battery/meter shape — temperatures, +DC voltages, MPPT currents, grid frequency — use: + +```lua +host.emit_metric("inverter_temp_c", 42.3) +host.emit_metric("battery_dc_v", 48.7) +host.emit_metric("grid_hz", 50.01) +``` + +Naming convention: snake_case with a unit suffix. These land in the +long-format TSDB where the UI charts them on demand. There's no +allow-list — pick a stable name and keep using it. + +### 3.3 MQTT (granted only if the driver has `mqtt:` in its config) + +| Call | Purpose | +|---|---| +| `host.mqtt_subscribe(topic)` | Subscribe to one topic. Call from `driver_init`. | +| `host.mqtt_publish(topic, payload)` | Publish a string payload. | +| `host.mqtt_messages()` | Return the array of `{topic, payload}` received since the last call, then clear the buffer. | + +`mqtt_sub` and `mqtt_pub` exist as aliases (`lua.go:252, 268`). If the +driver has no MQTT capability, these calls return an error string. See +`drivers/ferroamp.lua:96-115` for a working init + poll loop. + +### 3.4 Modbus (granted only if the driver has `modbus:` in its config) + +| Call | Purpose | +|---|---| +| `host.modbus_read(addr, count, kind)` | `kind = "input" \| "holding" \| "coil" \| "discrete"`. Returns a 1-indexed Lua table of uint16s. | +| `host.modbus_write(addr, value)` | Single holding-register write (FC 0x06). | +| `host.modbus_write_multi(addr, values)` | Multi-register write (FC 0x10). `values` is a Lua array of uint16s. | + +Always wrap reads in `pcall` — a single failed register read should not +crash the whole poll cycle. See `drivers/sungrow.lua:127-129` for the +pattern. + +### 3.5 Decoders + +Modbus returns raw uint16s. These helpers combine pairs back into the +integer you actually want. Parameter order matters — `_le` takes +`(lo, hi)`, `_be` takes `(hi, lo)`. + +| Call | Returns | +|---|---| +| `host.decode_u32_le(lo, hi)` | Unsigned 32-bit, little-endian word order (Sungrow's habit). | +| `host.decode_u32_be(hi, lo)` | Unsigned 32-bit, big-endian. | +| `host.decode_i32_le(lo, hi)` | Signed 32-bit, little-endian. | +| `host.decode_i32_be(hi, lo)` | Signed 32-bit, big-endian. | +| `host.decode_i16(reg)` | Sign-extend a single uint16 to int16. | + +### 3.6 JSON + +| Call | Purpose | +|---|---| +| `host.json_decode(str)` | Returns a Lua table on success, `nil, err_msg` on failure. | +| `host.json_encode(tbl)` | Returns a JSON string. | + +## 4. Sign convention + +This is the single most important rule in driver-land. Emit every value +in **site convention**; the EMS, battery models, controller, UI, and +API all assume it. + +| Channel | Positive | Negative | +|---|---|---| +| `meter.w` | importing from grid | exporting to grid | +| `pv.w` | (never — always ≤ 0) | generating | +| `battery.w` | charging (energy INTO battery) | discharging (energy OUT of battery) | + +Drivers convert at the boundary. If your device reports PV as a positive +number (almost all do), negate it before `host.emit`. If your device +encodes battery direction in a status register (Sungrow does — +`drivers/sungrow.lua:127-130, 207-209`), decode direction first, then +apply sign, then emit. + +Above the driver layer, everything is in site convention. Full rationale +in `docs/site-convention.md`. + +## 5. Step-by-step: adding a new device + +1. Copy `drivers/sungrow.lua` (Modbus) or `drivers/ferroamp.lua` (MQTT) + to `drivers/my-device.lua`. +2. Update the `DRIVER` metadata table: `id`, `name`, `manufacturer`, + `version`, `protocols`, `capabilities`, `description`, `homepage`, + `authors`, `tested_models`. +3. Replace the protocol code (Modbus register reads or MQTT topic subs) + with your device's spec. Convert signs at the boundary. +4. Test in isolation: + + ```bash + cd /Users/fredde/repositories/forty-two-watts/go + go test -count=1 -run TestLuaDriverLifecycle ./internal/drivers/ + ``` + +5. Wire the driver into `config.yaml`: + + ```yaml + drivers: + - name: my-device + lua: drivers/my-device.lua + is_site_meter: false + battery_capacity_wh: 10000 + modbus: + host: 192.168.1.50 + port: 502 + unit_id: 1 + ``` + + For an MQTT driver, swap `modbus:` for `mqtt:` with `host`, `port`, + `username`, `password`. + +6. Restart the service (or wait for `fsnotify` to hot-reload + `config.yaml`). +7. Open Settings → Devices in the UI and confirm your driver appears + with the correct `device_id`. + +## 6. Common pitfalls + +- **Forgetting the sign flip.** PV inverters report generation as + positive; the EMS expects negative. Quick check: + `curl localhost:8080/api/status` — `pv_w` should be negative when the + sun is up. +- **Blocking `driver_poll`.** The runtime calls poll on a single + goroutine per driver. Never sleep or block on long IO. Read, emit, + return the next-poll-ms. +- **Updating config without a restart.** `fsnotify` catches writes to + `config.yaml`, but a syntax error means the reload silently fails — + check the service log after editing. +- **Ignoring `driver_default_mode`.** If you don't revert to autonomous + in this function, your device will get stuck in the last-commanded + forced mode if the EMS crashes. Always revert. +- **Emitting telemetry too often.** Poll cadence ≈ 1 Hz is plenty. + Faster doesn't help control quality and saturates Modbus. The EMS + control loop runs at `control_interval_s` (default 5 s) — your + driver doesn't need to outrun it. +- **Using `soc` as a percent.** The EMS expects `soc` as a 0.0–1.0 + fraction. If the device reports 0–100, divide. + +## 7. Testing your driver + +Lua syntax + ABI check: + +```bash +cd /Users/fredde/repositories/forty-two-watts/go +go test -count=1 -run TestLuaDriverLifecycle ./internal/drivers/ +``` + +The harness in `lua_test.go` spins up a real `LuaDriver`, calls `Init`, +`Poll`, and `Command` three times, and asserts that telemetry and +identity flow correctly. If you want to add assertions for your new +driver, follow that pattern. + +Live with the simulators: + +```bash +make run-sim # starts sim-ferroamp + sim-sungrow +# In another terminal: +make dev # starts main app +``` + +After deploying to a real device: + +```bash +curl localhost:8080/api/status +curl localhost:8080/api/drivers/catalog +curl localhost:8080/api/series/catalog +``` + +## 8. Driver catalog + +The `DRIVER` metadata table is parsed by +`go/internal/drivers/catalog.go:LoadCatalog` with a regex — no Lua VM +is spun up. The `GET /api/drivers/catalog` endpoint returns the parsed +entries. Settings → Devices uses this for the "Add from catalog" +dropdown. + +If the regex can't extract your metadata, the driver still runs but +won't appear in the catalog. Keep the block plain: + +```lua +DRIVER = { + id = "value", + list = { "a", "b" }, +} +``` + +No function calls, no concatenation, no variables — the catalog loader +reads the source as text.