signalk-chiplog 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/LICENSE +21 -0
- package/README.md +290 -0
- package/index.js +327 -0
- package/lib/api.js +427 -0
- package/lib/database.js +264 -0
- package/lib/detection.js +407 -0
- package/lib/entries.js +345 -0
- package/lib/errors.js +22 -0
- package/lib/event-watcher.js +332 -0
- package/lib/events.js +287 -0
- package/lib/export.js +220 -0
- package/lib/formats.js +215 -0
- package/lib/manoeuvre-types.js +84 -0
- package/lib/observation-recorder.js +152 -0
- package/lib/place-names.js +208 -0
- package/lib/places.js +128 -0
- package/lib/propulsion-detector.js +156 -0
- package/lib/propulsion.js +75 -0
- package/lib/rows.js +15 -0
- package/lib/track-recorder.js +149 -0
- package/lib/track.js +58 -0
- package/lib/usb-scheduler.js +131 -0
- package/lib/validation.js +105 -0
- package/package.json +67 -0
- package/public/app.css +603 -0
- package/public/entry/entry.css +528 -0
- package/public/entry/icons/apple-touch-icon.png +0 -0
- package/public/entry/icons/icon-192.png +0 -0
- package/public/entry/icons/icon-512.png +0 -0
- package/public/entry/index.html +22 -0
- package/public/entry/js/access.mjs +116 -0
- package/public/entry/js/clock.mjs +29 -0
- package/public/entry/js/components/AccessGate.mjs +70 -0
- package/public/entry/js/components/Dialogs.mjs +64 -0
- package/public/entry/js/components/ManoeuvrePad.mjs +106 -0
- package/public/entry/js/components/NotePanel.mjs +34 -0
- package/public/entry/js/components/RecentList.mjs +113 -0
- package/public/entry/js/components/SketchPanel.mjs +181 -0
- package/public/entry/js/components/StatusHeader.mjs +69 -0
- package/public/entry/js/journal.mjs +139 -0
- package/public/entry/js/main.mjs +360 -0
- package/public/entry/js/outbox.mjs +124 -0
- package/public/entry/js/strokes.mjs +81 -0
- package/public/entry/manifest.webmanifest +26 -0
- package/public/entry/sw.js +75 -0
- package/public/icon.svg +7 -0
- package/public/index.html +18 -0
- package/public/js/api.mjs +85 -0
- package/public/js/auth.mjs +41 -0
- package/public/js/components/ExportView.mjs +182 -0
- package/public/js/components/LogView.mjs +132 -0
- package/public/js/components/PassageView.mjs +343 -0
- package/public/js/components/PropulsionStrip.mjs +60 -0
- package/public/js/components/StatusBar.mjs +55 -0
- package/public/js/components/Timeline.mjs +226 -0
- package/public/js/components/TrackMap.mjs +85 -0
- package/public/js/components/common.mjs +70 -0
- package/public/js/context.mjs +56 -0
- package/public/js/days.mjs +56 -0
- package/public/js/format.mjs +84 -0
- package/public/js/i18n.mjs +480 -0
- package/public/js/ids.mjs +10 -0
- package/public/js/main.mjs +48 -0
- package/public/js/status.mjs +21 -0
- package/public/vendor/htm-LICENSE +202 -0
- package/public/vendor/leaflet/LICENSE +26 -0
- package/public/vendor/leaflet/images/layers-2x.png +0 -0
- package/public/vendor/leaflet/images/layers.png +0 -0
- package/public/vendor/leaflet/images/marker-icon-2x.png +0 -0
- package/public/vendor/leaflet/images/marker-icon.png +0 -0
- package/public/vendor/leaflet/images/marker-shadow.png +0 -0
- package/public/vendor/leaflet/leaflet.css +661 -0
- package/public/vendor/leaflet/leaflet.js +6 -0
- package/public/vendor/preact-LICENSE +21 -0
- package/public/vendor/preact-htm.mjs +1 -0
package/lib/detection.js
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
const { withTransaction } = require('./database');
|
|
2
|
+
const { initialPlaceName } = require('./place-names');
|
|
3
|
+
const { createObservationRecorder } = require('./observation-recorder');
|
|
4
|
+
const { createPropulsionTracker } = require('./propulsion-detector');
|
|
5
|
+
const { toPosition } = require('./rows');
|
|
6
|
+
|
|
7
|
+
const METRES_PER_SECOND_PER_KNOT = 1852 / 3600;
|
|
8
|
+
|
|
9
|
+
const TICK_INTERVAL_MS = 15 * 1000;
|
|
10
|
+
// Speed is averaged over this window, so a gust swinging the boat at anchor
|
|
11
|
+
// does not start a passage.
|
|
12
|
+
const SPEED_WINDOW_MS = 3 * 60 * 1000;
|
|
13
|
+
const MIN_SPEED_SAMPLES = 6;
|
|
14
|
+
const HEARTBEAT_INTERVAL_MS = 60 * 1000;
|
|
15
|
+
const LATE_CLOSURE_MARGIN_MS = 5 * 60 * 1000;
|
|
16
|
+
// signalk-autostate needs about ten minutes to notice a change; raw speed
|
|
17
|
+
// samples older than this are no longer trusted to date one.
|
|
18
|
+
const MAX_REFINEMENT_AGE_MS = 20 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
// How long a Signal K value stays current after it last changed.
|
|
21
|
+
// signalk-autostate republishes navigation.state at least every ten minutes.
|
|
22
|
+
const MAX_AGE_MS = {
|
|
23
|
+
'navigation.state': 20 * 60 * 1000,
|
|
24
|
+
'navigation.speedOverGround': 2 * 60 * 1000,
|
|
25
|
+
'navigation.position': 2 * 60 * 1000
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// navigation.state can have several sources: an AIS class A transponder
|
|
29
|
+
// reports the boat's own navigational status — often left undefined, or at
|
|
30
|
+
// "under way using engine" while moored — which would otherwise hide
|
|
31
|
+
// signalk-autostate's decision. Its value is preferred whenever present.
|
|
32
|
+
const AUTOSTATE_SOURCE_PREFIX = 'signalk-autostate';
|
|
33
|
+
|
|
34
|
+
const STOPPED_STATES = new Set(['moored', 'anchored', 'aground', 'not-under-way']);
|
|
35
|
+
const UNDERWAY_STATES = new Set([
|
|
36
|
+
'sailing',
|
|
37
|
+
'motoring',
|
|
38
|
+
'not under command',
|
|
39
|
+
'towing < 200m',
|
|
40
|
+
'towing > 200m',
|
|
41
|
+
'pushing',
|
|
42
|
+
'fishing',
|
|
43
|
+
'fishing-hampered',
|
|
44
|
+
'trawling',
|
|
45
|
+
'trawling-shooting',
|
|
46
|
+
'trawling-hauling',
|
|
47
|
+
'pilotage',
|
|
48
|
+
'restricted manouverability',
|
|
49
|
+
'restricted manouverability towing < 200m',
|
|
50
|
+
'restricted manouverability towing > 200m',
|
|
51
|
+
'restricted manouverability underwater operations',
|
|
52
|
+
'constrained by draft',
|
|
53
|
+
'mine clearance'
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
const DETECTION_DEFAULTS = { stopClosureMinutes: 30, fallbackUnderwaySpeed: 1 };
|
|
57
|
+
|
|
58
|
+
function iso(ms) {
|
|
59
|
+
return new Date(ms).toISOString();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function classifyState(state) {
|
|
63
|
+
if (STOPPED_STATES.has(state)) {
|
|
64
|
+
return 'stopped';
|
|
65
|
+
}
|
|
66
|
+
return UNDERWAY_STATES.has(state) ? 'underway' : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// A value is trusted when its timestamp changed recently by our own clock, so
|
|
70
|
+
// a system clock that is off — a Raspberry Pi without a real-time clock boots
|
|
71
|
+
// with the wrong date — does not make live data look stale. Only a value seen
|
|
72
|
+
// for the first time is judged against the system clock.
|
|
73
|
+
function createFreshnessTracker(readSelfPath) {
|
|
74
|
+
const seen = new Map();
|
|
75
|
+
|
|
76
|
+
// `key` names what is tracked; `node` defaults to the value at that path, and
|
|
77
|
+
// is given to track one source of a path on its own.
|
|
78
|
+
return function readFresh(key, now, maxAge = MAX_AGE_MS[key], node = readSelfPath(key)) {
|
|
79
|
+
if (!node || node.value === null || node.value === undefined || !node.timestamp) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const record = seen.get(key);
|
|
84
|
+
if (!record || record.timestamp !== node.timestamp) {
|
|
85
|
+
let observedAt = now;
|
|
86
|
+
if (!record) {
|
|
87
|
+
const stamped = Date.parse(node.timestamp);
|
|
88
|
+
observedAt = Math.abs(now - stamped) <= maxAge ? Math.min(now, stamped) : null;
|
|
89
|
+
}
|
|
90
|
+
seen.set(key, { timestamp: node.timestamp, observedAt });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const { observedAt } = seen.get(key);
|
|
94
|
+
return observedAt !== null && now - observedAt <= maxAge ? node.value : undefined;
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The reading of navigation.state detection relies on — signalk-autostate's
|
|
99
|
+
// when it publishes one — with its source, from Signal K's per-source values.
|
|
100
|
+
function stateReading(node) {
|
|
101
|
+
if (!node) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
const sources = node.values && typeof node.values === 'object' ? node.values : {};
|
|
105
|
+
const autostate = Object.keys(sources).find((source) =>
|
|
106
|
+
source.startsWith(AUTOSTATE_SOURCE_PREFIX)
|
|
107
|
+
);
|
|
108
|
+
if (autostate) {
|
|
109
|
+
return { source: autostate, node: sources[autostate] };
|
|
110
|
+
}
|
|
111
|
+
return { source: node.$source ?? null, node };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Why detection is not following navigation.state, for the apps to explain.
|
|
115
|
+
function describeStateIssue(reading, freshValue) {
|
|
116
|
+
const value = reading?.node?.value;
|
|
117
|
+
if (!reading || value === undefined) {
|
|
118
|
+
return { reason: 'absent' };
|
|
119
|
+
}
|
|
120
|
+
const detail = { source: reading.source, value, updatedAt: reading.node.timestamp ?? null };
|
|
121
|
+
if (value === null) {
|
|
122
|
+
return { reason: 'pending', ...detail };
|
|
123
|
+
}
|
|
124
|
+
return { reason: freshValue === undefined ? 'stale' : 'unrecognised', ...detail };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function createSpeedTracker({ underwaySpeed, stoppedSpeed }) {
|
|
128
|
+
const samples = [];
|
|
129
|
+
let motion = 'unknown';
|
|
130
|
+
// Contiguous streaks of raw samples, used to date and place transitions more
|
|
131
|
+
// precisely than the averaged or autostate decision can.
|
|
132
|
+
let lastStill = null;
|
|
133
|
+
let leftStillAt = null;
|
|
134
|
+
let stillSince = null;
|
|
135
|
+
|
|
136
|
+
function prune(now) {
|
|
137
|
+
while (samples.length > 0 && samples[0].time < now - SPEED_WINDOW_MS) {
|
|
138
|
+
samples.shift();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
observe(time, sog, position) {
|
|
144
|
+
samples.push({ time, sog });
|
|
145
|
+
const point = { time, position };
|
|
146
|
+
if (sog < stoppedSpeed) {
|
|
147
|
+
lastStill = point;
|
|
148
|
+
leftStillAt = null;
|
|
149
|
+
stillSince = stillSince ?? point;
|
|
150
|
+
} else {
|
|
151
|
+
leftStillAt = leftStillAt ?? point;
|
|
152
|
+
stillSince = null;
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
motion(now) {
|
|
157
|
+
prune(now);
|
|
158
|
+
if (samples.length === 0) {
|
|
159
|
+
motion = 'unknown';
|
|
160
|
+
} else if (samples.length >= MIN_SPEED_SAMPLES) {
|
|
161
|
+
const mean = samples.reduce((sum, sample) => sum + sample.sog, 0) / samples.length;
|
|
162
|
+
if (mean >= underwaySpeed) {
|
|
163
|
+
motion = 'underway';
|
|
164
|
+
} else if (mean < stoppedSpeed) {
|
|
165
|
+
motion = 'stopped';
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return motion;
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
departure(now) {
|
|
172
|
+
if (!leftStillAt || now - leftStillAt.time > MAX_REFINEMENT_AGE_MS) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
const berth = lastStill && now - lastStill.time <= MAX_REFINEMENT_AGE_MS ? lastStill : null;
|
|
176
|
+
return { time: leftStillAt.time, position: berth?.position ?? leftStillAt.position };
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
arrival(now) {
|
|
180
|
+
return stillSince && now - stillSince.time <= MAX_REFINEMENT_AGE_MS ? stillSince : null;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function createPassageDetector({ db, readSelfPath, settings, clock = Date.now }) {
|
|
186
|
+
const readFresh = createFreshnessTracker(readSelfPath);
|
|
187
|
+
const underwaySpeed = settings.fallbackUnderwaySpeed * METRES_PER_SECOND_PER_KNOT;
|
|
188
|
+
const speeds = createSpeedTracker({ underwaySpeed, stoppedSpeed: underwaySpeed / 2 });
|
|
189
|
+
const closureMs = settings.stopClosureMinutes * 60 * 1000;
|
|
190
|
+
const propulsion = createPropulsionTracker({ db, readSelfPath, readFresh, settings });
|
|
191
|
+
const observations = createObservationRecorder({ db, readSelfPath, readFresh, settings });
|
|
192
|
+
|
|
193
|
+
let mode = 'fallback';
|
|
194
|
+
let stateIssue = { reason: 'absent' };
|
|
195
|
+
let motion = 'unknown';
|
|
196
|
+
let propulsionType = null;
|
|
197
|
+
let resumed = false;
|
|
198
|
+
|
|
199
|
+
const activeEntry = () => db.prepare("SELECT * FROM log_entries WHERE state = 'active'").get();
|
|
200
|
+
|
|
201
|
+
function storedEnd(entry) {
|
|
202
|
+
return toPosition(entry.end_lat, entry.end_lon);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function sense(now) {
|
|
206
|
+
const position = readFresh('navigation.position', now);
|
|
207
|
+
const current =
|
|
208
|
+
position && Number.isFinite(position.latitude) && Number.isFinite(position.longitude)
|
|
209
|
+
? { lat: position.latitude, lon: position.longitude }
|
|
210
|
+
: null;
|
|
211
|
+
|
|
212
|
+
const sog = readFresh('navigation.speedOverGround', now);
|
|
213
|
+
if (typeof sog === 'number' && Number.isFinite(sog)) {
|
|
214
|
+
speeds.observe(now, sog, current);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
observations.sense(now);
|
|
218
|
+
const reading = stateReading(readSelfPath('navigation.state'));
|
|
219
|
+
const navigationState = reading
|
|
220
|
+
? readFresh(
|
|
221
|
+
`navigation.state@${reading.source}`,
|
|
222
|
+
now,
|
|
223
|
+
MAX_AGE_MS['navigation.state'],
|
|
224
|
+
reading.node
|
|
225
|
+
)
|
|
226
|
+
: undefined;
|
|
227
|
+
const fromState = classifyState(navigationState);
|
|
228
|
+
mode = fromState ? 'autostate' : 'fallback';
|
|
229
|
+
stateIssue = fromState ? null : describeStateIssue(reading, navigationState);
|
|
230
|
+
return {
|
|
231
|
+
motion: fromState ?? speeds.motion(now),
|
|
232
|
+
position: current,
|
|
233
|
+
propulsion: propulsion.sense(now, navigationState)
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// When a stopped passage gets going again, it resumed when raw speed left
|
|
238
|
+
// standstill, not when the averaged or autostate decision caught up.
|
|
239
|
+
function resumeTime(entry, now) {
|
|
240
|
+
const moving = speeds.departure(now);
|
|
241
|
+
const stoppedAt = Date.parse(entry.stopped_since);
|
|
242
|
+
return moving && moving.time > stoppedAt ? moving.time : now;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function recordMovement(entry, now, position, { force = false } = {}) {
|
|
246
|
+
const last = entry.last_moving_at === null ? null : Date.parse(entry.last_moving_at);
|
|
247
|
+
if (!force && last !== null && now - last < HEARTBEAT_INTERVAL_MS) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const at = position ?? storedEnd(entry);
|
|
251
|
+
db.prepare(
|
|
252
|
+
`UPDATE log_entries
|
|
253
|
+
SET last_moving_at = ?, end_lat = ?, end_lon = ?, stopped_since = NULL, updated_at = ?
|
|
254
|
+
WHERE id = ?`
|
|
255
|
+
).run(iso(now), at ? at.lat : null, at ? at.lon : null, iso(now), entry.id);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function markStopped(entry, { time, position }, now) {
|
|
259
|
+
const at = position ?? storedEnd(entry);
|
|
260
|
+
const stoppedAt = time < entry.start_time ? entry.start_time : time;
|
|
261
|
+
db.prepare(
|
|
262
|
+
`UPDATE log_entries SET stopped_since = ?, end_lat = ?, end_lon = ?, updated_at = ?
|
|
263
|
+
WHERE id = ?`
|
|
264
|
+
).run(stoppedAt, at ? at.lat : null, at ? at.lon : null, iso(now), entry.id);
|
|
265
|
+
return activeEntry();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function arrivalOf(entry, now, position) {
|
|
269
|
+
const still = speeds.arrival(now);
|
|
270
|
+
if (still && iso(still.time) >= entry.start_time) {
|
|
271
|
+
return { time: iso(still.time), position: still.position ?? position };
|
|
272
|
+
}
|
|
273
|
+
// Without a speed streak, the last recorded movement is the best bound.
|
|
274
|
+
return { time: entry.last_moving_at ?? iso(now), position: storedEnd(entry) ?? position };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function closePassage(entry, now) {
|
|
278
|
+
// A name the crew typed before arrival is kept as it is.
|
|
279
|
+
const place =
|
|
280
|
+
entry.end_place_name === null
|
|
281
|
+
? initialPlaceName(db, storedEnd(entry), settings.placeMatchRadius)
|
|
282
|
+
: { id: entry.end_place_id, name: entry.end_place_name, pending: entry.end_place_pending };
|
|
283
|
+
db.prepare(
|
|
284
|
+
`UPDATE log_entries
|
|
285
|
+
SET state = 'closed', end_time = stopped_since, stopped_since = NULL,
|
|
286
|
+
end_place_id = ?, end_place_name = ?, end_place_pending = ?, updated_at = ?
|
|
287
|
+
WHERE id = ?`
|
|
288
|
+
).run(place.id, place.name, place.pending, iso(now), entry.id);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function openPassage(now, position) {
|
|
292
|
+
const departure = speeds.departure(now) ?? { time: now, position };
|
|
293
|
+
const previousEnd = db
|
|
294
|
+
.prepare('SELECT MAX(end_time) AS endTime FROM log_entries')
|
|
295
|
+
.get().endTime;
|
|
296
|
+
const startTime =
|
|
297
|
+
previousEnd && iso(departure.time) < previousEnd ? previousEnd : iso(departure.time);
|
|
298
|
+
const start = departure.position ?? position;
|
|
299
|
+
const place = initialPlaceName(db, start, settings.placeMatchRadius);
|
|
300
|
+
|
|
301
|
+
const { lastInsertRowid } = db
|
|
302
|
+
.prepare(
|
|
303
|
+
`INSERT INTO log_entries (
|
|
304
|
+
state, start_time, start_lat, start_lon, start_place_id, start_place_name,
|
|
305
|
+
start_place_pending, last_moving_at, end_lat, end_lon, created_at, updated_at
|
|
306
|
+
) VALUES ('active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
307
|
+
)
|
|
308
|
+
.run(
|
|
309
|
+
startTime,
|
|
310
|
+
start ? start.lat : null,
|
|
311
|
+
start ? start.lon : null,
|
|
312
|
+
place.id,
|
|
313
|
+
place.name,
|
|
314
|
+
place.pending,
|
|
315
|
+
iso(now),
|
|
316
|
+
position ? position.lat : null,
|
|
317
|
+
position ? position.lon : null,
|
|
318
|
+
iso(now),
|
|
319
|
+
iso(now)
|
|
320
|
+
);
|
|
321
|
+
return Number(lastInsertRowid);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function step(now, current, position, previous) {
|
|
325
|
+
let entry = activeEntry();
|
|
326
|
+
let resumedAt = null;
|
|
327
|
+
let opened = null;
|
|
328
|
+
let closed = null;
|
|
329
|
+
|
|
330
|
+
// A passage left open across a restart with no movement recorded for longer
|
|
331
|
+
// than the tolerance ended while the plugin was not running — typically
|
|
332
|
+
// power switched off on arrival.
|
|
333
|
+
if (!resumed) {
|
|
334
|
+
resumed = true;
|
|
335
|
+
if (entry && entry.stopped_since === null) {
|
|
336
|
+
const lastMoving = entry.last_moving_at ?? entry.start_time;
|
|
337
|
+
if (now - Date.parse(lastMoving) >= closureMs) {
|
|
338
|
+
entry = markStopped(entry, { time: lastMoving, position: storedEnd(entry) }, now);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (entry && entry.stopped_since === null) {
|
|
344
|
+
if (current === 'underway') {
|
|
345
|
+
recordMovement(entry, now, position);
|
|
346
|
+
} else if (current === 'stopped') {
|
|
347
|
+
entry = markStopped(entry, arrivalOf(entry, now, position), now);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (entry && entry.stopped_since !== null) {
|
|
352
|
+
const stoppedFor = now - Date.parse(entry.stopped_since);
|
|
353
|
+
if (stoppedFor >= closureMs) {
|
|
354
|
+
closePassage(entry, now);
|
|
355
|
+
// Closed well after the tolerance ran out means the plugin was not
|
|
356
|
+
// running: current conditions say nothing about that arrival.
|
|
357
|
+
closed = { id: entry.id, late: stoppedFor > closureMs + LATE_CLOSURE_MARGIN_MS };
|
|
358
|
+
entry = null;
|
|
359
|
+
} else if (current === 'underway') {
|
|
360
|
+
resumedAt = resumeTime(entry, now);
|
|
361
|
+
recordMovement(entry, now, position, { force: true });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Only a transition opens a passage: an entry closed by hand while still
|
|
366
|
+
// moving must not be reopened on the next tick.
|
|
367
|
+
if (!entry && current === 'underway' && previous !== 'underway') {
|
|
368
|
+
opened = openPassage(now, position);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return { resumedAt, opened, closed };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
tick() {
|
|
376
|
+
const now = clock();
|
|
377
|
+
const sensed = sense(now);
|
|
378
|
+
const previous = motion;
|
|
379
|
+
withTransaction(db, () => {
|
|
380
|
+
const outcome = step(now, sensed.motion, sensed.position, previous);
|
|
381
|
+
propulsion.reconcile(now, sensed.propulsion, outcome);
|
|
382
|
+
observations.afterDetection(now, outcome);
|
|
383
|
+
});
|
|
384
|
+
motion = sensed.motion;
|
|
385
|
+
propulsionType = motion === 'underway' ? sensed.propulsion.type : null;
|
|
386
|
+
return {
|
|
387
|
+
mode,
|
|
388
|
+
motion,
|
|
389
|
+
propulsion: propulsionType,
|
|
390
|
+
activeEntryId: activeEntry()?.id ?? null
|
|
391
|
+
};
|
|
392
|
+
},
|
|
393
|
+
mode: () => mode,
|
|
394
|
+
stateIssue: () => stateIssue,
|
|
395
|
+
motion: () => motion,
|
|
396
|
+
propulsion: () => propulsionType,
|
|
397
|
+
observeEvent: (entryId, time) => observations.recordEvent(entryId, time, clock())
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
module.exports = {
|
|
402
|
+
createFreshnessTracker,
|
|
403
|
+
createPassageDetector,
|
|
404
|
+
DETECTION_DEFAULTS,
|
|
405
|
+
MAX_REFINEMENT_AGE_MS,
|
|
406
|
+
TICK_INTERVAL_MS
|
|
407
|
+
};
|