@condorinst/mobile-bridge 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -6,19 +6,18 @@ owns login, settings and the Bluetooth connection to the devices; you own what
6
6
  the person sees on the Home.
7
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
9
+ enforced, either by a gate in the app or by the browser runtime on a real
10
10
  device, where mistakes are expensive to diagnose.
11
11
 
12
12
  ## The one thing that makes this different
13
13
 
14
- **Failures are silent.** If you send a malformed command, an oversized payload,
15
- or a command this device's firmware does not know, nothing happens. No
16
- exception, no rejected promise, no console error. The native side logs it and
17
- moves on.
14
+ **Failures are silent.** A malformed command, an oversized payload, a command
15
+ sent before the app is ready, a request for an account with no device: nothing
16
+ happens. No exception, no rejected promise, no console error.
18
17
 
19
- So: never write code whose correctness you infer from the absence of an error.
20
- Drive your UI from the events that arrive, and put a timeout on anything you
21
- wait for.
18
+ So never write code whose correctness you infer from the absence of an error.
19
+ Drive the UI from the events that arrive, give every wait a deadline, and
20
+ always have a screen for "it did not come".
22
21
 
23
22
  ## Hard rules
24
23
 
@@ -35,12 +34,14 @@ wait for.
35
34
  whole bundle**.
36
35
  5. **Build single-chunk.** No code splitting, no ES module imports between
37
36
  chunks — `srcdoc` cannot resolve them. Vite: `vite-plugin-singlefile`.
38
- 6. **The `.zip` must be at most 2 MB.** Remember that `data:` URIs inflate
39
- assets by about a third.
37
+ 6. **The `.zip` must be at most 2 MB.** `data:` URIs inflate assets by about a
38
+ third.
40
39
  7. **Use `BridgeClient`, never raw `postMessage`.** Hand-rolled messages fail
41
40
  schema validation and are dropped without a trace.
42
41
  8. **Never assume a capability.** Read `nativeVersion` from the handshake and
43
42
  branch on it. See the capability matrix in `docs/bridge-contract.md`.
43
+ 9. **Never ask once and wait.** Use `BridgeClient.requestUserDevices()` for the
44
+ device list; a single `send('userDevice')` can be lost (see below).
44
45
 
45
46
  ## What you cannot reach
46
47
 
@@ -49,38 +50,101 @@ wait for.
49
50
  camera, microphone and geolocation (denied unconditionally) · the `dfu` key and
50
51
  command (denied by policy).
51
52
 
52
- ## The API
53
+ ## The golden path
53
54
 
54
55
  ```bash
55
- npm install @condorinst/mobile-bridge
56
+ npm install @condorinst/mobile-bridge@^0.22.0
56
57
  ```
57
58
 
58
59
  ```ts
59
- import { BridgeClient, BRIDGE_KEYS, type UserDevicePayload } from '@condorinst/mobile-bridge';
60
+ import {
61
+ BridgeClient, BRIDGE_KEYS, versionAtLeast, type UserDevicePayload,
62
+ } from '@condorinst/mobile-bridge';
60
63
 
61
- // 1. Handshake first. It is how you learn what this device can do.
64
+ // 1. Subscribe before you ask for anything. Roster changes arrive here too.
65
+ BridgeClient.on(BRIDGE_KEYS.userDevice, (devices: UserDevicePayload) => renderDevices(devices));
66
+ BridgeClient.on(BRIDGE_KEYS.systemStatus, (s) => renderEnvironment(s)); // bluetooth, internet, permissions
67
+
68
+ // 2. Handshake. nativeVersion is the ONLY capability signal; ack.accepted is
69
+ // always true and carries no information — never branch on it.
62
70
  const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' });
63
- // ack.nativeVersion is the ONLY capability signal. ack.accepted is always
64
- // true and carries no information — never branch on it.
71
+ const canAddressById = versionAtLeast(ack.nativeVersion, '0.17.0'); // null → false
72
+
73
+ // 3. Ask for the devices with the helper. It resends until they arrive.
74
+ 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);
80
+ } else {
81
+ // 4. Subscribe to the connection state of each device (`add` pairs nothing).
82
+ for (const d of result.devices.devicesConfig) {
83
+ BridgeClient.send('deviceConnection', {
84
+ type: 'add', serialNo: d.serialNo, ...(canAddressById ? { deviceId: d.deviceId } : {}),
85
+ });
86
+ }
87
+ }
88
+
89
+ // 5. History: one page per call; it resolves { success: false, error } on
90
+ // failure and only rejects on timeout.
91
+ const page = await BridgeClient.fetchSensorLog({ requestId: crypto.randomUUID(), serialNo });
92
+ ```
65
93
 
