Custom Firmware
Build custom firmware when the official Mikro.js firmware lacks something an app needs: a native module, different ESP-IDF settings, a bigger flash chip, or custom startup code. A firmware project is a small npm package, and you don't need the Mikro.js repository.
Prerequisites
Create a project
package.json depends on @mikrojs/firmware, on mikro for the mikro idf command, and on the packages whose native modules you want. @mikrojs/firmware has to be a direct dependency: mikro idf resolves it from the project, which package managers such as pnpm allow only for the project's own dependencies.
{
"name": "my-firmware",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"@mikrojs/firmware": "^0.1.0",
"@my-scope/epaper": "^0.1.0",
"mikro": "^0.1.0"
}
}CMakeLists.txt:
cmake_minimum_required(VERSION 3.22)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
# The native modules to compile in, by the names that apps import
set(MIKROJS_NATIVE_MODULES "@my-scope/epaper/panel")
if(NOT DEFINED MikroFirmware_DIR)
message(FATAL_ERROR "Build with `mikro idf`, which tells CMake where @mikrojs/firmware is")
endif()
find_package(MikroFirmware REQUIRED COMPONENTS esp32 NO_DEFAULT_PATH)
project(my-firmware)mikro idf resolves @mikrojs/firmware from the project and passes its folder to CMake as MikroFirmware_DIR, so build with mikro idf, not plain idf.py. NO_DEFAULT_PATH makes CMake use only that folder, never another copy installed elsewhere on the system. COMPONENTS esp32 selects the chip family.
Separate several native modules with ;. The build stops if an entry is not an installed native module. Without MIKROJS_NATIVE_MODULES, the build is the official Mikro.js firmware with the project's settings.
The firmware takes the name of the project's package. The device reports it as sys.board.name, mikro fw pack names the archive after it, and mikro flash --board finds a board package's image by it. To name it otherwise, set MIKROJS_BOARD_NAME, and MIKROJS_BOARD_DESCRIPTION for the description mikro flash shows. A name has at most 63 characters and the form of a package name, optionally followed by /<board>: @acme/boards/t-display. You can also set the variables in the environment or with -D, which take priority over set().
An app can be its own firmware project: add @mikrojs/firmware to the app's dependencies, and put CMakeLists.txt next to its package.json. examples/chip-temperature is set up this way; pn create mikro --firmware scaffolds one. ESP-IDF writes sdkconfig, managed_components/ and dependencies.lock into the project folder, and the build into .mikro/, so add them to .gitignore.
Build and flash
In the project folder, install the dependencies, set the chip, and build:
pn install
pn mikro idf set-target esp32c6
pn mikro idf build flash monitormikro idf passes its arguments to ESP-IDF's idf.py. When ESP-IDF is not active in the shell, it runs idf.py through EIM, which activates ESP-IDF first.
An IDE that runs CMake itself, such as the ESP-IDF extension for VS Code, has to pass MikroFirmware_DIR too. Run pn mikro idf reconfigure once; the MikroFirmware_DIR line in .mikro/build-fw/CMakeCache.txt (.mikro/build-fw-<folder>/ for a firmware project in a folder of the app) then has the folder. Add -DMikroFirmware_DIR=<folder> to the CMake arguments in the IDE's settings. After you update @mikrojs/firmware, check the folder again, because it can change.
The build says "qjsc not found"
pnpm skipped the build script of @mikrojs/quickjs, which builds the QuickJS bytecode compiler. Run pnpm approve-builds, select @mikrojs/quickjs, and install again.
Change ESP-IDF settings
Put the settings in a sdkconfig.defaults file in the project. They override the firmware package's defaults. ESP-IDF reads the file only when it creates sdkconfig, so after you change it, delete sdkconfig and run mikro idf set-target again.
For example, an app that does all its networking over a cellular modem can leave WiFi out, which frees about 20 KB of internal RAM:
CONFIG_MIKROJS_WIFI=nOn chips with USB-Serial/JTAG (ESP32-C3, ESP32-C5, ESP32-C6, ESP32-S3), a battery-powered device can enter light sleep between timers. This is experimental:
CONFIG_MIKROJS_AUTO_LIGHT_SLEEP=yThe option also turns on ESP-IDF power management, which lowers the CPU clock while the app waits. The device stays awake while a USB host is connected, so development over USB works as before. It also stays awake while the app has a Pwm, Uart, NeoPixel or I2s handle open. A main.cpp that doesn't call MIK_Main() gets no light sleep unless it calls esp_pm_configure() itself. Light sleep has two limits. The device can miss a GPIO edge that happens while it sleeps. If you connect a USB host while the device sleeps, the host may not detect the device until you reset it.
At the start of every boot, the firmware waits 500 ms for the recovery trigger (mikro deploy --recover or a double reset, see Recovering a crash-looping device). A wake from deep sleep is a boot too, so an app that wakes often can skip the window. The double reset needs a window long enough to press reset twice, and --recover needs it to outlast the host's reconnect after the reset, so a short window is in practice the same as none. Without the window, --recover and the double reset do not work, and a crash-looping app has to be erased from the ROM bootloader.
CONFIG_MIKROJS_RECOVERY_WINDOW_MS=0Use a bigger flash chip
The official firmware's partition table is for 4 MB of flash. mikro flash stretches user, the partition that holds the app and its files, to the end of a bigger chip for prebuilt images: the generic firmware, a board package's image and --from firmware. A build flashed with --build-dir or pn mikro idf flash uses the flash size in its sdkconfig and the table as you wrote it. For a bigger chip, set its size in sdkconfig.defaults, and add a partitions.csv that gives the extra space to user. For 8 MB:
CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y
CONFIG_ESPTOOLPY_FLASHSIZE="8MB"# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x6000,
phy_init, data, phy, 0xf000, 0x1000,
factory, app, factory, 0x10000, 0x280000,
user, data, littlefs, , 0x570000,For 16 MB, use CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y, CONFIG_ESPTOOLPY_FLASHSIZE="16MB" and a user size of 0xD70000. Keep the names and types of user and factory: the firmware looks the filesystem up by the name user.
Shrinking the user partition reformats it
If the new user partition is smaller than the one on the device, the device reformats it on the next boot, and you need to deploy the app again. storageUsage().total shows the size on the device. A build laid out for 4 MB is smaller on an 8 MB or 16 MB module that has run the generic firmware, which uses all of its flash. mikro flash --build-dir refuses such a flash unless you pass --force.
Flash the new table over USB with pn mikro idf flash. On its first boot, the device grows the filesystem to fill the new user partition and keeps any existing files.
Custom startup code
The firmware starts with MIK_Main(), which sets up NVS, the filesystem and the JavaScript runtime, and runs the REPL and the deploy protocol. To run code before it, add a main/ folder:
// main/main.cpp
#include "mikrojs_esp32.h"
extern "C" void app_main(void) {
// Setup code here
MIK_Main();
}# main/CMakeLists.txt
idf_component_register(SRCS "main.cpp"
PRIV_REQUIRES spi_flash mikrojs littlefs esp_driver_uart esp_driver_usb_serial_jtag
INCLUDE_DIRS "")To change what MIK_Main() itself does, start from its source.
Mikro.js keeps a 68-byte structure in ESP-IDF's custom app description (.rodata_custom_desc), where mikro flash writes a board's name into the generic firmware. A structure of your own shares that section, and the link order decides which of the two comes first, so its offset in the image isn't fixed: give it a marker of its own to find it by.
Custom firmware and the CLI
The CLI comes with the official Mikro.js firmware, but it never flashes that over custom firmware unless you ask it to. After a CLI upgrade, it asks you to rebuild and flash the custom firmware instead. To switch a device back to the official Mikro.js firmware, run mikro flash --force.
Share a build
Others can flash the firmware without building it. In the project folder, build the firmware and pack it:
pn mikro fw packThis writes mikro-fw-my-firmware-esp32c6.tar.gz, named after the firmware and the chip, to the current folder: @ is dropped and / becomes -, so @acme/devboard gives mikro-fw-acme-devboard-esp32c6.tar.gz. Put it where others can download it, such as a GitHub release, and flash it by its URL:
pn mikro flash --from https://github.com/my-org/my-firmware/releases/download/v1.0.0/mikro-fw-my-firmware-esp32c6.tar.gzTo make firmware for a development board that apps can install with npm and flash with mikro flash, publish it as a board package.