diff --git a/README.md b/README.md index e542e94..13228be 100644 --- a/README.md +++ b/README.md @@ -1,126 +1,382 @@ # ESPressio Observable -Synchronous Observer Pattern components for the Flowduino ESPressio -Development Platform. +Synchronous Observer Pattern components for the Flowduino ESPressio Development Platform. + +ESPressio Observable provides small, reusable building blocks for one-to-many synchronous notification without forcing an Observable object to know which concrete consumers are listening to it. ## Latest Stable Version **3.0.1** -### 3.0.1 performance maintenance +Version 3.0.1 preserves the Observable 3.0 ownership-safe registration API while reducing the cost of the no-observer path in `ThreadSafeObservable`. + +## Why use ESPressio Observable? + +Use Observable when a state change and its notification belong to the same synchronous operation, but the producer should remain independent of the concrete consumers. + +Compared with a single callback, an Observable can notify any number of independently implemented Observer objects. Compared with ESPressio Event, Observable does not create an asynchronous scheduling boundary. + +```text +Observable + +--> Observer A + +--> Observer B + +--> Observer C +``` + +This makes Observable particularly useful for lifecycle notifications, state-change callbacks, diagnostics hooks, and low-overhead subsystem observation. -Version 3.0.1 preserves the Observable 3.0 public API and ownership-safe -registration model while reducing the cost of optional observability when no -Observers are registered. +## Observer Pattern and dependency direction -`ThreadSafeObservable` now maintains a lightweight atomic Observer count so -`ExecuteNotification()` can return immediately without taking the notification -mutex or acquiring a notification-lifetime `shared_ptr` when there are no -Observers. Registration, unregistration, mutation-during-notification and RAII -handle semantics are unchanged. +A well-designed Observer relationship is one-way: + +```text +Observer -----> Observable +Observable -X-> concrete Observer +``` + +The Observable defines the notification contract, but never stores knowledge of concrete application consumers. This is a useful way to avoid circular references while still allowing multiple components to react to the same state change. + +### Do + +- Define small, logically focused Observer interfaces. +- Keep Observer callbacks short unless the synchronous work is intentionally part of the notifying operation. +- Keep an Observer alive for as long as it remains registered. +- Retain the registration handle for exactly as long as the registration should exist. +- Use `ThreadSafeObservable` when registration/notification can occur from multiple threads. + +### Do not + +- Introduce a back-reference from an Observable implementation to a concrete Observer. +- Mutate shared notification arguments unless the notification contract explicitly permits it. +- Assume `ThreadSafeObservable` automatically makes the custom state in your derived class thread-safe. +- Retain a raw pointer returned by `IObserverHandle::GetObservable()` or `GetObserver()` beyond the immediate operation. ## ESPressio Development Platform -ESPressio is a collection of discrete, composable component libraries -built around a common development ethos: +ESPressio is a collection of discrete, composable component libraries built around a common development ethos: -- **Light-weight** --- minimise memory consumption and runtime - overhead without sacrificing correctness. -- **Ease of use** --- provide strongly typed, developer-friendly - abstractions over lower-level facilities. -- **Object-oriented** --- a type for everything, and everything in a - type. -- **SOLID** --- favour focused responsibilities, extensibility, - substitutable abstractions, narrow interfaces, and dependency - inversion wherever practical on embedded C++ platforms. +- **Light-weight** — minimise memory consumption and runtime overhead without sacrificing correctness. +- **Ease of use** — provide strongly typed, developer-friendly abstractions over lower-level facilities. +- **Object-oriented** — a type for everything, and everything in a type. +- **SOLID** — favour focused responsibilities, extensibility, substitutable abstractions, narrow interfaces, and dependency inversion wherever practical on embedded C++ platforms. ## License Licensed under the **Apache License 2.0**. See [LICENSE](LICENSE). -## ESPressio Library Dependencies +## Namespace + +```cpp +ESPressio::Observable +``` -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. +The most important public types are: -For a complete overview of required dependencies, opt-in dependencies, -and the overall hierarchy, see: +- `IObserver` +- `IObserverHandle` +- `ObserverHandlePtr` +- `IObservable` +- `IUntypedObservable` +- `Observable` +- `ThreadSafeObservable` +- `ObservableWithBuckets` -**[ESPressio Library Dependency Chart](ESPRESSIO_DEPENDENCY_CHART.md)** +## Installation -- **Solid relationships** represent required ESPressio dependencies. -- **Dashed relationships** represent opt-in dependencies introduced - only by the corresponding feature, integration, type, or header. +PlatformIO: -### Required ESPressio dependencies +```ini +lib_deps = + flowduino/ESPressio-Observable@^3.0.1 +``` -None. +Or, when intentionally tracking the latest development branch: -## Namespace +```ini +lib_deps = + https://github.com/Flowduino/ESPressio-Observable.git +``` -``` cpp -ESPressio::Observable +The library requires C++ RTTI because notification filtering uses `dynamic_cast` against Observer interfaces. If your toolchain disables RTTI by default, enable it for the application, for example: + +```ini +build_unflags = + -fno-rtti ``` -## Observer Pattern +## Basic usage: a Thermometer Observable -Observable is intended for synchronous relationships between an -operation and interested observers: +The following example deliberately uses a small, concrete problem: a `Thermometer` detects a changed reading and synchronously notifies any registered temperature Observers. -``` text -Observable - +--> Observer A - +--> Observer B - +--> Observer C +### 1. Define the Observer interface + +```cpp +#pragma once + +#include + +class ITemperatureObserver : + public virtual ESPressio::Observable::IObserver { +public: + virtual ~ITemperatureObserver() = default; + + virtual void OnTemperatureChanged( + float previous, + float current + ) {} + + virtual void OnTemperatureIncreased(float by) {} + virtual void OnTemperatureDecreased(float by) {} +}; ``` -A subsystem normally defines a focused Observer interface derived from -the library's `IObserver` contract and exposes registration through its -Observable surface. +The callback bodies are intentionally optional. A concrete Observer can implement only the notifications it needs. + +### 2. Implement the Observable + +```cpp +#pragma once + +#include +#include "ITemperatureObserver.hpp" + +class Thermometer final : + public ESPressio::Observable::Observable { +private: + float _temperature = 0.0f; + +public: + float GetTemperature() const { + return _temperature; + } + + void SetTemperature(float temperature) { + if (_temperature == temperature) { + return; + } + + const float previous = _temperature; + _temperature = temperature; + + ExecuteNotification([&](NotificationContext& notification) { + notification.WithObservers( + [&](ITemperatureObserver* observer) { + observer->OnTemperatureChanged(previous, temperature); + + if (temperature > previous) { + observer->OnTemperatureIncreased( + temperature - previous + ); + } else { + observer->OnTemperatureDecreased( + previous - temperature + ); + } + } + ); + }); + } +}; +``` -## Registration lifetime +`ExecuteNotification()` retains the Observable for the complete notification operation. In the 3.x API, an Observable participating in notifications must therefore be owned through `std::shared_ptr`. + +### 3. Implement an Observer + +```cpp +#pragma once + +#include +#include "ITemperatureObserver.hpp" + +class TemperatureLogger final : + public ITemperatureObserver { +public: + void OnTemperatureChanged( + float previous, + float current + ) override { + Serial.printf( + "Temperature changed from %.2f to %.2f\n", + previous, + current + ); + } + + void OnTemperatureIncreased(float by) override { + Serial.printf("Temperature increased by %.2f\n", by); + } + + void OnTemperatureDecreased(float by) override { + Serial.printf("Temperature decreased by %.2f\n", by); + } +}; +``` -Version 3.x uses ownership-safe registration handles. Registrations can -be explicitly removed and naturally follow the lifetime of their -registration handles. +### 4. Register the Observer and retain the handle -Observers remain non-owning: the application must keep an Observer alive -for as long as it remains registered. +```cpp +#include +#include -## Synchronous semantics +#include "Thermometer.hpp" +#include "TemperatureLogger.hpp" -Observer callbacks execute synchronously as part of the notifying -operation. +std::shared_ptr thermometer; +TemperatureLogger temperatureLogger; +ESPressio::Observable::ObserverHandlePtr temperatureRegistration; -Use Observable when the notification belongs directly to the operation -and an asynchronous task boundary is undesirable. +void setup() { + Serial.begin(115200); -Use ESPressio Event when producers and consumers should be independently -scheduled. + thermometer = std::make_shared(); -## Relationship with ESPressio Event + temperatureRegistration = + thermometer->RegisterObserver(&temperatureLogger); -Observable does **not** depend on Event. + thermometer->SetTemperature(21.5f); + thermometer->SetTemperature(22.0f); +} -Higher-level bridges may consume synchronous Observer callbacks and emit -asynchronous Events: +void loop() {} +``` -``` text -Timing / Threads - -> Observable callback - -> optional Event bridge - -> asynchronous Event +The returned `ObserverHandlePtr` is a `std::unique_ptr`. Destroying the handle automatically unregisters the Observer. You can also unregister explicitly: + +```cpp +temperatureRegistration->Unregister(); ``` -This dependency direction keeps foundational libraries independent. +or directly through the Observable: + +```cpp +thermometer->UnregisterObserver(&temperatureLogger); +``` + +The application must keep `temperatureLogger` alive for the complete period in which the registration is active. + +## Registration lifetime and ownership + +Version 3.x deliberately makes registration lifetime explicit and ownership-safe: + +```text +std::shared_ptr + | + +-- registration --> ObserverHandlePtr + | + +-- destruction/unregister + removes registration +``` + +Important rules: + +- `RegisterObserver()` rejects `nullptr`. +- Duplicate registration against the same Observable is rejected. +- The Observer remains non-owning; registering it does not extend its lifetime. +- The registration handle does not own the Observable. +- Observable destruction invalidates outstanding registrations safely. +- Notification-aware Observable instances must be `std::shared_ptr`-owned so `ExecuteNotification()` can retain them while callbacks execute. + +## One Observable, multiple Observer interfaces + +A single Observable can expose several independent notification contracts. This is useful when different consumers care about different aspects of the same subsystem. + +For example, a physical environmental sensor could define both: + +```cpp +class ITemperatureObserver : + public virtual ESPressio::Observable::IObserver { +public: + virtual void OnTemperatureChanged(float previous, float current) {} +}; + +class IAirPressureObserver : + public virtual ESPressio::Observable::IObserver { +public: + virtual void OnAirPressureChanged(float previous, float current) {} +}; +``` + +The Observable can then target each interface independently: + +```cpp +ExecuteNotification([&](NotificationContext& notification) { + notification.WithObservers( + [&](ITemperatureObserver* observer) { + observer->OnTemperatureChanged(oldTemperature, newTemperature); + } + ); +}); + +ExecuteNotification([&](NotificationContext& notification) { + notification.WithObservers( + [&](IAirPressureObserver* observer) { + observer->OnAirPressureChanged(oldPressure, newPressure); + } + ); +}); +``` + +A concrete Observer may implement one interface or several of them. This keeps notification contracts focused and supports the Interface Segregation Principle without forcing the Observable to know which combinations exist. + +## Thread-safe Observables + +`Observable` is intentionally not thread-safe. It supports registration/unregistration during notification, but simultaneous operations from multiple threads require external synchronization. + +When registrations or notifications can cross thread boundaries, derive from: + +```cpp +#include + +class Thermometer final : + public ESPressio::Observable::ThreadSafeObservable { + // notification code follows the same model +}; +``` + +The Observer-facing model remains the same: register an `IObserver`, retain the returned handle, and use the protected notification operation supplied by the Observable implementation. + +> **Important:** `ThreadSafeObservable` protects its Observer registration/notification machinery. It does not automatically protect members such as `_temperature`, sensor buffers, configuration state, or any other fields added by your derived class. + +## Mutation during notification + +The current implementation deliberately supports an Observer unregistering while a notification is in progress. Registration containers are compacted safely after the outer notification operation completes. + +This makes patterns such as one-shot observers practical without invalidating the iteration currently delivering a notification. + +## Observable vs Event + +Use Observable when the notification is synchronous and naturally belongs to the operation being performed: + +```text +operation -> state changes -> Observer callbacks -> operation continues +``` + +Use ESPressio Event when producers and consumers should be independently scheduled: + +```text +producer -> dispatch Event -> producer continues + \ + -> asynchronous consumer(s) +``` + +Observable does **not** depend on Event. Higher-level libraries may consume Observable callbacks and optionally translate them into Events, preserving a one-way dependency graph. + +## ESPressio Library Dependencies + +**Required ESPressio dependencies: none.** + +For the complete ESPressio hierarchy, including optional downstream integrations, see [ESPRESSIO_DEPENDENCY_CHART.md](ESPRESSIO_DEPENDENCY_CHART.md). + +- Solid relationships represent required dependencies. +- Dashed relationships represent opt-in dependencies. ## Design goals -- Focused Observer contracts. -- Explicit registration lifetime. -- Non-owning Observer relationships. -- Synchronous deterministic notification. -- Reusable infrastructure. -- No dependency on ESPressio Event. +- Focused Observer contracts. +- Explicit registration lifetime. +- Non-owning Observer relationships. +- Synchronous deterministic notification. +- Safe registration mutation during callbacks. +- A thread-safe implementation when required. +- Reusable infrastructure with no dependency on ESPressio Event.