Fist commit

This commit is contained in:
Angel Ivan
2026-10-02 12:08:21 -06:00
commit 2a9eabce14
54 changed files with 10737 additions and 0 deletions

46
lib/README Executable file
View File

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

198
lib/timbre_core/CivilTime.cpp Executable file
View File

@@ -0,0 +1,198 @@
#include "CivilTime.h"
#include <limits>
namespace timbre_core {
namespace {
constexpr std::int64_t kSecondsPerDay = 86400;
std::int64_t floorDiv(std::int64_t value, std::int64_t divisor) noexcept {
const std::int64_t quotient = value / divisor;
const std::int64_t remainder = value % divisor;
return remainder != 0 && ((remainder < 0) != (divisor < 0)) ? quotient - 1 : quotient;
}
} // namespace
bool CivilTime::isLeapYear(std::int32_t year) noexcept {
return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
}
std::uint8_t CivilTime::daysInMonth(std::int32_t year, std::uint8_t month) noexcept {
static constexpr std::uint8_t days[] = {
0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31
};
if (month < 1 || month > 12) {
return 0;
}
if (month == 2 && isLeapYear(year)) {
return 29;
}
return days[month];
}
bool CivilTime::isValidDate(std::int32_t year, std::uint8_t month, std::uint8_t day) noexcept {
// The conversion routines are useful well beyond the current product
// lifetime, but keep the public civil value inside int32_t.
if (year < std::numeric_limits<std::int32_t>::min() ||
year > std::numeric_limits<std::int32_t>::max()) {
return false;
}
return day >= 1 && day <= daysInMonth(year, month);
}
std::uint8_t CivilTime::weekdayForDate(std::int32_t year, std::uint8_t month,
std::uint8_t day) noexcept {
if (!isValidDate(year, month, day)) {
return 0;
}
// 1970-01-01 was Thursday (4 with Sunday == 0).
const std::int64_t days = daysFromCivil(year, month, day);
std::int64_t weekday = (days + 4) % 7;
if (weekday < 0) {
weekday += 7;
}
return static_cast<std::uint8_t>(weekday);
}
std::int64_t CivilTime::daysFromCivil(std::int32_t year, std::uint8_t month,
std::uint8_t day) noexcept {
// Howard Hinnant's civil calendar transform. It is valid for the full
// practical range of the public model and does not depend on time_t.
std::int64_t y = year;
y -= month <= 2 ? 1 : 0;
const std::int64_t era = (y >= 0 ? y : y - 399) / 400;
const std::int64_t yearOfEra = y - era * 400;
const std::int64_t monthPrime = month + (month > 2 ? -3 : 9);
const std::int64_t dayOfYear = (153 * monthPrime + 2) / 5 + day - 1;
const std::int64_t dayOfEra = yearOfEra * 365 + yearOfEra / 4 - yearOfEra / 100 + dayOfYear;
return era * 146097 + dayOfEra - 719468;
}
bool CivilTime::civilFromDays(std::int64_t days, std::int32_t& year,
std::uint8_t& month, std::uint8_t& day) noexcept {
// The inverse transform is kept separate so weekday/date validation and
// the scheduler can use exactly the same arithmetic.
days += 719468;
const std::int64_t era = (days >= 0 ? days : days - 146096) / 146097;
const std::int64_t dayOfEra = days - era * 146097;
const std::int64_t yearOfEra =
(dayOfEra - dayOfEra / 1460 + dayOfEra / 36524 - dayOfEra / 146096) / 365;
std::int64_t y = yearOfEra + era * 400;
const std::int64_t dayOfYear =
dayOfEra - (365 * yearOfEra + yearOfEra / 4 - yearOfEra / 100);
const std::int64_t monthPrime =
(5 * dayOfYear + 2) / 153;
const std::int64_t d = dayOfYear - (153 * monthPrime + 2) / 5 + 1;
const std::int64_t m = monthPrime + (monthPrime < 10 ? 3 : -9);
y += m <= 2;
if (y < std::numeric_limits<std::int32_t>::min() ||
y > std::numeric_limits<std::int32_t>::max()) {
return false;
}
year = static_cast<std::int32_t>(y);
month = static_cast<std::uint8_t>(m);
day = static_cast<std::uint8_t>(d);
return true;
}
CivilDateTime CivilTime::fromUnixSeconds(std::int64_t utcSeconds) noexcept {
CivilDateTime result;
(void)fromUnixSeconds(utcSeconds, result);
return result;
}
bool CivilTime::fromUnixSeconds(std::int64_t utcSeconds, CivilDateTime& result) noexcept {
// Use floor seconds so epochs before 1970 are not biased toward 1969.
const std::int64_t localSeconds = utcSeconds + kUtcOffsetSeconds;
const std::int64_t days = floorDiv(localSeconds, kSecondsPerDay);
std::int64_t secondOfDay = localSeconds - days * kSecondsPerDay;
if (secondOfDay < 0) {
secondOfDay += kSecondsPerDay;
}
std::int32_t year = 0;
std::uint8_t month = 0;
std::uint8_t day = 0;
if (!civilFromDays(days, year, month, day)) {
return false;
}
result.year = year;
result.month = month;
result.day = day;
result.hour = static_cast<std::uint8_t>(secondOfDay / 3600);
result.minute = static_cast<std::uint8_t>((secondOfDay / 60) % 60);
result.second = static_cast<std::uint8_t>(secondOfDay % 60);
result.weekday = weekdayForDate(year, month, day);
return true;
}
CivilDateTime CivilTime::fromUnixMilliseconds(std::int64_t utcMilliseconds) noexcept {
const std::int64_t seconds = floorDiv(utcMilliseconds, 1000);
return fromUnixSeconds(seconds);
}
std::int64_t CivilTime::toUnixSeconds(const CivilDateTime& localTime) noexcept {
std::int64_t result = 0;
(void)toUnixSeconds(localTime, result);
return result;
}
bool CivilTime::toUnixSeconds(const CivilDateTime& localTime, std::int64_t& result) noexcept {
if (!localTime.valid()) {
return false;
}
const std::int64_t localSeconds =
CivilTime::daysFromCivil(localTime.year, localTime.month, localTime.day) * kSecondsPerDay +
static_cast<std::int64_t>(localTime.hour) * 3600 +
static_cast<std::int64_t>(localTime.minute) * 60 +
static_cast<std::int64_t>(localTime.second);
result = localSeconds - kUtcOffsetSeconds;
return true;
}
std::int64_t CivilTime::toUnixMilliseconds(const CivilDateTime& localTime) noexcept {
return toUnixSeconds(localTime) * 1000;
}
bool CivilDateTime::valid() const noexcept {
return CivilTime::isValidDate(year, month, day) && hour < 24 &&
minute < 60 && second < 60;
}
std::uint16_t CivilDateTime::minuteOfDay() const noexcept {
return static_cast<std::uint16_t>(static_cast<std::uint16_t>(hour) * 60u + minute);
}
std::int64_t CivilDateTime::dateKey() const noexcept {
return static_cast<std::int64_t>(year) * 10000LL +
static_cast<std::int64_t>(month) * 100LL + day;
}
std::int64_t CivilDateTime::minuteSerial() const noexcept {
return static_cast<std::int64_t>(CivilTime::daysFromCivil(year, month, day)) * 1440LL +
minuteOfDay();
}
bool CivilDateTime::operator==(const CivilDateTime& other) const noexcept {
return year == other.year && month == other.month && day == other.day &&
hour == other.hour && minute == other.minute && second == other.second;
}
std::int64_t LocalMinuteKey::serial() const noexcept {
const std::int64_t y = date / 10000LL;
const std::int64_t m = (date / 100LL) % 100LL;
const std::int64_t d = date % 100LL;
if (y < std::numeric_limits<std::int32_t>::min() ||
y > std::numeric_limits<std::int32_t>::max() || m < 1 || m > 12 || d < 1 || d > 31) {
return std::numeric_limits<std::int64_t>::min();
}
return CivilTime::daysFromCivil(static_cast<std::int32_t>(y),
static_cast<std::uint8_t>(m),
static_cast<std::uint8_t>(d)) * 1440LL + minuteOfDay;
}
} // namespace timbre_core

95
lib/timbre_core/CivilTime.h Executable file
View File

