PySide6/PJSUA2-basiertes Desktop-Softphone mit: - SIP-Telefonie (Anrufe, DTMF, Halten, Transfer, Konferenz) - CardDAV-Kontaktsync mit Write-Back (Multi-Account) - Kontaktverwaltung mit Suche und Bearbeiten-Dialog - Anrufliste mit Namensauflösung - Favoriten-Panel (manuell + häufig angerufen) - BLF-Panel (SIP Presence Monitoring) - Responsives Layout (kompakt/erweitert) - Click-to-Call (tel:/sip: URI via D-Bus) - KDE-Integration (Tray, Benachrichtigungen) - PipeWire/PulseAudio Audio-Routing - AppImage Build-Support (PyInstaller + appimagetool) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
152 lines
5.3 KiB
Python
152 lines
5.3 KiB
Python
"""SIP-Anruf Klasse - Wrapper um pjsua2.Call mit Qt-Signalweiterleitung."""
|
|
|
|
import pjsua2 as pj
|
|
|
|
|
|
class MeinAnruf(pj.Call):
|
|
"""Repräsentiert einen einzelnen SIP-Anruf.
|
|
|
|
Leitet PJSUA2-Callbacks an eine Callback-Funktion weiter,
|
|
die dann Qt-Signals im Hauptthread auslöst.
|
|
|
|
Audio-Verbindung wird von PJSUA2 automatisch über die Conference Bridge
|
|
gemanagt (setCaptureDev/setPlaybackDev). Kein manuelles startTransmit nötig.
|
|
"""
|
|
|
|
def __init__(self, acc, call_id=pj.PJSUA_INVALID_ID, callback=None):
|
|
pj.Call.__init__(self, acc, call_id)
|
|
self._callback = callback
|
|
self._ist_aktiv = False
|
|
|
|
@property
|
|
def ist_aktiv(self):
|
|
"""Gibt True zurück wenn der Anruf gerade verbunden ist."""
|
|
return self._ist_aktiv
|
|
|
|
def onCallState(self, prm):
|
|
"""Wird bei jeder Änderung des Anrufstatus aufgerufen."""
|
|
ci = self.getInfo()
|
|
zustand = ci.stateText
|
|
|
|
if ci.state == pj.PJSIP_INV_STATE_CONFIRMED:
|
|
self._ist_aktiv = True
|
|
elif ci.state == pj.PJSIP_INV_STATE_DISCONNECTED:
|
|
self._ist_aktiv = False
|
|
|
|
if self._callback:
|
|
self._callback("zustand", {
|
|
"state": ci.state,
|
|
"stateText": zustand,
|
|
"remoteUri": ci.remoteUri,
|
|
"lastReason": ci.lastReason,
|
|
"lastStatusCode": ci.lastStatusCode,
|
|
"connectDuration": ci.connectDuration.sec,
|
|
})
|
|
|
|
def onCallMediaState(self, prm):
|
|
"""Audio-Verbindung bei aktivem Media sicherstellen.
|
|
|
|
startTransmit() auf bereits verbundenen Ports ist ein No-Op,
|
|
daher sicher bei mehrfachem Aufruf (183→200 Neuverhandlung).
|
|
"""
|
|
ci = self.getInfo()
|
|
for i, mi in enumerate(ci.media):
|
|
if mi.type == pj.PJMEDIA_TYPE_AUDIO and \
|
|
mi.status == pj.PJSUA_CALL_MEDIA_ACTIVE:
|
|
try:
|
|
call_audio = self.getAudioMedia(i)
|
|
aud_mgr = pj.Endpoint.instance().audDevManager()
|
|
# Mikrofon → Anruf
|
|
aud_mgr.getCaptureDevMedia().startTransmit(call_audio)
|
|
# Anruf → Lautsprecher
|
|
call_audio.startTransmit(
|
|
aud_mgr.getPlaybackDevMedia()
|
|
)
|
|
except pj.Error:
|
|
pass
|
|
|
|
def onCallTransferRequest(self, prm):
|
|
"""Eingehende Weiterleitungs-Anfrage."""
|
|
if self._callback:
|
|
self._callback("transfer_anfrage", {"ziel": prm.currentTarget})
|
|
|
|
def onDtmfDigit(self, prm):
|
|
"""DTMF-Ton empfangen."""
|
|
if self._callback:
|
|
self._callback("dtmf_empfangen", {"digit": prm.digit})
|
|
|
|
def dtmf_senden(self, ziffern):
|
|
"""DTMF-Töne senden (z.B. für IVR-Menüs)."""
|
|
try:
|
|
prm = pj.CallSendDtmfParam()
|
|
prm.digits = ziffern
|
|
prm.method = pj.PJSUA_DTMF_METHOD_RFC2833
|
|
self.sendDtmf(prm)
|
|
except pj.Error as e:
|
|
if self._callback:
|
|
self._callback("fehler", {"text": f"DTMF-Fehler: {e}"})
|
|
|
|
def halten(self):
|
|
"""Anruf auf Halten setzen."""
|
|
try:
|
|
prm = pj.CallOpParam(True)
|
|
self.setHold(prm)
|
|
except pj.Error as e:
|
|
if self._callback:
|
|
self._callback("fehler", {"text": f"Hold-Fehler: {e}"})
|
|
|
|
def fortsetzen(self):
|
|
"""Gehaltenen Anruf fortsetzen."""
|
|
try:
|
|
prm = pj.CallOpParam(True)
|
|
prm.opt.flag = pj.PJSUA_CALL_UNHOLD
|
|
prm.opt.audioCount = 1
|
|
prm.opt.videoCount = 0
|
|
self.reinvite(prm)
|
|
except pj.Error as e:
|
|
if self._callback:
|
|
self._callback("fehler", {"text": f"Unhold-Fehler: {e}"})
|
|
|
|
def stummschalten(self, stumm):
|
|
"""Mikrofon stumm schalten / wieder aktivieren.
|
|
|
|
Nutzt adjustTxLevel() statt stopTransmit() - zuverlässiger
|
|
weil es die Conference-Bridge-Verbindung nicht unterbricht.
|
|
"""
|
|
try:
|
|
aud_mgr = pj.Endpoint.instance().audDevManager()
|
|
if stumm:
|
|
# TX-Level auf 0 = Stille senden
|
|
aud_mgr.getCaptureDevMedia().adjustTxLevel(0.0)
|
|
else:
|
|
# TX-Level auf 1 = normale Lautstärke
|
|
aud_mgr.getCaptureDevMedia().adjustTxLevel(1.0)
|
|
except pj.Error:
|
|
pass
|
|
|
|
def blind_transfer(self, ziel_uri):
|
|
"""Blinde Weiterleitung an eine andere Nummer."""
|
|
try:
|
|
prm = pj.CallOpParam()
|
|
self.xfer(ziel_uri, prm)
|
|
except pj.Error as e:
|
|
if self._callback:
|
|
self._callback("fehler", {"text": f"Transfer-Fehler: {e}"})
|
|
|
|
def attended_transfer(self, anderer_anruf):
|
|
"""Vermittelte Weiterleitung (Attended Transfer)."""
|
|
try:
|
|
prm = pj.CallOpParam()
|
|
self.xferReplaces(anderer_anruf, prm)
|
|
except pj.Error as e:
|
|
if self._callback:
|
|
self._callback("fehler", {"text": f"Attended-Transfer-Fehler: {e}"})
|
|
|
|
def auflegen(self):
|
|
"""Anruf beenden."""
|
|
try:
|
|
prm = pj.CallOpParam()
|
|
prm.statusCode = pj.PJSIP_SC_DECLINE
|
|
self.hangup(prm)
|
|
except pj.Error:
|
|
pass # Anruf war bereits beendet
|