This commit is contained in:
2025-10-06 18:27:50 +02:00
commit 3e191a4f60
213 changed files with 22261 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch
+10
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"
]
}
+304
View File
@@ -0,0 +1,304 @@
# RFID-Master-Client
A comprehensive RFID access control system based on ESP32, providing a web UI for management, OTA updates, and synchronization with an external server.
## Overview
The RFID-Master-Client project is an embedded system for access control using RFID cards. It combines hardware control with a modern web interface, enabling management of users, settings, and logs through a user-friendly interface.
## Features
- **RFID Access Control**: Management of RFID cards and access logging
- **Web UI**: Modern SvelteKit-based user interface for configuration and monitoring
- **WiFi Management**: Support for Station and Access Point modes
- **OTA Updates**: Over-the-Air firmware updates for easy maintenance
- **Server Synchronization**: Automatic data synchronization with an external server via RPC
- **Hardware Configuration**: Customizable hardware settings (relays, sensors, pins)
- **Logging and Debugging**: Comprehensive logging system with various levels
- **TFT Display**: Local display for status and interaction
- **Protobuf Communication**: Efficient serial communication between components
## Architecture
The system consists of several modules:
### Backend (C++ on ESP32)
- **Core Modules**: Logger, Settings, Hardware Configuration
- **RFID Module**: Database and processing of RFID cards
- **WebUI Module**: HTTP server for the web interface
- **WiFi Manager**: Network management
- **OTA Manager**: Firmware updates
- **RPC Module**: Communication with external server
- **Device Manager**: Hardware control (relays, LEDs)
### Frontend (SvelteKit)
- **Dashboard**: Overview of system status
- **User Management**: RFID cards and permissions
- **Settings**: System and hardware configuration
- **Logs**: Display of system logs
### Communication
- **Protobuf**: Binary serial communication between ESP32 and connected sensors
- **HTTP/REST**: Web API for the user interface
- **WebSocket**: Real-time updates in the web UI
## Prerequisites
### Hardware
- ESP32 development board (e.g., AZ-Delivery-DevKit-V4)
- RFID reader (MFRC522 or compatible)
- TFT display (ST7735/ST7789)
- Relay module for access control
- RTC module for timestamps
### Software
- PlatformIO (for ESP32 development)
- Node.js 18+ (for web UI)
- protoc (Protocol Buffers Compiler)
## Installation
1. **Clone the repository**:
```bash
git clone <repository-url>
cd RFID-Master-Client
```
2. **Install PlatformIO dependencies**:
```bash
pio pkg install
```
3. **Install web UI dependencies**:
```bash
cd ui
npm install
cd ..
```
4. **Generate protobuf files**:
```bash
cd ui
npm run proto
cd ..
```
## Build and Flash
### Development Environment
```bash
pio run -e dev
pio run -e dev -t upload
pio run -e dev -t monitor
```
### Production Environment
```bash
pio run -e prod
pio run -e prod -t upload
```
### Build Web UI
```bash
cd ui
npm run build
npm run copy
cd ..
```
## Configuration
### Hardware Configuration
Edit `lib/data/hardware_config.hpp` for pin assignments and hardware parameters.
### System Settings
Configure via the web UI:
- WiFi settings
- Server synchronization
- Log level
- Hardware parameters
### Partitioning
- `partitions_custom.csv`: For development (more memory for debugging)
- `partitions_prod.csv`: For production (optimized for size)
## Usage
1. **Initial Configuration**:
- Start device in AP mode
- Connect to WiFi "RFID-Master"
- Open web UI at `http://192.168.4.1`
- Configure WiFi and server settings
2. **Add RFID Cards**:
- Navigate to "Users" in web UI
- Scan new RFID card or enter manually
- Assign permissions
3. **Monitoring**:
- Dashboard for system status
- Logs for debugging
- View access logs
## API Reference
### REST API Endpoints
- `GET /api/status`: Retrieve device status
- `GET /api/users`: Retrieve user list
- `POST /api/users`: Add new user
- `GET /api/logs`: Retrieve system logs
- `POST /api/settings`: Update settings
### Protobuf Messages
- `device.proto`: Device information and status
- `webui.proto`: Web UI communication
- `control_communication.proto`: Control messages
- `settings.proto`: Configuration settings
- `hardware.proto`: Hardware configuration
## Development
### Code Structure
```
src/
├── main.cpp # Main entry point
├── webui/ # Web server and API
├── wifi/ # WiFi management
├── device/ # Device management
lib/
├── data/ # Data models and configuration
├── hardware/ # Hardware abstractions
├── log/ # Logging system
├── ota/ # OTA updates
├── rpc/ # Server communication
├── tft/ # Display control
ui/ # Web UI (SvelteKit)
proto/ # Protocol Buffer definitions
```
### Debugging
- Use `pio run -e dev -t monitor` for serial logs
- Adjust log level via web UI
- Core dumps are enabled in dev environment
### Tests
```bash
# Unit tests (if implemented)
pio test
# Web UI tests
cd ui
npm run check
```
## Deployment
1. **Build firmware**:
```bash
pio run -e prod
```
2. **Build and copy web UI**:
```bash
cd ui
npm run build
npm run copy
cd ..
```
3. **Flash**:
```bash
pio run -e prod -t upload
```
4. **Flash filesystem** (for web UI):
```bash
pio run -e prod -t uploadfs
```
## Troubleshooting
### Common Issues
- **WiFi connection failed**: Check SSID/password, verify signal strength
- **RFID not recognized**: Check hardware connections, test MFRC522 module
- **Web UI unreachable**: Check firewall settings, verify IP address
- **OTA update failed**: Check network connection, verify firmware size
### Analyze Logs
- Set log level to DEBUG for detailed output
- Use serial monitor for real-time logs
- Web UI logs for HTTP requests
### Hardware Debugging
- Verify pin assignments in `hardware_config.hpp`
- Ensure proper power supply to modules
- Test I2C/serial connections
## Contributing
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/AmazingFeature`)
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
4. Push to the branch (`git push origin feature/AmazingFeature`)
5. Open a Pull Request
### Code Standards
- Use C++17 for ESP32 code
- TypeScript for web UI
- Protobuf for serial communication
- Document new features and API changes
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## Support
For questions or issues:
- Open an issue in the GitHub repository
- Check logs for error indications
- Ensure all prerequisites are met
## Changelog
### [Unreleased]
- Initial release of the RFID-Master-Client system
- Complete RFID access control
- Web UI with SvelteKit
- OTA update functionality
- Server synchronization
---
For detailed information about specific modules, see the corresponding README files in the subdirectories.
Binary file not shown.
+37
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
+46
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
+131
View File
@@ -0,0 +1,131 @@
#include "hardware_config.hpp"
#include <pb_encode.h>
#include <pb_decode.h>
#include <Arduino.h>
#include <logger.hpp>
HardwareConfigRepo::HardwareConfigRepo() {
setDefaults();
}
HardwareConfigRepo::~HardwareConfigRepo() {
// Cleanup if needed
}
void HardwareConfigRepo::setChangeCallback(std::function<void(const hardware_HardwareConfig&)> callback) {
changeCallback_ = callback;
}
void HardwareConfigRepo::setDefaults() {
data_.hold_duration_ms = DEFAULT_HOLD_DURATION_MS;
data_.override = DEFAULT_OVERRIDE;
data_.relay_pin = DEFAULT_RELAY_PIN;
data_.sensor_rx_pin = DEFAULT_SENSOR_RX_PIN;
data_.sensor_tx_pin = DEFAULT_SENSOR_TX_PIN;
// Initialize LedConfig to defaults (empty)
// Nanopb initializes to zero, so ok
}
bool HardwareConfigRepo::fileExists() {
return LittleFS.exists(filename_);
}
bool HardwareConfigRepo::load() {
if (!LittleFS.begin()) {
LOG_ERROR("HardwareConfigRepo: LittleFS mount failed");
setDefaults();
return false;
}
if (!fileExists()) {
// File doesn't exist, use defaults
setDefaults();
runCallback();
return true;
}
File file = LittleFS.open(filename_, "r");
if (!file) {
LOG_ERROR("Failed to open hardware config file for reading");
setDefaults();
return false;
}
// Read file into buffer
size_t fileSize = file.size();
uint8_t buffer[fileSize];
file.read(buffer, fileSize);
file.close();
// Decode protobuf
pb_istream_t stream = pb_istream_from_buffer(buffer, fileSize);
bool status = pb_decode(&stream, hardware_HardwareConfig_fields, &data_);
if (!status) {
LOG_ERROR("Failed to decode hardware config protobuf");
setDefaults();
return false;
}
runCallback();
return true;
}
bool HardwareConfigRepo::save() {
if (!LittleFS.begin()) {
LOG_ERROR("HardwareConfigRepo: LittleFS mount failed");
return false;
}
// Encode to buffer first to get size
size_t bufferSize = 0;
pb_get_encoded_size(&bufferSize, hardware_HardwareConfig_fields, &data_);
uint8_t buffer[bufferSize];
pb_ostream_t stream = pb_ostream_from_buffer(buffer, bufferSize);
bool status = pb_encode(&stream, hardware_HardwareConfig_fields, &data_);
if (!status) {
LOG_ERROR("Failed to encode hardware config protobuf");
return false;
}
// Write to file
File file = LittleFS.open(filename_, "w");
if (!file) {
LOG_ERROR("Failed to open hardware config file for writing");
return false;
}
size_t bytesWritten = file.write(buffer, bufferSize);
file.close();
if (bytesWritten != bufferSize) {
LOG_ERROR("Failed to write complete hardware config file");
return false;
}
runCallback();
return true;
}
void HardwareConfigRepo::setData(const hardware_HardwareConfig& newData) {
data_ = newData;
save(); // Persist immediately
}
void HardwareConfigRepo::resetToDefaults() {
setDefaults();
save();
}
void HardwareConfigRepo::runCallback() {
if (changeCallback_) {
changeCallback_(data_);
}
}
HardwareConfigRepo hardwareConfigRepo;
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include <LittleFS.h>
#include <hardware.pb.h>
class HardwareConfigRepo {
public:
HardwareConfigRepo();
~HardwareConfigRepo();
// Set callback that gets called when config changes (load/save)
void setChangeCallback(std::function<void(const hardware_HardwareConfig&)> callback);
// Load config from LittleFS, use defaults if file doesn't exist
bool load();
// Get reference to config data
hardware_HardwareConfig& getData() { return data_; }
// Set config data
void setData(const hardware_HardwareConfig& newData);
// Reset to default values
void resetToDefaults();
private:
// Save current config to LittleFS
bool save();
hardware_HardwareConfig data_;
const char* filename_ = "/hardware.pb";
std::function<void(const hardware_HardwareConfig&)> changeCallback_ = nullptr;
// Default values
static constexpr uint32_t DEFAULT_HOLD_DURATION_MS = 5000;
static constexpr bool DEFAULT_OVERRIDE = false;
static constexpr uint32_t DEFAULT_RELAY_PIN = 2;
static constexpr uint32_t DEFAULT_SENSOR_RX_PIN = 16;
static constexpr uint32_t DEFAULT_SENSOR_TX_PIN = 17;
// Helper methods
void setDefaults();
bool fileExists();
void runCallback();
};
extern HardwareConfigRepo hardwareConfigRepo;
+6
View File
@@ -0,0 +1,6 @@
{
"name": "hardware",
"dependencies": {
"nanopb": "*"
}
}
+282
View File
@@ -0,0 +1,282 @@
#include "rfid.hpp"
#include <logger.hpp>
RfidDB rfidDB;
RfidDB::RfidDB(const String& filename)
: filename_(filename), tmpFilename_(filename + ".tmp"), initialized_(false) {}
RfidDB::~RfidDB() {}
void printDatabaseContents() {
LOG_DEBUG("--- RFID Database Contents (Stored Values) ---");
rfidDB.iterate([](uint32_t stored_id) {
// 'stored_id' ist der Wert, wie er in der Datei steht (Byte-geswappt)
Serial.printf("Stored HEX: 0x%08X\n", stored_id);
});
LOG_DEBUG("-------------------------------------------");
}
bool RfidDB::begin() {
if (initialized_) {
return true;
}
if (!LittleFS.begin()) {
LOG_ERROR("RfidDB: LittleFS mount failed");
return false;
}
// Ensure the file exists, creating it if necessary.
if (!LittleFS.exists(filename_)) {
File f = LittleFS.open(filename_, "w");
if (!f) {
return false; // Could not create the file
}
f.close();
}
initialized_ = true;
printDatabaseContents();
return true;
}
uint32_t RfidDB::count() {
if (!initialized_) return 0;
File f = LittleFS.open(filename_, "r");
if (!f) return 0;
uint32_t n = fileEntryCount(f);
f.close();
return n;
}
bool RfidDB::contains(uint32_t raw_id) {
if (!initialized_) {
LOG_DEBUG("RfidDB: contains not initialized");
return false;
}
File f = LittleFS.open(filename_, "r");
if (!f) {
LOG_DEBUG("RfidDB: contains failed to open file");
return false;
}
uint32_t id = byteSwap(raw_id);
uint32_t idx;
bool found = false;
bool ok = binarySearch(f, idx, id, found);
f.close();
LOG_DEBUG("RfidDB: contains id=%08X, ok=%d, found=%d", raw_id, ok, found);
return ok && found;
}
void RfidDB::iterate(std::function<void(uint32_t)> callback) {
if (!initialized_ || !callback) return;
File f = LittleFS.open(filename_, "r");
if (!f) return;
uint32_t n = fileEntryCount(f);
for (uint32_t i = 0; i < n; ++i) {
uint32_t v;
if (readEntryAt(f, i, v)) {
callback(v);
}
}
f.close();
}
uint32_t RfidDB::fileEntryCount(File &f) {
return f.size() / ENTRY_SIZE;
}
// 🚀 More efficient read/write methods
bool RfidDB::readEntryAt(File &f, uint32_t index, uint32_t &out) {
if (!f.seek(index * ENTRY_SIZE, SeekSet)) return false;
return f.read(reinterpret_cast<uint8_t*>(&out), ENTRY_SIZE) == ENTRY_SIZE;
}
bool RfidDB::writeEntryAt(File &f, uint32_t index, uint32_t value) {
if (!f.seek(index * ENTRY_SIZE, SeekSet)) return false;
return f.write(reinterpret_cast<const uint8_t*>(&value), ENTRY_SIZE) == ENTRY_SIZE;
}
bool RfidDB::binarySearch(File &f, uint32_t &outIndex, uint32_t key, bool &found) {
// Get the number of entries in the file (each entry is 4 bytes)
uint32_t n = fileEntryCount(f);
// If the file is empty, return with outIndex = 0 and found = false
if (n == 0) {
outIndex = 0;
found = false;
return true;
}
// Initialize search boundaries for binary search
uint32_t left = 0;
uint32_t right = n - 1;
// Perform binary search on the sorted database
// Note: The database must contain values in ascending order of their *swapped* (Little-Endian) representation
// to ensure correct search results. The 'key' parameter is the byte-swapped (Little-Endian) value of the
// raw RFID ID (e.g., raw ID 0x635C426D is swapped to 0x6D425C63 for comparison).
while (left <= right) {
// Calculate the middle index
uint32_t mid = left + (right - left) / 2;
uint32_t v;
// Read the entry at index 'mid' from the file
// The file stores IDs as Big-Endian (e.g., bytes 63 5C 42 6D for original ID 0x635C426D).
// On this Little-Endian platform (e.g., ESP32), reading 4 bytes into 'v' interprets them as
// Little-Endian, so bytes 63 5C 42 6D become v = 0x6D425C63 (swapped).
if (!readEntryAt(f, mid, v)) {
return false; // Failed to read entry
}
// Compare the read value 'v' (Little-Endian, swapped) with the search key (also Little-Endian, swapped)
if (v == key) {
outIndex = mid;
found = true;
return true; // Found the ID at index 'mid'
}
// Since the database is sorted by the swapped (Little-Endian) values,
// adjust the search boundaries based on the comparison
if (v < key) {
left = mid + 1; // Search in the right half
} else {
if (mid == 0) break; // Prevent underflow when right = mid - 1
right = mid - 1; // Search in the left half
}
}
// If not found, set outIndex to the insertion point where the key would be added
// to maintain the sorted order of swapped (Little-Endian) values
outIndex = left;
found = false;
return true;
}
uint32_t RfidDB::byteSwap(uint32_t x) const
{
return ((x & 0xFF000000) >> 24) |
((x & 0x00FF0000) >> 8) |
((x & 0x0000FF00) << 8) |
((x & 0x000000FF) << 24);
}
bool RfidDB::add(uint32_t raw_id) {
if (!initialized_) return false;
File src = LittleFS.open(filename_, "r");
if (!src) return false;
uint32_t id = byteSwap(raw_id);
uint32_t idx;
bool found;
if (!binarySearch(src, idx, id, found)) {
src.close();
return false;
}
if (found) {
src.close();
return true; // Already present, we consider this a success
}
File dst = LittleFS.open(tmpFilename_, "w");
if (!dst) {
src.close();
return false;
}
bool success = true;
uint32_t n = fileEntryCount(src);
uint32_t v;
// Copy entries before the insertion point
for (uint32_t i = 0; i < idx; ++i) {
if (!readEntryAt(src, i, v) || !writeEntryAt(dst, i, v)) {
success = false;
break;
}
}
// Insert the new entry
if (success && !writeEntryAt(dst, idx, id)) {
success = false;
}
// Copy the remaining entries
if (success) {
for (uint32_t i = idx; i < n; ++i) {
if (!readEntryAt(src, i, v) || !writeEntryAt(dst, i + 1, v)) {
success = false;
break;
}
}
}
src.close();
dst.close();
if (!success) {
LittleFS.remove(tmpFilename_);
return false;
}
// Atomic replace
if (!LittleFS.rename(tmpFilename_, filename_)) {
// Fallback in case rename fails
LittleFS.remove(tmpFilename_);
return false;
}
return true;
}
bool RfidDB::remove(uint32_t raw_id) {
if (!initialized_) return false;
File src = LittleFS.open(filename_, "r");
if (!src) return false;
uint32_t id = byteSwap(raw_id);
uint32_t idx;
bool found;
if (!binarySearch(src, idx, id, found) || !found) {
src.close();
return false; // Not found, so nothing to remove
}
File dst = LittleFS.open(tmpFilename_, "w");
if (!dst) {
src.close();
return false;
}
bool success = true;
uint32_t n = fileEntryCount(src);
uint32_t written = 0;
uint32_t v;
for (uint32_t i = 0; i < n; ++i) {
if (i == idx) continue; // Skip the entry to be deleted
if (!readEntryAt(src, i, v) || !writeEntryAt(dst, written, v)) {
success = false;
break;
}
written++;
}
src.close();
dst.close();
if (!success) {
LittleFS.remove(tmpFilename_);
return false;
}
if (!LittleFS.rename(tmpFilename_, filename_)) {
LittleFS.remove(tmpFilename_);
return false;
}
return true;
}
+68
View File
@@ -0,0 +1,68 @@
#pragma once
#include <Arduino.h>
#include <LittleFS.h>
#include <functional> // For std::function
class RfidDB {
public:
// The default constructor uses "/rfid.db"
RfidDB(const String& filename = "/rfid.db");
~RfidDB();
/**
* @brief Initializes the filesystem and the database file.
* Must be called once in setup().
* @return true on success, otherwise false.
*/
bool begin();
/**
* @brief Returns the number of stored IDs.
*/
uint32_t count();
/**
* @brief Checks if an ID exists in the database.
* @param id The ID to check.
* @return true if the ID exists, otherwise false.
*/
bool contains(uint32_t id);
/**
* @brief Adds a new ID. The ID is only added if it does not already exist.
* @param id The ID to add.
* @return true if the ID was successfully added, otherwise false.
*/
bool add(uint32_t id);
/**
* @brief Removes an ID from the database.
* @param id The ID to remove.
* @return true if the ID was found and removed, otherwise false.
*/
bool remove(uint32_t id);
/**
* @brief Iterates over all stored IDs and calls a callback function for each.
* @param callback The function to be called for each ID (e.g., a lambda: `[](uint32_t id){ Serial.println(id); }`).
*/
void iterate(std::function<void(uint32_t)> callback);
private:
String filename_;
String tmpFilename_;
bool initialized_ = false;
static constexpr size_t ENTRY_SIZE = sizeof(uint32_t);
// Helper methods
bool readEntryAt(File &f, uint32_t index, uint32_t &out);
bool writeEntryAt(File &f, uint32_t index, uint32_t value);
bool binarySearch(File &f, uint32_t &outIndex, uint32_t key, bool &found);
uint32_t byteSwap(uint32_t x) const;
uint32_t fileEntryCount(File &f);
};
// Global instance, kept as requested
extern RfidDB rfidDB;
+108
View File
@@ -0,0 +1,108 @@
#include "session.hpp"
#include <pgmspace.h> // Required for PROGMEM
// Definition of the global instance
SessionManager sessionManager;
// The character set is now stored in flash memory to save RAM.
const char charset[] PROGMEM = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
SessionManager::SessionManager() {
session_mutex_ = xSemaphoreCreateMutex();
for (size_t i = 0; i < MAX_SESSIONS; ++i) {
sessions_[i].username[0] = '\0';
}
}
SessionManager::~SessionManager() {
vSemaphoreDelete(session_mutex_);
}
const char* SessionManager::createSession(const char* username) {
const char* result_token = nullptr;
// Lock the mutex to ensure exclusive access to the sessions array.
if (xSemaphoreTake(session_mutex_, portMAX_DELAY) == pdTRUE) {
for (size_t i = 0; i < MAX_SESSIONS; ++i) {
if (sessions_[i].username[0] == '\0') {
strncpy(sessions_[i].username, username, sizeof(sessions_[i].username) - 1);
sessions_[i].username[sizeof(sessions_[i].username) - 1] = '\0';
generateToken(sessions_[i].token, sizeof(sessions_[i].token));
sessions_[i].expiry_time = millis() + (15 * 60 * 1000);
result_token = sessions_[i].token;
break; // Exit loop once a slot is found
}
}
xSemaphoreGive(session_mutex_); // Release the mutex
}
return result_token;
}
const char* SessionManager::validateSession(const char* token) {
if (token == nullptr) {
return nullptr;
}
// We must copy the username to a static buffer, because the session array
// could be modified by another task after we release the mutex.
static char valid_username[33];
valid_username[0] = '\0';
if (xSemaphoreTake(session_mutex_, portMAX_DELAY) == pdTRUE) {
for (size_t i = 0; i < MAX_SESSIONS; ++i) {
if (sessions_[i].username[0] != '\0' && strcmp(sessions_[i].token, token) == 0) {
if (millis() < sessions_[i].expiry_time) {
strcpy(valid_username, sessions_[i].username);
} else {
sessions_[i].username[0] = '\0'; // Expired, clear it
}
break;
}
}
xSemaphoreGive(session_mutex_);
}
return (valid_username[0] != '\0') ? valid_username : nullptr;
}
bool SessionManager::endSession(const char* token) {
if (token == nullptr) return false;
bool found = false;
if (xSemaphoreTake(session_mutex_, portMAX_DELAY) == pdTRUE) {
for (size_t i = 0; i < MAX_SESSIONS; ++i) {
if (sessions_[i].username[0] != '\0' && strcmp(sessions_[i].token, token) == 0) {
sessions_[i].username[0] = '\0';
found = true;
break;
}
}
xSemaphoreGive(session_mutex_);
}
return found;
}
void SessionManager::cleanupExpiredSessions() {
unsigned long current_time = millis();
if (xSemaphoreTake(session_mutex_, portMAX_DELAY) == pdTRUE) {
for (size_t i = 0; i < MAX_SESSIONS; ++i) {
if (sessions_[i].username[0] != '\0') {
if (current_time >= sessions_[i].expiry_time) {
sessions_[i].username[0] = '\0';
}
}
}
xSemaphoreGive(session_mutex_);
}
}
void SessionManager::generateToken(char* buffer, size_t buffer_size) {
const size_t charset_size = sizeof(charset) - 1;
for (size_t i = 0; i < buffer_size - 1; ++i) {
// Read the character directly from flash memory (PROGMEM).
buffer[i] = pgm_read_byte(&charset[esp_random() % charset_size]);
}
buffer[buffer_size - 1] = '\0';
}
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#include <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h> // Required for mutex
// Represents an active session.
// An empty username (username[0] == '\0') indicates the slot is free.
struct Session {
char username[33];
char token[17]; // 16 chars + null terminator
unsigned long expiry_time; // Expiration timestamp from millis()
};
class SessionManager {
public:
SessionManager();
~SessionManager(); // Destructor to clean up the mutex
const char* createSession(const char* username);
const char* validateSession(const char* token);
bool endSession(const char* token);
void cleanupExpiredSessions();
private:
static constexpr size_t MAX_SESSIONS = 3;
Session sessions_[MAX_SESSIONS];
SemaphoreHandle_t session_mutex_; // Mutex to protect access to sessions_
void generateToken(char* buffer, size_t buffer_size);
};
// Central, global instance.
extern SessionManager sessionManager;
+161
View File
@@ -0,0 +1,161 @@
#include "settings.hpp"
#include <pb_encode.h>
#include <pb_decode.h>
#include <Arduino.h>
#include <logger.hpp>
Settings::Settings() {
setDefaults();
}
Settings::~Settings() {
// Cleanup if needed
}
void Settings::setDefaults() {
strcpy(data_.sync_server_url, "https://ssvc.ldev/api/");
strcpy(data_.device_api_key, "c62a6b5c-914e-49d4-9871-1896a627e0df");
strcpy(data_.sync_server_url, DEFAULT_SYNC_SERVER_URL);
strcpy(data_.device_api_key, DEFAULT_DEVICE_API_KEY);
data_.sync_interval_seconds = DEFAULT_SYNC_INTERVAL;
data_.auto_sync = DEFAULT_AUTO_SYNC;
data_.wifi_mode = DEFAULT_WIFI_MODE;
strcpy(data_.station_ssid, DEFAULT_STATION_SSID);
strcpy(data_.station_password, DEFAULT_STATION_PASSWORD);
strcpy(data_.ap_ssid, DEFAULT_AP_SSID);
strcpy(data_.ap_password, DEFAULT_AP_PASSWORD);
data_.ap_channel = DEFAULT_AP_CHANNEL;
data_.enable_fallback_ap = DEFAULT_ENABLE_FALLBACK_AP;
data_.log_level = DEFAULT_LOG_LEVEL;
data_.updated_at = millis(); // Use current uptime as timestamp
data_.version = DEFAULT_VERSION;
}
bool Settings::fileExists() {
return LittleFS.exists(filename_);
}
bool Settings::load() {
if (!fileExists()) {
// File doesn't exist, use defaults
setDefaults();
}else{
File file = LittleFS.open(filename_, "r");
if (!file) {
LOG_ERROR("Failed to open settings file for reading");
setDefaults();
return false;
}
// Read file into buffer
size_t fileSize = file.size();
uint8_t buffer[fileSize];
file.read(buffer, fileSize);
file.close();
// Decode protobuf
pb_istream_t stream = pb_istream_from_buffer(buffer, fileSize);
bool status = pb_decode(&stream, settings_SettingsData_fields, &data_);
if (!status) {
LOG_ERROR("Failed to decode settings protobuf");
setDefaults();
return false;
}
}
// callback if set
runCallback();
return true;
}
bool Settings::save() {
// Update timestamp and version
data_.updated_at = millis();
data_.version++;
// Encode to buffer first to get size
size_t bufferSize = 0;
pb_get_encoded_size(&bufferSize, settings_SettingsData_fields, &data_);
uint8_t buffer[bufferSize];
pb_ostream_t stream = pb_ostream_from_buffer(buffer, bufferSize);
bool status = pb_encode(&stream, settings_SettingsData_fields, &data_);
if (!status) {
LOG_ERROR("Failed to encode settings protobuf");
return false;
}
// Write to file
File file = LittleFS.open(filename_, "w");
if (!file) {
LOG_ERROR("Failed to open settings file for writing");
return false;
}
size_t bytesWritten = file.write(buffer, bufferSize);
file.close();
if (bytesWritten != bufferSize) {
LOG_ERROR("Failed to write complete settings file");
return false;
}
return true;
}
void Settings::setData(const settings_SettingsData& newData) {
data_ = newData;
data_.updated_at = millis();
data_.version++;
// Notify callback if set
runCallback();
}
void Settings::resetToDefaults() {
setDefaults();
}
struct SettingsCallbackParams {
settings_SettingsData data;
std::function<void(const settings_SettingsData&)> callback;
};
static void settingsChangeTask(void* pvParameters) {
SettingsCallbackParams* params = static_cast<SettingsCallbackParams*>(pvParameters);
if (params->callback) {
vTaskDelay(100 / portTICK_PERIOD_MS); // Small delay to ensure system stability
LOG_DEBUG("Settings (Task): Executing change callback...");
params->callback(params->data);
LOG_DEBUG("Settings (Task): Callback finished.");
}
delete params;
vTaskDelete(NULL);
}
void Settings::runCallback()
{
if (changeCallback_) {
SettingsCallbackParams* params = new SettingsCallbackParams();
params->data = data_; // Create a safe copy for the task
params->callback = changeCallback_;
xTaskCreate(
settingsChangeTask,
"SettingsChangeTask",
4096, // Stack size
params,
1, // Priority
NULL
);
}
}
void Settings::setChangeCallback(std::function<void(const settings_SettingsData&)> callback) {
changeCallback_ = callback;
}
Settings settings;
+56
View File
@@ -0,0 +1,56 @@
#pragma once
#include <LittleFS.h>
#include <settings.pb.h>
class Settings {
public:
Settings();
~Settings();
// Set callback that gets called when settings change
void setChangeCallback(std::function<void(const settings_SettingsData&)> callback);
// Load settings from LittleFS, use defaults if file doesn't exist
bool load();
// Save current settings to LittleFS
bool save();
// Get reference to settings data
settings_SettingsData& getData() { return data_; }
// Set settings data and update timestamp/version
void setData(const settings_SettingsData& newData);
// Reset to default values
void resetToDefaults();
private:
void runCallback();
settings_SettingsData data_;
const char* filename_ = "/settings.pb";
std::function<void(const settings_SettingsData&)> changeCallback_ = nullptr;
SemaphoreHandle_t dataMutex_ = nullptr;
// Default values
static constexpr const char* DEFAULT_SYNC_SERVER_URL = "https://sync.example.com/api";
static constexpr const char* DEFAULT_DEVICE_API_KEY = "";
static constexpr uint32_t DEFAULT_SYNC_INTERVAL = 1800;
static constexpr bool DEFAULT_AUTO_SYNC = true;
static constexpr settings_WifiMode DEFAULT_WIFI_MODE = settings_WifiMode_WIFI_MODE_AP_STATION;
static constexpr const char* DEFAULT_STATION_SSID = "HOME";
static constexpr const char* DEFAULT_STATION_PASSWORD = "redacted";
static constexpr const char* DEFAULT_AP_SSID = "RFID-Master-AP";
static constexpr const char* DEFAULT_AP_PASSWORD = "rfid12345";
static constexpr uint32_t DEFAULT_AP_CHANNEL = 6;
static constexpr bool DEFAULT_ENABLE_FALLBACK_AP = true;
static constexpr settings_LogLevel DEFAULT_LOG_LEVEL = settings_LogLevel_LOG_LEVEL_INFO;
static constexpr uint32_t DEFAULT_VERSION = 1;
// Helper methods
void setDefaults();
bool fileExists();
};
extern Settings settings;
+392
View File
@@ -0,0 +1,392 @@
#include "users.hpp"
#include <pb_encode.h>
#include <pb_decode.h>
#include <Arduino.h>
#include <cstring>
#include <functional>
#include <SHA256.h>
#include <logger.hpp>
// Definition of the global instance.
Users users;
// =================================================================
// == 1. NANOPB STREAM HELPERS for Arduino File Objects
// =================================================================
// A state structure to pass the Arduino File object to Nanopb's stream callbacks.
struct FileStreamState {
File* file;
};
// Nanopb callback for writing data to a File.
static bool writeFileCallback(pb_ostream_t *stream, const uint8_t *buf, size_t count) {
FileStreamState* state = (FileStreamState*)stream->state;
return state->file->write(buf, count) == count;
}
// Nanopb callback for reading from a File.
static bool readFileCallback(pb_istream_t *stream, uint8_t *buf, size_t count) {
FileStreamState* state = (FileStreamState*)stream->state;
if (count == 0) return true;
size_t len = state->file->read(buf, count);
if (len < count) {
stream->bytes_left = 0; // Signal End-Of-File
}
return len > 0 || count == 0;
}
// Helper to create a Nanopb output stream from an Arduino File object.
static pb_ostream_t pb_ostream_from_file(File &file) {
FileStreamState* state = new FileStreamState{&file};
pb_ostream_t stream = {&writeFileCallback, state, SIZE_MAX, 0};
return stream;
}
// Helper to create a Nanopb input stream from an Arduino File object.
static pb_istream_t pb_istream_from_file(File &file) {
FileStreamState* state = new FileStreamState{&file};
pb_istream_t stream = {&readFileCallback, state, file.size()};
return stream;
}
// This structure is used to pass data to the nanopb decode callbacks
// when decoding into a single, stack-allocated UserData object.
struct DecodeTarget {
UserData* user_buffer;
};
// Nanopb callback to decode a username string into our temporary buffer.
static bool decodeUsernameCallback(pb_istream_t *stream, const pb_field_t *field, void **arg) {
DecodeTarget* target = (DecodeTarget*)*arg;
// 1. Store the length of the incoming string BEFORE reading from the stream.
size_t len = stream->bytes_left;
if (len >= sizeof(target->user_buffer->username)) {
return false; // Input string is too long for our buffer.
}
// 2. Read 'len' bytes. This will modify stream->bytes_left.
if (!pb_read(stream, (uint8_t*)target->user_buffer->username, len)) {
return false;
}
// 3. Use the stored length 'len' to place the null terminator correctly.
target->user_buffer->username[len] = '\0';
return true;
}
// Nanopb callback to decode a password hash (bytes) into our temporary buffer.
static bool decodePasswordCallback(pb_istream_t *stream, const pb_field_t *field, void **arg) {
DecodeTarget* target = (DecodeTarget*)*arg;
if (stream->bytes_left != sizeof(target->user_buffer->password_hash)) {
return false; // A SHA-256 hash MUST be 32 bytes.
}
return pb_read(stream, target->user_buffer->password_hash, stream->bytes_left);
}
// =================================================================
// == 2. USERS CLASS IMPLEMENTATION
// =================================================================
Users::Users() {}
Users::~Users() {}
void Users::hashPassword(const char* password, uint8_t* hash_out) const {
SHA256 sha256;
sha256.update(password, strlen(password));
sha256.finalize(hash_out, 32);
}
// --- PRIVATE STATIC HELPERS (Moved inside the class scope) ---
// Generic helper function to iterate over all users in the file
// and execute a callback for each one. The callback can stop the iteration by returning false.
bool Users::_iterateUsers(const char* filename, const std::function<bool(UserData&)>& user_callback) {
// check if file exists
if(!LittleFS.exists(filename)){
return true; // No file is not an error, just means no users to iterate.
}
File file = LittleFS.open(filename, "r");
if (!file || file.size() == 0) {
return true; // No file is not an error, just means no users to iterate.
}
pb_istream_t istream = pb_istream_from_file(file);
UserData user_buffer; // Single user buffer on the stack
DecodeTarget target = { &user_buffer };
webui_WebUiUser user_msg = webui_WebUiUser_init_zero;
user_msg.password_hash.funcs.decode = &decodePasswordCallback;
user_msg.password_hash.arg = &target;
bool continue_iterating = true;
while (istream.bytes_left > 0) {
memset(&user_buffer, 0, sizeof(user_buffer));
if (pb_decode_delimited(&istream, webui_WebUiUser_fields, &user_msg)) {
strcpy(user_buffer.username, user_msg.username);
if (!user_callback(user_buffer)) {
continue_iterating = false;
break;
}
} else {
// Decoding failed, stop iterating.
break;
}
}
delete (FileStreamState*)istream.state;
file.close();
return continue_iterating;
}
// Generic helper for streaming transformations (delete, update).
// The callback modifies a user buffer and returns true to keep it, false to delete it.
bool Users::_transformUsers(const char* src_filename, const std::function<bool(UserData&)>& transform_callback) {
const char* tmp_filename = "/users.pb.tmp";
File src_file = LittleFS.open(src_filename, "r");
if (!src_file) return true;
File dst_file = LittleFS.open(tmp_filename, "w");
if (!dst_file) {
src_file.close();
return false;
}
pb_istream_t istream = pb_istream_from_file(src_file);
pb_ostream_t ostream = pb_ostream_from_file(dst_file);
bool success = true;
UserData user_buffer; // Our 1-user buffer
// --- CORRECTION START ---
// 1. A structure ONLY for decoding (reading)
DecodeTarget decode_target = { &user_buffer };
webui_WebUiUser decode_msg = webui_WebUiUser_init_zero;
decode_msg.password_hash.funcs.decode = &decodePasswordCallback;
decode_msg.password_hash.arg = &decode_target;
// 2. A structure ONLY for encoding (writing)
webui_WebUiUser encode_msg = webui_WebUiUser_init_zero;
encode_msg.password_hash.funcs.encode = [](pb_ostream_t *stream, const pb_field_t *field, void * const *arg) {
UserData* u = (UserData*)*arg;
return pb_encode_tag_for_field(stream, field) && pb_encode_string(stream, u->password_hash, sizeof(u->password_hash));
};
// 3. Main loop: Decode with decode_msg, transform user_buffer, encode with encode_msg
while (istream.bytes_left > 0 && pb_decode_delimited(&istream, webui_WebUiUser_fields, &decode_msg)) {
strcpy(user_buffer.username, decode_msg.username);
if (transform_callback(user_buffer)) {
// Keep: Encode the content from the buffer with the encode_msg structure
strcpy(encode_msg.username, user_buffer.username);
encode_msg.password_hash.arg = &user_buffer;
if (!pb_encode_delimited(&ostream, webui_WebUiUser_fields, &encode_msg)) {
success = false;
break;
}
}
}
// --- Rest of the function remains the same ---
delete (FileStreamState*)istream.state;
delete (FileStreamState*)ostream.state;
src_file.close();
dst_file.close();
if (!success) {
LittleFS.remove(tmp_filename);
return false;
}
if (LittleFS.exists(src_filename)) {
LittleFS.remove(src_filename);
}
return LittleFS.rename(tmp_filename, src_filename);
}
// --- PUBLIC READ-ONLY METHODS ---
size_t Users::getUserCount() {
if(initialized_) {
return userCount_;
}
size_t count = 0;
_iterateUsers(filename_, [&](UserData& user) {
count++;
return true; // Continue iterating
});
userCount_ = count;
initialized_ = true;
return count;
}
bool Users::userExists(const char* username) const {
bool found = false;
_iterateUsers(filename_, [&](UserData& user) {
if (strcmp(user.username, username) == 0) {
found = true;
return false; // Stop iterating
}
return true; // Continue iterating
});
return found;
}
bool Users::checkPassword(const char* username, const char* password) const {
bool success = false;
_iterateUsers(filename_, [&](UserData& user) {
if (strcmp(user.username, username) == 0) {
uint8_t provided_hash[32];
hashPassword(password, provided_hash);
if (memcmp(user.password_hash, provided_hash, 32) == 0) {
success = true;
}
return false; // Stop iterating, user found.
}
return true; // Continue iterating
});
return success;
}
bool Users::loadUsernamesCallback(const std::function<bool(const char *)> &callback) const
{
if (!callback) return false;
return _iterateUsers(filename_, [&](UserData &user) {
auto res = callback(user.username);
if (!res) return false; // Stop iterating
return true; // Continue iterating
});
}
bool Users::addUser(const char* username, const char* password) {
// --- Phase 1: Checking preconditions ---
bool exists = userExists(username);
size_t count = getUserCount();
if (exists || count >= MAX_USERS) {
if (exists) {
LOG_ERROR("addUser: User already exists. Aborting.");
} else {
LOG_ERROR("addUser: User limit reached. Aborting.");
}
return false;
}
// --- Phase 2: Preparing the files ---
const char* tmp_filename = "/users.pb.tmp";
File dst_file = LittleFS.open(tmp_filename, "w");
if (!dst_file) {
LOG_ERROR("addUser: Failed to open temporary file. Check filesystem space and permissions.");
return false;
}
File src_file = LittleFS.open(filename_, "r");
if (src_file && src_file.size() > 0) {
uint8_t buffer[128];
while (src_file.available()) {
size_t len = src_file.read(buffer, sizeof(buffer));
dst_file.write(buffer, len);
}
src_file.close();
} else {
if (src_file) src_file.close();
}
// --- Phase 3: Appending the new user ---
pb_ostream_t ostream = pb_ostream_from_file(dst_file);
webui_WebUiUser new_user_msg = webui_WebUiUser_init_zero;
// Prepare username for the encoder
strcpy(new_user_msg.username, username);
// Prepare password hash for the encoder
uint8_t password_hash[32];
hashPassword(password, password_hash); // Important: Hash is created here, not in the lambda
new_user_msg.password_hash.arg = password_hash;
new_user_msg.password_hash.funcs.encode = [](pb_ostream_t *stream, const pb_field_t *field, void * const *arg) {
return pb_encode_tag_for_field(stream, field) && pb_encode_string(stream, (uint8_t*)*arg, 32);
};
bool status = pb_encode_delimited(&ostream, webui_WebUiUser_fields, &new_user_msg);
// Important: Always release resources (stream state and file)
delete (FileStreamState*)ostream.state;
dst_file.close();
if (!status) {
LOG_ERROR("addUser: Protobuf encoding (pb_encode_delimited) failed!");
LOG_DEBUG("addUser: Removing failed temporary file: %s\n", tmp_filename);
LittleFS.remove(tmp_filename);
return false;
}
// --- Phase 4: Atomic replacement of the old file ---
if (LittleFS.exists(filename_)) {
if (!LittleFS.remove(filename_)) {
LOG_ERROR("addUser: Could not remove old file '%s'. Aborting.\n", filename_);
LittleFS.remove(tmp_filename); // Cleanup temp file
return false;
}
}
bool rename_ok = LittleFS.rename(tmp_filename, filename_);
if (rename_ok) {
} else {
LOG_ERROR("addUser: Final rename failed! Filesystem might be in an inconsistent state.");
LittleFS.remove(tmp_filename); // Attempt to clean up
}
if (rename_ok) {
userCount_++;
}
return rename_ok;
}
bool Users::deleteUser(const char* username) {
bool user_was_found = false;
bool success = _transformUsers(filename_, [&](UserData& user) {
if (strcmp(user.username, username) == 0) {
user_was_found = true;
return false; // Delete this user
}
return true; // Keep this user
});
bool user_deleted = success && user_was_found;
if (user_deleted) {
userCount_--;
}
return user_deleted;
}
bool Users::updatePassword(const char* username, const char* newPassword) {
bool user_was_found = false;
bool success = _transformUsers(filename_, [&](UserData& user) {
if (strcmp(user.username, username) == 0) {
user_was_found = true;
hashPassword(newPassword, user.password_hash);
}
return true; // Always keep the user, just maybe modified.
});
return success && user_was_found;
}
void Users::reset() {
if (LittleFS.exists(filename_)) {
LittleFS.remove(filename_);
}
userCount_ = 0;
initialized_ = false;
}
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <LittleFS.h>
#include <webui.pb.h> // Ensure this is the version with 'bytes password_hash'
#include <functional> // Required for std::function
// This struct definition is correct and necessary.
struct UserData {
char username[33]; // 32 chars + null terminator
uint8_t password_hash[32]; // 32-byte raw SHA-256 hash
};
class Users {
public:
Users();
~Users();
bool addUser(const char* username, const char* password);
bool deleteUser(const char* username);
bool updatePassword(const char* username, const char* newPassword);
bool userExists(const char* username) const;
bool checkPassword(const char* username, const char* password) const;
bool loadUsernamesCallback(const std::function<bool(const char*)>& callback) const;
size_t getUserCount();
void reset();
private:
const char* filename_ = "/users.pb";
static constexpr size_t MAX_USERS = 5;
mutable size_t userCount_ = 0;
mutable bool initialized_ = false;
void hashPassword(const char* password, uint8_t* hash_out) const; static bool _iterateUsers(const char* filename, const std::function<bool(UserData&)>& user_callback);
static bool _transformUsers(const char* src_filename, const std::function<bool(UserData&)>& transform_callback);
};
extern Users users;
+168
View File
@@ -0,0 +1,168 @@
#include "hardware_rtc.hpp"
#include <logger.hpp>
HardwareRTC::HardwareRTC(TwoWire& wire): wire(wire), initialized(false) {}
HardwareRTC::~HardwareRTC() {}
void HardwareRTC::begin() {
if(wire.getClock() == 0) {
wire.begin();
wire.setClock(100000); // Set to 100kHz
}
initialized = true;
LOG_DEBUG("RTC initialized");
setSystemTime(0);
}
bool HardwareRTC::setTime(time_t unixTimestamp) {
if (!initialized) {
LOG_WARN("RTC not initialized");
return false;
}
// Use time_t, which on ESP32 is 64-bit and safe for this conversion
time_t t = unixTimestamp;
struct tm timeStruct;
gmtime_r(&t, &timeStruct);
// Check if the year is within the DS1307's valid range (2000-2099)
if (timeStruct.tm_year < 100 || timeStruct.tm_year > 199) {
// Year is out of the 2000-2099 range that the DS1307 can store.
LOG_ERROR("RTC setTime: Year %d is out of range (2000-2099)", timeStruct.tm_year + 1900);
return false;
}
uint8_t buffer[7];
buffer[0] = decToBcd(timeStruct.tm_sec);
buffer[1] = decToBcd(timeStruct.tm_min);
buffer[2] = decToBcd(timeStruct.tm_hour);
buffer[3] = decToBcd(timeStruct.tm_wday + 1);
buffer[4] = decToBcd(timeStruct.tm_mday);
buffer[5] = decToBcd(timeStruct.tm_mon + 1);
// The DS1307 only stores years 00-99. We handle this by taking the year since 2000.
buffer[6] = decToBcd(timeStruct.tm_year - 100);
writeRegisters(buffer, 7);
// set system time to match RTC
setSystemTime(0);
LOG_DEBUG("RTC setTime: %s", toDateString(unixTimestamp).c_str());
return true;
}
time_t HardwareRTC::getTime() {
if (!initialized) return 0;
uint8_t buffer[7];
readRegisters(buffer, 7);
struct tm timeStruct = {0}; // Important: Initialize the struct to zero
timeStruct.tm_sec = bcdToDec(buffer[0] & 0x7F);
timeStruct.tm_min = bcdToDec(buffer[1]);
timeStruct.tm_hour = bcdToDec(buffer[2] & 0x3F);
timeStruct.tm_wday = bcdToDec(buffer[3]) - 1;
timeStruct.tm_mday = bcdToDec(buffer[4]);
timeStruct.tm_mon = bcdToDec(buffer[5]) - 1;
// Assume all 2-digit years from the RTC are in the 21st century (2000-2099)
timeStruct.tm_year = bcdToDec(buffer[6]) + 100; // Years since 1900
// mktime converts a local time struct to a time_t.
// Since the RTC stores time without timezone info, we treat it as UTC.
// timegm is the correct function for this, but mktime is often used
// on embedded systems with the timezone set to UTC.
time_t t = mktime(&timeStruct);
LOG_DEBUG("RTC getTime: %s", toDateString(t).c_str());
// Cast the 64-bit time_t to uint64_t for the return type
return static_cast<time_t>(t);
}
void HardwareRTC::setSystemTime(int timezoneOffsetHours) {
if (!initialized) {
LOG_WARN("RTC not initialized");
return;
}
uint64_t unixTime = getTime();
if (unixTime == 0) return;
unixTime += timezoneOffsetHours * 3600;
LOG_DEBUG("RTC setSystemTime: %s", toDateString(unixTime).c_str());
struct timeval tv;
// The tv_sec field is of type time_t, which is 64-bit on ESP32
tv.tv_sec = unixTime;
tv.tv_usec = 0;
settimeofday(&tv, NULL);
}
bool HardwareRTC::isRunning() {
if (!initialized) return false;
uint8_t buffer[1];
readRegisters(buffer, 1);
return !(buffer[0] & 0x80); // CH bit is 0 when running
}
void HardwareRTC::setTimezone(const char* timezone) {
setenv("TZ", timezone, 1);
tzset();
}
void HardwareRTC::update()
{
static uint32_t nextSync = 0;
time_t now = millis();
if (now >= nextSync) {
if(!isRunning()) {
LOG_WARN("RTC is not running, skipping update");
return;
}
setSystemTime(0);
nextSync = now + (15UL * 60UL * 1000UL); // every 15 minutes
LOG_DEBUG("RTC update: System time synchronized with RTC");
}
}
String HardwareRTC::toDateString(time_t timestamp) {
struct tm* timeinfo = gmtime(&timestamp);
char buffer[40];
strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", timeinfo);
return String(buffer);
}
uint8_t HardwareRTC::bcdToDec(uint8_t bcd) {
return ((bcd >> 4) * 10) + (bcd & 0x0F);
}
uint8_t HardwareRTC::decToBcd(uint8_t dec) {
return ((dec / 10) << 4) | (dec % 10);
}
void HardwareRTC::readRegisters(uint8_t* buffer, uint8_t length) {
Wire.beginTransmission(DS1307_ADDRESS);
Wire.write(0x00); // start at register 0
Wire.endTransmission();
Wire.requestFrom(DS1307_ADDRESS, length);
for (uint8_t i = 0; i < length; i++) {
buffer[i] = Wire.read();
}
}
void HardwareRTC::writeRegisters(uint8_t* buffer, uint8_t length) {
Wire.beginTransmission(DS1307_ADDRESS);
Wire.write(0x00); // start at register 0
for (uint8_t i = 0; i < length; i++) {
Wire.write(buffer[i]);
}
Wire.endTransmission();
}
+53
View File
@@ -0,0 +1,53 @@
#pragma once
#include <Wire.h>
#include <time.h>
#include <sys/time.h>
#include <logger.hpp>
#define DS1307_ADDRESS 0x68
#define EEPROM_ADDRESS 0x50
class HardwareRTC {
public:
HardwareRTC(TwoWire& wire = Wire);
~HardwareRTC();
void begin();
// Set RTC time from a 64-bit Unix timestamp
bool setTime(time_t unixTimestamp);
// Get current time as a 64-bit Unix timestamp
time_t getTime();
static time_t getSystemTime() {
struct timeval tv;
if (gettimeofday(&tv, nullptr) != 0) {
LOG_ERROR("Failed to get system time for log entry");
return 0; // Fallback-Wert
} else {
return tv.tv_sec; // Unix-Timestamp in Sekunden
}
gettimeofday(&tv, NULL);
return tv.tv_sec;
}
// Helper method to convert time_t to date string
static String toDateString(time_t timestamp);
void setSystemTime(int timezoneOffsetHours = 0);
bool isRunning();
void setTimezone(const char* timezone);
void update(); // Placeholder for future use
private:
bool initialized;
TwoWire& wire;
uint8_t bcdToDec(uint8_t bcd);
uint8_t decToBcd(uint8_t dec);
void readRegisters(uint8_t* buffer, uint8_t length);
void writeRegisters(uint8_t* buffer, uint8_t length);
};
+222
View File
@@ -0,0 +1,222 @@
#include "hardware_serial.hpp"
#include <Arduino.h>
#include <logger.hpp>
//#define DEBUG_PROTO
#ifdef DEBUG_PROTO
#define LOG(msg) LOG_DEBUG(msg)
#else
#define LOG(msg)
#endif
ProtoSerial::ProtoSerial()
: serial(nullptr), initialized(false), lastError{0}, callback(nullptr),
currentState(WAITING_FOR_SYNC_START), payloadLength(0), messageAvailable(false),
lastUpdate(0), lastByteTime(0) {
receivedMessage = INCOMING_MESSAGE_INIT;
}
ProtoSerial::~ProtoSerial() {
// No delete needed; serial is caller-managed
}
void ProtoSerial::begin(SerialType& serialPort) {
serial = &serialPort;
initialized = true;
currentState = WAITING_FOR_SYNC_START;
payloadLength = 0;
messageAvailable = false;
lastError[0] = '\0';
lastUpdate = 0;
lastByteTime = 0;
snprintf(lastError, sizeof(lastError), "Initialized ProtoSerial");
LOG(lastError);
}
bool ProtoSerial::sendMessage(const OutgoingMessage& message) {
if (!initialized || !serial || !serial->availableForWrite()) {
snprintf(lastError, sizeof(lastError), "Serial not initialized or not ready");
LOG(lastError);
return false;
}
uint8_t buffer[OUTGOING_MESSAGE_SIZE];
pb_ostream_t stream = pb_ostream_from_buffer(buffer, sizeof(buffer));
// Encode the message
if (!pb_encode(&stream, OUTGOING_MESSAGE_FIELDS, &message)) {
snprintf(lastError, sizeof(lastError), "Failed to encode message");
LOG(lastError);
return false;
}
uint16_t messageLength = stream.bytes_written;
if (messageLength > sizeof(buffer)) {
snprintf(lastError, sizeof(lastError), "Encoded message too large: %u", messageLength);
LOG(lastError);
return false;
}
// Send framing: sync start, length, payload, CRC, sync end
serial->write(SYNC_START);
serial->write((uint8_t*)&messageLength, sizeof(messageLength));
serial->write(buffer, messageLength);
uint8_t crc = calculate_crc8(buffer, messageLength);
serial->write(crc);
serial->write(SYNC_END);
serial->flush();
#ifdef DEBUG_PROTO
dumpHex(buffer, messageLength, "Sent");
snprintf(lastError, sizeof(lastError), "Sent message, length: %u, CRC: 0x%02X", messageLength, crc);
LOG(lastError);
#endif
return true;
}
void ProtoSerial::update() {
if (!initialized || !serial) {
snprintf(lastError, sizeof(lastError), "Serial not initialized");
LOG(lastError);
return;
}
// Throttle updates to ~100Hz
unsigned long now = millis();
if (now - lastUpdate < 10) return;
lastUpdate = now;
// Timeout if no data received for 1 second
if (currentState != WAITING_FOR_SYNC_START && now - lastByteTime > 1000) {
snprintf(lastError, sizeof(lastError), "Receive timeout");
LOG(lastError);
currentState = WAITING_FOR_SYNC_START;
payloadLength = 0;
}
while (serial->available()) {
lastByteTime = now;
switch (currentState) {
case WAITING_FOR_SYNC_START: {
uint8_t byte = serial->read();
if (byte == SYNC_START) {
currentState = WAITING_FOR_LENGTH;
LOG("Received SYNC_START");
}
break;
}
case WAITING_FOR_LENGTH: {
if (serial->available() >= sizeof(payloadLength)) {
serial->readBytes((uint8_t*)&payloadLength, sizeof(payloadLength));
if (payloadLength > sizeof(payloadBuffer) || payloadLength == 0) {
snprintf(lastError, sizeof(lastError), "Invalid payload length: %u", payloadLength);
LOG(lastError);
currentState = WAITING_FOR_SYNC_START;
payloadLength = 0;
} else {
currentState = READING_PAYLOAD;
snprintf(lastError, sizeof(lastError), "Received length: %u", payloadLength);
LOG(lastError);
}
}
break;
}
case READING_PAYLOAD: {
if (serial->available() >= payloadLength) {
serial->readBytes(payloadBuffer, payloadLength);
currentState = READING_CRC;
}
break;
}
case READING_CRC: {
if (serial->available() >= 1) {
uint8_t received_crc = serial->read();
uint8_t expected_crc = calculate_crc8(payloadBuffer, payloadLength);
if (received_crc != expected_crc) {
snprintf(lastError, sizeof(lastError), "CRC mismatch: received 0x%02X, expected 0x%02X", received_crc, expected_crc);
LOG(lastError);
} else if (serial->available() >= 1 && serial->read() != SYNC_END) {
snprintf(lastError, sizeof(lastError), "Missing SYNC_END");
LOG(lastError);
} else {
dumpHex(payloadBuffer, payloadLength, "Received");
processReceivedMessage(payloadBuffer, payloadLength);
snprintf(lastError, sizeof(lastError), "Received valid message, length: %u", payloadLength);
LOG(lastError);
}
currentState = WAITING_FOR_SYNC_START;
payloadLength = 0;
}
break;
}
}
}
}
bool ProtoSerial::hasMessage() const {
return messageAvailable;
}
const IncomingMessage& ProtoSerial::getMessage() const {
return receivedMessage;
}
void ProtoSerial::clearMessage() {
messageAvailable = false;
receivedMessage = INCOMING_MESSAGE_INIT;
snprintf(lastError, sizeof(lastError), "Message cleared");
LOG(lastError);
}
void ProtoSerial::setCallback(Callback cb) {
callback = cb;
snprintf(lastError, sizeof(lastError), "Callback set");
LOG(lastError);
}
void ProtoSerial::processReceivedMessage(uint8_t* buffer, uint16_t length) {
receivedMessage = INCOMING_MESSAGE_INIT;
pb_istream_t stream = pb_istream_from_buffer(buffer, length);
if (pb_decode(&stream, INCOMING_MESSAGE_FIELDS, &receivedMessage)) {
if (receivedMessage.which_payload == 0) {
snprintf(lastError, sizeof(lastError), "Invalid message: which_payload not set");
LOG(lastError);
return;
}
messageAvailable = true;
if (callback) {
callback(receivedMessage);
}
snprintf(lastError, sizeof(lastError), "Message decoded successfully");
LOG(lastError);
} else {
snprintf(lastError, sizeof(lastError), "Failed to decode message");
LOG(lastError);
}
}
uint8_t ProtoSerial::calculate_crc8(const uint8_t* data, uint16_t len) {
uint8_t crc = 0x00;
for (uint16_t i = 0; i < len; i++) {
crc ^= data[i];
for (uint8_t j = 0; j < 8; j++) {
if (crc & 0x80) crc = (crc << 1) ^ 0x31; // CRC-8 polynomial
else crc <<= 1;
}
}
return crc;
}
void ProtoSerial::dumpHex(const uint8_t* data, uint16_t len, const char* label) {
#ifdef DEBUG_PROTO
Serial.print(label);
Serial.print(": ");
for (uint16_t i = 0; i < len; i++) {
if (i % 16 == 0) Serial.println();
Serial.printf("%02X ", data[i]);
}
Serial.println();
#endif
}
ProtoSerial pserial;
+171
View File
@@ -0,0 +1,171 @@
#pragma once
#ifdef ESP32
#include <HardwareSerial.h>
typedef HardwareSerial SerialType;
#define SERIAL_TYPE HardwareSerial
#elif defined(ESP8266)
#include <HardwareSerial.h>
typedef HardwareSerial SerialType;
#define SERIAL_TYPE HardwareSerial
#else
#error "Unsupported platform. Only ESP32 and ESP8266 are supported."
#endif
#include <pb_encode.h>
#include <pb_decode.h>
#include "hardware.pb.h"
// Define the role of this device
// Uncomment one of the following defines to set the role
#define HARDWARE_SERIAL_ROLE_CONTROL
// #define HARDWARE_SERIAL_ROLE_SENSOR
// Based on role, define the message types
#if defined(HARDWARE_SERIAL_ROLE_CONTROL)
typedef hardware_SensorToControlMessage IncomingMessage;
typedef hardware_ControlToSensorMessage OutgoingMessage;
#define INCOMING_MESSAGE_SIZE hardware_SensorToControlMessage_size
#define OUTGOING_MESSAGE_SIZE hardware_ControlToSensorMessage_size
#define INCOMING_MESSAGE_FIELDS hardware_SensorToControlMessage_fields
#define OUTGOING_MESSAGE_FIELDS hardware_ControlToSensorMessage_fields
#define INCOMING_MESSAGE_INIT hardware_SensorToControlMessage_init_zero
#define OUTGOING_MESSAGE_INIT hardware_ControlToSensorMessage_init_zero
#elif defined(HARDWARE_SERIAL_ROLE_SENSOR)
typedef hardware_ControlToSensorMessage IncomingMessage;
typedef hardware_SensorToControlMessage OutgoingMessage;
#define INCOMING_MESSAGE_SIZE hardware_ControlToSensorMessage_size
#define OUTGOING_MESSAGE_SIZE hardware_SensorToControlMessage_size
#define INCOMING_MESSAGE_FIELDS hardware_ControlToSensorMessage_fields
#define OUTGOING_MESSAGE_FIELDS hardware_SensorToControlMessage_fields
#define INCOMING_MESSAGE_INIT hardware_ControlToSensorMessage_init_zero
#define OUTGOING_MESSAGE_INIT hardware_ControlToSensorMessage_init_zero
#else
#error "Must define either HARDWARE_SERIAL_ROLE_CONTROL or HARDWARE_SERIAL_ROLE_SENSOR"
#endif
/**
* @class ProtoSerial
* @brief Manages serial communication with Protocol Buffers for ESP32/ESP8266.
* Supports length-prefixed messages with CRC-8 checksum and sync bytes.
* Configured for SENSOR or CONTROL role via HARDWARE_SERIAL_ROLE_* macros.
* On ESP8266, uses UART0 (Serial, RX=GPIO3, TX=GPIO1). On ESP32, uses caller-provided UART.
*/
class ProtoSerial {
public:
using Callback = void (*)(const IncomingMessage&); ///< Callback for received messages.
/**
* @brief Constructor.
*/
ProtoSerial();
/**
* @brief Destructor. No-op (serial is managed by caller).
*/
~ProtoSerial();
/**
* @brief Initializes serial communication with a caller-provided serial port.
* @param serialPort Reference to a configured SerialType (e.g., Serial for ESP8266, HardwareSerial for ESP32).
*/
void begin(SerialType& serialPort);
/**
* @brief Sends a Protocol Buffers message with sync bytes and CRC-8.
* @param message The message to send (OutgoingMessage type).
* @return True if sent successfully, false otherwise.
*/
bool sendMessage(const OutgoingMessage& message);
/**
* @brief Checks for incoming messages (non-blocking).
*/
void update();
/**
* @brief Checks if a message is available.
* @return True if a message is ready to be read.
*/
bool hasMessage() const;
/**
* @brief Gets the received message.
* @return Reference to the received message (valid only if hasMessage() is true).
*/
const IncomingMessage& getMessage() const;
/**
* @brief Clears the received message.
*/
void clearMessage();
/**
* @brief Sets the callback for received messages.
* @param cb Callback function to handle incoming messages.
*/
void setCallback(Callback cb);
/**
* @brief Checks if the serial interface is initialized.
* @return True if initialized.
*/
bool isInitialized() const { return initialized; }
/**
* @brief Gets the last error message (for debugging).
* @return Last error string or empty if none.
*/
const char* getLastError() const { return lastError; }
private:
SerialType* serial; ///< Serial interface (pointer to caller-provided SerialType).
bool initialized; ///< Tracks initialization state.
char lastError[64]; ///< Last error message (fixed-size to avoid heap issues).
Callback callback; ///< Callback for received messages.
// Framing constants
static const uint8_t SYNC_START = 0xAA; ///< Start byte for message framing.
static const uint8_t SYNC_END = 0xBB; ///< End byte for message framing.
// Receive state machine
enum RxState {
WAITING_FOR_SYNC_START, ///< Waiting for start byte (0xAA).
WAITING_FOR_LENGTH, ///< Waiting for 2-byte length.
READING_PAYLOAD, ///< Reading payload bytes.
READING_CRC ///< Reading CRC-8 byte.
};
RxState currentState; ///< Current state of receive state machine.
uint16_t payloadLength; ///< Length of incoming payload.
uint8_t payloadBuffer[INCOMING_MESSAGE_SIZE + 10]; ///< Buffer for payload (plus overhead).
IncomingMessage receivedMessage; ///< Decoded incoming message.
bool messageAvailable; ///< True if a message is ready.
unsigned long lastUpdate; ///< Timestamp of last update (for throttling).
unsigned long lastByteTime; ///< Timestamp of last byte received (for timeout).
// Helper methods
/**
* @brief Processes a received payload and decodes it.
* @param buffer Payload buffer.
* @param length Payload length.
*/
void processReceivedMessage(uint8_t* buffer, uint16_t length);
/**
* @brief Calculates CRC-8 for a buffer.
* @param data Buffer to compute CRC over.
* @param len Length of buffer.
* @return CRC-8 value.
*/
uint8_t calculate_crc8(const uint8_t* data, uint16_t len);
/**
* @brief Logs a hex dump of a buffer (debug only).
* @param data Buffer to dump.
* @param len Length of buffer.
* @param label Label for the dump.
*/
void dumpHex(const uint8_t* data, uint16_t len, const char* label);
};
extern ProtoSerial pserial;
+149
View File
@@ -0,0 +1,149 @@
#include "logger.hpp"
#include <Arduino.h>
#include <string.h>
#include <stdarg.h> // For va_list, vsnprintf
#include <sys/time.h>
Logger::Logger()
: logCount(0), logIndex(0), currentLogLevel(LOG_INFO),
logCallback(nullptr), logQueue(nullptr), logTaskHandle(nullptr) {}
Logger::~Logger() {
// Clean up FreeRTOS objects
if (logTaskHandle) vTaskDelete(logTaskHandle);
if (logQueue) vQueueDelete(logQueue);
}
void Logger::begin(UBaseType_t taskPriority, uint32_t taskStackSize) {
// Create a queue that can hold up to 10 LogEntry items.
logQueue = xQueueCreate(10, sizeof(LogEntry));
if (logQueue == nullptr) {
Serial.println("FATAL: Failed to create logger queue!");
return;
}
// Create the background task. It passes 'this' so the static function
// can access the instance's members.
xTaskCreate(
logProcessingTask,
"LoggerTask",
taskStackSize,
this, // Pass the current object instance to the task
taskPriority,
&logTaskHandle
);
if (logTaskHandle == nullptr) {
Serial.println("FATAL: Failed to create logger task!");
}
}
void Logger::addLog(LogLevel level, const char* format, va_list args) {
if (level < currentLogLevel) {
return;
}
LogEntry entry;
entry.level = level;
// Verwende Systemzeit statt millis()
struct timeval tv;
if (gettimeofday(&tv, nullptr) != 0) {
LOG_ERROR("Failed to get system time for log entry");
entry.timestamp = 0; // Fallback-Wert
} else {
entry.timestamp = tv.tv_sec; // Unix-Timestamp in Sekunden
}
vsnprintf(entry.message, sizeof(entry.message), format, args);
// Add to internal circular buffer for history
logs[logIndex] = entry;
logIndex = (logIndex + 1) % MAX_LOGS;
if (logCount < MAX_LOGS) {
logCount++;
}
if (logQueue != nullptr) {
xQueueSend(logQueue, &entry, pdMS_TO_TICKS(10));
}
}
// This is the function that runs in its own task.
void Logger::logProcessingTask(void* instance) {
// The instance pointer is the 'this' we passed in xTaskCreate.
Logger* loggerInstance = static_cast<Logger*>(instance);
LogEntry entryToProcess;
while (true) {
if (xQueueReceive(loggerInstance->logQueue, &entryToProcess, portMAX_DELAY) == pdPASS) {
if (loggerInstance->logCallback) {
loggerInstance->logCallback(entryToProcess);
}
}
}
}
void Logger::setLogLevel(LogLevel level) {
currentLogLevel = level;
}
LogLevel Logger::getLogLevel() const {
return currentLogLevel;
}
void Logger::setLogCallback(std::function<void(const LogEntry& entry)> callback) {
logCallback = callback;
}
// --- NEW: Public methods now handle the variable arguments ---
void Logger::debug(const char* format, ...) {
va_list args;
va_start(args, format);
addLog(LOG_DEBUG, format, args);
va_end(args);
}
void Logger::info(const char* format, ...) {
va_list args;
va_start(args, format);
addLog(LOG_INFO, format, args);
va_end(args);
}
void Logger::warn(const char* format, ...) {
va_list args;
va_start(args, format);
addLog(LOG_WARN, format, args);
va_end(args);
}
void Logger::error(const char* format, ...) {
va_list args;
va_start(args, format);
addLog(LOG_ERROR, format, args);
va_end(args);
}
const LogEntry* Logger::getLogs(int& count) const {
count = logCount;
return logs;
}
void Logger::clear() {
logCount = 0;
logIndex = 0;
}
const char* Logger::levelToString(LogLevel level) const {
switch (level) {
case LOG_DEBUG: return "D";
case LOG_INFO: return "I";
case LOG_WARN: return "W";
case LOG_ERROR: return "E";
default: return "U";
}
}
Logger logger;
+68
View File
@@ -0,0 +1,68 @@
#pragma once
#include <stdint.h>
#include <time.h>
#include <functional> // For std::function
#include <cstdarg>
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/task.h"
enum LogLevel {
LOG_DEBUG,
LOG_INFO,
LOG_WARN,
LOG_ERROR
};
struct LogEntry {
LogLevel level;
time_t timestamp;
char message[128];
};
class Logger {
public:
Logger();
~Logger();
void begin(UBaseType_t taskPriority = 1, uint32_t taskStackSize = 2048);
void setLogLevel(LogLevel level);
LogLevel getLogLevel() const;
// Use the more flexible std::function for callbacks
void setLogCallback(std::function<void(const LogEntry& entry)> callback);
// --- NEW: Variadic functions for printf-style formatting ---
void debug(const char* format, ...);
void info(const char* format, ...);
void warn(const char* format, ...);
void error(const char* format, ...);
const LogEntry* getLogs(int& count) const;
void clear();
const char* levelToString(LogLevel level) const;
private:
static const int MAX_LOGS = 20;
LogEntry logs[MAX_LOGS];
int logCount;
int logIndex;
LogLevel currentLogLevel;
std::function<void(const LogEntry& entry)> logCallback;
QueueHandle_t logQueue;
TaskHandle_t logTaskHandle;
static void logProcessingTask(void* instance);
void addLog(LogLevel level, const char* format, va_list args);
};
extern Logger logger;
// --- OPTIONAL: Performance-enhancing macros ---
#define LOG_DEBUG(format, ...) if(logger.getLogLevel() <= LOG_DEBUG) { logger.debug(format, ##__VA_ARGS__); }
#define LOG_INFO(format, ...) if(logger.getLogLevel() <= LOG_INFO) { logger.info(format, ##__VA_ARGS__); }
#define LOG_WARN(format, ...) if(logger.getLogLevel() <= LOG_WARN) { logger.warn(format, ##__VA_ARGS__); }
#define LOG_ERROR(format, ...) if(logger.getLogLevel() <= LOG_ERROR) { logger.error(format, ##__VA_ARGS__); }
+94
View File
@@ -0,0 +1,94 @@
#include "ota_manager.hpp"
OTAManager::OTAManager() : enabled(false), status("Disabled") {}
OTAManager::~OTAManager() {}
void OTAManager::begin() {
ArduinoOTA.setHostname("RFID-Master-Client");
ArduinoOTA.onStart([this]() {
this->onStart();
});
ArduinoOTA.onEnd([this]() {
this->onEnd();
});
ArduinoOTA.onProgress([this](unsigned int progress, unsigned int total) {
this->onProgress(progress, total);
});
ArduinoOTA.onError([this](ota_error_t error) {
this->onError(error);
});
LOG_INFO("OTA Manager initialized");
}
void OTAManager::enable() {
if (!enabled) {
ArduinoOTA.begin();
enabled = true;
status = "Enabled";
LOG_INFO("OTA enabled");
}
}
void OTAManager::disable() {
if (enabled) {
ArduinoOTA.end();
enabled = false;
status = "Disabled";
LOG_INFO("OTA disabled");
}
}
bool OTAManager::isEnabled() const {
return enabled;
}
String OTAManager::getStatus() const {
return status;
}
void OTAManager::onStart() {
status = "Starting OTA update...";
LOG_INFO("OTA update started");
}
void OTAManager::onEnd() {
status = "OTA update completed";
LOG_INFO("OTA update completed");
}
void OTAManager::onProgress(unsigned int progress, unsigned int total) {
status = "OTA progress: " + String(progress / (total / 100)) + "%";
LOG_DEBUG("OTA progress: %u/%u", progress, total);
}
void OTAManager::onError(ota_error_t error) {
String errorMsg;
switch (error) {
case OTA_AUTH_ERROR:
errorMsg = "Auth Failed";
break;
case OTA_BEGIN_ERROR:
errorMsg = "Begin Failed";
break;
case OTA_CONNECT_ERROR:
errorMsg = "Connect Failed";
break;
case OTA_RECEIVE_ERROR:
errorMsg = "Receive Failed";
break;
case OTA_END_ERROR:
errorMsg = "End Failed";
break;
default:
errorMsg = "Unknown Error";
break;
}
status = "OTA Error: " + errorMsg;
LOG_ERROR("OTA error: %s", errorMsg.c_str());
}
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include <ArduinoOTA.h>
#include <WiFi.h>
#include <logger.hpp>
class OTAManager {
public:
OTAManager();
~OTAManager();
void begin();
void enable();
void disable();
bool isEnabled() const;
String getStatus() const;
private:
bool enabled;
String status;
void onStart();
void onEnd();
void onProgress(unsigned int progress, unsigned int total);
void onError(ota_error_t error);
};
+6
View File
@@ -0,0 +1,6 @@
{
"name": "ota",
"dependencies": {
"hardware": "*"
}
}
+445
View File
@@ -0,0 +1,445 @@
#include "rpc.hpp"
#include <Arduino.h>
#include <WiFi.h>
#include <LittleFS.h>
#include <pb_encode.h>
#include <pb_decode.h>
#include <vector>
#include <cstring>
#include <HTTPClient.h>
#include <control_communication.pb.h>
#include <Stream.h>
#include <logger.hpp>
#include <hardware_rtc.hpp>
#define LAST_SYNC_TIME_FILE "/last_sync.time"
// ---- Constructor / Destructor ----
RPC::RPC() {
// NOTE: Initialization is done here. Ensure this global object is created
// AFTER core systems like LittleFS are initialized in setup().
apiKey_[0] = '\0';
baseUrl_[0] = '\0';
if (!accessLogQueue) {
accessLogQueue = xQueueCreate(100, sizeof(RpcAccessLogEntry));
if (!accessLogQueue) {
LOG_ERROR("RPC: FATAL: Failed to create access log queue!");
}
}
syncTriggerSemaphore_ = xSemaphoreCreateBinary();
if (!syncTriggerSemaphore_) {
LOG_ERROR("RPC: FATAL: Failed to create sync trigger semaphore!");
}
// Load the last sync time from persistent storage on startup.
this->lastSyncTime = loadLastSyncTime();
LOG_INFO("RPC: Instance created. Loaded last sync timestamp: %s", HardwareRTC::toDateString(this->lastSyncTime).c_str());
}
RPC::~RPC() {
stopAutoSync();
if (accessLogQueue) {
vQueueDelete(accessLogQueue);
accessLogQueue = nullptr;
}
if (syncTriggerSemaphore_) {
vSemaphoreDelete(syncTriggerSemaphore_);
syncTriggerSemaphore_ = nullptr;
}
}
// ---- Public Methods ----
bool RPC::sync(bool forceDownload) {
if (isSyncing_) {
logger.warn("RPC: Sync aborted, another sync is already in progress.");
return false;
}
if (baseUrl_[0] == '\0') {
LOG_ERROR("RPC: Sync aborted (Base URL not set). Call startAutoSync first.");
return false;
}
if (WiFi.status() != WL_CONNECTED) {
LOG_ERROR("RPC: Sync aborted (no WiFi connection).");
return false;
}
isSyncing_ = true;
LOG_INFO("RPC: Starting synchronization process...");
bool pendingChanges = false;
bool syncSuccess = false;
if (!syncLogsAndCheckForUpdates(&pendingChanges)) {
LOG_ERROR("RPC: Log synchronization failed.");
syncSuccess = false;
} else {
// Log sync was successful, now check if a DB download is needed.
if (pendingChanges || forceDownload) {
const char* dbPath = "/rfid.db";
LOG_DEBUG("RPC: Pending changes: %d, forceDownload: %d", pendingChanges, forceDownload);
if (!downloadDatabaseToFile(dbPath)) {
LOG_ERROR("RPC: Failed to download database.");
syncSuccess = false;
} else {
syncSuccess = true;
}
} else {
LOG_DEBUG("RPC: No pending changes reported by server, skipping database download.");
syncSuccess = true;
}
}
isSyncing_ = false;
if(syncSuccess) {
LOG_INFO("RPC: Synchronization completed successfully.");
} else {
LOG_ERROR("RPC: Synchronization failed.");
}
return syncSuccess;
}
void RPC::addAccessLog(uint32_t rfidId) {
if (!accessLogQueue) {
return; // Queue not created, do nothing.
}
struct timeval tv;
RpcAccessLogEntry entry;
entry.timestamp = HardwareRTC::getSystemTime();
entry.rfidId = rfidId;
if (xQueueSend(accessLogQueue, &entry, 0) != pdPASS) {
LOG_WARN("RPC: Access log queue is full, dropping entry.");
return;
}
// --- TRIGGER LOGIC ---
// If the queue has reached the threshold and we can sync, trigger it.
if (uxQueueMessagesWaiting(accessLogQueue) >= LOG_SYNC_THRESHOLD && baseUrl_[0] != '\0') {
LOG_INFO("RPC: Queue size: %d. Triggering immediate sync.", uxQueueMessagesWaiting(accessLogQueue));
xSemaphoreGive(syncTriggerSemaphore_);
}
}
// ---- Auto-Sync Task Management ----
void RPC::startAutoSync(uint32_t intervalMs) {
stopAutoSync(); // Always stop any existing task to ensure a clean restart.
if (baseUrl_[0] == '\0') {
LOG_ERROR("RPC: Cannot start auto-sync. Base URL is not configured.");
return;
}
LOG_INFO("RPC: Starting auto-sync");
syncIntervalMs = intervalMs;
autoSyncRunning = true;
BaseType_t result = xTaskCreate(
autoSyncTask, "AutoSyncTask", 8192, this, 1, &autoSyncTaskHandle
);
if (result != pdPASS) {
LOG_ERROR("RPC: Failed to create auto-sync task.");
autoSyncRunning = false;
autoSyncTaskHandle = nullptr;
} else {
LOG_INFO("RPC: Auto-sync task started successfully.");
}
}
void RPC::stopAutoSync() {
if (!autoSyncTaskHandle) {
return;
}
LOG_INFO("RPC: Attempting to stop auto-sync task...");
autoSyncRunning = false; // Signal the task to exit its loop
// Wake the task if it's waiting on the semaphore
if (syncTriggerSemaphore_) {
xSemaphoreGive(syncTriggerSemaphore_);
}
// Wait for the task to terminate itself (indicated by handle becoming nullptr)
uint32_t startTime = millis();
while (autoSyncTaskHandle != nullptr && millis() - startTime < 1000) {
vTaskDelay(pdMS_TO_TICKS(50));
}
// If the task did not stop gracefully, force its deletion
if (autoSyncTaskHandle != nullptr) {
LOG_ERROR("RPC: Task did not stop gracefully. Forcing deletion.");
vTaskDelete(autoSyncTaskHandle);
autoSyncTaskHandle = nullptr;
} else {
LOG_INFO("RPC: Auto-sync task stopped gracefully.");
}
}
void RPC::setApiKeyAndBaseUrl(const char* apiKey, const char* baseUrl) {
if (apiKey) {
strncpy(apiKey_, apiKey, sizeof(apiKey_) - 1);
apiKey_[sizeof(apiKey_) - 1] = '\0'; // Ensure null-termination
} else {
apiKey_[0] = '\0'; // Set to empty string
}
if (baseUrl) {
strncpy(baseUrl_, baseUrl, sizeof(baseUrl_) - 1);
baseUrl_[sizeof(baseUrl_) - 1] = '\0';
} else {
baseUrl_[0] = '\0';
}
}
// ---- Private Helper Methods ----
HttpResponse RPC::sendRequest(const String& url, const String& method, const uint8_t* payload, size_t payloadLen, const String& contentType) {
HttpResponse response; // Default httpCode is 0
if (WiFi.status() != WL_CONNECTED) {
response.httpCode = -1; // Custom code for no WiFi
return response;
}
HTTPClient http;
http.begin(url);
http.setTimeout(10000);
if (apiKey_[0] != '\0') {
http.addHeader("x-device-key", apiKey_);
}
if (method.equalsIgnoreCase("POST")) {
http.addHeader("Content-Type", contentType);
response.httpCode = http.POST(const_cast<uint8_t*>(payload), payloadLen);
} else {
response.httpCode = http.GET();
}
if (response.httpCode > 0) {
int len = http.getSize();
if (len > 0) {
response.payload.resize(len);
WiFiClient* stream = http.getStreamPtr();
stream->readBytes(response.payload.data(), len);
}
} else {
LOG_ERROR(("RPC: HTTP " + method + " " + url + " failed: " + http.errorToString(response.httpCode)).c_str());
}
http.end(); // Encapsulated resource management
return response;
}
bool RPC::syncLogsAndCheckForUpdates(bool* pendingChanges) {
// NOTE: This implementation assumes the logs should be sent with the sync request.
// The previous code collected logs but did not send them. This version does.
std::vector<RpcAccessLogEntry> logBatch;
RpcAccessLogEntry entry;
// Drain the queue to send all pending logs.
// To be more memory efficient on larger queues, this could be done in batches.
while (xQueueReceive(accessLogQueue, &entry, 0) == pdPASS) {
logBatch.push_back(entry);
}
control_communication_SyncRequest request = control_communication_SyncRequest_init_zero;
request.has_lastSync = (lastSyncTime > 0);
request.lastSync = lastSyncTime;
request.accessLogs.arg = &logBatch;
request.accessLogs.funcs.encode = [](pb_ostream_t* stream, const pb_field_t* field, void* const* arg) -> bool {
auto logs = static_cast<std::vector<RpcAccessLogEntry>*>(*arg);
if (!logs || logs->empty()) {
return true; // No logs to encode, return success
}
for (const auto& log : *logs) {
control_communication_SyncRequest_AccessLogsEntry entry = { log.timestamp, log.rfidId };
if (!pb_encode_tag_for_field(stream, field)) {
return false;
}
if (!pb_encode_submessage(stream, control_communication_SyncRequest_AccessLogsEntry_fields, &entry)) {
return false;
}
}
return true;
};
uint8_t requestBuffer[512];
pb_ostream_t ostream = pb_ostream_from_buffer(requestBuffer, sizeof(requestBuffer));
if (!pb_encode(&ostream, control_communication_SyncRequest_fields, &request)) {
LOG_ERROR("RPC: Failed to encode sync request.");
return false;
}
String url = String(baseUrl_) + "device-communication/sync-binary";
HttpResponse httpResponse = sendRequest(url, "POST", requestBuffer, ostream.bytes_written, "application/octet-stream");
if (httpResponse.httpCode != HTTP_CODE_OK) {
LOG_ERROR("RPC: Sync logs request failed with HTTP code %d", httpResponse.httpCode)
return false;
}
// A successful connection was made.
lastSyncConnection = millis();
control_communication_SyncResponse response = control_communication_SyncResponse_init_zero;
pb_istream_t istream = pb_istream_from_buffer(httpResponse.payload.data(), httpResponse.payload.size());
if (!pb_decode(&istream, control_communication_SyncResponse_fields, &response)) {
LOG_ERROR("RPC: Failed to decode sync response.");
return false;
}
// As per your design, only update lastSyncTime if the server reports pending changes.
if (response.pendingChanges) {
this->lastSyncTime = response.currentTime;
saveLastSyncTime(this->lastSyncTime);
}
// Pass pendingChanges flag back to the caller.
if (pendingChanges) {
*pendingChanges = response.pendingChanges;
}
if(newTimeCallback_){
newTimeCallback_(response.currentTime);
}
return true;
}
bool RPC::downloadDatabaseToFile(const char* filePath) {
String url = String(baseUrl_) + "device-communication/db";
if (WiFi.status() != WL_CONNECTED) {
return false;
}
HTTPClient http;
http.begin(url);
http.setTimeout(10000);
if (apiKey_[0] != '\0') {
http.addHeader("x-device-key", apiKey_);
}
int httpCode = http.GET();
if (httpCode != HTTP_CODE_OK) {
LOG_ERROR(("RPC: DB download failed with HTTP code " + String(httpCode)).c_str());
http.end();
return false;
}
lastSyncConnection = millis();
int len = http.getSize();
if (len <= 0) { // Removed the multiple of 4 check, as it might be too restrictive.
LOG_ERROR(("RPC: Invalid database size: " + String(len) + " bytes.").c_str());
http.end();
return false;
}
LOG_INFO(("RPC: Downloading database (" + String(len) + " bytes) to file '" + String(filePath) + "'...").c_str());
File dbFile = LittleFS.open(filePath, "w");
if (!dbFile) {
LOG_ERROR(("RPC: Failed to open " + String(filePath) + " for writing.").c_str());
http.end();
return false;
}
// Write the file content by streaming it from the WiFi client.
WiFiClient* stream = http.getStreamPtr();
const size_t bufferSize = 1024;
uint8_t buffer[bufferSize];
size_t written = 0;
// Read from the stream in chunks until all bytes are received
while (http.connected() && (written < (size_t)len)) {
// Get available data size
size_t available = stream->available();
if (available) {
// Read up to bufferSize bytes
int bytesRead = stream->read(buffer, std::min(bufferSize, available));
// Write the chunk to the file
dbFile.write(buffer, bytesRead);
written += bytesRead;
}
}
dbFile.close();
http.end();
if (written != (size_t)len) {
LOG_ERROR("RPC: File download incomplete. Wrote %d of %d bytes.", (unsigned long)written, len);
LittleFS.remove(filePath); // Delete partial file
return false;
}
LOG_INFO("RPC: Database downloaded successfully.");
return true;
}
// ---- Persistence Methods ----
void RPC::saveLastSyncTime(time_t timestamp) {
File file = LittleFS.open(LAST_SYNC_TIME_FILE, "w");
if (!file) {
LOG_ERROR("RPC: Failed to open last_sync.time for writing.");
return;
}
file.write(reinterpret_cast<const uint8_t*>(&timestamp), sizeof(timestamp));
file.close();
}
time_t RPC::loadLastSyncTime() {
if (!LittleFS.exists(LAST_SYNC_TIME_FILE)) {
return 0;
}
File file = LittleFS.open(LAST_SYNC_TIME_FILE, "r");
if (!file || file.size() != sizeof(time_t)) {
LOG_ERROR("RPC: Corrupt or unreadable timestamp file. Resetting to 0.");
if(file) file.close();
LittleFS.remove(LAST_SYNC_TIME_FILE);
return 0;
}
time_t timestamp = 0;
file.read(reinterpret_cast<uint8_t*>(&timestamp), sizeof(timestamp));
file.close();
return timestamp;
}
// ---- FreeRTOS Task ----
void RPC::autoSyncTask(void* pvParameters) {
RPC* rpc = static_cast<RPC*>(pvParameters);
while (rpc->autoSyncRunning) {
// Wait for either the timer to expire OR the trigger semaphore to be given.
if (xSemaphoreTake(rpc->syncTriggerSemaphore_, pdMS_TO_TICKS(rpc->syncIntervalMs)) == pdTRUE) {
LOG_INFO("RPC: Sync triggered by log queue.");
} else {
LOG_INFO("RPC: Sync triggered by timer.");
}
if (!rpc->autoSyncRunning) {
break; // Exit immediately if stop was called while waiting
}
rpc->sync(); // Call sync without forcing a download
}
// Safer handshake to signal graceful shutdown
rpc->autoSyncTaskHandle = nullptr;
vTaskDelete(NULL); // Task deletes itself
}
RPC rpc;
+155
View File
@@ -0,0 +1,155 @@
#pragma once
#include <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <freertos/task.h>
#include <freertos/semphr.h> // Required for semaphores
#include <vector>
/**
* @struct RpcAccessLogEntry
* @brief Holds a single access log record to be queued for synchronization.
*/
struct RpcAccessLogEntry {
time_t timestamp;
uint32_t rfidId;
};
/**
* @struct HttpResponse
* @brief Encapsulates the result of an HTTP request.
*/
struct HttpResponse {
int httpCode = 0;
std::vector<uint8_t> payload;
};
/**
* @class RPC
* @brief Manages remote procedure calls (RPC) for device synchronization.
* * This class handles queuing access logs, synchronizing them with a server,
* and downloading updated data. It uses a background FreeRTOS task for
* automatic, periodic synchronization.
*/
class RPC {
public:
RPC();
~RPC();
/**
* @brief Performs a full, one-time synchronization cycle.
* @param forceDownload If true, the database will be downloaded even if the
* server reports no pending changes.
* @return true on success, false on failure.
*/
bool sync(bool forceDownload = false);
/**
* @brief Adds an access log entry to the queue for the next sync cycle.
* If the queue reaches a threshold, a sync may be triggered.
* @param rfidId The RFID ID that was accessed.
*/
void addAccessLog(uint32_t rfidId);
/**
* @brief Starts or restarts the automatic background synchronization task.
* @param intervalMs The sync interval in milliseconds.
*/
void startAutoSync(uint32_t intervalMs);
/**
* @brief Stops the automatic synchronization task gracefully.
*/
void stopAutoSync();
/**
* @brief Sets the API key and base URL for server communication.
*/
void setApiKeyAndBaseUrl(const char* apiKey, const char* baseUrl);
/**
* @brief Gets the timestamp (from millis()) of the last successful communication.
* @return The last connection timestamp, or 0 if never connected.
*/
time_t getLastConnectTime() const { return lastSyncConnection; }
/**
* @brief Checks if a sync operation is currently in progress.
* @return true if syncing, false otherwise.
*/
bool isSyncing() const { return isSyncing_; }
/**
* @brief Sets a callback function to get the current time in seconds since epoch.
* This is used to set the lastSync time during sync operations.
* @param callback A function that returns the current time as time_t.
*/
void setNewTimeCallback(std::function<void(time_t)> callback) {
newTimeCallback_ = callback;
}
private:
// --- Member Variables ---
// Configuration
char apiKey_[65]; // 64 chars + 1 null-terminator
char baseUrl_[257]; // 256 chars + 1 null-terminator
// State
time_t lastSyncTime = 0;
time_t lastSyncConnection = 0;
bool isSyncing_ = false;
// FreeRTOS components
QueueHandle_t accessLogQueue = nullptr;
TaskHandle_t autoSyncTaskHandle = nullptr;
SemaphoreHandle_t syncTriggerSemaphore_ = nullptr;
uint32_t syncIntervalMs = 300000; // Default: 5 minutes
bool autoSyncRunning = false;
// Constants
static constexpr uint8_t LOG_SYNC_THRESHOLD = 20;
// --- Private Helper Methods ---
/**
* @brief Sends an HTTP request and returns the full response.
* This method is self-contained and manages the HTTPClient lifecycle.
*/
HttpResponse sendRequest(const String& url, const String& method = "GET",
const uint8_t* payload = nullptr, size_t payloadLen = 0,
const String& contentType = "");
/**
* @brief Streams an HTTP GET response directly to a file on LittleFS.
* This is memory-efficient for large files like a database.
*/
bool downloadDatabaseToFile(const char* filePath);
/**
* @brief Sends queued logs to the server and checks if updates are pending.
* This is the first step in the sync process.
*/
bool syncLogsAndCheckForUpdates(bool* pendingChanges);
/**
* @brief Saves the last successful database download timestamp to flash.
*/
void saveLastSyncTime(time_t timestamp);
/**
* @brief Loads the last successful database download timestamp from flash.
*/
time_t loadLastSyncTime();
/**
* @brief The static function that runs as the FreeRTOS background task.
*/
static void autoSyncTask(void* pvParameters);
std::function<void(time_t)> newTimeCallback_;
};
extern RPC rpc;
+3
View File
@@ -0,0 +1,3 @@
#include "tft.hpp"
TFT tft;
+48
View File
@@ -0,0 +1,48 @@
#include <Adafruit_ST7735.h>
#include <Print.h> // Wichtig, um von der Print-Klasse erben zu können
#define TFT_CS 5
#define TFT_RST 4
#define TFT_DC 19
#define TFT_MOSI 23
#define TFT_SCLK 18
class TFT : public Print {
public:
void setup() {
// Initialize the TFT display
// Setup SPI and display parameters
tft.initR(INITR_144GREENTAB); // Initialize a ST7735S chip, green tab
tft.fillScreen(ST77XX_BLACK);
tft.setRotation(1); // Landscape orientation
tft.setTextColor(ST77XX_WHITE);
tft.setTextSize(1);
//tft.setScrollDefinition(0, tft.height(), 0);
tft.setCursor(0, 0);
}
virtual size_t write(uint8_t c) override {
return tft.write(c);
}
Adafruit_ST7735& getGfx() {
return tft;
}
void checkCursor() {
if (tft.getCursorY() >= tft.height()) {
tft.setCursor(0, 0);
tft.fillScreen(ST77XX_BLACK);
}
}
void resetCursor() {
tft.fillScreen(ST77XX_BLACK);
tft.setCursor(0, 0);
}
private:
Adafruit_ST7735 tft = Adafruit_ST7735(TFT_CS, TFT_DC, TFT_MOSI, TFT_SCLK, TFT_RST);
};
extern TFT tft; // Deine globale Instanz
+7
View File
@@ -0,0 +1,7 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xe000, 0x2000,
ota_0, app, ota_0, 0x10000, 0x160000,
ota_1, app, ota_1, 0x170000, 0x160000,
spiffs, data, spiffs, 0x2D0000, 0x120000,
coredump, data, coredump, 0x3F0000, 0x10000,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x5000
3 otadata data ota 0xe000 0x2000
4 ota_0 app ota_0 0x10000 0x160000
5 ota_1 app ota_1 0x170000 0x160000
6 spiffs data spiffs 0x2D0000 0x120000
7 coredump data coredump 0x3F0000 0x10000
+6
View File
@@ -0,0 +1,6 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xe000, 0x2000,
ota_0, app, ota_0, 0x10000, 0x270000,
spiffs, data, spiffs, 0x280000, 0x170000,
coredump, data, coredump,0x3F0000, 0x10000,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x5000
3 otadata data ota 0xe000 0x2000
4 ota_0 app ota_0 0x10000 0x270000
5 spiffs data spiffs 0x280000 0x170000
6 coredump data coredump 0x3F0000 0x10000
+6
View File
@@ -0,0 +1,6 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xe000, 0x2000,
ota_0, app, ota_0, 0x10000, 0x160000,
ota_1, app, ota_1, 0x170000, 0x160000,
spiffs, data, spiffs, 0x2D0000, 0x130000,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x5000
3 otadata data ota 0xe000 0x2000
4 ota_0 app ota_0 0x10000 0x160000
5 ota_1 app ota_1 0x170000 0x160000
6 spiffs data spiffs 0x2D0000 0x130000
+94
View File
@@ -0,0 +1,94 @@
; 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
[platformio]
default_envs = dev
[env]
platform = espressif32
board = az-delivery-devkit-v4
framework = arduino
board_build.tasks_stack_size = 8192
lib_deps =
adafruit/Adafruit ST7735 and ST7789 Library@^1.11.0
https://github.com/avrilogic/nanopb.git#fix/esp32-pio-include-path
ESP32Async/AsyncTCP@^3.4.8
ESP32Async/ESPAsyncWebServer@^3.8.1
rweather/Crypto@^0.4.0
adafruit/Adafruit NeoPixel@^1.15.1
ArduinoOTA
monitor_speed = 115200
monitor_filters = esp32_exception_decoder
board_build.filesystem = littlefs
#lib_extra_dirs = .pio/libdeps/dev/Nanopb
build_unflags = -std=gnu++11 -fno-rtti
build_flags =
-std=gnu++17
-ffunction-sections
-fdata-sections
-Wl,--gc-sections
-D CONFIG_BT_ENABLED=n
-D CONFIG_BLUEDROID_ENABLED=n
custom_nanopb_protos =
+<proto/device.proto>
+<proto/control_communication.proto>
+<proto/webui.proto>
+<proto/settings.proto>
+<proto/hardware.proto>
custom_nanopb_options =
--error-on-unmatched
[env:dev]
build_type = debug
board_build.partitions = partitions_custom.csv
build_flags =
${env.build_flags}
-Os
-D DEBUG_MODE
-D CORE_DEBUG_LEVEL=4
-D LOG_LOCAL_LEVEL=4
-fstack-protector-strong
-D CONFIG_ESP32_ENABLE_COREDUMP_TO_FLASH=y
-D CONFIG_ESP32_COREDUMP_DATA_FORMAT_ELF=y
-D CONFIG_FREERTOS_WATCHPOINT_END_OF_STACK=y
-D CONFIG_ESP_TASK_WDT=y
-D CONFIG_ESP_TASK_WDT_TIMEOUT_S=15
-D CONFIG_ESP_TASK_WDT_PANIC=y
[env:prod]
build_type = release
board_build.partitions = partitions_prod.csv
build_flags =
${env.build_flags}
; --- CORE OPTIMIZATIONS ---
-Os ; <-- 1. Optimize for SIZE, not speed
-fno-exceptions ; <-- 2. Disable C++ exceptions
; --- DISABLE BLUETOOTH ---
-D CONFIG_BT_ENABLED=n ; <-- 2. Disable Bluetooth stack
-D CONFIG_BLUEDROID_ENABLED=n ; <-- 2. Disable classic Bluetooth
; --- STRIP DEBUG SYMBOLS (already correct) ---
-D NDEBUG
-D CORE_DEBUG_LEVEL=0
-D LOG_LOCAL_LEVEL=0
; --- Security vs. Size Trade-off (optional) ---
-fstack-protector ; Keep this for security. Removing it saves a tiny amount of space.
; --- OTHER FLAGS (already correct) ---
-D CONFIG_ESP32_ENABLE_COREDUMP=n
-D CONFIG_ESP_TASK_WDT=y
-D CONFIG_ESP_TASK_WDT_TIMEOUT_S=60
-D CONFIG_ESP_TASK_WDT_PANIC=n
+9
View File
@@ -0,0 +1,9 @@
# Nanopb options for control_communication.proto
# Adjust these limits as needed for memory/performance.
# RfidId
control_communication.RfidId.value max_length:8
# DeviceCommunicationSyncRequest
# Limit number of log entries and their key/value sizes.
control_communication.SyncRequest.logs max_count:50
+16
View File
@@ -0,0 +1,16 @@
syntax = "proto3";
package control_communication;
message RfidId {
uint32 value = 1;
}
message SyncResponse {
int64 currentTime = 1;
bool pendingChanges = 2;
}
message SyncRequest {
optional int64 lastSync = 1;
map<uint64, uint32> accessLogs = 2;
}
+15
View File
@@ -0,0 +1,15 @@
# Nanopb options for device.proto
# DeviceInfo
device.DeviceInfo.device_id max_length:18
device.DeviceInfo.firmware_version max_length:8
device.DeviceInfo.hardware_version max_length:8
device.DeviceInfo.sta_ip max_length:16
device.DeviceInfo.sta_gateway max_length:16
device.DeviceInfo.ap_ip max_length:16
# DeviceLogEntry
device.DeviceLogEntry.message max_length:128
# DeviceStatus
device.DeviceStatus.logs max_length:20
+46
View File
@@ -0,0 +1,46 @@
syntax = "proto3";
package device;
// WiFi connection state enumeration
enum WifiConnectionState {
WIFI_DISCONNECTED = 0;
WIFI_CONNECTED = 1;
WIFI_CONNECTING = 2;
WIFI_FAILED = 3;
}
// Device information
message DeviceInfo {
string device_id = 1;
string firmware_version = 2;
string hardware_version = 3;
uint32 uptime_seconds = 4;
// STA mode info
WifiConnectionState sta_connection_state = 5;
string sta_ip = 6;
string sta_gateway = 7;
int32 sta_signal_strength = 8; // Signal strength in dBm
// AP mode info
WifiConnectionState ap_connection_state = 9;
string ap_ip = 10;
uint32 ap_client_count = 11;
}
enum DeviceLogLevel {
LOG_LEVEL_DEBUG = 0;
LOG_LEVEL_INFO = 1;
LOG_LEVEL_WARN = 2;
LOG_LEVEL_ERROR = 3;
}
message DeviceLogEntry {
int64 timestamp = 1; // Unix timestamp in milliseconds
DeviceLogLevel level = 2;
string message = 3;
}
message DeviceStatus {
DeviceInfo info = 1;
repeated DeviceLogEntry logs = 2;
}
+12
View File
@@ -0,0 +1,12 @@
# Nanopb options for hardware.proto
# LED configuration
hardware.FadeParams.colors max_count:5
hardware.SensorOTAEnable.ssid max_length:32
hardware.SensorOTAEnable.password max_length:64
hardware.SensorOTAEnable.static_ip max_length:16
hardware.SensorOTAEnable.netmask max_length:16
hardware.SensorOTAEnable.gateway max_length:16
hardware.SensorOTAEnableResponse.ip_address max_length:16
hardware.SensorOTAEnableResponse.error_message max_length:64
+104
View File
@@ -0,0 +1,104 @@
syntax = "proto3";
package hardware;
// LED animation types
enum LedAnimation {
LED_ANIMATION_STATIC = 0;
LED_ANIMATION_PULSE = 1;
LED_ANIMATION_FADE = 2;
LED_ANIMATION_FLICKER = 3;
}
// Hardware configuration
message HardwareConfig {
uint32 hold_duration_ms = 1;
bool override = 2;
uint32 relay_pin = 3;
uint32 sensor_rx_pin = 4;
uint32 sensor_tx_pin = 5;
LedConfig on_open_led = 6;
LedConfig default_led = 7;
LedConfig on_invalid_led = 8;
bool enable_serial_sensor = 9;
// repeated char sensor_api_key = 10;
// bool enable_ws_sensor = 11;
LedConfig on_override_open_led = 12;
LedConfig on_interception_led = 13;
}
// LED configuration
message LedConfig {
// General properties that apply to all animations
uint32 brightness = 1; // 0-255
uint32 duration_ms = 2; // 0 for indefinite
oneof animation_params {
StaticParams static_params = 3;
PulseParams pulse_params = 4;
FadeParams fade_params = 5;
FlickerParams flicker_params = 6;
}
}
// Define the specific parameters for each animation type
message StaticParams {
uint32 color = 1;
}
message PulseParams {
uint32 color = 1;
uint32 speed_ms = 2;
}
message FadeParams {
repeated uint32 colors = 1; // Fade between these colors
uint32 speed_ms = 2;
}
message FlickerParams {
uint32 color = 1;
uint32 intensity = 2; // e.g., 0-100
}
message RfidReading {
uint32 card_id = 1;
}
message SensorToControlMessage {
uint32 sensor_id = 1;
oneof payload {
RfidReading rfid_reading = 2;
SensorOTAEnableResponse ota_response = 3;
// Add other sensor message types as needed
}
}
message SensorOTAEnable {
string ssid = 1;
string password = 2;
uint32 timeout_seconds = 3;
bool as_station_mode = 4;
bool use_static_ip = 5;
string static_ip = 6;
string netmask = 7;
string gateway = 8;
}
message SensorRestart {
}
message SensorOTAEnableResponse {
bool success = 1;
string ip_address = 2;
string error_message = 3;
}
message ControlToSensorMessage {
uint32 control_id = 1;
oneof payload {
LedConfig led_config = 2;
SensorOTAEnable ota_enable = 3;
SensorRestart restart = 4;
// Add other control message types as needed
}
}
+12
View File
@@ -0,0 +1,12 @@
# Nanopb options for settings.proto
# SettingsData
settings.SettingsData.sync_server_url max_length:256
settings.SettingsData.device_api_key max_length:64
settings.SettingsData.station_ssid max_length:32
settings.SettingsData.station_password max_length:64
settings.SettingsData.ap_ssid max_length:32
settings.SettingsData.ap_password max_length:64
# UpdateSettingsResponse
settings.UpdateSettingsResponse.error max_length:96
+57
View File
@@ -0,0 +1,57 @@
syntax = "proto3";
package settings;
// WiFi mode enumeration
enum WifiMode {
WIFI_MODE_UNSPECIFIED = 0;
WIFI_MODE_STATION = 1;
WIFI_MODE_AP = 2;
WIFI_MODE_AP_STATION = 3;
}
// Log level enumeration
enum LogLevel {
LOG_LEVEL_DEBUG = 0;
LOG_LEVEL_INFO = 1;
LOG_LEVEL_WARN = 2;
LOG_LEVEL_ERROR = 3;
}
// Settings data structure
message SettingsData {
string sync_server_url = 1;
string device_api_key = 2;
uint32 sync_interval_seconds = 3;
bool auto_sync = 4;
WifiMode wifi_mode = 5;
string station_ssid = 6;
string station_password = 7;
string ap_ssid = 8;
string ap_password = 9;
uint32 ap_channel = 10;
bool enable_fallback_ap = 11;
int64 updated_at = 12; // Unix timestamp in milliseconds
uint32 version = 13; // Settings version for change tracking
LogLevel log_level = 14;
}
// Request to get current settings
message GetSettingsRequest {
}
// Response with current settings
message GetSettingsResponse {
SettingsData settings = 1;
}
// Request to update settings
message UpdateSettingsRequest {
SettingsData settings = 1;
}
// Response to settings update
message UpdateSettingsResponse {
bool success = 1;
optional string error = 2;
}
+36
View File
@@ -0,0 +1,36 @@
# Nanopb options for webui.proto
# Use callback-based strings for dynamic memory allocation
# No max_length specified to enable callbacks
webui.WebUiLoginRequest.username max_length:32
webui.WebUiLoginRequest.password max_length:64
# WebUiLoginResponse
webui.WebUiLoginResponse.token max_length:128
webui.WebUiLoginResponse.error max_length:96
# WebUiUser
webui.WebUiUser.username max_length:32
webui.WebUiUser.password_hash max_length:32
# GetUsersResponse
webui.GetUsersResponse.usernames max_length:32
# AddUserRequest
webui.AddUserRequest.username max_length:32
webui.AddUserRequest.password max_length:64
# DeleteUserRequest
webui.DeleteUserRequest.username max_length:32
# UpdateUserPasswordRequest
webui.UpdateUserPasswordRequest.username max_length:32
webui.UpdateUserPasswordRequest.new_password max_length:64
# Response errors
webui.AddUserResponse.error max_length:96
webui.DeleteUserResponse.error max_length:96
webui.UpdateUserPasswordResponse.error max_length:96
# WebUiAuthCheckResponse
webui.WebUiAuthCheckResponse.username max_length:32
+83
View File
@@ -0,0 +1,83 @@
syntax = "proto3";
package webui;
// Login request from Web UI
message WebUiLoginRequest {
string username = 1; // 1-32 chars
string password = 2; // 1-64 chars (plain or hashed)
}
// Login response
message WebUiLoginResponse {
bool success = 1;
optional string token = 2; // session/JWT when success
optional string error = 3; // error message when failed
}
// Logout response
message WebUiLogoutResponse {
bool success = 1;
string error = 2;
}
// User data structure
message WebUiUser {
string username = 1; // 1-32 chars
bytes password_hash = 2; // 32 bytes (SHA-256)
}
// User list (max 5 users)
message WebUiUserList {
repeated WebUiUser users = 1;
}
// Request to get users
message GetUsersRequest {
}
// Response with users
message GetUsersResponse {
repeated string usernames = 1; // List of usernames
}
// Request to add user
message AddUserRequest {
string username = 1;
string password = 2;
}
// Response to add user
message AddUserResponse {
bool success = 1;
optional string error = 2; // e.g., "table full", "user exists"
}
// Request to delete user
message DeleteUserRequest {
string username = 1;
}
// Response to delete user
message DeleteUserResponse {
bool success = 1;
optional string error = 2; // e.g., "user not found"
}
// Request to update user password
message UpdateUserPasswordRequest {
string username = 1;
string new_password = 2;
}
// Response to update user password
message UpdateUserPasswordResponse {
bool success = 1;
optional string error = 2; // e.g., "user not found"
}
// Auth check response
message WebUiAuthCheckResponse {
bool authenticated = 1;
optional string username = 2;
}
+255
View File
@@ -0,0 +1,255 @@
#include "device_manager.hpp"
#include "rfid.hpp" // For rfidDB
#include <logger.hpp>
#include <hardware_config.hpp>
DeviceManager::DeviceManager() {
// Constructor
}
DeviceManager::~DeviceManager() {
if (taskHandle != nullptr) {
vTaskDelete(taskHandle);
taskHandle = nullptr;
}
if (mutex != nullptr) {
vSemaphoreDelete(mutex);
mutex = nullptr;
}
}
void DeviceManager::begin() {
if (taskHandle == nullptr) {
scans = 0; // Reset scan count on start
// Initialize relay pin
setRelayPin(relayPin);
// Create mutex
mutex = xSemaphoreCreateMutex();
// Create the FreeRTOS task
xTaskCreate(
taskFunction, // Task function
"DeviceManagerTask", // Name
4096, // Stack size
this, // Parameter
1, // Priority
&taskHandle // Task handle
);
}
}
void DeviceManager::end() {
if (taskHandle != nullptr) {
vTaskDelete(taskHandle);
taskHandle = nullptr;
}
// Reset state
xSemaphoreTake(mutex, portMAX_DELAY);
isOpen = false;
isOverridden = false;
openTillTime = 0;
xSemaphoreGive(mutex);
}
void DeviceManager::setOpenDuration(uint32_t ms) {
openDurationMs = ms;
}
void DeviceManager::processRfid(uint32_t cardId) {
if(isOverridden) {
LOG_DEBUG("DeviceManager: RFID card scanned while overridden, ignoring: %08X", cardId);
return; // Ignore scans while overridden
}
scans++; // Increment scan count
if (rfidDB.contains(cardId)) {
LOG_DEBUG("DeviceManager: Valid RFID card scanned: %08X", cardId);
// Valid card
xSemaphoreTake(mutex, portMAX_DELAY);
if (!isOverridden) {
isOpen = true;
openTillTime = millis() + openDurationMs;
if (onDoorOpenCb) {
onDoorOpenCb();
}
}
setLEDState(LEDState::ON_OPEN);
xSemaphoreGive(mutex);
// If overridden, do nothing or perhaps log
} else {
LOG_DEBUG("DeviceManager: Invalid RFID card scanned: %08X", cardId);
setLEDState(LEDState::ON_INVALID);
// Invalid card
if (onInvalidCardCb) {
onInvalidCardCb();
}
}
}
void DeviceManager::overrideOpen(bool open) {
LOG_DEBUG("DeviceManager::overrideOpen called with open=%d, current isOverridden=%d, isOpen=%d", open, isOverridden, isOpen);
xSemaphoreTake(mutex, portMAX_DELAY);
if (open) {
if (!isOverridden) {
LOG_DEBUG("DeviceManager: Enabling override mode");
setLEDState(LEDState::ON_OVERRIDE_OPEN);
openTillTime = 0; // Override keeps it open indefinitely
isOverridden = true;
isOpen = true;
if (onDoorOpenCb) {
onDoorOpenCb();
}
} else {
LOG_DEBUG("DeviceManager: Override already enabled");
}
} else {
if (isOverridden) {
LOG_DEBUG("DeviceManager: Disabling override mode");
setLEDState(LEDState::ON_DEFAULT);
isOpen = false;
isOverridden = false;
if (onDoorCloseCb) {
onDoorCloseCb();
}
} else {
LOG_DEBUG("DeviceManager: Override already disabled");
}
}
LOG_DEBUG("DeviceManager::overrideOpen finished, isOverridden=%d, isOpen=%d", isOverridden, isOpen);
xSemaphoreGive(mutex);
}
DeviceManager::State DeviceManager::getState() const {
xSemaphoreTake(mutex, portMAX_DELAY);
State state = {isOpen, isOverridden};
xSemaphoreGive(mutex);
return state;
}
uint32_t DeviceManager::getScanCount() const {
return scans;
}
void DeviceManager::onDoorOpen(std::function<void()> callback) {
onDoorOpenCb = callback;
}
void DeviceManager::onDoorClose(std::function<void()> callback) {
onDoorCloseCb = callback;
}
void DeviceManager::onInvalidCard(std::function<void()> callback) {
onInvalidCardCb = callback;
}
void DeviceManager::onLEDStateChange(std::function<void(hardware_ControlToSensorMessage message)> callback)
{
onLEDStateChangeCb = callback;
}
void DeviceManager::setRelayPin(int pin) {
if(pin == relayPin && relayInitialized) {
return; // No change
}
relayPin = pin;
pinMode(relayPin, OUTPUT);
digitalWrite(relayPin, isOpen ? HIGH : LOW);
relayInitialized = true;
}
void DeviceManager::setRelay(bool state) {
if (relayInitialized) {
digitalWrite(relayPin, state ? HIGH : LOW);
}
}
void DeviceManager::setLEDState(LEDState state) {
if(ledState == state) {
return; // No change
}
resetToDefaultLEDStateTimer = 0;
ledState = state;
ledStateChanged = true;
}
void DeviceManager::applyLEDState() {
bool apply =false;
unsigned long now = millis();
if(ledStateChanged) {
apply = true;
ledStateChanged = false;
}else if(resetToDefaultLEDStateTimer > 0 && now >= resetToDefaultLEDStateTimer) {
apply = true;
resetToDefaultLEDStateTimer = 0;
ledState = LEDState::ON_DEFAULT;
}
if (apply && onLEDStateChangeCb) {
hardware_HardwareConfig currentConfig = hardwareConfigRepo.getData();
hardware_ControlToSensorMessage message = hardware_ControlToSensorMessage_init_zero;
message.which_payload = hardware_ControlToSensorMessage_led_config_tag;
switch (ledState)
{
case LEDState::ON_DEFAULT:
// Set to default LED config
message.payload.led_config = currentConfig.default_led;
break;
case LEDState::ON_OPEN:
// Set to open LED config
resetToDefaultLEDStateTimer = millis() + openDurationMs;
message.payload.led_config = currentConfig.on_open_led;
break;
case LEDState::ON_OVERRIDE_OPEN:
// Set to override open LED config
message.payload.led_config = currentConfig.on_override_open_led;
break;
case LEDState::ON_INVALID:
// Set to invalid LED config
resetToDefaultLEDStateTimer = millis() + 2000; // 3 seconds
message.payload.led_config = currentConfig.on_invalid_led;
break;
case LEDState::ON_INTERCEPT:
// Set to intercept LED config
message.payload.led_config = currentConfig.on_interception_led;
break;
default:
break;
}
onLEDStateChangeCb(message);
}
}
void DeviceManager::taskFunction(void* param) {
DeviceManager* self = static_cast<DeviceManager*>(param);
self->runTask();
}
void DeviceManager::runTask() {
while (true) {
xSemaphoreTake(mutex, portMAX_DELAY);
unsigned long now = millis();
// Check if it's time to close the door
if (!isOverridden && openTillTime > 0 && now >= openTillTime) {
LOG_DEBUG("DeviceManager: Closing door due to timeout, openTillTime=%lu, now=%lu", openTillTime, now);
isOpen = false;
openTillTime = 0;
if (onDoorCloseCb) {
onDoorCloseCb();
}
}
// Control relay based on door state
if (isOpen != previousIsOpen) {
LOG_DEBUG("DeviceManager: Setting relay to %d (isOpen=%d, isOverridden=%d)", isOpen, isOpen, isOverridden);
setRelay(isOpen);
previousIsOpen = isOpen;
}
// Apply LED state changes if needed
applyLEDState();
xSemaphoreGive(mutex);
// Delay for 100ms
vTaskDelay(pdMS_TO_TICKS(100));
}
}
+166
View File
@@ -0,0 +1,166 @@
#pragma once
#define RELAY_PIN 2
#include <Arduino.h>
#include <functional>
#include "rfid.hpp"
#include "hardware.pb.h"
/**
* @enum DoorState
* @brief Internal states for the door locking mechanism.
*/
enum class DoorState {
CLOSED,
OPENING,
OPEN,
CLOSING,
OVERRIDDEN_OPEN
};
/**
* @enum LEDState
* @brief States for LED indication.
*/
enum class LEDState {
ON_DEFAULT,
ON_OPEN,
ON_INVALID,
ON_OVERRIDE_OPEN,
ON_INTERCEPT
};
/**
* @class DeviceManager
* @brief Manages the door locking mechanism with RFID processing, overrides, and timing.
*
* This class handles the state of a door lock, processes RFID cards, manages open durations,
* and provides callbacks for various events. It runs its own FreeRTOS task for timing.
*/
class DeviceManager {
public:
/**
* @struct State
* @brief Represents the current state of the device.
*/
struct State {
bool currentlyOpen;
bool currentlyOverridden;
};
DeviceManager();
~DeviceManager();
/**
* @brief Initializes the device manager and starts the background task.
*/
void begin();
/**
* @brief Stops the device manager and its background task gracefully.
*/
void end();
/**
* @brief Sets the duration the door stays open after a successful RFID card.
* @param ms Duration in milliseconds.
*/
void setOpenDuration(uint32_t ms);
/**
* @brief Processes an RFID card read event.
* @param cardId The RFID card ID read.
*/
void processRfid(uint32_t cardId);
/**
* @brief Overrides the door state.
* @param open True to override open, false to override close.
*/
void overrideOpen(bool open);
/**
* @brief Gets the current state of the device.
* @return State struct with currentlyOpen and currentlyOverridden.
*/
State getState() const;
/**
* @brief Gets the number of RFID scans since the device started.
* @return The scan count.
*/
uint32_t getScanCount() const;
/**
* @brief Sets the callback for door open event.
*/
void onDoorOpen(std::function<void()> callback);
/**
* @brief Sets the callback for door close event.
*/
void onDoorClose(std::function<void()> callback);
/**
* @brief Sets the callback for invalid card event.
*/
void onInvalidCard(std::function<void()> callback);
/**
* @brief Sets the callback for LED state change event.
* @param callback Function to call with the new LED state message.
*/
void onLEDStateChange(std::function<void(hardware_ControlToSensorMessage message)> callback);
/**
* @brief Sets the relay pin number.
* @param pin The GPIO pin number for the relay.
*/
void setRelayPin(int pin);
/**
* @brief Controls the relay state.
* @param state True to activate relay (HIGH), false to deactivate (LOW).
*/
void setRelay(bool state);
/**
* @brief Sets the LED state and triggers the LED state change callback.
* @param state The new LEDState to set.
*/
void setLEDState(LEDState state);
private:
// Internal state
volatile bool isOpen = false;
volatile bool isOverridden = false;
uint32_t openDurationMs = 5000; // Default 5 seconds
uint32_t openTillTime = 0;
uint32_t scans = 0;
// Relay control
volatile int relayPin = RELAY_PIN;
volatile bool relayInitialized = false;
volatile bool previousIsOpen = false;
// Mutex for thread safety
SemaphoreHandle_t mutex;
// Callbacks
std::function<void()> onDoorOpenCb = nullptr;
std::function<void()> onDoorCloseCb = nullptr;
std::function<void()> onInvalidCardCb = nullptr;
std::function<void(hardware_ControlToSensorMessage message)> onLEDStateChangeCb = nullptr;
// Task
TaskHandle_t taskHandle = nullptr;
static void taskFunction(void* param);
void runTask();
// LED State
LEDState ledState = LEDState::ON_DEFAULT;
uint32_t resetToDefaultLEDStateTimer = 0;
void applyLEDState();
volatile bool ledStateChanged = false;
};
+86
View File
@@ -0,0 +1,86 @@
#include "device_status.hpp"
#include <pb_encode.h>
#include <cstring>
#include <WiFi.h>
extern WiFiManager wifiManager;
// Version constants
const char* FIRMWARE_VERSION = "1.0.0";
const char* HARDWARE_VERSION = "1.0";
DeviceStatus::DeviceStatus() {
status = device_DeviceStatus_init_zero;
}
DeviceStatus::~DeviceStatus() {
// Clean up if needed
}
void DeviceStatus::setInfo(const device_DeviceInfo& info) {
status.has_info = true;
status.info = info;
}
bool DeviceStatus::writeStreamCallback(pb_ostream_t *stream, const uint8_t *buf, size_t count) {
StreamState* state = (StreamState*)stream->state;
return state->stream->write(buf, count) == count;
}
const device_DeviceStatus& DeviceStatus::getStatus() {
populate();
return status;
}
void DeviceStatus::populate() {
// Populate device info from wifi manager
status.has_info = true;
wifiManager.getState(status.info);
// Set device ID to MAC address
String mac = WiFi.macAddress();
strncpy(status.info.device_id, mac.c_str(), sizeof(status.info.device_id) - 1);
status.info.device_id[sizeof(status.info.device_id) - 1] = '\0';
// Set firmware version
strncpy(status.info.firmware_version, FIRMWARE_VERSION, sizeof(status.info.firmware_version) - 1);
status.info.firmware_version[sizeof(status.info.firmware_version) - 1] = '\0';
// Set hardware version
strncpy(status.info.hardware_version, HARDWARE_VERSION, sizeof(status.info.hardware_version) - 1);
status.info.hardware_version[sizeof(status.info.hardware_version) - 1] = '\0';
// Set uptime in seconds
status.info.uptime_seconds = millis() / 1000;
// Set up the logs callback to use the logger's logs
status.logs.funcs.encode = [](pb_ostream_t *pb_stream, const pb_field_t *field, void * const *arg) -> bool {
int count;
const LogEntry* log_entries = logger.getLogs(count);
for (int i = 0; i < count; ++i) {
device_DeviceLogEntry log;
log.timestamp = (int64_t)log_entries[i].timestamp * 1000; // Convert to milliseconds
log.level = (device_DeviceLogLevel)log_entries[i].level;
strncpy(log.message, log_entries[i].message, sizeof(log.message) - 1);
log.message[sizeof(log.message) - 1] = '\0';
if (!pb_encode_tag_for_field(pb_stream, field) ||
!pb_encode_submessage(pb_stream, device_DeviceLogEntry_fields, &log)) {
return false;
}
}
return true;
};
status.logs.arg = nullptr; // Not used
}
bool DeviceStatus::encode(Stream& stream) {
populate();
// Create pb_ostream_t from Stream
StreamState state = {&stream};
pb_ostream_t pb_stream = {&writeStreamCallback, &state, SIZE_MAX, 0};
// Encode the message
return pb_encode(&pb_stream, device_DeviceStatus_fields, &status);
}
+31
View File
@@ -0,0 +1,31 @@
#ifndef DEVICE_STATUS_HPP
#define DEVICE_STATUS_HPP
#include <device.pb.h>
#include <Stream.h>
#include <logger.hpp>
#include "wifi/wifi_manager.hpp"
class DeviceStatus {
private:
device_DeviceStatus status;
// Stream callback
struct StreamState {
Stream* stream;
};
static bool writeStreamCallback(pb_ostream_t *stream, const uint8_t *buf, size_t count);
public:
DeviceStatus();
~DeviceStatus();
void setInfo(const device_DeviceInfo& info);
void populate();
const device_DeviceStatus& getStatus();
bool encode(Stream& stream);
};
#endif // DEVICE_STATUS_HPP
+147
View File
@@ -0,0 +1,147 @@
#include <Arduino.h>
#include <logger.hpp>
#include <WiFi.h>
#include <Adafruit_ST7735.h>
#include <Adafruit_GFX.h>
#include <tft.hpp>
#include <HTTPClient.h>
#include <control_communication.pb.h>
#include "webui/webui.hpp"
#include <LittleFS.h>
#include <rfid.hpp>
#include <rpc.hpp>
#include "wifi/wifi_manager.hpp"
#include <hardware_rtc.hpp>
#include <device.pb.h>
#include <hardware_serial.hpp>
#include "device/device_manager.hpp"
#include "hardware.pb.h"
#include <ArduinoOTA.h>
#include "ota_manager.hpp"
#define RXD2 16 // GPIO16 (RX2)
#define TXD2 17 // GPIO17 (TX2)
WebUI webui;
WiFiManager wifiManager;
HardwareRTC rtc(Wire);
DeviceManager deviceManager;
OTAManager otaManager;
void setup() {
// **Hardware Initialization**
// Init I2C
Wire.begin(22, 21); // SDA=21, SCL=22 for ESP32
Wire.setClock(100000);
Serial.begin(115200);
// **System Initialization**
// Init logger
logger.setLogCallback([](const LogEntry& entry) {
Serial.print("[");
Serial.print(logger.levelToString(entry.level));
Serial.print("] ");
Serial.println(entry.message);
});
logger.begin();
rtc.begin();
otaManager.begin();
logger.setLogLevel(LOG_DEBUG);
tft.setup();
delay(100);
rpc.setNewTimeCallback([](time_t timestamp) {
rtc.setTime(timestamp);
});
tft.println("Starting...");
// Init filesystem
if (!LittleFS.begin(true)) {
LOG_ERROR("Failed to mount LittleFS");
return;
}
rfidDB.begin();
deviceManager.begin();
wifiManager.onStaConnected([](IPAddress ip) {
webui.begin();
});
wifiManager.onApStarted([](IPAddress ip) {
webui.begin();
});
wifiManager.onPreShutdown([]() {
webui.end();
});
webui.setOTAManager(&otaManager);
settings.setChangeCallback([](const settings_SettingsData& newData) {
wifiManager.begin(newData);
rpc.setApiKeyAndBaseUrl(newData.device_api_key, newData.sync_server_url);
// Set log level
logger.setLogLevel(static_cast<LogLevel>(newData.log_level));
// React to specific changes if needed
if (newData.auto_sync) {
rpc.startAutoSync(newData.sync_interval_seconds * 1000);
} else {
rpc.stopAutoSync();
}
});
// Load settings initially
if (!settings.load()) {
LOG_ERROR("Failed to load settings");
} else {
LOG_INFO("Settings loaded");
}
pserial.setCallback([](const IncomingMessage& msg) {
LOG_DEBUG("Received message via pserial");
if (msg.which_payload == hardware_SensorToControlMessage_rfid_reading_tag) {
uint32_t cardId = msg.payload.rfid_reading.card_id;
deviceManager.processRfid(cardId);
rpc.addAccessLog(cardId);
} else if (msg.which_payload == hardware_SensorToControlMessage_ota_response_tag) {
if (msg.payload.ota_response.success) {
LOG_INFO("Sensor OTA update enabled successfully");
LOG_INFO("Sensor IP: %s", msg.payload.ota_response.ip_address);
} else {
LOG_WARN("Sensor OTA update enable failed");
}
} else {
LOG_WARN("Received unknown message type via serial");
}
});
hardwareConfigRepo.setChangeCallback([](const hardware_HardwareConfig& newData) {
// Apply hardware config changes if needed
if (newData.enable_serial_sensor) {
Serial2.begin(9600, SERIAL_8N1, newData.sensor_rx_pin, newData.sensor_tx_pin);
vTaskDelay(80 / portTICK_PERIOD_MS); // Give some time for Serial2 to initialize
pserial.begin(Serial2);
} else {
Serial2.end();
}
deviceManager.setRelayPin(newData.relay_pin);
deviceManager.setOpenDuration(newData.hold_duration_ms);
deviceManager.overrideOpen(newData.override);
});
hardwareConfigRepo.load();
deviceManager.onLEDStateChange([](hardware_ControlToSensorMessage message) {
pserial.sendMessage(message);
});
webui.onSendToSensor([](hardware_ControlToSensorMessage message) {
pserial.sendMessage(message);
});
}
void loop() {
rtc.update();
pserial.update();
wifiManager.update();
ArduinoOTA.handle();
}
+49
View File
@@ -0,0 +1,49 @@
#include "auth.hpp"
#include <cstring>
Auth::Auth(Users& users, SessionManager& sessionManager)
: users_(users), sessionManager_(sessionManager) {}
const char* Auth::login(const char* username, const char* password) {
if (users_.checkPassword(username, password)) {
return sessionManager_.createSession(username);
}
return nullptr;
}
bool Auth::logout(const char* token) {
return sessionManager_.endSession(token);
}
const char* Auth::checkAuth(const char* token) {
// check if users exist
if (!isAuthRequired()) {
return "no-auth";
}
return sessionManager_.validateSession(token);
}
ArMiddlewareCallback Auth::middleware() {
return [this](AsyncWebServerRequest* request, ArMiddlewareNext next) {
if (!isAuthRequired()) {
// No users exist, authentication not required
next();
return;
}
// Check Authorization header for Bearer token
if (request->hasHeader("Authorization")) {
String authHeader = request->getHeader("Authorization")->value();
if (authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7); // Remove "Bearer "
const char* username = checkAuth(token.c_str());
if (username) {
request->setAttribute("username", username);
next();
return;
}
}
}
// If no valid auth, respond with 401 Unauthorized
request->send(401, CONTENT_TYPE_TEXT, CONTENT_TYPE_TEXT);
};
}
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#include <ESPAsyncWebServer.h>
#include <users.hpp>
#include <session.hpp>
#include "webui_strings.hpp"
class Auth {
public:
Auth(Users& users, SessionManager& sessionManager);
// Login: returns token on success, nullptr on failure
const char* login(const char* username, const char* password);
// Logout: ends session for token
bool logout(const char* token);
// Check auth: returns username if token valid, nullptr otherwise
const char* checkAuth(const char* token);
bool isAuthRequired() const {
return users_.getUserCount() > 0;
}
// Middleware function
ArMiddlewareCallback middleware();
private:
Users& users_;
SessionManager& sessionManager_;
};
+183
View File
@@ -0,0 +1,183 @@
#include "webui.hpp"
#include <LittleFS.h>
#include <pb_encode.h>
#include <cstring>
WebUI::WebUI(uint16_t port) : server(port), auth_(users, sessionManager) {}
WebUI::~WebUI() {
end();
}
void WebUI::begin(bool restartIfRunning) {
if (isRunning_ && !restartIfRunning) {
LOG_INFO(WEBUI_SERVER_ALREADY_RUNNING);
return;
}
if (!LittleFS.begin(true)) {
LOG_ERROR(LITTLEFS_MOUNT_FAILED);
return;
}
end();
setupRoutes();
server.begin();
isRunning_ = true;
LOG_INFO(WEBUI_SERVER_STARTED);
}
void WebUI::end() {
if (!isRunning_) return;
server.end();
isRunning_ = false;
vTaskDelay(pdMS_TO_TICKS(50));
}
void WebUI::setupRoutes() {
// API endpoint for login
server.on("/api/login", HTTP_POST, [](AsyncWebServerRequest *request){}, NULL, [this](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {
handleLogin(request, data, len, index, total);
});
// API endpoint for logout
server.on("/api/logout", HTTP_POST, [this](AsyncWebServerRequest *request) {
handleLogout(request);
});
// API endpoint for auth check
server.on("/api/authchk", HTTP_GET, [this](AsyncWebServerRequest *request) {
handleAuthChk(request);
});
// API endpoints for settings
server.on("/api/settings", HTTP_GET, [this](AsyncWebServerRequest *request) {
handleGetSettings(request);
}).addMiddleware(auth_.middleware());
server.on("/api/settings", HTTP_POST, [](AsyncWebServerRequest *request){}, NULL, [this](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {
handleUpdateSettings(request, data, len, index, total);
}).addMiddleware(auth_.middleware());
// API endpoint for status
server.on("/api/status", HTTP_GET, [this](AsyncWebServerRequest *request) {
handleGetStatus(request);
}).addMiddleware(auth_.middleware());
// API endpoints for hardware
server.on("/api/hardware", HTTP_GET, [this](AsyncWebServerRequest *request) {
handleGetHardware(request);
}).addMiddleware(auth_.middleware());
server.on("/api/hardware", HTTP_POST, [](AsyncWebServerRequest *request){}, NULL, [this](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {
handleUpdateHardware(request, data, len, index, total);
}).addMiddleware(auth_.middleware());
// API endpoints for users
server.on("/api/users/password", HTTP_POST, [](AsyncWebServerRequest *request){}, NULL, [this](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {
handleUpdateUserPassword(request, data, len, index, total);
}).addMiddleware(auth_.middleware());
server.on("/api/users", HTTP_GET, [this](AsyncWebServerRequest *request) {
handleGetUsers(request);
}).addMiddleware(auth_.middleware());
server.on("/api/users", HTTP_POST, [](AsyncWebServerRequest *request){}, NULL, [this](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {
handleAddUser(request, data, len, index, total);
}).addMiddleware(auth_.middleware());
server.on("/api/users", HTTP_DELETE, [](AsyncWebServerRequest *request){}, NULL, [this](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {
handleDeleteUser(request, data, len, index, total);
}).addMiddleware(auth_.middleware());
// API endpoints for RFID database
server.on("/api/db", HTTP_GET, [this](AsyncWebServerRequest *request) {
handleGetRfidDatabase(request);
}).addMiddleware(auth_.middleware());
server.on("/api/db", HTTP_POST, [](AsyncWebServerRequest *request){}, NULL, [this](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {
handleUpdateRfidDatabase(request, data, len, index, total);
}).addMiddleware(auth_.middleware());
// API endpoints for RPC sync
server.on("/api/sync/full", HTTP_POST, [this](AsyncWebServerRequest *request) {
handleFullSync(request);
}).addMiddleware(auth_.middleware());
server.on("/api/sync", HTTP_POST, [this](AsyncWebServerRequest *request) {
handleSync(request);
}).addMiddleware(auth_.middleware());
// API endpoint for OTA enable
server.on("/api/ota", HTTP_POST, [](AsyncWebServerRequest *request){}, NULL, [this](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {
handleEnableOTA(request, data, len, index, total);
}).addMiddleware(auth_.middleware());
// API endpoint for sensor restart
server.on("/api/restart-sensor", HTTP_POST, [this](AsyncWebServerRequest *request) {
handleRestartSensor(request);
}).addMiddleware(auth_.middleware());
// API endpoints for ESP OTA
server.on("/api/esp-ota-enable", HTTP_POST, [this](AsyncWebServerRequest *request) {
handleEnableESPOTA(request);
}).addMiddleware(auth_.middleware());
server.on("/api/esp-ota-disable", HTTP_POST, [this](AsyncWebServerRequest *request) {
handleDisableESPOTA(request);
}).addMiddleware(auth_.middleware());
server.on("/api/esp-ota-status", HTTP_GET, [this](AsyncWebServerRequest *request) {
handleGetESPOTAStatus(request);
}).addMiddleware(auth_.middleware());
server.on("/api/esp-ota-upload", HTTP_POST, [](AsyncWebServerRequest *request) {
}, [this](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) {
handleESPOTAUpload(request, filename, index, data, len, final);
}).addMiddleware(auth_.middleware());
server.onNotFound([this](AsyncWebServerRequest *request) {
handleRoot(request);
});
}
void WebUI::sendProtobufResponse(AsyncWebServerRequest *request, void *proto_msg, const pb_msgdesc_t *fields) {
size_t resp_size = 0;
if (!pb_get_encoded_size(&resp_size, fields, proto_msg)) {
LOG_ERROR("PROTO-API: failed to get encoded size");
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)ENCODING_FAILED);
return;
}
uint8_t *resp_buffer = new uint8_t[resp_size];
if (!resp_buffer) {
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)MEMORY_ALLOCATION_FAILED);
return;
}
pb_ostream_t resp_stream = pb_ostream_from_buffer(resp_buffer, resp_size);
if (!pb_encode(&resp_stream, fields, proto_msg)) {
LOG_ERROR("PROTO-API: response encode failed");
delete[] resp_buffer;
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)ENCODING_FAILED);
return;
}
size_t actual_size = resp_stream.bytes_written;
LOG_DEBUG("PROTO-API: encoded size=%d", actual_size);
auto send_lambda = [resp_buffer, actual_size](uint8_t *buf, size_t maxLen, size_t index) -> size_t {
size_t copy_len = std::min(maxLen, actual_size - index);
std::memcpy(buf, resp_buffer + index, copy_len);
if (index + copy_len >= actual_size) {
delete[] resp_buffer;
}
return copy_len;
};
AsyncWebServerResponse *response = request->beginResponse((__FlashStringHelper*)CONTENT_TYPE_OCTET, actual_size, send_lambda);
request->send(response);
}
void WebUI::handleRoot(AsyncWebServerRequest *request) {
AsyncWebServerResponse *response = request->beginResponse(LittleFS, "/web/index.html.gz", "text/html", false);
response->addHeader("Content-Encoding", "gzip");
request->send(response);
}
+64
View File
@@ -0,0 +1,64 @@
#pragma once
#include <ESPAsyncWebServer.h>
#include <webui.pb.h>
#include <settings.pb.h>
#include <hardware.pb.h>
#include <settings.hpp>
#include <hardware_config.hpp>
#include <users.hpp>
#include <session.hpp>
#include <logger.hpp>
#include "webui_strings.hpp"
#include "auth.hpp"
#include "ota_manager.hpp"
class WebUI {
public:
WebUI(uint16_t port = 80);
~WebUI();
void begin(bool restartIfRunning=false);
void end();
void onSendToSensor(std::function<void(hardware_ControlToSensorMessage message)> callback);
void setOTAManager(OTAManager* otaManager);
private:
AsyncWebServer server;
Auth auth_;
bool isRunning_ = false;
OTAManager* otaManager_ = nullptr;
void setupRoutes();
void handleLogin(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total);
void handleLogout(AsyncWebServerRequest *request);
void handleAuthChk(AsyncWebServerRequest *request);
void handleGetSettings(AsyncWebServerRequest *request);
void handleUpdateSettings(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total);
void handleGetStatus(AsyncWebServerRequest *request);
void handleGetHardware(AsyncWebServerRequest *request);
void handleUpdateHardware(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total);
void handleGetUsers(AsyncWebServerRequest *request);
void handleAddUser(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total);
void handleDeleteUser(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total);
void handleUpdateUserPassword(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total);
void handleGetRfidDatabase(AsyncWebServerRequest *request);
void handleUpdateRfidDatabase(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total);
void handleSync(AsyncWebServerRequest *request);
void handleFullSync(AsyncWebServerRequest *request);
void handleEnableOTA(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total);
void handleRestartSensor(AsyncWebServerRequest *request);
void handleEnableESPOTA(AsyncWebServerRequest *request);
void handleDisableESPOTA(AsyncWebServerRequest *request);
void handleGetESPOTAStatus(AsyncWebServerRequest *request);
void handleESPOTAUpload(AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final);
void handleRoot(AsyncWebServerRequest *request);
// Protobuf callback functions
static bool decode_string(pb_istream_t *stream, const pb_field_t *field, void **arg);
static bool encode_string(pb_ostream_t *stream, const pb_field_t *field, void * const *arg);
static bool encode_string_array(pb_ostream_t *stream, const pb_field_t *field, void * const *arg);
void sendProtobufResponse(AsyncWebServerRequest *request, void *proto_msg, const pb_msgdesc_t *fields);
// Callback for sending messages to sensor
std::function<void(hardware_ControlToSensorMessage message)> sendToSensorCallback = nullptr;
};
+127
View File
@@ -0,0 +1,127 @@
#include "webui.hpp"
#include <pb_encode.h>
#include <pb_decode.h>
#include <cstring>
#include <vector> // Für sichere Buffer-Handling (optional, fallback zu new)
void WebUI::handleLogin(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {
static std::vector<uint8_t> buffer; // Non-static, aber static für chunked callback (per-request, assume no overlap)
// Alternative ohne STL: static uint8_t* buffer = nullptr; static size_t buffer_size = 0; (wie original, aber mit Warnung)
if (index == 0) {
if (strcmp_P(request->contentType().c_str(), CONTENT_TYPE_OCTET) != 0) {
request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)INVALID_CONTENT_TYPE);
return;
}
if (total == 0) {
request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)EMPTY_REQUEST);
return;
}
buffer.resize(total); // Oder: buffer = new uint8_t[total]; buffer_size = total;
}
std::memcpy(buffer.data() + index, data, len); // Oder buffer + index
if (index + len >= total) {
pb_istream_t stream = pb_istream_from_buffer(buffer.data(), buffer.size());
webui_WebUiLoginRequest login_req = webui_WebUiLoginRequest_init_zero;
bool decode_ok = pb_decode(&stream, webui_WebUiLoginRequest_fields, &login_req);
buffer.clear(); // Oder delete[] buffer; buffer = nullptr;
if (!decode_ok || stream.bytes_left != 0) {
LOG_ERROR("/api/login: protobuf decode failed");
request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)INVALID_PROTOBUF);
return;
}
LOG_DEBUG("/api/login: decoded username='%s' (password length=%d)", login_req.username, strlen(login_req.password)); // Password maskiert
const char* token = auth_.login(login_req.username, login_req.password);
bool success = (token != nullptr);
LOG_DEBUG("/api/login: auth result success=%d", success);
webui_WebUiLoginResponse login_resp = webui_WebUiLoginResponse_init_zero;
login_resp.success = success;
if (success) {
login_resp.has_token = true;
strcpy(login_resp.token, token);
} else {
char error_buf[30];
strcpy_P(error_buf, INVALID_CREDENTIALS);
login_resp.has_error = true;
strcpy(login_resp.error, error_buf);
}
sendProtobufResponse(request, &login_resp, webui_WebUiLoginResponse_fields);
}
}
void WebUI::handleLogout(AsyncWebServerRequest *request) {
const char* auth_value = request->getHeader("Authorization")->value().c_str();
if (!auth_value || strncmp(auth_value, "Bearer ", 7) != 0) {
request->send(401, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)UNAUTHORIZED);
return;
}
const char* token = auth_value + 7; // Skip "Bearer "
bool success = auth_.logout(token);
webui_WebUiLogoutResponse logout_resp = webui_WebUiLogoutResponse_init_zero; // Angenommen, proto definiert
logout_resp.success = success;
LOG_DEBUG("/api/logout: success=%d", success);
sendProtobufResponse(request, &logout_resp, webui_WebUiLogoutResponse_fields);
}
void WebUI::handleAuthChk(AsyncWebServerRequest *request) {
// Manuell formatierte Client-IP für Logging
char ip_buf[16];
snprintf(ip_buf, sizeof(ip_buf), "%d.%d.%d.%d",
request->client()->remoteIP()[0],
request->client()->remoteIP()[1],
request->client()->remoteIP()[2],
request->client()->remoteIP()[3]);
LOG_DEBUG("/api/authchk: processing request, client=%s", ip_buf);
// Prepare response
webui_WebUiAuthCheckResponse auth_resp = webui_WebUiAuthCheckResponse_init_zero;
auth_resp.authenticated = !auth_.isAuthRequired();
if (auth_.isAuthRequired()) {
// Prüfe Authorization-Header
if (!request->hasHeader("Authorization")) {
LOG_DEBUG("/api/authchk: no Authorization header, client=%s", ip_buf);
} else {
const AsyncWebHeader *auth_header = request->getHeader("Authorization");
String auth_value = auth_header->value();
if (!auth_value.startsWith("Bearer ")) {
LOG_DEBUG("/api/authchk: invalid Authorization header, client=%s", ip_buf);
} else {
String token = auth_value.substring(7); // Entferne "Bearer "
LOG_DEBUG("/api/authchk: checking token (masked), client=%s", ip_buf);
const char* username = auth_.checkAuth(token.c_str());
if (username) {
auth_resp.authenticated = true;
auth_resp.has_username = true;
// Verwende strncpy für Sicherheit (angenommen, username hat max_size in .proto)
strncpy(auth_resp.username, username, sizeof(auth_resp.username) - 1);
auth_resp.username[sizeof(auth_resp.username) - 1] = '\0'; // Null-Terminierung
LOG_DEBUG("/api/authchk: authenticated as '%s', client=%s", auth_resp.username, ip_buf);
} else {
LOG_DEBUG("/api/authchk: invalid token, client=%s", ip_buf);
}
}
}
} else {
LOG_DEBUG("/api/authchk: no auth required, client=%s", ip_buf);
}
// Entferne & vor _fields (Makro ist bereits Pointer)
sendProtobufResponse(request, &auth_resp, webui_WebUiAuthCheckResponse_fields);
}
+98
View File
@@ -0,0 +1,98 @@
#include "webui.hpp"
#include <pb_encode.h>
#include <pb_decode.h>
#include <cstring>
#include <hardware_config.hpp>
void WebUI::handleGetHardware(AsyncWebServerRequest *request) {
// Get hardware config
hardware_HardwareConfig& config = hardwareConfigRepo.getData();
// Encode response
size_t resp_size = 0;
pb_get_encoded_size(&resp_size, hardware_HardwareConfig_fields, &config);
if (resp_size == 0) {
LOG_ERROR("WebUI: /api/hardware GET: Response size is 0 - protobuf encoding failed!");
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"Response size is 0");
return;
}
uint8_t *resp_buffer = new uint8_t[resp_size];
if (!resp_buffer) {
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)MEMORY_ALLOCATION_FAILED);
return;
}
pb_ostream_t resp_stream = pb_ostream_from_buffer(resp_buffer, resp_size);
bool encode_ok = pb_encode(&resp_stream, hardware_HardwareConfig_fields, &config);
if (!encode_ok) {
delete[] resp_buffer;
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)ENCODING_FAILED);
return;
}
// Send response
AsyncWebServerResponse *response = request->beginResponse(CONTENT_TYPE_OCTET, resp_size, [resp_buffer, resp_size](uint8_t *buf, size_t maxLen, size_t index) -> size_t {
size_t len = min(maxLen, resp_size - index);
memcpy(buf, resp_buffer + index, len);
if (index + len >= resp_size) {
delete[] resp_buffer;
}
return len;
});
request->send(response);
}
void WebUI::handleUpdateHardware(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {
static uint8_t *buffer = nullptr;
static size_t buffer_size = 0;
if (index == 0) {
// First chunk
if (strcmp_P(request->contentType().c_str(), CONTENT_TYPE_OCTET) != 0) {
request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)INVALID_CONTENT_TYPE);
return;
}
buffer_size = total;
if (buffer_size == 0) {
request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)EMPTY_REQUEST);
return;
}
buffer = new uint8_t[buffer_size];
if (!buffer) {
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)MEMORY_ALLOCATION_FAILED);
return;
}
LOG_INFO("/api/hardware POST: receiving payload total=%d", buffer_size);
}
memcpy(buffer + index, data, len);
if (index + len >= total) {
// Last chunk, process
hardware_HardwareConfig update_config = hardware_HardwareConfig_init_zero;
pb_istream_t stream = pb_istream_from_buffer(buffer, buffer_size);
bool decode_ok = pb_decode(&stream, hardware_HardwareConfig_fields, &update_config);
delete[] buffer;
buffer = nullptr;
if (!decode_ok) {
LOG_ERROR("/api/hardware POST: protobuf decode failed");
request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)INVALID_PROTOBUF);
return;
}
// Update hardware config
hardwareConfigRepo.setData(update_config);
// Prepare response (simple success)
AsyncWebServerResponse *response = request->beginResponse(200, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"OK");
request->send(response);
}
}
+169
View File
@@ -0,0 +1,169 @@
#include "webui.hpp"
#include <pb_encode.h>
#include <pb_decode.h>
#include <cstring>
#include <Update.h>
void WebUI::handleEnableOTA(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {
static uint8_t *buffer = nullptr;
static size_t buffer_size = 0;
if (index == 0) {
// First chunk
if (strcmp_P(request->contentType().c_str(), CONTENT_TYPE_OCTET) != 0) {
request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)INVALID_CONTENT_TYPE);
return;
}
buffer_size = total;
if (buffer_size == 0) {
request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)EMPTY_REQUEST);
return;
}
buffer = new uint8_t[buffer_size];
if (!buffer) {
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)MEMORY_ALLOCATION_FAILED);
return;
}
LOG_INFO("/api/ota POST: receiving payload total=%d", buffer_size);
}
memcpy(buffer + index, data, len);
if (index + len >= total) {
// Last chunk, process
hardware_SensorOTAEnable ota_enable = hardware_SensorOTAEnable_init_zero;
pb_istream_t stream = pb_istream_from_buffer(buffer, buffer_size);
bool decode_ok = pb_decode(&stream, hardware_SensorOTAEnable_fields, &ota_enable);
delete[] buffer;
buffer = nullptr;
if (!decode_ok) {
LOG_ERROR("/api/ota POST: protobuf decode failed");
request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)INVALID_PROTOBUF);
return;
}
// Create ControlToSensorMessage with OTA enable
hardware_ControlToSensorMessage msg = hardware_ControlToSensorMessage_init_zero;
msg.control_id = 0; // Assuming control_id is 0 for now
msg.which_payload = hardware_ControlToSensorMessage_ota_enable_tag;
msg.payload.ota_enable = ota_enable;
// Send via callback
if (sendToSensorCallback) {
sendToSensorCallback(msg);
request->send(200, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"OTA enable sent");
} else {
LOG_ERROR("No sendToSensorCallback set");
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"Internal error");
}
}
}
void WebUI::handleRestartSensor(AsyncWebServerRequest *request) {
// Create ControlToSensorMessage with restart payload
hardware_ControlToSensorMessage msg = hardware_ControlToSensorMessage_init_zero;
msg.control_id = 0; // Assuming control_id is 0 for now
msg.which_payload = hardware_ControlToSensorMessage_restart_tag;
msg.payload.restart = hardware_SensorRestart_init_zero;
// Send via callback
if (sendToSensorCallback) {
sendToSensorCallback(msg);
request->send(200, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"Restart command sent");
} else {
LOG_ERROR("No sendToSensorCallback set");
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"Internal error");
}
}
void WebUI::onSendToSensor(std::function<void(hardware_ControlToSensorMessage message)> callback) {
sendToSensorCallback = callback;
}
void WebUI::setOTAManager(OTAManager* otaManager) {
otaManager_ = otaManager;
}
void WebUI::handleEnableESPOTA(AsyncWebServerRequest *request) {
if (!otaManager_) {
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"OTA manager not initialized");
return;
}
otaManager_->enable();
request->send(200, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"ESP OTA enabled");
}
void WebUI::handleDisableESPOTA(AsyncWebServerRequest *request) {
if (!otaManager_) {
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"OTA manager not initialized");
return;
}
otaManager_->disable();
request->send(200, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"ESP OTA disabled");
}
void WebUI::handleGetESPOTAStatus(AsyncWebServerRequest *request) {
if (!otaManager_) {
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"OTA manager not initialized");
return;
}
String status = "{ \"enabled\": " + String(otaManager_->isEnabled() ? "true" : "false") + ", \"status\": \"" + otaManager_->getStatus() + "\" }";
request->send(200, "application/json", status);
}
void WebUI::handleESPOTAUpload(AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) {
if (!index) {
LOG_INFO("OTA Update Start: %s (total expected: %u bytes)", filename.c_str(), request->contentLength());
// Prüfe freien Speicher (Debug-Info)
Serial.printf("Free sketch space: %u bytes\n", ESP.getFreeSketchSpace());
if (!Update.begin(UPDATE_SIZE_UNKNOWN, U_FLASH)) { // Explizit U_FLASH für Klarheit
Update.printError(Serial);
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"OTA update failed to begin");
return;
}
}
// Debug: Fortschritt loggen (alle 100 KB oder so, um nicht zu spammen)
static size_t lastLogged = 0;
size_t currentPos = index + len;
if (currentPos - lastLogged > 100000) { // Alle 100 KB
LOG_INFO("Free Heap: %u bytes", ESP.getFreeHeap());
LOG_INFO("OTA Progress: %u / %u bytes (%d%%)", currentPos, request->contentLength(),
(request->contentLength() > 0 ? (currentPos * 100 / request->contentLength()) : 0));
lastLogged = currentPos;
}
// Write firmware
size_t written = Update.write(data, len);
if (written != len) {
Update.printError(Serial);
LOG_ERROR("Write failed: wrote %u of %u bytes", written, len);
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"OTA update failed during write");
return;
}
if (final) {
if (Update.end(true)) {
LOG_INFO("OTA Update Success: %u bytes total", index + len);
request->send(200, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"OTA update successful. Rebooting...");
// Start restart task with delay
xTaskCreate([](void* pvParameters) {
vTaskDelay(2000 / portTICK_PERIOD_MS); // 2 second delay
ESP.restart();
vTaskDelete(NULL);
}, "RestartTask", 2048, NULL, 1, NULL);
} else {
Update.printError(Serial);
LOG_ERROR("End failed: error %u", Update.getError());
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"OTA update failed to end");
}
}
}
+34
View File
@@ -0,0 +1,34 @@
#include "webui.hpp"
#include <pb_encode.h>
#include <pb_decode.h>
#include <cstring>
// Protobuf callback functions
bool WebUI::decode_string(pb_istream_t *stream, const pb_field_t *field, void **arg) {
size_t len = stream->bytes_left;
char *str = (char*)malloc(len + 1);
if (!str) return false;
if (!pb_read(stream, (uint8_t*)str, len)) {
free(str);
return false;
}
str[len] = '\0';
*arg = str;
return true;
}
bool WebUI::encode_string(pb_ostream_t *stream, const pb_field_t *field, void * const *arg) {
char *str = (char*)*arg;
return pb_encode_tag_for_field(stream, field) && pb_encode_string(stream, (uint8_t*)str, strlen(str));
}
bool WebUI::encode_string_array(pb_ostream_t *stream, const pb_field_t *field, void * const *arg) {
std::vector<std::string> *usernames = (std::vector<std::string>*)*arg;
for (const auto& username : *usernames) {
if (!pb_encode_tag_for_field(stream, field) ||
!pb_encode_string(stream, (uint8_t*)username.c_str(), username.length())) {
return false;
}
}
return true;
}
+81
View File
@@ -0,0 +1,81 @@
#include "webui.hpp"
#include <LittleFS.h>
#include <rfid.hpp>
void WebUI::handleGetRfidDatabase(AsyncWebServerRequest *request) {
// Open the RFID database file directly from LittleFS
File file = LittleFS.open("/rfid.db", "r");
if (!file) {
// File doesn't exist or can't be opened
request->send(404, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"RFID database file not found");
return;
}
size_t fileSize = file.size();
if (fileSize == 0) {
// Empty file
file.close();
AsyncWebServerResponse *response = request->beginResponse(CONTENT_TYPE_OCTET, 0, [](uint8_t *buf, size_t maxLen, size_t index) -> size_t {
return 0;
});
request->send(response);
return;
}
// Send file content directly without modification
AsyncWebServerResponse *response = request->beginResponse(CONTENT_TYPE_OCTET, fileSize, [file](uint8_t *buf, size_t maxLen, size_t reqIndex) mutable -> size_t {
if (reqIndex >= file.size()) {
file.close();
return 0;
}
size_t bytesToRead = min(maxLen, (size_t)(file.size() - reqIndex));
size_t bytesRead = file.read(buf, bytesToRead);
// Close file when done
if (reqIndex + bytesRead >= file.size()) {
file.close();
}
return bytesRead;
});
request->send(response);
}
void WebUI::handleUpdateRfidDatabase(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {
static File uploadFile;
if (index == 0) {
// First chunk - open file for writing
uploadFile = LittleFS.open("/rfid.db", "w");
if (!uploadFile) {
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)ACTION_FAILED);
return;
}
LOG_INFO("WebUI: Starting RFID database upload, total size: %d bytes\n", total);
}
// Write data chunk to file
if (uploadFile) {
size_t bytesWritten = uploadFile.write(data, len);
if (bytesWritten != len) {
uploadFile.close();
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)ACTION_FAILED);
return;
}
}
if (index + len >= total) {
// Last chunk - close file and send response
if (uploadFile) {
uploadFile.close();
LOG_INFO("WebUI: RFID database upload completed, %d bytes written\n", total);
}
// Send success response
request->send(200, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)ACTION_SUCCESS);
}
}
+22
View File
@@ -0,0 +1,22 @@
#include "webui.hpp"
#include <rpc.hpp>
void WebUI::handleSync(AsyncWebServerRequest *request) {
if (rpc.sync(false)) {
request->send(200, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)ACTION_SUCCESS);
LOG_INFO("WebUI: Normal sync completed successfully");
} else {
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)ACTION_FAILED);
LOG_ERROR("WebUI: Normal sync failed");
}
}
void WebUI::handleFullSync(AsyncWebServerRequest *request) {
if (rpc.sync(true)) {
request->send(200, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)ACTION_SUCCESS);
LOG_INFO("WebUI: Full sync completed successfully");
} else {
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)ACTION_FAILED);
LOG_ERROR("WebUI: Full sync failed");
}
}
+77
View File
@@ -0,0 +1,77 @@
#include "webui.hpp"
#include <pb.h>
#include <pb_encode.h>
#include <pb_decode.h>
#include <cstring>
#include <vector>
#include <algorithm> // Für std::min
void WebUI::handleGetSettings(AsyncWebServerRequest *request) {
// Prepare response
settings_GetSettingsResponse resp = settings_GetSettingsResponse_init_zero;
resp.settings = settings.getData();
resp.has_settings = true;
String client_ip = request->client()->remoteIP().toString();
LOG_DEBUG("/api/settings GET: sending settings, client=%s", client_ip.c_str());
sendProtobufResponse(request, &resp, settings_GetSettingsResponse_fields);
}
void WebUI::handleUpdateSettings(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {
static std::vector<uint8_t> buffer; // Sicherer Buffer pro Request
String client_ip = request->client()->remoteIP().toString();
if (index == 0) {
// First chunk
if (strcmp_P(request->contentType().c_str(), CONTENT_TYPE_OCTET) != 0) {
LOG_ERROR("/api/settings POST: invalid content type, client=%s", client_ip.c_str());
request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)INVALID_CONTENT_TYPE);
return;
}
if (total == 0) {
LOG_ERROR("/api/settings POST: empty request, client=%s", client_ip.c_str());
request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)EMPTY_REQUEST);
return;
}
buffer.resize(total);
LOG_INFO("/api/settings POST: receiving payload total=%d, client=%s", total, client_ip.c_str());
}
std::memcpy(buffer.data() + index, data, len);
if (index + len >= total) {
// Last chunk, process
settings_UpdateSettingsRequest update_req = settings_UpdateSettingsRequest_init_zero;
pb_istream_t stream = pb_istream_from_buffer(buffer.data(), buffer.size());
bool decode_ok = pb_decode(&stream, settings_UpdateSettingsRequest_fields, &update_req);
buffer.clear();
if (!decode_ok || stream.bytes_left != 0) {
LOG_ERROR("/api/settings POST: protobuf decode failed, client=%s", client_ip.c_str());
request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)INVALID_PROTOBUF);
return;
}
// Update settings
settings.setData(update_req.settings);
// Save settings
if (!settings.save()) {
LOG_ERROR("/api/settings POST: failed to save settings, client=%s", client_ip.c_str());
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)FAILED_TO_SAVE_SETTINGS);
return;
}
// Prepare response
settings_UpdateSettingsResponse update_resp = settings_UpdateSettingsResponse_init_zero;
update_resp.success = true;
LOG_DEBUG("/api/settings POST: settings updated successfully, client=%s", client_ip.c_str());
sendProtobufResponse(request, &update_resp, settings_UpdateSettingsResponse_fields);
}
}
+49
View File
@@ -0,0 +1,49 @@
#include "webui.hpp"
#include <pb_encode.h>
#include <pb_decode.h>
#include <cstring>
#include "device/device_status.hpp"
void WebUI::handleGetStatus(AsyncWebServerRequest *request) {
// Create DeviceStatus instance
DeviceStatus deviceStatus;
// Get the populated status
const device_DeviceStatus& status = deviceStatus.getStatus();
// Get encoded size
size_t resp_size = 0;
pb_get_encoded_size(&resp_size, device_DeviceStatus_fields, &status);
if (resp_size == 0) {
LOG_ERROR("WebUI: /api/status GET: Response size is 0 - protobuf encoding failed!");
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)"Response size is 0");
return;
}
uint8_t *resp_buffer = new uint8_t[resp_size];
if (!resp_buffer) {
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)MEMORY_ALLOCATION_FAILED);
return;
}
pb_ostream_t resp_stream = pb_ostream_from_buffer(resp_buffer, resp_size);
bool encode_ok = pb_encode(&resp_stream, device_DeviceStatus_fields, &status);
if (!encode_ok) {
delete[] resp_buffer;
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)ENCODING_FAILED);
return;
}
// Send response
AsyncWebServerResponse *response = request->beginResponse(CONTENT_TYPE_OCTET, resp_size, [resp_buffer, resp_size](uint8_t *buf, size_t maxLen, size_t index) -> size_t {
size_t len = min(maxLen, resp_size - index);
memcpy(buf, resp_buffer + index, len);
if (index + len >= resp_size) {
delete[] resp_buffer;
}
return len;
});
request->send(response);
}
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <pgmspace.h>
// Frequently used strings stored in PROGMEM
const char CONTENT_TYPE_OCTET[] PROGMEM = "application/octet-stream";
const char CONTENT_TYPE_TEXT[] PROGMEM = "text/plain";
const char INVALID_CONTENT_TYPE[] PROGMEM = "Invalid content type";
const char EMPTY_REQUEST[] PROGMEM = "Empty request";
const char MEMORY_ALLOCATION_FAILED[] PROGMEM = "Memory allocation failed";
const char INVALID_PROTOBUF[] PROGMEM = "Invalid protobuf";
const char ENCODING_FAILED[] PROGMEM = "Encoding failed";
const char FAILED_TO_LOAD_SETTINGS[] PROGMEM = "Failed to load settings";
const char FAILED_TO_SAVE_SETTINGS[] PROGMEM = "Failed to save settings";
const char LITTLEFS_MOUNT_FAILED[] PROGMEM = "LittleFS Mount Failed";
const char WEBUI_SERVER_STARTED[] PROGMEM = "WebUI server started";
const char WEBUI_SERVER_ALREADY_RUNNING[] PROGMEM = "WebUI server already running";
const char DUMMY_TOKEN[] PROGMEM = "dummy_token";
const char INVALID_CREDENTIALS[] PROGMEM = "Invalid credentials";
const char UNAUTHORIZED[] PROGMEM = "Unauthorized";
// Generic action response messages
const char ACTION_SUCCESS[] PROGMEM = "Action completed successfully";
const char ACTION_FAILED[] PROGMEM = "Action failed";
+329
View File
@@ -0,0 +1,329 @@
#include "webui.hpp"
#include <pb_encode.h>
#include <pb_decode.h>
#include <cstring>
#include <vector>
void WebUI::handleGetUsers(AsyncWebServerRequest *request) {
// CHANGE: Use a temporary in-memory list to collect usernames.
// We use std::vector<std::string> because it manages memory for us
// and ensures the strings are valid throughout the function's scope.
std::vector<std::string> usernames;
// Populate the list using the new callback method from the Users class.
// The lambda function captures our 'usernames' vector by reference ([&])
// and adds each username it receives to the list.
users.loadUsernamesCallback([&](const char* username) {
usernames.push_back(username);
return true; // Return true to continue iterating.
});
// Prepare the Protobuf response structure.
webui_GetUsersResponse resp = webui_GetUsersResponse_init_zero;
// Wire up our new encoder callback and pass it a pointer to our temporary list.
resp.usernames.funcs.encode = &WebUI::encode_string_array;
resp.usernames.arg = &usernames; // Pass the address of our vector.
// --- The rest of the logic remains mostly the same ---
// Encode response to a buffer.
size_t resp_size = 0;
// Note: pb_get_encoded_size will fail here because the callback needs to run.
// We must encode directly and handle a potentially oversized buffer.
// A safe upper limit: (32 chars username + 2 bytes overhead) * 5 users + overhead
const size_t max_resp_size = (32 + 2) * 5 + 4;
uint8_t *resp_buffer = new uint8_t[max_resp_size];
if (!resp_buffer) {
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)MEMORY_ALLOCATION_FAILED);
return;
}
pb_ostream_t resp_stream = pb_ostream_from_buffer(resp_buffer, max_resp_size);
bool encode_ok = pb_encode(&resp_stream, webui_GetUsersResponse_fields, &resp);
if (!encode_ok) {
delete[] resp_buffer;
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)ENCODING_FAILED);
return;
}
// Use the actual encoded size from the stream.
size_t final_resp_size = resp_stream.bytes_written;
// Send response.
AsyncWebServerResponse *response = request->beginResponse(CONTENT_TYPE_OCTET, final_resp_size, [resp_buffer, final_resp_size](uint8_t *buf, size_t maxLen, size_t index) -> size_t {
size_t len = std::min(maxLen, final_resp_size - index);
if (len > 0) {
memcpy(buf, resp_buffer + index, len);
}
// Delete the buffer only when the transfer is complete.
if (index + len >= final_resp_size) {
delete[] resp_buffer;
}
return len;
});
request->send(response);
}
void WebUI::handleAddUser(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {
static uint8_t *buffer = nullptr;
static size_t buffer_size = 0;
if (index == 0) {
// First chunk
if (strcmp_P(request->contentType().c_str(), CONTENT_TYPE_OCTET) != 0) {
request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)INVALID_CONTENT_TYPE);
return;
}
buffer_size = total;
if (buffer_size == 0) {
request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)EMPTY_REQUEST);
return;
}
buffer = new uint8_t[buffer_size];
if (!buffer) {
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)MEMORY_ALLOCATION_FAILED);
return;
}
}
memcpy(buffer + index, data, len);
if (index + len >= total) {
// Last chunk, process
webui_AddUserRequest add_req = webui_AddUserRequest_init_zero;
pb_istream_t stream = pb_istream_from_buffer(buffer, buffer_size);
bool decode_ok = pb_decode(&stream, webui_AddUserRequest_fields, &add_req);
delete[] buffer;
buffer = nullptr;
if (!decode_ok) {
LOG_ERROR("/api/users POST: protobuf decode failed");
request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)INVALID_PROTOBUF);
return;
}
bool success = users.addUser(add_req.username, add_req.password);
// Prepare response
webui_AddUserResponse add_resp = webui_AddUserResponse_init_zero;
add_resp.success = success;
if (!success) {
add_resp.has_error = true;
strcpy(add_resp.error, "table full or user exists");
}else{
add_resp.has_error = false;
}
// Encode response
size_t resp_size = 0;
pb_get_encoded_size(&resp_size, webui_AddUserResponse_fields, &add_resp);
uint8_t *resp_buffer = new uint8_t[resp_size];
if (!resp_buffer) {
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)MEMORY_ALLOCATION_FAILED);
return;
}
pb_ostream_t resp_stream = pb_ostream_from_buffer(resp_buffer, resp_size);
bool encode_ok = pb_encode(&resp_stream, webui_AddUserResponse_fields, &add_resp);
if (!encode_ok) {
LOG_ERROR("/api/users POST: protobuf encode failed");
delete[] resp_buffer;
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)ENCODING_FAILED);
return;
}
// Send response
AsyncWebServerResponse *response = request->beginResponse(CONTENT_TYPE_OCTET, resp_size, [resp_buffer, resp_size](uint8_t *buf, size_t maxLen, size_t index) -> size_t {
size_t len = min(maxLen, resp_size - index);
memcpy(buf, resp_buffer + index, len);
if (index + len >= resp_size) {
delete[] resp_buffer;
}
return len;
});
request->send(response);
}
}
void WebUI::handleDeleteUser(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {
static uint8_t *buffer = nullptr;
static size_t buffer_size = 0;
if (index == 0) {
// First chunk
if (request->contentType() != "application/octet-stream") {
request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)INVALID_CONTENT_TYPE);
return;
}
buffer_size = total;
if (buffer_size == 0) {
request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)EMPTY_REQUEST);
return;
}
buffer = new uint8_t[buffer_size];
if (!buffer) {
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)MEMORY_ALLOCATION_FAILED);
return;
}
}
memcpy(buffer + index, data, len);
if (index + len >= total) {
// Last chunk, process
webui_DeleteUserRequest delete_req = webui_DeleteUserRequest_init_zero;
pb_istream_t stream = pb_istream_from_buffer(buffer, buffer_size);
bool decode_ok = pb_decode(&stream, webui_DeleteUserRequest_fields, &delete_req);
delete[] buffer;
buffer = nullptr;
if (!decode_ok) {
request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)INVALID_PROTOBUF);
return;
}
// Delete user
bool success = users.deleteUser(delete_req.username);
// Prepare response
webui_DeleteUserResponse delete_resp = webui_DeleteUserResponse_init_zero;
delete_resp.success = success;
if (!success) {
delete_resp.has_error = true;
strcpy(delete_resp.error, "User not found");
}
// Encode response
size_t resp_size = 0;
pb_get_encoded_size(&resp_size, webui_DeleteUserResponse_fields, &delete_resp);
uint8_t *resp_buffer = new uint8_t[resp_size];
if (!resp_buffer) {
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)MEMORY_ALLOCATION_FAILED);
return;
}
pb_ostream_t resp_stream = pb_ostream_from_buffer(resp_buffer, resp_size);
bool encode_ok = pb_encode(&resp_stream, webui_DeleteUserResponse_fields, &delete_resp);
if (!encode_ok) {
delete[] resp_buffer;
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)ENCODING_FAILED);
return;
}
// Send response
AsyncWebServerResponse *response = request->beginResponse("application/octet-stream", resp_size, [resp_buffer, resp_size](uint8_t *buf, size_t maxLen, size_t index) -> size_t {
size_t len = min(maxLen, resp_size - index);
memcpy(buf, resp_buffer + index, len);
if (index + len >= resp_size) {
delete[] resp_buffer;
}
return len;
});
request->send(response);
}
}
void WebUI::handleUpdateUserPassword(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {
static uint8_t *buffer = nullptr;
static size_t buffer_size = 0;
if (index == 0) {
// First chunk
if (request->contentType() != "application/octet-stream") {
request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)INVALID_CONTENT_TYPE);
return;
}
buffer_size = total;
if (buffer_size == 0) {
request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)EMPTY_REQUEST);
return;
}
buffer = new uint8_t[buffer_size];
if (!buffer) {
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)MEMORY_ALLOCATION_FAILED);
return;
}
}
memcpy(buffer + index, data, len);
if (index + len >= total) {
// Last chunk, process
webui_UpdateUserPasswordRequest update_req = webui_UpdateUserPasswordRequest_init_zero;
pb_istream_t stream = pb_istream_from_buffer(buffer, buffer_size);
bool decode_ok = pb_decode(&stream, webui_UpdateUserPasswordRequest_fields, &update_req);
delete[] buffer;
buffer = nullptr;
if (!decode_ok) {
request->send(400, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)INVALID_PROTOBUF);
return;
}
// Update password
bool success = users.updatePassword(update_req.username, update_req.new_password);
// Prepare response
webui_UpdateUserPasswordResponse update_resp = webui_UpdateUserPasswordResponse_init_zero;
update_resp.success = success;
if (!success) {
update_resp.has_error = true;
strcpy(update_resp.error, "User not found");
}
// Encode response
size_t resp_size = 0;
pb_get_encoded_size(&resp_size, webui_UpdateUserPasswordResponse_fields, &update_resp);
uint8_t *resp_buffer = new uint8_t[resp_size];
if (!resp_buffer) {
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)MEMORY_ALLOCATION_FAILED);
return;
}
pb_ostream_t resp_stream = pb_ostream_from_buffer(resp_buffer, resp_size);
bool encode_ok = pb_encode(&resp_stream, webui_UpdateUserPasswordResponse_fields, &update_resp);
if (!encode_ok) {
delete[] resp_buffer;
request->send(500, (__FlashStringHelper*)CONTENT_TYPE_TEXT, (__FlashStringHelper*)ENCODING_FAILED);
return;
}
// Send response
AsyncWebServerResponse *response = request->beginResponse("application/octet-stream", resp_size, [resp_buffer, resp_size](uint8_t *buf, size_t maxLen, size_t index) -> size_t {
size_t len = min(maxLen, resp_size - index);
memcpy(buf, resp_buffer + index, len);
if (index + len >= resp_size) {
delete[] resp_buffer;
}
return len;
});
request->send(response);
}
}
+277
View File
@@ -0,0 +1,277 @@
#include "wifi_manager.hpp"
#include <Arduino.h>
#include <logger.hpp>
WiFiManager::WiFiManager() {
registerEvents();
}
WiFiManager::~WiFiManager() {
stop();
unregisterEvents();
}
void WiFiManager::registerEvents() {
WiFi.onEvent(std::bind(&WiFiManager::onWiFiStaGotIP, this, std::placeholders::_1, std::placeholders::_2), WiFiEvent_t::ARDUINO_EVENT_WIFI_STA_GOT_IP);
WiFi.onEvent(std::bind(&WiFiManager::onWiFiStaDisconnected, this, std::placeholders::_1, std::placeholders::_2), WiFiEvent_t::ARDUINO_EVENT_WIFI_STA_DISCONNECTED);
}
void WiFiManager::unregisterEvents() {
WiFi.removeEvent(ARDUINO_EVENT_WIFI_STA_GOT_IP);
WiFi.removeEvent(ARDUINO_EVENT_WIFI_STA_DISCONNECTED);
}
void WiFiManager::begin(const settings_SettingsData& settings) {
// Safely copy the new configuration
wifi_mode_ = settings.wifi_mode;
strncpy(station_ssid_, settings.station_ssid, sizeof(station_ssid_) - 1);
station_ssid_[sizeof(station_ssid_) - 1] = '\0';
strncpy(station_password_, settings.station_password, sizeof(station_password_) - 1);
station_password_[sizeof(station_password_) - 1] = '\0';
strncpy(ap_ssid_, settings.ap_ssid, sizeof(ap_ssid_) - 1);
ap_ssid_[sizeof(ap_ssid_) - 1] = '\0';
strncpy(ap_password_, settings.ap_password, sizeof(ap_password_) - 1);
ap_password_[sizeof(ap_password_) - 1] = '\0';
ap_channel_ = settings.ap_channel;
stop(); // Start with a clean slate
bool success = false;
switch (wifi_mode_) {
case settings_WifiMode_WIFI_MODE_STATION:
success = applyStationMode();
break;
case settings_WifiMode_WIFI_MODE_AP:
success = applyAccessPointMode();
break;
case settings_WifiMode_WIFI_MODE_AP_STATION:
success = applyStationAndAPMode();
break;
default:
LOG_ERROR("WiFi: Unknown or unspecified WiFi mode. Stopping WiFi.");
state_ = WiFiState::STOPPED;
break;
}
if (!success) {
state_ = WiFiState::CONNECTION_FAILED;
}
}
void WiFiManager::update() {
static uint32_t last_check_time = 0;
uint32_t now = millis();
if (now - last_check_time < 1000) {
return; // Check every second
}else last_check_time = now;
switch (state_) {
case WiFiState::CONNECTING_STATION:
if (millis() - connection_start_time_ > CONNECTION_TIMEOUT_MS) {
LOG_WARN("WiFi: Station connection timed out.");
WiFi.disconnect();
state_ = WiFiState::CONNECTION_FAILED;
last_reconnect_attempt_ = millis();
}
break;
case WiFiState::CONNECTION_FAILED:
if (millis() - last_reconnect_attempt_ > reconnect_delay_ms_) {
LOG_INFO("WiFi: Attempting to reconnect...");
applyStationMode();
}
break;
default:
break;
}
}
void WiFiManager::stop() {
if (state_ == WiFiState::STOPPED) {
return; // Already stopped
}
LOG_INFO("WiFi: Stopping all WiFi activity...");
if (onPreShutdownCallback_) {
onPreShutdownCallback_();
}
// Give the network stack a moment to release the web server's port.
// 100ms is usually more than enough.
vTaskDelay(pdMS_TO_TICKS(100));
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
state_ = WiFiState::STOPPED;
}
bool WiFiManager::isConnected() const {
return (state_ == WiFiState::CONNECTED_STATION || state_ == WiFiState::RUNNING_AP_STA) && (WiFi.status() == WL_CONNECTED);
}
void WiFiManager::getState(device_DeviceInfo& deviceInfo) const {
// Set STA connection state
switch (state_) {
case WiFiState::CONNECTED_STATION:
case WiFiState::RUNNING_AP_STA:
deviceInfo.sta_connection_state = device_WifiConnectionState_WIFI_CONNECTED;
break;
case WiFiState::CONNECTING_STATION:
deviceInfo.sta_connection_state = device_WifiConnectionState_WIFI_CONNECTING;
break;
case WiFiState::CONNECTION_FAILED:
deviceInfo.sta_connection_state = device_WifiConnectionState_WIFI_FAILED;
break;
default:
deviceInfo.sta_connection_state = device_WifiConnectionState_WIFI_DISCONNECTED;
break;
}
// Set AP connection state
if (state_ == WiFiState::RUNNING_AP || state_ == WiFiState::RUNNING_AP_STA) {
deviceInfo.ap_connection_state = device_WifiConnectionState_WIFI_CONNECTED;
} else {
deviceInfo.ap_connection_state = device_WifiConnectionState_WIFI_DISCONNECTED;
}
// Populate STA details if connected
if (deviceInfo.sta_connection_state == device_WifiConnectionState_WIFI_CONNECTED) {
strncpy(deviceInfo.sta_ip, WiFi.localIP().toString().c_str(), sizeof(deviceInfo.sta_ip) - 1);
deviceInfo.sta_ip[sizeof(deviceInfo.sta_ip) - 1] = '\0';
strncpy(deviceInfo.sta_gateway, WiFi.gatewayIP().toString().c_str(), sizeof(deviceInfo.sta_gateway) - 1);
deviceInfo.sta_gateway[sizeof(deviceInfo.sta_gateway) - 1] = '\0';
deviceInfo.sta_signal_strength = WiFi.RSSI();
} else {
memset(deviceInfo.sta_ip, 0, sizeof(deviceInfo.sta_ip));
memset(deviceInfo.sta_gateway, 0, sizeof(deviceInfo.sta_gateway));
deviceInfo.sta_signal_strength = 0;
}
// Populate AP details if running
if (deviceInfo.ap_connection_state == device_WifiConnectionState_WIFI_CONNECTED) {
strncpy(deviceInfo.ap_ip, WiFi.softAPIP().toString().c_str(), sizeof(deviceInfo.ap_ip) - 1);
deviceInfo.ap_ip[sizeof(deviceInfo.ap_ip) - 1] = '\0';
deviceInfo.ap_client_count = WiFi.softAPgetStationNum();
} else {
memset(deviceInfo.ap_ip, 0, sizeof(deviceInfo.ap_ip));
deviceInfo.ap_client_count = 0;
}
}
// --- Event Handlers ---
void WiFiManager::onWiFiStaGotIP(WiFiEvent_t event, WiFiEventInfo_t info) {
IPAddress ip = WiFi.localIP();
LOG_INFO("WiFi: Station connected! IP: %s", ip.toString().c_str());
if (wifi_mode_ == settings_WifiMode_WIFI_MODE_AP_STATION) {
state_ = WiFiState::RUNNING_AP_STA;
} else {
state_ = WiFiState::CONNECTED_STATION;
}
if (onStaConnectedCallback_) {
onStaConnectedCallback_(ip);
}
}
void WiFiManager::onWiFiStaDisconnected(WiFiEvent_t event, WiFiEventInfo_t info) {
LOG_WARN("WiFi: Station disconnected. Reason: %d", info.wifi_sta_disconnected.reason);
state_ = WiFiState::CONNECTION_FAILED;
last_reconnect_attempt_ = millis(); // Set timestamp to start reconnect delay
if (onDisconnectedCallback_) {
onDisconnectedCallback_();
}
}
// --- Private Helper Methods ---
bool WiFiManager::applyStationMode() {
if (!validateStationConfig()) {
LOG_ERROR("WiFi: Invalid station configuration.");
return false;
}
LOG_INFO("WiFi: Connecting to station '%s'...", station_ssid_);
WiFi.mode(WIFI_STA);
WiFi.begin(station_ssid_, station_password_);
state_ = WiFiState::CONNECTING_STATION;
connection_start_time_ = millis();
return true;
}
bool WiFiManager::applyAccessPointMode() {
if (!validateAPConfig()) {
LOG_ERROR("WiFi: Invalid AP configuration.");
return false;
}
LOG_INFO("WiFi: Starting AP '%s'...", ap_ssid_);
WiFi.mode(WIFI_AP);
if (WiFi.softAP(ap_ssid_, ap_password_, ap_channel_)) {
IPAddress ip = WiFi.softAPIP();
LOG_INFO("WiFi: AP started, IP: %s", ip.toString().c_str());
state_ = WiFiState::RUNNING_AP;
if (onApStartedCallback_) {
onApStartedCallback_(ip);
}
return true;
} else {
LOG_ERROR("WiFi: Failed to start AP.");
return false;
}
}
bool WiFiManager::applyStationAndAPMode() {
if (!validateStationConfig() || !validateAPConfig()) {
LOG_ERROR("WiFi: Invalid station or AP configuration.");
return false;
}
LOG_INFO("WiFi: Starting AP + Station mode...");
WiFi.mode(WIFI_AP_STA);
// Start AP
if (!WiFi.softAP(ap_ssid_, ap_password_, ap_channel_)) {
LOG_ERROR("WiFi: Failed to start AP in AP+STA mode.");
return false;
}
IPAddress ip = WiFi.softAPIP();
LOG_INFO("WiFi: AP started, IP: %s", ip.toString().c_str());
if (onApStartedCallback_) {
onApStartedCallback_(ip);
}
// Begin connecting to station
WiFi.begin(station_ssid_, station_password_);
state_ = WiFiState::CONNECTING_STATION;
connection_start_time_ = millis();
return true;
}
bool WiFiManager::validateStationConfig() const {
return strlen(station_ssid_) > 0;
}
bool WiFiManager::validateAPConfig() const {
return strlen(ap_ssid_) > 0 && strlen(ap_password_) >= 8 && ap_channel_ >= 1 && ap_channel_ <= 13;
}
void WiFiManager::onStaConnected(std::function<void(IPAddress ip)> callback) {
onStaConnectedCallback_ = callback;
}
void WiFiManager::onApStarted(std::function<void(IPAddress ip)> callback) {
onApStartedCallback_ = callback;
}
void WiFiManager::onDisconnected(std::function<void()> callback) {
onDisconnectedCallback_ = callback;
}
void WiFiManager::onPreShutdown(std::function<void()> callback) {
onPreShutdownCallback_ = callback;
}
+123
View File
@@ -0,0 +1,123 @@
#pragma once
#include <Arduino.h>
#include <WiFi.h>
#include "settings.pb.h" // Your nanopb settings header
#include "device.pb.h" // Your nanopb device header
/**
* @enum WiFiState
* @brief Defines the possible operational states of the WiFiManager.
*/
enum class WiFiState {
STOPPED,
CONNECTING_STATION,
CONNECTED_STATION,
CONNECTION_FAILED,
RUNNING_AP,
RUNNING_AP_STA
};
/**
* @class WiFiManager
* @brief A non-blocking, event-driven manager for ESP32 WiFi connectivity.
*
* This class handles WiFi configuration and state management asynchronously,
* using the ESP32's event system instead of blocking delay loops.
*/
class WiFiManager {
public:
WiFiManager();
~WiFiManager();
/**
* @brief Applies a new WiFi configuration and initiates the connection process.
* @param settings The settings_SettingsData struct containing the new configuration.
*/
void begin(const settings_SettingsData& settings);
/**
* @brief The main update loop for the manager. Must be called repeatedly from the main loop().
* This method handles state transitions like connection timeouts and reconnect attempts.
*/
void update();
/**
* @brief Stops all WiFi activity and disconnects.
*/
void stop();
/**
* @brief Checks if the device is currently connected as a WiFi station.
* @return true if connected to an access point, false otherwise.
*/
bool isConnected() const;
/**
* @brief Gets the current operational state of the WiFiManager.
* @return The current WiFiState.
*/
WiFiState getState() const { return state_; }
/**
* @brief Populates the provided DeviceInfo with current WiFi status information.
* @param deviceInfo Reference to the DeviceInfo message to populate.
*/
void getState(device_DeviceInfo& deviceInfo) const;
/**
* @brief Registers a callback for when the Station interface connects and gets an IP.
*/
void onStaConnected(std::function<void(IPAddress ip)> callback);
/**
* @brief Registers a callback for when the Access Point interface has started.
*/
void onApStarted(std::function<void(IPAddress ip)> callback);
/**
* @brief Registers a callback function to be called upon station disconnection.
*/
void onDisconnected(std::function<void()> callback);
/**
* @brief Registers a callback to be called just before WiFi is disconnected or stopped.
* This allows other modules to shut down gracefully.
*/
void onPreShutdown(std::function<void()> callback);
private:
// --- Configuration ---
settings_WifiMode wifi_mode_;
char station_ssid_[33];
char station_password_[65];
char ap_ssid_[33];
char ap_password_[65];
uint8_t ap_channel_;
// --- State Machine ---
WiFiState state_ = WiFiState::STOPPED;
uint32_t connection_start_time_ = 0;
uint32_t reconnect_delay_ms_ = 10000; // Wait 10s before reconnecting
uint32_t last_reconnect_attempt_ = 0;
static constexpr uint32_t CONNECTION_TIMEOUT_MS = 30000; // 30-second timeout
// --- WiFi Event Handlers ---
void registerEvents();
void unregisterEvents();
void onWiFiStaGotIP(WiFiEvent_t event, WiFiEventInfo_t info);
void onWiFiStaDisconnected(WiFiEvent_t event, WiFiEventInfo_t info);
// --- Private Helper Methods ---
bool applyStationMode();
bool applyAccessPointMode();
bool applyStationAndAPMode();
bool validateStationConfig() const;
bool validateAPConfig() const;
std::function<void(IPAddress ip)> onStaConnectedCallback_ = nullptr;
std::function<void(IPAddress ip)> onApStartedCallback_ = nullptr;
std::function<void()> onDisconnectedCallback_ = nullptr;
std::function<void()> onPreShutdownCallback_ = nullptr;
};
+11
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
+23
View File
@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+9
View File
@@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/
+16
View File
@@ -0,0 +1,16 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
],
"tailwindStylesheet": "./src/app.css"
}
+38
View File
@@ -0,0 +1,38 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src\\app.css",
"baseColor": "zinc"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry"
}
+41
View File
@@ -0,0 +1,41 @@
import prettier from 'eslint-config-prettier';
import { fileURLToPath } from 'node:url';
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import { defineConfig } from 'eslint/config';
import globals from 'globals';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default defineConfig(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node }
},
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig
}
}
}
);
+5398
View File
File diff suppressed because it is too large Load Diff
+58
View File
@@ -0,0 +1,58 @@
{
"name": "ui",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"copy": "node scripts/copy-web-files.js",
"proto": "protoc --plugin=./node_modules/.bin/protoc-gen-ts_proto.cmd --ts_proto_out=./src/lib/gen --ts_proto_opt=useOptionals=messages,outputEncodeMethods=true,outputJsonMethods=false,outputClientImpl=true,addGrpcMetadata=false --proto_path=../proto ../proto/*.proto"
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.22.0",
"@internationalized/date": "^3.9.0",
"@lucide/svelte": "^0.544.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^22",
"bits-ui": "^2.11.4",
"clsx": "^2.1.1",
"eslint": "^9.22.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
"globals": "^16.0.0",
"mode-watcher": "^1.1.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"singlefile": "^0.0.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-sonner": "^1.0.5",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^3.1.1",
"tailwindcss": "^4.0.0",
"tw-animate-css": "^1.4.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"vite": "^7.0.4",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-singlefile": "^2.3.0"
},
"dependencies": {
"svelte-i18n": "^4.0.1",
"ts-proto": "^2.7.7"
}
}
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import zlib from 'zlib';
import { fileURLToPath } from 'url';
import { pipeline } from 'stream/promises';
// --- Konfiguration ---
const GZIP_OPTIONS = { level: zlib.constants.Z_BEST_COMPRESSION };
// --------------------
/**
* Kopiert und komprimiert nur die index.html Datei von sourceDir nach destDir.
* Die Datei wird nur als .gz gespeichert.
*
* @param {string} sourceDir - Quellordner
* @param {string} destDir - Zielordner
*/
async function processIndexHtml(sourceDir, destDir) {
console.log(`Processing index.html from '${sourceDir}' to '${destDir}'`);
// Erstelle Zielverzeichnis, wenn es nicht existiert
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
const sourcePath = path.join(sourceDir, 'index.html');
const destPathGz = path.join(destDir, 'index.html.gz');
// Prüfe ob index.html existiert
if (!fs.existsSync(sourcePath)) {
throw new Error(`index.html not found in '${sourceDir}'`);
}
// Komprimiere index.html
console.log(`Compressing ${sourcePath} -> ${destPathGz}`);
try {
const readStream = fs.createReadStream(sourcePath);
const gzipStream = zlib.createGzip(GZIP_OPTIONS);
const writeStream = fs.createWriteStream(destPathGz);
await pipeline(readStream, gzipStream, writeStream);
} catch (err) {
console.error(`Error compressing ${sourcePath}:`, err);
throw err;
}
}
/**
* Lösche den Ordner und seinen gesamten Inhalt
*
* @param {string} dir - Der zu löschende Ordner
*/
function removeDir(dir) {
if (fs.existsSync(dir)) {
console.log(`Removing existing directory '${dir}'`);
fs.rmSync(dir, { recursive: true, force: true });
}
}
// Hauptfunktion
async function main() {
// Projekt-Root-Verzeichnis ermitteln
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const scriptDir = __dirname;
const projectRoot = path.resolve(scriptDir, '..');
const rootDir = path.resolve(projectRoot, '..');
// Quell- und Zielverzeichnisse
const sourceDir = path.join(projectRoot, 'build');
const destDir = path.join(rootDir, 'data', 'web');
if (!fs.existsSync(sourceDir) || !fs.statSync(sourceDir).isDirectory()) {
console.error(`Error: Source directory '${sourceDir}' doesn't exist or is not a directory!`);
console.error("Make sure to build the Svelte app first with 'npm run build'");
process.exit(1);
}
removeDir(destDir);
try {
await processIndexHtml(sourceDir, destDir);
console.log("index.html processed successfully!");
} catch(err) {
console.error("Error during file processing:", err);
process.exit(1);
}
}
// Ausführen des Skripts
main();
+121
View File
@@ -0,0 +1,121 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+13
View File
@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+278
View File
@@ -0,0 +1,278 @@
import {
GetSettingsResponse,
SettingsData,
UpdateSettingsRequest,
UpdateSettingsResponse,
WifiMode
} from '$lib/gen/settings';
import { DeviceInfo } from '$lib/gen/device';
import {
AddUserRequest,
AddUserResponse,
DeleteUserRequest,
DeleteUserResponse,
GetUsersResponse,
UpdateUserPasswordRequest,
UpdateUserPasswordResponse,
WebUiLoginRequest,
WebUiLoginResponse
} from '$lib/gen/webui';
import type { Handle } from '@sveltejs/kit';
// ESP32 proxy URL - comment out this line to use mock API instead
const ESP32_URL = 'http://192.168.20.38';
// Mock settings storage (in-memory for development)
let mockSettings = SettingsData.create({
syncServerUrl: 'http://mock-server.com',
syncIntervalSeconds: 1800, // 30 minutes in seconds
autoSync: true,
wifiMode: WifiMode.WIFI_MODE_STATION,
stationSsid: 'MockWiFi',
stationPassword: 'password123',
apSsid: 'ESP32-AP',
apPassword: 'ap123456',
apChannel: 6,
enableFallbackAp: true,
updatedAt: Date.now(),
version: 1
});
// Mock users storage (in-memory for development, max 5 users)
let mockUsers: WebUiLoginRequest[] = [
WebUiLoginRequest.create({ username: 'admin', password: 'password' }),
WebUiLoginRequest.create({ username: 'user1', password: 'pass123' })
];
export const handle: Handle = async ({ event, resolve }) => {
// If ESP32_URL is defined, proxy to ESP32, otherwise use mock
if (ESP32_URL && event.url.pathname.startsWith('/api/')) {
const esp32Url = ESP32_URL + event.url.pathname;
try {
let body: ArrayBuffer | undefined;
if (event.request.method !== 'GET') {
body = await event.request.arrayBuffer();
}
const headers: Record<string, string> = {
'Content-Type': 'application/octet-stream'
};
// Forward Authorization header if present
const authHeader = event.request.headers.get('Authorization');
if (authHeader) {
headers['Authorization'] = authHeader;
console.log('Authorization:', authHeader);
}
const response = await fetch(esp32Url, {
method: event.request.method,
headers,
body: body
});
if (!response.ok) {
return new Response(response.statusText, { status: response.status });
}
const responseData = await response.arrayBuffer();
return new Response(responseData, {
headers: {
'Content-Type': 'application/octet-stream'
}
});
} catch (error) {
console.error('ESP32 proxy error:', error);
return new Response('ESP32 connection failed', { status: 502 });
}
}
// Mock the ESP32 API endpoints (when ESP32_URL is not set)
if (event.url.pathname === '/api/login' && event.request.method === 'POST') {
// Get the binary data
const buffer = await event.request.arrayBuffer();
const uint8Array = new Uint8Array(buffer);
// Decode the protobuf request
const loginReq = WebUiLoginRequest.decode(uint8Array);
// Simple authentication (same as ESP32)
const success = loginReq.username === 'admin' && loginReq.password === 'password';
// Create response
const loginResp = WebUiLoginResponse.create({
success,
token: success ? 'mock_token' : undefined,
error: success ? undefined : 'Invalid credentials'
});
// Encode response
const responseData = WebUiLoginResponse.encode(loginResp).finish();
return new Response(responseData, {
headers: {
'Content-Type': 'application/octet-stream'
}
});
}
// Mock settings endpoints
if (event.url.pathname === '/api/settings') {
if (event.request.method === 'GET') {
// Mock get settings using global mockSettings
const mockDeviceInfo = DeviceInfo.create({
deviceId: 'ESP32-001',
firmwareVersion: '1.0.0-mock',
hardwareVersion: 'ESP32-v1',
uptimeSeconds: 123
});
const getResp = GetSettingsResponse.create({
settings: mockSettings
});
const responseData = GetSettingsResponse.encode(getResp).finish();
return new Response(responseData, {
headers: {
'Content-Type': 'application/octet-stream'
}
});
} else if (event.request.method === 'POST') {
// Mock update settings
const buffer = await event.request.arrayBuffer();
const uint8Array = new Uint8Array(buffer);
// Decode the protobuf request
const updateReq = UpdateSettingsRequest.decode(uint8Array);
// Update the global mock settings
mockSettings = SettingsData.create({
...mockSettings,
...updateReq.settings,
updatedAt: Date.now(),
version: mockSettings.version + 1
});
console.log('Mock updated settings:', mockSettings);
// Create success response
const updateResp = UpdateSettingsResponse.create({
success: true
});
const responseData = UpdateSettingsResponse.encode(updateResp).finish();
return new Response(responseData, {
headers: {
'Content-Type': 'application/octet-stream'
}
});
}
}
// Mock user endpoints
if (event.url.pathname === '/api/users') {
if (event.request.method === 'GET') {
// Get users
const usernames = mockUsers.map((u) => u.username);
const getResp = GetUsersResponse.create({ usernames });
const responseData = GetUsersResponse.encode(getResp).finish();
return new Response(responseData, {
headers: {
'Content-Type': 'application/octet-stream'
}
});
} else if (event.request.method === 'POST') {
// Add user
const buffer = await event.request.arrayBuffer();
const uint8Array = new Uint8Array(buffer);
const addReq = AddUserRequest.decode(uint8Array);
// Check if user already exists
const existingUser = mockUsers.find((u) => u.username === addReq.username);
if (existingUser) {
const addResp = AddUserResponse.create({ success: false, error: 'User already exists' });
const responseData = AddUserResponse.encode(addResp).finish();
return new Response(responseData, {
headers: { 'Content-Type': 'application/octet-stream' }
});
}
// Check if table is full (max 5 users)
if (mockUsers.length >= 5) {
const addResp = AddUserResponse.create({ success: false, error: 'User table full' });
const responseData = AddUserResponse.encode(addResp).finish();
return new Response(responseData, {
headers: { 'Content-Type': 'application/octet-stream' }
});
}
// Add user
mockUsers.push(
WebUiLoginRequest.create({ username: addReq.username, password: addReq.password })
);
const addResp = AddUserResponse.create({ success: true });
const responseData = AddUserResponse.encode(addResp).finish();
return new Response(responseData, {
headers: { 'Content-Type': 'application/octet-stream' }
});
} else if (event.request.method === 'DELETE') {
// Delete user
const buffer = await event.request.arrayBuffer();
const uint8Array = new Uint8Array(buffer);
const deleteReq = DeleteUserRequest.decode(uint8Array);
const userIndex = mockUsers.findIndex((u) => u.username === deleteReq.username);
if (userIndex === -1) {
const deleteResp = DeleteUserResponse.create({ success: false, error: 'User not found' });
const responseData = DeleteUserResponse.encode(deleteResp).finish();
return new Response(responseData, {
headers: { 'Content-Type': 'application/octet-stream' }
});
}
mockUsers.splice(userIndex, 1);
const deleteResp = DeleteUserResponse.create({ success: true });
const responseData = DeleteUserResponse.encode(deleteResp).finish();
return new Response(responseData, {
headers: { 'Content-Type': 'application/octet-stream' }
});
}
}
// Mock user password update endpoint
if (event.url.pathname === '/api/users/password' && event.request.method === 'POST') {
const buffer = await event.request.arrayBuffer();
const uint8Array = new Uint8Array(buffer);
const updateReq = UpdateUserPasswordRequest.decode(uint8Array);
const user = mockUsers.find((u) => u.username === updateReq.username);
if (!user) {
const updateResp = UpdateUserPasswordResponse.create({
success: false,
error: 'User not found'
});
const responseData = UpdateUserPasswordResponse.encode(updateResp).finish();
return new Response(responseData, {
headers: { 'Content-Type': 'application/octet-stream' }
});
}
user.password = updateReq.newPassword;
const updateResp = UpdateUserPasswordResponse.create({ success: true });
const responseData = UpdateUserPasswordResponse.encode(updateResp).finish();
return new Response(responseData, {
headers: { 'Content-Type': 'application/octet-stream' }
});
}
// For other requests, continue normally
return resolve(event);
};
+1
View File
@@ -0,0 +1 @@
export const BASE_URL = window.location.origin;
+26
View File
@@ -0,0 +1,26 @@
import { goto } from '$app/navigation';
import { get } from 'svelte/store';
import { authStore } from '../stores/auth';
export async function authenticatedFetch(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
const authState = get(authStore);
const headers = new Headers(init?.headers);
if (authState.token && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${authState.token}`);
}
const updatedInit = { ...init, headers };
const response = await fetch(input, updatedInit);
if (response.status === 401) {
authStore.logout();
goto('/login');
}
return response;
}
+402
View File
@@ -0,0 +1,402 @@
import {
GetSettingsResponse,
SettingsData,
UpdateSettingsRequest,
UpdateSettingsResponse
} from '../gen/settings';
import { HardwareConfig, SensorOTAEnable } from '../gen/hardware';
import {
AddUserRequest,
AddUserResponse,
DeleteUserRequest,
DeleteUserResponse,
GetUsersResponse,
UpdateUserPasswordRequest,
UpdateUserPasswordResponse,
WebUiAuthCheckResponse,
WebUiLoginRequest,
WebUiLoginResponse
} from '../gen/webui';
import { DeviceStatus } from '../gen/device';
import { BASE_URL } from './constants';
import { authenticatedFetch } from './fetch';
export class WebUIApi {
private baseUrl: string;
private username: string | null = null;
constructor(baseUrl: string = BASE_URL) {
this.baseUrl = baseUrl;
}
async login(username: string, password: string): Promise<WebUiLoginResponse> {
const request = WebUiLoginRequest.create({ username, password });
const requestData = WebUiLoginRequest.encode(request).finish();
const response = await authenticatedFetch(`${this.baseUrl}/api/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream'
},
body: requestData
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const responseData = await response.arrayBuffer();
const loginResponse = WebUiLoginResponse.decode(new Uint8Array(responseData));
if (loginResponse.token) {
this.username = username;
}
return loginResponse;
}
async authchk(): Promise<WebUiAuthCheckResponse> {
const response = await authenticatedFetch(`${this.baseUrl}/api/authchk`, {
method: 'GET'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const responseData = await response.arrayBuffer();
const authResponse = WebUiAuthCheckResponse.decode(new Uint8Array(responseData));
if (authResponse.authenticated) {
this.username = authResponse.username || null;
}
return authResponse;
}
async getStatus(): Promise<DeviceStatus> {
const response = await authenticatedFetch(`${this.baseUrl}/api/status`, {
method: 'GET'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const responseData = await response.arrayBuffer();
const statusResponse = DeviceStatus.decode(new Uint8Array(responseData));
return statusResponse;
}
async getSettings(): Promise<GetSettingsResponse> {
const response = await authenticatedFetch(`${this.baseUrl}/api/settings`, {
method: 'GET'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const responseData = await response.arrayBuffer();
const settingsResponse = GetSettingsResponse.decode(new Uint8Array(responseData));
return settingsResponse;
}
async updateSettings(settings: SettingsData): Promise<UpdateSettingsResponse> {
const request = UpdateSettingsRequest.create({ settings });
const requestData = UpdateSettingsRequest.encode(request).finish();
const response = await authenticatedFetch(`${this.baseUrl}/api/settings`, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream'
},
body: requestData
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const responseData = await response.arrayBuffer();
const updateResponse = UpdateSettingsResponse.decode(new Uint8Array(responseData));
return updateResponse;
}
async getHardware(): Promise<HardwareConfig> {
const response = await authenticatedFetch(`${this.baseUrl}/api/hardware`, {
method: 'GET'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const responseData = await response.arrayBuffer();
const hardwareConfig = HardwareConfig.decode(new Uint8Array(responseData));
return hardwareConfig;
}
async updateHardware(config: HardwareConfig): Promise<void> {
const requestData = HardwareConfig.encode(config).finish();
const response = await authenticatedFetch(`${this.baseUrl}/api/hardware`, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream'
},
body: requestData
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
}
async getUsers(): Promise<GetUsersResponse> {
const response = await authenticatedFetch(`${this.baseUrl}/api/users`, {
method: 'GET',
headers: {
'Content-Type': 'application/octet-stream'
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const responseData = await response.arrayBuffer();
const usersResponse = GetUsersResponse.decode(new Uint8Array(responseData));
return usersResponse;
}
async addUser(username: string, password: string): Promise<AddUserResponse> {
const request = AddUserRequest.create({ username, password });
const requestData = AddUserRequest.encode(request).finish();
const response = await authenticatedFetch(`${this.baseUrl}/api/users`, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream'
},
body: requestData
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const responseData = await response.arrayBuffer();
const addResponse = AddUserResponse.decode(new Uint8Array(responseData));
return addResponse;
}
async deleteUser(username: string): Promise<DeleteUserResponse> {
const request = DeleteUserRequest.create({ username });
const requestData = DeleteUserRequest.encode(request).finish();
const response = await authenticatedFetch(`${this.baseUrl}/api/users`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/octet-stream'
},
body: requestData
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const responseData = await response.arrayBuffer();
const deleteResponse = DeleteUserResponse.decode(new Uint8Array(responseData));
return deleteResponse;
}
async updateUserPassword(
username: string,
newPassword: string
): Promise<UpdateUserPasswordResponse> {
const request = UpdateUserPasswordRequest.create({ username, newPassword });
const requestData = UpdateUserPasswordRequest.encode(request).finish();
const response = await authenticatedFetch(`${this.baseUrl}/api/users/password`, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream'
},
body: requestData
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const responseData = await response.arrayBuffer();
const updateResponse = UpdateUserPasswordResponse.decode(new Uint8Array(responseData));
return updateResponse;
}
get getUsername(): string | null {
return this.username;
}
async getRfidDatabase(): Promise<Uint32Array> {
const response = await authenticatedFetch(`${this.baseUrl}/api/db`, {
method: 'GET'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const binaryData = await response.arrayBuffer();
const dataView = new DataView(binaryData);
// Read uint32 values as big endian
const rfidIds = new Uint32Array(binaryData.byteLength / 4);
for (let i = 0; i < rfidIds.length; i++) {
rfidIds[i] = dataView.getUint32(i * 4, false); // false = big endian
}
return rfidIds;
}
async updateRfidDatabase(rfidIds: Uint32Array): Promise<void> {
// Sort the RFID IDs
const sortedIds = new Uint32Array(rfidIds);
sortedIds.sort((a, b) => a - b);
// Create a buffer and write values in big endian format
const buffer = new ArrayBuffer(sortedIds.length * 4);
const dataView = new DataView(buffer);
for (let i = 0; i < sortedIds.length; i++) {
dataView.setUint32(i * 4, sortedIds[i], false); // false = big endian
}
const response = await authenticatedFetch(`${this.baseUrl}/api/db`, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream'
},
body: buffer
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
}
async sync(): Promise<string> {
const response = await authenticatedFetch(`${this.baseUrl}/api/sync`, {
method: 'POST'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.text();
}
async fullSync(): Promise<string> {
const response = await authenticatedFetch(`${this.baseUrl}/api/sync/full`, {
method: 'POST'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.text();
}
async enableOTA(otaConfig: SensorOTAEnable): Promise<string> {
const requestData = SensorOTAEnable.encode(otaConfig).finish();
const response = await authenticatedFetch(`${this.baseUrl}/api/ota`, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream'
},
body: requestData
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.text();
}
async restartSensor(): Promise<string> {
const response = await authenticatedFetch(`${this.baseUrl}/api/restart-sensor`, {
method: 'POST'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.text();
}
async enableESPOTA(): Promise<string> {
const response = await authenticatedFetch(`${this.baseUrl}/api/esp-ota-enable`, {
method: 'POST'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.text();
}
async disableESPOTA(): Promise<string> {
const response = await authenticatedFetch(`${this.baseUrl}/api/esp-ota-disable`, {
method: 'POST'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.text();
}
async getESPOTAStatus(): Promise<{ enabled: boolean; status: string }> {
const response = await authenticatedFetch(`${this.baseUrl}/api/esp-ota-status`, {
method: 'GET'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
}
async uploadESPOTAFirmware(file: File): Promise<string> {
const formData = new FormData();
formData.append('firmware', file);
const response = await authenticatedFetch(`${this.baseUrl}/api/esp-ota-upload`, {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.text();
}
}
export const webUIApi = new WebUIApi();
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,88 @@
<script lang="ts">
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card';
import { Label } from '$lib/components/ui/label';
import type { DeviceInfo } from '$lib/gen/device';
export let deviceInfo: DeviceInfo | undefined;
function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);
return `${days}d ${hours}h ${mins}m`;
}
function getWifiStatus(state: number): string {
switch (state) {
case 0:
return 'Disconnected';
case 1:
return 'Connected';
case 2:
return 'Connecting';
case 3:
return 'Failed';
default:
return 'Unknown';
}
}
</script>
<Card>
<CardHeader>
<CardTitle>Device Information</CardTitle>
<CardDescription>Read-only information about this device</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
{#if deviceInfo}
<div class="grid grid-cols-2 gap-4">
<div>
<Label class="text-sm font-medium text-gray-700">Device ID</Label>
<p class="text-sm text-gray-900">{deviceInfo.deviceId || 'N/A'}</p>
</div>
<div>
<Label class="text-sm font-medium text-gray-700">Firmware Version</Label>
<p class="text-sm text-gray-900">{deviceInfo.firmwareVersion || 'N/A'}</p>
</div>
<div>
<Label class="text-sm font-medium text-gray-700">Hardware Version</Label>
<p class="text-sm text-gray-900">{deviceInfo.hardwareVersion || 'N/A'}</p>
</div>
<div>
<Label class="text-sm font-medium text-gray-700">Uptime</Label>
<p class="text-sm text-gray-900">{formatUptime(deviceInfo.uptimeSeconds)}</p>
</div>
</div>
<div class="mt-4">
<h4 class="mb-2 text-sm font-medium text-gray-700">WiFi Status</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<Label class="text-sm font-medium text-gray-700">Station</Label>
<p class="text-sm text-gray-900">{getWifiStatus(deviceInfo.staConnectionState)}</p>
{#if deviceInfo.staConnectionState === 1}
<p class="text-xs text-gray-500">IP: {deviceInfo.staIp}</p>
<p class="text-xs text-gray-500">Signal: {deviceInfo.staSignalStrength} dBm</p>
{/if}
</div>
<div>
<Label class="text-sm font-medium text-gray-700">Access Point</Label>
<p class="text-sm text-gray-900">{getWifiStatus(deviceInfo.apConnectionState)}</p>
{#if deviceInfo.apConnectionState === 1}
<p class="text-xs text-gray-500">IP: {deviceInfo.apIp}</p>
<p class="text-xs text-gray-500">Clients: {deviceInfo.apClientCount}</p>
{/if}
</div>
</div>
</div>
{:else}
<p class="text-sm text-gray-500">Loading device information...</p>
{/if}
</CardContent>
</Card>
@@ -0,0 +1,369 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import { LedConfig } from '$lib/gen/hardware';
import LedConfigDialog from './LedConfigDialog.svelte';
interface Props {
holdDurationMs: number;
override: boolean;
relayPin: number;
enableSerialSensor: boolean;
sensorRxPin: number;
sensorTxPin: number;
onOpenLed?: LedConfig;
defaultLed?: LedConfig;
onInvalidLed?: LedConfig;
onOverrideOpenLed?: LedConfig;
onInterceptionLed?: LedConfig;
}
let {
holdDurationMs = $bindable(5000),
override = $bindable(false),
relayPin = $bindable(12),
enableSerialSensor = $bindable(true),
sensorRxPin = $bindable(16),
sensorTxPin = $bindable(17),
onOpenLed = $bindable(),
defaultLed = $bindable(),
onInvalidLed = $bindable(),
onOverrideOpenLed = $bindable(),
onInterceptionLed = $bindable()
}: Props = $props();
// Dialog state
let dialogOpen = $state(false);
let currentLedType = $state<
'onOpen' | 'default' | 'onInvalid' | 'onOverrideOpen' | 'onInterception'
>('onOpen');
let currentLedConfig = $state<LedConfig | undefined>();
function openDialog(
type: 'onOpen' | 'default' | 'onInvalid' | 'onOverrideOpen' | 'onInterception'
) {
currentLedType = type;
currentLedConfig =
type === 'onOpen'
? onOpenLed
: type === 'default'
? defaultLed
: type === 'onInvalid'
? onInvalidLed
: type === 'onOverrideOpen'
? onOverrideOpenLed
: onInterceptionLed;
dialogOpen = true;
}
function saveLedConfig(config: LedConfig) {
if (currentLedType === 'onOpen') {
onOpenLed = config;
} else if (currentLedType === 'default') {
defaultLed = config;
} else if (currentLedType === 'onInvalid') {
onInvalidLed = config;
} else if (currentLedType === 'onOverrideOpen') {
onOverrideOpenLed = config;
} else {
onInterceptionLed = config;
}
dialogOpen = false;
}
function removeLedConfig(
type: 'onOpen' | 'default' | 'onInvalid' | 'onOverrideOpen' | 'onInterception'
) {
if (type === 'onOpen') {
onOpenLed = undefined;
} else if (type === 'default') {
defaultLed = undefined;
} else if (type === 'onInvalid') {
onInvalidLed = undefined;
} else if (type === 'onOverrideOpen') {
onOverrideOpenLed = undefined;
} else {
onInterceptionLed = undefined;
}
}
// Helper to get animation name
function getAnimationName(animation: string): string {
switch (animation) {
case 'static':
return 'Static';
case 'pulse':
return 'Pulse';
case 'fade':
return 'Fade';
case 'flicker':
return 'Flicker';
default:
return 'Unknown';
}
}
// Helper to get current animation
function getCurrentAnimation(config?: LedConfig): string {
if (!config) return 'static';
if (config.staticParams) return 'static';
if (config.pulseParams) return 'pulse';
if (config.fadeParams) return 'fade';
if (config.flickerParams) return 'flicker';
return 'static';
}
</script>
<div class="space-y-8">
<!-- Door Relay Section -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<span>🔒</span>
Door Relay
</CardTitle>
<CardDescription>
Configure the door lock mechanism. The relay controls the door opening duration and which
GPIO pin is used.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<Label for="hold-duration">Hold Duration (ms)</Label>
<Input id="hold-duration" type="number" bind:value={holdDurationMs} placeholder="5000" />
<p class="mt-1 text-xs text-gray-500">
How long the door stays unlocked after valid RFID scan
</p>
</div>
<div>
<Label for="relay-pin">Relay Pin</Label>
<Input id="relay-pin" type="number" bind:value={relayPin} placeholder="12" />
<p class="mt-1 text-xs text-gray-500">GPIO pin connected to the door relay</p>
</div>
</div>
</CardContent>
</Card>
<!-- Sensor Unit Section -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<span>📡</span>
Sensor Unit (RFID Reader)
</CardTitle>
<CardDescription>
The RFID reader unit with integrated RGB LED. Communication happens via serial connection
(UART). The reader scans RFID cards and communicates card data to the main controller.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex items-center space-x-2">
<Switch id="enable-serial-sensor" bind:checked={enableSerialSensor} />
<Label for="enable-serial-sensor">Enable Serial Sensor</Label>
</div>
<p class="mt-2 text-xs text-gray-500">Enable or disable the serial sensor unit.</p>
{#if enableSerialSensor}
<div class="grid grid-cols-2 gap-4">
<div>
<Label for="sensor-rx">Sensor RX Pin</Label>
<Input id="sensor-rx" type="number" bind:value={sensorRxPin} placeholder="16" />
<p class="mt-1 text-xs text-gray-500">
UART RX pin for receiving data from RFID reader
</p>
</div>
<div>
<Label for="sensor-tx">Sensor TX Pin</Label>
<Input id="sensor-tx" type="number" bind:value={sensorTxPin} placeholder="17" />
<p class="mt-1 text-xs text-gray-500">
UART TX pin for sending commands to RFID reader
</p>
</div>
</div>
{/if}
</CardContent>
</Card>
<!-- Permanent Unlock Section -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<span>🔓</span>
Permanent Unlock
</CardTitle>
<CardDescription>
Override mode allows keeping the door permanently unlocked. Use with caution - this bypasses
all access control.
</CardDescription>
</CardHeader>
<CardContent>
<div class="flex items-center space-x-2">
<Switch id="override" bind:checked={override} />
<Label for="override">Override Mode</Label>
</div>
<p class="mt-2 text-xs text-gray-500">
When enabled, the door relay stays activated permanently, ignoring RFID access control.
</p>
</CardContent>
</Card>
<!-- LED Configuration Section -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<span>💡</span>
LED Configuration
</CardTitle>
<CardDescription>
Configure RGB LED animations for different access states. Each LED can have custom colors,
brightness, and animation patterns.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<!-- On Open LED -->
<div class="flex items-center justify-between rounded border p-4">
<div>
<p class="font-medium">On Open LED</p>
<p class="text-sm text-gray-600">
{onOpenLed
? `Animation: ${getAnimationName(getCurrentAnimation(onOpenLed))}`
: 'Not configured'}
</p>
<p class="text-xs text-gray-500">Shows when door is unlocked after valid RFID scan</p>
</div>
<div class="flex gap-2">
<Button size="sm" onclick={() => openDialog('onOpen')}>
{onOpenLed ? 'Update' : 'Set'}
</Button>
{#if onOpenLed}
<Button size="sm" variant="destructive" onclick={() => removeLedConfig('onOpen')}>
Remove
</Button>
{/if}
</div>
</div>
<!-- Default LED -->
<div class="flex items-center justify-between rounded border p-4">
<div>
<p class="font-medium">Default LED</p>
<p class="text-sm text-gray-600">
{defaultLed
? `Animation: ${getAnimationName(getCurrentAnimation(defaultLed))}`
: 'Not configured'}
</p>
<p class="text-xs text-gray-500">Default idle state when system is ready</p>
</div>
<div class="flex gap-2">
<Button size="sm" onclick={() => openDialog('default')}>
{defaultLed ? 'Update' : 'Set'}
</Button>
{#if defaultLed}
<Button size="sm" variant="destructive" onclick={() => removeLedConfig('default')}>
Remove
</Button>
{/if}
</div>
</div>
<!-- On Invalid LED -->
<div class="flex items-center justify-between rounded border p-4">
<div>
<p class="font-medium">On Invalid LED</p>
<p class="text-sm text-gray-600">
{onInvalidLed
? `Animation: ${getAnimationName(getCurrentAnimation(onInvalidLed))}`
: 'Not configured'}
</p>
<p class="text-xs text-gray-500">Shows when RFID scan is rejected</p>
</div>
<div class="flex gap-2">
<Button size="sm" onclick={() => openDialog('onInvalid')}>
{onInvalidLed ? 'Update' : 'Set'}
</Button>
{#if onInvalidLed}
<Button size="sm" variant="destructive" onclick={() => removeLedConfig('onInvalid')}>
Remove
</Button>
{/if}
</div>
</div>
<!-- On Override Open LED -->
<div class="flex items-center justify-between rounded border p-4">
<div>
<p class="font-medium">On Override Open LED</p>
<p class="text-sm text-gray-600">
{onOverrideOpenLed
? `Animation: ${getAnimationName(getCurrentAnimation(onOverrideOpenLed))}`
: 'Not configured'}
</p>
<p class="text-xs text-gray-500">Shows when door is in permanent unlock mode</p>
</div>
<div class="flex gap-2">
<Button size="sm" onclick={() => openDialog('onOverrideOpen')}>
{onOverrideOpenLed ? 'Update' : 'Set'}
</Button>
{#if onOverrideOpenLed}
<Button
size="sm"
variant="destructive"
onclick={() => removeLedConfig('onOverrideOpen')}
>
Remove
</Button>
{/if}
</div>
</div>
<!-- On Interception LED -->
<div class="flex items-center justify-between rounded border p-4">
<div>
<p class="font-medium">On Interception LED</p>
<p class="text-sm text-gray-600">
{onInterceptionLed
? `Animation: ${getAnimationName(getCurrentAnimation(onInterceptionLed))}`
: 'Not configured'}
</p>
<p class="text-xs text-gray-500">Shows when RFID card is read to the ui</p>
</div>
<div class="flex gap-2">
<Button size="sm" onclick={() => openDialog('onInterception')}>
{onInterceptionLed ? 'Update' : 'Set'}
</Button>
{#if onInterceptionLed}
<Button
size="sm"
variant="destructive"
onclick={() => removeLedConfig('onInterception')}
>
Remove
</Button>
{/if}
</div>
</div>
</CardContent>
</Card>
</div>
<!-- LED Config Dialog -->
<LedConfigDialog
bind:open={dialogOpen}
ledType={currentLedType}
config={currentLedConfig || {
brightness: 255,
durationMs: 0,
staticParams: { color: 0xffffff }
}}
onSave={saveLedConfig}
onCancel={() => (dialogOpen = false)}
/>
@@ -0,0 +1,271 @@
<script lang="ts">
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle
} from '$lib/components/ui/alert-dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Button } from '$lib/components/ui/button';
import ColorPicker from '$lib/components/ui/color-picker.svelte';
import { LedConfig } from '$lib/gen/hardware';
interface Props {
open: boolean;
ledType: 'onOpen' | 'default' | 'onInvalid' | 'onOverrideOpen' | 'onInterception';
config: LedConfig;
onSave: (config: LedConfig) => void;
onCancel: () => void;
}
let {
open = $bindable(false),
ledType,
config = $bindable(),
onSave,
onCancel
}: Props = $props();
// Temp config for editing
let tempConfig = $state<LedConfig>({ ...config });
let selectedAnimation = $state('static');
// Color bindings for inputs
let staticColor = $state('#ffffff');
let pulseColor = $state('#ffffff');
let flickerColor = $state('#ffffff');
let fadeColors = $state(['#ffffff', '#000000']);
// Initialize colors when config changes
$effect(() => {
if (config.staticParams) {
staticColor = numberToHexColor(config.staticParams.color);
}
if (config.pulseParams) {
pulseColor = numberToHexColor(config.pulseParams.color);
}
if (config.flickerParams) {
flickerColor = numberToHexColor(config.flickerParams.color);
}
if (config.fadeParams) {
fadeColors = config.fadeParams.colors.map(numberToHexColor);
}
selectedAnimation = getCurrentAnimation(config);
tempConfig = { ...config };
});
function getCurrentAnimation(config?: LedConfig): string {
if (!config) return 'static';
if (config.staticParams) return 'static';
if (config.pulseParams) return 'pulse';
if (config.fadeParams) return 'fade';
if (config.flickerParams) return 'flicker';
return 'static';
}
function updateAnimation(event: Event) {
const target = event.target as HTMLSelectElement;
const animation = target.value;
selectedAnimation = animation;
// Clear all animation params
tempConfig.staticParams = undefined;
tempConfig.pulseParams = undefined;
tempConfig.fadeParams = undefined;
tempConfig.flickerParams = undefined;
// Set the selected animation params
if (animation === 'static') {
tempConfig.staticParams = { color: 0xffffff };
staticColor = numberToHexColor(0xffffff);
} else if (animation === 'pulse') {
tempConfig.pulseParams = { color: 0xffffff, speedMs: 500 };
pulseColor = numberToHexColor(0xffffff);
} else if (animation === 'fade') {
tempConfig.fadeParams = { colors: [0xffffff, 0x000000], speedMs: 1000 };
fadeColors = ['#ffffff', '#000000'];
} else if (animation === 'flicker') {
tempConfig.flickerParams = { color: 0xffffff, intensity: 50 };
flickerColor = numberToHexColor(0xffffff);
}
}
function updateStaticColor() {
if (tempConfig.staticParams) {
tempConfig.staticParams.color = hexColorToNumber(staticColor);
}
}
function updatePulseColor() {
if (tempConfig.pulseParams) {
tempConfig.pulseParams.color = hexColorToNumber(pulseColor);
}
}
function updateFlickerColor() {
if (tempConfig.flickerParams) {
tempConfig.flickerParams.color = hexColorToNumber(flickerColor);
}
}
function updateFadeColor(index: number) {
if (tempConfig.fadeParams) {
tempConfig.fadeParams.colors[index] = hexColorToNumber(fadeColors[index]);
}
}
// Helper functions for color conversion
function numberToHexColor(num: number): string {
return '#' + num.toString(16).padStart(6, '0');
}
function hexColorToNumber(hex: string): number {
return parseInt(hex.substring(1), 16);
}
function handleSave() {
onSave(tempConfig);
}
</script>
<AlertDialog bind:open>
<AlertDialogContent class="max-w-md">
<AlertDialogHeader>
<AlertDialogTitle>Configure {ledType} LED</AlertDialogTitle>
<AlertDialogDescription>Set the LED animation and parameters.</AlertDialogDescription>
</AlertDialogHeader>
<div class="space-y-4">
<div>
<Label for="brightness">Brightness (0-255)</Label>
<Input id="brightness" type="number" min="0" max="255" bind:value={tempConfig.brightness} />
</div>
<div>
<Label for="duration">Duration (ms, 0 for indefinite)</Label>
<Input id="duration" type="number" min="0" bind:value={tempConfig.durationMs} />
</div>
<div>
<Label for="animation">Animation</Label>
<select bind:value={selectedAnimation} onchange={updateAnimation}>
<option value="static">Static</option>
<option value="pulse">Pulse</option>
<option value="fade">Fade</option>
<option value="flicker">Flicker</option>
</select>
</div>
<!-- Animation-specific options -->
{#if tempConfig.staticParams}
<div>
<Label>Color</Label>
<ColorPicker
value={staticColor}
onChange={(color) => {
staticColor = color;
updateStaticColor();
}}
/>
</div>
{:else if tempConfig.pulseParams}
<div class="space-y-2">
<div>
<Label>Color</Label>
<ColorPicker
value={pulseColor}
onChange={(color) => {
pulseColor = color;
updatePulseColor();
}}
/>
</div>
<div>
<Label for="pulse-speed">Speed (ms)</Label>
<Input
id="pulse-speed"
type="number"
min="100"
bind:value={tempConfig.pulseParams.speedMs}
/>
</div>
</div>
{:else if tempConfig.fadeParams}
<div class="space-y-2">
<div>
<Label>Colors</Label>
{#each fadeColors as color, i}
<div class="flex items-center gap-2">
<ColorPicker
value={color}
onChange={(newColor) => {
fadeColors[i] = newColor;
updateFadeColor(i);
}}
class="flex-1"
/>
<Button
size="sm"
variant="destructive"
onclick={() => {
fadeColors.splice(i, 1);
tempConfig.fadeParams?.colors.splice(i, 1);
}}
>
Remove
</Button>
</div>
{/each}
<Button
size="sm"
onclick={() => {
fadeColors.push('#ffffff');
tempConfig.fadeParams?.colors.push(0xffffff);
}}
>
Add Color
</Button>
</div>
<div>
<Label for="fade-speed">Speed (ms)</Label>
<Input
id="fade-speed"
type="number"
min="100"
bind:value={tempConfig.fadeParams.speedMs}
/>
</div>
</div>
{:else if tempConfig.flickerParams}
<div class="space-y-2">
<div>
<Label>Color</Label>
<ColorPicker
value={flickerColor}
onChange={(color) => {
flickerColor = color;
updateFlickerColor();
}}
/>
</div>
<div>
<Label for="flicker-intensity">Intensity (0-100)</Label>
<Input
id="flicker-intensity"
type="number"
min="0"
max="100"
bind:value={tempConfig.flickerParams.intensity}
/>
</div>
</div>
{/if}
</div>
<div class="mt-4 flex justify-end gap-2">
<Button variant="outline" onclick={onCancel}>Cancel</Button>
<Button onclick={handleSave}>Save</Button>
</div>
</AlertDialogContent>
</AlertDialog>
@@ -0,0 +1,83 @@
<script lang="ts">
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import { Separator } from '$lib/components/ui/separator';
interface Props {
syncServerUrl: string;
deviceApiKey: string;
syncInterval: number;
autoSync: boolean;
}
let {
syncServerUrl = $bindable(),
deviceApiKey = $bindable(),
syncInterval = $bindable(),
autoSync = $bindable()
}: Props = $props();
</script>
<Card>
<CardHeader>
<CardTitle>Sync Server Configuration</CardTitle>
<CardDescription>Configure the server URL and synchronization settings</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="sync-url">Server URL</Label>
<Input
id="sync-url"
type="url"
placeholder="https://sync.example.com/api"
bind:value={syncServerUrl}
/>
<p class="text-sm text-gray-500">The URL of the synchronization server for data exchange</p>
</div>
<div class="space-y-2">
<Label for="device-api-key">Device API Key</Label>
<Input
id="device-api-key"
type="text"
placeholder="Enter device API key"
bind:value={deviceApiKey}
/>
<p class="text-sm text-gray-500">API key for device authentication with the sync server</p>
</div>
<Separator />
<div class="space-y-4">
<div class="flex items-center space-x-2">
<Switch id="auto-sync" bind:checked={autoSync} />
<Label for="auto-sync">Enable synchronization</Label>
</div>
{#if autoSync}
<div class="space-y-2">
<Label for="sync-interval">Sync Interval (seconds)</Label>
<Input
id="sync-interval"
type="number"
min="5"
max="86400"
placeholder="1800"
bind:value={syncInterval}
/>
<p class="text-sm text-gray-500">
How often to synchronize data with the server (5-86400 seconds)
</p>
</div>
{/if}
</div>
</CardContent>
</Card>
@@ -0,0 +1,129 @@
<script lang="ts">
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import { WifiMode } from '$lib/gen/settings';
interface Props {
wifiMode: WifiMode;
stationSsid: string;
stationPassword: string;
apSsid: string;
apPassword: string;
apChannel: number;
enableFallbackAp: boolean;
}
let {
wifiMode = $bindable(),
stationSsid = $bindable(),
stationPassword = $bindable(),
apSsid = $bindable(),
apPassword = $bindable(),
apChannel = $bindable(),
enableFallbackAp = $bindable()
}: Props = $props();
</script>
<Card>
<CardHeader>
<CardTitle>WiFi Settings</CardTitle>
<CardDescription>Configure WiFi connection modes and access point settings</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<div class="space-y-4">
<div class="space-y-2">
<Label for="wifi-mode">WiFi Mode</Label>
<select
id="wifi-mode"
bind:value={wifiMode}
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value={WifiMode.WIFI_MODE_STATION}>Station Mode (Connect to existing WiFi)</option
>
<option value={WifiMode.WIFI_MODE_AP}>Access Point Mode (Create WiFi network)</option>
<option value={WifiMode.WIFI_MODE_AP_STATION}>AP + Station Mode (Both)</option>
</select>
<p class="text-sm text-gray-500">
Choose how the device connects to and provides WiFi networks
</p>
</div>
{#if [WifiMode.WIFI_MODE_STATION, WifiMode.WIFI_MODE_AP_STATION].includes(wifiMode)}
<div class="border-t pt-4">
<h4 class="mb-3 text-sm font-medium">Station Settings (Connect to WiFi)</h4>
<div class="space-y-3">
<div class="space-y-2">
<Label for="station-ssid">Network Name (SSID)</Label>
<Input id="station-ssid" placeholder="MyHomeNetwork" bind:value={stationSsid} />
</div>
<div class="space-y-2">
<Label for="station-password">Password</Label>
<Input
id="station-password"
type="password"
placeholder="Enter WiFi password"
bind:value={stationPassword}
/>
</div>
</div>
</div>
{/if}
{#if [WifiMode.WIFI_MODE_STATION, WifiMode.WIFI_MODE_AP, WifiMode.WIFI_MODE_AP_STATION].includes(wifiMode)}
<div class="border-t pt-4">
{#if wifiMode === WifiMode.WIFI_MODE_STATION}
<div class="mb-4 flex items-center justify-between">
<div class="flex items-center space-x-2">
<Switch id="fallback-ap" bind:checked={enableFallbackAp} />
<Label for="fallback-ap">Enable fallback access point</Label>
</div>
<p class="text-sm text-gray-500">
If station connection fails, create AP for configuration
</p>
</div>
{/if}
<h4 class="mb-3 text-sm font-medium">Access Point Settings (Create WiFi Network)</h4>
{#if [WifiMode.WIFI_MODE_STATION, WifiMode.WIFI_MODE_AP_STATION].includes(wifiMode) || (wifiMode === WifiMode.WIFI_MODE_STATION && enableFallbackAp)}
<div class="space-y-3">
<div class="space-y-2">
<Label for="ap-ssid">Network Name (SSID)</Label>
<Input id="ap-ssid" placeholder="RFID-Master-AP" bind:value={apSsid} />
</div>
<div class="space-y-2">
<Label for="ap-password">Password</Label>
<Input
id="ap-password"
type="password"
placeholder="rfid12345"
bind:value={apPassword}
/>
<p class="text-sm text-gray-500">Password must be 8-63 characters long</p>
</div>
<div class="space-y-2">
<Label for="ap-channel">Channel</Label>
<select
id="ap-channel"
typeof="number"
bind:value={apChannel}
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{#each Array.from({ length: 13 }, (_, i) => i + 1) as channel}
<option value={channel}>{channel}</option>
{/each}
</select>
</div>
</div>
{/if}
</div>
{/if}
</div>
</CardContent>
</Card>
@@ -0,0 +1,18 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.ActionProps = $props();
</script>
<AlertDialogPrimitive.Action
bind:ref
data-slot="alert-dialog-action"
class={cn(buttonVariants(), className)}
{...restProps}
/>
@@ -0,0 +1,18 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.CancelProps = $props();
</script>
<AlertDialogPrimitive.Cancel
bind:ref
data-slot="alert-dialog-cancel"
class={cn(buttonVariants({ variant: "outline" }), className)}
{...restProps}
/>
@@ -0,0 +1,27 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import AlertDialogOverlay from "./alert-dialog-overlay.svelte";
import { cn, type WithoutChild, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
portalProps,
...restProps
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<AlertDialogPrimitive.PortalProps>;
} = $props();
</script>
<AlertDialogPrimitive.Portal {...portalProps}>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
bind:ref
data-slot="alert-dialog-content"
class={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...restProps}
/>
</AlertDialogPrimitive.Portal>
@@ -0,0 +1,17 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.DescriptionProps = $props();
</script>
<AlertDialogPrimitive.Description
bind:ref
data-slot="alert-dialog-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-footer"
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-header"
class={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.OverlayProps = $props();
</script>
<AlertDialogPrimitive.Overlay
bind:ref
data-slot="alert-dialog-overlay"
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...restProps}
/>
@@ -0,0 +1,17 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.TitleProps = $props();
</script>
<AlertDialogPrimitive.Title
bind:ref
data-slot="alert-dialog-title"
class={cn("text-lg font-semibold", className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: AlertDialogPrimitive.TriggerProps = $props();
</script>
<AlertDialogPrimitive.Trigger bind:ref data-slot="alert-dialog-trigger" {...restProps} />
@@ -0,0 +1,39 @@
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import Trigger from "./alert-dialog-trigger.svelte";
import Title from "./alert-dialog-title.svelte";
import Action from "./alert-dialog-action.svelte";
import Cancel from "./alert-dialog-cancel.svelte";
import Footer from "./alert-dialog-footer.svelte";
import Header from "./alert-dialog-header.svelte";
import Overlay from "./alert-dialog-overlay.svelte";
import Content from "./alert-dialog-content.svelte";
import Description from "./alert-dialog-description.svelte";
const Root = AlertDialogPrimitive.Root;
const Portal = AlertDialogPrimitive.Portal;
export {
Root,
Title,
Action,
Cancel,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
//
Root as AlertDialog,
Title as AlertDialogTitle,
Action as AlertDialogAction,
Cancel as AlertDialogCancel,
Portal as AlertDialogPortal,
Footer as AlertDialogFooter,
Header as AlertDialogHeader,
Trigger as AlertDialogTrigger,
Overlay as AlertDialogOverlay,
Content as AlertDialogContent,
Description as AlertDialogDescription,
};
@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-description"
class={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...restProps}
>
{@render children?.()}
</div>

Some files were not shown because too many files have changed in this diff Show More