66
- // 2. Subscribe before you ask for anything.
67
- BridgeClient.on(BRIDGE_KEYS.userDevice, (payload: UserDevicePayload) => render(payload));
68
- BridgeClient.on(BRIDGE_KEYS.systemStatus, (s) => renderEnvironment(s));
94
+ **Why the helper.** Device data comes from a background service that the app
95
+ starts *after* your bundle is already running. Anything you send to it before it
96
+ is up is lost without a reply — while the handshake, `systemStatus` and
97
+ `language` already work, so the bridge *looks* ready. And an account with no
98
+ linked device gets no `userDevice` reply at all.
99
+
100
+ **Identify devices by `deviceId`.** `serialNo` can repeat: an ActLumus and an
101
+ ActLumus Plus may share one. Join `devicesConfig`, `devicesTelemetry` and
102
+ `devicesUserConfig` on `deviceId`; send `serialNo` in every command, plus
103
+ `deviceId` when `nativeVersion` ≥ 0.17.0 (`versionAtLeast` does the comparison).
104
+ The typed shapes (`DeviceConfigItem`, `DeviceTelemetryItem`, …) show every field
105
+ and its unit — `devicesConfig` has no name or battery; those are in the
106
+ telemetry.
107
+
108
+ **Timestamps are device-local.** `log_time` is `'YYYY-MM-DD HH:MM:SS'` in the
109
+ device's wall-clock time. Parse and build it with local `Date` getters, never
110
+ with `toISOString()` or a `'Z'` suffix.
111
+
112
+ Three commands open a confirmation dialog owned by Condor: `syncTime`,
113
+ `deleteMemory` and `logout`. If the person declines, you get nothing — the ack
114
+ never arrives. Do not block your UI on them.
69
115
 
70
- // 3. Commands are fire-and-forget. No return value, no throw.
71
- BridgeClient.send('userDevice', { getFromServer: false });
116
+ ## Develop in the harness, not in a bare browser tab
72
117
 
73
- // 4. The one request/response helper: it correlates the id and times out.
74
- const page = await BridgeClient.fetchSensorLog({ requestId: crypto.randomUUID(), serialNo });
75
- // Resolves with { success: false, error } on failure — it only REJECTS on timeout.
118
+ ```bash
119
+ npx condor-mobile-bridge dev -i dist/index.html --scenario cold-start
76
120
  ```
77
121
 
78
- Three commands open a confirmation modal owned by Condor: `syncTime`,
79
- `deleteMemory` and `logout`. If the person declines, you get nothing — the ack
80
- simply never arrives. Do not block your UI on them.
122
+ It serves your bundle at `http://localhost:5173` inside the **same sandbox,
123
+ shell and policy as the app**, with a simulated native side. Opening
124
+ `dist/index.html` directly does not work: `BridgeClient.handshake()` rejects at
125
+ once outside the shell. Rebuild and reload; the harness re-reads the file.
81
126
 
82
- `systemStatus` and `language` are emitted on attach, again right after the
83
- handshake, and on every change. Render from the latest one you received.
127
+ | Scenario | What it reproduces |
128
+ | --- | --- |
129
+ | `default` | two devices, realistic latency |
130
+ | `cold-start` | the background service takes 4 s; earlier `ping`/`userDevice` are lost |
131
+ | `no-devices` | `userDevice` never answers; `hasDevices: false` |
132
+ | `shared-serial` | an ActLumus and a Plus share serial 7047 |
133
+ | `offline` | no internet; `sensorLog` fails |
134
+
135
+ The harness console (`[condor dev]`) says what was sent (with its arguments),
136
+ answered and dropped. The **◀ OS back** button at the top presses the OS back
137
+ button. `--native-version null` (or `0.15.0`, …) makes the handshake report that
138
+ version, to test your capability branches.
139
+
140
+ ## Publishing — ask the human, never run it yourself
141
+
142
+ `npx condor-mobile-bridge canvas publish` puts the bundle live at once for every
143
+ user of a company, and asks for the person's password. **Do not run it.** When
144
+ the bundle is ready, tell the human the exact command — for example
145
+ `npx condor-mobile-bridge canvas publish dist/index.html --version 1.0.1` — and
146
+ recommend publishing to their **test company** first. See
147
+ `docs/device-testing.md`.
84
148
 
85
149
  ## The validation loop — run this before you call the work done
86
150
 
@@ -91,22 +155,23 @@ npx condor-mobile-bridge validate -i dist/bundle.zip --strict
91
155
 
92
156
  `validate` applies the same gates the app applies on download, plus a lint for
93
157
  the APIs the sandbox blocks at runtime. Under `--strict` it exits non-zero on
94
- warnings too. **Treat a non-zero exit as a build failure.**
95
-
96
- It is a textual check, so it cannot see through minification: a clean run is
97
- necessary, not sufficient. It will not catch a capability you assumed without
98
- checking `nativeVersion`.
158
+ warnings too. **Treat a non-zero exit as a build failure.** It is a textual
159
+ check, so a clean run is necessary, not sufficient.
99
160
 
100
161
  ## Definition of done
101
162
 
102
163
  - [ ] `validate --strict` exits 0.
103
- - [ ] The UI renders correctly before any event arrives — first paint must not
104
- depend on the bridge.
105
- - [ ] Every capability beyond the baseline is guarded by a `nativeVersion` check.
106
- - [ ] Nothing waits forever on an ack that may never come.
107
- - [ ] Tested in the browser with the mock (in the example: `npm run build`, then
108
- open `dist/index.html`), where `nativeVersion` is `null` — your code must
109
- survive that.
164
+ - [ ] The bundle works in **every** harness scenario: it leaves the loading
165
+ screen in `cold-start`, shows an empty state in `no-devices`, keeps two
166
+ cards apart in `shared-serial`, and explains the failure in `offline`.
167
+ - [ ] The UI renders before any event arrives — first paint must not depend on
168
+ the bridge.
169
+ - [ ] Every capability beyond the baseline is guarded by a `nativeVersion` check
170
+ (`versionAtLeast`), and the page still renders with
171
+ `--native-version null`.
172
+ - [ ] Nothing waits forever on a reply that may never come.
173
+
174
+ To put the bundle on a phone, read `docs/device-testing.md`.
110
175
 
111
176
  ## Where to read more
112
177
 
