From 4f700b683fc2be3699fd66644e7f754f269f8fc0 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 08:03:10 +0200 Subject: [PATCH 1/3] feat(cars): a car says when its cable went in and how far away it is Vehicle drivers may add plugged_since_ms, the time their source saw the cable take its state, and site_distance_m, the car's distance from the site. Core reads up to 300 m as here and over 1 km as elsewhere, with no answer between. Rule 2 uses the car's own time, so a source that reports minutes after the plug-in, or the first report after a restart, still names its car when the cable went in within the minute. host.site_location() gives the weather pin to vehicle drivers only. The pin nobody moved and 0,0 are no position: a car measured against central Stockholm would turn the only car into a visitor. Co-Authored-By: Claude Opus 5.5 --- .changeset/car-says-plugged-here.md | 5 + docs/cars.md | 10 +- docs/writing-a-driver.md | 23 +++- go/cmd/ftw/driver_registry.go | 20 +++ go/cmd/ftw/driver_registry_test.go | 20 +++ go/cmd/ftw/main.go | 8 +- go/internal/cars/carsays_test.go | 115 ++++++++++++++++++ go/internal/cars/tick.go | 13 +- go/internal/drivers/host.go | 6 + go/internal/drivers/lua.go | 34 +++++- go/internal/drivers/lua_site_location_test.go | 57 +++++++++ go/internal/drivers/registry.go | 4 + go/internal/telemetry/vehicle.go | 74 +++++++++-- go/internal/telemetry/vehicle_test.go | 46 +++++++ 14 files changed, 414 insertions(+), 21 deletions(-) create mode 100644 .changeset/car-says-plugged-here.md create mode 100644 go/internal/cars/carsays_test.go create mode 100644 go/internal/drivers/lua_site_location_test.go diff --git a/.changeset/car-says-plugged-here.md b/.changeset/car-says-plugged-here.md new file mode 100644 index 00000000..430b442e --- /dev/null +++ b/.changeset/car-says-plugged-here.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +A car can say it was plugged in here. Vehicle drivers may report when their car's cable went in (`plugged_since_ms`) and how far the car is from the site (`site_distance_m`), and `host.site_location()` gives a vehicle driver the site's position so the car's coordinates stay in the driver. The box names a car by its own word even when the report arrives late, and never names a car that says it is somewhere else. diff --git a/docs/cars.md b/docs/cars.md index 8889e75c..f72f2f34 100644 --- a/docs/cars.md +++ b/docs/cars.md @@ -106,9 +106,13 @@ At each plug-in the box asks, in this order (`carIdentifiedBy`): 2. `car`: one of the car's sources saw its cable go in within a minute of the plug-in and does not say it is elsewhere (`at_site` false), or says it is charging at this site (`at_site` true) while this charger alone - delivers. A source says so with `plugged` and `at_site` in its `vehicle` - emit, or with a charging state that needs a cable. Two cars saying so is - no answer. + delivers. A source says so with `plugged` and `at_site` (or + `site_distance_m`) in its `vehicle` emit, or with a charging state that + needs a cable. When the source gives `plugged_since_ms`, that is when the + cable went in; otherwise it is when the box first saw the source say so. + A source that does not know where the car is counts only by that minute. + Two cars saying so is no answer. The fields are in + [writing-a-driver.md](writing-a-driver.md#which-car-is-plugged-in). 3. `only`: exactly one car could be here: the box knows one car that is not on another charger, and it has not said it is somewhere else. The car's usual charger counts the same way: a car the box made from a charger's diff --git a/docs/writing-a-driver.md b/docs/writing-a-driver.md index 1635f68f..d0d99d91 100644 --- a/docs/writing-a-driver.md +++ b/docs/writing-a-driver.md @@ -37,7 +37,8 @@ current host API and the source of truth for it. Today it registers: | Telemetry | `emit`, `emit_metric` | | Identity and nameplate | `set_make`, `set_sn`, `set_model`, `set_rated_w` | | Driver control | `set_poll_interval`, `set_warmup_s`, `set_watchdog_timeout_s`, `set_device_fault` | -| Helpers | `log`, `sleep`, `millis`, `now_ms`, `json_encode`, `json_decode`, `persist_secret` | +| Helpers | `log`, `sleep`, `millis`, `now_ms`, `unix_ms`, `json_encode`, `json_decode`, `persist_secret` | +| Vehicle | `reserve_vehicle_wake`, `site_location` | | Decoding | `decode_string`, `decode_i16`, `decode_i32_be`, `decode_i32_le`, `decode_u32_be`, `decode_u32_le` | | Modbus | `modbus_read`, `write`, `write_registers` (canonical); `modbus_write`, `modbus_write_multi` (legacy aliases) | | MQTT | `mqtt_pub`, `mqtt_sub`, `mqtt_publish`, `mqtt_subscribe`, `mqtt_messages` | @@ -232,3 +233,23 @@ 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. + +### Which car is plugged in + +A vehicle emit may say where the car's cable and the car are, so Core can +name the car at a plug-in ([cars.md](cars.md), rule 2). Every field is +optional; leave out what the source does not know. + +| Field | Meaning | +|---|---| +| `plugged` | The car's own word on its charge cable: `true` in, `false` out. | +| `plugged_since_ms` | Unix ms at which the source saw the cable take that state: the source's own change time (Home Assistant's `last_changed`), else the first reading that showed it. Leave it out when the driver has not seen a change, as at its first reading. | +| `at_site` | `true` when the car is at this site, `false` when it is somewhere else, from the source's own idea of home. | +| `site_distance_m` | The car's distance from the site in metres. Core reads up to 300 m as here and over 1 km as elsewhere, and does not know in between. `at_site` wins when both are given. | + +`host.site_location()` returns the site's `lat, lon`, or `nil` when the site +has no position (no weather pin, or the pin nobody moved). Only a driver whose +`DRIVER.capabilities` lists `vehicle` gets it. Measure the distance in the +driver and emit only `site_distance_m`, so the car's coordinates stay there. +Read the cable and the place at the driver's own cadence: never wake a car +only to learn them. diff --git a/go/cmd/ftw/driver_registry.go b/go/cmd/ftw/driver_registry.go index 5a208d23..c936a545 100644 --- a/go/cmd/ftw/driver_registry.go +++ b/go/cmd/ftw/driver_registry.go @@ -1,6 +1,7 @@ package main import ( + "math" "time" "github.com/srcfl/ftw/go/internal/config" @@ -28,3 +29,22 @@ func newDriverRegistry(tel *telemetry.Store, st *state.Store) *drivers.Registry } return reg } + +// Where the settings page puts the weather pin when nobody has moved it. +const unsetPinLat, unsetPinLon = 59.3293, 18.0686 + +// siteLocation is the site's position: the weather pin on the box's map. +// The untouched pin and 0,0 are no position. A car measured against a pin in +// central Stockholm would read as elsewhere and turn the only car into a +// visitor. +func siteLocation(cfg *config.Config) (lat, lon float64, ok bool) { + if cfg == nil || cfg.Weather == nil { + return 0, 0, false + } + lat, lon = cfg.Weather.Latitude, cfg.Weather.Longitude + if math.IsNaN(lat) || math.IsNaN(lon) || lat < -90 || lat > 90 || lon < -180 || lon > 180 || + (lat == 0 && lon == 0) || (lat == unsetPinLat && lon == unsetPinLon) { + return 0, 0, false + } + return lat, lon, true +} diff --git a/go/cmd/ftw/driver_registry_test.go b/go/cmd/ftw/driver_registry_test.go index 08dda698..cdefeaff 100644 --- a/go/cmd/ftw/driver_registry_test.go +++ b/go/cmd/ftw/driver_registry_test.go @@ -232,3 +232,23 @@ api: t.Error("reused name did not start with the new account token C") } } + +// The weather pin is the site's position, unless nobody has moved it. +func TestSiteLocationSkipsTheUntouchedPin(t *testing.T) { + for _, tc := range []struct { + name string + w *config.Weather + ok bool + }{ + {"no weather", nil, false}, + {"0,0", &config.Weather{}, false}, + {"the untouched pin", &config.Weather{Latitude: 59.3293, Longitude: 18.0686}, false}, + {"out of range", &config.Weather{Latitude: 95, Longitude: 18}, false}, + {"a pin someone set", &config.Weather{Latitude: 59.6099, Longitude: 16.5448}, true}, + } { + lat, lon, ok := siteLocation(&config.Config{Weather: tc.w}) + if ok != tc.ok || (ok && (lat != tc.w.Latitude || lon != tc.w.Longitude)) { + t.Errorf("%s: %v,%v %v", tc.name, lat, lon, ok) + } + } +} diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 175b3e32..71338de3 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -701,7 +701,14 @@ func main() { if cfg.DeviceRepository != nil && cfg.DeviceRepository.Enabled { go driverRepositoryRefreshLoop(ctx, driverRepository, cfg.DeviceRepository.RefreshIntervalH) } + // cfgMu guards cfg, which a config apply replaces in place. + cfgMu := &sync.RWMutex{} reg := newDriverRegistry(tel, st) + reg.SiteLocation = func() (float64, float64, bool) { + cfgMu.RLock() + defer cfgMu.RUnlock() + return siteLocation(cfg) + } reg.SetTroubleshootingMode(cfg.Site.TroubleshootingMode) reg.RuntimePolicyResolver = driverRepository.RuntimePolicy reg.MQTTFactory = func(name string, c *config.MQTTConfig) (drivers.MQTTCap, error) { @@ -795,7 +802,6 @@ func main() { // counter every one of them had to remember would be wrong within a month. controlRev := &control.Revision{} capMu := &sync.RWMutex{} - cfgMu := &sync.RWMutex{} modelsMu := &sync.Mutex{} // Pre-declare services that the hot-reload Applier needs to touch. diff --git a/go/internal/cars/carsays_test.go b/go/internal/cars/carsays_test.go new file mode 100644 index 00000000..db7f49eb --- /dev/null +++ b/go/internal/cars/carsays_test.go @@ -0,0 +1,115 @@ +package cars + +import ( + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/telemetry" + "github.com/srcfl/ftw/go/protocol" +) + +// twoCars is Fredrik's box with a second car: the Tesla reads through Home +// Assistant (tesla-ha) and the Volvo through its cloud. With two cars rule 3 +// cannot answer, so only the cars' own word or a tap names one. +func twoCars(t *testing.T) (*harness, protocol.Car, protocol.Car) { + t.Helper() + h := newHarness(t, homeConfig(), nil, nil) + h.start() + tesla := h.only() + if _, err := h.rt.UpdateCar(tesla.ID, Update{Sources: &[]string{"tesla-ha"}}); err != nil { + t.Fatal(err) + } + volvo := h.add(protocol.Car{Name: "Volvo", CapacityWh: 40000, Sources: []string{"volvo-cloud"}}) + return h, tesla, volvo +} + +// says is a vehicle driver's report of its car's cable and place, made now. +func (h *harness) says(driver string, plugged bool, since time.Time, atSite *bool) { + h.obs[driver] = telemetry.VehicleObservation{Driver: driver, SoC: .4, At: h.now, + Plugged: boolp(plugged), PluggedSince: since, AtSite: atSite, ReportedAt: h.now} +} + +// A car whose source reports minutes after the plug-in still names itself +// when its own clock puts the cable within a minute of it, even as the +// source's first report since the box started. +func TestTheCarsOwnClockSaysWhenItsCableWentIn(t *testing.T) { + h, tesla, _ := twoCars(t) + h.plug("easee", "s1") + plugAt := h.now + h.tick(90 * time.Second) + if st := h.state("easee"); st.CarID != "" || len(h.asked) != 1 { + t.Fatalf("with no word from a car the box asks: %+v asked %d", st, len(h.asked)) + } + h.says("tesla-ha", true, plugAt.Add(5*time.Second), boolp(true)) + h.tick(time.Second) + if st := h.state("easee"); st.CarID != tesla.ID || st.CarIdentifiedBy != protocol.CarIdentifiedByCar { + t.Fatalf("the car's own clock: %+v", st) + } +} + +// Two cars that both say they were plugged in within the minute are no +// answer, whether or not they know where they are: the box asks. +func TestTwoCarsClaimingThePlugInIsNoAnswer(t *testing.T) { + for _, atSite := range []*bool{boolp(true), nil} { + h, _, _ := twoCars(t) + h.says("tesla-ha", false, time.Time{}, atSite) + h.says("volvo-cloud", false, time.Time{}, atSite) + h.tick(time.Second) + h.plug("easee", "s1") + h.says("tesla-ha", true, h.now.Add(10*time.Second), atSite) + h.says("volvo-cloud", true, h.now.Add(20*time.Second), atSite) + h.tick(20 * time.Second) + if st := h.state("easee"); st.CarID != "" || st.CarIdentifiedBy == protocol.CarIdentifiedByCar { + t.Fatalf("at_site %v: two claims named a car: %+v", atSite, st) + } + h.tick(time.Minute) + if st := h.state("easee"); st.CarIdentifiedBy != protocol.CarIdentifiedByAsking || len(h.asked) != 1 { + t.Fatalf("at_site %v: the box should ask: %+v asked %d", atSite, st, len(h.asked)) + } + } +} + +// A car that says it is somewhere else is never named, even when it says +// its cable went in at the same minute and it is the only car. +func TestACarAwayIsNeverNamed(t *testing.T) { + h := newHarness(t, homeConfig(), nil, nil) + h.start() + car := h.only() + if _, err := h.rt.UpdateCar(car.ID, Update{Sources: &[]string{"tesla-ha"}}); err != nil { + t.Fatal(err) + } + h.says("tesla-ha", false, time.Time{}, boolp(false)) + h.tick(time.Second) + h.plug("easee", "s1") + h.says("tesla-ha", true, h.now.Add(5*time.Second), boolp(false)) + for i := 0; i < 4; i++ { + h.tick(30 * time.Second) + if st := h.state("easee"); st.CarID == car.ID { + t.Fatalf("a car away was named: %+v", st) + } + } + if st := h.state("easee"); st.CarIdentifiedBy != protocol.CarIdentifiedByNone { + t.Fatalf("the only car is away, so this is a visitor: %+v", st) + } +} + +// A car that does not know where it is counts only by the minute: a cable +// that went in five minutes before this plug-in is another plug-in. +func TestACarThatDoesNotKnowWhereItIsCountsOnlyByTheMinute(t *testing.T) { + h, tesla, _ := twoCars(t) + h.plug("easee", "s1") + h.says("tesla-ha", true, h.now.Add(-5*time.Minute), nil) + h.tick(90 * time.Second) + if st := h.state("easee"); st.CarID != "" || st.CarIdentifiedBy != protocol.CarIdentifiedByAsking { + t.Fatalf("an old plug-in named the car: %+v", st) + } + h.unplug("easee") + h.says("tesla-ha", false, h.now, nil) + h.tick(time.Second) + h.plug("easee", "s2") + h.says("tesla-ha", true, h.now.Add(15*time.Second), nil) + h.tick(20 * time.Second) + if st := h.state("easee"); st.CarID != tesla.ID || st.CarIdentifiedBy != protocol.CarIdentifiedByCar { + t.Fatalf("a fresh plug-in with no place: %+v", st) + } +} diff --git a/go/internal/cars/tick.go b/go/internal/cars/tick.go index 4daf3d70..6dd2762a 100644 --- a/go/internal/cars/tick.go +++ b/go/internal/cars/tick.go @@ -259,9 +259,18 @@ func (r *Runtime) ingestLocked(obs []telemetry.VehicleObservation, states []load r.sources[o.Driver] = ss } plugged := pluggedEvidence(o) - if !plugged { + switch { + case !plugged: ss.pluggedSince = time.Time{} - } else if !ss.plugged && ss.seen { + case o.Plugged != nil && !o.PluggedSince.IsZero(): + // The car's own clock says when its cable went in, which a + // source that reports minutes apart, or a box that just + // started, cannot see for itself. + ss.pluggedSince = o.PluggedSince + if ss.pluggedSince.After(now) { + ss.pluggedSince = now + } + case !ss.plugged && ss.seen: ss.pluggedSince = o.ReportedAt if ss.pluggedSince.IsZero() || ss.pluggedSince.After(now) { ss.pluggedSince = now diff --git a/go/internal/drivers/host.go b/go/internal/drivers/host.go index 4bb01f60..a94ee575 100644 --- a/go/internal/drivers/host.go +++ b/go/internal/drivers/host.go @@ -190,6 +190,12 @@ type HostEnv struct { // The host binds this to hardware identity, independent of the driver name. ReserveVehicleWake func(makeName, serial string) (bool, time.Duration, error) + // SiteLocation returns the site's latitude and longitude, or false when + // the site has none. host.site_location() gives it only to a vehicle + // driver, which turns its car's position into site_distance_m so the + // car's coordinates never leave the driver. + SiteLocation func() (lat, lon float64, ok bool) + // 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. diff --git a/go/internal/drivers/lua.go b/go/internal/drivers/lua.go index 31a4749b..1b1593a4 100644 --- a/go/internal/drivers/lua.go +++ b/go/internal/drivers/lua.go @@ -167,21 +167,26 @@ func driverDeclaresReadOnly(L *lua.LState) bool { } func driverDeclaresReadOnlyBattery(L *lua.LState) bool { - if !driverDeclaresReadOnly(L) { + return driverDeclaresReadOnly(L) && driverDeclaresCapability(L, "battery") +} + +// driverDeclaresCapability reports whether the DRIVER table lists capability. +func driverDeclaresCapability(L *lua.LState, capability string) bool { + meta, ok := L.GetGlobal("DRIVER").(*lua.LTable) + if !ok { return false } - meta := L.GetGlobal("DRIVER").(*lua.LTable) caps, ok := meta.RawGetString("capabilities").(*lua.LTable) if !ok { return false } - hasBattery := false + found := false caps.ForEach(func(_, value lua.LValue) { - if strings.EqualFold(value.String(), "battery") { - hasBattery = true + if strings.EqualFold(value.String(), capability) { + found = true } }) - return hasBattery + return found } // Init calls driver_init(config) if defined. config is the optional @@ -648,6 +653,23 @@ func registerHost(L *lua.LState, env *HostEnv) { L.Push(lua.LNumber(time.Now().UnixMilli())) return 1 })) + // The site's coordinates, for a vehicle driver only: it measures its + // car's distance from the site and emits site_distance_m. Other drivers + // talk to vendor clouds that have no use for where the home is. + host.RawSetString("site_location", L.NewFunction(func(L *lua.LState) int { + if env.SiteLocation == nil || !driverDeclaresCapability(L, "vehicle") { + L.Push(lua.LNil) + return 1 + } + lat, lon, ok := env.SiteLocation() + if !ok { + L.Push(lua.LNil) + return 1 + } + L.Push(lua.LNumber(lat)) + L.Push(lua.LNumber(lon)) + return 2 + })) 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) && diff --git a/go/internal/drivers/lua_site_location_test.go b/go/internal/drivers/lua_site_location_test.go new file mode 100644 index 00000000..78a1aad4 --- /dev/null +++ b/go/internal/drivers/lua_site_location_test.go @@ -0,0 +1,57 @@ +package drivers + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// host.site_location gives the site's coordinates to a vehicle driver only, +// and nil when the site has none. +func TestLuaSiteLocationIsForVehicleDriversOnly(t *testing.T) { + for _, tc := range []struct { + name, caps string + set, ok bool + want string + }{ + {"vehicle", `{ "vehicle" }`, true, true, "59.5,17.25"}, + {"vehicle on a site with no position", `{ "vehicle" }`, true, false, "nil"}, + {"vehicle on a host without it", `{ "vehicle" }`, false, false, "nil"}, + {"a charger", `{ "ev" }`, true, true, "nil"}, + } { + t.Run(tc.name, func(t *testing.T) { + source := `DRIVER = { capabilities = ` + tc.caps + ` } +function driver_init(config) end +function driver_poll() + local lat, lon = host.site_location() + if lat == nil then SITE = "nil" else SITE = lat .. "," .. lon end +end +function driver_default_mode() end` + path := filepath.Join(t.TempDir(), "car.lua") + if err := os.WriteFile(path, []byte(source), 0600); err != nil { + t.Fatal(err) + } + env := NewHostEnv("car", telemetry.NewStore()) + if tc.set { + env.SiteLocation = func() (float64, float64, bool) { return 59.5, 17.25, tc.ok } + } + d, err := NewLuaDriver(path, env) + if err != nil { + t.Fatal(err) + } + defer d.Cleanup() + if err := d.Init(context.Background(), nil); err != nil { + t.Fatal(err) + } + if _, err := d.Poll(context.Background()); err != nil { + t.Fatal(err) + } + if got := d.L.GetGlobal("SITE").String(); got != tc.want { + t.Fatalf("site_location %s, want %s", got, tc.want) + } + }) + } +} diff --git a/go/internal/drivers/registry.go b/go/internal/drivers/registry.go index f8e0d412..a1082b6a 100644 --- a/go/internal/drivers/registry.go +++ b/go/internal/drivers/registry.go @@ -103,6 +103,9 @@ type Registry struct { // restart. Returns ("", false) when no override exists. SecretOverride func(owner, key string) (string, bool) VehicleWakeReservation func(makeName, serial string) (bool, time.Duration, error) + // SiteLocation, when set, gives vehicle drivers the site's coordinates + // (HostEnv.SiteLocation). Wired by main.go to the live config. + SiteLocation func() (lat, lon float64, ok bool) // RuntimePolicyResolver returns the verified signed policy of a managed // read-only artifact. Nil means bundled, local and control-capable // signed drivers, which run without one. @@ -547,6 +550,7 @@ func (r *Registry) add(ctx context.Context, cfg config.Driver, startupDefault bo } return r.VehicleWakeReservation(makeName, serial) } + env.SiteLocation = r.SiteLocation secretOwner := cfg.SecretOwner() env.PersistSecret = func(key, value string) error { if r.SecretPersister == nil { diff --git a/go/internal/telemetry/vehicle.go b/go/internal/telemetry/vehicle.go index edc009c9..dc8dcfd4 100644 --- a/go/internal/telemetry/vehicle.go +++ b/go/internal/telemetry/vehicle.go @@ -16,6 +16,48 @@ import ( // car's level: a level stays the car's until energy flows (docs/cars.md). const VehicleMaxAge = 5 * time.Minute +// A vehicle driver may give the car's distance from the site instead of +// at_site. Within SiteNearM the car is here; beyond SiteAwayM it is +// elsewhere; between, the box does not know. The gap absorbs a site pin set +// by a click on a zoomed-out map and a parked car's drifting GPS fix: a car +// that wrongly reads as elsewhere turns the only car into a visitor +// (docs/cars.md), which is worse than not knowing. +const ( + SiteNearM = 300.0 + SiteAwayM = 1000.0 +) + +// atSiteFromDistance turns a distance from the site into the car's at_site. +// Nil is unknown, as is a distance that is not a finite, non-negative number. +func atSiteFromDistance(m *float64) *bool { + if m == nil || math.IsNaN(*m) || math.IsInf(*m, 0) || *m < 0 { + return nil + } + switch { + case *m <= SiteNearM: + v := true + return &v + case *m > SiteAwayM: + v := false + return &v + } + return nil +} + +// pluggedSinceFrom reads plugged_since_ms, the unix ms at which the car's +// source saw its cable take the state plugged says. Zero is unknown, as is a +// time before 2020 or more than a day ahead of now. +func pluggedSinceFrom(ms *float64, now time.Time) time.Time { + if ms == nil || math.IsNaN(*ms) || math.IsInf(*ms, 0) { + return time.Time{} + } + t := time.UnixMilli(int64(*ms)) + if t.Before(time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)) || t.After(now.Add(24*time.Hour)) { + return time.Time{} + } + return t +} + // VehicleConnectedRank scores how likely a DerVehicle driver is to be // the one physically plugged into the loadpoint right now, using the // charging_state vocabulary every vehicle driver normalizes to (the @@ -81,9 +123,14 @@ type VehicleObservation struct { // level may anchor an estimate, but its charging state is not live. Stale bool // Plugged and AtSite are the car's own word on its cable and place, - // when its driver gives them. Nil is unknown. + // when its driver gives them. Nil is unknown. AtSite comes from + // at_site, or else from site_distance_m. Plugged *bool AtSite *bool + // PluggedSince is when the car's source saw its cable take the state + // Plugged says, by its own clock: Home Assistant's last_changed, or the + // first reading that showed it. Zero is unknown. + PluggedSince time.Time // ReportedAt is when the driver last reported anything, which can be // later than At: a cable changes without a new level. ReportedAt time.Time @@ -122,16 +169,26 @@ func VehicleObservations(s *Store) []VehicleObservation { continue } var meta struct { - ChargingState string `json:"charging_state"` - ChargeLimit float64 `json:"charge_limit"` - ChargeLimitPct float64 `json:"charge_limit_pct"` - Stale bool `json:"stale"` - Plugged *bool `json:"plugged"` - AtSite *bool `json:"at_site"` + ChargingState string `json:"charging_state"` + ChargeLimit float64 `json:"charge_limit"` + ChargeLimitPct float64 `json:"charge_limit_pct"` + Stale bool `json:"stale"` + Plugged *bool `json:"plugged"` + PluggedSince *float64 `json:"plugged_since_ms"` + AtSite *bool `json:"at_site"` + SiteDistanceM *float64 `json:"site_distance_m"` } if len(vr.Data) > 0 { _ = json.Unmarshal(vr.Data, &meta) } + atSite := meta.AtSite + if atSite == nil { + atSite = atSiteFromDistance(meta.SiteDistanceM) + } + var since time.Time + if meta.Plugged != nil { + since = pluggedSinceFrom(meta.PluggedSince, vr.UpdatedAt) + } out = append(out, VehicleObservation{ Driver: vr.Driver, SoC: units.ClampFraction(*socValue), @@ -140,7 +197,8 @@ func VehicleObservations(s *Store) []VehicleObservation { At: at, Stale: meta.Stale || cached, Plugged: meta.Plugged, - AtSite: meta.AtSite, + AtSite: atSite, + PluggedSince: since, ReportedAt: vr.UpdatedAt, }) } diff --git a/go/internal/telemetry/vehicle_test.go b/go/internal/telemetry/vehicle_test.go index 48848b09..708ef2b3 100644 --- a/go/internal/telemetry/vehicle_test.go +++ b/go/internal/telemetry/vehicle_test.go @@ -254,3 +254,49 @@ func TestVehicleObservationsCarryPluggedAndAtSite(t *testing.T) { t.Fatalf("plugged/at_site %+v", o) } } + +// A car says where its cable and it are: plugged, the time its source saw +// the cable go in, and at_site or a distance the box reads with a gap. +func TestVehicleObservationsReadPlugAndPlace(t *testing.T) { + since := time.Now().Add(-20 * time.Second).UnixMilli() + for _, tc := range []struct { + name string + data map[string]any + wantSite *bool + wantSince bool + }{ + {"at_site", map[string]any{"plugged": true, "plugged_since_ms": since, "at_site": true}, ptrBool(true), true}, + {"near", map[string]any{"plugged": true, "site_distance_m": 120}, ptrBool(true), false}, + {"between", map[string]any{"plugged": true, "site_distance_m": 600}, nil, false}, + {"away", map[string]any{"plugged": true, "site_distance_m": 4200}, ptrBool(false), false}, + {"at_site wins", map[string]any{"at_site": true, "site_distance_m": 4200}, ptrBool(true), false}, + {"a negative distance is unknown", map[string]any{"site_distance_m": -5}, nil, false}, + {"a time with no plug flag is ignored", map[string]any{"plugged_since_ms": since}, nil, false}, + {"a time before 2020 is unknown", map[string]any{"plugged": true, "plugged_since_ms": 1000}, nil, false}, + {"a time days ahead is unknown", map[string]any{"plugged": true, + "plugged_since_ms": time.Now().Add(72 * time.Hour).UnixMilli()}, nil, false}, + } { + t.Run(tc.name, func(t *testing.T) { + s := NewStore() + soc := 0.5 + data, _ := json.Marshal(tc.data) + s.Update("car", DerVehicle, 0, &soc, data) + s.DriverHealthMut("car").RecordSuccess() + o, ok := observation(t, s, "car") + if !ok { + t.Fatal("no observation") + } + if (o.AtSite == nil) != (tc.wantSite == nil) || (o.AtSite != nil && *o.AtSite != *tc.wantSite) { + t.Fatalf("at_site %v, want %v", o.AtSite, tc.wantSite) + } + if got := !o.PluggedSince.IsZero(); got != tc.wantSince { + t.Fatalf("plugged since %v, want set=%v", o.PluggedSince, tc.wantSince) + } + if tc.wantSince && o.PluggedSince.UnixMilli() != since { + t.Fatalf("plugged since %v, want %d", o.PluggedSince, since) + } + }) + } +} + +func ptrBool(v bool) *bool { return &v } From 69c6fae193afc810c3925772564a61dabe8d0575 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 08:10:40 +0200 Subject: [PATCH 2/3] fix(cars): a reading from before the plug-in does not make the only car a visitor Rule 3's "not here" took the time a report arrived. A source that replays a reading from before the plug-in, or one that has not read the car since it came home, would then say "cable out" or "elsewhere" within the minute, and the only car would become a visitor for the whole session. No driver emitted plugged before; the Home Assistant, Tesla, TeslaMate and VAG drivers now do. The reading's own time counts instead. Co-Authored-By: Claude Opus 5.5 --- docs/cars.md | 7 ++++--- go/internal/cars/carsays_test.go | 29 +++++++++++++++++++++++++++++ go/internal/cars/tick.go | 11 +++++++---- 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/docs/cars.md b/docs/cars.md index f72f2f34..d90ce54b 100644 --- a/docs/cars.md +++ b/docs/cars.md @@ -119,9 +119,10 @@ At each plug-in the box asks, in this order (`carIdentifiedBy`): old settings has that charger as its usual one, and a plug-in there names it when no other car that could be here claims the charger, that is when each of them has a usual charger elsewhere. A car with none, as one an - owner added since, could be on any charger, and the box asks. If the car's source says, within a minute of the - plug-in, `at_site` false or that its cable is out, the session is a - visitor's (`none`). One car is on one charger at a time: rules 2 and 3 + owner added since, could be on any charger, and the box asks. If the car's source read the car within a minute after + the plug-in and found `at_site` false or its cable out, the session is a + visitor's (`none`). A reading from before the plug-in does not count, + however late it arrives. One car is on one charger at a time: rules 2 and 3 skip a car another session has. 4. `tap`, after `asking`: with more than one car, the box waits a minute for rules 1 and 2, then raises `car.which` once per plug-in and says diff --git a/go/internal/cars/carsays_test.go b/go/internal/cars/carsays_test.go index db7f49eb..5ac1499a 100644 --- a/go/internal/cars/carsays_test.go +++ b/go/internal/cars/carsays_test.go @@ -113,3 +113,32 @@ func TestACarThatDoesNotKnowWhereItIsCountsOnlyByTheMinute(t *testing.T) { t.Fatalf("a fresh plug-in with no place: %+v", st) } } + +// A source that replays a reading from before the plug-in has not seen it: +// its old "cable out" or "elsewhere" does not make the only car a visitor. +// A reading made after the plug-in does. +func TestAReadingFromBeforeThePlugInSaysNothingAboutIt(t *testing.T) { + h := newHarness(t, homeConfig(), nil, nil) + h.start() + car := h.only() + if _, err := h.rt.UpdateCar(car.ID, Update{Sources: &[]string{"tesla-ha"}}); err != nil { + t.Fatal(err) + } + readAt := h.now + h.tick(10 * time.Minute) + h.plug("easee", "s1") + // Home Assistant has not read the car since it came home. + h.obs["tesla-ha"] = telemetry.VehicleObservation{Driver: "tesla-ha", SoC: .4, At: readAt, + Plugged: boolp(false), AtSite: boolp(false), Stale: true, ReportedAt: h.now.Add(10 * time.Second)} + h.tick(10 * time.Second) + if st := h.state("easee"); st.CarID != car.ID || st.CarIdentifiedBy != protocol.CarIdentifiedByOnly { + t.Fatalf("an old reading turned the only car into a visitor: %+v", st) + } + // Then it reads the car, 20 s after the plug-in: elsewhere. + h.obs["tesla-ha"] = telemetry.VehicleObservation{Driver: "tesla-ha", SoC: .4, At: h.now.Add(10 * time.Second), + Plugged: boolp(false), AtSite: boolp(false), ReportedAt: h.now.Add(10 * time.Second)} + h.tick(10 * time.Second) + if st := h.state("easee"); st.CarID != "" || st.CarIdentifiedBy != protocol.CarIdentifiedByNone { + t.Fatalf("a reading after the plug-in says the car is elsewhere: %+v", st) + } +} diff --git a/go/internal/cars/tick.go b/go/internal/cars/tick.go index 6dd2762a..52246a2c 100644 --- a/go/internal/cars/tick.go +++ b/go/internal/cars/tick.go @@ -496,9 +496,12 @@ func (r *Runtime) candidatesLocked(lpID string) []*Car { return out } -// notHereLocked reports whether one of the car's sources said, within -// IdentifyWindow of the plug-in, that the car is not here: somewhere else, -// or with its cable out. +// notHereLocked reports whether one of the car's sources read the car within +// IdentifyWindow after the plug-in and found it not here: somewhere else, or +// with its cable out. The reading's own time counts, not when it arrived: a +// source that replays a reading from before the plug-in, or reads the car +// minutes apart, has not seen this plug-in, and its old "cable out" would +// make the only car a visitor. func (r *Runtime) notHereLocked(car *Car, s *session) (time.Time, bool) { plugAt := time.UnixMilli(s.PluggedMs) for _, d := range car.Sources { @@ -507,7 +510,7 @@ func (r *Runtime) notHereLocked(car *Car, s *session) (time.Time, bool) { continue } o := ss.obs - at := o.ReportedAt + at := o.At if at.Before(plugAt) || at.Sub(plugAt) > IdentifyWindow { continue } From 250c7879c38420fe7df8025b3521a5e1ba65aae0 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 10:13:22 +0200 Subject: [PATCH 3/3] fix(cars): the review of the car's own word - Rule 2 gives no cable answer when another charger saw a plug-in within the same minute: the car could be on either. Rule 2 is per car: one source that says the car is elsewhere rules it out. - A change the box saw between readings more than a minute apart has no time. A car read elsewhere after the plug-in is no rule 3 candidate. - A tap over the box's own visitor verdict is final. - site_distance_m reads as elsewhere only beyond 5 km: the site is a forecast pin. - host.site_location answers only a driver whose signed catalog entry lists vehicle; RuntimePolicy carries the signed capabilities. - Home Assistant's vehicle_json leaves out at_site, site_distance_m and plugged_since_ms. - The driver card shows the stored level x100 and reads charge_limit as a fraction. - testdata copies of the Home Assistant, TeslaMate and Fleet API drivers from srcfl/device-drivers#182 run end to end in this host. Co-Authored-By: Claude Opus 5.5 --- .changeset/car-says-plugged-here.md | 2 +- docs/cars.md | 26 +- docs/writing-a-driver.md | 20 +- go/internal/api/api.go | 20 +- go/internal/api/api_vehicle_card_test.go | 58 ++ go/internal/cars/carsays_test.go | 117 ++++ go/internal/cars/ops.go | 4 +- go/internal/cars/tick.go | 92 ++- go/internal/driverrepo/manager_test.go | 3 +- go/internal/driverrepo/runtime_policy.go | 1 + go/internal/drivers/lua.go | 7 +- go/internal/drivers/lua_site_location_test.go | 34 +- go/internal/drivers/runtime_policy.go | 16 + .../testdata/home_assistant_vehicle.lua | 331 +++++++++ go/internal/drivers/testdata/tesla_cloud.lua | 640 ++++++++++++++++++ .../drivers/testdata/teslamate_vehicle.lua | 529 +++++++++++++++ go/internal/drivers/vehicle_plugged_test.go | 189 ++++++ go/internal/ha/bridge.go | 37 +- go/internal/ha/bridge_test.go | 21 + go/internal/telemetry/vehicle.go | 11 +- go/internal/telemetry/vehicle_test.go | 5 +- 21 files changed, 2071 insertions(+), 92 deletions(-) create mode 100644 go/internal/api/api_vehicle_card_test.go create mode 100644 go/internal/drivers/testdata/home_assistant_vehicle.lua create mode 100644 go/internal/drivers/testdata/tesla_cloud.lua create mode 100644 go/internal/drivers/testdata/teslamate_vehicle.lua create mode 100644 go/internal/drivers/vehicle_plugged_test.go diff --git a/.changeset/car-says-plugged-here.md b/.changeset/car-says-plugged-here.md index 430b442e..92382838 100644 --- a/.changeset/car-says-plugged-here.md +++ b/.changeset/car-says-plugged-here.md @@ -2,4 +2,4 @@ "ftw": minor --- -A car can say it was plugged in here. Vehicle drivers may report when their car's cable went in (`plugged_since_ms`) and how far the car is from the site (`site_distance_m`), and `host.site_location()` gives a vehicle driver the site's position so the car's coordinates stay in the driver. The box names a car by its own word even when the report arrives late, and never names a car that says it is somewhere else. +A car can say it was plugged in here. Vehicle drivers may report when their source read the car's cable go in (`plugged_since_ms`) and how far the car is from the site (`site_distance_m`; up to 300 m is here, over 5 km is elsewhere). `host.site_location()` gives the site's position to a signed vehicle driver, so the car's coordinates stay in the driver; Home Assistant's `vehicle_json` leaves out where the car is. The box names a car by its own word even when the report arrives late, but not when two chargers saw a plug-in in the same minute, and never a car that was read elsewhere after the plug-in. A reading from before the plug-in no longer makes the only car a visitor, and a tap over the box's visitor verdict stays. The driver card shows a car's level in percent when the driver sends a fraction. diff --git a/docs/cars.md b/docs/cars.md index d90ce54b..304886ac 100644 --- a/docs/cars.md +++ b/docs/cars.md @@ -108,21 +108,27 @@ At each plug-in the box asks, in this order (`carIdentifiedBy`): it is charging at this site (`at_site` true) while this charger alone delivers. A source says so with `plugged` and `at_site` (or `site_distance_m`) in its `vehicle` emit, or with a charging state that - needs a cable. When the source gives `plugged_since_ms`, that is when the - cable went in; otherwise it is when the box first saw the source say so. - A source that does not know where the car is counts only by that minute. - Two cars saying so is no answer. The fields are in + needs a cable. The time that counts is when the source read the cable + change: its `plugged_since_ms`, or else when the box first saw the source + say so, if the source's reading before that was less than a minute + older. A change seen between readings further apart has no time and does + not count. A source that does not know where the car is counts only by + that minute. Two cars saying so is no answer, and so is a cable time + when another charger saw a plug-in within the same minute: the car could + be on either. One source of a car that says it is elsewhere rules the + car out. The fields are in [writing-a-driver.md](writing-a-driver.md#which-car-is-plugged-in). -3. `only`: exactly one car could be here: the box knows one car that is not - on another charger, and it has not said it is somewhere else. The car's +3. `only`: exactly one car could be here: one car is not on another + charger and has not been read elsewhere since the plug-in. The car's usual charger counts the same way: a car the box made from a charger's old settings has that charger as its usual one, and a plug-in there names it when no other car that could be here claims the charger, that is when each of them has a usual charger elsewhere. A car with none, as one an - owner added since, could be on any charger, and the box asks. If the car's source read the car within a minute after - the plug-in and found `at_site` false or its cable out, the session is a - visitor's (`none`). A reading from before the plug-in does not count, - however late it arrives. One car is on one charger at a time: rules 2 and 3 + owner added since, could be on any charger, and the box asks. A car whose + source read it within a minute after the plug-in and found `at_site` + false or its cable out is not one that could be here; when that leaves + no car, the session is a visitor's (`none`). A reading from before the + plug-in does not count, however late it arrives. One car is on one charger at a time: rules 2 and 3 skip a car another session has. 4. `tap`, after `asking`: with more than one car, the box waits a minute for rules 1 and 2, then raises `car.which` once per plug-in and says diff --git a/docs/writing-a-driver.md b/docs/writing-a-driver.md index d0d99d91..a91d6e0e 100644 --- a/docs/writing-a-driver.md +++ b/docs/writing-a-driver.md @@ -243,13 +243,19 @@ optional; leave out what the source does not know. | Field | Meaning | |---|---| | `plugged` | The car's own word on its charge cable: `true` in, `false` out. | -| `plugged_since_ms` | Unix ms at which the source saw the cable take that state: the source's own change time (Home Assistant's `last_changed`), else the first reading that showed it. Leave it out when the driver has not seen a change, as at its first reading. | +| `plugged_since_ms` | Unix ms at which the source saw the cable take that state. Send it only when the source reads the cable at least every 30 seconds, and leave it out until the driver has seen a change. The time is when the source read the change, not the plug-in itself: Home Assistant's `last_changed` is when Home Assistant read it. | | `at_site` | `true` when the car is at this site, `false` when it is somewhere else, from the source's own idea of home. | -| `site_distance_m` | The car's distance from the site in metres. Core reads up to 300 m as here and over 1 km as elsewhere, and does not know in between. `at_site` wins when both are given. | +| `site_distance_m` | The car's distance from the site in metres. Core reads up to 300 m as here and over 5 km as elsewhere, and does not know in between: the site's position is a weather pin. `at_site` wins when both are given. | `host.site_location()` returns the site's `lat, lon`, or `nil` when the site -has no position (no weather pin, or the pin nobody moved). Only a driver whose -`DRIVER.capabilities` lists `vehicle` gets it. Measure the distance in the -driver and emit only `site_distance_m`, so the car's coordinates stay there. -Read the cable and the place at the driver's own cadence: never wake a car -only to learn them. +has no position (no weather pin, or the pin nobody moved). Only a driver from +the signed channel whose signed catalog entry lists `vehicle` gets it; a +bundled or local file gets `nil`, whatever its own `DRIVER` table says. +Measure the distance in the driver and emit only `site_distance_m`, so the +car's coordinates stay there. Prefer the source's own idea of home (a Home +Assistant zone, a TeslaMate geofence) as `at_site`: the pin is set for +forecasts. Read the cable and the place at the driver's own cadence: never +wake a car only to learn them. + +Send a vehicle `soc` as a percent, except 1 % or less, which goes as a +fraction: Core reads 1 or less as a fraction, so a plain `1` is 100 %. diff --git a/go/internal/api/api.go b/go/internal/api/api.go index d2aa2c45..7baaac9c 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -903,6 +903,7 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { if r := s.deps.Tel.Get(name, telemetry.DerVehicle); r != nil { var v struct { SoC *float64 `json:"soc"` + ChargeLimit *float64 `json:"charge_limit"` ChargeLimitPct *float64 `json:"charge_limit_pct"` ChargingState *string `json:"charging_state"` TimeToFullMin *int `json:"time_to_full_min"` @@ -911,11 +912,22 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { Stale *bool `json:"stale"` } if r.Data != nil && json.Unmarshal(r.Data, &v) == nil { - if v.SoC != nil { - d["vehicle_soc"] = *v.SoC + // Drivers send a fraction or a percent; the store holds the + // fraction, and the card shows a percent. + if r.SoC != nil { + d["vehicle_soc"] = units.PercentFromFraction(*r.SoC) + } else if v.SoC != nil { + d["vehicle_soc"] = units.PercentFromFraction(units.FractionFromLegacyPercent(*v.SoC)) } - if v.ChargeLimitPct != nil { - d["vehicle_charge_limit_pct"] = *v.ChargeLimitPct + if v.ChargeLimit != nil || v.ChargeLimitPct != nil { + var canonical, legacy float64 + if v.ChargeLimit != nil { + canonical = *v.ChargeLimit + } + if v.ChargeLimitPct != nil { + legacy = *v.ChargeLimitPct + } + d["vehicle_charge_limit_pct"] = units.PercentFromFraction(units.DecodeJSONFraction(canonical, legacy)) } if v.ChargingState != nil { d["vehicle_charging_state"] = *v.ChargingState diff --git a/go/internal/api/api_vehicle_card_test.go b/go/internal/api/api_vehicle_card_test.go new file mode 100644 index 00000000..99d3e5a3 --- /dev/null +++ b/go/internal/api/api_vehicle_card_test.go @@ -0,0 +1,58 @@ +package api + +import ( + "encoding/json" + "math" + "net/http/httptest" + "path/filepath" + "sync" + "testing" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/control" + "github.com/srcfl/ftw/go/internal/state" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// A driver card shows a car's level and limit in percent, whether the +// driver sends fractions (the Home Assistant driver) or percents (Tesla's). +func TestTheDriverCardShowsAVehicleLevelInPercent(t *testing.T) { + st, err := state.Open(filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + tel := telemetry.NewStore() + for name, data := range map[string]string{ + "tesla-ha": `{"soc":0.14,"charge_limit":0.8,"soc_fresh":true}`, + "tesla-cloud": `{"soc":14,"charge_limit_pct":80,"soc_fresh":true}`, + } { + var v struct { + SoC float64 `json:"soc"` + } + _ = json.Unmarshal([]byte(data), &v) + soc := v.SoC + if soc > 1 { + soc /= 100 + } + tel.Update(name, telemetry.DerVehicle, 0, &soc, json.RawMessage(data)) + tel.DriverHealthMut(name).RecordSuccess() + } + srv := New(&Deps{State: st, Tel: tel, Ctrl: &control.State{}, CtrlMu: &sync.Mutex{}, CapMu: &sync.RWMutex{}, CfgMu: &sync.RWMutex{}, Cfg: &config.Config{}}) + rr := httptest.NewRecorder() + srv.handleStatus(rr, httptest.NewRequest("GET", "/api/status", nil)) + var resp struct { + Drivers map[string]map[string]any `json:"drivers"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + for _, name := range []string{"tesla-ha", "tesla-cloud"} { + d := resp.Drivers[name] + soc, _ := d["vehicle_soc"].(float64) + limit, _ := d["vehicle_charge_limit_pct"].(float64) + if math.Abs(soc-14) > 1e-9 || math.Abs(limit-80) > 1e-9 { + t.Errorf("%s card: soc %v limit %v, want 14 and 80", name, d["vehicle_soc"], d["vehicle_charge_limit_pct"]) + } + } +} diff --git a/go/internal/cars/carsays_test.go b/go/internal/cars/carsays_test.go index 5ac1499a..7ca8aa12 100644 --- a/go/internal/cars/carsays_test.go +++ b/go/internal/cars/carsays_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "github.com/srcfl/ftw/go/internal/loadpoint" "github.com/srcfl/ftw/go/internal/telemetry" "github.com/srcfl/ftw/go/protocol" ) @@ -142,3 +143,119 @@ func TestAReadingFromBeforeThePlugInSaysNothingAboutIt(t *testing.T) { t.Fatalf("a reading after the plug-in says the car is elsewhere: %+v", st) } } + +// Two plug-ins on two chargers within a minute: a cable time cannot say +// which charger a car is on. The review's case: the Volvo on the carport +// Easee, the Tesla on the garage charger 30 s later, and the Tesla's report +// arriving first. Matching by time alone named the Tesla on the Easee and +// the Volvo in the garage. Both chargers ask instead. +func TestTwoPlugInsWithinAMinuteAreNoCableAnswer(t *testing.T) { + h := newHarness(t, []loadpoint.Config{{ID: "easee", DriverName: "easee", VehicleCapacityWh: 76000}, + {ID: "garage", DriverName: "garage", VehicleCapacityWh: 40000}}, nil, nil) + h.start() + for _, c := range h.rt.Cars()[1:] { + if err := h.rt.Remove(c.ID); err != nil { + t.Fatal(err) + } + } + tesla := h.only() + if _, err := h.rt.UpdateCar(tesla.ID, Update{Sources: &[]string{"tesla-ha"}}); err != nil { + t.Fatal(err) + } + h.add(protocol.Car{Name: "Volvo", CapacityWh: 40000, Sources: []string{"volvo-cloud"}}) + h.says("tesla-ha", false, time.Time{}, boolp(true)) + h.says("volvo-cloud", false, time.Time{}, boolp(true)) + h.tick(time.Second) + + volvoIn := h.now + h.plug("easee", "s1") + h.tick(30 * time.Second) + teslaIn := h.now + h.mgr.ObserveSession("garage", true, 0, 0, true, "garage:G1", "g1") + h.rt.Tick() + h.tick(5 * time.Second) + h.says("tesla-ha", true, teslaIn.Add(2*time.Second), boolp(true)) + h.tick(5 * time.Second) + h.says("volvo-cloud", true, volvoIn.Add(2*time.Second), boolp(true)) + h.tick(5 * time.Second) + for _, lp := range []string{"easee", "garage"} { + if st := h.state(lp); st.CarID != "" || st.CarIdentifiedBy == protocol.CarIdentifiedByCar { + t.Fatalf("%s was named by a cable time: %+v", lp, st) + } + } + h.tick(time.Minute) + if a, b := h.state("easee"), h.state("garage"); a.CarIdentifiedBy != protocol.CarIdentifiedByAsking || + b.CarIdentifiedBy != protocol.CarIdentifiedByAsking { + t.Fatalf("both chargers should ask: easee %s, garage %s", a.CarIdentifiedBy, b.CarIdentifiedBy) + } +} + +// A tap over the box's own visitor verdict is final: the rule that found +// the only car elsewhere must not undo it at the next tick. +func TestATapOverTheBoxsVisitorVerdictIsFinal(t *testing.T) { + h := newHarness(t, homeConfig(), nil, nil) + h.start() + car := h.only() + if _, err := h.rt.UpdateCar(car.ID, Update{Sources: &[]string{"tesla-ha"}}); err != nil { + t.Fatal(err) + } + h.plug("easee", "s1") + h.says("tesla-ha", false, time.Time{}, boolp(false)) + h.tick(5 * time.Second) + if st := h.state("easee"); st.CarIdentifiedBy != protocol.CarIdentifiedByNone { + t.Fatalf("the only car said it is away: %+v", st) + } + if _, err := h.tap("easee", car.ID); err != nil { + t.Fatal(err) + } + for i := 0; i < 3; i++ { + h.tick(15 * time.Second) + // The source reads the car again, still within the minute, and + // still says elsewhere: newer than the tap. + h.says("tesla-ha", false, time.Time{}, boolp(false)) + h.tick(time.Second) + if st := h.state("easee"); st.CarID != car.ID || st.CarIdentifiedBy != protocol.CarIdentifiedByTap { + t.Fatalf("the tap was undone: %+v", st) + } + } +} + +// A car whose source read it elsewhere after the plug-in is no candidate: +// with two cars and one away, the other is the only car that can be here. +func TestACarAwayIsNoCandidate(t *testing.T) { + h, tesla, _ := twoCars(t) + h.plug("easee", "s1") + h.says("volvo-cloud", false, time.Time{}, boolp(false)) + h.tick(5 * time.Second) + if st := h.state("easee"); st.CarID != tesla.ID || st.CarIdentifiedBy != protocol.CarIdentifiedByOnly { + t.Fatalf("the Volvo is away, so the Tesla is the only car: %+v", st) + } + // Both away: a visitor. + again, _, _ := twoCars(t) + again.plug("easee", "s1") + again.says("volvo-cloud", false, time.Time{}, boolp(false)) + again.says("tesla-ha", false, time.Time{}, boolp(false)) + again.tick(5 * time.Second) + if st := again.state("easee"); st.CarID != "" || st.CarIdentifiedBy != protocol.CarIdentifiedByNone { + t.Fatalf("both cars away is a visitor: %+v", st) + } +} + +// A source that saw the cable change between two readings more than a +// minute apart cannot date it: no cable answer, the box asks. +func TestAChangeBetweenReadingsFarApartHasNoTime(t *testing.T) { + for _, gap := range []time.Duration{30 * time.Second, 5 * time.Minute} { + h, tesla, _ := twoCars(t) + h.obs["tesla-ha"] = telemetry.VehicleObservation{Driver: "tesla-ha", SoC: .4, At: h.now, + ChargingState: "Disconnected", ReportedAt: h.now} + h.tick(gap - 10*time.Second) + h.plug("easee", "s1") + h.obs["tesla-ha"] = telemetry.VehicleObservation{Driver: "tesla-ha", SoC: .4, At: h.now.Add(5 * time.Second), + ChargingState: "Stopped", ReportedAt: h.now.Add(5 * time.Second)} + h.tick(10 * time.Second) + named := h.state("easee").CarID == tesla.ID + if want := gap <= time.Minute; named != want { + t.Fatalf("gap %v: named %v, want %v (%+v)", gap, named, want, h.state("easee")) + } + } +} diff --git a/go/internal/cars/ops.go b/go/internal/cars/ops.go index 893b4894..c464f1cc 100644 --- a/go/internal/cars/ops.go +++ b/go/internal/cars/ops.go @@ -429,8 +429,10 @@ func (r *Runtime) SetSessionCar(lpID, sessionID, carID string) (protocol.Charger r.mu.Unlock() return protocol.ChargerCar{}, ErrElsewhere } + // The box's own answer, a visitor's included, makes the tap final: a + // rule that answered once would otherwise undo it at the next tick. automatic := s.By == protocol.CarIdentifiedByCharger || s.By == protocol.CarIdentifiedByCar || - s.By == protocol.CarIdentifiedByOnly + s.By == protocol.CarIdentifiedByOnly || (s.By == protocol.CarIdentifiedByNone && s.Asked && !s.Tapped) by := protocol.CarIdentifiedByTap if carID == "" { by = protocol.CarIdentifiedByNone diff --git a/go/internal/cars/tick.go b/go/internal/cars/tick.go index 52246a2c..8c9abe82 100644 --- a/go/internal/cars/tick.go +++ b/go/internal/cars/tick.go @@ -270,7 +270,10 @@ func (r *Runtime) ingestLocked(obs []telemetry.VehicleObservation, states []load if ss.pluggedSince.After(now) { ss.pluggedSince = now } - case !ss.plugged && ss.seen: + case !ss.plugged && ss.seen && o.At.Sub(ss.obs.At) <= IdentifyWindow: + // The box saw the change between two readings less than a + // minute apart. Further apart, the cable could have gone in at + // any time between them, and the time stays unknown. ss.pluggedSince = o.ReportedAt if ss.pluggedSince.IsZero() || ss.pluggedSince.After(now) { ss.pluggedSince = now @@ -440,19 +443,18 @@ func (r *Runtime) answerLocked(st loadpoint.State, s *session, states []loadpoin if c, evidenceAt := r.carEvidenceLocked(st, s, states, now); c != "" { return answer{car: c, by: protocol.CarIdentifiedByCar, rank: identifiedRank[protocol.CarIdentifiedByCar], at: evidenceAt} } - if candidates := r.candidatesLocked(st.ID); len(candidates) == 1 { - only := candidates[0] - if at, away := r.notHereLocked(only, s); away { - return answer{by: protocol.CarIdentifiedByNone, rank: identifiedRank[protocol.CarIdentifiedByOnly], at: at, decisive: true} - } - return answer{car: only.ID, by: protocol.CarIdentifiedByOnly, rank: identifiedRank[protocol.CarIdentifiedByOnly], + candidates, awayAt := r.candidatesLocked(st.ID, s) + if len(candidates) == 1 { + return answer{car: candidates[0].ID, by: protocol.CarIdentifiedByOnly, rank: identifiedRank[protocol.CarIdentifiedByOnly], + at: time.UnixMilli(s.PluggedMs)} + } + if len(candidates) == 0 && !awayAt.IsZero() { + // Every car that could be here said it is not: a visitor. + return answer{by: protocol.CarIdentifiedByNone, rank: identifiedRank[protocol.CarIdentifiedByOnly], at: awayAt, decisive: true} + } + if usual := r.usualCarLocked(st.ID, candidates); usual != nil { + return answer{car: usual.ID, by: protocol.CarIdentifiedByOnly, rank: identifiedRank[protocol.CarIdentifiedByOnly], at: time.UnixMilli(s.PluggedMs)} - } else if usual := r.usualCarLocked(st.ID, candidates); usual != nil { - if _, away := r.notHereLocked(usual, s); !away { - return answer{car: usual.ID, by: protocol.CarIdentifiedByOnly, rank: identifiedRank[protocol.CarIdentifiedByOnly], - at: time.UnixMilli(s.PluggedMs)} - } - // The usual car is elsewhere, but another car may be here: ask. } return none } @@ -484,16 +486,24 @@ func (r *Runtime) onOtherChargerLocked(carID, lpID string) bool { return other != "" && other != lpID } -// candidatesLocked are the cars that could be on a charger: every car not on -// another charger. Rule 3 answers when there is exactly one. -func (r *Runtime) candidatesLocked(lpID string) []*Car { - var out []*Car +// candidatesLocked are the cars that could be on a charger's session: every +// car not on another charger whose source has not read it elsewhere, or with +// its cable out, since the plug-in. Rule 3 answers when there is exactly one. +// awayAt is the latest such reading, zero when no car was ruled out. +func (r *Runtime) candidatesLocked(lpID string, s *session) (out []*Car, awayAt time.Time) { for _, c := range r.rec.Cars { - if !r.onOtherChargerLocked(c.ID, lpID) { - out = append(out, c) + if r.onOtherChargerLocked(c.ID, lpID) { + continue } + if at, away := r.notHereLocked(c, s); away { + if at.After(awayAt) { + awayAt = at + } + continue + } + out = append(out, c) } - return out + return out, awayAt } // notHereLocked reports whether one of the car's sources read the car within @@ -522,22 +532,33 @@ func (r *Runtime) notHereLocked(car *Car, s *session) (time.Time, bool) { } // carEvidenceLocked is rule 2: a car's own source says it was plugged in -// within IdentifyWindow of this plug-in and not somewhere else, or says it is -// charging at this site while this charger alone delivers. Two cars saying -// so is no answer. +// within IdentifyWindow of this plug-in, or says it is charging at this site +// while this charger alone delivers, and none of the car's sources says it is +// somewhere else. Two cars saying so is no answer. Nor is a cable time when +// another charger saw a plug-in within the same window: a car whose cable +// went in then could be on either. func (r *Runtime) carEvidenceLocked(st loadpoint.State, s *session, states []loadpoint.State, now time.Time) (string, time.Time) { plugAt := time.UnixMilli(s.PluggedMs) delivering := st.CurrentPowerW >= loadpoint.DeliveringW + byCable := true for _, other := range states { - if other.ID != st.ID && other.PluggedIn && other.CurrentPowerW >= loadpoint.DeliveringW { + if other.ID == st.ID || !other.PluggedIn { + continue + } + if other.CurrentPowerW >= loadpoint.DeliveringW { delivering = false } + if os := r.rec.Sessions[other.ID]; os != nil && + absDuration(time.UnixMilli(os.PluggedMs).Sub(plugAt)) <= IdentifyWindow { + byCable = false + } } found, foundAt := "", time.Time{} for _, c := range r.rec.Cars { if r.onOtherChargerLocked(c.ID, st.ID) { continue } + claims, away, at := false, false, time.Time{} for _, d := range c.Sources { ss := r.sources[d] if ss == nil || !ss.seen { @@ -545,23 +566,24 @@ func (r *Runtime) carEvidenceLocked(st loadpoint.State, s *session, states []loa } o := ss.obs if o.AtSite != nil && !*o.AtSite { - continue + away = true + break } - var at time.Time switch { - case ss.plugged && !ss.pluggedSince.IsZero() && absDuration(ss.pluggedSince.Sub(plugAt)) <= IdentifyWindow: - at = ss.pluggedSince + case byCable && ss.plugged && !ss.pluggedSince.IsZero() && absDuration(ss.pluggedSince.Sub(plugAt)) <= IdentifyWindow: + claims, at = true, ss.pluggedSince case delivering && o.AtSite != nil && *o.AtSite && o.Live(now) && telemetry.VehicleConnectedRank(o.ChargingState) == 3: - at = now - default: - continue - } - if found != "" && found != c.ID { - return "", time.Time{} + claims, at = true, now } - found, foundAt = c.ID, at } + if !claims || away { + continue + } + if found != "" { + return "", time.Time{} + } + found, foundAt = c.ID, at } return found, foundAt } diff --git a/go/internal/driverrepo/manager_test.go b/go/internal/driverrepo/manager_test.go index b7d7f0a8..079059d7 100644 --- a/go/internal/driverrepo/manager_test.go +++ b/go/internal/driverrepo/manager_test.go @@ -199,6 +199,7 @@ func TestOfficialBetaChannelInstallsOneSignedDriver(t *testing.T) { fixture.manifest.Drivers[0].Metadata.ReadOnly = true fixture.manifest.Drivers[0].Metadata.AuthPostPath = "/oauth/token" fixture.manifest.Drivers[0].Metadata.ConfigSecrets = []string{"client_secret", "refresh_token"} + fixture.manifest.Drivers[0].Metadata.Capabilities = []string{"vehicle"} fixture.mu.Unlock() dir := t.TempDir() @@ -257,7 +258,7 @@ func TestOfficialBetaChannelInstallsOneSignedDriver(t *testing.T) { t.Helper() policy, err := m.RuntimePolicy(driverCfg) if err != nil || policy == nil || !policy.IsReadOnly() || policy.AuthPostPath != "/oauth/token" || - len(policy.ConfigSecrets) != 2 || policy.ConfigSecrets[1] != "refresh_token" { + len(policy.ConfigSecrets) != 2 || policy.ConfigSecrets[1] != "refresh_token" || !policy.Lists("vehicle") { t.Fatalf("installed beta OAuth policy = %+v, %v", policy, err) } } diff --git a/go/internal/driverrepo/runtime_policy.go b/go/internal/driverrepo/runtime_policy.go index 877b1ef0..e64622ac 100644 --- a/go/internal/driverrepo/runtime_policy.go +++ b/go/internal/driverrepo/runtime_policy.go @@ -271,5 +271,6 @@ func (m *Manager) directManifestRuntimePolicy( AuthPostPath: matched.Metadata.AuthPostPath, AuthPostPaths: append([]string(nil), matched.Metadata.AuthPostPaths...), ConfigSecrets: append([]string(nil), matched.Metadata.ConfigSecrets...), + Capabilities: append([]string(nil), matched.Metadata.Capabilities...), }, nil } diff --git a/go/internal/drivers/lua.go b/go/internal/drivers/lua.go index 1b1593a4..6132fa42 100644 --- a/go/internal/drivers/lua.go +++ b/go/internal/drivers/lua.go @@ -655,9 +655,12 @@ func registerHost(L *lua.LState, env *HostEnv) { })) // The site's coordinates, for a vehicle driver only: it measures its // car's distance from the site and emits site_distance_m. Other drivers - // talk to vendor clouds that have no use for where the home is. + // talk to vendor clouds that have no use for where the home is. What + // makes a driver a vehicle driver is its signed catalog entry: a Lua + // file's own DRIVER table could claim anything, so a bundled or local + // file gets nil. host.RawSetString("site_location", L.NewFunction(func(L *lua.LState) int { - if env.SiteLocation == nil || !driverDeclaresCapability(L, "vehicle") { + if env.SiteLocation == nil || !env.RuntimePolicy.Lists("vehicle") { L.Push(lua.LNil) return 1 } diff --git a/go/internal/drivers/lua_site_location_test.go b/go/internal/drivers/lua_site_location_test.go index 78a1aad4..30a2eb62 100644 --- a/go/internal/drivers/lua_site_location_test.go +++ b/go/internal/drivers/lua_site_location_test.go @@ -4,26 +4,36 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "github.com/srcfl/ftw/go/internal/telemetry" ) -// host.site_location gives the site's coordinates to a vehicle driver only, -// and nil when the site has none. -func TestLuaSiteLocationIsForVehicleDriversOnly(t *testing.T) { +// host.site_location gives the site's coordinates to a driver whose signed +// catalog entry lists vehicle, and nil to every other driver: a charger, a +// file that only calls itself a vehicle driver, or a site with no position. +func TestLuaSiteLocationIsForSignedVehicleDriversOnly(t *testing.T) { + signed := func(caps ...string) *RuntimePolicy { + return &RuntimePolicy{PackageID: "com.sourceful.driver.car", Version: "0.1.0", + ArtifactSHA256: strings.Repeat("b", 64), RuntimeABI: "gopher-lua-source-v1", + HostAPIProfile: "sourceful.host/ftw-core/v1", ReadOnly: true, Capabilities: caps} + } for _, tc := range []struct { - name, caps string - set, ok bool - want string + name string + policy *RuntimePolicy + set bool + ok bool + want string }{ - {"vehicle", `{ "vehicle" }`, true, true, "59.5,17.25"}, - {"vehicle on a site with no position", `{ "vehicle" }`, true, false, "nil"}, - {"vehicle on a host without it", `{ "vehicle" }`, false, false, "nil"}, - {"a charger", `{ "ev" }`, true, true, "nil"}, + {"signed vehicle", signed("vehicle"), true, true, "59.5,17.25"}, + {"signed vehicle on a site with no position", signed("vehicle"), true, false, "nil"}, + {"signed vehicle on a host without it", signed("vehicle"), false, false, "nil"}, + {"signed charger", signed("ev"), true, true, "nil"}, + {"a local file that calls itself a vehicle driver", nil, true, true, "nil"}, } { t.Run(tc.name, func(t *testing.T) { - source := `DRIVER = { capabilities = ` + tc.caps + ` } + source := `DRIVER = { capabilities = { "vehicle" }, read_only = true } function driver_init(config) end function driver_poll() local lat, lon = host.site_location() @@ -38,7 +48,7 @@ function driver_default_mode() end` if tc.set { env.SiteLocation = func() (float64, float64, bool) { return 59.5, 17.25, tc.ok } } - d, err := NewLuaDriver(path, env) + d, err := NewLuaDriverWithPolicy(path, env, tc.policy) if err != nil { t.Fatal(err) } diff --git a/go/internal/drivers/runtime_policy.go b/go/internal/drivers/runtime_policy.go index 913efe25..b5f69902 100644 --- a/go/internal/drivers/runtime_policy.go +++ b/go/internal/drivers/runtime_policy.go @@ -35,6 +35,22 @@ type RuntimePolicy struct { // ConfigSecrets comes from verified signed metadata. A read-only OAuth // driver may persist only these keys in its own secret namespace. ConfigSecrets []string + // Capabilities are the signed catalog entry's, not what the Lua file + // says of itself. host.site_location() reads them. + Capabilities []string +} + +// Lists reports whether the signed catalog entry lists capability. +func (p *RuntimePolicy) Lists(capability string) bool { + if p == nil { + return false + } + for _, c := range p.Capabilities { + if strings.EqualFold(c, capability) { + return true + } + } + return false } // authPaths returns every declared sign-in path. diff --git a/go/internal/drivers/testdata/home_assistant_vehicle.lua b/go/internal/drivers/testdata/home_assistant_vehicle.lua new file mode 100644 index 00000000..d3b33eb3 --- /dev/null +++ b/go/internal/drivers/testdata/home_assistant_vehicle.lua @@ -0,0 +1,331 @@ +-- Vehicle from Home Assistant (telemetry-only, via MQTT) +-- Emits: Vehicle (DerVehicle) +-- Protocol: MQTT (a JSON message a Home Assistant automation publishes) +-- +-- For a car whose level Home Assistant already reads, through the Tesla +-- Custom Integration or any other car integration. An automation in Home +-- Assistant publishes one JSON message to a topic the owner picks, every +-- 30 seconds and whenever a car entity changes. This driver only +-- subscribes: FTW holds no Home Assistant token, calls no Home Assistant +-- API and sends nothing back. It never wakes or commands the car. +-- +-- The message. Times are Unix seconds, as Home Assistant's as_timestamp +-- gives them. Leave out, or send null for, what Home Assistant does not +-- know. +-- available true when the car entities have values +-- soc_pct 0-100 +-- observed_at when the integration last read the car +-- source_age_s seconds since observed_at, by Home Assistant's clock +-- charge_limit_pct 0-100 +-- charging_state Charging | Starting | NoPower | Stopped | Complete | +-- Disconnected +-- plugged true when the charge cable is in, false when it is out +-- plugged_since when the cable entity took that state (last_changed); +-- send it only when the integration reads the cable at +-- least every 30 seconds, see below +-- at_site true when the car's tracker is in zone home, false +-- when it is somewhere else +-- source_age_s measures the car's reading, not the moment Home Assistant +-- forwards it, so a repeat never makes an old reading fresh. A message +-- older than the last one (smaller observed_at) is ignored. +-- +-- plugged, plugged_since and at_site let FTW tell which car was plugged in +-- (FTW docs/cars.md, rule 2). Home Assistant's zone home must be this site. +-- last_changed is when Home Assistant read the change, not the plug-in: an +-- integration that polls a car every few minutes dates a plug-in minutes +-- late, and FTW would match it to the wrong charger or none. FTW counts a +-- cable time only within a minute of a charger's plug-in, so leave +-- plugged_since out of the template unless the integration reads the cable +-- at least every 30 seconds while the car is at home. +-- +-- Config (MQTT host/port/user live on the capability grant, not here): +-- +-- drivers: +-- - name: tesla-ha +-- lua: drivers/home_assistant_vehicle.lua +-- capabilities: +-- mqtt: +-- host: 192.168.1.10 # the broker Home Assistant publishes to +-- port: 1883 +-- config: +-- topic: ftw/vehicle/tesla-ha/state +-- vin: "5YJ3E1EA1KF000000" # binds the reading to the car +-- make: Tesla # optional +-- model: Model Y # optional +-- +-- Automation for the Tesla Custom Integration with a car named "Tesla +-- Model Y". Other integrations name their entities differently; map them +-- in the template. The cable is binary_sensor._charger (device class +-- plug, with the car's charging_state as an attribute); the place is +-- device_tracker._location_tracker. +-- +-- - id: ftw_tesla_mqtt +-- alias: "FTW: Tesla via MQTT" +-- triggers: +-- - trigger: state +-- entity_id: +-- - sensor.tesla_model_y_battery +-- - sensor.tesla_model_y_data_last_update_time +-- - number.tesla_model_y_charge_limit +-- - binary_sensor.tesla_model_y_charger +-- - device_tracker.tesla_model_y_location_tracker +-- - trigger: time_pattern +-- seconds: "/30" +-- - trigger: homeassistant +-- event: start +-- actions: +-- - action: mqtt.publish +-- data: +-- topic: ftw/vehicle/tesla-ha/state +-- qos: 0 +-- retain: false +-- payload: >- +-- {% set cable = states.binary_sensor.tesla_model_y_charger %} +-- {% set known = cable is not none and cable.state in ['on', 'off'] %} +-- {% set tracker = 'device_tracker.tesla_model_y_location_tracker' %} +-- {% set observed = as_timestamp(states('sensor.tesla_model_y_data_last_update_time'), 0) %} +-- {% set soc = states('sensor.tesla_model_y_battery') | float(-1) %} +-- {% set charging = state_attr('binary_sensor.tesla_model_y_charger', 'charging_state') %} +-- {{ { +-- 'available': observed > 0 and soc >= 0 and soc <= 100, +-- 'soc_pct': soc, +-- 'charge_limit_pct': states('number.tesla_model_y_charge_limit') | float(-1), +-- 'charging_state': charging if charging else ('Disconnected' if known and cable.state == 'off' else ''), +-- 'observed_at': observed, +-- 'source_age_s': as_timestamp(now()) - observed, +-- 'plugged': (cable.state == 'on') if known else none, +-- 'at_site': is_state(tracker, 'home') if has_value(tracker) else none +-- } | to_json }} +-- mode: restart +-- +-- With an integration that reads the cable at least every 30 seconds, add +-- 'plugged_since': as_timestamp(cable.last_changed) if known else none, +-- +-- Publish non-retained: a retained message would replay an old reading at +-- every reconnect. + +DRIVER = { + host_api_min = 1, + host_api_max = 1, + id = "home_assistant_vehicle", + name = "Vehicle (Home Assistant MQTT)", + manufacturer = "Home Assistant", + version = "0.1.0", + protocols = { "mqtt" }, + capabilities = { "vehicle" }, + read_only = true, + description = "Read-only vehicle level, charge limit, charging state, charge cable and place from a JSON message a Home Assistant automation publishes over MQTT. Does not wake or command the car.", + homepage = "https://www.home-assistant.io/integrations/mqtt/", + authors = { "FTW contributors" }, + tested_models = { "Model Y" }, + verification_status = "experimental", +} + +PROTOCOL = "mqtt" + +-- File-local so host.mqtt_subscribe(topic) stays a static topic expression +-- for the MQTT contract tests. +local topic = "ftw/vehicle/car/state" + +local POLL_MS = 5000 +local WATCHDOG_TIMEOUT_S = 60 +-- A car reading older than this is not fresh: Core's own live window. +local MAX_AGE_S = 300 +-- Home Assistant publishes every 30 seconds. After this long without a +-- message the broker or Home Assistant is gone, and the driver stops +-- emitting so Core sees the gap instead of a replay. +local SILENT_AFTER_MS = 900000 + +local CHARGING_STATES = { + Charging = true, Starting = true, NoPower = true, + Stopped = true, Complete = true, Disconnected = true, +} + +local vin = nil +-- The last accepted message, as it will be emitted. +local last = nil +-- observed_at (Unix seconds) of the last accepted message. +local observed_at = nil +-- host.millis() after which the last reading is older than MAX_AGE_S. +local expires_ms = 0 +-- host.millis() of the last message on the topic, valid or not. +local heard_ms = 0 + +-- Logs get pasted into public issues, and a VIN identifies the owner's +-- car. Every log line therefore shows only the VIN's last four characters. +local function log(level, message) + message = tostring(message) + if type(vin) == "string" and #vin > 4 then + local escaped = vin:gsub("%p", "%%%0") + message = message:gsub(escaped, "****" .. vin:sub(-4)) + end + host.log(level, message) +end + +local function finite(n) + return type(n) == "number" and n == n and n > -math.huge and n < math.huge +end + +local function percent(n) + return finite(n) and n >= 0 and n <= 100 +end + +-- FTW reads a vehicle soc of 1 or less as a fraction, and more as a +-- percent; FTW before 0.131 reads every value as a percent. A percent above +-- 1 means the same to both, so only 1 % or less goes as a fraction: 0.01 is +-- 1 % to a new FTW and 0.01 % to an old one, where a plain 1 would be 100 %. +local function soc_value(pct) + if pct <= 1 then return pct / 100 end + return pct +end + +local function bool_or_nil(v) + if v == true or v == false then return v end + return nil +end + +-- Unix ms on the box's clock. A Home Assistant clock a little ahead of the +-- box must not put a reading in the future, which Core refuses. +local function to_ms(seconds) + local ms = math.floor(seconds * 1000) + if host.unix_ms then + local now = host.unix_ms() + if finite(now) and ms > now then ms = math.floor(now) end + end + return ms +end + +local function decode(payload) + local ok, p = pcall(host.json_decode, payload) + if ok and type(p) == "table" then return p end + return nil +end + +-- A message whose reading is usable: a level, its time and its age. +local function usable(p) + return p.available == true and percent(p.soc_pct) + and finite(p.observed_at) and p.observed_at > 0 + and finite(p.source_age_s) and p.source_age_s >= 0 +end + +local function reading_from(p) + local r = { + soc = soc_value(p.soc_pct), + charge_limit_pct = percent(p.charge_limit_pct) and p.charge_limit_pct or nil, + charging_state = CHARGING_STATES[p.charging_state] and p.charging_state or nil, + soc_observed_at_ms = to_ms(p.observed_at), + plugged = bool_or_nil(p.plugged), + at_site = bool_or_nil(p.at_site), + } + if r.plugged ~= nil and finite(p.plugged_since) and p.plugged_since > 0 then + r.plugged_since_ms = to_ms(p.plugged_since) + end + return r +end + +-- Returns true when the message carried a newer car reading. +local function apply(p, now) + if not usable(p) then + -- Unavailable or invalid: the old value must not stay live. + expires_ms = 0 + return false + end + if observed_at ~= nil and p.observed_at < observed_at then + return false + end + local deadline = now + (MAX_AGE_S - p.source_age_s) * 1000 + local newer = observed_at == nil or p.observed_at > observed_at + if newer then + expires_ms = deadline + else + -- A repeat of the same reading may say it is older, never younger. + expires_ms = math.min(expires_ms, deadline) + end + observed_at = p.observed_at + last = reading_from(p) + return newer and p.source_age_s < MAX_AGE_S +end + +function driver_init(config) + config = config or {} + last, observed_at, expires_ms, heard_ms = nil, nil, 0, 0 + -- Home Assistant does not say the make; a wrong one is worse than none. + if type(config.make) == "string" and config.make ~= "" then + host.set_make(config.make) + end + if type(config.model) == "string" and config.model ~= "" and host.set_model then + host.set_model(config.model) + end + if type(config.vin) == "string" and config.vin ~= "" then + vin = config.vin + host.set_sn(vin) + else + vin = nil + log("error", "home_assistant_vehicle: config.vin required to bind the reading to a car") + end + if type(config.topic) == "string" and config.topic ~= "" then + topic = config.topic + else + log("error", "home_assistant_vehicle: config.topic required") + end + if host.set_watchdog_timeout_s then + host.set_watchdog_timeout_s(WATCHDOG_TIMEOUT_S) + end + host.set_poll_interval(POLL_MS) + local err = host.mqtt_subscribe(topic) + if type(err) == "string" and err ~= "" then + log("error", "home_assistant_vehicle: subscribe failed: " .. err) + else + log("info", "home_assistant_vehicle: subscribed to " .. topic .. + " vin=" .. tostring(vin or "(missing)") .. " telemetry-only") + end +end + +function driver_poll() + host.set_poll_interval(POLL_MS) + local now = host.millis() + local fresh = false + local messages = host.mqtt_messages() + if not messages then messages = {} end + for _, msg in ipairs(messages) do + if type(msg) == "table" and msg.topic == topic then + heard_ms = now + local p = decode(msg.payload) + if p then + if apply(p, now) then fresh = true end + else + expires_ms = 0 + end + end + end + if not vin or not last or now - heard_ms > SILENT_AFTER_MS then + return POLL_MS + end + local stale = now >= expires_ms + host.emit("vehicle", { + soc = last.soc, + charge_limit_pct = last.charge_limit_pct, + charging_state = last.charging_state, + soc_observed_at_ms = last.soc_observed_at_ms, + plugged = last.plugged, + plugged_since_ms = last.plugged_since_ms, + at_site = last.at_site, + stale = stale, + soc_fresh = fresh and not stale, + }) + return POLL_MS +end + +function driver_command() + return false +end + +function driver_default_mode() + return true +end + +function driver_cleanup() + last, observed_at, expires_ms, heard_ms = nil, nil, 0, 0 + vin = nil + topic = "ftw/vehicle/car/state" +end diff --git a/go/internal/drivers/testdata/tesla_cloud.lua b/go/internal/drivers/testdata/tesla_cloud.lua new file mode 100644 index 00000000..e68e7eb2 --- /dev/null +++ b/go/internal/drivers/testdata/tesla_cloud.lua @@ -0,0 +1,640 @@ +-- Tesla Vehicle Driver (telemetry-only, Tesla Fleet / official cloud API) +-- Emits: Vehicle (DerVehicle) +-- Protocol: HTTPS (Tesla Fleet API) +-- +-- Optional cloud SoC next to tesla_vehicle.lua (TeslaBLEProxy / VIN on the +-- LAN). This file does not replace that driver. Cloud access is not a +-- charging prerequisite: an asleep or unreachable car is a supported case, +-- and Core already plans from a stated default or the plug-in slider. +-- +-- Telemetry only. No wake_up, charge_start, or other vehicle commands. +-- GET /api/1/vehicles/{vin}/vehicle_data is a live call to the car and a +-- billed request; Tesla says not to poll it regularly. GET +-- /api/1/vehicles/{vin} returns the car's state without a live call and +-- is not billed. So the driver checks the state every 5 minutes and reads +-- charge_state only from a car that is already awake: every 4 minutes +-- while it charges, every 20 minutes otherwise, and not on the first check +-- after it wakes, so it does not stretch the car's own short wakes. A +-- sleeping car is never woken from here. The host polls every minute; a +-- poll between those checks sends nothing and replays the last reading. +-- +-- Vendor documents (public): +-- https://developer.tesla.com/docs/fleet-api/endpoints/vehicle-endpoints +-- https://developer.tesla.com/docs/fleet-api/authentication/third-party-tokens +-- https://developer.tesla.com/docs/fleet-api/billing-and-limits +-- They are not in the manifest's upstream_docs: the page HTML changes +-- with every deploy of Tesla's site, and the watcher hashes raw bytes. +-- +-- Auth is OAuth refresh_token against fleet-auth. Refresh tokens rotate and +-- are persisted via host.persist_secret. Scopes needed: openid, offline_access, +-- vehicle_device_data. Do not grant vehicle_cmds / vehicle_charging_cmds for +-- this driver; it never posts a command. +-- +-- Which car is plugged in (FTW docs/cars.md, rule 2). The same charge_state +-- read says whether a cable is in (conn_charge_cable, else charging_state). +-- The driver sends no plugged_since_ms: it reads the car minutes apart, so +-- a read that shows the cable in says nothing about when it went in. With +-- the optional vehicle_location scope and a site position from the host +-- (host.site_location), the read also asks for location_data, and the +-- driver reports only the car's distance from the site (site_distance_m), +-- never its coordinates. Without the scope Tesla refuses location_data; the +-- driver then reads charge_state alone and asks again after a day. Nothing +-- here adds a request or wakes the car: the cable and the place come with +-- the reads the level already needs. +-- +-- Config: +-- drivers: +-- - name: tesla-cloud +-- lua: drivers/tesla_cloud.lua +-- capabilities: +-- http: +-- allowed_hosts: +-- - fleet-auth.prd.vn.cloud.tesla.com +-- - fleet-api.prd.eu.vn.cloud.tesla.com +-- - fleet-api.prd.na.vn.cloud.tesla.com +-- - fleet-api.prd.cn.vn.cloud.tesla.com.cn +-- config: +-- client_id: "..." +-- client_secret: "..." # optional for some Tesla app types +-- refresh_token: "..." # from the Fleet API auth-code exchange +-- vin: "5YJ3E1EA1KF000000" # optional; first vehicle if omitted +-- region: eu # na | eu | cn +-- +-- Settings → Devices currently scaffolds {ip, vin} for any "vehicle" +-- capability (the BLE proxy form). Edit the YAML for Fleet OAuth; do not +-- point this driver at TeslaBLEProxy. + +DRIVER = { + host_api_min = 1, + host_api_max = 1, + id = "tesla_cloud", + name = "Tesla Vehicle (Fleet API)", + manufacturer = "Tesla", + version = "0.2.0", + protocols = { "http" }, + capabilities = { "vehicle" }, + read_only = true, + auth_post_path = "/oauth2/v3/token", + description = "Read-only Tesla vehicle SoC, charge limit and charging state via the official Fleet API. Optional next to the local BLE-proxy driver. Does not wake or command the car.", + homepage = "https://developer.tesla.com/docs/fleet-api", + http_hosts = { + "fleet-auth.prd.vn.cloud.tesla.com", + "fleet-api.prd.na.vn.cloud.tesla.com", + "fleet-api.prd.eu.vn.cloud.tesla.com", + "fleet-api.prd.cn.vn.cloud.tesla.com.cn", + }, + authors = { "FTW contributors" }, + tested_models = { "Model Y", "Model 3" }, + verification_status = "experimental", + config_secrets = { "client_secret", "refresh_token" }, +} + +PROTOCOL = "http" + +-- Fixed: the POST that carries the refresh token and client secret only +-- ever goes to Tesla's auth host. +local AUTH_URL = "https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token" +local REGION_URL = { + na = "https://fleet-api.prd.na.vn.cloud.tesla.com", + eu = "https://fleet-api.prd.eu.vn.cloud.tesla.com", + cn = "https://fleet-api.prd.cn.vn.cloud.tesla.com.cn", +} + +-- Tesla VINs carry the model line in the fourth character. +local VIN_MODEL = { + S = "Model S", + ["3"] = "Model 3", + X = "Model X", + Y = "Model Y", + C = "Cybertruck", +} + +-- The host ignores driver_poll's return value and keeps the interval last +-- set with host.set_poll_interval, so every poll sets it. Requests to Tesla +-- are paced separately by next_request_ms. +local POLL_MS = 60000 +local CHARGING_EVERY_MS = 240000 -- charging: the car is awake anyway +local IDLE_EVERY_MS = 1200000 -- awake, not charging: let it fall asleep +local ASLEEP_EVERY_MS = 300000 -- state check only: free, wakes nothing +local ERROR_RETRY_MS = 300000 +local BACKOFF_MS = 900000 -- failed token refresh or HTTP 429 +-- Longer than IDLE_EVERY_MS, so an awake idle car stays listed between +-- reads. Core itself stops using a vehicle SoC 5 minutes after it was read. +local STALE_AFTER_MS = 1500000 +local WATCHDOG_TIMEOUT_S = 300 + +local client_id = nil +local client_secret = nil +local refresh_token = nil +local access_token = nil +local token_expires_at = 0 +local vin = nil +local base_url = REGION_URL.eu +local next_request_ms = 0 +-- Whether the car was online at the previous state check (nil: not +-- checked yet since start). +local was_online = nil + +-- Logs get pasted into public issues, and a VIN identifies the owner's +-- car. Every log line therefore shows only the VIN's last four characters. +local function log(level, message) + message = tostring(message) + if type(vin) == "string" and #vin > 4 then + local escaped = vin:gsub("%p", "%%%0") + message = message:gsub(escaped, "****" .. vin:sub(-4)) + end + host.log(level, message) +end + +-- Last vendor observation. seen_ms is host.millis() of the last successful +-- charge_state read. Age is measured on that clock; Tesla's unix ms cannot +-- be compared to host.millis(). +local last = { + seen_ms = 0, + soc = nil, + charge_limit = nil, + charging_state = nil, + time_to_full = nil, + charge_amps = nil, + charger_actual_current = nil, + plugged = nil, + site_distance_m = nil, +} +-- host.millis() before which the driver does not ask for location_data: +-- Tesla refused it (no vehicle_location scope). The owner may grant the +-- scope later, so it asks again after LOCATION_RETRY_MS. +local LOCATION_RETRY_MS = 86400000 +local location_denied_until = 0 + +local function redact_http_err(err) + if err == nil then return "ok" end + return tostring(err):match("^(HTTP %d+)") or "request failed" +end + +local function url_encode(s) + return (tostring(s or ""):gsub("[^%w%-%.%_%~]", function(c) + return string.format("%%%02X", string.byte(c)) + end)) +end + +local function decode_json(raw) + if raw == nil or raw == "" then return nil, "empty body" end + local ok, data = pcall(host.json_decode, raw) + if not ok then return nil, tostring(data) end + if data == nil then return nil, "decode failed" end + return data, nil +end + +local function safe_http_get(url, headers) + local ok, resp, err = pcall(host.http_get, url, headers) + if not ok then return nil, tostring(resp) end + return resp, err +end + +local function safe_http_post(url, body, headers) + local ok, resp, err = pcall(host.http_post, url, body, headers) + if not ok then return nil, tostring(resp) end + return resp, err +end + +local function auth_headers() + return { + Authorization = "Bearer " .. (access_token or ""), + Accept = "application/json", + } +end + +local function unwrap(decoded) + if type(decoded) ~= "table" then return nil end + if type(decoded.response) == "table" then + if type(decoded.response.response) == "table" then + return decoded.response.response + end + return decoded.response + end + return decoded +end + +local function as_list(t) + if type(t) ~= "table" then return {} end + if t[1] ~= nil then return t end + if type(t.vehicles) == "table" then return t.vehicles end + return {} +end + +local function persist_refresh(new_token) + if not new_token or new_token == "" or new_token == refresh_token then + return + end + refresh_token = new_token + if not host.persist_secret then return end + local ok, perr = host.persist_secret("refresh_token", refresh_token) + if not ok then + log("warn", "tesla_cloud: could not persist rotated refresh_token: " .. tostring(perr)) + end +end + +local function fetch_token() + if not refresh_token or refresh_token == "" then + log("warn", "tesla_cloud: not connected — set refresh_token from a Tesla Fleet API auth-code exchange") + return false + end + if not client_id or client_id == "" then + log("error", "tesla_cloud: client_id required") + return false + end + local body = "grant_type=refresh_token" + .. "&client_id=" .. url_encode(client_id) + .. "&refresh_token=" .. url_encode(refresh_token) + if client_secret and client_secret ~= "" then + body = body .. "&client_secret=" .. url_encode(client_secret) + end + local resp, err = safe_http_post(AUTH_URL, body, { + ["Content-Type"] = "application/x-www-form-urlencoded", + Accept = "application/json", + }) + if err then + log("error", "tesla_cloud: token refresh failed: " .. redact_http_err(err)) + return false + end + local data, derr = decode_json(resp) + if derr or type(data) ~= "table" or not data.access_token then + log("error", "tesla_cloud: no access_token in refresh response") + return false + end + access_token = data.access_token + local expires_in = tonumber(data.expires_in) or 28800 + token_expires_at = host.millis() + (expires_in * 1000) - 60000 + persist_refresh(data.refresh_token) + return true +end + +local function ensure_auth() + if access_token and access_token ~= "" and host.millis() < token_expires_at then + return true + end + return fetch_token() +end + +-- The model comes from the VIN, not display_name: that is the name the +-- owner gave the car. +local function bind_identity(next_vin) + if not next_vin or next_vin == "" then return end + local s = tostring(next_vin) + if s == vin then return end + vin = s + host.set_sn(vin) + local model = VIN_MODEL[vin:sub(4, 4):upper()] + if model and host.set_model then + host.set_model(model) + end +end + +local function pick_vehicle(rows, want) + if type(rows) ~= "table" then return nil end + if want and want ~= "" then + for i = 1, #rows do + local row = rows[i] + if type(row) == "table" and tostring(row.vin or "") == want then + return row + end + end + return nil + end + for i = 1, #rows do + if type(rows[i]) == "table" and rows[i].vin then + return rows[i] + end + end + return nil +end + +local function list_vehicles() + local resp, err = safe_http_get(base_url .. "/api/1/vehicles", auth_headers()) + if err then return nil, err end + local data, derr = decode_json(resp) + if derr then return nil, derr end + return as_list(unwrap(data)), nil +end + +local function vehicle_row() + if vin and vin ~= "" then + local resp, err = safe_http_get(base_url .. "/api/1/vehicles/" .. vin, auth_headers()) + if not err then + local data, derr = decode_json(resp) + if not derr then + local row = unwrap(data) + if type(row) == "table" and (row.vin or row.state) then + return row, nil + end + end + end + if err and tostring(err):match("HTTP 401") then + return nil, err + end + end + local rows, lerr = list_vehicles() + if lerr then return nil, lerr end + local row = pick_vehicle(rows, vin) + if not row then + return nil, "no matching vehicle" + end + return row, nil +end + +local function charge_state_from(decoded) + local root = unwrap(decoded) + if type(root) ~= "table" then return nil end + if type(root.charge_state) == "table" then + return root.charge_state + end + if root.battery_level ~= nil then + return root + end + return nil +end + +local function finite(n) + return type(n) == "number" and n == n and n > -math.huge and n < math.huge +end + +-- The car's word on its cable: conn_charge_cable names the cable in the +-- port ("" when there is none), else the charging state says it. +local function plugged_from(cs) + local cable = cs.conn_charge_cable + if type(cable) == "string" and cable ~= "" then + return cable ~= "" + end + if type(cs.charging_state) == "string" and cs.charging_state ~= "" then + return cs.charging_state ~= "Disconnected" + end + return nil +end + +-- Great-circle distance in metres. +local function distance_m(lat1, lon1, lat2, lon2) + local r = math.pi / 180 + local dlat, dlon = (lat2 - lat1) * r, (lon2 - lon1) * r + local a = math.sin(dlat / 2) ^ 2 + + math.cos(lat1 * r) * math.cos(lat2 * r) * math.sin(dlon / 2) ^ 2 + return 2 * 6371000 * math.asin(math.min(1, math.sqrt(a))) +end + +local function site() + if not host.site_location then return nil end + local lat, lon = host.site_location() + if not finite(lat) or not finite(lon) then return nil end + return lat, lon +end + +-- The car's distance from the site, from location_data's drive_state. +local function site_distance_from(root) + local ds = type(root) == "table" and root.drive_state + if type(ds) ~= "table" then return nil end + local lat, lon = tonumber(ds.latitude), tonumber(ds.longitude) + local slat, slon = site() + if not finite(lat) or not finite(lon) or not slat then return nil end + if math.abs(lat) > 90 or math.abs(lon) > 180 or (lat == 0 and lon == 0) then return nil end + return math.floor(distance_m(slat, slon, lat, lon) + 0.5) +end + +-- FTW reads a vehicle soc of 1 or less as a fraction, and more as a +-- percent; FTW before 0.131 reads every value as a percent. A percent above +-- 1 means the same to both, so only 1 % or less goes as a fraction: 0.01 is +-- 1 % to a new FTW and 0.01 % to an old one, where a plain 1 would be 100 %. +local function soc_value(pct) + if pct <= 1 then return pct / 100 end + return pct +end + +local function remember(cs, root) + local soc = tonumber(cs.battery_level) + if soc == nil then return false end + last.plugged = plugged_from(cs) + last.site_distance_m = site_distance_from(root) + last.soc = soc + last.charge_limit = tonumber(cs.charge_limit_soc) + last.charge_amps = tonumber(cs.charge_amps) + last.charger_actual_current = tonumber(cs.charger_actual_current) + local cs_state = cs.charging_state + if type(cs_state) ~= "string" then cs_state = nil end + last.charging_state = cs_state + local ttf_min = tonumber(cs.minutes_to_full_charge) + if ttf_min == nil then + local ttf_h = tonumber(cs.time_to_full_charge) + if ttf_h ~= nil then ttf_min = math.floor(ttf_h * 60 + 0.5) end + end + last.time_to_full = ttf_min + -- Successful vehicle_data is a new observation. Age is for failed or + -- asleep polls that would otherwise replay this cache forever. + last.seen_ms = host.millis() + return true +end + +-- A replay repeats the last observation with soc_fresh=false, so Core keeps +-- the time of that observation and ages it itself. Marking a replay stale +-- would drop the car from Core between two reads. After STALE_AFTER_MS the +-- driver stops emitting. +local function emit_vehicle(fresh) + if last.soc == nil or last.seen_ms == 0 then return end + local age = host.millis() - last.seen_ms + if age > STALE_AFTER_MS then + return + end + host.emit("vehicle", { + soc = soc_value(last.soc), + charge_limit_pct = last.charge_limit, + charging_state = last.charging_state, + time_to_full_min = last.time_to_full, + charge_amps = last.charge_amps, + charger_actual_current = last.charger_actual_current, + plugged = last.plugged, + site_distance_m = last.site_distance_m, + stale = false, + soc_fresh = fresh, + }) +end + +local function fetch_vehicle_data(with_location) + local url = base_url .. "/api/1/vehicles/" .. vin + .. "/vehicle_data?endpoints=charge_state" + if with_location then + url = url .. "%3Blocation_data" + end + local resp, err = safe_http_get(url, auth_headers()) + if err then return nil, nil, err end + local decoded, derr = decode_json(resp) + if derr then return nil, nil, derr end + if type(decoded) == "table" and decoded.error then + return nil, nil, tostring(decoded.error) + end + return charge_state_from(decoded), unwrap(decoded), nil +end + +-- charge_state, with location_data when the site has a position and Tesla +-- has not refused it lately. One request either way, so the place costs +-- nothing. +local function fetch_charge_state() + local with_location = host.millis() >= location_denied_until and site() ~= nil + local cs, root, err = fetch_vehicle_data(with_location) + if with_location and err and tostring(err):match("HTTP 403") then + location_denied_until = host.millis() + LOCATION_RETRY_MS + log("info", "tesla_cloud: no vehicle_location scope; reading charge_state alone for a day") + cs, root, err = fetch_vehicle_data(false) + end + return cs, root, err +end + +local function wait(ms) + next_request_ms = host.millis() + ms +end + +local function is_charging(state) + return state == "Charging" or state == "Starting" +end + +function driver_init(config) + host.set_make("Tesla") + config = config or {} + client_id = config.client_id + client_secret = config.client_secret + refresh_token = config.refresh_token + if config.vin and tostring(config.vin) ~= "" then + bind_identity(config.vin) + end + local region = tostring(config.region or "eu"):lower() + base_url = REGION_URL[region] + if not base_url then + log("warn", "tesla_cloud: unknown region " .. region .. ", using eu") + region = "eu" + base_url = REGION_URL.eu + end + next_request_ms = 0 + if host.set_watchdog_timeout_s then + host.set_watchdog_timeout_s(WATCHDOG_TIMEOUT_S) + end + -- First poll soon; driver_poll sets POLL_MS on every path after that. + host.set_poll_interval(500) + if not client_id or client_id == "" or not refresh_token or refresh_token == "" then + log("error", "tesla_cloud: client_id and refresh_token required (Fleet API auth-code exchange)") + end + log("info", "tesla_cloud: init region=" .. region .. + " vin=" .. tostring(vin or "(discover)") .. + " telemetry-only") +end + +function driver_poll() + host.set_poll_interval(POLL_MS) + if not refresh_token or refresh_token == "" then + return POLL_MS + end + if host.millis() < next_request_ms then + emit_vehicle(false) + return POLL_MS + end + + if not ensure_auth() then + wait(BACKOFF_MS) + emit_vehicle(false) + return POLL_MS + end + + local row, err = vehicle_row() + if err and tostring(err):match("HTTP 401") then + token_expires_at = 0 + if ensure_auth() then + row, err = vehicle_row() + end + end + if err or type(row) ~= "table" then + log("warn", "tesla_cloud: vehicle status: " .. redact_http_err(err)) + wait(ERROR_RETRY_MS) + emit_vehicle(false) + return POLL_MS + end + + bind_identity(row.vin) + if not vin or vin == "" then + log("warn", "tesla_cloud: no VIN on account") + wait(ERROR_RETRY_MS) + return POLL_MS + end + + local state = tostring(row.state or ""):lower() + local online = state == "online" + local woke_just_now = online and was_online == false + was_online = online + if not online then + log("debug", "tesla_cloud: " .. vin .. " is " .. (state ~= "" and state or "unknown") .. + " — not calling vehicle_data") + wait(ASLEEP_EVERY_MS) + emit_vehicle(false) + return POLL_MS + end + if woke_just_now then + -- The car wakes briefly on its own. A live call now would keep it + -- awake; if it is still online at the next check, it is in use. + log("debug", "tesla_cloud: " .. vin .. " just woke — reading at the next check") + wait(ASLEEP_EVERY_MS) + emit_vehicle(false) + return POLL_MS + end + + local cs, root, ferr = fetch_charge_state() + if ferr and tostring(ferr):match("HTTP 401") then + token_expires_at = 0 + if ensure_auth() then + cs, root, ferr = fetch_charge_state() + end + end + if ferr then + local es = tostring(ferr) + if es:match("HTTP 408") or es:match("vehicle unavailable") then + log("debug", "tesla_cloud: vehicle_data unavailable (asleep)") + wait(ASLEEP_EVERY_MS) + elseif es:match("HTTP 429") then + log("warn", "tesla_cloud: rate limited") + wait(BACKOFF_MS) + else + log("warn", "tesla_cloud: vehicle_data: " .. redact_http_err(ferr)) + wait(ERROR_RETRY_MS) + end + emit_vehicle(false) + return POLL_MS + end + if type(cs) ~= "table" or not remember(cs, root) then + log("debug", "tesla_cloud: no battery_level in charge_state") + wait(IDLE_EVERY_MS) + emit_vehicle(false) + return POLL_MS + end + + if is_charging(last.charging_state) then + wait(CHARGING_EVERY_MS) + else + wait(IDLE_EVERY_MS) + end + log("info", "tesla_cloud: emit soc=" .. tostring(last.soc) .. + " limit=" .. tostring(last.charge_limit) .. + " state=" .. tostring(last.charging_state)) + emit_vehicle(true) + return POLL_MS +end + +function driver_cleanup() + last.seen_ms = 0 + last.soc = nil + last.charge_limit = nil + last.charging_state = nil + last.time_to_full = nil + last.charge_amps = nil + last.charger_actual_current = nil + last.plugged = nil + last.site_distance_m = nil + location_denied_until = 0 + access_token = nil + token_expires_at = 0 + next_request_ms = 0 + was_online = nil + vin = nil +end diff --git a/go/internal/drivers/testdata/teslamate_vehicle.lua b/go/internal/drivers/testdata/teslamate_vehicle.lua new file mode 100644 index 00000000..b7d911b5 --- /dev/null +++ b/go/internal/drivers/testdata/teslamate_vehicle.lua @@ -0,0 +1,529 @@ +-- Tesla Vehicle Driver (telemetry-only, via TeslaMate MQTT) +-- Emits: Vehicle (DerVehicle) +-- Protocol: MQTT (TeslaMate retained topics teslamate/cars/{id}/…) +-- +-- Optional SoC next to tesla_vehicle.lua (TeslaBLEProxy on the LAN). +-- This file does not replace that driver, and it is not a Fleet API client. +-- FTW reads the TeslaMate instance the owner already runs. It does not talk +-- to Tesla Fleet cloud and it does not ingest Home Assistant entities. +-- Cloud access is not a charging prerequisite: an asleep or unreachable car +-- is a supported case, and Core already plans from a stated default or the +-- plug-in slider. +-- +-- Telemetry only. No wake, charge_start, or other vehicle command. TeslaMate +-- MQTT is subscribe-only; this driver never publishes. +-- +-- TeslaMate does not publish VIN on MQTT (privacy). Bind identity from YAML: +-- host.set_make("Tesla"), host.set_sn(VIN). Car id selects the topic prefix. +-- +-- Vendor document: +-- https://docs.teslamate.org/docs/integrations/mqtt +-- TeslaMate payloads are per-topic strings, retained, and published only +-- when a value changes. `healthy` is the exception: it is not retained and +-- goes out with every update TeslaMate makes, so a healthy=true message +-- while the car is awake means TeslaMate has just read the car and the +-- retained values still hold. While the car is asleep TeslaMate stops +-- updating; retained values can be hours old. Age is host.millis() of the +-- last awake observation (state online / charging / driving / updating). +-- In between, the driver replays that observation with soc_fresh=false so +-- Core ages it from the observation, and stops after STALE_AFTER_MS. A +-- first subscribe that only sees an asleep car is not a live observation +-- and emits nothing. +-- +-- Config (MQTT host/port/user live on the capability grant, not here): +-- +-- drivers: +-- - name: teslamate +-- lua: drivers/teslamate_vehicle.lua +-- capabilities: +-- mqtt: +-- host: 192.168.1.10 # TeslaMate's broker (often Mosquitto) +-- port: 1883 +-- username: "" # optional +-- password: "" # optional +-- config: +-- vin: "5YJ3E1EA1KF000000" # required — TeslaMate MQTT has no VIN +-- car_id: 1 # TeslaMate id, usually starts at 1 +-- # geofence: Home # optional: this site's geofence name +-- # topic_prefix: teslamate/cars/1 # optional override of the prefix +-- +-- Enable MQTT in TeslaMate (MQTT_HOST in its compose) before pointing FTW +-- at the broker. Settings → Devices currently scaffolds vehicle drivers as +-- {ip, vin} (BLE proxy). Edit the YAML for TeslaMate MQTT. +-- +-- No HTTP. TeslaMate's UI and GraphQL are out of scope. If a later HTTP +-- lookup is added, grant capabilities.http.allowed_hosts for that host only. +-- +-- Which car is plugged in (FTW docs/cars.md, rule 2). TeslaMate's +-- plugged_in topic gives the cable. A change gives plugged_since_ms, by the +-- box's clock, only when a live update carries it: a healthy message within +-- 10 seconds. A retained value, at subscribe or redelivered after the broker +-- reconnects, comes without one, and its time is unknown. +-- +-- The place: with `geofence` set to the name of this site's geofence in +-- TeslaMate, the geofence topic gives at_site. Without it, latitude and +-- longitude, with the site position from the host (host.site_location), +-- give site_distance_m; the car's coordinates are not emitted. The +-- geofence is the better of the two: the site position is a weather pin. +-- All of this comes with the updates TeslaMate already sends. + +DRIVER = { + host_api_min = 1, + host_api_max = 1, + id = "teslamate_vehicle", + name = "Tesla Vehicle (TeslaMate)", + manufacturer = "Tesla", + version = "0.2.0", + protocols = { "mqtt" }, + capabilities = { "vehicle" }, + read_only = true, + description = "Read-only Tesla vehicle SoC, charge limit and charging state via TeslaMate MQTT. Optional next to tesla_vehicle (BLE). Does not wake or command the car.", + homepage = "https://docs.teslamate.org/docs/integrations/mqtt", + authors = { "FTW contributors" }, + tested_models = { "Model Y", "Model 3" }, + verification_status = "experimental", +} + +PROTOCOL = "mqtt" + +-- File-local so host.mqtt_subscribe(base_topic .. "/#") stays a static +-- topic expression for the MQTT contract tests. +local base_topic = "teslamate/cars/1" + +local POLL_INTERVAL_MS = 5000 +local STALE_AFTER_MS = 900000 +local WATCHDOG_TIMEOUT_S = 300 + +-- TeslaMate's state topic also carries asleep, offline and suspended, and +-- briefly start or unavailable. None of those is a live reading. +local AWAKE = { + online = true, + charging = true, + driving = true, + updating = true, +} + +local vin = nil + +-- Logs get pasted into public issues, and a VIN identifies the owner's +-- car. Every log line therefore shows only the VIN's last four characters. +local function log(level, message) + message = tostring(message) + if type(vin) == "string" and #vin > 4 then + local escaped = vin:gsub("%p", "%%%0") + message = message:gsub(escaped, "****" .. vin:sub(-4)) + end + host.log(level, message) +end + +-- Last live (awake) observation. seen_ms is host.millis() when TeslaMate +-- last published charge fields while the car was awake. TeslaMate's +-- ISO `since` cannot be compared to host.millis() (monotonic uptime), +-- and os.time is sandbox-forbidden in this repository. +-- The car's cable and place. Kept apart from `last`: TeslaMate sends them +-- when they change, and they do not make a level fresh. +local plug = { + plugged = nil, + since_ms = nil, + -- A change waiting for a healthy message: its unix ms, and host.millis() + -- when it arrived. + pending_ms = nil, + pending_at = nil, + lat = nil, + lon = nil, + geofence = nil, +} +-- host.millis() of the last healthy=true message. +local healthy_at = nil +-- This site's TeslaMate geofence, from config. +local home_geofence = nil +-- A plugged_in change and its healthy message arrive this close together. +local LIVE_WITHIN_MS = 10000 + +local last = { + seen_ms = 0, + soc = nil, + charge_limit = nil, + charging_state = nil, + time_to_full = nil, + charge_amps = nil, + charger_actual_current = nil, + tm_state = nil, + healthy = nil, +} + +-- Fields collected from MQTT that have not yet been accepted as a live +-- observation. Kept across polls so state and battery_level can arrive +-- on different ticks. nil means not received; false means TeslaMate sent +-- an empty value (it does for time_to_full_charge and +-- charger_actual_current once charging ends). +local incoming = { + soc = nil, + charge_limit = nil, + charging_state = nil, + time_to_full = nil, + charge_amps = nil, + charger_actual_current = nil, +} + +local function payload_present(p) + if p == nil then return false end + local s = tostring(p) + return s ~= "" and s ~= "nil" and s ~= "null" +end + +local function as_bool(p) + if p == true then return true end + if p == false then return false end + local s = tostring(p or ""):lower() + if s == "true" or s == "1" then return true end + if s == "false" or s == "0" then return false end + return nil +end + +local function model_name(raw) + local s = tostring(raw or "") + if s == "3" or s == "Y" or s == "S" or s == "X" then + return "Model " .. s + end + return s +end + +local function normalize_prefix(s) + s = tostring(s or ""):gsub("/+$", "") + s = s:gsub("/#$", "") + return s +end + +local function topic_key(topic) + if type(topic) ~= "string" then return nil end + local prefix = base_topic .. "/" + if topic:sub(1, #prefix) ~= prefix then return nil end + local key = topic:sub(#prefix + 1) + -- TeslaMate charge fields are a single path segment. Reject + -- teslamate/cars/10/... when the prefix is teslamate/cars/1. + if key == "" or key:find("/", 1, true) then return nil end + return key +end + +local function hours_to_min(hours) + local h = tonumber(hours) + if h == nil then return nil end + return math.floor(h * 60 + 0.5) +end + +local function reset_incoming() + incoming.soc = nil + incoming.charge_limit = nil + incoming.charging_state = nil + incoming.time_to_full = nil + incoming.charge_amps = nil + incoming.charger_actual_current = nil +end + +local function finite(n) + return type(n) == "number" and n == n and n > -math.huge and n < math.huge +end + +-- Great-circle distance in metres. +local function distance_m(lat1, lon1, lat2, lon2) + local r = math.pi / 180 + local dlat, dlon = (lat2 - lat1) * r, (lon2 - lon1) * r + local a = math.sin(dlat / 2) ^ 2 + + math.cos(lat1 * r) * math.cos(lat2 * r) * math.sin(dlon / 2) ^ 2 + return 2 * 6371000 * math.asin(math.min(1, math.sqrt(a))) +end + +local function site_distance() + if not host.site_location or not finite(plug.lat) or not finite(plug.lon) then + return nil + end + local slat, slon = host.site_location() + if not finite(slat) or not finite(slon) then return nil end + return math.floor(distance_m(slat, slon, plug.lat, plug.lon) + 0.5) +end + +local function reset_plug() + plug.plugged, plug.since_ms, plug.lat, plug.lon = nil, nil, nil, nil + plug.pending_ms, plug.pending_at, plug.geofence = nil, nil, nil + healthy_at = nil +end + +-- A pending change becomes the cable's time when a healthy message came +-- within LIVE_WITHIN_MS of it, and is dropped once that cannot happen. +local function settle_plug(now) + if not plug.pending_at then return end + if healthy_at and math.abs(healthy_at - plug.pending_at) <= LIVE_WITHIN_MS then + plug.since_ms = plug.pending_ms + plug.pending_ms, plug.pending_at = nil, nil + elseif now - plug.pending_at > LIVE_WITHIN_MS then + plug.pending_ms, plug.pending_at = nil, nil + end +end + +local function at_site() + if not home_geofence then return nil end + return plug.geofence == home_geofence +end + +-- FTW reads a vehicle soc of 1 or less as a fraction, and more as a +-- percent; FTW before 0.131 reads every value as a percent. A percent above +-- 1 means the same to both, so only 1 % or less goes as a fraction: 0.01 is +-- 1 % to a new FTW and 0.01 % to an old one, where a plain 1 would be 100 %. +local function soc_value(pct) + if pct <= 1 then return pct / 100 end + return pct +end + +local function reset_last() + reset_plug() + last.seen_ms = 0 + last.soc = nil + last.charge_limit = nil + last.charging_state = nil + last.time_to_full = nil + last.charge_amps = nil + last.charger_actual_current = nil + last.tm_state = nil + last.healthy = nil + reset_incoming() +end + +local function car_is_awake() + local state = last.tm_state + return type(state) == "string" and AWAKE[state] == true +end + +local function can_accept_fresh() + if not vin or vin == "" then return false end + if last.healthy == false then return false end + return car_is_awake() +end + +local function remember() + local soc = tonumber(incoming.soc) + if soc == nil then soc = last.soc end + if soc == nil then return false end + last.soc = soc + if incoming.charge_limit ~= nil then + last.charge_limit = tonumber(incoming.charge_limit) + end + if incoming.charge_amps ~= nil then + last.charge_amps = tonumber(incoming.charge_amps) + end + if incoming.charger_actual_current ~= nil then + last.charger_actual_current = tonumber(incoming.charger_actual_current) or nil + end + local cs = incoming.charging_state + if type(cs) == "string" and cs ~= "" then + last.charging_state = cs + end + if incoming.time_to_full ~= nil then + last.time_to_full = incoming.time_to_full or nil + end + last.seen_ms = host.millis() + return true +end + +-- A replay repeats the last awake observation with soc_fresh=false, so Core +-- keeps the time of that observation and ages it itself. Marking every +-- replay stale would drop the car from Core between two MQTT updates. +-- stale=true only when TeslaMate reports itself unhealthy. +local function emit_vehicle(fresh) + if last.soc == nil or last.seen_ms == 0 then return end + local age = host.millis() - last.seen_ms + if age > STALE_AFTER_MS then + return + end + host.emit("vehicle", { + soc = soc_value(last.soc), + charge_limit_pct = last.charge_limit, + charging_state = last.charging_state, + time_to_full_min = last.time_to_full, + charge_amps = last.charge_amps, + charger_actual_current = last.charger_actual_current, + plugged = plug.plugged, + plugged_since_ms = plug.since_ms, + at_site = at_site(), + site_distance_m = not home_geofence and site_distance() or nil, + stale = last.healthy == false, + soc_fresh = fresh, + }) +end + +-- Returns true when this message shows TeslaMate has just read the car: a +-- charge-field update, or the healthy=true it sends with every update. +-- State and model do not refresh seen_ms on their own. +local function apply_message(msg) + if type(msg) ~= "table" then return false end + local key = topic_key(msg.topic) + if not key then return false end + local p = msg.payload + if key == "state" then + if payload_present(p) then + last.tm_state = tostring(p):lower() + end + return false + end + if key == "healthy" then + local b = as_bool(p) + if b ~= nil then last.healthy = b end + if b == true then healthy_at = host.millis() end + return b == true + end + if key == "plugged_in" then + local b = as_bool(p) + if b == nil then return false end + if plug.plugged ~= nil and b ~= plug.plugged then + plug.since_ms = nil + if host.unix_ms then + plug.pending_ms, plug.pending_at = host.unix_ms(), host.millis() + end + end + plug.plugged = b + return false + end + if key == "geofence" then + plug.geofence = p ~= nil and tostring(p) or "" + return false + end + if key == "latitude" or key == "longitude" then + local n = tonumber(p) + if n == nil or (key == "latitude" and math.abs(n) > 90) or math.abs(n) > 180 then + return false + end + if key == "latitude" then plug.lat = n else plug.lon = n end + return false + end + if key == "model" then + if payload_present(p) and host.set_model then + host.set_model(model_name(p)) + end + return false + end + if key == "battery_level" then + local n = tonumber(p) + if n == nil then return false end + incoming.soc = n + return true + end + if key == "charge_limit_soc" then + local n = tonumber(p) + if n == nil then return false end + incoming.charge_limit = n + return true + end + if key == "charging_state" then + if not payload_present(p) then return false end + incoming.charging_state = tostring(p) + return true + end + if key == "time_to_full_charge" then + if not payload_present(p) then + incoming.time_to_full = false + return true + end + local mins = hours_to_min(p) + if mins == nil then return false end + incoming.time_to_full = mins + return true + end + if key == "charge_current_request" then + local n = tonumber(p) + if n == nil then return false end + incoming.charge_amps = n + return true + end + if key == "charger_actual_current" then + if not payload_present(p) then + incoming.charger_actual_current = false + return true + end + local n = tonumber(p) + if n == nil then return false end + incoming.charger_actual_current = n + return true + end + return false +end + +function driver_init(config) + host.set_make("Tesla") + config = config or {} + + if config.vin and tostring(config.vin) ~= "" then + vin = tostring(config.vin) + host.set_sn(vin) + else + log("error", "teslamate_vehicle: config.vin required (TeslaMate MQTT does not publish VIN)") + end + + if config.topic_prefix and tostring(config.topic_prefix) ~= "" then + base_topic = normalize_prefix(config.topic_prefix) + elseif config.base_topic and tostring(config.base_topic) ~= "" then + base_topic = normalize_prefix(config.base_topic) + elseif config.topic and tostring(config.topic) ~= "" then + base_topic = normalize_prefix(config.topic) + elseif config.car_id ~= nil and tostring(config.car_id) ~= "" then + base_topic = "teslamate/cars/" .. tostring(config.car_id) + else + base_topic = "teslamate/cars/1" + end + home_geofence = nil + if config.geofence ~= nil and tostring(config.geofence) ~= "" then + home_geofence = tostring(config.geofence) + end + + if host.set_watchdog_timeout_s then + host.set_watchdog_timeout_s(WATCHDOG_TIMEOUT_S) + end + host.set_poll_interval(500) + + -- Real host returns nil on success. The test mock returns true. + local err = host.mqtt_subscribe(base_topic .. "/#") + if type(err) == "string" and err ~= "" then + log("error", "teslamate_vehicle: subscribe failed: " .. err) + else + log("info", "teslamate_vehicle: subscribed to " .. base_topic .. "/#" .. + " vin=" .. tostring(vin or "(missing)") .. + " telemetry-only") + end +end + +function driver_poll() + host.set_poll_interval(POLL_INTERVAL_MS) + + if not vin or vin == "" then + return POLL_INTERVAL_MS + end + + local messages = host.mqtt_messages() + if not messages then messages = {} end + local saw_charge = false + for _, msg in ipairs(messages) do + if apply_message(msg) then saw_charge = true end + end + settle_plug(host.millis()) + + -- Only a charge-field message or a healthy heartbeat while TeslaMate says + -- the car is awake starts (or refreshes) the age clock. Idle polls and + -- asleep retained values must not look like a new BMS reading. + if can_accept_fresh() and saw_charge and remember() then + log("info", "teslamate_vehicle: emit soc=" .. tostring(last.soc) .. + " limit=" .. tostring(last.charge_limit) .. + " state=" .. tostring(last.charging_state) .. + " tm=" .. tostring(last.tm_state)) + emit_vehicle(true) + else + emit_vehicle(false) + end + + return POLL_INTERVAL_MS +end + +function driver_cleanup() + reset_last() + home_geofence = nil + vin = nil + base_topic = "teslamate/cars/1" +end diff --git a/go/internal/drivers/vehicle_plugged_test.go b/go/internal/drivers/vehicle_plugged_test.go new file mode 100644 index 00000000..909df91b --- /dev/null +++ b/go/internal/drivers/vehicle_plugged_test.go @@ -0,0 +1,189 @@ +package drivers + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// The vehicle drivers that say where a car's cable and the car are, run in +// this host through to the observations the cars runtime reads. testdata +// holds copies of srcfl/device-drivers#182 (home_assistant_vehicle 0.1.0, +// tesla_cloud 0.2.0, teslamate_vehicle 0.2.0) until the driver pin moves. + +func vehicleTestdata(name string) string { + _, thisFile, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(thisFile), "testdata", name+".lua") +} + +// A signed vehicle driver's policy: host.site_location answers it. +func signedVehicle(id string) *RuntimePolicy { + return &RuntimePolicy{PackageID: "com.sourceful.driver." + id, Version: "0.1.0", + ArtifactSHA256: strings.Repeat("c", 64), RuntimeABI: "gopher-lua-source-v1", + HostAPIProfile: "sourceful.host/ftw-core/v1", ReadOnly: true, + Permissions: map[string]bool{"mqtt.subscribe": true, "http.get": true, "http.post": true}, + AuthPostPath: "/oauth2/v3/token", Capabilities: []string{"vehicle"}} +} + +// vehicleEnv is a host that has run for a minute. The drivers read a +// host.millis() of 0 as "never read", which a real box is long past by its +// first reading. +func vehicleEnv(name string) *HostEnv { + env := NewHostEnv(name, telemetry.NewStore()) + env.Start = time.Now().Add(-time.Minute) + return env +} + +// Uppsala Cathedral, a public landmark, stands in for the site. +func uppsala() (float64, float64, bool) { return 59.858, 17.6336, true } + +// observe waits for the driver's observation: the store takes an emit as it +// lands, and the health that lists a driver comes with it. +func observe(t *testing.T, env *HostEnv) (telemetry.VehicleObservation, *telemetry.DerReading) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for { + env.Telemetry.DriverHealthMut(env.DriverName).RecordSuccess() + for _, o := range telemetry.VehicleObservations(env.Telemetry) { + if o.Driver == env.DriverName { + return o, env.Telemetry.Get(env.DriverName, telemetry.DerVehicle) + } + } + if time.Now().After(deadline) { + t.Fatalf("%s: no observation", env.DriverName) + } + time.Sleep(20 * time.Millisecond) + } +} + +func noCoordinates(t *testing.T, r *telemetry.DerReading, coords ...string) { + t.Helper() + for _, c := range coords { + if strings.Contains(string(r.Data), c) { + t.Fatalf("the car's coordinates left the driver: %s", r.Data) + } + } +} + +func TestHomeAssistantVehicleSaysWhereTheCarIs(t *testing.T) { + env := vehicleEnv("tesla-ha") + mq := &fakeMQTT{} + env.WithMQTT(mq) + d, err := NewLuaDriver(vehicleTestdata("home_assistant_vehicle"), env) + if err != nil { + t.Fatal(err) + } + defer d.Cleanup() + if err := d.Init(context.Background(), map[string]any{"topic": "ftw/vehicle/tesla-ha/state", + "vin": "5YJ3E1EA1KF000000", "make": "Tesla"}); err != nil { + t.Fatal(err) + } + now := float64(time.Now().UnixMilli()) / 1000 + since := now - 20 + f := func(v float64) string { return strconv.FormatFloat(v, 'f', 3, 64) } + mq.Push("ftw/vehicle/tesla-ha/state", `{"available":true,"soc_pct":14.0,"charge_limit_pct":80.0,`+ + `"charging_state":"Stopped","observed_at":`+f(now-5)+`,"source_age_s":5.0,"plugged":true,`+ + `"plugged_since":`+f(since)+`,"at_site":true}`) + if _, err := d.Poll(context.Background()); err != nil { + t.Fatal(err) + } + o, _ := observe(t, env) + if o.SoC != 0.14 || o.ChargeLimit != 0.8 || o.Plugged == nil || !*o.Plugged || o.AtSite == nil || !*o.AtSite { + t.Fatalf("observation %+v", o) + } + if d := o.PluggedSince.Sub(time.UnixMilli(int64(since * 1000))); d < -time.Millisecond || d > time.Millisecond { + t.Fatalf("plugged since %v, want last_changed", o.PluggedSince) + } + if age := time.Since(o.At); age < 4*time.Second || age > 7*time.Second { + t.Fatalf("the reading's own time: %v old", age) + } +} + +func TestTeslaMateVehicleSaysWhereTheCarIs(t *testing.T) { + env := vehicleEnv("teslamate") + mq := &fakeMQTT{} + env.WithMQTT(mq) + env.SiteLocation = uppsala + d, err := NewLuaDriverWithPolicy(vehicleTestdata("teslamate_vehicle"), env, signedVehicle("teslamate_vehicle")) + if err != nil { + t.Fatal(err) + } + defer d.Cleanup() + if err := d.Init(context.Background(), map[string]any{"vin": "5YJ3E1EA1KF000000", "car_id": 1}); err != nil { + t.Fatal(err) + } + for _, kv := range [][2]string{{"state", "online"}, {"healthy", "true"}, {"battery_level", "14"}, + {"plugged_in", "false"}, {"latitude", "59.8583"}, {"longitude", "17.6340"}} { + mq.Push("teslamate/cars/1/"+kv[0], kv[1]) + } + if _, err := d.Poll(context.Background()); err != nil { + t.Fatal(err) + } + // A live update: the cable goes in, with its healthy message. + mq.Push("teslamate/cars/1/plugged_in", "true") + mq.Push("teslamate/cars/1/healthy", "true") + in := time.Now() + if _, err := d.Poll(context.Background()); err != nil { + t.Fatal(err) + } + o, r := observe(t, env) + if o.Plugged == nil || !*o.Plugged || o.AtSite == nil || !*o.AtSite || o.SoC != 0.14 { + t.Fatalf("observation %+v", o) + } + if d := o.PluggedSince.Sub(in); d < -time.Second || d > time.Second { + t.Fatalf("plugged since %v, want about %v", o.PluggedSince, in) + } + noCoordinates(t, r, "59.858", "17.634") +} + +func TestTeslaCloudSaysWhereTheCarIs(t *testing.T) { + src, err := os.ReadFile(vehicleTestdata("tesla_cloud")) + if err != nil { + t.Fatal(err) + } + // Tesla's API, stubbed in Lua: the car is online, on an IEC cable, at + // Stockholm Central. + ts := strconv.FormatInt(time.Now().UnixMilli(), 10) + stub := ` +host.http_post = function(url) return '{"access_token":"a","refresh_token":"r","expires_in":28800}' end +host.http_get = function(url) + if url:find("vehicle_data", 1, true) then + assert(url:find("location_data", 1, true), "no location_data asked: " .. url) + return '{"response":{"vin":"5YJ3E1EA1KF000000","state":"online","charge_state":{"battery_level":14,` + + `"charge_limit_soc":80,"charging_state":"Stopped","conn_charge_cable":"IEC","timestamp":` + ts + `},` + + `"drive_state":{"latitude":59.3301,"longitude":18.0586,"timestamp":` + ts + `}}}' + end + return '{"response":{"vin":"5YJ3E1EA1KF000000","state":"online"}}' +end +` + path := filepath.Join(t.TempDir(), "tesla_cloud.lua") + if err := os.WriteFile(path, append([]byte(stub), src...), 0600); err != nil { + t.Fatal(err) + } + env := vehicleEnv("tesla-cloud") + env.SiteLocation = uppsala + d, err := NewLuaDriverWithPolicy(path, env, signedVehicle("tesla_cloud")) + if err != nil { + t.Fatal(err) + } + defer d.Cleanup() + if err := d.Init(context.Background(), map[string]any{"client_id": "c", "refresh_token": "r", + "vin": "5YJ3E1EA1KF000000"}); err != nil { + t.Fatal(err) + } + if _, err := d.Poll(context.Background()); err != nil { + t.Fatal(err) + } + o, r := observe(t, env) + if o.Plugged == nil || !*o.Plugged || o.AtSite == nil || *o.AtSite || !o.PluggedSince.IsZero() || o.SoC != 0.14 { + t.Fatalf("observation %+v (data %s)", o, r.Data) + } + noCoordinates(t, r, "59.33", "18.05") +} diff --git a/go/internal/ha/bridge.go b/go/internal/ha/bridge.go index d11cd619..16bb3f62 100644 --- a/go/internal/ha/bridge.go +++ b/go/internal/ha/bridge.go @@ -1112,24 +1112,37 @@ func (b *Bridge) publishState() { if r.SoC != nil { b.publishDriver(name, "vehicle_soc_pct", units.PercentFromFraction(*r.SoC)) } - attrs := map[string]any{ - "updated_at": r.UpdatedAt.UTC().Format(time.RFC3339), - } - if r.SoC != nil { - attrs["soc_pct"] = units.PercentFromFraction(*r.SoC) + if d, err := json.Marshal(vehicleAttrs(r)); err == nil { + b.publish(b.driverTopic(name, "vehicle_json"), d, false) } - if len(r.Data) > 0 { - var extra map[string]any - if json.Unmarshal(r.Data, &extra) == nil { - for k, v := range extra { + } +} + +// vehicleWhereKeys say where the car is and when its cable went in. They are +// for the box's own choice of car, and Home Assistant already knows them from +// its own entities; vehicle_json goes to a broker other clients read. +var vehicleWhereKeys = map[string]bool{"at_site": true, "site_distance_m": true, "plugged_since_ms": true} + +// vehicleAttrs is a vehicle reading's vehicle_json: its level in percent and +// the driver's own fields, less where the car is. +func vehicleAttrs(r *telemetry.DerReading) map[string]any { + attrs := map[string]any{ + "updated_at": r.UpdatedAt.UTC().Format(time.RFC3339), + } + if len(r.Data) > 0 { + var extra map[string]any + if json.Unmarshal(r.Data, &extra) == nil { + for k, v := range extra { + if !vehicleWhereKeys[k] { attrs[k] = v } } } - if d, err := json.Marshal(attrs); err == nil { - b.publish(b.driverTopic(name, "vehicle_json"), d, false) - } } + if r.SoC != nil { + attrs["soc_pct"] = units.PercentFromFraction(*r.SoC) + } + return attrs } // announceEVDriver publishes HA discovery for an EV charger reader. diff --git a/go/internal/ha/bridge_test.go b/go/internal/ha/bridge_test.go index 8140e4d3..2bb7fccc 100644 --- a/go/internal/ha/bridge_test.go +++ b/go/internal/ha/bridge_test.go @@ -1,6 +1,7 @@ package ha import ( + "encoding/json" "fmt" "strings" "testing" @@ -9,6 +10,8 @@ import ( paho "github.com/eclipse/paho.mqtt.golang" "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/telemetry" + "github.com/srcfl/ftw/go/internal/units" ) // TestNilBridgeSafe verifies that all diagnostic methods on a nil *Bridge @@ -377,3 +380,21 @@ func TestPercentFromFractionUsedForSoCDoor(t *testing.T) { t.Fatalf("_soc suffix class = (%q, %q)", unit, class) } } + +// vehicle_json goes to the LAN broker: it carries the car's level and +// charging fields, never where the car is or when its cable went in. +func TestVehicleJSONLeavesOutWhereTheCarIs(t *testing.T) { + soc := 0.14 + r := &telemetry.DerReading{Driver: "tesla-ha", SoC: &soc, UpdatedAt: time.Unix(1792082304, 0), + Data: json.RawMessage(`{"soc":0.14,"charge_limit":0.8,"charging_state":"Stopped","plugged":true,` + + `"plugged_since_ms":1792082304065,"at_site":true,"site_distance_m":40}`)} + attrs := vehicleAttrs(r) + for _, k := range []string{"at_site", "site_distance_m", "plugged_since_ms"} { + if _, ok := attrs[k]; ok { + t.Errorf("vehicle_json carries %s: %v", k, attrs) + } + } + if attrs["soc_pct"] != units.PercentFromFraction(soc) || attrs["charging_state"] != "Stopped" || attrs["plugged"] != true { + t.Errorf("vehicle_json lost the car's own fields: %v", attrs) + } +} diff --git a/go/internal/telemetry/vehicle.go b/go/internal/telemetry/vehicle.go index dc8dcfd4..bb79a70a 100644 --- a/go/internal/telemetry/vehicle.go +++ b/go/internal/telemetry/vehicle.go @@ -18,13 +18,14 @@ const VehicleMaxAge = 5 * time.Minute // A vehicle driver may give the car's distance from the site instead of // at_site. Within SiteNearM the car is here; beyond SiteAwayM it is -// elsewhere; between, the box does not know. The gap absorbs a site pin set -// by a click on a zoomed-out map and a parked car's drifting GPS fix: a car -// that wrongly reads as elsewhere turns the only car into a visitor -// (docs/cars.md), which is worse than not knowing. +// elsewhere; between, the box does not know. The site's position is the +// weather pin, set for forecasts with a click on a map shown at zoom 11, so +// it can be off by a kilometre or more. A car that wrongly reads as +// elsewhere turns the only car into a visitor (docs/cars.md), which is worse +// than not knowing. const ( SiteNearM = 300.0 - SiteAwayM = 1000.0 + SiteAwayM = 5000.0 ) // atSiteFromDistance turns a distance from the site into the car's at_site. diff --git a/go/internal/telemetry/vehicle_test.go b/go/internal/telemetry/vehicle_test.go index 708ef2b3..121b40f5 100644 --- a/go/internal/telemetry/vehicle_test.go +++ b/go/internal/telemetry/vehicle_test.go @@ -268,8 +268,9 @@ func TestVehicleObservationsReadPlugAndPlace(t *testing.T) { {"at_site", map[string]any{"plugged": true, "plugged_since_ms": since, "at_site": true}, ptrBool(true), true}, {"near", map[string]any{"plugged": true, "site_distance_m": 120}, ptrBool(true), false}, {"between", map[string]any{"plugged": true, "site_distance_m": 600}, nil, false}, - {"away", map[string]any{"plugged": true, "site_distance_m": 4200}, ptrBool(false), false}, - {"at_site wins", map[string]any{"at_site": true, "site_distance_m": 4200}, ptrBool(true), false}, + {"a few km is still unknown", map[string]any{"plugged": true, "site_distance_m": 4200}, nil, false}, + {"away", map[string]any{"plugged": true, "site_distance_m": 6200}, ptrBool(false), false}, + {"at_site wins", map[string]any{"at_site": true, "site_distance_m": 6200}, ptrBool(true), false}, {"a negative distance is unknown", map[string]any{"site_distance_m": -5}, nil, false}, {"a time with no plug flag is ignored", map[string]any{"plugged_since_ms": since}, nil, false}, {"a time before 2020 is unknown", map[string]any{"plugged": true, "plugged_since_ms": 1000}, nil, false},