@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 +3 -1
- package/include/mikrojs/app_store.h +23 -0
- package/include/mikrojs/platform.h +9 -0
- package/include/mikrojs/private.h +11 -0
- package/package.json +5 -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/internal.d.ts +29 -2
- package/runtime/kv/nvs.ts +16 -4
- package/runtime/kv/shared.ts +3 -2
- package/runtime/kv/types.ts +3 -2
- package/runtime/ota/ota.ts +77 -0
- package/runtime/ota/policy.ts +299 -0
- package/runtime/ota/types.ts +147 -0
- package/runtime/sys/sys.ts +36 -0
- package/runtime/sys/types.ts +22 -0
- package/src/mik_app_store.cpp +134 -0
- package/src/mik_repl.cpp +65 -37
- package/src/mik_sys.cpp +64 -0
- package/src/mikrojs.cpp +5 -3
- package/src/platform_posix.cpp +18 -0
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.
|
|
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.
|
|
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"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/runtime/internal.d.ts
CHANGED
|
@@ -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():
|
|
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 {
|
|
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 {
|
|
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
|
package/runtime/kv/shared.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
package/runtime/kv/types.ts
CHANGED
|
@@ -138,8 +138,9 @@ export interface NvsStorage {
|
|
|
138
138
|
options?: Opts,
|
|
139
139
|
): KVValue<InferOpts<Schema, Opts>>
|
|
140
140
|
|
|
141
|
-
/** Erase all
|
|
142
|
-
|
|
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
|
package/runtime/sys/sys.ts
CHANGED
|
@@ -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 {
|
package/runtime/sys/types.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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",
|
|
94
|
-
"
|
|
95
|
-
"
|
|
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);
|
package/src/platform_posix.cpp
CHANGED
|
@@ -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
|
|