@@ -115,7 +180,9 @@ checking `nativeVersion`.
115
180
  | Every key, command, error shape and capability | `docs/bridge-contract.md` |
116
181
  | The same, as JSON you can parse | `contract/bridge-contract.json` |
117
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` |
118
185
  | Every hard limit, with the code that enforces it | `docs/limits.md` |
119
- | "My command did nothing" | `docs/troubleshooting.md` |
120
186
  | Payload shapes, authoritative | `dist/index.d.ts` (your editor shows them) |
121
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,105 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.22.0] — 2026-10-20
11
+
12
+ ### Added
13
+
14
+ - **`condor-mobile-bridge canvas publish | list | revoke`** — publish the bundle
15
+ of your company in the Condor Canvas app from the command line, through the
16
+ same endpoints and login as the Condor web admin. The bundle is validated
17
+ locally first; the command shows company, version, size and hash and asks
18
+ before sending; the password is read only from a prompt without echo (there
19
+ is no flag or variable for it) and never stored; the two-factor code is asked
20
+ when the company uses it. Production API by default; `--env dev` for Condor
21
+ staff, `--base-url` to override. Company and user ids may come from
22
+ `CONDOR_CANVAS_COMPANY` / `CONDOR_CANVAS_USER`.
23
+ - `docs/device-testing.md`: publishing from the command line, and the test
24
+ company workflow.
25
+
26
+ ### Changed
27
+
28
+ - `docs/device-testing.md` no longer sends clients to a development
29
+ environment: clients work in production, on a test company.
30
+ - `AGENTS.md`: an agent must never run `canvas publish`; it hands the command to
31
+ the human.
32
+
33
+ ## [0.21.0] — 2026-10-20
34
+
35
+ A client built from the 0.20.0 docs worked in the browser and stalled on the
36
+ first real device: the device list never arrived. This release makes the
37
+ browser behave like the device, gives the boot sequence a helper, and types the
38
+ device payloads.
39
+
40
+ ### Added
41
+
42
+ - **`condor-mobile-bridge dev`** — serves a bundle (`index.html`, `dist/` or
43
+ `.zip`) in a browser harness that reproduces the app: the bundle runs in
44
+ `<iframe sandbox="allow-scripts">` behind the real `BridgeShell`, with the
45
+ app's policy and CSP and a simulated native side. Scenarios: `default`,
46
+ `cold-start` (requests lost while the app's background service starts),
47
+ `no-devices` (`userDevice` never answers), `shared-serial` and `offline`.
48
+ The page logs every command, reply and dropped request.
49
+ - **`@condorinst/mobile-bridge/dev`** — the same harness as a library
50
+ (`createDevHarness`, `createSimulatedNative`, scenarios and fixtures) for a
51
+ host page on your own dev server.
52
+ - **`BridgeClient.requestUserDevices({ getFromServer })`** — requests the device
53
+ list and resends it with backoff (1.5 s, 3 s, 6 s, then every 10 s, and on
54
+ every `systemStatus`) until it arrives. Never rejects: resolves
55
+ `{ status: 'timeout', hasDevices }` after 30 s, with `hasDevices` from the
56
+ latest `systemStatus` received — also one that arrived before the call.
57
+ - **`BridgeClient.waitForService()`** — resends `ping` until the app's
58
+ background service answers.
59
+ - **Typed device payloads**: `DeviceConfigItem`, `DeviceTelemetryItem`,
60
+ `DeviceUserConfigItem`, `DeviceSyncErrorItem` and `SensorLogItem`, with units
61
+ (battery and memory in %, `bleConnectionPeriod` in minutes, epoch ms) and the
62
+ join rule across the three arrays of `userDevice` (`deviceId`).
63
+ - **`docs/device-testing.md`** — how a bundle reaches a phone, development vs
64
+ production, inspecting the WebView, and where errors go.
65
+ - **`llms-full.txt`** — `AGENTS.md`, the docs and the reference example in one
66
+ file, generated on build.
67
+ - Contract: `payloadItems`, `devTools`, `sensorLog` paging rules, and two
68
+ silent-drop paths for `userDevice` (request before the background service is
69
+ up; a list that failed to load, including an account with no device).
70
+ - `validate` fails a bundle that includes the dev harness
71
+ (`dev-harness-bundled`).
72
+ - `dev --native-version <v|null>` sets the `nativeVersion` the harness
73
+ reports; the harness page has an **◀ OS back** button (emits
74
+ `navigationBack` only after the bundle reported `canGoBack: true`), and its
75
+ log shows the arguments of every command.
76
+ - `versionAtLeast(version, min)` — numeric capability check; `null` → `false`.
77
+ - `SensorLogItem` declares the ActLumus Plus channels `light_sensor_x/_y/_z/_fd`
78
+ (`null` on gen1).
79
+
80
+ ### Changed
81
+
82
+ - **`BridgeClient.handshake()` rejects at once outside the shell** (a page
83
+ opened directly in a browser), with a message pointing to the harness. It
84
+ used to wait 5 s and time out.
85
+ - `UserDevicePayload` arrays and `SensorLogPayload.logs` are typed instead of
86
+ `Record<string, unknown>`. Code that cast items to `Record<string, unknown>`
87
+ still compiles.
88
+ - `AGENTS.md` is rewritten around the boot sequence that survives a cold start
89
+ and an account with no device; the docs no longer describe a browser mock for
90
+ `BridgeClient`, which never existed.
91
+ - `examples/minimal-home` uses the helpers and the harness, and reads the device
92
+ name from the telemetry (it read a field `devicesConfig` does not have).
93
+ `examples/client_demo` drops its private mock and reads `log_time` as
94
+ device-local time.
95
+
96
+ ### Documented
97
+
98
+ - `userDevice` is not guaranteed to answer; `systemStatus.hasDevices` is a hint
99
+ (`false` also when the lookup failed); what `getFromServer` does.
100
+ - `deviceConnection` `type: 'add'` subscribes to a device's connection state and
101
+ pairs nothing.
102
+ - `sensorLog` error codes are matched by prefix; transport failures carry free
103
+ text. A full 7-day sweep recipe (keep the range on every page).
104
+ - The back button example keeps navigation in memory instead of `history`.
105
+ - `sensorLog`: without a range the first page is the most recent; with
106
+ `startTime` the oldest of the range. The log period is programmable per
107
+ device (default 60 s).
108
+
10
109
  ## [0.20.0] — 2026-10-13
11
110
 
12
111
  ### Added
package/README.md CHANGED
@@ -20,7 +20,8 @@ Over the network, every document is plain text at a pinned, unauthenticated
20
20
  URL. `llms.txt` is the index:
21
21
 
22
22
  ```
