Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions hosts/esp-idf/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# PocketJS on ESP-IDF

`hosts/esp-idf` is the ESP-IDF product-host half of the network stack: the
QuickJS-ng guest owner, the network modules over the portable core
(`engine/net`) and lwIP, and the board bring-up for the first two profiles.
The renderer side for ESP32-P4 lives in `hosts/esp32p4` (PPA backend).

| Component | Role |
|---|---|
| `components/pocketjs_net_core` | `engine/net` (HTTP client, HTTP server, WebSocket client cores) plus the BSD-socket driver compiled against lwIP |
| `components/pocketjs_esp_host` | QuickJS-ng guest on one owner task, fixed-rate `frame()` ticks with `begin_tick` before each, `globalThis.net` / `ws` / `httpd` bindings, a network task that services sockets under the runtime lock |
| `components/pocketjs_net_esptls` | ESP-TLS TlsProvider (ESP-TLS + the IDF certificate bundle) for `https:`/`wss:` |
| `components/pocketjs_board` | Wi-Fi station + DHCP + SNTP for the AtomS3R (native Wi-Fi) and the Tab5 (ESP32-P4 rev 1.3 + ESP32-C6 over SDIO via esp_hosted 2.12.12 / esp_wifi_remote 1.6.4, WLAN rail on the PI4IOE5V6408 @0x44 bit 0) |
| `examples/net-smoke` | Headless smoke app (`app.ts`) and the firmware template used by the hardware gate |

Toolchain: ESP-IDF v6.0.2 (`7101770dc6db`), QuickJS-ng 0.14.0 from the
component registry, Bun for the guest bundle.

## Execution model

The guest runs only inside `frame()` on the owner task. Before every
frame the owner
task calls `pnet_runtime_begin_tick()`, which freezes the visible event set;
inside `frame()` the framework service pump calls each module's `poll` once
and copies bodies out with `readInto`; Promise reactions run in the job
drain right after `frame()`. The network task never touches QuickJS: it
runs `pnet_runtime_service()` under the same mutex the bindings take, waits
in `select()` with the core's next deadline, and is woken through a
loopback UDP socket whenever the guest issued an op. DNS lookups run on the
driver's own `pnet-dns` task, never on the network task.

Tick k is scheduled at `t0 + k / tick_hz` on the microsecond timer, so a
60 Hz guest runs at **60.00 Hz** (an integer 16 ms FreeRTOS period would be
62.5 Hz and drift the virtual clock from the wall clock by 4 %). A frame that
overruns makes the next ticks late, and each late tick still gets its one
turn (Law 3); only a host more than 0.5 s behind drops ticks, counted in
`stats.frames_skipped`. Shutdown is a single unwind: `stop()` asks both
tasks to exit, bounds a guest turn in progress through the QuickJS interrupt
handler, waits for both exit flags and only then frees; a failed start
releases everything it created.

## Build Plan inputs

The firmware authors no network policy. `examples/net-smoke/pocket.json` is
a **format 3** manifest (`permissions.network`); `tools/esp-idf.ts`, run by
`main/CMakeLists.txt`, merges the rig's endpoints (Kconfig: workstation
peer, peer board, serve port, TLS host), resolves the plan against the
board's private profile (`tools/esp-idf-profile.ts`: `atoms3r-dev` /
`tab5-dev`, advertising the HTTP client (+TLS), HTTP server and WebSocket
client (+TLS) roles) and writes into the build directory:

| File | Use |
|---|---|
| `network-policy.json` | the canonical `ResolvedNetworkPolicy` (plan truth, covered by `planHash`), embedded and passed to `pnet_runtime_create` verbatim |
| `host-inputs.h` | `POCKETJS_PLAN_HASH`, target, resolved features (`POCKETJS_FEATURE_*`) — the roles `main.c` mounts |
| `app.js` | the guest bundle built against the same plan |
| `plan.json`, `pocket.resolved.json` | the plan and the merged manifest, for inspection |

`wall_clock_trusted` is a board state, not a date check: the board layer
latches it when an SNTP sync completes (`pocketjs_board_sync_time`, and every
re-sync through the SNTP notification) or when the product asserts it; until
then every verifying TLS connection fails closed with `tls_clock_untrusted`.

