@mikrojs/firmware 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.
Files changed (37) hide show
  1. package/components/mikrojs/CMakeLists.txt +9 -2
  2. package/components/mikrojs/include/mikrojs_esp32.h +23 -0
  3. package/components/mikrojs/mik_config.cpp +159 -5
  4. package/components/mikrojs/mik_deploy.cpp +54 -35
  5. package/components/mikrojs/mik_main.cpp +66 -5
  6. package/components/mikrojs/mik_nvs_kv.cpp +64 -18
  7. package/components/mikrojs/mik_ota.cpp +1791 -0
  8. package/components/mikrojs/platform_esp32.cpp +59 -0
  9. package/components/mikrojs/test/CMakeLists.txt +1 -0
  10. package/components/mikrojs/test/ble_test.cpp +2 -2
  11. package/components/mikrojs/test/fs_js_test.cpp +1 -1
  12. package/components/mikrojs/test/fs_pub_test.cpp +23 -23
  13. package/components/mikrojs/test/http_server_test.cpp +12 -2
  14. package/components/mikrojs/test/http_test.cpp +2 -2
  15. package/components/mikrojs/test/modules_extended_test.cpp +2 -2
  16. package/components/mikrojs/test/modules_test.cpp +2 -2
  17. package/components/mikrojs/test/nvs_kv_test.cpp +245 -0
  18. package/components/mikrojs/test/ota_host/gunzip_host_test.cpp +118 -0
  19. package/components/mikrojs/test/ota_host/run.sh +174 -0
  20. package/components/mikrojs/test/rtc_test.cpp +1 -1
  21. package/components/mikrojs/test/sleep_test.cpp +1 -1
  22. package/components/mikrojs/test/sntp_test.cpp +1 -1
  23. package/components/mikrojs/test/stdio_test.cpp +1 -1
  24. package/components/mikrojs/test/sys_test.cpp +1 -1
  25. package/components/mikrojs/test/udp_test.cpp +5 -5
  26. package/components/mikrojs/test/wifi_test.cpp +2 -2
  27. package/package.json +3 -3
  28. package/prebuilds/esp32/bootloader/bootloader.bin +0 -0
  29. package/prebuilds/esp32/mikrojs.bin +0 -0
  30. package/prebuilds/esp32c3/bootloader/bootloader.bin +0 -0
  31. package/prebuilds/esp32c3/mikrojs.bin +0 -0
  32. package/prebuilds/esp32c5/bootloader/bootloader.bin +0 -0
  33. package/prebuilds/esp32c5/mikrojs.bin +0 -0
  34. package/prebuilds/esp32c6/bootloader/bootloader.bin +0 -0
  35. package/prebuilds/esp32c6/mikrojs.bin +0 -0
  36. package/prebuilds/esp32s3/bootloader/bootloader.bin +0 -0
  37. package/prebuilds/esp32s3/mikrojs.bin +0 -0
@@ -41,6 +41,11 @@ if(CONFIG_BT_ENABLED)
41
41
  list(APPEND _MIK_BLE_SRCS "mik_ble.cpp" "mik_ble_c_shim.c")
42
42
  endif()
43
43
 