@@ -0,0 +1,95 @@
#pragma once
#include "Model.h"
#include <cstdint>
namespace timbre_core {
struct CivilDateTime {
std::int32_t year = 1970;
std::uint8_t month = 1; // 1..12
std::uint8_t day = 1; // 1..31
std::uint8_t hour = 0; // 0..23
std::uint8_t minute = 0; // 0..59
std::uint8_t second = 0; // 0..59
std::uint8_t weekday = 0; // 0 = Sunday, 6 = Saturday
bool valid() const noexcept;
std::uint16_t minuteOfDay() const noexcept;
std::int64_t dateKey() const noexcept;
std::int64_t minuteSerial() const noexcept;
bool operator==(const CivilDateTime& other) const noexcept;
bool operator!=(const CivilDateTime& other) const noexcept { return !(*this == other); }
};
using CivilDateTimePoint = CivilDateTime;
struct LocalMinuteKey {
std::int64_t date = 0; // YYYYMMDD
std::uint16_t minuteOfDay = 0;
static LocalMinuteKey from(const CivilDateTime& value) noexcept {
return LocalMinuteKey{value.dateKey(), value.minuteOfDay()};
}
std::int64_t serial() const noexcept;
std::int64_t dateKey() const noexcept { return date; }
std::uint16_t minute() const noexcept { return minuteOfDay; }
bool operator==(const LocalMinuteKey& other) const noexcept {
return date == other.date && minuteOfDay == other.minuteOfDay;
}
bool operator!=(const LocalMinuteKey& other) const noexcept { return !(*this == other); }
bool operator<(const LocalMinuteKey& other) const noexcept { return serial() < other.serial(); }
bool operator>(const LocalMinuteKey& other) const noexcept { return serial() > other.serial(); }
bool operator<=(const LocalMinuteKey& other) const noexcept { return serial() <= other.serial(); }
bool operator>=(const LocalMinuteKey& other) const noexcept { return serial() >= other.serial(); }
};
class CivilTime {
public:
// The product deliberately has one fixed civil offset: UTC-06:00.
static constexpr std::int32_t kUtcOffsetSeconds = -6 * 60 * 60;
static constexpr std::int32_t kUtcOffsetMinutes = -6 * 60;
static constexpr std::int32_t UTC_OFFSET_SECONDS = kUtcOffsetSeconds;
static constexpr std::int32_t UTC_OFFSET_MINUTES = kUtcOffsetMinutes;
static bool isLeapYear(std::int32_t year) noexcept;
static std::uint8_t daysInMonth(std::int32_t year, std::uint8_t month) noexcept;
static bool isValidDate(std::int32_t year, std::uint8_t month, std::uint8_t day) noexcept;
static std::uint8_t weekdayForDate(std::int32_t year, std::uint8_t month,
std::uint8_t day) noexcept;
static CivilDateTime fromUnixSeconds(std::int64_t utcSeconds) noexcept;
static bool fromUnixSeconds(std::int64_t utcSeconds, CivilDateTime& result) noexcept;
static CivilDateTime fromUnix(std::int64_t utcSeconds) noexcept {
return fromUnixSeconds(utcSeconds);
}
static bool fromUnix(std::int64_t utcSeconds, CivilDateTime& result) noexcept {
return fromUnixSeconds(utcSeconds, result);
}
static CivilDateTime fromUtc(std::int64_t utcSeconds) noexcept {
return fromUnixSeconds(utcSeconds);
}
static CivilDateTime utcToLocal(std::int64_t utcSeconds) noexcept {
return fromUnixSeconds(utcSeconds);
}
static CivilDateTime fromUnixMilliseconds(std::int64_t utcMilliseconds) noexcept;
static std::int64_t toUnixSeconds(const CivilDateTime& localTime) noexcept;
static bool toUnixSeconds(const CivilDateTime& localTime, std::int64_t& result) noexcept;
static std::int64_t toUnixMilliseconds(const CivilDateTime& localTime) noexcept;
static LocalMinuteKey minuteKey(const CivilDateTime& localTime) noexcept {
return LocalMinuteKey::from(localTime);
}
// These helpers are public because they are useful to a scheduler and are
// deterministic, allocation-free operations on an ESP32.
static std::int64_t daysFromCivil(std::int32_t year, std::uint8_t month,
std::uint8_t day) noexcept;
static bool civilFromDays(std::int64_t days, std::int32_t& year,
std::uint8_t& month, std::uint8_t& day) noexcept;
};
} // namespace timbre_core

603
lib/timbre_core/Model.h Executable file
View File

