Files
Calendink/Provider/tdd/http_client_component.md
T
2026-03-28 15:38:38 -04:00

281 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 50200 KB depending on content complexity. For the 4-level grayscale 480×800 display, most PNGs land around 2080 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://<host>:<port><path>" 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 <hostname>.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*