From 2a9eabce14bd212d72d7689d2e887274669c2fe9 Mon Sep 17 00:00:00 2001 From: Angel Ivan Date: Fri, 2 Oct 2026 12:08:21 -0600 Subject: [PATCH] Fist commit --- .gitignore | 16 + .vscode/extensions.json | 11 + docs/BACKUP.md | 68 + include/README | 37 + lib/README | 46 + lib/timbre_core/CivilTime.cpp | 198 ++ lib/timbre_core/CivilTime.h | 95 + lib/timbre_core/Model.h | 603 ++++++ lib/timbre_core/PatternEngine.cpp | 259 +++ lib/timbre_core/PatternEngine.h | 151 ++ lib/timbre_core/ScheduleValidator.cpp | 214 ++ lib/timbre_core/ScheduleValidator.h | 92 + lib/timbre_core/Scheduler.cpp | 123 ++ lib/timbre_core/Scheduler.h | 90 + lib/timbre_core/SessionClock.cpp | 134 ++ lib/timbre_core/SessionClock.h | 91 + lib/timbre_core/StateRepository.h | 75 + lib/timbre_core/TimbreCore.h | 10 + lib/timbre_core/TimbreService.cpp | 305 +++ lib/timbre_core/TimbreService.h | 136 ++ lib/timbre_core/library.json | 13 + partitions/ota_dual_4mb_littlefs.csv | 7 + platformio.ini | 34 + .../__pycache__/backup_device.cpython-313.pyc | Bin 0 -> 22187 bytes scripts/backup_device.py | 521 +++++ scripts/embed_ui.py | 96 + src/app/DeviceConfig.h | 28 + src/main.cpp | 701 ++++++ src/platform/AtomicFileStore.cpp | 152 ++ src/platform/AtomicFileStore.h | 43 + src/platform/EspMonotonicClock.h | 17 + src/platform/EspRelayBank.cpp | 57 + src/platform/EspRelayBank.h | 32 + src/security/AuthService.cpp | 533 +++++ src/security/AuthService.h | 113 + src/web/Cookies.h | 46 + src/web/JsonHttp.h | 44 + src/web/NativeHttpServer.cpp | 361 ++++ src/web/NativeHttpServer.h | 88 + src/web/UiAssets.cpp | 39 + src/web/UiAssets.h | 17 + test/README | 11 + .../FakeMonotonicClock.h | 15 + test/test_native_civil_time/test_main.cpp | 109 + test/test_native_pattern_engine/test_main.cpp | 153 ++ .../FakeMonotonicClock.h | 15 + test/test_native_scheduler/test_main.cpp | 136 ++ test/test_native_validation/test_main.cpp | 165 ++ ui/README.md | 22 + ui/app.js | 1921 +++++++++++++++++ ui/index.html | 397 ++++ ui/package-lock.json | 489 +++++ ui/package.json | 13 + ui/styles.css | 1595 ++++++++++++++ 54 files changed, 10737 insertions(+) create mode 100755 .gitignore create mode 100755 .vscode/extensions.json create mode 100644 docs/BACKUP.md create mode 100755 include/README create mode 100755 lib/README create mode 100755 lib/timbre_core/CivilTime.cpp create mode 100755 lib/timbre_core/CivilTime.h create mode 100755 lib/timbre_core/Model.h create mode 100755 lib/timbre_core/PatternEngine.cpp create mode 100755 lib/timbre_core/PatternEngine.h create mode 100755 lib/timbre_core/ScheduleValidator.cpp create mode 100755 lib/timbre_core/ScheduleValidator.h create mode 100755 lib/timbre_core/Scheduler.cpp create mode 100755 lib/timbre_core/Scheduler.h create mode 100755 lib/timbre_core/SessionClock.cpp create mode 100755 lib/timbre_core/SessionClock.h create mode 100755 lib/timbre_core/StateRepository.h create mode 100755 lib/timbre_core/TimbreCore.h create mode 100755 lib/timbre_core/TimbreService.cpp create mode 100755 lib/timbre_core/TimbreService.h create mode 100755 lib/timbre_core/library.json create mode 100755 partitions/ota_dual_4mb_littlefs.csv create mode 100755 platformio.ini create mode 100644 scripts/__pycache__/backup_device.cpython-313.pyc create mode 100644 scripts/backup_device.py create mode 100755 scripts/embed_ui.py create mode 100755 src/app/DeviceConfig.h create mode 100755 src/main.cpp create mode 100755 src/platform/AtomicFileStore.cpp create mode 100755 src/platform/AtomicFileStore.h create mode 100755 src/platform/EspMonotonicClock.h create mode 100755 src/platform/EspRelayBank.cpp create mode 100755 src/platform/EspRelayBank.h create mode 100755 src/security/AuthService.cpp create mode 100755 src/security/AuthService.h create mode 100755 src/web/Cookies.h create mode 100755 src/web/JsonHttp.h create mode 100755 src/web/NativeHttpServer.cpp create mode 100755 src/web/NativeHttpServer.h create mode 100755 src/web/UiAssets.cpp create mode 100755 src/web/UiAssets.h create mode 100755 test/README create mode 100755 test/test_native_civil_time/FakeMonotonicClock.h create mode 100755 test/test_native_civil_time/test_main.cpp create mode 100755 test/test_native_pattern_engine/test_main.cpp create mode 100755 test/test_native_scheduler/FakeMonotonicClock.h create mode 100755 test/test_native_scheduler/test_main.cpp create mode 100755 test/test_native_validation/test_main.cpp create mode 100755 ui/README.md create mode 100755 ui/app.js create mode 100755 ui/index.html create mode 100755 ui/package-lock.json create mode 100755 ui/package.json create mode 100755 ui/styles.css diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..61f7291 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100755 index 0000000..a91ca8f --- /dev/null +++ b/.vscode/extensions.json @@ -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" + ] +} diff --git a/docs/BACKUP.md b/docs/BACKUP.md new file mode 100644 index 0000000..f3c4650 --- /dev/null +++ b/docs/BACKUP.md @@ -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. \ No newline at end of file diff --git a/include/README b/include/README new file mode 100755 index 0000000..630164d --- /dev/null +++ b/include/README @@ -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 diff --git a/lib/README b/lib/README new file mode 100755 index 0000000..8d3ee2a --- /dev/null +++ b/lib/README @@ -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 +#include + +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 diff --git a/lib/timbre_core/CivilTime.cpp b/lib/timbre_core/CivilTime.cpp new file mode 100755 index 0000000..088e88c --- /dev/null +++ b/lib/timbre_core/CivilTime.cpp @@ -0,0 +1,198 @@ +#include "CivilTime.h" + +#include + +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::min() || + year > std::numeric_limits::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(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::min() || + y > std::numeric_limits::max()) { + return false; + } + year = static_cast(y); + month = static_cast(m); + day = static_cast(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(secondOfDay / 3600); + result.minute = static_cast((secondOfDay / 60) % 60); + result.second = static_cast(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(localTime.hour) * 3600 + + static_cast(localTime.minute) * 60 + + static_cast(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(static_cast(hour) * 60u + minute); +} + +std::int64_t CivilDateTime::dateKey() const noexcept { + return static_cast(year) * 10000LL + + static_cast(month) * 100LL + day; +} + +std::int64_t CivilDateTime::minuteSerial() const noexcept { + return static_cast(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::min() || + y > std::numeric_limits::max() || m < 1 || m > 12 || d < 1 || d > 31) { + return std::numeric_limits::min(); + } + return CivilTime::daysFromCivil(static_cast(y), + static_cast(m), + static_cast(d)) * 1440LL + minuteOfDay; +} + +} // namespace timbre_core diff --git a/lib/timbre_core/CivilTime.h b/lib/timbre_core/CivilTime.h new file mode 100755 index 0000000..b798cf8 --- /dev/null +++ b/lib/timbre_core/CivilTime.h @@ -0,0 +1,95 @@ +#pragma once + +#include "Model.h" + +#include + +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 diff --git a/lib/timbre_core/Model.h b/lib/timbre_core/Model.h new file mode 100755 index 0000000..f839444 --- /dev/null +++ b/lib/timbre_core/Model.h @@ -0,0 +1,603 @@ +#pragma once + +#include +#include +#include +#include +#include + +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(id) <= static_cast(PatternId::C); +} + +constexpr std::size_t patternIndex(PatternId id) noexcept { + return isPatternId(id) ? static_cast(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(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 +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 data_{}; + std::size_t length_ = 0; +}; + +template +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 + bool emplace_back(Args&&... args) noexcept { + if (full()) { + return false; + } + data_[size_++] = T(std::forward(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 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(kMaxPatternPhases); + union { + std::array phases; + std::array durations; + std::array phaseSeconds; + }; + + Pattern() : phases{} {} + explicit Pattern(PatternId patternIdentifier) : id(patternIdentifier), phases{} {} + + Pattern(PatternId patternIdentifier, std::initializer_list values) + : id(patternIdentifier), phaseCount(0), phases{} { + for (std::uint8_t value : values) { + if (phaseCount < kMaxPatternPhases) { + phases[phaseCount++] = value; + } + } + } + + Pattern(PatternId patternIdentifier, const std::array& values) + : id(patternIdentifier), phases(values) {} + + explicit Pattern(const std::array& 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(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(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(hour) * 60u + minute, + daysMask, isEnabled); + } + + std::uint8_t hourPart() const noexcept { + return static_cast(minuteOfDay / 60u); + } + std::uint8_t minutePart() const noexcept { + return static_cast(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 profiles; + std::array patterns; + FixedVector schedules; + FixedVector 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 + 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; diff --git a/lib/timbre_core/PatternEngine.cpp b/lib/timbre_core/PatternEngine.cpp new file mode 100755 index 0000000..9aa6601 --- /dev/null +++ b/lib/timbre_core/PatternEngine.cpp @@ -0,0 +1,259 @@ +#include "PatternEngine.h" + +#include + +namespace timbre_core { + +void PatternEngine::initializeDefaults() noexcept { + for (std::size_t i = 0; i < kMaxPatterns; ++i) { + patterns_[i].id = patternIdFromIndex(i); + } + configuredPatternMask_ = + static_cast((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( + 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((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(seconds) * kMillisecondsPerSecond; + if (deadline > std::numeric_limits::max() - duration) { + return std::numeric_limits::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(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 diff --git a/lib/timbre_core/PatternEngine.h b/lib/timbre_core/PatternEngine.h new file mode 100755 index 0000000..8ab0ea5 --- /dev/null +++ b/lib/timbre_core/PatternEngine.h @@ -0,0 +1,151 @@ +#pragma once + +#include "Model.h" +#include "ScheduleValidator.h" +#include "SessionClock.h" + +#include +#include +#include +#include + +namespace timbre_core { + +class PatternEngine { +public: + using OutputCallback = std::function; + using ClockCallback = std::function; + using SimpleOutputCallback = std::function; + + 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 patterns_{}; + std::uint8_t configuredPatternMask_ = + static_cast((1u << kMaxPatterns) - 1u); + + std::array 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 diff --git a/lib/timbre_core/ScheduleValidator.cpp b/lib/timbre_core/ScheduleValidator.cpp new file mode 100755 index 0000000..3a51158 --- /dev/null +++ b/lib/timbre_core/ScheduleValidator.cpp @@ -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(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 diff --git a/lib/timbre_core/ScheduleValidator.h b/lib/timbre_core/ScheduleValidator.h new file mode 100755 index 0000000..1218a65 --- /dev/null +++ b/lib/timbre_core/ScheduleValidator.h @@ -0,0 +1,92 @@ +#pragma once + +#include "Model.h" + +#include +#include + +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(~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 diff --git a/lib/timbre_core/Scheduler.cpp b/lib/timbre_core/Scheduler.cpp new file mode 100755 index 0000000..9d49c05 --- /dev/null +++ b/lib/timbre_core/Scheduler.cpp @@ -0,0 +1,123 @@ +#include "Scheduler.h" + +#include "ScheduleValidator.h" + +#include + +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(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::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(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 diff --git a/lib/timbre_core/Scheduler.h b/lib/timbre_core/Scheduler.h new file mode 100755 index 0000000..cd0c5ad --- /dev/null +++ b/lib/timbre_core/Scheduler.h @@ -0,0 +1,90 @@ +#pragma once + +#include "CivilTime.h" +#include "Model.h" +#include "SessionClock.h" + +#include + +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 diff --git a/lib/timbre_core/SessionClock.cpp b/lib/timbre_core/SessionClock.cpp new file mode 100755 index 0000000..d7af167 --- /dev/null +++ b/lib/timbre_core/SessionClock.cpp @@ -0,0 +1,134 @@ +#include "SessionClock.h" + +#include +#include + +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(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(predicted - utcEpochSeconds) + : static_cast(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(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 diff --git a/lib/timbre_core/SessionClock.h b/lib/timbre_core/SessionClock.h new file mode 100755 index 0000000..a5e26b6 --- /dev/null +++ b/lib/timbre_core/SessionClock.h @@ -0,0 +1,91 @@ +#pragma once + +#include "CivilTime.h" + +#include +#include +#include + +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; + + 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 diff --git a/lib/timbre_core/StateRepository.h b/lib/timbre_core/StateRepository.h new file mode 100755 index 0000000..6462d19 --- /dev/null +++ b/lib/timbre_core/StateRepository.h @@ -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 diff --git a/lib/timbre_core/TimbreCore.h b/lib/timbre_core/TimbreCore.h new file mode 100755 index 0000000..06a8dcc --- /dev/null +++ b/lib/timbre_core/TimbreCore.h @@ -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" diff --git a/lib/timbre_core/TimbreService.cpp b/lib/timbre_core/TimbreService.cpp new file mode 100755 index 0000000..c230121 --- /dev/null +++ b/lib/timbre_core/TimbreService.cpp @@ -0,0 +1,305 @@ +#include "TimbreService.h" + +#include + +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& patterns) { + return transact([&patterns](AppState& candidate) { + candidate.patterns = patterns; + return true; + }); +} + +} // namespace timbre_core diff --git a/lib/timbre_core/TimbreService.h b/lib/timbre_core/TimbreService.h new file mode 100755 index 0000000..8f6da6d --- /dev/null +++ b/lib/timbre_core/TimbreService.h @@ -0,0 +1,136 @@ +#pragma once + +#include "ScheduleValidator.h" +#include "StateRepository.h" + +#include +#include +#include + +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 + bool transact(Mutator&& mutator) { + AppState candidate = ram_; + bool accepted = true; + if constexpr (std::is_void_v>) { + std::forward(mutator)(candidate); + } else { + accepted = static_cast( + std::forward(mutator)(candidate)); + } + if (!accepted) { + return fail(ServiceCode::MutationRejected, ValidationResult{}); + } + + if (candidate.revision == std::numeric_limits::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 + bool mutate(Mutator&& mutator) { + return transact(std::forward(mutator)); + } + + template + bool apply(Mutator&& mutator) { + return transact(std::forward(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& 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 diff --git a/lib/timbre_core/library.json b/lib/timbre_core/library.json new file mode 100755 index 0000000..3ba3533 --- /dev/null +++ b/lib/timbre_core/library.json @@ -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" + ] + } +} diff --git a/partitions/ota_dual_4mb_littlefs.csv b/partitions/ota_dual_4mb_littlefs.csv new file mode 100755 index 0000000..f8bba9d --- /dev/null +++ b/partitions/ota_dual_4mb_littlefs.csv @@ -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, diff --git a/platformio.ini b/platformio.ini new file mode 100755 index 0000000..a889d32 --- /dev/null +++ b/platformio.ini @@ -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 diff --git a/scripts/__pycache__/backup_device.cpython-313.pyc b/scripts/__pycache__/backup_device.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3c265f0a06223aaba86d646314716b461edcb25d GIT binary patch literal 22187 zcmeHvdvqIDdf(tZc#{MPg6|PuA}A7+M7>B!mL-vTi;^*fESZ*JkN_n}B*2{kB@6p7 z^Ee4*r$^N8I-(M{#%|m$z1>qQZQ95u+uC_MN&f&0C4^xn(Mh(a$7y>?t(5M@?K$c1 zyMqBBing`sza5EpF79jYeD~h(e&6r=?mV>HEgY`l|MC8%*AH^s-_j5DGAI$v!=Kl1 z+#6g6Cvp-eYDDdE%`vT{6)3Jdu9I}^u9pn#Zj_AdZjwyw&PzPIn5TO&cujvK6z+}3rmhL& zp^3}0Gs4Bl)J#MYCgYNjxEK*eqtoM3WLP{c;IDtH&te(97?lNiLW<5LgjnPX%3hVC ziHIyD;;aTMCxl|*LheLpYDyL&Qb>*jC#OR4MV}xXO$f6xRk;|koSq6L@U-cpr-X<+ zlZeNsglJ4opjIdmjmM~237r(A&{cue$IlDMkWmbIAt4c+j-dSXOeEZ6nUSJbLWzj_ zHsPognMlN?Yv}k;WHK~6l_*y43WpLQyh(f};vKXI!gxHMn2LwOcq3u+E1S1$-n_Yo zBD)Yl_Do1hM5#wI%cfM8P34dsyMpw>g-Qcz_I4#Zo(QqWQ3+%ZtJzzX?5SuXF%_9) zO>BOppDMA|4^YHn3CIzlx0iKQn4F!ODiVc;Xd*s6GZn!*Zxc>N4-{w=jS2Bs1f7;; zHWnxt6C$r9q|ijd0@@0(&~yYuEs%0jice!SLJ1){9l8*~5RIZ`_9&JBT2D;PhB0KJ znVG5RgfcYpR6HSDLb7l*f);V7?y%P^vazk-pOipWIanQMuQ@mPOh_Q-w8lnaI< zSE3UU-^?|E+vFMJg#qI);BAU%MmFWn(E!MW$V_>Mfy_z zEuYdd@oMtcP%MU_W5Ygz-}=*`#6{fsqY2FD@u`T{vIwGGOgq^xaVz?bQoWi!QFw9u<~Z6t@TYlr1d%tm0O#V4 zG;&-_6VUj}%TZZeDtGQuesWmiyb19C>oFGUIxaA#;62ZtTQ8^KfU62GM?#7?oiC~WVq^4!woU>LhTN^Xh#${`B z*4mtNHGX7kd|=?*!m5$8Rj^llY>4%!%ifO1yS%}T;iq!>>CtPh(%usT9Yjs974OA~ z+QMB&_dZ=rGggoe=&jXVDy9u+$I)g$+s=*AieZi<(?a@0*FluJmDP_svA($(HqV zilvhqpC}-*4OZ?eHR+4I63Oe(T;5VjOpcDk1#AwH*hD;*km5pVH}whov2zQNDPcy6 zU!z@60RIk0gfpUWFphh?u+N6dDj|nfn^B@s&g(D6r?DGS)F@w^O+=^ihN~B&6BpT9 zctYZ-)2gD6%=jZ)TBQ8GhHQNnd50JNC%J*&7xIv$oE;;hf2qF?n*% z>UrBrW$mrTZ#FJQvX$-2l?Q%ceAo7U+npD)T?gil{gtV5#pzl&mUXtI`IZ$+&8k*o zZg{BES&gfl&S3oiGaSS4bTho+iD}AaINQ~ejbD)2pKN;~w>ZODbvCxb7i;NHJ!2OthM+N*grCfOkK3wFKF?~S) zIo!0!tI?iQtzY8c;HHL=Wc6bc2GRn=QaM>q`ay3&PnfyZ-B*Z0cuZQ#;ycz%oX4wS z<}hZ73G|J=vgi1`JY#%}uYu)3T#SVC`U_L>amEED+BYTA8W{uh8kB;z8Krwc2^s`r zhmx1dbR%HG0&&3t8?>=GSm>#=4J9H}>N0}ICScB0o37uSac!PE0p_-7TlRP}9&ft) zP}XxeTX|&e*ov+G^ZDRZwsO-toG9MqB`vK*c4-`Yn5DfeB7 z#DYle-%7$Y#@?$hjhnO;ePF}o)s{9WnS>K{5L^l)P!+l$H7f0(YWS0Be|mf@Dr;`l z-K@*;{)aj(+l3$*)h~8utsQB72czX72xO+I_=O-8&b()CQ zW)K0SiQIOY-?#+s`7fgJtK~Ens{$tU+SRXn8d^7Vc&irWDdi#FsamWV(E2x&rz-K{ zJT#X^R&=jB&&^`3$mxr-{b_nM_D>l7vVJJ*&hxZuDWLDgED`IRoLJv)P*>uGh7DW~ zr*3p%tm;Y3h8B#2z>WOTUR?_}soB7dwW_o+aUra7Dlzi%B&Xzi8%Oe*v849Akk<&K z?`R-QPtD2~551$$8zwGBCNAgo2|zd4kP_keta6V?l0-v}(t1(|lU2ebV4XP7pcwwb z(F<|GtC9Ah2(c#cNo;v?o@csXFn&3y_kmsIHCI0~%qAv#ccC4gl!!3&M6Zt3llG&s zbO1rJDli3<4FC^m)wp5f%xeboCMg0{WCGf#bPUDvM)WE!$$5<>`ZxdhkN=pgIIn_J zp>P~JijdU#woK+Va-Q>6N8v@14o2TP|TjH_kMBXCLjze*o0OQZ= zfCvG4IeL0HZxly|P6b9;-|`lSfwL0z8k&)OO=g{CTXWtpeVJIX9^hUqZzcdS$Y^Mh z0ben20I0?(kpU213s`o1dK&t4-Y^v!k4#CV=S#cjolL+Cm>6Kh9t8L>Z^RJHU=(;_ z_cMTl@;aiVfk-3E#7JnjS1v{Mv7-CI!ULp>D3HXT{MQ=VW*#{>gJpjId}RLlg#!!n ztygZok}_p$yV7oN+R}Ze{UiO(6-U*L*lV$@qj~P|Uzr>$l?}I=zuBC#xpGytxyqUq zSJSP1H}~PT>}t=r+8 zWb0n3YD(3lOzDjScRQB{k7ovtrw2}?n@?t|M$)#CoU>-$e&6KyxmOEMO_`QvGnLz~ z8}IYZ8}{q=#qiSTygkEj&AI9qucpF_FJ)Yt=J}kx=EnK!=NJ2zYUj^q>|5@LD>W^* zUi#)sxr*AHyCGLwzf!wlxwb1)+x5U~a69G?{Y1w(wxZ9rs>i#UuI@~|lDc$9ldjtO zL)ZKD@6~@~8(P)s?KL@%ui)1{KYw^9_s5-R4dT9G}utMm| zkePLagtFYCP(WQ!E{GWhhF7h#F^WdET?648gN>n3-UMuGoh=GmZ&8nkXvT^{!Fpl< zv&dtN^UnDEf3 z^bE?dy(PI((V}K%iF;pSz>Xwr1crkRfFOHp2XbQLpe1R4sjC31zbJ0<0;j|d7P2ZPT(bK}2*pBDG%gn&AeTLX_DgC#9G3@$&$K;4 zvePF72c}Vo!iMYfpNr9O7s9Ip1mI^p3?#p&un?bbG&ta$1i+!;kjRU71=r4t{nEw z==JELG3#h~0DdgockFmz(AsxC)aj{~-fUU5V8dD1aD5=Hul|Q0x8Z3rX*|Ez&@0FaLo|ArFCkrG(0sC+5rT~um_`%R$Z zW!Q9)1Ci}utf=hvqGO%1a~MaSLD>CHz>aj>OZn(tP`R)kT-Z92vC$xpD%ibzZbX2V!!a8imyB;Y07pWsh^ zhyZ|4MfJkyw=8oLjV{KTrHf5~D4Hu845~;~_&zW@n^I6B)G=Fx*T6ts7YkR z0}F}wUwH3@_g}mb&a@279ZK6_=CI=l^WisLZ`I$f|A=pAzlsd-?TPPQ`p%_~_$|a6 z9iGMNMOZ&HEn5)Sx0KlEstK(AAASsB{J2J8g`J)uLI3+&Bf`g6A#FKebDD=$V1h5{ zzFb&UmoOM4_r;K*mYddweM)L+Y%B^P)E@7t( zC5>`$CxA$$CnXklz|yl4)MDCu<_fm8JyW6S@o?ywe3N`Jv~~LqW+V3PnTo^tMt%kn zCHudiicC`aPv^K)=l3>!XVcQllf{pkl3qmlydEAtQr8{Xu)M&lOWP^e?w(1AwX|d z?A0WcWbI9Jhp;Mc)LpMz*!C+ei-%Tf>h9GK-aYsiHP6lu=WJCsPF_E`*zp%O;en2G z@7F+WwA7KI8fqgg$*i?0t#A5?38}x*@H^Zx}UCCtDvwN>>u+sD69tiXKcxTtFnXpmzJ%%-_&f?Jw_lR9wX4N zqPuT!eY61AB~pn1fI4+!MIvL_E^yo|+L##z7_3{54U}{k0lLKW{W!Q*4B$S>rLZ7pp|in)dz$nEl=(6Kt&;N z?r_fRy3umIWwH96xjEJO*R9@+t$BWEv7MOD@$1JIjrVLVIal4S-8XkHCbF*f`JokO z)xx&duPqK`ox+NHL(aYZPWaB5yL&#=e{co+>xiZ;*XCW?km_FAk7ZWVhwZku3R+8L z)hZ7^n@mmT{Gk>_iWET=-}GAi8r>ac5yb>+{jP7h~tF{ zKb3|4?E?)526VqSU^&Q}CW<@?lpsKeE%eQ7p$9ay^Iy=Eu=A3-QYIuM|frrzGK%tj-Rqb+@9XiFGaM>smk zPzOlRBAA-0~$~L}v2L8{xhYlCDW% zD)=%569}FVjVoDiAR4O%Q(3x$a#ltB8IV9Jqg?7LI< z!-F3ff9Lprc71r}&l=K>z}#U~^5vahCigxDIRJ>h@%8IpUv{=;oUK`B`?B*~y6aq; zKbNyQ6=Qb!+#wN%&EFM7-e6AVG>1sV?uVjFwnE&E0WG`hM3cyiX3-*AQOefmAgd|1 zD%kk}+v(Sn0z^C5zJv5TFmWg16@_>u;#GyXtF*mu!z3twgLLXb<9{dnDOda?vihaCecU$eg;p$%eh3e@Um&$Y&;zJfH`Kr13PACb}O8 z&4zKe9X)yalz(*p$mp;*m>lR8hM|l?r)2mS;535Hl+6TU34v?mdJUwIG)bm;%5ayy z689ya?G;AgbO!AfIv`cT1|$g+5`M-efbv0Od_H<8DqJJqVKjtNAvkBE@re-4b)|(e z2(rwXT#(_L!MxlQL~vGy6$eEh@2W3(xK}tl3x5JAy>JyD#~vum##GgGA_|{_a0vd+ zXqg_5#db0@AxzA&#^gvyn2F1zNBcfA`Or0DC{)PB&MJ1$1%moe8?R6r3R}|~?Kf=Kp^aB8xEGB#o6{9S%DmKh z$CK$EOnZ0V-FkQOgEQ~N(tA&ScqVNd{a-q**#aHJRgXQ~VEN?}KX>BCS`%O8I2NCVmP-*@E(NHK z6mL2blQi`9LJmzH94hfYtPD9c1w3mv*n@)li*ZK8=l@qh$$s@4PFmuclQCt%I zr%qxF(L6GDGxETa0!tET9A`fPcg~_KwEGAX%sW|NL`$O4!jY4t?_?a9BHAs$u-8gN zp|(R46WFi-aTbhEUQ?cjrU51_NAiyS(gil?r|G98(ea18Effw1QG{hEc&Cm$9xSX5 z<;aBRuwN3xa}*~*=}(EC6znBd_dgLKgrsKK-u{uj{kLi}c0hhPy=7Tno6*-U^68Dw zr77w!rsa6&ioG&t_XE!}cph+i@FWXouqrije8q~rB3I$g*-x#SSpfqI*vbl2<|+hM z;Lzt3fZugP&i>5jwC`fM&wpX=-p6uPyFZQ~r1i~DvP!aGFjjd0OCGEegi$V47>{vf zNNkiZOKsz&SspSrrVmUtMEI7JUDI@ZPTS$Ls3<%>uPdS!x8 zj=Bs#x*oHXwQ3IV5X3D3vuFjbV+&aD%l?@7jouOrjHkD%{Vd&!w9-J$HK0s;DNl_} zoC2QIss8e-Rn!FB{hP{u6r$=#`g@YVeF?zQGB@h;}cgtb>T3w?o~ zxjk^3zEYd`wYk4Io8Z;+W%R8qr|g)hb~aE6V?&`Xs9mw{tkH{GN>3gOlvImThxXaO#vaVr}km-@`Y2yF{g ziQB=CcLaF>RE@MZhgSSsm4loaNb&c*WuX#t-`hOeTb*y*PimRLm&3DKGX&3*6l-7ed79WdUo@A?-{62 zXVg>os;<0OWwZOhZ7Ys13?or=h>{2l{rN;a*HCDQB)4>pX z4g9V?pc#LTb}IkVNIG~9Ouia%n9;K#(6Amo#Y2I5aFOqmH+J_BoCjF2Ky-$7Qfwn~OxsgcT zUO-3yEyC(a5J_l~oE$6{0TU~U(jQX0fH|Tw!DtxZNr>9nMTtD47E+T=#TOR1$7I}> zz!x?~sL&A#NQ{$ym5Sq}0!|>XI?}(O)Hs4qi3um246QE_UwsVC$fW1QlV;rMC@7Sy zQ7o-JgjeAu>9Yx;m>~Ug)XMYt!U(&h6n+9@Fo1GWB57ccf=zd8IIro&5fF@X(kLhm zn#b_%h3Mq9^MdpnsKqR}5|3E2WHyosi^g#Yql|MY)1kZ_AU+&I!a?R~lDDcqXA=|B zzo$m9$T;agAjs?4cqk_^=zBBLZ&Rup;D_>66?_GR&T#|*p2Vf3{V}AQP!hP$0@6uZ z{KNZ)P7V{HlP={W5=8@d7&un4_GAGg9|Z7IhL$IL6!RK7JQYoB z-`*fJj+p}3p_jZx@l~N?IMO1uq6dW%bigLa914CwEs$eS-W-VmGn2^G$Rg<~O<|}AU<6oa?5OC!p>&$R()TI&4h7#uz}6XjD{1NG zVdDTWXl2Z!aAe4)Fpa`5hhp#uVp@|+L*C4M9+)?kfoA(F(!Zv5DoRU4G1unpr4%g3 zysdoYIia5pVN0k0%OzI; zc^*a@IwD*1`kuL=6}|D7kNo_R`M@up$~Cvn9b46(*BaoW?SB2zye{W--MDuB+Oo6f z&EreDY-7*7euano;XS^2rBcXNw$2+@s+zy$n>Vf0^WG2iz7k!UvQ0hb7yKxYU|=|a+biSBL=SzF1q1mne%ilZTLYjUAs5!erEpgk9hk6 zPhO5`N6S54fHUNRX;J@{<+f$vYiUROJ-&ndBQM|M>pv-ac+knH0}F|T$;F|Rdr6nF zEge`&EKS~lTgC3bu*-w9=Yeno}8dV!> zOto+6)ok^?`Qeqirnl;Tt?t(wQeVx~ZB1`KmZ>}T;nno>FQ(6&PoI1#Q}@#KWA{g#(~Vnf$YZJ%WZoyZF_Kj zD=moW(U;Orn=GVjUoo>cW6{oUVeAE2;NY3WY*&1@Tnw-tET4%4Y%8Zxch?jHRxk#UdCTUTmZQ`?pXmwGa7JMLae`_E)* zpN9ji*_C#0%$PSW^ZgmVKWD35Y=_THsxf2hy=z+4apr9gI9S`zNwf6^{~Etg50{us zW#65ed;HG(Ter5lCg&-VGFE4JEPMfLq^A?Nli)}|_N*QfU1Zc52%&!%*B z&$@{#)wPS}Z;h;)4X&yOIwM?k99(Tz&eQT^m&NW_b#oPUH@{`9`*H-*VsKOyA+O?+BcKj%wOh+WXUO+wL&mADlf_+nD>$-!u20 zDsJ<@-P#W|_xg@yDmE<`GPRo)^{H^Kt}Rp7yXQU@1Lq&;4W zkEYrdk1aY^JPnJ7e%*>Ut##u~YutF#rq)ykCVs=x{-yA;Z(qi@?*n7jcR1a9Bwc?r z*VqOkfFf?Lx}MyhzO{GR)s=B|Ee&N|n^)>Pat$quohf7L%J;tdov;4J*Oxc$$!y${ z?%bQ+>rac%r~PNsXV0ZQFW?Y;gL{?J*0?dEm9AU-H~B?(s^(tB##}}7az#g`qT{~M zo@?(+U0Iq)O{H7=b3*${Ywyxzwskw+AN6!5_&`-~mF`7zsy*dSNlWcZ?xibf_l~>a zdld&CZ09x~*R1a18avZnyD|;CumW-(bz0b0YP*))>HW{8#h0_SAxuDPZN}O{$5&`H z&Zma&*}CC7TI*S9YI$qy_Sjp&+rey;Z@Fn8(=?E6+P&N~oM{@)HXUARXnJe+?cHzf zyS?uRTi@ON&hB^jy|XXduzR^-IMXnkZ8)5(sk`;U%@;5Z_)Z-&VT+&%vj*nx%7(?MOl3D2upP^MU52lFb5klz&fa(0-#5Q!{@|Hxy+3_E4AXdq zkEoU1i?V3O&Iw$!oIp0s20J@|%`i~7cUd>4!CxySF#IV*mORrF@<+uh5pdorzi z($>8XIlG|}oGD}7kh-+oxiizbbGdUM(>buJHRD)jU0b@77}$al2BhVR4Vj7!IIC0B zoNI1dd_Hx6Jm9+SH?_n1Q`rj36y}qK!{>W!H*Jf7Kd)%}>58lV@l%w<%1w)gg{xrY zB{z0w#s6MyKu124Nt$|l$a2=m{Xw7T#w}-Td7)XC>+rl#r^`Llea@@>xJ!fh$6m|1 z9fptlwG`j3q4ET~*?2q? zJoHqQHC{+%k_c;nQuBKDd_8jwR;0F4VPje9ZM2g&FmF$#xf<%9QTiUW&mO{ro#Nv@ zt7U!0kvG=y9w9!CvvrB{=kZP?2Pt)!48-cZTTBZVW7Lx*jK7jfCX1pEu-=#6gG5E? zPbgs0R4tLD^b!hblT{GNn2ppo0(Vat-w7#FovM1hinSG=gqxhj_~4Upl8&1EBMSZ- z1%F4uuTb!RDEJWtq%%*q7{>IRjX7A25F=D@9l^^dRfz`sd@B_ zTVr{&)u3s9RAJEAA62+Du18z#c%smvX?rxJY16bkI-wcYYARQc892RdKAF|keQc?| VIr^X!fj literal 0 HcmV?d00001 diff --git a/scripts/backup_device.py b/scripts/backup_device.py new file mode 100644 index 0000000..972e30d --- /dev/null +++ b/scripts/backup_device.py @@ -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()) \ No newline at end of file diff --git a/scripts/embed_ui.py b/scripts/embed_ui.py new file mode 100755 index 0000000..7fb8335 --- /dev/null +++ b/scripts/embed_ui.py @@ -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 ", + "#include ", + "", + "#include ", + "", +] +source_lines = [ + "// Generated by scripts/embed_ui.py. Do not edit manually.", + "#include \"UiAssets.h\"", + "", + "#include ", + "", +] + +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)") diff --git a/src/app/DeviceConfig.h b/src/app/DeviceConfig.h new file mode 100755 index 0000000..baba31d --- /dev/null +++ b/src/app/DeviceConfig.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +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 diff --git a/src/main.cpp b/src/main.cpp new file mode 100755 index 0000000..05d1439 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,701 @@ +#include +#include +#include +#include +#include +#include +#include + +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( + + + + + + Control Timbres ESP32 + + + + + +
+ + +
+
+

