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

Filter by extension

Filter by extension

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

The box keeps cars apart from its chargers. A car's level is its own: a reading from the car stays exact until 1 kWh has reached it, however old the reading, so a nine-minute-old 14 % with nothing delivered since shows as "from the car". The box knows which car is on a charger from the charger's identifier, the car's own report, the only car, or one tap after it asks (`car.which`), and the plan takes that car's capacity, goal, surplus choice and power limit. On first start the box makes Car 1 from each charger's settings and a car from each vehicle profile, and binds each vehicle driver by VIN or to the only car. The charger routes and ops act on the session's car for this release, so apps already installed keep working.
15 changes: 10 additions & 5 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ open to an agent.
| `planner.prefs.set` | `ftw.goals.request` | `agent.request` only |
| `loadpoint.soc.set` | `ftw.goals.request` | `agent.request` only |
| `loadpoint.surplus_only.set` | `ftw.goals.request` | `agent.request` only |
| `car.goal.set`, `car.soc.set` | `ftw.goals.request` | `agent.request` only, naming the car by its agent id or the charger by `charger_id` ([cars.md](cars.md)) |
| `battery.hold`, `loadpoint.boost` | none | Never. They return when the box can bound them: `battery.hold` has no handler, and a boost has no power of its own to hold to `maxW` |

- **A per-grant allowlist.** A grant lists `ops`, a subset of the ops whose
Expand All @@ -139,9 +140,11 @@ open to an agent.
`clear`; `voltage`, `max_amps_per_phase`, `site_phases`, `phase_split_w`
and `min_phase_hold_s` stay with owners.
- **Ids are the agent's.** A `loadpoint` argument is the opaque charger id
the agent read from `loadpoints.get`. The box resolves it with its own
agent key, so an id from another box, or from the readable copy, names
nothing (`E_UNKNOWN_OP {arg: "id"}`).
the agent read from `loadpoints.get`, and a `car` argument the car id it
read from `cars.get`. The box resolves each with its own agent key, so an
id from another box, or from the readable copy, names nothing
(`E_UNKNOWN_OP {arg: "id"}`). A command's outcome names a car and a
charger's car by the same ids.
- **A durable change waits.** An agent that sends a `ftw.goals.request` op as
`cmd` gets `E_NEEDS_OWNER`, with `{use: "agent.request"}`.
- **The checks run in Core's order:** the command's expiry, a known op, the
Expand Down Expand Up @@ -400,8 +403,10 @@ The registry's `reads` table names each read and the scope it needs.
fallbacks, refusals and plan against actual join it when the box keeps
them; a field the box cannot fill is absent.
- The box answers `events.get` and `diag.run` with `E_UNKNOWN_OP` until it
builds them, and `cars.get` until it has the car store. Until then
`loadpoints.get` carries no car id.
builds them. `cars.get` carries no name, maker, VIN, identifier or source,
and the redactor takes those out of `logs.get` and `config.get`; a box
without the car store answers it `E_UNAVAILABLE`. `plan.get` names each
charger and its car by agent id.
- `logs.get` looks back over the box's whole log ring, its newest 2,000
lines. `beforeMs` and `afterMs` together pick the lines between them; an
`afterMs` at or past `beforeMs` gets `E_UNKNOWN_OP {field: "beforeMs"}`.
Expand Down
14 changes: 11 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,19 @@ session checkpoint failed. Neither means the saved schedule disappeared.
Core assigns each saved vehicle-limit goal an `intent_id` and a one-shot
`first_deadline_ms`; clients send user choices, not those bookkeeping fields.
A fresh vehicle Complete can finish a one-shot goal across restart and later
plug sessions. Completion requires one vehicle source, one connected loadpoint,
a reading after the observed connection and no measured charging. An ambiguous
match cannot finish the goal. A charger declining current is reported as a refusal, never
plug sessions. Completion requires a live report from the session car's own
source, made after the observed connection, and no measured charging. A car
the box has not named cannot finish the goal. A charger declining current is reported as a refusal, never
as an invented battery level or proof that the target was reached.

