@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 CHANGED
@@ -2,12 +2,25 @@
2
2
 
3
3
  You are writing the **Home screen** of a Condor Canvas app. It is a single web
4
4
  bundle that renders inside a sandboxed iframe in a native mobile app. The app
5
- owns login, settings and the Bluetooth connection to the devices; you own what
6
- the person sees on the Home.
5
+ owns login, its settings screen, Bluetooth and background collection; you own
6
+ everything the person sees after login, including the way to those settings and
7
+ the way out of the account.
7
8
 
8
- This file is the contract. Read it before writing code. Everything here is
9
- enforced, either by a gate in the app or by the browser runtime on a real
10
- device, where mistakes are expensive to diagnose.
9
+ This file is the procedure: read it before writing code, top to bottom. Each
10
+ subject has one owner, so look things up there instead of guessing:
11
+
12
+ | Subject | Owner |
13
+ | --- | --- |
14
+ | What each key, command, ack and error means | `docs/bridge-contract.md` (and `contract/bridge-contract.json`) |
15
+ | Payload shapes, field by field | `dist/index.d.ts` — your editor shows them |
16
+ | Harness, scripted tests, `validate`, publishing, the phone | `docs/device-testing.md` |
17
+ | A symptom you do not understand | `docs/troubleshooting.md` |
18
+ | Sizes, rates and timeouts | `docs/limits.md` |
19
+ | A working client to copy | `examples/minimal-home/` |
20
+ | All of it in one file | `llms-full.txt` |
21
+
22
+ Everything here is enforced, either by a gate in the app or by the browser
23
+ runtime on a real device, where mistakes are expensive to diagnose.
11
24
 
12
25
  ## The one thing that makes this different
13
26
 
@@ -19,11 +32,34 @@ So never write code whose correctness you infer from the absence of an error.
19
32
  Drive the UI from the events that arrive, give every wait a deadline, and
20
33
  always have a screen for "it did not come".
21
34
 
35
+ ## Your bundle is the whole screen
36
+
37
+ The app draws **nothing** around your page except the status bar strip (its
38
+ color is yours: `setStatusBarColor`) and the system safe areas, which it already
39
+ keeps clear — do not pad for them again. There is no native title, no back
40
+ arrow, no menu. So your bundle must provide:
41
+
42
+ - **a header and navigation** between your screens, with the Android back button
43
+ wired (`reportNavigation` / `onBack`): back goes one screen up, and only on
44
+ your home does it leave;
45
+ - **a way to log out** — `send('logout')` is the **only** way out of the account;
46
+ the app has no logout button of its own;
47
+ - **a way to the app settings** — `openSettings()` opens the Canvas settings
48
+ screen (language, notifications, Bluetooth, refresh the device list, date
49
+ format);
50
+ - **a way to refresh** the device list (`requestUserDevices({ getFromServer: true })`)
51
+ and to grant missing permissions (`requestPermission`).
52
+
53
+ A bundle without these traps the person inside it.
54
+
22
55
  ## Hard rules
23
56
 
24
57
  1. **No storage.** `localStorage`, `sessionStorage`, `indexedDB` and cookies all
25
- fail — the iframe has a `null` origin. Keep state in memory and accept that
26
- it is lost on reload. Re-request what you need at startup.
58
+ fail — the iframe has a `null` origin — and the bridge has no storage
59
+ command: there is no channel to keep anything. Keep state in memory and
60
+ accept that it is lost whenever the app reloads the page (an app restart,
61
+ for one). Re-request what you need at startup; if the person typed
62
+ something, say on screen that it is not kept.
27
63
  2. **No network.** `connect-src 'self'` on a null origin blocks every `fetch`,
28
64
  `XMLHttpRequest` and `WebSocket`. All data comes through the bridge.
29
65
  3. **No `eval`, no `new Function`.** The CSP has no `'unsafe-eval'`. Configure
@@ -42,6 +78,16 @@ always have a screen for "it did not come".
42
78
  branch on it. See the capability matrix in `docs/bridge-contract.md`.
43
79
  9. **Never ask once and wait.** Use `BridgeClient.requestUserDevices()` for the