23
- https://cdn.jsdelivr.net/npm/@condorinst/mobile-bridge@0.19.0/llms.txt
23
+ https://cdn.jsdelivr.net/npm/@condorinst/mobile-bridge@0.22.0/llms.txt
24
+ https://cdn.jsdelivr.net/npm/@condorinst/mobile-bridge@0.22.0/llms-full.txt # every doc in one file
24
25
  ```
25
26
 
26
27
  The machine-readable contract — keys, commands, limits, capability matrix — is
@@ -43,7 +44,15 @@ const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' });
43
44
  // ack.nativeVersion is your capability signal. ack.accepted is always true.
44
45
 
45
46
  BridgeClient.on(BRIDGE_KEYS.userDevice, (devices) => render(devices));
46
- BridgeClient.send('userDevice', { getFromServer: false });
47
+ const result = await BridgeClient.requestUserDevices(); // resends until the list arrives
48
+ if (result.status === 'timeout') renderEmptyState();
49
+ ```
50
+
51
+ Develop it in the browser with the harness, which runs the bundle under the
52
+ same sandbox and shell as the app:
53
+
54
+ ```bash
55
+ npx condor-mobile-bridge dev -i dist/index.html --scenario cold-start
47
56
  ```
48
57
 
49
58
  Then read the [Client Guide](docs/client-guide.md) — _"Build your White Label
@@ -63,11 +72,15 @@ The published package ships a prebuilt `dist/` (ESM + CJS + type
63
72
  declarations) — **no build toolchain is required on your side**.
64
73
 
65
74
  > **Versioning during the pilot.** The package is on `0.x` (beta). Pin
66
- > `"@condorinst/mobile-bridge": "^0.19.0"`. Under semver, while on `0.x` a
67
- > caret range auto-updates **patches only** (`0.19.x`); moving to `0.20.0` is
75
+ > `"@condorinst/mobile-bridge": "^0.22.0"`. Under semver, while on `0.x` a
76
+ > caret range auto-updates **patches only** (`0.22.x`); moving to `0.23.0` is
68
77
  > an intentional opt-in. `1.0.0` is reserved for production.
69
78
 
70
- ## Quickstart
79
+ ## Quickstart (legacy, shell-side `Bridge`)
80
+
81
+ > **Not for client bundles.** This is the API the Condor shell uses to talk to
82
+ > the native layer. A client bundle cannot reach it from the sandbox — use
83
+ > `BridgeClient` (above, and in the next section).
71
84
 
