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.
- To expose another port, change Local port to expose in menuconfig, or list up to four in
pinscope_config_t.ports. Extra ports are served at<port>-<device address>. - Share creates a link that lets someone open the web UI for 24 hours without an account. You can revoke all links of a device at once.
- If
esp_http_serveranswers slowly, setTCP_NODELAYon its connections in itsopen_fn. It sends headers and body separately, which otherwise costs about 250 ms per request on any network.
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:
| Metric | Meaning |
|---|---|
heap_free, heap_min, heap_largest | Free 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_disconnects | WiFi signal and how often the connection dropped |
chip_temp_c, uptime_s | Chip 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 IRAM | 1,239 |
| Task stack | 3,072 |
| Task, lock and the socket to Pinscope | 1,604 |
| Total | 9,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.