@@ -0,0 +1,603 @@
#pragma once
#include <array>
#include <cstddef>
#include <cstdint>
#include <initializer_list>
#include <utility>
namespace timbre_core {
// The data model deliberately uses bounded storage: it is safe for the ESP32
// heap and never needs PSRAM or a dynamic container.
constexpr std::size_t kMaxProfiles = 8;
constexpr std::size_t kMaxPatterns = 3;
constexpr std::size_t kMaxProfileOrPatternSlots = 8;
constexpr std::size_t kMaxSchedules = 100;
constexpr std::size_t kMaxHolidays = 64;
constexpr std::size_t kPatternQueueCapacity = 8;
constexpr std::size_t kPatternCount = kMaxPatterns;
constexpr std::size_t kCanonicalPatternCount = kMaxPatterns;
constexpr std::size_t kMaxConfiguredPatterns = 8;
constexpr std::size_t kProfileLimit = kMaxProfiles;
constexpr std::size_t kMaxPatternPhases = 6;
constexpr std::uint8_t kMaxPhaseDurationSeconds = 99;
constexpr std::uint16_t kMaxPatternTotalSeconds = 600;
// Upper-case aliases make the limits convenient for small configuration code.
constexpr std::size_t MAX_PROFILES = kMaxProfiles;
constexpr std::size_t MAX_PATTERNS = kMaxPatterns;
constexpr std::size_t MAX_PROFILE_PATTERN_SLOTS = kMaxProfileOrPatternSlots;
constexpr std::size_t MAX_SCHEDULES = kMaxSchedules;
constexpr std::size_t MAX_HOLIDAYS = kMaxHolidays;
constexpr std::size_t PATTERN_QUEUE_CAPACITY = kPatternQueueCapacity;
constexpr std::uint8_t MAX_PHASE_DURATION = kMaxPhaseDurationSeconds;
constexpr std::uint16_t MAX_PATTERN_TOTAL = kMaxPatternTotalSeconds;
using Id = std::uint16_t;
using EntityId = Id;
using ProfileId = Id;
using PatternIdValue = Id;
using ScheduleId = Id;
using HolidayId = Id;
using StableId = Id;
constexpr Id kInvalidId = 0xFFFFu;
enum class PatternId : std::uint8_t {
A = 0,
B = 1,
C = 2
};
// RelayId is intentionally separate from PatternId. A/B/C are sound
// patterns; they are never relay identifiers.
enum class RelayId : std::uint8_t {
Main = 0,
Primary = 0,
MainRelay = 0,
Relay1 = 0,
MainGPIO22 = 0,
Reserved = 1,
ReservedRelay = 1,
Relay2 = 1,
ReservedGPIO23 = 1,
Invalid = 0xFF
};
constexpr std::uint8_t kMainRelayGpio = 22;
constexpr std::uint8_t kReservedRelayGpio = 23;
constexpr std::uint8_t kMainRelayGPIO = kMainRelayGpio;
constexpr std::uint8_t kReservedRelayGPIO = kReservedRelayGpio;
constexpr std::uint8_t RELAY_MAIN_GPIO = kMainRelayGpio;
constexpr std::uint8_t RELAY_RESERVED_GPIO = kReservedRelayGpio;
constexpr std::uint8_t kRelayCount = 2;
constexpr RelayId kPrimaryRelay = RelayId::Main;
constexpr RelayId kReservedRelay = RelayId::Reserved;
constexpr std::uint8_t kAllWeekdaysMask = 0x7F;
constexpr std::uint8_t kSundayMask = 1u << 0;
constexpr std::uint8_t kMondayMask = 1u << 1;
constexpr std::uint8_t kTuesdayMask = 1u << 2;
constexpr std::uint8_t kWednesdayMask = 1u << 3;
constexpr std::uint8_t kThursdayMask = 1u << 4;
constexpr std::uint8_t kFridayMask = 1u << 5;
constexpr std::uint8_t kSaturdayMask = 1u << 6;
constexpr bool isPatternId(PatternId id) noexcept {
return static_cast<std::uint8_t>(id) <= static_cast<std::uint8_t>(PatternId::C);
}
constexpr std::size_t patternIndex(PatternId id) noexcept {
return isPatternId(id) ? static_cast<std::size_t>(id) : kMaxPatterns;
}
constexpr bool isRelayId(RelayId id) noexcept {
return id == RelayId::Main || id == RelayId::Reserved;
}
constexpr std::uint8_t gpioForRelay(RelayId id) noexcept {
return id == RelayId::Main ? kMainRelayGpio
: (id == RelayId::Reserved ? kReservedRelayGpio : 0xFFu);
}
constexpr bool isReservedRelay(RelayId id) noexcept {
return id == RelayId::Reserved;
}
constexpr std::size_t relayIndex(RelayId id) noexcept {
return id == RelayId::Main ? 0u
: (id == RelayId::Reserved ? 1u : kRelayCount);
}
constexpr RelayId relayIdFromIndex(std::size_t index) noexcept {
return index == 0 ? RelayId::Main
: (index == 1 ? RelayId::Reserved : RelayId::Invalid);
}
constexpr RelayId relayIdFromGpio(std::uint8_t gpio) noexcept {
return gpio == kMainRelayGpio ? RelayId::Main
: (gpio == kReservedRelayGpio ? RelayId::Reserved : RelayId::Invalid);
}
constexpr bool isKnownRelayGpio(std::uint8_t gpio) noexcept {
return gpio == kMainRelayGpio || gpio == kReservedRelayGpio;
}
constexpr std::uint8_t relayGpio(RelayId id) noexcept { return gpioForRelay(id); }
constexpr PatternId patternIdFromIndex(std::size_t index) noexcept {
return index < kMaxPatterns ? static_cast<PatternId>(index) : PatternId::A;
}
constexpr PatternId patternIdFromChar(char value) noexcept {
return value == 'A' || value == 'a' ? PatternId::A
: (value == 'B' || value == 'b' ? PatternId::B
: (value == 'C' || value == 'c' ? PatternId::C : PatternId::A));
}
constexpr char patternChar(PatternId id) noexcept {
return id == PatternId::A ? 'A'
: (id == PatternId::B ? 'B'
: (id == PatternId::C ? 'C' : '?'));
}
enum class Weekday : std::uint8_t {
Sunday = 0,
Monday = 1,
Tuesday = 2,
Wednesday = 3,
Thursday = 4,
Friday = 5,
Saturday = 6
};
template <std::size_t Capacity>
class FixedText {
public:
FixedText() = default;
explicit FixedText(const char* value) { assign(value); }
FixedText& operator=(const char* value) {
assign(value);
return *this;
}
void assign(const char* value) noexcept {
std::size_t index = 0;
if (value != nullptr) {
while (value[index] != '\0' && index + 1 < Capacity) {
data_[index] = value[index];
++index;
}
}
while (index < Capacity) {
data_[index] = '\0';
++index;
}
length_ = 0;
while (length_ < Capacity && data_[length_] != '\0') {
++length_;
}
}
const char* c_str() const noexcept { return data_.data(); }
std::size_t size() const noexcept { return length_; }
std::size_t length() const noexcept { return length_; }
bool empty() const noexcept { return length_ == 0; }
char operator[](std::size_t index) const noexcept {
return index < length_ ? data_[index] : '\0';
}
bool operator==(const FixedText& other) const noexcept {
if (length_ != other.length_) {
return false;
}
for (std::size_t i = 0; i < length_; ++i) {
if (data_[i] != other.data_[i]) {
return false;
}
}
return true;
}
bool operator!=(const FixedText& other) const noexcept { return !(*this == other); }
bool operator==(const char* other) const noexcept {
if (other == nullptr) {
return length_ == 0;
}
for (std::size_t i = 0; i < length_; ++i) {
if (other[i] == '\0' || other[i] != data_[i]) {
return false;
}
}
return other[length_] == '\0';
}
bool operator!=(const char* other) const noexcept { return !(*this == other); }
private:
std::array<char, Capacity> data_{};
std::size_t length_ = 0;
};
template <typename T, std::size_t Capacity>
class FixedVector {
public:
using value_type = T;
using iterator = T*;
using const_iterator = const T*;
std::size_t size() const noexcept { return size_; }
std::size_t capacity() const noexcept { return Capacity; }
bool empty() const noexcept { return size_ == 0; }
bool full() const noexcept { return size_ == Capacity; }
void clear() noexcept { size_ = 0; }
bool push_back(const T& value) noexcept {
if (full()) {
return false;
}
data_[size_++] = value;
return true;
}
template <typename... Args>
bool emplace_back(Args&&... args) noexcept {
if (full()) {
return false;
}
data_[size_++] = T(std::forward<Args>(args)...);
return true;
}
bool pop_back() noexcept {
if (empty()) {
return false;
}
--size_;
return true;
}
bool erase(std::size_t index) noexcept {
if (index >= size_) {
return false;
}
for (std::size_t i = index + 1; i < size_; ++i) {
data_[i - 1] = data_[i];
}
--size_;
return true;
}
T& operator[](std::size_t index) noexcept { return data_[index]; }
const T& operator[](std::size_t index) const noexcept { return data_[index]; }
T& back() noexcept { return data_[size_ - 1]; }
const T& back() const noexcept { return data_[size_ - 1]; }
T& front() noexcept { return data_[0]; }
const T& front() const noexcept { return data_[0]; }
// No throwing allocator or unchecked access is needed by the core. The
// caller can use size() before this helper when a reference is required.
T* find_if_index(std::size_t index) noexcept {
return index < size_ ? &data_[index] : nullptr;
}
const T* find_if_index(std::size_t index) const noexcept {
return index < size_ ? &data_[index] : nullptr;
}
T* data() noexcept { return data_.data(); }
const T* data() const noexcept { return data_.data(); }
iterator begin() noexcept { return data_.data(); }
const_iterator begin() const noexcept { return data_.data(); }
iterator end() noexcept { return data_.data() + size_; }
const_iterator end() const noexcept { return data_.data() + size_; }
private:
std::array<T, Capacity> data_{};
std::size_t size_ = 0;
};
struct Profile {
union {
ProfileId id;
ProfileId profileId;
};
FixedText<32> name{};
bool enabled = true;
Profile() : id(0) {}
explicit Profile(ProfileId identifier) : id(identifier) {}
Profile(ProfileId identifier, const char* profileName) : id(identifier), name(profileName) {}
};
struct Pattern {
// Six alternating durations, in seconds: ON, OFF, ON, OFF, ...
// phaseCount permits a short final pattern while retaining a fixed buffer.
PatternId id = PatternId::A;
std::uint8_t phaseCount = static_cast<std::uint8_t>(kMaxPatternPhases);
union {
std::array<std::uint8_t, kMaxPatternPhases> phases;
std::array<std::uint8_t, kMaxPatternPhases> durations;
std::array<std::uint8_t, kMaxPatternPhases> phaseSeconds;
};
Pattern() : phases{} {}
explicit Pattern(PatternId patternIdentifier) : id(patternIdentifier), phases{} {}
Pattern(PatternId patternIdentifier, std::initializer_list<std::uint8_t> values)
: id(patternIdentifier), phaseCount(0), phases{} {
for (std::uint8_t value : values) {
if (phaseCount < kMaxPatternPhases) {
phases[phaseCount++] = value;
}
}
}
Pattern(PatternId patternIdentifier, const std::array<std::uint8_t, kMaxPatternPhases>& values)
: id(patternIdentifier), phases(values) {}
explicit Pattern(const std::array<std::uint8_t, kMaxPatternPhases>& values)
: phases(values) {}
Pattern(std::uint8_t onSeconds, std::uint8_t offSeconds, std::uint8_t onSeconds2,
std::uint8_t offSeconds2, std::uint8_t onSeconds3, std::uint8_t offSeconds3)
: phases{onSeconds, offSeconds, onSeconds2, offSeconds2, onSeconds3, offSeconds3} {}
std::uint16_t totalSeconds() const noexcept {
std::uint16_t total = 0;
const std::size_t count = phaseCount <= kMaxPatternPhases ? phaseCount : 0;
for (std::size_t i = 0; i < count; ++i) {
total = static_cast<std::uint16_t>(total + phases[i]);
}
return total;
}
bool valid() const noexcept {
if (!isPatternId(id) || phaseCount > kMaxPatternPhases) {
return false;
}
std::uint16_t total = 0;
for (std::size_t i = 0; i < phaseCount; ++i) {
if (phases[i] > kMaxPhaseDurationSeconds) {
return false;
}
total = static_cast<std::uint16_t>(total + phases[i]);
}
return total <= kMaxPatternTotalSeconds;
}
bool phaseIsOn(std::size_t index) const noexcept {
return index < phaseCount && (index & 1u) == 0u;
}
};
struct Schedule {
union {
ScheduleId id;
ScheduleId scheduleId;
};
union {
ProfileId profileId;
ProfileId profile;
};
union {
PatternId patternId;
PatternId pattern;
};
union {
std::uint16_t minuteOfDay;
std::uint16_t minute;
};
union {
std::uint8_t weekdayMask;
std::uint8_t days;
std::uint8_t dayMask;
};
bool enabled = true;
Schedule()
: id(kInvalidId),
profileId(0),
patternId(PatternId::A),
minuteOfDay(0),
weekdayMask(kAllWeekdaysMask) {}
Schedule(ScheduleId scheduleIdentifier,
ProfileId profileIdentifier,
PatternId patternIdentifier,
std::uint16_t minute,
std::uint8_t daysMask,
bool isEnabled = true)
: id(scheduleIdentifier),
profileId(profileIdentifier),
patternId(patternIdentifier),
minuteOfDay(minute),
weekdayMask(daysMask),
enabled(isEnabled) {}
// Convenience overload for callers that naturally specify time before the
// pattern identifier.
Schedule(ScheduleId scheduleIdentifier,
ProfileId profileIdentifier,
std::uint16_t minute,
PatternId patternIdentifier,
std::uint8_t daysMask,
bool isEnabled = true)
: id(scheduleIdentifier),
profileId(profileIdentifier),
patternId(patternIdentifier),
minuteOfDay(minute),
weekdayMask(daysMask),
enabled(isEnabled) {}
static Schedule at(ScheduleId scheduleIdentifier,
ProfileId profileIdentifier,
PatternId patternIdentifier,
std::uint8_t hour,
std::uint8_t minute,
std::uint8_t daysMask,
bool isEnabled = true) {
return Schedule(scheduleIdentifier, profileIdentifier, patternIdentifier,
static_cast<std::uint16_t>(hour) * 60u + minute,
daysMask, isEnabled);
}
std::uint8_t hourPart() const noexcept {
return static_cast<std::uint8_t>(minuteOfDay / 60u);
}
std::uint8_t minutePart() const noexcept {
return static_cast<std::uint8_t>(minuteOfDay % 60u);
}
};
struct Holiday {
union {
HolidayId id;
HolidayId holidayId;
};
std::uint8_t month = 1;
std::uint8_t day = 1;
bool enabled = true;
Holiday() : id(kInvalidId) {}
Holiday(HolidayId holidayIdentifier, std::uint8_t holidayMonth,
std::uint8_t holidayDay, bool isEnabled = true)
: id(holidayIdentifier), month(holidayMonth), day(holidayDay), enabled(isEnabled) {}
Holiday(std::uint8_t holidayMonth, std::uint8_t holidayDay)
: month(holidayMonth), day(holidayDay) {}
static Holiday recurring(std::uint8_t holidayMonth, std::uint8_t holidayDay,
HolidayId holidayIdentifier = kInvalidId,
bool isEnabled = true) {
return Holiday(holidayIdentifier, holidayMonth, holidayDay, isEnabled);
}
};
struct AppState {
FixedVector<Profile, kMaxProfiles> profiles;
std::array<Pattern, kMaxPatterns> patterns;
FixedVector<Schedule, kMaxSchedules> schedules;
FixedVector<Holiday, kMaxHolidays> holidays;
union {
ProfileId activeProfileId;
ProfileId activeProfile;
};
std::uint32_t revision = 0;
AppState() : activeProfileId(0) {
for (std::size_t i = 0; i < kMaxPatterns; ++i) {
patterns[i].id = patternIdFromIndex(i);
}
}
std::size_t profileCount() const noexcept { return profiles.size(); }
std::size_t scheduleCount() const noexcept { return schedules.size(); }
std::size_t holidayCount() const noexcept { return holidays.size(); }
Pattern* pattern(PatternId id) noexcept {
const std::size_t index = patternIndex(id);
return index < kMaxPatterns ? &patterns[index] : nullptr;
}
const Pattern* pattern(PatternId id) const noexcept {
const std::size_t index = patternIndex(id);
return index < kMaxPatterns ? &patterns[index] : nullptr;
}
Profile* profile(ProfileId id) noexcept {
for (std::size_t i = 0; i < profiles.size(); ++i) {
if (profiles[i].id == id) {
return &profiles[i];
}
}
return nullptr;
}
const Profile* profile(ProfileId id) const noexcept {
for (std::size_t i = 0; i < profiles.size(); ++i) {
if (profiles[i].id == id) {
return &profiles[i];
}
}
return nullptr;
}
Schedule* schedule(ScheduleId id) noexcept {
for (std::size_t i = 0; i < schedules.size(); ++i) {
if (schedules[i].id == id) {
return &schedules[i];
}
}
return nullptr;
}
const Schedule* schedule(ScheduleId id) const noexcept {
for (std::size_t i = 0; i < schedules.size(); ++i) {
if (schedules[i].id == id) {
return &schedules[i];
}
}
return nullptr;
}
Holiday* holiday(HolidayId id) noexcept {
for (std::size_t i = 0; i < holidays.size(); ++i) {
if (holidays[i].id == id) {
return &holidays[i];
}
}
return nullptr;
}
const Holiday* holiday(HolidayId id) const noexcept {
for (std::size_t i = 0; i < holidays.size(); ++i) {
if (holidays[i].id == id) {
return &holidays[i];
}
}
return nullptr;
}
};
// Monotonic IDs are never inferred from a vector position. Deleting an item
// therefore cannot silently change the identity of another item.
class StableIdAllocator {
public:
StableIdAllocator() = default;
explicit StableIdAllocator(Id first) noexcept : next_(first == 0 ? 1 : first) {}
Id nextValue() const noexcept { return next_; }
void setNextValue(Id value) noexcept { next_ = value == 0 ? 1 : value; }
Id next() noexcept {
for (;;) {
if (next_ == 0 || next_ == kInvalidId) {
next_ = 1;
}
const Id result = next_++;
if (result != 0 && result != kInvalidId) {
return result;
}
}
}
void observe(Id id) noexcept {
if (id != kInvalidId && id >= next_) {
next_ = static_cast<Id>(id + 1u);
}
}
void reset(Id first = 1) noexcept { next_ = first == 0 ? 1 : first; }
private:
Id next_ = 1;
};
} // namespace timbre_core
// A short namespace alias keeps the library pleasant to use from both the
// application and small native tests.
namespace timbre = timbre_core;

