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
9 changes: 9 additions & 0 deletions .changeset/complete-mcp-onboarding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"ftw": minor
---

Set up a new box with an MCP assistant. Start with no equipment in measurement
mode, resume answers from a durable draft, discover and test equipment, and
finish MyUplink sign-in without putting tokens in chat. Review the physical
connection and live meter before enabling control. Save optional solar and
annual-load estimates as cold-start priors without changing measured history.
43 changes: 38 additions & 5 deletions docs/adr/0011-agent-led-setup.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ADR 0011: Set up a home through an authorized agent

- Status: proposed; implementation and field tests remain work to do.
- Status: implemented for beta review; novice and hardware trials remain.
- Date: 2026-10-18
- Owner: Fredrik. Requested complete setup through MCP on 18 October 2026.

Expand Down Expand Up @@ -88,10 +88,39 @@ preserve exact numbers, and audit the caller and changed field paths without
logging credentials. A reply must distinguish saved, applied, restart needed
and verified on hardware.

Current first-run bootstrap serves only the web wizard. It must gain a
passive owner-claim path before claiming that a fresh installation can use
Cloud MCP. Existing agents can read and propose goals; that is not authority
to replace configuration. Both gaps need code and tests.
First-run bootstrap now offers assistant setup alongside the wizard. It stores
an empty measurement-only configuration and a private house-password hash in
one published SQLite settings file, then restarts. The owner signs in from
another device on the LAN, reviews Cloud terms and approves the setup grant
in Access. Statistics-only sharing cannot carry an assistant session.

`setup_begin` requests diagnostic and setup access together. The box supplies
the complete typed configuration schema, supported modes, runtime time zone,
driver catalogue, outstanding questions, version and commissioning status.
`setup_discover` scans only after confirmation. `setup_test` uses private draft
credentials and blocks equipment writes. It reads an existing driver's health
instead of forking a running OAuth token family. `setup_connect` binds MyUplink
browser consent to the exact draft and grant. Other vendors use their driver
catalogue fields. A driver that needs an undeclared authentication or read
subscription write may require a driver update before it works in measurement
mode; do not present that as a verified connection.

Save equipment in measurement mode first. Read fresh measurements and the
running meter's identity. While control remains blocked, use the ordinary
`goal_request` and `car_goal_request` tools to choose mode, planning style and
car departure goals. The owner approves these in local Access or the FTW app;
Check requests and access refreshes the local list. Read both request receipts
and active values. These goals use their existing state and never replay old
setup answers over later choices. Confirm the host time zone before local
HH:MM targets; a wrong host zone needs a host change and Core restart.

The final draft records the reviewed connection digest, equipment and live
meter identity. Enabling control requires a fresh grid-boundary meter with
usable phase currents for the configured fuse, and unchanged meter connection
settings. Changing measurement mode requires a restart. Saved mode and active
mode appear separately. Measurement mode blocks driver writes even in init,
poll and cleanup; a changed meter identity blocks new dispatch while allowing
Core's safe stop/default paths. Hardware response still needs a measured test.

## Useful forecasts before learning

Expand All @@ -112,6 +141,10 @@ model's sample count. Measured days progressively replace the prior through
the model's existing learning weights. Changing a prior must preserve learned
history and must not change an equipment safety limit.

If no separate heating estimate exists, an annual whole-house estimate stays
a coarse prior. Its unlearned share already includes average heating; only
the measured share adds the learned weather-sensitive heat term.

