@mikrojs/firmware 0.17.0-pr-276.20260721233726 → 0.17.0

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.
@@ -134,6 +134,26 @@ target_compile_definitions(${COMPONENT_LIB} PRIVATE "MIK_PACKAGE_NAME=\"mikro\""
134
134
  set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
135
135
  "${CMAKE_CURRENT_LIST_DIR}/../../package.json")
136
136
 
137
+ # Firmware identity: the consuming project's package.json name, reported in
138
+ # MSG_READY as `fw`. The CLI only auto-flashes its bundled prebuilt over a
139
+ # device whose identity matches the prebuilt's own recorded name, so custom
140
+ # firmware is protected from the auto-reflash without any project marker.
141
+ # Absent (no package.json, e.g. on-device test apps) the field is omitted.
142
+ execute_process(
143
+ COMMAND node ${_MIK_RESOLVE} projectName ${CMAKE_SOURCE_DIR}
144
+ OUTPUT_VARIABLE _MIK_FW_NAME
145
+ OUTPUT_STRIP_TRAILING_WHITESPACE
146
+ )
147
+ # STREQUAL, not if(_MIK_FW_NAME): a project named "0", "off", or "false"
148
+ # would read as a false constant and silently lose its identity.
149
+ if(NOT _MIK_FW_NAME STREQUAL "")
150
+ target_compile_definitions(${COMPONENT_LIB} PRIVATE "MIK_FW_NAME=\"${_MIK_FW_NAME}\"")
151
+ endif()
152
+ if(EXISTS "${CMAKE_SOURCE_DIR}/package.json")
153
+ set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
154
+ "${CMAKE_SOURCE_DIR}/package.json")
155
+ endif()
156
+
137
157
  # UTC build timestamp. `string(TIMESTAMP ... UTC)` honors SOURCE_DATE_EPOCH
138
158
  # when set. Refreshes on re-configure only (idf.py triggers this on each
139
159
  # build via its own reconfigure step, so in practice it's close to
@@ -151,7 +171,7 @@ include("${MIK_BYTECODE_CMAKE}")
151
171
 
152
172
  # Force linker to include self-registering native modules
153
173
  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)
174
+ 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 ota/client pin pwm reader sleep spi sntp stdio stream sys test uart udp wifi)
155
175
  if(CONFIG_BT_ENABLED)
156
176
  list(APPEND _MIK_MODULES ble)
157
177
  list(APPEND _MIK_BYTECODE_MODULES ble)
@@ -25,13 +25,28 @@ void MIK_DeployRecover(void);
25
25
  * (may esp_restart). Call right after MIK_DeployRecover(). */
26
26
  void mik__ota_boot_reconcile(void);
27
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
28
+ /* Stage a streamed .tgz for install at the next boot with adopt semantics:
29
+ * no trial, no rollback baseline, one install attempt. Backs
30
+ * MIK_CMD_DEPLOY_BUILD. Verifies the build against `checksum` (lowercase hex,
31
+ * may be empty to skip) so a corrupt upload still fails synchronously over
32
+ * serial, then arms the boot reconcile's adopt install. Returns false with
33
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);
34
+ bool mik__ota_stage_adopt(const char* tgz_path, const char* checksum, const char** err);
35
+
36
+ /* Outcome of the last adopt-mode boot install, for MIK_CMD_DEPLOY_RESULT.
37
+ * Kept separate from the reconcile record on purpose: that record reaches the
38
+ * registry as `lastInstall` and would blacklist a checksum a developer is
39
+ * about to legitimately re-push over the cable. */
40
+ typedef struct MIKDeployResult {
41
+ uint8_t status; /* 0 none, 1 ok, 2 fail */
42
+ char checksum[96];
43
+ char reason[32];
44
+ char detail[160];
45
+ } MIKDeployResult;
46
+
47
+ /* Read and clear the recorded adopt-install outcome (mik_ota.cpp). status 0
48
+ * with empty strings when nothing is recorded. */
49
+ void mik__ota_take_deploy_result(MIKDeployResult* out);
35
50
 
36
51
  /* True while the running build is an unconfirmed OTA trial (mik_ota.cpp). Lets
37
52
  * the boot path treat a JS-level fatal during a trial as a failed trial. */
@@ -20,7 +20,7 @@ static const char* DEPLOY_TMP = "/appfs/.deploy-tmp";
20
20
  static const char* APP_DIR = "/appfs/app";
21
21
  static const char* CHECKSUMS_PATH = "/appfs/app/.checksums";
22
22
  /* Where DEPLOY_PUT("/.build.tgz", ...) streams the OTA build (DEPLOY_TMP +
23
- * "/.build.tgz"). Adopted by MIK_CMD_DEPLOY_BUILD. */
23
+ * "/.build.tgz"). Staged for boot install by MIK_CMD_DEPLOY_BUILD. */
24
24
  static const char* BUILD_TGZ_PATH = "/appfs/.deploy-tmp/.build.tgz";
