Timing Components of the Flowduino ESPressio Development Platform.
High-resolution system, stopwatch, and RTC clock abstractions with a generic public time representation.
ESPressio is designed as a modular ecosystem of independently useful libraries, with required dependencies kept explicit and optional integrations introduced only when the corresponding functionality is selected.
For a complete overview of the relationships between ESPressio libraries—including required dependencies, opt-in dependencies, and the overall dependency hierarchy—see:
ESPressio Library Dependency Chart
In the dependency chart:
- Solid relationships represent required ESPressio library dependencies.
- Dashed relationships represent opt-in dependencies that are introduced only when the corresponding feature, integration, type, or header is used.
Version 2.2.0 adds first-class Observer notifications throughout meaningful Timing state transitions, using ESPressio Observable 3.x. The synchronization and generic TTime architecture introduced in 2.0/2.1 remains unchanged.
The library no longer defines one globally fixed ClockTime contract for every clock. Instead, clock interfaces and implementations are parameterized by their public TTime representation.
This allows an application to use ordinary ESPressio Unit time values, opt-in Serializable Unit time values, or another compatible/custom representation without duplicating the Timing algorithms.
Version 2.2.0 makes ESPressio Observable >= 3.0.0 a required dependency and adds observer interfaces for meaningful Timing operations.
Timing deliberately does not notify for ordinary reads such as GetTime(), GetResolution(), GetIsRunning(), or GetSynchronizationStatus(). Observer callbacks represent operations and state transitions rather than polling activity.
Observable dispatchers are internally owned by std::shared_ptr, matching ESPressio Observable's notification-lifetime contract. Timing clocks themselves do not need to inherit from ThreadSafeObservable.
Observer exceptions are contained by Timing and do not alter clock state or abort the Timing operation being observed.
Implement:
ISystemClockObserver<ClockTick>and register through any typed System Clock facade:
class ClockObserver :
public Timing::ISystemClockObserver<
Timing::ClockTick
> {
public:
void OnSystemClockSynchronized(
Timing::ClockTick before,
Timing::ClockTick after,
int64_t immediateDifference,
const Timing::ClockSynchronizationResult<
Timing::ClockTick
>& result,
const Timing::ClockSynchronizationStatus<
Timing::ClockTick
>& status
) override {
// ...
}
};
ClockObserver observer;
auto handle =
Timing::SystemClock<>::GetInstance().
RegisterObserver(&observer);Because the observable belongs to the shared SystemClockCore, registering through one SystemClock<TTime> facade observes operations performed through every facade sharing that core.
System Clock notifications include:
OnSystemClockTimeSet
OnSystemClockSynchronizationSampleAccepted
OnSystemClockSynchronizationSampleRejected
OnSystemClockSynchronized
OnSystemClockSynchronizationStateChanged
OnSystemClockSynchronizationReset
OnSystemClockSynchronizationConfigurationChanged
OnSystemClockCallbackScheduled
OnSystemClockCallbackScheduleFailed
OnSystemClockCallbackExecuted
OnSystemClockCallbackExecutionFailed
OnSystemClockCallbacksCleared
OnSystemClockSynchronized() receives:
clockBeforeNanoseconds
clockAfterNanoseconds
immediateDifferenceNanoseconds
ClockSynchronizationResult
ClockSynchronizationStatus
The immediateDifferenceNanoseconds value describes the actual immediate public System Clock change caused by processing that synchronization sample.
This distinction matters for SlewOnly:
measured offset = +2,000,000 ns
pending correction = +2,000,000 ns
clock before = 10,000,000 ns
clock after = 10,000,000 ns
immediate difference = 0 ns
The correction is scheduled for gradual slewing, so reporting a +2 ms instantaneous clock jump would be incorrect.
For StepIfUnsynchronized or StepAlways, where an immediate step is actually applied, the before/after difference reports that real step.
IStopwatchClockObserver<TTime, TTick> provides callbacks for:
OnStopwatchStarted
OnStopwatchStopped
OnStopwatchReset
OnStopwatchRestarted
OnStopwatchTimeSet
Callbacks include relevant elapsed-time values, running state, and before/after difference where applicable.
GPTimerClock delegates its observer registration to its internal Stopwatch implementation, so the same Stopwatch observer interface is used.
IRTCClockObserver<TTime, TTick> provides callbacks for:
OnRTCSynchronizationSucceeded
OnRTCSynchronizationFailed
OnRTCInterruptReceived
OnRTCInterruptTimeReceived
OnRTCTimeWriteSucceeded
OnRTCTimeWriteFailed
Successful synchronization and write callbacks include the previous and resulting RTC-clock values plus their signed nanosecond difference.
Registration returns the normal ESPressio Observable owning handle:
Observable::ObserverHandlePtrDestroying or explicitly unregistering the handle removes the Observer registration according to the standard ESPressio Observable lifecycle model.
Version 2.1.0 adds a transport-independent synchronization and clock-discipline layer to the shared SystemClockCore.
The design deliberately separates:
transport
|
| captures / carries four timestamps
v
IClockSynchronizationTarget
|
v
ClockDiscipline
|
+-- offset estimation
+-- round-trip-delay estimation
+-- sample validation/rejection
+-- phase filtering
+-- monotonic phase slewing
+-- residual drift estimation
+-- continuous rate correction
|
v
SystemClockCore
|
+-------------------------+
| |
v v
SystemClock<Time> SystemClock<SerializableTime>
Timing has no ESP-NOW, Wi-Fi, UDP, Ethernet, CAN, LoRa, MAC-address, peer, master-election, or packet-format concepts.
A transport library only needs to implement the message exchange required to capture four timestamps:
Local Remote
T1 request transmit -------->
<-------- T2 request receive
<-------- T3 response transmit
T4 response receive
and submit:
ClockSynchronizationSample<ClockTick> sample;
sample.LocalRequestTransmitTime = t1;
sample.RemoteRequestReceiveTime = t2;
sample.RemoteResponseTransmitTime = t3;
sample.LocalResponseReceiveTime = t4;
auto result =
SystemClock<>::GetInstance().
SubmitSynchronizationSample(
sample
);The standard two-way estimates are calculated inside Timing:
round-trip delay =
(T4 - T1) - (T3 - T2)
clock offset =
((T2 - T1) + (T3 - T4)) / 2
Transport implementations should depend on:
IClockSynchronizationTarget<ClockTick>rather than on a particular SystemClock<TTime> specialization.
The interface exposes:
GetSynchronizationTimestampNanoseconds()
SubmitSynchronizationSample(...)
GetSynchronizationStatus()
ConfigureSynchronization(...)
GetSynchronizationConfig()
ResetSynchronization()Because synchronization operates in the raw nanosecond clock domain, it is independent of the public Unit representation.
Synchronization status reports:
ClockSynchronizationState::Unsynchronized
ClockSynchronizationState::Acquiring
ClockSynchronizationState::Synchronizedalong with:
last measured offset
filtered offset
pending phase correction
applied correction
round-trip delay
estimated drift in ppm
accepted/rejected sample counts
last accepted sample time
A synchronized state can become stale when no accepted sample has arrived within the configured maximum age.
Malformed exchanges are rejected.
A configurable maximum round-trip delay also allows a transport to discard high-jitter/high-latency samples before they influence the clock.
The default adjustment mode is:
ClockSynchronizationAdjustmentMode::SlewOnlyA synchronization sample therefore does not abruptly rebase the System Clock.
Instead, the measured phase error is removed gradually at the configured maximum slew rate. This preserves monotonic progression and is the recommended mode while deadline-driven consumers such as Precision Threads are running.
The default maximum phase slew rate is:
500 ppm
and can be configured.
Large initial offsets can take a long time to remove with a conservative slew rate.
For startup/bootstrap, before monotonic deadline consumers begin operating, an application may explicitly request:
ClockSynchronizationAdjustmentMode::
StepIfUnsynchronizedThis permits the first accepted synchronization sample to perform an immediate phase correction. Later samples return to normal slewing.
An explicit:
ClockSynchronizationAdjustmentMode::
StepAlwaysis also available, but it may move the System Clock forwards or backwards and should not normally be used while monotonic consumers are active.
Once phase error is settled, successive accepted samples can estimate residual relative clock-rate error.
The estimate is expressed in parts per million:
EstimatedDriftPpm
and is applied continuously as a rate correction between synchronization exchanges.
Drift learning is deliberately suspended while a significant phase slew is in progress so intentional phase correction is not mistaken for oscillator drift.
The maximum learned drift correction, learning interval, filtering weights, and phase-learning threshold are configurable.
ClockSynchronizationConfig controls:
MaximumRoundTripDelayNanoseconds
MaximumSlewRatePpm
MaximumDriftCorrectionPpm
OffsetFilterWeight
DriftFilterWeight
DriftLearningPhaseThresholdNanoseconds
MinimumDriftLearningIntervalNanoseconds
SynchronizationToleranceNanoseconds
MinimumSamplesForSynchronizedState
MaximumSampleAgeNanoseconds
For example:
ClockSynchronizationConfig config;
config.MaximumRoundTripDelayNanoseconds =
10000000ULL; // 10 ms
config.MaximumSlewRatePpm =
500;
config.SynchronizationToleranceNanoseconds =
500000ULL; // 0.5 ms
SystemClock<>::GetInstance().
ConfigureSynchronization(
config
);Synchronization is owned by SystemClockCore, not by the typed facade.
Therefore:
SystemClock<DefaultClockTime>
SystemClock<MyCustomTime>
SystemClock<SerializableClockTime>all observe exactly the same disciplined System Clock on the device.
Calling:
SystemClock<TTime>::SetTime(...)is an explicit hard rebase.
It resets the current synchronization/discipline state because previously measured phase and drift estimates are no longer valid after the rebase.
A future ESP-NOW implementation can capture timestamps through the synchronization-target interface and submit complete samples without requiring any ESP-NOW-specific functionality inside ESPressio Timing.
The same Timing API can equally be used by UDP, Ethernet, CAN, serial, LoRa, or another transport.
See:
examples/ClockSynchronization
Timing separates three concerns:
Raw time source
|
v
ClockTick / TTick
|
| timing arithmetic
v
Clock implementation
|
v
TimeTraits<TTime>
|
v
Public TTime representation
Raw timing state remains numeric and independent of serialization.
The public representation is selected at compile time.
The default is:
using DefaultClockTime =
ESPressio::Units::Time<
uint64_t,
ESPressio::Units::Nano
>;Therefore:
StopwatchClock<> stopwatch;uses ordinary, non-Serializable ESPressio Units.
Timing itself does not depend on ESPressio Serializable.
Every clock and clock interface exposes its time representation as a template parameter:
IClock<TTime>
IClockSettable<TTime>
IStopwatchClock<TTime>
ISystemClock<TTime>
IRTCClock<TTime>
StopwatchClock<TTime, TLockPolicy, TTick>
SystemClock<TTime, TLockPolicy, TTick>
RTCClockBase<TTime, TLockPolicy, TTick>
GPTimerClock<TTime, TLockPolicy, TTick>For example:
using SerializableClockTime =
Units::SerializableNanoSeconds<
uint64_t
>;
StopwatchClock<
SerializableClockTime
> stopwatch;
SerializableClockTime elapsed =
stopwatch.GetTime();Only that consuming project needs to include the Serializable Unit header and depend upon ESPressio Serializable.
The clock itself is not serialized; its returned TimeType is serializable.
A monotonic clock contains runtime/hardware state such as source ticks and synchronization epochs. Persisting that internal state across restart is generally not meaningful.
For example, restoring an old monotonic _startTime after reboot would be incorrect.
If persistent stopwatch/clock state is required, it should be represented by an explicit snapshot DTO with defined restoration semantics rather than by serializing the live clock implementation.
SystemClock<TTime> is a typed facade over one non-templated-by-time
SystemClockCore<TLockPolicy, TTick> singleton.
This is important because the system clock represents one global timeline. Different public time representations must not create independent clocks.
For example:
using SerializableClockTime =
Units::SerializableNanoSeconds<uint64_t>;
auto& ordinary =
SystemClock<
DefaultClockTime
>::GetInstance();
auto& serializable =
SystemClock<
SerializableClockTime
>::GetInstance();These are two typed views over the same underlying SystemClockCore.
Therefore:
ordinary.SetTime(...);
auto serializedTime =
serializable.GetTime();observes the same clock state.
Callbacks are also stored by the shared core in raw nanosecond ticks. A callback
registered through one SystemClock<TTime> specialization can be serviced by
calling Update() through another specialization.
The tiny typed facade objects themselves are template specializations, but they contain no independent timeline or scheduler state.
This singleton-core/view distinction applies specifically to SystemClock.
User-created clocks such as StopwatchClock<TTime> continue to own their own raw
timing state because separate stopwatch instances are semantically meaningful.
TimeTraits<TTime> connects the public representation to Timing's internal nanosecond domain.
The default specialization supports types exposing:
value
orderOfMagnitude
and constructible from:
TTime(value, magnitude)This includes ordinary ESPressio Time types and their optional Serializable wrappers.
Unrelated representations can be integrated by explicitly specializing:
template<>
struct ESPressio::Timing::TimeTraits<MyTime> {
template<typename TTick>
static MyTime FromNanoseconds(
TTick nanoseconds,
TTick resolution
);
template<typename TTick>
static TTick ToNanoseconds(
const MyTime& time
);
};The storage/arithmetic type is separately configurable:
StopwatchClock<
MyTime,
ThreadSafeLockPolicy,
uint64_t
>The default is:
ClockTickwhich is currently uint64_t.
Serialization properties of TTime therefore do not leak into the timing algorithm or raw clock storage.
Interfaces are now generic:
IClock<DefaultClockTime>
IClock<MyApplicationTime>These are intentionally distinct C++ contracts.
Generic consuming code should use the clock's nested type:
template<typename TClock>
void ReadClock(
TClock& clock
) {
typename TClock::TimeType now =
clock.GetTime();
}rather than assuming a global ClockTime.
The lock policy remains a compile-time parameter.
The normal default is:
ThreadSafeLockPolicyFor single-context applications:
SingleThreadedStopwatchClock<MyTime>
SingleThreadedSystemClock<MyTime>
SingleThreadedRTCClockBase<MyTime>use NoLockPolicy.
Ordinary Timing project:
Application
|
+-- ESPressio-Timing
|
+-- ESPressio-Units
No ESPressio Serializable dependency is required.
Application selecting a Serializable Unit as TTime:
Application
|
+-- ESPressio-Timing
|
+-- ESPressio-Units
| |
| +-- optional *_Serializable.hpp
|
+-- ESPressio-Serializable
Timing remains unaware of ESPressio Serializable.
Version 1.x:
StopwatchClock stopwatch(true);
ClockTime elapsed =
stopwatch.GetTime();
const IClock& clock =
stopwatch;Version 2.x:
StopwatchClock<> stopwatch(true);
DefaultClockTime elapsed =
stopwatch.GetTime();
const IClock<
DefaultClockTime
>& clock =
stopwatch;Generic code should preferably use:
typename TClock::TimeTypeinstead of naming DefaultClockTime.
lib_deps =
flowduino/ESPressio-Timing@^2.1.0A project selecting Serializable Units additionally declares the appropriate ESPressio Units version and ESPressio Serializable dependency.