@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.
- package/components/mikrojs/mik_logfile.cpp +64 -37
- package/components/mikrojs/mik_main.cpp +49 -33
- package/components/mikrojs/mik_ota_client_module.cpp +186 -10
- package/components/mikrojs/platform_esp32.cpp +5 -0
- package/package.json +3 -3
- package/prebuilds/esp32/bootloader/bootloader.bin +0 -0
- package/prebuilds/esp32/mikrojs.bin +0 -0
- package/prebuilds/esp32c3/bootloader/bootloader.bin +0 -0
- package/prebuilds/esp32c3/mikrojs.bin +0 -0
- package/prebuilds/esp32c5/bootloader/bootloader.bin +0 -0
- package/prebuilds/esp32c5/mikrojs.bin +0 -0
- package/prebuilds/esp32c6/bootloader/bootloader.bin +0 -0
- package/prebuilds/esp32c6/mikrojs.bin +0 -0
- package/prebuilds/esp32s3/bootloader/bootloader.bin +0 -0
- package/prebuilds/esp32s3/mikrojs.bin +0 -0
- package/sdkconfig.defaults +14 -0
|
@@ -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
|
-
/*
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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(
|
|
71
|
-
rename(
|
|
72
|
-
s_file = fopen(
|
|
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,
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
103
|
-
if (n < 0 || (size_t)n >= sizeof(
|
|
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(
|
|
108
|
-
|
|
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(
|
|
111
|
-
if (s_line_pos == 0 ||
|
|
112
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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(
|
|
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)
|
|
206
|
+
if (!s_mtx) {
|
|
207
|
+
free(s_bufs);
|
|
208
|
+
s_bufs = nullptr;
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
186
211
|
|
|
187
|
-
s_file = fopen(
|
|
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,
|
|
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(
|
|
251
|
+
s_file = fopen(s_bufs->path_main, "a");
|
|
225
252
|
if (s_file) {
|
|
226
|
-
setvbuf(s_file,
|
|
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(
|
|
243
|
-
unlink(
|
|
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(
|
|
272
|
+
s_file = fopen(s_bufs->path_main, "a");
|
|
246
273
|
if (s_file) {
|
|
247
|
-
setvbuf(s_file,
|
|
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
|
-
|
|
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 (
|
|
275
|
-
|
|
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(
|
|
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
|
-
/*
|
|
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
|
|
108
|
-
*
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
552
|
-
*
|
|
553
|
-
*
|
|
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(
|
|
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(
|
|
559
|
-
mik__proto_send(&transport, MIK_MSG_DEBUG,
|
|
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],
|
|
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(
|
|
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(
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
600
|
-
if (rc == -EFAULT &&
|
|
601
|
-
if (mik__json_escape(
|
|
602
|
-
|
|
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 (
|
|
607
|
-
n = snprintf(
|
|
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
|
-
|
|
625
|
+
sup->esc, fail_reason, sup->err_esc);
|
|
611
626
|
} else {
|
|
612
|
-
n = snprintf(
|
|
627
|
+
n = snprintf(sup->buf, sizeof(sup->buf),
|
|
613
628
|
"{\"e\":3,\"s\":\"<load>\",\"t\":\"%s\",\"d\":0,"
|
|
614
629
|
"\"m\":\"%s\"}",
|
|
615
|
-
|
|
630
|
+
sup->esc, fail_reason);
|
|
616
631
|
}
|
|
617
|
-
if (n > 0 && n < (int)sizeof(
|
|
618
|
-
mik__proto_send(&transport, MIK_MSG_TEST,
|
|
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
|
-
|
|
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.
|
|
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/
|
|
51
|
-
"@mikrojs/
|
|
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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/sdkconfig.defaults
CHANGED
|
@@ -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.
|