Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/car-says-plugged-here.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": minor
---

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.
27 changes: 19 additions & 8 deletions docs/cars.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,18 +106,29 @@ 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.
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
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. 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: 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 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. 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
Expand Down
29 changes: 28 additions & 1 deletion docs/writing-a-driver.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -232,3 +233,29 @@ 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. 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 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 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 %.
20 changes: 20 additions & 0 deletions go/cmd/ftw/driver_registry.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"math"
"time"

"github.com/srcfl/ftw/go/internal/config"
Expand Down Expand Up @@ -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
}
20 changes: 20 additions & 0 deletions go/cmd/ftw/driver_registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
8 changes: 7 additions & 1 deletion go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down
20 changes: 16 additions & 4 deletions go/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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
Expand Down
58 changes: 58 additions & 0 deletions go/internal/api/api_vehicle_card_test.go
Original file line number Diff line number Diff line change
@@ -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"])
}
}
}
Loading
Loading