259
lib/timbre_core/PatternEngine.cpp Executable file
View File

@@ -0,0 +1,259 @@
#include "PatternEngine.h"
#include <limits>
namespace timbre_core {
void PatternEngine::initializeDefaults() noexcept {
for (std::size_t i = 0; i < kMaxPatterns; ++i) {
patterns_[i].id = patternIdFromIndex(i);
}
configuredPatternMask_ =
static_cast<std::uint8_t>((1u << kMaxPatterns) - 1u);
}
std::uint64_t PatternEngine::readClock() const {
if (clockInterface_ != nullptr) {
return clockInterface_->nowMs();
}
if (clock_) {
return clock_();
}
return 0;
}
bool PatternEngine::validPattern(const Pattern& pattern) const noexcept {
return isPatternId(pattern.id) && ScheduleValidator::validatePattern(pattern).valid;
}
void PatternEngine::setPattern(const Pattern& pattern) noexcept {
if (!validPattern(pattern)) {
return;
}
const std::size_t index = patternIndex(pattern.id);
if (index < patterns_.size()) {
patterns_[index] = pattern;
configuredPatternMask_ = static_cast<std::uint8_t>(
configuredPatternMask_ | (1u << index));
}
}
void PatternEngine::setPatterns(const AppState& state) noexcept {
bool allValid = true;
for (std::size_t i = 0; i < kMaxPatterns; ++i) {
if (!validPattern(state.patterns[i])) {
allValid = false;
break;
}
}
if (!allValid) {
return;
}
patterns_ = state.patterns;
configuredPatternMask_ = static_cast<std::uint8_t>((1u << kMaxPatterns) - 1u);
}
bool PatternEngine::enqueueSnapshot(const Pattern& pattern, std::uint64_t startAtMs) {
if (!validPattern(pattern) || queueSize_ >= kQueueCapacity) {
return false;
}
const std::size_t tail = (queueHead_ + queueSize_) % kQueueCapacity;
queue_[tail].pattern = pattern;
queue_[tail].startAtMs = startAtMs;
++queueSize_;
return true;
}
bool PatternEngine::enqueue(PatternId id) {
return enqueue(id, readClock());
}
bool PatternEngine::enqueue(PatternId id, std::uint64_t startAtMs) {
const std::size_t index = patternIndex(id);
if (index >= patterns_.size() ||
(configuredPatternMask_ & (1u << index)) == 0) {
return false;
}
return enqueueSnapshot(patterns_[index], startAtMs);
}
bool PatternEngine::enqueue(const Pattern& pattern) {
return enqueue(pattern, readClock());
}
bool PatternEngine::enqueue(const Pattern& pattern, std::uint64_t startAtMs) {
return enqueueSnapshot(pattern, startAtMs);
}
bool PatternEngine::enqueue(PatternId id, const AppState& state, std::uint64_t startAtMs) {
const Pattern* pattern = state.pattern(id);
return pattern != nullptr && enqueueSnapshot(*pattern, startAtMs);
}
bool PatternEngine::enqueue(PatternId id, const AppState& state) {
return enqueue(id, state, readClock());
}
bool PatternEngine::start(PatternId id, std::uint64_t nowMs) {
const std::size_t index = patternIndex(id);
if (index >= patterns_.size() ||
(configuredPatternMask_ & (1u << index)) == 0) {
return false;
}
return startSnapshot(patterns_[index], nowMs);
}
bool PatternEngine::start(const Pattern& pattern, std::uint64_t nowMs) {
return startSnapshot(pattern, nowMs);
}
bool PatternEngine::start(PatternId id, const AppState& state, std::uint64_t nowMs) {
const Pattern* pattern = state.pattern(id);
return pattern != nullptr && startSnapshot(*pattern, nowMs);
}
bool PatternEngine::start(PatternId id, const AppState& state) {
return start(id, state, readClock());
}
std::uint64_t PatternEngine::addDuration(std::uint64_t deadline,
std::uint8_t seconds) noexcept {
const std::uint64_t duration = static_cast<std::uint64_t>(seconds) * kMillisecondsPerSecond;
if (deadline > std::numeric_limits<std::uint64_t>::max() - duration) {
return std::numeric_limits<std::uint64_t>::max();
}
return deadline + duration;
}
bool PatternEngine::startSnapshot(const Pattern& pattern, std::uint64_t nowMs) {
if (!validPattern(pattern)) {
return false;
}
if (active_) {
finishActive();
}
activePattern_ = pattern;
active_ = true;
phase_ = 0;
deadlineMs_ = 0;
outputOn_ = false;
hasUpdateTime_ = true;
lastUpdateMs_ = nowMs;
std::size_t firstActivePhase = 0;
while (firstActivePhase < pattern.phaseCount &&
pattern.phases[firstActivePhase] == 0) {
++firstActivePhase;
}
if (firstActivePhase == pattern.phaseCount) {
finishActive();
return true;
}
phase_ = static_cast<std::uint8_t>(firstActivePhase);
emit(pattern.phaseIsOn(firstActivePhase));
deadlineMs_ = addDuration(nowMs, pattern.phases[firstActivePhase]);
return true;
}
void PatternEngine::emit(bool on) noexcept {
// The engine has one physical output by design. Reserved GPIO23 is never
// passed to an output sink, even if a caller supplies an arbitrary ID.
outputOn_ = on;
lastOutputRelay_ = RelayId::Main;
if (output_) {
output_(RelayId::Main, on);
} else if (simpleOutput_) {
simpleOutput_(on);
}
}
void PatternEngine::finishActive() noexcept {
if (active_ && outputOn_) {
emit(false);
}
active_ = false;
phase_ = kNoPhase;
deadlineMs_ = 0;
outputOn_ = false;
}
void PatternEngine::pump(std::uint64_t nowMs) {
// FIFO order is intentional. A future head blocks later requests rather
// than reordering a user's schedule.
while (!active_ && queueSize_ != 0 && queue_[queueHead_].startAtMs <= nowMs) {
const Request request = queue_[queueHead_];
queueHead_ = (queueHead_ + 1) % kQueueCapacity;
--queueSize_;
(void)startSnapshot(request.pattern, nowMs);
}
}
bool PatternEngine::update(std::uint64_t nowMs) {
if (hasUpdateTime_ && nowMs < lastUpdateMs_) {
// A clock regression is unsafe for an active relay. Stop the sequence
// instead of leaving the last output energized indefinitely.
const bool changed = active_ || outputOn_;
finishActive();
return changed;
}
hasUpdateTime_ = true;
lastUpdateMs_ = nowMs;
bool changed = false;
if (active_) {
// Deadlines are absolute. A delayed poll walks the finite phase list
// and cannot accumulate delay or schedule a phase in the past.
while (active_ && nowMs >= deadlineMs_) {
if (phase_ + 1u >= activePattern_.phaseCount) {
finishActive();
changed = true;
break;
}
++phase_;
emit(activePattern_.phaseIsOn(phase_));
deadlineMs_ = addDuration(deadlineMs_, activePattern_.phases[phase_]);
changed = true;
}
}
if (!active_ && queueSize_ != 0) {
const std::size_t before = queueSize_;
pump(nowMs);
changed = changed || before != queueSize_ || active_;
}
return changed;
}
bool PatternEngine::update() {
if (clockInterface_ == nullptr && !clock_) {
return false;
}
return update(readClock());
}
void PatternEngine::stop(std::uint64_t nowMs) {
if (hasUpdateTime_ && nowMs < lastUpdateMs_) {
// A bad sample must not prevent the safety turn-off.
finishActive();
return;
}
hasUpdateTime_ = true;
lastUpdateMs_ = nowMs;
finishActive();
}
void PatternEngine::stop() {
if (clockInterface_ != nullptr || clock_) {
stop(readClock());
} else {
stop(lastUpdateMs_);
}
}
void PatternEngine::cancelAll() noexcept {
queueHead_ = 0;
queueSize_ = 0;
}
} // namespace timbre_core

151
lib/timbre_core/PatternEngine.h Executable file
View File