### Cars

A car belongs to its owner and visits chargers. [`go/internal/cars`](../go/internal/cars)
keeps the owners' cars, decides which car each charging session is, and
hands that car's capacity, goal, surplus choice and level to the loadpoint,
which keeps the session's energy and the estimate the planner reads. A car's
reading stays exact until 1 kWh has reached the car. See [cars.md](cars.md).

### History measurement quality

An offline flow leaves working devices recording. Missing inputs make their
Expand Down
32 changes: 24 additions & 8 deletions docs/cars.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ The Go types are in [`go/protocol/cars.go`](../go/protocol/cars.go).
| `GET /api/cars` with `ftw.cars.read` | The owner role | Every field |
| `GET /api/cars` without it | Viewers and watches | `id`, `name`, the level fields, `goal`, `surplusOnly` |
| FTW Cloud's `car` class | Phones with `ftw.live.read` | Opaque `id`, the level fields, `goal`, `surplusOnly` |
| `cars.get` | Agents with `ftw.live.read` | The car class under the agent key ([agents.md](agents.md)) |

An agent asks for a car's goal or level with `car.goal.set` and
`car.soc.set` as `agent.request`, which waits for an owner, naming the car
by the id `cars.get` gave it. It never taps and never keeps the list.

`car.add`, `car.update` and `car.remove` need `ftw.cars.write`, which only
the owner role carries. They change no hardware, so they need no passkey
Expand Down Expand Up @@ -147,10 +152,14 @@ At each plug-in the box asks, in this order (`carIdentifiedBy`):

The plan is made per session, from the session's car: its level, its
capacity, its goal and its surplus-only choice. Planned power is held to the
lower of the charger's maximum and the car's own (`maxW`). The charge limit
the car reports caps the plan only while the level is the car's own and
still exact. `phases` is the owner's note for now: the controller follows
the charger's phase mode.
lower of the charger's maximum and the car's own (`maxW`), keeping the
charger's lowest step when that is above the car's maximum. The charge
limit the car reports caps the plan only while the level is the car's own
and still exact. The reserve that keeps a goal charging while no plan is
accepted holds to the same power and limit. `phases` is the owner's note:
`maxW` already counts the car's phases, and the controller follows the
charger's phase mode. A change of car or goal rolls the charger's deadline
before the replan, so that plan has the car's goal in it.

- **One goal per car**, used wherever the car is plugged in. `readyByMin` is
in the box's time zone.
Expand All @@ -162,9 +171,12 @@ the charger's phase mode.
charger's `vehicle_capacity_wh`, so energy delivered still counts toward a
goal, though the box shows no level. `car.goal.set` and
`car.soc.set` with `charger_id` set them, and so do the charger routes;
neither ever touches a car. Until then the car charges from surplus only,
with no level. A goal set for it plans from empty, and the car stops when
it is full. The design's "or the cheapest quarters" without a goal is not
neither ever touches a car. Until someone sets a goal it charges from
surplus only, with no level. Someone who sets a goal wants it met: the
session no longer charges from surplus alone, and the plan and the reserve
plan from empty, while the level stays unknown and is never shown as a
number. The car stops when it is full. Clearing the goal brings
surplus-only back. The design's "or the cheapest quarters" without a goal is not
built: with neither a level nor a goal there is no amount of energy to
place.

Expand Down Expand Up @@ -223,7 +235,11 @@ by default: until someone taps, the car charges as a visitor.
each do not fit, and the last is left out with TRUNC, as for any `now`.
- FTW Cloud's `lp` carries the same charger fields with opaque car ids, and
its `car` class carries each car's level and goal and nothing that names
it ([protocol-cloud.md](protocol-cloud.md)).
it ([protocol-cloud.md](protocol-cloud.md)). An agent's `loadpoints.get`
and `cars.get` are the same bodies under the agent key.
- The plan a phone reads or is pushed names each charger's car in
`chargers[].carId`; a change of car replans, and the plan reaches the
phone pending at once.

