Portable C++17 core utilities for embedded firmware: a platform-abstracted time/delay source, NVS-backed key-value preferences with CRC-checked program slots, fixed-capacity queue, string helpers, CRC32, MCU diagnostics, and a heap health monitor. ESP32 (ESP-IDF) is the fully supported target; the portable subset (strings, queue, math, CRC, time on host) builds for STM32/host with the bundled host backend.
Preferences layout is split by responsibility:
ungula/core/preferences/i_preferences.h— platform-agnostic interface.ungula/core/preferences/platforms/*— platform implementations.ungula/core/preferences/preferences.h— single include facade for hosts.ungula/core/preferences/tools/*— reusable preference-backed utilities.
The library is header-mostly. One umbrella header pulls in the public surface for Arduino discovery; individual headers can also be included directly when only part of the surface is needed.
- Own source minimum:
C++17. - Effective minimum for consumers:
C++17. - Dependency impact: None (no declared internal dependencies).
Use this section first, then jump to the detailed API sections.
- Most app code:
#include <ungula/core.h>. - Portable persistence contract:
#include <ungula/core/preferences/i_preferences.h>. - Platform-selected preferences facade:
#include <ungula/core/preferences/preferences.h>. - Program/recipe slots utility:
#include <ungula/core/preferences/tools/programs/program_store.h>. - Time only:
#include <ungula/core/time/time.h>.
| Module | ESP32 | Host tests / desktop | Notes |
|---|---|---|---|
time/* |
Yes | Yes | Host backend: std::chrono + std::thread |
preferences/i_preferences.h |
Yes | Yes | Interface-only |
preferences/preferences.h (initStorage()) |
Yes | Yes | Declaration in facade; implementation picked at link time |
preferences/platforms/esp32_preferences.* |
Yes | No | Compiled when ESP_PLATFORM, ARDUINO_ARCH_ESP32, or ESP32 is defined; aliased as Preferences via preferences.h. Provides ESP32 initStorage() (wraps nvs_flash_init) |
preferences/platforms/host_preferences.cpp |
No | Yes | No-op initStorage() for host tests / non-MCU builds |
preferences/tools/programs/program_store.h |
Yes | Yes | Requires injected IPreferences implementation |
util/* (queue, crc32, string_*, types) |
Yes | Yes | Header-only utility layer |
system/health_monitor.* |
Yes | Yes | Host returns heap counters as 0 |
system/system_reboot.* |
Yes | No | Non-ESP build errors at compile time |
system/chip_info.* |
Yes | No | Implementation uses ESP-IDF headers |
- Do: include
ungula/core/time/time.h(orungula/core.h) and call onlymillis/micros/delay*/delayUntil*/yield/now*. - Do: use
delayUntilMsfor periodic loops; useyield()in tight supervisory loops. - Do: install
ITimeProviderbefore treatingnow()as real wall clock. - Don't: include
time/platforms/*directly. - Don't: call
esp_timer_*,vTaskDelay, or Arduinomillis()/delay()directly from domain code.
- Do: call
initStorage()exactly once at boot, beforePreferences::begin(),wifi,espnow,ble, or any other subsystem that touches non-volatile storage. Skipping it producesESP_ERR_NVS_NOT_INITIALIZEDon ESP-IDF and a reboot loop. - Do: include
ungula/core/preferences/preferences.hin composition-root code. - Do: depend on
IPreferencesin reusable/domain classes. - Do: pair every
begin(ns)withend(). - Do: use
ProgramStore(tools/programs/program_store.h) for recipe/profile slots. - Don't: include
preferences/platforms/*in portable domain code. - Don't: read/write the
programsnamespace by hand when usingProgramStore. - Don't: share one
Preferencesinstance across tasks. - Don't: spell the platform class name (
Esp32Preferences) in application code — use thePreferencesalias so the code stays portable.
#include <ungula/core/preferences/preferences.h>
extern "C" void app_main()
{
if (!ungula::core::preferences::initStorage()) {
abort(); // storage backend refused even after erase-and-retry
}
// wifi::espnow_init(); transport.init(); Preferences::begin(...); ...
}Backend-agnostic free function. ESP32 backend wraps nvs_flash_init() and
handles ESP_ERR_NVS_NO_FREE_PAGES / ESP_ERR_NVS_NEW_VERSION_FOUND by
erasing the partition and retrying once. Host backend is a no-op returning
true so the same source compiles and links for unit tests.
Call this function prior to wifi, espnow, ble, or any
Preferences::begin().
- Do: treat
HealthMonitoras portable (ESP + host test stubs). - Do: gate
SystemControlandqueryChipInfo()usage to ESP32 builds. - Don't: assume reboot/chip-info code compiles on non-ESP targets.
- Do: use
Queue<T,N>for bounded no-heap buffering. - Do: use
crc32/crc32_bytefor wire/storage integrity checks. - Do: use
string_t/string_view_tandstr::*helpers for text utilities. - Don't: reimplement CRC/queue/string helpers already provided here.
| Goal | Call | Return / behavior |
|---|---|---|
| monotonic ms/us | tc::millis(), tc::micros() |
int64_t monotonic ticks |
| blocking wait | tc::delayMs(ms), tc::delayUs(us) |
void; non-positive input is no-op |
| drift-free periodic loop | tc::delayUntilMs(ref, period) / delayUntilUs |
advances ref by period |
| cooperative handoff | tc::yield() |
ESP32: one RTOS tick (vTaskDelay(1)); host: thread yield |
| wall clock (provider-aware) | tc::now(), tc::nowUtc(), tc::nowLocal() |
falls back to local monotonic clock when provider invalid |
| zone conversion | tc::setTimezone(...), tc::nowInTz(offsetSec) |
fixed-offset only (no DST logic) |
| sync offset clock | setSyncTime, syncNow, clearSync |
additive offset model |
| Goal | Call | Return / behavior |
|---|---|---|
| open namespace | prefs.begin("ns") |
bool success |
| close namespace | prefs.end() |
void |
| write values | putString/putBytes/putUInt8/putUInt32 |
bool success |
| read values | getString/getBytes |
bytes read (0 on missing/error) |
| typed reads | getUInt8/getUInt32(key, default) |
default on missing/error |
| key management | hasKey/remove/clear |
bool |
| Goal | Call | Return / behavior |
|---|---|---|
| initialize slots | store.init(defaultName, createDefault) |
creates slot 0 when none valid |
| read slot | store.getProgram(index) |
out-of-range clamps to slot 0 |
| save slot | store.saveProgram(index, p) |
forces valid=true; persists immediately |
| delete slot | store.deleteProgram(index) |
refuses to delete last valid slot |
| active slot | getActiveIndex/setActiveIndex |
rejects invalid target slot |
| metadata | getLastUsedIndex/setLastUsedIndex |
persisted in same namespace |
| capacity | countValid/maxPrograms |
count / compile-time capacity |
#include <ungula/core.h>
namespace tc = ungula::core::time;
void setup() {}
void loop() {
auto ref = tc::millis();
while (true) {
// ... read sensors, push status ...
tc::delayUntilMs(ref, 50); // every 50 ms, no drift
}
}When to use this: any periodic task (sensor poll, heartbeat, control
loop). delayUntilMs advances ref by the period, so jitter from work
inside the loop does not accumulate.
#include <ungula/core/time/time.h>
ungula::core::time::delayUs(250); // ~250 µs busy-wait on ESP32When to use this: bit-banging, short hardware-timing windows. Prefer
delayMs for anything ≥ 1 ms — it yields to FreeRTOS.
#include <ungula/core/time/time.h>
#include <ungula/core/time/i_time_provider.h>
class NtpClock : public ungula::core::time::ITimeProvider {
public:
int64_t nowMs() const override { return epochMs_; }
bool isValid() const override { return synced_; }
void onSync(int64_t epochMs) { epochMs_ = epochMs; synced_ = true; }
private:
int64_t epochMs_ = 0;
bool synced_ = false;
};
static NtpClock clock;
void setup() {
ungula::core::time::setTimeProvider(&clock);
ungula::core::time::setTimezone(ungula::core::time::tz::Timezone::CET);
}
void printNow() {
char buf[20];
if (ungula::core::time::formatLocal(buf, sizeof(buf)) > 0) {
// buf == "2026-04-26 14:32:01"
}
}When to use this: any code that needs a real wall-clock instant
(logging timestamps, scheduled tasks). Without a provider, now()
falls back to monotonic-since-boot.
#include <ungula/core/preferences/preferences.h>
ungula::core::preferences::Preferences prefs;
void saveSsid(const char* ssid) {
if (!prefs.begin("wifi")) return;
prefs.putString("ssid", ssid);
prefs.end();
}
bool readSsid(char* buf, size_t bufSize) {
if (!prefs.begin("wifi")) return false;
size_t n = prefs.getString("ssid", buf, bufSize);
prefs.end();
return n > 0;
}When to use this: configuration that must survive reboot. Namespace names are limited to 15 characters by NVS.
#include <ungula/core/preferences/preferences.h>
#include <ungula/core/preferences/tools/programs/program_store.h>
#include <cstdio>
struct Recipe {
char name[32];
int speedRpm;
float targetC;
bool valid;
};
static Recipe defaultRecipe(const char* name) {
Recipe r{};
std::snprintf(r.name, sizeof(r.name), "%s", name);
r.speedRpm = 1000;
r.targetC = 25.0f;
r.valid = true;
return r;
}
ungula::core::preferences::Preferences prefs;
ungula::core::preferences::programs::ProgramStore<Recipe, 10> store(prefs);
void setup() {
store.init("DEFAULT", &defaultRecipe);
Recipe r = store.getProgram(store.getActiveIndex());
r.speedRpm = 1500;
store.saveProgram(store.getActiveIndex(), r);
}When to use this: a fixed-size table of user recipes/profiles where
each slot must be self-checking (corruption survives reboots
otherwise). CRC32 is computed across sizeof(ProgramT) and rejects
any slot that fails verification on load.
#include <ungula/core/util/queue.h>
ungula::core::util::Queue<int, 16> q;
void onIrq(int sample) {
q.push(sample); // false if full — caller decides
}
void drain() {
int v;
while (q.pop(v)) {
// process
}
}When to use this: producer/consumer between an ISR and a task, or any bounded buffer where heap allocation is forbidden.
#include <ungula/core/util/string_utils.h>
using ungula::core::util::string_t;
using namespace ungula::core::util::str;
string_t s = " Hello, World ";
trim(s); // "Hello, World"
to_lower(s); // "hello, world"
int n = replaceAll(s, "world", "ungula"); // 1
auto parts = tokenizeByDelimiter(s, ','); // ["hello", " ungula"]#include <ungula/core/util/crc32.h>
uint8_t payload[] = { 0x01, 0x02, 0x03, 0x04 };
uint32_t crc = ungula::core::util::crc32(payload, sizeof(payload));#include <ungula/core/system/system_reboot.h>
ungula::core::system::SystemControl::reboot(); // immediate
ungula::core::system::SystemControl::rebootAfterMs(2000); // give logs time to flush#include <ungula/core/system/health_monitor.h>
static ungula::core::system::HealthMonitor health;
void loop() {
ungula::core::system::HealthSample s;
if (health.sample(60000, s)) {
// emit s.free_heap, s.min_free_heap, s.delta, s.uptime_ms
}
}When to use this: long-running firmware. A monotonically falling
free_heap is the only early signal of an allocator leak.
#include <ungula/core/system/chip_info.h>
void printBootBanner() {
ungula::core::system::ChipInfo info = ungula::core::system::queryChipInfo();
// info.model, info.sdkVersion, info.cores, info.hasWifi, ...
}| Type | Header | Purpose |
|---|---|---|
ungula::core::time (namespace) |
ungula/core/time/time.h |
Free-function time/delay API |
ungula::core::time::ITimeProvider |
ungula/core/time/i_time_provider.h |
Pluggable wall-clock source |
ungula::core::time::tz::Timezone (enum) |
ungula/core/time/timezones.h |
Named UTC offset codes |
ungula::core::preferences::IPreferences |
ungula/core/preferences/i_preferences.h |
Abstract NVS interface |
ungula::core::preferences::Preferences |
ungula/core/preferences/preferences.h |
Platform-selected alias (concrete impl picked at compile time) |
ungula::core::preferences::Esp32Preferences |
ungula/core/preferences/platforms/esp32_preferences.h |
ESP-IDF NVS implementation (internal — use the Preferences alias from app code) |
ungula::core::preferences::programs::ProgramStore<T, N> |
ungula/core/preferences/tools/programs/program_store.h |
CRC-checked recipe slot table |
ungula::core::util::Queue<T, Capacity> |
ungula/core/util/queue.h |
Fixed-capacity circular queue |
ungula::core::system::SystemControl |
ungula/core/system/system_reboot.h |
Reboot helpers |
ungula::core::system::HealthMonitor / HealthSample |
ungula/core/system/health_monitor.h |
Heap sampler |
ungula::core::system::ChipInfo |
ungula/core/system/chip_info.h |
MCU identity struct |
ungula::core::util::string_t, string_view_t, vector_string_t |
ungula/core/util/string_types.h |
std-aliases used across all libraries |
Time aliases at namespace scope (ungula::core::time::tick_ms_t,
tick_us_t, duration_ms_t, duration_us_t, epoch_ms_t) — all
int64_t. Names exist to make intent visible at call sites; types are
interchangeable.
SystemControl is non-instantiable (constructors deleted, all members
static).
All free functions in the ungula::core::time namespace. There is no
class to instantiate — call them directly, or under a short alias
(namespace tc = ungula::core::time;).
tick_ms_t millis()/tick_us_t micros()Monotonic since boot. Bothint64_t— never wrap in any device lifetime.void delay(duration_ms_t)/delayMs/delayUsBlock the current task. ESP32 backend yields via FreeRTOS fordelayMs;delayUsbusy-waits.void delayUntilMs(tick_ms_t& ref, duration_ms_t period)Wait untilref + period, then advancerefbyperiod. Drift-free. Identical signature indelayUntilUs.void yield()— cooperative scheduler handoff (not justdelayMs(0)). On ESP32 it maps tovTaskDelay(1)(exactly one RTOS tick — the minimum blocking interval that guarantees IDLE can run and feed the watchdog). On host it maps tostd::this_thread::yield().epoch_ms_t now() / nowUtc() / nowLocal()Wall-clock if a provider is installed and valid; otherwise monotonic-since-boot.nowLocal()adds the configured offset.epoch_ms_t nowInTz(int32_t offsetSeconds)One-shot conversion without touching the stored offset.void setTimeProvider(ITimeProvider*)/clearTimeProvider()— install/remove the wall-clock source.void setTimezone(tz::Timezone)/setTimezoneOffsetSeconds(int32_t)/int32_t timezoneOffsetSeconds()size_t formatUtc(char*, size_t)/formatLocal(char*, size_t)/formatNow(char*, size_t, const char* strftimeFmt)All return0when no valid provider is installed (formatting a monotonic tick as a date would print "1970-…" otherwise). The 5-argformat(buf, size, fmt, epochSec, offset)fromtime_format.hhandles arbitrary stored timestamps.void setSyncTime(tick_ms_t remoteMs)/setSyncTimeUs/tick_ms_t syncNow()/tick_us_t syncNowUs()/int64_t syncOffset()/bool isSynced()/clearSync()Network-coordinator clock alignment.setSyncTimestores a single offset; reads add it on the hot path.
int64_t nowMs() const— provider-reported current wall-clock in ms.bool isValid() const— when false,time::now()falls back to local monotonic clock.
size_t format(char*, size_t, const char* fmt, time_t epochSec, int32_t offsetSec = 0)— pure formatter; returns 0 on invalid inputs.size_t formatIso8601(char*, size_t, time_t epochSec, int32_t offsetSec = 0)— convenience wrapper for"%Y-%m-%d %H:%M:%S".
Host-facing include: ungula/core/preferences/preferences.h.
-
IPreferenceslives atungula/core/preferences/i_preferences.h. -
Preferencesis a compile-time alias defined inungula/core/preferences/preferences.h. It resolves to the platform's concrete implementation (Esp32Preferenceson ESP-IDF). App code should always spell the type asPreferencesso that switching platforms requires no source changes. -
Esp32Preferenceslives atungula/core/preferences/platforms/esp32_preferences.hand is compiled only on ESP32 macro branches (ESP_PLATFORM,ARDUINO_ARCH_ESP32,ESP32). Don't reference it by name from app code — go through thePreferencesalias. -
bool begin(const char* ns)— open NVS namespace (≤ 15 chars). Must succeed before any get/put. -
void end()— close namespace; pair with eachbegin. -
bool putString / putBytes / putUInt8 / putUInt32— write. -
size_t getString(key, buf, bufSize)/getBytes(key, buf, bufSize)— return bytes read,0if missing. -
uint8_t / uint32_t getUInt8/32(key, defaultVal)— typed reads. -
bool remove(key),bool clear(),bool hasKey(key).
Esp32Preferences is the only concrete shipped implementation; it
compiles only on ESP32 macro branches and is reached through the
Preferences alias. STM32 branches are currently explicit
compile-time errors until a backend is added. Inject IPreferences&
into code that needs to be host-testable.
Esp32Preferences::hasKey() probes all key types used by this library
(blob, str, u8, u32) because ESP-IDF v5.1 has no
type-agnostic "exists" call.
Header path:
ungula/core/preferences/tools/programs/program_store.h.
ProgramT must be POD with char name[N] and bool valid fields.
explicit ProgramStore(IPreferences&)— borrow-only reference.void init(const char* defaultName, ProgramT (*createDefault)(const char*))Load all slots, validate CRCs, auto-create slot 0 fromcreateDefaultif no valid program exists.const ProgramT& getProgram(int index)— clamps to slot 0 on out-of-range input (does NOT return null).bool saveProgram(int index, const ProgramT&)— overwrites slot, forcesvalid = true, persists.bool deleteProgram(int index)— refuses to delete the last valid slot. ReassignsactiveIndex_if the active slot is deleted.int getActiveIndex()/setActiveIndex(int)— active slot is rejected if it points to an invalid program.int getLastUsedIndex()/setLastUsedIndex(int).int countValid(),static constexpr int maxPrograms().
CRC32 is computed across the raw bytes of ProgramT. Any change to
the struct layout (added field, reordering, padding shift) invalidates
existing slots silently — bump a project version field inside
ProgramT if you want explicit migration.
bool push(const T&)/bool push(T&&)—falsewhen full.bool pop(T& out)—falsewhen empty; moves intoout.bool peek(T& out) const— copy of the front element.size_t count(),bool isFull(),bool isEmpty(),constexpr size_t capacity(),void clear().
noexcept throughout. Storage is a T data_[Capacity] member — no
heap. Not thread-safe; wrap with a mutex or use one queue per
producer/consumer pair.
trim(string_t&), as_trim, to_lower, as_lower, to_upper,
as_upper, startsWith, replaceAll, escapeString, countChar,
countTokensByChar, tokenizeByDelimiter, cleanDelimitedValues,
string_indexOf, string_substring, string_equals,
num_to_string<T>, num_to_stringf<T>, skipWhitespace*,
trimWhitespace. All inline.
math::clamp— return clamped value, no side effects.math::clamp_v— modify the first argument by reference.math::clamp01 / lerp.temp::packCelsius(float celsius) -> int16_t— multiplies by 10 and rounds for wire transmission (0.1 °C resolution). Pairs withunpackCelsius.temp::unpackCelsius(int16_t packed) -> float— divides by 10 to restore Celsius from a packed wire value.temp::celsiusToFahrenheit / fahrenheitToCelsius(double and float variants),isValidTemperature(C)returns false for non-finite or values outside(-200°C, 1800°C).enums::toUint8<E>(),enums::fromUint8<E>(uint8_t)— generic enum conversion.
uint32_t ungula::core::util::crc32(const uint8_t* data, size_t len)uint32_t ungula::core::util::crc32_byte(uint32_t crc, uint8_t byte)— step function for streaming.
Polynomial 0xEDB88320 (zlib/Ethernet). Initial 0xFFFFFFFF,
final XOR 0xFFFFFFFF.
static void reboot()— immediate hard reset.static void rebootAfterMs(uint32_t)— sleeps then reboots.
Implementation status: ESP32-only (esp_restart). Non-ESP builds that
compile this translation unit fail by design (#error "Unsupported platform").
bool sample(uint32_t intervalMs, HealthSample& out)— fillsoutand returnstrueonly when at leastintervalMshave passed since the previous sample. The first call always returnstruewithdelta == 0.void reset()— clear baseline (use after a planned big alloc/free).
Returns a ChipInfo populated by the platform backend. Strings are
fixed-size in-struct buffers, so the value can be stored or copied
freely.
Implementation status: ESP32-only at the moment.
enum class Timezone : uint8_t— ~30 entries (UTC, GMT, CET, EST, JST, …). Disambiguated suffixes for clashes (CST_NA,CST_CN,IST_IN).constexpr int32_t tz::offsetSeconds(Timezone)— fixed offset from UTC. No DST awareness. Enum values are stable across versions (entries can be added at the end only).
- Time API (
ungula::core::time) — no init required formillis/micros/delay*. Fornow()to return wall-clock, install a provider withsetTimeProviderand ensureisValid()returnstruebefore the first call.setTimezoneis a one-shot configuration. - Preferences — every read/write must be sandwiched in
begin(ns)…end(). On ESP32 (Esp32Preferences), NVS init (nvs_flash_init) must happen before the firstbegin. Forgettingend()leaks an NVS handle. - ProgramStore — call
init()exactly once insetup(). Must run after the underlyingIPreferencesis usable.saveProgramanddeleteProgrampersist immediately; metadata (activeIndex,lastUsedIndex) is also persisted on every change. - HealthMonitor — single instance per project, sampled from
loop(). No init required. - Queue — value-initialized; no init required.
No object in this library uses new/delete after construction.
- No exceptions used in public APIs. Failures surface as
boolreturn values,0byte-count returns, or out-of-band sentinel values. IPreferences::getString/getBytes— return0when the key is missing or the buffer is too small.IPreferences::getUInt*— returndefaultValwhen the key is missing.ProgramStore::getProgram(index)— out-of-range index falls back to slot 0; checkgetProgram(idx).validto distinguish "real slot 0" from "fallback slot 0".Queue::push/pop/peek—falseon full/empty; never block.tc::format*— return0when no valid time provider is installed (caller must check before treating the buffer as printable).tz::offsetSeconds— undefined enum values return0(UTC).
- tc::millis / micros: ESP32 backend uses
esp_timer_get_time()— ISR-safe and lock-free. Host backend usesstd::chrono::steady_clock. - tc::delayMs: ESP32 yields via FreeRTOS
(
vTaskDelay-equivalent). Host backend usesstd::this_thread::sleep_for. Do NOT call from inside an ISR. - tc::delayUs: busy-wait on ESP32 — does not yield. Acceptable up to a few hundred microseconds.
- tc::yield: on ESP32 this is one RTOS tick (
vTaskDelay(1)), intentionally chosen so IDLE can run and watchdog feeding is not starved. On host it maps tostd::this_thread::yield(). - Module state in
ungula::core::time::detail::(provider_,sync_,timezoneOffsetSeconds_): not protected by a mutex. Configure once duringsetup()from a single task; readers can be concurrent. - Queue: not thread-safe. One producer + one consumer is fine
only if you accept the standard SPSC caveats; for ISR↔task use a
FreeRTOS queue or wrap with
portENTER_CRITICAL. - NVS access is mutex-protected by ESP-IDF, but
Preferencesis single-instance-per-namespace by design — do not share onePreferencesobject across tasks. - CRC32 functions are pure and reentrant.
- HealthMonitor::sample reads
esp_get_free_heap_size/esp_get_minimum_free_heap_sizeon ESP32 — cheap, but not zero-cost; the interval gate exists for that reason.
ungula/core/time/platforms/time_control_esp32.h,ungula/core/time/platforms/time_control_host.h— picked automatically bytime.h. Never#includedirectly.ungula/core/preferences/platforms/*in generic domain code — injectIPreferencesinstead. Use platform headers only in composition-root code.ungula::core::time::detail::SyncStateand the inline-static storage members (provider_,sync_,timezoneOffsetSeconds_) — implementation detail of the namespace; do not reach into them.ProgramStore::ProgramBlob,computeCrc,loadAllFromNvs,saveProgramToNvs,loadMetaFromNvs,saveMetaToNvs,programKey,NVS_NS = "programs"— internal layout. Do not reach into NVS for these keys directly.ungula/core/preferences/platforms/esp32_preferences.cpp— thenvs_flashglue. Use theIPreferencesinterface; never include this file from app code.
- Use the
ungula::core::timefree functions for all time and delay needs (e.g.ungula::core::time::delay(2000)). Never callmillis(),micros(),delay(), or platform timer APIs (esp_timer_*,vTaskDelay) directly from project code. - Use
delayUntilMsfor periodic loops;delayMsonly for fire-and- forget waits. - Treat
IPreferencesas the only persistence interface; depend on the abstract type, not on the platform aliasPreferencesor the concreteEsp32Preferences. Single-namespace per object. - For recipe-style state, prefer
ProgramStoreover hand-rolled NVS serialization — CRC and slot management are non-trivial to redo. - Use
Queue<T, N>instead ofstd::deque/ dynamic ring buffers for bounded buffers. No heap is allocated after construction. - Use
ungula::core::util::string_t/string_view_taliases in new code rather thanString(Arduino) or rawstd::string. Useungula::core::util::str::helpers; thestringFoofree functions inungula/core/util/types.hare a porting aid and should not be the destination for new code. - All time values flowing through public APIs are
int64_t. Don't truncate touint32_t"to save space" — past 49 days it wraps. - Don't include
ungula/core/time/platforms/*headers; lettime.hdispatch. - Don't read or write the
programsNVS namespace by hand — that'sProgramStore's territory. - Don't add logging into this library; surface state via return values or callbacks (project rule).
- The umbrella header
<ungula/core.h>is the Arduino-CLI discovery hook; in non-Arduino builds, including the specificungula/core/time/...,ungula/core/preferences/...,ungula/core/util/...,ungula/core/system/...header is equivalent.