@ateam-ai-mobile/expo 0.1.7 → 0.1.8

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.
Files changed (71) hide show
  1. package/dist/bootstrap.js +841 -323
  2. package/dist/bootstrap.js.map +1 -1
  3. package/dist/bootstrap.mjs +9 -7
  4. package/dist/bootstrap.mjs.map +1 -1
  5. package/dist/chunk-7FWYEHN5.mjs +203 -0
  6. package/dist/chunk-7FWYEHN5.mjs.map +1 -0
  7. package/dist/{chunk-4XYXJYN5.mjs → chunk-C7M5WAHA.mjs} +583 -298
  8. package/dist/chunk-C7M5WAHA.mjs.map +1 -0
  9. package/dist/{chunk-5HM6UA2K.mjs → chunk-GF2MPTAB.mjs} +83 -7
  10. package/dist/chunk-GF2MPTAB.mjs.map +1 -0
  11. package/dist/chunk-J6XRNX7P.mjs +19 -0
  12. package/dist/chunk-J6XRNX7P.mjs.map +1 -0
  13. package/dist/{chunk-TZXWVYYR.mjs → chunk-PCAEUX2N.mjs} +1 -1
  14. package/dist/chunk-PCAEUX2N.mjs.map +1 -0
  15. package/dist/{chunk-VQJPSLHP.mjs → chunk-Z5AI2SXX.mjs} +13 -4
  16. package/dist/chunk-Z5AI2SXX.mjs.map +1 -0
  17. package/dist/createBridge.js +41 -10
  18. package/dist/createBridge.js.map +1 -1
  19. package/dist/createBridge.mjs +5 -1
  20. package/dist/createBridge.mjs.map +1 -1
  21. package/dist/device/createDeviceProvider.js +278 -17
  22. package/dist/device/createDeviceProvider.js.map +1 -1
  23. package/dist/device/createDeviceProvider.mjs +5 -3
  24. package/dist/device-bridge/index.d.mts +13 -1
  25. package/dist/device-bridge/index.d.ts +13 -1
  26. package/dist/device-bridge/index.js +49 -12
  27. package/dist/device-bridge/index.js.map +1 -1
  28. package/dist/device-bridge/index.mjs +3 -1
  29. package/dist/index.d.mts +19 -3
  30. package/dist/index.d.ts +19 -3
  31. package/dist/index.js +594 -297
  32. package/dist/index.js.map +1 -1
  33. package/dist/index.mjs +2 -1
  34. package/dist/maps/index.d.mts +56 -0
  35. package/dist/maps/index.d.ts +56 -0
  36. package/dist/maps/index.js +95 -0
  37. package/dist/maps/index.js.map +1 -0
  38. package/dist/maps/index.mjs +17 -0
  39. package/dist/maps/index.mjs.map +1 -0
  40. package/dist/vision/index.d.mts +4 -2
  41. package/dist/vision/index.d.ts +4 -2
  42. package/dist/vision/index.js.map +1 -1
  43. package/dist/vision/index.mjs +1 -1
  44. package/expo-module.config.json +1 -1
  45. package/ios/AdaMaps/AdaMapsModule.swift +180 -0
  46. package/ios/AdaMaps.podspec +25 -0
  47. package/ios/AdaVision.podspec +4 -0
  48. package/ios/AdaVisionModule.swift +63 -9
  49. package/package.json +10 -4
  50. package/src/args.ts +56 -0
  51. package/src/createBridge.ts +3 -1
  52. package/src/device/createDeviceProvider.ts +88 -3
  53. package/src/device-bridge/collectors/location.ts +21 -9
  54. package/src/device-state/collectors.ts +5 -1
  55. package/src/device-state/domains/deviceContext.ts +5 -3
  56. package/src/device-state/domains/interaction.ts +2 -1
  57. package/src/device-state/domains/spatial.ts +200 -116
  58. package/src/device-state/domains/visual.ts +34 -13
  59. package/src/device-state/events.ts +49 -0
  60. package/src/device-state/headingSource.ts +43 -8
  61. package/src/device-state/index.ts +3 -2
  62. package/src/device-state/replay.ts +76 -11
  63. package/src/device-state/spatialEvents.ts +300 -0
  64. package/src/geo.ts +23 -0
  65. package/src/maps/index.ts +81 -0
  66. package/src/maps/normalize.ts +225 -0
  67. package/src/vision/index.ts +4 -2
  68. package/dist/chunk-4XYXJYN5.mjs.map +0 -1
  69. package/dist/chunk-5HM6UA2K.mjs.map +0 -1
  70. package/dist/chunk-TZXWVYYR.mjs.map +0 -1
  71. package/dist/chunk-VQJPSLHP.mjs.map +0 -1