44
80
  device list; a single `send('userDevice')` can be lost (see below).
81
+ 10. **No `crypto.randomUUID`.** It exists only in a secure context, and the
82
+ app's iframe is probably not one — it works in the harness on `localhost` and
83
+ may be `undefined` on the phone. `fetchSensorLog` creates its `requestId`;
84
+ use `createRequestId()` or `crypto.getRandomValues` for anything else.
85
+ 11. **Update in place; don't rebuild the screen on every event.** Events arrive
86
+ on their own (`systemStatus`, telemetry, a reading per second), and
87
+ replacing the node under the user's finger loses the tap. Rebuild a view
88
+ when the user navigates; on an event, change the text and attributes that
89
+ moved. The example rebuilds with `innerHTML`, which is fine for a screen
90
+ with no live readings, not for one that updates every second.
45
91
 
46
92
  ## What you cannot reach
47
93
 
@@ -50,19 +96,56 @@ always have a screen for "it did not come".
50
96
  camera, microphone and geolocation (denied unconditionally) · the `dfu` key and
51
97
  command (denied by policy).
52
98
 
99
+ **Nothing leaves the screen — by design, for security.** The iframe is
100
+ `sandbox="allow-scripts"` and nothing more, and the bridge has no share,
101
+ open-URL, copy or save command. Do not look for a way around it; if a client
102
+ needs one of these, it is a request to Condor:
103
+
104
+ | You want to | On the phone |
105
+ | --- | --- |
106
+ | Download a file (`<a download>`, a `blob:`/`data:` URL) | blocked: no `allow-downloads`, and the app registers no download handler |
107
+ | Open a link (`target=_blank`, `window.open`, `_top`) | blocked: no `allow-popups`, no `allow-top-navigation`. A plain `<a href="https://…">` navigates **your iframe** away and the user loses your screen — don't render external links as links |
108
+ | `navigator.share` | treat as unavailable: no `allow="web-share"` |
109
+ | `navigator.clipboard.writeText` | unavailable: no `allow="clipboard-write"`, so the permissions policy rejects it with `NotAllowedError` (measured in the harness, which has the same iframe) |
110
+ | `document.execCommand('copy')` on a selected `<textarea>`, inside a tap handler | works in the harness (desktop Chrome); **not measured on a device** |
111
+
112
+ To hand data to the user, show it: a read-only `<textarea>` with the text
113
+ (CSV, a code) that the user can select and copy, plus an `execCommand('copy')`
114
+ button that says when it failed. Say in your README that export is copy-only.
115
+ `img-src` is `'self' data:` (no `blob:`): draw a chart on a `<canvas>` or use
116
+ a `data:` URL, never `URL.createObjectURL`.
117
+
53
118
  ## The golden path
54
119
 
120
+ Start from the example — it already has this boot sequence and a single-file
121
+ build. Copy it, then install, so its `package.json` is the one you keep:
122
+
55
123
  ```bash
56
- npm install @condorinst/mobile-bridge@^0.22.0
124
+ mkdir my-home && cd my-home
125
+ npm pack @condorinst/mobile-bridge@^0.23.1 && tar -xzf condorinst-mobile-bridge-*.tgz
126
+ cp -r package/examples/minimal-home/. . && rm -rf package condorinst-mobile-bridge-*.tgz
127
+ npm install
57
128
  ```
58
129
 
