ESP32-S3 firmware demonstrating high-frequency, high-resolution data streaming between two devices over Agora Signaling at 100 Hz — pushing the limits of low-latency peer-to-peer messaging on embedded hardware.
flowchart LR
subgraph A["Device A"]
A_sensor["sensor"]
A_haptic["haptic"]
end
subgraph Cloud["Agora Signaling"]
end
subgraph B["Device B"]
B_sensor["sensor"]
B_haptic["haptic"]
end
A_sensor -->|"TX 100 Hz"| Cloud -->|"RX"| B_haptic
B_sensor -->|"TX 100 Hz"| Cloud -->|"RX"| A_haptic
| Item | Details |
|---|---|
| SoC | ESP32-S3 with 8 MB embedded OPI PSRAM |
| Flash | 8 MB |
- ESP-IDF v5.2.x
- AOSL source tree checked out at
../aosl(sibling of this repo) - Agora Account with Signaling enabled
- Atem CLI for token generation
HapticHatch/
├── components/
│ ├── aosl/ # ESP-IDF wrapper for the AOSL source tree
│ ├── haptic/ # Haptic driver (stub: logs intensity)
│ ├── rtsa_transport/ # Agora RTSA SDK transport + link glue
│ └── sensor/ # Sensor driver (stub: sine wave at 100 Hz)
├── main/
│ ├── main.c # app_main: wires sensor → haptic, starts Signaling demo
│ ├── signaling_demo.c # WiFi init, Signaling login, 100 Hz TX task, RX callback
│ └── signaling_demo.h
├── partitions.csv # 3 MB factory partition (required by SDK size)
└── sdkconfig.defaults # Target, flash, PSRAM, and credential defaults
Copy sdkconfig.defaults and fill in your credentials before building.
# sdkconfig.defaults
CONFIG_AGORA_APP_ID="<your-agora-app-id>"
# Both tokens live in the build so you can flash both boards without
# touching sdkconfig in between — only change DEVICE_ID between flashes.
CONFIG_AGORA_SIGNALING_UID_A="device_a"
CONFIG_AGORA_SIGNALING_UID_B="device_b"
CONFIG_AGORA_SIGNALING_TOKEN_A="<signaling-token-for-device_a>"
CONFIG_AGORA_SIGNALING_TOKEN_B="<signaling-token-for-device_b>"
CONFIG_AGORA_SIGNALING_DEVICE_ID="A" # "A" for first board, "B" for second
CONFIG_DEMO_WIFI_SSID="<your-ssid>"
CONFIG_DEMO_WIFI_PASSWORD="<your-password>"Signaling tokens expire after 3600 s, but you only need to generate them once per session — then you can flash both boards without regenerating.
atem token rtm create --rtm-user-id device_a # → TOKEN_A
atem token rtm create --rtm-user-id device_b # → TOKEN_BPaste both tokens into sdkconfig.defaults.
Each UID (device_a, device_b) needs its own token. The token is signed by Agora's backend using your App ID's secret and binds to {App ID, UID, expiry}. The device presents the token to Agora at boot time; only after login succeeds can it send or receive messages.
sequenceDiagram
autonumber
actor Dev as Developer
participant Atem as Atem CLI
participant Agora as Agora Cloud
participant Device as ESP32-S3
Note over Dev,Atem: 1. Create a Signaling token — once per UID
Dev->>Atem: atem token rtm create --rtm-user-id device_a
Note over Atem: Sign token locally with<br/>App ID + App Certificate + UID<br/>(TTL 3600 s)
Atem-->>Dev: Token string
Note over Dev,Device: 2. Embed token in firmware and flash
Dev->>Device: sdkconfig: APP_ID, UID_A/B, TOKEN_A/B, DEVICE_ID → idf.py flash
Note over Device,Agora: 3. Boot-time login (signaling_demo.c)
Device->>Device: WiFi STA connect
Device->>Agora: agora_rtc_init(App ID)
Device->>Agora: agora_rtc_login_rtm(UID, token)
Agora->>Agora: Verify App ID + UID + signature + expiry
Agora-->>Device: on_rtm_event(LOGIN, OK)
Note over Device,Agora: 4. Messaging is now allowed
loop every 10 ms (100 Hz)
Device->>Agora: agora_rtc_send_rtm_data(peer_uid, payload)
Agora-->>Device: on_rtm_data(from_uid, payload)
end
Common login failures:
- Token expired (TTL 3600 s) → regenerate with
atem DEVICE_IDdoesn't match a filled-in token (e.g.DEVICE_ID="B"butTOKEN_Bis empty)- App ID mismatch between the token and
CONFIG_AGORA_APP_ID
# 1. Activate ESP-IDF
source /path/to/esp-idf/export.sh
# 2. Remove stale config (required whenever sdkconfig.defaults changes)
rm -f sdkconfig
# 3. Build
idf.py buildWith both tokens already in sdkconfig.defaults, the only thing that changes between the two flashes is CONFIG_AGORA_SIGNALING_DEVICE_ID.
# sdkconfig.defaults
CONFIG_AGORA_SIGNALING_DEVICE_ID="A"rm sdkconfig && idf.py -p /dev/ttyUSB0 build flash# sdkconfig.defaults
CONFIG_AGORA_SIGNALING_DEVICE_ID="B"rm sdkconfig && idf.py -p /dev/ttyUSB1 build flash# terminal 1
idf.py -p /dev/ttyUSB0 monitor
# terminal 2
idf.py -p /dev/ttyUSB1 monitordevice_a (transmitting to device_b, receiving from device_b):
I (...) signaling: Device ID=A → uid=device_a, peer=device_b
I (...) signaling: Agora SDK 1.10.0 initialized
I (...) signaling: Signaling login success uid=device_a
I (...) signaling: Starting 100 Hz Signaling sender
I (...) signaling: TX seq=1 ts=5080 ms force=0.57
I (...) signaling: TX seq=2 ts=5090 ms force=0.59
...
I (...) signaling: RX from=device_b seq=1 ts=5150 ms force=0.420 type=haptic
Messages flow in both directions at 100 Hz.
| Symptom | Cause | Fix |
|---|---|---|
aosl_malloc N byte failed on boot |
PSRAM not enabled | Verify CONFIG_SPIRAM=y in sdkconfig.defaults, do rm sdkconfig before rebuild |
Signaling login failed err=101 with GetAddrErr code=2010005 |
Token expired, or the App ID's project doesn't have Signaling enabled, or a typo in the App ID | Confirm the active atem project has Signaling enabled (atem project show), regenerate tokens, rm sdkconfig, rebuild |
Invalid CONFIG_AGORA_SIGNALING_DEVICE_ID="..." followed by abort |
DEVICE_ID is not "A" or "B" |
Set CONFIG_AGORA_SIGNALING_DEVICE_ID="A" or "B" in sdkconfig.defaults |
TX result state=2 warnings |
Peer not yet online | Normal until both devices are logged in |
idf.py: command not found |
IDF env not sourced | source /path/to/esp-idf/export.sh |
Build fails: AOSL_LOG_ERR undeclared |
Outdated AOSL tree (pre-fix) | Update AOSL: cd ../aosl && git pull (fix is in AgoraIO-Community/aosl#3) |