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>
292 lines
10 KiB
Python
292 lines
10 KiB
Python
"""SIP-Engine - Verbindet PJSUA2 mit dem Qt-Event-Loop."""
|
|
|
|
import pjsua2 as pj
|
|
from PySide6.QtCore import QObject, Signal, QTimer
|
|
|
|
from sip.account import MeinAccount
|
|
|
|
|
|
class SipEngine(QObject):
|
|
"""Hauptklasse: PJSUA2-Endpoint mit Qt-Signal-Integration.
|
|
|
|
Kritische Implementierungsdetails:
|
|
- threadCnt=0: PJSUA2 erstellt KEINE eigenen Worker-Threads
|
|
- mainThreadOnly=True: Alle Callbacks werden im Hauptthread ausgeführt
|
|
- QTimer pollt libHandleEvents() alle 50ms im Qt-Event-Loop
|
|
"""
|
|
|
|
# Qt-Signals für Thread-sichere UI-Updates
|
|
registrierung_geaendert = Signal(dict) # {aktiv, code, grund, uri}
|
|
anruf_zustand_geaendert = Signal(dict) # {state, stateText, remoteUri, ...}
|
|
eingehender_anruf = Signal(dict) # {remoteUri, remoteContact}
|
|
anruf_beendet = Signal(dict) # {lastReason, lastStatusCode, ...}
|
|
dtmf_empfangen = Signal(str) # Empfangene DTMF-Ziffer
|
|
fehler = Signal(str) # Fehlermeldung
|
|
transfer_anfrage = Signal(str) # Transfer-Ziel
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.ep = None
|
|
self.account = None
|
|
self.poll_timer = None
|
|
self._server = ""
|
|
self._ist_initialisiert = False
|
|
|
|
def initialisieren(self):
|
|
"""PJSUA2-Endpoint erstellen und konfigurieren."""
|
|
if self._ist_initialisiert:
|
|
return
|
|
|
|
self.ep = pj.Endpoint()
|
|
self.ep.libCreate()
|
|
|
|
ep_cfg = pj.EpConfig()
|
|
|
|
# PFLICHT für Python + Qt: Keine Worker-Threads
|
|
ep_cfg.uaConfig.threadCnt = 0
|
|
ep_cfg.uaConfig.mainThreadOnly = True
|
|
|
|
# User-Agent setzen
|
|
ep_cfg.uaConfig.userAgent = "SipWebApp/1.0 (PJSUA2/Python)"
|
|
|
|
# Logging (Level 4 = normal, 5 = debug)
|
|
ep_cfg.logConfig.level = 4
|
|
ep_cfg.logConfig.consoleLevel = 4
|
|
|
|
self.ep.libInit(ep_cfg)
|
|
|
|
# UDP-Transport anlegen (IPv4 erzwingen)
|
|
tp_cfg = pj.TransportConfig()
|
|
tp_cfg.port = 0 # Automatische Port-Wahl
|
|
tp_cfg.boundAddress = "0.0.0.0" # IPv4 erzwingen
|
|
self.ep.transportCreate(pj.PJSIP_TRANSPORT_UDP, tp_cfg)
|
|
|
|
self.ep.libStart()
|
|
|
|
# QTimer für PJSIP-Event-Polling (50ms)
|
|
self.poll_timer = QTimer(self)
|
|
self.poll_timer.timeout.connect(self._poll_events)
|
|
self.poll_timer.start(50)
|
|
|
|
self._ist_initialisiert = True
|
|
|
|
def _poll_events(self):
|
|
"""Verarbeite PJSIP-Events im Qt-Hauptthread (non-blocking)."""
|
|
if self.ep:
|
|
try:
|
|
self.ep.libHandleEvents(0)
|
|
except pj.Error:
|
|
pass # Endpoint bereits zerstört
|
|
|
|
def registrieren(self, server, extension, passwort, port=5060):
|
|
"""Mit FreePBX registrieren.
|
|
|
|
Args:
|
|
server: FreePBX IP-Adresse oder Hostname
|
|
extension: SIP-Extension (z.B. "200")
|
|
passwort: SIP-Passwort
|
|
port: SIP-Port (Standard: 5060)
|
|
"""
|
|
if not self._ist_initialisiert:
|
|
self.initialisieren()
|
|
|
|
self._server = server
|
|
|
|
# Bestehenden Account sauber abräumen
|
|
if self.account:
|
|
try:
|
|
# Erst Registrierung aufheben, dann warten
|
|
self.account.setRegistration(False)
|
|
except pj.Error:
|
|
pass
|
|
# Pending Transactions abarbeiten lassen
|
|
try:
|
|
self.ep.libHandleEvents(500)
|
|
except pj.Error:
|
|
pass
|
|
try:
|
|
self.account.shutdown()
|
|
except pj.Error:
|
|
pass
|
|
self.account = None
|
|
|
|
acc_cfg = pj.AccountConfig()
|
|
acc_cfg.idUri = f"sip:{extension}@{server}"
|
|
acc_cfg.regConfig.registrarUri = f"sip:{server}:{port}"
|
|
|
|
# Anmeldedaten
|
|
cred = pj.AuthCredInfo()
|
|
cred.scheme = "digest"
|
|
cred.realm = "*"
|
|
cred.username = extension
|
|
cred.dataType = 0 # Klartext-Passwort
|
|
cred.data = passwort
|
|
acc_cfg.sipConfig.authCreds.append(cred)
|
|
|
|
# Outbound-Proxy: ALLE SIP-Requests über den richtigen Port routen
|
|
acc_cfg.sipConfig.proxies.append(f"sip:{server}:{port}")
|
|
|
|
# NAT-Konfiguration (kein ICE nötig im lokalen Netz,
|
|
# ICE erzeugt IPv6-Kandidaten die Asterisk nicht versteht)
|
|
acc_cfg.natConfig.iceEnabled = False
|
|
acc_cfg.natConfig.sdpNatRewriteUse = 1
|
|
|
|
# Registrierung alle 300 Sekunden erneuern
|
|
acc_cfg.regConfig.timeoutSec = 300
|
|
|
|
self.account = MeinAccount(callback=self._sip_callback)
|
|
self.account.create(acc_cfg)
|
|
|
|
def abmelden(self):
|
|
"""SIP-Registrierung aufheben."""
|
|
if self.account:
|
|
try:
|
|
self.account.setRegistration(False)
|
|
except pj.Error:
|
|
pass
|
|
|
|
def anruf_starten(self, ziel_nummer):
|
|
"""Ausgehenden Anruf starten."""
|
|
if not self.account:
|
|
self.fehler.emit("Nicht registriert - kann keinen Anruf starten")
|
|
return None
|
|
|
|
ziel_uri = f"sip:{ziel_nummer}@{self._server}"
|
|
return self.account.anruf_starten(ziel_uri)
|
|
|
|
def anruf_annehmen(self):
|
|
"""Eingehenden Anruf annehmen."""
|
|
if self.account:
|
|
self.account.anruf_annehmen()
|
|
|
|
def anruf_beenden(self):
|
|
"""Aktiven Anruf beenden."""
|
|
if self.account:
|
|
self.account.anruf_beenden()
|
|
|
|
def dtmf_senden(self, ziffern):
|
|
"""DTMF-Töne senden."""
|
|
if self.account and self.account.aktueller_anruf:
|
|
self.account.aktueller_anruf.dtmf_senden(ziffern)
|
|
|
|
def halten(self):
|
|
"""Aktuellen Anruf auf Halten setzen."""
|
|
if self.account and self.account.aktueller_anruf:
|
|
self.account.aktueller_anruf.halten()
|
|
|
|
def fortsetzen(self):
|
|
"""Gehaltenen Anruf fortsetzen."""
|
|
if self.account and self.account.aktueller_anruf:
|
|
self.account.aktueller_anruf.fortsetzen()
|
|
|
|
def stummschalten(self, stumm):
|
|
"""Mikrofon stumm schalten."""
|
|
if self.account and self.account.aktueller_anruf:
|
|
self.account.aktueller_anruf.stummschalten(stumm)
|
|
|
|
def blind_transfer(self, ziel_nummer):
|
|
"""Blinde Weiterleitung."""
|
|
if self.account and self.account.aktueller_anruf:
|
|
ziel_uri = f"sip:{ziel_nummer}@{self._server}"
|
|
self.account.aktueller_anruf.blind_transfer(ziel_uri)
|
|
|
|
def konferenz_starten(self, zweite_nummer):
|
|
"""3er-Konferenz: Zweiten Anruf starten und Audio verbinden."""
|
|
if not self.account or not self.account.aktueller_anruf:
|
|
self.fehler.emit("Kein aktiver Anruf für Konferenz")
|
|
return None
|
|
|
|
# Zweiten Anruf starten
|
|
ziel_uri = f"sip:{zweite_nummer}@{self._server}"
|
|
zweiter_anruf = self.account.anruf_starten(ziel_uri)
|
|
# Audio-Verbindung wird automatisch über die Conference Bridge hergestellt
|
|
return zweiter_anruf
|
|
|
|
def audiogeraete_auflisten(self):
|
|
"""Gibt Liste aller verfügbaren Audiogeräte zurück."""
|
|
if not self.ep:
|
|
return []
|
|
|
|
geraete = []
|
|
try:
|
|
dev_list = self.ep.audDevManager().enumDev()
|
|
for i, dev in enumerate(dev_list):
|
|
geraete.append({
|
|
"id": i,
|
|
"name": dev.name,
|
|
"eingaenge": dev.inputCount,
|
|
"ausgaenge": dev.outputCount,
|
|
"driver": dev.driver,
|
|
})
|
|
except pj.Error:
|
|
pass
|
|
return geraete
|
|
|
|
def audiogeraet_setzen(self, aufnahme_id=None, wiedergabe_id=None):
|
|
"""Audiogeräte für Aufnahme und/oder Wiedergabe setzen."""
|
|
if not self.ep:
|
|
return
|
|
try:
|
|
if aufnahme_id is not None:
|
|
self.ep.audDevManager().setCaptureDev(aufnahme_id)
|
|
if wiedergabe_id is not None:
|
|
self.ep.audDevManager().setPlaybackDev(wiedergabe_id)
|
|
except pj.Error as e:
|
|
self.fehler.emit(f"Audiogerät-Fehler: {e}")
|
|
|
|
def lautstaerke_setzen(self, rx_level=1.0, tx_level=1.0):
|
|
"""Lautstärke anpassen (0.0 = stumm, 1.0 = normal, 2.0 = doppelt)."""
|
|
if not self.ep:
|
|
return
|
|
try:
|
|
aud_mgr = self.ep.audDevManager()
|
|
# Empfangslautstärke (Lautsprecher)
|
|
aud_mgr.getPlaybackDevMedia().adjustRxLevel(rx_level)
|
|
# Sendelautstärke (Mikrofon)
|
|
aud_mgr.getCaptureDevMedia().adjustTxLevel(tx_level)
|
|
except pj.Error:
|
|
pass
|
|
|
|
def _sip_callback(self, ereignis, daten):
|
|
"""Interne Callback-Funktion → leitet an Qt-Signals weiter."""
|
|
if ereignis == "registrierung":
|
|
self.registrierung_geaendert.emit(daten)
|
|
elif ereignis == "eingehend":
|
|
self.eingehender_anruf.emit(daten)
|
|
elif ereignis == "zustand":
|
|
self.anruf_zustand_geaendert.emit(daten)
|
|
# Bei Disconnect auch anruf_beendet Signal senden
|
|
if daten.get("state") == pj.PJSIP_INV_STATE_DISCONNECTED:
|
|
self.anruf_beendet.emit(daten)
|
|
# Anruf aus der Liste entfernen
|
|
if self.account and self.account.aktueller_anruf:
|
|
self.account.anruf_entfernen(
|
|
self.account.aktueller_anruf
|
|
)
|
|
elif ereignis == "dtmf_empfangen":
|
|
self.dtmf_empfangen.emit(daten.get("digit", ""))
|
|
elif ereignis == "fehler":
|
|
self.fehler.emit(daten.get("text", "Unbekannter Fehler"))
|
|
elif ereignis == "transfer_anfrage":
|
|
self.transfer_anfrage.emit(daten.get("ziel", ""))
|
|
|
|
def beenden(self):
|
|
"""PJSUA2-Endpoint sauber herunterfahren."""
|
|
if self.poll_timer:
|
|
self.poll_timer.stop()
|
|
|
|
if self.account:
|
|
try:
|
|
self.account.shutdown()
|
|
except pj.Error:
|
|
pass
|
|
self.account = None
|
|
|
|
if self.ep:
|
|
try:
|
|
self.ep.libDestroy()
|
|
except pj.Error:
|
|
pass
|
|
self.ep = None
|
|
|
|
self._ist_initialisiert = False
|