130
+ In a project you already have: `npm install @condorinst/mobile-bridge@^0.23.1`
131
+ (the caret gives you patches only while the package is below 1.0.0) and copy
132
+ `build.mjs` from `node_modules/@condorinst/mobile-bridge/examples/minimal-home/`.
133
+
134
+ **Render first.** Paint the whole screen with no data — empty list, unknown
135
+ status, default language — then fill it as events arrive. Events are emitted
136
+ before your bundle exists, so there is always a window with nothing.
137
+
59
138
  ```ts
60
139
  import {
61
140
  BridgeClient, BRIDGE_KEYS, versionAtLeast, type UserDevicePayload,
62
141
  } from '@condorinst/mobile-bridge';
63
142
 
64
- // 1. Subscribe before you ask for anything. Roster changes arrive here too.
65
- BridgeClient.on(BRIDGE_KEYS.userDevice, (devices: UserDevicePayload) => renderDevices(devices));
143
+ // 1. Subscribe before you ask for anything. Roster changes arrive here too —
144
+ // and, from app 0.18.0, failures: `error` set + empty arrays. Only
145
+ // `NO_DEVICES` means "no devices"; on any other error keep what is on screen.
146
+ BridgeClient.on(BRIDGE_KEYS.userDevice, (devices: UserDevicePayload) => {
147
+ if (devices.error === undefined || devices.error === 'NO_DEVICES') renderDevices(devices);
148
+ });
66
149
  BridgeClient.on(BRIDGE_KEYS.systemStatus, (s) => renderEnvironment(s)); // bluetooth, internet, permissions
67
150
 
68
151
  // 2. Handshake. nativeVersion is the ONLY capability signal; ack.accepted is
@@ -72,11 +155,13 @@ const canAddressById = versionAtLeast(ack.nativeVersion, '0.17.0'); // null →
72
155
 
73
156
  // 3. Ask for the devices with the helper. It resends until they arrive.
74
157
  const result = await BridgeClient.requestUserDevices();
75
- if (result.status === 'timeout') {
76
- // No reply in 30 s. hasDevices === false suggests an account with no device;
77
- // otherwise the app could not load the list. Either way: show an empty state
78
- // with a "Try again" button that calls requestUserDevices({ getFromServer: true }).
79
- renderEmpty(result.hasDevices === false);
158
+ if (result.status === 'no-devices') {
159
+ renderEmpty('No device is linked to your account yet.'); // server confirmed (app 0.18.0+)
160
+ } else if (result.status === 'error' || result.status === 'timeout') {
161
+ // error: NETWORK | SESSION | UNKNOWN (app 0.18.0+). timeout: no reply in 30 s,
162
+ // the only failure an older app can produce. Always offer "Try again", which
163
+ // calls requestUserDevices({ getFromServer: true }).
164
+ renderEmpty('Could not load your devices.');
80
165
  } else {
81
166
  // 4. Subscribe to the connection state of each device (`add` pairs nothing).
82
167
  for (const d of result.devices.devicesConfig) {
@@ -88,9 +173,15 @@ if (result.status === 'timeout') {
88
173
 
89
174
  // 5. History: one page per call; it resolves { success: false, error } on
90
175
  // failure and only rejects on timeout.
91
- const page = await BridgeClient.fetchSensorLog({ requestId: crypto.randomUUID(), serialNo });
176
+ const page = await BridgeClient.fetchSensorLog({ serialNo }) // requestId is created for you;
92
177
  ```
93
178
 
179
+ `on` returns an unsubscribe function: call it when a view unmounts, or handlers
180
+ pile up. **Permissions:** when `systemStatus.missingEssentialPermissions` is not
181
+ empty, background collection is off — show a blocking card whose button calls
182
+ `BridgeClient.requestPermission(name)`. There is no result event: the app
183
+ re-emits `systemStatus`, and the card goes when the array empties.
184
+
94
185
  **Why the helper.** Device data comes from a background service that the app
95
186
  starts *after* your bundle is already running. Anything you send to it before it
96
187
  is up is lost without a reply — while the handshake, `systemStatus` and
@@ -111,7 +202,17 @@ with `toISOString()` or a `'Z'` suffix.
111
202
 
112
203
  Three commands open a confirmation dialog owned by Condor: `syncTime`,
113
204
  `deleteMemory` and `logout`. If the person declines, you get nothing — the ack
114
- never arrives. Do not block your UI on them.
205
+ never arrives. Do not block your UI on them, and do not add a second
206
+ confirmation of your own: explain the consequence next to the button instead.
207
+ `logout` never answers — the app unloads your page.
208
+
209
+ **An ActLumus is disconnected most of the time** — it connects for a short sync
210
+ every few minutes. `realTime`, `syncTime` and `deleteMemory` work anyway: the ack
211
+ means "recorded", and the app carries it out at the device's next session
212
+ (`deleteMemory` only after that sync uploaded the data). Never disable them for
213
+ a disconnected device. `deviceConnection` `add` reports only *changes*: start
214
+ each device as "connection unknown". Details: `docs/bridge-contract.md`,
215
+ "What an ack means".
115
216
 
