Skip to content

Tutorial: an endpoint, live events and a UI tab

Advanced. Your firmware grows a REST endpoint of its own under /api/v1, pushes changes to browsers over the existing SSE stream, and adds a tab to the web UI — the three registration points espOS has for extending the device, in the shape of components/espos_httpd/examples/app_endpoint_and_page. Start from tank-level (a value worth showing) or the minimal example; the contract you extend is rest-api.md.

1. The endpoint

espos_httpd_register() takes an esp_http_server httpd_uri_t, and the server must be running, so register after espos_start(). Handlers run on the one HTTP server task: build the answer, send, return — anything slow stalls the whole UI. Paths under /api/v1/app/ belong to the application; espOS never uses that prefix. Add espos_httpd to PRIV_REQUIRES.

#include <stdio.h>
#include "espos_httpd.h"
#include "espos_httpd_sse.h"
static float s_level; static int32_t s_readings; /* written by the sensor loop, read by the handlers: word-sized, no lock */

static int tank_json(char *buf, size_t n)
{
    return snprintf(buf, n, "{\"level\":%.3f,\"readings\":%ld}", s_level, (long)s_readings);
}
static esp_err_t tank_get(httpd_req_t *req)
{
    char buf[64];
    tank_json(buf, sizeof(buf));
    return espos_httpd_send_json(req, NULL, buf);           /* 200, application/json */
}
static esp_err_t tank_zero_post(httpd_req_t *req)
{
    if (!espos_httpd_require_json(req)) {
        return ESP_OK;                                       /* 415 already sent: the CSRF guard of rest-api.md */
    }
    s_readings = 0;
    return espos_httpd_send_json(req, "202 Accepted", "{\"status\":\"zeroed\"}");
}
static const httpd_uri_t tank_uris[] = {
    { .uri = "/api/v1/app/tank", .method = HTTP_GET, .handler = tank_get },
    { .uri = "/api/v1/app/tank/zero", .method = HTTP_POST, .handler = tank_zero_post },
};

Errors keep the contract's shape through espos_httpd_send_error(req, "404 Not Found", "not_found", "…"); a body comes from espos_httpd_read_body() (malloc'ed, NUL-terminated; the 413 for an oversize one is sent for you).

2. Live updates over SSE

One stream, GET /api/v1/events, carries every component's events. Publish yours from the sensor loop, and hand each newly connected client a snapshot — the UI subscribes once and expects current state:

static void tank_on_connect(int client, void *arg)   /* server task: send and return */
{
    (void)arg;
    char buf[64];
    tank_json(buf, sizeof(buf));
    espos_httpd_sse_send(client, "app.tank", buf);
}
/* app_main(), after espos_start(): */
for (size_t i = 0; i < sizeof(tank_uris) / sizeof(tank_uris[0]); i++) {
    ESP_ERROR_CHECK(espos_httpd_register(&tank_uris[i]));
}
ESP_ERROR_CHECK(espos_httpd_sse_on_connect(tank_on_connect, NULL));
/* the sensor loop, after each reading: */
s_level = level, s_readings++;
char buf[64];
tank_json(buf, sizeof(buf));
espos_httpd_sse_publish("app.tank", buf);             /* any task; ESP_OK even with no client */

Sends are serialised with a 250 ms per-socket timeout, so a stalled browser costs the loop at most that; CONFIG_ESPOS_HTTPD_SSE_MAX_CLIENTS (3) streams at once, the oldest evicted when full. Flash and check:

D=http://espos-xxxx.local/api/v1
curl -s $D/app/tank                                                    # {"level":0.583,"readings":42}
curl -s -X POST -H 'Content-Type: application/json' $D/app/tank/zero  # {"status":"zeroed"}
curl -s -X POST $D/app/tank/zero                                       # 415 unsupported_media_type
curl -N $D/events                                                      # event: app.tank … on connect, then on every reading

3. A tab in the web UI

The page list is a registry (ui.md): your firmware keeps a small Vite project and calls espOS's entry point with its page registered first. Create ui/ in the project: copy package.json, tsconfig.json, index.html and scripts/gzip-dist.mjs from espos/ui/; copy vite.config.ts too, minus the mock plugin (keep preact(), build and the /api proxy). Then two files:

// ui/src/main.tsx
import { registerPage, mount } from "../../espos/ui/src/mount";
import { TankPage } from "./tank";
registerPage({ path: "/tank", title: "Tank", page: TankPage, order: 35 }); // between SignalK (30) and Config (50)
mount();
// ui/src/tank.tsx — the shell's EventSource wires only the core events, so the page listens for its own
import { useEffect, useState } from "preact/hooks";
import { BASE, get, post } from "../../espos/ui/src/api";
interface Tank { level: number; readings: number }
export function TankPage() {
  const [t, setT] = useState<Tank>();
  useEffect(() => {
    void get<Tank>("/app/tank").then(setT);
    const es = new EventSource(BASE + "/events");      // the second of the three SSE slots; closed on leave
    es.addEventListener("app.tank", (e) => setT(JSON.parse((e as MessageEvent).data) as Tank));
    return () => es.close();
  }, []);
  return (<><h1>Tank</h1><p>{t ? `${Math.round(t.level * 100)} % after ${t.readings} readings` : "…"}</p>
    <button onClick={() => void post("/app/tank/zero")}>Zero the counter</button></>);
}

npm ci && npm run build in ui/ writes ui/dist-gz/; point the partition at it in the root CMakeLists.txt — espos_project_ui_partition(DIR "${CMAKE_CURRENT_LIST_DIR}/ui/dist-gz") — rebuild and flash. http://espos-xxxx.local/tank shows the new tab, updating with every reading; ESPOS_API=http://espos-xxxx.local npm run dev develops the page against the live device without reflashing. Commit dist-gz/ with the source, as espOS does: a firmware build must never depend on Node.