@condorinst/mobile-bridge 0.19.2 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +100 -42
- package/CHANGELOG.md +106 -0
- package/README.md +64 -14
- package/contract/bridge-contract.json +70 -25
- package/dist/cli.cjs +436 -5
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +433 -3
- package/dist/cli.js.map +1 -1
- package/dist/dev-harness.global.js +2110 -0
- package/dist/dev.cjs +1662 -0
- package/dist/dev.cjs.map +1 -0
- package/dist/dev.d.cts +683 -0
- package/dist/dev.d.ts +683 -0
- package/dist/dev.js +1611 -0
- package/dist/dev.js.map +1 -0
- package/dist/index.cjs +96 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +265 -19
- package/dist/index.d.ts +265 -19
- package/dist/index.js +94 -3
- package/dist/index.js.map +1 -1
- package/docs/agent-guide.md +83 -25
- package/docs/bridge-contract.md +154 -25
- package/docs/client-guide.md +39 -28
- package/docs/device-testing.md +84 -0
- package/docs/pilot-onboarding.md +26 -12
- package/docs/troubleshooting.md +83 -18
- package/examples/minimal-home/AGENTS.md +9 -6
- package/examples/minimal-home/README.md +10 -5
- package/examples/minimal-home/build.mjs +2 -1
- package/examples/minimal-home/index.html +2 -0
- package/examples/minimal-home/package.json +3 -2
- package/examples/minimal-home/src/main.ts +151 -73
- package/llms-full.txt +2041 -0
- package/llms.txt +15 -13
- package/package.json +9 -3
package/docs/agent-guide.md
CHANGED
|
@@ -19,33 +19,55 @@ in memory until the next reload.
|
|
|
19
19
|
|
|
20
20
|
## Stage 0 — set up so you can see what you are doing
|
|
21
21
|
|
|
22
|
+
Start from `examples/minimal-home/` — a complete, working client with the
|
|
23
|
+
reference boot sequence, and a build script that produces a single-file bundle.
|
|
24
|
+
Copy it first, then install, so its `package.json` is the one you keep:
|
|
25
|
+
|
|
22
26
|
```bash
|
|
23
27
|
mkdir my-home && cd my-home
|
|
24
|
-
npm
|
|
25
|
-
|
|
26
|
-
npm install
|
|
28
|
+
npm pack @condorinst/mobile-bridge@^0.21.0 && tar -xzf condorinst-mobile-bridge-*.tgz
|
|
29
|
+
cp -r package/examples/minimal-home/. . && rm -rf package condorinst-mobile-bridge-*.tgz
|
|
30
|
+
npm install
|
|
27
31
|
```
|
|
28
32
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
Or, in a project you already have: `npm install @condorinst/mobile-bridge@^0.21.0`
|
|
34
|
+
and copy only `src/main.ts`, `build.mjs` and `index.html` from
|
|
35
|
+
`node_modules/@condorinst/mobile-bridge/examples/minimal-home/`.
|
|
36
|
+
|
|
37
|
+
Pin the SDK to the minor you are building against. Below `1.0.0`, a minor can
|
|
38
|
+
change the contract — `^0.21.0` gives you patches only, which is what you want.
|
|
39
|
+
|
|
40
|
+
**Run it in the harness**, not in a bare browser tab:
|
|
32
41
|
|
|
33
42
|
```bash
|
|
34
|
-
|
|
35
|
-
|
|
43
|
+
npm run build
|
|
44
|
+
npx condor-mobile-bridge dev -i dist/index.html --scenario default
|
|
36
45
|
```
|
|
37
46
|
|
|
38
|
-
|
|
39
|
-
|
|
47
|
+
The harness serves your bundle inside the same sandboxed iframe, shell, policy
|
|
48
|
+
and CSP as the app, with a simulated native side behind it, and a dev panel to
|
|
49
|
+
emit events by hand. It re-reads the file on every reload. Opening
|
|
50
|
+
`dist/index.html` directly does **not** work: there is no shell, so
|
|
51
|
+
`BridgeClient.handshake()` rejects at once.
|
|
52
|
+
|
|
53
|
+
The scenarios reproduce what the device does to you — switch with the selector
|
|
54
|
+
at the top or `--scenario`:
|
|
55
|
+
|
|
56
|
+
| Scenario | Your bundle must… |
|
|
57
|
+
| --- | --- |
|
|
58
|
+
| `default` | render two devices |
|
|
59
|
+
| `cold-start` | leave the loading screen although the first `userDevice` requests are lost |
|
|
60
|
+
| `no-devices` | show an empty state when `userDevice` never answers |
|
|
61
|
+
| `shared-serial` | keep two devices with the same `serialNo` apart |
|
|
62
|
+
| `offline` | explain that history is unavailable without internet |
|
|
40
63
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
the app to build a Home.
|
|
64
|
+
Add `--native-version null` (or an older version such as `0.15.0`) to test
|
|
65
|
+
your capability branches, and use the **◀ OS back** button at the top to press
|
|
66
|
+
the OS back button.
|
|
45
67
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
68
|
+
If you already have a dev server (Vite), mount the same harness from your own
|
|
69
|
+
page with `createDevHarness()` from `@condorinst/mobile-bridge/dev` — in the
|
|
70
|
+
host page only, never in the bundle (`validate` rejects that).
|
|
49
71
|
|
|
50
72
|
## Stage 1 — render before you connect
|
|
51
73
|
|
|
@@ -72,9 +94,9 @@ const canQueryTimeRange = has('0.15.0');
|
|
|
72
94
|
const hasLanguageKey = has('0.14.0');
|
|
73
95
|
```
|
|
74
96
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
as versions.
|
|
97
|
+
Or use the SDK's `versionAtLeast(ack.nativeVersion, '0.16.0')`, which does
|
|
98
|
+
exactly that and returns `false` for `null`. Do not compare version strings
|
|
99
|
+
lexically — `'0.9.0' > '0.16.0'` is `true` as strings and false as versions.
|
|
78
100
|
|
|
79
101
|
**Do not branch on `ack.accepted`.** It is always `true`; the native side never
|
|
80
102
|
refuses a handshake. Anything you gate on it is ungated.
|
|
@@ -89,7 +111,27 @@ BridgeClient.on(BRIDGE_KEYS.userDevice, (p) => { setDevices(p); render(); });
|
|
|
89
111
|
BridgeClient.on(BRIDGE_KEYS.systemStatus, (s) => { setStatus(s); render(); });
|
|
90
112
|
BridgeClient.on(BRIDGE_KEYS.deviceConnection, (c) => { setConnected(c); render(); });
|
|
91
113
|
|
|
92
|
-
|
|
114
|
+
const result = await BridgeClient.requestUserDevices();
|
|
115
|
+
if (result.status === 'timeout') renderEmptyState(result.hasDevices === false);
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
**Do not replace the helper with `send('userDevice')`.** The device list is
|
|
119
|
+
served by the app's background service, which starts after your bundle; a
|
|
120
|
+
request sent before it is up is lost, and an account with no device never gets
|
|
121
|
+
a reply. The helper resends with backoff for 30 s and then tells you to show the
|
|
122
|
+
empty state. Keep the `userDevice` subscription: roster changes arrive on it
|
|
123
|
+
later without a request. `docs/troubleshooting.md` has the details.
|
|
124
|
+
|
|
125
|
+
Read device data from the typed items: the list is `devicesConfig`, and the
|
|
126
|
+
name, battery and last sync are in `devicesTelemetry` — join them on
|
|
127
|
+
`deviceId`, never on `serialNo` alone.
|
|
128
|
+
|
|
129
|
+
Then follow the connection state of each device:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
for (const d of result.devices.devicesConfig) {
|
|
133
|
+
BridgeClient.send('deviceConnection', { type: 'add', serialNo: d.serialNo, deviceId: d.deviceId });
|
|
134
|
+
}
|
|
93
135
|
```
|
|
94
136
|
|
|
95
137
|
`on` returns an unsubscribe function. If your UI has views that mount and
|
|
@@ -130,6 +172,12 @@ BridgeClient.send('realTime', { serialNo, value: true });
|
|
|
130
172
|
|
|
131
173
|
`serialNo` is a **number**. A string is dropped in silence.
|
|
132
174
|
|
|
175
|
+
Two devices can share a `serialNo` (an ActLumus and an ActLumus Plus). To act on
|
|
176
|
+
one of them, send its `deviceId` **together with** `serialNo`, taken from the
|
|
177
|
+
`userDevice` list: `BridgeClient.send('realTime', { serialNo, deviceId, value })`.
|
|
178
|
+
A command with only the shared `serialNo` is ignored on purpose. Gate this on
|
|
179
|
+
`nativeVersion` ≥ 0.17.0; details in `docs/bridge-contract.md`.
|
|
180
|
+
|
|
133
181
|
In a raw channel, `null` means the channel does not exist on that hardware
|
|
134
182
|
generation. It does not mean zero — `0.0` is a valid reading. Coercing `null`
|
|
135
183
|
to `0` invents data, and has previously led to a confident, wrong conclusion
|
|
@@ -151,7 +199,11 @@ Timestamps are **device-local**. Build ranges with local `Date` getters, never
|
|
|
151
199
|
`toISOString()` — converting to UTC shifts every series by the timezone offset
|
|
152
200
|
and produces data that looks entirely plausible.
|
|
153
201
|
|
|
154
|
-
Paginate with the cursors exactly as received. They are opaque.
|
|
202
|
+
Paginate with the cursors exactly as received. They are opaque. Without a range
|
|
203
|
+
the first page is the **most recent** one (go older with `endingBefore`); with
|
|
204
|
+
`startTime` it is the **oldest** of the range (go newer with `startingAfter`).
|
|
205
|
+
A device writes one log per period — programmable, 60 s by default — so a week
|
|
206
|
+
is about 10 000 logs: use `limit: 1000` and do not assume the cadence.
|
|
155
207
|
|
|
156
208
|
## Stage 7 — back button
|
|
157
209
|
|
|
@@ -191,16 +243,22 @@ necessary, not sufficient.
|
|
|
191
243
|
(`syncTime`, `deleteMemory`, `logout`) may never ack, because the person
|
|
192
244
|
can decline.
|
|
193
245
|
- [ ] No `localStorage`, no `fetch`, no `eval`. Not even behind a `try`.
|
|
194
|
-
- [ ] `serialNo` is a number everywhere
|
|
246
|
+
- [ ] `serialNo` is a number everywhere, and a command for a device whose `serialNo`
|
|
247
|
+
repeats on the account also carries its `deviceId` (`nativeVersion` ≥ 0.17.0).
|
|
195
248
|
- [ ] Errors are reported through `BridgeClient.send('jsError', …)`, the only
|
|
196
249
|
channel out.
|
|
197
|
-
- [ ]
|
|
250
|
+
- [ ] Every harness scenario passes: `default`, `cold-start`, `no-devices`,
|
|
251
|
+
`shared-serial`, `offline`.
|
|
252
|
+
- [ ] The `nativeVersion === null` branch renders something sensible.
|
|
198
253
|
|
|
199
254
|
## Delivery
|
|
200
255
|
|
|
201
256
|
Hand Condor the `.zip` along with its size and `sha256-<hex>` hash, both printed
|
|
202
257
|
by the `build` command. The app verifies the hash against the manifest and
|
|
203
|
-
rejects any mismatch.
|
|
258
|
+
rejects any mismatch. Condor publishes it for your company, which reaches every
|
|
259
|
+
user of that company — test on a development test company first.
|
|
260
|
+
`docs/device-testing.md` explains the path to the phone and how to inspect the
|
|
261
|
+
bundle there; `docs/pilot-onboarding.md` has the full checklist.
|
|
204
262
|
|
|
205
263
|
## When something does not work
|
|
206
264
|
|
package/docs/bridge-contract.md
CHANGED
|
@@ -29,10 +29,10 @@ Four numbers coexist. Only one tells you what the device can do.
|
|
|
29
29
|
|
|
30
30
|
| Number | Current | What it means |
|
|
31
31
|
| --- | --- | --- |
|
|
32
|
-
| `nativeVersion` | `0.
|
|
32
|
+
| `nativeVersion` | `0.17.0` | **The capability signal.** The bridge schema the app on this device implements. |
|
|
33
33
|
| `shellVersion` | `0.6.0` | The Condor shell wrapping your iframe. |
|
|
34
34
|
| client protocol | `0.5.0` | The postMessage protocol `BridgeClient` speaks. |
|
|
35
|
-
| package | `0.
|
|
35
|
+
| package | `0.21.0` | The npm version you compiled against. |
|
|
36
36
|
|
|
37
37
|
```ts
|
|
38
38
|
const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' });
|
|
@@ -44,11 +44,11 @@ Two traps:
|
|
|
44
44
|
**`accepted` is always `true`.** There is no version negotiation and the native
|
|
45
45
|
side never refuses. Branching on it is branching on a constant.
|
|
46
46
|
|
|
47
|
-
**`nativeVersion` can be `null`.**
|
|
48
|
-
bundles compiled against SDK ≤ 0.16.x, where the shell did not
|
|
49
|
-
`null` as "assume only the baseline" and keep a working
|
|
50
|
-
|
|
51
|
-
|
|
47
|
+
**`nativeVersion` can be `null`.** When the shell could not reach the native
|
|
48
|
+
side, and in bundles compiled against SDK ≤ 0.16.x, where the shell did not
|
|
49
|
+
relay it. Treat `null` as "assume only the baseline" and keep a working
|
|
50
|
+
default. The dev harness reports `0.17.0` by default; `--native-version null`
|
|
51
|
+
exercises this path.
|
|
52
52
|
|
|
53
53
|
### Capability matrix
|
|
54
54
|
|
|
@@ -58,6 +58,7 @@ the one you see most.
|
|
|
58
58
|
| `language` key and the `language` pull command | `nativeVersion` ≥ 0.14.0 |
|
|
59
59
|
| `sensorLog` accepts `startTime` / `endTime` | `nativeVersion` ≥ 0.15.0 |
|
|
60
60
|
| `deviceLightSensors` carries raw `light_sensor_*` channels, and the `actlumus2` variant | `nativeVersion` ≥ 0.16.0 |
|
|
61
|
+
| Address one device by `deviceId` when two share a `serialNo` (`realTime`, `syncTime`, `deleteMemory`, `deviceConnection`); `deviceId` in the device events; `systemStatus.deviceSyncErrorById` | `nativeVersion` ≥ 0.17.0 |
|
|
61
62
|
| `nativeVersion` reaches the client at all | `shellVersion` ≥ 0.6.0 |
|
|
62
63
|
|
|
63
64
|
`CHANGELOG.md` is the precise record of when each of these landed.
|
|
@@ -69,8 +70,8 @@ with `BridgeClient.on(key, cb)`, which returns an unsubscribe function.
|
|
|
69
70
|
|
|
70
71
|
| Key | Payload type | Emitted when |
|
|
71
72
|
| --- | --- | --- |
|
|
72
|
-
| `pingPong` | `PingPongPayload` | reply to
|
|
73
|
-
| `userDevice` | `UserDevicePayload` | reply to `userDevice
|
|
73
|
+
| `pingPong` | `PingPongPayload` | reply to `ping`, once the app's background service is up |
|
|
74
|
+
| `userDevice` | `UserDevicePayload` | reply to `userDevice` **when the list loads** (not guaranteed — see below), and on roster changes |
|
|
74
75
|
| `deviceLightSensors` | `DeviceLightSensorsPayload` | real-time stream, only while `realTime` is on for that device |
|
|
75
76
|
| `deviceConnection` | `DeviceConnectionPayload` | a device connects or disconnects |
|
|
76
77
|
| `deviceNotification` | `DeviceNotificationPayload` | device-level notice, including errors |
|
|
@@ -115,7 +116,8 @@ conclusions before.
|
|
|
115
116
|
bluetooth: boolean; internet: boolean; permission: boolean; hasDevices: boolean;
|
|
116
117
|
missingEssentialPermissions: string[]; // e.g. ["bluetoothScan", "bluetoothConnect"]
|
|
117
118
|
missingOptionalPermissions: string[]; // e.g. ["notification", "backgroundLocation"]
|
|
118
|
-
deviceSyncError: Record<number, string>; // serialNo → message, already localized
|
|
119
|
+
deviceSyncError: Record<number, string>; // serialNo → message, already localized; a shared serialNo is left out
|
|
120
|
+
deviceSyncErrorById?: Record<number, string>; // deviceId → message, every device (nativeVersion ≥ 0.17.0)
|
|
119
121
|
}
|
|
120
122
|
```
|
|
121
123
|
|
|
@@ -123,6 +125,52 @@ conclusions before.
|
|
|
123
125
|
off. Render a blocking card; wire its button to
|
|
124
126
|
`BridgeClient.requestPermission(name)` with a name taken from that array.
|
|
125
127
|
|
|
128
|
+
**`hasDevices` is a hint.** It is `true` only after the app loaded a non-empty
|
|
129
|
+
device list from the server. `false` means "no linked device" **or** "the app
|
|
130
|
+
could not ask" (no internet, session problem). Do not show "you have no device"
|
|
131
|
+
on `false` alone; see the next section.
|
|
132
|
+
|
|
133
|
+
### `userDevice` in detail
|
|
134
|
+
|
|
135
|
+
**It is not guaranteed to answer.** `userDevice` (and `ping`) are handled by
|
|
136
|
+
the app's background service, which starts after your bundle is running; a
|
|
137
|
+
request sent before it is up is lost. And the app replies only when the list
|
|
138
|
+
loads: an account with no linked device, or a failed request, gets **no reply
|
|
139
|
+
at all** — never an empty list. Use the helper, which resends with backoff and
|
|
140
|
+
tells you when to give up:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
const r = await BridgeClient.requestUserDevices(); // getFromServer: false
|
|
144
|
+
// { status: 'ok', devices } | { status: 'timeout', hasDevices: boolean | null }
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
**`getFromServer`.** `false` (default) answers from the app's memory or cache
|
|
148
|
+
while it is fresh, and asks the server otherwise — use it at boot. `true` asks
|
|
149
|
+
the server first and falls back to the cache if that fails — use it for a
|
|
150
|
+
"Refresh" button.
|
|
151
|
+
|
|
152
|
+
**Shape.** Three arrays, typed as `DeviceConfigItem`, `DeviceTelemetryItem` and
|
|
153
|
+
`DeviceUserConfigItem` in `dist/index.d.ts`:
|
|
154
|
+
|
|
155
|
+
| Array | Holds | Notable fields |
|
|
156
|
+
| --- | --- | --- |
|
|
157
|
+
| `devicesConfig` | one item per linked device — **the list to render** | `deviceId`, `serialNo`, `modelId` (`'actlumus'`, `'actlumus_plus'`; absent = ActLumus), `deviceBodyPart` (`'wrist'` \| `'necklace'` \| `null`), `bleConnectionPeriod` (minutes) |
|
|
158
|
+
| `devicesTelemetry` | last state read from each device | `deviceName`, `batteryLevel` (0–100 %), `memoryDataUsage` (0–100 %), `updataDateTime` (epoch ms, last sync — the misspelling is the real name), `deviceVersion`, `isCharging`, `isOnDock`, `syncErrors[]` |
|
|
159
|
+
| `devicesUserConfig` | per-device toggles | `syncTime`, `realTime`, `deleteMemory`, `readAllMemory`, `readAllSystemLog` |
|
|
160
|
+
|
|
161
|
+
The name and battery of a device are in **telemetry**, not in the config. A
|
|
162
|
+
device that never synced may have no telemetry item, or one with every reading
|
|
163
|
+
`null`: render it anyway.
|
|
164
|
+
|
|
165
|
+
**Joining the arrays.** Join on `deviceId`. An item of `devicesTelemetry` or
|
|
166
|
+
`devicesUserConfig` without `deviceId` is legacy data: fall back to `serialNo`
|
|
167
|
+
only when that `serialNo` appears once in `devicesConfig`.
|
|
168
|
+
|
|
169
|
+
**Sync errors.** `devicesTelemetry[].syncErrors` is the per-device history
|
|
170
|
+
(`attemptDate`, `message`, `messageCode`); `systemStatus.deviceSyncErrorById`
|
|
171
|
+
is the current error per device, already localized. Show the current one; use
|
|
172
|
+
the history for detail screens.
|
|
173
|
+
|
|
126
174
|
## Commands
|
|
127
175
|
|
|
128
176
|
`BridgeClient.send(command, args)` is **fire-and-forget**: no return value, no
|
|
@@ -133,8 +181,8 @@ column.
|
|
|
133
181
|
| --- | --- | --- | --- |
|
|
134
182
|
| `ping` | — | no | `pingPong` |
|
|
135
183
|
| `userDevice` | `{ getFromServer?: boolean }` | no | `userDevice` |
|
|
136
|
-
| `realTime` | `{ serialNo: number, value: boolean }` | no | `realTime` |
|
|
137
|
-
| `deviceConnection` | `{ type: 'add' \| 'remove', serialNo: number }` | no | `deviceConnection` |
|
|
184
|
+
| `realTime` | `{ serialNo: number, deviceId?: number, value: boolean }` | no | `realTime` |
|
|
185
|
+
| `deviceConnection` | `{ type: 'add' \| 'remove', serialNo: number, deviceId?: number }` — subscribe to / stop following one device's connection state | no | `deviceConnection` |
|
|
138
186
|
| `sensorLog` | see below | no | `sensorLog` |
|
|
139
187
|
| `openSettings` | — | no | — |
|
|
140
188
|
| `requestPermission` | `{ permission: string }` | no | — (re-emits `systemStatus`) |
|
|
@@ -142,14 +190,45 @@ column.
|
|
|
142
190
|
| `navigationState` | `{ canGoBack: boolean }` | no | — |
|
|
143
191
|
| `language` | — | no | `language` |
|
|
144
192
|
| `jsError` | `{ message, stack?, source?, lineno?, colno? }` | no | — |
|
|
145
|
-
| `syncTime` | `{ serialNo: number }` | **yes** | `syncTime` |
|
|
146
|
-
| `deleteMemory` | `{ serialNo: number }` | **yes** | `deleteMemory` |
|
|
193
|
+
| `syncTime` | `{ serialNo: number, deviceId?: number }` | **yes** | `syncTime` |
|
|
194
|
+
| `deleteMemory` | `{ serialNo: number, deviceId?: number }` | **yes** | `deleteMemory` |
|
|
147
195
|
| `logout` | — | **yes** | — |
|
|
148
196
|
| `dfu` | — | denied | — |
|
|
149
197
|
|
|
150
198
|
`serialNo` is an integer. Passing a string makes the native side drop the
|
|
151
199
|
message in silence.
|
|
152
200
|
|
|
201
|
+
`deviceConnection` **pairs nothing.** `type: 'add'` asks the app to report the
|
|
202
|
+
BLE connection state of a device you already have from `userDevice`; the state
|
|
203
|
+
then arrives on the `deviceConnection` key whenever it changes. `'remove'` stops
|
|
204
|
+
it. Send `add` for each device after the list arrives.
|
|
205
|
+
|
|
206
|
+
### Two devices with the same `serialNo`
|
|
207
|
+
|
|
208
|
+
An ActLumus and an ActLumus Plus have separate serial sequences, so one account
|
|
209
|
+
can hold both with the **same** `serialNo` (the server `deviceId` is always
|
|
210
|
+
unique). A command that carries only `serialNo` cannot say which one you mean:
|
|
211
|
+
the app **ignores it** instead of guessing, because guessing wrong would act on
|
|
212
|
+
the wrong device (erasing the wrong memory, for instance).
|
|
213
|
+
|
|
214
|
+
- **Send `deviceId` together with `serialNo`**, taken from the `userDevice`
|
|
215
|
+
list (`devicesConfig[].deviceId`). Always keep `serialNo` in the message: an
|
|
216
|
+
app older than 0.17.0 drops in silence a command that lacks it.
|
|
217
|
+
- **Read `deviceId` when it is there.** `deviceConnection`,
|
|
218
|
+
`deviceNotification` and `deviceLightSensors` carry it **only when the
|
|
219
|
+
`serialNo` is shared**; with a unique `serialNo` it is absent and `serialNo`
|
|
220
|
+
alone identifies the device. Never treat a missing `deviceId` as "unknown
|
|
221
|
+
device".
|
|
222
|
+
- **Acks always carry `serialNo`**, plus the `deviceId` you sent.
|
|
223
|
+
- **`systemStatus.deviceSyncError` leaves a shared `serialNo` out**, because no
|
|
224
|
+
single device owns it. Read `deviceSyncErrorById` for every device.
|
|
225
|
+
- **Gate on `nativeVersion` ≥ 0.17.0** before relying on any of this, and
|
|
226
|
+
degrade to `serialNo` only below it (where a shared `serialNo` was already
|
|
227
|
+
unaddressable).
|
|
228
|
+
- `sensorLog` takes no `deviceId` yet: for a shared `serialNo` the backend
|
|
229
|
+
answers with the error code `DEVICE_SERIAL_AMBIGUOUS`. Show your own copy for
|
|
230
|
+
it; do not retry.
|
|
231
|
+
|
|
153
232
|
### Commands that need human confirmation
|
|
154
233
|
|
|
155
234
|
`syncTime`, `deleteMemory` and `logout` open a modal owned by the Condor shell.
|
|
@@ -164,6 +243,8 @@ timeout on any spinner.
|
|
|
164
243
|
Prefer these over raw `send` — they add correlation, timeouts and types:
|
|
165
244
|
|
|
166
245
|
```ts
|
|
246
|
+
BridgeClient.requestUserDevices({ getFromServer }) // resends until the list arrives, 30 s default
|
|
247
|
+
BridgeClient.waitForService() // resends ping until the background service answers
|
|
167
248
|
BridgeClient.fetchSensorLog(query, { timeoutMs }) // correlates requestId, 10 s default
|
|
168
249
|
BridgeClient.requestPermission(name)
|
|
169
250
|
BridgeClient.reportNavigation(canGoBack)
|
|
@@ -197,9 +278,48 @@ series by the timezone offset, producing plausible data and no error at all.
|
|
|
197
278
|
**Cursors are opaque.** `startingAfter` and `endingBefore` are mutually
|
|
198
279
|
exclusive; echo back exactly what you received and never construct one.
|
|
199
280
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
281
|
+
**Sweeping a period.** Keep `startTime` and `endTime` on **every** page and add
|
|
282
|
+
the cursor; the cursors are valid only inside the range they came from:
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
const logs: SensorLogItem[] = [];
|
|
286
|
+
let startingAfter: string | undefined;
|
|
287
|
+
for (;;) {
|
|
288
|
+
const page = await BridgeClient.fetchSensorLog({
|
|
289
|
+
requestId: crypto.randomUUID(), serialNo, limit: 1000,
|
|
290
|
+
startTime: '2026-10-13 00:00:00', endTime: '2026-10-20 00:00:00', // local time
|
|
291
|
+
...(startingAfter ? { startingAfter } : {}),
|
|
292
|
+
});
|
|
293
|
+
if (!page.success) break; // see the error codes below
|
|
294
|
+
logs.push(...(page.logs ?? []));
|
|
295
|
+
if (!page.hasMore || !page.startingAfter) break;
|
|
296
|
+
startingAfter = page.startingAfter;
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
**Which page comes first.** `logs` is always chronological ascending inside a
|
|
301
|
+
page. Which page you get first depends on the query:
|
|
302
|
+
|
|
303
|
+
| Query | First page | Next page |
|
|
304
|
+
| --- | --- | --- |
|
|
305
|
+
| no cursor, no range | the **most recent** `limit` logs | older: `endingBefore` = the reply's `endingBefore`, while `hasPrevious` |
|
|
306
|
+
| `startTime` (with or without `endTime`) | the **oldest** logs of the range | newer: `startingAfter` = the reply's `startingAfter`, while `hasMore` |
|
|
307
|
+
| only `endTime` | the most recent logs before it | older, as in the first row |
|
|
308
|
+
|
|
309
|
+
`limit` defaults to 10. With a time range, `hasMore` and `hasPrevious` are
|
|
310
|
+
computed *within* the period, so `hasMore: false` means "this period is
|
|
311
|
+
complete", not "no more data exists".
|
|
312
|
+
|
|
313
|
+
**How many logs to expect.** One per log period, which is **programmable per
|
|
314
|
+
device** (default 60 s, so about 1 440 per day). Do not hard-code it: derive the
|
|
315
|
+
period from consecutive `log_time` values, and page with `limit` up to 1000.
|
|
316
|
+
Pages depend on the previous cursor, so they cannot be fetched in parallel.
|
|
317
|
+
|
|
318
|
+
**Fields.** Each log is a `SensorLogItem`: `log_time`, `body_part`,
|
|
319
|
+
`temperature` (°C), `lux_photopic` and `lux_melanopic` (lux), the raw light
|
|
320
|
+
channels and the activity counters. `null` on a channel means the hardware does
|
|
321
|
+
not have it, never zero. `device_log_time` and `app_log_time` mark the upload
|
|
322
|
+
batch, not the log — do not plot them.
|
|
203
323
|
|
|
204
324
|
## Error shapes
|
|
205
325
|
|
|
@@ -209,11 +329,15 @@ There are three, and none of them is a thrown exception.
|
|
|
209
329
|
`no-confirmation-handler`, `confirmation-error`, `user-rejected`, `not-allowed`.
|
|
210
330
|
Visible only as `console.warn`.
|
|
211
331
|
|
|
212
|
-
**2. `sensorLog` failure** — `{ requestId, success: false, error }`. The
|
|
213
|
-
are `CONFLICTING_CURSORS`, `INVALID_BODY_PART`, `INVALID_LIMIT`,
|
|
332
|
+
**2. `sensorLog` failure** — `{ requestId, success: false, error }`. The
|
|
333
|
+
contract codes are `CONFLICTING_CURSORS`, `INVALID_BODY_PART`, `INVALID_LIMIT`,
|
|
214
334
|
`INVALID_START_TIME`, `INVALID_END_TIME`, `INVALID_TIME_RANGE`,
|
|
215
|
-
`MISSING_DEVICE_SERIAL_NO`, `invalid session
|
|
216
|
-
`
|
|
335
|
+
`MISSING_DEVICE_SERIAL_NO`, `DEVICE_SERIAL_AMBIGUOUS`, `invalid session` and
|
|
336
|
+
`device not found for this user`. Some carry a suffix (`INVALID_LIMIT: 5000`),
|
|
337
|
+
so **match by prefix**, not equality. When the request never reached the
|
|
338
|
+
server (no internet, timeout), `error` is free text from the transport — so
|
|
339
|
+
treat any value outside the list as "history unavailable right now" and offer a
|
|
340
|
+
retry. `error` is a **diagnostic, never user-facing text** — map it to your own
|
|
217
341
|
copy.
|
|
218
342
|
|
|
219
343
|
**3. Silence** — nothing reaches JavaScript. This is the dominant failure mode.
|
|
@@ -224,12 +348,17 @@ The six paths that produce it are enumerated in `docs/troubleshooting.md`.
|
|
|
224
348
|
The app owns the OS back button and will delegate it to you only if you ask.
|
|
225
349
|
|
|
226
350
|
```ts
|
|
227
|
-
|
|
228
|
-
|
|
351
|
+
const stack: string[] = ['home']; // keep navigation in memory
|
|
352
|
+
function go(view: string) { stack.push(view); render(); BridgeClient.reportNavigation(stack.length > 1); }
|
|
353
|
+
BridgeClient.onBack(() => { stack.pop(); render(); BridgeClient.reportNavigation(stack.length > 1); });
|
|
229
354
|
```
|
|
230
355
|
|
|
231
|
-
|
|
232
|
-
|
|
356
|
+
Keep the navigation stack in memory rather than in the History API: the
|
|
357
|
+
bundle lives in a sandboxed `srcdoc` iframe, where `history.pushState` is not
|
|
358
|
+
something to rely on. Report `false` when you are at your root, and the app
|
|
359
|
+
handles back itself. Report state on every internal navigation, not once at
|
|
360
|
+
startup. In the harness, the **◀ OS back** button at the top presses the OS
|
|
361
|
+
back button for you.
|
|
233
362
|
|
|
234
363
|
## Status bar
|
|
235
364
|
|
package/docs/client-guide.md
CHANGED
|
@@ -61,13 +61,13 @@ Pin the version with a caret:
|
|
|
61
61
|
```jsonc
|
|
62
62
|
// package.json
|
|
63
63
|
"dependencies": {
|
|
64
|
-
"@condorinst/mobile-bridge": "^0.
|
|
64
|
+
"@condorinst/mobile-bridge": "^0.21.0"
|
|
65
65
|
}
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
-
**Updates.** The SDK is on `0.x` during the pilot. With `^0.
|
|
69
|
-
`npm install` (or your CI) auto-picks **patch** releases (`0.
|
|
70
|
-
no action needed. A `0.
|
|
68
|
+
**Updates.** The SDK is on `0.x` during the pilot. With `^0.21.0`, running
|
|
69
|
+
`npm install` (or your CI) auto-picks **patch** releases (`0.21.x`) — bug fixes,
|
|
70
|
+
no action needed. A `0.22.0` (a larger change during beta) is an intentional
|
|
71
71
|
opt-in: you bump the range when you're ready. `1.0.0` will mark the
|
|
72
72
|
production-stable contract.
|
|
73
73
|
|
|
@@ -112,7 +112,7 @@ const unsubscribe = BridgeClient.on(BRIDGE_KEYS.deviceConnection, (payload) => {
|
|
|
112
112
|
### Send commands (you → native)
|
|
113
113
|
|
|
114
114
|
```typescript
|
|
115
|
-
|
|
115
|
+
const devices = await BridgeClient.requestUserDevices(); // device list, resent until it arrives
|
|
116
116
|
BridgeClient.send('realTime', { serialNo: 1234, value: true }); // start real-time
|
|
117
117
|
BridgeClient.openSettings(); // open native Settings
|
|
118
118
|
BridgeClient.requestPermission('bluetoothScan'); // in-app permission dialog
|
|
@@ -120,6 +120,12 @@ BridgeClient.requestPermission('bluetoothScan'); // in-app permission
|
|
|
120
120
|
|
|
121
121
|
> Commands are gated by the app's policy. A command that isn't allowed is
|
|
122
122
|
> silently ignored — design your UI to not depend on a response you won't get.
|
|
123
|
+
>
|
|
124
|
+
> Ask for the device list with `requestUserDevices()`, not with
|
|
125
|
+
> `send('userDevice')`: the first request after the app starts is often lost,
|
|
126
|
+
> and an account with no device gets no reply at all. The helper resends with
|
|
127
|
+
> backoff and resolves `{ status: 'timeout', hasDevices }` after 30 s, so you can
|
|
128
|
+
> show an empty state. See [Troubleshooting](./troubleshooting.md).
|
|
123
129
|
|
|
124
130
|
### Handle the system back button
|
|
125
131
|
|
|
@@ -265,32 +271,34 @@ if you need to branch on it explicitly.
|
|
|
265
271
|
|
|
266
272
|
## 4. Develop in your browser (no app needed)
|
|
267
273
|
|
|
268
|
-
|
|
269
|
-
auto-activates **mock mode**: the bridge no longer expects a native side, and
|
|
270
|
-
you get tooling to simulate it.
|
|
274
|
+
Build your page, then serve it with the harness:
|
|
271
275
|
|
|
272
|
-
```
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
if (Bridge.isMock) {
|
|
276
|
-
// Floating dev panel: inspect sent messages and emit native events by hand.
|
|
277
|
-
Bridge.installDevPanel({ position: 'bottom-right', theme: 'dark' });
|
|
278
|
-
}
|
|
276
|
+
```bash
|
|
277
|
+
npx condor-mobile-bridge dev -i dist/index.html
|
|
279
278
|
```
|
|
280
279
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
280
|
+
Open `http://localhost:5173`. Your page runs inside the same sandboxed iframe,
|
|
281
|
+
shell, policy and CSP as in the app, and a simulated app answers your commands.
|
|
282
|
+
Rebuild and reload — the harness re-reads the file.
|
|
283
|
+
|
|
284
|
+
Pick a **scenario** in the selector at the top (or with `--scenario`) to see
|
|
285
|
+
what the real app does to your page:
|
|
286
|
+
|
|
287
|
+
- `cold-start` — the first device requests are lost while the app starts;
|
|
288
|
+
- `no-devices` — an account with no device: the device list never comes;
|
|
289
|
+
- `shared-serial` — two devices with the same serial number;
|
|
290
|
+
- `offline` — no internet, history unavailable.
|
|
291
|
+
|
|
292
|
+
Your page is ready for a phone when it behaves in all of them. The page's
|
|
293
|
+
console (`[condor dev]` lines) shows each command, reply and dropped request,
|
|
294
|
+
and the dev panel in the corner lets you emit any event by hand.
|
|
287
295
|
|
|
288
|
-
>
|
|
289
|
-
>
|
|
290
|
-
>
|
|
296
|
+
> Opening `dist/index.html` directly in a browser does not work: there is no
|
|
297
|
+
> shell around it, so `BridgeClient.handshake()` rejects at once with a message
|
|
298
|
+
> pointing to the harness.
|
|
291
299
|
|
|
292
|
-
The [`examples/minimal-home`](../examples/minimal-home) example
|
|
293
|
-
|
|
300
|
+
The [`examples/minimal-home`](../examples/minimal-home) example runs this way
|
|
301
|
+
out of the box.
|
|
294
302
|
|
|
295
303
|
---
|
|
296
304
|
|
|
@@ -364,7 +372,9 @@ bundles and falls back to a default screen, so keep under the limit.
|
|
|
364
372
|
Send the resulting `dist/bundle.zip` through the publication channel Condor
|
|
365
373
|
gave you (the admin upload). The backend validates structure, self-containment,
|
|
366
374
|
and size on upload, and records the `bundleSize`/`bundleHash`. Once published,
|
|
367
|
-
the app downloads, verifies, and renders your Home
|
|
375
|
+
the app downloads, verifies, and renders your Home — for **every user of your
|
|
376
|
+
company**, so test on a development test company first. See
|
|
377
|
+
[Testing on a device](./device-testing.md).
|
|
368
378
|
|
|
369
379
|
---
|
|
370
380
|
|
|
@@ -376,7 +386,8 @@ the app downloads, verifies, and renders your Home.
|
|
|
376
386
|
| Images missing | Referenced by path instead of `data:` URI — the CLI inlines local images; remote images are blocked. |
|
|
377
387
|
| Works in browser, not in app | You relied on a network call or external asset at load time (CSP blocks it), or a code-split chunk didn't resolve. Go single-chunk + self-contained. |
|
|
378
388
|
| Bundle rejected on upload / falls back | Over 2 MB, multiple files at root, or not self-contained. |
|
|
379
|
-
| `handshake`
|
|
389
|
+
| `handshake` rejects in the browser | The page is not inside the shell. Serve it with `npx condor-mobile-bridge dev`. |
|
|
390
|
+
| Stuck on "loading devices" on the phone | The device request was lost or the account has no device. Use `requestUserDevices()` and test the `cold-start` and `no-devices` scenarios. |
|
|
380
391
|
| A command does nothing | The command may not be allowed by the app policy; it's dropped silently. |
|
|
381
392
|
|
|
382
393
|
---
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Testing on a device
|
|
2
|
+
|
|
3
|
+
The harness (`npx condor-mobile-bridge dev`) catches most problems in the
|
|
4
|
+
browser. This page covers the rest: how a bundle reaches a phone, what that
|
|
5
|
+
costs, and how to see what it is doing once it is there.
|
|
6
|
+
|
|
7
|
+
## How a bundle reaches the screen
|
|
8
|
+
|
|
9
|
+
You do not install a bundle on a phone. The app downloads it:
|
|
10
|
+
|
|
11
|
+
1. You deliver `bundle.zip`, its `bundleSize` and its `bundleHash` (printed by
|
|
12
|
+
`condor-mobile-bridge build`) to your Condor contact.
|
|
13
|
+
2. Condor publishes it for **your company**. There is exactly one current
|
|
14
|
+
bundle per company.
|
|
15
|
+
3. Each copy of the app asks for its company's manifest when the Home opens
|
|
16
|
+
and every time the app returns to the foreground. If the `bundleHash`
|
|
17
|
+
changed, it downloads the `.zip`, checks size and hash, and swaps the Home
|
|
18
|
+
without a restart. Otherwise it keeps the cached copy, also when offline.
|
|
19
|
+
|
|
20
|
+
Consequences:
|
|
21
|
+
|
|
22
|
+
- **Reinstalling the app changes nothing.** The bundle is not inside the app.
|
|
23
|
+
- **Publishing reaches every user of that company** the next time their app
|
|
24
|
+
comes to the foreground. There is no per-user or per-device preview today.
|
|
25
|
+
- **A rejected bundle is invisible to the person.** The app keeps its previous
|
|
26
|
+
bundle, or shows its default screen, if the download fails a gate — which is
|
|
27
|
+
why `validate --strict` must pass before you hand anything over.
|
|
28
|
+
|
|
29
|
+
## Development and production are separate worlds
|
|
30
|
+
|
|
31
|
+
Condor runs a **development** environment next to production. An app build
|
|
32
|
+
talks to exactly one of them, and accounts, devices and bundles do not cross
|
|
33
|
+
over:
|
|
34
|
+
|
|
35
|
+
- a development build of the app sees only development accounts and bundles;
|
|
36
|
+
- the store app sees only production.
|
|
37
|
+
|
|
38
|
+
So, to test on a phone, ask Condor for three things that match: a **test
|
|
39
|
+
account** in a **test company** in the development environment, a
|
|
40
|
+
**development build** of the app (Android), and to publish your bundle **to that
|
|
41
|
+
test company**. Never iterate on a production company: every publish reaches
|
|
42
|
+
real people.
|
|
43
|
+
|
|
44
|
+
Each iteration on the device is therefore: build → validate → hand over →
|
|
45
|
+
Condor publishes → bring the app to the foreground. That is slow, so do every
|
|
46
|
+
iteration you can in the harness first.
|
|
47
|
+
|
|
48
|
+
## Seeing what the bundle does on the phone
|
|
49
|
+
|
|
50
|
+
**Release builds of the app cannot be inspected.** Ask Condor for a
|
|
51
|
+
development (debuggable) Android build. Then:
|
|
52
|
+
|
|
53
|
+
1. Connect the phone by USB with USB debugging on.
|
|
54
|
+
2. Open `chrome://inspect/#devices` in desktop Chrome and click **inspect** on
|
|
55
|
+
the app's WebView.
|
|
56
|
+
3. In the DevTools console, switch the context selector from `top` to the
|
|
57
|
+
frame `about:srcdoc` — that is your bundle. The `top` frame is the Condor
|
|
58
|
+
shell.
|
|
59
|
+
|
|
60
|
+
There you see your own `console.*` output, uncaught errors, and the shell's
|
|
61
|
+
refusals: a `nack` reaches you only as a `[BridgeClient] command nacked by
|
|
62
|
+
shell` warning, so this is the one place to find out that a command was denied
|
|
63
|
+
or declined.
|
|
64
|
+
|
|
65
|
+
What you will **not** see from the WebView: the native side. A command dropped
|
|
66
|
+
by the app (wrong argument type, unknown command, a request lost while the
|
|
67
|
+
background service starts) leaves no trace in the console — reproduce it in the
|
|
68
|
+
harness instead, whose console says what was dropped and why.
|
|
69
|
+
|
|
70
|
+
## Errors after the fact
|
|
71
|
+
|
|
72
|
+
`BridgeClient.send('jsError', { message, stack })` sends an error to Condor's
|
|
73
|
+
logging and crash reporting (rate limited: 20 per 10 s, 4 KB per field). Those
|
|
74
|
+
are Condor's tools: when you need the errors of a test session, ask your Condor
|
|
75
|
+
contact, with the test account and the time window.
|
|
76
|
+
|
|
77
|
+
## Before you ask for a publish
|
|
78
|
+
|
|
79
|
+
- [ ] `npx condor-mobile-bridge validate -i dist/bundle.zip --strict` exits 0.
|
|
80
|
+
- [ ] Every harness scenario passes (`default`, `cold-start`, `no-devices`,
|
|
81
|
+
`shared-serial`, `offline`).
|
|
82
|
+
- [ ] You know which environment and which company the bundle is for.
|
|
83
|
+
- [ ] You sent `bundleSize` and `bundleHash` with the `.zip`, so the published
|
|
84
|
+
bundle can be checked against what you built.
|