Compare commits

...

17 Commits

Author SHA1 Message Date
46dfe82568 TDD for device and screen management. Authored by gemini 3.1 pro 2026-03-15 10:52:43 -04:00
f0297418cf sdkconfig.defaults modified to remove some lvgl modules that are active by default. Also moved the ethernet config to default as it keeps on being deleted 2026-03-14 21:24:12 -04:00
238b408947 optimize lvgl setup by turning of some values 2026-03-14 18:50:42 -04:00
6384e93020 Added lvgl support to generate images. Made basic example, grayscale background + text displayed everytime we call /api/display/image.png 2026-03-14 18:41:00 -04:00
a9d5aa83dc added udp logger to log over network and not uart
Added modem sleep for wif
2026-03-14 17:27:24 -04:00
b702839f8e Asked claude opus to make an audit of code and use coding guidelines etc. This is the fix 2026-03-09 22:26:16 -04:00
75d88f441c coding guidelines, agents and gemini.md 2026-03-09 22:07:48 -04:00
9d3a277f45 Updated the task view and backend to handle more like a routine 2026-03-08 22:10:46 -04:00
4161ff9513 Fix spinner appearing every 5 secds 2026-03-08 18:23:20 -04:00
54c56002ee removing printf in mdns service that was useless 2026-03-08 18:19:38 -04:00
38201280ea adding mdns so we dont rely on ip to connect 2026-03-08 18:16:50 -04:00
c034999d20 multiple-connection-handling (#3)
Reviewed-on: #3
2026-03-08 18:05:34 -04:00
56acf92f75 Added a workflow to let the agent automatically deploy frontend onto the esp32. 2026-03-08 15:19:17 -04:00
9388bf17af Remade side bar to work better on phone and tablet 2026-03-08 14:40:29 -04:00
72e031a99d removed the blinking light after connection as it was taking a lot of iteration time.
Made the sidebar icon use the famous burger
2026-03-07 23:55:43 -05:00
ac95358561 Separating users from tasks + fixing weird ota bug 2026-03-07 23:41:26 -05:00
3fa879d007 Fix wifi crashing because stack overflow and fix esp_ota cancelling when starting from factory 2026-03-07 22:49:41 -05:00
56 changed files with 12055 additions and 1005 deletions

3
.gitignore vendored
View File

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

View 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

View File

@@ -10,3 +10,4 @@ Pleaes add the date.
The TDD should starts with the What, then the Why and finally the how. 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. 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 implementation is finished, the user can add to edit the TDD to add more informations (implementation details that are important, benchmarks, any change of plan during development)
When you add a tdd, please update Gemini.md to add the tdd.

View File

@@ -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
View 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.

View File

@@ -86,3 +86,19 @@ Once the backend supports it (Phase 2+), you can update the frontend without usi
For a safer and more convenient update experience, you can bundle both the Firmware and Frontend into a single file. 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. See the [Universal OTA Bundle Guide](build_bundle.md) for details.
## 7. AI Agent Workflow
If you are an AI agent, you should use the automated deployment scripts to speed up your work.
### Deployment Prerequisites
- Ensure `VITE_API_BASE` in `frontend/.env` is set to the target device IP.
- Ensure `MKLITTLEFS_PATH` is correctly set if you are on Windows.
### Standard OTA Workflow
Follow the [Frontend OTA Workflow](file:///w:/Classified/Calendink/Provider/.agents/workflows/frontend-ota.md) for automated building and deployment.
**Summary of commands (run in `frontend/`):**
1. `npm run build:esp32` - Build production assets.
2. `npm run ota:package` - Create LittleFS image.
3. `npm run ota:deploy` - Upload to device via HTTP.

View File

@@ -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
View 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)]"
```

View File

@@ -121,6 +121,16 @@ dependencies:
registry_url: https://components.espressif.com/ registry_url: https://components.espressif.com/
type: service type: service
version: 3.0.3 version: 3.0.3
espressif/mdns:
component_hash: 7c0fa01a1cd0e72a87ec1928c3b661c0a3a9034a6d3a69dcf4850db8c6f272db
dependencies:
- name: idf
require: private
version: '>=5.0'
source:
registry_url: https://components.espressif.com/
type: service
version: 1.10.1
idf: idf:
source: source:
type: idf type: idf
@@ -135,11 +145,20 @@ dependencies:
registry_url: https://components.espressif.com/ registry_url: https://components.espressif.com/
type: service type: service
version: 1.20.4 version: 1.20.4
lvgl/lvgl:
component_hash: 184e532558c1c45fefed631f3e235423d22582aafb4630f3e8885c35281a49ae
dependencies: []
source:
registry_url: https://components.espressif.com/
type: service
version: 9.5.0
direct_dependencies: direct_dependencies:
- espressif/ethernet_init - espressif/ethernet_init
- espressif/led_strip - espressif/led_strip
- espressif/mdns
- idf - idf
- joltwallet/littlefs - joltwallet/littlefs
manifest_hash: 21816aafdbbde14bfaaaabda34966eec49ae1e6f551bc16fe3ff74370b0fb54c - lvgl/lvgl
manifest_hash: 96112412d371d78cc527b7d0904042e5a7ca7c4f25928de9483a1b53dd2a2f4e
target: esp32s3 target: esp32s3
version: 2.0.0 version: 2.0.0

View File

@@ -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 MKLITTLEFS_PATH=W:\Classified\Calendink\Provider\build\littlefs_py_venv\Scripts\littlefs-python.exe

View File

@@ -9,6 +9,8 @@
"build:esp32": "vite build && node scripts/gzip.js", "build:esp32": "vite build && node scripts/gzip.js",
"ota:package": "node scripts/package.js", "ota:package": "node scripts/package.js",
"ota:bundle": "npm run ota:package && node scripts/bundle.js", "ota:bundle": "npm run ota:package && node scripts/bundle.js",
"ota:deploy": "node scripts/deploy.js --frontend",
"ota:deploy-bundle": "node scripts/deploy.js --bundle",
"preview": "vite preview" "preview": "vite preview"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -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();

View File

@@ -1,8 +1,11 @@
<script> <script>
import { getSystemInfo, reboot, getOTAStatus, getUpcomingTasks } 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 OTAUpdate from "./lib/OTAUpdate.svelte";
import Sidebar from "./lib/Sidebar.svelte"; import Sidebar from "./lib/Sidebar.svelte";
import TaskManager from "./lib/TaskManager.svelte"; import TaskManager from "./lib/TaskManager.svelte";
import UserManager from "./lib/UserManager.svelte";
import Spinner from "./lib/Spinner.svelte";
/** @type {'loading' | 'ok' | 'error' | 'rebooting'} */ /** @type {'loading' | 'ok' | 'error' | 'rebooting'} */
let status = $state("loading"); let status = $state("loading");
@@ -10,8 +13,9 @@
let showRebootConfirm = $state(false); let showRebootConfirm = $state(false);
let isRecovering = $state(false); let isRecovering = $state(false);
/** @type {'dashboard' | 'tasks'} */ /** @type {'dashboard' | 'tasks' | 'users'} */
let currentView = $state("dashboard"); let currentView = $state("dashboard");
let mobileMenuOpen = $state(false);
let systemInfo = $state({ let systemInfo = $state({
chip: "—", chip: "—",
@@ -29,55 +33,29 @@
let upcomingData = $state({ users: [] }); let upcomingData = $state({ users: [] });
/** Format uptime seconds into human-readable string */ let isFetching = false; // mutex, not reactive
function formatUptime(seconds) { let lastKnownFirmware = null;
const d = Math.floor(seconds / 86400); let lastKnownSlot = null;
const h = Math.floor((seconds % 86400) / 3600); async function fetchAll(silent = false) {
const m = Math.floor((seconds % 3600) / 60); if (isFetching) return;
const s = seconds % 60; isFetching = true;
const parts = [];
if (d > 0) parts.push(`${d}d`);
if (h > 0) parts.push(`${h}h`);
if (m > 0) parts.push(`${m}m`);
parts.push(`${s}s`);
return parts.join(" ");
}
/** Format bytes into human-readable string */
function formatBytes(bytes) {
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
}
function formatRelativeDate(timestamp) {
const now = Date.now() / 1000;
const diff = timestamp - now;
const absDiff = Math.abs(diff);
if (absDiff < 3600) {
const mins = Math.round(absDiff / 60);
return diff < 0 ? `${mins}m ago` : `in ${mins}m`;
}
if (absDiff < 86400) {
const hours = Math.round(absDiff / 3600);
return diff < 0 ? `${hours}h ago` : `in ${hours}h`;
}
const days = Math.round(absDiff / 86400);
return diff < 0 ? `${days}d ago` : `in ${days}d`;
}
function isOverdue(timestamp) {
return timestamp < Date.now() / 1000;
}
async function fetchAll() {
try { try {
if (!silent) status = "loading";
const [sys, ota, upcoming] = await Promise.all([ const [sys, ota, upcoming] = await Promise.all([
getSystemInfo(), getSystemInfo(),
getOTAStatus(), getOTAStatus(),
getUpcomingTasks().catch(() => ({ users: [] })) 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; systemInfo = sys;
otaStatus = ota; otaStatus = ota;
upcomingData = upcoming; upcomingData = upcoming;
@@ -88,6 +66,8 @@
status = "error"; status = "error";
errorMsg = e.message || "Connection failed"; errorMsg = e.message || "Connection failed";
} }
} finally {
isFetching = false;
} }
} }
@@ -104,8 +84,8 @@
$effect(() => { $effect(() => {
fetchAll(); fetchAll();
// Poll for status updates every 5 seconds // Poll for status updates every 5 seconds (silently to avoid flashing)
const interval = setInterval(fetchAll, 5000); const interval = setInterval(() => fetchAll(true), 5000);
return () => clearInterval(interval); return () => clearInterval(interval);
}); });
@@ -137,14 +117,41 @@
]); ]);
</script> </script>
<div class="app-layout"> <div class="flex min-h-screen bg-bg-primary">
<Sidebar {currentView} onNavigate={(view) => currentView = view} /> <Sidebar
{currentView}
isOpen={mobileMenuOpen}
onNavigate={(view) => { currentView = view; mobileMenuOpen = false; }}
onToggle={() => mobileMenuOpen = !mobileMenuOpen}
/>
{#if mobileMenuOpen}
<button
class="fixed inset-0 bg-black/40 backdrop-blur z-[999] border-none p-0 cursor-pointer"
onclick={() => mobileMenuOpen = false}
aria-label="Close menu"
></button>
{/if}
<main class="main-gradient-bg flex-1 p-8 h-screen overflow-y-auto max-md:p-4 max-md:h-auto">
<!-- Mobile Top Bar -->
<header class="hidden max-md:flex items-center justify-between px-4 py-3 bg-bg-card border-b border-border sticky top-0 z-50 backdrop-blur-[10px] max-md:-mx-4 max-md:-mt-4 max-md:mb-5">
<!-- svelte-ignore a11y_consider_explicit_label -->
<button class="bg-transparent border-none text-text-primary p-2 cursor-pointer" onclick={() => mobileMenuOpen = true}>
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
<span class="font-bold text-base tracking-[-0.02em] text-accent">Calendink</span>
<div class="w-10"></div> <!-- Spacer -->
</header>
<main class="main-content">
<div class="w-full max-w-6xl mx-auto space-y-8"> <div class="w-full max-w-6xl mx-auto space-y-8">
<!-- Header --> <!-- Header -->
<div class="text-center"> <div class="text-center">
<h1 class="text-2xl font-bold text-accent">Calendink Provider 🚀👑🥸</h1> <h1 class="text-2xl font-bold text-accent">Calendink Provider 📅⚡✨🌈</h1>
<p class="text-text-secondary text-sm">ESP32-S3 System Dashboard v{__APP_VERSION__}</p> <p class="text-text-secondary text-sm">ESP32-S3 System Dashboard v{__APP_VERSION__}</p>
<!-- Status Badge --> <!-- Status Badge -->
@@ -178,29 +185,46 @@
<!-- Upcoming Tasks Section (top priority) --> <!-- Upcoming Tasks Section (top priority) -->
{#if upcomingData.users.length > 0} {#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="bg-bg-card border border-border rounded-xl overflow-hidden shadow-xl">
<div class="px-5 py-3 border-b border-border"> <div class="px-5 py-3 border-b border-border">
<h2 class="text-sm font-semibold text-text-primary uppercase tracking-wider"> <h2 class="text-sm font-semibold text-text-primary uppercase tracking-wider">
📋 Upcoming Tasks 📋 Today's Routine
</h2> </h2>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-0 divide-y md:divide-y-0 md:divide-x divide-border"> <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} {#each upcomingData.users as user}
{@const routineTasks = user.tasks.filter(t => t.recurrence > 0)}
<div class="p-4"> <div class="p-4">
<h3 class="text-xs font-bold text-accent mb-3 uppercase tracking-wider">{user.name}</h3> <h3 class="text-xs font-bold text-accent mb-3 uppercase tracking-wider">{user.name}</h3>
{#if user.tasks.length === 0} {#if routineTasks.length === 0}
<p class="text-[11px] text-text-secondary italic">No pending tasks</p> <p class="text-[11px] text-text-secondary italic">No routine tasks</p>
{:else} {:else}
<div class="space-y-2"> {#each [0, 1, 2] as periodIdx}
{#each user.tasks as task} {@const tasksForPeriod = routineTasks.filter(t => t.period & (1 << periodIdx))}
<div class="flex items-start gap-2"> {#if tasksForPeriod.length > 0}
<span class="text-[10px] mt-0.5 {isOverdue(task.due_date) ? 'text-danger' : 'text-text-secondary'} font-mono whitespace-nowrap"> <div class="mb-2">
{formatRelativeDate(task.due_date)} <div class="text-[10px] uppercase tracking-wider text-text-secondary font-semibold mb-1">
</span> {periodIcons[periodIdx]} {periodNames[periodIdx]}
<span class="text-xs text-text-primary leading-tight">{task.title}</span> </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> </div>
{/each} {/if}
</div> {/each}
{/if} {/if}
</div> </div>
{/each} {/each}
@@ -313,7 +337,7 @@
</div> </div>
<!-- Updates & Maintenance Card --> <!-- Updates & Maintenance Card -->
<OTAUpdate onReboot={() => { status = "rebooting"; isRecovering = true; }} /> <OTAUpdate otaInfo={otaStatus} {systemInfo} onReboot={() => { status = "rebooting"; isRecovering = true; }} />
</div> </div>
</div> </div>
@@ -323,6 +347,11 @@
<div class="bg-bg-card border border-border rounded-xl p-6 shadow-xl"> <div class="bg-bg-card border border-border rounded-xl p-6 shadow-xl">
<TaskManager /> <TaskManager />
</div> </div>
{:else if currentView === 'users'}
<!-- User Management View -->
<div class="bg-bg-card border border-border rounded-xl p-8 shadow-xl">
<UserManager mode="manager" />
</div>
{/if} {/if}
<!-- Reboot Confirmation Modal --> <!-- Reboot Confirmation Modal -->
@@ -355,22 +384,4 @@
</main> </main>
</div> </div>
<style> <Spinner />
.app-layout {
display: flex;
min-height: 100vh;
background: var(--color-bg-primary);
}
.main-content {
flex: 1;
padding: 16px 32px;
overflow-y: auto;
}
@media (max-width: 768px) {
.main-content {
padding: 16px;
}
}
</style>

View File

@@ -17,3 +17,28 @@
--color-danger: #ef4444; --color-danger: #ef4444;
--color-danger-hover: #f87171; --color-danger-hover: #f87171;
} }
/*
* Global utility: main content radial gradient background.
* Can't be expressed as a Tailwind arbitrary value due to multiple background layers.
*/
.main-gradient-bg {
background: radial-gradient(circle at top right, var(--color-bg-card), transparent 40%),
var(--color-bg-primary);
}
/*
* Global utility: makes the native date picker icon cover the full input area.
* This is a vendor pseudo-element — cannot be done with Tailwind utilities.
*/
.date-input::-webkit-calendar-picker-indicator {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: auto;
height: auto;
opacity: 0;
cursor: pointer;
}

View File

@@ -1,47 +1,26 @@
<script> <script>
let { onReboot = null } = $props(); import { uploadOTAFrontend, uploadOTAFirmware, uploadOTABundle } from "./api.js";
import { getOTAStatus, uploadOTAFrontend, uploadOTAFirmware, uploadOTABundle, getSystemInfo } from "./api.js";
// otaInfo and systemInfo are passed from App.svelte (already fetched there every 5s)
let {
onReboot = null,
otaInfo = { active_slot: -1, active_partition: "—", target_partition: "—", partitions: [], running_firmware_label: "—" },
systemInfo = { firmware: "—" }
} = $props();
const IS_DEV = import.meta.env.DEV; const IS_DEV = import.meta.env.DEV;
/** @type {'idle' | 'loading_status' | 'uploading' | 'success' | 'error'} */ /** @type {'idle' | 'uploading' | 'success' | 'error'} */
let status = $state("idle"); let status = $state("idle");
let errorMsg = $state(""); let errorMsg = $state("");
let uploadProgress = $state(0); let uploadProgress = $state(0);
let otaInfo = $state({
active_slot: -1,
active_partition: "—",
target_partition: "—",
partitions: [],
running_firmware_label: "—"
});
let systemInfo = $state({
firmware: "—"
});
let selectedFile = $state(null); let selectedFile = $state(null);
let showAdvanced = $state(false); let showAdvanced = $state(false);
/** @type {'frontend' | 'firmware' | 'bundle'} */ /** @type {'frontend' | 'firmware' | 'bundle'} */
let updateMode = $state("frontend"); let updateMode = $state("frontend");
let isDragging = $state(false); let isDragging = $state(false);
async function fetchStatus() {
status = "loading_status";
try {
[otaInfo, systemInfo] = await Promise.all([getOTAStatus(), getSystemInfo()]);
status = "idle";
} catch (e) {
status = "error";
errorMsg = "Failed to fetch OTA status: " + e.message;
}
}
$effect(() => {
fetchStatus();
});
function handleFileChange(event) { function handleFileChange(event) {
const files = event.target.files; const files = event.target.files;
if (files && files.length > 0) processFile(files[0]); if (files && files.length > 0) processFile(files[0]);
@@ -114,7 +93,7 @@
} }
} }
const currentTarget = $derived(() => { const currentTarget = $derived.by(() => {
if (updateMode === 'bundle') return 'FW + UI'; if (updateMode === 'bundle') return 'FW + UI';
if (updateMode === 'frontend') return otaInfo.target_partition; if (updateMode === 'frontend') return otaInfo.target_partition;
// For firmware, target is the slot that is NOT the running one // For firmware, target is the slot that is NOT the running one
@@ -182,7 +161,7 @@
OTA Upgrade ({updateMode === 'frontend' ? 'UI' : 'Firmware'}) OTA Upgrade ({updateMode === 'frontend' ? 'UI' : 'Firmware'})
</h3> </h3>
<div class="text-[10px] text-text-secondary"> <div class="text-[10px] text-text-secondary">
Target: <span class="font-mono text-accent">{currentTarget()}</span> Target: <span class="font-mono text-accent">{currentTarget}</span>
{#if updateMode === 'frontend' && otaInfo.partitions} {#if updateMode === 'frontend' && otaInfo.partitions}
<span class="ml-1"> <span class="ml-1">
({(otaInfo.partitions.find(p => p.label === otaInfo.target_partition)?.size / 1024 / 1024).toFixed(1)} MB) ({(otaInfo.partitions.find(p => p.label === otaInfo.target_partition)?.size / 1024 / 1024).toFixed(1)} MB)

View File

@@ -1,135 +1,67 @@
<script> <script>
let { currentView = 'dashboard', onNavigate = () => {} } = $props(); let { currentView = 'dashboard', onNavigate = () => {}, isOpen = false, onToggle = null } = $props();
let collapsed = $state(typeof window !== 'undefined' && window.innerWidth <= 768); 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 = [ const navItems = [
{ id: 'dashboard', label: 'Dashboard', icon: '🏠' }, { id: 'dashboard', label: 'Dashboard', icon: '🏠' },
{ id: 'tasks', label: 'Tasks', icon: '📋' }, { id: 'tasks', label: 'Tasks', icon: '📋' },
{ id: 'users', label: 'Users', icon: '👥' },
]; ];
</script> </script>
<aside class="sidebar {collapsed ? 'collapsed' : ''}"> <aside class="
<div class="sidebar-header"> 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} {#if !collapsed}
<span class="sidebar-title">Menu</span> <span class="text-xs font-bold uppercase tracking-[0.05em] text-text-secondary whitespace-nowrap overflow-hidden">
Menu
</span>
{/if} {/if}
<button <button
class="collapse-btn" 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
onclick={() => collapsed = !collapsed} {onToggle ? 'max-md:flex' : ''}"
onclick={() => onToggle ? onToggle() : (collapsed = !collapsed)}
title={collapsed ? 'Expand' : 'Collapse'} title={collapsed ? 'Expand' : 'Collapse'}
> >
{collapsed ? '▶' : '◀'} <svg class="w-[18px] h-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button> </button>
</div> </div>
<nav class="sidebar-nav"> <nav class="flex flex-col gap-1 p-2">
{#each navItems as item} {#each navItems as item}
<button <button
class="nav-item {currentView === item.id ? 'active' : ''}" class="flex items-center gap-2.5 px-3 py-2.5 border-none rounded-lg cursor-pointer text-[13px] font-medium transition-all whitespace-nowrap text-left
max-md:px-4 max-md:py-3.5 max-md:text-[15px]
{currentView === item.id
? 'bg-accent/15 text-accent'
: 'bg-transparent text-text-secondary hover:bg-bg-card-hover hover:text-text-primary'}"
onclick={() => onNavigate(item.id)} onclick={() => onNavigate(item.id)}
title={item.label} title={item.label}
> >
<span class="nav-icon">{item.icon}</span> <span class="text-base flex-shrink-0">{item.icon}</span>
{#if !collapsed} {#if !collapsed}
<span class="nav-label">{item.label}</span> <span class="overflow-hidden">{item.label}</span>
{/if} {/if}
</button> </button>
{/each} {/each}
</nav> </nav>
</aside> </aside>
<style>
.sidebar {
width: 200px;
min-height: 100vh;
background: var(--color-bg-card);
border-right: 1px solid var(--color-border);
display: flex;
flex-direction: column;
transition: width 0.25s ease;
flex-shrink: 0;
}
.sidebar.collapsed {
width: 56px;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 12px;
border-bottom: 1px solid var(--color-border);
min-height: 56px;
}
.sidebar-title {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
}
.collapse-btn {
background: none;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
padding: 4px;
font-size: 10px;
border-radius: 4px;
transition: color 0.15s, background 0.15s;
flex-shrink: 0;
}
.collapse-btn:hover {
color: var(--color-text-primary);
background: var(--color-bg-card-hover);
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.15s;
white-space: nowrap;
text-align: left;
}
.nav-item:hover {
background: var(--color-bg-card-hover);
color: var(--color-text-primary);
}
.nav-item.active {
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
color: var(--color-accent);
}
.nav-icon {
font-size: 16px;
flex-shrink: 0;
}
.nav-label {
overflow: hidden;
}
</style>

View 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}

View File

@@ -1,22 +1,67 @@
<script> <script>
import { getTasks, addTask, updateTask, deleteTask } from './api.js'; import { getTasks, addTask, updateTask, deleteTask } from './api.js';
import { formatRelativeDate, isOverdue } from './utils.js';
import UserManager from './UserManager.svelte'; import UserManager from './UserManager.svelte';
let selectedUserId = $state(null); let selectedUserId = $state(null);
let tasks = $state([]); let tasks = $state([]);
let error = $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 // Add task form state
let newTitle = $state(''); let newTitle = $state('');
let newPeriod = $state(0x01);
let newRecurrence = $state(0);
let newDueDay = $state(''); let newDueDay = $state('');
let newDueTime = $state('');
let showAddForm = $state(false); let showAddForm = $state(false);
// Edit state // Edit state
let editingTaskId = $state(null); let editingTaskId = $state(null);
let editTitle = $state(''); let editTitle = $state('');
let editPeriod = $state(0);
let editRecurrence = $state(0);
let editDueDay = $state(''); let editDueDay = $state('');
let editDueTime = $state('');
// Confirm delete // Confirm delete
let confirmDeleteId = $state(null); let confirmDeleteId = $state(null);
@@ -28,8 +73,13 @@
} }
try { try {
tasks = await getTasks(selectedUserId); tasks = await getTasks(selectedUserId);
// Sort by due_date ascending // Sort: recurrent first (by day), then one-off by due_date
tasks.sort((a, b) => a.due_date - b.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 = ''; error = '';
} catch (e) { } catch (e) {
error = e.message; error = e.message;
@@ -47,14 +97,19 @@
async function handleAddTask(e) { async function handleAddTask(e) {
e.preventDefault(); e.preventDefault();
if (!newTitle.trim() || !newDueDay || !newDueTime) return; if (!newTitle.trim()) return;
if (newRecurrence === 0 && !newDueDay) return;
const dueTimestamp = newRecurrence > 0
? 0
: Math.floor(new Date(`${newDueDay}T00:00`).getTime() / 1000);
const dueTimestamp = Math.floor(new Date(`${newDueDay}T${newDueTime}`).getTime() / 1000);
try { try {
await addTask(selectedUserId, newTitle.trim(), dueTimestamp); await addTask(selectedUserId, newTitle.trim(), dueTimestamp, newPeriod, newRecurrence);
newTitle = ''; newTitle = '';
newPeriod = 0x01;
newRecurrence = 0;
newDueDay = ''; newDueDay = '';
newDueTime = '';
showAddForm = false; showAddForm = false;
await fetchTasks(); await fetchTasks();
} catch (e) { } catch (e) {
@@ -74,20 +129,34 @@
function startEditing(task) { function startEditing(task) {
editingTaskId = task.id; editingTaskId = task.id;
editTitle = task.title; editTitle = task.title;
const parts = formatDateForInput(task.due_date); editPeriod = task.period;
editDueDay = parts.day; editRecurrence = task.recurrence;
editDueTime = parts.time; 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) { async function saveEdit(e) {
e.preventDefault(); e.preventDefault();
if (!editTitle.trim()) return; if (!editTitle.trim()) return;
if (editRecurrence === 0 && !editDueDay) return;
const dueTimestamp = editRecurrence > 0
? 0
: Math.floor(new Date(`${editDueDay}T00:00`).getTime() / 1000);
const dueTimestamp = Math.floor(new Date(`${editDueDay}T${editDueTime}`).getTime() / 1000);
try { try {
await updateTask(editingTaskId, { await updateTask(editingTaskId, {
title: editTitle.trim(), title: editTitle.trim(),
due_date: dueTimestamp due_date: dueTimestamp,
period: editPeriod,
recurrence: editRecurrence
}); });
editingTaskId = null; editingTaskId = null;
await fetchTasks(); await fetchTasks();
@@ -106,436 +175,212 @@
} }
} }
function formatDateForInput(timestamp) { function setTodayForNewTask() {
const d = new Date(timestamp * 1000); const d = new Date();
const year = d.getFullYear(); const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0'); const month = String(d.getMonth() + 1).padStart(2, '0');
const dayNum = String(d.getDate()).padStart(2, '0'); const dayNum = String(d.getDate()).padStart(2, '0');
const hours = String(d.getHours()).padStart(2, '0'); newDueDay = `${year}-${month}-${dayNum}`;
const mins = String(d.getMinutes()).padStart(2, '0');
return { day: `${year}-${month}-${dayNum}`, time: `${hours}:${mins}` };
} }
function setNowForNewTask() { function taskIsOverdue(task) {
const parts = formatDateForInput(Date.now() / 1000); if (task.recurrence > 0) return false;
newDueDay = parts.day; return isOverdue(task.due_date);
newDueTime = parts.time;
}
function formatRelativeDate(timestamp) {
const now = Date.now() / 1000;
const diff = timestamp - now;
const absDiff = Math.abs(diff);
if (absDiff < 3600) {
const mins = Math.round(absDiff / 60);
return diff < 0 ? `${mins}m ago` : `in ${mins}m`;
}
if (absDiff < 86400) {
const hours = Math.round(absDiff / 3600);
return diff < 0 ? `${hours}h ago` : `in ${hours}h`;
}
const days = Math.round(absDiff / 86400);
return diff < 0 ? `${days}d ago` : `in ${days}d`;
}
function isOverdue(timestamp) {
return timestamp < Date.now() / 1000;
} }
</script> </script>
<div class="task-manager"> <div class="w-full">
<UserManager bind:selectedUserId onUsersChanged={fetchTasks} /> <UserManager bind:selectedUserId onUsersChanged={fetchTasks} />
{#if selectedUserId} {#if selectedUserId}
<div class="task-header"> <div class="flex items-center justify-between mb-3">
<h2 class="task-title">Tasks</h2> <h2 class="text-sm font-semibold uppercase tracking-[0.05em] text-text-primary">Tasks</h2>
{#if !showAddForm} {#if !showAddForm}
<button class="add-task-btn" onclick={() => { showAddForm = true; setNowForNewTask(); }}>+ Add Task</button> <button
class="px-3.5 py-1.5 rounded-lg border border-accent bg-accent/10 text-accent cursor-pointer text-xs font-semibold transition-all hover:bg-accent/20"
onclick={() => { showAddForm = true; setTodayForNewTask(); }}
>+ Add Task</button>
{/if} {/if}
</div> </div>
{#if showAddForm} {#if showAddForm}
<form class="add-task-form" onsubmit={handleAddTask}> <form class="flex flex-col gap-2 p-3 rounded-[10px] border border-border bg-bg-card mb-3" onsubmit={handleAddTask}>
<!-- svelte-ignore a11y_autofocus --> <!-- svelte-ignore a11y_autofocus -->
<input <input
type="text" type="text"
bind:value={newTitle} bind:value={newTitle}
placeholder="Task title..." placeholder="Task title..."
class="task-input" class="px-3 py-2 rounded-lg border border-border bg-bg-primary text-text-primary text-[13px] outline-none w-full box-border focus:border-accent"
autofocus autofocus
/> />
<div class="date-time-row">
<label class="date-time-field"> <div class="flex gap-2">
<span class="field-label">Date</span> <div class="flex flex-col gap-1 flex-1">
<input type="date" bind:value={newDueDay} class="task-date-input" /> <span class="text-[10px] font-semibold uppercase tracking-[0.05em] text-text-secondary">Period</span>
</label> <div class="flex gap-1">
<label class="date-time-field"> {#each PERIODS as p, i}
<span class="field-label">Time</span> <button
<input type="time" bind:value={newDueTime} class="task-time-input" /> type="button"
</label> 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>
<div class="form-actions">
<button type="submit" class="btn-primary" disabled={!newTitle.trim() || !newDueDay || !newDueTime}>Add</button> <div class="flex gap-2">
<button type="button" class="btn-secondary" onclick={() => { showAddForm = false; newTitle = ''; newDueDay = ''; newDueTime = ''; }}>Cancel</button> <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> </div>
</form> </form>
{/if} {/if}
<div class="task-list"> <div class="flex flex-col gap-1">
{#each tasks as task} {#each tasks as task}
<div class="task-item {task.completed ? 'completed' : ''} {isOverdue(task.due_date) && !task.completed ? 'overdue' : ''}"> <div class="flex items-center justify-between flex-wrap px-3.5 py-2.5 rounded-[10px] border bg-bg-card transition-all hover:bg-bg-card-hover
{task.completed ? 'opacity-50' : ''}
{taskIsOverdue(task) && !task.completed ? 'border-danger/40' : 'border-border'}">
{#if editingTaskId === task.id} {#if editingTaskId === task.id}
<form class="edit-form" onsubmit={saveEdit}> <form class="w-full flex flex-col gap-2 p-0" onsubmit={saveEdit}>
<input type="text" bind:value={editTitle} class="task-input" /> <input type="text" bind:value={editTitle}
<div class="date-time-row"> 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" />
<label class="date-time-field">
<span class="field-label">Date</span> <div class="flex gap-2">
<input type="date" bind:value={editDueDay} class="task-date-input" /> <div class="flex flex-col gap-1 flex-1">
</label> <span class="text-[10px] font-semibold uppercase tracking-[0.05em] text-text-secondary">Period</span>
<label class="date-time-field"> <div class="flex gap-1">
<span class="field-label">Time</span> {#each PERIODS as p, i}
<input type="time" bind:value={editDueTime} class="task-time-input" /> <button type="button"
</label> 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>
<div class="form-actions">
<button type="submit" class="btn-primary btn-sm">Save</button> <div class="flex gap-2">
<button type="button" class="btn-secondary btn-sm" onclick={() => editingTaskId = null}>Cancel</button> <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> </div>
</form> </form>
{:else} {:else}
<div class="task-left"> <div class="flex items-center gap-2.5 flex-1 min-w-0">
<input <input
type="checkbox" type="checkbox"
checked={task.completed} checked={task.completed}
onchange={() => handleToggleComplete(task)} onchange={() => handleToggleComplete(task)}
class="task-checkbox" class="w-4 h-4 cursor-pointer flex-shrink-0"
style="accent-color: var(--color-accent)"
/> />
<div class="task-info"> <div class="flex flex-col gap-0.5 min-w-0">
<span class="task-text">{task.title}</span> <span class="text-[13px] text-text-primary whitespace-nowrap overflow-hidden text-ellipsis
<span class="task-due {isOverdue(task.due_date) && !task.completed ? 'text-overdue' : ''}"> {task.completed ? 'line-through text-text-secondary' : ''}">{task.title}</span>
{formatRelativeDate(task.due_date)} <div class="flex gap-2 items-center flex-wrap">
</span> <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> </div>
<div class="task-actions"> <div class="flex gap-1 items-center flex-shrink-0">
<button class="action-btn" onclick={() => startEditing(task)} title="Edit">✏️</button> <button class="bg-transparent border-none cursor-pointer text-xs p-1 rounded opacity-60 transition-[background] hover:opacity-100 hover:bg-bg-card-hover"
onclick={() => startEditing(task)} title="Edit">✏️</button>
{#if confirmDeleteId === task.id} {#if confirmDeleteId === task.id}
<button class="action-btn text-danger" onclick={() => handleDelete(task.id)} title="Confirm"></button> <button class="bg-transparent border-none cursor-pointer text-xs p-1 rounded text-danger opacity-60 hover:opacity-100"
<button class="action-btn" onclick={() => confirmDeleteId = null} title="Cancel"></button> 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} {:else}
<button class="action-btn" onclick={() => confirmDeleteId = task.id} title="Delete">🗑️</button> <button class="bg-transparent border-none cursor-pointer text-xs p-1 rounded opacity-60 transition-[background] hover:opacity-100 hover:bg-bg-card-hover"
onclick={() => confirmDeleteId = task.id} title="Delete">🗑️</button>
{/if} {/if}
</div> </div>
{/if} {/if}
</div> </div>
{:else} {:else}
<div class="empty-state">No tasks yet. Add one above!</div> <div class="text-center p-8 text-text-secondary text-[13px]">No tasks yet. Add one above!</div>
{/each} {/each}
</div> </div>
{:else} {:else}
<div class="empty-state">Select or add a user to see their tasks.</div> <div class="text-center p-8 text-text-secondary text-[13px]">Select or add a user to see their tasks.</div>
{/if} {/if}
{#if error} {#if error}
<div class="task-error">{error}</div> <div class="mt-2 px-2.5 py-1.5 rounded-md bg-danger/10 border border-danger/20 text-danger text-[11px]">{error}</div>
{/if} {/if}
</div> </div>
<style>
.task-manager {
width: 100%;
}
.task-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.task-title {
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-primary);
}
.add-task-btn {
padding: 6px 14px;
border-radius: 8px;
border: 1px solid var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
color: var(--color-accent);
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition: all 0.15s;
}
.add-task-btn:hover {
background: color-mix(in srgb, var(--color-accent) 20%, transparent);
}
.add-task-form, .edit-form {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border-radius: 10px;
border: 1px solid var(--color-border);
background: var(--color-bg-card);
margin-bottom: 12px;
}
.task-input {
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--color-border);
background: var(--color-bg-primary);
color: var(--color-text-primary);
font-size: 13px;
outline: none;
width: 100%;
box-sizing: border-box;
}
.task-input:focus {
border-color: var(--color-accent);
}
.date-time-row {
display: flex;
gap: 8px;
}
.date-time-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.date-time-field:first-child {
flex: 1;
}
.field-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary);
}
.task-date-input {
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--color-border);
background: var(--color-bg-primary);
color: var(--color-text-primary);
font-size: 13px;
outline: none;
color-scheme: dark;
flex: 1;
cursor: pointer;
position: relative;
}
.task-time-input {
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--color-border);
background: var(--color-bg-primary);
color: var(--color-text-primary);
font-size: 13px;
outline: none;
color-scheme: dark;
width: 110px;
cursor: pointer;
position: relative;
}
/* Make the native picker icon cover the entire input (Chrome/Edge only) */
.task-date-input::-webkit-calendar-picker-indicator,
.task-time-input::-webkit-calendar-picker-indicator {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: auto;
height: auto;
opacity: 0;
cursor: pointer;
}
.task-date-input:focus, .task-time-input:focus {
border-color: var(--color-accent);
}
.form-actions {
display: flex;
gap: 6px;
}
.btn-primary {
padding: 6px 16px;
border-radius: 8px;
border: none;
background: var(--color-accent);
color: white;
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition: filter 0.15s;
}
.btn-primary:hover {
filter: brightness(1.1);
}
.btn-primary:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-secondary {
padding: 6px 16px;
border-radius: 8px;
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 12px;
transition: all 0.15s;
}
.btn-secondary:hover {
background: var(--color-bg-card-hover);
}
.btn-sm {
padding: 4px 10px;
font-size: 11px;
}
.task-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.task-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-radius: 10px;
border: 1px solid var(--color-border);
background: var(--color-bg-card);
transition: all 0.15s;
}
.task-item:hover {
background: var(--color-bg-card-hover);
}
.task-item.completed {
opacity: 0.5;
}
.task-item.overdue {
border-color: color-mix(in srgb, var(--color-danger) 40%, transparent);
}
.task-left {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
}
.task-checkbox {
width: 16px;
height: 16px;
accent-color: var(--color-accent);
cursor: pointer;
flex-shrink: 0;
}
.task-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.task-text {
font-size: 13px;
color: var(--color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.completed .task-text {
text-decoration: line-through;
color: var(--color-text-secondary);
}
.task-due {
font-size: 10px;
color: var(--color-text-secondary);
font-weight: 500;
}
.text-overdue {
color: var(--color-danger) !important;
}
.text-danger {
color: var(--color-danger);
}
.task-actions {
display: flex;
gap: 4px;
align-items: center;
flex-shrink: 0;
}
.action-btn {
background: none;
border: none;
cursor: pointer;
font-size: 12px;
padding: 4px;
border-radius: 4px;
transition: background 0.15s;
opacity: 0.6;
}
.action-btn:hover {
opacity: 1;
background: var(--color-bg-card-hover);
}
.empty-state {
text-align: center;
padding: 32px;
color: var(--color-text-secondary);
font-size: 13px;
}
.task-error {
margin-top: 8px;
padding: 6px 10px;
border-radius: 6px;
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-danger) 20%, transparent);
color: var(--color-danger);
font-size: 11px;
}
</style>

View File

@@ -1,7 +1,11 @@
<script> <script>
import { getUsers, addUser, removeUser } from './api.js'; import { getUsers, addUser, removeUser, updateUser } from './api.js';
let { selectedUserId = $bindable(null), onUsersChanged = () => {} } = $props(); let {
selectedUserId = $bindable(null),
onUsersChanged = () => {},
mode = 'selector' // 'selector' | 'manager'
} = $props();
let users = $state([]); let users = $state([]);
let newUserName = $state(''); let newUserName = $state('');
@@ -9,10 +13,14 @@
let error = $state(''); let error = $state('');
let confirmDeleteId = $state(null); let confirmDeleteId = $state(null);
// Edit state
let editingUserId = $state(null);
let editName = $state('');
async function fetchUsers() { async function fetchUsers() {
try { try {
users = await getUsers(); users = await getUsers();
if (users.length > 0 && !selectedUserId) { if (users.length > 0 && !selectedUserId && mode === 'selector') {
selectedUserId = users[0].id; selectedUserId = users[0].id;
} }
// If selected user was deleted, select first available // If selected user was deleted, select first available
@@ -32,7 +40,7 @@
newUserName = ''; newUserName = '';
showAddForm = false; showAddForm = false;
await fetchUsers(); await fetchUsers();
selectedUserId = user.id; if (mode === 'selector') selectedUserId = user.id;
onUsersChanged(); onUsersChanged();
} catch (e) { } catch (e) {
error = e.message; error = e.message;
@@ -50,225 +58,151 @@
} }
} }
function startEditing(user) {
editingUserId = user.id;
editName = user.name;
}
async function handleUpdateUser() {
if (!editName.trim()) return;
try {
await updateUser(editingUserId, editName.trim());
editingUserId = null;
await fetchUsers();
onUsersChanged();
} catch (e) {
error = e.message;
}
}
// Load initial data on mount
$effect(() => { $effect(() => {
fetchUsers(); fetchUsers();
}); });
</script> </script>
<div class="user-manager"> <div class="mb-4">
<div class="user-bar"> {#if mode === 'selector'}
<div class="user-chips"> <div class="flex items-center gap-2">
{#each users as user} <div class="flex flex-wrap gap-1.5 items-center">
<!-- svelte-ignore node_invalid_placement_ssr --> {#each users as user}
<div <!-- svelte-ignore node_invalid_placement_ssr -->
class="user-chip {selectedUserId === user.id ? 'selected' : ''}" <div
onclick={() => selectedUserId = user.id} class="flex items-center gap-1.5 px-4 py-1.5 rounded-full border cursor-pointer text-[13px] font-semibold
role="button" transition-all duration-200 hover:-translate-y-px hover:shadow-[0_4px_12px_rgba(0,0,0,0.1)]
tabindex="0" {selectedUserId === user.id
onkeydown={(e) => { if (e.key === 'Enter') 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'}"
<span class="user-name">{user.name}</span> onclick={() => selectedUserId = user.id}
{#if confirmDeleteId === user.id} role="button"
<span class="confirm-delete"> tabindex="0"
<button class="confirm-yes" onclick={(e) => { e.stopPropagation(); handleRemoveUser(user.id); }} title="Confirm delete"></button> onkeydown={(e) => { if (e.key === 'Enter') selectedUserId = user.id; }}
<button class="confirm-no" onclick={(e) => { e.stopPropagation(); confirmDeleteId = null; }} title="Cancel"></button> >
</span> <span>{user.name}</span>
{:else} </div>
<button {/each}
class="remove-btn" </div>
onclick={(e) => { e.stopPropagation(); confirmDeleteId = user.id; }} </div>
title="Remove user" {:else}
></button> <!-- Manager Mode -->
{/if} <header class="flex justify-between items-center mb-6">
</div> <h1 class="text-xl font-bold text-text-primary">User Management</h1>
{/each} <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} {#if showAddForm}
<form class="add-user-form" onsubmit={(e) => { e.preventDefault(); handleAddUser(); }}> <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 --> <!-- svelte-ignore a11y_autofocus -->
<input <input
type="text" type="text"
bind:value={newUserName} bind:value={newUserName}
placeholder="Name..." placeholder="Name..."
class="add-user-input" class="px-4 py-3 rounded-xl border border-border bg-bg-primary text-text-primary text-sm outline-none focus:border-accent"
autofocus autofocus
/> />
<button type="submit" class="add-user-submit" disabled={!newUserName.trim()}>+</button> <div class="flex gap-3 justify-end">
<button type="button" class="add-user-cancel" onclick={() => { showAddForm = false; newUserName = ''; }}>✕</button> <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> </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} {:else}
<button class="add-user-btn" onclick={() => showAddForm = true}> <div class="text-center px-12 py-12 bg-bg-card border border-dashed border-border rounded-2xl text-text-secondary">
+ User No users found. Create one to get started!
</button> </div>
{/if} {/each}
</div> </div>
</div> {/if}
{#if error} {#if error}
<div class="user-error">{error}</div> <div class="mt-4 p-3 rounded-xl bg-danger/10 border border-danger/20 text-danger text-[13px] text-center">{error}</div>
{/if} {/if}
</div> </div>
<style>
.user-manager {
margin-bottom: 16px;
}
.user-bar {
display: flex;
align-items: center;
gap: 8px;
}
.user-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.user-chip {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 20px;
border: 1px solid var(--color-border);
background: var(--color-bg-card);
color: var(--color-text-secondary);
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: all 0.15s;
}
.user-chip:hover {
border-color: var(--color-accent);
color: var(--color-text-primary);
}
.user-chip.selected {
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
border-color: var(--color-accent);
color: var(--color-accent);
}
.user-name {
white-space: nowrap;
}
.remove-btn {
background: none;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 10px;
padding: 0 2px;
opacity: 0.5;
transition: opacity 0.15s, color 0.15s;
line-height: 1;
}
.remove-btn:hover {
opacity: 1;
color: var(--color-danger);
}
.confirm-delete {
display: flex;
gap: 4px;
}
.confirm-yes, .confirm-no {
background: none;
border: none;
cursor: pointer;
font-size: 11px;
padding: 0 2px;
line-height: 1;
}
.confirm-yes {
color: var(--color-danger);
}
.confirm-no {
color: var(--color-text-secondary);
}
.add-user-btn {
padding: 6px 12px;
border-radius: 20px;
border: 1px dashed var(--color-border);
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: all 0.15s;
}
.add-user-btn:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
.add-user-form {
display: flex;
align-items: center;
gap: 4px;
}
.add-user-input {
padding: 5px 10px;
border-radius: 16px;
border: 1px solid var(--color-border);
background: var(--color-bg-primary);
color: var(--color-text-primary);
font-size: 12px;
width: 100px;
outline: none;
}
.add-user-input:focus {
border-color: var(--color-accent);
}
.add-user-submit {
background: var(--color-accent);
color: white;
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.add-user-submit:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.add-user-cancel {
background: none;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 12px;
}
.user-error {
margin-top: 8px;
padding: 6px 10px;
border-radius: 6px;
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-danger) 20%, transparent);
color: var(--color-danger);
font-size: 11px;
}
</style>

View File

@@ -7,13 +7,26 @@
*/ */
const API_BASE = import.meta.env.VITE_API_BASE || ''; const API_BASE = import.meta.env.VITE_API_BASE || '';
import { pendingRequests } from './stores.js';
/**
* Wrapper around fetch that tracks the number of pending requests globally
*/
async function trackedFetch(url, options = {}) {
pendingRequests.update(n => n + 1);
try {
return await fetch(url, options);
} finally {
pendingRequests.update(n => Math.max(0, n - 1));
}
}
/** /**
* Fetch system information from the ESP32. * Fetch system information from the ESP32.
* @returns {Promise<{chip: string, freeHeap: number, uptime: number, firmware: string, connection: string}>} * @returns {Promise<{chip: string, freeHeap: number, uptime: number, firmware: string, connection: string}>}
*/ */
export async function getSystemInfo() { export async function getSystemInfo() {
const res = await fetch(`${API_BASE}/api/system/info`); const res = await trackedFetch(`${API_BASE}/api/system/info`);
if (!res.ok) { if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`); throw new Error(`HTTP ${res.status}: ${res.statusText}`);
} }
@@ -29,7 +42,7 @@ export async function getSystemInfo() {
* @returns {Promise<{message: string}>} * @returns {Promise<{message: string}>}
*/ */
export async function reboot() { export async function reboot() {
const res = await fetch(`${API_BASE}/api/system/reboot`, { const res = await trackedFetch(`${API_BASE}/api/system/reboot`, {
method: 'POST', method: 'POST',
}); });
if (!res.ok) { if (!res.ok) {
@@ -43,7 +56,7 @@ export async function reboot() {
* @returns {Promise<{active_slot: number, active_partition: string, target_partition: string, partitions: any[], running_firmware_label: string, running_firmware_slot: number}>} * @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() { 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) { if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`); throw new Error(`HTTP ${res.status}: ${res.statusText}`);
} }
@@ -56,7 +69,7 @@ export async function getOTAStatus() {
* @returns {Promise<{status: string, message: string}>} * @returns {Promise<{status: string, message: string}>}
*/ */
export async function uploadOTAFrontend(file) { export async function uploadOTAFrontend(file) {
const res = await fetch(`${API_BASE}/api/ota/frontend`, { const res = await trackedFetch(`${API_BASE}/api/ota/frontend`, {
method: 'POST', method: 'POST',
body: file, // Send the raw file Blob/Buffer body: file, // Send the raw file Blob/Buffer
headers: { headers: {
@@ -79,7 +92,7 @@ export async function uploadOTAFrontend(file) {
* @returns {Promise<{status: string, message: string}>} * @returns {Promise<{status: string, message: string}>}
*/ */
export async function uploadOTAFirmware(file) { export async function uploadOTAFirmware(file) {
const res = await fetch(`${API_BASE}/api/ota/firmware`, { const res = await trackedFetch(`${API_BASE}/api/ota/firmware`, {
method: 'POST', method: 'POST',
body: file, body: file,
headers: { headers: {
@@ -101,7 +114,7 @@ export async function uploadOTAFirmware(file) {
* @returns {Promise<{status: string, message: string}>} * @returns {Promise<{status: string, message: string}>}
*/ */
export async function uploadOTABundle(file) { export async function uploadOTABundle(file) {
const res = await fetch(`${API_BASE}/api/ota/bundle`, { const res = await trackedFetch(`${API_BASE}/api/ota/bundle`, {
method: 'POST', method: 'POST',
body: file, body: file,
headers: { headers: {
@@ -124,7 +137,7 @@ export async function uploadOTABundle(file) {
* @returns {Promise<Array<{id: number, name: string}>>} * @returns {Promise<Array<{id: number, name: string}>>}
*/ */
export async function getUsers() { export async function getUsers() {
const res = await fetch(`${API_BASE}/api/users`); const res = await trackedFetch(`${API_BASE}/api/users`);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
return res.json(); return res.json();
} }
@@ -135,7 +148,7 @@ export async function getUsers() {
* @returns {Promise<{id: number, name: string}>} * @returns {Promise<{id: number, name: string}>}
*/ */
export async function addUser(name) { export async function addUser(name) {
const res = await fetch(`${API_BASE}/api/users`, { const res = await trackedFetch(`${API_BASE}/api/users`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }) body: JSON.stringify({ name })
@@ -153,7 +166,7 @@ export async function addUser(name) {
* @returns {Promise<{status: string}>} * @returns {Promise<{status: string}>}
*/ */
export async function removeUser(id) { export async function removeUser(id) {
const res = await fetch(`${API_BASE}/api/users?id=${id}`, { const res = await trackedFetch(`${API_BASE}/api/users?id=${id}`, {
method: 'DELETE' method: 'DELETE'
}); });
if (!res.ok) { if (!res.ok) {
@@ -163,6 +176,25 @@ export async function removeUser(id) {
return res.json(); 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 ───────────────────────────────────────────────────────── // ─── Task Management ─────────────────────────────────────────────────────────
/** /**
@@ -171,7 +203,7 @@ export async function removeUser(id) {
* @returns {Promise<Array<{id: number, user_id: number, title: string, due_date: number, completed: boolean}>>} * @returns {Promise<Array<{id: number, user_id: number, title: string, due_date: number, completed: boolean}>>}
*/ */
export async function getTasks(userId) { export async function getTasks(userId) {
const res = await fetch(`${API_BASE}/api/tasks?user_id=${userId}`); const res = await trackedFetch(`${API_BASE}/api/tasks?user_id=${userId}`);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
return res.json(); return res.json();
} }
@@ -181,7 +213,7 @@ export async function getTasks(userId) {
* @returns {Promise<{users: Array<{id: number, name: string, tasks: Array}>}>} * @returns {Promise<{users: Array<{id: number, name: string, tasks: Array}>}>}
*/ */
export async function getUpcomingTasks() { export async function getUpcomingTasks() {
const res = await fetch(`${API_BASE}/api/tasks/upcoming`); const res = await trackedFetch(`${API_BASE}/api/tasks/upcoming`);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
return res.json(); return res.json();
} }
@@ -190,14 +222,16 @@ export async function getUpcomingTasks() {
* Create a new task. * Create a new task.
* @param {number} userId * @param {number} userId
* @param {string} title * @param {string} title
* @param {number} dueDate Unix timestamp in seconds * @param {number} dueDate Unix timestamp in seconds (used for non-recurrent tasks)
* @returns {Promise<{id: number, user_id: number, title: string, due_date: number, completed: boolean}>} * @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) { export async function addTask(userId, title, dueDate, period = 0, recurrence = 0) {
const res = await fetch(`${API_BASE}/api/tasks`, { const res = await trackedFetch(`${API_BASE}/api/tasks`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId, title, due_date: dueDate }) body: JSON.stringify({ user_id: userId, title, due_date: dueDate, period, recurrence })
}); });
if (!res.ok) { if (!res.ok) {
const errorText = await res.text(); const errorText = await res.text();
@@ -209,11 +243,11 @@ export async function addTask(userId, title, dueDate) {
/** /**
* Update a task (partial update — only include fields you want to change). * Update a task (partial update — only include fields you want to change).
* @param {number} id * @param {number} id
* @param {Object} fields - { title?: string, due_date?: number, completed?: boolean } * @param {Object} fields - { title?: string, due_date?: number, period?: number, recurrence?: number, completed?: boolean }
* @returns {Promise<{status: string}>} * @returns {Promise<{status: string}>}
*/ */
export async function updateTask(id, fields) { export async function updateTask(id, fields) {
const res = await fetch(`${API_BASE}/api/tasks/update`, { const res = await trackedFetch(`${API_BASE}/api/tasks/update`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, ...fields }) body: JSON.stringify({ id, ...fields })
@@ -231,7 +265,7 @@ export async function updateTask(id, fields) {
* @returns {Promise<{status: string}>} * @returns {Promise<{status: string}>}
*/ */
export async function deleteTask(id) { export async function deleteTask(id) {
const res = await fetch(`${API_BASE}/api/tasks?id=${id}`, { const res = await trackedFetch(`${API_BASE}/api/tasks?id=${id}`, {
method: 'DELETE' method: 'DELETE'
}); });
if (!res.ok) { if (!res.ok) {

View File

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

View 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;
}

View File

@@ -1,5 +1,5 @@
{ {
"major": 0, "major": 0,
"minor": 1, "minor": 1,
"revision": 4 "revision": 30
} }

View File

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

View File

@@ -24,6 +24,52 @@ menu "CalendarInk Network Configuration"
help help
Number of times to retry the WiFi connection before failing completely. Number of times to retry the WiFi connection before failing completely.
config CALENDINK_BLINK_IP
bool "Blink last IP digit on connect"
default n
help
If enabled, the LED will blink the last digit of the IP address
acquired to assist in debugging.
config CALENDINK_MDNS_HOSTNAME
string "mDNS Hostname"
default "calendink"
help
The hostname to use for mDNS. The device will be accessible
at <hostname>.local. (e.g., calendink.local)
config CALENDINK_UDP_LOG_TARGET_IP
string "UDP Logger Target IP Address"
default ""
help
The IP address to send UDP logs to via port 514.
If left blank, logs will be broadcast to 255.255.255.255.
choice CALENDINK_WIFI_PS_MODE
prompt "WiFi Power Save Mode"
default CALENDINK_WIFI_PS_NONE
help
Select the WiFi power save mode to balance power consumption and network stability.
config CALENDINK_WIFI_PS_NONE
bool "None (No power save, highest consumption)"
config CALENDINK_WIFI_PS_MIN_MODEM
bool "Minimum Modem (Wakes on beacon, balanced)"
config CALENDINK_WIFI_PS_MAX_MODEM
bool "Maximum Modem (Lowest consumption, may drop connection on strict routers)"
endchoice
config CALENDINK_ALLOW_LIGHT_SLEEP
bool "Allow Light Sleep (Tickless Idle)"
default n
help
If enabled, the device will heavily use light sleep to reduce power
consumption. Note that this may BREAK the UART console monitor since the
CPU sleeps and halts the UART! Use UDP logging if you need logs
while light sleep is enabled.
endmenu endmenu
menu "Calendink Web Server" menu "Calendink Web Server"
@@ -42,4 +88,16 @@ menu "Calendink Web Server"
help help
VFS path where the LittleFS partition is mounted. VFS path where the LittleFS partition is mounted.
config CALENDINK_DISPLAY_WIDTH
int "LVGL Display Width"
default 800
help
Width of the virtual LVGL display used for image generation.
config CALENDINK_DISPLAY_HEIGHT
int "LVGL Display Height"
default 480
help
Height of the virtual LVGL display used for image generation.
endmenu endmenu

View File

@@ -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};

View File

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

View File

@@ -1,5 +1,6 @@
// POST /api/tasks — Create a new task // POST /api/tasks — Create a new task
// Body: {"user_id":1, "title":"...", "due_date":1741369200} // Body: {"user_id":1, "title":"...", "due_date":1741369200, "period":0,
// "recurrence":0}
#include "cJSON.h" #include "cJSON.h"
#include "esp_http_server.h" #include "esp_http_server.h"
@@ -31,19 +32,26 @@ internal esp_err_t api_tasks_post_handler(httpd_req_t *req)
cJSON *user_id_item = cJSON_GetObjectItem(body, "user_id"); cJSON *user_id_item = cJSON_GetObjectItem(body, "user_id");
cJSON *title_item = cJSON_GetObjectItem(body, "title"); cJSON *title_item = cJSON_GetObjectItem(body, "title");
cJSON *due_date_item = cJSON_GetObjectItem(body, "due_date"); 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) || if (!cJSON_IsNumber(user_id_item) || !cJSON_IsString(title_item))
!cJSON_IsNumber(due_date_item))
{ {
cJSON_Delete(body); cJSON_Delete(body);
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing user_id or title");
"Missing user_id, title, or due_date");
return ESP_FAIL; 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 = task_t *task =
add_task((uint8)user_id_item->valueint, title_item->valuestring, add_task((uint8)user_id_item->valueint, title_item->valuestring, due_date,
(int64)due_date_item->valuedouble); period, recurrence);
cJSON_Delete(body); cJSON_Delete(body);
if (!task) if (!task)
@@ -58,6 +66,8 @@ internal esp_err_t api_tasks_post_handler(httpd_req_t *req)
cJSON_AddNumberToObject(resp, "user_id", task->user_id); cJSON_AddNumberToObject(resp, "user_id", task->user_id);
cJSON_AddStringToObject(resp, "title", task->title); cJSON_AddStringToObject(resp, "title", task->title);
cJSON_AddNumberToObject(resp, "due_date", (double)task->due_date); 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); cJSON_AddBoolToObject(resp, "completed", task->completed);
const char *json = cJSON_PrintUnformatted(resp); const char *json = cJSON_PrintUnformatted(resp);

View File

@@ -41,6 +41,8 @@ internal esp_err_t api_tasks_get_handler(httpd_req_t *req)
cJSON_AddNumberToObject(obj, "user_id", g_Tasks[i].user_id); cJSON_AddNumberToObject(obj, "user_id", g_Tasks[i].user_id);
cJSON_AddStringToObject(obj, "title", g_Tasks[i].title); cJSON_AddStringToObject(obj, "title", g_Tasks[i].title);
cJSON_AddNumberToObject(obj, "due_date", (double)g_Tasks[i].due_date); 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_AddBoolToObject(obj, "completed", g_Tasks[i].completed);
cJSON_AddItemToArray(arr, obj); cJSON_AddItemToArray(arr, obj);
} }

View File

@@ -6,7 +6,7 @@
#include "api/users/store.hpp" #include "api/users/store.hpp"
// Find a task by ID, returns nullptr if not found // Find a task by ID, returns nullptr if not found
internal task_t *find_task(uint16 id) task_t *find_task(uint16 id)
{ {
for (int i = 0; i < MAX_TASKS; i++) for (int i = 0; i < MAX_TASKS; i++)
{ {
@@ -19,7 +19,8 @@ internal task_t *find_task(uint16 id)
} }
// Add a task, returns pointer to new task or nullptr if full // Add a task, returns pointer to new task or nullptr if full
internal task_t *add_task(uint8 user_id, const char *title, int64 due_date) task_t *add_task(uint8 user_id, const char *title, int64 due_date, uint8 period,
uint8 recurrence)
{ {
// Verify user exists // Verify user exists
if (find_user(user_id) == nullptr) if (find_user(user_id) == nullptr)
@@ -35,6 +36,8 @@ internal task_t *add_task(uint8 user_id, const char *title, int64 due_date)
g_Tasks[i].user_id = user_id; g_Tasks[i].user_id = user_id;
strlcpy(g_Tasks[i].title, title, sizeof(g_Tasks[i].title)); strlcpy(g_Tasks[i].title, title, sizeof(g_Tasks[i].title));
g_Tasks[i].due_date = due_date; 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].completed = false;
g_Tasks[i].active = true; g_Tasks[i].active = true;
return &g_Tasks[i]; return &g_Tasks[i];
@@ -44,7 +47,7 @@ internal task_t *add_task(uint8 user_id, const char *title, int64 due_date)
} }
// Remove a task by ID, returns true if found and removed // Remove a task by ID, returns true if found and removed
internal bool remove_task(uint16 id) bool remove_task(uint16 id)
{ {
for (int i = 0; i < MAX_TASKS; i++) for (int i = 0; i < MAX_TASKS; i++)
{ {
@@ -59,7 +62,7 @@ internal bool remove_task(uint16 id)
} }
// Remove all tasks belonging to a user // Remove all tasks belonging to a user
internal void remove_tasks_for_user(uint8 user_id) void remove_tasks_for_user(uint8 user_id)
{ {
for (int i = 0; i < MAX_TASKS; i++) for (int i = 0; i < MAX_TASKS; i++)
{ {
@@ -73,7 +76,7 @@ internal void remove_tasks_for_user(uint8 user_id)
// Simple insertion sort for small arrays — sort task pointers by due_date // Simple insertion sort for small arrays — sort task pointers by due_date
// ascending // ascending
internal void sort_tasks_by_due_date(task_t **arr, int count) void sort_tasks_by_due_date(task_t **arr, int count)
{ {
for (int i = 1; i < count; i++) for (int i = 1; i < count; i++)
{ {
@@ -90,22 +93,23 @@ internal void sort_tasks_by_due_date(task_t **arr, int count)
// Populate dummy tasks on boot for development iteration. // Populate dummy tasks on boot for development iteration.
// Uses relative offsets from current time so due dates always make sense. // Uses relative offsets from current time so due dates always make sense.
internal void seed_tasks() void seed_tasks()
{ {
int64 now = (int64)(esp_timer_get_time() / 1000000); int64 now = (int64)(esp_timer_get_time() / 1000000);
// Alice's tasks (user_id = 1) // Alice's tasks (user_id = 1) — mix of one-off and recurring
add_task(1, "Buy groceries", now + 86400); // +1 day add_task(1, "Buy groceries", now + 86400, PERIOD_MORNING);
add_task(1, "Review PR #42", now + 3600); // +1 hour add_task(1, "Review PR #42", now + 3600, PERIOD_AFTERNOON);
add_task(1, "Book dentist appointment", now + 172800); // +2 days add_task(1, "Book dentist appointment", now + 172800, PERIOD_MORNING);
add_task(1, "Update resume", now + 604800); // +7 days add_task(1, "Update resume", now + 604800, PERIOD_EVENING);
// Bob's tasks (user_id = 2) // Bob's tasks (user_id = 2) — some recurring routines
add_task(2, "Fix login bug", now + 7200); // +2 hours add_task(2, "Morning standup", 0, PERIOD_MORNING, 0x1F); // Mon-Fri
add_task(2, "Deploy staging", now + 43200); // +12 hours add_task(2, "Deploy staging", now + 43200, PERIOD_AFTERNOON);
add_task(2, "Write unit tests", now + 259200); // +3 days add_task(2, "Write unit tests", now + 259200, PERIOD_MORNING);
// Charlie's tasks (user_id = 3) // Charlie's tasks (user_id = 3) — kid routine examples
add_task(3, "Water plants", now + 1800); // +30 min add_task(3, "Breakfast", 0, PERIOD_MORNING, 0x1F); // Mon-Fri
add_task(3, "Call plumber", now + 86400); // +1 day add_task(3, "Homework", 0, PERIOD_AFTERNOON, 0x15); // Mon+Wed+Fri
add_task(3, "Bath time", 0, PERIOD_EVENING, 0x7F); // Every day
} }

View File

@@ -4,9 +4,10 @@
#include "types.hpp" #include "types.hpp"
// Data store operations for tasks // Data store operations for tasks
internal task_t *find_task(uint16 id); task_t *find_task(uint16 id);
internal task_t *add_task(uint8 user_id, const char *title, int64 due_date); task_t *add_task(uint8 user_id, const char *title, int64 due_date,
internal bool remove_task(uint16 id); uint8 period = PERIOD_MORNING, uint8 recurrence = 0);
internal void remove_tasks_for_user(uint8 user_id); bool remove_task(uint16 id);
internal void sort_tasks_by_due_date(task_t **arr, int count); void remove_tasks_for_user(uint8 user_id);
internal void seed_tasks(); void sort_tasks_by_due_date(task_t **arr, int count);
void seed_tasks();

View File

@@ -1,4 +1,4 @@
// GET /api/tasks/upcoming — Top 3 upcoming tasks per user (for Dashboard) // GET /api/tasks/upcoming — Today's tasks per user, grouped by period
#include "cJSON.h" #include "cJSON.h"
#include "esp_http_server.h" #include "esp_http_server.h"
@@ -20,36 +20,26 @@ internal esp_err_t api_tasks_upcoming_handler(httpd_req_t *req)
continue; continue;
// Collect incomplete tasks for this user // Collect incomplete tasks for this user
task_t *user_tasks[MAX_TASKS]; // Include: recurring tasks (any day) and one-off tasks
int count = 0;
for (int t = 0; t < MAX_TASKS; t++)
{
if (g_Tasks[t].active && g_Tasks[t].user_id == g_Users[u].id &&
!g_Tasks[t].completed)
{
user_tasks[count++] = &g_Tasks[t];
}
}
// Sort by due_date ascending
sort_tasks_by_due_date(user_tasks, count);
// Build user object with top 3
cJSON *user_obj = cJSON_CreateObject(); cJSON *user_obj = cJSON_CreateObject();
cJSON_AddNumberToObject(user_obj, "id", g_Users[u].id); cJSON_AddNumberToObject(user_obj, "id", g_Users[u].id);
cJSON_AddStringToObject(user_obj, "name", g_Users[u].name); cJSON_AddStringToObject(user_obj, "name", g_Users[u].name);
cJSON *tasks_arr = cJSON_AddArrayToObject(user_obj, "tasks"); cJSON *tasks_arr = cJSON_AddArrayToObject(user_obj, "tasks");
int limit = count < 3 ? count : 3;
for (int i = 0; i < limit; i++) for (int t = 0; t < MAX_TASKS; t++)
{ {
if (!g_Tasks[t].active || g_Tasks[t].completed ||
g_Tasks[t].user_id != g_Users[u].id)
continue;
cJSON *t_obj = cJSON_CreateObject(); cJSON *t_obj = cJSON_CreateObject();
cJSON_AddNumberToObject(t_obj, "id", user_tasks[i]->id); cJSON_AddNumberToObject(t_obj, "id", g_Tasks[t].id);
cJSON_AddStringToObject(t_obj, "title", user_tasks[i]->title); cJSON_AddStringToObject(t_obj, "title", g_Tasks[t].title);
cJSON_AddNumberToObject(t_obj, "due_date", cJSON_AddNumberToObject(t_obj, "due_date", (double)g_Tasks[t].due_date);
(double)user_tasks[i]->due_date); cJSON_AddNumberToObject(t_obj, "period", g_Tasks[t].period);
cJSON_AddBoolToObject(t_obj, "completed", user_tasks[i]->completed); 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(tasks_arr, t_obj);
} }

View File

@@ -1,6 +1,6 @@
// POST /api/tasks/update — Modify a task // POST /api/tasks/update — Modify a task
// Body: {"id":1, "title":"...", "due_date":..., "completed":true} // Body: {"id":1, "title":"...", "due_date":..., "period":0, "recurrence":0,
// All fields except "id" are optional // "completed":true} All fields except "id" are optional
#include "cJSON.h" #include "cJSON.h"
#include "esp_http_server.h" #include "esp_http_server.h"
@@ -58,6 +58,18 @@ internal esp_err_t api_tasks_update_handler(httpd_req_t *req)
task->due_date = (int64)due_date_item->valuedouble; 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"); cJSON *completed_item = cJSON_GetObjectItem(body, "completed");
if (cJSON_IsBool(completed_item)) if (cJSON_IsBool(completed_item))
{ {

View File

@@ -3,7 +3,7 @@
#include "api/users/store.hpp" #include "api/users/store.hpp"
// Find a user by ID, returns nullptr if not found // Find a user by ID, returns nullptr if not found
internal user_t *find_user(uint8 id) user_t *find_user(uint8 id)
{ {
for (int i = 0; i < MAX_USERS; i++) for (int i = 0; i < MAX_USERS; i++)
{ {
@@ -16,7 +16,7 @@ internal user_t *find_user(uint8 id)
} }
// Add a user, returns pointer to new user or nullptr if full // Add a user, returns pointer to new user or nullptr if full
internal user_t *add_user(const char *name) user_t *add_user(const char *name)
{ {
for (int i = 0; i < MAX_USERS; i++) for (int i = 0; i < MAX_USERS; i++)
{ {
@@ -32,7 +32,7 @@ internal user_t *add_user(const char *name)
} }
// Remove a user by ID, returns true if found and removed // Remove a user by ID, returns true if found and removed
internal bool remove_user(uint8 id) bool remove_user(uint8 id)
{ {
for (int i = 0; i < MAX_USERS; i++) for (int i = 0; i < MAX_USERS; i++)
{ {
@@ -47,8 +47,19 @@ internal bool remove_user(uint8 id)
return false; 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 // Populate dummy users on boot for development iteration
internal void seed_users() void seed_users()
{ {
add_user("Alice"); add_user("Alice");
add_user("Bob"); add_user("Bob");

View File

@@ -3,9 +3,9 @@
#include "types.hpp" #include "types.hpp"
#include "user.hpp" #include "user.hpp"
// Data store operations for users // Data store operations for users
internal user_t *find_user(uint8 id); user_t *find_user(uint8 id);
internal user_t *add_user(const char *name); user_t *add_user(const char *name);
internal bool remove_user(uint8 id); bool remove_user(uint8 id);
internal void seed_users(); user_t *update_user(uint8 id, const char *name);
void seed_users();

View File

@@ -3,4 +3,5 @@
#include "api/users/list.cpp" #include "api/users/list.cpp"
#include "api/users/add.cpp" #include "api/users/add.cpp"
#include "api/users/remove.cpp" #include "api/users/remove.cpp"
#include "api/users/update.cpp"
// clang-format on // clang-format on

View 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};

View File

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

View File

@@ -3,11 +3,13 @@
#include <string.h> #include <string.h>
// SDK // SDK
#include "driver/gpio.h"
#include "esp_err.h" #include "esp_err.h"
#include "esp_eth.h" #include "esp_eth.h"
#include "esp_eth_driver.h" #include "esp_eth_driver.h"
#include "esp_event.h" #include "esp_event.h"
#include "esp_log.h" #include "esp_log.h"
#include "esp_sleep.h"
#include "esp_wifi.h" #include "esp_wifi.h"
#include "ethernet_init.h" #include "ethernet_init.h"
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
@@ -18,9 +20,11 @@
#include "types.hpp" #include "types.hpp"
// Forward declarations // Forward declarations
#if CONFIG_CALENDINK_BLINK_IP
internal esp_err_t get_ip_info(esp_netif_ip_info_t *ip_info); internal esp_err_t get_ip_info(esp_netif_ip_info_t *ip_info);
internal void led_blink_number(int n, uint8_t r, uint8_t g, uint8_t b); internal void led_blink_number(int n, uint8_t r, uint8_t g, uint8_t b);
internal void blink_last_ip_octet(); internal void blink_last_ip_octet();
#endif
// Internal states // Internal states
internal esp_netif_ip_info_t s_current_ip_info = {}; internal esp_netif_ip_info_t s_current_ip_info = {};
@@ -70,6 +74,10 @@ void ethernet_event_handler(void *arg, esp_event_base_t event_base,
{ {
ESP_LOGI(kLogEthernet, "Ethernet Link Down"); ESP_LOGI(kLogEthernet, "Ethernet Link Down");
s_eth_link_up = false; s_eth_link_up = false;
if (s_semph_eth_link)
{
xSemaphoreGive(s_semph_eth_link);
}
} }
} }
@@ -157,7 +165,9 @@ internal esp_err_t connect_ethernet(bool blockUntilIPAcquired)
{ {
ESP_LOGI(kLogEthernet, "Waiting for IP address."); ESP_LOGI(kLogEthernet, "Waiting for IP address.");
xSemaphoreTake(s_semph_get_ip_addrs, portMAX_DELAY); xSemaphoreTake(s_semph_get_ip_addrs, portMAX_DELAY);
#if CONFIG_CALENDINK_BLINK_IP
blink_last_ip_octet(); blink_last_ip_octet();
#endif
} }
return ESP_OK; return ESP_OK;
@@ -182,10 +192,18 @@ internal esp_err_t check_ethernet_connection(uint32_t timeoutSeconds)
{ {
if (!xSemaphoreTake(s_semph_eth_link, pdMS_TO_TICKS(5000))) if (!xSemaphoreTake(s_semph_eth_link, pdMS_TO_TICKS(5000)))
{ {
ESP_LOGE(kLogEthernet, ESP_LOGE(
"No physical Ethernet link detected. Skipping DHCP wait."); kLogEthernet,
"No physical Ethernet link detected (Timeout). Skipping DHCP wait.");
return ESP_ERR_INVALID_STATE; // Special error to skip retries return ESP_ERR_INVALID_STATE; // Special error to skip retries
} }
if (!s_eth_link_up)
{
ESP_LOGE(kLogEthernet, "No physical Ethernet link detected "
"(Disconnected). Skipping DHCP wait.");
return ESP_ERR_INVALID_STATE;
}
} }
ESP_LOGI(kLogEthernet, "Waiting for IP address for %d seconds.", ESP_LOGI(kLogEthernet, "Waiting for IP address for %d seconds.",
@@ -193,7 +211,9 @@ internal esp_err_t check_ethernet_connection(uint32_t timeoutSeconds)
if (xSemaphoreTake(s_semph_get_ip_addrs, if (xSemaphoreTake(s_semph_get_ip_addrs,
pdMS_TO_TICKS(timeoutSeconds * 1000))) pdMS_TO_TICKS(timeoutSeconds * 1000)))
{ {
#if CONFIG_CALENDINK_BLINK_IP
blink_last_ip_octet(); blink_last_ip_octet();
#endif
return ESP_OK; return ESP_OK;
} }
else else
@@ -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_get_wifi_ip_addrs = nullptr;
internal SemaphoreHandle_t s_semph_wifi_link = nullptr; internal SemaphoreHandle_t s_semph_wifi_link = nullptr;
internal volatile bool s_wifi_link_up = false; internal volatile bool s_wifi_link_up = false;
internal TimerHandle_t s_wifi_reconnect_timer = nullptr;
#ifndef NDEBUG #ifndef NDEBUG
internal bool s_wifi_connected = false; internal bool s_wifi_connected = false;
#endif #endif
// Forward declaration for timer callback
internal void wifi_reconnect_timer_cb(TimerHandle_t xTimer);
void wifi_event_handler(void *arg, esp_event_base_t event_base, void wifi_event_handler(void *arg, esp_event_base_t event_base,
int32_t event_id, void *event_data) int32_t event_id, void *event_data)
{ {
@@ -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) 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; 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) 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() void teardown_wifi()
{ {
if (s_wifi_reconnect_timer != nullptr)
{
xTimerStop(s_wifi_reconnect_timer, 0);
xTimerDelete(s_wifi_reconnect_timer, 0);
s_wifi_reconnect_timer = nullptr;
}
if (s_semph_wifi_link != nullptr) if (s_semph_wifi_link != nullptr)
{ {
@@ -313,11 +317,6 @@ internal esp_err_t connect_wifi(const char *ssid, const char *password,
return ESP_ERR_NO_MEM; return ESP_ERR_NO_MEM;
} }
// Create a 5-second timer to avoid spamming connect requests
s_wifi_reconnect_timer =
xTimerCreate("wifi_recon", pdMS_TO_TICKS(5000), pdFALSE, (void *)0,
wifi_reconnect_timer_cb);
// esp_netif_init() is already called by Ethernet, but safe to call multiple // esp_netif_init() is already called by Ethernet, but safe to call multiple
// times or we can assume it's initialized. // times or we can assume it's initialized.
s_wifi_netif = esp_netif_create_default_wifi_sta(); s_wifi_netif = esp_netif_create_default_wifi_sta();
@@ -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_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
ESP_ERROR_CHECK(esp_wifi_start()); ESP_ERROR_CHECK(esp_wifi_start());
// Set Power Save mode based on sdkconfig selection
#if defined(CONFIG_CALENDINK_WIFI_PS_MAX_MODEM)
ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_MAX_MODEM));
#elif defined(CONFIG_CALENDINK_WIFI_PS_NONE)
ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE));
#else
ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_MIN_MODEM));
#endif
ESP_ERROR_CHECK(esp_wifi_connect()); ESP_ERROR_CHECK(esp_wifi_connect());
ESP_ERROR_CHECK(esp_register_shutdown_handler(&teardown_wifi)); ESP_ERROR_CHECK(esp_register_shutdown_handler(&teardown_wifi));
@@ -351,7 +360,9 @@ internal esp_err_t connect_wifi(const char *ssid, const char *password,
{ {
ESP_LOGI(kLogWifi, "Waiting for IP address."); ESP_LOGI(kLogWifi, "Waiting for IP address.");
xSemaphoreTake(s_semph_get_wifi_ip_addrs, portMAX_DELAY); xSemaphoreTake(s_semph_get_wifi_ip_addrs, portMAX_DELAY);
#if CONFIG_CALENDINK_BLINK_IP
blink_last_ip_octet(); blink_last_ip_octet();
#endif
} }
return ESP_OK; return ESP_OK;
@@ -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 // Wait up to 10000ms for the physical link to associate with the AP
if (!s_wifi_link_up) if (!s_wifi_link_up)
{ {
// If the timer isn't already running, kickstart a connection attempt now ESP_LOGI(kLogWifi,
if (s_wifi_reconnect_timer != nullptr && "Physical link is down. Requesting immediate AP association...");
xTimerIsTimerActive(s_wifi_reconnect_timer) == pdFALSE) esp_err_t err = esp_wifi_connect();
if (err != ESP_OK && err != ESP_ERR_WIFI_CONN)
{ {
ESP_LOGI(kLogWifi, ESP_LOGE(kLogWifi, "esp_wifi_connect failed: %s", esp_err_to_name(err));
"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));
}
} }
if (!xSemaphoreTake(s_semph_wifi_link, pdMS_TO_TICKS(10000))) if (!xSemaphoreTake(s_semph_wifi_link, pdMS_TO_TICKS(10000)))
{ {
ESP_LOGE(kLogWifi, "Failed to associate with WiFi AP."); ESP_LOGE(kLogWifi, "Failed to associate with WiFi AP (Timeout).");
return ESP_ERR_TIMEOUT; // Return timeout so main.cpp triggers a retry return ESP_ERR_TIMEOUT; // Return timeout so main.cpp triggers a retry
} }
// After semaphore is taken, check if it was because of a disconnect
if (!s_wifi_link_up)
{
ESP_LOGE(kLogWifi, "Failed to associate with WiFi AP (Disconnected).");
return ESP_ERR_TIMEOUT;
}
} }
ESP_LOGI(kLogWifi, "Waiting for IP address for %d seconds.", timeoutSeconds); ESP_LOGI(kLogWifi, "Waiting for IP address for %d seconds.", timeoutSeconds);
if (xSemaphoreTake(s_semph_get_wifi_ip_addrs, if (xSemaphoreTake(s_semph_get_wifi_ip_addrs,
pdMS_TO_TICKS(timeoutSeconds * 1000))) pdMS_TO_TICKS(timeoutSeconds * 1000)))
{ {
#if CONFIG_CALENDINK_BLINK_IP
blink_last_ip_octet(); blink_last_ip_octet();
#endif
return ESP_OK; return ESP_OK;
} }
else else
@@ -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) internal esp_err_t get_ip_info(esp_netif_ip_info_t *ip_info)
{ {
*ip_info = s_current_ip_info; *ip_info = s_current_ip_info;
return ESP_OK; return ESP_OK;
} }
internal void led_blink_number(int n, uint8_t r, uint8_t g, uint8_t b);
internal void blink_last_ip_octet() internal void blink_last_ip_octet()
{ {
esp_netif_ip_info_t ip_info; esp_netif_ip_info_t ip_info;
@@ -438,3 +452,4 @@ internal void blink_last_ip_octet()
led_blink_number(u, 255, 255, 255); led_blink_number(u, 255, 255, 255);
} }
} }
#endif

View File

@@ -19,6 +19,7 @@
#include "api/ota/status.cpp" #include "api/ota/status.cpp"
#include "api/system/info.cpp" #include "api/system/info.cpp"
#include "api/system/reboot.cpp" #include "api/system/reboot.cpp"
#include "api/display/unity.cpp"
#include "api/tasks/unity.cpp" #include "api/tasks/unity.cpp"
#include "api/users/unity.cpp" #include "api/users/unity.cpp"
@@ -28,11 +29,53 @@ constexpr uint8 kGZ_Extension_Length = sizeof(".gz") - 1;
#define FILE_PATH_MAX (ESP_VFS_PATH_MAX + 128) #define FILE_PATH_MAX (ESP_VFS_PATH_MAX + 128)
#define SCRATCH_BUFSIZE 4096 #define SCRATCH_BUFSIZE 4096
#define MAX_SCRATCH_BUFFERS 10
typedef struct typedef struct
{ {
char scratch[SCRATCH_BUFSIZE]; char *buffers[MAX_SCRATCH_BUFFERS];
} http_server_data_t; 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 #ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES
// Set HTTP response content type according to file extension // Set HTTP response content type according to file extension
@@ -138,8 +181,14 @@ internal esp_err_t static_file_handler(httpd_req_t *req)
set_content_type_from_file(req, filepath); set_content_type_from_file(req, filepath);
http_server_data_t *rest_context = (http_server_data_t *)req->user_ctx; char *chunk = get_scratch_buffer();
char *chunk = rest_context->scratch; if (chunk == NULL)
{
close(fd);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Server busy");
return ESP_FAIL;
}
ssize_t read_bytes; ssize_t read_bytes;
do do
@@ -155,6 +204,7 @@ internal esp_err_t static_file_handler(httpd_req_t *req)
{ {
close(fd); close(fd);
ESP_LOGE(TAG, "File sending failed!"); ESP_LOGE(TAG, "File sending failed!");
free_scratch_buffer(chunk);
httpd_resp_sendstr_chunk(req, NULL); // Abort sending httpd_resp_sendstr_chunk(req, NULL); // Abort sending
return ESP_FAIL; return ESP_FAIL;
} }
@@ -162,6 +212,7 @@ internal esp_err_t static_file_handler(httpd_req_t *req)
} while (read_bytes > 0); } while (read_bytes > 0);
close(fd); close(fd);
free_scratch_buffer(chunk);
httpd_resp_send_chunk(req, NULL, 0); // End response httpd_resp_send_chunk(req, NULL, 0); // End response
return ESP_OK; return ESP_OK;
@@ -186,6 +237,7 @@ internal httpd_handle_t start_webserver(void)
esp_vfs_littlefs_conf_t conf = {}; esp_vfs_littlefs_conf_t conf = {};
conf.base_path = "/www"; conf.base_path = "/www";
conf.partition_label = g_Active_WWW_Partition == 0 ? "www_0" : "www_1"; conf.partition_label = g_Active_WWW_Partition == 0 ? "www_0" : "www_1";
ESP_LOGI(TAG, "Mounting LittleFS partition: %s", conf.partition_label);
conf.format_if_mount_failed = false; conf.format_if_mount_failed = false;
conf.dont_mount = false; conf.dont_mount = false;
esp_err_t ret = esp_vfs_littlefs_register(&conf); esp_err_t ret = esp_vfs_littlefs_register(&conf);
@@ -209,17 +261,13 @@ internal httpd_handle_t start_webserver(void)
ESP_LOGI(TAG, "LittleFS mounted on /www"); ESP_LOGI(TAG, "LittleFS mounted on /www");
} }
#endif #endif
http_server_data_t *rest_context =
(http_server_data_t *)calloc(1, sizeof(http_server_data_t));
if (rest_context == NULL)
{
ESP_LOGE(TAG, "No memory for rest context");
return NULL;
}
httpd_config_t config = HTTPD_DEFAULT_CONFIG(); httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.uri_match_fn = httpd_uri_match_wildcard; config.uri_match_fn = httpd_uri_match_wildcard;
config.max_uri_handlers = 20; config.max_uri_handlers = 20;
config.max_open_sockets = 24;
config.lru_purge_enable = true;
config.stack_size = 16384;
httpd_handle_t server = NULL; httpd_handle_t server = NULL;
ESP_LOGI(TAG, "Starting HTTP Server on port: '%d'", config.server_port); ESP_LOGI(TAG, "Starting HTTP Server on port: '%d'", config.server_port);
@@ -236,6 +284,7 @@ internal httpd_handle_t start_webserver(void)
// Register system API routes // Register system API routes
httpd_register_uri_handler(server, &api_system_info_uri); httpd_register_uri_handler(server, &api_system_info_uri);
httpd_register_uri_handler(server, &api_system_reboot_uri); httpd_register_uri_handler(server, &api_system_reboot_uri);
httpd_register_uri_handler(server, &api_display_image_uri);
httpd_register_uri_handler(server, &api_ota_status_uri); httpd_register_uri_handler(server, &api_ota_status_uri);
httpd_register_uri_handler(server, &api_ota_frontend_uri); httpd_register_uri_handler(server, &api_ota_frontend_uri);
httpd_register_uri_handler(server, &api_ota_firmware_uri); httpd_register_uri_handler(server, &api_ota_firmware_uri);
@@ -244,6 +293,7 @@ internal httpd_handle_t start_webserver(void)
// Register todo list API routes // Register todo list API routes
httpd_register_uri_handler(server, &api_users_get_uri); 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_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_users_delete_uri);
httpd_register_uri_handler(server, &api_tasks_upcoming_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_get_uri);
@@ -251,16 +301,18 @@ internal httpd_handle_t start_webserver(void)
httpd_register_uri_handler(server, &api_tasks_update_uri); httpd_register_uri_handler(server, &api_tasks_update_uri);
httpd_register_uri_handler(server, &api_tasks_delete_uri); httpd_register_uri_handler(server, &api_tasks_delete_uri);
// Populate dummy data for development // Populate dummy data for development (debug builds only)
#ifndef NDEBUG
seed_users(); seed_users();
seed_tasks(); seed_tasks();
#endif
#ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES #ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES
// Register static file handler last as a catch-all wildcard if deployed // Register static file handler last as a catch-all wildcard if deployed
httpd_uri_t static_get_uri = {.uri = "/*", httpd_uri_t static_get_uri = {.uri = "/*",
.method = HTTP_GET, .method = HTTP_GET,
.handler = static_file_handler, .handler = static_file_handler,
.user_ctx = rest_context}; .user_ctx = NULL};
httpd_register_uri_handler(server, &static_get_uri); httpd_register_uri_handler(server, &static_get_uri);
#endif #endif
@@ -268,18 +320,16 @@ internal httpd_handle_t start_webserver(void)
} }
ESP_LOGE(TAG, "Error starting server!"); ESP_LOGE(TAG, "Error starting server!");
free(rest_context);
return NULL; return NULL;
} }
internal void stop_webserver(httpd_handle_t server) internal void stop_webserver(httpd_handle_t server, uint8_t partition_index)
{ {
if (server) if (server)
{ {
httpd_stop(server); httpd_stop(server);
#ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES #ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES
esp_vfs_littlefs_unregister(g_Active_WWW_Partition == 0 ? "www_0" esp_vfs_littlefs_unregister(partition_index == 0 ? "www_0" : "www_1");
: "www_1");
#endif #endif
} }
} }

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}

View 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
View 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);
}

View 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();

View File

@@ -1,15 +1,18 @@
// STD Lib // STD Lib
#include <stdio.h> #include <stdio.h>
#include <string.h>
// SDK // SDK
#include "esp_log.h" #include "esp_log.h"
#include "esp_ota_ops.h" #include "esp_ota_ops.h"
#include "esp_pm.h"
#include "esp_psram.h" #include "esp_psram.h"
#include "esp_system.h" #include "esp_system.h"
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/task.h" #include "freertos/task.h"
#include "nvs.h" #include "nvs.h"
#include "nvs_flash.h" #include "nvs_flash.h"
#include "sdkconfig.h"
#include "soc/gpio_num.h" #include "soc/gpio_num.h"
// Project headers // Project headers
@@ -21,15 +24,46 @@
#include "led_status.cpp" #include "led_status.cpp"
#include "connect.cpp" #include "connect.cpp"
#include "http_server.cpp" #include "http_server.cpp"
#include "mdns_service.cpp"
#include "udp_logger.cpp"
#include "lodepng_alloc.cpp"
#include "lodepng/lodepng.cpp"
#include "lv_setup.cpp"
// clang-format on // clang-format on
internal 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() extern "C" void app_main()
{ {
printf("Hello, Calendink OTA! [V1.1]\n"); ESP_LOGI(kTagMain, "Hello, Calendink OTA! [V0.1.1]");
ESP_LOGI(kTagMain, "PSRAM size: %d bytes", esp_psram_get_size());
printf("PSRAM size: %d bytes\n", esp_psram_get_size()); #if CONFIG_PM_ENABLE
esp_pm_config_t pm_config = {};
pm_config.max_freq_mhz = 240;
pm_config.min_freq_mhz = 40;
#if CONFIG_CALENDINK_ALLOW_LIGHT_SLEEP
pm_config.light_sleep_enable = true;
#else
pm_config.light_sleep_enable = false;
#endif
esp_pm_configure(&pm_config);
ESP_LOGI(kTagMain, "Dynamic Power Management initialized. Light sleep %s.",
pm_config.light_sleep_enable ? "ENABLED" : "DISABLED");
#endif
httpd_handle_t web_server = NULL; httpd_handle_t web_server = NULL;
@@ -46,20 +80,24 @@ extern "C" void app_main()
{ {
// Read active www partition from NVS // Read active www partition from NVS
err = nvs_get_u8(my_handle, "www_part", &g_Active_WWW_Partition); err = nvs_get_u8(my_handle, "www_part", &g_Active_WWW_Partition);
if (err == ESP_OK)
{
ESP_LOGI(kTagMain, "NVS: Found active www partition: %d",
g_Active_WWW_Partition);
}
if (err == ESP_ERR_NVS_NOT_FOUND) if (err == ESP_ERR_NVS_NOT_FOUND)
{ {
// First boot (no NVS key yet): default to www_0 // First boot (no NVS key yet): default to www_0
// This ensures that after a fresh USB flash (which only writes www_0), ESP_LOGI(kTagMain, "No www_part in NVS, defaulting to 0.");
// we start from the correct partition.
printf("No www_part in NVS, defaulting to 0.\n");
g_Active_WWW_Partition = 0; g_Active_WWW_Partition = 0;
nvs_set_u8(my_handle, "www_part", 0); nvs_set_u8(my_handle, "www_part", 0);
nvs_commit(my_handle); nvs_commit(my_handle);
} }
else if (err != ESP_OK) else if (err != ESP_OK)
{ {
printf("Error reading www_part from NVS: %s\n", esp_err_to_name(err)); ESP_LOGE(kTagMain, "Error reading www_part from NVS: %s",
esp_err_to_name(err));
g_Active_WWW_Partition = 0; g_Active_WWW_Partition = 0;
} }
@@ -71,7 +109,78 @@ extern "C" void app_main()
} }
else 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()); ESP_ERROR_CHECK(esp_event_loop_create_default());
@@ -99,7 +208,7 @@ extern "C" void app_main()
if (result == ESP_ERR_INVALID_STATE) 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; break;
} }
@@ -116,7 +225,7 @@ extern "C" void app_main()
if (result != ESP_OK) if (result != ESP_OK)
{ {
printf("Ethernet failed, trying wifi\n"); ESP_LOGW(kTagMain, "Ethernet failed, trying wifi");
disconnect_ethernet(); disconnect_ethernet();
g_Ethernet_Initialized = false; g_Ethernet_Initialized = false;
@@ -153,39 +262,109 @@ extern "C" void app_main()
if (result != ESP_OK) if (result != ESP_OK)
{ {
printf("Wifi failed.\n"); ESP_LOGE(kTagMain, "Wifi failed.");
goto shutdown; goto shutdown;
} }
set_led_status(led_status::ReadyWifi); set_led_status(led_status::ReadyWifi);
printf("Will use Wifi!\n"); ESP_LOGI(kTagMain, "Will use Wifi!");
} }
else else
{ {
set_led_status(led_status::ReadyEthernet); set_led_status(led_status::ReadyEthernet);
printf("Will use Ethernet!\n"); ESP_LOGI(kTagMain, "Will use Ethernet!");
} }
printf("Connected!\n"); ESP_LOGI(kTagMain, "Connected! IP acquired.");
// Mark the current app as valid to cancel rollback #if !defined(NDEBUG)
esp_ota_mark_app_valid_cancel_rollback(); start_udp_logging(514);
#endif
// Start LVGL
ESP_LOGI(kTagMain, "ABOUT TO START LVGL");
vTaskDelay(pdMS_TO_TICKS(500));
setup_lvgl();
ESP_LOGI(kTagMain, "LVGL STARTED");
vTaskDelay(pdMS_TO_TICKS(500));
// Start the webserver // Start the webserver
web_server = start_webserver(); web_server = start_webserver();
// Keep the main task alive indefinitely // Start mDNS
while (true) 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: shutdown:
printf("Shutting down.\n"); ESP_LOGE(kTagMain, "Shutting down.");
if (web_server) if (web_server)
{ {
stop_webserver(web_server); stop_webserver(web_server, g_Active_WWW_Partition);
web_server = NULL; web_server = NULL;
} }

View 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);
}

View File

@@ -5,14 +5,24 @@
#include "types.hpp" #include "types.hpp"
#include "user.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 struct task_t
{ {
uint16 id; // Auto-assigned (165535, 0 = empty slot) char title[64]; // Task description
uint8 user_id; // Owner (matches user_t.id) int64 due_date; // Unix timestamp (seconds) - used when recurrence is 0
char title[64]; // Task description uint16 id; // Auto-assigned (165535, 0 = empty slot)
int64 due_date; // Unix timestamp (seconds) uint8 user_id; // Owner (matches user_t.id)
bool completed; // Done flag uint8 recurrence; // Bitmask: bit0=Mon, bit1=Tue, ..., bit6=Sun. 0=none
bool active; // Slot in use 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; constexpr int MAX_TASKS = 32;

View 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);
}

View File

@@ -1,3 +1,98 @@
CONFIG_IDF_TARGET="esp32s3"
CONFIG_PARTITION_TABLE_CUSTOM=y CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
CONFIG_PARTITION_TABLE_FILENAME="partitions.csv" CONFIG_PARTITION_TABLE_FILENAME="partitions.csv"
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
CONFIG_LWIP_MAX_SOCKETS=32
CONFIG_PM_ENABLE=y
CONFIG_FREERTOS_USE_TICKLESS_IDLE=y
# W5500 Ethernet Configuration
CONFIG_ETH_USE_SPI_ETHERNET=y
CONFIG_ETH_SPI_ETHERNET_W5500=y
CONFIG_ETHERNET_SPI_USE_W5500=1
CONFIG_ETHERNET_SPI_DEV0_W5500=y
CONFIG_ETHERNET_SPI_DEV0_ID=2
CONFIG_ETHERNET_SPI_HOST=1
CONFIG_ETHERNET_SPI_SCLK_GPIO=13
CONFIG_ETHERNET_SPI_MOSI_GPIO=11
CONFIG_ETHERNET_SPI_MISO_GPIO=12
CONFIG_ETHERNET_SPI_CLOCK_MHZ=32
CONFIG_ETHERNET_SPI_CS0_GPIO=14
CONFIG_ETHERNET_SPI_INT0_GPIO=10
CONFIG_ETHERNET_SPI_PHY_RST0_GPIO=9
CONFIG_ETHERNET_SPI_PHY_ADDR0=1
CONFIG_ETHERNET_SPI_AUTOCONFIG_MAC_ADDR0=y
CONFIG_ETHERNET_SPI_POLLING0_MS=0
# Enable PSRAM
CONFIG_SPIRAM=y
CONFIG_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

View 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.

View 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.

View 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*