Robust monotonic mesh time synchronization library for ESP32 over ESP-NOW radio.
Distributed, driverless 64-bit time sync—perfect for synchronized DMX, MIDI, MTC, media, lighting and show control mesh applications.. or whatever you want to do with wireless micro-seconds accuracy sync !
- Distributed: every node broadcasts its local mesh time, others align (forward-only, monotonic)
- No rollover risk: uses embedded libclock with 64-bit hardware timer (
fastmicros64_isr), and clock value is broadcasted as 56-bit value (~2283 years rollover). - Slewed/smoothed time alignment: handles radio packet jitter and burst/delay gracefully
- Collision avoidance: randomized broadcast intervals prevent packet collisions in dense meshes
- State monitoring: query sync status (ALONE, SYNCED, LOST)
- Configurable timeout: detect and report lost mesh connectivity
- Robust across network loss/packet drop—self-healing mesh
- Simple API: just call
begin()+loop() - ESP-NOW compatible: works alongside existing ESP-NOW code via callback delegation/chaining with magic header packet identification
- Plug-and-play with PlatformIO: drop into any project (
lib_deps)
Add this to your platformio.ini:
lib_deps =
https://github.com/Hemisphere-Project/ESPNowMeshClock.git
#include <ESPNowMeshClock.h>
// Create mesh clock with:
// - 1000ms broadcast interval
// - 0.25 slew rate
// - 10ms large step threshold
// - 5000ms sync timeout
// - ±10% random variation
ESPNowMeshClock meshClock(1000, 0.25, 10000, 5000, 10);
void setup() {
Serial.begin(115200);
meshClock.begin();
Serial.println("ESPNowMeshClock started");
}
void loop() {
meshClock.loop();
// Check sync state
SyncState state = meshClock.getSyncState();
static SyncState lastState = SyncState::ALONE;
if(state != lastState) {
lastState = state;
switch(state) {
case SyncState::ALONE:
Serial.println("Status: Waiting for sync...");
break;
case SyncState::SYNCED:
Serial.println("Status: SYNCED");
break;
case SyncState::LOST:
Serial.println("Status: LINK LOST!");
break;
}
}
// Use mesh-synced time for your application
uint64_t meshTime = meshClock.meshMicros();
uint32_t meshMs = meshClock.meshMillis();
// Example: Synchronized action every second
static uint32_t lastAction = 0;
if(meshMs - lastAction >= 1000) {
lastAction = meshMs;
Serial.printf("Synchronized tick at %llu µs\n", meshTime);
}
delay(10);
}
The Arduino micros() function on ESP32 is 32-bit and wraps every ~71min (breaking any forward-only mesh time sync algorithm after a single wrap)!
By embedding libclock, this library defaults to fastmicros64_isr(), a true 64-bit hardware timer—no rollover, maximum robustness.
ESPNowMeshClock(uint16_t interval_ms = 1000,
float slew_alpha = 0.25,
uint32_t large_step_us = 10000,
uint32_t sync_timeout_ms = 5000,
uint8_t random_variation_percent = 10,
ClockFn clkfn = nullptr)Creates a new ESPNowMeshClock instance with configurable parameters.
Parameters:
interval_ms(default: 1000): Broadcast interval in milliseconds. How often this node broadcasts its mesh time to the network.slew_alpha(default: 0.25): Slew rate for smooth time adjustments (0.0 to 1.0). Lower values = smoother but slower convergence. Higher values = faster convergence but more abrupt changes.large_step_us(default: 10000): Threshold in microseconds for direct time adjustment vs slewing. Deltas larger than this will use direct adjustment instead of gradual slewing.sync_timeout_ms(default: 5000): Time in milliseconds after which the sync link is considered lost if no messages are received.random_variation_percent(default: 10): Percentage of random variation (±) applied to broadcast interval to avoid packet collisions. Higher values = better collision avoidance in dense meshes.clkfn(default: nullptr): Optional custom clock function. If null, uses the built-in 64-bit hardware timer (fastmicros64_isr).
Example:
// Default settings (1s broadcast, 0.25 slew, 10ms threshold, 5s timeout, ±10% variation)
ESPNowMeshClock meshClock;
// Custom settings for faster sync with tighter timeout and more collision avoidance
ESPNowMeshClock meshClock(500, 0.5, 5000, 2000, 20);
// Dense mesh with many nodes - increase variation
ESPNowMeshClock meshClock(1000, 0.25, 10000, 5000, 25);Initializes the mesh clock synchronization. Must be called in setup() before using the clock.
Parameters:
registerCallback(default: true): If true, registers internal ESP-NOW receive callback. Set to false if you want to manage ESP-NOW callbacks yourself (see ESP-NOW Integration).
Actions:
- Sets WiFi to station mode
- Initializes ESP-NOW
- Registers receive callback (if
registerCallbackis true) - Adds broadcast peer
Example:
void setup() {
Serial.begin(115200);
meshClock.begin(); // Standard usage
}
// OR for custom ESP-NOW integration:
void setup() {
meshClock.begin(false); // Don't register callback
esp_now_register_recv_cb(myCallback); // Use your own
}Handles periodic broadcast of mesh time. Must be called frequently in main loop().
It is a low priority task, since it only handles periodic broadcasting.
Actions:
- Checks if broadcast interval has elapsed
- Broadcasts current mesh time to all peers
Example:
void loop() {
meshClock.loop(); // Call this first
// ... rest of your code ...
}Returns the current mesh-synchronized time in microseconds.
Returns: 64-bit unsigned integer representing microseconds since mesh epoch.
Example:
uint64_t now = meshClock.meshMicros();
Serial.printf("Mesh time: %llu µs\n", now);Returns the current mesh-synchronized time in milliseconds.
Returns: 32-bit unsigned integer representing milliseconds since mesh epoch.
Note: This is derived from meshMicros() / 1000. For precision work, use meshMicros().
Example:
uint32_t now = meshClock.meshMillis();
Serial.printf("Mesh time: %u ms\n", now);Returns the current synchronization state of the mesh clock.
Returns: One of three SyncState enum values:
SyncState::ALONE- No sync messages have been received yet (node is alone)SyncState::SYNCED- Currently synchronized with the meshSyncState::LOST- Was previously synced, but no messages received within timeout period
Example:
SyncState state = meshClock.getSyncState();
switch(state) {
case SyncState::ALONE:
Serial.println("Waiting for initial sync...");
break;
case SyncState::SYNCED:
Serial.println("Synchronized!");
break;
case SyncState::LOST:
Serial.println("Warning: Sync link lost!");
break;
}Use Cases:
- Display sync status on LED/display
- Trigger fallback behavior when link is lost
- Monitor mesh health in diagnostics
- Conditional logic based on sync reliability
Manually process an ESP-NOW packet to check if it's a mesh clock packet. Use this when managing your own ESP-NOW callbacks.
Packet Identification:
- Checks for exactly 10 bytes
- Validates "MCK" magic header (0x4D, 0x43, 0x4B)
- Extracts 56-bit timestamp if valid
Parameters:
mac: MAC address of senderdata: Packet datalen: Packet length
Returns: true if the packet was a valid mesh clock packet (processed), false otherwise
Example:
void myESPNowCallback(const uint8_t *mac, const uint8_t *data, int len) {
if (meshClock.handleReceive(mac, data, len)) {
return; // Was a clock packet (10 bytes with "MCK" header)
}
// Handle your own packets here
}See ESP-NOW Integration for complete examples.
Register a callback to receive non-clock ESP-NOW packets. The library will automatically route 8-byte packets to the mesh clock and forward all other packets to your callback.
Parameters:
callback: Function pointer with signaturevoid callback(const uint8_t *mac, const uint8_t *data, int len)
Example:
void myCallback(const uint8_t *mac, const uint8_t *data, int len) {
// Only receives packets that are NOT mesh clock packets
Serial.printf("Custom packet: %d bytes\n", len);
}
void setup() {
meshClock.setUserCallback(myCallback);
meshClock.begin(); // Auto-routing enabled
}See ESP-NOW Integration for complete examples.
The library includes several example sketches to help you get started:
Location: examples/BasicSync/BasicSync.ino
A simple introduction to the library showing:
- Basic initialization and setup
- Monitoring sync state changes
- Using mesh time for coordinated actions
- Serial output of sync status
Location: examples/StateMonitoring/StateMonitoring.ino
Advanced state monitoring with visual feedback:
- LED indicators for each sync state
- Detailed statistics and diagnostics
- Custom configuration for faster sync
- Pretty-printed serial output with boxes and symbols
Location: examples/SynchronizedLED/SynchronizedLED.ino
Demonstrates perfect synchronization across multiple devices:
- Synchronized LED blinking patterns
- All devices in the mesh blink in perfect unison
- Shows how to use mesh time for coordinated animations
- Includes alternative pattern examples (breathing, pulses)
Location: examples/CustomESPNowIntegration_Option1/CustomESPNowIntegration_Option1.ino
For projects that already use ESP-NOW - manual integration:
- You manage your own ESP-NOW callback
- Call
meshClock.handleReceive()to process clock packets - Full control over packet routing
- Perfect for complex ESP-NOW applications
Location: examples/CustomESPNowIntegration_Option2/CustomESPNowIntegration_Option2.ino
For projects that already use ESP-NOW - automatic chaining:
- Register your callback with
meshClock.setUserCallback() - Library automatically routes packets
- 8-byte packets → mesh clock (handled internally)
- Other packets → your callback
- Simpler integration than Option 1
Important: ESP-NOW only supports a single receive callback. If your project already uses ESP-NOW, choose one of these integration methods:
ESPNowMeshClock meshClock;
void myESPNowCallback(const uint8_t *mac, const uint8_t *data, int len) {
// Let mesh clock process if it's a clock packet
if (meshClock.handleReceive(mac, data, len)) {
return; // Was a clock packet, done
}
// Otherwise handle your own ESP-NOW messages
// ... your code ...
}
void setup() {
meshClock.begin(false); // false = don't register own callback
esp_now_register_recv_cb(myESPNowCallback);
}ESPNowMeshClock meshClock;
void myCustomCallback(const uint8_t *mac, const uint8_t *data, int len) {
// Only receives NON-clock packets (not 8 bytes)
// ... handle your messages ...
}
void setup() {
meshClock.setUserCallback(myCustomCallback);
meshClock.begin(); // Registers callback with auto-chaining
}New API Methods:
bool handleReceive(mac, data, len)- Returns true if packet was a clock packet (10 bytes with "MCK" magic header)void setUserCallback(callback)- Set callback for non-clock packetsvoid begin(bool registerCallback = true)- Optional callback registration
Mesh clock packets are identified by a unique magic header to prevent conflicts with other ESP-NOW messages.
Packet Structure (10 bytes total):
Offset | Size | Description
-------|------|-------------
0-2 | 3 | Magic header: "MCK" (0x4D, 0x43, 0x4B)
3-9 | 7 | Timestamp: 56-bit microseconds (little-endian)
Why 56-bit timestamp?
- Rollover period: ~2,283 years (vs 584,000 years for 64-bit)
- Compact packet size: 10 bytes total
- More than sufficient for any practical application
Magic Header "MCK":
- Prevents misidentification of random 10-byte packets
- Allows multiple ESP-NOW protocols to coexist
- Future-proof for protocol versioning
Only packets matching this exact format will be processed as mesh clock packets. All other ESP-NOW packets will be ignored or forwarded to your custom callback (if using ESP-NOW integration).
- Each node broadcasts its mesh time every N ms (default: 1000ms ± 10% random variation)
- Broadcast packet: 10 bytes ("MCK" + 56-bit timestamp)
- Random variation prevents broadcast collisions in dense meshes
- On receive, any node forward-only slews its offset toward the most advanced clock (large steps only at first sync)
- Smoothing parameter (
slew_alpha) ensures jumps are absorbed rather than causing AV/motion artifacts - Sync timeout monitoring allows detection of lost connectivity
ESPNowMeshClockby Hemisphere-Projectlibclockby peufeu
ESPNowMeshClock core sources (src/ESPNowMeshClock.* and examples) are licensed under GPL-3.0-or-later. The bundled src/libclock directory remains under its original MIT terms from peufeu. When redistributing, keep both notices intact so downstream users understand which parts fall under which license.