Turning a Reolink video doorbell into a SIP extension on my PBX
Posted on 16th of September 2026.
I have a Reolink Video Doorbell WiFi and a FreePBX box on the same LAN. What I wanted is simple to describe: someone presses the button, my desk phone rings, I pick up and talk to whoever is at the door. And the other way round: dial the doorbell's extension from any phone and get two-way audio to the door. No cloud, no Reolink app, just SIP.
The result is reolink-sip-bridge on GitHub: a Python service that registers as a SIP extension and glues the doorbell's microphone, speaker and button to it. The README covers setup properly, so if you just want it running, start there. This post is about how it works and what it took to get the delay down to something you can hold a conversation over.
What the doorbell actually gives you
The camera exposes three things you need, over three different protocols, none of them designed to be combined:
| What | How the camera exposes it |
|---|---|
| Microphone | Inside the RTSP video stream, as 16 kHz AAC (MPEG4-GENERIC/16000) |
| Speaker | ONVIF backchannel: an RTSP sendonly audio track (PCMU/8000) you push RTP up to the camera |
| Button | Reolink's proprietary Baichuan push protocol on TCP port 9000 |
Findings are from firmware v3.0.0.4662_2503122270. Other Reolink models with the talk ability should behave the same way, but I only have this one.
pyVoIP, then PJSIP
I started with pyVoIP because it's pure Python and looked simple. It registered fine, and then:
- FreePBX qualifies endpoints with
OPTIONSevery 60 seconds. pyVoIP cannot parseOPTIONS(it's missing fromSIPCompatibleMethodson both the release and master branch), so the extension gets marked UNREACHABLE within a minute. - No
CANCEL, so an unanswered outbound call can't be torn down properly. - RTP underruns are padded with
0x80. In µ-law that's full scale, not silence. Every dropout would be a loud click. encode_packetlabels µ-law as A-law.- A blocking
recv()insideinvite().
Fixing that list means writing a SIP stack, so I switched to PJSIP 2.15.1 through the pjsua2-python wheels. It handles OPTIONS, CANCEL, retransmission and jitter buffering itself, and exposes AudioMediaPort with onFrameRequested / onFrameReceived, which is exactly the hook needed to feed custom audio in and out of a call.
Two PJSIP lifecycle landmines worth knowing about:
- Shutdown assertion.
pjmedia_conf_remove_port: Assertion 'port < conf->max_ports' failedmeans a media port's C++ destructor ran afterlibDestroy(). Python's GC timing versus C++ destructor ordering. Fix:del port; gc.collect()before destroying the endpoint. - Two
Endpoints in one process abort instantly. Not an exception, butterminate called after throwing an instance of 'pj::Error', uncatchable from Python. TwoAccounts on one endpoint work fine.
The wheels are Linux-only and I develop on Windows, so everything ran on a Linux VM. Every test in the rest of this post went through it.
The audio path
Internally everything is 16-bit linear PCM, 8 kHz, mono, 20 ms frames. That's PJSIP's native format once G.711 is decoded, so the SIP leg needs no conversion at all and nothing ever resamples. Conversion happens only at the two doorbell edges:
doorbell mic AAC 16 kHz --ffmpeg--> PCM 8 kHz --> caller
caller PCM 8 kHz --g711.py--> µ-law --> doorbell speaker The mic is ffmpeg as a supervised subprocess, with -allowed_media_types audio so it doesn't pull the whole H.264 stream just to reach the audio track. The camera drops RTSP sessions now and then, so it restarts.
The speaker is a minimal RTSP client I wrote by hand rather than pulling in a library: digest auth, DESCRIBE with Require: www.onvif.org/ver20/backchannel, pick the sendonly track, SETUP with interleaved TCP, PLAY, then push RTP packets up the same TCP socket framed as $ <channel> <len16>. Plus a drain thread that reads and discards whatever the camera sends back, because if you don't empty the socket the connection stalls. TCP_NODELAY matters here: RTP packets are about 176 bytes and Nagle would sit on each one waiting for the previous ACK.
G.711 is pure Python. audioop is gone in Python 3.13, so I wrote µ-law/A-law from the reference segment tables and made every conversion a single bytes.translate() over a precomputed table. It's byte-exact against audioop over the full 16-bit sweep. Fun detail: µ-law silence is 0xFF and A-law silence is 0xD5. Neither is zero.
The button uses reolink_aio, which already speaks Baichuan. Register a callback, watch visitor_detected() for a rising edge, debounce. Worked first time, the only part that did.
The mic that wasn't there
Phase 3: the caller hears nothing. Level 0.0001. I tried 8 RTSP variants, 3 RTMP streams, main and sub, TCP and UDP, audio-only and audio+video. Flat across all of them.
So I wrote a self-test: play one of the camera's built-in quick-reply voice messages through its own speaker and listen on its own mic. Ratio 1.2×. Conclusion: mic is dead.
Wrong. I could hear the voice message fine standing at the door, and the reason the mic didn't is that the camera mutes its mic while its speaker plays. The self-test was invalid by construction. Tapping the doorbell body showed the mic did work, it was just about 40 dB down from what the phone app presented. I set MIC_GAIN_DB=30 and moved on. This comes back later.
The mute-during-playback quirk has a nice side effect: it's free half-duplex echo suppression. An intercom normally needs acoustic echo cancellation, this one doesn't, because the camera can't hear its own speaker. PJSIP's echo canceller is off (ecTailLen = 0) since it only adds delay.
Latency, the good part
With everything working, the delay was way more than the Reolink app, in both directions. The obvious first question: are we transcoding, and can we skip it?
Transcoding wasn't it. The mic is only offered as AAC 16 kHz, on both streams, and the speaker only takes PCMU/8000. So transcoding is forced in one direction and already absent in the other, and decoding costs microseconds anyway.
ffmpeg wasn't it either. I suspected its 32 KB output buffer (that's 2 seconds of 8 kHz PCM). Timestamping every pipe read showed a steady trickle of 64 ms AAC frames, sometimes three at once, gaps of 50 to 220 ms. -flush_packets 1, -avioflags direct, -probesize 32, -analyzeduration 0, -max_delay 0: none of them changed anything measurable. Good to have ruled out with data instead of folklore.
A fixed-rate reader never catches up
The original bridge had a "pump" loop: every 20 ms, read one frame from the mic buffer and write it to the SIP session, read one frame from the session and write it to the speaker. Five buffers, three independent 20 ms clocks chained together.
The insight that fixed it: a reader that takes one 20 ms frame every 20 ms runs at exactly the right average rate, and that is precisely the problem. It can never go faster, so whatever backlog a burst leaves behind is kept forever. Backlog is delay. I measured the mic buffer sawtoothing between 20 and 184 ms, averaging about 100 ms, and that was one hop of five.
What changed:
- Deleted the pump. The doorbell is wired straight into PJSIP's media callbacks:
onFrameRequestedreads the mic buffer,onFrameReceivedwrites the speaker buffer. PJSIP's media thread is the only clock. Two buffers and a whole clock domain gone. - Buffers shed delay on purpose, dropping 20 ms of pause at a time until back at a target. Inaudible, because conversation is mostly pauses.
- A self-tuning cushion. Start at 60 ms, grow 40 ms when the buffer runs dry while someone is speaking, give back 20 ms per 5 seconds of calm. Running dry during a pause is free and ignored, otherwise an intercom, which is silent most of the time, would pad itself forever.
- Speech detection relative to the noise floor. A fixed "quiet" threshold broke immediately: 30 dB of mic gain lifts room tone above any constant, so nothing was ever quiet and nothing could ever be trimmed. Now it tracks the quietest recent level and calls anything within 1.8× of it a pause.
- PJSIP's jitter buffer set to LAN sizes instead of internet-sized defaults.
Door to phone went from about 100 ms of our own buffering to near zero. What I'd expect from a real-time call.
Phone to door was still huge
Identical code in both directions, my buffers at 0 to 32 ms, so the delay had to be at the far end, inside the camera. Which I couldn't see.
I had two confident theories. First: clock drift accumulating in the camera, so stop sending RTP during pauses and let it drain. Shipped it. Result: when I spoke into the phone I heard nothing at the door, and when I started speaking again the previous sentence got flushed out first. Second theory: the camera only plays while packets arrive, so use a longer hangover. Also wrong.
Time to measure instead of theorise. The catch is that the code can't hear the doorbell (mic mutes during playback, remember), so the measurement had to use my ears without making me time anything:
The doorbell plays a tone that steps up in pitch once a second. The console prints the step it's sending. The gap between the number printed and the number heard is the delay, in seconds.
Then stop sending dead for 8 seconds, then send more steps. Result: exactly two more tones after "STOPPED", then nothing, ever again. Three facts from one 40-second run:
- The camera holds a fixed ~2 second cushion before it plays anything. Not drift, a prefetch. Constant.
- It does drain on its own clock (the two tones came out).
- Once fully starved, the backchannel is dead until torn down and rebuilt. That's why silence suppression broke the call.
So: never stop the stream. But the cushion drains. Therefore thin it: during silence send slightly fewer packets than real time, one skipped in two, with the RTP timestamp still advancing. The camera receives an unbroken stream with a marginally shorter pause in it, and everything after that pause plays that much sooner. Prime at real time for 2.5 seconds first (the cushion has to exist before it can be drained), reclaim a fixed budget of 1600 ms, stop. Not 2000: the remaining depth is unobservable and the failure mode is unrecoverable.
Second run of the tone test with a drain phase in the middle: before the drain it lagged 1.5 s, after it 0.5 s. After a 15 second idle phase: still 0.5 s. The cushion doesn't refill.
One artefact: a tone got clipped short. Thinning had started 0.3 s after the tone stopped, while the camera still held its tail, and the packets being dropped were the ones that would have pushed it out. Fix: wait out (cushion minus already reclaimed) before thinning, so about 2 s at call start, shrinking to about 0.4 s.
The drain that lied
Dialing the extension was now fine. Button presses still had the full delay, even when I answered 10 seconds later. The logs said 1600 of 1600ms cushion reclaimed. Our accounting said success.
Then it clicked: could the doorbell playing its chime be the problem? Yes. Button pressed, backchannel opened in the same second, and the camera was playing its own chime on that speaker. Everything we sent during it was discarded, not played. We drained a buffer that wasn't draining, hit the counter, declared victory. Bonus symptom: the chime cut out when our stream opened.
Three things that can't run in sequence, so they run in parallel:
- The phone rings immediately. A visitor who pressed a button expects something to happen.
- Camera audio opens after
BUTTON_CHIME_MS(3000 ms on my unit), then primes and drains. - Whoever answers early hears a hold tone until both ends are ready. Any "hello?" during the hold is discarded rather than sent to a camera that isn't listening.
For inbound calls there's no chime, so the bridge just sends 180 Ringing and doesn't answer until the drain is done. Ringback covers the wait.
About that hold tone: the first version was an invented 660 Hz beep. I looked up what the Dutch network actually does, and KPN uses 425 Hz for everything, with the meaning carried entirely by cadence: ringback is 1 s on / 4 s off, busy is 500/500, congestion 250/250. Ringback is semantically exact here ("reached, not answered yet") and every Dutch ear already knows it. So 425 Hz, 1 s / 4 s, with 10 ms fades so the sine doesn't click. It lives in rsb/audio/hold.py if your country's cadence is different.
End state
| Leg | Start | End |
|---|---|---|
| door to phone | camera + ~100 ms of our own buffering | near zero |
| phone to door | ~2 s | ~0.4 s |
The remaining 400 ms is the deliberate margin on the camera's cushion.
The third silence gate
After all of that, one more. PJSIP ships with its own VAD on (MediaConfig.noVad defaults to false). Below an adaptive threshold of its own it simply stops sending RTP toward the caller, clipping the start of a word or swallowing a quiet visitor, and logs nothing when it does. Three silence detectors were stacked on one audio path by the end (ours, the camera's cushion logic, PJSIP's) and the invisible one was found last. It's now off by default (SIP_VAD=false); the bandwidth it saves is meaningless for one G.711 call on a LAN.
The mic that wasn't there, the ending
With latency solved, the remaining complaint was that I had to be within 10 to 15 cm of the mic to be heard.
I went at it properly. The camera's API has no mic gain: GetAudioCfg exposes exactly one control, volume, which is the speaker. So I built mic_tune.py: capture one raw recording, run it offline through seven candidate ffmpeg chains (plain gain, high-pass plus afftdn denoise, speechnorm, dynaudnorm, acompressor) and write a WAV for each, so I talk to the door once and A/B-listen to all of them. An empty-room capture showed a raw noise floor of -67 dB peak, so the mic was insensitive, not noisy. Nothing captured my voice past 15 cm. Just the noise and artefacts changing.
I parked it as probably hardware. It was a defective unit. Replaced under warranty, and the new doorbell works at MIC_GAIN_DB=0: no gain, no filter chain, normal conversational distance. The 30 dB of "compensation", the 40 dB attenuation measurement, the noise-floor-relative threshold invented specifically because gain lifted room tone above any constant, all of it was engineering around a broken microphone. The threshold logic stays because it's correct anyway (a noisy doorway does the same thing gain did), but the default gain is now zero.
A sensor that's 40 dB down but otherwise functional is much harder to diagnose than one that's dead. The only reason I felt comfortable blaming the hardware is that every software path had been ruled out with a measurement first.
Running it yourself
The README explains everything, but the short version:
git clone https://github.com/WouterGritter/reolink-sip-bridge
cd reolink-sip-bridge
cp .env.example .env # fill in hosts, credentials, extensions
docker compose up -d --build
docker compose logs -f Things that will bite you if you skip the README:
- The host needs to be on the same routable network as both the PBX and the doorbell, with no NAT between any of the three. SIP and RTP put real addresses in their headers, which is also why the container runs with
network_mode: host. SIP_BIND_IPis the address of the host running the container, not the PBX. Get it wrong and registration still succeeds while audio goes nowhere.SIP_PORTmust match what's actually answering. On FreePBX that's 5160 forchan_sipand 5060 forchan_pjsip.python tools/probe.py --siptells you which.- Measure your chime.
BUTTON_CHIME_MSis 3000 on my unit; if yours is longer, the drain silently reclaims nothing. - Don't raise
SPEAKER_DRAIN_MSto 2000. A camera that runs dry stops playing until the stream is rebuilt. - For a second doorbell, run the service twice with distinct
SIP_BIND_PORTand RTP ranges. Not for effort, for fault isolation: the backchannel dies when starved, ffmpeg wedges, RTSP drops, and one camera doing any of that shouldn't touch the other's SIP registration.
Nothing in the bridge is FreePBX-specific. Any PBX that accepts a registering SIP endpoint will do.
Without a PBX
My setup is on the "complex" end: FreePBX with Asterisk, desk phones, and a trunk out to the real phone network. You don't need any of that. The bridge is just a SIP endpoint, so you could register it directly with a public SIP provider and give your doorbell a real phone number. Press the button and it calls your mobile; call the number yourself and you're connected straight to the doorbell.
One caveat if you go that route: right now the bridge auto-answers anything that reaches its extension. In my setup FreePBX decides who can reach it, so the PBX acts as a sort of firewall, in the same sense that NAT is a "firewall". Hang the bridge directly off a public number and anyone who dials it gets two-way audio to your front door. If that's your use case, add a caller ID allowlist (or a PIN) where rsb/sip/phone.py decides to answer, before you put the number anywhere.
Sources
- WouterGritter/reolink-sip-bridge: the bridge, the diagnostic tools and the full setup docs.
- pjsua2-python: PJSIP 2.15.1 as a
manylinuxwheel, which is what makes the Docker image a plainpython:3.12-slimplus ffmpeg. - reolink_aio: the Baichuan implementation used for the button.
- ONVIF Streaming Specification: the backchannel section (
www.onvif.org/ver20/backchannel) is what the speaker side implements. - Telefoontonen on nl.wikipedia: where the 425 Hz cadences come from.
- pyVoIP: note that
OPTIONSis missing fromSIPCompatibleMethodson both release and master, which makes it unusable behind FreePBX's qualify.