Skip to content

Writing a Module

An ESPM module is a single C file (which may include sibling files) plus a module.toml manifest. It has no ESP-IDF dependency: every firmware service is reached through the syscall table.

Two ways to write the entry point

Include espm.h once. It supplies module_entry, a file-scope static espm_sys_t *sys, weak callbacks, and macros that map names like printf, malloc, and gpio_set directly onto the syscall table. Do not include <stdio.h>, <stdlib.h>, or <string.h> - the header provides equivalents.

#include "espm.h"

void espm_init(void *arg) {     // called once on load - register commands here
    cmd_register("scan", cmd_scan, NULL);
    printf("scanner loaded\n");
}

void espm_exec(void) {          // main task, runs in its own FreeRTOS task
    while (!task_notify_wait(5000)) {
        watchdog_feed();
    }
}

void espm_stop(void) {          // before unload - release resources
    printf("scanner shutting down\n");
}

Callbacks are weak; define only the ones you need.

Raw ABI

Include espm_module.h and export module_entry yourself, switching on cmd. This is what the mod_hello template does:

#include "espm_module.h"

int module_entry(espm_sys_t *sys, int cmd, void *arg)
{
    if (cmd == ESPM_CMD_INIT) { sys->printf("init OK\n"); return 0; }
    if (cmd == ESPM_CMD_EXEC) {
        uint32_t model, cores, rev;
        sys->get_chip_info(&model, &cores, &rev);
        sys->printf("heap=%lu\n", (unsigned long)sys->get_free_heap());
        return 0;
    }
    if (cmd == ESPM_CMD_STOP) { sys->printf("stop\n"); return 0; }
    return -1;
}

Registering commands and returning output

Inside espm_init, register named C2 commands:

cmd_register("scan", cmd_scan, NULL);   // name (<= 31 chars), handler, module_ctx

A handler receives an espm_cmd_ctx_t:

typedef struct {
    const char *args;        // CLI args after the command name
    const char *request_id;  // C2 request id (may be NULL)
    void       *module_ctx;  // your private state
} espm_cmd_ctx_t;

Return output to the operator with the C2 messaging syscalls. msg_result is the mandatory end-of-command marker:

Syscall Use
msg_info(tag, msg, request_id) Human-readable progress
msg_error(tag, msg, request_id) Error (follow with msg_result)
msg_data(tag, data, len, eof, request_id) Binary data frame
msg_result(tag, msg, request_id) Signal completion (call exactly once)

Feeding the C3PO Data view

Modules that populate C3PO's Data tables emit pipe-delimited lines via msg_info, for example:

AP|<ssid>|<bssid>|<rssi>|<channel>|<auth>      # Data > WiFi
BLE|<addr>|<name>|<rssi>|<type>                # Data > BLE
HOST|<ip>|<mac>|<latency>|<ports>              # Data > Network

A complete command handler

static int cmd_scan(espm_cmd_ctx_t *ctx)
{
    espm_ap_record_t aps[16];
    uint16_t count = 16;

    wifi_scan_start();
    wifi_scan_get(aps, &count);

    for (uint16_t i = 0; i < count; i++) {
        char buf[96];
        snprintf(buf, sizeof(buf), "AP|%s|%02x..|%d|%d|%d",
                 (const char *)aps[i].ssid, aps[i].bssid[0],
                 aps[i].rssi, aps[i].channel, aps[i].authmode);
        msg_info("scanner", buf, ctx->request_id);
    }
    msg_result("scanner", "done", ctx->request_id);
    return 0;
}

The module.toml manifest

Every module directory has a module.toml that C3PO parses to build the catalog and drive compilation:

[module]
name        = "mod_example"          # defaults to the directory name
version     = "1.0.0"                # defaults to "0.0.0"
description = "what it does"
targets     = ["esp32", "esp32c6"]   # chip base names and/or board variants
source      = "cmd_example.c"        # entry .c to compile (defaults to "<name>.c")

[commands]                            # name -> help string, shown in the Modules view
example_cmd = "Do the thing <arg>"

[requires]
syscalls = ["socket", "wifi_scan"]    # informational capability hints
min_heap = 32768                      # bytes

A module supports a chip if a targets entry equals the chip or its base name (so esp32s3-cam-n16r8 matches a target of esp32s3).

Guidelines

  • Long-running loops must call watchdog_feed() regularly (default timeout 30 s).
  • Spawn background work with task_create(...); a spawned task must call task_exit() before returning.
  • Free everything in espm_stop - close sockets, stop radios, delete queues and mutexes.

See also