add bed cooler project.

This commit is contained in:
2026-06-28 13:15:53 -05:00
commit 0f2a03044c
46 changed files with 3597 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch

10
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,10 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"platformio.platformio-ide"
],
"unwantedRecommendations": [
"ms-vscode.cpptools-extension-pack"
]
}

411
README.md Normal file
View File

@@ -0,0 +1,411 @@
# Boilerplate project for ESP32 (c3) development.
## Specifications
### Summary
This project is a starting point for ESP32-C3 development.
It provides basic "infrastructure" and "Framework" for specific developments.
### Functionality
- Enables the user to connect to their WiFi network
- Has a mechanism to "Factory reset" the device
- Eases updates by providing OTA update mechanism using update-server URL
- Role based User Management
- Standard Roles
- Sysadmin (Can administer the system, update, change settings)
- UserAdmin (Can administer user accounts)
- WebUIConnect (Allows logging in to the Web UI)
- Debugger (Allowed to use API Test UI in the Web UI)
- Secure, Role based Rest API for all functions
- API functions access can be configured either as "Public" or a user role can be assigned to determine access (only users who have the corresponding rolle can call that API).
- User authentication needs to return a token that is needed to be passed into all rest API requests (unless the called API endpoint is public)
- All admin functions (including HTTPS configuration, logs, password changes, ...) are available in the API by default
- All API calls will return a JSON that returns
- Result of the last call (either success or detailed error description)
- If applicable, the result of the call
- There are 3 boilerplate APIs that are available publicly by default
- Control the on-board LED (set brightness; 0-> off; 100->full brightness)
- Ping (that returns the current uptime as JSON)
- Add (takes 2 integers and returns the result of adding those integers)
- Admin Web-UI
- Role based security configuration
- Roles are centrally maintained
- User management
- Users can change their password
- Users can be assigned zero or more roles
- Allows to interactively calling of the Rest APIs via the web UI
- The corresponding user token is preset with the current user's token by decfault, but it can be overwritten
- This functionality is only accessible when the user has the role Debugger
- Enables secrity configuration of the exposed API functions
- Configuration of logging
- log level
- log max size in non volatile memory (default 50kb)
- Viewing- and management of logs
- filter
- search
- view
- clear logs
- Networking configuration
- Host name
- DHCP or static IPv4 parameters
- HTTPS certificate configuration
- By default a self-signed certificate is used
- Allows the set up of certificates for HTTPS (file upload)
- Firmware update handling
- Upload new firmware via file selector
- Check URL for new firmware button (this will reach out to a configurable (in the code) URL to to try and find new firmware. If new firmware is available it offers to install.)
### Technical details
- The target platform is ESP32-C3 with Arduino Framework
- Memory is to be treated as a sparse resource, so the size of the code must be kept small
- The API layer exclusively uses JSON messages to communicate in both direcitons
- The Web UI is to be implemented as a Vanilla JS single page web site UI that communicates with the backend via the REST API.
- All documentation goes into the README.md file
- The Admin UI is implemented in an extendable way, so that developers can easily use the same mechnism to implement their own UIs
- The "factory reset" function should execute when a definable PIN (choose a good default) is pulled high- or low on boot and held that way for 10 seconds.
- When starting after initial flashing or factory reset, the device needs to act as an unsecured access point with a defined SSID .
- The user can then connect to that AP and is presented with a basic user interface (setup screen) where the user can
- Choose their WIFI
- Enter the password of their WIFI (can be left empty for unsecuried wifi)
- Change the admin useranme (defaults to "admin")
- Enter an Admin password (can be left empty)
- A submit button that submits the entered information
- Once the user entered submitted information, the information is stored in non volatile memory, and the device is restarted.
- On subsequent startups, the device looks for configuraiton stored in non volatile memory
- The factory reset functionality deletes the information from the non volatile memory, which will lead to the setup screen.
- Logging is done based on log level.
- Log levels are `Error`, `Warn`, `SecurityAudit`, `Info`, and `Debug`; `SecurityAudit` records security-relevant events such as login/logout, invalid bearer tokens, authorization failures, unknown API URLs/methods, and security configuration changes.
- The logs are stored in non volatile memory
- they need to be implemented as a ring buffer that occupies a configurable space in non volatile memory
- There is a standard API endpoint that returns the logs (restricted to user role Debugger)
### Source organization
The firmware is split by responsibility:
| Path | Responsibility |
| --- | --- |
| `src/main.cpp` | Arduino `setup()`/`loop()` and boot orchestration |
| `include/app.h` | Framework constants, state, structs, and function declarations |
| `include/custom_api.h` | Custom API handler declarations |
| `src/config/api_definitions.cpp` | Central API catalog, route handlers, and default role/public access mapping |
| `src/config/api_definitions_custom.cpp` | Custom API catalog and default public access mapping |
| `src/core/state.cpp` | Global state, project/device identity, hashing, persisted settings |
| `src/core/logging.cpp` | LittleFS log ring buffer |
| `src/core/auth.cpp` | Users, roles, tokens, and API authorization |
| `src/core/device.cpp` | LED control, factory reset, and WiFi connection |
| `src/util/json_utils.cpp` | Small JSON response and request parsing helpers |
| `src/web/ui.cpp` | LittleFS-backed HTML serving and captive-portal helper pages |
| `src/web/routes.cpp` | Page/captive route registration and generic API route registration from framework and custom API catalogs |
| `src/handlers/handlers_setup.cpp` | Setup and WiFi scan route handlers |
| `src/handlers/handlers_custom_api.cpp` | Custom API handlers for ping, add, and LED brightness |
| `src/handlers/handlers_api.cpp` | API ACL list/detail management handlers |
| `src/handlers/handlers_auth.cpp` | Login, users, roles, and password route handlers |
| `src/handlers/handlers_admin.cpp` | Settings, logs, and certificate route handlers |
| `src/handlers/handlers_ota.cpp` | Firmware upload and OTA route handlers |
| `data/setup.html` | First-run provisioning UI served from LittleFS |
| `data/admin.html` | Admin UI served from LittleFS |
## Current implementation
The firmware in `src/main.cpp` implements the boilerplate as a compact Arduino ESP32-C3 application using built-in ESP32 Arduino libraries only:
- WiFi provisioning access point and first-run setup UI.
- Factory reset on boot by holding `FACTORY_RESET_PIN` high/low default behavior: GPIO4 held LOW for 10 seconds.
- Admin Web UI served from `/`.
- Token-based login with role checks.
- Configurable API access control where each route can be `PUBLIC` or require one role.
- Public boilerplate APIs: ping, add, and LED brightness.
- LED brightness is persisted in non-volatile memory, applied on boot, and loaded into the Admin UI slider.
- DS18B20 temperature sensor readings are exposed through the live Admin UI card and `/api/uptime` compatibility endpoint.
- User management with the standard roles `Sysadmin`, `UserAdmin`, `WebUIConnect`, and `Debugger`.
- Custom role management. System roles are protected and cannot be deleted.
- Active/inactive user accounts with role checkboxes in the Admin UI.
- API security management through a list/detail UI with a role dropdown per API.
- Ring-buffer logging in LittleFS with default maximum size of 50 KiB.
- Firmware and LittleFS filesystem upload OTA and update-from-URL hooks.
- HTTPS certificate storage API. The default Arduino `WebServer` runs HTTP; stored certificate material is available for applications that add TLS termination.
- Network settings for hostname plus DHCP/static IPv4 configuration.
### Web UI files
The setup and admin pages are stored as editable HTML files in the PlatformIO `data/` directory and are served from LittleFS by request path:
| URL | File |
| --- | --- |
| `/` in setup mode | `/setup.html` |
| `/` in normal mode | `/admin.html` |
| `/setup.html` | `/setup.html` |
| `/admin.html` | `/admin.html` |
After changing files in `data/`, upload the filesystem image as well as the firmware:
```sh
pio run -t uploadfs
```
In the PlatformIO UI, use the `Upload Firmware and Filesystem` project task when you want one action to upload both firmware and the LittleFS image that contains the web UI.
Uploading the filesystem image replaces the LittleFS contents, including stored log files. WiFi configuration, users, roles, and settings are stored in NVS preferences and are not part of that filesystem image.
### Provisioning
After initial flashing or factory reset, the device starts an unsecured access point:
```text
TSL-Embedded-XXXXXX
```
Connect to the access point and open:
```text
http://192.168.1.1/
```
While in setup mode, the device also runs a captive-portal DNS responder. Most phones and laptops will automatically open or suggest the setup page after joining the AP; unknown HTTP requests are redirected to `http://192.168.1.1/`.
The setup page lets you choose WiFi, set the WiFi password, set the admin username, and set the admin password. The default admin username is `admin`; the password may be empty. After submit, the device stores the configuration in non-volatile memory and restarts.
The default project name is `TSL-Embedded`. The firmware combines the project name with the chip suffix to form the device name, for example `TSL-Embedded-BDF5F0`. That name is used as the setup AP SSID, the station-mode WiFi hostname, and the mDNS hostname. Override the project name at compile time if needed:
```cpp
#define PROJECT_NAME "MyProject"
```
On normal boot, the device connects to the configured WiFi and serves the Admin UI at the IP printed to serial.
### Networking
Open `Networking` in the Admin UI to configure the station-mode host name and IP parameters.
The host name field is optional. If it is empty, the firmware uses the generated default host name based on `PROJECT_NAME` and the chip suffix, for example `TSL-Embedded-BDF5F0`. A custom host name must be 1-31 characters and may contain only letters, digits, and hyphens. It cannot start or end with a hyphen.
Address mode defaults to DHCP. To use a static IPv4 address, select `Static IPv4` and provide:
| Field | Required | Example |
| --- | --- | --- |
| Static IP | Yes | `192.168.1.50` |
| Gateway | Yes | `192.168.1.1` |
| Subnet mask | Yes | `255.255.255.0` |
| DNS 1 | No | `192.168.1.1` |
| DNS 2 | No | `8.8.8.8` |
Networking changes are stored immediately, but they apply on the next WiFi reconnect or reboot.
### Factory reset
The default reset pin is GPIO4. Hold GPIO4 LOW during boot for 10 seconds to clear stored configuration and logs, then the device restarts into setup mode.
The defaults can be changed at compile time:
```cpp
#define FACTORY_RESET_PIN 4
#define FACTORY_RESET_ACTIVE_LEVEL LOW
```
### DS18B20 temperature sensor
The live Admin UI card reads a DS18B20 one-wire temperature sensor. The default data pin is GPIO3, which is exposed as `D1` on the Seeed Studio XIAO ESP32C3.
Connect the 3-wire sensor as follows:
| Sensor wire | Connect to device | Notes |
| --- | --- | --- |
| Black | `GND` | Common ground |
| Yellow | GPIO3 / `D1` | One-wire data signal |
| Red | `3V3` | Use the board's 3.3 V output |
Add a 4.7 kOhm pull-up resistor between the Yellow data wire and the Red `3V3` wire. The DS18B20 bus needs this pull-up for reliable readings; do not rely on the ESP32 internal pull-up.
Keep the sensor powered from `3V3`, not `5V`, so the data line stays safe for the ESP32-C3. If you need a different data pin, override the default at compile time:
```cpp
#define DS18B20_PIN 3
```
### Authentication
Login:
```http
POST /api/login
Content-Type: application/json
{"username":"admin","password":""}
```
The response contains a bearer token. Pass it to protected APIs:
```http
Authorization: Bearer <token>
```
### HTTPS certificate configuration
The Admin UI stores HTTPS certificate material so applications built on this boilerplate can use it when adding TLS termination. The default Arduino `WebServer` used by this project serves HTTP only; uploading a certificate stores the material in NVS preferences but does not by itself switch the built-in web server to HTTPS.
To configure the stored certificate material:
1. Log in as a user with the `Sysadmin` role.
2. Open `Networking` -> `HTTPS Certificate`.
3. Select a certificate file and click `Save certificate`.
4. Use `Load current` to verify what is currently stored.
The upload file must be a plain text PEM-style file. Use UTF-8 or ASCII text and preserve the PEM block line breaks exactly. The file may use `.pem`, `.cer`, `.crt`, or `.txt`.
Valid content is one or more PEM blocks, for example a certificate chain:
```text
-----BEGIN CERTIFICATE-----
...base64 certificate data...
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
...base64 intermediate certificate data...
-----END CERTIFICATE-----
```
If your TLS integration expects both the certificate and private key from this stored value, put both PEM blocks in the same text file:
```text
-----BEGIN CERTIFICATE-----
...base64 certificate data...
-----END CERTIFICATE-----
-----BEGIN PRIVATE KEY-----
...base64 private key data...
-----END PRIVATE KEY-----
```
Do not upload binary DER, PKCS#12/PFX, or password-protected keystore files directly. Convert those to PEM text first. The equivalent API is:
```http
POST /api/cert
Authorization: Bearer <token>
Content-Type: application/json
{"certificate":"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n"}
```
### API response shape
Every API returns JSON with a `success` field. Errors include an `error` string:
```json
{"success":false,"error":"Authentication required"}
```
### Public boilerplate APIs
Ping:
```http
GET /api/ping
```
Add two integers:
```http
POST /api/add
Content-Type: application/json
{"a":1,"b":2}
```
Set LED brightness from 0 to 100:
```http
POST /api/led
Content-Type: application/json
{"brightness":50}
```
### Admin APIs
Protected APIs and their default roles:
| API | Role |
| --- | --- |
| `POST /api/logout` | `WebUIConnect` |
| `GET /api/me` | `WebUIConnect` |
| `GET /api/apis` | `Sysadmin` |
| `POST /api/apis` | `Sysadmin` |
| `GET /api/users` | `UserAdmin` |
| `POST /api/users` | `UserAdmin` |
| `POST /api/password` | `WebUIConnect` |
| `GET /api/settings` | `Sysadmin` |
| `POST /api/settings` | `Sysadmin` |
| `GET /api/logs` | `Debugger` |
| `POST /api/logs/clear` | `Debugger` |
| `GET /api/files` | `Debugger` |
| `GET /api/files/download` | `Debugger` |
| `POST /api/factory-reset` | `Sysadmin` |
| `POST /api/ota/check` | `Sysadmin` |
| `POST /api/ota/run` | `Sysadmin` |
| `POST /api/update` | `Sysadmin` |
| `GET /api/cert` | `Sysadmin` |
| `POST /api/cert` | `Sysadmin` |
Change API security:
```http
POST /api/apis
Authorization: Bearer <token>
Content-Type: application/json
{"path":"/api/led","method":"POST","role":"WebUIConnect"}
```
Use `"role":"PUBLIC"` to make a known API public.
### Firmware and filesystem updates
Because the Admin UI is stored in LittleFS, OTA releases are distributed as one combined update package. The package contains both the LittleFS filesystem image and the firmware image, so UI and firmware changes cannot drift apart during an update.
| Artifact | Purpose | Build output |
| --- | --- | --- |
| `firmware.bin` | Intermediate ESP32 application firmware | `.pio/build/seeed_xiao_esp32c3/firmware.bin` |
| `littlefs.bin` | Intermediate files from `data/`, including `admin.html` and `setup.html` | `.pio/build/seeed_xiao_esp32c3/littlefs.bin` |
| `update.tslpkg` | Single OTA package containing LittleFS plus firmware | `.pio/build/seeed_xiao_esp32c3/update.tslpkg` |
Create the single OTA package:
```sh
pio run
pio run -t buildfs
python scripts/create_update_package.py
```
For a direct USB flash during development:
```sh
pio run -t upload
pio run -t uploadfs
```
For OTA updates, host `update.tslpkg` on your update server. The Admin UI has one update package URL. The default is:
```text
http://example.com/update.tslpkg
```
The package format is intentionally small:
| Offset | Size | Value |
| --- | --- | --- |
| `0` | 8 | Magic bytes `TSLUPD1\0` |
| `8` | 4 | Header size, little-endian `uint32`, currently `32` |
| `12` | 4 | LittleFS image size, little-endian `uint32` |
| `16` | 4 | Firmware image size, little-endian `uint32` |
| `20` | 12 | Reserved, zero-filled |
| `32` | variable | LittleFS image bytes |
| `32 + littlefsSize` | variable | Firmware image bytes |
Upload an update package from the Admin UI or post multipart form data to:
```http
POST /api/update
```
`/api/ota/check` checks reachability and content length for the configured package URL. `/api/ota/run` streams the package from the URL, applies the LittleFS image first, applies the firmware image second, and restarts.
Installing an update package replaces the LittleFS partition, including stored log files. WiFi configuration, users, roles, API ACLs, and settings are stored in NVS preferences and are not part of the LittleFS image.

Binary file not shown.

Binary file not shown.

108
data/www/admin.html Normal file
View File

