@condorinst/mobile-bridge 0.22.0 → 0.23.1
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 +133 -34
- package/CHANGELOG.md +187 -0
- package/README.md +30 -481
- package/contract/bridge-contract.json +32 -21
- package/dist/cli.cjs +126 -29
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +126 -29
- package/dist/cli.js.map +1 -1
- package/dist/dev-harness.global.js +402 -55
- package/dist/dev.cjs +403 -55
- package/dist/dev.cjs.map +1 -1
- package/dist/dev.d.cts +245 -9
- package/dist/dev.d.ts +245 -9
- package/dist/dev.js +402 -55
- package/dist/dev.js.map +1 -1
- package/dist/index.cjs +30 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +154 -26
- package/dist/index.d.ts +154 -26
- package/dist/index.js +29 -8
- package/dist/index.js.map +1 -1
- package/docs/agent-guide.md +9 -265
- package/docs/bridge-contract.md +317 -26
- package/docs/client-guide.md +34 -400
- package/docs/device-testing.md +235 -0
- package/docs/pilot-onboarding.md +41 -115
- package/docs/shell-integration.md +292 -0
- package/docs/troubleshooting.md +19 -11
- package/examples/minimal-home/README.md +2 -2
- package/examples/minimal-home/package.json +1 -1
- package/examples/minimal-home/src/main.ts +23 -3
- package/llms-full.txt +769 -865
- package/llms.txt +13 -15
- package/package.json +1 -1
package/docs/bridge-contract.md
CHANGED
|
@@ -27,12 +27,12 @@ protocol for you.
|
|
|
27
27
|
|
|
28
28
|
Four numbers coexist. Only one tells you what the device can do.
|
|
29
29
|
|
|
30
|
-
| Number |
|
|
30
|
+
| Number | Latest | What it means |
|
|
31
31
|
| --- | --- | --- |
|
|
32
|
-
| `nativeVersion` | `0.
|
|
32
|
+
| `nativeVersion` | `0.18.0` | **The capability signal.** The bridge schema the app on this device implements. No Canvas release is in the field yet; the first one implements `0.18.0`. Later releases will coexist with it on phones for a while, so read it at runtime — never assume the latest. |
|
|
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.23.1` | The npm version you compiled against. |
|
|
36
36
|
|
|
37
37
|
```ts
|
|
38
38
|
const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' });
|
|
@@ -47,7 +47,7 @@ side never refuses. Branching on it is branching on a constant.
|
|
|
47
47
|
**`nativeVersion` can be `null`.** When the shell could not reach the native
|
|
48
48
|
side, and in bundles compiled against SDK ≤ 0.16.x, where the shell did not
|
|
49
49
|
relay it. Treat `null` as "assume only the baseline" and keep a working
|
|
50
|
-
default. The dev harness reports `0.
|
|
50
|
+
default. The dev harness reports `0.18.0` by default (`--native-version 0.17.0` reproduces an app that may stay silent); `--native-version null`
|
|
51
51
|
exercises this path.
|
|
52
52
|
|
|
53
53
|
### Capability matrix
|
|
@@ -59,6 +59,7 @@ exercises this path.
|
|
|
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
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 |
|
|
62
|
+
| `userDevice` always answers (re-sent after a cold start; `error` when the list could not load); `systemStatus.hasDevicesKnown`; payload limited to the contract fields | `nativeVersion` ≥ 0.18.0 |
|
|
62
63
|
| `nativeVersion` reaches the client at all | `shellVersion` ≥ 0.6.0 |
|
|
63
64
|
|
|
64
65
|
`CHANGELOG.md` is the precise record of when each of these landed.
|
|
@@ -92,7 +93,24 @@ you; the second is the one you receive. This is why `language` also exists as a
|
|
|
92
93
|
pull command — a client that mounts late can ask.
|
|
93
94
|
|
|
94
95
|
Render a skeleton synchronously and fill it as events arrive. Do not make first
|
|
95
|
-
paint depend on any event.
|
|
96
|
+
paint depend on any event. **No order is guaranteed between events.** On the
|
|
97
|
+
phone the post-handshake `systemStatus` usually arrives before the answer to
|
|
98
|
+
your first `userDevice` (that answer is a round trip to the background
|
|
99
|
+
service), but nothing promises it, and `systemStatus` is re-emitted on every
|
|
100
|
+
change. Don't hold a request waiting for `systemStatus`: a `sensorLog` sent
|
|
101
|
+
offline fails at once with an error you already handle.
|
|
102
|
+
|
|
103
|
+
`deviceLightSensors` arrives at the device's pace — about once per second, set
|
|
104
|
+
by the firmware, not throttled by the app (an observation, not a guarantee).
|
|
105
|
+
Treat "no reading for a few seconds" as the device leaving its session, and
|
|
106
|
+
let `deviceConnection` confirm it.
|
|
107
|
+
|
|
108
|
+
**`data.time`** is when the **phone** received the reading: epoch milliseconds
|
|
109
|
+
(UTC) from the phone clock, stamped on arrival — not the device clock, and not
|
|
110
|
+
shifted by the timezone like `log_time`. Each reading is forwarded as it
|
|
111
|
+
arrives, with no queue. To join live readings to the history, compare in the
|
|
112
|
+
same frame: format `data.time` with local `Date` getters (as you build
|
|
113
|
+
`log_time` ranges), or parse `log_time` as local time.
|
|
96
114
|
|
|
97
115
|
### `deviceLightSensors` is a discriminated union
|
|
98
116
|
|
|
@@ -104,6 +122,13 @@ if (payload.type === 'actlumus2') {
|
|
|
104
122
|
}
|
|
105
123
|
```
|
|
106
124
|
|
|
125
|
+
**Units.** `photopic` and `melanopic` are lux (photopic illuminance and
|
|
126
|
+
melanopic EDI) — the same as `lux_photopic` / `lux_melanopic` in the history.
|
|
127
|
+
The `wavelength*` and `light_sensor_*` channels have **no physical unit**: they
|
|
128
|
+
are relative values (arbitrary units). Use them for the shape of the spectrum
|
|
129
|
+
and for comparisons on the same device generation; do not label them W/m², µW/cm²
|
|
130
|
+
or lux, and do not compare an ActLumus with an ActLumus Plus channel by channel.
|
|
131
|
+
|
|
107
132
|
A `null` in a raw channel means **that channel does not exist on this hardware
|
|
108
133
|
generation** — never "the reading was zero". `0.0` is a valid reading. Do not
|
|
109
134
|
collapse `null` to `0`: doing so fabricates data, and it has produced wrong
|
|
@@ -125,23 +150,144 @@ conclusions before.
|
|
|
125
150
|
off. Render a blocking card; wire its button to
|
|
126
151
|
`BridgeClient.requestPermission(name)` with a name taken from that array.
|
|
127
152
|
|
|
153
|
+
The names, for your labels (never send a name you typed — take it from the
|
|
154
|
+
arrays):
|
|
155
|
+
|
|
156
|
+
| Name | Platform | Essential? | What `requestPermission` does |
|
|
157
|
+
| --- | --- | --- | --- |
|
|
158
|
+
| `bluetoothScan`, `bluetoothConnect` | Android | yes | system prompt; app settings if permanently denied |
|
|
159
|
+
| `fineLocation` | Android ≤ 11 | yes | system prompt; app settings if permanently denied |
|
|
160
|
+
| `bluetooth` | iOS | yes | system prompt; app settings as fallback |
|
|
161
|
+
| `notification` | both | no | system prompt (Android) / iOS permission request |
|
|
162
|
+
| `batteryOptimization` | Android | no | battery-optimization exemption screen |
|
|
163
|
+
| `exactAlarm` | Android | no | exact-alarm settings, when battery optimization is not exempt |
|
|
164
|
+
| `backgroundLocation` | Android ≤ 11 | no | system prompt, else app settings |
|
|
165
|
+
|
|
166
|
+
An unknown name is dropped.
|
|
167
|
+
|
|
168
|
+
`deviceSyncErrorById` (and `deviceSyncError`) hold only the **last** error per
|
|
169
|
+
device, already in the app language (the app translates the `messageCode`; an
|
|
170
|
+
error with `messageCode: null` never lands here). The entry is removed when
|
|
171
|
+
newer telemetry of that device arrives — and a failed sync saves telemetry too
|
|
172
|
+
(with the error added to its `syncErrors`), so the entry can vanish seconds
|
|
173
|
+
after it appeared. Use it for a live banner, not as the record of the last
|
|
174
|
+
failure: that is the newest item of `devicesTelemetry[].syncErrors` (compare
|
|
175
|
+
its `attemptDate` with the telemetry's `updataDateTime` to know whether a
|
|
176
|
+
later sync succeeded). `deviceNotification` is the live feed of
|
|
177
|
+
a sync — see the next section.
|
|
178
|
+
|
|
179
|
+
### Sync progress: `deviceNotification`
|
|
180
|
+
|
|
181
|
+
Every BLE session of a device is one sync, and the app reports it step by step
|
|
182
|
+
— dozens of events per session, for **every** linked device (no subscription
|
|
183
|
+
needed). Payload: `{ serialNo, deviceId?, isConnect, message, messageCode,
|
|
184
|
+
isError }`.
|
|
185
|
+
|
|
186
|
+
- **Branch on `messageCode`**, never on `message`. `message` is the app's log
|
|
187
|
+
line in English (`[Start] Get data log`, `[Error] Send data log: …`), not
|
|
188
|
+
localized and not stable; show your own text per code. Some progress events
|
|
189
|
+
have `messageCode: null` — ignore them.
|
|
190
|
+
- **`isConnect` is not the connection state** here: it is `!isError` on pipeline
|
|
191
|
+
events. Read connection from `deviceConnection`.
|
|
192
|
+
- `deviceId` only when the `serialNo` is shared, as in every device event.
|
|
193
|
+
|
|
194
|
+
A successful session, in order:
|
|
195
|
+
|
|
196
|
+
| `messageCode` | Step |
|
|
197
|
+
| --- | --- |
|
|
198
|
+
| `null`, `message: 'Device connected'` | the session starts |
|
|
199
|
+
| `enable_ble_periodic_notification`, then `…_not_needed_notification` if there was nothing to do | device setup |
|
|
200
|
+
| `set_ble_period_notification`, then `…_not_needed_notification` likewise | device setup |
|
|
201
|
+
| `set_sync_time_notification`, then `set_sync_time_not_needed_notification` likewise | the clock (a pending `syncTime` runs here) |
|
|
202
|
+
| `get_data_log_notification`, `send_data_log_notification`, `set_last_data_log_sent_to_server_notification` | reading and uploading the measurements |
|
|
203
|
+
| `get_system_log_notification`, `send_system_log_notification` | system log |
|
|
204
|
+
| `get_full_charge_log_notification`, `send_full_charge_log_notification` | charge log |
|
|
205
|
+
| `set_erase_memory_notification`, `erase_memory_notification` (or `escape_erase_memory_notification`: postponed) | a pending `deleteMemory`, after the upload |
|
|
206
|
+
| `get_device_telemetry_notification`, `save_device_telemetry_notification` | battery, memory, last sync |
|
|
207
|
+
| **`pipeline_done_notification`** | **the sync succeeded** — the only success marker |
|
|
208
|
+
| `null`, `message: 'Device disconnected'`, `isConnect: false` | the session ends |
|
|
209
|
+
|
|
210
|
+
Every stage emits its `…_notification` code when it **starts**, whether or not
|
|
211
|
+
it has work to do; it does not mean "done". A stage is done when a later stage
|
|
212
|
+
starts without its `…_error`. The three log stages run in parallel: their events
|
|
213
|
+
interleave in any order. Codes may repeat; treat every event as idempotent.
|
|
214
|
+
|
|
215
|
+
**When is a pending `syncTime` / `deleteMemory` done?** Count it done when the
|
|
216
|
+
sync reaches `pipeline_done_notification`. If the sync fails first, it may still
|
|
217
|
+
be pending — the app keeps a request it could not carry out (a clock it could
|
|
218
|
+
not set, an erase postponed by `escape_erase_memory_notification` or by a failed
|
|
219
|
+
upload) and runs it at the next session. Say "will happen at the next sync"
|
|
220
|
+
until then. With a `syncTime` pending, the clock stage never reports
|
|
221
|
+
`set_sync_time_not_needed_notification`: it sets the clock.
|
|
222
|
+
|
|
223
|
+
**History and live readings overlap in time, not in data.** Live readings are
|
|
224
|
+
not logs and never enter the history. While it streams, the device keeps
|
|
225
|
+
logging at its own period; those logs reach the history when a sync uploads
|
|
226
|
+
them (`send_data_log`). So the history ends at the last upload, live readings
|
|
227
|
+
cover the time after it, and the next sync fills that span with logs. Prefer
|
|
228
|
+
the history for any span it covers, and use live readings only after its last
|
|
229
|
+
`log_time`.
|
|
230
|
+
|
|
231
|
+
**A failed sync** has no "failed" event: the failing stage emits
|
|
232
|
+
`<stage>_error` (`isError: true`) and **`pipeline_done_notification` never
|
|
233
|
+
comes**. Every `*_error` ends the sync — `enable_ble_periodic_error`,
|
|
234
|
+
`set_ble_period_error`, `set_sync_time_error`, `get_data_log_error`,
|
|
235
|
+
`send_data_log_error`, `set_last_data_log_sent_to_server_error`,
|
|
236
|
+
`get_system_log_error`, `send_system_log_error`,
|
|
237
|
+
`set_last_system_log_sent_to_server_error`, `get_full_charge_log_error`,
|
|
238
|
+
`send_full_charge_log_error`, `set_erase_memory_error`,
|
|
239
|
+
`get_device_telemetry_error` — except two, which are `isError: true` while the
|
|
240
|
+
sync goes on: `config_write_not_persisted` (a setting did not stick; the sync
|
|
241
|
+
continues to `pipeline_done_notification`) and `save_device_telemetry_error`
|
|
242
|
+
(the final save failed; the outcome is already decided). A failure loading the
|
|
243
|
+
device list ends the sync with `messageCode: null`. After an ending error the
|
|
244
|
+
app mutes that device's **pipeline** events for about 3 s, so a second error
|
|
245
|
+
from a parallel log stage may never reach you; the first one is the cause.
|
|
246
|
+
|
|
247
|
+
**The session start and end are the two events with `messageCode: null` you
|
|
248
|
+
can rely on**, without reading `message`:
|
|
249
|
+
- **end:** `isConnect: false` **and** `isError: false` — no other event has
|
|
250
|
+
that pair (pipeline events have `isConnect === !isError`). It is not muted.
|
|
251
|
+
- **start:** the first event of the device after an end (or after your bundle
|
|
252
|
+
loaded). A `deviceConnection` with `isConnect: true` says the same if you
|
|
253
|
+
subscribed with `add`.
|
|
254
|
+
|
|
255
|
+
Rule for a progress UI: "syncing" from the start, the step from each code,
|
|
256
|
+
**success** on `pipeline_done_notification`, **failure** when the end arrives
|
|
257
|
+
after an ending `*_error` without `pipeline_done_notification`. Keep a time-out
|
|
258
|
+
of your own (a few minutes without events): if the disconnect itself fails, the
|
|
259
|
+
app sends no end event.
|
|
260
|
+
|
|
128
261
|
**`hasDevices` is a hint.** It is `true` only after the app loaded a non-empty
|
|
129
262
|
device list from the server. `false` means "no linked device" **or** "the app
|
|
130
|
-
could not ask" (no internet, session problem).
|
|
131
|
-
|
|
263
|
+
could not ask" (no internet, session problem). From `nativeVersion` 0.18.0,
|
|
264
|
+
`hasDevicesKnown: true` says `hasDevices` is the server's answer, and `false`
|
|
265
|
+
says the lookup failed. Do not show "you have no device" on `hasDevices: false`
|
|
266
|
+
alone; see the next section.
|
|
132
267
|
|
|
133
268
|
### `userDevice` in detail
|
|
134
269
|
|
|
135
|
-
**
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
270
|
+
**From `nativeVersion` 0.18.0 it always answers.** The app re-sends a request
|
|
271
|
+
lost while its background service starts, and a failed lookup arrives as the
|
|
272
|
+
three arrays empty plus `error`:
|
|
273
|
+
|
|
274
|
+
| `error` | Meaning |
|
|
275
|
+
| --- | --- |
|
|
276
|
+
| `NO_DEVICES` | the server says the account has no linked device |
|
|
277
|
+
| `NETWORK` | no answer from the server (offline, timeout) or a 5xx |
|
|
278
|
+
| `SESSION` | no session, or the server ended it |
|
|
279
|
+
| `UNKNOWN` | anything else; treat unknown values the same way |
|
|
280
|
+
|
|
281
|
+
**Below 0.18.0 it is not guaranteed to answer.** `userDevice` (and `ping`) are
|
|
282
|
+
handled by the background service, which starts after your bundle is running;
|
|
283
|
+
a request sent before it is up is lost. And a failed lookup — including an
|
|
284
|
+
account with no device — gets **no reply at all**. Use the helper, which covers
|
|
285
|
+
both versions:
|
|
141
286
|
|
|
142
287
|
```ts
|
|
143
288
|
const r = await BridgeClient.requestUserDevices(); // getFromServer: false
|
|
144
|
-
// { status: 'ok', devices } | { status: '
|
|
289
|
+
// { status: 'ok', devices } | { status: 'no-devices' } | { status: 'error', code }
|
|
290
|
+
// | { status: 'timeout', hasDevices: boolean | null }
|
|
145
291
|
```
|
|
146
292
|
|
|
147
293
|
**`getFromServer`.** `false` (default) answers from the app's memory or cache
|
|
@@ -155,7 +301,7 @@ the server first and falls back to the cache if that fails — use it for a
|
|
|
155
301
|
| Array | Holds | Notable fields |
|
|
156
302
|
| --- | --- | --- |
|
|
157
303
|
| `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
|
|
304
|
+
| `devicesTelemetry` | last state read from each device | `deviceName`, `batteryLevel` (0–100 %), `memoryDataUsage` (0–100 %, the measurement memory — the one to show as "memory used"; `memorySystemUsage` is the device's internal log, diagnostics only), `updataDateTime` (epoch ms, last sync — the misspelling is the real name), `deviceVersion`, `isCharging`, `isOnDock`, `syncErrors[]` |
|
|
159
305
|
| `devicesUserConfig` | per-device toggles | `syncTime`, `realTime`, `deleteMemory`, `readAllMemory`, `readAllSystemLog` |
|
|
160
306
|
|
|
161
307
|
The name and battery of a device are in **telemetry**, not in the config. A
|
|
@@ -167,15 +313,82 @@ device that never synced may have no telemetry item, or one with every reading
|
|
|
167
313
|
only when that `serialNo` appears once in `devicesConfig`.
|
|
168
314
|
|
|
169
315
|
**Sync errors.** `devicesTelemetry[].syncErrors` is the per-device history
|
|
170
|
-
(`attemptDate`, `message`, `messageCode`)
|
|
316
|
+
(`attemptDate`, `message`, `messageCode`). `messageCode` is the `…_error` code
|
|
317
|
+
of the stage that failed — the same vocabulary as `deviceNotification` (see
|
|
318
|
+
"Sync progress") — or `null`; `message` is the app's English log line, not for
|
|
319
|
+
display. Map the codes to your own words; `systemStatus.deviceSyncErrorById`
|
|
171
320
|
is the current error per device, already localized. Show the current one; use
|
|
172
321
|
the history for detail screens.
|
|
173
322
|
|
|
323
|
+
### `language` — which language to render
|
|
324
|
+
|
|
325
|
+
The app tells you **two** languages, and the distinction between them is the
|
|
326
|
+
whole point of the channel:
|
|
327
|
+
|
|
328
|
+
```typescript
|
|
329
|
+
BridgeClient.on(BRIDGE_KEYS.language, ({ deviceLocale, appLanguage, resolvedLanguage }) => {
|
|
330
|
+
// Your precedence rule. This one honours an explicit choice, else the device.
|
|
331
|
+
const lang = appLanguage ?? pickSupported(deviceLocale) ?? 'en';
|
|
332
|
+
renderIn(lang);
|
|
333
|
+
});
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
- **`deviceLocale`** — the raw device locale as a BCP-47 tag (`"pt-BR"`,
|
|
337
|
+
`"es-419"`, `"ja-JP"`), exactly as the OS reports it, region included.
|
|
338
|
+
- **`appLanguage`** — the language the user explicitly picked in the app
|
|
339
|
+
Settings (`"en" | "pt" | "de" | "fr"`), or **`null`** when they never picked
|
|
340
|
+
one.
|
|
341
|
+
- **`resolvedLanguage`** — the language the native chrome (splash, Settings,
|
|
342
|
+
loading/error screens) is rendering right now.
|
|
343
|
+
|
|
344
|
+
**Why two raw values instead of one answer.** The app itself ships four
|
|
345
|
+
languages and collapses everything else to English. Your bundle has no such
|
|
346
|
+
limit — it may ship Spanish, Italian, Japanese. If we sent you only the resolved
|
|
347
|
+
language, a device set to Spanish would reach you as `en` and you would render
|
|
348
|
+
English while holding a Spanish bundle. The information would have been
|
|
349
|
+
destroyed on our side before reaching you. So we send both and let you decide:
|
|
350
|
+
|
|
351
|
+
| `deviceLocale` | `appLanguage` | a reasonable decision |
|
|
352
|
+
|---|---|---|
|
|
353
|
+
| `es-ES` | `null` | render **Spanish** — you have it, we don't. Our chrome stays English while your content is Spanish, which is better, not worse |
|
|
354
|
+
| `es-ES` | `"pt"` | render **Portuguese** — an explicit choice outranks the device |
|
|
355
|
+
| `pt-BR` | `null` | render Portuguese |
|
|
356
|
+
| `ja-JP` | `"de"` | render German (the explicit choice), or Japanese if you'd rather honour the device — your call, with both values in hand |
|
|
357
|
+
|
|
358
|
+
`appLanguage` is `null` when — and only when — the user never chose. It is never
|
|
359
|
+
a fallback in disguise, so `null` is a real signal you can branch on. Use
|
|
360
|
+
`resolvedLanguage` when you'd rather stay visually consistent with the native
|
|
361
|
+
frame around your page than follow the device.
|
|
362
|
+
|
|
363
|
+
**When it arrives — and how to ask for it.** The app pushes this key on attach,
|
|
364
|
+
again right after the handshake ack, and on every change in the app Settings, so
|
|
365
|
+
a page already loaded re-renders without a restart. But the first push happens
|
|
366
|
+
while the WebView is still being created, before your bundle exists, so **send
|
|
367
|
+
the `language` command once after your handshake** to pull the current state:
|
|
368
|
+
|
|
369
|
+
```typescript
|
|
370
|
+
BridgeClient.send('language', {}); // replies with a `language` event
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Without that pull, a first launch would show your default language until the
|
|
374
|
+
user changed it in Settings. Subscribe first, then pull.
|
|
375
|
+
|
|
376
|
+
Do not read `navigator.language` instead: it follows the system, not the in-app
|
|
377
|
+
choice, and you would end up in Spanish while the native chrome around you is in
|
|
378
|
+
Portuguese.
|
|
379
|
+
|
|
380
|
+
**Older app versions.** There is no version negotiation. An app older than
|
|
381
|
+
native `0.14.0` never emits this key, so the event simply never arrives — keep a
|
|
382
|
+
default language for that case, and read `nativeVersion` from the handshake ack
|
|
383
|
+
if you need to branch on it explicitly.
|
|
384
|
+
|
|
174
385
|
## Commands
|
|
175
386
|
|
|
176
387
|
`BridgeClient.send(command, args)` is **fire-and-forget**: no return value, no
|
|
177
388
|
exception, no delivery confirmation. Replies arrive on the key in the last
|
|
178
|
-
column.
|
|
389
|
+
column. Its arguments are typed per command (`CommandArgs`): a string
|
|
390
|
+
`serialNo`, a missing `value` or an unknown command is a compile error, not a
|
|
391
|
+
silent drop.
|
|
179
392
|
|
|
180
393
|
| Command | Arguments | Confirmation | Replies on |
|
|
181
394
|
| --- | --- | --- | --- |
|
|
@@ -184,7 +397,7 @@ column.
|
|
|
184
397
|
| `realTime` | `{ serialNo: number, deviceId?: number, value: boolean }` | no | `realTime` |
|
|
185
398
|
| `deviceConnection` | `{ type: 'add' \| 'remove', serialNo: number, deviceId?: number }` — subscribe to / stop following one device's connection state | no | `deviceConnection` |
|
|
186
399
|
| `sensorLog` | see below | no | `sensorLog` |
|
|
187
|
-
| `openSettings` | — | no | — |
|
|
400
|
+
| `openSettings` | — (opens the Canvas settings screen: language, notifications, Bluetooth, refresh device list, date format; not the OS settings, no logout) | no | — |
|
|
188
401
|
| `requestPermission` | `{ permission: string }` | no | — (re-emits `systemStatus`) |
|
|
189
402
|
| `setStatusBarColor` | `{ color: '#RRGGBB' }` | no | — |
|
|
190
403
|
| `navigationState` | `{ canGoBack: boolean }` | no | — |
|
|
@@ -199,9 +412,50 @@ column.
|
|
|
199
412
|
message in silence.
|
|
200
413
|
|
|
201
414
|
`deviceConnection` **pairs nothing.** `type: 'add'` asks the app to report the
|
|
202
|
-
BLE connection state of a device you already have from `userDevice
|
|
203
|
-
|
|
204
|
-
|
|
415
|
+
BLE connection state of a device you already have from `userDevice`. `'remove'`
|
|
416
|
+
stops it. Send `add` for each device after the list arrives.
|
|
417
|
+
|
|
418
|
+
- **`add` does not report the current state.** The app reports **changes**: a
|
|
419
|
+
device already connected (or already disconnected) when you subscribe sends
|
|
420
|
+
nothing until its next BLE session starts or ends. Start every device as
|
|
421
|
+
"connection unknown" and switch on the first event. The one exception: a
|
|
422
|
+
device the app does not know answers `isConnect: false` at once.
|
|
423
|
+
- **"Disconnected" is the normal state.** An ActLumus is not connected all the
|
|
424
|
+
time: it opens a short BLE session to sync every few minutes
|
|
425
|
+
(`devicesConfig[].bleConnectionPeriod`, in minutes) and the app disconnects
|
|
426
|
+
it as soon as the sync ends, successful or not. With `realTime` on, the app
|
|
427
|
+
does not disconnect it, but the session still ends within minutes (below).
|
|
428
|
+
Show the last sync time (`devicesTelemetry[].updataDateTime`) rather than a
|
|
429
|
+
red "offline" badge.
|
|
430
|
+
|
|
431
|
+
### What an ack means
|
|
432
|
+
|
|
433
|
+
`realTime`, `syncTime` and `deleteMemory` do not need the device to be
|
|
434
|
+
connected, and their ack does **not** mean "done":
|
|
435
|
+
|
|
436
|
+
| Command | `success: true` means | When it happens on the device |
|
|
437
|
+
| --- | --- | --- |
|
|
438
|
+
| `syncTime` | the request is recorded | at the next BLE session; the device clock is set to the phone's local time (its time zone included) |
|
|
439
|
+
| `deleteMemory` | the request is recorded | at the end of the next sync, **only after the data was uploaded**; if the upload fails, it stays pending and is retried. Unsent data is never erased. Not reversible once done. |
|
|
440
|
+
| `realTime` (`value: true`) | real time is switched on for that device | readings flow on `deviceLightSensors` while it is connected; it starts by itself at the next session if it is not connected now |
|
|
441
|
+
|
|
442
|
+
- `success: false` means the app could not record the request (for example,
|
|
443
|
+
a shared `serialNo` sent without `deviceId`), never "device not connected".
|
|
444
|
+
- **Do not disable these actions while the device is disconnected.** Say what
|
|
445
|
+
will happen instead: "The clock will be set at the next sync" / "The memory
|
|
446
|
+
will be erased after the next sync uploads its data".
|
|
447
|
+
- **A live session is short.** With `realTime` on, the app does not
|
|
448
|
+
disconnect the device after the sync, and switching `realTime` off ends the
|
|
449
|
+
session. But on a real device (an ActLumus Plus on an Android tablet, twice)
|
|
450
|
+
the session ended about **3 minutes** after it started, with `realTime` still
|
|
451
|
+
on — the link or the device ends it, not the app. Design "live" as a few
|
|
452
|
+
minutes per sync, not a continuous stream: say "connected now" only while
|
|
453
|
+
readings arrive, and expect the next readings at the next session
|
|
454
|
+
(`bleConnectionPeriod`). Readings stop when the session ends and resume at
|
|
455
|
+
its next session while `realTime` stays on. There is no "last connection" field: the last sync
|
|
456
|
+
(`devicesTelemetry[].updataDateTime`) is the closest one. The app switches `realTime` off for every
|
|
457
|
+
device when it restarts — switch it on again from your screen when you need
|
|
458
|
+
it, and off when the person leaves that screen.
|
|
205
459
|
|
|
206
460
|
### Two devices with the same `serialNo`
|
|
207
461
|
|
|
@@ -227,7 +481,9 @@ the wrong device (erasing the wrong memory, for instance).
|
|
|
227
481
|
unaddressable).
|
|
228
482
|
- `sensorLog` takes no `deviceId` yet: for a shared `serialNo` the backend
|
|
229
483
|
answers with the error code `DEVICE_SERIAL_AMBIGUOUS`. Show your own copy for
|
|
230
|
-
it; do not retry
|
|
484
|
+
it ("history is not available for these two devices yet"); do not retry, and
|
|
485
|
+
do not filter another query's result to work around it. Real time, commands
|
|
486
|
+
and the device list still work for both devices, by `deviceId`.
|
|
231
487
|
|
|
232
488
|
### Commands that need human confirmation
|
|
233
489
|
|
|
@@ -238,6 +494,13 @@ which reaches you only as a `console.warn`. **The ack simply never arrives.**
|
|
|
238
494
|
Design for that: show an optimistic state only if you can undo it, and put a
|
|
239
495
|
timeout on any spinner.
|
|
240
496
|
|
|
497
|
+
**Do not add your own confirmation dialog in front of these three.** The app's
|
|
498
|
+
dialog is the confirmation; a second one makes the person confirm twice. Explain
|
|
499
|
+
the consequence on your screen, next to the button, before it is pressed.
|
|
500
|
+
|
|
501
|
+
**`logout` has no reply.** Once confirmed, the app returns to its login screen
|
|
502
|
+
and your page is unloaded; nothing comes back. Show nothing that waits for it.
|
|
503
|
+
|
|
241
504
|
### Typed helpers
|
|
242
505
|
|
|
243
506
|
Prefer these over raw `send` — they add correlation, timeouts and types:
|
|
@@ -256,7 +519,6 @@ BridgeClient.openSettings()
|
|
|
256
519
|
|
|
257
520
|
```ts
|
|
258
521
|
const page = await BridgeClient.fetchSensorLog({
|
|
259
|
-
requestId: crypto.randomUUID(),
|
|
260
522
|
serialNo,
|
|
261
523
|
bodyPart: 'wrist', // or 'necklace'; omit for no filter
|
|
262
524
|
limit: 200, // 1..1000, backend default 10
|
|
@@ -266,7 +528,15 @@ const page = await BridgeClient.fetchSensorLog({
|
|
|
266
528
|
```
|
|
267
529
|
|
|
268
530
|
It **resolves** with `{ success: false, error }` on failure and only **rejects**
|
|
269
|
-
on timeout
|
|
531
|
+
on timeout (10 s by default; the app's own server timeout is 50 s, so raise
|
|
532
|
+
`timeoutMs` on slow networks rather than retrying at once). Check `success`
|
|
533
|
+
before reading `logs`. Leave `requestId` out: `fetchSensorLog` creates one
|
|
534
|
+
(`createRequestId()`, which does not need `crypto.randomUUID`).
|
|
535
|
+
|
|
536
|
+
There is no rate limit on the app side, but every page is a server request
|
|
537
|
+
under the person's single session: fetch pages one after another (they depend
|
|
538
|
+
on each other anyway), never several sweeps in parallel, and refresh on a
|
|
539
|
+
human scale — minutes, not seconds.
|
|
270
540
|
|
|
271
541
|
Two things that have caused real bugs:
|
|
272
542
|
|
|
@@ -274,6 +544,11 @@ Two things that have caused real bugs:
|
|
|
274
544
|
device RTC. Build your range with local `Date` getters — `getFullYear()`,
|
|
275
545
|
`getHours()` — and **never** with `toISOString()`. Converting shifts every
|
|
276
546
|
series by the timezone offset, producing plausible data and no error at all.
|
|
547
|
+
A "day" is the date part of `log_time` (`log_time.slice(0, 10)`): the
|
|
548
|
+
device's local day, on the clock `syncTime` set to the phone's local time. The
|
|
549
|
+
log carries no timezone, so after the participant changes timezone the logs
|
|
550
|
+
stay on the old clock until the next `syncTime` — say so if your report is per
|
|
551
|
+
day.
|
|
277
552
|
|
|
278
553
|
**Cursors are opaque.** `startingAfter` and `endingBefore` are mutually
|
|
279
554
|
exclusive; echo back exactly what you received and never construct one.
|
|
@@ -286,7 +561,7 @@ const logs: SensorLogItem[] = [];
|
|
|
286
561
|
let startingAfter: string | undefined;
|
|
287
562
|
for (;;) {
|
|
288
563
|
const page = await BridgeClient.fetchSensorLog({
|
|
289
|
-
|
|
564
|
+
serialNo, limit: 1000,
|
|
290
565
|
startTime: '2026-10-13 00:00:00', endTime: '2026-10-20 00:00:00', // local time
|
|
291
566
|
...(startingAfter ? { startingAfter } : {}),
|
|
292
567
|
});
|
|
@@ -318,9 +593,25 @@ Pages depend on the previous cursor, so they cannot be fetched in parallel.
|
|
|
318
593
|
**Fields.** Each log is a `SensorLogItem`: `log_time`, `body_part`,
|
|
319
594
|
`temperature` (°C), `lux_photopic` and `lux_melanopic` (lux), the raw light
|
|
320
595
|
channels and the activity counters. `null` on a channel means the hardware does
|
|
321
|
-
not have it, never zero. `
|
|
596
|
+
not have it, never zero. `device_id` is the server device id — the same value as
|
|
597
|
+
`devicesConfig[].deviceId` — so you can check that a log belongs to the device
|
|
598
|
+
you asked for. `device_log_time` and `app_log_time` mark the upload
|
|
322
599
|
batch, not the log — do not plot them.
|
|
323
600
|
|
|
601
|
+
**Was the device worn?** Read `off_wrist`, and mind its name: it is the
|
|
602
|
+
device's raw wear flag, **`1` = worn, `0` = not worn** (on its dock); treat any
|
|
603
|
+
other value as not worn and `null` as unknown. `body_part` (`'wrist'` or
|
|
604
|
+
`'necklace'`) is where the device was *set* to be worn when the log was uploaded
|
|
605
|
+
— a setting, not a detection; it can differ from today's `deviceBodyPart`.
|
|
606
|
+
|
|
607
|
+
**Time worn per day** (one reasonable recipe, not an official metric): count the
|
|
608
|
+
logs of that local day with `off_wrist === 1` and multiply by the log period
|
|
609
|
+
derived from consecutive `log_time` values. A missing log is time not recorded,
|
|
610
|
+
not time worn. Ask for the day with `startTime` = local midnight and `endTime`
|
|
611
|
+
= the next local midnight: `startTime` is inclusive and `endTime` exclusive.
|
|
612
|
+
At the default 60 s, one day is about 1 440 logs — two pages of 1 000 — and 14
|
|
613
|
+
days about 20 pages per device, so compute once and refresh on a human scale.
|
|
614
|
+
|
|
324
615
|
## Error shapes
|
|
325
616
|
|
|
326
617
|
There are three, and none of them is a thrown exception.
|