## One release of overlap

Expand Down
30 changes: 27 additions & 3 deletions go/cmd/ftw/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,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/config"
"github.com/srcfl/ftw/go/internal/redact"
Expand Down Expand Up @@ -128,6 +129,9 @@ type agentPorts struct {
boxPage func(by string) (*appproto.Handler, error)
// key cuts every id an agent sees, and the redactor's.
key []byte
// cars are the owners' cars, whose names, VINs, identifiers and
// sources the redactor takes out. Nil on a box that keeps none.
cars *cars.Runtime
// warned holds the rows already told they expire within a day.
warnMu sync.Mutex
warned map[string]bool
Expand Down Expand Up @@ -160,12 +164,22 @@ func (p *agentPorts) redactor() *redact.Redactor {

// names is everything the configuration calls something.
func (p *agentPorts) names() []string {
var out []string
if p.cars != nil {
// The cars an owner named on the box or in the app: what names
// them, and the drivers that report them.
for _, c := range p.cars.Cars() {
out = append(out, c.Name, c.VIN)
out = append(out, c.Identifiers...)
out = append(out, c.Sources...)
}
}
if p.cfg == nil {
return nil
return out
}
p.cfgMu.RLock()
defer p.cfgMu.RUnlock()
out := []string{p.cfg.Site.Name}
out = append(out, p.cfg.Site.Name)
for _, d := range p.cfg.Drivers {
out = append(out, d.Name)
}
Expand Down Expand Up @@ -341,7 +355,10 @@ func (p *agentPorts) warnExpiring(nowMs int64) {

// views builds the typed reads' bodies from the same source as FTW Cloud's
// copy, with the agent key's ids.
type agentViews struct{ site *cloudSite }
type agentViews struct {
site *cloudSite
cars *cars.Runtime
}

func (v agentViews) Live(key []byte) cloudwire.Live {
return cloudlink.ProjectLive(key, time.Since(v.site.site.started).Milliseconds(), v.site.Live())
Expand All @@ -355,6 +372,13 @@ func (v agentViews) Loadpoints(key []byte) (cloudwire.LP, bool) {
return cloudlink.ProjectLP(key, lp), true
}

func (v agentViews) Cars(key []byte) (cloudwire.Cars, bool) {
if v.cars == nil {
return cloudwire.Cars{}, false
}
return cloudlink.ProjectCars(key, v.cars.Cars()), true
}

// endAgentHolds releases every hold an agent set, by its subject.
func endAgentHolds(lp appproto.Loadpoints) func(subject string) {
return func(subject string) {
Expand Down
39 changes: 31 additions & 8 deletions go/cmd/ftw/app_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/srcfl/ftw/go/internal/appenroll"
"github.com/srcfl/ftw/go/internal/appproto"
"github.com/srcfl/ftw/go/internal/appuplink"
"github.com/srcfl/ftw/go/internal/cars"
"github.com/srcfl/ftw/go/internal/config"
"github.com/srcfl/ftw/go/internal/control"
"github.com/srcfl/ftw/go/internal/loadpoint"
Expand Down Expand Up @@ -418,6 +419,7 @@ type appLoadpoints struct {
mgr *loadpoint.Manager
ctrl *loadpoint.Controller
mpc *mpc.Service
cars *cars.Runtime
}

func (a *appLoadpoints) WaitForPersistence(ctx context.Context) error {
Expand Down Expand Up @@ -485,16 +487,28 @@ func (a *appLoadpoints) SetSoC(id string, soc float64) bool {

// Readings is every charger as the manager holds it. The car's level counts
// only while it is plugged in: unplugged, the manager's number is the last
// session's, and a watch would draw it as now.
// session's, and a watch would draw it as now. A visitor's car has none.
func (a *appLoadpoints) Readings() []appproto.LoadpointReading {
states := a.mgr.States()
out := make([]appproto.LoadpointReading, 0, len(states))
for _, st := range states {
out = append(out, appproto.LoadpointReading{
r := appproto.LoadpointReading{
ID: st.ID, Plugged: st.PluggedIn,
SoC: st.CurrentSoC, SoCKnown: st.PluggedIn,
SoC: st.CurrentSoC, SoCKnown: st.PluggedIn && !st.SoCUnknown,
PowerW: st.CurrentPowerW,
})
}
if a.cars != nil {
r.Car = a.cars.ChargerCar(st.ID)
if v, ok := a.cars.Session(st.ID); ok && v.Source != "" && r.SoCKnown {
// The level the car shows, and where it came from.
r.SoC, r.SoCSource, r.SoCEstimate = v.Shown, v.Source, v.Estimate
if !v.At.IsZero() {
age := max(0, int64(time.Since(v.At)/time.Second))
r.SoCAgeS = &age
}
}
}
out = append(out, r)
}
return out
}
Expand Down Expand Up @@ -573,7 +587,7 @@ func (a *appPlans) CeilingW() *int64 {

// appPlanChargers is each charger's share of the plan, read from the same plan
// snapshot and the same windows GET /api/loadpoints serves. Keyed by the
// charger; CarID stays empty until Core keeps cars.
// charger, with the session's car while the box knows it.
type appPlanChargers struct {
planner *mpc.Service
mgr *loadpoint.Manager
Expand All @@ -592,7 +606,7 @@ func (a *appPlanChargers) PlanChargers(now time.Time) []appproto.PlanCharger {
out := make([]appproto.PlanCharger, 0, len(states))
for _, st := range states {
ch := appproto.PlanCharger{
ID: st.ID, PluggedIn: st.PluggedIn,
ID: st.ID, CarID: st.CarID, PluggedIn: st.PluggedIn,
Pending: snap.Pending, Outdated: snap.Outdated,
Windows: []appproto.PlanChargerWindow{},
GoalRev: goalRev(st),
Expand Down Expand Up @@ -650,6 +664,7 @@ func startAppLink(
planner *mpc.Service,
lpMgr *loadpoint.Manager,
lpCtrl *loadpoint.Controller,
carsRT *cars.Runtime,
priceSvc *prices.Service,
ctrl *control.State,
ctrlMu *sync.Mutex,
Expand Down Expand Up @@ -705,9 +720,16 @@ func startAppLink(
// hides the buttons rather than drawing dead ones.
var loadpoints appproto.Loadpoints
if lpMgr != nil && lpCtrl != nil {
loadpoints = &appLoadpoints{mgr: lpMgr, ctrl: lpCtrl, mpc: planner}
loadpoints = &appLoadpoints{mgr: lpMgr, ctrl: lpCtrl, mpc: planner, cars: carsRT}
caps = append(caps, appproto.CapDerEv)
}
// Cars ride with the chargers (docs/cars.md). The box says der.car when
// it keeps them, and the car ops answer.
var carPort appproto.Cars
if carsRT != nil && loadpoints != nil {
carPort = carsRT
caps = append(caps, appproto.CapDerCar)
}

// The plan reaches every streaming phone the moment the planner
// replaces it, with each charger's windows, so a car plugged in shows
Expand Down Expand Up @@ -766,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}}
views := agentViews{site: &cloudSite{site: site, api: gateway}, cars: carsRT}

// handlerFor builds one handler for one session: a phone's stream, or a
// watch's exchange. They share the ports above, which are read-only
Expand All @@ -790,6 +812,7 @@ func startAppLink(
Loadpoints: loadpoints,
PlanChargers: planChargers,
PlanFeed: planFeed,
Cars: carPort,
API: gateway,
Caller: caller,
Grants: grants,
Expand Down
Loading
Loading