Repository navigation
Expand file tree
/
Copy pathcommand.go
More file actions
251 lines (231 loc) · 8.73 KB
/
Copy pathcommand.go
File metadata and controls
251 lines (231 loc) · 8.73 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
package appproto
import (
"sort"
"sync"
)
// CmdRetentionMs is how long a command id is remembered.
//
// Twenty-four hours. Long enough that a phone which lost its connection
// mid-command and retried the next morning gets the original outcome back
// instead of acting a second time; short enough that the table stays small.
// Measured in box uptime like everything else here.
const CmdRetentionMs int64 = 24 * 60 * 60 * 1000
// LeaseMs is how long a dispatcher lease stays valid.
const LeaseMs int64 = 15 * 60 * 1000
// opSpec is what an operation needs before it may run.
type opSpec struct {
// scope the grant must carry.
scope string
// dispatchWrite marks operations that move energy. Those are refused
// while the box's own dispatch gate is blocked — the safety invariant
// is that stale meter data stops dispatch, and an operation that would
// dispatch must not slip past it through a different door.
//
// Setting the site mode is deliberately not one of these. The mode is
// state the box holds; the dispatch gate already refuses to act on it
// until the meter is back, and refusing the mode change too would leave
// a user unable to pick a safer strategy precisely when a meter is sick.
dispatchWrite bool
}
// defaultOps is every operation this box will accept. A handler holds its own
// copy so nothing rewrites a package-wide table underneath a live session.
func defaultOps() map[string]opSpec {
return map[string]opSpec{
OpSetMode: {scope: ScopeModeWrite, dispatchWrite: false},
// Household planner prefs can switch which planner mode is driving,
// which is state the box holds. The plan that comes out of them still
// meets the dispatch gate before anything moves, so a sick meter must
// not lock the household out of holding more reserve.
OpPlannerPrefsSet: {scope: ScopeModeWrite, dispatchWrite: false},
// The household clock is setup the box holds, and a goal read on the
// wrong clock is wrong whatever the meter says.
OpSiteTimeZonePropose: {scope: ScopeSetupWrite, dispatchWrite: false},
// The loadpoint pair moves energy — a manual hold commands the
// charger and a boost opens the battery-to-EV path — so both sit
// behind the dispatch gate. A box with no loadpoint controller
// answers E_UNAVAILABLE, the session's word for the HTTP 503 the
// same routes give.
OpLoadpointHold: {scope: ScopeDispatchWrite, dispatchWrite: true},
OpLoadpointBoost: {scope: ScopeDispatchWrite, dispatchWrite: true},
// The two loadpoint settings carry the same scope — their HTTP
// routes are priced Actuate, and a viewer must not change what the
// car may draw — but not the dispatch gate, for the mode's reason:
// each is state the box holds, and the planner output it reshapes
// still meets the gate before anything moves. Refusing a corrected
// charge level or a PV-only toggle while a meter is sick would keep
// the plan wrong for exactly as long as the box cannot act on it.
OpLoadpointSoCSet: {scope: ScopeDispatchWrite, dispatchWrite: false},
OpLoadpointSurplusOnlySet: {scope: ScopeDispatchWrite, dispatchWrite: false},
// Cars (docs/cars.md). A car's goal, its level and which car is on a
// charger are a phone's to set, as the charger settings above are,
// and for the same reason none waits for the dispatch gate. The list
// of cars is the owner's alone: ftw.cars.write is in no other role.
OpCarGoalSet: {scope: ScopeDispatchWrite, dispatchWrite: false},
OpCarSoCSet: {scope: ScopeDispatchWrite, dispatchWrite: false},
OpSessionCarSet: {scope: ScopeDispatchWrite, dispatchWrite: false},
OpCarAdd: {scope: ScopeCarsWrite, dispatchWrite: false},
OpCarUpdate: {scope: ScopeCarsWrite, dispatchWrite: false},
OpCarRemove: {scope: ScopeCarsWrite, dispatchWrite: false},
}
}
// cmdRecord is one remembered outcome.
type cmdRecord struct {
ack CmdAck
// result is nil until the driver has read the value back. A retry
// arriving in that window gets the ack again and keeps waiting, which
// is the truth: the box has the intent and the hardware has not
// confirmed.
result *CmdResult
storedAt int64
}
// cmdKey names one intent: the device at the root of the caller's enrolment
// and the command id that device minted. Two devices may mint the same id, and
// each still means its own intent.
type cmdKey struct {
root string
cmdID string
}
// CmdLogPerRoot caps how many command ids one root device keeps.
//
// A person sends a few commands a day, and a slider a few a second while it
// moves. A thousand in a day is far above both. The cap bounds memory when one
// enrolled device floods the box, and it evicts only that device's oldest
// ids, so no device can push another's out of the log.
const CmdLogPerRoot = 1024
// CmdLog is the box's idempotency table.
//
// One per box, shared by every app session. A session lives as long as its
// socket, and a phone that loses its socket mid-command retries in a new one;
// a table that died with the first session would let that retry act again.
// Kept in memory and measured in box uptime, as every age here is.
type CmdLog struct {
mu sync.Mutex
entries map[cmdKey]*cmdRecord
}
// NewCmdLog makes an empty table.
func NewCmdLog() *CmdLog {
return &CmdLog{entries: make(map[cmdKey]*cmdRecord)}
}
// lookup returns a remembered outcome, if the box has seen this id from this
// root device before.
func (l *CmdLog) lookup(root, cmdID string) (*cmdRecord, bool) {
l.mu.Lock()
defer l.mu.Unlock()
rec, ok := l.entries[cmdKey{root, cmdID}]
if !ok {
return nil, false
}
copied := *rec
return &copied, true
}
// remember stores an ack unless the id is already there, and prunes anything
// past the retention window.
//
// Two sessions of one root device can pass lookup with the same id at the same
// moment. Only the first to get here stores its ack and may act; the other
// receives the stored record and must replay it instead.
func (l *CmdLog) remember(root, cmdID string, ack CmdAck, uptimeMs int64) (*cmdRecord, bool) {
l.mu.Lock()
defer l.mu.Unlock()
for k, rec := range l.entries {
if uptimeMs-rec.storedAt > CmdRetentionMs {
delete(l.entries, k)
}
}
key := cmdKey{root, cmdID}
if rec, ok := l.entries[key]; ok {
copied := *rec
return &copied, false
}
l.entries[key] = &cmdRecord{ack: ack, storedAt: uptimeMs}
l.capRoot(key)
return nil, true
}
// capRoot drops one root device's oldest id, other than the one just stored,
// when that device holds more than CmdLogPerRoot. Ids arrive one at a time, so
// one drop is enough.
func (l *CmdLog) capRoot(fresh cmdKey) {
held := 0
var oldest cmdKey
var oldestAt int64
for k, rec := range l.entries {
if k.root != fresh.root {
continue
}
held++
if k != fresh && (oldest == (cmdKey{}) || rec.storedAt < oldestAt) {
oldest, oldestAt = k, rec.storedAt
}
}
if held > CmdLogPerRoot {
delete(l.entries, oldest)
}
}
// forget drops a command id the box took but never acted on, so a retry
// acts.
func (l *CmdLog) forget(root, cmdID string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.entries, cmdKey{root, cmdID})
}
// settle attaches the outcome to a remembered command.
func (l *CmdLog) settle(root, cmdID string, res CmdResult) {
l.mu.Lock()
defer l.mu.Unlock()
if rec, ok := l.entries[cmdKey{root, cmdID}]; ok {
copied := res
rec.result = &copied
}
}
// size is for tests: the table must not grow without bound.
func (l *CmdLog) size() int {
l.mu.Lock()
defer l.mu.Unlock()
return len(l.entries)
}
// guardsHold re-evaluates every guard against state as it is now.
//
// Now, not as it was when the user pressed the button. The comparison uses the
// same integers the telemetry stream sends, so a guard written against what
// the app displayed means what the person who wrote it thought it meant.
//
// An unknown field id fails. A guard nobody can evaluate is not a guard that
// passed.
func guardsHold(guards []Guard, fields map[string]int64) (bool, Guard) {
for _, g := range guards {
v, ok := fields[fidKey(g.Fid)]
if !ok {
return false, g
}
fv := float64(v)
pass := false
switch g.Op {
case GuardLT:
pass = fv < g.Value
case GuardLTE:
pass = fv <= g.Value
case GuardGT:
pass = fv > g.Value
case GuardGTE:
pass = fv >= g.Value
default:
// An operator this box does not know cannot be checked, and an
// unchecked precondition is the one thing a guard must never be.
pass = false
}
if !pass {
return false, g
}
}
return true, Guard{}
}
// sortedGuardFids is only for error args, so the app can name the guard that
// failed without the box writing a sentence.
func sortedGuardFids(guards []Guard) []int64 {
out := make([]int64, 0, len(guards))
for _, g := range guards {
out = append(out, int64(g.Fid))
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}