@mikrojs/native 0.17.0-pr-276.20260721233726 → 0.17.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/CMakeLists.txt +1 -1
- package/include/mikrojs/private.h +9 -3
- package/package.json +4 -3
- package/prebuilds/darwin-arm64/mikrojs.napi.node +0 -0
- package/prebuilds/linux-arm64/mikrojs.napi.node +0 -0
- package/prebuilds/linux-x64/mikrojs.napi.node +0 -0
- package/runtime/ota/client-impl.ts +590 -0
- package/runtime/ota/client.ts +77 -0
- package/runtime/ota/ota.ts +2 -2
- package/runtime/ota/policy.ts +3 -3
- package/runtime/ota/types.ts +3 -3
- package/src/mik_repl.cpp +31 -9
package/CMakeLists.txt
CHANGED
|
@@ -82,7 +82,7 @@ endif()
|
|
|
82
82
|
include(cmake/mikrojs_bytecode.cmake)
|
|
83
83
|
mikrojs_generate_bytecode(
|
|
84
84
|
RUNTIME_DIR "${CMAKE_CURRENT_SOURCE_DIR}/runtime"
|
|
85
|
-
MODULES abort cbor env result schema fs http/helpers http/request i2c kv/nvs kv/rtc kv/shared module neopixel observable observable/lazy observable/operators ota pin pwm reader sleep spi sntp stdio stream sys test uart udp wifi
|
|
85
|
+
MODULES abort cbor env result schema fs http/helpers http/request i2c kv/nvs kv/rtc kv/shared module neopixel observable observable/lazy observable/operators ota ota/client pin pwm reader sleep spi sntp stdio stream sys test uart udp wifi
|
|
86
86
|
MODULE_PREFIX "mikro"
|
|
87
87
|
SYMBOL_PREFIX "mikro"
|
|
88
88
|
TARGET gen_bytecode
|
|
@@ -340,11 +340,17 @@ void mik__repl_set_paused(bool paused);
|
|
|
340
340
|
* log.txt, and replies MIK_MSG_OK. No-op (still OK) when file logging
|
|
341
341
|
* is disabled. */
|
|
342
342
|
#define MIK_CMD_LOG_RESET 0x2C
|
|
343
|
-
/*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
343
|
+
/* Stage a streamed .tgz build for install at the next boot. The build must
|
|
344
|
+
* already be staged via DEPLOY_PUT("/.build.tgz", size) + PUT_CHUNK frames;
|
|
345
|
+
* the device verifies its SHA-256 now and the boot reconcile installs it on
|
|
346
|
+
* a clean heap (adopt mode: comes up GOOD, no trial, no rollback baseline).
|
|
346
347
|
* Payload: u16le checksum_len | checksum bytes. */
|
|
347
348
|
#define MIK_CMD_DEPLOY_BUILD 0x2D
|
|
349
|
+
/* Report and clear the outcome of the last adopt-mode boot install. No
|
|
350
|
+
* payload. Reply is MIK_MSG_OK with:
|
|
351
|
+
* u8 status (0 none | 1 ok | 2 fail) | u16le chk_len | chk
|
|
352
|
+
* | u16le reason_len | reason | u16le detail_len | detail. */
|
|
353
|
+
#define MIK_CMD_DEPLOY_RESULT 0x2E
|
|
348
354
|
|
|
349
355
|
#define MIK_CMD_CONFIG_LIST 0x40
|
|
350
356
|
#define MIK_CMD_CONFIG_SET 0x41
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mikrojs/native",
|
|
3
|
-
"version": "0.17.0
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Mikro.js C++ runtime library and Node.js native addon",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"esp32",
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
"./runtime/neopixel/types": "./runtime/neopixel/types.ts",
|
|
65
65
|
"./runtime/observable/operators": "./runtime/observable/operators.ts",
|
|
66
66
|
"./runtime/observable/types": "./runtime/observable/types.ts",
|
|
67
|
+
"./runtime/ota/client-impl": "./runtime/ota/client-impl.ts",
|
|
67
68
|
"./runtime/ota/types": "./runtime/ota/types.ts",
|
|
68
69
|
"./runtime/pin/types": "./runtime/pin/types.ts",
|
|
69
70
|
"./runtime/pwm/types": "./runtime/pwm/types.ts",
|
|
@@ -85,14 +86,14 @@
|
|
|
85
86
|
"cmake-js": "^8.0.0",
|
|
86
87
|
"node-addon-api": "^8.7.0",
|
|
87
88
|
"node-gyp-build": "^4.8.4",
|
|
88
|
-
"@mikrojs/quickjs": "0.17.0
|
|
89
|
+
"@mikrojs/quickjs": "0.17.0"
|
|
89
90
|
},
|
|
90
91
|
"devDependencies": {
|
|
91
92
|
"@swc/core": "^1.15.30",
|
|
92
93
|
"@types/node": "^24.12.2",
|
|
93
94
|
"esbuild": "^0.28.0",
|
|
94
95
|
"terser": "^5.46.2",
|
|
95
|
-
"@mikrojs/registry": "0.17.0
|
|
96
|
+
"@mikrojs/registry": "0.17.0"
|
|
96
97
|
},
|
|
97
98
|
"engines": {
|
|
98
99
|
"node": ">=24.0.0"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
// Internal: the OTA client state machine behind `mikro/ota/client`. Bundled
|
|
2
|
+
// into ota/client.js; not exposed as its own subpath. Exists as a seam so host
|
|
3
|
+
// tests can drive the whole client against fakes — no hardware, no registry,
|
|
4
|
+
// no real restart.
|
|
5
|
+
|
|
6
|
+
import type {Request, RequestError} from 'mikro/http/helpers'
|
|
7
|
+
import {err, ok} from 'mikro/result'
|
|
8
|
+
|
|
9
|
+
import type {CborError} from '../cbor/types.js'
|
|
10
|
+
import type {Result} from '../result/types.js'
|
|
11
|
+
import type {DeviceName} from '../sys/types.js'
|
|
12
|
+
import type {Diagnostic, Offer, Ota, OtaError, Update} from './types.js'
|
|
13
|
+
|
|
14
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
|
15
|
+
|
|
16
|
+
/** Immutable facts about this device and the firmware under it. */
|
|
17
|
+
export interface DeviceIdentity {
|
|
18
|
+
deviceId: string
|
|
19
|
+
/** Firmware version string, and the hash of that image. A build published
|
|
20
|
+
* against a different firmware is withheld rather than offered. */
|
|
21
|
+
firmware: string
|
|
22
|
+
firmwareHash: string
|
|
23
|
+
/** QuickJS bytecode version the linked engine reads; a build compiled for
|
|
24
|
+
* another one cannot run here. */
|
|
25
|
+
bytecode: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Every effect the client can have on the world, in one record. Nothing below
|
|
30
|
+
* `createOtaClient` reaches for a runtime import — effects arrive here, so the
|
|
31
|
+
* policy can run against a fake with no hardware and no risk of a real
|
|
32
|
+
* restart().
|
|
33
|
+
*/
|
|
34
|
+
export interface ClientIo {
|
|
35
|
+
/** Wall-clock delay. The only clock the client has. */
|
|
36
|
+
sleep(ms: number): Promise<void>
|
|
37
|
+
/** HTTP. The only network the client has. */
|
|
38
|
+
request: Request
|
|
39
|
+
/** Uniform [0, 1). Injected so tests can pin the jitter. */
|
|
40
|
+
random(): number
|
|
41
|
+
/** Serial diagnostics. Format string and args pass through untouched. */
|
|
42
|
+
log(level: LogLevel, format: string, ...args: unknown[]): void
|
|
43
|
+
/** CBOR codec for the check-in wire. */
|
|
44
|
+
encode(value: unknown): Result<Uint8Array, CborError>
|
|
45
|
+
decode(data: Uint8Array): Result<unknown, CborError>
|
|
46
|
+
|
|
47
|
+
/** The `mikro/ota` policy surface (staging, trials, retry budgets). */
|
|
48
|
+
ota: Ota
|
|
49
|
+
identity(): DeviceIdentity
|
|
50
|
+
/** Bytes free on the partition a build is staged onto, or undefined when the
|
|
51
|
+
* platform cannot report it. */
|
|
52
|
+
storageFree(): number | undefined
|
|
53
|
+
deviceName(): DeviceName
|
|
54
|
+
setDeviceName(name: DeviceName): void
|
|
55
|
+
restart(): never
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface CheckOptions {
|
|
59
|
+
/** Budget for the check-in round trip. Default 10s. */
|
|
60
|
+
checkinTimeoutMs?: number
|
|
61
|
+
/** Budget for the build download. Separate from the check-in's because it is
|
|
62
|
+
* a total wallclock deadline that cancels the transfer mid-stream, and an
|
|
63
|
+
* image needs orders of magnitude more of it than a check-in body does.
|
|
64
|
+
* Default 5m. */
|
|
65
|
+
downloadTimeoutMs?: number
|
|
66
|
+
/** Require a completed check-in (via `ota.confirm()`, which the client fires
|
|
67
|
+
* itself) before an installed build is kept. Default true. */
|
|
68
|
+
requireConfirm?: boolean
|
|
69
|
+
/** Clean boots a trial may consume before an unconfirmed build reverts.
|
|
70
|
+
* A deep-sleep wake counts as a clean boot, so wake-cycle devices on flaky
|
|
71
|
+
* networks should raise this above the default 1. */
|
|
72
|
+
trialBoots?: number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Runs after its round settles — after the check and any download, and before
|
|
76
|
+
* an auto-restart — so the network brought up in `beforeCheck` can go down. */
|
|
77
|
+
export type Teardown = () => void | Promise<void>
|
|
78
|
+
|
|
79
|
+
export interface WatchOptions extends CheckOptions {
|
|
80
|
+
/** Steady interval between rounds, end-of-round to start-of-next. Default 30m. */
|
|
81
|
+
checkinIntervalMs?: number
|
|
82
|
+
/** Delay before the first round. Default 5s. */
|
|
83
|
+
initialDelayMs?: number
|
|
84
|
+
/** Interval after a failed round, capped at `checkinIntervalMs`. Default 1m. */
|
|
85
|
+
retryAfterFailureMs?: number
|
|
86
|
+
/** Spread every scheduled sleep by ±10%, so a fleet that lost power together
|
|
87
|
+
* does not check in phase-locked forever. Default true; pass false for
|
|
88
|
+
* exact intervals (a demo watching for the update to land, a single
|
|
89
|
+
* device where the spread only delays it). */
|
|
90
|
+
jitter?: boolean
|
|
91
|
+
/** Bring the network up for one round. `err` skips the round (retried at the
|
|
92
|
+
* failure interval) and no teardown runs — partial setup is the hook's own
|
|
93
|
+
* job to unwind. State shared with the teardown stays in this one scope. */
|
|
94
|
+
beforeCheck?(): Promise<Result<Teardown, unknown>>
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface Watcher {
|
|
98
|
+
/** Prevent future rounds and cancel the pending sleep. An in-flight round
|
|
99
|
+
* completes, but a build it stages no longer auto-restarts — it stays armed
|
|
100
|
+
* for the next natural reboot. */
|
|
101
|
+
stop(): void
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Why an offered build was not armed. Each is the policy working as intended;
|
|
105
|
+
* the distinction is logged and returned because the app's next move differs. */
|
|
106
|
+
export type DeclineReason =
|
|
107
|
+
| 'trial-pending'
|
|
108
|
+
| 'current'
|
|
109
|
+
| 'abandoned'
|
|
110
|
+
| 'exhausted'
|
|
111
|
+
| 'download-failed'
|
|
112
|
+
| 'install-failed'
|
|
113
|
+
|
|
114
|
+
/** The check-in never completed. */
|
|
115
|
+
export type CheckError = RequestError | CborError | {name: 'Status'; status: number}
|
|
116
|
+
|
|
117
|
+
export type CheckResult =
|
|
118
|
+
/** Build downloaded, verified, and armed. The app restarts when ready. */
|
|
119
|
+
| {status: 'staged'; offer: Offer}
|
|
120
|
+
| {status: 'up-to-date'}
|
|
121
|
+
/** An offer arrived but was not armed. */
|
|
122
|
+
| {status: 'not-staged'; reason: DeclineReason; error?: OtaError}
|
|
123
|
+
/** Transient: the check-in did not complete, so the running trial (if any)
|
|
124
|
+
* was not confirmed. */
|
|
125
|
+
| {status: 'failed'; error: CheckError}
|
|
126
|
+
/** The registry rejected the update key (HTTP 401). Permanent until
|
|
127
|
+
* re-enrollment over the cable. */
|
|
128
|
+
| {status: 'unauthorized'}
|
|
129
|
+
| {status: 'not-enrolled'}
|
|
130
|
+
|
|
131
|
+
export interface OtaClient {
|
|
132
|
+
check(options?: CheckOptions): Promise<CheckResult>
|
|
133
|
+
watch(options?: WatchOptions): Watcher
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const DEFAULT_CHECKIN_TIMEOUT_MS = 10_000
|
|
137
|
+
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 300_000
|
|
138
|
+
const DEFAULT_INTERVAL_MS = 30 * 60_000
|
|
139
|
+
const DEFAULT_INITIAL_DELAY_MS = 5_000
|
|
140
|
+
const DEFAULT_RETRY_MS = 60_000
|
|
141
|
+
|
|
142
|
+
/** What the watch loop does after a round: retry at the shortened interval, or
|
|
143
|
+
* wait the full one. Deliberately not "did the check-in succeed" — a rejected
|
|
144
|
+
* update key also waits the full interval (a dead key mustn't hammer the
|
|
145
|
+
* registry every retry interval forever). */
|
|
146
|
+
type NextRound = 'soon' | 'later'
|
|
147
|
+
|
|
148
|
+
interface Enrollment {
|
|
149
|
+
registryUrl: string
|
|
150
|
+
bearer: string
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function createOtaClient(io: ClientIo): OtaClient {
|
|
154
|
+
// One check at a time: staging is a single native session, and two
|
|
155
|
+
// interleaved check-ins would corrupt it. Later calls queue behind the
|
|
156
|
+
// running one; each still gets its own result.
|
|
157
|
+
let chain: Promise<unknown> = Promise.resolve()
|
|
158
|
+
|
|
159
|
+
// reconcile() clears its report as it reads, so it runs once per boot and
|
|
160
|
+
// the report is held here until a check-in actually delivers it — a failed
|
|
161
|
+
// first check-in no longer loses it.
|
|
162
|
+
let reconciled = false
|
|
163
|
+
let lastInstall: Diagnostic | undefined
|
|
164
|
+
|
|
165
|
+
let warnedInsecure = false
|
|
166
|
+
|
|
167
|
+
function enqueue<T>(task: () => Promise<T>): Promise<T> {
|
|
168
|
+
const run = chain.then(task)
|
|
169
|
+
chain = run.then(
|
|
170
|
+
() => undefined,
|
|
171
|
+
() => undefined,
|
|
172
|
+
)
|
|
173
|
+
return run
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function enrollment(): Enrollment | undefined {
|
|
177
|
+
// Written as a pair over the cable by `mikro ota enroll`; enrollment is the
|
|
178
|
+
// opt-in. Update keys never arrive over the network.
|
|
179
|
+
const registryUrl = io.ota.registry()
|
|
180
|
+
const bearer = io.ota.bearer()
|
|
181
|
+
if (registryUrl === undefined || bearer === undefined) return undefined
|
|
182
|
+
return {registryUrl, bearer}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function reconcileOnce(): void {
|
|
186
|
+
if (reconciled) return
|
|
187
|
+
reconciled = true
|
|
188
|
+
const report = io.ota.reconcile()
|
|
189
|
+
lastInstall = report.lastInstall
|
|
190
|
+
if (report.reverted) {
|
|
191
|
+
io.log('warn', 'ota: previous update failed its trial and was rolled back')
|
|
192
|
+
}
|
|
193
|
+
if (report.installed) {
|
|
194
|
+
io.log('info', 'ota: installed build %s on this boot', report.installed.slice(0, 12))
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function runCheck(enrolled: Enrollment, options: CheckOptions): Promise<CheckResult> {
|
|
199
|
+
const {registryUrl, bearer} = enrolled
|
|
200
|
+
const allowInsecure = isPrivateHttp(registryUrl)
|
|
201
|
+
if (allowInsecure && !warnedInsecure) {
|
|
202
|
+
// Every boot, not once at enrollment: a device that quietly runs a
|
|
203
|
+
// forgeable update channel should say so for as long as it does.
|
|
204
|
+
warnedInsecure = true
|
|
205
|
+
io.log(
|
|
206
|
+
'warn',
|
|
207
|
+
'ota: registry is http:// on a private network — updates are NOT authenticated',
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
reconcileOnce()
|
|
211
|
+
|
|
212
|
+
const running = io.ota.running()
|
|
213
|
+
const identity = io.identity()
|
|
214
|
+
const named = io.deviceName()
|
|
215
|
+
const free = io.storageFree()
|
|
216
|
+
// The shape is a contract with the registry's /api/v1/checkin: these are
|
|
217
|
+
// the inputs it arbitrates an offer from. Optional keys are omitted, not
|
|
218
|
+
// sent as undefined — the registry reads an absent `free` as "no figure to
|
|
219
|
+
// report", and JSON clients drop the key the same way.
|
|
220
|
+
const report = {
|
|
221
|
+
...identity,
|
|
222
|
+
running,
|
|
223
|
+
// The name pair: `[rev]` when cleared, `[rev, name]` otherwise. Sent
|
|
224
|
+
// every time so a lost response settles on the next check-in.
|
|
225
|
+
name: named.name === undefined ? [named.rev] : [named.rev, named.name],
|
|
226
|
+
...(free === undefined ? {} : {free}),
|
|
227
|
+
...(lastInstall === undefined ? {} : {lastInstall}),
|
|
228
|
+
}
|
|
229
|
+
io.log(
|
|
230
|
+
'debug',
|
|
231
|
+
'ota: checking %s (running %s v%s, bytecode %d, fw %s)',
|
|
232
|
+
`${registryUrl}/api/v1/checkin`,
|
|
233
|
+
running.checksum?.slice(0, 12) ?? 'none',
|
|
234
|
+
running.version ?? '?',
|
|
235
|
+
identity.bytecode,
|
|
236
|
+
identity.firmware,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
const encoded = io.encode(report)
|
|
240
|
+
if (!encoded.ok) {
|
|
241
|
+
io.log('error', 'ota: could not encode the check-in report', encoded.error)
|
|
242
|
+
return {status: 'failed', error: encoded.error}
|
|
243
|
+
}
|
|
244
|
+
const res = await io.request(`${registryUrl}/api/v1/checkin`, {
|
|
245
|
+
method: 'POST',
|
|
246
|
+
headers: {
|
|
247
|
+
'content-type': 'application/cbor',
|
|
248
|
+
accept: 'application/cbor',
|
|
249
|
+
authorization: `Bearer ${bearer}`,
|
|
250
|
+
},
|
|
251
|
+
timeoutMs: options.checkinTimeoutMs ?? DEFAULT_CHECKIN_TIMEOUT_MS,
|
|
252
|
+
body: encoded.value,
|
|
253
|
+
})
|
|
254
|
+
if (!res.ok) {
|
|
255
|
+
io.log('error', 'ota: checkin failed', res.error)
|
|
256
|
+
return {status: 'failed', error: res.error}
|
|
257
|
+
}
|
|
258
|
+
const response = res.value
|
|
259
|
+
if (response.status === 401) {
|
|
260
|
+
// The update key no longer authenticates (rotated, device deleted).
|
|
261
|
+
// Only a 401 means this. There is no fallback secret — re-enroll over
|
|
262
|
+
// the cable. Release the connection before returning.
|
|
263
|
+
await response.close()
|
|
264
|
+
io.log(
|
|
265
|
+
'error',
|
|
266
|
+
'ota: registry rejected the update key; re-enroll with `mikro ota enroll --re-enroll`',
|
|
267
|
+
)
|
|
268
|
+
return {status: 'unauthorized'}
|
|
269
|
+
}
|
|
270
|
+
if (response.status === 415) {
|
|
271
|
+
// The wire is CBOR-only by design; there is no JSON fallback to hide a
|
|
272
|
+
// registry that predates it.
|
|
273
|
+
await response.close()
|
|
274
|
+
io.log(
|
|
275
|
+
'error',
|
|
276
|
+
'ota: registry does not accept CBOR check-ins; upgrade the registry to a version that supports application/cbor',
|
|
277
|
+
)
|
|
278
|
+
return {status: 'failed', error: {name: 'Status', status: 415}}
|
|
279
|
+
}
|
|
280
|
+
if (!response.ok) {
|
|
281
|
+
// Release the connection before the heap-sensitive image request — an
|
|
282
|
+
// abandoned response holds its native slot and TLS buffers until its
|
|
283
|
+
// timeout fires.
|
|
284
|
+
await response.close()
|
|
285
|
+
io.log('error', 'ota: checkin returned status %d', response.status)
|
|
286
|
+
return {status: 'failed', error: {name: 'Status', status: response.status}}
|
|
287
|
+
}
|
|
288
|
+
const raw = await response.bytes()
|
|
289
|
+
if (!raw.ok) {
|
|
290
|
+
io.log('error', 'ota: reading the checkin response failed', raw.error)
|
|
291
|
+
return {status: 'failed', error: raw.error}
|
|
292
|
+
}
|
|
293
|
+
const body = io.decode(raw.value)
|
|
294
|
+
if (!body.ok) {
|
|
295
|
+
io.log('error', 'ota: invalid checkin response body', body.error)
|
|
296
|
+
return {status: 'failed', error: body.error}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// The check-in completed: the cached install report is delivered.
|
|
300
|
+
lastInstall = undefined
|
|
301
|
+
|
|
302
|
+
// Confirm before applying: a completed check-in is the whole health signal
|
|
303
|
+
// requireConfirm waits for, whether or not an offer follows — and resolving
|
|
304
|
+
// the trial now lets an offer published during it stage in this same pass
|
|
305
|
+
// (applyOffer skips every offer while a trial is unresolved).
|
|
306
|
+
if (running.trial) {
|
|
307
|
+
io.log('info', 'ota: check-in completed — confirming this build as healthy')
|
|
308
|
+
}
|
|
309
|
+
io.ota.confirm()
|
|
310
|
+
|
|
311
|
+
const renamed = nameFrom(body.value)
|
|
312
|
+
if (renamed !== undefined) {
|
|
313
|
+
io.setDeviceName(renamed)
|
|
314
|
+
io.log('info', 'ota: registry renamed this device to %s', renamed.name ?? '(no name)')
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const offer = io.ota.parseOffer(body.value, {allowInsecure})
|
|
318
|
+
if (offer === undefined) {
|
|
319
|
+
io.log('debug', 'ota: up to date, running the latest build')
|
|
320
|
+
return {status: 'up-to-date'}
|
|
321
|
+
}
|
|
322
|
+
io.log(
|
|
323
|
+
'info',
|
|
324
|
+
'ota: update available (%s, %d bytes); downloading',
|
|
325
|
+
offer.checksum.slice(0, 12),
|
|
326
|
+
offer.size,
|
|
327
|
+
)
|
|
328
|
+
const applied = await io.ota.applyOffer(
|
|
329
|
+
offer,
|
|
330
|
+
(update) => download(enrolled, options, offer, update),
|
|
331
|
+
{
|
|
332
|
+
requireConfirm: options.requireConfirm ?? true,
|
|
333
|
+
trialBoots: options.trialBoots ?? 1,
|
|
334
|
+
},
|
|
335
|
+
)
|
|
336
|
+
if (!applied.ok) {
|
|
337
|
+
const reason: DeclineReason =
|
|
338
|
+
applied.error.name === 'DownloadFailed' ? 'download-failed' : 'install-failed'
|
|
339
|
+
io.log('error', 'ota: update failed', applied.error)
|
|
340
|
+
return {status: 'not-staged', reason, error: applied.error}
|
|
341
|
+
}
|
|
342
|
+
if (applied.value === 'staged') {
|
|
343
|
+
io.log('info', 'ota: download verified and staged')
|
|
344
|
+
return {status: 'staged', offer}
|
|
345
|
+
}
|
|
346
|
+
// Not staged, not an error — applyOffer declined the offer:
|
|
347
|
+
// current already running this build
|
|
348
|
+
// trial-pending the running build hasn't resolved its trial (nearly
|
|
349
|
+
// unreachable: the confirm above resolves it first)
|
|
350
|
+
// abandoned the build is corrupt and won't be retried
|
|
351
|
+
// exhausted the retry budget for this build is spent until next boot
|
|
352
|
+
io.log('info', 'ota: offer not staged (%s)', applied.value)
|
|
353
|
+
return {status: 'not-staged', reason: applied.value}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** The download pump: stream the build straight into the staging area,
|
|
357
|
+
* resuming an interrupted transfer with a Range request. Chunked on purpose:
|
|
358
|
+
* buffering the whole build would put ~2x its size on the heap. */
|
|
359
|
+
async function download(
|
|
360
|
+
enrolled: Enrollment,
|
|
361
|
+
options: CheckOptions,
|
|
362
|
+
offer: Offer,
|
|
363
|
+
update: Update,
|
|
364
|
+
): Promise<Result<void, {message: string}>> {
|
|
365
|
+
const from = update.resumeOffset
|
|
366
|
+
// Already complete: a finish that failed transiently leaves every byte on
|
|
367
|
+
// flash and the offer pending, so resuming would ask for `bytes=<size>-`
|
|
368
|
+
// and take the 416 as a failed download forever. Hand it straight back to
|
|
369
|
+
// re-verify.
|
|
370
|
+
if (from >= offer.size) return ok()
|
|
371
|
+
if (from > 0) {
|
|
372
|
+
io.log('info', 'ota: resuming download from byte %d of %d', from, offer.size)
|
|
373
|
+
}
|
|
374
|
+
// Same-origin only. `offer.url` may legitimately point at another host (a
|
|
375
|
+
// CDN, an object store), so the update key goes out only when the download
|
|
376
|
+
// is on the registry's own origin. A build fetched elsewhere is a public
|
|
377
|
+
// artifact the checksum vouches for; a store that needs auth carries it in
|
|
378
|
+
// a signed url instead.
|
|
379
|
+
const headers: Record<string, string> = {}
|
|
380
|
+
if (from > 0) headers.range = `bytes=${from}-`
|
|
381
|
+
if (sameOrigin(offer.url, enrolled.registryUrl)) {
|
|
382
|
+
headers.authorization = `Bearer ${enrolled.bearer}`
|
|
383
|
+
}
|
|
384
|
+
const res = await io.request(offer.url, {
|
|
385
|
+
headers,
|
|
386
|
+
timeoutMs: options.downloadTimeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS,
|
|
387
|
+
})
|
|
388
|
+
if (!res.ok) {
|
|
389
|
+
return err({message: `download failed: ${describeRequestError(res.error)}`})
|
|
390
|
+
}
|
|
391
|
+
const response = res.value
|
|
392
|
+
if (!response.ok) {
|
|
393
|
+
await response.close()
|
|
394
|
+
return err({message: `download returned status ${response.status}`})
|
|
395
|
+
}
|
|
396
|
+
// A server free to ignore Range answers 200 with the whole build. Drop the
|
|
397
|
+
// prefix we already hold rather than failing, which would spend a retry
|
|
398
|
+
// for nothing.
|
|
399
|
+
let skip = response.status === 206 ? 0 : from
|
|
400
|
+
for await (const chunk of response.body) {
|
|
401
|
+
if (!chunk.ok) {
|
|
402
|
+
await response.close()
|
|
403
|
+
return err({message: `reading the download failed: ${describeRequestError(chunk.error)}`})
|
|
404
|
+
}
|
|
405
|
+
let bytes = chunk.value
|
|
406
|
+
if (skip > 0) {
|
|
407
|
+
if (bytes.length <= skip) {
|
|
408
|
+
skip -= bytes.length
|
|
409
|
+
continue
|
|
410
|
+
}
|
|
411
|
+
bytes = bytes.subarray(skip)
|
|
412
|
+
skip = 0
|
|
413
|
+
}
|
|
414
|
+
const written = update.write(bytes)
|
|
415
|
+
if (!written.ok) {
|
|
416
|
+
await response.close()
|
|
417
|
+
return written
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return ok()
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function check(options: CheckOptions = {}): Promise<CheckResult> {
|
|
424
|
+
const enrolled = enrollment()
|
|
425
|
+
if (enrolled === undefined) {
|
|
426
|
+
return Promise.resolve({status: 'not-enrolled'})
|
|
427
|
+
}
|
|
428
|
+
return enqueue(() => runCheck(enrolled, options))
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function watch(options: WatchOptions = {}): Watcher {
|
|
432
|
+
const maybeEnrolled = enrollment()
|
|
433
|
+
if (maybeEnrolled === undefined) {
|
|
434
|
+
io.log('info', 'ota: device not enrolled; run `mikro ota enroll` to enable OTA updates')
|
|
435
|
+
return {stop: () => undefined}
|
|
436
|
+
}
|
|
437
|
+
// Explicitly typed: the narrowing above does not reach into the hoisted
|
|
438
|
+
// round() declaration below.
|
|
439
|
+
const enrolled: Enrollment = maybeEnrolled
|
|
440
|
+
const intervalMs = options.checkinIntervalMs ?? DEFAULT_INTERVAL_MS
|
|
441
|
+
const retryMs = Math.min(options.retryAfterFailureMs ?? DEFAULT_RETRY_MS, intervalMs)
|
|
442
|
+
|
|
443
|
+
let stopped = false
|
|
444
|
+
let cancelSleep: (() => void) | undefined
|
|
445
|
+
|
|
446
|
+
// ±10% so a power-cut fleet doesn't stay phase-locked on the registry.
|
|
447
|
+
const jitterOn = options.jitter ?? true
|
|
448
|
+
function jitter(ms: number): number {
|
|
449
|
+
if (!jitterOn) return ms
|
|
450
|
+
return Math.round(ms * (0.9 + io.random() * 0.2))
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function pause(ms: number): Promise<void> {
|
|
454
|
+
return new Promise((resolve) => {
|
|
455
|
+
cancelSleep = resolve
|
|
456
|
+
void io.sleep(jitter(ms)).then(resolve)
|
|
457
|
+
})
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function round(): Promise<NextRound> {
|
|
461
|
+
let teardown: Teardown | undefined
|
|
462
|
+
if (options.beforeCheck) {
|
|
463
|
+
let setup: Result<Teardown, unknown>
|
|
464
|
+
try {
|
|
465
|
+
setup = await options.beforeCheck()
|
|
466
|
+
} catch (e) {
|
|
467
|
+
setup = err(e)
|
|
468
|
+
}
|
|
469
|
+
if (!setup.ok) {
|
|
470
|
+
io.log('warn', 'ota: beforeCheck failed; skipping this check', setup.error)
|
|
471
|
+
return 'soon'
|
|
472
|
+
}
|
|
473
|
+
teardown = setup.value
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Contain OTA faults: an uncaught throw (e.g. an OOM from the native
|
|
477
|
+
// HTTP layer under a low-heap stream) must not bubble out of the
|
|
478
|
+
// detached loop. A crashed check is just a check that didn't complete.
|
|
479
|
+
let outcome: CheckResult | undefined
|
|
480
|
+
try {
|
|
481
|
+
outcome = await enqueue(() => runCheck(enrolled, options))
|
|
482
|
+
} catch (e) {
|
|
483
|
+
io.log('error', 'ota: check crashed', e)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Teardown before any restart: the round is over either way, and the
|
|
487
|
+
// hook's invariant — teardown runs whenever setup succeeded — must not
|
|
488
|
+
// depend on what the check found.
|
|
489
|
+
if (teardown !== undefined) {
|
|
490
|
+
try {
|
|
491
|
+
await teardown()
|
|
492
|
+
} catch (e) {
|
|
493
|
+
io.log('warn', 'ota: teardown failed', e)
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (outcome === undefined) return 'soon'
|
|
498
|
+
if (outcome.status === 'staged') {
|
|
499
|
+
if (stopped) {
|
|
500
|
+
// stop() won the race: leave the build armed for the next natural
|
|
501
|
+
// reboot instead of restarting under the caller.
|
|
502
|
+
io.log('info', 'ota: update staged; watcher stopped, restart deferred')
|
|
503
|
+
return 'later'
|
|
504
|
+
}
|
|
505
|
+
io.log('info', 'ota: staged; restarting to install')
|
|
506
|
+
io.restart() // never returns
|
|
507
|
+
}
|
|
508
|
+
return outcome.status === 'failed' ? 'soon' : 'later'
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async function loop(): Promise<void> {
|
|
512
|
+
await pause(options.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS)
|
|
513
|
+
while (!stopped) {
|
|
514
|
+
const next = await round()
|
|
515
|
+
if (stopped) return
|
|
516
|
+
await pause(next === 'later' ? intervalMs : retryMs)
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Detached: never awaited by the caller, so the app keeps running
|
|
521
|
+
// regardless of OTA outcome. round() contains every throw, so this
|
|
522
|
+
// promise cannot reject.
|
|
523
|
+
void loop()
|
|
524
|
+
|
|
525
|
+
return {
|
|
526
|
+
stop: () => {
|
|
527
|
+
stopped = true
|
|
528
|
+
cancelSleep?.()
|
|
529
|
+
},
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return {check, watch}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** Render a RequestError into prose. `DownloadFn`'s contract carries only a
|
|
537
|
+
* `message` string forward into the `DownloadFailed` the policy reports, so
|
|
538
|
+
* this line is all that survives to explain a failed download on serial —
|
|
539
|
+
* include the underlying message, not just the variant name. */
|
|
540
|
+
function describeRequestError(error: RequestError): string {
|
|
541
|
+
return 'message' in error && error.message !== '' ? `${error.name}: ${error.message}` : error.name
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* The name pair a check-in response carries, as `[rev, name]` (or `[rev]` when
|
|
546
|
+
* it was cleared), or undefined when there is nothing to adopt. No `name` field
|
|
547
|
+
* means "no change" and never "clear it": a response lost after the registry
|
|
548
|
+
* adopted the device's own name looks exactly the same as one that never
|
|
549
|
+
* arrived, and re-sending the pair next check-in settles it.
|
|
550
|
+
*/
|
|
551
|
+
export function nameFrom(body: unknown): DeviceName | undefined {
|
|
552
|
+
if (typeof body !== 'object' || body === null) return undefined
|
|
553
|
+
const pair = (body as {name?: unknown}).name
|
|
554
|
+
if (!Array.isArray(pair)) return undefined
|
|
555
|
+
const rev = pair[0]
|
|
556
|
+
if (typeof rev !== 'number' || !Number.isInteger(rev) || rev < 0) return undefined
|
|
557
|
+
const value = pair[1]
|
|
558
|
+
return typeof value === 'string' && value !== '' ? {rev, name: value} : {rev}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** True when both urls share a scheme and authority. Deliberately literal: with
|
|
562
|
+
* no URL parser here, a spelled-out default port does not compare equal. Used
|
|
563
|
+
* to decide whether the update key may ride the download request. */
|
|
564
|
+
export function sameOrigin(a: string, b: string): boolean {
|
|
565
|
+
const origin = (url: string): string | undefined => {
|
|
566
|
+
const sep = url.indexOf('://')
|
|
567
|
+
if (sep < 0) return undefined
|
|
568
|
+
const start = sep + 3
|
|
569
|
+
const end = url.indexOf('/', start)
|
|
570
|
+
const authority = end < 0 ? url.slice(start) : url.slice(start, end)
|
|
571
|
+
return authority === '' ? undefined : (url.slice(0, start) + authority).toLowerCase()
|
|
572
|
+
}
|
|
573
|
+
const left = origin(a)
|
|
574
|
+
return left !== undefined && left === origin(b)
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** True for http:// on a LAN/loopback/mDNS host: development, not the internet.
|
|
578
|
+
* Anywhere else the scheme is not a judgement call — over http the offer's
|
|
579
|
+
* checksum is forgeable in the same response that names it. */
|
|
580
|
+
export function isPrivateHttp(url: string): boolean {
|
|
581
|
+
if (!url.startsWith('http://')) return false
|
|
582
|
+
const host = (url.slice(7).split('/')[0] ?? '').split(':')[0] ?? ''
|
|
583
|
+
if (host === 'localhost' || host.endsWith('.local')) return true
|
|
584
|
+
const p = host.split('.').map((s) => parseInt(s, 10))
|
|
585
|
+
if (p.length !== 4 || p.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false
|
|
586
|
+
if (p[0] === 10 || p[0] === 127) return true
|
|
587
|
+
if (p[0] === 192 && p[1] === 168) return true
|
|
588
|
+
if (p[0] === 169 && p[1] === 254) return true
|
|
589
|
+
return p[0] === 172 && p[1]! >= 16 && p[1]! <= 31
|
|
590
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import {decode, encode} from 'mikro/cbor'
|
|
2
|
+
import {request} from 'mikro/http/request'
|
|
3
|
+
import {ota} from 'mikro/ota'
|
|
4
|
+
import {sleep} from 'mikro/sleep'
|
|
5
|
+
import {
|
|
6
|
+
deviceId,
|
|
7
|
+
deviceName,
|
|
8
|
+
firmware,
|
|
9
|
+
restart,
|
|
10
|
+
setDeviceName,
|
|
11
|
+
storageUsage,
|
|
12
|
+
version,
|
|
13
|
+
} from 'mikro/sys'
|
|
14
|
+
|
|
15
|
+
import {type ClientIo, createOtaClient, type LogLevel} from './client-impl.js'
|
|
16
|
+
|
|
17
|
+
export type {
|
|
18
|
+
CheckError,
|
|
19
|
+
CheckOptions,
|
|
20
|
+
CheckResult,
|
|
21
|
+
DeclineReason,
|
|
22
|
+
Teardown,
|
|
23
|
+
Watcher,
|
|
24
|
+
WatchOptions,
|
|
25
|
+
} from './client-impl.js'
|
|
26
|
+
|
|
27
|
+
/* eslint-disable no-console -- serial diagnostics are the client's only output channel */
|
|
28
|
+
const consoleFor: Record<LogLevel, (format: string, ...args: unknown[]) => void> = {
|
|
29
|
+
debug: (format, ...args) => console.debug(format, ...args),
|
|
30
|
+
info: (format, ...args) => console.log(format, ...args),
|
|
31
|
+
warn: (format, ...args) => console.warn(format, ...args),
|
|
32
|
+
error: (format, ...args) => console.error(format, ...args),
|
|
33
|
+
}
|
|
34
|
+
/* eslint-enable no-console */
|
|
35
|
+
|
|
36
|
+
/** The real device. Every runtime symbol the client uses is bound here and
|
|
37
|
+
* nowhere else; `ota` members go through arrows because they are methods on
|
|
38
|
+
* the builtin singleton. */
|
|
39
|
+
const deviceIo: ClientIo = {
|
|
40
|
+
sleep,
|
|
41
|
+
request,
|
|
42
|
+
random: Math.random,
|
|
43
|
+
log: (level, format, ...args) => consoleFor[level](format, ...args),
|
|
44
|
+
encode,
|
|
45
|
+
decode,
|
|
46
|
+
ota,
|
|
47
|
+
identity: () => ({
|
|
48
|
+
deviceId,
|
|
49
|
+
firmware: version,
|
|
50
|
+
firmwareHash: firmware.hash,
|
|
51
|
+
bytecode: firmware.bytecodeVersion,
|
|
52
|
+
}),
|
|
53
|
+
storageFree: () => storageUsage()?.free,
|
|
54
|
+
deviceName,
|
|
55
|
+
setDeviceName,
|
|
56
|
+
restart,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const client = createOtaClient(deviceIo)
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* One-shot update check for wake-cycle apps: check in with the registry,
|
|
63
|
+
* confirm the running trial, and download + stage any offered build. Never
|
|
64
|
+
* restarts — on `{status: 'staged'}` the app calls `restart()` once its
|
|
65
|
+
* in-flight work is done. Connectivity is the app's business: call this with
|
|
66
|
+
* the network already up.
|
|
67
|
+
*/
|
|
68
|
+
export const check = client.check
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Periodic update checks for always-on apps: a detached background loop that
|
|
72
|
+
* checks on a jittered cadence, retries sooner after failures, and restarts
|
|
73
|
+
* the device after staging a build. Use `beforeCheck` to bring the network up
|
|
74
|
+
* per round (its returned teardown runs after the round). Do not combine with
|
|
75
|
+
* `check()` — the two modes are alternatives, one per app.
|
|
76
|
+
*/
|
|
77
|
+
export const watch = client.watch
|
package/runtime/ota/ota.ts
CHANGED
|
@@ -44,9 +44,9 @@ const store: OtaStore = {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
// Written as a pair to mik.sys by `mikro ota enroll`: the registry url and
|
|
47
|
-
// the device
|
|
47
|
+
// the device update key that authenticates against it.
|
|
48
48
|
function bearer(): string | undefined {
|
|
49
|
-
const v = sysGet('ota.
|
|
49
|
+
const v = sysGet('ota.updateKey')
|
|
50
50
|
return typeof v === 'string' ? v : undefined
|
|
51
51
|
}
|
|
52
52
|
|
package/runtime/ota/policy.ts
CHANGED
|
@@ -62,10 +62,10 @@ interface OtaDeps {
|
|
|
62
62
|
store: OtaStore
|
|
63
63
|
/** Live app version from /app/package.json, or undefined if unreadable. */
|
|
64
64
|
readAppVersion(): string | undefined
|
|
65
|
-
/** The check-in bearer: the device
|
|
65
|
+
/** The check-in bearer: the device update key from the system store, or
|
|
66
66
|
* undefined on an un-enrolled device. */
|
|
67
67
|
bearer(): string | undefined
|
|
68
|
-
/** The registry url the
|
|
68
|
+
/** The registry url the update key was issued against, or undefined. */
|
|
69
69
|
registry(): string | undefined
|
|
70
70
|
}
|
|
71
71
|
|
|
@@ -116,7 +116,7 @@ export function parseOffer(raw: unknown, opts?: {allowInsecure?: boolean}): Offe
|
|
|
116
116
|
// name where the build lives — a CDN or object store on another host, a signed
|
|
117
117
|
// url with its own query. Integrity is the checksum, verified over the whole
|
|
118
118
|
// download before install, so a wrong host yields a failed install, never a
|
|
119
|
-
// bad one; and the
|
|
119
|
+
// bad one; and the update key is the caller's to confine (the reference client
|
|
120
120
|
// attaches it only when the url is same-origin with the registry).
|
|
121
121
|
return {url: o.url, checksum: o.checksum, size: o.size}
|
|
122
122
|
}
|
package/runtime/ota/types.ts
CHANGED
|
@@ -133,12 +133,12 @@ export interface Ota {
|
|
|
133
133
|
confirm(): void
|
|
134
134
|
/** Reinstall the previous build immediately. */
|
|
135
135
|
revert(): Result<void, OtaInstallError>
|
|
136
|
-
/** The check-in bearer: the device
|
|
136
|
+
/** The check-in bearer: the device update key written at enrollment
|
|
137
137
|
* (`mikro ota enroll`), or `undefined` on an un-enrolled device. Read-only:
|
|
138
|
-
*
|
|
138
|
+
* update keys are provisioned over serial, never delivered in-band. */
|
|
139
139
|
bearer(): string | undefined
|
|
140
140
|
/** The registry url the device was enrolled against, written next to the
|
|
141
|
-
*
|
|
141
|
+
* update key at enrollment, or `undefined` on an un-enrolled device. */
|
|
142
142
|
registry(): string | undefined
|
|
143
143
|
}
|
|
144
144
|
|
package/src/mik_repl.cpp
CHANGED
|
@@ -32,9 +32,11 @@ static bool repl_protocol_mode = false;
|
|
|
32
32
|
static bool repl_paused = false;
|
|
33
33
|
static bool repl_async_skipped = false; /* set when eval bails on paused async */
|
|
34
34
|
static MIKReplTransport* repl_transport = nullptr;
|
|
35
|
-
/* Sized to hold the base map plus
|
|
36
|
-
*
|
|
37
|
-
|
|
35
|
+
/* Sized to hold the base map plus a maximum-length npm package name as the
|
|
36
|
+
* `fw` identity (214 chars), with room to spare for a typical `[rev, name]`
|
|
37
|
+
* pair. A max-length identity combined with a near-max device name can still
|
|
38
|
+
* exceed this; refresh_ready then drops the name (never the identity). */
|
|
39
|
+
static uint8_t ready_buf[384];
|
|
38
40
|
static size_t ready_len = 0;
|
|
39
41
|
|
|
40
42
|
/* Transient flag: set by MIK_ProtocolExit to break the current ServeLoop
|
|
@@ -879,7 +881,12 @@ static std::vector<uint8_t> proto_complete(JSContext* ctx, const char* partial,
|
|
|
879
881
|
/* ── Protocol-mode REPL ──────────────────────────────────────────── */
|
|
880
882
|
|
|
881
883
|
/* Fills ready_buf/ready_len with CBOR device info:
|
|
882
|
-
* {"chip": tstr, "id": tstr, "v": tstr, "
|
|
884
|
+
* {"chip": tstr, "id": tstr, "v": tstr, "fw": tstr (when built with
|
|
885
|
+
* MIK_FW_NAME), "name": tstr (only when named)}.
|
|
886
|
+
* `fw` is the firmware identity (the firmware project's package name). The
|
|
887
|
+
* host only auto-flashes its bundled prebuilt over a device whose identity
|
|
888
|
+
* matches that prebuilt; omitting it reads as firmware predating identity
|
|
889
|
+
* reporting, which the host treats as its own bundled firmware.
|
|
883
890
|
* `name` carries the raw `[rev, name]` pair from mik.sys, so the host reads the
|
|
884
891
|
* name and its revision together and can never see them out of step. Omitted
|
|
885
892
|
* entirely when the device has never been named, which the host reads as
|
|
@@ -896,16 +903,20 @@ static void refresh_ready(MIKReplTransport* transport);
|
|
|
896
903
|
/* Encodes the MSG_READY map into buf, or measures it when buf is null. Returns
|
|
897
904
|
* the length the encoding needs, which exceeds cap when it did not fit. */
|
|
898
905
|
static size_t encode_ready(uint8_t* buf, size_t cap, const char* chip, const char* id,
|
|
899
|
-
const char* version, const char* name) {
|
|
906
|
+
const char* version, const char* fw, const char* name) {
|
|
900
907
|
nanocbor_encoder_t enc;
|
|
901
908
|
nanocbor_encoder_init(&enc, buf, cap);
|
|
902
|
-
nanocbor_fmt_map(&enc, name ?
|
|
909
|
+
nanocbor_fmt_map(&enc, 3 + (fw ? 1 : 0) + (name ? 1 : 0));
|
|
903
910
|
nanocbor_put_tstr(&enc, "chip");
|
|
904
911
|
nanocbor_put_tstr(&enc, chip);
|
|
905
912
|
nanocbor_put_tstr(&enc, "id");
|
|
906
913
|
nanocbor_put_tstr(&enc, id);
|
|
907
914
|
nanocbor_put_tstr(&enc, "v");
|
|
908
915
|
nanocbor_put_tstr(&enc, version);
|
|
916
|
+
if (fw) {
|
|
917
|
+
nanocbor_put_tstr(&enc, "fw");
|
|
918
|
+
nanocbor_put_tstr(&enc, fw);
|
|
919
|
+
}
|
|
909
920
|
if (name) {
|
|
910
921
|
nanocbor_put_tstr(&enc, "name");
|
|
911
922
|
nanocbor_put_tstr(&enc, name);
|
|
@@ -922,6 +933,12 @@ static void refresh_ready(MIKReplTransport* transport) {
|
|
|
922
933
|
MIK_FW_VERSION;
|
|
923
934
|
#else
|
|
924
935
|
"0.0.0-dev";
|
|
936
|
+
#endif
|
|
937
|
+
const char* fw =
|
|
938
|
+
#ifdef MIK_FW_NAME
|
|
939
|
+
MIK_FW_NAME;
|
|
940
|
+
#else
|
|
941
|
+
nullptr;
|
|
925
942
|
#endif
|
|
926
943
|
const char* name =
|
|
927
944
|
MIK_GetPlatform()->get_device_name ? MIK_GetPlatform()->get_device_name() : nullptr;
|
|
@@ -929,11 +946,16 @@ static void refresh_ready(MIKReplTransport* transport) {
|
|
|
929
946
|
* end but still reports the length it would have needed, so ready_len must
|
|
930
947
|
* come from a run that actually fit: sending the measured length would read
|
|
931
948
|
* past ready_buf. Drop `name` rather than truncate it, since a partial pair
|
|
932
|
-
* decodes as a different name at the host.
|
|
933
|
-
|
|
949
|
+
* decodes as a different name at the host. `name` goes before `fw`: a
|
|
950
|
+
* dropped `fw` reads as the host's own bundled firmware, re-enabling the
|
|
951
|
+
* auto-reflash the identity exists to prevent. */
|
|
952
|
+
if (name && encode_ready(nullptr, 0, chip, id, version, fw, name) > sizeof(ready_buf)) {
|
|
934
953
|
name = nullptr;
|
|
935
954
|
}
|
|
936
|
-
|
|
955
|
+
if (fw && encode_ready(nullptr, 0, chip, id, version, fw, name) > sizeof(ready_buf)) {
|
|
956
|
+
fw = nullptr;
|
|
957
|
+
}
|
|
958
|
+
ready_len = encode_ready(ready_buf, sizeof(ready_buf), chip, id, version, fw, name);
|
|
937
959
|
if (ready_len > sizeof(ready_buf)) ready_len = 0;
|
|
938
960
|
}
|
|
939
961
|
|