44
+ # mik_ota.cpp includes <miniz.h> for tinfl (gunzip). On the esp32c6 the
45
+ # implementation is in the chip mask ROM and the header comes from the
46
+ # always-available `esp_rom` common dependency, so no REQUIRES entry is needed.
47
+ # A target without ROM miniz needs the `miniz` managed component adding to
48
+ # idf_component.yml.
44
49
  idf_component_register(
45
50
  INCLUDE_DIRS "include" "${MIK_INCLUDE_DIR}"
46
51
  REQUIRES bt littlefs esp_adc esp_app_format esp_driver_gpio esp_driver_i2c esp_driver_i2s esp_driver_ledc esp_driver_rmt esp_driver_spi esp_psram esp_timer esp_http_client esp_http_server esp-tls esp_netif esp_event esp_wifi nvs_flash esp_driver_uart esp_driver_usb_serial_jtag spi_flash
@@ -55,6 +60,7 @@ idf_component_register(
55
60
  "${MIK_SRC_DIR}/fs.cpp"
56
61
  "${MIK_SRC_DIR}/mem.cpp"
57
62
  "${MIK_SRC_DIR}/mik_app_config.cpp"
63
+ "${MIK_SRC_DIR}/mik_app_store.cpp"
58
64
  "${MIK_SRC_DIR}/mik_color.cpp"
59
65
  "${MIK_SRC_DIR}/mik_console.cpp"
60
66
  "${MIK_SRC_DIR}/mik_inspect.cpp"
@@ -90,6 +96,7 @@ idf_component_register(
90
96
  "mik_pin.cpp"
91
97
  "mik_pwm.cpp"
92
98
  "mik_nvs_kv.cpp"
99
+ "mik_ota.cpp"
93
100
  "mik_recovery.cpp"
94
101
  "mik_rtc.cpp"
95
102
  "mik_serial_io.cpp"
@@ -143,8 +150,8 @@ endif()
143
150
  include("${MIK_BYTECODE_CMAKE}")
144
151
 
145
152
  # Force linker to include self-registering native modules
146
- set(_MIK_MODULES cbor pin i2c i2s spi http http_server wifi rtc nvs_kv sntp sleep neopixel pwm uart)
147
- set(_MIK_BYTECODE_MODULES abort cbor env result schema fs http/helpers http/request http/server i2c i2s 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)
153
+ set(_MIK_MODULES cbor pin i2c i2s spi http http_server wifi rtc nvs_kv sntp sleep neopixel pwm uart ota)
154
+ set(_MIK_BYTECODE_MODULES abort cbor env result schema fs http/helpers http/request http/server i2c i2s 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)
148
155
  if(CONFIG_BT_ENABLED)
149
156
  list(APPEND _MIK_MODULES ble)
150
157
  list(APPEND _MIK_BYTECODE_MODULES ble)
@@ -20,6 +20,29 @@ void MIK_Main(void);
20
20
  /* Deploy recovery (mik_deploy.cpp) */
21
21
  void MIK_DeployRecover(void);
22
22
 
23
+ /* OTA boot reconcile (mik_ota.cpp). Runs before the JS app loads: reflash
24
+ * guard, trial verdict, and a deferred clean-heap install of a staged build
25
+ * (may esp_restart). Call right after MIK_DeployRecover(). */
26
+ void mik__ota_boot_reconcile(void);
27
+
28
+ /* Adopt a streamed .tgz as the live app with no trial, recording it as the OTA
29
+ * rollback baseline (.ota-last-good.tgz). Backs MIK_CMD_DEPLOY_BUILD: a deploy
30
+ * (not an OTA download) becomes the known-good build. Verifies the build
31
+ * against `checksum` (lowercase hex, may be empty to skip), installs it, sets
32
+ * state GOOD with no trial, and clears any pending build. Returns false with
33
+ * *err pointing at a static reason string on failure. */
34
+ bool mik__ota_adopt_build(const char* tgz_path, const char* checksum, const char** err);
35
+
36
+ /* True while the running build is an unconfirmed OTA trial (mik_ota.cpp). Lets
37
+ * the boot path treat a JS-level fatal during a trial as a failed trial. */
38
+ bool mik__ota_in_trial(void);
39
+
40
+ /* Record that the running trial build hit a fatal JS error (an uncaught
41
+ * exception or unhandled rejection), so the next reconcile reverts it even
42
+ * though the reboot looks like a clean software reset. No-op outside a trial.
43
+ * `detail` is a short message kept for the revert diagnostic. */
44
+ void mik__ota_note_trial_failure(const char* detail);
45
+
23
46
  /* Safe-mode recovery window (mik_recovery.cpp).
24
47
  * Opens a brief window (typ. 500ms) right after console init that watches
25
48
  * for two triggers and returns true if either fires:
@@ -96,6 +96,11 @@ bool mik__handle_config_command(MIKReplTransport* transport, uint8_t cmd_type,
96
96
 
97
97
  case MIK_CMD_CONFIG_SET: {
98
98
  /* Payload: u8 flags | u16le key_len | key | u16le val_len | value */
99
+ if (payload_len < 3) {
100
+ mik__proto_drain(transport, payload_len);
101
+ mik__proto_send_err(transport, "config header too short");
102
+ return true;
103
+ }
99
104
  uint8_t flags;
100
105
  if (!mik__proto_read_exact(transport, &flags, 1)) return false;
101
106
 
@@ -104,26 +109,35 @@ bool mik__handle_config_command(MIKReplTransport* transport, uint8_t cmd_type,
104
109
  uint16_t key_len = kl[0] | (kl[1] << 8);
105
110
 
106
111
  char key[64];
107
- if (key_len >= sizeof(key)) {
112
+ if (key_len >= sizeof(key) || (uint32_t)key_len + 3 > payload_len) {
108
113
  mik__proto_drain(transport, payload_len - 3);
109
114
  mik__proto_send_err(transport, "key too long");
110
115
  return true;
111
116
  }
112
117
  if (!mik__proto_read_exact(transport, key, key_len)) return false;
113
118
  key[key_len] = '\0';
119
+ uint32_t consumed = 3 + (uint32_t)key_len;
114
120
 
121
+ if (consumed + 2 > payload_len) {
122
+ mik__proto_drain(transport, payload_len - consumed);
123
+ mik__proto_send_err(transport, "config value header truncated");
124
+ return true;
125
+ }
115
126
  uint8_t vl[2];
116
127
  if (!mik__proto_read_exact(transport, vl, 2)) return false;
117
128
  uint16_t val_len = vl[0] | (vl[1] << 8);
129
+ consumed += 2;
118
130
 
119
131
  char value[512];
120
- if (val_len >= sizeof(value)) {
121
- mik__proto_drain(transport, val_len);
132
+ if (val_len >= sizeof(value) || consumed + (uint32_t)val_len > payload_len) {
133
+ mik__proto_drain(transport, payload_len - consumed);
122
134
  mik__proto_send_err(transport, "value too long");
123
135
  return true;
124
136
  }
125
137
  if (!mik__proto_read_exact(transport, value, val_len)) return false;
126
138
  value[val_len] = '\0';
139
+ consumed += val_len;
140
+ if (payload_len > consumed) mik__proto_drain(transport, payload_len - consumed);
127
141
 
128
142
  if (key_len > 15) {
129
143
  mik__proto_send_err(transport, "key exceeds NVS 15-char limit");
@@ -142,24 +156,164 @@ bool mik__handle_config_command(MIKReplTransport* transport, uint8_t cmd_type,
142
156
 
143
157
  case MIK_CMD_CONFIG_DELETE: {
144
158
  /* Payload: u16le key_len | key */
159
+ if (payload_len < 2) {
160
+ mik__proto_drain(transport, payload_len);
161
+ mik__proto_send_err(transport, "config header too short");
162
+ return true;
163
+ }
145
164
  uint8_t kl[2];
146
165
  if (!mik__proto_read_exact(transport, kl, 2)) return false;
147
166
  uint16_t key_len = kl[0] | (kl[1] << 8);
148
167
 
149
168
  char key[64];
150
- if (key_len >= sizeof(key)) {
151
- mik__proto_drain(transport, key_len);
169
+ if (key_len >= sizeof(key) || (uint32_t)key_len + 2 > payload_len) {
170
+ mik__proto_drain(transport, payload_len - 2);
152
171
  mik__proto_send_err(transport, "key too long");
153
172
  return true;
154
173
  }
155
174
  if (!mik__proto_read_exact(transport, key, key_len)) return false;
156
175
  key[key_len] = '\0';
176
+ uint32_t consumed = 2 + (uint32_t)key_len;
177
+ if (payload_len > consumed) mik__proto_drain(transport, payload_len - consumed);
157
178
 
158
179
  uint8_t byte = mik__nvs_env_delete(key) ? 1 : 0;
159
180
  mik__proto_send(transport, MIK_MSG_OK, &byte, 1);
160
181
  return true;
161
182
  }
162
183
 
184
+ case MIK_CMD_KV_SET: {
185
+ /* Payload: u8 ns | u16le key_len | key | u16le val_len | value.
186
+ * ns selects the namespace (0 = mik.kv, 1 = mik.sys). The value is
187
+ * stored as a CBOR text-string blob, matching what `mikro/kv/nvs`
188
+ * writes, so JS reads it back as a plain string. */
189
+ if (payload_len < 3) {
190
+ mik__proto_drain(transport, payload_len);
191
+ mik__proto_send_err(transport, "kv header too short");
192
+ return true;
193
+ }
194
+ uint8_t ns;
195
+ if (!mik__proto_read_exact(transport, &ns, 1)) return false;
196
+ if (ns > 1) {
197
+ mik__proto_drain(transport, payload_len - 1);
198
+ mik__proto_send_err(transport, "unknown kv namespace");
199
+ return true;
200
+ }
201
+
202
+ uint8_t kl[2];
203
+ if (!mik__proto_read_exact(transport, kl, 2)) return false;
204
+ uint16_t key_len = kl[0] | (kl[1] << 8);
205
+
206
+ char key[64];
207
+ if (key_len >= sizeof(key) || (uint32_t)key_len + 3 > payload_len) {
208
+ mik__proto_drain(transport, payload_len - 3);
209
+ mik__proto_send_err(transport, "key too long");
210
+ return true;
211
+ }
212
+ if (!mik__proto_read_exact(transport, key, key_len)) return false;
213
+ key[key_len] = '\0';
214
+ uint32_t consumed = 3 + (uint32_t)key_len;
215
+
216
+ if (consumed + 2 > payload_len) {
217
+ mik__proto_drain(transport, payload_len - consumed);
218
+ mik__proto_send_err(transport, "kv value header truncated");
219
+ return true;
220
+ }
221
+ uint8_t vl[2];
222
+ if (!mik__proto_read_exact(transport, vl, 2)) return false;
223
+ uint16_t val_len = vl[0] | (vl[1] << 8);
224
+ consumed += 2;
225
+
226
+ char value[512];
227
+ if (val_len >= sizeof(value) || consumed + (uint32_t)val_len > payload_len) {
228
+ mik__proto_drain(transport, payload_len - consumed);
229
+ mik__proto_send_err(transport, "value too long");
230
+ return true;
231
+ }
232
+ if (!mik__proto_read_exact(transport, value, val_len)) return false;
233
+ value[val_len] = '\0';
234
+ consumed += val_len;
235
+ if (payload_len > consumed) mik__proto_drain(transport, payload_len - consumed);
236
+
237
+ if (key_len == 0 || key_len > 15) {
238
+ mik__proto_send_err(transport, "key exceeds NVS 15-char limit");
239
+ return true;
240
+ }
241
+
242
+ nanocbor_encoder_t enc;
243
+ uint8_t blob[sizeof(value) + 4];
244
+ nanocbor_encoder_init(&enc, blob, sizeof(blob));
245
+ if (nanocbor_put_tstrn(&enc, value, val_len) < 0) {
246
+ mik__proto_send_err(transport, "cbor encode failed");
247
+ return true;
248
+ }
249
+ size_t blob_len = nanocbor_encoded_len(&enc);
250
+
251
+ nvs_handle_t handle;
252
+ esp_err_t err = nvs_open(ns == 1 ? "mik.sys" : "mik.kv", NVS_READWRITE, &handle);
253
+ if (err == ESP_OK) {
254
+ err = nvs_set_blob(handle, key, blob, blob_len);
255
+ if (err == ESP_OK) err = nvs_commit(handle);
256
+ nvs_close(handle);
257
+ }
258
+ if (err == ESP_OK) {
259
+ mik__proto_send_ok(transport);
260
+ } else {
261
+ mik__proto_send_err(transport, "nvs write failed");
262
+ }
263
+ return true;
264
+ }
265
+
266
+ case MIK_CMD_KV_DELETE: {
267
+ /* Payload: u8 ns | u16le key_len | key */
268
+ if (payload_len < 3) {
269
+ mik__proto_drain(transport, payload_len);
270
+ mik__proto_send_err(transport, "kv header too short");
271
+ return true;
272
+ }
273
+ uint8_t ns;
274
+ if (!mik__proto_read_exact(transport, &ns, 1)) return false;
275
+ if (ns > 1) {
276
+ mik__proto_drain(transport, payload_len - 1);
277
+ mik__proto_send_err(transport, "unknown kv namespace");
278
+ return true;
279
+ }
280
+
281
+ uint8_t kl[2];
282
+ if (!mik__proto_read_exact(transport, kl, 2)) return false;
283
+ uint16_t key_len = kl[0] | (kl[1] << 8);
284
+
285
+ char key[64];
286
+ if (key_len >= sizeof(key) || (uint32_t)key_len + 3 > payload_len) {
287
+ mik__proto_drain(transport, payload_len - 3);
288
+ mik__proto_send_err(transport, "key too long");
289
+ return true;
290
+ }
291
+ if (!mik__proto_read_exact(transport, key, key_len)) return false;
292
+ key[key_len] = '\0';
293
+ uint32_t consumed = 3 + (uint32_t)key_len;
294
+ if (payload_len > consumed) mik__proto_drain(transport, payload_len - consumed);
295
+
296
+ bool existed = false;
297
+ esp_err_t err = ESP_FAIL;
298
+ nvs_handle_t handle;
299
+ if (nvs_open(ns == 1 ? "mik.sys" : "mik.kv", NVS_READWRITE, &handle) == ESP_OK) {
300
+ size_t len = 0;
301
+ existed = nvs_get_blob(handle, key, nullptr, &len) == ESP_OK;
302
+ err = nvs_erase_key(handle, key);
303
+ /* Deleting a key that was never there is a successful no-op. */
304
+ if (err == ESP_ERR_NVS_NOT_FOUND) err = ESP_OK;
305
+ if (err == ESP_OK) err = nvs_commit(handle);
306
+ nvs_close(handle);
307
+ }
308
+ if (err != ESP_OK) {
309
+ mik__proto_send_err(transport, "nvs delete failed");
310
+ return true;
311
+ }
312
+ uint8_t byte = existed ? 1 : 0;
313
+ mik__proto_send(transport, MIK_MSG_OK, &byte, 1);
314
+ return true;
315
+ }
316
+
163
317
  default:
164
318
  mik__proto_drain(transport, payload_len);
165
319
  return false;
@@ -9,6 +9,7 @@
9
9
  #include <freertos/FreeRTOS.h>
10
10
  #include <freertos/task.h>
11
11
 
12
+ #include "mikrojs/app_store.h"
12
13
  #include "mikrojs/mikrojs.h"
13
14
  #include "mikrojs/platform.h"
14
15
  #include "mikrojs/private.h"
@@ -16,9 +17,11 @@
16
17
 
17
18
  static const char* FS_BASE = "/appfs";
18
19
  static const char* DEPLOY_TMP = "/appfs/.deploy-tmp";
19
- static const char* DEPLOY_OLD = "/appfs/.deploy-old";
20
20
  static const char* APP_DIR = "/appfs/app";
21
21
  static const char* CHECKSUMS_PATH = "/appfs/app/.checksums";
22
+ /* Where DEPLOY_PUT("/.build.tgz", ...) streams the OTA build (DEPLOY_TMP +
23
+ * "/.build.tgz"). Adopted by MIK_CMD_DEPLOY_BUILD. */
24
+ static const char* BUILD_TGZ_PATH = "/appfs/.deploy-tmp/.build.tgz";
22
25
 
23
26
  /* ── Checksums manifest ─────────────────────────────────────────── */
24
27
 
@@ -239,19 +242,7 @@ static bool copy_file(const char* src, const char* dst) {
239
242
  /* ── Deploy recovery ─────────────────────────────────────────────── */
240
243
 
241
244
  void MIK_DeployRecover(void) {
242
- bool has_app = path_exists(APP_DIR);
243
- bool has_old = path_exists(DEPLOY_OLD);
244
- bool has_tmp = path_exists(DEPLOY_TMP);
245
-
246
- if (!has_app && has_old) {
247
- rename(DEPLOY_OLD, APP_DIR);
248
- } else if (has_old) {
249
- rmdir_recursive(DEPLOY_OLD);
250
- }
251
-
252
- if (has_tmp) {
253
- rmdir_recursive(DEPLOY_TMP);
254
- }
245
+ mik__app_recover(FS_BASE);
255
246
  }
256
247
 
257
248
  /* ── Unified protocol deploy handler ────────────────────────────── */
@@ -540,36 +531,64 @@ bool mik__handle_deploy_command(MIKReplTransport* transport, uint8_t cmd_type,
540
531
  }
541
532
 
542
533
  /* Atomic swap: staging dir → app dir */
543
- if (path_exists(APP_DIR) && !s_deploy_erased) {
544
- rmdir_recursive(DEPLOY_OLD);
545
- if (rename(APP_DIR, DEPLOY_OLD) != 0) {
546
- mik__proto_send_err(transport, "rename app to old failed");
547
- deploy_cleanup();
548
- return true;
549
- }
534
+ MIKAppCommitResult commit = mik__app_commit(FS_BASE, s_deploy_erased);
535
+ if (commit == MIK_APP_COMMIT_STASH_FAILED) {
536
+ mik__proto_send_err(transport, "rename app to old failed");
537
+ deploy_cleanup();
538
+ return true;
550
539
  }
551
-
552
- char staged_app[512];
553
- snprintf(staged_app, sizeof(staged_app), "%s/app", DEPLOY_TMP);
554
- if (path_exists(staged_app)) {
555
- if (rename(staged_app, APP_DIR) != 0) {
556
- if (path_exists(DEPLOY_OLD)) {
557
- rename(DEPLOY_OLD, APP_DIR);
558
- }
559
- mik__proto_send_err(transport, "rename staged to app failed");
560
- deploy_cleanup();
561
- return true;
562
- }
540
+ if (commit == MIK_APP_COMMIT_SWAP_FAILED) {
541
+ mik__proto_send_err(transport, "rename staged to app failed");
542
+ deploy_cleanup();
543
+ return true;
563
544
  }
564
545
 
565
- rmdir_recursive(DEPLOY_OLD);
566
- rmdir_recursive(DEPLOY_TMP);
567
546
  stamp_checksums_manifest();
568
547
  mik__proto_send_ok(transport);
569
548
  deploy_cleanup();
570
549
  return true;
571
550
  }
572
551
 
552
+ case MIK_CMD_DEPLOY_BUILD: {
553
+ /* Adopt a streamed .tgz as the live app via the OTA install path,
554
+ * establishing the rollback baseline. The build was streamed via
555
+ * PUT("/.build.tgz") + chunks into BUILD_TGZ_PATH.
556
+ * Payload: u16le checksum_len | checksum. */
557
+ if (payload_len < 2) {
558
+ mik__proto_drain(transport, payload_len);
559
+ mik__proto_send_err(transport, "build header too short");
560
+ deploy_cleanup();
561
+ return true;
562
+ }
563
+ uint8_t cl[2];
564
+ if (!mik__proto_read_exact(transport, cl, 2)) return false;
565
+ uint16_t chk_len = cl[0] | (cl[1] << 8);
566
+
567
+ char checksum[96];
568
+ if (chk_len >= sizeof(checksum) || (uint32_t)chk_len + 2 > payload_len) {
569
+ mik__proto_drain(transport, payload_len - 2);
570
+ mik__proto_send_err(transport, "build checksum too long");
571
+ deploy_cleanup();
572
+ return true;
573
+ }
574
+ if (!mik__proto_read_exact(transport, checksum, chk_len)) return false;
575
+ checksum[chk_len] = '\0';
576
+ uint32_t consumed = 2 + (uint32_t)chk_len;
577
+ if (payload_len > consumed) mik__proto_drain(transport, payload_len - consumed);
578
+
579
+ /* Flush the staged .tgz to disk before adopt reads it back. */
580
+ put_close_and_clear();
581
+
582
+ const char* err = nullptr;
583
+ if (mik__ota_adopt_build(BUILD_TGZ_PATH, checksum, &err)) {
584
+ mik__proto_send_ok(transport);
585
+ } else {
586
+ mik__proto_send_err(transport, err ? err : "build install failed");
587
+ }
588
+ deploy_cleanup();
589
+ return true;
590
+ }
591
+
573
592
  case MIK_CMD_DEPLOY_ABORT:
574
593
  mik__proto_drain(transport, payload_len);
575
594
  rmdir_recursive(DEPLOY_TMP);
@@ -21,6 +21,41 @@
21
21
 
22
22
  static const char* TAG = "mikrojs";
23
23
 
24
+ /* Error handler armed during a normal boot: when the app hits a fatal JS error
25
+ * (uncaught exception or unhandled rejection) while running an OTA trial build,
26
+ * flag the trial so the next reconcile reverts it. A JS error reboots as a clean
27
+ * software reset, which the trial state machine can't otherwise tell from a
28
+ * healthy restart. No-op outside a trial. */
29
+ static void ota_trial_error_handler(JSContext* ctx, JSValue error, void* /*opaque*/) {
30
+ if (!mik__ota_in_trial()) {
31
+ return;
32
+ }
33
+ char detail[160] = "uncaught error";
34
+ if (JS_IsObject(error)) {
35
+ JSValue name_v = JS_GetPropertyStr(ctx, error, "name");
36
+ JSValue msg_v = JS_GetPropertyStr(ctx, error, "message");
37
+ const char* name = JS_ToCString(ctx, name_v);
38
+ const char* msg = JS_ToCString(ctx, msg_v);
39
+ if (name && name[0] && msg && msg[0]) {
40
+ snprintf(detail, sizeof(detail), "%s: %s", name, msg);
41
+ } else if (msg && msg[0]) {
42
+ snprintf(detail, sizeof(detail), "%s", msg);
43
+ } else if (name && name[0]) {
44
+ snprintf(detail, sizeof(detail), "%s", name);
45
+ }
46
+ /* A throwing getter (or a toString that throws) leaves an exception
47
+ * pending on ctx that nothing downstream would ever clear. */
48
+ if (!name || !msg) {
49
+ JS_FreeValue(ctx, JS_GetException(ctx));
50
+ }
51
+ if (name) JS_FreeCString(ctx, name);
52
+ if (msg) JS_FreeCString(ctx, msg);
53
+ JS_FreeValue(ctx, name_v);
54
+ JS_FreeValue(ctx, msg_v);
55
+ }
56
+ mik__ota_note_trial_failure(detail);
57
+ }
58
+
24
59
  /* Apply the MIK_LOG_LEVEL env var (if set in NVS) to our log tags.
25
60
  *
26
61
  * The firmware compiles in all levels up to DEBUG
@@ -163,8 +198,9 @@ static bool platform_command_handler(MIKReplTransport* transport, uint8_t cmd_ty
163
198
  return true;
164
199
  }
165
200
 
166
- /* Deploy commands (0x20-0x27) */
167
- if (cmd_type >= MIK_CMD_DEPLOY_PUT && cmd_type <= MIK_CMD_DEPLOY_CHECKSUM) {
201
+ /* Deploy commands (0x20-0x27) plus the build-install command (0x2D) */
202
+ if ((cmd_type >= MIK_CMD_DEPLOY_PUT && cmd_type <= MIK_CMD_DEPLOY_CHECKSUM) ||
203
+ cmd_type == MIK_CMD_DEPLOY_BUILD) {
168
204
  return mik__handle_deploy_command(transport, cmd_type, payload_len);
169
205
  }
170
206
 
@@ -181,8 +217,8 @@ static bool platform_command_handler(MIKReplTransport* transport, uint8_t cmd_ty
181
217
  return true;
182
218
  }
183
219
 
184
- /* Config commands (0x40-0x42) */
185
- if (cmd_type >= MIK_CMD_CONFIG_LIST && cmd_type <= MIK_CMD_CONFIG_DELETE) {
220
+ /* Config + kv provisioning commands (0x40-0x44) */
221
+ if (cmd_type >= MIK_CMD_CONFIG_LIST && cmd_type <= MIK_CMD_KV_DELETE) {
186
222
  return mik__handle_config_command(transport, cmd_type, payload_len);
187
223
  }
188
224
 
@@ -291,6 +327,20 @@ void MIK_Main(void) {
291
327
  /* Recover from incomplete deploys */
292
328
  MIK_DeployRecover();
293
329
 
330
+ /* Reconcile OTA state before the JS app loads: reflash guard, trial
331
+ * verdict, and a deferred clean-heap install of a staged build (may
332
+ * restart). Runs after DeployRecover so an interrupted unpack's staging
333
+ * dir is already cleaned.
334
+ *
335
+ * Skipped in safe mode. The install budget bounds a crashing install, but
336
+ * safe mode is what a developer reaches for while it is still burning
337
+ * through that budget, and reconcile runs long before the REPL comes up:
338
+ * without this, the recovery window opens onto an install that panics
339
+ * before anything can be typed into it. */
340
+ if (!safe_mode) {
341
+ mik__ota_boot_reconcile();
342
+ }
343
+
294
344
  /* Load app config (mikro.config.json) if present */
295
345
  MIKConfig app_config;
296
346
  MIK_LoadConfig("/appfs", &app_config);
@@ -565,13 +615,24 @@ void MIK_Main(void) {
565
615
  if (safe_mode) {
566
616
  ESP_LOGW(TAG, "Safe mode: skipping entry point %s", app_config.entry_point);
567
617
  } else {
568
- int rc = MIK_RunEntry(mik_rt, app_config.entry_point);
618
+ /* Catch async fatals (unhandled rejections) during an OTA trial. */
619
+ MIK_SetErrorHandler(mik_rt, ota_trial_error_handler, nullptr);
620
+ char entry_err[160] = {0};
621
+ int rc = MIK_RunEntryErr(mik_rt, app_config.entry_point, entry_err, sizeof(entry_err));
569
622
  if (rc == -EINVAL) {
570
623
  ESP_LOGW(TAG, "No entry point configured (no \"main\" field in package.json)");
571
624
  } else if (rc == -ENOENT) {
572
625
  ESP_LOGW(TAG, "Entry point not found: %s", app_config.entry_point);
573
626
  } else if (rc == -EFAULT) {
574
627
  ESP_LOGE(TAG, "Failed to evaluate %s", app_config.entry_point);
628
+ /* A synchronous top-level throw doesn't reach the rejection handler
629
+ * above. If this is a trial build, flag it and arm the panic restart
630
+ * so reconcile reverts on the next (clean-looking) boot. */
631
+ if (mik__ota_in_trial()) {
632
+ mik__ota_note_trial_failure(entry_err[0] ? entry_err
633
+ : "entry failed to evaluate");
634
+ MIK_Stop(mik_rt);
635
+ }
575
636
  }
576
637
  }
577
638
  }