Control de Timbres

+

Cargando estado...

+
+
+
+ Hora del Sistema + --:--:-- +
+
+
+ + +
+ +
+

Modo de Operación

+

Selecciona el perfil que el timbre debe obedecer hoy.

+ +
+ + +
+
+
+

Gestor de Perfiles

+

Crea, renombra o elimina perfiles de horarios.

+
+ +
+
+ +
+
+
+ + +
+ +
+
+

Patrones de Timbres (Ciclos)

+
+ + +
+
+ + + + + + + + + + + + + + + + +
TimbreCiclo 1 (Seg)Ciclo 2 (Seg)Ciclo 3 (Seg)
AltoBajoAltoBajoAltoBajo
+
+ +
+
+ + +
+

Días Feriados

+

Fechas de silencio (se repiten cada año).

+
+ + +
+
+
+
+
+ + +
+
+
+ +
+

Agregar horario a esta pestaña

+
+
+
+ + +
+
+ + +
+
+
+ +
+ + + + + + + +
+
+
+ +
+
+
+ +
+ + + + + +
HoraTimbreDíasAcción
+
+
+
+
+ + + + +)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(); // 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(); arrA.add(3); arrA.add(1); arrA.add(5); arrA.add(1); arrA.add(0); arrA.add(0); + JsonArray arrB = doc["B"].to(); arrB.add(2); arrB.add(2); arrB.add(2); arrB.add(2); arrB.add(2); arrB.add(2); + JsonArray arrC = doc["C"].to(); 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(); + 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(); + } + + if (!LittleFS.exists(ARCHIVO_NOMBRES)) { + JsonDocument doc; JsonArray arr = doc.to(); + JsonObject p0 = arr.add(); + 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(); + 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 horarios = docExist.as(); + JsonDocument docNuevo; deserializeJson(docNuevo, body); + JsonObject nH = docNuevo.as(); 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(); + File aL = LittleFS.open(ARCHIVO_HORARIOS, "r"); JsonDocument docE; deserializeJson(docE, aL); aL.close(); + JsonArray viejos = docE.as(); JsonDocument docF; JsonArray nuevos = docF.to(); + for (JsonObject h : viejos) { if (h["id"].as() != 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(); + 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(); + 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(); + 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(); + for (JsonObject h : horarios) { + String hProg = h["hora"].as(); + String dProg = h["dias"].as(); + String tipoTimbre = h["tipo"].is() ? h["tipo"].as() : "A"; + int idPerfilHorario = h["perfil"].is() ? h["perfil"].as() : 0; + + if (hProg == horaAComparar && dProg.indexOf(diaHoy) >= 0 && idPerfilHorario == perfilActivoIndex) { + dispararTimbre(tipoTimbre); + break; + } + } + } + archivo.close(); + } + } + } + } +} \ No newline at end of file diff --git a/src/platform/AtomicFileStore.cpp b/src/platform/AtomicFileStore.cpp new file mode 100755 index 0000000..b4b9c73 --- /dev/null +++ b/src/platform/AtomicFileStore.cpp @@ -0,0 +1,152 @@ +#include "AtomicFileStore.h" + +#include + +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() != kFormatVersion) { + return result; + } + + const std::uint32_t generation = document["generation"].as(); + const std::uint32_t expectedCrc = document["crc32"].as(); + if (!document["payload"].is()) { + return result; + } + const String payload = document["payload"].as(); + if (payload.isEmpty() || payload.length() > kMaxPayloadBytes) { + return result; + } + + JsonDocument payloadDocument; + if (deserializeJson(payloadDocument, payload)) { + return result; + } + + const std::uint32_t actualCrc = + crc32(reinterpret_cast(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(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 diff --git a/src/platform/AtomicFileStore.h b/src/platform/AtomicFileStore.h new file mode 100755 index 0000000..35377aa --- /dev/null +++ b/src/platform/AtomicFileStore.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include + +#include +#include + +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 diff --git a/src/platform/EspMonotonicClock.h b/src/platform/EspMonotonicClock.h new file mode 100755 index 0000000..5c1d2e7 --- /dev/null +++ b/src/platform/EspMonotonicClock.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +#include + +namespace timbre { + +class EspMonotonicClock { + public: + std::uint64_t micros() const { return static_cast(esp_timer_get_time()); } + std::uint32_t milliseconds() const { + return static_cast(micros() / 1000ULL); + } +}; + +} // namespace timbre diff --git a/src/platform/EspRelayBank.cpp b/src/platform/EspRelayBank.cpp new file mode 100755 index 0000000..52b349c --- /dev/null +++ b/src/platform/EspRelayBank.cpp @@ -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(1U << channel); + std::uint8_t state = stateMask_.load(std::memory_order_relaxed); + if (value) { + state = static_cast(state | bit); + } else { + state = static_cast(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(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 diff --git a/src/platform/EspRelayBank.h b/src/platform/EspRelayBank.h new file mode 100755 index 0000000..204f3a7 --- /dev/null +++ b/src/platform/EspRelayBank.h @@ -0,0 +1,32 @@ +#pragma once + +#include + +#include +#include + +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 stateMask_{0}; + bool initialized_ = false; +}; + +} // namespace timbre diff --git a/src/security/AuthService.cpp b/src/security/AuthService.cpp new file mode 100755 index 0000000..3c80ef5 --- /dev/null +++ b/src/security/AuthService.cpp @@ -0,0 +1,533 @@ +#include "AuthService.h" + +#include +#include +#include +#include +#include + +#include +#include + +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(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 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 suppliedDigest{}; + if (!tokenDigest(setupToken, suppliedDigest) || + !constantTimeEqual(suppliedDigest, setupTokenHash_)) { + return result(AuthResult::Code::InvalidToken); + } + + std::array salt{}; + std::array 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 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(nowMs - stored->createdMs) >= + kAbsoluteTimeoutMs || + static_cast(nowMs - stored->lastSeenMs) >= + kIdleTimeoutMs) { + stored->used = false; + stored->csrfToken = String(); + return false; + } + + const std::uint32_t idleRemaining = + kIdleTimeoutMs - + static_cast(nowMs - stored->lastSeenMs); + const std::uint32_t absoluteRemaining = + kAbsoluteTimeoutMs - + static_cast(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(nowMs - bucket.windowStartedMs) >= + kFailureWindowMs) { + continue; + } + globalFailures = static_cast( + std::min(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& salt, + const std::array& 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& salt, + std::uint32_t iterations, std::array& 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(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 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(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& digest) { + return mbedtls_sha256_ret(data, length, digest.data(), 0) == 0; +} + +bool AuthService::constantTimeEqual( + const std::array& left, + const std::array& right) { + return mbedtls_ct_memcmp(left.data(), right.data(), left.size()) == 0; +} + +bool AuthService::tokenDigest(const String& token, + std::array& digest) { + return sha256(reinterpret_cast(token.c_str()), token.length(), + digest); +} + +AuthService::Session* AuthService::findSession(const String& cookie) { + if (cookie.isEmpty() || cookie.length() > 128) { + return nullptr; + } + std::array 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(nowMs - session.createdMs) >= + kAbsoluteTimeoutMs || + static_cast(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(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 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(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 diff --git a/src/security/AuthService.h b/src/security/AuthService.h new file mode 100755 index 0000000..d08b9f4 --- /dev/null +++ b/src/security/AuthService.h @@ -0,0 +1,113 @@ +#pragma once + +#include + +#include +#include + +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 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& salt, + const std::array& passwordHash, + std::uint32_t iterations, bool clearSetupToken); + static bool derivePasswordHash(const String& password, + const std::array& salt, + std::uint32_t iterations, + std::array& 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& digest); + static bool constantTimeEqual(const std::array& left, + const std::array& right); + static bool tokenDigest(const String& token, + std::array& 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 passwordSalt_{}; + std::array passwordHash_{}; + std::array setupTokenHash_{}; + std::uint32_t pbkdf2Iterations_ = kPbkdf2Iterations; + + std::array sessions_{}; + std::array failures_{}; +}; + +} // namespace timbre diff --git a/src/web/Cookies.h b/src/web/Cookies.h new file mode 100755 index 0000000..14d4feb --- /dev/null +++ b/src/web/Cookies.h @@ -0,0 +1,46 @@ +#pragma once + +#include + +#include + +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 diff --git a/src/web/JsonHttp.h b/src/web/JsonHttp.h new file mode 100755 index 0000000..d3a1ea6 --- /dev/null +++ b/src/web/JsonHttp.h @@ -0,0 +1,44 @@ +#pragma once + +#include "NativeHttpServer.h" + +#include + +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 diff --git a/src/web/NativeHttpServer.cpp b/src/web/NativeHttpServer.cpp new file mode 100755 index 0000000..b57ef97 --- /dev/null +++ b/src/web/NativeHttpServer.cpp @@ -0,0 +1,361 @@ +#include "NativeHttpServer.h" + +#include + +#include +#include + +#include +#include +#include +#include + +namespace timbre { +namespace { + +std::string toLower(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { + return static_cast(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(request->user_ctx); + if (self == nullptr) { + return ESP_FAIL; + } + HttpResponse response; + const bool prepared = self->dispatchRequest( + request, static_cast(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(&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(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(received)); + remaining -= static_cast(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(length)); + } +} + +} // namespace timbre diff --git a/src/web/NativeHttpServer.h b/src/web/NativeHttpServer.h new file mode 100755 index 0000000..57af1f2 --- /dev/null +++ b/src/web/NativeHttpServer.h @@ -0,0 +1,88 @@ +#pragma once + +#include +#include + +#include +#include +#include + +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 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 diff --git a/src/web/UiAssets.cpp b/src/web/UiAssets.cpp new file mode 100755 index 0000000..a350d88 --- /dev/null +++ b/src/web/UiAssets.cpp @@ -0,0 +1,39 @@ +#include "UiAssets.h" + +#include "generated/UiAssets.h" + +#include + +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 diff --git a/src/web/UiAssets.h b/src/web/UiAssets.h new file mode 100755 index 0000000..020afc9 --- /dev/null +++ b/src/web/UiAssets.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +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 diff --git a/test/README b/test/README new file mode 100755 index 0000000..b0416ad --- /dev/null +++ b/test/README @@ -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 diff --git a/test/test_native_civil_time/FakeMonotonicClock.h b/test/test_native_civil_time/FakeMonotonicClock.h new file mode 100755 index 0000000..1dd7e60 --- /dev/null +++ b/test/test_native_civil_time/FakeMonotonicClock.h @@ -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_; +}; diff --git a/test/test_native_civil_time/test_main.cpp b/test/test_native_civil_time/test_main.cpp new file mode 100755 index 0000000..975995e --- /dev/null +++ b/test/test_native_civil_time/test_main.cpp @@ -0,0 +1,109 @@ +#include + +#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(); +} diff --git a/test/test_native_pattern_engine/test_main.cpp b/test/test_native_pattern_engine/test_main.cpp new file mode 100755 index 0000000..2612dc4 --- /dev/null +++ b/test/test_native_pattern_engine/test_main.cpp @@ -0,0 +1,153 @@ +#include + +#include + +#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 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(RelayId::Main), + static_cast(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 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 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 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 events; + PatternEngine engine([&events](RelayId relay, bool on) { + events.push_back({relay, on}); + }); + + TEST_ASSERT_FALSE(engine.start(static_cast(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 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(RelayId::Main), + static_cast(engine.lastOutputRelay())); + for (const OutputEvent& event : events) { + TEST_ASSERT_EQUAL_UINT8(static_cast(RelayId::Main), + static_cast(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(); +} diff --git a/test/test_native_scheduler/FakeMonotonicClock.h b/test/test_native_scheduler/FakeMonotonicClock.h new file mode 100755 index 0000000..1dd7e60 --- /dev/null +++ b/test/test_native_scheduler/FakeMonotonicClock.h @@ -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_; +}; diff --git a/test/test_native_scheduler/test_main.cpp b/test/test_native_scheduler/test_main.cpp new file mode 100755 index 0000000..8b070ed --- /dev/null +++ b/test/test_native_scheduler/test_main.cpp @@ -0,0 +1,136 @@ +#include + +#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(PatternId::A), + static_cast(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(); +} diff --git a/test/test_native_validation/test_main.cpp b/test/test_native_validation/test_main.cpp new file mode 100755 index 0000000..c275a5c --- /dev/null +++ b/test/test_native_validation/test_main.cpp @@ -0,0 +1,165 @@ +#include + +#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(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(ValidationCode::InvalidPhaseDuration), + static_cast(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(ValidationCode::InvalidPhaseCount), + static_cast(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(ValidationCode::UnknownProfile), + static_cast(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(ValidationCode::DuplicateProfileId), + static_cast(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(ValidationCode::DuplicateHolidayId), + static_cast(result.code)); + + state = validState(); + for (std::size_t i = 0; i < 64; ++i) { + const std::uint8_t month = static_cast((i % 12) + 1); + const std::uint8_t day = static_cast((i / 12) + 1); + TEST_ASSERT_TRUE(state.holidays.push_back( + Holiday{static_cast(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(ValidationCode::InvalidHolidayDate), + static_cast(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(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(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(ServiceCode::CommitFailed), + static_cast(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(); +} diff --git a/ui/README.md b/ui/README.md new file mode 100755 index 0000000..8995231 --- /dev/null +++ b/ui/README.md @@ -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. diff --git a/ui/app.js b/ui/app.js new file mode 100755 index 0000000..cea4a61 --- /dev/null +++ b/ui/app.js @@ -0,0 +1,1921 @@ +"use strict"; + +(() => { + const API_BASE = "/api/v1"; + const STATE_POLL_MS = 5000; + const MAX_SCHEDULES = 100; + const MAX_PROFILES = 8; + const MAX_PATTERNS = 3; + const MAX_HOLIDAYS = 64; + const MAX_PHASE_SECONDS = 99; + const MAX_PATTERN_SECONDS = 600; + const REQUEST_TIMEOUT_MS = 12000; + const DAY_NAMES = [ + { index: 0, short: "D", name: "Domingo" }, + { index: 1, short: "L", name: "Lunes" }, + { index: 2, short: "M", name: "Martes" }, + { index: 3, short: "X", name: "Miércoles" }, + { index: 4, short: "J", name: "Jueves" }, + { index: 5, short: "V", name: "Viernes" }, + { index: 6, short: "S", name: "Sábado" } + ]; + const PHASE_NAMES = ["Alto 1", "Bajo 1", "Alto 2", "Bajo 2", "Alto 3", "Bajo 3"]; + const MONTH_NAMES = [ + "enero", "febrero", "marzo", "abril", "mayo", "junio", + "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre" + ]; + + const $ = (selector, root = document) => root.querySelector(selector); + const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector)); + + const refs = { + app: $("#app"), + screens: { + bootstrap: $("#screen-bootstrap"), + claim: $("#screen-claim"), + login: $("#screen-login") + }, + dashboard: $("#dashboard"), + bootstrapMessage: $("#bootstrap-message"), + bootstrapError: $("#bootstrap-error"), + bootstrapErrorMessage: $("#bootstrap-error-message"), + bootstrapRetry: $("#bootstrap-retry"), + claimForm: $("#claim-form"), + claimToken: $("#claim-token"), + claimPassword: $("#claim-password"), + claimPasswordConfirm: $("#claim-password-confirm"), + claimError: $("#claim-error"), + claimSubmit: $("#claim-submit"), + loginForm: $("#login-form"), + loginPassword: $("#login-password"), + loginIntro: $("#login-intro"), + loginError: $("#login-error"), + loginSubmit: $("#login-submit"), + logoutButton: $("#logout-button"), + refreshButton: $("#refresh-data"), + csrfWarning: $("#csrf-warning"), + connectionBadge: $("#connection-badge"), + connectionText: $("#connection-text"), + clockValue: $("#clock-value"), + clockDate: $("#clock-date"), + clockStatus: $("#clock-status"), + clockIndicator: $("#clock-indicator"), + syncTimeButton: $("#sync-time-button"), + sequenceIndicator: $("#sequence-indicator"), + activePatternLabel: $("#active-pattern-label"), + stateMessage: $("#state-message"), + stopButton: $("#stop-button"), + addProfileButton: $("#add-profile-button"), + activeProfileForm: $("#active-profile-form"), + activeProfileSelect: $("#active-profile-select"), + activeProfileSubmit: $("#active-profile-submit"), + profilesContent: $("#profiles-content"), + addScheduleButton: $("#add-schedule-button"), + profileTabs: $("#profile-tabs"), + scheduleCount: $("#schedule-count"), + schedulesContent: $("#schedules-content"), + addPatternButton: $("#add-pattern-button"), + patternsContent: $("#patterns-content"), + holidayForm: $("#holiday-form"), + holidayMonth: $("#holiday-month"), + holidayDay: $("#holiday-day"), + holidaySubmit: $("#holiday-submit"), + holidayError: $("#holiday-error"), + holidaysContent: $("#holidays-content"), + profileDialog: $("#profile-dialog"), + profileForm: $("#profile-form"), + profileDialogTitle: $("#profile-dialog-title"), + profileId: $("#profile-id"), + profileName: $("#profile-name"), + profileFormError: $("#profile-form-error"), + profileSubmit: $("#profile-submit"), + scheduleDialog: $("#schedule-dialog"), + scheduleForm: $("#schedule-form"), + scheduleDialogTitle: $("#schedule-dialog-title"), + scheduleId: $("#schedule-id"), + scheduleTime: $("#schedule-time"), + scheduleProfile: $("#schedule-profile"), + schedulePattern: $("#schedule-pattern"), + scheduleDays: $("#schedule-days"), + scheduleFormError: $("#schedule-form-error"), + scheduleSubmit: $("#schedule-submit"), + patternDialog: $("#pattern-dialog"), + patternForm: $("#pattern-form"), + patternDialogTitle: $("#pattern-dialog-title"), + patternId: $("#pattern-id"), + patternCode: $("#pattern-code"), + patternPhases: $("#pattern-phases"), + patternEnabled: $("#pattern-enabled"), + patternFormError: $("#pattern-form-error"), + patternSubmit: $("#pattern-submit"), + confirmDialog: $("#confirm-dialog"), + confirmTitle: $("#confirm-title"), + confirmMessage: $("#confirm-message"), + confirmActionButton: $("#confirm-action-button"), + toastRegion: $("#toast-region") + }; + + const appState = { + authenticated: false, + csrfToken: null, + selectedProfileId: null, + activeProfileId: null, + activeProfileKnown: false, + timeSyncAttempted: false, + status: null, + profiles: [], + schedules: [], + patterns: [], + holidays: [], + resources: { + profiles: { loading: true, error: null }, + schedules: { loading: true, error: null }, + patterns: { loading: true, error: null }, + holidays: { loading: true, error: null } + } + }; + + let statePollTimer = null; + let clockTimer = null; + let stateRequestInFlight = false; + let confirmHandler = null; + + class ApiError extends Error { + constructor(message, status = 0, code = "") { + super(message); + this.name = "ApiError"; + this.status = status; + this.code = code; + } + } + + function createElement(tagName, className = "", text = null) { + const node = document.createElement(tagName); + if (className) node.className = className; + if (text !== null && text !== undefined) node.textContent = String(text); + return node; + } + + function setHidden(node, hidden) { + node.hidden = Boolean(hidden); + } + + function firstDefined(...values) { + return values.find((value) => value !== undefined && value !== null); + } + + function asString(value) { + if (value === undefined || value === null) return ""; + return String(value); + } + + function asFiniteNumber(value) { + if (typeof value === "number") return Number.isFinite(value) ? value : null; + if (typeof value !== "string" || value.trim() === "") return null; + const number = Number(value); + return Number.isFinite(number) ? number : null; + } + + function asBoolean(value) { + if (typeof value === "boolean") return value; + if (typeof value === "number") return value !== 0; + if (typeof value !== "string") return null; + const normalized = value.trim().toLowerCase(); + if (["true", "1", "yes", "sí", "si", "valid", "válido", "active", "activo"].includes(normalized)) return true; + if (["false", "0", "no", "invalid", "inválido", "inactive", "inactivo"].includes(normalized)) return false; + return null; + } + + function sameId(left, right) { + return left !== undefined && left !== null && right !== undefined && right !== null && String(left) === String(right); + } + + function findById(items, id) { + return items.find((item) => sameId(item.id, id)) || null; + } + + function extractData(payload, aliases = []) { + let candidate = payload; + for (let depth = 0; depth < 3; depth += 1) { + if (Array.isArray(candidate) || candidate === null || candidate === undefined) return candidate; + if (typeof candidate !== "object") return candidate; + for (const alias of aliases) { + if (Object.prototype.hasOwnProperty.call(candidate, alias)) { + candidate = candidate[alias]; + break; + } + } + if (!Array.isArray(candidate) && typeof candidate === "object" && Object.prototype.hasOwnProperty.call(candidate, "data")) { + candidate = candidate.data; + continue; + } + return candidate; + } + return candidate; + } + + function extractList(payload, aliases, label) { + let candidate = payload; + for (let depth = 0; depth < 4; depth += 1) { + if (Array.isArray(candidate)) return candidate; + if (candidate === null || candidate === undefined || typeof candidate !== "object") break; + const alias = aliases.find((name) => Object.prototype.hasOwnProperty.call(candidate, name)); + if (alias) { + candidate = candidate[alias]; + continue; + } + if (Object.prototype.hasOwnProperty.call(candidate, "data")) { + candidate = candidate.data; + continue; + } + if (Object.prototype.hasOwnProperty.call(candidate, "items")) { + candidate = candidate.items; + continue; + } + break; + } + throw new ApiError(`La respuesta de ${label} no contiene una lista válida.`); + } + + function responseMetadata(payload) { + const metadata = []; + let current = payload; + for (let depth = 0; depth < 3 && current && typeof current === "object"; depth += 1) { + if (!Array.isArray(current)) metadata.push(current); + current = current.data; + } + return metadata; + } + + function metadataValue(payload, aliases) { + for (const object of responseMetadata(payload)) { + for (const alias of aliases) { + if (Object.prototype.hasOwnProperty.call(object, alias)) return object[alias]; + } + } + return undefined; + } + + function errorMessageFromPayload(payload, response) { + if (payload && typeof payload === "object") { + if (typeof payload.message === "string" && payload.message.trim()) return payload.message; + if (payload.error && typeof payload.error === "object" && typeof payload.error.message === "string") { + return payload.error.message; + } + if (typeof payload.error === "string" && payload.error.trim()) return payload.error; + } + if (response.status === 400) return "El dispositivo rechazó los datos enviados."; + if (response.status === 401) return "La sesión no es válida o ha expirado."; + if (response.status === 403) return "No se pudo validar la sesión de protección."; + if (response.status === 404) return "El recurso solicitado no está disponible."; + if (response.status === 409) return "La operación entra en conflicto con el estado actual."; + if (response.status >= 500) return "El dispositivo no pudo completar la operación."; + return `La solicitud falló con el código ${response.status}.`; + } + + async function api(path, options = {}) { + const method = String(options.method || "GET").toUpperCase(); + const isMutation = !["GET", "HEAD", "OPTIONS"].includes(method); + const isAuthenticatedMutation = isMutation && appState.authenticated; + + if (isAuthenticatedMutation && !appState.csrfToken) { + throw new ApiError("No hay un token de protección en memoria. Actualiza la sesión antes de modificar datos."); + } + + const headers = { Accept: "application/json" }; + if (options.body !== undefined) headers["Content-Type"] = "application/json"; + if (isAuthenticatedMutation) headers["X-CSRF-Token"] = appState.csrfToken; + + const controller = new AbortController(); + const timeoutId = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + let response; + let payload = null; + + try { + const requestOptions = { + method, + headers, + credentials: "same-origin", + cache: "no-store", + signal: controller.signal + }; + if (options.body !== undefined) requestOptions.body = JSON.stringify(options.body); + response = await fetch(`${API_BASE}${path}`, requestOptions); + const rawText = await response.text(); + if (rawText) { + try { + payload = JSON.parse(rawText); + } catch (_error) { + if (response.ok) throw new ApiError("El dispositivo devolvió una respuesta que no es JSON válido.", response.status); + payload = null; + } + } + } catch (error) { + if (error instanceof ApiError) throw error; + if (error && error.name === "AbortError") { + throw new ApiError("El dispositivo tardó demasiado en responder.", 0, "timeout"); + } + throw new ApiError("No se pudo conectar con TimbreESP. Comprueba que el equipo esté accesible.", 0, "network"); + } finally { + window.clearTimeout(timeoutId); + } + + const payloadFailed = payload && typeof payload === "object" && payload.ok === false; + if (!response.ok || payloadFailed) { + const code = payload && typeof payload === "object" && payload.error && typeof payload.error === "object" + ? asString(payload.error.code) + : asString(payload && typeof payload === "object" ? payload.error : ""); + throw new ApiError(errorMessageFromPayload(payload, response), response.status, code); + } + + return payload; + } + + function setFormError(node, message = "") { + node.textContent = message; + setHidden(node, !message); + } + + function setButtonBusy(button, busy, busyText = "Guardando…") { + if (!button) return; + if (busy) { + button.dataset.idleText = button.textContent; + button.dataset.wasDisabled = button.disabled ? "true" : "false"; + button.textContent = busyText; + button.disabled = true; + button.setAttribute("aria-busy", "true"); + button.classList.add("is-busy"); + } else { + if (button.dataset.idleText !== undefined) button.textContent = button.dataset.idleText; + button.disabled = button.dataset.wasDisabled === "true"; + button.removeAttribute("aria-busy"); + button.classList.remove("is-busy"); + } + } + + function showToast(message, type = "success") { + const toast = createElement("div", `toast toast--${type}`, message); + toast.setAttribute("role", type === "error" ? "alert" : "status"); + refs.toastRegion.append(toast); + window.setTimeout(() => toast.remove(), type === "error" ? 7000 : 4200); + } + + function setConnection(kind, text) { + refs.connectionBadge.className = `connection-badge connection-badge--${kind}`; + refs.connectionText.textContent = text; + } + + function showScreen(name) { + for (const [key, screen] of Object.entries(refs.screens)) setHidden(screen, key !== name); + setHidden(refs.dashboard, name !== "dashboard"); + } + + function showBootstrapError(error) { + refs.bootstrapMessage.textContent = "No se pudo iniciar la interfaz."; + refs.bootstrapErrorMessage.textContent = error instanceof Error ? error.message : "Ha ocurrido un error inesperado."; + setHidden(refs.bootstrapError, false); + } + + function stopTimers() { + if (statePollTimer !== null) window.clearInterval(statePollTimer); + if (clockTimer !== null) window.clearInterval(clockTimer); + statePollTimer = null; + clockTimer = null; + stateRequestInFlight = false; + } + + function startTimers() { + stopTimers(); + clockTimer = window.setInterval(renderClock, 1000); + statePollTimer = window.setInterval(() => void pollStatus(false), STATE_POLL_MS); + } + + function updateSessionFromPayload(payload) { + const session = extractData(payload, ["session"]) || {}; + const authenticated = asBoolean(session.authenticated) === true; + const csrfToken = asString(session.csrfToken || session.csrf_token); + appState.authenticated = authenticated; + appState.csrfToken = authenticated && csrfToken ? csrfToken : null; + setHidden(refs.csrfWarning, !authenticated || Boolean(appState.csrfToken)); + return { authenticated, setupRequired: asBoolean(session.setupRequired) === true }; + } + + function resetDashboardData() { + appState.status = null; + appState.selectedProfileId = null; + appState.activeProfileId = null; + appState.activeProfileKnown = false; + appState.profiles = []; + appState.schedules = []; + appState.patterns = []; + appState.holidays = []; + for (const resource of Object.values(appState.resources)) { + resource.loading = true; + resource.error = null; + } + for (const container of [refs.profilesContent, refs.schedulesContent, refs.patternsContent, refs.holidaysContent]) { + container.setAttribute("aria-busy", "true"); + container.replaceChildren(); + } + refs.profileTabs.replaceChildren(); + refs.scheduleCount.textContent = `0 / ${MAX_SCHEDULES} horarios`; + refs.addProfileButton.disabled = true; + refs.addScheduleButton.disabled = true; + refs.addPatternButton.disabled = true; + refs.holidaySubmit.disabled = true; + renderLoading(refs.profilesContent, "Cargando perfiles…"); + renderLoading(refs.schedulesContent, "Cargando horarios…"); + renderLoading(refs.patternsContent, "Cargando patrones…"); + renderLoading(refs.holidaysContent, "Cargando feriados…"); + renderStatus(); + } + + function showClaim(message = "") { + stopTimers(); + appState.authenticated = false; + appState.csrfToken = null; + resetDashboardData(); + showScreen("claim"); + setFormError(refs.claimError, message); + window.setTimeout(() => refs.claimToken.focus(), 0); + } + + function showLogin(message = "") { + stopTimers(); + appState.authenticated = false; + appState.csrfToken = null; + resetDashboardData(); + showScreen("login"); + refs.loginPassword.value = ""; + refs.loginIntro.textContent = "Introduce la contraseña configurada para controlar el timbre."; + setFormError(refs.loginError, message); + window.setTimeout(() => refs.loginPassword.focus(), 0); + } + + function handleSessionExpired(message = "La sesión expiró. Vuelve a iniciar sesión.") { + appState.authenticated = false; + appState.csrfToken = null; + showLogin(message); + } + + function showDashboard(session) { + appState.timeSyncAttempted = false; + if (session && session.setupRequired) { + showClaim(); + return; + } + resetDashboardData(); + showScreen("dashboard"); + setHidden(refs.csrfWarning, Boolean(appState.csrfToken)); + setConnection("loading", "Conectando"); + startTimers(); + void pollStatus(true); + void loadAllData(); + } + + async function bootstrap() { + stopTimers(); + appState.authenticated = false; + appState.csrfToken = null; + showScreen("bootstrap"); + refs.app.setAttribute("aria-busy", "true"); + refs.bootstrapMessage.textContent = "Comprobando el estado de la configuración."; + setHidden(refs.bootstrapError, true); + + try { + const setupPayload = await api("/setup"); + const setup = extractData(setupPayload, ["setup"]) || {}; + const setupRequired = asBoolean(firstDefined(setup.setupRequired, setup.required, setup.claimRequired)); + if (setupRequired === true) { + showClaim(); + return; + } + + const sessionPayload = await api("/session"); + const session = updateSessionFromPayload(sessionPayload); + if (session.setupRequired) { + showClaim(); + } else if (session.authenticated) { + showDashboard(session); + } else { + showLogin(); + } + } catch (error) { + showScreen("bootstrap"); + showBootstrapError(error); + } finally { + refs.app.setAttribute("aria-busy", "false"); + } + } + + async function handleClaimSubmit(event) { + event.preventDefault(); + setFormError(refs.claimError); + if (!refs.claimForm.reportValidity()) return; + if (refs.claimPassword.value !== refs.claimPasswordConfirm.value) { + setFormError(refs.claimError, "Las contraseñas no coinciden."); + return; + } + + setButtonBusy(refs.claimSubmit, true, "Claim…"); + try { + await api("/setup/claim", { + method: "POST", + body: { + token: refs.claimToken.value.trim(), + password: refs.claimPassword.value + } + }); + refs.claimForm.reset(); + let sessionPayload; + try { + sessionPayload = await api("/session"); + } catch (_error) { + sessionPayload = null; + } + if (sessionPayload) { + const session = updateSessionFromPayload(sessionPayload); + if (session.authenticated) { + showDashboard(session); + showToast("Configuración inicial completada."); + return; + } + } + appState.authenticated = false; + appState.csrfToken = null; + showLogin("Configuración completada. Inicia sesión con la contraseña nueva."); + } catch (error) { + setFormError(refs.claimError, error instanceof Error ? error.message : "No se pudo completar el Claim."); + } finally { + setButtonBusy(refs.claimSubmit, false); + } + } + + async function handleLoginSubmit(event) { + event.preventDefault(); + setFormError(refs.loginError); + if (!refs.loginForm.reportValidity()) return; + + setButtonBusy(refs.loginSubmit, true, "Entrando…"); + try { + const loginPayload = await api("/session", { + method: "POST", + body: { password: refs.loginPassword.value } + }); + const directSession = updateSessionFromPayload(loginPayload); + if (directSession.authenticated && appState.csrfToken) { + refs.loginForm.reset(); + showDashboard(directSession); + return; + } + + const sessionPayload = await api("/session"); + const session = updateSessionFromPayload(sessionPayload); + if (session.setupRequired) { + showClaim(); + } else if (session.authenticated) { + refs.loginForm.reset(); + showDashboard(session); + } else { + setFormError(refs.loginError, "La contraseña no fue aceptada."); + } + } catch (error) { + setFormError(refs.loginError, error instanceof Error ? error.message : "No se pudo iniciar sesión."); + } finally { + setButtonBusy(refs.loginSubmit, false); + } + } + + async function handleLogout() { + setButtonBusy(refs.logoutButton, true, "Cerrando…"); + try { + await api("/session", { method: "DELETE" }); + appState.authenticated = false; + appState.csrfToken = null; + showLogin("Sesión cerrada."); + } catch (error) { + if (error instanceof ApiError && error.status === 401) { + appState.authenticated = false; + appState.csrfToken = null; + showLogin(); + } else { + showToast(error instanceof Error ? error.message : "No se pudo cerrar la sesión.", "error"); + } + } finally { + setButtonBusy(refs.logoutButton, false); + } + } + + function normalizeDayMask(value) { + if (Array.isArray(value)) { + return value.reduce((mask, day) => { + if (typeof day === "number" && day >= 0 && day <= 6) return mask | (1 << day); + const legacyIndex = DAY_NAMES.findIndex((entry) => entry.short.toLowerCase() === asString(day).trim().toLowerCase()); + return legacyIndex >= 0 ? mask | (1 << legacyIndex) : mask; + }, 0); + } + + const number = asFiniteNumber(value); + if (number !== null) { + if (String(value).trim().toLowerCase().startsWith("0b")) { + const binary = Number.parseInt(String(value).slice(2), 2); + return Number.isInteger(binary) ? binary & 0x7f : 0; + } + return Number.isInteger(number) ? number & 0x7f : 0; + } + + if (typeof value === "string") { + const legacyCodes = value.split(",").map((part) => part.trim()).filter(Boolean); + return legacyCodes.reduce((mask, code) => { + const index = DAY_NAMES.findIndex((entry) => entry.short.toLowerCase() === code.toLowerCase()); + return index >= 0 ? mask | (1 << index) : mask; + }, 0); + } + return 0; + } + + function parseProfiles(payload) { + const items = extractList(payload, ["profiles", "perfiles"], "perfiles"); + const profiles = items.map((item, index) => ({ + raw: item, + id: firstDefined(item.id, item.profileId, item.perfilId, index), + name: asString(firstDefined(item.name, item.nombre, item.label) || `Perfil ${index + 1}`) + })); + const activeValue = metadataValue(payload, [ + "activeProfileId", "activeProfile", "perfilActivoId", "perfilActivo", "activo" + ]); + return { profiles, activeProfileId: activeValue ?? null, activeProfileKnown: activeValue !== undefined }; + } + + function minuteToTime(value) { + const minute = asFiniteNumber(value); + if (minute === null || !Number.isInteger(minute) || minute < 0 || minute > 1439) { + return "00:00"; + } + return `${String(Math.floor(minute / 60)).padStart(2, "0")}:${String(minute % 60).padStart(2, "0")}`; + } + + function parseSchedules(payload) { + const items = extractList(payload, ["schedules", "horarios"], "horarios"); + return items.map((item, index) => ({ + raw: item, + id: firstDefined(item.id, item.scheduleId, item.horarioId, index), + profileId: firstDefined(item.profileId, item.perfilId, item.profile, item.perfil), + patternId: firstDefined(item.patternId, item.patronId, item.pattern, item.patron), + time: firstDefined(item.time, item.hora) !== undefined + ? asString(firstDefined(item.time, item.hora)) + : minuteToTime(item.minuteOfDay), + days: normalizeDayMask(firstDefined(item.days, item.weekdays, item.dayMask, item.dias, 0)), + enabled: asBoolean(firstDefined(item.enabled, true)) !== false + })); + } + + function parsePatterns(payload) { + const items = extractList(payload, ["patterns", "patrones"], "patrones"); + return items.map((item, index) => { + const phases = Array.isArray(item.phaseSeconds) + ? item.phaseSeconds + : (Array.isArray(item.fases) ? item.fases : []); + const phaseSeconds = Array.from({ length: 6 }, (_, phaseIndex) => { + const value = asFiniteNumber(phases[phaseIndex]); + return value === null || value < 0 ? 0 : value; + }); + return { + raw: item, + id: firstDefined(item.id, item.patternId, index), + legacyCode: asString(firstDefined(item.legacyCode, item.code, `Patrón ${index + 1}`)).toUpperCase(), + target: firstDefined(item.target, "GPIO22"), + phaseSeconds, + enabled: asBoolean(firstDefined(item.enabled, true)) !== false + }; + }); + } + + function parseHolidays(payload) { + const items = extractList(payload, ["holidays", "feriados"], "feriados"); + return items + .map((item) => ({ + raw: item, + month: asFiniteNumber(firstDefined(item.month, item.mes)), + day: asFiniteNumber(firstDefined(item.day, item.dia)) + })) + .filter((holiday) => holiday.month !== null && holiday.day !== null) + .map((holiday) => ({ ...holiday, month: Math.trunc(holiday.month), day: Math.trunc(holiday.day) })) + .sort((left, right) => left.month - right.month || left.day - right.day); + } + + function normalizeEpoch(value) { + const epoch = asFiniteNumber(value); + if (epoch === null) return null; + const seconds = epoch > 10_000_000_000 ? epoch / 1000 : epoch; + return Number.isInteger(seconds) ? seconds : Math.trunc(seconds); + } + + function parseStatus(payload) { + const root = extractData(payload, ["estado", "state"]) || {}; + if (typeof root !== "object" || Array.isArray(root)) { + return { clockValid: false, sequenceActive: false, epoch: null }; + } + const clock = root.clock && typeof root.clock === "object" ? root.clock : {}; + const clockEs = root.reloj && typeof root.reloj === "object" ? root.reloj : {}; + const epoch = normalizeEpoch(firstDefined( + root.epoch, root.timeEpoch, root.unixTime, root.horaEpoch, + typeof root.reloj === "number" ? root.reloj : undefined, + typeof root.hora === "number" ? root.hora : undefined, + clock.epoch, clockEs.epoch + )); + const validFlag = asBoolean(firstDefined( + root.clockValid, root.timeValid, root.hasValidTime, root.relojValido, root.horaValida, + clock.valid, clock.clockValid, clockEs.valido, clockEs.valid + )); + const plausibleEpoch = epoch !== null && epoch >= 1_577_836_800 && epoch <= 4_102_444_800; + const sequenceFlag = asBoolean(firstDefined( + root.sequenceActive, root.secuenciaActiva, root.ringing, root.sonando, + root.outputActive, root.salidaActiva, root.patternRunning, root.patronEnEjecucion, + root.actionActive, root.accionActiva + )); + return { + epoch, + clockValid: validFlag === null ? plausibleEpoch : validFlag, + sequenceActive: sequenceFlag === true, + activePatternId: firstDefined( + root.activePatternId, root.currentPatternId, root.patternId, + root.patronActivoId, root.patronActualId, root.patronId + ) ?? null, + activeProfileId: firstDefined( + root.activeProfileId, root.perfilActivoId, root.profileId, root.perfilId + ) ?? null, + wifiConnected: asBoolean(firstDefined(root.wifiConnected, root.wifiConectado, root.online)) + }; + } + + function renderClock() { + if (!appState.status) return; + if (!appState.status.clockValid || appState.status.epoch === null) { + refs.clockValue.textContent = "--:--:--"; + refs.clockValue.removeAttribute("datetime"); + refs.clockDate.textContent = "Hora no disponible"; + return; + } + const date = new Date(appState.status.epoch * 1000); + if (Number.isNaN(date.getTime())) { + refs.clockValue.textContent = "--:--:--"; + refs.clockValue.removeAttribute("datetime"); + refs.clockDate.textContent = "Hora no disponible"; + return; + } + try { + const time = new Intl.DateTimeFormat("es-ES", { + timeZone: "Etc/GMT+6", + hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23" + }).format(date); + const fullDate = new Intl.DateTimeFormat("es-ES", { + timeZone: "Etc/GMT+6", + weekday: "long", year: "numeric", month: "long", day: "2-digit" + }).format(date); + refs.clockValue.textContent = time; + refs.clockValue.setAttribute("datetime", date.toISOString()); + refs.clockDate.textContent = fullDate; + } catch (_error) { + refs.clockValue.textContent = "--:--:--"; + refs.clockValue.removeAttribute("datetime"); + refs.clockDate.textContent = "Hora no disponible"; + } + } + + function renderStatus() { + if (!appState.status) { + refs.clockIndicator.textContent = "Inválido"; + refs.clockIndicator.className = "state-pill state-pill--invalid"; + refs.clockValue.textContent = "--:--:--"; + refs.clockValue.removeAttribute("datetime"); + refs.clockDate.textContent = "Hora no disponible"; + refs.clockStatus.textContent = "No se ha podido validar el reloj. Se considera inválido hasta recibir una hora válida."; + refs.clockStatus.className = "status-message status-message--error"; + refs.sequenceIndicator.textContent = "Desconocido"; + refs.sequenceIndicator.className = "state-pill state-pill--muted"; + refs.stopButton.disabled = true; + return; + } + + if (appState.status.clockValid && appState.status.epoch !== null) { + refs.clockIndicator.textContent = "Válido"; + refs.clockIndicator.className = "state-pill state-pill--valid"; + refs.clockStatus.textContent = "Reloj válido. El estado se consulta cada 5 segundos."; + refs.clockStatus.className = "status-message status-message--success"; + } else { + refs.clockIndicator.textContent = "Inválido"; + refs.clockIndicator.className = "state-pill state-pill--invalid"; + refs.clockStatus.textContent = "Reloj inválido. Sincroniza la hora antes de confiar en la programación."; + refs.clockStatus.className = "status-message status-message--error"; + } + renderClock(); + + const activePattern = findById(appState.patterns, appState.status.activePatternId); + refs.activePatternLabel.textContent = activePattern + ? `${activePattern.legacyCode} · ${activePattern.enabled ? "habilitado" : "deshabilitado"}` + : (appState.status.activePatternId !== null ? `Patrón ${appState.status.activePatternId}` : "Ninguno"); + if (appState.status.sequenceActive) { + refs.sequenceIndicator.textContent = "Activo"; + refs.sequenceIndicator.className = "state-pill state-pill--active"; + } else { + refs.sequenceIndicator.textContent = "Inactivo"; + refs.sequenceIndicator.className = "state-pill state-pill--muted"; + } + refs.stopButton.disabled = !appState.status.sequenceActive; + } + + async function pollStatus(initial) { + if (!appState.authenticated || stateRequestInFlight || refs.dashboard.hidden) return; + const requestToken = appState.csrfToken; + stateRequestInFlight = true; + if (initial) { + setConnection("loading", "Sincronizando"); + refs.stateMessage.textContent = "Consultando el estado…"; + refs.stateMessage.className = "status-message status-message--muted"; + } + + try { + const payload = await api("/estado"); + if (refs.dashboard.hidden || appState.csrfToken !== requestToken) return; + appState.status = parseStatus(payload); + if (!appState.status.clockValid && !appState.timeSyncAttempted) { + appState.timeSyncAttempted = true; + try { + await api("/hora", { + method: "POST", + body: { epoch: Math.floor(Date.now() / 1000) } + }); + const refreshed = await api("/estado"); + if (!refs.dashboard.hidden && appState.csrfToken === requestToken) { + appState.status = parseStatus(refreshed); + } + } catch (syncError) { + refs.clockStatus.textContent = syncError instanceof Error + ? syncError.message + : "No se pudo sincronizar la hora automáticamente."; + } + } + if (appState.status.activeProfileId !== null) { + appState.activeProfileId = appState.status.activeProfileId; + appState.activeProfileKnown = true; + renderActiveProfileControl(); + renderProfiles(); + } + setConnection("online", "En línea"); + refs.stateMessage.textContent = `Actualizado a las ${new Intl.DateTimeFormat("es-ES", { + hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23" + }).format(new Date())}`; + refs.stateMessage.className = "status-message status-message--success"; + renderStatus(); + } catch (error) { + if (refs.dashboard.hidden || appState.csrfToken !== requestToken) return; + if (error instanceof ApiError && (error.status === 401 || error.status === 403)) { + handleSessionExpired(); + return; + } + setConnection("error", "Sin respuesta"); + if (!appState.status) { + refs.clockIndicator.textContent = "Inválido"; + refs.clockIndicator.className = "state-pill state-pill--invalid"; + refs.clockValue.textContent = "--:--:--"; + refs.clockDate.textContent = "Hora no disponible"; + refs.clockStatus.textContent = "No se pudo consultar el reloj. Se considera inválido hasta recibir una respuesta válida."; + refs.clockStatus.className = "status-message status-message--error"; + } + refs.stateMessage.textContent = error instanceof Error ? error.message : "No se pudo consultar el estado."; + refs.stateMessage.className = "status-message status-message--error"; + renderStatus(); + } finally { + if (appState.csrfToken === requestToken) stateRequestInFlight = false; + } + } + + function renderLoading(container, message) { + container.setAttribute("aria-busy", "true"); + const wrapper = createElement("div", "resource-state"); + const spinner = createElement("span", "spinner"); + spinner.setAttribute("aria-hidden", "true"); + const text = createElement("p", "", message); + wrapper.append(spinner, text); + container.replaceChildren(wrapper); + } + + function renderResourceError(container, title, error, retry) { + container.setAttribute("aria-busy", "false"); + const wrapper = createElement("div", "resource-state resource-state--error"); + wrapper.append(createElement("h3", "", title)); + wrapper.append(createElement("p", "", error instanceof Error ? error.message : "Ha ocurrido un error inesperado.")); + const button = createElement("button", "button button--secondary", "Reintentar"); + button.type = "button"; + button.addEventListener("click", retry); + wrapper.append(button); + container.replaceChildren(wrapper); + } + + function renderEmpty(container, title, message, actionLabel, action) { + container.setAttribute("aria-busy", "false"); + const wrapper = createElement("div", "resource-state"); + wrapper.append(createElement("h3", "", title)); + wrapper.append(createElement("p", "", message)); + if (actionLabel && action) { + const button = createElement("button", "button button--secondary", actionLabel); + button.type = "button"; + button.addEventListener("click", action); + wrapper.append(button); + } + container.replaceChildren(wrapper); + } + + async function loadProfiles() { + if (!appState.authenticated || refs.dashboard.hidden) return false; + const requestToken = appState.csrfToken; + const resource = appState.resources.profiles; + const initial = resource.loading && !appState.profiles.length; + if (initial) renderLoading(refs.profilesContent, "Cargando perfiles…"); + try { + const payload = await api("/perfiles"); + if (refs.dashboard.hidden || appState.csrfToken !== requestToken) return false; + const parsed = parseProfiles(payload); + appState.profiles = parsed.profiles; + if (parsed.activeProfileKnown) { + appState.activeProfileId = parsed.activeProfileId; + appState.activeProfileKnown = true; + } + if (parsed.activeProfileKnown && !findById(appState.profiles, appState.activeProfileId)) { + appState.activeProfileId = appState.profiles[0]?.id ?? null; + } + if (!findById(appState.profiles, appState.selectedProfileId)) { + appState.selectedProfileId = appState.profiles[0]?.id ?? null; + } + resource.loading = false; + resource.error = null; + renderActiveProfileControl(); + renderProfiles(); + renderProfileTabs(); + renderSchedules(); + return true; + } catch (error) { + if (refs.dashboard.hidden || appState.csrfToken !== requestToken) return false; + if (error instanceof ApiError && (error.status === 401 || error.status === 403)) { + handleSessionExpired(); + return false; + } + resource.loading = false; + resource.error = error; + if (initial) { + renderResourceError(refs.profilesContent, "No se pudieron cargar los perfiles", error, () => void loadProfiles()); + } + return false; + } + } + + async function loadSchedules() { + if (!appState.authenticated || refs.dashboard.hidden) return false; + const requestToken = appState.csrfToken; + const resource = appState.resources.schedules; + const initial = resource.loading && !appState.schedules.length; + if (initial) renderLoading(refs.schedulesContent, "Cargando horarios…"); + try { + const payload = await api("/horarios"); + if (refs.dashboard.hidden || appState.csrfToken !== requestToken) return false; + appState.schedules = parseSchedules(payload); + resource.loading = false; + resource.error = null; + renderSchedules(); + return true; + } catch (error) { + if (refs.dashboard.hidden || appState.csrfToken !== requestToken) return false; + if (error instanceof ApiError && (error.status === 401 || error.status === 403)) { + handleSessionExpired(); + return false; + } + resource.loading = false; + resource.error = error; + if (initial) { + renderResourceError(refs.schedulesContent, "No se pudieron cargar los horarios", error, () => void loadSchedules()); + } + return false; + } + } + + async function loadPatterns() { + if (!appState.authenticated || refs.dashboard.hidden) return false; + const requestToken = appState.csrfToken; + const resource = appState.resources.patterns; + const initial = resource.loading && !appState.patterns.length; + if (initial) renderLoading(refs.patternsContent, "Cargando patrones…"); + try { + const payload = await api("/patrones"); + if (refs.dashboard.hidden || appState.csrfToken !== requestToken) return false; + appState.patterns = parsePatterns(payload); + resource.loading = false; + resource.error = null; + renderPatterns(); + renderStatus(); + return true; + } catch (error) { + if (refs.dashboard.hidden || appState.csrfToken !== requestToken) return false; + if (error instanceof ApiError && (error.status === 401 || error.status === 403)) { + handleSessionExpired(); + return false; + } + resource.loading = false; + resource.error = error; + if (initial) { + renderResourceError(refs.patternsContent, "No se pudieron cargar los patrones", error, () => void loadPatterns()); + } + return false; + } + } + + async function loadHolidays() { + if (!appState.authenticated || refs.dashboard.hidden) return false; + const requestToken = appState.csrfToken; + const resource = appState.resources.holidays; + const initial = resource.loading && !appState.holidays.length; + if (initial) renderLoading(refs.holidaysContent, "Cargando feriados…"); + try { + const payload = await api("/feriados"); + if (refs.dashboard.hidden || appState.csrfToken !== requestToken) return false; + appState.holidays = parseHolidays(payload); + resource.loading = false; + resource.error = null; + renderHolidays(); + return true; + } catch (error) { + if (refs.dashboard.hidden || appState.csrfToken !== requestToken) return false; + if (error instanceof ApiError && (error.status === 401 || error.status === 403)) { + handleSessionExpired(); + return false; + } + resource.loading = false; + resource.error = error; + if (initial) { + renderResourceError(refs.holidaysContent, "No se pudieron cargar los feriados", error, () => void loadHolidays()); + } + return false; + } + } + + async function loadAllData() { + const results = []; + for (const loader of [loadProfiles, loadSchedules, loadPatterns, loadHolidays]) { + if (!appState.authenticated || refs.dashboard.hidden) return false; + results.push(await loader()); + } + return results.every(Boolean); + } + + async function refreshAll() { + setButtonBusy(refs.refreshButton, true, "Actualizando…"); + const resourcesOk = await loadAllData(); + await pollStatus(false); + setButtonBusy(refs.refreshButton, false); + showToast(resourcesOk ? "Datos actualizados." : "Los datos se actualizaron parcialmente; revisa los avisos.", resourcesOk ? "success" : "error"); + } + + function updateActiveProfileSubmit() { + const selected = findById(appState.profiles, refs.activeProfileSelect.value); + const unchanged = appState.activeProfileKnown && sameId(selected?.id, appState.activeProfileId); + refs.activeProfileSubmit.disabled = !selected || unchanged || !appState.csrfToken; + } + + function renderActiveProfileControl() { + const hasProfiles = appState.profiles.length > 0; + const select = refs.activeProfileSelect; + select.replaceChildren(); + + if (!hasProfiles && appState.resources.profiles.loading) { + select.append(createElement("option", "", "Cargando perfiles…")); + select.value = ""; + } else if (!hasProfiles) { + const option = createElement("option", "", "No hay perfiles disponibles"); + option.value = ""; + select.append(option); + select.value = ""; + } else { + if (!appState.activeProfileKnown) { + const placeholder = createElement("option", "", "Selecciona un perfil"); + placeholder.value = ""; + select.append(placeholder); + } + for (const profile of appState.profiles) { + const option = createElement("option", "", profile.name); + option.value = String(profile.id); + select.append(option); + } + select.value = appState.activeProfileKnown && findById(appState.profiles, appState.activeProfileId) + ? String(appState.activeProfileId) + : ""; + } + + select.disabled = !hasProfiles; + updateActiveProfileSubmit(); + } + + function renderProfiles() { + refs.addProfileButton.disabled = appState.profiles.length >= MAX_PROFILES; + if (!appState.profiles.length) { + if (appState.resources.profiles.loading) { + renderLoading(refs.profilesContent, "Cargando perfiles…"); + } else { + renderEmpty( + refs.profilesContent, + "No hay perfiles", + "Crea el primer perfil para empezar a organizar los horarios.", + "Añadir perfil", + () => openProfileDialog() + ); + } + return; + } + + const grid = createElement("div", "profile-grid"); + for (const profile of appState.profiles) { + const isActive = appState.activeProfileKnown && sameId(profile.id, appState.activeProfileId); + const card = createElement("article", `profile-card${isActive ? " profile-card--active" : ""}`); + const heading = createElement("div", "profile-card__title"); + heading.append(createElement("h3", "", profile.name)); + if (isActive) { + const badge = createElement("span", "state-pill state-pill--active", "Activo"); + heading.append(badge); + } + const actions = createElement("div", "profile-card__actions"); + const editButton = createElement("button", "button button--secondary button--small", "Renombrar"); + editButton.type = "button"; + editButton.addEventListener("click", () => openProfileDialog(profile)); + actions.append(editButton); + + const deleteButton = createElement("button", "button button--danger button--small", "Eliminar"); + deleteButton.type = "button"; + deleteButton.disabled = appState.profiles.length <= 1; + deleteButton.addEventListener("click", () => confirmProfileDeletion(profile)); + actions.append(deleteButton); + card.append(heading, actions); + grid.append(card); + } + refs.profilesContent.setAttribute("aria-busy", "false"); + refs.profilesContent.replaceChildren(grid); + } + + function renderProfileTabs() { + refs.profileTabs.replaceChildren(); + if (!appState.profiles.length) { + const empty = createElement("span", "field-help", "Crea un perfil para ver sus horarios."); + refs.profileTabs.append(empty); + return; + } + for (const profile of appState.profiles) { + const button = createElement("button", "", profile.name); + button.type = "button"; + button.setAttribute("role", "tab"); + const selected = sameId(profile.id, appState.selectedProfileId); + button.setAttribute("aria-selected", selected ? "true" : "false"); + button.tabIndex = selected ? 0 : -1; + button.addEventListener("click", () => selectProfile(profile.id)); + button.addEventListener("keydown", (event) => handleProfileTabKeydown(event, profile.id)); + refs.profileTabs.append(button); + } + } + + function handleProfileTabKeydown(event, currentId) { + if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; + event.preventDefault(); + if (!appState.profiles.length) return; + let index = appState.profiles.findIndex((profile) => sameId(profile.id, currentId)); + if (index < 0) index = 0; + if (event.key === "Home") index = 0; + else if (event.key === "End") index = appState.profiles.length - 1; + else if (event.key === "ArrowRight") index = (index + 1) % appState.profiles.length; + else index = (index - 1 + appState.profiles.length) % appState.profiles.length; + const nextId = appState.profiles[index].id; + selectProfile(nextId); + const buttons = $$('[role="tab"]', refs.profileTabs); + if (buttons[index]) buttons[index].focus(); + } + + function selectProfile(id) { + appState.selectedProfileId = id; + renderProfileTabs(); + renderSchedules(); + } + + function formatDays(mask) { + const selected = DAY_NAMES.filter((day) => (mask & (1 << day.index)) !== 0); + return selected.length ? selected.map((day) => day.short).join(" · ") : "Ningún día"; + } + + function dayBadges(mask) { + const list = createElement("div", "day-list"); + for (const day of DAY_NAMES) { + const badge = createElement("span", "day-badge", day.short); + badge.title = day.name; + if ((mask & (1 << day.index)) === 0) badge.setAttribute("aria-label", `${day.name}: no seleccionado`); + else badge.setAttribute("aria-label", `${day.name}: seleccionado`); + list.append(badge); + } + return list; + } + + function renderSchedules() { + refs.scheduleCount.textContent = `${appState.schedules.length} / ${MAX_SCHEDULES} horarios`; + refs.addScheduleButton.disabled = ( + appState.schedules.length >= MAX_SCHEDULES || + !appState.profiles.length || + !appState.patterns.length || + appState.resources.schedules.loading + ); + renderProfileTabs(); + + if (appState.resources.schedules.loading && !appState.schedules.length) { + renderLoading(refs.schedulesContent, "Cargando horarios…"); + return; + } + if (appState.resources.schedules.error && !appState.schedules.length) { + renderResourceError(refs.schedulesContent, "No se pudieron cargar los horarios", appState.resources.schedules.error, () => void loadSchedules()); + return; + } + if (!appState.profiles.length) { + renderEmpty(refs.schedulesContent, "Primero crea un perfil", "Los horarios se organizan dentro de un perfil."); + return; + } + + if (!findById(appState.profiles, appState.selectedProfileId)) { + appState.selectedProfileId = appState.profiles[0]?.id ?? null; + renderProfileTabs(); + } + const visibleSchedules = appState.schedules + .filter((schedule) => sameId(schedule.profileId, appState.selectedProfileId)) + .sort((left, right) => left.time.localeCompare(right.time)); + if (!visibleSchedules.length) { + const profile = findById(appState.profiles, appState.selectedProfileId); + renderEmpty( + refs.schedulesContent, + "No hay horarios en este perfil", + `Añade una hora para ${profile ? profile.name : "este perfil"} o elige otro perfil.`, + "Añadir horario", + () => openScheduleDialog() + ); + return; + } + + const wrapper = createElement("div", "table-wrap"); + const table = createElement("table", "data-table"); + const caption = createElement("caption", "visually-hidden", "Horarios del perfil seleccionado"); + const head = createElement("thead"); + const headRow = createElement("tr"); + for (const label of ["Hora", "Patrón", "Días", "Acciones"]) { + const th = createElement("th", "", label); + th.scope = "col"; + headRow.append(th); + } + head.append(headRow); + const body = createElement("tbody"); + + for (const schedule of visibleSchedules) { + const row = createElement("tr"); + const timeCell = createElement("td"); + timeCell.append(createElement("span", "cell-title", schedule.time)); + row.append(timeCell); + + const pattern = findById(appState.patterns, schedule.patternId); + const patternCell = createElement("td"); + patternCell.append(createElement("span", "cell-title", pattern ? pattern.legacyCode : `Patrón ${schedule.patternId}`)); + patternCell.append(createElement("span", "cell-subtitle", pattern && !pattern.enabled ? "Deshabilitado" : "GPIO22 · principal")); + row.append(patternCell); + + const daysCell = createElement("td"); + daysCell.append(dayBadges(schedule.days)); + row.append(daysCell); + + const actionCell = createElement("td"); + const actions = createElement("div", "cell-actions"); + const editButton = createElement("button", "button button--secondary button--small", "Editar"); + editButton.type = "button"; + editButton.addEventListener("click", () => openScheduleDialog(schedule)); + const deleteButton = createElement("button", "button button--danger button--small", "Eliminar"); + deleteButton.type = "button"; + deleteButton.addEventListener("click", () => confirmScheduleDeletion(schedule)); + actions.append(editButton, deleteButton); + actionCell.append(actions); + row.append(actionCell); + body.append(row); + } + table.append(caption, head, body); + wrapper.append(table); + refs.schedulesContent.setAttribute("aria-busy", "false"); + refs.schedulesContent.replaceChildren(wrapper); + } + + function renderPatterns() { + refs.addPatternButton.disabled = appState.patterns.length >= MAX_PATTERNS; + if (appState.resources.patterns.loading && !appState.patterns.length) { + renderLoading(refs.patternsContent, "Cargando patrones…"); + return; + } + if (appState.resources.patterns.error && !appState.patterns.length) { + renderResourceError(refs.patternsContent, "No se pudieron cargar los patrones", appState.resources.patterns.error, () => void loadPatterns()); + return; + } + if (!appState.patterns.length) { + renderEmpty( + refs.patternsContent, + "No hay patrones", + "Crea un ciclo de seis fases para asociarlo a los horarios.", + "Añadir patrón", + () => openPatternDialog() + ); + return; + } + + const grid = createElement("div", "pattern-card-grid"); + for (const pattern of appState.patterns) { + const card = createElement("article", "pattern-card"); + const header = createElement("div", "pattern-card__header"); + const title = createElement("div", "pattern-card__title"); + title.append(createElement("span", "pattern-card__code", pattern.legacyCode)); + const headingText = createElement("div"); + headingText.append(createElement("h3", "", pattern.enabled ? "Patrón habilitado" : "Patrón deshabilitado")); + headingText.append(createElement("p", "cell-subtitle", "Destino: GPIO22 · principal")); + title.append(headingText); + header.append(title); + header.append(createElement("span", `state-pill ${pattern.enabled ? "state-pill--valid" : "state-pill--muted"}`, pattern.enabled ? "Activo" : "Inactivo")); + card.append(header); + + const phases = createElement("div", "pattern-phases"); + pattern.phaseSeconds.forEach((seconds, index) => { + const phase = createElement("div", "pattern-phase"); + phase.append(createElement("strong", "", `${seconds} s`)); + phase.append(createElement("span", "", PHASE_NAMES[index])); + phases.append(phase); + }); + card.append(phases); + + const total = pattern.phaseSeconds.reduce((sum, seconds) => sum + seconds, 0); + const footer = createElement("div", "pattern-card__footer"); + footer.append(createElement("p", "", `Duración total: ${total} segundos`)); + const actions = createElement("div", "pattern-card__actions"); + const testButton = createElement("button", "button button--secondary button--small", "Probar"); + testButton.type = "button"; + testButton.disabled = !pattern.enabled; + testButton.addEventListener("click", (event) => void testPattern(pattern, event.currentTarget)); + const editButton = createElement("button", "button button--secondary button--small", "Editar"); + editButton.type = "button"; + editButton.addEventListener("click", () => openPatternDialog(pattern)); + actions.append(testButton, editButton); + footer.append(actions); + card.append(footer); + grid.append(card); + } + refs.patternsContent.setAttribute("aria-busy", "false"); + refs.patternsContent.replaceChildren(grid); + } + + function renderHolidays() { + refs.holidaySubmit.disabled = appState.holidays.length >= MAX_HOLIDAYS; + if (appState.resources.holidays.loading && !appState.holidays.length) { + renderLoading(refs.holidaysContent, "Cargando feriados…"); + return; + } + if (appState.resources.holidays.error && !appState.holidays.length) { + renderResourceError(refs.holidaysContent, "No se pudieron cargar los feriados", appState.resources.holidays.error, () => void loadHolidays()); + return; + } + if (!appState.holidays.length) { + renderEmpty(refs.holidaysContent, "No hay feriados", "Añade arriba una fecha que deba silenciar el timbre todos los años."); + return; + } + + const list = createElement("div", "holiday-list"); + for (const holiday of appState.holidays) { + const item = createElement("div", "holiday-item"); + const date = createElement("div", "holiday-date"); + date.append(createElement("strong", "", String(holiday.day).padStart(2, "0"))); + date.append(createElement("span", "", MONTH_NAMES[holiday.month - 1] || `Mes ${holiday.month}`)); + item.append(date); + const deleteButton = createElement("button", "button button--danger button--small", "Eliminar"); + deleteButton.type = "button"; + deleteButton.addEventListener("click", () => confirmHolidayDeletion(holiday)); + item.append(deleteButton); + list.append(item); + } + refs.holidaysContent.setAttribute("aria-busy", "false"); + refs.holidaysContent.replaceChildren(list); + } + + function openDialog(dialog) { + if (typeof dialog.showModal === "function") dialog.showModal(); + else dialog.setAttribute("open", ""); + } + + function closeDialog(dialog) { + if (typeof dialog.close === "function") dialog.close(); + else dialog.removeAttribute("open"); + } + + function openProfileDialog(profile = null) { + refs.profileForm.reset(); + setFormError(refs.profileFormError); + refs.profileId.value = profile ? String(profile.id) : ""; + refs.profileName.value = profile ? profile.name : ""; + refs.profileDialogTitle.textContent = profile ? "Renombrar perfil" : "Añadir perfil"; + openDialog(refs.profileDialog); + window.setTimeout(() => refs.profileName.focus(), 0); + } + + function selectOptions(select, items, selectedValue) { + select.replaceChildren(); + for (const item of items) { + const option = createElement("option", "", item.label); + option.value = String(item.value); + select.append(option); + } + if (selectedValue !== undefined && selectedValue !== null) select.value = String(selectedValue); + } + + function buildProfileOptions() { + return appState.profiles.map((profile) => ({ value: profile.id, label: profile.name })); + } + + function buildPatternOptions() { + return appState.patterns.map((pattern) => ({ + value: pattern.id, + label: `${pattern.legacyCode} · ${pattern.enabled ? "habilitado" : "deshabilitado"}` + })); + } + + function openScheduleDialog(schedule = null) { + if (appState.schedules.length >= MAX_SCHEDULES && !schedule) { + showToast(`El límite es de ${MAX_SCHEDULES} horarios. Elimina uno antes de añadir otro.`, "error"); + return; + } + if (!appState.profiles.length) { + showToast("Crea un perfil antes de añadir un horario.", "error"); + return; + } + if (!appState.patterns.length) { + showToast("Crea un patrón antes de añadir un horario.", "error"); + return; + } + refs.scheduleForm.reset(); + setFormError(refs.scheduleFormError); + refs.scheduleId.value = schedule ? String(schedule.id) : ""; + refs.scheduleTime.value = schedule ? schedule.time : ""; + selectOptions(refs.scheduleProfile, buildProfileOptions(), schedule ? schedule.profileId : appState.selectedProfileId); + selectOptions(refs.schedulePattern, buildPatternOptions(), schedule ? schedule.patternId : appState.patterns[0]?.id); + setScheduleDayMask(schedule ? schedule.days : 0); + refs.scheduleDialogTitle.textContent = schedule ? "Editar horario" : "Añadir horario"; + openDialog(refs.scheduleDialog); + window.setTimeout(() => refs.scheduleTime.focus(), 0); + } + + function setScheduleDayMask(mask) { + for (const input of $$('input[name="scheduleDay"]', refs.scheduleForm)) { + const day = Number(input.value); + input.checked = (mask & (1 << day)) !== 0; + } + } + + function readScheduleDayMask() { + return $$('input[name="scheduleDay"]', refs.scheduleForm).reduce((mask, input) => { + return input.checked ? mask | (1 << Number(input.value)) : mask; + }, 0); + } + + function openPatternDialog(pattern = null) { + refs.patternForm.reset(); + setFormError(refs.patternFormError); + refs.patternId.value = pattern ? String(pattern.id) : ""; + refs.patternCode.value = pattern ? pattern.legacyCode : ""; + refs.patternEnabled.checked = pattern ? pattern.enabled : true; + for (let index = 0; index < 6; index += 1) { + const input = $(`#pattern-phase-${index}`, refs.patternForm); + input.value = pattern ? String(pattern.phaseSeconds[index]) : "0"; + } + refs.patternDialogTitle.textContent = pattern ? "Editar patrón" : "Añadir patrón"; + openDialog(refs.patternDialog); + window.setTimeout(() => refs.patternCode.focus(), 0); + } + + function openConfirm(title, message, actionLabel, handler, danger = true) { + confirmHandler = handler; + refs.confirmTitle.textContent = title; + refs.confirmMessage.textContent = message; + refs.confirmActionButton.textContent = actionLabel; + refs.confirmActionButton.className = `button ${danger ? "button--danger" : "button--primary"}`; + openDialog(refs.confirmDialog); + window.setTimeout(() => refs.confirmActionButton.focus(), 0); + } + + function confirmProfileDeletion(profile) { + if (appState.profiles.length <= 1) { + showToast("Debe existir al menos un perfil.", "error"); + return; + } + openConfirm( + "Eliminar perfil", + `Se eliminará “${profile.name}” y sus horarios. Esta acción no se puede deshacer.`, + "Eliminar perfil", + () => deleteProfile(profile) + ); + } + + function confirmScheduleDeletion(schedule) { + openConfirm( + "Eliminar horario", + `Se eliminará el horario de las ${schedule.time}.`, + "Eliminar horario", + () => deleteSchedule(schedule) + ); + } + + function confirmHolidayDeletion(holiday) { + openConfirm( + "Eliminar feriado", + `Se eliminará el ${holiday.day} de ${MONTH_NAMES[holiday.month - 1] || "este mes"}.`, + "Eliminar feriado", + () => deleteHoliday(holiday) + ); + } + + async function handleMutation(button, operation, successMessage, busyText = "Guardando…") { + setButtonBusy(button, true, busyText); + try { + await operation(); + if (successMessage) showToast(successMessage); + return true; + } catch (error) { + if (error instanceof ApiError && (error.status === 401 || error.status === 403)) { + handleSessionExpired(); + } else { + showToast(error instanceof Error ? error.message : "No se pudo completar la operación.", "error"); + } + return false; + } finally { + setButtonBusy(button, false); + } + } + + async function handleProfileSubmit(event) { + event.preventDefault(); + setFormError(refs.profileFormError); + if (!refs.profileForm.reportValidity()) return; + const id = refs.profileId.value; + const existing = findById(appState.profiles, id); + const name = refs.profileName.value.trim(); + if (!name) { + setFormError(refs.profileFormError, "Escribe un nombre para el perfil."); + return; + } + const duplicate = appState.profiles.some((profile) => ( + profile.name.trim().toLocaleLowerCase("es") === name.toLocaleLowerCase("es") && !sameId(profile.id, id) + )); + if (duplicate) { + setFormError(refs.profileFormError, "Ya existe un perfil con ese nombre."); + return; + } + if (!id && appState.profiles.length >= MAX_PROFILES) { + setFormError(refs.profileFormError, `No se pueden crear más de ${MAX_PROFILES} perfiles.`); + return; + } + + setButtonBusy(refs.profileSubmit, true); + try { + await api(id ? "/perfiles/actualizar" : "/perfiles", { + method: "POST", + body: id ? { id: existing.id, name } : { name } + }); + closeDialog(refs.profileDialog); + showToast(id ? "Perfil actualizado." : "Perfil creado."); + await loadProfiles(); + } catch (error) { + if (error instanceof ApiError && (error.status === 401 || error.status === 403)) { + handleSessionExpired(); + } else { + setFormError(refs.profileFormError, error instanceof Error ? error.message : "No se pudo guardar el perfil."); + } + } finally { + setButtonBusy(refs.profileSubmit, false); + } + } + + async function deleteProfile(profile) { + const ok = await handleMutation( + null, + () => api("/perfiles/eliminar", { method: "POST", body: { id: profile.id } }), + "Perfil eliminado.", + "Eliminando…" + ); + if (ok) await loadAllData(); + } + + async function handleActiveProfileSubmit(event) { + event.preventDefault(); + const profile = findById(appState.profiles, refs.activeProfileSelect.value); + if (!profile) { + showToast("Selecciona un perfil válido.", "error"); + return; + } + const ok = await handleMutation( + refs.activeProfileSubmit, + () => api("/config/perfil-activo", { method: "POST", body: { id: profile.id } }), + "Perfil activo actualizado.", + "Guardando…" + ); + if (ok) { + appState.activeProfileId = profile.id; + appState.activeProfileKnown = true; + renderActiveProfileControl(); + renderProfiles(); + } + } + + async function handleScheduleSubmit(event) { + event.preventDefault(); + setFormError(refs.scheduleFormError); + if (!refs.scheduleForm.reportValidity()) return; + const id = refs.scheduleId.value; + const days = readScheduleDayMask(); + if (days === 0) { + setFormError(refs.scheduleFormError, "Selecciona al menos un día de la semana."); + return; + } + if (!id && appState.schedules.length >= MAX_SCHEDULES) { + setFormError(refs.scheduleFormError, `No se pueden guardar más de ${MAX_SCHEDULES} horarios.`); + return; + } + const profile = findById(appState.profiles, refs.scheduleProfile.value); + const pattern = findById(appState.patterns, refs.schedulePattern.value); + if (!profile || !pattern) { + setFormError(refs.scheduleFormError, "El perfil o el patrón seleccionados ya no están disponibles."); + return; + } + const body = { + profileId: profile.id, + time: refs.scheduleTime.value, + patternId: pattern.id, + days + }; + if (id) body.id = findById(appState.schedules, id)?.id ?? id; + + setButtonBusy(refs.scheduleSubmit, true); + try { + await api(id ? "/horarios/actualizar" : "/horarios", { method: "POST", body }); + closeDialog(refs.scheduleDialog); + showToast(id ? "Horario actualizado." : "Horario creado."); + await loadSchedules(); + } catch (error) { + if (error instanceof ApiError && (error.status === 401 || error.status === 403)) { + handleSessionExpired(); + } else { + setFormError(refs.scheduleFormError, error instanceof Error ? error.message : "No se pudo guardar el horario."); + } + } finally { + setButtonBusy(refs.scheduleSubmit, false); + } + } + + async function deleteSchedule(schedule) { + const ok = await handleMutation( + null, + () => api("/horarios/eliminar", { method: "POST", body: { id: schedule.id } }), + "Horario eliminado.", + "Eliminando…" + ); + if (ok) await loadSchedules(); + } + + async function handlePatternSubmit(event) { + event.preventDefault(); + setFormError(refs.patternFormError); + if (!refs.patternForm.reportValidity()) return; + const id = refs.patternId.value; + const legacyCode = refs.patternCode.value.trim().toUpperCase(); + if (!["A", "B", "C"].includes(legacyCode)) { + setFormError(refs.patternFormError, "El código debe ser A, B o C."); + return; + } + const duplicate = appState.patterns.some((pattern) => ( + pattern.legacyCode.toUpperCase() === legacyCode && !sameId(pattern.id, id) + )); + if (duplicate) { + setFormError(refs.patternFormError, "Ya existe un patrón con ese código."); + return; + } + const phaseSeconds = Array.from({ length: 6 }, (_, index) => { + const input = $(`#pattern-phase-${index}`, refs.patternForm); + return Math.max(0, Math.trunc(asFiniteNumber(input.value) ?? 0)); + }); + if (phaseSeconds.some((seconds) => seconds > MAX_PHASE_SECONDS)) { + setFormError(refs.patternFormError, `Cada fase debe durar como máximo ${MAX_PHASE_SECONDS} segundos.`); + return; + } + const totalSeconds = phaseSeconds.reduce((sum, seconds) => sum + seconds, 0); + if (totalSeconds > MAX_PATTERN_SECONDS) { + setFormError(refs.patternFormError, `La duración total no puede superar ${MAX_PATTERN_SECONDS} segundos.`); + return; + } + if (!id && appState.patterns.length >= MAX_PATTERNS) { + setFormError(refs.patternFormError, `No se pueden crear más de ${MAX_PATTERNS} patrones.`); + return; + } + const existing = findById(appState.patterns, id); + const targetReference = existing || appState.patterns.find((pattern) => sameId(pattern.target, 22)); + const targetUsesPinNumber = targetReference && ( + targetReference.target === 22 || asString(targetReference.target).trim() === "22" + ); + const body = { + legacyCode, + target: targetUsesPinNumber ? 22 : "GPIO22", + phaseSeconds, + enabled: refs.patternEnabled.checked + }; + if (id) body.id = existing ? existing.id : id; + + setButtonBusy(refs.patternSubmit, true); + try { + await api(id ? "/patrones/actualizar" : "/patrones", { method: "POST", body }); + closeDialog(refs.patternDialog); + showToast(id ? "Patrón actualizado." : "Patrón creado."); + await loadPatterns(); + await loadSchedules(); + } catch (error) { + if (error instanceof ApiError && (error.status === 401 || error.status === 403)) { + handleSessionExpired(); + } else { + setFormError(refs.patternFormError, error instanceof Error ? error.message : "No se pudo guardar el patrón."); + } + } finally { + setButtonBusy(refs.patternSubmit, false); + } + } + + async function testPattern(pattern, button) { + const ok = await handleMutation( + button, + () => api("/acciones/probar", { method: "POST", body: { patternId: pattern.id } }), + `Prueba del patrón ${pattern.legacyCode} iniciada.`, + "Probando…" + ); + if (ok) window.setTimeout(() => void pollStatus(false), 250); + } + + async function handleStopAction() { + const ok = await handleMutation( + refs.stopButton, + () => api("/acciones/detener", { method: "POST" }), + "Secuencia detenida.", + "Deteniendo…" + ); + if (ok) window.setTimeout(() => void pollStatus(false), 250); + } + + function maximumDayForMonth(month) { + return [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1] || 31; + } + + function updateHolidayDayLimit() { + const month = Math.trunc(asFiniteNumber(refs.holidayMonth.value) || 1); + const maximum = maximumDayForMonth(month); + refs.holidayDay.max = String(maximum); + if (asFiniteNumber(refs.holidayDay.value) > maximum) refs.holidayDay.value = String(maximum); + } + + async function handleHolidaySubmit(event) { + event.preventDefault(); + setFormError(refs.holidayError); + if (!refs.holidayForm.reportValidity()) return; + const month = Math.trunc(asFiniteNumber(refs.holidayMonth.value) ?? 0); + const day = Math.trunc(asFiniteNumber(refs.holidayDay.value) ?? 0); + if (month < 1 || month > 12) { + setFormError(refs.holidayError, "Selecciona un mes válido."); + return; + } + if (day < 1 || day > maximumDayForMonth(month)) { + setFormError(refs.holidayError, "Selecciona un día válido para el mes."); + return; + } + if (appState.holidays.some((holiday) => holiday.month === month && holiday.day === day)) { + setFormError(refs.holidayError, "Ese feriado ya está registrado."); + return; + } + + const ok = await handleMutation( + refs.holidaySubmit, + () => api("/feriados", { method: "POST", body: { month, day } }), + "Feriado añadido.", + "Añadiendo…" + ); + if (ok) { + refs.holidayDay.value = "1"; + await loadHolidays(); + } + } + + async function deleteHoliday(holiday) { + const ok = await handleMutation( + null, + () => api("/feriados/eliminar", { + method: "POST", + body: { month: holiday.month, day: holiday.day } + }), + "Feriado eliminado.", + "Eliminando…" + ); + if (ok) await loadHolidays(); + } + + async function handleSyncTime() { + const ok = await handleMutation( + refs.syncTimeButton, + () => api("/hora", { method: "POST", body: { epoch: Math.floor(Date.now() / 1000) } }), + "Hora del dispositivo sincronizada.", + "Sincronizando…" + ); + if (ok) { + appState.timeSyncAttempted = true; + await pollStatus(false); + } + } + + function buildStaticControls() { + for (let month = 1; month <= 12; month += 1) { + const option = createElement("option", "", `${month} · ${MONTH_NAMES[month - 1]}`); + option.value = String(month); + refs.holidayMonth.append(option); + } + refs.holidayMonth.value = "1"; + + for (const day of DAY_NAMES) { + const label = createElement("label", "day-option"); + const input = document.createElement("input"); + input.type = "checkbox"; + input.name = "scheduleDay"; + input.value = String(day.index); + const text = createElement("span", "", day.short); + text.title = day.name; + label.append(input, text); + refs.scheduleDays.append(label); + } + + for (let index = 0; index < 6; index += 1) { + const field = createElement("div", "phase-field"); + const label = createElement("label", "", PHASE_NAMES[index]); + label.htmlFor = `pattern-phase-${index}`; + const input = document.createElement("input"); + input.type = "number"; + input.id = `pattern-phase-${index}`; + input.name = `phaseSeconds[${index}]`; + input.min = "0"; + input.max = String(MAX_PHASE_SECONDS); + input.step = "1"; + input.value = "0"; + input.required = true; + input.inputMode = "numeric"; + field.append(label, input); + refs.patternPhases.append(field); + } + } + + function bindEvents() { + refs.bootstrapRetry.addEventListener("click", () => void bootstrap()); + refs.claimForm.addEventListener("submit", (event) => void handleClaimSubmit(event)); + refs.loginForm.addEventListener("submit", (event) => void handleLoginSubmit(event)); + refs.logoutButton.addEventListener("click", () => void handleLogout()); + refs.refreshButton.addEventListener("click", () => void refreshAll()); + refs.syncTimeButton.addEventListener("click", () => void handleSyncTime()); + refs.stopButton.addEventListener("click", () => openConfirm( + "Detener secuencia", + "La salida GPIO22 se pondrá en reposo de inmediato.", + "Detener ahora", + () => handleStopAction(), + true + )); + refs.addProfileButton.addEventListener("click", () => openProfileDialog()); + refs.activeProfileForm.addEventListener("submit", (event) => void handleActiveProfileSubmit(event)); + refs.activeProfileSelect.addEventListener("change", updateActiveProfileSubmit); + refs.addScheduleButton.addEventListener("click", () => openScheduleDialog()); + refs.addPatternButton.addEventListener("click", () => openPatternDialog()); + refs.profileForm.addEventListener("submit", (event) => void handleProfileSubmit(event)); + refs.scheduleForm.addEventListener("submit", (event) => void handleScheduleSubmit(event)); + refs.patternForm.addEventListener("submit", (event) => void handlePatternSubmit(event)); + refs.holidayForm.addEventListener("submit", (event) => void handleHolidaySubmit(event)); + refs.holidayMonth.addEventListener("change", updateHolidayDayLimit); + + for (const closeButton of $$("[data-close-dialog]")) { + closeButton.addEventListener("click", () => closeDialog(closeButton.closest("dialog"))); + } + refs.confirmDialog.addEventListener("close", () => { confirmHandler = null; }); + refs.confirmActionButton.addEventListener("click", () => { + const handler = confirmHandler; + confirmHandler = null; + closeDialog(refs.confirmDialog); + if (handler) void handler(); + }); + } + + buildStaticControls(); + bindEvents(); + void bootstrap(); +})(); diff --git a/ui/index.html b/ui/index.html new file mode 100755 index 0000000..8c1a0d4 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,397 @@ + + + + + + + + + TimbreESP · Control local + + + + + + +
+
+
+
+ + TimbreESP +
+
+ +
+

