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

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"
]
}
}