Pulse is a millisecond timer library for ESP32. It schedules short runtime timeouts, intervals, and countdowns from one internal FreeRTOS task using monotonic uptime, so NTP sync, timezone changes, DST, and wall-clock corrections do not affect timer deadlines.
Pulse v0.2.0 uses Strata for memory placement and owned FreeRTOS storage.
- Monotonic uptime timers — timeouts, intervals, and countdowns use ESP-IDF runtime time.
- One scheduler task — callbacks execute serially from one internal Pulse task.
- Bounded scheduling storage — timer counts and command-queue capacity are configured up front.
- Shared memory policy —
Strata::MemoryPolicycontrols general Pulse-owned allocation and task-stack placement. - Strata-owned FreeRTOS storage — scheduler stack/TCB, recursive mutex storage, and command queue storage use static Strata ownership.
- Lifecycle-safe shutdown — scheduler task deletion happens from another FreeRTOS task context after quiescence.
- Generation-safe reuse — stale
end()waiters cannot affect a newer Pulse lifecycle. - Runtime diagnostics — timer/queue counters plus requested placement and observed memory region.
Pulse v0.2.0 requires Strata v0.1.2.
[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
lib_deps =
https://github.com/ZekStack/pulse.git#v0.2.0
build_flags =
-std=gnu++20
build_unflags =
-std=gnu++11Pulse's library.json pins Strata, so PlatformIO resolves it transitively.
Pulse and Strata are not published to Arduino Library Manager yet. Install both repositories into the Arduino libraries directory:
Arduino/libraries/Strata
Arduino/libraries/Pulse
#include <Arduino.h>
#include <Pulse.h>
Pulse pulse;
PulseTimerId intervalId = 0;
void setup() {
Serial.begin(115200);
PulseResult initResult = pulse.init();
if (!initResult) {
Serial.println(initResult.message);
return;
}
pulse.setTimeout([]() {
Serial.println("after one second");
}, 1000);
PulseTimerResult interval = pulse.setInterval([]() {
Serial.println("every second");
}, 1000);
if (interval) {
intervalId = interval.id;
}
}
void loop() {
delay(1000);
}Pulse uses the same memory-policy shape as other Strata-backed ZekStack libraries:
PulseConfig config;
config.memory.allocation = Strata::Placement::PreferExternal;
config.memory.taskStack = Strata::Placement::PreferExternal;
PulseResult result = pulse.init(config);memory.allocation controls movable Pulse-owned storage, including timer records, shared_ptr control blocks, bounded timer-registry backing, and command-queue item storage. memory.taskStack controls the scheduler task stack.
Defaults:
allocation = Strata::Placement::Default
taskStack = Strata::Placement::PreferExternal
PreferExternal falls back to internal memory. RequireExternal fails instead of consuming internal memory when external memory is unavailable. Strata keeps FreeRTOS control blocks internal.
Requested memory policy and observed memory location are intentionally separate:
PulseDiag diag = pulse.getDiagnostics();
Serial.printf(
"stack=%s/%s queue=%s/%s\n",
Strata::toString(diag.requestedStackPlacement),
Strata::toString(diag.stackRegion),
Strata::toString(diag.commandQueueStoragePlacement),
Strata::toString(diag.commandQueueStorageRegion)
);requestedStackPlacement is what the application asked for. stackRegion is where Strata actually placed the scheduler stack. With PreferExternal, those can differ when Pulse falls back to internal memory.
Important
Pulse callbacks run from the internal Pulse task. Keep callbacks short and offload long-running work to Worker.
setInterval()uses delay-after-callback timing and does not catch up missed ticks.- Countdown callbacks first run after
tickMs; the final callback is guaranteed withisFinished=true. clear(),pause(),resume(), andrestart()enqueue nonblocking controls.- Callback-generated controls are processed before another already-due timer is selected.
- A timeout and final countdown are removed before their terminal callback; controls for that timer return
TimerNotFound. - Calling
end()from a Pulse callback returnsPulseStatus::Busybecause the scheduler cannot wait for itself. - If
end(timeoutMs)returnsTimeout, shutdown remains requested; callend()again to continue waiting. - Destroying Pulse from another task waits until the scheduler quiesces and is externally reaped.
- Destroying Pulse from its own callback remains supported; final Strata task deletion is deferred to the FreeRTOS timer-service task.
- Zero-millisecond timer values are rejected.
- Stack sizes are FreeRTOS byte sizes on ESP32 and must be at least 1024 bytes and aligned to
sizeof(StackType_t).
Each successful init() starts a new lifecycle generation. The scheduler runs until shutdown is requested, releases timer and queue resources, then reaches a quiescent handoff. end() claims that generation's reap, calls Strata::FreeRTOS::Task::reset() from an external task context, and only then makes Pulse reusable.
This external deletion is required because Strata owns the scheduler's static task stack and task control block. Concurrent and stale end() waiters are generation-checked before they can reap anything.
Pulse routes storage it directly owns through Strata. That includes the implementation object, timer registry backing, timer records, shared ownership control blocks created by Pulse, scheduler task storage, recursive mutex control storage, and command queue storage.
std::function remains the public callback surface. Allocation performed by the standard library or caller while constructing/copying user callback captures is outside Pulse's direct ownership boundary. The CI no-exceptions build proves compilation compatibility; it does not claim every standard-library allocation failure is recoverable.
PulseConfig config;
config.memory.allocation = Strata::Placement::Default;
config.memory.taskStack = Strata::Placement::PreferExternal;
config.stackSizeBytes = 4096;
config.priority = 1;
config.coreId = tskNO_AFFINITY;
config.maxTimeouts = 16;
config.maxIntervals = 16;
config.maxCountdowns = 8;
config.commandQueueSize = 20;
config.taskName = "pulse-task";See docs/configuration.md for details.
| Example | Description |
|---|---|
Basic |
Minimal init, timeout, interval, and clear. |
Countdown |
Countdown ticks and final completion callback. |
PauseResumeRestart |
Timer pause, resume, restart, and state checks. |
ConfigAndLimits |
Strata memory policy, task settings, queue, and timer limits. |
Diagnostics |
Runtime counters plus Strata placement and region diagnostics. |
BindableCallbacks |
std::bind with private class methods. |
| Document | Description |
|---|---|
docs/getting-started.md |
Dependency setup, first timers, and memory policy. |
docs/configuration.md |
Memory placement, task settings, limits, and queue sizing. |
docs/api.md |
Public API, lifecycle, diagnostics, and v0.1.0 migration mapping. |
docs/examples.md |
Included examples. |
docs/troubleshooting.md |
Lifecycle, placement, and common runtime issues. |
Pulse v0.2.0 intentionally adopts the shared Strata vocabulary instead of retaining Pulse-specific aliases:
| v0.1.0 | v0.2.0 |
|---|---|
PulseStackType::Auto |
Strata::Placement::PreferExternal |
PulseStackType::Internal |
Strata::Placement::Internal |
PulseStackType::Psram |
Strata::Placement::RequireExternal |
config.stackType |
config.memory.taskStack |
| no general allocation policy | config.memory.allocation |
The default v0.2.0 task-stack policy preserves the old Auto behavior.
| Item | Support |
|---|---|
| Framework | Arduino ESP32 |
| Platform | espressif32 |
| Language | C++20 |
| Strata | v0.1.2 |
| PSRAM | Optional through Strata placement policy |
| Exceptions | No explicit throws; callback/STL allocation follows toolchain behavior |
| CI targets | ESP32, ESP32-S3, ESP32-C3, ESP32-P4 |
MIT — see LICENSE.md.
Part of the ZekStack ESP32 library stack. Pulse follows the shared Strata memory-policy contract used by higher-level ZekStack libraries.