@mhome/app-facade-protocol 1.33.0 → 1.34.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/README.md +16 -0
- package/contract/host-provisioning-v1.md +173 -0
- package/contract/pod-commissioning-v1.md +447 -0
- package/fixtures/host-provision.completed.json +26 -0
- package/fixtures/pod-commission.awaiting-authorization.json +29 -0
- package/fixtures/pod-commission.failed.json +22 -0
- package/fixtures/pod-commission.reprovision-completed.json +24 -0
- package/fixtures/pod-discovery.snapshot.json +18 -0
- package/manifest/app-facade.v1.json +9 -1
- package/manifest/host-provision-targets.v1.json +39 -0
- package/manifest/hub-targets.v1.json +1 -1
- package/manifest/pod-targets.v1.json +102 -0
- package/manifest/routing.v1.json +1 -1
- package/manifest/targets.v1.json +1 -1
- package/package.json +1 -1
- package/protocol.json +5 -1
- package/schema/host-provision.v1.schema.json +121 -0
- package/schema/pod-commission.v1.schema.json +144 -0
- package/schema/pod-credential-status.v1.schema.json +18 -0
- package/schema/pod-discovery.v1.schema.json +50 -0
package/README.md
CHANGED
|
@@ -39,6 +39,22 @@ domain input and output reuse `mhome-artifact-api`; Node runtimes use the
|
|
|
39
39
|
separate transport-neutral `/artifact/*` targets and never enter the App
|
|
40
40
|
Facade.
|
|
41
41
|
|
|
42
|
+
`pod` (1.34.0) defines native-Client pod discovery and commissioning
|
|
43
|
+
(`/local/pod/*`), the shared BLE advertising format and the pod credential
|
|
44
|
+
protocol with Lion. Sessions are owned through a renewed lease, and rejected
|
|
45
|
+
requests carry a stable `details.reason`. See
|
|
46
|
+
[the contract](contract/pod-commissioning-v1.md). Targets, BLE constants, session
|
|
47
|
+
timings and request reasons are in `manifest/pod-targets.v1.json`; snapshots use
|
|
48
|
+
`schema/pod-discovery.v1.schema.json` and `schema/pod-commission.v1.schema.json`;
|
|
49
|
+
the owner's credential status is `schema/pod-credential-status.v1.schema.json`.
|
|
50
|
+
|
|
51
|
+
`host_provision` (1.34.0) defines BLE Wi-Fi provisioning of embedded Hosts
|
|
52
|
+
(`/local/host/provision/*`) on the same BLE layer; discovery candidates carry
|
|
53
|
+
`kind`. A completed session carries the one-time `claimToken` that the first
|
|
54
|
+
claim of that Host requires. See [the contract](contract/host-provisioning-v1.md), the targets in
|
|
55
|
+
`manifest/host-provision-targets.v1.json` and
|
|
56
|
+
`schema/host-provision.v1.schema.json`.
|
|
57
|
+
|
|
42
58
|
## Routing contract
|
|
43
59
|
|
|
44
60
|
`manifest/routing.v1.json` is the canonical client-side routing policy for the
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# Host provisioning v1
|
|
2
|
+
|
|
3
|
+
Embedded Linux Hosts without a working network get Wi-Fi over BLE from a native
|
|
4
|
+
Client (desktop/CLI Clientd, Android, iOS). Desktop, Docker and Android Hosts are
|
|
5
|
+
already online and never advertise. Provisioning connects the Host to the LAN
|
|
6
|
+
and hands the provisioner a one-time claim token; ownership is then established
|
|
7
|
+
by the existing Host claim over the LAN, which requires that token (section 4).
|
|
8
|
+
|
|
9
|
+
The BLE layer is the one pods use (`pod-commissioning-v1.md` sections 2 and 3):
|
|
10
|
+
same service UUID, manufacturer data with device kind `2`, protocomm security 2
|
|
11
|
+
with username `meow` and a 6-digit code, the same standard endpoints, and the
|
|
12
|
+
same characteristic UUID fallback.
|
|
13
|
+
|
|
14
|
+
## 1. Advertising
|
|
15
|
+
|
|
16
|
+
- The Host advertises only in first provisioning, inside a re-provision window,
|
|
17
|
+
or after a factory reset. A Host on a working network does not advertise.
|
|
18
|
+
- Flags: bit 0 commissionable (set while advertising, except during a code
|
|
19
|
+
lockout), bit 1 set when the Host still has a saved network (re-provision
|
|
20
|
+
window).
|
|
21
|
+
- `shortId`: the first 4 bytes of the SHA-256 of the Host bootstrap public key,
|
|
22
|
+
which survives factory reset. The bootstrap key only names the Host (`shortId`,
|
|
23
|
+
`fingerprint`); it signs nothing in provisioning or claim.
|
|
24
|
+
- Local name: the Host name, at most 20 bytes.
|
|
25
|
+
|
|
26
|
+
## 2. Pairing code
|
|
27
|
+
|
|
28
|
+
Hosts that support BLE provisioning have a display or a local console.
|
|
29
|
+
|
|
30
|
+
- The Host generates a uniformly random 6-digit code and precomputes the SRP salt
|
|
31
|
+
and verifier when BLE starts.
|
|
32
|
+
- The code is published to the local display interface (and the Host log) only
|
|
33
|
+
while a central is connected.
|
|
34
|
+
- A new code is generated after a successful provisioning, after 5 failed
|
|
35
|
+
handshakes, or 10 minutes after it was first shown.
|
|
36
|
+
- Lockout: each rotation caused by 5 failed handshakes is followed by 30 seconds
|
|
37
|
+
in which the Host refuses every handshake, doubling for each consecutive such
|
|
38
|
+
rotation up to 10 minutes; a successful handshake resets the backoff. While
|
|
39
|
+
locked the Host advertises with the commissionable flag cleared, its
|
|
40
|
+
`proto-ver` app info is `"meow":{"ver":"1","cap":["host"],"lockedForMs":N}`
|
|
41
|
+
with the remaining lockout, and every handshake is refused with ATT
|
|
42
|
+
Insufficient Authorization, like a wrong code. The commissioner reads
|
|
43
|
+
`proto-ver` after each refused handshake and fails the session with
|
|
44
|
+
`code_locked` when `lockedForMs` is present. The Host's local status reports
|
|
45
|
+
the lockout end as `lockedUntilMs`.
|
|
46
|
+
- One BLE connection at a time: a second central is disconnected, and only the
|
|
47
|
+
central that owns the setup session may write. Request and response buffers
|
|
48
|
+
are kept per central.
|
|
49
|
+
- A session ends 10 minutes after connect, or 2 minutes after connect without
|
|
50
|
+
a handshake.
|
|
51
|
+
|
|
52
|
+
## 3. Endpoints
|
|
53
|
+
|
|
54
|
+
| Endpoint | Encrypted | Purpose |
|
|
55
|
+
| --- | --- | --- |
|
|
56
|
+
| `proto-ver` | no | app info `"meow":{"ver":"1","cap":["host"]}` |
|
|
57
|
+
| `prov-session` | handshake | security 2 |
|
|
58
|
+
| `prov-scan` | yes | standard Wi-Fi scan |
|
|
59
|
+
| `prov-config` | yes | standard Wi-Fi set/apply/status |
|
|
60
|
+
| `prov-ctrl` | yes | standard reset after a failed join, before a retry |
|
|
61
|
+
| `host-info` | yes | Host information and finish |
|
|
62
|
+
|
|
63
|
+
Fallback characteristic UUIDs: the standard endpoints as for pods, `host-info`
|
|
64
|
+
`0xFF54`.
|
|
65
|
+
|
|
66
|
+
`host-info` bodies are UTF-8 JSON, at most 480 bytes:
|
|
67
|
+
|
|
68
|
+
- `{"op":"info"}` →
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"protocol": 1,
|
|
73
|
+
"hostId": "…",
|
|
74
|
+
"name": "Kitchen Host",
|
|
75
|
+
"productId": "camera",
|
|
76
|
+
"firmwareVersion": "0.9.0",
|
|
77
|
+
"fingerprint": "<hex SHA-256 of the bootstrap public key>",
|
|
78
|
+
"wifiConfigured": false
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`productId` is optional.
|
|
83
|
+
- `{"op":"finish"}` → `{"ok":true,"claimToken":"…"}`. After a successful join
|
|
84
|
+
during first provisioning the response carries the claim token: 32 random
|
|
85
|
+
bytes as 43 unpadded base64url characters. It is created when the join
|
|
86
|
+
succeeds, the same token is returned by every `finish` until the Host closes
|
|
87
|
+
BLE, and it is absent when no join succeeded and in a re-provision window
|
|
88
|
+
(the Host already has an owner). The Host then closes
|
|
89
|
+
BLE and continues its lifecycle (starts managed services, announces itself
|
|
90
|
+
over mDNS). Without `finish` the Host closes BLE 30 seconds after a successful
|
|
91
|
+
join, or when the central disconnects; the commissioner retries `finish`
|
|
92
|
+
within that window.
|
|
93
|
+
|
|
94
|
+
Every request and every encrypted response is at most 512 bytes; the response
|
|
95
|
+
is read with offset reads. `prov-scan` start blocks while the Host scans and
|
|
96
|
+
answers within 12 seconds, so commissioners give that exchange a longer timeout
|
|
97
|
+
(the shared core uses 30 seconds). Scan result requests return as many of the
|
|
98
|
+
requested entries as fit one encrypted 512-byte value, possibly fewer than
|
|
99
|
+
`count`; commissioners advance `start_index` by the number of entries returned
|
|
100
|
+
and stop at `result_count` or on an empty page. This also works with ESP-IDF
|
|
101
|
+
devices, which return exactly the requested entries (commissioners ask for at
|
|
102
|
+
most 4 at a time).
|
|
103
|
+
|
|
104
|
+
`prov-config` status reports `connected` with the IPv4 address once the Host is
|
|
105
|
+
on the LAN (the address may be empty when the Host has none yet); `connection_failed` with `auth_error` or `network_not_found` when
|
|
106
|
+
the join failed. After a failed join the commissioner sends `prov-ctrl` reset
|
|
107
|
+
before the next `set_config`. In a re-provision window the previous network
|
|
108
|
+
stays saved until the new one connects.
|
|
109
|
+
|
|
110
|
+
## 4. Native Client targets
|
|
111
|
+
|
|
112
|
+
Handled by the native Client, never forwarded. Payloads are domain input JSON.
|
|
113
|
+
Candidates come from the shared discovery (`/local/pod/discovery/*`) with
|
|
114
|
+
`kind` = `host`.
|
|
115
|
+
|
|
116
|
+
| Target | Request | Response |
|
|
117
|
+
| --- | --- | --- |
|
|
118
|
+
| `/local/host/provision/start` | `{"candidateId"}` | session snapshot |
|
|
119
|
+
| `/local/host/provision/code` | `{"sessionId","code"}` | session snapshot |
|
|
120
|
+
| `/local/host/provision/wifi` | `{"sessionId","ssid","password"?}` | session snapshot |
|
|
121
|
+
| `/local/host/provision/wifi/scan` | `{"sessionId"}` | session snapshot |
|
|
122
|
+
| `/local/host/provision/cancel` | `{"sessionId"}` | session snapshot |
|
|
123
|
+
| `/local/host/provision/renew` | `{"sessionId"}` | session snapshot |
|
|
124
|
+
| `/local/host/provision/status` | `{}` | `{"session": snapshot or null}` |
|
|
125
|
+
|
|
126
|
+
Event `/local/host/provision/changed` carries a session snapshot. One session
|
|
127
|
+
per native Client, and none while a pod session is active; discovery scanning
|
|
128
|
+
pauses during either. Rejected requests use the error envelope and the stable
|
|
129
|
+
`details.reason` values of `pod-commissioning-v1.md` section 7. The session is
|
|
130
|
+
owned through the same 30-second lease, renewed with
|
|
131
|
+
`/local/host/provision/renew` every 10 seconds; an expired lease cancels it.
|
|
132
|
+
|
|
133
|
+
Session states, in order: `connecting`, `awaiting_code`, `securing`,
|
|
134
|
+
`reading_info`, `awaiting_wifi`, `joining_wifi`, `completed`; terminal `failed`,
|
|
135
|
+
`cancelled`, `timed_out`. `completed` carries `host`, the reported `addresses`
|
|
136
|
+
(IPv4 only, possibly empty) and `claimToken` from the `finish` response; the
|
|
137
|
+
commissioner sends `finish` (up to 3 times while the secure session lasts)
|
|
138
|
+
before reporting it. `claimToken` is absent when `finish` could not be
|
|
139
|
+
delivered, in a re-provision window, and with Hosts that predate claim tokens.
|
|
140
|
+
No other state carries `claimToken`.
|
|
141
|
+
|
|
142
|
+
The first claim (TOFU) of a Host that was provisioned over BLE must present this
|
|
143
|
+
token: `/app/system/hosts/claim` takes `{"hostId","claimToken"?}` and the native
|
|
144
|
+
`host.claim` passes it through to the Host's offline claim, `POST /v1/auth/claim`
|
|
145
|
+
`{"proof","claimToken"?}` (`core-api` `host::auth::ClaimRequest`). Clients send
|
|
146
|
+
`claimToken` only when they have one, because older Hosts reject unknown fields.
|
|
147
|
+
While a token is outstanding the Host answers a claim without it, or with a
|
|
148
|
+
different one, with 403 `{"code":"HOST_CLAIM_TOKEN_REQUIRED"}`. The token stays valid until a claim succeeds or the Host is
|
|
149
|
+
factory reset; the UI claims automatically with it after `completed`. Hosts
|
|
150
|
+
that were never provisioned over BLE keep the plain first claim.
|
|
151
|
+
|
|
152
|
+
Error codes are the pod session codes without the credential ones:
|
|
153
|
+
`bluetooth_unavailable`, `device_busy`, `disconnected`, `code_rejected` (retry
|
|
154
|
+
allowed), `code_locked`, `wifi_auth_failed`, `wifi_not_found`, `wifi_failed`,
|
|
155
|
+
`session_timeout`. Retry rules are the pod ones: a rejected code returns to
|
|
156
|
+
`awaiting_code`, a failed join to `awaiting_wifi`, each with its error; `failed`
|
|
157
|
+
and `timed_out` always carry `error`. `code_locked` is terminal.
|
|
158
|
+
|
|
159
|
+
The schema is `schema/host-provision.v1.schema.json`.
|
|
160
|
+
|
|
161
|
+
## 5. Host-side entry points
|
|
162
|
+
|
|
163
|
+
- First provisioning starts BLE at boot when the Host has no known network.
|
|
164
|
+
- Re-provision window: opened from the Host's local interface (display settings
|
|
165
|
+
or `meowhostd provision open`). Mutating local provisioning commands (`open`,
|
|
166
|
+
`close`, `factory-reset`) require root or membership in the `meow-admin`
|
|
167
|
+
group (checked with the peer credentials of the local control socket); status
|
|
168
|
+
stays readable by the same user. The command help says to use `sudo`. Wi-Fi only; Host identity, owners and services
|
|
169
|
+
are kept. Closes after a successful join, on `meowhostd provision close`, or
|
|
170
|
+
after 10 minutes.
|
|
171
|
+
- Factory reset: from the local interface (`meowhostd factory-reset`). Stops
|
|
172
|
+
services, forgets saved Wi-Fi, erases owners and the Host security store, keeps
|
|
173
|
+
the bootstrap key, and returns to first provisioning.
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
# Pod commissioning v1
|
|
2
|
+
|
|
3
|
+
A pod is a MeowLink end device that acts as an App client of one user. This
|
|
4
|
+
contract is implementation-neutral: ESP-IDF on BLE-capable ESP32 chips is the
|
|
5
|
+
first implementation, not a requirement. Commissioners are the native Clients:
|
|
6
|
+
desktop/CLI Clientd, Android and iOS. The shared UI and CLI never talk BLE; they
|
|
7
|
+
call the `/local/pod/*` targets of their native Client.
|
|
8
|
+
|
|
9
|
+
Commissioning order is fixed: secure BLE session, Wi-Fi joined, user
|
|
10
|
+
authorization, credential issue, delivery, activation. A credential is issued only
|
|
11
|
+
after the pod reports a joined network. Any failure after issue revokes it.
|
|
12
|
+
|
|
13
|
+
## 1. Device identity
|
|
14
|
+
|
|
15
|
+
- On first boot and after factory reset the pod generates a P-256 identity key.
|
|
16
|
+
- `keyId` is `p256:<lowercase hex SHA-256 of the 65-byte uncompressed point>`;
|
|
17
|
+
`fingerprint` is the same hex without prefix. `x`/`y` are unpadded base64url
|
|
18
|
+
32-byte coordinates. This matches the Hub identity format.
|
|
19
|
+
- `deviceId` is a stable hardware identifier chosen by the implementation that
|
|
20
|
+
survives factory reset (ESP: lowercase hex MAC without separators).
|
|
21
|
+
- `shortId` is the first 4 bytes of the fingerprint, shown as 8 uppercase hex
|
|
22
|
+
characters in UI and advertising. It changes with the identity key.
|
|
23
|
+
|
|
24
|
+
## 2. BLE advertising
|
|
25
|
+
|
|
26
|
+
Pods advertise only while unprovisioned, while re-commissioning after logout or
|
|
27
|
+
revocation, or inside a re-provision window. A commissioned pod does not advertise.
|
|
28
|
+
|
|
29
|
+
- Service UUID (128-bit, shared by every MeowLink commissionable device):
|
|
30
|
+
`a17a7ad5-9b2f-4010-9cf0-aa15ac54c40e`.
|
|
31
|
+
- Manufacturer data, in the advertisement or scan response:
|
|
32
|
+
|
|
33
|
+
| Offset | Size | Value |
|
|
34
|
+
| --- | --- | --- |
|
|
35
|
+
| 0 | 2 | Company ID, little-endian. Development value `0xFFFF` |
|
|
36
|
+
| 2 | 2 | Magic `0x4D 0x57` (`MW`) |
|
|
37
|
+
| 4 | 1 | Advertising format version, `1` |
|
|
38
|
+
| 5 | 1 | Device kind: `1` pod, `2` Host (see `host-provisioning-v1.md`) |
|
|
39
|
+
| 6 | 1 | Flags: bit 0 commissionable, bit 1 Wi-Fi configured |
|
|
40
|
+
| 7 | 4 | `shortId` bytes |
|
|
41
|
+
|
|
42
|
+
Receivers ignore records whose company ID, magic or version they do not know.
|
|
43
|
+
- Local name: `MeowPod <model>`. `<model>` comes from product configuration and
|
|
44
|
+
is at most 8 ASCII characters. Commissioners display `<name> · <shortId>`.
|
|
45
|
+
- Commissioners scan actively and filter on the service UUID.
|
|
46
|
+
|
|
47
|
+
## 3. Secure session
|
|
48
|
+
|
|
49
|
+
Transport is ESP protocomm over BLE GATT. Endpoints are GATT characteristics
|
|
50
|
+
named by their Characteristic User Description descriptor (`0x2901`). A
|
|
51
|
+
commissioner that cannot read descriptors falls back to fixed UUIDs: the service
|
|
52
|
+
UUID with bytes 2–3 replaced by `prov-ctrl` `0xFF4F`, `prov-scan` `0xFF50`,
|
|
53
|
+
`prov-session` `0xFF51`, `prov-config` `0xFF52`, `proto-ver` `0xFF53`, then the
|
|
54
|
+
custom endpoints in table order from `0xFF54` (`pod-info` `0xFF54`,
|
|
55
|
+
`pod-credential` `0xFF55`).
|
|
56
|
+
|
|
57
|
+
- Security: protocomm security 2 (SRP6a 3072-bit with SHA-512, then AES-256-GCM).
|
|
58
|
+
Username `meow`. Password: a 6-digit decimal code.
|
|
59
|
+
- Pods with a display generate a uniformly random code, precompute salt and
|
|
60
|
+
verifier at boot, and show the code only while a central is connected. A new
|
|
61
|
+
code is generated after a successful commissioning, after 5 failed handshakes,
|
|
62
|
+
or 10 minutes after it was first shown. Pods without a display are out of scope
|
|
63
|
+
for v1.
|
|
64
|
+
- The salt is 16 bytes. `x` hashes the salt as an integer (no leading zero
|
|
65
|
+
bytes) while the proof `M` hashes the salt bytes as sent, so a device generates
|
|
66
|
+
salts whose first byte is non-zero. The commissioner sends `A` as exactly 384
|
|
67
|
+
bytes and picks a new ephemeral when `g^a mod N` would need a leading zero
|
|
68
|
+
byte; devices reject any other length. `B` is sent without leading zeros and
|
|
69
|
+
`M` uses it as sent; `u` pads both to 384 bytes.
|
|
70
|
+
- The record nonce is 8 random bytes followed by a 32-bit big-endian counter
|
|
71
|
+
starting at 1, advanced after every encrypt and decrypt on both sides
|
|
72
|
+
(security 2 patch version 1; patch 0 keeps it fixed). Commissioners accept
|
|
73
|
+
patch versions 0 and 1 only. A lost or failed exchange leaves the two counters
|
|
74
|
+
unknown, so the commissioner abandons the secure session after any transport or
|
|
75
|
+
decryption failure. A new `SessionCmd0` restarts the handshake on the same link.
|
|
76
|
+
- While a device refuses handshakes after repeated wrong codes it clears the
|
|
77
|
+
commissionable advertising flag and reports the lockout in its `proto-ver`
|
|
78
|
+
app info, as `"lockedForMs":<remaining ms>` (Hosts) or `"locked":true`. A
|
|
79
|
+
handshake while locked is refused the same way as a wrong code (an ATT error
|
|
80
|
+
such as Insufficient Authorization), so after every rejected handshake the
|
|
81
|
+
commissioner reads `proto-ver` again and fails the session with `code_locked`
|
|
82
|
+
when either field is present. A pod session also fails with `code_locked` after
|
|
83
|
+
the fifth rejected code. ESP pods restart with a new code after 5 failed
|
|
84
|
+
handshakes instead of reporting a lockout.
|
|
85
|
+
- One BLE connection at a time; a second central receives a disconnect.
|
|
86
|
+
- Session deadline: 10 minutes from connect, or 2 minutes waiting for a
|
|
87
|
+
handshake. On expiry the pod discards all unpersisted state.
|
|
88
|
+
|
|
89
|
+
| Endpoint | Encrypted | Purpose |
|
|
90
|
+
| --- | --- | --- |
|
|
91
|
+
| `proto-ver` | no | protocomm version JSON with app info `"meow":{"ver":"1","cap":["pod"]}` (label `meow`, capability `pod`) |
|
|
92
|
+
| `prov-session` | handshake | security 2 |
|
|
93
|
+
| `prov-scan` | yes | standard Wi-Fi scan |
|
|
94
|
+
| `prov-config` | yes | standard Wi-Fi set/apply/status |
|
|
95
|
+
| `prov-ctrl` | yes | standard reset after a failed join, before a retry |
|
|
96
|
+
| `pod-info` | yes | device information |
|
|
97
|
+
| `pod-credential` | yes | credential delivery and activation status |
|
|
98
|
+
|
|
99
|
+
Custom endpoint bodies are UTF-8 JSON, at most 480 bytes in each direction.
|
|
100
|
+
|
|
101
|
+
`pod-info` request `{}`, response:
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{
|
|
105
|
+
"protocol": 1,
|
|
106
|
+
"deviceId": "a0b1c2d3e4f5",
|
|
107
|
+
"model": "S1",
|
|
108
|
+
"platform": "esp32c5",
|
|
109
|
+
"firmwareVersion": "0.3.0",
|
|
110
|
+
"identity": { "keyId": "p256:…", "alg": "ES256", "x": "…", "y": "…", "fingerprint": "…" },
|
|
111
|
+
"wifiConfigured": false,
|
|
112
|
+
"state": "secured"
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`platform` is diagnostic only. `wifiConfigured` is true when the pod kept a
|
|
117
|
+
working network across logout; the commissioner then skips the Wi-Fi steps after
|
|
118
|
+
confirming connectivity with `prov-config` status. Inside a Wi-Fi change window
|
|
119
|
+
the response also carries `"mode":"reprovision"` and `wifiConfigured` is false
|
|
120
|
+
(section 4).
|
|
121
|
+
|
|
122
|
+
`pod-credential` requests:
|
|
123
|
+
|
|
124
|
+
- `{"op":"deliver","podId","refreshToken","scopeId","cloudApi","cloudWs"}` →
|
|
125
|
+
`{"ok":true,"state":"activating"}`. Accepted only in `wifi_connected` outside a
|
|
126
|
+
Wi-Fi change window; otherwise `{"ok":false,"state":<current pod state>,"reason"}`
|
|
127
|
+
with `reason` `invalid_payload` (a missing or malformed field, including a
|
|
128
|
+
scheme this firmware does not accept) or `wrong_state`. `cloudApi` is
|
|
129
|
+
`https://…/api/v1`, `cloudWs` is a `wss://` URL. Production firmware accepts
|
|
130
|
+
only these schemes; development firmware built with insecure cloud access
|
|
131
|
+
enabled (ESP: `MEOW_POD_ALLOW_INSECURE_CLOUD`, default off) also accepts
|
|
132
|
+
`http://` and `ws://`. Commissioners never check the scheme, but they refuse to
|
|
133
|
+
issue a credential when the cloud host is loopback (`localhost`,
|
|
134
|
+
`127.0.0.0/8`, `::1`), because the pod cannot reach it; the request fails with
|
|
135
|
+
reason `cloud_unreachable_for_device`.
|
|
136
|
+
- `{"op":"status"}` → `{"state":<pod state>,"error"?:{"code","message"}}`; `error`
|
|
137
|
+
is present only in `failed`.
|
|
138
|
+
- `{"op":"finish"}` → `{"ok":true}` in `commissioned`; the pod stops BLE and
|
|
139
|
+
restarts into normal operation. In any other state it answers
|
|
140
|
+
`{"ok":false,"state":<current pod state>,"reason":"wrong_state"}`. The pod also
|
|
141
|
+
stops BLE 30 seconds after reaching `commissioned` without `finish`. `finish` is
|
|
142
|
+
best-effort: the commissioner records `completed` before sending it.
|
|
143
|
+
|
|
144
|
+
Activation error codes: `time_sync_failed`, `cloud_unreachable`,
|
|
145
|
+
`refresh_rejected`, `auth_rejected`, `storage_failed` (the final commit, or in a
|
|
146
|
+
Wi-Fi change window saving the new network, failed).
|
|
147
|
+
|
|
148
|
+
The pod persists Wi-Fi, credential, endpoints and Space in one commit after a
|
|
149
|
+
successful activation. Nothing from an abandoned attempt survives it; an
|
|
150
|
+
implementation whose Wi-Fi driver caches the network on its own (ESP-IDF
|
|
151
|
+
provisioning does) erases that cache when the attempt ends.
|
|
152
|
+
|
|
153
|
+
## 4. Pod state machine
|
|
154
|
+
|
|
155
|
+
```text
|
|
156
|
+
unprovisioned ──connect──▶ session_open ──handshake──▶ secured
|
|
157
|
+
secured ──prov-config apply──▶ wifi_joining ──▶ wifi_connected | wifi_failed
|
|
158
|
+
wifi_failed ──retry──▶ wifi_joining
|
|
159
|
+
wifi_connected ──deliver──▶ activating ──time sync, refresh, commit──▶ commissioned | failed
|
|
160
|
+
any pre-commit state ──timeout/disconnect/cancel──▶ unprovisioned
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Runtime states after commit:
|
|
164
|
+
|
|
165
|
+
- `commissioned`: normal operation.
|
|
166
|
+
- `reprovision_window`: Wi-Fi replacement only, opened from the pod's own
|
|
167
|
+
settings; the pod restarts and advertises for 10 minutes from boot with a new
|
|
168
|
+
code. The credential is kept and the previous network stays persisted until
|
|
169
|
+
the new one connects. `pod-info` reports `"mode":"reprovision"` and
|
|
170
|
+
`wifiConfigured: false`, and only the Wi-Fi endpoints and `pod-credential`
|
|
171
|
+
`status` and `finish` are accepted (`deliver` answers `wrong_state`). After
|
|
172
|
+
`prov-config` apply the pod joins the new network, saves it, and reports
|
|
173
|
+
`commissioned`, or `failed` with `storage_failed` if saving failed; it then
|
|
174
|
+
restarts on `finish`, 30 seconds later, or at the end of the window. A failed
|
|
175
|
+
join or an expired window restarts with the previous network.
|
|
176
|
+
- `needs_recommission`: logout, or refresh rejected because the credential was
|
|
177
|
+
revoked or the user no longer exists. Credential and local JWTs are erased,
|
|
178
|
+
Wi-Fi is kept, advertising resumes with the Wi-Fi-configured flag.
|
|
179
|
+
- `factory_resetting`: best-effort self-revoke, erase all persisted data
|
|
180
|
+
including the identity key, reboot to `unprovisioned`.
|
|
181
|
+
|
|
182
|
+
Time sync uses SNTP; if NTP is unreachable the pod may use the `Date` header of a
|
|
183
|
+
Lion HTTPS response. Activation proofs require a synced clock.
|
|
184
|
+
|
|
185
|
+
## 5. Lion credential API
|
|
186
|
+
|
|
187
|
+
All bodies are JSON. Errors use the standard Lion envelope
|
|
188
|
+
`{"error","message","details"?}`. Credential failures are `UNAUTHORIZED` with one
|
|
189
|
+
of these `message` values: `pod_credential_invalid` (unknown pod, wrong or
|
|
190
|
+
revoked refresh token, or an owner account that no longer exists),
|
|
191
|
+
`pod_proof_invalid`, `pod_proof_replayed`, `pod_activation_expired`. A pod treats
|
|
192
|
+
`pod_credential_invalid` and `pod_activation_expired` as revocation. Renaming a
|
|
193
|
+
pod the caller does not own, or that does not exist, is `NOT_FOUND` with
|
|
194
|
+
`pod_not_found`.
|
|
195
|
+
|
|
196
|
+
### Issue
|
|
197
|
+
|
|
198
|
+
`POST /api/v1/pod/credential/issue`, user `Authorization`, `ScopeId` header.
|
|
199
|
+
|
|
200
|
+
```json
|
|
201
|
+
{
|
|
202
|
+
"deviceId": "a0b1c2d3e4f5",
|
|
203
|
+
"model": "S1",
|
|
204
|
+
"platform": "esp32c5",
|
|
205
|
+
"firmwareVersion": "0.3.0",
|
|
206
|
+
"name": "MeowPod S1",
|
|
207
|
+
"identity": { "keyId": "p256:…", "alg": "ES256", "x": "…", "y": "…", "fingerprint": "…" },
|
|
208
|
+
"clientId": "issuing client id"
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
`deviceId` matches `[A-Za-z0-9._:-]{1,64}`; `model` and `name` are 1–64
|
|
213
|
+
characters, `platform` and `firmwareVersion` at most 64, `clientId` at most 128. Lion requires Space membership for `ScopeId`, validates the identity, and
|
|
214
|
+
creates a `pending` credential that must activate before `activateBefore`
|
|
215
|
+
(issue time + 10 minutes). Response `{"podId","refreshToken","activateBefore"}`.
|
|
216
|
+
|
|
217
|
+
### Refresh and activation
|
|
218
|
+
|
|
219
|
+
`POST /api/v1/pod/token/refresh`, body `{"podId","refreshToken","proof"}`.
|
|
220
|
+
|
|
221
|
+
`proof` is a compact ES256 JWS signed by the identity key: header `kid=keyId`;
|
|
222
|
+
claims `iss` and `sub` = `podId`, a single-valued `aud` = `pod-token-refresh`,
|
|
223
|
+
`token_hash` = unpadded base64url SHA-256 of the UTF-8 refresh token (including
|
|
224
|
+
`pod-`), `iat`, `exp` with `0 ≤ exp - iat ≤ 120 s`, and a unique `jti` of at most
|
|
225
|
+
128 characters. Lion allows 120 s clock skew and remembers each `jti` for
|
|
226
|
+
7 minutes (proof lifetime plus twice the skew, plus a minute), longer than any
|
|
227
|
+
proof can be accepted; a reused `jti` in that window fails with
|
|
228
|
+
`pod_proof_replayed`.
|
|
229
|
+
|
|
230
|
+
The first successful refresh moves `pending` to `active` and revokes every other
|
|
231
|
+
credential with the same `identity.keyId` (whichever user holds it) and the same
|
|
232
|
+
user's other credentials with the same `deviceId`. A `pending` credential past
|
|
233
|
+
`activateBefore` is rejected and deleted. Revoked credentials are deleted, so a
|
|
234
|
+
later refresh fails with `pod_credential_invalid`. Response
|
|
235
|
+
`{"accessToken","expiresIn"}`.
|
|
236
|
+
|
|
237
|
+
### Status
|
|
238
|
+
|
|
239
|
+
`POST /api/v1/pod/credential/status`, user `Authorization`, body `{"podId"}` →
|
|
240
|
+
`{"podId","status","activatedAt"?}` with `status` one of `pending`, `active`,
|
|
241
|
+
`absent` (schema `schema/pod-credential-status.v1.schema.json`). `absent`
|
|
242
|
+
covers no such credential, revoked, pending past `activateBefore`, and a
|
|
243
|
+
credential owned by another user, so the answer never reveals other users'
|
|
244
|
+
pods. `activatedAt` (epoch ms) is present when `active`. Commissioners use it to
|
|
245
|
+
decide the outcome of an activation they could not observe (section 7).
|
|
246
|
+
|
|
247
|
+
### Revoke
|
|
248
|
+
|
|
249
|
+
`POST /api/v1/pod/credential/revoke`, body `{"podId"}` with the owning user's
|
|
250
|
+
`Authorization`, or `{"podId","refreshToken","proof"}` where the proof uses
|
|
251
|
+
`aud` = `pod-credential-revoke` (pod logout and factory reset). Idempotent:
|
|
252
|
+
revoking a pod that does not exist or that the caller does not own is a no-op
|
|
253
|
+
that also answers `{"ok":true}`, so the answer never reveals other users' pods.
|
|
254
|
+
Response `{"ok":true}`.
|
|
255
|
+
|
|
256
|
+
### List and rename
|
|
257
|
+
|
|
258
|
+
`POST /api/v1/pod/list` (user) returns `{"pods":[…]}`, the user's active pods
|
|
259
|
+
and unexpired pending pods, each
|
|
260
|
+
`{"podId","name","deviceId","model","platform","firmwareVersion","identityFingerprint","status","issuedAt","activatedAt"}`.
|
|
261
|
+
`POST /api/v1/pod/rename` `{"podId","name"}` (user) returns the updated pod.
|
|
262
|
+
|
|
263
|
+
### Tokens
|
|
264
|
+
|
|
265
|
+
- Access: `pod-` + HS256 JWT. Claims: `iss`, `aud`, `sub` = `podId`, `user_id`,
|
|
266
|
+
`tenant_id`, `device_id`, `token_type` = `pod_access`, `iat`, `exp` (3600 s),
|
|
267
|
+
`jti`. The signing key is derived from the Hub secret with HMAC-SHA256 over the
|
|
268
|
+
label `mhome-pod-access-v1`, so Hub and pod tokens never verify as each other.
|
|
269
|
+
- Refresh: `pod-` + 43-character base64url of 32 random bytes. Lion stores only
|
|
270
|
+
its SHA-256. It never rotates; the identity proof binds it to the device.
|
|
271
|
+
- Pods send `Authorization: Bearer pod-…` (and `"token":"Bearer pod-…"` on the
|
|
272
|
+
WebSocket). `pod-` is dispatched by prefix before any JWT parsing. Only the cloud WebSocket
|
|
273
|
+
`/auth` and `/api/v1/hub/token/exchange` accept pod access tokens. Every other
|
|
274
|
+
API keeps accepting user tokens only. No target allowlist applies in v1.
|
|
275
|
+
- Lion checks an access token against the stored credential on every use, so a
|
|
276
|
+
revoked or superseded credential stops working before its `exp`.
|
|
277
|
+
- A pod session acts as its user across all of that user's Spaces. A cloud
|
|
278
|
+
WebSocket session authenticated with a pod access token remembers the `podId`
|
|
279
|
+
and the token `exp`. Lion closes it with a policy-violation close whose reason
|
|
280
|
+
is `POD_TOKEN_EXPIRED` at `exp` and `POD_REVOKED` when the pod is revoked;
|
|
281
|
+
revocations reach sessions on every Lion instance through a shared event
|
|
282
|
+
channel. Either close only ends that connection; whether the pod is revoked
|
|
283
|
+
is decided by its next refresh.
|
|
284
|
+
- `/api/v1/hub/token/exchange` with a pod access token returns a Hub JWT with
|
|
285
|
+
`clientKind` = `pod` and `podId` claims and the same lifetime as a Hub JWT
|
|
286
|
+
for a user token.
|
|
287
|
+
- Revoking a pod credential, by any path, also revokes the pod on the Hubs of
|
|
288
|
+
its user's local Spaces. Before committing the revocation Lion marks the
|
|
289
|
+
`scope.pods` resource dirty for each such Space; after it commits, Lion
|
|
290
|
+
notifies the Hub through the pull-sync contract. A Hub that is offline pulls
|
|
291
|
+
the marker when its bridge reconnects. `scope.pods` is a snapshot
|
|
292
|
+
`{"podIds":[…]}` of the active pods of the Space's members. The Hub revokes
|
|
293
|
+
the local tokens it issued to any other pod before the pull, disables the
|
|
294
|
+
local auth of that pod's app client once no active token is left, and refuses
|
|
295
|
+
and stops renewing that pod's local connections.
|
|
296
|
+
|
|
297
|
+
## 6. Pod runtime authentication
|
|
298
|
+
|
|
299
|
+
- Cloud `/auth`: `{"token":"Bearer pod-…","devToken":null,"source":"pod","deviceId","activeScope"}`,
|
|
300
|
+
then `/focus`. Lion rejects `source=pod` without a pod token, a pod token
|
|
301
|
+
with any other source, and a `deviceId` other than the one the credential was
|
|
302
|
+
issued to.
|
|
303
|
+
- The pod refreshes its access token proactively, 2 minutes before `exp`, and
|
|
304
|
+
re-authenticates the cloud session with the new token. A refresh rejected
|
|
305
|
+
with `pod_credential_invalid` or `pod_activation_expired` moves the pod to
|
|
306
|
+
`needs_recommission`.
|
|
307
|
+
- Space switching: the pod chooses among the Spaces returned by `/auth` and
|
|
308
|
+
re-sends `/focus`; it keeps one Hub JWT and Hub key per Hub and Space.
|
|
309
|
+
- Local Hub: the Hub identity endpoint `POST /api/v1/hub/identity/get`
|
|
310
|
+
(`{"hubId","tenantId","scopeId"}`) is public and needs no token. The pod
|
|
311
|
+
proves the Hub identity, then authenticates to the local Hub the way a Client
|
|
312
|
+
does: the first local `/auth` carries its pod access token with
|
|
313
|
+
`source=cloud` (`clientSource=pod`, `deviceId`, `tenantId`, `scopeId`,
|
|
314
|
+
`hubId`). The Hub exchanges that token with the cloud and returns a
|
|
315
|
+
long-lived local Hub JWT in `jwtToken`, which the pod stores per Hub. Later
|
|
316
|
+
local `/auth` requests use only that JWT with `source=local`. When the stored
|
|
317
|
+
JWT is about to expire or the Hub rejects it, the pod discards it and
|
|
318
|
+
authenticates with `source=cloud` again.
|
|
319
|
+
|
|
320
|
+
## 7. Native Client targets
|
|
321
|
+
|
|
322
|
+
All targets are handled by the native Client and never forwarded. Payloads are
|
|
323
|
+
domain input JSON. Clients without a usable Bluetooth adapter report
|
|
324
|
+
`adapter.state` and reject commissioning with `bluetooth_unavailable`.
|
|
325
|
+
|
|
326
|
+
A rejected request uses the facade error envelope
|
|
327
|
+
`{"error","message","details":{"reason"}}`: `error` is `BAD_REQUEST` for an
|
|
328
|
+
unreadable body, `UNSUPPORTED` for an unknown target and `PRECONDITION_FAIL`
|
|
329
|
+
otherwise. `PRECONDITION_FAIL` errors carry `details.reason`, one of the stable
|
|
330
|
+
values below. User interfaces
|
|
331
|
+
map the reason (and the session `error.code` and `detail`) to their own copy and
|
|
332
|
+
never show these values or `message` verbatim.
|
|
333
|
+
|
|
334
|
+
| Reason | Meaning |
|
|
335
|
+
| --- | --- |
|
|
336
|
+
| `session_active` | another pod or Host session is running |
|
|
337
|
+
| `device_gone` | the candidate is no longer nearby |
|
|
338
|
+
| `not_commissionable` | the device is not accepting commissioning now (also while it is locked) |
|
|
339
|
+
| `wrong_kind` | a Host candidate given to a pod target or the reverse |
|
|
340
|
+
| `unknown_session` | no session with this `sessionId` |
|
|
341
|
+
| `wrong_state` | the session is not in a state that accepts this request |
|
|
342
|
+
| `invalid_code` | the code is not 6 digits |
|
|
343
|
+
| `invalid_wifi` | SSID not 1–32 bytes, or password not empty or 8–63 characters (64 hex digits) |
|
|
344
|
+
| `bluetooth_unavailable` | no usable Bluetooth adapter |
|
|
345
|
+
| `not_signed_in` | no signed-in MeowLink account |
|
|
346
|
+
| `scope_not_offered` | the Space is not among those offered, or the account has no Space |
|
|
347
|
+
| `cloud_unreachable_for_device` | the cloud address is loopback |
|
|
348
|
+
| `busy` | the native Client cannot take the request now; retry |
|
|
349
|
+
|
|
350
|
+
| Target | Request | Response |
|
|
351
|
+
| --- | --- | --- |
|
|
352
|
+
| `/local/pod/discovery/start` | `{}` | `{"leaseId","expiresAtMs"}`, 30 s lease |
|
|
353
|
+
| `/local/pod/discovery/renew` | `{"leaseId"}` | `{"leaseId","expiresAtMs"}` |
|
|
354
|
+
| `/local/pod/discovery/stop` | `{"leaseId"}` | `{}` |
|
|
355
|
+
| `/local/pod/discovery/list` | `{}` | discovery snapshot |
|
|
356
|
+
| `/local/pod/commission/start` | `{"candidateId"}` | session snapshot |
|
|
357
|
+
| `/local/pod/commission/code` | `{"sessionId","code"}` | session snapshot |
|
|
358
|
+
| `/local/pod/commission/wifi` | `{"sessionId","ssid","password"?}` | session snapshot |
|
|
359
|
+
| `/local/pod/commission/wifi/scan` | `{"sessionId"}` | session snapshot |
|
|
360
|
+
| `/local/pod/commission/authorize` | `{"sessionId","scopeId"}` | session snapshot |
|
|
361
|
+
| `/local/pod/commission/cancel` | `{"sessionId"}` | session snapshot |
|
|
362
|
+
| `/local/pod/commission/renew` | `{"sessionId"}` | session snapshot |
|
|
363
|
+
| `/local/pod/commission/status` | `{}` | `{"session": snapshot or null}` |
|
|
364
|
+
| `/local/pod/bluetooth/settings` | `{}` | `{"opened"}` |
|
|
365
|
+
|
|
366
|
+
`/local/pod/bluetooth/settings` opens the system settings that fix the
|
|
367
|
+
current adapter state (enable Bluetooth or Location, or the app's permission
|
|
368
|
+
page) and answers whether it did; a platform without such a page answers
|
|
369
|
+
`{"opened":false}`.
|
|
370
|
+
|
|
371
|
+
Discovery is shared by every MeowLink device kind: each candidate carries
|
|
372
|
+
`kind` (`pod` or `host`). `/local/pod/commission/start` accepts only `pod`
|
|
373
|
+
candidates; Host candidates go to `/local/host/provision/*`.
|
|
374
|
+
|
|
375
|
+
Events: `/local/pod/discovery/changed` carries a discovery snapshot, at most
|
|
376
|
+
once per second; `/local/pod/commission/changed` carries a session snapshot.
|
|
377
|
+
Scanning runs only while at least one lease is live and pauses while a pod or
|
|
378
|
+
Host session runs; it resumes after Bluetooth is turned back on. Candidates
|
|
379
|
+
unseen for 10 s are removed. One session per native Client. Events are hints:
|
|
380
|
+
UIs reconcile with `status` and discovery `list` on start, resume, reconnect and
|
|
381
|
+
when they become visible, and a snapshot with a lower `revision` than one
|
|
382
|
+
already seen for the same session is stale.
|
|
383
|
+
|
|
384
|
+
The session that `start` creates is owned through a 30-second lease. The
|
|
385
|
+
caller renews it with `/local/pod/commission/renew` every 10 seconds while it
|
|
386
|
+
shows the session. An expired lease cancels the session, except in `delivering`
|
|
387
|
+
and `activating`, which run to their conclusion.
|
|
388
|
+
|
|
389
|
+
Sign-in and Spaces are checked at `start` (`not_signed_in`,
|
|
390
|
+
`scope_not_offered`) and again when the authorization prompt is built.
|
|
391
|
+
|
|
392
|
+
A pod inside a Wi-Fi change window (`pod-info` `"mode":"reprovision"`) gets a
|
|
393
|
+
Wi-Fi-only session. From `reading_info` on, its snapshots carry
|
|
394
|
+
`"mode":"reprovision"`; the commissioner always offers Wi-Fi, and after the pod
|
|
395
|
+
joins it polls `pod-credential` `status` (still `joining_wifi`) until the pod
|
|
396
|
+
reports `commissioned`, then records `completed` and sends `finish`. No
|
|
397
|
+
authorization prompt is shown, no credential is issued and `completed` carries
|
|
398
|
+
no `podId`; the pod keeps its existing credential and Space. A pod that reports
|
|
399
|
+
`failed` ends the session with `wifi_failed` carrying the pod's code (for
|
|
400
|
+
example `storage_failed`) in `detail`. UIs show such a session as changing the
|
|
401
|
+
pod's Wi-Fi rather than adding a pod.
|
|
402
|
+
|
|
403
|
+
Session states, in order: `connecting`, `awaiting_code`, `securing`,
|
|
404
|
+
`reading_info`, `awaiting_wifi`, `joining_wifi`, `awaiting_authorization`,
|
|
405
|
+
`issuing`, `delivering`, `activating`, `completed`; terminal `failed`,
|
|
406
|
+
`cancelled`, `timed_out`. Wi-Fi states are skipped when the pod reports a
|
|
407
|
+
configured network and `prov-config` status reports it connected; while that
|
|
408
|
+
status is `connecting` the commissioner keeps polling, and only after a failure
|
|
409
|
+
does it reset Wi-Fi (`prov-ctrl`) and offer new credentials. The commissioner
|
|
410
|
+
scans for networks once on entering `awaiting_wifi`; a scan request while that
|
|
411
|
+
scan runs waits for it. `revision` increases on every observable change.
|
|
412
|
+
|
|
413
|
+
`expiresAtMs` is 10 minutes after `start` until delivery starts; from
|
|
414
|
+
`delivering` on it is `activateBefore` plus 30 seconds.
|
|
415
|
+
|
|
416
|
+
Lion decides the outcome of activation. In `delivering` and `activating`, a
|
|
417
|
+
lost BLE link, a failed status exchange or the deadline does not revoke by
|
|
418
|
+
itself; the commissioner asks `/api/v1/pod/credential/status`:
|
|
419
|
+
|
|
420
|
+
- `active`: `completed` with `podId`.
|
|
421
|
+
- `pending`: ask again every 5 seconds until `activateBefore` plus 30 seconds,
|
|
422
|
+
then revoke and fail with `activation_failed`.
|
|
423
|
+
- `absent`: fail with `activation_failed`.
|
|
424
|
+
|
|
425
|
+
A refused `deliver` fails with `delivery_failed` carrying the pod's `reason`
|
|
426
|
+
(`invalid_payload` or `wrong_state`) in `detail`, and revokes. When the pod
|
|
427
|
+
itself reports `failed`, the commissioner revokes and fails with
|
|
428
|
+
`activation_failed` carrying the pod's code. `activation_failed` without a pod
|
|
429
|
+
code carries `not_activated`. The commissioner records `completed` before
|
|
430
|
+
sending `finish`. Native Clients keep a non-terminal session running while the
|
|
431
|
+
app is in the background (Android foreground service of type
|
|
432
|
+
`connectedDevice`, iOS background task).
|
|
433
|
+
|
|
434
|
+
Session error codes: `bluetooth_unavailable`, `device_busy`, `disconnected`,
|
|
435
|
+
`code_rejected` (retry allowed), `code_locked`, `wifi_auth_failed`,
|
|
436
|
+
`wifi_not_found`, `wifi_failed`, `issue_failed`, `delivery_failed`,
|
|
437
|
+
`activation_failed` (with the pod's activation code, or `not_activated`, in
|
|
438
|
+
`detail`), `session_timeout`. `code_locked` is terminal: the device shows a new
|
|
439
|
+
code once it accepts handshakes again. `failed` and `timed_out` always carry `error`. A rejected
|
|
440
|
+
code returns to `awaiting_code` with `code_rejected`; a failed join returns to
|
|
441
|
+
`awaiting_wifi` with the `wifi_*` code. No other state carries `error`. After
|
|
442
|
+
issue, every terminal state other than `completed` revokes the credential unless
|
|
443
|
+
Lion already reported it `absent`; the revoke uses the account that issued the
|
|
444
|
+
credential.
|
|
445
|
+
|
|
446
|
+
The schemas are `schema/pod-discovery.v1.schema.json` and
|
|
447
|
+
`schema/pod-commission.v1.schema.json`.
|