## Hardware smoke (plaintext)

`examples/net-smoke` against `bun tools/net-peer.ts` on the workstation and
board-to-board, both boards serving on :8080:

| Board | Result |
|---|---|
| AtomS3R (ESP32-S3-PICO-1-N8R8) | 20/20 plaintext + 6 TLS = 26/26: GET/POST/JSON/chunked/404, redirect follow+manual, 200 KB body through an 8 KiB queue at ~350 KiB/s, aggregate limit, headers timeout, permission_denied, connect refused, WebSocket echo (text/binary/ping/pong/close), peer board GET/POST/JSON/stream/404, continuous pings |
| Tab5 (ESP32-P4 rev 1.3 + C6) | 26/26, same suite, ~370 KiB/s |

The TLS block (enable `CONFIG_SMOKE_ENABLE_TLS=y`) needs internet and
an SNTP sync: HTTPS/1.1 to a public host with a valid chain from the IDF
certificate bundle, plus badssl.com's expired / wrong-host / self-signed /
untrusted-root endpoints, all failing closed. Hostname mismatch reports
`tls_hostname_mismatch`; the other certificate faults report
`tls_certificate_invalid` or `tls_handshake_failed` (ESP-TLS exposes the
Mbed TLS verify flags inconsistently on the async path) — the precise
per-fault codes are proven in the desktop OpenSSL conformance suite.

Steady state after 60 s: guest heap ≈363 KB (high water ≈686 KB during
bundle evaluation), core heap ≈4 KB, one socket per live connection, no
growth. An earlier 12-minute board-to-board soak (43,200 frames, 330 HTTP
round trips each way, both boards serving the other) ended with zero
failures and the same heap figures — it ran on the 16 ms (62.5 Hz) host, so
its "12 minutes" was the frame count ÷ 60 and about 11.5 min of wall clock;
the exact-cadence host reports 1800 frames per 30.0 s of uptime in its
periodic stats. Bundle evaluation of the 116 KB smoke IIFE: ≈780 ms on the
S3, ≈350 ms on the P4. ESP-TLS handshake steps run under the runtime lock, so
a handshake stalls the guest's `begin_tick` for up to a couple of seconds;
the overload guard shows this as `frames_skipped` during the TLS block.

## Tab5 pitfalls

- Rev 1.3 silicon needs `CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y` and
`CONFIG_ESP32P4_REV_MIN_100=y`; the default v3-only image does not boot.
- The C6 sits behind the SDIO1 preset (`CONFIG_ESP32P4_TAB5_C6_BOARD=y`:
CLK 12, CMD 13, D0–D3 11/10/9/8, reset GPIO 15) and needs
`CONFIG_ESP_HOSTED_SDIO_RESET_ACTIVE_HIGH=y` — GPIO15 drives EN through
1 kΩ; the active-low default leaves the C6 held in reset (SDIO CMD5
timeout).
- Power the WLAN rail before `esp_wifi_init()` (`pocketjs_board_prepare_wifi`).
- `CONFIG_FREERTOS_HZ=1000` keeps the hosted transport free of bus jitter warnings.
10 changes: 10 additions & 0 deletions hosts/esp-idf/components/pocketjs_board/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
set(reqs esp_wifi esp_netif esp_event nvs_flash esp_driver_i2c)
if(CONFIG_IDF_TARGET_ESP32P4)
list(APPEND reqs esp_wifi_remote esp_hosted)
endif()
idf_component_register(
SRCS "src/board_wifi.c" "src/board_prepare.c"
INCLUDE_DIRS "include"
REQUIRES ${reqs}
PRIV_REQUIRES log freertos)
target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror)
13 changes: 13 additions & 0 deletions hosts/esp-idf/components/pocketjs_board/idf_component.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
description: Wi-Fi station bring-up for the AtomS3R (ESP32-S3) and Tab5 (ESP32-P4 + C6) PocketJS profiles.
version: "0.1.0"
dependencies:
idf:
version: ">=5.4"
espressif/esp_wifi_remote:
version: "1.6.4"
rules:
- if: "target in [esp32p4]"
espressif/esp_hosted:
version: "2.12.12"
rules:
- if: "target in [esp32p4]"
65 changes: 65 additions & 0 deletions hosts/esp-idf/components/pocketjs_board/include/pocketjs/board.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/* Board bring-up for the first two ESP-IDF PocketJS profiles:
*
* AtomS3R ESP32-S3-PICO-1-N8R8, native Wi-Fi.
* Tab5 ESP32-P4 rev 1.3 + on-board ESP32-C6 over SDIO (esp_hosted +
* esp_wifi_remote); the C6 power rail sits behind the PI4IOE5V6408
* IO expander at 0x44 (bit 0, WLAN_PWR_EN) on the internal I2C bus
* (SDA GPIO31, SCL GPIO32) and must be on before esp_wifi_init().
*
* The public network modules never see any of this: link driver, BSP and
* credentials are product/host concerns. This component gives the smoke
* firmware one call that brings the
* station interface up with DHCP and returns the address.
*/
#ifndef POCKETJS_BOARD_H
#define POCKETJS_BOARD_H

