@ateam-ai-mobile/expo 0.1.5 → 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.
- package/android/build.gradle +16 -0
- package/android/src/main/java/expo/modules/adavision/AdaVisionModule.kt +75 -0
- package/dist/attachmentCache-DhQTqi2E.d.mts +18 -0
- package/dist/attachmentCache-DhQTqi2E.d.ts +18 -0
- package/dist/bootstrap.d.mts +95 -0
- package/dist/bootstrap.d.ts +95 -0
- package/dist/bootstrap.js +2473 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/bootstrap.mjs +64 -0
- package/dist/bootstrap.mjs.map +1 -0
- package/dist/chunk-4BMWVN25.mjs +91 -0
- package/dist/chunk-4BMWVN25.mjs.map +1 -0
- package/dist/chunk-4XYXJYN5.mjs +1513 -0
- package/dist/chunk-4XYXJYN5.mjs.map +1 -0
- package/dist/chunk-5HM6UA2K.mjs +571 -0
- package/dist/chunk-5HM6UA2K.mjs.map +1 -0
- package/dist/chunk-6LXF3JVX.mjs +96 -0
- package/dist/chunk-6LXF3JVX.mjs.map +1 -0
- package/dist/chunk-FF6ZZZXH.mjs +71 -0
- package/dist/chunk-FF6ZZZXH.mjs.map +1 -0
- package/dist/chunk-KOXF2FZM.mjs +92 -0
- package/dist/chunk-KOXF2FZM.mjs.map +1 -0
- package/dist/chunk-L4V7OCJ3.mjs +71 -0
- package/dist/chunk-L4V7OCJ3.mjs.map +1 -0
- package/dist/chunk-TZXWVYYR.mjs +67 -0
- package/dist/chunk-TZXWVYYR.mjs.map +1 -0
- package/dist/chunk-VQJPSLHP.mjs +1801 -0
- package/dist/chunk-VQJPSLHP.mjs.map +1 -0
- package/dist/chunk-WMJKH4XE.mjs +34 -0
- package/dist/chunk-WMJKH4XE.mjs.map +1 -0
- package/dist/chunk-XQSY6MHP.mjs +213 -0
- package/dist/chunk-XQSY6MHP.mjs.map +1 -0
- package/dist/createBridge.mjs +2 -0
- package/dist/createBridge.mjs.map +1 -1
- package/dist/device/createDeviceProvider.d.mts +23 -0
- package/dist/device/createDeviceProvider.d.ts +23 -0
- package/dist/device/createDeviceProvider.js +967 -0
- package/dist/device/createDeviceProvider.js.map +1 -0
- package/dist/device/createDeviceProvider.mjs +17 -0
- package/dist/device/createDeviceProvider.mjs.map +1 -0
- package/dist/device-bridge/collectors/appEngagement.d.mts +10 -0
- package/dist/device-bridge/collectors/appEngagement.d.ts +10 -0
- package/dist/device-bridge/collectors/appEngagement.js +128 -0
- package/dist/device-bridge/collectors/appEngagement.js.map +1 -0
- package/dist/device-bridge/collectors/appEngagement.mjs +12 -0
- package/dist/device-bridge/collectors/appEngagement.mjs.map +1 -0
- package/dist/device-bridge/collectors/health.d.mts +24 -0
- package/dist/device-bridge/collectors/health.d.ts +24 -0
- package/dist/device-bridge/collectors/health.js +238 -0
- package/dist/device-bridge/collectors/health.js.map +1 -0
- package/dist/device-bridge/collectors/health.mjs +18 -0
- package/dist/device-bridge/collectors/health.mjs.map +1 -0
- package/dist/device-bridge/cookies.d.mts +49 -0
- package/dist/device-bridge/cookies.d.ts +49 -0
- package/dist/device-bridge/cookies.js +90 -0
- package/dist/device-bridge/cookies.js.map +1 -0
- package/dist/device-bridge/cookies.mjs +14 -0
- package/dist/device-bridge/cookies.mjs.map +1 -0
- package/dist/device-bridge/index.d.mts +483 -0
- package/dist/device-bridge/index.d.ts +483 -0
- package/dist/device-bridge/index.js +2084 -0
- package/dist/device-bridge/index.js.map +1 -0
- package/dist/device-bridge/index.mjs +32 -0
- package/dist/device-bridge/index.mjs.map +1 -0
- package/dist/device-bridge/nfc.d.mts +30 -0
- package/dist/device-bridge/nfc.d.ts +30 -0
- package/dist/device-bridge/nfc.js +109 -0
- package/dist/device-bridge/nfc.js.map +1 -0
- package/dist/device-bridge/nfc.mjs +14 -0
- package/dist/device-bridge/nfc.mjs.map +1 -0
- package/dist/index.d.mts +493 -18
- package/dist/index.d.ts +493 -18
- package/dist/index.js +1409 -6
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +23 -194
- package/dist/index.mjs.map +1 -1
- package/dist/oauth/expoOAuth.d.mts +18 -0
- package/dist/oauth/expoOAuth.d.ts +18 -0
- package/dist/oauth/expoOAuth.js +102 -0
- package/dist/oauth/expoOAuth.js.map +1 -0
- package/dist/oauth/expoOAuth.mjs +8 -0
- package/dist/oauth/expoOAuth.mjs.map +1 -0
- package/dist/types-BRanJYTn.d.mts +138 -0
- package/dist/types-BRanJYTn.d.ts +138 -0
- package/dist/vision/index.d.mts +81 -0
- package/dist/vision/index.d.ts +81 -0
- package/dist/vision/index.js +101 -0
- package/dist/vision/index.js.map +1 -0
- package/dist/vision/index.mjs +28 -0
- package/dist/vision/index.mjs.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/AdaVision.podspec +28 -0
- package/ios/AdaVisionModule.swift +432 -0
- package/ios/Resources/yolov8n-oiv7.mlmodelc/analytics/coremldata.bin +0 -0
- package/ios/Resources/yolov8n-oiv7.mlmodelc/coremldata.bin +0 -0
- package/ios/Resources/yolov8n-oiv7.mlmodelc/metadata.json +773 -0
- package/ios/Resources/yolov8n-oiv7.mlmodelc/model0/analytics/coremldata.bin +0 -0
- package/ios/Resources/yolov8n-oiv7.mlmodelc/model0/coremldata.bin +0 -0
- package/ios/Resources/yolov8n-oiv7.mlmodelc/model0/model.mil +720 -0
- package/ios/Resources/yolov8n-oiv7.mlmodelc/model0/weights/weight.bin +0 -0
- package/ios/Resources/yolov8n-oiv7.mlmodelc/model1/coremldata.bin +0 -0
- package/package.json +107 -7
- package/src/bootstrap.ts +159 -0
- package/src/camera/CameraTaskHost.tsx +175 -0
- package/src/camera/cameraTasks.ts +129 -0
- package/src/device/createDeviceProvider.ts +832 -0
- package/src/device-bridge/actions/calendar-actions.ts +217 -0
- package/src/device-bridge/actions/dnd.ts +74 -0
- package/src/device-bridge/actions/health-actions.ts +58 -0
- package/src/device-bridge/actions/index.ts +12 -0
- package/src/device-bridge/actions/messaging.ts +74 -0
- package/src/device-bridge/actions/navigation.ts +88 -0
- package/src/device-bridge/actions/queue-poller.ts +151 -0
- package/src/device-bridge/background.ts +137 -0
- package/src/device-bridge/biometrics.ts +64 -0
- package/src/device-bridge/capabilities.ts +218 -0
- package/src/device-bridge/collectors/appEngagement.ts +120 -0
- package/src/device-bridge/collectors/battery.ts +83 -0
- package/src/device-bridge/collectors/calendar.ts +162 -0
- package/src/device-bridge/collectors/camera.ts +27 -0
- package/src/device-bridge/collectors/connectivity.ts +60 -0
- package/src/device-bridge/collectors/contacts.ts +200 -0
- package/src/device-bridge/collectors/health.ts +299 -0
- package/src/device-bridge/collectors/index.ts +12 -0
- package/src/device-bridge/collectors/location.ts +224 -0
- package/src/device-bridge/collectors/mood.ts +141 -0
- package/src/device-bridge/collectors/notifications.ts +70 -0
- package/src/device-bridge/cookies.ts +89 -0
- package/src/device-bridge/dynamic-connector.ts +422 -0
- package/src/device-bridge/index.ts +467 -0
- package/src/device-bridge/nfc.ts +85 -0
- package/src/device-bridge/plugin.ts +71 -0
- package/src/device-bridge/relay-client.ts +237 -0
- package/src/device-bridge/scanner.ts +42 -0
- package/src/device-bridge/sharing.ts +35 -0
- package/src/device-bridge/types.ts +326 -0
- package/src/device-state/activityClassifier.ts +151 -0
- package/src/device-state/activityEngine.ts +252 -0
- package/src/device-state/collectors.ts +31 -0
- package/src/device-state/domains/activity.ts +93 -0
- package/src/device-state/domains/capability.ts +60 -0
- package/src/device-state/domains/connectivity.ts +60 -0
- package/src/device-state/domains/deviceContext.ts +55 -0
- package/src/device-state/domains/interaction.ts +59 -0
- package/src/device-state/domains/spatial.ts +283 -0
- package/src/device-state/domains/visual.ts +100 -0
- package/src/device-state/headingSource.ts +84 -0
- package/src/device-state/index.ts +77 -0
- package/src/device-state/quality.ts +60 -0
- package/src/device-state/replay.ts +220 -0
- package/src/device-state/turnDetector.ts +121 -0
- package/src/device-state/visualEngine.ts +341 -0
- package/src/haptics.ts +4 -0
- package/src/index.ts +37 -0
- package/src/oauth/expoOAuth.tsx +113 -0
- package/src/vision/analysis.js +80 -0
- package/src/vision/index.ts +144 -0
- 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
|
+
}
|