@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.
- 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,55 @@
|
|
|
1
|
+
// ── deviceContext ──────────────────────────────────────────────────────────
|
|
2
|
+
// power ✓ thermal ✗ (needs a native module) memory/storage ✗
|
|
3
|
+
import * as ExpoBattery from 'expo-battery';
|
|
4
|
+
import type { DeviceState, DeviceContextSnapshot } from '@ateam-ai-mobile/plugin-runtime';
|
|
5
|
+
import { qualityFrom, qualityUnknown } from '../quality';
|
|
6
|
+
|
|
7
|
+
export function createDeviceContextDomain(): DeviceState['deviceContext'] {
|
|
8
|
+
return {
|
|
9
|
+
async getSnapshot(): Promise<DeviceContextSnapshot> {
|
|
10
|
+
const observedAt = Date.now();
|
|
11
|
+
try {
|
|
12
|
+
const [level, state, lowPower] = await Promise.all([
|
|
13
|
+
ExpoBattery.getBatteryLevelAsync(),
|
|
14
|
+
ExpoBattery.getBatteryStateAsync(),
|
|
15
|
+
ExpoBattery.isLowPowerModeEnabledAsync(),
|
|
16
|
+
]);
|
|
17
|
+
return {
|
|
18
|
+
power: {
|
|
19
|
+
// expo-battery returns -1 when it cannot tell. That is not a level.
|
|
20
|
+
batteryLevel: typeof level === 'number' && level >= 0 ? level : null,
|
|
21
|
+
charging:
|
|
22
|
+
state === ExpoBattery.BatteryState.CHARGING ||
|
|
23
|
+
state === ExpoBattery.BatteryState.FULL,
|
|
24
|
+
lowPowerMode: lowPower,
|
|
25
|
+
},
|
|
26
|
+
// No Expo API for either. iOS needs ProcessInfo.thermalState, Android
|
|
27
|
+
// PowerManager.getThermalStatus — a native module that does not exist.
|
|
28
|
+
thermal: { state: 'unknown' },
|
|
29
|
+
resources: { memoryPressure: 'unknown', storagePressure: 'unknown' },
|
|
30
|
+
quality: qualityFrom(observedAt),
|
|
31
|
+
};
|
|
32
|
+
} catch {
|
|
33
|
+
// The battery read failing is rare and uninteresting; reporting it as
|
|
34
|
+
// 0% would not be.
|
|
35
|
+
return {
|
|
36
|
+
power: { batteryLevel: null, charging: null, lowPowerMode: null },
|
|
37
|
+
thermal: { state: 'unknown' },
|
|
38
|
+
resources: { memoryPressure: 'unknown', storagePressure: 'unknown' },
|
|
39
|
+
quality: qualityUnknown(),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
// Real events, not a placeholder: expo-battery exposes native listeners,
|
|
45
|
+
// so a plugin that subscribes genuinely hears about a change rather than
|
|
46
|
+
// sitting on a subscription that can never fire.
|
|
47
|
+
subscribe(listener) {
|
|
48
|
+
const subs = [
|
|
49
|
+
ExpoBattery.addBatteryLevelListener(() => listener({ type: 'batteryLevelChanged' } as any)),
|
|
50
|
+
ExpoBattery.addBatteryStateListener(() => listener({ type: 'chargingChanged' } as any)),
|
|
51
|
+
];
|
|
52
|
+
return { unsubscribe: () => subs.forEach(x => x?.remove?.()) };
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// ── interaction ──────────────────────────────────────────────────────────
|
|
2
|
+
// app lifecycle ✓ screenOn ✗ audio ✗
|
|
3
|
+
import { AppState } from 'react-native';
|
|
4
|
+
import type { DeviceState, InteractionSnapshot } from '@ateam-ai-mobile/plugin-runtime';
|
|
5
|
+
import { qualityFrom } from '../quality';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Lifecycle only, and honest about the rest.
|
|
9
|
+
*
|
|
10
|
+
* `screenOn` is NOT derivable from AppState: RN reports `inactive` for a
|
|
11
|
+
* notification shade, a control centre swipe, an incoming call AND a locked
|
|
12
|
+
* screen. Mapping `inactive` to "screen off" would be wrong most of the time
|
|
13
|
+
* it fired — so it stays null until a native module can answer it.
|
|
14
|
+
*
|
|
15
|
+
* The whole `audio` block reports unknown for the same reason. `interruption`
|
|
16
|
+
* carries the distinction that matters: `none` means "checked, nothing is
|
|
17
|
+
* interrupting", `unknown` means "no knowledge" — and we have the latter.
|
|
18
|
+
*/
|
|
19
|
+
export function createInteractionDomain(): DeviceState['interaction'] {
|
|
20
|
+
const mapState = (s: string): InteractionSnapshot['lifecycle']['appState'] =>
|
|
21
|
+
s === 'active' ? 'foreground'
|
|
22
|
+
: s === 'background' ? 'background'
|
|
23
|
+
: s === 'inactive' ? 'inactive'
|
|
24
|
+
: 'unknown';
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
async getSnapshot(): Promise<InteractionSnapshot> {
|
|
28
|
+
const observedAt = Date.now();
|
|
29
|
+
return {
|
|
30
|
+
lifecycle: {
|
|
31
|
+
appState: mapState(AppState.currentState as string),
|
|
32
|
+
screenOn: null,
|
|
33
|
+
userActive: null,
|
|
34
|
+
idleDurationMs: null,
|
|
35
|
+
},
|
|
36
|
+
audio: {
|
|
37
|
+
captureActive: null,
|
|
38
|
+
playbackActive: null,
|
|
39
|
+
route: 'unknown',
|
|
40
|
+
interruption: 'unknown',
|
|
41
|
+
},
|
|
42
|
+
// The voice block is OMITTED rather than filled with nulls: the design
|
|
43
|
+
// says a platform that cannot report voice session state leaves the
|
|
44
|
+
// group out instead of guessing `false` for each field. The app's own
|
|
45
|
+
// voice store knows this, but it is not reachable from the SDK.
|
|
46
|
+
quality: qualityFrom(observedAt),
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
// Real: AppState genuinely emits on every foreground/background change,
|
|
51
|
+
// which is the one part of this domain that works.
|
|
52
|
+
subscribe(listener) {
|
|
53
|
+
const sub = AppState.addEventListener('change', next => {
|
|
54
|
+
listener({ type: 'appStateChanged', appState: mapState(next as string) } as any);
|
|
55
|
+
});
|
|
56
|
+
return { unsubscribe: () => sub.remove() };
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
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';
|
|
7
|
+
import { qualityFrom, qualityUnknown, unsupported } from '../quality';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Position, movement, facing and a short recent path.
|
|
11
|
+
*
|
|
12
|
+
* The design's performance rule is that navigation reads come from a WARM
|
|
13
|
+
* native cache, not a fresh GPS+compass call per invocation. There is no such
|
|
14
|
+
* cache today, so `getSnapshot` takes a fix — correct but not cheap. That is
|
|
15
|
+
* why `start()` matters: it opens a `watchPosition` stream AND a compass watch,
|
|
16
|
+
* and every later snapshot serves the newest streamed values instead, which is
|
|
17
|
+
* the warm path this layer can offer without a native module.
|
|
18
|
+
*
|
|
19
|
+
* TWO DIRECTIONS, NOT ONE, and conflating them is the bug this replaced.
|
|
20
|
+
*
|
|
21
|
+
* `orientation.headingDeg` — where the phone is POINTING. The compass.
|
|
22
|
+
* `movement.bearingDeg` — where the user is GOING. Course over ground.
|
|
23
|
+
*
|
|
24
|
+
* They disagree constantly and each answers a different sentence. "Turn right
|
|
25
|
+
* at the corner" is decided by the compass; "you are heading north on Main
|
|
26
|
+
* Street" by the course. The previous version assigned the same variable to
|
|
27
|
+
* both, which is only right by accident, and that variable was permanently
|
|
28
|
+
* null because the streaming collector dropped the fields.
|
|
29
|
+
*
|
|
30
|
+
* TURNS ARE SIGNED. `changes.lastMeaningfulTurnDeg` is positive for clockwise
|
|
31
|
+
* (right) and negative for anticlockwise (left), so a guide can say which way
|
|
32
|
+
* the user just went without a second field to consult. The freeze names the
|
|
33
|
+
* field but states no convention; this is a decision, recorded here.
|
|
34
|
+
*/
|
|
35
|
+
export function createSpatialDomain(
|
|
36
|
+
collectors: DeviceStateCollectors,
|
|
37
|
+
speedRef: SpeedRef,
|
|
38
|
+
): DeviceState['spatial'] {
|
|
39
|
+
// The warm cache, when a session is open. Newest last, bounded — an unbounded
|
|
40
|
+
// path on a walking user grows without limit.
|
|
41
|
+
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
|
+
|
|
51
|
+
let recentPath: SpatialPoint[] = [];
|
|
52
|
+
let latest: SpatialPoint | null = null;
|
|
53
|
+
/** Speed and course from the newest STREAMED fix — see `latest`. */
|
|
54
|
+
let latestSpeed: number | null = null;
|
|
55
|
+
let latestCourse: number | null = null;
|
|
56
|
+
let stopWatch: (() => void) | null = null;
|
|
57
|
+
let sessionId: string | null = null;
|
|
58
|
+
|
|
59
|
+
const nonNegative = (v: unknown): number | null =>
|
|
60
|
+
typeof v === 'number' && v >= 0 ? v : null;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Speed from the path when the OS declined to estimate one.
|
|
64
|
+
*
|
|
65
|
+
* Only over the last two points and only if they are close in time: two fixes
|
|
66
|
+
* a minute apart give an average that describes neither moment. Returns null
|
|
67
|
+
* rather than a number the caller cannot tell apart from a measured one —
|
|
68
|
+
* the confidence on the snapshot is what marks it as derived.
|
|
69
|
+
*/
|
|
70
|
+
const derivedSpeed = (): number | null => {
|
|
71
|
+
if (recentPath.length < 2) return null;
|
|
72
|
+
const b = recentPath[recentPath.length - 1];
|
|
73
|
+
const a = recentPath[recentPath.length - 2];
|
|
74
|
+
const dtS = (b.timestamp - a.timestamp) / 1000;
|
|
75
|
+
if (dtS <= 0 || dtS > 10) return null;
|
|
76
|
+
return distanceOf([a, b]) / dtS;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** Course from the path, for the same reason and with the same caution. */
|
|
80
|
+
const derivedCourse = (): number | null => {
|
|
81
|
+
if (recentPath.length < 2) return null;
|
|
82
|
+
const b = recentPath[recentPath.length - 1];
|
|
83
|
+
const a = recentPath[recentPath.length - 2];
|
|
84
|
+
if (b.timestamp - a.timestamp > 10_000) return null;
|
|
85
|
+
// Too small a step is noise, not a direction.
|
|
86
|
+
if (distanceOf([a, b]) < 3) return null;
|
|
87
|
+
return bearingBetween(a, b);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
async getSnapshot(): Promise<SpatialSnapshot> {
|
|
92
|
+
// 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;
|
|
97
|
+
|
|
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
|
+
}
|
|
121
|
+
}
|
|
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
|
+
};
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Open the warm window: position stream + compass.
|
|
165
|
+
*
|
|
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.
|
|
169
|
+
*/
|
|
170
|
+
async start(options) {
|
|
171
|
+
if (stopWatch) return { id: sessionId!, intent: options.intent, startedAt: Date.now() } as any;
|
|
172
|
+
sessionId = `spatial_${Date.now()}`;
|
|
173
|
+
if (!collectors.location) throw unsupported('location collector');
|
|
174
|
+
// The compass is what makes heading and turn detection possible, and it
|
|
175
|
+
// has to run for the session's whole life: a turn happens between reads,
|
|
176
|
+
// 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();
|
|
179
|
+
stopWatch = collectors.location.watchPosition((pos: any) => {
|
|
180
|
+
const p: SpatialPoint = {
|
|
181
|
+
latitude: pos.latitude,
|
|
182
|
+
longitude: pos.longitude,
|
|
183
|
+
altitude: pos.altitude ?? null,
|
|
184
|
+
accuracyM: nonNegative(pos.accuracy),
|
|
185
|
+
timestamp: pos.timestamp ?? Date.now(),
|
|
186
|
+
};
|
|
187
|
+
latest = p;
|
|
188
|
+
latestSpeed = nonNegative(pos.speed);
|
|
189
|
+
latestCourse = nonNegative(pos.course);
|
|
190
|
+
speedRef.mps = latestSpeed;
|
|
191
|
+
speedRef.at = p.timestamp;
|
|
192
|
+
recentPath = [...recentPath, p].slice(-RECENT_PATH_MAX);
|
|
193
|
+
});
|
|
194
|
+
return { id: sessionId, intent: options.intent, startedAt: Date.now() } as any;
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
async stop() {
|
|
198
|
+
stopWatch?.();
|
|
199
|
+
stopWatch = null;
|
|
200
|
+
sessionId = null;
|
|
201
|
+
// The path belongs to the session that recorded it. Keeping it would let
|
|
202
|
+
// a later snapshot report movement from a walk that ended hours ago.
|
|
203
|
+
recentPath = [];
|
|
204
|
+
latest = null;
|
|
205
|
+
latestSpeed = null;
|
|
206
|
+
latestCourse = null;
|
|
207
|
+
speedRef.mps = null;
|
|
208
|
+
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();
|
|
212
|
+
},
|
|
213
|
+
|
|
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'); },
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* How much to trust this spatial reading.
|
|
222
|
+
*
|
|
223
|
+
* Three things degrade it and they are not interchangeable: a loose GPS fix
|
|
224
|
+
* (the position is vague), an uncalibrated compass (the FACING is vague, which
|
|
225
|
+
* is what breaks "turn left"), and values derived from the path rather than
|
|
226
|
+
* measured by the OS. The lowest of the three wins, because a caller acting on
|
|
227
|
+
* the snapshot is exposed to the worst part of it, not the average.
|
|
228
|
+
*
|
|
229
|
+
* This is the signal that decides Ada Guide's modality switch: indoors the fix
|
|
230
|
+
* degrades, confidence drops, and that drop is what makes the guide ask for the
|
|
231
|
+
* camera instead of guessing from a GPS fix it should not trust.
|
|
232
|
+
*/
|
|
233
|
+
function spatialConfidence(
|
|
234
|
+
accuracyM: number | null,
|
|
235
|
+
calibration: number | null,
|
|
236
|
+
derived: boolean,
|
|
237
|
+
): StateQuality['confidence'] {
|
|
238
|
+
const rank = (v: 'high' | 'medium' | 'low' | 'unknown') =>
|
|
239
|
+
v === 'high' ? 3 : v === 'medium' ? 2 : v === 'low' ? 1 : 0;
|
|
240
|
+
|
|
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';
|
|
245
|
+
|
|
246
|
+
// iOS calibration rank: 3 high, 2 medium, 1 low, 0 none.
|
|
247
|
+
const fromCompass: 'high' | 'medium' | 'low' | 'unknown' =
|
|
248
|
+
calibration === null ? 'unknown'
|
|
249
|
+
: calibration >= 3 ? 'high'
|
|
250
|
+
: calibration >= 2 ? 'medium'
|
|
251
|
+
: 'low';
|
|
252
|
+
|
|
253
|
+
const parts = [fromFix, fromCompass, ...(derived ? (['low'] as const) : [])];
|
|
254
|
+
const known = parts.filter(p => p !== 'unknown');
|
|
255
|
+
if (!known.length) return { level: 'unknown' };
|
|
256
|
+
const worst = known.reduce((a, b) => (rank(a) <= rank(b) ? a : b));
|
|
257
|
+
return { level: worst };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Initial bearing from `a` to `b`, degrees clockwise from north. */
|
|
261
|
+
function bearingBetween(a: SpatialPoint, b: SpatialPoint): number {
|
|
262
|
+
const toRad = (d: number) => (d * Math.PI) / 180;
|
|
263
|
+
const la1 = toRad(a.latitude), la2 = toRad(b.latitude);
|
|
264
|
+
const dLon = toRad(b.longitude - a.longitude);
|
|
265
|
+
const y = Math.sin(dLon) * Math.cos(la2);
|
|
266
|
+
const x = Math.cos(la1) * Math.sin(la2) - Math.sin(la1) * Math.cos(la2) * Math.cos(dLon);
|
|
267
|
+
return (((Math.atan2(y, x) * 180) / Math.PI) + 360) % 360;
|
|
268
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// ── visual ───────────────────────────────────────────────────────────────
|
|
2
|
+
// frames ✓ heading ✓ freshness ✓ blur/exposure ✗
|
|
3
|
+
//
|
|
4
|
+
// Visual owns the semantic frame timeline/retention: the rolling ring, opaque
|
|
5
|
+
// frame ids, and the freshness the frames carry (frameQuality) — see
|
|
6
|
+
// visualEngine.ts. Opaque ids only above L1; pixels never cross to JS.
|
|
7
|
+
import type { DeviceState, VisualSnapshot } from '@ateam-ai-mobile/plugin-runtime';
|
|
8
|
+
import { visualEngine, frameQuality } from '../visualEngine';
|
|
9
|
+
import { currentHeading, startHeading, stopHeading } from '../headingSource';
|
|
10
|
+
import { unsupported } from '../quality';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Reads the rolling timeline the engine maintains. See visualEngine.ts for why
|
|
14
|
+
* the capture half lives in a mounted component.
|
|
15
|
+
*
|
|
16
|
+
* `getSnapshot` NEVER opens the camera and never takes a picture — that is the
|
|
17
|
+
* design's explicit performance rule, and the reason a guidance loop can ask
|
|
18
|
+
* every turn without paying for it. What it costs is one array read.
|
|
19
|
+
*/
|
|
20
|
+
export function createVisualDomain(): DeviceState['visual'] {
|
|
21
|
+
return {
|
|
22
|
+
async getSnapshot(): Promise<VisualSnapshot> {
|
|
23
|
+
const session = visualEngine.getSession();
|
|
24
|
+
const latest = visualEngine.latest();
|
|
25
|
+
const timeline = visualEngine.timeline();
|
|
26
|
+
|
|
27
|
+
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
|
+
},
|
|
37
|
+
// null, not a stale frame dressed as current. A caller with no usable
|
|
38
|
+
// frame must see nothing rather than something old.
|
|
39
|
+
latest: latest as any,
|
|
40
|
+
recentTimeline: timeline as any,
|
|
41
|
+
quality: frameQuality(latest),
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
async start(options) {
|
|
46
|
+
const s = visualEngine.startSession((options?.intent as any) || 'navigation');
|
|
47
|
+
// Warm the compass alongside the camera. Started here rather than at app
|
|
48
|
+
// launch: a magnetometer subscription running all day for a feature
|
|
49
|
+
// nobody opened is exactly the kind of cost this layer is meant to avoid.
|
|
50
|
+
visualEngine.setContextProvider({ heading: currentHeading });
|
|
51
|
+
void startHeading();
|
|
52
|
+
return { id: s.id, intent: s.intent, startedAt: s.startedAt } as any;
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
async stop() {
|
|
56
|
+
visualEngine.stopSession();
|
|
57
|
+
visualEngine.setContextProvider(null);
|
|
58
|
+
stopHeading();
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A deliberate one-shot. Distinct from getSnapshot because it is an
|
|
63
|
+
* OPERATION: it waits for a frame rather than serving the newest one.
|
|
64
|
+
*
|
|
65
|
+
* Throws when it cannot, rather than returning an empty result — the
|
|
66
|
+
* design is explicit that capture is an operation and reads are state.
|
|
67
|
+
*/
|
|
68
|
+
async capture() {
|
|
69
|
+
if (!visualEngine.getSession()) {
|
|
70
|
+
throw unsupported('visual.capture without an open session — call start({ intent }) first');
|
|
71
|
+
}
|
|
72
|
+
if (!visualEngine.isCameraReady()) {
|
|
73
|
+
const err: any = new Error(
|
|
74
|
+
'The camera is not running right now — the app may be in the background.',
|
|
75
|
+
);
|
|
76
|
+
err.code = 'TEMPORARILY_UNAVAILABLE';
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
// Wait briefly for the NEXT frame rather than returning the last one:
|
|
80
|
+
// "capture" means now, and the newest ring entry may be a second old.
|
|
81
|
+
const before = visualEngine.latest()?.timestamp ?? 0;
|
|
82
|
+
const deadline = Date.now() + 4000;
|
|
83
|
+
while (Date.now() < deadline) {
|
|
84
|
+
const f = visualEngine.latest();
|
|
85
|
+
if (f && f.timestamp > before) return { frame: f } as any;
|
|
86
|
+
await new Promise(r => setTimeout(r, 100));
|
|
87
|
+
}
|
|
88
|
+
const err: any = new Error('No frame arrived within 4s.');
|
|
89
|
+
err.code = 'TIMEOUT';
|
|
90
|
+
throw err;
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
subscribe(listener) {
|
|
94
|
+
const off = visualEngine.subscribe(() => {
|
|
95
|
+
listener({ type: 'visualStateChanged' } as any);
|
|
96
|
+
});
|
|
97
|
+
return { unsubscribe: off };
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The compass, kept warm — and the turn detector fed from it.
|
|
3
|
+
*
|
|
4
|
+
* `getHeadingAsync()` is not usable on the capture path: it starts a watch,
|
|
5
|
+
* waits for a reading and stops it, which costs hundreds of milliseconds — per
|
|
6
|
+
* frame. So this holds ONE subscription while a session is open and callers
|
|
7
|
+
* read the last value synchronously.
|
|
8
|
+
*
|
|
9
|
+
* Returns null until a reading arrives, and null means NOT MEASURED. A heading
|
|
10
|
+
* of 0 is due north, so an unstarted compass must never report 0.
|
|
11
|
+
*
|
|
12
|
+
* WHY THE TURN DETECTOR IS FED HERE. A turn happens BETWEEN reads — it is an
|
|
13
|
+
* event, not a state — so a detector driven by sampled snapshots would miss
|
|
14
|
+
* exactly the moment a guide needs. It runs on the compass callback instead.
|
|
15
|
+
* The logic itself lives in `turnDetector.ts`, free of any Expo import, so it
|
|
16
|
+
* can be tested against a scripted walk.
|
|
17
|
+
*/
|
|
18
|
+
import * as ExpoLocation from 'expo-location';
|
|
19
|
+
|
|
20
|
+
import { TurnDetector, type TurnEvent } from './turnDetector';
|
|
21
|
+
|
|
22
|
+
export type { TurnEvent };
|
|
23
|
+
export { signedDelta } from './turnDetector';
|
|
24
|
+
|
|
25
|
+
interface HeadingReading {
|
|
26
|
+
headingDeg: number | null;
|
|
27
|
+
/**
|
|
28
|
+
* iOS calibration rank: 3 high, 2 medium, 1 low, 0 none. NOT degrees of
|
|
29
|
+
* error, which is why it is never surfaced as `headingAccuracyDeg` — that
|
|
30
|
+
* field wants degrees, and converting a rank into one would be a fabricated
|
|
31
|
+
* error bar. It drives CONFIDENCE instead, which is what it can honestly say.
|
|
32
|
+
*/
|
|
33
|
+
calibration: number | null;
|
|
34
|
+
timestamp: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let latest: HeadingReading | null = null;
|
|
38
|
+
let sub: { remove(): void } | null = null;
|
|
39
|
+
const turns = new TurnDetector();
|
|
40
|
+
|
|
41
|
+
/** Start watching. Idempotent; safe to call on every session start. */
|
|
42
|
+
export async function startHeading(): Promise<void> {
|
|
43
|
+
if (sub) return;
|
|
44
|
+
try {
|
|
45
|
+
sub = await ExpoLocation.watchHeadingAsync(h => {
|
|
46
|
+
// trueHeading is -1 until the compass calibrates; magHeading is the
|
|
47
|
+
// fallback the OS itself recommends.
|
|
48
|
+
const raw = h.trueHeading >= 0 ? h.trueHeading : h.magHeading;
|
|
49
|
+
const deg = typeof raw === 'number' && raw >= 0 ? raw : null;
|
|
50
|
+
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());
|
|
53
|
+
});
|
|
54
|
+
} catch {
|
|
55
|
+
// No compass, or permission refused. Callers get headingDeg: null, which
|
|
56
|
+
// is the honest answer and does not stop anything else working.
|
|
57
|
+
sub = null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function stopHeading(): void {
|
|
62
|
+
sub?.remove();
|
|
63
|
+
sub = null;
|
|
64
|
+
// Clear everything: a heading from a finished session describes a direction
|
|
65
|
+
// the user may have long since turned away from, and a turn from a previous
|
|
66
|
+
// walk is not something a new one should report.
|
|
67
|
+
latest = null;
|
|
68
|
+
turns.reset();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The newest reading, or null if the compass has said nothing yet. */
|
|
72
|
+
export function currentHeading(): { headingDeg: number | null } | null {
|
|
73
|
+
return latest ? { headingDeg: latest.headingDeg } : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The newest reading with its calibration and age. */
|
|
77
|
+
export function currentHeadingDetail(): HeadingReading | null {
|
|
78
|
+
return latest;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The most recent completed turn. Signed: positive = clockwise = right. */
|
|
82
|
+
export function lastMeaningfulTurn(): TurnEvent | null {
|
|
83
|
+
return turns.last();
|
|
84
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device Bridge v2 — the runtime behind `props.deviceState`.
|
|
3
|
+
*
|
|
4
|
+
* R5 split the former monolith into one module per domain (./domains/*), a
|
|
5
|
+
* single quality module that owns freshness/confidence/unknown (./quality), and
|
|
6
|
+
* this composition root, which wires the domains and injects their dependencies.
|
|
7
|
+
* The behaviour — and every snapshot shape — is unchanged; only the file layout is.
|
|
8
|
+
*
|
|
9
|
+
* WHAT IS AND IS NOT HERE — stated up front, because a half-built state layer
|
|
10
|
+
* that pretends to be whole is the exact failure this whole effort exists to
|
|
11
|
+
* end:
|
|
12
|
+
*
|
|
13
|
+
* deviceContext power ✓ thermal ✗ memory/storage ✗
|
|
14
|
+
* connectivity reachability ✓ transport ✓ quality/stability/metered ✗
|
|
15
|
+
* capability permission ✓ availability ✗ restriction ✗
|
|
16
|
+
* interaction app lifecycle ✓ screenOn ✗ audio ✗
|
|
17
|
+
* spatial position ✓ path ✓ (while a session is open) heading/speed ✗
|
|
18
|
+
* visual frames ✓ heading ✓ freshness ✓ blur/exposure ✗
|
|
19
|
+
* activity / environment — NOT IMPLEMENTED AT ALL
|
|
20
|
+
*
|
|
21
|
+
* Everything marked ✗ reports `unknown`, which is a real answer in this design
|
|
22
|
+
* and the only honest one. It never reports a plausible default: an invented
|
|
23
|
+
* 'nominal' thermal state is indistinguishable from a measured one, and that is
|
|
24
|
+
* how 56 fabrications reached users from the layer below.
|
|
25
|
+
*
|
|
26
|
+
* A domain with no engine here is not simply missing: `gateDeviceState` (in
|
|
27
|
+
* plugin-runtime/src/deviceStateHost.ts) fills the gap with a domain whose
|
|
28
|
+
* reads answer explicit `unknown`s and whose ACTIONS throw. So a plugin can
|
|
29
|
+
* call any of the eight without an existence check, and still cannot mistake
|
|
30
|
+
* "not built" for "nothing to report". `environment` is deliberately absent
|
|
31
|
+
* from the composition below for exactly that reason.
|
|
32
|
+
*/
|
|
33
|
+
import type { DeviceState } from '@ateam-ai-mobile/plugin-runtime';
|
|
34
|
+
import { DeviceStateCollectors, SpeedRef } from './collectors';
|
|
35
|
+
import { createDeviceContextDomain } from './domains/deviceContext';
|
|
36
|
+
import { createConnectivityDomain } from './domains/connectivity';
|
|
37
|
+
import { createCapabilityDomain } from './domains/capability';
|
|
38
|
+
import { createInteractionDomain } from './domains/interaction';
|
|
39
|
+
import { createSpatialDomain } from './domains/spatial';
|
|
40
|
+
import { createVisualDomain } from './domains/visual';
|
|
41
|
+
import { createActivityDomain } from './domains/activity';
|
|
42
|
+
|
|
43
|
+
export type { DeviceStateCollectors, SpeedRef } from './collectors';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build the semantic surface.
|
|
47
|
+
*
|
|
48
|
+
* Only implemented domains appear. `environment` is deliberately absent: a
|
|
49
|
+
* plugin touching it gets a TypeError naming it, which is a far better failure
|
|
50
|
+
* than a snapshot of nulls that reads like a quiet device.
|
|
51
|
+
*/
|
|
52
|
+
export function createDeviceState(collectors: DeviceStateCollectors = {}): Partial<DeviceState> {
|
|
53
|
+
// The ONE explicit cross-domain dependency: Activity needs Spatial's newest
|
|
54
|
+
// speed to tell "walking" from "sitting in a moving car". Passed as a shared
|
|
55
|
+
// reference, not a cross-domain import — see SpeedRef.
|
|
56
|
+
const speedRef: SpeedRef = { mps: null, at: 0 };
|
|
57
|
+
return {
|
|
58
|
+
deviceContext: createDeviceContextDomain(),
|
|
59
|
+
connectivity: createConnectivityDomain(collectors),
|
|
60
|
+
capability: createCapabilityDomain(collectors),
|
|
61
|
+
interaction: createInteractionDomain(),
|
|
62
|
+
spatial: createSpatialDomain(collectors, speedRef),
|
|
63
|
+
visual: createVisualDomain(),
|
|
64
|
+
activity: createActivityDomain(speedRef),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Which domains this build can actually answer. */
|
|
69
|
+
export const IMPLEMENTED_DOMAINS = [
|
|
70
|
+
'deviceContext',
|
|
71
|
+
'connectivity',
|
|
72
|
+
'capability',
|
|
73
|
+
'interaction',
|
|
74
|
+
'spatial',
|
|
75
|
+
'visual',
|
|
76
|
+
'activity',
|
|
77
|
+
] as const;
|