72
85
  ```typescript
73
86
  import { Bridge, BRIDGE_KEYS } from '@condorinst/mobile-bridge';
@@ -82,8 +95,8 @@ const unsubscribe = Bridge.on(BRIDGE_KEYS.deviceConnection, (payload) => {
82
95
  console.log('Device:', payload);
83
96
  });
84
97
 
85
- // Command: enable real-time
86
- Bridge.send('realTime', { enabled: true });
98
+ // Command: enable real-time for one device
99
+ Bridge.send('realTime', { serialNo: 1234, value: true });
87
100
 
88
101
  // Native hook: open the app's native Settings screen
89
102
  Bridge.openSettings();
@@ -134,8 +147,8 @@ skip it and the app just stays on Home when back is pressed. See
134
147
  dev/mock APIs live only on the shell.
135
148
  - `handshake` resolves with `{ shellVersion, nativeVersion, accepted }`. Since
136
149
  0.17.0 the shell relays `nativeVersion` into the iframe — it is the only
137
- capability signal you get, and it is `null` in mock mode and against shells
138
- older than 0.6.0. `accepted` is always `true`; never branch on it.
150
+ capability signal you get, and it is `null` when the shell could not reach
151
+ the native side and against shells older than 0.6.0. `accepted` is always `true`; never branch on it.
139
152
  - Commands not allowed by the `ShellPolicy` are silently dropped (with a
140
153
  `console.warn`). The external client has no visibility into which commands
141
154
  do or do not exist.
@@ -198,6 +211,50 @@ if (!verify.ok) {
198
211
  | **3** | (Native side) | Shell nonce, stamped on every outgoing message via `Bridge.setOutgoingStamp`, validated natively. **This is the guarantee.** |
199
212
  | **4** | (Fallback if 1+2 fail) | Dual `InAppWebView` — shell and client in separate WebViews. |
200
213
 
214
+ ## Dev harness — `condor-mobile-bridge dev`
215
+
216
+ ```bash
217
+ npx condor-mobile-bridge dev -i dist/index.html [--scenario cold-start] [--port 5173]
218
+ ```
219
+
220
+ Serves the bundle (an `index.html`, a `dist/` folder or a `.zip`) at
221
+ `http://localhost:5173` inside a sandboxed iframe, behind the real
222
+ `BridgeShell` with the app's policy and CSP, and a simulated native side. The
223
+ input is re-read on every reload.
224
+
225
+ Scenarios reproduce device behaviour a naive mock hides: `default`,
226
+ `cold-start` (requests lost while the app's background service starts),
227
+ `no-devices` (`userDevice` never answers), `shared-serial` (two devices, one
228
+ `serialNo`) and `offline`. The page's console (`[condor dev]`) logs what was
229
+ sent, answered and dropped.
230
+
231
+ With your own dev server, mount the same harness from the **host** page:
232
+
233
+ ```ts
234
+ import { createDevHarness } from '@condorinst/mobile-bridge/dev';
235
+ const html = await (await fetch('/bundle/index.html')).text();
236
+ await createDevHarness({ html, scenario: 'no-devices', mount: document.getElementById('device')! });
237
+ ```
238
+
239
+ Never import `@condorinst/mobile-bridge/dev` into the bundle itself: `validate`
240
+ fails it with `dev-harness-bundled`.
241
+
242
+ ## Publish CLI — `condor-mobile-bridge canvas`
243
+
244
+ ```bash
245
+ npx condor-mobile-bridge canvas publish dist/index.html --version 1.4.0 # validate, confirm, publish
246
+ npx condor-mobile-bridge canvas list
247
+ npx condor-mobile-bridge canvas revoke <bundleId> --fallback-version 1.3.0
248
+ ```
249
+
250
+ Publishes the bundle of your company in the Condor Canvas app, through the same
251
+ endpoints and login as the Condor web admin: company id, user id, password
252
+ (prompted without echo, never stored) and the two-factor code when your company
253
+ uses one. It needs a **manager** user and an interactive terminal. A publish goes
254
+ **live at once** for every user of the company — use a test company first. The
255
+ API is production by default (`--env dev` is for Condor staff; `--base-url`
256
+ overrides). Details: `docs/device-testing.md`.
257
+
201
258
  ## Build CLI — `condor-mobile-bridge build`
202
259
 
203
260
  Generates the **self-contained `.zip` bundle** that the white-label app
@@ -280,10 +337,10 @@ The client passes `Record<string, unknown>`.
280
337
 
281
338
  ## Versioning policy (semver)
282
339
 
283
- - **PATCH** (`0.8.x`): bugfix with no API change.
340
+ - **PATCH** (`0.22.x`): bugfix with no API change.
284
341
  - **MINOR** (`0.x.0`): new observable key, new command, backward-compatible API.
285
342
  > While on `0.x`, a minor bump may carry breaking changes (semver treats
286
- > pre-1.0 minors as breaking). Pin `^0.8.0` to stay on safe patches.
343
+ > pre-1.0 minors as breaking). Pin `^0.22.0` to stay on safe patches.
287
344
  - **MAJOR** (`x.0.0`): incompatible schema change (key rename, payload shape
288
345
  change, public API change). `1.0.0` marks the production-stable contract.
289
346
 
@@ -301,9 +358,15 @@ The package detects automatically:
301
358
  JSON string. Takes precedence when present.
302
359
  - **Android / iOS (legacy)** — `window.flutter_inappwebview` (via the `flutter_inappwebview`
303
360
  package). Still supported for the transition; will be removed in a future minor.
304
- - **Browser outside the app** (dev/test): **mock mode** activates automatically.
361
+ - **Browser outside the app** (dev/test): **mock mode** activates automatically
362
+ for `Bridge`. A client bundle (`BridgeClient`) runs in the browser only
363
+ through the `dev` harness.
305
364
 
306
- ## Mock mode
365
+ ## Mock mode (shell-side `Bridge`)
366
+
367
+ > For code that uses the legacy `Bridge` directly — the shell, tests,
368
+ > Storybook. A client bundle uses `BridgeClient`, which has no mock of its own:
369
+ > develop it with `condor-mobile-bridge dev` (above).
307
370
 
308
371
  When the package runs outside the Flutter WebView (regular browser, Vite dev
309
372
  server, Storybook), the adapter activates mock mode automatically. You can
@@ -361,7 +424,10 @@ console.log(state.subscriberKeys);
361
424
  // → ['deviceConnection']
