diff --git a/.changeset/cloud-car-class.md b/.changeset/cloud-car-class.md new file mode 100644 index 00000000..b6f7adba --- /dev/null +++ b/.changeset/cloud-car-class.md @@ -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. diff --git a/docs/protocol-cloud.md b/docs/protocol-cloud.md index dc381bd1..f175c586 100644 --- a/docs/protocol-cloud.md +++ b/docs/protocol-cloud.md @@ -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. @@ -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` diff --git a/go/cmd/ftw/app_link.go b/go/cmd/ftw/app_link.go index d13d858a..314fb398 100644 --- a/go/cmd/ftw/app_link.go +++ b/go/cmd/ftw/app_link.go @@ -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 diff --git a/go/cmd/ftw/cloud_car_test.go b/go/cmd/ftw/cloud_car_test.go new file mode 100644 index 00000000..89475c56 --- /dev/null +++ b/go/cmd/ftw/cloud_car_test.go @@ -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 +} diff --git a/go/cmd/ftw/cloud_link.go b/go/cmd/ftw/cloud_link.go index 6d4baea8..14682616 100644 --- a/go/cmd/ftw/cloud_link.go +++ b/go/cmd/ftw/cloud_link.go @@ -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" @@ -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" ) @@ -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 @@ -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() @@ -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, }} diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 71338de3..e97e0c2e 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -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 diff --git a/go/internal/cloudlink/car_test.go b/go/internal/cloudlink/car_test.go new file mode 100644 index 00000000..3c9da9b4 --- /dev/null +++ b/go/internal/cloudlink/car_test.go @@ -0,0 +1,195 @@ +package cloudlink + +import ( + "bytes" + "crypto/ecdsa" + "slices" + "sync" + "testing" + "time" + + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// carSite is the fake box with cars. +type carSite struct { + *fakeSite + carMu sync.Mutex + cars []protocol.Car + calls int +} + +func (s *carSite) Cars() ([]protocol.Car, bool) { + s.carMu.Lock() + defer s.carMu.Unlock() + s.calls++ + return slices.Clone(s.cars), true +} + +func (s *carSite) change(f func(c *protocol.Car)) { + s.carMu.Lock() + defer s.carMu.Unlock() + c := s.cars[0] + f(&c) + s.cars = []protocol.Car{c} +} + +const volvoID = "0123456789abcdef0123456789abcdef" + +func newCarHarness(t *testing.T, svc *fakeService) (*harness, *carSite) { + t.Helper() + soc, age := int64(420), int64(60) + cs := &carSite{fakeSite: &fakeSite{grid: 1200, state: protocol.SourceLive}, cars: []protocol.Car{{ + ID: volvoID, Name: "Familjens Volvo", Make: "Volvo", VIN: "YV1XZK7V5N2000001", CapacityWh: 40000, + SocPermille: &soc, SocSource: protocol.CarSoCVehicle, SocAgeS: &age, + Goal: &protocol.CarGoal{TargetPermille: 800, ReadyByMin: 420, Days: 0x7f}, Identifiers: []string{"rfid-7"}, + Sources: []string{"volvo-cloud"}, + }}} + h := newHarnessWith(t, svc, []*ecdsa.PublicKey{&svc.key.PublicKey}, nil, func(o *Options) { o.Site = cs }) + h.site = cs.fakeSite + return h, cs +} + +func readCars(t *testing.T, env cloudwire.Envelope) cloudwire.Cars { + t.Helper() + var c cloudwire.Cars + if err := cloudwire.Unmarshal(env.B, &c); err != nil { + t.Fatal(err) + } + return c +} + +// The car class goes with the copy under ftw.live.read, by the grant's +// opaque id, and nothing in it names the car. +func TestTheCarClassGoesByAnOpaqueIdAndNamesNoCar(t *testing.T) { + svc := newFakeService(t) + h, _ := newCarHarness(t, svc) + h.grant(t) + env := svc.waitFor(cloudwire.MsgCar, 5*time.Second) + cars := readCars(t, env) + gk := h.record(t).GrantKey + if len(cars.Cars) != 1 || cars.Cars[0].ID != cloudwire.CarID(gk, volvoID) || *cars.Cars[0].SocPermille != 420 || + cars.Cars[0].Goal == nil || cars.Cars[0].Goal.TargetPermille != 800 { + t.Fatalf("car %+v", cars) + } + for _, name := range []string{"Familjens Volvo", "Volvo", "YV1XZK7V5N2000001", "rfid-7", "volvo-cloud", volvoID} { + if bytes.Contains(env.B, []byte(name)) { + t.Fatalf("the car class names the car: %q", name) + } + } +} + +// Without ftw.live.read in the grant, no car class goes, and hello does not +// name it. +func TestTheCarClassNeedsLive(t *testing.T) { + svc := newFakeService(t) + h, cs := newCarHarness(t, svc) + h.grant(t, protocol.ScopeEventsRead) + svc.waitFor(cloudwire.MsgRoster, 5*time.Second) + time.Sleep(500 * time.Millisecond) + for _, env := range svc.received() { + if env.T == cloudwire.MsgCar { + t.Fatal("the car class went without ftw.live.read") + } + } + cs.carMu.Lock() + defer cs.carMu.Unlock() + if cs.calls != 0 { + t.Fatalf("the link read the cars %d times with no class to send", cs.calls) + } +} + +// A goal set goes at once, within lp's period; energy flowing into the car +// does not send the class at every reading. +func TestTheCarClassGoesOnAChangeAndNotOnEveryReading(t *testing.T) { + svc := newFakeService(t) + svc.afterHello = func(conn uplinkConn, home string) { + svc.write(conn, nil, home, cloudwire.MsgWatch, cloudwire.Watch{Hz: 1}) + } + h, cs := newCarHarness(t, svc) + h.grant(t) + svc.waitFor(cloudwire.MsgCar, 5*time.Second) + + cs.change(func(c *protocol.Car) { c.EnergySinceWh = 60 }) + time.Sleep(2500 * time.Millisecond) + for _, env := range svc.received() { + if env.T == cloudwire.MsgCar && readCars(t, env).Cars[0].EnergySinceWh == 60 { + t.Fatal("60 Wh into the car sent the class") + } + } + + cs.change(func(c *protocol.Car) { c.Goal = &protocol.CarGoal{TargetPermille: 600, ReadyByMin: 390, Days: 0x1f} }) + start := time.Now() + for { + cars := readCars(t, svc.waitFor(cloudwire.MsgCar, 4*time.Second)) + if g := cars.Cars[0].Goal; g != nil && g.TargetPermille == 600 { + break + } + } + if d := time.Since(start); d > 3*time.Second { + t.Fatalf("a new goal took %s", d) + } +} + +func TestTheCarChangeTestIgnoresAgesAndCountsEnergyAsLPDoes(t *testing.T) { + soc, age, older := int64(500), int64(5), int64(900) + a := cloudwire.Cars{Cars: []cloudwire.Car{{ID: "c", SocPermille: &soc, SocAgeS: &age, EnergySinceWh: 1000}}} + b := cloudwire.Cars{Cars: []cloudwire.Car{{ID: "c", SocPermille: &soc, SocAgeS: &older, EnergySinceWh: 1099}}} + if carsChanged(a, b) { + t.Fatal("an age and 99 Wh count as a change") + } + b.Cars[0].EnergySinceWh = 1100 + if !carsChanged(a, b) { + t.Fatal("100 Wh does not count") + } + b.Cars[0].EnergySinceWh = 1000 + moved := int64(509) + b.Cars[0].SocPermille = &moved + if carsChanged(a, b) { + t.Fatal("an estimate that moved 9 ‰ counts") + } + moved = 510 + if !carsChanged(a, b) { + t.Fatal("10 ‰ does not count") + } + b.Cars[0].SocPermille = &soc + b.Cars[0].SocEstimate = true + if !carsChanged(a, b) { + t.Fatal("an exact level turning into an estimate does not count") + } + if !carsChanged(a, cloudwire.Cars{Cars: append(slices.Clone(a.Cars), cloudwire.Car{ID: "d"})}) { + t.Fatal("a new car does not count") + } +} + +// A resync for full sends the car class again, unchanged, as it sends lp. +func TestAResyncSendsTheCarClassAgain(t *testing.T) { + svc := newFakeService(t) + svc.afterHello = func(conn uplinkConn, home string) { + go func() { + time.Sleep(700 * time.Millisecond) + svc.write(conn, nil, home, cloudwire.MsgResync, cloudwire.Resync{What: []string{cloudwire.ResyncFull}}) + }() + } + h, _ := newCarHarness(t, svc) + h.grant(t) + first := svc.waitFor(cloudwire.MsgCar, 5*time.Second) + again := svc.waitFor(cloudwire.MsgCar, 3*time.Second) + if !bytes.Equal(readCarsKey(t, first), readCarsKey(t, again)) { + t.Fatal("the resync sent another car class") + } +} + +func readCarsKey(t *testing.T, env cloudwire.Envelope) []byte { + t.Helper() + c := readCars(t, env) + for i := range c.Cars { + c.Cars[i].SocAgeS = nil + } + raw, err := cloudwire.Marshal(c.Cars) + if err != nil { + t.Fatal(err) + } + return raw +} diff --git a/go/internal/cloudlink/link.go b/go/internal/cloudlink/link.go index 3df6ecee..f38a0e4e 100644 --- a/go/internal/cloudlink/link.go +++ b/go/internal/cloudlink/link.go @@ -118,6 +118,14 @@ type Site interface { Prices() (p cloudwire.Prices, ok bool) } +// CarSite is a Site that keeps cars (docs/cars.md). Cars is every car in +// the box's order, with its names, which the link leaves at home +// (ProjectCars). ok is false while the box keeps no cars. A Site without it +// sends no car class. +type CarSite interface { + Cars() (cars []protocol.Car, ok bool) +} + // Live is the live class before ids are cut. type Live struct { // Fields uses the app protocol's frozen ids as text keys. @@ -1036,8 +1044,9 @@ func (snap grantSnapshot) allowed(class string) bool { // when the box takes the tunnel. func (l *Link) caps(snap grantSnapshot) []string { var out []string - for _, c := range []string{cloudwire.MsgLive, cloudwire.MsgLP, cloudwire.MsgPrices, cloudwire.MsgHB, cloudwire.MsgEvent} { - if snap.allowed(c) { + _, cars := l.opts.Site.(CarSite) + for _, c := range []string{cloudwire.MsgLive, cloudwire.MsgLP, cloudwire.MsgCar, cloudwire.MsgPrices, cloudwire.MsgHB, cloudwire.MsgEvent} { + if snap.allowed(c) && (c != cloudwire.MsgCar || cars) { out = append(out, c) } } diff --git a/go/internal/cloudlink/sender.go b/go/internal/cloudlink/sender.go index 93777ae9..54b905f7 100644 --- a/go/internal/cloudlink/sender.go +++ b/go/internal/cloudlink/sender.go @@ -79,6 +79,8 @@ type cadence struct { liveAt time.Time lpLast *cloudwire.LP lpAt time.Time + carLast *cloudwire.Cars + carAt time.Time pricesKey string pricesAt time.Time anyAt time.Time // last live or hb @@ -92,8 +94,8 @@ type cadence struct { // still waiting for its period. Composing reads the box, and on a Pi // the loadpoint view is not free, so it happens no faster than a // message could go. - liveCheckedAt, lpCheckedAt time.Time - liveWaiting, lpWaiting bool + liveCheckedAt, lpCheckedAt, carCheckedAt time.Time + liveWaiting, lpWaiting, carWaiting bool // arrived says a driver finished a poll since live was last composed, // while a phone watches. arrived bool @@ -318,6 +320,28 @@ func (w *writer) tick(snap grantSnapshot, st *cadence, now time.Time) error { } } } + // car goes as lp does: at most once per lp's period, and in full with + // it (docs/protocol-cloud.md, car). + if cs, ok := l.opts.Site.(CarSite); ok && snap.allowed(cloudwire.MsgCar) && (full || now.Sub(st.carCheckedAt) >= lpPeriod) { + st.carCheckedAt = now + st.carWaiting = false + if in, ok := cs.Cars(); ok { + cars := ProjectCars(snap.g.GrantKey, in) + changed := st.carLast == nil || carsChanged(*st.carLast, cars) + switch { + case full || (changed && now.Sub(st.carAt) >= lpPeriod): + sent, err := w.sendBody(snap, cloudwire.MsgCar, &cars, false) + if err != nil { + return err + } + if sent { + st.carLast, st.carAt = &cars, now + } + case changed: + st.carWaiting = true + } + } + } if full { st.full, st.lastFull = false, now } @@ -608,6 +632,36 @@ func lpChanged(prev, cur cloudwire.LP) bool { return false } +// carsKey is the car class without its ages and its measured values, which +// carsChanged compares with lp's deadbands instead. +func carsKey(c cloudwire.Cars) string { + k := cloudwire.Cars{Cars: make([]cloudwire.Car, 0, len(c.Cars))} + for _, car := range c.Cars { + car.SocAgeS, car.SocPermille, car.EnergySinceWh = nil, nil, 0 + k.Cars = append(k.Cars, car) + } + return digest(k) +} + +// carsChanged reports whether cur says something prev did not. The energy +// since a reading counts as lp's sessionWh does, and an estimated level as +// lp's level does, so a charging car does not send its class at every +// reading. +func carsChanged(prev, cur cloudwire.Cars) bool { + if carsKey(prev) != carsKey(cur) { + return true + } + for i, c := range cur.Cars { + p := prev.Cars[i] + if abs(p.EnergySinceWh-c.EnergySinceWh) >= lpEnergyBandWh || + (p.SocPermille == nil) != (c.SocPermille == nil) || + (c.SocPermille != nil && abs(*p.SocPermille-*c.SocPermille) >= lpSocBandPermille) { + return true + } + } + return false +} + func abs(v int64) int64 { if v < 0 { return -v @@ -791,6 +845,13 @@ func (l *Link) sample() []sentFrame { lp.Header = h add(cloudwire.MsgLP, lp) } + if cs, ok := l.opts.Site.(CarSite); ok && snap.allowed(cloudwire.MsgCar) { + if in, ok := cs.Cars(); ok { + cars := ProjectCars(gk, in) + cars.Header = h + add(cloudwire.MsgCar, cars) + } + } if p, ok := l.opts.Site.Prices(); ok && snap.allowed(cloudwire.MsgPrices) { p.Header = h add(cloudwire.MsgPrices, p)