Fist commit
This commit is contained in:
16
.gitignore
vendored
Executable file
16
.gitignore
vendored
Executable file
@@ -0,0 +1,16 @@
|
||||
.pio/
|
||||
.vscode/.browse.c_cpp.db*
|
||||
.vscode/c_cpp_properties.json
|
||||
.vscode/launch.json
|
||||
.vscode/ipch/
|
||||
src/generated/
|
||||
ui/node_modules/
|
||||
ui/dist/
|
||||
.coverage/
|
||||
coverage/
|
||||
*.gcda
|
||||
*.gcno
|
||||
*.log
|
||||
.env
|
||||
compile_commands.json
|
||||
backups/
|
||||
11
.vscode/extensions.json
vendored
Executable file
11
.vscode/extensions.json
vendored
Executable file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
// 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",
|
||||
"pioarduino.pioarduino-ide"
|
||||
]
|
||||
}
|
||||
68
docs/BACKUP.md
Normal file
68
docs/BACKUP.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Copia de seguridad del ESP32
|
||||
|
||||
`scripts/backup_device.py` realiza una copia de solo lectura de las regiones críticas del ESP32. No escribe en la flash y no ejecuta `erase_flash`.
|
||||
|
||||
## Copia recomendada
|
||||
|
||||
Cerrar el Serial Monitor y ejecutar:
|
||||
|
||||
```bash
|
||||
cd ~/Desktop/TimbreESPv2
|
||||
python3 scripts/backup_device.py --scope data
|
||||
```
|
||||
|
||||
El script autodetecta el puerto USB y usa 115200 baudios, que es más seguro para cables CH340 y hubs USB.
|
||||
|
||||
Para incluir también la imagen completa de 4 MiB:
|
||||
|
||||
```bash
|
||||
python3 scripts/backup_device.py --scope full
|
||||
```
|
||||
|
||||
La copia completa puede fallar en algunos cables CH340. Si falla, usar la copia de datos:
|
||||
|
||||
```bash
|
||||
python3 scripts/backup_device.py --scope data --baud 115200
|
||||
```
|
||||
|
||||
## Regiones guardadas
|
||||
|
||||
| Archivo | Offset | Tamaño |
|
||||
|---|---:|---:|
|
||||
| `bootloader.bin` | `0x01000` | 32 KiB |
|
||||
| `partitions.bin` | `0x08000` | 4 KiB |
|
||||
| `nvs.bin` | `0x09000` | 20 KiB |
|
||||
| `otadata.bin` | `0x0E000` | 8 KiB |
|
||||
| `littlefs.bin` | `0x350000` | 704 KiB |
|
||||
|
||||
En el modo `full` también se guardan `app0.bin`, `app1.bin` y `flash-full.bin`.
|
||||
|
||||
## Archivos generados
|
||||
|
||||
Cada copia incluye:
|
||||
|
||||
- `manifest.json`: fecha, puerto, baudios, tamaños y hashes SHA-256;
|
||||
- `partitions.csv`: tabla de particiones decodificada;
|
||||
- `device-info.txt`: identificación del chip y flash;
|
||||
- `README.txt`: advertencia de restauración;
|
||||
- `logs/`: salida de `esptool`.
|
||||
|
||||
El directorio predeterminado es:
|
||||
|
||||
```text
|
||||
~/Documents/TimbreESPv2-backups/backup-YYYYMMDD-HHMMSS/
|
||||
```
|
||||
|
||||
Los archivos se crean con permisos privados porque `nvs.bin` puede contener credenciales WiFi y autenticación.
|
||||
|
||||
## Restauración
|
||||
|
||||
No hay restauración automática. Antes de escribir cualquier imagen:
|
||||
|
||||
1. Comparar `partitions.csv` con la tabla esperada.
|
||||
2. Verificar el tamaño de flash del ESP32.
|
||||
3. Confirmar offsets y tamaños.
|
||||
4. Guardar otra copia antes de modificar la flash.
|
||||
5. No utilizar un directorio marcado con `INCOMPLETE.txt`.
|
||||
|
||||
La copia raw de LittleFS contiene el sistema de archivos completo; no son archivos individuales extraídos. Para consultar o migrar su contenido hay que montarlo en un entorno compatible o implementar una extracción posterior.
|
||||
37
include/README
Executable file
37
include/README
Executable file
@@ -0,0 +1,37 @@
|
||||
|
||||
This directory is intended for project header files.
|
||||
|
||||
A header file is a file containing C declarations and macro definitions
|
||||
to be shared between several project source files. You request the use of a
|
||||
header file in your project source file (C, C++, etc) located in `src` folder
|
||||
by including it, with the C preprocessing directive `#include'.
|
||||
|
||||
```src/main.c
|
||||
|
||||
#include "header.h"
|
||||
|
||||
int main (void)
|
||||
{
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Including a header file produces the same results as copying the header file
|
||||
into each source file that needs it. Such copying would be time-consuming
|
||||
and error-prone. With a header file, the related declarations appear
|
||||
in only one place. If they need to be changed, they can be changed in one
|
||||
place, and programs that include the header file will automatically use the
|
||||
new version when next recompiled. The header file eliminates the labor of
|
||||
finding and changing all the copies as well as the risk that a failure to
|
||||
find one copy will result in inconsistencies within a program.
|
||||
|
||||
In C, the convention is to give header files names that end with `.h'.
|
||||
|
||||
Read more about using header files in official GCC documentation:
|
||||
|
||||
* Include Syntax
|
||||
* Include Operation
|
||||
* Once-Only Headers
|
||||
* Computed Includes
|
||||
|
||||
https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html
|
||||
46
lib/README
Executable file
46
lib/README
Executable file
@@ -0,0 +1,46 @@
|
||||
|
||||
This directory is intended for project specific (private) libraries.
|
||||
PlatformIO will compile them to static libraries and link into the executable file.
|
||||
|
||||
The source code of each library should be placed in a separate directory
|
||||
("lib/your_library_name/[Code]").
|
||||
|
||||
For example, see the structure of the following example libraries `Foo` and `Bar`:
|
||||
|
||||
|--lib
|
||||
| |
|
||||
| |--Bar
|
||||
| | |--docs
|
||||
| | |--examples
|
||||
| | |--src
|
||||
| | |- Bar.c
|
||||
| | |- Bar.h
|
||||
| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html
|
||||
| |
|
||||
| |--Foo
|
||||
| | |- Foo.c
|
||||
| | |- Foo.h
|
||||
| |
|
||||
| |- README --> THIS FILE
|
||||
|
|
||||
|- platformio.ini
|
||||
|--src
|
||||
|- main.c
|
||||
|
||||
Example contents of `src/main.c` using Foo and Bar:
|
||||
```
|
||||
#include <Foo.h>
|
||||
#include <Bar.h>
|
||||
|
||||
int main (void)
|
||||
{
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
The PlatformIO Library Dependency Finder will find automatically dependent
|
||||
libraries by scanning project source files.
|
||||
|
||||
More information about PlatformIO Library Dependency Finder
|
||||
- https://docs.platformio.org/page/librarymanager/ldf.html
|
||||
198
lib/timbre_core/CivilTime.cpp
Executable file
198
lib/timbre_core/CivilTime.cpp
Executable file
@@ -0,0 +1,198 @@
|
||||
#include "CivilTime.h"
|
||||
|
||||
#include <limits>
|
||||
|
||||
namespace timbre_core {
|
||||
namespace {
|
||||
|
||||
constexpr std::int64_t kSecondsPerDay = 86400;
|
||||
|
||||
std::int64_t floorDiv(std::int64_t value, std::int64_t divisor) noexcept {
|
||||
const std::int64_t quotient = value / divisor;
|
||||
const std::int64_t remainder = value % divisor;
|
||||
return remainder != 0 && ((remainder < 0) != (divisor < 0)) ? quotient - 1 : quotient;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool CivilTime::isLeapYear(std::int32_t year) noexcept {
|
||||
return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
|
||||
}
|
||||
|
||||
std::uint8_t CivilTime::daysInMonth(std::int32_t year, std::uint8_t month) noexcept {
|
||||
static constexpr std::uint8_t days[] = {
|
||||
0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31
|
||||
};
|
||||
if (month < 1 || month > 12) {
|
||||
return 0;
|
||||
}
|
||||
if (month == 2 && isLeapYear(year)) {
|
||||
return 29;
|
||||
}
|
||||
return days[month];
|
||||
}
|
||||
|
||||
bool CivilTime::isValidDate(std::int32_t year, std::uint8_t month, std::uint8_t day) noexcept {
|
||||
// The conversion routines are useful well beyond the current product
|
||||
// lifetime, but keep the public civil value inside int32_t.
|
||||
if (year < std::numeric_limits<std::int32_t>::min() ||
|
||||
year > std::numeric_limits<std::int32_t>::max()) {
|
||||
return false;
|
||||
}
|
||||
return day >= 1 && day <= daysInMonth(year, month);
|
||||
}
|
||||
|
||||
std::uint8_t CivilTime::weekdayForDate(std::int32_t year, std::uint8_t month,
|
||||
std::uint8_t day) noexcept {
|
||||
if (!isValidDate(year, month, day)) {
|
||||
return 0;
|
||||
}
|
||||
// 1970-01-01 was Thursday (4 with Sunday == 0).
|
||||
const std::int64_t days = daysFromCivil(year, month, day);
|
||||
std::int64_t weekday = (days + 4) % 7;
|
||||
if (weekday < 0) {
|
||||
weekday += 7;
|
||||
}
|
||||
return static_cast<std::uint8_t>(weekday);
|
||||
}
|
||||
|
||||
std::int64_t CivilTime::daysFromCivil(std::int32_t year, std::uint8_t month,
|
||||
std::uint8_t day) noexcept {
|
||||
// Howard Hinnant's civil calendar transform. It is valid for the full
|
||||
// practical range of the public model and does not depend on time_t.
|
||||
std::int64_t y = year;
|
||||
y -= month <= 2 ? 1 : 0;
|
||||
const std::int64_t era = (y >= 0 ? y : y - 399) / 400;
|
||||
const std::int64_t yearOfEra = y - era * 400;
|
||||
const std::int64_t monthPrime = month + (month > 2 ? -3 : 9);
|
||||
const std::int64_t dayOfYear = (153 * monthPrime + 2) / 5 + day - 1;
|
||||
const std::int64_t dayOfEra = yearOfEra * 365 + yearOfEra / 4 - yearOfEra / 100 + dayOfYear;
|
||||
return era * 146097 + dayOfEra - 719468;
|
||||
}
|
||||
|
||||
bool CivilTime::civilFromDays(std::int64_t days, std::int32_t& year,
|
||||
std::uint8_t& month, std::uint8_t& day) noexcept {
|
||||
// The inverse transform is kept separate so weekday/date validation and
|
||||
// the scheduler can use exactly the same arithmetic.
|
||||
days += 719468;
|
||||
const std::int64_t era = (days >= 0 ? days : days - 146096) / 146097;
|
||||
const std::int64_t dayOfEra = days - era * 146097;
|
||||
const std::int64_t yearOfEra =
|
||||
(dayOfEra - dayOfEra / 1460 + dayOfEra / 36524 - dayOfEra / 146096) / 365;
|
||||
std::int64_t y = yearOfEra + era * 400;
|
||||
const std::int64_t dayOfYear =
|
||||
dayOfEra - (365 * yearOfEra + yearOfEra / 4 - yearOfEra / 100);
|
||||
const std::int64_t monthPrime =
|
||||
(5 * dayOfYear + 2) / 153;
|
||||
const std::int64_t d = dayOfYear - (153 * monthPrime + 2) / 5 + 1;
|
||||
const std::int64_t m = monthPrime + (monthPrime < 10 ? 3 : -9);
|
||||
y += m <= 2;
|
||||
|
||||
if (y < std::numeric_limits<std::int32_t>::min() ||
|
||||
y > std::numeric_limits<std::int32_t>::max()) {
|
||||
return false;
|
||||
}
|
||||
year = static_cast<std::int32_t>(y);
|
||||
month = static_cast<std::uint8_t>(m);
|
||||
day = static_cast<std::uint8_t>(d);
|
||||
return true;
|
||||
}
|
||||
|
||||
CivilDateTime CivilTime::fromUnixSeconds(std::int64_t utcSeconds) noexcept {
|
||||
CivilDateTime result;
|
||||
(void)fromUnixSeconds(utcSeconds, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool CivilTime::fromUnixSeconds(std::int64_t utcSeconds, CivilDateTime& result) noexcept {
|
||||
// Use floor seconds so epochs before 1970 are not biased toward 1969.
|
||||
const std::int64_t localSeconds = utcSeconds + kUtcOffsetSeconds;
|
||||
const std::int64_t days = floorDiv(localSeconds, kSecondsPerDay);
|
||||
std::int64_t secondOfDay = localSeconds - days * kSecondsPerDay;
|
||||
if (secondOfDay < 0) {
|
||||
secondOfDay += kSecondsPerDay;
|
||||
}
|
||||
|
||||
std::int32_t year = 0;
|
||||
std::uint8_t month = 0;
|
||||
std::uint8_t day = 0;
|
||||
if (!civilFromDays(days, year, month, day)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
result.year = year;
|
||||
result.month = month;
|
||||
result.day = day;
|
||||
result.hour = static_cast<std::uint8_t>(secondOfDay / 3600);
|
||||
result.minute = static_cast<std::uint8_t>((secondOfDay / 60) % 60);
|
||||
result.second = static_cast<std::uint8_t>(secondOfDay % 60);
|
||||
result.weekday = weekdayForDate(year, month, day);
|
||||
return true;
|
||||
}
|
||||
|
||||
CivilDateTime CivilTime::fromUnixMilliseconds(std::int64_t utcMilliseconds) noexcept {
|
||||
const std::int64_t seconds = floorDiv(utcMilliseconds, 1000);
|
||||
return fromUnixSeconds(seconds);
|
||||
}
|
||||
|
||||
std::int64_t CivilTime::toUnixSeconds(const CivilDateTime& localTime) noexcept {
|
||||
std::int64_t result = 0;
|
||||
(void)toUnixSeconds(localTime, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool CivilTime::toUnixSeconds(const CivilDateTime& localTime, std::int64_t& result) noexcept {
|
||||
if (!localTime.valid()) {
|
||||
return false;
|
||||
}
|
||||
const std::int64_t localSeconds =
|
||||
CivilTime::daysFromCivil(localTime.year, localTime.month, localTime.day) * kSecondsPerDay +
|
||||
static_cast<std::int64_t>(localTime.hour) * 3600 +
|
||||
static_cast<std::int64_t>(localTime.minute) * 60 +
|
||||
static_cast<std::int64_t>(localTime.second);
|
||||
result = localSeconds - kUtcOffsetSeconds;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::int64_t CivilTime::toUnixMilliseconds(const CivilDateTime& localTime) noexcept {
|
||||
return toUnixSeconds(localTime) * 1000;
|
||||
}
|
||||
|
||||
bool CivilDateTime::valid() const noexcept {
|
||||
return CivilTime::isValidDate(year, month, day) && hour < 24 &&
|
||||
minute < 60 && second < 60;
|
||||
}
|
||||
|
||||
std::uint16_t CivilDateTime::minuteOfDay() const noexcept {
|
||||
return static_cast<std::uint16_t>(static_cast<std::uint16_t>(hour) * 60u + minute);
|
||||
}
|
||||
|
||||
std::int64_t CivilDateTime::dateKey() const noexcept {
|
||||
return static_cast<std::int64_t>(year) * 10000LL +
|
||||
static_cast<std::int64_t>(month) * 100LL + day;
|
||||
}
|
||||
|
||||
std::int64_t CivilDateTime::minuteSerial() const noexcept {
|
||||
return static_cast<std::int64_t>(CivilTime::daysFromCivil(year, month, day)) * 1440LL +
|
||||
minuteOfDay();
|
||||
}
|
||||
|
||||
bool CivilDateTime::operator==(const CivilDateTime& other) const noexcept {
|
||||
return year == other.year && month == other.month && day == other.day &&
|
||||
hour == other.hour && minute == other.minute && second == other.second;
|
||||
}
|
||||
|
||||
std::int64_t LocalMinuteKey::serial() const noexcept {
|
||||
const std::int64_t y = date / 10000LL;
|
||||
const std::int64_t m = (date / 100LL) % 100LL;
|
||||
const std::int64_t d = date % 100LL;
|
||||
if (y < std::numeric_limits<std::int32_t>::min() ||
|
||||
y > std::numeric_limits<std::int32_t>::max() || m < 1 || m > 12 || d < 1 || d > 31) {
|
||||
return std::numeric_limits<std::int64_t>::min();
|
||||
}
|
||||
return CivilTime::daysFromCivil(static_cast<std::int32_t>(y),
|
||||
static_cast<std::uint8_t>(m),
|
||||
static_cast<std::uint8_t>(d)) * 1440LL + minuteOfDay;
|
||||
}
|
||||
|
||||
} // namespace timbre_core
|
||||
95
lib/timbre_core/CivilTime.h
Executable file
95
lib/timbre_core/CivilTime.h
Executable file
@@ -0,0 +1,95 @@
|
||||
#pragma once
|
||||
|
||||
#include "Model.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace timbre_core {
|
||||
|
||||
struct CivilDateTime {
|
||||
std::int32_t year = 1970;
|
||||
std::uint8_t month = 1; // 1..12
|
||||
std::uint8_t day = 1; // 1..31
|
||||
std::uint8_t hour = 0; // 0..23
|
||||
std::uint8_t minute = 0; // 0..59
|
||||
std::uint8_t second = 0; // 0..59
|
||||
std::uint8_t weekday = 0; // 0 = Sunday, 6 = Saturday
|
||||
|
||||
bool valid() const noexcept;
|
||||
std::uint16_t minuteOfDay() const noexcept;
|
||||
std::int64_t dateKey() const noexcept;
|
||||
std::int64_t minuteSerial() const noexcept;
|
||||
bool operator==(const CivilDateTime& other) const noexcept;
|
||||
bool operator!=(const CivilDateTime& other) const noexcept { return !(*this == other); }
|
||||
};
|
||||
|
||||
using CivilDateTimePoint = CivilDateTime;
|
||||
|
||||
struct LocalMinuteKey {
|
||||
std::int64_t date = 0; // YYYYMMDD
|
||||
std::uint16_t minuteOfDay = 0;
|
||||
|
||||
static LocalMinuteKey from(const CivilDateTime& value) noexcept {
|
||||
return LocalMinuteKey{value.dateKey(), value.minuteOfDay()};
|
||||
}
|
||||
|
||||
std::int64_t serial() const noexcept;
|
||||
std::int64_t dateKey() const noexcept { return date; }
|
||||
std::uint16_t minute() const noexcept { return minuteOfDay; }
|
||||
bool operator==(const LocalMinuteKey& other) const noexcept {
|
||||
return date == other.date && minuteOfDay == other.minuteOfDay;
|
||||
}
|
||||
bool operator!=(const LocalMinuteKey& other) const noexcept { return !(*this == other); }
|
||||
bool operator<(const LocalMinuteKey& other) const noexcept { return serial() < other.serial(); }
|
||||
bool operator>(const LocalMinuteKey& other) const noexcept { return serial() > other.serial(); }
|
||||
bool operator<=(const LocalMinuteKey& other) const noexcept { return serial() <= other.serial(); }
|
||||
bool operator>=(const LocalMinuteKey& other) const noexcept { return serial() >= other.serial(); }
|
||||
};
|
||||
|
||||
class CivilTime {
|
||||
public:
|
||||
// The product deliberately has one fixed civil offset: UTC-06:00.
|
||||
static constexpr std::int32_t kUtcOffsetSeconds = -6 * 60 * 60;
|
||||
static constexpr std::int32_t kUtcOffsetMinutes = -6 * 60;
|
||||
static constexpr std::int32_t UTC_OFFSET_SECONDS = kUtcOffsetSeconds;
|
||||
static constexpr std::int32_t UTC_OFFSET_MINUTES = kUtcOffsetMinutes;
|
||||
|
||||
static bool isLeapYear(std::int32_t year) noexcept;
|
||||
static std::uint8_t daysInMonth(std::int32_t year, std::uint8_t month) noexcept;
|
||||
static bool isValidDate(std::int32_t year, std::uint8_t month, std::uint8_t day) noexcept;
|
||||
static std::uint8_t weekdayForDate(std::int32_t year, std::uint8_t month,
|
||||
std::uint8_t day) noexcept;
|
||||
|
||||
static CivilDateTime fromUnixSeconds(std::int64_t utcSeconds) noexcept;
|
||||
static bool fromUnixSeconds(std::int64_t utcSeconds, CivilDateTime& result) noexcept;
|
||||
static CivilDateTime fromUnix(std::int64_t utcSeconds) noexcept {
|
||||
return fromUnixSeconds(utcSeconds);
|
||||
}
|
||||
static bool fromUnix(std::int64_t utcSeconds, CivilDateTime& result) noexcept {
|
||||
return fromUnixSeconds(utcSeconds, result);
|
||||
}
|
||||
static CivilDateTime fromUtc(std::int64_t utcSeconds) noexcept {
|
||||
return fromUnixSeconds(utcSeconds);
|
||||
}
|
||||
static CivilDateTime utcToLocal(std::int64_t utcSeconds) noexcept {
|
||||
return fromUnixSeconds(utcSeconds);
|
||||
}
|
||||
|
||||
static CivilDateTime fromUnixMilliseconds(std::int64_t utcMilliseconds) noexcept;
|
||||
static std::int64_t toUnixSeconds(const CivilDateTime& localTime) noexcept;
|
||||
static bool toUnixSeconds(const CivilDateTime& localTime, std::int64_t& result) noexcept;
|
||||
static std::int64_t toUnixMilliseconds(const CivilDateTime& localTime) noexcept;
|
||||
|
||||
static LocalMinuteKey minuteKey(const CivilDateTime& localTime) noexcept {
|
||||
return LocalMinuteKey::from(localTime);
|
||||
}
|
||||
|
||||
// These helpers are public because they are useful to a scheduler and are
|
||||
// deterministic, allocation-free operations on an ESP32.
|
||||
static std::int64_t daysFromCivil(std::int32_t year, std::uint8_t month,
|
||||
std::uint8_t day) noexcept;
|
||||
static bool civilFromDays(std::int64_t days, std::int32_t& year,
|
||||
std::uint8_t& month, std::uint8_t& day) noexcept;
|
||||
};
|
||||
|
||||
} // namespace timbre_core
|
||||
603
lib/timbre_core/Model.h
Executable file
603
lib/timbre_core/Model.h
Executable file
@@ -0,0 +1,603 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <initializer_list>
|
||||
#include <utility>
|
||||
|
||||
namespace timbre_core {
|
||||
|
||||
// The data model deliberately uses bounded storage: it is safe for the ESP32
|
||||
// heap and never needs PSRAM or a dynamic container.
|
||||
constexpr std::size_t kMaxProfiles = 8;
|
||||
constexpr std::size_t kMaxPatterns = 3;
|
||||
constexpr std::size_t kMaxProfileOrPatternSlots = 8;
|
||||
constexpr std::size_t kMaxSchedules = 100;
|
||||
constexpr std::size_t kMaxHolidays = 64;
|
||||
constexpr std::size_t kPatternQueueCapacity = 8;
|
||||
constexpr std::size_t kPatternCount = kMaxPatterns;
|
||||
constexpr std::size_t kCanonicalPatternCount = kMaxPatterns;
|
||||
constexpr std::size_t kMaxConfiguredPatterns = 8;
|
||||
constexpr std::size_t kProfileLimit = kMaxProfiles;
|
||||
constexpr std::size_t kMaxPatternPhases = 6;
|
||||
constexpr std::uint8_t kMaxPhaseDurationSeconds = 99;
|
||||
constexpr std::uint16_t kMaxPatternTotalSeconds = 600;
|
||||
|
||||
// Upper-case aliases make the limits convenient for small configuration code.
|
||||
constexpr std::size_t MAX_PROFILES = kMaxProfiles;
|
||||
constexpr std::size_t MAX_PATTERNS = kMaxPatterns;
|
||||
constexpr std::size_t MAX_PROFILE_PATTERN_SLOTS = kMaxProfileOrPatternSlots;
|
||||
constexpr std::size_t MAX_SCHEDULES = kMaxSchedules;
|
||||
constexpr std::size_t MAX_HOLIDAYS = kMaxHolidays;
|
||||
constexpr std::size_t PATTERN_QUEUE_CAPACITY = kPatternQueueCapacity;
|
||||
constexpr std::uint8_t MAX_PHASE_DURATION = kMaxPhaseDurationSeconds;
|
||||
constexpr std::uint16_t MAX_PATTERN_TOTAL = kMaxPatternTotalSeconds;
|
||||
|
||||
using Id = std::uint16_t;
|
||||
using EntityId = Id;
|
||||
using ProfileId = Id;
|
||||
using PatternIdValue = Id;
|
||||
using ScheduleId = Id;
|
||||
using HolidayId = Id;
|
||||
using StableId = Id;
|
||||
constexpr Id kInvalidId = 0xFFFFu;
|
||||
|
||||
enum class PatternId : std::uint8_t {
|
||||
A = 0,
|
||||
B = 1,
|
||||
C = 2
|
||||
};
|
||||
|
||||
// RelayId is intentionally separate from PatternId. A/B/C are sound
|
||||
// patterns; they are never relay identifiers.
|
||||
enum class RelayId : std::uint8_t {
|
||||
Main = 0,
|
||||
Primary = 0,
|
||||
MainRelay = 0,
|
||||
Relay1 = 0,
|
||||
MainGPIO22 = 0,
|
||||
Reserved = 1,
|
||||
ReservedRelay = 1,
|
||||
Relay2 = 1,
|
||||
ReservedGPIO23 = 1,
|
||||
Invalid = 0xFF
|
||||
};
|
||||
|
||||
constexpr std::uint8_t kMainRelayGpio = 22;
|
||||
constexpr std::uint8_t kReservedRelayGpio = 23;
|
||||
constexpr std::uint8_t kMainRelayGPIO = kMainRelayGpio;
|
||||
constexpr std::uint8_t kReservedRelayGPIO = kReservedRelayGpio;
|
||||
constexpr std::uint8_t RELAY_MAIN_GPIO = kMainRelayGpio;
|
||||
constexpr std::uint8_t RELAY_RESERVED_GPIO = kReservedRelayGpio;
|
||||
constexpr std::uint8_t kRelayCount = 2;
|
||||
constexpr RelayId kPrimaryRelay = RelayId::Main;
|
||||
constexpr RelayId kReservedRelay = RelayId::Reserved;
|
||||
constexpr std::uint8_t kAllWeekdaysMask = 0x7F;
|
||||
constexpr std::uint8_t kSundayMask = 1u << 0;
|
||||
constexpr std::uint8_t kMondayMask = 1u << 1;
|
||||
constexpr std::uint8_t kTuesdayMask = 1u << 2;
|
||||
constexpr std::uint8_t kWednesdayMask = 1u << 3;
|
||||
constexpr std::uint8_t kThursdayMask = 1u << 4;
|
||||
constexpr std::uint8_t kFridayMask = 1u << 5;
|
||||
constexpr std::uint8_t kSaturdayMask = 1u << 6;
|
||||
|
||||
constexpr bool isPatternId(PatternId id) noexcept {
|
||||
return static_cast<std::uint8_t>(id) <= static_cast<std::uint8_t>(PatternId::C);
|
||||
}
|
||||
|
||||
constexpr std::size_t patternIndex(PatternId id) noexcept {
|
||||
return isPatternId(id) ? static_cast<std::size_t>(id) : kMaxPatterns;
|
||||
}
|
||||
|
||||
constexpr bool isRelayId(RelayId id) noexcept {
|
||||
return id == RelayId::Main || id == RelayId::Reserved;
|
||||
}
|
||||
|
||||
constexpr std::uint8_t gpioForRelay(RelayId id) noexcept {
|
||||
return id == RelayId::Main ? kMainRelayGpio
|
||||
: (id == RelayId::Reserved ? kReservedRelayGpio : 0xFFu);
|
||||
}
|
||||
|
||||
constexpr bool isReservedRelay(RelayId id) noexcept {
|
||||
return id == RelayId::Reserved;
|
||||
}
|
||||
|
||||
constexpr std::size_t relayIndex(RelayId id) noexcept {
|
||||
return id == RelayId::Main ? 0u
|
||||
: (id == RelayId::Reserved ? 1u : kRelayCount);
|
||||
}
|
||||
|
||||
constexpr RelayId relayIdFromIndex(std::size_t index) noexcept {
|
||||
return index == 0 ? RelayId::Main
|
||||
: (index == 1 ? RelayId::Reserved : RelayId::Invalid);
|
||||
}
|
||||
|
||||
constexpr RelayId relayIdFromGpio(std::uint8_t gpio) noexcept {
|
||||
return gpio == kMainRelayGpio ? RelayId::Main
|
||||
: (gpio == kReservedRelayGpio ? RelayId::Reserved : RelayId::Invalid);
|
||||
}
|
||||
|
||||
constexpr bool isKnownRelayGpio(std::uint8_t gpio) noexcept {
|
||||
return gpio == kMainRelayGpio || gpio == kReservedRelayGpio;
|
||||
}
|
||||
|
||||
constexpr std::uint8_t relayGpio(RelayId id) noexcept { return gpioForRelay(id); }
|
||||
|
||||
constexpr PatternId patternIdFromIndex(std::size_t index) noexcept {
|
||||
return index < kMaxPatterns ? static_cast<PatternId>(index) : PatternId::A;
|
||||
}
|
||||
|
||||
constexpr PatternId patternIdFromChar(char value) noexcept {
|
||||
return value == 'A' || value == 'a' ? PatternId::A
|
||||
: (value == 'B' || value == 'b' ? PatternId::B
|
||||
: (value == 'C' || value == 'c' ? PatternId::C : PatternId::A));
|
||||
}
|
||||
|
||||
constexpr char patternChar(PatternId id) noexcept {
|
||||
return id == PatternId::A ? 'A'
|
||||
: (id == PatternId::B ? 'B'
|
||||
: (id == PatternId::C ? 'C' : '?'));
|
||||
}
|
||||
|
||||
enum class Weekday : std::uint8_t {
|
||||
Sunday = 0,
|
||||
Monday = 1,
|
||||
Tuesday = 2,
|
||||
Wednesday = 3,
|
||||
Thursday = 4,
|
||||
Friday = 5,
|
||||
Saturday = 6
|
||||
};
|
||||
|
||||
template <std::size_t Capacity>
|
||||
class FixedText {
|
||||
public:
|
||||
FixedText() = default;
|
||||
|
||||
explicit FixedText(const char* value) { assign(value); }
|
||||
|
||||
FixedText& operator=(const char* value) {
|
||||
assign(value);
|
||||
return *this;
|
||||
}
|
||||
|
||||
void assign(const char* value) noexcept {
|
||||
std::size_t index = 0;
|
||||
if (value != nullptr) {
|
||||
while (value[index] != '\0' && index + 1 < Capacity) {
|
||||
data_[index] = value[index];
|
||||
++index;
|
||||
}
|
||||
}
|
||||
while (index < Capacity) {
|
||||
data_[index] = '\0';
|
||||
++index;
|
||||
}
|
||||
length_ = 0;
|
||||
while (length_ < Capacity && data_[length_] != '\0') {
|
||||
++length_;
|
||||
}
|
||||
}
|
||||
|
||||
const char* c_str() const noexcept { return data_.data(); }
|
||||
std::size_t size() const noexcept { return length_; }
|
||||
std::size_t length() const noexcept { return length_; }
|
||||
bool empty() const noexcept { return length_ == 0; }
|
||||
|
||||
char operator[](std::size_t index) const noexcept {
|
||||
return index < length_ ? data_[index] : '\0';
|
||||
}
|
||||
|
||||
bool operator==(const FixedText& other) const noexcept {
|
||||
if (length_ != other.length_) {
|
||||
return false;
|
||||
}
|
||||
for (std::size_t i = 0; i < length_; ++i) {
|
||||
if (data_[i] != other.data_[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool operator!=(const FixedText& other) const noexcept { return !(*this == other); }
|
||||
bool operator==(const char* other) const noexcept {
|
||||
if (other == nullptr) {
|
||||
return length_ == 0;
|
||||
}
|
||||
for (std::size_t i = 0; i < length_; ++i) {
|
||||
if (other[i] == '\0' || other[i] != data_[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return other[length_] == '\0';
|
||||
}
|
||||
bool operator!=(const char* other) const noexcept { return !(*this == other); }
|
||||
|
||||
private:
|
||||
std::array<char, Capacity> data_{};
|
||||
std::size_t length_ = 0;
|
||||
};
|
||||
|
||||
template <typename T, std::size_t Capacity>
|
||||
class FixedVector {
|
||||
public:
|
||||
using value_type = T;
|
||||
using iterator = T*;
|
||||
using const_iterator = const T*;
|
||||
|
||||
std::size_t size() const noexcept { return size_; }
|
||||
std::size_t capacity() const noexcept { return Capacity; }
|
||||
bool empty() const noexcept { return size_ == 0; }
|
||||
bool full() const noexcept { return size_ == Capacity; }
|
||||
void clear() noexcept { size_ = 0; }
|
||||
|
||||
bool push_back(const T& value) noexcept {
|
||||
if (full()) {
|
||||
return false;
|
||||
}
|
||||
data_[size_++] = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
template <typename... Args>
|
||||
bool emplace_back(Args&&... args) noexcept {
|
||||
if (full()) {
|
||||
return false;
|
||||
}
|
||||
data_[size_++] = T(std::forward<Args>(args)...);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool pop_back() noexcept {
|
||||
if (empty()) {
|
||||
return false;
|
||||
}
|
||||
--size_;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool erase(std::size_t index) noexcept {
|
||||
if (index >= size_) {
|
||||
return false;
|
||||
}
|
||||
for (std::size_t i = index + 1; i < size_; ++i) {
|
||||
data_[i - 1] = data_[i];
|
||||
}
|
||||
--size_;
|
||||
return true;
|
||||
}
|
||||
|
||||
T& operator[](std::size_t index) noexcept { return data_[index]; }
|
||||
const T& operator[](std::size_t index) const noexcept { return data_[index]; }
|
||||
T& back() noexcept { return data_[size_ - 1]; }
|
||||
const T& back() const noexcept { return data_[size_ - 1]; }
|
||||
T& front() noexcept { return data_[0]; }
|
||||
const T& front() const noexcept { return data_[0]; }
|
||||
|
||||
// No throwing allocator or unchecked access is needed by the core. The
|
||||
// caller can use size() before this helper when a reference is required.
|
||||
T* find_if_index(std::size_t index) noexcept {
|
||||
return index < size_ ? &data_[index] : nullptr;
|
||||
}
|
||||
const T* find_if_index(std::size_t index) const noexcept {
|
||||
return index < size_ ? &data_[index] : nullptr;
|
||||
}
|
||||
|
||||
T* data() noexcept { return data_.data(); }
|
||||
const T* data() const noexcept { return data_.data(); }
|
||||
iterator begin() noexcept { return data_.data(); }
|
||||
const_iterator begin() const noexcept { return data_.data(); }
|
||||
iterator end() noexcept { return data_.data() + size_; }
|
||||
const_iterator end() const noexcept { return data_.data() + size_; }
|
||||
|
||||
private:
|
||||
std::array<T, Capacity> data_{};
|
||||
std::size_t size_ = 0;
|
||||
};
|
||||
|
||||
struct Profile {
|
||||
union {
|
||||
ProfileId id;
|
||||
ProfileId profileId;
|
||||
};
|
||||
FixedText<32> name{};
|
||||
bool enabled = true;
|
||||
|
||||
Profile() : id(0) {}
|
||||
explicit Profile(ProfileId identifier) : id(identifier) {}
|
||||
Profile(ProfileId identifier, const char* profileName) : id(identifier), name(profileName) {}
|
||||
};
|
||||
|
||||
struct Pattern {
|
||||
// Six alternating durations, in seconds: ON, OFF, ON, OFF, ...
|
||||
// phaseCount permits a short final pattern while retaining a fixed buffer.
|
||||
PatternId id = PatternId::A;
|
||||
std::uint8_t phaseCount = static_cast<std::uint8_t>(kMaxPatternPhases);
|
||||
union {
|
||||
std::array<std::uint8_t, kMaxPatternPhases> phases;
|
||||
std::array<std::uint8_t, kMaxPatternPhases> durations;
|
||||
std::array<std::uint8_t, kMaxPatternPhases> phaseSeconds;
|
||||
};
|
||||
|
||||
Pattern() : phases{} {}
|
||||
explicit Pattern(PatternId patternIdentifier) : id(patternIdentifier), phases{} {}
|
||||
|
||||
Pattern(PatternId patternIdentifier, std::initializer_list<std::uint8_t> values)
|
||||
: id(patternIdentifier), phaseCount(0), phases{} {
|
||||
for (std::uint8_t value : values) {
|
||||
if (phaseCount < kMaxPatternPhases) {
|
||||
phases[phaseCount++] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Pattern(PatternId patternIdentifier, const std::array<std::uint8_t, kMaxPatternPhases>& values)
|
||||
: id(patternIdentifier), phases(values) {}
|
||||
|
||||
explicit Pattern(const std::array<std::uint8_t, kMaxPatternPhases>& values)
|
||||
: phases(values) {}
|
||||
|
||||
Pattern(std::uint8_t onSeconds, std::uint8_t offSeconds, std::uint8_t onSeconds2,
|
||||
std::uint8_t offSeconds2, std::uint8_t onSeconds3, std::uint8_t offSeconds3)
|
||||
: phases{onSeconds, offSeconds, onSeconds2, offSeconds2, onSeconds3, offSeconds3} {}
|
||||
|
||||
std::uint16_t totalSeconds() const noexcept {
|
||||
std::uint16_t total = 0;
|
||||
const std::size_t count = phaseCount <= kMaxPatternPhases ? phaseCount : 0;
|
||||
for (std::size_t i = 0; i < count; ++i) {
|
||||
total = static_cast<std::uint16_t>(total + phases[i]);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
bool valid() const noexcept {
|
||||
if (!isPatternId(id) || phaseCount > kMaxPatternPhases) {
|
||||
return false;
|
||||
}
|
||||
std::uint16_t total = 0;
|
||||
for (std::size_t i = 0; i < phaseCount; ++i) {
|
||||
if (phases[i] > kMaxPhaseDurationSeconds) {
|
||||
return false;
|
||||
}
|
||||
total = static_cast<std::uint16_t>(total + phases[i]);
|
||||
}
|
||||
return total <= kMaxPatternTotalSeconds;
|
||||
}
|
||||
|
||||
bool phaseIsOn(std::size_t index) const noexcept {
|
||||
return index < phaseCount && (index & 1u) == 0u;
|
||||
}
|
||||
};
|
||||
|
||||
struct Schedule {
|
||||
union {
|
||||
ScheduleId id;
|
||||
ScheduleId scheduleId;
|
||||
};
|
||||
union {
|
||||
ProfileId profileId;
|
||||
ProfileId profile;
|
||||
};
|
||||
union {
|
||||
PatternId patternId;
|
||||
PatternId pattern;
|
||||
};
|
||||
union {
|
||||
std::uint16_t minuteOfDay;
|
||||
std::uint16_t minute;
|
||||
};
|
||||
|
||||
union {
|
||||
std::uint8_t weekdayMask;
|
||||
std::uint8_t days;
|
||||
std::uint8_t dayMask;
|
||||
};
|
||||
|
||||
bool enabled = true;
|
||||
|
||||
Schedule()
|
||||
: id(kInvalidId),
|
||||
profileId(0),
|
||||
patternId(PatternId::A),
|
||||
minuteOfDay(0),
|
||||
weekdayMask(kAllWeekdaysMask) {}
|
||||
|
||||
Schedule(ScheduleId scheduleIdentifier,
|
||||
ProfileId profileIdentifier,
|
||||
PatternId patternIdentifier,
|
||||
std::uint16_t minute,
|
||||
std::uint8_t daysMask,
|
||||
bool isEnabled = true)
|
||||
: id(scheduleIdentifier),
|
||||
profileId(profileIdentifier),
|
||||
patternId(patternIdentifier),
|
||||
minuteOfDay(minute),
|
||||
weekdayMask(daysMask),
|
||||
enabled(isEnabled) {}
|
||||
|
||||
// Convenience overload for callers that naturally specify time before the
|
||||
// pattern identifier.
|
||||
Schedule(ScheduleId scheduleIdentifier,
|
||||
ProfileId profileIdentifier,
|
||||
std::uint16_t minute,
|
||||
PatternId patternIdentifier,
|
||||
std::uint8_t daysMask,
|
||||
bool isEnabled = true)
|
||||
: id(scheduleIdentifier),
|
||||
profileId(profileIdentifier),
|
||||
patternId(patternIdentifier),
|
||||
minuteOfDay(minute),
|
||||
weekdayMask(daysMask),
|
||||
enabled(isEnabled) {}
|
||||
|
||||
static Schedule at(ScheduleId scheduleIdentifier,
|
||||
ProfileId profileIdentifier,
|
||||
PatternId patternIdentifier,
|
||||
std::uint8_t hour,
|
||||
std::uint8_t minute,
|
||||
std::uint8_t daysMask,
|
||||
bool isEnabled = true) {
|
||||
return Schedule(scheduleIdentifier, profileIdentifier, patternIdentifier,
|
||||
static_cast<std::uint16_t>(hour) * 60u + minute,
|
||||
daysMask, isEnabled);
|
||||
}
|
||||
|
||||
std::uint8_t hourPart() const noexcept {
|
||||
return static_cast<std::uint8_t>(minuteOfDay / 60u);
|
||||
}
|
||||
std::uint8_t minutePart() const noexcept {
|
||||
return static_cast<std::uint8_t>(minuteOfDay % 60u);
|
||||
}
|
||||
};
|
||||
|
||||
struct Holiday {
|
||||
union {
|
||||
HolidayId id;
|
||||
HolidayId holidayId;
|
||||
};
|
||||
std::uint8_t month = 1;
|
||||
std::uint8_t day = 1;
|
||||
bool enabled = true;
|
||||
|
||||
Holiday() : id(kInvalidId) {}
|
||||
Holiday(HolidayId holidayIdentifier, std::uint8_t holidayMonth,
|
||||
std::uint8_t holidayDay, bool isEnabled = true)
|
||||
: id(holidayIdentifier), month(holidayMonth), day(holidayDay), enabled(isEnabled) {}
|
||||
|
||||
Holiday(std::uint8_t holidayMonth, std::uint8_t holidayDay)
|
||||
: month(holidayMonth), day(holidayDay) {}
|
||||
|
||||
static Holiday recurring(std::uint8_t holidayMonth, std::uint8_t holidayDay,
|
||||
HolidayId holidayIdentifier = kInvalidId,
|
||||
bool isEnabled = true) {
|
||||
return Holiday(holidayIdentifier, holidayMonth, holidayDay, isEnabled);
|
||||
}
|
||||
};
|
||||
|
||||
struct AppState {
|
||||
FixedVector<Profile, kMaxProfiles> profiles;
|
||||
std::array<Pattern, kMaxPatterns> patterns;
|
||||
FixedVector<Schedule, kMaxSchedules> schedules;
|
||||
FixedVector<Holiday, kMaxHolidays> holidays;
|
||||
union {
|
||||
ProfileId activeProfileId;
|
||||
ProfileId activeProfile;
|
||||
};
|
||||
std::uint32_t revision = 0;
|
||||
|
||||
AppState() : activeProfileId(0) {
|
||||
for (std::size_t i = 0; i < kMaxPatterns; ++i) {
|
||||
patterns[i].id = patternIdFromIndex(i);
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t profileCount() const noexcept { return profiles.size(); }
|
||||
std::size_t scheduleCount() const noexcept { return schedules.size(); }
|
||||
std::size_t holidayCount() const noexcept { return holidays.size(); }
|
||||
|
||||
Pattern* pattern(PatternId id) noexcept {
|
||||
const std::size_t index = patternIndex(id);
|
||||
return index < kMaxPatterns ? &patterns[index] : nullptr;
|
||||
}
|
||||
|
||||
const Pattern* pattern(PatternId id) const noexcept {
|
||||
const std::size_t index = patternIndex(id);
|
||||
return index < kMaxPatterns ? &patterns[index] : nullptr;
|
||||
}
|
||||
|
||||
Profile* profile(ProfileId id) noexcept {
|
||||
for (std::size_t i = 0; i < profiles.size(); ++i) {
|
||||
if (profiles[i].id == id) {
|
||||
return &profiles[i];
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const Profile* profile(ProfileId id) const noexcept {
|
||||
for (std::size_t i = 0; i < profiles.size(); ++i) {
|
||||
if (profiles[i].id == id) {
|
||||
return &profiles[i];
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
Schedule* schedule(ScheduleId id) noexcept {
|
||||
for (std::size_t i = 0; i < schedules.size(); ++i) {
|
||||
if (schedules[i].id == id) {
|
||||
return &schedules[i];
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const Schedule* schedule(ScheduleId id) const noexcept {
|
||||
for (std::size_t i = 0; i < schedules.size(); ++i) {
|
||||
if (schedules[i].id == id) {
|
||||
return &schedules[i];
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
Holiday* holiday(HolidayId id) noexcept {
|
||||
for (std::size_t i = 0; i < holidays.size(); ++i) {
|
||||
if (holidays[i].id == id) {
|
||||
return &holidays[i];
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const Holiday* holiday(HolidayId id) const noexcept {
|
||||
for (std::size_t i = 0; i < holidays.size(); ++i) {
|
||||
if (holidays[i].id == id) {
|
||||
return &holidays[i];
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
};
|
||||
|
||||
// Monotonic IDs are never inferred from a vector position. Deleting an item
|
||||
// therefore cannot silently change the identity of another item.
|
||||
class StableIdAllocator {
|
||||
public:
|
||||
StableIdAllocator() = default;
|
||||
explicit StableIdAllocator(Id first) noexcept : next_(first == 0 ? 1 : first) {}
|
||||
|
||||
Id nextValue() const noexcept { return next_; }
|
||||
void setNextValue(Id value) noexcept { next_ = value == 0 ? 1 : value; }
|
||||
|
||||
Id next() noexcept {
|
||||
for (;;) {
|
||||
if (next_ == 0 || next_ == kInvalidId) {
|
||||
next_ = 1;
|
||||
}
|
||||
const Id result = next_++;
|
||||
if (result != 0 && result != kInvalidId) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void observe(Id id) noexcept {
|
||||
if (id != kInvalidId && id >= next_) {
|
||||
next_ = static_cast<Id>(id + 1u);
|
||||
}
|
||||
}
|
||||
|
||||
void reset(Id first = 1) noexcept { next_ = first == 0 ? 1 : first; }
|
||||
|
||||
private:
|
||||
Id next_ = 1;
|
||||
};
|
||||
|
||||
} // namespace timbre_core
|
||||
|
||||
// A short namespace alias keeps the library pleasant to use from both the
|
||||
// application and small native tests.
|
||||
namespace timbre = timbre_core;
|
||||
259
lib/timbre_core/PatternEngine.cpp
Executable file
259
lib/timbre_core/PatternEngine.cpp
Executable file
@@ -0,0 +1,259 @@
|
||||
#include "PatternEngine.h"
|
||||
|
||||
#include <limits>
|
||||
|
||||
namespace timbre_core {
|
||||
|
||||
void PatternEngine::initializeDefaults() noexcept {
|
||||
for (std::size_t i = 0; i < kMaxPatterns; ++i) {
|
||||
patterns_[i].id = patternIdFromIndex(i);
|
||||
}
|
||||
configuredPatternMask_ =
|
||||
static_cast<std::uint8_t>((1u << kMaxPatterns) - 1u);
|
||||
}
|
||||
|
||||
std::uint64_t PatternEngine::readClock() const {
|
||||
if (clockInterface_ != nullptr) {
|
||||
return clockInterface_->nowMs();
|
||||
}
|
||||
if (clock_) {
|
||||
return clock_();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool PatternEngine::validPattern(const Pattern& pattern) const noexcept {
|
||||
return isPatternId(pattern.id) && ScheduleValidator::validatePattern(pattern).valid;
|
||||
}
|
||||
|
||||
void PatternEngine::setPattern(const Pattern& pattern) noexcept {
|
||||
if (!validPattern(pattern)) {
|
||||
return;
|
||||
}
|
||||
const std::size_t index = patternIndex(pattern.id);
|
||||
if (index < patterns_.size()) {
|
||||
patterns_[index] = pattern;
|
||||
configuredPatternMask_ = static_cast<std::uint8_t>(
|
||||
configuredPatternMask_ | (1u << index));
|
||||
}
|
||||
}
|
||||
|
||||
void PatternEngine::setPatterns(const AppState& state) noexcept {
|
||||
bool allValid = true;
|
||||
for (std::size_t i = 0; i < kMaxPatterns; ++i) {
|
||||
if (!validPattern(state.patterns[i])) {
|
||||
allValid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!allValid) {
|
||||
return;
|
||||
}
|
||||
patterns_ = state.patterns;
|
||||
configuredPatternMask_ = static_cast<std::uint8_t>((1u << kMaxPatterns) - 1u);
|
||||
}
|
||||
|
||||
bool PatternEngine::enqueueSnapshot(const Pattern& pattern, std::uint64_t startAtMs) {
|
||||
if (!validPattern(pattern) || queueSize_ >= kQueueCapacity) {
|
||||
return false;
|
||||
}
|
||||
const std::size_t tail = (queueHead_ + queueSize_) % kQueueCapacity;
|
||||
queue_[tail].pattern = pattern;
|
||||
queue_[tail].startAtMs = startAtMs;
|
||||
++queueSize_;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PatternEngine::enqueue(PatternId id) {
|
||||
return enqueue(id, readClock());
|
||||
}
|
||||
|
||||
bool PatternEngine::enqueue(PatternId id, std::uint64_t startAtMs) {
|
||||
const std::size_t index = patternIndex(id);
|
||||
if (index >= patterns_.size() ||
|
||||
(configuredPatternMask_ & (1u << index)) == 0) {
|
||||
return false;
|
||||
}
|
||||
return enqueueSnapshot(patterns_[index], startAtMs);
|
||||
}
|
||||
|
||||
bool PatternEngine::enqueue(const Pattern& pattern) {
|
||||
return enqueue(pattern, readClock());
|
||||
}
|
||||
|
||||
bool PatternEngine::enqueue(const Pattern& pattern, std::uint64_t startAtMs) {
|
||||
return enqueueSnapshot(pattern, startAtMs);
|
||||
}
|
||||
|
||||
bool PatternEngine::enqueue(PatternId id, const AppState& state, std::uint64_t startAtMs) {
|
||||
const Pattern* pattern = state.pattern(id);
|
||||
return pattern != nullptr && enqueueSnapshot(*pattern, startAtMs);
|
||||
}
|
||||
|
||||
bool PatternEngine::enqueue(PatternId id, const AppState& state) {
|
||||
return enqueue(id, state, readClock());
|
||||
}
|
||||
|
||||
bool PatternEngine::start(PatternId id, std::uint64_t nowMs) {
|
||||
const std::size_t index = patternIndex(id);
|
||||
if (index >= patterns_.size() ||
|
||||
(configuredPatternMask_ & (1u << index)) == 0) {
|
||||
return false;
|
||||
}
|
||||
return startSnapshot(patterns_[index], nowMs);
|
||||
}
|
||||
|
||||
bool PatternEngine::start(const Pattern& pattern, std::uint64_t nowMs) {
|
||||
return startSnapshot(pattern, nowMs);
|
||||
}
|
||||
|
||||
bool PatternEngine::start(PatternId id, const AppState& state, std::uint64_t nowMs) {
|
||||
const Pattern* pattern = state.pattern(id);
|
||||
return pattern != nullptr && startSnapshot(*pattern, nowMs);
|
||||
}
|
||||
|
||||
bool PatternEngine::start(PatternId id, const AppState& state) {
|
||||
return start(id, state, readClock());
|
||||
}
|
||||
|
||||
std::uint64_t PatternEngine::addDuration(std::uint64_t deadline,
|
||||
std::uint8_t seconds) noexcept {
|
||||
const std::uint64_t duration = static_cast<std::uint64_t>(seconds) * kMillisecondsPerSecond;
|
||||
if (deadline > std::numeric_limits<std::uint64_t>::max() - duration) {
|
||||
return std::numeric_limits<std::uint64_t>::max();
|
||||
}
|
||||
return deadline + duration;
|
||||
}
|
||||
|
||||
bool PatternEngine::startSnapshot(const Pattern& pattern, std::uint64_t nowMs) {
|
||||
if (!validPattern(pattern)) {
|
||||
return false;
|
||||
}
|
||||
if (active_) {
|
||||
finishActive();
|
||||
}
|
||||
activePattern_ = pattern;
|
||||
active_ = true;
|
||||
phase_ = 0;
|
||||
deadlineMs_ = 0;
|
||||
outputOn_ = false;
|
||||
hasUpdateTime_ = true;
|
||||
lastUpdateMs_ = nowMs;
|
||||
|
||||
std::size_t firstActivePhase = 0;
|
||||
while (firstActivePhase < pattern.phaseCount &&
|
||||
pattern.phases[firstActivePhase] == 0) {
|
||||
++firstActivePhase;
|
||||
}
|
||||
if (firstActivePhase == pattern.phaseCount) {
|
||||
finishActive();
|
||||
return true;
|
||||
}
|
||||
|
||||
phase_ = static_cast<std::uint8_t>(firstActivePhase);
|
||||
emit(pattern.phaseIsOn(firstActivePhase));
|
||||
deadlineMs_ = addDuration(nowMs, pattern.phases[firstActivePhase]);
|
||||
return true;
|
||||
}
|
||||
|
||||
void PatternEngine::emit(bool on) noexcept {
|
||||
// The engine has one physical output by design. Reserved GPIO23 is never
|
||||
// passed to an output sink, even if a caller supplies an arbitrary ID.
|
||||
outputOn_ = on;
|
||||
lastOutputRelay_ = RelayId::Main;
|
||||
if (output_) {
|
||||
output_(RelayId::Main, on);
|
||||
} else if (simpleOutput_) {
|
||||
simpleOutput_(on);
|
||||
}
|
||||
}
|
||||
|
||||
void PatternEngine::finishActive() noexcept {
|
||||
if (active_ && outputOn_) {
|
||||
emit(false);
|
||||
}
|
||||
active_ = false;
|
||||
phase_ = kNoPhase;
|
||||
deadlineMs_ = 0;
|
||||
outputOn_ = false;
|
||||
}
|
||||
|
||||
void PatternEngine::pump(std::uint64_t nowMs) {
|
||||
// FIFO order is intentional. A future head blocks later requests rather
|
||||
// than reordering a user's schedule.
|
||||
while (!active_ && queueSize_ != 0 && queue_[queueHead_].startAtMs <= nowMs) {
|
||||
const Request request = queue_[queueHead_];
|
||||
queueHead_ = (queueHead_ + 1) % kQueueCapacity;
|
||||
--queueSize_;
|
||||
(void)startSnapshot(request.pattern, nowMs);
|
||||
}
|
||||
}
|
||||
|
||||
bool PatternEngine::update(std::uint64_t nowMs) {
|
||||
if (hasUpdateTime_ && nowMs < lastUpdateMs_) {
|
||||
// A clock regression is unsafe for an active relay. Stop the sequence
|
||||
// instead of leaving the last output energized indefinitely.
|
||||
const bool changed = active_ || outputOn_;
|
||||
finishActive();
|
||||
return changed;
|
||||
}
|
||||
hasUpdateTime_ = true;
|
||||
lastUpdateMs_ = nowMs;
|
||||
bool changed = false;
|
||||
|
||||
if (active_) {
|
||||
// Deadlines are absolute. A delayed poll walks the finite phase list
|
||||
// and cannot accumulate delay or schedule a phase in the past.
|
||||
while (active_ && nowMs >= deadlineMs_) {
|
||||
if (phase_ + 1u >= activePattern_.phaseCount) {
|
||||
finishActive();
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
++phase_;
|
||||
emit(activePattern_.phaseIsOn(phase_));
|
||||
deadlineMs_ = addDuration(deadlineMs_, activePattern_.phases[phase_]);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!active_ && queueSize_ != 0) {
|
||||
const std::size_t before = queueSize_;
|
||||
pump(nowMs);
|
||||
changed = changed || before != queueSize_ || active_;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
bool PatternEngine::update() {
|
||||
if (clockInterface_ == nullptr && !clock_) {
|
||||
return false;
|
||||
}
|
||||
return update(readClock());
|
||||
}
|
||||
|
||||
void PatternEngine::stop(std::uint64_t nowMs) {
|
||||
if (hasUpdateTime_ && nowMs < lastUpdateMs_) {
|
||||
// A bad sample must not prevent the safety turn-off.
|
||||
finishActive();
|
||||
return;
|
||||
}
|
||||
hasUpdateTime_ = true;
|
||||
lastUpdateMs_ = nowMs;
|
||||
finishActive();
|
||||
}
|
||||
|
||||
void PatternEngine::stop() {
|
||||
if (clockInterface_ != nullptr || clock_) {
|
||||
stop(readClock());
|
||||
} else {
|
||||
stop(lastUpdateMs_);
|
||||
}
|
||||
}
|
||||
|
||||
void PatternEngine::cancelAll() noexcept {
|
||||
queueHead_ = 0;
|
||||
queueSize_ = 0;
|
||||
}
|
||||
|
||||
} // namespace timbre_core
|
||||
151
lib/timbre_core/PatternEngine.h
Executable file
151
lib/timbre_core/PatternEngine.h
Executable file
@@ -0,0 +1,151 @@
|
||||
#pragma once
|
||||
|
||||
#include "Model.h"
|
||||
#include "ScheduleValidator.h"
|
||||
#include "SessionClock.h"
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <utility>
|
||||
|
||||
namespace timbre_core {
|
||||
|
||||
class PatternEngine {
|
||||
public:
|
||||
using OutputCallback = std::function<void(RelayId, bool)>;
|
||||
using ClockCallback = std::function<std::uint64_t()>;
|
||||
using SimpleOutputCallback = std::function<void(bool)>;
|
||||
|
||||
struct Request {
|
||||
Pattern pattern{};
|
||||
std::uint64_t startAtMs = 0;
|
||||
};
|
||||
|
||||
static constexpr std::size_t kQueueCapacity = kPatternQueueCapacity;
|
||||
static constexpr std::uint8_t kNoPhase = 0xFFu;
|
||||
static constexpr std::uint64_t kMillisecondsPerSecond = 1000ULL;
|
||||
|
||||
PatternEngine() {
|
||||
initializeDefaults();
|
||||
}
|
||||
explicit PatternEngine(OutputCallback output) : output_(std::move(output)) {
|
||||
initializeDefaults();
|
||||
}
|
||||
explicit PatternEngine(ClockCallback clock, OutputCallback output = OutputCallback())
|
||||
: clock_(std::move(clock)), output_(std::move(output)) {
|
||||
initializeDefaults();
|
||||
}
|
||||
explicit PatternEngine(MonotonicClock& clock, OutputCallback output = OutputCallback())
|
||||
: clockInterface_(&clock), output_(std::move(output)) {
|
||||
initializeDefaults();
|
||||
}
|
||||
explicit PatternEngine(const AppState& state, OutputCallback output = OutputCallback())
|
||||
: output_(std::move(output)) {
|
||||
initializeDefaults();
|
||||
setPatterns(state);
|
||||
}
|
||||
|
||||
void setClock(ClockCallback clock) {
|
||||
clockInterface_ = nullptr;
|
||||
clock_ = std::move(clock);
|
||||
}
|
||||
void setClock(MonotonicClock& clock) {
|
||||
clock_ = ClockCallback();
|
||||
clockInterface_ = &clock;
|
||||
}
|
||||
void setOutput(OutputCallback output) {
|
||||
output_ = std::move(output);
|
||||
simpleOutput_ = SimpleOutputCallback();
|
||||
}
|
||||
void setSimpleOutput(SimpleOutputCallback output) {
|
||||
simpleOutput_ = std::move(output);
|
||||
output_ = OutputCallback();
|
||||
}
|
||||
|
||||
void setPattern(const Pattern& pattern) noexcept;
|
||||
void setPatterns(const AppState& state) noexcept;
|
||||
void setState(const AppState& state) noexcept { setPatterns(state); }
|
||||
void configure(const AppState& state) noexcept { setPatterns(state); }
|
||||
|
||||
// Queueing is non-blocking and bounded. A request is a snapshot, so a
|
||||
// later configuration commit cannot mutate an already queued sequence.
|
||||
bool enqueue(PatternId id);
|
||||
bool enqueue(PatternId id, std::uint64_t startAtMs);
|
||||
bool enqueue(const Pattern& pattern);
|
||||
bool enqueue(const Pattern& pattern, std::uint64_t startAtMs);
|
||||
bool enqueue(PatternId id, const AppState& state, std::uint64_t startAtMs);
|
||||
bool enqueue(PatternId id, const AppState& state);
|
||||
|
||||
bool start(PatternId id, std::uint64_t nowMs);
|
||||
bool start(const Pattern& pattern, std::uint64_t nowMs);
|
||||
bool start(PatternId id, const AppState& state, std::uint64_t nowMs);
|
||||
bool start(PatternId id, const AppState& state);
|
||||
|
||||
// Returns true when the state/output changed. It performs no waiting and
|
||||
// consumes at most one bounded transition burst (six phases).
|
||||
bool update(std::uint64_t nowMs);
|
||||
bool update();
|
||||
bool tick(std::uint64_t nowMs) { return update(nowMs); }
|
||||
bool tick() { return update(); }
|
||||
|
||||
// Stops the active sequence and guarantees an OFF output. Pending queue
|
||||
// entries are retained; cancelAll() removes them as well.
|
||||
void stop(std::uint64_t nowMs);
|
||||
void stop();
|
||||
void finish() { stop(); }
|
||||
void cancelAll() noexcept;
|
||||
|
||||
bool running() const noexcept { return active_; }
|
||||
bool outputOn() const noexcept { return outputOn_; }
|
||||
PatternId activePatternId() const noexcept { return activePattern_.id; }
|
||||
const Pattern* activePattern() const noexcept { return active_ ? &activePattern_ : nullptr; }
|
||||
std::uint8_t activePhase() const noexcept { return active_ ? phase_ : kNoPhase; }
|
||||
std::uint64_t deadlineMs() const noexcept { return active_ ? deadlineMs_ : 0; }
|
||||
std::uint64_t deadline() const noexcept { return deadlineMs(); }
|
||||
std::uint64_t nextDeadlineMs() const noexcept { return deadlineMs(); }
|
||||
std::size_t queueSize() const noexcept { return queueSize_; }
|
||||
std::size_t queueCapacity() const noexcept { return kQueueCapacity; }
|
||||
bool configured(PatternId id) const noexcept {
|
||||
const std::size_t index = patternIndex(id);
|
||||
return index < kMaxPatterns && (configuredPatternMask_ & (1u << index)) != 0;
|
||||
}
|
||||
RelayId lastOutputRelay() const noexcept { return lastOutputRelay_; }
|
||||
bool reservedRelayDriven() const noexcept { return reservedRelayDriven_; }
|
||||
|
||||
private:
|
||||
void initializeDefaults() noexcept;
|
||||
std::uint64_t readClock() const;
|
||||
bool validPattern(const Pattern& pattern) const noexcept;
|
||||
bool enqueueSnapshot(const Pattern& pattern, std::uint64_t startAtMs);
|
||||
bool startSnapshot(const Pattern& pattern, std::uint64_t nowMs);
|
||||
void pump(std::uint64_t nowMs);
|
||||
void finishActive() noexcept;
|
||||
void emit(bool on) noexcept;
|
||||
static std::uint64_t addDuration(std::uint64_t deadline, std::uint8_t seconds) noexcept;
|
||||
|
||||
ClockCallback clock_;
|
||||
MonotonicClock* clockInterface_ = nullptr;
|
||||
OutputCallback output_;
|
||||
SimpleOutputCallback simpleOutput_;
|
||||
|
||||
std::array<Pattern, kMaxPatterns> patterns_{};
|
||||
std::uint8_t configuredPatternMask_ =
|
||||
static_cast<std::uint8_t>((1u << kMaxPatterns) - 1u);
|
||||
|
||||
std::array<Request, kQueueCapacity> queue_{};
|
||||
std::size_t queueHead_ = 0;
|
||||
std::size_t queueSize_ = 0;
|
||||
|
||||
Pattern activePattern_{};
|
||||
bool active_ = false;
|
||||
std::uint8_t phase_ = kNoPhase;
|
||||
std::uint64_t deadlineMs_ = 0;
|
||||
std::uint64_t lastUpdateMs_ = 0;
|
||||
bool hasUpdateTime_ = false;
|
||||
bool outputOn_ = false;
|
||||
RelayId lastOutputRelay_ = RelayId::Main;
|
||||
bool reservedRelayDriven_ = false;
|
||||
};
|
||||
|
||||
} // namespace timbre_core
|
||||
214
lib/timbre_core/ScheduleValidator.cpp
Executable file
214
lib/timbre_core/ScheduleValidator.cpp
Executable file
@@ -0,0 +1,214 @@
|
||||
#include "ScheduleValidator.h"
|
||||
|
||||
namespace timbre_core {
|
||||
namespace {
|
||||
|
||||
ValidationResult failure(ValidationCode code, std::size_t index = 0) noexcept {
|
||||
return ValidationResult(code, index);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const char* ValidationResult::message() const noexcept {
|
||||
switch (code) {
|
||||
case ValidationCode::Ok:
|
||||
return "ok";
|
||||
case ValidationCode::TooManyProfiles:
|
||||
return "too many profiles";
|
||||
case ValidationCode::TooManyPatterns:
|
||||
return "too many patterns";
|
||||
case ValidationCode::TooManySchedules:
|
||||
return "too many schedules";
|
||||
case ValidationCode::TooManyHolidays:
|
||||
return "too many holidays";
|
||||
case ValidationCode::InvalidProfileId:
|
||||
return "invalid profile id";
|
||||
case ValidationCode::DuplicateProfileId:
|
||||
return "duplicate profile id";
|
||||
case ValidationCode::InvalidPatternId:
|
||||
return "invalid pattern id";
|
||||
case ValidationCode::DuplicatePatternId:
|
||||
return "duplicate pattern id";
|
||||
case ValidationCode::InvalidPhaseCount:
|
||||
return "invalid phase count";
|
||||
case ValidationCode::InvalidPhaseDuration:
|
||||
return "invalid phase duration";
|
||||
case ValidationCode::PatternTotalTooLarge:
|
||||
return "pattern total is too large";
|
||||
case ValidationCode::InvalidHolidayId:
|
||||
return "invalid holiday id";
|
||||
case ValidationCode::DuplicateHolidayId:
|
||||
return "duplicate holiday id";
|
||||
case ValidationCode::InvalidHolidayDate:
|
||||
return "invalid holiday date";
|
||||
case ValidationCode::InvalidScheduleId:
|
||||
return "invalid schedule id";
|
||||
case ValidationCode::DuplicateScheduleId:
|
||||
return "duplicate schedule id";
|
||||
case ValidationCode::InvalidMinute:
|
||||
return "invalid minute";
|
||||
case ValidationCode::InvalidWeekdayMask:
|
||||
return "invalid weekday mask";
|
||||
case ValidationCode::UnknownProfile:
|
||||
return "schedule references an unknown profile";
|
||||
case ValidationCode::UnknownPattern:
|
||||
return "schedule references an unknown pattern";
|
||||
case ValidationCode::InvalidActiveProfile:
|
||||
return "active profile does not exist";
|
||||
}
|
||||
return "validation error";
|
||||
}
|
||||
|
||||
ValidationResult ScheduleValidator::validateProfile(const Profile& profile,
|
||||
std::size_t index) noexcept {
|
||||
if (profile.id == kInvalidId) {
|
||||
return failure(ValidationCode::InvalidProfileId, index);
|
||||
}
|
||||
return ValidationResult{};
|
||||
}
|
||||
|
||||
ValidationResult ScheduleValidator::validatePattern(const Pattern& pattern,
|
||||
std::size_t index) noexcept {
|
||||
if (!isPatternId(pattern.id)) {
|
||||
return failure(ValidationCode::InvalidPatternId, index);
|
||||
}
|
||||
if (pattern.phaseCount > kMaxPatternPhases) {
|
||||
return failure(ValidationCode::InvalidPhaseCount, index);
|
||||
}
|
||||
|
||||
std::uint16_t total = 0;
|
||||
for (std::size_t i = 0; i < pattern.phaseCount; ++i) {
|
||||
if (!validPhaseDuration(pattern.phases[i])) {
|
||||
return failure(ValidationCode::InvalidPhaseDuration, index);
|
||||
}
|
||||
total = static_cast<std::uint16_t>(total + pattern.phases[i]);
|
||||
if (!validPatternTotal(total)) {
|
||||
return failure(ValidationCode::PatternTotalTooLarge, index);
|
||||
}
|
||||
}
|
||||
return ValidationResult{};
|
||||
}
|
||||
|
||||
ValidationResult ScheduleValidator::validateHoliday(const Holiday& holiday,
|
||||
std::size_t index) noexcept {
|
||||
if (holiday.id == kInvalidId) {
|
||||
return failure(ValidationCode::InvalidHolidayId, index);
|
||||
}
|
||||
if (!validHolidayDate(holiday.month, holiday.day)) {
|
||||
return failure(ValidationCode::InvalidHolidayDate, index);
|
||||
}
|
||||
return ValidationResult{};
|
||||
}
|
||||
|
||||
bool ScheduleValidator::validHolidayDate(std::uint8_t month, std::uint8_t day) noexcept {
|
||||
// Holidays are recurring month/day values. February 29 is valid and is
|
||||
// naturally inert in non-leap years.
|
||||
if (month < 1 || month > 12 || day < 1) {
|
||||
return false;
|
||||
}
|
||||
static constexpr std::uint8_t maximumDays[] = {
|
||||
0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31
|
||||
};
|
||||
return day <= maximumDays[month];
|
||||
}
|
||||
|
||||
ValidationResult ScheduleValidator::validateSchedule(const Schedule& schedule,
|
||||
const AppState& state,
|
||||
std::size_t index) noexcept {
|
||||
if (schedule.id == kInvalidId) {
|
||||
return failure(ValidationCode::InvalidScheduleId, index);
|
||||
}
|
||||
if (!validMinute(schedule.minuteOfDay)) {
|
||||
return failure(ValidationCode::InvalidMinute, index);
|
||||
}
|
||||
if (!validWeekdayMask(schedule.weekdayMask)) {
|
||||
return failure(ValidationCode::InvalidWeekdayMask, index);
|
||||
}
|
||||
if (state.profile(schedule.profileId) == nullptr) {
|
||||
return failure(ValidationCode::UnknownProfile, index);
|
||||
}
|
||||
if (state.pattern(schedule.patternId) == nullptr ||
|
||||
!isPatternId(schedule.patternId)) {
|
||||
return failure(ValidationCode::UnknownPattern, index);
|
||||
}
|
||||
const Pattern* pattern = state.pattern(schedule.patternId);
|
||||
if (pattern == nullptr || !validatePattern(*pattern).valid) {
|
||||
return failure(ValidationCode::UnknownPattern, index);
|
||||
}
|
||||
return ValidationResult{};
|
||||
}
|
||||
|
||||
ValidationResult ScheduleValidator::validate(const AppState& state) noexcept {
|
||||
if (state.profiles.size() > kMaxProfiles) {
|
||||
return failure(ValidationCode::TooManyProfiles, state.profiles.size());
|
||||
}
|
||||
if (state.patterns.size() != kMaxPatterns) {
|
||||
return failure(ValidationCode::TooManyPatterns, state.patterns.size());
|
||||
}
|
||||
if (state.schedules.size() > kMaxSchedules) {
|
||||
return failure(ValidationCode::TooManySchedules, state.schedules.size());
|
||||
}
|
||||
if (state.holidays.size() > kMaxHolidays) {
|
||||
return failure(ValidationCode::TooManyHolidays, state.holidays.size());
|
||||
}
|
||||
|
||||
for (std::size_t i = 0; i < state.profiles.size(); ++i) {
|
||||
const ValidationResult result = validateProfile(state.profiles[i], i);
|
||||
if (!result.valid) {
|
||||
return result;
|
||||
}
|
||||
for (std::size_t j = 0; j < i; ++j) {
|
||||
if (state.profiles[j].id == state.profiles[i].id) {
|
||||
return failure(ValidationCode::DuplicateProfileId, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (std::size_t i = 0; i < state.patterns.size(); ++i) {
|
||||
const ValidationResult result = validatePattern(state.patterns[i], i);
|
||||
if (!result.valid) {
|
||||
return result;
|
||||
}
|
||||
for (std::size_t j = 0; j < i; ++j) {
|
||||
if (state.patterns[j].id == state.patterns[i].id) {
|
||||
return failure(ValidationCode::DuplicatePatternId, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (std::size_t i = 0; i < state.holidays.size(); ++i) {
|
||||
const ValidationResult result = validateHoliday(state.holidays[i], i);
|
||||
if (!result.valid) {
|
||||
return result;
|
||||
}
|
||||
for (std::size_t j = 0; j < i; ++j) {
|
||||
if (state.holidays[j].id == state.holidays[i].id) {
|
||||
return failure(ValidationCode::DuplicateHolidayId, i);
|
||||
}
|
||||
if (state.holidays[j].enabled && state.holidays[i].enabled &&
|
||||
state.holidays[j].month == state.holidays[i].month &&
|
||||
state.holidays[j].day == state.holidays[i].day) {
|
||||
return failure(ValidationCode::DuplicateHolidayId, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (std::size_t i = 0; i < state.schedules.size(); ++i) {
|
||||
const ValidationResult result = validateSchedule(state.schedules[i], state, i);
|
||||
if (!result.valid) {
|
||||
return result;
|
||||
}
|
||||
for (std::size_t j = 0; j < i; ++j) {
|
||||
if (state.schedules[j].id == state.schedules[i].id) {
|
||||
return failure(ValidationCode::DuplicateScheduleId, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.profiles.size() != 0 && state.profile(state.activeProfileId) == nullptr) {
|
||||
return failure(ValidationCode::InvalidActiveProfile, 0);
|
||||
}
|
||||
return ValidationResult{};
|
||||
}
|
||||
|
||||
} // namespace timbre_core
|
||||
92
lib/timbre_core/ScheduleValidator.h
Executable file
92
lib/timbre_core/ScheduleValidator.h
Executable file
@@ -0,0 +1,92 @@
|
||||
#pragma once
|
||||
|
||||
#include "Model.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace timbre_core {
|
||||
|
||||
enum class ValidationCode : std::uint8_t {
|
||||
Ok = 0,
|
||||
None = 0,
|
||||
TooManyProfiles,
|
||||
TooManyPatterns,
|
||||
TooManySchedules,
|
||||
TooManyHolidays,
|
||||
InvalidProfileId,
|
||||
DuplicateProfileId,
|
||||
InvalidPatternId,
|
||||
DuplicatePatternId,
|
||||
InvalidPhaseCount,
|
||||
InvalidPhaseDuration,
|
||||
PatternTotalTooLarge,
|
||||
InvalidHolidayId,
|
||||
DuplicateHolidayId,
|
||||
InvalidHolidayDate,
|
||||
InvalidScheduleId,
|
||||
DuplicateScheduleId,
|
||||
InvalidMinute,
|
||||
InvalidWeekdayMask,
|
||||
UnknownProfile,
|
||||
UnknownPattern,
|
||||
InvalidActiveProfile
|
||||
};
|
||||
|
||||
using ValidationError = ValidationCode;
|
||||
|
||||
struct ValidationResult {
|
||||
bool valid = true;
|
||||
ValidationCode code = ValidationCode::Ok;
|
||||
ValidationCode error = ValidationCode::Ok;
|
||||
std::size_t index = 0;
|
||||
|
||||
constexpr ValidationResult() = default;
|
||||
explicit constexpr ValidationResult(ValidationCode resultCode,
|
||||
std::size_t resultIndex = 0) noexcept
|
||||
: valid(resultCode == ValidationCode::Ok),
|
||||
code(resultCode),
|
||||
error(resultCode),
|
||||
index(resultIndex) {}
|
||||
|
||||
constexpr bool ok() const noexcept { return valid; }
|
||||
constexpr bool isValid() const noexcept { return valid; }
|
||||
constexpr explicit operator bool() const noexcept { return valid; }
|
||||
const char* message() const noexcept;
|
||||
};
|
||||
|
||||
class ScheduleValidator {
|
||||
public:
|
||||
static ValidationResult validate(const AppState& state) noexcept;
|
||||
static ValidationResult validateState(const AppState& state) noexcept { return validate(state); }
|
||||
static ValidationResult validateAppState(const AppState& state) noexcept { return validate(state); }
|
||||
static bool valid(const AppState& state) noexcept { return validate(state).valid; }
|
||||
static bool isValid(const AppState& state) noexcept { return valid(state); }
|
||||
|
||||
static ValidationResult validateProfile(const Profile& profile,
|
||||
std::size_t index = 0) noexcept;
|
||||
static ValidationResult validatePattern(const Pattern& pattern,
|
||||
std::size_t index = 0) noexcept;
|
||||
static ValidationResult validateSchedule(const Schedule& schedule,
|
||||
const AppState& state,
|
||||
std::size_t index = 0) noexcept;
|
||||
static ValidationResult validateHoliday(const Holiday& holiday,
|
||||
std::size_t index = 0) noexcept;
|
||||
|
||||
static std::uint16_t patternTotalSeconds(const Pattern& pattern) noexcept {
|
||||
return pattern.totalSeconds();
|
||||
}
|
||||
static bool validPhaseDuration(std::uint8_t value) noexcept {
|
||||
return value <= kMaxPhaseDurationSeconds;
|
||||
}
|
||||
static bool validPatternTotal(std::uint16_t totalSeconds) noexcept {
|
||||
return totalSeconds <= kMaxPatternTotalSeconds;
|
||||
}
|
||||
static bool validWeekdayMask(std::uint8_t value) noexcept {
|
||||
return value != 0 && (value & static_cast<std::uint8_t>(~kAllWeekdaysMask)) == 0;
|
||||
}
|
||||
static bool validMinute(std::uint16_t minute) noexcept { return minute < 1440; }
|
||||
static bool validHolidayDate(std::uint8_t month, std::uint8_t day) noexcept;
|
||||
};
|
||||
|
||||
} // namespace timbre_core
|
||||
123
lib/timbre_core/Scheduler.cpp
Executable file
123
lib/timbre_core/Scheduler.cpp
Executable file
@@ -0,0 +1,123 @@
|
||||
#include "Scheduler.h"
|
||||
|
||||
#include "ScheduleValidator.h"
|
||||
|
||||
#include <limits>
|
||||
|
||||
namespace timbre_core {
|
||||
|
||||
void Scheduler::reset() noexcept {
|
||||
hasLastKey_ = false;
|
||||
lastKey_ = LocalMinuteKey{};
|
||||
}
|
||||
|
||||
bool Scheduler::isHoliday(const AppState& state, std::uint8_t month,
|
||||
std::uint8_t day) noexcept {
|
||||
for (std::size_t i = 0; i < state.holidays.size(); ++i) {
|
||||
const Holiday& holiday = state.holidays[i];
|
||||
if (holiday.enabled && holiday.month == month && holiday.day == day) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool Scheduler::scheduleMatches(const Schedule& schedule, const AppState& state,
|
||||
const CivilDateTime& localTime) noexcept {
|
||||
if (!schedule.enabled || schedule.minuteOfDay != localTime.minuteOfDay()) {
|
||||
return false;
|
||||
}
|
||||
const Profile* activeProfile = state.profile(state.activeProfileId);
|
||||
if (activeProfile == nullptr || !activeProfile->enabled ||
|
||||
schedule.profileId != state.activeProfileId) {
|
||||
return false;
|
||||
}
|
||||
const std::uint8_t weekday = CivilTime::weekdayForDate(localTime.year, localTime.month,
|
||||
localTime.day);
|
||||
if ((schedule.weekdayMask & static_cast<std::uint8_t>(1u << weekday)) == 0) {
|
||||
return false;
|
||||
}
|
||||
if (isHoliday(state, localTime.month, localTime.day)) {
|
||||
return false;
|
||||
}
|
||||
const Pattern* pattern = state.pattern(schedule.patternId);
|
||||
return pattern != nullptr && isPatternId(schedule.patternId) &&
|
||||
ScheduleValidator::validatePattern(*pattern).valid;
|
||||
}
|
||||
|
||||
ScheduleDecision Scheduler::poll(const AppState& state,
|
||||
const CivilDateTime& localTime,
|
||||
bool firstObservation) {
|
||||
ScheduleDecision decision;
|
||||
decision.firstObservation = firstObservation;
|
||||
|
||||
if (!localTime.valid()) {
|
||||
decision.invalidTime = true;
|
||||
return decision;
|
||||
}
|
||||
|
||||
const LocalMinuteKey currentKey = LocalMinuteKey::from(localTime);
|
||||
const std::int64_t currentSerial = currentKey.serial();
|
||||
if (currentSerial == std::numeric_limits<std::int64_t>::min()) {
|
||||
decision.invalidTime = true;
|
||||
return decision;
|
||||
}
|
||||
|
||||
if (hasLastKey_) {
|
||||
const std::int64_t previousSerial = lastKey_.serial();
|
||||
if (currentSerial <= previousSerial) {
|
||||
decision.alreadyProcessed = true;
|
||||
return decision;
|
||||
}
|
||||
const std::int64_t distance = currentSerial - previousSerial;
|
||||
decision.missedMinutes = distance > 1 ? static_cast<std::uint64_t>(distance - 1) : 0;
|
||||
}
|
||||
|
||||
// Consume the current key before evaluating matches. Therefore a failed
|
||||
// lookup (holiday, disabled profile, etc.) cannot be replayed later.
|
||||
lastKey_ = currentKey;
|
||||
hasLastKey_ = true;
|
||||
|
||||
if (firstObservation) {
|
||||
return decision;
|
||||
}
|
||||
|
||||
if (isHoliday(state, localTime.month, localTime.day)) {
|
||||
decision.blockedByHoliday = true;
|
||||
return decision;
|
||||
}
|
||||
|
||||
const Schedule* selected = nullptr;
|
||||
for (std::size_t i = 0; i < state.schedules.size(); ++i) {
|
||||
const Schedule& candidate = state.schedules[i];
|
||||
if (!scheduleMatches(candidate, state, localTime)) {
|
||||
continue;
|
||||
}
|
||||
// Stable IDs, rather than array positions, define deterministic
|
||||
// precedence when two schedules intentionally share a minute.
|
||||
if (selected == nullptr || candidate.id < selected->id) {
|
||||
selected = &candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (selected != nullptr) {
|
||||
decision.due = true;
|
||||
decision.scheduleId = selected->id;
|
||||
decision.patternId = selected->patternId;
|
||||
}
|
||||
return decision;
|
||||
}
|
||||
|
||||
ScheduleDecision Scheduler::poll(const AppState& state,
|
||||
SessionClock& clock,
|
||||
std::int64_t utcEpochSeconds) {
|
||||
const SessionClock::Observation observation = clock.observe(utcEpochSeconds);
|
||||
if (!observation.valid) {
|
||||
ScheduleDecision decision;
|
||||
decision.invalidTime = true;
|
||||
return decision;
|
||||
}
|
||||
return poll(state, observation.local, observation.firstObservation);
|
||||
}
|
||||
|
||||
} // namespace timbre_core
|
||||
90
lib/timbre_core/Scheduler.h
Executable file
90
lib/timbre_core/Scheduler.h
Executable file
@@ -0,0 +1,90 @@
|
||||
#pragma once
|
||||
|
||||
#include "CivilTime.h"
|
||||
#include "Model.h"
|
||||
#include "SessionClock.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace timbre_core {
|
||||
|
||||
struct ScheduleDecision {
|
||||
bool due = false;
|
||||
bool firstObservation = false;
|
||||
bool alreadyProcessed = false;
|
||||
bool blockedByHoliday = false;
|
||||
bool invalidTime = false;
|
||||
ScheduleId scheduleId = kInvalidId;
|
||||
PatternId patternId = PatternId::A;
|
||||
std::uint64_t missedMinutes = 0;
|
||||
|
||||
constexpr explicit operator bool() const noexcept { return due; }
|
||||
constexpr bool triggered() const noexcept { return due; }
|
||||
};
|
||||
|
||||
using ScheduleMatch = ScheduleDecision;
|
||||
|
||||
class Scheduler {
|
||||
public:
|
||||
Scheduler() = default;
|
||||
|
||||
// Only the currently observed local minute is considered. If the source
|
||||
// jumped from minute N to N+K, the intermediate minutes are intentionally
|
||||
// discarded rather than replayed.
|
||||
ScheduleDecision poll(const AppState& state,
|
||||
const CivilDateTime& localTime,
|
||||
bool firstObservation = false);
|
||||
ScheduleDecision process(const AppState& state,
|
||||
const CivilDateTime& localTime,
|
||||
bool firstObservation = false) {
|
||||
return poll(state, localTime, firstObservation);
|
||||
}
|
||||
|
||||
// Convenience integration with SessionClock. The caller supplies the
|
||||
// current wall value; the injected monotonic source remains authoritative
|
||||
// inside SessionClock.
|
||||
ScheduleDecision poll(const AppState& state,
|
||||
SessionClock& clock,
|
||||
std::int64_t utcEpochSeconds);
|
||||
ScheduleDecision poll(const AppState& state,
|
||||
const SessionClock::Observation& observation) {
|
||||
if (!observation.valid) {
|
||||
ScheduleDecision decision;
|
||||
decision.invalidTime = true;
|
||||
return decision;
|
||||
}
|
||||
return poll(state, observation.local, observation.firstObservation);
|
||||
}
|
||||
ScheduleDecision process(const AppState& state,
|
||||
SessionClock& clock,
|
||||
std::int64_t utcEpochSeconds) {
|
||||
return poll(state, clock, utcEpochSeconds);
|
||||
}
|
||||
ScheduleDecision tick(const AppState& state,
|
||||
const CivilDateTime& localTime,
|
||||
bool firstObservation = false) {
|
||||
return poll(state, localTime, firstObservation);
|
||||
}
|
||||
ScheduleDecision evaluate(const AppState& state,
|
||||
const CivilDateTime& localTime,
|
||||
bool firstObservation = false) {
|
||||
return poll(state, localTime, firstObservation);
|
||||
}
|
||||
|
||||
bool hasLastKey() const noexcept { return hasLastKey_; }
|
||||
const LocalMinuteKey& lastKey() const noexcept { return lastKey_; }
|
||||
const LocalMinuteKey& key() const noexcept { return lastKey_; }
|
||||
void reset() noexcept;
|
||||
|
||||
static bool isHoliday(const AppState& state, std::uint8_t month,
|
||||
std::uint8_t day) noexcept;
|
||||
static bool scheduleMatches(const Schedule& schedule,
|
||||
const AppState& state,
|
||||
const CivilDateTime& localTime) noexcept;
|
||||
|
||||
private:
|
||||
bool hasLastKey_ = false;
|
||||
LocalMinuteKey lastKey_{};
|
||||
};
|
||||
|
||||
} // namespace timbre_core
|
||||
134
lib/timbre_core/SessionClock.cpp
Executable file
134
lib/timbre_core/SessionClock.cpp
Executable file
@@ -0,0 +1,134 @@
|
||||
#include "SessionClock.h"
|
||||
|
||||
#include <limits>
|
||||
#include <utility>
|
||||
|
||||
namespace timbre_core {
|
||||
|
||||
std::uint64_t SessionClock::readMonotonic() const {
|
||||
if (clock_ != nullptr) {
|
||||
return clock_->nowMs();
|
||||
}
|
||||
if (callback_) {
|
||||
return callback_();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool SessionClock::setObservation(std::int64_t wallSeconds, std::uint64_t monotonicMs,
|
||||
bool first, bool regression, bool resynchronized) {
|
||||
Observation observation;
|
||||
observation.valid = CivilTime::fromUnixSeconds(wallSeconds, observation.local);
|
||||
observation.firstObservation = first;
|
||||
observation.monotonicRegression = regression;
|
||||
observation.resynchronized = resynchronized;
|
||||
observation.monotonicMs = monotonicMs;
|
||||
observation.utcEpochSeconds = wallSeconds;
|
||||
if (!observation.valid) {
|
||||
lastObservation_ = observation;
|
||||
return false;
|
||||
}
|
||||
|
||||
initialized_ = true;
|
||||
if (first) {
|
||||
anchorMonotonicMs_ = monotonicMs;
|
||||
anchorWallSeconds_ = wallSeconds;
|
||||
}
|
||||
lastMonotonicMs_ = monotonicMs;
|
||||
lastWallSeconds_ = wallSeconds;
|
||||
lastLocal_ = observation.local;
|
||||
lastObservation_ = observation;
|
||||
return true;
|
||||
}
|
||||
|
||||
SessionClock::Observation SessionClock::observeAt(std::int64_t utcEpochSeconds,
|
||||
std::uint64_t monotonicMs) {
|
||||
if (!initialized_) {
|
||||
(void)setObservation(utcEpochSeconds, monotonicMs, true, false, false);
|
||||
return lastObservation_;
|
||||
}
|
||||
|
||||
if (monotonicMs < lastMonotonicMs_) {
|
||||
// A monotonic source must never run backwards. Keep the last good
|
||||
// anchor and reject the sample rather than manufacturing elapsed time.
|
||||
Observation observation = lastObservation_;
|
||||
observation.valid = false;
|
||||
observation.firstObservation = false;
|
||||
observation.monotonicRegression = true;
|
||||
observation.resynchronized = false;
|
||||
observation.monotonicMs = monotonicMs;
|
||||
lastObservation_ = observation;
|
||||
return observation;
|
||||
}
|
||||
|
||||
const std::uint64_t delta = monotonicMs - lastMonotonicMs_;
|
||||
const std::int64_t predicted = lastWallSeconds_ +
|
||||
static_cast<std::int64_t>(delta / 1000ULL);
|
||||
// A wall-clock correction is resynchronized at the current sample, never
|
||||
// expanded into a stream of historical minutes.
|
||||
const std::uint64_t difference = predicted >= utcEpochSeconds
|
||||
? static_cast<std::uint64_t>(predicted - utcEpochSeconds)
|
||||
: static_cast<std::uint64_t>(utcEpochSeconds - predicted);
|
||||
const bool resynchronized = difference > 1ULL;
|
||||
(void)setObservation(utcEpochSeconds, monotonicMs, false, false, resynchronized);
|
||||
return lastObservation_;
|
||||
}
|
||||
|
||||
SessionClock::Observation SessionClock::observe(std::int64_t utcEpochSeconds) {
|
||||
return observeAt(utcEpochSeconds, readMonotonic());
|
||||
}
|
||||
|
||||
SessionClock::Observation SessionClock::tick() {
|
||||
if (!initialized_) {
|
||||
return Observation{};
|
||||
}
|
||||
const std::uint64_t sample = readMonotonic();
|
||||
if (sample < lastMonotonicMs_) {
|
||||
Observation observation = lastObservation_;
|
||||
observation.valid = false;
|
||||
observation.firstObservation = false;
|
||||
observation.monotonicRegression = true;
|
||||
observation.resynchronized = false;
|
||||
observation.monotonicMs = sample;
|
||||
lastObservation_ = observation;
|
||||
return observation;
|
||||
}
|
||||
const std::uint64_t delta = sample - lastMonotonicMs_;
|
||||
// Do not use a platform wall clock and do not add a whole missed interval
|
||||
// to a queue. This is the monotonic, bounded session-time path.
|
||||
const std::int64_t wall = lastWallSeconds_ + static_cast<std::int64_t>(delta / 1000ULL);
|
||||
(void)setObservation(wall, sample, false, false, false);
|
||||
return lastObservation_;
|
||||
}
|
||||
|
||||
std::uint64_t SessionClock::monotonicNowMs() const {
|
||||
return readMonotonic();
|
||||
}
|
||||
|
||||
std::uint64_t SessionClock::elapsedMs() const noexcept {
|
||||
if (!initialized_) {
|
||||
return 0;
|
||||
}
|
||||
const std::uint64_t sample = monotonicNowMs();
|
||||
return sample < lastMonotonicMs_ ? 0 : sample - lastMonotonicMs_;
|
||||
}
|
||||
|
||||
std::uint64_t SessionClock::sessionElapsedMs() const noexcept {
|
||||
if (!initialized_) {
|
||||
return 0;
|
||||
}
|
||||
const std::uint64_t sample = monotonicNowMs();
|
||||
return sample < anchorMonotonicMs_ ? 0 : sample - anchorMonotonicMs_;
|
||||
}
|
||||
|
||||
void SessionClock::reset() noexcept {
|
||||
initialized_ = false;
|
||||
anchorMonotonicMs_ = 0;
|
||||
lastMonotonicMs_ = 0;
|
||||
anchorWallSeconds_ = 0;
|
||||
lastWallSeconds_ = 0;
|
||||
lastLocal_ = CivilDateTime{};
|
||||
lastObservation_ = Observation{};
|
||||
}
|
||||
|
||||
} // namespace timbre_core
|
||||
91
lib/timbre_core/SessionClock.h
Executable file
91
lib/timbre_core/SessionClock.h
Executable file
@@ -0,0 +1,91 @@
|
||||
#pragma once
|
||||
|
||||
#include "CivilTime.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <utility>
|
||||
|
||||
namespace timbre_core {
|
||||
|
||||
// Hardware integration supplies this interface. The core never calls a
|
||||
// platform-specific tick function itself.
|
||||
class MonotonicClock {
|
||||
public:
|
||||
virtual ~MonotonicClock() = default;
|
||||
virtual std::uint64_t nowMs() const = 0;
|
||||
};
|
||||
|
||||
class SessionClock {
|
||||
public:
|
||||
using Callback = std::function<std::uint64_t()>;
|
||||
|
||||
struct Observation {
|
||||
bool valid = false;
|
||||
bool firstObservation = false;
|
||||
bool monotonicRegression = false;
|
||||
bool resynchronized = false;
|
||||
std::uint64_t monotonicMs = 0;
|
||||
std::int64_t utcEpochSeconds = 0;
|
||||
CivilDateTime local{};
|
||||
|
||||
constexpr operator bool() const noexcept { return valid; }
|
||||
constexpr bool isFirstObservation() const noexcept { return firstObservation; }
|
||||
};
|
||||
|
||||
explicit SessionClock(Callback callback) : callback_(std::move(callback)) {}
|
||||
explicit SessionClock(MonotonicClock& clock) : clock_(&clock) {}
|
||||
SessionClock(const SessionClock&) = delete;
|
||||
SessionClock& operator=(const SessionClock&) = delete;
|
||||
|
||||
// The first wall-clock observation establishes an anchor and returns
|
||||
// firstObservation=true. It never attempts to replay older wall time.
|
||||
Observation observe(std::int64_t utcEpochSeconds);
|
||||
Observation poll(std::int64_t utcEpochSeconds) { return observe(utcEpochSeconds); }
|
||||
Observation sync(std::int64_t utcEpochSeconds) { return observe(utcEpochSeconds); }
|
||||
Observation update(std::int64_t utcEpochSeconds) { return observe(utcEpochSeconds); }
|
||||
|
||||
// Deterministic injection for tests and for a caller that already has a
|
||||
// monotonic sample. It follows exactly the same first-observation rule.
|
||||
Observation observeAt(std::int64_t utcEpochSeconds, std::uint64_t monotonicMs);
|
||||
|
||||
// Advance only from the injected monotonic source. No wall-clock catch-up
|
||||
// is performed, so a sleep or a clock correction cannot create old events.
|
||||
Observation tick();
|
||||
Observation update() { return tick(); }
|
||||
|
||||
bool initialized() const noexcept { return initialized_; }
|
||||
bool hasObservation() const noexcept { return initialized_; }
|
||||
bool hasFirstObservation() const noexcept { return initialized_; }
|
||||
bool lastWasFirstObservation() const noexcept { return lastObservation_.firstObservation; }
|
||||
std::uint64_t monotonicNowMs() const;
|
||||
std::uint64_t now() const { return monotonicNowMs(); }
|
||||
std::uint64_t elapsedMs() const noexcept;
|
||||
std::uint64_t sessionElapsedMs() const noexcept;
|
||||
std::uint64_t elapsedSinceAnchorMs() const noexcept { return sessionElapsedMs(); }
|
||||
std::int64_t utcEpochSeconds() const noexcept { return lastWallSeconds_; }
|
||||
std::int64_t wallEpochSeconds() const noexcept { return lastWallSeconds_; }
|
||||
std::int64_t anchorUtcEpochSeconds() const noexcept { return anchorWallSeconds_; }
|
||||
const CivilDateTime& localTime() const noexcept { return lastLocal_; }
|
||||
const CivilDateTime& civilTime() const noexcept { return lastLocal_; }
|
||||
const Observation& lastObservation() const noexcept { return lastObservation_; }
|
||||
|
||||
void reset() noexcept;
|
||||
|
||||
private:
|
||||
std::uint64_t readMonotonic() const;
|
||||
bool setObservation(std::int64_t wallSeconds, std::uint64_t monotonicMs,
|
||||
bool first, bool regression, bool resynchronized);
|
||||
|
||||
Callback callback_;
|
||||
MonotonicClock* clock_ = nullptr;
|
||||
bool initialized_ = false;
|
||||
std::uint64_t anchorMonotonicMs_ = 0;
|
||||
std::uint64_t lastMonotonicMs_ = 0;
|
||||
std::int64_t anchorWallSeconds_ = 0;
|
||||
std::int64_t lastWallSeconds_ = 0;
|
||||
CivilDateTime lastLocal_{};
|
||||
Observation lastObservation_{};
|
||||
};
|
||||
|
||||
} // namespace timbre_core
|
||||
75
lib/timbre_core/StateRepository.h
Executable file
75
lib/timbre_core/StateRepository.h
Executable file
@@ -0,0 +1,75 @@
|
||||
#pragma once
|
||||
|
||||
#include "Model.h"
|
||||
|
||||
namespace timbre_core {
|
||||
|
||||
// Persistence is represented only by this small interface. Implementations
|
||||
// may use a file system on the final product, but the domain layer remains
|
||||
// portable and has no filesystem dependency.
|
||||
class StateRepository {
|
||||
public:
|
||||
virtual ~StateRepository() = default;
|
||||
|
||||
// Canonical names used by TimbreService.
|
||||
virtual bool load(AppState& destination) { return read(destination); }
|
||||
virtual bool commit(const AppState& source) { return save(source); }
|
||||
|
||||
// Compatibility hooks for small repositories that naturally use read/write
|
||||
// terminology. Overriding either pair is sufficient; the defaults above
|
||||
// bridge the two naming conventions without recursion.
|
||||
virtual bool read(AppState& destination) {
|
||||
(void)destination;
|
||||
return false;
|
||||
}
|
||||
virtual bool write(const AppState& source) {
|
||||
(void)source;
|
||||
return false;
|
||||
}
|
||||
|
||||
virtual bool save(const AppState& source) { return write(source); }
|
||||
bool loadState(AppState& destination) { return load(destination); }
|
||||
bool commitState(const AppState& source) { return commit(source); }
|
||||
};
|
||||
|
||||
class MemoryStateRepository final : public StateRepository {
|
||||
public:
|
||||
bool load(AppState& destination) override {
|
||||
if (!loadSucceeds_ || !hasValue_) {
|
||||
return false;
|
||||
}
|
||||
destination = value_;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool commit(const AppState& source) override {
|
||||
++commitAttempts_;
|
||||
if (!commitSucceeds_) {
|
||||
return false;
|
||||
}
|
||||
value_ = source;
|
||||
hasValue_ = true;
|
||||
++commitCount_;
|
||||
return true;
|
||||
}
|
||||
|
||||
void seed(const AppState& state) {
|
||||
value_ = state;
|
||||
hasValue_ = true;
|
||||
}
|
||||
|
||||
void setCommitSucceeds(bool succeeds) noexcept { commitSucceeds_ = succeeds; }
|
||||
void setLoadSucceeds(bool succeeds) noexcept { loadSucceeds_ = succeeds; }
|
||||
std::size_t commitAttempts() const noexcept { return commitAttempts_; }
|
||||
std::size_t commitCount() const noexcept { return commitCount_; }
|
||||
|
||||
private:
|
||||
AppState value_{};
|
||||
bool hasValue_ = false;
|
||||
bool loadSucceeds_ = true;
|
||||
bool commitSucceeds_ = true;
|
||||
std::size_t commitAttempts_ = 0;
|
||||
std::size_t commitCount_ = 0;
|
||||
};
|
||||
|
||||
} // namespace timbre_core
|
||||
10
lib/timbre_core/TimbreCore.h
Executable file
10
lib/timbre_core/TimbreCore.h
Executable file
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
|
||||
#include "Model.h"
|
||||
#include "CivilTime.h"
|
||||
#include "SessionClock.h"
|
||||
#include "ScheduleValidator.h"
|
||||
#include "Scheduler.h"
|
||||
#include "PatternEngine.h"
|
||||
#include "StateRepository.h"
|
||||
#include "TimbreService.h"
|
||||
305
lib/timbre_core/TimbreService.cpp
Executable file
305
lib/timbre_core/TimbreService.cpp
Executable file
@@ -0,0 +1,305 @@
|
||||
#include "TimbreService.h"
|
||||
|
||||
#include <limits>
|
||||
|
||||
namespace timbre_core {
|
||||
namespace {
|
||||
|
||||
bool containsProfile(const AppState& state, ProfileId id) noexcept {
|
||||
return state.profile(id) != nullptr;
|
||||
}
|
||||
|
||||
bool containsSchedule(const AppState& state, ScheduleId id) noexcept {
|
||||
return state.schedule(id) != nullptr;
|
||||
}
|
||||
|
||||
bool containsHoliday(const AppState& state, HolidayId id) noexcept {
|
||||
return state.holiday(id) != nullptr;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool TimbreService::fail(ServiceCode code, const ValidationResult& validation) {
|
||||
lastResult_ = ServiceResult{false, code, validation};
|
||||
return false;
|
||||
}
|
||||
|
||||
void TimbreService::observeIds(const AppState& state) noexcept {
|
||||
nextProfileId_.reset();
|
||||
nextScheduleId_.reset();
|
||||
nextHolidayId_.reset();
|
||||
for (std::size_t i = 0; i < state.profiles.size(); ++i) {
|
||||
nextProfileId_.observe(state.profiles[i].id);
|
||||
}
|
||||
for (std::size_t i = 0; i < state.schedules.size(); ++i) {
|
||||
nextScheduleId_.observe(state.schedules[i].id);
|
||||
}
|
||||
for (std::size_t i = 0; i < state.holidays.size(); ++i) {
|
||||
nextHolidayId_.observe(state.holidays[i].id);
|
||||
}
|
||||
}
|
||||
|
||||
Id TimbreService::nextAvailableProfileId(const AppState& state) {
|
||||
for (;;) {
|
||||
const Id id = nextProfileId_.next();
|
||||
if (id != kInvalidId && !containsProfile(state, id)) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Id TimbreService::nextAvailableScheduleId(const AppState& state) {
|
||||
for (;;) {
|
||||
const Id id = nextScheduleId_.next();
|
||||
if (id != kInvalidId && !containsSchedule(state, id)) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Id TimbreService::nextAvailableHolidayId(const AppState& state) {
|
||||
for (;;) {
|
||||
const Id id = nextHolidayId_.next();
|
||||
if (id != kInvalidId && !containsHoliday(state, id)) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool TimbreService::load() {
|
||||
AppState candidate;
|
||||
if (repository_ == nullptr || !repository_->load(candidate)) {
|
||||
return fail(ServiceCode::LoadFailed, ValidationResult{});
|
||||
}
|
||||
const ValidationResult validation = ScheduleValidator::validate(candidate);
|
||||
if (!validation.valid) {
|
||||
return fail(ServiceCode::InvalidRepositoryState, validation);
|
||||
}
|
||||
ram_ = candidate;
|
||||
observeIds(ram_);
|
||||
loaded_ = true;
|
||||
lastResult_ = ServiceResult{true, ServiceCode::Ok, ValidationResult{}};
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TimbreService::commitCurrent() {
|
||||
const ValidationResult validation = ScheduleValidator::validate(ram_);
|
||||
if (!validation.valid) {
|
||||
return fail(ServiceCode::ValidationFailed, validation);
|
||||
}
|
||||
if (repository_ == nullptr || !repository_->commit(ram_)) {
|
||||
return fail(ServiceCode::CommitFailed, ValidationResult{});
|
||||
}
|
||||
lastResult_ = ServiceResult{true, ServiceCode::Ok, ValidationResult{}};
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TimbreService::addProfile(const Profile& profile) {
|
||||
if (ram_.profiles.full()) {
|
||||
return fail(ServiceCode::LimitExceeded, ValidationResult{});
|
||||
}
|
||||
const ProfileId id = profile.id == kInvalidId ? nextAvailableProfileId(ram_) : profile.id;
|
||||
if (containsProfile(ram_, id)) {
|
||||
return fail(ServiceCode::DuplicateId, ValidationResult{});
|
||||
}
|
||||
Profile copy = profile;
|
||||
copy.id = id;
|
||||
const StableIdAllocator savedAllocator = nextProfileId_;
|
||||
const bool committed = transact([©](AppState& candidate) {
|
||||
if (!candidate.profiles.push_back(copy)) {
|
||||
return false;
|
||||
}
|
||||
if (candidate.profiles.size() == 1) {
|
||||
candidate.activeProfileId = copy.id;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (!committed) {
|
||||
nextProfileId_ = savedAllocator;
|
||||
}
|
||||
return committed;
|
||||
}
|
||||
|
||||
bool TimbreService::updateProfile(ProfileId id, const Profile& profile) {
|
||||
if (!containsProfile(ram_, id)) {
|
||||
return fail(ServiceCode::NotFound, ValidationResult{});
|
||||
}
|
||||
Profile copy = profile;
|
||||
copy.id = id;
|
||||
return transact([©](AppState& candidate) {
|
||||
Profile* target = candidate.profile(copy.id);
|
||||
if (target == nullptr) {
|
||||
return false;
|
||||
}
|
||||
*target = copy;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
bool TimbreService::removeProfile(ProfileId id) {
|
||||
if (!containsProfile(ram_, id)) {
|
||||
return fail(ServiceCode::NotFound, ValidationResult{});
|
||||
}
|
||||
return transact([id](AppState& candidate) {
|
||||
std::size_t index = 0;
|
||||
for (; index < candidate.profiles.size(); ++index) {
|
||||
if (candidate.profiles[index].id == id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (index >= candidate.profiles.size() || !candidate.profiles.erase(index)) {
|
||||
return false;
|
||||
}
|
||||
if (candidate.activeProfileId == id) {
|
||||
candidate.activeProfileId = candidate.profiles.empty()
|
||||
? 0
|
||||
: candidate.profiles[0].id;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
bool TimbreService::addSchedule(const Schedule& schedule) {
|
||||
if (ram_.schedules.full()) {
|
||||
return fail(ServiceCode::LimitExceeded, ValidationResult{});
|
||||
}
|
||||
const ScheduleId id = schedule.id == kInvalidId
|
||||
? nextAvailableScheduleId(ram_)
|
||||
: schedule.id;
|
||||
if (containsSchedule(ram_, id)) {
|
||||
return fail(ServiceCode::DuplicateId, ValidationResult{});
|
||||
}
|
||||
Schedule copy = schedule;
|
||||
copy.id = id;
|
||||
const StableIdAllocator savedAllocator = nextScheduleId_;
|
||||
const bool committed = transact([©](AppState& candidate) {
|
||||
if (!candidate.schedules.push_back(copy)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (!committed) {
|
||||
nextScheduleId_ = savedAllocator;
|
||||
}
|
||||
return committed;
|
||||
}
|
||||
|
||||
bool TimbreService::updateSchedule(ScheduleId id, const Schedule& schedule) {
|
||||
if (!containsSchedule(ram_, id)) {
|
||||
return fail(ServiceCode::NotFound, ValidationResult{});
|
||||
}
|
||||
Schedule copy = schedule;
|
||||
copy.id = id;
|
||||
return transact([©](AppState& candidate) {
|
||||
Schedule* target = candidate.schedule(copy.id);
|
||||
if (target == nullptr) {
|
||||
return false;
|
||||
}
|
||||
*target = copy;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
bool TimbreService::removeSchedule(ScheduleId id) {
|
||||
if (!containsSchedule(ram_, id)) {
|
||||
return fail(ServiceCode::NotFound, ValidationResult{});
|
||||
}
|
||||
return transact([id](AppState& candidate) {
|
||||
for (std::size_t i = 0; i < candidate.schedules.size(); ++i) {
|
||||
if (candidate.schedules[i].id == id) {
|
||||
return candidate.schedules.erase(i);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
bool TimbreService::addHoliday(const Holiday& holiday) {
|
||||
if (ram_.holidays.full()) {
|
||||
return fail(ServiceCode::LimitExceeded, ValidationResult{});
|
||||
}
|
||||
const HolidayId id = holiday.id == kInvalidId
|
||||
? nextAvailableHolidayId(ram_)
|
||||
: holiday.id;
|
||||
if (containsHoliday(ram_, id)) {
|
||||
return fail(ServiceCode::DuplicateId, ValidationResult{});
|
||||
}
|
||||
Holiday copy = holiday;
|
||||
copy.id = id;
|
||||
const StableIdAllocator savedAllocator = nextHolidayId_;
|
||||
const bool committed = transact([©](AppState& candidate) {
|
||||
if (!candidate.holidays.push_back(copy)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (!committed) {
|
||||
nextHolidayId_ = savedAllocator;
|
||||
}
|
||||
return committed;
|
||||
}
|
||||
|
||||
bool TimbreService::updateHoliday(HolidayId id, const Holiday& holiday) {
|
||||
if (!containsHoliday(ram_, id)) {
|
||||
return fail(ServiceCode::NotFound, ValidationResult{});
|
||||
}
|
||||
Holiday copy = holiday;
|
||||
copy.id = id;
|
||||
return transact([©](AppState& candidate) {
|
||||
Holiday* target = candidate.holiday(copy.id);
|
||||
if (target == nullptr) {
|
||||
return false;
|
||||
}
|
||||
*target = copy;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
bool TimbreService::removeHoliday(HolidayId id) {
|
||||
if (!containsHoliday(ram_, id)) {
|
||||
return fail(ServiceCode::NotFound, ValidationResult{});
|
||||
}
|
||||
return transact([id](AppState& candidate) {
|
||||
for (std::size_t i = 0; i < candidate.holidays.size(); ++i) {
|
||||
if (candidate.holidays[i].id == id) {
|
||||
return candidate.holidays.erase(i);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
bool TimbreService::setActiveProfile(ProfileId id) {
|
||||
if (!containsProfile(ram_, id)) {
|
||||
return fail(ServiceCode::NotFound, ValidationResult{});
|
||||
}
|
||||
return transact([id](AppState& candidate) {
|
||||
candidate.activeProfileId = id;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
bool TimbreService::setPattern(PatternId patternIdentifier, const Pattern& pattern) {
|
||||
if (!isPatternId(patternIdentifier) ||
|
||||
patternIdentifier != pattern.id ||
|
||||
!ScheduleValidator::validatePattern(pattern).valid) {
|
||||
return fail(ServiceCode::ValidationFailed, ValidationResult{});
|
||||
}
|
||||
return transact([patternIdentifier, pattern](AppState& candidate) {
|
||||
const std::size_t index = patternIndex(patternIdentifier);
|
||||
if (index >= candidate.patterns.size()) {
|
||||
return false;
|
||||
}
|
||||
candidate.patterns[index] = pattern;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
bool TimbreService::setPatterns(const std::array<Pattern, kMaxPatterns>& patterns) {
|
||||
return transact([&patterns](AppState& candidate) {
|
||||
candidate.patterns = patterns;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace timbre_core
|
||||
136
lib/timbre_core/TimbreService.h
Executable file
136
lib/timbre_core/TimbreService.h
Executable file
@@ -0,0 +1,136 @@
|
||||
#pragma once
|
||||
|
||||
#include "ScheduleValidator.h"
|
||||
#include "StateRepository.h"
|
||||
|
||||
#include <limits>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
|
||||
namespace timbre_core {
|
||||
|
||||
enum class ServiceCode : std::uint8_t {
|
||||
Ok = 0,
|
||||
NotLoaded,
|
||||
LoadFailed,
|
||||
InvalidRepositoryState,
|
||||
MutationRejected,
|
||||
ValidationFailed,
|
||||
CommitFailed,
|
||||
NotFound,
|
||||
LimitExceeded,
|
||||
DuplicateId
|
||||
};
|
||||
|
||||
struct ServiceResult {
|
||||
bool ok = true;
|
||||
ServiceCode code = ServiceCode::Ok;
|
||||
ValidationResult validation{};
|
||||
|
||||
constexpr bool succeeded() const noexcept { return ok; }
|
||||
constexpr explicit operator bool() const noexcept { return ok; }
|
||||
};
|
||||
|
||||
class TimbreService {
|
||||
public:
|
||||
explicit TimbreService(StateRepository& repository) : repository_(&repository) {}
|
||||
|
||||
bool load();
|
||||
bool loaded() const noexcept { return loaded_; }
|
||||
const AppState& state() const noexcept { return ram_; }
|
||||
const AppState& ram() const noexcept { return ram_; }
|
||||
const ServiceResult& lastResult() const noexcept { return lastResult_; }
|
||||
ServiceCode lastError() const noexcept { return lastResult_.code; }
|
||||
|
||||
// Copy, mutate, validate, persist, and only then publish to ram_. The
|
||||
// lambda may return bool (false rejects the transaction) or void.
|
||||
template <typename Mutator>
|
||||
bool transact(Mutator&& mutator) {
|
||||
AppState candidate = ram_;
|
||||
bool accepted = true;
|
||||
if constexpr (std::is_void_v<std::invoke_result_t<Mutator, AppState&>>) {
|
||||
std::forward<Mutator>(mutator)(candidate);
|
||||
} else {
|
||||
accepted = static_cast<bool>(
|
||||
std::forward<Mutator>(mutator)(candidate));
|
||||
}
|
||||
if (!accepted) {
|
||||
return fail(ServiceCode::MutationRejected, ValidationResult{});
|
||||
}
|
||||
|
||||
if (candidate.revision == std::numeric_limits<std::uint32_t>::max()) {
|
||||
candidate.revision = 0;
|
||||
} else {
|
||||
++candidate.revision;
|
||||
}
|
||||
const ValidationResult validation = ScheduleValidator::validate(candidate);
|
||||
if (!validation.valid) {
|
||||
return fail(ServiceCode::ValidationFailed, validation);
|
||||
}
|
||||
if (!repository_->commit(candidate)) {
|
||||
return fail(ServiceCode::CommitFailed, ValidationResult{});
|
||||
}
|
||||
|
||||
// This is the only assignment to the live model in a transaction.
|
||||
ram_ = candidate;
|
||||
loaded_ = true;
|
||||
lastResult_ = ServiceResult{true, ServiceCode::Ok, ValidationResult{}};
|
||||
return true;
|
||||
}
|
||||
|
||||
template <typename Mutator>
|
||||
bool mutate(Mutator&& mutator) {
|
||||
return transact(std::forward<Mutator>(mutator));
|
||||
}
|
||||
|
||||
template <typename Mutator>
|
||||
bool apply(Mutator&& mutator) {
|
||||
return transact(std::forward<Mutator>(mutator));
|
||||
}
|
||||
|
||||
bool replace(const AppState& replacement) {
|
||||
return transact([&replacement](AppState& candidate) {
|
||||
candidate = replacement;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
bool commitCurrent();
|
||||
bool commit() { return commitCurrent(); }
|
||||
|
||||
bool addProfile(const Profile& profile);
|
||||
bool updateProfile(ProfileId id, const Profile& profile);
|
||||
bool removeProfile(ProfileId id);
|
||||
bool deleteProfile(ProfileId id) { return removeProfile(id); }
|
||||
|
||||
bool addSchedule(const Schedule& schedule);
|
||||
bool updateSchedule(ScheduleId id, const Schedule& schedule);
|
||||
bool removeSchedule(ScheduleId id);
|
||||
bool deleteSchedule(ScheduleId id) { return removeSchedule(id); }
|
||||
|
||||
bool addHoliday(const Holiday& holiday);
|
||||
bool updateHoliday(HolidayId id, const Holiday& holiday);
|
||||
bool removeHoliday(HolidayId id);
|
||||
bool deleteHoliday(HolidayId id) { return removeHoliday(id); }
|
||||
|
||||
bool setActiveProfile(ProfileId id);
|
||||
bool setPattern(PatternId id, const Pattern& pattern);
|
||||
bool setPatterns(const std::array<Pattern, kMaxPatterns>& patterns);
|
||||
|
||||
private:
|
||||
bool fail(ServiceCode code, const ValidationResult& validation);
|
||||
void observeIds(const AppState& state) noexcept;
|
||||
Id nextAvailableProfileId(const AppState& state);
|
||||
Id nextAvailableScheduleId(const AppState& state);
|
||||
Id nextAvailableHolidayId(const AppState& state);
|
||||
|
||||
StateRepository* repository_ = nullptr;
|
||||
AppState ram_{};
|
||||
StableIdAllocator nextProfileId_{};
|
||||
StableIdAllocator nextScheduleId_{};
|
||||
StableIdAllocator nextHolidayId_{};
|
||||
bool loaded_ = false;
|
||||
ServiceResult lastResult_{};
|
||||
};
|
||||
|
||||
} // namespace timbre_core
|
||||
13
lib/timbre_core/library.json
Executable file
13
lib/timbre_core/library.json
Executable file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "timbre_core",
|
||||
"version": "0.1.0",
|
||||
"description": "Portable, bounded domain core for the TimbreESP project",
|
||||
"keywords": "timbre, esp32, scheduler",
|
||||
"license": "MIT",
|
||||
"frameworks": "*",
|
||||
"build": {
|
||||
"flags": [
|
||||
"-std=c++17"
|
||||
]
|
||||
}
|
||||
}
|
||||
7
partitions/ota_dual_4mb_littlefs.csv
Executable file
7
partitions/ota_dual_4mb_littlefs.csv
Executable file
@@ -0,0 +1,7 @@
|
||||
# TimbreESP: 4 MB, dos slots OTA de 1.625 MiB y 704 KiB LittleFS.
|
||||
# Name, Type, SubType, Offset, Size, Flags
|
||||
nvs, data, nvs, 0x9000, 0x5000,
|
||||
otadata, data, ota, 0xE000, 0x2000,
|
||||
app0, app, ota_0, 0x10000, 0x1A0000,
|
||||
app1, app, ota_1, 0x1B0000, 0x1A0000,
|
||||
spiffs, data, spiffs, 0x350000, 0xB0000,
|
||||
|
34
platformio.ini
Executable file
34
platformio.ini
Executable file
@@ -0,0 +1,34 @@
|
||||
; 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 = esp32dev
|
||||
|
||||
[env:esp32dev]
|
||||
platform = espressif32@6.3.2
|
||||
board = esp32dev
|
||||
framework = arduino
|
||||
monitor_speed = 115200
|
||||
; Stable for this CH340/USB cable; use 460800 if another adapter reports packet errors.
|
||||
upload_speed = 500000
|
||||
board_build.partitions = partitions/ota_dual_4mb_littlefs.csv
|
||||
board_build.filesystem = littlefs
|
||||
extra_scripts =
|
||||
pre:scripts/embed_ui.py
|
||||
lib_deps =
|
||||
tzapu/WiFiManager
|
||||
bblanchon/ArduinoJson
|
||||
|
||||
[env:native]
|
||||
platform = native
|
||||
test_framework = unity
|
||||
test_build_src = no
|
||||
build_flags =
|
||||
-std=gnu++17
|
||||
BIN
scripts/__pycache__/backup_device.cpython-313.pyc
Normal file
BIN
scripts/__pycache__/backup_device.cpython-313.pyc
Normal file
Binary file not shown.
521
scripts/backup_device.py
Normal file
521
scripts/backup_device.py
Normal file
@@ -0,0 +1,521 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only backup helper for the TimbreESP ESP32.
|
||||
|
||||
This script never writes to the ESP32 and never calls erase_flash. It uses the
|
||||
PlatformIO esptool installation to read raw flash regions into a timestamped,
|
||||
private backup directory.
|
||||
|
||||
Default regions (data scope):
|
||||
bootloader 0x01000, 0x08000
|
||||
partitions 0x08000, 0x01000
|
||||
nvs 0x09000, 0x05000
|
||||
otadata 0x0E000, 0x02000
|
||||
littlefs 0x350000, 0xB0000
|
||||
|
||||
Use --scope full to read the complete 4 MiB flash in one pass and then extract
|
||||
all named regions from that image. The full image includes application slots
|
||||
as well as the data regions.
|
||||
|
||||
Examples:
|
||||
python3 scripts/backup_device.py --dry-run
|
||||
python3 scripts/backup_device.py --scope full
|
||||
python3 scripts/backup_device.py --port /dev/cu.usbserial-11220 --scope data
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
REGIONS: tuple[tuple[str, int, int], ...] = (
|
||||
("bootloader.bin", 0x01000, 0x08000),
|
||||
("partitions.bin", 0x08000, 0x01000),
|
||||
("nvs.bin", 0x09000, 0x05000),
|
||||
("otadata.bin", 0x0E000, 0x02000),
|
||||
("littlefs.bin", 0x350000, 0x0B0000),
|
||||
("app0.bin", 0x10000, 0x1A0000),
|
||||
("app1.bin", 0x1B0000, 0x1A0000),
|
||||
)
|
||||
|
||||
FULL_FLASH_SIZE = 0x400000
|
||||
EXPECTED_PARTITIONS = ("nvs", "otadata", "app0", "app1", "spiffs")
|
||||
|
||||
|
||||
class BackupError(RuntimeError):
|
||||
"""A recoverable backup failure."""
|
||||
|
||||
|
||||
def first_existing(candidates: Iterable[Path]) -> Path | None:
|
||||
for candidate in candidates:
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def find_pio() -> Path:
|
||||
home = Path.home()
|
||||
candidates = [
|
||||
home / ".platformio/penv/bin/pio",
|
||||
home / ".platformio/penv/bin/platformio",
|
||||
home / ".platformio/penv/Scripts/platformio.exe",
|
||||
]
|
||||
found = first_existing(candidates)
|
||||
if found is not None:
|
||||
return found
|
||||
executable = shutil.which("pio") or shutil.which("platformio")
|
||||
if executable:
|
||||
return Path(executable)
|
||||
raise BackupError(
|
||||
"No se encontro PlatformIO. Abre el proyecto desde VS Code o instala "
|
||||
"PlatformIO Core."
|
||||
)
|
||||
|
||||
|
||||
def find_pio_python() -> Path:
|
||||
home = Path.home()
|
||||
candidates = [
|
||||
home / ".platformio/penv/bin/python",
|
||||
home / ".platformio/penv/bin/python3",
|
||||
home / ".platformio/penv/Scripts/python.exe",
|
||||
]
|
||||
found = first_existing(candidates)
|
||||
if found is not None:
|
||||
return found
|
||||
executable = shutil.which("python3") or shutil.which("python")
|
||||
if executable:
|
||||
return Path(executable)
|
||||
raise BackupError("No se encontro un interprete de Python para esptool.")
|
||||
|
||||
|
||||
def find_esptool() -> Path:
|
||||
home = Path.home()
|
||||
package_root = home / ".platformio/packages"
|
||||
candidates = sorted(package_root.glob("tool-esptoolpy*/esptool.py"))
|
||||
found = first_existing(candidates)
|
||||
if found is not None:
|
||||
return found
|
||||
executable = shutil.which("esptool.py")
|
||||
if executable:
|
||||
return Path(executable)
|
||||
raise BackupError("No se encontro tool-esptoolpy dentro de ~/.platformio.")
|
||||
|
||||
|
||||
def find_partition_tool() -> Path | None:
|
||||
home = Path.home()
|
||||
candidates = home.glob(
|
||||
".platformio/packages/framework-arduinoespressif32/tools/gen_esp32part.py"
|
||||
)
|
||||
return first_existing(candidates)
|
||||
|
||||
|
||||
def run_command(
|
||||
command: list[str],
|
||||
*,
|
||||
label: str,
|
||||
log_dir: Path | None = None,
|
||||
capture: bool = False,
|
||||
) -> str:
|
||||
print(f"[{label}] {' '.join(command)}", flush=True)
|
||||
started = time.monotonic()
|
||||
if capture:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
check=False,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
output = result.stdout
|
||||
if result.returncode != 0:
|
||||
print(output, file=sys.stderr)
|
||||
raise BackupError(f"{label} fallo con codigo {result.returncode}")
|
||||
return output
|
||||
|
||||
if log_dir is None:
|
||||
result = subprocess.run(command, check=False)
|
||||
else:
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_path = log_dir / f"{label}.log"
|
||||
with log_path.open("w", encoding="utf-8") as log:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
check=False,
|
||||
stdout=log,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"Ultimas lineas de {log_path}:", file=sys.stderr)
|
||||
try:
|
||||
lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
print("\n".join(lines[-30:]), file=sys.stderr)
|
||||
except OSError:
|
||||
pass
|
||||
raise BackupError(f"{label} fallo con codigo {result.returncode}")
|
||||
|
||||
print(f"[{label}] completado en {time.monotonic() - started:.1f}s", flush=True)
|
||||
return ""
|
||||
|
||||
|
||||
def detect_port(pio: Path) -> str:
|
||||
result = subprocess.run(
|
||||
[str(pio), "device", "list"],
|
||||
check=False,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise BackupError(f"No se pudo ejecutar 'pio device list':\n{result.stdout}")
|
||||
ports = re.findall(r"^(/dev/cu\S+)", result.stdout, flags=re.MULTILINE)
|
||||
serial_ports = [
|
||||
port
|
||||
for port in ports
|
||||
if "usbserial" in port.lower() or "wchusbserial" in port.lower()
|
||||
]
|
||||
unique = sorted(set(serial_ports))
|
||||
if not unique:
|
||||
raise BackupError(
|
||||
"No se detecto ningun puerto USB serial. Desconecta/reconecta el "
|
||||
"ESP32 y vuelve a ejecutar el script."
|
||||
)
|
||||
if len(unique) > 1:
|
||||
raise BackupError(
|
||||
"Hay varios puertos USB serial conectados: "
|
||||
+ ", ".join(unique)
|
||||
+ ". Usa --port para indicar el correcto."
|
||||
)
|
||||
return unique[0]
|
||||
|
||||
|
||||
def ensure_port_free(port: str) -> None:
|
||||
port_path = Path(port)
|
||||
if not port_path.exists():
|
||||
raise BackupError(f"El puerto {port} ya no existe. Vuelve a detectar el ESP32.")
|
||||
lsof = shutil.which("lsof")
|
||||
if lsof is None:
|
||||
print("[AVISO] lsof no esta disponible; no se pudo comprobar el bloqueo del puerto.")
|
||||
return
|
||||
result = subprocess.run(
|
||||
[lsof, port],
|
||||
check=False,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
owners = [line for line in result.stdout.splitlines() if line.strip()]
|
||||
if owners:
|
||||
print(port, file=sys.stderr)
|
||||
print("\n".join(owners), file=sys.stderr)
|
||||
raise BackupError(
|
||||
f"El puerto {port} esta ocupado. Cierra el Serial Monitor antes "
|
||||
"de ejecutar la copia de seguridad."
|
||||
)
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def write_private(path: Path, data: bytes) -> None:
|
||||
path.write_bytes(data)
|
||||
try:
|
||||
path.chmod(0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def extract_regions(full_image: Path, output_dir: Path) -> list[dict[str, object]]:
|
||||
entries: list[dict[str, object]] = []
|
||||
with full_image.open("rb") as source:
|
||||
for name, offset, size in REGIONS:
|
||||
source.seek(offset)
|
||||
data = source.read(size)
|
||||
if len(data) != size:
|
||||
raise BackupError(
|
||||
f"La imagen completa no contiene {size} bytes para {name} "
|
||||
f"en offset 0x{offset:X}."
|
||||
)
|
||||
target = output_dir / name
|
||||
write_private(target, data)
|
||||
entries.append(
|
||||
{
|
||||
"file": name,
|
||||
"offset": f"0x{offset:06X}",
|
||||
"size": size,
|
||||
"sha256": sha256_file(target),
|
||||
}
|
||||
)
|
||||
print(f"[extract] {name}: {size} bytes", flush=True)
|
||||
return entries
|
||||
|
||||
|
||||
def decode_partitions(python: Path, partition_tool: Path, binary: Path, output: Path) -> str | None:
|
||||
if partition_tool is None:
|
||||
return "gen_esp32part.py no encontrado"
|
||||
command = [str(python), str(partition_tool), "--quiet", str(binary), str(output)]
|
||||
result = subprocess.run(
|
||||
command,
|
||||
check=False,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return result.stdout.strip() or "No se pudo decodificar la tabla de particiones."
|
||||
return None
|
||||
|
||||
|
||||
def make_readme(scope: str, port: str, baud: int, regions: list[dict[str, object]]) -> str:
|
||||
lines = [
|
||||
"TimbreESP backup (read-only)",
|
||||
"===========================",
|
||||
"",
|
||||
f"Scope: {scope}",
|
||||
f"Port: {port}",
|
||||
f"Baud: {baud}",
|
||||
"",
|
||||
"IMPORTANTES:",
|
||||
"- Este directorio contiene datos privado del dispositivo.",
|
||||
"- NVS puede contener credenciales WiFi y datos de autenticacion.",
|
||||
"- No ejecutar erase_flash sobre el ESP32 usando este directorio.",
|
||||
"- Guardar tambien una copia cifrada fuera de la Mac cuando sea posible.",
|
||||
"",
|
||||
"Regiones:",
|
||||
]
|
||||
for region in regions:
|
||||
lines.append(
|
||||
f"- {region['file']}: offset {region['offset']}, {region['size']} bytes"
|
||||
)
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"La tabla de particiones decodificada esta en partitions.csv.",
|
||||
"Ver manifest.json para hashes SHA-256.",
|
||||
"",
|
||||
"Restore: no se automatiza. Revisar offsets, tabla de particiones y",
|
||||
"tamano de flash antes de escribir cualquier imagen.",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--port", help="Puerto serie; si se omite se autodetecta")
|
||||
parser.add_argument(
|
||||
"--out",
|
||||
type=Path,
|
||||
help="Directorio de salida; por defecto Documents/TimbreESPv2-backups/...",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--baud",
|
||||
type=int,
|
||||
default=115200,
|
||||
help="Baud de lectura (por defecto 115200, mas seguro para CH340/hubs)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--scope",
|
||||
choices=("data", "full"),
|
||||
default="data",
|
||||
help="data: regiones criticas; full: imagen completa de 4 MiB",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-apps",
|
||||
action="store_true",
|
||||
help="En scope=data, leer tambien app0.bin y app1.bin",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-stub",
|
||||
action="store_true",
|
||||
help="Usar el lector ROM sin stub; mas lento pero mas robusto con cables CH340",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Mostrar puerto y regiones sin acceder al ESP32",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
os.umask(0o077)
|
||||
output_dir: Path | None = None
|
||||
|
||||
try:
|
||||
pio = find_pio()
|
||||
port = args.port or detect_port(pio)
|
||||
ensure_port_free(port)
|
||||
python = find_pio_python()
|
||||
esptool = find_esptool()
|
||||
partition_tool = find_partition_tool()
|
||||
|
||||
regions = list(REGIONS[:5])
|
||||
if args.include_apps:
|
||||
regions.extend(REGIONS[5:])
|
||||
if args.scope == "full":
|
||||
regions = [("flash-full.bin", 0, FULL_FLASH_SIZE)]
|
||||
|
||||
print(f"Puerto: {port}")
|
||||
print(f"Scope: {args.scope}")
|
||||
print("Regiones:")
|
||||
for name, offset, size in regions:
|
||||
print(f" {name}: 0x{offset:06X}, {size} bytes")
|
||||
if args.dry_run:
|
||||
return 0
|
||||
|
||||
if args.out is None:
|
||||
stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
output_dir = Path.home() / "Documents" / "TimbreESPv2-backups" / f"backup-{stamp}"
|
||||
else:
|
||||
output_dir = args.out.expanduser().resolve()
|
||||
if output_dir.exists() and any(output_dir.iterdir()):
|
||||
raise BackupError(f"El directorio de salida ya existe y no esta vacio: {output_dir}")
|
||||
output_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
log_dir = output_dir / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
|
||||
base = [
|
||||
str(python),
|
||||
str(esptool),
|
||||
"--chip",
|
||||
"esp32",
|
||||
"--port",
|
||||
port,
|
||||
"--baud",
|
||||
str(args.baud),
|
||||
"--before",
|
||||
"default_reset",
|
||||
"--after",
|
||||
"hard_reset",
|
||||
]
|
||||
if args.no_stub:
|
||||
base.append("--no-stub")
|
||||
|
||||
chip_output = run_command(base + ["chip_id"], label="chip_id", capture=True)
|
||||
flash_output = run_command(base + ["flash_id"], label="flash_id", capture=True)
|
||||
(output_dir / "device-info.txt").write_text(
|
||||
chip_output + "\n" + flash_output, encoding="utf-8"
|
||||
)
|
||||
os.chmod(output_dir / "device-info.txt", 0o600)
|
||||
|
||||
region_entries: list[dict[str, object]] = []
|
||||
if args.scope == "full":
|
||||
full_path = output_dir / "flash-full.bin"
|
||||
run_command(
|
||||
base
|
||||
+ [
|
||||
"read_flash",
|
||||
f"0x{regions[0][1]:06X}",
|
||||
f"0x{regions[0][2]:06X}",
|
||||
str(full_path),
|
||||
],
|
||||
label="read-flash-full",
|
||||
log_dir=log_dir,
|
||||
)
|
||||
if full_path.stat().st_size != FULL_FLASH_SIZE:
|
||||
raise BackupError(
|
||||
f"La imagen completa mide {full_path.stat().st_size} bytes, "
|
||||
f"se esperaban {FULL_FLASH_SIZE}."
|
||||
)
|
||||
os.chmod(full_path, 0o600)
|
||||
region_entries = extract_regions(full_path, output_dir)
|
||||
region_entries.append(
|
||||
{
|
||||
"file": "flash-full.bin",
|
||||
"offset": "0x000000",
|
||||
"size": FULL_FLASH_SIZE,
|
||||
"sha256": sha256_file(full_path),
|
||||
}
|
||||
)
|
||||
else:
|
||||
for index, (name, offset, size) in enumerate(regions, start=1):
|
||||
target = output_dir / name
|
||||
run_command(
|
||||
base
|
||||
+ [
|
||||
"read_flash",
|
||||
f"0x{offset:06X}",
|
||||
f"0x{size:06X}",
|
||||
str(target),
|
||||
],
|
||||
label=f"read-{index:02d}-{name.removesuffix('.bin')}",
|
||||
log_dir=log_dir,
|
||||
)
|
||||
if target.stat().st_size != size:
|
||||
raise BackupError(
|
||||
f"{name} mide {target.stat().st_size} bytes, se esperaban {size}."
|
||||
)
|
||||
os.chmod(target, 0o600)
|
||||
region_entries.append(
|
||||
{
|
||||
"file": name,
|
||||
"offset": f"0x{offset:06X}",
|
||||
"size": size,
|
||||
"sha256": sha256_file(target),
|
||||
}
|
||||
)
|
||||
print(f"[verify] {name}: {size} bytes", flush=True)
|
||||
|
||||
partition_binary = output_dir / "partitions.bin"
|
||||
partition_csv = output_dir / "partitions.csv"
|
||||
decode_error = decode_partitions(python, partition_tool, partition_binary, partition_csv)
|
||||
if decode_error is None:
|
||||
os.chmod(partition_csv, 0o600)
|
||||
|
||||
manifest = {
|
||||
"schema": 1,
|
||||
"created_local": dt.datetime.now().astimezone().isoformat(),
|
||||
"created_utc": dt.datetime.now(dt.timezone.utc).isoformat(),
|
||||
"scope": args.scope,
|
||||
"port": port,
|
||||
"baud": args.baud,
|
||||
"chip": "esp32",
|
||||
"read_only": True,
|
||||
"regions": region_entries,
|
||||
"partition_decode_error": decode_error,
|
||||
}
|
||||
manifest_path = output_dir / "manifest.json"
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||
os.chmod(manifest_path, 0o600)
|
||||
readme = output_dir / "README.txt"
|
||||
readme.write_text(make_readme(args.scope, port, args.baud, region_entries), encoding="utf-8")
|
||||
os.chmod(readme, 0o600)
|
||||
|
||||
print(f"Backup completada: {output_dir}")
|
||||
print("Manifest: manifest.json")
|
||||
print("No se realizo ninguna escritura en el ESP32.")
|
||||
return 0
|
||||
except (BackupError, OSError, subprocess.SubprocessError) as error:
|
||||
if output_dir is not None and output_dir.exists():
|
||||
try:
|
||||
marker = output_dir / "INCOMPLETE.txt"
|
||||
marker.write_text(
|
||||
"Backup incompleto. No usar este directorio para restaurar.\n"
|
||||
f"Error: {error}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
marker.chmod(0o600)
|
||||
except OSError:
|
||||
pass
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
96
scripts/embed_ui.py
Executable file
96
scripts/embed_ui.py
Executable file
@@ -0,0 +1,96 @@
|
||||
Import("env") # noqa: F821
|
||||
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
|
||||
project_dir = Path(env.subst("$PROJECT_DIR")) # noqa: F821
|
||||
ui_dir = project_dir / "ui"
|
||||
generated_dir = project_dir / "src" / "generated"
|
||||
generated_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
npm = shutil.which("npm")
|
||||
if npm and (ui_dir / "node_modules").is_dir():
|
||||
subprocess.run([npm, "run", "build"], cwd=ui_dir, check=True)
|
||||
|
||||
assets = {
|
||||
"indexHtml": ui_dir / "dist" / "index.html",
|
||||
"appCss": ui_dir / "dist" / "styles.min.css",
|
||||
"appJs": ui_dir / "dist" / "app.min.js",
|
||||
}
|
||||
if not all(path.is_file() for path in assets.values()):
|
||||
assets = {
|
||||
"indexHtml": ui_dir / "index.html",
|
||||
"appCss": ui_dir / "styles.css",
|
||||
"appJs": ui_dir / "app.js",
|
||||
}
|
||||
|
||||
missing = [str(path) for path in assets.values() if not path.is_file()]
|
||||
if missing:
|
||||
raise SystemExit(
|
||||
"Missing UI assets: " + ", ".join(missing)
|
||||
)
|
||||
|
||||
|
||||
def emit_array(name, data):
|
||||
lines = [f"const char {name}[] PROGMEM = {{"]
|
||||
for offset in range(0, len(data), 16):
|
||||
chunk = data[offset : offset + 16]
|
||||
values = ", ".join(f"0x{value:02X}" for value in chunk)
|
||||
lines.append(f" {values},")
|
||||
lines.append(" 0x00")
|
||||
lines.append("};")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def emit_length(name, data):
|
||||
return f"const std::size_t {name}Length = {len(data)}U;"
|
||||
|
||||
|
||||
header_lines = [
|
||||
"#pragma once",
|
||||
"",
|
||||
"#include <Arduino.h>",
|
||||
"#include <pgmspace.h>",
|
||||
"",
|
||||
"#include <cstddef>",
|
||||
"",
|
||||
]
|
||||
source_lines = [
|
||||
"// Generated by scripts/embed_ui.py. Do not edit manually.",
|
||||
"#include \"UiAssets.h\"",
|
||||
"",
|
||||
"#include <cstddef>",
|
||||
"",
|
||||
]
|
||||
|
||||
for name, path in assets.items():
|
||||
data = path.read_bytes()
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
header_lines.append(f"extern const char {name}[] PROGMEM;")
|
||||
header_lines.append(f"extern const std::size_t {name}Length;")
|
||||
header_lines.append(f"extern const char {name}Sha256[] PROGMEM;")
|
||||
source_lines.extend(
|
||||
[
|
||||
emit_array(name, data),
|
||||
emit_length(name, data),
|
||||
f'const char {name}Sha256[] PROGMEM = "{digest}";',
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
def write_if_changed(path, content):
|
||||
if path.is_file() and path.read_text(encoding="utf-8") == content:
|
||||
return
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
write_if_changed(generated_dir / "UiAssets.h", "\n".join(header_lines) + "\n")
|
||||
write_if_changed(generated_dir / "UiAssets.cpp", "\n".join(source_lines) + "\n")
|
||||
|
||||
print("Embedded UI assets:")
|
||||
for name, path in assets.items():
|
||||
data = path.read_bytes()
|
||||
print(f" {name}: {path.name} ({len(data)} bytes)")
|
||||
28
src/app/DeviceConfig.h
Executable file
28
src/app/DeviceConfig.h
Executable file
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace timbre::config {
|
||||
|
||||
inline constexpr std::int32_t kUtcOffsetSeconds = -6 * 60 * 60;
|
||||
inline constexpr std::uint8_t kPrincipalRelayPin = 22;
|
||||
inline constexpr std::uint8_t kReservedRelayPin = 23;
|
||||
inline constexpr bool kRelayActiveLow = true;
|
||||
|
||||
inline constexpr std::size_t kMaxSchedules = 100;
|
||||
inline constexpr std::size_t kMaxProfiles = 8;
|
||||
inline constexpr std::size_t kMaxPatterns = 8;
|
||||
inline constexpr std::size_t kMaxHolidays = 64;
|
||||
inline constexpr std::size_t kPatternPhaseCount = 6;
|
||||
inline constexpr std::uint16_t kMaxPhaseSeconds = 99;
|
||||
inline constexpr std::uint16_t kMaxPatternSeconds = 600;
|
||||
inline constexpr std::size_t kMaxProfileNameBytes = 32;
|
||||
inline constexpr std::size_t kMaxApiBodyBytes = 16 * 1024;
|
||||
inline constexpr std::size_t kMaxAuthBodyBytes = 2 * 1024;
|
||||
inline constexpr std::size_t kPatternQueueCapacity = 8;
|
||||
|
||||
inline constexpr const char* kStateSlotA = "/timbre-state-a.json";
|
||||
inline constexpr const char* kStateSlotB = "/timbre-state-b.json";
|
||||
|
||||
} // namespace timbre::config
|
||||
701
src/main.cpp
Executable file
701
src/main.cpp
Executable file
@@ -0,0 +1,701 @@
|
||||
#include <WiFi.h>
|
||||
#include <WebServer.h>
|
||||
#include <WiFiManager.h>
|
||||
#include <LittleFS.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <time.h>
|
||||
#include <sys/time.h>
|
||||
|
||||
const char* ARCHIVO_HORARIOS = "/horarios.json";
|
||||
const char* ARCHIVO_TIMBRES = "/timbres.json";
|
||||
const char* ARCHIVO_FERIADOS = "/feriados.json";
|
||||
const char* ARCHIVO_PERFIL = "/perfil.json"; // Guarda qué perfil está activo (ID)
|
||||
const char* ARCHIVO_NOMBRES = "/nombres.json"; // Guarda el arreglo dinámico de perfiles
|
||||
|
||||
WebServer server(80);
|
||||
|
||||
const int rele1 = 22;
|
||||
const int rele2 = 23;
|
||||
|
||||
bool horaSincronizada = false;
|
||||
int ultimoMinutoRevisado = -1;
|
||||
unsigned long tiempoAnteriorSerie = 0;
|
||||
|
||||
// Variables Globales en RAM para acceso rápido
|
||||
int perfilActivoIndex = 0;
|
||||
|
||||
// --- VARIABLES PARA LA MÁQUINA DE ESTADOS DEL TIMBRE ---
|
||||
bool secuenciaActiva = false;
|
||||
int secuenciaTiempos[6] = {0, 0, 0, 0, 0, 0};
|
||||
int pasoSecuencia = 0;
|
||||
unsigned long tiempoInicioPaso = 0;
|
||||
|
||||
String obtenerDiaLetra(int diaNum) {
|
||||
switch(diaNum) {
|
||||
case 0: return "D"; case 1: return "L"; case 2: return "M";
|
||||
case 3: return "X"; case 4: return "J"; case 5: return "V"; case 6: return "S";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
// --- DISEÑO DE LA PÁGINA WEB ---
|
||||
const char* pagina_html = R"rawliteral(
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Control Timbres ESP32</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style> body { font-family: 'Inter', sans-serif; } input[type="number"] { width: 3.5rem; text-align: center; } </style>
|
||||
</head>
|
||||
<body class="bg-slate-50 text-slate-800 min-h-screen py-8 px-4 md:px-8 flex flex-col items-center">
|
||||
<div class="w-full max-w-6xl space-y-6">
|
||||
|
||||
<!-- Encabezado -->
|
||||
<header class="flex flex-col md:flex-row justify-between items-center bg-white p-6 rounded-2xl shadow-sm border border-slate-100">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-slate-800">Control de Timbres</h1>
|
||||
<p class="text-slate-500 text-sm mt-1" id="estado-reloj">Cargando estado...</p>
|
||||
</div>
|
||||
<div class="mt-4 md:mt-0 flex items-center bg-slate-100 px-5 py-3 rounded-xl">
|
||||
<div class="flex flex-col text-left">
|
||||
<span class="text-[10px] uppercase font-bold text-slate-500">Hora del Sistema</span>
|
||||
<span id="reloj" class="text-2xl font-bold text-red-500 font-mono">--:--:--</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- SECCIÓN 1: Perfiles y Modos -->
|
||||
<section class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Selector de Perfil Activo -->
|
||||
<div class="col-span-1 bg-white rounded-2xl border shadow-sm p-6 flex flex-col justify-center border-l-4 border-l-red-500">
|
||||
<h2 class="text-lg font-bold mb-2">Modo de Operación</h2>
|
||||
<p class="text-sm text-slate-500 mb-4">Selecciona el perfil que el timbre debe obedecer hoy.</p>
|
||||
<select id="select-perfil-activo" onchange="cambiarPerfilActivo(this.value)" class="w-full bg-slate-50 border p-3 rounded-xl outline-none font-semibold text-red-700 cursor-pointer">
|
||||
<!-- Se llena dinámicamente con JS -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Gestor Dinámico de Perfiles -->
|
||||
<div class="col-span-1 lg:col-span-2 bg-white rounded-2xl border shadow-sm p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold">Gestor de Perfiles</h2>
|
||||
<p class="text-sm text-slate-500">Crea, renombra o elimina perfiles de horarios.</p>
|
||||
</div>
|
||||
<button id="btn-agregar-perfil" onclick="agregarPerfil()" class="bg-red-100 text-red-700 hover:bg-red-200 font-bold py-2 px-4 rounded-lg text-sm shadow-sm transition-colors">
|
||||
+ Agregar Perfil
|
||||
</button>
|
||||
</div>
|
||||
<div id="lista-gestor-perfiles" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<!-- Los perfiles se llenan aquí -->
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SECCIÓN 2: Timbres y Feriados -->
|
||||
<section class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Patrones de Timbres -->
|
||||
<div class="col-span-1 lg:col-span-2 bg-white rounded-2xl border shadow-sm p-6 overflow-x-auto">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-lg font-bold">Patrones de Timbres (Ciclos)</h2>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="controlarRele(1, 'on')" class="bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded-lg shadow text-xs">TEST TIMBRE A</button>
|
||||
<button onclick="controlarRele(1, 'off')" class="bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded-lg shadow text-xs">PARAR</button>
|
||||
</div>
|
||||
</div>
|
||||
<table class="w-full text-sm text-center">
|
||||
<thead class="text-xs uppercase bg-slate-50 border-b">
|
||||
<tr>
|
||||
<th class="py-3 px-2">Timbre</th>
|
||||
<th colspan="2" class="py-3 px-2 border-l border-slate-200">Ciclo 1 (Seg)</th>
|
||||
<th colspan="2" class="py-3 px-2 border-l border-slate-200">Ciclo 2 (Seg)</th>
|
||||
<th colspan="2" class="py-3 px-2 border-l border-slate-200">Ciclo 3 (Seg)</th>
|
||||
</tr>
|
||||
<tr class="bg-slate-50 border-b text-[10px] text-slate-500">
|
||||
<th></th>
|
||||
<th class="border-l border-slate-200 py-1">Alto</th><th>Bajo</th>
|
||||
<th class="border-l border-slate-200 py-1">Alto</th><th>Bajo</th>
|
||||
<th class="border-l border-slate-200 py-1">Alto</th><th>Bajo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody-patrones" class="divide-y divide-slate-100"></tbody>
|
||||
</table>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<button onclick="guardarPatrones()" class="bg-red-600 hover:bg-red-700 text-white text-sm font-bold py-2 px-6 rounded-lg shadow">Guardar Patrones</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Feriados -->
|
||||
<div class="col-span-1 bg-white rounded-2xl border shadow-sm p-6 flex flex-col">
|
||||
<h2 class="text-lg font-bold mb-2">Días Feriados</h2>
|
||||
<p class="text-xs text-slate-500 mb-4">Fechas de silencio (se repiten cada año).</p>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<input type="date" id="input-fecha" class="flex-1 bg-slate-50 border p-2 rounded-lg text-sm outline-none">
|
||||
<button onclick="agregarFeriado()" class="bg-red-600 text-white font-bold px-4 py-2 rounded-lg text-sm hover:bg-red-700">+</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto border border-slate-100 rounded-lg p-2 bg-slate-50 min-h-[120px] max-h-40" id="lista-feriados">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SECCIÓN 3: Programación de Horarios por Pestañas -->
|
||||
<section>
|
||||
<div class="bg-white rounded-2xl border shadow-sm overflow-hidden">
|
||||
<div class="flex flex-wrap border-b border-slate-200 bg-slate-50" id="tabs-perfiles"></div>
|
||||
|
||||
<div class="p-6 md:p-8 border-b border-slate-100 bg-white">
|
||||
<h3 class="text-sm font-bold text-slate-500 uppercase tracking-wider mb-4">Agregar horario a esta pestaña</h3>
|
||||
<form id="form-horario" class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">Hora de Activación</label>
|
||||
<input type="time" id="input-hora" required class="w-full bg-slate-50 border p-3 rounded-xl outline-none focus:border-red-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">Tipo de Timbre</label>
|
||||
<select id="input-tipo" class="w-full bg-slate-50 border p-3 rounded-xl outline-none focus:border-red-500">
|
||||
<option value="A">Timbre A</option>
|
||||
<option value="B">Timbre B</option>
|
||||
<option value="C">Timbre C</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-3">Días de la semana</label>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<label><input type="checkbox" value="L" name="dia" class="peer sr-only"><div class="w-12 h-12 rounded-xl flex items-center justify-center bg-slate-50 border-2 peer-checked:bg-red-500 peer-checked:border-red-500 peer-checked:text-white cursor-pointer font-bold transition-colors">L</div></label>
|
||||
<label><input type="checkbox" value="M" name="dia" class="peer sr-only"><div class="w-12 h-12 rounded-xl flex items-center justify-center bg-slate-50 border-2 peer-checked:bg-red-500 peer-checked:border-red-500 peer-checked:text-white cursor-pointer font-bold transition-colors">M</div></label>
|
||||
<label><input type="checkbox" value="X" name="dia" class="peer sr-only"><div class="w-12 h-12 rounded-xl flex items-center justify-center bg-slate-50 border-2 peer-checked:bg-red-500 peer-checked:border-red-500 peer-checked:text-white cursor-pointer font-bold transition-colors">X</div></label>
|
||||
<label><input type="checkbox" value="J" name="dia" class="peer sr-only"><div class="w-12 h-12 rounded-xl flex items-center justify-center bg-slate-50 border-2 peer-checked:bg-red-500 peer-checked:border-red-500 peer-checked:text-white cursor-pointer font-bold transition-colors">J</div></label>
|
||||
<label><input type="checkbox" value="V" name="dia" class="peer sr-only"><div class="w-12 h-12 rounded-xl flex items-center justify-center bg-slate-50 border-2 peer-checked:bg-red-500 peer-checked:border-red-500 peer-checked:text-white cursor-pointer font-bold transition-colors">V</div></label>
|
||||
<label><input type="checkbox" value="S" name="dia" class="peer sr-only"><div class="w-12 h-12 rounded-xl flex items-center justify-center bg-slate-50 border-2 peer-checked:bg-red-500 peer-checked:border-red-500 peer-checked:text-white cursor-pointer font-bold transition-colors">S</div></label>
|
||||
<label><input type="checkbox" value="D" name="dia" class="peer sr-only"><div class="w-12 h-12 rounded-xl flex items-center justify-center bg-slate-50 border-2 peer-checked:bg-red-500 peer-checked:border-red-500 peer-checked:text-white cursor-pointer font-bold transition-colors">D</div></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end pt-2">
|
||||
<button type="submit" class="bg-red-600 hover:bg-red-700 text-white font-bold py-3 px-8 rounded-xl shadow-md">Agregar Horario</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm text-left">
|
||||
<thead class="text-xs uppercase bg-slate-50 border-b">
|
||||
<tr><th class="px-6 py-4">Hora</th><th class="px-6 py-4">Timbre</th><th class="px-6 py-4">Días</th><th class="px-6 py-4 text-right">Acción</th></tr>
|
||||
</thead>
|
||||
<tbody id="tabla-horarios-body" class="divide-y divide-slate-100"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let perfiles = [];
|
||||
let horarios = [];
|
||||
let patrones = {};
|
||||
let feriados = [];
|
||||
|
||||
let perfilActivo = 0;
|
||||
let perfilViendo = 0;
|
||||
|
||||
// INYECCIÓN DE HORA
|
||||
function inyectarHora() {
|
||||
const tiempoEpoch = Math.floor(Date.now() / 1000);
|
||||
fetch('/api/sync-hora', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ epoch: tiempoEpoch })
|
||||
}).then(() => {
|
||||
document.getElementById('estado-reloj').innerText = "Sistema Sincronizado ✅";
|
||||
document.getElementById('estado-reloj').classList.add("text-green-600", "font-bold");
|
||||
});
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
const ahora = new Date();
|
||||
document.getElementById('reloj').innerText = `${ahora.getHours().toString().padStart(2, '0')}:${ahora.getMinutes().toString().padStart(2, '0')}:${ahora.getSeconds().toString().padStart(2, '0')}`;
|
||||
}, 1000);
|
||||
|
||||
function cargarDatos() {
|
||||
fetch('/api/nombres').then(res => res.json()).then(data => { perfiles = data; actualizarUIPerfiles(); });
|
||||
fetch('/api/perfil').then(res => res.json()).then(data => { perfilActivo = data.activo; document.getElementById('select-perfil-activo').value = perfilActivo; });
|
||||
fetch('/api/horarios').then(res => res.json()).then(data => { horarios = data; renderizarHorarios(); });
|
||||
fetch('/api/timbres').then(res => res.json()).then(data => { patrones = data; renderizarPatrones(); });
|
||||
fetch('/api/feriados').then(res => res.json()).then(data => { feriados = data; renderizarFeriados(); });
|
||||
}
|
||||
|
||||
function actualizarUIPerfiles() {
|
||||
let selector = document.getElementById('select-perfil-activo');
|
||||
selector.innerHTML = '';
|
||||
perfiles.forEach(p => { selector.innerHTML += `<option value="${p.id}">${p.nombre}</option>`; });
|
||||
selector.value = perfilActivo;
|
||||
|
||||
let gestor = document.getElementById('lista-gestor-perfiles');
|
||||
gestor.innerHTML = '';
|
||||
perfiles.forEach(p => {
|
||||
gestor.innerHTML += `
|
||||
<div class="flex justify-between items-center bg-slate-50 border border-slate-200 p-3 rounded-xl">
|
||||
<span class="font-bold text-slate-700 truncate mr-2">${p.nombre}</span>
|
||||
<div class="flex gap-2 shrink-0">
|
||||
<button onclick="renombrarPerfil(${p.id})" class="text-red-500 hover:text-red-700 text-sm font-bold bg-red-50 p-2 rounded-lg">✏️</button>
|
||||
${perfiles.length > 1 ? `<button onclick="eliminarPerfil(${p.id})" class="text-red-500 hover:text-red-700 text-sm font-bold bg-red-50 p-2 rounded-lg">🗑️</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
document.getElementById('btn-agregar-perfil').style.display = perfiles.length < 3 ? 'inline-block' : 'none';
|
||||
|
||||
let tabs = document.getElementById('tabs-perfiles');
|
||||
tabs.innerHTML = '';
|
||||
perfiles.forEach(p => {
|
||||
tabs.innerHTML += `<button onclick="cambiarPestana(${p.id})" id="tab-btn-${p.id}" class="flex-1 py-4 px-2 text-sm font-bold border-b-2 border-transparent text-slate-500 hover:bg-slate-100 transition-colors">${p.nombre}</button>`;
|
||||
});
|
||||
|
||||
if (!perfiles.find(p => p.id === perfilViendo)) perfilViendo = perfiles[0].id;
|
||||
cambiarPestana(perfilViendo);
|
||||
}
|
||||
|
||||
function agregarPerfil() {
|
||||
if (perfiles.length >= 3) return;
|
||||
const nombre = prompt("Ingresa el nombre del nuevo perfil:");
|
||||
if (!nombre || nombre.trim() === "") return;
|
||||
let nuevoId = 0; while(perfiles.find(p => p.id === nuevoId)) nuevoId++;
|
||||
perfiles.push({id: nuevoId, nombre: nombre.trim()});
|
||||
guardarPerfilesServidor();
|
||||
}
|
||||
|
||||
function renombrarPerfil(id) {
|
||||
const perfil = perfiles.find(p => p.id === id);
|
||||
const nuevoNombre = prompt("Nuevo nombre para el perfil:", perfil.nombre);
|
||||
if (nuevoNombre && nuevoNombre.trim() !== "") {
|
||||
perfil.nombre = nuevoNombre.trim();
|
||||
guardarPerfilesServidor();
|
||||
}
|
||||
}
|
||||
|
||||
function eliminarPerfil(id) {
|
||||
if (perfiles.length <= 1) return alert("Debe existir al menos un perfil.");
|
||||
if (confirm("¿Estás seguro de eliminar este perfil? Se borrarán todos sus horarios permanentemente.")) {
|
||||
perfiles = perfiles.filter(p => p.id !== id);
|
||||
let horariosABorrar = horarios.filter(h => h.perfil === id);
|
||||
horariosABorrar.forEach(h => { fetch('/api/eliminar', { method: 'POST', body: JSON.stringify({id: h.id}) }); });
|
||||
if (perfilActivo == id) cambiarPerfilActivo(perfiles[0].id);
|
||||
guardarPerfilesServidor();
|
||||
setTimeout(cargarDatos, 600);
|
||||
}
|
||||
}
|
||||
|
||||
function guardarPerfilesServidor() {
|
||||
fetch('/api/nombres', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(perfiles) })
|
||||
.then(() => actualizarUIPerfiles());
|
||||
}
|
||||
|
||||
function cambiarPerfilActivo(nuevoIndex) {
|
||||
fetch('/api/perfil', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ activo: parseInt(nuevoIndex) }) });
|
||||
}
|
||||
|
||||
function cambiarPestana(idPerfil) {
|
||||
perfilViendo = parseInt(idPerfil);
|
||||
perfiles.forEach(p => {
|
||||
let btn = document.getElementById(`tab-btn-${p.id}`);
|
||||
if(btn) {
|
||||
if(p.id === perfilViendo) {
|
||||
btn.classList.add('border-red-500', 'text-red-600', 'bg-white');
|
||||
btn.classList.remove('border-transparent', 'text-slate-500');
|
||||
} else {
|
||||
btn.classList.remove('border-red-500', 'text-red-600', 'bg-white');
|
||||
btn.classList.add('border-transparent', 'text-slate-500');
|
||||
}
|
||||
}
|
||||
});
|
||||
renderizarHorarios();
|
||||
}
|
||||
|
||||
function renderizarHorarios() {
|
||||
const tbody = document.getElementById('tabla-horarios-body'); tbody.innerHTML = '';
|
||||
let filtrados = horarios.filter(h => h.perfil === perfilViendo);
|
||||
filtrados.sort((a, b) => a.hora.localeCompare(b.hora));
|
||||
|
||||
if(filtrados.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="4" class="text-center py-8 text-slate-400">No hay horarios. Utiliza el formulario superior para agregar uno.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
filtrados.forEach(h => {
|
||||
const tr = document.createElement('tr');
|
||||
let arrayDias = typeof h.dias === 'string' ? h.dias.split(',') : h.dias;
|
||||
const diasHTML = arrayDias.map(d => `<span class="inline-flex w-6 h-6 mr-1 bg-slate-100 border text-slate-600 justify-center items-center rounded text-xs font-bold">${d}</span>`).join('');
|
||||
tr.innerHTML = `
|
||||
<td class="px-6 py-4 text-lg font-bold">${h.hora}</td>
|
||||
<td class="px-6 py-4 font-bold text-red-600">Timbre ${h.tipo || 'A'}</td>
|
||||
<td class="px-6 py-4">${diasHTML}</td>
|
||||
<td class="px-6 py-4 text-right"><button onclick="eliminarHorario(${h.id})" class="text-red-500 font-bold p-2 bg-red-50 hover:bg-red-100 rounded-lg">Borrar</button></td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('form-horario').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
const checkboxes = document.querySelectorAll('input[name="dia"]:checked');
|
||||
let diasSeleccionados = []; checkboxes.forEach((cb) => { diasSeleccionados.push(cb.value); });
|
||||
if (diasSeleccionados.length === 0) return alert("Selecciona un día.");
|
||||
|
||||
const nuevo = {
|
||||
hora: document.getElementById('input-hora').value,
|
||||
tipo: document.getElementById('input-tipo').value,
|
||||
perfil: perfilViendo,
|
||||
dias: diasSeleccionados.join(',')
|
||||
};
|
||||
|
||||
fetch('/api/horarios', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(nuevo)
|
||||
}).then(() => {
|
||||
fetch('/api/horarios').then(res => res.json()).then(data => { horarios = data; renderizarHorarios(); });
|
||||
document.getElementById('form-horario').reset();
|
||||
});
|
||||
});
|
||||
|
||||
function eliminarHorario(idBorrar) {
|
||||
fetch('/api/eliminar', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: idBorrar })
|
||||
}).then(() => { horarios = horarios.filter(h => h.id !== idBorrar); renderizarHorarios(); });
|
||||
}
|
||||
|
||||
function agregarFeriado() {
|
||||
const dateVal = document.getElementById('input-fecha').value;
|
||||
if(!dateVal) return;
|
||||
const mmdd = dateVal.substring(5);
|
||||
if(!feriados.includes(mmdd)) { feriados.push(mmdd); guardarFeriados(); }
|
||||
}
|
||||
function borrarFeriado(fecha) { feriados = feriados.filter(f => f !== fecha); guardarFeriados(); }
|
||||
function guardarFeriados() {
|
||||
fetch('/api/feriados', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(feriados) })
|
||||
.then(() => renderizarFeriados());
|
||||
}
|
||||
function renderizarFeriados() {
|
||||
feriados.sort();
|
||||
const div = document.getElementById('lista-feriados');
|
||||
div.innerHTML = feriados.length === 0 ? '<p class="text-xs text-slate-400 text-center py-4">Sin feriados registrados</p>' : '';
|
||||
feriados.forEach(f => {
|
||||
div.innerHTML += `<div class="flex justify-between items-center bg-white border p-2 rounded-lg mb-1 text-sm font-mono text-slate-700 shadow-sm">
|
||||
<span>${f.split('-').reverse().join('/')}</span>
|
||||
<button onclick="borrarFeriado('${f}')" class="text-red-500 font-bold hover:bg-red-50 px-2 rounded">x</button>
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
function renderizarPatrones() {
|
||||
const tbody = document.getElementById('tbody-patrones'); tbody.innerHTML = '';
|
||||
['A', 'B', 'C'].forEach(letra => {
|
||||
const tr = document.createElement('tr'); let celdas = `<td class="py-3 font-bold">Timbre ${letra}</td>`;
|
||||
for(let i=0; i<6; i++) {
|
||||
const extraClase = (i%2===0) ? "border-l border-slate-200" : "";
|
||||
celdas += `<td class="py-3 ${extraClase}"><input type="number" min="0" max="99" id="pat_${letra}_${i}" value="${patrones[letra]?.[i]||0}" class="border rounded p-1"></td>`;
|
||||
}
|
||||
tr.innerHTML = celdas; tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
function guardarPatrones() {
|
||||
['A', 'B', 'C'].forEach(letra => {
|
||||
if(!patrones[letra]) patrones[letra] = [];
|
||||
for(let i=0; i<6; i++) patrones[letra][i] = parseInt(document.getElementById(`pat_${letra}_${i}`).value) || 0;
|
||||
});
|
||||
fetch('/api/timbres', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patrones) }).then(() => alert("Patrones guardados exitosamente."));
|
||||
}
|
||||
|
||||
function controlarRele(rele, accion) {
|
||||
fetch(`/rele${rele}/${accion}`);
|
||||
}
|
||||
|
||||
inyectarHora();
|
||||
cargarDatos();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
)rawliteral";
|
||||
|
||||
// --- FUNCIONES DE MEMORIA Y BASE DE DATOS ---
|
||||
|
||||
void imprimirBaseDeDatos() {
|
||||
Serial.println("\n=========================================");
|
||||
Serial.println(" BASE DE DATOS ACTUAL EN EL SISTEMA ");
|
||||
Serial.println("=========================================\n");
|
||||
|
||||
const char* archivos[] = {ARCHIVO_NOMBRES, ARCHIVO_PERFIL, ARCHIVO_TIMBRES, ARCHIVO_FERIADOS, ARCHIVO_HORARIOS};
|
||||
const char* titulos[] = {"NOMBRES DE PERFILES", "PERFIL ACTIVO", "PATRONES DE TIMBRES", "DIAS FERIADOS", "HORARIOS PROGRAMADOS"};
|
||||
|
||||
for (int i = 0; i < 5; i++) {
|
||||
Serial.print("--- ");
|
||||
Serial.print(titulos[i]);
|
||||
Serial.println(" ---");
|
||||
|
||||
File file = LittleFS.open(archivos[i], "r");
|
||||
if (!file) {
|
||||
Serial.println("Archivo vacío o no existe.\n");
|
||||
} else {
|
||||
JsonDocument doc;
|
||||
DeserializationError error = deserializeJson(doc, file);
|
||||
if (!error) {
|
||||
serializeJsonPretty(doc, Serial);
|
||||
Serial.println("\n");
|
||||
} else {
|
||||
file.seek(0);
|
||||
String contenido = file.readString();
|
||||
Serial.println(contenido + "\n");
|
||||
}
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
Serial.println("=========================================\n");
|
||||
}
|
||||
|
||||
void inicializarMemoria() {
|
||||
LittleFS.begin(true);
|
||||
|
||||
// --- BLOQUE NUEVO PARA HORARIOS VACÍOS ---
|
||||
if (!LittleFS.exists(ARCHIVO_HORARIOS)) {
|
||||
JsonDocument doc;
|
||||
doc.to<JsonArray>(); // Crea un arreglo vacío []
|
||||
File f = LittleFS.open(ARCHIVO_HORARIOS, "w");
|
||||
serializeJson(doc, f);
|
||||
f.close();
|
||||
}
|
||||
// -----------------------------------------
|
||||
|
||||
if (!LittleFS.exists(ARCHIVO_TIMBRES)) {
|
||||
JsonDocument doc;
|
||||
JsonArray arrA = doc["A"].to<JsonArray>(); arrA.add(3); arrA.add(1); arrA.add(5); arrA.add(1); arrA.add(0); arrA.add(0);
|
||||
JsonArray arrB = doc["B"].to<JsonArray>(); arrB.add(2); arrB.add(2); arrB.add(2); arrB.add(2); arrB.add(2); arrB.add(2);
|
||||
JsonArray arrC = doc["C"].to<JsonArray>(); arrC.add(2); arrC.add(2); arrC.add(2); arrC.add(2); arrC.add(2); arrC.add(2);
|
||||
File f = LittleFS.open(ARCHIVO_TIMBRES, "w"); serializeJson(doc, f); f.close();
|
||||
}
|
||||
|
||||
if (!LittleFS.exists(ARCHIVO_FERIADOS)) {
|
||||
JsonDocument doc; JsonArray arr = doc.to<JsonArray>();
|
||||
File f = LittleFS.open(ARCHIVO_FERIADOS, "w"); serializeJson(doc, f); f.close();
|
||||
}
|
||||
|
||||
if (!LittleFS.exists(ARCHIVO_PERFIL)) {
|
||||
JsonDocument doc; doc["activo"] = 0;
|
||||
File f = LittleFS.open(ARCHIVO_PERFIL, "w"); serializeJson(doc, f); f.close();
|
||||
} else {
|
||||
File f = LittleFS.open(ARCHIVO_PERFIL, "r"); JsonDocument doc; deserializeJson(doc, f); f.close();
|
||||
perfilActivoIndex = doc["activo"].as<int>();
|
||||
}
|
||||
|
||||
if (!LittleFS.exists(ARCHIVO_NOMBRES)) {
|
||||
JsonDocument doc; JsonArray arr = doc.to<JsonArray>();
|
||||
JsonObject p0 = arr.add<JsonObject>();
|
||||
p0["id"] = 0;
|
||||
p0["nombre"] = "Perfil Principal";
|
||||
File f = LittleFS.open(ARCHIVO_NOMBRES, "w"); serializeJson(doc, f); f.close();
|
||||
}
|
||||
}
|
||||
|
||||
void dispararTimbre(String tipo) {
|
||||
File f = LittleFS.open(ARCHIVO_TIMBRES, "r");
|
||||
if(f) {
|
||||
JsonDocument doc; deserializeJson(doc, f); f.close();
|
||||
JsonArray patron = doc[tipo].as<JsonArray>();
|
||||
for(int i=0; i<6; i++) secuenciaTiempos[i] = patron[i];
|
||||
Serial.println("Iniciando secuencia de Timbre " + tipo);
|
||||
secuenciaActiva = true; pasoSecuencia = 0; tiempoInicioPaso = millis();
|
||||
}
|
||||
}
|
||||
|
||||
void manejarSecuenciaTimbre() {
|
||||
if (!secuenciaActiva) return;
|
||||
unsigned long ahora = millis();
|
||||
int duracionPaso = secuenciaTiempos[pasoSecuencia];
|
||||
|
||||
while(duracionPaso == 0 && pasoSecuencia < 6) {
|
||||
pasoSecuencia++;
|
||||
if(pasoSecuencia < 6) duracionPaso = secuenciaTiempos[pasoSecuencia];
|
||||
}
|
||||
|
||||
if (pasoSecuencia >= 6) {
|
||||
digitalWrite(rele1, HIGH);
|
||||
secuenciaActiva = false;
|
||||
Serial.println("Secuencia finalizada."); return;
|
||||
}
|
||||
|
||||
bool estadoON = (pasoSecuencia % 2 == 0);
|
||||
digitalWrite(rele1, estadoON ? LOW : HIGH);
|
||||
|
||||
if (ahora - tiempoInicioPaso >= (duracionPaso * 1000UL)) {
|
||||
pasoSecuencia++; tiempoInicioPaso = ahora;
|
||||
}
|
||||
}
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
inicializarMemoria();
|
||||
|
||||
// --- LÍNEA DE LIMPIEZA (Borrador de base de datos) ---
|
||||
// LittleFS.remove(ARCHIVO_HORARIOS);
|
||||
|
||||
// Imprimir base de datos tras inicializar memoria
|
||||
imprimirBaseDeDatos();
|
||||
|
||||
pinMode(rele1, OUTPUT); pinMode(rele2, OUTPUT);
|
||||
digitalWrite(rele1, HIGH); digitalWrite(rele2, HIGH);
|
||||
|
||||
WiFiManager wifiManager; wifiManager.autoConnect("Configurar_Timbre");
|
||||
|
||||
configTime(0, 0, "pool.ntp.org");
|
||||
|
||||
server.on("/", []() { server.send(200, "text/html", pagina_html); });
|
||||
|
||||
server.on("/rele1/on", []() { dispararTimbre("A"); server.send(200, "text/plain", "OK"); });
|
||||
server.on("/rele1/off", []() { secuenciaActiva = false; digitalWrite(rele1, HIGH); server.send(200, "text/plain", "OK"); });
|
||||
|
||||
// API Horarios
|
||||
server.on("/api/horarios", HTTP_GET, []() {
|
||||
File a = LittleFS.open(ARCHIVO_HORARIOS, "r");
|
||||
if(!a){ server.send(200, "application/json", "[]"); return; }
|
||||
String json = a.readString(); a.close(); server.send(200, "application/json", json);
|
||||
});
|
||||
server.on("/api/horarios", HTTP_POST, []() {
|
||||
String body = server.arg("plain"); File aL = LittleFS.open(ARCHIVO_HORARIOS, "r");
|
||||
JsonDocument docExist; if(aL) { deserializeJson(docExist, aL); aL.close(); } else { docExist.to<JsonArray>(); }
|
||||
JsonArray horarios = docExist.as<JsonArray>();
|
||||
JsonDocument docNuevo; deserializeJson(docNuevo, body);
|
||||
JsonObject nH = docNuevo.as<JsonObject>(); nH["id"] = millis();
|
||||
horarios.add(nH);
|
||||
File aE = LittleFS.open(ARCHIVO_HORARIOS, "w"); serializeJson(docExist, aE); aE.close();
|
||||
server.send(200, "application/json", "{\"status\":\"ok\"}");
|
||||
});
|
||||
server.on("/api/eliminar", HTTP_POST, []() {
|
||||
String body = server.arg("plain"); JsonDocument docID; deserializeJson(docID, body);
|
||||
unsigned long idBorrar = docID["id"].as<unsigned long>();
|
||||
File aL = LittleFS.open(ARCHIVO_HORARIOS, "r"); JsonDocument docE; deserializeJson(docE, aL); aL.close();
|
||||
JsonArray viejos = docE.as<JsonArray>(); JsonDocument docF; JsonArray nuevos = docF.to<JsonArray>();
|
||||
for (JsonObject h : viejos) { if (h["id"].as<unsigned long>() != idBorrar) nuevos.add(h); }
|
||||
File aE = LittleFS.open(ARCHIVO_HORARIOS, "w"); serializeJson(docF, aE); aE.close();
|
||||
server.send(200, "application/json", "{\"status\":\"ok\"}");
|
||||
});
|
||||
|
||||
// API Patrones y Feriados
|
||||
server.on("/api/timbres", HTTP_GET, []() {
|
||||
File a = LittleFS.open(ARCHIVO_TIMBRES, "r");
|
||||
if(!a) { server.send(200, "application/json", "{}"); return; }
|
||||
String json = a.readString(); a.close(); server.send(200, "application/json", json);
|
||||
});
|
||||
server.on("/api/timbres", HTTP_POST, []() {
|
||||
String body = server.arg("plain"); File a = LittleFS.open(ARCHIVO_TIMBRES, "w");
|
||||
a.print(body); a.close(); server.send(200, "application/json", "{\"status\":\"ok\"}");
|
||||
});
|
||||
server.on("/api/feriados", HTTP_GET, []() {
|
||||
File a = LittleFS.open(ARCHIVO_FERIADOS, "r");
|
||||
if(!a) { server.send(200, "application/json", "[]"); return; }
|
||||
String json = a.readString(); a.close(); server.send(200, "application/json", json);
|
||||
});
|
||||
server.on("/api/feriados", HTTP_POST, []() {
|
||||
String body = server.arg("plain"); File a = LittleFS.open(ARCHIVO_FERIADOS, "w");
|
||||
a.print(body); a.close(); server.send(200, "application/json", "{\"status\":\"ok\"}");
|
||||
});
|
||||
|
||||
// API Nombres de Perfiles y Perfil Activo
|
||||
server.on("/api/nombres", HTTP_GET, []() {
|
||||
File a = LittleFS.open(ARCHIVO_NOMBRES, "r");
|
||||
if(!a) { server.send(200, "application/json", "[]"); return; }
|
||||
String json = a.readString(); a.close(); server.send(200, "application/json", json);
|
||||
});
|
||||
server.on("/api/nombres", HTTP_POST, []() {
|
||||
String body = server.arg("plain"); File a = LittleFS.open(ARCHIVO_NOMBRES, "w");
|
||||
a.print(body); a.close();
|
||||
server.send(200, "application/json", "{\"status\":\"ok\"}");
|
||||
});
|
||||
server.on("/api/perfil", HTTP_GET, []() {
|
||||
File a = LittleFS.open(ARCHIVO_PERFIL, "r");
|
||||
if(!a) { server.send(200, "application/json", "{\"activo\":0}"); return; }
|
||||
String json = a.readString(); a.close(); server.send(200, "application/json", json);
|
||||
});
|
||||
server.on("/api/perfil", HTTP_POST, []() {
|
||||
String body = server.arg("plain"); File a = LittleFS.open(ARCHIVO_PERFIL, "w");
|
||||
a.print(body); a.close();
|
||||
JsonDocument doc; deserializeJson(doc, body);
|
||||
perfilActivoIndex = doc["activo"].as<int>();
|
||||
Serial.printf("Perfil de operación cambiado al ID: %d\n", perfilActivoIndex);
|
||||
server.send(200, "application/json", "{\"status\":\"ok\"}");
|
||||
});
|
||||
|
||||
server.on("/api/sync-hora", HTTP_POST, []() {
|
||||
String body = server.arg("plain"); JsonDocument doc; deserializeJson(doc, body);
|
||||
unsigned long epochCelular = doc["epoch"].as<unsigned long>();
|
||||
struct timeval tv; tv.tv_sec = epochCelular; tv.tv_usec = 0;
|
||||
settimeofday(&tv, NULL);
|
||||
horaSincronizada = true; server.send(200, "application/json", "{\"status\":\"ok\"}");
|
||||
});
|
||||
|
||||
server.begin();
|
||||
}
|
||||
|
||||
void loop() {
|
||||
server.handleClient();
|
||||
manejarSecuenciaTimbre();
|
||||
|
||||
time_t ahoraUTC;
|
||||
time(&ahoraUTC);
|
||||
|
||||
if (ahoraUTC > 1000000000) horaSincronizada = true;
|
||||
|
||||
if (horaSincronizada) {
|
||||
time_t ahoraLocal = ahoraUTC - 21600;
|
||||
|
||||
struct tm infoHora;
|
||||
gmtime_r(&ahoraLocal, &infoHora);
|
||||
|
||||
if (millis() - tiempoAnteriorSerie >= 1000) {
|
||||
tiempoAnteriorSerie = millis();
|
||||
}
|
||||
|
||||
if (infoHora.tm_min != ultimoMinutoRevisado) {
|
||||
ultimoMinutoRevisado = infoHora.tm_min;
|
||||
|
||||
char bufFechaHoy[6];
|
||||
sprintf(bufFechaHoy, "%02d-%02d", infoHora.tm_mon + 1, infoHora.tm_mday);
|
||||
String fechaHoy = String(bufFechaHoy);
|
||||
bool esFeriado = false;
|
||||
|
||||
File fFer = LittleFS.open(ARCHIVO_FERIADOS, "r");
|
||||
if (fFer) {
|
||||
JsonDocument docFer;
|
||||
if (!deserializeJson(docFer, fFer)) {
|
||||
JsonArray arrFer = docFer.as<JsonArray>();
|
||||
for (String f : arrFer) { if (f == fechaHoy) { esFeriado = true; break; } }
|
||||
}
|
||||
fFer.close();
|
||||
}
|
||||
|
||||
if (esFeriado) {
|
||||
Serial.println("Hoy es Feriado (" + fechaHoy + "). El timbre permanecerá en silencio.");
|
||||
} else {
|
||||
char bufHora[6];
|
||||
sprintf(bufHora, "%02d:%02d", infoHora.tm_hour, infoHora.tm_min);
|
||||
String horaAComparar = String(bufHora);
|
||||
String diaHoy = obtenerDiaLetra(infoHora.tm_wday);
|
||||
|
||||
File archivo = LittleFS.open(ARCHIVO_HORARIOS, "r");
|
||||
if (archivo) {
|
||||
JsonDocument doc;
|
||||
if (!deserializeJson(doc, archivo)) {
|
||||
JsonArray horarios = doc.as<JsonArray>();
|
||||
for (JsonObject h : horarios) {
|
||||
String hProg = h["hora"].as<String>();
|
||||
String dProg = h["dias"].as<String>();
|
||||
String tipoTimbre = h["tipo"].is<String>() ? h["tipo"].as<String>() : "A";
|
||||
int idPerfilHorario = h["perfil"].is<int>() ? h["perfil"].as<int>() : 0;
|
||||
|
||||
if (hProg == horaAComparar && dProg.indexOf(diaHoy) >= 0 && idPerfilHorario == perfilActivoIndex) {
|
||||
dispararTimbre(tipoTimbre);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
archivo.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
152
src/platform/AtomicFileStore.cpp
Executable file
152
src/platform/AtomicFileStore.cpp
Executable file
@@ -0,0 +1,152 @@
|
||||
#include "AtomicFileStore.h"
|
||||
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
namespace timbre {
|
||||
|
||||
bool AtomicFileStore::mount() {
|
||||
mounted_ = LittleFS.begin(false);
|
||||
return mounted_;
|
||||
}
|
||||
|
||||
StorageCapacity AtomicFileStore::capacity() const {
|
||||
StorageCapacity result;
|
||||
if (!mounted_) {
|
||||
return result;
|
||||
}
|
||||
result.totalBytes = LittleFS.totalBytes();
|
||||
result.usedBytes = LittleFS.usedBytes();
|
||||
return result;
|
||||
}
|
||||
|
||||
StateSlot AtomicFileStore::readSlot(const char* path) const {
|
||||
StateSlot result;
|
||||
if (!mounted_ || path == nullptr || !LittleFS.exists(path)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
File file = LittleFS.open(path, "r");
|
||||
if (!file || file.size() > kMaxPayloadBytes * 2U + 1024U) {
|
||||
if (file) {
|
||||
file.close();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const String envelope = file.readString();
|
||||
file.close();
|
||||
|
||||
JsonDocument document;
|
||||
if (deserializeJson(document, envelope)) {
|
||||
return result;
|
||||
}
|
||||
if (document["format"].as<int>() != kFormatVersion) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const std::uint32_t generation = document["generation"].as<std::uint32_t>();
|
||||
const std::uint32_t expectedCrc = document["crc32"].as<std::uint32_t>();
|
||||
if (!document["payload"].is<const char*>()) {
|
||||
return result;
|
||||
}
|
||||
const String payload = document["payload"].as<String>();
|
||||
if (payload.isEmpty() || payload.length() > kMaxPayloadBytes) {
|
||||
return result;
|
||||
}
|
||||
|
||||
JsonDocument payloadDocument;
|
||||
if (deserializeJson(payloadDocument, payload)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const std::uint32_t actualCrc =
|
||||
crc32(reinterpret_cast<const uint8_t*>(payload.c_str()), payload.length());
|
||||
if (actualCrc != expectedCrc) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result.valid = true;
|
||||
result.generation = generation;
|
||||
result.crc32 = actualCrc;
|
||||
result.payload = payload;
|
||||
return result;
|
||||
}
|
||||
|
||||
bool AtomicFileStore::commitSlot(const char* inactivePath,
|
||||
std::uint32_t generation,
|
||||
const String& payload) {
|
||||
if (!mounted_ || inactivePath == nullptr || payload.isEmpty() ||
|
||||
payload.length() > kMaxPayloadBytes) {
|
||||
return false;
|
||||
}
|
||||
|
||||
JsonDocument payloadDocument;
|
||||
if (deserializeJson(payloadDocument, payload)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::uint32_t checksum =
|
||||
crc32(reinterpret_cast<const uint8_t*>(payload.c_str()), payload.length());
|
||||
JsonDocument envelope;
|
||||
envelope["format"] = kFormatVersion;
|
||||
envelope["generation"] = generation;
|
||||
envelope["crc32"] = checksum;
|
||||
envelope["payload"] = payload;
|
||||
|
||||
String temporaryPath(inactivePath);
|
||||
temporaryPath += ".tmp";
|
||||
LittleFS.remove(temporaryPath);
|
||||
|
||||
File file = LittleFS.open(temporaryPath.c_str(), "w");
|
||||
if (!file) {
|
||||
return false;
|
||||
}
|
||||
const std::size_t written = serializeJson(envelope, file);
|
||||
file.flush();
|
||||
file.close();
|
||||
if (written == 0) {
|
||||
LittleFS.remove(temporaryPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
const StateSlot verifiedTemporary = readSlot(temporaryPath.c_str());
|
||||
if (!verifiedTemporary.valid || verifiedTemporary.generation != generation ||
|
||||
verifiedTemporary.crc32 != checksum ||
|
||||
verifiedTemporary.payload != payload) {
|
||||
LittleFS.remove(temporaryPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
LittleFS.remove(inactivePath);
|
||||
if (!LittleFS.rename(temporaryPath, inactivePath)) {
|
||||
LittleFS.remove(temporaryPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
const StateSlot verifiedFinal = readSlot(inactivePath);
|
||||
return verifiedFinal.valid && verifiedFinal.generation == generation &&
|
||||
verifiedFinal.crc32 == checksum && verifiedFinal.payload == payload;
|
||||
}
|
||||
|
||||
void AtomicFileStore::removeTemporary(const char* path) const {
|
||||
if (!mounted_ || path == nullptr) {
|
||||
return;
|
||||
}
|
||||
String temporaryPath(path);
|
||||
temporaryPath += ".tmp";
|
||||
LittleFS.remove(temporaryPath);
|
||||
}
|
||||
|
||||
std::uint32_t AtomicFileStore::crc32(const uint8_t* data, std::size_t length) {
|
||||
std::uint32_t crc = 0xFFFFFFFFUL;
|
||||
for (std::size_t index = 0; index < length; ++index) {
|
||||
crc ^= data[index];
|
||||
for (uint8_t bit = 0; bit < 8; ++bit) {
|
||||
const std::uint32_t mask = 0U - (crc & 1U);
|
||||
crc = (crc >> 1U) ^ (0xEDB88320UL & mask);
|
||||
}
|
||||
}
|
||||
return ~crc;
|
||||
}
|
||||
|
||||
} // namespace timbre
|
||||
43
src/platform/AtomicFileStore.h
Executable file
43
src/platform/AtomicFileStore.h
Executable file
@@ -0,0 +1,43 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <LittleFS.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace timbre {
|
||||
|
||||
struct StateSlot {
|
||||
bool valid = false;
|
||||
std::uint32_t generation = 0;
|
||||
std::uint32_t crc32 = 0;
|
||||
String payload;
|
||||
};
|
||||
|
||||
struct StorageCapacity {
|
||||
std::size_t totalBytes = 0;
|
||||
std::size_t usedBytes = 0;
|
||||
};
|
||||
|
||||
class AtomicFileStore {
|
||||
public:
|
||||
static constexpr std::uint16_t kFormatVersion = 2;
|
||||
static constexpr std::size_t kMaxPayloadBytes = 64U * 1024U;
|
||||
|
||||
bool mount();
|
||||
bool mounted() const { return mounted_; }
|
||||
|
||||
StorageCapacity capacity() const;
|
||||
StateSlot readSlot(const char* path) const;
|
||||
bool commitSlot(const char* inactivePath, std::uint32_t generation,
|
||||
const String& payload);
|
||||
void removeTemporary(const char* path) const;
|
||||
|
||||
private:
|
||||
static std::uint32_t crc32(const uint8_t* data, std::size_t length);
|
||||
|
||||
bool mounted_ = false;
|
||||
};
|
||||
|
||||
} // namespace timbre
|
||||
17
src/platform/EspMonotonicClock.h
Executable file
17
src/platform/EspMonotonicClock.h
Executable file
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
|
||||
#include <esp_timer.h>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace timbre {
|
||||
|
||||
class EspMonotonicClock {
|
||||
public:
|
||||
std::uint64_t micros() const { return static_cast<std::uint64_t>(esp_timer_get_time()); }
|
||||
std::uint32_t milliseconds() const {
|
||||
return static_cast<std::uint32_t>(micros() / 1000ULL);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace timbre
|
||||
57
src/platform/EspRelayBank.cpp
Executable file
57
src/platform/EspRelayBank.cpp
Executable file
@@ -0,0 +1,57 @@
|
||||
#include "EspRelayBank.h"
|
||||
|
||||
namespace timbre {
|
||||
|
||||
void EspRelayBank::beginSafe() {
|
||||
// Preload the output latch before enabling the output driver. This avoids a
|
||||
// possible LOW pulse on active-low relay modules.
|
||||
digitalWrite(kReservedPin, kOffLevel);
|
||||
digitalWrite(kPrincipalPin, kOffLevel);
|
||||
pinMode(kReservedPin, OUTPUT);
|
||||
pinMode(kPrincipalPin, OUTPUT);
|
||||
digitalWrite(kReservedPin, kOffLevel);
|
||||
digitalWrite(kPrincipalPin, kOffLevel);
|
||||
stateMask_.store(0, std::memory_order_release);
|
||||
initialized_ = true;
|
||||
}
|
||||
|
||||
void EspRelayBank::set(std::uint8_t channel, bool value) {
|
||||
if (!initialized_ || !validChannel(channel)) {
|
||||
return;
|
||||
}
|
||||
digitalWrite(pinFor(channel), value ? kOnLevel : kOffLevel);
|
||||
const std::uint8_t bit = static_cast<std::uint8_t>(1U << channel);
|
||||
std::uint8_t state = stateMask_.load(std::memory_order_relaxed);
|
||||
if (value) {
|
||||
state = static_cast<std::uint8_t>(state | bit);
|
||||
} else {
|
||||
state = static_cast<std::uint8_t>(state & ~bit);
|
||||
}
|
||||
stateMask_.store(state, std::memory_order_release);
|
||||
}
|
||||
|
||||
void EspRelayBank::allOff() {
|
||||
if (initialized_) {
|
||||
digitalWrite(kPrincipalPin, kOffLevel);
|
||||
digitalWrite(kReservedPin, kOffLevel);
|
||||
}
|
||||
stateMask_.store(0, std::memory_order_release);
|
||||
}
|
||||
|
||||
bool EspRelayBank::energized(std::uint8_t channel) const {
|
||||
if (!validChannel(channel)) {
|
||||
return false;
|
||||
}
|
||||
const std::uint8_t bit = static_cast<std::uint8_t>(1U << channel);
|
||||
return (stateMask_.load(std::memory_order_acquire) & bit) != 0;
|
||||
}
|
||||
|
||||
bool EspRelayBank::validChannel(std::uint8_t channel) const {
|
||||
return channel < kChannelCount;
|
||||
}
|
||||
|
||||
std::uint8_t EspRelayBank::pinFor(std::uint8_t channel) const {
|
||||
return channel == kReserved ? kReservedPin : kPrincipalPin;
|
||||
}
|
||||
|
||||
} // namespace timbre
|
||||
32
src/platform/EspRelayBank.h
Executable file
32
src/platform/EspRelayBank.h
Executable file
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
|
||||
namespace timbre {
|
||||
|
||||
class EspRelayBank {
|
||||
public:
|
||||
static constexpr std::uint8_t kPrincipal = 0;
|
||||
static constexpr std::uint8_t kReserved = 1;
|
||||
static constexpr std::uint8_t kChannelCount = 2;
|
||||
static constexpr std::uint8_t kPrincipalPin = 22;
|
||||
static constexpr std::uint8_t kReservedPin = 23;
|
||||
static constexpr std::uint8_t kOffLevel = HIGH;
|
||||
static constexpr std::uint8_t kOnLevel = LOW;
|
||||
|
||||
void beginSafe();
|
||||
void set(std::uint8_t channel, bool energized);
|
||||
void allOff();
|
||||
bool energized(std::uint8_t channel) const;
|
||||
bool validChannel(std::uint8_t channel) const;
|
||||
|
||||
private:
|
||||
std::uint8_t pinFor(std::uint8_t channel) const;
|
||||
std::atomic<std::uint8_t> stateMask_{0};
|
||||
bool initialized_ = false;
|
||||
};
|
||||
|
||||
} // namespace timbre
|
||||
533
src/security/AuthService.cpp
Executable file
533
src/security/AuthService.cpp
Executable file
@@ -0,0 +1,533 @@
|
||||
#include "AuthService.h"
|
||||
|
||||
#include <Preferences.h>
|
||||
#include <esp_random.h>
|
||||
#include <mbedtls/constant_time.h>
|
||||
#include <mbedtls/pkcs5.h>
|
||||
#include <mbedtls/sha256.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
namespace timbre {
|
||||
namespace {
|
||||
|
||||
constexpr const char* kNamespace = "timbre_auth";
|
||||
constexpr const char* kClaimedKey = "claimed";
|
||||
constexpr const char* kSaltKey = "pwd_salt";
|
||||
constexpr const char* kHashKey = "pwd_hash";
|
||||
constexpr const char* kIterationsKey = "pwd_iter";
|
||||
constexpr const char* kSetupHashKey = "setup_hash";
|
||||
constexpr std::uint32_t kFailureWindowMs = 15UL * 60UL * 1000UL;
|
||||
constexpr std::uint8_t kMaxFailuresPerClient = 5;
|
||||
constexpr std::uint8_t kMaxGlobalFailures = 20;
|
||||
|
||||
AuthResult result(AuthResult::Code code, bool ok = false) {
|
||||
AuthResult value;
|
||||
value.ok = ok;
|
||||
value.code = code;
|
||||
return value;
|
||||
}
|
||||
|
||||
bool constantTimeStringEqual(const String& left, const String& right) {
|
||||
if (left.length() != right.length()) {
|
||||
return false;
|
||||
}
|
||||
std::uint8_t difference = 0;
|
||||
const std::size_t length = left.length();
|
||||
for (std::size_t index = 0; index < length; ++index) {
|
||||
difference |= static_cast<std::uint8_t>(left[index] ^ right[index]);
|
||||
}
|
||||
return difference == 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool AuthService::begin() {
|
||||
Preferences preferences;
|
||||
if (!preferences.begin(kNamespace, false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
claimed_ = preferences.getBool(kClaimedKey, false);
|
||||
pbkdf2Iterations_ = preferences.getUInt(kIterationsKey, kPbkdf2Iterations);
|
||||
if (pbkdf2Iterations_ < 10000U) {
|
||||
pbkdf2Iterations_ = kPbkdf2Iterations;
|
||||
}
|
||||
|
||||
bool valid = true;
|
||||
if (claimed_) {
|
||||
valid = preferences.getBytes(kSaltKey, passwordSalt_.data(),
|
||||
passwordSalt_.size()) ==
|
||||
passwordSalt_.size() &&
|
||||
preferences.getBytes(kHashKey, passwordHash_.data(),
|
||||
passwordHash_.size()) == passwordHash_.size();
|
||||
}
|
||||
preferences.end();
|
||||
|
||||
if (!valid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!claimed_) {
|
||||
const String token = rotateSetupToken();
|
||||
if (token.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
Serial.println();
|
||||
Serial.println("=== TIMBRE ESP: CLAIM DE ADMINISTRACION ===");
|
||||
Serial.println("Token de un solo uso (guardelo ahora):");
|
||||
Serial.println(token);
|
||||
Serial.println("El token cambia en cada arranque hasta configurar la contrasena.");
|
||||
Serial.println("===========================================");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
String AuthService::rotateSetupToken() {
|
||||
if (claimed_) {
|
||||
return String();
|
||||
}
|
||||
const String token = randomToken(16);
|
||||
std::array<std::uint8_t, 32> digest{};
|
||||
if (token.isEmpty() || !tokenDigest(token, digest)) {
|
||||
return String();
|
||||
}
|
||||
|
||||
Preferences preferences;
|
||||
if (!preferences.begin(kNamespace, false)) {
|
||||
return String();
|
||||
}
|
||||
const std::size_t written =
|
||||
preferences.putBytes(kSetupHashKey, digest.data(), digest.size());
|
||||
preferences.end();
|
||||
const bool committed = written == digest.size();
|
||||
if (!committed) {
|
||||
return String();
|
||||
}
|
||||
setupTokenHash_ = digest;
|
||||
return token;
|
||||
}
|
||||
|
||||
AuthResult AuthService::claim(const String& setupToken, const String& password) {
|
||||
if (claimed_) {
|
||||
return result(AuthResult::Code::AlreadyClaimed);
|
||||
}
|
||||
if (!validPassword(password) || setupToken.length() < 16 ||
|
||||
setupToken.length() > 128) {
|
||||
return result(AuthResult::Code::InvalidRequest);
|
||||
}
|
||||
|
||||
std::array<std::uint8_t, 32> suppliedDigest{};
|
||||
if (!tokenDigest(setupToken, suppliedDigest) ||
|
||||
!constantTimeEqual(suppliedDigest, setupTokenHash_)) {
|
||||
return result(AuthResult::Code::InvalidToken);
|
||||
}
|
||||
|
||||
std::array<std::uint8_t, 16> salt{};
|
||||
std::array<std::uint8_t, 32> hash{};
|
||||
esp_fill_random(salt.data(), salt.size());
|
||||
if (!derivePasswordHash(password, salt, kPbkdf2Iterations, hash) ||
|
||||
!saveCredential(salt, hash, kPbkdf2Iterations, true)) {
|
||||
return result(AuthResult::Code::StorageError);
|
||||
}
|
||||
|
||||
claimed_ = true;
|
||||
setupTokenHash_.fill(0);
|
||||
return result(AuthResult::Code::Ok, true);
|
||||
}
|
||||
|
||||
AuthResult AuthService::login(const String& password, String& sessionCookie,
|
||||
AuthSessionView& session, std::uint32_t nowMs) {
|
||||
if (!claimed_) {
|
||||
return result(AuthResult::Code::NotClaimed);
|
||||
}
|
||||
if (!validPassword(password)) {
|
||||
return result(AuthResult::Code::InvalidPassword);
|
||||
}
|
||||
|
||||
std::array<std::uint8_t, 32> candidate{};
|
||||
if (!derivePasswordHash(password, passwordSalt_, pbkdf2Iterations_, candidate)) {
|
||||
return result(AuthResult::Code::CryptoError);
|
||||
}
|
||||
if (!constantTimeEqual(candidate, passwordHash_)) {
|
||||
return result(AuthResult::Code::InvalidPassword);
|
||||
}
|
||||
|
||||
removeExpiredSessions(nowMs);
|
||||
sessionCookie = randomToken(32);
|
||||
const String csrfToken = randomToken(32);
|
||||
if (sessionCookie.isEmpty() || csrfToken.isEmpty() ||
|
||||
!registerSession(sessionCookie, csrfToken, nowMs)) {
|
||||
sessionCookie = String();
|
||||
return result(AuthResult::Code::StorageError);
|
||||
}
|
||||
|
||||
session.csrfToken = csrfToken;
|
||||
session.expiresInSeconds = kIdleTimeoutMs / 1000U;
|
||||
return result(AuthResult::Code::Ok, true);
|
||||
}
|
||||
|
||||
bool AuthService::authenticate(const String& sessionCookie,
|
||||
std::uint32_t nowMs, AuthSessionView& session) {
|
||||
Session* stored = findSession(sessionCookie);
|
||||
if (!stored) {
|
||||
return false;
|
||||
}
|
||||
if (static_cast<std::uint32_t>(nowMs - stored->createdMs) >=
|
||||
kAbsoluteTimeoutMs ||
|
||||
static_cast<std::uint32_t>(nowMs - stored->lastSeenMs) >=
|
||||
kIdleTimeoutMs) {
|
||||
stored->used = false;
|
||||
stored->csrfToken = String();
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::uint32_t idleRemaining =
|
||||
kIdleTimeoutMs -
|
||||
static_cast<std::uint32_t>(nowMs - stored->lastSeenMs);
|
||||
const std::uint32_t absoluteRemaining =
|
||||
kAbsoluteTimeoutMs -
|
||||
static_cast<std::uint32_t>(nowMs - stored->createdMs);
|
||||
stored->lastSeenMs = nowMs;
|
||||
session.csrfToken = stored->csrfToken;
|
||||
session.expiresInSeconds = std::min(idleRemaining, absoluteRemaining) / 1000U;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool AuthService::validateMutation(const String& sessionCookie,
|
||||
const String& csrfToken,
|
||||
std::uint32_t nowMs) {
|
||||
AuthSessionView ignored;
|
||||
return authenticate(sessionCookie, nowMs, ignored) &&
|
||||
!csrfToken.isEmpty() && constantTimeStringEqual(csrfToken, ignored.csrfToken);
|
||||
}
|
||||
|
||||
void AuthService::logout(const String& sessionCookie) {
|
||||
Session* stored = findSession(sessionCookie);
|
||||
if (!stored) {
|
||||
return;
|
||||
}
|
||||
stored->used = false;
|
||||
stored->csrfToken = String();
|
||||
stored->tokenHash.fill(0);
|
||||
}
|
||||
|
||||
void AuthService::tick(std::uint32_t nowMs) {
|
||||
removeExpiredSessions(nowMs);
|
||||
}
|
||||
|
||||
bool AuthService::rateLimited(std::uint32_t clientAddress,
|
||||
std::uint32_t nowMs) const {
|
||||
std::uint8_t globalFailures = 0;
|
||||
for (const auto& bucket : failures_) {
|
||||
if (!bucket.used) {
|
||||
continue;
|
||||
}
|
||||
if (static_cast<std::uint32_t>(nowMs - bucket.windowStartedMs) >=
|
||||
kFailureWindowMs) {
|
||||
continue;
|
||||
}
|
||||
globalFailures = static_cast<std::uint8_t>(
|
||||
std::min<int>(255, globalFailures + bucket.failures));
|
||||
if (bucket.clientAddress == clientAddress &&
|
||||
bucket.failures >= kMaxFailuresPerClient) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return globalFailures >= kMaxGlobalFailures;
|
||||
}
|
||||
|
||||
void AuthService::recordFailure(std::uint32_t clientAddress,
|
||||
std::uint32_t nowMs) {
|
||||
addFailure(clientAddress, nowMs);
|
||||
}
|
||||
|
||||
void AuthService::clearFailures(std::uint32_t clientAddress) {
|
||||
for (auto& bucket : failures_) {
|
||||
if (bucket.used && bucket.clientAddress == clientAddress) {
|
||||
resetFailureBucket(bucket);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AuthResult AuthService::resetAuthentication() {
|
||||
Preferences preferences;
|
||||
if (!preferences.begin(kNamespace, false)) {
|
||||
return result(AuthResult::Code::StorageError);
|
||||
}
|
||||
preferences.remove(kClaimedKey);
|
||||
preferences.remove(kSaltKey);
|
||||
preferences.remove(kHashKey);
|
||||
preferences.remove(kIterationsKey);
|
||||
preferences.remove(kSetupHashKey);
|
||||
preferences.end();
|
||||
|
||||
claimed_ = false;
|
||||
passwordSalt_.fill(0);
|
||||
passwordHash_.fill(0);
|
||||
setupTokenHash_.fill(0);
|
||||
pbkdf2Iterations_ = kPbkdf2Iterations;
|
||||
for (auto& session : sessions_) {
|
||||
session.used = false;
|
||||
session.csrfToken = String();
|
||||
session.tokenHash.fill(0);
|
||||
}
|
||||
const String token = rotateSetupToken();
|
||||
if (token.isEmpty()) {
|
||||
return result(AuthResult::Code::StorageError);
|
||||
}
|
||||
Serial.println("Autenticacion restablecida. Nuevo token de claim:");
|
||||
Serial.println(token);
|
||||
return result(AuthResult::Code::Ok, true);
|
||||
}
|
||||
|
||||
bool AuthService::saveCredential(
|
||||
const std::array<std::uint8_t, 16>& salt,
|
||||
const std::array<std::uint8_t, 32>& passwordHash, std::uint32_t iterations,
|
||||
bool clearSetupToken) {
|
||||
Preferences preferences;
|
||||
if (!preferences.begin(kNamespace, false)) {
|
||||
return false;
|
||||
}
|
||||
bool ok = preferences.putBytes(kSaltKey, salt.data(), salt.size()) ==
|
||||
salt.size();
|
||||
ok = ok && preferences.putBytes(kHashKey, passwordHash.data(),
|
||||
passwordHash.size()) == passwordHash.size();
|
||||
ok = ok && preferences.putUInt(kIterationsKey, iterations) ==
|
||||
sizeof(std::uint32_t);
|
||||
ok = ok && preferences.putBool(kClaimedKey, true) == sizeof(std::uint8_t);
|
||||
if (ok && clearSetupToken) {
|
||||
preferences.remove(kSetupHashKey);
|
||||
}
|
||||
preferences.end();
|
||||
if (!ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
passwordSalt_ = salt;
|
||||
passwordHash_ = passwordHash;
|
||||
pbkdf2Iterations_ = iterations;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool AuthService::derivePasswordHash(
|
||||
const String& password, const std::array<std::uint8_t, 16>& salt,
|
||||
std::uint32_t iterations, std::array<std::uint8_t, 32>& output) {
|
||||
const mbedtls_md_info_t* info =
|
||||
mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
|
||||
if (info == nullptr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
mbedtls_md_context_t context;
|
||||
mbedtls_md_init(&context);
|
||||
if (mbedtls_md_setup(&context, info, 1) != 0) {
|
||||
mbedtls_md_free(&context);
|
||||
return false;
|
||||
}
|
||||
const int result = mbedtls_pkcs5_pbkdf2_hmac(
|
||||
&context, reinterpret_cast<const unsigned char*>(password.c_str()),
|
||||
password.length(), salt.data(), salt.size(), iterations, output.size(),
|
||||
output.data());
|
||||
mbedtls_md_free(&context);
|
||||
return result == 0;
|
||||
}
|
||||
|
||||
bool AuthService::validPassword(const String& password) {
|
||||
const std::size_t length = password.length();
|
||||
if (length < kMinPasswordBytes || length > kMaxPasswordBytes) {
|
||||
return false;
|
||||
}
|
||||
for (std::size_t index = 0; index < length; ++index) {
|
||||
const char value = password[index];
|
||||
if (value == '\0' || value == '\r' || value == '\n') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
String AuthService::randomToken(std::size_t bytes) {
|
||||
if (bytes == 0 || bytes > 48) {
|
||||
return String();
|
||||
}
|
||||
std::array<std::uint8_t, 48> randomBytes{};
|
||||
esp_fill_random(randomBytes.data(), bytes);
|
||||
|
||||
static constexpr char kAlphabet[] =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
String encoded;
|
||||
encoded.reserve(((bytes + 2U) / 3U) * 4U);
|
||||
for (std::size_t index = 0; index < bytes; index += 3) {
|
||||
const std::size_t remaining = std::min<std::size_t>(3, bytes - index);
|
||||
const std::uint32_t first = randomBytes[index];
|
||||
const std::uint32_t second =
|
||||
remaining > 1 ? randomBytes[index + 1] : 0;
|
||||
const std::uint32_t third =
|
||||
remaining > 2 ? randomBytes[index + 2] : 0;
|
||||
const std::uint32_t triple = (first << 16U) | (second << 8U) | third;
|
||||
encoded += kAlphabet[(triple >> 18U) & 0x3FU];
|
||||
if (remaining == 1) {
|
||||
break;
|
||||
}
|
||||
encoded += kAlphabet[(triple >> 12U) & 0x3FU];
|
||||
if (remaining == 2) {
|
||||
break;
|
||||
}
|
||||
encoded += kAlphabet[(triple >> 6U) & 0x3FU];
|
||||
encoded += kAlphabet[triple & 0x3FU];
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
bool AuthService::sha256(const uint8_t* data, std::size_t length,
|
||||
std::array<std::uint8_t, 32>& digest) {
|
||||
return mbedtls_sha256_ret(data, length, digest.data(), 0) == 0;
|
||||
}
|
||||
|
||||
bool AuthService::constantTimeEqual(
|
||||
const std::array<std::uint8_t, 32>& left,
|
||||
const std::array<std::uint8_t, 32>& right) {
|
||||
return mbedtls_ct_memcmp(left.data(), right.data(), left.size()) == 0;
|
||||
}
|
||||
|
||||
bool AuthService::tokenDigest(const String& token,
|
||||
std::array<std::uint8_t, 32>& digest) {
|
||||
return sha256(reinterpret_cast<const uint8_t*>(token.c_str()), token.length(),
|
||||
digest);
|
||||
}
|
||||
|
||||
AuthService::Session* AuthService::findSession(const String& cookie) {
|
||||
if (cookie.isEmpty() || cookie.length() > 128) {
|
||||
return nullptr;
|
||||
}
|
||||
std::array<std::uint8_t, 32> digest{};
|
||||
if (!tokenDigest(cookie, digest)) {
|
||||
return nullptr;
|
||||
}
|
||||
for (auto& session : sessions_) {
|
||||
if (session.used && constantTimeEqual(session.tokenHash, digest)) {
|
||||
return &session;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void AuthService::removeExpiredSessions(std::uint32_t nowMs) {
|
||||
for (auto& session : sessions_) {
|
||||
if (!session.used) {
|
||||
continue;
|
||||
}
|
||||
if (static_cast<std::uint32_t>(nowMs - session.createdMs) >=
|
||||
kAbsoluteTimeoutMs ||
|
||||
static_cast<std::uint32_t>(nowMs - session.lastSeenMs) >=
|
||||
kIdleTimeoutMs) {
|
||||
session.used = false;
|
||||
session.csrfToken = String();
|
||||
session.tokenHash.fill(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void AuthService::evictOldestSession(std::uint32_t nowMs) {
|
||||
Session* oldest = nullptr;
|
||||
std::uint32_t oldestAge = 0;
|
||||
for (auto& session : sessions_) {
|
||||
if (!session.used) {
|
||||
oldest = &session;
|
||||
break;
|
||||
}
|
||||
const std::uint32_t age =
|
||||
static_cast<std::uint32_t>(nowMs - session.lastSeenMs);
|
||||
if (!oldest || age > oldestAge) {
|
||||
oldest = &session;
|
||||
oldestAge = age;
|
||||
}
|
||||
}
|
||||
if (oldest) {
|
||||
oldest->used = false;
|
||||
oldest->csrfToken = String();
|
||||
oldest->tokenHash.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
bool AuthService::registerSession(const String& cookie, const String& csrf,
|
||||
std::uint32_t nowMs) {
|
||||
std::array<std::uint8_t, 32> digest{};
|
||||
if (!tokenDigest(cookie, digest)) {
|
||||
return false;
|
||||
}
|
||||
Session* slot = nullptr;
|
||||
for (auto& session : sessions_) {
|
||||
if (!session.used) {
|
||||
slot = &session;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!slot) {
|
||||
evictOldestSession(nowMs);
|
||||
for (auto& session : sessions_) {
|
||||
if (!session.used) {
|
||||
slot = &session;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!slot) {
|
||||
return false;
|
||||
}
|
||||
slot->tokenHash = digest;
|
||||
slot->csrfToken = csrf;
|
||||
slot->createdMs = nowMs;
|
||||
slot->lastSeenMs = nowMs;
|
||||
slot->used = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool AuthService::addFailure(std::uint32_t clientAddress,
|
||||
std::uint32_t nowMs) {
|
||||
FailureBucket* bucket = nullptr;
|
||||
for (auto& candidate : failures_) {
|
||||
if (candidate.used && candidate.clientAddress == clientAddress) {
|
||||
bucket = &candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!bucket) {
|
||||
for (auto& candidate : failures_) {
|
||||
if (!candidate.used) {
|
||||
bucket = &candidate;
|
||||
candidate.clientAddress = clientAddress;
|
||||
candidate.failures = 0;
|
||||
candidate.windowStartedMs = nowMs;
|
||||
candidate.used = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!bucket) {
|
||||
bucket = &failures_[0];
|
||||
*bucket = FailureBucket{};
|
||||
bucket->clientAddress = clientAddress;
|
||||
bucket->windowStartedMs = nowMs;
|
||||
bucket->used = true;
|
||||
} else if (static_cast<std::uint32_t>(nowMs - bucket->windowStartedMs) >=
|
||||
kFailureWindowMs) {
|
||||
bucket->failures = 0;
|
||||
bucket->windowStartedMs = nowMs;
|
||||
}
|
||||
if (bucket->failures < 255) {
|
||||
++bucket->failures;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void AuthService::resetFailureBucket(FailureBucket& bucket) {
|
||||
bucket.used = false;
|
||||
bucket.clientAddress = 0;
|
||||
bucket.failures = 0;
|
||||
bucket.windowStartedMs = 0;
|
||||
}
|
||||
|
||||
} // namespace timbre
|
||||
113
src/security/AuthService.h
Executable file
113
src/security/AuthService.h
Executable file
@@ -0,0 +1,113 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
|
||||
namespace timbre {
|
||||
|
||||
struct AuthSessionView {
|
||||
String csrfToken;
|
||||
std::uint32_t expiresInSeconds = 0;
|
||||
};
|
||||
|
||||
struct AuthResult {
|
||||
bool ok = false;
|
||||
enum class Code : std::uint8_t {
|
||||
Ok,
|
||||
NotClaimed,
|
||||
AlreadyClaimed,
|
||||
InvalidToken,
|
||||
InvalidPassword,
|
||||
InvalidRequest,
|
||||
RateLimited,
|
||||
StorageError,
|
||||
CryptoError,
|
||||
SessionInvalid,
|
||||
} code = Code::InvalidRequest;
|
||||
};
|
||||
|
||||
class AuthService {
|
||||
public:
|
||||
static constexpr std::size_t kMaxSessions = 4;
|
||||
static constexpr std::uint32_t kIdleTimeoutMs = 30UL * 60UL * 1000UL;
|
||||
static constexpr std::uint32_t kAbsoluteTimeoutMs = 12UL * 60UL * 60UL * 1000UL;
|
||||
static constexpr std::uint32_t kPbkdf2Iterations = 100000;
|
||||
static constexpr std::size_t kMinPasswordBytes = 12;
|
||||
static constexpr std::size_t kMaxPasswordBytes = 128;
|
||||
|
||||
bool begin();
|
||||
bool isClaimed() const { return claimed_; }
|
||||
|
||||
// Generates a new one-time setup token. Only its SHA-256 digest is stored.
|
||||
String rotateSetupToken();
|
||||
AuthResult claim(const String& setupToken, const String& password);
|
||||
AuthResult login(const String& password, String& sessionCookie,
|
||||
AuthSessionView& session, std::uint32_t nowMs);
|
||||
|
||||
bool authenticate(const String& sessionCookie, std::uint32_t nowMs,
|
||||
AuthSessionView& session);
|
||||
bool validateMutation(const String& sessionCookie, const String& csrfToken,
|
||||
std::uint32_t nowMs);
|
||||
void logout(const String& sessionCookie);
|
||||
void tick(std::uint32_t nowMs);
|
||||
|
||||
bool rateLimited(std::uint32_t clientAddress, std::uint32_t nowMs) const;
|
||||
void recordFailure(std::uint32_t clientAddress, std::uint32_t nowMs);
|
||||
void clearFailures(std::uint32_t clientAddress);
|
||||
|
||||
// Physical recovery path. Preserves application data and only resets auth.
|
||||
AuthResult resetAuthentication();
|
||||
|
||||
private:
|
||||
struct Session {
|
||||
std::array<std::uint8_t, 32> tokenHash{};
|
||||
String csrfToken;
|
||||
std::uint32_t createdMs = 0;
|
||||
std::uint32_t lastSeenMs = 0;
|
||||
bool used = false;
|
||||
};
|
||||
|
||||
struct FailureBucket {
|
||||
std::uint32_t clientAddress = 0;
|
||||
std::uint8_t failures = 0;
|
||||
std::uint32_t windowStartedMs = 0;
|
||||
bool used = false;
|
||||
};
|
||||
|
||||
bool saveCredential(const std::array<std::uint8_t, 16>& salt,
|
||||
const std::array<std::uint8_t, 32>& passwordHash,
|
||||
std::uint32_t iterations, bool clearSetupToken);
|
||||
static bool derivePasswordHash(const String& password,
|
||||
const std::array<std::uint8_t, 16>& salt,
|
||||
std::uint32_t iterations,
|
||||
std::array<std::uint8_t, 32>& output);
|
||||
static bool validPassword(const String& password);
|
||||
static String randomToken(std::size_t bytes);
|
||||
static bool sha256(const uint8_t* data, std::size_t length,
|
||||
std::array<std::uint8_t, 32>& digest);
|
||||
static bool constantTimeEqual(const std::array<std::uint8_t, 32>& left,
|
||||
const std::array<std::uint8_t, 32>& right);
|
||||
static bool tokenDigest(const String& token,
|
||||
std::array<std::uint8_t, 32>& digest);
|
||||
|
||||
Session* findSession(const String& cookie);
|
||||
void removeExpiredSessions(std::uint32_t nowMs);
|
||||
void evictOldestSession(std::uint32_t nowMs);
|
||||
bool registerSession(const String& cookie, const String& csrf,
|
||||
std::uint32_t nowMs);
|
||||
bool addFailure(std::uint32_t clientAddress, std::uint32_t nowMs);
|
||||
void resetFailureBucket(FailureBucket& bucket);
|
||||
|
||||
bool claimed_ = false;
|
||||
std::array<std::uint8_t, 16> passwordSalt_{};
|
||||
std::array<std::uint8_t, 32> passwordHash_{};
|
||||
std::array<std::uint8_t, 32> setupTokenHash_{};
|
||||
std::uint32_t pbkdf2Iterations_ = kPbkdf2Iterations;
|
||||
|
||||
std::array<Session, kMaxSessions> sessions_{};
|
||||
std::array<FailureBucket, 4> failures_{};
|
||||
};
|
||||
|
||||
} // namespace timbre
|
||||
46
src/web/Cookies.h
Executable file
46
src/web/Cookies.h
Executable file
@@ -0,0 +1,46 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace timbre {
|
||||
|
||||
inline String cookieValue(const String& cookieHeader, const char* name) {
|
||||
if (cookieHeader.isEmpty() || name == nullptr) {
|
||||
return String();
|
||||
}
|
||||
const String prefix = String(name) + "=";
|
||||
std::size_t start = 0;
|
||||
while (start < cookieHeader.length()) {
|
||||
std::size_t end = cookieHeader.indexOf(';', start);
|
||||
if (end == String::npos) {
|
||||
end = cookieHeader.length();
|
||||
}
|
||||
String item = cookieHeader.substring(start, end);
|
||||
item.trim();
|
||||
if (item.startsWith(prefix)) {
|
||||
String value = item.substring(prefix.length());
|
||||
value.trim();
|
||||
if (value.length() <= 128) {
|
||||
return value;
|
||||
}
|
||||
return String();
|
||||
}
|
||||
start = end + 1;
|
||||
}
|
||||
return String();
|
||||
}
|
||||
|
||||
inline String sessionCookieHeader(const String& token,
|
||||
std::uint32_t maxAgeSeconds) {
|
||||
return String("Set-Cookie: esp_sid=") + token +
|
||||
"; Path=/; HttpOnly; SameSite=Strict; Max-Age=" +
|
||||
String(maxAgeSeconds);
|
||||
}
|
||||
|
||||
inline String clearSessionCookieHeader() {
|
||||
return F("Set-Cookie: esp_sid=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0");
|
||||
}
|
||||
|
||||
} // namespace timbre
|
||||
44
src/web/JsonHttp.h
Executable file
44
src/web/JsonHttp.h
Executable file
@@ -0,0 +1,44 @@
|
||||
#pragma once
|
||||
|
||||
#include "NativeHttpServer.h"
|
||||
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
namespace timbre {
|
||||
|
||||
inline bool isJsonRequest(const HttpRequest& request) {
|
||||
return request.contentType.startsWith("application/json");
|
||||
}
|
||||
|
||||
inline bool parseJsonRequest(const HttpRequest& request, JsonDocument& document) {
|
||||
if (!isJsonRequest(request) || request.body.isEmpty() ||
|
||||
request.body.length() > NativeHttpServer::kMaxBodyBytes) {
|
||||
return false;
|
||||
}
|
||||
DeserializationError error = deserializeJson(document, request.body);
|
||||
return !error && !document.overflowed();
|
||||
}
|
||||
|
||||
inline void setJsonResponse(HttpResponse& response, int status,
|
||||
const char* json) {
|
||||
response.status = status;
|
||||
response.contentType = "application/json; charset=utf-8";
|
||||
response.setBody(json);
|
||||
response.cacheControl = "no-store";
|
||||
}
|
||||
|
||||
inline void setErrorResponse(HttpResponse& response, int status, const char* code,
|
||||
const String& message) {
|
||||
JsonDocument document;
|
||||
document["ok"] = false;
|
||||
document["error"] = code;
|
||||
document["message"] = message;
|
||||
response.status = status;
|
||||
response.contentType = "application/json; charset=utf-8";
|
||||
serializeJson(document, response.body);
|
||||
response.bodyData = nullptr;
|
||||
response.bodyLength = 0;
|
||||
response.cacheControl = "no-store";
|
||||
}
|
||||
|
||||
} // namespace timbre
|
||||
361
src/web/NativeHttpServer.cpp
Executable file
361
src/web/NativeHttpServer.cpp
Executable file
@@ -0,0 +1,361 @@
|
||||
#include "NativeHttpServer.h"
|
||||
|
||||
#include <WiFi.h>
|
||||
|
||||
#include <lwip/sockets.h>
|
||||
#include <netinet/in.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
namespace timbre {
|
||||
namespace {
|
||||
|
||||
std::string toLower(std::string value) {
|
||||
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) {
|
||||
return static_cast<char>(std::tolower(c));
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
std::string hostWithoutPort(const std::string& value) {
|
||||
const std::size_t separator = value.find_first_of(':');
|
||||
return separator == std::string::npos ? value : value.substr(0, separator);
|
||||
}
|
||||
|
||||
std::string normalizedAuthority(const std::string& value) {
|
||||
const std::string host = toLower(hostWithoutPort(value));
|
||||
const std::size_t separator = value.find_first_of(':');
|
||||
if (separator == std::string::npos ||
|
||||
value.substr(separator + 1).empty() ||
|
||||
toLower(value.substr(separator + 1)) == "80") {
|
||||
return host;
|
||||
}
|
||||
return host + ":" + toLower(value.substr(separator + 1));
|
||||
}
|
||||
|
||||
bool allowedHost(const std::string& value) {
|
||||
const std::string host = toLower(hostWithoutPort(value));
|
||||
if (host.empty()) {
|
||||
return false;
|
||||
}
|
||||
const IPAddress station = WiFi.localIP();
|
||||
if (station != IPAddress(0, 0, 0, 0) &&
|
||||
host == toLower(station.toString().c_str())) {
|
||||
return true;
|
||||
}
|
||||
const IPAddress accessPoint = WiFi.softAPIP();
|
||||
if (accessPoint != IPAddress(0, 0, 0, 0) &&
|
||||
host == toLower(accessPoint.toString().c_str())) {
|
||||
return true;
|
||||
}
|
||||
String hostname = WiFi.getHostname();
|
||||
if (hostname.length() > 0) {
|
||||
hostname += ".local";
|
||||
return host == toLower(hostname.c_str());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool validRequestOrigin(const HttpRequest& request) {
|
||||
if (request.secFetchSite == "cross-site") {
|
||||
return false;
|
||||
}
|
||||
if (request.host.isEmpty() || !allowedHost(request.host.c_str())) {
|
||||
return false;
|
||||
}
|
||||
if (request.origin.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
const std::string origin = request.origin.c_str();
|
||||
const std::string prefix = "http://";
|
||||
if (origin.compare(0, prefix.size(), prefix) != 0) {
|
||||
return false;
|
||||
}
|
||||
std::string authority = origin.substr(prefix.size());
|
||||
const std::size_t slash = authority.find('/');
|
||||
if (slash != std::string::npos) {
|
||||
authority = authority.substr(0, slash);
|
||||
}
|
||||
return allowedHost(authority) &&
|
||||
normalizedAuthority(authority) == normalizedAuthority(request.host.c_str());
|
||||
}
|
||||
|
||||
void setError(HttpResponse& response, int status, const char* code,
|
||||
const char* message) {
|
||||
response.status = status;
|
||||
response.contentType = "application/json; charset=utf-8";
|
||||
response.body = String("{\"ok\":false,\"error\":\"") + code +
|
||||
"\",\"message\":\"" + message + "\"}";
|
||||
response.bodyData = nullptr;
|
||||
response.bodyLength = 0;
|
||||
}
|
||||
|
||||
const char* statusText(int status) {
|
||||
switch (status) {
|
||||
case 200: return "200 OK";
|
||||
case 201: return "201 Created";
|
||||
case 202: return "202 Accepted";
|
||||
case 204: return "204 No Content";
|
||||
case 304: return "304 Not Modified";
|
||||
case 400: return "400 Bad Request";
|
||||
case 401: return "401 Unauthorized";
|
||||
case 403: return "403 Forbidden";
|
||||
case 404: return "404 Not Found";
|
||||
case 405: return "405 Method Not Allowed";
|
||||
case 409: return "409 Conflict";
|
||||
case 411: return "411 Length Required";
|
||||
case 413: return "413 Payload Too Large";
|
||||
case 414: return "414 URI Too Long";
|
||||
case 421: return "421 Misdirected Request";
|
||||
case 422: return "422 Unprocessable Entity";
|
||||
case 429: return "429 Too Many Requests";
|
||||
case 500: return "500 Internal Server Error";
|
||||
case 503: return "503 Service Unavailable";
|
||||
default: return "400 Bad Request";
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool NativeHttpServer::start(IHttpHandler* handler) {
|
||||
if (handler == nullptr || server_ != nullptr) {
|
||||
return false;
|
||||
}
|
||||
handler_ = handler;
|
||||
|
||||
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
|
||||
config.task_priority = 4;
|
||||
config.stack_size = 12288;
|
||||
config.core_id = 1;
|
||||
config.max_open_sockets = 4;
|
||||
config.max_uri_handlers = 20;
|
||||
config.max_resp_headers = 24;
|
||||
config.recv_wait_timeout = 5;
|
||||
config.send_wait_timeout = 5;
|
||||
config.lru_purge_enable = true;
|
||||
config.uri_match_fn = httpd_uri_match_wildcard;
|
||||
|
||||
if (httpd_start(&server_, &config) != ESP_OK) {
|
||||
server_ = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
const httpd_method_t methods[] = {HTTP_GET, HTTP_POST, HTTP_DELETE};
|
||||
for (httpd_method_t method : methods) {
|
||||
httpd_uri_t route{};
|
||||
route.uri = method == HTTP_GET ? "/*" : "/api/v1/*";
|
||||
route.method = method;
|
||||
route.handler = dispatch;
|
||||
route.user_ctx = this;
|
||||
if (httpd_register_uri_handler(server_, &route) != ESP_OK) {
|
||||
stop();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void NativeHttpServer::stop() {
|
||||
if (server_ != nullptr) {
|
||||
httpd_stop(server_);
|
||||
server_ = nullptr;
|
||||
}
|
||||
handler_ = nullptr;
|
||||
}
|
||||
|
||||
esp_err_t NativeHttpServer::dispatch(httpd_req_t* request) {
|
||||
NativeHttpServer* self =
|
||||
static_cast<NativeHttpServer*>(request->user_ctx);
|
||||
if (self == nullptr) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
HttpResponse response;
|
||||
const bool prepared = self->dispatchRequest(
|
||||
request, static_cast<httpd_method_t>(request->method), response);
|
||||
if (prepared) {
|
||||
self->sendResponse(request, response);
|
||||
} else {
|
||||
setError(response, 500, "internal_error", "Error interno");
|
||||
self->sendResponse(request, response);
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
bool NativeHttpServer::dispatchRequest(httpd_req_t* source, httpd_method_t method,
|
||||
HttpResponse& response) {
|
||||
if (handler_ == nullptr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
HttpRequest request;
|
||||
request.method = method;
|
||||
request.uri = source->uri;
|
||||
request.contentLength = source->content_len;
|
||||
|
||||
const int socketFd = httpd_req_to_sockfd(source);
|
||||
sockaddr_in address{};
|
||||
socklen_t addressLength = sizeof(address);
|
||||
if (socketFd >= 0 &&
|
||||
getpeername(socketFd, reinterpret_cast<sockaddr*>(&address),
|
||||
&addressLength) == 0) {
|
||||
request.remoteAddress = ntohl(address.sin_addr.s_addr);
|
||||
}
|
||||
|
||||
if (!readHeader(source, "Host", request.host) || request.host.isEmpty()) {
|
||||
setError(response, 421, "invalid_host", "Host local no permitido");
|
||||
return true;
|
||||
}
|
||||
if (!allowedHost(request.host.c_str())) {
|
||||
setError(response, 421, "invalid_host", "Host local no permitido");
|
||||
return true;
|
||||
}
|
||||
|
||||
readHeader(source, "Origin", request.origin);
|
||||
readHeader(source, "Content-Type", request.contentType);
|
||||
readHeader(source, "Cookie", request.cookie);
|
||||
readHeader(source, "X-CSRF-Token", request.csrfToken);
|
||||
readHeader(source, "Sec-Fetch-Site", request.secFetchSite);
|
||||
|
||||
if (!validRequestOrigin(request)) {
|
||||
setError(response, 403, "origin_rejected", "Origen no permitido");
|
||||
return true;
|
||||
}
|
||||
|
||||
std::size_t queryLength = httpd_req_get_url_query_len(source);
|
||||
if (queryLength > kMaxUriBytes) {
|
||||
setError(response, 414, "uri_too_long", "Consulta demasiado larga");
|
||||
return true;
|
||||
}
|
||||
if (queryLength > 0) {
|
||||
char query[NativeHttpServer::kMaxUriBytes + 1];
|
||||
const esp_err_t result =
|
||||
httpd_req_get_url_query_str(source, query, queryLength + 1);
|
||||
if (result != ESP_OK) {
|
||||
setError(response, 400, "invalid_query", "Consulta no válida");
|
||||
return true;
|
||||
}
|
||||
request.query = query;
|
||||
}
|
||||
|
||||
String transferEncoding;
|
||||
if (readHeader(source, "Transfer-Encoding", transferEncoding) &&
|
||||
transferEncoding.length() > 0) {
|
||||
setError(response, 411, "length_required", "Content-Length requerido");
|
||||
return true;
|
||||
}
|
||||
|
||||
const std::size_t limit = handler_->bodyLimit(source->uri);
|
||||
if (source->content_len > limit) {
|
||||
setError(response, 413, "payload_too_large", "Cuerpo demasiado grande");
|
||||
return true;
|
||||
}
|
||||
if (!readBody(source, limit, request.body)) {
|
||||
setError(response, 400, "invalid_body", "Cuerpo no válido");
|
||||
return true;
|
||||
}
|
||||
|
||||
return handler_->handle(request, response);
|
||||
}
|
||||
|
||||
bool NativeHttpServer::readHeader(httpd_req_t* request, const char* name,
|
||||
String& output) {
|
||||
output = String();
|
||||
const std::size_t length = httpd_req_get_hdr_value_len(request, name);
|
||||
if (length == 0 || length > 1024) {
|
||||
return false;
|
||||
}
|
||||
char value[1025];
|
||||
const esp_err_t result =
|
||||
httpd_req_get_hdr_value_str(request, name, value, sizeof(value));
|
||||
if (result == ESP_OK) {
|
||||
output = value;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool NativeHttpServer::readBody(httpd_req_t* request, std::size_t limit,
|
||||
String& body) {
|
||||
body = String();
|
||||
if (request->content_len == 0) {
|
||||
return true;
|
||||
}
|
||||
if (request->content_len > limit) {
|
||||
return false;
|
||||
}
|
||||
body.reserve(request->content_len);
|
||||
std::size_t remaining = request->content_len;
|
||||
std::uint32_t startedMs = millis();
|
||||
char buffer[1024];
|
||||
while (remaining > 0) {
|
||||
if (static_cast<std::uint32_t>(millis() - startedMs) > 5000U) {
|
||||
body = String();
|
||||
return false;
|
||||
}
|
||||
const std::size_t wanted = std::min(remaining, sizeof(buffer));
|
||||
const int received = httpd_req_recv(request, buffer, wanted);
|
||||
if (received <= 0) {
|
||||
body = String();
|
||||
return false;
|
||||
}
|
||||
body.concat(buffer, static_cast<unsigned int>(received));
|
||||
remaining -= static_cast<std::size_t>(received);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void NativeHttpServer::sendResponse(httpd_req_t* request,
|
||||
const HttpResponse& response) {
|
||||
httpd_resp_set_status(
|
||||
request, statusText(response.status == 0 ? 200 : response.status));
|
||||
httpd_resp_set_type(request, response.contentType.c_str());
|
||||
httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
|
||||
httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
|
||||
httpd_resp_set_hdr(request, "X-Frame-Options", "DENY");
|
||||
httpd_resp_set_hdr(request, "Permissions-Policy",
|
||||
"camera=(), microphone=(), geolocation=(), usb=()");
|
||||
httpd_resp_set_hdr(request, "Cross-Origin-Opener-Policy", "same-origin");
|
||||
httpd_resp_set_hdr(request, "Cross-Origin-Resource-Policy", "same-origin");
|
||||
httpd_resp_set_hdr(request, "X-DNS-Prefetch-Control", "off");
|
||||
httpd_resp_set_hdr(request, "Content-Security-Policy",
|
||||
"default-src 'none'; script-src 'self'; style-src 'self'; "
|
||||
"connect-src 'self'; img-src 'self' data:; font-src 'self'; "
|
||||
"object-src 'none'; base-uri 'none'; form-action 'self'; "
|
||||
"frame-ancestors 'none'");
|
||||
httpd_resp_set_hdr(request, "Cache-Control", response.cacheControl.c_str());
|
||||
if (!response.etag.isEmpty()) {
|
||||
String quoted = response.etag;
|
||||
quoted.replace("\"", "");
|
||||
quoted = "\"" + quoted + "\"";
|
||||
httpd_resp_set_hdr(request, "ETag", quoted.c_str());
|
||||
}
|
||||
for (const String& header : response.extraHeaders) {
|
||||
if (header.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
const std::size_t separator = header.indexOf(':');
|
||||
if (separator <= 0) {
|
||||
continue;
|
||||
}
|
||||
const String name = header.substring(0, separator);
|
||||
const String value = header.substring(separator + 1);
|
||||
httpd_resp_set_hdr(request, name.c_str(), value.c_str());
|
||||
}
|
||||
|
||||
const char* data = response.bodyData != nullptr
|
||||
? response.bodyData
|
||||
: response.body.c_str();
|
||||
const std::size_t length = response.bodyData != nullptr
|
||||
? response.bodyLength
|
||||
: response.body.length();
|
||||
if (response.status == 204 || response.status == 304) {
|
||||
httpd_resp_send(request, nullptr, 0);
|
||||
} else {
|
||||
httpd_resp_send(request, data, static_cast<ssize_t>(length));
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace timbre
|
||||
88
src/web/NativeHttpServer.h
Executable file
88
src/web/NativeHttpServer.h
Executable file
@@ -0,0 +1,88 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <esp_http_server.h>
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace timbre {
|
||||
|
||||
struct HttpRequest {
|
||||
httpd_method_t method = HTTP_GET;
|
||||
String uri;
|
||||
String query;
|
||||
String body;
|
||||
String host;
|
||||
String origin;
|
||||
String contentType;
|
||||
String cookie;
|
||||
String csrfToken;
|
||||
String secFetchSite;
|
||||
std::uint32_t remoteAddress = 0;
|
||||
std::size_t contentLength = 0;
|
||||
};
|
||||
|
||||
struct HttpResponse {
|
||||
int status = 200;
|
||||
String contentType = "text/plain; charset=utf-8";
|
||||
String body;
|
||||
const char* bodyData = nullptr;
|
||||
std::size_t bodyLength = 0;
|
||||
String etag;
|
||||
String cacheControl = "no-store";
|
||||
std::array<String, 8> extraHeaders{};
|
||||
|
||||
void setBody(const String& value) {
|
||||
body = value;
|
||||
bodyData = nullptr;
|
||||
bodyLength = 0;
|
||||
}
|
||||
|
||||
void setBody(const char* data, std::size_t length) {
|
||||
body.clear();
|
||||
bodyData = data;
|
||||
bodyLength = length;
|
||||
}
|
||||
|
||||
void addHeader(const String& value) {
|
||||
for (auto& header : extraHeaders) {
|
||||
if (header.isEmpty()) {
|
||||
header = value;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
class IHttpHandler {
|
||||
public:
|
||||
virtual ~IHttpHandler() = default;
|
||||
virtual std::size_t bodyLimit(const char* uri) const = 0;
|
||||
virtual bool handle(const HttpRequest& request, HttpResponse& response) = 0;
|
||||
};
|
||||
|
||||
class NativeHttpServer {
|
||||
public:
|
||||
static constexpr std::size_t kMaxBodyBytes = 16U * 1024U;
|
||||
static constexpr std::size_t kMaxAuthBodyBytes = 2U * 1024U;
|
||||
static constexpr std::size_t kMaxUriBytes = 256U;
|
||||
|
||||
bool start(IHttpHandler* handler);
|
||||
void stop();
|
||||
bool running() const { return server_ != nullptr; }
|
||||
|
||||
private:
|
||||
static esp_err_t dispatch(httpd_req_t* request);
|
||||
bool dispatchRequest(httpd_req_t* source, httpd_method_t method,
|
||||
HttpResponse& response);
|
||||
bool readHeader(httpd_req_t* request, const char* name, String& output);
|
||||
bool readBody(httpd_req_t* request, std::size_t limit, String& body);
|
||||
void sendResponse(httpd_req_t* request, const HttpResponse& response);
|
||||
|
||||
httpd_handle_t server_ = nullptr;
|
||||
IHttpHandler* handler_ = nullptr;
|
||||
};
|
||||
|
||||
} // namespace timbre
|
||||
39
src/web/UiAssets.cpp
Executable file
39
src/web/UiAssets.cpp
Executable file
@@ -0,0 +1,39 @@
|
||||
#include "UiAssets.h"
|
||||
|
||||
#include "generated/UiAssets.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace timbre {
|
||||
namespace {
|
||||
|
||||
const EmbeddedAsset kIndexHtml{
|
||||
indexHtml, indexHtmlLength, "text/html; charset=utf-8", indexHtmlSha256};
|
||||
const EmbeddedAsset kAppCss{
|
||||
appCss, appCssLength, "text/css; charset=utf-8", appCssSha256};
|
||||
const EmbeddedAsset kAppJs{
|
||||
appJs, appJsLength, "application/javascript; charset=utf-8", appJsSha256};
|
||||
|
||||
} // namespace
|
||||
|
||||
const EmbeddedAsset* findEmbeddedAsset(const char* path) {
|
||||
if (path == nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
if (std::strcmp(path, "/") == 0 || std::strcmp(path, "/index.html") == 0) {
|
||||
return &kIndexHtml;
|
||||
}
|
||||
if (std::strcmp(path, "/styles.css") == 0) {
|
||||
return &kAppCss;
|
||||
}
|
||||
if (std::strcmp(path, "/app.js") == 0) {
|
||||
return &kAppJs;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::size_t embeddedUiBytes() {
|
||||
return indexHtmlLength + appCssLength + appJsLength;
|
||||
}
|
||||
|
||||
} // namespace timbre
|
||||
17
src/web/UiAssets.h
Executable file
17
src/web/UiAssets.h
Executable file
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
namespace timbre {
|
||||
|
||||
struct EmbeddedAsset {
|
||||
const char* data;
|
||||
std::size_t length;
|
||||
const char* contentType;
|
||||
const char* sha256;
|
||||
};
|
||||
|
||||
const EmbeddedAsset* findEmbeddedAsset(const char* path);
|
||||
std::size_t embeddedUiBytes();
|
||||
|
||||
} // namespace timbre
|
||||
11
test/README
Executable file
11
test/README
Executable file
@@ -0,0 +1,11 @@
|
||||
|
||||
This directory is intended for PlatformIO Test Runner and project tests.
|
||||
|
||||
Unit Testing is a software testing method by which individual units of
|
||||
source code, sets of one or more MCU program modules together with associated
|
||||
control data, usage procedures, and operating procedures, are tested to
|
||||
determine whether they are fit for use. Unit testing finds problems early
|
||||
in the development cycle.
|
||||
|
||||
More information about PlatformIO Unit Testing:
|
||||
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html
|
||||
15
test/test_native_civil_time/FakeMonotonicClock.h
Executable file
15
test/test_native_civil_time/FakeMonotonicClock.h
Executable file
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
#include "SessionClock.h"
|
||||
|
||||
class FakeMonotonicClock final : public timbre_core::MonotonicClock {
|
||||
public:
|
||||
explicit FakeMonotonicClock(std::uint64_t initial = 0) : value_(initial) {}
|
||||
|
||||
std::uint64_t nowMs() const override { return value_; }
|
||||
void advance(std::uint64_t milliseconds) noexcept { value_ += milliseconds; }
|
||||
void set(std::uint64_t milliseconds) noexcept { value_ = milliseconds; }
|
||||
|
||||
private:
|
||||
std::uint64_t value_;
|
||||
};
|
||||
109
test/test_native_civil_time/test_main.cpp
Executable file
109
test/test_native_civil_time/test_main.cpp
Executable file
@@ -0,0 +1,109 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include "CivilTime.h"
|
||||
#include "SessionClock.h"
|
||||
#include "FakeMonotonicClock.h"
|
||||
|
||||
using namespace timbre_core;
|
||||
|
||||
void setUp(void) {}
|
||||
void tearDown(void) {}
|
||||
|
||||
static void test_fixed_utc_minus_six_conversion(void) {
|
||||
// 2024-01-01 00:00 UTC is 2023-12-31 18:00 in the fixed local zone.
|
||||
const CivilDateTime local = CivilTime::fromUnixSeconds(1704067200LL);
|
||||
TEST_ASSERT_EQUAL_INT(2023, local.year);
|
||||
TEST_ASSERT_EQUAL_INT(12, local.month);
|
||||
TEST_ASSERT_EQUAL_INT(31, local.day);
|
||||
TEST_ASSERT_EQUAL_INT(18, local.hour);
|
||||
TEST_ASSERT_EQUAL_INT(0, local.minute);
|
||||
TEST_ASSERT_EQUAL_INT(0, local.second);
|
||||
TEST_ASSERT_EQUAL_INT(0, local.weekday); // Sunday
|
||||
}
|
||||
|
||||
static void test_leap_day_and_round_trip(void) {
|
||||
CivilDateTime leap;
|
||||
leap.year = 2024;
|
||||
leap.month = 2;
|
||||
leap.day = 29;
|
||||
leap.hour = 12;
|
||||
leap.minute = 34;
|
||||
leap.second = 56;
|
||||
leap.weekday = CivilTime::weekdayForDate(leap.year, leap.month, leap.day);
|
||||
|
||||
TEST_ASSERT_TRUE(leap.valid());
|
||||
std::int64_t epoch = 0;
|
||||
TEST_ASSERT_TRUE(CivilTime::toUnixSeconds(leap, epoch));
|
||||
const CivilDateTime roundTrip = CivilTime::fromUnixSeconds(epoch);
|
||||
TEST_ASSERT_TRUE(roundTrip == leap);
|
||||
|
||||
TEST_ASSERT_TRUE(CivilTime::isLeapYear(2024));
|
||||
TEST_ASSERT_FALSE(CivilTime::isLeapYear(1900));
|
||||
TEST_ASSERT_TRUE(CivilTime::isLeapYear(2000));
|
||||
TEST_ASSERT_EQUAL_INT(29, CivilTime::daysInMonth(2024, 2));
|
||||
TEST_ASSERT_EQUAL_INT(28, CivilTime::daysInMonth(1900, 2));
|
||||
}
|
||||
|
||||
static void test_pre_epoch_values_use_floor_seconds(void) {
|
||||
const CivilDateTime local = CivilTime::fromUnixSeconds(-1);
|
||||
TEST_ASSERT_EQUAL_INT(1969, local.year);
|
||||
TEST_ASSERT_EQUAL_INT(12, local.month);
|
||||
TEST_ASSERT_EQUAL_INT(31, local.day);
|
||||
// UTC-1 is 17:59:59 in the fixed UTC-06:00 local zone.
|
||||
TEST_ASSERT_EQUAL_INT(17, local.hour);
|
||||
TEST_ASSERT_EQUAL_INT(59, local.minute);
|
||||
TEST_ASSERT_EQUAL_INT(59, local.second);
|
||||
|
||||
const CivilDateTime invalid{CivilDateTime{2023, 2, 29, 0, 0, 0, 0}};
|
||||
TEST_ASSERT_FALSE(invalid.valid());
|
||||
}
|
||||
|
||||
static void test_date_and_minute_keys_are_stable(void) {
|
||||
CivilDateTime value{2024, 1, 1, 8, 5, 0, 0};
|
||||
const LocalMinuteKey first = LocalMinuteKey::from(value);
|
||||
TEST_ASSERT_EQUAL_INT64(20240101LL, first.date);
|
||||
TEST_ASSERT_EQUAL_INT(485, first.minuteOfDay);
|
||||
|
||||
// Keep the same local time so the serial difference is exactly one day.
|
||||
CivilDateTime nextDay{2024, 1, 2, 8, 5, 0, 0};
|
||||
const LocalMinuteKey second = LocalMinuteKey::from(nextDay);
|
||||
TEST_ASSERT_TRUE(first < second);
|
||||
TEST_ASSERT_TRUE(first != second);
|
||||
TEST_ASSERT_EQUAL_INT64(1LL * 1440LL, second.serial() - first.serial());
|
||||
}
|
||||
|
||||
static void test_session_clock_anchors_first_observation_without_catchup(void) {
|
||||
FakeMonotonicClock source(100);
|
||||
SessionClock clock(source);
|
||||
|
||||
const SessionClock::Observation first = clock.observe(1704067200LL);
|
||||
TEST_ASSERT_TRUE(first.valid);
|
||||
TEST_ASSERT_TRUE(first.firstObservation);
|
||||
TEST_ASSERT_EQUAL_UINT64(100, first.monotonicMs);
|
||||
|
||||
source.advance(5000);
|
||||
const SessionClock::Observation later = clock.observe(1704067205LL);
|
||||
TEST_ASSERT_TRUE(later.valid);
|
||||
TEST_ASSERT_FALSE(later.firstObservation);
|
||||
TEST_ASSERT_FALSE(later.monotonicRegression);
|
||||
TEST_ASSERT_EQUAL_INT(2023, later.local.year);
|
||||
TEST_ASSERT_EQUAL_INT(18, later.local.hour);
|
||||
TEST_ASSERT_EQUAL_INT(0, later.local.minute);
|
||||
TEST_ASSERT_EQUAL_INT(5, later.local.second);
|
||||
|
||||
source.advance(1000);
|
||||
const SessionClock::Observation tick = clock.tick();
|
||||
TEST_ASSERT_TRUE(tick.valid);
|
||||
TEST_ASSERT_EQUAL_INT(6, tick.local.second);
|
||||
TEST_ASSERT_EQUAL_UINT64(6000, clock.sessionElapsedMs());
|
||||
}
|
||||
|
||||
int main(int, char**) {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_fixed_utc_minus_six_conversion);
|
||||
RUN_TEST(test_leap_day_and_round_trip);
|
||||
RUN_TEST(test_pre_epoch_values_use_floor_seconds);
|
||||
RUN_TEST(test_date_and_minute_keys_are_stable);
|
||||
RUN_TEST(test_session_clock_anchors_first_observation_without_catchup);
|
||||
return UNITY_END();
|
||||
}
|
||||
153
test/test_native_pattern_engine/test_main.cpp
Executable file
153
test/test_native_pattern_engine/test_main.cpp
Executable file
@@ -0,0 +1,153 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "PatternEngine.h"
|
||||
|
||||
using namespace timbre_core;
|
||||
|
||||
void setUp(void) {}
|
||||
void tearDown(void) {}
|
||||
|
||||
struct OutputEvent {
|
||||
RelayId relay;
|
||||
bool on;
|
||||
};
|
||||
|
||||
static void test_absolute_deadlines_and_final_off(void) {
|
||||
std::vector<OutputEvent> events;
|
||||
PatternEngine engine([&events](RelayId relay, bool on) {
|
||||
events.push_back(OutputEvent{relay, on});
|
||||
});
|
||||
const Pattern pattern{PatternId::A, {2, 1, 1, 1}};
|
||||
|
||||
TEST_ASSERT_TRUE(engine.start(pattern, 1000));
|
||||
TEST_ASSERT_TRUE(engine.running());
|
||||
TEST_ASSERT_TRUE(engine.outputOn());
|
||||
TEST_ASSERT_EQUAL_UINT64(3000, engine.deadlineMs());
|
||||
TEST_ASSERT_EQUAL_size_t(1, events.size());
|
||||
TEST_ASSERT_TRUE(events[0].on);
|
||||
TEST_ASSERT_EQUAL_UINT8(static_cast<std::uint8_t>(RelayId::Main),
|
||||
static_cast<std::uint8_t>(events[0].relay));
|
||||
|
||||
TEST_ASSERT_FALSE(engine.update(2999));
|
||||
TEST_ASSERT_TRUE(engine.outputOn());
|
||||
TEST_ASSERT_TRUE(engine.update(3000));
|
||||
TEST_ASSERT_FALSE(engine.outputOn());
|
||||
TEST_ASSERT_EQUAL_UINT8(1, engine.activePhase());
|
||||
TEST_ASSERT_EQUAL_UINT64(4000, engine.deadlineMs());
|
||||
|
||||
TEST_ASSERT_TRUE(engine.update(5000));
|
||||
// At 5000 ms the third phase has also reached its boundary; the
|
||||
// absolute-deadline engine therefore advances to the final OFF phase.
|
||||
TEST_ASSERT_FALSE(engine.outputOn());
|
||||
TEST_ASSERT_EQUAL_UINT8(3, engine.activePhase());
|
||||
TEST_ASSERT_EQUAL_UINT64(6000, engine.deadlineMs());
|
||||
|
||||
TEST_ASSERT_TRUE(engine.update(7000));
|
||||
TEST_ASSERT_FALSE(engine.running());
|
||||
TEST_ASSERT_FALSE(engine.outputOn());
|
||||
TEST_ASSERT_EQUAL_UINT8(PatternEngine::kNoPhase, engine.activePhase());
|
||||
TEST_ASSERT_EQUAL_size_t(4, events.size());
|
||||
TEST_ASSERT_FALSE(events.back().on);
|
||||
}
|
||||
|
||||
static void test_clock_regression_turns_output_off(void) {
|
||||
std::vector<OutputEvent> events;
|
||||
PatternEngine engine([&events](RelayId relay, bool on) {
|
||||
events.push_back({relay, on});
|
||||
});
|
||||
const Pattern pattern{PatternId::A, {2, 1}};
|
||||
|
||||
TEST_ASSERT_TRUE(engine.start(pattern, 1000));
|
||||
TEST_ASSERT_TRUE(engine.outputOn());
|
||||
TEST_ASSERT_TRUE(engine.update(500));
|
||||
TEST_ASSERT_FALSE(engine.running());
|
||||
TEST_ASSERT_FALSE(engine.outputOn());
|
||||
TEST_ASSERT_FALSE(events.empty());
|
||||
TEST_ASSERT_FALSE(events.back().on);
|
||||
}
|
||||
|
||||
static void test_late_poll_does_not_accumulate_or_leave_on(void) {
|
||||
std::vector<OutputEvent> events;
|
||||
PatternEngine engine([&events](RelayId, bool on) { events.push_back({RelayId::Main, on}); });
|
||||
const Pattern pattern{PatternId::B, {1, 1, 1, 1, 1, 1}};
|
||||
|
||||
TEST_ASSERT_TRUE(engine.start(pattern, 10));
|
||||
TEST_ASSERT_TRUE(engine.update(1000000));
|
||||
TEST_ASSERT_FALSE(engine.running());
|
||||
TEST_ASSERT_FALSE(engine.outputOn());
|
||||
TEST_ASSERT_EQUAL_UINT64(0, engine.deadlineMs());
|
||||
TEST_ASSERT_FALSE(events.empty());
|
||||
TEST_ASSERT_FALSE(events.back().on);
|
||||
}
|
||||
|
||||
static void test_fixed_queue_capacity_and_absolute_request_time(void) {
|
||||
std::vector<OutputEvent> events;
|
||||
PatternEngine engine([&events](RelayId relay, bool on) {
|
||||
events.push_back({relay, on});
|
||||
});
|
||||
const Pattern pattern{PatternId::C, {1, 1}};
|
||||
|
||||
for (std::size_t i = 0; i < 8; ++i) {
|
||||
TEST_ASSERT_TRUE(engine.enqueue(pattern, 1000));
|
||||
}
|
||||
TEST_ASSERT_EQUAL_size_t(8, engine.queueSize());
|
||||
TEST_ASSERT_FALSE(engine.enqueue(pattern, 1000));
|
||||
TEST_ASSERT_EQUAL_size_t(8, engine.queueSize());
|
||||
TEST_ASSERT_FALSE(engine.running());
|
||||
|
||||
engine.update(999);
|
||||
TEST_ASSERT_FALSE(engine.running());
|
||||
TEST_ASSERT_EQUAL_size_t(8, engine.queueSize());
|
||||
engine.update(1000);
|
||||
TEST_ASSERT_TRUE(engine.running());
|
||||
TEST_ASSERT_EQUAL_size_t(7, engine.queueSize());
|
||||
TEST_ASSERT_TRUE(engine.outputOn());
|
||||
TEST_ASSERT_EQUAL_UINT64(2000, engine.deadlineMs());
|
||||
}
|
||||
|
||||
static void test_invalid_pattern_id_and_zero_phase_are_safe(void) {
|
||||
std::vector<OutputEvent> events;
|
||||
PatternEngine engine([&events](RelayId relay, bool on) {
|
||||
events.push_back({relay, on});
|
||||
});
|
||||
|
||||
TEST_ASSERT_FALSE(engine.start(static_cast<PatternId>(99), 0));
|
||||
TEST_ASSERT_FALSE(engine.running());
|
||||
TEST_ASSERT_TRUE(events.empty());
|
||||
|
||||
const Pattern silent{PatternId::A, {0, 0}};
|
||||
TEST_ASSERT_TRUE(engine.start(silent, 0));
|
||||
TEST_ASSERT_FALSE(engine.running());
|
||||
TEST_ASSERT_FALSE(engine.outputOn());
|
||||
TEST_ASSERT_EQUAL_size_t(0, events.size());
|
||||
}
|
||||
|
||||
static void test_reserved_gpio_is_never_driven(void) {
|
||||
std::vector<OutputEvent> events;
|
||||
PatternEngine engine([&events](RelayId relay, bool on) {
|
||||
events.push_back({relay, on});
|
||||
});
|
||||
const Pattern pattern{PatternId::A, {1, 1}};
|
||||
TEST_ASSERT_TRUE(engine.start(pattern, 0));
|
||||
TEST_ASSERT_TRUE(engine.update(2000));
|
||||
TEST_ASSERT_FALSE(engine.reservedRelayDriven());
|
||||
TEST_ASSERT_EQUAL_UINT8(static_cast<std::uint8_t>(RelayId::Main),
|
||||
static_cast<std::uint8_t>(engine.lastOutputRelay()));
|
||||
for (const OutputEvent& event : events) {
|
||||
TEST_ASSERT_EQUAL_UINT8(static_cast<std::uint8_t>(RelayId::Main),
|
||||
static_cast<std::uint8_t>(event.relay));
|
||||
}
|
||||
}
|
||||
|
||||
int main(int, char**) {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_absolute_deadlines_and_final_off);
|
||||
RUN_TEST(test_clock_regression_turns_output_off);
|
||||
RUN_TEST(test_late_poll_does_not_accumulate_or_leave_on);
|
||||
RUN_TEST(test_fixed_queue_capacity_and_absolute_request_time);
|
||||
RUN_TEST(test_invalid_pattern_id_and_zero_phase_are_safe);
|
||||
RUN_TEST(test_reserved_gpio_is_never_driven);
|
||||
return UNITY_END();
|
||||
}
|
||||
15
test/test_native_scheduler/FakeMonotonicClock.h
Executable file
15
test/test_native_scheduler/FakeMonotonicClock.h
Executable file
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
#include "SessionClock.h"
|
||||
|
||||
class FakeMonotonicClock final : public timbre_core::MonotonicClock {
|
||||
public:
|
||||
explicit FakeMonotonicClock(std::uint64_t initial = 0) : value_(initial) {}
|
||||
|
||||
std::uint64_t nowMs() const override { return value_; }
|
||||
void advance(std::uint64_t milliseconds) noexcept { value_ += milliseconds; }
|
||||
void set(std::uint64_t milliseconds) noexcept { value_ = milliseconds; }
|
||||
|
||||
private:
|
||||
std::uint64_t value_;
|
||||
};
|
||||
136
test/test_native_scheduler/test_main.cpp
Executable file
136
test/test_native_scheduler/test_main.cpp
Executable file
@@ -0,0 +1,136 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include "Scheduler.h"
|
||||
#include "FakeMonotonicClock.h"
|
||||
|
||||
using namespace timbre_core;
|
||||
|
||||
void setUp(void) {}
|
||||
void tearDown(void) {}
|
||||
|
||||
static AppState schedulerState() {
|
||||
AppState state;
|
||||
state.profiles.push_back(Profile{4, "Escuela"});
|
||||
state.activeProfileId = 4;
|
||||
state.patterns[0] = Pattern{PatternId::A, {2, 1, 2, 1, 2, 1}};
|
||||
state.patterns[1] = Pattern{PatternId::B, {1, 1, 1, 1, 1, 1}};
|
||||
state.patterns[2] = Pattern{PatternId::C, {1, 1, 1, 1, 1, 1}};
|
||||
// 08:30 Monday through Friday. Bit 1 is Monday.
|
||||
state.schedules.push_back(Schedule{20, 4, PatternId::A, 8 * 60 + 30, 0x3E, true});
|
||||
return state;
|
||||
}
|
||||
|
||||
static CivilDateTime mondayAt(std::uint8_t hour, std::uint8_t minute) {
|
||||
CivilDateTime value{2024, 1, 1, hour, minute, 0, 0};
|
||||
value.weekday = CivilTime::weekdayForDate(value.year, value.month, value.day);
|
||||
return value;
|
||||
}
|
||||
|
||||
static void test_first_observation_and_same_key_are_not_replayed(void) {
|
||||
Scheduler scheduler;
|
||||
const AppState state = schedulerState();
|
||||
|
||||
ScheduleDecision decision = scheduler.poll(state, mondayAt(8, 30), true);
|
||||
TEST_ASSERT_FALSE(decision.due);
|
||||
TEST_ASSERT_TRUE(decision.firstObservation);
|
||||
|
||||
decision = scheduler.poll(state, mondayAt(8, 30), false);
|
||||
TEST_ASSERT_FALSE(decision.due);
|
||||
TEST_ASSERT_TRUE(decision.alreadyProcessed);
|
||||
|
||||
decision = scheduler.poll(state, mondayAt(8, 30), false);
|
||||
TEST_ASSERT_FALSE(decision.due);
|
||||
TEST_ASSERT_TRUE(decision.alreadyProcessed);
|
||||
}
|
||||
|
||||
static void test_current_minute_is_triggered_once(void) {
|
||||
Scheduler scheduler;
|
||||
const AppState state = schedulerState();
|
||||
|
||||
TEST_ASSERT_FALSE(scheduler.poll(state, mondayAt(8, 29), true).due);
|
||||
const ScheduleDecision due = scheduler.poll(state, mondayAt(8, 30), false);
|
||||
TEST_ASSERT_TRUE(due.due);
|
||||
TEST_ASSERT_EQUAL_UINT16(20, due.scheduleId);
|
||||
TEST_ASSERT_EQUAL_UINT8(static_cast<std::uint8_t>(PatternId::A),
|
||||
static_cast<std::uint8_t>(due.patternId));
|
||||
|
||||
const ScheduleDecision duplicate = scheduler.poll(state, mondayAt(8, 30), false);
|
||||
TEST_ASSERT_FALSE(duplicate.due);
|
||||
TEST_ASSERT_TRUE(duplicate.alreadyProcessed);
|
||||
}
|
||||
|
||||
static void test_gap_does_not_replay_intermediate_minutes(void) {
|
||||
Scheduler scheduler;
|
||||
AppState state = schedulerState();
|
||||
// Only 08:31 is configured for this isolated test.
|
||||
state.schedules.clear();
|
||||
state.schedules.push_back(Schedule{21, 4, PatternId::C, 8 * 60 + 31, 0x3E, true});
|
||||
|
||||
TEST_ASSERT_FALSE(scheduler.poll(state, mondayAt(8, 30), true).due);
|
||||
const ScheduleDecision skipped = scheduler.poll(state, mondayAt(8, 33), false);
|
||||
TEST_ASSERT_FALSE(skipped.due);
|
||||
TEST_ASSERT_EQUAL_UINT64(2, skipped.missedMinutes);
|
||||
|
||||
// A wall-clock rollback cannot make the old minute ring again.
|
||||
const ScheduleDecision rollback = scheduler.poll(state, mondayAt(8, 31), false);
|
||||
TEST_ASSERT_FALSE(rollback.due);
|
||||
TEST_ASSERT_TRUE(rollback.alreadyProcessed);
|
||||
}
|
||||
|
||||
static void test_weekday_profile_and_holiday_guards(void) {
|
||||
Scheduler scheduler;
|
||||
AppState state = schedulerState();
|
||||
state.holidays.push_back(Holiday{1, 1, 1, true});
|
||||
|
||||
TEST_ASSERT_FALSE(scheduler.poll(state, mondayAt(8, 29), true).due);
|
||||
const ScheduleDecision holiday = scheduler.poll(state, mondayAt(8, 30), false);
|
||||
TEST_ASSERT_FALSE(holiday.due);
|
||||
TEST_ASSERT_TRUE(holiday.blockedByHoliday);
|
||||
|
||||
state.holidays.clear();
|
||||
state.activeProfileId = 99;
|
||||
const ScheduleDecision wrongProfile = scheduler.poll(state, mondayAt(8, 31), false);
|
||||
TEST_ASSERT_FALSE(wrongProfile.due);
|
||||
|
||||
state.activeProfileId = 4;
|
||||
const CivilDateTime sunday{2024, 1, 7, 8, 30, 0, 0};
|
||||
const ScheduleDecision wrongDay = scheduler.poll(state, sunday, false);
|
||||
TEST_ASSERT_FALSE(wrongDay.due);
|
||||
}
|
||||
|
||||
static void test_session_clock_integration_uses_first_observation_flag(void) {
|
||||
Scheduler scheduler;
|
||||
const AppState state = schedulerState();
|
||||
FakeMonotonicClock source(10);
|
||||
SessionClock clock(source);
|
||||
const CivilDateTime firstLocal = mondayAt(8, 30);
|
||||
const std::int64_t firstEpoch = CivilTime::toUnixSeconds(firstLocal);
|
||||
|
||||
const ScheduleDecision first = scheduler.poll(state, clock, firstEpoch);
|
||||
TEST_ASSERT_FALSE(first.due);
|
||||
TEST_ASSERT_TRUE(first.firstObservation);
|
||||
|
||||
source.advance(60000);
|
||||
const ScheduleDecision due = scheduler.poll(
|
||||
state, clock, CivilTime::toUnixSeconds(mondayAt(8, 31)));
|
||||
TEST_ASSERT_FALSE(due.due); // configured at 08:30, not 08:31
|
||||
|
||||
source.advance(60000);
|
||||
const CivilDateTime sameMinute = mondayAt(8, 30);
|
||||
// The local key is already consumed; moving the fake wall source back
|
||||
// must not replay it.
|
||||
const ScheduleDecision replay = scheduler.poll(state, clock,
|
||||
CivilTime::toUnixSeconds(sameMinute));
|
||||
TEST_ASSERT_FALSE(replay.due);
|
||||
TEST_ASSERT_TRUE(replay.alreadyProcessed);
|
||||
}
|
||||
|
||||
int main(int, char**) {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_first_observation_and_same_key_are_not_replayed);
|
||||
RUN_TEST(test_current_minute_is_triggered_once);
|
||||
RUN_TEST(test_gap_does_not_replay_intermediate_minutes);
|
||||
RUN_TEST(test_weekday_profile_and_holiday_guards);
|
||||
RUN_TEST(test_session_clock_integration_uses_first_observation_flag);
|
||||
return UNITY_END();
|
||||
}
|
||||
165
test/test_native_validation/test_main.cpp
Executable file
165
test/test_native_validation/test_main.cpp
Executable file
@@ -0,0 +1,165 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include "ScheduleValidator.h"
|
||||
#include "TimbreService.h"
|
||||
|
||||
using namespace timbre_core;
|
||||
|
||||
void setUp(void) {}
|
||||
void tearDown(void) {}
|
||||
|
||||
static AppState validState() {
|
||||
AppState state;
|
||||
state.profiles.push_back(Profile{7, "Principal"});
|
||||
state.activeProfileId = 7;
|
||||
state.patterns[0] = Pattern{PatternId::A, {1, 1, 1, 1, 1, 1}};
|
||||
state.patterns[1] = Pattern{PatternId::B, {2, 1, 2, 1, 2, 1}};
|
||||
state.patterns[2] = Pattern{PatternId::C, {1, 2, 1, 2, 1, 2}};
|
||||
return state;
|
||||
}
|
||||
|
||||
static void test_limits_and_canonical_patterns(void) {
|
||||
TEST_ASSERT_EQUAL_size_t(8, kMaxProfiles);
|
||||
TEST_ASSERT_EQUAL_size_t(3, kMaxPatterns);
|
||||
TEST_ASSERT_EQUAL_size_t(100, kMaxSchedules);
|
||||
TEST_ASSERT_EQUAL_size_t(64, kMaxHolidays);
|
||||
TEST_ASSERT_EQUAL_size_t(8, kPatternQueueCapacity);
|
||||
TEST_ASSERT_EQUAL_UINT8(99, kMaxPhaseDurationSeconds);
|
||||
TEST_ASSERT_EQUAL_UINT16(600, kMaxPatternTotalSeconds);
|
||||
|
||||
TEST_ASSERT_TRUE(isPatternId(PatternId::A));
|
||||
TEST_ASSERT_TRUE(isPatternId(PatternId::B));
|
||||
TEST_ASSERT_TRUE(isPatternId(PatternId::C));
|
||||
TEST_ASSERT_FALSE(isPatternId(static_cast<PatternId>(3)));
|
||||
TEST_ASSERT_EQUAL_UINT8(22, gpioForRelay(RelayId::Main));
|
||||
TEST_ASSERT_EQUAL_UINT8(23, gpioForRelay(RelayId::Reserved));
|
||||
TEST_ASSERT_TRUE(isReservedRelay(RelayId::Reserved));
|
||||
TEST_ASSERT_FALSE(isReservedRelay(RelayId::Main));
|
||||
}
|
||||
|
||||
static void test_valid_state_and_phase_limits(void) {
|
||||
AppState state = validState();
|
||||
TEST_ASSERT_TRUE(ScheduleValidator::validate(state).valid);
|
||||
|
||||
state.patterns[0].phases[0] = 100;
|
||||
ValidationResult result = ScheduleValidator::validate(state);
|
||||
TEST_ASSERT_FALSE(result.valid);
|
||||
TEST_ASSERT_EQUAL_UINT8(static_cast<std::uint8_t>(ValidationCode::InvalidPhaseDuration),
|
||||
static_cast<std::uint8_t>(result.code));
|
||||
|
||||
state = validState();
|
||||
state.patterns[1].phaseCount = kMaxPatternPhases + 1;
|
||||
result = ScheduleValidator::validate(state);
|
||||
TEST_ASSERT_FALSE(result.valid);
|
||||
TEST_ASSERT_EQUAL_UINT8(static_cast<std::uint8_t>(ValidationCode::InvalidPhaseCount),
|
||||
static_cast<std::uint8_t>(result.code));
|
||||
|
||||
state = validState();
|
||||
state.patterns[2].phaseCount = 6;
|
||||
for (std::size_t i = 0; i < 6; ++i) {
|
||||
state.patterns[2].phases[i] = 99;
|
||||
}
|
||||
TEST_ASSERT_TRUE(ScheduleValidator::validate(state).valid);
|
||||
}
|
||||
|
||||
static void test_schedule_references_and_stable_ids(void) {
|
||||
AppState state = validState();
|
||||
Schedule schedule{10, 7, PatternId::A, 8 * 60 + 30, 0x7F, true};
|
||||
state.schedules.push_back(schedule);
|
||||
TEST_ASSERT_TRUE(ScheduleValidator::validate(state).valid);
|
||||
|
||||
Schedule* found = state.schedule(10);
|
||||
TEST_ASSERT_NOT_NULL(found);
|
||||
TEST_ASSERT_EQUAL_UINT16(510, found->minuteOfDay);
|
||||
|
||||
state.schedules[0].profileId = 99;
|
||||
ValidationResult result = ScheduleValidator::validate(state);
|
||||
TEST_ASSERT_FALSE(result.valid);
|
||||
TEST_ASSERT_EQUAL_UINT8(static_cast<std::uint8_t>(ValidationCode::UnknownProfile),
|
||||
static_cast<std::uint8_t>(result.code));
|
||||
|
||||
state = validState();
|
||||
state.profiles.push_back(Profile{7, "Duplicado"});
|
||||
result = ScheduleValidator::validate(state);
|
||||
TEST_ASSERT_FALSE(result.valid);
|
||||
TEST_ASSERT_EQUAL_UINT8(static_cast<std::uint8_t>(ValidationCode::DuplicateProfileId),
|
||||
static_cast<std::uint8_t>(result.code));
|
||||
}
|
||||
|
||||
static void test_holiday_validation_and_capacity(void) {
|
||||
AppState state = validState();
|
||||
state.holidays.push_back(Holiday{1, 1, 1, true});
|
||||
state.holidays.push_back(Holiday{1, 1, 2, true});
|
||||
ValidationResult result = ScheduleValidator::validate(state);
|
||||
TEST_ASSERT_FALSE(result.valid);
|
||||
TEST_ASSERT_EQUAL_UINT8(static_cast<std::uint8_t>(ValidationCode::DuplicateHolidayId),
|
||||
static_cast<std::uint8_t>(result.code));
|
||||
|
||||
state = validState();
|
||||
for (std::size_t i = 0; i < 64; ++i) {
|
||||
const std::uint8_t month = static_cast<std::uint8_t>((i % 12) + 1);
|
||||
const std::uint8_t day = static_cast<std::uint8_t>((i / 12) + 1);
|
||||
TEST_ASSERT_TRUE(state.holidays.push_back(
|
||||
Holiday{static_cast<HolidayId>(i + 1), month, day, true}));
|
||||
}
|
||||
TEST_ASSERT_EQUAL_size_t(64, state.holidays.size());
|
||||
TEST_ASSERT_TRUE(ScheduleValidator::validate(state).valid);
|
||||
|
||||
state.holidays[0].month = 0;
|
||||
result = ScheduleValidator::validate(state);
|
||||
TEST_ASSERT_FALSE(result.valid);
|
||||
TEST_ASSERT_EQUAL_UINT8(static_cast<std::uint8_t>(ValidationCode::InvalidHolidayDate),
|
||||
static_cast<std::uint8_t>(result.code));
|
||||
}
|
||||
|
||||
static void test_schedule_and_profile_capacity(void) {
|
||||
AppState state;
|
||||
for (std::size_t i = 0; i < kMaxProfiles; ++i) {
|
||||
TEST_ASSERT_TRUE(state.profiles.push_back(
|
||||
Profile{static_cast<ProfileId>(i + 1), "P"}));
|
||||
}
|
||||
state.activeProfileId = 1;
|
||||
for (std::size_t i = 0; i < kMaxSchedules; ++i) {
|
||||
TEST_ASSERT_TRUE(state.schedules.push_back(
|
||||
Schedule{static_cast<ScheduleId>(i + 1), 1, PatternId::A, 0, 0x7F, true}));
|
||||
}
|
||||
TEST_ASSERT_TRUE(ScheduleValidator::validate(state).valid);
|
||||
TEST_ASSERT_EQUAL_size_t(kMaxProfiles, state.profiles.capacity());
|
||||
TEST_ASSERT_EQUAL_size_t(kMaxSchedules, state.schedules.capacity());
|
||||
}
|
||||
|
||||
static void test_transaction_publishes_only_after_commit(void) {
|
||||
MemoryStateRepository repository;
|
||||
AppState initial = validState();
|
||||
repository.seed(initial);
|
||||
TimbreService service(repository);
|
||||
TEST_ASSERT_TRUE(service.load());
|
||||
|
||||
const std::size_t before = service.state().schedules.size();
|
||||
repository.setCommitSucceeds(false);
|
||||
TEST_ASSERT_FALSE(service.transact([](AppState& candidate) {
|
||||
candidate.schedules.push_back(Schedule{44, 7, PatternId::B, 60, 0x7F, true});
|
||||
}));
|
||||
TEST_ASSERT_EQUAL_size_t(before, service.state().schedules.size());
|
||||
TEST_ASSERT_EQUAL_UINT8(static_cast<std::uint8_t>(ServiceCode::CommitFailed),
|
||||
static_cast<std::uint8_t>(service.lastError()));
|
||||
|
||||
repository.setCommitSucceeds(true);
|
||||
TEST_ASSERT_TRUE(service.transact([](AppState& candidate) {
|
||||
candidate.schedules.push_back(Schedule{44, 7, PatternId::B, 60, 0x7F, true});
|
||||
}));
|
||||
TEST_ASSERT_EQUAL_size_t(before + 1, service.state().schedules.size());
|
||||
TEST_ASSERT_EQUAL_UINT16(before == 0 ? 1 : 0,
|
||||
service.state().schedules.back().id == 44 ? 1 : 0);
|
||||
}
|
||||
|
||||
int main(int, char**) {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_limits_and_canonical_patterns);
|
||||
RUN_TEST(test_valid_state_and_phase_limits);
|
||||
RUN_TEST(test_schedule_references_and_stable_ids);
|
||||
RUN_TEST(test_holiday_validation_and_capacity);
|
||||
RUN_TEST(test_schedule_and_profile_capacity);
|
||||
RUN_TEST(test_transaction_publishes_only_after_commit);
|
||||
return UNITY_END();
|
||||
}
|
||||
22
ui/README.md
Executable file
22
ui/README.md
Executable file
@@ -0,0 +1,22 @@
|
||||
# Dashboard TimbreESP
|
||||
|
||||
Frontend local, sin framework y sin recursos de red en tiempo de ejecución.
|
||||
|
||||
## Desarrollo
|
||||
|
||||
```powershell
|
||||
cd ui
|
||||
npm ci
|
||||
npm run build
|
||||
node --check app.js
|
||||
```
|
||||
|
||||
`npm run build` genera:
|
||||
|
||||
- `dist/index.html`
|
||||
- `dist/styles.min.css`
|
||||
- `dist/app.min.js`
|
||||
|
||||
El build de PlatformIO ejecuta `npm run build` automáticamente si Node.js y `ui/node_modules` están disponibles. Si no, incrusta los fuentes sin minificar como respaldo.
|
||||
|
||||
No se deben añadir recursos externos, frameworks, WebSockets ni información de credenciales al navegador.
|
||||
397
ui/index.html
Executable file
397
ui/index.html
Executable file
@@ -0,0 +1,397 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#123047">
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'">
|
||||
<title>TimbreESP · Control local</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<script src="app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#contenido">Saltar al contenido</a>
|
||||
|
||||
<div id="app" aria-busy="true">
|
||||
<section id="screen-bootstrap" class="auth-screen" aria-labelledby="bootstrap-title">
|
||||
<div class="auth-card auth-card--compact">
|
||||
<div class="brand brand--centered" aria-label="TimbreESP">
|
||||
<span class="brand-mark" aria-hidden="true">TE</span>
|
||||
<span>TimbreESP</span>
|
||||
</div>
|
||||
<div class="state-block" role="status" aria-live="polite">
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
<div>
|
||||
<h1 id="bootstrap-title">Preparando el equipo</h1>
|
||||
<p id="bootstrap-message">Comprobando el estado de la configuración.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="bootstrap-error" class="notice notice--error" role="alert" hidden>
|
||||
<p id="bootstrap-error-message"></p>
|
||||
<button id="bootstrap-retry" class="button button--secondary" type="button">Reintentar</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="screen-claim" class="auth-screen" aria-labelledby="claim-title" hidden>
|
||||
<div class="auth-card">
|
||||
<div class="brand brand--centered" aria-label="TimbreESP">
|
||||
<span class="brand-mark" aria-hidden="true">TE</span>
|
||||
<span>TimbreESP</span>
|
||||
</div>
|
||||
<div class="auth-heading">
|
||||
<p class="eyebrow">Configuración inicial</p>
|
||||
<h1 id="claim-title" tabindex="-1">Protege el equipo</h1>
|
||||
<p>Usa el token de un solo uso y crea una contraseña para esta interfaz local.</p>
|
||||
</div>
|
||||
<form id="claim-form" class="form-stack" novalidate>
|
||||
<div class="form-field">
|
||||
<label for="claim-token">Token de configuración</label>
|
||||
<input id="claim-token" name="token" type="text" required minlength="16" autocomplete="off" autocapitalize="none" spellcheck="false" maxlength="128" placeholder="Introduce el token entregado por el equipo">
|
||||
<p class="field-help">El token se invalida después de Claim. No se conserva en este navegador.</p>
|
||||
</div>
|
||||
<div class="form-grid form-grid--two">
|
||||
<div class="form-field">
|
||||
<label for="claim-password">Nueva contraseña</label>
|
||||
<input id="claim-password" name="password" type="password" required minlength="12" maxlength="128" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="claim-password-confirm">Repite la contraseña</label>
|
||||
<input id="claim-password-confirm" name="passwordConfirm" type="password" required minlength="12" maxlength="128" autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
<p id="claim-error" class="form-error" role="alert" hidden></p>
|
||||
<button id="claim-submit" class="button button--primary button--wide" type="submit">Claim y continuar</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="screen-login" class="auth-screen" aria-labelledby="login-title" hidden>
|
||||
<div class="auth-card auth-card--compact">
|
||||
<div class="brand brand--centered" aria-label="TimbreESP">
|
||||
<span class="brand-mark" aria-hidden="true">TE</span>
|
||||
<span>TimbreESP</span>
|
||||
</div>
|
||||
<div class="auth-heading">
|
||||
<p class="eyebrow">Acceso local</p>
|
||||
<h1 id="login-title" tabindex="-1">Iniciar sesión</h1>
|
||||
<p id="login-intro">Introduce la contraseña configurada para controlar el timbre.</p>
|
||||
</div>
|
||||
<form id="login-form" class="form-stack" novalidate>
|
||||
<div class="form-field">
|
||||
<label for="login-password">Contraseña</label>
|
||||
<input id="login-password" name="password" type="password" required maxlength="128" autocomplete="current-password" autofocus>
|
||||
</div>
|
||||
<p id="login-error" class="form-error" role="alert" hidden></p>
|
||||
<button id="login-submit" class="button button--primary button--wide" type="submit">Entrar</button>
|
||||
</form>
|
||||
<p class="privacy-note">La sesión y el token de protección se mantienen únicamente en memoria.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="dashboard" hidden>
|
||||
<header class="topbar">
|
||||
<div class="topbar__inner">
|
||||
<div class="brand">
|
||||
<span class="brand-mark" aria-hidden="true">TE</span>
|
||||
<span>
|
||||
<strong>TimbreESP</strong>
|
||||
<small>Control local</small>
|
||||
</span>
|
||||
</div>
|
||||
<div class="topbar__actions">
|
||||
<span id="connection-badge" class="connection-badge connection-badge--loading" role="status" aria-live="polite">
|
||||
<span class="status-indicator" aria-hidden="true"></span>
|
||||
<span id="connection-text">Conectando</span>
|
||||
</span>
|
||||
<button id="logout-button" class="button button--quiet" type="button">Cerrar sesión</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="contenido" class="page-shell" tabindex="-1">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Panel operativo</p>
|
||||
<h1>Estado del equipo</h1>
|
||||
<p>Configuración, programación y comprobaciones del timbre principal.</p>
|
||||
</div>
|
||||
<button id="refresh-data" class="button button--secondary" type="button">Actualizar datos</button>
|
||||
</div>
|
||||
|
||||
<div id="csrf-warning" class="notice notice--warning" role="alert" hidden>
|
||||
La sesión está iniciada, pero el dispositivo no entregó un token de protección. Las acciones de configuración se bloquearán.
|
||||
</div>
|
||||
|
||||
<section class="overview-grid" aria-label="Resumen del equipo">
|
||||
<article class="card clock-card" aria-labelledby="clock-title">
|
||||
<div class="card-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Hora del dispositivo</p>
|
||||
<h2 id="clock-title">Reloj</h2>
|
||||
</div>
|
||||
<span id="clock-indicator" class="state-pill state-pill--muted">Sin datos</span>
|
||||
</div>
|
||||
<div class="clock-readout" aria-live="off">
|
||||
<time id="clock-value" datetime="">--:--:--</time>
|
||||
<p id="clock-date">Esperando estado…</p>
|
||||
</div>
|
||||
<p id="clock-status" class="status-message status-message--muted">Consultando la hora.</p>
|
||||
<div class="card-footer card-footer--actions">
|
||||
<button id="sync-time-button" class="button button--secondary" type="button">Usar la hora de este dispositivo</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card" aria-labelledby="sequence-title">
|
||||
<div class="card-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Salida principal</p>
|
||||
<h2 id="sequence-title">Ciclo del timbre</h2>
|
||||
</div>
|
||||
<span id="sequence-indicator" class="state-pill state-pill--muted">Inactivo</span>
|
||||
</div>
|
||||
<dl class="detail-list">
|
||||
<div>
|
||||
<dt>Patrón</dt>
|
||||
<dd id="active-pattern-label">—</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Salida</dt>
|
||||
<dd>GPIO22</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p id="state-message" class="status-message status-message--muted">Consultando el estado…</p>
|
||||
<div class="card-footer">
|
||||
<button id="stop-button" class="button button--danger" type="button" disabled>Detener secuencia</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card" aria-labelledby="hardware-title">
|
||||
<div class="card-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Salidas</p>
|
||||
<h2 id="hardware-title">Hardware</h2>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="hardware-list">
|
||||
<li>
|
||||
<span class="hardware-icon hardware-icon--primary" aria-hidden="true">22</span>
|
||||
<span><strong>GPIO22</strong><small>Timbre principal</small></span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="hardware-icon" aria-hidden="true">23</span>
|
||||
<span><strong>GPIO23</strong><small>Reservado; no se controla</small></span>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="card section-card" aria-labelledby="profiles-title">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Modos de operación</p>
|
||||
<h2 id="profiles-title">Perfiles</h2>
|
||||
<p>Selecciona el perfil activo y organiza distintos modos de horario.</p>
|
||||
</div>
|
||||
<button id="add-profile-button" class="button button--primary" type="button">Añadir perfil</button>
|
||||
</div>
|
||||
<div class="active-profile-panel">
|
||||
<form id="active-profile-form" class="active-profile-form">
|
||||
<div class="form-field">
|
||||
<label for="active-profile-select">Perfil activo</label>
|
||||
<select id="active-profile-select" required disabled>
|
||||
<option value="">Cargando perfiles…</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="active-profile-submit" class="button button--secondary" type="submit" disabled>Guardar perfil activo</button>
|
||||
</form>
|
||||
</div>
|
||||
<div id="profiles-content" class="resource-content" aria-live="polite" aria-busy="true"></div>
|
||||
</section>
|
||||
|
||||
<section class="card section-card" aria-labelledby="schedules-title">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Automatización</p>
|
||||
<h2 id="schedules-title">Horarios</h2>
|
||||
<p>El timbre principal puede tener como máximo 100 horarios entre todos los perfiles.</p>
|
||||
</div>
|
||||
<button id="add-schedule-button" class="button button--primary" type="button">Añadir horario</button>
|
||||
</div>
|
||||
<div class="schedule-toolbar">
|
||||
<div id="profile-tabs" class="segmented-control" role="tablist" aria-label="Filtrar horarios por perfil"></div>
|
||||
<span id="schedule-count" class="count-badge">0 / 100 horarios</span>
|
||||
</div>
|
||||
<div id="schedules-content" class="resource-content" aria-live="polite" aria-busy="true"></div>
|
||||
</section>
|
||||
|
||||
<section class="card section-card" aria-labelledby="patterns-title">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Secuencias electricas</p>
|
||||
<h2 id="patterns-title">Patrones</h2>
|
||||
<p>Cada patrón alterna seis fases: encendido, apagado, encendido, apagado, encendido y apagado.</p>
|
||||
</div>
|
||||
<button id="add-pattern-button" class="button button--primary" type="button">Añadir patrón</button>
|
||||
</div>
|
||||
<div class="hardware-note">
|
||||
<strong>Destino:</strong> GPIO22 (principal). GPIO23 está reservado y no se puede seleccionar.
|
||||
</div>
|
||||
<div id="patterns-content" class="resource-content" aria-live="polite" aria-busy="true"></div>
|
||||
</section>
|
||||
|
||||
<section class="card section-card" aria-labelledby="holidays-title">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Excepciones anuales</p>
|
||||
<h2 id="holidays-title">Feriados</h2>
|
||||
<p>Las fechas de silencio se repiten cada año, sin guardar un año concreto.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form id="holiday-form" class="holiday-form" novalidate>
|
||||
<div class="form-field">
|
||||
<label for="holiday-month">Mes</label>
|
||||
<select id="holiday-month" name="month" required></select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="holiday-day">Día</label>
|
||||
<input id="holiday-day" name="day" type="number" min="1" max="31" step="1" value="1" required inputmode="numeric">
|
||||
</div>
|
||||
<button id="holiday-submit" class="button button--primary" type="submit">Añadir feriado</button>
|
||||
</form>
|
||||
<p id="holiday-error" class="form-error" role="alert" hidden></p>
|
||||
<div id="holidays-content" class="resource-content" aria-live="polite" aria-busy="true"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="page-footer">
|
||||
<p>Interfaz local de TimbreESP. No se carga ningún recurso desde Internet.</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="profile-dialog" class="dialog" aria-labelledby="profile-dialog-title">
|
||||
<form id="profile-form" class="dialog__body" novalidate>
|
||||
<div class="dialog__header">
|
||||
<div>
|
||||
<p class="eyebrow">Perfil</p>
|
||||
<h2 id="profile-dialog-title">Añadir perfil</h2>
|
||||
</div>
|
||||
<button class="icon-button" type="button" data-close-dialog aria-label="Cerrar">×</button>
|
||||
</div>
|
||||
<input id="profile-id" name="id" type="hidden">
|
||||
<div class="form-field">
|
||||
<label for="profile-name">Nombre del perfil</label>
|
||||
<input id="profile-name" name="name" type="text" required maxlength="32" autocomplete="off" placeholder="Ej. horario de invierno">
|
||||
</div>
|
||||
<p id="profile-form-error" class="form-error" role="alert" hidden></p>
|
||||
<div class="dialog__actions">
|
||||
<button class="button button--quiet" type="button" data-close-dialog>Cancelar</button>
|
||||
<button id="profile-submit" class="button button--primary" type="submit">Guardar perfil</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="schedule-dialog" class="dialog dialog--wide" aria-labelledby="schedule-dialog-title">
|
||||
<form id="schedule-form" class="dialog__body" novalidate>
|
||||
<div class="dialog__header">
|
||||
<div>
|
||||
<p class="eyebrow">Programación</p>
|
||||
<h2 id="schedule-dialog-title">Añadir horario</h2>
|
||||
</div>
|
||||
<button class="icon-button" type="button" data-close-dialog aria-label="Cerrar">×</button>
|
||||
</div>
|
||||
<input id="schedule-id" name="id" type="hidden">
|
||||
<div class="form-grid form-grid--two">
|
||||
<div class="form-field">
|
||||
<label for="schedule-time">Hora</label>
|
||||
<input id="schedule-time" name="time" type="time" required>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="schedule-profile">Perfil</label>
|
||||
<select id="schedule-profile" name="profileId" required></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="schedule-pattern">Patrón del timbre</label>
|
||||
<select id="schedule-pattern" name="patternId" required></select>
|
||||
</div>
|
||||
<fieldset class="days-fieldset">
|
||||
<legend>Días de la semana</legend>
|
||||
<p class="field-help">Se envía como bitmask: domingo es el bit 0 y sábado el bit 6.</p>
|
||||
<div id="schedule-days" class="day-picker"></div>
|
||||
</fieldset>
|
||||
<p id="schedule-form-error" class="form-error" role="alert" hidden></p>
|
||||
<div class="dialog__actions">
|
||||
<button class="button button--quiet" type="button" data-close-dialog>Cancelar</button>
|
||||
<button id="schedule-submit" class="button button--primary" type="submit">Guardar horario</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="pattern-dialog" class="dialog dialog--wide" aria-labelledby="pattern-dialog-title">
|
||||
<form id="pattern-form" class="dialog__body" novalidate>
|
||||
<div class="dialog__header">
|
||||
<div>
|
||||
<p class="eyebrow">Ciclo eléctrico</p>
|
||||
<h2 id="pattern-dialog-title">Añadir patrón</h2>
|
||||
</div>
|
||||
<button class="icon-button" type="button" data-close-dialog aria-label="Cerrar">×</button>
|
||||
</div>
|
||||
<input id="pattern-id" name="id" type="hidden">
|
||||
<div class="form-grid form-grid--two">
|
||||
<div class="form-field">
|
||||
<label for="pattern-code">Código</label>
|
||||
<select id="pattern-code" name="legacyCode" required>
|
||||
<option value="A">A</option>
|
||||
<option value="B">B</option>
|
||||
<option value="C">C</option>
|
||||
</select>
|
||||
<p class="field-help">Código heredado que identifica el patrón.</p>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<span class="field-label">Destino</span>
|
||||
<div class="readonly-value">GPIO22 · principal</div>
|
||||
<p class="field-help">GPIO23 está reservado.</p>
|
||||
</div>
|
||||
</div>
|
||||
<fieldset class="phases-fieldset">
|
||||
<legend>Fases del ciclo</legend>
|
||||
<div class="phase-grid" id="pattern-phases"></div>
|
||||
</fieldset>
|
||||
<label class="checkbox-field checkbox-field--standalone" for="pattern-enabled">
|
||||
<input id="pattern-enabled" name="enabled" type="checkbox">
|
||||
<span>Patrón habilitado</span>
|
||||
</label>
|
||||
<p id="pattern-form-error" class="form-error" role="alert" hidden></p>
|
||||
<div class="dialog__actions">
|
||||
<button class="button button--quiet" type="button" data-close-dialog>Cancelar</button>
|
||||
<button id="pattern-submit" class="button button--primary" type="submit">Guardar patrón</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="confirm-dialog" class="dialog dialog--confirm" aria-labelledby="confirm-title" aria-describedby="confirm-message">
|
||||
<div class="dialog__body">
|
||||
<div class="dialog__header">
|
||||
<div>
|
||||
<p class="eyebrow">Confirmar acción</p>
|
||||
<h2 id="confirm-title">Confirmar</h2>
|
||||
</div>
|
||||
<button class="button button--quiet" type="button" data-close-dialog aria-label="Cerrar">Cerrar</button>
|
||||
</div>
|
||||
<p id="confirm-message" class="confirm-message"></p>
|
||||
<div class="dialog__actions">
|
||||
<button class="button button--secondary" type="button" data-close-dialog>Cancelar</button>
|
||||
<button id="confirm-action-button" class="button button--danger" type="button">Confirmar</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<div id="toast-region" class="toast-region" aria-live="polite" aria-atomic="false"></div>
|
||||
<noscript>
|
||||
<div class="noscript-notice">Esta interfaz necesita JavaScript para comunicarse con TimbreESP.</div>
|
||||
</noscript>
|
||||
</body>
|
||||
</html>
|
||||
489
ui/package-lock.json
generated
Executable file
489
ui/package-lock.json
generated
Executable file
@@ -0,0 +1,489 @@
|
||||
{
|
||||
"name": "timbreesp-ui",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "timbreesp-ui",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"clean-css-cli": "5.6.3",
|
||||
"terser": "5.44.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/source-map": {
|
||||
"version": "0.3.11",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
|
||||
"integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.25"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
|
||||
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
|
||||
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"normalize-path": "^3.0.0",
|
||||
"picomatch": "^2.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.21",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
|
||||
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
"braces": "~3.0.2",
|
||||
"glob-parent": "~5.1.2",
|
||||
"is-binary-path": "~2.1.0",
|
||||
"is-glob": "~4.0.1",
|
||||
"normalize-path": "~3.0.0",
|
||||
"readdirp": "~3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/clean-css": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz",
|
||||
"integrity": "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"source-map": "~0.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/clean-css-cli": {
|
||||
"version": "5.6.3",
|
||||
"resolved": "https://registry.npmjs.org/clean-css-cli/-/clean-css-cli-5.6.3.tgz",
|
||||
"integrity": "sha512-MUAta8pEqA/d2DKQwtZU5nm0Og8TCyAglOx3GlWwjhGdKBwY4kVF6E5M6LU/jmmuswv+HbYqG/dKKkq5p1dD0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^3.5.2",
|
||||
"clean-css": "^5.3.3",
|
||||
"commander": "7.x",
|
||||
"glob": "^7.1.6"
|
||||
},
|
||||
"bin": {
|
||||
"cleancss": "bin/cleancss"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
||||
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
||||
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"once": "^1.3.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"binary-extensions": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-glob": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extglob": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-number": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picomatch": "^2.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-support": {
|
||||
"version": "0.5.21",
|
||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
|
||||
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"source-map": "^0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/terser": {
|
||||
"version": "5.44.0",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz",
|
||||
"integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.3",
|
||||
"acorn": "^8.15.0",
|
||||
"commander": "^2.20.0",
|
||||
"source-map-support": "~0.5.20"
|
||||
},
|
||||
"bin": {
|
||||
"terser": "bin/terser"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/terser/node_modules/commander": {
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
}
|
||||
}
|
||||
}
|
||||
13
ui/package.json
Executable file
13
ui/package.json
Executable file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "timbreesp-ui",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"description": "Dashboard local y sin dependencias de runtime para TimbreESP",
|
||||
"scripts": {
|
||||
"build": "node -e \"const fs=require('fs');fs.mkdirSync('dist',{recursive:true});fs.copyFileSync('index.html','dist/index.html')\" && cleancss -O2 -o dist/styles.min.css styles.css && terser app.js --compress --mangle --ecma 2020 -o dist/app.min.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"clean-css-cli": "5.6.3",
|
||||
"terser": "5.44.0"
|
||||
}
|
||||
}
|
||||
1595
ui/styles.css
Executable file
1595
ui/styles.css
Executable file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user