@@ -0,0 +1,108 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ESP32-C3 Admin</title>
<link rel="stylesheet" href="/css/app.css">
<script defer src="/js/app.js"></script>
<script type="module" src="/components/status-card.js"></script>
<script type="module" src="/components/uptime-card.js"></script>
<script type="module" src="/components/led-card.js"></script>
<script type="module" src="/components/add-card.js"></script>
</head>
<body>
<div id="appTitle" class="top">ESP32-C3 Admin</div>
<div id="busyChip" class="busyChip hidden"><span class="spinner"></span>Working...</div>
<main>
<div id="loginPanel" class="box">
<h2>Login</h2>
<label>User</label><input id="user" value="admin">
<label>Password</label><input id="pass" type="password">
<button id="loginBtn">Login</button>
<pre id="loginOut"></pre>
</div>
<div id="app" class="hidden">
<div class="tabs">
<button class="tab active" data-tab="dashboard">Dashboard</button>
<button class="tab" data-tab="access">Access</button>
<button class="tab" data-tab="network">Networking</button>
<button class="tab" data-tab="ops">System</button>
</div>
<div id="dashboard" class="panel active"><div class="grid">
<status-card></status-card>
<uptime-card></uptime-card>
<led-card></led-card>
<add-card></add-card>
</div></div>
<div id="access" class="panel">
<div class="tabs subtabs">
<button class="tab subtab active" data-subtab="users">User Management</button>
<button class="tab subtab" data-subtab="roles">Role Management</button>
<button class="tab subtab" data-subtab="apis">API Management</button>
</div>
<div id="users" class="subpanel active">
<section id="usersOverview">
<div class="toolbar"><h3>User Overview</h3><button id="usersRefresh">Refresh</button><button id="userNew" class="secondary">New</button></div>
<div id="userList" class="list"></div>
</section>
<section id="userDetail" class="hidden">
<div class="toolbar"><button id="userBack" class="secondary">Back</button><h3 id="userTitle" class="detailTitle">User Detail</h3></div>
<label>Name</label><input id="userName">
<label>Password</label><input id="userPass" type="password" placeholder="Leave blank to keep unchanged">
<label class="inlineCheck"><input id="userActive" type="checkbox" checked> Active</label>
<label>Roles</label><div id="userRoles" class="checkGrid"></div>
<div class="detailActions"><button id="userSave">Save user</button></div>
<pre id="usersOut"></pre>
</section>
</div>
<div id="roles" class="subpanel">
<section id="rolesOverview">
<div class="toolbar"><h3>Role Overview</h3><button id="rolesRefresh">Refresh</button><button id="roleNew" class="secondary">New</button></div>
<div id="rolesOut" class="list"></div>
</section>
<section id="roleDetail" class="hidden">
<div class="toolbar"><button id="roleBack" class="secondary">Back</button><h3 id="roleTitle" class="detailTitle">Role Detail</h3></div>
<label>Name</label><input id="roleName">
<div id="roleMeta" class="meta"></div>
<div id="roleUsage" class="usageGrid"></div>
<div class="detailActions"><button id="roleSave">Save role</button><button id="roleDelete" class="danger">Delete role</button></div>
<pre id="roleMsg"></pre>
</section>
</div>
<div id="apis" class="subpanel">
<section id="apisOverview">
<div class="toolbar"><h3>API Overview</h3></div>
<div id="apiList" class="list"></div>
</section>
<section id="apiDetail" class="hidden">
<div class="toolbar"><button id="apiBack" class="secondary">Back</button><h3 id="apiTitle" class="detailTitle">API Detail</h3></div>
<label>Path</label><input id="apiPath" readonly>
<label>Method</label><input id="apiMethod" readonly>
<label>Required role</label><select id="apiRole"></select>
<button id="apiSave">Save ACL</button>
<pre id="apisOut"></pre>
<h3>Test</h3>
<label>JSON body</label><textarea id="apiTestBody" placeholder='{"key":"value"}'></textarea>
<div class="detailActions"><button id="apiTest">Run test</button><button id="apiTestClear" class="secondary">Clear body</button></div>
<pre id="apiTestOut"></pre>
</section>
</div>
</div>
<div id="network" class="panel"><div class="grid">
<section><h3>Network</h3><label>Host name</label><input id="netHost" placeholder="Default host name"><label>Address mode</label><select id="netDhcp"><option value="true">DHCP</option><option value="false">Static IPv4</option></select><div id="netCurrent" class="muted"></div><label>Static IP</label><input id="netIp" inputmode="decimal" placeholder="192.168.1.50"><label>Gateway</label><input id="netGateway" inputmode="decimal" placeholder="192.168.1.1"><label>Subnet mask</label><input id="netSubnet" inputmode="decimal" placeholder="255.255.255.0"><div class="row"><div><label>DNS 1</label><input id="netDns1" inputmode="decimal" placeholder="192.168.1.1"></div><div><label>DNS 2</label><input id="netDns2" inputmode="decimal" placeholder="8.8.8.8"></div></div><button id="networkSaveBtn">Save network</button><pre id="networkOut"></pre></section>
<section><h3>HTTPS Certificate</h3><label>Certificate file</label><input id="certFile" type="file" accept=".pem,.cer,.crt,.txt,application/x-pem-file,application/pkix-cert"><button id="certSaveBtn">Save certificate</button><button id="certLoad" class="secondary">Load current</button><pre id="certOut"></pre></section>
</div></div>
<div id="ops" class="panel"><div class="grid">
<section><h3>Settings</h3><label>Log level</label><select id="logLevel"><option value="0">Error</option><option value="1">Warn</option><option value="2">SecurityAudit</option><option value="3" selected>Info</option><option value="4">Debug</option></select><label>Max log bytes</label><input id="logMax" type="number" value="51200"><label>Update package URL</label><input id="upd"><button id="settingsSave">Save</button><pre id="settingsOut"></pre></section>
<section><h3>Updates</h3><label>Update package</label><input id="pkg" type="file"><button id="uploadPkgBtn">Upload package</button><button id="otaCheck" class="secondary">Check URL</button><button id="otaRun" class="danger">Install from URL</button><pre id="fwOut"></pre></section>
<section><h3>Maintenance</h3><button id="configDownload">Download config</button><label>Restore configuration</label><input id="configFile" type="file" accept=".json,application/json"><button id="restoreConfigBtn" class="secondary">Restore config</button><button id="factoryReset" class="danger">Factory reset</button><pre id="maintOut"></pre></section>
<section class="fullWidth"><div class="toolbar"><h3>Files</h3><span id="filesPath" class="filePathChip">/</span></div><div id="fileList" class="list"></div><pre id="filesOut"></pre></section>
<section class="fullWidth"><h3>Logs</h3><div class="row"><button id="logsLoad">Load</button><button id="logsClear" class="danger">Clear</button></div><pre id="logsOut"></pre></section>
</div></div>
</div>
</main>
</body>
</html>

View File

@@ -0,0 +1,26 @@
class AddCard extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<style>
:host{display:block}section{height:100%;box-sizing:border-box}.row{display:flex;gap:8px;align-items:end}.row>*{flex:1}
</style>
<section>
<h3>Add API</h3>
<div class="row">
<div><label>A</label><input id="a" type="number" value="1"></div>
<div><label>B</label><input id="b" type="number" value="2"></div>
</div>
<button id="add">Add</button>
<pre id="out"></pre>
</section>`;
this.querySelector('#add').addEventListener('click', () => this.addNums());
}
async addNums() {
const a = +this.querySelector('#a').value;
const b = +this.querySelector('#b').value;
this.querySelector('#out').textContent = await window.App.req('POST', '/api/add', {a, b});
}
}
customElements.define('add-card', AddCard);

View File

@@ -0,0 +1,32 @@
class LedCard extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<style>
:host{display:block}section{height:100%;box-sizing:border-box}.rangeRow{display:flex;gap:12px;align-items:center}.rangeRow input{flex:1}.rangeRow output{min-width:3ch;text-align:right;font-weight:650}
</style>
<section>
<h3>LED</h3>
<label>Brightness</label>
<div class="rangeRow"><input id="brightness" type="range" min="0" max="100" value="0"><output id="value">0</output></div>
<button id="apply">Apply</button>
<pre id="out"></pre>
</section>`;
this.brightness = this.querySelector('#brightness');
this.value = this.querySelector('#value');
this.brightness.addEventListener('input', () => this.value.value = this.brightness.value);
this.querySelector('#apply').addEventListener('click', () => this.setLed());
window.addEventListener('app:settings-loaded', event => this.applySettings(event.detail.settings));
}
applySettings(settings) {
if (!settings || settings.ledBrightness === undefined) return;
this.brightness.value = settings.ledBrightness;
this.value.value = settings.ledBrightness;
}
async setLed() {
this.querySelector('#out').textContent = await window.App.req('POST', '/api/led', {brightness: +this.brightness.value});
}
}
customElements.define('led-card', LedCard);

View File