362
425
  ```
363
426
 
364
- ## Dev panel
427
+ ## Dev panel (shell-side `Bridge`)
428
+
429
+ > The `dev` harness installs this panel for you. Call `installDevPanel` yourself
430
+ > only when hosting `Bridge` directly.
365
431
 
366
432
  An opt-in HTML panel to inspect the bridge in the browser during development.
367
433
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "contractVersion": "0.20.0",
3
+ "contractVersion": "0.22.0",
4
4
  "description": "Machine-readable contract of the Condor Canvas JS<->Native bridge. It states what the TypeScript declarations in dist/index.d.ts cannot express: which keys and commands a sandboxed client can actually reach, which ones need human confirmation, where messages are dropped in silence, the hard limits, and which capability each native version carries. The TypeScript types remain canonical for payload shapes.",
5
5
  "audience": "Developers and AI agents building the client bundle that renders inside the Canvas Home.",
6
6
 
7
7
  "versions": {
8
8
  "note": "Four independent numbers coexist. Only nativeVersion tells you what the device can do.",
9
- "package": "0.20.0",
9
+ "package": "0.22.0",
10
10
  "shellVersion": "0.6.0",
11
11
  "clientProtocolVersion": "0.5.0",
12
12
  "nativeVersion": "0.17.0",
@@ -14,7 +14,7 @@
14
14
  "how": "await BridgeClient.handshake({ clientVersion }) resolves to { shellVersion, nativeVersion, accepted }. Compare ack.nativeVersion against the minNativeVersion in capabilities[].",
15
15
  "acceptedIsAlwaysTrue": true,
16
16
  "acceptedWarning": "The native side never rejects a handshake, so `accepted` carries no information. Never branch on it.",
17
- "nativeVersionMayBeNull": "null in a plain browser (mock mode) and in bundles compiled against SDK <= 0.16.x, where the shell did not relay it. Treat null as 'assume nothing beyond the 0.13.0 baseline'."
17
+ "nativeVersionMayBeNull": "null when the shell could not reach the native side, and in bundles compiled against SDK <= 0.16.x, where the shell did not relay it. Treat null as 'assume nothing beyond the 0.13.0 baseline'. The dev harness reports 0.17.0 unless a scenario sets it to null."
18
18
  }
19
19
  },
20
20
 
@@ -80,8 +80,8 @@
80
80
  "keys": {
81
81
  "note": "13 keys exist; 12 are reachable by the sandboxed client. Subscribe with BridgeClient.on(key, cb). Payload shapes are canonical in dist/index.d.ts under the listed type.",
82
82
  "entries": {
83
- "pingPong": { "reachableByClient": true, "payloadType": "PingPongPayload", "emittedWhen": "reply to the `ping` command" },
84
- "userDevice": { "reachableByClient": true, "payloadType": "UserDevicePayload", "emittedWhen": "reply to the `userDevice` command, and on device roster changes" },
83
+ "pingPong": { "reachableByClient": true, "payloadType": "PingPongPayload", "emittedWhen": "reply to the `ping` command, once the app's background service is up" },
84
+ "userDevice": { "reachableByClient": true, "payloadType": "UserDevicePayload", "emittedWhen": "reply to the `userDevice` command when the device list loads, and on device roster changes. Not guaranteed: see silentDropPaths and prefer BridgeClient.requestUserDevices()." },
85
85
  "deviceLightSensors": { "reachableByClient": true, "payloadType": "DeviceLightSensorsPayload", "emittedWhen": "real-time stream, only while realTime is on for that device", "discriminator": "type", "variants": ["actlumus", "actlumus2"] },
86
86
  "syncTime": { "reachableByClient": true, "payloadType": "SyncTimeAckPayload", "emittedWhen": "ack of the `syncTime` command" },
87
87
  "realTime": { "reachableByClient": true, "payloadType": "RealTimeAckPayload", "emittedWhen": "ack of the `realTime` command" },
@@ -99,11 +99,11 @@
99
99
  "commands": {
100
100
  "note": "Send with BridgeClient.send(command, args). It is fire-and-forget: there is no return value and no throw. Replies arrive on the key named in repliesOn.",
101
101
  "entries": {
102
- "ping": { "args": {}, "requiresConfirmation": false, "repliesOn": "pingPong" },
103
- "userDevice": { "args": { "getFromServer": "boolean, default false" }, "requiresConfirmation": false, "repliesOn": "userDevice" },
102
+ "ping": { "args": {}, "requiresConfirmation": false, "repliesOn": "pingPong", "note": "Handled by the app's background service: lost without a reply if sent before the service is up. BridgeClient.waitForService() resends it until a pingPong arrives." },
103
+ "userDevice": { "args": { "getFromServer": "boolean, default false. false: memory or cache while fresh, else the server (use at boot). true: the server first, cache on failure (use for a refresh button)." }, "requiresConfirmation": false, "repliesOn": "userDevice", "prefer": "BridgeClient.requestUserDevices({ getFromServer }), which resends with backoff and resolves { status: 'timeout', hasDevices } instead of waiting forever.", "note": "Handled by the app's background service. A request sent before it is up is lost, and no reply is sent when the list fails to load — including an account with no linked device. An empty list is never sent." },
104
104
  "realTime": { "args": { "serialNo": "int", "deviceId": "int, optional, minNativeVersion 0.17.0", "value": "boolean" }, "requiresConfirmation": false, "repliesOn": "realTime", "note": "Off by default. The deviceLightSensors stream only flows while this is on. Always send serialNo; add deviceId to address one device when two share a serialNo." },
105
- "deviceConnection": { "args": { "type": "'add' | 'remove'", "serialNo": "int", "deviceId": "int, optional, minNativeVersion 0.17.0" }, "requiresConfirmation": false, "repliesOn": "deviceConnection" },
106
- "sensorLog": { "args": { "requestId": "string", "serialNo": "int", "bodyPart": "'wrist' | 'necklace', optional", "limit": "1..1000, optional", "startingAfter": "opaque cursor, optional", "endingBefore": "opaque cursor, optional", "startTime": "timestamp, optional, minNativeVersion 0.15.0", "endTime": "timestamp, optional, minNativeVersion 0.15.0" }, "requiresConfirmation": false, "repliesOn": "sensorLog", "prefer": "BridgeClient.fetchSensorLog(query), which correlates requestId and applies a timeout for you." },
105
+ "deviceConnection": { "args": { "type": "'add' | 'remove'", "serialNo": "int", "deviceId": "int, optional, minNativeVersion 0.17.0" }, "requiresConfirmation": false, "repliesOn": "deviceConnection", "note": "Pairs nothing. 'add' subscribes to the BLE connection state of a device from the userDevice list; the state arrives on the deviceConnection key on every change. 'remove' stops it." },
106
+ "sensorLog": { "args": { "requestId": "string", "serialNo": "int", "bodyPart": "'wrist' | 'necklace', optional", "limit": "1..1000, optional", "startingAfter": "opaque cursor, optional", "endingBefore": "opaque cursor, optional", "startTime": "timestamp, optional, minNativeVersion 0.15.0", "endTime": "timestamp, optional, minNativeVersion 0.15.0" }, "requiresConfirmation": false, "repliesOn": "sensorLog", "prefer": "BridgeClient.fetchSensorLog(query), which correlates requestId and applies a timeout for you.", "paging": { "orderWithinPage": "chronological ascending", "noCursorNoRange": "the most recent `limit` logs; older pages with endingBefore while hasPrevious", "withStartTime": "the oldest logs of the range first; newer pages with startingAfter while hasMore", "onlyEndTime": "the most recent logs before endTime", "defaultLimit": 10, "maxLimit": 1000, "logPeriod": "one log per period, programmable per device, default 60 s; derive it from consecutive log_time values" } },
107
107
  "openSettings": { "args": {}, "requiresConfirmation": false, "repliesOn": null },
108
108
  "requestPermission": { "args": { "permission": "string, must match a native AppPermission name" }, "requiresConfirmation": false, "repliesOn": null, "note": "There is no permission-result key. The OS shows its own prompt and the outcome reaches you as a fresh systemStatus emission." },
109
109
  "setStatusBarColor": { "args": { "color": "'#RRGGBB'" }, "requiresConfirmation": false, "repliesOn": null },
@@ -148,7 +148,9 @@
148
148
  { "cause": "unknown command type", "trigger": "typo, or a command added in a newer nativeVersion than the device runs", "observable": "nothing" },
149
149
  { "cause": "permission name outside the native enum", "trigger": "requestPermission with an unrecognized string", "observable": "nothing" },
150
150
  { "cause": "malformed args", "trigger": "realTime, syncTime, deleteMemory or deviceConnection with wrong types", "observable": "nothing" },
151
- { "cause": "postMessage failing schema validation", "trigger": "hand-rolled postMessage instead of BridgeClient", "observable": "nothing" }
151
+ { "cause": "postMessage failing schema validation", "trigger": "hand-rolled postMessage instead of BridgeClient", "observable": "nothing" },
152
+ { "cause": "background service not running yet", "trigger": "userDevice or ping sent before the app's background service is up, typically right after a cold start; the app does not re-send the list when the service comes up", "observable": "nothing (the handshake, systemStatus and language already work)", "mitigation": "BridgeClient.requestUserDevices() / waitForService()" },
153
+ { "cause": "device list did not load", "trigger": "userDevice for an account with no linked device, or when the server request fails (no internet, session problem); the app replies only on success", "observable": "nothing; systemStatus.hasDevices stays false", "mitigation": "BridgeClient.requestUserDevices() resolves { status: 'timeout', hasDevices } after 30 s: render an empty state with a retry" }
152
154
  ],
153
155
 
154
156
  "capabilities": [
@@ -172,16 +174,49 @@
172
174
  "integrity": { "algorithm": "sha256", "format": "sha256-<lowercase hex>", "computedOver": "the exact bytes of the .zip" },
173
175
  "tooling": {
174
176
  "build": "npx condor-mobile-bridge build -i dist/index.html -o dist/bundle.zip",
175
- "validate": "npx condor-mobile-bridge validate -i dist/bundle.zip --strict"
177
+ "validate": "npx condor-mobile-bridge validate -i dist/bundle.zip --strict",
178
+ "publish": "npx condor-mobile-bridge canvas publish dist/index.html --version <v> — interactive (company id, user id, password, 2FA code); live at once for every user of the company; needs a manager user. Never run it from an agent."
176
179
  },
177
180
  "statusBarHook": "<meta name=\"theme-color\" content=\"#RRGGBB\"> in the bundle sets the native status bar colour. The setStatusBarColor command overrides it at runtime."
178
181
  },
179
182
 
183
+ "payloadItems": {
184
+ "note": "Item shapes of userDevice and sensorLog. Canonical types in dist/index.d.ts. Fields not listed here are not part of the public contract — do not read them.",
185
+ "joinKey": "deviceId. An item of devicesTelemetry or devicesUserConfig without deviceId is legacy data: fall back to serialNo only when that serialNo appears once in devicesConfig.",
186
+ "hasDevices": "systemStatus.hasDevices is true only after the app loaded a non-empty list from the server; false means no device OR the lookup failed. A hint, never a verdict.",
187
+ "devicesConfig": {
188
+ "type": "DeviceConfigItem",
189
+ "role": "the list of linked devices to render",
190
+ "fields": { "deviceId": "int, unique", "serialNo": "int, may repeat across an ActLumus and a Plus", "deviceBodyPart": "'wrist' | 'necklace' | null", "modelId": "'actlumus' | 'actlumus_plus', absent = ActLumus (gen1)", "bleConnectionPeriod": "int, minutes", "autosync": "bool", "eraseDeviceMemory": "bool", "autoEraseDeviceMemory": "bool", "autoEraseDeviceMemoryThreshold": "int", "readSensorsMemory": "bool", "startDevice": "bool", "readFullMemory": "bool", "allowFirmwareUpdate": "bool" }
191
+ },
192
+ "devicesTelemetry": {
193
+ "type": "DeviceTelemetryItem",
194
+ "role": "last state read from each device over BLE; readings are null until the first sync",
195
+ "fields": { "serialNo": "int", "deviceId": "int, omitted on legacy data", "updataDateTime": "epoch ms | null — last sync; the misspelling is the real field name", "deviceName": "string | null", "deviceVersion": "string | null (firmware)", "hardwareVersion": "string | null", "deviceDateTime": "epoch ms | null", "memoryDataUsage": "number 0-100 (%) | null", "memorySystemUsage": "number 0-100 (%) | null", "batteryLevel": "number 0-100 (%) | null", "batteryVoltage": "number, volts | null", "isCharging": "bool | null", "isOnDock": "bool | null", "syncErrors": "Array<{ serialNo, attemptDate: epoch ms, message: localized string, messageCode: string | null }>" }
196
+ },
197
+ "devicesUserConfig": {
198
+ "type": "DeviceUserConfigItem",
199
+ "role": "per-device toggles",
200
+ "fields": { "serialNo": "int", "deviceId": "int, omitted on legacy data", "syncTime": "bool", "realTime": "bool", "deleteMemory": "bool", "readAllMemory": "bool", "readAllSystemLog": "bool" }
201
+ },
202
+ "sensorLogItem": {
203
+ "type": "SensorLogItem",
204
+ "fields": { "id": "int", "log_time": "'YYYY-MM-DD HH:MM:SS', device-LOCAL time, not UTC", "log_type": "int", "body_part": "'wrist' | 'necklace' | null", "temperature": "°C | null", "lux_photopic": "lux | null", "lux_melanopic": "lux | null", "light_sensor_f1..f8, light_sensor_clear, light_sensor_nir": "raw channels | null (null = the hardware has no such channel)", "act_data_pim, act_data_tat, act_data_zcm": "int | null", "device_log_time, app_log_time": "upload batch markers, not per-log timestamps" }
205
+ }
206
+ },
207
+
208
+ "devTools": {
209
+ "harness": "npx condor-mobile-bridge dev -i <index.html|dist|bundle.zip> [--scenario <name>] [--port 5173]",
210
+ "subpath": "@condorinst/mobile-bridge/dev exports createDevHarness() for a host page; never import it into the bundle (validate fails it with dev-harness-bundled)",
211
+ "scenarios": ["default", "cold-start", "no-devices", "shared-serial", "offline"],
212
+ "outsideShell": "BridgeClient.handshake() rejects at once when the page is not inside the shell (opened directly in a browser)."
213
+ },
214
+
180
215
  "verifiedAgainst": {
181
216
  "monorepo": "condorinstprogdev/mobile_applications",
182
217
  "branch": "dev_canvas",
183
- "commit": "7a40a779d23d1ee49433982d37ebf4979444bf95",
184
- "verifiedOn": "2026-10-13",
218
+ "commit": "a1a524fd8491e4c266c7ea7bbcd9b1aa334620db",
219
+ "verifiedOn": "2026-10-20",
185
220
  "sources": {
186
221
  "nativeVersion": "apps/canvas/lib/ui/pages/home/bridge/condor_bridge.dart:38",
187
222
  "jsToNativePayloadBytes": "apps/canvas/lib/ui/pages/home/bridge/condor_bridge.dart:94",
@@ -196,7 +231,17 @@
196
231
  "csp": "apps/canvas/web-mock-src/shell.template.html:28",
197
232
  "shellPolicy": "apps/canvas/web-mock-src/src/shell.ts:48-98",
198
233
  "webViewPermissionDeny": "apps/canvas/lib/ui/pages/home/home_page.dart:263-271",
199
- "opaqueOrigin": "apps/canvas/lib/ui/pages/home/home_page.dart:299-301"
234
+ "opaqueOrigin": "apps/canvas/lib/ui/pages/home/home_page.dart:299-301",
235
+ "userDeviceToBackgroundService": "apps/canvas/lib/ui/pages/home/bridge/condor_bridge.dart:671-683",
236
+ "userDeviceRepliesOnlyOnSuccess": "packages/condor_core/lib/src/data/services/user_devices_service.dart:28-39",
237
+ "getFromServerSemantics": "packages/condor_core/lib/src/data/repositories/user_devices_repository.dart:49-85",
238
+ "hasDevices": "apps/canvas/lib/data/services/sys_notification_service.dart:224-237",
239
+ "deviceConfigItem": "packages/condor_core/lib/src/domain/entities/device_config_entity.dart:61-77",
240
+ "deviceTelemetryItem": "packages/condor_core/lib/src/domain/entities/device_telemetry_entity.dart:100-117",
241
+ "deviceUserConfigItem": "packages/condor_core/lib/src/domain/entities/device_user_config_entity.dart:78-88",
242
+ "deviceSyncErrorItem": "packages/condor_core/lib/src/domain/entities/device_sync_error_entity.dart:16-23",
243
+ "userDevicesEnvelope": "packages/condor_core/lib/src/domain/entities/user_devices_entity.dart:282-289",
244
+ "bleConnectionPeriodMinutes": "packages/condor_core/lib/src/domain/entities/device_session_record_entity.dart:25"
200
245
  },
201
246
  "maintenance": "Re-verify every entry in sources before cutting a release. A published contract that drifts from the app is worse than no contract."
202
247
  }