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/cloud-car-class.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

FTW Cloud's copy carries each car's level and goal as the `car` class, by an opaque id and with no name, VIN or identifier, when it changes and in full every 15 minutes. A charger's level and goal fields in the copy now read from its session's car, so phones that know cars and phones that know only chargers see the same numbers.
18 changes: 14 additions & 4 deletions docs/protocol-cloud.md
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,12 @@ With cars ([docs/cars.md](cars.md)), the level fields of a Loadpoint
(`socPermille`, `socSource`, `socAgeS`, `targetPermille`, `readyByMs`,
`recurring`, `surplusOnly`) are views of the session's car, kept for apps
that know only chargers. The join is `carId` to the `car` class's `id`, and
where the two disagree the `car` class wins. A box drops the views two
where the two disagree the `car` class wins. The box fills the views from
the car's own: `socPermille` as the car's; `socSource` `vehicle` for the
car's exact reading, with its `socAgeS`, `assumed` for an assumed level,
`unknown` with no level, and `inferred` otherwise; `targetPermille` and
`recurring` (`days` not 0) from its goal; `surplusOnly` as the car's; and
`readyByMs` as the next ready time of that goal. A box drops the views two
releases after the one that first sends `car`, and from then lists
`lp.bare` in `hello.caps`: its Loadpoints carry no level fields. A box from
before cars never lists it.
Expand Down Expand Up @@ -533,9 +538,14 @@ name, maker, VIN, identifiers and sources from the box over Noise.

The box sends `car` when a car's level, source, goal or preference changes,
at most once per period like `lp`, and in full every 15 minutes and after a
`resync`, as `lp`. The change test ignores `socAgeS` and counts
`energySinceWh` as changed at 100 Wh, as `lp` counts `sessionWh`. A service
that does not know the class drops it, as for any unknown `t`.
`resync`, as `lp`. The change test ignores `socAgeS`, counts
`energySinceWh` as changed at 100 Wh, as `lp` counts `sessionWh`, and
`socPermille` at 10 or when it appears or goes, as `lp` counts its level, so
an estimate that rises with a charging car does not send the class at every
reading. Whether the level is an estimate counts at once. The box lists
`car` in `hello.caps` while it keeps cars and the grant has
`ftw.live.read`. A service that does not know the class drops it, as for any
unknown `t`.

### `prices`: `ftw.live.read`

