linux.sipsoftphone/sip/engine.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

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