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
5 changes: 5 additions & 0 deletions .changeset/tesla-telemetry-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Vehicle telemetry refresh no longer needs a fresh SoC to find its recipient. Core retains vehicle source time and saves a wake budget across restarts. The release includes Tesla BLE driver 0.2.5 for bounded recovery; charging commands keep their existing freshness checks. It also carries the merged Heishamon 0.8.0 and MyUplink 1.2.3 driver updates.
24 changes: 24 additions & 0 deletions docs/writing-a-driver.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,3 +208,27 @@ 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.

### Vehicle telemetry recovery

A vehicle driver may declare `DRIVER.telemetry_wake = true` for a `wake_up`
command that only requests telemetry. Core selects it from configuration,
without requiring a fresh SoC. Without an explicit vehicle-to-loadpoint
binding, refresh requires one enabled vehicle and one connected loadpoint.
Read-only and observe-only drivers cannot receive it. Planning and
`charge_start` still require fresh vehicle evidence.

`host.unix_ms()` returns Unix milliseconds. `host.millis()` and `host.now_ms()`
remain process uptime. A vehicle emit may include `soc_observed_at_ms` and
`soc_fresh`; Core retains the source time and rejects invalid, future and
repeated timestamps as new SoC observations. Old drivers that omit source
time keep their receipt-time behavior.

Call `host.reserve_vehicle_wake()` before a telemetry wake. It returns
`allowed, retry_ms, err` and saves the attempt before the network call, keyed
by the make and serial set by the driver. The limit is three attempts per
30-minute window, at least 90 seconds apart. Reload, rename, failed HTTP and
Core restart do not reset it. Missing identity, storage failure and hosts
without the function must prevent recovery wakes. This API reserves a budget;
it does not grant a network or command capability. Proxy cache hits do not
prove a new observation, even when HTTP succeeds.
2 changes: 1 addition & 1 deletion drivers/BUNDLED_SOURCE.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"from the signed channel. Run scripts/sync-bundled-drivers.sh to update."
],
"repository": "srcfl/device-drivers",
"commit": "7c3440c9d10faedfd554f1c795cdbf7b197f3339",
"commit": "c9de25106b07071fde55c9f8f5e95b69c58e6fc9",
"source_dir": "drivers/lua",
"drivers": [
"ambibox_v2x", "ctek", "ctek_hybrid", "ctek_v2", "deye", "easee_cloud",
Expand Down
5 changes: 5 additions & 0 deletions go/cmd/ftw/driver_registry.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package main

import (
"time"

"github.com/srcfl/ftw/go/internal/config"
"github.com/srcfl/ftw/go/internal/drivers"
"github.com/srcfl/ftw/go/internal/state"
Expand All @@ -21,5 +23,8 @@ func newDriverRegistry(tel *telemetry.Store, st *state.Store) *drivers.Registry
reg.SecretOverride = func(owner, key string) (string, bool) {
return st.LoadConfig(driverSecretKey(owner, key))
}
reg.VehicleWakeReservation = func(makeName, serial string) (bool, time.Duration, error) {
return st.ReserveVehicleWake(state.ResolveDeviceID(makeName, serial, "", ""), time.Now())
}
return reg
}
28 changes: 18 additions & 10 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2119,6 +2119,22 @@ func main() {
return pick.Driver, pick.ChargingState, true
})

lpController.SetVehicleRefreshTarget(func(lpID string) (string, error) {
lp, ok := lpMgr.State(lpID)
if !ok || !lp.PluggedIn {
return "", nil
}
connected := 0
for _, other := range lpMgr.States() {
if other.PluggedIn {
connected++
}
}
cfgMu.RLock()
defer cfgMu.RUnlock()
return configuredVehicleRefreshDriver(cfg.Drivers, connected)
})

