Step 2.1: web server, embedded UI, /api/status, /api/reboot

- aes67_web: httpd (wildcard URI matching), serves web/index.html embedded
  via EMBED_TXTFILES, status registry (status_register) and route registry
  (web_register_uri), GET /api/status, POST /api/reboot (restarts 0.5 s
  after the response).
- Status fields come from each core component: aes67_net (ip, aes67_ip,
  mac, link), aes67_health (fw, uptime_s, heap_free, psram_free),
  aes67_tx stub (tx_packets, underruns = 0). No ptp object yet, so the UI
  shows "no data".
- Verified on board: / serves index.html byte-identical (27601 B),
  /api/status returns live values, reboot via API returns in ~12 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 22:56:06 +10:00
parent 69b4e18118
commit 35275b8ccf
12 changed files with 205 additions and 14 deletions
+2 -1
View File
@@ -1,2 +1,3 @@
idf_component_register(SRCS "aes67_health.c" idf_component_register(SRCS "aes67_health.c"
INCLUDE_DIRS "include") INCLUDE_DIRS "include"
PRIV_REQUIRES aes67_web esp_app_format esp_timer heap)
+14 -2
View File
@@ -1,7 +1,19 @@
#include "aes67_health.h" #include "aes67_health.h"
// Stub (build step 0). #include "aes67_web.h"
#include "esp_app_desc.h"
#include "esp_heap_caps.h"
#include "esp_timer.h"
static void health_status(cJSON *st)
{
cJSON_AddStringToObject(st, "fw", esp_app_get_description()->version);
cJSON_AddNumberToObject(st, "uptime_s", (double)(esp_timer_get_time() / 1000000));
cJSON_AddNumberToObject(st, "heap_free", heap_caps_get_free_size(MALLOC_CAP_INTERNAL));
cJSON_AddNumberToObject(st, "psram_free", heap_caps_get_free_size(MALLOC_CAP_SPIRAM));
}
esp_err_t aes67_health_init(void) esp_err_t aes67_health_init(void)
{ {
return ESP_OK; return status_register(health_status);
} }
@@ -4,4 +4,5 @@
#include "esp_err.h" #include "esp_err.h"
// Registers the health fields (fw, uptime_s, heap_free, psram_free) in /api/status.
esp_err_t aes67_health_init(void); esp_err_t aes67_health_init(void);
+2 -1
View File
@@ -1,3 +1,4 @@
idf_component_register(SRCS "aes67_net.c" idf_component_register(SRCS "aes67_net.c"
INCLUDE_DIRS "include" INCLUDE_DIRS "include"
REQUIRES esp_eth esp_netif esp_event) REQUIRES esp_eth esp_netif esp_event
PRIV_REQUIRES aes67_web)
+31 -2
View File
@@ -1,5 +1,8 @@
#include "aes67_net.h" #include "aes67_net.h"
#include <stdio.h>
#include "aes67_web.h"
#include "esp_eth.h" #include "esp_eth.h"
#include "esp_event.h" #include "esp_event.h"
#include "esp_log.h" #include "esp_log.h"
@@ -7,6 +10,9 @@
static const char *TAG = "net"; static const char *TAG = "net";
static esp_netif_t *s_netif;
static char s_link[24] = "down";
static void eth_event(void *arg, esp_event_base_t base, int32_t id, void *data) static void eth_event(void *arg, esp_event_base_t base, int32_t id, void *data)
{ {
esp_eth_handle_t eth = *(esp_eth_handle_t *)data; esp_eth_handle_t eth = *(esp_eth_handle_t *)data;
@@ -22,10 +28,13 @@ static void eth_event(void *arg, esp_event_base_t base, int32_t id, void *data)
ESP_LOGI(TAG, "link up: %s Mbps %s duplex, MAC %02x:%02x:%02x:%02x:%02x:%02x", ESP_LOGI(TAG, "link up: %s Mbps %s duplex, MAC %02x:%02x:%02x:%02x:%02x:%02x",
speed == ETH_SPEED_100M ? "100" : "10", duplex == ETH_DUPLEX_FULL ? "full" : "half", speed == ETH_SPEED_100M ? "100" : "10", duplex == ETH_DUPLEX_FULL ? "full" : "half",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
snprintf(s_link, sizeof(s_link), "up, %s Mbps %s", speed == ETH_SPEED_100M ? "100" : "10",
duplex == ETH_DUPLEX_FULL ? "full" : "half");
break; break;
} }
case ETHERNET_EVENT_DISCONNECTED: case ETHERNET_EVENT_DISCONNECTED:
ESP_LOGW(TAG, "link down"); ESP_LOGW(TAG, "link down");
snprintf(s_link, sizeof(s_link), "down");
break; break;
default: default:
break; break;
@@ -39,6 +48,25 @@ static void ip_event(void *arg, esp_event_base_t base, int32_t id, void *data)
IP2STR(&ev->ip_info.ip), IP2STR(&ev->ip_info.netmask), IP2STR(&ev->ip_info.gw)); IP2STR(&ev->ip_info.ip), IP2STR(&ev->ip_info.netmask), IP2STR(&ev->ip_info.gw));
} }
static void net_status(cJSON *st)
{
char ip[16] = "", mac_s[18] = "";
esp_netif_ip_info_t info;
if (esp_netif_get_ip_info(s_netif, &info) == ESP_OK && info.ip.addr) {
snprintf(ip, sizeof(ip), IPSTR, IP2STR(&info.ip));
}
uint8_t mac[6];
if (esp_netif_get_mac(s_netif, mac) == ESP_OK) {
snprintf(mac_s, sizeof(mac_s), "%02x:%02x:%02x:%02x:%02x:%02x",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
}
// Single untagged interface until the VLAN split: AES67 and web share it.
cJSON_AddStringToObject(st, "ip", ip);
cJSON_AddStringToObject(st, "aes67_ip", ip);
cJSON_AddStringToObject(st, "mac", mac_s);
cJSON_AddStringToObject(st, "link", s_link);
}
esp_err_t aes67_net_init(esp_eth_handle_t eth) esp_err_t aes67_net_init(esp_eth_handle_t eth)
{ {
ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_netif_init());
@@ -48,11 +76,12 @@ esp_err_t aes67_net_init(esp_eth_handle_t eth)
} }
esp_netif_config_t netif_cfg = ESP_NETIF_DEFAULT_ETH(); esp_netif_config_t netif_cfg = ESP_NETIF_DEFAULT_ETH();
esp_netif_t *netif = esp_netif_new(&netif_cfg); s_netif = esp_netif_new(&netif_cfg);
ESP_ERROR_CHECK(esp_netif_attach(netif, esp_eth_new_netif_glue(eth))); ESP_ERROR_CHECK(esp_netif_attach(s_netif, esp_eth_new_netif_glue(eth)));
ESP_ERROR_CHECK(esp_event_handler_register(ETH_EVENT, ESP_EVENT_ANY_ID, eth_event, NULL)); ESP_ERROR_CHECK(esp_event_handler_register(ETH_EVENT, ESP_EVENT_ANY_ID, eth_event, NULL));
ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_ETH_GOT_IP, ip_event, NULL)); ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_ETH_GOT_IP, ip_event, NULL));
status_register(net_status);
return esp_eth_start(eth); return esp_eth_start(eth);
} }
+2 -1
View File
@@ -1,2 +1,3 @@
idf_component_register(SRCS "aes67_tx.c" idf_component_register(SRCS "aes67_tx.c"
INCLUDE_DIRS "include") INCLUDE_DIRS "include"
PRIV_REQUIRES aes67_web)
+10 -2
View File
@@ -1,7 +1,15 @@
#include "aes67_tx.h" #include "aes67_tx.h"
// Stub (build step 0). #include "aes67_web.h"
// Stub (build step 2): counters stay 0 until the RTP sender exists (step 4).
static void tx_status(cJSON *st)
{
cJSON_AddNumberToObject(st, "tx_packets", 0);
cJSON_AddNumberToObject(st, "underruns", 0);
}
esp_err_t aes67_tx_init(void) esp_err_t aes67_tx_init(void)
{ {
return ESP_OK; return status_register(tx_status);
} }
+4 -1
View File
@@ -1,2 +1,5 @@
idf_component_register(SRCS "aes67_web.c" idf_component_register(SRCS "aes67_web.c"
INCLUDE_DIRS "include") INCLUDE_DIRS "include"
REQUIRES esp_http_server json
PRIV_REQUIRES esp_timer
EMBED_TXTFILES "${PROJECT_DIR}/web/index.html")
+117 -2
View File
@@ -1,7 +1,122 @@
#include "aes67_web.h" #include "aes67_web.h"
// Stub (build step 0). #include "esp_log.h"
esp_err_t aes67_web_init(void) #include "esp_system.h"
#include "esp_timer.h"
#define MAX_STATUS_CBS 16
#define MAX_URIS 32
#define MAX_EARLY_URIS 16
static const char *TAG = "web";
extern const char index_html_start[] asm("_binary_index_html_start");
extern const char index_html_end[] asm("_binary_index_html_end");
static httpd_handle_t s_httpd;
static status_cb_t s_status_cbs[MAX_STATUS_CBS];
static int s_status_n;
static httpd_uri_t s_early_uris[MAX_EARLY_URIS];
static int s_early_n;
esp_err_t status_register(status_cb_t cb)
{ {
if (s_status_n >= MAX_STATUS_CBS) {
return ESP_ERR_NO_MEM;
}
s_status_cbs[s_status_n++] = cb;
return ESP_OK;
}
esp_err_t web_register_uri(const httpd_uri_t *uri)
{
if (s_httpd) {
return httpd_register_uri_handler(s_httpd, uri);
}
if (s_early_n >= MAX_EARLY_URIS) {
return ESP_ERR_NO_MEM;
}
s_early_uris[s_early_n++] = *uri;
return ESP_OK;
}
esp_err_t web_send_json(httpd_req_t *req, const cJSON *json)
{
char *body = cJSON_PrintUnformatted(json);
if (!body) {
return httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "out of memory");
}
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Cache-Control", "no-store");
esp_err_t err = httpd_resp_sendstr(req, body);
cJSON_free(body);
return err;
}
static esp_err_t index_get(httpd_req_t *req)
{
httpd_resp_set_type(req, "text/html; charset=utf-8");
// EMBED_TXTFILES appends a NUL; don't send it.
return httpd_resp_send(req, index_html_start, index_html_end - index_html_start - 1);
}
static esp_err_t status_get(httpd_req_t *req)
{
cJSON *st = cJSON_CreateObject();
for (int i = 0; i < s_status_n; i++) {
s_status_cbs[i](st);
}
esp_err_t err = web_send_json(req, st);
cJSON_Delete(st);
return err;
}
static void reboot_cb(void *arg)
{
esp_restart();
}
static esp_err_t reboot_post(httpd_req_t *req)
{
ESP_LOGW(TAG, "reboot requested via API");
httpd_resp_sendstr(req, "");
// Let the response go out before restarting.
const esp_timer_create_args_t args = { .callback = reboot_cb, .name = "reboot" };
esp_timer_handle_t t;
if (esp_timer_create(&args, &t) == ESP_OK) {
esp_timer_start_once(t, 500 * 1000);
}
return ESP_OK;
}
esp_err_t aes67_web_start(void)
{
httpd_config_t cfg = HTTPD_DEFAULT_CONFIG();
cfg.max_uri_handlers = MAX_URIS;
cfg.stack_size = 8192;
cfg.lru_purge_enable = true;
cfg.uri_match_fn = httpd_uri_match_wildcard;
esp_err_t err = httpd_start(&s_httpd, &cfg);
if (err != ESP_OK) {
ESP_LOGE(TAG, "httpd start failed: %s", esp_err_to_name(err));
return err;
}
static const httpd_uri_t core[] = {
{ .uri = "/", .method = HTTP_GET, .handler = index_get },
{ .uri = "/index.html", .method = HTTP_GET, .handler = index_get },
{ .uri = "/api/status", .method = HTTP_GET, .handler = status_get },
{ .uri = "/api/reboot", .method = HTTP_POST, .handler = reboot_post },
};
for (int i = 0; i < sizeof(core) / sizeof(core[0]); i++) {
httpd_register_uri_handler(s_httpd, &core[i]);
}
for (int i = 0; i < s_early_n; i++) {
httpd_register_uri_handler(s_httpd, &s_early_uris[i]);
}
ESP_LOGI(TAG, "httpd started, index.html %d bytes, %d project routes",
(int)(index_html_end - index_html_start - 1), s_early_n);
return ESP_OK; return ESP_OK;
} }
+14 -1
View File
@@ -2,6 +2,19 @@
// Core component: must not depend on main/ (project code). // Core component: must not depend on main/ (project code).
#pragma once #pragma once
#include "cJSON.h"
#include "esp_err.h" #include "esp_err.h"
#include "esp_http_server.h"
esp_err_t aes67_web_init(void); // Adds fields to the /api/status object. Called on every GET /api/status.
typedef void (*status_cb_t)(cJSON *status);
// Registries: call before or after aes67_web_start(); routes registered early are added on start.
esp_err_t status_register(status_cb_t cb);
esp_err_t web_register_uri(const httpd_uri_t *uri);
// Start httpd on port 80 with the core routes (/, /api/status, /api/reboot).
esp_err_t aes67_web_start(void);
// Send a cJSON object as the response body (application/json).
esp_err_t web_send_json(httpd_req_t *req, const cJSON *json);
+2 -1
View File
@@ -1,3 +1,4 @@
idf_component_register(SRCS "main.c" idf_component_register(SRCS "main.c"
INCLUDE_DIRS "." INCLUDE_DIRS "."
REQUIRES esp_app_format esp_hw_support aes67_board aes67_net) REQUIRES esp_app_format esp_hw_support
aes67_board aes67_net aes67_health aes67_tx aes67_web)
+6
View File
@@ -1,5 +1,8 @@
#include "aes67_board.h" #include "aes67_board.h"
#include "aes67_health.h"
#include "aes67_net.h" #include "aes67_net.h"
#include "aes67_tx.h"
#include "aes67_web.h"
#include "esp_app_desc.h" #include "esp_app_desc.h"
#include "esp_chip_info.h" #include "esp_chip_info.h"
#include "esp_log.h" #include "esp_log.h"
@@ -20,4 +23,7 @@ void app_main(void)
esp_eth_handle_t eth; esp_eth_handle_t eth;
ESP_ERROR_CHECK(aes67_board_eth_init(&eth)); ESP_ERROR_CHECK(aes67_board_eth_init(&eth));
ESP_ERROR_CHECK(aes67_net_init(eth)); ESP_ERROR_CHECK(aes67_net_init(eth));
ESP_ERROR_CHECK(aes67_health_init());
ESP_ERROR_CHECK(aes67_tx_init());
ESP_ERROR_CHECK(aes67_web_start());
} }