@ateam-ai-mobile/expo 0.1.4 → 0.1.7

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 (158) hide show
  1. package/android/build.gradle +16 -0
  2. package/android/src/main/java/expo/modules/adavision/AdaVisionModule.kt +75 -0
  3. package/dist/attachmentCache-DhQTqi2E.d.mts +18 -0
  4. package/dist/attachmentCache-DhQTqi2E.d.ts +18 -0
  5. package/dist/bootstrap.d.mts +95 -0
  6. package/dist/bootstrap.d.ts +95 -0
  7. package/dist/bootstrap.js +2473 -0
  8. package/dist/bootstrap.js.map +1 -0
  9. package/dist/bootstrap.mjs +64 -0
  10. package/dist/bootstrap.mjs.map +1 -0
  11. package/dist/chunk-4BMWVN25.mjs +91 -0
  12. package/dist/chunk-4BMWVN25.mjs.map +1 -0
  13. package/dist/chunk-4XYXJYN5.mjs +1513 -0
  14. package/dist/chunk-4XYXJYN5.mjs.map +1 -0
  15. package/dist/chunk-5HM6UA2K.mjs +571 -0
  16. package/dist/chunk-5HM6UA2K.mjs.map +1 -0
  17. package/dist/chunk-6LXF3JVX.mjs +96 -0
  18. package/dist/chunk-6LXF3JVX.mjs.map +1 -0
  19. package/dist/chunk-FF6ZZZXH.mjs +71 -0
  20. package/dist/chunk-FF6ZZZXH.mjs.map +1 -0
  21. package/dist/chunk-KOXF2FZM.mjs +92 -0
  22. package/dist/chunk-KOXF2FZM.mjs.map +1 -0
  23. package/dist/chunk-L4V7OCJ3.mjs +71 -0
  24. package/dist/chunk-L4V7OCJ3.mjs.map +1 -0
  25. package/dist/chunk-TZXWVYYR.mjs +67 -0
  26. package/dist/chunk-TZXWVYYR.mjs.map +1 -0
  27. package/dist/chunk-VQJPSLHP.mjs +1801 -0
  28. package/dist/chunk-VQJPSLHP.mjs.map +1 -0
  29. package/dist/chunk-WMJKH4XE.mjs +34 -0
  30. package/dist/chunk-WMJKH4XE.mjs.map +1 -0
  31. package/dist/chunk-XQSY6MHP.mjs +213 -0
  32. package/dist/chunk-XQSY6MHP.mjs.map +1 -0
  33. package/dist/createBridge.mjs +2 -0
  34. package/dist/createBridge.mjs.map +1 -1
  35. package/dist/device/createDeviceProvider.d.mts +23 -0
  36. package/dist/device/createDeviceProvider.d.ts +23 -0
  37. package/dist/device/createDeviceProvider.js +967 -0
  38. package/dist/device/createDeviceProvider.js.map +1 -0
  39. package/dist/device/createDeviceProvider.mjs +17 -0
  40. package/dist/device/createDeviceProvider.mjs.map +1 -0
  41. package/dist/device-bridge/collectors/appEngagement.d.mts +10 -0
  42. package/dist/device-bridge/collectors/appEngagement.d.ts +10 -0
  43. package/dist/device-bridge/collectors/appEngagement.js +128 -0
  44. package/dist/device-bridge/collectors/appEngagement.js.map +1 -0
  45. package/dist/device-bridge/collectors/appEngagement.mjs +12 -0
  46. package/dist/device-bridge/collectors/appEngagement.mjs.map +1 -0
  47. package/dist/device-bridge/collectors/health.d.mts +24 -0
  48. package/dist/device-bridge/collectors/health.d.ts +24 -0
  49. package/dist/device-bridge/collectors/health.js +238 -0
  50. package/dist/device-bridge/collectors/health.js.map +1 -0
  51. package/dist/device-bridge/collectors/health.mjs +18 -0
  52. package/dist/device-bridge/collectors/health.mjs.map +1 -0
  53. package/dist/device-bridge/cookies.d.mts +49 -0
  54. package/dist/device-bridge/cookies.d.ts +49 -0
  55. package/dist/device-bridge/cookies.js +90 -0
  56. package/dist/device-bridge/cookies.js.map +1 -0
  57. package/dist/device-bridge/cookies.mjs +14 -0
  58. package/dist/device-bridge/cookies.mjs.map +1 -0
  59. package/dist/device-bridge/index.d.mts +483 -0
  60. package/dist/device-bridge/index.d.ts +483 -0
  61. package/dist/device-bridge/index.js +2084 -0
  62. package/dist/device-bridge/index.js.map +1 -0
  63. package/dist/device-bridge/index.mjs +32 -0
  64. package/dist/device-bridge/index.mjs.map +1 -0
  65. package/dist/device-bridge/nfc.d.mts +30 -0
  66. package/dist/device-bridge/nfc.d.ts +30 -0
  67. package/dist/device-bridge/nfc.js +109 -0
  68. package/dist/device-bridge/nfc.js.map +1 -0
  69. package/dist/device-bridge/nfc.mjs +14 -0
  70. package/dist/device-bridge/nfc.mjs.map +1 -0
  71. package/dist/index.d.mts +493 -18
  72. package/dist/index.d.ts +493 -18
  73. package/dist/index.js +1409 -6
  74. package/dist/index.js.map +1 -1
  75. package/dist/index.mjs +23 -194
  76. package/dist/index.mjs.map +1 -1
  77. package/dist/oauth/expoOAuth.d.mts +18 -0
  78. package/dist/oauth/expoOAuth.d.ts +18 -0
  79. package/dist/oauth/expoOAuth.js +102 -0
  80. package/dist/oauth/expoOAuth.js.map +1 -0
  81. package/dist/oauth/expoOAuth.mjs +8 -0
  82. package/dist/oauth/expoOAuth.mjs.map +1 -0
  83. package/dist/types-BRanJYTn.d.mts +138 -0
  84. package/dist/types-BRanJYTn.d.ts +138 -0
  85. package/dist/vision/index.d.mts +81 -0
  86. package/dist/vision/index.d.ts +81 -0
  87. package/dist/vision/index.js +101 -0
  88. package/dist/vision/index.js.map +1 -0
  89. package/dist/vision/index.mjs +28 -0
  90. package/dist/vision/index.mjs.map +1 -0
  91. package/expo-module.config.json +9 -0
  92. package/ios/AdaVision.podspec +28 -0
  93. package/ios/AdaVisionModule.swift +432 -0
  94. package/ios/Resources/yolov8n-oiv7.mlmodelc/analytics/coremldata.bin +0 -0
  95. package/ios/Resources/yolov8n-oiv7.mlmodelc/coremldata.bin +0 -0
  96. package/ios/Resources/yolov8n-oiv7.mlmodelc/metadata.json +773 -0
  97. package/ios/Resources/yolov8n-oiv7.mlmodelc/model0/analytics/coremldata.bin +0 -0
  98. package/ios/Resources/yolov8n-oiv7.mlmodelc/model0/coremldata.bin +0 -0
  99. package/ios/Resources/yolov8n-oiv7.mlmodelc/model0/model.mil +720 -0
  100. package/ios/Resources/yolov8n-oiv7.mlmodelc/model0/weights/weight.bin +0 -0
  101. package/ios/Resources/yolov8n-oiv7.mlmodelc/model1/coremldata.bin +0 -0
  102. package/package.json +107 -7
  103. package/src/bootstrap.ts +159 -0
  104. package/src/camera/CameraTaskHost.tsx +175 -0
  105. package/src/camera/cameraTasks.ts +129 -0
  106. package/src/device/createDeviceProvider.ts +832 -0
  107. package/src/device-bridge/actions/calendar-actions.ts +217 -0
  108. package/src/device-bridge/actions/dnd.ts +74 -0
  109. package/src/device-bridge/actions/health-actions.ts +58 -0
  110. package/src/device-bridge/actions/index.ts +12 -0
  111. package/src/device-bridge/actions/messaging.ts +74 -0
  112. package/src/device-bridge/actions/navigation.ts +88 -0
  113. package/src/device-bridge/actions/queue-poller.ts +151 -0
  114. package/src/device-bridge/background.ts +137 -0
  115. package/src/device-bridge/biometrics.ts +64 -0
  116. package/src/device-bridge/capabilities.ts +218 -0
  117. package/src/device-bridge/collectors/appEngagement.ts +120 -0
  118. package/src/device-bridge/collectors/battery.ts +83 -0
  119. package/src/device-bridge/collectors/calendar.ts +162 -0
  120. package/src/device-bridge/collectors/camera.ts +27 -0
  121. package/src/device-bridge/collectors/connectivity.ts +60 -0
  122. package/src/device-bridge/collectors/contacts.ts +200 -0
  123. package/src/device-bridge/collectors/health.ts +299 -0
  124. package/src/device-bridge/collectors/index.ts +12 -0
  125. package/src/device-bridge/collectors/location.ts +224 -0
  126. package/src/device-bridge/collectors/mood.ts +141 -0
  127. package/src/device-bridge/collectors/notifications.ts +70 -0
  128. package/src/device-bridge/cookies.ts +89 -0
  129. package/src/device-bridge/dynamic-connector.ts +422 -0
  130. package/src/device-bridge/index.ts +467 -0
  131. package/src/device-bridge/nfc.ts +85 -0
  132. package/src/device-bridge/plugin.ts +71 -0
  133. package/src/device-bridge/relay-client.ts +237 -0
  134. package/src/device-bridge/scanner.ts +42 -0
  135. package/src/device-bridge/sharing.ts +35 -0
  136. package/src/device-bridge/types.ts +326 -0
  137. package/src/device-state/activityClassifier.ts +151 -0
  138. package/src/device-state/activityEngine.ts +252 -0
  139. package/src/device-state/collectors.ts +31 -0
  140. package/src/device-state/domains/activity.ts +93 -0
  141. package/src/device-state/domains/capability.ts +60 -0
  142. package/src/device-state/domains/connectivity.ts +60 -0
  143. package/src/device-state/domains/deviceContext.ts +55 -0
  144. package/src/device-state/domains/interaction.ts +59 -0
  145. package/src/device-state/domains/spatial.ts +283 -0
  146. package/src/device-state/domains/visual.ts +100 -0
  147. package/src/device-state/headingSource.ts +84 -0
  148. package/src/device-state/index.ts +77 -0
  149. package/src/device-state/quality.ts +60 -0
  150. package/src/device-state/replay.ts +220 -0
  151. package/src/device-state/turnDetector.ts +121 -0
  152. package/src/device-state/visualEngine.ts +341 -0
  153. package/src/haptics.ts +4 -0
  154. package/src/index.ts +37 -0
  155. package/src/oauth/expoOAuth.tsx +113 -0
  156. package/src/vision/analysis.js +80 -0
  157. package/src/vision/index.ts +144 -0
  158. package/src/vision/ring.js +35 -0
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The honesty helpers — one place, shared by every domain module.
3
+ *
4
+ * A snapshot's `quality` says how much to trust it. R5 centralizes the three
5
+ * ways a domain answers honestly here so no domain re-implements them:
6
+ * - qualityFrom(observedAt) a live read, freshness stamped from its age.
7
+ * - qualityUnknown() "nothing was observed" — never dressed as fresh.
8
+ * - unsupported(what) an ACTION this build can't honour, refused by name.
9
+ *
10
+ * The `unknown` freshness shape is NOT re-declared here: it is the single
11
+ * canonical `neverObserved()` from the contract package, so the engine, the gate
12
+ * (deviceStateHost) and the types can never drift into three subtly different
13
+ * "unknown"s.
14
+ */
15
+ import { neverObserved } from '@ateam-ai-mobile/plugin-runtime';
16
+ import type { StateQuality } from '@ateam-ai-mobile/plugin-runtime';
17
+
18
+ // ── Freshness ────────────────────────────────────────────────────────────
19
+ //
20
+ // Every snapshot carries when it was observed. `observedAt` is stamped at read
21
+ // time by a live-reading domain; a caching domain passes its cache timestamp
22
+ // instead. This is the one place the fresh/aging/stale thresholds live.
23
+
24
+ export const FRESH_MS = 10_000;
25
+ export const AGING_MS = 60_000;
26
+
27
+ export function qualityFrom(
28
+ observedAt: number,
29
+ confidence?: StateQuality['confidence'],
30
+ ): StateQuality {
31
+ const ageMs = Date.now() - observedAt;
32
+ return {
33
+ freshness: {
34
+ state: ageMs < FRESH_MS ? 'fresh' : ageMs < AGING_MS ? 'aging' : 'stale',
35
+ observedAt,
36
+ ageMs,
37
+ },
38
+ ...(confidence ? { confidence } : {}),
39
+ };
40
+ }
41
+
42
+ /** Age genuinely unknown — treated as stale, never as fresh. */
43
+ export function qualityUnknown(): StateQuality {
44
+ return { freshness: neverObserved() };
45
+ }
46
+
47
+ /**
48
+ * Refuse, by name, with the frozen error code.
49
+ *
50
+ * Used where the contract declares a method this build cannot honour. The
51
+ * alternative — a no-op that resolves — is the fabrication pattern in method
52
+ * form: the caller cannot tell "did nothing" from "worked".
53
+ */
54
+ export function unsupported(what: string): Error {
55
+ const err: any = new Error(
56
+ `${what} is not implemented in this build — the native engine behind it does not exist yet.`,
57
+ );
58
+ err.code = 'UNSUPPORTED';
59
+ return err;
60
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Replay — run guidance logic against a RECORDED WALK instead of a phone.
3
+ *
4
+ * WHY THIS EXISTS. Everything device-shaped is verifiable only on real
5
+ * hardware: the simulator has no camera, no pedometer, no moving GPS. So the
6
+ * loop was write → ask for a build → wait → ask someone to walk around the
7
+ * block. We could unit-test that the window clamp picks the smaller number; we
8
+ * could not test that THE GUIDE SAYS "TURN RIGHT" AT THE CORNER, which is the
9
+ * only thing a user cares about.
10
+ *
11
+ * A fixture is one real walk, recorded once by the app (see
12
+ * docs/SPEC_device_walk_recorder.md) and replayed here forever.
13
+ *
14
+ * IT REPLAYS AT THE SNAPSHOT LEVEL, not the sensor level, and that is a
15
+ * deliberate boundary. The engines already have their own tests — the turn
16
+ * detector, the activity classifier, the window clamp. What had no test was
17
+ * everything ABOVE them. So this implements the same `DeviceState` surface a
18
+ * plugin sees and serves recorded snapshots through it: the code under test is
19
+ * the guidance logic, unchanged, unaware it is not on a phone.
20
+ *
21
+ * THE CLOCK IS VIRTUAL. A ten-minute walk runs in milliseconds, and a test
22
+ * never waits. `t` in a fixture is milliseconds since the recording started,
23
+ * never a wall clock, so a fixture does not rot when the date changes.
24
+ *
25
+ * WHAT IT REFUSES TO SMOOTH OVER: a fixture's `unknown`s, `null`s and refusals
26
+ * are replayed exactly as recorded. They are the most valuable records in the
27
+ * file — they are what a guide must handle correctly, and precisely what a
28
+ * hand-written fixture would never contain.
29
+ */
30
+ import type { DeviceState } from '@ateam-ai-mobile/plugin-runtime';
31
+
32
+ /** One tick of a recorded walk. Domains are stored verbatim, never normalised. */
33
+ export interface WalkSample {
34
+ /** Milliseconds since recording started. NOT a wall clock. */
35
+ t: number;
36
+ spatial?: any;
37
+ activity?: any;
38
+ visual?: any;
39
+ deviceContext?: any;
40
+ interaction?: any;
41
+ connectivity?: any;
42
+ capability?: any;
43
+ environment?: any;
44
+ }
45
+
46
+ /**
47
+ * A domain whose `start()` FAILED during recording.
48
+ *
49
+ * Recorded so a replayed walk fails the same way the real one did. Without it,
50
+ * a fixture where the pedometer was denied is indistinguishable from a fixture
51
+ * where activity simply never appeared — and only one of those is a bug.
52
+ */
53
+ export interface WalkRefusal {
54
+ domain: string;
55
+ /** The error code the real call threw, e.g. RESTRICTED / UNSUPPORTED. */
56
+ code: string;
57
+ message?: string;
58
+ }
59
+
60
+ export interface WalkFixture {
61
+ /** Provenance. Without `sdkCommit`, a fixture that stops matching cannot be
62
+ * told apart from a regression. */
63
+ header: {
64
+ recordedAt?: string;
65
+ appVersion?: string;
66
+ sdkCommit?: string;
67
+ device?: string;
68
+ os?: string;
69
+ /** What the visual engine reported it was really holding. */
70
+ retentionMs?: number | null;
71
+ withFrames?: boolean;
72
+ note?: string;
73
+ };
74
+ refusals?: WalkRefusal[];
75
+ /** Ascending by `t`. */
76
+ samples: WalkSample[];
77
+ }
78
+
79
+ /** A clock a test drives by hand. Nothing here ever waits. */
80
+ export interface ReplayClock {
81
+ /** Current position in the walk, ms since start. */
82
+ now(): number;
83
+ /** Jump forward. Returns the new position. */
84
+ advance(ms: number): number;
85
+ /** Jump to an absolute offset. */
86
+ seek(ms: number): number;
87
+ /** Run to the end of the fixture. */
88
+ end(): number;
89
+ /** Total length of the recorded walk. */
90
+ durationMs(): number;
91
+ }
92
+
93
+ export interface ReplaySession {
94
+ deviceState: DeviceState;
95
+ clock: ReplayClock;
96
+ /** Which domains had a session opened by the code under test. */
97
+ started(): string[];
98
+ }
99
+
100
+ const DOMAINS = [
101
+ 'spatial', 'visual', 'activity', 'deviceContext',
102
+ 'connectivity', 'capability', 'environment', 'interaction',
103
+ ] as const;
104
+
105
+ /** The snapshot a domain returns when the fixture has nothing for it. */
106
+ function unknownSnapshot(domain: string): any {
107
+ const quality = { freshness: { state: 'unknown', observedAt: null, ageMs: null }, confidence: { level: 'unknown' } };
108
+ switch (domain) {
109
+ case 'spatial':
110
+ return { ...{ quality }, position: null, movement: { speedMps: null, bearingDeg: null, distanceRecentM: null, state: 'unknown' }, orientation: { headingDeg: null }, recentPath: [] };
111
+ case 'visual':
112
+ return { quality, camera: { available: false, active: false, facing: 'unknown' }, latest: null, recentTimeline: { windowMs: 0, frames: [] } };
113
+ case 'activity':
114
+ return { quality, human: { state: 'unknown', since: null, confidence: { level: 'unknown' } }, devicePose: { placement: 'unknown', orientation: 'unknown', confidence: { level: 'unknown' } }, recentTransitions: [] };
115
+ default:
116
+ return { quality };
117
+ }
118
+ }
119
+
120
+ function refusal(domain: string, code: string, message?: string): Error {
121
+ const err: any = new Error(message || `deviceState.${domain}: refused during recording (${code}).`);
122
+ err.code = code;
123
+ err.domain = domain;
124
+ return err;
125
+ }
126
+
127
+ /**
128
+ * Build a `DeviceState` backed by a recorded walk.
129
+ *
130
+ * Hand the result to the code under test exactly as the host would hand it the
131
+ * real one — it cannot tell the difference, which is the point.
132
+ */
133
+ export function createReplayDeviceState(fixture: WalkFixture): ReplaySession {
134
+ const samples = [...(fixture.samples || [])].sort((a, b) => a.t - b.t);
135
+ const duration = samples.length ? samples[samples.length - 1].t : 0;
136
+ const refusals = new Map((fixture.refusals || []).map(r => [r.domain, r]));
137
+ const openSessions = new Set<string>();
138
+
139
+ let cursor = 0;
140
+
141
+ const clock: ReplayClock = {
142
+ now: () => cursor,
143
+ advance: ms => (cursor = Math.min(duration, Math.max(0, cursor + ms))),
144
+ seek: ms => (cursor = Math.min(duration, Math.max(0, ms))),
145
+ end: () => (cursor = duration),
146
+ durationMs: () => duration,
147
+ };
148
+
149
+ /**
150
+ * The newest sample AT OR BEFORE the cursor.
151
+ *
152
+ * Never the nearest, and never interpolated. A guide reads what the device
153
+ * had ALREADY produced at that instant; letting a test see a value from
154
+ * slightly in the future would make it pass on information the real code
155
+ * could not have had.
156
+ */
157
+ const sampleAt = (): WalkSample | null => {
158
+ let found: WalkSample | null = null;
159
+ for (const s of samples) {
160
+ if (s.t > cursor) break;
161
+ found = s;
162
+ }
163
+ return found;
164
+ };
165
+
166
+ const domainFor = (name: string): any => ({
167
+ async getSnapshot() {
168
+ const s = sampleAt();
169
+ const recorded = s ? (s as any)[name] : undefined;
170
+ // Nothing recorded is an honest unknown, not an empty object — the same
171
+ // answer the real gate gives for a domain it cannot serve.
172
+ return recorded ?? unknownSnapshot(name);
173
+ },
174
+ async start() {
175
+ const r = refusals.get(name);
176
+ // Replay the refusal the real walk hit. A fixture recorded with the
177
+ // pedometer denied must fail here, or a test proves the guide works in
178
+ // conditions the user never had.
179
+ if (r) throw refusal(name, r.code, r.message);
180
+ openSessions.add(name);
181
+ return { id: `replay_${name}`, intent: 'replay', startedAt: 0 } as any;
182
+ },
183
+ async stop() { openSessions.delete(name); },
184
+ subscribe() {
185
+ // Deliberately unsupported: a fixture is a timeline a test steps through,
186
+ // so events would have to fire on `advance()`, and a subscription that
187
+ // silently never fires reads as "nothing has changed" — the failure this
188
+ // whole layer exists to prevent. Step the clock and read instead.
189
+ throw refusal(name, 'UNSUPPORTED', `deviceState.${name}.subscribe is not replayable — advance the clock and read.`);
190
+ },
191
+ });
192
+
193
+ const deviceState: any = {};
194
+ for (const d of DOMAINS) deviceState[d] = domainFor(d);
195
+
196
+ return { deviceState: deviceState as DeviceState, clock, started: () => [...openSessions] };
197
+ }
198
+
199
+ /** Parse a `.jsonl` recording into a fixture. First line is the header. */
200
+ export function parseWalkJsonl(text: string): WalkFixture {
201
+ const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
202
+ if (!lines.length) throw new Error('Empty recording — no header line.');
203
+
204
+ const first = JSON.parse(lines[0]);
205
+ const header = first.header ?? first;
206
+ const refusals: WalkRefusal[] = first.refusals ?? [];
207
+ const samples: WalkSample[] = [];
208
+
209
+ for (const line of lines.slice(1)) {
210
+ const rec = JSON.parse(line);
211
+ // Refusals may arrive as their own records mid-file (a session that failed
212
+ // partway through), not only in the header.
213
+ if (rec.refusal) { refusals.push(rec.refusal); continue; }
214
+ if (typeof rec.t === 'number') samples.push(rec);
215
+ }
216
+
217
+ // A truncated file is still usable — a walk that ended on a flat battery
218
+ // should not cost us the part that was recorded.
219
+ return { header, refusals, samples };
220
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Turning, and which way.
3
+ *
4
+ * "Turn right at the corner" and "you have just turned left" are the two
5
+ * sentences a walking guide says most, and neither can be read off a single
6
+ * heading — they need heading OVER TIME. This is that, as a pure state machine
7
+ * with no Expo import, so it can be tested against a scripted walk instead of
8
+ * against a phone in someone's hand.
9
+ *
10
+ * SIGN IS THE WHOLE POINT. Compass degrees increase clockwise, so a positive
11
+ * delta is a turn to the RIGHT and a negative one to the LEFT. A caller that
12
+ * wants only the magnitude can take the absolute value; a caller handed a
13
+ * magnitude cannot recover the side, and "turn 90°" without the side is worse
14
+ * than saying nothing.
15
+ *
16
+ * THE HARD PART IS NOT DETECTING TURNS, IT IS NOT INVENTING THEM. A phone held
17
+ * by a walking person swings 15–20° with every stride, and a compass drifts.
18
+ * Compare against a fixed origin and the drift eventually crosses any threshold
19
+ * on its own, so the machine re-baselines whenever the heading holds steady:
20
+ * "straight ahead" is wherever you have been pointing for the last couple of
21
+ * seconds, not wherever you were pointing when the session opened.
22
+ */
23
+
24
+ /** A completed turn. `deg` is signed: **positive = clockwise = right**. */
25
+ export interface TurnEvent {
26
+ deg: number;
27
+ /** When the turn was recognised as complete. */
28
+ timestamp: number;
29
+ }
30
+
31
+ export interface TurnDetectorOptions {
32
+ /**
33
+ * How far the heading must swing before it counts as a turn.
34
+ *
35
+ * 35° is a judgement, not a measurement: a street corner is ~90°, a gentle
36
+ * bend in a corridor 30–45°, and hand-sway while walking routinely 15–20°.
37
+ * Lower and the detector narrates the user's arm.
38
+ */
39
+ minTurnDeg?: number;
40
+ /** Within this of the reference counts as "still pointing the same way". */
41
+ stableDeg?: number;
42
+ /** How long it must stay there before the reference moves. */
43
+ stableMs?: number;
44
+ }
45
+
46
+ /** Signed shortest arc from `a` to `b`, in (-180, 180]. */
47
+ export function signedDelta(a: number, b: number): number {
48
+ return ((b - a + 540) % 360) - 180;
49
+ }
50
+
51
+ export class TurnDetector {
52
+ private readonly minTurnDeg: number;
53
+ private readonly stableDeg: number;
54
+ private readonly stableMs: number;
55
+
56
+ /** The heading currently treated as "straight ahead". */
57
+ private refDeg: number | null = null;
58
+ private stableSince = 0;
59
+ private lastTurn: TurnEvent | null = null;
60
+
61
+ constructor(opts: TurnDetectorOptions = {}) {
62
+ this.minTurnDeg = opts.minTurnDeg ?? 35;
63
+ this.stableDeg = opts.stableDeg ?? 12;
64
+ this.stableMs = opts.stableMs ?? 2500;
65
+ }
66
+
67
+ /** Feed one compass reading. `null` degrees means the compass said nothing. */
68
+ push(deg: number | null, now: number): TurnEvent | null {
69
+ if (deg === null) return null;
70
+
71
+ if (this.refDeg === null) {
72
+ this.refDeg = deg;
73
+ this.stableSince = now;
74
+ return null;
75
+ }
76
+
77
+ const delta = signedDelta(this.refDeg, deg);
78
+
79
+ if (Math.abs(delta) >= this.minTurnDeg) {
80
+ // Re-baseline immediately, so a continuous 180° about-face is reported as
81
+ // the turns it is made of. Left as one growing delta it would flip sign
82
+ // at the halfway point and report a right turn for a left one.
83
+ this.lastTurn = { deg: Math.round(delta), timestamp: now };
84
+ this.refDeg = deg;
85
+ this.stableSince = now;
86
+ return this.lastTurn;
87
+ }
88
+
89
+ if (Math.abs(delta) > this.stableDeg) {
90
+ // Moving, but not far enough yet. Hold the reference and keep watching —
91
+ // this is what lets a slow 90° turn accumulate instead of being
92
+ // re-baselined away one degree at a time.
93
+ this.stableSince = now;
94
+ return null;
95
+ }
96
+
97
+ // Steady. After a while, treat here as the new straight-ahead.
98
+ if (now - this.stableSince >= this.stableMs) {
99
+ this.refDeg = deg;
100
+ this.stableSince = now;
101
+ }
102
+ return null;
103
+ }
104
+
105
+ /**
106
+ * The most recent completed turn, or null if none this session.
107
+ *
108
+ * It does NOT expire on its own: the age is derivable from `timestamp`, and
109
+ * "recent enough to mention" is a product judgement this layer must not make
110
+ * on the caller's behalf.
111
+ */
112
+ last(): TurnEvent | null {
113
+ return this.lastTurn;
114
+ }
115
+
116
+ reset(): void {
117
+ this.refDeg = null;
118
+ this.stableSince = 0;
119
+ this.lastTurn = null;
120
+ }
121
+ }