Compare commits
21 Commits
849d126ce0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 46dfe82568 | |||
| f0297418cf | |||
| 238b408947 | |||
| 6384e93020 | |||
| a9d5aa83dc | |||
| b702839f8e | |||
| 75d88f441c | |||
| 9d3a277f45 | |||
| 4161ff9513 | |||
| 54c56002ee | |||
| 38201280ea | |||
| c034999d20 | |||
| 56acf92f75 | |||
| 9388bf17af | |||
| 72e031a99d | |||
| ac95358561 | |||
| 3fa879d007 | |||
| e661e15bbf | |||
| 2bee7dce43 | |||
| 8ab1efcca7 | |||
| 0ec7f7f08a |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -93,3 +93,6 @@ external/*
|
||||
|
||||
# Agent Tasks
|
||||
Provider/AgentTasks/
|
||||
|
||||
# OTA files
|
||||
*.bundle
|
||||
124
Provider/.agents/rules/coding-guidelines.md
Normal file
124
Provider/.agents/rules/coding-guidelines.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Calendink Coding Guidelines
|
||||
|
||||
These rules apply to all code in this workspace.
|
||||
|
||||
---
|
||||
|
||||
## Backend — C++ / ESP-IDF
|
||||
|
||||
### Philosophy: C-with-Utilities
|
||||
Write **C-style code** using C++ convenience features. No classes, no methods, no RAII, no exceptions.
|
||||
|
||||
**Use freely:** `template`, `auto`, `constexpr`, `enum class`, type aliases from `types.hpp`
|
||||
**Avoid:** classes, constructors/destructors, `std::` containers, inheritance, virtual functions, RAII wrappers
|
||||
|
||||
`goto` for cleanup/shutdown paths in `app_main` is acceptable.
|
||||
|
||||
### Unity Build
|
||||
All `.cpp` files are `#include`-ed into `main.cpp` as a single translation unit. **Do not register new source files in CMakeLists.txt.**
|
||||
|
||||
- Use `unity.cpp` aggregators per API group (e.g., `api/tasks/unity.cpp`)
|
||||
- Mark all file-scoped symbols with `internal` (defined as `static` in `types.hpp`)
|
||||
- Use `.hpp` for declarations shared across included files only
|
||||
|
||||
### API Handler Pattern
|
||||
```cpp
|
||||
// METHOD /api/path — What it does
|
||||
// Body: { "field": value } (if applicable)
|
||||
|
||||
internal esp_err_t api_foo_post_handler(httpd_req_t *req) {
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
|
||||
// 1. Parse request
|
||||
// 2. Call store function
|
||||
// 3. Build cJSON response
|
||||
// Always cJSON_Delete() objects and free() printed strings — no leaks
|
||||
}
|
||||
|
||||
internal const httpd_uri_t api_foo_post_uri = { .uri = "/api/foo", .method = HTTP_POST, .handler = api_foo_post_handler, .user_ctx = NULL };
|
||||
```
|
||||
|
||||
### Store / Handler Separation
|
||||
Data operations in `store.cpp` / `store.hpp`. HTTP parsing in endpoint files. Never mix them.
|
||||
|
||||
### Logging
|
||||
**Always use `ESP_LOGI` / `ESP_LOGW` / `ESP_LOGE`** — never `printf()`.
|
||||
|
||||
Since this is a Unity Build (single translation unit), the log tag must be unique per file to avoid redefinition. Name it after the module, not a generic `TAG`:
|
||||
```cpp
|
||||
// In each file, use a unique local name:
|
||||
internal const char *kTagHttpServer = "HTTP_SERVER";
|
||||
internal const char *kTagMain = "MAIN";
|
||||
internal const char *kTagMDNS = "MDNS";
|
||||
```
|
||||
|
||||
### Type Aliases
|
||||
Use `types.hpp` aliases: `uint8`, `uint16`, `uint32`, `uint64`, `int8`–`int64`, `internal`
|
||||
|
||||
### Seed Data
|
||||
`seed_users()` and `seed_tasks()` must be guarded:
|
||||
```cpp
|
||||
#ifndef NDEBUG
|
||||
seed_users();
|
||||
seed_tasks();
|
||||
#endif
|
||||
```
|
||||
|
||||
### Data Persistence
|
||||
All task/user data is currently **in-RAM only** (intentional — NVS/LittleFS persistence is a future milestone). Do not add persistence without a TDD.
|
||||
|
||||
---
|
||||
|
||||
## Frontend — Svelte 5 + TailwindCSS v4
|
||||
|
||||
### Styling: Tailwind Only
|
||||
Use TailwindCSS exclusively. **No `<style>` blocks in components.**
|
||||
|
||||
- All design tokens are in `app.css` via `@theme`: `bg-bg-card`, `text-text-primary`, `border-border`, `text-accent`, `text-success`, `text-danger`, etc.
|
||||
- If a utility is missing, add a token to `@theme` — don't add inline CSS
|
||||
|
||||
### Reactivity: Svelte 5 Runes Only
|
||||
| Do ✅ | Don't ❌ |
|
||||
|---|---|
|
||||
| `$state()` | `writable()`, `readable()` |
|
||||
| `$derived()` / `$derived.by()` | `$:` reactive statements |
|
||||
| `$effect()` | `onMount()`, `onDestroy()`, `.subscribe()` |
|
||||
| `$props()` / `$bindable()` | `export let` |
|
||||
|
||||
### `$state()` vs Plain `let`
|
||||
- **`$state()`** — values the template reads, or that changes should cause a re-render
|
||||
- **Plain `let`** — script-only internals (mutex flags, interval handles, etc.) the template never reads
|
||||
|
||||
### `$effect()` for Initialization
|
||||
When an effect has no reactive dependencies and runs once on mount, add a comment:
|
||||
```js
|
||||
// Load initial data on mount
|
||||
$effect(() => { fetchData(); });
|
||||
```
|
||||
|
||||
### Shared Utilities
|
||||
- Date/time helpers, formatters → `lib/utils.js`
|
||||
- Cross-component reactive state → `lib/stores.js`
|
||||
- API calls → `lib/api.js` (always via `trackedFetch()`)
|
||||
|
||||
**Never duplicate functions across components.**
|
||||
|
||||
### Component Structure
|
||||
```svelte
|
||||
<script>
|
||||
// 1. Imports
|
||||
// 2. $props()
|
||||
// 3. $state()
|
||||
// 4. $derived()
|
||||
// 5. Functions
|
||||
// 6. $effect()
|
||||
</script>
|
||||
<!-- Template — Tailwind classes only -->
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## General
|
||||
|
||||
- **Don't commit build artifacts**: `dist/`, `bundles/`, `temp_*`, `*.gz` — update `.gitignore` accordingly
|
||||
- **Version** is managed through `version.json`, injected as `__APP_VERSION__` at build time
|
||||
5
Provider/.agents/rules/how-to-work-with-user.md
Normal file
5
Provider/.agents/rules/how-to-work-with-user.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
The way you must work with the human user is simple. When you finish a task, tell him what you did, what you think you should do next and ask for review and confirmation. Never go rogue.
|
||||
13
Provider/.agents/rules/how-to-write-tdd.md
Normal file
13
Provider/.agents/rules/how-to-write-tdd.md
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
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.
|
||||
49
Provider/.agents/workflows/frontend-ota.md
Normal file
49
Provider/.agents/workflows/frontend-ota.md
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
description: Build, package, and deploy frontend updates via OTA
|
||||
---
|
||||
|
||||
This workflow automates the process of building the frontend, packaging it into a LittleFS binary, and uploading it to the ESP32 device.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Ensure `VITE_API_BASE` in `frontend/.env` is set to the target device's IP address.
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Build and Package Frontend**
|
||||
Run the following command in the `frontend/` directory to build the production assets and create the versioned `.bin` file:
|
||||
```bash
|
||||
npm run build:esp32 && npm run ota:package
|
||||
```
|
||||
|
||||
2. **Deploy Update**
|
||||
Run the deployment script to upload the latest version to the device:
|
||||
```bash
|
||||
npm run ota:deploy
|
||||
```
|
||||
|
||||
3. **Verify Update**
|
||||
Wait for the device to reboot and verify the changes on the dashboard.
|
||||
|
||||
---
|
||||
|
||||
### Universal Bundle Option
|
||||
|
||||
If you also need to update the firmware, use the universal bundle workflow:
|
||||
|
||||
1. **Build Firmware**
|
||||
From the project root:
|
||||
```bash
|
||||
idf.py build
|
||||
```
|
||||
|
||||
2. **Create Bundle**
|
||||
In the `frontend/` directory:
|
||||
```bash
|
||||
npm run ota:bundle
|
||||
```
|
||||
|
||||
3. **Deploy Bundle**
|
||||
In the `frontend/` directory:
|
||||
```bash
|
||||
npm run ota:deploy-bundle
|
||||
```
|
||||
92
Provider/AGENTS.md
Normal file
92
Provider/AGENTS.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# AGENTS.md
|
||||
|
||||
Calendink Provider — ESP32-S3 firmware + Svelte 5 web dashboard.
|
||||
Uses **ESP-IDF** (not Arduino). Serves UI from LittleFS flash partitions over Ethernet/WiFi.
|
||||
|
||||
---
|
||||
|
||||
## Build Commands
|
||||
|
||||
```powershell
|
||||
# Backend (from project root)
|
||||
idf.py build
|
||||
idf.py flash monitor
|
||||
|
||||
# Frontend (from frontend/)
|
||||
npm run dev # Dev server — calls real ESP32 API (set VITE_API_BASE in .env.development)
|
||||
npm run build # Production build → dist/index.html
|
||||
npm run build:esp32 # Build + gzip → dist/index.html.gz
|
||||
npm run ota:deploy # OTA deploy frontend to device
|
||||
### Logging on Ethernet
|
||||
When the device is connected via Ethernet, logs are broadcast over UDP. Run `ncat -ul 514` on your PC to view the live `ESP_LOG` output.
|
||||
*(If `ncat` throws a `WSAEMSGSIZE` error on Windows, use this Python command instead:)*
|
||||
```powershell
|
||||
python -c "import socket; s=socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.bind(('', 514)); [print(m[0].decode(errors='ignore'), end='') for m in iter(lambda:s.recvfrom(4096), None)]"
|
||||
```
|
||||
|
||||
There are no automated tests. Verify by building and inspecting on-device.
|
||||
|
||||
---
|
||||
|
||||
## Project Layout
|
||||
|
||||
```
|
||||
main/ C++ firmware (Unity Build — one translation unit)
|
||||
main/api/<domain>/ HTTP endpoint files, one per verb
|
||||
main/http_server.cpp Server setup, static file serving, scratch buffer pool
|
||||
main/connect.cpp Ethernet + WiFi management
|
||||
frontend/src/ Svelte 5 components + api.js
|
||||
frontend/src/app.css TailwindCSS v4 @theme design tokens
|
||||
tdd/ Technical Design Documents — read before major changes
|
||||
.agents/rules/ Coding guidelines
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backend Rules (C++ / ESP-IDF)
|
||||
|
||||
- **Unity Build**: `main.cpp` `#include`s all `.cpp` files. Do NOT add files to `CMakeLists.txt`.
|
||||
- **C-style only**: no classes, no `std::`, no RAII, no exceptions. `template`, `auto`, `constexpr` are fine.
|
||||
- **`internal`** (= `static`) on all file-scoped symbols — defined in `main/types.hpp`.
|
||||
- **Logging**: use `ESP_LOGI` / `ESP_LOGW` / `ESP_LOGE` only — never `printf()`.
|
||||
- Because of Unity Build, tag variables must have unique names per file: `kTagHttpServer`, `kTagMain`, etc. — never use a shared `TAG`.
|
||||
- **Seed data**: `seed_users()` / `seed_tasks()` must be inside `#ifndef NDEBUG` guards.
|
||||
- **API handler pattern**: set CORS header + response type → parse request → call store function → build cJSON response → `free()` strings, `cJSON_Delete()` objects.
|
||||
- **Data is in-RAM only** (`g_Users[8]`, `g_Tasks[32]`). This is intentional — persistence is a future milestone. Don't add it without a TDD.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Rules (Svelte 5 + TailwindCSS v4)
|
||||
|
||||
- **Tailwind only** — no `<style>` blocks in components. Design tokens are in `frontend/src/app.css` (`@theme`).
|
||||
- **Svelte 5 runes only** — `$state`, `$derived`, `$effect`, `$props`, `$bindable`. No `export let`, `onMount`, `onDestroy`, `writable`, or `.subscribe()`.
|
||||
- **`$state()`** for values the template reads. **Plain `let`** for script-only handles/flags the template never reads.
|
||||
- **`$effect()` with no reactive deps** runs once on mount — add a comment saying so.
|
||||
- All API calls through `lib/api.js` via `trackedFetch()` — never raw `fetch()`.
|
||||
- Shared utilities (formatters, helpers) in `lib/utils.js`. Never duplicate functions across components.
|
||||
|
||||
---
|
||||
|
||||
## Technical Design Documents
|
||||
|
||||
Read the relevant TDD before any major architectural change:
|
||||
|
||||
| TDD | Read when |
|
||||
|---|---|
|
||||
| `tdd/backend_architecture.md` | Changing server setup, adding API groups |
|
||||
| `tdd/frontend_technology_choices.md` | Changing build tools or dependencies |
|
||||
| `tdd/todo_list.md` | Changing task/user data model or API contracts |
|
||||
| `tdd/firmware_ota.md` | Touching OTA partition logic |
|
||||
| `tdd/frontend_ota.md` | Touching frontend OTA upload or versioning |
|
||||
| `tdd/concurrent_requests.md` | Changing HTTP server socket/connection config |
|
||||
| `tdd/lvgl_image_generation.md` | Touching LVGL headless display or image gen |
|
||||
| `tdd/device_screens.md` | Changing device registration, MAC routing, or XML layout logic |
|
||||
|
||||
---
|
||||
|
||||
## Working with the User
|
||||
|
||||
- After finishing a task: explain what changed, what you recommend next, and wait for approval.
|
||||
- Never make changes beyond the agreed scope without asking first.
|
||||
- Write a TDD (see `.agents/rules/how-to-write-tdd.md`) before major architectural changes.
|
||||
- **Keep `AGENTS.md` and `GEMINI.md` up to date.** If a task changes the architecture, adds new rules, introduces new build commands, or modifies the project layout — update both files before closing the task.
|
||||
59
Provider/Documentation/build_bundle.md
Normal file
59
Provider/Documentation/build_bundle.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# 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`.
|
||||
@@ -80,3 +80,25 @@ Once the backend supports it (Phase 2+), you can update the frontend without usi
|
||||
- Go to the **Frontend Update** section.
|
||||
- Select the `www.bin` file and click **Flash**.
|
||||
- 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.
|
||||
|
||||
23
Provider/Documentation/listen_udp_logs.md
Normal file
23
Provider/Documentation/listen_udp_logs.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Listening to UDP Logs
|
||||
|
||||
When the Calendink Provider device is running (especially when connected via Ethernet and serial monitoring is not feasible), the firmware broadcasts the `ESP_LOG` output over UDP on port **514**.
|
||||
|
||||
You can listen to these live logs directly from your PC.
|
||||
|
||||
## Option 1: Using ncat (Nmap)
|
||||
If you have `ncat` installed, you can listen to the UDP broadcast by running this command in your terminal:
|
||||
|
||||
```powershell
|
||||
ncat -ul 514
|
||||
```
|
||||
|
||||
## Option 2: Using Python (Windows Fallback)
|
||||
On Windows, `ncat` sometimes throws a `WSAEMSGSIZE` error if a log line exceeds a certain size (like the chunked PM dump locks). If this happens, or if you don't have `ncat` installed, you can run this Python one-liner in your terminal instead:
|
||||
|
||||
```powershell
|
||||
python -c "import socket; s=socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.bind(('', 514)); [print(m[0].decode(errors='ignore'), end='') for m in iter(lambda:s.recvfrom(4096), None)]"
|
||||
```
|
||||
|
||||
### Configuration
|
||||
- **Port:** The UDP server broadcasts on port `514`.
|
||||
- **Target IP:** By default, logs are broadcast to `255.255.255.255`. If you need to target a specific machine, make sure `CONFIG_CALENDINK_UDP_LOG_TARGET_IP` is set to your PC's IP address in your `sdkconfig`.
|
||||
115
Provider/GEMINI.md
Normal file
115
Provider/GEMINI.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Calendink Provider — Agent Context
|
||||
|
||||
This is an **ESP32-S3 IoT device** firmware + web dashboard project.
|
||||
It uses **ESP-IDF** (native, not Arduino) and serves a **Svelte 5 web UI** from flash.
|
||||
|
||||
---
|
||||
|
||||
## Project Summary
|
||||
|
||||
Calendink Provider is a connected desk device that serves a local web dashboard over Ethernet/WiFi.
|
||||
The firmware manages network connections, serves a Svelte SPA from LittleFS flash partitions, and exposes a REST API for system control (reboot, OTA, tasks, users).
|
||||
|
||||
### Key Paths
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `main/` | C++ firmware (Unity Build) |
|
||||
| `main/api/` | REST API handlers, one file per endpoint |
|
||||
| `main/http_server.cpp` | HTTP server setup, static file serving |
|
||||
| `main/connect.cpp` | Ethernet + WiFi connection management |
|
||||
| `frontend/src/` | Svelte 5 frontend source |
|
||||
| `frontend/src/lib/api.js` | All API calls (always use `trackedFetch`) |
|
||||
| `frontend/src/app.css` | TailwindCSS v4 design tokens (`@theme`) |
|
||||
| `tdd/` | Technical Design Documents (read before major changes) |
|
||||
| `.agents/rules/` | Coding guidelines (always follow) |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend
|
||||
|
||||
- **Unity Build**: `main.cpp` `#include`s all `.cpp` files. Do NOT add files to CMakeLists.txt. Everything is one translation unit.
|
||||
- **API handlers**: Each endpoint is its own `.cpp` file in `main/api/<domain>/`. A `unity.cpp` aggregates them.
|
||||
- **HTTP server**: `esp_http_server` + `cJSON`. CORS is always on (`*`).
|
||||
- **Data storage**: Users and tasks are in static BSS arrays (`g_Users[8]`, `g_Tasks[32]`). In-RAM only, intentionally — persistence is a future feature.
|
||||
- **OTA**: A/B partition scheme — `www_0` / `www_1` for frontend, `ota_0` / `ota_1` for firmware. NVS tracks which slot is active.
|
||||
- **Connections**: Ethernet first, WiFi fallback. Connection state managed with FreeRTOS semaphores.
|
||||
|
||||
### Frontend
|
||||
|
||||
- **Svelte 5** with runes (`$state`, `$derived`, `$effect`, `$props`). No legacy Svelte 4 APIs.
|
||||
- **TailwindCSS v4** (utility classes only — no `<style>` blocks in components).
|
||||
- **Single-file build**: `vite-plugin-singlefile` inlines all JS+CSS into one `index.html`.
|
||||
- **Version**: Tracked in `version.json`, injected as `__APP_VERSION__` at build time.
|
||||
- **Dev mode**: Set `VITE_API_BASE=http://<ESP32_IP>` in `.env.development` — the frontend runs on PC and calls the real ESP32 API.
|
||||
|
||||
---
|
||||
|
||||
## Coding Rules (summary — full rules in `.agents/rules/coding-guidelines.md`)
|
||||
|
||||
### Backend
|
||||
- C-style code: no classes, no `std::`, no RAII, no exceptions. Use `template`, `auto`, `constexpr` freely.
|
||||
- Use `internal` (= `static`) for all file-scoped symbols.
|
||||
- Log with `ESP_LOGI/W/E` only — never `printf()`. Name log tags per-module with unique variable names (e.g., `kTagHttpServer`), not a shared `TAG`, to avoid Unity Build redefinition.
|
||||
- Seed data (`seed_users`, `seed_tasks`) must be `#ifndef NDEBUG` guarded.
|
||||
|
||||
### Frontend
|
||||
- Svelte 5 runes only. No `onMount`, `onDestroy`, `writable`, `.subscribe`.
|
||||
- Tailwind only. No `<style>` blocks.
|
||||
- Shared logic goes in `lib/utils.js`. Never duplicate functions across components.
|
||||
- API calls go through `lib/api.js` via `trackedFetch()` — never raw `fetch()`.
|
||||
- `$state()` for values the template reads. Plain `let` for script-only flags/handles.
|
||||
|
||||
---
|
||||
|
||||
## Technical Design Documents
|
||||
|
||||
Always read the relevant TDD before making major architectural changes:
|
||||
|
||||
| TDD | When to read |
|
||||
|---|---|
|
||||
| [backend_architecture.md](tdd/backend_architecture.md) | Changing server setup, adding new API groups |
|
||||
| [frontend_technology_choices.md](tdd/frontend_technology_choices.md) | Changing build tools, adding dependencies |
|
||||
| [todo_list.md](tdd/todo_list.md) | Changing task/user data models or API contracts |
|
||||
| [firmware_ota.md](tdd/firmware_ota.md) | Touching OTA partition logic |
|
||||
| [frontend_ota.md](tdd/frontend_ota.md) | Touching frontend OTA upload or versioning |
|
||||
| [concurrent_requests.md](tdd/concurrent_requests.md) | Changing HTTP server socket/connection config |
|
||||
| [lvgl_image_generation.md](tdd/lvgl_image_generation.md) | Touching LVGL headless display or image gen |
|
||||
| [device_screens.md](tdd/device_screens.md) | Changing device registration, MAC routing, or XML layout logic |
|
||||
|
||||
---
|
||||
|
||||
## How to Work With the User
|
||||
|
||||
- After finishing any task: tell the user what you did, what you think the next step should be, and ask for approval before proceeding.
|
||||
- Never go rogue — propose changes, wait for confirmation.
|
||||
- Write a TDD before major architectural changes. See `.agents/rules/how-to-write-tdd.md`.
|
||||
- **Keep `AGENTS.md` and `GEMINI.md` up to date.** If a task changes the architecture, adds new rules, introduces new build commands, or modifies the project layout — update both files before closing the task.
|
||||
|
||||
---
|
||||
|
||||
## Build Commands
|
||||
|
||||
```powershell
|
||||
# Backend — from project root
|
||||
idf.py build
|
||||
idf.py flash monitor
|
||||
|
||||
# Frontend — from frontend/
|
||||
npm run dev # Dev server (PC, calls real ESP32 API)
|
||||
npm run build # Production build → dist/index.html
|
||||
npm run build:esp32 # Build + gzip → dist/index.html.gz
|
||||
npm run ota:package # Package as versioned .bin
|
||||
npm run ota:bundle # Package FW + frontend as .bundle
|
||||
npm run ota:deploy # Deploy frontend OTA to device
|
||||
```
|
||||
|
||||
## Logging on Ethernet
|
||||
|
||||
When the device is connected to Ethernet, logs are broadcast over UDP to port 514. Use `ncat -ul 514` on your PC to view them live.
|
||||
*(If `ncat` throws a `WSAEMSGSIZE` error on Windows, use this Python command instead:)*
|
||||
```powershell
|
||||
python -c "import socket; s=socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.bind(('', 514)); [print(m[0].decode(errors='ignore'), end='') for m in iter(lambda:s.recvfrom(4096), None)]"
|
||||
```
|
||||
@@ -121,6 +121,16 @@ dependencies:
|
||||
registry_url: https://components.espressif.com/
|
||||
type: service
|
||||
version: 3.0.3
|
||||
espressif/mdns:
|
||||
component_hash: 7c0fa01a1cd0e72a87ec1928c3b661c0a3a9034a6d3a69dcf4850db8c6f272db
|
||||
dependencies:
|
||||
- name: idf
|
||||
require: private
|
||||
version: '>=5.0'
|
||||
source:
|
||||
registry_url: https://components.espressif.com/
|
||||
type: service
|
||||
version: 1.10.1
|
||||
idf:
|
||||
source:
|
||||
type: idf
|
||||
@@ -135,11 +145,20 @@ dependencies:
|
||||
registry_url: https://components.espressif.com/
|
||||
type: service
|
||||
version: 1.20.4
|
||||
lvgl/lvgl:
|
||||
component_hash: 184e532558c1c45fefed631f3e235423d22582aafb4630f3e8885c35281a49ae
|
||||
dependencies: []
|
||||
source:
|
||||
registry_url: https://components.espressif.com/
|
||||
type: service
|
||||
version: 9.5.0
|
||||
direct_dependencies:
|
||||
- espressif/ethernet_init
|
||||
- espressif/led_strip
|
||||
- espressif/mdns
|
||||
- idf
|
||||
- joltwallet/littlefs
|
||||
manifest_hash: 21816aafdbbde14bfaaaabda34966eec49ae1e6f551bc16fe3ff74370b0fb54c
|
||||
- lvgl/lvgl
|
||||
manifest_hash: 96112412d371d78cc527b7d0904042e5a7ca7c4f25928de9483a1b53dd2a2f4e
|
||||
target: esp32s3
|
||||
version: 2.0.0
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
VITE_API_BASE=http://192.168.50.216
|
||||
VITE_API_BASE=http://calendink.local/
|
||||
MKLITTLEFS_PATH=W:\Classified\Calendink\Provider\build\littlefs_py_venv\Scripts\littlefs-python.exe
|
||||
|
||||
BIN
Provider/frontend/bundles/universal_v0.1.12.bundle
Normal file
BIN
Provider/frontend/bundles/universal_v0.1.12.bundle
Normal file
Binary file not shown.
BIN
Provider/frontend/bundles/universal_v0.1.13.bundle
Normal file
BIN
Provider/frontend/bundles/universal_v0.1.13.bundle
Normal file
Binary file not shown.
BIN
Provider/frontend/bundles/universal_v0.1.9.bundle
Normal file
BIN
Provider/frontend/bundles/universal_v0.1.9.bundle
Normal file
Binary file not shown.
@@ -8,6 +8,9 @@
|
||||
"build": "vite build",
|
||||
"build:esp32": "vite build && node scripts/gzip.js",
|
||||
"ota:package": "node scripts/package.js",
|
||||
"ota:bundle": "npm run ota:package && node scripts/bundle.js",
|
||||
"ota:deploy": "node scripts/deploy.js --frontend",
|
||||
"ota:deploy-bundle": "node scripts/deploy.js --bundle",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
79
Provider/frontend/scripts/bundle.js
Normal file
79
Provider/frontend/scripts/bundle.js
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
128
Provider/frontend/scripts/deploy.js
Normal file
128
Provider/frontend/scripts/deploy.js
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* OTA Deployment Script
|
||||
* Uploads www.bin or universal.bundle to the ESP32 device.
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync, existsSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import http from 'http';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = resolve(__dirname, '..');
|
||||
const binDir = resolve(projectRoot, 'bin');
|
||||
const bundleDir = resolve(projectRoot, 'bundles');
|
||||
|
||||
/**
|
||||
* Simple .env parser to load VITE_API_BASE
|
||||
*/
|
||||
function loadEnv() {
|
||||
const envPath = resolve(projectRoot, '.env');
|
||||
if (existsSync(envPath)) {
|
||||
const content = readFileSync(envPath, 'utf8');
|
||||
content.split('\n').forEach(line => {
|
||||
const [key, ...valueParts] = line.split('=');
|
||||
if (key && valueParts.length > 0) {
|
||||
const value = valueParts.join('=').trim().replace(/^["']|["']$/g, '');
|
||||
process.env[key.trim()] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
loadEnv();
|
||||
|
||||
const targetIP = process.env.VITE_API_BASE ? process.env.VITE_API_BASE.replace('http://', '').replace(/\/+$/, '') : null;
|
||||
|
||||
if (!targetIP) {
|
||||
console.error('Error: VITE_API_BASE not found in frontend/.env. Please set it to the target device IP.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const isBundle = args.includes('--bundle');
|
||||
const isFrontend = args.includes('--frontend');
|
||||
|
||||
if (!isBundle && !isFrontend) {
|
||||
console.error('Error: Specify --frontend or --bundle.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function getLatestFile(dir, pattern) {
|
||||
if (!existsSync(dir)) return null;
|
||||
const files = readdirSync(dir)
|
||||
.filter(f => f.match(pattern))
|
||||
.sort((a, b) => {
|
||||
const getParts = (s) => {
|
||||
const m = s.match(/v(\d+)\.(\d+)\.(\d+)/);
|
||||
return m ? m.slice(1).map(Number) : [0, 0, 0];
|
||||
};
|
||||
const [aMajor, aMinor, aRev] = getParts(a);
|
||||
const [bMajor, bMinor, bRev] = getParts(b);
|
||||
return (bMajor - aMajor) || (bMinor - aMinor) || (bRev - aRev);
|
||||
});
|
||||
return files.length > 0 ? resolve(dir, files[0]) : null;
|
||||
}
|
||||
|
||||
const filePath = isBundle
|
||||
? getLatestFile(bundleDir, /^universal_v.*\.bundle$/)
|
||||
: getLatestFile(binDir, /^www_v.*\.bin$/);
|
||||
|
||||
if (!filePath) {
|
||||
console.error(`Error: No recent ${isBundle ? 'bundle' : 'frontend bin'} found in ${isBundle ? bundleDir : binDir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const fileName = filePath.split(/[\\/]/).pop();
|
||||
const versionMatch = fileName.match(/v(\d+\.\d+\.\d+)/);
|
||||
const version = versionMatch ? versionMatch[1] : 'unknown';
|
||||
|
||||
console.log('-------------------------------------------');
|
||||
console.log(`🚀 Deployment Started`);
|
||||
console.log(`📍 Target device: http://${targetIP}`);
|
||||
console.log(`📦 Packaging type: ${isBundle ? 'Universal Bundle' : 'Frontend Only'}`);
|
||||
console.log(`📄 File: ${fileName}`);
|
||||
console.log(`🔖 Version: ${version}`);
|
||||
console.log('-------------------------------------------');
|
||||
|
||||
const fileBuf = readFileSync(filePath);
|
||||
const urlPath = isBundle ? '/api/ota/bundle' : '/api/ota/frontend';
|
||||
const url = `http://${targetIP}${urlPath}`;
|
||||
|
||||
console.log(`Uploading ${fileBuf.length} bytes using fetch API... (This mimics the browser UI upload)`);
|
||||
|
||||
async function deploy() {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: fileBuf,
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Length': fileBuf.length.toString()
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const text = await response.text();
|
||||
console.log('\n✅ Success: Update committed!');
|
||||
console.log('Server response:', text);
|
||||
console.log('The device will reboot in ~1 second.');
|
||||
|
||||
// Wait a few seconds to let the socket close gracefully before killing the process
|
||||
setTimeout(() => {
|
||||
console.log('Deployment script finished.');
|
||||
process.exit(0);
|
||||
}, 3000);
|
||||
} else {
|
||||
const text = await response.text();
|
||||
console.error(`\n❌ Error (${response.status}):`, text);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`\n❌ Problem with request:`, e.message);
|
||||
if (e.cause) console.error(e.cause);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
deploy();
|
||||
@@ -1,6 +1,11 @@
|
||||
<script>
|
||||
import { getSystemInfo, reboot } from "./lib/api.js";
|
||||
import { getSystemInfo, reboot, getOTAStatus, getUpcomingTasks } from "./lib/api.js";
|
||||
import { formatUptime, formatBytes, formatRelativeDate, isOverdue } from "./lib/utils.js";
|
||||
import OTAUpdate from "./lib/OTAUpdate.svelte";
|
||||
import Sidebar from "./lib/Sidebar.svelte";
|
||||
import TaskManager from "./lib/TaskManager.svelte";
|
||||
import UserManager from "./lib/UserManager.svelte";
|
||||
import Spinner from "./lib/Spinner.svelte";
|
||||
|
||||
/** @type {'loading' | 'ok' | 'error' | 'rebooting'} */
|
||||
let status = $state("loading");
|
||||
@@ -8,6 +13,10 @@
|
||||
let showRebootConfirm = $state(false);
|
||||
let isRecovering = $state(false);
|
||||
|
||||
/** @type {'dashboard' | 'tasks' | 'users'} */
|
||||
let currentView = $state("dashboard");
|
||||
let mobileMenuOpen = $state(false);
|
||||
|
||||
let systemInfo = $state({
|
||||
chip: "—",
|
||||
freeHeap: 0,
|
||||
@@ -16,30 +25,40 @@
|
||||
connection: "—",
|
||||
});
|
||||
|
||||
/** Format uptime seconds into human-readable string */
|
||||
function formatUptime(seconds) {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
const parts = [];
|
||||
if (d > 0) parts.push(`${d}d`);
|
||||
if (h > 0) parts.push(`${h}h`);
|
||||
if (m > 0) parts.push(`${m}m`);
|
||||
parts.push(`${s}s`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
let otaStatus = $state({
|
||||
partitions: [],
|
||||
active_partition: "—",
|
||||
running_firmware_label: "—"
|
||||
});
|
||||
|
||||
/** Format bytes into human-readable string */
|
||||
function formatBytes(bytes) {
|
||||
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${bytes} B`;
|
||||
}
|
||||
let upcomingData = $state({ users: [] });
|
||||
|
||||
async function fetchInfo() {
|
||||
let isFetching = false; // mutex, not reactive
|
||||
let lastKnownFirmware = null;
|
||||
let lastKnownSlot = null;
|
||||
async function fetchAll(silent = false) {
|
||||
if (isFetching) return;
|
||||
isFetching = true;
|
||||
try {
|
||||
systemInfo = await getSystemInfo();
|
||||
if (!silent) status = "loading";
|
||||
const [sys, ota, upcoming] = await Promise.all([
|
||||
getSystemInfo(),
|
||||
getOTAStatus(),
|
||||
getUpcomingTasks().catch(() => ({ users: [] }))
|
||||
]);
|
||||
// Detect any OTA update: firmware version change OR www partition flip
|
||||
const fwChanged = lastKnownFirmware && sys.firmware !== lastKnownFirmware;
|
||||
const slotChanged = lastKnownSlot !== null && ota.active_slot !== lastKnownSlot;
|
||||
if (fwChanged || slotChanged) {
|
||||
console.log(`OTA detected (fw: ${fwChanged}, slot: ${slotChanged}). Reloading...`);
|
||||
window.location.href = window.location.pathname + '?t=' + Date.now();
|
||||
return;
|
||||
}
|
||||
lastKnownFirmware = sys.firmware;
|
||||
lastKnownSlot = ota.active_slot;
|
||||
systemInfo = sys;
|
||||
otaStatus = ota;
|
||||
upcomingData = upcoming;
|
||||
status = "ok";
|
||||
errorMsg = "";
|
||||
} catch (e) {
|
||||
@@ -47,6 +66,8 @@
|
||||
status = "error";
|
||||
errorMsg = e.message || "Connection failed";
|
||||
}
|
||||
} finally {
|
||||
isFetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +83,10 @@
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
fetchInfo();
|
||||
fetchAll();
|
||||
// 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
|
||||
@@ -93,138 +117,271 @@
|
||||
]);
|
||||
</script>
|
||||
|
||||
<main class="min-h-screen bg-bg-primary flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-xl space-y-4">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-6">
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex min-h-screen bg-bg-primary">
|
||||
<Sidebar
|
||||
{currentView}
|
||||
isOpen={mobileMenuOpen}
|
||||
onNavigate={(view) => { currentView = view; mobileMenuOpen = false; }}
|
||||
onToggle={() => mobileMenuOpen = !mobileMenuOpen}
|
||||
/>
|
||||
|
||||
<!-- Status Badge -->
|
||||
<div class="flex justify-center">
|
||||
{#if status === "loading"}
|
||||
<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>Connecting to ESP32...</span>
|
||||
{#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 -->
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-bold text-accent">Calendink Provider 📅⚡✨🌈</h1>
|
||||
<p class="text-text-secondary text-sm">ESP32-S3 System Dashboard v{__APP_VERSION__}</p>
|
||||
|
||||
<!-- Status Badge -->
|
||||
<div class="flex justify-center mt-4">
|
||||
{#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">
|
||||
<span class="w-2 h-2 rounded-full bg-accent animate-pulse"></span>
|
||||
<span>Connecting...</span>
|
||||
</div>
|
||||
{: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">
|
||||
<span class="w-2 h-2 rounded-full bg-success animate-pulse"></span>
|
||||
<span>Connected</span>
|
||||
</div>
|
||||
{: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">
|
||||
<span class="w-2 h-2 rounded-full bg-amber-400 animate-pulse"></span>
|
||||
<span>Rebooting...</span>
|
||||
</div>
|
||||
{: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">
|
||||
<span class="w-2 h-2 rounded-full bg-danger"></span>
|
||||
<span>Offline — {errorMsg}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if status === "ok"}
|
||||
<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>Connected</span>
|
||||
</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 -->
|
||||
<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">
|
||||
System Info
|
||||
</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-border">
|
||||
{#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 gap-3">
|
||||
<span class="text-base">{item.icon}</span>
|
||||
<span class="text-sm text-text-secondary">{item.label}</span>
|
||||
</div>
|
||||
<span class="text-sm font-mono text-text-primary">
|
||||
{#if status === "loading"}
|
||||
<span class="inline-block w-16 h-4 bg-border rounded animate-pulse"></span>
|
||||
{:else}
|
||||
{item.value}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Partition Table Card -->
|
||||
<div class="bg-bg-card border border-border rounded-xl overflow-hidden shadow-xl">
|
||||
<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>
|
||||
<h2 class="text-sm font-semibold text-text-primary uppercase tracking-wider">
|
||||
Device Control
|
||||
</h2>
|
||||
<p class="text-xs text-text-secondary mt-1">
|
||||
Restart the ESP32 microcontroller
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => (showRebootConfirm = true)}
|
||||
disabled={status === "rebooting" || status === "loading"}
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg transition-colors
|
||||
bg-danger/10 text-danger border border-danger/20
|
||||
hover:bg-danger/20 hover:border-danger/30
|
||||
disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Reboot
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Updates & Maintenance Card -->
|
||||
<OTAUpdate otaInfo={otaStatus} {systemInfo} onReboot={() => { status = "rebooting"; isRecovering = true; }} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{: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-2 text-sm text-amber-400"
|
||||
>
|
||||
<span class="w-2 h-2 rounded-full bg-amber-400 animate-pulse"></span>
|
||||
<span>Rebooting...</span>
|
||||
|
||||
{:else if currentView === 'tasks'}
|
||||
<!-- Task Manager View -->
|
||||
<div class="bg-bg-card border border-border rounded-xl p-6 shadow-xl">
|
||||
<TaskManager />
|
||||
</div>
|
||||
{:else}
|
||||
<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>Offline — {errorMsg}</span>
|
||||
{: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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- System Info Card -->
|
||||
<div class="bg-bg-card border border-border rounded-xl overflow-hidden">
|
||||
<div class="px-5 py-3 border-b border-border">
|
||||
<h2
|
||||
class="text-sm font-semibold text-text-primary uppercase tracking-wider"
|
||||
>
|
||||
System Info
|
||||
</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-border">
|
||||
{#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 gap-3">
|
||||
<span class="text-base">{item.icon}</span>
|
||||
<span class="text-sm text-text-secondary">{item.label}</span>
|
||||
<!-- Reboot Confirmation Modal -->
|
||||
{#if showRebootConfirm}
|
||||
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||||
<div class="bg-bg-card border border-border rounded-xl p-6 max-w-sm w-full space-y-4 shadow-2xl">
|
||||
<h3 class="text-lg font-semibold text-text-primary">Confirm Reboot</h3>
|
||||
<p class="text-sm text-text-secondary">
|
||||
Are you sure you want to reboot the ESP32? The device will be temporarily unavailable.
|
||||
</p>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button
|
||||
onclick={() => (showRebootConfirm = false)}
|
||||
class="px-4 py-2 text-sm rounded-lg bg-border/30 text-text-secondary hover:bg-border/50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Reboot Now
|
||||
</button>
|
||||
</div>
|
||||
<span class="text-sm font-mono text-text-primary">
|
||||
{#if status === "loading"}
|
||||
<span
|
||||
class="inline-block w-16 h-4 bg-border rounded animate-pulse"
|
||||
></span>
|
||||
{:else}
|
||||
{item.value}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Control Section (Reboot) -->
|
||||
<div class="bg-bg-card border border-border rounded-xl p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-text-primary uppercase tracking-wider">
|
||||
Device Control
|
||||
</h2>
|
||||
<p class="text-xs text-text-secondary mt-1">
|
||||
Restart the ESP32 microcontroller
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => (showRebootConfirm = true)}
|
||||
disabled={status === "rebooting" || status === "loading"}
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg transition-colors
|
||||
bg-danger/10 text-danger border border-danger/20
|
||||
hover:bg-danger/20 hover:border-danger/30
|
||||
disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Reboot
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Frontend Info & OTA Section -->
|
||||
<OTAUpdate onReboot={() => (status = "rebooting")} />
|
||||
|
||||
<!-- Reboot Confirmation Modal -->
|
||||
{#if showRebootConfirm}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4"
|
||||
>
|
||||
<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">
|
||||
Are you sure you want to reboot the ESP32? The device will be
|
||||
temporarily unavailable.
|
||||
</p>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button
|
||||
onclick={() => (showRebootConfirm = false)}
|
||||
class="px-4 py-2 text-sm rounded-lg bg-border/30 text-text-secondary hover:bg-border/50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={handleReboot}
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg bg-danger text-white hover:bg-danger-hover transition-colors"
|
||||
>
|
||||
Reboot Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Spinner />
|
||||
|
||||
@@ -17,3 +17,28 @@
|
||||
--color-danger: #ef4444;
|
||||
--color-danger-hover: #f87171;
|
||||
}
|
||||
|
||||
/*
|
||||
* Global utility: main content radial gradient background.
|
||||
* Can't be expressed as a Tailwind arbitrary value due to multiple background layers.
|
||||
*/
|
||||
.main-gradient-bg {
|
||||
background: radial-gradient(circle at top right, var(--color-bg-card), transparent 40%),
|
||||
var(--color-bg-primary);
|
||||
}
|
||||
|
||||
/*
|
||||
* Global utility: makes the native date picker icon cover the full input area.
|
||||
* This is a vendor pseudo-element — cannot be done with Tailwind utilities.
|
||||
*/
|
||||
.date-input::-webkit-calendar-picker-indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: auto;
|
||||
height: auto;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1,58 +1,48 @@
|
||||
<script>
|
||||
let { onReboot = null } = $props();
|
||||
import { getOTAStatus, uploadOTAFrontend } from "./api.js";
|
||||
import { uploadOTAFrontend, uploadOTAFirmware, uploadOTABundle } from "./api.js";
|
||||
|
||||
// otaInfo and systemInfo are passed from App.svelte (already fetched there every 5s)
|
||||
let {
|
||||
onReboot = null,
|
||||
otaInfo = { active_slot: -1, active_partition: "—", target_partition: "—", partitions: [], running_firmware_label: "—" },
|
||||
systemInfo = { firmware: "—" }
|
||||
} = $props();
|
||||
|
||||
const IS_DEV = import.meta.env.DEV;
|
||||
|
||||
/** @type {'idle' | 'loading_status' | 'uploading' | 'success' | 'error'} */
|
||||
/** @type {'idle' | 'uploading' | 'success' | 'error'} */
|
||||
let status = $state("idle");
|
||||
let errorMsg = $state("");
|
||||
let uploadProgress = $state(0); // 0 to 100
|
||||
|
||||
let otaInfo = $state({
|
||||
active_slot: -1,
|
||||
active_partition: "—",
|
||||
target_partition: "—",
|
||||
});
|
||||
let uploadProgress = $state(0);
|
||||
|
||||
let selectedFile = $state(null);
|
||||
let showAdvanced = $state(false);
|
||||
/** @type {'frontend' | 'firmware' | 'bundle'} */
|
||||
let updateMode = $state("frontend");
|
||||
let isDragging = $state(false);
|
||||
|
||||
async function fetchStatus() {
|
||||
status = "loading_status";
|
||||
try {
|
||||
otaInfo = 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) {
|
||||
const files = event.target.files;
|
||||
if (files && files.length > 0) {
|
||||
processFile(files[0]);
|
||||
}
|
||||
if (files && files.length > 0) processFile(files[0]);
|
||||
}
|
||||
|
||||
function handleDrop(event) {
|
||||
event.preventDefault();
|
||||
isDragging = false;
|
||||
const files = event.dataTransfer.files;
|
||||
if (files && files.length > 0) {
|
||||
processFile(files[0]);
|
||||
}
|
||||
if (files && files.length > 0) processFile(files[0]);
|
||||
}
|
||||
|
||||
function processFile(file) {
|
||||
if (file.name.endsWith('.bin')) {
|
||||
if (updateMode === 'bundle') {
|
||||
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;
|
||||
errorMsg = "";
|
||||
} else {
|
||||
@@ -73,7 +63,13 @@
|
||||
}, 500);
|
||||
|
||||
try {
|
||||
await uploadOTAFrontend(selectedFile);
|
||||
if (updateMode === "frontend") {
|
||||
await uploadOTAFrontend(selectedFile);
|
||||
} else if (updateMode === "firmware") {
|
||||
await uploadOTAFirmware(selectedFile);
|
||||
} else {
|
||||
await uploadOTABundle(selectedFile);
|
||||
}
|
||||
clearInterval(progressInterval);
|
||||
uploadProgress = 100;
|
||||
status = "success";
|
||||
@@ -85,119 +81,144 @@
|
||||
errorMsg = e.message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !IS_DEV}
|
||||
<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">
|
||||
<h2 class="text-sm font-semibold text-text-primary uppercase tracking-wider">
|
||||
Frontend Info
|
||||
</h2>
|
||||
<button
|
||||
onclick={() => showAdvanced = !showAdvanced}
|
||||
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 ? 'Hide Tools' : 'OTA Update'}
|
||||
</button>
|
||||
</div>
|
||||
function toggleMode(mode) {
|
||||
if (showAdvanced && updateMode === mode) {
|
||||
showAdvanced = false;
|
||||
} else {
|
||||
showAdvanced = true;
|
||||
updateMode = mode;
|
||||
selectedFile = null;
|
||||
errorMsg = "";
|
||||
}
|
||||
}
|
||||
|
||||
<div class="p-5 space-y-4">
|
||||
<!-- Version & Slot Info -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<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">Version</div>
|
||||
<div class="text-sm font-mono text-accent">v{__APP_VERSION__}</div>
|
||||
</div>
|
||||
<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">Active Slot</div>
|
||||
<div class="text-xs font-mono text-text-primary">
|
||||
{otaInfo.active_partition}
|
||||
{#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>
|
||||
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>
|
||||
|
||||
{#if showAdvanced}
|
||||
<div class="pt-2 border-t border-border/50 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-xs font-bold text-text-primary">OTA Upgrade</h3>
|
||||
<div class="text-[10px] text-text-secondary">
|
||||
Target: <span class="font-mono">{otaInfo.target_partition}</span>
|
||||
{#if otaInfo.partitions}
|
||||
<span class="ml-1">
|
||||
({(otaInfo.partitions.find(p => p.label === otaInfo.target_partition)?.size / 1024 / 1024).toFixed(2)} MB capacity)
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#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">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-success"></span>
|
||||
Update successful! The device is rebooting...
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Drag and Drop Zone -->
|
||||
<div
|
||||
role="button"
|
||||
aria-label="Upload partition image"
|
||||
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
|
||||
{isDragging ? 'border-accent bg-accent/5' : 'border-border hover:border-border-hover bg-bg-primary/20'}
|
||||
{status === 'uploading' ? 'opacity-50 pointer-events-none' : ''}"
|
||||
ondragover={(e) => { e.preventDefault(); isDragging = true; }}
|
||||
ondragleave={() => isDragging = false}
|
||||
ondrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept=".bin"
|
||||
onchange={handleFileChange}
|
||||
class="absolute inset-0 opacity-0 cursor-pointer"
|
||||
/>
|
||||
|
||||
<div class="text-2xl">📦</div>
|
||||
{#if selectedFile}
|
||||
<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>
|
||||
{:else}
|
||||
<div class="text-xs text-text-primary">Drag & Drop .bin here</div>
|
||||
<div class="text-[10px] text-text-secondary">or click to browse</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selectedFile}
|
||||
<button
|
||||
onclick={handleUpload}
|
||||
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"
|
||||
>
|
||||
{status === "uploading" ? 'Processing Update...' : `Flash to ${otaInfo.target_partition}`}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if status === "uploading"}
|
||||
<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>
|
||||
{:else if status === "error"}
|
||||
<p class="text-[10px] text-danger mt-1 bg-danger/5 p-2 rounded border border-danger/10">
|
||||
{errorMsg}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if !IS_DEV}
|
||||
<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">
|
||||
<h2 class="text-sm font-semibold text-text-primary uppercase tracking-wider">
|
||||
Updates & Maintenance
|
||||
</h2>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={() => toggleMode('frontend')}
|
||||
class="text-[10px] font-bold uppercase tracking-tight px-2 py-1 rounded transition-colors
|
||||
{showAdvanced && updateMode === 'frontend' ? 'bg-accent text-white' : 'bg-border text-text-secondary hover:text-text-primary'}"
|
||||
>
|
||||
Frontend OTA
|
||||
</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>
|
||||
{/if}
|
||||
|
||||
<div class="p-5 space-y-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="text-[10px] uppercase text-text-secondary font-bold mb-1">UI 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 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-sm font-mono text-text-primary">{systemInfo.firmware}</div>
|
||||
<div class="text-[9px] text-text-secondary mt-1">
|
||||
Active: {otaInfo.active_slot === 0 ? 'ota_0' : 'ota_1'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showAdvanced}
|
||||
<div class="pt-2 border-t border-border/50 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-xs font-bold text-text-primary">
|
||||
OTA Upgrade ({updateMode === 'frontend' ? 'UI' : 'Firmware'})
|
||||
</h3>
|
||||
<div class="text-[10px] text-text-secondary">
|
||||
Target: <span class="font-mono text-accent">{currentTarget}</span>
|
||||
{#if updateMode === 'frontend' && otaInfo.partitions}
|
||||
<span class="ml-1">
|
||||
({(otaInfo.partitions.find(p => p.label === otaInfo.target_partition)?.size / 1024 / 1024).toFixed(1)} MB)
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#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">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-success"></span>
|
||||
Update successful! Rebooting device...
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
role="button"
|
||||
aria-label="Upload data"
|
||||
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
|
||||
{isDragging ? 'border-accent bg-accent/5' : 'border-border hover:border-border-hover bg-bg-primary/20'}
|
||||
{status === 'uploading' ? 'opacity-50 pointer-events-none' : ''}"
|
||||
ondragover={(e) => { e.preventDefault(); isDragging = true; }}
|
||||
ondragleave={() => isDragging = false}
|
||||
ondrop={handleDrop}
|
||||
>
|
||||
<input type="file" accept="{updateMode === 'bundle' ? '.bundle' : '.bin'}" onchange={handleFileChange} class="absolute inset-0 opacity-0 cursor-pointer" />
|
||||
<div class="text-2xl">{updateMode === 'frontend' ? '🎨' : '⚙️'}</div>
|
||||
{#if selectedFile}
|
||||
<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>
|
||||
{:else}
|
||||
<div class="text-xs text-text-primary">Drop {updateMode === 'bundle' ? 'Universal .bundle' : updateMode === 'frontend' ? 'UI .bin' : 'Firmware .bin'} here</div>
|
||||
<div class="text-[10px] text-text-secondary">or click to browse</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selectedFile}
|
||||
<button
|
||||
onclick={handleUpload}
|
||||
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"
|
||||
>
|
||||
{status === "uploading" ? 'Flashing...' : `Update ${updateMode === 'bundle' ? 'Everything' : updateMode === 'frontend' ? 'UI' : 'Firmware'}`}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if status === "uploading"}
|
||||
<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>
|
||||
{:else if status === "error"}
|
||||
<p class="text-[10px] text-danger mt-1 bg-danger/5 p-2 rounded border border-danger/10">{errorMsg}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
67
Provider/frontend/src/lib/Sidebar.svelte
Normal file
67
Provider/frontend/src/lib/Sidebar.svelte
Normal file
@@ -0,0 +1,67 @@
|
||||
<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: '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>
|
||||
31
Provider/frontend/src/lib/Spinner.svelte
Normal file
31
Provider/frontend/src/lib/Spinner.svelte
Normal file
@@ -0,0 +1,31 @@
|
||||
<script>
|
||||
import { pendingRequests } from './stores.js';
|
||||
|
||||
let showSpinner = $state(false);
|
||||
let timer = null; // script-only handle, not reactive
|
||||
|
||||
// Track pending request count and show spinner after 1s delay
|
||||
$effect(() => {
|
||||
const count = $pendingRequests;
|
||||
if (count > 0) {
|
||||
if (!timer) {
|
||||
timer = setTimeout(() => { showSpinner = true; }, 1000);
|
||||
}
|
||||
} else {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
showSpinner = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if showSpinner}
|
||||
<div class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div class="flex flex-col items-center p-8 bg-bg-card rounded-2xl shadow-xl border border-border">
|
||||
<div class="w-12 h-12 border-4 border-accent border-t-transparent rounded-full animate-spin"></div>
|
||||
<p class="mt-4 text-text-primary font-medium tracking-wide animate-pulse">Communicating with Device...</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
386
Provider/frontend/src/lib/TaskManager.svelte
Normal file
386
Provider/frontend/src/lib/TaskManager.svelte
Normal file
@@ -0,0 +1,386 @@
|
||||
<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>
|
||||
208
Provider/frontend/src/lib/UserManager.svelte
Normal file
208
Provider/frontend/src/lib/UserManager.svelte
Normal file
@@ -0,0 +1,208 @@
|
||||
<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>
|
||||
@@ -7,13 +7,26 @@
|
||||
*/
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || '';
|
||||
import { pendingRequests } from './stores.js';
|
||||
|
||||
/**
|
||||
* Wrapper around fetch that tracks the number of pending requests globally
|
||||
*/
|
||||
async function trackedFetch(url, options = {}) {
|
||||
pendingRequests.update(n => n + 1);
|
||||
try {
|
||||
return await fetch(url, options);
|
||||
} finally {
|
||||
pendingRequests.update(n => Math.max(0, n - 1));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch system information from the ESP32.
|
||||
* @returns {Promise<{chip: string, freeHeap: number, uptime: number, firmware: string, connection: string}>}
|
||||
*/
|
||||
export async function getSystemInfo() {
|
||||
const res = await fetch(`${API_BASE}/api/system/info`);
|
||||
const res = await trackedFetch(`${API_BASE}/api/system/info`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
@@ -29,7 +42,7 @@ export async function getSystemInfo() {
|
||||
* @returns {Promise<{message: string}>}
|
||||
*/
|
||||
export async function reboot() {
|
||||
const res = await fetch(`${API_BASE}/api/system/reboot`, {
|
||||
const res = await trackedFetch(`${API_BASE}/api/system/reboot`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -40,10 +53,10 @@ export async function reboot() {
|
||||
|
||||
/**
|
||||
* Fetch OTA status from the ESP32.
|
||||
* @returns {Promise<{active_slot: number, active_partition: string, target_partition: string}>}
|
||||
* @returns {Promise<{active_slot: number, active_partition: string, target_partition: string, partitions: any[], running_firmware_label: string, running_firmware_slot: number}>}
|
||||
*/
|
||||
export async function getOTAStatus() {
|
||||
const res = await fetch(`${API_BASE}/api/ota/status`);
|
||||
const res = await trackedFetch(`${API_BASE}/api/ota/status`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
@@ -56,7 +69,7 @@ export async function getOTAStatus() {
|
||||
* @returns {Promise<{status: string, message: string}>}
|
||||
*/
|
||||
export async function uploadOTAFrontend(file) {
|
||||
const res = await fetch(`${API_BASE}/api/ota/frontend`, {
|
||||
const res = await trackedFetch(`${API_BASE}/api/ota/frontend`, {
|
||||
method: 'POST',
|
||||
body: file, // Send the raw file Blob/Buffer
|
||||
headers: {
|
||||
@@ -73,3 +86,191 @@ export async function uploadOTAFrontend(file) {
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
3
Provider/frontend/src/lib/stores.js
Normal file
3
Provider/frontend/src/lib/stores.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const pendingRequests = writable(0);
|
||||
48
Provider/frontend/src/lib/utils.js
Normal file
48
Provider/frontend/src/lib/utils.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Shared utility functions for the Calendink dashboard.
|
||||
* Import from here — never duplicate these across components.
|
||||
*/
|
||||
|
||||
/** Format uptime seconds into human-readable string */
|
||||
export function formatUptime(seconds) {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
const parts = [];
|
||||
if (d > 0) parts.push(`${d}d`);
|
||||
if (h > 0) parts.push(`${h}h`);
|
||||
if (m > 0) parts.push(`${m}m`);
|
||||
parts.push(`${s}s`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/** Format bytes into human-readable string */
|
||||
export function formatBytes(bytes) {
|
||||
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
/** Format a Unix timestamp (seconds) as a human-readable relative string */
|
||||
export function formatRelativeDate(timestamp) {
|
||||
const now = Date.now() / 1000;
|
||||
const diff = timestamp - now;
|
||||
const absDiff = Math.abs(diff);
|
||||
|
||||
if (absDiff < 3600) {
|
||||
const mins = Math.round(absDiff / 60);
|
||||
return diff < 0 ? `${mins}m ago` : `in ${mins}m`;
|
||||
}
|
||||
if (absDiff < 86400) {
|
||||
const hours = Math.round(absDiff / 3600);
|
||||
return diff < 0 ? `${hours}h ago` : `in ${hours}h`;
|
||||
}
|
||||
const days = Math.round(absDiff / 86400);
|
||||
return diff < 0 ? `${days}d ago` : `in ${days}d`;
|
||||
}
|
||||
|
||||
/** Returns true if a Unix timestamp (seconds) is in the past */
|
||||
export function isOverdue(timestamp) {
|
||||
return timestamp < Date.now() / 1000;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"major": 0,
|
||||
"minor": 1,
|
||||
"revision": 7
|
||||
"revision": 30
|
||||
}
|
||||
@@ -2,7 +2,7 @@ idf_component_register(SRCS "main.cpp"
|
||||
# Needed as we use minimal build
|
||||
PRIV_REQUIRES esp_http_server esp_eth
|
||||
esp_wifi nvs_flash esp_netif vfs
|
||||
json app_update esp_timer
|
||||
json app_update esp_timer esp_psram mdns driver
|
||||
INCLUDE_DIRS ".")
|
||||
|
||||
if(CONFIG_CALENDINK_DEPLOY_WEB_PAGES)
|
||||
|
||||
@@ -24,6 +24,52 @@ menu "CalendarInk Network Configuration"
|
||||
help
|
||||
Number of times to retry the WiFi connection before failing completely.
|
||||
|
||||
config CALENDINK_BLINK_IP
|
||||
bool "Blink last IP digit on connect"
|
||||
default n
|
||||
help
|
||||
If enabled, the LED will blink the last digit of the IP address
|
||||
acquired to assist in debugging.
|
||||
|
||||
config CALENDINK_MDNS_HOSTNAME
|
||||
string "mDNS Hostname"
|
||||
default "calendink"
|
||||
help
|
||||
The hostname to use for mDNS. The device will be accessible
|
||||
at <hostname>.local. (e.g., calendink.local)
|
||||
|
||||
config CALENDINK_UDP_LOG_TARGET_IP
|
||||
string "UDP Logger Target IP Address"
|
||||
default ""
|
||||
help
|
||||
The IP address to send UDP logs to via port 514.
|
||||
If left blank, logs will be broadcast to 255.255.255.255.
|
||||
|
||||
choice CALENDINK_WIFI_PS_MODE
|
||||
prompt "WiFi Power Save Mode"
|
||||
default CALENDINK_WIFI_PS_NONE
|
||||
help
|
||||
Select the WiFi power save mode to balance power consumption and network stability.
|
||||
|
||||
config CALENDINK_WIFI_PS_NONE
|
||||
bool "None (No power save, highest consumption)"
|
||||
|
||||
config CALENDINK_WIFI_PS_MIN_MODEM
|
||||
bool "Minimum Modem (Wakes on beacon, balanced)"
|
||||
|
||||
config CALENDINK_WIFI_PS_MAX_MODEM
|
||||
bool "Maximum Modem (Lowest consumption, may drop connection on strict routers)"
|
||||
endchoice
|
||||
|
||||
config CALENDINK_ALLOW_LIGHT_SLEEP
|
||||
bool "Allow Light Sleep (Tickless Idle)"
|
||||
default n
|
||||
help
|
||||
If enabled, the device will heavily use light sleep to reduce power
|
||||
consumption. Note that this may BREAK the UART console monitor since the
|
||||
CPU sleeps and halts the UART! Use UDP logging if you need logs
|
||||
while light sleep is enabled.
|
||||
|
||||
endmenu
|
||||
|
||||
menu "Calendink Web Server"
|
||||
@@ -42,4 +88,16 @@ menu "Calendink Web Server"
|
||||
help
|
||||
VFS path where the LittleFS partition is mounted.
|
||||
|
||||
config CALENDINK_DISPLAY_WIDTH
|
||||
int "LVGL Display Width"
|
||||
default 800
|
||||
help
|
||||
Width of the virtual LVGL display used for image generation.
|
||||
|
||||
config CALENDINK_DISPLAY_HEIGHT
|
||||
int "LVGL Display Height"
|
||||
default 480
|
||||
help
|
||||
Height of the virtual LVGL display used for image generation.
|
||||
|
||||
endmenu
|
||||
|
||||
113
Provider/main/api/display/image.cpp
Normal file
113
Provider/main/api/display/image.cpp
Normal file
@@ -0,0 +1,113 @@
|
||||
#include "../../lv_setup.hpp"
|
||||
#include "../../lodepng/lodepng.h"
|
||||
#include "esp_http_server.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_random.h"
|
||||
#include "lvgl.h"
|
||||
#include <string.h>
|
||||
#include "../../lodepng_alloc.hpp"
|
||||
|
||||
internal const char *kTagDisplayImage = "API_DISPLAY_IMAGE";
|
||||
|
||||
internal esp_err_t api_display_image_handler(httpd_req_t *req)
|
||||
{
|
||||
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
|
||||
|
||||
// We are generating PNG on the fly, don't let it be cached locally immediately
|
||||
httpd_resp_set_hdr(req, "Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
httpd_resp_set_type(req, "image/png");
|
||||
|
||||
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;
|
||||
|
||||
// LodePNG expects tightly packed data without stride padding.
|
||||
// Ensure we copy the data if stride differs from width.
|
||||
uint8_t *packed_data = (uint8_t *)draw_buf->data;
|
||||
bool needs_free = false;
|
||||
|
||||
if (draw_buf->header.stride != width)
|
||||
{
|
||||
ESP_LOGI(kTagDisplayImage, "Stride %lu differs from width %lu. Repacking buffer...", draw_buf->header.stride, width);
|
||||
packed_data = (uint8_t *)heap_caps_malloc(width * height, MALLOC_CAP_SPIRAM);
|
||||
if (!packed_data)
|
||||
{
|
||||
xSemaphoreGive(g_LvglMutex);
|
||||
ESP_LOGE(kTagDisplayImage, "Failed to allocate packed buffer in PSRAM");
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
needs_free = true;
|
||||
|
||||
for (uint32_t y = 0; y < height; ++y)
|
||||
{
|
||||
memcpy(packed_data + (y * width), (uint8_t *)draw_buf->data + (y * draw_buf->header.stride), width);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert LVGL 8-bit L8 buffer to 8-bit grayscale PNG using LodePNG.
|
||||
// LCT_GREY = 0, bitdepth = 8
|
||||
unsigned char *png = nullptr;
|
||||
size_t pngsize = 0;
|
||||
|
||||
// We are about to start a huge memory operation inside LodePNG.
|
||||
// We reset our 2MB PSRAM bump allocator to 0 bytes used.
|
||||
lodepng_allocator_reset();
|
||||
|
||||
ESP_LOGI(kTagDisplayImage, "Encoding %lux%lu frame to PNG...", width, height);
|
||||
unsigned error = lodepng_encode_memory(&png, &pngsize, packed_data, width, height, LCT_GREY, 8);
|
||||
|
||||
if (needs_free)
|
||||
{
|
||||
free(packed_data);
|
||||
}
|
||||
|
||||
xSemaphoreGive(g_LvglMutex);
|
||||
|
||||
if (error)
|
||||
{
|
||||
ESP_LOGE(kTagDisplayImage, "PNG encoding error %u: %s", error, lodepng_error_text(error));
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "PNG generation failed");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
ESP_LOGI(kTagDisplayImage, "Prepared PNG, size: %zu bytes. Sending to client...", pngsize);
|
||||
esp_err_t res = httpd_resp_send(req, (const char *)png, pngsize);
|
||||
|
||||
// No need to free(png) because it is managed by our bump allocator
|
||||
// which automatically resets the entire 2MB buffer to 0 next time
|
||||
// lodepng_allocator_reset() is called.
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
httpd_uri_t api_display_image_uri = {
|
||||
.uri = "/api/display/image.png",
|
||||
.method = HTTP_GET,
|
||||
.handler = api_display_image_handler,
|
||||
.user_ctx = NULL};
|
||||
2
Provider/main/api/display/unity.cpp
Normal file
2
Provider/main/api/display/unity.cpp
Normal file
@@ -0,0 +1,2 @@
|
||||
// Unity build entry for display endpoints
|
||||
#include "image.cpp"
|
||||
187
Provider/main/api/ota/bundle.cpp
Normal file
187
Provider/main/api/ota/bundle.cpp
Normal file
@@ -0,0 +1,187 @@
|
||||
// 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};
|
||||
164
Provider/main/api/ota/firmware.cpp
Normal file
164
Provider/main/api/ota/firmware.cpp
Normal file
@@ -0,0 +1,164 @@
|
||||
// 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};
|
||||
@@ -8,7 +8,6 @@
|
||||
#include "nvs.h"
|
||||
#include "nvs_flash.h"
|
||||
|
||||
|
||||
// Project
|
||||
#include "appstate.hpp"
|
||||
#include "types.hpp"
|
||||
@@ -59,6 +58,7 @@ internal esp_err_t api_ota_frontend_handler(httpd_req_t *req)
|
||||
|
||||
int total_read = 0;
|
||||
int remaining = req->content_len;
|
||||
bool first_chunk = true;
|
||||
|
||||
while (remaining > 0)
|
||||
{
|
||||
@@ -77,6 +77,21 @@ internal esp_err_t api_ota_frontend_handler(httpd_req_t *req)
|
||||
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);
|
||||
if (err != ESP_OK)
|
||||
{
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
#include <cstddef>
|
||||
|
||||
// SDK
|
||||
#include "cJSON.h"
|
||||
#include "esp_http_server.h"
|
||||
#include "esp_image_format.h"
|
||||
#include "esp_littlefs.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_ota_ops.h"
|
||||
#include "esp_partition.h"
|
||||
#include "esp_vfs.h"
|
||||
#include <string.h>
|
||||
|
||||
// Project
|
||||
#include "appstate.hpp"
|
||||
#include "types.hpp"
|
||||
#include "utils.hpp"
|
||||
|
||||
internal esp_err_t api_ota_status_handler(httpd_req_t *req)
|
||||
{
|
||||
@@ -17,34 +25,58 @@ internal esp_err_t api_ota_status_handler(httpd_req_t *req)
|
||||
|
||||
cJSON_AddNumberToObject(root, "active_slot", g_Active_WWW_Partition);
|
||||
|
||||
const char *partitions[] = {"www_0", "www_1"};
|
||||
cJSON *parts_arr = cJSON_AddArrayToObject(root, "partitions");
|
||||
|
||||
for (int i = 0; i < 2; i++)
|
||||
esp_partition_iterator_t it = esp_partition_find(
|
||||
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_AddStringToObject(p_obj, "label", partitions[i]);
|
||||
cJSON_AddStringToObject(p_obj, "label", p->label);
|
||||
cJSON_AddNumberToObject(p_obj, "type", p->type);
|
||||
cJSON_AddNumberToObject(p_obj, "subtype", p->subtype);
|
||||
cJSON_AddNumberToObject(p_obj, "address", p->address);
|
||||
cJSON_AddNumberToObject(p_obj, "size", p->size);
|
||||
|
||||
const esp_partition_t *p = esp_partition_find_first(
|
||||
ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_ANY, partitions[i]);
|
||||
if (p)
|
||||
// Try to get LittleFS info if it's a data partition
|
||||
if (p->type == ESP_PARTITION_TYPE_DATA)
|
||||
{
|
||||
cJSON_AddNumberToObject(p_obj, "size", p->size);
|
||||
|
||||
size_t total = 0, used = 0;
|
||||
if (esp_littlefs_info(partitions[i], &total, &used) == ESP_OK)
|
||||
if (esp_littlefs_info(p->label, &total, &used) == ESP_OK)
|
||||
{
|
||||
cJSON_AddNumberToObject(p_obj, "used", used);
|
||||
cJSON_AddNumberToObject(p_obj, "free", total - used);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Not mounted or not LFS
|
||||
cJSON_AddNumberToObject(p_obj, "used", 0);
|
||||
cJSON_AddNumberToObject(p_obj, "free", p->size);
|
||||
// For other data partitions (nvs, phy_init), just show total as used
|
||||
// for now
|
||||
cJSON_AddNumberToObject(p_obj, "used", 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);
|
||||
it = esp_partition_next(it);
|
||||
}
|
||||
|
||||
cJSON_AddStringToObject(root, "active_partition",
|
||||
@@ -52,6 +84,24 @@ internal esp_err_t api_ota_status_handler(httpd_req_t *req)
|
||||
cJSON_AddStringToObject(root, "target_partition",
|
||||
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);
|
||||
httpd_resp_sendstr(req, status_info);
|
||||
|
||||
|
||||
86
Provider/main/api/tasks/add.cpp
Normal file
86
Provider/main/api/tasks/add.cpp
Normal file
@@ -0,0 +1,86 @@
|
||||
// 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};
|
||||
64
Provider/main/api/tasks/list.cpp
Normal file
64
Provider/main/api/tasks/list.cpp
Normal file
@@ -0,0 +1,64 @@
|
||||
// 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};
|
||||
42
Provider/main/api/tasks/remove.cpp
Normal file
42
Provider/main/api/tasks/remove.cpp
Normal file
@@ -0,0 +1,42 @@
|
||||
// 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};
|
||||
115
Provider/main/api/tasks/store.cpp
Normal file
115
Provider/main/api/tasks/store.cpp
Normal file
@@ -0,0 +1,115 @@
|
||||
// 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
|
||||
}
|
||||
13
Provider/main/api/tasks/store.hpp
Normal file
13
Provider/main/api/tasks/store.hpp
Normal file
@@ -0,0 +1,13 @@
|
||||
#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();
|
||||
8
Provider/main/api/tasks/unity.cpp
Normal file
8
Provider/main/api/tasks/unity.cpp
Normal file
@@ -0,0 +1,8 @@
|
||||
// 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
|
||||
62
Provider/main/api/tasks/upcoming.cpp
Normal file
62
Provider/main/api/tasks/upcoming.cpp
Normal file
@@ -0,0 +1,62 @@
|
||||
// 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};
|
||||
89
Provider/main/api/tasks/update.cpp
Normal file
89
Provider/main/api/tasks/update.cpp
Normal file
@@ -0,0 +1,89 @@
|
||||
// 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};
|
||||
67
Provider/main/api/users/add.cpp
Normal file
67
Provider/main/api/users/add.cpp
Normal file
@@ -0,0 +1,67 @@
|
||||
// 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};
|
||||
41
Provider/main/api/users/list.cpp
Normal file
41
Provider/main/api/users/list.cpp
Normal file
@@ -0,0 +1,41 @@
|
||||
// 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};
|
||||
47
Provider/main/api/users/remove.cpp
Normal file
47
Provider/main/api/users/remove.cpp
Normal file
@@ -0,0 +1,47 @@
|
||||
// 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};
|
||||
67
Provider/main/api/users/store.cpp
Normal file
67
Provider/main/api/users/store.cpp
Normal file
@@ -0,0 +1,67 @@
|
||||
// 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");
|
||||
}
|
||||
11
Provider/main/api/users/store.hpp
Normal file
11
Provider/main/api/users/store.hpp
Normal file
@@ -0,0 +1,11 @@
|
||||
#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();
|
||||
7
Provider/main/api/users/unity.cpp
Normal file
7
Provider/main/api/users/unity.cpp
Normal file
@@ -0,0 +1,7 @@
|
||||
// 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
|
||||
68
Provider/main/api/users/update.cpp
Normal file
68
Provider/main/api/users/update.cpp
Normal file
@@ -0,0 +1,68 @@
|
||||
// POST /api/users/update — Update an existing user's name
|
||||
// Body: {"id": 1, "name": "Bob"}
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "esp_http_server.h"
|
||||
|
||||
#include "api/users/store.hpp"
|
||||
#include "types.hpp"
|
||||
#include "user.hpp"
|
||||
|
||||
internal esp_err_t api_users_update_handler(httpd_req_t *req)
|
||||
{
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
|
||||
|
||||
char buf[128];
|
||||
int received = httpd_req_recv(req, buf, sizeof(buf) - 1);
|
||||
if (received <= 0)
|
||||
{
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Empty body");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
buf[received] = '\0';
|
||||
|
||||
cJSON *body = cJSON_Parse(buf);
|
||||
if (!body)
|
||||
{
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
cJSON *id_item = cJSON_GetObjectItem(body, "id");
|
||||
cJSON *name_item = cJSON_GetObjectItem(body, "name");
|
||||
|
||||
if (!cJSON_IsNumber(id_item) || !cJSON_IsString(name_item) ||
|
||||
strlen(name_item->valuestring) == 0)
|
||||
{
|
||||
cJSON_Delete(body);
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing 'id' or 'name'");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
user_t *user = update_user((uint8)id_item->valueint, name_item->valuestring);
|
||||
cJSON_Delete(body);
|
||||
|
||||
if (!user)
|
||||
{
|
||||
httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "User not found");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
cJSON *resp = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(resp, "status", "ok");
|
||||
|
||||
const char *json = cJSON_PrintUnformatted(resp);
|
||||
httpd_resp_sendstr(req, json);
|
||||
|
||||
free((void *)json);
|
||||
cJSON_Delete(resp);
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
internal const httpd_uri_t api_users_update_uri = {.uri = "/api/users/update",
|
||||
.method = HTTP_POST,
|
||||
.handler =
|
||||
api_users_update_handler,
|
||||
.user_ctx = NULL};
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
#include "types.hpp"
|
||||
|
||||
// Shared Application State (Unity Build)
|
||||
internal bool g_Ethernet_Initialized = false;
|
||||
internal bool g_Wifi_Initialized = false;
|
||||
internal uint8_t g_Active_WWW_Partition = 0;
|
||||
// Shared Application State
|
||||
extern bool g_Ethernet_Initialized;
|
||||
extern bool g_Wifi_Initialized;
|
||||
extern uint8_t g_Active_WWW_Partition;
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
#include <string.h>
|
||||
|
||||
// SDK
|
||||
#include "driver/gpio.h"
|
||||
#include "esp_err.h"
|
||||
#include "esp_eth.h"
|
||||
#include "esp_eth_driver.h"
|
||||
#include "esp_event.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_sleep.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "ethernet_init.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
@@ -18,9 +20,11 @@
|
||||
#include "types.hpp"
|
||||
|
||||
// Forward declarations
|
||||
#if CONFIG_CALENDINK_BLINK_IP
|
||||
internal esp_err_t get_ip_info(esp_netif_ip_info_t *ip_info);
|
||||
internal void led_blink_number(int n, uint8_t r, uint8_t g, uint8_t b);
|
||||
internal void blink_last_ip_octet();
|
||||
#endif
|
||||
|
||||
// Internal states
|
||||
internal esp_netif_ip_info_t s_current_ip_info = {};
|
||||
@@ -70,6 +74,10 @@ void ethernet_event_handler(void *arg, esp_event_base_t event_base,
|
||||
{
|
||||
ESP_LOGI(kLogEthernet, "Ethernet Link Down");
|
||||
s_eth_link_up = false;
|
||||
if (s_semph_eth_link)
|
||||
{
|
||||
xSemaphoreGive(s_semph_eth_link);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +165,9 @@ internal esp_err_t connect_ethernet(bool blockUntilIPAcquired)
|
||||
{
|
||||
ESP_LOGI(kLogEthernet, "Waiting for IP address.");
|
||||
xSemaphoreTake(s_semph_get_ip_addrs, portMAX_DELAY);
|
||||
#if CONFIG_CALENDINK_BLINK_IP
|
||||
blink_last_ip_octet();
|
||||
#endif
|
||||
}
|
||||
|
||||
return ESP_OK;
|
||||
@@ -182,10 +192,18 @@ internal esp_err_t check_ethernet_connection(uint32_t timeoutSeconds)
|
||||
{
|
||||
if (!xSemaphoreTake(s_semph_eth_link, pdMS_TO_TICKS(5000)))
|
||||
{
|
||||
ESP_LOGE(kLogEthernet,
|
||||
"No physical Ethernet link detected. Skipping DHCP wait.");
|
||||
ESP_LOGE(
|
||||
kLogEthernet,
|
||||
"No physical Ethernet link detected (Timeout). Skipping DHCP wait.");
|
||||
return ESP_ERR_INVALID_STATE; // Special error to skip retries
|
||||
}
|
||||
|
||||
if (!s_eth_link_up)
|
||||
{
|
||||
ESP_LOGE(kLogEthernet, "No physical Ethernet link detected "
|
||||
"(Disconnected). Skipping DHCP wait.");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
}
|
||||
|
||||
ESP_LOGI(kLogEthernet, "Waiting for IP address for %d seconds.",
|
||||
@@ -193,7 +211,9 @@ internal esp_err_t check_ethernet_connection(uint32_t timeoutSeconds)
|
||||
if (xSemaphoreTake(s_semph_get_ip_addrs,
|
||||
pdMS_TO_TICKS(timeoutSeconds * 1000)))
|
||||
{
|
||||
#if CONFIG_CALENDINK_BLINK_IP
|
||||
blink_last_ip_octet();
|
||||
#endif
|
||||
return ESP_OK;
|
||||
}
|
||||
else
|
||||
@@ -209,15 +229,11 @@ internal esp_netif_t *s_wifi_netif = nullptr;
|
||||
internal SemaphoreHandle_t s_semph_get_wifi_ip_addrs = nullptr;
|
||||
internal SemaphoreHandle_t s_semph_wifi_link = nullptr;
|
||||
internal volatile bool s_wifi_link_up = false;
|
||||
internal TimerHandle_t s_wifi_reconnect_timer = nullptr;
|
||||
|
||||
#ifndef NDEBUG
|
||||
internal bool s_wifi_connected = false;
|
||||
#endif
|
||||
|
||||
// Forward declaration for timer callback
|
||||
internal void wifi_reconnect_timer_cb(TimerHandle_t xTimer);
|
||||
|
||||
void wifi_event_handler(void *arg, esp_event_base_t event_base,
|
||||
int32_t event_id, void *event_data)
|
||||
{
|
||||
@@ -232,11 +248,11 @@ void wifi_event_handler(void *arg, esp_event_base_t event_base,
|
||||
}
|
||||
else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED)
|
||||
{
|
||||
ESP_LOGI(kLogWifi, "WiFi disconnected. Scheduling reconnect...");
|
||||
ESP_LOGI(kLogWifi, "WiFi disconnected.");
|
||||
s_wifi_link_up = false;
|
||||
if (s_wifi_reconnect_timer != nullptr)
|
||||
if (s_semph_wifi_link)
|
||||
{
|
||||
xTimerStart(s_wifi_reconnect_timer, 0);
|
||||
xSemaphoreGive(s_semph_wifi_link);
|
||||
}
|
||||
}
|
||||
else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP)
|
||||
@@ -254,20 +270,8 @@ void wifi_event_handler(void *arg, esp_event_base_t event_base,
|
||||
}
|
||||
}
|
||||
|
||||
internal void wifi_reconnect_timer_cb(TimerHandle_t xTimer)
|
||||
{
|
||||
ESP_LOGI(kLogWifi, "Timer expired. Executing esp_wifi_connect()...");
|
||||
esp_wifi_connect();
|
||||
}
|
||||
|
||||
void teardown_wifi()
|
||||
{
|
||||
if (s_wifi_reconnect_timer != nullptr)
|
||||
{
|
||||
xTimerStop(s_wifi_reconnect_timer, 0);
|
||||
xTimerDelete(s_wifi_reconnect_timer, 0);
|
||||
s_wifi_reconnect_timer = nullptr;
|
||||
}
|
||||
|
||||
if (s_semph_wifi_link != nullptr)
|
||||
{
|
||||
@@ -313,11 +317,6 @@ internal esp_err_t connect_wifi(const char *ssid, const char *password,
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
// Create a 5-second timer to avoid spamming connect requests
|
||||
s_wifi_reconnect_timer =
|
||||
xTimerCreate("wifi_recon", pdMS_TO_TICKS(5000), pdFALSE, (void *)0,
|
||||
wifi_reconnect_timer_cb);
|
||||
|
||||
// esp_netif_init() is already called by Ethernet, but safe to call multiple
|
||||
// times or we can assume it's initialized.
|
||||
s_wifi_netif = esp_netif_create_default_wifi_sta();
|
||||
@@ -343,6 +342,16 @@ internal esp_err_t connect_wifi(const char *ssid, const char *password,
|
||||
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
|
||||
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
|
||||
ESP_ERROR_CHECK(esp_wifi_start());
|
||||
|
||||
// Set Power Save mode based on sdkconfig selection
|
||||
#if defined(CONFIG_CALENDINK_WIFI_PS_MAX_MODEM)
|
||||
ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_MAX_MODEM));
|
||||
#elif defined(CONFIG_CALENDINK_WIFI_PS_NONE)
|
||||
ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE));
|
||||
#else
|
||||
ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_MIN_MODEM));
|
||||
#endif
|
||||
|
||||
ESP_ERROR_CHECK(esp_wifi_connect());
|
||||
|
||||
ESP_ERROR_CHECK(esp_register_shutdown_handler(&teardown_wifi));
|
||||
@@ -351,7 +360,9 @@ internal esp_err_t connect_wifi(const char *ssid, const char *password,
|
||||
{
|
||||
ESP_LOGI(kLogWifi, "Waiting for IP address.");
|
||||
xSemaphoreTake(s_semph_get_wifi_ip_addrs, portMAX_DELAY);
|
||||
#if CONFIG_CALENDINK_BLINK_IP
|
||||
blink_last_ip_octet();
|
||||
#endif
|
||||
}
|
||||
|
||||
return ESP_OK;
|
||||
@@ -373,31 +384,35 @@ internal esp_err_t check_wifi_connection(uint32_t timeoutSeconds)
|
||||
// Wait up to 10000ms for the physical link to associate with the AP
|
||||
if (!s_wifi_link_up)
|
||||
{
|
||||
// If the timer isn't already running, kickstart a connection attempt now
|
||||
if (s_wifi_reconnect_timer != nullptr &&
|
||||
xTimerIsTimerActive(s_wifi_reconnect_timer) == pdFALSE)
|
||||
ESP_LOGI(kLogWifi,
|
||||
"Physical link is down. Requesting immediate AP association...");
|
||||
esp_err_t err = esp_wifi_connect();
|
||||
if (err != ESP_OK && err != ESP_ERR_WIFI_CONN)
|
||||
{
|
||||
ESP_LOGI(kLogWifi,
|
||||
"Physical link is down. Requesting immediate AP association...");
|
||||
esp_err_t err = esp_wifi_connect();
|
||||
if (err != ESP_OK && err != ESP_ERR_WIFI_CONN)
|
||||
{
|
||||
ESP_LOGE(kLogWifi, "esp_wifi_connect failed: %s", esp_err_to_name(err));
|
||||
}
|
||||
ESP_LOGE(kLogWifi, "esp_wifi_connect failed: %s", esp_err_to_name(err));
|
||||
}
|
||||
|
||||
if (!xSemaphoreTake(s_semph_wifi_link, pdMS_TO_TICKS(10000)))
|
||||
{
|
||||
ESP_LOGE(kLogWifi, "Failed to associate with WiFi AP.");
|
||||
ESP_LOGE(kLogWifi, "Failed to associate with WiFi AP (Timeout).");
|
||||
return ESP_ERR_TIMEOUT; // Return timeout so main.cpp triggers a retry
|
||||
}
|
||||
|
||||
// After semaphore is taken, check if it was because of a disconnect
|
||||
if (!s_wifi_link_up)
|
||||
{
|
||||
ESP_LOGE(kLogWifi, "Failed to associate with WiFi AP (Disconnected).");
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
}
|
||||
|
||||
ESP_LOGI(kLogWifi, "Waiting for IP address for %d seconds.", timeoutSeconds);
|
||||
if (xSemaphoreTake(s_semph_get_wifi_ip_addrs,
|
||||
pdMS_TO_TICKS(timeoutSeconds * 1000)))
|
||||
{
|
||||
#if CONFIG_CALENDINK_BLINK_IP
|
||||
blink_last_ip_octet();
|
||||
#endif
|
||||
return ESP_OK;
|
||||
}
|
||||
else
|
||||
@@ -406,14 +421,13 @@ internal esp_err_t check_wifi_connection(uint32_t timeoutSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
#if CONFIG_CALENDINK_BLINK_IP
|
||||
internal esp_err_t get_ip_info(esp_netif_ip_info_t *ip_info)
|
||||
{
|
||||
*ip_info = s_current_ip_info;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
internal void led_blink_number(int n, uint8_t r, uint8_t g, uint8_t b);
|
||||
|
||||
internal void blink_last_ip_octet()
|
||||
{
|
||||
esp_netif_ip_info_t ip_info;
|
||||
@@ -438,3 +452,4 @@ internal void blink_last_ip_octet()
|
||||
led_blink_number(u, 255, 255, 255);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -13,11 +13,15 @@
|
||||
#endif
|
||||
|
||||
// Project
|
||||
#include "api/ota/bundle.cpp"
|
||||
#include "api/ota/firmware.cpp"
|
||||
#include "api/ota/frontend.cpp"
|
||||
#include "api/ota/status.cpp"
|
||||
#include "api/system/info.cpp"
|
||||
#include "api/system/reboot.cpp"
|
||||
|
||||
#include "api/display/unity.cpp"
|
||||
#include "api/tasks/unity.cpp"
|
||||
#include "api/users/unity.cpp"
|
||||
|
||||
internal const char *TAG = "HTTP_SERVER";
|
||||
|
||||
@@ -25,11 +29,53 @@ constexpr uint8 kGZ_Extension_Length = sizeof(".gz") - 1;
|
||||
|
||||
#define FILE_PATH_MAX (ESP_VFS_PATH_MAX + 128)
|
||||
#define SCRATCH_BUFSIZE 4096
|
||||
#define MAX_SCRATCH_BUFFERS 10
|
||||
|
||||
typedef struct
|
||||
{
|
||||
char scratch[SCRATCH_BUFSIZE];
|
||||
} http_server_data_t;
|
||||
char *buffers[MAX_SCRATCH_BUFFERS];
|
||||
bool in_use[MAX_SCRATCH_BUFFERS];
|
||||
} scratch_pool_t;
|
||||
|
||||
static scratch_pool_t global_scratch_pool = {};
|
||||
|
||||
char *get_scratch_buffer()
|
||||
{
|
||||
for (int i = 0; i < MAX_SCRATCH_BUFFERS; i++)
|
||||
{
|
||||
if (!global_scratch_pool.in_use[i])
|
||||
{
|
||||
if (global_scratch_pool.buffers[i] == NULL)
|
||||
{
|
||||
global_scratch_pool.buffers[i] = (char *)malloc(SCRATCH_BUFSIZE);
|
||||
if (global_scratch_pool.buffers[i] == NULL)
|
||||
{
|
||||
ESP_LOGE(TAG, "Failed to allocate scratch buffer from heap!");
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
global_scratch_pool.in_use[i] = true;
|
||||
return global_scratch_pool.buffers[i];
|
||||
}
|
||||
}
|
||||
ESP_LOGE(TAG, "All scratch buffers in use! Increase MAX_SCRATCH_BUFFERS");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void free_scratch_buffer(char *buffer)
|
||||
{
|
||||
if (buffer == NULL)
|
||||
return;
|
||||
for (int i = 0; i < MAX_SCRATCH_BUFFERS; i++)
|
||||
{
|
||||
if (global_scratch_pool.buffers[i] == buffer)
|
||||
{
|
||||
global_scratch_pool.in_use[i] = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
ESP_LOGE(TAG, "Attempted to free unknown scratch buffer!");
|
||||
}
|
||||
|
||||
#ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES
|
||||
// Set HTTP response content type according to file extension
|
||||
@@ -135,8 +181,14 @@ internal esp_err_t static_file_handler(httpd_req_t *req)
|
||||
|
||||
set_content_type_from_file(req, filepath);
|
||||
|
||||
http_server_data_t *rest_context = (http_server_data_t *)req->user_ctx;
|
||||
char *chunk = rest_context->scratch;
|
||||
char *chunk = get_scratch_buffer();
|
||||
if (chunk == NULL)
|
||||
{
|
||||
close(fd);
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Server busy");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
ssize_t read_bytes;
|
||||
|
||||
do
|
||||
@@ -152,6 +204,7 @@ internal esp_err_t static_file_handler(httpd_req_t *req)
|
||||
{
|
||||
close(fd);
|
||||
ESP_LOGE(TAG, "File sending failed!");
|
||||
free_scratch_buffer(chunk);
|
||||
httpd_resp_sendstr_chunk(req, NULL); // Abort sending
|
||||
return ESP_FAIL;
|
||||
}
|
||||
@@ -159,6 +212,7 @@ internal esp_err_t static_file_handler(httpd_req_t *req)
|
||||
} while (read_bytes > 0);
|
||||
|
||||
close(fd);
|
||||
free_scratch_buffer(chunk);
|
||||
httpd_resp_send_chunk(req, NULL, 0); // End response
|
||||
|
||||
return ESP_OK;
|
||||
@@ -169,7 +223,8 @@ internal esp_err_t static_file_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-Methods", "GET, POST, OPTIONS");
|
||||
httpd_resp_set_hdr(req, "Access-Control-Allow-Methods",
|
||||
"GET, POST, DELETE, OPTIONS");
|
||||
httpd_resp_set_hdr(req, "Access-Control-Allow-Headers", "Content-Type");
|
||||
httpd_resp_set_status(req, "204 No Content");
|
||||
httpd_resp_send(req, NULL, 0);
|
||||
@@ -182,6 +237,7 @@ internal httpd_handle_t start_webserver(void)
|
||||
esp_vfs_littlefs_conf_t conf = {};
|
||||
conf.base_path = "/www";
|
||||
conf.partition_label = g_Active_WWW_Partition == 0 ? "www_0" : "www_1";
|
||||
ESP_LOGI(TAG, "Mounting LittleFS partition: %s", conf.partition_label);
|
||||
conf.format_if_mount_failed = false;
|
||||
conf.dont_mount = false;
|
||||
esp_err_t ret = esp_vfs_littlefs_register(&conf);
|
||||
@@ -205,17 +261,13 @@ internal httpd_handle_t start_webserver(void)
|
||||
ESP_LOGI(TAG, "LittleFS mounted on /www");
|
||||
}
|
||||
#endif
|
||||
http_server_data_t *rest_context =
|
||||
(http_server_data_t *)calloc(1, sizeof(http_server_data_t));
|
||||
if (rest_context == NULL)
|
||||
{
|
||||
ESP_LOGE(TAG, "No memory for rest context");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
|
||||
config.uri_match_fn = httpd_uri_match_wildcard;
|
||||
config.max_uri_handlers = 10; // We have info, reboot, options, and static
|
||||
config.max_uri_handlers = 20;
|
||||
config.max_open_sockets = 24;
|
||||
config.lru_purge_enable = true;
|
||||
config.stack_size = 16384;
|
||||
|
||||
httpd_handle_t server = NULL;
|
||||
ESP_LOGI(TAG, "Starting HTTP Server on port: '%d'", config.server_port);
|
||||
@@ -232,15 +284,35 @@ internal httpd_handle_t start_webserver(void)
|
||||
// Register system API routes
|
||||
httpd_register_uri_handler(server, &api_system_info_uri);
|
||||
httpd_register_uri_handler(server, &api_system_reboot_uri);
|
||||
httpd_register_uri_handler(server, &api_display_image_uri);
|
||||
httpd_register_uri_handler(server, &api_ota_status_uri);
|
||||
httpd_register_uri_handler(server, &api_ota_frontend_uri);
|
||||
httpd_register_uri_handler(server, &api_ota_firmware_uri);
|
||||
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);
|
||||
|
||||
// Populate dummy data for development (debug builds only)
|
||||
#ifndef NDEBUG
|
||||
seed_users();
|
||||
seed_tasks();
|
||||
#endif
|
||||
|
||||
#ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES
|
||||
// Register static file handler last as a catch-all wildcard if deployed
|
||||
httpd_uri_t static_get_uri = {.uri = "/*",
|
||||
.method = HTTP_GET,
|
||||
.handler = static_file_handler,
|
||||
.user_ctx = rest_context};
|
||||
.user_ctx = NULL};
|
||||
httpd_register_uri_handler(server, &static_get_uri);
|
||||
#endif
|
||||
|
||||
@@ -248,18 +320,16 @@ internal httpd_handle_t start_webserver(void)
|
||||
}
|
||||
|
||||
ESP_LOGE(TAG, "Error starting server!");
|
||||
free(rest_context);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
internal void stop_webserver(httpd_handle_t server)
|
||||
internal void stop_webserver(httpd_handle_t server, uint8_t partition_index)
|
||||
{
|
||||
if (server)
|
||||
{
|
||||
httpd_stop(server);
|
||||
#ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES
|
||||
esp_vfs_littlefs_unregister(g_Active_WWW_Partition == 0 ? "www_0"
|
||||
: "www_1");
|
||||
esp_vfs_littlefs_unregister(partition_index == 0 ? "www_0" : "www_1");
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,5 +15,7 @@ dependencies:
|
||||
# # All dependencies of `main` are public by default.
|
||||
# public: true
|
||||
espressif/led_strip: ^3.0.3
|
||||
espressif/mdns: ^1.4.1
|
||||
espressif/ethernet_init: ^1.3.0
|
||||
joltwallet/littlefs: "^1.20" # https://github.com/joltwallet/esp_littlefs
|
||||
lvgl/lvgl: "^9.2.0"
|
||||
|
||||
@@ -58,6 +58,8 @@ internal void set_led_status(led_status status)
|
||||
}
|
||||
led_strip_refresh(led_strip);
|
||||
}
|
||||
|
||||
#if CONFIG_CALENDINK_BLINK_IP
|
||||
internal void led_blink_number(int n, uint8_t r, uint8_t g, uint8_t b)
|
||||
{
|
||||
if (n <= 0)
|
||||
@@ -85,3 +87,4 @@ internal void led_blink_number(int n, uint8_t r, uint8_t g, uint8_t b)
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||
}
|
||||
#endif
|
||||
|
||||
7248
Provider/main/lodepng/lodepng.cpp
Normal file
7248
Provider/main/lodepng/lodepng.cpp
Normal file
File diff suppressed because it is too large
Load Diff
2188
Provider/main/lodepng/lodepng.h
Normal file
2188
Provider/main/lodepng/lodepng.h
Normal file
File diff suppressed because it is too large
Load Diff
152
Provider/main/lodepng_alloc.cpp
Normal file
152
Provider/main/lodepng_alloc.cpp
Normal file
@@ -0,0 +1,152 @@
|
||||
#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 (2 * 1024 * 1024)
|
||||
|
||||
static uint8_t* s_lodepng_pool = nullptr;
|
||||
static size_t s_lodepng_pool_used = 0;
|
||||
|
||||
void lodepng_allocator_init()
|
||||
{
|
||||
if (s_lodepng_pool != nullptr) return;
|
||||
|
||||
ESP_LOGI(kTagLodeAlloc, "Allocating %d bytes in PSRAM for LodePNG bump allocator...", LODEPNG_ALLOC_POOL_SIZE);
|
||||
|
||||
// SPIRAM fallback to internal if someone tests without a PSRAM chip
|
||||
s_lodepng_pool = (uint8_t*)heap_caps_malloc(LODEPNG_ALLOC_POOL_SIZE, MALLOC_CAP_SPIRAM);
|
||||
if (!s_lodepng_pool)
|
||||
{
|
||||
s_lodepng_pool = (uint8_t*)heap_caps_malloc(LODEPNG_ALLOC_POOL_SIZE, MALLOC_CAP_DEFAULT);
|
||||
}
|
||||
|
||||
if (!s_lodepng_pool)
|
||||
{
|
||||
ESP_LOGE(kTagLodeAlloc, "CRITICAL: Failed to allocate LodePNG PSRAM pool!");
|
||||
}
|
||||
}
|
||||
|
||||
void lodepng_allocator_reset()
|
||||
{
|
||||
s_lodepng_pool_used = 0;
|
||||
}
|
||||
|
||||
void lodepng_allocator_free()
|
||||
{
|
||||
if (s_lodepng_pool)
|
||||
{
|
||||
free(s_lodepng_pool);
|
||||
s_lodepng_pool = nullptr;
|
||||
}
|
||||
s_lodepng_pool_used = 0;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// Custom Allocators injected into lodepng.c
|
||||
// ----------------------------------------------------
|
||||
|
||||
// To support realloc properly, we prefix each allocation with an 8-byte header storing the size.
|
||||
struct AllocHeader {
|
||||
size_t size;
|
||||
};
|
||||
|
||||
void* lodepng_custom_malloc(size_t size)
|
||||
{
|
||||
if (!s_lodepng_pool)
|
||||
{
|
||||
ESP_LOGE(kTagLodeAlloc, "lodepng_malloc called before lodepng_allocator_init!");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Align size to 8 bytes to avoid unaligned access faults
|
||||
size_t aligned_size = (size + 7) & ~7;
|
||||
size_t total_alloc = sizeof(AllocHeader) + aligned_size;
|
||||
|
||||
if (s_lodepng_pool_used + total_alloc > LODEPNG_ALLOC_POOL_SIZE)
|
||||
{
|
||||
ESP_LOGE(kTagLodeAlloc, "LodePNG pool exhausted! Requested: %zu, Used: %zu, Total: %d", size, s_lodepng_pool_used, LODEPNG_ALLOC_POOL_SIZE);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Grab pointer and bump
|
||||
uint8_t* ptr = s_lodepng_pool + s_lodepng_pool_used;
|
||||
s_lodepng_pool_used += total_alloc;
|
||||
|
||||
// Write header
|
||||
AllocHeader* header = (AllocHeader*)ptr;
|
||||
header->size = size; // We store exact size for realloc memcpy bounds
|
||||
|
||||
// Return pointer right after header
|
||||
return ptr + sizeof(AllocHeader);
|
||||
}
|
||||
|
||||
void* lodepng_custom_realloc(void* ptr, size_t new_size)
|
||||
{
|
||||
if (!ptr)
|
||||
{
|
||||
return lodepng_custom_malloc(new_size);
|
||||
}
|
||||
|
||||
if (new_size == 0)
|
||||
{
|
||||
lodepng_custom_free(ptr);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Get original header
|
||||
uint8_t* orig_ptr = (uint8_t*)ptr - sizeof(AllocHeader);
|
||||
AllocHeader* header = (AllocHeader*)orig_ptr;
|
||||
|
||||
size_t old_size = header->size;
|
||||
if (new_size <= old_size)
|
||||
{
|
||||
// Don't shrink to save time, bump allocator can't reclaim it easily anyway.
|
||||
return ptr;
|
||||
}
|
||||
|
||||
// Let's see if this ptr was the *very last* allocation.
|
||||
// If so, we can just expand it in place!
|
||||
size_t old_aligned_size = (old_size + 7) & ~7;
|
||||
if (orig_ptr + sizeof(AllocHeader) + old_aligned_size == s_lodepng_pool + s_lodepng_pool_used)
|
||||
{
|
||||
// We are at the end! Just bump further!
|
||||
size_t new_aligned_size = (new_size + 7) & ~7;
|
||||
size_t size_diff = new_aligned_size - old_aligned_size;
|
||||
|
||||
if (s_lodepng_pool_used + size_diff > LODEPNG_ALLOC_POOL_SIZE)
|
||||
{
|
||||
ESP_LOGE(kTagLodeAlloc, "LodePNG pool exhausted during in-place realloc!");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
s_lodepng_pool_used += size_diff;
|
||||
header->size = new_size;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
// Otherwise, we have to copy into a new block
|
||||
void* new_ptr = lodepng_custom_malloc(new_size);
|
||||
if (new_ptr)
|
||||
{
|
||||
memcpy(new_ptr, ptr, old_size);
|
||||
}
|
||||
|
||||
return new_ptr;
|
||||
}
|
||||
|
||||
void lodepng_custom_free(void* ptr)
|
||||
{
|
||||
// No-op! The bump pointer will just reset to 0 once the API endpoint is done!
|
||||
(void)ptr;
|
||||
}
|
||||
14
Provider/main/lodepng_alloc.hpp
Normal file
14
Provider/main/lodepng_alloc.hpp
Normal file
@@ -0,0 +1,14 @@
|
||||
#ifndef LODEPNG_ALLOC_HPP
|
||||
#define LODEPNG_ALLOC_HPP
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
void lodepng_allocator_init();
|
||||
void lodepng_allocator_reset();
|
||||
void lodepng_allocator_free();
|
||||
|
||||
void* lodepng_custom_malloc(size_t size);
|
||||
void* lodepng_custom_realloc(void* ptr, size_t new_size);
|
||||
void lodepng_custom_free(void* ptr);
|
||||
|
||||
#endif // LODEPNG_ALLOC_HPP
|
||||
106
Provider/main/lv_setup.cpp
Normal file
106
Provider/main/lv_setup.cpp
Normal file
@@ -0,0 +1,106 @@
|
||||
#include "lv_setup.hpp"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/task.h"
|
||||
#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);
|
||||
}
|
||||
|
||||
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_L8 (1 byte per pixel)
|
||||
size_t buf_size = LV_DRAW_BUF_SIZE(width, height, LV_COLOR_FORMAT_L8);
|
||||
|
||||
// Fallback to MALLOC_CAP_DEFAULT if we can't get SPIRAM (for debugging without it)
|
||||
void *buf1 = heap_caps_malloc(buf_size, MALLOC_CAP_SPIRAM);
|
||||
if (!buf1)
|
||||
{
|
||||
ESP_LOGW(kTagLvgl, "Failed to allocate LVGL draw buffer in PSRAM, falling back to internal RAM");
|
||||
buf1 = heap_caps_malloc(buf_size, MALLOC_CAP_DEFAULT);
|
||||
}
|
||||
|
||||
if (!buf1)
|
||||
{
|
||||
ESP_LOGE(kTagLvgl, "Failed to allocate LVGL draw buffer entirely.");
|
||||
return;
|
||||
}
|
||||
|
||||
g_LvglDrawBuffer = (uint8_t *)buf1;
|
||||
|
||||
lv_display_set_buffers(g_LvglDisplay, buf1, nullptr, buf_size, LV_DISPLAY_RENDER_MODE_FULL);
|
||||
|
||||
// Explicitly set the color format of the display if it's set in sdkconfig/driver
|
||||
lv_display_set_color_format(g_LvglDisplay, LV_COLOR_FORMAT_L8);
|
||||
|
||||
// Create the background task for the LVGL timer
|
||||
xTaskCreate(lvgl_tick_task, "LVGL Tick", 4096, nullptr, 5, nullptr);
|
||||
|
||||
// Draw the sample UI
|
||||
if (xSemaphoreTake(g_LvglMutex, portMAX_DELAY) == pdTRUE)
|
||||
{
|
||||
lv_draw_sample_ui();
|
||||
xSemaphoreGive(g_LvglMutex);
|
||||
}
|
||||
|
||||
ESP_LOGI(kTagLvgl, "LVGL fully initialized. Display %lux%lu created.", width, height);
|
||||
}
|
||||
11
Provider/main/lv_setup.hpp
Normal file
11
Provider/main/lv_setup.hpp
Normal file
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "lvgl.h"
|
||||
|
||||
extern SemaphoreHandle_t g_LvglMutex;
|
||||
extern lv_display_t *g_LvglDisplay;
|
||||
extern uint8_t *g_LvglDrawBuffer;
|
||||
|
||||
void setup_lvgl();
|
||||
@@ -1,8 +1,13 @@
|
||||
// STD Lib
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
// SDK
|
||||
#include "esp_log.h"
|
||||
#include "esp_ota_ops.h"
|
||||
#include "esp_pm.h"
|
||||
#include "esp_psram.h"
|
||||
#include "esp_system.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "nvs.h"
|
||||
@@ -10,7 +15,6 @@
|
||||
#include "sdkconfig.h"
|
||||
#include "soc/gpio_num.h"
|
||||
|
||||
|
||||
// Project headers
|
||||
#include "appstate.hpp"
|
||||
#include "types.hpp"
|
||||
@@ -20,13 +24,46 @@
|
||||
#include "led_status.cpp"
|
||||
#include "connect.cpp"
|
||||
#include "http_server.cpp"
|
||||
#include "mdns_service.cpp"
|
||||
#include "udp_logger.cpp"
|
||||
#include "lodepng_alloc.cpp"
|
||||
#include "lodepng/lodepng.cpp"
|
||||
#include "lv_setup.cpp"
|
||||
// clang-format on
|
||||
|
||||
internal constexpr bool kBlockUntilEthernetEstablished = false;
|
||||
internal const char *kTagMain = "MAIN";
|
||||
|
||||
// Global Application State Definitions
|
||||
bool g_Ethernet_Initialized = false;
|
||||
bool g_Wifi_Initialized = false;
|
||||
uint8_t g_Active_WWW_Partition = 0;
|
||||
|
||||
constexpr bool kBlockUntilEthernetEstablished = false;
|
||||
|
||||
internal void my_timer_callback(void *arg)
|
||||
{
|
||||
ESP_LOGI(kTagMain, "Timer finished! Turning Led Off...");
|
||||
destroy_led();
|
||||
}
|
||||
|
||||
extern "C" void app_main()
|
||||
{
|
||||
printf("Hello, Worldi!\n");
|
||||
ESP_LOGI(kTagMain, "Hello, Calendink OTA! [V0.1.1]");
|
||||
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;
|
||||
|
||||
@@ -41,11 +78,29 @@ extern "C" void app_main()
|
||||
nvs_handle_t my_handle;
|
||||
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);
|
||||
if (err != ESP_OK && err != ESP_ERR_NVS_NOT_FOUND)
|
||||
if (err == ESP_OK)
|
||||
{
|
||||
printf("Error reading www_part from NVS: %s\n", esp_err_to_name(err));
|
||||
ESP_LOGI(kTagMain, "NVS: Found active www partition: %d",
|
||||
g_Active_WWW_Partition);
|
||||
}
|
||||
|
||||
if (err == ESP_ERR_NVS_NOT_FOUND)
|
||||
{
|
||||
// First boot (no NVS key yet): default to www_0
|
||||
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)
|
||||
{
|
||||
g_Active_WWW_Partition = 0;
|
||||
@@ -54,7 +109,78 @@ extern "C" void app_main()
|
||||
}
|
||||
else
|
||||
{
|
||||
printf("Error opening NVS handle!\n");
|
||||
ESP_LOGE(kTagMain, "Error opening NVS handle!");
|
||||
}
|
||||
|
||||
// Detect if this is the first boot after a new flash (Firmware or Frontend)
|
||||
bool is_new_flash = false;
|
||||
if (nvs_open("storage", NVS_READWRITE, &my_handle) == ESP_OK)
|
||||
{
|
||||
// 1. Check Firmware Compile Time
|
||||
char last_time[64] = {0};
|
||||
size_t time_size = sizeof(last_time);
|
||||
const char *current_time = __DATE__ " " __TIME__;
|
||||
if (nvs_get_str(my_handle, "last_fw_time", last_time, &time_size) !=
|
||||
ESP_OK ||
|
||||
strcmp(last_time, current_time) != 0)
|
||||
{
|
||||
ESP_LOGI(kTagMain, "New firmware detected! (Last: %s, Current: %s)",
|
||||
last_time[0] ? last_time : "None", current_time);
|
||||
is_new_flash = true;
|
||||
nvs_set_str(my_handle, "last_fw_time", current_time);
|
||||
}
|
||||
|
||||
// 2. Check Frontend Partition Fingerprint (www_0)
|
||||
const esp_partition_t *www0_p = esp_partition_find_first(
|
||||
ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_LITTLEFS, "www_0");
|
||||
if (www0_p)
|
||||
{
|
||||
uint8_t current_sha[32];
|
||||
if (esp_partition_get_sha256(www0_p, current_sha) == ESP_OK)
|
||||
{
|
||||
uint8_t last_sha[32] = {0};
|
||||
size_t sha_size = sizeof(last_sha);
|
||||
if (nvs_get_blob(my_handle, "www0_sha", last_sha, &sha_size) !=
|
||||
ESP_OK ||
|
||||
memcmp(last_sha, current_sha, 32) != 0)
|
||||
{
|
||||
ESP_LOGI(kTagMain, "New frontend partition detected via SHA256!");
|
||||
is_new_flash = true;
|
||||
nvs_set_blob(my_handle, "www0_sha", current_sha, 32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (is_new_flash)
|
||||
{
|
||||
nvs_commit(my_handle);
|
||||
}
|
||||
nvs_close(my_handle);
|
||||
}
|
||||
|
||||
// If we are running from FACTORY and a new flash was detected, override to
|
||||
// www_0
|
||||
{
|
||||
const esp_partition_t *running = esp_ota_get_running_partition();
|
||||
if (running != NULL &&
|
||||
running->subtype == ESP_PARTITION_SUBTYPE_APP_FACTORY)
|
||||
{
|
||||
if (is_new_flash && g_Active_WWW_Partition != 0)
|
||||
{
|
||||
ESP_LOGW(kTagMain,
|
||||
"FACTORY APP + NEW FLASH: Overriding www_part to 0 (was %d)",
|
||||
g_Active_WWW_Partition);
|
||||
g_Active_WWW_Partition = 0;
|
||||
|
||||
// Persist the override
|
||||
if (nvs_open("storage", NVS_READWRITE, &my_handle) == ESP_OK)
|
||||
{
|
||||
nvs_set_u8(my_handle, "www_part", 0);
|
||||
nvs_commit(my_handle);
|
||||
nvs_close(my_handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ESP_ERROR_CHECK(esp_event_loop_create_default());
|
||||
@@ -82,7 +208,7 @@ extern "C" void app_main()
|
||||
|
||||
if (result == ESP_ERR_INVALID_STATE)
|
||||
{
|
||||
printf("Ethernet cable not plugged in, skipping retries.\n");
|
||||
ESP_LOGW(kTagMain, "Ethernet cable not plugged in, skipping retries.");
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -99,7 +225,7 @@ extern "C" void app_main()
|
||||
|
||||
if (result != ESP_OK)
|
||||
{
|
||||
printf("Ethernet failed, trying wifi\n");
|
||||
ESP_LOGW(kTagMain, "Ethernet failed, trying wifi");
|
||||
disconnect_ethernet();
|
||||
g_Ethernet_Initialized = false;
|
||||
|
||||
@@ -136,36 +262,109 @@ extern "C" void app_main()
|
||||
|
||||
if (result != ESP_OK)
|
||||
{
|
||||
printf("Wifi failed.\n");
|
||||
ESP_LOGE(kTagMain, "Wifi failed.");
|
||||
goto shutdown;
|
||||
}
|
||||
|
||||
set_led_status(led_status::ReadyWifi);
|
||||
printf("Will use Wifi!\n");
|
||||
ESP_LOGI(kTagMain, "Will use Wifi!");
|
||||
}
|
||||
else
|
||||
{
|
||||
set_led_status(led_status::ReadyEthernet);
|
||||
printf("Will use Ethernet!\n");
|
||||
ESP_LOGI(kTagMain, "Will use Ethernet!");
|
||||
}
|
||||
|
||||
printf("Connected!\n");
|
||||
ESP_LOGI(kTagMain, "Connected! IP acquired.");
|
||||
|
||||
#if !defined(NDEBUG)
|
||||
start_udp_logging(514);
|
||||
#endif
|
||||
|
||||
// Start LVGL
|
||||
ESP_LOGI(kTagMain, "ABOUT TO START LVGL");
|
||||
vTaskDelay(pdMS_TO_TICKS(500));
|
||||
setup_lvgl();
|
||||
ESP_LOGI(kTagMain, "LVGL STARTED");
|
||||
vTaskDelay(pdMS_TO_TICKS(500));
|
||||
|
||||
// Start the webserver
|
||||
web_server = start_webserver();
|
||||
|
||||
// Keep the main task alive indefinitely
|
||||
while (true)
|
||||
// Start mDNS
|
||||
start_mdns();
|
||||
|
||||
// Mark the current app as valid to cancel rollback, only if it's an OTA app
|
||||
{
|
||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||
const esp_partition_t *running = esp_ota_get_running_partition();
|
||||
if (running != NULL &&
|
||||
running->subtype >= ESP_PARTITION_SUBTYPE_APP_OTA_MIN &&
|
||||
running->subtype < ESP_PARTITION_SUBTYPE_APP_OTA_MAX)
|
||||
{
|
||||
esp_ota_mark_app_valid_cancel_rollback();
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const esp_timer_create_args_t timer_args = {.callback = &my_timer_callback,
|
||||
.arg = nullptr,
|
||||
.dispatch_method =
|
||||
ESP_TIMER_TASK,
|
||||
.name = "Led Turn Off",
|
||||
.skip_unhandled_events = true};
|
||||
|
||||
// Create and start timer if needed, or this was just stub code?
|
||||
esp_timer_handle_t timer_handle;
|
||||
if (esp_timer_create(&timer_args, &timer_handle) == ESP_OK)
|
||||
{
|
||||
esp_timer_start_once(timer_handle, 5'000'000); // 5 sec cooldown
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the main task alive indefinitely
|
||||
{
|
||||
#if CONFIG_PM_PROFILING
|
||||
int pm_dump_counter = 0;
|
||||
#endif
|
||||
while (true)
|
||||
{
|
||||
vTaskDelay(pdMS_TO_TICKS(100));
|
||||
|
||||
#if CONFIG_PM_PROFILING
|
||||
if (++pm_dump_counter >= 50)
|
||||
{ // Every 5 seconds
|
||||
pm_dump_counter = 0;
|
||||
ESP_LOGI(kTagMain, "--- PM Profiling Dump ---");
|
||||
|
||||
char *ptr = nullptr;
|
||||
size_t size = 0;
|
||||
FILE *f = open_memstream(&ptr, &size);
|
||||
if (f != nullptr)
|
||||
{
|
||||
esp_pm_dump_locks(f);
|
||||
fclose(f);
|
||||
if (ptr != nullptr)
|
||||
{
|
||||
char *saveptr;
|
||||
char *line = strtok_r(ptr, "\n", &saveptr);
|
||||
while (line != nullptr) {
|
||||
ESP_LOGI(kTagMain, "%s", line);
|
||||
line = strtok_r(nullptr, "\n", &saveptr);
|
||||
}
|
||||
free(ptr);
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
shutdown:
|
||||
printf("Shutting down.\n");
|
||||
ESP_LOGE(kTagMain, "Shutting down.");
|
||||
|
||||
if (web_server)
|
||||
{
|
||||
stop_webserver(web_server);
|
||||
stop_webserver(web_server, g_Active_WWW_Partition);
|
||||
web_server = NULL;
|
||||
}
|
||||
|
||||
|
||||
35
Provider/main/mdns_service.cpp
Normal file
35
Provider/main/mdns_service.cpp
Normal file
@@ -0,0 +1,35 @@
|
||||
#include "esp_log.h"
|
||||
#include "mdns.h"
|
||||
#include "sdkconfig.h"
|
||||
|
||||
#include "types.hpp"
|
||||
|
||||
internal const char *kTagMDNS = "MDNS";
|
||||
|
||||
void start_mdns()
|
||||
{
|
||||
// Initialize mDNS
|
||||
esp_err_t err = mdns_init();
|
||||
if (err != ESP_OK)
|
||||
{
|
||||
ESP_LOGE(kTagMDNS, "mDNS Init failed: %d", err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set mDNS hostname (from Kconfig)
|
||||
const char *hostname = CONFIG_CALENDINK_MDNS_HOSTNAME;
|
||||
mdns_hostname_set(hostname);
|
||||
ESP_LOGI(kTagMDNS, "mDNS Hostname set to: [%s.local]", hostname);
|
||||
|
||||
// Set mDNS instance name
|
||||
mdns_instance_name_set("Calendink Provider");
|
||||
|
||||
// Add HTTP service
|
||||
err = mdns_service_add(NULL, "_http", "_tcp", 80, NULL, 0);
|
||||
if (err != ESP_OK)
|
||||
{
|
||||
ESP_LOGE(kTagMDNS, "mDNS Service add failed: %d", err);
|
||||
}
|
||||
|
||||
ESP_LOGI(kTagMDNS, "mDNS Service initialized with hostname [%s.local]", hostname);
|
||||
}
|
||||
30
Provider/main/todo.hpp
Normal file
30
Provider/main/todo.hpp
Normal file
@@ -0,0 +1,30 @@
|
||||
#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 (1–65535, 0 = empty slot)
|
||||
uint8 user_id; // Owner (matches user_t.id)
|
||||
uint8 recurrence; // Bitmask: bit0=Mon, bit1=Tue, ..., bit6=Sun. 0=none
|
||||
uint8 period : 3; // Bitmask: bit0=Morning, bit1=Afternoon, bit2=Evening
|
||||
bool completed : 1; // Done flag
|
||||
bool active : 1; // Slot in use
|
||||
};
|
||||
|
||||
constexpr int MAX_TASKS = 32;
|
||||
internal task_t g_Tasks[MAX_TASKS] = {};
|
||||
internal uint16 g_NextTaskId = 1;
|
||||
71
Provider/main/udp_logger.cpp
Normal file
71
Provider/main/udp_logger.cpp
Normal file
@@ -0,0 +1,71 @@
|
||||
#include <string.h>
|
||||
|
||||
#include "esp_log.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "lwip/sockets.h"
|
||||
|
||||
#include "types.hpp"
|
||||
|
||||
internal constexpr char kTagUdpLogger[] = "UDP_LOG";
|
||||
|
||||
internal int s_udp_log_socket = -1;
|
||||
internal struct sockaddr_in s_udp_dest_addr;
|
||||
internal vprintf_like_t s_original_vprintf = NULL;
|
||||
|
||||
internal int udp_logging_vprintf(const char *fmt, va_list ap)
|
||||
{
|
||||
char buf[512];
|
||||
va_list ap_copy;
|
||||
va_copy(ap_copy, ap);
|
||||
int len = vsnprintf(buf, sizeof(buf) - 1, fmt, ap_copy);
|
||||
va_end(ap_copy);
|
||||
|
||||
if (len > 0)
|
||||
{
|
||||
if (len >= sizeof(buf))
|
||||
{
|
||||
len = sizeof(buf) - 1;
|
||||
}
|
||||
buf[len] = '\0';
|
||||
if (s_udp_log_socket >= 0)
|
||||
{
|
||||
sendto(s_udp_log_socket, buf, len, 0, (struct sockaddr *)&s_udp_dest_addr,
|
||||
sizeof(s_udp_dest_addr));
|
||||
}
|
||||
}
|
||||
return s_original_vprintf(fmt, ap);
|
||||
}
|
||||
|
||||
internal void start_udp_logging(int port)
|
||||
{
|
||||
s_udp_log_socket = socket(AF_INET, SOCK_DGRAM, IPPROTO_IP);
|
||||
if (s_udp_log_socket < 0)
|
||||
{
|
||||
ESP_LOGE(kTagUdpLogger, "Unable to create socket: errno %d", errno);
|
||||
return;
|
||||
}
|
||||
|
||||
int opt_val = 1;
|
||||
setsockopt(s_udp_log_socket, SOL_SOCKET, SO_BROADCAST, &opt_val,
|
||||
sizeof(opt_val));
|
||||
|
||||
s_udp_dest_addr.sin_family = AF_INET;
|
||||
s_udp_dest_addr.sin_port = htons(port);
|
||||
|
||||
#ifdef CONFIG_CALENDINK_UDP_LOG_TARGET_IP
|
||||
if (strlen(CONFIG_CALENDINK_UDP_LOG_TARGET_IP) > 1)
|
||||
{
|
||||
s_udp_dest_addr.sin_addr.s_addr =
|
||||
inet_addr(CONFIG_CALENDINK_UDP_LOG_TARGET_IP);
|
||||
}
|
||||
else
|
||||
{
|
||||
s_udp_dest_addr.sin_addr.s_addr = inet_addr("255.255.255.255");
|
||||
}
|
||||
#else
|
||||
s_udp_dest_addr.sin_addr.s_addr = inet_addr("255.255.255.255");
|
||||
#endif
|
||||
|
||||
s_original_vprintf = esp_log_set_vprintf(&udp_logging_vprintf);
|
||||
ESP_LOGI(kTagUdpLogger, "UDP logging broadcast started on port %d", port);
|
||||
}
|
||||
16
Provider/main/user.hpp
Normal file
16
Provider/main/user.hpp
Normal file
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "types.hpp"
|
||||
|
||||
struct user_t
|
||||
{
|
||||
uint8 id; // Auto-assigned (1–255, 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;
|
||||
8
Provider/main/utils.hpp
Normal file
8
Provider/main/utils.hpp
Normal file
@@ -0,0 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
#include <types.hpp>
|
||||
|
||||
template <typename T, size_t N> constexpr size_t ArrayCount(T (&)[N])
|
||||
{
|
||||
return N;
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
# Name, Type, SubType, Offset, Size, Flags
|
||||
nvs, data, nvs, 0x9000, 0x6000,
|
||||
phy_init, data, phy, 0xf000, 0x1000,
|
||||
factory, app, factory, 0x10000, 1M,
|
||||
otadata, data, ota, , 0x2000,
|
||||
phy_init, data, phy, , 0x1000,
|
||||
factory, app, factory, , 2M,
|
||||
ota_0, app, ota_0, , 2M,
|
||||
ota_1, app, ota_1, , 2M,
|
||||
www_0, data, littlefs, , 1M,
|
||||
www_1, data, littlefs, , 1M,
|
||||
|
||||
|
@@ -1,3 +1,98 @@
|
||||
CONFIG_IDF_TARGET="esp32s3"
|
||||
CONFIG_PARTITION_TABLE_CUSTOM=y
|
||||
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
|
||||
CONFIG_PARTITION_TABLE_FILENAME="partitions.csv"
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
|
||||
CONFIG_LWIP_MAX_SOCKETS=32
|
||||
CONFIG_PM_ENABLE=y
|
||||
CONFIG_FREERTOS_USE_TICKLESS_IDLE=y
|
||||
|
||||
# W5500 Ethernet Configuration
|
||||
CONFIG_ETH_USE_SPI_ETHERNET=y
|
||||
CONFIG_ETH_SPI_ETHERNET_W5500=y
|
||||
CONFIG_ETHERNET_SPI_USE_W5500=1
|
||||
CONFIG_ETHERNET_SPI_DEV0_W5500=y
|
||||
CONFIG_ETHERNET_SPI_DEV0_ID=2
|
||||
CONFIG_ETHERNET_SPI_HOST=1
|
||||
CONFIG_ETHERNET_SPI_SCLK_GPIO=13
|
||||
CONFIG_ETHERNET_SPI_MOSI_GPIO=11
|
||||
CONFIG_ETHERNET_SPI_MISO_GPIO=12
|
||||
CONFIG_ETHERNET_SPI_CLOCK_MHZ=32
|
||||
CONFIG_ETHERNET_SPI_CS0_GPIO=14
|
||||
CONFIG_ETHERNET_SPI_INT0_GPIO=10
|
||||
CONFIG_ETHERNET_SPI_PHY_RST0_GPIO=9
|
||||
CONFIG_ETHERNET_SPI_PHY_ADDR0=1
|
||||
CONFIG_ETHERNET_SPI_AUTOCONFIG_MAC_ADDR0=y
|
||||
CONFIG_ETHERNET_SPI_POLLING0_MS=0
|
||||
|
||||
# Enable PSRAM
|
||||
CONFIG_SPIRAM=y
|
||||
CONFIG_ESP32S3_SPIRAM_SUPPORT=y
|
||||
CONFIG_SPIRAM_MODE_OCT=y
|
||||
CONFIG_SPIRAM_SPEED_80M=y
|
||||
CONFIG_SPIRAM_FETCH_INSTRUCTIONS=y
|
||||
CONFIG_SPIRAM_RODATA=y
|
||||
|
||||
# LVGL Configuration
|
||||
CONFIG_LV_COLOR_DEPTH_8=y
|
||||
CONFIG_LV_USE_SYSMON=n
|
||||
|
||||
# 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=n
|
||||
CONFIG_LV_DRAW_SW_SUPPORT_RGB565_SWAPPED=n
|
||||
CONFIG_LV_DRAW_SW_SUPPORT_RGB565A8=n
|
||||
CONFIG_LV_DRAW_SW_SUPPORT_RGB888=n
|
||||
CONFIG_LV_DRAW_SW_SUPPORT_XRGB8888=n
|
||||
CONFIG_LV_DRAW_SW_SUPPORT_ARGB8888=n
|
||||
CONFIG_LV_DRAW_SW_SUPPORT_ARGB8888_PREMULTIPLIED=n
|
||||
CONFIG_LV_DRAW_SW_SUPPORT_L8=y
|
||||
CONFIG_LV_DRAW_SW_SUPPORT_AL88=n
|
||||
CONFIG_LV_DRAW_SW_SUPPORT_A8=y
|
||||
CONFIG_LV_DRAW_SW_SUPPORT_I1=n
|
||||
|
||||
# Disable complex drawing features to save memory (no shadows, no complex gradients)
|
||||
CONFIG_LV_DRAW_SW_COMPLEX=n
|
||||
|
||||
# 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=n
|
||||
CONFIG_LV_USE_DROPDOWN=n
|
||||
CONFIG_LV_USE_IMAGEBUTTON=n
|
||||
CONFIG_LV_USE_ROLLER=n
|
||||
CONFIG_LV_USE_SCALE=n
|
||||
CONFIG_LV_USE_SLIDER=n
|
||||
CONFIG_LV_USE_SWITCH=n
|
||||
CONFIG_LV_USE_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
|
||||
|
||||
51
Provider/tdd/concurrent_requests.md
Normal file
51
Provider/tdd/concurrent_requests.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Concurrent Requests Support for ESP32-S3 Provider
|
||||
|
||||
**Authored by Antigravity**
|
||||
**Date:** 2026-03-08
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal (What)
|
||||
|
||||
Enable the ESP32-S3 HTTP server to gracefully handle multiple concurrent web clients. Currently, if one browser connects, it consumes all available server sockets with "keep-alive" connections and blocks the `static_file_handler` via a single shared scratch buffer. The goal is to allow up to 5-10 clients (e.g., PCs, tablets, phones) on the local network to open the dashboard simultaneously without hanging, and to add a frontend safeguard (a loading spinner) to improve the user experience during slow network responses.
|
||||
|
||||
## 2. Rationale (Why)
|
||||
|
||||
The default ESP-IDF HTTP server (`esp_http_server`) is configured for minimal resource usage:
|
||||
- `max_open_sockets = 7`: A single modern browser tries to open up to 6 connections simultaneously to fetch HTML, CSS, JS, and API data.
|
||||
- `lru_purge_enable = false`: When a browser finishes loading, it keeps those 6 sockets open (Keep-Alive) for future requests. If a second device tries to connect, it is rejected because the server has no free sockets, even though the first device's sockets are idle.
|
||||
|
||||
Furthermore, the current `static_file_handler` relies on a single shared `rest_context->scratch` buffer allocated globally. If the server is modified to multiplex handlers concurrently, this shared buffer would be overwritten by competing requests, causing data corruption in the served files.
|
||||
|
||||
## 3. Chosen Approach (How)
|
||||
|
||||
### 3.1 Backend Configuration (ESP-IDF)
|
||||
Instead of implementing complex multi-threading (spawning multiple FreeRTOS worker tasks), we will leverage the HTTP server's built-in event loop multiplexing by tuning its configuration:
|
||||
1. **Increase LwIP Socket Limit**: `LWIP_MAX_SOCKETS` is set to `32` in `sdkconfig.defaults`.
|
||||
2. **Increase HTTP Socket Limit**: Set `config.max_open_sockets = 24`. This deliberately reserves `8` sockets for LwIP internals and outwards connections, guaranteeing the network stack always has headroom to accept a TCP handshake from a new client.
|
||||
3. **Enable Stale Socket Purging**: Set `config.lru_purge_enable = true`. This is the critical fix. When the 24 socket limit is reached and a new device attempts to connect, the server will intentionally drop the oldest idle keep-alive socket to make room, allowing the new device to load the page seamlessly.
|
||||
|
||||
### 3.2 Backend Scratch Buffer Pooling
|
||||
To safely support multiplexed file serving without heavy `malloc`/`free` overhead on every request, we will replace the single shared scratch buffer with a **Static Shared Buffer Pool**:
|
||||
- We allocated a global struct with a fixed array of `MAX_SCRATCH_BUFFERS = 10`.
|
||||
- When `static_file_handler` begins, it will request an available chunk from the pool, allocating a 4KB chunk on the heap only the first time it is used.
|
||||
- When the handler finishes, the chunk is marked as available yielding it for the next request.
|
||||
- This provides isolation between up to 10 active transmission connections while minimizing heap fragmentation compared to per-request `mallocs`.
|
||||
|
||||
### 3.3 Frontend Safety (Loading Spinner)
|
||||
Even with backend improvements, network latency or heavy load might cause delays. We will implement a global request tracker to improve perceived performance:
|
||||
- A new Svelte writable store `pendingRequests` will track the count of active API calls.
|
||||
- `api.js` will wrap the native `fetch` in a `trackedFetch` function that increments/decrements this store.
|
||||
- A new `<Spinner />` component will be rendered at the root (`App.svelte`). It will overlay the screen when `$pendingRequests > 0`, optionally with a small delay (e.g., 300ms) to prevent flashing on fast requests.
|
||||
|
||||
## 4. Design Decisions & Trade-offs
|
||||
|
||||
| Approach | Pros | Cons | Decision |
|
||||
|---|---|---|---|
|
||||
| **True Multi-Threading (Multiple Worker Tasks)** | Can process files fully in parallel on both cores. | High memory overhead for stack space per task; over-engineered for simple static file serving. | **Rejected**. Relying on the event loop's multiplexing is sufficient for local network use cases. |
|
||||
| **Per-Request `malloc` / `free`** | Simplest way to isolate scratch buffers. | High heap fragmentation risk; computationally expensive on every HTTP request. | **Rejected**. |
|
||||
| **Fixed Pool (10 buffers)** | Low overhead; memory footprint only grows organically to the maximum concurrent need limit (10 * 4KB = 40KB) and stabilizes. | Strict limit on how many connections can be actively transmitting data at the exact same millisecond. | **Selected**. Best balance of performance and memory safety. |
|
||||
|
||||
## 5. Potential Future Improvements
|
||||
- If the `realloc` pool grows too large during an unexpected spike, we could implement a cleanup routine that periodically shrinks the pool back to a baseline size when the server is idle.
|
||||
- If true parallel processing is needed later, the HTTP server's `config.core_id` and `async_workers` could be utilized, but this requires ensuring all API handlers are perfectly thread-safe.
|
||||
59
Provider/tdd/device_screens.md
Normal file
59
Provider/tdd/device_screens.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# 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.
|
||||
|
||||
### 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`.
|
||||
- **Integration:** The `App.svelte` router will be updated to include the `'devices'` view state alongside Dashboard, Tasks, and Users.
|
||||
79
Provider/tdd/firmware_ota.md
Normal file
79
Provider/tdd/firmware_ota.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Firmware OTA Strategy for ESP32-S3 Provider
|
||||
|
||||
**Authored by Antigravity**
|
||||
**Date:** 2026-03-03
|
||||
|
||||
---
|
||||
|
||||
## 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:
|
||||
- Update the core application logic and the user interface 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 permanent "factory" fallback as an extreme safety measure.
|
||||
- Prevent accidental cross-flashing (e.g., flashing UI to firmware slots).
|
||||
- Maintain a clear versioning scheme visible to the user, with accurate partition space reporting.
|
||||
|
||||
## 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.
|
||||
|
||||
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 `.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.1. Why Dual-Partition (A/B) with Factory?
|
||||
- **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.
|
||||
- **Frontend Sync**: The frontend also uses a dual-partition layout (`www_0`, `www_1`). The Universal Bundle ensures both FW and UI switch together.
|
||||
|
||||
### 3.2. Automatic App Rollback
|
||||
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.
|
||||
- **Validation Point**: We consider the firmware "valid" only after it successfully establishes a network connection.
|
||||
|
||||
### 3.3. Universal Bundle Format & Automation
|
||||
- **Format**: A custom 12-byte header (`BNDL` magic + 4-byte FW size + 4-byte UI size) followed by the FW binary and UI binary.
|
||||
- **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.
|
||||
|
||||
### 3.4. Safety & Validation
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
### 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.1. The Partition Table
|
||||
```csv
|
||||
# Name, Type, SubType, Offset, Size
|
||||
nvs, data, nvs, 0x9000, 0x6000
|
||||
otadata, data, ota, , 0x2000
|
||||
phy_init, data, phy, , 0x1000
|
||||
factory, app, factory, , 2M
|
||||
ota_0, app, ota_0, , 2M
|
||||
ota_1, app, ota_1, , 2M
|
||||
www_0, data, littlefs, , 1M
|
||||
www_1, data, littlefs, , 1M
|
||||
```
|
||||
|
||||
### 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.
|
||||
- `firmware.cpp` & `frontend.cpp`: Handles individual component updates.
|
||||
- `status.cpp`: Uses `esp_partition_find` and `esp_image_get_metadata` to report partition sizes and active slots.
|
||||
- `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
|
||||
- The Svelte Dashboard features a comprehensive "Update System" component supporting individual (FW/UI) and combined (Bundle) uploads.
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
*Created by Antigravity - Last Updated: 2026-03-03*
|
||||
75
Provider/tdd/lvgl_image_generation.md
Normal file
75
Provider/tdd/lvgl_image_generation.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# LVGL Image Generation Architecture
|
||||
|
||||
**Authored by Antigravity**
|
||||
**Date:** 2026-03-14
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Integrate the Light and Versatile Graphics Library (LVGL) into the Calendink Provider to generate UI images server-side. The ESP32-S3 will render screens into a memory buffer and serve the resulting image (PNG) directly over the HTTP REST API to clients.
|
||||
|
||||
This allows the Provider to act as a "smart renderer" for dump clients like e-ink displays or web widgets that only have the capability to fetch and display static images. The display resolution (default 800x480) and color depth (4-level grayscale) are parameterized via Kconfig to support future hardware changes.
|
||||
|
||||
## 2. Chosen Approach
|
||||
|
||||
### 2.1. Headless LVGL Display Driver
|
||||
LVGL is typically used to drive physical displays via SPI/I2C/RGB interfaces. For this use case, we will configure a **virtual (headless) display**:
|
||||
- A display instance (`lv_display_t`) will be created without a physical flush callback (or a dummy one that does nothing).
|
||||
- We will allocate a full-screen frame buffer (e.g., 800x480 at 8-bit grayscale). While small enough to fit in SRAM (~384 KB), we will use **PSRAM** to avoid memory pressure on the system.
|
||||
- When an image is requested, we will force LVGL to update the screen (`lv_refr_now()`) and then read the raw pixel data directly from the draw buffer.
|
||||
|
||||
### 2.2. Image Encoding (PNG)
|
||||
Raw pixel data is not web-friendly. To serve the image over HTTP, we will encode it as a **PNG** image.
|
||||
- **Why PNG?** PNG is smaller over the network than BMP and natively supports lossless grayscale compression. Given the target of 4-level grayscale, a PNG will compress exceptionally well.
|
||||
- **Encoder:** We will use `lodepng`, a lightweight single-file C library, to compress the raw LVGL buffer into a PNG on the fly.
|
||||
- **Color Depth:** The target is **4-level grayscale**. We will map LVGL's output to a 2-bit grayscale PNG to minimize the payload size.
|
||||
- **Parameterization:** Resolution (e.g., 800 width, 480 height) is configurable via Kconfig (`CONFIG_DISPLAY_WIDTH`, `CONFIG_DISPLAY_HEIGHT`) so it can be easily changed for different e-ink displays.
|
||||
|
||||
### 2.3. Thread Safety and Concurrency
|
||||
LVGL is **not thread-safe**. Since the HTTP server runs its API handlers in different FreeRTOS tasks (from the `httpd` thread pool), we must protect LVGL with a **Mutex**.
|
||||
- All LVGL initialization, UI setup (`lv_obj_create`, etc.), and the periodic `lv_timer_handler()` will take the `g_LvglMutex`.
|
||||
- The `GET /api/display/image` API endpoint will acquire `g_LvglMutex`, draw the screen, encode the BMP header, transmit the buffer, and then release the mutex.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
### 3.1. File Structure
|
||||
|
||||
```
|
||||
Provider/main/
|
||||
├── lv_setup.hpp # LVGL initialization and mutex declarations
|
||||
├── lv_setup.cpp # Driver setup, PSRAM buffer allocation, LVGL tick task
|
||||
├── api/display/
|
||||
│ ├── image.cpp # GET /api/display/image handler (BMP streamer)
|
||||
│ └── unity.cpp # Aggregator for display endpoints
|
||||
├── http_server.cpp # Modified to include api/display/unity.cpp
|
||||
└── main.cpp # Starts LVGL task before HTTP server
|
||||
```
|
||||
|
||||
### 3.2. Data Flow
|
||||
|
||||
1. **Client** makes a `GET /api/display/image` request.
|
||||
2. **`image.cpp` handler** intercepts the request and takes the `g_LvglMutex`.
|
||||
3. The handler forces LVGL to finish any pending rendering.
|
||||
4. The handler uses `lodepng` to compress the raw frame buffer into a PNG payload.
|
||||
5. The handler sends the PNG via `httpd_resp_send()`.
|
||||
6. The handler frees the PNG payload buffer and releases `g_LvglMutex`.
|
||||
7. The HTTP response completes.
|
||||
|
||||
## 4. Design Decisions & Trade-offs
|
||||
|
||||
| Decision | Trade-off | Rationale |
|
||||
|---|---|---|
|
||||
| **PSRAM Allocation** | Slower access than SRAM | A full framebuffer for high-res displays exceeds internal SRAM. PSRAM is required, and since we only read/write it occasionally for HTTP, the speed penalty is negligible compared to network latency. |
|
||||
| **PNG over BMP** | More CPU overhead | PNG compression takes CPU cycles and temporary RAM, but significantly reduces network payload, which is better for web clients and saves transmission time. |
|
||||
| **Pull vs Push** | Requires polling | The client must actively request the image. This is standard for web, but means the client won't know instantly if the UI state changes unless using WebSockets/SSE (future scope). |
|
||||
| **Synchronous Render** | Blocks HTTP task | The HTTP handler waits for LVGL to finish drawing and PNG encoding to complete. Since LVGL renders very quickly in memory, this block should be acceptable. |
|
||||
|
||||
## 5. Next Steps
|
||||
1. Add `lvgl/lvgl` to `idf_component.yml`.
|
||||
2. Configure LVGL in `sdkconfig` to use custom memory allocation (routed to PSRAM).
|
||||
3. Implement `lv_setup.cpp` and `api/display/image.cpp`.
|
||||
4. Draw a sample UI on the virtual display.
|
||||
|
||||
---
|
||||
*Created by Antigravity - Last Updated: 2026-03-14*
|
||||
195
Provider/tdd/todo_list.md
Normal file
195
Provider/tdd/todo_list.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# 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*
|
||||
Reference in New Issue
Block a user