Expand Down
2 changes: 1 addition & 1 deletion go/cmd/ftw/app_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -788,7 +788,7 @@ func startAppLink(
caps = append(caps, appproto.CapAccessAgents)
// One view for every agent session, so a charger's plug-in count is
// the same whichever session reads it.
views := agentViews{site: &cloudSite{site: site, api: gateway}, cars: carsRT}
views := agentViews{site: &cloudSite{site: site, api: gateway, cars: carsRT}, cars: carsRT}

// handlerFor builds one handler for one session: a phone's stream, or a
// watch's exchange. They share the ports above, which are read-only
Expand Down
100 changes: 100 additions & 0 deletions go/cmd/ftw/cloud_car_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package main

import (
"testing"
"time"

"github.com/srcfl/ftw/go/internal/cars"
"github.com/srcfl/ftw/go/internal/cloudlink"
"github.com/srcfl/ftw/go/internal/events"
"github.com/srcfl/ftw/go/internal/loadpoint"
"github.com/srcfl/ftw/go/internal/telemetry"
"github.com/srcfl/ftw/go/protocol"
"github.com/srcfl/ftw/go/protocol/cloudwire"
)

// A charger's level and goal fields in FTW Cloud's copy are views of its
// session's car: a phone that joins carId to the car class finds the two
// agree, at each step of a session (docs/protocol-cloud.md, lp).
func TestTheChargerShowsItsCarAsTheCarClassDoes(t *testing.T) {
now := time.Date(2026, 10, 16, 16, 30, 0, 0, time.UTC)
mgr := loadpoint.NewManager()
mgr.SetNowFn(func() time.Time { return now })
mgr.SetLocation(time.UTC)
mgr.Load([]loadpoint.Config{{ID: "easee", DriverName: "easee", VehicleCapacityWh: 76000, MinChargeW: 4140, MaxChargeW: 11000}})
rt := cars.New(cars.Deps{Store: &memRows{rows: map[string]string{}}, Loadpoints: mgr, Bus: events.NewBus(),
Now: func() time.Time { return now }, Vehicles: func() []telemetry.VehicleObservation { return nil }})
if err := rt.Start(nil); err != nil {
t.Fatal(err)
}
mgr.SetLevelObserver(rt.LevelSet)
volvo, err := rt.Add(protocol.Car{ID: "0123456789abcdef0123456789abcdef", Name: "Volvo", CapacityWh: 40000,
Goal: &protocol.CarGoal{TargetPermille: 600, ReadyByMin: 390, Days: 0x7F}, SurplusOnly: true})
if err != nil {
t.Fatal(err)
}
if _, err := rt.Add(protocol.Car{ID: "fedcba9876543210fedcba9876543210", Name: "Tesla", CapacityWh: 76000}); err != nil {
t.Fatal(err)
}
step := func(d time.Duration, wh float64) {
now = now.Add(d)
mgr.ObserveSession("easee", true, 4140, wh, true, "easee:EH1", "s1")
mgr.RollSchedules(now)
rt.Tick()
}
step(0, 0)
if _, err := rt.SetSessionCar("easee", rt.ChargerCar("easee").Session, volvo.ID); err != nil {
t.Fatal(err)
}
step(time.Second, 0)

site := &cloudSite{cars: rt}
gk := []byte("grant key")
agree := func(stage, source string) {
t.Helper()
st, _ := mgr.State("easee")
lps := []cloudwire.Loadpoint{site.loadpoint(st, now)}
site.joinCars(lps)
lp := cloudlink.ProjectLP(gk, cloudwire.LP{Loadpoints: lps}).Loadpoints[0]
var car *cloudwire.Car
for _, c := range cloudlink.ProjectCars(gk, rt.Cars()).Cars {
if c.ID == lp.CarID {
car = &c
}
}
if car == nil {
t.Fatalf("%s: carId %q is no car in the car class", stage, lp.CarID)
}
if (lp.SocPermille == nil) != (car.SocPermille == nil) ||
(lp.SocPermille != nil && *lp.SocPermille != *car.SocPermille) || lp.SocSource != source {
t.Fatalf("%s: charger level %v %q, car %v %q estimate %v", stage, deref(lp.SocPermille), lp.SocSource,
deref(car.SocPermille), car.SocSource, car.SocEstimate)
}
if lp.TargetPermille == nil || *lp.TargetPermille != car.Goal.TargetPermille || !lp.Recurring ||
lp.ReadyByMs == nil || lp.SurplusOnly != car.SurplusOnly {
t.Fatalf("%s: charger goal %v by %v recurring %v surplus %v, car %+v", stage, deref(lp.TargetPermille),
deref(lp.ReadyByMs), lp.Recurring, lp.SurplusOnly, *car)
}
}

// Nobody knows the Volvo's level: assumed.
agree("no level", "assumed")
// Someone sets it, and half a kWh flows: the car still shows the
// number given, and so does the charger.
if _, err := rt.SetLevel(volvo.ID, 0.42); err != nil {
t.Fatal(err)
}
step(time.Second, 0)
step(5*time.Minute, 500)
agree("a level set, 0.5 kWh since", "inferred")
// Past a kWh it is an estimate on both.
step(10*time.Minute, 2500)
agree("an estimate", "inferred")
}

func deref[T any](p *T) any {
if p == nil {
return nil
}
return *p
}
74 changes: 72 additions & 2 deletions go/cmd/ftw/cloud_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/srcfl/ftw/go/internal/appenroll"
"github.com/srcfl/ftw/go/internal/appproto"
"github.com/srcfl/ftw/go/internal/appuplink"
"github.com/srcfl/ftw/go/internal/cars"
"github.com/srcfl/ftw/go/internal/cloudlink"
"github.com/srcfl/ftw/go/internal/control"
"github.com/srcfl/ftw/go/internal/loadpoint"
Expand All @@ -25,6 +26,7 @@ import (
"github.com/srcfl/ftw/go/internal/state"
"github.com/srcfl/ftw/go/internal/telemetry"
"github.com/srcfl/ftw/go/internal/units"
"github.com/srcfl/ftw/go/protocol"
"github.com/srcfl/ftw/go/protocol/cloudwire"
)

Expand All @@ -43,6 +45,10 @@ type cloudSite struct {
site *appSite
api *lateAPI
prices *appPrices
// cars keeps the box's cars, nil on a box without the car runtime.
// With it, the link sends the car class, and a charger's level and
// goal fields are views of its session's car.
cars *cars.Runtime

sessMu sync.Mutex
sessions map[string]*plugSession
Expand Down Expand Up @@ -143,12 +149,76 @@ func (c *cloudSite) Loadpoints() (cloudwire.LP, bool) {
for _, st := range states {
out.Loadpoints = append(out.Loadpoints, c.loadpoint(st, now))
}
c.joinCars(out.Loadpoints)
for _, f := range feedback {
out.Feedback = append(out.Feedback, cloudFeedback(f, now))
}
return out, true
}

// Cars is every car the box keeps, for the car class. The link cuts the
// ids and leaves the names at home (cloudlink.ProjectCars).
func (c *cloudSite) Cars() ([]protocol.Car, bool) {
if c.cars == nil {
return nil, false
}
return c.cars.Cars(), true
}

// joinCars makes each charger's level and goal fields views of its
// session's car, read with the car class's own view.
func (c *cloudSite) joinCars(lps []cloudwire.Loadpoint) {
if c.cars == nil {
return
}
byID := map[string]protocol.Car{}
for _, car := range c.cars.Cars() {
byID[car.ID] = car
}
for i := range lps {
if car, ok := byID[lps[i].CarID]; ok && lps[i].CarID != "" {
joinCar(&lps[i], car)
}
}
}

// A charger's socSource on the wire (docs/protocol-cloud.md, lp).
const (
lpSoCVehicle = "vehicle"
lpSoCInferred = "inferred"
lpSoCAssumed = "assumed"
lpSoCUnknown = "unknown"
)

// joinCar makes a charger's level and goal fields views of its session's
// car (docs/protocol-cloud.md, lp): a phone that joins carId to the car
// class finds the two agree, and an app that knows only chargers reads the
// car's own level and goal. readyByMs stays the box's next ready time for
// that goal.
func joinCar(lp *cloudwire.Loadpoint, car protocol.Car) {
lp.SocPermille, lp.SocAgeS = car.SocPermille, nil
switch {
case car.SocPermille == nil:
lp.SocSource = lpSoCUnknown
case car.SocSource == protocol.CarSoCAssumed:
lp.SocSource = lpSoCAssumed
case car.SocEstimate || car.SocSource != protocol.CarSoCVehicle:
lp.SocSource = lpSoCInferred
default:
lp.SocSource, lp.SocAgeS = lpSoCVehicle, car.SocAgeS
}
lp.TargetPermille, lp.Recurring = nil, false
if g := car.Goal; g != nil {
if g.TargetPermille > 0 {
lp.TargetPermille = ptr(g.TargetPermille)
}
lp.Recurring = g.Days != 0
} else {
lp.ReadyByMs = nil
}
lp.SurplusOnly = car.SurplusOnly
}

func (c *cloudSite) session(id string, plugged bool) string {
c.sessMu.Lock()
defer c.sessMu.Unlock()
Expand Down Expand Up @@ -311,12 +381,12 @@ func startCloudLink(
ctx context.Context, statePath, build string, st *state.Store, enroll *appenroll.Identity,
tel *telemetry.Store, ctrl *control.State, ctrlMu *sync.Mutex, revision *control.Revision,
siteMeterStale time.Duration, priceSvc *prices.Service, gateway *lateAPI, notices *cloudNotices,
tunnel *appuplink.Tunnel,
tunnel *appuplink.Tunnel, carsRT *cars.Runtime,
) *cloudlink.Link {
if st == nil || enroll == nil {
return nil
}
site := &cloudSite{api: gateway, site: &appSite{
site := &cloudSite{api: gateway, cars: carsRT, site: &appSite{
tel: tel, ctrl: ctrl, ctrlMu: ctrlMu, revision: revision,
started: processStarted, siteMeterStale: siteMeterStale,
}}
Expand Down
2 changes: 1 addition & 1 deletion go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2425,7 +2425,7 @@ func main() {
appTunnel = appUplink.Tunnel()
}
cloudLink := startCloudLink(ctx, statePath, Version, st, appEnroll,
tel, ctrl, ctrlMu, controlRev, appLinkWatchdog, priceSvc, appAPI, cloudNotify, appTunnel)
tel, ctrl, ctrlMu, controlRev, appLinkWatchdog, priceSvc, appAPI, cloudNotify, appTunnel, carsRT)
appCloud.bind(cloudLink)
if cloudLink != nil {
agents.home = cloudLink.Home
Expand Down
Loading
Loading