@@ -1,10 +1,25 @@
1
1
  // ── spatial ──────────────────────────────────────────────────────────────
2
- // position ✓ path ✓ (while a session is open) heading ✓ speed ~derived
3
- import type { DeviceState, SpatialSnapshot, SpatialPoint, StateQuality } from '@ateam-ai-mobile/plugin-runtime';
4
- import { visualEngine } from '../visualEngine';
5
- import { startHeading, stopHeading, currentHeadingDetail, lastMeaningfulTurn } from '../headingSource';
6
- import type { DeviceStateCollectors, SpeedRef } from '../collectors';
2
+ // position ✓ path ✓ heading ✓ turns ✓ events ✓ (while a session is open) speed ~derived
3
+ import type {
4
+ DeviceState,
5
+ SpatialIntent,
6
+ SpatialSnapshot,
7
+ SpatialStateEvent,
8
+ SpatialPoint,
9
+ StateQuality,
10
+ Subscription,
11
+ } from '@ateam-ai-mobile/plugin-runtime';
12
+ import { startHeading, stopHeading, currentHeadingDetail, lastMeaningfulTurn, onTurn } from '../headingSource';
13
+ import type { DeviceStateCollectors, SpeedRef, WatchAccuracy } from '../collectors';
7
14
  import { qualityFrom, qualityUnknown, unsupported } from '../quality';
15
+ import { distanceOf } from '../../geo';
16
+ import { listenerSet } from '../events';
17
+ import {
18
+ SpatialEventDetector,
19
+ fixConfidence,
20
+ movementStateOf,
21
+ spatialEventProfile,
22
+ } from '../spatialEvents';
8
23
 
9
24
  /**
10
25
  * Position, movement, facing and a short recent path.
@@ -31,7 +46,23 @@ import { qualityFrom, qualityUnknown, unsupported } from '../quality';
31
46
  * (right) and negative for anticlockwise (left), so a guide can say which way
32
47
  * the user just went without a second field to consult. The freeze names the
33
48
  * field but states no convention; this is a decision, recorded here.
49
+ *
50
+ * EVENTS ARE THE SNAPSHOT, DIFFED. `subscribe` publishes what changed between
51
+ * successive warm snapshots — the same ones `getSnapshot` returns — via the
52
+ * pure detector in spatialEvents.ts. It is fed on every fix, the moment a turn
53
+ * completes, and once a second, so a transition that happens between fixes
54
+ * (fixes stopping is itself one: the state goes stale) is still noticed.
34
55
  */