25
25
 
26
26
  /* ── Checksums manifest ─────────────────────────────────────────── */
@@ -550,9 +550,12 @@ bool mik__handle_deploy_command(MIKReplTransport* transport, uint8_t cmd_type,
550
550
  }
551
551
 
552
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.
553
+ /* Stage a streamed .tgz for install at the next boot. The install
554
+ * runs from the boot reconcile on a clean heap; installing here
555
+ * with the app live needs a 32 KB contiguous inflate window a
556
+ * fragmented heap cannot provide. The checksum is verified now so
557
+ * a corrupt upload still fails synchronously. The build was
558
+ * streamed via PUT("/.build.tgz") + chunks into BUILD_TGZ_PATH.
556
559
  * Payload: u16le checksum_len | checksum. */
557
560
  if (payload_len < 2) {
558
561
  mik__proto_drain(transport, payload_len);
@@ -576,14 +579,14 @@ bool mik__handle_deploy_command(MIKReplTransport* transport, uint8_t cmd_type,
576
579
  uint32_t consumed = 2 + (uint32_t)chk_len;
577
580
  if (payload_len > consumed) mik__proto_drain(transport, payload_len - consumed);
578
581
 
579
- /* Flush the staged .tgz to disk before adopt reads it back. */
582
+ /* Flush the staged .tgz to disk before staging verifies it. */
580
583
  put_close_and_clear();
581
584
 
582
585
  const char* err = nullptr;
583
- if (mik__ota_adopt_build(BUILD_TGZ_PATH, checksum, &err)) {
586
+ if (mik__ota_stage_adopt(BUILD_TGZ_PATH, checksum, &err)) {
584
587
  mik__proto_send_ok(transport);
585
588
  } else {
586
- mik__proto_send_err(transport, err ? err : "build install failed");
589
+ mik__proto_send_err(transport, err ? err : "build stage failed");
587
590
  }
588
591
  deploy_cleanup();
589
592
  return true;
@@ -198,12 +198,35 @@ static bool platform_command_handler(MIKReplTransport* transport, uint8_t cmd_ty
198
198
  return true;
199
199
  }
200
200
 
201
- /* Deploy commands (0x20-0x27) plus the build-install command (0x2D) */
201
+ /* Deploy commands (0x20-0x27) plus the build-stage command (0x2D) */
202
202
  if ((cmd_type >= MIK_CMD_DEPLOY_PUT && cmd_type <= MIK_CMD_DEPLOY_CHECKSUM) ||
203
203
  cmd_type == MIK_CMD_DEPLOY_BUILD) {
204
204
  return mik__handle_deploy_command(transport, cmd_type, payload_len);
205
205
  }
206
206
 
207
+ /* Install outcome (0x2E): report + clear the result of the last staged
208
+ * build install. Handled here, not in the deploy handler: it is a
209
+ * standalone read after the post-deploy reboot and must not start a
210
+ * deploy session (pause the runtime, clear the staging dir). */
211
+ if (cmd_type == MIK_CMD_DEPLOY_RESULT) {
212
+ mik__proto_drain(transport, payload_len);
213
+ MIKDeployResult res;
214
+ mik__ota_take_deploy_result(&res);
215
+ uint8_t buf[1 + 3 * 2 + sizeof(res.checksum) + sizeof(res.reason) + sizeof(res.detail)];
216
+ size_t n = 0;
217
+ buf[n++] = res.status;
218
+ const char* fields[] = {res.checksum, res.reason, res.detail};
219
+ for (const char* s : fields) {
220
+ size_t len = strlen(s);
221
+ buf[n++] = (uint8_t)(len & 0xFF);
222
+ buf[n++] = (uint8_t)((len >> 8) & 0xFF);
223
+ memcpy(buf + n, s, len);
224
+ n += len;
225
+ }
226
+ mik__proto_send(transport, MIK_MSG_OK, buf, n);
227
+ return true;
228
+ }
229
+
207
230
  /* File pull (0x2B) */
208
231
  if (cmd_type == MIK_CMD_FS_GET) {
209
232
  return mik__handle_fs_get(transport, payload_len);
@@ -696,6 +696,7 @@ constexpr const char* kKeyTrialLeft = "trialLeft"; // u8
696
696
  constexpr const char* kKeyTrialN = "trialN"; // u8: trial budget for a pending install
697
697
  constexpr const char* kKeyConfirm = "confirm"; // u8: trial requires markValid() to promote
698
698
  constexpr const char* kKeyPending = "pending"; // u8: 1 = .ota-pending.tgz awaits clean-heap install
699
+ constexpr const char* kKeyPendMode = "pendMode"; // u8: how to install the pending build (see below)
699
700
  constexpr const char* kKeyInstLeft = "instLeft"; // u8: install-attempt budget
700
701
  constexpr const char* kKeyPromLeft = "promLeft"; // u8: promote-attempt budget
701
702
  constexpr const char* kKeyRbLeft = "rbLeft"; // u8: rollback-attempt budget
@@ -712,9 +713,22 @@ constexpr const char* kKeyOInst = "oInst"; // str: checksum just installe
712
713
  constexpr const char* kKeyORevert = "oRevert"; // u8
713
714
  constexpr const char* kKeyORsn = "oRsn"; // str: diagnostic reason
714
715
  constexpr const char* kKeyODetail = "oDetail"; // str: diagnostic detail
716
+ // Adopt-install outcome, read once by MIK_CMD_DEPLOY_RESULT and then cleared.
717
+ // Separate from the reconcile record above: that one reaches the registry as
718
+ // `lastInstall` and would blacklist a checksum the developer is re-pushing.
719
+ constexpr const char* kKeyDRes = "dRes"; // u8: 1 ok, 2 fail
720
+ constexpr const char* kKeyDChk = "dChk"; // str: staged checksum
721
+ constexpr const char* kKeyDRsn = "dRsn"; // str: failure reason
722
+ constexpr const char* kKeyDDetail = "dDetail"; // str: failure detail
715
723
 
716
724
  constexpr uint8_t kStateGood = 0;
717
725
  constexpr uint8_t kStateTrial = 1;
726
+ // kKeyPendMode values. Trial is the OTA path (trialBoots, rollback, abandon
727
+ // policy); adopt is the cable-deploy path (straight to GOOD, no trial, no
728
+ // rollback baseline, one install attempt). Absent defaults to trial so a
729
+ // pending build staged by older firmware keeps its semantics.
730
+ constexpr uint8_t kPendModeTrial = 0;
731
+ constexpr uint8_t kPendModeAdopt = 1;
718
732
  constexpr uint8_t kInstallBudget = 3; // boot install attempts before abandoning a pending build
719
733
  // Boot attempts to make a survived trial the revert target before giving up on
720
734
  // the revert target rather than on the device: a trial that cannot promote and
@@ -867,37 +881,6 @@ bool nvs_str(nvs_handle_t h, const char* key, char* buf, size_t cap) {
867
881
  return buf[0] != 0;
868
882
  }
869
883
 
870
- // Copy src to dst (truncating dst). Used to move the staged build to the
871
- // rollback slot before install_build rmtrees the staging tree it lives under.
872
- bool copy_file(const char* src, const char* dst) {
873
- FILE* in = fopen(src, "rb");
874
- if (!in) return false;
875
- FILE* out = fopen(dst, "wb");
876
- if (!out) {
877
- fclose(in);
878
- return false;
879
- }
880
- unsigned char* buf = (unsigned char*)malloc(kChunk);
881
- if (!buf) {
882
- fclose(in);
883
- fclose(out);
884
- return false;
885
- }
886
- bool ok = true;
887
- size_t n;
888
- while ((n = fread(buf, 1, kChunk, in)) > 0) {
889
- if (fwrite(buf, 1, n, out) != n) {
890
- ok = false;
891
- break;
892
- }
893
- }
894
- if (ok && ferror(in)) ok = false;
895
- free(buf);
896
- if (fclose(out) != 0) ok = false;
897
- fclose(in);
898
- return ok;
899
- }
900
-
901
884
  // ── install (gunzip -> untar -> atomic swap) ─────────────────────────────────
902
885
  // Unpacks `tgz` into <base>/.deploy-tmp/app and promotes it to /app via the
903
886
  // shared app-store engine (crash-safe: an interrupted swap is rolled back by
@@ -1197,6 +1180,9 @@ JSValue js_stage_finish(JSContext* ctx, JSValueConst, int argc, JSValueConst* ar
1197
1180
  nvs_erase_key(h, kKeyStgChk);
1198
1181
  nvs_erase_key(h, kKeyStgSize);
1199
1182
  nvs_set_str(h, kKeyPendChk, want_chk);
1183
+ // Explicit even though trial is the default: a leftover adopt marker from
1184
+ // an earlier cable deploy must not turn this OTA build into an adopt.
1185
+ nvs_set_u8(h, kKeyPendMode, kPendModeTrial);
1200
1186
  nvs_set_u8(h, kKeyTrialN, (uint8_t)trial_boots);
1201
1187
  nvs_set_u8(h, kKeyConfirm, require_confirm ? 1 : 0);
1202
1188
 
@@ -1477,21 +1463,37 @@ bool attempt_rollback(nvs_handle_t h, const char* reason, const char* detail) {
1477
1463
  return true;
1478
1464
  }
1479
1465
 
1466
+ // Resolve a failed adopt install: drop the pending build, record the outcome
1467
+ // for MIK_CMD_DEPLOY_RESULT, and leave the previous /app running. No rollback
1468
+ // and no retry -- the developer is on the serial cable and re-pushes. Never
1469
+ // touches the reconcile record (oRsn/oDetail), which reaches the registry.
1470
+ void adopt_fail(nvs_handle_t h, const char* chk, const char* reason, const char* detail) {
1471
+ nvs_set_u8(h, kKeyPending, 0);
1472
+ nvs_erase_key(h, kKeyPendChk);
1473
+ nvs_erase_key(h, kKeyPendMode);
1474
+ nvs_erase_key(h, kKeyInstLeft);
1475
+ unlink(kPending);
1476
+ nvs_set_u8(h, kKeyDRes, 2);
1477
+ nvs_set_str(h, kKeyDChk, chk);
1478
+ nvs_set_str(h, kKeyDRsn, reason);
1479
+ nvs_set_str(h, kKeyDDetail, detail);
1480
+ nvs_commit(h);
1481
+ }
1482
+
1480
1483
  } // namespace
1481
1484
 
1482
- // Adopt a streamed .tgz as the live app with NO trial (the deploy / baseline
1483
- // path, distinct from an OTA download which runs a trial). Verifies the build
1484
- // against `checksum`, copies it to the rollback slot first so it survives the
1485
- // commit (install_build rmtrees the staging tree the source lives under),
1486
- // installs it, then records it as the running good build. NOT JS-exposed —
1487
- // called from the serial DEPLOY_BUILD handler.
1488
- bool mik__ota_adopt_build(const char* tgz_path, const char* checksum, const char** err) {
1485
+ // Stage a streamed .tgz for adopt install at the next boot (the cable-deploy
1486
+ // path, distinct from an OTA download which runs a trial). The install itself
1487
+ // runs from mik__ota_boot_reconcile() on a clean heap: doing it here, with the
1488
+ // app live, needs a 32 KB contiguous inflate window a fragmented heap cannot
1489
+ // provide. Verification stays synchronous so a corrupt upload still fails over
1490
+ // serial; empty checksum skips the check. NOT JS-exposed — called from the
1491
+ // serial DEPLOY_BUILD handler.
1492
+ bool mik__ota_stage_adopt(const char* tgz_path, const char* checksum, const char** err) {
1489
1493
  if (!path_exists(tgz_path)) {
1490
1494
  *err = "build not staged";
1491
1495
  return false;
1492
1496
  }
1493
- // Verify SHA-256 before install so a corrupt upload can't become the
1494
- // rollback baseline. Empty checksum skips the check.
1495
1497
  if (checksum && checksum[0]) {
1496
1498
  char got[65];
1497
1499
  if (!sha256_file(tgz_path, got)) {
@@ -1503,43 +1505,74 @@ bool mik__ota_adopt_build(const char* tgz_path, const char* checksum, const char
1503
1505
  return false;
1504
1506
  }
1505
1507
  }
1506
- // Install from a copy outside the staging tree: install_build commits by
1507
- // rmtree-ing that tree, and the staged .tgz lives under it. Promote the copy
1508
- // to the rollback slot only once the install succeeded -- overwriting
1509
- // kLastGood up front would, on a failed install, leave the revert target
1510
- // naming a build that is not installed and never was, so a later revert()
1511
- // would roll *forward* onto it while reporting the old checksum.
1512
- unlink(kLastGoodTmp);
1513
- if (!copy_file(tgz_path, kLastGoodTmp)) {
1514
- *err = "stage rollback baseline";
1508
+ nvs_handle_t h;
1509
+ if (nvs_open(kNs, NVS_READWRITE, &h) != ESP_OK) {
1510
+ *err = "nvs open failed";
1515
1511
  return false;
1516
1512
  }
1517
- OtaErr kind = OtaErr::kTransient;
1518
- if (!install_build(kLastGoodTmp, err, &kind)) {
1519
- unlink(kLastGoodTmp);
1513
+ // Move the .tgz out of the deploy tmp tree before acking: MIK_DeployRecover
1514
+ // cleans that tree at boot, before the reconcile would read it. Rename
1515
+ // before anything else mutates: a failed rename (e.g. littlefs metadata
1516
+ // ENOSPC) must error back over serial having left trial state untouched.
1517
+ unlink(kPending);
1518
+ if (rename(tgz_path, kPending) != 0) {
1519
+ nvs_close(h);
1520
+ *err = "stage pending build";
1520
1521
  return false;
1521
1522
  }
1522
- unlink(kLastGood);
1523
- if (rename(kLastGoodTmp, kLastGood) != 0) {
1524
- // Installed and live, but with no revert target until the next adopt or
1525
- // promote. Not worth failing the deploy over.
1526
- unlink(kLastGoodTmp);
1527
- }
1528
- nvs_handle_t h;
1529
- if (nvs_open(kNs, NVS_READWRITE, &h) == ESP_OK) {
1530
- nvs_set_str(h, kKeyInstChk, checksum ? checksum : "");
1523
+ // A cable deploy supersedes an in-flight trial. Resolve it to GOOD now,
1524
+ // while the trial's checksum is still in pendChk (overwritten below): the
1525
+ // trial build is what /app holds, so it becomes the installed build, the
1526
+ // same accounting the reflash guard does. Without this, the next boot's
1527
+ // trial verdict would roll back or promote against the pending slot this
1528
+ // deploy just replaced.
1529
+ if (nvs_u8(h, kKeyState, kStateGood) == kStateTrial) {
1530
+ char trial_chk[96];
1531
+ if (nvs_str(h, kKeyPendChk, trial_chk, sizeof(trial_chk))) {
1532
+ nvs_set_str(h, kKeyInstChk, trial_chk);
1533
+ }
1531
1534
  nvs_set_u8(h, kKeyState, kStateGood);
1532
1535
  nvs_set_u8(h, kKeyTrialLeft, 0);
1533
- nvs_set_u8(h, kKeyPending, 0);
1536
+ nvs_erase_key(h, kKeyPromLeft);
1537
+ nvs_erase_key(h, kKeyRbLeft);
1538
+ nvs_erase_key(h, kKeyNeutLeft);
1534
1539
  clear_trial_failure(h);
1535
- unlink(kPending);
1536
- nvs_erase_key(h, kKeyPendChk);
1537
- nvs_commit(h);
1538
- nvs_close(h);
1539
1540
  }
1541
+ nvs_set_str(h, kKeyPendChk, checksum ? checksum : "");
1542
+ nvs_set_u8(h, kKeyPendMode, kPendModeAdopt);
1543
+ nvs_set_u8(h, kKeyPending, 1);
1544
+ nvs_set_u8(h, kKeyInstLeft, 1); // one attempt: the developer is on the cable
1545
+ // Drop any unread outcome from an earlier adopt (e.g. a --no-restart deploy
1546
+ // that installed at a natural boot), so the post-restart result read can
1547
+ // only ever see this deploy's outcome.
1548
+ nvs_erase_key(h, kKeyDRes);
1549
+ nvs_erase_key(h, kKeyDChk);
1550
+ nvs_erase_key(h, kKeyDRsn);
1551
+ nvs_erase_key(h, kKeyDDetail);
1552
+ nvs_commit(h);
1553
+ nvs_close(h);
1540
1554
  return true;
1541
1555
  }
1542
1556
 
1557
+ // Read and clear the adopt-install outcome for MIK_CMD_DEPLOY_RESULT.
1558
+ void mik__ota_take_deploy_result(MIKDeployResult* out) {
1559
+ memset(out, 0, sizeof(*out));
1560
+ nvs_handle_t h;
1561
+ if (nvs_open(kNs, NVS_READWRITE, &h) != ESP_OK) return;
1562
+ out->status = nvs_u8(h, kKeyDRes, 0);
1563
+ nvs_str(h, kKeyDChk, out->checksum, sizeof(out->checksum));
1564
+ nvs_str(h, kKeyDRsn, out->reason, sizeof(out->reason));
1565
+ nvs_str(h, kKeyDDetail, out->detail, sizeof(out->detail));
1566
+ if (out->status != 0) {
1567
+ nvs_erase_key(h, kKeyDRes);
1568
+ nvs_erase_key(h, kKeyDChk);
1569
+ nvs_erase_key(h, kKeyDRsn);
1570
+ nvs_erase_key(h, kKeyDDetail);
1571
+ nvs_commit(h);
1572
+ }
1573
+ nvs_close(h);
1574
+ }
1575
+
1543
1576
  // True while an unconfirmed OTA trial is the running build.
1544
1577
  bool mik__ota_in_trial(void) {
1545
1578
  nvs_handle_t h;
@@ -1564,15 +1597,16 @@ void mik__ota_note_trial_failure(const char* detail) {
1564
1597
  }
1565
1598
 
1566
1599
  // Boot reconcile: NOT JS-exposed. Called from MIK_Main() before the JS app
1567
- // loads, after MIK_DeployRecover(). Runs the reflash guard, the trial verdict,
1568
- // and a deferred install in that order.
1600
+ // loads, after MIK_DeployRecover(). Runs the reflash guard, an adopt install
1601
+ // (cable deploy), the trial verdict, and a deferred trial install in that
1602
+ // order.
1569
1603
  void mik__ota_boot_reconcile(void) {
1570
1604
  nvs_handle_t h;
1571
1605
  if (nvs_open(kNs, NVS_READWRITE, &h) != ESP_OK) return;
1572
1606
 
1573
- // An adopt that died between copying its install source and promoting it to
1574
- // the rollback slot leaves this behind. It is never valid across a boot, and
1575
- // it is a whole build's worth of flash.
1607
+ // Legacy leftover: firmware that installed cable deploys synchronously
1608
+ // staged its install source here and could die before promoting it. Never
1609
+ // valid across a boot, and a whole build's worth of flash.
1576
1610
  unlink(kLastGoodTmp);
1577
1611
 
1578
1612
  // 1. Reflash guard. An out-of-band `idf.py flash` swaps the firmware without
@@ -1602,6 +1636,7 @@ void mik__ota_boot_reconcile(void) {
1602
1636
  }
1603
1637
  }
1604
1638
  nvs_erase_key(h, kKeyPendChk);
1639
+ nvs_erase_key(h, kKeyPendMode);
1605
1640
  nvs_set_u8(h, kKeyState, kStateGood);
1606
1641
  nvs_set_u8(h, kKeyTrialLeft, 0);
1607
1642
  nvs_set_u8(h, kKeyPending, 0);
@@ -1613,7 +1648,65 @@ void mik__ota_boot_reconcile(void) {
1613
1648
  nvs_commit(h);
1614
1649
  }
1615
1650
 
1616
- // 2. Trial verdict. Evaluate why we just reset while a trial was in flight.
1651
+ // 2. Adopt install: a cable deploy staged by MIK_CMD_DEPLOY_BUILD. No
1652
+ // trial, no rollback baseline, one attempt (charged before the attempt,
1653
+ // same crash reasoning as the deferred install below). Runs before the
1654
+ // trial verdict as a backstop: stage_adopt resolves an in-flight trial
1655
+ // at stage time, but stale trial state must never roll back or promote
1656
+ // against the pending slot the deploy overwrote.
1657
+ if (nvs_u8(h, kKeyPending, 0) == 1 &&
1658
+ nvs_u8(h, kKeyPendMode, kPendModeTrial) == kPendModeAdopt) {
1659
+ char chk[96] = {0};
1660
+ nvs_str(h, kKeyPendChk, chk, sizeof(chk));
1661
+ if (!path_exists(kPending)) {
1662
+ adopt_fail(h, chk, "install-failed", "staged build missing");
1663
+ nvs_close(h);
1664
+ return;
1665
+ }
1666
+ uint8_t left = nvs_u8(h, kKeyInstLeft, 1);
1667
+ if (left == 0) {
1668
+ // The charged attempt never came back: the install panicked.
1669
+ adopt_fail(h, chk, "install-failed", "install did not complete (device restarted)");
1670
+ nvs_close(h);
1671
+ return;
1672
+ }
1673
+ nvs_set_u8(h, kKeyInstLeft, (uint8_t)(left - 1));
1674
+ nvs_commit(h);
1675
+
1676
+ const char* err = nullptr;
1677
+ OtaErr kind = OtaErr::kTransient;
1678
+ if (!install_build(kPending, &err, &kind)) {
1679
+ adopt_fail(h, chk, kind == OtaErr::kCorrupt ? "install-corrupt" : "install-failed",
1680
+ err != nullptr ? err : "install failed");
1681
+ nvs_close(h);
1682
+ return;
1683
+ }
1684
+ // Installed. A cable deploy resets the OTA safety story like a reflash
1685
+ // does: nothing to roll back to until the next OTA trial promotes.
1686
+ unlink(kPending);
1687
+ unlink(kLastGood);
1688
+ nvs_set_str(h, kKeyInstChk, chk);
1689
+ nvs_set_u8(h, kKeyState, kStateGood);
1690
+ nvs_set_u8(h, kKeyTrialLeft, 0);
1691
+ nvs_set_u8(h, kKeyPending, 0);
1692
+ nvs_erase_key(h, kKeyPendChk);
1693
+ nvs_erase_key(h, kKeyPendMode);
1694
+ nvs_erase_key(h, kKeyInstLeft);
1695
+ nvs_erase_key(h, kKeyPromLeft);
1696
+ nvs_erase_key(h, kKeyRbLeft);
1697
+ nvs_erase_key(h, kKeyNeutLeft);
1698
+ clear_trial_failure(h);
1699
+ nvs_set_u8(h, kKeyDRes, 1);
1700
+ nvs_set_str(h, kKeyDChk, chk);
1701
+ nvs_erase_key(h, kKeyDRsn);
1702
+ nvs_erase_key(h, kKeyDDetail);
1703
+ nvs_commit(h);
1704
+ nvs_close(h);
1705
+ esp_restart(); // boot into the deployed app on a clean heap
1706
+ return;
1707
+ }
1708
+
1709
+ // 3. Trial verdict. Evaluate why we just reset while a trial was in flight.
1617
1710
  if (nvs_u8(h, kKeyState, kStateGood) == kStateTrial) {
1618
1711
  esp_reset_reason_t reason = esp_reset_reason();
1619
1712
  TrialVerdict verdict = classify_reset(reason);
@@ -1715,8 +1808,10 @@ void mik__ota_boot_reconcile(void) {
1715
1808
  // kNeutral: don't penalize — leave the trial untouched and proceed.
1716
1809
  }
1717
1810
 
1718
- // 3. Deferred install. A build verified by stageFinish(installNow=false) is
1719
- // installed here, on a clean heap, then we restart into its trial.
1811
+ // 4. Deferred trial install. A build verified by stageFinish(installNow=
1812
+ // false) is installed here, on a clean heap, then we restart into its
1813
+ // trial. (An adopt-mode pending build never reaches this: step 2
1814
+ // returns.)
1720
1815
  if (nvs_u8(h, kKeyPending, 0) == 1 && nvs_u8(h, kKeyState, kStateGood) == kStateGood) {
1721
1816
  if (!path_exists(kPending)) {
1722
1817
  nvs_set_u8(h, kKeyPending, 0);
package/index.d.ts CHANGED
@@ -5,3 +5,4 @@ export const projectCmakePath: string
5
5
 
6
6
  export function prebuiltFirmwareDir(chip: string): string
7
7
  export function hasPrebuiltFirmware(chip: string): boolean
8
+ export function prebuiltFirmwareName(chip: string): string | undefined
package/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import {existsSync} from 'node:fs'
1
+ import {existsSync, readFileSync} from 'node:fs'
2
2
  import {dirname, join} from 'node:path'
3
3
  import {fileURLToPath} from 'node:url'
4
4
 
@@ -18,3 +18,15 @@ export function prebuiltFirmwareDir(chip) {
18
18
  export function hasPrebuiltFirmware(chip) {
19
19
  return existsSync(join(prebuildsRoot, chip, 'flasher_args.json'))
20
20
  }
21
+
22
+ /** Identity of the bundled prebuilt for `chip` (the package name of the
23
+ * firmware project it was built from), or undefined when no prebuilt or no
24
+ * recorded identity exists. */
25
+ export function prebuiltFirmwareName(chip) {
26
+ try {
27
+ const {name} = JSON.parse(readFileSync(join(prebuildsRoot, chip, 'firmware.json'), 'utf8'))
28
+ return typeof name === 'string' ? name : undefined
29
+ } catch {
30
+ return undefined
31
+ }
32
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikrojs/firmware",
3
- "version": "0.17.0-pr-276.20260721233726",
3
+ "version": "0.17.0",
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.17.0-pr-276.20260721233726+1bcc126",
51
- "@mikrojs/quickjs": "0.17.0-pr-276.20260721233726+1bcc126"
50
+ "@mikrojs/quickjs": "0.17.0",
51
+ "@mikrojs/native": "0.17.0"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=24.0.0"
@@ -0,0 +1 @@
1
+ {"name":"@mikrojs/firmware-dev"}
Binary file
@@ -0,0 +1 @@
1
+ {"name":"@mikrojs/firmware-dev"}
Binary file
@@ -0,0 +1 @@
1
+ {"name":"@mikrojs/firmware-dev"}
Binary file
@@ -0,0 +1 @@
1
+ {"name":"@mikrojs/firmware-dev"}
Binary file
@@ -0,0 +1 @@
1
+ {"name":"@mikrojs/firmware-dev"}
Binary file
package/project.cmake CHANGED
@@ -81,16 +81,58 @@ if(EXISTS "${CMAKE_SOURCE_DIR}/sdkconfig.defaults.${IDF_TARGET}")
81
81
  list(APPEND _SDKCONFIG_LIST "${CMAKE_SOURCE_DIR}/sdkconfig.defaults.${IDF_TARGET}")
82
82
  endif()
83
83
 
84
- # Partition table: generate a sdkconfig fragment with the absolute path
84
+ # Partition table: copy the source csv into the build dir and point the
85
+ # config at it, project-relative when possible (ESP-IDF resolves a relative
86
+ # value against the project dir). Writing the resolved package path directly
87
+ # would freeze an absolute node_modules/store path into sdkconfig — sdkconfig
88
+ # wins over defaults files, so every @mikrojs/firmware upgrade would then
89
+ # break the build with a missing-partitions.csv error pointing at a version
90
+ # that is no longer installed.
85
91
  if(EXISTS "${CMAKE_SOURCE_DIR}/partitions.csv")
86
- set(_PARTITION_CSV "${CMAKE_SOURCE_DIR}/partitions.csv")
92
+ set(_PARTITION_SRC "${CMAKE_SOURCE_DIR}/partitions.csv")
87
93
  else()
88
- set(_PARTITION_CSV "${_MIK_CONFIG_DIR}/partitions.csv")
94
+ set(_PARTITION_SRC "${_MIK_CONFIG_DIR}/partitions.csv")
89
95
  endif()
90
- set(_PARTITION_FRAGMENT "${CMAKE_SOURCE_DIR}/build/sdkconfig.partitions")
96
+ configure_file("${_PARTITION_SRC}" "${CMAKE_BINARY_DIR}/partitions.csv" COPYONLY)
97
+ file(RELATIVE_PATH _PARTITION_CSV "${CMAKE_SOURCE_DIR}" "${CMAKE_BINARY_DIR}/partitions.csv")
98
+ if(_PARTITION_CSV MATCHES "^\\.\\.")
99
+ # Build dir outside the project (idf.py -B): absolute path, still stable
100
+ # across package upgrades, and the heal below re-points it if it moves.
101
+ set(_PARTITION_CSV "${CMAKE_BINARY_DIR}/partitions.csv")
102
+ endif()
103
+ set(_PARTITION_FRAGMENT "${CMAKE_BINARY_DIR}/sdkconfig.partitions")
91
104
  file(WRITE "${_PARTITION_FRAGMENT}" "CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=\"${_PARTITION_CSV}\"\n")
92
105
  list(APPEND _SDKCONFIG_LIST "${_PARTITION_FRAGMENT}")
93
106
 
107
+ # Heal an sdkconfig whose frozen path points outside the project: a stale
108
+ # resolved package path, or a build dir that moved. Must fire even when the
109
+ # frozen file still exists — an older package version often is still in the
110
+ # store, and its old partition table would be built silently. Paths inside
111
+ # the project are deliberate menuconfig overrides and are left alone.
112
+ if(EXISTS "${CMAKE_SOURCE_DIR}/sdkconfig")
113
+ file(READ "${CMAKE_SOURCE_DIR}/sdkconfig" _SDKCONFIG_CONTENT)
114
+ string(REGEX MATCH "CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=\"([^\"]*)\"" _PARTITION_MATCH "${_SDKCONFIG_CONTENT}")
115
+ if(_PARTITION_MATCH AND NOT CMAKE_MATCH_1 STREQUAL "${_PARTITION_CSV}")
116
+ set(_PARTITION_FROZEN "${CMAKE_MATCH_1}")
117
+ get_filename_component(_PARTITION_FROZEN_ABS "${_PARTITION_FROZEN}" ABSOLUTE BASE_DIR "${CMAKE_SOURCE_DIR}")
118
+ string(FIND "${_PARTITION_FROZEN_ABS}" "${CMAKE_SOURCE_DIR}/" _PARTITION_INSIDE)
119
+ if(NOT _PARTITION_INSIDE EQUAL 0)
120
+ string(REGEX REPLACE "CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=\"[^\"]*\""
121
+ "CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=\"${_PARTITION_CSV}\""
122
+ _SDKCONFIG_CONTENT "${_SDKCONFIG_CONTENT}")
123
+ # The derived twin should be recomputed by kconfgen, but rewrite it
124
+ # too: if it kept the saved value the build breaks the same way.
125
+ string(REGEX REPLACE "CONFIG_PARTITION_TABLE_FILENAME=\"[^\"]*\""
126
+ "CONFIG_PARTITION_TABLE_FILENAME=\"${_PARTITION_CSV}\""
127
+ _SDKCONFIG_CONTENT "${_SDKCONFIG_CONTENT}")
128
+ file(WRITE "${CMAKE_SOURCE_DIR}/sdkconfig" "${_SDKCONFIG_CONTENT}")
129
+ message(WARNING
130
+ "mikrojs: sdkconfig CONFIG_PARTITION_TABLE_CUSTOM_FILENAME pointed outside "
131
+ "the project (\"${_PARTITION_FROZEN}\"); repointed at \"${_PARTITION_CSV}\"")
132
+ endif()
133
+ endif()
134
+ endif()
135
+
94
136
  if(_BOARD_JSON)
95
137
  string(JSON _BOARD_COMPONENT_DIRS GET "${_BOARD_JSON}" "components")
96
138
  string(JSON _BOARD_SDKCONFIG_DEFAULTS GET "${_BOARD_JSON}" "sdkconfigs")
package/resolve.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * quickjs — path to quickjs.cmake
13
13
  * native — JSON with native package paths
14
14
  * version — firmware package version
15
+ * projectName <dir> — package.json name of the consuming project (empty if none)
15
16
  * discover <dir> — discover board/driver components from project dir
16
17
  */
17
18
  import {componentDir, projectCmakePath} from './cmake.js'
@@ -44,6 +45,15 @@ if (query === 'componentDir') {
44
45
  } else if (query === 'version') {
45
46
  const {default: pkg} = await import('./package.json', {with: {type: 'json'}})
46
47
  process.stdout.write(pkg.version)
48
+ } else if (query === 'projectName') {
49
+ const {readFileSync} = await import('node:fs')
50
+ const {join} = await import('node:path')
51
+ try {
52
+ const pkg = JSON.parse(readFileSync(join(process.argv[3], 'package.json'), 'utf8'))
53
+ if (typeof pkg.name === 'string') process.stdout.write(pkg.name)
54
+ } catch {
55
+ // No package.json (e.g. on-device test apps): no identity, empty output.
56
+ }
47
57
  } else if (query === 'discover') {
48
58
  const projectDir = process.argv[3]
49
59
  const {discover} = await import('./discover.js')