@mikrojs/native 0.16.0 → 0.17.0-next.20260722220623

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 CHANGED
@@ -49,6 +49,7 @@ set(MIKROJS_CORE_SOURCES
49
49
  src/mik_sys.cpp
50
50
  src/mik_repl.cpp
51
51
  src/mik_app_config.cpp
52
+ src/mik_app_store.cpp
52
53
  src/mik_udp.cpp
53
54
  src/mik_observable.cpp
54
55
  )
@@ -81,7 +82,7 @@ endif()
81
82
  include(cmake/mikrojs_bytecode.cmake)
82
83
  mikrojs_generate_bytecode(
83
84
  RUNTIME_DIR "${CMAKE_CURRENT_SOURCE_DIR}/runtime"
84
- 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 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 pin pwm reader sleep spi sntp stdio stream sys test uart udp wifi
85
86
  MODULE_PREFIX "mikro"
86
87
  SYMBOL_PREFIX "mikro"
87
88
  TARGET gen_bytecode
@@ -163,6 +164,7 @@ if(BUILD_TESTING)
163
164
  test/abort_test.cpp
164
165
  test/repl_protocol_test.cpp
165
166
  test/app_config_test.cpp
167
+ test/app_store_test.cpp
166
168
  test/oom_test.cpp
167
169
  test/reader_test.cpp
168
170
  test/stream_test.cpp
@@ -0,0 +1,23 @@
1
+ #pragma once
2
+
3
+ /* Portable app-store engine: atomic promotion of a staged app into the live
4
+ * slot, plus crash recovery. On-disk layout relative to a base path:
5
+ * <base>/app live app
6
+ * <base>/.deploy-tmp staging dir (new app built at <base>/.deploy-tmp/app)
7
+ * <base>/.deploy-old rollback copy of the previous live app
8
+ * POSIX only (rename/stat/dirent); no ESP-IDF, no QuickJS. */
9
+
10
+ enum MIKAppCommitResult {
11
+ MIK_APP_COMMIT_OK = 0,
12
+ MIK_APP_COMMIT_STASH_FAILED, // rename <base>/app -> <base>/.deploy-old failed
13
+ MIK_APP_COMMIT_SWAP_FAILED, // rename <base>/.deploy-tmp/app -> <base>/app failed
14
+ };
15
+
16
+ /* Promote the staged app at <base>/.deploy-tmp/app into <base>/app. When
17
+ * `erased` is true the live app was already removed, so no rollback copy is
18
+ * stashed. On failure the caller is expected to clean up staging. */
19
+ MIKAppCommitResult mik__app_commit(const char* base, bool erased);
20
+
21
+ /* Recover from an interrupted commit: restore the rollback copy if the live
22
+ * app is missing, otherwise drop leftover staging/rollback dirs. */
23
+ void mik__app_recover(const char* base);
@@ -64,6 +64,15 @@ typedef struct MIKPlatform {
64
64
  * original 6 MAC bytes. The returned pointer must remain valid for the
65
65
  * lifetime of the platform. */
66
66
  const char* (*get_device_id)(void);
67
+ /** The device's stored name, or NULL when it has never been named. The
68
+ * value is opaque here: it is the `[rev, name]` pair the OTA check-in
69
+ * carries, kept as one string so the name and its revision can never be
70
+ * stored out of step. Backed by NVS on ESP32; host platforms keep it in
71
+ * memory for the process. The returned pointer must remain valid until
72
+ * the next set_device_name call. */
73
+ const char* (*get_device_name)(void);
74
+ /** Store the name pair, replacing any previous value. */
75
+ void (*set_device_name)(const char* value);
67
76
  /** Reason the chip last reset, as a stable lowercase string. On ESP32
68
77
  * this maps esp_reset_reason(): "power-on", "software", "panic",
69
78
  * "watchdog", "interrupt-watchdog", "task-watchdog", "brownout",
@@ -340,10 +340,21 @@ 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
+ /* Adopt a streamed .tgz build as the live app via the OTA install path,
344
+ * establishing the rollback baseline (.ota-last-good.tgz). The build must
345
+ * already be staged via DEPLOY_PUT("/.build.tgz", size) + PUT_CHUNK frames.
346
+ * Payload: u16le checksum_len | checksum bytes. */
347
+ #define MIK_CMD_DEPLOY_BUILD 0x2D
343
348
 
344
349
  #define MIK_CMD_CONFIG_LIST 0x40
345
350
  #define MIK_CMD_CONFIG_SET 0x41
346
351
  #define MIK_CMD_CONFIG_DELETE 0x42
352
+ /* KV provisioning: write/delete a `mikro/kv/nvs` string value over serial
353
+ * (stored as a CBOR text-string blob in the "mik.kv" namespace, the same
354
+ * encoding the native kv module uses). Device provisioning state, unlike
355
+ * env config it is never synced or cleared by deploys. */
356
+ #define MIK_CMD_KV_SET 0x43
357
+ #define MIK_CMD_KV_DELETE 0x44
347
358
 
348
359
  /* Env entry flags */
349
360
  #define MIK_ENV_FLAG_SECRET 0x01
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikrojs/native",
3
- "version": "0.16.0",
3
+ "version": "0.17.0-next.20260722220623",
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/types": "./runtime/ota/types.ts",
67
68
  "./runtime/pin/types": "./runtime/pin/types.ts",
68
69
  "./runtime/pwm/types": "./runtime/pwm/types.ts",
69
70
  "./runtime/reader/types": "./runtime/reader/types.ts",
@@ -84,13 +85,14 @@
84
85
  "cmake-js": "^8.0.0",
85
86
  "node-addon-api": "^8.7.0",
86
87
  "node-gyp-build": "^4.8.4",
87
- "@mikrojs/quickjs": "0.16.0"
88
+ "@mikrojs/quickjs": "0.17.0-next.20260722220623+e03f4a6"
88
89
  },
89
90
  "devDependencies": {
90
91
  "@swc/core": "^1.15.30",
91
92
  "@types/node": "^24.12.2",
92
93
  "esbuild": "^0.28.0",
93
- "terser": "^5.46.2"
94
+ "terser": "^5.46.2",
95
+ "@mikrojs/registry": "0.17.0-next.20260722220623+e03f4a6"
94
96
  },
95
97
  "engines": {
96
98
  "node": ">=24.0.0"
@@ -8,7 +8,7 @@ type NR<T> = {ok: true; value: T} | {ok: false; error: {code: number; message: s
8
8
  // import them. Keep these in sync with the MODULES list in the firmware +
9
9
  // @mikrojs/native CMakeLists.
10
10
  declare module 'mikro/kv/shared' {
11
- export {KVError, makeCreateValue, type NativeKvFns} from './kv/shared.js'
11
+ export {KVError, makeCreateValue, mapKvError, type NativeKvFns} from './kv/shared.js'
12
12
  }
13
13
 
14
14
  declare module 'mikro/observable/lazy' {
@@ -44,6 +44,7 @@ declare module 'native:mikro/sys' {
44
44
  systemTotal: number
45
45
  systemLargestFree: number
46
46
  }
47
+ export function storageUsage(): {total: number; used: number; free: number} | undefined
47
48
  export function jsMemoryUsage(): JsMemoryUsage
48
49
  export function gc(): void
49
50
  /** Unload the module whose namespace object is `ns`; returns the number of
@@ -52,6 +53,10 @@ declare module 'native:mikro/sys' {
52
53
  /** True if `ns` is the namespace of a loaded, non-anchored (non-builtin) module. */
53
54
  export function isUnloadableNamespace(ns: object): boolean
54
55
  export function activeTimers(): number
56
+ /** The raw stored `[rev, name]` pair as text, or undefined when never named.
57
+ * Backed by NVS on device and by process memory on hosts. */
58
+ export function deviceName(): string | undefined
59
+ export function setDeviceName(value: string): void
55
60
  export function setTime(millisSinceEpoch: number): void
56
61
  export function uptime(): {boot: number; rtc: number}
57
62
  export function restart(): never
@@ -289,7 +294,11 @@ declare module 'native:mikro/nvs_kv' {
289
294
  export function set(key: string, value: unknown): NRV
290
295
  export function get(key: string): unknown
291
296
  export function remove(key: string): boolean
292
- export function clear(): void
297
+ export function clear(): NRV
298
+ export function sysSet(key: string, value: unknown): NRV
299
+ export function sysGet(key: string): unknown
300
+ export function sysRemove(key: string): boolean
301
+ export function sysClear(): NRV
293
302
  export function info(): {entries: number; used: number; total: number; free: number}
294
303
  }
295
304
 
@@ -519,6 +528,24 @@ declare module 'native:mikro/udp' {
519
528
  export function bind(opts: BindOptions): Promise<Result<NativeUdpSocket, UdpError>>
520
529
  }
521
530
 
531
+ declare module 'native:mikro/ota' {
532
+ import type {NativeOta} from './ota/policy.js'
533
+
534
+ // The native module exports the NativeOta contract's methods directly.
535
+ export function stageBegin(checksum: string, size: number): ReturnType<NativeOta['stageBegin']>
536
+ export function stageWrite(bytes: Uint8Array): ReturnType<NativeOta['stageWrite']>
537
+ export function stageFinish(
538
+ trialBoots: number,
539
+ requireConfirm: boolean,
540
+ installNow: boolean,
541
+ ): ReturnType<NativeOta['stageFinish']>
542
+ export function stageAbort(): void
543
+ export function markValid(): void
544
+ export function revert(): ReturnType<NativeOta['revert']>
545
+ export function running(): ReturnType<NativeOta['running']>
546
+ export function reconcile(): ReturnType<NativeOta['reconcile']>
547
+ }
548
+
522
549
  declare module 'native:mikro/i2s' {
523
550
  import type {I2sError, I2sSamples} from '@mikrojs/native/runtime/i2s/types'
524
551
  import type {Result} from 'mikro/result'
package/runtime/kv/nvs.ts CHANGED
@@ -1,7 +1,9 @@
1
- import {KVError, makeCreateValue} from 'mikro/kv/shared'
2
- import {clear, get, info, remove, set} from 'native:mikro/nvs_kv'
1
+ import {KVError, makeCreateValue, mapKvError} from 'mikro/kv/shared'
2
+ import {err, ok} from 'mikro/result'
3
+ import {clear, get, info, remove, set, sysClear} from 'native:mikro/nvs_kv'
3
4
 
4
- import type {NvsStorage} from './types.js'
5
+ import type {Result} from '../result/types.js'
6
+ import type {KVError as KVErrorType, NvsStorage} from './types.js'
5
7
 
6
8
  // Loaded in isolation: this file avoids `native:mikro/rtc`, so apps importing
7
9
  // `mikrojs/kv/nvs` directly don't pay for the RTC backend. `KVError` and
@@ -10,8 +12,18 @@ import type {NvsStorage} from './types.js'
10
12
 
11
13
  export {KVError}
12
14
 
15
+ // A full clear attempts both stores even if the first fails; the first
16
+ // error is returned so a partial wipe surfaces.
17
+ function clearStorage(options?: {full?: boolean}): Result<void, KVErrorType> {
18
+ const cleared = clear()
19
+ const sysCleared = options?.full === true ? sysClear() : undefined
20
+ if (!cleared.ok) return err(mapKvError(cleared.error))
21
+ if (sysCleared !== undefined && !sysCleared.ok) return err(mapKvError(sysCleared.error))
22
+ return ok()
23
+ }
24
+
13
25
  export const nvsStorage = {
14
26
  createValue: makeCreateValue({get, set, remove, clear, info}),
15
- clear,
27
+ clear: clearStorage,
16
28
  info,
17
29
  } as NvsStorage
@@ -30,11 +30,12 @@ export type NativeKvFns = {
30
30
  get: (key: string) => unknown
31
31
  set: (key: string, value: unknown) => NativeResult
32
32
  remove: (key: string) => boolean
33
- clear: () => void
33
+ /* void (rtc) or a native Result (nvs); unused by createValue */
34
+ clear: () => unknown
34
35
  info: () => unknown
35
36
  }
36
37
 
37
- function mapKvError(e: NativeError) {
38
+ export function mapKvError(e: NativeError) {
38
39
  switch (e.code) {
39
40
  case KV_STORAGE_FULL:
40
41
  case KV_TOO_LARGE:
@@ -138,8 +138,9 @@ export interface NvsStorage {
138
138
  options?: Opts,
139
139
  ): KVValue<InferOpts<Schema, Opts>>
140
140
 
141
- /** Erase all NVS key-value data. */
142
- clear(): void
141
+ /** Erase all app key-value data. With `{full: true}`, also erase the runtime's
142
+ * system store. The system store is never touched otherwise. */
143
+ clear(options?: {full?: boolean}): Result<void, KVError>
143
144
  /** Get info about NVS key-value usage. */
144
145
  info(): NvsStorageInfo
145
146
  }
@@ -0,0 +1,77 @@
1
+ import {readFile} from 'mikro/fs'
2
+ import {sysGet, sysSet} from 'native:mikro/nvs_kv'
3
+ import * as native from 'native:mikro/ota'
4
+
5
+ import {createOta, type OtaStore} from './policy.js'
6
+ import type {Ota} from './types.js'
7
+
8
+ // Policy state, persisted to the mik.sys NVS namespace so the retry budget
9
+ // survives a crash-loop and app-level nvsStorage.clear() can't wipe it.
10
+ // NVS keys are capped at 15 chars.
11
+ // A dropped write cannot be recovered from here, but it must not pass in
12
+ // silence: `ota.tries` and `ota.inflight` are the crash-loop latch, so losing
13
+ // either hands the retry budget back on every boot and the bound that stops a
14
+ // panicking build from being retried forever is gone.
15
+ function put(key: string, value: string | number): void {
16
+ const r = sysSet(key, value)
17
+ // eslint-disable-next-line no-console
18
+ if (!r.ok) console.error(`ota: could not persist ${key}`, r.error)
19
+ }
20
+
21
+ const store: OtaStore = {
22
+ getUrl: () => {
23
+ const v = sysGet('ota.url')
24
+ return typeof v === 'string' ? v : undefined
25
+ },
26
+ setUrl: (url) => put('ota.url', url),
27
+ getAttempt: () => {
28
+ const v = sysGet('ota.att')
29
+ return typeof v === 'string' ? v : undefined
30
+ },
31
+ setAttempt: (checksum) => put('ota.att', checksum),
32
+ getTries: () => {
33
+ const v = sysGet('ota.tries')
34
+ return typeof v === 'number' ? v : 0
35
+ },
36
+ setTries: (n) => put('ota.tries', n),
37
+ getBad: () => {
38
+ const v = sysGet('ota.bad')
39
+ return typeof v === 'string' ? v : undefined
40
+ },
41
+ setBad: (checksum) => put('ota.bad', checksum),
42
+ getInFlight: () => sysGet('ota.inflight') === 1,
43
+ setInFlight: (value) => put('ota.inflight', value ? 1 : 0),
44
+ }
45
+
46
+ // Written as a pair to mik.sys by `mikro ota enroll`: the registry url and
47
+ // the device credential that authenticates against it.
48
+ function bearer(): string | undefined {
49
+ const v = sysGet('ota.credential')
50
+ return typeof v === 'string' ? v : undefined
51
+ }
52
+
53
+ function registry(): string | undefined {
54
+ const v = sysGet('ota.registry')
55
+ return typeof v === 'string' ? v : undefined
56
+ }
57
+
58
+ function readAppVersion(): string | undefined {
59
+ const r = readFile('/app/package.json', 'utf-8')
60
+ if (!r.ok) return undefined
61
+ try {
62
+ const pkg = JSON.parse(r.value) as {version?: unknown}
63
+ return typeof pkg.version === 'string' ? pkg.version : undefined
64
+ } catch {
65
+ return undefined
66
+ }
67
+ }
68
+
69
+ const ota: Ota = createOta({
70
+ native,
71
+ store,
72
+ readAppVersion,
73
+ bearer,
74
+ registry,
75
+ })
76
+
77
+ export {ota}
@@ -0,0 +1,299 @@
1
+ import {err, ok} from 'mikro/result'
2
+
3
+ import type {Result} from '../result/types.js'
4
+ import type {
5
+ ApplyOutcome,
6
+ DownloadFn,
7
+ InstallOptions,
8
+ InstallOutcome,
9
+ Offer,
10
+ Ota,
11
+ OtaDownloadError,
12
+ OtaError,
13
+ OtaInstallError,
14
+ RunningBuild,
15
+ Update,
16
+ } from './types.js'
17
+
18
+ /** The native:mikro/ota contract (ESP-only C module; stubbed on host). */
19
+ export interface NativeOta {
20
+ stageBegin(
21
+ checksum: string,
22
+ size: number,
23
+ ): {ok: true; resumeOffset: number} | {ok: false; error: string}
24
+ // No `kind` here, unlike stageFinish: every write failure is a storage
25
+ // problem and maps to StagingFull, so there is nothing to discriminate on.
26
+ stageWrite(bytes: Uint8Array): {ok: true} | {ok: false; error: string}
27
+ stageFinish(
28
+ trialBoots: number,
29
+ requireConfirm: boolean,
30
+ installNow: boolean,
31
+ ): {ok: true} | {ok: false; error: string; kind: 'corrupt' | 'transient' | 'oom'}
32
+ stageAbort(): void
33
+ markValid(): void
34
+ revert(): {ok: true} | {ok: false; error: string}
35
+ running(): {checksum?: string; trial: boolean}
36
+ reconcile(): {
37
+ installed?: string
38
+ reverted: boolean
39
+ diagnostic?: {reason: string; detail?: string}
40
+ }
41
+ }
42
+
43
+ /** Crash-loop-safe policy state, persisted to NVS with short keys. */
44
+ export interface OtaStore {
45
+ getUrl(): string | undefined
46
+ setUrl(url: string): void
47
+ /** Checksum the retry budget is currently counting against. */
48
+ getAttempt(): string | undefined
49
+ setAttempt(checksum: string): void
50
+ getTries(): number
51
+ setTries(n: number): void
52
+ getBad(): string | undefined
53
+ setBad(checksum: string): void
54
+ /** True while an install attempt is running. Still true at boot means the
55
+ * last one never returned, i.e. it crashed the device. */
56
+ getInFlight(): boolean
57
+ setInFlight(value: boolean): void
58
+ }
59
+
60
+ interface OtaDeps {
61
+ native: NativeOta
62
+ store: OtaStore
63
+ /** Live app version from /app/package.json, or undefined if unreadable. */
64
+ readAppVersion(): string | undefined
65
+ /** The check-in bearer: the device credential from the system store, or
66
+ * undefined on an un-enrolled device. */
67
+ bearer(): string | undefined
68
+ /** The registry url the credential was minted against, or undefined. */
69
+ registry(): string | undefined
70
+ }
71
+
72
+ /** Number of staging attempts for one url before a checksum is abandoned. */
73
+ const MAX_TRIES = 3
74
+
75
+ /** Validate an untrusted registry value into an `Offer`, or `undefined`. */
76
+ export function parseOffer(raw: unknown, opts?: {allowInsecure?: boolean}): Offer | undefined {
77
+ // null/undefined is the registry's "no update available" signal, not a
78
+ // malformed offer, so return quietly without a warning.
79
+ if (raw === null || raw === undefined) return undefined
80
+ const reject = (reason: string): undefined => {
81
+ // eslint-disable-next-line no-console
82
+ console.warn(`ota: ignoring offer (${reason})`)
83
+ return undefined
84
+ }
85
+ if (typeof raw !== 'object') return reject('not an object')
86
+ const o = raw as Record<string, unknown>
87
+ // No url is "no update", not a malformed offer: a check-in with nothing newer
88
+ // still returns a body when the registry has something else to say — a name to
89
+ // adopt, say — and the reference client reads that before parseOffer. Warn
90
+ // only when a url is present but unusable, so a dashboard rename that coincides
91
+ // with "up to date" does not log a misleading warning on every device.
92
+ if (o.url === undefined) return undefined
93
+ if (typeof o.url !== 'string') return reject('missing url')
94
+ // https is required so the device never downloads executable bytecode over
95
+ // plaintext; allowInsecure (dev only) also permits http for local registries.
96
+ const schemeOk =
97
+ o.url.startsWith('https://') || (opts?.allowInsecure === true && o.url.startsWith('http://'))
98
+ // Only the path has to name a .tgz. A registry is free to hang a query on the
99
+ // url — a signed-url registry puts an expiry and signature there — and the
100
+ // device treats the whole string as opaque, so rejecting on the query would
101
+ // forbid that for no gain.
102
+ const pathEnd = o.url.search(/[?#]/)
103
+ const urlPath = pathEnd < 0 ? o.url : o.url.slice(0, pathEnd)
104
+ if (!schemeOk || !urlPath.endsWith('.tgz')) {
105
+ return reject(opts?.allowInsecure ? 'url must be an http(s) .tgz' : 'url must be an https .tgz')
106
+ }
107
+ if (typeof o.checksum !== 'string' || o.checksum.length === 0) return reject('missing checksum')
108
+ // Must be a positive integer: a negative size fails the first write with
109
+ // TooLarge, and 0 disables the cap on both sides, leaving the download
110
+ // unbounded.
111
+ if (typeof o.size !== 'number' || !Number.isInteger(o.size) || o.size <= 0) {
112
+ return reject('invalid size')
113
+ }
114
+ // The url's host is not checked. The offer arrives in an authenticated
115
+ // check-in response from the enrolled registry, so the registry is trusted to
116
+ // name where the build lives — a CDN or object store on another host, a signed
117
+ // url with its own query. Integrity is the checksum, verified over the whole
118
+ // download before install, so a wrong host yields a failed install, never a
119
+ // bad one; and the credential is the caller's to confine (the reference client
120
+ // attaches it only when the url is same-origin with the registry).
121
+ return {url: o.url, checksum: o.checksum, size: o.size}
122
+ }
123
+
124
+ function makeUpdate(native: NativeOta, size: number | undefined, resumeOffset: number): Update {
125
+ let written = resumeOffset
126
+ return {
127
+ resumeOffset,
128
+ write(bytes) {
129
+ if (size !== undefined && written + bytes.length > size) {
130
+ return err({name: 'TooLarge' as const, message: `build exceeds offered size ${size}`})
131
+ }
132
+ const r = native.stageWrite(bytes)
133
+ if (!r.ok) return err({name: 'StagingFull' as const, message: r.error})
134
+ written += bytes.length
135
+ return ok()
136
+ },
137
+ finish(options) {
138
+ const r = native.stageFinish(
139
+ options?.trialBoots ?? 1,
140
+ options?.requireConfirm ?? false,
141
+ options?.install === 'now',
142
+ )
143
+ if (r.ok) return ok()
144
+ return err({name: 'InstallFailed' as const, kind: r.kind, message: r.error})
145
+ },
146
+ abort() {
147
+ native.stageAbort()
148
+ },
149
+ }
150
+ }
151
+
152
+ /** True if the failure means the bytes are bad and must not be retried. */
153
+ function isAbandon(e: OtaError): boolean {
154
+ return e.name === 'InstallFailed' && e.kind === 'corrupt'
155
+ }
156
+
157
+ export function createOta(deps: OtaDeps): Ota {
158
+ const {native, store, readAppVersion} = deps
159
+
160
+ function beginUpdate(options: {checksum: string; size: number}) {
161
+ const r = native.stageBegin(options.checksum, options.size)
162
+ if (!r.ok) return err({name: 'StagingFailed' as const, message: r.error})
163
+ return ok(makeUpdate(native, options.size, r.resumeOffset))
164
+ }
165
+ // beginUpdate stays internal: staging is only ever driven through the policy.
166
+
167
+ function reconcile(): InstallOutcome {
168
+ // Give the budget back once per boot. Everything it counts is transient
169
+ // (OOM, a truncated download), and a reboot is the one signal available
170
+ // here that conditions may have changed — there is no clock to back off
171
+ // against. Without this the budget is a permanent latch: three OOM
172
+ // failures would strand the device on the old build forever, even once
173
+ // free heap recovered.
174
+ //
175
+ // Unless the last attempt crashed. A native OOM panics into a restart, so
176
+ // an unconditional reset zeroes the count on exactly the failure the budget
177
+ // exists to bound, and the device reboots into the same attempt forever.
178
+ // The in-flight flag is still set only in that case, so the crashed attempt
179
+ // keeps its bump and MAX_TRIES eventually binds.
180
+ if (store.getInFlight()) store.setInFlight(false)
181
+ else store.setTries(0)
182
+ const r = native.reconcile()
183
+ const out: InstallOutcome = {reverted: r.reverted}
184
+ if (r.installed !== undefined) out.installed = r.installed
185
+ if (r.diagnostic !== undefined) out.lastInstall = r.diagnostic
186
+ return out
187
+ }
188
+
189
+ function running(): RunningBuild {
190
+ const r = native.running()
191
+ const out: RunningBuild = {trial: r.trial}
192
+ if (r.checksum !== undefined) out.checksum = r.checksum
193
+ const v = readAppVersion()
194
+ if (v !== undefined) out.version = v
195
+ return out
196
+ }
197
+
198
+ function revert(): Result<void, OtaInstallError> {
199
+ const r = native.revert()
200
+ if (r.ok) return ok()
201
+ return err({name: 'InstallFailed' as const, kind: 'transient' as const, message: r.error})
202
+ }
203
+
204
+ async function applyOffer(
205
+ offer: Offer,
206
+ download: DownloadFn,
207
+ options?: InstallOptions,
208
+ ): Promise<Result<ApplyOutcome, OtaError>> {
209
+ const run = native.running()
210
+ // (a) a trial is unresolved. Reported distinctly because the caller's
211
+ // response differs from every other skip: the build on trial still needs
212
+ // confirming, and treating this like "nothing to do" lets the trial lapse
213
+ // and roll back a healthy build just because a newer one was published.
214
+ if (run.trial) return ok('trial-pending')
215
+ // (b) already running this build
216
+ if (offer.checksum === run.checksum) return ok('current')
217
+ // (d) device has given up on this build
218
+ if (offer.checksum === store.getBad()) return ok('abandoned')
219
+ // (e) retry budget, keyed on url *and* checksum. Exhausting it only stops
220
+ // attempts against this exact build at this exact url; the checksum is not
221
+ // abandoned, because everything counted here is transient by construction
222
+ // (a corrupt build is abandoned at (h) instead). Marking it bad would be
223
+ // unrecoverable: check (d) runs before this, so re-publishing the same
224
+ // build at a fresh url could never revive it, and nothing clears `bad`. A
225
+ // device on flaky wifi would permanently lose a good build after three
226
+ // failed downloads.
227
+ //
228
+ // The checksum has to be part of the key because a registry that serves a
229
+ // stable url ("/latest.tgz", re-pointed on publish) would otherwise never
230
+ // reset: three failures against the old build would skip every future one
231
+ // at that url, permanently, since the reset on success is unreachable.
232
+ if (offer.url !== store.getUrl() || offer.checksum !== store.getAttempt()) {
233
+ store.setUrl(offer.url)
234
+ store.setAttempt(offer.checksum)
235
+ store.setTries(0)
236
+ }
237
+ const tries = store.getTries()
238
+ if (tries >= MAX_TRIES) return ok('exhausted')
239
+ // (f) bump before the attempt, so a crash mid-attempt still counts — the
240
+ // flag is what makes that true across a reboot (see reconcile). The finally
241
+ // clears it on every ordinary exit, and deliberately does not run when the
242
+ // attempt takes the device down with it.
243
+ store.setTries(tries + 1)
244
+ store.setInFlight(true)
245
+ try {
246
+ return await attempt(offer, download, options)
247
+ } finally {
248
+ store.setInFlight(false)
249
+ }
250
+ }
251
+
252
+ async function attempt(
253
+ offer: Offer,
254
+ download: DownloadFn,
255
+ options?: InstallOptions,
256
+ ): Promise<Result<ApplyOutcome, OtaError>> {
257
+ // (g) stage, download, verify
258
+ const begun = beginUpdate({checksum: offer.checksum, size: offer.size})
259
+ if (!begun.ok) return err(begun.error)
260
+ const update = begun.value
261
+ // The download callback is the app's transport code. A failure there (network,
262
+ // a write rejection) is transient: keep the bumped tries so it retries next time,
263
+ // and do NOT abandon the checksum (that is only for a corrupt build).
264
+ const downloaded = await download(update)
265
+ if (!downloaded.ok) {
266
+ update.abort()
267
+ const downloadError: OtaDownloadError = {
268
+ name: 'DownloadFailed',
269
+ message: downloaded.error.message,
270
+ }
271
+ return err(downloadError)
272
+ }
273
+ // (h) a corrupt build is the one thing worth abandoning: the same bytes will
274
+ // fail identically forever. Abort first, or the verified-bad staging file
275
+ // (up to the whole build) sits on the app partition until some later offer
276
+ // happens to reclaim it.
277
+ const finished = update.finish(options)
278
+ if (!finished.ok) {
279
+ if (isAbandon(finished.error)) {
280
+ update.abort()
281
+ store.setBad(offer.checksum)
282
+ }
283
+ return err(finished.error)
284
+ }
285
+ store.setTries(0)
286
+ return ok('staged')
287
+ }
288
+
289
+ return {
290
+ reconcile,
291
+ running,
292
+ parseOffer,
293
+ applyOffer,
294
+ confirm: () => native.markValid(),
295
+ revert,
296
+ bearer: deps.bearer,
297
+ registry: deps.registry,
298
+ }
299
+ }
@@ -0,0 +1,147 @@
1
+ import type {Result} from '../result/types.js'
2
+
3
+ /** An update offered by a registry: what to fetch and how to verify it, nothing
4
+ * more. Whether this device should take it is the registry's decision, made
5
+ * against the firmware and bytecode version the device reports at check-in and
6
+ * the whole set of builds it holds. Validate untrusted input with `parseOffer`. */
7
+ export interface Offer {
8
+ /** https URL of the .tgz build */
9
+ url: string
10
+ /** content hash, verified after download */
11
+ checksum: string
12
+ /** build size in bytes */
13
+ size: number
14
+ }
15
+
16
+ export interface InstallOptions {
17
+ /** clean cycles a trial must survive before it is kept (default 1) */
18
+ trialBoots?: number
19
+ /** require `ota.confirm()` instead of auto-keeping (default false) */
20
+ requireConfirm?: boolean
21
+ /** when to unpack and swap (default 'next-boot') */
22
+ install?: 'now' | 'next-boot'
23
+ }
24
+
25
+ export interface Diagnostic {
26
+ /** short failure category, e.g. "ota_install_failed" */
27
+ reason: string
28
+ /** human-readable detail */
29
+ detail?: string
30
+ }
31
+
32
+ export interface InstallOutcome {
33
+ /** checksum of a build installed this boot, if any */
34
+ installed?: string
35
+ /** whether a trial was rolled back this boot */
36
+ reverted: boolean
37
+ /** why a previous install failed, to forward to the registry */
38
+ lastInstall?: Diagnostic
39
+ }
40
+
41
+ export interface RunningBuild {
42
+ /** checksum of the executing build */
43
+ checksum?: string
44
+ /** its package.json version */
45
+ version?: string
46
+ /** true while it is still on trial */
47
+ trial: boolean
48
+ }
49
+
50
+ export interface Update {
51
+ /** Bytes already staged for this checksum. Start a Range request here to resume. */
52
+ readonly resumeOffset: number
53
+
54
+ /** Append downloaded bytes. Enforces the size limit as bytes arrive. */
55
+ write(bytes: Uint8Array): Result<void, OtaWriteError>
56
+
57
+ /** Verify the staged build against the checksum and size, then stage it for install.
58
+ * Defaults to installing on the next boot; pass `{install: 'now'}` to install in place. */
59
+ finish(options?: InstallOptions): Result<void, OtaError>
60
+
61
+ /** Discard the staging session. */
62
+ abort(): void
63
+ }
64
+
65
+ /** The download callback given to `applyOffer`. Fetch the build bytes any way
66
+ * you like and write them with `update.write`; return `ok()` when done, or
67
+ * `err({message})` to signal a download failure (retried on a later attempt). */
68
+ export type DownloadFn = (update: Update) => Promise<Result<void, {message: string}>>
69
+
70
+ /** Staging ran out of room, or exceeded the offered size. */
71
+ export type OtaWriteError =
72
+ | {name: 'StagingFull'; message: string}
73
+ | {name: 'TooLarge'; message: string}
74
+
75
+ /** Staging could not be started: the offer is malformed, or the build does not
76
+ * fit this device's filesystem. Not blacklisted — a malformed offer is not the
77
+ * build's fault, and a re-publish at a corrected size must be able to succeed. */
78
+ export type OtaBeginError = {name: 'StagingFailed'; message: string}
79
+
80
+ /** The build failed to unpack or swap. `kind` is `corrupt` for bad bytes
81
+ * (retrying cannot help, so it is abandoned), or `transient`/`oom` for storage
82
+ * and memory failures that are retried within the retry limit. */
83
+ export type OtaInstallError = {
84
+ name: 'InstallFailed'
85
+ kind: 'corrupt' | 'transient' | 'oom'
86
+ message: string
87
+ }
88
+
89
+ /** The download callback returned a failure. Transient: the attempt is
90
+ * retried on a later `applyOffer`, not abandoned (unlike a corrupt build). */
91
+ export type OtaDownloadError = {name: 'DownloadFailed'; message: string}
92
+
93
+ /** Why `applyOffer` did not stage the offer. Only `staged` means bytes landed.
94
+ *
95
+ * These are outcomes, not errors: each one is the policy working as intended.
96
+ * They are distinguished because the caller's next move differs — in
97
+ * particular `trial-pending` is the one case where the app should confirm the
98
+ * build it is running rather than treat the offer as handled. */
99
+ export type ApplyOutcome =
100
+ /** The build was downloaded, verified, and armed for install. */
101
+ | 'staged'
102
+ /** A trial is unresolved: this device must settle the build it is running
103
+ * before it can take another. */
104
+ | 'trial-pending'
105
+ /** This build is already the running build. */
106
+ | 'current'
107
+ /** This build was abandoned as corrupt and will not be retried. */
108
+ | 'abandoned'
109
+ /** The retry budget for this build is spent until the next boot. */
110
+ | 'exhausted'
111
+
112
+ /** Returned by `applyOffer` and `finish`. A failed checksum arrives here as
113
+ * `InstallFailed` with `kind: 'corrupt'` — the native side has no separate
114
+ * mismatch code, since it cannot tell a bad hash from bad bytes. */
115
+ export type OtaError = OtaWriteError | OtaBeginError | OtaInstallError | OtaDownloadError
116
+
117
+ export interface Ota {
118
+ /** Report what happened to a previous update on this boot, and clear the report. */
119
+ reconcile(): InstallOutcome
120
+ /** The build currently executing, read from the live app. */
121
+ running(): RunningBuild
122
+ /** Validate a registry value into an `Offer`, or `undefined` if unusable.
123
+ * `allowInsecure` (dev only) accepts an http build url instead of https. */
124
+ parseOffer(raw: unknown, opts?: {allowInsecure?: boolean}): Offer | undefined
125
+ /** Run the full update policy: skip checks, compatibility, retry limit,
126
+ * download via the `download` callback, and verification. */
127
+ applyOffer(
128
+ offer: Offer,
129
+ download: DownloadFn,
130
+ options?: InstallOptions,
131
+ ): Promise<Result<ApplyOutcome, OtaError>>
132
+ /** Mark the running trial as healthy so it is kept rather than rolled back. */
133
+ confirm(): void
134
+ /** Reinstall the previous build immediately. */
135
+ revert(): Result<void, OtaInstallError>
136
+ /** The check-in bearer: the device credential written at enrollment
137
+ * (`mikro ota enroll`), or `undefined` on an un-enrolled device. Read-only:
138
+ * credentials are provisioned over serial, never delivered in-band. */
139
+ bearer(): string | undefined
140
+ /** The registry url the device was enrolled against, written next to the
141
+ * credential at enrollment, or `undefined` on an un-enrolled device. */
142
+ registry(): string | undefined
143
+ }
144
+
145
+ /** The `mikro/ota` singleton. The runtime value is provided by the on-device
146
+ * builtin (or the sim stub); this declaration carries its type for hosts. */
147
+ export declare const ota: Ota
@@ -2,6 +2,8 @@ import {err, ok, PanicError, type Result} from 'mikro/result'
2
2
  import {getWakeupCause as nativeGetWakeupCause} from 'native:mikro/sleep'
3
3
  import * as native from 'native:mikro/sys'
4
4
 
5
+ import type {DeviceName} from './types.js'
6
+
5
7
  export function uptime(): {boot: number; rtc: number} {
6
8
  return native.uptime()
7
9
  }
@@ -21,6 +23,12 @@ export function memoryUsage() {
21
23
  return native.memoryUsage()
22
24
  }
23
25
 
26
+ /** Bytes on the app filesystem, the partition an OTA build is downloaded and
27
+ * staged onto. Undefined when the platform cannot report it. */
28
+ export function storageUsage() {
29
+ return native.storageUsage()
30
+ }
31
+
24
32
  export function jsMemoryUsage() {
25
33
  return native.jsMemoryUsage()
26
34
  }
@@ -37,6 +45,34 @@ export const firmware = native.firmware
37
45
 
38
46
  export const deviceId: string = native.deviceId
39
47
 
48
+ /**
49
+ * The device's name and the logical revision that orders renames against a
50
+ * registry. Stored as one `[rev, name]` value — the same shape a check-in
51
+ * carries — so the pair can never be read or written half-updated. Revision 0
52
+ * with no name means never named, and callers fall back to {@link deviceId}.
53
+ */
54
+ export function deviceName(): DeviceName {
55
+ const raw = native.deviceName()
56
+ if (typeof raw !== 'string') return {rev: 0}
57
+ try {
58
+ const pair = JSON.parse(raw) as unknown
59
+ if (!Array.isArray(pair)) return {rev: 0}
60
+ const rev: unknown = pair[0]
61
+ if (typeof rev !== 'number' || !Number.isInteger(rev) || rev < 0) return {rev: 0}
62
+ const value: unknown = pair[1]
63
+ return typeof value === 'string' && value !== '' ? {rev, name: value} : {rev}
64
+ } catch {
65
+ return {rev: 0}
66
+ }
67
+ }
68
+
69
+ /** Persist a name pair, e.g. one adopted from a registry check-in. */
70
+ export function setDeviceName(value: DeviceName): void {
71
+ native.setDeviceName(
72
+ JSON.stringify(value.name === undefined ? [value.rev] : [value.rev, value.name]),
73
+ )
74
+ }
75
+
40
76
  export const resetReason = native.resetReason
41
77
 
42
78
  export function restart(): never {
@@ -113,8 +113,28 @@ export declare const board: BoardInfo
113
113
  * yields the original 6 MAC bytes. On host/Node builds, derived from
114
114
  * the hostname via FNV-1a hash (stable across restarts on the same
115
115
  * machine). */
116
+ /** Bytes on the app filesystem, the partition an OTA build is downloaded and
117
+ * staged onto. Undefined when the platform cannot report it. */
118
+ export declare function storageUsage(): {total: number; used: number; free: number} | undefined
119
+
116
120
  export declare const deviceId: string
117
121
 
122
+ /** A device name paired with the revision that orders renames. Both sides bump
123
+ * the revision on every deliberate rename, so whichever is higher wins without
124
+ * needing a clock the device may not have. */
125
+ export interface DeviceName {
126
+ rev: number
127
+ /** Absent when the name is cleared, or never set. */
128
+ name?: string
129
+ }
130
+
131
+ /** The device's name and its revision; revision 0 with no name means never
132
+ * named, and callers fall back to {@link deviceId}. */
133
+ export declare function deviceName(): DeviceName
134
+
135
+ /** Persist a name pair, e.g. one adopted from a registry check-in. */
136
+ export declare function setDeviceName(value: DeviceName): void
137
+
118
138
  /** Firmware build identifiers */
119
139
  export declare const firmware: {
120
140
  /** ELF SHA256 hash on ESP32, "dev" on host */
@@ -123,6 +143,8 @@ export declare const firmware: {
123
143
  readonly date: string
124
144
  /** ESP-IDF version, undefined on host */
125
145
  readonly idfVersion: string | undefined
146
+ /** QuickJS bytecode version the linked engine reads/writes */
147
+ readonly bytecodeVersion: number
126
148
  }
127
149
 
128
150
  /** Why the device last reset. A clean `restart()` reports `'software'`;
@@ -0,0 +1,134 @@
1
+ #include "mikrojs/app_store.h"
2
+
3
+ #include <dirent.h>
4
+ #include <stdio.h>
5
+ #include <string.h>
6
+ #include <sys/stat.h>
7
+ #include <unistd.h>
8
+
9
+ namespace {
10
+
11
+ bool path_exists(const char* path) {
12
+ struct stat st;
13
+ return stat(path, &st) == 0;
14
+ }
15
+
16
+ /* An app is a directory. `stat` succeeds for a regular file too, so a build
17
+ * archive carrying a *file* named `app` would otherwise pass the staged-app
18
+ * guard below: commit would stash the live app, move that file into the app
19
+ * slot, and drop the stash, leaving the device with no app and no rollback. */
20
+ static bool dir_exists(const char* path) {
21
+ struct stat st;
22
+ return stat(path, &st) == 0 && S_ISDIR(st.st_mode);
23
+ }
24
+
25
+ /* Deepest tree this will descend into. Builds are 2-3 levels; the untar path
26
+ * caps member depth well below this. The bound exists so a tree left by an
27
+ * older firmware (or a hostile archive that predates the cap) cannot overflow
28
+ * the main task stack here, because this runs from boot recovery: a panic
29
+ * would reboot straight back into the same call and never reach the install
30
+ * budget. Past the bound the tree is left on disk, which fails the next
31
+ * install instead of bricking the device. */
32
+ constexpr int kMaxDepth = 32;
33
+
34
+ /* `path` is a mutable buffer of `cap` bytes holding the directory to remove;
35
+ * each level appends into it and truncates on the way out, so a frame costs a
36
+ * few dozen bytes rather than a 512-byte path of its own. */
37
+ void rmdir_recursive_at(char* path, size_t cap, int depth) {
38
+ DIR* dir = opendir(path);
39
+ if (!dir) return;
40
+
41
+ const size_t base = strlen(path);
42
+ struct dirent* entry;
43
+ while ((entry = readdir(dir)) != NULL) {
44
+ if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) continue;
45
+
46
+ const int n = snprintf(path + base, cap - base, "/%s", entry->d_name);
47
+ if (n < 0 || (size_t)n >= cap - base) {
48
+ path[base] = 0; /* would truncate to a different path; skip it */
49
+ continue;
50
+ }
51
+
52
+ struct stat st;
53
+ if (stat(path, &st) == 0 && S_ISDIR(st.st_mode)) {
54
+ if (depth < kMaxDepth) rmdir_recursive_at(path, cap, depth + 1);
55
+ } else {
56
+ unlink(path);
57
+ }
58
+ path[base] = 0;
59
+ }
60
+ closedir(dir);
61
+ rmdir(path);
62
+ }
63
+
64
+ void rmdir_recursive(const char* path) {
65
+ char buf[512];
66
+ const size_t n = strlen(path);
67
+ if (n >= sizeof(buf)) return;
68
+ memcpy(buf, path, n + 1);
69
+ rmdir_recursive_at(buf, sizeof(buf), 0);
70
+ }
71
+
72
+ } // namespace
73
+
74
+ MIKAppCommitResult mik__app_commit(const char* base, bool erased) {
75
+ char app[512];
76
+ char tmp[512];
77
+ char old[512];
78
+ char staged_app[512];
79
+ snprintf(app, sizeof(app), "%s/app", base);
80
+ snprintf(tmp, sizeof(tmp), "%s/.deploy-tmp", base);
81
+ snprintf(old, sizeof(old), "%s/.deploy-old", base);
82
+ snprintf(staged_app, sizeof(staged_app), "%s/app", tmp);
83
+
84
+ /* No-staged guard (hardening over the original): if nothing was staged,
85
+ * leave the live app untouched and just clean up. The original logic
86
+ * would have deleted the live app in this degenerate case. */
87
+ if (!dir_exists(staged_app)) {
88
+ rmdir_recursive(old);
89
+ rmdir_recursive(tmp);
90
+ return MIK_APP_COMMIT_OK;
91
+ }
92
+
93
+ /* Atomic swap: staging dir → app dir */
94
+ if (path_exists(app) && !erased) {
95
+ rmdir_recursive(old);
96
+ if (rename(app, old) != 0) {
97
+ return MIK_APP_COMMIT_STASH_FAILED;
98
+ }
99
+ }
100
+
101
+ if (rename(staged_app, app) != 0) {
102
+ if (path_exists(old)) {
103
+ rename(old, app);
104
+ }
105
+ return MIK_APP_COMMIT_SWAP_FAILED;
106
+ }
107
+
108
+ rmdir_recursive(old);
109
+ rmdir_recursive(tmp);
110
+ return MIK_APP_COMMIT_OK;
111
+ }
112
+
113
+ void mik__app_recover(const char* base) {
114
+ char app[512];
115
+ char tmp[512];
116
+ char old[512];
117
+ snprintf(app, sizeof(app), "%s/app", base);
118
+ snprintf(tmp, sizeof(tmp), "%s/.deploy-tmp", base);
119
+ snprintf(old, sizeof(old), "%s/.deploy-old", base);
120
+
121
+ bool has_app = path_exists(app);
122
+ bool has_old = path_exists(old);
123
+ bool has_tmp = path_exists(tmp);
124
+
125
+ if (!has_app && has_old) {
126
+ rename(old, app);
127
+ } else if (has_old) {
128
+ rmdir_recursive(old);
129
+ }
130
+
131
+ if (has_tmp) {
132
+ rmdir_recursive(tmp);
133
+ }
134
+ }
package/src/mik_repl.cpp CHANGED
@@ -32,7 +32,9 @@ 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
- static uint8_t ready_buf[96];
35
+ /* Sized to hold the base map plus the longest `[rev, name]` pair the platform
36
+ * name setter accepts, so a named device never has to drop its name. */
37
+ static uint8_t ready_buf[256];
36
38
  static size_t ready_len = 0;
37
39
 
38
40
  /* Transient flag: set by MIK_ProtocolExit to break the current ServeLoop
@@ -876,6 +878,65 @@ static std::vector<uint8_t> proto_complete(JSContext* ctx, const char* partial,
876
878
 
877
879
  /* ── Protocol-mode REPL ──────────────────────────────────────────── */
878
880
 
881
+ /* Fills ready_buf/ready_len with CBOR device info:
882
+ * {"chip": tstr, "id": tstr, "v": tstr, "name": tstr (only when named)}.
883
+ * `name` carries the raw `[rev, name]` pair from mik.sys, so the host reads the
884
+ * name and its revision together and can never see them out of step. Omitted
885
+ * entirely when the device has never been named, which the host reads as
886
+ * revision 0.
887
+ *
888
+ * Rebuilt per CMD_HELLO rather than cached at protocol open: the running app
889
+ * can adopt a registry name mid-session, and a host holding a revision from the
890
+ * opening handshake would write its next rename at a stale revision, which the
891
+ * registry then reads as behind and reverts. Only ever emitted in reply to a
892
+ * client CMD_HELLO, so a passive serial viewer (e.g. idf.py monitor) still
893
+ * doesn't see the device announcing itself unprompted. */
894
+ static void refresh_ready(MIKReplTransport* transport);
895
+
896
+ /* Encodes the MSG_READY map into buf, or measures it when buf is null. Returns
897
+ * the length the encoding needs, which exceeds cap when it did not fit. */
898
+ static size_t encode_ready(uint8_t* buf, size_t cap, const char* chip, const char* id,
899
+ const char* version, const char* name) {
900
+ nanocbor_encoder_t enc;
901
+ nanocbor_encoder_init(&enc, buf, cap);
902
+ nanocbor_fmt_map(&enc, name ? 4 : 3);
903
+ nanocbor_put_tstr(&enc, "chip");
904
+ nanocbor_put_tstr(&enc, chip);
905
+ nanocbor_put_tstr(&enc, "id");
906
+ nanocbor_put_tstr(&enc, id);
907
+ nanocbor_put_tstr(&enc, "v");
908
+ nanocbor_put_tstr(&enc, version);
909
+ if (name) {
910
+ nanocbor_put_tstr(&enc, "name");
911
+ nanocbor_put_tstr(&enc, name);
912
+ }
913
+ return nanocbor_encoded_len(&enc);
914
+ }
915
+
916
+ static void refresh_ready(MIKReplTransport* transport) {
917
+ const char* chip = transport->chip_name ? transport->chip_name : "unknown";
918
+ const char* id = MIK_GetPlatform()->get_device_id();
919
+ if (!id) id = "";
920
+ const char* version =
921
+ #ifdef MIK_FW_VERSION
922
+ MIK_FW_VERSION;
923
+ #else
924
+ "0.0.0-dev";
925
+ #endif
926
+ const char* name =
927
+ MIK_GetPlatform()->get_device_name ? MIK_GetPlatform()->get_device_name() : nullptr;
928
+ /* Measured against a null buffer first. nanocbor stops writing at the buffer
929
+ * end but still reports the length it would have needed, so ready_len must
930
+ * come from a run that actually fit: sending the measured length would read
931
+ * past ready_buf. Drop `name` rather than truncate it, since a partial pair
932
+ * decodes as a different name at the host. */
933
+ if (name && encode_ready(nullptr, 0, chip, id, version, name) > sizeof(ready_buf)) {
934
+ name = nullptr;
935
+ }
936
+ ready_len = encode_ready(ready_buf, sizeof(ready_buf), chip, id, version, name);
937
+ if (ready_len > sizeof(ready_buf)) ready_len = 0;
938
+ }
939
+
879
940
  void MIK_ProtocolOpen(MIKReplTransport* transport) {
880
941
  if (repl_active) return;
881
942
 
@@ -886,41 +947,6 @@ void MIK_ProtocolOpen(MIKReplTransport* transport) {
886
947
  show_depth = 2;
887
948
  show_hidden = false;
888
949
 
889
- /* Build MSG_READY with CBOR device info: {"chip": tstr, "id": tstr, "v": tstr}.
890
- * Cached here and only emitted in response to a client CMD_HELLO so a
891
- * passive serial viewer (e.g. idf.py monitor) doesn't see the device
892
- * spamming its identity unprompted. */
893
- {
894
- const char* chip = transport->chip_name ? transport->chip_name : "unknown";
895
- const char* id = MIK_GetPlatform()->get_device_id();
896
- if (!id) id = "";
897
- const char* version =
898
- #ifdef MIK_FW_VERSION
899
- MIK_FW_VERSION;
900
- #else
901
- "0.0.0-dev";
902
- #endif
903
-
904
- nanocbor_encoder_t enc;
905
- nanocbor_encoder_init(&enc, nullptr, 0);
906
- nanocbor_fmt_map(&enc, 3);
907
- nanocbor_put_tstr(&enc, "chip");
908
- nanocbor_put_tstr(&enc, chip);
909
- nanocbor_put_tstr(&enc, "id");
910
- nanocbor_put_tstr(&enc, id);
911
- nanocbor_put_tstr(&enc, "v");
912
- nanocbor_put_tstr(&enc, version);
913
- ready_len = nanocbor_encoded_len(&enc);
914
-
915
- nanocbor_encoder_init(&enc, ready_buf, sizeof(ready_buf));
916
- nanocbor_fmt_map(&enc, 3);
917
- nanocbor_put_tstr(&enc, "chip");
918
- nanocbor_put_tstr(&enc, chip);
919
- nanocbor_put_tstr(&enc, "id");
920
- nanocbor_put_tstr(&enc, id);
921
- nanocbor_put_tstr(&enc, "v");
922
- nanocbor_put_tstr(&enc, version);
923
- }
924
950
  }
925
951
 
926
952
  void MIK_ProtocolAttach(MIKRuntime* mik_rt) {
@@ -1102,7 +1128,9 @@ void MIK_ProtocolServeLoop(void) {
1102
1128
  break;
1103
1129
 
1104
1130
  case MIK_CMD_HELLO:
1105
- /* Client-initiated handshake: reply with cached MSG_READY. */
1131
+ /* Client-initiated handshake: rebuild so the name pair is
1132
+ * current, then reply. */
1133
+ refresh_ready(transport);
1106
1134
  mik__proto_send(transport, MIK_MSG_READY, ready_buf, ready_len);
1107
1135
  break;
1108
1136
  }
package/src/mik_sys.cpp CHANGED
@@ -28,6 +28,27 @@ static JSValue mik__sys_eval_script(JSContext* ctx, JSValue this_val, int argc,
28
28
  return ret;
29
29
  }
30
30
 
31
+ /* Free/used bytes on the app filesystem ("user", mounted at /appfs) — the
32
+ * partition an OTA build is downloaded and staged onto. Undefined when the
33
+ * platform can't report it. */
34
+ static JSValue mik__sys_storage_usage(JSContext* ctx, JSValue this_val, int argc,
35
+ JSValue* argv) {
36
+ (void)this_val;
37
+ (void)argc;
38
+ (void)argv;
39
+ const MIKPlatform* platform = MIK_GetPlatform();
40
+ size_t total = 0;
41
+ size_t used = 0;
42
+ if (!platform->get_fs_info || !platform->get_fs_info("user", &total, &used)) {
43
+ return JS_UNDEFINED;
44
+ }
45
+ JSValue obj = JS_NewObject(ctx);
46
+ JS_SetPropertyStr(ctx, obj, "total", JS_NewInt64(ctx, (int64_t)total));
47
+ JS_SetPropertyStr(ctx, obj, "used", JS_NewInt64(ctx, (int64_t)used));
48
+ JS_SetPropertyStr(ctx, obj, "free", JS_NewInt64(ctx, (int64_t)(total > used ? total - used : 0)));
49
+ return obj;
50
+ }
51
+
31
52
  static JSValue mik__sys_memory_usage(JSContext* ctx, JSValue this_val, int argc,
32
53
  JSValue* argv) {
33
54
  JSMemoryUsage mem;
@@ -227,14 +248,52 @@ static JSValue mik__sys_firmware(JSContext* ctx) {
227
248
  JS_SetPropertyStr(ctx, obj, "date", JS_NewString(ctx, MIK_BUILD_DATE_UTC));
228
249
  JS_SetPropertyStr(ctx, obj, "idfVersion", JS_NewString(ctx, "n/a"));
229
250
  #endif
251
+ /* BC_VERSION is a #define inside quickjs.c (not exported in any header).
252
+ * Serialize a trivial value and read byte 0 of the output: that's the
253
+ * bytecode version the linked engine writes (matches builtins.cpp's
254
+ * data[0] read). */
255
+ size_t bc_len = 0;
256
+ uint8_t* bc_buf = JS_WriteObject(ctx, &bc_len, JS_NULL, JS_WRITE_OBJ_BYTECODE);
257
+ int bc = (bc_buf && bc_len > 0) ? bc_buf[0] : 0;
258
+ if (bc_buf) js_free(ctx, bc_buf);
259
+ JS_SetPropertyStr(ctx, obj, "bytecodeVersion", JS_NewInt32(ctx, bc));
230
260
  return obj;
231
261
  }
232
262
 
263
+ /* The stored `[rev, name]` pair, or undefined when never named. Read through
264
+ * the platform so `mikro/sys` stays loadable on hosts with no NVS. */
265
+ static JSValue mik__sys_device_name(JSContext* ctx, JSValue this_val, int argc, JSValue* argv) {
266
+ (void)this_val;
267
+ (void)argc;
268
+ (void)argv;
269
+ const MIKPlatform* platform = MIK_GetPlatform();
270
+ const char* value = platform->get_device_name ? platform->get_device_name() : NULL;
271
+ return value ? JS_NewString(ctx, value) : JS_UNDEFINED;
272
+ }
273
+
274
+ static JSValue mik__sys_set_device_name(JSContext* ctx, JSValue this_val, int argc,
275
+ JSValue* argv) {
276
+ (void)this_val;
277
+ const MIKPlatform* platform = MIK_GetPlatform();
278
+ if (!platform->set_device_name) return JS_UNDEFINED;
279
+ if (argc < 1 || JS_IsUndefined(argv[0]) || JS_IsNull(argv[0])) {
280
+ platform->set_device_name(NULL);
281
+ return JS_UNDEFINED;
282
+ }
283
+ const char* value = JS_ToCString(ctx, argv[0]);
284
+ if (!value) return JS_EXCEPTION;
285
+ platform->set_device_name(value);
286
+ JS_FreeCString(ctx, value);
287
+ return JS_UNDEFINED;
288
+ }
289
+
233
290
  void mik__sys_api_init(JSContext* ctx, JSValue ns) {
234
291
  JS_SetPropertyStr(ctx, ns, "evalScript",
235
292
  JS_NewCFunction(ctx, mik__sys_eval_script, "evalScript", 1));
236
293
  JS_SetPropertyStr(ctx, ns, "memoryUsage",
237
294
  JS_NewCFunction(ctx, mik__sys_memory_usage, "memoryUsage", 0));
295
+ JS_SetPropertyStr(ctx, ns, "storageUsage",
296
+ JS_NewCFunction(ctx, mik__sys_storage_usage, "storageUsage", 0));
238
297
  JS_SetPropertyStr(ctx, ns, "jsMemoryUsage",
239
298
  JS_NewCFunction(ctx, mik__sys_js_memory_usage, "jsMemoryUsage", 0));
240
299
  JS_SetPropertyStr(ctx, ns, "gc", JS_NewCFunction(ctx, mik__sys_gc, "gc", 0));
@@ -269,6 +328,11 @@ void mik__sys_api_init(JSContext* ctx, JSValue ns) {
269
328
  JS_SetPropertyStr(ctx, ns, "deviceId",
270
329
  device_id ? JS_NewString(ctx, device_id) : JS_UNDEFINED);
271
330
 
331
+ JS_SetPropertyStr(ctx, ns, "deviceName",
332
+ JS_NewCFunction(ctx, mik__sys_device_name, "deviceName", 0));
333
+ JS_SetPropertyStr(ctx, ns, "setDeviceName",
334
+ JS_NewCFunction(ctx, mik__sys_set_device_name, "setDeviceName", 1));
335
+
272
336
  const char* reset_reason =
273
337
  platform->get_reset_reason ? platform->get_reset_reason() : NULL;
274
338
  JS_SetPropertyStr(ctx, ns, "resetReason",
package/src/mikrojs.cpp CHANGED
@@ -90,9 +90,11 @@ static void mik__add_exports(JSContext* ctx, JSModuleDef* m, const char* const*
90
90
  }
91
91
 
92
92
  static const char* const sys_exports[] = {
93
- "evalScript", "memoryUsage", "jsMemoryUsage", "gc", "setTime",
94
- "uptime", "restart", "version", "board", "firmware",
95
- "deviceId", "resetReason", "activeTimers", "unloadNamespace", "isUnloadableNamespace"};
93
+ "evalScript", "memoryUsage", "storageUsage", "jsMemoryUsage",
94
+ "gc", "setTime", "uptime", "restart",
95
+ "version", "board", "firmware", "deviceId",
96
+ "deviceName", "setDeviceName", "resetReason", "activeTimers",
97
+ "unloadNamespace", "isUnloadableNamespace"};
96
98
 
97
99
  static int mik__sys_module_init(JSContext* ctx, JSModuleDef* m) {
98
100
  JSValue ns = JS_NewObjectProto(ctx, JS_NULL);
@@ -92,6 +92,22 @@ static int posix_stdin_read(void* buf, size_t len) {
92
92
  /* Derive a stable device ID from the hostname. FNV-1a hash truncated to
93
93
  * 6 bytes, then Crockford's Base32 encoded (10 lowercase chars).
94
94
  * Deterministic across restarts on the same machine. */
95
+ /* Host builds have no NVS: the name lives for the process only, which is
96
+ * enough for tests and the simulator. */
97
+ static char posix_device_name[128] = {0};
98
+
99
+ static const char* posix_get_device_name(void) {
100
+ return posix_device_name[0] == '\0' ? NULL : posix_device_name;
101
+ }
102
+
103
+ static void posix_set_device_name(const char* value) {
104
+ if (!value) {
105
+ posix_device_name[0] = '\0';
106
+ return;
107
+ }
108
+ snprintf(posix_device_name, sizeof(posix_device_name), "%s", value);
109
+ }
110
+
95
111
  static const char* posix_get_device_id(void) {
96
112
  static const char cb32[] = "0123456789abcdefghjkmnpqrstvwxyz";
97
113
  static char id[11] = {0};
@@ -151,6 +167,8 @@ static const MIKPlatform posix_platform = {
151
167
  .stderr_write = posix_stderr_write,
152
168
  .stdin_read = posix_stdin_read,
153
169
  .get_device_id = posix_get_device_id,
170
+ .get_device_name = posix_get_device_name,
171
+ .set_device_name = posix_set_device_name,
154
172
  .get_reset_reason = posix_get_reset_reason,
155
173
  };
156
174