#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>

#include "esp_err.h"
#include "esp_netif_ip_addr.h"

#ifdef __cplusplus
extern "C" {
#endif

typedef struct pocketjs_board_wifi_config {
const char *ssid;
const char *password;
/** Wait for DHCP this long (0 = 30 s). */
uint32_t timeout_ms;
} pocketjs_board_wifi_config;

/** Board-specific power/transport preparation (Tab5: enable the C6 rail and
* start the hosted transport). No-op on AtomS3R. Idempotent. */
esp_err_t pocketjs_board_prepare_wifi(void);

/** NVS + netif + event loop + STA + DHCP; returns once an IPv4 address is
* bound (written to *ip) or fails after the timeout. Reconnects on drops. */
esp_err_t pocketjs_board_wifi_connect(const pocketjs_board_wifi_config *cfg, esp_ip4_addr_t *ip);

/** Current station IPv4 address as text ("0.0.0.0" when down). */
void pocketjs_board_ip_text(char *out, size_t cap);

/** Sync the wall clock over SNTP. Returns ESP_OK once the time is set (the
* clock is then trusted, see below), ESP_ERR_TIMEOUT otherwise. */
esp_err_t pocketjs_board_sync_time(uint32_t timeout_ms);

/** Wall-clock trust state for TLS certificate validation — a state the board
* layer maintains, not a guess from the date: true after an SNTP sync
* completed (pocketjs_board_sync_time, or any later SNTP re-sync reported
* through the sync notification), or after the product asserted it with
* pocketjs_board_set_clock_trusted (a validated battery-backed RTC,
* provisioning). Wire it into pocketjs_esp_host_config.wall_clock_trusted. */
bool pocketjs_board_clock_trusted(void);
void pocketjs_board_set_clock_trusted(bool trusted);
/** Adapter with the host's callback signature (ignores `user`). */
bool pocketjs_board_clock_trusted_cb(void *user);

#ifdef __cplusplus
}
#endif

#endif /* POCKETJS_BOARD_H */
112 changes: 112 additions & 0 deletions hosts/esp-idf/components/pocketjs_board/src/board_prepare.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/* Board-specific preparation before esp_wifi_init(). */
#include "pocketjs/board.h"

#include "esp_log.h"
#include "sdkconfig.h"

static const char *TAG = "board";

#if CONFIG_IDF_TARGET_ESP32P4
/* Tab5: the ESP32-C6 module is powered through the second PI4IOE5V6408 IO
* expander (0x44, bit 0 = WLAN_PWR_EN) on the internal I2C bus, and reached
* over SDIO through esp_hosted. The expander register values are the ones
* M5Stack's Tab5 demo programs; only bit 0 matters here. GPIO15 (P4) drives
* the C6 EN pin through 1 kΩ and is left to esp_hosted's reset sequence,
* which the sdkconfig must configure active-high. */
#include "driver/i2c_master.h"
#include "esp_hosted.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

#define TAB5_I2C_PORT 0
#define TAB5_I2C_SDA 31
#define TAB5_I2C_SCL 32
#define TAB5_PI4IOE2_ADDR 0x44
#define PI4IO_REG_CHIP_RESET 0x01
#define PI4IO_REG_IO_DIR 0x03
#define PI4IO_REG_OUT_SET 0x05
#define PI4IO_REG_OUT_H_IM 0x07
#define PI4IO_REG_PULL_EN 0x0B
#define PI4IO_REG_PULL_SEL 0x0D

