Connect an ESP32 in five minutes

You need an ESP-IDF 5.x or 6.x project that already connects to WiFi or Ethernet, and a Pinscope account.

1. Connect

Add the component

Declare it as a dependency in your project's main/idf_component.yml. The build downloads it the next time it runs.

dependencies:
  pinscope:
    git: https://github.com/fluxcorelabs/pinscope-esp.git

The source is yours to read and to change. Its licence allows use with the Pinscope service only.

Enter your project key

In the dashboard, open your project and the tab Connect a device and copy the project key. Enter it in idf.py menuconfig under Pinscope. The same key goes into every device of the project; each device registers itself.

Add CONFIG_LWIP_TCP_RECVMBOX_SIZE=32 to your sdkconfig.defaults as well, so that large downloads through the tunnel do not pause (see Options).

Start it

#include "pinscope.h"

void app_main(void)
{
    /* your WiFi and web server setup */

    pinscope_start(NULL);
}

Call it once, any time after esp_netif_init() and esp_event_loop_create_default(). It does not need the network to be up yet and it reconnects by itself. Flash the firmware and the device appears under Devices.

2. Reach the device

Whatever listens on port 80 of the device is now served at the device's own HTTPS address, shown on its page in the dashboard. Open web UI takes you there. Pages, JSON APIs and WebSockets pass through unchanged, including headers, cookies and HTTP Basic authentication of your own server.

3. Logs and metrics

Output written with ESP_LOG is forwarded automatically. In the Logs tab you can follow it live, jump to a date and time, load older lines and search everything that is stored.

These are reported with no code, once a minute:

MetricMeaning
heap_free, heap_min, heap_largestFree heap now, the lowest it has been, the largest free block
cpu_load, cpu/<task>Overall CPU load and the share of each task (switch on PINSCOPE_METRIC_TASK_CPU)
stack_min, stack/<task>Least free stack of any task, and of each task
rssi, wifi_disconnectsWiFi signal and how often the connection dropped
chip_temp_c, uptime_sChip temperature (where there is a sensor) and time since boot

Send your own from any task:

pinscope_metric("water_level_cm", 42.5);

4. Crashes

When the firmware panics, the reason, the task and the backtrace are kept in RTC memory across the reboot and sent on the next connection. They appear in the Crashes tab.

To read function names and line numbers instead of addresses, upload build/<project>.elf of that build under the project's Firmware tab. Builds are matched by the ELF hash the device reports, so a crash is only decoded with the ELF it came from. Without the ELF the dashboard shows the addresses and the addr2line command to decode them yourself.

5. Firmware updates

The partition table needs two OTA application slots. Then upload build/<project>.bin under Firmware and press Update firmware on a device. The image travels over the existing connection, is written to the spare slot with esp_ota, verified and booted.

With CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE, the new image is confirmed when it reaches the server. An image that never does is replaced by the previous one at the next reset. Images built for a different chip are refused.

6. Alerts

Under the project's Alerts tab, choose to be told when a device stays offline longer than a set time (and when it returns) and when it crashes. Alerts go to your email, to a webhook, or both. The webhook receives a JSON POST:

{
  "event": "offline",
  "title": "pump-station-07 is offline",
  "detail": "It has not been connected for 5 minutes.",
  "at": "2026-10-10T14:21:07.000Z",
  "project": { "id": 1, "name": "Pumps" },
  "device": { "id": 7, "name": "pump-station-07", "hardwareId": "84f703d9f0bc", "url": "https://..." }
}

event is offline, online, crash or test.

7. Scripts and rules

A script or a backend reaches a device with an API token from the project's API tokens tab:

curl -H "Authorization: Bearer pst_..." https://<device address>/api/status

The token is removed before the request reaches the device. If your device's own API needs the Authorization header, send the token as X-Pinscope-Token instead.

Request rules refuse chosen requests on the server, for everyone or only for share links or API tokens. For example: block POST /update for share links, so a guest can look but not flash.

Memory

The component needs 9.3 KB of RAM in total while connected, and 17 KB of flash. The sizes are fixed; there is nothing to tune. It brings no TLS library: the connection to Pinscope uses the Noise protocol (X25519, ChaCha20-Poly1305) on port 443. Measured on an ESP32-S2, as the free heap of the same application built with and without Pinscope:

Bytes
Frame buffers (1 KB out, 512 B in) and the log and metric queue (2 KB)3,625
Rest of the state, and code it places in IRAM1,239
Task stack3,072
Task, lock and the socket to Pinscope1,604
Total9,540

Opening the device's page adds no buffers of Pinscope's own. Each open tunnelled connection uses two lwIP sockets; two at a time fit the stock CONFIG_LWIP_MAX_SOCKETS=10.

Options

Everything is under Pinscope in menuconfig, and every kind of reported data has its own switch under Reported data: device info, log capture, crash reports, and each group of metrics. The same choices can be made in code, where every field is optional:

pinscope_config_t cfg = {
    .ports = { 80, 8080 },
    .metrics = PINSCOPE_M_HEAP | PINSCOPE_M_RSSI,
    .log_capture = PINSCOPE_OFF,
};
pinscope_start(&cfg);

Set CONFIG_LWIP_TCP_RECVMBOX_SIZE=32 in your sdkconfig.defaults: with lwIP's default of 6, packets are dropped when the device's web server answers in many small pieces, and large downloads through the tunnel pause for seconds. The component logs a warning when it is missing. CPU usage per task is off by default: it switches on FreeRTOS run-time statistics for the whole firmware, which read a timer on every context switch. Crash capture wraps esp_panic_handler; switch it off if your application wraps that function itself.