116
217
  ## Develop in the harness, not in a bare browser tab
117
218
 
@@ -126,9 +227,9 @@ once outside the shell. Rebuild and reload; the harness re-reads the file.
126
227
 
127
228
  | Scenario | What it reproduces |
128
229
  | --- | --- |
129
- | `default` | two devices, realistic latency |
230
+ | `default` | two devices, realistic latency; the first starts a BLE session after 3 s, the second stays between sessions |
130
231
  | `cold-start` | the background service takes 4 s; earlier `ping`/`userDevice` are lost |
131
- | `no-devices` | `userDevice` never answers; `hasDevices: false` |
232
+ | `no-devices` | `userDevice` answers `error: 'NO_DEVICES'` (`--native-version 0.17.0`: never answers); `hasDevices: false` |
132
233
  | `shared-serial` | an ActLumus and a Plus share serial 7047 |
133
234
  | `offline` | no internet; `sensorLog` fails |
134
235
 
@@ -137,6 +238,11 @@ answered and dropped. The **◀ OS back** button at the top presses the OS back
137
238
  button. `--native-version null` (or `0.15.0`, …) makes the handshake report that
138
239
  version, to test your capability branches.
139
240
 
241
+ The harness does not change state by itself after load: drive internet loss,
242
+ disconnects, readings and the confirmation dialog from a script through
243
+ `window.condorDev` — what it simulates, the scripted-test recipe and your own
244
+ scenario files are in `docs/device-testing.md`.
245
+
140
246
  ## Publishing — ask the human, never run it yourself
141
247
 
142
248
  `npx condor-mobile-bridge canvas publish` puts the bundle live at once for every
@@ -169,20 +275,13 @@ check, so a clean run is necessary, not sufficient.
169
275
  - [ ] Every capability beyond the baseline is guarded by a `nativeVersion` check
170
276
  (`versionAtLeast`), and the page still renders with
171
277
  `--native-version null`.
172
- - [ ] Nothing waits forever on a reply that may never come.
278
+ - [ ] Nothing waits forever on a reply that may never come — the three
279
+ confirmable commands may never ack, because the person can decline.
280
+ - [ ] The person can navigate back, reach the app settings and log out from
281
+ your screens ("Your bundle is the whole screen").
282
+ - [ ] `serialNo` is a number everywhere; commands carry `deviceId` too when
283
+ `nativeVersion` ≥ 0.17.0.
284
+ - [ ] Unexpected errors go to `BridgeClient.send('jsError', …)`, your only
285
+ channel out.
173
286
 
174
287
  To put the bundle on a phone, read `docs/device-testing.md`.