Preparando el equipo

+

Comprobando el estado de la configuración.

+
+
+ +
+
+ + + + + + +
+ + +
+
+
+

Perfil

+

Añadir perfil

+
+ +
+ +
+ + +
+ +
+ + +
+
+
+ + +
+
+
+

Programación

+

Añadir horario

+
+ +
+ +
+
+ + +
+
+ + +
+
+
+ + +
+
+ Días de la semana +

Se envía como bitmask: domingo es el bit 0 y sábado el bit 6.

+
+
+ +
+ + +
+
+
+ + +
+
+
+

Ciclo eléctrico

+

Añadir patrón

+
+ +
+ +
+
+ + +

Código heredado que identifica el patrón.

+
+
+ Destino +
GPIO22 · principal
+

GPIO23 está reservado.

+
+
+
+ Fases del ciclo +
+
+ + +
+ + +
+
+
+ + +
+
+
+

Confirmar acción

+

Confirmar

+
+ +
+

+
+ + +
+
+
+ +
+ + + diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100755 index 0000000..ec33822 --- /dev/null +++ b/ui/package-lock.json @@ -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" + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100755 index 0000000..504a7fd --- /dev/null +++ b/ui/package.json @@ -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" + } +} diff --git a/ui/styles.css b/ui/styles.css new file mode 100755 index 0000000..aa37854 --- /dev/null +++ b/ui/styles.css @@ -0,0 +1,1595 @@ +:root { + color-scheme: light; + --color-bg: #f3f6f8; + --color-surface: #ffffff; + --color-surface-muted: #eef3f6; + --color-surface-strong: #dce6ec; + --color-text: #152b3a; + --color-text-muted: #526776; + --color-border: #c9d5dd; + --color-border-strong: #9aadb9; + --color-primary: #155d7a; + --color-primary-hover: #104c65; + --color-primary-soft: #dceef4; + --color-success: #176b49; + --color-success-soft: #dff2e8; + --color-warning: #825400; + --color-warning-soft: #fff1c9; + --color-danger: #a32935; + --color-danger-hover: #861f2b; + --color-danger-soft: #fae5e7; + --color-focus: #0b6e9e; + --shadow-small: 0 1px 2px rgb(20 43 58 / 8%); + --shadow-dialog: 0 20px 50px rgb(20 43 58 / 24%); + --radius-small: 8px; + --radius-medium: 12px; + --radius-large: 18px; + --content-width: 1180px; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + min-width: 320px; + background: var(--color-bg); + scroll-behavior: smooth; +} + +body { + min-width: 320px; + min-height: 100vh; + margin: 0; + background: var(--color-bg); + color: var(--color-text); + font-size: 16px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +button, +input, +select { + font: inherit; +} + +button, +select { + cursor: pointer; +} + +button:disabled, +select:disabled, +input:disabled { + cursor: not-allowed; + opacity: 0.58; +} + +[hidden] { + display: none !important; +} + +h1, +h2, +h3, +p { + margin-top: 0; +} + +h1, +h2, +h3 { + color: var(--color-text); + line-height: 1.2; + text-wrap: balance; +} + +h1 { + margin-bottom: 0.5rem; + font-size: clamp(1.8rem, 4vw, 2.55rem); + letter-spacing: -0.035em; +} + +h2 { + margin-bottom: 0.45rem; + font-size: clamp(1.25rem, 2.5vw, 1.55rem); + letter-spacing: -0.02em; +} + +h3 { + margin-bottom: 0.35rem; + font-size: 1.05rem; +} + +p:last-child { + margin-bottom: 0; +} + +small { + color: var(--color-text-muted); +} + +.skip-link { + position: fixed; + z-index: 1000; + top: 0.75rem; + left: 0.75rem; + padding: 0.65rem 0.9rem; + border-radius: var(--radius-small); + background: var(--color-text); + color: #fff; + font-weight: 700; + transform: translateY(-160%); +} + +.skip-link:focus { + transform: translateY(0); +} + +:focus-visible { + outline: 3px solid var(--color-focus); + outline-offset: 2px; +} + +.visually-hidden { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} + +/* Authentication */ +.auth-screen { + display: grid; + min-height: 100vh; + place-items: center; + padding: 1.25rem; + background: #e8eff3; +} + +.auth-card { + width: min(100%, 620px); + padding: clamp(1.4rem, 4vw, 2.5rem); + border: 1px solid var(--color-border); + border-radius: var(--radius-large); + background: var(--color-surface); + box-shadow: 0 16px 40px rgb(20 43 58 / 10%); +} + +.auth-card--compact { + width: min(100%, 470px); +} + +.brand { + display: flex; + align-items: center; + gap: 0.7rem; + font-size: 1.12rem; + font-weight: 800; + letter-spacing: -0.02em; +} + +.brand > span:last-child { + display: flex; + flex-direction: column; + line-height: 1.15; +} + +.brand small { + margin-top: 0.2rem; + font-size: 0.72rem; + font-weight: 650; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.brand--centered { + justify-content: center; + margin-bottom: 2rem; +} + +.brand-mark { + display: grid; + width: 2.65rem; + height: 2.65rem; + flex: 0 0 auto; + place-items: center; + border-radius: 12px; + background: var(--color-primary); + color: #fff; + font-size: 0.88rem; + font-weight: 800; + letter-spacing: 0.04em; +} + +.auth-heading { + margin-bottom: 1.6rem; + text-align: center; +} + +.auth-heading p:not(.eyebrow) { + color: var(--color-text-muted); +} + +.eyebrow { + margin-bottom: 0.4rem; + color: var(--color-primary); + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.11em; + text-transform: uppercase; +} + +.form-stack { + display: flex; + flex-direction: column; + gap: 1.15rem; +} + +.form-grid { + display: grid; + gap: 1rem; +} + +.form-grid--two { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.form-field { + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.42rem; +} + +.form-field label, +.field-label, +.days-fieldset legend, +.phases-fieldset legend { + color: var(--color-text); + font-size: 0.87rem; + font-weight: 750; +} + +.field-label { + display: block; +} + +input, +select, +.readonly-value { + width: 100%; + min-height: 44px; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-small); + background: var(--color-surface); + color: var(--color-text); + padding: 0.66rem 0.75rem; +} + +input:hover, +select:hover { + border-color: #738b99; +} + +input:focus, +select:focus { + border-color: var(--color-focus); +} + +input::placeholder { + color: #6e828f; + opacity: 1; +} + +.readonly-value { + display: flex; + align-items: center; + background: var(--color-surface-muted); + font-weight: 700; +} + +.field-help { + margin: 0; + color: var(--color-text-muted); + font-size: 0.78rem; +} + +.form-error, +.status-message, +.notice p { + overflow-wrap: anywhere; +} + +.form-error { + margin: 0; + border-left: 3px solid var(--color-danger); + border-radius: 4px; + background: var(--color-danger-soft); + color: #751b25; + padding: 0.7rem 0.8rem; + font-size: 0.87rem; + font-weight: 650; +} + +.privacy-note { + margin-top: 1.25rem; + color: var(--color-text-muted); + font-size: 0.78rem; + text-align: center; +} + +.state-block { + display: flex; + align-items: flex-start; + gap: 0.9rem; +} + +.state-block h1 { + font-size: 1.25rem; +} + +.state-block p { + margin: 0; + color: var(--color-text-muted); +} + +.spinner { + width: 1.35rem; + height: 1.35rem; + flex: 0 0 auto; + margin-top: 0.18rem; + border: 3px solid var(--color-surface-strong); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: spin 0.9s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Shared controls */ +.button, +.icon-button { + display: inline-flex; + min-height: 42px; + align-items: center; + justify-content: center; + border: 1px solid transparent; + border-radius: var(--radius-small); + font-weight: 750; + line-height: 1.2; + text-decoration: none; + transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease; +} + +.button { + gap: 0.45rem; + padding: 0.62rem 0.9rem; + font-size: 0.88rem; +} + +.button--wide { + width: 100%; +} + +.button--primary { + border-color: var(--color-primary); + background: var(--color-primary); + color: #fff; +} + +.button--primary:hover:not(:disabled) { + border-color: var(--color-primary-hover); + background: var(--color-primary-hover); +} + +.button--secondary { + border-color: var(--color-border-strong); + background: var(--color-surface); + color: var(--color-text); +} + +.button--secondary:hover:not(:disabled) { + border-color: var(--color-primary); + background: var(--color-primary-soft); + color: var(--color-primary-hover); +} + +.button--quiet { + border-color: transparent; + background: transparent; + color: var(--color-primary); +} + +.button--quiet:hover:not(:disabled) { + background: var(--color-primary-soft); +} + +.button--danger { + border-color: var(--color-danger); + background: var(--color-danger); + color: #fff; +} + +.button--danger:hover:not(:disabled) { + border-color: var(--color-danger-hover); + background: var(--color-danger-hover); +} + +.button--small { + min-height: 36px; + padding: 0.45rem 0.65rem; + font-size: 0.8rem; +} + +.icon-button { + width: 42px; + flex: 0 0 auto; + border-color: transparent; + background: transparent; + color: var(--color-text-muted); + font-size: 1.5rem; +} + +.icon-button:hover { + background: var(--color-surface-muted); + color: var(--color-text); +} + +.button.is-busy, +.icon-button.is-busy { + cursor: wait; +} + +/* Application frame */ +.topbar { + position: sticky; + z-index: 20; + top: 0; + border-bottom: 1px solid var(--color-border); + background: rgb(255 255 255 / 96%); + box-shadow: var(--shadow-small); +} + +.topbar__inner { + display: flex; + width: min(100% - 2rem, var(--content-width)); + min-height: 72px; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin: 0 auto; +} + +.topbar__actions { + display: flex; + align-items: center; + gap: 0.55rem; +} + +.page-shell { + width: min(100% - 2rem, var(--content-width)); + margin: 0 auto; + padding: 2.2rem 0 3rem; +} + +.page-shell:focus { + outline: none; +} + +.page-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 1.25rem; + margin-bottom: 1.5rem; +} + +.page-heading p:not(.eyebrow) { + max-width: 650px; + margin-bottom: 0; + color: var(--color-text-muted); +} + +.page-footer { + width: min(100% - 2rem, var(--content-width)); + margin: 0 auto; + border-top: 1px solid var(--color-border); + color: var(--color-text-muted); + padding: 1.2rem 0 2rem; + font-size: 0.8rem; + text-align: center; +} + +.connection-badge { + display: inline-flex; + min-height: 32px; + align-items: center; + gap: 0.45rem; + border: 1px solid var(--color-border); + border-radius: 999px; + background: var(--color-surface-muted); + color: var(--color-text-muted); + padding: 0.28rem 0.65rem; + font-size: 0.78rem; + font-weight: 750; +} + +.status-indicator { + width: 0.55rem; + height: 0.55rem; + flex: 0 0 auto; + border-radius: 50%; + background: #70828d; +} + +.connection-badge--online { + border-color: #9ac9b3; + background: var(--color-success-soft); + color: #0e5539; +} + +.connection-badge--online .status-indicator { + background: var(--color-success); +} + +.connection-badge--error { + border-color: #dda5aa; + background: var(--color-danger-soft); + color: #751b25; +} + +.connection-badge--error .status-indicator { + background: var(--color-danger); +} + +/* Cards and overview */ +.overview-grid { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(240px, 0.9fr) minmax(220px, 0.7fr); + gap: 1rem; + margin-bottom: 1rem; +} + +.card { + min-width: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-large); + background: var(--color-surface); + box-shadow: var(--shadow-small); +} + +.overview-grid > .card { + padding: 1.25rem; +} + +.card-heading, +.section-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.card-heading h2, +.section-heading h2 { + margin-bottom: 0; +} + +.state-pill, +.count-badge { + display: inline-flex; + min-height: 28px; + flex: 0 0 auto; + align-items: center; + border: 1px solid var(--color-border); + border-radius: 999px; + background: var(--color-surface-muted); + color: var(--color-text-muted); + padding: 0.22rem 0.6rem; + font-size: 0.74rem; + font-weight: 800; +} + +.state-pill--active, +.state-pill--valid { + border-color: #9ac9b3; + background: var(--color-success-soft); + color: #0e5539; +} + +.state-pill--invalid, +.state-pill--danger { + border-color: #dda5aa; + background: var(--color-danger-soft); + color: #751b25; +} + +.state-pill--warning { + border-color: #ddbd68; + background: var(--color-warning-soft); + color: #684300; +} + +.clock-readout { + margin: 1.25rem 0 0.9rem; +} + +.clock-readout time { + display: block; + font-family: ui-monospace, "SFMono-Regular", Consolas, monospace; + font-size: clamp(2.2rem, 7vw, 3.4rem); + font-weight: 720; + letter-spacing: -0.06em; + line-height: 1; +} + +.clock-readout p { + margin: 0.55rem 0 0; + color: var(--color-text-muted); + font-size: 0.88rem; +} + +.status-message { + margin: 1rem 0 0; + color: var(--color-text-muted); + font-size: 0.82rem; +} + +.status-message--error { + color: var(--color-danger); + font-weight: 700; +} + +.status-message--success { + color: var(--color-success); + font-weight: 700; +} + +.status-message--muted { + color: var(--color-text-muted); +} + +.card-footer { + margin-top: 1.15rem; + border-top: 1px solid var(--color-border); + padding-top: 1rem; +} + +.card-footer--actions { + display: flex; + justify-content: flex-start; +} + +.detail-list { + display: grid; + gap: 0.75rem; + margin: 1.15rem 0 0; +} + +.detail-list > div { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; +} + +.detail-list dt { + color: var(--color-text-muted); + font-size: 0.82rem; +} + +.detail-list dd { + margin: 0; + font-weight: 800; + text-align: right; +} + +.hardware-list { + display: grid; + gap: 0.8rem; + margin: 1.1rem 0 0; + padding: 0; + list-style: none; +} + +.hardware-list li { + display: flex; + align-items: center; + gap: 0.7rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-medium); + padding: 0.7rem; +} + +.hardware-list li > span:last-child { + display: flex; + min-width: 0; + flex-direction: column; +} + +.hardware-list small { + font-size: 0.75rem; +} + +.hardware-icon { + display: grid; + width: 2.35rem; + height: 2.35rem; + flex: 0 0 auto; + place-items: center; + border-radius: var(--radius-small); + background: var(--color-surface-strong); + color: var(--color-text-muted); + font-family: ui-monospace, "SFMono-Regular", Consolas, monospace; + font-size: 0.8rem; + font-weight: 800; +} + +.hardware-icon--primary { + background: var(--color-primary-soft); + color: var(--color-primary-hover); +} + +.notice { + margin-bottom: 1rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-medium); + padding: 0.85rem 1rem; + font-size: 0.88rem; +} + +.notice--error { + border-color: #dda5aa; + background: var(--color-danger-soft); + color: #751b25; +} + +.notice--warning { + border-color: #ddbd68; + background: var(--color-warning-soft); + color: #684300; +} + +.notice .button { + margin-top: 0.7rem; +} + +/* Resource sections */ +.section-card { + margin-top: 1rem; + overflow: hidden; +} + +.section-card > .section-heading, +.section-card > .active-profile-panel, +.section-card > .schedule-toolbar, +.section-card > .hardware-note, +.section-card > .holiday-form, +.section-card > .resource-content, +.section-card > .form-error { + margin-right: 1.35rem; + margin-left: 1.35rem; +} + +.section-card > .section-heading { + padding: 1.35rem 0 1rem; +} + +.section-heading p:not(.eyebrow) { + max-width: 680px; + margin: 0.35rem 0 0; + color: var(--color-text-muted); + font-size: 0.88rem; +} + +.active-profile-panel { + margin-bottom: 1.2rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-medium); + background: var(--color-surface-muted); + padding: 1rem; +} + +.active-profile-form { + display: grid; + grid-template-columns: minmax(200px, 1fr) auto; + align-items: end; + gap: 0.8rem; +} + +.schedule-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.8rem; + margin-bottom: 1rem; +} + +.segmented-control { + display: flex; + max-width: 100%; + gap: 0.35rem; + overflow-x: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius-medium); + background: var(--color-surface-muted); + padding: 0.28rem; + scrollbar-width: thin; +} + +.segmented-control button { + min-height: 38px; + flex: 0 0 auto; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--color-text-muted); + padding: 0.45rem 0.75rem; + font-size: 0.82rem; + font-weight: 750; +} + +.segmented-control button:hover:not(:disabled) { + color: var(--color-primary); +} + +.segmented-control button[aria-selected="true"] { + background: var(--color-surface); + color: var(--color-primary-hover); + box-shadow: var(--shadow-small); +} + +.hardware-note { + margin-bottom: 1rem; + border-left: 3px solid var(--color-primary); + border-radius: 4px; + background: var(--color-primary-soft); + color: #204b5c; + padding: 0.7rem 0.8rem; + font-size: 0.84rem; +} + +.holiday-form { + display: grid; + grid-template-columns: minmax(140px, 1fr) minmax(110px, 0.5fr) auto; + align-items: end; + gap: 0.8rem; + margin-bottom: 1rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-medium); + background: var(--color-surface-muted); + padding: 1rem; +} + +.section-card > .form-error { + margin-bottom: 1rem; +} + +.resource-content { + min-height: 112px; + padding-bottom: 1.35rem; +} + +.resource-state { + display: grid; + min-height: 112px; + place-items: center; + border: 1px dashed var(--color-border-strong); + border-radius: var(--radius-medium); + color: var(--color-text-muted); + padding: 1.1rem; + text-align: center; +} + +.resource-state h3 { + margin-bottom: 0.2rem; +} + +.resource-state p { + max-width: 520px; + font-size: 0.85rem; +} + +.resource-state .button { + margin-top: 0.7rem; +} + +.resource-state--error { + border-color: #dda5aa; + background: #fff8f8; + color: #751b25; +} + +.profile-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.8rem; +} + +.profile-card { + display: flex; + min-width: 0; + flex-direction: column; + justify-content: space-between; + gap: 1rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-medium); + background: var(--color-surface); + padding: 1rem; +} + +.profile-card--active { + border-color: var(--color-primary); + background: #f7fbfd; +} + +.profile-card__title { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.7rem; +} + +.profile-card__title h3 { + overflow-wrap: anywhere; +} + +.profile-card__actions { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.table-wrap { + max-width: 100%; + overflow-x: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius-medium); +} + +.data-table { + width: 100%; + min-width: 650px; + border-collapse: collapse; + font-size: 0.86rem; +} + +.data-table th, +.data-table td { + border-bottom: 1px solid var(--color-border); + padding: 0.8rem 0.9rem; + text-align: left; + vertical-align: middle; +} + +.data-table th { + background: var(--color-surface-muted); + color: var(--color-text-muted); + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.055em; + text-transform: uppercase; +} + +.data-table tbody tr:last-child td { + border-bottom: 0; +} + +.data-table tbody tr:hover { + background: #f8fafb; +} + +.cell-title { + display: block; + font-weight: 800; +} + +.cell-subtitle { + display: block; + margin-top: 0.15rem; + color: var(--color-text-muted); + font-size: 0.76rem; +} + +.cell-actions { + display: flex; + justify-content: flex-end; + gap: 0.35rem; +} + +.day-list { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} + +.day-badge { + display: inline-grid; + width: 27px; + height: 27px; + place-items: center; + border: 1px solid var(--color-border); + border-radius: 7px; + background: var(--color-surface-muted); + color: var(--color-text-muted); + font-size: 0.7rem; + font-weight: 850; +} + +.phase-bars { + display: flex; + min-width: 170px; + gap: 3px; +} + +.phase-bar { + width: 23px; + border-radius: 3px; + background: var(--color-primary); +} + +.phase-bar--off { + background: var(--color-border-strong); +} + +.pattern-card-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.8rem; +} + +.pattern-card { + display: flex; + min-width: 0; + flex-direction: column; + gap: 1rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-medium); + padding: 1rem; +} + +.pattern-card__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.8rem; +} + +.pattern-card__code { + display: inline-grid; + min-width: 38px; + height: 38px; + place-items: center; + border-radius: var(--radius-small); + background: var(--color-primary-soft); + color: var(--color-primary-hover); + font-family: ui-monospace, "SFMono-Regular", Consolas, monospace; + font-weight: 850; +} + +.pattern-card__title { + display: flex; + min-width: 0; + align-items: center; + gap: 0.7rem; +} + +.pattern-card__title h3 { + overflow-wrap: anywhere; +} + +.pattern-phases { + display: grid; + grid-template-columns: repeat(6, minmax(44px, 1fr)); + gap: 0.3rem; +} + +.pattern-phase { + min-width: 0; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-surface-muted); + padding: 0.4rem 0.2rem; + text-align: center; +} + +.pattern-phase strong, +.pattern-phase span { + display: block; +} + +.pattern-phase strong { + font-size: 0.78rem; +} + +.pattern-phase span { + margin-top: 0.1rem; + color: var(--color-text-muted); + font-size: 0.65rem; +} + +.pattern-card__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.7rem; + border-top: 1px solid var(--color-border); + padding-top: 0.8rem; +} + +.pattern-card__footer p { + margin: 0; + color: var(--color-text-muted); + font-size: 0.78rem; +} + +.pattern-card__actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.4rem; +} + +.holiday-list { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.65rem; +} + +.holiday-item { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 0.7rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-medium); + background: var(--color-surface); + padding: 0.75rem; +} + +.holiday-date { + display: flex; + min-width: 0; + align-items: baseline; + gap: 0.55rem; +} + +.holiday-date strong { + font-size: 1.25rem; +} + +.holiday-date span { + color: var(--color-text-muted); + font-size: 0.82rem; +} + +/* Dialogs */ +.dialog { + width: min(calc(100% - 2rem), 520px); + max-height: min(90vh, 850px); + margin: auto; + overflow: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius-large); + background: var(--color-surface); + color: var(--color-text); + padding: 0; + box-shadow: var(--shadow-dialog); +} + +.dialog--wide { + width: min(calc(100% - 2rem), 720px); +} + +.dialog--confirm { + width: min(calc(100% - 2rem), 470px); +} + +.dialog::backdrop { + background: rgb(11 31 43 / 62%); +} + +.dialog__body { + margin: 0; + padding: 1.35rem; +} + +.dialog__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.3rem; +} + +.dialog__header h2 { + margin-bottom: 0; +} + +.dialog__actions { + display: flex; + justify-content: flex-end; + gap: 0.55rem; + margin-top: 1.35rem; + border-top: 1px solid var(--color-border); + padding-top: 1rem; +} + +.days-fieldset, +.phases-fieldset { + min-width: 0; + margin: 1.2rem 0 0; + border: 0; + padding: 0; +} + +.days-fieldset legend, +.phases-fieldset legend { + margin-bottom: 0.25rem; +} + +.day-picker { + display: grid; + grid-template-columns: repeat(7, minmax(40px, 1fr)); + gap: 0.45rem; + margin-top: 0.7rem; +} + +.day-option input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; +} + +.day-option span { + display: grid; + min-height: 58px; + place-items: center; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-small); + background: var(--color-surface); + color: var(--color-text-muted); + font-size: 0.82rem; + font-weight: 850; + cursor: pointer; +} + +.day-option input:focus-visible + span { + outline: 3px solid var(--color-focus); + outline-offset: 2px; +} + +.day-option input:checked + span { + border-color: var(--color-primary); + background: var(--color-primary); + color: #fff; +} + +.phase-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.7rem; + margin-top: 0.8rem; +} + +.phase-field { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.phase-field label { + color: var(--color-text-muted); + font-size: 0.74rem; + font-weight: 750; +} + +.phase-field input { + min-width: 0; +} + +.checkbox-field { + display: inline-flex; + align-items: center; + gap: 0.55rem; + cursor: pointer; +} + +.checkbox-field input { + width: 1.15rem; + height: 1.15rem; + min-height: auto; + accent-color: var(--color-primary); +} + +.checkbox-field--standalone { + margin-top: 1.2rem; + font-weight: 700; +} + +.confirm-message { + color: var(--color-text-muted); +} + +/* Notifications */ +.toast-region { + position: fixed; + z-index: 100; + right: 1rem; + bottom: 1rem; + display: grid; + width: min(calc(100% - 2rem), 380px); + gap: 0.55rem; + pointer-events: none; +} + +.toast { + display: flex; + align-items: flex-start; + gap: 0.65rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-medium); + background: var(--color-surface); + color: var(--color-text); + padding: 0.8rem 0.9rem; + box-shadow: 0 10px 28px rgb(20 43 58 / 18%); + font-size: 0.86rem; + font-weight: 650; + pointer-events: auto; +} + +.toast::before { + width: 0.45rem; + height: 0.45rem; + flex: 0 0 auto; + margin-top: 0.36rem; + border-radius: 50%; + background: var(--color-primary); + content: ""; +} + +.toast--success::before { + background: var(--color-success); +} + +.toast--error { + border-color: #dda5aa; + background: #fff8f8; + color: #751b25; +} + +.toast--error::before { + background: var(--color-danger); +} + +.noscript-notice { + position: fixed; + z-index: 200; + right: 1rem; + bottom: 1rem; + left: 1rem; + border: 2px solid var(--color-danger); + border-radius: var(--radius-small); + background: #fff; + color: #751b25; + padding: 1rem; + font-weight: 750; + text-align: center; +} + +@media (max-width: 960px) { + .overview-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .clock-card { + grid-column: 1 / -1; + } + + .profile-grid, + .holiday-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 720px) { + .topbar__inner, + .page-shell, + .page-footer { + width: min(100% - 1.25rem, var(--content-width)); + } + + .topbar__inner { + min-height: 64px; + } + + .topbar .brand small, + .topbar .brand-mark { + display: none; + } + + .connection-badge { + min-height: 30px; + } + + .topbar__actions { + gap: 0.25rem; + } + + .topbar__actions .button { + min-height: 38px; + padding: 0.5rem 0.55rem; + font-size: 0.8rem; + } + + .page-shell { + padding-top: 1.35rem; + } + + .page-heading, + .section-heading, + .schedule-toolbar { + align-items: stretch; + flex-direction: column; + } + + .page-heading .button, + .section-heading .button { + width: 100%; + } + + .overview-grid, + .profile-grid, + .pattern-card-grid, + .holiday-list, + .form-grid--two { + grid-template-columns: 1fr; + } + + .clock-card { + grid-column: auto; + } + + .active-profile-form { + grid-template-columns: 1fr; + } + + .active-profile-form .button { + width: 100%; + } + + .section-card > .section-heading, + .section-card > .active-profile-panel, + .section-card > .schedule-toolbar, + .section-card > .hardware-note, + .section-card > .holiday-form, + .section-card > .resource-content, + .section-card > .form-error { + margin-right: 1rem; + margin-left: 1rem; + } + + .holiday-form { + grid-template-columns: 1fr 1fr; + } + + .holiday-form .button { + grid-column: 1 / -1; + } + + .segmented-control { + width: 100%; + } + + .schedule-toolbar .count-badge { + align-self: flex-start; + } + + .day-picker { + grid-template-columns: repeat(4, minmax(42px, 1fr)); + } + + .phase-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .dialog__body { + padding: 1.1rem; + } + + .dialog__actions { + flex-direction: column-reverse; + } + + .dialog__actions .button { + width: 100%; + } +} + +@media (max-width: 480px) { + .auth-screen { + align-items: start; + padding: 0; + } + + .auth-card { + min-height: 100vh; + border: 0; + border-radius: 0; + padding: 1.25rem; + } + + .brand--centered { + margin-bottom: 1.5rem; + } + + .topbar__actions .connection-badge { + max-width: 105px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .overview-grid > .card { + padding: 1rem; + } + + .holiday-form { + grid-template-columns: 1fr; + } + + .holiday-form .button { + grid-column: auto; + } + + .day-picker { + grid-template-columns: repeat(3, minmax(42px, 1fr)); + } + + .pattern-phases { + grid-template-columns: repeat(3, minmax(40px, 1fr)); + } + + .pattern-card__footer { + align-items: stretch; + flex-direction: column; + } + + .pattern-card__actions { + justify-content: stretch; + } + + .pattern-card__actions .button { + flex: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +}