diff --git a/.changeset/ftw-cloud-events.md b/.changeset/ftw-cloud-events.md new file mode 100644 index 000000000..250b8348c --- /dev/null +++ b/.changeset/ftw-cloud-events.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +With FTW Cloud on and events in the grant, the box sends its notifications as signed events for the owners' phones; names never leave the house. A restart the box chooses, an update among them, tells FTW Cloud so it holds back "box out of reach". A new notification, "The car may not be ready", tells once per plug-in when the box knows the car's level and the plan cannot reach the goal by the ready time. A car that reports nothing itself gets "Car charged" in kWh once the box's estimate from a level someone set reaches the target. "Car plugged in" now waits 90 seconds and is sent only when the box needs the level or a goal. Each notification transport now has its own 10-second limit, so one that hangs no longer delays the others. diff --git a/contract/push-catalogue.yaml b/contract/push-catalogue.yaml index 8560885d2..8473df44a 100644 --- a/contract/push-catalogue.yaml +++ b/contract/push-catalogue.yaml @@ -19,6 +19,12 @@ events: - kind: charging.session_complete title: Car charged body: "{kwh} kWh delivered — ready to go." + - kind: charging.goal_at_risk + # Only when the box knows the car's level, from the car or from someone + # who set it, and the plan cannot reach the goal by the ready time. An + # assumed level would make this a guess. Once per plug-in. + title: The car may not be ready + body: "It will not reach {target} % by {by} at this pace." - kind: charging.interrupted # Only after the hysteresis window the box applies: a charge that had # been running steadily and then stopped without finishing. A flapping diff --git a/go/cmd/ftw/cloud_link.go b/go/cmd/ftw/cloud_link.go index 92120aab9..065969b23 100644 --- a/go/cmd/ftw/cloud_link.go +++ b/go/cmd/ftw/cloud_link.go @@ -337,6 +337,20 @@ func startCloudLink( return link } +// cloudByeWait bounds how long shutdown waits for the bye to leave. +const cloudByeWait = 2 * time.Second + +// cloudEventQueue turns a rendered notification into an event for the +// uplink, which cuts the names out before it signs. +func cloudEventQueue(link *cloudlink.Link) func(notifications.Message) bool { + return func(m notifications.Message) bool { + return link.QueueEvent(cloudlink.EventIn{ + Kind: m.Kind, Priority: m.Priority, Args: m.Args, + Driver: m.Device, Loadpoint: m.LoadpointID, At: m.At, + }) + } +} + // cloudForAPI hands the link to the API, or nothing: a typed nil in an // interface would pass the routes' nil check and panic on first use. func cloudForAPI(link *cloudlink.Link) api.CloudGrant { diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 95a9686cd..cb9e61603 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -68,6 +68,7 @@ import ( "github.com/srcfl/ftw/go/internal/selfupdate" "github.com/srcfl/ftw/go/internal/state" "github.com/srcfl/ftw/go/internal/telemetry" + "github.com/srcfl/ftw/go/protocol/cloudwire" ) // Version gets injected at build time via -ldflags. Defaults to "dev" for @@ -2657,6 +2658,20 @@ func main() { notifSvc.AddPublisher(webPush) deps.WebPush = webPush } + // FTW Cloud carries the catalogue's events to the owners' phones, + // signed, when the grant includes them. The publisher only queues. + if cloudLink != nil { + notifSvc.AddPublisher(notifications.CloudPublisher{Queue: cloudEventQueue(cloudLink)}) + } + notifSvc.SetChargingFacts(func(id string) (notifications.ChargingFacts, bool) { + lp, ok := lpMgr.State(id) + if !ok { + return notifications.ChargingFacts{}, false + } + return notifications.ChargingFacts{ + Plugged: lp.PluggedIn, LevelKnown: lp.PluggedIn && lp.SoCSource != "assumed", HasGoal: lp.TargetSoC > 0, + }, true + }) if appUplink != nil { deps.PushResync = appUplink.ResyncDeadman } @@ -2773,6 +2788,7 @@ func main() { select { case <-sigc: slog.Info("shutting down") + cloudLink.Bye(cloudwire.ByeShutdown, cloudByeWait) markNativeCleanExit(nativeRoot) flushHistoryOnStop(st) if err := st.RecordEvent("shutdown"); err != nil { @@ -2785,6 +2801,9 @@ func main() { } else { slog.Info("restart requested via API — exiting cleanly so the supervisor brings us back") } + // A restart the box chose, an update among them: FTW Cloud holds + // back "box out of reach" until the box is back. + cloudLink.Bye(cloudwire.ByeUpdate, cloudByeWait) markNativeCleanExit(nativeRoot) flushHistoryOnStop(st) if err := st.RecordEvent("restart"); err != nil { @@ -3195,6 +3214,15 @@ func main() { } } + // A car whose plan cannot make its goal: the loadpoint manager + // tells the household once per plug-in, from a plan that is + // neither being remade nor out of date. + if mpcSvc != nil { + if snap := mpcSvc.PlanSnapshot(); snap.Plan != nil && !snap.Pending && !snap.Outdated { + lpMgr.ObservePlanShortfall(snap.Plan.LoadpointShortfallWh, tickNow) + } + } + // First-vehicle-SoC replan trigger: as soon as any // DerVehicle driver is online and reporting SoC, redo the // plan once with measured-truth instead of the pluginSoC diff --git a/go/cmd/sim-ocpp/main.go b/go/cmd/sim-ocpp/main.go index bef67bd43..92a1b46c9 100644 --- a/go/cmd/sim-ocpp/main.go +++ b/go/cmd/sim-ocpp/main.go @@ -121,6 +121,17 @@ func main() { } w.WriteHeader(204) }) + for path, stalled := range map[string]bool{"stall": true, "resume": false} { + mux.HandleFunc("POST /charger/{id}/"+path, func(w http.ResponseWriter, r *http.Request) { + sim := findSim(sims, r.PathValue("id")) + if sim == nil { + http.NotFound(w, r) + return + } + sim.Stall(stalled) + w.WriteHeader(204) + }) + } go func() { slog.Info("control listening", "addr", *control) if err := http.ListenAndServe(*control, mux); err != nil { diff --git a/go/cmd/sim-ocpp/ocppcp/physics.go b/go/cmd/sim-ocpp/ocppcp/physics.go index 40377afa1..badd76fb1 100644 --- a/go/cmd/sim-ocpp/ocppcp/physics.go +++ b/go/cmd/sim-ocpp/ocppcp/physics.go @@ -19,6 +19,9 @@ type Physics struct { DrawA float64 EnergyWh float64 Plugged bool + // Stalled is a charger that stops delivering while the car still wants + // energy, as a tripped relay or a cloud-side fault does. + Stalled bool } func newPhysics(m Model, tauS float64) Physics { @@ -39,7 +42,7 @@ func (p Physics) PowerW() float64 { } func (p Physics) targetA() float64 { - if !p.Plugged || p.LimitA < minChargeAmps { + if !p.Plugged || p.Stalled || p.LimitA < minChargeAmps { return 0 } if p.LimitA > p.MaxAmps { diff --git a/go/cmd/sim-ocpp/ocppcp/sim.go b/go/cmd/sim-ocpp/ocppcp/sim.go index 685135a05..59886a807 100644 --- a/go/cmd/sim-ocpp/ocppcp/sim.go +++ b/go/cmd/sim-ocpp/ocppcp/sim.go @@ -169,6 +169,13 @@ func (s *Sim) Unplug() error { return s.statusPlugged(false) } +// Stall stops delivery with the car still asking, or lets it go on. +func (s *Sim) Stall(stalled bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.physics.Stalled = stalled +} + // Tick advances physics. Tests call this with a positive dt and TauS=0 to settle. func (s *Sim) Tick(dt time.Duration) { s.mu.Lock() diff --git a/go/internal/cloudlink/conn.go b/go/internal/cloudlink/conn.go index 0d5bf20fe..4559c59a6 100644 --- a/go/internal/cloudlink/conn.go +++ b/go/internal/cloudlink/conn.go @@ -69,7 +69,7 @@ func (l *Link) once(ctx context.Context, snap grantSnapshot) (time.Duration, err l.attempt = 0 } l.mu.Unlock() - if errors.Is(err, errPurged) { + if errors.Is(err, errPurged) || errors.Is(err, errClosed) { return 0, nil } return l.backoff(), err diff --git a/go/internal/cloudlink/events.go b/go/internal/cloudlink/events.go new file mode 100644 index 000000000..3ad8cce27 --- /dev/null +++ b/go/internal/cloudlink/events.go @@ -0,0 +1,196 @@ +package cloudlink + +import ( + "encoding/base64" + "errors" + "time" + + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// Events and the last word. +// +// The box's notifications reach FTW Cloud as signed events, which the +// service wraps unchanged in an alert for the owners' phones. QueueEvent only +// queues; the writer signs and sends on its own goroutine, ahead of anything +// else due on that tick. An event waits at most cloudwire.AlertMaxAgeMs for +// the uplink: older, a phone would show it without its text and buttons, so +// it is dropped instead. A reconnect sends the events still waiting and +// replays none that left. + +// EventIn is one notification the box raised, before ids are cut. +type EventIn struct { + Kind string + // Priority is the rule's, 1 to 5. It becomes the event's severity. + Priority int + // Args are the catalogue's placeholders. Names are never sent: the + // link drops the name placeholders and sends the device's opaque id. + Args map[string]string + // Driver and Loadpoint are the box's own names for what the event + // concerns, empty when it concerns neither. + Driver string + Loadpoint string + At time.Time +} + +const ( + // eventRing is how many events wait for the uplink. When it is full + // the oldest goes, and the drop is counted. + eventRing = 32 + // eventIDBytes makes a 12-character event id. + eventIDBytes = 9 +) + +// nameArgs are catalogue placeholders that hold a name. The phone fills +// them from the table it holds from the box. +var nameArgs = map[string]bool{"name": true} + +// errClosed ends the uplink after its bye, as the box shuts down. +var errClosed = errors.New("cloudlink: the box is shutting down") + +// QueueEvent keeps an event for the uplink to sign and send. It never +// blocks. False means FTW Cloud will not carry it: off, being revoked, or a +// grant that leaves events out. +func (l *Link) QueueEvent(e EventIn) bool { + l.mu.Lock() + g := l.grant + if g == nil || g.Revoked || !g.has(protocol.ScopeEventsRead) || l.closing { + l.mu.Unlock() + return false + } + if e.At.IsZero() { + e.At = time.Now() + } + if len(l.events) >= eventRing { + l.events = l.events[1:] + l.eventsDropped++ + } + l.events = append(l.events, e) + l.mu.Unlock() + l.signal() + return true +} + +// sendEvents signs and sends what waits, oldest first. +func (w *writer) sendEvents(snap grantSnapshot, now time.Time) error { + l := w.link + if !snap.allowed(cloudwire.MsgEvent) { + return nil + } + l.mu.Lock() + queued := l.events + l.events = nil + l.mu.Unlock() + for i, e := range queued { + if now.Sub(e.At) > cloudwire.AlertMaxAgeMs*time.Millisecond { + l.mu.Lock() + l.eventsDropped++ + l.mu.Unlock() + continue + } + ev, err := l.projectEvent(snap, e) + if err != nil { + return err + } + if _, err := w.sendBody(snap, cloudwire.MsgEvent, &ev, true); err != nil { + // The one that failed may have left; the rest have not. + l.mu.Lock() + l.events = append(queued[i+1:len(queued):len(queued)], l.events...) + l.mu.Unlock() + return err + } + } + return nil +} + +// projectEvent cuts opaque ids and drops names. +func (l *Link) projectEvent(snap grantSnapshot, e EventIn) (cloudwire.Event, error) { + id, err := randomBytes(eventIDBytes) + if err != nil { + return cloudwire.Event{}, err + } + gk := snap.g.GrantKey + ev := cloudwire.Event{ + EventID: base64.RawURLEncoding.EncodeToString(id), Kind: e.Kind, + Severity: severity(e.Priority), Args: map[string]string{}, + } + for k, v := range e.Args { + if !nameArgs[k] { + ev.Args[k] = v + } + } + if e.Driver != "" { + ev.Src = cloudwire.SourceID(gk, e.Driver) + } + if e.Loadpoint != "" { + ev.LP = cloudwire.LoadpointID(gk, e.Loadpoint) + } + if l.clockSynced() { + ts := e.At.UnixMilli() + ev.Ts = &ts + } + return ev, nil +} + +func severity(priority int) string { + switch { + case priority >= 5: + return cloudwire.SeverityAlarm + case priority == 4: + return cloudwire.SeverityWarning + default: + return cloudwire.SeverityInfo + } +} + +// Bye tells the service why the box is going and stops the uplink for good. +// why is cloudwire.ByeUpdate for a restart the box chose, such as an update, +// which holds back box.unreachable for an hour; ByeShutdown otherwise. It +// waits at most wait for the bye to leave, and returns at once when the +// uplink is down. +func (l *Link) Bye(why string, wait time.Duration) { + if l == nil { + return + } + l.mu.Lock() + if l.closing { + l.mu.Unlock() + return + } + l.closing, l.byeWhy = true, why + done := make(chan struct{}) + l.byeDone = done + connected := l.connected + l.mu.Unlock() + l.signal() + if !connected { + return + } + select { + case <-done: + case <-time.After(wait): + } +} + +// closeIfAsked sends the bye Bye asked for and ends the connection. +func (w *writer) closeIfAsked(snap grantSnapshot) error { + l := w.link + l.mu.Lock() + closing, why, done := l.closing, l.byeWhy, l.byeDone + l.mu.Unlock() + if !closing { + return nil + } + err := w.bye(snap, why, false) + l.mu.Lock() + l.byeDone = nil + l.mu.Unlock() + if done != nil { + close(done) + } + if err != nil { + return err + } + return errClosed +} diff --git a/go/internal/cloudlink/events_test.go b/go/internal/cloudlink/events_test.go new file mode 100644 index 000000000..a91f36b45 --- /dev/null +++ b/go/internal/cloudlink/events_test.go @@ -0,0 +1,158 @@ +package cloudlink + +import ( + "bytes" + "testing" + "time" + + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// An event leaves signed, numbered and timed, with opaque ids for what it +// concerns and no name in it anywhere. +func TestAnEventLeavesSignedWithoutNames(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + h.grant(t, protocol.ScopeLiveRead, protocol.ScopeEventsRead) + svc.waitFor(cloudwire.MsgLive, 5*time.Second) + + at := time.Now().Add(-3 * time.Second) + if !h.link.QueueEvent(EventIn{Kind: protocol.PushDriverOffline, Priority: 4, + Args: map[string]string{"name": chargerName}, Driver: chargerName, At: at}) { + t.Fatal("a grant with events refused an event") + } + if !h.link.QueueEvent(EventIn{Kind: protocol.PushChargingInterrupted, Priority: 4, Loadpoint: lpName}) { + t.Fatal("a grant with events refused an event") + } + first := svc.waitFor(cloudwire.MsgEvent, 5*time.Second) + second := svc.waitFor(cloudwire.MsgEvent, 5*time.Second) + + pub, err := cloudwire.ParsePublicKey(h.link.current().pk) + if err != nil { + t.Fatal(err) + } + var a, b cloudwire.Event + for _, x := range []struct { + env cloudwire.Envelope + out *cloudwire.Event + }{{first, &a}, {second, &b}} { + if err := cloudwire.Verify(pub, svc.home, x.env); err != nil { + t.Fatalf("event not signed by the box: %v", err) + } + if err := cloudwire.Unmarshal(x.env.B, x.out); err != nil { + t.Fatal(err) + } + } + gk := h.link.current().g.GrantKey + if a.Kind != protocol.PushDriverOffline || a.Severity != cloudwire.SeverityWarning || + a.Src != cloudwire.SourceID(gk, chargerName) || len(a.Args) != 0 || a.LP != "" { + t.Fatalf("driver.offline went as %+v", a) + } + if a.Ts == nil || *a.Ts != at.UnixMilli() || a.WallMs == nil || len(a.EventID) != 12 { + t.Fatalf("driver.offline has no time or id: %+v", a) + } + if b.Kind != protocol.PushChargingInterrupted || b.LP != cloudwire.LoadpointID(gk, lpName) || + b.Seq <= a.Seq || b.EventID == a.EventID { + t.Fatalf("charging.interrupted went as %+v after %+v", b, a) + } + for _, raw := range [][]byte{first.B, second.B} { + if bytes.Contains(raw, []byte(chargerName)) || bytes.Contains(raw, []byte(lpName)) { + t.Fatalf("a name left the house in %x", raw) + } + } +} + +// Events are their own scope: a grant without it sends none. +func TestAGrantWithoutEventsQueuesNothing(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + if h.link.QueueEvent(EventIn{Kind: protocol.PushChargingConnected}) { + t.Fatal("an event was queued with FTW Cloud off") + } + h.grant(t, protocol.ScopeLiveRead) + if h.link.QueueEvent(EventIn{Kind: protocol.PushChargingConnected}) { + t.Fatal("an event was queued under a grant that leaves events out") + } +} + +// An event that waited longer than a phone would act on it is dropped and +// counted, not sent late. +func TestAnEventTooOldForThePhoneIsDropped(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + h.grant(t, protocol.ScopeLiveRead, protocol.ScopeEventsRead) + svc.waitFor(cloudwire.MsgLive, 5*time.Second) + h.link.QueueEvent(EventIn{Kind: protocol.PushChargingConnected, At: time.Now().Add(-11 * time.Minute)}) + h.link.QueueEvent(EventIn{Kind: protocol.PushChargingInterrupted}) + env := svc.waitFor(cloudwire.MsgEvent, 5*time.Second) + var e cloudwire.Event + if err := cloudwire.Unmarshal(env.B, &e); err != nil { + t.Fatal(err) + } + if e.Kind != protocol.PushChargingInterrupted { + t.Fatalf("sent %s, an event eleven minutes old", e.Kind) + } + if st := h.link.Status(); st.EventsDropped != 1 { + t.Fatalf("dropped %d", st.EventsDropped) + } +} + +// The queue holds the newest events and counts what it pushed out. Here the +// service fails the binding, so nothing drains the queue. +func TestTheEventQueueKeepsTheNewest(t *testing.T) { + svc := newFakeService(t) + svc.badEKM = true + h := newHarness(t, svc) + h.grant(t, protocol.ScopeLiveRead, protocol.ScopeEventsRead) + for range eventRing + 3 { + if !h.link.QueueEvent(EventIn{Kind: protocol.PushChargingConnected}) { + t.Fatal("refused") + } + } + h.link.mu.Lock() + n := len(h.link.events) + h.link.mu.Unlock() + if st := h.link.Status(); n != eventRing || st.EventsDropped != 3 { + t.Fatalf("queue %d, dropped %d", n, st.EventsDropped) + } +} + +// A restart the box chose says so, and the link stops dialling. +func TestByeForAnUpdateLeavesAndTheLinkStops(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + h.grant(t, protocol.ScopeLiveRead) + svc.waitFor(cloudwire.MsgLive, 5*time.Second) + for !h.link.Status().Connected { + time.Sleep(10 * time.Millisecond) + } + + h.link.Bye(cloudwire.ByeUpdate, 2*time.Second) + env := svc.waitFor(cloudwire.MsgBye, time.Second) + var b cloudwire.Bye + if err := cloudwire.Unmarshal(env.B, &b); err != nil { + t.Fatal(err) + } + if b.Why != cloudwire.ByeUpdate || b.Purge { + t.Fatalf("bye %+v", b) + } + select { + case err := <-h.done: + if err != nil { + t.Fatalf("Run ended with %v", err) + } + h.done <- nil // for the cleanup + case <-time.After(3 * time.Second): + t.Fatal("the link kept running after its bye") + } + if h.link.QueueEvent(EventIn{Kind: protocol.PushChargingConnected}) { + t.Fatal("an event was queued after the bye") + } + svc.mu.Lock() + conns := svc.conns + svc.mu.Unlock() + if conns != 1 { + t.Fatalf("the box dialled %d times", conns) + } +} diff --git a/go/internal/cloudlink/link.go b/go/internal/cloudlink/link.go index 7f4f15823..3b15fe923 100644 --- a/go/internal/cloudlink/link.go +++ b/go/internal/cloudlink/link.go @@ -160,6 +160,15 @@ type Link struct { attempt int purgedAt int64 rosterDue bool + + // events wait for the writer; eventsDropped counts those that never + // left. closing is set by Bye, after which the link does not dial + // again. + events []EventIn + eventsDropped int64 + closing bool + byeWhy string + byeDone chan struct{} } // New loads the grant and the key and returns a link that runs once Run is @@ -461,6 +470,10 @@ type Status struct { BudgetReached bool `json:"budgetReached"` // PurgedAtMs is the time on the service's receipt for the last deletion. PurgedAtMs int64 `json:"purgedAtMs,omitempty"` + // EventsDropped counts notifications that never reached the service: + // the queue was full, or the uplink was down for longer than a phone + // would still act on them. + EventsDropped int64 `json:"eventsDropped"` } // Status reports the grant and the link. @@ -470,7 +483,7 @@ func (l *Link) Status() Status { s := Status{ State: "off", Notice: l.notice, Available: len(l.opts.ServiceKeys) > 0, Scopes: []string{}, Purposes: []string{}, Connected: l.connected, - LastSendAgeMs: -1, PurgedAtMs: l.purgedAt, + LastSendAgeMs: -1, PurgedAtMs: l.purgedAt, EventsDropped: l.eventsDropped, BytesLast24h: l.budget.total(l.uptimeMs()), BudgetBytes: budgetBytes, } s.BudgetReached = s.BytesLast24h >= budgetBytes @@ -517,6 +530,9 @@ func (l *Link) current() grantSnapshot { // Run keeps the uplink up while a grant is on, until ctx ends. func (l *Link) Run(ctx context.Context) error { for { + if l.isClosing() { + return nil + } snap := l.current() if snap.g == nil || len(l.opts.ServiceKeys) == 0 { select { @@ -543,6 +559,12 @@ func (l *Link) Run(ctx context.Context) error { } } +func (l *Link) isClosing() bool { + l.mu.Lock() + defer l.mu.Unlock() + return l.closing +} + // allowed reports whether the grant lets a class leave the house. func (snap grantSnapshot) allowed(class string) bool { scope, forwarded := cloudwire.ClassScope[class] @@ -555,7 +577,7 @@ func (snap grantSnapshot) allowed(class string) bool { // caps is the classes this box sends under the grant. func (snap grantSnapshot) caps() []string { var out []string - for _, c := range []string{cloudwire.MsgLive, cloudwire.MsgLP, cloudwire.MsgPrices, cloudwire.MsgHB} { + for _, c := range []string{cloudwire.MsgLive, cloudwire.MsgLP, cloudwire.MsgPrices, cloudwire.MsgHB, cloudwire.MsgEvent} { if snap.allowed(c) { out = append(out, c) } diff --git a/go/internal/cloudlink/sender.go b/go/internal/cloudlink/sender.go index 04d528a70..499e735ad 100644 --- a/go/internal/cloudlink/sender.go +++ b/go/internal/cloudlink/sender.go @@ -120,8 +120,16 @@ func (w *writer) run(ctx context.Context, events <-chan cloudwire.Envelope) erro } else if now.Sub(purgeSince) > purgeWait { return errors.New("cloudlink: no purge receipt yet") } - } else if err := w.tick(snap, st, now); err != nil { - return err + } else { + if err := w.closeIfAsked(snap); err != nil { + return err + } + if err := w.sendEvents(snap, now); err != nil { + return err + } + if err := w.tick(snap, st, now); err != nil { + return err + } } select { diff --git a/go/internal/events/bus.go b/go/internal/events/bus.go index 27072c483..fceaad484 100644 --- a/go/internal/events/bus.go +++ b/go/internal/events/bus.go @@ -71,6 +71,7 @@ const ( KindChargingConnected = "charging.connected" KindChargingSessionComplete = "charging.session_complete" KindChargingInterrupted = "charging.interrupted" + KindChargingGoalAtRisk = "charging.goal_at_risk" ) // HealthTick is fired every control-loop tick with the current telemetry @@ -156,9 +157,11 @@ type UpdateInstalled struct { func (UpdateInstalled) Kind() string { return KindUpdateInstalled } -// ChargingSessionComplete is emitted once per session when a fresh, -// matched vehicle BMS reading confirms that the active target was reached. -// A charger refusing current does not establish battery state. +// ChargingSessionComplete is emitted once per session when the active target +// was reached: by a fresh, matched vehicle BMS reading, or, for a car that +// reports nothing itself, by the box's estimate from a level someone set. +// The estimate is energy the box measured, so subscribers say it in kWh. A +// charger refusing current does not establish battery state. type ChargingSessionComplete struct { LoadpointID string KWh float64 // what the session meter delivered @@ -185,3 +188,18 @@ type ChargingConnected struct { } func (ChargingConnected) Kind() string { return KindChargingConnected } + +// ChargingGoalAtRisk is emitted once per plug-in when the plan cannot bring +// the car to its target by the ready time, and the box knows the car's +// level rather than assuming it. +type ChargingGoalAtRisk struct { + LoadpointID string + TargetSoC float64 // fraction + By time.Time + // ByClock is By as a household reads it, "07:00" in the box's zone. + ByClock string + ShortfallWh float64 + At time.Time +} + +func (ChargingGoalAtRisk) Kind() string { return KindChargingGoalAtRisk } diff --git a/go/internal/loadpoint/goal_risk.go b/go/internal/loadpoint/goal_risk.go new file mode 100644 index 000000000..af76420cc --- /dev/null +++ b/go/internal/loadpoint/goal_risk.go @@ -0,0 +1,59 @@ +package loadpoint + +import ( + "time" + + "github.com/srcfl/ftw/go/internal/events" +) + +const ( + // GoalRiskMinWh is the smallest shortfall worth telling anyone about. + // Below it the plan is close enough that a rounding of the estimate or + // a slightly cheaper slot changes the answer. + GoalRiskMinWh = 1000 + // minCompletionWh keeps "car charged" for sessions that charged. + minCompletionWh = 100 +) + +// ObservePlanShortfall takes a fresh plan's shortfall per loadpoint, in +// battery watt-hours, and raises charging.goal_at_risk once per plug-in for +// a car that will not reach its target by its ready time. Only when the box +// knows the car's level, from the car or from someone who set it: on an +// assumed level the shortfall is a guess, and the plug-in notification +// already asks for the level. The caller passes only plans that are neither +// being remade nor out of date. +func (m *Manager) ObservePlanShortfall(shortfallWh map[string]float64, now time.Time) { + if m == nil || len(shortfallWh) == 0 { + return + } + var fired []events.Event + m.mu.Lock() + bus := m.bus + for _, id := range m.order { + lp, ok := m.byID[id] + if !ok || !lp.pluggedIn || lp.goalRiskNotified || !lp.socConfirmed || lp.completionNotified || + lp.targetSoC <= 0 || lp.targetTime.IsZero() || !lp.targetTime.After(now) { + continue + } + wh := shortfallWh[id] + if wh < GoalRiskMinWh { + continue + } + lp.goalRiskNotified = true + by := lp.targetTime.Local() + if m.loc != nil { + by = lp.targetTime.In(m.loc) + } + fired = append(fired, events.ChargingGoalAtRisk{ + LoadpointID: id, TargetSoC: lp.targetSoC, By: lp.targetTime, ByClock: by.Format("15:04"), + ShortfallWh: wh, At: now, + }) + } + m.mu.Unlock() + if bus == nil { + return + } + for _, e := range fired { + bus.Publish(e) + } +} diff --git a/go/internal/loadpoint/goal_risk_test.go b/go/internal/loadpoint/goal_risk_test.go new file mode 100644 index 000000000..6a99541f0 --- /dev/null +++ b/go/internal/loadpoint/goal_risk_test.go @@ -0,0 +1,136 @@ +package loadpoint + +import ( + "sync" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/events" +) + +func goalRiskLog(bus *events.Bus) func() []events.ChargingGoalAtRisk { + var mu sync.Mutex + var got []events.ChargingGoalAtRisk + bus.Subscribe(events.KindChargingGoalAtRisk, func(e events.Event) { + if ev, ok := e.(events.ChargingGoalAtRisk); ok { + mu.Lock() + got = append(got, ev) + mu.Unlock() + } + }) + return func() []events.ChargingGoalAtRisk { + mu.Lock() + defer mu.Unlock() + return append([]events.ChargingGoalAtRisk(nil), got...) + } +} + +// A car that reports nothing is done when the box's estimate, from a level +// someone set, reaches the target. Once, in kWh. +func TestACarWithoutItsOwnDataIsDoneAtTheBoxsEstimate(t *testing.T) { + r := newSessionRig(t) + r.mgr.SetTarget("garage", 0.8, time.Time{}) + r.tick(0, true, 11_000, 0, true) + r.mgr.SetCurrentSoC("garage", 0.5) + r.tick(time.Hour, true, 11_000, 10_000, true) + if c, _ := r.log.counts(); c != 0 { + t.Fatal("done before the estimate reached the target") + } + need := 0.3 * 60_000 / DefaultChargeEfficiency + r.tick(time.Hour, true, 11_000, need+50, true) + if c, _ := r.log.counts(); c != 1 { + t.Fatalf("complete events = %d, want 1", c) + } + if got := r.log.complete[0]; got.KWh < need/1000 || got.LoadpointID != "garage" { + t.Fatalf("event %+v", got) + } + for range 5 { + r.tick(10*time.Minute, true, 0, need+50, false) + } + if c, _ := r.log.counts(); c != 1 { + t.Fatalf("complete events = %d after hours, want 1", c) + } +} + +// On an assumed level the estimate is a guess, so nothing claims the car is +// done. +func TestAnAssumedLevelNeverSaysDone(t *testing.T) { + r := newSessionRig(t) + r.mgr.SetTarget("garage", 0.8, time.Time{}) + r.tick(0, true, 11_000, 0, true) + r.tick(5*time.Hour, true, 11_000, 60_000, true) + if c, _ := r.log.counts(); c != 0 { + t.Fatalf("complete events = %d on an assumed level", c) + } +} + +// A car that reports its own level is done by its own reading, not by the +// box's estimate. +func TestACarWithItsOwnDataIsDoneOnlyByItsReading(t *testing.T) { + r := newSessionRig(t) + r.mgr.SetTarget("garage", 0.8, time.Time{}) + r.tick(0, true, 11_000, 0, true) + r.mgr.AnchorVehicleSoC("garage", 0.5) + r.tick(5*time.Hour, true, 11_000, 30_000, true) + if c, _ := r.log.counts(); c != 0 { + t.Fatalf("complete events = %d from an estimate for a car that reports", c) + } +} + +// After a restart beside a car that is already done, nothing is said: it +// has not drawn power since the box started. +func TestARestartBesideAFinishedCarSaysNothing(t *testing.T) { + r := newSessionRig(t) + r.mgr.SetTarget("garage", 0.8, time.Time{}) + r.tick(0, true, 0, 25_000, false) + r.mgr.SetCurrentSoC("garage", 0.85) + for range 5 { + r.tick(time.Minute, true, 0, 25_000, false) + } + if c, _ := r.log.counts(); c != 0 { + t.Fatalf("complete events = %d after a restart", c) + } +} + +// The plan's shortfall tells the household once per plug-in, and only when +// the level is known, the goal has a time ahead, and the gap matters. +func TestGoalAtRiskOncePerPlugInOnAKnownLevel(t *testing.T) { + r := newSessionRig(t) + got := goalRiskLog(r.mgr.bus) + by := r.now.Add(8 * time.Hour) + r.mgr.SetTarget("garage", 0.8, by) + r.tick(0, true, 0, 0, true) + + short := map[string]float64{"garage": 9_000} + r.mgr.ObservePlanShortfall(short, r.now) + if len(got()) != 0 { + t.Fatal("goal at risk on an assumed level") + } + r.mgr.SetCurrentSoC("garage", 0.2) + r.mgr.ObservePlanShortfall(map[string]float64{"garage": GoalRiskMinWh - 1}, r.now) + if len(got()) != 0 { + t.Fatal("goal at risk for a gap under the threshold") + } + r.mgr.ObservePlanShortfall(short, r.now) + r.mgr.ObservePlanShortfall(short, r.now.Add(time.Minute)) + evs := got() + if len(evs) != 1 { + t.Fatalf("goal at risk %d times, want once", len(evs)) + } + if e := evs[0]; e.LoadpointID != "garage" || e.TargetSoC != 0.8 || !e.By.Equal(by) || e.ShortfallWh != 9_000 { + t.Fatalf("event %+v", e) + } + + // A new plug-in may speak again, but not about a ready time that passed. + r.tick(time.Minute, false, 0, 0, false) + r.tick(time.Minute, true, 0, 0, true) + r.mgr.SetCurrentSoC("garage", 0.2) + r.mgr.ObservePlanShortfall(short, by.Add(time.Minute)) + if len(got()) != 1 { + t.Fatal("goal at risk for a ready time already gone") + } + r.mgr.ObservePlanShortfall(short, r.now) + if len(got()) != 2 { + t.Fatal("the next plug-in was not told") + } +} diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index ce4dcfa46..530348366 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -363,6 +363,13 @@ type loadpointRuntime struct { sessionID string socRetention string completionNotified bool + // goalRiskNotified latches charging.goal_at_risk once per plug-in. + // vehicleAnchored is true once the car itself reported a level this + // session; chargedSinceStart once it drew power in this process, so a + // restart beside a car that is already done says nothing. + goalRiskNotified bool + vehicleAnchored bool + chargedSinceStart bool Config pluggedIn bool @@ -771,6 +778,9 @@ func (m *Manager) observe(id string, pluggedIn bool, powerW, deliveredWh float64 lp.sessionPluginSoC = anchor lp.socConfirmed = false lp.completionNotified = false + lp.goalRiskNotified = false + lp.vehicleAnchored = false + lp.chargedSinceStart = false lp.deliveredHistory = nil lp.notRequestingSince = time.Time{} lp.chargingDeclined = false @@ -878,6 +888,17 @@ func (m *Manager) observe(id string, pluggedIn bool, powerW, deliveredWh float64 if pluggedIn { lp.currentSoC = estimateSoC(lp.sessionPluginSoC, deliveredWh, lp.VehicleCapacityWh) + if powerW >= DeliveringW { + lp.chargedSinceStart = true + } + // A car that reports nothing itself is done when the box's own + // estimate, from a level someone set, reaches the target. Said in + // kWh, which the box measured; the level is an estimate. + if !lp.completionNotified && !lp.vehicleAnchored && lp.socConfirmed && lp.chargedSinceStart && + lp.targetSoC > 0 && lp.currentSoC >= lp.targetSoC && deliveredWh >= minCompletionWh { + lp.completionNotified = true + fired = append(fired, events.ChargingSessionComplete{LoadpointID: id, KWh: deliveredWh / 1000, At: now}) + } } else { lp.currentSoC = 0 } @@ -1140,6 +1161,7 @@ func (m *Manager) AnchorVehicleSoCAt(id string, socPct float64, observedAt time. } } reanchorSoCAtLocked(lp, socPct, deliveredAt, m.now()) + lp.vehicleAnchored = true if lp.targetSoC > 0 && lp.currentSoC >= lp.targetSoC && !lp.completionNotified { lp.completionNotified = true completion = &events.ChargingSessionComplete{LoadpointID: id, KWh: lp.deliveredWhSession / 1000, At: m.now()} diff --git a/go/internal/notifications/catalogue.go b/go/internal/notifications/catalogue.go index 52a38c122..868ca2753 100644 --- a/go/internal/notifications/catalogue.go +++ b/go/internal/notifications/catalogue.go @@ -14,6 +14,7 @@ const ( PushChargingConnected = protocol.PushChargingConnected PushChargingSessionComplete = protocol.PushChargingSessionComplete PushChargingInterrupted = protocol.PushChargingInterrupted + PushChargingGoalAtRisk = protocol.PushChargingGoalAtRisk PushUpdateInstalled = protocol.PushUpdateInstalled PushDriverOffline = protocol.PushDriverOffline PushFuseOverLimit = protocol.PushFuseOverLimit diff --git a/go/internal/notifications/charging.go b/go/internal/notifications/charging.go new file mode 100644 index 000000000..4ca51b792 --- /dev/null +++ b/go/internal/notifications/charging.go @@ -0,0 +1,51 @@ +package notifications + +import "time" + +// ConnectedSettle is how long after a plug-in the box decides whether to +// tell anyone. A car that reports its own level usually does so within it, +// and a car whose level and goal are both known needs no question. +const ConnectedSettle = 90 * time.Second + +// ChargingFacts is what the plug-in rule needs to know about a loadpoint. +type ChargingFacts struct { + Plugged bool + // LevelKnown is true when the car's level came from the car or from + // someone who set it, not from the plug-in assumption. + LevelKnown bool + // HasGoal is true when a target level is set. + HasGoal bool +} + +// SetChargingFacts wires what the plug-in rule reads. Nil tells every +// plug-in, as before. +func (s *Service) SetChargingFacts(f func(loadpointID string) (ChargingFacts, bool)) { + if s == nil { + return + } + s.mu.Lock() + s.charging = f + s.mu.Unlock() +} + +// handleConnected tells a plug-in only when the box needs the household: +// the car's level is an assumption, or no goal is set. It decides after +// ConnectedSettle, and says nothing if the cable came out meanwhile. +func (s *Service) handleConnected(loadpointID string) { + if s == nil { + return + } + s.mu.Lock() + facts, settle := s.charging, s.connectedSettle + s.mu.Unlock() + if facts != nil { + if settle > 0 { + time.Sleep(settle) + } + f, ok := facts(loadpointID) + if !ok || !f.Plugged || (f.LevelKnown && f.HasGoal) { + return + } + } + s.handleCatalogued(PushChargingConnected, loadpointID, nil) +} diff --git a/go/internal/notifications/cloud.go b/go/internal/notifications/cloud.go index f2e62af18..e5a4e0141 100644 --- a/go/internal/notifications/cloud.go +++ b/go/internal/notifications/cloud.go @@ -1,5 +1,7 @@ package notifications +import "context" + // NotifyCloudGrant tells every owner that FTW Cloud was turned on or off, // with the catalogue's sentence for kind. The rule gates it like any other, // but a box whose stored rules predate these kinds still sends it: a missing @@ -34,3 +36,23 @@ func (s *Service) NotifyCloudGrant(kind string) { } s.deliver(kind, "", Message{Kind: kind, Title: title, Body: body, Priority: prio, Tags: splitTags(rule.Tags)}) } + +// CloudPublisher hands each catalogue-worded message to FTW Cloud's uplink, +// which signs it and sends it as an event for the owners' phones. It only +// queues: the uplink sends on its own goroutine, so a slow or absent service +// never holds up another transport. Template rules carry no kind and stay on +// the box's own transports. +type CloudPublisher struct { + // Queue takes one message and reports whether FTW Cloud will carry it. + // False means FTW Cloud is off or its grant leaves events out. + Queue func(Message) bool +} + +// Publish queues m, or answers ErrNothingToSend when FTW Cloud will not +// carry it. +func (p CloudPublisher) Publish(_ context.Context, m Message) error { + if m.Kind == "" || p.Queue == nil || !p.Queue(m) { + return ErrNothingToSend + } + return nil +} diff --git a/go/internal/notifications/cloud_events_test.go b/go/internal/notifications/cloud_events_test.go new file mode 100644 index 000000000..2bf9a1edc --- /dev/null +++ b/go/internal/notifications/cloud_events_test.go @@ -0,0 +1,150 @@ +package notifications + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/events" +) + +// K5: a catalogue sentence carries its kind and its arguments, so a +// transport whose phones write their own words, as FTW Cloud's do, gets the +// facts and not only the English. +func TestACatalogueMessageCarriesItsKindAndArgs(t *testing.T) { + pub := &fakePub{} + svc, clk := newSvc(pushCfg(config.NotificationRule{ + Type: PushDriverOffline, Enabled: true, ThresholdS: 600, Priority: 4, CooldownS: 3600, + }), pub) + last := clk.now() + clk.advance(800 * time.Second) + svc.Observe(healthStale(last)) + msgs := pub.Messages() + if len(msgs) != 1 { + t.Fatalf("got %d msgs", len(msgs)) + } + m := msgs[0] + if m.Kind != PushDriverOffline || m.Args["name"] != "ferroamp" || m.Device != "ferroamp" || m.At.IsZero() { + t.Fatalf("message %+v", m) + } +} + +// hangingPub never answers until its context ends. +type hangingPub struct{ entered chan struct{} } + +func (h hangingPub) Publish(ctx context.Context, _ Message) error { + close(h.entered) + <-ctx.Done() + return ctx.Err() +} + +// Each transport has its own time: one that hangs does not hold up another. +func TestAHangingTransportDoesNotDelayAnother(t *testing.T) { + quick := &fakePub{published: make(chan struct{}, 1)} + svc, _ := newSvc(pushCfg(config.NotificationRule{ + Type: PushChargingInterrupted, Enabled: true, Priority: 4, + }), hangingPub{entered: make(chan struct{})}) + svc.AddPublisher(quick) + bus := events.NewBus() + svc.Subscribe(bus) + + start := time.Now() + bus.Publish(events.ChargingInterrupted{LoadpointID: "garage", At: start}) + select { + case <-quick.published: + case <-time.After(2 * time.Second): + t.Fatal("a hanging transport held up the other") + } + if waited := time.Since(start); waited > time.Second { + t.Fatalf("the quick transport waited %s", waited) + } +} + +// The FTW Cloud transport only queues, and only messages with a kind. +func TestCloudPublisherQueuesOnlyCatalogueKinds(t *testing.T) { + var mu sync.Mutex + var queued []Message + on := true + p := CloudPublisher{Queue: func(m Message) bool { + mu.Lock() + defer mu.Unlock() + queued = append(queued, m) + return on + }} + if err := p.Publish(context.Background(), Message{Title: "FTW: test"}); err != ErrNothingToSend { + t.Fatalf("a template message went to FTW Cloud: %v", err) + } + if err := p.Publish(context.Background(), Message{Kind: PushChargingConnected}); err != nil { + t.Fatal(err) + } + on = false + if err := p.Publish(context.Background(), Message{Kind: PushChargingConnected}); err != ErrNothingToSend { + t.Fatalf("FTW Cloud off answered %v", err) + } + if len(queued) != 2 { + t.Fatalf("queued %d", len(queued)) + } + if err := (CloudPublisher{}).Publish(context.Background(), Message{Kind: PushChargingConnected}); err != ErrNothingToSend { + t.Fatalf("no queue answered %v", err) + } +} + +// The plug-in notification asks only when the box needs the household: the +// level is assumed or no goal is set. It decides after a pause, and says +// nothing about a cable already out. +func TestPlugInAsksOnlyWhenTheBoxNeedsTheHousehold(t *testing.T) { + cases := []struct { + name string + facts ChargingFacts + ok bool + want int + }{ + {"assumed level, goal set", ChargingFacts{Plugged: true, HasGoal: true}, true, 1}, + {"known level, no goal", ChargingFacts{Plugged: true, LevelKnown: true}, true, 1}, + {"known level and goal", ChargingFacts{Plugged: true, LevelKnown: true, HasGoal: true}, true, 0}, + {"unplugged meanwhile", ChargingFacts{}, true, 0}, + {"unknown loadpoint", ChargingFacts{}, false, 0}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + pub := &fakePub{published: make(chan struct{}, 1)} + svc, _ := newSvc(pushCfg(config.NotificationRule{Type: PushChargingConnected, Enabled: true}), pub) + svc.connectedSettle = 0 + svc.SetChargingFacts(func(id string) (ChargingFacts, bool) { + if id != "garage" { + t.Errorf("asked about %q", id) + } + return c.facts, c.ok + }) + bus := events.NewBus() + svc.Subscribe(bus) + bus.Publish(events.ChargingConnected{LoadpointID: "garage", At: time.Now()}) + if c.want == 1 { + waitForMsgs(t, pub, 1) + return + } + settle(svc) + if n := len(pub.Messages()); n != 0 { + t.Fatalf("sent %d", n) + } + }) + } +} + +// Goal at risk says the target and the ready time. +func TestGoalAtRiskSaysTargetAndTime(t *testing.T) { + pub := &fakePub{published: make(chan struct{}, 1)} + svc, _ := newSvc(pushCfg(config.NotificationRule{Type: PushChargingGoalAtRisk, Enabled: true, Priority: 4}), pub) + bus := events.NewBus() + svc.Subscribe(bus) + bus.Publish(events.ChargingGoalAtRisk{LoadpointID: "garage", TargetSoC: 0.8, ByClock: "07:00", At: time.Now()}) + m := waitForMsgs(t, pub, 1)[0] + if m.Kind != PushChargingGoalAtRisk || m.LoadpointID != "garage" || m.Args["target"] != "80" || m.Args["by"] != "07:00" { + t.Fatalf("message %+v", m) + } + if m.Title != "The car may not be ready" || m.Body != "It will not reach 80 % by 07:00 at this pace." { + t.Fatalf("words %q / %q", m.Title, m.Body) + } +} diff --git a/go/internal/notifications/service.go b/go/internal/notifications/service.go index e481fe4bd..bc63b5908 100644 --- a/go/internal/notifications/service.go +++ b/go/internal/notifications/service.go @@ -21,6 +21,7 @@ import ( "errors" "fmt" "log/slog" + "math" "sort" "strconv" "strings" @@ -97,6 +98,9 @@ func DefaultRules() []config.NotificationRule { {Type: PushChargingConnected, Enabled: false, Priority: 3, CooldownS: 60}, {Type: PushChargingSessionComplete, Enabled: false, Priority: 3}, {Type: PushChargingInterrupted, Enabled: false, Priority: 4, CooldownS: 3600}, + // Once per plug-in, only when the box knows the car's level and the + // plan cannot make the goal: rare, and the moment to act. + {Type: PushChargingGoalAtRisk, Enabled: false, Priority: 4}, {Type: PushUpdateInstalled, Enabled: false, Priority: 2}, // Phone lock-screen counterparts of the operator rules above. // Same thresholds so a blip or a kettle is not a notification; @@ -122,7 +126,7 @@ func KnownRuleTypes() []string { EventDriverOffline, EventDriverRecovered, EventUpdateAvailable, EventFuseOverLimit, EventConcurrentDriversOffline, PushChargingConnected, PushChargingSessionComplete, PushChargingInterrupted, - PushUpdateInstalled, PushDriverOffline, PushFuseOverLimit, + PushChargingGoalAtRisk, PushUpdateInstalled, PushDriverOffline, PushFuseOverLimit, PushCloudGranted, PushCloudRevoked, } } @@ -145,6 +149,14 @@ type Message struct { Body string Priority int Tags []string + // Args are the catalogue's placeholders, for a transport whose phones + // write their own words, as FTW Cloud's do. Nil for template rules. + Args map[string]string + // Device is the driver the event concerns, for a transport that names + // it some other way than by its name. + Device string + // At is when the event happened. + At time.Time } // Publisher dispatches a rendered Message to its transport. @@ -261,6 +273,10 @@ type Service struct { sent uint64 failed uint64 now func() time.Time + // charging answers what the plug-in rule needs to know about a + // loadpoint; connectedSettle is how long it waits before asking. + charging func(loadpointID string) (ChargingFacts, bool) + connectedSettle time.Duration } // New constructs a Service. cfg may be nil (no-op until Reload is called). @@ -274,6 +290,7 @@ func New(cfg *config.Notifications, pub Publisher, lookup DeviceLookup) *Service activeAlert: map[string]bool{}, fuseFirstOverAt: map[string]time.Time{}, now: time.Now, + connectedSettle: ConnectedSettle, } } @@ -398,7 +415,15 @@ func (s *Service) Subscribe(bus *events.Bus) { // cooldown. Words come from the catalogue and nowhere else. bus.Subscribe(events.KindChargingConnected, func(e events.Event) { if ev, ok := e.(events.ChargingConnected); ok { - go s.handleCatalogued(PushChargingConnected, ev.LoadpointID, nil) + go s.handleConnected(ev.LoadpointID) + } + }) + bus.Subscribe(events.KindChargingGoalAtRisk, func(e events.Event) { + if ev, ok := e.(events.ChargingGoalAtRisk); ok { + go s.handleCatalogued(PushChargingGoalAtRisk, ev.LoadpointID, map[string]string{ + "target": strconv.Itoa(int(math.Round(ev.TargetSoC * 100))), + "by": ev.ByClock, + }) } }) bus.Subscribe(events.KindChargingSessionComplete, func(e events.Event) { @@ -477,6 +502,8 @@ func (s *Service) handleCatalogued(kind, device string, args map[string]string) Body: body, Priority: prio, Tags: splitTags(rule.Tags), + Args: args, + At: now, }) } @@ -874,19 +901,7 @@ func (s *Service) SendTest() error { Body: fmt.Sprintf("Test notification sent at %s.", time.Now().UTC().Format(time.RFC3339)), Priority: prio, } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - var sent int - var errs []error - for _, pub := range pubs { - switch err := pub.Publish(ctx, msg); { - case err == nil: - sent++ - case errors.Is(err, ErrNothingToSend): - default: - errs = append(errs, err) - } - } + sent, errs := publishAll(pubs, msg) var err error if sent == 0 { if len(errs) > 0 { @@ -953,7 +968,8 @@ func (s *Service) buildData(driver, eventType string, since time.Duration, now t } func (s *Service) dispatchCatalogue(cfg *config.Notifications, rule config.NotificationRule, data templateData) { - title, body, err := RenderPush(rule.Type, catalogueArgs(rule.Type, data)) + args := catalogueArgs(rule.Type, data) + title, body, err := RenderPush(rule.Type, args) if err != nil { slog.Warn("notifications: catalogue render failed", "event", rule.Type, "err", err) s.bumpFailed() @@ -965,10 +981,14 @@ func (s *Service) dispatchCatalogue(cfg *config.Notifications, rule config.Notif prio = cfg.DefaultPriority } s.deliver(rule.Type, data.Device, Message{ + Kind: rule.Type, Title: title, Body: body, Priority: prio, Tags: splitTags(rule.Tags), + Args: args, + Device: data.Device, + At: s.now(), }) } @@ -1040,22 +1060,7 @@ func (s *Service) dispatch(cfg *config.Notifications, rule config.NotificationRu // enabled with no provider installed — and stays a truthful failure in the // history rather than a panic or a silence. func (s *Service) deliver(eventType, device string, msg Message) { - pubs := s.publishers() - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - var sent int - var errs []error - for _, pub := range pubs { - switch err := pub.Publish(ctx, msg); { - case err == nil: - sent++ - case errors.Is(err, ErrNothingToSend): - // Absence, not failure. - default: - errs = append(errs, err) - } - } + sent, errs := publishAll(s.publishers(), msg) switch { case sent > 0: if len(errs) > 0 { @@ -1076,6 +1081,35 @@ func (s *Service) deliver(eventType, device string, msg Message) { } } +// PublishTimeout bounds one transport's Publish. Each transport has its own, +// and they run side by side, so a gateway that hangs costs only itself. +const PublishTimeout = 10 * time.Second + +// publishAll hands msg to every transport at once, each under its own +// timeout, and counts the outcome. ErrNothingToSend is absence, not failure. +func publishAll(pubs []Publisher, msg Message) (sent int, errs []error) { + results := make([]error, len(pubs)) + var wg sync.WaitGroup + for i, pub := range pubs { + wg.Go(func() { + ctx, cancel := context.WithTimeout(context.Background(), PublishTimeout) + defer cancel() + results[i] = pub.Publish(ctx, msg) + }) + } + wg.Wait() + for _, err := range results { + switch { + case err == nil: + sent++ + case errors.Is(err, ErrNothingToSend): + default: + errs = append(errs, err) + } + } + return sent, errs +} + // emitDispatched publishes a NotificationDispatched event for history // loggers + future audit subscribers. Safe when the bus is nil. func (s *Service) emitDispatched(eventType, driver string, m Message, status, errStr string) { diff --git a/go/protocol/cloudwire/allowlist.go b/go/protocol/cloudwire/allowlist.go index 42acd3829..22fb4f80f 100644 --- a/go/protocol/cloudwire/allowlist.go +++ b/go/protocol/cloudwire/allowlist.go @@ -31,6 +31,8 @@ var Fields = map[string][]string{ MsgHB: {"clock", "srcRev", "liveSeq", "lpSeq", "ages"}, + MsgEvent: {"eventId", "ts", "kind", "severity", "args", "src", "lp"}, + MsgRoster: {"rosterSeq", "ttlS", "devices", "grant"}, "Device": {"id", "key", "role", "scopes", "epoch"}, "Grant": {"id", "scopes", "purposes", "controller"}, @@ -39,14 +41,15 @@ var Fields = map[string][]string{ } // Bodies maps each class the box sends to its body type. Classes the box does -// not send yet (plan, health, event, tiles) have no entry; the service treats -// their bytes as opaque either way. +// not send yet (plan, health, tiles) have no entry; the service treats their +// bytes as opaque either way. var Bodies = map[string]any{ MsgHello: Hello{}, MsgLive: Live{}, MsgLP: LP{}, MsgPrices: Prices{}, MsgHB: HB{}, + MsgEvent: Event{}, MsgRoster: Roster{}, MsgBye: Bye{}, } diff --git a/go/protocol/cloudwire/push.go b/go/protocol/cloudwire/push.go new file mode 100644 index 000000000..57650dfc3 --- /dev/null +++ b/go/protocol/cloudwire/push.go @@ -0,0 +1,220 @@ +package cloudwire + +import ( + "encoding/json" + + "github.com/srcfl/ftw/go/protocol" +) + +// Events and push: what the box tells phones that are not looking, how a +// phone tells the service where Apple can reach it, and what the service +// hands Apple. docs/protocol-cloud.md, "Push", is the normative text. +// +// The box signs every event. The service wraps the envelope, unchanged, in an +// APNs alert; the phone's notification extension checks the signature with +// the box key it learned over Noise, writes its own text from the kind, and +// picks the buttons from the verified kind alone. The service picks nothing: +// not the words, not the buttons, not the priority. box.unreachable is the +// one alert the service writes itself, because a box that cannot reach the +// service cannot sign one; it carries no arguments and no buttons. + +// Message types a phone sends the service over its app session. +const ( + MsgPushRegister = "push.register" + MsgPushUnregister = "push.unregister" +) + +// APNs environments. A development build of the app gets sandbox tokens. +const ( + PushSandbox = "sandbox" + PushProduction = "production" +) + +// PushKindAlert is the only kind of registration until widgets and Live +// Activities get their own pushes. +const PushKindAlert = "alert" + +// APNsTokenMaxBytes bounds a device token. Apple's are 32 bytes today and +// documented as variable length. +const APNsTokenMaxBytes = 100 + +// Event severities, from the box's notification priority. +const ( + SeverityInfo = "info" + SeverityWarning = "warning" + SeverityAlarm = "alarm" +) + +// Event is one notification the box raised, for devices with +// ftw.events.read. Args are the catalogue's placeholders except names: an +// event about a device names it by Src, and the phone looks the name up in +// the table it holds from the box. +type Event struct { + Header + // EventID is unique per event. + EventID string `cbor:"eventId"` + // Ts is the box's wall time when the event happened, present only + // while NTP has synced the box clock. It can be earlier than the + // header's wallMs when the event waited for the uplink. + Ts *int64 `cbor:"ts,omitempty"` + Kind string `cbor:"kind"` + Severity string `cbor:"severity"` + Args map[string]string `cbor:"args"` + // Src is the opaque source id of the device the event concerns. + Src string `cbor:"src,omitempty"` + // LP is the opaque loadpoint id of the loadpoint it concerns. + LP string `cbor:"lp,omitempty"` +} + +// PushRegister tells the service a device's APNs token for one kind of push. +// The service binds it to the device's roster entry and forgets it when the +// roster drops the device, its epoch changes, or Apple says the token is +// gone. +type PushRegister struct { + APNsToken []byte `cbor:"apnsToken"` + Environment string `cbor:"environment"` + Kind string `cbor:"kind"` + // ActivityID names a Live Activity. Unused until its push exists. + ActivityID string `cbor:"activityId,omitempty"` +} + +// PushUnregister forgets one token. +type PushUnregister struct { + APNsToken []byte `cbor:"apnsToken"` +} + +// AlertMaxAgeMs is how old an event may be and still get its text and +// buttons on the phone. An older one, or one the phone cannot anchor in +// time, shows the generic line with no buttons. +const AlertMaxAgeMs = 10 * 60 * 1000 + +// UnreachableAfterMs is how long the service waits without the box's socket +// before it sends box.unreachable. A bye for an update holds it back until +// the box returns or UpdateGraceMs has passed. +const ( + UnreachableAfterMs = 600 * 1000 + UpdateGraceMs = 60 * 60 * 1000 +) + +// Alert is the "ftw" member of an APNs alert payload. Event is the box's +// envelope exactly as it arrived; Kind is set only on the alert the service +// writes itself, box.unreachable, and a phone accepts no other kind there. +type Alert struct { + V int `json:"v"` + Home string `json:"home"` + Event []byte `json:"event,omitempty"` + Kind string `json:"kind,omitempty"` +} + +// AlertKey is the payload key Alert sits under, beside Apple's "aps". +const AlertKey = "ftw" + +// Localisation keys of the line Apple shows if the extension does not run. +// The app's own strings hold the words, so the service writes none. +const ( + AlertTitleLocKey = "push.title" + AlertBodyLocKey = "push.generic" +) + +// AlertPayload is the whole APNs payload for one alert: Apple's "aps" with +// the generic line and mutable-content, so the extension runs, and Alert +// under AlertKey. Every alert gets the same aps, whatever its kind. +func AlertPayload(a Alert) ([]byte, error) { + type apsAlert struct { + TitleLocKey string `json:"title-loc-key"` + LocKey string `json:"loc-key"` + } + type aps struct { + Alert apsAlert `json:"alert"` + MutableContent int `json:"mutable-content"` + Sound string `json:"sound"` + } + a.V = Version + return json.Marshal(struct { + APS aps `json:"aps"` + FTW Alert `json:"ftw"` + }{aps{apsAlert{AlertTitleLocKey, AlertBodyLocKey}, 1, "default"}, a}) +} + +// What a phone makes of an alert. +const ( + // AlertEvent: a box-signed event, anchored and fresh, of a kind the + // catalogue has. The phone writes its text and offers its buttons. + AlertEvent = "event" + // AlertUnreachable: the service's box.unreachable. Its own text, no + // buttons. + AlertUnreachable = "unreachable" + // AlertGeneric: anything else. The generic line, no buttons. + AlertGeneric = "generic" +) + +// AlertView is what an alert says once checked. +type AlertView struct { + Verdict string + Home string + // The rest is set only for AlertEvent. + Kind string + Args map[string]string + Src string + LP string + Boot []byte + Seq uint64 + // AtMs is when the event happened by the phone's reading of the box + // clock: never later than it arrived. + AtMs int64 +} + +// ClockLeadMs is how far ahead of the phone a box's wall clock may run +// before the phone stops trusting it (rule N). +const ClockLeadMs = 2000 + +// ReadAlert is the reference for what a phone's notification extension does +// with an alert payload. boxKey returns the key the phone learned over Noise +// for a home, nil when it has none; nowMs is the phone's clock at arrival. +// Whether a (boot, seq) was seen before is the caller's state, not this +// function's. +func ReadAlert(payload []byte, boxKey func(home string) []byte, nowMs int64) AlertView { + var p struct { + FTW *Alert `json:"ftw"` + } + if json.Unmarshal(payload, &p) != nil || p.FTW == nil || p.FTW.V != Version { + return AlertView{Verdict: AlertGeneric} + } + a := *p.FTW + view := AlertView{Verdict: AlertGeneric, Home: a.Home} + if len(a.Event) == 0 { + if a.Kind == protocol.PushBoxUnreachable { + view.Verdict = AlertUnreachable + } + return view + } + if a.Kind != "" { + return view + } + env, err := DecodeEnvelope(a.Event) + if err != nil || env.T != MsgEvent { + return view + } + pk := boxKey(a.Home) + if pk == nil { + return view + } + pub, err := ParsePublicKey(pk) + if err != nil || Verify(pub, a.Home, env) != nil { + return view + } + var ev Event + if Unmarshal(env.B, &ev) != nil || ev.WallMs == nil || *ev.WallMs > nowMs+ClockLeadMs { + return view + } + at := min(*ev.WallMs, nowMs) + if ev.Ts != nil { + at = min(at, *ev.Ts) + } + _, known := protocol.PushSentences[ev.Kind] + if !known || ev.Kind == protocol.PushBoxUnreachable || nowMs-at > AlertMaxAgeMs { + return view + } + return AlertView{Verdict: AlertEvent, Home: a.Home, Kind: ev.Kind, Args: ev.Args, Src: ev.Src, LP: ev.LP, + Boot: ev.Boot, Seq: ev.Seq, AtMs: at} +} diff --git a/go/protocol/cloudwire/push_vectors_test.go b/go/protocol/cloudwire/push_vectors_test.go new file mode 100644 index 000000000..66002ba5d --- /dev/null +++ b/go/protocol/cloudwire/push_vectors_test.go @@ -0,0 +1,289 @@ +package cloudwire + +import ( + "bytes" + "crypto/ecdsa" + "crypto/rand" + "encoding/hex" + "encoding/json" + "os" + "slices" + "testing" + + "github.com/srcfl/ftw/go/protocol" +) + +// Push vectors: APNs payloads as the service makes them, the phone's clock +// when each arrives, and what the phone must make of it. ReadAlert is the +// reference; the phone's notification extension replays the same file. +// +// go test ./cloudwire -run TestPushVectors -update +// +// Signatures are stored, as in cloud-vectors.json, so an update that only +// adds a case leaves the others' bytes alone. + +const pushVectorsPath = "testdata/push-vectors.json" + +type pushVectorFile struct { + // Keys are the box keys the phone holds, by home. + Keys []pushVectorKey `json:"keys"` + Cases []pushVectorCase `json:"cases"` +} + +type pushVectorKey struct { + Home string `json:"home"` + PK string `json:"pk"` +} + +type pushVectorCase struct { + Name string `json:"name"` + // Payload is the APNs payload, exactly as the service sends it. + Payload string `json:"payload"` + // Sig is the event's stored signature, hex, empty when there is no + // event or it is unsigned. + Sig string `json:"sig"` + NowMs int64 `json:"nowMs"` + Want pushVectorOut `json:"want"` +} + +type pushVectorOut struct { + Verdict string `json:"verdict"` + Home string `json:"home"` + Kind string `json:"kind,omitempty"` + Args map[string]string `json:"args,omitempty"` + Src string `json:"src,omitempty"` + LP string `json:"lp,omitempty"` + Boot string `json:"boot,omitempty"` + Seq uint64 `json:"seq,omitempty"` + AtMs int64 `json:"atMs,omitempty"` +} + +func TestPushVectors(t *testing.T) { + box := testKey(t, "ftw.cloud vector box") + other := testKey(t, "ftw.cloud vector box two") + home, boxPK := homeOf(t, box) + otherHome, otherPK := homeOf(t, other) + grantKey := bytes.Repeat([]byte{0x77}, GrantKeyBytes) + charger := SourceID(grantKey, "easee") + garage := LoadpointID(grantKey, "garage") + boot := bytes.Repeat([]byte{0xb0}, BootBytes) + wall := int64(1_791_820_800_000) + at := func(uptime int64) *int64 { return i64(wall + uptime - 60_000) } + + var stored pushVectorFile + if !*update { + raw, err := os.ReadFile(pushVectorsPath) + if err != nil { + t.Fatalf("%v (run with -update to create it)", err) + } + if err := json.Unmarshal(raw, &stored); err != nil { + t.Fatal(err) + } + } + storedSig := func(i int, name string) []byte { + if i >= len(stored.Cases) || stored.Cases[i].Name != name { + t.Fatalf("push vector %d is not %q in %s; regenerate with -update", i, name, pushVectorsPath) + } + sig, _ := hex.DecodeString(stored.Cases[i].Sig) + return sig + } + + connected := Event{ + Header: Header{Seq: 9, Boot: boot, UptimeMs: 71_000, WallMs: at(71_000)}, + EventID: "Q2ZrMjR0dzVh", Ts: i64(*at(71_000) - 400), Kind: protocol.PushChargingConnected, + Severity: SeverityInfo, Args: map[string]string{}, Src: charger, LP: garage, + } + interrupted := Event{ + Header: Header{Seq: 10, Boot: boot, UptimeMs: 72_000, WallMs: at(72_000)}, + EventID: "aW50cnB0ZDAx", Ts: i64(*at(72_000) - 300), Kind: protocol.PushChargingInterrupted, + Severity: SeverityWarning, Args: map[string]string{}, Src: charger, LP: garage, + } + noClock := connected + noClock.WallMs, noClock.Ts, noClock.Seq = nil, nil, 12 + ahead := connected + ahead.WallMs, ahead.Ts, ahead.Seq = at(80_000), nil, 13 + unknown := connected + unknown.Kind, unknown.Seq = "charging.someday", 14 + goal := Event{ + Header: Header{Seq: 15, Boot: boot, UptimeMs: 74_000, WallMs: at(74_000)}, + EventID: "Z29hbHJpc2sx", Ts: i64(*at(74_000) - 100), Kind: protocol.PushChargingGoalAtRisk, + Severity: SeverityWarning, Args: map[string]string{"target": "80", "by": "07:00"}, LP: garage, + } + signedAsOther := connected + signedAsOther.Seq = 16 + + type spec struct { + name string + key *ecdsa.PrivateKey // signer; nil for no event + signFor string // home the box signs for + event *Event + alert Alert // Home and Kind; Event is filled in + mutate func(env []byte) []byte + nowMs int64 + want pushVectorOut + } + fresh := func(e Event) int64 { return *e.WallMs + 1_200 } + event := func(e Event) pushVectorOut { + at := min(*e.WallMs, fresh(e)) + if e.Ts != nil { + at = min(at, *e.Ts) + } + return pushVectorOut{Verdict: AlertEvent, Home: home, Kind: e.Kind, Args: e.Args, Src: e.Src, LP: e.LP, + Boot: hex.EncodeToString(e.Boot), Seq: e.Seq, AtMs: at} + } + generic := pushVectorOut{Verdict: AlertGeneric, Home: home} + specs := []spec{ + {name: "connected, fresh", key: box, signFor: home, event: &connected, alert: Alert{Home: home}, + nowMs: fresh(connected), want: event(connected)}, + {name: "interrupted, fresh", key: box, signFor: home, event: &interrupted, alert: Alert{Home: home}, + nowMs: fresh(interrupted), want: event(interrupted)}, + {name: "goal at risk, fresh", key: box, signFor: home, event: &goal, alert: Alert{Home: home}, + nowMs: fresh(goal), want: event(goal)}, + {name: "one byte of the body changed", key: box, signFor: home, event: &connected, alert: Alert{Home: home}, + mutate: func(env []byte) []byte { + out := bytes.Clone(env) + i := bytes.Index(out, []byte("charging.connected")) + out[i+len("charging.")] ^= 0x01 + return out + }, + nowMs: fresh(connected), want: generic}, + {name: "named for another home the phone holds", key: box, signFor: home, event: &connected, + alert: Alert{Home: otherHome}, nowMs: fresh(connected), + want: pushVectorOut{Verdict: AlertGeneric, Home: otherHome}}, + {name: "signed by another box for this home", key: other, signFor: home, event: &signedAsOther, + alert: Alert{Home: home}, nowMs: fresh(signedAsOther), want: generic}, + {name: "two hours old", key: box, signFor: home, event: &connected, alert: Alert{Home: home}, + nowMs: *connected.WallMs + 2*60*60*1000, want: generic}, + {name: "ten minutes old is still fresh", key: box, signFor: home, event: &interrupted, alert: Alert{Home: home}, + nowMs: *interrupted.Ts + AlertMaxAgeMs, + want: func() pushVectorOut { + o := event(interrupted) + o.AtMs = *interrupted.Ts + return o + }()}, + {name: "box clock not synced", key: box, signFor: home, event: &noClock, alert: Alert{Home: home}, + nowMs: fresh(connected), want: generic}, + {name: "box clock ahead of the phone", key: box, signFor: home, event: &ahead, alert: Alert{Home: home}, + nowMs: *ahead.WallMs - ClockLeadMs - 1, want: generic}, + {name: "a kind the phone does not know", key: box, signFor: home, event: &unknown, alert: Alert{Home: home}, + nowMs: fresh(unknown), want: generic}, + {name: "unreachable, from the service", alert: Alert{Home: home, Kind: protocol.PushBoxUnreachable}, + nowMs: wall, want: pushVectorOut{Verdict: AlertUnreachable, Home: home}}, + {name: "another kind from the service", alert: Alert{Home: home, Kind: protocol.PushChargingConnected}, + nowMs: wall, want: generic}, + {name: "a home the phone does not hold", key: box, signFor: home, event: &connected, + alert: Alert{Home: "aaaaaaaaaaaaaaaaaaaaaaaaaa"}, nowMs: fresh(connected), + want: pushVectorOut{Verdict: AlertGeneric, Home: "aaaaaaaaaaaaaaaaaaaaaaaaaa"}}, + } + + keys := map[string][]byte{home: boxPK, otherHome: otherPK} + out := pushVectorFile{Keys: []pushVectorKey{ + {Home: home, PK: hex.EncodeToString(boxPK)}, + {Home: otherHome, PK: hex.EncodeToString(otherPK)}, + }} + for i, s := range specs { + a := s.alert + var sig []byte + if s.event != nil { + body, err := Marshal(*s.event) + if err != nil { + t.Fatal(err) + } + if *update { + if sig, err = Sign(rand.Reader, s.key, s.signFor, MsgEvent, body); err != nil { + t.Fatal(err) + } + } else { + sig = storedSig(i, s.name) + } + if err := Verify(&s.key.PublicKey, s.signFor, Envelope{T: MsgEvent, B: body, Sig: sig}); err != nil { + t.Fatalf("%s: stored signature does not verify: %v", s.name, err) + } + env, err := Envelope{T: MsgEvent, B: body, Sig: sig}.Encode() + if err != nil { + t.Fatal(err) + } + if s.mutate != nil { + env = s.mutate(env) + } + a.Event = env + } + payload, err := AlertPayload(a) + if err != nil { + t.Fatal(err) + } + if len(payload) > 4096 { + t.Fatalf("%s: %d bytes is over APNs' 4 KB", s.name, len(payload)) + } + + view := ReadAlert(payload, func(h string) []byte { return keys[h] }, s.nowMs) + got := pushVectorOut{Verdict: view.Verdict, Home: view.Home, Kind: view.Kind, Args: view.Args, + Src: view.Src, LP: view.LP, Seq: view.Seq, AtMs: view.AtMs} + if view.Boot != nil { + got.Boot = hex.EncodeToString(view.Boot) + } + if !equalOut(got, s.want) { + t.Errorf("%s: got %+v, want %+v", s.name, got, s.want) + } + out.Cases = append(out.Cases, pushVectorCase{Name: s.name, Payload: string(payload), + Sig: hex.EncodeToString(sig), NowMs: s.nowMs, Want: s.want}) + } + + got, err := json.MarshalIndent(out, "", " ") + if err != nil { + t.Fatal(err) + } + got = append(got, '\n') + if *update { + if err := os.WriteFile(pushVectorsPath, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, _ := os.ReadFile(pushVectorsPath) + if !bytes.Equal(got, want) { + t.Fatalf("the bytes no longer match %s. If the change is intended, regenerate with -update "+ + "and pair it with srcfl/ftw-cloud and the app", pushVectorsPath) + } +} + +func equalOut(a, b pushVectorOut) bool { + if len(a.Args) == 0 && len(b.Args) == 0 { + a.Args, b.Args = nil, nil + } + ka, kb := make([]string, 0, len(a.Args)), make([]string, 0, len(b.Args)) + for k, v := range a.Args { + ka = append(ka, k+"="+v) + } + for k, v := range b.Args { + kb = append(kb, k+"="+v) + } + slices.Sort(ka) + slices.Sort(kb) + a.Args, b.Args = nil, nil + return a.Verdict == b.Verdict && a.Home == b.Home && a.Kind == b.Kind && a.Src == b.Src && a.LP == b.LP && + a.Boot == b.Boot && a.Seq == b.Seq && a.AtMs == b.AtMs && slices.Equal(ka, kb) +} + +// The service's alert has the same aps whatever it carries, so Apple learns +// a kind only from the signed event, never from priority or wording. +func TestEveryAlertHasTheSameAps(t *testing.T) { + a, err := AlertPayload(Alert{Home: "h", Event: []byte{1, 2, 3}}) + if err != nil { + t.Fatal(err) + } + b, err := AlertPayload(Alert{Home: "h", Kind: protocol.PushBoxUnreachable}) + if err != nil { + t.Fatal(err) + } + var pa, pb map[string]json.RawMessage + if json.Unmarshal(a, &pa) != nil || json.Unmarshal(b, &pb) != nil { + t.Fatal("not JSON") + } + if !bytes.Equal(pa["aps"], pb["aps"]) { + t.Fatalf("aps differs: %s vs %s", pa["aps"], pb["aps"]) + } + if !bytes.Contains(pa["aps"], []byte(`"mutable-content":1`)) { + t.Fatalf("no mutable-content: %s", pa["aps"]) + } +} diff --git a/go/protocol/cloudwire/testdata/cloud-vectors.json b/go/protocol/cloudwire/testdata/cloud-vectors.json index d578b866a..9ad224461 100644 --- a/go/protocol/cloudwire/testdata/cloud-vectors.json +++ b/go/protocol/cloudwire/testdata/cloud-vectors.json @@ -161,6 +161,36 @@ "sig": "910f5b966d04ed95f1e5d2e33fd542391976b4013c82b9beb0f5c42c365dd1f35c5f71d5efe89a44a4fb67cd35f93f85087549e8e4283f6c20e794d92d12cc32", "envelope": "a361625832a36461744d731b000001a130daad40647768617464686f6d65676772616e7449645044444444444444444444444444444444617466707572676564637369675840910f5b966d04ed95f1e5d2e33fd542391976b4013c82b9beb0f5c42c365dd1f35c5f71d5efe89a44a4fb67cd35f93f85087549e8e4283f6c20e794d92d12cc32", "frame": "010100000086a361625832a36461744d731b000001a130daad40647768617464686f6d65676772616e7449645044444444444444444444444444444444617466707572676564637369675840910f5b966d04ed95f1e5d2e33fd542391976b4013c82b9beb0f5c42c365dd1f35c5f71d5efe89a44a4fb67cd35f93f85087549e8e4283f6c20e794d92d12cc32" + }, + { + "name": "event connected", + "signer": "box", + "t": "event", + "body": "ab626c706b6a33485a4f524e435748736274731b000001a130d7c9686373657109637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64726368617267696e672e636f6e6e65637465646677616c6c4d731b000001a130d7caf8676576656e7449646c51325a724d6a5230647a566868736576657269747964696e666f68757074696d654d731a00011558", + "signedInputSha256": "6e13d5c342e20d31aef8530bd5d8d89b08540809278bd1aa93d2d156fe2b6d29", + "sig": "490916f3211c463ee9de60249e27a7a44e405a175679f57e238cb910fa9c05891209946aa986336d246eed44413583089e7c7777859cd13172eb8750a684dc23", + "envelope": "a3616258a6ab626c706b6a33485a4f524e435748736274731b000001a130d7c9686373657109637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64726368617267696e672e636f6e6e65637465646677616c6c4d731b000001a130d7caf8676576656e7449646c51325a724d6a5230647a566868736576657269747964696e666f68757074696d654d731a000115586174656576656e74637369675840490916f3211c463ee9de60249e27a7a44e405a175679f57e238cb910fa9c05891209946aa986336d246eed44413583089e7c7777859cd13172eb8750a684dc23", + "frame": "0101000000f9a3616258a6ab626c706b6a33485a4f524e435748736274731b000001a130d7c9686373657109637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64726368617267696e672e636f6e6e65637465646677616c6c4d731b000001a130d7caf8676576656e7449646c51325a724d6a5230647a566868736576657269747964696e666f68757074696d654d731a000115586174656576656e74637369675840490916f3211c463ee9de60249e27a7a44e405a175679f57e238cb910fa9c05891209946aa986336d246eed44413583089e7c7777859cd13172eb8750a684dc23" + }, + { + "name": "event interrupted", + "signer": "box", + "t": "event", + "body": "ab626c706b6a33485a4f524e435748736274731b000001a130d7cdb4637365710a637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64746368617267696e672e696e7465727275707465646677616c6c4d731b000001a130d7cee0676576656e7449646c61573530636e42305a444178687365766572697479677761726e696e6768757074696d654d731a00011940", + "signedInputSha256": "b29e1deef680d5b19f631a1ee29965bf70774e690afe1dbab5e7e25c4738ddbb", + "sig": "ae81be2140544a903e180cf33bf97a43d075ead5d1547226ce9df7a3e0b40d18c3e3727c607138fcf68806b7a5068d7cf0ec12e3536f73229ea3ff40d1a31f25", + "envelope": "a3616258abab626c706b6a33485a4f524e435748736274731b000001a130d7cdb4637365710a637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64746368617267696e672e696e7465727275707465646677616c6c4d731b000001a130d7cee0676576656e7449646c61573530636e42305a444178687365766572697479677761726e696e6768757074696d654d731a000119406174656576656e74637369675840ae81be2140544a903e180cf33bf97a43d075ead5d1547226ce9df7a3e0b40d18c3e3727c607138fcf68806b7a5068d7cf0ec12e3536f73229ea3ff40d1a31f25", + "frame": "0101000000fea3616258abab626c706b6a33485a4f524e435748736274731b000001a130d7cdb4637365710a637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64746368617267696e672e696e7465727275707465646677616c6c4d731b000001a130d7cee0676576656e7449646c61573530636e42305a444178687365766572697479677761726e696e6768757074696d654d731a000119406174656576656e74637369675840ae81be2140544a903e180cf33bf97a43d075ead5d1547226ce9df7a3e0b40d18c3e3727c607138fcf68806b7a5068d7cf0ec12e3536f73229ea3ff40d1a31f25" + }, + { + "name": "event session complete", + "signer": "box", + "t": "event", + "body": "ab626c706b6a33485a4f524e435748736274731b000001a130d7d200637365710b637372636b4455595757516d6e2d58676461726773a1636b77686431382e3464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e6478196368617267696e672e73657373696f6e5f636f6d706c6574656677616c6c4d731b000001a130d7d2c8676576656e7449646c5a4739755a5441774d44417868736576657269747964696e666f68757074696d654d731a00011d28", + "signedInputSha256": "37a352e6f700b18e8dfec643cc783a67bf284209ec5ef16956266dfe84ed0b0a", + "sig": "e75a581948135cbd2b54eec888fa0396f57b9159de07881ed78cc51b271cad39f1e81431d1125b51040c7794b109ff106702004dccb0285a61a879d29afb04bb", + "envelope": "a3616258b7ab626c706b6a33485a4f524e435748736274731b000001a130d7d200637365710b637372636b4455595757516d6e2d58676461726773a1636b77686431382e3464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e6478196368617267696e672e73657373696f6e5f636f6d706c6574656677616c6c4d731b000001a130d7d2c8676576656e7449646c5a4739755a5441774d44417868736576657269747964696e666f68757074696d654d731a00011d286174656576656e74637369675840e75a581948135cbd2b54eec888fa0396f57b9159de07881ed78cc51b271cad39f1e81431d1125b51040c7794b109ff106702004dccb0285a61a879d29afb04bb", + "frame": "01010000010aa3616258b7ab626c706b6a33485a4f524e435748736274731b000001a130d7d200637365710b637372636b4455595757516d6e2d58676461726773a1636b77686431382e3464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e6478196368617267696e672e73657373696f6e5f636f6d706c6574656677616c6c4d731b000001a130d7d2c8676576656e7449646c5a4739755a5441774d44417868736576657269747964696e666f68757074696d654d731a00011d286174656576656e74637369675840e75a581948135cbd2b54eec888fa0396f57b9159de07881ed78cc51b271cad39f1e81431d1125b51040c7794b109ff106702004dccb0285a61a879d29afb04bb" } ] } diff --git a/go/protocol/cloudwire/testdata/push-vectors.json b/go/protocol/cloudwire/testdata/push-vectors.json new file mode 100644 index 000000000..218d502fc --- /dev/null +++ b/go/protocol/cloudwire/testdata/push-vectors.json @@ -0,0 +1,181 @@ +{ + "keys": [ + { + "home": "fjzyr67buzpyncqmjliwgezvde", + "pk": "04cd7a7c09b7c1fed854fcba642c1da25b48df5bf3208c67bcc2158374011e8c84bf6490130896243c527842441c170a64252874fdf719aa61444e329fe6bc02b8" + }, + { + "home": "t3w5bfotxeygcjxjdhjaeo6pvq", + "pk": "04003ed3e19bde3ad2abbe687ce92a9d63cfacd49cb4bace9903c3ee95093700804081439d960c07e29171ab20fdaf94c603d87e4669f961d7a227e8d8616111f9" + } + ], + "cases": [ + { + "name": "connected, fresh", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQljc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEABMIFtaHgkzfeMrtApu30UgdXtawe2kzcX4lBlhZ9yVOdjZkXlCSoSUrd2nHXDYI4pClpW4ReFKP/H9mqucV2H\"}}", + "sig": "0130816d687824cdf78caed029bb7d1481d5ed6b07b6933717e25065859f7254e7636645e5092a1252b7769c75c3608e290a5a56e1178528ffc7f66aae715d87", + "nowMs": 1791820812200, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "charging.connected", + "src": "DUYWWQmn-Xg", + "lp": "j3HZORNCWHs", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 9, + "atMs": 1791820810600 + } + }, + { + "name": "interrupted, fresh", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKurYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw1820Y3NlcQpjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kdGNoYXJnaW5nLmludGVycnVwdGVkZndhbGxNcxsAAAGhMNfO4GdldmVudElkbGFXNTBjbkIwWkRBeGhzZXZlcml0eWd3YXJuaW5naHVwdGltZU1zGgABGUBhdGVldmVudGNzaWdYQKJwjC0k3f6XPDpapuR7NTwgus1FBgURGLD5DSpoTIsFWtfQ2iow7T1MWUHBPcWxPna0WQUCzpdkh5EcsZQULTs=\"}}", + "sig": "a2708c2d24ddfe973c3a5aa6e47b353c20bacd4506051118b0f90d2a684c8b055ad7d0da2a30ed3d4c5941c13dc5b13e76b4590502ce976487911cb194142d3b", + "nowMs": 1791820813200, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "charging.interrupted", + "src": "DUYWWQmn-Xg", + "lp": "j3HZORNCWHs", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 10, + "atMs": 1791820811700 + } + }, + { + "name": "goal at risk, fresh", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWK+qYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw19ZMY3NlcQ9kYXJnc6JiYnllMDc6MDBmdGFyZ2V0YjgwZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kdWNoYXJnaW5nLmdvYWxfYXRfcmlza2Z3YWxsTXMbAAABoTDX1rBnZXZlbnRJZGxaMjloYkhKcGMyc3hoc2V2ZXJpdHlnd2FybmluZ2h1cHRpbWVNcxoAASEQYXRlZXZlbnRjc2lnWEDAGlZS76Snuq3dzP6oBe1buyz2kzW/N79ukTRxpHgali+4Bqh7Um61zOsv4QTfS6Ss6vENXdDC6biX122t3pJg\"}}", + "sig": "c01a5652efa4a7baadddccfea805ed5bbb2cf69335bf37bf6e913471a4781a962fb806a87b526eb5cceb2fe104df4ba4aceaf10d5dd0c2e9b897d76dadde9260", + "nowMs": 1791820815200, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "charging.goal_at_risk", + "args": { + "by": "07:00", + "target": "80" + }, + "lp": "j3HZORNCWHs", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 15, + "atMs": 1791820813900 + } + }, + { + "name": "one byte of the body changed", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQljc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmJvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWECNIbMUmau1F9d1HqNW3rNdV0jlDVXEjK/Q3/b8bBjF7yCpDjUzd9n2FfuL0zIv0aJUJkc3BbScu+pvpr8piwGb\"}}", + "sig": "8d21b31499abb517d7751ea356deb35d5748e50d55c48cafd0dff6fc6c18c5ef20a90e353377d9f615fb8bd3322fd1a25426473705b49cbbea6fa6bf298b019b", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "named for another home the phone holds", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"t3w5bfotxeygcjxjdhjaeo6pvq\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQljc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEBH6wZnxbr20B6MN8GTyLZcR25rSVN1bugYCsIleoMmLRG5mELw/q7JcRUOiucK7LLM3ka46TK2T7FnZh3mxuIl\"}}", + "sig": "47eb0667c5baf6d01e8c37c193c8b65c476e6b4953756ee8180ac2257a83262d11b99842f0feaec971150e8ae70aecb2ccde46b8e932b64fb167661de6c6e225", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "t3w5bfotxeygcjxjdhjaeo6pvq" + } + }, + { + "name": "signed by another box for this home", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcRBjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEBJ68gFtSwOcqFpSXiK6MHVsHr2RiTTMxIm9h1lNwn/IroE4rs65mYQCkTY+xLXrenrOasV/suwp6HzhwgoHk8z\"}}", + "sig": "49ebc805b52c0e72a16949788ae8c1d5b07af64624d3331226f61d653709ff22ba04e2bb3ae666100a44d8fb12d7ade9eb39ab15fecbb0a7a1f38708281e4f33", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "two hours old", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQljc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEAg1t+OvFYf9y9gpMPg21Q7dzGvrJ5V8CrBNIr2EWpgLZl1QZgbSA8ELs8nrATH2p+tUjDAmJSyG1m8YlsgYEap\"}}", + "sig": "20d6df8ebc561ff72f60a4c3e0db543b7731afac9e55f02ac1348af6116a602d997541981b480f042ecf27ac04c7da9fad5230c09894b21b59bc625b206046a9", + "nowMs": 1791828011000, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "ten minutes old is still fresh", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKurYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw1820Y3NlcQpjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kdGNoYXJnaW5nLmludGVycnVwdGVkZndhbGxNcxsAAAGhMNfO4GdldmVudElkbGFXNTBjbkIwWkRBeGhzZXZlcml0eWd3YXJuaW5naHVwdGltZU1zGgABGUBhdGVldmVudGNzaWdYQB7Mfxasg5x9bJLORP3yYOgcto/sS+Y/Z2SgGKkgJucoOlYH2mg5ex6sShXDjDHRYZ4RHhRgC3ENSJ+FxFIryMI=\"}}", + "sig": "1ecc7f16ac839c7d6c92ce44fdf260e81cb68fec4be63f6764a018a92026e7283a5607da68397b1eac4a15c38c31d1619e111e14600b710d489f85c4522bc8c2", + "nowMs": 1791821411700, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "charging.interrupted", + "src": "DUYWWQmn-Xg", + "lp": "j3HZORNCWHs", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 10, + "atMs": 1791820811700 + } + }, + { + "name": "box clock not synced", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWIqpYmxwa2ozSFpPUk5DV0hzY3NlcQxjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGdldmVudElkbFEyWnJNalIwZHpWaGhzZXZlcml0eWRpbmZvaHVwdGltZU1zGgABFVhhdGVldmVudGNzaWdYQA4D7BC9hbsDGWWnl69+YzZWkvCG2DyNVfPtNFNW4O27uftmRBxY0ewGRUt+ilCSn+mRKmIIqpbBh0wAgJxCVaI=\"}}", + "sig": "0e03ec10bd85bb031965a797af7e63365692f086d83c8d55f3ed345356e0edbbb9fb66441c58d1ec06454b7e8a50929fe9912a6208aa96c1874c00809c4255a2", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "box clock ahead of the phone", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWJqqYmxwa2ozSFpPUk5DV0hzY3NlcQ1jc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDX7iBnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEDNcUjPNmeqdosZjUen9w+4n17dD+hThNnqvSLF0A89nUqTWpscn7BRZybd7FyfIHQ8We5wyuZLHQYmf4s2Ggd5\"}}", + "sig": "cd7148cf3667aa768b198d47a7f70fb89f5edd0fe85384d9eabd22c5d00f3d9d4a935a9b1c9fb0516726ddec5c9f20743c59ee70cae64b1d06267f8b361a0779", + "nowMs": 1791820817999, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "a kind the phone does not know", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKSrYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQ5jc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcGNoYXJnaW5nLnNvbWVkYXlmd2FsbE1zGwAAAaEw18r4Z2V2ZW50SWRsUTJack1qUjBkelZoaHNldmVyaXR5ZGluZm9odXB0aW1lTXMaAAEVWGF0ZWV2ZW50Y3NpZ1hAuiASso5HdVyi4+dXlmi5PKDjO5DBTXiaChzNJqz5l/nXU7o++4oPDzfzx3k0Ra0tM3vXtZ2GoHJafIP+dypV6g==\"}}", + "sig": "ba2012b28e47755ca2e3e7579668b93ca0e33b90c14d789a0a1ccd26acf997f9d753ba3efb8a0f0f37f3c7793445ad2d337bd7b59d86a0725a7c83fe772a55ea", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "unreachable, from the service", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"kind\":\"box.unreachable\"}}", + "sig": "", + "nowMs": 1791820800000, + "want": { + "verdict": "unreachable", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "another kind from the service", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"kind\":\"charging.connected\"}}", + "sig": "", + "nowMs": 1791820800000, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "a home the phone does not hold", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"aaaaaaaaaaaaaaaaaaaaaaaaaa\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQljc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWECFrKOjjhGZn6O0MCWuzT1paANIFK44ZLUd7mRqIzBcRgdME/ZjLp6jHx96zZY87aqltd41D9CG4Fn1IrGtzuNz\"}}", + "sig": "85aca3a38e11999fa3b43025aecd3d6968034814ae3864b51dee646a23305c46074c13f6632e9ea31f1f7acd963cedaaa5b5de350fd086e059f522b1adcee373", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "aaaaaaaaaaaaaaaaaaaaaaaaaa" + } + } + ] +} diff --git a/go/protocol/cloudwire/vectors_test.go b/go/protocol/cloudwire/vectors_test.go index a263773a4..172253841 100644 --- a/go/protocol/cloudwire/vectors_test.go +++ b/go/protocol/cloudwire/vectors_test.go @@ -159,6 +159,21 @@ func vectorBodies(grantKey []byte, boxPK []byte) []struct { {"bye", "box", MsgBye, Bye{Header: h(8, 70_000), Why: ByeRevoked, Purge: true}}, {"purged", "service", MsgPurged, Purged{AtMs: 1_791_821_000_000, What: PurgedHome, GrantID: bytes.Repeat([]byte{0x44}, GrantIDBytes)}}, + {"event connected", "box", MsgEvent, Event{ + Header: h(9, 71_000), EventID: "Q2ZrMjR0dzVh", Ts: i64(wall + 71_000 - 60_000 - 400), + Kind: protocol.PushChargingConnected, Severity: SeverityInfo, Args: map[string]string{}, + Src: charger, LP: garage, + }}, + {"event interrupted", "box", MsgEvent, Event{ + Header: h(10, 72_000), EventID: "aW50cnB0ZDAx", Ts: i64(wall + 72_000 - 60_000 - 300), + Kind: protocol.PushChargingInterrupted, Severity: SeverityWarning, Args: map[string]string{}, + Src: charger, LP: garage, + }}, + {"event session complete", "box", MsgEvent, Event{ + Header: h(11, 73_000), EventID: "ZG9uZTAwMDAx", Ts: i64(wall + 73_000 - 60_000 - 200), + Kind: protocol.PushChargingSessionComplete, Severity: SeverityInfo, + Args: map[string]string{"kwh": "18.4"}, Src: charger, LP: garage, + }}, } } diff --git a/go/protocol/push_gen.go b/go/protocol/push_gen.go index c0454eea2..bb6a93f20 100644 --- a/go/protocol/push_gen.go +++ b/go/protocol/push_gen.go @@ -10,6 +10,7 @@ const PushCatalogueVersion = 1 const ( PushChargingConnected = "charging.connected" PushChargingSessionComplete = "charging.session_complete" + PushChargingGoalAtRisk = "charging.goal_at_risk" PushChargingInterrupted = "charging.interrupted" PushUpdateInstalled = "update.installed" PushDriverOffline = "driver.offline" @@ -30,6 +31,7 @@ type PushSentence struct { var PushSentences = map[string]PushSentence{ PushChargingConnected: {Title: "Car plugged in", Body: "Open to see charging status, check the battery level and set a ready time."}, PushChargingSessionComplete: {Title: "Car charged", Body: "{kwh} kWh delivered — ready to go."}, + PushChargingGoalAtRisk: {Title: "The car may not be ready", Body: "It will not reach {target} % by {by} at this pace."}, PushChargingInterrupted: {Title: "Charging stopped early", Body: "The car stopped charging before it was done."}, PushUpdateInstalled: {Title: "Your box updated itself", Body: "Now running {version}. Everything came back on its own."}, PushDriverOffline: {Title: "A device went quiet", Body: "{name} stopped answering."},