175
-
176
- ## Where to read more
177
-
178
- | Question | File |
179
- | --- | --- |
180
- | Every key, command, error shape and capability | `docs/bridge-contract.md` |
181
- | The same, as JSON you can parse | `contract/bridge-contract.json` |
182
- | Step-by-step build of a Home | `docs/agent-guide.md` |
183
- | "My command did nothing" / "the device list never came" | `docs/troubleshooting.md` |
184
- | Getting the bundle onto a device, and seeing its errors | `docs/device-testing.md` |
185
- | Every hard limit, with the code that enforces it | `docs/limits.md` |
186
- | Payload shapes, authoritative | `dist/index.d.ts` (your editor shows them) |
187
- | A working client you can copy | `examples/minimal-home/` |
188
- | All of the above in one file | `llms-full.txt` |
package/CHANGELOG.md CHANGED
@@ -7,6 +7,193 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.23.1] — 2026-10-21
11
+
12
+ ### Fixed (docs, harness)
13
+
14
+ - A live session is short. 0.23.0 said that with `realTime` on the session
15
+ stays open until `realTime` goes off. On a real device (an ActLumus Plus on
16
+ an Android tablet, twice) it ended about 3 minutes after it started, with
17
+ `realTime` still on: the app does not end it, the link or the device does.
18
+ The docs now say to design live readings as a few minutes per sync.
19
+ - Harness: with `realTime` on, a simulated session ends 3 minutes after it
20
+ started (`Scenario.realTimeSessionMs`), or when `realTime` goes off.
21
+
22
+ ## [0.23.0] — 2026-10-21
23
+
24
+ For Canvas apps with `nativeVersion` **0.18.0**, which fix the silent
25
+ `userDevice` on the native side.
26
+
27
+ ### Added
28
+
29
+ - `UserDevicePayload.error` (`UserDeviceErrorCode`: `NO_DEVICES`, `NETWORK`,
30
+ `SESSION`, `UNKNOWN`): a failed lookup now arrives as empty arrays plus
31
+ `error` instead of silence.
32
+ - `SystemStatusPayload.hasDevicesKnown`: whether `hasDevices` is the server's
33
+ answer.
34
+ - `BridgeClient.requestUserDevices()` resolves `{ status: 'no-devices' }` and
35
+ `{ status: 'error', code }`; the result type is exported as
36
+ `UserDevicesResult`. On older apps it behaves as in 0.21.0 (`ok` or
37
+ `timeout`), and `timeout.hasDevices` is `null` when the app said
38
+ `hasDevicesKnown: false`.
39
+ - Contract capabilities `userDeviceAlwaysReplies`, `hasDevicesKnown` and
40
+ `userDevicePayloadAllowlist` (`minNativeVersion` 0.18.0).
41
+ - `docs/device-testing.md`: loading the bundle from your machine on a
42
+ development build (`WL_BUNDLE_DEV_URL` + `adb reverse`).
43
+ - Harness: `window.condorDev` on the `dev` page (`setStatus`, `setConnected`,
44
+ `emit`, `setConfirmations`, `pressBack`, `log`, `ready`) and `?confirm=accept|decline`,
45
+ for scripted tests; `DevHarness.setStatus`, `setConnected` and
46
+ `setConfirmations`; `lightSensorsReading()` fixture. Recipe in
47
+ `docs/device-testing.md`, "Scripted tests".
48
+ - `dev --latency <ms>`, `dev --no-panel`, `dev --scenario <file.json>` (a scenario
49
+ that extends a named one) and the URL parameters `latency`, `panel`;
50
+ `condorDev.ready` waits for the handshake; `log` entries carry the complete
51
+ `args` / `payload`.
52
+ - `createRequestId()`; `fetchSensorLog` creates the `requestId` when it is left
53
+ out (`SensorLogFetchQuery`).
54
+ - `validate` warns on `crypto.randomUUID` (`crypto-randomuuid`): it exists only in
55
+ a secure context, and the app's iframe is probably not one. The harness removes
56
+ it from the iframe, which on `localhost` would otherwise hide the problem.
57
+ - Docs: "Your bundle is the whole screen" in `AGENTS.md` (navigation, logout,
58
+ settings and refresh are the bundle's job); the permission names; what
59
+ `openSettings` opens; `syncTime` uses the phone's local time; `deviceNotification`
60
+ vs `deviceSyncErrorById` vs `syncErrors`; `memoryDataUsage` vs
61
+ `memorySystemUsage`; real-time cadence; `sensorLog` timeouts and pacing; the
62
+ `validate` rules (a drift test keeps the list complete).
63
+ - Docs: what an ack of `realTime`, `syncTime` and `deleteMemory` means ("recorded",
64
+ carried out at the device's next session; `deleteMemory` only after the upload),
65
+ `deviceConnection` reports only changes, `logout` has no reply, no second
66
+ confirmation dialog, `SensorLogItem.device_id` = `devicesConfig[].deviceId`.
67
+
68
+ ### Documentation
69
+
70
+ - **One owner per subject.** `AGENTS.md` is the procedure for agents and people
71
+ alike, with a table of which document owns what; `docs/agent-guide.md` (a
72
+ second copy of the procedure that kept drifting) and `docs/client-guide.md`
73
+ are now short pointers. The `language` section moved to
74
+ `docs/bridge-contract.md`; what `build` does moved to `docs/device-testing.md`;
75
+ `docs/pilot-onboarding.md` is the process only. The README is the npm landing
76
+ page; the shell-side material (`BridgeShell`, legacy `Bridge`, mock mode, dev
77
+ panel) moved to `docs/shell-integration.md`, outside `llms-full.txt`.
78
+ `llms-full.txt` shrank by about a third.
79
+ - A drift test checks every relative link in the docs.
80
+
81
+ ### Added (round 4)
82
+
83
+ - `SensorLogItem.off_wrist` and `body_part` documented (types, contract, docs):
84
+ `off_wrist` is the raw wear flag — `1` = worn, `0` = on the dock — and
85
+ `body_part` is the configured placement, not a detection. A recipe for time
86
+ worn per day; `endTime` is exclusive.
87
+ - Harness history is configurable per scenario (`history`: `days`, `dockHours`,
88
+ `gapHours`, `gapDates`; `sensorLogFailOnRequest`). The default history now has
89
+ a dock hour (07:00, `off_wrist: 0`) and `body_part` follows each device's
90
+ `deviceBodyPart` — the Plus fixture was set to the necklace but logged
91
+ `wrist`. Pages stay full across gaps.
92
+ - `docs/device-testing.md`: what the default history contains, and the harness
93
+ behaviours a test trips on (language from the browser, declined confirmation,
94
+ the 2 s `null` handshake, the 500-entry log).
95
+
96
+ ### Added (round 5)
97
+
98
+ - Sync progress documented ("Sync progress: `deviceNotification`" in
99
+ `docs/bridge-contract.md`): the steps of a session in order, the codes, the
100
+ only success marker (`pipeline_done_notification`), what a failed sync looks
101
+ like, and that `message` is an English log line — the JSDoc said it was
102
+ localized. `isConnect` on sync steps is `!isError`, not the connection.
103
+ - The harness emits that feed on every simulated session (`setConnected`),
104
+ with a pending `syncTime`/`deleteMemory` as its step; `sessionFailAt` makes
105
+ syncs fail at a stage, and without internet they fail at `send_data_log`.
106
+ - `AGENTS.md`: what the iframe cannot do (download, open a link, share,
107
+ `navigator.clipboard` — measured as blocked in the harness), with a copy-only
108
+ export recipe; rule 11, update in place instead of rebuilding on every event.
109
+ - `docs/bridge-contract.md`: a report "day" is the date part of `log_time`.
110
+
111
+ ### Added (round 8)
112
+
113
+ - `syncErrors[].messageCode` is the failing stage's `…_error` code (the
114
+ `deviceNotification` vocabulary); the harness fixture used `SYNC_FAILED`,
115
+ which the app never sends — now `send_data_log_error`.
116
+ - History vs live readings: they overlap in time, not in data; prefer the
117
+ history for any span it covers.
118
+ - `AGENTS.md` rule 1: no persistence channel at all, and what to tell the user.
119
+ - A pending `syncTime` never reports `set_sync_time_not_needed_notification`.
120
+
121
+ ### Added (round 7)
122
+
123
+ - Session lifetime documented: the app disconnects a device as soon as its sync
124
+ ends, unless `realTime` is on for it — then the session stays open until
125
+ `realTime` goes off or the link drops (docs, contract).
126
+ - Every sync stage emits its `…_notification` code when it **starts**, then
127
+ `…_not_needed_notification` if it had nothing to do; a pending `syncTime` /
128
+ `deleteMemory` counts as done at `pipeline_done_notification`, and a failed
129
+ sync keeps it pending.
130
+ - Harness: simulated sessions emit each stage's start code (and "not needed"),
131
+ and end by themselves after the sync unless `realTime` is on; switching
132
+ `realTime` off ends a session it kept open.
133
+
134
+ ### Added (round 6)
135
+
136
+ - `docs/bridge-contract.md`, sync progress: which `*_error` end a sync and the
137
+ two that don't (`config_write_not_persisted`, `save_device_telemetry_error`);
138
+ the session end is the only event with `isConnect: false` and
139
+ `isError: false`, and it is not muted — no need to read `message`.
140
+ - `deviceLightSensors` `data.time` documented: the phone's receive time, epoch
141
+ ms UTC (types and docs).
142
+ - No order is guaranteed between events at start (`systemStatus` vs the
143
+ `userDevice` answer).
144
+ - `deviceSyncErrorById` can be cleared seconds after a failed sync (the failure
145
+ saves telemetry too); the durable record is `devicesTelemetry[].syncErrors`.
146
+ - Harness: a failed simulated sync sets `deviceSyncErrorById` /
147
+ `deviceSyncError` and emits `systemStatus`; the next good sync clears it.
148
+
149
+ ### Fixed (harness)
150
+
151
+ - The `systemStatus` and `language` re-emitted after the handshake were
152
+ snapshots taken before it, so a `setStatus` made in between was undone.
153
+ - `deviceConnection`, `deviceNotification` and `deviceLightSensors` carry
154
+ `deviceId` only when the `serialNo` is shared, as in the app (it was on every
155
+ event from 0.17.0). A repeated `setConnected` with the same state emits
156
+ nothing.
157
+ - `docs/device-testing.md`: `?scenario=` in the URL replaces a `--scenario`
158
+ file, and the puppeteer recipe no longer passes it; `history.days` goes to 30.
159
+
160
+ - Only the `default` scenario starts a BLE session by itself (after 3 s), as the
161
+ docs said; every scenario did, which made tests of `shared-serial` flaky.
162
+ `Scenario.firstSessionAfterMs` sets it (`null` in a scenario file turns it off).
163
+
164
+ ### Fixed
165
+
166
+ - `examples/minimal-home`, `AGENTS.md`, `README.md`, the agent guide and pilot
167
+ onboarding treated a `userDevice` carrying `error` (0.18.0) as an empty roster —
168
+ "no devices" on a network error. Only `NO_DEVICES` empties the list now.
169
+ - `dev --help` and `docs/bridge-contract.md` said the harness reports 0.17.0; a
170
+ drift test now ties every mention to the contract.
171
+ - `docs/client-guide.md` pointed at `examples/client_demo` as if it shipped in
172
+ the package.
173
+
174
+ ### Changed
175
+
176
+ - The harness reports `nativeVersion` 0.18.0 by default: `cold-start` answers
177
+ the lost request once the service is up, `no-devices` answers with
178
+ `error: 'NO_DEVICES'`, and `systemStatus` carries `hasDevicesKnown`.
179
+ `--native-version 0.17.0` reproduces the old silence.
180
+ - Harness behaves like the app on connections: `deviceConnection` `add` reports
181
+ only changes (no immediate `isConnect: true`); the first device of `default`
182
+ starts a BLE session after 3 s; `realTime` streams `deviceLightSensors` once
183
+ per second while the device is connected; without internet `sensorLog` fails,
184
+ and `offline` recovers after `setStatus({ internet: true })`. `lightSensorsReading`
185
+ is exported from `/dev`.
186
+ - **Type change:** `SensorLogQueryPayload.bodyPart` is `'wrist' | 'necklace'`.
187
+ - **Type change:** `BridgeClient.send` is typed per command (`CommandArgs`,
188
+ `CommandName`, `DeviceAddress`): a string `serialNo`, a missing argument or an
189
+ unknown command no longer compiles. Each of them was already dropped in
190
+ silence by the app.
191
+ - **Type change:** `requestUserDevices()` gained two statuses. Code that read
192
+ `result.hasDevices` after checking only `status !== 'ok'` must now narrow to
193
+ `status === 'timeout'` (TypeScript flags it).
194
+ - The contract's `versions.nativeVersion` is 0.18.0; the two `userDevice`
195
+ silent-drop paths are marked `untilNativeVersion: 0.18.0`.
196
+
10
197
  ## [0.22.0] — 2026-10-20
11
198
 
12
199
  ### Added