@condorinst/mobile-bridge 0.22.0 → 0.23.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.
@@ -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 | Current | What it means |
30
+ | Number | Latest | What it means |
31
31
  | --- | --- | --- |
32
- | `nativeVersion` | `0.17.0` | **The capability signal.** The bridge schema the app on this device implements. |
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.22.0` | The npm version you compiled against. |
35
+ | package | `0.23.0` | 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.17.0` by default; `--native-version null`
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). Do not show "you have no device"
131
- on `false` alone; see the next section.
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
- **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:
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: 'timeout', hasDevices: boolean | null }
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 %), `updataDateTime` (epoch ms, last sync — the misspelling is the real name), `deviceVersion`, `isCharging`, `isOnDock`, `syncErrors[]` |
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`); `systemStatus.deviceSyncErrorById`
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,46 @@ 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`; 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.
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 — unless `realTime` is on for
427
+ that device (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
+ - **`realTime` keeps the session open.** With it on, the app does not
448
+ disconnect the device after the sync: readings keep flowing until you switch
449
+ `realTime` off (then the session ends) or the BLE link drops. Readings stop
450
+ when the session ends and resume at its next session while `realTime` stays
451
+ on. There is no "last connection" field: the last sync
452
+ (`devicesTelemetry[].updataDateTime`) is the closest one. The app switches `realTime` off for every
453
+ device when it restarts — switch it on again from your screen when you need
454
+ it, and off when the person leaves that screen.
205
455
 
206
456
  ### Two devices with the same `serialNo`
207
457
 
@@ -227,7 +477,9 @@ the wrong device (erasing the wrong memory, for instance).
227
477
  unaddressable).
228
478
  - `sensorLog` takes no `deviceId` yet: for a shared `serialNo` the backend
229
479
  answers with the error code `DEVICE_SERIAL_AMBIGUOUS`. Show your own copy for
230
- it; do not retry.
480
+ it ("history is not available for these two devices yet"); do not retry, and
481
+ do not filter another query's result to work around it. Real time, commands
482
+ and the device list still work for both devices, by `deviceId`.
231
483
 
232
484
  ### Commands that need human confirmation
233
485
 
@@ -238,6 +490,13 @@ which reaches you only as a `console.warn`. **The ack simply never arrives.**
238
490
  Design for that: show an optimistic state only if you can undo it, and put a
239
491
  timeout on any spinner.
240
492
 
493
+ **Do not add your own confirmation dialog in front of these three.** The app's
494
+ dialog is the confirmation; a second one makes the person confirm twice. Explain
495
+ the consequence on your screen, next to the button, before it is pressed.
496
+
497
+ **`logout` has no reply.** Once confirmed, the app returns to its login screen
498
+ and your page is unloaded; nothing comes back. Show nothing that waits for it.
499
+
241
500
  ### Typed helpers
242
501
 
243
502
  Prefer these over raw `send` — they add correlation, timeouts and types:
@@ -256,7 +515,6 @@ BridgeClient.openSettings()
256
515
 
257
516
  ```ts
258
517
  const page = await BridgeClient.fetchSensorLog({
259
- requestId: crypto.randomUUID(),
260
518
  serialNo,
261
519
  bodyPart: 'wrist', // or 'necklace'; omit for no filter
262
520
  limit: 200, // 1..1000, backend default 10
@@ -266,7 +524,15 @@ const page = await BridgeClient.fetchSensorLog({
266
524
  ```
267
525
 
268
526
  It **resolves** with `{ success: false, error }` on failure and only **rejects**
269
- on timeout. Check `success` before reading `logs`.
527
+ on timeout (10 s by default; the app's own server timeout is 50 s, so raise
528
+ `timeoutMs` on slow networks rather than retrying at once). Check `success`
529
+ before reading `logs`. Leave `requestId` out: `fetchSensorLog` creates one
530
+ (`createRequestId()`, which does not need `crypto.randomUUID`).
531
+
532
+ There is no rate limit on the app side, but every page is a server request
533
+ under the person's single session: fetch pages one after another (they depend
534
+ on each other anyway), never several sweeps in parallel, and refresh on a
535
+ human scale — minutes, not seconds.
270
536
 
271
537
  Two things that have caused real bugs:
272
538
 
@@ -274,6 +540,11 @@ Two things that have caused real bugs:
274
540
  device RTC. Build your range with local `Date` getters — `getFullYear()`,
275
541
  `getHours()` — and **never** with `toISOString()`. Converting shifts every
276
542
  series by the timezone offset, producing plausible data and no error at all.
543
+ A "day" is the date part of `log_time` (`log_time.slice(0, 10)`): the
544
+ device's local day, on the clock `syncTime` set to the phone's local time. The
545
+ log carries no timezone, so after the participant changes timezone the logs
546
+ stay on the old clock until the next `syncTime` — say so if your report is per
547
+ day.
277
548
 
278
549
  **Cursors are opaque.** `startingAfter` and `endingBefore` are mutually
279
550
  exclusive; echo back exactly what you received and never construct one.
@@ -286,7 +557,7 @@ const logs: SensorLogItem[] = [];
286
557
  let startingAfter: string | undefined;
287
558
  for (;;) {
288
559
  const page = await BridgeClient.fetchSensorLog({
289
- requestId: crypto.randomUUID(), serialNo, limit: 1000,
560
+ serialNo, limit: 1000,
290
561
  startTime: '2026-10-13 00:00:00', endTime: '2026-10-20 00:00:00', // local time
291
562
  ...(startingAfter ? { startingAfter } : {}),
292
563
  });
@@ -318,9 +589,25 @@ Pages depend on the previous cursor, so they cannot be fetched in parallel.
318
589
  **Fields.** Each log is a `SensorLogItem`: `log_time`, `body_part`,
319
590
  `temperature` (°C), `lux_photopic` and `lux_melanopic` (lux), the raw light
320
591
  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
592
+ not have it, never zero. `device_id` is the server device id — the same value as
593
+ `devicesConfig[].deviceId` — so you can check that a log belongs to the device
594
+ you asked for. `device_log_time` and `app_log_time` mark the upload
322
595
  batch, not the log — do not plot them.
323
596
 
597
+ **Was the device worn?** Read `off_wrist`, and mind its name: it is the
598
+ device's raw wear flag, **`1` = worn, `0` = not worn** (on its dock); treat any
599
+ other value as not worn and `null` as unknown. `body_part` (`'wrist'` or
600
+ `'necklace'`) is where the device was *set* to be worn when the log was uploaded
601
+ — a setting, not a detection; it can differ from today's `deviceBodyPart`.
602
+
603
+ **Time worn per day** (one reasonable recipe, not an official metric): count the
604
+ logs of that local day with `off_wrist === 1` and multiply by the log period
605
+ derived from consecutive `log_time` values. A missing log is time not recorded,
606
+ not time worn. Ask for the day with `startTime` = local midnight and `endTime`
607
+ = the next local midnight: `startTime` is inclusive and `endTime` exclusive.
608
+ At the default 60 s, one day is about 1 440 logs — two pages of 1 000 — and 14
609
+ days about 20 pages per device, so compute once and refresh on a human scale.
610
+
324
611
  ## Error shapes
325
612
 
326
613
  There are three, and none of them is a thrown exception.