"""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