@@ -0,0 +1,151 @@
#pragma once
#include "Model.h"
#include "ScheduleValidator.h"
#include "SessionClock.h"
#include <array>
#include <cstdint>
#include <functional>
#include <utility>
namespace timbre_core {
class PatternEngine {
public:
using OutputCallback = std::function<void(RelayId, bool)>;
using ClockCallback = std::function<std::uint64_t()>;
using SimpleOutputCallback = std::function<void(bool)>;
struct Request {
Pattern pattern{};
std::uint64_t startAtMs = 0;
};
static constexpr std::size_t kQueueCapacity = kPatternQueueCapacity;
static constexpr std::uint8_t kNoPhase = 0xFFu;
static constexpr std::uint64_t kMillisecondsPerSecond = 1000ULL;
PatternEngine() {
initializeDefaults();
}
explicit PatternEngine(OutputCallback output) : output_(std::move(output)) {
initializeDefaults();
}
explicit PatternEngine(ClockCallback clock, OutputCallback output = OutputCallback())
: clock_(std::move(clock)), output_(std::move(output)) {
initializeDefaults();
}
explicit PatternEngine(MonotonicClock& clock, OutputCallback output = OutputCallback())
: clockInterface_(&clock), output_(std::move(output)) {
initializeDefaults();
}
explicit PatternEngine(const AppState& state, OutputCallback output = OutputCallback())
: output_(std::move(output)) {
initializeDefaults();
setPatterns(state);
}
void setClock(ClockCallback clock) {
clockInterface_ = nullptr;
clock_ = std::move(clock);
}
void setClock(MonotonicClock& clock) {
clock_ = ClockCallback();
clockInterface_ = &clock;
}
void setOutput(OutputCallback output) {
output_ = std::move(output);
simpleOutput_ = SimpleOutputCallback();
}
void setSimpleOutput(SimpleOutputCallback output) {
simpleOutput_ = std::move(output);
output_ = OutputCallback();
}
void setPattern(const Pattern& pattern) noexcept;
void setPatterns(const AppState& state) noexcept;
void setState(const AppState& state) noexcept { setPatterns(state); }
void configure(const AppState& state) noexcept { setPatterns(state); }
// Queueing is non-blocking and bounded. A request is a snapshot, so a
// later configuration commit cannot mutate an already queued sequence.
bool enqueue(PatternId id);
bool enqueue(PatternId id, std::uint64_t startAtMs);
bool enqueue(const Pattern& pattern);
bool enqueue(const Pattern& pattern, std::uint64_t startAtMs);
bool enqueue(PatternId id, const AppState& state, std::uint64_t startAtMs);
bool enqueue(PatternId id, const AppState& state);
bool start(PatternId id, std::uint64_t nowMs);
bool start(const Pattern& pattern, std::uint64_t nowMs);
bool start(PatternId id, const AppState& state, std::uint64_t nowMs);
bool start(PatternId id, const AppState& state);
// Returns true when the state/output changed. It performs no waiting and
// consumes at most one bounded transition burst (six phases).
bool update(std::uint64_t nowMs);
bool update();
bool tick(std::uint64_t nowMs) { return update(nowMs); }
bool tick() { return update(); }
// Stops the active sequence and guarantees an OFF output. Pending queue
// entries are retained; cancelAll() removes them as well.
void stop(std::uint64_t nowMs);
void stop();
void finish() { stop(); }
void cancelAll() noexcept;
bool running() const noexcept { return active_; }
bool outputOn() const noexcept { return outputOn_; }
PatternId activePatternId() const noexcept { return activePattern_.id; }
const Pattern* activePattern() const noexcept { return active_ ? &activePattern_ : nullptr; }
std::uint8_t activePhase() const noexcept { return active_ ? phase_ : kNoPhase; }
std::uint64_t deadlineMs() const noexcept { return active_ ? deadlineMs_ : 0; }
std::uint64_t deadline() const noexcept { return deadlineMs(); }
std::uint64_t nextDeadlineMs() const noexcept { return deadlineMs(); }
std::size_t queueSize() const noexcept { return queueSize_; }
std::size_t queueCapacity() const noexcept { return kQueueCapacity; }
bool configured(PatternId id) const noexcept {
const std::size_t index = patternIndex(id);
return index < kMaxPatterns && (configuredPatternMask_ & (1u << index)) != 0;
}
RelayId lastOutputRelay() const noexcept { return lastOutputRelay_; }
bool reservedRelayDriven() const noexcept { return reservedRelayDriven_; }
private:
void initializeDefaults() noexcept;
std::uint64_t readClock() const;
bool validPattern(const Pattern& pattern) const noexcept;
bool enqueueSnapshot(const Pattern& pattern, std::uint64_t startAtMs);
bool startSnapshot(const Pattern& pattern, std::uint64_t nowMs);
void pump(std::uint64_t nowMs);
void finishActive() noexcept;
void emit(bool on) noexcept;
static std::uint64_t addDuration(std::uint64_t deadline, std::uint8_t seconds) noexcept;
ClockCallback clock_;
MonotonicClock* clockInterface_ = nullptr;
OutputCallback output_;
SimpleOutputCallback simpleOutput_;
std::array<Pattern, kMaxPatterns> patterns_{};
std::uint8_t configuredPatternMask_ =
static_cast<std::uint8_t>((1u << kMaxPatterns) - 1u);
std::array<Request, kQueueCapacity> queue_{};
std::size_t queueHead_ = 0;
std::size_t queueSize_ = 0;
Pattern activePattern_{};
bool active_ = false;
std::uint8_t phase_ = kNoPhase;
std::uint64_t deadlineMs_ = 0;
std::uint64_t lastUpdateMs_ = 0;
bool hasUpdateTime_ = false;
bool outputOn_ = false;
RelayId lastOutputRelay_ = RelayId::Main;
bool reservedRelayDriven_ = false;
};
} // namespace timbre_core

View File

@@ -0,0 +1,214 @@
#include "ScheduleValidator.h"
namespace timbre_core {
namespace {
ValidationResult failure(ValidationCode code, std::size_t index = 0) noexcept {
return ValidationResult(code, index);
}
} // namespace
const char* ValidationResult::message() const noexcept {
switch (code) {
case ValidationCode::Ok:
return "ok";
case ValidationCode::TooManyProfiles:
return "too many profiles";
case ValidationCode::TooManyPatterns:
return "too many patterns";
case ValidationCode::TooManySchedules:
return "too many schedules";
case ValidationCode::TooManyHolidays:
return "too many holidays";
case ValidationCode::InvalidProfileId:
return "invalid profile id";
case ValidationCode::DuplicateProfileId:
return "duplicate profile id";
case ValidationCode::InvalidPatternId:
return "invalid pattern id";
case ValidationCode::DuplicatePatternId:
return "duplicate pattern id";
case ValidationCode::InvalidPhaseCount:
return "invalid phase count";
case ValidationCode::InvalidPhaseDuration:
return "invalid phase duration";
case ValidationCode::PatternTotalTooLarge:
return "pattern total is too large";
case ValidationCode::InvalidHolidayId:
return "invalid holiday id";
case ValidationCode::DuplicateHolidayId:
return "duplicate holiday id";
case ValidationCode::InvalidHolidayDate:
return "invalid holiday date";
case ValidationCode::InvalidScheduleId:
return "invalid schedule id";
case ValidationCode::DuplicateScheduleId:
return "duplicate schedule id";
case ValidationCode::InvalidMinute:
return "invalid minute";
case ValidationCode::InvalidWeekdayMask:
return "invalid weekday mask";
case ValidationCode::UnknownProfile:
return "schedule references an unknown profile";
case ValidationCode::UnknownPattern:
return "schedule references an unknown pattern";
case ValidationCode::InvalidActiveProfile:
return "active profile does not exist";
}
return "validation error";
}
ValidationResult ScheduleValidator::validateProfile(const Profile& profile,
std::size_t index) noexcept {
if (profile.id == kInvalidId) {
return failure(ValidationCode::InvalidProfileId, index);
}
return ValidationResult{};
}
ValidationResult ScheduleValidator::validatePattern(const Pattern& pattern,
std::size_t index) noexcept {
if (!isPatternId(pattern.id)) {
return failure(ValidationCode::InvalidPatternId, index);
}
if (pattern.phaseCount > kMaxPatternPhases) {
return failure(ValidationCode::InvalidPhaseCount, index);
}
std::uint16_t total = 0;
for (std::size_t i = 0; i < pattern.phaseCount; ++i) {
if (!validPhaseDuration(pattern.phases[i])) {
return failure(ValidationCode::InvalidPhaseDuration, index);
}
total = static_cast<std::uint16_t>(total + pattern.phases[i]);
if (!validPatternTotal(total)) {
return failure(ValidationCode::PatternTotalTooLarge, index);
}
}
return ValidationResult{};
}
ValidationResult ScheduleValidator::validateHoliday(const Holiday& holiday,
std::size_t index) noexcept {
if (holiday.id == kInvalidId) {
return failure(ValidationCode::InvalidHolidayId, index);
}
if (!validHolidayDate(holiday.month, holiday.day)) {
return failure(ValidationCode::InvalidHolidayDate, index);
}
return ValidationResult{};
}
bool ScheduleValidator::validHolidayDate(std::uint8_t month, std::uint8_t day) noexcept {
// Holidays are recurring month/day values. February 29 is valid and is
// naturally inert in non-leap years.
if (month < 1 || month > 12 || day < 1) {
return false;
}
static constexpr std::uint8_t maximumDays[] = {
0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31
};
return day <= maximumDays[month];
}
ValidationResult ScheduleValidator::validateSchedule(const Schedule& schedule,
const AppState& state,
std::size_t index) noexcept {
if (schedule.id == kInvalidId) {
return failure(ValidationCode::InvalidScheduleId, index);
}
if (!validMinute(schedule.minuteOfDay)) {
return failure(ValidationCode::InvalidMinute, index);
}
if (!validWeekdayMask(schedule.weekdayMask)) {
return failure(ValidationCode::InvalidWeekdayMask, index);
}
if (state.profile(schedule.profileId) == nullptr) {
return failure(ValidationCode::UnknownProfile, index);
}
if (state.pattern(schedule.patternId) == nullptr ||
!isPatternId(schedule.patternId)) {
return failure(ValidationCode::UnknownPattern, index);
}
const Pattern* pattern = state.pattern(schedule.patternId);
if (pattern == nullptr || !validatePattern(*pattern).valid) {
return failure(ValidationCode::UnknownPattern, index);
}
return ValidationResult{};
}
ValidationResult ScheduleValidator::validate(const AppState& state) noexcept {
if (state.profiles.size() > kMaxProfiles) {
return failure(ValidationCode::TooManyProfiles, state.profiles.size());
}
if (state.patterns.size() != kMaxPatterns) {
return failure(ValidationCode::TooManyPatterns, state.patterns.size());
}
if (state.schedules.size() > kMaxSchedules) {
return failure(ValidationCode::TooManySchedules, state.schedules.size());
}
if (state.holidays.size() > kMaxHolidays) {
return failure(ValidationCode::TooManyHolidays, state.holidays.size());
}
for (std::size_t i = 0; i < state.profiles.size(); ++i) {
const ValidationResult result = validateProfile(state.profiles[i], i);
if (!result.valid) {
return result;
}
for (std::size_t j = 0; j < i; ++j) {
if (state.profiles[j].id == state.profiles[i].id) {
return failure(ValidationCode::DuplicateProfileId, i);
}
}
}
for (std::size_t i = 0; i < state.patterns.size(); ++i) {
const ValidationResult result = validatePattern(state.patterns[i], i);
if (!result.valid) {
return result;
}
for (std::size_t j = 0; j < i; ++j) {
if (state.patterns[j].id == state.patterns[i].id) {
return failure(ValidationCode::DuplicatePatternId, i);
}
}
}
for (std::size_t i = 0; i < state.holidays.size(); ++i) {
const ValidationResult result = validateHoliday(state.holidays[i], i);
if (!result.valid) {
return result;
}
for (std::size_t j = 0; j < i; ++j) {
if (state.holidays[j].id == state.holidays[i].id) {
return failure(ValidationCode::DuplicateHolidayId, i);
}
if (state.holidays[j].enabled && state.holidays[i].enabled &&
state.holidays[j].month == state.holidays[i].month &&
state.holidays[j].day == state.holidays[i].day) {
return failure(ValidationCode::DuplicateHolidayId, i);
}
}
}
for (std::size_t i = 0; i < state.schedules.size(); ++i) {
const ValidationResult result = validateSchedule(state.schedules[i], state, i);
if (!result.valid) {
return result;
}
for (std::size_t j = 0; j < i; ++j) {
if (state.schedules[j].id == state.schedules[i].id) {
return failure(ValidationCode::DuplicateScheduleId, i);
}
}
}
if (state.profiles.size() != 0 && state.profile(state.activeProfileId) == nullptr) {
return failure(ValidationCode::InvalidActiveProfile, 0);
}
return ValidationResult{};
}
} // namespace timbre_core

