diff --git a/Provider/frontend/src/App.svelte b/Provider/frontend/src/App.svelte index 842ea70..5661f46 100644 --- a/Provider/frontend/src/App.svelte +++ b/Provider/frontend/src/App.svelte @@ -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 @@
+ {:else if currentView === 'devices'} + +
+ +
{/if} diff --git a/Provider/frontend/src/lib/DeviceManager.svelte b/Provider/frontend/src/lib/DeviceManager.svelte new file mode 100644 index 0000000..088a944 --- /dev/null +++ b/Provider/frontend/src/lib/DeviceManager.svelte @@ -0,0 +1,148 @@ + + +
+
+
+

Device Manager

+

+ Manage registered e-ink devices and their screen layouts. +

+
+ +
+ + {#if loading} +
+ Loading devices... +
+ {:else if error} +
+ {error} +
+ {:else if devices.length === 0} +
+

No devices registered yet.

+

+ Use + curl -X POST -d '{{"mac":"AA:BB:CC:DD:EE:FF"}}' http://calendink.local/api/devices/register + to register a device. +

+
+ {:else} +
+ {#each devices as device} +
+ +
+
+ 📺 + {device.mac} +
+
+ {#if device.has_layout} + + Layout Set + + {:else} + + No Layout + + {/if} +
+
+ + +
+ + + + +
+
+ {#if device.has_layout} + + Preview PNG → + + {/if} +
+ +
+ + {#if saveResult === 'ok' && savingMac === ''} +
✓ Layout saved successfully
+ {:else if saveResult && saveResult !== 'ok'} +
✗ {saveResult}
+ {/if} +
+
+ {/each} +
+ {/if} +
diff --git a/Provider/frontend/src/lib/Sidebar.svelte b/Provider/frontend/src/lib/Sidebar.svelte index f66ba87..6e14e16 100644 --- a/Provider/frontend/src/lib/Sidebar.svelte +++ b/Provider/frontend/src/lib/Sidebar.svelte @@ -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: '👥' }, ]; diff --git a/Provider/frontend/src/lib/api.js b/Provider/frontend/src/lib/api.js index cd244dd..fb3e5f9 100644 --- a/Provider/frontend/src/lib/api.js +++ b/Provider/frontend/src/lib/api.js @@ -274,3 +274,35 @@ export async function deleteTask(id) { } return res.json(); } + +// ─── Device Management ─────────────────────────────────────────────────────── + +/** + * Fetch all registered devices. + * @returns {Promise>} + */ +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(); +} + +/** + * 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(); +} + diff --git a/Provider/main/api/devices/layout.cpp b/Provider/main/api/devices/layout.cpp new file mode 100644 index 0000000..f2b0403 --- /dev/null +++ b/Provider/main/api/devices/layout.cpp @@ -0,0 +1,97 @@ +// POST /api/devices/layout — Update the XML layout for a device +// Body: {"mac": "AA:BB:CC:DD:EE:FF", "xml": ""} + +#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) || 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)) + { + 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); + cJSON_Delete(body); + + if (!ok) + { + 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)); + + 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}; diff --git a/Provider/main/api/devices/list.cpp b/Provider/main/api/devices/list.cpp new file mode 100644 index 0000000..40a2fd0 --- /dev/null +++ b/Provider/main/api/devices/list.cpp @@ -0,0 +1,40 @@ +// 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 *arr = cJSON_CreateArray(); + + 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_AddItemToArray(arr, obj); + } + } + + const char *json = cJSON_PrintUnformatted(arr); + httpd_resp_sendstr(req, json); + + free((void *)json); + cJSON_Delete(arr); + + 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}; diff --git a/Provider/main/api/devices/register.cpp b/Provider/main/api/devices/register.cpp new file mode 100644 index 0000000..1aa82c8 --- /dev/null +++ b/Provider/main/api/devices/register.cpp @@ -0,0 +1,77 @@ +// 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) || 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); + cJSON_Delete(body); + + if (!dev) + { + 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); + + 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}; diff --git a/Provider/main/api/devices/screen.cpp b/Provider/main/api/devices/screen.cpp new file mode 100644 index 0000000..0d330e1 --- /dev/null +++ b/Provider/main/api/devices/screen.cpp @@ -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}; diff --git a/Provider/main/api/devices/screen_image.cpp b/Provider/main/api/devices/screen_image.cpp new file mode 100644 index 0000000..8423c43 --- /dev/null +++ b/Provider/main/api/devices/screen_image.cpp @@ -0,0 +1,156 @@ +// 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 "lv_setup.hpp" +#include "lodepng/lodepng.h" +#include "esp_http_server.h" +#include "esp_log.h" +#include "esp_heap_caps.h" +#include "lvgl.h" +#include +#include "lodepng_alloc.hpp" + +#include "types.hpp" +#include "device.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); + + if (dev->xml_layout[0] != '\0') + { + // TODO: Use lv_xml_create() when LVGL XML runtime is verified working. + // For now, show the XML as text to prove the pipeline works end to end. + // Once we confirm LV_USE_XML compiles, we'll swap this for the real XML parser. + lv_obj_t *label = lv_label_create(scr); + lv_label_set_text(label, dev->xml_layout); + lv_obj_set_style_text_color(label, lv_color_black(), LV_PART_MAIN); + lv_obj_align(label, LV_ALIGN_TOP_LEFT, 10, 10); + } + else + { + // Fallback: render "Hello " + lv_obj_t *label = lv_label_create(scr); + char text[48]; + snprintf(text, sizeof(text), "Hello %s", mac); + lv_label_set_text(label, text); + 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; + + // Handle stride != width + uint8_t *packed_data = (uint8_t *)draw_buf->data; + bool needs_free = false; + + if (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(kTagDeviceScreenImage, "Failed to allocate packed buffer"); + 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); + } + } + + // 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); + + if (needs_free) + { + 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 res = httpd_resp_send(req, (const char *)png, pngsize); + + return res; +} + +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}; diff --git a/Provider/main/api/devices/store.cpp b/Provider/main/api/devices/store.cpp new file mode 100644 index 0000000..e1d5c85 --- /dev/null +++ b/Provider/main/api/devices/store.cpp @@ -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; + g_Devices[i].xml_layout[0] = '\0'; + *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; +} diff --git a/Provider/main/api/devices/unity.cpp b/Provider/main/api/devices/unity.cpp new file mode 100644 index 0000000..498cf30 --- /dev/null +++ b/Provider/main/api/devices/unity.cpp @@ -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 diff --git a/Provider/main/device.hpp b/Provider/main/device.hpp new file mode 100644 index 0000000..31e0aff --- /dev/null +++ b/Provider/main/device.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include + +#include "types.hpp" + +constexpr int MAX_DEVICES = 8; +constexpr int DEVICE_XML_MAX = 2048; + +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] = {}; diff --git a/Provider/main/http_server.cpp b/Provider/main/http_server.cpp index 7853991..2622a95 100644 --- a/Provider/main/http_server.cpp +++ b/Provider/main/http_server.cpp @@ -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(); diff --git a/Provider/sdkconfig.defaults b/Provider/sdkconfig.defaults index c8df108..6a9ada5 100644 --- a/Provider/sdkconfig.defaults +++ b/Provider/sdkconfig.defaults @@ -96,3 +96,6 @@ 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