# HTTP Client Component for Calendink Client **Authored by Antigravity (Claude Opus 4.6)** **Date:** 2026-03-28 --- ## 1. What (Goal) Create a reusable ESP-IDF component (`components/http_client/`) that provides a simple, synchronous HTTP client API for the Calendink Client firmware. The component must: - Wrap ESP-IDF's `esp_http_client` in a clean C++ API with ergonomic result types. - Support **GET** requests returning either text (JSON) or binary (PNG image) data. - Support **POST** requests sending a JSON body and receiving a JSON response. - Build full URLs from path fragments using the Provider's address resolved via **mDNS** (`calendink.local`). - Be shared across projects via the `components/` directory, usable by any Calendink device firmware. The initial test integration will run in the Client's `main.cpp` after WiFi connects: 1. **Register the device** — `POST /api/devices/register` with the Client's own WiFi MAC address. 2. **Fetch the screen image** — `GET /api/devices/screen.png?mac=XX:XX:XX:XX:XX:XX`, download the PNG and log the received size. ## 2. Why (Reasoning) ### 2.1. Why a Shared Component? The Client firmware needs to talk to the Provider over HTTP. This is a fundamental capability that will be used by every Client feature going forward (screen polling, status reporting, configuration sync). Placing it in `components/http_client/` follows the established pattern (`network`, `led`) and makes it available to any future Calendink firmware project. ### 2.2. Why Synchronous? The Client firmware has a simple execution model: boot → connect WiFi → register → poll screen → sleep → repeat. There is no concurrent UI or web server to keep responsive. A synchronous (blocking) API is: - Simpler to use correctly — no callbacks, no state machines. - Easier to reason about memory lifetime — buffers are allocated and freed in the same scope. - Sufficient for the single-task polling loop the Client will run. If async requests are needed later (e.g., for a multi-task architecture), `_async` variants can be added without breaking the existing API. ### 2.3. Why mDNS Instead of Hardcoded IP? The Provider already advertises itself as `calendink.local` via mDNS (`CONFIG_CALENDINK_MDNS_HOSTNAME`). Using mDNS for discovery: - **Eliminates configuration coupling** — no need to update Client firmware when the Provider's DHCP lease changes. - **Plug-and-play** — a freshly flashed Client finds the Provider automatically on the same network. - **Fallback** — if mDNS resolution fails, the Client can fall back to a Kconfig-configured static IP (configured in the Client project, not the component). The mDNS resolution happens once at startup and is cached. The `mdns` component dependency is lightweight (~15 KB flash). ### 2.4. Why Download the Full Image? The ESP32-C6 has ~320 KB of internal SRAM. A typical screen PNG is 50–200 KB depending on content complexity. For the 4-level grayscale 480×800 display, most PNGs land around 20–80 KB (LodePNG with simple content compresses well). This fits comfortably in internal RAM. If a particular PNG exceeds available heap, the `http_get_binary()` function will return `ESP_ERR_NO_MEM` — the caller can handle this gracefully. For the initial test, we'll attempt the full download and log the result either way. ## 3. How (Implementation Details) ### 3.1. Component File Structure ``` components/http_client/ ├── CMakeLists.txt # Component registration ├── idf_component.yml # Component manifest ├── http_client.hpp # Public API header └── http_client.cpp # Implementation ``` No `Kconfig.projbuild` in the component itself — the Provider address configuration belongs to the consumer (Client project). The component only provides the URL builder and HTTP functions. ### 3.2. API Surface ```cpp // ── Result Types ────────────────────────────────────────────── struct http_text_response_t { int status_code; // HTTP status (200, 404, 500, etc.) char *body; // Heap-allocated, null-terminated (caller frees) size_t body_len; // Byte length (excluding null terminator) }; struct http_binary_response_t { int status_code; uint8_t *data; // Heap-allocated binary buffer (caller frees) size_t data_len; // Byte length }; // ── Functions ───────────────────────────────────────────────── // Build "http://:" from a host, port, and path. // Returns heap-allocated string; caller must free(). char *http_build_url(const char *host, uint16_t port, const char *path); // GET a text/JSON resource. Blocks until complete. esp_err_t http_get_text(const char *url, http_text_response_t *out); // GET a binary resource (e.g. PNG). Blocks until complete. esp_err_t http_get_binary(const char *url, http_binary_response_t *out); // POST JSON body, receive JSON response. Blocks until complete. esp_err_t http_post_json(const char *url, const char *json_body, http_text_response_t *out); ``` **Design note: `http_build_url` takes host/port as parameters** rather than reading Kconfig directly. This keeps the component generic — the caller (Client `main.cpp`) passes its own Kconfig values or mDNS-resolved addresses. The component has zero Kconfig of its own. ### 3.3. Implementation Details #### Event Handler — Accumulating Response Body `esp_http_client` delivers data in chunks via an event callback. We use a single internal handler that accumulates chunks into a dynamically growing buffer: ``` HTTP_EVENT_ON_DATA → realloc(buffer, current_len + new_len) → memcpy ``` The buffer pointer is passed through the `user_data` field of `esp_http_client_config_t`. A small internal struct tracks the buffer, length, and capacity: ```cpp struct http_receive_buffer_t { char *buf; size_t len; size_t capacity; }; ``` #### Timeout Default: **10 seconds** (`timeout_ms = 10000`). Sufficient for even the largest PNG response from the Provider (encoding + transfer). Can be made configurable later if needed. #### Memory Safety - Output struct is zeroed at function entry — on any error path, the caller gets a clean struct with null pointers. - On `esp_http_client_perform()` failure, any partially accumulated buffer is freed immediately. - On success, ownership of the buffer transfers to the caller (documented: "caller must `free()`"). #### Logging Uses `kTagHttpClient = "HTTP_CLIENT"` — unique tag per Unity Build convention (though this component is not Unity Build, keeping the convention consistent is good practice for when the Client eventually adopts it). Key log points: - `ESP_LOGI` — request start (method + URL), response status + size - `ESP_LOGW` — non-2xx status codes - `ESP_LOGE` — connection failures, allocation failures ### 3.4. Client Integration #### Kconfig (in `Client/main/Kconfig.projbuild` — NOT in the component) ```kconfig menu "Calendink Client" config CALENDINK_PROVIDER_MDNS_HOSTNAME string "Provider mDNS Hostname" default "calendink" help The mDNS hostname of the Provider device. The Client resolves .local to find the Provider IP. config CALENDINK_PROVIDER_PORT int "Provider HTTP Port" default 80 help The HTTP port of the Calendink Provider. config CALENDINK_PROVIDER_FALLBACK_IP string "Provider Fallback IP (if mDNS fails)" default "" help Static IP to use if mDNS resolution fails. Leave empty to disable fallback (the Client will retry mDNS). endmenu ``` #### Client File Structure Provider communication logic is in its own file, not in `main.cpp`: ``` Client/main/ ├── CMakeLists.txt # SRCS: main.cpp, provider.cpp ├── Kconfig.projbuild # Provider connection settings ├── idf_component.yml # Dependencies: network, led, http_client, espressif/mdns ├── main.cpp # Boot, NVS, WiFi connect → calls test_provider_communication() ├── provider.hpp # Header for provider communication └── provider.cpp # mDNS resolution, device registration, screen fetch ``` #### provider.cpp Test Flow After network connects (WiFi or Ethernet): ``` 1. Resolve Provider IP via mdns_query_a("calendink", 5000, &addr) - On success: use resolved IP - On failure: runtime check strlen(CONFIG_CALENDINK_PROVIDER_FALLBACK_IP) > 0 - If set: use fallback IP - If empty: abort with error log 2. Get own MAC via get_mac_address(mac_bytes) from the network component - Returns Ethernet MAC if connected via Ethernet, WiFi MAC if via WiFi - Format as "XX:XX:XX:XX:XX:XX" 3. POST /api/devices/register - Body: {"mac": "XX:XX:XX:XX:XX:XX"} - Log: status code + response body - Free response 4. GET /api/devices/screen.png?mac=XX:XX:XX:XX:XX:XX - Download full PNG into memory - Log: status code + received byte count - Free response ``` #### Dependencies Added to Client | File | Change | |---|---| | `Client/main/idf_component.yml` | Add `http_client` (local path) + `espressif/mdns: ^1.4.1` (managed) | | `Client/main/CMakeLists.txt` | Add `http_client` and `mdns` to `PRIV_REQUIRES` | #### Network Component Changes `get_mac_address(uint8_t *mac_out)` was added to `network.hpp` / `network.cpp`. It uses `esp_netif_get_mac()` on whichever interface is active — Ethernet preferred, WiFi fallback. This keeps all `esp_wifi` usage inside the network component. ### 3.5. Component Build Configuration ```cmake idf_component_register(SRCS "http_client.cpp" INCLUDE_DIRS "." "../shared" PRIV_REQUIRES esp_http_client) ``` - `INCLUDE_DIRS "."` — consumers `#include "http_client.hpp"` - `"../shared"` — for `types.hpp` (type aliases, `internal` macro) - `PRIV_REQUIRES esp_http_client` — ESP-IDF's built-in HTTP client The `mdns` dependency is on the **Client**, not the component — the component is agnostic to how the caller discovers the host. ### 3.6. Provider API Contracts (Reference) These are the two endpoints the Client will call. They already exist and require no changes. **POST /api/devices/register** ``` Request: {"mac": "AA:BB:CC:DD:EE:FF"} Response: {"status": "ok", "mac": "AA:BB:CC:DD:EE:FF"} (201-equivalent, new device) {"status": "already_registered", "mac": "AA:BB:..."} (200, already known) Error: 400 if missing mac, 500 if device slots full ``` **GET /api/devices/screen.png?mac=AA:BB:CC:DD:EE:FF** ``` Response: image/png binary (4-level grayscale, 480×800) Error: 400 if missing mac param, 404 if device not registered, 500 on render failure ``` ## 4. Summary We add a new shared component `http_client` that wraps `esp_http_client` in a simple synchronous API with `http_get_text()`, `http_get_binary()`, and `http_post_json()`. The component is generic — it takes host/port/path and knows nothing about mDNS or Kconfig. The Client firmware integrates it by resolving the Provider via mDNS (`calendink.local`), registering itself, and fetching its screen image as a connectivity test. No Provider-side changes are needed. ## 5. Implementation Notes (Post-Development) ### Decisions Made During Implementation 1. **File separation** — Provider communication logic was extracted from `main.cpp` into `provider.cpp`/`provider.hpp` to keep `main.cpp` focused on boot/init (~78 lines). This follows the pattern of keeping `main` lean. 2. **Fallback IP check** — The original plan used a `#if` preprocessor check to test if the fallback IP string was non-empty. This doesn't work because Kconfig string values can't be indexed in preprocessor expressions. Changed to a runtime `strlen()` check, which the compiler optimizes away when the string is a compile-time constant. 3. **Interface-agnostic MAC** — `get_mac_address()` was added to the `network` component using `esp_netif_get_mac()` (instead of `esp_wifi_get_mac()`). It checks `s_eth_netif` first, then `s_wifi_netif`, making it work for both Ethernet and WiFi connections without the caller needing to know which is active. 4. **mDNS as managed component** — The Client uses `espressif/mdns: ^1.4.1` from the ESP-IDF component registry (specified in `idf_component.yml`) rather than a local path, since it's an official Espressif component. 5. **No `esp_wifi` in Client** — The Client project has no direct dependency on `esp_wifi`. All WiFi/Ethernet access goes through the `network` component. This was a deliberate architectural decision to keep the Client decoupled from transport details. ### Verified On-Device - mDNS resolution of `calendink.local` → Provider IP ✅ - `POST /api/devices/register` → 200 with device MAC ✅ - `GET /api/devices/screen.png?mac=XX` → 200 with PNG binary data ✅ --- *Created by Antigravity (Claude Opus 4.6) - 2026-03-28* *Updated: 2026-03-28 — Post-implementation notes added*