Skip to content
33 changes: 33 additions & 0 deletions .changeset/driver-control-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"ftw": minor
---

An operator can now send a driver's declared command and hold it for a bounded
time. `POST /api/drivers/{name}/control` takes `{control, value, duration_s}`;
`DELETE` on the same path ends the hold early. The active hold appears on
`/api/drivers/{name}` so a UI can show what is set and until when.

Deliberately outside control v2. A signed package binds a RuntimePolicy and
goes through `CommandV2` with its write scope, lease and evidence, unchanged.
A bundled or local driver has no policy, and synthesising one would be worse
than doing nothing: `HostEnv.permissionAllowed` grants everything only while
the policy is nil, so a policy without permissions silently blocks the driver's
own MQTT, and `LuaDriver.Command` refuses a control v2 driver on the legacy
path — v2 wants `driver_command_v2` entrypoints no community driver has. This
path leaves the policy layer untouched and validates against the catalog
declaration instead.

What that costs, stated plainly: no host-enforced write scope, no host-verified
evidence. What it keeps is the part that protects hardware. Core clamps every
value to the declared bounds rather than trusting the Lua to do it — a driver
that forgets to clamp is exactly the driver this protects — and the driver's
own declaration is the whole allowlist, so an undeclared control is a 400
rather than a 200 for a command the Lua silently ignored.