Missing solar capacity stays unknown until the owner supplies an estimate or
measurement supports one. An observed production peak is not a verified
inverter limit. More detailed solar priors can follow measured comparison;
Expand Down
2 changes: 1 addition & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ of Core; new solver work belongs in Energyplan.
|---|---|---|
| Remote clients, 17 October 2026 | [Core #1687](https://github.com/srcfl/ftw/pull/1687), [Cloud #28](https://github.com/srcfl/ftw-cloud/pull/28), [web #95](https://github.com/srcfl/ftw-webapp/pull/95) and [iOS #83](https://github.com/srcfl/ftw-app/pull/83) converge on the Cloud tunnel. Core validates each command; browser offline notices remain box-encrypted. Core [v0.147.0-beta.1](https://github.com/srcfl/ftw/releases/tag/v0.147.0-beta.1) and the web client are published; iOS 0.4.8 build 13 is uploaded to App Store Connect. Cloud runs in Stockholm and the Frankfurt relay is stopped, with disk and address kept for rollback. Escrow remains separate. The production Cloud test paired a simulated box, confirmed 50 commands and resumed five times with no relay dial. | Update older boxes and pair older clients again. Confirm physical-site behaviour separately; the Cloud test used no hardware. |
| Mixed equipment | Separate SolarEdge legacy and Pixii drivers exist. Their [published evidence](https://github.com/srcfl/device-drivers/blob/7e5946555245dee2150d46d1b7278aaec5ebe242/drivers/lua/solaredge_legacy.lua) and [Pixii metadata](https://github.com/srcfl/device-drivers/blob/7e5946555245dee2150d46d1b7278aaec5ebe242/drivers/lua/pixii.lua) still say experimental; the legacy header and declared curtail capability also need to agree. | Reconcile known field runs with the catalog, then verify the named inverter + battery + charger combination. Distinguish measured telemetry, verified commands and unverified control. Metadata is not evidence that no user has ever run the hardware. |
| Setup and usable power | Discovery, fingerprinting and per-device settings exist. [Planner battery limits](../go/cmd/ftw/main.go) use configured limits or a 0.5C estimate, then an aggregate fuse cap. | Complete the [agent-led setup](adr/0011-agent-led-setup.md) path from a new box, including resumable drafts, explicit setup rights, shared access by code and verified commissioning. A guided commissioning result and measured usable-power learning are still missing from the reviewed setup path. Read verified limits first; learn response within them. A capacity-derived estimate is not a learned power limit. |
| Setup and usable power | Discovery, fingerprinting and per-device settings exist. Agent-led setup now adds a passive first claim, resumable MCP interview, draft discovery/vendor sign-in, optional annual-load and solar priors, and an exact connection review before control. [Planner battery limits](../go/cmd/ftw/main.go) use configured limits or a 0.5C estimate, then an aggregate fuse cap. | Run a complete novice trial of [agent-led setup](adr/0011-agent-led-setup.md) through two MCP clients and supported hardware. The setup status separates configured, measuring and control evidence; measured usable-power learning remains work to do. Read verified limits first; learn response within them. A capacity-derived estimate is not a learned power limit. |
| Forecasts and defaults | [PV learning](../optimizer/native/README.md) needs no panel geometry or rating. [Cold-load selection](../go/cmd/ftw/forecast_tracking.go) keeps the site prior until learning, and [load/net-risk tests](../go/cmd/ftw/forecast_load_risk_test.go) cover uncertainty. Charge and discharge efficiency are included. The [minimum arbitrage spread](../go/internal/config/config.go) defaults to zero. | Verify first-day and learned performance on held-out site periods. Keep the separate opt-in wear-cost requirement open; a minimum arbitrage spread is not a general wear model. Align all persisted defaults and worker support before exposing such a setting. Do not rebuild cold-start support already delivered in #1204. |
| Daily charging | [The webapp panel](https://github.com/srcfl/ftw-webapp/blob/ff7af033fa3fcdeb38882e3ff365e8d6d7aba75a/src/views/EvPanel.svelte) already saves SoC on slider release, changes schedules without a Save button and supports Charge now. | [Now](https://github.com/srcfl/ftw-webapp/blob/ff7af033fa3fcdeb38882e3ff365e8d6d7aba75a/src/views/Now.svelte) normally opens that panel after a charger tap or notification link. Bring the relevant SoC action directly into the post-plug-in entry experience. Complete the goal → plan → delivered-energy flow on real chargers, including offline cars and restarts. |
| Notifications | Core and the webapp implement subscription, charging connection/completion/interruption events and device alerts. See the [shared push catalogue](../contract/push-catalogue.yaml) and [rule defaults](../go/internal/notifications/service.go). | There is no dedicated predicted-missed-departure event in that catalogue. Add an actionable goal-risk notification and make activation clear during charging setup, with user consent. Verify delivery while the app is closed; an interrupted-session alert alone does not cover a future shortfall. |
Expand Down
70 changes: 69 additions & 1 deletion go/cmd/ftw/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ package main

import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"sync"

"github.com/srcfl/ftw/go/internal/api"
"github.com/srcfl/ftw/go/internal/config"
Expand All @@ -22,6 +24,15 @@ func runBootstrap(configPath, webDir, driverDir string) {
slog.Info("no config found — starting setup wizard", "url", "http://localhost:8080/setup")

mux := http.NewServeMux()
var initialWriteMu sync.Mutex
// Both first-run choices share one write lock. A late wizard request must
// not replace an assistant installation which has already started.
saveInitial := func(cfg *config.Config) error {
initialWriteMu.Lock()
defer initialWriteMu.Unlock()
return saveInitialConfig(configPath, cfg)
}
mux.HandleFunc("POST /api/setup/assistant", bootstrapAssistant(saveInitial, func() error { return execCurrentProcess(nil) }))
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
http.Redirect(w, r, "/setup", http.StatusFound)
Expand Down Expand Up @@ -103,7 +114,7 @@ func runBootstrap(configPath, webDir, driverDir string) {
writeBootstrapJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
return
}
if err := config.SaveAtomic(configPath, &cfg); err != nil {
if err := saveInitial(&cfg); err != nil {
writeBootstrapJSON(w, http.StatusInternalServerError, map[string]string{"error": "write config: " + err.Error()})
return
}
Expand All @@ -123,6 +134,63 @@ func runBootstrap(configPath, webDir, driverDir string) {
}
}

func saveInitialConfig(path string, cfg *config.Config) error {
if _, err := os.Lstat(path); !os.IsNotExist(err) {
return fmt.Errorf("settings already exist or cannot be checked; reload the box instead of starting again")
}
// A lost locator is a recovery case, never a new household. Check both
// the default working directory and the seed's directory without opening
// or creating a database.
for _, db := range []string{"state.db", filepath.Join(filepath.Dir(path), "state.db")} {
if _, err := os.Lstat(db); !os.IsNotExist(err) {
return fmt.Errorf("existing data found at %s; restore its settings locator before setup", db)
}
}
if err := cfg.Validate(); err != nil {
return err
}
if cfg.API.LANAuth {
return saveInitialAssistantStorage(path, cfg)
}
return config.SaveAtomic(path, cfg)
}

func bootstrapAssistant(save func(*config.Config) error, restart func() error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var q struct {
Confirmed bool `json:"confirmed"`
Password string `json:"password"`
}
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024))
dec.DisallowUnknownFields()
if err := dec.Decode(&q); err != nil || !q.Confirmed {
writeBootstrapJSON(w, http.StatusBadRequest, map[string]string{"error": "confirm starting an empty measurement installation"})
return
}
if err := dec.Decode(new(any)); err != io.EOF {
writeBootstrapJSON(w, http.StatusBadRequest, map[string]string{"error": "send one setup request"})
return
}
hash, err := api.HashInitialLANPassword(q.Password)
if err != nil {
writeBootstrapJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
cfg := config.NewSetup()
cfg.API.LANAuth, cfg.LANPasswordHash = true, hash
if err := save(cfg); err != nil {
writeBootstrapJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
return
}
writeBootstrapJSON(w, http.StatusOK, map[string]any{"saved": true, "restart_pending": true, "next": "/?setup=assistant"})
go func() {
if err := restart(); err != nil {
slog.Error("assistant setup restart failed", "err", err)
}
}()
}
}

// Bootstrap needs the same country/zone choices before any config exists.
func bootstrapPriceZones(w http.ResponseWriter, _ *http.Request) {
zones := prices.Zones()
Expand Down
109 changes: 109 additions & 0 deletions go/cmd/ftw/bootstrap_assistant_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package main

import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

"github.com/srcfl/ftw/go/internal/config"
"github.com/srcfl/ftw/go/internal/state"
)

func TestBootstrapAssistantCreatesARealEmptyInstallation(t *testing.T) {
t.Setenv("FTW_API_TOKEN", "")
path := filepath.Join(t.TempDir(), "config.yaml")
restarted := make(chan struct{}, 1)
handler := secureBootstrapMutations(bootstrapAssistant(func(cfg *config.Config) error { return saveInitialConfig(path, cfg) }, func() error { restarted <- struct{}{}; return nil }))
for _, tc := range []struct {
body, origin string
want int
}{
{`{"confirmed":true}`, "https://attacker.example", 403},
{`{"confirmed":false}`, "http://box.local", 400},
{`{"confirmed":true,"cloud":true}`, "http://box.local", 400},
{`{"confirmed":true,"password":"first-owner-password"}`, "http://box.local", 200},
} {
req := httptest.NewRequest("POST", "http://box.local/api/setup/assistant", strings.NewReader(tc.body))
req.RemoteAddr = "192.168.1.12:1234"
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Origin", tc.origin)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != tc.want {
t.Fatalf("%s: %d %s", tc.body, w.Code, w.Body.String())
}
}
<-restarted
cfg, err := config.Load(path)
if err != nil {
t.Fatal(err)
}
if !cfg.MeasurementOnly() || len(cfg.Drivers) != 0 || cfg.Fuse.MaxAmps != 0 || cfg.FleetPing.Enabled || !cfg.AppLink.Enabled || cfg.Setup.ConnectionConfirmed || !cfg.API.LANAuth || cfg.LANPasswordHash == "" {
t.Fatal("first boot invented equipment, limits or consent", cfg)
}

runtimeStore, err := state.Open(cfg.ConfigDatabase)
if err != nil {
t.Fatal("first real boot could not open its history", err)
}
if err := runtimeStore.Close(); err != nil {
t.Fatal(err)
}
before, _ := os.ReadFile(path)
if strings.Contains(string(before), "first-owner-password") || strings.Contains(string(before), cfg.LANPasswordHash) {
t.Fatal("locator contains the house password or its private hash")
}
if err := saveInitialConfig(path, config.NewSetup()); err == nil {
t.Fatal("a late first-run request replaced settings")
}
after, _ := os.ReadFile(path)
if string(before) != string(after) {
t.Fatal("refused first-run request changed settings")
}
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
recovered, err := config.Load(path)
if err != nil || recovered.Revision != cfg.Revision || recovered.LANPasswordHash != cfg.LANPasswordHash {
t.Fatal("interrupted locator publication lost first-claim settings", err)
}
}

func TestBootstrapDoesNotReplaceAnOrphanedDatabase(t *testing.T) {
dir := t.TempDir()
data := []byte("existing database must stay untouched")
db := filepath.Join(dir, "state.db")
if err := os.WriteFile(db, data, 0600); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "config.yaml")
if err := saveInitialConfig(path, config.NewSetup()); err == nil {
t.Fatal("offered a new home over existing data")
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatal("created a new settings locator")
}
got, _ := os.ReadFile(db)
if string(got) != string(data) {
t.Fatal("changed existing database")
}
}

func TestSetupCannotEnableChangedMeterConfiguration(t *testing.T) {
active := &config.Config{Drivers: []config.Driver{{Name: "meter", Lua: "meter.lua", IsSiteMeter: true, Config: map[string]any{"host": "192.0.2.1", "scale": 1.0}}}}
candidate := &config.Config{Drivers: []config.Driver{{Name: "meter", Lua: "meter.lua", IsSiteMeter: true, Config: map[string]any{"host": "192.0.2.1", "scale": 1.0}}}}
if !sameSetupMeterConfiguration(active, candidate) {
t.Fatal("same meter rejected")
}
candidate.Drivers[0].Config["scale"] = 2.0
if sameSetupMeterConfiguration(active, candidate) {
t.Fatal("old readings accepted for new scaling")
}
candidate.Drivers[0].Config["scale"] = 1.0
candidate.Drivers[0].Config["host"] = "192.0.2.2"
if sameSetupMeterConfiguration(active, candidate) {
t.Fatal("old readings accepted for another endpoint")
}
}
Loading
Loading