56
+ /** The GPS tier each intent's stream runs at. Navigation is the one that needs
57
+ * "which side of the street"; everything else trades precision for battery. */
58
+ const WATCH_ACCURACY: Record<SpatialIntent, WatchAccuracy> = {
59
+ navigation: 'navigation',
60
+ preciseLocate: 'high',
61
+ fitness: 'high',
62
+ geofence: 'balanced',
63
+ lowPowerTracking: 'low',
64
+ };
65
+
35
66
  export function createSpatialDomain(
36
67
  collectors: DeviceStateCollectors,
37
68
  speedRef: SpeedRef,
@@ -39,14 +70,6 @@ export function createSpatialDomain(
39
70
  // The warm cache, when a session is open. Newest last, bounded — an unbounded
40
71
  // path on a walking user grows without limit.
41
72
  const RECENT_PATH_MAX = 60;
42
- /**
43
- * Below this the user is standing still.
44
- *
45
- * 0.4 m/s is well under a slow walk (~1.2 m/s) and above the jitter a
46
- * stationary phone's GPS produces. The point of the band is that a guide
47
- * must not say "keep going straight" to someone who has stopped.
48
- */
49
- const STATIONARY_MPS = 0.4;
50
73
 
51
74
  let recentPath: SpatialPoint[] = [];
52
75
  let latest: SpatialPoint | null = null;
@@ -54,7 +77,13 @@ export function createSpatialDomain(
54
77
  let latestSpeed: number | null = null;
55
78
  let latestCourse: number | null = null;
56
79
  let stopWatch: (() => void) | null = null;
57
- let sessionId: string | null = null;
80
+ /** The running session. A second `start()` joins it, and is told so. */
81
+ let session: { id: string; intent: SpatialIntent; startedAt: number } | null = null;
82
+
83
+ const detector = new SpatialEventDetector();
84
+ const listeners = listenerSet<SpatialStateEvent>();
85
+ let ticker: ReturnType<typeof setInterval> | null = null;
86
+ let turnSub: Subscription | null = null;
58
87
 
59
88
  const nonNegative = (v: unknown): number | null =>
60
89
  typeof v === 'number' && v >= 0 ? v : null;
@@ -87,95 +116,155 @@ export function createSpatialDomain(
87
116
  return bearingBetween(a, b);
88
117
  };
89
118
 
119
+ /**
120
+ * The snapshot for a fix — the ONE builder, used by the read and by the
121
+ * event detector alike. Synchronous: everything it needs is already in hand.
122
+ */
123
+ const snapshotFrom = (
124
+ point: SpatialPoint,
125
+ measuredSpeed: number | null,
126
+ measuredCourse: number | null,
127
+ ): SpatialSnapshot => {
128
+ // Fall back to the path only where the OS said nothing.
129
+ let speedMps = measuredSpeed;
130
+ let courseDeg = measuredCourse;
131
+ let derived = false;
132
+ if (speedMps === null) { const d = derivedSpeed(); if (d !== null) { speedMps = d; derived = true; } }
133
+ if (courseDeg === null) { const d = derivedCourse(); if (d !== null) { courseDeg = d; derived = true; } }
134
+
135
+ const heading = currentHeadingDetail();
136
+ const turn = lastMeaningfulTurn();
137
+
138
+ return {
139
+ position: point,
140
+ movement: {
141
+ speedMps,
142
+ bearingDeg: courseDeg,
143
+ // Needs two points from the SAME session; without a stream there is
144
+ // nothing to measure against, and a distance of 0 would read as
145
+ // "the user has not moved".
146
+ distanceRecentM: recentPath.length >= 2 ? distanceOf(recentPath) : null,
147
+ state: movementStateOf(speedMps),
148
+ },
149
+ orientation: {
150
+ headingDeg: heading?.headingDeg ?? null,
151
+ // NOT the calibration rank. iOS reports 0–3 (none…high), which is not
152
+ // a number of degrees, and converting one to the other would be an
153
+ // invented error bar. The rank drives `confidence` below instead.
154
+ headingAccuracyDeg: null,
155
+ },
156
+ changes: {
157
+ lastMeaningfulTurnDeg: turn ? turn.deg : null,
158
+ timestamp: turn ? turn.timestamp : null,
159
+ },
160
+ recentPath: [...recentPath],
161
+ quality: qualityFrom(
162
+ point.timestamp,
163
+ spatialConfidence(point.accuracyM ?? null, heading?.calibration ?? null, derived),
164
+ ),
165
+ };
166
+ };
167
+
168
+ /** "No position" — honest nulls, never a (0,0). The compass may still know
169
+ * which way the phone points, so heading is kept when it has one. */
170
+ const noFix = (): SpatialSnapshot => ({
171
+ position: null,
172
+ movement: { speedMps: null, bearingDeg: null, distanceRecentM: null, state: 'unknown' },
173
+ orientation: { headingDeg: currentHeadingDetail()?.headingDeg ?? null },
174
+ recentPath: [],
175
+ quality: qualityUnknown(),
176
+ });
177
+
178
+ /** The warm snapshot, or null before the session's first streamed fix. */
179
+ const warmSnapshot = (): SpatialSnapshot | null =>
180
+ latest ? snapshotFrom(latest, latestSpeed, latestCourse) : null;
181
+
182
+ /**
183
+ * Diff the snapshot a read would get RIGHT NOW against the last one, and
184
+ * publish what changed. Before the first fix there is nothing to diff; a
185
+ * turn completed meanwhile is not lost, it is reported with the first fix.
186
+ */
187
+ const observe = () => {
188
+ const snapshot = warmSnapshot();
189
+ if (!snapshot) return;
190
+ for (const event of detector.push(snapshot, Date.now())) listeners.emit(event);
191
+ };
192
+
193
+ /**
194
+ * Re-diff once a second while a session is open. Fixes alone are not enough:
195
+ * the OS may pause them while the phone is still, and "the state has gone
196
+ * stale" or "you have been stationary for five seconds" are exactly the
197
+ * transitions that complete while nothing arrives. A tick re-reads the same
198
+ * fix, so it only completes a state the fixes already showed — it never
199
+ * turns one outlier fix into an event (spatialEvents.ts, MIN_FIXES).
200
+ */
201
+ const startTicking = () => {
202
+ ticker = setInterval(observe, 1000);
203
+ };
204
+
205
+ const stopTicking = () => {
206
+ if (ticker) clearInterval(ticker);
207
+ ticker = null;
208
+ };
209
+
90
210
  return {
91
211
  async getSnapshot(): Promise<SpatialSnapshot> {
92
212
  // Serve the warm fix when a session is streaming; otherwise take one.
93
- let point = latest;
94
- let speedMps = latestSpeed;
95
- let courseDeg = latestCourse;
96
- let derived = false;
213
+ const warm = warmSnapshot();
214
+ if (warm) return warm;
215
+ // A session is open but its stream has not delivered yet: say so NOW.
216
+ // A one-shot fix here would make every read wait on a cold GPS (seconds),
217
+ // stalling whatever loop reads us — and the stream answers soon anyway.
218
+ if (session) return noFix();
97
219
 
98
- if (!point) {
99
- try {
100
- if (!collectors.location) throw unsupported('location collector');
101
- const fix: any = await collectors.location.getPosition({ accuracy: 'low' });
102
- point = {
103
- latitude: fix.latitude,
104
- longitude: fix.longitude,
105
- altitude: fix.altitude ?? null,
106
- accuracyM: nonNegative(fix.accuracy),
107
- timestamp: fix.timestamp ?? Date.now(),
108
- };
109
- speedMps = nonNegative(fix.speed);
110
- courseDeg = nonNegative(fix.course);
111
- } catch {
112
- // Permission refused, or no fix. Not a position at (0,0).
113
- return {
114
- position: null,
115
- movement: { speedMps: null, bearingDeg: null, distanceRecentM: null, state: 'unknown' },
116
- orientation: { headingDeg: null },
117
- recentPath: [],
118
- quality: qualityUnknown(),
119
- };
120
- }
220
+ let point: SpatialPoint;
221
+ let speedMps: number | null;
222
+ let courseDeg: number | null;
223
+ try {
224
+ if (!collectors.location) throw unsupported('location collector');
225
+ const fix: any = await collectors.location.getPosition({ accuracy: 'low' });
226
+ point = {
227
+ latitude: fix.latitude,
228
+ longitude: fix.longitude,
229
+ altitude: fix.altitude ?? null,
230
+ accuracyM: nonNegative(fix.accuracy),
231
+ timestamp: fix.timestamp ?? Date.now(),
232
+ };
233
+ speedMps = nonNegative(fix.speed);
234
+ courseDeg = nonNegative(fix.course);
235
+ } catch {
236
+ // Permission refused, or no fix. Not a position at (0,0).
237
+ return noFix();
121
238
  }
122
-
123
- // Fall back to the path only where the OS said nothing.
124
- if (speedMps === null) { const d = derivedSpeed(); if (d !== null) { speedMps = d; derived = true; } }
125
- if (courseDeg === null) { const d = derivedCourse(); if (d !== null) { courseDeg = d; derived = true; } }
126
-
127
- const heading = currentHeadingDetail();
128
- const turn = lastMeaningfulTurn();
129
-
130
- return {
131
- position: point,
132
- movement: {
133
- speedMps,
134
- bearingDeg: courseDeg,
135
- // Needs two points from the SAME session; without a stream there is
136
- // nothing to measure against, and a distance of 0 would read as
137
- // "the user has not moved".
138
- distanceRecentM: recentPath.length >= 2 ? distanceOf(recentPath) : null,
139
- // Only claim stationary/moving when a speed was actually obtained.
140
- // With no speed the truthful answer is `unknown`, not `stationary` —
141
- // "you have stopped" is a false statement, not a neutral default.
142
- state: speedMps === null ? 'unknown' : speedMps < STATIONARY_MPS ? 'stationary' : 'moving',
143
- },
144
- orientation: {
145
- headingDeg: heading?.headingDeg ?? null,
146
- // NOT the calibration rank. iOS reports 0–3 (none…high), which is not
147
- // a number of degrees, and converting one to the other would be an
148
- // invented error bar. The rank drives `confidence` below instead.
149
- headingAccuracyDeg: null,
150
- },
151
- changes: {
152
- lastMeaningfulTurnDeg: turn ? turn.deg : null,
153
- timestamp: turn ? turn.timestamp : null,
154
- },
155
- recentPath: [...recentPath],
156
- quality: qualityFrom(
157
- point.timestamp,
158
- spatialConfidence(point.accuracyM ?? null, heading?.calibration ?? null, derived),
159
- ),
160
- };
239
+ return snapshotFrom(point, speedMps, courseDeg);
161
240
  },
162
241
 
163
242
  /**
164
- * Open the warm window: position stream + compass.
243
+ * Open the warm window: position stream + compass + event feed.
165
244
  *
166
- * The intent is recorded but does NOT yet change sampling — no native
167
- * strategy layer exists — so it is accepted rather than refused only
168
- * because the streams it opens are real and useful.
245
+ * The intent selects how eager the EVENTS are (spatialEvents.ts profiles)
246
+ * and the GPS TIER the stream runs at (WATCH_ACCURACY): navigation pays the
247
+ * battery for BestForNavigation; low-power tracking does not.
248
+ *
249
+ * A second `start()` while one is open joins the running session and is
250
+ * told ITS intent, not handed back the one it asked for: answering with the
251
+ * caller's intent would claim a profile that is not in force.
169
252
  */
170
253
  async start(options) {
171
- if (stopWatch) return { id: sessionId!, intent: options.intent, startedAt: Date.now() } as any;
172
- sessionId = `spatial_${Date.now()}`;
254
+ if (session) return { ...session };
173
255
  if (!collectors.location) throw unsupported('location collector');
256
+ const startedAt = Date.now();
257
+ session = { id: `spatial_${startedAt}`, intent: options.intent, startedAt };
258
+ // A turn the compass completed before this session (a visual session
259
+ // shares it) is already on record, not news.
260
+ detector.reset(spatialEventProfile(options.intent), lastMeaningfulTurn()?.timestamp ?? null);
174
261
  // The compass is what makes heading and turn detection possible, and it
175
262
  // has to run for the session's whole life: a turn happens between reads,
176
263
  // so a compass started on demand would miss exactly the event a guide
177
- // most needs. Idempotent, and shared with a visual session if one is open.
178
- void startHeading();
264
+ // most needs. Shared with a visual session if one is open.
265
+ void startHeading('spatial');
266
+ // A turn is published the moment it completes, not on the next tick.
267
+ turnSub = onTurn(observe);
179
268
  stopWatch = collectors.location.watchPosition((pos: any) => {
180
269
  const p: SpatialPoint = {
181
270
  latitude: pos.latitude,
@@ -190,14 +279,22 @@ export function createSpatialDomain(
190
279
  speedRef.mps = latestSpeed;
191
280
  speedRef.at = p.timestamp;
192
281
  recentPath = [...recentPath, p].slice(-RECENT_PATH_MAX);
193
- });
194
- return { id: sessionId, intent: options.intent, startedAt: Date.now() } as any;
282
+ observe();
283
+ }, { accuracy: WATCH_ACCURACY[options.intent] ?? 'balanced' });
284
+ startTicking();
285
+ return { ...session };
195
286
  },
196
287
 
197
288
  async stop() {
198
289
  stopWatch?.();
199
290
  stopWatch = null;
200
- sessionId = null;
291
+ session = null;
292
+ stopTicking();
293
+ turnSub?.unsubscribe();
294
+ turnSub = null;
295
+ // Listeners are KEPT: a subscription outlives a session, and hears the
296
+ // next one. Only what this session observed is forgotten.
297
+ detector.reset();
201
298
  // The path belongs to the session that recorded it. Keeping it would let
202
299
  // a later snapshot report movement from a walk that ended hours ago.
203
300
  recentPath = [];
@@ -206,14 +303,18 @@ export function createSpatialDomain(
206
303
  latestCourse = null;
207
304
  speedRef.mps = null;
208
305
  speedRef.at = 0;
209
- // Leave the compass alone if a visual session is still using it — it is
210
- // shared, and stopping it here would silently blind the frame stamps.
211
- if (!visualEngine.getSession()) stopHeading();
306
+ // Releases only spatial's hold — a visual session keeps the compass.
307
+ stopHeading('spatial');
212
308
  },
213
309
 
214
- // No event vocabulary is frozen for spatial, and inventing names here would
215
- // put them in plugins before the freeze can contradict them.
216
- subscribe() { throw unsupported('spatial.subscribe'); },
310
+ /**
311
+ * The six spatial events (see `SpatialEvent`). Subscribing opens no sensor:
312
+ * events flow only while a session is open, and a subscription made before
313
+ * `start()` hears that session from its first fix.
314
+ */
315
+ subscribe(listener) {
316
+ return listeners.add(listener);
317
+ },
217
318
  };
218
319
  }
219
320
 
@@ -238,10 +339,8 @@ function spatialConfidence(
238
339
  const rank = (v: 'high' | 'medium' | 'low' | 'unknown') =>
239
340
  v === 'high' ? 3 : v === 'medium' ? 2 : v === 'low' ? 1 : 0;
240
341
 
241
- // Metres of horizontal error. ~10m is a good open-sky fix; beyond ~50m the
242
- // position is street-level at best and useless for "turn at the next corner".
243
- const fromFix: 'high' | 'medium' | 'low' | 'unknown' =
244
- accuracyM === null ? 'unknown' : accuracyM <= 10 ? 'high' : accuracyM <= 50 ? 'medium' : 'low';
342
+ // The fix band is shared with positionConfidenceChanged — one set of numbers.
343
+ const fromFix = fixConfidence(accuracyM);
245
344
 
246
345
  // iOS calibration rank: 3 high, 2 medium, 1 low, 0 none.
247
346
  const fromCompass: 'high' | 'medium' | 'low' | 'unknown' =
@@ -266,18 +365,3 @@ function bearingBetween(a: SpatialPoint, b: SpatialPoint): number {
266
365
  const x = Math.cos(la1) * Math.sin(la2) - Math.sin(la1) * Math.cos(la2) * Math.cos(dLon);
267
366
  return (((Math.atan2(y, x) * 180) / Math.PI) + 360) % 360;
268
367
  }
269
-
270
- /** Metres along a path, great-circle per leg. */
271
- function distanceOf(path: SpatialPoint[]): number {
272
- const R = 6_371_000;
273
- let total = 0;
274
- for (let i = 1; i < path.length; i++) {
275
- const a = path[i - 1], b = path[i];
276
- const dLat = ((b.latitude - a.latitude) * Math.PI) / 180;
277
- const dLon = ((b.longitude - a.longitude) * Math.PI) / 180;
278
- const la1 = (a.latitude * Math.PI) / 180, la2 = (b.latitude * Math.PI) / 180;
279
- const h = Math.sin(dLat / 2) ** 2 + Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2;
280
- total += 2 * R * Math.asin(Math.sqrt(h));
281
- }
282
- return Math.round(total);
283
- }
@@ -8,6 +8,24 @@ import type { DeviceState, VisualSnapshot } from '@ateam-ai-mobile/plugin-runtim
8
8
  import { visualEngine, frameQuality } from '../visualEngine';
9
9
  import { currentHeading, startHeading, stopHeading } from '../headingSource';
10
10
  import { unsupported } from '../quality';
11
+ import { stateEvent } from '../events';
12
+
13
+ /**
14
+ * The camera block of a visual snapshot — one builder, so the read and the
15
+ * change event cannot describe the camera two ways.
16
+ */
17
+ function cameraState(): VisualSnapshot['camera'] {
18
+ const session = visualEngine.getSession();
19
+ return {
20
+ // `available` = the app could use a camera at all. `active` = one is
21
+ // running RIGHT NOW. They differ exactly when it matters: a pocketed
22
+ // phone is available but not active, and answering `true` to both
23
+ // would let a guide speak about a scene nothing is watching.
24
+ available: !!session,
25
+ active: visualEngine.isCameraReady(),
26
+ facing: session ? 'back' : 'unknown',
27
+ };
28
+ }
11
29
 
12
30
  /**
13
31
  * Reads the rolling timeline the engine maintains. See visualEngine.ts for why
@@ -20,20 +38,11 @@ import { unsupported } from '../quality';
20
38
  export function createVisualDomain(): DeviceState['visual'] {
21
39
  return {
22
40
  async getSnapshot(): Promise<VisualSnapshot> {
23
- const session = visualEngine.getSession();
24
41
  const latest = visualEngine.latest();
25
42
  const timeline = visualEngine.timeline();
26
43
 
27
44
  return {
28
- camera: {
29
- // `available` = the app could use a camera at all. `active` = one is
30
- // running RIGHT NOW. They differ exactly when it matters: a pocketed
31
- // phone is available but not active, and answering `true` to both
32
- // would let a guide speak about a scene nothing is watching.
33
- available: !!session,
34
- active: visualEngine.isCameraReady(),
35
- facing: session ? 'back' : 'unknown',
36
- },
45
+ camera: cameraState(),
37
46
  // null, not a stale frame dressed as current. A caller with no usable
38
47
  // frame must see nothing rather than something old.
39
48
  latest: latest as any,
@@ -48,14 +57,15 @@ export function createVisualDomain(): DeviceState['visual'] {
48
57
  // launch: a magnetometer subscription running all day for a feature
49
58
  // nobody opened is exactly the kind of cost this layer is meant to avoid.
50
59
  visualEngine.setContextProvider({ heading: currentHeading });
51
- void startHeading();
60
+ void startHeading('visual');
52
61
  return { id: s.id, intent: s.intent, startedAt: s.startedAt } as any;
53
62
  },
54
63
 
55
64
  async stop() {
56
65
  visualEngine.stopSession();
57
66
  visualEngine.setContextProvider(null);
58
- stopHeading();
67
+ // Releases only visual's hold — a spatial session keeps the compass.
68
+ stopHeading('visual');
59
69
  },
60
70
 
61
71
  /**
@@ -90,9 +100,20 @@ export function createVisualDomain(): DeviceState['visual'] {
90
100
  throw err;
91
101
  },
92
102
 
103
+ /**
104
+ * Fires when the CAMERA block changes — a session opening or closing, the
105
+ * camera coming up or going down. The engine notifies on every frame too,
106
+ * and forwarding that made this a raw frame stream, which the freeze's core
107
+ * rule 4 forbids. A frame arriving is what `getSnapshot()` is for.
108
+ */
93
109
  subscribe(listener) {
110
+ let last = JSON.stringify(cameraState());
94
111
  const off = visualEngine.subscribe(() => {
95
- listener({ type: 'visualStateChanged' } as any);
112
+ const camera = cameraState();
113
+ const key = JSON.stringify(camera);
114
+ if (key === last) return;
115
+ last = key;
116
+ listener(stateEvent('visual', 'visualStateChanged', { camera }));
96
117
  });
97
118
  return { unsubscribe: off };
98
119
  },
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The ONE event envelope every domain emits, and the one way listeners are held.
3
+ *
4
+ * The frozen shape is `StateEvent { domain, name, timestamp, payload }`. Three
5
+ * domains were born emitting `{ type: '…' }` instead, so a plugin written
6
+ * against the contract — `e.name === 'appStateChanged'` — never matched, and
7
+ * `e.name.endsWith(…)` threw on `undefined`. Building every event here is what
8
+ * keeps a second shape from growing back.
9
+ *
10
+ * Type-only imports, so the pure modules that use this (the spatial event
11
+ * detector) stay testable under plain `node --test`.
12
+ */
13
+ import type { DeviceBridgeDomain, StateEvent, Subscription } from '@ateam-ai-mobile/plugin-runtime';
14
+
15
+ /**
16
+ * Build an event. `payload` is omitted, not `undefined`, when there is none —
17
+ * an event that is only a nudge ("re-read the snapshot") says so by shape.
18
+ * `timestamp` is when the change was recognised; it defaults to now, and replay
19
+ * passes its virtual clock.
20
+ */
21
+ export function stateEvent<TDomain extends DeviceBridgeDomain, TName extends string, TPayload = never>(
22
+ domain: TDomain,
23
+ name: TName,
24
+ payload?: TPayload,
25
+ timestamp: number = Date.now(),
26
+ ): StateEvent<TName, TPayload> & { domain: TDomain } {
27
+ return payload === undefined
28
+ ? { domain, name, timestamp }
29
+ : { domain, name, timestamp, payload };
30
+ }
31
+
32
+ /**
33
+ * Listeners, each isolated from the others. A listener that throws must not
34
+ * stop the next one hearing the event, nor the engine that emitted it.
35
+ */
36
+ export function listenerSet<E>() {
37
+ const listeners = new Set<(event: E) => void>();
38
+ return {
39
+ add(fn: (event: E) => void): Subscription {
40
+ listeners.add(fn);
41
+ return { unsubscribe: () => { listeners.delete(fn); } };
42
+ },
43
+ emit(event: E): void {
44
+ for (const fn of listeners) {
45
+ try { fn(event); } catch { /* a bad listener is not the engine's problem */ }
46
+ }
47
+ },
48
+ };
49
+ }
@@ -13,15 +13,25 @@
13
13
  * event, not a state — so a detector driven by sampled snapshots would miss
14
14
  * exactly the moment a guide needs. It runs on the compass callback instead.
15
15
  * The logic itself lives in `turnDetector.ts`, free of any Expo import, so it
16
- * can be tested against a scripted walk.
16
+ * can be tested against a scripted walk. A completed turn is also PUSHED to
17
+ * `onTurn` listeners — spatial publishes it as an event the moment it lands.
18
+ *
19
+ * SHARED, SO COUNTED. Spatial and visual both keep the compass warm, and each
20
+ * used to stop it on its own `stop()`: visual's stop killed the compass — and
21
+ * wiped the turn history — under a live spatial session. Each caller now names
22
+ * itself, and the watch stops only when the last one leaves.
17
23
  */
18
24
  import * as ExpoLocation from 'expo-location';
19
25
 
20
26
  import { TurnDetector, type TurnEvent } from './turnDetector';
27
+ import { listenerSet } from './events';
21
28
 
22
29
  export type { TurnEvent };
23
30
  export { signedDelta } from './turnDetector';
24
31
 
32
+ /** Who is holding the compass open. */
33
+ export type HeadingOwner = 'spatial' | 'visual';
34
+
25
35
  interface HeadingReading {
26
36
  headingDeg: number | null;
27
37
  /**
@@ -36,21 +46,38 @@ interface HeadingReading {
36
46
 
37
47
  let latest: HeadingReading | null = null;
38
48
  let sub: { remove(): void } | null = null;
49
+ /** The watch being opened, so two owners starting at once open ONE. */
50
+ let opening: Promise<void> | null = null;
51
+ const owners = new Set<HeadingOwner>();
39
52
  const turns = new TurnDetector();
53
+ const turnListeners = listenerSet<TurnEvent>();
54
+
55
+ /** Start watching on behalf of `owner`. Idempotent per owner. */
56
+ export function startHeading(owner: HeadingOwner): Promise<void> {
57
+ owners.add(owner);
58
+ if (sub) return Promise.resolve();
59
+ opening ??= open().finally(() => { opening = null; });
60
+ return opening;
61
+ }
40
62
 
41
- /** Start watching. Idempotent; safe to call on every session start. */
42
- export async function startHeading(): Promise<void> {
43
- if (sub) return;
63
+ async function open(): Promise<void> {
44
64
  try {
45
- sub = await ExpoLocation.watchHeadingAsync(h => {
65
+ const s = await ExpoLocation.watchHeadingAsync(h => {
66
+ // A reading landing after the last owner left is not kept.
67
+ if (!owners.size) return;
46
68
  // trueHeading is -1 until the compass calibrates; magHeading is the
47
69
  // fallback the OS itself recommends.
48
70
  const raw = h.trueHeading >= 0 ? h.trueHeading : h.magHeading;
49
71
  const deg = typeof raw === 'number' && raw >= 0 ? raw : null;
50
72
  const cal = typeof h.accuracy === 'number' && h.accuracy >= 0 ? h.accuracy : null;
51
- latest = { headingDeg: deg, calibration: cal, timestamp: Date.now() };
52
- turns.push(deg, Date.now());
73
+ const now = Date.now();
74
+ latest = { headingDeg: deg, calibration: cal, timestamp: now };
75
+ const turn = turns.push(deg, now);
76
+ if (turn) turnListeners.emit(turn);
53
77
  });
78
+ // Every owner may have left while the OS was starting the watch.
79
+ if (!owners.size) { s.remove(); return; }
80
+ sub = s;
54
81
  } catch {
55
82
  // No compass, or permission refused. Callers get headingDeg: null, which
56
83
  // is the honest answer and does not stop anything else working.
@@ -58,7 +85,10 @@ export async function startHeading(): Promise<void> {
58
85
  }
59
86
  }
60
87
 
61
- export function stopHeading(): void {
88
+ /** Release `owner`'s hold. The watch stops only when no owner is left. */
89
+ export function stopHeading(owner: HeadingOwner): void {
90
+ owners.delete(owner);
91
+ if (owners.size) return;
62
92
  sub?.remove();
63
93
  sub = null;
64
94
  // Clear everything: a heading from a finished session describes a direction
@@ -68,6 +98,11 @@ export function stopHeading(): void {
68
98
  turns.reset();
69
99
  }
70
100
 
101
+ /** Hear each completed turn as it completes. Signed: positive = right. */
102
+ export function onTurn(fn: (turn: TurnEvent) => void) {
103
+ return turnListeners.add(fn);
104
+ }
105
+
71
106
  /** The newest reading, or null if the compass has said nothing yet. */
72
107
  export function currentHeading(): { headingDeg: number | null } | null {
73
108
  return latest ? { headingDeg: latest.headingDeg } : null;
@@ -14,9 +14,10 @@
14
14
  * connectivity reachability ✓ transport ✓ quality/stability/metered ✗
15
15
  * capability permission ✓ availability ✗ restriction ✗
16
16
  * interaction app lifecycle ✓ screenOn ✗ audio ✗
17
- * spatial position ✓ path ✓ (while a session is open) heading/speed ✗
17
+ * spatial position ✓ heading ✓ turns ✓ path/events ✓ (while a session is open) speed ~derived
18
18
  * visual frames ✓ heading ✓ freshness ✓ blur/exposure ✗
19
- * activity / environment — NOT IMPLEMENTED AT ALL
19
+ * activity walking/running/stationary ✓ pose ✓ cycling/driving ✗ events ✗
20
+ * environment NOT IMPLEMENTED AT ALL
20
21
  *
21
22
  * Everything marked ✗ reports `unknown`, which is a real answer in this design
22
23
  * and the only honest one. It never reports a plausible default: an invented