View File

@@ -0,0 +1,92 @@
#pragma once
#include "Model.h"
#include <cstddef>
#include <cstdint>
namespace timbre_core {
enum class ValidationCode : std::uint8_t {
Ok = 0,
None = 0,
TooManyProfiles,
TooManyPatterns,
TooManySchedules,
TooManyHolidays,
InvalidProfileId,
DuplicateProfileId,
InvalidPatternId,
DuplicatePatternId,
InvalidPhaseCount,
InvalidPhaseDuration,
PatternTotalTooLarge,
InvalidHolidayId,
DuplicateHolidayId,
InvalidHolidayDate,
InvalidScheduleId,
DuplicateScheduleId,
InvalidMinute,
InvalidWeekdayMask,
UnknownProfile,
UnknownPattern,
InvalidActiveProfile
};
using ValidationError = ValidationCode;
struct ValidationResult {
bool valid = true;
ValidationCode code = ValidationCode::Ok;
ValidationCode error = ValidationCode::Ok;
std::size_t index = 0;
constexpr ValidationResult() = default;
explicit constexpr ValidationResult(ValidationCode resultCode,
std::size_t resultIndex = 0) noexcept
: valid(resultCode == ValidationCode::Ok),
code(resultCode),
error(resultCode),
index(resultIndex) {}
constexpr bool ok() const noexcept { return valid; }
constexpr bool isValid() const noexcept { return valid; }
constexpr explicit operator bool() const noexcept { return valid; }
const char* message() const noexcept;
};
class ScheduleValidator {
public:
static ValidationResult validate(const AppState& state) noexcept;
static ValidationResult validateState(const AppState& state) noexcept { return validate(state); }
static ValidationResult validateAppState(const AppState& state) noexcept { return validate(state); }
static bool valid(const AppState& state) noexcept { return validate(state).valid; }
static bool isValid(const AppState& state) noexcept { return valid(state); }
static ValidationResult validateProfile(const Profile& profile,
std::size_t index = 0) noexcept;
static ValidationResult validatePattern(const Pattern& pattern,
std::size_t index = 0) noexcept;
static ValidationResult validateSchedule(const Schedule& schedule,
const AppState& state,
std::size_t index = 0) noexcept;
static ValidationResult validateHoliday(const Holiday& holiday,
std::size_t index = 0) noexcept;
static std::uint16_t patternTotalSeconds(const Pattern& pattern) noexcept {
return pattern.totalSeconds();
}
static bool validPhaseDuration(std::uint8_t value) noexcept {
return value <= kMaxPhaseDurationSeconds;
}
static bool validPatternTotal(std::uint16_t totalSeconds) noexcept {
return totalSeconds <= kMaxPatternTotalSeconds;
}
static bool validWeekdayMask(std::uint8_t value) noexcept {
return value != 0 && (value & static_cast<std::uint8_t>(~kAllWeekdaysMask)) == 0;
}
static bool validMinute(std::uint16_t minute) noexcept { return minute < 1440; }
static bool validHolidayDate(std::uint8_t month, std::uint8_t day) noexcept;
};
} // namespace timbre_core

123
lib/timbre_core/Scheduler.cpp Executable file
View File

@@ -0,0 +1,123 @@
#include "Scheduler.h"
#include "ScheduleValidator.h"
#include <limits>
namespace timbre_core {
void Scheduler::reset() noexcept {
hasLastKey_ = false;
lastKey_ = LocalMinuteKey{};
}
bool Scheduler::isHoliday(const AppState& state, std::uint8_t month,
std::uint8_t day) noexcept {
for (std::size_t i = 0; i < state.holidays.size(); ++i) {
const Holiday& holiday = state.holidays[i];
if (holiday.enabled && holiday.month == month && holiday.day == day) {
return true;
}
}
return false;
}
bool Scheduler::scheduleMatches(const Schedule& schedule, const AppState& state,
const CivilDateTime& localTime) noexcept {
if (!schedule.enabled || schedule.minuteOfDay != localTime.minuteOfDay()) {
return false;
}
const Profile* activeProfile = state.profile(state.activeProfileId);
if (activeProfile == nullptr || !activeProfile->enabled ||
schedule.profileId != state.activeProfileId) {
return false;
}
const std::uint8_t weekday = CivilTime::weekdayForDate(localTime.year, localTime.month,
localTime.day);
if ((schedule.weekdayMask & static_cast<std::uint8_t>(1u << weekday)) == 0) {
return false;
}
if (isHoliday(state, localTime.month, localTime.day)) {
return false;
}
const Pattern* pattern = state.pattern(schedule.patternId);
return pattern != nullptr && isPatternId(schedule.patternId) &&
ScheduleValidator::validatePattern(*pattern).valid;
}
ScheduleDecision Scheduler::poll(const AppState& state,
const CivilDateTime& localTime,
bool firstObservation) {
ScheduleDecision decision;
decision.firstObservation = firstObservation;
if (!localTime.valid()) {
decision.invalidTime = true;
return decision;
}
const LocalMinuteKey currentKey = LocalMinuteKey::from(localTime);
const std::int64_t currentSerial = currentKey.serial();
if (currentSerial == std::numeric_limits<std::int64_t>::min()) {
decision.invalidTime = true;
return decision;
}
if (hasLastKey_) {
const std::int64_t previousSerial = lastKey_.serial();
if (currentSerial <= previousSerial) {
decision.alreadyProcessed = true;
return decision;
}
const std::int64_t distance = currentSerial - previousSerial;
decision.missedMinutes = distance > 1 ? static_cast<std::uint64_t>(distance - 1) : 0;
}
// Consume the current key before evaluating matches. Therefore a failed
// lookup (holiday, disabled profile, etc.) cannot be replayed later.
lastKey_ = currentKey;
hasLastKey_ = true;
if (firstObservation) {
return decision;
}
if (isHoliday(state, localTime.month, localTime.day)) {
decision.blockedByHoliday = true;
return decision;
}
const Schedule* selected = nullptr;
for (std::size_t i = 0; i < state.schedules.size(); ++i) {
const Schedule& candidate = state.schedules[i];
if (!scheduleMatches(candidate, state, localTime)) {
continue;
}
// Stable IDs, rather than array positions, define deterministic
// precedence when two schedules intentionally share a minute.
if (selected == nullptr || candidate.id < selected->id) {
selected = &candidate;
}
}
if (selected != nullptr) {
decision.due = true;
decision.scheduleId = selected->id;
decision.patternId = selected->patternId;
}
return decision;
}
ScheduleDecision Scheduler::poll(const AppState& state,
SessionClock& clock,
std::int64_t utcEpochSeconds) {
const SessionClock::Observation observation = clock.observe(utcEpochSeconds);
if (!observation.valid) {
ScheduleDecision decision;
decision.invalidTime = true;
return decision;
}
return poll(state, observation.local, observation.firstObservation);
}
} // namespace timbre_core

90
lib/timbre_core/Scheduler.h Executable file
View File

@@ -0,0 +1,90 @@
#pragma once
#include "CivilTime.h"
#include "Model.h"
#include "SessionClock.h"
#include <cstdint>
namespace timbre_core {
struct ScheduleDecision {
bool due = false;
bool firstObservation = false;
bool alreadyProcessed = false;
bool blockedByHoliday = false;
bool invalidTime = false;
ScheduleId scheduleId = kInvalidId;
PatternId patternId = PatternId::A;
std::uint64_t missedMinutes = 0;
constexpr explicit operator bool() const noexcept { return due; }
constexpr bool triggered() const noexcept { return due; }
};
using ScheduleMatch = ScheduleDecision;
class Scheduler {
public:
Scheduler() = default;
// Only the currently observed local minute is considered. If the source
// jumped from minute N to N+K, the intermediate minutes are intentionally
// discarded rather than replayed.
ScheduleDecision poll(const AppState& state,
const CivilDateTime& localTime,
bool firstObservation = false);
ScheduleDecision process(const AppState& state,
const CivilDateTime& localTime,
bool firstObservation = false) {
return poll(state, localTime, firstObservation);
}
// Convenience integration with SessionClock. The caller supplies the
// current wall value; the injected monotonic source remains authoritative
// inside SessionClock.
ScheduleDecision poll(const AppState& state,
SessionClock& clock,
std::int64_t utcEpochSeconds);
ScheduleDecision poll(const AppState& state,
const SessionClock::Observation& observation) {
if (!observation.valid) {
ScheduleDecision decision;
decision.invalidTime = true;
return decision;
}
return poll(state, observation.local, observation.firstObservation);
}
ScheduleDecision process(const AppState& state,
SessionClock& clock,
std::int64_t utcEpochSeconds) {
return poll(state, clock, utcEpochSeconds);
}
ScheduleDecision tick(const AppState& state,
const CivilDateTime& localTime,
bool firstObservation = false) {
return poll(state, localTime, firstObservation);
}
ScheduleDecision evaluate(const AppState& state,
const CivilDateTime& localTime,
bool firstObservation = false) {
return poll(state, localTime, firstObservation);
}
bool hasLastKey() const noexcept { return hasLastKey_; }
const LocalMinuteKey& lastKey() const noexcept { return lastKey_; }
const LocalMinuteKey& key() const noexcept { return lastKey_; }
void reset() noexcept;
static bool isHoliday(const AppState& state, std::uint8_t month,
std::uint8_t day) noexcept;
static bool scheduleMatches(const Schedule& schedule,
const AppState& state,
const CivilDateTime& localTime) noexcept;
private:
bool hasLastKey_ = false;
LocalMinuteKey lastKey_{};
};
} // namespace timbre_core

