Compare commits
22 Commits
e661e15bbf
...
display-ma
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c79be36ef | |||
| 7f296f9857 | |||
| f64860125c | |||
| ebb0ccecf4 | |||
| baa0a8b1ba | |||
| 46dfe82568 | |||
| f0297418cf | |||
| 238b408947 | |||
| 6384e93020 | |||
| a9d5aa83dc | |||
| b702839f8e | |||
| 75d88f441c | |||
| 9d3a277f45 | |||
| 4161ff9513 | |||
| 54c56002ee | |||
| 38201280ea | |||
| c034999d20 | |||
| 56acf92f75 | |||
| 9388bf17af | |||
| 72e031a99d | |||
| ac95358561 | |||
| 3fa879d007 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -93,3 +93,9 @@ external/*
|
||||
|
||||
# Agent Tasks
|
||||
Provider/AgentTasks/
|
||||
|
||||
# OTA files
|
||||
*.bundle
|
||||
|
||||
#png
|
||||
*.png
|
||||
|
||||
124
Provider/.agents/rules/coding-guidelines.md
Normal file
124
Provider/.agents/rules/coding-guidelines.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Calendink Coding Guidelines
|
||||
|
||||
These rules apply to all code in this workspace.
|
||||
|
||||
---
|
||||
|
||||
## Backend — C++ / ESP-IDF
|
||||
|
||||
### Philosophy: C-with-Utilities
|
||||
Write **C-style code** using C++ convenience features. No classes, no methods, no RAII, no exceptions.
|
||||
|
||||
**Use freely:** `template`, `auto`, `constexpr`, `enum class`, type aliases from `types.hpp`
|
||||
**Avoid:** classes, constructors/destructors, `std::` containers, inheritance, virtual functions, RAII wrappers
|
||||
|
||||
`goto` for cleanup/shutdown paths in `app_main` is acceptable.
|
||||
|
||||
### Unity Build
|
||||
All `.cpp` files are `#include`-ed into `main.cpp` as a single translation unit. **Do not register new source files in CMakeLists.txt.**
|
||||
|
||||
- Use `unity.cpp` aggregators per API group (e.g., `api/tasks/unity.cpp`)
|
||||
- Mark all file-scoped symbols with `internal` (defined as `static` in `types.hpp`)
|
||||
- Use `.hpp` for declarations shared across included files only
|
||||
|
||||
### API Handler Pattern
|
||||
```cpp
|
||||
// METHOD /api/path — What it does
|
||||
// Body: { "field": value } (if applicable)
|
||||
|
||||
internal esp_err_t api_foo_post_handler(httpd_req_t *req) {
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
|
||||
// 1. Parse request
|
||||
// 2. Call store function
|
||||
// 3. Build cJSON response
|
||||
// Always cJSON_Delete() objects and free() printed strings — no leaks
|
||||
}
|
||||
|
||||
internal const httpd_uri_t api_foo_post_uri = { .uri = "/api/foo", .method = HTTP_POST, .handler = api_foo_post_handler, .user_ctx = NULL };
|
||||
```
|
||||
|
||||
### Store / Handler Separation
|
||||
Data operations in `store.cpp` / `store.hpp`. HTTP parsing in endpoint files. Never mix them.
|
||||
|
||||
### Logging
|
||||
**Always use `ESP_LOGI` / `ESP_LOGW` / `ESP_LOGE`** — never `printf()`.
|
||||
|
||||
Since this is a Unity Build (single translation unit), the log tag must be unique per file to avoid redefinition. Name it after the module, not a generic `TAG`:
|
||||
```cpp
|
||||
// In each file, use a unique local name:
|
||||
internal const char *kTagHttpServer = "HTTP_SERVER";
|
||||
internal const char *kTagMain = "MAIN";
|
||||
internal const char *kTagMDNS = "MDNS";
|
||||
```
|
||||
|
||||
### Type Aliases
|
||||
Use `types.hpp` aliases: `uint8`, `uint16`, `uint32`, `uint64`, `int8`–`int64`, `internal`
|
||||
|
||||
### Seed Data
|
||||
`seed_users()` and `seed_tasks()` must be guarded:
|
||||
```cpp
|
||||
#ifndef NDEBUG
|
||||
seed_users();
|
||||
seed_tasks();
|
||||
#endif
|
||||
```
|
||||
|
||||
### Data Persistence
|
||||
All task/user data is currently **in-RAM only** (intentional — NVS/LittleFS persistence is a future milestone). Do not add persistence without a TDD.
|
||||
|
||||
---
|
||||
|
||||
## Frontend — Svelte 5 + TailwindCSS v4
|
||||
|
||||
### Styling: Tailwind Only
|
||||
Use TailwindCSS exclusively. **No `<style>` blocks in components.**
|
||||
|
||||
- All design tokens are in `app.css` via `@theme`: `bg-bg-card`, `text-text-primary`, `border-border`, `text-accent`, `text-success`, `text-danger`, etc.
|
||||
- If a utility is missing, add a token to `@theme` — don't add inline CSS
|
||||
|
||||
### Reactivity: Svelte 5 Runes Only
|
||||
| Do ✅ | Don't ❌ |
|
||||
|---|---|
|
||||
| `$state()` | `writable()`, `readable()` |
|
||||
| `$derived()` / `$derived.by()` | `$:` reactive statements |
|
||||
| `$effect()` | `onMount()`, `onDestroy()`, `.subscribe()` |
|
||||
| `$props()` / `$bindable()` | `export let` |
|
||||
|
||||
### `$state()` vs Plain `let`
|
||||
- **`$state()`** — values the template reads, or that changes should cause a re-render
|
||||
- **Plain `let`** — script-only internals (mutex flags, interval handles, etc.) the template never reads
|
||||
|
||||
### `$effect()` for Initialization
|
||||
When an effect has no reactive dependencies and runs once on mount, add a comment:
|
||||
```js
|
||||
// Load initial data on mount
|
||||
$effect(() => { fetchData(); });
|
||||
```
|
||||
|
||||
### Shared Utilities
|
||||
- Date/time helpers, formatters → `lib/utils.js`
|
||||
- Cross-component reactive state → `lib/stores.js`
|
||||
- API calls → `lib/api.js` (always via `trackedFetch()`)
|
||||
|
||||
**Never duplicate functions across components.**
|
||||
|
||||
### Component Structure
|
||||
```svelte
|
||||
<script>
|
||||
// 1. Imports
|
||||
// 2. $props()
|
||||
// 3. $state()
|
||||
// 4. $derived()
|
||||
// 5. Functions
|
||||
// 6. $effect()
|
||||
</script>
|
||||
<!-- Template — Tailwind classes only -->
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## General
|
||||
|
||||
- **Don't commit build artifacts**: `dist/`, `bundles/`, `temp_*`, `*.gz` — update `.gitignore` accordingly
|
||||
- **Version** is managed through `version.json`, injected as `__APP_VERSION__` at build time
|
||||
@@ -10,3 +10,4 @@ Pleaes add the date.
|
||||
The TDD should starts with the What, then the Why and finally the how.
|
||||
Everytime you write a document you can read other tdd in the folder for inspiration.
|
||||
When implementation is finished, the user can add to edit the TDD to add more informations (implementation details that are important, benchmarks, any change of plan during development)
|
||||
When you add a tdd, please update Gemini.md to add the tdd.
|
||||
49
Provider/.agents/workflows/frontend-ota.md
Normal file
49
Provider/.agents/workflows/frontend-ota.md
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
description: Build, package, and deploy frontend updates via OTA
|
||||
---
|
||||
|
||||
This workflow automates the process of building the frontend, packaging it into a LittleFS binary, and uploading it to the ESP32 device.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Ensure `VITE_API_BASE` in `frontend/.env` is set to the target device's IP address.
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Build and Package Frontend**
|
||||
Run the following command in the `frontend/` directory to build the production assets and create the versioned `.bin` file:
|
||||
```bash
|
||||
npm run build:esp32 && npm run ota:package
|
||||
```
|
||||
|
||||
2. **Deploy Update**
|
||||
Run the deployment script to upload the latest version to the device:
|
||||
```bash
|
||||
npm run ota:deploy
|
||||
```
|
||||
|
||||
3. **Verify Update**
|
||||
Wait for the device to reboot and verify the changes on the dashboard.
|
||||
|
||||
---
|
||||
|
||||
### Universal Bundle Option
|
||||
|
||||
If you also need to update the firmware, use the universal bundle workflow:
|
||||
|
||||
1. **Build Firmware**
|
||||
From the project root:
|
||||
```bash
|
||||
idf.py build
|
||||
```
|
||||
|
||||
2. **Create Bundle**
|
||||
In the `frontend/` directory:
|
||||
```bash
|
||||
npm run ota:bundle
|
||||
```
|
||||
|
||||
3. **Deploy Bundle**
|
||||
In the `frontend/` directory:
|
||||
```bash
|
||||
npm run ota:deploy-bundle
|
||||
```
|
||||
92
Provider/AGENTS.md
Normal file
92
Provider/AGENTS.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# AGENTS.md
|
||||
|
||||
Calendink Provider — ESP32-S3 firmware + Svelte 5 web dashboard.
|
||||
Uses **ESP-IDF** (not Arduino). Serves UI from LittleFS flash partitions over Ethernet/WiFi.
|
||||
|
||||
---
|
||||
|
||||
## Build Commands
|
||||
|
||||
```powershell
|
||||
# Backend (from project root)
|
||||
idf.py build
|
||||
idf.py flash monitor
|
||||
|
||||
# Frontend (from frontend/)
|
||||
npm run dev # Dev server — calls real ESP32 API (set VITE_API_BASE in .env.development)
|
||||
npm run build # Production build → dist/index.html
|
||||
npm run build:esp32 # Build + gzip → dist/index.html.gz
|
||||
npm run ota:deploy # OTA deploy frontend to device
|
||||
### Logging on Ethernet
|
||||
When the device is connected via Ethernet, logs are broadcast over UDP. Run `ncat -ul 514` on your PC to view the live `ESP_LOG` output.
|
||||
*(If `ncat` throws a `WSAEMSGSIZE` error on Windows, use this Python command instead:)*
|
||||
```powershell
|
||||
python -c "import socket; s=socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.bind(('', 514)); [print(m[0].decode(errors='ignore'), end='') for m in iter(lambda:s.recvfrom(4096), None)]"
|
||||
```
|
||||
|
||||
There are no automated tests. Verify by building and inspecting on-device.
|
||||
|
||||
---
|
||||
|
||||
## Project Layout
|
||||
|
||||
```
|
||||
main/ C++ firmware (Unity Build — one translation unit)
|
||||
main/api/<domain>/ HTTP endpoint files, one per verb
|
||||
main/http_server.cpp Server setup, static file serving, scratch buffer pool
|
||||
main/connect.cpp Ethernet + WiFi management
|
||||
frontend/src/ Svelte 5 components + api.js
|
||||
frontend/src/app.css TailwindCSS v4 @theme design tokens
|
||||
tdd/ Technical Design Documents — read before major changes
|
||||
.agents/rules/ Coding guidelines
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backend Rules (C++ / ESP-IDF)
|
||||
|
||||
- **Unity Build**: `main.cpp` `#include`s all `.cpp` files. Do NOT add files to `CMakeLists.txt`.
|
||||
- **C-style only**: no classes, no `std::`, no RAII, no exceptions. `template`, `auto`, `constexpr` are fine.
|
||||
- **`internal`** (= `static`) on all file-scoped symbols — defined in `main/types.hpp`.
|
||||
- **Logging**: use `ESP_LOGI` / `ESP_LOGW` / `ESP_LOGE` only — never `printf()`.
|
||||
- Because of Unity Build, tag variables must have unique names per file: `kTagHttpServer`, `kTagMain`, etc. — never use a shared `TAG`.
|
||||
- **Seed data**: `seed_users()` / `seed_tasks()` must be inside `#ifndef NDEBUG` guards.
|
||||
- **API handler pattern**: set CORS header + response type → parse request → call store function → build cJSON response → `free()` strings, `cJSON_Delete()` objects.
|
||||
- **Data is in-RAM only** (`g_Users[8]`, `g_Tasks[32]`). This is intentional — persistence is a future milestone. Don't add it without a TDD.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Rules (Svelte 5 + TailwindCSS v4)
|
||||
|
||||
- **Tailwind only** — no `<style>` blocks in components. Design tokens are in `frontend/src/app.css` (`@theme`).
|
||||
- **Svelte 5 runes only** — `$state`, `$derived`, `$effect`, `$props`, `$bindable`. No `export let`, `onMount`, `onDestroy`, `writable`, or `.subscribe()`.
|
||||
- **`$state()`** for values the template reads. **Plain `let`** for script-only handles/flags the template never reads.
|
||||
- **`$effect()` with no reactive deps** runs once on mount — add a comment saying so.
|
||||
- All API calls through `lib/api.js` via `trackedFetch()` — never raw `fetch()`.
|
||||
- Shared utilities (formatters, helpers) in `lib/utils.js`. Never duplicate functions across components.
|
||||
|
||||
---
|
||||
|
||||
## Technical Design Documents
|
||||
|
||||
Read the relevant TDD before any major architectural change:
|
||||
|
||||
| TDD | Read when |
|
||||
|---|---|
|
||||
| `tdd/backend_architecture.md` | Changing server setup, adding API groups |
|
||||
| `tdd/frontend_technology_choices.md` | Changing build tools or dependencies |
|
||||
| `tdd/todo_list.md` | Changing task/user data model or API contracts |
|
||||
| `tdd/firmware_ota.md` | Touching OTA partition logic |
|
||||
| `tdd/frontend_ota.md` | Touching frontend OTA upload or versioning |
|
||||
| `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 |
|
||||
|
||||
---
|
||||
|
||||
## Working with the User
|
||||
|
||||
- After finishing a task: explain what changed, what you recommend next, and wait for approval.
|
||||
- Never make changes beyond the agreed scope without asking first.
|
||||
- Write a TDD (see `.agents/rules/how-to-write-tdd.md`) before major architectural changes.
|
||||
- **Keep `AGENTS.md` and `GEMINI.md` up to date.** If a task changes the architecture, adds new rules, introduces new build commands, or modifies the project layout — update both files before closing the task.
|
||||
@@ -86,3 +86,19 @@ Once the backend supports it (Phase 2+), you can update the frontend without usi
|
||||
For a safer and more convenient update experience, you can bundle both the Firmware and Frontend into a single file.
|
||||
|
||||
See the [Universal OTA Bundle Guide](build_bundle.md) for details.
|
||||
|
||||
## 7. AI Agent Workflow
|
||||
|
||||
If you are an AI agent, you should use the automated deployment scripts to speed up your work.
|
||||
|
||||
### Deployment Prerequisites
|
||||
- Ensure `VITE_API_BASE` in `frontend/.env` is set to the target device IP.
|
||||
- Ensure `MKLITTLEFS_PATH` is correctly set if you are on Windows.
|
||||
|
||||
### Standard OTA Workflow
|
||||
Follow the [Frontend OTA Workflow](file:///w:/Classified/Calendink/Provider/.agents/workflows/frontend-ota.md) for automated building and deployment.
|
||||
|
||||
**Summary of commands (run in `frontend/`):**
|
||||
1. `npm run build:esp32` - Build production assets.
|
||||
2. `npm run ota:package` - Create LittleFS image.
|
||||
3. `npm run ota:deploy` - Upload to device via HTTP.
|
||||
|
||||
23
Provider/Documentation/listen_udp_logs.md
Normal file
23
Provider/Documentation/listen_udp_logs.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Listening to UDP Logs
|
||||
|
||||
When the Calendink Provider device is running (especially when connected via Ethernet and serial monitoring is not feasible), the firmware broadcasts the `ESP_LOG` output over UDP on port **514**.
|
||||
|
||||
You can listen to these live logs directly from your PC.
|
||||
|
||||
## Option 1: Using ncat (Nmap)
|
||||
If you have `ncat` installed, you can listen to the UDP broadcast by running this command in your terminal:
|
||||
|
||||
```powershell
|
||||
ncat -ul 514
|
||||
```
|
||||
|
||||
## Option 2: Using Python (Windows Fallback)
|
||||
On Windows, `ncat` sometimes throws a `WSAEMSGSIZE` error if a log line exceeds a certain size (like the chunked PM dump locks). If this happens, or if you don't have `ncat` installed, you can run this Python one-liner in your terminal instead:
|
||||
|
||||
```powershell
|
||||
python -c "import socket; s=socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.bind(('', 514)); [print(m[0].decode(errors='ignore'), end='') for m in iter(lambda:s.recvfrom(4096), None)]"
|
||||
```
|
||||
|
||||
### Configuration
|
||||
- **Port:** The UDP server broadcasts on port `514`.
|
||||
- **Target IP:** By default, logs are broadcast to `255.255.255.255`. If you need to target a specific machine, make sure `CONFIG_CALENDINK_UDP_LOG_TARGET_IP` is set to your PC's IP address in your `sdkconfig`.
|
||||
115
Provider/GEMINI.md
Normal file
115
Provider/GEMINI.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Calendink Provider — Agent Context
|
||||
|
||||
This is an **ESP32-S3 IoT device** firmware + web dashboard project.
|
||||
It uses **ESP-IDF** (native, not Arduino) and serves a **Svelte 5 web UI** from flash.
|
||||
|
||||
---
|
||||
|
||||
## Project Summary
|
||||
|
||||
Calendink Provider is a connected desk device that serves a local web dashboard over Ethernet/WiFi.
|
||||
The firmware manages network connections, serves a Svelte SPA from LittleFS flash partitions, and exposes a REST API for system control (reboot, OTA, tasks, users).
|
||||
|
||||
### Key Paths
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `main/` | C++ firmware (Unity Build) |
|
||||
| `main/api/` | REST API handlers, one file per endpoint |
|
||||
| `main/http_server.cpp` | HTTP server setup, static file serving |
|
||||
| `main/connect.cpp` | Ethernet + WiFi connection management |
|
||||
| `frontend/src/` | Svelte 5 frontend source |
|
||||
| `frontend/src/lib/api.js` | All API calls (always use `trackedFetch`) |
|
||||
| `frontend/src/app.css` | TailwindCSS v4 design tokens (`@theme`) |
|
||||
| `tdd/` | Technical Design Documents (read before major changes) |
|
||||
| `.agents/rules/` | Coding guidelines (always follow) |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend
|
||||
|
||||
- **Unity Build**: `main.cpp` `#include`s all `.cpp` files. Do NOT add files to CMakeLists.txt. Everything is one translation unit.
|
||||
- **API handlers**: Each endpoint is its own `.cpp` file in `main/api/<domain>/`. A `unity.cpp` aggregates them.
|
||||
- **HTTP server**: `esp_http_server` + `cJSON`. CORS is always on (`*`).
|
||||
- **Data storage**: Users and tasks are in static BSS arrays (`g_Users[8]`, `g_Tasks[32]`). In-RAM only, intentionally — persistence is a future feature.
|
||||
- **OTA**: A/B partition scheme — `www_0` / `www_1` for frontend, `ota_0` / `ota_1` for firmware. NVS tracks which slot is active.
|
||||
- **Connections**: Ethernet first, WiFi fallback. Connection state managed with FreeRTOS semaphores.
|
||||
|
||||
### Frontend
|
||||
|
||||
- **Svelte 5** with runes (`$state`, `$derived`, `$effect`, `$props`). No legacy Svelte 4 APIs.
|
||||
- **TailwindCSS v4** (utility classes only — no `<style>` blocks in components).
|
||||
- **Single-file build**: `vite-plugin-singlefile` inlines all JS+CSS into one `index.html`.
|
||||
- **Version**: Tracked in `version.json`, injected as `__APP_VERSION__` at build time.
|
||||
- **Dev mode**: Set `VITE_API_BASE=http://<ESP32_IP>` in `.env.development` — the frontend runs on PC and calls the real ESP32 API.
|
||||
|
||||
---
|
||||
|
||||
## Coding Rules (summary — full rules in `.agents/rules/coding-guidelines.md`)
|
||||
|
||||
### Backend
|
||||
- C-style code: no classes, no `std::`, no RAII, no exceptions. Use `template`, `auto`, `constexpr` freely.
|
||||
- Use `internal` (= `static`) for all file-scoped symbols.
|
||||
- Log with `ESP_LOGI/W/E` only — never `printf()`. Name log tags per-module with unique variable names (e.g., `kTagHttpServer`), not a shared `TAG`, to avoid Unity Build redefinition.
|
||||
- Seed data (`seed_users`, `seed_tasks`) must be `#ifndef NDEBUG` guarded.
|
||||
|
||||
### Frontend
|
||||
- Svelte 5 runes only. No `onMount`, `onDestroy`, `writable`, `.subscribe`.
|
||||
- Tailwind only. No `<style>` blocks.
|
||||
- Shared logic goes in `lib/utils.js`. Never duplicate functions across components.
|
||||
- API calls go through `lib/api.js` via `trackedFetch()` — never raw `fetch()`.
|
||||
- `$state()` for values the template reads. Plain `let` for script-only flags/handles.
|
||||
|
||||
---
|
||||
|
||||
## Technical Design Documents
|
||||
|
||||
Always read the relevant TDD before making major architectural changes:
|
||||
|
||||
| TDD | When to read |
|
||||
|---|---|
|
||||
| [backend_architecture.md](tdd/backend_architecture.md) | Changing server setup, adding new API groups |
|
||||
| [frontend_technology_choices.md](tdd/frontend_technology_choices.md) | Changing build tools, adding dependencies |
|
||||
| [todo_list.md](tdd/todo_list.md) | Changing task/user data models or API contracts |
|
||||
| [firmware_ota.md](tdd/firmware_ota.md) | Touching OTA partition logic |
|
||||
| [frontend_ota.md](tdd/frontend_ota.md) | Touching frontend OTA upload or versioning |
|
||||
| [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 |
|
||||
|
||||
---
|
||||
|
||||
## How to Work With the User
|
||||
|
||||
- After finishing any task: tell the user what you did, what you think the next step should be, and ask for approval before proceeding.
|
||||
- Never go rogue — propose changes, wait for confirmation.
|
||||
- Write a TDD before major architectural changes. See `.agents/rules/how-to-write-tdd.md`.
|
||||
- **Keep `AGENTS.md` and `GEMINI.md` up to date.** If a task changes the architecture, adds new rules, introduces new build commands, or modifies the project layout — update both files before closing the task.
|
||||
|
||||
---
|
||||
|
||||
## Build Commands
|
||||
|
||||
```powershell
|
||||
# Backend — from project root
|
||||
idf.py build
|
||||
idf.py flash monitor
|
||||
|
||||
# Frontend — from frontend/
|
||||
npm run dev # Dev server (PC, calls real ESP32 API)
|
||||
npm run build # Production build → dist/index.html
|
||||
npm run build:esp32 # Build + gzip → dist/index.html.gz
|
||||
npm run ota:package # Package as versioned .bin
|
||||
npm run ota:bundle # Package FW + frontend as .bundle
|
||||
npm run ota:deploy # Deploy frontend OTA to device
|
||||
```
|
||||
|
||||
## Logging on Ethernet
|
||||
|
||||
When the device is connected to Ethernet, logs are broadcast over UDP to port 514. Use `ncat -ul 514` on your PC to view them live.
|
||||
*(If `ncat` throws a `WSAEMSGSIZE` error on Windows, use this Python command instead:)*
|
||||
```powershell
|
||||
python -c "import socket; s=socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.bind(('', 514)); [print(m[0].decode(errors='ignore'), end='') for m in iter(lambda:s.recvfrom(4096), None)]"
|
||||
```
|
||||
@@ -121,6 +121,16 @@ dependencies:
|
||||
registry_url: https://components.espressif.com/
|
||||
type: service
|
||||
version: 3.0.3
|
||||
espressif/mdns:
|
||||
component_hash: 7c0fa01a1cd0e72a87ec1928c3b661c0a3a9034a6d3a69dcf4850db8c6f272db
|
||||
dependencies:
|
||||
- name: idf
|
||||
require: private
|
||||
version: '>=5.0'
|
||||
source:
|
||||
registry_url: https://components.espressif.com/
|
||||
type: service
|
||||
version: 1.10.1
|
||||
idf:
|
||||
source:
|
||||
type: idf
|
||||
@@ -135,11 +145,20 @@ dependencies:
|
||||
registry_url: https://components.espressif.com/
|
||||
type: service
|
||||
version: 1.20.4
|
||||
lvgl/lvgl:
|
||||
component_hash: 17e68bfd21f0edf4c3ee838e2273da840bf3930e5dbc3bfa6c1190c3aed41f9f
|
||||
dependencies: []
|
||||
source:
|
||||
registry_url: https://components.espressif.com/
|
||||
type: service
|
||||
version: 9.4.0
|
||||
direct_dependencies:
|
||||
- espressif/ethernet_init
|
||||
- espressif/led_strip
|
||||
- espressif/mdns
|
||||
- idf
|
||||
- joltwallet/littlefs
|
||||
manifest_hash: 21816aafdbbde14bfaaaabda34966eec49ae1e6f551bc16fe3ff74370b0fb54c
|
||||
- lvgl/lvgl
|
||||
manifest_hash: 0c7ea64d32655d6be4f726b7946e96626bce0de88c2dc8f091bb5e365d26a374
|
||||
target: esp32s3
|
||||
version: 2.0.0
|
||||
|
||||
20
Provider/fetch_png.py
Normal file
20
Provider/fetch_png.py
Normal 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)
|
||||
@@ -1,2 +1,2 @@
|
||||
VITE_API_BASE=http://192.168.50.216
|
||||
VITE_API_BASE=http://calendink.local/
|
||||
MKLITTLEFS_PATH=W:\Classified\Calendink\Provider\build\littlefs_py_venv\Scripts\littlefs-python.exe
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"build:esp32": "vite build && node scripts/gzip.js",
|
||||
"ota:package": "node scripts/package.js",
|
||||
"ota:bundle": "npm run ota:package && node scripts/bundle.js",
|
||||
"ota:deploy": "node scripts/deploy.js --frontend",
|
||||
"ota:deploy-bundle": "node scripts/deploy.js --bundle",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
128
Provider/frontend/scripts/deploy.js
Normal file
128
Provider/frontend/scripts/deploy.js
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* OTA Deployment Script
|
||||
* Uploads www.bin or universal.bundle to the ESP32 device.
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync, existsSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import http from 'http';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = resolve(__dirname, '..');
|
||||
const binDir = resolve(projectRoot, 'bin');
|
||||
const bundleDir = resolve(projectRoot, 'bundles');
|
||||
|
||||
/**
|
||||
* Simple .env parser to load VITE_API_BASE
|
||||
*/
|
||||
function loadEnv() {
|
||||
const envPath = resolve(projectRoot, '.env');
|
||||
if (existsSync(envPath)) {
|
||||
const content = readFileSync(envPath, 'utf8');
|
||||
content.split('\n').forEach(line => {
|
||||
const [key, ...valueParts] = line.split('=');
|
||||
if (key && valueParts.length > 0) {
|
||||
const value = valueParts.join('=').trim().replace(/^["']|["']$/g, '');
|
||||
process.env[key.trim()] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
loadEnv();
|
||||
|
||||
const targetIP = process.env.VITE_API_BASE ? process.env.VITE_API_BASE.replace('http://', '').replace(/\/+$/, '') : null;
|
||||
|
||||
if (!targetIP) {
|
||||
console.error('Error: VITE_API_BASE not found in frontend/.env. Please set it to the target device IP.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const isBundle = args.includes('--bundle');
|
||||
const isFrontend = args.includes('--frontend');
|
||||
|
||||
if (!isBundle && !isFrontend) {
|
||||
console.error('Error: Specify --frontend or --bundle.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function getLatestFile(dir, pattern) {
|
||||
if (!existsSync(dir)) return null;
|
||||
const files = readdirSync(dir)
|
||||
.filter(f => f.match(pattern))
|
||||
.sort((a, b) => {
|
||||
const getParts = (s) => {
|
||||
const m = s.match(/v(\d+)\.(\d+)\.(\d+)/);
|
||||
return m ? m.slice(1).map(Number) : [0, 0, 0];
|
||||
};
|
||||
const [aMajor, aMinor, aRev] = getParts(a);
|
||||
const [bMajor, bMinor, bRev] = getParts(b);
|
||||
return (bMajor - aMajor) || (bMinor - aMinor) || (bRev - aRev);
|
||||
});
|
||||
return files.length > 0 ? resolve(dir, files[0]) : null;
|
||||
}
|
||||
|
||||
const filePath = isBundle
|
||||
? getLatestFile(bundleDir, /^universal_v.*\.bundle$/)
|
||||
: getLatestFile(binDir, /^www_v.*\.bin$/);
|
||||
|
||||
if (!filePath) {
|
||||
console.error(`Error: No recent ${isBundle ? 'bundle' : 'frontend bin'} found in ${isBundle ? bundleDir : binDir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const fileName = filePath.split(/[\\/]/).pop();
|
||||
const versionMatch = fileName.match(/v(\d+\.\d+\.\d+)/);
|
||||
const version = versionMatch ? versionMatch[1] : 'unknown';
|
||||
|
||||
console.log('-------------------------------------------');
|
||||
console.log(`🚀 Deployment Started`);
|
||||
console.log(`📍 Target device: http://${targetIP}`);
|
||||
console.log(`📦 Packaging type: ${isBundle ? 'Universal Bundle' : 'Frontend Only'}`);
|
||||
console.log(`📄 File: ${fileName}`);
|
||||
console.log(`🔖 Version: ${version}`);
|
||||
console.log('-------------------------------------------');
|
||||
|
||||
const fileBuf = readFileSync(filePath);
|
||||
const urlPath = isBundle ? '/api/ota/bundle' : '/api/ota/frontend';
|
||||
const url = `http://${targetIP}${urlPath}`;
|
||||
|
||||
console.log(`Uploading ${fileBuf.length} bytes using fetch API... (This mimics the browser UI upload)`);
|
||||
|
||||
async function deploy() {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: fileBuf,
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Length': fileBuf.length.toString()
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const text = await response.text();
|
||||
console.log('\n✅ Success: Update committed!');
|
||||
console.log('Server response:', text);
|
||||
console.log('The device will reboot in ~1 second.');
|
||||
|
||||
// Wait a few seconds to let the socket close gracefully before killing the process
|
||||
setTimeout(() => {
|
||||
console.log('Deployment script finished.');
|
||||
process.exit(0);
|
||||
}, 3000);
|
||||
} else {
|
||||
const text = await response.text();
|
||||
console.error(`\n❌ Error (${response.status}):`, text);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`\n❌ Problem with request:`, e.message);
|
||||
if (e.cause) console.error(e.cause);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
deploy();
|
||||
@@ -1,8 +1,12 @@
|
||||
<script>
|
||||
import { getSystemInfo, reboot, getOTAStatus, getUpcomingTasks } from "./lib/api.js";
|
||||
import { formatUptime, formatBytes, formatRelativeDate, isOverdue } from "./lib/utils.js";
|
||||
import OTAUpdate from "./lib/OTAUpdate.svelte";
|
||||
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'} */
|
||||
let status = $state("loading");
|
||||
@@ -10,8 +14,9 @@
|
||||
let showRebootConfirm = $state(false);
|
||||
let isRecovering = $state(false);
|
||||
|
||||
/** @type {'dashboard' | 'tasks'} */
|
||||
/** @type {'dashboard' | 'tasks' | 'users' | 'devices'} */
|
||||
let currentView = $state("dashboard");
|
||||
let mobileMenuOpen = $state(false);
|
||||
|
||||
let systemInfo = $state({
|
||||
chip: "—",
|
||||
@@ -29,55 +34,29 @@
|
||||
|
||||
let upcomingData = $state({ users: [] });
|
||||
|
||||
/** Format uptime seconds into human-readable string */
|
||||
function formatUptime(seconds) {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
const parts = [];
|
||||
if (d > 0) parts.push(`${d}d`);
|
||||
if (h > 0) parts.push(`${h}h`);
|
||||
if (m > 0) parts.push(`${m}m`);
|
||||
parts.push(`${s}s`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/** Format bytes into human-readable string */
|
||||
function formatBytes(bytes) {
|
||||
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
function formatRelativeDate(timestamp) {
|
||||
const now = Date.now() / 1000;
|
||||
const diff = timestamp - now;
|
||||
const absDiff = Math.abs(diff);
|
||||
|
||||
if (absDiff < 3600) {
|
||||
const mins = Math.round(absDiff / 60);
|
||||
return diff < 0 ? `${mins}m ago` : `in ${mins}m`;
|
||||
}
|
||||
if (absDiff < 86400) {
|
||||
const hours = Math.round(absDiff / 3600);
|
||||
return diff < 0 ? `${hours}h ago` : `in ${hours}h`;
|
||||
}
|
||||
const days = Math.round(absDiff / 86400);
|
||||
return diff < 0 ? `${days}d ago` : `in ${days}d`;
|
||||
}
|
||||
|
||||
function isOverdue(timestamp) {
|
||||
return timestamp < Date.now() / 1000;
|
||||
}
|
||||
|
||||
async function fetchAll() {
|
||||
let isFetching = false; // mutex, not reactive
|
||||
let lastKnownFirmware = null;
|
||||
let lastKnownSlot = null;
|
||||
async function fetchAll(silent = false) {
|
||||
if (isFetching) return;
|
||||
isFetching = true;
|
||||
try {
|
||||
if (!silent) status = "loading";
|
||||
const [sys, ota, upcoming] = await Promise.all([
|
||||
getSystemInfo(),
|
||||
getOTAStatus(),
|
||||
getUpcomingTasks().catch(() => ({ users: [] }))
|
||||
]);
|
||||
// Detect any OTA update: firmware version change OR www partition flip
|
||||
const fwChanged = lastKnownFirmware && sys.firmware !== lastKnownFirmware;
|
||||
const slotChanged = lastKnownSlot !== null && ota.active_slot !== lastKnownSlot;
|
||||
if (fwChanged || slotChanged) {
|
||||
console.log(`OTA detected (fw: ${fwChanged}, slot: ${slotChanged}). Reloading...`);
|
||||
window.location.href = window.location.pathname + '?t=' + Date.now();
|
||||
return;
|
||||
}
|
||||
lastKnownFirmware = sys.firmware;
|
||||
lastKnownSlot = ota.active_slot;
|
||||
systemInfo = sys;
|
||||
otaStatus = ota;
|
||||
upcomingData = upcoming;
|
||||
@@ -88,6 +67,8 @@
|
||||
status = "error";
|
||||
errorMsg = e.message || "Connection failed";
|
||||
}
|
||||
} finally {
|
||||
isFetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,8 +85,8 @@
|
||||
|
||||
$effect(() => {
|
||||
fetchAll();
|
||||
// Poll for status updates every 5 seconds
|
||||
const interval = setInterval(fetchAll, 5000);
|
||||
// Poll for status updates every 5 seconds (silently to avoid flashing)
|
||||
const interval = setInterval(() => fetchAll(true), 5000);
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
|
||||
@@ -137,14 +118,41 @@
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div class="app-layout">
|
||||
<Sidebar {currentView} onNavigate={(view) => currentView = view} />
|
||||
<div class="flex min-h-screen bg-bg-primary">
|
||||
<Sidebar
|
||||
{currentView}
|
||||
isOpen={mobileMenuOpen}
|
||||
onNavigate={(view) => { currentView = view; mobileMenuOpen = false; }}
|
||||
onToggle={() => mobileMenuOpen = !mobileMenuOpen}
|
||||
/>
|
||||
|
||||
{#if mobileMenuOpen}
|
||||
<button
|
||||
class="fixed inset-0 bg-black/40 backdrop-blur z-[999] border-none p-0 cursor-pointer"
|
||||
onclick={() => mobileMenuOpen = false}
|
||||
aria-label="Close menu"
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<main class="main-gradient-bg flex-1 p-8 h-screen overflow-y-auto max-md:p-4 max-md:h-auto">
|
||||
<!-- Mobile Top Bar -->
|
||||
<header class="hidden max-md:flex items-center justify-between px-4 py-3 bg-bg-card border-b border-border sticky top-0 z-50 backdrop-blur-[10px] max-md:-mx-4 max-md:-mt-4 max-md:mb-5">
|
||||
<!-- svelte-ignore a11y_consider_explicit_label -->
|
||||
<button class="bg-transparent border-none text-text-primary p-2 cursor-pointer" onclick={() => mobileMenuOpen = true}>
|
||||
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="font-bold text-base tracking-[-0.02em] text-accent">Calendink</span>
|
||||
<div class="w-10"></div> <!-- Spacer -->
|
||||
</header>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="w-full max-w-6xl mx-auto space-y-8">
|
||||
<!-- Header -->
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-bold text-accent">Calendink Provider 🚀👑🥸</h1>
|
||||
<h1 class="text-2xl font-bold text-accent">Calendink Provider 📅⚡✨🌈</h1>
|
||||
<p class="text-text-secondary text-sm">ESP32-S3 System Dashboard v{__APP_VERSION__}</p>
|
||||
|
||||
<!-- Status Badge -->
|
||||
@@ -178,29 +186,46 @@
|
||||
|
||||
<!-- Upcoming Tasks Section (top priority) -->
|
||||
{#if upcomingData.users.length > 0}
|
||||
{@const periodNames = ['Morning', 'Afternoon', 'Evening']}
|
||||
{@const periodIcons = ['🌅', '☀️', '🌙']}
|
||||
<div class="bg-bg-card border border-border rounded-xl overflow-hidden shadow-xl">
|
||||
<div class="px-5 py-3 border-b border-border">
|
||||
<h2 class="text-sm font-semibold text-text-primary uppercase tracking-wider">
|
||||
📋 Upcoming Tasks
|
||||
📋 Today's Routine
|
||||
</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-0 divide-y md:divide-y-0 md:divide-x divide-border">
|
||||
{#each upcomingData.users as user}
|
||||
{@const routineTasks = user.tasks.filter(t => t.recurrence > 0)}
|
||||
<div class="p-4">
|
||||
<h3 class="text-xs font-bold text-accent mb-3 uppercase tracking-wider">{user.name}</h3>
|
||||
{#if user.tasks.length === 0}
|
||||
<p class="text-[11px] text-text-secondary italic">No pending tasks</p>
|
||||
{#if routineTasks.length === 0}
|
||||
<p class="text-[11px] text-text-secondary italic">No routine tasks</p>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each user.tasks as task}
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-[10px] mt-0.5 {isOverdue(task.due_date) ? 'text-danger' : 'text-text-secondary'} font-mono whitespace-nowrap">
|
||||
{formatRelativeDate(task.due_date)}
|
||||
</span>
|
||||
<span class="text-xs text-text-primary leading-tight">{task.title}</span>
|
||||
{#each [0, 1, 2] as periodIdx}
|
||||
{@const tasksForPeriod = routineTasks.filter(t => t.period & (1 << periodIdx))}
|
||||
{#if tasksForPeriod.length > 0}
|
||||
<div class="mb-2">
|
||||
<div class="text-[10px] uppercase tracking-wider text-text-secondary font-semibold mb-1">
|
||||
{periodIcons[periodIdx]} {periodNames[periodIdx]}
|
||||
</div>
|
||||
{#each tasksForPeriod as task}
|
||||
<div class="flex items-center gap-2 py-0.5 pl-3">
|
||||
<span class="text-xs text-text-primary leading-tight">• {task.title}</span>
|
||||
{#if task.recurrence > 0}
|
||||
<span class="text-[9px] text-accent font-mono">
|
||||
{task.recurrence === 0x7F ? '∞' : task.recurrence === 0x1F ? 'wk' : ''}
|
||||
</span>
|
||||
{:else if task.due_date > 0}
|
||||
<span class="text-[9px] {isOverdue(task.due_date) ? 'text-danger' : 'text-text-secondary'} font-mono">
|
||||
{formatRelativeDate(task.due_date)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
@@ -313,7 +338,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Updates & Maintenance Card -->
|
||||
<OTAUpdate onReboot={() => { status = "rebooting"; isRecovering = true; }} />
|
||||
<OTAUpdate otaInfo={otaStatus} {systemInfo} onReboot={() => { status = "rebooting"; isRecovering = true; }} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -323,6 +348,16 @@
|
||||
<div class="bg-bg-card border border-border rounded-xl p-6 shadow-xl">
|
||||
<TaskManager />
|
||||
</div>
|
||||
{:else if currentView === 'users'}
|
||||
<!-- User Management View -->
|
||||
<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 -->
|
||||
@@ -355,22 +390,4 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: var(--color-bg-primary);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 16px 32px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<Spinner />
|
||||
|
||||
@@ -17,3 +17,28 @@
|
||||
--color-danger: #ef4444;
|
||||
--color-danger-hover: #f87171;
|
||||
}
|
||||
|
||||
/*
|
||||
* Global utility: main content radial gradient background.
|
||||
* Can't be expressed as a Tailwind arbitrary value due to multiple background layers.
|
||||
*/
|
||||
.main-gradient-bg {
|
||||
background: radial-gradient(circle at top right, var(--color-bg-card), transparent 40%),
|
||||
var(--color-bg-primary);
|
||||
}
|
||||
|
||||
/*
|
||||
* Global utility: makes the native date picker icon cover the full input area.
|
||||
* This is a vendor pseudo-element — cannot be done with Tailwind utilities.
|
||||
*/
|
||||
.date-input::-webkit-calendar-picker-indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: auto;
|
||||
height: auto;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
196
Provider/frontend/src/lib/DeviceManager.svelte
Normal file
196
Provider/frontend/src/lib/DeviceManager.svelte
Normal file
@@ -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,47 +1,26 @@
|
||||
<script>
|
||||
let { onReboot = null } = $props();
|
||||
import { getOTAStatus, uploadOTAFrontend, uploadOTAFirmware, uploadOTABundle, getSystemInfo } from "./api.js";
|
||||
import { uploadOTAFrontend, uploadOTAFirmware, uploadOTABundle } from "./api.js";
|
||||
|
||||
// otaInfo and systemInfo are passed from App.svelte (already fetched there every 5s)
|
||||
let {
|
||||
onReboot = null,
|
||||
otaInfo = { active_slot: -1, active_partition: "—", target_partition: "—", partitions: [], running_firmware_label: "—" },
|
||||
systemInfo = { firmware: "—" }
|
||||
} = $props();
|
||||
|
||||
const IS_DEV = import.meta.env.DEV;
|
||||
|
||||
/** @type {'idle' | 'loading_status' | 'uploading' | 'success' | 'error'} */
|
||||
/** @type {'idle' | 'uploading' | 'success' | 'error'} */
|
||||
let status = $state("idle");
|
||||
let errorMsg = $state("");
|
||||
let uploadProgress = $state(0);
|
||||
|
||||
let otaInfo = $state({
|
||||
active_slot: -1,
|
||||
active_partition: "—",
|
||||
target_partition: "—",
|
||||
partitions: [],
|
||||
running_firmware_label: "—"
|
||||
});
|
||||
|
||||
let systemInfo = $state({
|
||||
firmware: "—"
|
||||
});
|
||||
|
||||
let selectedFile = $state(null);
|
||||
let showAdvanced = $state(false);
|
||||
/** @type {'frontend' | 'firmware' | 'bundle'} */
|
||||
let updateMode = $state("frontend");
|
||||
let isDragging = $state(false);
|
||||
|
||||
async function fetchStatus() {
|
||||
status = "loading_status";
|
||||
try {
|
||||
[otaInfo, systemInfo] = await Promise.all([getOTAStatus(), getSystemInfo()]);
|
||||
status = "idle";
|
||||
} catch (e) {
|
||||
status = "error";
|
||||
errorMsg = "Failed to fetch OTA status: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
fetchStatus();
|
||||
});
|
||||
|
||||
function handleFileChange(event) {
|
||||
const files = event.target.files;
|
||||
if (files && files.length > 0) processFile(files[0]);
|
||||
@@ -114,7 +93,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
const currentTarget = $derived(() => {
|
||||
const currentTarget = $derived.by(() => {
|
||||
if (updateMode === 'bundle') return 'FW + UI';
|
||||
if (updateMode === 'frontend') return otaInfo.target_partition;
|
||||
// For firmware, target is the slot that is NOT the running one
|
||||
@@ -182,7 +161,7 @@
|
||||
OTA Upgrade ({updateMode === 'frontend' ? 'UI' : 'Firmware'})
|
||||
</h3>
|
||||
<div class="text-[10px] text-text-secondary">
|
||||
Target: <span class="font-mono text-accent">{currentTarget()}</span>
|
||||
Target: <span class="font-mono text-accent">{currentTarget}</span>
|
||||
{#if updateMode === 'frontend' && otaInfo.partitions}
|
||||
<span class="ml-1">
|
||||
({(otaInfo.partitions.find(p => p.label === otaInfo.target_partition)?.size / 1024 / 1024).toFixed(1)} MB)
|
||||
|
||||
@@ -1,135 +1,68 @@
|
||||
<script>
|
||||
let { currentView = 'dashboard', onNavigate = () => {} } = $props();
|
||||
let collapsed = $state(typeof window !== 'undefined' && window.innerWidth <= 768);
|
||||
let { currentView = 'dashboard', onNavigate = () => {}, isOpen = false, onToggle = null } = $props();
|
||||
let collapsed = $state(false);
|
||||
|
||||
// Auto-collapse on desktop if screen is small but not mobile
|
||||
$effect(() => {
|
||||
if (typeof window !== 'undefined' && window.innerWidth <= 1024 && window.innerWidth > 768) {
|
||||
collapsed = true;
|
||||
}
|
||||
});
|
||||
|
||||
const navItems = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: '🏠' },
|
||||
{ id: 'devices', label: 'Devices', icon: '📺' },
|
||||
{ id: 'tasks', label: 'Tasks', icon: '📋' },
|
||||
{ id: 'users', label: 'Users', icon: '👥' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<aside class="sidebar {collapsed ? 'collapsed' : ''}">
|
||||
<div class="sidebar-header">
|
||||
<aside class="
|
||||
flex flex-col bg-bg-card border-r border-border h-screen flex-shrink-0 sticky top-0
|
||||
transition-all duration-300
|
||||
{collapsed ? 'w-16' : 'w-[200px]'}
|
||||
max-md:fixed max-md:left-0 max-md:top-0 max-md:z-[1000] max-md:!w-[280px]
|
||||
max-md:shadow-[20px_0_50px_rgba(0,0,0,0.3)]
|
||||
{isOpen ? 'max-md:flex' : 'max-md:hidden'}
|
||||
">
|
||||
<div class="flex items-center border-b border-border min-h-14 px-3 py-4
|
||||
{collapsed ? 'justify-center' : 'justify-between'}
|
||||
max-md:px-4 max-md:py-5">
|
||||
{#if !collapsed}
|
||||
<span class="sidebar-title">Menu</span>
|
||||
<span class="text-xs font-bold uppercase tracking-[0.05em] text-text-secondary whitespace-nowrap overflow-hidden">
|
||||
Menu
|
||||
</span>
|
||||
{/if}
|
||||
<button
|
||||
class="collapse-btn"
|
||||
onclick={() => collapsed = !collapsed}
|
||||
class="bg-transparent border-none text-text-secondary cursor-pointer p-1.5 flex items-center justify-center rounded-md transition-all flex-shrink-0 hover:text-text-primary hover:bg-bg-card-hover
|
||||
{onToggle ? 'max-md:flex' : ''}"
|
||||
onclick={() => onToggle ? onToggle() : (collapsed = !collapsed)}
|
||||
title={collapsed ? 'Expand' : 'Collapse'}
|
||||
>
|
||||
{collapsed ? '▶' : '◀'}
|
||||
<svg class="w-[18px] h-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<nav class="flex flex-col gap-1 p-2">
|
||||
{#each navItems as item}
|
||||
<button
|
||||
class="nav-item {currentView === item.id ? 'active' : ''}"
|
||||
class="flex items-center gap-2.5 px-3 py-2.5 border-none rounded-lg cursor-pointer text-[13px] font-medium transition-all whitespace-nowrap text-left
|
||||
max-md:px-4 max-md:py-3.5 max-md:text-[15px]
|
||||
{currentView === item.id
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'bg-transparent text-text-secondary hover:bg-bg-card-hover hover:text-text-primary'}"
|
||||
onclick={() => onNavigate(item.id)}
|
||||
title={item.label}
|
||||
>
|
||||
<span class="nav-icon">{item.icon}</span>
|
||||
<span class="text-base flex-shrink-0">{item.icon}</span>
|
||||
{#if !collapsed}
|
||||
<span class="nav-label">{item.label}</span>
|
||||
<span class="overflow-hidden">{item.label}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
width: 200px;
|
||||
min-height: 100vh;
|
||||
background: var(--color-bg-card);
|
||||
border-right: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: width 0.25s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 56px;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
font-size: 10px;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.collapse-btn:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-card-hover);
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--color-bg-card-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
31
Provider/frontend/src/lib/Spinner.svelte
Normal file
31
Provider/frontend/src/lib/Spinner.svelte
Normal file
@@ -0,0 +1,31 @@
|
||||
<script>
|
||||
import { pendingRequests } from './stores.js';
|
||||
|
||||
let showSpinner = $state(false);
|
||||
let timer = null; // script-only handle, not reactive
|
||||
|
||||
// Track pending request count and show spinner after 1s delay
|
||||
$effect(() => {
|
||||
const count = $pendingRequests;
|
||||
if (count > 0) {
|
||||
if (!timer) {
|
||||
timer = setTimeout(() => { showSpinner = true; }, 1000);
|
||||
}
|
||||
} else {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
showSpinner = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if showSpinner}
|
||||
<div class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div class="flex flex-col items-center p-8 bg-bg-card rounded-2xl shadow-xl border border-border">
|
||||
<div class="w-12 h-12 border-4 border-accent border-t-transparent rounded-full animate-spin"></div>
|
||||
<p class="mt-4 text-text-primary font-medium tracking-wide animate-pulse">Communicating with Device...</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,22 +1,67 @@
|
||||
<script>
|
||||
import { getTasks, addTask, updateTask, deleteTask } from './api.js';
|
||||
import { formatRelativeDate, isOverdue } from './utils.js';
|
||||
import UserManager from './UserManager.svelte';
|
||||
|
||||
let selectedUserId = $state(null);
|
||||
let tasks = $state([]);
|
||||
let error = $state('');
|
||||
|
||||
// Period and day-of-week labels
|
||||
const PERIODS = ['Morning', 'Afternoon', 'Evening'];
|
||||
const PERIOD_ICONS = ['🌅', '☀️', '🌙'];
|
||||
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
// Shared bitmask helpers (used for both period and recurrence)
|
||||
function toggleBit(current, idx) {
|
||||
return current ^ (1 << idx);
|
||||
}
|
||||
|
||||
function hasBit(mask, idx) {
|
||||
return (mask & (1 << idx)) !== 0;
|
||||
}
|
||||
|
||||
function formatPeriod(mask) {
|
||||
if (mask === 0x07) return 'All Day';
|
||||
const names = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (mask & (1 << i)) names.push(PERIODS[i]);
|
||||
}
|
||||
return names.length > 0 ? names.join(', ') : 'None';
|
||||
}
|
||||
|
||||
function periodIcons(mask) {
|
||||
const icons = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (mask & (1 << i)) icons.push(PERIOD_ICONS[i]);
|
||||
}
|
||||
return icons.join('');
|
||||
}
|
||||
|
||||
function formatRecurrence(mask) {
|
||||
if (mask === 0x7F) return 'Every day';
|
||||
if (mask === 0x1F) return 'Weekdays';
|
||||
if (mask === 0x60) return 'Weekends';
|
||||
const names = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
if (mask & (1 << i)) names.push(DAYS[i]);
|
||||
}
|
||||
return names.join(', ');
|
||||
}
|
||||
|
||||
// Add task form state
|
||||
let newTitle = $state('');
|
||||
let newPeriod = $state(0x01);
|
||||
let newRecurrence = $state(0);
|
||||
let newDueDay = $state('');
|
||||
let newDueTime = $state('');
|
||||
let showAddForm = $state(false);
|
||||
|
||||
// Edit state
|
||||
let editingTaskId = $state(null);
|
||||
let editTitle = $state('');
|
||||
let editPeriod = $state(0);
|
||||
let editRecurrence = $state(0);
|
||||
let editDueDay = $state('');
|
||||
let editDueTime = $state('');
|
||||
|
||||
// Confirm delete
|
||||
let confirmDeleteId = $state(null);
|
||||
@@ -28,8 +73,13 @@
|
||||
}
|
||||
try {
|
||||
tasks = await getTasks(selectedUserId);
|
||||
// Sort by due_date ascending
|
||||
tasks.sort((a, b) => a.due_date - b.due_date);
|
||||
// Sort: recurrent first (by day), then one-off by due_date
|
||||
tasks.sort((a, b) => {
|
||||
if (a.recurrence && !b.recurrence) return -1;
|
||||
if (!a.recurrence && b.recurrence) return 1;
|
||||
if (a.recurrence && b.recurrence) return a.recurrence - b.recurrence;
|
||||
return a.due_date - b.due_date;
|
||||
});
|
||||
error = '';
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
@@ -47,14 +97,19 @@
|
||||
|
||||
async function handleAddTask(e) {
|
||||
e.preventDefault();
|
||||
if (!newTitle.trim() || !newDueDay || !newDueTime) return;
|
||||
if (!newTitle.trim()) return;
|
||||
if (newRecurrence === 0 && !newDueDay) return;
|
||||
|
||||
const dueTimestamp = newRecurrence > 0
|
||||
? 0
|
||||
: Math.floor(new Date(`${newDueDay}T00:00`).getTime() / 1000);
|
||||
|
||||
const dueTimestamp = Math.floor(new Date(`${newDueDay}T${newDueTime}`).getTime() / 1000);
|
||||
try {
|
||||
await addTask(selectedUserId, newTitle.trim(), dueTimestamp);
|
||||
await addTask(selectedUserId, newTitle.trim(), dueTimestamp, newPeriod, newRecurrence);
|
||||
newTitle = '';
|
||||
newPeriod = 0x01;
|
||||
newRecurrence = 0;
|
||||
newDueDay = '';
|
||||
newDueTime = '';
|
||||
showAddForm = false;
|
||||
await fetchTasks();
|
||||
} catch (e) {
|
||||
@@ -74,20 +129,34 @@
|
||||
function startEditing(task) {
|
||||
editingTaskId = task.id;
|
||||
editTitle = task.title;
|
||||
const parts = formatDateForInput(task.due_date);
|
||||
editDueDay = parts.day;
|
||||
editDueTime = parts.time;
|
||||
editPeriod = task.period;
|
||||
editRecurrence = task.recurrence;
|
||||
if (task.recurrence === 0 && task.due_date) {
|
||||
const d = new Date(task.due_date * 1000);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dayNum = String(d.getDate()).padStart(2, '0');
|
||||
editDueDay = `${year}-${month}-${dayNum}`;
|
||||
} else {
|
||||
editDueDay = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEdit(e) {
|
||||
e.preventDefault();
|
||||
if (!editTitle.trim()) return;
|
||||
if (editRecurrence === 0 && !editDueDay) return;
|
||||
|
||||
const dueTimestamp = editRecurrence > 0
|
||||
? 0
|
||||
: Math.floor(new Date(`${editDueDay}T00:00`).getTime() / 1000);
|
||||
|
||||
const dueTimestamp = Math.floor(new Date(`${editDueDay}T${editDueTime}`).getTime() / 1000);
|
||||
try {
|
||||
await updateTask(editingTaskId, {
|
||||
title: editTitle.trim(),
|
||||
due_date: dueTimestamp
|
||||
due_date: dueTimestamp,
|
||||
period: editPeriod,
|
||||
recurrence: editRecurrence
|
||||
});
|
||||
editingTaskId = null;
|
||||
await fetchTasks();
|
||||
@@ -106,436 +175,212 @@
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateForInput(timestamp) {
|
||||
const d = new Date(timestamp * 1000);
|
||||
function setTodayForNewTask() {
|
||||
const d = new Date();
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dayNum = String(d.getDate()).padStart(2, '0');
|
||||
const hours = String(d.getHours()).padStart(2, '0');
|
||||
const mins = String(d.getMinutes()).padStart(2, '0');
|
||||
return { day: `${year}-${month}-${dayNum}`, time: `${hours}:${mins}` };
|
||||
newDueDay = `${year}-${month}-${dayNum}`;
|
||||
}
|
||||
|
||||
function setNowForNewTask() {
|
||||
const parts = formatDateForInput(Date.now() / 1000);
|
||||
newDueDay = parts.day;
|
||||
newDueTime = parts.time;
|
||||
}
|
||||
|
||||
function formatRelativeDate(timestamp) {
|
||||
const now = Date.now() / 1000;
|
||||
const diff = timestamp - now;
|
||||
const absDiff = Math.abs(diff);
|
||||
|
||||
if (absDiff < 3600) {
|
||||
const mins = Math.round(absDiff / 60);
|
||||
return diff < 0 ? `${mins}m ago` : `in ${mins}m`;
|
||||
}
|
||||
if (absDiff < 86400) {
|
||||
const hours = Math.round(absDiff / 3600);
|
||||
return diff < 0 ? `${hours}h ago` : `in ${hours}h`;
|
||||
}
|
||||
const days = Math.round(absDiff / 86400);
|
||||
return diff < 0 ? `${days}d ago` : `in ${days}d`;
|
||||
}
|
||||
|
||||
function isOverdue(timestamp) {
|
||||
return timestamp < Date.now() / 1000;
|
||||
function taskIsOverdue(task) {
|
||||
if (task.recurrence > 0) return false;
|
||||
return isOverdue(task.due_date);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="task-manager">
|
||||
<div class="w-full">
|
||||
<UserManager bind:selectedUserId onUsersChanged={fetchTasks} />
|
||||
|
||||
{#if selectedUserId}
|
||||
<div class="task-header">
|
||||
<h2 class="task-title">Tasks</h2>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-[0.05em] text-text-primary">Tasks</h2>
|
||||
{#if !showAddForm}
|
||||
<button class="add-task-btn" onclick={() => { showAddForm = true; setNowForNewTask(); }}>+ Add Task</button>
|
||||
<button
|
||||
class="px-3.5 py-1.5 rounded-lg border border-accent bg-accent/10 text-accent cursor-pointer text-xs font-semibold transition-all hover:bg-accent/20"
|
||||
onclick={() => { showAddForm = true; setTodayForNewTask(); }}
|
||||
>+ Add Task</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showAddForm}
|
||||
<form class="add-task-form" onsubmit={handleAddTask}>
|
||||
<form class="flex flex-col gap-2 p-3 rounded-[10px] border border-border bg-bg-card mb-3" onsubmit={handleAddTask}>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newTitle}
|
||||
placeholder="Task title..."
|
||||
class="task-input"
|
||||
class="px-3 py-2 rounded-lg border border-border bg-bg-primary text-text-primary text-[13px] outline-none w-full box-border focus:border-accent"
|
||||
autofocus
|
||||
/>
|
||||
<div class="date-time-row">
|
||||
<label class="date-time-field">
|
||||
<span class="field-label">Date</span>
|
||||
<input type="date" bind:value={newDueDay} class="task-date-input" />
|
||||
</label>
|
||||
<label class="date-time-field">
|
||||
<span class="field-label">Time</span>
|
||||
<input type="time" bind:value={newDueTime} class="task-time-input" />
|
||||
</label>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-col gap-1 flex-1">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.05em] text-text-secondary">Period</span>
|
||||
<div class="flex gap-1">
|
||||
{#each PERIODS as p, i}
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 px-2 py-1.5 rounded-md border cursor-pointer text-[11px] transition-all whitespace-nowrap
|
||||
{hasBit(newPeriod, i)
|
||||
? 'border-accent bg-accent/15 text-accent font-semibold'
|
||||
: 'border-border bg-bg-primary text-text-secondary hover:bg-bg-card-hover'}"
|
||||
onclick={() => newPeriod = toggleBit(newPeriod, i)}
|
||||
>{PERIOD_ICONS[i]} {p}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary" disabled={!newTitle.trim() || !newDueDay || !newDueTime}>Add</button>
|
||||
<button type="button" class="btn-secondary" onclick={() => { showAddForm = false; newTitle = ''; newDueDay = ''; newDueTime = ''; }}>Cancel</button>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-col gap-1 flex-1">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.05em] text-text-secondary">Recurrence</span>
|
||||
<div class="flex gap-[3px] flex-wrap">
|
||||
{#each DAYS as day, i}
|
||||
<button
|
||||
type="button"
|
||||
class="px-2 py-[5px] rounded-md border cursor-pointer text-[11px] transition-all min-w-8 text-center
|
||||
{hasBit(newRecurrence, i)
|
||||
? 'border-accent bg-accent/15 text-accent font-semibold'
|
||||
: 'border-border bg-bg-primary text-text-secondary hover:bg-bg-card-hover'}"
|
||||
onclick={() => newRecurrence = toggleBit(newRecurrence, i)}
|
||||
>{day}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if newRecurrence === 0}
|
||||
<div class="flex gap-2">
|
||||
<label class="flex flex-col gap-1 flex-1">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.05em] text-text-secondary">Date</span>
|
||||
<input type="date" bind:value={newDueDay}
|
||||
class="date-input px-3 py-2 rounded-lg border border-border bg-bg-primary text-text-primary text-[13px] outline-none [color-scheme:dark] flex-1 cursor-pointer relative focus:border-accent" />
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-1.5">
|
||||
<button type="submit"
|
||||
class="px-4 py-1.5 rounded-lg border-none bg-accent text-white cursor-pointer text-xs font-semibold transition-[filter] hover:brightness-110 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
disabled={!newTitle.trim() || (newRecurrence === 0 && !newDueDay)}>Add</button>
|
||||
<button type="button"
|
||||
class="px-4 py-1.5 rounded-lg border border-border bg-transparent text-text-secondary cursor-pointer text-xs transition-all hover:bg-bg-card-hover"
|
||||
onclick={() => { showAddForm = false; newTitle = ''; newDueDay = ''; newRecurrence = 0; newPeriod = 0x01; }}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<div class="task-list">
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each tasks as task}
|
||||
<div class="task-item {task.completed ? 'completed' : ''} {isOverdue(task.due_date) && !task.completed ? 'overdue' : ''}">
|
||||
<div class="flex items-center justify-between flex-wrap px-3.5 py-2.5 rounded-[10px] border bg-bg-card transition-all hover:bg-bg-card-hover
|
||||
{task.completed ? 'opacity-50' : ''}
|
||||
{taskIsOverdue(task) && !task.completed ? 'border-danger/40' : 'border-border'}">
|
||||
{#if editingTaskId === task.id}
|
||||
<form class="edit-form" onsubmit={saveEdit}>
|
||||
<input type="text" bind:value={editTitle} class="task-input" />
|
||||
<div class="date-time-row">
|
||||
<label class="date-time-field">
|
||||
<span class="field-label">Date</span>
|
||||
<input type="date" bind:value={editDueDay} class="task-date-input" />
|
||||
</label>
|
||||
<label class="date-time-field">
|
||||
<span class="field-label">Time</span>
|
||||
<input type="time" bind:value={editDueTime} class="task-time-input" />
|
||||
</label>
|
||||
<form class="w-full flex flex-col gap-2 p-0" onsubmit={saveEdit}>
|
||||
<input type="text" bind:value={editTitle}
|
||||
class="px-3 py-2 rounded-lg border border-border bg-bg-primary text-text-primary text-[13px] outline-none w-full box-border focus:border-accent" />
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-col gap-1 flex-1">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.05em] text-text-secondary">Period</span>
|
||||
<div class="flex gap-1">
|
||||
{#each PERIODS as p, i}
|
||||
<button type="button"
|
||||
class="flex-1 px-2 py-1.5 rounded-md border cursor-pointer text-[11px] transition-all whitespace-nowrap
|
||||
{hasBit(editPeriod, i)
|
||||
? 'border-accent bg-accent/15 text-accent font-semibold'
|
||||
: 'border-border bg-bg-primary text-text-secondary hover:bg-bg-card-hover'}"
|
||||
onclick={() => editPeriod = toggleBit(editPeriod, i)}
|
||||
>{PERIOD_ICONS[i]} {p}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary btn-sm">Save</button>
|
||||
<button type="button" class="btn-secondary btn-sm" onclick={() => editingTaskId = null}>Cancel</button>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-col gap-1 flex-1">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.05em] text-text-secondary">Recurrence</span>
|
||||
<div class="flex gap-[3px] flex-wrap">
|
||||
{#each DAYS as day, i}
|
||||
<button type="button"
|
||||
class="px-2 py-[5px] rounded-md border cursor-pointer text-[11px] transition-all min-w-8 text-center
|
||||
{hasBit(editRecurrence, i)
|
||||
? 'border-accent bg-accent/15 text-accent font-semibold'
|
||||
: 'border-border bg-bg-primary text-text-secondary hover:bg-bg-card-hover'}"
|
||||
onclick={() => editRecurrence = toggleBit(editRecurrence, i)}
|
||||
>{day}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if editRecurrence === 0}
|
||||
<div class="flex gap-2">
|
||||
<label class="flex flex-col gap-1 flex-1">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.05em] text-text-secondary">Date</span>
|
||||
<input type="date" bind:value={editDueDay}
|
||||
class="date-input px-3 py-2 rounded-lg border border-border bg-bg-primary text-text-primary text-[13px] outline-none [color-scheme:dark] flex-1 cursor-pointer relative focus:border-accent" />
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-1.5">
|
||||
<button type="submit"
|
||||
class="px-2.5 py-1 rounded-lg border-none bg-accent text-white cursor-pointer text-[11px] font-semibold transition-[filter] hover:brightness-110">Save</button>
|
||||
<button type="button"
|
||||
class="px-2.5 py-1 rounded-lg border border-border bg-transparent text-text-secondary cursor-pointer text-[11px] transition-all hover:bg-bg-card-hover"
|
||||
onclick={() => editingTaskId = null}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<div class="task-left">
|
||||
<div class="flex items-center gap-2.5 flex-1 min-w-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={task.completed}
|
||||
onchange={() => handleToggleComplete(task)}
|
||||
class="task-checkbox"
|
||||
class="w-4 h-4 cursor-pointer flex-shrink-0"
|
||||
style="accent-color: var(--color-accent)"
|
||||
/>
|
||||
<div class="task-info">
|
||||
<span class="task-text">{task.title}</span>
|
||||
<span class="task-due {isOverdue(task.due_date) && !task.completed ? 'text-overdue' : ''}">
|
||||
{formatRelativeDate(task.due_date)}
|
||||
</span>
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<span class="text-[13px] text-text-primary whitespace-nowrap overflow-hidden text-ellipsis
|
||||
{task.completed ? 'line-through text-text-secondary' : ''}">{task.title}</span>
|
||||
<div class="flex gap-2 items-center flex-wrap">
|
||||
<span class="text-[10px] text-text-secondary font-medium">{periodIcons(task.period)} {formatPeriod(task.period)}</span>
|
||||
{#if task.recurrence > 0}
|
||||
<span class="text-[10px] text-accent font-medium">🔁 {formatRecurrence(task.recurrence)}</span>
|
||||
{:else}
|
||||
<span class="text-[10px] font-medium {taskIsOverdue(task) && !task.completed ? 'text-danger' : 'text-text-secondary'}">
|
||||
{formatRelativeDate(task.due_date)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-actions">
|
||||
<button class="action-btn" onclick={() => startEditing(task)} title="Edit">✏️</button>
|
||||
<div class="flex gap-1 items-center flex-shrink-0">
|
||||
<button class="bg-transparent border-none cursor-pointer text-xs p-1 rounded opacity-60 transition-[background] hover:opacity-100 hover:bg-bg-card-hover"
|
||||
onclick={() => startEditing(task)} title="Edit">✏️</button>
|
||||
{#if confirmDeleteId === task.id}
|
||||
<button class="action-btn text-danger" onclick={() => handleDelete(task.id)} title="Confirm">✓</button>
|
||||
<button class="action-btn" onclick={() => confirmDeleteId = null} title="Cancel">✕</button>
|
||||
<button class="bg-transparent border-none cursor-pointer text-xs p-1 rounded text-danger opacity-60 hover:opacity-100"
|
||||
onclick={() => handleDelete(task.id)} title="Confirm">✓</button>
|
||||
<button class="bg-transparent border-none cursor-pointer text-xs p-1 rounded opacity-60 hover:opacity-100 hover:bg-bg-card-hover"
|
||||
onclick={() => confirmDeleteId = null} title="Cancel">✕</button>
|
||||
{:else}
|
||||
<button class="action-btn" onclick={() => confirmDeleteId = task.id} title="Delete">🗑️</button>
|
||||
<button class="bg-transparent border-none cursor-pointer text-xs p-1 rounded opacity-60 transition-[background] hover:opacity-100 hover:bg-bg-card-hover"
|
||||
onclick={() => confirmDeleteId = task.id} title="Delete">🗑️</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty-state">No tasks yet. Add one above!</div>
|
||||
<div class="text-center p-8 text-text-secondary text-[13px]">No tasks yet. Add one above!</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty-state">Select or add a user to see their tasks.</div>
|
||||
<div class="text-center p-8 text-text-secondary text-[13px]">Select or add a user to see their tasks.</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="task-error">{error}</div>
|
||||
<div class="mt-2 px-2.5 py-1.5 rounded-md bg-danger/10 border border-danger/20 text-danger text-[11px]">{error}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.task-manager {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.task-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.add-task-btn {
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-accent);
|
||||
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
||||
color: var(--color-accent);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.add-task-btn:hover {
|
||||
background: color-mix(in srgb, var(--color-accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.add-task-form, .edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-card);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.task-input {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.task-input:focus {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.date-time-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.date-time-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.date-time-field:first-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.task-date-input {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
color-scheme: dark;
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.task-time-input {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
color-scheme: dark;
|
||||
width: 110px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Make the native picker icon cover the entire input (Chrome/Edge only) */
|
||||
.task-date-input::-webkit-calendar-picker-indicator,
|
||||
.task-time-input::-webkit-calendar-picker-indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: auto;
|
||||
height: auto;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-date-input:focus, .task-time-input:focus {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 6px 16px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition: filter 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 6px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--color-bg-card-hover);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-card);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.task-item:hover {
|
||||
background: var(--color-bg-card-hover);
|
||||
}
|
||||
|
||||
.task-item.completed {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.task-item.overdue {
|
||||
border-color: color-mix(in srgb, var(--color-danger) 40%, transparent);
|
||||
}
|
||||
|
||||
.task-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--color-accent);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-text {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.completed .task-text {
|
||||
text-decoration: line-through;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.task-due {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.text-overdue {
|
||||
color: var(--color-danger) !important;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
opacity: 1;
|
||||
background: var(--color-bg-card-hover);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.task-error {
|
||||
margin-top: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-danger) 20%, transparent);
|
||||
color: var(--color-danger);
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script>
|
||||
import { getUsers, addUser, removeUser } from './api.js';
|
||||
import { getUsers, addUser, removeUser, updateUser } from './api.js';
|
||||
|
||||
let { selectedUserId = $bindable(null), onUsersChanged = () => {} } = $props();
|
||||
let {
|
||||
selectedUserId = $bindable(null),
|
||||
onUsersChanged = () => {},
|
||||
mode = 'selector' // 'selector' | 'manager'
|
||||
} = $props();
|
||||
|
||||
let users = $state([]);
|
||||
let newUserName = $state('');
|
||||
@@ -9,10 +13,14 @@
|
||||
let error = $state('');
|
||||
let confirmDeleteId = $state(null);
|
||||
|
||||
// Edit state
|
||||
let editingUserId = $state(null);
|
||||
let editName = $state('');
|
||||
|
||||
async function fetchUsers() {
|
||||
try {
|
||||
users = await getUsers();
|
||||
if (users.length > 0 && !selectedUserId) {
|
||||
if (users.length > 0 && !selectedUserId && mode === 'selector') {
|
||||
selectedUserId = users[0].id;
|
||||
}
|
||||
// If selected user was deleted, select first available
|
||||
@@ -32,7 +40,7 @@
|
||||
newUserName = '';
|
||||
showAddForm = false;
|
||||
await fetchUsers();
|
||||
selectedUserId = user.id;
|
||||
if (mode === 'selector') selectedUserId = user.id;
|
||||
onUsersChanged();
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
@@ -50,225 +58,151 @@
|
||||
}
|
||||
}
|
||||
|
||||
function startEditing(user) {
|
||||
editingUserId = user.id;
|
||||
editName = user.name;
|
||||
}
|
||||
|
||||
async function handleUpdateUser() {
|
||||
if (!editName.trim()) return;
|
||||
try {
|
||||
await updateUser(editingUserId, editName.trim());
|
||||
editingUserId = null;
|
||||
await fetchUsers();
|
||||
onUsersChanged();
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Load initial data on mount
|
||||
$effect(() => {
|
||||
fetchUsers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="user-manager">
|
||||
<div class="user-bar">
|
||||
<div class="user-chips">
|
||||
{#each users as user}
|
||||
<!-- svelte-ignore node_invalid_placement_ssr -->
|
||||
<div
|
||||
class="user-chip {selectedUserId === user.id ? 'selected' : ''}"
|
||||
onclick={() => selectedUserId = user.id}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => { if (e.key === 'Enter') selectedUserId = user.id; }}
|
||||
>
|
||||
<span class="user-name">{user.name}</span>
|
||||
{#if confirmDeleteId === user.id}
|
||||
<span class="confirm-delete">
|
||||
<button class="confirm-yes" onclick={(e) => { e.stopPropagation(); handleRemoveUser(user.id); }} title="Confirm delete">✓</button>
|
||||
<button class="confirm-no" onclick={(e) => { e.stopPropagation(); confirmDeleteId = null; }} title="Cancel">✕</button>
|
||||
</span>
|
||||
{:else}
|
||||
<button
|
||||
class="remove-btn"
|
||||
onclick={(e) => { e.stopPropagation(); confirmDeleteId = user.id; }}
|
||||
title="Remove user"
|
||||
>✕</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<div class="mb-4">
|
||||
{#if mode === 'selector'}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-wrap gap-1.5 items-center">
|
||||
{#each users as user}
|
||||
<!-- svelte-ignore node_invalid_placement_ssr -->
|
||||
<div
|
||||
class="flex items-center gap-1.5 px-4 py-1.5 rounded-full border cursor-pointer text-[13px] font-semibold
|
||||
transition-all duration-200 hover:-translate-y-px hover:shadow-[0_4px_12px_rgba(0,0,0,0.1)]
|
||||
{selectedUserId === user.id
|
||||
? 'bg-accent/20 border-accent text-accent ring-1 ring-accent'
|
||||
: 'border-border bg-bg-card text-text-secondary hover:border-accent hover:text-text-primary'}"
|
||||
onclick={() => selectedUserId = user.id}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => { if (e.key === 'Enter') selectedUserId = user.id; }}
|
||||
>
|
||||
<span>{user.name}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Manager Mode -->
|
||||
<header class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-xl font-bold text-text-primary">User Management</h1>
|
||||
<button
|
||||
class="px-5 py-2.5 rounded-xl bg-accent text-white border-none font-semibold cursor-pointer transition-all
|
||||
shadow-accent/30 shadow-lg hover:brightness-110 hover:-translate-y-px"
|
||||
onclick={() => showAddForm = true}
|
||||
>
|
||||
+ New User
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if showAddForm}
|
||||
<form class="add-user-form" onsubmit={(e) => { e.preventDefault(); handleAddUser(); }}>
|
||||
{#if showAddForm}
|
||||
<div class="fixed inset-0 bg-black/70 backdrop-blur flex items-center justify-center z-[100]">
|
||||
<form
|
||||
class="bg-bg-card p-6 rounded-[20px] border border-border w-full max-w-sm flex flex-col gap-5 shadow-[0_20px_40px_rgba(0,0,0,0.4)]"
|
||||
onsubmit={(e) => { e.preventDefault(); handleAddUser(); }}
|
||||
>
|
||||
<h3 class="text-lg font-semibold text-text-primary">Add User</h3>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newUserName}
|
||||
placeholder="Name..."
|
||||
class="add-user-input"
|
||||
class="px-4 py-3 rounded-xl border border-border bg-bg-primary text-text-primary text-sm outline-none focus:border-accent"
|
||||
autofocus
|
||||
/>
|
||||
<button type="submit" class="add-user-submit" disabled={!newUserName.trim()}>+</button>
|
||||
<button type="button" class="add-user-cancel" onclick={() => { showAddForm = false; newUserName = ''; }}>✕</button>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 rounded-lg bg-bg-primary text-text-secondary border border-border cursor-pointer transition-all hover:bg-bg-card-hover"
|
||||
onclick={() => { showAddForm = false; newUserName = ''; }}
|
||||
>Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 rounded-lg bg-accent text-white border-none font-semibold cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={!newUserName.trim()}
|
||||
>Add User</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-3">
|
||||
{#each users as user}
|
||||
<div class="flex justify-between items-center px-5 py-4 rounded-2xl bg-bg-card border border-border transition-all hover:border-accent hover:bg-bg-card-hover">
|
||||
{#if editingUserId === user.id}
|
||||
<form class="flex gap-3 w-full" onsubmit={(e) => { e.preventDefault(); handleUpdateUser(); }}>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editName}
|
||||
class="flex-1 px-3 py-2 rounded-lg border border-accent bg-bg-primary text-text-primary outline-none"
|
||||
autofocus
|
||||
/>
|
||||
<div class="flex gap-1.5">
|
||||
<button type="submit" class="px-3 py-1 rounded-md border-none bg-success text-white cursor-pointer transition-all" title="Save">✓</button>
|
||||
<button type="button" class="px-3 py-1 rounded-md border border-border bg-bg-primary text-text-secondary cursor-pointer transition-all" onclick={() => editingUserId = null} title="Cancel">✕</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-base font-semibold text-text-primary">{user.name}</span>
|
||||
<span class="text-[11px] text-text-secondary font-mono uppercase tracking-[0.05em]">ID: {user.id}</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="bg-bg-primary border border-border text-text-secondary p-2 rounded-lg cursor-pointer text-sm transition-all hover:border-accent hover:text-accent hover:bg-bg-card"
|
||||
onclick={() => startEditing(user)}
|
||||
title="Rename"
|
||||
>✏️</button>
|
||||
{#if confirmDeleteId === user.id}
|
||||
<button
|
||||
class="bg-danger/10 border border-danger/30 text-danger px-3 py-1 rounded-lg cursor-pointer text-sm transition-all"
|
||||
onclick={() => handleRemoveUser(user.id)}
|
||||
>Delete!</button>
|
||||
<button
|
||||
class="bg-bg-primary border border-border text-text-secondary p-2 rounded-lg cursor-pointer text-sm transition-all hover:bg-bg-card-hover"
|
||||
onclick={() => confirmDeleteId = null}
|
||||
>Cancel</button>
|
||||
{:else}
|
||||
<button
|
||||
class="bg-bg-primary border border-border text-text-secondary p-2 rounded-lg cursor-pointer text-sm transition-all hover:border-danger hover:text-danger hover:bg-danger/10"
|
||||
onclick={() => confirmDeleteId = user.id}
|
||||
title="Delete"
|
||||
>🗑️</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<button class="add-user-btn" onclick={() => showAddForm = true}>
|
||||
+ User
|
||||
</button>
|
||||
{/if}
|
||||
<div class="text-center px-12 py-12 bg-bg-card border border-dashed border-border rounded-2xl text-text-secondary">
|
||||
No users found. Create one to get started!
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="user-error">{error}</div>
|
||||
<div class="mt-4 p-3 rounded-xl bg-danger/10 border border-danger/20 text-danger text-[13px] text-center">{error}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.user-manager {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.user-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.user-chip:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.user-chip.selected {
|
||||
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.user-name {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
padding: 0 2px;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.remove-btn:hover {
|
||||
opacity: 1;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.confirm-delete {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.confirm-yes, .confirm-no {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.confirm-yes {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.confirm-no {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.add-user-btn {
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
border: 1px dashed var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.add-user-btn:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.add-user-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.add-user-input {
|
||||
padding: 5px 10px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 12px;
|
||||
width: 100px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.add-user-input:focus {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.add-user-submit {
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.add-user-submit:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.add-user-cancel {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.user-error {
|
||||
margin-top: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-danger) 20%, transparent);
|
||||
color: var(--color-danger);
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,13 +7,26 @@
|
||||
*/
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || '';
|
||||
import { pendingRequests } from './stores.js';
|
||||
|
||||
/**
|
||||
* Wrapper around fetch that tracks the number of pending requests globally
|
||||
*/
|
||||
async function trackedFetch(url, options = {}) {
|
||||
pendingRequests.update(n => n + 1);
|
||||
try {
|
||||
return await fetch(url, options);
|
||||
} finally {
|
||||
pendingRequests.update(n => Math.max(0, n - 1));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch system information from the ESP32.
|
||||
* @returns {Promise<{chip: string, freeHeap: number, uptime: number, firmware: string, connection: string}>}
|
||||
*/
|
||||
export async function getSystemInfo() {
|
||||
const res = await fetch(`${API_BASE}/api/system/info`);
|
||||
const res = await trackedFetch(`${API_BASE}/api/system/info`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
@@ -29,7 +42,7 @@ export async function getSystemInfo() {
|
||||
* @returns {Promise<{message: string}>}
|
||||
*/
|
||||
export async function reboot() {
|
||||
const res = await fetch(`${API_BASE}/api/system/reboot`, {
|
||||
const res = await trackedFetch(`${API_BASE}/api/system/reboot`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -43,7 +56,7 @@ export async function reboot() {
|
||||
* @returns {Promise<{active_slot: number, active_partition: string, target_partition: string, partitions: any[], running_firmware_label: string, running_firmware_slot: number}>}
|
||||
*/
|
||||
export async function getOTAStatus() {
|
||||
const res = await fetch(`${API_BASE}/api/ota/status`);
|
||||
const res = await trackedFetch(`${API_BASE}/api/ota/status`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
@@ -56,7 +69,7 @@ export async function getOTAStatus() {
|
||||
* @returns {Promise<{status: string, message: string}>}
|
||||
*/
|
||||
export async function uploadOTAFrontend(file) {
|
||||
const res = await fetch(`${API_BASE}/api/ota/frontend`, {
|
||||
const res = await trackedFetch(`${API_BASE}/api/ota/frontend`, {
|
||||
method: 'POST',
|
||||
body: file, // Send the raw file Blob/Buffer
|
||||
headers: {
|
||||
@@ -79,7 +92,7 @@ export async function uploadOTAFrontend(file) {
|
||||
* @returns {Promise<{status: string, message: string}>}
|
||||
*/
|
||||
export async function uploadOTAFirmware(file) {
|
||||
const res = await fetch(`${API_BASE}/api/ota/firmware`, {
|
||||
const res = await trackedFetch(`${API_BASE}/api/ota/firmware`, {
|
||||
method: 'POST',
|
||||
body: file,
|
||||
headers: {
|
||||
@@ -101,7 +114,7 @@ export async function uploadOTAFirmware(file) {
|
||||
* @returns {Promise<{status: string, message: string}>}
|
||||
*/
|
||||
export async function uploadOTABundle(file) {
|
||||
const res = await fetch(`${API_BASE}/api/ota/bundle`, {
|
||||
const res = await trackedFetch(`${API_BASE}/api/ota/bundle`, {
|
||||
method: 'POST',
|
||||
body: file,
|
||||
headers: {
|
||||
@@ -124,7 +137,7 @@ export async function uploadOTABundle(file) {
|
||||
* @returns {Promise<Array<{id: number, name: string}>>}
|
||||
*/
|
||||
export async function getUsers() {
|
||||
const res = await fetch(`${API_BASE}/api/users`);
|
||||
const res = await trackedFetch(`${API_BASE}/api/users`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
@@ -135,7 +148,7 @@ export async function getUsers() {
|
||||
* @returns {Promise<{id: number, name: string}>}
|
||||
*/
|
||||
export async function addUser(name) {
|
||||
const res = await fetch(`${API_BASE}/api/users`, {
|
||||
const res = await trackedFetch(`${API_BASE}/api/users`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name })
|
||||
@@ -153,7 +166,7 @@ export async function addUser(name) {
|
||||
* @returns {Promise<{status: string}>}
|
||||
*/
|
||||
export async function removeUser(id) {
|
||||
const res = await fetch(`${API_BASE}/api/users?id=${id}`, {
|
||||
const res = await trackedFetch(`${API_BASE}/api/users?id=${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -163,6 +176,25 @@ export async function removeUser(id) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a user's name.
|
||||
* @param {number} id
|
||||
* @param {string} name
|
||||
* @returns {Promise<{status: string}>}
|
||||
*/
|
||||
export async function updateUser(id, name) {
|
||||
const res = await trackedFetch(`${API_BASE}/api/users/update`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, name })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
throw new Error(`Failed (${res.status}): ${errorText || res.statusText}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ─── Task Management ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -171,7 +203,7 @@ export async function removeUser(id) {
|
||||
* @returns {Promise<Array<{id: number, user_id: number, title: string, due_date: number, completed: boolean}>>}
|
||||
*/
|
||||
export async function getTasks(userId) {
|
||||
const res = await fetch(`${API_BASE}/api/tasks?user_id=${userId}`);
|
||||
const res = await trackedFetch(`${API_BASE}/api/tasks?user_id=${userId}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
@@ -181,7 +213,7 @@ export async function getTasks(userId) {
|
||||
* @returns {Promise<{users: Array<{id: number, name: string, tasks: Array}>}>}
|
||||
*/
|
||||
export async function getUpcomingTasks() {
|
||||
const res = await fetch(`${API_BASE}/api/tasks/upcoming`);
|
||||
const res = await trackedFetch(`${API_BASE}/api/tasks/upcoming`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
@@ -190,14 +222,16 @@ export async function getUpcomingTasks() {
|
||||
* Create a new task.
|
||||
* @param {number} userId
|
||||
* @param {string} title
|
||||
* @param {number} dueDate Unix timestamp in seconds
|
||||
* @returns {Promise<{id: number, user_id: number, title: string, due_date: number, completed: boolean}>}
|
||||
* @param {number} dueDate Unix timestamp in seconds (used for non-recurrent tasks)
|
||||
* @param {number} period 0=Morning, 1=Afternoon, 2=Evening
|
||||
* @param {number} recurrence 0=None, 1-7=Day of week (1=Mon)
|
||||
* @returns {Promise<{id: number, user_id: number, title: string, due_date: number, period: number, recurrence: number, completed: boolean}>}
|
||||
*/
|
||||
export async function addTask(userId, title, dueDate) {
|
||||
const res = await fetch(`${API_BASE}/api/tasks`, {
|
||||
export async function addTask(userId, title, dueDate, period = 0, recurrence = 0) {
|
||||
const res = await trackedFetch(`${API_BASE}/api/tasks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: userId, title, due_date: dueDate })
|
||||
body: JSON.stringify({ user_id: userId, title, due_date: dueDate, period, recurrence })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
@@ -209,11 +243,11 @@ export async function addTask(userId, title, dueDate) {
|
||||
/**
|
||||
* Update a task (partial update — only include fields you want to change).
|
||||
* @param {number} id
|
||||
* @param {Object} fields - { title?: string, due_date?: number, completed?: boolean }
|
||||
* @param {Object} fields - { title?: string, due_date?: number, period?: number, recurrence?: number, completed?: boolean }
|
||||
* @returns {Promise<{status: string}>}
|
||||
*/
|
||||
export async function updateTask(id, fields) {
|
||||
const res = await fetch(`${API_BASE}/api/tasks/update`, {
|
||||
const res = await trackedFetch(`${API_BASE}/api/tasks/update`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, ...fields })
|
||||
@@ -231,7 +265,7 @@ export async function updateTask(id, fields) {
|
||||
* @returns {Promise<{status: string}>}
|
||||
*/
|
||||
export async function deleteTask(id) {
|
||||
const res = await fetch(`${API_BASE}/api/tasks?id=${id}`, {
|
||||
const res = await trackedFetch(`${API_BASE}/api/tasks?id=${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -240,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();
|
||||
}
|
||||
|
||||
|
||||
3
Provider/frontend/src/lib/stores.js
Normal file
3
Provider/frontend/src/lib/stores.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const pendingRequests = writable(0);
|
||||
48
Provider/frontend/src/lib/utils.js
Normal file
48
Provider/frontend/src/lib/utils.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Shared utility functions for the Calendink dashboard.
|
||||
* Import from here — never duplicate these across components.
|
||||
*/
|
||||
|
||||
/** Format uptime seconds into human-readable string */
|
||||
export function formatUptime(seconds) {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
const parts = [];
|
||||
if (d > 0) parts.push(`${d}d`);
|
||||
if (h > 0) parts.push(`${h}h`);
|
||||
if (m > 0) parts.push(`${m}m`);
|
||||
parts.push(`${s}s`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/** Format bytes into human-readable string */
|
||||
export function formatBytes(bytes) {
|
||||
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
/** Format a Unix timestamp (seconds) as a human-readable relative string */
|
||||
export function formatRelativeDate(timestamp) {
|
||||
const now = Date.now() / 1000;
|
||||
const diff = timestamp - now;
|
||||
const absDiff = Math.abs(diff);
|
||||
|
||||
if (absDiff < 3600) {
|
||||
const mins = Math.round(absDiff / 60);
|
||||
return diff < 0 ? `${mins}m ago` : `in ${mins}m`;
|
||||
}
|
||||
if (absDiff < 86400) {
|
||||
const hours = Math.round(absDiff / 3600);
|
||||
return diff < 0 ? `${hours}h ago` : `in ${hours}h`;
|
||||
}
|
||||
const days = Math.round(absDiff / 86400);
|
||||
return diff < 0 ? `${days}d ago` : `in ${days}d`;
|
||||
}
|
||||
|
||||
/** Returns true if a Unix timestamp (seconds) is in the past */
|
||||
export function isOverdue(timestamp) {
|
||||
return timestamp < Date.now() / 1000;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"major": 0,
|
||||
"minor": 1,
|
||||
"revision": 4
|
||||
"revision": 32
|
||||
}
|
||||
@@ -2,7 +2,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
|
||||
json app_update esp_timer esp_psram mdns driver
|
||||
INCLUDE_DIRS ".")
|
||||
|
||||
if(CONFIG_CALENDINK_DEPLOY_WEB_PAGES)
|
||||
|
||||
@@ -24,6 +24,52 @@ menu "CalendarInk Network Configuration"
|
||||
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"
|
||||
@@ -42,4 +88,16 @@ menu "Calendink Web Server"
|
||||
help
|
||||
VFS path where the LittleFS partition is mounted.
|
||||
|
||||
config CALENDINK_DISPLAY_WIDTH
|
||||
int "LVGL Display Width"
|
||||
default 800
|
||||
help
|
||||
Width of the virtual LVGL display used for image generation.
|
||||
|
||||
config CALENDINK_DISPLAY_HEIGHT
|
||||
int "LVGL Display Height"
|
||||
default 480
|
||||
help
|
||||
Height of the virtual LVGL display used for image generation.
|
||||
|
||||
endmenu
|
||||
|
||||
99
Provider/main/api/devices/layout.cpp
Normal file
99
Provider/main/api/devices/layout.cpp
Normal 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
Provider/main/api/devices/list.cpp
Normal file
45
Provider/main/api/devices/list.cpp
Normal 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
Provider/main/api/devices/register.cpp
Normal file
79
Provider/main/api/devices/register.cpp
Normal 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
Provider/main/api/devices/screen.cpp
Normal file
59
Provider/main/api/devices/screen.cpp
Normal 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
Provider/main/api/devices/screen_image.cpp
Normal file
249
Provider/main/api/devices/screen_image.cpp
Normal 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
Provider/main/api/devices/store.cpp
Normal file
57
Provider/main/api/devices/store.cpp
Normal 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
Provider/main/api/devices/unity.cpp
Normal file
9
Provider/main/api/devices/unity.cpp
Normal 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
|
||||
145
Provider/main/api/display/image.cpp
Normal file
145
Provider/main/api/display/image.cpp
Normal file
@@ -0,0 +1,145 @@
|
||||
#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_random.h"
|
||||
#include "lvgl.h"
|
||||
#include <string.h>
|
||||
|
||||
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");
|
||||
|
||||
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);
|
||||
|
||||
// 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;
|
||||
|
||||
// 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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
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;
|
||||
|
||||
// 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};
|
||||
2
Provider/main/api/display/unity.cpp
Normal file
2
Provider/main/api/display/unity.cpp
Normal file
@@ -0,0 +1,2 @@
|
||||
// Unity build entry for display endpoints
|
||||
#include "image.cpp"
|
||||
@@ -1,5 +1,6 @@
|
||||
// POST /api/tasks — Create a new task
|
||||
// Body: {"user_id":1, "title":"...", "due_date":1741369200}
|
||||
// Body: {"user_id":1, "title":"...", "due_date":1741369200, "period":0,
|
||||
// "recurrence":0}
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "esp_http_server.h"
|
||||
@@ -31,19 +32,26 @@ internal esp_err_t api_tasks_post_handler(httpd_req_t *req)
|
||||
cJSON *user_id_item = cJSON_GetObjectItem(body, "user_id");
|
||||
cJSON *title_item = cJSON_GetObjectItem(body, "title");
|
||||
cJSON *due_date_item = cJSON_GetObjectItem(body, "due_date");
|
||||
cJSON *period_item = cJSON_GetObjectItem(body, "period");
|
||||
cJSON *recurrence_item = cJSON_GetObjectItem(body, "recurrence");
|
||||
|
||||
if (!cJSON_IsNumber(user_id_item) || !cJSON_IsString(title_item) ||
|
||||
!cJSON_IsNumber(due_date_item))
|
||||
if (!cJSON_IsNumber(user_id_item) || !cJSON_IsString(title_item))
|
||||
{
|
||||
cJSON_Delete(body);
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST,
|
||||
"Missing user_id, title, or due_date");
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing user_id or title");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
int64 due_date =
|
||||
cJSON_IsNumber(due_date_item) ? (int64)due_date_item->valuedouble : 0;
|
||||
uint8 period = cJSON_IsNumber(period_item) ? (uint8)period_item->valueint
|
||||
: (uint8)PERIOD_MORNING;
|
||||
uint8 recurrence =
|
||||
cJSON_IsNumber(recurrence_item) ? (uint8)recurrence_item->valueint : 0;
|
||||
|
||||
task_t *task =
|
||||
add_task((uint8)user_id_item->valueint, title_item->valuestring,
|
||||
(int64)due_date_item->valuedouble);
|
||||
add_task((uint8)user_id_item->valueint, title_item->valuestring, due_date,
|
||||
period, recurrence);
|
||||
cJSON_Delete(body);
|
||||
|
||||
if (!task)
|
||||
@@ -58,6 +66,8 @@ internal esp_err_t api_tasks_post_handler(httpd_req_t *req)
|
||||
cJSON_AddNumberToObject(resp, "user_id", task->user_id);
|
||||
cJSON_AddStringToObject(resp, "title", task->title);
|
||||
cJSON_AddNumberToObject(resp, "due_date", (double)task->due_date);
|
||||
cJSON_AddNumberToObject(resp, "period", task->period);
|
||||
cJSON_AddNumberToObject(resp, "recurrence", task->recurrence);
|
||||
cJSON_AddBoolToObject(resp, "completed", task->completed);
|
||||
|
||||
const char *json = cJSON_PrintUnformatted(resp);
|
||||
|
||||
@@ -41,6 +41,8 @@ internal esp_err_t api_tasks_get_handler(httpd_req_t *req)
|
||||
cJSON_AddNumberToObject(obj, "user_id", g_Tasks[i].user_id);
|
||||
cJSON_AddStringToObject(obj, "title", g_Tasks[i].title);
|
||||
cJSON_AddNumberToObject(obj, "due_date", (double)g_Tasks[i].due_date);
|
||||
cJSON_AddNumberToObject(obj, "period", g_Tasks[i].period);
|
||||
cJSON_AddNumberToObject(obj, "recurrence", g_Tasks[i].recurrence);
|
||||
cJSON_AddBoolToObject(obj, "completed", g_Tasks[i].completed);
|
||||
cJSON_AddItemToArray(arr, obj);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
#include "api/users/store.hpp"
|
||||
|
||||
// Find a task by ID, returns nullptr if not found
|
||||
internal task_t *find_task(uint16 id)
|
||||
task_t *find_task(uint16 id)
|
||||
{
|
||||
for (int i = 0; i < MAX_TASKS; i++)
|
||||
{
|
||||
@@ -19,7 +19,8 @@ internal task_t *find_task(uint16 id)
|
||||
}
|
||||
|
||||
// Add a task, returns pointer to new task or nullptr if full
|
||||
internal task_t *add_task(uint8 user_id, const char *title, int64 due_date)
|
||||
task_t *add_task(uint8 user_id, const char *title, int64 due_date, uint8 period,
|
||||
uint8 recurrence)
|
||||
{
|
||||
// Verify user exists
|
||||
if (find_user(user_id) == nullptr)
|
||||
@@ -35,6 +36,8 @@ internal task_t *add_task(uint8 user_id, const char *title, int64 due_date)
|
||||
g_Tasks[i].user_id = user_id;
|
||||
strlcpy(g_Tasks[i].title, title, sizeof(g_Tasks[i].title));
|
||||
g_Tasks[i].due_date = due_date;
|
||||
g_Tasks[i].period = period;
|
||||
g_Tasks[i].recurrence = recurrence;
|
||||
g_Tasks[i].completed = false;
|
||||
g_Tasks[i].active = true;
|
||||
return &g_Tasks[i];
|
||||
@@ -44,7 +47,7 @@ internal task_t *add_task(uint8 user_id, const char *title, int64 due_date)
|
||||
}
|
||||
|
||||
// Remove a task by ID, returns true if found and removed
|
||||
internal bool remove_task(uint16 id)
|
||||
bool remove_task(uint16 id)
|
||||
{
|
||||
for (int i = 0; i < MAX_TASKS; i++)
|
||||
{
|
||||
@@ -59,7 +62,7 @@ internal bool remove_task(uint16 id)
|
||||
}
|
||||
|
||||
// Remove all tasks belonging to a user
|
||||
internal void remove_tasks_for_user(uint8 user_id)
|
||||
void remove_tasks_for_user(uint8 user_id)
|
||||
{
|
||||
for (int i = 0; i < MAX_TASKS; i++)
|
||||
{
|
||||
@@ -73,7 +76,7 @@ internal void remove_tasks_for_user(uint8 user_id)
|
||||
|
||||
// Simple insertion sort for small arrays — sort task pointers by due_date
|
||||
// ascending
|
||||
internal void sort_tasks_by_due_date(task_t **arr, int count)
|
||||
void sort_tasks_by_due_date(task_t **arr, int count)
|
||||
{
|
||||
for (int i = 1; i < count; i++)
|
||||
{
|
||||
@@ -90,22 +93,23 @@ internal void sort_tasks_by_due_date(task_t **arr, int count)
|
||||
|
||||
// Populate dummy tasks on boot for development iteration.
|
||||
// Uses relative offsets from current time so due dates always make sense.
|
||||
internal void seed_tasks()
|
||||
void seed_tasks()
|
||||
{
|
||||
int64 now = (int64)(esp_timer_get_time() / 1000000);
|
||||
|
||||
// Alice's tasks (user_id = 1)
|
||||
add_task(1, "Buy groceries", now + 86400); // +1 day
|
||||
add_task(1, "Review PR #42", now + 3600); // +1 hour
|
||||
add_task(1, "Book dentist appointment", now + 172800); // +2 days
|
||||
add_task(1, "Update resume", now + 604800); // +7 days
|
||||
// Alice's tasks (user_id = 1) — mix of one-off and recurring
|
||||
add_task(1, "Buy groceries", now + 86400, PERIOD_MORNING);
|
||||
add_task(1, "Review PR #42", now + 3600, PERIOD_AFTERNOON);
|
||||
add_task(1, "Book dentist appointment", now + 172800, PERIOD_MORNING);
|
||||
add_task(1, "Update resume", now + 604800, PERIOD_EVENING);
|
||||
|
||||
// Bob's tasks (user_id = 2)
|
||||
add_task(2, "Fix login bug", now + 7200); // +2 hours
|
||||
add_task(2, "Deploy staging", now + 43200); // +12 hours
|
||||
add_task(2, "Write unit tests", now + 259200); // +3 days
|
||||
// Bob's tasks (user_id = 2) — some recurring routines
|
||||
add_task(2, "Morning standup", 0, PERIOD_MORNING, 0x1F); // Mon-Fri
|
||||
add_task(2, "Deploy staging", now + 43200, PERIOD_AFTERNOON);
|
||||
add_task(2, "Write unit tests", now + 259200, PERIOD_MORNING);
|
||||
|
||||
// Charlie's tasks (user_id = 3)
|
||||
add_task(3, "Water plants", now + 1800); // +30 min
|
||||
add_task(3, "Call plumber", now + 86400); // +1 day
|
||||
// Charlie's tasks (user_id = 3) — kid routine examples
|
||||
add_task(3, "Breakfast", 0, PERIOD_MORNING, 0x1F); // Mon-Fri
|
||||
add_task(3, "Homework", 0, PERIOD_AFTERNOON, 0x15); // Mon+Wed+Fri
|
||||
add_task(3, "Bath time", 0, PERIOD_EVENING, 0x7F); // Every day
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
#include "types.hpp"
|
||||
|
||||
// Data store operations for tasks
|
||||
internal task_t *find_task(uint16 id);
|
||||
internal task_t *add_task(uint8 user_id, const char *title, int64 due_date);
|
||||
internal bool remove_task(uint16 id);
|
||||
internal void remove_tasks_for_user(uint8 user_id);
|
||||
internal void sort_tasks_by_due_date(task_t **arr, int count);
|
||||
internal void seed_tasks();
|
||||
task_t *find_task(uint16 id);
|
||||
task_t *add_task(uint8 user_id, const char *title, int64 due_date,
|
||||
uint8 period = PERIOD_MORNING, uint8 recurrence = 0);
|
||||
bool remove_task(uint16 id);
|
||||
void remove_tasks_for_user(uint8 user_id);
|
||||
void sort_tasks_by_due_date(task_t **arr, int count);
|
||||
void seed_tasks();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// GET /api/tasks/upcoming — Top 3 upcoming tasks per user (for Dashboard)
|
||||
// GET /api/tasks/upcoming — Today's tasks per user, grouped by period
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "esp_http_server.h"
|
||||
@@ -20,36 +20,26 @@ internal esp_err_t api_tasks_upcoming_handler(httpd_req_t *req)
|
||||
continue;
|
||||
|
||||
// Collect incomplete tasks for this user
|
||||
task_t *user_tasks[MAX_TASKS];
|
||||
int count = 0;
|
||||
|
||||
for (int t = 0; t < MAX_TASKS; t++)
|
||||
{
|
||||
if (g_Tasks[t].active && g_Tasks[t].user_id == g_Users[u].id &&
|
||||
!g_Tasks[t].completed)
|
||||
{
|
||||
user_tasks[count++] = &g_Tasks[t];
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by due_date ascending
|
||||
sort_tasks_by_due_date(user_tasks, count);
|
||||
|
||||
// Build user object with top 3
|
||||
// Include: recurring tasks (any day) and one-off tasks
|
||||
cJSON *user_obj = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(user_obj, "id", g_Users[u].id);
|
||||
cJSON_AddStringToObject(user_obj, "name", g_Users[u].name);
|
||||
|
||||
cJSON *tasks_arr = cJSON_AddArrayToObject(user_obj, "tasks");
|
||||
int limit = count < 3 ? count : 3;
|
||||
for (int i = 0; i < limit; i++)
|
||||
|
||||
for (int t = 0; t < MAX_TASKS; t++)
|
||||
{
|
||||
if (!g_Tasks[t].active || g_Tasks[t].completed ||
|
||||
g_Tasks[t].user_id != g_Users[u].id)
|
||||
continue;
|
||||
|
||||
cJSON *t_obj = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(t_obj, "id", user_tasks[i]->id);
|
||||
cJSON_AddStringToObject(t_obj, "title", user_tasks[i]->title);
|
||||
cJSON_AddNumberToObject(t_obj, "due_date",
|
||||
(double)user_tasks[i]->due_date);
|
||||
cJSON_AddBoolToObject(t_obj, "completed", user_tasks[i]->completed);
|
||||
cJSON_AddNumberToObject(t_obj, "id", g_Tasks[t].id);
|
||||
cJSON_AddStringToObject(t_obj, "title", g_Tasks[t].title);
|
||||
cJSON_AddNumberToObject(t_obj, "due_date", (double)g_Tasks[t].due_date);
|
||||
cJSON_AddNumberToObject(t_obj, "period", g_Tasks[t].period);
|
||||
cJSON_AddNumberToObject(t_obj, "recurrence", g_Tasks[t].recurrence);
|
||||
cJSON_AddBoolToObject(t_obj, "completed", g_Tasks[t].completed);
|
||||
cJSON_AddItemToArray(tasks_arr, t_obj);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// POST /api/tasks/update — Modify a task
|
||||
// Body: {"id":1, "title":"...", "due_date":..., "completed":true}
|
||||
// All fields except "id" are optional
|
||||
// Body: {"id":1, "title":"...", "due_date":..., "period":0, "recurrence":0,
|
||||
// "completed":true} All fields except "id" are optional
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "esp_http_server.h"
|
||||
@@ -58,6 +58,18 @@ internal esp_err_t api_tasks_update_handler(httpd_req_t *req)
|
||||
task->due_date = (int64)due_date_item->valuedouble;
|
||||
}
|
||||
|
||||
cJSON *period_item = cJSON_GetObjectItem(body, "period");
|
||||
if (cJSON_IsNumber(period_item))
|
||||
{
|
||||
task->period = (uint8)period_item->valueint;
|
||||
}
|
||||
|
||||
cJSON *recurrence_item = cJSON_GetObjectItem(body, "recurrence");
|
||||
if (cJSON_IsNumber(recurrence_item))
|
||||
{
|
||||
task->recurrence = (uint8)recurrence_item->valueint;
|
||||
}
|
||||
|
||||
cJSON *completed_item = cJSON_GetObjectItem(body, "completed");
|
||||
if (cJSON_IsBool(completed_item))
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
#include "api/users/store.hpp"
|
||||
|
||||
// Find a user by ID, returns nullptr if not found
|
||||
internal user_t *find_user(uint8 id)
|
||||
user_t *find_user(uint8 id)
|
||||
{
|
||||
for (int i = 0; i < MAX_USERS; i++)
|
||||
{
|
||||
@@ -16,7 +16,7 @@ internal user_t *find_user(uint8 id)
|
||||
}
|
||||
|
||||
// Add a user, returns pointer to new user or nullptr if full
|
||||
internal user_t *add_user(const char *name)
|
||||
user_t *add_user(const char *name)
|
||||
{
|
||||
for (int i = 0; i < MAX_USERS; i++)
|
||||
{
|
||||
@@ -32,7 +32,7 @@ internal user_t *add_user(const char *name)
|
||||
}
|
||||
|
||||
// Remove a user by ID, returns true if found and removed
|
||||
internal bool remove_user(uint8 id)
|
||||
bool remove_user(uint8 id)
|
||||
{
|
||||
for (int i = 0; i < MAX_USERS; i++)
|
||||
{
|
||||
@@ -47,8 +47,19 @@ internal bool remove_user(uint8 id)
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update a user's name, returns pointer to user or nullptr if not found
|
||||
user_t *update_user(uint8 id, const char *name)
|
||||
{
|
||||
user_t *user = find_user(id);
|
||||
if (user)
|
||||
{
|
||||
strlcpy(user->name, name, sizeof(user->name));
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
// Populate dummy users on boot for development iteration
|
||||
internal void seed_users()
|
||||
void seed_users()
|
||||
{
|
||||
add_user("Alice");
|
||||
add_user("Bob");
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
#include "types.hpp"
|
||||
#include "user.hpp"
|
||||
|
||||
|
||||
// Data store operations for users
|
||||
internal user_t *find_user(uint8 id);
|
||||
internal user_t *add_user(const char *name);
|
||||
internal bool remove_user(uint8 id);
|
||||
internal void seed_users();
|
||||
user_t *find_user(uint8 id);
|
||||
user_t *add_user(const char *name);
|
||||
bool remove_user(uint8 id);
|
||||
user_t *update_user(uint8 id, const char *name);
|
||||
void seed_users();
|
||||
|
||||
@@ -3,4 +3,5 @@
|
||||
#include "api/users/list.cpp"
|
||||
#include "api/users/add.cpp"
|
||||
#include "api/users/remove.cpp"
|
||||
#include "api/users/update.cpp"
|
||||
// clang-format on
|
||||
|
||||
68
Provider/main/api/users/update.cpp
Normal file
68
Provider/main/api/users/update.cpp
Normal file
@@ -0,0 +1,68 @@
|
||||
// POST /api/users/update — Update an existing user's name
|
||||
// Body: {"id": 1, "name": "Bob"}
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "esp_http_server.h"
|
||||
|
||||
#include "api/users/store.hpp"
|
||||
#include "types.hpp"
|
||||
#include "user.hpp"
|
||||
|
||||
internal esp_err_t api_users_update_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 *id_item = cJSON_GetObjectItem(body, "id");
|
||||
cJSON *name_item = cJSON_GetObjectItem(body, "name");
|
||||
|
||||
if (!cJSON_IsNumber(id_item) || !cJSON_IsString(name_item) ||
|
||||
strlen(name_item->valuestring) == 0)
|
||||
{
|
||||
cJSON_Delete(body);
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing 'id' or 'name'");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
user_t *user = update_user((uint8)id_item->valueint, name_item->valuestring);
|
||||
cJSON_Delete(body);
|
||||
|
||||
if (!user)
|
||||
{
|
||||
httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "User not found");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
cJSON *resp = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(resp, "status", "ok");
|
||||
|
||||
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_users_update_uri = {.uri = "/api/users/update",
|
||||
.method = HTTP_POST,
|
||||
.handler =
|
||||
api_users_update_handler,
|
||||
.user_ctx = NULL};
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
#include "types.hpp"
|
||||
|
||||
// Shared Application State (Unity Build)
|
||||
internal bool g_Ethernet_Initialized = false;
|
||||
internal bool g_Wifi_Initialized = false;
|
||||
internal uint8_t g_Active_WWW_Partition = 0;
|
||||
// Shared Application State
|
||||
extern bool g_Ethernet_Initialized;
|
||||
extern bool g_Wifi_Initialized;
|
||||
extern uint8_t g_Active_WWW_Partition;
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
#include <string.h>
|
||||
|
||||
// SDK
|
||||
#include "driver/gpio.h"
|
||||
#include "esp_err.h"
|
||||
#include "esp_eth.h"
|
||||
#include "esp_eth_driver.h"
|
||||
#include "esp_event.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_sleep.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "ethernet_init.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
@@ -18,9 +20,11 @@
|
||||
#include "types.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
|
||||
|
||||
// Internal states
|
||||
internal esp_netif_ip_info_t s_current_ip_info = {};
|
||||
@@ -70,6 +74,10 @@ void ethernet_event_handler(void *arg, esp_event_base_t event_base,
|
||||
{
|
||||
ESP_LOGI(kLogEthernet, "Ethernet Link Down");
|
||||
s_eth_link_up = false;
|
||||
if (s_semph_eth_link)
|
||||
{
|
||||
xSemaphoreGive(s_semph_eth_link);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +165,9 @@ internal esp_err_t connect_ethernet(bool blockUntilIPAcquired)
|
||||
{
|
||||
ESP_LOGI(kLogEthernet, "Waiting for IP address.");
|
||||
xSemaphoreTake(s_semph_get_ip_addrs, portMAX_DELAY);
|
||||
#if CONFIG_CALENDINK_BLINK_IP
|
||||
blink_last_ip_octet();
|
||||
#endif
|
||||
}
|
||||
|
||||
return ESP_OK;
|
||||
@@ -182,10 +192,18 @@ internal esp_err_t check_ethernet_connection(uint32_t timeoutSeconds)
|
||||
{
|
||||
if (!xSemaphoreTake(s_semph_eth_link, pdMS_TO_TICKS(5000)))
|
||||
{
|
||||
ESP_LOGE(kLogEthernet,
|
||||
"No physical Ethernet link detected. Skipping DHCP wait.");
|
||||
ESP_LOGE(
|
||||
kLogEthernet,
|
||||
"No physical Ethernet link detected (Timeout). Skipping DHCP wait.");
|
||||
return ESP_ERR_INVALID_STATE; // Special error to skip retries
|
||||
}
|
||||
|
||||
if (!s_eth_link_up)
|
||||
{
|
||||
ESP_LOGE(kLogEthernet, "No physical Ethernet link detected "
|
||||
"(Disconnected). Skipping DHCP wait.");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
}
|
||||
|
||||
ESP_LOGI(kLogEthernet, "Waiting for IP address for %d seconds.",
|
||||
@@ -193,7 +211,9 @@ internal esp_err_t check_ethernet_connection(uint32_t timeoutSeconds)
|
||||
if (xSemaphoreTake(s_semph_get_ip_addrs,
|
||||
pdMS_TO_TICKS(timeoutSeconds * 1000)))
|
||||
{
|
||||
#if CONFIG_CALENDINK_BLINK_IP
|
||||
blink_last_ip_octet();
|
||||
#endif
|
||||
return ESP_OK;
|
||||
}
|
||||
else
|
||||
@@ -209,15 +229,11 @@ internal esp_netif_t *s_wifi_netif = nullptr;
|
||||
internal SemaphoreHandle_t s_semph_get_wifi_ip_addrs = nullptr;
|
||||
internal SemaphoreHandle_t s_semph_wifi_link = nullptr;
|
||||
internal volatile bool s_wifi_link_up = false;
|
||||
internal TimerHandle_t s_wifi_reconnect_timer = nullptr;
|
||||
|
||||
#ifndef NDEBUG
|
||||
internal bool s_wifi_connected = false;
|
||||
#endif
|
||||
|
||||
// Forward declaration for timer callback
|
||||
internal void wifi_reconnect_timer_cb(TimerHandle_t xTimer);
|
||||
|
||||
void wifi_event_handler(void *arg, esp_event_base_t event_base,
|
||||
int32_t event_id, void *event_data)
|
||||
{
|
||||
@@ -232,11 +248,11 @@ void wifi_event_handler(void *arg, esp_event_base_t event_base,
|
||||
}
|
||||
else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED)
|
||||
{
|
||||
ESP_LOGI(kLogWifi, "WiFi disconnected. Scheduling reconnect...");
|
||||
ESP_LOGI(kLogWifi, "WiFi disconnected.");
|
||||
s_wifi_link_up = false;
|
||||
if (s_wifi_reconnect_timer != nullptr)
|
||||
if (s_semph_wifi_link)
|
||||
{
|
||||
xTimerStart(s_wifi_reconnect_timer, 0);
|
||||
xSemaphoreGive(s_semph_wifi_link);
|
||||
}
|
||||
}
|
||||
else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP)
|
||||
@@ -254,20 +270,8 @@ void wifi_event_handler(void *arg, esp_event_base_t event_base,
|
||||
}
|
||||
}
|
||||
|
||||
internal void wifi_reconnect_timer_cb(TimerHandle_t xTimer)
|
||||
{
|
||||
ESP_LOGI(kLogWifi, "Timer expired. Executing esp_wifi_connect()...");
|
||||
esp_wifi_connect();
|
||||
}
|
||||
|
||||
void teardown_wifi()
|
||||
{
|
||||
if (s_wifi_reconnect_timer != nullptr)
|
||||
{
|
||||
xTimerStop(s_wifi_reconnect_timer, 0);
|
||||
xTimerDelete(s_wifi_reconnect_timer, 0);
|
||||
s_wifi_reconnect_timer = nullptr;
|
||||
}
|
||||
|
||||
if (s_semph_wifi_link != nullptr)
|
||||
{
|
||||
@@ -313,11 +317,6 @@ internal esp_err_t connect_wifi(const char *ssid, const char *password,
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
// Create a 5-second timer to avoid spamming connect requests
|
||||
s_wifi_reconnect_timer =
|
||||
xTimerCreate("wifi_recon", pdMS_TO_TICKS(5000), pdFALSE, (void *)0,
|
||||
wifi_reconnect_timer_cb);
|
||||
|
||||
// esp_netif_init() is already called by Ethernet, but safe to call multiple
|
||||
// times or we can assume it's initialized.
|
||||
s_wifi_netif = esp_netif_create_default_wifi_sta();
|
||||
@@ -343,6 +342,16 @@ internal esp_err_t connect_wifi(const char *ssid, const char *password,
|
||||
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
|
||||
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
|
||||
ESP_ERROR_CHECK(esp_wifi_start());
|
||||
|
||||
// Set Power Save mode based on sdkconfig selection
|
||||
#if defined(CONFIG_CALENDINK_WIFI_PS_MAX_MODEM)
|
||||
ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_MAX_MODEM));
|
||||
#elif defined(CONFIG_CALENDINK_WIFI_PS_NONE)
|
||||
ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE));
|
||||
#else
|
||||
ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_MIN_MODEM));
|
||||
#endif
|
||||
|
||||
ESP_ERROR_CHECK(esp_wifi_connect());
|
||||
|
||||
ESP_ERROR_CHECK(esp_register_shutdown_handler(&teardown_wifi));
|
||||
@@ -351,7 +360,9 @@ internal esp_err_t connect_wifi(const char *ssid, const char *password,
|
||||
{
|
||||
ESP_LOGI(kLogWifi, "Waiting for IP address.");
|
||||
xSemaphoreTake(s_semph_get_wifi_ip_addrs, portMAX_DELAY);
|
||||
#if CONFIG_CALENDINK_BLINK_IP
|
||||
blink_last_ip_octet();
|
||||
#endif
|
||||
}
|
||||
|
||||
return ESP_OK;
|
||||
@@ -373,31 +384,35 @@ internal 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)
|
||||
{
|
||||
// If the timer isn't already running, kickstart a connection attempt now
|
||||
if (s_wifi_reconnect_timer != nullptr &&
|
||||
xTimerIsTimerActive(s_wifi_reconnect_timer) == pdFALSE)
|
||||
ESP_LOGI(kLogWifi,
|
||||
"Physical link is down. Requesting immediate AP association...");
|
||||
esp_err_t err = esp_wifi_connect();
|
||||
if (err != ESP_OK && err != ESP_ERR_WIFI_CONN)
|
||||
{
|
||||
ESP_LOGI(kLogWifi,
|
||||
"Physical link is down. Requesting immediate AP association...");
|
||||
esp_err_t err = esp_wifi_connect();
|
||||
if (err != ESP_OK && err != ESP_ERR_WIFI_CONN)
|
||||
{
|
||||
ESP_LOGE(kLogWifi, "esp_wifi_connect failed: %s", esp_err_to_name(err));
|
||||
}
|
||||
ESP_LOGE(kLogWifi, "esp_wifi_connect failed: %s", esp_err_to_name(err));
|
||||
}
|
||||
|
||||
if (!xSemaphoreTake(s_semph_wifi_link, pdMS_TO_TICKS(10000)))
|
||||
{
|
||||
ESP_LOGE(kLogWifi, "Failed to associate with WiFi AP.");
|
||||
ESP_LOGE(kLogWifi, "Failed to associate with WiFi AP (Timeout).");
|
||||
return ESP_ERR_TIMEOUT; // Return timeout so main.cpp triggers a retry
|
||||
}
|
||||
|
||||
// After semaphore is taken, check if it was because of a disconnect
|
||||
if (!s_wifi_link_up)
|
||||
{
|
||||
ESP_LOGE(kLogWifi, "Failed to associate with WiFi AP (Disconnected).");
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
}
|
||||
|
||||
ESP_LOGI(kLogWifi, "Waiting for IP address for %d seconds.", timeoutSeconds);
|
||||
if (xSemaphoreTake(s_semph_get_wifi_ip_addrs,
|
||||
pdMS_TO_TICKS(timeoutSeconds * 1000)))
|
||||
{
|
||||
#if CONFIG_CALENDINK_BLINK_IP
|
||||
blink_last_ip_octet();
|
||||
#endif
|
||||
return ESP_OK;
|
||||
}
|
||||
else
|
||||
@@ -406,14 +421,13 @@ internal esp_err_t check_wifi_connection(uint32_t timeoutSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
#if CONFIG_CALENDINK_BLINK_IP
|
||||
internal esp_err_t get_ip_info(esp_netif_ip_info_t *ip_info)
|
||||
{
|
||||
*ip_info = s_current_ip_info;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
internal void led_blink_number(int n, uint8_t r, uint8_t g, uint8_t b);
|
||||
|
||||
internal void blink_last_ip_octet()
|
||||
{
|
||||
esp_netif_ip_info_t ip_info;
|
||||
@@ -438,3 +452,4 @@ internal void blink_last_ip_octet()
|
||||
led_blink_number(u, 255, 255, 255);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
25
Provider/main/device.hpp
Normal file
25
Provider/main/device.hpp
Normal 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] = {};
|
||||
@@ -19,6 +19,8 @@
|
||||
#include "api/ota/status.cpp"
|
||||
#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"
|
||||
|
||||
@@ -28,11 +30,53 @@ constexpr uint8 kGZ_Extension_Length = sizeof(".gz") - 1;
|
||||
|
||||
#define FILE_PATH_MAX (ESP_VFS_PATH_MAX + 128)
|
||||
#define SCRATCH_BUFSIZE 4096
|
||||
#define MAX_SCRATCH_BUFFERS 10
|
||||
|
||||
typedef struct
|
||||
{
|
||||
char scratch[SCRATCH_BUFSIZE];
|
||||
} http_server_data_t;
|
||||
char *buffers[MAX_SCRATCH_BUFFERS];
|
||||
bool in_use[MAX_SCRATCH_BUFFERS];
|
||||
} scratch_pool_t;
|
||||
|
||||
static scratch_pool_t global_scratch_pool = {};
|
||||
|
||||
char *get_scratch_buffer()
|
||||
{
|
||||
for (int i = 0; i < MAX_SCRATCH_BUFFERS; i++)
|
||||
{
|
||||
if (!global_scratch_pool.in_use[i])
|
||||
{
|
||||
if (global_scratch_pool.buffers[i] == NULL)
|
||||
{
|
||||
global_scratch_pool.buffers[i] = (char *)malloc(SCRATCH_BUFSIZE);
|
||||
if (global_scratch_pool.buffers[i] == NULL)
|
||||
{
|
||||
ESP_LOGE(TAG, "Failed to allocate scratch buffer from heap!");
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
global_scratch_pool.in_use[i] = true;
|
||||
return global_scratch_pool.buffers[i];
|
||||
}
|
||||
}
|
||||
ESP_LOGE(TAG, "All scratch buffers in use! Increase MAX_SCRATCH_BUFFERS");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void free_scratch_buffer(char *buffer)
|
||||
{
|
||||
if (buffer == NULL)
|
||||
return;
|
||||
for (int i = 0; i < MAX_SCRATCH_BUFFERS; i++)
|
||||
{
|
||||
if (global_scratch_pool.buffers[i] == buffer)
|
||||
{
|
||||
global_scratch_pool.in_use[i] = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
ESP_LOGE(TAG, "Attempted to free unknown scratch buffer!");
|
||||
}
|
||||
|
||||
#ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES
|
||||
// Set HTTP response content type according to file extension
|
||||
@@ -138,8 +182,14 @@ internal esp_err_t static_file_handler(httpd_req_t *req)
|
||||
|
||||
set_content_type_from_file(req, filepath);
|
||||
|
||||
http_server_data_t *rest_context = (http_server_data_t *)req->user_ctx;
|
||||
char *chunk = rest_context->scratch;
|
||||
char *chunk = get_scratch_buffer();
|
||||
if (chunk == NULL)
|
||||
{
|
||||
close(fd);
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Server busy");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
ssize_t read_bytes;
|
||||
|
||||
do
|
||||
@@ -155,6 +205,7 @@ internal esp_err_t static_file_handler(httpd_req_t *req)
|
||||
{
|
||||
close(fd);
|
||||
ESP_LOGE(TAG, "File sending failed!");
|
||||
free_scratch_buffer(chunk);
|
||||
httpd_resp_sendstr_chunk(req, NULL); // Abort sending
|
||||
return ESP_FAIL;
|
||||
}
|
||||
@@ -162,6 +213,7 @@ internal esp_err_t static_file_handler(httpd_req_t *req)
|
||||
} while (read_bytes > 0);
|
||||
|
||||
close(fd);
|
||||
free_scratch_buffer(chunk);
|
||||
httpd_resp_send_chunk(req, NULL, 0); // End response
|
||||
|
||||
return ESP_OK;
|
||||
@@ -186,6 +238,7 @@ internal httpd_handle_t start_webserver(void)
|
||||
esp_vfs_littlefs_conf_t conf = {};
|
||||
conf.base_path = "/www";
|
||||
conf.partition_label = g_Active_WWW_Partition == 0 ? "www_0" : "www_1";
|
||||
ESP_LOGI(TAG, "Mounting LittleFS partition: %s", conf.partition_label);
|
||||
conf.format_if_mount_failed = false;
|
||||
conf.dont_mount = false;
|
||||
esp_err_t ret = esp_vfs_littlefs_register(&conf);
|
||||
@@ -209,17 +262,13 @@ internal httpd_handle_t start_webserver(void)
|
||||
ESP_LOGI(TAG, "LittleFS mounted on /www");
|
||||
}
|
||||
#endif
|
||||
http_server_data_t *rest_context =
|
||||
(http_server_data_t *)calloc(1, sizeof(http_server_data_t));
|
||||
if (rest_context == NULL)
|
||||
{
|
||||
ESP_LOGE(TAG, "No memory for rest context");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
httpd_handle_t server = NULL;
|
||||
ESP_LOGI(TAG, "Starting HTTP Server on port: '%d'", config.server_port);
|
||||
@@ -236,6 +285,7 @@ internal httpd_handle_t start_webserver(void)
|
||||
// Register system API routes
|
||||
httpd_register_uri_handler(server, &api_system_info_uri);
|
||||
httpd_register_uri_handler(server, &api_system_reboot_uri);
|
||||
httpd_register_uri_handler(server, &api_display_image_uri);
|
||||
httpd_register_uri_handler(server, &api_ota_status_uri);
|
||||
httpd_register_uri_handler(server, &api_ota_frontend_uri);
|
||||
httpd_register_uri_handler(server, &api_ota_firmware_uri);
|
||||
@@ -244,6 +294,7 @@ internal httpd_handle_t start_webserver(void)
|
||||
// Register todo list API routes
|
||||
httpd_register_uri_handler(server, &api_users_get_uri);
|
||||
httpd_register_uri_handler(server, &api_users_post_uri);
|
||||
httpd_register_uri_handler(server, &api_users_update_uri);
|
||||
httpd_register_uri_handler(server, &api_users_delete_uri);
|
||||
httpd_register_uri_handler(server, &api_tasks_upcoming_uri);
|
||||
httpd_register_uri_handler(server, &api_tasks_get_uri);
|
||||
@@ -251,16 +302,25 @@ 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);
|
||||
|
||||
// Populate dummy data for development
|
||||
// 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();
|
||||
seed_tasks();
|
||||
#endif
|
||||
|
||||
#ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES
|
||||
// Register static file handler last as a catch-all wildcard if deployed
|
||||
httpd_uri_t static_get_uri = {.uri = "/*",
|
||||
.method = HTTP_GET,
|
||||
.handler = static_file_handler,
|
||||
.user_ctx = rest_context};
|
||||
.user_ctx = NULL};
|
||||
httpd_register_uri_handler(server, &static_get_uri);
|
||||
#endif
|
||||
|
||||
@@ -268,18 +328,16 @@ internal httpd_handle_t start_webserver(void)
|
||||
}
|
||||
|
||||
ESP_LOGE(TAG, "Error starting server!");
|
||||
free(rest_context);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
internal void stop_webserver(httpd_handle_t server)
|
||||
internal void stop_webserver(httpd_handle_t server, uint8_t partition_index)
|
||||
{
|
||||
if (server)
|
||||
{
|
||||
httpd_stop(server);
|
||||
#ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES
|
||||
esp_vfs_littlefs_unregister(g_Active_WWW_Partition == 0 ? "www_0"
|
||||
: "www_1");
|
||||
esp_vfs_littlefs_unregister(partition_index == 0 ? "www_0" : "www_1");
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,5 +15,7 @@ dependencies:
|
||||
# # 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.4.0"
|
||||
|
||||
@@ -58,6 +58,8 @@ internal void set_led_status(led_status status)
|
||||
}
|
||||
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)
|
||||
@@ -85,3 +87,4 @@ internal void led_blink_number(int n, uint8_t r, uint8_t g, uint8_t b)
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||
}
|
||||
#endif
|
||||
|
||||
7248
Provider/main/lodepng/lodepng.cpp
Normal file
7248
Provider/main/lodepng/lodepng.cpp
Normal file
File diff suppressed because it is too large
Load Diff
2188
Provider/main/lodepng/lodepng.h
Normal file
2188
Provider/main/lodepng/lodepng.h
Normal file
File diff suppressed because it is too large
Load Diff
163
Provider/main/lodepng_alloc.cpp
Normal file
163
Provider/main/lodepng_alloc.cpp
Normal file
@@ -0,0 +1,163 @@
|
||||
#include "lodepng_alloc.hpp"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_log.h"
|
||||
#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!
|
||||
|
||||
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 (1 * 1024 * 1024)
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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_free()
|
||||
{
|
||||
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;
|
||||
};
|
||||
|
||||
void *lodepng_custom_malloc(size_t size)
|
||||
{
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
void *lodepng_custom_realloc(void *ptr, size_t new_size)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
void lodepng_custom_free(void *ptr)
|
||||
{
|
||||
// No-op! The bump pointer will just reset to 0 once the API endpoint is done!
|
||||
(void)ptr;
|
||||
}
|
||||
14
Provider/main/lodepng_alloc.hpp
Normal file
14
Provider/main/lodepng_alloc.hpp
Normal file
@@ -0,0 +1,14 @@
|
||||
#ifndef LODEPNG_ALLOC_HPP
|
||||
#define LODEPNG_ALLOC_HPP
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
void lodepng_allocator_init();
|
||||
void lodepng_allocator_reset();
|
||||
void lodepng_allocator_free();
|
||||
|
||||
void* lodepng_custom_malloc(size_t size);
|
||||
void* lodepng_custom_realloc(void* ptr, size_t new_size);
|
||||
void lodepng_custom_free(void* ptr);
|
||||
|
||||
#endif // LODEPNG_ALLOC_HPP
|
||||
132
Provider/main/lv_setup.cpp
Normal file
132
Provider/main/lv_setup.cpp
Normal file
@@ -0,0 +1,132 @@
|
||||
#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";
|
||||
|
||||
SemaphoreHandle_t g_LvglMutex = nullptr;
|
||||
lv_display_t *g_LvglDisplay = nullptr;
|
||||
uint8_t *g_LvglDrawBuffer = nullptr;
|
||||
|
||||
internal void lvgl_tick_task(void *arg)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 *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");
|
||||
|
||||
g_LvglMutex = xSemaphoreCreateMutex();
|
||||
|
||||
lv_init();
|
||||
lv_tick_set_cb(my_tick_get_cb);
|
||||
|
||||
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);
|
||||
|
||||
// Initialize LodePNG custom bump allocator
|
||||
lodepng_allocator_init();
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
if (!buf1)
|
||||
{
|
||||
ESP_LOGE(kTagLvgl, "Failed to allocate LVGL draw buffer entirely.");
|
||||
return;
|
||||
}
|
||||
|
||||
g_LvglDrawBuffer = (uint8_t *)buf1;
|
||||
|
||||
// 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);
|
||||
|
||||
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);
|
||||
}
|
||||
11
Provider/main/lv_setup.hpp
Normal file
11
Provider/main/lv_setup.hpp
Normal file
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "lvgl.h"
|
||||
|
||||
extern SemaphoreHandle_t g_LvglMutex;
|
||||
extern lv_display_t *g_LvglDisplay;
|
||||
extern uint8_t *g_LvglDrawBuffer;
|
||||
|
||||
void setup_lvgl();
|
||||
@@ -1,15 +1,18 @@
|
||||
// STD Lib
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
// SDK
|
||||
#include "esp_log.h"
|
||||
#include "esp_ota_ops.h"
|
||||
#include "esp_pm.h"
|
||||
#include "esp_psram.h"
|
||||
#include "esp_system.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "nvs.h"
|
||||
#include "nvs_flash.h"
|
||||
#include "sdkconfig.h"
|
||||
#include "soc/gpio_num.h"
|
||||
|
||||
// Project headers
|
||||
@@ -21,15 +24,46 @@
|
||||
#include "led_status.cpp"
|
||||
#include "connect.cpp"
|
||||
#include "http_server.cpp"
|
||||
#include "mdns_service.cpp"
|
||||
#include "udp_logger.cpp"
|
||||
#include "lodepng_alloc.cpp"
|
||||
#include "lodepng/lodepng.cpp"
|
||||
#include "lv_setup.cpp"
|
||||
// clang-format on
|
||||
|
||||
internal constexpr bool kBlockUntilEthernetEstablished = false;
|
||||
internal const char *kTagMain = "MAIN";
|
||||
|
||||
// Global Application State Definitions
|
||||
bool g_Ethernet_Initialized = false;
|
||||
bool g_Wifi_Initialized = false;
|
||||
uint8_t g_Active_WWW_Partition = 0;
|
||||
|
||||
constexpr bool kBlockUntilEthernetEstablished = false;
|
||||
|
||||
internal void my_timer_callback(void *arg)
|
||||
{
|
||||
ESP_LOGI(kTagMain, "Timer finished! Turning Led Off...");
|
||||
destroy_led();
|
||||
}
|
||||
|
||||
extern "C" void app_main()
|
||||
{
|
||||
printf("Hello, Calendink OTA! [V1.1]\n");
|
||||
ESP_LOGI(kTagMain, "Hello, Calendink OTA! [V0.1.1]");
|
||||
ESP_LOGI(kTagMain, "PSRAM size: %d bytes", esp_psram_get_size());
|
||||
|
||||
printf("PSRAM size: %d bytes\n", esp_psram_get_size());
|
||||
#if CONFIG_PM_ENABLE
|
||||
esp_pm_config_t pm_config = {};
|
||||
pm_config.max_freq_mhz = 240;
|
||||
pm_config.min_freq_mhz = 40;
|
||||
#if CONFIG_CALENDINK_ALLOW_LIGHT_SLEEP
|
||||
pm_config.light_sleep_enable = true;
|
||||
#else
|
||||
pm_config.light_sleep_enable = false;
|
||||
#endif
|
||||
esp_pm_configure(&pm_config);
|
||||
ESP_LOGI(kTagMain, "Dynamic Power Management initialized. Light sleep %s.",
|
||||
pm_config.light_sleep_enable ? "ENABLED" : "DISABLED");
|
||||
#endif
|
||||
|
||||
httpd_handle_t web_server = NULL;
|
||||
|
||||
@@ -46,20 +80,24 @@ extern "C" void app_main()
|
||||
{
|
||||
// Read active www partition from NVS
|
||||
err = nvs_get_u8(my_handle, "www_part", &g_Active_WWW_Partition);
|
||||
if (err == ESP_OK)
|
||||
{
|
||||
ESP_LOGI(kTagMain, "NVS: Found active www partition: %d",
|
||||
g_Active_WWW_Partition);
|
||||
}
|
||||
|
||||
if (err == ESP_ERR_NVS_NOT_FOUND)
|
||||
{
|
||||
// First boot (no NVS key yet): default to www_0
|
||||
// This ensures that after a fresh USB flash (which only writes www_0),
|
||||
// we start from the correct partition.
|
||||
printf("No www_part in NVS, defaulting to 0.\n");
|
||||
ESP_LOGI(kTagMain, "No www_part in NVS, defaulting to 0.");
|
||||
g_Active_WWW_Partition = 0;
|
||||
nvs_set_u8(my_handle, "www_part", 0);
|
||||
nvs_commit(my_handle);
|
||||
}
|
||||
else if (err != ESP_OK)
|
||||
{
|
||||
printf("Error reading www_part from NVS: %s\n", esp_err_to_name(err));
|
||||
ESP_LOGE(kTagMain, "Error reading www_part from NVS: %s",
|
||||
esp_err_to_name(err));
|
||||
g_Active_WWW_Partition = 0;
|
||||
}
|
||||
|
||||
@@ -71,7 +109,78 @@ extern "C" void app_main()
|
||||
}
|
||||
else
|
||||
{
|
||||
printf("Error opening NVS handle!\n");
|
||||
ESP_LOGE(kTagMain, "Error opening NVS handle!");
|
||||
}
|
||||
|
||||
// Detect if this is the first boot after a new flash (Firmware or Frontend)
|
||||
bool is_new_flash = false;
|
||||
if (nvs_open("storage", NVS_READWRITE, &my_handle) == ESP_OK)
|
||||
{
|
||||
// 1. Check Firmware Compile Time
|
||||
char last_time[64] = {0};
|
||||
size_t time_size = sizeof(last_time);
|
||||
const char *current_time = __DATE__ " " __TIME__;
|
||||
if (nvs_get_str(my_handle, "last_fw_time", last_time, &time_size) !=
|
||||
ESP_OK ||
|
||||
strcmp(last_time, current_time) != 0)
|
||||
{
|
||||
ESP_LOGI(kTagMain, "New firmware detected! (Last: %s, Current: %s)",
|
||||
last_time[0] ? last_time : "None", current_time);
|
||||
is_new_flash = true;
|
||||
nvs_set_str(my_handle, "last_fw_time", current_time);
|
||||
}
|
||||
|
||||
// 2. Check Frontend Partition Fingerprint (www_0)
|
||||
const esp_partition_t *www0_p = esp_partition_find_first(
|
||||
ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_LITTLEFS, "www_0");
|
||||
if (www0_p)
|
||||
{
|
||||
uint8_t current_sha[32];
|
||||
if (esp_partition_get_sha256(www0_p, current_sha) == ESP_OK)
|
||||
{
|
||||
uint8_t last_sha[32] = {0};
|
||||
size_t sha_size = sizeof(last_sha);
|
||||
if (nvs_get_blob(my_handle, "www0_sha", last_sha, &sha_size) !=
|
||||
ESP_OK ||
|
||||
memcmp(last_sha, current_sha, 32) != 0)
|
||||
{
|
||||
ESP_LOGI(kTagMain, "New frontend partition detected via SHA256!");
|
||||
is_new_flash = true;
|
||||
nvs_set_blob(my_handle, "www0_sha", current_sha, 32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (is_new_flash)
|
||||
{
|
||||
nvs_commit(my_handle);
|
||||
}
|
||||
nvs_close(my_handle);
|
||||
}
|
||||
|
||||
// If we are running from FACTORY and a new flash was detected, override to
|
||||
// www_0
|
||||
{
|
||||
const esp_partition_t *running = esp_ota_get_running_partition();
|
||||
if (running != NULL &&
|
||||
running->subtype == ESP_PARTITION_SUBTYPE_APP_FACTORY)
|
||||
{
|
||||
if (is_new_flash && g_Active_WWW_Partition != 0)
|
||||
{
|
||||
ESP_LOGW(kTagMain,
|
||||
"FACTORY APP + NEW FLASH: Overriding www_part to 0 (was %d)",
|
||||
g_Active_WWW_Partition);
|
||||
g_Active_WWW_Partition = 0;
|
||||
|
||||
// Persist the override
|
||||
if (nvs_open("storage", NVS_READWRITE, &my_handle) == ESP_OK)
|
||||
{
|
||||
nvs_set_u8(my_handle, "www_part", 0);
|
||||
nvs_commit(my_handle);
|
||||
nvs_close(my_handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ESP_ERROR_CHECK(esp_event_loop_create_default());
|
||||
@@ -99,7 +208,7 @@ extern "C" void app_main()
|
||||
|
||||
if (result == ESP_ERR_INVALID_STATE)
|
||||
{
|
||||
printf("Ethernet cable not plugged in, skipping retries.\n");
|
||||
ESP_LOGW(kTagMain, "Ethernet cable not plugged in, skipping retries.");
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -116,7 +225,7 @@ extern "C" void app_main()
|
||||
|
||||
if (result != ESP_OK)
|
||||
{
|
||||
printf("Ethernet failed, trying wifi\n");
|
||||
ESP_LOGW(kTagMain, "Ethernet failed, trying wifi");
|
||||
disconnect_ethernet();
|
||||
g_Ethernet_Initialized = false;
|
||||
|
||||
@@ -153,39 +262,109 @@ extern "C" void app_main()
|
||||
|
||||
if (result != ESP_OK)
|
||||
{
|
||||
printf("Wifi failed.\n");
|
||||
ESP_LOGE(kTagMain, "Wifi failed.");
|
||||
goto shutdown;
|
||||
}
|
||||
|
||||
set_led_status(led_status::ReadyWifi);
|
||||
printf("Will use Wifi!\n");
|
||||
ESP_LOGI(kTagMain, "Will use Wifi!");
|
||||
}
|
||||
else
|
||||
{
|
||||
set_led_status(led_status::ReadyEthernet);
|
||||
printf("Will use Ethernet!\n");
|
||||
ESP_LOGI(kTagMain, "Will use Ethernet!");
|
||||
}
|
||||
|
||||
printf("Connected!\n");
|
||||
ESP_LOGI(kTagMain, "Connected! IP acquired.");
|
||||
|
||||
// Mark the current app as valid to cancel rollback
|
||||
esp_ota_mark_app_valid_cancel_rollback();
|
||||
#if !defined(NDEBUG)
|
||||
start_udp_logging(514);
|
||||
#endif
|
||||
|
||||
// Start LVGL
|
||||
ESP_LOGI(kTagMain, "ABOUT TO START LVGL");
|
||||
vTaskDelay(pdMS_TO_TICKS(500));
|
||||
setup_lvgl();
|
||||
ESP_LOGI(kTagMain, "LVGL STARTED");
|
||||
vTaskDelay(pdMS_TO_TICKS(500));
|
||||
|
||||
// Start the webserver
|
||||
web_server = start_webserver();
|
||||
|
||||
// Keep the main task alive indefinitely
|
||||
while (true)
|
||||
// Start mDNS
|
||||
start_mdns();
|
||||
|
||||
// Mark the current app as valid to cancel rollback, only if it's an OTA app
|
||||
{
|
||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||
const esp_partition_t *running = esp_ota_get_running_partition();
|
||||
if (running != NULL &&
|
||||
running->subtype >= ESP_PARTITION_SUBTYPE_APP_OTA_MIN &&
|
||||
running->subtype < ESP_PARTITION_SUBTYPE_APP_OTA_MAX)
|
||||
{
|
||||
esp_ota_mark_app_valid_cancel_rollback();
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const esp_timer_create_args_t timer_args = {.callback = &my_timer_callback,
|
||||
.arg = nullptr,
|
||||
.dispatch_method =
|
||||
ESP_TIMER_TASK,
|
||||
.name = "Led Turn Off",
|
||||
.skip_unhandled_events = true};
|
||||
|
||||
// Create and start timer if needed, or this was just stub code?
|
||||
esp_timer_handle_t timer_handle;
|
||||
if (esp_timer_create(&timer_args, &timer_handle) == ESP_OK)
|
||||
{
|
||||
esp_timer_start_once(timer_handle, 5'000'000); // 5 sec cooldown
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the main task alive indefinitely
|
||||
{
|
||||
#if CONFIG_PM_PROFILING
|
||||
int pm_dump_counter = 0;
|
||||
#endif
|
||||
while (true)
|
||||
{
|
||||
vTaskDelay(pdMS_TO_TICKS(100));
|
||||
|
||||
#if CONFIG_PM_PROFILING
|
||||
if (++pm_dump_counter >= 50)
|
||||
{ // Every 5 seconds
|
||||
pm_dump_counter = 0;
|
||||
ESP_LOGI(kTagMain, "--- PM Profiling Dump ---");
|
||||
|
||||
char *ptr = nullptr;
|
||||
size_t size = 0;
|
||||
FILE *f = open_memstream(&ptr, &size);
|
||||
if (f != nullptr)
|
||||
{
|
||||
esp_pm_dump_locks(f);
|
||||
fclose(f);
|
||||
if (ptr != nullptr)
|
||||
{
|
||||
char *saveptr;
|
||||
char *line = strtok_r(ptr, "\n", &saveptr);
|
||||
while (line != nullptr) {
|
||||
ESP_LOGI(kTagMain, "%s", line);
|
||||
line = strtok_r(nullptr, "\n", &saveptr);
|
||||
}
|
||||
free(ptr);
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
shutdown:
|
||||
printf("Shutting down.\n");
|
||||
ESP_LOGE(kTagMain, "Shutting down.");
|
||||
|
||||
if (web_server)
|
||||
{
|
||||
stop_webserver(web_server);
|
||||
stop_webserver(web_server, g_Active_WWW_Partition);
|
||||
web_server = NULL;
|
||||
}
|
||||
|
||||
|
||||
35
Provider/main/mdns_service.cpp
Normal file
35
Provider/main/mdns_service.cpp
Normal file
@@ -0,0 +1,35 @@
|
||||
#include "esp_log.h"
|
||||
#include "mdns.h"
|
||||
#include "sdkconfig.h"
|
||||
|
||||
#include "types.hpp"
|
||||
|
||||
internal const char *kTagMDNS = "MDNS";
|
||||
|
||||
void start_mdns()
|
||||
{
|
||||
// Initialize mDNS
|
||||
esp_err_t err = mdns_init();
|
||||
if (err != ESP_OK)
|
||||
{
|
||||
ESP_LOGE(kTagMDNS, "mDNS Init failed: %d", err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set mDNS hostname (from Kconfig)
|
||||
const char *hostname = CONFIG_CALENDINK_MDNS_HOSTNAME;
|
||||
mdns_hostname_set(hostname);
|
||||
ESP_LOGI(kTagMDNS, "mDNS Hostname set to: [%s.local]", hostname);
|
||||
|
||||
// Set mDNS instance name
|
||||
mdns_instance_name_set("Calendink Provider");
|
||||
|
||||
// Add HTTP service
|
||||
err = mdns_service_add(NULL, "_http", "_tcp", 80, NULL, 0);
|
||||
if (err != ESP_OK)
|
||||
{
|
||||
ESP_LOGE(kTagMDNS, "mDNS Service add failed: %d", err);
|
||||
}
|
||||
|
||||
ESP_LOGI(kTagMDNS, "mDNS Service initialized with hostname [%s.local]", hostname);
|
||||
}
|
||||
@@ -5,14 +5,24 @@
|
||||
#include "types.hpp"
|
||||
#include "user.hpp"
|
||||
|
||||
enum task_period_t : uint8
|
||||
{
|
||||
PERIOD_MORNING = 0x01, // bit 0
|
||||
PERIOD_AFTERNOON = 0x02, // bit 1
|
||||
PERIOD_EVENING = 0x04, // bit 2
|
||||
PERIOD_ALL_DAY = 0x07 // all bits
|
||||
};
|
||||
|
||||
struct task_t
|
||||
{
|
||||
uint16 id; // Auto-assigned (1–65535, 0 = empty slot)
|
||||
uint8 user_id; // Owner (matches user_t.id)
|
||||
char title[64]; // Task description
|
||||
int64 due_date; // Unix timestamp (seconds)
|
||||
bool completed; // Done flag
|
||||
bool active; // Slot in use
|
||||
char title[64]; // Task description
|
||||
int64 due_date; // Unix timestamp (seconds) - used when recurrence is 0
|
||||
uint16 id; // Auto-assigned (1–65535, 0 = empty slot)
|
||||
uint8 user_id; // Owner (matches user_t.id)
|
||||
uint8 recurrence; // Bitmask: bit0=Mon, bit1=Tue, ..., bit6=Sun. 0=none
|
||||
uint8 period : 3; // Bitmask: bit0=Morning, bit1=Afternoon, bit2=Evening
|
||||
bool completed : 1; // Done flag
|
||||
bool active : 1; // Slot in use
|
||||
};
|
||||
|
||||
constexpr int MAX_TASKS = 32;
|
||||
|
||||
71
Provider/main/udp_logger.cpp
Normal file
71
Provider/main/udp_logger.cpp
Normal file
@@ -0,0 +1,71 @@
|
||||
#include <string.h>
|
||||
|
||||
#include "esp_log.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "lwip/sockets.h"
|
||||
|
||||
#include "types.hpp"
|
||||
|
||||
internal constexpr char kTagUdpLogger[] = "UDP_LOG";
|
||||
|
||||
internal int s_udp_log_socket = -1;
|
||||
internal struct sockaddr_in s_udp_dest_addr;
|
||||
internal vprintf_like_t s_original_vprintf = NULL;
|
||||
|
||||
internal int udp_logging_vprintf(const char *fmt, va_list ap)
|
||||
{
|
||||
char buf[512];
|
||||
va_list ap_copy;
|
||||
va_copy(ap_copy, ap);
|
||||
int len = vsnprintf(buf, sizeof(buf) - 1, fmt, ap_copy);
|
||||
va_end(ap_copy);
|
||||
|
||||
if (len > 0)
|
||||
{
|
||||
if (len >= sizeof(buf))
|
||||
{
|
||||
len = sizeof(buf) - 1;
|
||||
}
|
||||
buf[len] = '\0';
|
||||
if (s_udp_log_socket >= 0)
|
||||
{
|
||||
sendto(s_udp_log_socket, buf, len, 0, (struct sockaddr *)&s_udp_dest_addr,
|
||||
sizeof(s_udp_dest_addr));
|
||||
}
|
||||
}
|
||||
return s_original_vprintf(fmt, ap);
|
||||
}
|
||||
|
||||
internal void start_udp_logging(int port)
|
||||
{
|
||||
s_udp_log_socket = socket(AF_INET, SOCK_DGRAM, IPPROTO_IP);
|
||||
if (s_udp_log_socket < 0)
|
||||
{
|
||||
ESP_LOGE(kTagUdpLogger, "Unable to create socket: errno %d", errno);
|
||||
return;
|
||||
}
|
||||
|
||||
int opt_val = 1;
|
||||
setsockopt(s_udp_log_socket, SOL_SOCKET, SO_BROADCAST, &opt_val,
|
||||
sizeof(opt_val));
|
||||
|
||||
s_udp_dest_addr.sin_family = AF_INET;
|
||||
s_udp_dest_addr.sin_port = htons(port);
|
||||
|
||||
#ifdef CONFIG_CALENDINK_UDP_LOG_TARGET_IP
|
||||
if (strlen(CONFIG_CALENDINK_UDP_LOG_TARGET_IP) > 1)
|
||||
{
|
||||
s_udp_dest_addr.sin_addr.s_addr =
|
||||
inet_addr(CONFIG_CALENDINK_UDP_LOG_TARGET_IP);
|
||||
}
|
||||
else
|
||||
{
|
||||
s_udp_dest_addr.sin_addr.s_addr = inet_addr("255.255.255.255");
|
||||
}
|
||||
#else
|
||||
s_udp_dest_addr.sin_addr.s_addr = inet_addr("255.255.255.255");
|
||||
#endif
|
||||
|
||||
s_original_vprintf = esp_log_set_vprintf(&udp_logging_vprintf);
|
||||
ESP_LOGI(kTagUdpLogger, "UDP logging broadcast started on port %d", port);
|
||||
}
|
||||
@@ -1,3 +1,102 @@
|
||||
CONFIG_IDF_TARGET="esp32s3"
|
||||
CONFIG_PARTITION_TABLE_CUSTOM=y
|
||||
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
|
||||
CONFIG_PARTITION_TABLE_FILENAME="partitions.csv"
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
|
||||
CONFIG_LWIP_MAX_SOCKETS=32
|
||||
CONFIG_PM_ENABLE=y
|
||||
CONFIG_FREERTOS_USE_TICKLESS_IDLE=y
|
||||
|
||||
# W5500 Ethernet Configuration
|
||||
CONFIG_ETH_USE_SPI_ETHERNET=y
|
||||
CONFIG_ETH_SPI_ETHERNET_W5500=y
|
||||
CONFIG_ETHERNET_SPI_USE_W5500=1
|
||||
CONFIG_ETHERNET_SPI_DEV0_W5500=y
|
||||
CONFIG_ETHERNET_SPI_DEV0_ID=2
|
||||
CONFIG_ETHERNET_SPI_HOST=1
|
||||
CONFIG_ETHERNET_SPI_SCLK_GPIO=13
|
||||
CONFIG_ETHERNET_SPI_MOSI_GPIO=11
|
||||
CONFIG_ETHERNET_SPI_MISO_GPIO=12
|
||||
CONFIG_ETHERNET_SPI_CLOCK_MHZ=32
|
||||
CONFIG_ETHERNET_SPI_CS0_GPIO=14
|
||||
CONFIG_ETHERNET_SPI_INT0_GPIO=10
|
||||
CONFIG_ETHERNET_SPI_PHY_RST0_GPIO=9
|
||||
CONFIG_ETHERNET_SPI_PHY_ADDR0=1
|
||||
CONFIG_ETHERNET_SPI_AUTOCONFIG_MAC_ADDR0=y
|
||||
CONFIG_ETHERNET_SPI_POLLING0_MS=0
|
||||
|
||||
# Enable PSRAM
|
||||
CONFIG_SPIRAM=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_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
|
||||
CONFIG_LV_USE_CLIB_MALLOC=y
|
||||
CONFIG_LV_USE_BUILTIN_STRING=n
|
||||
CONFIG_LV_USE_CLIB_STRING=y
|
||||
CONFIG_LV_USE_BUILTIN_SPRINTF=n
|
||||
CONFIG_LV_USE_CLIB_SPRINTF=y
|
||||
|
||||
# LVGL Headless / Optimization Configurations
|
||||
# Disable default examples and demos that waste flash
|
||||
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=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=n
|
||||
CONFIG_LV_DRAW_SW_SUPPORT_AL88=n
|
||||
CONFIG_LV_DRAW_SW_SUPPORT_A8=n
|
||||
CONFIG_LV_DRAW_SW_SUPPORT_I1=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
|
||||
CONFIG_LV_USE_WIN=n
|
||||
CONFIG_LV_USE_TABVIEW=n
|
||||
CONFIG_LV_USE_TILEVIEW=n
|
||||
CONFIG_LV_USE_LIST=n
|
||||
CONFIG_LV_USE_MENU=n
|
||||
CONFIG_LV_USE_MSGBOX=n
|
||||
CONFIG_LV_USE_SPINBOX=n
|
||||
CONFIG_LV_USE_SPINNER=n
|
||||
CONFIG_LV_USE_KEYBOARD=n
|
||||
CONFIG_LV_USE_CALENDAR=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=y
|
||||
CONFIG_LV_USE_TEXTAREA=n
|
||||
CONFIG_LV_USE_TABLE=n
|
||||
|
||||
# Disable animations to save code and RAM
|
||||
CONFIG_LV_USE_ANIMIMG=n
|
||||
|
||||
# Disable theme transitions (we just want static renders)
|
||||
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
|
||||
|
||||
51
Provider/tdd/concurrent_requests.md
Normal file
51
Provider/tdd/concurrent_requests.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Concurrent Requests Support for ESP32-S3 Provider
|
||||
|
||||
**Authored by Antigravity**
|
||||
**Date:** 2026-03-08
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal (What)
|
||||
|
||||
Enable the ESP32-S3 HTTP server to gracefully handle multiple concurrent web clients. Currently, if one browser connects, it consumes all available server sockets with "keep-alive" connections and blocks the `static_file_handler` via a single shared scratch buffer. The goal is to allow up to 5-10 clients (e.g., PCs, tablets, phones) on the local network to open the dashboard simultaneously without hanging, and to add a frontend safeguard (a loading spinner) to improve the user experience during slow network responses.
|
||||
|
||||
## 2. Rationale (Why)
|
||||
|
||||
The default ESP-IDF HTTP server (`esp_http_server`) is configured for minimal resource usage:
|
||||
- `max_open_sockets = 7`: A single modern browser tries to open up to 6 connections simultaneously to fetch HTML, CSS, JS, and API data.
|
||||
- `lru_purge_enable = false`: When a browser finishes loading, it keeps those 6 sockets open (Keep-Alive) for future requests. If a second device tries to connect, it is rejected because the server has no free sockets, even though the first device's sockets are idle.
|
||||
|
||||
Furthermore, the current `static_file_handler` relies on a single shared `rest_context->scratch` buffer allocated globally. If the server is modified to multiplex handlers concurrently, this shared buffer would be overwritten by competing requests, causing data corruption in the served files.
|
||||
|
||||
## 3. Chosen Approach (How)
|
||||
|
||||
### 3.1 Backend Configuration (ESP-IDF)
|
||||
Instead of implementing complex multi-threading (spawning multiple FreeRTOS worker tasks), we will leverage the HTTP server's built-in event loop multiplexing by tuning its configuration:
|
||||
1. **Increase LwIP Socket Limit**: `LWIP_MAX_SOCKETS` is set to `32` in `sdkconfig.defaults`.
|
||||
2. **Increase HTTP Socket Limit**: Set `config.max_open_sockets = 24`. This deliberately reserves `8` sockets for LwIP internals and outwards connections, guaranteeing the network stack always has headroom to accept a TCP handshake from a new client.
|
||||
3. **Enable Stale Socket Purging**: Set `config.lru_purge_enable = true`. This is the critical fix. When the 24 socket limit is reached and a new device attempts to connect, the server will intentionally drop the oldest idle keep-alive socket to make room, allowing the new device to load the page seamlessly.
|
||||
|
||||
### 3.2 Backend Scratch Buffer Pooling
|
||||
To safely support multiplexed file serving without heavy `malloc`/`free` overhead on every request, we will replace the single shared scratch buffer with a **Static Shared Buffer Pool**:
|
||||
- We allocated a global struct with a fixed array of `MAX_SCRATCH_BUFFERS = 10`.
|
||||
- When `static_file_handler` begins, it will request an available chunk from the pool, allocating a 4KB chunk on the heap only the first time it is used.
|
||||
- When the handler finishes, the chunk is marked as available yielding it for the next request.
|
||||
- This provides isolation between up to 10 active transmission connections while minimizing heap fragmentation compared to per-request `mallocs`.
|
||||
|
||||
### 3.3 Frontend Safety (Loading Spinner)
|
||||
Even with backend improvements, network latency or heavy load might cause delays. We will implement a global request tracker to improve perceived performance:
|
||||
- A new Svelte writable store `pendingRequests` will track the count of active API calls.
|
||||
- `api.js` will wrap the native `fetch` in a `trackedFetch` function that increments/decrements this store.
|
||||
- A new `<Spinner />` component will be rendered at the root (`App.svelte`). It will overlay the screen when `$pendingRequests > 0`, optionally with a small delay (e.g., 300ms) to prevent flashing on fast requests.
|
||||
|
||||
## 4. Design Decisions & Trade-offs
|
||||
|
||||
| Approach | Pros | Cons | Decision |
|
||||
|---|---|---|---|
|
||||
| **True Multi-Threading (Multiple Worker Tasks)** | Can process files fully in parallel on both cores. | High memory overhead for stack space per task; over-engineered for simple static file serving. | **Rejected**. Relying on the event loop's multiplexing is sufficient for local network use cases. |
|
||||
| **Per-Request `malloc` / `free`** | Simplest way to isolate scratch buffers. | High heap fragmentation risk; computationally expensive on every HTTP request. | **Rejected**. |
|
||||
| **Fixed Pool (10 buffers)** | Low overhead; memory footprint only grows organically to the maximum concurrent need limit (10 * 4KB = 40KB) and stabilizes. | Strict limit on how many connections can be actively transmitting data at the exact same millisecond. | **Selected**. Best balance of performance and memory safety. |
|
||||
|
||||
## 5. Potential Future Improvements
|
||||
- If the `realloc` pool grows too large during an unexpected spike, we could implement a cleanup routine that periodically shrinks the pool back to a baseline size when the server is idle.
|
||||
- If true parallel processing is needed later, the HTTP server's `config.core_id` and `async_workers` could be utilized, but this requires ensuring all API handlers are perfectly thread-safe.
|
||||
67
Provider/tdd/device_screens.md
Normal file
67
Provider/tdd/device_screens.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Device Screens Management
|
||||
|
||||
**Authored by Antigravity**
|
||||
**Date:** 2026-03-15
|
||||
|
||||
---
|
||||
|
||||
## 1. What (Goal)
|
||||
|
||||
The goal is to enable the Calendink Provider to act as a backend screen generator for dump clients (like e-ink devices) connected to the network.
|
||||
|
||||
We need to implement a system that:
|
||||
- Allows devices to register themselves via their MAC address.
|
||||
- Provides a Frontend "Device Manager" interface where a user can upload and assign a custom LVGL XML string to each registered device.
|
||||
- Generates a custom PNG image on the fly when a device requests its screen, by parsing its assigned XML string using the LVGL XML runtime.
|
||||
- Temporarily stores this data in RAM (as per the current project architecture), deferring persistent storage (like SQLite) to a later phase.
|
||||
|
||||
## 2. Why (Reasoning)
|
||||
|
||||
Dumb e-ink clients (like the TRMNL) typically cannot run complex UI frameworks or parse rich data formats like JSON to render screens themselves. They simply download and display a static image buffer.
|
||||
|
||||
By having the ESP32-S3 Provider generate these images using LVGL's headless rendering capabilities:
|
||||
1. **Centralized Configuration:** The user can design and assign screens for all their devices from a single web dashboard.
|
||||
2. **Dynamic UI:** Using the new `LV_USE_XML` feature in LVGL 9.4+, the layout is completely decoupled from the C++ firmware. Users can radically change what a display looks like by simply uploading a new XML string via the web interface, without needing to recompile or flash the ESP32.
|
||||
3. **Payload Efficiency:** Returning a URL `{"image_url": "/api/devices/screen.png"}` in the JSON response instead of a base64 encoded binary prevents massive memory spikes and reduces transmission time for the constrained devices.
|
||||
4. **Consistency:** Storing user settings in BSS static arrays aligns with the existing non-persistent data models (like Tasks). It avoids heap fragmentation risks on the ESP32 until a proper SQLite database is integrated.
|
||||
|
||||
## 3. How (Implementation Details)
|
||||
|
||||
### Backend Storage & State
|
||||
Similar to the `Todo` app from `tdd/todo_list.md`, we use static arrays in the BSS segment to manage devices. The structure holds the device MAC, an `active` flag, and a statically allocated string buffer (2048 bytes) to store the uploaded LVGL XML.
|
||||
|
||||
```cpp
|
||||
struct Device {
|
||||
char mac[18];
|
||||
bool active;
|
||||
char xml_layout[2048];
|
||||
};
|
||||
extern Device g_Devices[8];
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
The following REST endpoints handle the device lifecycle and image generation:
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/devices` | Returns a JSON array of all active devices, including whether they have a custom XML layout set. |
|
||||
| `POST` | `/api/devices/register` | Accepts `{"mac": "..."}`. Claims a slot in `g_Devices` if not already registered. |
|
||||
| `POST` | `/api/devices/layout` | Accepts `{"mac": "...", "xml": "<lvgl xml>"}`. Stores the XML string in the device's struct buffer. |
|
||||
| `GET` | `/api/devices/screen?mac=XX` | Returns `{"image_url": "/api/devices/screen.png?mac=XX"}` (TRMNL API pattern). |
|
||||
| `GET` | `/api/devices/screen.png?mac=XX` | Core rendering endpoint. Claims `g_LvglMutex`, clears the screen, parses the `xml_layout` buffer using `lv_xml_create()`, forces a refresh `lv_refr_now()`, encodes the buffer to PNG using `lodepng`, and streams the response. |
|
||||
|
||||
### 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.
|
||||
75
Provider/tdd/lvgl_image_generation.md
Normal file
75
Provider/tdd/lvgl_image_generation.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# LVGL Image Generation Architecture
|
||||
|
||||
**Authored by Antigravity**
|
||||
**Date:** 2026-03-14
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Integrate the Light and Versatile Graphics Library (LVGL) into the Calendink Provider to generate UI images server-side. The ESP32-S3 will render screens into a memory buffer and serve the resulting image (PNG) directly over the HTTP REST API to clients.
|
||||
|
||||
This allows the Provider to act as a "smart renderer" for dump clients like e-ink displays or web widgets that only have the capability to fetch and display static images. The display resolution (default 800x480) and color depth (4-level grayscale) are parameterized via Kconfig to support future hardware changes.
|
||||
|
||||
## 2. Chosen Approach
|
||||
|
||||
### 2.1. Headless LVGL Display Driver
|
||||
LVGL is typically used to drive physical displays via SPI/I2C/RGB interfaces. For this use case, we will configure a **virtual (headless) display**:
|
||||
- A display instance (`lv_display_t`) will be created without a physical flush callback (or a dummy one that does nothing).
|
||||
- We will allocate a full-screen frame buffer (e.g., 800x480 at 8-bit grayscale). While small enough to fit in SRAM (~384 KB), we will use **PSRAM** to avoid memory pressure on the system.
|
||||
- When an image is requested, we will force LVGL to update the screen (`lv_refr_now()`) and then read the raw pixel data directly from the draw buffer.
|
||||
|
||||
### 2.2. Image Encoding (PNG)
|
||||
Raw pixel data is not web-friendly. To serve the image over HTTP, we will encode it as a **PNG** image.
|
||||
- **Why PNG?** PNG is smaller over the network than BMP and natively supports lossless grayscale compression. Given the target of 4-level grayscale, a PNG will compress exceptionally well.
|
||||
- **Encoder:** We will use `lodepng`, a lightweight single-file C library, to compress the raw LVGL buffer into a PNG on the fly.
|
||||
- **Color Depth:** The target is **4-level grayscale**. We will map LVGL's output to a 2-bit grayscale PNG to minimize the payload size.
|
||||
- **Parameterization:** Resolution (e.g., 800 width, 480 height) is configurable via Kconfig (`CONFIG_DISPLAY_WIDTH`, `CONFIG_DISPLAY_HEIGHT`) so it can be easily changed for different e-ink displays.
|
||||
|
||||
### 2.3. Thread Safety and Concurrency
|
||||
LVGL is **not thread-safe**. Since the HTTP server runs its API handlers in different FreeRTOS tasks (from the `httpd` thread pool), we must protect LVGL with a **Mutex**.
|
||||
- All LVGL initialization, UI setup (`lv_obj_create`, etc.), and the periodic `lv_timer_handler()` will take the `g_LvglMutex`.
|
||||
- The `GET /api/display/image` API endpoint will acquire `g_LvglMutex`, draw the screen, encode the BMP header, transmit the buffer, and then release the mutex.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
### 3.1. File Structure
|
||||
|
||||
```
|
||||
Provider/main/
|
||||
├── lv_setup.hpp # LVGL initialization and mutex declarations
|
||||
├── lv_setup.cpp # Driver setup, PSRAM buffer allocation, LVGL tick task
|
||||
├── api/display/
|
||||
│ ├── image.cpp # GET /api/display/image handler (BMP streamer)
|
||||
│ └── unity.cpp # Aggregator for display endpoints
|
||||
├── http_server.cpp # Modified to include api/display/unity.cpp
|
||||
└── main.cpp # Starts LVGL task before HTTP server
|
||||
```
|
||||
|
||||
### 3.2. Data Flow
|
||||
|
||||
1. **Client** makes a `GET /api/display/image` request.
|
||||
2. **`image.cpp` handler** intercepts the request and takes the `g_LvglMutex`.
|
||||
3. The handler forces LVGL to finish any pending rendering.
|
||||
4. The handler uses `lodepng` to compress the raw frame buffer into a PNG payload.
|
||||
5. The handler sends the PNG via `httpd_resp_send()`.
|
||||
6. The handler frees the PNG payload buffer and releases `g_LvglMutex`.
|
||||
7. The HTTP response completes.
|
||||
|
||||
## 4. Design Decisions & Trade-offs
|
||||
|
||||
| Decision | Trade-off | Rationale |
|
||||
|---|---|---|
|
||||
| **PSRAM Allocation** | Slower access than SRAM | A full framebuffer for high-res displays exceeds internal SRAM. PSRAM is required, and since we only read/write it occasionally for HTTP, the speed penalty is negligible compared to network latency. |
|
||||
| **PNG over BMP** | More CPU overhead | PNG compression takes CPU cycles and temporary RAM, but significantly reduces network payload, which is better for web clients and saves transmission time. |
|
||||
| **Pull vs Push** | Requires polling | The client must actively request the image. This is standard for web, but means the client won't know instantly if the UI state changes unless using WebSockets/SSE (future scope). |
|
||||
| **Synchronous Render** | Blocks HTTP task | The HTTP handler waits for LVGL to finish drawing and PNG encoding to complete. Since LVGL renders very quickly in memory, this block should be acceptable. |
|
||||
|
||||
## 5. Next Steps
|
||||
1. Add `lvgl/lvgl` to `idf_component.yml`.
|
||||
2. Configure LVGL in `sdkconfig` to use custom memory allocation (routed to PSRAM).
|
||||
3. Implement `lv_setup.cpp` and `api/display/image.cpp`.
|
||||
4. Draw a sample UI on the virtual display.
|
||||
|
||||
---
|
||||
*Created by Antigravity - Last Updated: 2026-03-14*
|
||||
Reference in New Issue
Block a user