Compare commits
29 Commits
46dfe82568
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b34754c77 | |||
| 58948bdfb6 | |||
| 9269e3b873 | |||
| 74b3e01556 | |||
| 7e21bde538 | |||
| 2ffd258f2f | |||
| f139ee8a00 | |||
| bf91dc1af6 | |||
| 04264ef8e1 | |||
| e30fd59f1c | |||
| be735990ff | |||
| e5b72bbb20 | |||
| 2ced2f0b0a | |||
| 46d1ab6358 | |||
| a886b9aa11 | |||
| 7ba6ab56e7 | |||
| 7d3d1de277 | |||
| dc935dd72d | |||
| 197f4a640c | |||
| f42236532f | |||
| f483a4d292 | |||
| cfe19d5e65 | |||
| 71d618f28c | |||
| e4b8ab4586 | |||
| 2c79be36ef | |||
| 7f296f9857 | |||
| f64860125c | |||
| ebb0ccecf4 | |||
| baa0a8b1ba |
+4
-1
@@ -92,7 +92,10 @@ external/*
|
||||
**/frontend/dist/
|
||||
|
||||
# Agent Tasks
|
||||
Provider/AgentTasks/
|
||||
# Provider/AgentTasks/
|
||||
|
||||
# OTA files
|
||||
*.bundle
|
||||
|
||||
#png
|
||||
*.png
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# EPD Implementation Reference — GDEY075T7 (UC8179)
|
||||
|
||||
This document describes the current working state of the UC8179 E-Paper driver as implemented in `components/EPD/epd.cpp`.
|
||||
|
||||
## Panel Info
|
||||
|
||||
- **Controller:** UC8179 (Good Display)
|
||||
- **Panel:** GDEY075T7 (7.5" B/W, 800×480)
|
||||
- **Platform:** ESP-IDF (Native SPI2 Host)
|
||||
- **Status:** **Fully Operational** (B/W and 4-Level Grayscale)
|
||||
|
||||
## Hardware Interface
|
||||
|
||||
### BUSY Pin Polarity
|
||||
- **Logic:** LOW = Busy, HIGH = Idle
|
||||
- **Wait loop:** `while (gpio_get_level(BUSY) == 0) { vTaskDelay(5); }`
|
||||
|
||||
### SPI Configuration
|
||||
- **Host:** `SPI2_HOST`
|
||||
- **Clock:** `SPI_FREQUENCY` (2MHz typically for EPD)
|
||||
- **Mode:** SPI Mode 0
|
||||
- **Transfer Size:** Chunked into 4096-byte buffers to avoid DMA limits and task watchdogs.
|
||||
|
||||
## Key Implementation Details
|
||||
|
||||
### Data Polarity & VCOM (0x50)
|
||||
We use `0x29` for VCOM and Data Interval:
|
||||
- **VBD:** `00` (Floating/Black border?)
|
||||
- **N2OCP:** `1` (Auto-copy NEW data to OLD data after refresh)
|
||||
- **DDX:** `01` (Data Polarity: 0=Black, 1=White)
|
||||
- **Note:** In our 4-gray unpacking, we send `~output_byte`, effectively inverting the logic to match the panel's expectation for the LUTs used by the UC8179.
|
||||
|
||||
### Refresh Management
|
||||
The driver tracks refresh history to prevent ghosting or panel damage:
|
||||
- **Full Refresh:** Required every 5 fast refreshes or after 24 hours.
|
||||
- **Fast Refresh (B/W):** Optimized waveforms via `0xE5` -> `0x5A`.
|
||||
- **4-Gray Refresh:** Optimized waveforms via `0xE5` -> `0x5F`.
|
||||
|
||||
## Grayscale Mapping (4-Level)
|
||||
|
||||
The input is a packed 2bpp bitmap (4 pixels per byte). We unpack this into two sequential data layers: **Old (0x10)** and **New (0x13)**.
|
||||
|
||||
| Gray Level | Input Bits (2bpp) | Old Layer (0x10) | New Layer (0x13) | Final Value (Inverted) |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **White** | `11` | 1 | 1 | `00` (Low) |
|
||||
| **Gray 1** | `10` | 0 | 1 | `10` |
|
||||
| **Gray 2** | `01` | 1 | 0 | `01` |
|
||||
| **Black** | `00` | 0 | 0 | `11` (High) |
|
||||
|
||||
*Note: The hardware logic for 4-gray on UC8179 drives the pixels based on the difference between the Old and New layers over multiple sub-frames.*
|
||||
|
||||
## Driver State Machine
|
||||
|
||||
### Initialization (Full)
|
||||
1. **Hardware Reset:** RST Low (10ms), RST High (10ms).
|
||||
2. **Power Setting (0x01):** `0x07, 0x07, 0x3f, 0x3f`.
|
||||
3. **Booster Soft Start (0x06):** `0x17, 0x17, 0x28, 0x17`.
|
||||
4. **Power On (0x04):** Wait 100ms + Busy Idle.
|
||||
5. **Panel Setting (0x00):** `0x1F` (800x480, KW Mode).
|
||||
6. **Resolution (0x61):** `800×480`.
|
||||
7. **VCOM (0x50):** `0x29, 0x07`.
|
||||
|
||||
### Fast Mode Adjustments
|
||||
- **Booster (0x06):** `0x27, 0x27, 0x18, 0x17` (Stronger drive for fast transitions).
|
||||
- **Fast Mode Enable (0xE0):** `0x02`.
|
||||
- **Timing (0xE5):** `0x5A` (B/W) or `0x5F` (4-Gray).
|
||||
|
||||
### Sleep Sequence
|
||||
1. **VCOM pre-sleep (0x50):** `0xF7`.
|
||||
2. **Power Off (0x02):** Wait for Busy Idle.
|
||||
3. **Deep Sleep (0x07):** `0xA5` (Check code).
|
||||
@@ -0,0 +1,7 @@
|
||||
# The following five lines of boilerplate have to be in your project's
|
||||
# CMakeLists in this exact order for cmake to work correctly
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||
idf_build_set_property(MINIMAL_BUILD ON)
|
||||
project(Client)
|
||||
@@ -0,0 +1,183 @@
|
||||
dependencies:
|
||||
epd:
|
||||
dependencies:
|
||||
- name: idf
|
||||
version: '>=5.0.0'
|
||||
source:
|
||||
path: C:\Dev\Classified\Calendink\components\epd
|
||||
type: local
|
||||
version: 1.0.0
|
||||
espressif/ethernet_init:
|
||||
component_hash: 9f7d29acf5fe32315579ddb6247388291cda555aa529409108c0ada0aa7cd99d
|
||||
dependencies:
|
||||
- matches:
|
||||
- if: $CONFIG{ETHERNET_SPI_USE_CH390} == 1
|
||||
name: espressif/ch390
|
||||
registry_url: https://components.espressif.com
|
||||
require: private
|
||||
version: '*'
|
||||
- matches:
|
||||
- if: $CONFIG{ETHERNET_SPI_USE_DM9051} == 1
|
||||
name: espressif/dm9051
|
||||
registry_url: https://components.espressif.com
|
||||
require: private
|
||||
rules:
|
||||
- if: idf_version >=6.0
|
||||
version: ^1.0.0
|
||||
version: '*'
|
||||
- matches:
|
||||
- if: idf_version >=6.0
|
||||
version: ^1.0.0
|
||||
name: espressif/dp83848
|
||||
registry_url: https://components.espressif.com
|
||||
require: private
|
||||
rules:
|
||||
- if: target in [esp32, esp32p4]
|
||||
- if: $CONFIG{ETHERNET_PHY_USE_DP83848} == 1
|
||||
version: '*'
|
||||
- matches:
|
||||
- if: $CONFIG{ETHERNET_SPI_USE_ENC28J60} == 1
|
||||
name: espressif/enc28j60
|
||||
registry_url: https://components.espressif.com
|
||||
require: private
|
||||
version: '*'
|
||||
- matches:
|
||||
- if: idf_version >=6.0
|
||||
version: ^1.0.0
|
||||
name: espressif/ip101
|
||||
registry_url: https://components.espressif.com
|
||||
require: private
|
||||
rules:
|
||||
- if: target in [esp32, esp32p4]
|
||||
- if: $CONFIG{ETHERNET_PHY_USE_IP101} == 1
|
||||
version: '*'
|
||||
- matches:
|
||||
- if: idf_version >=6.0
|
||||
version: ^1.0.0
|
||||
name: espressif/ksz80xx
|
||||
registry_url: https://components.espressif.com
|
||||
require: private
|
||||
rules:
|
||||
- if: target in [esp32, esp32p4]
|
||||
- if: $CONFIG{ETHERNET_PHY_USE_KSZ80XX} == 1
|
||||
version: '*'
|
||||
- matches:
|
||||
- if: $CONFIG{ETHERNET_SPI_USE_KSZ8851SNL} == 1
|
||||
name: espressif/ksz8851snl
|
||||
registry_url: https://components.espressif.com
|
||||
require: private
|
||||
rules:
|
||||
- if: idf_version >=6.0
|
||||
version: ^1.0.0
|
||||
version: '*'
|
||||
- matches:
|
||||
- if: $CONFIG{ETHERNET_SPI_USE_LAN865X} == 1
|
||||
name: espressif/lan865x
|
||||
registry_url: https://components.espressif.com
|
||||
require: private
|
||||
version: ^0.1.1
|
||||
- name: espressif/lan867x
|
||||
registry_url: https://components.espressif.com
|
||||
require: private
|
||||
rules:
|
||||
- if: target in [esp32, esp32p4]
|
||||
- if: $CONFIG{ETHERNET_PHY_USE_LAN867X} == 1
|
||||
version: '>=2.0.0'
|
||||
- matches:
|
||||
- if: idf_version >=6.0
|
||||
version: ^1.0.0
|
||||
name: espressif/lan87xx
|
||||
registry_url: https://components.espressif.com
|
||||
require: private
|
||||
rules:
|
||||
- if: target in [esp32, esp32p4]
|
||||
- if: $CONFIG{ETHERNET_PHY_USE_LAN87XX} == 1
|
||||
version: '*'
|
||||
- matches:
|
||||
- if: idf_version >=6.0
|
||||
version: ^1.0.0
|
||||
name: espressif/rtl8201
|
||||
registry_url: https://components.espressif.com
|
||||
require: private
|
||||
rules:
|
||||
- if: target in [esp32, esp32p4]
|
||||
- if: $CONFIG{ETHERNET_PHY_USE_RTL8201} == 1
|
||||
version: '*'
|
||||
- matches:
|
||||
- if: $CONFIG{ETHERNET_SPI_USE_W5500} == 1
|
||||
name: espressif/w5500
|
||||
registry_url: https://components.espressif.com
|
||||
require: private
|
||||
rules:
|
||||
- if: idf_version >=6.0
|
||||
version: ^1.0.0
|
||||
version: '*'
|
||||
- name: idf
|
||||
require: private
|
||||
version: '>=5.4.3,!=5.5.0,!=5.5.1'
|
||||
source:
|
||||
registry_url: https://components.espressif.com/
|
||||
type: service
|
||||
version: 1.3.0
|
||||
espressif/led_strip:
|
||||
component_hash: 28621486f77229aaf81c71f5e15d6fbf36c2949cf11094e07090593e659e7639
|
||||
dependencies:
|
||||
- name: idf
|
||||
require: private
|
||||
version: '>=5.0'
|
||||
source:
|
||||
registry_url: https://components.espressif.com/
|
||||
type: service
|
||||
version: 3.0.3
|
||||
espressif/mdns:
|
||||
component_hash: 1ebe3bd675bb9d1c58f52bc0b609b32f74e572b01c328f9e61282040c775495c
|
||||
dependencies:
|
||||
- name: idf
|
||||
require: private
|
||||
version: '>=5.0'
|
||||
source:
|
||||
registry_url: https://components.espressif.com/
|
||||
type: service
|
||||
version: 1.11.0
|
||||
http_client:
|
||||
dependencies:
|
||||
- name: idf
|
||||
version: '>=5.0.0'
|
||||
source:
|
||||
path: C:\Dev\Classified\Calendink\components\http_client
|
||||
type: local
|
||||
version: 1.0.0
|
||||
idf:
|
||||
source:
|
||||
type: idf
|
||||
version: 5.5.3
|
||||
led:
|
||||
dependencies:
|
||||
- name: idf
|
||||
version: '>=4.1.0'
|
||||
- name: espressif/led_strip
|
||||
version: ^3.0.3
|
||||
source:
|
||||
path: C:\Dev\Classified\Calendink\components\led
|
||||
type: local
|
||||
version: 1.0.0
|
||||
network:
|
||||
dependencies:
|
||||
- name: idf
|
||||
version: '>=4.1.0'
|
||||
- name: espressif/ethernet_init
|
||||
version: ^1.3.0
|
||||
source:
|
||||
path: C:\Dev\Classified\Calendink\components\network
|
||||
type: local
|
||||
version: 1.0.0
|
||||
direct_dependencies:
|
||||
- epd
|
||||
- espressif/mdns
|
||||
- http_client
|
||||
- idf
|
||||
- led
|
||||
- network
|
||||
manifest_hash: 534458d93ffd9c5d8b40be759ddfb47940a38560cb0565b9d0ba18f8ec7510b7
|
||||
target: esp32c6
|
||||
version: 2.0.0
|
||||
@@ -0,0 +1,5 @@
|
||||
idf_component_register(SRCS "main.cpp" "provider.cpp"
|
||||
PRIV_REQUIRES driver nvs_flash
|
||||
esp_event esp_timer
|
||||
led network http_client mdns epd
|
||||
INCLUDE_DIRS ".")
|
||||
@@ -0,0 +1,23 @@
|
||||
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 device.
|
||||
|
||||
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.
|
||||
|
||||
endmenu
|
||||
@@ -0,0 +1,13 @@
|
||||
## IDF Component Manager Manifest File
|
||||
dependencies:
|
||||
idf:
|
||||
version: '>=4.1.0'
|
||||
espressif/mdns: ^1.4.1
|
||||
network:
|
||||
path: "../../components/network"
|
||||
led:
|
||||
path: "../../components/led"
|
||||
http_client:
|
||||
path: "../../components/http_client"
|
||||
epd:
|
||||
path: "../../components/epd"
|
||||
@@ -0,0 +1,108 @@
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
|
||||
#include "sdkconfig.h"
|
||||
|
||||
#include "esp_event.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_system.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "nvs.h"
|
||||
#include "nvs_flash.h"
|
||||
#include "esp_sleep.h"
|
||||
#include "epd.hpp"
|
||||
#include "led.hpp"
|
||||
#include "network.hpp"
|
||||
#include "provider.hpp"
|
||||
|
||||
#include "test_image.h"
|
||||
|
||||
static const char *TAG = "ClientMain";
|
||||
|
||||
extern "C" void app_main()
|
||||
{
|
||||
ESP_LOGI(TAG, "Hello, Calendink Client!");
|
||||
|
||||
// Initialize NVS
|
||||
esp_err_t ret = nvs_flash_init();
|
||||
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND)
|
||||
{
|
||||
ESP_ERROR_CHECK(nvs_flash_erase());
|
||||
ret = nvs_flash_init();
|
||||
}
|
||||
ESP_ERROR_CHECK(ret);
|
||||
|
||||
ESP_ERROR_CHECK(esp_event_loop_create_default());
|
||||
|
||||
setup_led();
|
||||
|
||||
static uint8 display_buffer[96000];
|
||||
bool received_from_provider = false;
|
||||
|
||||
// Connect to WiFi
|
||||
ESP_LOGI(TAG, "Initializing WiFi connection");
|
||||
initialize_network();
|
||||
|
||||
esp_err_t err = connect_wifi(CONFIG_CALENDINK_WIFI_SSID,
|
||||
CONFIG_CALENDINK_WIFI_PASSWORD, false);
|
||||
|
||||
if (err == ESP_OK)
|
||||
{
|
||||
uint8_t retries = 1;
|
||||
do
|
||||
{
|
||||
err = check_wifi_connection(retries);
|
||||
if (err != ESP_OK)
|
||||
{
|
||||
ESP_LOGW(TAG, "WiFi connection check timeout, retrying... (%d)",
|
||||
retries);
|
||||
led_blink_number(3, 255, 0, 0);
|
||||
}
|
||||
retries++;
|
||||
} while (err == ESP_ERR_TIMEOUT &&
|
||||
retries <= CONFIG_CALENDINK_WIFI_RETRIES);
|
||||
}
|
||||
|
||||
if (err == ESP_OK)
|
||||
{
|
||||
ESP_LOGI(TAG, "Successfully connected to WiFi!");
|
||||
ESP_LOGI(TAG, "Fetching screen from Provider...");
|
||||
received_from_provider = test_provider_communication(display_buffer, sizeof(display_buffer));
|
||||
ESP_LOGI(TAG, "Provider result: %s", received_from_provider ? "SUCCESS" : "FAILED");
|
||||
}
|
||||
else
|
||||
{
|
||||
ESP_LOGE(TAG, "Failed to connect to WiFi.");
|
||||
}
|
||||
|
||||
turn_off_led();
|
||||
|
||||
ESP_LOGI(TAG, "Initializing EPD");
|
||||
epd_init();
|
||||
epd_init_display(true);
|
||||
|
||||
if (received_from_provider) {
|
||||
ESP_LOGI(TAG, "Drawing image from Provider");
|
||||
epd_draw_bitmap_grayscale(epd_color::WHITE, display_buffer);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Drawing fallback test image");
|
||||
epd_draw_bitmap_grayscale(epd_color::WHITE, gImage_4G1);
|
||||
}
|
||||
|
||||
epd_refresh();
|
||||
epd_shutdown_display();
|
||||
|
||||
if (err == ESP_OK)
|
||||
{
|
||||
disconnect_wifi();
|
||||
shutdown_network();
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Waiting 5 seconds before deep sleep...");
|
||||
vTaskDelay(pdMS_TO_TICKS(15000));
|
||||
|
||||
ESP_LOGI(TAG, "Entering Deep Sleep for 60 seconds...");
|
||||
esp_sleep_enable_timer_wakeup(30ULL * 1000000ULL);
|
||||
esp_deep_sleep_start();
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Provider communication — mDNS discovery, device registration, screen fetch.
|
||||
|
||||
#include "provider.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
#include "esp_log.h"
|
||||
#include "mdns.h"
|
||||
#include "sdkconfig.h"
|
||||
|
||||
#include "http_client.hpp"
|
||||
#include "network.hpp"
|
||||
|
||||
static const char *TAG = "Provider";
|
||||
|
||||
// ── mDNS Provider Discovery ────────────────────────────────────────────────
|
||||
|
||||
// Resolve the Provider's IP via mDNS. Returns true and fills `out_ip` on
|
||||
// success. Falls back to CONFIG_CALENDINK_PROVIDER_FALLBACK_IP if set.
|
||||
static bool resolve_provider_ip(char *out_ip, size_t out_ip_len)
|
||||
{
|
||||
ESP_LOGI(TAG, "Resolving Provider via mDNS: %s.local",
|
||||
CONFIG_CALENDINK_PROVIDER_MDNS_HOSTNAME);
|
||||
|
||||
esp_err_t err = mdns_init();
|
||||
if (err != ESP_OK)
|
||||
{
|
||||
ESP_LOGE(TAG, "mDNS init failed: %s", esp_err_to_name(err));
|
||||
goto fallback;
|
||||
}
|
||||
|
||||
{
|
||||
esp_ip4_addr_t addr = {};
|
||||
constexpr int kMaxRetries = 3;
|
||||
for (int attempt = 1; attempt <= kMaxRetries; attempt++)
|
||||
{
|
||||
err = mdns_query_a(CONFIG_CALENDINK_PROVIDER_MDNS_HOSTNAME, 5000, &addr);
|
||||
if (err == ESP_OK)
|
||||
{
|
||||
snprintf(out_ip, out_ip_len, IPSTR, IP2STR(&addr));
|
||||
ESP_LOGI(TAG, "Provider resolved: %s (attempt %d)", out_ip, attempt);
|
||||
return true;
|
||||
}
|
||||
ESP_LOGW(TAG, "mDNS attempt %d/%d failed: %s", attempt, kMaxRetries,
|
||||
esp_err_to_name(err));
|
||||
}
|
||||
}
|
||||
|
||||
fallback:
|
||||
if (strlen(CONFIG_CALENDINK_PROVIDER_FALLBACK_IP) > 0)
|
||||
{
|
||||
strlcpy(out_ip, CONFIG_CALENDINK_PROVIDER_FALLBACK_IP, out_ip_len);
|
||||
ESP_LOGW(TAG, "Using fallback IP: %s", out_ip);
|
||||
return true;
|
||||
}
|
||||
|
||||
ESP_LOGE(TAG, "No fallback IP configured. Cannot reach Provider.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Provider Communication Test ─────────────────────────────────────────────
|
||||
|
||||
bool test_provider_communication(uint8 *out_buffer, size_t buffer_size)
|
||||
{
|
||||
bool success = false;
|
||||
|
||||
// 1. Resolve Provider IP
|
||||
char provider_ip[16] = {};
|
||||
if (!resolve_provider_ip(provider_ip, sizeof(provider_ip)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
uint16_t provider_port = CONFIG_CALENDINK_PROVIDER_PORT;
|
||||
|
||||
// 2. Get our own MAC address
|
||||
uint8_t mac_bytes[6] = {};
|
||||
esp_err_t err = get_mac_address(mac_bytes);
|
||||
if (err != ESP_OK)
|
||||
{
|
||||
ESP_LOGE(TAG, "Failed to get WiFi MAC: %s", esp_err_to_name(err));
|
||||
return false;
|
||||
}
|
||||
|
||||
char mac_str[18] = {};
|
||||
snprintf(mac_str, sizeof(mac_str), "%02X:%02X:%02X:%02X:%02X:%02X",
|
||||
mac_bytes[0], mac_bytes[1], mac_bytes[2], mac_bytes[3], mac_bytes[4],
|
||||
mac_bytes[5]);
|
||||
ESP_LOGI(TAG, "Client MAC: %s", mac_str);
|
||||
|
||||
// 3. Register with Provider: POST /api/devices/register
|
||||
// This may return "already_registered" — that's fine, we continue regardless.
|
||||
{
|
||||
char *url =
|
||||
http_build_url(provider_ip, provider_port, "/api/devices/register");
|
||||
if (url != nullptr)
|
||||
{
|
||||
char json_body[64] = {};
|
||||
snprintf(json_body, sizeof(json_body), "{\"mac\":\"%s\"}", mac_str);
|
||||
|
||||
http_text_response_t resp = {};
|
||||
err = http_post_json(url, json_body, &resp);
|
||||
|
||||
if (err == ESP_OK)
|
||||
{
|
||||
ESP_LOGI(TAG, "Register response (%d): %s", resp.status_code,
|
||||
resp.body ? resp.body : "(empty)");
|
||||
}
|
||||
else
|
||||
{
|
||||
ESP_LOGW(TAG, "Register request failed: %s (continuing anyway)",
|
||||
esp_err_to_name(err));
|
||||
}
|
||||
|
||||
free(resp.body);
|
||||
free(url);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fetch screen bitmap: GET /api/devices/screen.bin?mac=XX:XX:XX:XX:XX:XX
|
||||
{
|
||||
char path[80] = {};
|
||||
snprintf(path, sizeof(path), "/api/devices/screen.bin?mac=%s", mac_str);
|
||||
|
||||
char *url = http_build_url(provider_ip, provider_port, path);
|
||||
if (url == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
http_binary_response_t resp = {};
|
||||
err = http_get_binary(url, &resp);
|
||||
|
||||
if (err == ESP_OK && resp.status_code == 200)
|
||||
{
|
||||
ESP_LOGI(TAG, "Screen bitmap response: %zu bytes", resp.data_len);
|
||||
if (resp.data != nullptr && resp.data_len > 0)
|
||||
{
|
||||
size_t copy_size = (resp.data_len < buffer_size) ? resp.data_len : buffer_size;
|
||||
memcpy(out_buffer, resp.data, copy_size);
|
||||
success = true;
|
||||
|
||||
// Debug: log first 10 bytes (should be 0xFF for white top-left pixels)
|
||||
ESP_LOGI(TAG, "First 10 bytes: %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X",
|
||||
out_buffer[0], out_buffer[1], out_buffer[2], out_buffer[3],
|
||||
out_buffer[4], out_buffer[5], out_buffer[6], out_buffer[7],
|
||||
out_buffer[8], out_buffer[9]);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ESP_LOGE(TAG, "Screen bitmap request failed: %s (status %d)",
|
||||
esp_err_to_name(err), resp.status_code);
|
||||
}
|
||||
|
||||
free(resp.data);
|
||||
free(url);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
#include "types.hpp"
|
||||
#include <cstddef>
|
||||
|
||||
// Resolve the Provider's IP and run the device registration + screen fetch
|
||||
// test flow. Call once after WiFi is connected.
|
||||
bool test_provider_communication(uint8 *out_buffer, size_t buffer_size);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -81,6 +81,7 @@ Read the relevant TDD before any major architectural change:
|
||||
| `tdd/concurrent_requests.md` | Changing HTTP server socket/connection config |
|
||||
| `tdd/lvgl_image_generation.md` | Touching LVGL headless display or image gen |
|
||||
| `tdd/device_screens.md` | Changing device registration, MAC routing, or XML layout logic |
|
||||
| `tdd/http_client_component.md` | Changing the shared HTTP client component or Client↔Provider communication |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Epic 1: Client Power Strategy
|
||||
|
||||
## Goal
|
||||
Achieve a battery life measured in months for the ESP32-C6 Client.
|
||||
|
||||
## Context
|
||||
The ESP32-C6 serves as a "dumb" E-ink display client connected to the Calendink Provider. It needs to wake up, check for new layout data, download it, refresh the E-ink panel, and go back to sleep.
|
||||
The current standard method of deep sleep followed by full Wi-Fi re-association (DHCP negotiation) consumes ~100-300mA for multiple seconds, drastically limiting battery life for frequent updates.
|
||||
|
||||
## Scope & Technologies to Investigate
|
||||
1. **Wi-Fi 6 Target Wake Time (TWT):**
|
||||
- The ESP32-C6 supports 802.11ax TWT. TWT allows the device to negotiate specific wake-up schedules with the router, meaning the radio can sleep while the connection remains "active".
|
||||
- Packets sent to the device during sleep are buffered by the AP until the Target Wake Time.
|
||||
2. **ESP-PM (Power Management):**
|
||||
- Combine TWT with `esp-pm` dynamic frequency scaling and automatic Light Sleep.
|
||||
3. **Alternative - Hybrid ESP-NOW:**
|
||||
- If TWT requires unsupported features on the specific home router, evaluate a fallback where the Client sends a sub-millisecond ESP-NOW broadcast to ask "Is there an update?". Full Wi-Fi is only enabled if the Provider replies "Yes".
|
||||
|
||||
## Next Steps to Start
|
||||
1. Create a `tdd/client_power_strategy.md` in the Provider/Client workspace.
|
||||
2. Develop a minimal test firmware on the C6 to enable TWT via the `esp_wifi_twt_setup` API, monitoring power draw and wake times using a multimeter or profile.
|
||||
3. Document the final chosen power strategy pattern before modifying `main.cpp` or `epd.cpp`.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Epic 2: Provider Persistent Storage (SD Card)
|
||||
|
||||
## Goal
|
||||
Ensure Users, Tasks, and Settings survive device reboots on the ESP32-S3 Provider.
|
||||
|
||||
## Context
|
||||
Currently, the Provider's state (Todo tasks, Registered Devices, and User objects) resides in static BSS arrays like `g_Tasks[32]`. This means the state is lost on every reset.
|
||||
The ESP32-S3 board has an onboard 3GB SD Card reader. This Epic focuses on migrating the data layer to utilize this SD Card.
|
||||
|
||||
## Scope & Technologies to Investigate
|
||||
1. **Hardware Pinout:**
|
||||
- Determine the exact physical pins the SD Card reader is using on the specific ESP32-S3 board.
|
||||
- Investigate if it is wired for standard SPI (`sdspi`) or native SDMMC (1-bit or 4-bit mode).
|
||||
2. **ESP-IDF Storage Drivers:**
|
||||
- Mount a FATFS partition using the `esp_vfs_fat_sdmmc` / `esp_vfs_fat_sdspi` components.
|
||||
3. **Data Model:**
|
||||
- Decide between compiling SQLite for ESP-IDF (better querying, harder setup) or relying on flat `.json` files parsed via cJSON (easier setup, sufficient for MVP limits like 4 users and 32 tasks).
|
||||
|
||||
## Next Steps to Start
|
||||
1. Create a `tdd/sd_card_persistence.md`.
|
||||
2. Find the board schematic or test GPIO configurations to successfully mount the SD Card.
|
||||
3. Abstract the storage functionality into a generic `store.hpp/cpp` interface so the `manage.cpp` and API handlers don't need to be rewritten.
|
||||
4. Update `seed_users()` and `seed_tasks()` routines to populate initial `.json` files if the SD Card is empty.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Epic 3: Voice-to-Task AI (Gemini)
|
||||
|
||||
## Goal
|
||||
Allow users to dictate tasks naturally in French, directly creating JSON task structures on the Provider.
|
||||
|
||||
## Context
|
||||
Adding tasks manually using a keyboard via the web dashboard is friction. We want to utilize automated intelligence (Google Gemini API) to ingest natural language (specifically French) and figure out the JSON properties (task name, user, due date).
|
||||
Input can come from the Svelte frontend (browser recording) or an iOS Shortcut automation.
|
||||
|
||||
## Scope & Technologies to Investigate
|
||||
1. **Ingestion Endpoint:**
|
||||
- Create a `POST /api/tasks/audio` endpoint on the ESP32-S3 Provider.
|
||||
- Decide exactly what format this accepts. Is it raw audio binaries like `.wav` or `.m4a`? Or is it transcribed text sent from the device's native speech-to-text? (Note during implementation if ESP32 memory constraints force pre-transcription on iOS before sending).
|
||||
2. **Gemini API Integration:**
|
||||
- Implement an HTTP/HTTPS client on the ESP32-S3 to call Gemini API endpoints.
|
||||
- Construct a prompt template: *"You are an assistant. Extract the task properties from the following French audio/text and output strict JSON matching our schema: { "title": "...", "due_date": "...", "user_name": "..." }"*
|
||||
3. **API Key Management:**
|
||||
- Add a Svelte dashboard configuration page to allow the user to securely save their Gemini API key to the SD Card.
|
||||
|
||||
## Next Steps to Start
|
||||
1. Create a `tdd/voice_ai_integration.md`.
|
||||
2. Secure an HTTPS connection from ESP-IDF to the Google Gemini API (managing certs/mbedtls).
|
||||
3. Test a hardcoded prompt execution before wiring up the web UI.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Epic 4: Modular Grid Layout & Provider Integration
|
||||
|
||||
## Goal
|
||||
Replace the single full-screen XML structure with a flexible 6-pane grid that dynamically wraps external data.
|
||||
|
||||
## Context
|
||||
Currently, the Provider sends an entire E-ink screen generated from a single massive LVGL XML string. For the MVP, we want a flexible grid: 2 large top canvases (Main Task, Weather) and 4 small bottom canvases (One per Family Member).
|
||||
The user can assign distinct XML templates to any of these 6 panes.
|
||||
|
||||
## Scope & Technologies to Investigate
|
||||
1. **Widget Architecture (Grid):**
|
||||
- Rework the LVGL rendering logic in the Provider. It should boot an LVGL display, split it into 6 designated Canvas areas, and parse smaller, independent user XML strings into each respective area using `LV_USE_XML`.
|
||||
2. **Data Binding:**
|
||||
- Investigate how live data gets into the XML before parsing. E.g., if Canvas 2 is 'Weather', how does `{{TEMP}}` inside the user's XML become `22°C`? String replacement via standard C functions before passing to LVGL.
|
||||
3. **OpenWeatherMap Integration:**
|
||||
- Create a backend polling task or direct fetch mechanism on the Provider to query the OpenWeatherMap API.
|
||||
- Expose the OpenWeatherMap API Key configuration in the Svelte dashboard.
|
||||
4. **4-User Constraint:**
|
||||
- Enforce that the system tracks exactly 4 active users to match the 4 bottom E-ink panes.
|
||||
|
||||
## Next Steps to Start
|
||||
1. Create a `tdd/modular_grid_layout.md`.
|
||||
2. Prototype a basic HTTP GET to OpenWeatherMap using the `http_client` component and parse the resulting JSON.
|
||||
3. Rework the Svelte `DeviceManager.svelte` to show 6 individual XML editors per Device instead of just 1.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Calendink MVP Plan - Executive Summary
|
||||
|
||||
This document defines the macroscopic project scope for the Calendink Minimum Viable Product (MVP).
|
||||
|
||||
We will structure the development into **4 Major Epics**. Because features like Power Management and SD Card Integration require deep technical investigation, we will not prematurely guess the solutions here. Instead, each Epic will begin with a dedicated **Technical Design Document (TDD)** to map out the exact implementation before coding.
|
||||
|
||||
---
|
||||
|
||||
## The 4 Development Epics
|
||||
|
||||
### Epic 1: Client Power Strategy
|
||||
**Goal:** Achieve a battery life measured in months for the ESP32-C6 Client.
|
||||
**Scope:**
|
||||
- Research and design a formal power strategy.
|
||||
- Evaluate Target Wake Time (TWT), `esp-pm`, Light Sleep, Deep Sleep, and hybrid ESP-NOW routing.
|
||||
- The outcome will be a dedicated TDD followed by the firmware implementation.
|
||||
|
||||
### Epic 2: Provider Persistent Storage (SD Card)
|
||||
**Goal:** Ensure Users, Tasks, and Settings survive device reboots.
|
||||
**Scope:**
|
||||
- Investigate the physical SD Card pinout on the ESP32-S3.
|
||||
- Decide between SQLite or flat JSON files.
|
||||
- Implement the ESP-IDF SDMMC/SDSPI driver.
|
||||
- Migrate the current in-RAM `g_Tasks` and `g_Users` arrays to the new persistent backend.
|
||||
|
||||
### Epic 3: Voice-to-Task AI (Gemini)
|
||||
**Goal:** Allow users to dictate tasks naturally in French.
|
||||
**Scope:**
|
||||
- Implement an API endpoint on the Provider to accept raw audio/text.
|
||||
- Create an internal HTTPS client on the S3 to proxy the data to the Google Gemini API.
|
||||
- Parse the structured JSON response from Gemini to automatically save the new task.
|
||||
|
||||
### Epic 4: Modular Grid Layout & Provider Integration
|
||||
**Goal:** Replace the single-XML screen structure with a flexible 6-pane grid.
|
||||
**Scope:**
|
||||
- Define a fixed layout layout on the E-ink display: 2 large top canvases (Main Task, Weather) and 4 small bottom canvases (One per family member).
|
||||
- Limit the user system to exactly 4 Active Users.
|
||||
- Allow the dashboard to assign distinct XML templates to any of the 6 canvases, making it adaptable to future widgets.
|
||||
- Integrate an OpenWeatherMap API wrapper on the Provider.
|
||||
@@ -78,6 +78,7 @@ Always read the relevant TDD before making major architectural changes:
|
||||
| [concurrent_requests.md](tdd/concurrent_requests.md) | Changing HTTP server socket/connection config |
|
||||
| [lvgl_image_generation.md](tdd/lvgl_image_generation.md) | Touching LVGL headless display or image gen |
|
||||
| [device_screens.md](tdd/device_screens.md) | Changing device registration, MAC routing, or XML layout logic |
|
||||
| [http_client_component.md](tdd/http_client_component.md) | Changing the shared HTTP client component or Client↔Provider communication |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
},
|
||||
{
|
||||
"path": "../components"
|
||||
},
|
||||
{
|
||||
"path": "../Client"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"clangd.path": "C:\\Espressif\\tools\\esp-clang\\esp-19.1.2_20250312\\esp-clang\\bin\\clangd.exe",
|
||||
"clangd.arguments": [
|
||||
"--background-index",
|
||||
"--query-driver=**",
|
||||
"--path-mappings=C:/Dev/Classified/Calendink=W:/Classified/Calendink,c:/Dev/Classified/Calendink=w:/Classified/Calendink,C:\\Dev\\Classified\\Calendink=W:\\Classified\\Calendink,c:\\Dev\\Classified\\Calendink=w:\\Classified\\Calendink"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -145,20 +145,41 @@ dependencies:
|
||||
registry_url: https://components.espressif.com/
|
||||
type: service
|
||||
version: 1.20.4
|
||||
led:
|
||||
dependencies:
|
||||
- name: idf
|
||||
version: '>=4.1.0'
|
||||
- name: espressif/led_strip
|
||||
version: ^3.0.3
|
||||
source:
|
||||
path: C:\Dev\Classified\Calendink\components\led
|
||||
type: local
|
||||
version: 1.0.0
|
||||
lvgl/lvgl:
|
||||
component_hash: 184e532558c1c45fefed631f3e235423d22582aafb4630f3e8885c35281a49ae
|
||||
component_hash: 17e68bfd21f0edf4c3ee838e2273da840bf3930e5dbc3bfa6c1190c3aed41f9f
|
||||
dependencies: []
|
||||
source:
|
||||
registry_url: https://components.espressif.com/
|
||||
type: service
|
||||
version: 9.5.0
|
||||
version: 9.4.0
|
||||
network:
|
||||
dependencies:
|
||||
- name: idf
|
||||
version: '>=4.1.0'
|
||||
- name: espressif/ethernet_init
|
||||
version: ^1.3.0
|
||||
source:
|
||||
path: C:\Dev\Classified\Calendink\components\network
|
||||
type: local
|
||||
version: 1.0.0
|
||||
direct_dependencies:
|
||||
- espressif/ethernet_init
|
||||
- espressif/led_strip
|
||||
- espressif/mdns
|
||||
- idf
|
||||
- joltwallet/littlefs
|
||||
- led
|
||||
- lvgl/lvgl
|
||||
manifest_hash: 96112412d371d78cc527b7d0904042e5a7ca7c4f25928de9483a1b53dd2a2f4e
|
||||
- network
|
||||
manifest_hash: d2227bd8a79a4aafd4c3e25c1cc63e12fb3bc2afb66337e9f9412b0e1252145f
|
||||
target: esp32s3
|
||||
version: 2.0.0
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import urllib.request
|
||||
import json
|
||||
import time
|
||||
|
||||
# 1. Register a fake device
|
||||
req = urllib.request.Request('http://calendink.local/api/devices/register', data=json.dumps({'mac': 'DE:BU:G0:44:55:66'}).encode('utf-8'), headers={'Content-Type': 'application/json'})
|
||||
try:
|
||||
with urllib.request.urlopen(req) as response:
|
||||
print("Registered:", response.read().decode())
|
||||
except Exception as e:
|
||||
print("Error registering:", e)
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
# 2. Download the PNG
|
||||
try:
|
||||
urllib.request.urlretrieve('http://calendink.local/api/devices/screen.png?mac=DE:BU:G0:44:55:66', 'test_png.png')
|
||||
print("Downloaded test_png.png")
|
||||
except Exception as e:
|
||||
print("Error downloading PNG:", e)
|
||||
@@ -5,6 +5,7 @@
|
||||
import Sidebar from "./lib/Sidebar.svelte";
|
||||
import TaskManager from "./lib/TaskManager.svelte";
|
||||
import UserManager from "./lib/UserManager.svelte";
|
||||
import DeviceManager from "./lib/DeviceManager.svelte";
|
||||
import Spinner from "./lib/Spinner.svelte";
|
||||
|
||||
/** @type {'loading' | 'ok' | 'error' | 'rebooting'} */
|
||||
@@ -13,7 +14,7 @@
|
||||
let showRebootConfirm = $state(false);
|
||||
let isRecovering = $state(false);
|
||||
|
||||
/** @type {'dashboard' | 'tasks' | 'users'} */
|
||||
/** @type {'dashboard' | 'tasks' | 'users' | 'devices'} */
|
||||
let currentView = $state("dashboard");
|
||||
let mobileMenuOpen = $state(false);
|
||||
|
||||
@@ -352,6 +353,11 @@
|
||||
<div class="bg-bg-card border border-border rounded-xl p-8 shadow-xl">
|
||||
<UserManager mode="manager" />
|
||||
</div>
|
||||
{:else if currentView === 'devices'}
|
||||
<!-- Device Management View -->
|
||||
<div class="bg-bg-card border border-border rounded-xl p-6 shadow-xl">
|
||||
<DeviceManager />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Reboot Confirmation Modal -->
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
<script>
|
||||
import { getDevices, updateDeviceLayout, registerDevice } from './api.js';
|
||||
|
||||
let devices = $state([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
// Track XML edits per device (keyed by MAC)
|
||||
let editingXml = $state({});
|
||||
let savingMac = $state('');
|
||||
let saveResult = $state('');
|
||||
|
||||
let defaultXml = $state('');
|
||||
|
||||
// Debug states
|
||||
let debugRegistering = $state(false);
|
||||
|
||||
async function loadDevices() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const data = await getDevices();
|
||||
devices = data.devices;
|
||||
defaultXml = data.default_layout;
|
||||
} catch (e) {
|
||||
error = e.message || 'Failed to load devices';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDebugRegister() {
|
||||
debugRegistering = true;
|
||||
try {
|
||||
// Generate a fake MAC like "DE:BU:G0:xx:xx:xx"
|
||||
const hex = () => Math.floor(Math.random() * 256).toString(16).padStart(2, '0').toUpperCase();
|
||||
const fakeMac = `DE:BU:G0:${hex()}:${hex()}:${hex()}`;
|
||||
await registerDevice(fakeMac);
|
||||
await loadDevices();
|
||||
} catch (e) {
|
||||
error = e.message || 'Failed to register debug device';
|
||||
} finally {
|
||||
debugRegistering = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveLayout(device) {
|
||||
savingMac = device.mac;
|
||||
saveResult = '';
|
||||
try {
|
||||
await updateDeviceLayout(device.mac, editingXml[device.mac] ?? device.xml_layout);
|
||||
saveResult = 'ok';
|
||||
await loadDevices();
|
||||
} catch (e) {
|
||||
saveResult = e.message || 'Save failed';
|
||||
} finally {
|
||||
savingMac = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Runs once on mount — no reactive deps
|
||||
$effect(() => {
|
||||
loadDevices();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-text-primary">Device Manager</h2>
|
||||
<p class="text-xs text-text-secondary mt-1">
|
||||
Manage registered e-ink devices and their screen layouts.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={loadDevices}
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-lg bg-accent/10 text-accent border border-accent/20 hover:bg-accent/20 transition-colors"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="text-center text-sm text-text-secondary py-8 animate-pulse">
|
||||
Loading devices...
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="text-center text-sm text-danger py-8">
|
||||
{error}
|
||||
</div>
|
||||
{:else if devices.length === 0}
|
||||
<div class="bg-bg-card-hover/50 border border-border rounded-xl p-8 text-center">
|
||||
<p class="text-text-secondary text-sm">No devices registered yet.</p>
|
||||
<p class="text-text-secondary text-xs mt-2">
|
||||
Use <code class="bg-bg-card px-1.5 py-0.5 rounded text-[11px] font-mono text-accent">
|
||||
curl -X POST -d '{{"mac":"AA:BB:CC:DD:EE:FF"}}' http://calendink.local/api/devices/register
|
||||
</code> to register a device.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
{#each devices as device}
|
||||
<div class="bg-bg-card border border-border rounded-xl overflow-hidden shadow-lg">
|
||||
<!-- Device Header -->
|
||||
<div class="px-5 py-3 border-b border-border flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-base">📺</span>
|
||||
<span class="text-sm font-mono font-bold text-text-primary">{device.mac}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if device.has_layout}
|
||||
{#if device.xml_layout === defaultXml}
|
||||
<span class="text-[10px] bg-accent/20 text-accent px-2 py-0.5 rounded-full uppercase tracking-wider font-semibold">
|
||||
Default Layout
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-[10px] bg-success/20 text-success px-2 py-0.5 rounded-full uppercase tracking-wider font-semibold">
|
||||
Layout Set
|
||||
</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="text-[10px] bg-border/50 text-text-secondary px-2 py-0.5 rounded-full uppercase tracking-wider font-semibold">
|
||||
No Layout
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- XML Editor -->
|
||||
<div class="p-5 space-y-3">
|
||||
<label for="xml-{device.mac}" class="text-xs font-semibold text-text-secondary uppercase tracking-wider">
|
||||
LVGL XML Layout
|
||||
</label>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<textarea
|
||||
id="xml-{device.mac}"
|
||||
class="w-full h-32 bg-bg-primary border border-border rounded-lg p-3 text-xs font-mono text-text-primary resize-y focus:border-accent focus:outline-none transition-colors placeholder:text-text-secondary/50"
|
||||
placeholder={defaultXml}
|
||||
value={editingXml[device.mac] ?? device.xml_layout}
|
||||
oninput={(e) => editingXml[device.mac] = e.currentTarget.value}
|
||||
></textarea>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<a
|
||||
href="/api/devices/screen.png?mac={device.mac}"
|
||||
target="_blank"
|
||||
class="text-xs text-accent hover:text-accent/80 transition-colors underline"
|
||||
>
|
||||
Preview PNG →
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => handleSaveLayout(device)}
|
||||
disabled={savingMac === device.mac}
|
||||
class="px-4 py-2 text-xs font-medium rounded-lg transition-colors
|
||||
bg-accent/10 text-accent border border-accent/20
|
||||
hover:bg-accent/20 hover:border-accent/30
|
||||
disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{savingMac === device.mac ? 'Saving...' : 'Save Layout'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if saveResult === 'ok' && savingMac === ''}
|
||||
<div class="text-xs text-success">✓ Layout saved successfully</div>
|
||||
{:else if saveResult && saveResult !== 'ok'}
|
||||
<div class="text-xs text-danger">✗ {saveResult}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<details class="mt-8 border text-xs border-border rounded-lg bg-bg-card opacity-50 hover:opacity-100 transition-opacity">
|
||||
<summary class="px-4 py-3 cursor-pointer font-medium text-text-secondary select-none">
|
||||
Debug Tools
|
||||
</summary>
|
||||
<div class="p-4 border-t border-border border-dashed space-y-4">
|
||||
<p class="text-text-secondary">
|
||||
Quickly register a new device to format layouts.
|
||||
</p>
|
||||
<button
|
||||
onclick={handleDebugRegister}
|
||||
disabled={debugRegistering}
|
||||
class="px-4 py-2 font-medium rounded-lg transition-colors
|
||||
bg-accent/10 text-accent border border-accent/20
|
||||
hover:bg-accent/20 hover:border-accent/30
|
||||
disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{debugRegistering ? 'Registering...' : 'Add Fake Device'}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
const navItems = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: '🏠' },
|
||||
{ id: 'devices', label: 'Devices', icon: '📺' },
|
||||
{ id: 'tasks', label: 'Tasks', icon: '📋' },
|
||||
{ id: 'users', label: 'Users', icon: '👥' },
|
||||
];
|
||||
|
||||
@@ -274,3 +274,53 @@ export async function deleteTask(id) {
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ─── Device Management ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch all registered devices and the default layout.
|
||||
* @returns {Promise<{default_layout: string, devices: Array<{mac: string, has_layout: boolean, xml_layout: string}>}>}
|
||||
*/
|
||||
export async function getDevices() {
|
||||
const res = await trackedFetch(`${API_BASE}/api/devices`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new device.
|
||||
* @param {string} mac
|
||||
* @returns {Promise<{status: string}>}
|
||||
*/
|
||||
export async function registerDevice(mac) {
|
||||
const res = await trackedFetch(`${API_BASE}/api/devices/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mac })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
throw new Error(`Failed (${res.status}): ${errorText || res.statusText}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the LVGL XML layout for a device.
|
||||
* @param {string} mac
|
||||
* @param {string} xml
|
||||
* @returns {Promise<{status: string}>}
|
||||
*/
|
||||
export async function updateDeviceLayout(mac, xml) {
|
||||
const res = await trackedFetch(`${API_BASE}/api/devices/layout`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mac, xml })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
throw new Error(`Failed (${res.status}): ${errorText || res.statusText}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"major": 0,
|
||||
"minor": 1,
|
||||
"revision": 30
|
||||
"revision": 32
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
idf_component_register(SRCS "main.cpp"
|
||||
# Needed as we use minimal build
|
||||
PRIV_REQUIRES esp_http_server esp_eth
|
||||
esp_wifi nvs_flash esp_netif vfs
|
||||
json app_update esp_timer esp_psram mdns driver
|
||||
INCLUDE_DIRS ".")
|
||||
PRIV_REQUIRES esp_http_server
|
||||
nvs_flash vfs esp_timer
|
||||
json app_update esp_psram mdns driver network
|
||||
INCLUDE_DIRS "." "../../components/shared")
|
||||
|
||||
if(CONFIG_CALENDINK_DEPLOY_WEB_PAGES)
|
||||
set(WEB_SRC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../frontend")
|
||||
|
||||
@@ -1,76 +1,3 @@
|
||||
menu "CalendarInk Network Configuration"
|
||||
|
||||
config CALENDINK_WIFI_SSID
|
||||
string "WiFi SSID"
|
||||
default ""
|
||||
help
|
||||
SSID (network name) for the WiFi connection.
|
||||
|
||||
config CALENDINK_WIFI_PASSWORD
|
||||
string "WiFi Password"
|
||||
default ""
|
||||
help
|
||||
Password for the WiFi connection.
|
||||
|
||||
config CALENDINK_ETH_RETRIES
|
||||
int "Maximum Ethernet Connection Retries"
|
||||
default 5
|
||||
help
|
||||
Number of times to retry the Ethernet connection before falling back to WiFi.
|
||||
|
||||
config CALENDINK_WIFI_RETRIES
|
||||
int "Maximum WiFi Connection Retries"
|
||||
default 5
|
||||
help
|
||||
Number of times to retry the WiFi connection before failing completely.
|
||||
|
||||
config CALENDINK_BLINK_IP
|
||||
bool "Blink last IP digit on connect"
|
||||
default n
|
||||
help
|
||||
If enabled, the LED will blink the last digit of the IP address
|
||||
acquired to assist in debugging.
|
||||
|
||||
config CALENDINK_MDNS_HOSTNAME
|
||||
string "mDNS Hostname"
|
||||
default "calendink"
|
||||
help
|
||||
The hostname to use for mDNS. The device will be accessible
|
||||
at <hostname>.local. (e.g., calendink.local)
|
||||
|
||||
config CALENDINK_UDP_LOG_TARGET_IP
|
||||
string "UDP Logger Target IP Address"
|
||||
default ""
|
||||
help
|
||||
The IP address to send UDP logs to via port 514.
|
||||
If left blank, logs will be broadcast to 255.255.255.255.
|
||||
|
||||
choice CALENDINK_WIFI_PS_MODE
|
||||
prompt "WiFi Power Save Mode"
|
||||
default CALENDINK_WIFI_PS_NONE
|
||||
help
|
||||
Select the WiFi power save mode to balance power consumption and network stability.
|
||||
|
||||
config CALENDINK_WIFI_PS_NONE
|
||||
bool "None (No power save, highest consumption)"
|
||||
|
||||
config CALENDINK_WIFI_PS_MIN_MODEM
|
||||
bool "Minimum Modem (Wakes on beacon, balanced)"
|
||||
|
||||
config CALENDINK_WIFI_PS_MAX_MODEM
|
||||
bool "Maximum Modem (Lowest consumption, may drop connection on strict routers)"
|
||||
endchoice
|
||||
|
||||
config CALENDINK_ALLOW_LIGHT_SLEEP
|
||||
bool "Allow Light Sleep (Tickless Idle)"
|
||||
default n
|
||||
help
|
||||
If enabled, the device will heavily use light sleep to reduce power
|
||||
consumption. Note that this may BREAK the UART console monitor since the
|
||||
CPU sleeps and halts the UART! Use UDP logging if you need logs
|
||||
while light sleep is enabled.
|
||||
|
||||
endmenu
|
||||
|
||||
menu "Calendink Web Server"
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// POST /api/devices/layout — Update the XML layout for a device
|
||||
// Body: {"mac": "AA:BB:CC:DD:EE:FF", "xml": "<lv_label .../>"}
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "esp_http_server.h"
|
||||
#include "esp_log.h"
|
||||
|
||||
#include "types.hpp"
|
||||
#include "device.hpp"
|
||||
|
||||
internal const char *kTagDeviceLayout = "API_DEV_LAYOUT";
|
||||
|
||||
internal esp_err_t api_devices_layout_handler(httpd_req_t *req)
|
||||
{
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
|
||||
|
||||
// The XML payload can be large, so use a bigger buffer
|
||||
// DEVICE_XML_MAX (2048) + JSON overhead for mac key etc.
|
||||
constexpr int kBufSize = DEVICE_XML_MAX + 256;
|
||||
char *buf = (char *)malloc(kBufSize);
|
||||
if (!buf)
|
||||
{
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
int total = 0;
|
||||
int remaining = req->content_len;
|
||||
if (remaining >= kBufSize)
|
||||
{
|
||||
free(buf);
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Payload too large");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
while (remaining > 0)
|
||||
{
|
||||
int received = httpd_req_recv(req, buf + total, remaining);
|
||||
if (received <= 0)
|
||||
{
|
||||
free(buf);
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Receive error");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
total += received;
|
||||
remaining -= received;
|
||||
}
|
||||
buf[total] = '\0';
|
||||
|
||||
cJSON *body = cJSON_Parse(buf);
|
||||
free(buf);
|
||||
|
||||
if (!body)
|
||||
{
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
cJSON *mac_item = cJSON_GetObjectItem(body, "mac");
|
||||
cJSON *xml_item = cJSON_GetObjectItem(body, "xml");
|
||||
|
||||
if (!cJSON_IsString(mac_item) || !mac_item->valuestring || strlen(mac_item->valuestring) == 0)
|
||||
{
|
||||
cJSON_Delete(body);
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing 'mac'");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
if (!cJSON_IsString(xml_item) || !xml_item->valuestring)
|
||||
{
|
||||
cJSON_Delete(body);
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing 'xml'");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
bool ok = update_device_layout(mac_item->valuestring, xml_item->valuestring);
|
||||
|
||||
if (!ok)
|
||||
{
|
||||
cJSON_Delete(body);
|
||||
httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Device not found");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
ESP_LOGI(kTagDeviceLayout, "Updated layout for %s (%zu bytes)", mac_item->valuestring,
|
||||
strlen(xml_item->valuestring));
|
||||
|
||||
cJSON_Delete(body);
|
||||
|
||||
httpd_resp_sendstr(req, "{\"status\":\"ok\"}");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
internal const httpd_uri_t api_devices_layout_uri = {
|
||||
.uri = "/api/devices/layout",
|
||||
.method = HTTP_POST,
|
||||
.handler = api_devices_layout_handler,
|
||||
.user_ctx = NULL};
|
||||
@@ -0,0 +1,45 @@
|
||||
// GET /api/devices — List all registered devices
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "esp_http_server.h"
|
||||
|
||||
#include "types.hpp"
|
||||
#include "device.hpp"
|
||||
|
||||
internal esp_err_t api_devices_get_handler(httpd_req_t *req)
|
||||
{
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
|
||||
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(root, "default_layout", kDefaultLayoutXml);
|
||||
|
||||
cJSON *arr = cJSON_CreateArray();
|
||||
cJSON_AddItemToObject(root, "devices", arr);
|
||||
|
||||
for (int i = 0; i < MAX_DEVICES; i++)
|
||||
{
|
||||
if (g_Devices[i].active)
|
||||
{
|
||||
cJSON *obj = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(obj, "mac", g_Devices[i].mac);
|
||||
cJSON_AddBoolToObject(obj, "has_layout", g_Devices[i].xml_layout[0] != '\0');
|
||||
cJSON_AddStringToObject(obj, "xml_layout", g_Devices[i].xml_layout);
|
||||
cJSON_AddItemToArray(arr, obj);
|
||||
}
|
||||
}
|
||||
|
||||
const char *json = cJSON_PrintUnformatted(root);
|
||||
httpd_resp_sendstr(req, json);
|
||||
|
||||
free((void *)json);
|
||||
cJSON_Delete(root);
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
internal const httpd_uri_t api_devices_get_uri = {
|
||||
.uri = "/api/devices",
|
||||
.method = HTTP_GET,
|
||||
.handler = api_devices_get_handler,
|
||||
.user_ctx = NULL};
|
||||
@@ -0,0 +1,79 @@
|
||||
// POST /api/devices/register — Register a new device by MAC
|
||||
// Body: {"mac": "AA:BB:CC:DD:EE:FF"}
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "esp_http_server.h"
|
||||
#include "esp_log.h"
|
||||
|
||||
#include "types.hpp"
|
||||
#include "device.hpp"
|
||||
|
||||
internal const char *kTagDeviceRegister = "API_DEV_REG";
|
||||
|
||||
internal esp_err_t api_devices_register_handler(httpd_req_t *req)
|
||||
{
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
|
||||
|
||||
char buf[128];
|
||||
int received = httpd_req_recv(req, buf, sizeof(buf) - 1);
|
||||
if (received <= 0)
|
||||
{
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Empty body");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
buf[received] = '\0';
|
||||
|
||||
cJSON *body = cJSON_Parse(buf);
|
||||
if (!body)
|
||||
{
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
cJSON *mac_item = cJSON_GetObjectItem(body, "mac");
|
||||
if (!cJSON_IsString(mac_item) || !mac_item->valuestring || strlen(mac_item->valuestring) == 0)
|
||||
{
|
||||
cJSON_Delete(body);
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing 'mac'");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
bool was_new = false;
|
||||
device_t *dev = register_device(mac_item->valuestring, &was_new);
|
||||
|
||||
if (!dev)
|
||||
{
|
||||
cJSON_Delete(body);
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Device limit reached");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
cJSON *resp = cJSON_CreateObject();
|
||||
if (was_new)
|
||||
{
|
||||
cJSON_AddStringToObject(resp, "status", "ok");
|
||||
ESP_LOGI(kTagDeviceRegister, "Registered new device: %s", dev->mac);
|
||||
}
|
||||
else
|
||||
{
|
||||
cJSON_AddStringToObject(resp, "status", "already_registered");
|
||||
}
|
||||
cJSON_AddStringToObject(resp, "mac", dev->mac);
|
||||
|
||||
cJSON_Delete(body);
|
||||
|
||||
const char *json = cJSON_PrintUnformatted(resp);
|
||||
httpd_resp_sendstr(req, json);
|
||||
|
||||
free((void *)json);
|
||||
cJSON_Delete(resp);
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
internal const httpd_uri_t api_devices_register_uri = {
|
||||
.uri = "/api/devices/register",
|
||||
.method = HTTP_POST,
|
||||
.handler = api_devices_register_handler,
|
||||
.user_ctx = NULL};
|
||||
@@ -0,0 +1,59 @@
|
||||
// GET /api/devices/screen?mac=XX — Return the image URL for a device's current screen
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "esp_http_server.h"
|
||||
|
||||
#include "types.hpp"
|
||||
#include "device.hpp"
|
||||
|
||||
internal esp_err_t api_devices_screen_handler(httpd_req_t *req)
|
||||
{
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
|
||||
|
||||
// Extract mac query parameter
|
||||
char mac[18] = {};
|
||||
size_t buf_len = httpd_req_get_url_query_len(req) + 1;
|
||||
if (buf_len > 1)
|
||||
{
|
||||
char query[64] = {};
|
||||
if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK)
|
||||
{
|
||||
httpd_query_key_value(query, "mac", mac, sizeof(mac));
|
||||
}
|
||||
}
|
||||
|
||||
if (mac[0] == '\0')
|
||||
{
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing 'mac' query param");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
device_t *dev = find_device(mac);
|
||||
if (!dev)
|
||||
{
|
||||
httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Device not registered");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
// Build image_url: /api/devices/screen.png?mac=XX
|
||||
char image_url[64];
|
||||
snprintf(image_url, sizeof(image_url), "/api/devices/screen.png?mac=%s", mac);
|
||||
|
||||
cJSON *resp = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(resp, "image_url", image_url);
|
||||
|
||||
const char *json = cJSON_PrintUnformatted(resp);
|
||||
httpd_resp_sendstr(req, json);
|
||||
|
||||
free((void *)json);
|
||||
cJSON_Delete(resp);
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
internal const httpd_uri_t api_devices_screen_info_uri = {
|
||||
.uri = "/api/devices/screen",
|
||||
.method = HTTP_GET,
|
||||
.handler = api_devices_screen_handler,
|
||||
.user_ctx = NULL};
|
||||
@@ -0,0 +1,235 @@
|
||||
// GET /api/devices/screen.bin?mac=XX — Render and return a raw 2bpp grayscale
|
||||
// bitmap for the device's current screen.
|
||||
// Uses LVGL to render the device's XML layout, quantizes to 4 grayscale levels,
|
||||
// and packs into 2 bits per pixel (96,000 bytes for 800×480).
|
||||
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_http_server.h"
|
||||
#include "esp_log.h"
|
||||
#include "lv_setup.hpp"
|
||||
#include "lvgl.h"
|
||||
#include <string.h>
|
||||
|
||||
#include "device.hpp"
|
||||
#include "types.hpp"
|
||||
|
||||
internal const char *kTagDeviceScreenBitmap = "API_DEV_SCREEN_BMP";
|
||||
|
||||
internal esp_err_t api_devices_screen_bitmap_handler(httpd_req_t *req)
|
||||
{
|
||||
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
|
||||
httpd_resp_set_hdr(req, "Cache-Control",
|
||||
"no-cache, no-store, must-revalidate");
|
||||
httpd_resp_set_type(req, "application/octet-stream");
|
||||
|
||||
// Extract mac query parameter
|
||||
char mac[18] = {};
|
||||
size_t buf_len = httpd_req_get_url_query_len(req) + 1;
|
||||
if (buf_len > 1)
|
||||
{
|
||||
char query[64] = {};
|
||||
if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK)
|
||||
{
|
||||
httpd_query_key_value(query, "mac", mac, sizeof(mac));
|
||||
}
|
||||
}
|
||||
|
||||
if (mac[0] == '\0')
|
||||
{
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST,
|
||||
"Missing 'mac' query param");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
device_t *dev = find_device(mac);
|
||||
if (!dev)
|
||||
{
|
||||
httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Device not registered");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
// --- LVGL rendering (mutex-protected) ---
|
||||
if (xSemaphoreTake(g_LvglMutex, pdMS_TO_TICKS(5000)) != pdTRUE)
|
||||
{
|
||||
ESP_LOGE(kTagDeviceScreenBitmap, "Failed to get LVGL mutex");
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "LVGL busy");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
lv_obj_t *scr = lv_screen_active();
|
||||
|
||||
// Clear all children from the active screen
|
||||
lv_obj_clean(scr);
|
||||
|
||||
// White background for grayscale
|
||||
lv_obj_set_style_bg_color(scr, lv_color_white(), LV_PART_MAIN);
|
||||
|
||||
// Setup the MAC address subject so the XML can bind to it
|
||||
static lv_subject_t mac_subject;
|
||||
static char mac_buf[18];
|
||||
static char mac_prev_buf[18];
|
||||
|
||||
strncpy(mac_buf, mac, sizeof(mac_buf));
|
||||
strncpy(mac_prev_buf, mac, sizeof(mac_prev_buf));
|
||||
|
||||
lv_subject_init_string(&mac_subject, mac_buf, mac_prev_buf, sizeof(mac_buf),
|
||||
mac);
|
||||
|
||||
// Register the subject in the global XML scope under the name "device_mac"
|
||||
lv_xml_component_scope_t *global_scope =
|
||||
lv_xml_component_get_scope("globals");
|
||||
if (global_scope)
|
||||
{
|
||||
lv_xml_register_subject(global_scope, "device_mac", &mac_subject);
|
||||
ESP_LOGI(kTagDeviceScreenBitmap,
|
||||
"Registered subject 'device_mac' with value: %s", mac);
|
||||
}
|
||||
|
||||
bool render_success = false;
|
||||
|
||||
// 1. Prepare the XML payload
|
||||
const char *xml_to_register = NULL;
|
||||
|
||||
if (dev->xml_layout[0] == '\0')
|
||||
{
|
||||
ESP_LOGI(kTagDeviceScreenBitmap, "Device %s has no layout xml.", mac);
|
||||
xSemaphoreGive(g_LvglMutex);
|
||||
httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "No layout configured");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
if (strstr(dev->xml_layout, "<screen") != NULL)
|
||||
{
|
||||
xml_to_register = dev->xml_layout;
|
||||
ESP_LOGI(kTagDeviceScreenBitmap,
|
||||
"XML already contains <screen>, passing directly to parser.");
|
||||
}
|
||||
|
||||
// 2. Register the XML payload as a component
|
||||
lv_result_t res =
|
||||
lv_xml_register_component_from_data("current_device", xml_to_register);
|
||||
|
||||
if (res == LV_RESULT_OK)
|
||||
{
|
||||
ESP_LOGI(kTagDeviceScreenBitmap,
|
||||
"Successfully registered XML for device %s", mac);
|
||||
|
||||
lv_obj_t *new_scr = lv_xml_create_screen("current_device");
|
||||
|
||||
if (new_scr)
|
||||
{
|
||||
lv_screen_load(new_scr);
|
||||
scr = new_scr;
|
||||
render_success = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
ESP_LOGE(kTagDeviceScreenBitmap,
|
||||
"lv_xml_create_screen failed for device %s", mac);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ESP_LOGE(kTagDeviceScreenBitmap,
|
||||
"lv_xml_register_component_from_data failed for device %s", mac);
|
||||
}
|
||||
|
||||
// 3. Fallback if LVGL XML parsing or creation failed
|
||||
if (!render_success)
|
||||
{
|
||||
ESP_LOGW(kTagDeviceScreenBitmap,
|
||||
"XML render failed, falling back to raw text layout");
|
||||
lv_obj_t *label = lv_label_create(scr);
|
||||
lv_label_set_text(label, "XML Parsing Error\nSee serial log");
|
||||
lv_obj_set_style_text_color(label, lv_color_black(), LV_PART_MAIN);
|
||||
lv_obj_align(label, LV_ALIGN_CENTER, 0, 0);
|
||||
}
|
||||
|
||||
// Force LVGL to fully render the screen
|
||||
lv_refr_now(g_LvglDisplay);
|
||||
|
||||
lv_draw_buf_t *draw_buf = lv_display_get_buf_active(g_LvglDisplay);
|
||||
if (!draw_buf)
|
||||
{
|
||||
xSemaphoreGive(g_LvglMutex);
|
||||
ESP_LOGE(kTagDeviceScreenBitmap, "No active draw buffer");
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
|
||||
"Display uninitialized");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
uint32_t width = CONFIG_CALENDINK_DISPLAY_WIDTH;
|
||||
uint32_t height = CONFIG_CALENDINK_DISPLAY_HEIGHT;
|
||||
|
||||
// Output: 2 bits per pixel, 4 pixels per byte → width*height/4 bytes
|
||||
constexpr uint32_t kBitmapSize = 96000; // 800 * 480 / 4
|
||||
uint8_t *bitmap = (uint8_t *)heap_caps_malloc(kBitmapSize, MALLOC_CAP_SPIRAM);
|
||||
if (!bitmap)
|
||||
{
|
||||
bitmap = (uint8_t *)malloc(kBitmapSize);
|
||||
if (!bitmap)
|
||||
{
|
||||
xSemaphoreGive(g_LvglMutex);
|
||||
ESP_LOGE(kTagDeviceScreenBitmap, "Failed to allocate bitmap buffer");
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
|
||||
"Out of memory");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
}
|
||||
|
||||
// LVGL renders into RGB565 (2 bytes per pixel).
|
||||
// Quantize to 4 grayscale levels and pack 4 pixels per byte (2bpp).
|
||||
// Pixel encoding (MSB first):
|
||||
// 0b00 = BLACK (lum 0)
|
||||
// 0b01 = DARK_GRAY (lum 85)
|
||||
// 0b10 = LIGHT_GRAY (lum 170)
|
||||
// 0b11 = WHITE (lum 255)
|
||||
uint32_t bitmap_idx = 0;
|
||||
for (uint32_t y = 0; y < height; ++y)
|
||||
{
|
||||
const uint16_t *src_row =
|
||||
(const uint16_t *)((const uint8_t *)draw_buf->data +
|
||||
(y * draw_buf->header.stride));
|
||||
|
||||
for (uint32_t x = 0; x < width; x += 4)
|
||||
{
|
||||
uint8_t packed = 0;
|
||||
for (int p = 0; p < 4; ++p)
|
||||
{
|
||||
uint16_t c = src_row[x + p];
|
||||
// Expand 5/6/5 components
|
||||
uint8_t r_5 = (c >> 11) & 0x1F;
|
||||
uint8_t g_6 = (c >> 5) & 0x3F;
|
||||
uint8_t b_5 = c & 0x1F;
|
||||
|
||||
// Unpack to 8-bit
|
||||
uint8_t r = (r_5 << 3) | (r_5 >> 2);
|
||||
uint8_t g = (g_6 << 2) | (g_6 >> 4);
|
||||
uint8_t b = (b_5 << 3) | (b_5 >> 2);
|
||||
|
||||
// Luminance → 2-bit quantized level (0..3)
|
||||
uint8_t lum = (r * 77 + g * 150 + b * 29) >> 8;
|
||||
uint8_t level = lum >> 6; // 0,1,2,3
|
||||
|
||||
packed |= (level << (6 - p * 2));
|
||||
}
|
||||
bitmap[bitmap_idx++] = packed;
|
||||
}
|
||||
}
|
||||
|
||||
xSemaphoreGive(g_LvglMutex);
|
||||
|
||||
ESP_LOGI(kTagDeviceScreenBitmap, "Bitmap ready: %lu bytes. Sending...",
|
||||
(unsigned long)bitmap_idx);
|
||||
esp_err_t sendRes = httpd_resp_send(req, (const char *)bitmap, bitmap_idx);
|
||||
|
||||
free(bitmap);
|
||||
|
||||
return sendRes;
|
||||
}
|
||||
|
||||
internal const httpd_uri_t api_devices_screen_bitmap_uri = {
|
||||
.uri = "/api/devices/screen.bin",
|
||||
.method = HTTP_GET,
|
||||
.handler = api_devices_screen_bitmap_handler,
|
||||
.user_ctx = NULL};
|
||||
@@ -0,0 +1,249 @@
|
||||
// GET /api/devices/screen.png?mac=XX — Render and return a PNG for the device's
|
||||
// current screen Uses LVGL to render the device's XML layout (or a fallback
|
||||
// label) then encodes to PNG via lodepng.
|
||||
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_http_server.h"
|
||||
#include "esp_log.h"
|
||||
#include "lodepng/lodepng.h"
|
||||
#include "lodepng_alloc.hpp"
|
||||
#include "lv_setup.hpp"
|
||||
#include "lvgl.h"
|
||||
#include <string.h>
|
||||
|
||||
#include "device.hpp"
|
||||
#include "types.hpp"
|
||||
|
||||
internal const char *kTagDeviceScreenImage = "API_DEV_SCREEN_IMG";
|
||||
|
||||
internal esp_err_t api_devices_screen_image_handler(httpd_req_t *req)
|
||||
{
|
||||
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
|
||||
httpd_resp_set_hdr(req, "Cache-Control",
|
||||
"no-cache, no-store, must-revalidate");
|
||||
httpd_resp_set_type(req, "image/png");
|
||||
|
||||
// Extract mac query parameter
|
||||
char mac[18] = {};
|
||||
size_t buf_len = httpd_req_get_url_query_len(req) + 1;
|
||||
if (buf_len > 1)
|
||||
{
|
||||
char query[64] = {};
|
||||
if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK)
|
||||
{
|
||||
httpd_query_key_value(query, "mac", mac, sizeof(mac));
|
||||
}
|
||||
}
|
||||
|
||||
if (mac[0] == '\0')
|
||||
{
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST,
|
||||
"Missing 'mac' query param");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
device_t *dev = find_device(mac);
|
||||
if (!dev)
|
||||
{
|
||||
httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Device not registered");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
// --- LVGL rendering (mutex-protected) ---
|
||||
if (xSemaphoreTake(g_LvglMutex, pdMS_TO_TICKS(5000)) != pdTRUE)
|
||||
{
|
||||
ESP_LOGE(kTagDeviceScreenImage, "Failed to get LVGL mutex");
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "LVGL busy");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
lv_obj_t *scr = lv_screen_active();
|
||||
|
||||
// Clear all children from the active screen
|
||||
lv_obj_clean(scr);
|
||||
|
||||
// White background for grayscale
|
||||
lv_obj_set_style_bg_color(scr, lv_color_white(), LV_PART_MAIN);
|
||||
|
||||
// Setup the MAC address subject so the XML can bind to it
|
||||
static lv_subject_t mac_subject;
|
||||
// Two buffers are needed by LVGL for string observers (current and previous)
|
||||
static char mac_buf[18];
|
||||
static char mac_prev_buf[18];
|
||||
|
||||
strncpy(mac_buf, mac, sizeof(mac_buf));
|
||||
strncpy(mac_prev_buf, mac, sizeof(mac_prev_buf));
|
||||
|
||||
lv_subject_init_string(&mac_subject, mac_buf, mac_prev_buf, sizeof(mac_buf),
|
||||
mac);
|
||||
|
||||
// Register the subject in the global XML scope under the name "device_mac"
|
||||
lv_xml_component_scope_t *global_scope =
|
||||
lv_xml_component_get_scope("globals");
|
||||
if (global_scope)
|
||||
{
|
||||
lv_xml_register_subject(global_scope, "device_mac", &mac_subject);
|
||||
ESP_LOGI(kTagDeviceScreenImage,
|
||||
"Registered subject 'device_mac' with value: %s", mac);
|
||||
}
|
||||
|
||||
bool render_success = false;
|
||||
|
||||
// 1. Prepare the XML payload
|
||||
const char *xml_to_register = NULL;
|
||||
static char
|
||||
xml_buffer[DEVICE_XML_MAX + 100]; // static buffer to avoid stack overflow
|
||||
|
||||
if (dev->xml_layout[0] == '\0')
|
||||
{
|
||||
ESP_LOGI(kTagDeviceScreenImage, "Device %s has no layout xml.", mac);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
if (strstr(dev->xml_layout, "<screen") != NULL)
|
||||
{
|
||||
// The user provided a correct <screen> wrapped XML
|
||||
xml_to_register = dev->xml_layout;
|
||||
ESP_LOGI(kTagDeviceScreenImage,
|
||||
"XML already contains <screen>, passing directly to parser.");
|
||||
}
|
||||
|
||||
// 2. Register the XML payload as a component
|
||||
lv_result_t res =
|
||||
lv_xml_register_component_from_data("current_device", xml_to_register);
|
||||
|
||||
if (res == LV_RESULT_OK)
|
||||
{
|
||||
ESP_LOGI(kTagDeviceScreenImage, "Successfully registered XML for device %s",
|
||||
mac);
|
||||
|
||||
// 3. Since we enforce <screen> now, we always create a screen instance
|
||||
lv_obj_t *new_scr = lv_xml_create_screen("current_device");
|
||||
|
||||
if (new_scr)
|
||||
{
|
||||
// We must load this newly created screen to make it active before
|
||||
// rendering
|
||||
lv_screen_load(new_scr);
|
||||
scr = new_scr; // Update local pointer since active screen changed
|
||||
render_success = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
ESP_LOGE(kTagDeviceScreenImage,
|
||||
"lv_xml_create_screen failed for device %s", mac);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ESP_LOGE(kTagDeviceScreenImage,
|
||||
"lv_xml_register_component_from_data failed for device %s", mac);
|
||||
}
|
||||
|
||||
// 3. Fallback if LVGL XML parsing or creation failed
|
||||
if (!render_success)
|
||||
{
|
||||
ESP_LOGW(kTagDeviceScreenImage,
|
||||
"XML render failed, falling back to raw text layout");
|
||||
lv_obj_t *label = lv_label_create(scr);
|
||||
lv_label_set_text(label, "XML Parsing Error\nSee serial log");
|
||||
lv_obj_set_style_text_color(label, lv_color_black(), LV_PART_MAIN);
|
||||
lv_obj_align(label, LV_ALIGN_CENTER, 0, 0);
|
||||
}
|
||||
|
||||
// Force LVGL to fully render the screen
|
||||
lv_refr_now(g_LvglDisplay);
|
||||
|
||||
lv_draw_buf_t *draw_buf = lv_display_get_buf_active(g_LvglDisplay);
|
||||
if (!draw_buf)
|
||||
{
|
||||
xSemaphoreGive(g_LvglMutex);
|
||||
ESP_LOGE(kTagDeviceScreenImage, "No active draw buffer");
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
|
||||
"Display uninitialized");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
uint32_t width = CONFIG_CALENDINK_DISPLAY_WIDTH;
|
||||
uint32_t height = CONFIG_CALENDINK_DISPLAY_HEIGHT;
|
||||
|
||||
// Allocate bounding memory for quantizing RGB565 buffer into tightly packed
|
||||
// 8-bit PNG data.
|
||||
uint8_t *packed_data =
|
||||
(uint8_t *)heap_caps_malloc(width * height, MALLOC_CAP_SPIRAM);
|
||||
if (!packed_data)
|
||||
{
|
||||
packed_data = (uint8_t *)malloc(width * height);
|
||||
if (!packed_data)
|
||||
{
|
||||
xSemaphoreGive(g_LvglMutex);
|
||||
ESP_LOGE(kTagDeviceScreenImage, "Failed to allocate packed buffer");
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
|
||||
"Out of memory");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
}
|
||||
|
||||
// LVGL renders into RGB565 (2 bytes per pixel).
|
||||
// Parse pixels, extract luminance, and quantize to 4 levels (0, 85, 170, 255).
|
||||
for (uint32_t y = 0; y < height; ++y)
|
||||
{
|
||||
const uint16_t *src_row = (const uint16_t *)((const uint8_t *)draw_buf->data + (y * draw_buf->header.stride));
|
||||
uint8_t *dst_row = packed_data + (y * width);
|
||||
|
||||
for (uint32_t x = 0; x < width; ++x)
|
||||
{
|
||||
uint16_t c = src_row[x];
|
||||
// Expand 5/6/5 components
|
||||
uint8_t r_5 = (c >> 11) & 0x1F;
|
||||
uint8_t g_6 = (c >> 5) & 0x3F;
|
||||
uint8_t b_5 = c & 0x1F;
|
||||
|
||||
// Unpack to 8-bit true values
|
||||
uint8_t r = (r_5 << 3) | (r_5 >> 2);
|
||||
uint8_t g = (g_6 << 2) | (g_6 >> 4);
|
||||
uint8_t b = (b_5 << 3) | (b_5 >> 2);
|
||||
|
||||
// Simple luminance
|
||||
uint8_t lum = (r * 77 + g * 150 + b * 29) >> 8;
|
||||
|
||||
// 4-level linear quantization (0, 85, 170, 255)
|
||||
dst_row[x] = (lum >> 6) * 85;
|
||||
}
|
||||
}
|
||||
|
||||
// Encode to PNG
|
||||
unsigned char *png = nullptr;
|
||||
size_t pngsize = 0;
|
||||
|
||||
lodepng_allocator_reset();
|
||||
|
||||
ESP_LOGI(kTagDeviceScreenImage, "Encoding %lux%lu PNG for device %s", width,
|
||||
height, mac);
|
||||
unsigned error = lodepng_encode_memory(&png, &pngsize, packed_data, width,
|
||||
height, LCT_GREY, 8);
|
||||
|
||||
free(packed_data);
|
||||
|
||||
xSemaphoreGive(g_LvglMutex);
|
||||
|
||||
if (error)
|
||||
{
|
||||
ESP_LOGE(kTagDeviceScreenImage, "PNG encoding error %u: %s", error,
|
||||
lodepng_error_text(error));
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
|
||||
"PNG generation failed");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
ESP_LOGI(kTagDeviceScreenImage, "PNG ready: %zu bytes. Sending...", pngsize);
|
||||
esp_err_t sendRes = httpd_resp_send(req, (const char *)png, pngsize);
|
||||
|
||||
return sendRes;
|
||||
}
|
||||
|
||||
internal const httpd_uri_t api_devices_screen_image_uri = {
|
||||
.uri = "/api/devices/screen.png",
|
||||
.method = HTTP_GET,
|
||||
.handler = api_devices_screen_image_handler,
|
||||
.user_ctx = NULL};
|
||||
@@ -0,0 +1,57 @@
|
||||
// Device data store: CRUD helpers
|
||||
|
||||
#include "device.hpp"
|
||||
|
||||
// Find a device by MAC address, returns nullptr if not found
|
||||
internal device_t *find_device(const char *mac)
|
||||
{
|
||||
for (int i = 0; i < MAX_DEVICES; i++)
|
||||
{
|
||||
if (g_Devices[i].active && strcmp(g_Devices[i].mac, mac) == 0)
|
||||
{
|
||||
return &g_Devices[i];
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Register a device by MAC. Returns pointer to device (existing or new).
|
||||
// Sets *was_new to true if it was freshly registered.
|
||||
internal device_t *register_device(const char *mac, bool *was_new)
|
||||
{
|
||||
*was_new = false;
|
||||
|
||||
// Check for existing
|
||||
device_t *existing = find_device(mac);
|
||||
if (existing)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Find a free slot
|
||||
for (int i = 0; i < MAX_DEVICES; i++)
|
||||
{
|
||||
if (!g_Devices[i].active)
|
||||
{
|
||||
strlcpy(g_Devices[i].mac, mac, sizeof(g_Devices[i].mac));
|
||||
g_Devices[i].active = true;
|
||||
strlcpy(g_Devices[i].xml_layout, kDefaultLayoutXml, sizeof(g_Devices[i].xml_layout));
|
||||
*was_new = true;
|
||||
return &g_Devices[i];
|
||||
}
|
||||
}
|
||||
|
||||
return nullptr; // All slots full
|
||||
}
|
||||
|
||||
// Update the XML layout for a device. Returns true on success.
|
||||
internal bool update_device_layout(const char *mac, const char *xml)
|
||||
{
|
||||
device_t *dev = find_device(mac);
|
||||
if (!dev)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
strlcpy(dev->xml_layout, xml, sizeof(dev->xml_layout));
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Unity build entry for device endpoints
|
||||
// clang-format off
|
||||
#include "api/devices/store.cpp"
|
||||
#include "api/devices/list.cpp"
|
||||
#include "api/devices/register.cpp"
|
||||
#include "api/devices/layout.cpp"
|
||||
#include "api/devices/screen.cpp"
|
||||
#include "api/devices/screen_image.cpp"
|
||||
#include "api/devices/screen_bitmap.cpp"
|
||||
// clang-format on
|
||||
@@ -1,113 +1,145 @@
|
||||
#include "../../lv_setup.hpp"
|
||||
#include "../../lodepng/lodepng.h"
|
||||
#include "../../lodepng_alloc.hpp"
|
||||
#include "../../lv_setup.hpp"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_http_server.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_random.h"
|
||||
#include "lvgl.h"
|
||||
#include <string.h>
|
||||
#include "../../lodepng_alloc.hpp"
|
||||
|
||||
internal const char *kTagDisplayImage = "API_DISPLAY_IMAGE";
|
||||
|
||||
internal esp_err_t api_display_image_handler(httpd_req_t *req)
|
||||
{
|
||||
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
|
||||
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
|
||||
|
||||
// We are generating PNG on the fly, don't let it be cached locally immediately
|
||||
httpd_resp_set_hdr(req, "Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
httpd_resp_set_type(req, "image/png");
|
||||
// We are generating PNG on the fly, don't let it be cached locally
|
||||
// immediately
|
||||
httpd_resp_set_hdr(req, "Cache-Control",
|
||||
"no-cache, no-store, must-revalidate");
|
||||
httpd_resp_set_type(req, "image/png");
|
||||
|
||||
if (xSemaphoreTake(g_LvglMutex, pdMS_TO_TICKS(5000)) != pdTRUE)
|
||||
{
|
||||
ESP_LOGE(kTagDisplayImage, "Failed to get LVGL mutex");
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "LVGL busy");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
if (xSemaphoreTake(g_LvglMutex, pdMS_TO_TICKS(5000)) != pdTRUE)
|
||||
{
|
||||
ESP_LOGE(kTagDisplayImage, "Failed to get LVGL mutex");
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "LVGL busy");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
// Change the background color securely to a random grayscale value
|
||||
// esp_random() returns 32 bits, we just take the lowest 8.
|
||||
uint8_t rand_gray = esp_random() & 0xFF;
|
||||
lv_obj_t* active_screen = lv_screen_active();
|
||||
lv_obj_set_style_bg_color(active_screen, lv_color_make(rand_gray, rand_gray, rand_gray), LV_PART_MAIN);
|
||||
// Change the background color securely to a random grayscale value
|
||||
// esp_random() returns 32 bits, we just take the lowest 8.
|
||||
uint8_t rand_gray = esp_random() & 0xFF;
|
||||
lv_obj_t *active_screen = lv_screen_active();
|
||||
// lv_obj_set_style_bg_color(active_screen, lv_color_make(rand_gray,
|
||||
// rand_gray, rand_gray), LV_PART_MAIN);
|
||||
|
||||
// Force a screen refresh to get the latest rendered frame
|
||||
lv_refr_now(g_LvglDisplay);
|
||||
|
||||
lv_draw_buf_t *draw_buf = lv_display_get_buf_active(g_LvglDisplay);
|
||||
if (!draw_buf)
|
||||
{
|
||||
xSemaphoreGive(g_LvglMutex);
|
||||
ESP_LOGE(kTagDisplayImage, "No active draw buffer available");
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Display uninitialized");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
uint32_t width = CONFIG_CALENDINK_DISPLAY_WIDTH;
|
||||
uint32_t height = CONFIG_CALENDINK_DISPLAY_HEIGHT;
|
||||
|
||||
// LodePNG expects tightly packed data without stride padding.
|
||||
// Ensure we copy the data if stride differs from width.
|
||||
uint8_t *packed_data = (uint8_t *)draw_buf->data;
|
||||
bool needs_free = false;
|
||||
|
||||
if (draw_buf->header.stride != width)
|
||||
{
|
||||
ESP_LOGI(kTagDisplayImage, "Stride %lu differs from width %lu. Repacking buffer...", draw_buf->header.stride, width);
|
||||
packed_data = (uint8_t *)heap_caps_malloc(width * height, MALLOC_CAP_SPIRAM);
|
||||
if (!packed_data)
|
||||
{
|
||||
xSemaphoreGive(g_LvglMutex);
|
||||
ESP_LOGE(kTagDisplayImage, "Failed to allocate packed buffer in PSRAM");
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
needs_free = true;
|
||||
|
||||
for (uint32_t y = 0; y < height; ++y)
|
||||
{
|
||||
memcpy(packed_data + (y * width), (uint8_t *)draw_buf->data + (y * draw_buf->header.stride), width);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert LVGL 8-bit L8 buffer to 8-bit grayscale PNG using LodePNG.
|
||||
// LCT_GREY = 0, bitdepth = 8
|
||||
unsigned char *png = nullptr;
|
||||
size_t pngsize = 0;
|
||||
|
||||
// We are about to start a huge memory operation inside LodePNG.
|
||||
// We reset our 2MB PSRAM bump allocator to 0 bytes used.
|
||||
lodepng_allocator_reset();
|
||||
|
||||
ESP_LOGI(kTagDisplayImage, "Encoding %lux%lu frame to PNG...", width, height);
|
||||
unsigned error = lodepng_encode_memory(&png, &pngsize, packed_data, width, height, LCT_GREY, 8);
|
||||
|
||||
if (needs_free)
|
||||
{
|
||||
free(packed_data);
|
||||
}
|
||||
// Force a screen refresh to get the latest rendered frame
|
||||
lv_refr_now(g_LvglDisplay);
|
||||
|
||||
lv_draw_buf_t *draw_buf = lv_display_get_buf_active(g_LvglDisplay);
|
||||
if (!draw_buf)
|
||||
{
|
||||
xSemaphoreGive(g_LvglMutex);
|
||||
ESP_LOGE(kTagDisplayImage, "No active draw buffer available");
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
|
||||
"Display uninitialized");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
if (error)
|
||||
uint32_t width = CONFIG_CALENDINK_DISPLAY_WIDTH;
|
||||
uint32_t height = CONFIG_CALENDINK_DISPLAY_HEIGHT;
|
||||
|
||||
// We allocate a new buffer for the tightly packed 8-bit PNG grayscale data.
|
||||
// Converting RGB565 frame to 4-level grayscale (quantized to 0, 85, 170, 255).
|
||||
uint8_t *packed_data =
|
||||
(uint8_t *)heap_caps_malloc(width * height, MALLOC_CAP_SPIRAM);
|
||||
if (!packed_data)
|
||||
{
|
||||
packed_data = (uint8_t *)malloc(width * height); // Fallback
|
||||
if (!packed_data)
|
||||
{
|
||||
ESP_LOGE(kTagDisplayImage, "PNG encoding error %u: %s", error, lodepng_error_text(error));
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "PNG generation failed");
|
||||
return ESP_FAIL;
|
||||
xSemaphoreGive(g_LvglMutex);
|
||||
ESP_LOGE(kTagDisplayImage, "Failed to allocate packed buffer");
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
|
||||
"Out of memory");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
}
|
||||
|
||||
ESP_LOGI(kTagDisplayImage, "Prepared PNG, size: %zu bytes. Sending to client...", pngsize);
|
||||
esp_err_t res = httpd_resp_send(req, (const char *)png, pngsize);
|
||||
// LVGL renders into RGB565 (2 bytes per pixel).
|
||||
// Iterating to create an 8-bit grayscale PNG using 4 specific values.
|
||||
for (uint32_t y = 0; y < height; ++y)
|
||||
{
|
||||
const uint16_t *src_row =
|
||||
(const uint16_t *)((const uint8_t *)draw_buf->data +
|
||||
(y * draw_buf->header.stride));
|
||||
uint8_t *dst_row = packed_data + (y * width);
|
||||
|
||||
// No need to free(png) because it is managed by our bump allocator
|
||||
// which automatically resets the entire 2MB buffer to 0 next time
|
||||
// lodepng_allocator_reset() is called.
|
||||
for (uint32_t x = 0; x < width; ++x)
|
||||
{
|
||||
uint16_t c = src_row[x];
|
||||
// Note: LVGL may use swapped bytes for SPI rendering depending on config,
|
||||
// but in memory RGB565 is standard if no SWAP is active. Usually standard
|
||||
// RGB565 format: R(5) G(6) B(5)
|
||||
uint8_t r_5 = (c >> 11) & 0x1F;
|
||||
uint8_t g_6 = (c >> 5) & 0x3F;
|
||||
uint8_t b_5 = c & 0x1F;
|
||||
|
||||
return res;
|
||||
// Expand to 8 bits
|
||||
uint8_t r = (r_5 << 3) | (r_5 >> 2);
|
||||
uint8_t g = (g_6 << 2) | (g_6 >> 4);
|
||||
uint8_t b = (b_5 << 3) | (b_5 >> 2);
|
||||
|
||||
// Simple luminance calculation (fast)
|
||||
uint8_t lum = (r * 77 + g * 150 + b * 29) >> 8;
|
||||
|
||||
// Quantize to 4 levels (0..3)
|
||||
uint8_t level = lum >> 6;
|
||||
|
||||
// Expand level back to 8-bit for PNG: 0, 85, 170, 255
|
||||
dst_row[x] = level * 85;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert LVGL 8-bit L8 buffer to 8-bit grayscale PNG using LodePNG.
|
||||
// LCT_GREY = 0, bitdepth = 8
|
||||
unsigned char *png = nullptr;
|
||||
size_t pngsize = 0;
|
||||
|
||||
// We are about to start a huge memory operation inside LodePNG.
|
||||
// We reset our 3MB PSRAM bump allocator to 0 bytes used.
|
||||
lodepng_allocator_reset();
|
||||
|
||||
ESP_LOGI(kTagDisplayImage, "Encoding %lux%lu frame to PNG...", width, height);
|
||||
unsigned error = lodepng_encode_memory(&png, &pngsize, packed_data, width,
|
||||
height, LCT_GREY, 8);
|
||||
|
||||
free(packed_data);
|
||||
|
||||
xSemaphoreGive(g_LvglMutex);
|
||||
|
||||
if (error)
|
||||
{
|
||||
ESP_LOGE(kTagDisplayImage, "PNG encoding error %u: %s", error,
|
||||
lodepng_error_text(error));
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
|
||||
"PNG generation failed");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
ESP_LOGI(kTagDisplayImage,
|
||||
"Prepared PNG, size: %zu bytes. Sending to client...", pngsize);
|
||||
esp_err_t res = httpd_resp_send(req, (const char *)png, pngsize);
|
||||
|
||||
// No need to free(png) because it is managed by our bump allocator
|
||||
// which automatically resets the entire 2MB buffer to 0 next time
|
||||
// lodepng_allocator_reset() is called.
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
httpd_uri_t api_display_image_uri = {
|
||||
.uri = "/api/display/image.png",
|
||||
.method = HTTP_GET,
|
||||
.handler = api_display_image_handler,
|
||||
.user_ctx = NULL};
|
||||
httpd_uri_t api_display_image_uri = {.uri = "/api/display/image.png",
|
||||
.method = HTTP_GET,
|
||||
.handler = api_display_image_handler,
|
||||
.user_ctx = NULL};
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "types.hpp"
|
||||
|
||||
constexpr int MAX_DEVICES = 8;
|
||||
constexpr int DEVICE_XML_MAX = 2048;
|
||||
|
||||
constexpr char kDefaultLayoutXml[] =
|
||||
"<screen>\n"
|
||||
" <view width=\"100%\" height=\"100%\" layout=\"flex\" flex_flow=\"column\" style_flex_main_place=\"center\" style_flex_track_place=\"center\" style_pad_row=\"10\">\n"
|
||||
" <lv_label text=\"Hello World\" />\n"
|
||||
" <lv_label bind_text=\"device_mac\" />\n"
|
||||
" </view>\n"
|
||||
"</screen>";
|
||||
|
||||
struct device_t
|
||||
{
|
||||
char mac[18]; // "AA:BB:CC:DD:EE:FF\0"
|
||||
bool active; // Slot in use
|
||||
char xml_layout[DEVICE_XML_MAX]; // LVGL XML string for the current screen
|
||||
};
|
||||
|
||||
internal device_t g_Devices[MAX_DEVICES] = {};
|
||||
@@ -20,6 +20,7 @@
|
||||
#include "api/system/info.cpp"
|
||||
#include "api/system/reboot.cpp"
|
||||
#include "api/display/unity.cpp"
|
||||
#include "api/devices/unity.cpp"
|
||||
#include "api/tasks/unity.cpp"
|
||||
#include "api/users/unity.cpp"
|
||||
|
||||
@@ -264,7 +265,7 @@ internal httpd_handle_t start_webserver(void)
|
||||
|
||||
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
|
||||
config.uri_match_fn = httpd_uri_match_wildcard;
|
||||
config.max_uri_handlers = 20;
|
||||
config.max_uri_handlers = 26;
|
||||
config.max_open_sockets = 24;
|
||||
config.lru_purge_enable = true;
|
||||
config.stack_size = 16384;
|
||||
@@ -301,6 +302,14 @@ internal httpd_handle_t start_webserver(void)
|
||||
httpd_register_uri_handler(server, &api_tasks_update_uri);
|
||||
httpd_register_uri_handler(server, &api_tasks_delete_uri);
|
||||
|
||||
// Register device API routes
|
||||
httpd_register_uri_handler(server, &api_devices_get_uri);
|
||||
httpd_register_uri_handler(server, &api_devices_register_uri);
|
||||
httpd_register_uri_handler(server, &api_devices_layout_uri);
|
||||
httpd_register_uri_handler(server, &api_devices_screen_info_uri);
|
||||
httpd_register_uri_handler(server, &api_devices_screen_image_uri);
|
||||
httpd_register_uri_handler(server, &api_devices_screen_bitmap_uri);
|
||||
|
||||
// Populate dummy data for development (debug builds only)
|
||||
#ifndef NDEBUG
|
||||
seed_users();
|
||||
|
||||
@@ -14,8 +14,11 @@ dependencies:
|
||||
# # `public` flag doesn't have an effect dependencies of the `main` component.
|
||||
# # All dependencies of `main` are public by default.
|
||||
# public: true
|
||||
espressif/led_strip: ^3.0.3
|
||||
espressif/mdns: ^1.4.1
|
||||
espressif/ethernet_init: ^1.3.0
|
||||
joltwallet/littlefs: "^1.20" # https://github.com/joltwallet/esp_littlefs
|
||||
lvgl/lvgl: "^9.2.0"
|
||||
lvgl/lvgl: "9.4.0"
|
||||
network:
|
||||
path: "../../components/network"
|
||||
led:
|
||||
path: "../../components/led"
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
// Project includes
|
||||
#include "types.hpp"
|
||||
|
||||
// SDK Includes
|
||||
#include "led_strip.h"
|
||||
|
||||
// Could be a config but its the GPIO on my ESP32-S3-ETH
|
||||
#define LED_GPIO GPIO_NUM_21
|
||||
|
||||
enum class led_status : uint8
|
||||
{
|
||||
ConnectingEthernet,
|
||||
ConnectingWifi,
|
||||
ReadyEthernet,
|
||||
ReadyWifi,
|
||||
Failed
|
||||
};
|
||||
|
||||
internal led_strip_handle_t led_strip;
|
||||
|
||||
internal void setup_led(void)
|
||||
{
|
||||
/* LED strip initialization with the GPIO and pixels number*/
|
||||
led_strip_config_t strip_config = {};
|
||||
strip_config.strip_gpio_num = LED_GPIO;
|
||||
strip_config.max_leds = 1; // at least one LED on board
|
||||
|
||||
led_strip_rmt_config_t rmt_config = {};
|
||||
rmt_config.resolution_hz = 10 * 1000 * 1000; // 10MHz
|
||||
rmt_config.flags.with_dma = false;
|
||||
ESP_ERROR_CHECK(
|
||||
led_strip_new_rmt_device(&strip_config, &rmt_config, &led_strip));
|
||||
|
||||
led_strip_clear(led_strip);
|
||||
}
|
||||
|
||||
internal void destroy_led(void) { led_strip_clear(led_strip); }
|
||||
|
||||
internal void set_led_status(led_status status)
|
||||
{
|
||||
switch (status)
|
||||
{
|
||||
case led_status::ConnectingEthernet:
|
||||
led_strip_set_pixel(led_strip, 0, 255, 165, 0);
|
||||
break;
|
||||
case led_status::ConnectingWifi:
|
||||
led_strip_set_pixel(led_strip, 0, 148, 0, 211);
|
||||
break;
|
||||
case led_status::ReadyEthernet:
|
||||
led_strip_set_pixel(led_strip, 0, 0, 255, 0); // Green
|
||||
break;
|
||||
case led_status::ReadyWifi:
|
||||
led_strip_set_pixel(led_strip, 0, 0, 0, 255); // Blue
|
||||
break;
|
||||
case led_status::Failed:
|
||||
led_strip_set_pixel(led_strip, 0, 255, 0, 0);
|
||||
break;
|
||||
}
|
||||
led_strip_refresh(led_strip);
|
||||
}
|
||||
|
||||
#if CONFIG_CALENDINK_BLINK_IP
|
||||
internal void led_blink_number(int n, uint8_t r, uint8_t g, uint8_t b)
|
||||
{
|
||||
if (n <= 0)
|
||||
{
|
||||
for (int i = 0; i < 2; i++)
|
||||
{
|
||||
led_strip_set_pixel(led_strip, 0, r, g, b);
|
||||
led_strip_refresh(led_strip);
|
||||
vTaskDelay(pdMS_TO_TICKS(50));
|
||||
led_strip_clear(led_strip);
|
||||
led_strip_refresh(led_strip);
|
||||
vTaskDelay(pdMS_TO_TICKS(50));
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
led_strip_set_pixel(led_strip, 0, r, g, b);
|
||||
led_strip_refresh(led_strip);
|
||||
vTaskDelay(pdMS_TO_TICKS(300));
|
||||
led_strip_clear(led_strip);
|
||||
led_strip_refresh(led_strip);
|
||||
vTaskDelay(pdMS_TO_TICKS(300));
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||
}
|
||||
#endif
|
||||
+115
-104
@@ -4,149 +4,160 @@
|
||||
#include <string.h>
|
||||
|
||||
// LVGL's LodePNG memory optimization
|
||||
// Instead of standard heap allocations which fragment quickly and crash on the ESP32,
|
||||
// we allocate a single massive buffer in PSRAM and just bump a pointer during encode!
|
||||
// Instead of standard heap allocations which fragment quickly and crash on the
|
||||
// ESP32, we allocate a single massive buffer in PSRAM and just bump a pointer
|
||||
// during encode!
|
||||
|
||||
static const char* kTagLodeAlloc = "LODE_ALLOC";
|
||||
static const char *kTagLodeAlloc = "LODE_ALLOC";
|
||||
|
||||
// 2MB buffer for LodePNG encoding intermediate state.
|
||||
// A typical 800x480 grayscale PNG should compress to ~50-100KB, but the dynamic window
|
||||
// matching and filtering algorithms need a good amount of scratch space.
|
||||
// We can tune this down to 1MB if 2MB is too aggressive, but PSRAM provides 8MB.
|
||||
#define LODEPNG_ALLOC_POOL_SIZE (2 * 1024 * 1024)
|
||||
// A typical 800x480 grayscale PNG should compress to ~50-100KB, but the dynamic
|
||||
// window matching and filtering algorithms need a good amount of scratch space.
|
||||
// We can tune this down to 1MB if 2MB is too aggressive, but PSRAM provides
|
||||
// 8MB.
|
||||
#define LODEPNG_ALLOC_POOL_SIZE (1 * 1024 * 1024)
|
||||
|
||||
static uint8_t* s_lodepng_pool = nullptr;
|
||||
static uint8_t *s_lodepng_pool = nullptr;
|
||||
static size_t s_lodepng_pool_used = 0;
|
||||
|
||||
void lodepng_allocator_init()
|
||||
{
|
||||
if (s_lodepng_pool != nullptr) return;
|
||||
if (s_lodepng_pool != nullptr)
|
||||
return;
|
||||
|
||||
ESP_LOGI(kTagLodeAlloc, "Allocating %d bytes in PSRAM for LodePNG bump allocator...", LODEPNG_ALLOC_POOL_SIZE);
|
||||
ESP_LOGI(kTagLodeAlloc,
|
||||
"Allocating %d bytes in PSRAM for LodePNG bump allocator...",
|
||||
LODEPNG_ALLOC_POOL_SIZE);
|
||||
|
||||
// SPIRAM fallback to internal if someone tests without a PSRAM chip
|
||||
s_lodepng_pool = (uint8_t*)heap_caps_malloc(LODEPNG_ALLOC_POOL_SIZE, MALLOC_CAP_SPIRAM);
|
||||
if (!s_lodepng_pool)
|
||||
{
|
||||
s_lodepng_pool = (uint8_t*)heap_caps_malloc(LODEPNG_ALLOC_POOL_SIZE, MALLOC_CAP_DEFAULT);
|
||||
}
|
||||
// SPIRAM fallback to internal if someone tests without a PSRAM chip
|
||||
s_lodepng_pool =
|
||||
(uint8_t *)heap_caps_malloc(LODEPNG_ALLOC_POOL_SIZE, MALLOC_CAP_SPIRAM);
|
||||
if (!s_lodepng_pool)
|
||||
{
|
||||
s_lodepng_pool = (uint8_t *)heap_caps_malloc(LODEPNG_ALLOC_POOL_SIZE,
|
||||
MALLOC_CAP_DEFAULT);
|
||||
}
|
||||
|
||||
if (!s_lodepng_pool)
|
||||
{
|
||||
ESP_LOGE(kTagLodeAlloc, "CRITICAL: Failed to allocate LodePNG PSRAM pool!");
|
||||
}
|
||||
if (!s_lodepng_pool)
|
||||
{
|
||||
ESP_LOGE(kTagLodeAlloc, "CRITICAL: Failed to allocate LodePNG PSRAM pool!");
|
||||
}
|
||||
}
|
||||
|
||||
void lodepng_allocator_reset()
|
||||
{
|
||||
s_lodepng_pool_used = 0;
|
||||
}
|
||||
void lodepng_allocator_reset() { s_lodepng_pool_used = 0; }
|
||||
|
||||
void lodepng_allocator_free()
|
||||
{
|
||||
if (s_lodepng_pool)
|
||||
{
|
||||
free(s_lodepng_pool);
|
||||
s_lodepng_pool = nullptr;
|
||||
}
|
||||
s_lodepng_pool_used = 0;
|
||||
if (s_lodepng_pool)
|
||||
{
|
||||
free(s_lodepng_pool);
|
||||
s_lodepng_pool = nullptr;
|
||||
}
|
||||
s_lodepng_pool_used = 0;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// Custom Allocators injected into lodepng.c
|
||||
// ----------------------------------------------------
|
||||
|
||||
// To support realloc properly, we prefix each allocation with an 8-byte header storing the size.
|
||||
struct AllocHeader {
|
||||
size_t size;
|
||||
// To support realloc properly, we prefix each allocation with an 8-byte header
|
||||
// storing the size.
|
||||
struct AllocHeader
|
||||
{
|
||||
size_t size;
|
||||
};
|
||||
|
||||
void* lodepng_custom_malloc(size_t size)
|
||||
void *lodepng_custom_malloc(size_t size)
|
||||
{
|
||||
if (!s_lodepng_pool)
|
||||
{
|
||||
ESP_LOGE(kTagLodeAlloc, "lodepng_malloc called before lodepng_allocator_init!");
|
||||
return nullptr;
|
||||
}
|
||||
if (!s_lodepng_pool)
|
||||
{
|
||||
ESP_LOGE(kTagLodeAlloc,
|
||||
"lodepng_malloc called before lodepng_allocator_init!");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Align size to 8 bytes to avoid unaligned access faults
|
||||
size_t aligned_size = (size + 7) & ~7;
|
||||
size_t total_alloc = sizeof(AllocHeader) + aligned_size;
|
||||
// Align size to 8 bytes to avoid unaligned access faults
|
||||
size_t aligned_size = (size + 7) & ~7;
|
||||
size_t total_alloc = sizeof(AllocHeader) + aligned_size;
|
||||
|
||||
if (s_lodepng_pool_used + total_alloc > LODEPNG_ALLOC_POOL_SIZE)
|
||||
{
|
||||
ESP_LOGE(kTagLodeAlloc, "LodePNG pool exhausted! Requested: %zu, Used: %zu, Total: %d", size, s_lodepng_pool_used, LODEPNG_ALLOC_POOL_SIZE);
|
||||
return nullptr;
|
||||
}
|
||||
if (s_lodepng_pool_used + total_alloc > LODEPNG_ALLOC_POOL_SIZE)
|
||||
{
|
||||
ESP_LOGE(kTagLodeAlloc,
|
||||
"LodePNG pool exhausted! Requested: %zu, Used: %zu, Total: %d",
|
||||
size, s_lodepng_pool_used, LODEPNG_ALLOC_POOL_SIZE);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Grab pointer and bump
|
||||
uint8_t* ptr = s_lodepng_pool + s_lodepng_pool_used;
|
||||
s_lodepng_pool_used += total_alloc;
|
||||
// Grab pointer and bump
|
||||
uint8_t *ptr = s_lodepng_pool + s_lodepng_pool_used;
|
||||
s_lodepng_pool_used += total_alloc;
|
||||
|
||||
// Write header
|
||||
AllocHeader* header = (AllocHeader*)ptr;
|
||||
header->size = size; // We store exact size for realloc memcpy bounds
|
||||
// Write header
|
||||
AllocHeader *header = (AllocHeader *)ptr;
|
||||
header->size = size; // We store exact size for realloc memcpy bounds
|
||||
|
||||
// Return pointer right after header
|
||||
return ptr + sizeof(AllocHeader);
|
||||
// Return pointer right after header
|
||||
return ptr + sizeof(AllocHeader);
|
||||
}
|
||||
|
||||
void* lodepng_custom_realloc(void* ptr, size_t new_size)
|
||||
void *lodepng_custom_realloc(void *ptr, size_t new_size)
|
||||
{
|
||||
if (!ptr)
|
||||
if (!ptr)
|
||||
{
|
||||
return lodepng_custom_malloc(new_size);
|
||||
}
|
||||
|
||||
if (new_size == 0)
|
||||
{
|
||||
lodepng_custom_free(ptr);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Get original header
|
||||
uint8_t *orig_ptr = (uint8_t *)ptr - sizeof(AllocHeader);
|
||||
AllocHeader *header = (AllocHeader *)orig_ptr;
|
||||
|
||||
size_t old_size = header->size;
|
||||
if (new_size <= old_size)
|
||||
{
|
||||
// Don't shrink to save time, bump allocator can't reclaim it easily anyway.
|
||||
return ptr;
|
||||
}
|
||||
|
||||
// Let's see if this ptr was the *very last* allocation.
|
||||
// If so, we can just expand it in place!
|
||||
size_t old_aligned_size = (old_size + 7) & ~7;
|
||||
if (orig_ptr + sizeof(AllocHeader) + old_aligned_size ==
|
||||
s_lodepng_pool + s_lodepng_pool_used)
|
||||
{
|
||||
// We are at the end! Just bump further!
|
||||
size_t new_aligned_size = (new_size + 7) & ~7;
|
||||
size_t size_diff = new_aligned_size - old_aligned_size;
|
||||
|
||||
if (s_lodepng_pool_used + size_diff > LODEPNG_ALLOC_POOL_SIZE)
|
||||
{
|
||||
return lodepng_custom_malloc(new_size);
|
||||
ESP_LOGE(kTagLodeAlloc,
|
||||
"LodePNG pool exhausted during in-place realloc!");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (new_size == 0)
|
||||
{
|
||||
lodepng_custom_free(ptr);
|
||||
return nullptr;
|
||||
}
|
||||
s_lodepng_pool_used += size_diff;
|
||||
header->size = new_size;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
// Get original header
|
||||
uint8_t* orig_ptr = (uint8_t*)ptr - sizeof(AllocHeader);
|
||||
AllocHeader* header = (AllocHeader*)orig_ptr;
|
||||
// Otherwise, we have to copy into a new block
|
||||
void *new_ptr = lodepng_custom_malloc(new_size);
|
||||
if (new_ptr)
|
||||
{
|
||||
memcpy(new_ptr, ptr, old_size);
|
||||
}
|
||||
|
||||
size_t old_size = header->size;
|
||||
if (new_size <= old_size)
|
||||
{
|
||||
// Don't shrink to save time, bump allocator can't reclaim it easily anyway.
|
||||
return ptr;
|
||||
}
|
||||
|
||||
// Let's see if this ptr was the *very last* allocation.
|
||||
// If so, we can just expand it in place!
|
||||
size_t old_aligned_size = (old_size + 7) & ~7;
|
||||
if (orig_ptr + sizeof(AllocHeader) + old_aligned_size == s_lodepng_pool + s_lodepng_pool_used)
|
||||
{
|
||||
// We are at the end! Just bump further!
|
||||
size_t new_aligned_size = (new_size + 7) & ~7;
|
||||
size_t size_diff = new_aligned_size - old_aligned_size;
|
||||
|
||||
if (s_lodepng_pool_used + size_diff > LODEPNG_ALLOC_POOL_SIZE)
|
||||
{
|
||||
ESP_LOGE(kTagLodeAlloc, "LodePNG pool exhausted during in-place realloc!");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
s_lodepng_pool_used += size_diff;
|
||||
header->size = new_size;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
// Otherwise, we have to copy into a new block
|
||||
void* new_ptr = lodepng_custom_malloc(new_size);
|
||||
if (new_ptr)
|
||||
{
|
||||
memcpy(new_ptr, ptr, old_size);
|
||||
}
|
||||
|
||||
return new_ptr;
|
||||
return new_ptr;
|
||||
}
|
||||
|
||||
void lodepng_custom_free(void* ptr)
|
||||
void lodepng_custom_free(void *ptr)
|
||||
{
|
||||
// No-op! The bump pointer will just reset to 0 once the API endpoint is done!
|
||||
(void)ptr;
|
||||
// No-op! The bump pointer will just reset to 0 once the API endpoint is done!
|
||||
(void)ptr;
|
||||
}
|
||||
|
||||
+84
-58
@@ -1,8 +1,11 @@
|
||||
#include "lv_setup.hpp"
|
||||
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/task.h"
|
||||
#include "lodepng_alloc.hpp"
|
||||
|
||||
#include "types.hpp"
|
||||
|
||||
internal const char *kTagLvgl = "LVGL";
|
||||
@@ -13,94 +16,117 @@ uint8_t *g_LvglDrawBuffer = nullptr;
|
||||
|
||||
internal void lvgl_tick_task(void *arg)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
while (true)
|
||||
{
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
|
||||
if (xSemaphoreTake(g_LvglMutex, portMAX_DELAY) == pdTRUE)
|
||||
{
|
||||
lv_timer_handler();
|
||||
xSemaphoreGive(g_LvglMutex);
|
||||
}
|
||||
if (xSemaphoreTake(g_LvglMutex, portMAX_DELAY) == pdTRUE)
|
||||
{
|
||||
lv_timer_handler();
|
||||
xSemaphoreGive(g_LvglMutex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal uint32_t my_tick_get_cb()
|
||||
{
|
||||
return (uint32_t)(esp_timer_get_time() / 1000);
|
||||
return (uint32_t)(esp_timer_get_time() / 1000);
|
||||
}
|
||||
|
||||
internal void lv_dummy_flush_cb(lv_display_t *disp, const lv_area_t *area, uint8_t *px_map)
|
||||
internal void lv_dummy_flush_cb(lv_display_t *disp, const lv_area_t *area,
|
||||
uint8_t *px_map)
|
||||
{
|
||||
// Headless display, so we don't actually flush to SPI/I2C.
|
||||
// We just tell LVGL that the "flush" is completed so it unblocks wait_for_flushing.
|
||||
lv_display_flush_ready(disp);
|
||||
// Headless display, so we don't actually flush to SPI/I2C.
|
||||
// We just tell LVGL that the "flush" is completed so it unblocks
|
||||
// wait_for_flushing.
|
||||
lv_display_flush_ready(disp);
|
||||
}
|
||||
|
||||
internal void lv_draw_sample_ui()
|
||||
{
|
||||
lv_obj_t *scr = lv_screen_active();
|
||||
// Default background to white for the grayscale PNG
|
||||
lv_obj_set_style_bg_color(scr, lv_color_white(), 0);
|
||||
lv_obj_t *scr = lv_screen_active();
|
||||
// Default background to white for the grayscale PNG
|
||||
lv_obj_set_style_bg_color(scr, lv_color_white(), 0);
|
||||
|
||||
lv_obj_t *label = lv_label_create(scr);
|
||||
lv_label_set_text(label, "Calendink Provider\nLVGL Headless Renderer");
|
||||
lv_obj_align(label, LV_ALIGN_CENTER, 0, 0);
|
||||
lv_obj_t *label = lv_label_create(scr);
|
||||
lv_label_set_text(label, "Calendink Provider\nLVGL Headless Renderer");
|
||||
lv_obj_align(label, LV_ALIGN_CENTER, 0, 0);
|
||||
|
||||
static lv_style_t style;
|
||||
lv_style_init(&style);
|
||||
|
||||
lv_style_set_line_color(&style, lv_palette_main(LV_PALETTE_GREY));
|
||||
lv_style_set_line_width(&style, 6);
|
||||
lv_style_set_line_rounded(&style, true);
|
||||
|
||||
/*Create an object with the new style*/
|
||||
lv_obj_t *obj = lv_line_create(scr);
|
||||
lv_obj_add_style(obj, &style, 0);
|
||||
|
||||
static lv_point_precise_t p[] = {{10, 30}, {30, 50}, {100, 0}};
|
||||
lv_line_set_points(obj, p, 3);
|
||||
|
||||
lv_obj_center(obj);
|
||||
}
|
||||
|
||||
void setup_lvgl()
|
||||
{
|
||||
ESP_LOGI(kTagLvgl, "Initializing LVGL");
|
||||
ESP_LOGI(kTagLvgl, "Initializing LVGL");
|
||||
|
||||
g_LvglMutex = xSemaphoreCreateMutex();
|
||||
g_LvglMutex = xSemaphoreCreateMutex();
|
||||
|
||||
lv_init();
|
||||
lv_tick_set_cb(my_tick_get_cb);
|
||||
lv_init();
|
||||
lv_tick_set_cb(my_tick_get_cb);
|
||||
|
||||
uint32_t width = CONFIG_CALENDINK_DISPLAY_WIDTH;
|
||||
uint32_t height = CONFIG_CALENDINK_DISPLAY_HEIGHT;
|
||||
uint32_t width = CONFIG_CALENDINK_DISPLAY_WIDTH;
|
||||
uint32_t height = CONFIG_CALENDINK_DISPLAY_HEIGHT;
|
||||
|
||||
// Create a virtual display
|
||||
g_LvglDisplay = lv_display_create(width, height);
|
||||
lv_display_set_flush_cb(g_LvglDisplay, lv_dummy_flush_cb);
|
||||
// Create a virtual display
|
||||
g_LvglDisplay = lv_display_create(width, height);
|
||||
lv_display_set_flush_cb(g_LvglDisplay, lv_dummy_flush_cb);
|
||||
|
||||
// Initialize LodePNG custom bump allocator
|
||||
lodepng_allocator_init();
|
||||
// Initialize LodePNG custom bump allocator
|
||||
lodepng_allocator_init();
|
||||
|
||||
// Allocate draw buffers in PSRAM
|
||||
// Using LV_COLOR_FORMAT_L8 (1 byte per pixel)
|
||||
size_t buf_size = LV_DRAW_BUF_SIZE(width, height, LV_COLOR_FORMAT_L8);
|
||||
// Allocate draw buffers in PSRAM
|
||||
// Using LV_COLOR_FORMAT_RGB565 (2 bytes per pixel)
|
||||
size_t buf_size = LV_DRAW_BUF_SIZE(width, height, LV_COLOR_FORMAT_RGB565);
|
||||
|
||||
// Fallback to MALLOC_CAP_DEFAULT if we can't get SPIRAM (for debugging without it)
|
||||
void *buf1 = heap_caps_malloc(buf_size, MALLOC_CAP_SPIRAM);
|
||||
if (!buf1)
|
||||
{
|
||||
ESP_LOGW(kTagLvgl, "Failed to allocate LVGL draw buffer in PSRAM, falling back to internal RAM");
|
||||
buf1 = heap_caps_malloc(buf_size, MALLOC_CAP_DEFAULT);
|
||||
}
|
||||
// Fallback to MALLOC_CAP_DEFAULT if we can't get SPIRAM (for debugging
|
||||
// without it)
|
||||
void *buf1 = heap_caps_malloc(buf_size, MALLOC_CAP_SPIRAM);
|
||||
if (!buf1)
|
||||
{
|
||||
ESP_LOGW(kTagLvgl, "Failed to allocate LVGL draw buffer in PSRAM, falling "
|
||||
"back to internal RAM");
|
||||
buf1 = heap_caps_malloc(buf_size, MALLOC_CAP_DEFAULT);
|
||||
}
|
||||
|
||||
if (!buf1)
|
||||
{
|
||||
ESP_LOGE(kTagLvgl, "Failed to allocate LVGL draw buffer entirely.");
|
||||
return;
|
||||
}
|
||||
if (!buf1)
|
||||
{
|
||||
ESP_LOGE(kTagLvgl, "Failed to allocate LVGL draw buffer entirely.");
|
||||
return;
|
||||
}
|
||||
|
||||
g_LvglDrawBuffer = (uint8_t *)buf1;
|
||||
g_LvglDrawBuffer = (uint8_t *)buf1;
|
||||
|
||||
lv_display_set_buffers(g_LvglDisplay, buf1, nullptr, buf_size, LV_DISPLAY_RENDER_MODE_FULL);
|
||||
// Explicitly set the color format of the display FIRST
|
||||
// so that stride and byte-per-pixel calculations align with our buffer.
|
||||
lv_display_set_color_format(g_LvglDisplay, LV_COLOR_FORMAT_RGB565);
|
||||
|
||||
// Explicitly set the color format of the display if it's set in sdkconfig/driver
|
||||
lv_display_set_color_format(g_LvglDisplay, LV_COLOR_FORMAT_L8);
|
||||
lv_display_set_buffers(g_LvglDisplay, buf1, nullptr, buf_size,
|
||||
LV_DISPLAY_RENDER_MODE_FULL);
|
||||
|
||||
// Create the background task for the LVGL timer
|
||||
xTaskCreate(lvgl_tick_task, "LVGL Tick", 4096, nullptr, 5, nullptr);
|
||||
// Create the background task for the LVGL timer
|
||||
xTaskCreate(lvgl_tick_task, "LVGL Tick", 4096, nullptr, 5, nullptr);
|
||||
|
||||
// Draw the sample UI
|
||||
if (xSemaphoreTake(g_LvglMutex, portMAX_DELAY) == pdTRUE)
|
||||
{
|
||||
lv_draw_sample_ui();
|
||||
xSemaphoreGive(g_LvglMutex);
|
||||
}
|
||||
// Draw the sample UI
|
||||
if (xSemaphoreTake(g_LvglMutex, portMAX_DELAY) == pdTRUE)
|
||||
{
|
||||
lv_draw_sample_ui();
|
||||
xSemaphoreGive(g_LvglMutex);
|
||||
}
|
||||
|
||||
ESP_LOGI(kTagLvgl, "LVGL fully initialized. Display %lux%lu created.", width, height);
|
||||
ESP_LOGI(kTagLvgl, "LVGL fully initialized. Display %lux%lu created.", width,
|
||||
height);
|
||||
}
|
||||
|
||||
+11
-8
@@ -16,13 +16,12 @@
|
||||
#include "soc/gpio_num.h"
|
||||
|
||||
// Project headers
|
||||
#include "appstate.hpp"
|
||||
#include "led.hpp"
|
||||
#include "network.hpp"
|
||||
#include "types.hpp"
|
||||
|
||||
// Project cpp (Unity Build entry)
|
||||
// clang-format off
|
||||
#include "led_status.cpp"
|
||||
#include "connect.cpp"
|
||||
#include "http_server.cpp"
|
||||
#include "mdns_service.cpp"
|
||||
#include "udp_logger.cpp"
|
||||
@@ -43,7 +42,7 @@ constexpr bool kBlockUntilEthernetEstablished = false;
|
||||
internal void my_timer_callback(void *arg)
|
||||
{
|
||||
ESP_LOGI(kTagMain, "Timer finished! Turning Led Off...");
|
||||
destroy_led();
|
||||
turn_off_led();
|
||||
}
|
||||
|
||||
extern "C" void app_main()
|
||||
@@ -187,6 +186,8 @@ extern "C" void app_main()
|
||||
|
||||
setup_led();
|
||||
|
||||
initialize_network();
|
||||
|
||||
set_led_status(led_status::ConnectingEthernet);
|
||||
g_Ethernet_Initialized = true;
|
||||
esp_err_t result = connect_ethernet(kBlockUntilEthernetEstablished);
|
||||
@@ -347,9 +348,10 @@ extern "C" void app_main()
|
||||
{
|
||||
char *saveptr;
|
||||
char *line = strtok_r(ptr, "\n", &saveptr);
|
||||
while (line != nullptr) {
|
||||
ESP_LOGI(kTagMain, "%s", line);
|
||||
line = strtok_r(nullptr, "\n", &saveptr);
|
||||
while (line != nullptr)
|
||||
{
|
||||
ESP_LOGI(kTagMain, "%s", line);
|
||||
line = strtok_r(nullptr, "\n", &saveptr);
|
||||
}
|
||||
free(ptr);
|
||||
}
|
||||
@@ -379,8 +381,9 @@ shutdown:
|
||||
g_Wifi_Initialized = false;
|
||||
}
|
||||
|
||||
destroy_led();
|
||||
turn_off_led();
|
||||
|
||||
ESP_ERROR_CHECK(esp_event_loop_delete_default());
|
||||
ESP_ERROR_CHECK(nvs_flash_deinit());
|
||||
shutdown_network();
|
||||
}
|
||||
|
||||
+14
-10
@@ -27,15 +27,16 @@ CONFIG_ETHERNET_SPI_POLLING0_MS=0
|
||||
|
||||
# Enable PSRAM
|
||||
CONFIG_SPIRAM=y
|
||||
CONFIG_ESP32S3_SPIRAM_SUPPORT=y
|
||||
CONFIG_SPIRAM_MODE_OCT=y
|
||||
CONFIG_SPIRAM_SPEED_80M=y
|
||||
CONFIG_SPIRAM_FETCH_INSTRUCTIONS=y
|
||||
CONFIG_SPIRAM_RODATA=y
|
||||
|
||||
# LVGL Configuration
|
||||
CONFIG_LV_COLOR_DEPTH_8=y
|
||||
CONFIG_LV_COLOR_DEPTH_16=y
|
||||
CONFIG_LV_USE_SYSMON=n
|
||||
CONFIG_LV_USE_OBJ_NAME=y
|
||||
CONFIG_LV_ATTRIBUTE_FAST_MEM_USE_IRAM=y
|
||||
|
||||
# LVGL Memory Allocator (Use ESP-IDF Heap instead of internal 64kB BSS pool!)
|
||||
CONFIG_LV_USE_BUILTIN_MALLOC=n
|
||||
@@ -51,20 +52,19 @@ CONFIG_LV_BUILD_EXAMPLES=n
|
||||
CONFIG_LV_BUILD_DEMOS=n
|
||||
|
||||
# Disable unused software drawing color formats (Only L8 and A8 matter for grayscale)
|
||||
CONFIG_LV_DRAW_SW_SUPPORT_RGB565=n
|
||||
CONFIG_LV_DRAW_SW_SUPPORT_RGB565_SWAPPED=n
|
||||
CONFIG_LV_DRAW_SW_SUPPORT_RGB565=y
|
||||
CONFIG_LV_DRAW_SW_SUPPORT_RGB565A8=n
|
||||
CONFIG_LV_DRAW_SW_SUPPORT_RGB888=n
|
||||
CONFIG_LV_DRAW_SW_SUPPORT_XRGB8888=n
|
||||
CONFIG_LV_DRAW_SW_SUPPORT_ARGB8888=n
|
||||
CONFIG_LV_DRAW_SW_SUPPORT_ARGB8888_PREMULTIPLIED=n
|
||||
CONFIG_LV_DRAW_SW_SUPPORT_L8=y
|
||||
CONFIG_LV_DRAW_SW_SUPPORT_L8=n
|
||||
CONFIG_LV_DRAW_SW_SUPPORT_AL88=n
|
||||
CONFIG_LV_DRAW_SW_SUPPORT_A8=y
|
||||
CONFIG_LV_DRAW_SW_SUPPORT_A8=n
|
||||
CONFIG_LV_DRAW_SW_SUPPORT_I1=n
|
||||
|
||||
# Disable complex drawing features to save memory (no shadows, no complex gradients)
|
||||
CONFIG_LV_DRAW_SW_COMPLEX=n
|
||||
# Enable complex drawing features (required for lines thicker than 1px, rounded lines, arcs, and gradients)
|
||||
CONFIG_LV_DRAW_SW_COMPLEX=y
|
||||
|
||||
# Disable unneeded widgets for a simple static screen generator
|
||||
CONFIG_LV_USE_CHART=n
|
||||
@@ -78,13 +78,13 @@ CONFIG_LV_USE_SPINBOX=n
|
||||
CONFIG_LV_USE_SPINNER=n
|
||||
CONFIG_LV_USE_KEYBOARD=n
|
||||
CONFIG_LV_USE_CALENDAR=n
|
||||
CONFIG_LV_USE_CHECKBOX=n
|
||||
CONFIG_LV_USE_CHECKBOX=y
|
||||
CONFIG_LV_USE_DROPDOWN=n
|
||||
CONFIG_LV_USE_IMAGEBUTTON=n
|
||||
CONFIG_LV_USE_ROLLER=n
|
||||
CONFIG_LV_USE_SCALE=n
|
||||
CONFIG_LV_USE_SLIDER=n
|
||||
CONFIG_LV_USE_SWITCH=n
|
||||
CONFIG_LV_USE_SWITCH=y
|
||||
CONFIG_LV_USE_TEXTAREA=n
|
||||
CONFIG_LV_USE_TABLE=n
|
||||
|
||||
@@ -96,3 +96,7 @@ CONFIG_LV_THEME_DEFAULT_TRANSITION_TIME=0
|
||||
|
||||
# Disable data observer patterns (unused in static render flow)
|
||||
CONFIG_LV_USE_OBSERVER=n
|
||||
|
||||
# Enable XML runtime for dynamic screen layouts (LVGL 9.4+)
|
||||
CONFIG_LV_USE_XML=y
|
||||
CONFIG_LV_USE_OBSERVER=y
|
||||
|
||||
@@ -53,7 +53,15 @@ The following REST endpoints handle the device lifecycle and image generation:
|
||||
### Subsystems Config
|
||||
The ESP-IDF project configuration (`sdkconfig.defaults`) must be modified to enable the `CONFIG_LV_USE_XML=y` flag, which compiles the LVGL XML parser component into the firmware image.
|
||||
|
||||
### XML Runtime Integration
|
||||
The user provided documentation for the `LV_USE_XML` runtime feature. We must:
|
||||
1. Call `lv_xml_register_component_from_data("current_device", dev->xml_layout)` to register the XML payload.
|
||||
2. Check if the XML string contains `<screen>`. If it does, LVGL expects us to instantiate it as a full screen using `lv_obj_t * root = lv_xml_create_screen("current_device");`.
|
||||
3. If it does not contain `<screen>`, it's just a regular component/widget, so we create it *on* the active screen using `lv_obj_t * root = lv_xml_create(scr, "current_device", NULL);`.
|
||||
4. Fallback to a string label if the XML is empty or parsing fails.
|
||||
|
||||
### Frontend
|
||||
- **DeviceManager.svelte:** A new component accessible from the Sidebar. It fetches the device list on load.
|
||||
- **XML Uploading:** For each device card, a text area allows the user to paste an LVGL XML string. Clicking "Save Layout" updates the device via `POST /api/devices/layout`.
|
||||
- **Debug Features:** A collapsed section (e.g. `<details>`) in the UI will contain a button to "Register Debug Device" that triggers a POST to `/api/devices/register` with a random or hardcoded MAC (e.g., `00:11:22:33:44:55`).
|
||||
- **Integration:** The `App.svelte` router will be updated to include the `'devices'` view state alongside Dashboard, Tasks, and Users.
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
# 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://<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*
|
||||
@@ -0,0 +1,3 @@
|
||||
idf_component_register(SRCS "epd.cpp"
|
||||
INCLUDE_DIRS "." "../shared"
|
||||
PRIV_REQUIRES driver)
|
||||
@@ -0,0 +1,45 @@
|
||||
menu "EPD Configuration"
|
||||
|
||||
config CALENDINK_EPD_SCLK
|
||||
int "EPD SPI SCLK GPIO"
|
||||
default 19
|
||||
help
|
||||
GPIO pin for SPI SCLK (D8 on XIAO C6)
|
||||
|
||||
config CALENDINK_EPD_MISO
|
||||
int "EPD SPI MISO GPIO"
|
||||
default 20
|
||||
help
|
||||
GPIO pin for SPI MISO (D9 on XIAO C6)
|
||||
|
||||
config CALENDINK_EPD_MOSI
|
||||
int "EPD SPI MOSI GPIO"
|
||||
default 18
|
||||
help
|
||||
GPIO pin for SPI MOSI (D10 on XIAO C6)
|
||||
|
||||
config CALENDINK_EPD_CS
|
||||
int "EPD SPI CS GPIO"
|
||||
default 1
|
||||
help
|
||||
GPIO pin for SPI CS (D1 on XIAO C6)
|
||||
|
||||
config CALENDINK_EPD_DC
|
||||
int "EPD Data/Command GPIO"
|
||||
default 21
|
||||
help
|
||||
GPIO pin for Data/Command selection (D3 on XIAO C6)
|
||||
|
||||
config CALENDINK_EPD_BUSY
|
||||
int "EPD Busy GPIO"
|
||||
default 2
|
||||
help
|
||||
GPIO pin for Busy signal (D2 on XIAO C6)
|
||||
|
||||
config CALENDINK_EPD_RST
|
||||
int "EPD Reset GPIO"
|
||||
default 0
|
||||
help
|
||||
GPIO pin for Reset signal (D0 on XIAO C6)
|
||||
|
||||
endmenu
|
||||
@@ -0,0 +1,458 @@
|
||||
#include <assert.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "driver/gpio.h"
|
||||
#include "driver/spi_master.h"
|
||||
#include "esp_log.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#include <time.h>
|
||||
|
||||
#include "epd.hpp"
|
||||
|
||||
internal const char *kTagEPD = "EPD";
|
||||
|
||||
internal spi_device_handle_t g_spi_handle;
|
||||
internal bool g_is_asleep = true;
|
||||
internal constexpr size_t kTotal_bytes = (EPD_WIDTH * EPD_HEIGHT) / 8;
|
||||
internal uint8 g_scratch_buffer[4096];
|
||||
|
||||
internal struct epd_refresh_stats{
|
||||
time_t last_full_refresh_bw;
|
||||
int fast_count_bw;
|
||||
time_t last_full_refresh_4g;
|
||||
int fast_count_4g;
|
||||
} g_epd_stats = {0, 0, 0, 0};
|
||||
|
||||
internal void epd_spi_init(void)
|
||||
{
|
||||
// 1. Initialize the SPI Bus
|
||||
spi_bus_config_t buscfg = {};
|
||||
buscfg.miso_io_num =
|
||||
TFT_MISO; // Initialize MISO as input, matching `spi.begin(TFT_SCLK,
|
||||
// TFT_MISO, TFT_MOSI, -1)`
|
||||
buscfg.mosi_io_num = TFT_MOSI;
|
||||
buscfg.sclk_io_num = TFT_SCLK;
|
||||
buscfg.quadwp_io_num = -1;
|
||||
buscfg.quadhd_io_num = -1;
|
||||
buscfg.max_transfer_sz = 4096;
|
||||
|
||||
// SPI2_HOST is the general-purpose SPI host on ESP32-S3
|
||||
ESP_ERROR_CHECK(spi_bus_initialize(SPI2_HOST, &buscfg, SPI_DMA_CH_AUTO));
|
||||
|
||||
// 2. Add the EPD device to the bus
|
||||
spi_device_interface_config_t devcfg = {};
|
||||
devcfg.clock_speed_hz = SPI_FREQUENCY;
|
||||
devcfg.mode = 0; // Standard EPD SPI mode
|
||||
devcfg.spics_io_num = TFT_CS; // The driver handles CS automatically
|
||||
devcfg.queue_size = 7;
|
||||
devcfg.flags = 0;
|
||||
|
||||
ESP_ERROR_CHECK(spi_bus_add_device(SPI2_HOST, &devcfg, &g_spi_handle));
|
||||
}
|
||||
|
||||
internal void epd_gpio_init()
|
||||
{
|
||||
gpio_reset_pin((gpio_num_t)TFT_BUSY);
|
||||
gpio_set_direction((gpio_num_t)TFT_BUSY, GPIO_MODE_INPUT);
|
||||
gpio_reset_pin((gpio_num_t)TFT_RST);
|
||||
gpio_set_direction((gpio_num_t)TFT_RST, GPIO_MODE_OUTPUT);
|
||||
gpio_set_level((gpio_num_t)TFT_RST,
|
||||
1); // Set high, do not share pin with another SPI device
|
||||
gpio_reset_pin((gpio_num_t)TFT_DC);
|
||||
gpio_set_direction((gpio_num_t)TFT_DC, GPIO_MODE_OUTPUT);
|
||||
gpio_set_level((gpio_num_t)TFT_DC, 1); // Data/Command high = data mode
|
||||
gpio_reset_pin((gpio_num_t)TFT_CS);
|
||||
gpio_set_direction((gpio_num_t)TFT_CS, GPIO_MODE_OUTPUT);
|
||||
gpio_set_level((gpio_num_t)TFT_CS, 1); // Chip select high (inactive)
|
||||
}
|
||||
|
||||
internal void epd_writecommand(unsigned char command)
|
||||
{
|
||||
assert(!g_is_asleep);
|
||||
// Pull DC Low for Command
|
||||
gpio_set_level((gpio_num_t)TFT_DC, GPIO_LOW);
|
||||
|
||||
spi_transaction_t t = {};
|
||||
t.length = 8; // length in bits
|
||||
t.tx_buffer = &command;
|
||||
|
||||
spi_device_transmit(g_spi_handle, &t);
|
||||
}
|
||||
|
||||
internal void epd_write_buffer(const uint8 *data, size_t len)
|
||||
{
|
||||
assert(!g_is_asleep);
|
||||
if (len == 0)
|
||||
return;
|
||||
|
||||
// Pull DC High for Data
|
||||
gpio_set_level((gpio_num_t)TFT_DC, GPIO_HIGH);
|
||||
|
||||
int chunk_count = 0;
|
||||
while (len > 0)
|
||||
{
|
||||
// max_transfer_sz is typically 4096 which is the limit for a single SPI
|
||||
// transaction
|
||||
size_t chunk = (len > 4096) ? 4096 : len;
|
||||
spi_transaction_t t = {};
|
||||
t.length = chunk * 8; // length in bits
|
||||
t.tx_buffer = data;
|
||||
|
||||
spi_device_transmit(g_spi_handle, &t);
|
||||
data += chunk;
|
||||
len -= chunk;
|
||||
|
||||
// Yield every 16 chunks (~64KB) to prevent task watchdog timeout
|
||||
if (++chunk_count % 16 == 0)
|
||||
{
|
||||
vTaskDelay(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal void epd_fill_layer(uint8 cmd, uint8 color_byte)
|
||||
{
|
||||
epd_writecommand(cmd);
|
||||
|
||||
memset(g_scratch_buffer, color_byte, sizeof(g_scratch_buffer));
|
||||
|
||||
size_t remaining = kTotal_bytes;
|
||||
while (remaining > 0)
|
||||
{
|
||||
size_t to_write =
|
||||
(remaining > sizeof(g_scratch_buffer)) ? sizeof(g_scratch_buffer) : remaining;
|
||||
epd_write_buffer(g_scratch_buffer, to_write);
|
||||
remaining -= to_write;
|
||||
}
|
||||
}
|
||||
|
||||
void epd_writedata(unsigned char data) { epd_write_buffer(&data, 1); }
|
||||
|
||||
internal void epd_wait_until_idle()
|
||||
{
|
||||
// BUSY pin on this board: LOW = busy, HIGH = idle
|
||||
// (opposite of GDEY075T7 reference driver)
|
||||
while (gpio_get_level((gpio_num_t)TFT_BUSY) == 0)
|
||||
{
|
||||
vTaskDelay(pdMS_TO_TICKS(5));
|
||||
}
|
||||
}
|
||||
|
||||
void epd_init(void)
|
||||
{
|
||||
ESP_LOGI(kTagEPD, "Initializing EPaper Driver");
|
||||
|
||||
epd_gpio_init();
|
||||
epd_spi_init();
|
||||
|
||||
ESP_LOGI(kTagEPD, "EPaper Driver initialized successfully");
|
||||
}
|
||||
|
||||
void epd_shutdown(void)
|
||||
{
|
||||
spi_bus_remove_device(g_spi_handle);
|
||||
spi_bus_free(SPI2_HOST);
|
||||
}
|
||||
|
||||
internal void epd_init_display_full(void)
|
||||
{
|
||||
ESP_LOGI(kTagEPD, "Performing FULL initialization");
|
||||
epd_writecommand(0x01); // POWER SETTING
|
||||
epd_writedata(0x07);
|
||||
epd_writedata(0x07);
|
||||
epd_writedata(0x3f);
|
||||
epd_writedata(0x3f);
|
||||
|
||||
epd_writecommand(0x06); // Booster Soft Start
|
||||
epd_writedata(0x17);
|
||||
epd_writedata(0x17);
|
||||
epd_writedata(0x28);
|
||||
epd_writedata(0x17);
|
||||
|
||||
epd_writecommand(0x04); // POWER ON
|
||||
vTaskDelay(pdMS_TO_TICKS(100));
|
||||
epd_wait_until_idle();
|
||||
|
||||
epd_writecommand(0X00); // PANEL SETTING
|
||||
epd_writedata(0x1F);
|
||||
|
||||
epd_writecommand(0x61); // TRES
|
||||
epd_writedata(EPD_WIDTH >> 8);
|
||||
epd_writedata(EPD_WIDTH & 0xFF);
|
||||
epd_writedata(EPD_HEIGHT >> 8);
|
||||
epd_writedata(EPD_HEIGHT & 0xFF);
|
||||
|
||||
epd_writecommand(0x15);
|
||||
epd_writedata(0x00);
|
||||
|
||||
epd_writecommand(0x50); // VCOM AND DATA INTERVAL SETTING
|
||||
epd_writedata(0x29); // BDV=10 (White Border), N2OCP=1 (Auto-copy NEW to OLD),
|
||||
// DDX=01 (0=Black, 1=White)
|
||||
epd_writedata(0x07);
|
||||
|
||||
epd_writecommand(0x60); // TCON SETTING
|
||||
epd_writedata(0x22);
|
||||
}
|
||||
|
||||
internal void epd_init_display_fast(void)
|
||||
{
|
||||
ESP_LOGI(kTagEPD, "Performing FAST initialization");
|
||||
epd_writecommand(0X00); // PANEL SETTING
|
||||
epd_writedata(0x1F);
|
||||
|
||||
epd_writecommand(0x50); // VCOM AND DATA INTERVAL SETTING
|
||||
epd_writedata(0x29);
|
||||
epd_writedata(0x07);
|
||||
|
||||
epd_writecommand(0x04); // POWER ON
|
||||
vTaskDelay(pdMS_TO_TICKS(100));
|
||||
epd_wait_until_idle();
|
||||
|
||||
// Enhanced display drive (Booster values for fast mode)
|
||||
epd_writecommand(0x06); // Booster Soft Start
|
||||
epd_writedata(0x27);
|
||||
epd_writedata(0x27);
|
||||
epd_writedata(0x18);
|
||||
epd_writedata(0x17);
|
||||
|
||||
epd_writecommand(0xE0);
|
||||
epd_writedata(0x02);
|
||||
epd_writecommand(0xE5);
|
||||
epd_writedata(0x5A);
|
||||
}
|
||||
|
||||
internal void epd_init_display_4g_full(void)
|
||||
{
|
||||
ESP_LOGI(kTagEPD, "Performing FULL 4-GRAY initialization");
|
||||
epd_writecommand(0X00); // PANEL SETTING
|
||||
epd_writedata(0x1F);
|
||||
|
||||
epd_writecommand(0X50); // VCOM AND DATA INTERVAL SETTING
|
||||
epd_writedata(0x29);
|
||||
epd_writedata(0x07);
|
||||
|
||||
epd_writecommand(0x04); // POWER ON
|
||||
vTaskDelay(pdMS_TO_TICKS(100)); // Standard delay for full
|
||||
epd_wait_until_idle();
|
||||
|
||||
// Standard display drive (Full Booster values)
|
||||
epd_writecommand(0x06); // Booster Soft Start
|
||||
epd_writedata(0x17);
|
||||
epd_writedata(0x17);
|
||||
epd_writedata(0x28);
|
||||
epd_writedata(0x17);
|
||||
|
||||
epd_writecommand(0xE0);
|
||||
epd_writedata(0x02);
|
||||
epd_writecommand(0xE5);
|
||||
epd_writedata(0x5F); // 0x5F -- 4 Gray
|
||||
}
|
||||
|
||||
internal void epd_init_display_4g_fast(void)
|
||||
{
|
||||
ESP_LOGI(kTagEPD, "Performing FAST 4-GRAY initialization");
|
||||
epd_writecommand(0X00); // PANEL SETTING
|
||||
epd_writedata(0x1F);
|
||||
|
||||
epd_writecommand(0X50); // VCOM AND DATA INTERVAL SETTING
|
||||
epd_writedata(0x29);
|
||||
epd_writedata(0x07);
|
||||
|
||||
epd_writecommand(0x04); // POWER ON
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
epd_wait_until_idle();
|
||||
|
||||
// Enhanced display drive (Fast Booster values)
|
||||
epd_writecommand(0x06); // Booster Soft Start
|
||||
epd_writedata(0x27);
|
||||
epd_writedata(0x27);
|
||||
epd_writedata(0x18);
|
||||
epd_writedata(0x17);
|
||||
|
||||
epd_writecommand(0xE0);
|
||||
epd_writedata(0x02);
|
||||
epd_writecommand(0xE5);
|
||||
epd_writedata(0x5F); // 0x5F -- 4 Gray
|
||||
}
|
||||
|
||||
void epd_init_display(bool is_4gray)
|
||||
{
|
||||
g_is_asleep = false;
|
||||
|
||||
// Module reset
|
||||
gpio_set_level((gpio_num_t)TFT_RST, 0);
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
gpio_set_level((gpio_num_t)TFT_RST, 1);
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
|
||||
time_t now = time(NULL);
|
||||
|
||||
if (is_4gray)
|
||||
{
|
||||
double diff = difftime(now, g_epd_stats.last_full_refresh_4g);
|
||||
bool force_full = (g_epd_stats.last_full_refresh_4g == 0) || (diff > 86400.0) ||
|
||||
(g_epd_stats.fast_count_4g >= 5);
|
||||
|
||||
if (force_full)
|
||||
{
|
||||
epd_init_display_4g_full();
|
||||
g_epd_stats.last_full_refresh_4g = now;
|
||||
g_epd_stats.fast_count_4g = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
epd_init_display_4g_fast();
|
||||
g_epd_stats.fast_count_4g++;
|
||||
}
|
||||
ESP_LOGI(kTagEPD, "4G Refresh stats: (Fast count: %d/5, Age: %.1f hours)",
|
||||
g_epd_stats.fast_count_4g, diff / 3600.0);
|
||||
}
|
||||
else
|
||||
{
|
||||
double diff = difftime(now, g_epd_stats.last_full_refresh_bw);
|
||||
bool force_full = (g_epd_stats.last_full_refresh_bw == 0) || (diff > 86400.0) ||
|
||||
(g_epd_stats.fast_count_bw >= 5);
|
||||
|
||||
if (force_full)
|
||||
{
|
||||
epd_init_display_full();
|
||||
g_epd_stats.last_full_refresh_bw = now;
|
||||
g_epd_stats.fast_count_bw = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
epd_init_display_fast();
|
||||
g_epd_stats.fast_count_bw++;
|
||||
}
|
||||
ESP_LOGI(kTagEPD, "BW Refresh stats: (Fast count: %d/5, Age: %.1f hours)",
|
||||
g_epd_stats.fast_count_bw, diff / 3600.0);
|
||||
}
|
||||
}
|
||||
|
||||
void epd_shutdown_display(void)
|
||||
{
|
||||
assert(!g_is_asleep);
|
||||
ESP_LOGI(kTagEPD, "Shutting down display (Power Off)");
|
||||
|
||||
epd_writecommand(0x50); // VCOM AND DATA INTERVAL SETTING (pre-sleep)
|
||||
epd_writedata(0xF7);
|
||||
|
||||
epd_writecommand(0x02); // POWER OFF
|
||||
epd_wait_until_idle();
|
||||
|
||||
epd_writecommand(0x07); // DEEP SLEEP
|
||||
epd_writedata(0xA5); // Deep sleep check code
|
||||
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
g_is_asleep = true;
|
||||
}
|
||||
|
||||
void epd_refresh(void)
|
||||
{
|
||||
assert(!g_is_asleep);
|
||||
ESP_LOGI(kTagEPD, "Refreshing display...");
|
||||
epd_writecommand(0x12); // REFRESH
|
||||
vTaskDelay(pdMS_TO_TICKS(1));
|
||||
epd_wait_until_idle();
|
||||
}
|
||||
|
||||
bool epd_is_asleep(void) { return g_is_asleep; }
|
||||
|
||||
void epd_clear(epd_color level)
|
||||
{
|
||||
uint8 color_byte = static_cast<uint8>(level);
|
||||
ESP_LOGI(kTagEPD, "Clearing display (byte=0x%02X)", color_byte);
|
||||
|
||||
epd_fill_layer(0x10,
|
||||
0xFF); // Old data layer (0xFF is mapped to White)
|
||||
epd_fill_layer(0x13, color_byte); // New data layer
|
||||
|
||||
ESP_LOGI(kTagEPD, "Data transmission complete (Refresh required)");
|
||||
}
|
||||
|
||||
void epd_draw_bitmap(epd_color clearColor, const uint8 *bitmap)
|
||||
{
|
||||
uint8 color_byte = static_cast<uint8>(clearColor);
|
||||
ESP_LOGI(kTagEPD, "Drawing bitmap (clearColor byte=0x%02X)", color_byte);
|
||||
|
||||
epd_fill_layer(0x10, color_byte); // Send clear color to "Old" layer
|
||||
|
||||
epd_writecommand(0x13); // "New" data layer
|
||||
epd_write_buffer(bitmap, kTotal_bytes);
|
||||
|
||||
ESP_LOGI(kTagEPD, "Data transmission complete (Refresh required)");
|
||||
}
|
||||
|
||||
void epd_draw_bitmap_grayscale(epd_color clearColor, const uint8 *bitmap)
|
||||
{
|
||||
ESP_LOGI(kTagEPD, "Drawing 4-GRAY bitmap (with unpacking)");
|
||||
|
||||
// THE LOGIC:
|
||||
// The input bitmap is 2-bits per pixel packed (4 pixels per byte).
|
||||
// Total pixels: 800 * 480 = 384,000.
|
||||
// Input size: 384,000 / 4 = 96,000 bytes.
|
||||
// Output: Two frames of 800 * 480 / 8 = 48,000 bytes each.
|
||||
|
||||
auto process_layer = [&](uint8 cmd, bool is_new_layer)
|
||||
{
|
||||
epd_writecommand(cmd);
|
||||
size_t scratch_idx = 0;
|
||||
|
||||
// Process 48,000 output bytes (each covers 8 pixels)
|
||||
for (size_t i = 0; i < 48000; i++)
|
||||
{
|
||||
uint8 output_byte = 0;
|
||||
// Each output byte comes from 2 input bytes (j=0, j=1)
|
||||
for (int j = 0; j < 2; j++)
|
||||
{
|
||||
uint8 input_byte = bitmap[i * 2 + j];
|
||||
// Extract 4 pixels from each input byte
|
||||
for (int k = 0; k < 4; k++)
|
||||
{
|
||||
uint8 pixel_bits = (input_byte >> (6 - k * 2)) & 0x03; // Correct bit order
|
||||
bool bit_val = false;
|
||||
|
||||
if (is_new_layer)
|
||||
{
|
||||
// NEW Layer (0x13) Mapping
|
||||
if (pixel_bits == 0x03) bit_val = true; // White (11)
|
||||
else if (pixel_bits == 0x00) bit_val = false; // Black (00)
|
||||
else if (pixel_bits == 0x02) bit_val = true; // Gray1 (10)
|
||||
else if (pixel_bits == 0x01) bit_val = false; // Gray2 (01)
|
||||
}
|
||||
else
|
||||
{
|
||||
// OLD Layer (0x10) Mapping
|
||||
if (pixel_bits == 0x03) bit_val = true; // White (11)
|
||||
else if (pixel_bits == 0x00) bit_val = false; // Black (00)
|
||||
else if (pixel_bits == 0x02) bit_val = false; // Gray1 (10)
|
||||
else if (pixel_bits == 0x01) bit_val = true; // Gray2 (01)
|
||||
}
|
||||
|
||||
if (bit_val) output_byte |= (1 << (7 - (j * 4 + k)));
|
||||
}
|
||||
}
|
||||
|
||||
g_scratch_buffer[scratch_idx++] = output_byte;
|
||||
|
||||
if (scratch_idx >= sizeof(g_scratch_buffer))
|
||||
{
|
||||
epd_write_buffer(g_scratch_buffer, scratch_idx);
|
||||
scratch_idx = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (scratch_idx > 0)
|
||||
{
|
||||
epd_write_buffer(g_scratch_buffer, scratch_idx);
|
||||
}
|
||||
};
|
||||
|
||||
process_layer(0x10, false); // Old data
|
||||
process_layer(0x13, true); // New data
|
||||
|
||||
ESP_LOGI(kTagEPD, "Data transmission complete (Refresh required)");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// DRIVER FOR UC8179 + GDEY075T7
|
||||
#pragma once
|
||||
|
||||
#include "sdkconfig.h"
|
||||
#include "types.hpp"
|
||||
|
||||
// EPD Pin Definitions - Defaulting to CONFIG_ values defined in Kconfig
|
||||
#define TFT_SCLK CONFIG_CALENDINK_EPD_SCLK
|
||||
#define TFT_MISO CONFIG_CALENDINK_EPD_MISO
|
||||
#define TFT_MOSI CONFIG_CALENDINK_EPD_MOSI
|
||||
#define TFT_CS CONFIG_CALENDINK_EPD_CS
|
||||
#define TFT_DC CONFIG_CALENDINK_EPD_DC
|
||||
#define TFT_BUSY CONFIG_CALENDINK_EPD_BUSY
|
||||
#define TFT_RST CONFIG_CALENDINK_EPD_RST
|
||||
|
||||
#define SPI_FREQUENCY 10000000
|
||||
#define SPI_READ_FREQUENCY 4000000
|
||||
|
||||
#define EPD_WIDTH 800
|
||||
#define EPD_HEIGHT 480
|
||||
|
||||
enum class epd_color : uint8
|
||||
{
|
||||
BLACK = 0x00,
|
||||
DARK_GRAY = 0x55,
|
||||
LIGHT_GRAY = 0xAA,
|
||||
WHITE = 0xFF
|
||||
};
|
||||
|
||||
void epd_init(void);
|
||||
void epd_shutdown(void);
|
||||
void epd_init_display(bool is_4gray);
|
||||
void epd_shutdown_display(void);
|
||||
void epd_refresh(void);
|
||||
void epd_clear(epd_color level);
|
||||
void epd_draw_bitmap(epd_color clearColor, const uint8 *bitmap);
|
||||
void epd_draw_bitmap_grayscale(epd_color clearColor, const uint8 *bitmap);
|
||||
bool epd_is_asleep(void);
|
||||
@@ -0,0 +1,6 @@
|
||||
version: "1.0.0"
|
||||
description: "Calendink EPD Component"
|
||||
|
||||
dependencies:
|
||||
idf:
|
||||
version: '>=5.0.0'
|
||||
@@ -0,0 +1,3 @@
|
||||
idf_component_register(SRCS "http_client.cpp"
|
||||
INCLUDE_DIRS "." "../shared"
|
||||
PRIV_REQUIRES esp_http_client)
|
||||
@@ -0,0 +1,259 @@
|
||||
// HTTP Client Component — synchronous GET / POST wrapper around esp_http_client.
|
||||
// See tdd/http_client_component.md for design rationale.
|
||||
|
||||
#include "http_client.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
#include "esp_http_client.h"
|
||||
#include "esp_log.h"
|
||||
|
||||
#include "types.hpp"
|
||||
|
||||
internal const char *kTagHttpClient = "HTTP_CLIENT";
|
||||
|
||||
// ── Internal receive buffer ─────────────────────────────────────────────────
|
||||
|
||||
struct http_receive_buffer_t
|
||||
{
|
||||
char *buf;
|
||||
size_t len;
|
||||
size_t capacity;
|
||||
};
|
||||
|
||||
// ── Event handler ───────────────────────────────────────────────────────────
|
||||
|
||||
internal esp_err_t http_event_handler(esp_http_client_event_t *evt)
|
||||
{
|
||||
auto *recv = (http_receive_buffer_t *)evt->user_data;
|
||||
|
||||
switch (evt->event_id)
|
||||
{
|
||||
case HTTP_EVENT_ON_DATA:
|
||||
{
|
||||
if (recv == nullptr)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
size_t new_len = recv->len + evt->data_len;
|
||||
|
||||
// Grow buffer if needed
|
||||
if (new_len > recv->capacity)
|
||||
{
|
||||
// Double or fit, whichever is larger
|
||||
size_t new_cap = recv->capacity * 2;
|
||||
if (new_cap < new_len)
|
||||
{
|
||||
new_cap = new_len;
|
||||
}
|
||||
|
||||
// +1 for potential null terminator
|
||||
char *new_buf = (char *)realloc(recv->buf, new_cap + 1);
|
||||
if (new_buf == nullptr)
|
||||
{
|
||||
ESP_LOGE(kTagHttpClient, "realloc failed (%zu bytes)", new_cap + 1);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
recv->buf = new_buf;
|
||||
recv->capacity = new_cap;
|
||||
}
|
||||
|
||||
memcpy(recv->buf + recv->len, evt->data, evt->data_len);
|
||||
recv->len = new_len;
|
||||
break;
|
||||
}
|
||||
|
||||
case HTTP_EVENT_ERROR:
|
||||
ESP_LOGE(kTagHttpClient, "HTTP_EVENT_ERROR");
|
||||
break;
|
||||
|
||||
case HTTP_EVENT_ON_CONNECTED:
|
||||
case HTTP_EVENT_HEADERS_SENT:
|
||||
case HTTP_EVENT_ON_HEADER:
|
||||
case HTTP_EVENT_ON_FINISH:
|
||||
case HTTP_EVENT_DISCONNECTED:
|
||||
case HTTP_EVENT_REDIRECT:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// ── Shared perform helper ───────────────────────────────────────────────────
|
||||
|
||||
// Performs an HTTP request, accumulates body into recv buffer.
|
||||
// On success, recv->buf contains the raw response body (not null-terminated).
|
||||
// Caller is responsible for cleaning up recv->buf on both success and failure.
|
||||
internal esp_err_t http_perform(const char *url,
|
||||
esp_http_client_method_t method,
|
||||
const char *post_data,
|
||||
size_t post_data_len,
|
||||
const char *content_type,
|
||||
http_receive_buffer_t *recv,
|
||||
int *out_status_code)
|
||||
{
|
||||
*out_status_code = 0;
|
||||
|
||||
esp_http_client_config_t config = {};
|
||||
config.url = url;
|
||||
config.method = method;
|
||||
config.event_handler = http_event_handler;
|
||||
config.user_data = recv;
|
||||
config.timeout_ms = 10000;
|
||||
config.buffer_size = 1024;
|
||||
config.buffer_size_tx = 1024;
|
||||
|
||||
esp_http_client_handle_t client = esp_http_client_init(&config);
|
||||
if (client == nullptr)
|
||||
{
|
||||
ESP_LOGE(kTagHttpClient, "Failed to init HTTP client for %s", url);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
// Set POST body if provided
|
||||
if (post_data != nullptr && post_data_len > 0)
|
||||
{
|
||||
esp_http_client_set_post_field(client, post_data, (int)post_data_len);
|
||||
}
|
||||
|
||||
// Set Content-Type header if provided
|
||||
if (content_type != nullptr)
|
||||
{
|
||||
esp_http_client_set_header(client, "Content-Type", content_type);
|
||||
}
|
||||
|
||||
ESP_LOGI(kTagHttpClient, "%s %s",
|
||||
method == HTTP_METHOD_POST ? "POST" : "GET", url);
|
||||
|
||||
esp_err_t err = esp_http_client_perform(client);
|
||||
if (err != ESP_OK)
|
||||
{
|
||||
ESP_LOGE(kTagHttpClient, "HTTP request failed: %s", esp_err_to_name(err));
|
||||
esp_http_client_cleanup(client);
|
||||
return err;
|
||||
}
|
||||
|
||||
*out_status_code = esp_http_client_get_status_code(client);
|
||||
int64_t content_length = esp_http_client_get_content_length(client);
|
||||
|
||||
if (*out_status_code >= 200 && *out_status_code < 300)
|
||||
{
|
||||
ESP_LOGI(kTagHttpClient, "Response: %d, %zu bytes received",
|
||||
*out_status_code, recv->len);
|
||||
}
|
||||
else
|
||||
{
|
||||
ESP_LOGW(kTagHttpClient, "Response: %d (content-length: %lld)",
|
||||
*out_status_code, content_length);
|
||||
}
|
||||
|
||||
esp_http_client_cleanup(client);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
char *http_build_url(const char *host, uint16_t port, const char *path)
|
||||
{
|
||||
// "http://" (7) + host + ":" (1) + port (max 5) + path + null
|
||||
size_t host_len = strlen(host);
|
||||
size_t path_len = path ? strlen(path) : 0;
|
||||
size_t url_len = 7 + host_len + 1 + 5 + path_len + 1;
|
||||
|
||||
char *url = (char *)malloc(url_len);
|
||||
if (url == nullptr)
|
||||
{
|
||||
ESP_LOGE(kTagHttpClient, "Failed to allocate URL buffer");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
snprintf(url, url_len, "http://%s:%u%s", host, port, path ? path : "");
|
||||
return url;
|
||||
}
|
||||
|
||||
esp_err_t http_get_text(const char *url, http_text_response_t *out)
|
||||
{
|
||||
memset(out, 0, sizeof(*out));
|
||||
|
||||
http_receive_buffer_t recv = {};
|
||||
int status_code = 0;
|
||||
|
||||
esp_err_t err = http_perform(url, HTTP_METHOD_GET,
|
||||
nullptr, 0, nullptr,
|
||||
&recv, &status_code);
|
||||
if (err != ESP_OK)
|
||||
{
|
||||
free(recv.buf);
|
||||
return err;
|
||||
}
|
||||
|
||||
// Null-terminate the text body
|
||||
if (recv.buf != nullptr)
|
||||
{
|
||||
recv.buf[recv.len] = '\0'; // Safe: we always allocate capacity + 1
|
||||
}
|
||||
|
||||
out->status_code = status_code;
|
||||
out->body = recv.buf;
|
||||
out->body_len = recv.len;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t http_get_binary(const char *url, http_binary_response_t *out)
|
||||
{
|
||||
memset(out, 0, sizeof(*out));
|
||||
|
||||
http_receive_buffer_t recv = {};
|
||||
int status_code = 0;
|
||||
|
||||
esp_err_t err = http_perform(url, HTTP_METHOD_GET,
|
||||
nullptr, 0, nullptr,
|
||||
&recv, &status_code);
|
||||
if (err != ESP_OK)
|
||||
{
|
||||
free(recv.buf);
|
||||
return err;
|
||||
}
|
||||
|
||||
out->status_code = status_code;
|
||||
out->data = (uint8_t *)recv.buf;
|
||||
out->data_len = recv.len;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t http_post_json(const char *url, const char *json_body,
|
||||
http_text_response_t *out)
|
||||
{
|
||||
memset(out, 0, sizeof(*out));
|
||||
|
||||
http_receive_buffer_t recv = {};
|
||||
int status_code = 0;
|
||||
|
||||
size_t body_len = json_body ? strlen(json_body) : 0;
|
||||
|
||||
esp_err_t err = http_perform(url, HTTP_METHOD_POST,
|
||||
json_body, body_len,
|
||||
"application/json",
|
||||
&recv, &status_code);
|
||||
if (err != ESP_OK)
|
||||
{
|
||||
free(recv.buf);
|
||||
return err;
|
||||
}
|
||||
|
||||
// Null-terminate the text body
|
||||
if (recv.buf != nullptr)
|
||||
{
|
||||
recv.buf[recv.len] = '\0';
|
||||
}
|
||||
|
||||
out->status_code = status_code;
|
||||
out->body = recv.buf;
|
||||
out->body_len = recv.len;
|
||||
return ESP_OK;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
// ── Result Types ────────────────────────────────────────────────────────────
|
||||
|
||||
// Text/JSON response. Caller must free(body) after use.
|
||||
struct http_text_response_t
|
||||
{
|
||||
int status_code; // HTTP status (200, 404, 500, …)
|
||||
char *body; // Heap-allocated, null-terminated
|
||||
size_t body_len; // Byte length (excluding null terminator)
|
||||
};
|
||||
|
||||
// Binary response (e.g. PNG image). Caller must free(data) after use.
|
||||
struct http_binary_response_t
|
||||
{
|
||||
int status_code;
|
||||
uint8_t *data; // Heap-allocated binary buffer
|
||||
size_t data_len; // Byte length
|
||||
};
|
||||
|
||||
// ── Functions ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Build "http://<host>:<port><path>".
|
||||
// Returns a 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.
|
||||
// On success (ESP_OK): out is filled. Caller must free(out->body).
|
||||
// On failure: out is zeroed, returns an esp_err_t.
|
||||
esp_err_t http_get_text(const char *url, http_text_response_t *out);
|
||||
|
||||
// GET a binary resource (e.g. PNG image). Blocks until complete.
|
||||
// On success (ESP_OK): out is filled. Caller must free(out->data).
|
||||
// On failure: out is zeroed, returns an esp_err_t.
|
||||
esp_err_t http_get_binary(const char *url, http_binary_response_t *out);
|
||||
|
||||
// POST a JSON body, receive a JSON response. Blocks until complete.
|
||||
// json_body must be a null-terminated JSON string.
|
||||
// On success (ESP_OK): out is filled. Caller must free(out->body).
|
||||
// On failure: out is zeroed, returns an esp_err_t.
|
||||
esp_err_t http_post_json(const char *url, const char *json_body,
|
||||
http_text_response_t *out);
|
||||
@@ -0,0 +1,6 @@
|
||||
version: "1.0.0"
|
||||
description: "Calendink HTTP Client Component"
|
||||
|
||||
dependencies:
|
||||
idf:
|
||||
version: '>=5.0.0'
|
||||
@@ -0,0 +1,3 @@
|
||||
idf_component_register(SRCS "led.cpp"
|
||||
INCLUDE_DIRS "." "../shared"
|
||||
PRIV_REQUIRES led_strip driver)
|
||||
@@ -0,0 +1,29 @@
|
||||
menu "Calendink LED Configuration"
|
||||
|
||||
choice CALENDINK_LED_TYPE
|
||||
prompt "LED Type"
|
||||
default CALENDINK_LED_TYPE_STRIP
|
||||
help
|
||||
Select the type of LED used on the board.
|
||||
|
||||
config CALENDINK_LED_TYPE_STRIP
|
||||
bool "Addressable RGB LED Strip (e.g. WS2812)"
|
||||
|
||||
config CALENDINK_LED_TYPE_SIMPLE
|
||||
bool "Simple GPIO LED (On/Off)"
|
||||
endchoice
|
||||
|
||||
config CALENDINK_LED_GPIO
|
||||
int "LED GPIO"
|
||||
default 21
|
||||
help
|
||||
GPIO pin for the LED.
|
||||
|
||||
config CALENDINK_LED_ACTIVE_LOW
|
||||
bool "LED is Active Low (0 turns it ON)"
|
||||
depends on CALENDINK_LED_TYPE_SIMPLE
|
||||
default y
|
||||
help
|
||||
Select this if your LED turns ON when the GPIO is driven low (0V).
|
||||
Many development boards wire LEDs between VCC and the GPIO pin, making them active low.
|
||||
endmenu
|
||||
@@ -0,0 +1,7 @@
|
||||
version: "1.0.0"
|
||||
description: "Calendink LED Component"
|
||||
|
||||
dependencies:
|
||||
idf:
|
||||
version: '>=4.1.0'
|
||||
espressif/led_strip: ^3.0.3
|
||||
@@ -0,0 +1,123 @@
|
||||
// Project includes
|
||||
#include "led.hpp"
|
||||
|
||||
// SDK Includes
|
||||
#include "driver/gpio.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#if defined(CONFIG_CALENDINK_LED_TYPE_STRIP) || !defined(CONFIG_CALENDINK_LED_TYPE_SIMPLE)
|
||||
#ifndef CONFIG_CALENDINK_LED_TYPE_STRIP
|
||||
#define CONFIG_CALENDINK_LED_TYPE_STRIP 1
|
||||
#endif
|
||||
#include "led_strip.h"
|
||||
#endif
|
||||
|
||||
#define LED_GPIO (gpio_num_t)CONFIG_CALENDINK_LED_GPIO
|
||||
|
||||
#if defined(CONFIG_CALENDINK_LED_TYPE_SIMPLE)
|
||||
#if defined(CONFIG_CALENDINK_LED_ACTIVE_LOW)
|
||||
#define SIMPLE_LED_ON 0
|
||||
#define SIMPLE_LED_OFF 1
|
||||
#else
|
||||
#define SIMPLE_LED_ON 1
|
||||
#define SIMPLE_LED_OFF 0
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if defined(CONFIG_CALENDINK_LED_TYPE_STRIP)
|
||||
internal led_strip_handle_t led_strip;
|
||||
#endif
|
||||
|
||||
void setup_led(void)
|
||||
{
|
||||
#if defined(CONFIG_CALENDINK_LED_TYPE_STRIP)
|
||||
/* LED strip initialization with the GPIO and pixels number*/
|
||||
led_strip_config_t strip_config = {};
|
||||
strip_config.strip_gpio_num = LED_GPIO;
|
||||
strip_config.max_leds = 1; // at least one LED on board
|
||||
|
||||
led_strip_rmt_config_t rmt_config = {};
|
||||
rmt_config.resolution_hz = 10 * 1000 * 1000; // 10MHz
|
||||
rmt_config.flags.with_dma = false;
|
||||
ESP_ERROR_CHECK(
|
||||
led_strip_new_rmt_device(&strip_config, &rmt_config, &led_strip));
|
||||
|
||||
led_strip_clear(led_strip);
|
||||
led_strip_refresh(led_strip);
|
||||
#elif defined(CONFIG_CALENDINK_LED_TYPE_SIMPLE)
|
||||
gpio_reset_pin(LED_GPIO);
|
||||
gpio_set_direction(LED_GPIO, GPIO_MODE_OUTPUT);
|
||||
gpio_set_level(LED_GPIO, SIMPLE_LED_OFF);
|
||||
#endif
|
||||
}
|
||||
|
||||
void turn_off_led(void)
|
||||
{
|
||||
#if defined(CONFIG_CALENDINK_LED_TYPE_STRIP)
|
||||
led_strip_clear(led_strip);
|
||||
led_strip_refresh(led_strip);
|
||||
#elif defined(CONFIG_CALENDINK_LED_TYPE_SIMPLE)
|
||||
gpio_set_level(LED_GPIO, SIMPLE_LED_OFF);
|
||||
#endif
|
||||
}
|
||||
|
||||
void set_led_status(led_status status)
|
||||
{
|
||||
#if defined(CONFIG_CALENDINK_LED_TYPE_STRIP)
|
||||
switch (status)
|
||||
{
|
||||
case led_status::ConnectingEthernet:
|
||||
led_strip_set_pixel(led_strip, 0, 255, 165, 0);
|
||||
break;
|
||||
case led_status::ConnectingWifi:
|
||||
led_strip_set_pixel(led_strip, 0, 148, 0, 211);
|
||||
break;
|
||||
case led_status::ReadyEthernet:
|
||||
led_strip_set_pixel(led_strip, 0, 0, 255, 0); // Green
|
||||
break;
|
||||
case led_status::ReadyWifi:
|
||||
led_strip_set_pixel(led_strip, 0, 0, 0, 255); // Blue
|
||||
break;
|
||||
case led_status::Failed:
|
||||
led_strip_set_pixel(led_strip, 0, 255, 0, 0);
|
||||
break;
|
||||
}
|
||||
led_strip_refresh(led_strip);
|
||||
#elif defined(CONFIG_CALENDINK_LED_TYPE_SIMPLE)
|
||||
// For a simple LED we just turn it on to show it's reached a status,
|
||||
// or we could add simple blink patterns, but keeping it ON is simplest for status presence.
|
||||
switch (status)
|
||||
{
|
||||
case led_status::ConnectingEthernet:
|
||||
case led_status::ConnectingWifi:
|
||||
case led_status::ReadyEthernet:
|
||||
case led_status::ReadyWifi:
|
||||
case led_status::Failed:
|
||||
gpio_set_level(LED_GPIO, SIMPLE_LED_ON);
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void led_blink_number(int n, uint8_t r, uint8_t g, uint8_t b)
|
||||
{
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
#if defined(CONFIG_CALENDINK_LED_TYPE_STRIP)
|
||||
led_strip_set_pixel(led_strip, 0, r, g, b);
|
||||
led_strip_refresh(led_strip);
|
||||
#elif defined(CONFIG_CALENDINK_LED_TYPE_SIMPLE)
|
||||
gpio_set_level(LED_GPIO, SIMPLE_LED_ON);
|
||||
#endif
|
||||
vTaskDelay(pdMS_TO_TICKS(300));
|
||||
#if defined(CONFIG_CALENDINK_LED_TYPE_STRIP)
|
||||
led_strip_clear(led_strip);
|
||||
led_strip_refresh(led_strip);
|
||||
#elif defined(CONFIG_CALENDINK_LED_TYPE_SIMPLE)
|
||||
gpio_set_level(LED_GPIO, SIMPLE_LED_OFF);
|
||||
#endif
|
||||
vTaskDelay(pdMS_TO_TICKS(300));
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
|
||||
// Project includes
|
||||
#include "types.hpp"
|
||||
|
||||
enum class led_status : uint8
|
||||
{
|
||||
ConnectingEthernet,
|
||||
ConnectingWifi,
|
||||
ReadyEthernet,
|
||||
ReadyWifi,
|
||||
Failed
|
||||
};
|
||||
|
||||
void setup_led(void);
|
||||
void turn_off_led(void);
|
||||
void set_led_status(led_status status);
|
||||
void led_blink_number(int n, uint8_t r, uint8_t g, uint8_t b);
|
||||
@@ -0,0 +1,3 @@
|
||||
idf_component_register(SRCS "network.cpp"
|
||||
INCLUDE_DIRS "." "../shared"
|
||||
PRIV_REQUIRES esp_eth esp_wifi esp_netif driver led)
|
||||
@@ -0,0 +1,73 @@
|
||||
menu "Calendink Network Configuration"
|
||||
|
||||
config CALENDINK_WIFI_SSID
|
||||
string "WiFi SSID"
|
||||
default ""
|
||||
help
|
||||
SSID (network name) for the WiFi connection.
|
||||
|
||||
config CALENDINK_WIFI_PASSWORD
|
||||
string "WiFi Password"
|
||||
default ""
|
||||
help
|
||||
Password for the WiFi connection.
|
||||
|
||||
config CALENDINK_ETH_RETRIES
|
||||
int "Maximum Ethernet Connection Retries"
|
||||
default 5
|
||||
help
|
||||
Number of times to retry the Ethernet connection before falling back to WiFi.
|
||||
|
||||
config CALENDINK_WIFI_RETRIES
|
||||
int "Maximum WiFi Connection Retries"
|
||||
default 5
|
||||
help
|
||||
Number of times to retry the WiFi connection before failing completely.
|
||||
|
||||
config CALENDINK_BLINK_IP
|
||||
bool "Blink last IP digit on connect"
|
||||
default n
|
||||
help
|
||||
If enabled, the LED will blink the last digit of the IP address
|
||||
acquired to assist in debugging.
|
||||
|
||||
config CALENDINK_MDNS_HOSTNAME
|
||||
string "mDNS Hostname"
|
||||
default "calendink"
|
||||
help
|
||||
The hostname to use for mDNS. The device will be accessible
|
||||
at <hostname>.local. (e.g., calendink.local)
|
||||
|
||||
config CALENDINK_UDP_LOG_TARGET_IP
|
||||
string "UDP Logger Target IP Address"
|
||||
default ""
|
||||
help
|
||||
The IP address to send UDP logs to via port 514.
|
||||
If left blank, logs will be broadcast to 255.255.255.255.
|
||||
|
||||
choice CALENDINK_WIFI_PS_MODE
|
||||
prompt "WiFi Power Save Mode"
|
||||
default CALENDINK_WIFI_PS_NONE
|
||||
help
|
||||
Select the WiFi power save mode to balance power consumption and network stability.
|
||||
|
||||
config CALENDINK_WIFI_PS_NONE
|
||||
bool "None (No power save, highest consumption)"
|
||||
|
||||
config CALENDINK_WIFI_PS_MIN_MODEM
|
||||
bool "Minimum Modem (Wakes on beacon, balanced)"
|
||||
|
||||
config CALENDINK_WIFI_PS_MAX_MODEM
|
||||
bool "Maximum Modem (Lowest consumption, may drop connection on strict routers)"
|
||||
endchoice
|
||||
|
||||
config CALENDINK_ALLOW_LIGHT_SLEEP
|
||||
bool "Allow Light Sleep (Tickless Idle)"
|
||||
default n
|
||||
help
|
||||
If enabled, the device will heavily use light sleep to reduce power
|
||||
consumption. Note that this may BREAK the UART console monitor since the
|
||||
CPU sleeps and halts the UART! Use UDP logging if you need logs
|
||||
while light sleep is enabled.
|
||||
|
||||
endmenu
|
||||
@@ -0,0 +1,7 @@
|
||||
version: "1.0.0"
|
||||
description: "Calendink Shared Network Component"
|
||||
|
||||
dependencies:
|
||||
idf:
|
||||
version: '>=4.1.0'
|
||||
espressif/ethernet_init: ^1.3.0
|
||||
@@ -16,13 +16,15 @@
|
||||
#include "freertos/semphr.h"
|
||||
#include "freertos/timers.h"
|
||||
|
||||
// Project includes
|
||||
#include "types.hpp"
|
||||
|
||||
// Project includes
|
||||
#include "led.hpp"
|
||||
#include "network.hpp"
|
||||
|
||||
// Forward declarations
|
||||
#if CONFIG_CALENDINK_BLINK_IP
|
||||
internal esp_err_t get_ip_info(esp_netif_ip_info_t *ip_info);
|
||||
internal void led_blink_number(int n, uint8_t r, uint8_t g, uint8_t b);
|
||||
internal void blink_last_ip_octet();
|
||||
#endif
|
||||
|
||||
@@ -45,6 +47,9 @@ internal volatile bool s_eth_link_up = false;
|
||||
internal bool s_ethernet_connected = false;
|
||||
#endif
|
||||
|
||||
void initialize_network() { ESP_ERROR_CHECK(esp_netif_init()); }
|
||||
void shutdown_network() { esp_netif_deinit(); }
|
||||
|
||||
void ethernet_event_handler(void *arg, esp_event_base_t event_base,
|
||||
int32_t event_id, void *event_data)
|
||||
{
|
||||
@@ -112,11 +117,9 @@ void teardown_ethernet()
|
||||
s_eth_netif = nullptr;
|
||||
s_eth_handles = nullptr;
|
||||
s_eth_count = 0;
|
||||
|
||||
esp_netif_deinit();
|
||||
}
|
||||
|
||||
internal esp_err_t connect_ethernet(bool blockUntilIPAcquired)
|
||||
esp_err_t connect_ethernet(bool blockUntilIPAcquired)
|
||||
{
|
||||
#ifndef NDEBUG
|
||||
assert(!s_ethernet_connected &&
|
||||
@@ -130,10 +133,6 @@ internal esp_err_t connect_ethernet(bool blockUntilIPAcquired)
|
||||
{
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
// Connection is split in two steps. First we open the connection and ask for
|
||||
// an ip. Second a semaphor will block until the ip is acquired. If we dont
|
||||
// block then the user have to verify the semaphore before continuing.
|
||||
ESP_ERROR_CHECK(esp_netif_init());
|
||||
ESP_ERROR_CHECK(ethernet_init_all(&s_eth_handles, &s_eth_count));
|
||||
|
||||
esp_netif_inherent_config_t esp_netif_config =
|
||||
@@ -185,7 +184,7 @@ void disconnect_ethernet()
|
||||
ESP_ERROR_CHECK(esp_unregister_shutdown_handler(&teardown_ethernet));
|
||||
}
|
||||
|
||||
internal esp_err_t check_ethernet_connection(uint32_t timeoutSeconds)
|
||||
esp_err_t check_ethernet_connection(uint32_t timeoutSeconds)
|
||||
{
|
||||
// Wait up to 5000ms for the physical link to negotiate
|
||||
if (!s_eth_link_up)
|
||||
@@ -302,8 +301,8 @@ void teardown_wifi()
|
||||
}
|
||||
}
|
||||
|
||||
internal esp_err_t connect_wifi(const char *ssid, const char *password,
|
||||
bool blockUntilIPAcquired)
|
||||
esp_err_t connect_wifi(const char *ssid, const char *password,
|
||||
bool blockUntilIPAcquired)
|
||||
{
|
||||
#ifndef NDEBUG
|
||||
assert(!s_wifi_connected && "WiFi connect called but already connected!");
|
||||
@@ -379,7 +378,7 @@ void disconnect_wifi()
|
||||
ESP_ERROR_CHECK(esp_unregister_shutdown_handler(&teardown_wifi));
|
||||
}
|
||||
|
||||
internal esp_err_t check_wifi_connection(uint32_t timeoutSeconds)
|
||||
esp_err_t check_wifi_connection(uint32_t timeoutSeconds)
|
||||
{
|
||||
// Wait up to 10000ms for the physical link to associate with the AP
|
||||
if (!s_wifi_link_up)
|
||||
@@ -453,3 +452,22 @@ internal void blink_last_ip_octet()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// === MAC Address ===
|
||||
|
||||
esp_err_t get_mac_address(uint8_t *mac_out)
|
||||
{
|
||||
// Prefer Ethernet if connected, fall back to WiFi
|
||||
if (s_eth_netif != nullptr)
|
||||
{
|
||||
return esp_netif_get_mac(s_eth_netif, mac_out);
|
||||
}
|
||||
|
||||
if (s_wifi_netif != nullptr)
|
||||
{
|
||||
return esp_netif_get_mac(s_wifi_netif, mac_out);
|
||||
}
|
||||
|
||||
ESP_LOGE("NET", "No active network interface for MAC address");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
void initialize_network();
|
||||
void shutdown_network();
|
||||
|
||||
esp_err_t connect_ethernet(bool blockUntilIPAcquired);
|
||||
void disconnect_ethernet();
|
||||
esp_err_t check_ethernet_connection(uint32_t timeoutSeconds);
|
||||
|
||||
esp_err_t connect_wifi(const char *ssid, const char *password,
|
||||
bool blockUntilIPAcquired);
|
||||
void disconnect_wifi();
|
||||
esp_err_t check_wifi_connection(uint32_t timeoutSeconds);
|
||||
|
||||
// Get the MAC address of the active network interface (WiFi STA).
|
||||
// mac_out must point to a buffer of at least 6 bytes.
|
||||
esp_err_t get_mac_address(uint8_t *mac_out);
|
||||
@@ -13,3 +13,6 @@ using int32 = int32_t;
|
||||
using int64 = int64_t;
|
||||
|
||||
#define internal static
|
||||
|
||||
#define GPIO_HIGH 1
|
||||
#define GPIO_LOW 0
|
||||
Reference in New Issue
Block a user