134
lib/timbre_core/SessionClock.cpp Executable file
View File

@@ -0,0 +1,134 @@
#include "SessionClock.h"
#include <limits>
#include <utility>
namespace timbre_core {
std::uint64_t SessionClock::readMonotonic() const {
if (clock_ != nullptr) {
return clock_->nowMs();
}
if (callback_) {
return callback_();
}
return 0;
}
bool SessionClock::setObservation(std::int64_t wallSeconds, std::uint64_t monotonicMs,
bool first, bool regression, bool resynchronized) {
Observation observation;
observation.valid = CivilTime::fromUnixSeconds(wallSeconds, observation.local);
observation.firstObservation = first;
observation.monotonicRegression = regression;
observation.resynchronized = resynchronized;
observation.monotonicMs = monotonicMs;
observation.utcEpochSeconds = wallSeconds;
if (!observation.valid) {
lastObservation_ = observation;
return false;
}
initialized_ = true;
if (first) {
anchorMonotonicMs_ = monotonicMs;
anchorWallSeconds_ = wallSeconds;
}
lastMonotonicMs_ = monotonicMs;
lastWallSeconds_ = wallSeconds;
lastLocal_ = observation.local;
lastObservation_ = observation;
return true;
}
SessionClock::Observation SessionClock::observeAt(std::int64_t utcEpochSeconds,
std::uint64_t monotonicMs) {
if (!initialized_) {
(void)setObservation(utcEpochSeconds, monotonicMs, true, false, false);
return lastObservation_;
}
if (monotonicMs < lastMonotonicMs_) {
// A monotonic source must never run backwards. Keep the last good
// anchor and reject the sample rather than manufacturing elapsed time.
Observation observation = lastObservation_;
observation.valid = false;
observation.firstObservation = false;
observation.monotonicRegression = true;
observation.resynchronized = false;
observation.monotonicMs = monotonicMs;
lastObservation_ = observation;
return observation;
}
const std::uint64_t delta = monotonicMs - lastMonotonicMs_;
const std::int64_t predicted = lastWallSeconds_ +
static_cast<std::int64_t>(delta / 1000ULL);
// A wall-clock correction is resynchronized at the current sample, never
// expanded into a stream of historical minutes.
const std::uint64_t difference = predicted >= utcEpochSeconds
? static_cast<std::uint64_t>(predicted - utcEpochSeconds)
: static_cast<std::uint64_t>(utcEpochSeconds - predicted);
const bool resynchronized = difference > 1ULL;
(void)setObservation(utcEpochSeconds, monotonicMs, false, false, resynchronized);
return lastObservation_;
}
SessionClock::Observation SessionClock::observe(std::int64_t utcEpochSeconds) {
return observeAt(utcEpochSeconds, readMonotonic());
}
SessionClock::Observation SessionClock::tick() {
if (!initialized_) {
return Observation{};
}
const std::uint64_t sample = readMonotonic();
if (sample < lastMonotonicMs_) {
Observation observation = lastObservation_;
observation.valid = false;
observation.firstObservation = false;
observation.monotonicRegression = true;
observation.resynchronized = false;
observation.monotonicMs = sample;
lastObservation_ = observation;
return observation;
}
const std::uint64_t delta = sample - lastMonotonicMs_;
// Do not use a platform wall clock and do not add a whole missed interval
// to a queue. This is the monotonic, bounded session-time path.
const std::int64_t wall = lastWallSeconds_ + static_cast<std::int64_t>(delta / 1000ULL);
(void)setObservation(wall, sample, false, false, false);
return lastObservation_;
}
std::uint64_t SessionClock::monotonicNowMs() const {
return readMonotonic();
}
std::uint64_t SessionClock::elapsedMs() const noexcept {
if (!initialized_) {
return 0;
}
const std::uint64_t sample = monotonicNowMs();
return sample < lastMonotonicMs_ ? 0 : sample - lastMonotonicMs_;
}
std::uint64_t SessionClock::sessionElapsedMs() const noexcept {
if (!initialized_) {
return 0;
}
const std::uint64_t sample = monotonicNowMs();
return sample < anchorMonotonicMs_ ? 0 : sample - anchorMonotonicMs_;
}
void SessionClock::reset() noexcept {
initialized_ = false;
anchorMonotonicMs_ = 0;
lastMonotonicMs_ = 0;
anchorWallSeconds_ = 0;
lastWallSeconds_ = 0;
lastLocal_ = CivilDateTime{};
lastObservation_ = Observation{};
}
} // namespace timbre_core

91
lib/timbre_core/SessionClock.h Executable file
View File

@@ -0,0 +1,91 @@
#pragma once
#include "CivilTime.h"
#include <cstdint>
#include <functional>
#include <utility>
namespace timbre_core {
// Hardware integration supplies this interface. The core never calls a
// platform-specific tick function itself.
class MonotonicClock {
public:
virtual ~MonotonicClock() = default;
virtual std::uint64_t nowMs() const = 0;
};
class SessionClock {
public:
using Callback = std::function<std::uint64_t()>;
struct Observation {
bool valid = false;
bool firstObservation = false;
bool monotonicRegression = false;
bool resynchronized = false;
std::uint64_t monotonicMs = 0;
std::int64_t utcEpochSeconds = 0;
CivilDateTime local{};
constexpr operator bool() const noexcept { return valid; }
constexpr bool isFirstObservation() const noexcept { return firstObservation; }
};
explicit SessionClock(Callback callback) : callback_(std::move(callback)) {}
explicit SessionClock(MonotonicClock& clock) : clock_(&clock) {}
SessionClock(const SessionClock&) = delete;
SessionClock& operator=(const SessionClock&) = delete;
// The first wall-clock observation establishes an anchor and returns
// firstObservation=true. It never attempts to replay older wall time.
Observation observe(std::int64_t utcEpochSeconds);
Observation poll(std::int64_t utcEpochSeconds) { return observe(utcEpochSeconds); }
Observation sync(std::int64_t utcEpochSeconds) { return observe(utcEpochSeconds); }
Observation update(std::int64_t utcEpochSeconds) { return observe(utcEpochSeconds); }
// Deterministic injection for tests and for a caller that already has a
// monotonic sample. It follows exactly the same first-observation rule.
Observation observeAt(std::int64_t utcEpochSeconds, std::uint64_t monotonicMs);
// Advance only from the injected monotonic source. No wall-clock catch-up
// is performed, so a sleep or a clock correction cannot create old events.
Observation tick();
Observation update() { return tick(); }
bool initialized() const noexcept { return initialized_; }
bool hasObservation() const noexcept { return initialized_; }
bool hasFirstObservation() const noexcept { return initialized_; }
bool lastWasFirstObservation() const noexcept { return lastObservation_.firstObservation; }
std::uint64_t monotonicNowMs() const;
std::uint64_t now() const { return monotonicNowMs(); }
std::uint64_t elapsedMs() const noexcept;
std::uint64_t sessionElapsedMs() const noexcept;
std::uint64_t elapsedSinceAnchorMs() const noexcept { return sessionElapsedMs(); }
std::int64_t utcEpochSeconds() const noexcept { return lastWallSeconds_; }
std::int64_t wallEpochSeconds() const noexcept { return lastWallSeconds_; }
std::int64_t anchorUtcEpochSeconds() const noexcept { return anchorWallSeconds_; }
const CivilDateTime& localTime() const noexcept { return lastLocal_; }
const CivilDateTime& civilTime() const noexcept { return lastLocal_; }
const Observation& lastObservation() const noexcept { return lastObservation_; }
void reset() noexcept;
private:
std::uint64_t readMonotonic() const;
bool setObservation(std::int64_t wallSeconds, std::uint64_t monotonicMs,
bool first, bool regression, bool resynchronized);
Callback callback_;
MonotonicClock* clock_ = nullptr;
bool initialized_ = false;
std::uint64_t anchorMonotonicMs_ = 0;
std::uint64_t lastMonotonicMs_ = 0;
std::int64_t anchorWallSeconds_ = 0;
std::int64_t lastWallSeconds_ = 0;
CivilDateTime lastLocal_{};
Observation lastObservation_{};
};
} // namespace timbre_core

View File

@@ -0,0 +1,75 @@
#pragma once
#include "Model.h"
namespace timbre_core {
// Persistence is represented only by this small interface. Implementations
// may use a file system on the final product, but the domain layer remains
// portable and has no filesystem dependency.
class StateRepository {
public:
virtual ~StateRepository() = default;
// Canonical names used by TimbreService.
virtual bool load(AppState& destination) { return read(destination); }
virtual bool commit(const AppState& source) { return save(source); }
// Compatibility hooks for small repositories that naturally use read/write
// terminology. Overriding either pair is sufficient; the defaults above
// bridge the two naming conventions without recursion.
virtual bool read(AppState& destination) {
(void)destination;
return false;
}
virtual bool write(const AppState& source) {
(void)source;
return false;
}
virtual bool save(const AppState& source) { return write(source); }
bool loadState(AppState& destination) { return load(destination); }
bool commitState(const AppState& source) { return commit(source); }
};
class MemoryStateRepository final : public StateRepository {
public:
bool load(AppState& destination) override {
if (!loadSucceeds_ || !hasValue_) {
return false;
}
destination = value_;
return true;
}
bool commit(const AppState& source) override {
++commitAttempts_;
if (!commitSucceeds_) {
return false;
}
value_ = source;
hasValue_ = true;
++commitCount_;
return true;
}
void seed(const AppState& state) {
value_ = state;
hasValue_ = true;
}
void setCommitSucceeds(bool succeeds) noexcept { commitSucceeds_ = succeeds; }
void setLoadSucceeds(bool succeeds) noexcept { loadSucceeds_ = succeeds; }
std::size_t commitAttempts() const noexcept { return commitAttempts_; }
std::size_t commitCount() const noexcept { return commitCount_; }
private:
AppState value_{};
bool hasValue_ = false;
bool loadSucceeds_ = true;
bool commitSucceeds_ = true;
std::size_t commitAttempts_ = 0;
std::size_t commitCount_ = 0;
};
} // namespace timbre_core

