@mikrojs/firmware 0.18.2 → 0.18.3-next.20260829153835

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.
@@ -5,6 +5,7 @@
5
5
 
6
6
  #include <cstdarg>
7
7
  #include <cstdio>
8
+ #include <cstdlib>
8
9
  #include <cstring>
9
10
  #include <ctime>
10
11
  #include <sys/stat.h>
@@ -18,11 +19,16 @@
18
19
 
19
20
  static const char* TAG = "mik_logfile";
20
21
 
21
- /* Static buffers — total ~1.5 KiB plus a FreeRTOS mutex. */
22
- static char s_line_buf[512]; /* current line being assembled */
23
- static char s_stdio_buf[1024]; /* setvbuf target for the FILE* */
24
- static char s_path_main[96]; /* "<dir>/log.txt" */
25
- static char s_path_rot[100]; /* "<dir>/log.txt.1" */
22
+ /* Log buffers (~1.7 KiB), heap-allocated in mik_logfile_init so boots
23
+ * without a configured log file don't carry them in BSS. Never freed once
24
+ * logging is enabled suspend/resume reuse the paths and stdio buffer. */
25
+ struct MIKLogBufs {
26
+ char line[512]; /* current line being assembled */
27
+ char stdio[1024]; /* setvbuf target for the FILE* */
28
+ char path_main[96]; /* "<dir>/log.txt" */
29
+ char path_rot[100]; /* "<dir>/log.txt.1" */
30
+ };
31
+ static MIKLogBufs* s_bufs = nullptr;
26
32
  static SemaphoreHandle_t s_mtx = nullptr;
27
33
  static FILE* s_file = nullptr;
28
34
  static size_t s_file_size = 0;
@@ -62,16 +68,26 @@ static bool line_is_error_level(const char* line, size_t len) {
62
68
  return false;
63
69
  }
64
70
 
71
+ /* Caller holds s_mtx. fflush only moves the stdio buffer into the VFS;
72
+ * on LittleFS the bytes stay in the file handle's cache — invisible to
73
+ * other opens (fs.readFile, `mikro logs pull`) and lost on power cut —
74
+ * until fsync commits them. A flush that isn't durable defeats the
75
+ * purpose of the flush policy, so always pair the two. */
76
+ static void flush_to_flash() {
77
+ fflush(s_file);
78
+ fsync(fileno(s_file));
79
+ }
80
+
65
81
  /* Caller holds s_mtx. */
66
82
  static void rotate_if_needed() {
67
83
  if (s_file_size < s_max_size) return;
68
84
  fclose(s_file);
69
85
  s_file = nullptr;
70
- unlink(s_path_rot);
71
- rename(s_path_main, s_path_rot);
72
- s_file = fopen(s_path_main, "a");
86
+ unlink(s_bufs->path_rot);
87
+ rename(s_bufs->path_main, s_bufs->path_rot);
88
+ s_file = fopen(s_bufs->path_main, "a");
73
89
  if (s_file) {
74
- setvbuf(s_file, s_stdio_buf, _IOFBF, sizeof(s_stdio_buf));
90
+ setvbuf(s_file, s_bufs->stdio, _IOFBF, sizeof(s_bufs->stdio));
75
91
  s_file_size = 0;
76
92
  }
77
93
  }
@@ -84,12 +100,12 @@ static void emit_line() {
84
100
  s_line_has_ts = false;
85
101
  return;
86
102
  }
87
- size_t n = fwrite(s_line_buf, 1, s_line_pos, s_file);
103
+ size_t n = fwrite(s_bufs->line, 1, s_line_pos, s_file);
88
104
  s_file_size += n;
89
- bool is_err = line_is_error_level(s_line_buf, s_line_pos);
105
+ bool is_err = line_is_error_level(s_bufs->line, s_line_pos);
90
106
  if (s_flush_policy == MIK_LOG_FLUSH_LINE ||
91
107
  (s_flush_policy == MIK_LOG_FLUSH_ERROR && is_err)) {
92
- fflush(s_file);
108
+ flush_to_flash();
93
109
  }
94
110
  rotate_if_needed();
95
111
  s_line_pos = 0;
