Event-Driven Observer Pattern components of the Flowduino ESPressio Development Platform.
ESPressio Event provides a foundation for designing, structuring, and implementing ESP32 applications using Event-Driven Development (EDD): asynchronous typed Event routing, Event-aware Threads, bounded receiver queues, listener/observer registration, priority dispatch, high-resolution Event timing, optional Observer-to-Event bridges, and optional transport-neutral Serializable Event routing.
The latest stable version is 5.4.0.
ESPressio Event targets the ESP32 family using Arduino-ESP32.
The implementation uses ESP-IDF FreeRTOS facilities, C++ RTTI,
std::shared_mutex, and Arduino APIs through its dependency stack.
RTTI must be enabled:
build_unflags =
-fno-rttiCompatibility should still be verified by compiling for the intended ESP32 board/core/toolchain combination.
The ESPressio Development Platform is a collection of discrete, sometimes interconnected component libraries developed around a common design ethos.
The principal objectives are:
- Light-weight --- components should strive to minimise memory consumption and operational overhead without sacrificing clarity or correctness.
- Ease of Use --- ESPressio components frequently provide developer-friendly, strongly typed abstractions over lower-level procedural facilities.
- Object-Oriented --- a type for everything, and everything in a type.
- SOLID --- to the maximum extent practical within C++, Arduino,
FreeRTOS, and microcontroller constraints:
- Single Responsibility Principle (SRP) --- keep components small and focused.
- Open/Closed Principle (OCP) --- prefer extension without modification.
- Liskov Substitution Principle (LSP) --- derived implementations should remain substitutable for their abstractions.
- Interface Segregation Principle (ISP) --- prefer focused, client-specific interfaces.
- Dependency Inversion Principle (DIP) --- depend upon abstractions rather than concrete implementations.
ESPressio Event follows those principles by making the Event itself the shared data contract while keeping Event producers and Event consumers independent of one another.
ESPressio and its component libraries are licensed under the Apache License 2.0.
See LICENSE for details.
The Event API resides beneath:
ESPressio::EventESPressio 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 required and opt-in relationships, see:
ESPressio Library Dependency Chart
In the dependency chart:
- Solid relationships represent required ESPressio dependencies.
- Dashed relationships represent opt-in dependencies introduced only when the corresponding feature, integration, type, or header is used.
Normal Event applications require only the dependencies declared by the library:
lib_deps =
flowduino/ESPressio-Event@^5.4.0The mandatory dependency graph is:
ESPressio Event 5.4
|
+-- ESPressio Threads >= 3.1.0
|
+-- ESPressio Observable >= 3.0.0
|
+-- ESPressio Timing >= 2.2.0
ESPressio Units is available through the Timing dependency stack.
ESPressio Serializable is not a mandatory Event dependency. It is required only when an application explicitly opts into Serializable Events or Event Transport.
Event-Driven Observer Pattern is a means of fully decoupling distinct areas of application functionality.
Instead of one module directly invoking another, the producer dispatches
an Event containing context-specific payload data. Independent
consumers register interest in that Event type and react when it is
dispatched.
Conceptually:
Producer
|
| dispatches
v
Event<T>
|
v
EventManager
|
+----------+----------+
| | |
v v v
Listener A Listener B Listener C
The producer does not need to know whether zero, one, or many listeners exist.
An Event is therefore best thought of as a data contract between independently implemented pieces of functionality. The producer populates the contract; interested consumers read it.
A central EventManager coordinates delivery to interested Event
receivers. Any Event type can consequently be dispatched from anywhere
in the application without the dispatching code acquiring direct
relationships with its consumers.
This is a natural asynchronous counterpart to the synchronous Observer Pattern provided by ESPressio Observable.
Event-Driven Observer Pattern does not imply a globally defined order of execution between independent listeners.
If an operation requires a strict synchronous ordering relationship, conventional Observer callbacks or direct sequencing may be more appropriate for that particular relationship.
Use the mechanism that correctly expresses the semantics of the operation rather than forcing every interaction through Events.
Event dispatch is fundamentally asynchronous.
When an Event is queued or stacked, the execution chain that dispatched it continues without waiting for every interested receiver to finish processing it.
In practical terms, Events are fire and forget.
This is one of the primary reasons Event-driven design is useful for separating application concerns: the producer neither knows nor waits for the implementation details of its consumers.
Asynchronous operation does not prevent request/result workflows.
A listener processing one Event may dispatch a second, reciprocal Event containing the result of its work:
RequestEvent
|
v
Worker
|
v
ResultEvent
The original requester can itself listen for the result Event without introducing a direct call relationship between the two modules.
Not every notification should become an Event.
ESPressio deliberately supports both models:
ESPressio Observable
-> synchronous observation
-> useful when the caller/operation and notification are tightly related
ESPressio Event
-> asynchronous observation
-> useful when producers and consumers should remain independently scheduled
The opt-in Timing and Threads Event Bridges demonstrate this distinction directly: the originating libraries expose synchronous Observer notifications, while ESPressio Event can optionally convert those notifications into asynchronous Events.
IEvent is the non-templated, type-erased Event interface used by the
routing infrastructure.
Routing, receivers, listeners, ownership, and Event Manager infrastructure operate using:
IEvent*Lifecycle timing is available to type-erased infrastructure as raw nanoseconds:
uint64_t GetDispatchTimeNanoseconds() const;
uint64_t GetTimeSinceDispatchNanoseconds() const;This keeps the Event engine independent of the public Unit representation chosen by a concrete Event.
The normal Event base is:
template<
typename TTime = Timing::DefaultClockTime
>
class Event;Ordinary Event code therefore uses:
class MyEvent :
public ESPressio::Event::Event<> {
};An Event normally contains the immutable contextual payload needed by its consumers.
For example:
class TemperatureChangeEvent final :
public ESPressio::Event::Event<> {
private:
const int _previousTemperature;
const int _newTemperature;
public:
TemperatureChangeEvent(
int previousTemperature,
int newTemperature
) :
_previousTemperature(previousTemperature),
_newTemperature(newTemperature) {
}
int GetPreviousTemperature() const {
return _previousTemperature;
}
int GetNewTemperature() const {
return _newTemperature;
}
};The producer and consumers need to agree only on this Event contract.
A different Timing-compatible public representation may be selected:
using MyTime = SomeCompatibleTimeType;
class MyEvent :
public ESPressio::Event::Event<MyTime> {
};The strongly typed lifecycle API is then:
TTime GetDispatchTime() const;
TTime GetTimeSinceDispatch() const;Internally, Event lifecycle timing remains stored as raw nanoseconds and is converted through:
Timing::TimeTraits<TTime>The Event System Clock uses:
Timing::SystemClock<TTime>::GetInstance()Timing 2.x typed System Clock facades share one underlying global System Clock core.
An Event can be dispatched from anywhere.
Queue dispatch provides FIFO semantics:
(new TemperatureChangeEvent(
previousTemperature,
temperature
))->Queue();Stack dispatch provides LIFO semantics:
(new TemperatureChangeEvent(
previousTemperature,
temperature
))->Stack();The first dispatch records the Event's dispatch time. Redispatching the same Event does not replace that original timestamp.
Events may be dispatched using the supported Event priority levels. Priority participates in the Event receiver's normal dispatch ordering.
When no explicit priority is supplied, normal priority is used.
Event receiver queues are bounded to 64 pending Events by default.
Define:
ESPRESSIO_EVENT_DEFAULT_MAX_PENDING_EVENT_COUNTbefore including the library to choose another embedded-safe default, or explicitly configure a receiver maximum of zero when an unbounded queue is genuinely required.
Queue diagnostics include:
GetPendingEventCount()
GetPeakPendingEventCount()
GetRejectedEventCount()
GetDroppedEventCount()
ResetEventQueueStatistics()Drained Event collections have a separate retained-capacity policy:
EventCollectionCapacityPolicy::Retain
EventCollectionCapacityPolicy::ShrinkWhenUnderutilized
EventCollectionCapacityPolicy::ReleaseAfterDrainFor example:
thread.SetEventCollectionCapacityPolicy(
Event::EventCollectionCapacityPolicy::ShrinkWhenUnderutilized
);
thread.SetMinimumRetainedEventCapacity(4);
thread.SetEventCapacityExcessFactor(2);EventThread is the principal Event-processing Thread type.
It is built on ESPressio Threads, but differs from a conventional continuously looping Thread.
An EventThread can remain efficiently suspended until an Event
matching one of its registered listeners arrives. The relevant Event
callback is then executed on the Event Thread's own task. Once pending
Events have been processed, the Thread can return to waiting without
consuming CPU cycles merely to poll for work.
Remember:
An Event can be created and dispatched from anywhere. Only Event-capable receiver types need to participate in Event processing.
PrecisionEventThread combines Event processing with the deterministic
iteration model provided by ESPressio Threads:
template<
typename TTime = Timing::DefaultClockTime,
typename TRepresentationTraits =
Threads::PrecisionThreadTraits<TTime>
>
class PrecisionEventThread;Ordinary usage:
class ControlThread :
public ESPressio::Event::PrecisionEventThread<> {
};Event processing order can be configured using:
PrecisionEventProcessOrder::EventsBeforeIteration
PrecisionEventProcessOrder::EventsAfterIterationArrival behavior can be configured using:
PrecisionEventArrivalPolicy::ProcessOnNextIteration
PrecisionEventArrivalPolicy::TriggerImmediateIteration
PrecisionEventArrivalPolicy::ProcessImmediatelyProcessImmediately still means asynchronously as soon as the owning
task is scheduled; Event handlers are not moved onto the dispatching
task.
Listeners express interest in a specific Event type and invoke application code when a matching Event is delivered.
A listener can be registered on an Event-capable receiver:
Event::EventListenerHandlePtr handle =
eventThread.RegisterListener<
TemperatureChangeEvent
>(
[](TemperatureChangeEvent* event,
Event::EventDispatchMethod dispatchMethod,
Event::EventPriority priority) {
// React to the Event.
}
);The returned handle owns the registration lifetime. Destroying or unregistering the handle removes the listener.
Listeners can therefore be enabled or disabled dynamically without adding a boolean check to every callback invocation.
Listener interest policies allow a receiver to reject Events that are not relevant to it.
EventListenerInterest::YoungerThan uses a typed EventTime threshold,
converted through Timing traits and compared with the Event's raw
nanosecond age.
Custom interest logic can also be used where application-specific filtering is required.
Callback-based listeners can alternatively be represented as typed Observers.
Implement:
IEventObserver<EventType>and register it with the Event receiver:
class TemperatureObserver :
public Event::IEventObserver<
TemperatureChangeEvent
> {
public:
void OnEvent(
TemperatureChangeEvent* event,
Event::EventDispatchMethod dispatchMethod,
Event::EventPriority priority
) override {
// React to the Event.
}
};Registration uses the same asynchronous Event pipeline:
TemperatureObserver observer;
Event::EventListenerHandlePtr observerHandle =
eventThread.RegisterObserver<
TemperatureChangeEvent
>(
&observer
);IEventObserver<EventType> derives from ESPressio Observable's
IObserver contract.
Observers are non-owning: the Observer instance must remain alive until its registration handle is unregistered or destroyed.
An Observer may implement multiple IEventObserver<EventType>
interfaces and register each independently.
EventManager is the central asynchronous dispatch infrastructure.
It coordinates the transit of Events from dispatching code to the interested Event receivers without requiring the producer to know those receivers.
The Event Manager is process-lifetime FreeRTOS infrastructure. Its task, semaphore, dispatcher, and per-Event-type routing structures intentionally remain allocated until device shutdown; this is fixed infrastructure rather than leaked per-dispatch Event ownership.
The complete Event type topology is available here:
The editable vector source is available at:
diagrams/espressio-event-type-topology.svg
The following example illustrates the core purpose of the library.
We want three independent pieces of functionality:
Thermometer
-> reads a sensor
TemperatureSerialLogger
-> reports changes to Serial
TemperatureDisplay
-> updates a physical display
None should need a direct reference to either of the others.
Their only shared contract is:
TemperatureChangeEvent#pragma once
#include <ESPressio_Event.hpp>
class TemperatureChangeEvent final :
public ESPressio::Event::Event<> {
private:
const int _previousTemperature;
const int _newTemperature;
public:
TemperatureChangeEvent(
int previousTemperature,
int newTemperature
) :
_previousTemperature(previousTemperature),
_newTemperature(newTemperature) {
}
int GetPreviousTemperature() const {
return _previousTemperature;
}
int GetNewTemperature() const {
return _newTemperature;
}
};Both values are supplied through the constructor and no setters are exposed. The Event therefore describes one complete temperature transition.
#pragma once
#include <Arduino.h>
#include <ESPressio_EventThread.hpp>
#include "TemperatureChangeEvent.hpp"
class TemperatureSerialLogger final :
public ESPressio::Event::EventThread {
private:
ESPressio::Event::EventListenerHandlePtr
_temperatureChangeListener =
RegisterListener<
TemperatureChangeEvent
>(
[](TemperatureChangeEvent* event,
ESPressio::Event::EventDispatchMethod,
ESPressio::Event::EventPriority) {
const int change =
event->GetNewTemperature() -
event->GetPreviousTemperature();
const char* direction =
change >= 0 ? "UP" : "DOWN";
const int magnitude =
change >= 0 ? change : -change;
Serial.printf(
"Temperature is %s by %d degrees "
"(from %d to %d).\n",
direction,
magnitude,
event->GetPreviousTemperature(),
event->GetNewTemperature()
);
}
);
public:
TemperatureSerialLogger() :
EventThread(false) {
}
};The logger contains no sensor code and no display code. It understands only the Event contract.
A display implementation can independently register for exactly the same Event:
#pragma once
#include <ESPressio_EventThread.hpp>
#include "TemperatureChangeEvent.hpp"
class TemperatureDisplay final :
public ESPressio::Event::EventThread {
private:
ESPressio::Event::EventListenerHandlePtr
_temperatureChangeListener =
RegisterListener<
TemperatureChangeEvent
>(
[this](TemperatureChangeEvent* event,
ESPressio::Event::EventDispatchMethod,
ESPressio::Event::EventPriority) {
DisplayTemperature(
event->GetNewTemperature()
);
}
);
void DisplayTemperature(int temperature) {
// Update the application's physical display.
}
public:
TemperatureDisplay() :
EventThread(false) {
}
};Again, there is no relationship to the logger or sensor implementation.
The sensor-side code only needs to dispatch the Event:
#pragma once
#include "TemperatureChangeEvent.hpp"
class Thermometer {
private:
int _temperature = 0;
int ReadTemperatureSensor() {
// Replace with the appropriate sensor implementation.
return _temperature;
}
public:
void UpdateTemperature() {
const int temperature =
ReadTemperatureSensor();
if (temperature == _temperature) {
return;
}
const int previousTemperature =
_temperature;
_temperature = temperature;
(new TemperatureChangeEvent(
previousTemperature,
temperature
))->Queue();
}
};The significant line is simply:
(new TemperatureChangeEvent(
previousTemperature,
temperature
))->Queue();The Thermometer does not know which modules---if any---will process the Event.
The editable vector source is available at:
diagrams/thermometer-example-topology.svg
The important result is:
TemperatureSerialLogger
has no direct relationship with
TemperatureDisplay or Thermometer
TemperatureDisplay
has no direct relationship with
TemperatureSerialLogger or Thermometer
Thermometer
has no direct relationship with
TemperatureSerialLogger or TemperatureDisplay
Yet both consumers react independently whenever Thermometer dispatches
a TemperatureChangeEvent.
Additional consumers can be introduced later without modifying the existing producer or consumers.
That is the central architectural advantage of ESPressio Event.
#include <ESPressio_Event.hpp>
#include <ESPressio_PrecisionEventThread.hpp>
using namespace ESPressio;
class SetpointEvent final :
public Event::Event<> {
private:
const int _setpoint;
public:
explicit SetpointEvent(int setpoint) :
_setpoint(setpoint) {
}
int GetSetpoint() const {
return _setpoint;
}
};
class ControlThread final :
public Event::PrecisionEventThread<> {
private:
int _setpoint = 0;
protected:
void OnIteration(
IterationTime delta,
IterationTime startTime,
Threads::SkippedIterationCount skippedIterations
) override {
(void)delta;
(void)startTime;
(void)skippedIterations;
// Perform deterministic control work.
}
public:
void ApplySetpoint(SetpointEvent* event) {
_setpoint = event->GetSetpoint();
}
};See:
examples/PrecisionEventThread
for the complete repository example.
Serializable Event support is opt-in:
#include <ESPressio_Event_Serializable.hpp>or:
#include <ESPressio_SerializableEvent.hpp>Neither is imported by the normal ESPressio_Event.hpp path.
A consuming application using Serializable Events declares ESPressio Serializable explicitly:
lib_deps =
flowduino/ESPressio-Event@^5.4.0
flowduino/ESPressio-Serializable@^0.9.0The optional base is:
template<
typename TDerived,
typename TTime =
Units::SerializableNanoSeconds<uint64_t>
>
class SerializableEvent;Example:
#include <ESPressio_Event_Serializable.hpp>
class TemperatureEvent final :
public ESPressio::Event::SerializableEvent<
TemperatureEvent
> {
private:
float _temperature = 0.0f;
uint32_t _sensorId = 0;
public:
ESPRESSIO_SERIALIZABLE_TYPE(
TemperatureEvent
)
ESPRESSIO_SERIALIZABLE_SCHEMA_VERSION(1)
ESPRESSIO_SERIALIZABLE_PROPERTIES(
ESPRESSIO_PROPERTY(
"temperature",
_temperature
),
ESPRESSIO_PROPERTY(
"sensorId",
_sensorId
)
)
};The derived payload can use the ESPressio Serializable feature set, including JSON, CBOR, Binary, schema versions, aliases, validation, migrations, streaming, and schema introspection.
Local Event runtime state is deliberately not Event payload:
reference count
local dispatch status
local System Clock dispatch timestamp
routing/listener state
A deserialized Serializable Event is a new local Event which can subsequently be queued or stacked normally.
Transport metadata belongs either explicitly in the Event payload or in the transport envelope.
This:
#include <ESPressio_Event.hpp>
class ButtonEvent :
public ESPressio::Event::Event<> {
};does not require ESPressio Serializable.
ESPressio Event can optionally convert synchronous Observer notifications from other ESPressio libraries into asynchronous Events.
The important dependency direction is:
Timing / Threads
expose synchronous Observer APIs
Event
optionally consumes those APIs
and emits asynchronous Events
Timing and Threads therefore do not need to depend upward on Event.
Timing 2.2 System Clock Observer notifications can be bridged using:
#include <ESPressio_SystemClockEventBridge.hpp>
Event::SystemClockEventBridge::
GetInstance().
Initialize();Timing Events are grouped under:
src/timing-events/
with:
#include <ESPressio_TimingEvents.hpp>Every ISystemClockObserver callback has a corresponding strongly typed
Event carrying the relevant callback snapshot, including synchronization
before/after values, clock difference, result/status, state changes,
configuration changes, and callback lifecycle information.
Serializable counterparts are separately opt-in:
#include <ESPressio_TimingEvents_Serializable.hpp>
#include <ESPressio_SystemClockEventBridge_Serializable.hpp>
Event::SerializableSystemClockEventBridge::
GetInstance().
Initialize();Both bridges remain dormant until explicitly initialized.
The singleton infrastructure Observer APIs in ESPressio Threads can similarly be bridged through:
ThreadManagerEventBridge
ThreadGarbageCollectorEventBridge
ThreadTerminationDispatcherEventBridgeand the opt-in Serializable counterparts:
SerializableThreadManagerEventBridge
SerializableThreadGarbageCollectorEventBridge
SerializableThreadTerminationDispatcherEventBridgeThread Events are grouped beneath:
src/thread-events/
with:
#include <ESPressio_ThreadEvents.hpp>
#include <ESPressio_ThreadEvents_Serializable.hpp>Bridge batch headers are:
#include <ESPressio_ThreadEventBridges.hpp>
#include <ESPressio_ThreadEventBridges_Serializable.hpp>The ordinary bridges do not require ESPressio Serializable.
Version 5.3 introduced a transport-neutral bidirectional routing layer for Serializable Events, extended in 5.4 with per-transport routing policy.
The subsystem is opt-in:
#include <ESPressio_EventTransport.hpp>Concrete transport implementations live outside ESPressio Event and implement:
IEventTransportESPressio Event owns:
Event type registration
Binary serialization/deserialization
stable wire identities
inbound/outbound policy
dispatch metadata
pending-work lifecycle
remote-to-local loop prevention
per-transport routing policy
Every transported Event declares a stable wire identity:
ESPRESSIO_EVENT_TRANSPORT_TYPE(
MySerializableEvent,
"com.example.my-event.v1"
)The stable name is converted to the wire identifier; RTTI implementation names are not used as protocol contracts.
Global/default policy:
manager.RegisterInboundEvent<RemoteCommandEvent>();
manager.RegisterOutboundEvent<TelemetryEvent>();
manager.RegisterBidirectionalEvent<SharedStateEvent>();C++17 bulk forms are available:
manager.RegisterOutboundEvents<
TelemetryEvent,
DiagnosticsEvent,
BatteryStatusEvent
>();A concrete transport can have its own override:
manager.RegisterOutboundEvent<
TelemetryEvent
>(
&udpTransport
);
manager.RegisterBidirectionalEvent<
SharedStateEvent
>(
&espNowTransport
);The model is:
global/default direction
+
optional per-transport override
A transport-specific override is authoritative for that transport,
including EventTransportDirection::None.
Multiple transports can be registered simultaneously:
manager.RegisterTransport(
&espNowTransport
);
manager.RegisterTransport(
&udpTransport
);The same Event can therefore have different routing policy on different physical/network mechanisms.
Unregistration mirrors registration and includes bulk forms.
Pending work can be independently configured to complete or be discarded using:
EventTransportUnregistrationOptionsTransport-scoped unregistration affects only matching work for the selected transport.
EventDispatchContext records whether an Event originated locally or
remotely, together with transport metadata such as message ID and hop
count.
Remotely received Events are dispatched normally to local listeners but are not retransmitted by default, preventing simple transport loops such as:
A -> B -> A -> B ...
The version-1 transport envelope preserves:
stable Event type ID
Serializable schema version
message ID
dispatch method
priority
hop count
payload length
The payload uses ESPressio Serializable BinaryArchive.
EventTransportManager exposes an IEventTransportManagerObserver
surface for transport registration, type/route lifecycle, and
inbound/outbound diagnostics.
4.x:
class MyEvent :
public Event::Event {
};5.x:
class MyEvent :
public Event::Event<> {
};4.x:
class MyThread :
public Event::PrecisionEventThread {
};5.x:
class MyThread :
public Event::PrecisionEventThread<> {
};Timing 1.x's fixed ClockTime is no longer the Event representation
contract.
The default representation is:
Timing::DefaultClockTimeGeneric code should prefer:
typename MyEvent::TimeTypeIEvent infrastructure uses:
GetDispatchTimeNanoseconds()
GetTimeSinceDispatchNanoseconds()while concrete typed Events expose:
event->GetDispatchTime()
event->GetTimeSinceDispatch()The core 5.x architecture is:
IEvent
type-erased Event core
|
v
Event<TTime>
|
+----------+----------+
| |
v v
DefaultClockTime SerializableNanoSeconds
|
v
SerializableEvent<TDerived>
and:
Threads::PrecisionThread<TTime, Traits>
|
v
PrecisionEventThread<TTime, Traits>
The wider integration architecture is:
ESPressio Event
|
+--------------+--------------+
| | |
v v v
local routing Event Bridges Event Transport
| | |
| Timing/Threads |
| Observers |
| |
+-----------------------------+
|
Event contracts
The library therefore provides one Event engine and one asynchronous routing system while allowing:
- the public time representation to vary at compile time;
- synchronous subsystem notifications to be bridged into Events only when requested;
- Serializable Events to remain optional;
- concrete network/radio transports to remain outside Event;
- global and per-transport routing policy to coexist.
Most importantly, the original Event-driven design principle remains unchanged:
Application modules communicate through Event contracts rather than acquiring direct relationships with one another.
That is the purpose of ESPressio Event.

