Three invariants hold this stack together. Every one of them started as a multi-day bug.
What it does
A BK7258 camera module streams live 720p H.264 video and two-way G.711A audio directly into a web browser. No app, no proprietary relay protocol, no vendor cloud in the media path. The browser gets a real RTCPeerConnection, and the device is a real WebRTC peer.
Signalling rides over Anedya’s MQTT, and the connection goes peer to peer where the network allows it, falling back to a TURN relay where it does not.
Why a port at all
WebRTC on microcontrollers is usually faked. The common shortcut is a proprietary stream with a server-side transcoder pretending to be WebRTC at the browser end, which puts a box in the middle of every call.
libpeer is a small C WebRTC implementation that makes the honest version possible, so I brought it to the BK7258 as an Armino component alongside libsrtp, cJSON, and miniz. The BK7258 is dual core and everything here runs on the AP core.
The MbedTLS 2 to 3 gap
The single largest piece of work was not WebRTC. It was the fact that the SoC ships MbedTLS 3.x with the PSA API, while the lwIP source Beken bundles inside ap/components/lwip_intf_v2_1 is still written against MbedTLS 2.x.
MbedTLS 3 made four changes that break that code outright:
| Change in MbedTLS 3 | Consequence |
|---|---|
Direct access to net.h removed | Socket glue in the TLS layer no longer compiles |
ssl_internal.h removed | Internal structures the wrapper reached into are gone |
out_left struct fields removed | Buffer accounting has no field to read |
mbedtls_pk_parse_key signature changed | Key parsing now demands an explicit RNG |
I patched the altcp_tls_mbedtls.c wrapper to polyfill the missing definitions and wired mbedtls_ctr_drbg_random into the key parser, which is the change MbedTLS made deliberately for security rather than by accident.
Signalling through a 1KB pipe
An SDP offer with all its candidates is comfortably larger than Anedya’s command budget of roughly 1KB. Rather than fragment it, both directions send base64(raw-deflate(SDP)), using miniz on the device and CompressionStream("deflate-raw") in the browser.
There is no trickle ICE. The answer carries every candidate at once, which suits a device that would rather do one round of work than hold a negotiation open.
The media path
Video. UVC MJPEG goes through the JPEG decoder, into the H.264 encoder, and out through a frame callback registered for IMAGE_H264. Whole NAL units come back and get fragmented into FU-A packets. This deliberately bypasses Beken’s wifi_transfer path, which prepends its own transfer_data_t header to every chunk and corrupts the stream.
Audio out. Microphone into G.711A, then out through the transfer callbacks. The prepare() hook has to copy the buffer. It shipped as an empty stub, so for weeks the device faithfully transmitted an untouched static array, which sounds exactly like bursts of random noise.
Audio in. The browser’s audio arrives on the track callback and goes straight to the voice service. libpeer’s receive path was already complete. Nothing had been connected to it.
Three invariants
FreeRTOS here is strictly priority preemptive, which makes the priority numbers load bearing rather than decorative.
The peer loop must outrank the video sender. Sitting one priority below it meant every encoded frame preempted the peer loop mid-handshake and mid-audio-write. That single number produced both the choppy audio and a continuous storm of picture loss indications.
One mutex has to span encrypt and send. All audio and video funnels through a single outgoing packet path that calls srtp_protect() on one shared srtp_t, and libSRTP is explicitly not thread safe. Adding audio added a second sender task. Concurrent calls corrupt sequence and rollover state, the receiver silently discards the packet, and with no NACK every corrupted packet costs a whole frame. That becomes a keyframe request, an IDR burst, and then the same thing again. The mutex also covers the TURN send path, which writes through one static ChannelData buffer.
Every MQTT call needs the lwIP core lock. The publish and connection-check functions open with LWIP_ASSERT_CORE_LOCKED(), which this port compiles to nothing, so every violation was completely silent. Racing the close path, which nulls the client connection pointer, gave a null dereference that took about an hour of uptime to appear.
The 160-byte rule
The generic RTP encoder advances the PCMA timestamp by exactly one 20ms step per call, no matter how many bytes you hand it. So the microphone read size and the frame size constant have to agree.
They did not. The read size was 1280 bytes, which is 160ms of audio being stamped as 20ms, an eight times clock error. The send path now splits defensively, but the two values still live in separate files with nothing at compile time linking them.
Still open
- No NACK, and no congestion control. Bitrate is fixed at roughly 3.9 Mbps.
- No RTP pacing.
- No Wi-Fi or MQTT reconnect handling.
- TURN relay is on by default on the device side. With a browser also forcing relay, the server allocates on both ends, so this wants to be a deliberate decision rather than an inherited one.