linux.sipsoftphone/sip/call.py
data 48ddb4b4af Initiales Release: SIP Softphone für FreePBX/Asterisk
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>
2026-02-18 13:18:54 +01:00

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