linux.sipsoftphone/ui/hauptfenster.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

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()