@@ -0,0 +1,26 @@
class StatusCard extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<style>
:host{display:block}section{height:100%;box-sizing:border-box}button+button{margin-left:6px}
</style>
<section>
<h3>Status</h3>
<button id="ping">Ping</button>
<button id="me" class="secondary">Me</button>
<pre id="out"></pre>
</section>`;
this.querySelector('#ping').addEventListener('click', () => this.call('GET', '/api/ping'));
this.querySelector('#me').addEventListener('click', () => this.loadMe());
}
async call(method, url) {
this.querySelector('#out').textContent = await window.App.req(method, url);
}
async loadMe() {
await this.call('GET', '/api/me');
}
}
customElements.define('status-card', StatusCard);

View File

@@ -0,0 +1,60 @@
class UptimeCard extends HTMLElement {
constructor() {
super();
this.source = null;
}
connectedCallback() {
this.innerHTML = `
<style>
:host{display:block}section{height:100%;box-sizing:border-box}.liveValue{font-size:34px;font-weight:700;line-height:1.15;margin-top:10px}.state{display:inline-flex;align-items:center;gap:6px;margin-top:6px}.dot{width:9px;height:9px;border-radius:50%;background:#b3261e}.dot.on{background:#188038}
</style>
<section>
<h3>Live temperature</h3>
<div class="muted">Server-Sent Events</div>
<div class="liveValue"><span id="temperature">--</span>&deg;C</div>
<div class="muted state"><span id="dot" class="dot"></span><span id="state">Disconnected</span></div>
</section>`;
window.addEventListener('app:login', event => this.start(event.detail.token));
window.addEventListener('beforeunload', () => this.stop());
if (window.App && window.App.token()) this.start(window.App.token());
}
disconnectedCallback() {
this.stop();
}
setState(text, connected) {
this.querySelector('#state').textContent = text;
this.querySelector('#dot').classList.toggle('on', !!connected);
}
start(token) {
this.stop();
if (!token || !window.EventSource) {
this.setState('Unavailable', false);
return;
}
this.setState('Connecting', false);
this.source = new EventSource('/api/uptime/events?token=' + encodeURIComponent(token));
this.source.addEventListener('open', () => this.setState('Connected', true));
this.source.addEventListener('temperature', event => {
try {
const json = JSON.parse(event.data);
const connected = !!json.sensorConnected && json.temperatureC !== null;
this.querySelector('#temperature').textContent = connected ? Number(json.temperatureC).toFixed(2) : '--';
this.setState(connected ? 'Connected' : 'Sensor unavailable', connected);
} catch (error) {
this.setState('Invalid event', false);
}
});
this.source.addEventListener('error', () => this.setState('Reconnecting', false));
}
stop() {
if (this.source) this.source.close();
this.source = null;
}
}
customElements.define('uptime-card', UptimeCard);

26
data/www/css/app.css Normal file
View File

@@ -0,0 +1,26 @@
:root{font-family:system-ui,-apple-system,Segoe UI,sans-serif;color:#202124;background:#f5f7f8}
body{margin:0}.top{background:#263238;color:white;padding:14px 18px;font-weight:650}
main{max-width:1040px;margin:0 auto;padding:18px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px}
section,.box{background:white;border:1px solid #d8dee2;border-radius:8px;padding:14px}
h3{margin-top:0}label{display:block;font-size:13px;margin:10px 0 4px}input,select,textarea,button{font:inherit}
input,select,textarea{width:100%;box-sizing:border-box;padding:9px;border:1px solid #b9c2c8;border-radius:6px}
textarea{min-height:110px;resize:vertical}button{border:0;border-radius:6px;background:#1261a6;color:white;padding:9px 12px;cursor:pointer;margin-top:10px}
button:disabled{opacity:.65;cursor:wait}.spinner{display:inline-block;width:1em;height:1em;border:2px solid rgba(255,255,255,.55);border-top-color:#fff;border-radius:50%;animation:spin .8s linear infinite;vertical-align:-2px;margin-right:6px}@keyframes spin{to{transform:rotate(360deg)}}
button.secondary{background:#607d8b}button.danger{background:#b3261e}
pre{white-space:pre-wrap;background:#111;color:#d7ffd7;padding:10px;border-radius:6px;max-height:300px;overflow:auto}
.row{display:flex;gap:8px;align-items:end}.row>*{flex:1}.hidden{display:none!important}.muted{color:#607d8b;font-size:13px}
.tabs{display:flex;gap:6px;border-bottom:1px solid #cfd8dc;margin:0 0 14px;overflow-x:auto}
.tab{background:transparent;color:#263238;border-radius:6px 6px 0 0;margin:0;padding:10px 14px;white-space:nowrap}
.tab.active{background:#1261a6;color:white}.panel{display:none}.panel.active{display:block}
.subtabs{margin-top:4px}.subtab{font-size:14px;padding:8px 12px}.subpanel{display:none}.subpanel.active{display:block}
.rangeRow{display:flex;gap:12px;align-items:center}.rangeRow input{flex:1}.rangeRow output{min-width:3ch;text-align:right;font-weight:650}
.list{display:flex;flex-direction:column;gap:6px;margin-top:10px}.item{background:#eef3f6;color:#263238;text-align:left;margin:0;padding:9px 12px;box-sizing:border-box}.item:hover{background:#dbe7ed}
.meta{display:flex;gap:8px;flex-wrap:wrap;margin-top:4px}.badge{background:#eef3f6;border-radius:6px;padding:4px 7px;font-size:12px;color:#263238}
.checkGrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:6px;margin-top:8px}.checkGrid label{display:flex;gap:6px;align-items:center;margin:0}.checkGrid input{width:auto}
.pill{display:inline-flex;gap:6px;align-items:center;background:#eef3f6;border-radius:6px;padding:5px 8px;margin:4px 4px 0 0}.pill button{margin:0;padding:3px 7px;background:#b3261e}
.detailTitle{margin:0 0 8px}.inlineCheck{display:flex;gap:8px;align-items:center;margin-top:10px}.inlineCheck input{width:auto}
.toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:10px}.toolbar button{margin-top:0}.detailActions{display:flex;gap:8px;flex-wrap:wrap}.detailActions button{flex:0 1 auto}
.fullWidth{grid-column:1/-1}.busyChip{position:fixed;right:14px;top:10px;z-index:10;background:#1261a6;color:white;border-radius:6px;padding:7px 10px;box-shadow:0 2px 10px rgba(0,0,0,.22);font-size:13px}
.fileList{gap:3px}.fileRow{display:grid;grid-template-columns:28px minmax(0,1fr) auto;gap:8px;align-items:center;background:#eef3f6;border-radius:6px;color:#263238;min-height:30px;padding:3px 8px;box-sizing:border-box}.fileRow[data-path]{cursor:pointer}.fileRow:hover{background:#dbe7ed}.filePath{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:13px}.filePathChip{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:13px;color:#263238;background:#eef3f6;border-radius:6px;padding:6px 8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%}.fileSize{color:#607d8b;font-size:12px;white-space:nowrap}.iconBtn,.folderIcon{width:24px;height:24px;display:inline-grid;place-items:center}.iconBtn{border:0;border-radius:5px;background:transparent;color:#1261a6;margin:0;padding:0}.iconBtn:hover{background:#c9dbe5}.iconBtn svg,.folderIcon svg{width:16px;height:16px}.folderIcon{color:#607d8b}
.apiRow{display:grid;grid-template-columns:minmax(180px,1fr) minmax(220px,auto);gap:10px;align-items:center;background:#eef3f6;border-radius:6px;padding:7px 9px}.apiPath{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:13px}.apiVerbs{display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end}.verbBadge{display:inline-flex;gap:6px;align-items:center;background:white;color:#263238;border:1px solid #cfd8dc;border-radius:6px;margin:0;padding:5px 7px;font-size:12px}.verbBadge:hover{background:#dbe7ed}.verbBadge span{color:#607d8b}
.usageGrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin:12px 0}.usageGrid h3{font-size:14px;margin:0 0 6px}.usageList{display:flex;flex-direction:column;gap:4px}.usageItem{display:flex;gap:6px;align-items:center;background:#eef3f6;border-radius:6px;padding:6px 8px;min-height:28px}.usageItem code{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px}

7
data/www/css/setup.css Normal file
View File

@@ -0,0 +1,7 @@
:root{font-family:system-ui,-apple-system,Segoe UI,sans-serif;color:#202124;background:#f5f7f8}
body{margin:0}.top{background:#263238;color:white;padding:14px 18px;font-weight:650}
main{max-width:520px;margin:0 auto;padding:18px}.box{background:white;border:1px solid #d8dee2;border-radius:8px;padding:14px}
label{display:block;font-size:13px;margin:10px 0 4px}input,button{font:inherit}
input{width:100%;box-sizing:border-box;padding:9px;border:1px solid #b9c2c8;border-radius:6px}
button{border:0;border-radius:6px;background:#1261a6;color:white;padding:9px 12px;cursor:pointer;margin-top:10px}
pre{white-space:pre-wrap;background:#111;color:#d7ffd7;padding:10px;border-radius:6px;max-height:220px;overflow:auto}

599
data/www/js/app.js Normal file
View File

@@ -0,0 +1,599 @@
let token = '';
let roles = [];
let users = [];
let apis = [];
let selectedUser = '';
let selectedRole = '';
let selectedApi = -1;
let currentFilePath = '/';
let filesLoaded = false;
let busyCount = 0;
const el = id => document.getElementById(id);
function setGlobalBusy(on) {
busyCount += on ? 1 : -1;
if (busyCount < 0) busyCount = 0;
el('busyChip').classList.toggle('hidden', busyCount == 0);
}
async function tracked(fn) {
setGlobalBusy(true);
try {
return await fn();
} finally {
setGlobalBusy(false);
}
}
async function apiFetch(url, options) {
return await tracked(() => fetch(url, options));
}
async function req(method, url, body) {
const options = {method, headers: {'Content-Type': 'application/json'}};
if (token) options.headers.Authorization = 'Bearer ' + token;
if (body !== undefined && method != 'GET') options.body = JSON.stringify(body);
const response = await apiFetch(url, options);
return await response.text();
}
window.App = {
apiFetch,
req,
token: () => token
};
function showTab(id) {
document.querySelectorAll('.tab[data-tab]').forEach(button => button.classList.toggle('active', button.dataset.tab == id));
document.querySelectorAll('.panel').forEach(panel => panel.classList.toggle('active', panel.id == id));
if (id == 'network') loadSettings();
if (id == 'ops' && !filesLoaded) loadFiles('/');
}
function showAccessTab(id) {
document.querySelectorAll('.subtab').forEach(button => button.classList.toggle('active', button.dataset.subtab == id));
document.querySelectorAll('.subpanel').forEach(panel => panel.classList.toggle('active', panel.id == id));
if (id == 'users') showUserOverview();
if (id == 'roles') showRoleOverview();
if (id == 'apis') showApiOverview();
}
async function doLogin() {
const text = await req('POST', '/api/login', {username: el('user').value, password: el('pass').value});
el('loginOut').textContent = text;
const json = JSON.parse(text);
if (!json.success) return;
token = json.token;
el('loginPanel').classList.add('hidden');
el('app').classList.remove('hidden');
window.dispatchEvent(new CustomEvent('app:login', {detail: {token}}));
loadSettings();
loadAccess();
loadFiles('/');
}
function esc(value) {
return String(value).replace(/[&<>"']/g, char => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[char]));
}
function roleNames() {
return roles.map(role => role.name);
}
function userHasRole(user, role) {
return (user.roles || '').split('|').includes(role);
}
function roleChecks(id, selected) {
el(id).innerHTML = roleNames().map(role => '<label><input type=checkbox value="' + esc(role) + '" ' + (selected.includes(role) ? 'checked' : '') + '> ' + esc(role) + '</label>').join('');
}
function selectedRoles() {
return [...el('userRoles').querySelectorAll('input:checked')].map(input => input.value);
}
function showUserOverview() {
el('usersOverview').classList.remove('hidden');
el('userDetail').classList.add('hidden');
}
function showRoleOverview() {
el('rolesOverview').classList.remove('hidden');
el('roleDetail').classList.add('hidden');
}
function showApiOverview() {
el('apisOverview').classList.remove('hidden');
el('apiDetail').classList.add('hidden');
}
function renderRoles() {
el('rolesOut').innerHTML = roles.map((role, index) => '<button class=item data-role-index="' + index + '"><strong>' + esc(role.name) + '</strong><span class=meta><span class=badge>' + (role.system ? 'System' : 'Custom') + '</span></span></button>').join('');
}
function renderRoleUsage(role) {
const roleUsers = users.filter(user => userHasRole(user, role.name)).sort((a, b) => a.username.localeCompare(b.username));
const roleApis = apis.filter(api => api.role == role.name).sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
const usersHtml = roleUsers.length
? roleUsers.map(user => '<div class="usageItem"><strong>' + esc(user.username) + '</strong><span class="badge">' + (user.active ? 'Active' : 'Inactive') + '</span></div>').join('')
: '<div class="muted">No users</div>';
const apisHtml = roleApis.length
? roleApis.map(api => '<div class="usageItem"><span class="badge">' + esc(api.method) + '</span><code>' + esc(api.path) + '</code></div>').join('')
: '<div class="muted">No API endpoints</div>';
el('roleUsage').innerHTML = '<div><h3>Users</h3><div class="usageList">' + usersHtml + '</div></div><div><h3>API endpoints</h3><div class="usageList">' + apisHtml + '</div></div>';
}
async function loadRoles() {
const json = JSON.parse(await req('GET', '/api/roles'));
if (!json.success) return;
roles = json.roles;
renderRoles();
roleOptions();
if (selectedUser && !el('userDetail').classList.contains('hidden')) selectUser(selectedUser);
}
function newRoleRecord() {
selectedRole = '';
el('rolesOverview').classList.add('hidden');
el('roleDetail').classList.remove('hidden');
el('roleTitle').textContent = 'New role';
el('roleName').value = '';
el('roleName').readOnly = false;
el('roleMeta').innerHTML = '';
el('roleUsage').innerHTML = '';
el('roleSave').classList.remove('hidden');
el('roleDelete').classList.add('hidden');
el('roleMsg').textContent = '';
}
function selectRole(index) {
const role = roles[index];
if (!role) return;
selectedRole = role.name;
el('rolesOverview').classList.add('hidden');
el('roleDetail').classList.remove('hidden');
el('roleTitle').textContent = role.name;
el('roleName').value = role.name;
el('roleName').readOnly = true;
el('roleMeta').innerHTML = '<span class=badge>' + (role.system ? 'System role' : 'Custom role') + '</span>';
renderRoleUsage(role);
el('roleSave').classList.add('hidden');
el('roleDelete').classList.toggle('hidden', role.system);
el('roleMsg').textContent = '';
}
async function addRole() {
const text = await req('POST', '/api/roles', {role: el('roleName').value});
el('roleMsg').textContent = text;
await loadAccess();
showRoleOverview();
}
async function deleteSelectedRole() {
if (!selectedRole) return;
const text = await req('DELETE', '/api/roles', {role: selectedRole});
el('roleMsg').textContent = text;
selectedRole = '';
await loadAccess();
showRoleOverview();
}
function renderUsers() {
el('userList').innerHTML = users.map(user => '<button class=item data-user="' + esc(user.username) + '"><strong>' + esc(user.username) + '</strong><span class=meta><span class=badge>' + (user.active ? 'Active' : 'Inactive') + '</span><span class=badge>' + esc(user.roles || 'No roles') + '</span></span></button>').join('');
}
async function loadUsers() {
const json = JSON.parse(await req('GET', '/api/users'));
if (!json.success) return;
users = json.users;
renderUsers();
if (selectedUser && !el('userDetail').classList.contains('hidden')) selectUser(selectedUser);
}
function newUserRecord() {
selectedUser = '';
el('usersOverview').classList.add('hidden');
el('userDetail').classList.remove('hidden');
el('userTitle').textContent = 'New user';
el('userName').value = '';
el('userName').readOnly = false;
el('userPass').value = '';
el('userActive').checked = true;
roleChecks('userRoles', []);
el('usersOut').textContent = '';
}
function selectUser(name) {
selectedUser = name;
const user = users.find(item => item.username == name);
if (!user) return;
el('usersOverview').classList.add('hidden');
el('userDetail').classList.remove('hidden');
el('userTitle').textContent = name;
el('userName').value = name;
el('userName').readOnly = true;
el('userPass').value = '';
el('userActive').checked = user.active;
roleChecks('userRoles', (user.roles || '').split('|'));
el('usersOut').textContent = '';
}
async function saveSelectedUser() {
const text = await req('POST', '/api/users', {username: el('userName').value, password: el('userPass').value, active: el('userActive').checked, roles: selectedRoles()});
el('usersOut').textContent = text;
selectedUser = el('userName').value;
await loadUsers();
showUserOverview();
}
function roleOptions() {
const options = '<option>PUBLIC</option>' + roleNames().map(role => '<option>' + esc(role) + '</option>').join('');
el('apiRole').innerHTML = options;
}
function renderApis() {
const groups = new Map();
apis.forEach((api, index) => {
if (!groups.has(api.path)) groups.set(api.path, []);
groups.get(api.path).push({...api, index});
});
const methodOrder = {GET: 1, POST: 2, PUT: 3, PATCH: 4, DELETE: 5};
el('apiList').innerHTML = [...groups.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([path, items]) => {
items.sort((a, b) => (methodOrder[a.method] || 99) - (methodOrder[b.method] || 99) || a.method.localeCompare(b.method));
const verbs = items.map(api => '<button class="verbBadge" data-api-index="' + api.index + '"><strong>' + esc(api.method) + '</strong><span>' + esc(api.role) + '</span></button>').join('');
return '<div class="apiRow"><div class="apiPath" title="' + esc(path) + '">' + esc(path) + '</div><div class="apiVerbs">' + verbs + '</div></div>';
})
.join('');
}
async function loadApis() {
const json = JSON.parse(await req('GET', '/api/apis'));
if (!json.success) return;
apis = json.apis;
renderApis();
if (selectedApi >= 0 && !el('apiDetail').classList.contains('hidden')) selectApi(selectedApi);
}
function selectApi(index) {
selectedApi = index;
const api = apis[index];
if (!api) return;
el('apisOverview').classList.add('hidden');
el('apiDetail').classList.remove('hidden');
el('apiTitle').textContent = api.method + ' ' + api.path;
el('apiPath').value = api.path;
el('apiMethod').value = api.method;
roleOptions();
el('apiRole').value = api.role;
el('apisOut').textContent = '';
el('apiTestOut').textContent = '';
}
async function saveSelectedApi() {
if (selectedApi < 0) return;
const text = await req('POST', '/api/apis', {path: el('apiPath').value, method: el('apiMethod').value, role: el('apiRole').value});
el('apisOut').textContent = text;
await loadApis();
}
function clearApiTestBody() {
el('apiTestBody').value = '';
}
async function testSelectedApi() {
if (selectedApi < 0) return;
const api = apis[selectedApi];
const body = el('apiTestBody').value.trim();
let payload;
if (body) {
try {
payload = JSON.parse(body);
} catch (error) {
el('apiTestOut').textContent = 'Invalid JSON body';
return;
}
}
el('apiTestOut').textContent = await req(api.method, api.path, payload);
}
async function loadAccess() {
await loadRoles();
await loadUsers();
await loadApis();
}
function fileName(path) {
const index = String(path).lastIndexOf('/');
return index >= 0 ? path.substring(index + 1) : path;
}
function fileIcon(file) {
if (file.directory) return '<span class="folderIcon" title="Folder"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7h7l2 2h9v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M3 7V5a2 2 0 0 1 2-2h4l2 2h4"/></svg></span>';
return '<button class="iconBtn" data-download="' + esc(file.path) + '" title="Download" aria-label="Download ' + esc(file.path) + '"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M5 21h14"/></svg></button>';
}
function fileSize(file) {
if (file.directory) return 'Folder';
return file.size + ' B';
}
function parentPath(path) {
if (!path || path == '/') return '/';
const end = path.lastIndexOf('/');
return end <= 0 ? '/' : path.substring(0, end);
}
function renderFiles(files) {
el('fileList').classList.add('fileList');
files.sort((a, b) => Number(b.directory) - Number(a.directory) || a.path.localeCompare(b.path));
const rows = currentFilePath == '/' ? [] : [{name: '..', path: parentPath(currentFilePath), directory: true, parent: true}];
rows.push(...files);
el('fileList').innerHTML = rows.map(file => '<div class="fileRow" ' + (file.directory ? 'data-path="' + esc(file.path) + '"' : '') + '>' + fileIcon(file) + '<div class="filePath" title="' + esc(file.path) + '">' + esc(file.name || file.path) + '</div><div class="fileSize">' + (file.parent ? '' : fileSize(file)) + '</div></div>').join('');
}
async function loadFiles(path = currentFilePath) {
const text = await req('GET', '/api/files?path=' + encodeURIComponent(path));
el('filesOut').textContent = '';
try {
const json = JSON.parse(text);
if (json.success) {
currentFilePath = json.path || path || '/';
filesLoaded = true;
el('filesPath').textContent = currentFilePath;
renderFiles(json.files || []);
if (!(json.files || []).length) el('filesOut').textContent = 'No files found';
} else {
el('filesOut').textContent = text;
}
} catch (error) {
el('filesOut').textContent = text;
}
}
async function downloadFile(path) {
const response = await apiFetch('/api/files/download?path=' + encodeURIComponent(path), {headers: {Authorization: 'Bearer ' + token}});
if (!response.ok) {
el('filesOut').textContent = await response.text();
return;
}
const blob = await response.blob();
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = fileName(path) || 'download';
document.body.appendChild(link);
link.click();
URL.revokeObjectURL(link.href);
link.remove();
}
async function downloadConfig() {
const response = await apiFetch('/api/config/export', {headers: {Authorization: 'Bearer ' + token}});
if (!response.ok) {
el('maintOut').textContent = await response.text();
return;
}
const blob = await response.blob();
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'config-backup.json';
document.body.appendChild(link);
link.click();
URL.revokeObjectURL(link.href);
link.remove();
el('maintOut').textContent = 'Configuration backup downloaded';
}
async function restoreConfig() {
if (!el('configFile').files.length) {
el('maintOut').textContent = 'Select a configuration JSON file first';
return;
}
if (!confirm('Restore configuration from this file? WiFi settings will not change.')) return;
setBusy('restoreConfigBtn', true, 'Restoring');
try {
await tracked(async () => {
const body = await el('configFile').files[0].text();
const response = await fetch('/api/config/import', {method: 'POST', headers: {'Content-Type': 'application/json', Authorization: 'Bearer ' + token}, body});
el('maintOut').textContent = await response.text();
if (response.ok) {
loadSettings();
loadAccess();
}
});
} catch (error) {
el('maintOut').textContent = 'Restore failed: ' + error.message;
} finally {
setBusy('restoreConfigBtn', false, 'Restore config');
}
}
async function loadCert() {
const text = await req('GET', '/api/cert');
try {
const json = JSON.parse(text);
el('certOut').textContent = json.success ? (json.certificate || 'No certificate stored') : text;
} catch (error) {
el('certOut').textContent = text;
}
}
async function saveCertFile() {
if (!el('certFile').files.length) {
el('certOut').textContent = 'Select a PEM/CER/CRT file first';
return;
}
setBusy('certSaveBtn', true, 'Saving');
try {
await tracked(async () => {
const certificate = await el('certFile').files[0].text();
el('certOut').textContent = await req('POST', '/api/cert', {certificate});
});
} catch (error) {
el('certOut').textContent = 'Certificate save failed: ' + error.message;
} finally {
setBusy('certSaveBtn', false, 'Save certificate');
}
}
async function factoryResetDevice() {
if (!confirm('Factory reset this device and restart?')) return;
el('maintOut').textContent = await req('POST', '/api/factory-reset');
}
async function loadLogs() {
const text = await req('GET', '/api/logs');
try {
const json = JSON.parse(text);
el('logsOut').textContent = json.success ? (json.logs || '') : text;
} catch (error) {
el('logsOut').textContent = text;
}
}
async function clearLogs() {
const text = await req('POST', '/api/logs/clear');
el('logsOut').textContent = text;
try {
if (JSON.parse(text).success) el('logsOut').textContent = 'Logs cleared';
} catch (error) {}
}
function syncNetworkMode() {
const staticMode = el('netDhcp').value == 'false';
['netIp', 'netGateway', 'netSubnet', 'netDns1', 'netDns2'].forEach(id => el(id).disabled = !staticMode);
}
async function loadSettings() {
const text = await req('GET', '/api/settings');
el('settingsOut').textContent = text;
const json = JSON.parse(text);
if (!json.success) return;
const settings = json.settings;
const projectName = settings.projectName || 'ESP32-C3';
el('appTitle').textContent = projectName + ' Admin';
document.title = projectName + ' Admin';
el('logLevel').value = settings.logLevel;
el('logMax').value = settings.maxLogBytes;
el('upd').value = settings.updateUrl;
el('netHost').value = settings.hostname || '';
el('netHost').placeholder = settings.defaultHostname || 'Default host name';
el('netDhcp').value = String(settings.dhcp !== false);
el('netIp').value = settings.ip || '';
el('netGateway').value = settings.gateway || '';
el('netSubnet').value = settings.subnet || '';
el('netDns1').value = settings.dns1 || '';
el('netDns2').value = settings.dns2 || '';
el('netCurrent').textContent = 'Current IP: ' + (settings.currentIp || 'not connected');
syncNetworkMode();
window.dispatchEvent(new CustomEvent('app:settings-loaded', {detail: {settings}}));
}
async function saveSettings() {
el('settingsOut').textContent = await req('POST', '/api/settings', {logLevel: +el('logLevel').value, maxLogBytes: +el('logMax').value, updateUrl: el('upd').value});
}
async function saveNetwork() {
setBusy('networkSaveBtn', true, 'Saving');
try {
const body = {hostname: el('netHost').value, dhcp: el('netDhcp').value == 'true', ip: el('netIp').value, gateway: el('netGateway').value, subnet: el('netSubnet').value, dns1: el('netDns1').value, dns2: el('netDns2').value};
const text = await req('POST', '/api/settings', body);
el('networkOut').textContent = text + '\nNetwork changes apply on the next WiFi reconnect or reboot.';
if (JSON.parse(text).success) await loadSettings();
} catch (error) {
el('networkOut').textContent = 'Network save failed: ' + error.message;
} finally {
setBusy('networkSaveBtn', false, 'Save network');
}
}
function setBusy(id, busy, label) {
const button = el(id);
if (!button) return;
button.disabled = busy;
button.innerHTML = busy ? '<span class=spinner></span>' + label : label;
}
async function uploadPkg() {
if (!el('pkg').files.length) {
el('fwOut').textContent = 'Select an update package first';
return;
}
setBusy('uploadPkgBtn', true, 'Uploading');
el('fwOut').textContent = 'Uploading update package...';
try {
const formData = new FormData();
formData.append('package', el('pkg').files[0]);
const response = await apiFetch('/api/update', {method: 'POST', headers: {Authorization: 'Bearer ' + token}, body: formData});
el('fwOut').textContent = await response.text();
} catch (error) {
el('fwOut').textContent = 'Upload failed: ' + error.message;
} finally {
setBusy('uploadPkgBtn', false, 'Upload package');
}
}
async function checkOta() {
el('fwOut').textContent = await req('POST', '/api/ota/check');
}
async function runOta() {
el('fwOut').textContent = await req('POST', '/api/ota/run');
}
function bindEvents() {
el('loginBtn').addEventListener('click', doLogin);
document.querySelectorAll('.tab[data-tab]').forEach(button => button.addEventListener('click', () => showTab(button.dataset.tab)));
document.querySelectorAll('.subtab').forEach(button => button.addEventListener('click', () => showAccessTab(button.dataset.subtab)));
el('usersRefresh').addEventListener('click', loadUsers);
el('userNew').addEventListener('click', newUserRecord);
el('userBack').addEventListener('click', showUserOverview);
el('userSave').addEventListener('click', saveSelectedUser);
el('rolesRefresh').addEventListener('click', loadRoles);
el('roleNew').addEventListener('click', newRoleRecord);
el('roleBack').addEventListener('click', showRoleOverview);
el('roleSave').addEventListener('click', addRole);
el('roleDelete').addEventListener('click', deleteSelectedRole);
el('apiBack').addEventListener('click', showApiOverview);
el('apiSave').addEventListener('click', saveSelectedApi);
el('apiTest').addEventListener('click', testSelectedApi);
el('apiTestClear').addEventListener('click', clearApiTestBody);
el('rolesOut').addEventListener('click', event => {
const button = event.target.closest('[data-role-index]');
if (button) selectRole(+button.dataset.roleIndex);
});
el('userList').addEventListener('click', event => {
const button = event.target.closest('[data-user]');
if (button) selectUser(button.dataset.user);
});
el('apiList').addEventListener('click', event => {
const button = event.target.closest('[data-api-index]');
if (button) selectApi(+button.dataset.apiIndex);
});
el('netDhcp').addEventListener('change', syncNetworkMode);
el('networkSaveBtn').addEventListener('click', saveNetwork);
el('certSaveBtn').addEventListener('click', saveCertFile);
el('certLoad').addEventListener('click', loadCert);
el('settingsSave').addEventListener('click', saveSettings);
el('uploadPkgBtn').addEventListener('click', uploadPkg);
el('otaCheck').addEventListener('click', checkOta);
el('otaRun').addEventListener('click', runOta);
el('configDownload').addEventListener('click', downloadConfig);
el('restoreConfigBtn').addEventListener('click', restoreConfig);
el('factoryReset').addEventListener('click', factoryResetDevice);
el('fileList').addEventListener('click', event => {
const button = event.target.closest('[data-download]');
if (button) {
downloadFile(button.dataset.download);
return;
}
const directory = event.target.closest('.fileRow[data-path]');
if (directory) loadFiles(directory.dataset.path);
});
el('logsLoad').addEventListener('click', loadLogs);
el('logsClear').addEventListener('click', clearLogs);
}
bindEvents();

20
data/www/js/setup.js Normal file
View File

@@ -0,0 +1,20 @@
const ssid = document.getElementById('ssid');
const wifiPass = document.getElementById('wifiPass');
const admin = document.getElementById('admin');
const adminPass = document.getElementById('adminPass');
const out = document.getElementById('out');
async function scan() {
const response = await fetch('/api/wifi/scan');
const json = await response.json();
ssid.innerHTML = json.networks.map(network => `<option>${network.ssid}</option>`).join('');
}
async function save() {
const body = {ssid: ssid.value, wifiPass: wifiPass.value, admin: admin.value, adminPass: adminPass.value};
const response = await fetch('/api/setup', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)});
out.textContent = await response.text();
}
document.getElementById('saveSetup').addEventListener('click', save);
scan();

27
data/www/setup.html Normal file
View File

@@ -0,0 +1,27 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ESP32-C3 first-run setup</title>
<link rel="stylesheet" href="/css/setup.css">
<script defer src="/js/setup.js"></script>
</head>
<body>
<div class="top">ESP32-C3 first-run setup</div>
<main>
<section class="box">
<h2>Network and admin setup</h2>
<label>WiFi network</label>
<select id="ssid"></select>
<label>WiFi password</label>
<input id="wifiPass" type="password" autocomplete="off">
<label>Admin username</label>
<input id="admin" value="admin">
<label>Admin password</label>
<input id="adminPass" type="password" autocomplete="new-password">
<button id="saveSetup">Save and restart</button>
<pre id="out"></pre>
</section>
</main>
</body>
</html>

Binary file not shown.

Binary file not shown.

37
include/README Normal file
View File

@@ -0,0 +1,37 @@
This directory is intended for project header files.
A header file is a file containing C declarations and macro definitions
to be shared between several project source files. You request the use of a
header file in your project source file (C, C++, etc) located in `src` folder
by including it, with the C preprocessing directive `#include'.
```src/main.c
#include "header.h"
int main (void)
{
...
}
```
Including a header file produces the same results as copying the header file
into each source file that needs it. Such copying would be time-consuming
and error-prone. With a header file, the related declarations appear
in only one place. If they need to be changed, they can be changed in one
place, and programs that include the header file will automatically use the
new version when next recompiled. The header file eliminates the labor of
finding and changing all the copies as well as the risk that a failure to
find one copy will result in inconsistencies within a program.
In C, the convention is to give header files names that end with `.h'.
Read more about using header files in official GCC documentation:
* Include Syntax
* Include Operation
* Once-Only Headers
* Computed Includes
https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html

179
include/app.h Normal file
View File

@@ -0,0 +1,179 @@
#pragma once
#include <Arduino.h>
#include <DNSServer.h>
#include <Preferences.h>
#include <WebServer.h>
#include <WiFi.h>
#ifndef LED_BUILTIN
#define LED_BUILTIN 8
#endif
#ifndef FACTORY_RESET_PIN
#define FACTORY_RESET_PIN 4
#endif
#ifndef FACTORY_RESET_ACTIVE_LEVEL
#define FACTORY_RESET_ACTIVE_LEVEL LOW
#endif
#ifndef DS18B20_PIN
#define DS18B20_PIN 3
#endif
#ifndef PROJECT_NAME
#define PROJECT_NAME "TSL Bed Cooler"
#endif
extern const char *APP_VERSION;
extern const char *PROJECT_NAME_VALUE;
extern const char *DEFAULT_ADMIN;
extern const char *DEFAULT_UPDATE_URL;
extern const uint32_t FACTORY_RESET_HOLD_MS;
extern const size_t MAX_USERS;
extern const size_t MAX_TOKENS;
extern const bool DEFAULT_LED_INVERTED;
extern const byte DNS_PORT;
extern const IPAddress SETUP_AP_IP;
extern const IPAddress SETUP_AP_GATEWAY;
extern const IPAddress SETUP_AP_SUBNET;
extern const char *WWW_ROOT;
extern const char *LOG_DIR;
extern const char *LOG_FILE_PATH;
enum LogLevel : uint8_t {
LOG_ERROR = 0,
LOG_WARN = 1,
LOG_SECURITY_AUDIT = 2,
LOG_INFO = 3,
LOG_DEBUG = 4
};
struct User {
String name;
String passwordHash;
String roles;
bool active;
};
struct Token {
String value;
String user;
String roles;
uint32_t lastSeen;
};
typedef void (*RouteHandler)();
struct ApiDef {
const char *path;
const char *method;
const char *defaultRole;
bool publicByDefault;
RouteHandler handler;
RouteHandler uploadHandler;
};
extern WebServer server;
extern DNSServer dnsServer;
extern Preferences prefs;
extern Token tokens[];
extern ApiDef apiDefs[];
extern const size_t API_DEF_COUNT;
extern ApiDef customApiDefs[];
extern const size_t CUSTOM_API_DEF_COUNT;
extern LogLevel currentLogLevel;
extern size_t maxLogBytes;
extern bool setupMode;
extern uint8_t ledBrightness;
extern bool ledInverted;
extern String updateUrl;
extern String networkHostname;
extern bool networkDhcp;
extern String networkIp;
extern String networkGateway;
extern String networkSubnet;
extern String networkDns1;
extern String networkDns2;
String jsonEscape(const String &s);
String jsonOk(const String &payload = "");
String jsonError(const String &message);
void sendJson(int code, const String &body);
String requestBody();
String jsonStringValue(const String &json, const char *key, const String &fallback = "");
int jsonIntValue(const String &json, const char *key, int fallback = 0);
bool jsonBoolValue(const String &json, const char *key, bool fallback = false);
String jsonRolesValue(const String &json, const char *key, const String &fallback = "");
String chipId();
String deviceName();
String defaultDeviceName();
String sha256(const String &input);
String passwordHash(const String &password);
String prefString(const char *key, const String &fallback = "");
void loadSettings();
bool hasRole(const String &roles, const String &role);
bool validName(const String &s);
String cleanRoles(const String &roles);
bool isKnownRole(const String &role);
bool isSystemRole(const String &role);
String allRoles();
String rolesJson();
bool addCustomRole(const String &role);
bool deleteCustomRole(const String &role);
size_t parseUsers(User *users, size_t maxUsers);
void saveUsers(User *users, size_t count);
bool findUser(const String &name, User &user);
String createToken(const User &user);
Token *currentToken();
ApiDef *findApi(const String &path, const String &method);
String configuredRole(ApiDef *api);
bool authorize();
void appendLog(LogLevel level, const String &message);
void applyLed();
void factoryReset();
void checkFactoryResetPin();
bool connectWifi();
void handleSetupPage();
bool handleHtmlFileRequest();
void redirectToSetupPage();
void handleCaptiveProbe();
void handleFavicon();
void handleAdminPage();
void handleWifiScan();
void handleSetupSubmit();
void handleLogin();
void handleLogout();
void handleMe();
void handleUsersGet();
void handleUsersPost();
void handlePassword();
void handleRolesGet();
void handleRolesPost();
void handleRolesDelete();
void handleApisGet();
void handleApisPost();
void handleSettingsGet();
void handleSettingsPost();
void handleLogsGet();
void handleLogsClear();
void handleFilesList();
void handleFileDownload();
void handleFactoryReset();
void handleConfigExport();
void handleConfigImport();
void handleOtaCheck();
void handleOtaRun();
void handleUpdateUploadDone();
void handleUpdateUploadChunk();
void handleCertGet();
void handleCertPost();
void registerRoutes();

7
include/custom_api.h Normal file
View File

@@ -0,0 +1,7 @@
#pragma once
void handlePing();
void handleAdd();
void handleLed();
void handleUptime();
void handleUptimeEvents();

46
lib/README Normal file
View File

@@ -0,0 +1,46 @@
This directory is intended for project specific (private) libraries.
PlatformIO will compile them to static libraries and link into the executable file.
The source code of each library should be placed in a separate directory
("lib/your_library_name/[Code]").
For example, see the structure of the following example libraries `Foo` and `Bar`:
|--lib
| |
| |--Bar
| | |--docs
| | |--examples
| | |--src
| | |- Bar.c
| | |- Bar.h
| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html
| |
| |--Foo
| | |- Foo.c
| | |- Foo.h
| |
| |- README --> THIS FILE
|
|- platformio.ini
|--src
|- main.c
Example contents of `src/main.c` using Foo and Bar:
```
#include <Foo.h>
#include <Bar.h>
int main (void)
{
...
}
```
The PlatformIO Library Dependency Finder will find automatically dependent
libraries by scanning project source files.
More information about PlatformIO Library Dependency Finder
- https://docs.platformio.org/page/librarymanager/ldf.html

19
platformio.ini Normal file
View File

@@ -0,0 +1,19 @@
; PlatformIO Project Configuration File
;
; Build options: build flags, source filter
; Upload options: custom upload port, speed and extra flags
; Library options: dependencies, extra library storages
; Advanced options: extra scripting
;
; Please visit documentation for the other options and examples
; https://docs.platformio.org/page/projectconf.html
[env:seeed_xiao_esp32c3]
platform = espressif32
board = seeed_xiao_esp32c3
framework = arduino
board_build.filesystem = littlefs
lib_deps =
paulstoffregen/OneWire
milesburton/DallasTemperature
extra_scripts = scripts/platformio_targets.py

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,84 @@
#!/usr/bin/env python3
import argparse
import re
import shutil
import subprocess
import sys
from datetime import datetime
from pathlib import Path
DEFAULT_ENV = "seeed_xiao_esp32c3"
def find_pio():
pio = shutil.which("pio")
if pio:
return pio
candidate = Path.home() / ".platformio" / "penv" / "Scripts" / "pio.exe"
if candidate.exists():
return str(candidate)
candidate = Path.home() / ".platformio" / "penv" / "bin" / "pio"
if candidate.exists():
return str(candidate)
return "pio"
def run(command, cwd):
print("+ " + " ".join(command), flush=True)
subprocess.run(command, check=True, cwd=cwd)
def project_name(root):
app_h = root / "include" / "app.h"
if app_h.exists():
match = re.search(r'^\s*#define\s+PROJECT_NAME\s+"([^"]+)"', app_h.read_text(encoding="utf-8"), re.MULTILINE)
if match:
return match.group(1)
return root.name
def safe_filename_part(value):
value = value.strip().replace(" ", "-")
return re.sub(r"[^A-Za-z0-9._+-]+", "-", value).strip("-") or "project"
def package_timestamp():
return datetime.now().astimezone().isoformat(timespec="seconds").replace(":", "-")
def main():
parser = argparse.ArgumentParser(description="Build firmware and LittleFS, then create a timestamped OTA update package.")
parser.add_argument("-e", "--environment", default=DEFAULT_ENV, help="PlatformIO environment to build.")
parser.add_argument("-o", "--output-dir", default="dist", help="Directory for the generated OTA package.")
parser.add_argument("--pio", default=find_pio(), help="Path to the PlatformIO executable.")
args = parser.parse_args()
root = Path(__file__).resolve().parent.parent
build_dir = root / ".pio" / "build" / args.environment
firmware = build_dir / "firmware.bin"
filesystem = build_dir / "littlefs.bin"
output_dir = (root / args.output_dir).resolve()
output = output_dir / f"{safe_filename_part(project_name(root))}_{package_timestamp()}.tslpkg"
try:
run([args.pio, "run", "-e", args.environment, "-t", "buildfs"], root)
run([args.pio, "run", "-e", args.environment], root)
run([
sys.executable,
str(Path(__file__).resolve().parent / "create_update_package.py"),
"--firmware",
str(firmware),
"--filesystem",
str(filesystem),
"--output",
str(output),
], root)
except subprocess.CalledProcessError as error:
sys.exit(error.returncode)
print(f"OTA update file: {output.resolve()}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env python3
import argparse
import shutil
import subprocess
import sys
from pathlib import Path
DEFAULT_ENV = "seeed_xiao_esp32c3"
def find_pio():
pio = shutil.which("pio")
if pio:
return pio
candidate = Path.home() / ".platformio" / "penv" / "Scripts" / "pio.exe"
if candidate.exists():
return str(candidate)
candidate = Path.home() / ".platformio" / "penv" / "bin" / "pio"
if candidate.exists():
return str(candidate)
return "pio"
def run(command, cwd):
print("+ " + " ".join(command), flush=True)
subprocess.run(command, check=True, cwd=cwd)
def pio_command(args, pio, environment, upload_port):
command = [pio, "run", "-e", environment] + args
if upload_port:
command += ["--upload-port", upload_port]
return command
def main():
parser = argparse.ArgumentParser(description="Build firmware and LittleFS, then upload both to a connected device.")
parser.add_argument("-e", "--environment", default=DEFAULT_ENV, help="PlatformIO environment to build and upload.")
parser.add_argument("-p", "--upload-port", help="Upload port, for example COM3.")
parser.add_argument("--pio", default=find_pio(), help="Path to the PlatformIO executable.")
args = parser.parse_args()
root = Path(__file__).resolve().parent.parent
try:
run(pio_command(["-t", "buildfs"], args.pio, args.environment, None), root)
run(pio_command([], args.pio, args.environment, None), root)
run(pio_command(["-t", "uploadfs"], args.pio, args.environment, args.upload_port), root)
run(pio_command(["-t", "upload"], args.pio, args.environment, args.upload_port), root)
except subprocess.CalledProcessError as error:
sys.exit(error.returncode)
print(f"Uploaded filesystem and firmware for {args.environment} from {root}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env python3
import argparse
import struct
from pathlib import Path
MAGIC = b"TSLUPD1\0"
HEADER_SIZE = 32
def main():
parser = argparse.ArgumentParser(description="Create a combined ESP32 update package.")
parser.add_argument("--firmware", default=".pio/build/seeed_xiao_esp32c3/firmware.bin")
parser.add_argument("--filesystem", default=".pio/build/seeed_xiao_esp32c3/littlefs.bin")
parser.add_argument("--output", default=".pio/build/seeed_xiao_esp32c3/update.tslpkg")
args = parser.parse_args()
firmware_path = Path(args.firmware)
filesystem_path = Path(args.filesystem)
output_path = Path(args.output)
firmware = firmware_path.read_bytes()
filesystem = filesystem_path.read_bytes()
output_path.parent.mkdir(parents=True, exist_ok=True)
header = MAGIC + struct.pack("<III", HEADER_SIZE, len(filesystem), len(firmware)) + bytes(12)
output_path.write_bytes(header + filesystem + firmware)
print(f"Created {output_path}")
print(f" filesystem: {filesystem_path} ({len(filesystem)} bytes)")
print(f" firmware: {firmware_path} ({len(firmware)} bytes)")
print(f" total: {output_path.stat().st_size} bytes")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,9 @@
Import("env")
env.AddCustomTarget(
"uploadall",
["upload", "uploadfs"],
[],
title="Upload Firmware and Filesystem",
description="Upload firmware and the LittleFS image from data/",
)

View File

@@ -0,0 +1,33 @@
#include "app.h"
ApiDef apiDefs[] = {
{"/api/wifi/scan", "GET", "Sysadmin", false, handleWifiScan, nullptr},
{"/api/setup", "POST", "Sysadmin", false, handleSetupSubmit, nullptr},
{"/api/login", "POST", "", true, handleLogin, nullptr},
{"/api/logout", "POST", "WebUIConnect", false, handleLogout, nullptr},
{"/api/me", "GET", "WebUIConnect", false, handleMe, nullptr},
{"/api/apis", "GET", "Sysadmin", false, handleApisGet, nullptr},
{"/api/apis", "POST", "Sysadmin", false, handleApisPost, nullptr},
{"/api/users", "GET", "UserAdmin", false, handleUsersGet, nullptr},
{"/api/users", "POST", "UserAdmin", false, handleUsersPost, nullptr},
{"/api/roles", "GET", "UserAdmin", false, handleRolesGet, nullptr},
{"/api/roles", "POST", "UserAdmin", false, handleRolesPost, nullptr},
{"/api/roles", "DELETE", "UserAdmin", false, handleRolesDelete, nullptr},
{"/api/password", "POST", "WebUIConnect", false, handlePassword, nullptr},
{"/api/settings", "GET", "Sysadmin", false, handleSettingsGet, nullptr},
{"/api/settings", "POST", "Sysadmin", false, handleSettingsPost, nullptr},
{"/api/logs", "GET", "Debugger", false, handleLogsGet, nullptr},
{"/api/logs/clear", "POST", "Debugger", false, handleLogsClear, nullptr},
{"/api/files", "GET", "Debugger", false, handleFilesList, nullptr},
{"/api/files/download", "GET", "Debugger", false, handleFileDownload, nullptr},
{"/api/factory-reset", "POST", "Sysadmin", false, handleFactoryReset, nullptr},
{"/api/config/export", "GET", "Sysadmin", false, handleConfigExport, nullptr},
{"/api/config/import", "POST", "Sysadmin", false, handleConfigImport, nullptr},
{"/api/ota/check", "POST", "Sysadmin", false, handleOtaCheck, nullptr},
{"/api/ota/run", "POST", "Sysadmin", false, handleOtaRun, nullptr},
{"/api/update", "POST", "Sysadmin", false, handleUpdateUploadDone, handleUpdateUploadChunk},
{"/api/cert", "GET", "Sysadmin", false, handleCertGet, nullptr},
{"/api/cert", "POST", "Sysadmin", false, handleCertPost, nullptr},
};
const size_t API_DEF_COUNT = sizeof(apiDefs) / sizeof(apiDefs[0]);

View File

@@ -0,0 +1,12 @@
#include "app.h"
#include "custom_api.h"
ApiDef customApiDefs[] = {
{"/api/ping", "GET", "", true, handlePing, nullptr},
{"/api/add", "POST", "", true, handleAdd, nullptr},
{"/api/led", "POST", "", true, handleLed, nullptr},
{"/api/uptime", "GET", "WebUIConnect", false, handleUptime, nullptr},
{"/api/uptime/events", "GET", "WebUIConnect", false, handleUptimeEvents, nullptr},
};
const size_t CUSTOM_API_DEF_COUNT = sizeof(customApiDefs) / sizeof(customApiDefs[0]);

295
src/core/auth.cpp Normal file
View File

@@ -0,0 +1,295 @@
#include "app.h"
bool hasRole(const String &roles, const String &role) {
if (role.length() == 0) return true;
int start = 0;
while (start <= (int)roles.length()) {
int end = roles.indexOf('|', start);
if (end < 0) end = roles.length();
if (roles.substring(start, end) == role) return true;
start = end + 1;
}
return false;
}
bool validName(const String &s) {
if (s.length() == 0 || s.length() > 31) return false;
for (size_t i = 0; i < s.length(); i++) {
char c = s[i];
if (!(isalnum(c) || c == '_' || c == '-' || c == '.')) return false;
}
return true;
}
String cleanRoles(const String &roles) {
String out;
String known = allRoles();
int start = 0;
while (start <= (int)known.length()) {
int end = known.indexOf('|', start);
if (end < 0) end = known.length();
String role = known.substring(start, end);
if (role.length() && hasRole(roles, role)) {
if (out.length()) out += "|";
out += role;
}
start = end + 1;
}
return out;
}
bool isKnownRole(const String &role) {
return role == "PUBLIC" || hasRole(allRoles(), role);
}
bool isSystemRole(const String &role) {
return role == "Sysadmin" || role == "UserAdmin" || role == "WebUIConnect" || role == "Debugger";
}
String allRoles() {
String roles = "Sysadmin|UserAdmin|WebUIConnect|Debugger";
String custom = prefString("roles", "");
int start = 0;
while (start <= (int)custom.length()) {
int end = custom.indexOf('|', start);
if (end < 0) end = custom.length();
String role = custom.substring(start, end);
if (role.length() && !hasRole(roles, role)) roles += "|" + role;
start = end + 1;
if (!custom.length()) break;
}
return roles;
}
String rolesJson() {
String roles = allRoles();
String out = "\"roles\":[";
int start = 0;
bool first = true;
while (start <= (int)roles.length()) {
int end = roles.indexOf('|', start);
if (end < 0) end = roles.length();
String role = roles.substring(start, end);
if (role.length()) {
if (!first) out += ",";
out += "{\"name\":\"" + jsonEscape(role) + "\",\"system\":" + String(isSystemRole(role) ? "true" : "false") + "}";
first = false;
}
start = end + 1;
}
out += "]";
return out;
}
bool addCustomRole(const String &role) {
if (!validName(role) || isKnownRole(role)) return false;
String custom = prefString("roles", "");
if (custom.length()) custom += "|";
custom += role;
prefs.putString("roles", custom);
return true;
}
bool deleteCustomRole(const String &role) {
if (!validName(role) || isSystemRole(role) || !hasRole(prefString("roles", ""), role)) return false;
String custom = prefString("roles", "");
String kept;
int start = 0;
while (start <= (int)custom.length()) {
int end = custom.indexOf('|', start);
if (end < 0) end = custom.length();
String item = custom.substring(start, end);
if (item.length() && item != role) {
if (kept.length()) kept += "|";
kept += item;
}
start = end + 1;
}
prefs.putString("roles", kept);
User users[8];
size_t count = parseUsers(users, MAX_USERS);
for (size_t i = 0; i < count; i++) users[i].roles = cleanRoles(users[i].roles);
saveUsers(users, count);
for (size_t i = 0; i < API_DEF_COUNT + CUSTOM_API_DEF_COUNT; i++) {
ApiDef *api = i < API_DEF_COUNT ? &apiDefs[i] : &customApiDefs[i - API_DEF_COUNT];
if (configuredRole(api) == role) {
String fallback = api->publicByDefault ? "PUBLIC" : api->defaultRole;
String key = "acl";
key += api->method[0];
for (size_t j = 0; j < strlen(api->path); j++) {
char c = api->path[j];
if (isalnum(c)) key += c;
}
prefs.putString(key.substring(0, 15).c_str(), fallback);
}
}
return true;
}
static String defaultUsers() {
return String(DEFAULT_ADMIN) + "\t" + passwordHash("") + "\tSysadmin|UserAdmin|WebUIConnect|Debugger\t1\n";
}
static String usersText() {
String text = prefString("users", "");
return text.length() ? text : defaultUsers();
}
size_t parseUsers(User *users, size_t maxUsers) {
String text = usersText();
size_t count = 0;
int start = 0;
while (start < (int)text.length() && count < maxUsers) {
int end = text.indexOf('\n', start);
if (end < 0) end = text.length();
String line = text.substring(start, end);
int a = line.indexOf('\t');
int b = line.indexOf('\t', a + 1);
int c = line.indexOf('\t', b + 1);
if (a > 0 && b > a) {
users[count].name = line.substring(0, a);
users[count].passwordHash = line.substring(a + 1, b);
users[count].roles = cleanRoles(c > b ? line.substring(b + 1, c) : line.substring(b + 1));
users[count].active = c > b ? line.substring(c + 1).toInt() != 0 : true;
count++;
}
start = end + 1;
}
return count;
}
void saveUsers(User *users, size_t count) {
String text;
for (size_t i = 0; i < count; i++) {
text += users[i].name + "\t" + users[i].passwordHash + "\t" + cleanRoles(users[i].roles) + "\t" + String(users[i].active ? "1" : "0") + "\n";
}
prefs.putString("users", text);
}
bool findUser(const String &name, User &user) {
User users[8];
size_t count = parseUsers(users, MAX_USERS);
for (size_t i = 0; i < count; i++) {
if (users[i].name == name) {
user = users[i];
return true;
}
}
return false;
}
String createToken(const User &user) {
String value = sha256(String(esp_random(), HEX) + ":" + user.name + ":" + String(millis()));
int slot = 0;
uint32_t oldest = tokens[0].lastSeen;
for (int i = 0; i < (int)MAX_TOKENS; i++) {
if (!tokens[i].value.length()) {
slot = i;
break;
}
if (tokens[i].lastSeen < oldest) {
oldest = tokens[i].lastSeen;
slot = i;
}
}
tokens[slot].value = value;
tokens[slot].user = user.name;
tokens[slot].roles = user.roles;
tokens[slot].lastSeen = millis();
return value;
}
static String bearerToken() {
String auth = server.header("Authorization");
if (auth.startsWith("Bearer ")) return auth.substring(7);
if (server.hasHeader("X-Auth-Token")) return server.header("X-Auth-Token");
if (server.hasArg("token")) return server.arg("token");
return "";
}
static String requestMethodName();
Token *currentToken() {
String value = bearerToken();
if (!value.length()) return nullptr;
for (size_t i = 0; i < MAX_TOKENS; i++) {
if (tokens[i].value == value) {
tokens[i].lastSeen = millis();
return &tokens[i];
}
}
appendLog(LOG_SECURITY_AUDIT, "invalid bearer token for " + requestMethodName() + " " + server.uri());
return nullptr;
}
static String apiKey(const String &path, const String &method) {
String key = "acl";
key += method[0];
for (size_t i = 0; i < path.length(); i++) {
char c = path[i];
if (isalnum(c)) key += c;
}
return key.substring(0, 15);
}
ApiDef *findApi(const String &path, const String &method) {
for (size_t i = 0; i < API_DEF_COUNT; i++) {
if (path == apiDefs[i].path && method == apiDefs[i].method) return &apiDefs[i];
}
for (size_t i = 0; i < CUSTOM_API_DEF_COUNT; i++) {
if (path == customApiDefs[i].path && method == customApiDefs[i].method) return &customApiDefs[i];
}
return nullptr;
}
String configuredRole(ApiDef *api) {
if (!api) return "";
String key = apiKey(api->path, api->method);
return prefString(key.c_str(), api->publicByDefault ? "PUBLIC" : api->defaultRole);
}
static String requestMethodName() {
switch (server.method()) {
case HTTP_GET:
return "GET";
case HTTP_POST:
return "POST";
case HTTP_DELETE:
return "DELETE";
case HTTP_PUT:
return "PUT";
case HTTP_PATCH:
return "PATCH";
default:
return "";
}
}
bool authorize() {
String path = server.uri();
String method = requestMethodName();
ApiDef *api = findApi(path, method);
String required = configuredRole(api);
if (required == "PUBLIC" || (api && api->publicByDefault && !required.length())) return true;
Token *token = currentToken();
if (!token) {
if (!bearerToken().length()) appendLog(LOG_SECURITY_AUDIT, "authentication required for " + method + " " + path);
sendJson(401, jsonError("Authentication required"));
return false;
}
User user;
if (!findUser(token->user, user) || !user.active) {
appendLog(LOG_SECURITY_AUDIT, "inactive or unknown user token for " + token->user + " on " + method + " " + path);
sendJson(401, jsonError("User is inactive"));
return false;
}
token->roles = user.roles;
if (!hasRole(user.roles, required)) {
appendLog(LOG_SECURITY_AUDIT, "authorization denied for " + user.name + " on " + method + " " + path + " requires " + required);
sendJson(403, jsonError("Missing role " + required));
return false;
}
return true;
}

68
src/core/device.cpp Normal file
View File

@@ -0,0 +1,68 @@
#include "app.h"
#include <ESPmDNS.h>
#include <LittleFS.h>
void applyLed() {
uint8_t pct = constrain(ledBrightness, 0, 100);
uint8_t duty = map(pct, 0, 100, 0, 255);
if (ledInverted) duty = 255 - duty;
analogWrite(LED_BUILTIN, duty);
}
void factoryReset() {
prefs.clear();
LittleFS.remove(LOG_FILE_PATH);
appendLog(LOG_SECURITY_AUDIT, "factory reset requested");
}
void checkFactoryResetPin() {
pinMode(FACTORY_RESET_PIN, INPUT_PULLUP);
if (digitalRead(FACTORY_RESET_PIN) != FACTORY_RESET_ACTIVE_LEVEL) return;
uint32_t start = millis();
while (millis() - start < FACTORY_RESET_HOLD_MS) {
delay(100);
if (digitalRead(FACTORY_RESET_PIN) != FACTORY_RESET_ACTIVE_LEVEL) {
return;
}
}
factoryReset();
ESP.restart();
}
bool connectWifi() {
String ssid = prefString("wifiSsid", "");
String pass = prefString("wifiPass", "");
if (!ssid.length()) return false;
String name = deviceName();
WiFi.mode(WIFI_STA);
WiFi.setHostname(name.c_str());
if (!networkDhcp) {
IPAddress ip;
IPAddress gateway;
IPAddress subnet;
IPAddress dns1;
IPAddress dns2;
bool ok = ip.fromString(networkIp) && gateway.fromString(networkGateway) && subnet.fromString(networkSubnet);
if (ok) {
if (!networkDns1.length() || !dns1.fromString(networkDns1)) dns1 = gateway;
if (!networkDns2.length() || !dns2.fromString(networkDns2)) dns2 = IPAddress(0, 0, 0, 0);
if (!WiFi.config(ip, gateway, subnet, dns1, dns2)) appendLog(LOG_WARN, "static IP configuration failed; continuing with DHCP");
} else {
appendLog(LOG_WARN, "static IP configuration is incomplete or invalid; continuing with DHCP");
}
}
WiFi.begin(ssid.c_str(), pass.c_str());
appendLog(LOG_INFO, "connecting to WiFi " + ssid);
uint32_t start = millis();
while (WiFi.status() != WL_CONNECTED && millis() - start < 20000) {
delay(250);
}
if (WiFi.status() == WL_CONNECTED) {
MDNS.begin(name.c_str());
appendLog(LOG_INFO, "WiFi connected " + WiFi.localIP().toString());
return true;
}
appendLog(LOG_WARN, "WiFi connection failed; entering setup AP");
return false;
}

43
src/core/logging.cpp Normal file
View File

@@ -0,0 +1,43 @@
#include "app.h"
#include <LittleFS.h>
#include <time.h>
static String timestamp() {
time_t now = time(nullptr);
struct tm tm;
localtime_r(&now, &tm);
char buffer[20];
strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", &tm);
return String(buffer);
}
void appendLog(LogLevel level, const String &message) {
if (level > currentLogLevel) return;
const char *names[] = {"ERROR", "WARN", "SecurityAudit", "INFO", "DEBUG"};
String line = timestamp() + " " + names[level] + " " + message + "\n";
if (!LittleFS.exists(LOG_DIR)) LittleFS.mkdir(LOG_DIR);
File f = LittleFS.open(LOG_FILE_PATH, "a");
if (f) {
f.print(line);
f.close();
}
f = LittleFS.open(LOG_FILE_PATH, "r");
if (!f) return;
size_t size = f.size();
if (size <= maxLogBytes) {
f.close();
return;
}
size_t keep = maxLogBytes > 1024 ? maxLogBytes - 1024 : maxLogBytes;
f.seek(size - keep, SeekSet);
String tail = f.readString();
f.close();
int newline = tail.indexOf('\n');
if (newline >= 0) tail = tail.substring(newline + 1);
f = LittleFS.open(LOG_FILE_PATH, "w");
if (f) {
f.print(tail);
f.close();
}
}

117
src/core/state.cpp Normal file
View File

@@ -0,0 +1,117 @@
#include "app.h"
#include <mbedtls/md.h>
const char *APP_VERSION = "0.1.0";
const char *PROJECT_NAME_VALUE = PROJECT_NAME;
const char *DEFAULT_ADMIN = "admin";
const char *DEFAULT_UPDATE_URL = "http://example.com/update.tslpkg";
const uint32_t FACTORY_RESET_HOLD_MS = 10000;
const size_t MAX_USERS = 8;
const size_t MAX_TOKENS = 8;
const bool DEFAULT_LED_INVERTED = true;
const byte DNS_PORT = 53;
const IPAddress SETUP_AP_IP(192, 168, 1, 1);
const IPAddress SETUP_AP_GATEWAY(192, 168, 1, 1);
const IPAddress SETUP_AP_SUBNET(255, 255, 255, 0);
const char *WWW_ROOT = "/www";
const char *LOG_DIR = "/log";
const char *LOG_FILE_PATH = "/log/logs.txt";
WebServer server(80);
DNSServer dnsServer;
Preferences prefs;
Token tokens[8];
LogLevel currentLogLevel = LOG_INFO;
size_t maxLogBytes = 50 * 1024;
bool setupMode = false;
uint8_t ledBrightness = 0;
bool ledInverted = DEFAULT_LED_INVERTED;
String updateUrl = DEFAULT_UPDATE_URL;
String networkHostname;
bool networkDhcp = true;
String networkIp;
String networkGateway;
String networkSubnet;
String networkDns1;
String networkDns2;
String chipId() {
uint64_t mac = ESP.getEfuseMac();
char buf[13];
snprintf(buf, sizeof(buf), "%04X%08X", (uint16_t)(mac >> 32), (uint32_t)mac);
return String(buf);
}
String defaultDeviceName() {
String suffix = chipId().substring(6);
String base = PROJECT_NAME_VALUE;
base.trim();
for (size_t i = 0; i < base.length(); i++) {
char c = base[i];
if (!(isalnum(c) || c == '-')) base.setCharAt(i, '-');
}
while (base.startsWith("-")) base.remove(0, 1);
while (base.endsWith("-")) base.remove(base.length() - 1);
if (!base.length()) base = "ESP32C3";
const size_t maxHostnameLength = 31;
size_t maxBaseLength = maxHostnameLength - 1 - suffix.length();
if (base.length() > maxBaseLength) base = base.substring(0, maxBaseLength);
while (base.endsWith("-")) base.remove(base.length() - 1);
return base + "-" + suffix;
}
String deviceName() {
return networkHostname.length() ? networkHostname : defaultDeviceName();
}
String sha256(const String &input) {
uint8_t digest[32];
mbedtls_md_context_t ctx;
mbedtls_md_init(&ctx);
mbedtls_md_setup(&ctx, mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), 0);
mbedtls_md_starts(&ctx);
mbedtls_md_update(&ctx, (const unsigned char *)input.c_str(), input.length());
mbedtls_md_finish(&ctx, digest);
mbedtls_md_free(&ctx);
char out[65];
for (int i = 0; i < 32; i++) snprintf(out + (i * 2), 3, "%02x", digest[i]);
out[64] = 0;
return String(out);
}
String passwordHash(const String &password) {
return sha256(chipId() + ":" + password);
}
String prefString(const char *key, const String &fallback) {
return prefs.isKey(key) ? prefs.getString(key, fallback) : fallback;
}
void loadSettings() {
prefs.begin("boiler", false);
if (!prefs.getBool("ledPolV2", false)) {
prefs.putBool("ledInv", DEFAULT_LED_INVERTED);
prefs.putBool("ledPolV2", true);
}
uint8_t rawLogLevel = prefs.getUChar("logLevel", LOG_INFO);
if (!prefs.getBool("logLevelV2", false)) {
if (prefs.isKey("logLevel") && rawLogLevel >= 2) rawLogLevel++;
rawLogLevel = constrain(rawLogLevel, 0, 4);
prefs.putUChar("logLevel", rawLogLevel);
prefs.putBool("logLevelV2", true);
}
currentLogLevel = (LogLevel)constrain(rawLogLevel, 0, 4);
maxLogBytes = prefs.getUInt("logMax", 50 * 1024);
ledInverted = prefs.getBool("ledInv", DEFAULT_LED_INVERTED);
ledBrightness = constrain(prefs.getUChar("ledBright", 0), 0, 100);
updateUrl = prefString("updateUrl", DEFAULT_UPDATE_URL);
networkHostname = prefString("netHost", "");
networkDhcp = prefs.getBool("netDhcp", true);
networkIp = prefString("netIp", "");
networkGateway = prefString("netGw", "");
networkSubnet = prefString("netMask", "");
networkDns1 = prefString("netDns1", "");
networkDns2 = prefString("netDns2", "");
}

View File

@@ -0,0 +1,435 @@
#include "app.h"
#include <LittleFS.h>
static String networkSettingsJson(bool includeStatus) {
String out = "\"hostname\":\"" + jsonEscape(networkHostname) + "\",\"dhcp\":" + String(networkDhcp ? "true" : "false") + ",\"ip\":\"" + jsonEscape(networkIp) + "\",\"gateway\":\"" + jsonEscape(networkGateway) + "\",\"subnet\":\"" + jsonEscape(networkSubnet) + "\",\"dns1\":\"" + jsonEscape(networkDns1) + "\",\"dns2\":\"" + jsonEscape(networkDns2) + "\"";
if (includeStatus) out += ",\"defaultHostname\":\"" + jsonEscape(defaultDeviceName()) + "\",\"currentIp\":\"" + WiFi.localIP().toString() + "\"";
return out;
}
static bool validHostnameValue(const String &hostname) {
if (!hostname.length()) return true;
if (hostname.length() > 31) return false;
if (hostname.startsWith("-") || hostname.endsWith("-")) return false;
for (size_t i = 0; i < hostname.length(); i++) {
char c = hostname[i];
if (!(isalnum(c) || c == '-')) return false;
}
return true;
}
static bool validIpValue(const String &value) {
IPAddress ip;
return value.length() && ip.fromString(value);
}
void handleSettingsGet() {
if (!authorize()) return;
String out = "\"settings\":{\"projectName\":\"" + jsonEscape(PROJECT_NAME_VALUE) + "\",\"logLevel\":" + String(currentLogLevel) + ",\"maxLogBytes\":" + String(maxLogBytes) + ",\"updateUrl\":\"" + jsonEscape(updateUrl) + "\",\"ledInverted\":" + String(ledInverted ? "true" : "false") + ",\"ledBrightness\":" + String(ledBrightness) + "," + networkSettingsJson(true) + "}";
sendJson(200, jsonOk(out));
}
void handleSettingsPost() {
if (!authorize()) return;
String body = requestBody();
String nextHostname = jsonStringValue(body, "hostname", networkHostname);
nextHostname.trim();
bool nextDhcp = jsonBoolValue(body, "dhcp", networkDhcp);
String nextIp = jsonStringValue(body, "ip", networkIp);
String nextGateway = jsonStringValue(body, "gateway", networkGateway);
String nextSubnet = jsonStringValue(body, "subnet", networkSubnet);
String nextDns1 = jsonStringValue(body, "dns1", networkDns1);
String nextDns2 = jsonStringValue(body, "dns2", networkDns2);
nextIp.trim();
nextGateway.trim();
nextSubnet.trim();
nextDns1.trim();
nextDns2.trim();
if (!validHostnameValue(nextHostname)) return sendJson(400, jsonError("Hostname must be 1-31 letters, digits, or hyphens, and cannot start or end with a hyphen"));
if (!nextDhcp) {
if (!validIpValue(nextIp) || !validIpValue(nextGateway) || !validIpValue(nextSubnet)) return sendJson(400, jsonError("Static IP, gateway, and subnet must be valid IPv4 addresses"));
if (nextDns1.length() && !validIpValue(nextDns1)) return sendJson(400, jsonError("DNS 1 must be a valid IPv4 address"));
if (nextDns2.length() && !validIpValue(nextDns2)) return sendJson(400, jsonError("DNS 2 must be a valid IPv4 address"));
}
currentLogLevel = (LogLevel)constrain(jsonIntValue(body, "logLevel", currentLogLevel), 0, 4);
maxLogBytes = constrain(jsonIntValue(body, "maxLogBytes", maxLogBytes), 4096, 128 * 1024);
ledInverted = jsonBoolValue(body, "ledInverted", ledInverted);
ledBrightness = constrain(jsonIntValue(body, "ledBrightness", ledBrightness), 0, 100);
updateUrl = jsonStringValue(body, "updateUrl", updateUrl);
networkHostname = nextHostname;
networkDhcp = nextDhcp;
networkIp = nextIp;
networkGateway = nextGateway;
networkSubnet = nextSubnet;
networkDns1 = nextDns1;
networkDns2 = nextDns2;
prefs.putUChar("logLevel", currentLogLevel);
prefs.putUInt("logMax", maxLogBytes);
prefs.putBool("ledInv", ledInverted);
prefs.putUChar("ledBright", ledBrightness);
prefs.putString("updateUrl", updateUrl);
prefs.putString("netHost", networkHostname);
prefs.putBool("netDhcp", networkDhcp);
prefs.putString("netIp", networkIp);
prefs.putString("netGw", networkGateway);
prefs.putString("netMask", networkSubnet);
prefs.putString("netDns1", networkDns1);
prefs.putString("netDns2", networkDns2);
applyLed();
appendLog(LOG_SECURITY_AUDIT, "settings updated");
sendJson(200, jsonOk());
}
void handleLogsGet() {
if (!authorize()) return;
File f = LittleFS.open(LOG_FILE_PATH, "r");
String logs = f ? f.readString() : "";
if (f) f.close();
sendJson(200, jsonOk("\"logs\":\"" + jsonEscape(logs) + "\""));
}
void handleLogsClear() {
if (!authorize()) return;
LittleFS.remove(LOG_FILE_PATH);
appendLog(LOG_SECURITY_AUDIT, "logs cleared");
sendJson(200, jsonOk());
}
static bool validFsPath(const String &path) {
return path.length() && path.startsWith("/") && path.indexOf("..") < 0 && path.indexOf('\\') < 0;
}
static String fsPathArg(const String &fallback = "/") {
String path = server.arg("path");
if (!path.length()) path = fallback;
if (!path.startsWith("/")) path = "/" + path;
while (path.length() > 1 && path.endsWith("/")) path.remove(path.length() - 1);
return path;
}
static String joinedFsPath(const String &parent, const String &name) {
if (name.startsWith("/")) return name;
if (parent == "/") return "/" + name;
return parent + "/" + name;
}
static void appendFsEntryJson(String &out, bool &first, const String &parent, File &file) {
String path = file.name();
path = joinedFsPath(parent, path);
if (!first) out += ",";
out += "{\"name\":\"" + jsonEscape(path.substring(path.lastIndexOf('/') + 1)) + "\",\"path\":\"" + jsonEscape(path) + "\",\"size\":" + String(file.isDirectory() ? 0 : file.size()) + ",\"directory\":" + String(file.isDirectory() ? "true" : "false") + "}";
first = false;
}
static void appendDirectoryJson(String &out, bool &first, const String &path, File &dir) {
File file = dir.openNextFile();
while (file) {
appendFsEntryJson(out, first, path, file);
file.close();
file = dir.openNextFile();
}
}
void handleFilesList() {
if (!authorize()) return;
String path = fsPathArg("/");
if (!validFsPath(path)) return sendJson(400, jsonError("Invalid path"));
File dir = LittleFS.open(path, "r");
if (!dir && path != "/") dir = LittleFS.open(path + "/", "r");
if (!dir) return sendJson(404, jsonError("Directory not found"));
if (!dir.isDirectory()) {
dir.close();
return sendJson(400, jsonError("Path is not a directory"));
}
String out = "\"path\":\"" + jsonEscape(path) + "\",\"files\":[";
bool first = true;
appendDirectoryJson(out, first, path, dir);
dir.close();
out += "]";
sendJson(200, jsonOk(out));
}
void handleFileDownload() {
if (!authorize()) return;
String path = fsPathArg("");
if (!validFsPath(path)) return sendJson(400, jsonError("Invalid path"));
File file = LittleFS.open(path, "r");
if (!file || file.isDirectory()) return sendJson(404, jsonError("File not found"));
String name = path.substring(path.lastIndexOf('/') + 1);
server.sendHeader("Content-Disposition", "attachment; filename=\"" + name + "\"");
server.streamFile(file, "application/octet-stream");
file.close();
}
void handleFactoryReset() {
if (!authorize()) return;
factoryReset();
sendJson(200, jsonOk("\"restart\":true"));
delay(500);
ESP.restart();
}
static String apiPrefKey(const String &path, const String &method) {
String key = "acl";
key += method[0];
for (size_t i = 0; i < path.length(); i++) {
char c = path[i];
if (isalnum(c)) key += c;
}
return key.substring(0, 15);
}
static String jsonArrayStringList(const String &items) {
String out = "[";
int start = 0;
bool first = true;
while (start <= (int)items.length()) {
int end = items.indexOf('|', start);
if (end < 0) end = items.length();
String item = items.substring(start, end);
if (item.length()) {
if (!first) out += ",";
out += "\"" + jsonEscape(item) + "\"";
first = false;
}
start = end + 1;
if (!items.length()) break;
}
out += "]";
return out;
}
static String extractJsonArray(const String &json, const char *key) {
String needle = "\"" + String(key) + "\"";
int p = json.indexOf(needle);
if (p < 0) return "";
p = json.indexOf('[', p + needle.length());
if (p < 0) return "";
int start = p;
int depth = 0;
bool inString = false;
bool esc = false;
for (; p < (int)json.length(); p++) {
char c = json[p];
if (esc) {
esc = false;
} else if (c == '\\') {
esc = inString;
} else if (c == '"') {
inString = !inString;
} else if (!inString && c == '[') {
depth++;
} else if (!inString && c == ']') {
depth--;
if (depth == 0) return json.substring(start, p + 1);
}
}
return "";
}
static String objectAt(const String &array, int &pos) {
while (pos < (int)array.length() && array[pos] != '{') pos++;
if (pos >= (int)array.length()) return "";
int start = pos;
int depth = 0;
bool inString = false;
bool esc = false;
for (; pos < (int)array.length(); pos++) {
char c = array[pos];
if (esc) {
esc = false;
} else if (c == '\\') {
esc = inString;
} else if (c == '"') {
inString = !inString;
} else if (!inString && c == '{') {
depth++;
} else if (!inString && c == '}') {
depth--;
if (depth == 0) return array.substring(start, ++pos);
}
}
return "";
}
static String stringArrayToRoles(const String &array) {
String out;
int p = 0;
while (p < (int)array.length()) {
while (p < (int)array.length() && array[p] != '"') p++;
if (p >= (int)array.length()) break;
p++;
String item;
bool esc = false;
while (p < (int)array.length()) {
char c = array[p++];
if (esc) {
item += c == 'n' ? '\n' : c == 'r' ? '\r' : c;
esc = false;
} else if (c == '\\') {
esc = true;
} else if (c == '"') {
break;
} else {
item += c;
}
}
if (item.length()) {
if (out.length()) out += "|";
out += item;
}
}
return out;
}
static String cleanCustomRolesBackup(const String &roles) {
String out;
int start = 0;
while (start <= (int)roles.length()) {
int end = roles.indexOf('|', start);
if (end < 0) end = roles.length();
String role = roles.substring(start, end);
if (validName(role) && !isSystemRole(role) && !hasRole(out, role)) {
if (out.length()) out += "|";
out += role;
}
start = end + 1;
if (!roles.length()) break;
}
return out;
}
void handleConfigExport() {
if (!authorize()) return;
User users[MAX_USERS];
size_t userCount = parseUsers(users, MAX_USERS);
String out = "{";
out += "\"version\":1";
out += ",\"settings\":{\"logLevel\":" + String(currentLogLevel) + ",\"maxLogBytes\":" + String(maxLogBytes) + ",\"updateUrl\":\"" + jsonEscape(updateUrl) + "\",\"ledInverted\":" + String(ledInverted ? "true" : "false") + ",\"ledBrightness\":" + String(ledBrightness) + "," + networkSettingsJson(false) + "}";
out += ",\"customRoles\":" + jsonArrayStringList(prefString("roles", ""));
out += ",\"users\":[";
for (size_t i = 0; i < userCount; i++) {
if (i) out += ",";
out += "{\"username\":\"" + jsonEscape(users[i].name) + "\",\"passwordHash\":\"" + jsonEscape(users[i].passwordHash) + "\",\"roles\":\"" + jsonEscape(users[i].roles) + "\",\"active\":" + String(users[i].active ? "true" : "false") + "}";
}
out += "],\"apiAcls\":[";
for (size_t i = 0; i < API_DEF_COUNT + CUSTOM_API_DEF_COUNT; i++) {
ApiDef *api = i < API_DEF_COUNT ? &apiDefs[i] : &customApiDefs[i - API_DEF_COUNT];
if (i) out += ",";
out += "{\"path\":\"" + jsonEscape(api->path) + "\",\"method\":\"" + jsonEscape(api->method) + "\",\"role\":\"" + jsonEscape(configuredRole(api)) + "\"}";
}
out += "],\"certificate\":\"" + jsonEscape(prefString("cert", "")) + "\"}";
server.sendHeader("Cache-Control", "no-store");
server.sendHeader("Content-Disposition", "attachment; filename=\"config-backup.json\"");
server.send(200, "application/json", out);
}
void handleConfigImport() {
if (!authorize()) return;
String body = requestBody();
if (!body.length()) return sendJson(400, jsonError("Configuration JSON is required"));
String customRoles = cleanCustomRolesBackup(stringArrayToRoles(extractJsonArray(body, "customRoles")));
prefs.putString("roles", customRoles);
User users[MAX_USERS];
size_t userCount = 0;
String usersArray = extractJsonArray(body, "users");
int pos = 0;
while (userCount < MAX_USERS) {
String item = objectAt(usersArray, pos);
if (!item.length()) break;
String username = jsonStringValue(item, "username");
String password = jsonStringValue(item, "passwordHash");
if (!validName(username) || !password.length()) return sendJson(400, jsonError("Invalid user in backup"));
users[userCount].name = username;
users[userCount].passwordHash = password;
users[userCount].roles = cleanRoles(jsonStringValue(item, "roles", "WebUIConnect"));
users[userCount].active = jsonBoolValue(item, "active", true);
userCount++;
}
if (!userCount) return sendJson(400, jsonError("Backup must contain at least one user"));
bool hasActiveSysadmin = false;
for (size_t i = 0; i < userCount; i++) {
if (users[i].active && hasRole(users[i].roles, "Sysadmin")) hasActiveSysadmin = true;
}
if (!hasActiveSysadmin) return sendJson(400, jsonError("Backup must contain an active Sysadmin user"));
saveUsers(users, userCount);
currentLogLevel = (LogLevel)constrain(jsonIntValue(body, "logLevel", currentLogLevel), 0, 4);
maxLogBytes = constrain(jsonIntValue(body, "maxLogBytes", maxLogBytes), 4096, 128 * 1024);
updateUrl = jsonStringValue(body, "updateUrl", updateUrl);
ledInverted = jsonBoolValue(body, "ledInverted", ledInverted);
ledBrightness = constrain(jsonIntValue(body, "ledBrightness", ledBrightness), 0, 100);
String nextHostname = jsonStringValue(body, "hostname", networkHostname);
nextHostname.trim();
bool nextDhcp = jsonBoolValue(body, "dhcp", networkDhcp);
String nextIp = jsonStringValue(body, "ip", networkIp);
String nextGateway = jsonStringValue(body, "gateway", networkGateway);
String nextSubnet = jsonStringValue(body, "subnet", networkSubnet);
String nextDns1 = jsonStringValue(body, "dns1", networkDns1);
String nextDns2 = jsonStringValue(body, "dns2", networkDns2);
nextIp.trim();
nextGateway.trim();
nextSubnet.trim();
nextDns1.trim();
nextDns2.trim();
if (validHostnameValue(nextHostname)) networkHostname = nextHostname;
networkDhcp = nextDhcp;
if (networkDhcp || (validIpValue(nextIp) && validIpValue(nextGateway) && validIpValue(nextSubnet))) {
networkIp = nextIp;
networkGateway = nextGateway;
networkSubnet = nextSubnet;
if (!nextDns1.length() || validIpValue(nextDns1)) networkDns1 = nextDns1;
if (!nextDns2.length() || validIpValue(nextDns2)) networkDns2 = nextDns2;
}
prefs.putUChar("logLevel", currentLogLevel);
prefs.putUInt("logMax", maxLogBytes);
prefs.putString("updateUrl", updateUrl);
prefs.putBool("ledInv", ledInverted);
prefs.putUChar("ledBright", ledBrightness);
prefs.putString("netHost", networkHostname);
prefs.putBool("netDhcp", networkDhcp);
prefs.putString("netIp", networkIp);
prefs.putString("netGw", networkGateway);
prefs.putString("netMask", networkSubnet);
prefs.putString("netDns1", networkDns1);
prefs.putString("netDns2", networkDns2);
prefs.putString("cert", jsonStringValue(body, "certificate", ""));
String aclsArray = extractJsonArray(body, "apiAcls");
pos = 0;
while (true) {
String item = objectAt(aclsArray, pos);
if (!item.length()) break;
String path = jsonStringValue(item, "path");
String method = jsonStringValue(item, "method", "GET");
method.toUpperCase();
String role = jsonStringValue(item, "role", "PUBLIC");
ApiDef *api = findApi(path, method);
if (api && isKnownRole(role)) {
prefs.putString(apiPrefKey(path, method).c_str(), role);
}
}
applyLed();
appendLog(LOG_SECURITY_AUDIT, "configuration restored from backup");
sendJson(200, jsonOk());
}
void handleCertGet() {
if (!authorize()) return;
sendJson(200, jsonOk("\"certificate\":\"" + jsonEscape(prefString("cert", "")) + "\""));
}
void handleCertPost() {
if (!authorize()) return;
String cert = jsonStringValue(requestBody(), "certificate");
prefs.putString("cert", cert);
appendLog(LOG_SECURITY_AUDIT, "HTTPS certificate material saved");
sendJson(200, jsonOk("\"note\":\"certificate is stored for applications that enable TLS termination\""));
}

View File

@@ -0,0 +1,35 @@
#include "app.h"
void handleApisGet() {
if (!authorize()) return;
String out = "\"apis\":[";
for (size_t i = 0; i < API_DEF_COUNT + CUSTOM_API_DEF_COUNT; i++) {
ApiDef *api = i < API_DEF_COUNT ? &apiDefs[i] : &customApiDefs[i - API_DEF_COUNT];
if (i) out += ",";
String role = configuredRole(api);
out += "{\"path\":\"" + String(api->path) + "\",\"method\":\"" + String(api->method) + "\",\"role\":\"" + jsonEscape(role) + "\"}";
}
out += "]";
sendJson(200, jsonOk(out));
}
void handleApisPost() {
if (!authorize()) return;
String body = requestBody();
String path = jsonStringValue(body, "path");
String method = jsonStringValue(body, "method", "GET");
method.toUpperCase();
String role = jsonStringValue(body, "role", "PUBLIC");
ApiDef *api = findApi(path, method);
if (!api) return sendJson(404, jsonError("Unknown API"));
if (!isKnownRole(role)) return sendJson(400, jsonError("Set one known role or PUBLIC"));
String key = "acl";
key += method[0];
for (size_t i = 0; i < path.length(); i++) {
char c = path[i];
if (isalnum(c)) key += c;
}
prefs.putString(key.substring(0, 15).c_str(), role);
appendLog(LOG_SECURITY_AUDIT, "API ACL saved " + method + " " + path + " -> " + role);
sendJson(200, jsonOk());
}

View File

@@ -0,0 +1,107 @@
#include "app.h"
void handleLogin() {
String body = requestBody();
String username = jsonStringValue(body, "username");
String password = jsonStringValue(body, "password");
User user;
if (!findUser(username, user) || !user.active || user.passwordHash != passwordHash(password)) {
appendLog(LOG_SECURITY_AUDIT, "failed login for " + username);
return sendJson(401, jsonError("Invalid username or password"));
}
String token = createToken(user);
appendLog(LOG_SECURITY_AUDIT, "login " + username);
sendJson(200, jsonOk("\"token\":\"" + token + "\",\"username\":\"" + jsonEscape(user.name) + "\",\"roles\":\"" + jsonEscape(user.roles) + "\""));
}
void handleLogout() {
if (!authorize()) return;
Token *tok = currentToken();
if (tok) {
appendLog(LOG_SECURITY_AUDIT, "logout " + tok->user);
*tok = Token();
}
sendJson(200, jsonOk());
}
void handleMe() {
if (!authorize()) return;
Token *tok = currentToken();
sendJson(200, jsonOk("\"username\":\"" + jsonEscape(tok->user) + "\",\"roles\":\"" + jsonEscape(tok->roles) + "\""));
}
void handleUsersGet() {
if (!authorize()) return;
User users[8];
size_t count = parseUsers(users, MAX_USERS);
String out = "\"users\":[";
for (size_t i = 0; i < count; i++) {
if (i) out += ",";
out += "{\"username\":\"" + jsonEscape(users[i].name) + "\",\"roles\":\"" + jsonEscape(users[i].roles) + "\",\"active\":" + String(users[i].active ? "true" : "false") + "}";
}
out += "]";
sendJson(200, jsonOk(out));
}
void handleUsersPost() {
if (!authorize()) return;
String body = requestBody();
String username = jsonStringValue(body, "username");
String password = jsonStringValue(body, "password");
String roles = cleanRoles(jsonRolesValue(body, "roles", "WebUIConnect"));
bool active = jsonBoolValue(body, "active", true);
if (!validName(username)) return sendJson(400, jsonError("Username is invalid"));
User users[8];
size_t count = parseUsers(users, MAX_USERS);
size_t idx = count;
for (size_t i = 0; i < count; i++) {
if (users[i].name == username) idx = i;
}
if (idx == count && count >= MAX_USERS) return sendJson(400, jsonError("Maximum user count reached"));
users[idx].name = username;
if (password.length() || idx == count) users[idx].passwordHash = passwordHash(password);
users[idx].roles = roles;
users[idx].active = active;
if (idx == count) count++;
saveUsers(users, count);
appendLog(LOG_SECURITY_AUDIT, "user saved " + username);
sendJson(200, jsonOk());
}
void handlePassword() {
if (!authorize()) return;
Token *tok = currentToken();
String password = jsonStringValue(requestBody(), "password");
User users[8];
size_t count = parseUsers(users, MAX_USERS);
for (size_t i = 0; i < count; i++) {
if (users[i].name == tok->user) {
users[i].passwordHash = passwordHash(password);
saveUsers(users, count);
appendLog(LOG_SECURITY_AUDIT, "password changed " + tok->user);
return sendJson(200, jsonOk());
}
}
sendJson(404, jsonError("User not found"));
}
void handleRolesGet() {
if (!authorize()) return;
sendJson(200, jsonOk(rolesJson()));
}
void handleRolesPost() {
if (!authorize()) return;
String role = jsonStringValue(requestBody(), "role");
if (!addCustomRole(role)) return sendJson(400, jsonError("Role is invalid or already exists"));
appendLog(LOG_SECURITY_AUDIT, "role added " + role);
sendJson(200, jsonOk(rolesJson()));
}
void handleRolesDelete() {
if (!authorize()) return;
String role = jsonStringValue(requestBody(), "role", server.arg("role"));
if (!deleteCustomRole(role)) return sendJson(400, jsonError("Role cannot be deleted"));
appendLog(LOG_SECURITY_AUDIT, "role deleted " + role);
sendJson(200, jsonOk(rolesJson()));
}

View File

@@ -0,0 +1,87 @@
#include "app.h"
#include "custom_api.h"
#include <DallasTemperature.h>
#include <OneWire.h>
static OneWire oneWire(DS18B20_PIN);
static DallasTemperature temperatureSensors(&oneWire);
static bool temperatureStarted = false;
static bool temperaturePending = false;
static float lastTemperatureC = NAN;
static uint32_t temperatureRequestMs = 0;
static void beginTemperatureSensor() {
if (temperatureStarted) return;
temperatureSensors.begin();
temperatureSensors.setResolution(12);
temperatureSensors.setWaitForConversion(false);
temperatureSensors.requestTemperatures();
temperatureRequestMs = millis();
temperaturePending = true;
temperatureStarted = true;
}
static void updateTemperatureSensor() {
beginTemperatureSensor();
uint32_t now = millis();
if (temperaturePending && now - temperatureRequestMs >= 750) {
float value = temperatureSensors.getTempCByIndex(0);
if (value != DEVICE_DISCONNECTED_C) lastTemperatureC = value;
temperaturePending = false;
}
if (!temperaturePending && now - temperatureRequestMs >= 1000) {
temperatureSensors.requestTemperatures();
temperatureRequestMs = now;
temperaturePending = true;
}
}
static String temperatureJsonFields() {
updateTemperatureSensor();
String json = "\"pin\":" + String(DS18B20_PIN);
json += ",\"sensorConnected\":";
json += isnan(lastTemperatureC) ? "false" : "true";
json += ",\"temperatureC\":";
json += isnan(lastTemperatureC) ? "null" : String(lastTemperatureC, 2);
json += ",\"temperatureF\":";
json += isnan(lastTemperatureC) ? "null" : String((lastTemperatureC * 9.0f / 5.0f) + 32.0f, 2);
return json;
}
void handlePing() {
if (!authorize()) return;
sendJson(200, jsonOk("\"uptimeMs\":" + String(millis()) + ",\"version\":\"" + APP_VERSION + "\",\"name\":\"" + jsonEscape(deviceName()) + "\",\"ip\":\"" + WiFi.localIP().toString() + "\""));
}
void handleAdd() {
if (!authorize()) return;
String body = requestBody();
int a = jsonIntValue(body, "a", server.arg("a").toInt());
int b = jsonIntValue(body, "b", server.arg("b").toInt());
sendJson(200, jsonOk("\"result\":" + String(a + b)));
}
void handleLed() {
if (!authorize()) return;
ledBrightness = constrain(jsonIntValue(requestBody(), "brightness", server.arg("brightness").toInt()), 0, 100);
prefs.putUChar("ledBright", ledBrightness);
applyLed();
appendLog(LOG_INFO, "LED brightness set to " + String(ledBrightness));
sendJson(200, jsonOk("\"brightness\":" + String(ledBrightness)));
}
void handleUptime() {
if (!authorize()) return;
sendJson(200, jsonOk(temperatureJsonFields()));
}
void handleUptimeEvents() {
if (!authorize()) return;
String payload = "retry: 1000\n";
payload += "event: temperature\n";
payload += "data: {" + temperatureJsonFields() + "}\n\n";
server.sendHeader("Cache-Control", "no-store");
server.sendHeader("Connection", "close");
server.send(200, "text/event-stream", payload);
}

View File

@@ -0,0 +1,202 @@
#include "app.h"
#include <HTTPClient.h>
#include <LittleFS.h>
#include <Update.h>
static const uint8_t PACKAGE_HEADER_SIZE = 32;
static const char PACKAGE_MAGIC[8] = {'T', 'S', 'L', 'U', 'P', 'D', '1', 0};
enum PackageStage : uint8_t {
PKG_HEADER,
PKG_FILESYSTEM,
PKG_FIRMWARE,
PKG_DONE,
PKG_ERROR
};
struct PackageState {
PackageStage stage;
uint8_t header[PACKAGE_HEADER_SIZE];
size_t headerRead;
uint32_t filesystemSize;
uint32_t firmwareSize;
uint32_t remaining;
bool filesystemEnded;
String error;
};
static PackageState packageState;
static uint32_t readLe32(const uint8_t *p) {
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
}
static void resetPackageState() {
packageState.stage = PKG_HEADER;
packageState.headerRead = 0;
packageState.filesystemSize = 0;
packageState.firmwareSize = 0;
packageState.remaining = 0;
packageState.filesystemEnded = false;
packageState.error = "";
}
static bool failPackage(const String &message) {
packageState.stage = PKG_ERROR;
packageState.error = message;
Update.abort();
if (packageState.filesystemEnded) LittleFS.begin(false);
return false;
}
static bool beginPackagePart(uint32_t size, int command, const char *label) {
if (command == U_SPIFFS) LittleFS.end();
if (!Update.begin(size, command)) {
return failPackage(String(label) + " update begin failed: " + Update.errorString());
}
packageState.remaining = size;
return true;
}
static bool finishPackagePart(const char *label) {
if (!Update.end(true)) {
return failPackage(String(label) + " update failed: " + Update.errorString());
}
return true;
}
static bool parsePackageHeader() {
if (memcmp(packageState.header, PACKAGE_MAGIC, sizeof(PACKAGE_MAGIC)) != 0) {
return failPackage("Invalid update package magic");
}
uint32_t headerSize = readLe32(packageState.header + 8);
packageState.filesystemSize = readLe32(packageState.header + 12);
packageState.firmwareSize = readLe32(packageState.header + 16);
if (headerSize != PACKAGE_HEADER_SIZE) return failPackage("Unsupported update package header");
if (!packageState.filesystemSize || !packageState.firmwareSize) return failPackage("Update package must contain filesystem and firmware images");
packageState.stage = PKG_FILESYSTEM;
return beginPackagePart(packageState.filesystemSize, U_SPIFFS, "Filesystem");
}
static bool feedPackageBytes(const uint8_t *data, size_t length) {
while (length && packageState.stage != PKG_ERROR && packageState.stage != PKG_DONE) {
if (packageState.stage == PKG_HEADER) {
size_t n = min(length, (size_t)PACKAGE_HEADER_SIZE - packageState.headerRead);
memcpy(packageState.header + packageState.headerRead, data, n);
packageState.headerRead += n;
data += n;
length -= n;
if (packageState.headerRead == PACKAGE_HEADER_SIZE && !parsePackageHeader()) return false;
} else {
size_t n = min(length, (size_t)packageState.remaining);
if (Update.write((uint8_t *)data, n) != n) {
return failPackage(String(packageState.stage == PKG_FILESYSTEM ? "Filesystem" : "Firmware") + " write failed");
}
packageState.remaining -= n;
data += n;
length -= n;
if (packageState.remaining == 0) {
if (packageState.stage == PKG_FILESYSTEM) {
if (!finishPackagePart("Filesystem")) return false;
packageState.filesystemEnded = true;
packageState.stage = PKG_FIRMWARE;
if (!beginPackagePart(packageState.firmwareSize, U_FLASH, "Firmware")) return false;
} else {
if (!finishPackagePart("Firmware")) return false;
packageState.stage = PKG_DONE;
}
}
}
}
if (length && packageState.stage == PKG_DONE) return failPackage("Trailing bytes in update package");
return packageState.stage != PKG_ERROR;
}
static bool streamPackageFromUrl(const String &url, String &error) {
resetPackageState();
HTTPClient http;
http.begin(url);
int code = http.GET();
if (code != HTTP_CODE_OK) {
http.end();
error = "Update package URL returned HTTP " + String(code);
return false;
}
uint8_t buffer[1024];
WiFiClient *stream = http.getStreamPtr();
int expectedLength = http.getSize();
int receivedLength = 0;
uint32_t lastRead = millis();
while (expectedLength < 0 || receivedLength < expectedLength) {
int available = stream->available();
if (available > 0) {
int wanted = min(available, (int)sizeof(buffer));
if (expectedLength >= 0) wanted = min(wanted, expectedLength - receivedLength);
int n = stream->readBytes(buffer, wanted);
if (n > 0) {
receivedLength += n;
lastRead = millis();
if (!feedPackageBytes(buffer, n)) break;
}
} else {
if (!http.connected()) break;
if (millis() - lastRead > 30000) {
failPackage("Update package download timed out");
break;
}
delay(10);
}
}
http.end();
if (packageState.stage != PKG_DONE) {
error = packageState.error.length() ? packageState.error : "Incomplete update package";
return false;
}
return true;
}
void handleOtaCheck() {
if (!authorize()) return;
HTTPClient http;
http.begin(updateUrl);
int code = http.GET();
int size = http.getSize();
http.end();
sendJson(code > 0 && code < 400 ? 200 : 502, jsonOk("\"version\":\"" + String(APP_VERSION) + "\",\"url\":\"" + jsonEscape(updateUrl) + "\",\"httpStatus\":" + String(code) + ",\"contentLength\":" + String(size)));
}
void handleOtaRun() {
if (!authorize()) return;
appendLog(LOG_SECURITY_AUDIT, "package URL update started");
String error;
if (!streamPackageFromUrl(updateUrl, error)) return sendJson(502, jsonError(error));
sendJson(200, jsonOk("\"restart\":true,\"filesystemUpdated\":true,\"firmwareUpdated\":true"));
delay(500);
ESP.restart();
}
void handleUpdateUploadDone() {
if (!authorize()) return;
bool ok = packageState.stage == PKG_DONE;
String error = packageState.error.length() ? packageState.error : "Incomplete update package";
sendJson(ok ? 200 : 500, ok ? jsonOk("\"restart\":true,\"filesystemUpdated\":true,\"firmwareUpdated\":true") : jsonError(error));
if (ok) {
delay(500);
ESP.restart();
}
}
void handleUpdateUploadChunk() {
HTTPUpload &upload = server.upload();
if (upload.status == UPLOAD_FILE_START) {
if (!authorize()) return;
appendLog(LOG_SECURITY_AUDIT, "package upload started");
resetPackageState();
} else if (upload.status == UPLOAD_FILE_WRITE) {
feedPackageBytes(upload.buf, upload.currentSize);
}
}

View File

@@ -0,0 +1,33 @@
#include "app.h"
void handleWifiScan() {
if (!setupMode && !authorize()) return;
int n = WiFi.scanNetworks();
String out = "\"networks\":[";
for (int i = 0; i < n; i++) {
if (i) out += ",";
out += "{\"ssid\":\"" + jsonEscape(WiFi.SSID(i)) + "\",\"rssi\":" + String(WiFi.RSSI(i)) + ",\"open\":" + String(WiFi.encryptionType(i) == WIFI_AUTH_OPEN ? "true" : "false") + "}";
}
out += "]";
sendJson(200, jsonOk(out));
}
void handleSetupSubmit() {
if (!setupMode && !authorize()) return;
String body = requestBody();
String ssid = jsonStringValue(body, "ssid");
String wifiPass = jsonStringValue(body, "wifiPass");
String admin = jsonStringValue(body, "admin", DEFAULT_ADMIN);
String adminPass = jsonStringValue(body, "adminPass");
if (!ssid.length()) return sendJson(400, jsonError("WiFi SSID is required"));
if (!validName(admin)) return sendJson(400, jsonError("Admin username is invalid"));
prefs.putString("wifiSsid", ssid);
prefs.putString("wifiPass", wifiPass);
User u{admin, passwordHash(adminPass), "Sysadmin|UserAdmin|WebUIConnect|Debugger", true};
saveUsers(&u, 1);
prefs.putBool("configured", true);
appendLog(LOG_SECURITY_AUDIT, "initial setup saved");
sendJson(200, jsonOk("\"restart\":true"));
delay(500);
ESP.restart();
}

38
src/main.cpp Normal file
View File

@@ -0,0 +1,38 @@
#include "app.h"
#include <LittleFS.h>
void setup() {
Serial.begin(9600);
delay(200);
Serial.println("Booting");
LittleFS.begin(true);
loadSettings();
pinMode(LED_BUILTIN, OUTPUT);
applyLed();
checkFactoryResetPin();
bool configured = prefs.getBool("configured", false);
setupMode = !configured || !connectWifi();
if (setupMode) {
String ssid = deviceName();
WiFi.mode(WIFI_AP_STA);
WiFi.softAPConfig(SETUP_AP_IP, SETUP_AP_GATEWAY, SETUP_AP_SUBNET);
WiFi.softAP(ssid.c_str());
dnsServer.start(DNS_PORT, "*", SETUP_AP_IP);
appendLog(LOG_INFO, "setup AP started " + ssid);
Serial.println("Setup AP: " + ssid + " http://" + SETUP_AP_IP.toString() + "/");
} else {
Serial.println("Admin UI: http://" + WiFi.localIP().toString() + "/");
Serial.println("Admin UI local: http://" + deviceName() + ".local/");
}
registerRoutes();
server.begin();
appendLog(LOG_INFO, "HTTP server started");
}
void loop() {
if (setupMode) dnsServer.processNextRequest();
server.handleClient();
}

106
src/util/json_utils.cpp Normal file
View File

@@ -0,0 +1,106 @@
#include "app.h"
static int findJsonKey(const String &json, const char *key) {
String needle = "\"" + String(key) + "\"";
int p = json.indexOf(needle);
if (p < 0) return -1;
p = json.indexOf(':', p + needle.length());
return p < 0 ? -1 : p + 1;
}
String jsonEscape(const String &s) {
String out;
out.reserve(s.length() + 8);
for (size_t i = 0; i < s.length(); i++) {
char c = s[i];
if (c == '"' || c == '\\') {
out += '\\';
out += c;
} else if (c == '\n') {
out += "\\n";
} else if (c == '\r') {
out += "\\r";
} else {
out += c;
}
}
return out;
}
String jsonOk(const String &payload) {
return String("{\"success\":true") + (payload.length() ? "," + payload : "") + "}";
}
String jsonError(const String &message) {
return "{\"success\":false,\"error\":\"" + jsonEscape(message) + "\"}";
}
void sendJson(int code, const String &body) {
server.sendHeader("Cache-Control", "no-store");
server.send(code, "application/json", body);
}
String requestBody() {
return server.hasArg("plain") ? server.arg("plain") : "";
}
String jsonStringValue(const String &json, const char *key, const String &fallback) {
int p = findJsonKey(json, key);
if (p < 0) return fallback;
while (p < (int)json.length() && isspace(json[p])) p++;
if (p >= (int)json.length() || json[p] != '"') return fallback;
p++;
String out;
bool esc = false;
for (; p < (int)json.length(); p++) {
char c = json[p];
if (esc) {
out += c == 'n' ? '\n' : c == 'r' ? '\r' : c;
esc = false;
} else if (c == '\\') {
esc = true;
} else if (c == '"') {
return out;
} else {
out += c;
}
}
return fallback;
}
int jsonIntValue(const String &json, const char *key, int fallback) {
int p = findJsonKey(json, key);
if (p < 0) return fallback;
while (p < (int)json.length() && isspace(json[p])) p++;
return json.substring(p).toInt();
}
bool jsonBoolValue(const String &json, const char *key, bool fallback) {
int p = findJsonKey(json, key);
if (p < 0) return fallback;
while (p < (int)json.length() && isspace(json[p])) p++;
if (json.substring(p, p + 4) == "true") return true;
if (json.substring(p, p + 5) == "false") return false;
return fallback;
}
String jsonRolesValue(const String &json, const char *key, const String &fallback) {
int p = findJsonKey(json, key);
if (p < 0) return fallback;
while (p < (int)json.length() && isspace(json[p])) p++;
if (json[p] == '"') return jsonStringValue(json, key, fallback);
if (json[p] != '[') return fallback;
String roles;
p++;
while (p < (int)json.length() && json[p] != ']') {
while (p < (int)json.length() && json[p] != '"' && json[p] != ']') p++;
if (p >= (int)json.length() || json[p] == ']') break;
p++;
String role;
while (p < (int)json.length() && json[p] != '"') role += json[p++];
if (roles.length()) roles += "|";
roles += role;
p++;
}
return roles.length() ? roles : fallback;
}

76
src/web/routes.cpp Normal file
View File

@@ -0,0 +1,76 @@
#include "app.h"
static HTTPMethod httpMethod(const char *method) {
if (strcmp(method, "GET") == 0) return HTTP_GET;
if (strcmp(method, "POST") == 0) return HTTP_POST;
if (strcmp(method, "DELETE") == 0) return HTTP_DELETE;
if (strcmp(method, "PUT") == 0) return HTTP_PUT;
if (strcmp(method, "PATCH") == 0) return HTTP_PATCH;
return HTTP_ANY;
}
static void registerApiDefs(ApiDef *defs, size_t count) {
for (size_t i = 0; i < count; i++) {
ApiDef &api = defs[i];
if (api.uploadHandler) {
server.on(api.path, httpMethod(api.method), api.handler, api.uploadHandler);
} else {
server.on(api.path, httpMethod(api.method), api.handler);
}
}
}
static void registerApiRoutes() {
registerApiDefs(apiDefs, API_DEF_COUNT);
registerApiDefs(customApiDefs, CUSTOM_API_DEF_COUNT);
}
static String requestMethodName() {
switch (server.method()) {
case HTTP_GET:
return "GET";
case HTTP_POST:
return "POST";
case HTTP_DELETE:
return "DELETE";
case HTTP_PUT:
return "PUT";
case HTTP_PATCH:
return "PATCH";
default:
return "UNKNOWN";
}
}
void registerRoutes() {
static const char *headers[] = {"Authorization", "X-Auth-Token"};
server.collectHeaders(headers, 2);
server.on("/", HTTP_GET, setupMode ? handleSetupPage : handleAdminPage);
server.on("/favicon.ico", HTTP_GET, handleFavicon);
server.on("/generate_204", HTTP_GET, handleCaptiveProbe);
server.on("/gen_204", HTTP_GET, handleCaptiveProbe);
server.on("/hotspot-detect.html", HTTP_GET, handleCaptiveProbe);
server.on("/library/test/success.html", HTTP_GET, handleCaptiveProbe);
server.on("/connecttest.txt", HTTP_GET, handleCaptiveProbe);
server.on("/ncsi.txt", HTTP_GET, handleCaptiveProbe);
server.on("/fwlink", HTTP_GET, handleCaptiveProbe);
registerApiRoutes();
server.onNotFound([]() {
if (setupMode) {
if (server.uri().startsWith("/api/")) {
appendLog(LOG_SECURITY_AUDIT, "undefined URL or method " + requestMethodName() + " " + server.uri());
sendJson(404, jsonError("Not found"));
} else if (!handleHtmlFileRequest()) {
redirectToSetupPage();
}
} else if (server.method() == HTTP_GET && !server.uri().startsWith("/api/")) {
if (!handleHtmlFileRequest()) handleAdminPage();
} else {
appendLog(LOG_SECURITY_AUDIT, "undefined URL or method " + requestMethodName() + " " + server.uri());
sendJson(404, jsonError("Not found"));
}
});
}

69
src/web/ui.cpp Normal file
View File

@@ -0,0 +1,69 @@
#include "app.h"
#include <LittleFS.h>
static bool validPublicPath(const String &path) {
return path.startsWith("/") && path.indexOf("..") < 0 && path.indexOf('\\') < 0;
}
static String contentTypeFor(const String &path) {
if (path.endsWith(".html")) return "text/html";
if (path.endsWith(".css")) return "text/css";
if (path.endsWith(".js") || path.endsWith(".mjs")) return "text/javascript";
if (path.endsWith(".json")) return "application/json";
if (path.endsWith(".svg")) return "image/svg+xml";
if (path.endsWith(".png")) return "image/png";
if (path.endsWith(".jpg") || path.endsWith(".jpeg")) return "image/jpeg";
if (path.endsWith(".ico")) return "image/x-icon";
return "application/octet-stream";
}
static bool serveWwwFile(const String &path) {
if (!validPublicPath(path)) return false;
String fsPath = String(WWW_ROOT) + path;
File file = LittleFS.open(fsPath, "r");
if (!file || file.isDirectory()) {
if (file) file.close();
return false;
}
server.sendHeader("Cache-Control", path.endsWith(".html") ? "no-store" : "max-age=3600");
server.streamFile(file, contentTypeFor(path));
file.close();
return true;
}
void handleSetupPage() {
if (!serveWwwFile("/setup.html")) {
sendJson(500, jsonError("Missing /www/setup.html in LittleFS"));
}
}
bool handleHtmlFileRequest() {
String path = server.uri();
if (path == "/") path = setupMode ? "/setup.html" : "/admin.html";
return serveWwwFile(path);
}
void redirectToSetupPage() {
server.sendHeader("Location", String("http://") + SETUP_AP_IP.toString() + "/", true);
server.sendHeader("Cache-Control", "no-store");
server.send(302, "text/plain", "");
}
void handleCaptiveProbe() {
if (setupMode) {
redirectToSetupPage();
} else {
server.send(204, "text/plain", "");
}
}
void handleFavicon() {
if (!serveWwwFile("/favicon.ico")) server.send(204, "image/x-icon", "");
}
void handleAdminPage() {
if (!serveWwwFile("/admin.html")) {
sendJson(500, jsonError("Missing /www/admin.html in LittleFS"));
}
}

11
test/README Normal file
View File

@@ -0,0 +1,11 @@
This directory is intended for PlatformIO Test Runner and project tests.
Unit Testing is a software testing method by which individual units of
source code, sets of one or more MCU program modules together with associated
control data, usage procedures, and operating procedures, are tested to
determine whether they are fit for use. Unit testing finds problems early
in the development cycle.
More information about PlatformIO Unit Testing:
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html