static bool s_prepared;

static esp_err_t pi4io_write(i2c_master_dev_handle_t dev, uint8_t reg, uint8_t value) {
uint8_t buf[2] = {reg, value};
return i2c_master_transmit(dev, buf, sizeof buf, 100);
}

static esp_err_t tab5_power_wlan(void) {
i2c_master_bus_config_t bus_cfg = {
.clk_source = I2C_CLK_SRC_DEFAULT,
.i2c_port = TAB5_I2C_PORT,
.sda_io_num = TAB5_I2C_SDA,
.scl_io_num = TAB5_I2C_SCL,
.glitch_ignore_cnt = 7,
.flags.enable_internal_pullup = true,
};
i2c_master_bus_handle_t bus;
esp_err_t err = i2c_new_master_bus(&bus_cfg, &bus);
if (err != ESP_OK) {
/* The bus may already exist (a display BSP created it). */
err = i2c_master_get_bus_handle(TAB5_I2C_PORT, &bus);
if (err != ESP_OK) return err;
}
i2c_device_config_t dev_cfg = {
.dev_addr_length = I2C_ADDR_BIT_LEN_7,
.device_address = TAB5_PI4IOE2_ADDR,
.scl_speed_hz = 400000,
};
i2c_master_dev_handle_t dev;
err = i2c_master_bus_add_device(bus, &dev_cfg, &dev);
if (err != ESP_OK) return err;
/* Same programming as the M5Stack Tab5 demo for PI4IOE2. */
err = pi4io_write(dev, PI4IO_REG_IO_DIR, 0xB9);
if (err == ESP_OK) err = pi4io_write(dev, PI4IO_REG_OUT_SET, 0x09);
if (err == ESP_OK) err = pi4io_write(dev, PI4IO_REG_OUT_H_IM, 0x06);
if (err == ESP_OK) err = pi4io_write(dev, PI4IO_REG_PULL_EN, 0xF9);
if (err == ESP_OK) err = pi4io_write(dev, PI4IO_REG_PULL_SEL, 0xB9);
if (err == ESP_OK) {
/* WLAN_PWR_EN = bit 0 high (read-modify-write like bsp_set_wifi_power_enable). */
uint8_t reg = PI4IO_REG_OUT_SET;
uint8_t cur = 0;
if (i2c_master_transmit_receive(dev, &reg, 1, &cur, 1, 100) == ESP_OK) {
err = pi4io_write(dev, PI4IO_REG_OUT_SET, (uint8_t)(cur | 0x01));
} else {
err = pi4io_write(dev, PI4IO_REG_OUT_SET, 0x09);
}
}
i2c_master_bus_rm_device(dev);
if (err != ESP_OK) return err;
vTaskDelay(pdMS_TO_TICKS(200)); /* rail settle before the C6 reset sequence */
return ESP_OK;
}

esp_err_t pocketjs_board_prepare_wifi(void) {
if (s_prepared) return ESP_OK;
ESP_LOGI(TAG, "Tab5: enabling the WLAN power rail");
esp_err_t err = tab5_power_wlan();
if (err != ESP_OK) {
ESP_LOGE(TAG, "Tab5: WLAN_PWR_EN failed: %s", esp_err_to_name(err));
return err;
}
ESP_LOGI(TAG, "Tab5: starting the esp_hosted SDIO transport to the C6");
int rc = esp_hosted_init();
if (rc != 0) {
ESP_LOGE(TAG, "esp_hosted_init: %d", rc);
return ESP_FAIL;
}
rc = esp_hosted_connect_to_slave();
if (rc != 0) {
ESP_LOGE(TAG, "esp_hosted_connect_to_slave: %d", rc);
return ESP_FAIL;
}
s_prepared = true;
return ESP_OK;
}
#else
esp_err_t pocketjs_board_prepare_wifi(void) {
ESP_LOGI(TAG, "native Wi-Fi: no board preparation needed");
return ESP_OK;
}
#endif
Loading