I am working on my bachelor thesis: VOIP Video Doorbell with one-way video.
I am fighting with this a issue for a lot of hours. The issue is that I am unable to make videocalls via SIP work. I sucessfully connected audio calls, and even other requested features like "unlocking doors" (switching relay on GPIO when right DTMG code is entered) works.
Just the video is not working.
For development, I am using Raspberry Pi 4 as it compiles faster the Pi Zero 2W, but after sucesfull setup, I will recompile the PJSIP (version 2.10) and make everything work on Pi Zero 2W.
My other HW is a Respeaker 2Mics HAT and Pi Camera v1.3 (provided by my university).
I tried nearly everything I was able to with my programming skills (I study IT security and IT law, not programming).
- I checked that codecs are supported on both sides (using MicroSIP on Windows as the other side)
- I contected the SIP provider (odorik.cz) and checked my setup is compatible with videocalls - their team was helpful and confirmed my calls are not being blocked on their side.
- I tried numerous options how to compile the PJSIP library with or without FFMPEG, with V4L2 support. And I read hundreds of posts talking about video support, so I can make it work by myself.
- I even tried local calls without any SIP providers (Directly calling to an IP, as both devices are in the same local network)
Unfortunately, I was unable to make it work, so I decided to share my code and logs, so anybode that understand the PJSIP library and python better than me can help me get out of this hell of hunders of hours in this issue.
here is my main file Doorbelly.py that controls everything:
import sys
import time
import threading
import configparser
from os.path import exists
import RPi.GPIO as GPIO
import lock
import pjsua2 as pj
import endpoint
import settings
import account
import call
BUTTON = 13
GPIO.setmode(GPIO.BCM)
GPIO.setup(BUTTON, GPIO.IN, pull_up_down=GPIO.PUD_DOWN)
write=sys.stdout.write
configPath = sys.argv[1]
if(not exists(configPath)):
print("Path to config is not set or is not valid path!")
quit()
# Load config
userConfig = configparser.ConfigParser()
userConfig.read(configPath)
# Prepare lock class
lockInst = lock.Lock(userConfig['lock']['passcode'])
# Accounts
accList = []
ep = endpoint.Endpoint()
ep.libCreate()
# Default config
ep.autoTransmitOutgoing = True
ep.autoShowIncoming = False
appConfig = settings.AppConfig()
# Threading
appConfig.epConfig.uaConfig.threadCnt = 0
appConfig.epConfig.uaConfig.mainThreadOnly = True
# Config
appConfig.epConfig.uaConfig.maxCalls = 1
appConfig.epConfig.medConfig.clockRate = 44100 # Set the sample rate to 41000 Hz
appConfig.epConfig.medConfig.maxCodecCnt = 1
appConfig.epConfig.medConfig.maxActiveCodecCnt = 2
appConfig.epConfig.medConfig.defaultCaptureDevice = 0 # Use the first available capture device
ep.libInit(appConfig.epConfig)
ep.transportCreate(appConfig.udp.type, appConfig.udp.config)
#configure the library
config = pj.AccountConfig()
config.idUri = userConfig['pjsua2']['id']
config.regConfig.registrarUri = userConfig['pjsua2']['registrarUri']
config.presConfig.publishEnabled = True
cred = pj.AuthCredInfo()
cred.realm = userConfig['pjsua2']['realm']
cred.scheme = userConfig['pjsua2']['scheme']
cred.username = userConfig['pjsua2']['username']
cred.data = userConfig['pjsua2']['password']
config.sipConfig.authCreds.append(cred)
if not endpoint.validateSipUri(config.idUri):
print("ERROR IN ID URI")
if not endpoint.validateSipUri(config.regConfig.registrarUri):
print("ERROR IN REGISTRAR URI")
if not endpoint.validateSipUri(config.regConfig.contactParams):
print("ERROR IN CONTANT PARAMS")
account = account.Account()
account.create(config)
# Start library
ep.libStart()
ep.libHandleEvents(10)
# Initialize an ongoing_call variable before the loop
ongoing_call = None
while True:
input = GPIO.input(BUTTON)
if input == GPIO.HIGH: # Button is pressed
# Check if there's an ongoing call
if ongoing_call is None or ongoing_call.isCallDisconnected():
print("No call detected, creating a new one to " + userConfig['lock']['targetVoipUri'])
call_param = pj.CallOpParam()
call_param.opt.audioCount = 1
call_param.opt.videoCount = 1
call_setting = pj.CallSetting()
call_setting.audioCount = 1
call_setting.videoCount = 1
call_param.opt.callSetting = call_setting
ongoing_call = call.Call(account, userConfig['lock']['targetVoipUri'], lock=lockInst)
ongoing_call.makeCall(userConfig['lock']['targetVoipUri'], call_param)
else:
print("There's an ongoing call, can't make a new one.")
ep.libHandleEvents(10)
ep.libHandleEvents(10)
time.sleep(0.1) # Add a short delay to avoid excessive CPU usage
Then here is call.py file that controls the call itself:
# $Id$
import sys
import random
import pjsua2 as pj
import endpoint as ep
import lock
import time
# Call class
class Call(pj.Call):
"""
High level Python Call object, derived from pjsua2's Call object.
"""
def __init__(self, acc, peer_uri='', chat=None, call_id = pj.PJSUA_INVALID_ID, lock=None):
pj.Call.__init__(self, acc, call_id)
self.acc = acc
self.peerUri = peer_uri
self.chat = chat
self.connected = False
self.onhold = False
self.lockInst = lock
def onCallState(self, prm):
ci = self.getInfo()
self.connected = ci.state == pj.PJSIP_INV_STATE_CONFIRMED
if ci.state == pj.PJSIP_INV_STATE_CONFIRMED:
time.sleep(3)
print("---------Call answered---------")
param = pj.CallVidSetStreamParam()
param.dir = pj.PJMEDIA_DIR_CAPTURE
param.medIdx = 1
self.vidSetStream(pj.PJSUA_CALL_VID_STRM_ADD, param)
def onCallMediaState(self, prm):
ci = self.getInfo()
print("Call state:", ci.state)
for mi in ci.media:
if mi.type == pj.PJMEDIA_TYPE_AUDIO and \
(mi.status == pj.PJSUA_CALL_MEDIA_ACTIVE or \
mi.status == pj.PJSUA_CALL_MEDIA_REMOTE_HOLD):
m = self.getMedia(mi.index)
am = pj.AudioMedia.typecastFromMedia(m)
# connect ports
ep.Endpoint.instance.audDevManager().getCaptureDevMedia().startTransmit(am)
am.startTransmit(ep.Endpoint.instance.audDevManager().getPlaybackDevMedia())
if mi.status == pj.PJSUA_CALL_MEDIA_REMOTE_HOLD and not self.onhold:
self.chat.addMessage(None, "'%s' sets call onhold" % (self.peerUri))
self.onhold = True
elif mi.status == pj.PJSUA_CALL_MEDIA_ACTIVE and self.onhold:
self.chat.addMessage(None, "'%s' sets call active" % (self.peerUri))
self.onhold = False
if self.chat:
self.chat.updateCallMediaState(self, ci)
def onDtmfDigit(self, prm):
print("Received DTMF digit: " + prm.digit)
if(prm.digit == "#"):
print("Sending OK")
self.lockInst.receiveOk()
return
if(prm.digit == "*"):
print("Sending reset")
self.lockInst.receiveReset()
return
self.lockInst.addDigit(prm.digit)
def onCallMediaTransportState(self, prm):
#msgbox.showinfo("pygui", "Media transport state")
pass
def setLock(self, lock):
self.lockInst = lock
# Check for call disconnection to prevent crashes when button is pressed repeatedly
def isCallDisconnected(self):
try:
call_info = self.getInfo()
return call_info.state in (pj.PJSIP_INV_STATE_DISCONNECTED, pj.PJSIP_INV_STATE_NULL)
except pj.Error:
return True
And just linking other files like lock.py used for unlocking the door: https://pastebin.com/nB3hGz4s
Here is my config loaded by the doorbell.py and used for controlling the call destination and lock password:
[pjsua2]
id = sip:XXXXX@sip.odorik.cz
registrarUri = sip:sip.odorik.cz
realm = sip.odorik.cz
scheme = digest
username = XXXXXX
password = YYYYYY
[lock]
passcode = 123
targetVoipUri = sip:CALLEDID@sip.odorik.cz
And finally, here is my log of the call: https://pastebin.com/fFMN2HnG
But the most important problems from the log are (at least I think):
17:59:29.376 inv0xf293bc ....SDP negotiation done: Success
17:59:29.376 pjsua_media.c .....Call 0: updating media..
17:59:29.376 pjsua_media.c .......Media stream call00:0 is destroyed
17:59:29.376 pjsua_aud.c ......Audio channel update..
17:59:29.376 strm0xf3d0d4 .......VAD temporarily disabled
17:59:29.376 strm0xf3d0d4 .......Encoder stream started
17:59:29.376 strm0xf3d0d4 .......Decoder stream started
17:59:29.377 pjsua_media.c ......Audio updated, stream #0: GSM (sendrecv)
17:59:29.377 pjsua_media.c .......Media stream call00:1 is destroyed
17:59:29.377 pjsua_vid.c ......Video channel update..
17:59:29.403 vstenc0xf4238c .......Encoder stream started
17:59:29.403 vstdec0xf4238c .......Decoder stream started
17:59:29.403 pjsua_vid.c .......Setting up RX..
17:59:29.403 pjsua_vid.c ........Creating video window: type=stream, cap_id=-1, rend_id=1480776
17:59:29.403 pjsua_vid.c .........Window 0: destroying..
17:59:29.403 pjsua_media.c ......pjsua_vid_channel_update() failed for call_id 0 media 1: Invalid video device (PJMEDIA_EVID_INVDEV)
17:59:29.403 pjsua_vid.c .......Stopping video stream..
17:59:29.405 pjsua_media.c .......Media stream call00:1 is destroyed
17:59:29.405 pjsua_media.c ......Error updating media call00:1: Invalid video device (PJMEDIA_EVID_INVDEV)
17:59:29.406 pjsua_aud.c .....Conf connect: 0 --> 1
What I understand from this is that it tries to open an RX stream and its window, but it failes as there is no screen attached to the RPi (and it will not be in the project)
So my idea was to retry opening a new video stream with just outgoing direction once the call is estabilished. But that fails also.
17:59:32.410 pjsua_vid.c !.....Call 0: set video stream, op=1
17:59:32.410 pjsua_media.c ......RTP socket reachable at 10.9.0.41:4004
17:59:32.410 pjsua_media.c ......RTCP socket reachable at 10.9.0.41:4005
17:59:32.411 pjsua_vid.c ......Unable to create re-INVITE: Invalid operation (PJ_EINVALIDOP) [status=70013]
17:59:32.411 call.cpp .....pjsua_call_set_vid_strm(id, op, &prm) error: Invalid operation (PJ_EINVALIDOP) (status=70013) [../src/pjsua2/call.cpp:826]
I am sorry for such a long report and I thank you in advance for helping me with this issue. I am not a dead end and have no idea what to try next.
Thank you!