10
lib/timbre_core/TimbreCore.h Executable file
View File

@@ -0,0 +1,10 @@
#pragma once
#include "Model.h"
#include "CivilTime.h"
#include "SessionClock.h"
#include "ScheduleValidator.h"
#include "Scheduler.h"
#include "PatternEngine.h"
#include "StateRepository.h"
#include "TimbreService.h"

305
lib/timbre_core/TimbreService.cpp Executable file
View File

@@ -0,0 +1,305 @@
#include "TimbreService.h"
#include <limits>
namespace timbre_core {
namespace {
bool containsProfile(const AppState& state, ProfileId id) noexcept {
return state.profile(id) != nullptr;
}
bool containsSchedule(const AppState& state, ScheduleId id) noexcept {
return state.schedule(id) != nullptr;
}
bool containsHoliday(const AppState& state, HolidayId id) noexcept {
return state.holiday(id) != nullptr;
}
} // namespace
bool TimbreService::fail(ServiceCode code, const ValidationResult& validation) {
lastResult_ = ServiceResult{false, code, validation};
return false;
}
void TimbreService::observeIds(const AppState& state) noexcept {
nextProfileId_.reset();
nextScheduleId_.reset();
nextHolidayId_.reset();
for (std::size_t i = 0; i < state.profiles.size(); ++i) {
nextProfileId_.observe(state.profiles[i].id);
}
for (std::size_t i = 0; i < state.schedules.size(); ++i) {
nextScheduleId_.observe(state.schedules[i].id);
}
for (std::size_t i = 0; i < state.holidays.size(); ++i) {
nextHolidayId_.observe(state.holidays[i].id);
}
}
Id TimbreService::nextAvailableProfileId(const AppState& state) {
for (;;) {
const Id id = nextProfileId_.next();
if (id != kInvalidId && !containsProfile(state, id)) {
return id;
}
}
}
Id TimbreService::nextAvailableScheduleId(const AppState& state) {
for (;;) {
const Id id = nextScheduleId_.next();
if (id != kInvalidId && !containsSchedule(state, id)) {
return id;
}
}
}
Id TimbreService::nextAvailableHolidayId(const AppState& state) {
for (;;) {
const Id id = nextHolidayId_.next();
if (id != kInvalidId && !containsHoliday(state, id)) {
return id;
}
}
}
bool TimbreService::load() {
AppState candidate;
if (repository_ == nullptr || !repository_->load(candidate)) {
return fail(ServiceCode::LoadFailed, ValidationResult{});
}
const ValidationResult validation = ScheduleValidator::validate(candidate);
if (!validation.valid) {
return fail(ServiceCode::InvalidRepositoryState, validation);
}
ram_ = candidate;
observeIds(ram_);
loaded_ = true;
lastResult_ = ServiceResult{true, ServiceCode::Ok, ValidationResult{}};
return true;
}
bool TimbreService::commitCurrent() {
const ValidationResult validation = ScheduleValidator::validate(ram_);
if (!validation.valid) {
return fail(ServiceCode::ValidationFailed, validation);
}
if (repository_ == nullptr || !repository_->commit(ram_)) {
return fail(ServiceCode::CommitFailed, ValidationResult{});
}
lastResult_ = ServiceResult{true, ServiceCode::Ok, ValidationResult{}};
return true;
}
bool TimbreService::addProfile(const Profile& profile) {
if (ram_.profiles.full()) {
return fail(ServiceCode::LimitExceeded, ValidationResult{});
}
const ProfileId id = profile.id == kInvalidId ? nextAvailableProfileId(ram_) : profile.id;
if (containsProfile(ram_, id)) {
return fail(ServiceCode::DuplicateId, ValidationResult{});
}
Profile copy = profile;
copy.id = id;
const StableIdAllocator savedAllocator = nextProfileId_;
const bool committed = transact([&copy](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([&copy](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([&copy](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([&copy](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([&copy](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([&copy](AppState& candidate) {
Holiday* target = candidate.holiday(copy.id);
if (target == nullptr) {
return false;
}
*target = copy;
return true;
});
}
bool TimbreService::removeHoliday(HolidayId id) {
if (!containsHoliday(ram_, id)) {
return fail(ServiceCode::NotFound, ValidationResult{});
}
return transact([id](AppState& candidate) {
for (std::size_t i = 0; i < candidate.holidays.size(); ++i) {
if (candidate.holidays[i].id == id) {
return candidate.holidays.erase(i);
}
}
return false;
});
}
bool TimbreService::setActiveProfile(ProfileId id) {
if (!containsProfile(ram_, id)) {
return fail(ServiceCode::NotFound, ValidationResult{});
}
return transact([id](AppState& candidate) {
candidate.activeProfileId = id;
return true;
});
}
bool TimbreService::setPattern(PatternId patternIdentifier, const Pattern& pattern) {
if (!isPatternId(patternIdentifier) ||
patternIdentifier != pattern.id ||
!ScheduleValidator::validatePattern(pattern).valid) {
return fail(ServiceCode::ValidationFailed, ValidationResult{});
}
return transact([patternIdentifier, pattern](AppState& candidate) {
const std::size_t index = patternIndex(patternIdentifier);
if (index >= candidate.patterns.size()) {
return false;
}
candidate.patterns[index] = pattern;
return true;
});
}
bool TimbreService::setPatterns(const std::array<Pattern, kMaxPatterns>& patterns) {
return transact([&patterns](AppState& candidate) {
candidate.patterns = patterns;
return true;
});
}
} // namespace timbre_core

136
lib/timbre_core/TimbreService.h Executable file
View File

@@ -0,0 +1,136 @@
#pragma once
#include "ScheduleValidator.h"
#include "StateRepository.h"
#include <limits>
#include <type_traits>
#include <utility>
namespace timbre_core {
enum class ServiceCode : std::uint8_t {
Ok = 0,
NotLoaded,
LoadFailed,
InvalidRepositoryState,
MutationRejected,
ValidationFailed,
CommitFailed,
NotFound,
LimitExceeded,
DuplicateId
};
struct ServiceResult {
bool ok = true;
ServiceCode code = ServiceCode::Ok;
ValidationResult validation{};
constexpr bool succeeded() const noexcept { return ok; }
constexpr explicit operator bool() const noexcept { return ok; }
};
class TimbreService {
public:
explicit TimbreService(StateRepository& repository) : repository_(&repository) {}
bool load();
bool loaded() const noexcept { return loaded_; }
const AppState& state() const noexcept { return ram_; }
const AppState& ram() const noexcept { return ram_; }
const ServiceResult& lastResult() const noexcept { return lastResult_; }
ServiceCode lastError() const noexcept { return lastResult_.code; }
// Copy, mutate, validate, persist, and only then publish to ram_. The
// lambda may return bool (false rejects the transaction) or void.
template <typename Mutator>
bool transact(Mutator&& mutator) {
AppState candidate = ram_;
bool accepted = true;
if constexpr (std::is_void_v<std::invoke_result_t<Mutator, AppState&>>) {
std::forward<Mutator>(mutator)(candidate);
} else {
accepted = static_cast<bool>(
std::forward<Mutator>(mutator)(candidate));
}
if (!accepted) {
return fail(ServiceCode::MutationRejected, ValidationResult{});
}
if (candidate.revision == std::numeric_limits<std::uint32_t>::max()) {
candidate.revision = 0;
} else {
++candidate.revision;
}
const ValidationResult validation = ScheduleValidator::validate(candidate);
if (!validation.valid) {
return fail(ServiceCode::ValidationFailed, validation);
}
if (!repository_->commit(candidate)) {
return fail(ServiceCode::CommitFailed, ValidationResult{});
}
// This is the only assignment to the live model in a transaction.
ram_ = candidate;
loaded_ = true;
lastResult_ = ServiceResult{true, ServiceCode::Ok, ValidationResult{}};
return true;
}
template <typename Mutator>
bool mutate(Mutator&& mutator) {
return transact(std::forward<Mutator>(mutator));
}
template <typename Mutator>
bool apply(Mutator&& mutator) {
return transact(std::forward<Mutator>(mutator));
}
bool replace(const AppState& replacement) {
return transact([&replacement](AppState& candidate) {
candidate = replacement;
return true;
});
}
bool commitCurrent();
bool commit() { return commitCurrent(); }
bool addProfile(const Profile& profile);
bool updateProfile(ProfileId id, const Profile& profile);
bool removeProfile(ProfileId id);
bool deleteProfile(ProfileId id) { return removeProfile(id); }
bool addSchedule(const Schedule& schedule);
bool updateSchedule(ScheduleId id, const Schedule& schedule);
bool removeSchedule(ScheduleId id);
bool deleteSchedule(ScheduleId id) { return removeSchedule(id); }
bool addHoliday(const Holiday& holiday);
bool updateHoliday(HolidayId id, const Holiday& holiday);
bool removeHoliday(HolidayId id);
bool deleteHoliday(HolidayId id) { return removeHoliday(id); }
bool setActiveProfile(ProfileId id);
bool setPattern(PatternId id, const Pattern& pattern);
bool setPatterns(const std::array<Pattern, kMaxPatterns>& patterns);
private:
bool fail(ServiceCode code, const ValidationResult& validation);
void observeIds(const AppState& state) noexcept;
Id nextAvailableProfileId(const AppState& state);
Id nextAvailableScheduleId(const AppState& state);
Id nextAvailableHolidayId(const AppState& state);
StateRepository* repository_ = nullptr;
AppState ram_{};
StableIdAllocator nextProfileId_{};
StableIdAllocator nextScheduleId_{};
StableIdAllocator nextHolidayId_{};
bool loaded_ = false;
ServiceResult lastResult_{};
};
} // namespace timbre_core

13
lib/timbre_core/library.json Executable file
View File

@@ -0,0 +1,13 @@
{
"name": "timbre_core",
"version": "0.1.0",
"description": "Portable, bounded domain core for the TimbreESP project",
"keywords": "timbre, esp32, scheduler",
"license": "MIT",
"frameworks": "*",
"build": {
"flags": [
"-std=c++17"
]
}
}