Compare commits

..

2 Commits

83 changed files with 424 additions and 15300 deletions

6
.gitignore vendored
View File

@@ -93,9 +93,3 @@ external/*
# Agent Tasks # Agent Tasks
Provider/AgentTasks/ Provider/AgentTasks/
# OTA files
*.bundle
#png
*.png

View File

@@ -1,124 +0,0 @@
# 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

View File

@@ -1,13 +0,0 @@
---
trigger: model_decision
description: When working on a TDD (technical design document)
---
TDD Should be added to the /tdd folder.
When writing a TDD, please write it so that it can be read by human or agent ai.
Please start by writing that it was authored by an ai agent and write your actual agent name.
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.

View File

@@ -1,49 +0,0 @@
---
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
```

View File

@@ -1,92 +0,0 @@
# 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.

View File

@@ -1,59 +0,0 @@
# Universal OTA Bundle
The Universal OTA Bundle allows you to update both the **Firmware** and the **Frontend** of the Calendink Provider in a single operation. This ensures that your UI and backend logic are always in sync.
## 1. How it Works
The bundle is a custom `.bundle` file that contains:
1. A **12-byte header** (Magic `BNDL`, FW size, UI size).
2. The **Firmware binary** (`Provider.bin`).
3. The **Frontend LittleFS binary** (`www_v*.bin`).
The ESP32 backend streams this file, writing the firmware to the next OTA slot and the frontend to the inactive `www` partition. It only commits the update if both parts are written successfully.
## 2. Prerequisites
- You have a working [Frontend Build Environment](build_frontend.md).
- You have the [ESP-IDF SDK](https://docs.espressif.com/projects/esp-idf/en/latest/esp32/get-started/index.html) installed for firmware compilation.
## 3. Creating a Bundle
To create a new bundle, follow these steps in order:
### Step A: Build the Frontend
Inside the `frontend/` directory:
```bash
npm run build:esp32
```
### Step B: Build the Firmware
From the project **root** directory:
```bash
idf.py build
```
### Step C: Generate the Bundle
Inside the `frontend/` directory:
```bash
npm run ota:bundle
```
> [!NOTE]
> `npm run ota:bundle` now automatically runs `npm run ota:package` first to ensure the latest Svelte build is turned into a LittleFS image before bundling.
The output will be saved in `frontend/bundles/` with a name like `universal_v0.1.11.bundle`.
## 4. Flashing the Bundle
1. Open the Calendink Provider Dashboard in your browser.
2. Navigate to the **System Updates** section.
3. Click the **Universal Bundle** button.
4. Drag and drop your `.bundle` file into the upload area.
5. Click **Update**.
The device will reboot once the upload is complete. You can verify the update by checking the version numbers and the UI changes (like the number of rockets in the header!).
## 5. Troubleshooting
- **"Invalid bundle magic"**: Ensure you are uploading a `.bundle` file, not a `.bin`.
- **"Firmware part is corrupted"**: The bundle was likely created while the firmware build was incomplete or failed.
- **Old UI appearing**: Ensure you ran `npm run build:esp32` *before* `npm run ota:bundle`.

View File

@@ -80,25 +80,3 @@ Once the backend supports it (Phase 2+), you can update the frontend without usi
- Go to the **Frontend Update** section. - Go to the **Frontend Update** section.
- Select the `www.bin` file and click **Flash**. - Select the `www.bin` file and click **Flash**.
- The device will automatically write to the inactive partition and reboot. - The device will automatically write to the inactive partition and reboot.
## 6. Universal OTA Bundle
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.

View File

@@ -1,23 +0,0 @@
# 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`.

View File

@@ -1,115 +0,0 @@
# 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)]"
```

View File

@@ -121,16 +121,6 @@ dependencies:
registry_url: https://components.espressif.com/ registry_url: https://components.espressif.com/
type: service type: service
version: 3.0.3 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: idf:
source: source:
type: idf type: idf
@@ -145,20 +135,11 @@ dependencies:
registry_url: https://components.espressif.com/ registry_url: https://components.espressif.com/
type: service type: service
version: 1.20.4 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: direct_dependencies:
- espressif/ethernet_init - espressif/ethernet_init
- espressif/led_strip - espressif/led_strip
- espressif/mdns
- idf - idf
- joltwallet/littlefs - joltwallet/littlefs
- lvgl/lvgl manifest_hash: 21816aafdbbde14bfaaaabda34966eec49ae1e6f551bc16fe3ff74370b0fb54c
manifest_hash: 0c7ea64d32655d6be4f726b7946e96626bce0de88c2dc8f091bb5e365d26a374
target: esp32s3 target: esp32s3
version: 2.0.0 version: 2.0.0

View File

@@ -1,20 +0,0 @@
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)

View File

@@ -1,2 +1,2 @@
VITE_API_BASE=http://calendink.local/ VITE_API_BASE=http://192.168.50.216
MKLITTLEFS_PATH=W:\Classified\Calendink\Provider\build\littlefs_py_venv\Scripts\littlefs-python.exe MKLITTLEFS_PATH=W:\Classified\Calendink\Provider\build\littlefs_py_venv\Scripts\littlefs-python.exe

View File

@@ -8,9 +8,6 @@
"build": "vite build", "build": "vite build",
"build:esp32": "vite build && node scripts/gzip.js", "build:esp32": "vite build && node scripts/gzip.js",
"ota:package": "node scripts/package.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" "preview": "vite preview"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,79 +0,0 @@
/**
* Universal OTA Bundle Creator
* Packs FW (Provider.bin) and WWW (www_v*.bin) into a single .bundle file.
*/
import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = resolve(__dirname, '..');
const providerRoot = resolve(projectRoot, '..');
const binDir = resolve(projectRoot, 'bin');
// Paths
const fwFile = resolve(providerRoot, 'build', 'Provider.bin');
const bundleDir = resolve(projectRoot, 'bundles');
if (!existsSync(bundleDir)) {
mkdirSync(bundleDir, { recursive: true });
}
console.log('--- Universal Bundle Packaging ---');
// 1. Find the latest www.bin with proper semantic version sorting
const binFiles = readdirSync(binDir)
.filter(f => f.startsWith('www_v') && f.endsWith('.bin'))
.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);
});
if (binFiles.length === 0) {
console.error('Error: No www_v*.bin found in frontend/bin/. Run "npm run ota:package" first.');
process.exit(1);
}
const wwwFile = resolve(binDir, binFiles[0]);
if (!existsSync(fwFile)) {
console.error(`Error: Firmware binary not found at ${fwFile}. Run "idf.py build" first.`);
process.exit(1);
}
try {
console.log(`Packing Firmware: ${fwFile}`);
console.log(`Packing Frontend: ${wwwFile}`);
const fwBuf = readFileSync(fwFile);
const wwwBuf = readFileSync(wwwFile);
// Create 12-byte header
// Magic: BNDL (4 bytes)
// FW Size: uint32 (4 bytes)
// WWW Size: uint32 (4 bytes)
const header = Buffer.alloc(12);
header.write('BNDL', 0);
header.writeUInt32LE(fwBuf.length, 4);
header.writeUInt32LE(wwwBuf.length, 8);
const bundleBuf = Buffer.concat([header, fwBuf, wwwBuf]);
const outputFile = resolve(bundleDir, `universal_v${binFiles[0].replace('www_v', '').replace('.bin', '')}.bundle`);
writeFileSync(outputFile, bundleBuf);
console.log('-------------------------------');
console.log(`Success: Bundle created at ${outputFile}`);
console.log(`Total size: ${(bundleBuf.length / 1024 / 1024).toFixed(2)} MB`);
console.log('-------------------------------');
} catch (e) {
console.error('Error creating bundle:', e.message);
process.exit(1);
}

View File

