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>
767 lines
29 KiB
Python
767 lines
29 KiB
Python
"""Hauptfenster - Zentrales Fenster der SIP-Softphone-Anwendung."""
|
|
|
|
import os
|
|
import re
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from PySide6.QtWidgets import (
|
|
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
|
QPushButton, QLabel, QTabWidget, QSlider, QStatusBar,
|
|
QSystemTrayIcon, QMenu, QMessageBox, QInputDialog,
|
|
QSplitter, QSizePolicy,
|
|
)
|
|
from PySide6.QtCore import Qt, QTimer
|
|
from PySide6.QtGui import QIcon, QAction, QShortcut, QKeySequence
|
|
|
|
# Icon-Pfad relativ zum Projektverzeichnis
|
|
_ICON_PFAD = Path(__file__).parent.parent / "resources" / "icons" / "phone.svg"
|
|
|
|
from sip.engine import SipEngine
|
|
from utils.config_manager import ConfigManager
|
|
from utils.audio_manager import AudioManager
|
|
from utils.klingelton import KlingeltonPlayer
|
|
from utils.benachrichtigung import AnrufBenachrichtigung
|
|
from ui.waehlfeld import Waehlfeld
|
|
from ui.anrufliste import AnruflisteWidget
|
|
from ui.kontakte import KontakteWidget
|
|
from ui.einstellungen import EinstellungenDialog
|
|
from ui.blf_panel import BlfPanel
|
|
from ui.favoriten_panel import FavoritenPanel
|
|
|
|
|
|
class HauptFenster(QMainWindow):
|
|
"""Hauptfenster der SIP-Softphone-Anwendung."""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.setWindowTitle("SIP Softphone")
|
|
self.setMinimumSize(380, 600)
|
|
|
|
# App-Icon setzen
|
|
if _ICON_PFAD.exists():
|
|
self._app_icon = QIcon(str(_ICON_PFAD))
|
|
self.setWindowIcon(self._app_icon)
|
|
else:
|
|
self._app_icon = self.windowIcon()
|
|
|
|
# Kern-Komponenten
|
|
self._config = ConfigManager()
|
|
self._sip = SipEngine()
|
|
self._audio = AudioManager(self._sip)
|
|
self._klingelton = KlingeltonPlayer(self)
|
|
self._benachrichtigung = AnrufBenachrichtigung()
|
|
self._ist_stumm = False
|
|
self._ist_gehalten = False
|
|
self._anruf_start_zeit = None
|
|
self._anruf_timer = QTimer(self)
|
|
self._anruf_timer.timeout.connect(self._anruf_dauer_aktualisieren)
|
|
self._breit_modus = None # Wird in resizeEvent gesetzt
|
|
|
|
# UI aufbauen
|
|
self._ui_aufbauen()
|
|
self._menue_aufbauen()
|
|
self._tray_aufbauen()
|
|
self._hotkeys_aufbauen()
|
|
self._signale_verbinden()
|
|
|
|
# Kontaktliste ans Wählfeld + Favoriten
|
|
self._kontakte_an_waehlfeld()
|
|
self._favoriten_aktualisieren()
|
|
|
|
def _ui_aufbauen(self):
|
|
"""Hauptlayout mit Splitter: Links Wählfeld, Rechts Tabs."""
|
|
zentral = QWidget()
|
|
self.setCentralWidget(zentral)
|
|
layout = QVBoxLayout(zentral)
|
|
|
|
# === Status-Bereich (feste Höhe, nicht stretchen) ===
|
|
status_layout = QHBoxLayout()
|
|
self.status_label = QLabel("Nicht verbunden")
|
|
self.status_label.setStyleSheet(
|
|
"font-size: 13px; padding: 4px; "
|
|
"background-color: #666; color: white; border-radius: 4px;"
|
|
)
|
|
self.status_label.setSizePolicy(
|
|
QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
|
|
status_layout.addWidget(self.status_label)
|
|
self.dauer_label = QLabel("")
|
|
self.dauer_label.setStyleSheet("font-size: 13px; font-weight: bold;")
|
|
status_layout.addWidget(self.dauer_label)
|
|
status_layout.addStretch()
|
|
layout.addLayout(status_layout, 0) # Stretch=0: nicht wachsen
|
|
|
|
# === Anruf-Info ===
|
|
self.anruf_info_label = QLabel("")
|
|
self.anruf_info_label.setStyleSheet(
|
|
"font-size: 16px; font-weight: bold; padding: 8px;"
|
|
)
|
|
self.anruf_info_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.anruf_info_label.hide()
|
|
self.anruf_info_label.setSizePolicy(
|
|
QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
|
layout.addWidget(self.anruf_info_label, 0) # Stretch=0
|
|
|
|
# === Widgets erstellen ===
|
|
self.waehlfeld = Waehlfeld()
|
|
self.kontakte = KontakteWidget(self._config)
|
|
self.anrufliste = AnruflisteWidget(self._config)
|
|
self.blf_panel = BlfPanel()
|
|
self.favoriten = FavoritenPanel(self._config)
|
|
|
|
# === Splitter: Links (Wählfeld) | Rechts (Tabs) ===
|
|
self.splitter = QSplitter(Qt.Orientation.Horizontal)
|
|
self.splitter.setChildrenCollapsible(False)
|
|
self.splitter.setHandleWidth(1)
|
|
self.splitter.setStyleSheet(
|
|
"QSplitter::handle { background-color: #555; }")
|
|
|
|
# Linke Seite: Wählfeld in eigenem Container
|
|
self._links_widget = QWidget()
|
|
self._links_widget.setObjectName("telefon_panel")
|
|
links_layout = QVBoxLayout(self._links_widget)
|
|
links_layout.setContentsMargins(6, 6, 6, 6)
|
|
links_layout.addWidget(self.waehlfeld)
|
|
|
|
# Rechte Seite: Tabs (Favoriten, Verlauf, Kontakte, BLF)
|
|
self.tabs = QTabWidget()
|
|
self.tabs.addTab(self.favoriten, "Favoriten")
|
|
self.tabs.addTab(self.anrufliste, "Verlauf")
|
|
self.tabs.addTab(self.kontakte, "Kontakte")
|
|
self.tabs.addTab(self.blf_panel, "BLF")
|
|
|
|
self.splitter.addWidget(self._links_widget)
|
|
self.splitter.addWidget(self.tabs)
|
|
self.splitter.setStretchFactor(0, 0) # Links: nicht stretchen
|
|
self.splitter.setStretchFactor(1, 1) # Rechts: stretcht mit
|
|
layout.addWidget(self.splitter, 1) # Stretch=1: bekommt allen extra Platz
|
|
|
|
# === Anruf-Steuerung ===
|
|
steuerung_layout = QHBoxLayout()
|
|
|
|
btn_style_vorlage = (
|
|
"QPushButton {{ "
|
|
" background-color: {bg}; color: white; "
|
|
" font-size: 14px; font-weight: bold; border-radius: 8px; "
|
|
" border: none; "
|
|
"}}"
|
|
"QPushButton:hover {{ background-color: {hover}; }}"
|
|
"QPushButton:pressed {{ background-color: {pressed}; }}"
|
|
)
|
|
|
|
self.anrufen_btn = QPushButton("Anrufen")
|
|
self.anrufen_btn.setMinimumHeight(42)
|
|
self.anrufen_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
self.anrufen_btn.setStyleSheet(btn_style_vorlage.format(
|
|
bg="#4CAF50", hover="#45a049", pressed="#388E3C"))
|
|
self.anrufen_btn.clicked.connect(self._anrufen)
|
|
|
|
self.annehmen_btn = QPushButton("Annehmen")
|
|
self.annehmen_btn.setMinimumHeight(42)
|
|
self.annehmen_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
self.annehmen_btn.setStyleSheet(btn_style_vorlage.format(
|
|
bg="#2196F3", hover="#1e88e5", pressed="#1565C0"))
|
|
self.annehmen_btn.clicked.connect(self._annehmen)
|
|
self.annehmen_btn.hide()
|
|
|
|
self.auflegen_btn = QPushButton("Auflegen")
|
|
self.auflegen_btn.setMinimumHeight(42)
|
|
self.auflegen_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
self.auflegen_btn.setStyleSheet(btn_style_vorlage.format(
|
|
bg="#F44336", hover="#e53935", pressed="#C62828"))
|
|
self.auflegen_btn.clicked.connect(self._auflegen)
|
|
self.auflegen_btn.hide()
|
|
|
|
steuerung_layout.addWidget(self.anrufen_btn)
|
|
steuerung_layout.addWidget(self.annehmen_btn)
|
|
steuerung_layout.addWidget(self.auflegen_btn)
|
|
layout.addLayout(steuerung_layout, 0) # Stretch=0
|
|
|
|
# === Gespräch-Aktionen (während Anruf sichtbar) ===
|
|
self.gespraech_widget = QWidget()
|
|
gespraech_layout = QHBoxLayout(self.gespraech_widget)
|
|
gespraech_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
self.stumm_btn = QPushButton("Stumm")
|
|
self.stumm_btn.setCheckable(True)
|
|
self.stumm_btn.clicked.connect(self._stumm_umschalten)
|
|
gespraech_layout.addWidget(self.stumm_btn)
|
|
|
|
self.halten_btn = QPushButton("Halten")
|
|
self.halten_btn.setCheckable(True)
|
|
self.halten_btn.clicked.connect(self._halten_umschalten)
|
|
gespraech_layout.addWidget(self.halten_btn)
|
|
|
|
self.transfer_btn = QPushButton("Transfer")
|
|
self.transfer_btn.clicked.connect(self._transfer)
|
|
gespraech_layout.addWidget(self.transfer_btn)
|
|
|
|
self.konferenz_btn = QPushButton("Konferenz")
|
|
self.konferenz_btn.clicked.connect(self._konferenz)
|
|
gespraech_layout.addWidget(self.konferenz_btn)
|
|
|
|
self.gespraech_widget.hide()
|
|
layout.addWidget(self.gespraech_widget, 0) # Stretch=0
|
|
|
|
# === Lautstärke ===
|
|
laut_layout = QHBoxLayout()
|
|
|
|
laut_layout.addWidget(QLabel("Mik:"))
|
|
self.mik_slider = QSlider(Qt.Orientation.Horizontal)
|
|
self.mik_slider.setRange(0, 200)
|
|
self.mik_slider.setValue(100)
|
|
self.mik_slider.setToolTip("Mikrofon-Lautstärke")
|
|
self.mik_slider.valueChanged.connect(self._mik_lautstaerke_geaendert)
|
|
laut_layout.addWidget(self.mik_slider)
|
|
|
|
laut_layout.addWidget(QLabel("Lsp:"))
|
|
self.lsp_slider = QSlider(Qt.Orientation.Horizontal)
|
|
self.lsp_slider.setRange(0, 200)
|
|
self.lsp_slider.setValue(100)
|
|
self.lsp_slider.setToolTip("Lautsprecher-Lautstärke")
|
|
self.lsp_slider.valueChanged.connect(self._lsp_lautstaerke_geaendert)
|
|
laut_layout.addWidget(self.lsp_slider)
|
|
|
|
layout.addLayout(laut_layout, 0) # Stretch=0
|
|
|
|
# Statusleiste
|
|
self.setStatusBar(QStatusBar())
|
|
self.statusBar().showMessage("Bereit")
|
|
|
|
def resizeEvent(self, event):
|
|
"""Fensterbreite → Layout anpassen (kompakt vs. erweitert)."""
|
|
super().resizeEvent(event)
|
|
breite = event.size().width()
|
|
breit_modus = breite >= 650
|
|
|
|
if breit_modus == self._breit_modus:
|
|
return # Kein Wechsel nötig
|
|
|
|
self._breit_modus = breit_modus
|
|
|
|
if breit_modus:
|
|
# Erweiterter Modus: Tabs anzeigen, Wählfeld begrenzt
|
|
self.tabs.show()
|
|
self._links_widget.setMaximumWidth(350)
|
|
self._links_widget.setMinimumWidth(250)
|
|
self._links_widget.setStyleSheet(
|
|
"QWidget#telefon_panel { "
|
|
" background-color: rgba(255,255,255,0.03); "
|
|
" border-right: 1px solid #444; "
|
|
"}")
|
|
self.splitter.setSizes([280, breite - 280])
|
|
else:
|
|
# Kompakter Modus: Nur Wählfeld, volle Breite
|
|
self.tabs.hide()
|
|
self._links_widget.setMaximumWidth(16777215)
|
|
self._links_widget.setMinimumWidth(0)
|
|
self._links_widget.setStyleSheet(
|
|
"QWidget#telefon_panel { "
|
|
" background-color: transparent; "
|
|
"}")
|
|
|
|
def _menue_aufbauen(self):
|
|
"""Menüleiste erstellen."""
|
|
menubar = self.menuBar()
|
|
|
|
# Datei-Menü
|
|
datei = menubar.addMenu("Datei")
|
|
|
|
einstellungen_action = QAction("Einstellungen...", self)
|
|
einstellungen_action.setShortcut(QKeySequence("Ctrl+,"))
|
|
einstellungen_action.triggered.connect(self._einstellungen_oeffnen)
|
|
datei.addAction(einstellungen_action)
|
|
|
|
datei.addSeparator()
|
|
|
|
beenden_action = QAction("Beenden", self)
|
|
beenden_action.setShortcut(QKeySequence("Ctrl+Q"))
|
|
beenden_action.triggered.connect(self._wirklich_beenden)
|
|
datei.addAction(beenden_action)
|
|
|
|
# Verbindung-Menü
|
|
verbindung = menubar.addMenu("Verbindung")
|
|
|
|
self.verbinden_action = QAction("Verbinden...", self)
|
|
self.verbinden_action.triggered.connect(self._login_anzeigen)
|
|
verbindung.addAction(self.verbinden_action)
|
|
|
|
self.trennen_action = QAction("Trennen", self)
|
|
self.trennen_action.triggered.connect(self._trennen)
|
|
self.trennen_action.setEnabled(False)
|
|
verbindung.addAction(self.trennen_action)
|
|
|
|
def _tray_aufbauen(self):
|
|
"""System-Tray-Icon einrichten."""
|
|
self.tray = QSystemTrayIcon(self)
|
|
self.tray.setIcon(self._app_icon)
|
|
self.tray.setToolTip("SIP Softphone - Offline")
|
|
|
|
# Tray-Menü
|
|
tray_menu = QMenu()
|
|
|
|
anzeigen_action = QAction("Anzeigen", self)
|
|
anzeigen_action.triggered.connect(self._aus_tray_anzeigen)
|
|
tray_menu.addAction(anzeigen_action)
|
|
|
|
tray_menu.addSeparator()
|
|
|
|
beenden_action = QAction("Beenden", self)
|
|
beenden_action.triggered.connect(self._wirklich_beenden)
|
|
tray_menu.addAction(beenden_action)
|
|
|
|
self.tray.setContextMenu(tray_menu)
|
|
self.tray.activated.connect(self._tray_aktiviert)
|
|
self.tray.show()
|
|
|
|
def _hotkeys_aufbauen(self):
|
|
"""Tastatur-Shortcuts einrichten."""
|
|
# F5: Annehmen
|
|
QShortcut(QKeySequence("F5"), self, self._annehmen)
|
|
# F6: Auflegen
|
|
QShortcut(QKeySequence("F6"), self, self._auflegen)
|
|
# F7: Stummschalten
|
|
QShortcut(QKeySequence("F7"), self, self._stumm_umschalten)
|
|
# F8: Halten/Fortsetzen
|
|
QShortcut(QKeySequence("F8"), self, self._halten_umschalten)
|
|
# Escape: Nummernfeld leeren
|
|
QShortcut(QKeySequence("Escape"), self, self.waehlfeld.nummer_loeschen)
|
|
|
|
def _signale_verbinden(self):
|
|
"""SIP-Engine Signale mit UI verbinden."""
|
|
self._sip.registrierung_geaendert.connect(self._on_registrierung)
|
|
self._sip.eingehender_anruf.connect(self._on_eingehender_anruf)
|
|
self._sip.anruf_zustand_geaendert.connect(self._on_anruf_zustand)
|
|
self._sip.anruf_beendet.connect(self._on_anruf_beendet)
|
|
self._sip.dtmf_empfangen.connect(self._on_dtmf)
|
|
self._sip.fehler.connect(self._on_fehler)
|
|
|
|
# Wählfeld → Anruf starten
|
|
self.waehlfeld.nummer_gewaehlt.connect(self._anruf_mit_nummer)
|
|
self.waehlfeld.dtmf_gedrueckt.connect(self._sip.dtmf_senden)
|
|
|
|
# Kontakte → Anruf starten + Wählfeld-Suche aktualisieren
|
|
self.kontakte.anrufen.connect(self._anruf_mit_nummer)
|
|
self.kontakte.kontakte_geaendert.connect(self._kontakte_an_waehlfeld)
|
|
|
|
# Anrufliste → Rückruf
|
|
self.anrufliste.rueckruf.connect(self._anruf_mit_nummer)
|
|
|
|
# BLF → Anruf zu Extension
|
|
self.blf_panel.extension_geklickt.connect(self._anruf_mit_nummer)
|
|
|
|
# Favoriten → Anruf + Updates
|
|
self.favoriten.anrufen.connect(self._anruf_mit_nummer)
|
|
self.kontakte.favorit_toggle.connect(self._on_favorit_toggle)
|
|
|
|
# === SIP-Aktionen ===
|
|
|
|
def _anrufen(self):
|
|
"""Anruf-Button geklickt."""
|
|
nummer = self.waehlfeld.nummer_eingabe.text().strip()
|
|
if nummer:
|
|
self._anruf_mit_nummer(nummer)
|
|
|
|
@staticmethod
|
|
def _ist_gueltige_nummer(text):
|
|
"""Prüft ob der Text eine wählbare Nummer ist (keine Textsuche)."""
|
|
bereinigt = text.strip().replace(" ", "")
|
|
if not bereinigt:
|
|
return False
|
|
# Erlaubte Zeichen: Ziffern, +, *, #
|
|
return all(c in "0123456789+*#" for c in bereinigt)
|
|
|
|
def _anruf_mit_nummer(self, nummer):
|
|
"""Anruf an eine bestimmte Nummer starten."""
|
|
if not self._ist_gueltige_nummer(nummer):
|
|
self.statusBar().showMessage(
|
|
f"Keine gültige Nummer: {nummer}")
|
|
return
|
|
# Doppelklick-Schutz: Nicht starten wenn bereits ein Anruf läuft
|
|
if self._sip.account and self._sip.account.aktueller_anruf:
|
|
return
|
|
self._sip.anruf_starten(nummer)
|
|
self.anruf_info_label.setText(f"Rufe an: {nummer}")
|
|
self.anruf_info_label.show()
|
|
self._anruf_ui_aktivieren()
|
|
|
|
def _annehmen(self):
|
|
"""Eingehenden Anruf annehmen."""
|
|
self._klingelton.stoppen()
|
|
self._benachrichtigung.schliessen()
|
|
self._sip.anruf_annehmen()
|
|
|
|
def _auflegen(self):
|
|
"""Anruf beenden."""
|
|
self._klingelton.stoppen()
|
|
self._benachrichtigung.schliessen()
|
|
self._sip.anruf_beenden()
|
|
|
|
def _stumm_umschalten(self):
|
|
"""Mikrofon stumm schalten / aktivieren."""
|
|
self._ist_stumm = not self._ist_stumm
|
|
self._sip.stummschalten(self._ist_stumm)
|
|
self.stumm_btn.setChecked(self._ist_stumm)
|
|
self.stumm_btn.setText("Stumm (AN)" if self._ist_stumm else "Stumm")
|
|
|
|
def _halten_umschalten(self):
|
|
"""Anruf halten / fortsetzen."""
|
|
self._ist_gehalten = not self._ist_gehalten
|
|
if self._ist_gehalten:
|
|
self._sip.halten()
|
|
self.halten_btn.setText("Fortsetzen")
|
|
else:
|
|
self._sip.fortsetzen()
|
|
self.halten_btn.setText("Halten")
|
|
self.halten_btn.setChecked(self._ist_gehalten)
|
|
|
|
def _transfer(self):
|
|
"""Blinde Weiterleitung - Zielnummer abfragen."""
|
|
nummer, ok = QInputDialog.getText(
|
|
self, "Weiterleitung", "Weiterleiten an Nummer:"
|
|
)
|
|
if ok and nummer.strip():
|
|
self._sip.blind_transfer(nummer.strip())
|
|
|
|
def _konferenz(self):
|
|
"""3er-Konferenz - Zweite Nummer abfragen."""
|
|
nummer, ok = QInputDialog.getText(
|
|
self, "Konferenz", "Zweiten Teilnehmer anrufen:"
|
|
)
|
|
if ok and nummer.strip():
|
|
self._sip.konferenz_starten(nummer.strip())
|
|
|
|
# === SIP-Event-Handler ===
|
|
|
|
def _on_registrierung(self, daten):
|
|
"""Registrierungsstatus hat sich geändert."""
|
|
if daten.get("aktiv"):
|
|
self.status_label.setText("Registriert")
|
|
self.status_label.setStyleSheet(
|
|
"font-size: 13px; padding: 4px; "
|
|
"background-color: #4CAF50; color: white; border-radius: 4px;"
|
|
)
|
|
self.tray.setToolTip("SIP Softphone - Online")
|
|
self.trennen_action.setEnabled(True)
|
|
self.statusBar().showMessage(f"Registriert als {daten.get('uri', '')}")
|
|
|
|
# BLF konfigurieren
|
|
blf_extensions = self._config.blf.get("extensions", [])
|
|
if blf_extensions and self._sip.account:
|
|
self.blf_panel.konfigurieren(
|
|
self._sip.account,
|
|
self._config.sip.get("server", ""),
|
|
blf_extensions,
|
|
)
|
|
|
|
# PJSUA2 auf PipeWire routen (über 'pulse'-Device)
|
|
self._audio.pulse_als_standard_setzen()
|
|
|
|
# Gespeichertes PipeWire-Gerät anwenden
|
|
audio_cfg = self._config.audio
|
|
aufnahme = audio_cfg.get("aufnahme_geraet", "")
|
|
wiedergabe = audio_cfg.get("wiedergabe_geraet", "")
|
|
if aufnahme:
|
|
self._audio.aufnahme_geraet_setzen(aufnahme)
|
|
if wiedergabe:
|
|
self._audio.wiedergabe_geraet_setzen(wiedergabe)
|
|
else:
|
|
code = daten.get("code", 0)
|
|
grund = daten.get("grund", "")
|
|
self.status_label.setText(f"Nicht registriert ({code})")
|
|
self.status_label.setStyleSheet(
|
|
"font-size: 13px; padding: 4px; "
|
|
"background-color: #F44336; color: white; border-radius: 4px;"
|
|
)
|
|
self.tray.setToolTip("SIP Softphone - Offline")
|
|
self.statusBar().showMessage(f"Registrierung fehlgeschlagen: {grund}")
|
|
|
|
def _on_eingehender_anruf(self, daten):
|
|
"""Eingehender Anruf empfangen."""
|
|
anrufer = self._uri_zu_nummer(daten.get("remoteUri", "Unbekannt"))
|
|
|
|
# Kontaktname nachschlagen
|
|
anrufer_anzeige = self._kontakt_name_finden(anrufer) or anrufer
|
|
|
|
self.anruf_info_label.setText(f"Eingehend: {anrufer_anzeige}")
|
|
self.anruf_info_label.show()
|
|
self.annehmen_btn.show()
|
|
self.auflegen_btn.show()
|
|
|
|
# Klingelton über separates Gerät abspielen
|
|
klingelton_datei = self._config.allgemein.get("klingelton", "")
|
|
klingelton_geraet = self._config.audio.get("klingelton_geraet", "")
|
|
self._klingelton.abspielen(klingelton_datei, klingelton_geraet)
|
|
|
|
# KDE-Benachrichtigung mit Annehmen/Ablehnen-Buttons
|
|
self._benachrichtigung.anruf_anzeigen(
|
|
anrufer_anzeige,
|
|
callback_annehmen=self._annehmen,
|
|
callback_ablehnen=self._auflegen,
|
|
)
|
|
|
|
# Fenster in den Vordergrund
|
|
self.show()
|
|
self.activateWindow()
|
|
self.raise_()
|
|
|
|
self.statusBar().showMessage(f"Eingehender Anruf von {anrufer_anzeige}")
|
|
|
|
def _on_anruf_zustand(self, daten):
|
|
"""Anrufstatus hat sich geändert."""
|
|
import pjsua2 as pj
|
|
|
|
state = daten.get("state")
|
|
text = daten.get("stateText", "")
|
|
remote = self._uri_zu_nummer(daten.get("remoteUri", ""))
|
|
|
|
if state == pj.PJSIP_INV_STATE_CONFIRMED:
|
|
# Gespräch verbunden
|
|
self.anruf_info_label.setText(f"Verbunden: {remote}")
|
|
self.annehmen_btn.hide()
|
|
self._anruf_ui_aktivieren()
|
|
self._anruf_start_zeit = datetime.now()
|
|
self._anruf_timer.start(1000)
|
|
self.waehlfeld.set_im_gespraech(True)
|
|
self.tray.setToolTip(f"SIP Softphone - Im Gespräch mit {remote}")
|
|
elif state == pj.PJSIP_INV_STATE_DISCONNECTED:
|
|
# Anruf beendet/abgebrochen - UI immer zurücksetzen
|
|
self._anruf_ui_deaktivieren()
|
|
grund = daten.get("lastReason", "")
|
|
self.anruf_info_label.setText(f"Beendet: {remote} ({grund})")
|
|
self.anruf_info_label.show()
|
|
QTimer.singleShot(3000, self.anruf_info_label.hide)
|
|
self.tray.setToolTip("SIP Softphone - Online")
|
|
elif state == pj.PJSIP_INV_STATE_CALLING:
|
|
self.anruf_info_label.setText(f"Rufe an: {remote}")
|
|
elif state == pj.PJSIP_INV_STATE_EARLY:
|
|
self.anruf_info_label.setText(f"Klingelt: {remote}")
|
|
|
|
self.statusBar().showMessage(f"Anruf: {text}")
|
|
|
|
def _on_anruf_beendet(self, daten):
|
|
"""Anruf wurde beendet."""
|
|
remote = self._uri_zu_nummer(daten.get("remoteUri", ""))
|
|
grund = daten.get("lastReason", "")
|
|
dauer = daten.get("connectDuration", 0)
|
|
|
|
# In Anrufliste eintragen
|
|
richtung = "ausgehend" # Wird bei eingehend überschrieben
|
|
if "eingehend" in self.anruf_info_label.text().lower():
|
|
richtung = "eingehend" if dauer > 0 else "verpasst"
|
|
self.anrufliste.anruf_hinzufuegen(remote, richtung, dauer)
|
|
self._favoriten_aktualisieren()
|
|
|
|
# UI zurücksetzen
|
|
self._anruf_ui_deaktivieren()
|
|
self.anruf_info_label.setText(f"Beendet: {remote} ({grund})")
|
|
QTimer.singleShot(3000, self.anruf_info_label.hide)
|
|
|
|
self.statusBar().showMessage(f"Anruf beendet: {grund}")
|
|
self.tray.setToolTip("SIP Softphone - Online")
|
|
|
|
def _on_dtmf(self, digit):
|
|
"""DTMF-Ton empfangen."""
|
|
self.statusBar().showMessage(f"DTMF empfangen: {digit}")
|
|
|
|
def _on_fehler(self, text):
|
|
"""Fehlermeldung von der SIP-Engine."""
|
|
self.statusBar().showMessage(f"Fehler: {text}")
|
|
|
|
# === UI-Hilfsfunktionen ===
|
|
|
|
def _anruf_ui_aktivieren(self):
|
|
"""UI für aktiven Anruf umschalten."""
|
|
self.anrufen_btn.hide()
|
|
self.auflegen_btn.show()
|
|
self.gespraech_widget.show()
|
|
|
|
def _anruf_ui_deaktivieren(self):
|
|
"""UI nach Anrufende zurücksetzen."""
|
|
self._klingelton.stoppen()
|
|
self._benachrichtigung.schliessen()
|
|
self.anrufen_btn.show()
|
|
self.annehmen_btn.hide()
|
|
self.auflegen_btn.hide()
|
|
self.gespraech_widget.hide()
|
|
self.dauer_label.setText("")
|
|
self._anruf_timer.stop()
|
|
self._anruf_start_zeit = None
|
|
self._ist_stumm = False
|
|
self._ist_gehalten = False
|
|
self.stumm_btn.setChecked(False)
|
|
self.stumm_btn.setText("Stumm")
|
|
self.halten_btn.setChecked(False)
|
|
self.halten_btn.setText("Halten")
|
|
self.waehlfeld.set_im_gespraech(False)
|
|
|
|
def _anruf_dauer_aktualisieren(self):
|
|
"""Timer-Callback: Gesprächsdauer anzeigen."""
|
|
if self._anruf_start_zeit:
|
|
delta = datetime.now() - self._anruf_start_zeit
|
|
sekunden = int(delta.total_seconds())
|
|
minuten = sekunden // 60
|
|
sek = sekunden % 60
|
|
self.dauer_label.setText(f"{minuten:02d}:{sek:02d}")
|
|
|
|
def _uri_zu_nummer(self, uri):
|
|
"""SIP-URI in Nummer umwandeln: 'sip:200@server' → '200'."""
|
|
match = re.search(r"sip:([^@]+)@", uri)
|
|
return match.group(1) if match else uri
|
|
|
|
def _kontakt_name_finden(self, nummer):
|
|
"""Kontaktname für eine Nummer nachschlagen."""
|
|
if not nummer:
|
|
return None
|
|
# Lokale Kontakte
|
|
for kontakt in self._config.kontakte_laden():
|
|
# Altes Format: einzelne "nummer"
|
|
if kontakt.get("nummer") == nummer:
|
|
return kontakt.get("name")
|
|
# Neues Format: "nummern"-Dict
|
|
nummern = kontakt.get("nummern", {})
|
|
if nummer in nummern.values():
|
|
return kontakt.get("name")
|
|
# CardDAV-Cache
|
|
for kontakt in self._config.get("carddav", "kontakte_cache") or []:
|
|
nummern = kontakt.get("nummern", {})
|
|
if nummer in nummern.values():
|
|
return kontakt.get("name")
|
|
return None
|
|
|
|
def _mik_lautstaerke_geaendert(self, wert):
|
|
"""Mikrofon-Slider bewegt."""
|
|
level = wert / 100.0
|
|
self._audio.mikrofon_lautstaerke(level)
|
|
|
|
def _lsp_lautstaerke_geaendert(self, wert):
|
|
"""Lautsprecher-Slider bewegt."""
|
|
level = wert / 100.0
|
|
self._audio.lautsprecher_lautstaerke(level)
|
|
|
|
# === Verbindung / Login ===
|
|
|
|
def _login_anzeigen(self):
|
|
"""Login-Dialog öffnen und verbinden."""
|
|
from ui.login_dialog import LoginDialog
|
|
|
|
dialog = LoginDialog(self._config, parent=self)
|
|
if dialog.exec() == LoginDialog.DialogCode.Accepted:
|
|
self._verbinden()
|
|
|
|
def _verbinden(self):
|
|
"""Mit FreePBX verbinden."""
|
|
sip = self._config.sip
|
|
self.statusBar().showMessage("Verbinde...")
|
|
self._sip.registrieren(
|
|
server=sip.get("server", ""),
|
|
extension=sip.get("extension", ""),
|
|
passwort=sip.get("passwort", ""),
|
|
port=sip.get("port", 5060),
|
|
)
|
|
|
|
def _trennen(self):
|
|
"""Verbindung trennen."""
|
|
self._sip.abmelden()
|
|
self.blf_panel.aufraumen()
|
|
self.status_label.setText("Getrennt")
|
|
self.status_label.setStyleSheet(
|
|
"font-size: 13px; padding: 4px; "
|
|
"background-color: #666; color: white; border-radius: 4px;"
|
|
)
|
|
self.trennen_action.setEnabled(False)
|
|
|
|
def _einstellungen_oeffnen(self):
|
|
"""Einstellungs-Dialog öffnen."""
|
|
dialog = EinstellungenDialog(
|
|
self._config, self._audio, self._klingelton, parent=self)
|
|
dialog.einstellungen_geaendert.connect(self._on_einstellungen_geaendert)
|
|
dialog.exec()
|
|
|
|
def _kontakte_an_waehlfeld(self):
|
|
"""Kontaktliste für die Wählfeld-Suche aktualisieren."""
|
|
kontakte = self.kontakte.alle_kontakte_fuer_suche()
|
|
self.waehlfeld.kontakte_setzen(kontakte)
|
|
self.favoriten.kontakte_setzen(kontakte)
|
|
self.anrufliste.kontakte_setzen(kontakte)
|
|
|
|
def _favoriten_aktualisieren(self):
|
|
"""Favoriten-Panel neu aufbauen."""
|
|
self.favoriten.aktualisieren()
|
|
|
|
def _on_favorit_toggle(self, name, nummer, hinzufuegen):
|
|
"""Favorit hinzufügen/entfernen (aus Kontakt-Detail-Dialog)."""
|
|
if hinzufuegen:
|
|
self.favoriten.favorit_hinzufuegen(name, nummer)
|
|
else:
|
|
self.favoriten.favorit_entfernen(nummer)
|
|
self._favoriten_aktualisieren()
|
|
|
|
def _on_einstellungen_geaendert(self):
|
|
"""Einstellungen wurden gespeichert - anwenden."""
|
|
# PipeWire-Audiogeräte anwenden
|
|
audio_cfg = self._config.audio
|
|
aufnahme = audio_cfg.get("aufnahme_geraet", "")
|
|
wiedergabe = audio_cfg.get("wiedergabe_geraet", "")
|
|
if aufnahme:
|
|
self._audio.aufnahme_geraet_setzen(aufnahme)
|
|
if wiedergabe:
|
|
self._audio.wiedergabe_geraet_setzen(wiedergabe)
|
|
|
|
# BLF aktualisieren
|
|
blf_extensions = self._config.blf.get("extensions", [])
|
|
if self._sip.account:
|
|
self.blf_panel.konfigurieren(
|
|
self._sip.account,
|
|
self._config.sip.get("server", ""),
|
|
blf_extensions,
|
|
)
|
|
|
|
# Kontakte neu laden und Wählfeld + Favoriten aktualisieren
|
|
self.kontakte.aktualisieren()
|
|
self._kontakte_an_waehlfeld()
|
|
self._favoriten_aktualisieren()
|
|
|
|
# === System-Tray ===
|
|
|
|
def _tray_aktiviert(self, grund):
|
|
"""Tray-Icon wurde angeklickt."""
|
|
if grund == QSystemTrayIcon.ActivationReason.Trigger:
|
|
self._aus_tray_anzeigen()
|
|
|
|
def _aus_tray_anzeigen(self):
|
|
"""Fenster aus dem Tray wiederherstellen."""
|
|
self.show()
|
|
self.activateWindow()
|
|
self.raise_()
|
|
|
|
def closeEvent(self, event):
|
|
"""Fenster schließen → in Tray minimieren oder beenden."""
|
|
if self._config.allgemein.get("minimieren_in_tray", True):
|
|
event.ignore()
|
|
self.hide()
|
|
self.tray.showMessage(
|
|
"SIP Softphone",
|
|
"Läuft im Hintergrund weiter",
|
|
QSystemTrayIcon.MessageIcon.Information,
|
|
2000,
|
|
)
|
|
else:
|
|
self._wirklich_beenden()
|
|
|
|
def _wirklich_beenden(self):
|
|
"""Anwendung komplett beenden."""
|
|
self.blf_panel.aufraumen()
|
|
self._sip.beenden()
|
|
self.tray.hide()
|
|
from PySide6.QtWidgets import QApplication
|
|
QApplication.instance().quit()
|
|
|
|
def starten(self):
|
|
"""App starten: Login prüfen und ggf. verbinden."""
|
|
if self._config.hat_sip_zugangsdaten():
|
|
# Gespeicherte Zugangsdaten vorhanden → direkt verbinden
|
|
self._verbinden()
|
|
else:
|
|
# Keine Zugangsdaten → Login-Dialog zeigen
|
|
self._login_anzeigen()
|