@@ -99,17 +115,17 @@ static void emit_line() {
99
115
  /* Caller holds s_mtx. */
100
116
  static void append_byte(uint8_t c) {
101
117
  if (!s_line_has_ts) {
102
- int n = format_timestamp(s_line_buf, sizeof(s_line_buf) - 1);
103
- if (n < 0 || (size_t)n >= sizeof(s_line_buf) - 1) n = 0;
118
+ int n = format_timestamp(s_bufs->line, sizeof(s_bufs->line) - 1);
119
+ if (n < 0 || (size_t)n >= sizeof(s_bufs->line) - 1) n = 0;
104
120
  s_line_pos = (size_t)n;
105
121
  s_line_has_ts = true;
106
122
  }
107
- if (s_line_pos < sizeof(s_line_buf) - 1) {
108
- s_line_buf[s_line_pos++] = (char)c;
123
+ if (s_line_pos < sizeof(s_bufs->line) - 1) {
124
+ s_bufs->line[s_line_pos++] = (char)c;
109
125
  }
110
- if (c == '\n' || s_line_pos >= sizeof(s_line_buf) - 1) {
111
- if (s_line_pos == 0 || s_line_buf[s_line_pos - 1] != '\n') {
112
- s_line_buf[s_line_pos++] = '\n';
126
+ if (c == '\n' || s_line_pos >= sizeof(s_bufs->line) - 1) {
127
+ if (s_line_pos == 0 || s_bufs->line[s_line_pos - 1] != '\n') {
128
+ s_bufs->line[s_line_pos++] = '\n';
113
129
  }
114
130
  emit_line();
115
131
  }
@@ -133,12 +149,12 @@ static void log_emit_tap(uint8_t msg_type, const void* data, size_t len) {
133
149
  for (size_t i = 0; i < len; i++) append_byte(p[i]);
134
150
  /* Ensure the line is closed even if the body lacks a trailing newline
135
151
  * (mik__repl_proto_send_output is called once per logical message). */
136
- if (s_line_pos > 0 && s_line_buf[s_line_pos - 1] != '\n') {
152
+ if (s_line_pos > 0 && s_bufs->line[s_line_pos - 1] != '\n') {
137
153
  append_byte((uint8_t)'\n');
138
154
  }
139
155
  if (s_file && (msg_type == MIK_MSG_ERROR || msg_type == MIK_MSG_WARN ||
140
156
  msg_type == MIK_MSG_EVAL_ERROR)) {
141
- fflush(s_file);
157
+ flush_to_flash();
142
158
  }
143
159
  xSemaphoreGive(s_mtx);
144
160
  }
@@ -174,24 +190,35 @@ void mik_logfile_init(const MIKConfig* config) {
174
190
  * which is fine. */
175
191
  mkdir(config->log_dir, 0775);
176
192
 
177
- if ((size_t)snprintf(s_path_main, sizeof(s_path_main), "%s/log.txt", config->log_dir) >=
178
- sizeof(s_path_main)) {
193
+ s_bufs = static_cast<MIKLogBufs*>(calloc(1, sizeof(MIKLogBufs)));
194
+ if (!s_bufs) return;
195
+
196
+ if ((size_t)snprintf(s_bufs->path_main, sizeof(s_bufs->path_main), "%s/log.txt", config->log_dir) >=
197
+ sizeof(s_bufs->path_main)) {
179
198
  platform->log(MIK_LOG_WARN, TAG, "log dir path too long");
199
+ free(s_bufs);
200
+ s_bufs = nullptr;
180
201
  return;
181
202
  }
182
- snprintf(s_path_rot, sizeof(s_path_rot), "%s.1", s_path_main);
203
+ snprintf(s_bufs->path_rot, sizeof(s_bufs->path_rot), "%s.1", s_bufs->path_main);
183
204
 
184
205
  s_mtx = xSemaphoreCreateMutex();
185
- if (!s_mtx) return;
206
+ if (!s_mtx) {
207
+ free(s_bufs);
208
+ s_bufs = nullptr;
209
+ return;
210
+ }
186
211
 
187
- s_file = fopen(s_path_main, "a");
212
+ s_file = fopen(s_bufs->path_main, "a");
188
213
  if (!s_file) {
189
214
  platform->log(MIK_LOG_WARN, TAG, "Could not open log file");
190
215
  vSemaphoreDelete(s_mtx);
191
216
  s_mtx = nullptr;
217
+ free(s_bufs);
218
+ s_bufs = nullptr;
192
219
  return;
193
220
  }
194
- setvbuf(s_file, s_stdio_buf, _IOFBF, sizeof(s_stdio_buf));
221
+ setvbuf(s_file, s_bufs->stdio, _IOFBF, sizeof(s_bufs->stdio));
195
222
  fseek(s_file, 0, SEEK_END);
196
223
  long pos = ftell(s_file);
197
224
  s_file_size = pos > 0 ? (size_t)pos : 0;
@@ -221,9 +248,9 @@ void mik_logfile_resume(void) {
221
248
  if (!s_mtx) return;
222
249
  if (xSemaphoreTake(s_mtx, pdMS_TO_TICKS(100)) != pdTRUE) return;
223
250
  if (!s_file) {
224
- s_file = fopen(s_path_main, "a");
251
+ s_file = fopen(s_bufs->path_main, "a");
225
252
  if (s_file) {
226
- setvbuf(s_file, s_stdio_buf, _IOFBF, sizeof(s_stdio_buf));
253
+ setvbuf(s_file, s_bufs->stdio, _IOFBF, sizeof(s_bufs->stdio));
227
254
  fseek(s_file, 0, SEEK_END);
228
255
  long pos = ftell(s_file);
229
256
  s_file_size = pos > 0 ? (size_t)pos : 0;
@@ -239,12 +266,12 @@ void mik_logfile_reset(void) {
239
266
  fclose(s_file);
240
267
  s_file = nullptr;
241
268
  }
242
- unlink(s_path_main);
243
- unlink(s_path_rot);
269
+ unlink(s_bufs->path_main);
270
+ unlink(s_bufs->path_rot);
244
271
  /* Reopen the same path: unlinked, so this starts a fresh empty file. */
245
- s_file = fopen(s_path_main, "a");
272
+ s_file = fopen(s_bufs->path_main, "a");
246
273
  if (s_file) {
247
- setvbuf(s_file, s_stdio_buf, _IOFBF, sizeof(s_stdio_buf));
274
+ setvbuf(s_file, s_bufs->stdio, _IOFBF, sizeof(s_bufs->stdio));
248
275
  s_file_size = 0;
249
276
  }
250
277
  /* Drop any half-assembled line so a stale prefix doesn't bleed into
@@ -257,7 +284,7 @@ void mik_logfile_reset(void) {
257
284
  void mik_logfile_flush(void) {
258
285
  if (!s_mtx || !s_file) return;
259
286
  if (xSemaphoreTake(s_mtx, pdMS_TO_TICKS(50)) != pdTRUE) return;
260
- fflush(s_file);
287
+ flush_to_flash();
261
288
  xSemaphoreGive(s_mtx);
262
289
  }
263
290
 
@@ -271,10 +298,10 @@ void mik_logfile_close(void) {
271
298
  s_prev_vprintf = nullptr;
272
299
  }
273
300
  if (s_line_pos > 0) {
274
- if (s_line_buf[s_line_pos - 1] != '\n' && s_line_pos < sizeof(s_line_buf)) {
275
- s_line_buf[s_line_pos++] = '\n';
301
+ if (s_bufs->line[s_line_pos - 1] != '\n' && s_line_pos < sizeof(s_bufs->line)) {
302
+ s_bufs->line[s_line_pos++] = '\n';
276
303
  }
277
- if (s_file) fwrite(s_line_buf, 1, s_line_pos, s_file);
304
+ if (s_file) fwrite(s_bufs->line, 1, s_line_pos, s_file);
278
305
  s_line_pos = 0;
279
306
  }
280
307
  if (s_file) {
@@ -1,5 +1,6 @@
1
1
  #include <cerrno>
2
2
  #include <stdio.h>
3
+ #include <stdlib.h>
3
4
  #include <string.h>
4
5
  #include <strings.h>
5
6
  #include <sys/time.h>
@@ -97,24 +98,26 @@ static void mik__apply_nvs_log_level(void) {
97
98
  esp_log_level_set("mik_app_config", level);
98
99
  }
99
100
 
100
- /* File-scope scratch buffers for the supervisor loop.
101
+ /* Scratch buffers for the supervisor loop, heap-allocated for the duration
102
+ * of a test-manifest run so normal boots don't carry them in BSS (~3 KB).
101
103
  *
102
104
  * Kept off the stack because the main task stack is ~24 KB and cannot
103
105
  * accommodate 1-2 KB of local buffers on top of the nested eval/module
104
106
  * normalizer call chain (observed as a stack-protection fault in
105
107
  * mik_module_normalizer on the first test file). Fixed sizes instead of
106
108
  * PATH_MAX scaling — on newlib PATH_MAX can be 4096, which is absurd for
107
- * our purposes and would still blow the BSS budget. These sizes fit any
108
- * realistic on-device test path. Only one test manifest runs per boot,
109
- * so single-ownership is safe. */
109
+ * our purposes. These sizes fit any realistic on-device test path. Only
110
+ * one test manifest runs per boot, so single-ownership is safe. */
110
111
  #define MIK_SUP_PATH_MAX 384
111
- static char s_sup_dbg[MIK_SUP_PATH_MAX + 64];
112
- static char s_sup_esc[MIK_SUP_PATH_MAX * 2 + 8];
113
- static char s_sup_buf[MIK_SUP_PATH_MAX * 2 + 512];
114
- /* Exception text captured by MIK_RunEntryErr when a test file fails to
115
- * evaluate, and its JSON-escaped form for the synthesized test event. */
116
- static char s_sup_err[192];
117
- static char s_sup_err_esc[sizeof(s_sup_err) * 2 + 8];
112
+ struct MIKSupScratch {
113
+ char dbg[MIK_SUP_PATH_MAX + 64];
114
+ char esc[MIK_SUP_PATH_MAX * 2 + 8];
115
+ char buf[MIK_SUP_PATH_MAX * 2 + 512];
116
+ /* Exception text captured by MIK_RunEntryErr when a test file fails to
117
+ * evaluate, and its JSON-escaped form for the synthesized test event. */
118
+ char err[192];
119
+ char err_esc[sizeof(err) * 2 + 8];
120
+ };
118
121
 
119
122
  /* Minimal JSON string-escape into a bounded buffer. Handles `"`, `\`, and
120
123
  * control characters; everything else copies verbatim. Returns bytes
@@ -540,6 +543,18 @@ void MIK_Main(void) {
540
543
  MIK_ProtocolOpen(&transport);
541
544
 
542
545
  if (test_mode) {
546
+ auto* sup = static_cast<MIKSupScratch*>(malloc(sizeof(MIKSupScratch)));
547
+ if (!sup) {
548
+ ESP_LOGE(TAG, "Not enough memory for the test supervisor");
549
+ /* Fail loud: a synthesized failing run-done plus end-of-manifest
550
+ * lets the CLI report the failure instead of hanging on a stream
551
+ * that will never produce results. */
552
+ static const char kOomRunDone[] = "{\"e\":6,\"p\":0,\"f\":1,\"k\":0,\"o\":0,\"d\":0}";
553
+ mik__proto_send(&transport, MIK_MSG_TEST, kOomRunDone, sizeof(kOomRunDone) - 1);
554
+ mik__proto_send(&transport, MIK_MSG_MANIFEST_DONE, nullptr, 0);
555
+ return;
556
+ }
557
+
543
558
  /* Discard the primary runtime — each test gets a fresh one. */
544
559
  MIK_FreeRuntime(mik_rt);
545
560
  mik_rt = nullptr;
@@ -548,21 +563,21 @@ void MIK_Main(void) {
548
563
  /* Diagnostic: announce the file about to run so the CLI can
549
564
  * confirm the supervisor's iteration matches its own testFiles
550
565
  * order. The MSG_DEBUG frame is rendered as a dim log line.
551
- * Uses file-scope s_sup_dbg to avoid bloating the main task
552
- * stack — the normalizer call chain during module resolution
553
- * already sits a few KB deep. */
566
+ * Uses heap scratch to avoid bloating the main task stack —
567
+ * the normalizer call chain during module resolution already
568
+ * sits a few KB deep. */
554
569
  {
555
- int n = snprintf(s_sup_dbg, sizeof(s_sup_dbg),
570
+ int n = snprintf(sup->dbg, sizeof(sup->dbg),
556
571
  "[supervisor] running %zu/%zu: %s", i + 1, test_count,
557
572
  test_paths[i]);
558
- if (n > 0 && n < (int)sizeof(s_sup_dbg)) {
559
- mik__proto_send(&transport, MIK_MSG_DEBUG, s_sup_dbg, n);
573
+ if (n > 0 && n < (int)sizeof(sup->dbg)) {
574
+ mik__proto_send(&transport, MIK_MSG_DEBUG, sup->dbg, n);
560
575
  }
561
576
  }
562
577
  MIKRuntime* rt = create_runtime();
563
578
  MIK_EnableTestHelpers(rt);
564
579
  MIK_ProtocolAttach(rt);
565
- int rc = MIK_RunEntryErr(rt, test_paths[i], s_sup_err, sizeof(s_sup_err));
580
+ int rc = MIK_RunEntryErr(rt, test_paths[i], sup->err, sizeof(sup->err));
566
581
  const char* fail_reason = nullptr;
567
582
  if (rc == -ENOENT) {
568
583
  fail_reason = "Test file not found";
@@ -584,38 +599,38 @@ void MIK_Main(void) {
584
599
  * the runtime will never emit. Escape the path so any `"`
585
600
  * or `\` in it doesn't corrupt the JSON frame. */
586
601
  ESP_LOGE(TAG, "%s: %s", fail_reason, test_paths[i]);
587
- if (mik__json_escape(s_sup_esc, sizeof(s_sup_esc), test_paths[i]) < 0) {
602
+ if (mik__json_escape(sup->esc, sizeof(sup->esc), test_paths[i]) < 0) {
588
603
  /* Path too long to fit even escaped — fall back to
589
604
  * basename so the frame at least identifies something. */
590
605
  const char* base = strrchr(test_paths[i], '/');
591
606
  if (!base ||
592
- mik__json_escape(s_sup_esc, sizeof(s_sup_esc), base + 1) < 0) {
593
- s_sup_esc[0] = '?';
594
- s_sup_esc[1] = '\0';
607
+ mik__json_escape(sup->esc, sizeof(sup->esc), base + 1) < 0) {
608
+ sup->esc[0] = '?';
609
+ sup->esc[1] = '\0';
595
610
  }
596
611
  }
597
612
  /* Append the captured exception text (escaped) so the CLI
598
613
  * shows the actual error, not just "Evaluation threw". */
599
- s_sup_err_esc[0] = '\0';
600
- if (rc == -EFAULT && s_sup_err[0] != '\0') {
601
- if (mik__json_escape(s_sup_err_esc, sizeof(s_sup_err_esc), s_sup_err) < 0) {
602
- s_sup_err_esc[0] = '\0';
614
+ sup->err_esc[0] = '\0';
615
+ if (rc == -EFAULT && sup->err[0] != '\0') {
616
+ if (mik__json_escape(sup->err_esc, sizeof(sup->err_esc), sup->err) < 0) {
617
+ sup->err_esc[0] = '\0';
603
618
  }
604
619
  }
605
620
  int n;
606
- if (s_sup_err_esc[0] != '\0') {
607
- n = snprintf(s_sup_buf, sizeof(s_sup_buf),
621
+ if (sup->err_esc[0] != '\0') {
622
+ n = snprintf(sup->buf, sizeof(sup->buf),
608
623
  "{\"e\":3,\"s\":\"<load>\",\"t\":\"%s\",\"d\":0,"
609
624
  "\"m\":\"%s: %s\"}",
610
- s_sup_esc, fail_reason, s_sup_err_esc);
625
+ sup->esc, fail_reason, sup->err_esc);
611
626
  } else {
612
- n = snprintf(s_sup_buf, sizeof(s_sup_buf),
627
+ n = snprintf(sup->buf, sizeof(sup->buf),
613
628
  "{\"e\":3,\"s\":\"<load>\",\"t\":\"%s\",\"d\":0,"
614
629
  "\"m\":\"%s\"}",
615
- s_sup_esc, fail_reason);
630
+ sup->esc, fail_reason);
616
631
  }
617
- if (n > 0 && n < (int)sizeof(s_sup_buf)) {
618
- mik__proto_send(&transport, MIK_MSG_TEST, s_sup_buf, n);
632
+ if (n > 0 && n < (int)sizeof(sup->buf)) {
633
+ mik__proto_send(&transport, MIK_MSG_TEST, sup->buf, n);
619
634
  }
620
635
  static const char kRunDone[] =
621
636
  "{\"e\":6,\"p\":0,\"f\":1,\"k\":0,\"o\":0,\"d\":0}";
@@ -624,6 +639,7 @@ void MIK_Main(void) {
624
639
  MIK_ProtocolDetach();
625
640
  MIK_FreeRuntime(rt);
626
641
  }
642
+ free(sup);
627
643
 
628
644
  /* Signal end-of-manifest so the CLI can finalize its report
629
645
  * without waiting on a silent stream. */
@@ -69,6 +69,11 @@ struct MIKOtaClientState {
69
69
  JSValue on_config = JS_UNDEFINED;
70
70
  std::vector<std::unique_ptr<PendingCheck>> pending;
71
71
  bool watching = false;
72
+ /* The reconcile diagnostic, held for report() the way the built-in client
73
+ * holds it for its own rounds: env->reconcile is read-once, so this is the
74
+ * only copy left after ota.reconcile() ran. settle() marks it delivered. */
75
+ bool has_last_install = false;
76
+ MIKOtaDiagnostic last_install = {};
72
77
  };
73
78
 
74
79
  int mik__ota_client_slot = -1;
@@ -296,7 +301,17 @@ JSValue mik__ota_client_watch(JSContext* ctx, JSValue this_val, int argc, JSValu
296
301
  }
297
302
  }
298
303
 
299
- state->client->Watch(options);
304
+ if (!state->client->Watch(options)) {
305
+ /* Enrollment is written over the cable, so nothing this boot does can
306
+ * make a later round succeed. Drop the hooks installed above and hand
307
+ * the app a typed error instead of an inert watcher. */
308
+ state->hooks.reset();
309
+ JS_FreeValue(ctx, state->on_config);
310
+ state->on_config = JS_UNDEFINED;
311
+ return mik__result_err_named(
312
+ ctx, "NotEnrolled",
313
+ "device not enrolled; run `mikro ota enroll` to enable OTA updates");
314
+ }
300
315
  state->watching = true;
301
316
 
302
317
  JSValue handle = JS_NewObject(ctx);
@@ -305,7 +320,7 @@ JSValue mik__ota_client_watch(JSContext* ctx, JSValue this_val, int argc, JSValu
305
320
  JS_SetPropertyStr(
306
321
  ctx, handle, "setCheckinInterval",
307
322
  JS_NewCFunction(ctx, mik__ota_client_set_interval, "setCheckinInterval", 1));
308
- return handle;
323
+ return mik__result_ok(ctx, handle);
309
324
  }
310
325
 
311
326
  // ── the policy surface (mikro/ota) ──────────────────────────────────────────
@@ -345,6 +360,10 @@ JSValue ota_reconcile(JSContext* ctx, JSValue, int, JSValue*) {
345
360
  MIKOtaClientState* state = state_of(ctx);
346
361
  CHECK_NOT_NULL(state);
347
362
  MIKOtaReconcileOutcome outcome = mikrojs::mik__ota_policy_reconcile(state->env);
363
+ if (outcome.has_diagnostic) {
364
+ state->has_last_install = true;
365
+ state->last_install = outcome.diagnostic;
366
+ }
348
367
 
349
368
  JSValue obj = JS_NewObject(ctx);
350
369
  if (outcome.installed[0]) {
@@ -776,6 +795,20 @@ JSValue ota_apply_config(JSContext* ctx, JSValue, int argc, JSValue* argv) {
776
795
  return JS_NewString(ctx, mikrojs::mik__ota_config_write_to_str(write));
777
796
  }
778
797
 
798
+ /* Loads the rolled-back report and sets the echo rev on `obj` under `rev_key`.
799
+ * After a rollback the FAILED document's rev is the one to echo, not the
800
+ * restored one's: echoed-equals-held is what stops the registry serving the
801
+ * document that just failed, until an operator changes it. Returns whether a
802
+ * report stands, leaving it in *report for the caller to marshal. */
803
+ bool set_config_echo(JSContext* ctx, JSValue obj, const char* rev_key, const MIKOtaEnv* env,
804
+ MIKOtaConfigErrorReport* report) {
805
+ bool has_error = mikrojs::mik__ota_load_config_error(env, report);
806
+ mikrojs::MIKOtaLoadedConfig held = mikrojs::mik__ota_load_slot(env, MIK_OTA_CFG_CURRENT);
807
+ const char* rev = has_error ? report->rev : (held.present ? held.cfg.rev : "");
808
+ if (rev[0]) JS_SetPropertyStr(ctx, obj, rev_key, JS_NewString(ctx, rev));
809
+ return has_error;
810
+ }
811
+
779
812
  /* configState() -> {rev?, error?} */
780
813
  JSValue ota_config_state(JSContext* ctx, JSValue, int, JSValue*) {
781
814
  MIKOtaClientState* state = state_of(ctx);
@@ -783,22 +816,161 @@ JSValue ota_config_state(JSContext* ctx, JSValue, int, JSValue*) {
783
816
 
784
817
  JSValue obj = JS_NewObject(ctx);
785
818
  MIKOtaConfigErrorReport report = {};
786
- bool has_error = mikrojs::mik__ota_load_config_error(state->env, &report);
787
- if (has_error) {
819
+ if (set_config_echo(ctx, obj, "rev", state->env, &report)) {
788
820
  JSValue error = JS_NewObject(ctx);
789
821
  JS_SetPropertyStr(ctx, error, "rev", JS_NewString(ctx, report.rev));
790
822
  JS_SetPropertyStr(ctx, error, "message", JS_NewString(ctx, report.message));
791
823
  JS_SetPropertyStr(ctx, obj, "error", error);
792
824
  }
793
- /* After a rollback the FAILED document's rev is the one to echo, not the
794
- * restored one's: echoed-equals-held is what stops the registry serving the
795
- * document that just failed, until an operator changes it. */
796
- mikrojs::MIKOtaLoadedConfig held = mikrojs::mik__ota_load_slot(state->env, MIK_OTA_CFG_CURRENT);
797
- const char* rev = has_error ? report.rev : (held.present ? held.cfg.rev : "");
798
- if (rev[0]) JS_SetPropertyStr(ctx, obj, "rev", JS_NewString(ctx, rev));
799
825
  return obj;
800
826
  }
801
827
 
828
+ // ── the check-in exchange, for a client with its own transport ──────────────
829
+ //
830
+ // report() and settle() are the two halves the built-in client runs around its
831
+ // HTTP call: the same facts gathering as BeginCheckIn (mik_ota_client.cpp) and
832
+ // the same completed-round handling as its response parse. Everything either
833
+ // touches is an existing policy call, so a client that goes through here and
834
+ // the built-in cannot disagree about what a round sends or settles.
835
+
836
+ /* report() -> the check-in body the device owes its registry, wire field
837
+ * shapes throughout so a proxy can forward fields verbatim. */
838
+ JSValue ota_report(JSContext* ctx, JSValue, int, JSValue*) {
839
+ MIKOtaClientState* state = state_of(ctx);
840
+ CHECK_NOT_NULL(state);
841
+ const MIKOtaEnv* env = state->env;
842
+
843
+ JSValue obj = JS_NewObject(ctx);
844
+
845
+ MIKDeviceIdentity identity = {};
846
+ if (env && env->identity) env->identity(env->opaque, &identity);
847
+ JS_SetPropertyStr(ctx, obj, "deviceId", JS_NewString(ctx, identity.device_id));
848
+ JS_SetPropertyStr(ctx, obj, "firmware", JS_NewString(ctx, identity.firmware_version));
849
+ JS_SetPropertyStr(ctx, obj, "firmwareHash", JS_NewString(ctx, identity.firmware_hash));
850
+ JS_SetPropertyStr(ctx, obj, "bytecode", JS_NewInt32(ctx, identity.bytecode_version));
851
+
852
+ JS_SetPropertyStr(ctx, obj, "running", ota_running(ctx, JS_UNDEFINED, 0, nullptr));
853
+
854
+ /* The name pair, sent every round so a lost response settles on the next
855
+ * check-in: [rev, name], or [rev] when never named or cleared. */
856
+ int name_rev = 0;
857
+ char name[64] = {};
858
+ bool has_name = env && env->get_device_name &&
859
+ env->get_device_name(env->opaque, &name_rev, name, sizeof(name));
860
+ JSValue pair = JS_NewArray(ctx);
861
+ JS_SetPropertyUint32(ctx, pair, 0, JS_NewInt32(ctx, name_rev));
862
+ if (has_name && name[0]) JS_SetPropertyUint32(ctx, pair, 1, JS_NewString(ctx, name));
863
+ JS_SetPropertyStr(ctx, obj, "name", pair);
864
+
865
+ size_t free_bytes = 0;
866
+ if (env && env->storage_free && env->storage_free(env->opaque, &free_bytes)) {
867
+ JS_SetPropertyStr(ctx, obj, "free", JS_NewInt64(ctx, (int64_t)free_bytes));
868
+ }
869
+
870
+ if (state->has_last_install) {
871
+ JSValue diag = JS_NewObject(ctx);
872
+ JS_SetPropertyStr(ctx, diag, "reason", JS_NewString(ctx, state->last_install.reason));
873
+ if (state->last_install.detail[0]) {
874
+ JS_SetPropertyStr(ctx, diag, "detail", JS_NewString(ctx, state->last_install.detail));
875
+ }
876
+ JS_SetPropertyStr(ctx, obj, "lastInstall", diag);
877
+ }
878
+
879
+ /* configRev / configError, through the same resolution as configState(). */
880
+ MIKOtaConfigErrorReport report = {};
881
+ if (set_config_echo(ctx, obj, "configRev", env, &report)) {
882
+ JSValue error = JS_NewObject(ctx);
883
+ JS_SetPropertyStr(ctx, error, "rev", JS_NewString(ctx, report.rev));
884
+ JS_SetPropertyStr(ctx, error, "message", JS_NewString(ctx, report.message));
885
+ JS_SetPropertyStr(ctx, obj, "configError", error);
886
+ }
887
+ return obj;
888
+ }
889
+
890
+ /* settle(raw, {trialBoots, allowInsecure}) -> {offer?, config?, renamed}
891
+ *
892
+ * Only for a COMPLETED check-in: the confirm below is the whole health signal
893
+ * requireConfirm waits for, so settling a failed round would keep exactly the
894
+ * build (or document) that rollback exists to catch. */
895
+ JSValue ota_settle(JSContext* ctx, JSValue, int argc, JSValue* argv) {
896
+ MIKOtaClientState* state = state_of(ctx);
897
+ CHECK_NOT_NULL(state);
898
+
899
+ JSValue raw = argc > 0 ? argv[0] : JS_UNDEFINED;
900
+ JSValue out = JS_NewObject(ctx);
901
+
902
+ /* Mirror the built-in's response guard (its DecodeFailed path): null or
903
+ * undefined is the registry's quiet round, and an object is a decoded
904
+ * response — but anything else is a body that never decoded (a captive
905
+ * portal's HTML, a proxy's error page). That is not a completed round, so
906
+ * nothing settles: no confirm, and the cached install report stays for
907
+ * the round that does complete. */
908
+ bool quiet = JS_IsUndefined(raw) || JS_IsNull(raw);
909
+ bool usable = JS_IsObject(raw) && !JS_IsArray(raw) && !JS_IsFunction(ctx, raw);
910
+ if (!quiet && !usable) {
911
+ JS_SetPropertyStr(ctx, out, "renamed", JS_FALSE);
912
+ return out;
913
+ }
914
+
915
+ /* Confirm before the deliveries, as the built-in does: it must settle the
916
+ * document held BEFORE this round, never the one about to be armed. */
917
+ mikrojs::mik__ota_policy_confirm(state->env);
918
+ /* The round completed, so the cached install report was delivered. */
919
+ state->has_last_install = false;
920
+
921
+ bool renamed = false;
922
+ if (usable) {
923
+ /* The name pair: [rev] or [rev, name]. No `name` key means "no change"
924
+ * and never "clear it"; junk in the pair is treated the same way. The
925
+ * adopt is unconditional — the registry only sends the key when its
926
+ * rev should win. */
927
+ JSValue pair = JS_GetPropertyStr(ctx, raw, "name");
928
+ if (JS_IsArray(pair)) {
929
+ JSValue rev_val = JS_GetPropertyUint32(ctx, pair, 0);
930
+ /* A rev is a non-negative int32, as the wire carries it; a
931
+ * fractional or oversized number is junk, not a truncation. */
932
+ double rev_num = -1;
933
+ bool rev_ok = JS_IsNumber(rev_val) && JS_ToFloat64(ctx, &rev_num, rev_val) == 0 &&
934
+ rev_num >= 0 && rev_num <= 2147483647.0 &&
935
+ rev_num == (double)(int32_t)rev_num;
936
+ int32_t rev = rev_ok ? (int32_t)rev_num : -1;
937
+ JS_FreeValue(ctx, rev_val);
938
+ if (rev_ok && state->env && state->env->set_device_name) {
939
+ JSValue name_val = JS_GetPropertyUint32(ctx, pair, 1);
940
+ const char* name = JS_IsString(name_val) ? JS_ToCString(ctx, name_val) : nullptr;
941
+ state->env->set_device_name(state->env->opaque, rev,
942
+ name && name[0] ? name : nullptr);
943
+ renamed = true;
944
+ if (name) JS_FreeCString(ctx, name);
945
+ JS_FreeValue(ctx, name_val);
946
+ }
947
+ }
948
+ JS_FreeValue(ctx, pair);
949
+
950
+ /* The config document goes through the applyConfig path unchanged, so
951
+ * the two entrances cannot diverge on validation or placement. */
952
+ JSValue cfg_raw = JS_GetPropertyStr(ctx, raw, "config");
953
+ if (!JS_IsUndefined(cfg_raw) && !JS_IsNull(cfg_raw)) {
954
+ JSValue args[2] = {cfg_raw, argc > 1 ? argv[1] : JS_UNDEFINED};
955
+ JS_SetPropertyStr(ctx, out, "config", ota_apply_config(ctx, JS_UNDEFINED, 2, args));
956
+ }
957
+ JS_FreeValue(ctx, cfg_raw);
958
+
959
+ /* The offer fields are top-level in the response, so the whole body
960
+ * goes to the parser, exactly as ota.parseOffer(body) would. */
961
+ bool allow_insecure = false;
962
+ if (argc > 1 && JS_IsObject(argv[1])) {
963
+ allow_insecure = opt_bool(ctx, argv[1], "allowInsecure", false);
964
+ }
965
+ MIKOtaOffer offer;
966
+ if (mikrojs::mik__ota_parse_offer_js(ctx, raw, allow_insecure, &offer)) {
967
+ JS_SetPropertyStr(ctx, out, "offer", offer_to_js(ctx, offer));
968
+ }
969
+ }
970
+ JS_SetPropertyStr(ctx, out, "renamed", JS_NewBool(ctx, renamed));
971
+ return out;
972
+ }
973
+
802
974
  int mik__ota_client_module_init(JSContext* ctx, JSModuleDef* m) {
803
975
  JS_SetModuleExport(ctx, m, "check", JS_NewCFunction(ctx, mik__ota_client_check, "check", 1));
804
976
  JS_SetModuleExport(ctx, m, "watch", JS_NewCFunction(ctx, mik__ota_client_watch, "watch", 1));
@@ -819,6 +991,8 @@ int mik__ota_client_module_init(JSContext* ctx, JSModuleDef* m) {
819
991
  JS_NewCFunction(ctx, ota_apply_config, "applyConfig", 2));
820
992
  JS_SetModuleExport(ctx, m, "configState",
821
993
  JS_NewCFunction(ctx, ota_config_state, "configState", 0));
994
+ JS_SetModuleExport(ctx, m, "report", JS_NewCFunction(ctx, ota_report, "report", 0));
995
+ JS_SetModuleExport(ctx, m, "settle", JS_NewCFunction(ctx, ota_settle, "settle", 2));
822
996
  return 0;
823
997
  }
824
998
 
@@ -869,6 +1043,8 @@ JSModuleDef* mik__ota_client_init(JSContext* ctx) {
869
1043
  JS_AddModuleExport(ctx, m, "parseConfig");
870
1044
  JS_AddModuleExport(ctx, m, "applyConfig");
871
1045
  JS_AddModuleExport(ctx, m, "configState");
1046
+ JS_AddModuleExport(ctx, m, "report");
1047
+ JS_AddModuleExport(ctx, m, "settle");
872
1048
  return m;
873
1049
  }
874
1050
 
@@ -112,6 +112,10 @@ static void* esp32_realloc_psram(void* ptr, size_t size) {
112
112
  #endif
113
113
  }
114
114
 
115
+ static size_t esp32_malloc_usable_size(const void* ptr) {
116
+ return heap_caps_get_allocated_size(const_cast<void*>(ptr));
117
+ }
118
+
115
119
  static bool esp32_get_fs_info(const char* label, size_t* total, size_t* used) {
116
120
  #if HAS_LITTLEFS
117
121
  return esp_littlefs_info(label, total, used) == ESP_OK;
@@ -326,6 +330,7 @@ static const MIKPlatform esp32_platform = {
326
330
  .malloc_psram = esp32_malloc_psram,
327
331
  .calloc_psram = esp32_calloc_psram,
328
332
  .realloc_psram = esp32_realloc_psram,
333
+ .malloc_usable_size = esp32_malloc_usable_size,
329
334
  .get_fs_info = esp32_get_fs_info,
330
335
  .log = esp32_log,
331
336
  .stdout_write = esp32_stdout_write,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikrojs/firmware",
3
- "version": "0.18.2",
3
+ "version": "0.18.3-next.20260829153835",
4
4
  "description": "Mikro.js ESP32 firmware: ESP-IDF component, build tools, and project template",
5
5
  "keywords": [
6
6
  "esp-idf",
@@ -47,8 +47,8 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "esbuild": "^0.28.0",
50
- "@mikrojs/native": "0.18.2",
51
- "@mikrojs/quickjs": "0.18.2"
50
+ "@mikrojs/quickjs": "0.18.3-next.20260829153835+bf6b328",
51
+ "@mikrojs/native": "0.18.3-next.20260829153835+bf6b328"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=24.0.0"
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -32,6 +32,16 @@ CONFIG_HEAP_PLACE_FUNCTION_INTO_FLASH=y
32
32
  CONFIG_ESP_WIFI_IRAM_OPT=n
33
33
  CONFIG_ESP_WIFI_RX_IRAM_OPT=n
34
34
 
35
+ # Run the WiFi modem-sleep RX path from flash instead of IRAM (~13KB on C6:
36
+ # the .wifislprxiram/.wifislpiram sections). Same trade as the flags above,
37
+ # but for the sleep wake path: slightly slower beacon RX while in modem
38
+ # sleep with STA power save enabled.
39
+ CONFIG_ESP_WIFI_SLP_IRAM_OPT=n
40
+
41
+ # Drop the "extra" WiFi IRAM optimization (~7KB on C6: .wifiextrairam).
42
+ # Another member of the same IRAM-for-throughput family.
43
+ CONFIG_ESP_WIFI_EXTRA_IRAM_OPT=n
44
+
35
45
  # Trim WiFi driver buffers toward Espressif's low-RAM rank. The IDF defaults
36
46
  # left too little heap for a TLS handshake next to a JS runtime on no-PSRAM
37
47
  # chips. STATIC_RX is a real ~1.6 KB/buffer allocation made at wifi init; the
@@ -109,6 +119,10 @@ CONFIG_VFS_SUPPORT_SELECT=n
109
119
 
110
120
  # --- Assertions & error strings ---
111
121
  CONFIG_COMPILER_OPTIMIZATION_CHECKS_SILENT=y
122
+ # Silent assert(): still aborts (with the PC address), but drops the
123
+ # expression/file/line strings. The visible RAM win is spi_flash's
124
+ # cache-off code, whose rodata must live in DRAM: ~3.3KB on C6.
125
+ CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_SILENT=y
112
126
  # Keep esp_err_to_name() readable. Costs ~2 KB flash; without this, runtime
113
127
  # errors surface as "UNKNOWN ERROR" instead of e.g. ESP_ERR_NO_MEM, which
114
128
  # makes diagnosing on-device failures unnecessarily painful.