@@ -1,128 +0,0 @@
/**
* 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();

View File

@@ -1,12 +1,6 @@
<script> <script>
import { getSystemInfo, reboot, getOTAStatus, getUpcomingTasks } from "./lib/api.js"; import { getSystemInfo, reboot } from "./lib/api.js";
import { formatUptime, formatBytes, formatRelativeDate, isOverdue } from "./lib/utils.js";
import OTAUpdate from "./lib/OTAUpdate.svelte"; 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'} */ /** @type {'loading' | 'ok' | 'error' | 'rebooting'} */
let status = $state("loading"); let status = $state("loading");
@@ -14,10 +8,6 @@
let showRebootConfirm = $state(false); let showRebootConfirm = $state(false);
let isRecovering = $state(false); let isRecovering = $state(false);
/** @type {'dashboard' | 'tasks' | 'users' | 'devices'} */
let currentView = $state("dashboard");
let mobileMenuOpen = $state(false);
let systemInfo = $state({ let systemInfo = $state({
chip: "—", chip: "—",
freeHeap: 0, freeHeap: 0,
@@ -26,40 +16,30 @@
connection: "—", connection: "—",
}); });
let otaStatus = $state({ /** Format uptime seconds into human-readable string */
partitions: [], function formatUptime(seconds) {
active_partition: "—", const d = Math.floor(seconds / 86400);
running_firmware_label: "—" const h = Math.floor((seconds % 86400) / 3600);
}); const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
let upcomingData = $state({ users: [] }); const parts = [];
if (d > 0) parts.push(`${d}d`);
let isFetching = false; // mutex, not reactive if (h > 0) parts.push(`${h}h`);
let lastKnownFirmware = null; if (m > 0) parts.push(`${m}m`);
let lastKnownSlot = null; parts.push(`${s}s`);
async function fetchAll(silent = false) { return parts.join(" ");
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; /** Format bytes into human-readable string */
systemInfo = sys; function formatBytes(bytes) {
otaStatus = ota; if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
upcomingData = upcoming; if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
}
async function fetchInfo() {
try {
systemInfo = await getSystemInfo();
status = "ok"; status = "ok";
errorMsg = ""; errorMsg = "";
} catch (e) { } catch (e) {
@@ -67,8 +47,6 @@
status = "error"; status = "error";
errorMsg = e.message || "Connection failed"; errorMsg = e.message || "Connection failed";
} }
} finally {
isFetching = false;
} }
} }
@@ -84,10 +62,7 @@
} }
$effect(() => { $effect(() => {
fetchAll(); fetchInfo();
// Poll for status updates every 5 seconds (silently to avoid flashing)
const interval = setInterval(() => fetchAll(true), 5000);
return () => clearInterval(interval);
}); });
// Resilient recovery polling: Only poll when we are waiting for a reboot // Resilient recovery polling: Only poll when we are waiting for a reboot
@@ -118,143 +93,70 @@
]); ]);
</script> </script>
<div class="flex min-h-screen bg-bg-primary"> <main class="min-h-screen bg-bg-primary flex items-center justify-center p-4">
<Sidebar <div class="w-full max-w-xl space-y-4">
{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>
<div class="w-full max-w-6xl mx-auto space-y-8">
<!-- Header --> <!-- Header -->
<div class="text-center"> <div class="text-center mb-6">
<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> <p class="text-text-secondary text-sm">ESP32-S3 System Dashboard v{__APP_VERSION__}</p>
</div>
<!-- Status Badge --> <!-- Status Badge -->
<div class="flex justify-center mt-4"> <div class="flex justify-center">
{#if status === "loading"} {#if status === "loading"}
<div class="inline-flex items-center gap-2 bg-accent/10 border border-accent/20 rounded-full px-4 py-1.5 text-xs text-accent"> <div
class="inline-flex items-center gap-2 bg-accent/10 border border-accent/20 rounded-full px-4 py-2 text-sm text-accent"
>
<span class="w-2 h-2 rounded-full bg-accent animate-pulse"></span> <span class="w-2 h-2 rounded-full bg-accent animate-pulse"></span>
<span>Connecting...</span> <span>Connecting to ESP32...</span>
</div> </div>
{:else if status === "ok"} {:else if status === "ok"}
<div class="inline-flex items-center gap-2 bg-success/10 border border-success/20 rounded-full px-4 py-1.5 text-xs text-success"> <div
class="inline-flex items-center gap-2 bg-success/10 border border-success/20 rounded-full px-4 py-2 text-sm text-success"
>
<span class="w-2 h-2 rounded-full bg-success animate-pulse"></span> <span class="w-2 h-2 rounded-full bg-success animate-pulse"></span>
<span>Connected</span> <span>Connected</span>
</div> </div>
{:else if status === "rebooting"} {:else if status === "rebooting"}
<div class="inline-flex items-center gap-2 bg-amber-500/10 border border-amber-500/20 rounded-full px-4 py-1.5 text-xs text-amber-400"> <div
class="inline-flex items-center gap-2 bg-amber-500/10 border border-amber-500/20 rounded-full px-4 py-2 text-sm text-amber-400"
>
<span class="w-2 h-2 rounded-full bg-amber-400 animate-pulse"></span> <span class="w-2 h-2 rounded-full bg-amber-400 animate-pulse"></span>
<span>Rebooting...</span> <span>Rebooting...</span>
</div> </div>
{:else} {:else}
<div class="inline-flex items-center gap-2 bg-danger/10 border border-danger/20 rounded-full px-4 py-1.5 text-xs text-danger"> <div
class="inline-flex items-center gap-2 bg-danger/10 border border-danger/20 rounded-full px-4 py-2 text-sm text-danger"
>
<span class="w-2 h-2 rounded-full bg-danger"></span> <span class="w-2 h-2 rounded-full bg-danger"></span>
<span>Offline — {errorMsg}</span> <span>Offline — {errorMsg}</span>
</div> </div>
{/if} {/if}
</div> </div>
</div>
{#if currentView === 'dashboard'}
<!-- Dashboard View -->
<!-- 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">
📋 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 routineTasks.length === 0}
<p class="text-[11px] text-text-secondary italic">No routine tasks</p>
{:else}
{#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>
{/if}
{/each}
{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- 2-Column Grid Layout -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 items-start">
<!-- Left Column: System Info & Partition Table -->
<div class="space-y-8">
<!-- System Info Card --> <!-- System Info Card -->
<div class="bg-bg-card border border-border rounded-xl overflow-hidden shadow-xl"> <div class="bg-bg-card border border-border rounded-xl overflow-hidden">
<div class="px-5 py-3 border-b border-border"> <div class="px-5 py-3 border-b border-border">
<h2 class="text-sm font-semibold text-text-primary uppercase tracking-wider"> <h2
class="text-sm font-semibold text-text-primary uppercase tracking-wider"
>
System Info System Info
</h2> </h2>
</div> </div>
<div class="divide-y divide-border"> <div class="divide-y divide-border">
{#each infoItems as item} {#each infoItems as item}
<div class="flex items-center justify-between px-5 py-3 hover:bg-bg-card-hover transition-colors"> <div
class="flex items-center justify-between px-5 py-3 hover:bg-bg-card-hover transition-colors"
>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="text-base">{item.icon}</span> <span class="text-base">{item.icon}</span>
<span class="text-sm text-text-secondary">{item.label}</span> <span class="text-sm text-text-secondary">{item.label}</span>
</div> </div>
<span class="text-sm font-mono text-text-primary"> <span class="text-sm font-mono text-text-primary">
{#if status === "loading"} {#if status === "loading"}
<span class="inline-block w-16 h-4 bg-border rounded animate-pulse"></span> <span
class="inline-block w-16 h-4 bg-border rounded animate-pulse"
></span>
{:else} {:else}
{item.value} {item.value}
{/if} {/if}
@@ -264,57 +166,8 @@
</div> </div>
</div> </div>
<!-- Partition Table Card --> <!-- Device Control Section (Reboot) -->
<div class="bg-bg-card border border-border rounded-xl overflow-hidden shadow-xl"> <div class="bg-bg-card border border-border rounded-xl p-5">
<div class="px-5 py-3 border-b border-border flex items-center justify-between">
<h2 class="text-sm font-semibold text-text-primary uppercase tracking-wider">
Partition Table
</h2>
<span class="text-[10px] text-text-secondary font-mono">Flash: 16MB</span>
</div>
<div class="divide-y divide-border">
{#if status === "loading"}
<div class="p-5 text-center text-xs text-text-secondary animate-pulse">Loading memory layout...</div>
{:else}
{#each otaStatus.partitions as part}
<div class="px-5 py-2.5 flex items-center justify-between hover:bg-bg-card-hover transition-colors">
<div class="flex flex-col">
<div class="flex items-center gap-2">
<span class="text-[11px] font-mono font-bold {part.label === otaStatus.active_partition || part.label === otaStatus.running_firmware_label ? 'text-accent' : 'text-text-primary'}">
{part.label}
</span>
{#if part.label === otaStatus.active_partition || part.label === otaStatus.running_firmware_label}
<span class="text-[8px] bg-accent/20 text-accent px-1 rounded uppercase tracking-tighter">Active</span>
{/if}
</div>
<span class="text-[9px] text-text-secondary uppercase">
Type {part.type} / Sub {part.subtype}
</span>
</div>
<div class="text-right flex flex-col items-end">
<div class="text-[11px] font-mono text-text-primary">{formatBytes(part.size)}</div>
<div class="flex items-center gap-1.5 mt-0.5">
{#if part.app_version}
<span class="text-[9px] text-accent font-bold">v{part.app_version}</span>
{/if}
{#if part.free !== undefined}
<span class="text-[9px] {part.free > 1024 ? 'text-success' : 'text-text-secondary'} font-bold">
{formatBytes(part.free)} free
</span>
{/if}
</div>
</div>
</div>
{/each}
{/if}
</div>
</div>
</div>
<!-- Right Column: Device Control & OTA Updates -->
<div class="space-y-8">
<!-- Device Control Card -->
<div class="bg-bg-card border border-border rounded-xl p-5 shadow-xl">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h2 class="text-sm font-semibold text-text-primary uppercase tracking-wider"> <h2 class="text-sm font-semibold text-text-primary uppercase tracking-wider">
@@ -337,36 +190,23 @@
</div> </div>
</div> </div>
<!-- Updates & Maintenance Card --> <!-- Frontend Info & OTA Section -->
<OTAUpdate otaInfo={otaStatus} {systemInfo} onReboot={() => { status = "rebooting"; isRecovering = true; }} /> <OTAUpdate onReboot={() => (status = "rebooting")} />
</div>
</div>
{:else if currentView === 'tasks'}
<!-- Task Manager View -->
<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 --> <!-- Reboot Confirmation Modal -->
{#if showRebootConfirm} {#if showRebootConfirm}
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4 backdrop-blur-sm"> <div
<div class="bg-bg-card border border-border rounded-xl p-6 max-w-sm w-full space-y-4 shadow-2xl"> class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4"
<h3 class="text-lg font-semibold text-text-primary">Confirm Reboot</h3> >
<div
class="bg-bg-card border border-border rounded-xl p-6 max-w-sm w-full space-y-4"
>
<h3 class="text-lg font-semibold text-text-primary">
Confirm Reboot
</h3>
<p class="text-sm text-text-secondary"> <p class="text-sm text-text-secondary">
Are you sure you want to reboot the ESP32? The device will be temporarily unavailable. Are you sure you want to reboot the ESP32? The device will be
temporarily unavailable.
</p> </p>
<div class="flex gap-3 justify-end"> <div class="flex gap-3 justify-end">
<button <button
@@ -377,7 +217,7 @@
</button> </button>
<button <button
onclick={handleReboot} onclick={handleReboot}
class="px-4 py-2 text-sm font-medium rounded-lg bg-danger text-white hover:bg-danger-hover transition-colors shadow-lg shadow-danger/20" class="px-4 py-2 text-sm font-medium rounded-lg bg-danger text-white hover:bg-danger-hover transition-colors"
> >
Reboot Now Reboot Now
</button> </button>
@@ -388,6 +228,3 @@
</div> </div>
</main> </main>
</div>
<Spinner />

View File

@@ -17,28 +17,3 @@
--color-danger: #ef4444; --color-danger: #ef4444;
--color-danger-hover: #f87171; --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;
}

View File

@@ -1,196 +0,0 @@
<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>

View File

@@ -1,48 +1,58 @@
<script> <script>
import { uploadOTAFrontend, uploadOTAFirmware, uploadOTABundle } from "./api.js"; let { onReboot = null } = $props();
import { getOTAStatus, uploadOTAFrontend } 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; const IS_DEV = import.meta.env.DEV;
/** @type {'idle' | 'uploading' | 'success' | 'error'} */ /** @type {'idle' | 'loading_status' | 'uploading' | 'success' | 'error'} */
let status = $state("idle"); let status = $state("idle");
let errorMsg = $state(""); let errorMsg = $state("");
let uploadProgress = $state(0); let uploadProgress = $state(0); // 0 to 100
let otaInfo = $state({
active_slot: -1,
active_partition: "—",
target_partition: "—",
});
let selectedFile = $state(null); let selectedFile = $state(null);
let showAdvanced = $state(false); let showAdvanced = $state(false);
/** @type {'frontend' | 'firmware' | 'bundle'} */
let updateMode = $state("frontend");
let isDragging = $state(false); let isDragging = $state(false);
async function fetchStatus() {
status = "loading_status";
try {
otaInfo = await getOTAStatus();
status = "idle";
} catch (e) {
status = "error";
errorMsg = "Failed to fetch OTA status: " + e.message;
}
}
// Fetch status on mount
$effect(() => {
fetchStatus();
});
function handleFileChange(event) { function handleFileChange(event) {
const files = event.target.files; const files = event.target.files;
if (files && files.length > 0) processFile(files[0]); if (files && files.length > 0) {
processFile(files[0]);
}
} }
function handleDrop(event) { function handleDrop(event) {
event.preventDefault(); event.preventDefault();
isDragging = false; isDragging = false;
const files = event.dataTransfer.files; const files = event.dataTransfer.files;
if (files && files.length > 0) processFile(files[0]); if (files && files.length > 0) {
processFile(files[0]);
}
} }
function processFile(file) { function processFile(file) {
if (updateMode === 'bundle') { if (file.name.endsWith('.bin')) {
if (file.name.endsWith('.bundle')) {
selectedFile = file;
errorMsg = "";
} else {
selectedFile = null;
errorMsg = "Please select a valid .bundle file";
}
} else if (file.name.endsWith('.bin')) {
selectedFile = file; selectedFile = file;
errorMsg = ""; errorMsg = "";
} else { } else {
@@ -63,13 +73,7 @@
}, 500); }, 500);
try { try {
if (updateMode === "frontend") {
await uploadOTAFrontend(selectedFile); await uploadOTAFrontend(selectedFile);
} else if (updateMode === "firmware") {
await uploadOTAFirmware(selectedFile);
} else {
await uploadOTABundle(selectedFile);
}
clearInterval(progressInterval); clearInterval(progressInterval);
uploadProgress = 100; uploadProgress = 100;
status = "success"; status = "success";
@@ -81,75 +85,38 @@
errorMsg = e.message; errorMsg = e.message;
} }
} }
function toggleMode(mode) {
if (showAdvanced && updateMode === mode) {
showAdvanced = false;
} else {
showAdvanced = true;
updateMode = mode;
selectedFile = null;
errorMsg = "";
}
}
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
const runningLabel = otaInfo.running_firmware_label || 'ota_0';
return runningLabel === 'ota_0' ? 'ota_1' : 'ota_0';
});
</script> </script>
{#if !IS_DEV} {#if !IS_DEV}
<div class="bg-bg-card border border-border rounded-xl overflow-hidden mt-4"> <div class="bg-bg-card border border-border rounded-xl overflow-hidden mt-4">
<div class="px-5 py-3 border-b border-border flex items-center justify-between"> <div class="px-5 py-3 border-b border-border flex items-center justify-between">
<h2 class="text-sm font-semibold text-text-primary uppercase tracking-wider"> <h2 class="text-sm font-semibold text-text-primary uppercase tracking-wider">
Updates & Maintenance Frontend Info
</h2> </h2>
<div class="flex gap-2">
<button <button
onclick={() => toggleMode('frontend')} onclick={() => showAdvanced = !showAdvanced}
class="text-[10px] font-bold uppercase tracking-tight px-2 py-1 rounded transition-colors class="text-[10px] font-bold uppercase tracking-tight px-2 py-1 rounded bg-border text-text-secondary hover:text-text-primary transition-colors"
{showAdvanced && updateMode === 'frontend' ? 'bg-accent text-white' : 'bg-border text-text-secondary hover:text-text-primary'}"
> >
Frontend OTA {showAdvanced ? 'Hide Tools' : 'OTA Update'}
</button> </button>
<button
onclick={() => toggleMode('firmware')}
class="text-[10px] font-bold uppercase tracking-tight px-2 py-1 rounded transition-colors
{showAdvanced && updateMode === 'firmware' ? 'bg-accent text-white' : 'bg-border text-text-secondary hover:text-text-primary'}"
>
Firmware
</button>
<button
onclick={() => toggleMode('bundle')}
class="text-[10px] font-bold uppercase tracking-tight px-2 py-1 rounded transition-colors
{showAdvanced && updateMode === 'bundle' ? 'bg-accent text-white' : 'bg-border text-text-secondary hover:text-text-primary'}"
>
Universal Bundle
</button>
</div>
</div> </div>
<div class="p-5 space-y-4"> <div class="p-5 space-y-4">
<!-- Version & Slot Info -->
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="bg-bg-primary/30 p-3 rounded-lg border border-border/50"> <div class="bg-bg-primary/30 p-3 rounded-lg border border-border/50">
<div class="text-[10px] uppercase text-text-secondary font-bold mb-1">UI Version</div> <div class="text-[10px] uppercase text-text-secondary font-bold mb-1">Version</div>
<div class="text-sm font-mono text-accent">v{__APP_VERSION__}</div> <div class="text-sm font-mono text-accent">v{__APP_VERSION__}</div>
<div class="text-[9px] text-text-secondary mt-1">
Slot: {otaInfo.active_partition}
{#if otaInfo.partitions}
({(otaInfo.partitions.find(p => p.label === otaInfo.active_partition)?.free / 1024).toFixed(0)} KB free)
{/if}
</div>
</div> </div>
<div class="bg-bg-primary/30 p-3 rounded-lg border border-border/50"> <div class="bg-bg-primary/30 p-3 rounded-lg border border-border/50">
<div class="text-[10px] uppercase text-text-secondary font-bold mb-1">FW Version</div> <div class="text-[10px] uppercase text-text-secondary font-bold mb-1">Active Slot</div>
<div class="text-sm font-mono text-text-primary">{systemInfo.firmware}</div> <div class="text-xs font-mono text-text-primary">
<div class="text-[9px] text-text-secondary mt-1"> {otaInfo.active_partition}
Active: {otaInfo.active_slot === 0 ? 'ota_0' : 'ota_1'} {#if otaInfo.partitions}
<span class="text-text-secondary ml-1">
({(otaInfo.partitions.find(p => p.label === otaInfo.active_partition)?.free / 1024 / 1024).toFixed(2)} MB free)
</span>
{/if}
</div> </div>
</div> </div>
</div> </div>
@@ -157,14 +124,12 @@
{#if showAdvanced} {#if showAdvanced}
<div class="pt-2 border-t border-border/50 space-y-3"> <div class="pt-2 border-t border-border/50 space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h3 class="text-xs font-bold text-text-primary"> <h3 class="text-xs font-bold text-text-primary">OTA Upgrade</h3>
OTA Upgrade ({updateMode === 'frontend' ? 'UI' : 'Firmware'})
</h3>
<div class="text-[10px] text-text-secondary"> <div class="text-[10px] text-text-secondary">
Target: <span class="font-mono text-accent">{currentTarget}</span> Target: <span class="font-mono">{otaInfo.target_partition}</span>
{#if updateMode === 'frontend' && otaInfo.partitions} {#if otaInfo.partitions}
<span class="ml-1"> <span class="ml-1">
({(otaInfo.partitions.find(p => p.label === otaInfo.target_partition)?.size / 1024 / 1024).toFixed(1)} MB) ({(otaInfo.partitions.find(p => p.label === otaInfo.target_partition)?.size / 1024 / 1024).toFixed(2)} MB capacity)
</span> </span>
{/if} {/if}
</div> </div>
@@ -173,12 +138,13 @@
{#if status === "success"} {#if status === "success"}
<div class="bg-success/10 border border-success/20 text-success p-3 rounded-lg text-xs flex items-center gap-2"> <div class="bg-success/10 border border-success/20 text-success p-3 rounded-lg text-xs flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-success"></span> <span class="w-1.5 h-1.5 rounded-full bg-success"></span>
Update successful! Rebooting device... Update successful! The device is rebooting...
</div> </div>
{:else} {:else}
<!-- Drag and Drop Zone -->
<div <div
role="button" role="button"
aria-label="Upload data" aria-label="Upload partition image"
tabindex="0" tabindex="0"
class="relative border-2 border-dashed rounded-xl p-6 transition-all duration-200 flex flex-col items-center justify-center gap-2 class="relative border-2 border-dashed rounded-xl p-6 transition-all duration-200 flex flex-col items-center justify-center gap-2
{isDragging ? 'border-accent bg-accent/5' : 'border-border hover:border-border-hover bg-bg-primary/20'} {isDragging ? 'border-accent bg-accent/5' : 'border-border hover:border-border-hover bg-bg-primary/20'}
@@ -187,13 +153,19 @@
ondragleave={() => isDragging = false} ondragleave={() => isDragging = false}
ondrop={handleDrop} ondrop={handleDrop}
> >
<input type="file" accept="{updateMode === 'bundle' ? '.bundle' : '.bin'}" onchange={handleFileChange} class="absolute inset-0 opacity-0 cursor-pointer" /> <input
<div class="text-2xl">{updateMode === 'frontend' ? '🎨' : '⚙️'}</div> type="file"
accept=".bin"
onchange={handleFileChange}
class="absolute inset-0 opacity-0 cursor-pointer"
/>
<div class="text-2xl">📦</div>
{#if selectedFile} {#if selectedFile}
<div class="text-xs font-medium text-text-primary">{selectedFile.name}</div> <div class="text-xs font-medium text-text-primary">{selectedFile.name}</div>
<div class="text-[10px] text-text-secondary">{(selectedFile.size / 1024).toFixed(1)} KB</div> <div class="text-[10px] text-text-secondary">{(selectedFile.size / 1024).toFixed(1)} KB</div>
{:else} {:else}
<div class="text-xs text-text-primary">Drop {updateMode === 'bundle' ? 'Universal .bundle' : updateMode === 'frontend' ? 'UI .bin' : 'Firmware .bin'} here</div> <div class="text-xs text-text-primary">Drag & Drop .bin here</div>
<div class="text-[10px] text-text-secondary">or click to browse</div> <div class="text-[10px] text-text-secondary">or click to browse</div>
{/if} {/if}
</div> </div>
@@ -202,18 +174,25 @@
<button <button
onclick={handleUpload} onclick={handleUpload}
disabled={status === "uploading"} disabled={status === "uploading"}
class="w-full py-2 text-xs font-bold rounded-lg transition-colors bg-accent text-white hover:brightness-110 disabled:opacity-40" class="w-full py-2 text-xs font-bold rounded-lg transition-colors
bg-accent text-white hover:brightness-110
disabled:opacity-40"
> >
{status === "uploading" ? 'Flashing...' : `Update ${updateMode === 'bundle' ? 'Everything' : updateMode === 'frontend' ? 'UI' : 'Firmware'}`} {status === "uploading" ? 'Processing Update...' : `Flash to ${otaInfo.target_partition}`}
</button> </button>
{/if} {/if}
{#if status === "uploading"} {#if status === "uploading"}
<div class="w-full bg-border rounded-full h-1 mt-1 overflow-hidden"> <div class="w-full bg-border rounded-full h-1 mt-1 overflow-hidden">
<div class="bg-accent h-1 rounded-full transition-all duration-300" style="width: {uploadProgress}%"></div> <div
class="bg-accent h-1 rounded-full transition-all duration-300"
style="width: {uploadProgress}%"
></div>
</div> </div>
{:else if status === "error"} {:else if status === "error"}
<p class="text-[10px] text-danger mt-1 bg-danger/5 p-2 rounded border border-danger/10">{errorMsg}</p> <p class="text-[10px] text-danger mt-1 bg-danger/5 p-2 rounded border border-danger/10">
{errorMsg}
</p>
{/if} {/if}
{/if} {/if}
</div> </div>

View File

@@ -1,68 +0,0 @@
<script>
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="
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="text-xs font-bold uppercase tracking-[0.05em] text-text-secondary whitespace-nowrap overflow-hidden">
Menu
</span>
{/if}
<button
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'}
>
<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="flex flex-col gap-1 p-2">
{#each navItems as item}
<button
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="text-base flex-shrink-0">{item.icon}</span>
{#if !collapsed}
<span class="overflow-hidden">{item.label}</span>
{/if}
</button>
{/each}
</nav>
</aside>

View File

@@ -1,31 +0,0 @@
<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}

View File

@@ -1,386 +0,0 @@
<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 showAddForm = $state(false);
// Edit state
let editingTaskId = $state(null);
let editTitle = $state('');
let editPeriod = $state(0);
let editRecurrence = $state(0);
let editDueDay = $state('');
// Confirm delete
let confirmDeleteId = $state(null);
async function fetchTasks() {
if (!selectedUserId) {
tasks = [];
return;
}
try {
tasks = await getTasks(selectedUserId);
// 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;
}
}
// Refetch when selected user changes
$effect(() => {
if (selectedUserId) {
fetchTasks();
} else {
tasks = [];
}
});
async function handleAddTask(e) {
e.preventDefault();
if (!newTitle.trim()) return;
if (newRecurrence === 0 && !newDueDay) return;
const dueTimestamp = newRecurrence > 0
? 0
: Math.floor(new Date(`${newDueDay}T00:00`).getTime() / 1000);
try {
await addTask(selectedUserId, newTitle.trim(), dueTimestamp, newPeriod, newRecurrence);
newTitle = '';
newPeriod = 0x01;
newRecurrence = 0;
newDueDay = '';
showAddForm = false;
await fetchTasks();
} catch (e) {
error = e.message;
}
}
async function handleToggleComplete(task) {
try {
await updateTask(task.id, { completed: !task.completed });
await fetchTasks();
} catch (e) {
error = e.message;
}
}
function startEditing(task) {
editingTaskId = task.id;
editTitle = task.title;
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);
try {
await updateTask(editingTaskId, {
title: editTitle.trim(),
due_date: dueTimestamp,
period: editPeriod,
recurrence: editRecurrence
});
editingTaskId = null;
await fetchTasks();
} catch (e) {
error = e.message;
}
}
async function handleDelete(id) {
try {
await deleteTask(id);
confirmDeleteId = null;
await fetchTasks();
} catch (e) {
error = e.message;
}
}
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');
newDueDay = `${year}-${month}-${dayNum}`;
}
function taskIsOverdue(task) {
if (task.recurrence > 0) return false;
return isOverdue(task.due_date);
}
</script>
<div class="w-full">
<UserManager bind:selectedUserId onUsersChanged={fetchTasks} />
{#if selectedUserId}
<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="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="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="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="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="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="flex flex-col gap-1">
{#each tasks as task}
<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="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="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="flex items-center gap-2.5 flex-1 min-w-0">
<input
type="checkbox"
checked={task.completed}
onchange={() => handleToggleComplete(task)}
class="w-4 h-4 cursor-pointer flex-shrink-0"
style="accent-color: var(--color-accent)"
/>
<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="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="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="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="text-center p-8 text-text-secondary text-[13px]">No tasks yet. Add one above!</div>
{/each}
</div>
{:else}
<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="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>

View File

@@ -1,208 +0,0 @@
<script>
import { getUsers, addUser, removeUser, updateUser } from './api.js';
let {
selectedUserId = $bindable(null),
onUsersChanged = () => {},
mode = 'selector' // 'selector' | 'manager'
} = $props();
let users = $state([]);
let newUserName = $state('');
let showAddForm = $state(false);
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 && mode === 'selector') {
selectedUserId = users[0].id;
}
// If selected user was deleted, select first available
if (selectedUserId && !users.find(u => u.id === selectedUserId)) {
selectedUserId = users.length > 0 ? users[0].id : null;
}
error = '';
} catch (e) {
error = e.message;
}
}
async function handleAddUser() {
if (!newUserName.trim()) return;
try {
const user = await addUser(newUserName.trim());
newUserName = '';
showAddForm = false;
await fetchUsers();
if (mode === 'selector') selectedUserId = user.id;
onUsersChanged();
} catch (e) {
error = e.message;
}
}
async function handleRemoveUser(id) {
try {
await removeUser(id);
confirmDeleteId = null;
await fetchUsers();
onUsersChanged();
} catch (e) {
error = e.message;
}
}
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="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}
<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="px-4 py-3 rounded-xl border border-border bg-bg-primary text-text-primary text-sm outline-none focus:border-accent"
autofocus
/>
<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}
<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>
{/if}
{#if error}
<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>

View File

@@ -7,26 +7,13 @@
*/ */
const API_BASE = import.meta.env.VITE_API_BASE || ''; 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. * Fetch system information from the ESP32.
* @returns {Promise<{chip: string, freeHeap: number, uptime: number, firmware: string, connection: string}>} * @returns {Promise<{chip: string, freeHeap: number, uptime: number, firmware: string, connection: string}>}
*/ */
export async function getSystemInfo() { export async function getSystemInfo() {
const res = await trackedFetch(`${API_BASE}/api/system/info`); const res = await fetch(`${API_BASE}/api/system/info`);
if (!res.ok) { if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`); throw new Error(`HTTP ${res.status}: ${res.statusText}`);
} }
@@ -42,7 +29,7 @@ export async function getSystemInfo() {
* @returns {Promise<{message: string}>} * @returns {Promise<{message: string}>}
*/ */
export async function reboot() { export async function reboot() {
const res = await trackedFetch(`${API_BASE}/api/system/reboot`, { const res = await fetch(`${API_BASE}/api/system/reboot`, {
method: 'POST', method: 'POST',
}); });
if (!res.ok) { if (!res.ok) {
@@ -53,10 +40,10 @@ export async function reboot() {
/** /**
* Fetch OTA status from the ESP32. * Fetch OTA status from the ESP32.
* @returns {Promise<{active_slot: number, active_partition: string, target_partition: string, partitions: any[], running_firmware_label: string, running_firmware_slot: number}>} * @returns {Promise<{active_slot: number, active_partition: string, target_partition: string}>}
*/ */
export async function getOTAStatus() { export async function getOTAStatus() {
const res = await trackedFetch(`${API_BASE}/api/ota/status`); const res = await fetch(`${API_BASE}/api/ota/status`);
if (!res.ok) { if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`); throw new Error(`HTTP ${res.status}: ${res.statusText}`);
} }
@@ -69,7 +56,7 @@ export async function getOTAStatus() {
* @returns {Promise<{status: string, message: string}>} * @returns {Promise<{status: string, message: string}>}
*/ */
export async function uploadOTAFrontend(file) { export async function uploadOTAFrontend(file) {
const res = await trackedFetch(`${API_BASE}/api/ota/frontend`, { const res = await fetch(`${API_BASE}/api/ota/frontend`, {
method: 'POST', method: 'POST',
body: file, // Send the raw file Blob/Buffer body: file, // Send the raw file Blob/Buffer
headers: { headers: {
@@ -86,241 +73,3 @@ export async function uploadOTAFrontend(file) {
return res.json(); return res.json();
} }
/**
* Upload a new firmware binary image.
* @param {File} file The firmware binary file to upload.
* @returns {Promise<{status: string, message: string}>}
*/
export async function uploadOTAFirmware(file) {
const res = await trackedFetch(`${API_BASE}/api/ota/firmware`, {
method: 'POST',
body: file,
headers: {
'Content-Type': 'application/octet-stream'
}
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(`Upload failed (${res.status}): ${errorText || res.statusText}`);
}
return res.json();
}
/**
* Upload a universal .bundle image (FW + WWW).
* @param {File} file The bundle binary file to upload.
* @returns {Promise<{status: string, message: string}>}
*/
export async function uploadOTABundle(file) {
const res = await trackedFetch(`${API_BASE}/api/ota/bundle`, {
method: 'POST',
body: file,
headers: {
'Content-Type': 'application/octet-stream'
}
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(`Upload failed (${res.status}): ${errorText || res.statusText}`);
}
return res.json();
}
// ─── User Management ─────────────────────────────────────────────────────────
/**
* Fetch all users.
* @returns {Promise<Array<{id: number, name: string}>>}
*/
export async function getUsers() {
const res = await trackedFetch(`${API_BASE}/api/users`);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
return res.json();
}
/**
* Create a new user.
* @param {string} name
* @returns {Promise<{id: number, name: string}>}
*/
export async function addUser(name) {
const res = await trackedFetch(`${API_BASE}/api/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(`Failed (${res.status}): ${errorText || res.statusText}`);
}
return res.json();
}
/**
* Delete a user and all their tasks.
* @param {number} id
* @returns {Promise<{status: string}>}
*/
export async function removeUser(id) {
const res = await trackedFetch(`${API_BASE}/api/users?id=${id}`, {
method: 'DELETE'
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(`Failed (${res.status}): ${errorText || res.statusText}`);
}
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 ─────────────────────────────────────────────────────────
/**
* Fetch tasks for a specific user.
* @param {number} userId
* @returns {Promise<Array<{id: number, user_id: number, title: string, due_date: number, completed: boolean}>>}
*/
export async function getTasks(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();
}
/**
* Fetch top 3 upcoming tasks per user (for Dashboard).
* @returns {Promise<{users: Array<{id: number, name: string, tasks: Array}>}>}
*/
export async function getUpcomingTasks() {
const res = await trackedFetch(`${API_BASE}/api/tasks/upcoming`);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
return res.json();
}
/**
* Create a new task.
* @param {number} userId
* @param {string} title
* @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, 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, period, recurrence })
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(`Failed (${res.status}): ${errorText || res.statusText}`);
}
return res.json();
}
/**
* Update a task (partial update — only include fields you want to change).
* @param {number} id
* @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 trackedFetch(`${API_BASE}/api/tasks/update`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, ...fields })
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(`Failed (${res.status}): ${errorText || res.statusText}`);
}
return res.json();
}
/**
* Delete a task.
* @param {number} id
* @returns {Promise<{status: string}>}
*/
export async function deleteTask(id) {
const res = await trackedFetch(`${API_BASE}/api/tasks?id=${id}`, {
method: 'DELETE'
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(`Failed (${res.status}): ${errorText || res.statusText}`);
}
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();
}

View File

@@ -1,3 +0,0 @@
import { writable } from 'svelte/store';
export const pendingRequests = writable(0);

View File

@@ -1,48 +0,0 @@
/**
* 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;
}

View File

@@ -1,5 +1,5 @@
{ {
"major": 0, "major": 0,
"minor": 1, "minor": 1,
"revision": 32 "revision": 7
} }

View File

@@ -2,7 +2,7 @@ idf_component_register(SRCS "main.cpp"
# Needed as we use minimal build # Needed as we use minimal build
PRIV_REQUIRES esp_http_server esp_eth PRIV_REQUIRES esp_http_server esp_eth
esp_wifi nvs_flash esp_netif vfs esp_wifi nvs_flash esp_netif vfs
json app_update esp_timer esp_psram mdns driver json app_update esp_timer
INCLUDE_DIRS ".") INCLUDE_DIRS ".")
if(CONFIG_CALENDINK_DEPLOY_WEB_PAGES) if(CONFIG_CALENDINK_DEPLOY_WEB_PAGES)

View File

@@ -24,52 +24,6 @@ menu "CalendarInk Network Configuration"
help help
Number of times to retry the WiFi connection before failing completely. 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 endmenu
menu "Calendink Web Server" menu "Calendink Web Server"
@@ -88,16 +42,4 @@ menu "Calendink Web Server"
help help
VFS path where the LittleFS partition is mounted. 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 endmenu

View File

@@ -1,99 +0,0 @@
// 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};

View File

@@ -1,45 +0,0 @@
// 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};

View File

@@ -1,79 +0,0 @@
// 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};

View File

@@ -1,59 +0,0 @@
// 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};

View File

@@ -1,249 +0,0 @@
// 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};

View File

@@ -1,57 +0,0 @@
// 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;
}

View File

@@ -1,9 +0,0 @@
// 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

View File

@@ -1,145 +0,0 @@
#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};

View File

@@ -1,2 +0,0 @@
// Unity build entry for display endpoints
#include "image.cpp"

View File

@@ -1,187 +0,0 @@
// SDK
#include "cJSON.h"
#include "esp_http_server.h"
#include "esp_log.h"
#include "esp_ota_ops.h"
#include "esp_partition.h"
#include "esp_system.h"
#include "esp_timer.h"
#include "nvs.h"
#include "nvs_flash.h"
#include <sys/param.h>
// Project
#include "appstate.hpp"
#include "types.hpp"
#define BUNDLE_SCRATCH_BUFSIZE 4096
typedef struct
{
char magic[4];
uint32_t fw_size;
uint32_t www_size;
} bundle_header_t;
internal void bundle_ota_restart_timer_callback(void *arg) { esp_restart(); }
internal esp_err_t api_ota_bundle_handler(httpd_req_t *req)
{
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
if (req->content_len < sizeof(bundle_header_t))
{
ESP_LOGE("OTA_BUNDLE", "Request content too short");
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Content too short");
return ESP_FAIL;
}
char *buf = (char *)malloc(BUNDLE_SCRATCH_BUFSIZE);
if (!buf)
{
ESP_LOGE("OTA_BUNDLE", "Failed to allocate buffer");
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory");
return ESP_FAIL;
}
// 1. Read Header
bundle_header_t header;
int overhead = httpd_req_recv(req, (char *)&header, sizeof(bundle_header_t));
if (overhead <= 0)
{
free(buf);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"Header receive failed");
return ESP_FAIL;
}
if (memcmp(header.magic, "BNDL", 4) != 0)
{
free(buf);
ESP_LOGE("OTA_BUNDLE", "Invalid magic: %.4s", header.magic);
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid bundle magic");
return ESP_FAIL;
}
ESP_LOGI("OTA_BUNDLE",
"Starting Universal Update: FW %lu bytes, WWW %lu bytes",
header.fw_size, header.www_size);
// 2. Prepare Firmware Update
const esp_partition_t *fw_part = esp_ota_get_next_update_partition(NULL);
esp_ota_handle_t fw_handle = 0;
esp_err_t err = esp_ota_begin(fw_part, header.fw_size, &fw_handle);
if (err != ESP_OK)
{
free(buf);
ESP_LOGE("OTA_BUNDLE", "esp_ota_begin failed (%s)", esp_err_to_name(err));
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"OTA Begin failed");
return ESP_FAIL;
}
// 3. Stream Firmware
uint32_t fw_remaining = header.fw_size;
bool fw_first_chunk = true;
while (fw_remaining > 0)
{
int recv_len =
httpd_req_recv(req, buf, MIN(fw_remaining, BUNDLE_SCRATCH_BUFSIZE));
if (recv_len <= 0)
{
if (recv_len == HTTPD_SOCK_ERR_TIMEOUT)
continue;
esp_ota_abort(fw_handle);
free(buf);
return ESP_FAIL;
}
if (fw_first_chunk && recv_len > 0)
{
if ((uint8_t)buf[0] != 0xE9)
{
ESP_LOGE("OTA_BUNDLE", "Invalid FW magic in bundle: %02X",
(uint8_t)buf[0]);
esp_ota_abort(fw_handle);
free(buf);
httpd_resp_send_err(
req, HTTPD_400_BAD_REQUEST,
"Invalid Bundle: Firmware part is corrupted or invalid.");
return ESP_FAIL;
}
fw_first_chunk = false;
}
esp_ota_write(fw_handle, buf, recv_len);
fw_remaining -= recv_len;
}
esp_ota_end(fw_handle);
// 4. Prepare WWW Update
uint8_t target_www_slot = g_Active_WWW_Partition == 0 ? 1 : 0;
const char *www_label = target_www_slot == 0 ? "www_0" : "www_1";
const esp_partition_t *www_part = esp_partition_find_first(
ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_LITTLEFS, www_label);
esp_partition_erase_range(www_part, 0, www_part->size);
// 5. Stream WWW
uint32_t www_remaining = header.www_size;
uint32_t www_written = 0;
while (www_remaining > 0)
{
int recv_len =
httpd_req_recv(req, buf, MIN(www_remaining, BUNDLE_SCRATCH_BUFSIZE));
if (recv_len <= 0)
{
if (recv_len == HTTPD_SOCK_ERR_TIMEOUT)
continue;
free(buf);
return ESP_FAIL;
}
esp_partition_write(www_part, www_written, buf, recv_len);
www_written += recv_len;
www_remaining -= recv_len;
}
free(buf);
// 6. Commit Updates
esp_ota_set_boot_partition(fw_part);
nvs_handle_t nvs_h;
if (nvs_open("storage", NVS_READWRITE, &nvs_h) == ESP_OK)
{
nvs_set_u8(nvs_h, "www_part", target_www_slot);
nvs_commit(nvs_h);
nvs_close(nvs_h);
}
ESP_LOGI("OTA_BUNDLE", "Universal Update Complete! Rebooting...");
httpd_resp_set_type(req, "application/json");
cJSON *root = cJSON_CreateObject();
cJSON_AddStringToObject(root, "status", "success");
cJSON_AddStringToObject(root, "message",
"Universal update successful, rebooting...");
const char *resp = cJSON_Print(root);
httpd_resp_sendstr(req, resp);
free((void *)resp);
cJSON_Delete(root);
// Reboot
esp_timer_create_args_t tmr_args = {};
tmr_args.callback = &bundle_ota_restart_timer_callback;
tmr_args.name = "bundle_reboot";
esp_timer_handle_t tmr;
esp_timer_create(&tmr_args, &tmr);
esp_timer_start_once(tmr, 1'000'000);
return ESP_OK;
}
internal const httpd_uri_t api_ota_bundle_uri = {.uri = "/api/ota/bundle",
.method = HTTP_POST,
.handler =
api_ota_bundle_handler,
.user_ctx = NULL};

View File

@@ -1,164 +0,0 @@
// SDK
#include "cJSON.h"
#include "esp_http_server.h"
#include "esp_log.h"
#include "esp_ota_ops.h"
#include "esp_system.h"
#include "esp_timer.h"
#include <sys/param.h>
// Project
#include "appstate.hpp"
#include "types.hpp"
#define OTA_FIRMWARE_SCRATCH_BUFSIZE 4096
internal void firmware_ota_restart_timer_callback(void *arg) { esp_restart(); }
internal esp_err_t api_ota_firmware_handler(httpd_req_t *req)
{
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
const esp_partition_t *update_partition =
esp_ota_get_next_update_partition(NULL);
if (update_partition == NULL)
{
ESP_LOGE("OTA_FW", "Passive OTA partition not found");
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"OTA partition not found");
return ESP_FAIL;
}
ESP_LOGI("OTA_FW", "Writing to partition subtype %d at offset 0x%lx",
update_partition->subtype, update_partition->address);
esp_ota_handle_t update_handle = 0;
esp_err_t err =
esp_ota_begin(update_partition, OTA_SIZE_UNKNOWN, &update_handle);
if (err != ESP_OK)
{
ESP_LOGE("OTA_FW", "esp_ota_begin failed (%s)", esp_err_to_name(err));
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"OTA begin failed");
return ESP_FAIL;
}
char *buf = (char *)malloc(OTA_FIRMWARE_SCRATCH_BUFSIZE);
if (!buf)
{
ESP_LOGE("OTA_FW", "Failed to allocate buffer");
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory");
return ESP_FAIL;
}
int binary_file_len = 0;
int remaining = req->content_len;
while (remaining > 0)
{
int recv_len =
httpd_req_recv(req, buf, MIN(remaining, OTA_FIRMWARE_SCRATCH_BUFSIZE));
if (recv_len <= 0)
{
if (recv_len == HTTPD_SOCK_ERR_TIMEOUT)
{
continue;
}
ESP_LOGE("OTA_FW", "Receive failed");
esp_ota_abort(update_handle);
free(buf);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"Receive failed");
return ESP_FAIL;
}
if (binary_file_len == 0 && recv_len > 0)
{
if ((uint8_t)buf[0] != 0xE9)
{
ESP_LOGE("OTA_FW", "Invalid magic: %02X. Expected 0xE9 for Firmware.",
(uint8_t)buf[0]);
esp_ota_abort(update_handle);
free(buf);
httpd_resp_send_err(
req, HTTPD_400_BAD_REQUEST,
"Invalid file: This does not look like an ESP32 firmware binary.");
return ESP_FAIL;
}
}
err = esp_ota_write(update_handle, (const void *)buf, recv_len);
if (err != ESP_OK)
{
ESP_LOGE("OTA_FW", "esp_ota_write failed (%s)", esp_err_to_name(err));
esp_ota_abort(update_handle);
free(buf);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"Flash write failed");
return ESP_FAIL;
}
binary_file_len += recv_len;
remaining -= recv_len;
}
free(buf);
ESP_LOGI("OTA_FW", "Total binary data written: %d", binary_file_len);
err = esp_ota_end(update_handle);
if (err != ESP_OK)
{
if (err == ESP_ERR_OTA_VALIDATE_FAILED)
{
ESP_LOGE("OTA_FW", "Image validation failed, image is corrupted");
}
else
{
ESP_LOGE("OTA_FW", "esp_ota_end failed (%s)!", esp_err_to_name(err));
}
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"OTA validation/end failed");
return ESP_FAIL;
}
err = esp_ota_set_boot_partition(update_partition);
if (err != ESP_OK)
{
ESP_LOGE("OTA_FW", "esp_ota_set_boot_partition failed (%s)!",
esp_err_to_name(err));
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"Failed to set boot partition");
return ESP_FAIL;
}
ESP_LOGI("OTA_FW", "OTA successful, rebooting...");
httpd_resp_set_type(req, "application/json");
cJSON *root = cJSON_CreateObject();
cJSON_AddStringToObject(root, "status", "success");
cJSON_AddStringToObject(root, "message",
"Firmware update successful, rebooting...");
const char *response_text = cJSON_Print(root);
httpd_resp_sendstr(req, response_text);
free((void *)response_text);
cJSON_Delete(root);
// Trigger reboot with 1s delay
const esp_timer_create_args_t restart_timer_args = {
.callback = &firmware_ota_restart_timer_callback,
.arg = (void *)0,
.dispatch_method = ESP_TIMER_TASK,
.name = "fw_ota_restart_timer",
.skip_unhandled_events = false};
esp_timer_handle_t restart_timer;
esp_timer_create(&restart_timer_args, &restart_timer);
esp_timer_start_once(restart_timer, 1'000'000);
return ESP_OK;
}
internal const httpd_uri_t api_ota_firmware_uri = {.uri = "/api/ota/firmware",
.method = HTTP_POST,
.handler =
api_ota_firmware_handler,
.user_ctx = NULL};

View File

@@ -8,6 +8,7 @@
#include "nvs.h" #include "nvs.h"
#include "nvs_flash.h" #include "nvs_flash.h"
// Project // Project
#include "appstate.hpp" #include "appstate.hpp"
#include "types.hpp" #include "types.hpp"
@@ -58,7 +59,6 @@ internal esp_err_t api_ota_frontend_handler(httpd_req_t *req)
int total_read = 0; int total_read = 0;
int remaining = req->content_len; int remaining = req->content_len;
bool first_chunk = true;
while (remaining > 0) while (remaining > 0)
{ {
@@ -77,21 +77,6 @@ internal esp_err_t api_ota_frontend_handler(httpd_req_t *req)
return ESP_FAIL; return ESP_FAIL;
} }
if (first_chunk)
{
if ((uint8_t)buf[0] == 0xE9)
{
ESP_LOGE("OTA", "Magic 0xE9 detected. This looks like a FIRMWARE bin, "
"but you are uploading to FRONTEND slot!");
free(buf);
httpd_resp_send_err(
req, HTTPD_400_BAD_REQUEST,
"Invalid file: This is a Firmware binary, not a UI binary.");
return ESP_FAIL;
}
first_chunk = false;
}
err = esp_partition_write(partition, total_read, buf, recv_len); err = esp_partition_write(partition, total_read, buf, recv_len);
if (err != ESP_OK) if (err != ESP_OK)
{ {

View File

@@ -3,13 +3,8 @@
// SDK // SDK
#include "cJSON.h" #include "cJSON.h"
#include "esp_http_server.h" #include "esp_http_server.h"
#include "esp_image_format.h"
#include "esp_littlefs.h" #include "esp_littlefs.h"
#include "esp_log.h"
#include "esp_ota_ops.h"
#include "esp_partition.h" #include "esp_partition.h"
#include "esp_vfs.h"
#include <string.h>
// Project // Project
#include "appstate.hpp" #include "appstate.hpp"
@@ -25,58 +20,36 @@ internal esp_err_t api_ota_status_handler(httpd_req_t *req)
cJSON_AddNumberToObject(root, "active_slot", g_Active_WWW_Partition); cJSON_AddNumberToObject(root, "active_slot", g_Active_WWW_Partition);
constexpr const char *kPartitions[] = {"www_0", "www_1", "ota_0", "ota_1",
"factory"};
constexpr size_t kPartitionCount = ArrayCount(kPartitions);
cJSON *parts_arr = cJSON_AddArrayToObject(root, "partitions"); cJSON *parts_arr = cJSON_AddArrayToObject(root, "partitions");
esp_partition_iterator_t it = esp_partition_find( for (size_t i = 0; i < kPartitionCount; i++)
ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, NULL);
while (it != NULL)
{ {
const esp_partition_t *p = esp_partition_get(it);
cJSON *p_obj = cJSON_CreateObject(); cJSON *p_obj = cJSON_CreateObject();
cJSON_AddStringToObject(p_obj, "label", p->label); cJSON_AddStringToObject(p_obj, "label", kPartitions[i]);
cJSON_AddNumberToObject(p_obj, "type", p->type);
cJSON_AddNumberToObject(p_obj, "subtype", p->subtype); const esp_partition_t *p = esp_partition_find_first(
cJSON_AddNumberToObject(p_obj, "address", p->address); ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_ANY, kPartitions[i]);
if (p)
{
cJSON_AddNumberToObject(p_obj, "size", p->size); cJSON_AddNumberToObject(p_obj, "size", p->size);
// Try to get LittleFS info if it's a data partition
if (p->type == ESP_PARTITION_TYPE_DATA)
{
size_t total = 0, used = 0; size_t total = 0, used = 0;
if (esp_littlefs_info(p->label, &total, &used) == ESP_OK) if (esp_littlefs_info(kPartitions[i], &total, &used) == ESP_OK)
{ {
cJSON_AddNumberToObject(p_obj, "used", used); cJSON_AddNumberToObject(p_obj, "used", used);
cJSON_AddNumberToObject(p_obj, "free", total - used); cJSON_AddNumberToObject(p_obj, "free", total - used);
} }
else else
{ {
// For other data partitions (nvs, phy_init), just show total as used // Not mounted or not LFS
// for now cJSON_AddNumberToObject(p_obj, "used", 0);
cJSON_AddNumberToObject(p_obj, "used", p->size); cJSON_AddNumberToObject(p_obj, "free", p->size);
cJSON_AddNumberToObject(p_obj, "free", 0);
} }
} }
// For app partitions, try to find the binary size
else if (p->type == ESP_PARTITION_TYPE_APP)
{
esp_app_desc_t app_desc;
if (esp_ota_get_partition_description(p, &app_desc) == ESP_OK)
{
cJSON_AddStringToObject(p_obj, "app_version", app_desc.version);
// Get the true binary size from image metadata
esp_image_metadata_t data;
const esp_partition_pos_t pos = {.offset = p->address, .size = p->size};
if (esp_image_get_metadata(&pos, &data) == ESP_OK)
{
cJSON_AddNumberToObject(p_obj, "used", data.image_len);
cJSON_AddNumberToObject(p_obj, "free", p->size - data.image_len);
}
}
}
cJSON_AddItemToArray(parts_arr, p_obj); cJSON_AddItemToArray(parts_arr, p_obj);
it = esp_partition_next(it);
} }
cJSON_AddStringToObject(root, "active_partition", cJSON_AddStringToObject(root, "active_partition",
@@ -84,24 +57,6 @@ internal esp_err_t api_ota_status_handler(httpd_req_t *req)
cJSON_AddStringToObject(root, "target_partition", cJSON_AddStringToObject(root, "target_partition",
g_Active_WWW_Partition == 0 ? "www_1" : "www_0"); g_Active_WWW_Partition == 0 ? "www_1" : "www_0");
const esp_partition_t *running = esp_ota_get_running_partition();
if (running)
{
cJSON_AddStringToObject(root, "running_firmware_label", running->label);
if (running->subtype >= ESP_PARTITION_SUBTYPE_APP_OTA_MIN &&
running->subtype <= ESP_PARTITION_SUBTYPE_APP_OTA_MAX)
{
cJSON_AddNumberToObject(root, "running_firmware_slot",
running->subtype -
ESP_PARTITION_SUBTYPE_APP_OTA_MIN);
}
else
{
cJSON_AddNumberToObject(root, "running_firmware_slot",
-1); // Factory or other
}
}
const char *status_info = cJSON_Print(root); const char *status_info = cJSON_Print(root);
httpd_resp_sendstr(req, status_info); httpd_resp_sendstr(req, status_info);

View File

@@ -1,86 +0,0 @@
// POST /api/tasks — Create a new task
// Body: {"user_id":1, "title":"...", "due_date":1741369200, "period":0,
// "recurrence":0}
#include "cJSON.h"
#include "esp_http_server.h"
#include "todo.hpp"
#include "types.hpp"
internal esp_err_t api_tasks_post_handler(httpd_req_t *req)
{
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
char buf[256];
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 *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_Delete(body);
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, due_date,
period, recurrence);
cJSON_Delete(body);
if (!task)
{
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"Task limit reached or invalid user");
return ESP_FAIL;
}
cJSON *resp = cJSON_CreateObject();
cJSON_AddNumberToObject(resp, "id", task->id);
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);
httpd_resp_sendstr(req, json);
free((void *)json);
cJSON_Delete(resp);
return ESP_OK;
}
internal const httpd_uri_t api_tasks_post_uri = {.uri = "/api/tasks",
.method = HTTP_POST,
.handler =
api_tasks_post_handler,
.user_ctx = NULL};

View File

@@ -1,64 +0,0 @@
// GET /api/tasks?user_id=N — List tasks for a specific user
#include "cJSON.h"
#include "esp_http_server.h"
#include "todo.hpp"
#include "types.hpp"
internal esp_err_t api_tasks_get_handler(httpd_req_t *req)
{
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
char query[64] = {};
if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK)
{
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST,
"Missing query param 'user_id'");
return ESP_FAIL;
}
char user_id_str[8] = {};
if (httpd_query_key_value(query, "user_id", user_id_str,
sizeof(user_id_str)) != ESP_OK)
{
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST,
"Missing query param 'user_id'");
return ESP_FAIL;
}
uint8 user_id = (uint8)atoi(user_id_str);
cJSON *arr = cJSON_CreateArray();
for (int i = 0; i < MAX_TASKS; i++)
{
if (g_Tasks[i].active && g_Tasks[i].user_id == user_id)
{
cJSON *obj = cJSON_CreateObject();
cJSON_AddNumberToObject(obj, "id", g_Tasks[i].id);
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);
}
}
const char *json = cJSON_PrintUnformatted(arr);
httpd_resp_sendstr(req, json);
free((void *)json);
cJSON_Delete(arr);
return ESP_OK;
}
internal const httpd_uri_t api_tasks_get_uri = {.uri = "/api/tasks",
.method = HTTP_GET,
.handler =
api_tasks_get_handler,
.user_ctx = NULL};

View File

@@ -1,42 +0,0 @@
// DELETE /api/tasks?id=N — Delete a task
#include "esp_http_server.h"
#include "types.hpp"
internal esp_err_t api_tasks_delete_handler(httpd_req_t *req)
{
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
char query[32] = {};
if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK)
{
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing query param 'id'");
return ESP_FAIL;
}
char id_str[8] = {};
if (httpd_query_key_value(query, "id", id_str, sizeof(id_str)) != ESP_OK)
{
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing query param 'id'");
return ESP_FAIL;
}
uint16 id = (uint16)atoi(id_str);
if (!remove_task(id))
{
httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Task not found");
return ESP_FAIL;
}
httpd_resp_sendstr(req, "{\"status\":\"ok\"}");
return ESP_OK;
}
internal const httpd_uri_t api_tasks_delete_uri = {.uri = "/api/tasks",
.method = HTTP_DELETE,
.handler =
api_tasks_delete_handler,
.user_ctx = NULL};

View File

@@ -1,115 +0,0 @@
// Task data store: CRUD helpers, sorting, and seed data
#include "esp_timer.h"
#include "api/tasks/store.hpp"
#include "api/users/store.hpp"
// Find a task by ID, returns nullptr if not found
task_t *find_task(uint16 id)
{
for (int i = 0; i < MAX_TASKS; i++)
{
if (g_Tasks[i].active && g_Tasks[i].id == id)
{
return &g_Tasks[i];
}
}
return nullptr;
}
// Add a task, returns pointer to new task or nullptr if full
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)
{
return nullptr;
}
for (int i = 0; i < MAX_TASKS; i++)
{
if (!g_Tasks[i].active)
{
g_Tasks[i].id = g_NextTaskId++;
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];
}
}
return nullptr;
}
// Remove a task by ID, returns true if found and removed
bool remove_task(uint16 id)
{
for (int i = 0; i < MAX_TASKS; i++)
{
if (g_Tasks[i].active && g_Tasks[i].id == id)
{
g_Tasks[i].active = false;
g_Tasks[i].id = 0;
return true;
}
}
return false;
}
// Remove all tasks belonging to a user
void remove_tasks_for_user(uint8 user_id)
{
for (int i = 0; i < MAX_TASKS; i++)
{
if (g_Tasks[i].active && g_Tasks[i].user_id == user_id)
{
g_Tasks[i].active = false;
g_Tasks[i].id = 0;
}
}
}
// Simple insertion sort for small arrays — sort task pointers by due_date
// ascending
void sort_tasks_by_due_date(task_t **arr, int count)
{
for (int i = 1; i < count; i++)
{
task_t *key = arr[i];
int j = i - 1;
while (j >= 0 && arr[j]->due_date > key->due_date)
{
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
}
// Populate dummy tasks on boot for development iteration.
// Uses relative offsets from current time so due dates always make sense.
void seed_tasks()
{
int64 now = (int64)(esp_timer_get_time() / 1000000);
// 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) — 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) — 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
}

View File

@@ -1,13 +0,0 @@
#pragma once
#include "todo.hpp"
#include "types.hpp"
// Data store operations for 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();

View File

@@ -1,8 +0,0 @@
// clang-format off
#include "api/tasks/store.cpp"
#include "api/tasks/add.cpp"
#include "api/tasks/list.cpp"
#include "api/tasks/remove.cpp"
#include "api/tasks/upcoming.cpp"
#include "api/tasks/update.cpp"
// clang-format on

View File

@@ -1,62 +0,0 @@
// GET /api/tasks/upcoming — Today's tasks per user, grouped by period
#include "cJSON.h"
#include "esp_http_server.h"
#include "todo.hpp"
#include "types.hpp"
internal esp_err_t api_tasks_upcoming_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 *users_arr = cJSON_AddArrayToObject(root, "users");
for (int u = 0; u < MAX_USERS; u++)
{
if (!g_Users[u].active)
continue;
// Collect incomplete tasks for this user
// 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");
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", 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);
}
cJSON_AddItemToArray(users_arr, user_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_tasks_upcoming_uri = {
.uri = "/api/tasks/upcoming",
.method = HTTP_GET,
.handler = api_tasks_upcoming_handler,
.user_ctx = NULL};

View File

@@ -1,89 +0,0 @@
// POST /api/tasks/update — Modify a task
// 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"
#include "todo.hpp"
#include "types.hpp"
internal esp_err_t api_tasks_update_handler(httpd_req_t *req)
{
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
char buf[256];
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");
if (!cJSON_IsNumber(id_item))
{
cJSON_Delete(body);
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing 'id'");
return ESP_FAIL;
}
task_t *task = find_task((uint16)id_item->valueint);
if (!task)
{
cJSON_Delete(body);
httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Task not found");
return ESP_FAIL;
}
// Update fields if present
cJSON *title_item = cJSON_GetObjectItem(body, "title");
if (cJSON_IsString(title_item))
{
strlcpy(task->title, title_item->valuestring, sizeof(task->title));
}
cJSON *due_date_item = cJSON_GetObjectItem(body, "due_date");
if (cJSON_IsNumber(due_date_item))
{
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))
{
task->completed = cJSON_IsTrue(completed_item);
}
cJSON_Delete(body);
httpd_resp_sendstr(req, "{\"status\":\"ok\"}");
return ESP_OK;
}
internal const httpd_uri_t api_tasks_update_uri = {.uri = "/api/tasks/update",
.method = HTTP_POST,
.handler =
api_tasks_update_handler,
.user_ctx = NULL};

View File

@@ -1,67 +0,0 @@
// POST /api/users — Create a new user
// Body: {"name": "Alice"}
#include "cJSON.h"
#include "esp_http_server.h"
#include "types.hpp"
#include "user.hpp"
internal esp_err_t api_users_post_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 *name_item = cJSON_GetObjectItem(body, "name");
if (!cJSON_IsString(name_item) || strlen(name_item->valuestring) == 0)
{
cJSON_Delete(body);
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing 'name'");
return ESP_FAIL;
}
user_t *user = add_user(name_item->valuestring);
cJSON_Delete(body);
if (!user)
{
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"User limit reached");
return ESP_FAIL;
}
cJSON *resp = cJSON_CreateObject();
cJSON_AddNumberToObject(resp, "id", user->id);
cJSON_AddStringToObject(resp, "name", user->name);
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_post_uri = {.uri = "/api/users",
.method = HTTP_POST,
.handler =
api_users_post_handler,
.user_ctx = NULL};

View File

@@ -1,41 +0,0 @@
// GET /api/users — List all active users
#include "cJSON.h"
#include "esp_http_server.h"
#include "types.hpp"
#include "user.hpp"
internal esp_err_t api_users_get_handler(httpd_req_t *req)
{
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
cJSON *arr = cJSON_CreateArray();
for (int i = 0; i < MAX_USERS; i++)
{
if (g_Users[i].active)
{
cJSON *obj = cJSON_CreateObject();
cJSON_AddNumberToObject(obj, "id", g_Users[i].id);
cJSON_AddStringToObject(obj, "name", g_Users[i].name);
cJSON_AddItemToArray(arr, obj);
}
}
const char *json = cJSON_PrintUnformatted(arr);
httpd_resp_sendstr(req, json);
free((void *)json);
cJSON_Delete(arr);
return ESP_OK;
}
internal const httpd_uri_t api_users_get_uri = {.uri = "/api/users",
.method = HTTP_GET,
.handler =
api_users_get_handler,
.user_ctx = NULL};

View File

@@ -1,47 +0,0 @@
// DELETE /api/users?id=N — Delete a user and cascade-delete their tasks
#include "esp_http_server.h"
#include "api/tasks/store.hpp"
#include "api/users/store.hpp"
#include "types.hpp"
internal esp_err_t api_users_delete_handler(httpd_req_t *req)
{
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
char query[32] = {};
if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK)
{
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing query param 'id'");
return ESP_FAIL;
}
char id_str[8] = {};
if (httpd_query_key_value(query, "id", id_str, sizeof(id_str)) != ESP_OK)
{
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing query param 'id'");
return ESP_FAIL;
}
uint8 id = (uint8)atoi(id_str);
// Cascade: remove all tasks belonging to this user
remove_tasks_for_user(id);
if (!remove_user(id))
{
httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "User not found");
return ESP_FAIL;
}
httpd_resp_sendstr(req, "{\"status\":\"ok\"}");
return ESP_OK;
}
internal const httpd_uri_t api_users_delete_uri = {.uri = "/api/users",
.method = HTTP_DELETE,
.handler =
api_users_delete_handler,
.user_ctx = NULL};

View File

@@ -1,67 +0,0 @@
// User data store: CRUD helpers and seed data
#include "api/users/store.hpp"
// Find a user by ID, returns nullptr if not found
user_t *find_user(uint8 id)
{
for (int i = 0; i < MAX_USERS; i++)
{
if (g_Users[i].active && g_Users[i].id == id)
{
return &g_Users[i];
}
}
return nullptr;
}
// Add a user, returns pointer to new user or nullptr if full
user_t *add_user(const char *name)
{
for (int i = 0; i < MAX_USERS; i++)
{
if (!g_Users[i].active)
{
g_Users[i].id = g_NextUserId++;
strlcpy(g_Users[i].name, name, sizeof(g_Users[i].name));
g_Users[i].active = true;
return &g_Users[i];
}
}
return nullptr;
}
// Remove a user by ID, returns true if found and removed
bool remove_user(uint8 id)
{
for (int i = 0; i < MAX_USERS; i++)
{
if (g_Users[i].active && g_Users[i].id == id)
{
g_Users[i].active = false;
g_Users[i].id = 0;
g_Users[i].name[0] = '\0';
return true;
}
}
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
void seed_users()
{
add_user("Alice");
add_user("Bob");
add_user("Charlie");
}

View File

@@ -1,11 +0,0 @@
#pragma once
#include "types.hpp"
#include "user.hpp"
// Data store operations for 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();

View File

@@ -1,7 +0,0 @@
// clang-format off
#include "api/users/store.cpp"
#include "api/users/list.cpp"
#include "api/users/add.cpp"
#include "api/users/remove.cpp"
#include "api/users/update.cpp"
// clang-format on

View File

@@ -1,68 +0,0 @@
// 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};

View File

@@ -2,7 +2,7 @@
#include "types.hpp" #include "types.hpp"
// Shared Application State // Shared Application State (Unity Build)
extern bool g_Ethernet_Initialized; internal bool g_Ethernet_Initialized = false;
extern bool g_Wifi_Initialized; internal bool g_Wifi_Initialized = false;
extern uint8_t g_Active_WWW_Partition; internal uint8_t g_Active_WWW_Partition = 0;

View File

@@ -3,13 +3,11 @@
#include <string.h> #include <string.h>
// SDK // SDK
#include "driver/gpio.h"
#include "esp_err.h" #include "esp_err.h"
#include "esp_eth.h" #include "esp_eth.h"
#include "esp_eth_driver.h" #include "esp_eth_driver.h"
#include "esp_event.h" #include "esp_event.h"
#include "esp_log.h" #include "esp_log.h"
#include "esp_sleep.h"
#include "esp_wifi.h" #include "esp_wifi.h"
#include "ethernet_init.h" #include "ethernet_init.h"
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
@@ -20,11 +18,9 @@
#include "types.hpp" #include "types.hpp"
// Forward declarations // Forward declarations
#if CONFIG_CALENDINK_BLINK_IP
internal esp_err_t get_ip_info(esp_netif_ip_info_t *ip_info); 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 led_blink_number(int n, uint8_t r, uint8_t g, uint8_t b);
internal void blink_last_ip_octet(); internal void blink_last_ip_octet();
#endif
// Internal states // Internal states
internal esp_netif_ip_info_t s_current_ip_info = {}; internal esp_netif_ip_info_t s_current_ip_info = {};
@@ -74,10 +70,6 @@ void ethernet_event_handler(void *arg, esp_event_base_t event_base,
{ {
ESP_LOGI(kLogEthernet, "Ethernet Link Down"); ESP_LOGI(kLogEthernet, "Ethernet Link Down");
s_eth_link_up = false; s_eth_link_up = false;
if (s_semph_eth_link)
{
xSemaphoreGive(s_semph_eth_link);
}
} }
} }
@@ -165,9 +157,7 @@ internal esp_err_t connect_ethernet(bool blockUntilIPAcquired)
{ {
ESP_LOGI(kLogEthernet, "Waiting for IP address."); ESP_LOGI(kLogEthernet, "Waiting for IP address.");
xSemaphoreTake(s_semph_get_ip_addrs, portMAX_DELAY); xSemaphoreTake(s_semph_get_ip_addrs, portMAX_DELAY);
#if CONFIG_CALENDINK_BLINK_IP
blink_last_ip_octet(); blink_last_ip_octet();
#endif
} }
return ESP_OK; return ESP_OK;
@@ -192,18 +182,10 @@ internal esp_err_t check_ethernet_connection(uint32_t timeoutSeconds)
{ {
if (!xSemaphoreTake(s_semph_eth_link, pdMS_TO_TICKS(5000))) if (!xSemaphoreTake(s_semph_eth_link, pdMS_TO_TICKS(5000)))
{ {
ESP_LOGE( ESP_LOGE(kLogEthernet,
kLogEthernet, "No physical Ethernet link detected. Skipping DHCP wait.");
"No physical Ethernet link detected (Timeout). Skipping DHCP wait.");
return ESP_ERR_INVALID_STATE; // Special error to skip retries 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.", ESP_LOGI(kLogEthernet, "Waiting for IP address for %d seconds.",
@@ -211,9 +193,7 @@ internal esp_err_t check_ethernet_connection(uint32_t timeoutSeconds)
if (xSemaphoreTake(s_semph_get_ip_addrs, if (xSemaphoreTake(s_semph_get_ip_addrs,
pdMS_TO_TICKS(timeoutSeconds * 1000))) pdMS_TO_TICKS(timeoutSeconds * 1000)))
{ {
#if CONFIG_CALENDINK_BLINK_IP
blink_last_ip_octet(); blink_last_ip_octet();
#endif
return ESP_OK; return ESP_OK;
} }
else else
@@ -229,11 +209,15 @@ internal esp_netif_t *s_wifi_netif = nullptr;
internal SemaphoreHandle_t s_semph_get_wifi_ip_addrs = nullptr; internal SemaphoreHandle_t s_semph_get_wifi_ip_addrs = nullptr;
internal SemaphoreHandle_t s_semph_wifi_link = nullptr; internal SemaphoreHandle_t s_semph_wifi_link = nullptr;
internal volatile bool s_wifi_link_up = false; internal volatile bool s_wifi_link_up = false;
internal TimerHandle_t s_wifi_reconnect_timer = nullptr;
#ifndef NDEBUG #ifndef NDEBUG
internal bool s_wifi_connected = false; internal bool s_wifi_connected = false;
#endif #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, void wifi_event_handler(void *arg, esp_event_base_t event_base,
int32_t event_id, void *event_data) int32_t event_id, void *event_data)
{ {
@@ -248,11 +232,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) else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED)
{ {
ESP_LOGI(kLogWifi, "WiFi disconnected."); ESP_LOGI(kLogWifi, "WiFi disconnected. Scheduling reconnect...");
s_wifi_link_up = false; s_wifi_link_up = false;
if (s_semph_wifi_link) if (s_wifi_reconnect_timer != nullptr)
{ {
xSemaphoreGive(s_semph_wifi_link); xTimerStart(s_wifi_reconnect_timer, 0);
} }
} }
else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP)
@@ -270,8 +254,20 @@ 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() 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) if (s_semph_wifi_link != nullptr)
{ {
@@ -317,6 +313,11 @@ internal esp_err_t connect_wifi(const char *ssid, const char *password,
return ESP_ERR_NO_MEM; 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 // esp_netif_init() is already called by Ethernet, but safe to call multiple
// times or we can assume it's initialized. // times or we can assume it's initialized.
s_wifi_netif = esp_netif_create_default_wifi_sta(); s_wifi_netif = esp_netif_create_default_wifi_sta();
@@ -342,16 +343,6 @@ 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_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
ESP_ERROR_CHECK(esp_wifi_start()); 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_wifi_connect());
ESP_ERROR_CHECK(esp_register_shutdown_handler(&teardown_wifi)); ESP_ERROR_CHECK(esp_register_shutdown_handler(&teardown_wifi));
@@ -360,9 +351,7 @@ internal esp_err_t connect_wifi(const char *ssid, const char *password,
{ {
ESP_LOGI(kLogWifi, "Waiting for IP address."); ESP_LOGI(kLogWifi, "Waiting for IP address.");
xSemaphoreTake(s_semph_get_wifi_ip_addrs, portMAX_DELAY); xSemaphoreTake(s_semph_get_wifi_ip_addrs, portMAX_DELAY);
#if CONFIG_CALENDINK_BLINK_IP
blink_last_ip_octet(); blink_last_ip_octet();
#endif
} }
return ESP_OK; return ESP_OK;
@@ -383,6 +372,10 @@ internal esp_err_t check_wifi_connection(uint32_t timeoutSeconds)
{ {
// Wait up to 10000ms for the physical link to associate with the AP // Wait up to 10000ms for the physical link to associate with the AP
if (!s_wifi_link_up) 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, ESP_LOGI(kLogWifi,
"Physical link is down. Requesting immediate AP association..."); "Physical link is down. Requesting immediate AP association...");
@@ -391,28 +384,20 @@ internal esp_err_t check_wifi_connection(uint32_t timeoutSeconds)
{ {
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))) if (!xSemaphoreTake(s_semph_wifi_link, pdMS_TO_TICKS(10000)))
{ {
ESP_LOGE(kLogWifi, "Failed to associate with WiFi AP (Timeout)."); ESP_LOGE(kLogWifi, "Failed to associate with WiFi AP.");
return ESP_ERR_TIMEOUT; // Return timeout so main.cpp triggers a retry 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); ESP_LOGI(kLogWifi, "Waiting for IP address for %d seconds.", timeoutSeconds);
if (xSemaphoreTake(s_semph_get_wifi_ip_addrs, if (xSemaphoreTake(s_semph_get_wifi_ip_addrs,
pdMS_TO_TICKS(timeoutSeconds * 1000))) pdMS_TO_TICKS(timeoutSeconds * 1000)))
{ {
#if CONFIG_CALENDINK_BLINK_IP
blink_last_ip_octet(); blink_last_ip_octet();
#endif
return ESP_OK; return ESP_OK;
} }
else else
@@ -421,13 +406,14 @@ 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) internal esp_err_t get_ip_info(esp_netif_ip_info_t *ip_info)
{ {
*ip_info = s_current_ip_info; *ip_info = s_current_ip_info;
return ESP_OK; 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() internal void blink_last_ip_octet()
{ {
esp_netif_ip_info_t ip_info; esp_netif_ip_info_t ip_info;
@@ -452,4 +438,3 @@ internal void blink_last_ip_octet()
led_blink_number(u, 255, 255, 255); led_blink_number(u, 255, 255, 255);
} }
} }
#endif

View File

@@ -1,25 +0,0 @@
#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] = {};

View File

@@ -13,16 +13,11 @@
#endif #endif
// Project // Project
#include "api/ota/bundle.cpp"
#include "api/ota/firmware.cpp"
#include "api/ota/frontend.cpp" #include "api/ota/frontend.cpp"
#include "api/ota/status.cpp" #include "api/ota/status.cpp"
#include "api/system/info.cpp" #include "api/system/info.cpp"
#include "api/system/reboot.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"
internal const char *TAG = "HTTP_SERVER"; internal const char *TAG = "HTTP_SERVER";
@@ -30,53 +25,11 @@ constexpr uint8 kGZ_Extension_Length = sizeof(".gz") - 1;
#define FILE_PATH_MAX (ESP_VFS_PATH_MAX + 128) #define FILE_PATH_MAX (ESP_VFS_PATH_MAX + 128)
#define SCRATCH_BUFSIZE 4096 #define SCRATCH_BUFSIZE 4096
#define MAX_SCRATCH_BUFFERS 10
typedef struct typedef struct
{ {
char *buffers[MAX_SCRATCH_BUFFERS]; char scratch[SCRATCH_BUFSIZE];
bool in_use[MAX_SCRATCH_BUFFERS]; } http_server_data_t;
} 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 #ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES
// Set HTTP response content type according to file extension // Set HTTP response content type according to file extension
@@ -182,14 +135,8 @@ internal esp_err_t static_file_handler(httpd_req_t *req)
set_content_type_from_file(req, filepath); set_content_type_from_file(req, filepath);
char *chunk = get_scratch_buffer(); http_server_data_t *rest_context = (http_server_data_t *)req->user_ctx;
if (chunk == NULL) char *chunk = rest_context->scratch;
{
close(fd);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Server busy");
return ESP_FAIL;
}
ssize_t read_bytes; ssize_t read_bytes;
do do
@@ -205,7 +152,6 @@ internal esp_err_t static_file_handler(httpd_req_t *req)
{ {
close(fd); close(fd);
ESP_LOGE(TAG, "File sending failed!"); ESP_LOGE(TAG, "File sending failed!");
free_scratch_buffer(chunk);
httpd_resp_sendstr_chunk(req, NULL); // Abort sending httpd_resp_sendstr_chunk(req, NULL); // Abort sending
return ESP_FAIL; return ESP_FAIL;
} }
@@ -213,7 +159,6 @@ internal esp_err_t static_file_handler(httpd_req_t *req)
} while (read_bytes > 0); } while (read_bytes > 0);
close(fd); close(fd);
free_scratch_buffer(chunk);
httpd_resp_send_chunk(req, NULL, 0); // End response httpd_resp_send_chunk(req, NULL, 0); // End response
return ESP_OK; return ESP_OK;
@@ -224,8 +169,7 @@ internal esp_err_t static_file_handler(httpd_req_t *req)
internal esp_err_t cors_options_handler(httpd_req_t *req) internal esp_err_t cors_options_handler(httpd_req_t *req)
{ {
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
httpd_resp_set_hdr(req, "Access-Control-Allow-Methods", httpd_resp_set_hdr(req, "Access-Control-Allow-Methods", "GET, POST, OPTIONS");
"GET, POST, DELETE, OPTIONS");
httpd_resp_set_hdr(req, "Access-Control-Allow-Headers", "Content-Type"); httpd_resp_set_hdr(req, "Access-Control-Allow-Headers", "Content-Type");
httpd_resp_set_status(req, "204 No Content"); httpd_resp_set_status(req, "204 No Content");
httpd_resp_send(req, NULL, 0); httpd_resp_send(req, NULL, 0);
@@ -238,7 +182,6 @@ internal httpd_handle_t start_webserver(void)
esp_vfs_littlefs_conf_t conf = {}; esp_vfs_littlefs_conf_t conf = {};
conf.base_path = "/www"; conf.base_path = "/www";
conf.partition_label = g_Active_WWW_Partition == 0 ? "www_0" : "www_1"; 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.format_if_mount_failed = false;
conf.dont_mount = false; conf.dont_mount = false;
esp_err_t ret = esp_vfs_littlefs_register(&conf); esp_err_t ret = esp_vfs_littlefs_register(&conf);
@@ -262,13 +205,17 @@ internal httpd_handle_t start_webserver(void)
ESP_LOGI(TAG, "LittleFS mounted on /www"); ESP_LOGI(TAG, "LittleFS mounted on /www");
} }
#endif #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(); httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.uri_match_fn = httpd_uri_match_wildcard; config.uri_match_fn = httpd_uri_match_wildcard;
config.max_uri_handlers = 26; config.max_uri_handlers = 10; // We have info, reboot, options, and static
config.max_open_sockets = 24;
config.lru_purge_enable = true;
config.stack_size = 16384;
httpd_handle_t server = NULL; httpd_handle_t server = NULL;
ESP_LOGI(TAG, "Starting HTTP Server on port: '%d'", config.server_port); ESP_LOGI(TAG, "Starting HTTP Server on port: '%d'", config.server_port);
@@ -285,42 +232,15 @@ internal httpd_handle_t start_webserver(void)
// Register system API routes // Register system API routes
httpd_register_uri_handler(server, &api_system_info_uri); httpd_register_uri_handler(server, &api_system_info_uri);
httpd_register_uri_handler(server, &api_system_reboot_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_status_uri);
httpd_register_uri_handler(server, &api_ota_frontend_uri); httpd_register_uri_handler(server, &api_ota_frontend_uri);
httpd_register_uri_handler(server, &api_ota_firmware_uri);
httpd_register_uri_handler(server, &api_ota_bundle_uri);
// 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);
httpd_register_uri_handler(server, &api_tasks_post_uri);
httpd_register_uri_handler(server, &api_tasks_update_uri);
httpd_register_uri_handler(server, &api_tasks_delete_uri);
// Register device API routes
httpd_register_uri_handler(server, &api_devices_get_uri);
httpd_register_uri_handler(server, &api_devices_register_uri);
httpd_register_uri_handler(server, &api_devices_layout_uri);
httpd_register_uri_handler(server, &api_devices_screen_info_uri);
httpd_register_uri_handler(server, &api_devices_screen_image_uri);
// Populate dummy data for development (debug builds only)
#ifndef NDEBUG
seed_users();
seed_tasks();
#endif
#ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES #ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES
// Register static file handler last as a catch-all wildcard if deployed // Register static file handler last as a catch-all wildcard if deployed
httpd_uri_t static_get_uri = {.uri = "/*", httpd_uri_t static_get_uri = {.uri = "/*",
.method = HTTP_GET, .method = HTTP_GET,
.handler = static_file_handler, .handler = static_file_handler,
.user_ctx = NULL}; .user_ctx = rest_context};
httpd_register_uri_handler(server, &static_get_uri); httpd_register_uri_handler(server, &static_get_uri);
#endif #endif
@@ -328,16 +248,18 @@ internal httpd_handle_t start_webserver(void)
} }
ESP_LOGE(TAG, "Error starting server!"); ESP_LOGE(TAG, "Error starting server!");
free(rest_context);
return NULL; return NULL;
} }
internal void stop_webserver(httpd_handle_t server, uint8_t partition_index) internal void stop_webserver(httpd_handle_t server)
{ {
if (server) if (server)
{ {
httpd_stop(server); httpd_stop(server);
#ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES #ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES
esp_vfs_littlefs_unregister(partition_index == 0 ? "www_0" : "www_1"); esp_vfs_littlefs_unregister(g_Active_WWW_Partition == 0 ? "www_0"
: "www_1");
#endif #endif
} }
} }

View File

@@ -15,7 +15,5 @@ dependencies:
# # All dependencies of `main` are public by default. # # All dependencies of `main` are public by default.
# public: true # public: true
espressif/led_strip: ^3.0.3 espressif/led_strip: ^3.0.3
espressif/mdns: ^1.4.1
espressif/ethernet_init: ^1.3.0 espressif/ethernet_init: ^1.3.0
joltwallet/littlefs: "^1.20" # https://github.com/joltwallet/esp_littlefs joltwallet/littlefs: "^1.20" # https://github.com/joltwallet/esp_littlefs
lvgl/lvgl: "9.4.0"

View File

@@ -58,8 +58,6 @@ internal void set_led_status(led_status status)
} }
led_strip_refresh(led_strip); 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) internal void led_blink_number(int n, uint8_t r, uint8_t g, uint8_t b)
{ {
if (n <= 0) if (n <= 0)
@@ -87,4 +85,3 @@ internal void led_blink_number(int n, uint8_t r, uint8_t g, uint8_t b)
} }
vTaskDelay(pdMS_TO_TICKS(1000)); vTaskDelay(pdMS_TO_TICKS(1000));
} }
#endif

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,163 +0,0 @@
#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;
}

View File

@@ -1,14 +0,0 @@
#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

View File

@@ -1,132 +0,0 @@
#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);
}

View File

@@ -1,11 +0,0 @@
#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();

View File

@@ -1,13 +1,8 @@
// STD Lib // STD Lib
#include <stdio.h> #include <stdio.h>
#include <string.h>
// SDK // SDK
#include "esp_log.h" #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/FreeRTOS.h"
#include "freertos/task.h" #include "freertos/task.h"
#include "nvs.h" #include "nvs.h"
@@ -15,6 +10,7 @@
#include "sdkconfig.h" #include "sdkconfig.h"
#include "soc/gpio_num.h" #include "soc/gpio_num.h"
// Project headers // Project headers
#include "appstate.hpp" #include "appstate.hpp"
#include "types.hpp" #include "types.hpp"
@@ -24,46 +20,13 @@
#include "led_status.cpp" #include "led_status.cpp"
#include "connect.cpp" #include "connect.cpp"
#include "http_server.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 // clang-format on
internal const char *kTagMain = "MAIN"; internal constexpr bool kBlockUntilEthernetEstablished = false;
// 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() extern "C" void app_main()
{ {
ESP_LOGI(kTagMain, "Hello, Calendink OTA! [V0.1.1]"); printf("Hello, Worldi!\n");
ESP_LOGI(kTagMain, "PSRAM size: %d bytes", 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; httpd_handle_t web_server = NULL;
@@ -78,29 +41,11 @@ extern "C" void app_main()
nvs_handle_t my_handle; nvs_handle_t my_handle;
if (nvs_open("storage", NVS_READWRITE, &my_handle) == ESP_OK) if (nvs_open("storage", NVS_READWRITE, &my_handle) == ESP_OK)
{ {
// Read active www partition from NVS
err = nvs_get_u8(my_handle, "www_part", &g_Active_WWW_Partition); err = nvs_get_u8(my_handle, "www_part", &g_Active_WWW_Partition);
if (err == ESP_OK) if (err != ESP_OK && err != ESP_ERR_NVS_NOT_FOUND)
{ {
ESP_LOGI(kTagMain, "NVS: Found active www partition: %d", printf("Error reading www_part from NVS: %s\n", esp_err_to_name(err));
g_Active_WWW_Partition);
} }
if (err == ESP_ERR_NVS_NOT_FOUND)
{
// First boot (no NVS key yet): default to www_0
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)
{
ESP_LOGE(kTagMain, "Error reading www_part from NVS: %s",
esp_err_to_name(err));
g_Active_WWW_Partition = 0;
}
if (g_Active_WWW_Partition > 1) if (g_Active_WWW_Partition > 1)
{ {
g_Active_WWW_Partition = 0; g_Active_WWW_Partition = 0;
@@ -109,78 +54,7 @@ extern "C" void app_main()
} }
else else
{ {
ESP_LOGE(kTagMain, "Error opening NVS handle!"); printf("Error opening NVS handle!\n");
}
// 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()); ESP_ERROR_CHECK(esp_event_loop_create_default());
@@ -208,7 +82,7 @@ extern "C" void app_main()
if (result == ESP_ERR_INVALID_STATE) if (result == ESP_ERR_INVALID_STATE)
{ {
ESP_LOGW(kTagMain, "Ethernet cable not plugged in, skipping retries."); printf("Ethernet cable not plugged in, skipping retries.\n");
break; break;
} }
@@ -225,7 +99,7 @@ extern "C" void app_main()
if (result != ESP_OK) if (result != ESP_OK)
{ {
ESP_LOGW(kTagMain, "Ethernet failed, trying wifi"); printf("Ethernet failed, trying wifi\n");
disconnect_ethernet(); disconnect_ethernet();
g_Ethernet_Initialized = false; g_Ethernet_Initialized = false;
@@ -262,109 +136,36 @@ extern "C" void app_main()
if (result != ESP_OK) if (result != ESP_OK)
{ {
ESP_LOGE(kTagMain, "Wifi failed."); printf("Wifi failed.\n");
goto shutdown; goto shutdown;
} }
set_led_status(led_status::ReadyWifi); set_led_status(led_status::ReadyWifi);
ESP_LOGI(kTagMain, "Will use Wifi!"); printf("Will use Wifi!\n");
} }
else else
{ {
set_led_status(led_status::ReadyEthernet); set_led_status(led_status::ReadyEthernet);
ESP_LOGI(kTagMain, "Will use Ethernet!"); printf("Will use Ethernet!\n");
} }
ESP_LOGI(kTagMain, "Connected! IP acquired."); printf("Connected!\n");
#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 // Start the webserver
web_server = start_webserver(); web_server = start_webserver();
// Start mDNS
start_mdns();
// Mark the current app as valid to cancel rollback, only if it's an OTA app
{
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 // Keep the main task alive indefinitely
{
#if CONFIG_PM_PROFILING
int pm_dump_counter = 0;
#endif
while (true) while (true)
{ {
vTaskDelay(pdMS_TO_TICKS(100)); vTaskDelay(pdMS_TO_TICKS(1000));
#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: shutdown:
ESP_LOGE(kTagMain, "Shutting down."); printf("Shutting down.\n");
if (web_server) if (web_server)
{ {
stop_webserver(web_server, g_Active_WWW_Partition); stop_webserver(web_server);
web_server = NULL; web_server = NULL;
} }

View File

@@ -1,35 +0,0 @@
#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);
}

View File

@@ -1,30 +0,0 @@
#pragma once
#include <cstring>
#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
{
char title[64]; // Task description
int64 due_date; // Unix timestamp (seconds) - used when recurrence is 0
uint16 id; // Auto-assigned (165535, 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;
internal task_t g_Tasks[MAX_TASKS] = {};
internal uint16 g_NextTaskId = 1;

View File

@@ -1,71 +0,0 @@
#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);
}

View File

@@ -1,16 +0,0 @@
#pragma once
#include <cstring>
#include "types.hpp"
struct user_t
{
uint8 id; // Auto-assigned (1255, 0 = empty slot)
char name[32]; // Display name
bool active; // Slot in use
};
constexpr int MAX_USERS = 8;
internal user_t g_Users[MAX_USERS] = {};
internal uint8 g_NextUserId = 1;

View File

@@ -1,102 +1,3 @@
CONFIG_IDF_TARGET="esp32s3"
CONFIG_PARTITION_TABLE_CUSTOM=y CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
CONFIG_PARTITION_TABLE_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

View File

@@ -1,51 +0,0 @@
# 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.

View File

@@ -1,67 +0,0 @@
# 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.

View File

@@ -7,44 +7,38 @@
## 1. Goal ## 1. Goal
Implement a robust Over-The-Air (OTA) update mechanism for both the main firmware of the ESP32-S3 and the Svelte frontend. The update must: Implement a robust Over-The-Air (OTA) update mechanism specifically for the main firmware of the ESP32-S3. The update must:
- Update the core application logic and the user interface without requiring a physical USB connection. - Update the core application logic without requiring a physical USB connection.
- Keep the Firmware and Frontend in sync by allowing them to be updated together atomically.
- Provide a reliable fallback if an update fails (Rollback capability via A/B slots). - Provide a reliable fallback if an update fails (Rollback capability via A/B slots).
- Provide a permanent "factory" fallback as an extreme safety measure. - Provide a permanent "factory" fallback as an extreme safety measure.
- Prevent accidental cross-flashing (e.g., flashing UI to firmware slots). - Integrate seamlessly with the existing Svelte frontend UI for a push-based update experience.
- Maintain a clear versioning scheme visible to the user, with accurate partition space reporting. - Maintain a clear versioning scheme visible to the user.
## 2. Chosen Approach ## 2. Chosen Approach
We implemented a **Universal Dual-Partition OTA system** using ESP-IDF's native OTA mechanisms for the firmware and LittleFS for the frontend. We implemented a **Dual-Partition Image Flash (A/B slots) with Factory Fallback** strategy using ESP-IDF's native OTA mechanisms.
Updates can be performed individually (Firmware only via `.bin`, Frontend only via `.bin`), but the primary and recommended approach is the **Universal OTA Bundle**. The build process generates a single `.bin` firmware image. This image is uploaded via the frontend UI and streamed directly to the inactive OTA flash partition (`ota_0` or `ota_1`). Upon successful transfer and validation, the bootloader is instructed to boot from the new partition on the next restart.
The build process generates a single `.bundle` file containing both the firmware image and the compiled frontend filesystem. This bundle is uploaded via the frontend UI, streamed directly to the inactive OTA flash partition (`ota_0` or `ota_1`) and inactive UI partition (`www_0` or `www_1`). Upon successful transfer and validation of both components, the bootloader and NVS are instructed to switch active partitions on the next restart.
## 3. Design Decisions & Trade-offs ## 3. Design Decisions & Trade-offs
### 3.1. Why Dual-Partition (A/B) with Factory? ### 3.1. Why Dual-Partition (A/B) with Factory?
- **Safety**: A failed or interrupted upload never "bricks" the device. - **Safety**: A failed or interrupted upload never "bricks" the device.
- **Factory Fallback**: By maintaining a dedicated 2MB `factory` partition alongside the two 2MB OTA partitions (`ota_0`, `ota_1`), we ensure that even if both OTA slots are irrecoverably corrupted, the device can always boot into a known-good state. - **Factory Fallback**: By maintaining a dedicated 2MB `factory` partition alongside the two 2MB OTA partitions (`ota_0`, `ota_1`), we ensure that even if both OTA slots are irrecoverably corrupted, the device can always boot into a known-good state. This requires an initial USB flash to set up but provides maximum long-term reliability.
- **Frontend Sync**: The frontend also uses a dual-partition layout (`www_0`, `www_1`). The Universal Bundle ensures both FW and UI switch together. - **Storage Allocation**: With 16MB of total flash on the ESP32-S3, dedicating 6MB to application code (3x 2MB) is a worthwhile trade-off for extreme resilience, while still leaving ample room for the frontend (`www_0`, `www_1`) and NVS.
### 3.2. Automatic App Rollback ### 3.2. Automatic App Rollback
We rely on ESP-IDF's built-in "App Rollback" feature. We rely on ESP-IDF's built-in "App Rollback" feature.
- **The Mechanism**: When the ESP32 boots a newly OTA-flashed firmware, it is marked as "Pending Verify". If the application crashes or fails to mark itself as "valid", the bootloader reverts to the previous working partition. - **The Mechanism**: When the ESP32 boots a newly OTA-flashed firmware, it is marked as "Pending Verify". If the application crashes, resets, or fails to explicitly mark itself as "valid" during this initial boot, the bootloader automatically reverts to the previous working partition on the subsequent boot.
- **Validation Point**: We consider the firmware "valid" only after it successfully establishes a network connection. - **Validation Point**: We consider the firmware "valid" (and stop the rollback timer) only after it successfully establishes a network connection (Ethernet or WiFi). This ensures that a bad update won't permanently disconnect the device from future OTA attempts.
### 3.3. Universal Bundle Format & Automation ### 3.3. Push vs. Pull Updates
- **Format**: A custom 12-byte header (`BNDL` magic + 4-byte FW size + 4-byte UI size) followed by the FW binary and UI binary. - **Decision**: We implemented a "Push" mechanism where the user manually uploads the `.bin` file via the web UI.
- **Automation**: The Svelte build chain automates packaging. Running `npm run ota:bundle` automatically triggers Vite production build, LittleFS frontend packaging, applies proper semantic version sorting (to always pick the latest compiled UI), and generates the `.bundle` payload. - **Rationale**: This matches the existing frontend OTA workflow and is simpler to implement initially. It avoids the need for external update servers, manifest files, and polling mechanisms. A "Pull" mechanism can be added later if fleet management becomes a requirement.
### 3.4. Safety & Validation ### 3.4. Versioning Strategy
- **Magic Number Checks**: The backend enforces strict validation before writing to flash. Firmware endpoints and bundle streams check for the ESP32 image magic byte (`0xE9`), and Bundle endpoints check for the `BNDL` magic header. This prevents a user from accidentally uploading a LittleFS image to the Firmware slot, avoiding immediate boot loops. - **Decision**: We extract the firmware version directly from the ESP-IDF natively embedded `esp_app_desc_t` structure.
- **Atomic Commits**: The Universal Bundle handler only sets the new boot partition and updates the NVS UI partition index if *both* firmware and frontend streams complete successfully. - **Rationale**: This ensures the version reported by the API (`GET /api/system/info`) is exactly the version compiled by CMake (`PROJECT_VER`), eliminating the risk of manual mismatches or external version files getting out of sync.
### 3.5. Versioning & Partition Metadata
- **Firmware Versioning**: Extracted natively from `esp_app_desc_t`, syncing API version with CMake `PROJECT_VER`.
- **Space Reporting**: The system dynamically scans App partitions using `esp_image_get_metadata()` to determine the exact binary size flashed in each slot. This allows the UI to display accurate "used" and "free" space per partition, regardless of the fixed partition size.
## 4. Final Architecture ## 4. Final Architecture
@@ -62,18 +56,17 @@ www_1, data, littlefs, , 1M
``` ```
### 4.2. Backend Components ### 4.2. Backend Components
- `bundle.cpp`: Handles `POST /api/ota/bundle`. Streams the file, splitting it on the fly into the inactive `ota` and `www` partitions. - `main/api/ota/firmware.cpp`: The endpoint (`POST /api/ota/firmware`) handling the streaming ingestion of the `.bin` file using standard ESP-IDF `esp_ota` functions.
- `firmware.cpp` & `frontend.cpp`: Handles individual component updates. - `main/api/system/system.cpp`: The endpoint querying `esp_app_get_description()` to expose the unified version payload to the frontend.
- `status.cpp`: Uses `esp_partition_find` and `esp_image_get_metadata` to report partition sizes and active slots. - `main/main.cpp`: The orchestrator that calls `esp_ota_mark_app_valid_cancel_rollback()` post-network connection.
- `main.cpp`: Calls `esp_ota_mark_app_valid_cancel_rollback()` post-network connection and manages NVS synchronization for the UI slot when booting from Factory.
### 4.3. UI/UX Implementation ### 4.3. UI/UX Implementation
- The Svelte Dashboard features a comprehensive "Update System" component supporting individual (FW/UI) and combined (Bundle) uploads. - The Frontend OTA update component (`OTAUpdate.svelte`) is expanded to include a parallel "Firmware Update" section.
- A "Partition Table" view provides real-time visibility into the exact binary size, available free space, and version hash of every system and app partition. - This UI section handles file selection, upload progress visualization, and system reboot confirmation, providing parity with the existing frontend update UX.
## 5. Summary ## 5. Summary
We use **ESP-IDF's native OTA APIs** with a **Factory + Dual A/B Partition** layout, synchronized with a **Dual LittleFS Partition** layout for the frontend. The system relies on custom **Universal Bundles** to guarantee atomic FW+UI upgrades, protected by **Magic Number validations** and **Automatic App Rollbacks**. The entire process is driven from a highly integrated Svelte UI that leverages backend metadata extraction to provide accurate system insights. We use **ESP-IDF's native OTA APIs** with a **Factory + Dual A/B Partition** layout for maximum reliability. The system leverages **Automatic App Rollback** to prevent network lockouts from bad firmware. Versioning is natively controlled via the **CMake build descriptions**, and the entire update process is driven centrally from the **Svelte Frontend UI** via a Push-based REST endpoint.
--- ---
*Created by Antigravity - Last Updated: 2026-03-03* *Created by Antigravity - Last Updated: 2026-03-03*

View File

@@ -1,75 +0,0 @@
# 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*

View File

@@ -1,195 +0,0 @@
# Todo List System for ESP32-S3 Provider
**Authored by Antigravity (Claude Opus)**
**Date:** 2026-03-07
---
## 1. Goal
Add a user-managed todo list system to the Calendink Provider. The system must:
- Allow creating, modifying, and deleting **users** via a REST API.
- Allow creating, modifying, and deleting **tasks** per user, each with a due date.
- Display the **top 3 upcoming tasks** per user on the Dashboard home page.
- Introduce a **collapsible sidebar** to navigate between Dashboard and a detailed Task Manager view.
- Store everything **in RAM only** — all data is lost on reboot. Persistent storage (SQLite on SD card) is planned as a future phase.
- Pre-populate **seed data** on boot so the UI is immediately usable during development without manual setup after every reboot.
## 2. Chosen Approach
### 2.1. Backend: Static Arrays in BSS
Users and tasks are stored as **fixed-size static arrays** at file scope (BSS segment). This means:
- No `malloc` / `free` — zero fragmentation risk on a constrained device.
- Deterministic memory footprint: the arrays consume a fixed amount of RAM regardless of usage.
- Simple slot-based allocation: each entry has an `active` flag; adding an item finds the first inactive slot, deleting clears the flag.
Limits: `MAX_USERS = 8`, `MAX_TASKS = 32` (total across all users). At ~80 bytes per task and ~40 bytes per user, this uses <3KB of RAM — negligible against the ~247KB free heap the system typically has. These limits will become irrelevant when we migrate to SQLite.
### 2.2. Frontend: Sidebar + View Routing
The existing single-page dashboard is extended with a collapsible left sidebar. The `App.svelte` component gains a view state (`'dashboard' | 'tasks'`) and conditionally renders either the existing Dashboard content or a new `TodoManager` component. This avoids introducing a full client-side router for what is currently a two-view app.
### 2.3. API Style: RESTful with DELETE
We use standard REST conventions: `GET` for reads, `POST` for creates/updates, `DELETE` for removals. This requires updating the existing CORS handler to allow the `DELETE` method. We chose REST purity over the simpler "POST everything" approach because the API surface is small enough that the CORS overhead is negligible, and it sets the right pattern for future endpoint growth.
## 3. Design Decisions & Trade-offs
### 3.1. Why Static Arrays Over Heap Allocation?
| | Static Arrays (BSS) | Dynamic (malloc/vector) |
|---|---|---|
| **Fragmentation** | ✅ Impossible | ⚠️ Risk on long-running device |
| **Memory footprint** | Fixed, predictable | Grows/shrinks at runtime |
| **Complexity** | Trivial — array index + active flag | Requires careful lifetime management |
| **Scalability** | Hard limit (32 tasks) | Flexible |
| **Migration path** | Replace array access with SQLite queries | Same |
For a temporary in-memory store that will be replaced by SQLite, static arrays are the simpler and safer choice. The hard limits are acceptable given the short-lived nature of this storage layer.
### 3.2. Why a Dedicated `/api/tasks/upcoming` Endpoint?
The Dashboard needs to show the top 3 tasks per user, sorted by due date. Two options were considered:
1. **Client-side**: Fetch all tasks for all users, sort and slice on the frontend.
2. **Server-side**: Dedicated endpoint that returns pre-sorted, pre-sliced data.
We chose option 2 because:
- It reduces the number of API calls (1 instead of N per user).
- Sorting and filtering on the ESP32 is trivial for 32 items.
- The response payload is smaller — only the data the Dashboard actually needs.
### 3.3. Seed Data Strategy
Since all data is lost on reboot, manually re-creating users and tasks after every firmware flash would slow down development. Both `user.hpp` and `todo.hpp` expose `seed_users()` / `seed_tasks()` functions called at server startup. The seed tasks use **relative time offsets** from the current system time (e.g., "now + 1 day", "now + 3 days"), so due dates always appear realistic regardless of when the device boots.
### 3.4. Frontend Navigation: Sidebar vs. Tabs vs. Router
| | Sidebar | Tabs | Client-side Router |
|---|---|---|---|
| **Scalability** | ✅ Easily add more views | Limited horizontal space | Best for many routes |
| **Discoverability** | Always visible (or icon-only when collapsed) | Always visible | Requires URL navigation |
| **Complexity** | Low — simple boolean state | Low | Higher — routing library needed |
| **Mobile** | Collapsible — works well | Good | Good |
A sidebar is the best fit: it scales to future views (Calendar, Settings, etc.), gives a clear navigation model, and the collapsible behavior keeps it unobtrusive on smaller screens.
### 3.5. `max_uri_handlers` Increase
The ESP-IDF HTTP server has a compile-time limit on registered URI handlers (default: 8, currently set to 10). Adding 7 new endpoints (3 user + 4 task) brings the total to ~17. We increase the limit to 20 to accommodate the new routes with room for growth. Each handler slot costs minimal memory (~40 bytes).
## 4. Architecture
### 4.1. Data Models
```
user.hpp todo.hpp
┌─────────────────────┐ ┌──────────────────────────┐
│ user_t │ │ task_t │
│ id: uint8 │◄─────── │ user_id: uint8 │
│ name: char[32] │ │ id: uint16 │
│ active: bool │ │ title: char[64] │
└─────────────────────┘ │ due_date: int64 (epoch)│
g_Users[MAX_USERS=8] │ completed: bool │
│ active: bool │
└──────────────────────────┘
g_Tasks[MAX_TASKS=32]
```
### 4.2. API Endpoints
| Method | URI | Purpose |
|--------|-----|---------|
| `GET` | `/api/users` | List all active users |
| `POST` | `/api/users` | Create a user |
| `DELETE` | `/api/users?id=N` | Delete a user + cascade-delete their tasks |
| `GET` | `/api/tasks?user_id=N` | List tasks for a user |
| `GET` | `/api/tasks/upcoming` | Top 3 upcoming tasks per user (Dashboard) |
| `POST` | `/api/tasks` | Create a task |
| `POST` | `/api/tasks/update` | Modify a task (title, due date, completion) |
| `DELETE` | `/api/tasks?id=N` | Delete a task |
### 4.3. Backend File Structure
```
Provider/main/
├── user.hpp # user_t struct, g_Users[] (data only)
├── todo.hpp # task_t struct, g_Tasks[] (data only)
├── api/
│ ├── users/
│ │ ├── store.hpp # Forward declarations for user operations
│ │ ├── store.cpp # find_user, add_user, remove_user, seed_users
│ │ ├── list.cpp # GET /api/users
│ │ ├── add.cpp # POST /api/users
│ │ ├── remove.cpp # DELETE /api/users
│ │ └── unity.cpp # Includes all users/*.cpp
│ ├── tasks/
│ │ ├── store.hpp # Forward declarations for task operations
│ │ ├── store.cpp # find_task, add_task, remove_task, sort, seed_tasks
│ │ ├── list.cpp # GET /api/tasks?user_id=N
│ │ ├── upcoming.cpp # GET /api/tasks/upcoming
│ │ ├── add.cpp # POST /api/tasks
│ │ ├── update.cpp # POST /api/tasks/update
│ │ ├── remove.cpp # DELETE /api/tasks
│ │ └── unity.cpp # Includes all tasks/*.cpp
│ ├── system/
│ │ ├── info.cpp
│ │ └── reboot.cpp
│ └── ota/
│ └── ...
├── http_server.cpp # includes tasks/unity.cpp + users/unity.cpp
└── main.cpp # Entry point (unchanged)
```
### 4.4. Frontend Component Structure
```
frontend/src/
├── App.svelte # Layout with Sidebar + view routing
├── lib/
│ ├── Sidebar.svelte # Collapsible left nav
│ ├── UserManager.svelte # User selection & management
│ ├── TaskManager.svelte # Full task management UI (embeds UserManager)
│ ├── OTAUpdate.svelte # Existing OTA component
│ └── api.js # +user/task API functions
└── app.css # +sidebar theme tokens
```
## 5. Changelog
### 2026-03-07 — API Refactoring & UI Improvements
**Backend Refactoring:**
- Split monolithic `manage.cpp` and `utils.cpp` into single-responsibility files per endpoint (e.g., `list.cpp`, `add.cpp`, `remove.cpp`)
- Renamed `seed.cpp``store.cpp` in both `api/users/` and `api/tasks/` (better name for the data-store layer)
- Introduced `store.hpp` forward-declaration headers to resolve cross-domain dependencies (tasks needs `find_user`, users/remove needs `remove_tasks_for_user`)
- Added `unity.cpp` per domain to simplify `http_server.cpp` includes
- `.hpp` files contain structs and constants only; all functions live in `.cpp` files
**Frontend OTA Bug Fix:**
- Fixed `main.cpp` factory-partition check that unconditionally reset `www_part` to 0 on every boot, preventing OTA from ever switching to `www_1`
- Now only defaults to `www_0` on first boot (NVS key not found); OTA-written values are respected
**Date/Time Picker UX:**
- Fixed timezone drift bug: `formatDateForInput` used `toISOString()` (UTC) but `datetime-local` inputs use local time, causing ±N hours shift on each edit
- Split `datetime-local` into separate `date` + `time` inputs with labels
- Default date/time set to "now" when opening the add task form
- CSS trick to make Chrome/Edge open the picker on click anywhere in the input (`::-webkit-calendar-picker-indicator` stretched to fill)
**Dashboard Layout:**
- Moved "Upcoming Tasks" section above the system info grid (content-first, debug tools lower)
**Sidebar:**
- Auto-collapses on mobile (viewport ≤ 768px) via `window.innerWidth` check at init
**Build Warnings Fixed:**
- `<button>` inside `<button>` nesting in UserManager → changed user chips to `<div role="button">`
- `autofocus` a11y warnings suppressed with `<!-- svelte-ignore -->` comments
## 6. Summary
We implement a **temporary in-memory todo list** using **static BSS arrays** on the ESP32 backend, exposed via a **RESTful API** with 8 new endpoints. The frontend gains a **collapsible sidebar** for navigation between the existing Dashboard and a new **Task Manager** view. **Seed data** is populated on every boot for fast development iteration. The architecture is designed to make the eventual migration to **SQLite on SD card** straightforward — the API contracts and frontend components remain unchanged; only the storage layer swaps out.
---
*Created by Antigravity (Claude Opus) - Last Updated: 2026-03-07*