Merge pull request 'display-managemetn' (#4) from display-managemetn into main

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-03-17 21:10:08 -04:00
36 changed files with 1582 additions and 355 deletions
+4 -1
View File
@@ -95,4 +95,7 @@ external/*
Provider/AgentTasks/
# OTA files
*.bundle
*.bundle
#png
*.png
+7
View File
@@ -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)
+133
View File
@@ -0,0 +1,133 @@
dependencies:
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
idf:
source:
type: idf
version: 5.5.3
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:
- idf
- network
manifest_hash: 12f55da0a0684644c57ee6a400e8e98810d36140fad54f35a96e434eb9774b8c
target: esp32
version: 2.0.0
+3
View File
@@ -0,0 +1,3 @@
idf_component_register(SRCS "main.cpp"
PRIV_REQUIRES esp_eth esp_wifi esp_netif driver network nvs_flash
INCLUDE_DIRS ".")
+6
View File
@@ -0,0 +1,6 @@
## IDF Component Manager Manifest File
dependencies:
idf:
version: '>=4.1.0'
network:
path: "../../components/network"
+54
View File
@@ -0,0 +1,54 @@
#include <stdio.h>
#include "esp_log.h"
#include "esp_system.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "nvs_flash.h"
#include "network.hpp"
static const char *TAG = "ClientMain";
extern "C" void app_main()
{
ESP_LOGI(TAG, "Hello, Calendink Client!");
// Initialize NVS (required for some Wi-Fi configurations and network features)
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());
// Connect to WiFi
ESP_LOGI(TAG, "Initializing WiFi connection");
// Attempting to connect, we don't necessarily need to block forever here
// as network.cpp already handles some of the retries
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);
vTaskDelay(pdMS_TO_TICKS(1000));
}
retries++;
} while (err == ESP_ERR_TIMEOUT && retries <= CONFIG_CALENDINK_WIFI_RETRIES);
}
if (err == ESP_OK) {
ESP_LOGI(TAG, "Successfully connected to WiFi!");
} else {
ESP_LOGE(TAG, "Failed to connect to WiFi.");
}
while (true)
{
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
+14 -3
View File
@@ -146,12 +146,22 @@ dependencies:
type: service
version: 1.20.4
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
@@ -159,6 +169,7 @@ direct_dependencies:
- idf
- joltwallet/littlefs
- lvgl/lvgl
manifest_hash: 96112412d371d78cc527b7d0904042e5a7ca7c4f25928de9483a1b53dd2a2f4e
- network
manifest_hash: adc42d97d037e4815c3e1d03227cf1a5b29b8a914aa24fefa5760edd541a6bac
target: esp32s3
version: 2.0.0
+20
View File
@@ -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)
+7 -1
View File
@@ -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>
+1
View File
@@ -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: '👥' },
];
+50
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
{
"major": 0,
"minor": 1,
"revision": 30
"revision": 32
}
+1 -2
View File
@@ -1,8 +1,7 @@
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
json app_update esp_timer esp_psram mdns driver network
INCLUDE_DIRS ".")
if(CONFIG_CALENDINK_DEPLOY_WEB_PAGES)
-73
View File
@@ -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"
+99
View File
@@ -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};
+45
View File
@@ -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};
+79
View File
@@ -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};
+59
View File
@@ -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};
+249
View File
@@ -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};
+57
View File
@@ -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;
}
+9
View File
@@ -0,0 +1,9 @@
// 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"
// clang-format on
+120 -88
View File
@@ -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", "*");
// 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");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
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;
}
// 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");
// 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);
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;
}
// Force a screen refresh to get the latest rendered frame
lv_refr_now(g_LvglDisplay);
// 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);
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};
+25
View File
@@ -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] = {};
+9 -1
View File
@@ -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,13 @@ 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);
// Populate dummy data for development (debug builds only)
#ifndef NDEBUG
seed_users();
+3 -1
View File
@@ -18,4 +18,6 @@ dependencies:
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"
+117 -106
View File
@@ -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)
// 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 (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);
// 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);
}
ESP_LOGI(kTagLodeAlloc,
"Allocating %d bytes in PSRAM for LodePNG bump allocator...",
LODEPNG_ALLOC_POOL_SIZE);
if (!s_lodepng_pool)
{
ESP_LOGE(kTagLodeAlloc, "CRITICAL: Failed to allocate LodePNG PSRAM pool!");
}
// 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!");
}
}
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);
}
if (new_size == 0)
{
lodepng_custom_free(ptr);
return nullptr;
ESP_LOGE(kTagLodeAlloc,
"LodePNG pool exhausted during in-place realloc!");
return nullptr;
}
// Get original header
uint8_t* orig_ptr = (uint8_t*)ptr - sizeof(AllocHeader);
AllocHeader* header = (AllocHeader*)orig_ptr;
s_lodepng_pool_used += size_diff;
header->size = new_size;
return 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;
}
// 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);
}
// 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;
}
+87 -61
View File
@@ -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);
// 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;
}
// 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);
g_LvglDrawBuffer = (uint8_t *)buf1;
// 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);
}
lv_display_set_buffers(g_LvglDisplay, buf1, nullptr, buf_size, LV_DISPLAY_RENDER_MODE_FULL);
// 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);
if (!buf1)
{
ESP_LOGE(kTagLvgl, "Failed to allocate LVGL draw buffer entirely.");
return;
}
// Create the background task for the LVGL timer
xTaskCreate(lvgl_tick_task, "LVGL Tick", 4096, nullptr, 5, nullptr);
g_LvglDrawBuffer = (uint8_t *)buf1;
// Draw the sample UI
if (xSemaphoreTake(g_LvglMutex, portMAX_DELAY) == pdTRUE)
{
lv_draw_sample_ui();
xSemaphoreGive(g_LvglMutex);
}
// 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);
ESP_LOGI(kTagLvgl, "LVGL fully initialized. Display %lux%lu created.", width, height);
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);
// 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);
}
+1 -2
View File
@@ -16,13 +16,12 @@
#include "soc/gpio_num.h"
// Project headers
#include "appstate.hpp"
#include "types.hpp"
#include "network.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"
+14 -10
View File
@@ -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
+8
View File
@@ -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.
+3
View File
@@ -0,0 +1,3 @@
idf_component_register(SRCS "network.cpp"
INCLUDE_DIRS "include"
PRIV_REQUIRES esp_eth esp_wifi esp_netif driver)
+73
View File
@@ -0,0 +1,73 @@
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
+7
View File
@@ -0,0 +1,7 @@
version: "1.0.0"
description: "CalendarInk Shared Network Component"
dependencies:
idf:
version: '>=4.1.0'
espressif/ethernet_init: ^1.3.0
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#include "esp_err.h"
#include <cstdint>
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);
@@ -17,7 +17,11 @@
#include "freertos/timers.h"
// Project includes
#include "types.hpp"
#include "network.hpp"
#ifndef internal
#define internal static
#endif
// Forward declarations
#if CONFIG_CALENDINK_BLINK_IP
@@ -116,7 +120,7 @@ void teardown_ethernet()
esp_netif_deinit();
}
internal esp_err_t connect_ethernet(bool blockUntilIPAcquired)
esp_err_t connect_ethernet(bool blockUntilIPAcquired)
{
#ifndef NDEBUG
assert(!s_ethernet_connected &&
@@ -185,7 +189,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,7 +306,7 @@ void teardown_wifi()
}
}
internal esp_err_t connect_wifi(const char *ssid, const char *password,
esp_err_t connect_wifi(const char *ssid, const char *password,
bool blockUntilIPAcquired)
{
#ifndef NDEBUG
@@ -379,7 +383,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)