lpController.SetVehicleChargeState(func(lpID string) (loadpoint.VehicleChargeState, bool) {
pick := telemetry.PickVehicleForCompletion(tel, time.Now())
if pick.Driver == "" || pick.Stale || !lpMgr.VehicleObservationApplies(lpID, pick.UpdatedAt) {
Expand Down Expand Up @@ -3154,18 +3170,10 @@ func main() {
// plan once with measured-truth instead of the pluginSoC
// estimate the startup replan used.
if mpcSvc != nil && !vehicleReplanFired {
for _, vr := range tel.ReadingsByType(telemetry.DerVehicle) {
if vr.SoC == nil {
continue
}
if h := tel.DriverHealth(vr.Driver); h == nil || !h.IsOnline() {
continue
}
if pick := telemetry.PickBestVehicle(tel, time.Now()); pick.Driver != "" {
vehicleReplanFired = true
go mpcSvc.Replan(ctx)
slog.Info("first vehicle SoC seen → MPC replan triggered",
"driver", vr.Driver, "soc", *vr.SoC)
break
slog.Info("fresh vehicle SoC received; MPC replan requested", "driver", pick.Driver, "soc", pick.SoC)
}
}

Expand Down
44 changes: 44 additions & 0 deletions go/cmd/ftw/vehicle_refresh.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package main

import (
"fmt"
"slices"

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

// Until a loadpoint has an explicit vehicle binding, one configured vehicle
// and one connected charger are the only unambiguous telemetry recipient.
// Freshness remains a requirement for planning and charge_start, not for wake.
func configuredVehicleRefreshDriver(configured []config.Driver, connected int) (string, error) {
if connected != 1 {
return "", fmt.Errorf("vehicle refresh requires one connected loadpoint")
}
var candidate *config.Driver
var entry drivers.CatalogEntry
for i := range configured {
d := &configured[i]
if d.Disabled {
continue
}
e, err := drivers.ParseCatalogFile(d.Lua)
if err != nil {
return "", fmt.Errorf("vehicle refresh: cannot read driver metadata")
}
if !slices.Contains(e.Capabilities, "vehicle") {
continue
}
if candidate != nil {
return "", fmt.Errorf("vehicle refresh is ambiguous with multiple configured vehicles")
}
candidate, entry = d, e
}
if candidate == nil {
return "", nil
}
if candidate.ObserveOnly || entry.ReadOnly || !entry.TelemetryWake {
return "", nil
}
return candidate.Name, nil
}
122 changes: 122 additions & 0 deletions go/cmd/ftw/vehicle_refresh_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package main

import (
"context"
"github.com/srcfl/ftw/go/internal/config"
"github.com/srcfl/ftw/go/internal/state"
"github.com/srcfl/ftw/go/internal/telemetry"
"os"
"path/filepath"
"testing"
"time"
)

func TestVehicleRefreshSelectsOnlyUnambiguousAuthorizedConfiguration(t *testing.T) {
dir := t.TempDir()
vehicle := func(name string, readOnly, wake bool) config.Driver {
path := filepath.Join(dir, name+".lua")
ro, wk := "false", "false"
if readOnly {
ro = "true"
}
if wake {
wk = "true"
}
source := `DRIVER = {
id = "test",
capabilities = { "vehicle" },
read_only = ` + ro + `,
telemetry_wake = ` + wk + `,
}`
if err := os.WriteFile(path, []byte(source), 0600); err != nil {
t.Fatal(err)
}
return config.Driver{Name: name, Lua: path}
}
tesla := vehicle("tesla", false, true)
other := vehicle("other", true, false)
cases := []struct {
name string
drivers []config.Driver
connected int
want string
wantErr bool
}{
{"missing or stale soc", []config.Driver{tesla}, 1, "tesla", false},
{"two vehicles", []config.Driver{tesla, other}, 1, "", true},
{"two chargers", []config.Driver{tesla}, 2, "", true},
{"read only", []config.Driver{other}, 1, "", false},
{"no wake declaration", []config.Driver{vehicle("legacy", false, false)}, 1, "", false},
{"disabled other", []config.Driver{tesla, {Name: other.Name, Lua: other.Lua, Disabled: true}}, 1, "tesla", false},
{"observe only", []config.Driver{{Name: tesla.Name, Lua: tesla.Lua, ObserveOnly: true}}, 1, "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := configuredVehicleRefreshDriver(tc.drivers, tc.connected)
if got != tc.want || (err != nil) != tc.wantErr {
t.Fatalf("got=%q err=%v", got, err)
}
})
}
}

func TestVehicleRefreshPollsPromptlyAndKeepsBudgetAcrossRename(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "vehicle.lua")
source := `DRIVER = {
id = "vehicle-test",
capabilities = { "vehicle" },
telemetry_wake = true,
}
function driver_init(config)
host.set_make("Tesla")
host.set_sn("VIN-A")
host.set_poll_interval(3600000)
end
function driver_poll()
host.emit("vehicle", {soc=100, soc_fresh=true, soc_observed_at_ms=host.unix_ms(), charging_state="Complete"})
host.set_poll_interval(3600000)
end
function driver_command(action)
assert(action == "wake_up")
local allowed = host.reserve_vehicle_wake()
host.emit_metric("reserved", allowed and 1 or 0)
host.set_poll_interval(10)
return true
end
function driver_default_mode() end`
if err := os.WriteFile(path, []byte(source), 0600); err != nil {
t.Fatal(err)
}
st, err := state.Open(filepath.Join(dir, "state.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
tel := telemetry.NewStore()
reg := newDriverRegistry(tel, st)
defer reg.ShutdownAll()
for i, name := range []string{"original", "renamed"} {
if err := reg.Add(context.Background(), config.Driver{Name: name, Lua: path}); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
err := reg.Send(ctx, name, []byte(`{"action":"wake_up"}`))
cancel()
if err != nil {
t.Fatal(err)
}
expected := float64(1 - i)
if value, _, ok := tel.LatestMetric(name, "reserved"); !ok || value != expected {
t.Fatalf("reservation after rename: %v", value)
}
deadline := time.Now().Add(time.Second)
for tel.Get(name, telemetry.DerVehicle) == nil && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if reading := tel.Get(name, telemetry.DerVehicle); reading == nil || reading.SoC == nil || *reading.SoC != 1 {
t.Fatal("wake interval did not reach the active poll timer")
}
reg.Remove(name)
}
}
3 changes: 3 additions & 0 deletions go/internal/drivers/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ type CatalogEntry struct {
// write path, rather than FTW matching on a filename or vendor name.
// Read-only remains the default for every driver in the catalog.
WriteCapabilities []string `json:"write_capabilities,omitempty"`
// TelemetryWake declares a wake-only command, separate from charging.
TelemetryWake bool `json:"telemetry_wake,omitempty"`
// Replaces names catalog driver ids this driver takes over, such as
// esphome-dsmr folded into esphome_dsmr. A device running one of them is
// moved to this driver when the release ships it.
Expand Down Expand Up @@ -227,6 +229,7 @@ func parseCatalogEntry(path string) (CatalogEntry, error) {
e.TestedModels = pickList(block, "tested_models")
e.ConfigSecrets = pickList(block, "config_secrets")
e.WriteCapabilities = pickList(block, "write_capabilities")
e.TelemetryWake = pickBool(block, "telemetry_wake")
e.Replaces = pickList(block, "replaces")
e.AuthPostPath = pickString(block, "auth_post_path")
e.AuthPostPaths = pickList(block, "auth_post_paths")
Expand Down
4 changes: 3 additions & 1 deletion go/internal/drivers/heishamon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ func TestHeishamonEmitsMetricsWithoutFakeBattery(t *testing.T) {
}

mqtt.Push("panasonic_heat_pump/main/Outside_Temp", "-4.5")
mqtt.Push("panasonic_heat_pump/main/Heat_Power_Consumption", "1250")
mqtt.Push("panasonic_heat_pump/main/Main_Inlet_Temp", "31.2")
mqtt.Push("panasonic_heat_pump/main/Main_Outlet_Temp", "35.7")
mqtt.Push("panasonic_heat_pump/main/Main_Target_Temp", "36")
Expand All @@ -46,7 +47,8 @@ func TestHeishamonEmitsMetricsWithoutFakeBattery(t *testing.T) {
}

wants := map[string]float64{
"hp_outside_temp_c": -4.5,
"hp_outdoor_temp_c": -4.5,
"hp_power_w": 1250,
"hp_inlet_temp_c": 31.2,
"hp_outlet_temp_c": 35.7,
"hp_target_temp_c": 36,
Expand Down
4 changes: 4 additions & 0 deletions go/internal/drivers/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,10 @@ type HostEnv struct {
// to 1 MiB and checks signed secret-key grants for managed drivers.
PersistSecret func(key, value string) error

// ReserveVehicleWake checkpoints a telemetry wake before the driver sends it.
// The host binds this to hardware identity, independent of the driver name.
ReserveVehicleWake func(makeName, serial string) (bool, time.Duration, error)

// Poll-scoped Modbus evidence prevents a Modbus driver from turning failed
// reads into fresh zero-valued telemetry. The Lua runtime holds emissions
// until driver_poll ends, then commits them only when every read succeeded.
Expand Down
22 changes: 22 additions & 0 deletions go/internal/drivers/lua.go
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,28 @@ func registerHost(L *lua.LState, env *HostEnv) {
return 1
}))

// Wall-clock time is separate from millis/now_ms, which measure uptime.
host.RawSetString("unix_ms", L.NewFunction(func(L *lua.LState) int {
L.Push(lua.LNumber(time.Now().UnixMilli()))
return 1
}))
host.RawSetString("reserve_vehicle_wake", L.NewFunction(func(L *lua.LState) int {
allowed, retry, err := false, 30*time.Minute, ErrNoCapability
if !env.ProbeReadOnly && !driverDeclaresReadOnly(L) &&
(env.RuntimePolicy == nil || !env.RuntimePolicy.ReadOnly) && env.ReserveVehicleWake != nil {
makeName, serial := env.Identity()
allowed, retry, err = env.ReserveVehicleWake(makeName, serial)
}
L.Push(lua.LBool(allowed && err == nil))
L.Push(lua.LNumber(retry.Milliseconds()))
if err != nil {
L.Push(lua.LString("vehicle wake reservation failed"))
} else {
L.Push(lua.LNil)
}
return 3
}))

// host.sleep(ms) — block the driver goroutine for ms milliseconds.
// Used for vendor-required inter-write pacing (Solis 100ms, Deye 50ms);
// safe because each driver has its own goroutine and VM lock.
Expand Down
Loading
Loading