Skip to content
Merged
Show file tree
Hide file tree
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
7 changes: 7 additions & 0 deletions .changeset/calm-driver-source.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"ftw": minor
---

Enforce signed read-only driver permissions in the host and add a safe public driver feedback flow.

Local unsigned drivers remain offline-first and take priority over managed and bundled files. FTW now marks that source in inventory, keeps managed cache state unchanged, and documents the exact test, reload and rollback steps.
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,11 @@ rule. See [docs/architecture.md](docs/architecture.md).
- CalDAV planning intents and published schedules;
- hot-reloadable, independently released Lua drivers.

The local catalog is generated from `DRIVER` metadata. Device Support owns
canonical Sourceful driver versions and signed target artifacts; the
[`drivers/*.lua`](drivers/) tree remains FTW's bundled, offline recovery set.
The local catalog is generated from `DRIVER` metadata. The public
[`srcfl/device-drivers`](https://github.com/srcfl/device-drivers) repo is the
editable source for shared drivers. Device Support builds and signs an exact
public commit. The [`drivers/*.lua`](drivers/) tree remains FTW's bundled,
offline recovery set.

## Install on Linux

Expand Down Expand Up @@ -110,9 +112,10 @@ Drivers are plain Lua files and need no compilation. A driver declares its
catalog metadata, lifecycle and required capabilities in one file. The Go host
provides capability-scoped Modbus, MQTT, HTTP, WebSocket and TCP access.

Start with [docs/writing-a-driver.md](docs/writing-a-driver.md). Signed driver
artifacts are published once by Device Support, selected for FTW's explicit
GopherLua target, and can be installed or rolled back independently.
Start with [docs/writing-a-driver.md](docs/writing-a-driver.md). Send shared
driver changes to `srcfl/device-drivers`. Device Support signs approved target
artifacts, and FTW installs them from `drivers.sourceful.energy`. A driver can
update or roll back without a new FTW core release.

## Releases

Expand Down
14 changes: 9 additions & 5 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ make dispatch unsafe.
| Module | Source | Runtime | Responsibility |
|---|---|---|---|
| Core | `go/cmd/ftw`, `go/internal`, `web` | One Go binary | Configuration, telemetry, state, API/UI, safety, control and fallback planning |
| Drivers | Canonical packages in Device Support; bundled recovery in `drivers/*.lua`; host in `go/internal/drivers` | One sandboxed Lua VM per configured device | Vendor protocol, sign conversion and device commands |
| Drivers | Editable source in `srcfl/device-drivers`; bundled recovery in `drivers/*.lua`; host in `go/internal/drivers` | One sandboxed Lua VM per configured device | Vendor protocol, sign conversion and device commands |
| Optimizer | `optimizer`, contract in `go/internal/mpc` | Optional Python service/process | Solve the long-horizon mathematical plan |

Core can run without the optimizer. Hardware cannot be accessed without a
Expand Down Expand Up @@ -65,8 +65,12 @@ Planner output is an input to that loop, never a direct device command.

## Drivers

Device Support owns canonical Sourceful driver source, SemVer, permissions,
provenance and signed target artifacts. FTW selects only the `ftw-core` target.
The public `srcfl/device-drivers` repo owns editable driver source, versions,
package recipes, target adapters, contracts and tests. Private Device Support
locks an exact public commit, builds it, signs immutable packages and publishes
the indexes served by `drivers.sourceful.energy`. FTW never fetches executable
code from GitHub. It selects only a signed `ftw-core` target.

Each Lua artifact still contains its own `DRIVER` metadata and implements the
FTW lifecycle. `go/internal/drivers/lua.go` is the source of truth for FTW's
host API and capability sandbox. Network and protocol capabilities must be
Expand All @@ -80,8 +84,8 @@ Drivers are the only hardware-specific layer. They must:
- avoid policy decisions that belong in core;
- remain independently testable and hot-editable.

Bundled drivers provide the offline recovery set. A signed Device Support
index is discovery only; FTW independently verifies the selected package and
Bundled drivers provide the offline recovery set. A signed distribution index
is discovery only; FTW independently verifies the selected package and
artifact, while activation remains explicit and atomic. See
[writing-a-driver.md](writing-a-driver.md) and
[device-repository.md](device-repository.md).
Expand Down
61 changes: 37 additions & 24 deletions docs/device-repository.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
# Device Support driver packages
# Driver source and signed packages

`srcful-device-support` is the canonical owner of Sourceful driver source,
SemVer, package metadata and signed target artifacts. FTW is a target consumer:
it accepts only the exact `ftw-core` runtime profile and runs downloaded Lua in
the same capability-scoped sandbox as bundled drivers. Core remains the
activation and safety authority.
`srcfl/device-drivers` is the only editable source for shared driver code,
versions, package recipes, target adapters, contracts and tests. Private Device
Support consumes an exact public commit, builds it and signs release data. It
does not own a second editable driver tree. `drivers.sourceful.energy` is the
runtime distribution point.

FTW accepts only the exact `ftw-core` runtime profile. It never loads code from
GitHub. Core remains the activation and safety authority.

## Resolution and recovery

Expand Down Expand Up @@ -44,7 +47,7 @@ driver is unavailable.

| Format | Role |
|---|---|
| `sourceful.driver-index/v1` | Canonical signed Device Support discovery index and package envelopes |
| `sourceful.driver-index/v1` | Signed discovery index and package envelopes from `drivers.sourceful.energy` |
| `ftw.manifest/v1` | Transitional FTW-owned manifest retained until production cutover |

Use the beta channel only on a chosen pilot site:
Expand All @@ -69,7 +72,10 @@ the same pinned key. Change that default only after stable promotion. A catalog
refresh never installs or activates a driver.

Device Support publishes `beta` packages and promotes the exact reviewed
version and target hashes to `stable`. There is no edge driver channel.
version to `stable` without rebuilding the artifact. Package v1 signs the
channel inside its payload, so stable has a new envelope and signature. The
artifact bytes, hashes, URLs, public source commit, materials and provenance
must stay the same. There is no edge driver channel.

The Device Support index may reference several immutable versions. FTW adapts
the newest compatible version into the Update Center and keeps older compatible
Expand All @@ -90,22 +96,29 @@ host/runtime compatibility and immutable target artifacts. Core verifies:
- HTTPS URLs, safe paths, exact artifact size and SHA-256;
- Lua lifecycle and matching id/version/host API/`read_only` metadata.

Remote Lua is never executed directly from a URL. Unsigned and insecure sources
remain limited to the legacy local-development format. Sourceful index/package
envelopes are always signed. Cached indexes and packages are reverified before
offline discovery.
Remote Lua is never executed directly from a URL. Local unsigned drivers need
an explicit operator overlay and never claim managed or signed status.
Sourceful index/package envelopes are always signed. Cached indexes and
packages are reverified before offline discovery. A signed read-only package
also gets a host policy that denies write calls during init, poll, command,
default mode and cleanup.

The live beta path accepts read-only packages only. Packages with commands,
control capabilities, write permissions or control-enabled FTW targets stay
off. Control also needs per-driver process or heap isolation, default-mode and
lease proof, structured command results, readback, and physical HIL.

Phase 1 accepts read-only packages only. Packages with commands, control
capabilities, write permissions or control-enabled FTW targets fail closed.
That gate is removed only after default-mode invocation, bounded lease expiry,
structured command results and physical control HIL have explicit acceptance.
Before stable control, the trust policy must add key overlap and rotation,
emergency deny lists for keys, packages, versions and artifact hashes, and a
monotonic index policy. An offline host may keep its last verified artifact,
but an old or expired index must not offer a new install or activation.

## Driver versions

Each driver declares public runtime metadata in its `DRIVER` table, while
Device Support owns the canonical package version. Executable or public
metadata changes require a higher package SemVer. FTW refuses artifacts whose
Lua id/version/read-only contract differs from the signed package.
Each driver declares public runtime metadata in its `DRIVER` table. The public
repo owns the package version. Executable or public metadata changes require a
higher package SemVer. FTW refuses artifacts whose Lua id, version or
read-only value differs from the signed package.

```bash
cd go
Expand All @@ -114,7 +127,7 @@ go run ./cmd/ftw-driver-repository check-versions \
```

`go/cmd/ftw-driver-repository` and `.github/workflows/drivers-release.yml` are
legacy migration tooling, not the future canonical publisher. They remain until
the Device Support stable endpoint, trust-root provisioning and recovery
cutover have been exercised. New shared driver releases happen in Device
Support once, not in FTW.
old migration tools, not the shared source or future publisher. New source
changes start in `srcfl/device-drivers`. Device Support releases only an exact,
reviewed public commit. A public commit pin in FTW may serve as a contract test;
it must not make a core release necessary for a driver update.
40 changes: 40 additions & 0 deletions docs/sungrow-observe-pilot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Sungrow observe-only pilot

Do not activate Sungrow 1.3.1 on a live site. It can write even when its target
has `control_enabled=false`. The first site package is 1.3.2 or later and must
stay read-only: `read_only=true`, only `modbus.read`, no commands and no write
calls.

The public target currently blocks every model and firmware pair. Before a
site test, record and review:

- exact inverter model and family: SG-CX, SG-RT, SH-RS, SH-RT or SH-T;
- firmware revision and the matching public register-map revision;
- LAN connection type, port, and Modbus device ID;
- whether a battery and external site meter are present.

Do not put an IP address, serial number, credential or site ID in GitHub issues
or fleet inventory.

Test the local unsigned target first with **Test connection** or
`POST /api/drivers/test`. A Lua file change needs a new test. An active driver
needs a restart or a real config change that restarts the registry entry.

Compare these read-only values with the inverter UI or app and the site meter:

- PV power and MPPT power;
- battery power sign and size, battery voltage, current and SoC;
- grid import/export sign and total power;
- L1, L2 and L3 power, voltage and current;
- inverter temperature, grid frequency and running state.

Stop if the model or firmware does not match an approved public profile, any
sign differs, values are stale, or FTW attempts a write. After a local pass,
the release proof must bind the exact public commit and tree to the private
lock, artifact hash, signed beta index, FTW install and activation, rollback,
and Nova inventory. Keep the bundled Sungrow driver as recovery until the
signed target has parity and physical HIL evidence.

Control stays off until FTW has per-driver process or heap isolation, host
write denial, leases, safe default mode, command results and readback, and the
exact Sungrow model has passed physical HIL.
82 changes: 68 additions & 14 deletions docs/writing-a-driver.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ 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 Sourceful-supported device, create or change the canonical source and
package metadata in `srcful-device-support`. Start from David's hardware-near
Blixt implementation when it is the best protocol reference, then prove an
explicit `ftw-core` target against this host. The `drivers/` tree here is the
bundled FTW recovery snapshot; operator-only drivers may still live locally.
For a shared device, create or change the source and package recipe in the
public [`srcfl/device-drivers`](https://github.com/srcfl/device-drivers) repo.
Prove an explicit `ftw-core` target against this host. Private Device Support
builds and signs an exact reviewed public commit. The `drivers/` tree here is
the bundled FTW recovery snapshot; operator-only drivers may still live
locally.

## Metadata

Expand All @@ -32,9 +33,9 @@ DRIVER = {
```

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 Device
Support package. Do not duplicate the catalog in Markdown. Executable or
public metadata changes require one canonical package version bump.
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

Expand Down Expand Up @@ -114,7 +115,7 @@ not belong in structured meter/PV/battery/EV telemetry.

## Implementation sequence

1. Add or update the canonical driver and package source in Device Support.
1. Add or update the driver and package recipe 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.
Expand All @@ -141,9 +142,62 @@ device-specific safety knowledge in the driver next to the code it constrains.
## Local overrides and release

Custom drivers belong in the persistent user-driver directory, not inside a
container layer. Managed drivers are signed, installed atomically and
rollbackable; see [device-repository.md](device-repository.md).
container layer:

Device Support publishes new driver packages to `beta` first. Promote the same
reviewed source, target hashes and version to `stable` only after hardware
validation. FTW does not fork or independently renumber that release.
- 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:

```yaml
drivers:
- name: example
lua: drivers/example.lua
capabilities:
modbus:
host: 192.168.1.20
port: 502
unit_id: 1
```

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, Device Support or `drivers.sourceful.energy`.
It gets no auto-update or promotion and cannot claim signed package control.
The normal host capabilities and lifecycle still apply.

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.

```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
`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
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:

```bash
make test-driver ID=example
make package-driver ID=example TARGET=ftw-core
make check
```

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

Device Support publishes new driver packages to `beta` first. Promote without
rebuilding after hardware validation: package v1 changes the signed channel,
but keeps the exact artifact bytes, hashes, public source commit and materials.
FTW does not fork or independently renumber that release.
12 changes: 6 additions & 6 deletions go/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -1134,13 +1134,13 @@ func Parse(data []byte, baseDir string) (*Config, error) {
// Empty string preserves the historical "sibling-of-config" behaviour.
var DriversDirOverride string

// UserDriversDirOverride is the second lookup path tried before
// DriversDirOverride. Designed for persistent user-supplied drivers in
// UserDriversDirOverride is the first lookup path. It is tried before managed
// signed drivers and DriversDirOverride. It holds persistent user drivers in
// the docker deploy where DriversDirOverride lives in the immutable
// image layer. When set, ResolveDriverPaths checks whether a file
// exists in this directory first and uses it when found; otherwise
// falls back to DriversDirOverride. Empty = single-dir behaviour
// (back-compat).
// falls back to the managed and bundled directories. Empty = single-dir
// behaviour (back-compat).
var UserDriversDirOverride string

// ManagedDriversDirOverride contains stable active symlinks maintained by the
Expand All @@ -1151,8 +1151,8 @@ var ManagedDriversDirOverride string
// ResolveDriverPaths joins relative Lua driver paths with baseDir, or
// with DriversDirOverride when the relative path starts with "drivers/".
// When UserDriversDirOverride is also set, paths starting with "drivers/"
// are first probed in UserDriversDirOverride; only if the file is absent
// there do they fall through to DriversDirOverride.
// are first probed there. They then fall through to the managed directory
// and DriversDirOverride.
func (c *Config) ResolveDriverPaths(baseDir string) {
for i := range c.Drivers {
c.Drivers[i].Lua = stripLeadingDotDot(c.Drivers[i].Lua)
Expand Down
8 changes: 4 additions & 4 deletions go/internal/driverinventory/inventory.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ type Snapshot struct {
}

// RepositoryArtifact describes one activated repository file. PackageID and
// Channel are set only when a signed canonical package supplied them.
// Channel are set only when a signed package supplied them.
type RepositoryArtifact struct {
LogicalPath string
InstalledPath string
Expand Down Expand Up @@ -199,16 +199,16 @@ func inspectDriver(path string, in Input, artifacts map[string]RepositoryArtifac
return row, nil
}
// A missing or changed activation must not claim signed provenance.
row.Source = "local_override"
row.Source = "local"
row.SourceSHA256 = loadedSHA
return row, nil
}
if within(path, in.UserDriverDir) {
row.Source = "local_override"
row.Source = "local"
} else if within(path, in.BundledDriverDir) {
row.Source = "bundled"
} else {
row.Source = "local_override"
row.Source = "local"
}
row.SourceSHA256 = loadedSHA
return row, nil
Expand Down
Loading