init
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
.pio
|
||||
.vscode/.browse.c_cpp.db*
|
||||
.vscode/c_cpp_properties.json
|
||||
.vscode/launch.json
|
||||
.vscode/ipch
|
||||
Vendored
+10
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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.
@@ -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
@@ -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
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "hardware",
|
||||
"dependencies": {
|
||||
"nanopb": "*"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 = ⌖
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(×tamp);
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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__); }
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "ota",
|
||||
"dependencies": {
|
||||
"hardware": "*"
|
||||
}
|
||||
}
|
||||
+445
@@ -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*>(×tamp), 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*>(×tamp), 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
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
#include "tft.hpp"
|
||||
|
||||
TFT tft;
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
@@ -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,
|
||||
|
@@ -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,
|
||||
|
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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_;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
@@ -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-*
|
||||
@@ -0,0 +1,9 @@
|
||||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
bun.lock
|
||||
bun.lockb
|
||||
|
||||
# Miscellaneous
|
||||
/static/
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
Generated
+5398
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+13
@@ -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 {};
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export const BASE_URL = window.location.origin;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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
Reference in New Issue
Block a user