Every hold ends by itself, and ending means calling the driver's own
`driver_default_mode` rather than writing a value Core invented: only the
driver knows what neutral is. Default 4 h, maximum 24 h, and nothing survives a
restart. On process start or driver re-add, a legacy driver must also confirm
that default before control opens; a failed confirmation keeps control blocked
and retries with a bounded backoff. An offset left behind by a browser tab that
closed is a house heated wrong for weeks.
8 changes: 4 additions & 4 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2442,7 +2442,7 @@ func main() {
if !tr.Online {
slog.Warn("driver telemetry stale — marking offline + reverting to autonomous",
"name", tr.Name, "timeout", watchdogTimeout)
sendDriverDefault(ctx, reg, tr.Name, "watchdog", observeOnlySnap)
sendDriverDefault(ctx, srv, tr.Name, "watchdog", observeOnlySnap)
watchdogDefaulted[tr.Name] = struct{}{}
bus.Publish(events.DriverLost{Driver: tr.Name, At: time.Now()})
} else {
Expand Down Expand Up @@ -2488,7 +2488,7 @@ func main() {
if _, alreadyDefaulted := watchdogDefaulted[name]; alreadyDefaulted {
continue
}
sendDriverDefault(ctx, reg, name, freshness.Reason, observeOnlySnap)
sendDriverDefault(ctx, srv, name, freshness.Reason, observeOnlySnap)
}

// Loadpoint observation and schedule rolling stay live while the
Expand Down Expand Up @@ -2967,13 +2967,13 @@ func registerAllDevices(st *state.Store, reg *drivers.Registry) {

const driverDefaultTimeout = 2 * time.Second

func sendDriverDefault(ctx context.Context, reg *drivers.Registry, name, reason string, observeOnly map[string]bool) {
func sendDriverDefault(ctx context.Context, srv *api.Server, name, reason string, observeOnly map[string]bool) {
if observeOnly[name] {
return
}
cmdCtx, cancel := context.WithTimeout(ctx, driverDefaultTimeout)
defer cancel()
if err := reg.SendDefault(cmdCtx, name); err != nil {
if err := srv.SendDriverDefault(cmdCtx, name); err != nil {
slog.Warn("driver default command failed",
"name", name, "reason", reason, "timeout", driverDefaultTimeout, "err", err)
}
Expand Down
83 changes: 55 additions & 28 deletions go/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,37 +72,37 @@ type Deps struct {
Tel *telemetry.Store
// LogRing is the in-memory log buffer wired in main.go. Nil makes
// /api/drivers/{name}/logs and /api/support/dump return 503.
LogRing *telemetry.LogRing
Ctrl *control.State
CtrlMu *sync.Mutex
State *state.Store
CapMu *sync.RWMutex
Capacities map[string]float64 // driver → battery_capacity_wh (controllable pool)
TelemetryCapacities map[string]float64 // all site batteries incl. observe_only (SoC weighting)
CfgMu *sync.RWMutex
Cfg *config.Config
ConfigPath string
DriverDir string // where to scan for Lua drivers (default: <config-dir>/drivers)
UserDriverDir string // persistent user-drivers overlay; searched before DriverDir
Models map[string]*battery.Model
ModelsMu *sync.Mutex
SelfTune *selftune.Coordinator
DtS float64 // control interval seconds (for model τ / age displays)
SaveConfig func(path string, c *config.Config) error // injection for testability
LogRing *telemetry.LogRing
Ctrl *control.State
CtrlMu *sync.Mutex
State *state.Store
CapMu *sync.RWMutex
Capacities map[string]float64 // driver → battery_capacity_wh (controllable pool)
TelemetryCapacities map[string]float64 // all site batteries incl. observe_only (SoC weighting)
CfgMu *sync.RWMutex
Cfg *config.Config
ConfigPath string
DriverDir string // where to scan for Lua drivers (default: <config-dir>/drivers)
UserDriverDir string // persistent user-drivers overlay; searched before DriverDir
Models map[string]*battery.Model
ModelsMu *sync.Mutex
SelfTune *selftune.Coordinator
DtS float64 // control interval seconds (for model τ / age displays)
SaveConfig func(path string, c *config.Config) error // injection for testability
// ConfigApplier is main.go's config-applied callback — the same
// closure the configreload watcher runs (registry reload with SoC
// bounds, capacities, inverter groups, fuse and mpc/loadmodel
// site-meter sync). Injected so POST /api/config applies a saved
// config exactly like a file edit would. Nil (tests, minimal
// embeddings) still applies control-level fields via
// configreload.Apply; only the callback's extras are skipped.
ConfigApplier configreload.Applier
WebDir string // static assets root (default "web")
ColdDir string // cold-storage root for parquet rolloff; empty disables cold fallback
DataDir string // complete persistent-data root used by portable backups
StatePath string // absolute primary SQLite path used by portable backups
BackupDir string // full .ftwbak output; may be an externally mounted path
DataMaintenanceMu *sync.Mutex // excludes Parquet rolloff/pruning while a full backup is captured
ConfigApplier configreload.Applier
WebDir string // static assets root (default "web")
ColdDir string // cold-storage root for parquet rolloff; empty disables cold fallback
DataDir string // complete persistent-data root used by portable backups
StatePath string // absolute primary SQLite path used by portable backups
BackupDir string // full .ftwbak output; may be an externally mounted path
DataMaintenanceMu *sync.Mutex // excludes Parquet rolloff/pruning while a full backup is captured
// SnapshotDir is where pre-update snapshots of state.db + config.yaml
// are written by the self-update flow. Defaults to
// `<cold_dir_parent>/snapshots`; main.go is responsible for passing
Expand Down Expand Up @@ -206,6 +206,25 @@ type Server struct {
savingsCacheMu sync.Mutex
savingsCache map[string]daySavings

// controlStates serializes command dispatch, default dispatch, and hold
// transitions per driver. Process-lifetime only, deliberately: a restart
// should leave no device held by a setting nobody remembers making.
controlStateMu sync.Mutex
controlStates map[string]*controlDriverState

// beforeDriverControlSend is a package-test seam for reproducing a
// lifecycle change between request validation and registry dispatch. It is
// nil in production; SendWithGeneration still binds every real dispatch to
// the selected running generation.
beforeDriverControlSend func()
// beforeDriverControlStateLock is a package-test seam for the narrower
// lookup-to-lock lifecycle race. It runs after the state map lookup while
// the map lock is still held, before the per-driver state lock is taken.
beforeDriverControlStateLock func()
// beforeDriverDefaultStateLock is a package-test seam for the default path's
// lookup-to-lock lifecycle race. It is nil in production.
beforeDriverDefaultStateLock func()

versionUpdateMu sync.Mutex
driverUpdateMu sync.Mutex
backupMu sync.Mutex
Expand All @@ -225,10 +244,16 @@ func New(deps *Deps) *Server {
deps.WebDir = "web"
}
s := &Server{
deps: deps,
mux: http.NewServeMux(),
dailyCache: make(map[string]state.DayEnergy),
drafts: newDriverDrafts(),
deps: deps,
mux: http.NewServeMux(),
dailyCache: make(map[string]state.DayEnergy),
controlStates: make(map[string]*controlDriverState),
drafts: newDriverDrafts(),
}
if deps.Registry != nil {
// Registry removal is the lifecycle boundary for a driver generation.
// Clear the API hold before a replacement instance can be added.
deps.Registry.SetLifecycleHook(s.clearDriverControl)
}
s.routes()
// A draft's timer died with the previous process, so anything left behind
Expand Down Expand Up @@ -278,6 +303,8 @@ func (s *Server) routes() {
s.handle("GET /api/logs", s.handleGlobalLogs)
s.handle("GET /api/support/dump", s.handleSupportDump)
s.handle("GET /api/support/report", s.handleSupportReport)
s.handle("POST /api/drivers/{name}/control", s.handleDriverControl)
s.handle("DELETE /api/drivers/{name}/control", s.handleDriverControlRelease)
s.handle("POST /api/drivers/{name}/restart", s.handleDriverRestart)
s.handle("POST /api/drivers/{name}/disable", s.handleDriverDisable)
s.handle("POST /api/drivers/{name}/enable", s.handleDriverEnable)
Expand Down
Loading