diff --git a/.gitignore b/.gitignore
index ab3e8ce..3a4179b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,164 +1,34 @@
-# ---> Python
-# Byte-compiled / optimized / DLL files
+# Python
__pycache__/
*.py[cod]
*$py.class
-
-# C extensions
*.so
-
-# Distribution / packaging
-.Python
-build/
-develop-eggs/
-dist/
-downloads/
-eggs/
-.eggs/
-lib/
-lib64/
-parts/
-sdist/
-var/
-wheels/
-share/python-wheels/
*.egg-info/
-.installed.cfg
*.egg
-MANIFEST
+dist/
+build/
# PyInstaller
-# Usually these files are written by a python script from a template
-# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
-*.spec
+*.spec.bak
-# Installer logs
-pip-log.txt
-pip-delete-this-directory.txt
-
-# Unit test / coverage reports
-htmlcov/
-.tox/
-.nox/
-.coverage
-.coverage.*
-.cache
-nosetests.xml
-coverage.xml
-*.cover
-*.py,cover
-.hypothesis/
-.pytest_cache/
-cover/
-
-# Translations
-*.mo
-*.pot
-
-# Django stuff:
-*.log
-local_settings.py
-db.sqlite3
-db.sqlite3-journal
-
-# Flask stuff:
-instance/
-.webassets-cache
-
-# Scrapy stuff:
-.scrapy
-
-# Sphinx documentation
-docs/_build/
-
-# PyBuilder
-.pybuilder/
-target/
-
-# Jupyter Notebook
-.ipynb_checkpoints
-
-# IPython
-profile_default/
-ipython_config.py
-
-# pyenv
-# For a library or package, you might want to ignore these files since the code is
-# intended to run in multiple environments; otherwise, check them in:
-# .python-version
-
-# pipenv
-# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
-# However, in case of collaboration, if having platform-specific dependencies or dependencies
-# having no cross-platform support, pipenv may install dependencies that don't work, or not
-# install all needed dependencies.
-#Pipfile.lock
-
-# poetry
-# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
-# This is especially recommended for binary packages to ensure reproducibility, and is more
-# commonly ignored for libraries.
-# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
-#poetry.lock
-
-# pdm
-# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
-#pdm.lock
-# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
-# in version control.
-# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
-.pdm.toml
-.pdm-python
-.pdm-build/
-
-# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
-__pypackages__/
-
-# Celery stuff
-celerybeat-schedule
-celerybeat.pid
-
-# SageMath parsed files
-*.sage.py
-
-# Environments
-.env
-.venv
-env/
+# Virtual Environment
venv/
-ENV/
-env.bak/
-venv.bak/
+.venv/
-# Spyder project settings
-.spyderproject
-.spyproject
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
-# Rope project settings
-.ropeproject
+# OS
+.DS_Store
+Thumbs.db
-# mkdocs documentation
-/site
-
-# mypy
-.mypy_cache/
-.dmypy.json
-dmypy.json
-
-# Pyre type checker
-.pyre/
-
-# pytype static type analyzer
-.pytype/
-
-# Cython debug symbols
-cython_debug/
-
-# PyCharm
-# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
-# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
-# and can be added to the global gitignore or merged into this file. For a more nuclear
-# option (not recommended) you can uncomment the following to ignore the entire idea folder.
-#.idea/
+# App-spezifisch
+.claude/
+# AppImage Build-Artefakte (lokal)
+# dist/ wird schon oben ausgeschlossen
diff --git a/README.md b/README.md
index d69deb3..2502fdb 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,244 @@
-# linux.sipsoftphone
+# SIP Softphone
+Desktop-SIP-Softphone für FreePBX/Asterisk auf Linux (KDE/PipeWire).
+
+Basiert auf **PJSUA2** (SIP-Stack) und **PySide6** (Qt-GUI). Verbindet sich über SIP/UDP direkt mit der Telefonanlage.
+
+## Features
+
+- **SIP-Telefonie**: Anrufe tätigen/annehmen, DTMF, Halten, Transfer, Konferenz
+- **Kontaktverwaltung**: Lokale Kontakte + CardDAV-Sync (Nextcloud, Multi-Account)
+- **CardDAV Write-Back**: Kontakte bearbeiten und auf den Server zurückschreiben
+- **Anrufliste**: Verlauf mit Richtung, Dauer, Kontaktnamens-Auflösung
+- **Favoriten**: Manuelle Favoriten + häufig angerufene Nummern (automatisch)
+- **BLF-Panel**: Besetzt-Lampenfeld (Presence-Monitoring via SIP SUBSCRIBE)
+- **Kontaktsuche**: Live-Suche im Wählfeld nach Name/Firma
+- **Responsives Layout**: Kompakt (nur Wählfeld) oder Breit (Wählfeld + Tabs)
+- **Click-to-Call**: `tel:` und `sip:` URI-Handler via D-Bus Single-Instance
+- **KDE-Integration**: System-Tray, D-Bus-Benachrichtigungen mit Annehmen/Ablehnen
+- **PipeWire-Audio**: Natives PipeWire/PulseAudio-Routing, separates Klingelton-Gerät
+- **AppImage**: Komplett unabhängiges Paket (kein Python/Qt auf dem Zielsystem nötig)
+
+## Screenshots
+
+```
+┌──────────────────────────────────────────────┐
+│ [Registriert] 00:42 SIP Softphone │
+├──────────────┬───────────────────────────────┤
+│ │ Favoriten│Verlauf│Kontakte│BLF │
+│ [Nummer...] ├───────────────────────────────┤
+│ │ ★ Max Mustermann │
+│ [1] [2] [3]│ +49 40 555 1234 │
+│ [4] [5] [6]│ ★ Büro Hauptnummer │
+│ [7] [8] [9]│ +49 40 555 0000 │
+│ [*] [0] [#]│ │
+│ │ Häufig angerufen │
+│ │ Lieferant Meyer │
+├──────────────┴───────────────────────────────┤
+│ [ Anrufen ] │
+│ Mik: ═══════════ Lsp: ═══════════ │
+└──────────────────────────────────────────────┘
+```
+
+## Voraussetzungen
+
+### System
+
+- Linux x86_64 (getestet: Manjaro KDE)
+- PipeWire oder PulseAudio
+- Python 3.10+ (für Entwicklung)
+
+### Python-Pakete
+
+```
+PySide6>=6.6.0
+vobject>=0.9.6 # vCard-Parsing für CardDAV
+requests>=2.28.0 # HTTP-Client für CardDAV
+```
+
+### pjsua2 (SIP-Stack)
+
+Auf Arch/Manjaro via AUR:
+```bash
+yay -S python-pjproject
+```
+
+### Optional
+
+- `dbus-python`: Vorinstalliert auf KDE-Systemen (für Benachrichtigungen + Click-to-Call)
+
+## Installation
+
+### Aus Quellcode (Entwicklung)
+
+```bash
+git clone https://git.data-it-solution.de/data/linux.sipsoftphone.git
+cd linux.sipsoftphone
+pip install -r requirements.txt
+python3 main.py
+```
+
+### Als AppImage (empfohlen)
+
+```bash
+# AppImage bauen
+./build_appimage.sh
+
+# Starten
+chmod +x dist/SipSoftphone-x86_64.AppImage
+./dist/SipSoftphone-x86_64.AppImage
+```
+
+Das AppImage bündelt Python, Qt, pjsua2 und alle Abhängigkeiten. Auf dem Zielsystem wird nichts weiter benötigt.
+
+## Verwendung
+
+### Erster Start
+
+1. App starten → Login-Dialog erscheint
+2. SIP-Zugangsdaten eingeben (Server, Extension, Passwort, Port)
+3. Nach erfolgreicher Registrierung: Status wechselt zu grün "Registriert"
+
+### Anrufe
+
+| Aktion | Bedienung |
+|--------|-----------|
+| Anruf starten | Nummer eingeben + Enter oder "Anrufen"-Button |
+| Kontakt anrufen | Name im Wählfeld tippen → Vorschlag doppelklicken |
+| Annehmen | "Annehmen"-Button, F5, oder KDE-Benachrichtigung |
+| Auflegen | "Auflegen"-Button oder F6 |
+| Stummschalten | F7 |
+| Halten | F8 |
+| DTMF im Gespräch | Zifferntasten drücken (Wählfeld wechselt automatisch) |
+
+### CardDAV-Kontakte
+
+Über **Einstellungen → CardDAV** können mehrere Accounts konfiguriert werden (z.B. Nextcloud):
+
+- URL: `https://nextcloud.example.de/remote.php/dav`
+- Benutzername + Passwort
+- Automatischer Sync im Hintergrund
+
+Kontakte können im Detail-Dialog bearbeitet und per CardDAV zurückgeschrieben werden.
+
+### Favoriten
+
+- Im Kontakt-Detail-Dialog: "Favorit"-Button zum Pinnen/Entpinnen
+- Häufig angerufene Nummern werden automatisch angezeigt
+- Klick auf Favorit → Anruf wird gestartet
+
+### Click-to-Call (URI-Handler)
+
+```bash
+# tel: URI
+python3 main.py tel:+49123456789
+
+# sip: URI
+python3 main.py sip:200@server
+
+# Als Standard-Handler registrieren (KDE)
+xdg-mime default sipwebapp.desktop x-scheme-handler/tel
+xdg-mime default sipwebapp.desktop x-scheme-handler/sip
+```
+
+Bei laufender Instanz wird der Anruf per D-Bus an die bestehende App weitergeleitet.
+
+### Responsives Layout
+
+- **Schmal (<650px)**: Nur Wählfeld mit Kontaktsuche
+- **Breit (≥650px)**: Wählfeld links + Tabs rechts (Favoriten, Verlauf, Kontakte, BLF)
+
+## Projektstruktur
+
+```
+SipWebApp/
+├── main.py # Einstiegspunkt, D-Bus Single-Instance
+├── requirements.txt # Python-Abhängigkeiten
+├── sipwebapp.spec # PyInstaller Build-Konfiguration
+├── build_appimage.sh # AppImage Build-Skript
+├── sip/
+│ ├── engine.py # PJSUA2-Endpoint mit Qt-Integration
+│ ├── account.py # SIP-Account (Registrierung, Presence)
+│ └── call.py # SIP-Call (Medien, DTMF, Transfer)
+├── ui/
+│ ├── hauptfenster.py # Hauptfenster (Layout, Signale, SIP-Events)
+│ ├── waehlfeld.py # Wähltastatur + Kontaktsuche
+│ ├── kontakte.py # Kontakttabelle + Detail/Bearbeiten-Dialoge
+│ ├── anrufliste.py # Anrufverlauf mit Namensauflösung
+│ ├── favoriten_panel.py # Manuelle + automatische Favoriten
+│ ├── blf_panel.py # Besetzt-Lampenfeld (SIP Presence)
+│ ├── einstellungen.py # Einstellungs-Dialog (Audio, CardDAV, BLF)
+│ └── login_dialog.py # SIP-Login-Dialog
+├── utils/
+│ ├── config_manager.py # JSON-Konfiguration (~/.config/sipwebapp/)
+│ ├── carddav.py # CardDAV-Sync + Write-Back (vobject/requests)
+│ ├── audio_manager.py # PipeWire/PulseAudio Geräte-Verwaltung
+│ ├── klingelton.py # Klingelton über separates Audio-Gerät
+│ └── benachrichtigung.py # KDE D-Bus Desktop-Benachrichtigungen
+└── resources/
+ ├── sipwebapp.desktop # XDG Desktop-Entry
+ ├── icons/phone.svg # App-Icon
+ └── sounds/klingelton.wav # Standard-Klingelton
+```
+
+## Architektur
+
+### SIP-Integration (PJSUA2)
+
+PJSUA2 wird im **Single-Thread-Modus** betrieben:
+- `threadCnt=0`: Keine eigenen Worker-Threads
+- `mainThreadOnly=True`: Alle Callbacks im Qt-Hauptthread
+- `QTimer` pollt `libHandleEvents(0)` alle 50ms
+
+So werden Race-Conditions und Qt-Crash-Probleme vermieden.
+
+### Audio-Routing
+
+PJSUA2 nutzt das `pulse`-Device. PipeWire übernimmt das Routing:
+- Mikrofon/Lautsprecher: Konfigurierbar in den Einstellungen
+- Klingelton: Separates Ausgabegerät (z.B. eingebaute Lautsprecher)
+- Lautstärke: Mikrofon + Lautsprecher über Slider (0-200%)
+
+### Konfiguration
+
+Gespeichert unter `~/.config/sipwebapp/config.json`:
+- SIP-Zugangsdaten (Server, Extension, Passwort)
+- Audio-Geräte (Aufnahme, Wiedergabe, Klingelton)
+- CardDAV-Accounts (Multi-Account)
+- BLF-Extensions
+- Favoriten (manuell gepinnt)
+
+## AppImage bauen
+
+Voraussetzungen auf dem Build-System:
+- Python 3.14+
+- `python-pjproject` (AUR)
+- Internetzugang (lädt appimagetool + pip-Pakete)
+
+```bash
+./build_appimage.sh
+```
+
+Das Skript:
+1. Erstellt ein Python-venv mit allen Abhängigkeiten
+2. Kopiert pjsua2-Bindings + alle nativen Shared Libraries
+3. Bündelt alles mit PyInstaller
+4. Packt als AppImage (appimagetool)
+
+Ergebnis: `dist/SipSoftphone-x86_64.AppImage` (~90 MB)
+
+## Tastatur-Shortcuts
+
+| Taste | Aktion |
+|-------|--------|
+| F5 | Anruf annehmen |
+| F6 | Auflegen |
+| F7 | Stummschalten |
+| F8 | Halten/Fortsetzen |
+| Escape | Nummernfeld leeren |
+| Enter | Nummer wählen |
+| 0-9, *, # | DTMF-Ziffern (Numpad-kompatibel) |
+
+## Lizenz
+
+Proprietär - ALLES WATT LÄUFT (Eduard Wisch)
diff --git a/build_appimage.sh b/build_appimage.sh
new file mode 100644
index 0000000..187dd8b
--- /dev/null
+++ b/build_appimage.sh
@@ -0,0 +1,142 @@
+#!/bin/bash
+# ============================================================
+# SIP Softphone - AppImage Build-Skript
+# Erstellt ein komplett unabhängiges AppImage
+# ============================================================
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+BUILD_DIR="/tmp/sipwebapp_build"
+VENV_DIR="/tmp/sipwebapp_build_venv"
+APPDIR="$BUILD_DIR/SipWebApp.AppDir"
+PJSUA2_DIR="/tmp/sipwebapp_run"
+APPIMAGETOOL="/tmp/appimagetool"
+OUTPUT_DIR="$SCRIPT_DIR/dist"
+
+echo "=== SIP Softphone AppImage Builder ==="
+echo "Quellverzeichnis: $SCRIPT_DIR"
+echo "Build-Verzeichnis: $BUILD_DIR"
+echo ""
+
+# === 1. Voraussetzungen prüfen ===
+echo "[1/6] Voraussetzungen prüfen..."
+
+if [ ! -f "$PJSUA2_DIR/_pjsua2."*".so" ]; then
+ echo "FEHLER: pjsua2 Bindings nicht gefunden in $PJSUA2_DIR"
+ echo "Bitte zuerst die App normal starten, damit die Bindings kopiert werden."
+ exit 1
+fi
+
+if [ ! -f "$APPIMAGETOOL" ]; then
+ echo "appimagetool herunterladen..."
+ curl -L -o "$APPIMAGETOOL" \
+ https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
+ chmod +x "$APPIMAGETOOL"
+fi
+
+# === 2. Build-venv erstellen ===
+echo "[2/6] Python Build-Umgebung..."
+
+if [ ! -d "$VENV_DIR" ]; then
+ python3 -m venv "$VENV_DIR"
+ "$VENV_DIR/bin/pip" install -q pyinstaller PySide6 vobject requests
+fi
+
+# pjsua2 + dbus ins venv kopieren
+SITE_PKG=$("$VENV_DIR/bin/python3" -c "import site; print(site.getsitepackages()[0])")
+SYS_SITE="/usr/lib/python3.14/site-packages"
+
+cp "$PJSUA2_DIR"/pjsua2.py "$SITE_PKG/"
+cp "$PJSUA2_DIR"/_pjsua2.*.so "$SITE_PKG/"
+
+if [ -d "$SYS_SITE/dbus" ]; then
+ cp -r "$SYS_SITE/dbus" "$SITE_PKG/" 2>/dev/null || true
+ cp "$SYS_SITE"/_dbus_bindings*.so "$SITE_PKG/" 2>/dev/null || true
+ cp "$SYS_SITE"/_dbus_glib_bindings*.so "$SITE_PKG/" 2>/dev/null || true
+fi
+
+echo " Python-Deps OK"
+
+# === 3. PyInstaller ausführen ===
+echo "[3/6] PyInstaller bündelt Anwendung..."
+
+rm -rf "$BUILD_DIR/pyinstaller_build" "$BUILD_DIR/pyinstaller_dist"
+
+cd "$SCRIPT_DIR"
+"$VENV_DIR/bin/pyinstaller" \
+ --distpath "$BUILD_DIR/pyinstaller_dist" \
+ --workpath "$BUILD_DIR/pyinstaller_build" \
+ --noconfirm \
+ --clean \
+ sipwebapp.spec
+
+echo " PyInstaller fertig"
+
+# === 4. AppDir erstellen ===
+echo "[4/6] AppDir aufbauen..."
+
+rm -rf "$APPDIR"
+mkdir -p "$APPDIR/usr/bin"
+mkdir -p "$APPDIR/usr/lib"
+mkdir -p "$APPDIR/usr/share/applications"
+mkdir -p "$APPDIR/usr/share/icons/hicolor/scalable/apps"
+
+# PyInstaller-Output kopieren
+cp -r "$BUILD_DIR/pyinstaller_dist/sipwebapp/"* "$APPDIR/usr/bin/"
+
+# .desktop Datei (AppImage-Root + share)
+cp "$SCRIPT_DIR/resources/sipwebapp.desktop" "$APPDIR/"
+cp "$SCRIPT_DIR/resources/sipwebapp.desktop" "$APPDIR/usr/share/applications/"
+
+# Icon (SVG → AppImage-Root und share)
+cp "$SCRIPT_DIR/resources/icons/phone.svg" "$APPDIR/sipwebapp.svg"
+cp "$SCRIPT_DIR/resources/icons/phone.svg" \
+ "$APPDIR/usr/share/icons/hicolor/scalable/apps/sipwebapp.svg"
+
+# === 5. AppRun erstellen ===
+echo "[5/6] AppRun erstellen..."
+
+cat > "$APPDIR/AppRun" << 'APPRUN_EOF'
+#!/bin/bash
+# AppRun für SIP Softphone
+# Setzt die nötigen Umgebungsvariablen und startet die App
+
+SELF="$(readlink -f "$0")"
+HERE="${SELF%/*}"
+APPBIN="$HERE/usr/bin"
+
+# Bibliotheken im AppImage finden
+export LD_LIBRARY_PATH="$APPBIN:$HERE/usr/lib:${LD_LIBRARY_PATH}"
+
+# Qt-Plugins im AppImage finden
+export QT_PLUGIN_PATH="$APPBIN/PySide6/Qt/plugins:${QT_PLUGIN_PATH}"
+export QML2_IMPORT_PATH="$APPBIN/PySide6/Qt/qml:${QML2_IMPORT_PATH}"
+
+# XDG-Integration
+export XDG_DATA_DIRS="$HERE/usr/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"
+
+# PipeWire/PulseAudio erlauben (Audio braucht Host-Zugriff)
+# Keine Sandbox, weil SIP-Audio direkt über ALSA/PipeWire läuft
+
+exec "$APPBIN/sipwebapp" "$@"
+APPRUN_EOF
+chmod +x "$APPDIR/AppRun"
+
+echo " AppDir komplett"
+
+# === 6. AppImage erstellen ===
+echo "[6/6] AppImage packen..."
+
+mkdir -p "$OUTPUT_DIR"
+ARCH=x86_64 "$APPIMAGETOOL" "$APPDIR" \
+ "$OUTPUT_DIR/SipSoftphone-x86_64.AppImage" 2>&1 | tail -5
+
+chmod +x "$OUTPUT_DIR/SipSoftphone-x86_64.AppImage"
+
+echo ""
+echo "=== FERTIG ==="
+SIZE=$(du -h "$OUTPUT_DIR/SipSoftphone-x86_64.AppImage" | cut -f1)
+echo "AppImage: $OUTPUT_DIR/SipSoftphone-x86_64.AppImage ($SIZE)"
+echo ""
+echo "Starten mit: $OUTPUT_DIR/SipSoftphone-x86_64.AppImage"
+echo "Oder: chmod +x SipSoftphone-x86_64.AppImage && ./SipSoftphone-x86_64.AppImage"
diff --git a/main.py b/main.py
new file mode 100644
index 0000000..a2568e9
--- /dev/null
+++ b/main.py
@@ -0,0 +1,141 @@
+#!/usr/bin/env python3
+"""SIP Softphone - Desktop-Anwendung für FreePBX/Asterisk.
+
+Basiert auf PJSUA2 (SIP-Stack) und PySide6 (Qt-GUI).
+Verbindet sich über SIP/UDP direkt mit der FreePBX-Anlage.
+
+Voraussetzungen:
+ - Python 3.10+
+ - PySide6: pip install PySide6
+ - pjsua2: yay -S python-pjproject (AUR, Manjaro/Arch)
+
+Starten:
+ python3 main.py
+
+Click-to-Call (tel: URI):
+ python3 main.py tel:+49123456789
+ python3 main.py sip:200@server
+"""
+
+import re
+import sys
+import signal
+
+from PySide6.QtWidgets import QApplication
+from PySide6.QtCore import Qt
+
+# D-Bus Single-Instance
+DBUS_SERVICE = "com.alleswattlaeuft.sipwebapp"
+DBUS_PATH = "/com/alleswattlaeuft/sipwebapp"
+
+try:
+ import dbus
+ import dbus.service
+ from dbus.mainloop.glib import DBusGMainLoop
+ DBUS_VERFUEGBAR = True
+except ImportError:
+ DBUS_VERFUEGBAR = False
+
+
+def _uri_zu_nummer(uri):
+ """tel: oder sip: URI in Rufnummer umwandeln."""
+ if not uri:
+ return ""
+ # tel:+49123456789 → +49123456789
+ nummer = re.sub(r"^(tel:|sip:)", "", uri, flags=re.IGNORECASE)
+ # sip:200@server → 200
+ nummer = re.sub(r"@.*$", "", nummer)
+ # Leerzeichen und Sonderzeichen entfernen
+ nummer = re.sub(r"[^\d+*#]", "", nummer)
+ return nummer
+
+
+def _uri_aus_args():
+ """tel:/sip: URI aus Kommandozeilenargumenten extrahieren."""
+ for arg in sys.argv[1:]:
+ if arg.lower().startswith(("tel:", "sip:")):
+ return arg
+ return ""
+
+
+class _DBusService(dbus.service.Object):
+ """D-Bus Service für Single-Instance und URI-Weiterleitung."""
+
+ def __init__(self, bus_name, fenster):
+ super().__init__(bus_name, DBUS_PATH)
+ self._fenster = fenster
+
+ @dbus.service.method(DBUS_SERVICE, in_signature="s")
+ def anruf_von_uri(self, uri):
+ """Von zweiter Instanz aufgerufen: URI → Anruf starten."""
+ nummer = _uri_zu_nummer(uri)
+ if nummer and self._fenster:
+ self._fenster.show()
+ self._fenster.activateWindow()
+ self._fenster.raise_()
+ # Nummer ins Wählfeld setzen und anrufen
+ self._fenster.waehlfeld.nummer_eingabe.setText(nummer)
+ self._fenster._anruf_mit_nummer(nummer)
+
+
+def main():
+ # Ctrl+C im Terminal erlauben
+ signal.signal(signal.SIGINT, signal.SIG_DFL)
+
+ uri = _uri_aus_args()
+
+ # D-Bus Mainloop MUSS vor allem anderen initialisiert werden
+ if DBUS_VERFUEGBAR:
+ DBusGMainLoop(set_as_default=True)
+
+ # D-Bus Single-Instance: Prüfen ob bereits eine Instanz läuft
+ if DBUS_VERFUEGBAR and uri:
+ try:
+ bus = dbus.SessionBus()
+ proxy = bus.get_object(DBUS_SERVICE, DBUS_PATH)
+ iface = dbus.Interface(proxy, DBUS_SERVICE)
+ iface.anruf_von_uri(uri)
+ # Erfolgreich an laufende Instanz weitergeleitet
+ sys.exit(0)
+ except dbus.DBusException:
+ pass # Keine laufende Instanz → normal starten
+
+ app = QApplication(sys.argv)
+ app.setApplicationName("SIP Softphone")
+ app.setOrganizationName("AllesWattLaeuft")
+ app.setApplicationVersion("1.0.0")
+
+ # High-DPI-Unterstützung
+ app.setHighDpiScaleFactorRoundingPolicy(
+ Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
+ )
+
+ from ui.hauptfenster import HauptFenster
+ fenster = HauptFenster()
+
+ # D-Bus Service registrieren (für Click-to-Call von Browser)
+ dbus_service = None
+ if DBUS_VERFUEGBAR:
+ try:
+ bus = dbus.SessionBus()
+ bus_name = dbus.service.BusName(DBUS_SERVICE, bus=bus)
+ dbus_service = _DBusService(bus_name, fenster)
+ except dbus.DBusException:
+ pass
+
+ fenster.show()
+ fenster.starten()
+
+ # Wenn mit URI gestartet, Anruf auslösen
+ if uri:
+ nummer = _uri_zu_nummer(uri)
+ if nummer:
+ # Kurz warten bis Registrierung durch ist
+ from PySide6.QtCore import QTimer
+ QTimer.singleShot(2000, lambda: fenster._anruf_mit_nummer(nummer))
+
+ sys.exit(app.exec())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..e74269d
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,5 @@
+PySide6>=6.6.0
+vobject>=0.9.6 # vCard-Parsing für CardDAV
+requests>=2.28.0 # HTTP-Client für CardDAV
+# pjsua2 wird über AUR installiert: yay -S python-pjproject
+# dbus-python: vorinstalliert auf Manjaro KDE (für Notifications + Click-to-Call)
diff --git a/resources/icons/phone.svg b/resources/icons/phone.svg
new file mode 100644
index 0000000..17aca49
--- /dev/null
+++ b/resources/icons/phone.svg
@@ -0,0 +1,21 @@
+
diff --git a/resources/sipwebapp.desktop b/resources/sipwebapp.desktop
new file mode 100644
index 0000000..a7edb7a
--- /dev/null
+++ b/resources/sipwebapp.desktop
@@ -0,0 +1,13 @@
+[Desktop Entry]
+Type=Application
+Version=1.0
+Name=SIP Softphone
+Comment=SIP-Softphone für FreePBX/Asterisk
+GenericName=Internet-Telefonie
+Exec=sipwebapp %u
+Icon=sipwebapp
+MimeType=x-scheme-handler/tel;x-scheme-handler/sip;
+Categories=Network;Telephony;
+Keywords=SIP;VoIP;Telefon;Phone;
+StartupNotify=true
+Terminal=false
diff --git a/resources/sounds/klingelton.wav b/resources/sounds/klingelton.wav
new file mode 100644
index 0000000..3e8496c
Binary files /dev/null and b/resources/sounds/klingelton.wav differ
diff --git a/sip/__init__.py b/sip/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/sip/account.py b/sip/account.py
new file mode 100644
index 0000000..b4d78ab
--- /dev/null
+++ b/sip/account.py
@@ -0,0 +1,82 @@
+"""SIP-Account Klasse - Wrapper um pjsua2.Account für FreePBX-Registrierung."""
+
+import pjsua2 as pj
+
+from sip.call import MeinAnruf
+
+
+class MeinAccount(pj.Account):
+ """SIP-Account für FreePBX-Registrierung.
+
+ Verwaltet den Registrierungsstatus und eingehende Anrufe.
+ """
+
+ def __init__(self, callback=None):
+ pj.Account.__init__(self)
+ self._callback = callback
+ self.aktueller_anruf = None
+ # Für Konferenz: Liste aller aktiven Anrufe
+ self.aktive_anrufe = []
+
+ def onRegState(self, prm):
+ """Registrierungsstatus hat sich geändert."""
+ ai = self.getInfo()
+ if self._callback:
+ self._callback("registrierung", {
+ "aktiv": ai.regIsActive,
+ "code": prm.code,
+ "grund": prm.reason,
+ "uri": ai.uri,
+ })
+
+ def onIncomingCall(self, iprm):
+ """Eingehender Anruf empfangen."""
+ anruf = MeinAnruf(self, iprm.callId, self._callback)
+ ci = anruf.getInfo()
+
+ # Als aktuellen Anruf setzen und in Liste aufnehmen
+ self.aktueller_anruf = anruf
+ self.aktive_anrufe.append(anruf)
+
+ if self._callback:
+ self._callback("eingehend", {
+ "remoteUri": ci.remoteUri,
+ "remoteContact": ci.remoteContact,
+ })
+
+ # 180 Ringing senden
+ prm = pj.CallOpParam()
+ prm.statusCode = pj.PJSIP_SC_RINGING
+ anruf.answer(prm)
+
+ def anruf_starten(self, ziel_uri):
+ """Ausgehenden Anruf starten."""
+ anruf = MeinAnruf(self, callback=self._callback)
+ prm = pj.CallOpParam(True)
+ anruf.makeCall(ziel_uri, prm)
+
+ self.aktueller_anruf = anruf
+ self.aktive_anrufe.append(anruf)
+ return anruf
+
+ def anruf_annehmen(self):
+ """Eingehenden Anruf annehmen (200 OK)."""
+ if self.aktueller_anruf:
+ prm = pj.CallOpParam()
+ prm.statusCode = pj.PJSIP_SC_OK
+ self.aktueller_anruf.answer(prm)
+
+ def anruf_beenden(self):
+ """Aktuellen Anruf auflegen."""
+ if self.aktueller_anruf:
+ self.aktueller_anruf.auflegen()
+
+ def anruf_entfernen(self, anruf):
+ """Beendeten Anruf aus der Liste entfernen."""
+ if anruf in self.aktive_anrufe:
+ self.aktive_anrufe.remove(anruf)
+ if self.aktueller_anruf is anruf:
+ # Nächsten aktiven Anruf als aktuell setzen
+ self.aktueller_anruf = (
+ self.aktive_anrufe[-1] if self.aktive_anrufe else None
+ )
diff --git a/sip/call.py b/sip/call.py
new file mode 100644
index 0000000..140e866
--- /dev/null
+++ b/sip/call.py
@@ -0,0 +1,152 @@
+"""SIP-Anruf Klasse - Wrapper um pjsua2.Call mit Qt-Signalweiterleitung."""
+
+import pjsua2 as pj
+
+
+class MeinAnruf(pj.Call):
+ """Repräsentiert einen einzelnen SIP-Anruf.
+
+ Leitet PJSUA2-Callbacks an eine Callback-Funktion weiter,
+ die dann Qt-Signals im Hauptthread auslöst.
+
+ Audio-Verbindung wird von PJSUA2 automatisch über die Conference Bridge
+ gemanagt (setCaptureDev/setPlaybackDev). Kein manuelles startTransmit nötig.
+ """
+
+ def __init__(self, acc, call_id=pj.PJSUA_INVALID_ID, callback=None):
+ pj.Call.__init__(self, acc, call_id)
+ self._callback = callback
+ self._ist_aktiv = False
+
+ @property
+ def ist_aktiv(self):
+ """Gibt True zurück wenn der Anruf gerade verbunden ist."""
+ return self._ist_aktiv
+
+ def onCallState(self, prm):
+ """Wird bei jeder Änderung des Anrufstatus aufgerufen."""
+ ci = self.getInfo()
+ zustand = ci.stateText
+
+ if ci.state == pj.PJSIP_INV_STATE_CONFIRMED:
+ self._ist_aktiv = True
+ elif ci.state == pj.PJSIP_INV_STATE_DISCONNECTED:
+ self._ist_aktiv = False
+
+ if self._callback:
+ self._callback("zustand", {
+ "state": ci.state,
+ "stateText": zustand,
+ "remoteUri": ci.remoteUri,
+ "lastReason": ci.lastReason,
+ "lastStatusCode": ci.lastStatusCode,
+ "connectDuration": ci.connectDuration.sec,
+ })
+
+ def onCallMediaState(self, prm):
+ """Audio-Verbindung bei aktivem Media sicherstellen.
+
+ startTransmit() auf bereits verbundenen Ports ist ein No-Op,
+ daher sicher bei mehrfachem Aufruf (183→200 Neuverhandlung).
+ """
+ ci = self.getInfo()
+ for i, mi in enumerate(ci.media):
+ if mi.type == pj.PJMEDIA_TYPE_AUDIO and \
+ mi.status == pj.PJSUA_CALL_MEDIA_ACTIVE:
+ try:
+ call_audio = self.getAudioMedia(i)
+ aud_mgr = pj.Endpoint.instance().audDevManager()
+ # Mikrofon → Anruf
+ aud_mgr.getCaptureDevMedia().startTransmit(call_audio)
+ # Anruf → Lautsprecher
+ call_audio.startTransmit(
+ aud_mgr.getPlaybackDevMedia()
+ )
+ except pj.Error:
+ pass
+
+ def onCallTransferRequest(self, prm):
+ """Eingehende Weiterleitungs-Anfrage."""
+ if self._callback:
+ self._callback("transfer_anfrage", {"ziel": prm.currentTarget})
+
+ def onDtmfDigit(self, prm):
+ """DTMF-Ton empfangen."""
+ if self._callback:
+ self._callback("dtmf_empfangen", {"digit": prm.digit})
+
+ def dtmf_senden(self, ziffern):
+ """DTMF-Töne senden (z.B. für IVR-Menüs)."""
+ try:
+ prm = pj.CallSendDtmfParam()
+ prm.digits = ziffern
+ prm.method = pj.PJSUA_DTMF_METHOD_RFC2833
+ self.sendDtmf(prm)
+ except pj.Error as e:
+ if self._callback:
+ self._callback("fehler", {"text": f"DTMF-Fehler: {e}"})
+
+ def halten(self):
+ """Anruf auf Halten setzen."""
+ try:
+ prm = pj.CallOpParam(True)
+ self.setHold(prm)
+ except pj.Error as e:
+ if self._callback:
+ self._callback("fehler", {"text": f"Hold-Fehler: {e}"})
+
+ def fortsetzen(self):
+ """Gehaltenen Anruf fortsetzen."""
+ try:
+ prm = pj.CallOpParam(True)
+ prm.opt.flag = pj.PJSUA_CALL_UNHOLD
+ prm.opt.audioCount = 1
+ prm.opt.videoCount = 0
+ self.reinvite(prm)
+ except pj.Error as e:
+ if self._callback:
+ self._callback("fehler", {"text": f"Unhold-Fehler: {e}"})
+
+ def stummschalten(self, stumm):
+ """Mikrofon stumm schalten / wieder aktivieren.
+
+ Nutzt adjustTxLevel() statt stopTransmit() - zuverlässiger
+ weil es die Conference-Bridge-Verbindung nicht unterbricht.
+ """
+ try:
+ aud_mgr = pj.Endpoint.instance().audDevManager()
+ if stumm:
+ # TX-Level auf 0 = Stille senden
+ aud_mgr.getCaptureDevMedia().adjustTxLevel(0.0)
+ else:
+ # TX-Level auf 1 = normale Lautstärke
+ aud_mgr.getCaptureDevMedia().adjustTxLevel(1.0)
+ except pj.Error:
+ pass
+
+ def blind_transfer(self, ziel_uri):
+ """Blinde Weiterleitung an eine andere Nummer."""
+ try:
+ prm = pj.CallOpParam()
+ self.xfer(ziel_uri, prm)
+ except pj.Error as e:
+ if self._callback:
+ self._callback("fehler", {"text": f"Transfer-Fehler: {e}"})
+
+ def attended_transfer(self, anderer_anruf):
+ """Vermittelte Weiterleitung (Attended Transfer)."""
+ try:
+ prm = pj.CallOpParam()
+ self.xferReplaces(anderer_anruf, prm)
+ except pj.Error as e:
+ if self._callback:
+ self._callback("fehler", {"text": f"Attended-Transfer-Fehler: {e}"})
+
+ def auflegen(self):
+ """Anruf beenden."""
+ try:
+ prm = pj.CallOpParam()
+ prm.statusCode = pj.PJSIP_SC_DECLINE
+ self.hangup(prm)
+ except pj.Error:
+ pass # Anruf war bereits beendet
diff --git a/sip/engine.py b/sip/engine.py
new file mode 100644
index 0000000..b759409
--- /dev/null
+++ b/sip/engine.py
@@ -0,0 +1,292 @@
+"""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
diff --git a/sipwebapp.spec b/sipwebapp.spec
new file mode 100644
index 0000000..f671bc5
--- /dev/null
+++ b/sipwebapp.spec
@@ -0,0 +1,127 @@
+# -*- mode: python ; coding: utf-8 -*-
+"""PyInstaller Spec-Datei für SIP Softphone AppImage.
+
+Bündelt Python-App + pjsua2 + Qt + alle nativen Abhängigkeiten
+zu einem komplett unabhängigen Paket.
+"""
+
+import os
+import subprocess
+import re
+
+# === Pfade ===
+APP_DIR = os.path.dirname(os.path.abspath(SPEC))
+PJSUA2_SO = "/tmp/sipwebapp_run/_pjsua2.cpython-314-x86_64-linux-gnu.so"
+
+# === pjsua2 native Bibliotheken sammeln ===
+# Alle Shared Libraries die _pjsua2.so braucht (nicht-triviale)
+def pjsua2_libs_sammeln():
+ """Alle pjsua2-Abhängigkeiten aus ldd extrahieren."""
+ ergebnis = subprocess.run(
+ ["ldd", PJSUA2_SO], capture_output=True, text=True)
+ libs = []
+ # System-Basis-Libs die überall vorhanden sind (nicht bündeln)
+ skip = {
+ "linux-vdso", "ld-linux", "libc.so", "libm.so",
+ "libdl.so", "libpthread.so", "librt.so",
+ }
+ for zeile in ergebnis.stdout.splitlines():
+ match = re.match(r"\s+(\S+)\s+=>\s+(/\S+)", zeile)
+ if match:
+ name, pfad = match.groups()
+ if not any(s in name for s in skip) and os.path.exists(pfad):
+ libs.append((pfad, "."))
+ return libs
+
+pjsua2_binaries = pjsua2_libs_sammeln()
+
+# dbus native Libs
+dbus_libs = []
+for lib_name in ["libdbus-1.so.3", "libglib-2.0.so.0", "libgio-2.0.so.0",
+ "libgobject-2.0.so.0", "libgmodule-2.0.so.0"]:
+ for pfad in [f"/usr/lib/{lib_name}", f"/usr/lib/x86_64-linux-gnu/{lib_name}"]:
+ if os.path.exists(pfad):
+ dbus_libs.append((pfad, "."))
+ break
+
+# === pjsua2 Python-Bindings ===
+pjsua2_daten = [
+ (PJSUA2_SO, "."),
+ ("/tmp/sipwebapp_run/pjsua2.py", "."),
+]
+
+# === dbus Python-Bindings ===
+SYS_SITE = "/usr/lib/python3.14/site-packages"
+dbus_daten = []
+if os.path.isdir(f"{SYS_SITE}/dbus"):
+ dbus_daten.append((f"{SYS_SITE}/dbus", "dbus"))
+for so in ["_dbus_bindings", "_dbus_glib_bindings"]:
+ for f in os.listdir(SYS_SITE):
+ if f.startswith(so) and f.endswith(".so"):
+ dbus_daten.append((f"{SYS_SITE}/{f}", "."))
+
+# === Ressourcen ===
+ressourcen = [
+ (os.path.join(APP_DIR, "resources"), "resources"),
+]
+
+# === Hidden Imports (Module die PyInstaller nicht automatisch findet) ===
+hidden_imports = [
+ "pjsua2",
+ "_pjsua2",
+ "dbus",
+ "dbus.service",
+ "dbus.mainloop.glib",
+ "dbus._dbus",
+ "dbus.bus",
+ "dbus.connection",
+ "dbus.decorators",
+ "dbus.exceptions",
+ "dbus.lowlevel",
+ "dbus.proxies",
+ "dbus.types",
+ "vobject",
+ "vobject.vcard",
+ "vobject.base",
+]
+
+# === Analysis ===
+a = Analysis(
+ [os.path.join(APP_DIR, "main.py")],
+ pathex=[APP_DIR],
+ binaries=pjsua2_binaries + dbus_libs + pjsua2_daten,
+ datas=ressourcen + dbus_daten,
+ hiddenimports=hidden_imports,
+ hookspath=[],
+ hooksconfig={},
+ runtime_hooks=[],
+ excludes=[
+ "tkinter", "unittest", "test", "xmlrpc",
+ "pydoc", "doctest", "lib2to3",
+ ],
+ noarchive=False,
+)
+
+pyz = PYZ(a.pure)
+
+exe = EXE(
+ pyz,
+ a.scripts,
+ [],
+ exclude_binaries=True,
+ name="sipwebapp",
+ debug=False,
+ bootloader_ignore_signals=False,
+ strip=True,
+ upx=False,
+ console=False, # Kein Terminal-Fenster
+)
+
+coll = COLLECT(
+ exe,
+ a.binaries,
+ a.datas,
+ strip=True,
+ upx=False,
+ name="sipwebapp",
+)
diff --git a/ui/__init__.py b/ui/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/ui/anrufliste.py b/ui/anrufliste.py
new file mode 100644
index 0000000..2c157da
--- /dev/null
+++ b/ui/anrufliste.py
@@ -0,0 +1,234 @@
+"""Anrufliste-Widget - Zeigt vergangene Anrufe mit Rückruf-Funktion."""
+
+from datetime import datetime
+
+from PySide6.QtWidgets import (
+ QWidget, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem,
+ QPushButton, QHeaderView, QAbstractItemView,
+)
+from PySide6.QtCore import Signal, Qt
+from PySide6.QtGui import QColor
+
+
+class AnruflisteWidget(QWidget):
+ """Anrufliste mit Richtung, Nummer, Zeitpunkt und Dauer."""
+
+ # Signal: Nummer zum Rückrufen
+ rueckruf = Signal(str)
+
+ # Richtungs-Symbole
+ RICHTUNG_SYMBOLE = {
+ "ausgehend": "\u2191", # ↑
+ "eingehend": "\u2193", # ↓
+ "verpasst": "\u2199", # ↙
+ }
+
+ def __init__(self, config_manager, parent=None):
+ super().__init__(parent)
+ self._config = config_manager
+ self._anrufe = []
+ self._kontakte_liste = [] # Für Namensauflösung
+ self._ui_aufbauen()
+ self._anrufe_laden()
+
+ def _ui_aufbauen(self):
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(6)
+
+ # Tabelle
+ self.tabelle = QTableWidget()
+ self.tabelle.setColumnCount(4)
+ self.tabelle.setHorizontalHeaderLabels(
+ ["", "Nummer", "Zeitpunkt", "Dauer"]
+ )
+ self.tabelle.setSelectionBehavior(
+ QAbstractItemView.SelectionBehavior.SelectRows
+ )
+ self.tabelle.setEditTriggers(
+ QAbstractItemView.EditTrigger.NoEditTriggers
+ )
+ self.tabelle.setAlternatingRowColors(True)
+ self.tabelle.verticalHeader().setVisible(False)
+ self.tabelle.setStyleSheet(
+ "QTableWidget { "
+ " border: 1px solid #444; border-radius: 4px; "
+ " gridline-color: #3a3a3a; "
+ "}"
+ "QTableWidget::item { padding: 4px 6px; }"
+ "QTableWidget::item:selected { "
+ " background-color: rgba(85,153,221,0.3); "
+ "}"
+ "QHeaderView::section { "
+ " background-color: rgba(255,255,255,0.06); "
+ " border: none; border-bottom: 2px solid #555; "
+ " padding: 6px 8px; font-weight: bold; "
+ "}"
+ )
+
+ # Spaltenbreiten
+ header = self.tabelle.horizontalHeader()
+ header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
+ header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
+ header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
+ header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
+ self.tabelle.setColumnWidth(0, 30)
+
+ # Doppelklick → Rückruf
+ self.tabelle.doubleClicked.connect(self._doppelklick)
+
+ layout.addWidget(self.tabelle)
+
+ # Buttons
+ btn_layout = QHBoxLayout()
+ btn_layout.setSpacing(4)
+
+ btn_style = (
+ "QPushButton { "
+ " border: 1px solid #555; border-radius: 6px; "
+ " padding: 6px 12px; "
+ " background: rgba(255,255,255,0.06); "
+ "}"
+ "QPushButton:hover { "
+ " background: rgba(85,153,221,0.15); "
+ " border-color: #5599DD; "
+ "}"
+ "QPushButton:pressed { background: rgba(85,153,221,0.25); }"
+ )
+ gruen_style = (
+ "QPushButton { "
+ " border: 1px solid #4CAF50; border-radius: 6px; "
+ " padding: 6px 12px; color: #4CAF50; "
+ " background: rgba(76,175,80,0.08); "
+ "}"
+ "QPushButton:hover { background: rgba(76,175,80,0.2); }"
+ "QPushButton:pressed { background: rgba(76,175,80,0.3); }"
+ )
+
+ rueckruf_btn = QPushButton("Rückruf")
+ rueckruf_btn.setStyleSheet(gruen_style)
+ rueckruf_btn.setCursor(Qt.CursorShape.PointingHandCursor)
+ rueckruf_btn.clicked.connect(self._rueckruf_klick)
+
+ loeschen_btn = QPushButton("Liste leeren")
+ loeschen_btn.setStyleSheet(btn_style)
+ loeschen_btn.setCursor(Qt.CursorShape.PointingHandCursor)
+ loeschen_btn.clicked.connect(self._liste_leeren)
+
+ btn_layout.addWidget(rueckruf_btn)
+ btn_layout.addStretch()
+ btn_layout.addWidget(loeschen_btn)
+ layout.addLayout(btn_layout)
+
+ def anruf_hinzufuegen(self, nummer, richtung, dauer_sek=0):
+ """Neuen Anruf zur Liste hinzufügen.
+
+ Args:
+ nummer: Telefonnummer/Extension
+ richtung: "ausgehend", "eingehend" oder "verpasst"
+ dauer_sek: Gesprächsdauer in Sekunden
+ """
+ eintrag = {
+ "nummer": nummer,
+ "richtung": richtung,
+ "zeitpunkt": datetime.now().isoformat(),
+ "dauer": dauer_sek,
+ }
+ self._anrufe.insert(0, eintrag)
+
+ # Maximal 100 Einträge behalten
+ self._anrufe = self._anrufe[:100]
+
+ self._tabelle_aktualisieren()
+ self._anrufe_speichern()
+
+ def _tabelle_aktualisieren(self):
+ """Tabelle mit aktuellen Daten füllen."""
+ self.tabelle.setRowCount(len(self._anrufe))
+
+ for zeile, anruf in enumerate(self._anrufe):
+ richtung = anruf.get("richtung", "ausgehend")
+ symbol = self.RICHTUNG_SYMBOLE.get(richtung, "?")
+
+ # Richtung
+ richt_item = QTableWidgetItem(symbol)
+ richt_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
+ if richtung == "verpasst":
+ richt_item.setForeground(QColor("red"))
+ elif richtung == "eingehend":
+ richt_item.setForeground(QColor("green"))
+ self.tabelle.setItem(zeile, 0, richt_item)
+
+ # Nummer (mit Name wenn verfügbar)
+ nummer = anruf.get("nummer", "")
+ name = self._name_fuer_nummer(nummer)
+ anzeige = f"{name} ({nummer})" if name else nummer
+ self.tabelle.setItem(zeile, 1, QTableWidgetItem(anzeige))
+
+ # Zeitpunkt
+ try:
+ dt = datetime.fromisoformat(anruf["zeitpunkt"])
+ zeittext = dt.strftime("%d.%m. %H:%M")
+ except (KeyError, ValueError):
+ zeittext = ""
+ self.tabelle.setItem(zeile, 2, QTableWidgetItem(zeittext))
+
+ # Dauer
+ dauer = anruf.get("dauer", 0)
+ if dauer > 0:
+ minuten = dauer // 60
+ sekunden = dauer % 60
+ dauer_text = f"{minuten}:{sekunden:02d}"
+ else:
+ dauer_text = "-"
+ dauer_item = QTableWidgetItem(dauer_text)
+ dauer_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.tabelle.setItem(zeile, 3, dauer_item)
+
+ def _doppelklick(self, index):
+ """Doppelklick auf einen Eintrag → Rückruf."""
+ zeile = index.row()
+ if 0 <= zeile < len(self._anrufe):
+ nummer = self._anrufe[zeile].get("nummer", "")
+ if nummer:
+ self.rueckruf.emit(nummer)
+
+ def _rueckruf_klick(self):
+ """Rückruf-Button geklickt."""
+ zeile = self.tabelle.currentRow()
+ if 0 <= zeile < len(self._anrufe):
+ nummer = self._anrufe[zeile].get("nummer", "")
+ if nummer:
+ self.rueckruf.emit(nummer)
+
+ def _liste_leeren(self):
+ """Alle Einträge löschen."""
+ self._anrufe = []
+ self._tabelle_aktualisieren()
+ self._anrufe_speichern()
+
+ def _anrufe_laden(self):
+ """Anrufliste aus Config laden."""
+ self._anrufe = self._config.anrufliste_laden()
+ self._tabelle_aktualisieren()
+
+ def _name_fuer_nummer(self, nummer):
+ """Kontaktname für eine Nummer nachschlagen."""
+ if not nummer:
+ return ""
+ for kontakt in self._kontakte_liste:
+ nummern = kontakt.get("nummern", {})
+ if nummer in nummern.values():
+ return kontakt.get("name", "")
+ if kontakt.get("nummer") == nummer:
+ return kontakt.get("name", "")
+ return ""
+
+ def kontakte_setzen(self, kontakte):
+ """Kontaktliste für die Namensauflösung setzen."""
+ self._kontakte_liste = kontakte or []
+ self._tabelle_aktualisieren() # Tabelle neu aufbauen mit Namen
+
+ def _anrufe_speichern(self):
+ """Anrufliste in Config speichern."""
+ self._config.anrufliste_speichern(self._anrufe)
diff --git a/ui/blf_panel.py b/ui/blf_panel.py
new file mode 100644
index 0000000..4cc32d7
--- /dev/null
+++ b/ui/blf_panel.py
@@ -0,0 +1,157 @@
+"""BLF-Panel - Besetzt-Lampenfeld für Extension-Status-Überwachung."""
+
+import pjsua2 as pj
+
+from PySide6.QtWidgets import (
+ QWidget, QVBoxLayout, QGridLayout, QPushButton, QLabel,
+)
+from PySide6.QtCore import Signal, Qt
+from PySide6.QtGui import QColor
+
+
+class BlfBuddy(pj.Buddy):
+ """PJSUA2-Buddy für Presence-Monitoring einer Extension."""
+
+ def __init__(self, extension, callback=None):
+ pj.Buddy.__init__(self)
+ self.extension = extension
+ self._callback = callback
+
+ def onBuddyState(self):
+ """Wird aufgerufen wenn sich der Presence-Status ändert."""
+ bi = self.getInfo()
+ if self._callback:
+ self._callback(self.extension, {
+ "status": bi.presMonitorEnabled,
+ "activity": bi.presStatus.activity,
+ "statusText": bi.presStatus.statusText,
+ "online": bi.presStatus.status == pj.PJSUA_BUDDY_STATUS_ONLINE,
+ })
+
+
+class BlfPanel(QWidget):
+ """Panel mit Status-LEDs für konfigurierte Extensions."""
+
+ # Signal: Extension zum Anrufen angeklickt
+ extension_geklickt = Signal(str)
+
+ # Status-Farben
+ FARBEN = {
+ "frei": "#4CAF50", # Grün
+ "besetzt": "#F44336", # Rot
+ "klingelt": "#FF9800", # Orange
+ "offline": "#9E9E9E", # Grau
+ }
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._buddies = {} # extension → BlfBuddy
+ self._buttons = {} # extension → QPushButton
+ self._account = None
+ self._server = ""
+ self._ui_aufbauen()
+
+ def _ui_aufbauen(self):
+ self._layout = QVBoxLayout(self)
+ self._layout.setContentsMargins(0, 0, 0, 0)
+
+ titel = QLabel("BLF-Status")
+ titel.setStyleSheet("font-weight: bold; margin-bottom: 4px;")
+ self._layout.addWidget(titel)
+
+ self._grid = QGridLayout()
+ self._grid.setSpacing(4)
+ self._layout.addLayout(self._grid)
+ self._layout.addStretch()
+
+ def konfigurieren(self, account, server, extensions):
+ """BLF-Panel mit Extensions konfigurieren.
+
+ Args:
+ account: MeinAccount-Instanz (PJSUA2-Account)
+ server: FreePBX Server-IP
+ extensions: Liste von Extension-Nummern (z.B. ["200", "201"])
+ """
+ self._account = account
+ self._server = server
+
+ # Bestehende Buddies aufräumen
+ self._buddies_aufraumen()
+
+ # Bestehende Buttons entfernen
+ for btn in self._buttons.values():
+ self._grid.removeWidget(btn)
+ btn.deleteLater()
+ self._buttons.clear()
+
+ # Neue Buttons und Buddies erstellen
+ for i, ext in enumerate(extensions):
+ zeile = i // 4 # 4 Buttons pro Zeile
+ spalte = i % 4
+
+ btn = QPushButton(ext)
+ btn.setMinimumSize(60, 40)
+ btn.setStyleSheet(
+ f"background-color: {self.FARBEN['offline']}; "
+ "color: white; font-weight: bold; border-radius: 4px;"
+ )
+ btn.clicked.connect(
+ lambda checked, e=ext: self.extension_geklickt.emit(e)
+ )
+ self._grid.addWidget(btn, zeile, spalte)
+ self._buttons[ext] = btn
+
+ # Buddy für Presence-Monitoring erstellen
+ if account:
+ self._buddy_erstellen(ext)
+
+ def _buddy_erstellen(self, extension):
+ """PJSUA2-Buddy für eine Extension erstellen."""
+ try:
+ buddy = BlfBuddy(extension, callback=self._status_update)
+ buddy_cfg = pj.BuddyConfig()
+ buddy_cfg.uri = f"sip:{extension}@{self._server}"
+ buddy_cfg.subscribe = True
+ buddy.create(self._account, buddy_cfg)
+ self._buddies[extension] = buddy
+ except pj.Error:
+ pass # Extension nicht erreichbar
+
+ def _status_update(self, extension, status):
+ """Callback: Status einer Extension hat sich geändert."""
+ if extension not in self._buttons:
+ return
+
+ btn = self._buttons[extension]
+
+ if status.get("online"):
+ # Online → prüfe Activity
+ activity = status.get("activity", 0)
+ if activity == pj.PJRPID_ACTIVITY_BUSY:
+ farbe = self.FARBEN["besetzt"]
+ else:
+ farbe = self.FARBEN["frei"]
+ else:
+ farbe = self.FARBEN["offline"]
+
+ btn.setStyleSheet(
+ f"background-color: {farbe}; "
+ "color: white; font-weight: bold; border-radius: 4px;"
+ )
+
+ tooltip = status.get("statusText", "")
+ if tooltip:
+ btn.setToolTip(f"{extension}: {tooltip}")
+
+ def _buddies_aufraumen(self):
+ """Alle Buddies sauber herunterfahren."""
+ for buddy in self._buddies.values():
+ try:
+ buddy.subscribePresence(False)
+ except pj.Error:
+ pass
+ self._buddies.clear()
+
+ def aufraumen(self):
+ """Ressourcen freigeben."""
+ self._buddies_aufraumen()
diff --git a/ui/einstellungen.py b/ui/einstellungen.py
new file mode 100644
index 0000000..6492a83
--- /dev/null
+++ b/ui/einstellungen.py
@@ -0,0 +1,482 @@
+"""Einstellungen-Dialog - SIP, Audio, CardDAV und allgemeine Konfiguration."""
+
+from PySide6.QtWidgets import (
+ QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
+ QLineEdit, QPushButton, QComboBox, QCheckBox,
+ QTabWidget, QWidget, QSpinBox, QLabel, QFileDialog,
+ QListWidget, QListWidgetItem, QGroupBox, QGridLayout,
+)
+from PySide6.QtCore import Signal, Qt
+
+
+class CardDavAccountDialog(QDialog):
+ """Dialog zum Hinzufügen/Bearbeiten eines CardDAV-Accounts."""
+
+ def __init__(self, account=None, parent=None):
+ super().__init__(parent)
+ self._account = account or {}
+ self.setWindowTitle(
+ "Account bearbeiten" if account else "Neuer CardDAV-Account"
+ )
+ self.setMinimumWidth(450)
+ self.setModal(True)
+ self._ui_aufbauen()
+
+ def _ui_aufbauen(self):
+ layout = QVBoxLayout(self)
+ form = QFormLayout()
+
+ self.name_eingabe = QLineEdit(self._account.get("name", ""))
+ self.name_eingabe.setPlaceholderText("z.B. Privat, Geschäftlich")
+ form.addRow("Name:", self.name_eingabe)
+
+ self.url_eingabe = QLineEdit(self._account.get("url", ""))
+ self.url_eingabe.setPlaceholderText(
+ "https://nextcloud.example.com/remote.php/dav/addressbooks/"
+ "users/name/contacts/"
+ )
+ form.addRow("URL:", self.url_eingabe)
+
+ self.benutzer_eingabe = QLineEdit(
+ self._account.get("benutzername", ""))
+ self.benutzer_eingabe.setPlaceholderText("Benutzername")
+ form.addRow("Benutzer:", self.benutzer_eingabe)
+
+ self.passwort_eingabe = QLineEdit(self._account.get("passwort", ""))
+ self.passwort_eingabe.setEchoMode(QLineEdit.EchoMode.Password)
+ self.passwort_eingabe.setPlaceholderText("Passwort oder App-Token")
+ form.addRow("Passwort:", self.passwort_eingabe)
+
+ layout.addLayout(form)
+
+ btn_layout = QHBoxLayout()
+ ok_btn = QPushButton("OK")
+ ok_btn.setDefault(True)
+ ok_btn.clicked.connect(self.accept)
+ abbrechen_btn = QPushButton("Abbrechen")
+ abbrechen_btn.clicked.connect(self.reject)
+ btn_layout.addStretch()
+ btn_layout.addWidget(abbrechen_btn)
+ btn_layout.addWidget(ok_btn)
+ layout.addLayout(btn_layout)
+
+ @property
+ def account_daten(self):
+ """Account-Dict aus den Eingabefeldern."""
+ return {
+ "name": self.name_eingabe.text().strip(),
+ "url": self.url_eingabe.text().strip(),
+ "benutzername": self.benutzer_eingabe.text().strip(),
+ "passwort": self.passwort_eingabe.text(),
+ }
+
+
+class EinstellungenDialog(QDialog):
+ """Dialog für Anwendungseinstellungen mit Tabs."""
+
+ einstellungen_geaendert = Signal()
+
+ def __init__(self, config_manager, audio_manager=None,
+ klingelton_player=None, parent=None):
+ super().__init__(parent)
+ self._config = config_manager
+ self._audio = audio_manager
+ self._klingelton = klingelton_player
+ self.setWindowTitle("Einstellungen")
+ self.setMinimumWidth(500)
+ self.setModal(True)
+ self._ui_aufbauen()
+ self._werte_laden()
+
+ def _ui_aufbauen(self):
+ layout = QVBoxLayout(self)
+
+ self.tabs = QTabWidget()
+ layout.addWidget(self.tabs)
+
+ self._sip_tab_erstellen()
+ self._audio_tab_erstellen()
+ self._allgemein_tab_erstellen()
+ self._carddav_tab_erstellen()
+ self._blf_tab_erstellen()
+
+ # Buttons
+ btn_layout = QHBoxLayout()
+ self.speichern_btn = QPushButton("Speichern")
+ self.abbrechen_btn = QPushButton("Abbrechen")
+ self.speichern_btn.clicked.connect(self._speichern)
+ self.abbrechen_btn.clicked.connect(self.reject)
+ btn_layout.addStretch()
+ btn_layout.addWidget(self.abbrechen_btn)
+ btn_layout.addWidget(self.speichern_btn)
+ layout.addLayout(btn_layout)
+
+ def _sip_tab_erstellen(self):
+ """Tab für SIP-Einstellungen."""
+ widget = QWidget()
+ form = QFormLayout(widget)
+
+ self.sip_server = QLineEdit()
+ self.sip_server.setPlaceholderText("192.168.154.242")
+ form.addRow("Server:", self.sip_server)
+
+ self.sip_port = QSpinBox()
+ self.sip_port.setRange(1, 65535)
+ self.sip_port.setValue(5060)
+ form.addRow("Port:", self.sip_port)
+
+ self.sip_extension = QLineEdit()
+ self.sip_extension.setPlaceholderText("200")
+ form.addRow("Extension:", self.sip_extension)
+
+ self.sip_passwort = QLineEdit()
+ self.sip_passwort.setEchoMode(QLineEdit.EchoMode.Password)
+ form.addRow("Passwort:", self.sip_passwort)
+
+ self.sip_transport = QComboBox()
+ self.sip_transport.addItems(["UDP", "TCP"])
+ form.addRow("Transport:", self.sip_transport)
+
+ self.tabs.addTab(widget, "SIP")
+
+ def _audio_tab_erstellen(self):
+ """Tab für Audio-Einstellungen (PipeWire/KDE-Geräte)."""
+ widget = QWidget()
+ form = QFormLayout(widget)
+
+ hinweis = QLabel(
+ "Geräte werden wie in KDE-Audioeinstellungen angezeigt.")
+ hinweis.setStyleSheet(
+ "color: #888; font-size: 11px; margin-bottom: 4px;")
+ form.addRow(hinweis)
+
+ self.aufnahme_combo = QComboBox()
+ form.addRow("Mikrofon:", self.aufnahme_combo)
+
+ self.wiedergabe_combo = QComboBox()
+ form.addRow("Lautsprecher:", self.wiedergabe_combo)
+
+ self.klingelton_geraet_combo = QComboBox()
+ form.addRow("Klingelton-Gerät:", self.klingelton_geraet_combo)
+
+ aktualisieren_btn = QPushButton("Geräte aktualisieren")
+ aktualisieren_btn.clicked.connect(self._audiogeraete_aktualisieren)
+ form.addRow("", aktualisieren_btn)
+
+ self.tabs.addTab(widget, "Audio")
+
+ def _allgemein_tab_erstellen(self):
+ """Tab für allgemeine Einstellungen."""
+ widget = QWidget()
+ form = QFormLayout(widget)
+
+ self.tray_check = QCheckBox(
+ "Beim Schließen in System-Tray minimieren")
+ form.addRow(self.tray_check)
+
+ self.autostart_check = QCheckBox(
+ "Beim Systemstart automatisch starten")
+ form.addRow(self.autostart_check)
+
+ # Klingelton-Datei
+ klingelton_layout = QHBoxLayout()
+ self.klingelton_eingabe = QLineEdit()
+ self.klingelton_eingabe.setPlaceholderText("Standard-Klingelton")
+ self.klingelton_eingabe.setReadOnly(True)
+ klingelton_btn = QPushButton("Durchsuchen...")
+ klingelton_btn.clicked.connect(self._klingelton_waehlen)
+ klingelton_test_btn = QPushButton("Test")
+ klingelton_test_btn.clicked.connect(self._klingelton_testen)
+ klingelton_layout.addWidget(self.klingelton_eingabe)
+ klingelton_layout.addWidget(klingelton_btn)
+ klingelton_layout.addWidget(klingelton_test_btn)
+ form.addRow("Klingelton:", klingelton_layout)
+
+ self.tabs.addTab(widget, "Allgemein")
+
+ def _carddav_tab_erstellen(self):
+ """Tab für CardDAV-Konfiguration (Multi-Account)."""
+ widget = QWidget()
+ layout = QVBoxLayout(widget)
+
+ hinweis = QLabel(
+ "Kontakte von Nextcloud/CardDAV-Servern synchronisieren.\n"
+ "Mehrere Accounts möglich (z.B. Privat + Geschäftlich)."
+ )
+ hinweis.setStyleSheet(
+ "color: #888; font-size: 11px; margin-bottom: 4px;")
+ layout.addWidget(hinweis)
+
+ # Account-Liste
+ self.carddav_account_liste = QListWidget()
+ self.carddav_account_liste.setMaximumHeight(120)
+ layout.addWidget(self.carddav_account_liste)
+
+ # Account-Buttons
+ acc_btn_layout = QHBoxLayout()
+ acc_hinzu_btn = QPushButton("Account hinzufügen")
+ acc_hinzu_btn.clicked.connect(self._carddav_account_hinzufuegen)
+ acc_btn_layout.addWidget(acc_hinzu_btn)
+
+ acc_bearbeiten_btn = QPushButton("Bearbeiten")
+ acc_bearbeiten_btn.clicked.connect(self._carddav_account_bearbeiten)
+ acc_btn_layout.addWidget(acc_bearbeiten_btn)
+
+ acc_loeschen_btn = QPushButton("Entfernen")
+ acc_loeschen_btn.clicked.connect(self._carddav_account_loeschen)
+ acc_btn_layout.addWidget(acc_loeschen_btn)
+
+ layout.addLayout(acc_btn_layout)
+
+ # Sync-Optionen
+ form = QFormLayout()
+ self.carddav_auto_sync = QCheckBox("Automatisch synchronisieren")
+ form.addRow(self.carddav_auto_sync)
+
+ self.carddav_intervall = QSpinBox()
+ self.carddav_intervall.setRange(60, 86400)
+ self.carddav_intervall.setValue(3600)
+ self.carddav_intervall.setSuffix(" Sekunden")
+ form.addRow("Intervall:", self.carddav_intervall)
+
+ layout.addLayout(form)
+
+ # Sync-Button + Status
+ sync_layout = QHBoxLayout()
+ self.carddav_sync_btn = QPushButton("Jetzt synchronisieren")
+ self.carddav_sync_btn.clicked.connect(self._carddav_sync)
+ sync_layout.addWidget(self.carddav_sync_btn)
+ layout.addLayout(sync_layout)
+
+ self.carddav_status = QLabel("")
+ self.carddav_status.setStyleSheet("color: #888; font-size: 11px;")
+ layout.addWidget(self.carddav_status)
+
+ layout.addStretch()
+ self.tabs.addTab(widget, "CardDAV")
+
+ def _blf_tab_erstellen(self):
+ """Tab für BLF-Konfiguration."""
+ widget = QWidget()
+ layout = QVBoxLayout(widget)
+
+ hinweis = QLabel(
+ "BLF-Extensions (eine pro Zeile):\n"
+ "Diese Extensions werden auf Besetzt-Status überwacht."
+ )
+ layout.addWidget(hinweis)
+
+ from PySide6.QtWidgets import QTextEdit
+ self.blf_eingabe = QTextEdit()
+ self.blf_eingabe.setPlaceholderText("200\n201\n202")
+ layout.addWidget(self.blf_eingabe)
+
+ self.tabs.addTab(widget, "BLF")
+
+ def _werte_laden(self):
+ """Gespeicherte Werte in die Felder laden."""
+ sip = self._config.sip
+ self.sip_server.setText(sip.get("server", ""))
+ self.sip_port.setValue(sip.get("port", 5060))
+ self.sip_extension.setText(sip.get("extension", ""))
+ self.sip_passwort.setText(sip.get("passwort", ""))
+
+ transport = sip.get("transport", "udp").upper()
+ idx = self.sip_transport.findText(transport)
+ if idx >= 0:
+ self.sip_transport.setCurrentIndex(idx)
+
+ allg = self._config.allgemein
+ self.tray_check.setChecked(allg.get("minimieren_in_tray", True))
+ self.autostart_check.setChecked(allg.get("autostart", False))
+ self.klingelton_eingabe.setText(allg.get("klingelton", ""))
+
+ # CardDAV-Accounts laden
+ self._carddav_accounts = list(
+ self._config.get("carddav", "accounts") or [])
+ self._carddav_account_liste_aktualisieren()
+
+ cdav = self._config.carddav
+ self.carddav_auto_sync.setChecked(cdav.get("auto_sync", True))
+ self.carddav_intervall.setValue(cdav.get("sync_intervall", 3600))
+
+ blf = self._config.blf
+ extensions = blf.get("extensions", [])
+ self.blf_eingabe.setPlainText("\n".join(extensions))
+
+ self._audiogeraete_aktualisieren()
+
+ def _carddav_account_liste_aktualisieren(self):
+ """Account-Liste im UI aktualisieren."""
+ self.carddav_account_liste.clear()
+ for acc in self._carddav_accounts:
+ name = acc.get("name", "Unbenannt")
+ url = acc.get("url", "")
+ # Kurze URL-Anzeige
+ url_kurz = url
+ if len(url_kurz) > 50:
+ url_kurz = url_kurz[:47] + "..."
+ text = f"{name} - {url_kurz}"
+ item = QListWidgetItem(text)
+ self.carddav_account_liste.addItem(item)
+
+ def _carddav_account_hinzufuegen(self):
+ """Neuen CardDAV-Account hinzufügen."""
+ dialog = CardDavAccountDialog(parent=self)
+ if dialog.exec() == QDialog.DialogCode.Accepted:
+ acc = dialog.account_daten
+ if acc.get("url") and acc.get("benutzername"):
+ self._carddav_accounts.append(acc)
+ self._carddav_account_liste_aktualisieren()
+
+ def _carddav_account_bearbeiten(self):
+ """Ausgewählten Account bearbeiten."""
+ zeile = self.carddav_account_liste.currentRow()
+ if zeile < 0 or zeile >= len(self._carddav_accounts):
+ return
+ dialog = CardDavAccountDialog(
+ account=self._carddav_accounts[zeile], parent=self)
+ if dialog.exec() == QDialog.DialogCode.Accepted:
+ self._carddav_accounts[zeile] = dialog.account_daten
+ self._carddav_account_liste_aktualisieren()
+
+ def _carddav_account_loeschen(self):
+ """Ausgewählten Account entfernen."""
+ zeile = self.carddav_account_liste.currentRow()
+ if zeile < 0 or zeile >= len(self._carddav_accounts):
+ return
+ del self._carddav_accounts[zeile]
+ self._carddav_account_liste_aktualisieren()
+
+ def _audiogeraete_aktualisieren(self):
+ """Audiogeräte-Dropdowns mit PipeWire-Geräten aktualisieren."""
+ self.aufnahme_combo.clear()
+ self.wiedergabe_combo.clear()
+ self.klingelton_geraet_combo.clear()
+
+ self.aufnahme_combo.addItem("Standard (KDE-Einstellung)", "")
+ self.wiedergabe_combo.addItem("Standard (KDE-Einstellung)", "")
+ self.klingelton_geraet_combo.addItem("Standard (KDE-Einstellung)", "")
+
+ if not self._audio:
+ return
+
+ for geraet in self._audio.aufnahme_geraete():
+ self.aufnahme_combo.addItem(
+ geraet["name"], geraet["pw_name"])
+
+ for geraet in self._audio.wiedergabe_geraete():
+ self.wiedergabe_combo.addItem(
+ geraet["name"], geraet["pw_name"])
+ self.klingelton_geraet_combo.addItem(
+ geraet["name"], geraet["pw_name"])
+
+ audio_cfg = self._config.audio
+ self._combo_auswahl_setzen(
+ self.aufnahme_combo, audio_cfg.get("aufnahme_geraet", ""))
+ self._combo_auswahl_setzen(
+ self.wiedergabe_combo, audio_cfg.get("wiedergabe_geraet", ""))
+ self._combo_auswahl_setzen(
+ self.klingelton_geraet_combo,
+ audio_cfg.get("klingelton_geraet", ""))
+
+ def _combo_auswahl_setzen(self, combo, wert):
+ """ComboBox auf den Eintrag mit passendem Data-Wert setzen."""
+ for i in range(combo.count()):
+ if combo.itemData(i) == wert:
+ combo.setCurrentIndex(i)
+ return
+
+ def _klingelton_waehlen(self):
+ """Dateidialog für Klingelton-Auswahl."""
+ datei, _ = QFileDialog.getOpenFileName(
+ self, "Klingelton wählen", "",
+ "Audio-Dateien (*.wav *.mp3 *.ogg *.oga);;Alle Dateien (*)"
+ )
+ if datei:
+ self.klingelton_eingabe.setText(datei)
+
+ def _klingelton_testen(self):
+ """Klingelton einmal zum Testen abspielen."""
+ if not self._klingelton:
+ return
+ device = self.klingelton_geraet_combo.currentData() or ""
+ datei = self.klingelton_eingabe.text()
+ self._klingelton.test_abspielen(datei, device)
+
+ def _carddav_sync(self):
+ """CardDAV-Kontakte von allen Accounts synchronisieren."""
+ if not self._carddav_accounts:
+ self.carddav_status.setText("Keine Accounts konfiguriert")
+ self.carddav_status.setStyleSheet(
+ "color: orange; font-size: 11px;")
+ return
+
+ self.carddav_status.setText("Synchronisiere...")
+ self.carddav_status.setStyleSheet("color: #888; font-size: 11px;")
+ self.carddav_sync_btn.setEnabled(False)
+
+ from utils.carddav import CardDavSync
+ sync = CardDavSync()
+ kontakte, fehler = sync.alle_accounts_abrufen(
+ self._carddav_accounts)
+
+ self.carddav_sync_btn.setEnabled(True)
+
+ if fehler and not kontakte:
+ self.carddav_status.setText(
+ f"Fehler: {'; '.join(fehler)}")
+ self.carddav_status.setStyleSheet(
+ "color: red; font-size: 11px;")
+ else:
+ text = f"{len(kontakte)} Kontakte synchronisiert"
+ if fehler:
+ text += f" (Warnungen: {'; '.join(fehler)})"
+ self.carddav_status.setText(text)
+ self.carddav_status.setStyleSheet(
+ "color: green; font-size: 11px;")
+ self._config.set("carddav", "kontakte_cache", kontakte)
+
+ def _speichern(self):
+ """Einstellungen speichern und Dialog schließen."""
+ # SIP
+ self._config.set("sip", "server", self.sip_server.text().strip())
+ self._config.set("sip", "port", self.sip_port.value())
+ self._config.set("sip", "extension",
+ self.sip_extension.text().strip())
+ self._config.set("sip", "passwort", self.sip_passwort.text())
+ self._config.set("sip", "transport",
+ self.sip_transport.currentText().lower())
+
+ # Audio
+ aufnahme = self.aufnahme_combo.currentData() or ""
+ wiedergabe = self.wiedergabe_combo.currentData() or ""
+ klingelton_geraet = (
+ self.klingelton_geraet_combo.currentData() or "")
+ self._config.set("audio", "aufnahme_geraet", aufnahme)
+ self._config.set("audio", "wiedergabe_geraet", wiedergabe)
+ self._config.set("audio", "klingelton_geraet", klingelton_geraet)
+
+ # Allgemein
+ self._config.set("allgemein", "minimieren_in_tray",
+ self.tray_check.isChecked())
+ self._config.set("allgemein", "autostart",
+ self.autostart_check.isChecked())
+ self._config.set("allgemein", "klingelton",
+ self.klingelton_eingabe.text())
+
+ # CardDAV (Multi-Account)
+ self._config.set("carddav", "accounts", self._carddav_accounts)
+ self._config.set("carddav", "auto_sync",
+ self.carddav_auto_sync.isChecked())
+ self._config.set("carddav", "sync_intervall",
+ self.carddav_intervall.value())
+
+ # BLF
+ blf_text = self.blf_eingabe.toPlainText().strip()
+ extensions = [e.strip() for e in blf_text.split("\n") if e.strip()]
+ self._config.set("blf", "extensions", extensions)
+
+ self._config.speichern()
+ self.einstellungen_geaendert.emit()
+ self.accept()
diff --git a/ui/favoriten_panel.py b/ui/favoriten_panel.py
new file mode 100644
index 0000000..3d446ff
--- /dev/null
+++ b/ui/favoriten_panel.py
@@ -0,0 +1,188 @@
+"""Favoriten-Panel - Manuelle Favoriten + häufig angerufene Nummern."""
+
+from collections import Counter
+
+from PySide6.QtWidgets import (
+ QWidget, QVBoxLayout, QLabel, QPushButton, QScrollArea,
+ QSizePolicy,
+)
+from PySide6.QtCore import Signal, Qt
+from PySide6.QtGui import QFont
+
+
+class FavoritenPanel(QWidget):
+ """Kompaktes Panel mit Favoriten und häufig angerufenen Nummern."""
+
+ # Signal: Nummer zum Anrufen
+ anrufen = Signal(str)
+ # Signal: Favoriten-Liste hat sich geändert
+ favoriten_geaendert = Signal()
+
+ def __init__(self, config_manager, parent=None):
+ super().__init__(parent)
+ self._config = config_manager
+ self._kontakte_liste = [] # Für Namensauflösung
+ self._ui_aufbauen()
+
+ def _ui_aufbauen(self):
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(2)
+
+ # Überschrift
+ titel = QLabel("Favoriten")
+ titel_font = QFont()
+ titel_font.setPointSize(11)
+ titel_font.setBold(True)
+ titel.setFont(titel_font)
+ titel.setStyleSheet("padding: 4px;")
+ layout.addWidget(titel)
+
+ # Scrollbarer Bereich für die Buttons
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setFrameShape(QScrollArea.Shape.NoFrame)
+ scroll.setHorizontalScrollBarPolicy(
+ Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+
+ self._inhalt = QWidget()
+ self._inhalt_layout = QVBoxLayout(self._inhalt)
+ self._inhalt_layout.setContentsMargins(0, 0, 0, 0)
+ self._inhalt_layout.setSpacing(2)
+ self._inhalt_layout.addStretch()
+
+ scroll.setWidget(self._inhalt)
+ layout.addWidget(scroll)
+
+ def aktualisieren(self):
+ """Favoriten-Anzeige komplett neu aufbauen."""
+ # Alte Buttons entfernen
+ while self._inhalt_layout.count() > 1: # Stretch behalten
+ item = self._inhalt_layout.takeAt(0)
+ if item.widget():
+ item.widget().deleteLater()
+
+ max_anzeige = (
+ self._config.get("favoriten", "max_anzeige") or 10)
+
+ # Manuelle Favoriten
+ manuell = self._config.get("favoriten", "manuell") or []
+ for fav in manuell[:max_anzeige]:
+ name = fav.get("name", "")
+ nummer = fav.get("nummer", "")
+ if nummer:
+ self._button_hinzufuegen(name, nummer, manuell=True)
+
+ # Häufig angerufen (Rest auffüllen)
+ rest = max_anzeige - len(manuell)
+ if rest > 0:
+ haeufige = self._haeufige_nummern(rest, manuell)
+ if haeufige and manuell:
+ # Trennlinie
+ trenn = QLabel("Häufig angerufen")
+ trenn.setStyleSheet(
+ "color: #888; font-size: 10px; padding: 6px 4px 2px;")
+ self._inhalt_layout.insertWidget(
+ self._inhalt_layout.count() - 1, trenn)
+ for nummer, anzahl in haeufige:
+ name = self._name_fuer_nummer(nummer)
+ self._button_hinzufuegen(
+ name or nummer, nummer, manuell=False)
+
+ def _button_hinzufuegen(self, name, nummer, manuell=False):
+ """Einen Favoriten-Button ins Layout einfügen."""
+ if name and name != nummer:
+ text = f"{name}\n{nummer}"
+ else:
+ text = nummer
+
+ farbe = "#FFD700" if manuell else "#ddd"
+ hover_bg = "rgba(255,215,0,0.12)" if manuell else "rgba(76,175,80,0.12)"
+
+ btn = QPushButton(text)
+ btn.setStyleSheet(
+ f"QPushButton {{ "
+ f" text-align: left; padding: 8px 10px; "
+ f" font-size: 12px; border: 1px solid #555; "
+ f" border-radius: 6px; background: rgba(255,255,255,0.04); "
+ f" color: {farbe}; "
+ f"}}"
+ f"QPushButton:hover {{ "
+ f" background: {hover_bg}; border-color: {farbe}; "
+ f"}}"
+ f"QPushButton:pressed {{ "
+ f" background: rgba(255,255,255,0.1); "
+ f"}}"
+ )
+ btn.setCursor(Qt.CursorShape.PointingHandCursor)
+ btn.setSizePolicy(
+ QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
+ btn.setMinimumHeight(40 if "\n" in text else 34)
+ btn.setToolTip(f"Anrufen: {nummer}")
+
+ btn.clicked.connect(
+ lambda checked, n=nummer: self.anrufen.emit(n))
+
+ # Vor dem Stretch einfügen
+ self._inhalt_layout.insertWidget(
+ self._inhalt_layout.count() - 1, btn)
+
+ def _haeufige_nummern(self, limit, manuell_favoriten):
+ """Top-N häufig angerufene Nummern aus der Anrufliste berechnen."""
+ anrufe = self._config.anrufliste_laden()
+ if not anrufe:
+ return []
+
+ # Manuelle Favoriten-Nummern (zum Ausschließen)
+ manuell_nummern = {
+ f.get("nummer", "") for f in manuell_favoriten}
+
+ # Zählen
+ zaehler = Counter()
+ for anruf in anrufe:
+ nummer = anruf.get("nummer", "")
+ if nummer and nummer not in manuell_nummern:
+ zaehler[nummer] += 1
+
+ return zaehler.most_common(limit)
+
+ def _name_fuer_nummer(self, nummer):
+ """Name für eine Nummer in der Kontaktliste suchen."""
+ for kontakt in self._kontakte_liste:
+ nummern = kontakt.get("nummern", {})
+ if nummer in nummern.values():
+ return kontakt.get("name", "")
+ if kontakt.get("nummer") == nummer:
+ return kontakt.get("name", "")
+ return ""
+
+ def kontakte_setzen(self, kontakte):
+ """Kontaktliste für die Namensauflösung setzen."""
+ self._kontakte_liste = kontakte or []
+
+ def favorit_hinzufuegen(self, name, nummer):
+ """Kontakt als manuellen Favoriten hinzufügen."""
+ manuell = self._config.get("favoriten", "manuell") or []
+ # Duplikat prüfen
+ for fav in manuell:
+ if fav.get("nummer") == nummer:
+ return # Schon vorhanden
+ manuell.append({"name": name, "nummer": nummer})
+ self._config.set("favoriten", "manuell", manuell)
+ self._config.speichern()
+ self.aktualisieren()
+ self.favoriten_geaendert.emit()
+
+ def favorit_entfernen(self, nummer):
+ """Kontakt aus manuellen Favoriten entfernen."""
+ manuell = self._config.get("favoriten", "manuell") or []
+ manuell = [f for f in manuell if f.get("nummer") != nummer]
+ self._config.set("favoriten", "manuell", manuell)
+ self._config.speichern()
+ self.aktualisieren()
+ self.favoriten_geaendert.emit()
+
+ def ist_favorit(self, nummer):
+ """Prüft ob eine Nummer als manueller Favorit gespeichert ist."""
+ manuell = self._config.get("favoriten", "manuell") or []
+ return any(f.get("nummer") == nummer for f in manuell)
diff --git a/ui/hauptfenster.py b/ui/hauptfenster.py
new file mode 100644
index 0000000..46c42f1
--- /dev/null
+++ b/ui/hauptfenster.py
@@ -0,0 +1,767 @@
+"""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()
diff --git a/ui/kontakte.py b/ui/kontakte.py
new file mode 100644
index 0000000..e08a597
--- /dev/null
+++ b/ui/kontakte.py
@@ -0,0 +1,863 @@
+"""Kontakte-Widget - Tabelle mit lokalen + CardDAV-Kontakten und Detail-Dialog."""
+
+from PySide6.QtWidgets import (
+ QWidget, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem,
+ QPushButton, QDialog, QFormLayout, QLineEdit, QLabel, QHeaderView,
+ QAbstractItemView, QGroupBox, QGridLayout, QScrollArea,
+ QPlainTextEdit, QMessageBox,
+)
+from PySide6.QtCore import Signal, Qt
+from PySide6.QtGui import QColor, QFont
+
+
+class KontaktDetailDialog(QDialog):
+ """Detail-Dialog - zeigt alle Kontaktdaten, Nummern anklickbar."""
+
+ # Signal: Nummer zum Anrufen
+ anrufen = Signal(str)
+ # Signal: Favorit hinzufügen/entfernen (name, nummer, hinzufuegen)
+ favorit_toggle = Signal(str, str, bool)
+
+ def __init__(self, kontakt, parent=None):
+ super().__init__(parent)
+ self._kontakt = kontakt
+ self._bearbeiten_gewuenscht = False
+ self.setWindowTitle(kontakt.get("name", "Kontakt"))
+ self.setMinimumWidth(420)
+ self.setMinimumHeight(350)
+ self.setModal(True)
+ self._ui_aufbauen()
+
+ def _ui_aufbauen(self):
+ dialog_layout = QVBoxLayout(self)
+
+ # Scrollbereich für viele Daten
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setFrameShape(QScrollArea.Shape.NoFrame)
+ inhalt = QWidget()
+ layout = QVBoxLayout(inhalt)
+
+ # === Name ===
+ name_label = QLabel(self._kontakt.get("name", ""))
+ name_font = QFont()
+ name_font.setPointSize(16)
+ name_font.setBold(True)
+ name_label.setFont(name_font)
+ name_label.setTextInteractionFlags(
+ Qt.TextInteractionFlag.TextSelectableByMouse)
+ layout.addWidget(name_label)
+
+ # Firma + Titel
+ firma = self._kontakt.get("firma", "")
+ titel = self._kontakt.get("titel", "")
+ if firma and titel:
+ layout.addWidget(self._info_label(f"{titel} bei {firma}"))
+ elif firma:
+ layout.addWidget(self._info_label(firma))
+ elif titel:
+ layout.addWidget(self._info_label(titel))
+
+ # Account-Info
+ quelle = self._kontakt.get("quelle", "lokal")
+ account = self._kontakt.get("account", "")
+ if quelle == "carddav" and account:
+ layout.addWidget(self._info_label(
+ f"CardDAV: {account}", "#999", 11))
+
+ layout.addSpacing(8)
+
+ # === Telefonnummern (klickbar) ===
+ nummern = self._kontakt.get("nummern", {})
+ if not nummern and self._kontakt.get("nummer"):
+ nummern = {"telefon": self._kontakt["nummer"]}
+
+ if nummern:
+ nummern_box = QGroupBox("Telefonnummern")
+ nummern_grid = QGridLayout(nummern_box)
+ typ_labels = {
+ "telefon": "Telefon:",
+ "handy": "Handy:",
+ "geschaeftlich": "Geschäftlich:",
+ "sonstige": "Sonstige:",
+ }
+ zeile = 0
+ for typ, label_text in typ_labels.items():
+ nummer = nummern.get(typ, "")
+ if not nummer:
+ continue
+ label = QLabel(label_text)
+ label.setStyleSheet("font-weight: bold;")
+ nummern_grid.addWidget(label, zeile, 0)
+
+ nummer_btn = QPushButton(f" {nummer} ")
+ nummer_btn.setStyleSheet(
+ "text-align: left; font-size: 14px; "
+ "padding: 6px 12px; "
+ "color: #4CAF50; border: 1px solid #4CAF50; "
+ "border-radius: 4px; background: transparent;"
+ )
+ nummer_btn.setCursor(Qt.CursorShape.PointingHandCursor)
+ nummer_btn.setToolTip(f"Anrufen: {nummer}")
+ nummer_btn.clicked.connect(
+ lambda checked, n=nummer: self._nummer_anrufen(n)
+ )
+ nummern_grid.addWidget(nummer_btn, zeile, 1)
+ zeile += 1
+ layout.addWidget(nummern_box)
+
+ # === Details (E-Mail, Adresse, Geburtstag, etc.) ===
+ details_form = QFormLayout()
+ details_form.setSpacing(6)
+ details_vorhanden = False
+
+ # E-Mails
+ emails = self._kontakt.get("emails", [])
+ if not emails and self._kontakt.get("email"):
+ emails = [self._kontakt["email"]]
+ for em in emails:
+ details_form.addRow(
+ self._bold_label("E-Mail:"),
+ self._selectable_label(em, "#5599DD"))
+ details_vorhanden = True
+
+ # Adresse
+ adresse = self._kontakt.get("adresse", "")
+ if adresse:
+ adr_label = self._selectable_label(adresse)
+ adr_label.setWordWrap(True)
+ details_form.addRow(self._bold_label("Adresse:"), adr_label)
+ details_vorhanden = True
+
+ # Geburtstag
+ geburtstag = self._kontakt.get("geburtstag", "")
+ if geburtstag:
+ details_form.addRow(
+ self._bold_label("Geburtstag:"),
+ self._selectable_label(geburtstag))
+ details_vorhanden = True
+
+ # Website
+ url = self._kontakt.get("url", "")
+ if url:
+ details_form.addRow(
+ self._bold_label("Website:"),
+ self._selectable_label(url, "#5599DD"))
+ details_vorhanden = True
+
+ # Notiz
+ notiz = self._kontakt.get("notiz", "")
+ if notiz:
+ notiz_label = self._selectable_label(notiz)
+ notiz_label.setWordWrap(True)
+ details_form.addRow(self._bold_label("Notiz:"), notiz_label)
+ details_vorhanden = True
+
+ if details_vorhanden:
+ details_box = QGroupBox("Details")
+ details_box.setLayout(details_form)
+ layout.addWidget(details_box)
+
+ layout.addStretch()
+ scroll.setWidget(inhalt)
+ dialog_layout.addWidget(scroll)
+
+ # Buttons: Bearbeiten + Favorit + Schließen
+ btn_layout = QHBoxLayout()
+
+ bearbeiten_btn = QPushButton("Bearbeiten")
+ bearbeiten_btn.clicked.connect(self._bearbeiten_klick)
+ btn_layout.addWidget(bearbeiten_btn)
+
+ # Favorit-Toggle (nur wenn Nummer vorhanden)
+ self._haupt_nummer = self._haupt_nummer_bestimmen()
+ if self._haupt_nummer:
+ self.favorit_btn = QPushButton()
+ self.favorit_btn.setCheckable(True)
+ self._favorit_btn_aktualisieren(False)
+ self.favorit_btn.clicked.connect(self._favorit_toggle_klick)
+ btn_layout.addWidget(self.favorit_btn)
+
+ btn_layout.addStretch()
+
+ schliessen_btn = QPushButton("Schließen")
+ schliessen_btn.clicked.connect(self.accept)
+ btn_layout.addWidget(schliessen_btn)
+
+ dialog_layout.addLayout(btn_layout)
+
+ def _info_label(self, text, farbe="#888", groesse=12):
+ """Info-Label mit Farbe und Größe."""
+ label = QLabel(text)
+ label.setStyleSheet(f"color: {farbe}; font-size: {groesse}px;")
+ label.setTextInteractionFlags(
+ Qt.TextInteractionFlag.TextSelectableByMouse)
+ return label
+
+ def _bold_label(self, text):
+ """Fettgedrucktes Label."""
+ label = QLabel(text)
+ label.setStyleSheet("font-weight: bold;")
+ return label
+
+ def _selectable_label(self, text, farbe=None):
+ """Selektierbares/kopierbares Label."""
+ label = QLabel(text)
+ label.setTextInteractionFlags(
+ Qt.TextInteractionFlag.TextSelectableByMouse)
+ if farbe:
+ label.setStyleSheet(f"color: {farbe};")
+ return label
+
+ def _haupt_nummer_bestimmen(self):
+ """Erste verfügbare Nummer des Kontakts bestimmen."""
+ nummern = self._kontakt.get("nummern", {})
+ if not nummern and self._kontakt.get("nummer"):
+ return self._kontakt["nummer"]
+ for typ in ("handy", "telefon", "geschaeftlich", "sonstige"):
+ if typ in nummern:
+ return nummern[typ]
+ return ""
+
+ def favorit_status_setzen(self, ist_favorit):
+ """Favorit-Button von außen aktualisieren."""
+ if hasattr(self, "favorit_btn"):
+ self.favorit_btn.setChecked(ist_favorit)
+ self._favorit_btn_aktualisieren(ist_favorit)
+
+ def _favorit_btn_aktualisieren(self, aktiv):
+ """Favorit-Button Aussehen je nach Status."""
+ if aktiv:
+ self.favorit_btn.setText("Favorit entfernen")
+ self.favorit_btn.setStyleSheet(
+ "color: #FFD700; font-weight: bold;")
+ else:
+ self.favorit_btn.setText("Als Favorit")
+ self.favorit_btn.setStyleSheet("")
+
+ def _favorit_toggle_klick(self):
+ """Favorit-Button geklickt."""
+ ist_aktiv = self.favorit_btn.isChecked()
+ self._favorit_btn_aktualisieren(ist_aktiv)
+ name = self._kontakt.get("name", "")
+ self.favorit_toggle.emit(name, self._haupt_nummer, ist_aktiv)
+
+ def _bearbeiten_klick(self):
+ """Bearbeiten-Button geklickt → Flag setzen und schließen."""
+ self._bearbeiten_gewuenscht = True
+ self.accept()
+
+ @property
+ def bearbeiten_gewuenscht(self):
+ """True wenn der User 'Bearbeiten' geklickt hat."""
+ return self._bearbeiten_gewuenscht
+
+ def _nummer_anrufen(self, nummer):
+ """Nummer-Button geklickt → anrufen und Dialog schließen."""
+ self.anrufen.emit(nummer)
+ self.accept()
+
+
+class KontaktBearbeitenDialog(QDialog):
+ """Dialog zum Hinzufügen/Bearbeiten eines Kontakts (lokal + CardDAV)."""
+
+ def __init__(self, kontakt=None, parent=None):
+ super().__init__(parent)
+ self._kontakt = kontakt or {}
+ self.setWindowTitle(
+ "Kontakt bearbeiten" if kontakt else "Neuer Kontakt"
+ )
+ self.setMinimumWidth(420)
+ self.setMinimumHeight(500)
+ self.setModal(True)
+ self._ui_aufbauen()
+
+ def _ui_aufbauen(self):
+ dialog_layout = QVBoxLayout(self)
+
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setFrameShape(QScrollArea.Shape.NoFrame)
+ inhalt = QWidget()
+ layout = QVBoxLayout(inhalt)
+
+ # === Grunddaten ===
+ grund_form = QFormLayout()
+ grund_form.setSpacing(6)
+
+ self.name_eingabe = QLineEdit(self._kontakt.get("name", ""))
+ self.name_eingabe.setPlaceholderText("Vor- und Nachname")
+ grund_form.addRow("Name:", self.name_eingabe)
+
+ self.firma_eingabe = QLineEdit(self._kontakt.get("firma", ""))
+ self.firma_eingabe.setPlaceholderText("Firmenname")
+ grund_form.addRow("Firma:", self.firma_eingabe)
+
+ self.titel_eingabe = QLineEdit(self._kontakt.get("titel", ""))
+ self.titel_eingabe.setPlaceholderText("Berufsbezeichnung")
+ grund_form.addRow("Titel:", self.titel_eingabe)
+
+ grund_box = QGroupBox("Grunddaten")
+ grund_box.setLayout(grund_form)
+ layout.addWidget(grund_box)
+
+ # === Telefonnummern ===
+ nummern = self._kontakt.get("nummern", {})
+ if not nummern and self._kontakt.get("nummer"):
+ nummern = {"telefon": self._kontakt["nummer"]}
+
+ tel_form = QFormLayout()
+ tel_form.setSpacing(6)
+
+ self.telefon_eingabe = QLineEdit(nummern.get("telefon", ""))
+ self.telefon_eingabe.setPlaceholderText("Festnetz")
+ tel_form.addRow("Telefon:", self.telefon_eingabe)
+
+ self.handy_eingabe = QLineEdit(nummern.get("handy", ""))
+ self.handy_eingabe.setPlaceholderText("Mobilnummer")
+ tel_form.addRow("Handy:", self.handy_eingabe)
+
+ self.geschaeftlich_eingabe = QLineEdit(
+ nummern.get("geschaeftlich", ""))
+ self.geschaeftlich_eingabe.setPlaceholderText("Geschäftlich")
+ tel_form.addRow("Geschäftlich:", self.geschaeftlich_eingabe)
+
+ self.sonstige_eingabe = QLineEdit(nummern.get("sonstige", ""))
+ self.sonstige_eingabe.setPlaceholderText("Weitere Nummer")
+ tel_form.addRow("Sonstige:", self.sonstige_eingabe)
+
+ tel_box = QGroupBox("Telefonnummern")
+ tel_box.setLayout(tel_form)
+ layout.addWidget(tel_box)
+
+ # === Kontaktdaten ===
+ kontakt_form = QFormLayout()
+ kontakt_form.setSpacing(6)
+
+ # E-Mail (erste aus der Liste oder Einzelwert)
+ emails = self._kontakt.get("emails", [])
+ email_wert = emails[0] if emails else self._kontakt.get("email", "")
+ self.email_eingabe = QLineEdit(email_wert)
+ self.email_eingabe.setPlaceholderText("name@beispiel.de")
+ kontakt_form.addRow("E-Mail:", self.email_eingabe)
+
+ self.url_eingabe = QLineEdit(self._kontakt.get("url", ""))
+ self.url_eingabe.setPlaceholderText("https://...")
+ kontakt_form.addRow("Website:", self.url_eingabe)
+
+ self.geburtstag_eingabe = QLineEdit(
+ self._kontakt.get("geburtstag", ""))
+ self.geburtstag_eingabe.setPlaceholderText("TT.MM.JJJJ")
+ kontakt_form.addRow("Geburtstag:", self.geburtstag_eingabe)
+
+ kontakt_box = QGroupBox("Kontaktdaten")
+ kontakt_box.setLayout(kontakt_form)
+ layout.addWidget(kontakt_box)
+
+ # === Adresse + Notiz (mehrzeilig) ===
+ extra_form = QFormLayout()
+ extra_form.setSpacing(6)
+
+ self.adresse_eingabe = QPlainTextEdit(
+ self._kontakt.get("adresse", ""))
+ self.adresse_eingabe.setPlaceholderText(
+ "Straße, PLZ Ort, Land")
+ self.adresse_eingabe.setMaximumHeight(80)
+ extra_form.addRow("Adresse:", self.adresse_eingabe)
+
+ self.notiz_eingabe = QPlainTextEdit(
+ self._kontakt.get("notiz", ""))
+ self.notiz_eingabe.setPlaceholderText("Freitext-Notizen...")
+ self.notiz_eingabe.setMaximumHeight(80)
+ extra_form.addRow("Notiz:", self.notiz_eingabe)
+
+ extra_box = QGroupBox("Sonstiges")
+ extra_box.setLayout(extra_form)
+ layout.addWidget(extra_box)
+
+ layout.addStretch()
+ scroll.setWidget(inhalt)
+ dialog_layout.addWidget(scroll)
+
+ # Buttons
+ btn_layout = QHBoxLayout()
+ ok_btn = QPushButton("Speichern")
+ ok_btn.setDefault(True)
+ ok_btn.clicked.connect(self.accept)
+ abbrechen_btn = QPushButton("Abbrechen")
+ abbrechen_btn.clicked.connect(self.reject)
+ btn_layout.addStretch()
+ btn_layout.addWidget(abbrechen_btn)
+ btn_layout.addWidget(ok_btn)
+ dialog_layout.addLayout(btn_layout)
+
+ @property
+ def kontakt_daten(self):
+ """Kontakt-Dict aus den Eingabefeldern erstellen."""
+ name = self.name_eingabe.text().strip()
+ if not name:
+ return None
+
+ nummern = {}
+ if self.telefon_eingabe.text().strip():
+ nummern["telefon"] = self.telefon_eingabe.text().strip()
+ if self.handy_eingabe.text().strip():
+ nummern["handy"] = self.handy_eingabe.text().strip()
+ if self.geschaeftlich_eingabe.text().strip():
+ nummern["geschaeftlich"] = (
+ self.geschaeftlich_eingabe.text().strip())
+ if self.sonstige_eingabe.text().strip():
+ nummern["sonstige"] = self.sonstige_eingabe.text().strip()
+
+ # Mindestens Name muss vorhanden sein
+ # (Nummer nicht zwingend, z.B. bei CardDAV nur Email)
+ ergebnis = {
+ "name": name,
+ "nummern": nummern,
+ "firma": self.firma_eingabe.text().strip(),
+ "titel": self.titel_eingabe.text().strip(),
+ "email": self.email_eingabe.text().strip(),
+ "emails": ([self.email_eingabe.text().strip()]
+ if self.email_eingabe.text().strip() else []),
+ "adresse": self.adresse_eingabe.toPlainText().strip(),
+ "geburtstag": self.geburtstag_eingabe.text().strip(),
+ "url": self.url_eingabe.text().strip(),
+ "notiz": self.notiz_eingabe.toPlainText().strip(),
+ }
+
+ # Metadaten vom Original übernehmen (CardDAV-Referenz)
+ for key in ("_href", "_etag", "quelle", "account"):
+ if key in self._kontakt:
+ ergebnis[key] = self._kontakt[key]
+
+ return ergebnis
+
+
+class KontakteWidget(QWidget):
+ """Kontaktliste als Tabelle mit Spalten (Name, Telefon, Handy, Geschäftlich)."""
+
+ # Signal: Nummer zum Anrufen
+ anrufen = Signal(str)
+ # Signal: Kontaktliste hat sich geändert (für Wählfeld-Suche)
+ kontakte_geaendert = Signal()
+ # Signal: Favorit hinzufügen/entfernen (name, nummer, hinzufuegen)
+ favorit_toggle = Signal(str, str, bool)
+
+ # Spalten-Index
+ SPALTE_NAME = 0
+ SPALTE_TELEFON = 1
+ SPALTE_HANDY = 2
+ SPALTE_GESCHAEFTLICH = 3
+
+ def __init__(self, config_manager, parent=None):
+ super().__init__(parent)
+ self._config = config_manager
+ self._kontakte = [] # Lokale Kontakte
+ self._carddav_kontakte = [] # CardDAV-Kontakte (read-only)
+ self._alle_kontakte = [] # Zusammengeführte, sortierte Liste
+ self._ui_aufbauen()
+ self._kontakte_laden()
+
+ def _ui_aufbauen(self):
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(6)
+
+ # Suchfeld
+ self._suchfeld = QLineEdit()
+ self._suchfeld.setPlaceholderText("Kontakte durchsuchen...")
+ self._suchfeld.setClearButtonEnabled(True)
+ self._suchfeld.setStyleSheet(
+ "QLineEdit { "
+ " border: 1px solid #555; border-radius: 6px; "
+ " padding: 6px 10px; "
+ " background: rgba(255,255,255,0.05); "
+ "}"
+ "QLineEdit:focus { border-color: #5599DD; }"
+ )
+ self._suchfeld.textChanged.connect(self._suche_filtern)
+ layout.addWidget(self._suchfeld)
+
+ # Tabelle
+ self.tabelle = QTableWidget()
+ self.tabelle.setColumnCount(4)
+ self.tabelle.setHorizontalHeaderLabels(
+ ["Name", "Telefon", "Handy", "Geschäftlich"]
+ )
+ self.tabelle.setSelectionBehavior(
+ QAbstractItemView.SelectionBehavior.SelectRows
+ )
+ self.tabelle.setSelectionMode(
+ QAbstractItemView.SelectionMode.SingleSelection
+ )
+ self.tabelle.setEditTriggers(
+ QAbstractItemView.EditTrigger.NoEditTriggers
+ )
+ self.tabelle.setAlternatingRowColors(True)
+ self.tabelle.verticalHeader().setVisible(False)
+ self.tabelle.setSortingEnabled(True)
+ self.tabelle.setStyleSheet(
+ "QTableWidget { "
+ " border: 1px solid #444; border-radius: 4px; "
+ " gridline-color: #3a3a3a; "
+ "}"
+ "QTableWidget::item { padding: 4px 6px; }"
+ "QTableWidget::item:selected { "
+ " background-color: rgba(85,153,221,0.3); "
+ "}"
+ "QHeaderView::section { "
+ " background-color: rgba(255,255,255,0.06); "
+ " border: none; border-bottom: 2px solid #555; "
+ " padding: 6px 8px; font-weight: bold; "
+ "}"
+ )
+
+ # Spaltenbreiten
+ header = self.tabelle.horizontalHeader()
+ header.setSectionResizeMode(
+ self.SPALTE_NAME, QHeaderView.ResizeMode.Stretch
+ )
+ header.setSectionResizeMode(
+ self.SPALTE_TELEFON, QHeaderView.ResizeMode.ResizeToContents
+ )
+ header.setSectionResizeMode(
+ self.SPALTE_HANDY, QHeaderView.ResizeMode.ResizeToContents
+ )
+ header.setSectionResizeMode(
+ self.SPALTE_GESCHAEFTLICH, QHeaderView.ResizeMode.ResizeToContents
+ )
+
+ self.tabelle.doubleClicked.connect(self._doppelklick)
+ layout.addWidget(self.tabelle)
+
+ # Buttons
+ btn_layout = QHBoxLayout()
+ btn_layout.setSpacing(4)
+
+ btn_style = (
+ "QPushButton { "
+ " border: 1px solid #555; border-radius: 6px; "
+ " padding: 6px 12px; "
+ " background: rgba(255,255,255,0.06); "
+ "}"
+ "QPushButton:hover { "
+ " background: rgba(85,153,221,0.15); "
+ " border-color: #5599DD; "
+ "}"
+ "QPushButton:pressed { background: rgba(85,153,221,0.25); }"
+ )
+ gruen_style = (
+ "QPushButton { "
+ " border: 1px solid #4CAF50; border-radius: 6px; "
+ " padding: 6px 12px; color: #4CAF50; "
+ " background: rgba(76,175,80,0.08); "
+ "}"
+ "QPushButton:hover { background: rgba(76,175,80,0.2); }"
+ "QPushButton:pressed { background: rgba(76,175,80,0.3); }"
+ )
+
+ anrufen_btn = QPushButton("Anrufen")
+ anrufen_btn.setStyleSheet(gruen_style)
+ anrufen_btn.setCursor(Qt.CursorShape.PointingHandCursor)
+ anrufen_btn.clicked.connect(self._anrufen_klick)
+ btn_layout.addWidget(anrufen_btn)
+
+ details_btn = QPushButton("Details")
+ details_btn.setStyleSheet(btn_style)
+ details_btn.setCursor(Qt.CursorShape.PointingHandCursor)
+ details_btn.clicked.connect(self._details_zeigen)
+ btn_layout.addWidget(details_btn)
+
+ btn_layout.addStretch()
+
+ hinzufuegen_btn = QPushButton("Neu")
+ hinzufuegen_btn.setStyleSheet(btn_style)
+ hinzufuegen_btn.setCursor(Qt.CursorShape.PointingHandCursor)
+ hinzufuegen_btn.clicked.connect(self._kontakt_hinzufuegen)
+ btn_layout.addWidget(hinzufuegen_btn)
+
+ bearbeiten_btn = QPushButton("Bearbeiten")
+ bearbeiten_btn.setStyleSheet(btn_style)
+ bearbeiten_btn.setCursor(Qt.CursorShape.PointingHandCursor)
+ bearbeiten_btn.clicked.connect(self._kontakt_bearbeiten)
+ btn_layout.addWidget(bearbeiten_btn)
+
+ loeschen_btn = QPushButton("Löschen")
+ loeschen_btn.setStyleSheet(btn_style)
+ loeschen_btn.setCursor(Qt.CursorShape.PointingHandCursor)
+ loeschen_btn.clicked.connect(self._kontakt_loeschen)
+ btn_layout.addWidget(loeschen_btn)
+
+ sync_btn = QPushButton("Sync")
+ sync_btn.setToolTip("CardDAV-Kontakte synchronisieren")
+ sync_btn.setStyleSheet(btn_style)
+ sync_btn.setCursor(Qt.CursorShape.PointingHandCursor)
+ sync_btn.clicked.connect(self._carddav_sync)
+ btn_layout.addWidget(sync_btn)
+
+ layout.addLayout(btn_layout)
+
+ def _tabelle_fuellen(self):
+ """Tabelle mit allen Kontakten füllen."""
+ self.tabelle.setSortingEnabled(False)
+
+ # Alle Kontakte zusammenführen
+ self._alle_kontakte = []
+ for k in self._kontakte:
+ eintrag = {**k, "quelle": "lokal"}
+ # Abwärtskompatibel: einzelne "nummer" → nummern-Dict
+ if "nummer" in eintrag and "nummern" not in eintrag:
+ eintrag["nummern"] = {"telefon": eintrag["nummer"]}
+ self._alle_kontakte.append(eintrag)
+ for k in self._carddav_kontakte:
+ self._alle_kontakte.append({**k, "quelle": "carddav"})
+
+ self._alle_kontakte.sort(key=lambda k: k.get("name", "").lower())
+
+ self.tabelle.setRowCount(len(self._alle_kontakte))
+ carddav_farbe = QColor("#5599DD")
+
+ for zeile, kontakt in enumerate(self._alle_kontakte):
+ quelle = kontakt.get("quelle", "lokal")
+ nummern = kontakt.get("nummern", {})
+ account = kontakt.get("account", "")
+
+ # Name (mit Account-Info für CardDAV)
+ name = kontakt.get("name", "")
+ if quelle == "carddav" and account:
+ name_text = f"{name} [{account}]"
+ else:
+ name_text = name
+ name_item = QTableWidgetItem(name_text)
+ name_item.setData(Qt.ItemDataRole.UserRole, zeile) # Index
+
+ # Nummern-Spalten
+ telefon_item = QTableWidgetItem(nummern.get("telefon", ""))
+ handy_item = QTableWidgetItem(nummern.get("handy", ""))
+ geschaeft_item = QTableWidgetItem(
+ nummern.get("geschaeftlich", ""))
+
+ if quelle == "carddav":
+ for item in (name_item, telefon_item,
+ handy_item, geschaeft_item):
+ item.setForeground(carddav_farbe)
+
+ self.tabelle.setItem(zeile, self.SPALTE_NAME, name_item)
+ self.tabelle.setItem(zeile, self.SPALTE_TELEFON, telefon_item)
+ self.tabelle.setItem(zeile, self.SPALTE_HANDY, handy_item)
+ self.tabelle.setItem(
+ zeile, self.SPALTE_GESCHAEFTLICH, geschaeft_item)
+
+ self.tabelle.setSortingEnabled(True)
+
+ def _kontakt_am_index(self, zeile):
+ """Kontakt-Dict für eine Tabellenzeile holen."""
+ name_item = self.tabelle.item(zeile, self.SPALTE_NAME)
+ if not name_item:
+ return None
+ idx = name_item.data(Qt.ItemDataRole.UserRole)
+ if idx is not None and 0 <= idx < len(self._alle_kontakte):
+ return self._alle_kontakte[idx]
+ return None
+
+ def _aktuelle_zeile_kontakt(self):
+ """Kontakt der aktuell ausgewählten Zeile."""
+ zeile = self.tabelle.currentRow()
+ if zeile < 0:
+ return None
+ return self._kontakt_am_index(zeile)
+
+ def _doppelklick(self, index):
+ """Doppelklick → Detail-Dialog öffnen."""
+ kontakt = self._kontakt_am_index(index.row())
+ if kontakt:
+ self._detail_dialog_oeffnen(kontakt)
+
+ def _details_zeigen(self):
+ """Details-Button → Detail-Dialog."""
+ kontakt = self._aktuelle_zeile_kontakt()
+ if kontakt:
+ self._detail_dialog_oeffnen(kontakt)
+
+ def _detail_dialog_oeffnen(self, kontakt):
+ """Detail-Dialog mit allen Kontaktdaten und klickbaren Nummern."""
+ dialog = KontaktDetailDialog(kontakt, parent=self)
+ dialog.anrufen.connect(self.anrufen)
+ dialog.favorit_toggle.connect(
+ lambda n, nr, h: self.favorit_toggle.emit(n, nr, h))
+ dialog.exec()
+ if dialog.bearbeiten_gewuenscht:
+ self._kontakt_bearbeiten_dialog(kontakt)
+
+ def _anrufen_klick(self):
+ """Anrufen-Button → erste verfügbare Nummer anrufen."""
+ kontakt = self._aktuelle_zeile_kontakt()
+ if not kontakt:
+ return
+ nummern = kontakt.get("nummern", {})
+ if not nummern and kontakt.get("nummer"):
+ nummern = {"telefon": kontakt["nummer"]}
+ # Erste verfügbare Nummer (Priorität: handy > telefon > geschäftlich > sonstige)
+ for typ in ("handy", "telefon", "geschaeftlich", "sonstige"):
+ if typ in nummern:
+ self.anrufen.emit(nummern[typ])
+ return
+
+ def _kontakt_hinzufuegen(self):
+ """Neuen lokalen Kontakt hinzufügen."""
+ dialog = KontaktBearbeitenDialog(parent=self)
+ if dialog.exec() == QDialog.DialogCode.Accepted:
+ daten = dialog.kontakt_daten
+ if daten:
+ self._kontakte.append(daten)
+ self._kontakte.sort(
+ key=lambda k: k.get("name", "").lower())
+ self._tabelle_fuellen()
+ self._kontakte_speichern()
+
+ def _kontakt_bearbeiten(self):
+ """Ausgewählten Kontakt bearbeiten (über Tabellen-Button)."""
+ kontakt = self._aktuelle_zeile_kontakt()
+ if not kontakt:
+ return
+ self._kontakt_bearbeiten_dialog(kontakt)
+
+ def _kontakt_bearbeiten_dialog(self, kontakt):
+ """Edit-Dialog öffnen und Änderungen speichern (lokal + CardDAV)."""
+ dialog = KontaktBearbeitenDialog(kontakt=kontakt, parent=self)
+ if dialog.exec() != QDialog.DialogCode.Accepted:
+ return
+
+ daten = dialog.kontakt_daten
+ if not daten:
+ return
+
+ quelle = kontakt.get("quelle", "lokal")
+ if quelle == "carddav":
+ self._carddav_kontakt_speichern(kontakt, daten)
+ else:
+ self._lokalen_kontakt_speichern(kontakt, daten)
+
+ def _lokalen_kontakt_speichern(self, kontakt_alt, kontakt_neu):
+ """Lokalen Kontakt aktualisieren."""
+ alter_name = kontakt_alt.get("name", "")
+ for i, k in enumerate(self._kontakte):
+ if k.get("name") == alter_name:
+ self._kontakte[i] = kontakt_neu
+ break
+ else:
+ # Nicht gefunden → als neuen Kontakt anlegen
+ self._kontakte.append(kontakt_neu)
+
+ self._kontakte.sort(key=lambda k: k.get("name", "").lower())
+ self._tabelle_fuellen()
+ self._kontakte_speichern()
+ self.kontakte_geaendert.emit()
+
+ def _carddav_kontakt_speichern(self, kontakt_alt, kontakt_neu):
+ """CardDAV-Kontakt auf Server schreiben (PUT)."""
+ account_name = kontakt_alt.get("account", "")
+ accounts = self._config.get("carddav", "accounts") or []
+
+ # Account-Credentials suchen
+ account = None
+ for acc in accounts:
+ if acc.get("name") == account_name:
+ account = acc
+ break
+
+ if not account:
+ QMessageBox.warning(
+ self, "Fehler",
+ f"CardDAV-Account '{account_name}' nicht gefunden.\n"
+ "Kontakt kann nicht gespeichert werden.")
+ return
+
+ from utils.carddav import CardDavSync
+ sync = CardDavSync()
+ erfolg, fehler = sync.kontakt_aktualisieren(
+ account["url"], account["benutzername"],
+ account["passwort"], kontakt_neu,
+ )
+
+ if erfolg:
+ # Cache aktualisieren
+ for i, k in enumerate(self._carddav_kontakte):
+ if k.get("_href") == kontakt_alt.get("_href"):
+ self._carddav_kontakte[i] = kontakt_neu
+ break
+ self._config.set("carddav", "kontakte_cache",
+ self._carddav_kontakte)
+ self._config.speichern()
+ self._tabelle_fuellen()
+ self.kontakte_geaendert.emit()
+ QMessageBox.information(
+ self, "Gespeichert",
+ f"Kontakt '{kontakt_neu.get('name')}' wurde auf dem "
+ f"Server aktualisiert.")
+ else:
+ QMessageBox.warning(
+ self, "Fehler beim Speichern",
+ f"Kontakt konnte nicht gespeichert werden:\n{fehler}")
+
+ def _kontakt_loeschen(self):
+ """Ausgewählten Kontakt löschen (nur lokale)."""
+ kontakt = self._aktuelle_zeile_kontakt()
+ if not kontakt or kontakt.get("quelle") == "carddav":
+ return
+ name = kontakt.get("name")
+ self._kontakte = [k for k in self._kontakte if k.get("name") != name]
+ self._tabelle_fuellen()
+ self._kontakte_speichern()
+
+ def _kontakte_laden(self):
+ """Kontakte aus Config laden (lokal + CardDAV-Cache)."""
+ self._kontakte = self._config.kontakte_laden()
+ self._carddav_kontakte = (
+ self._config.get("carddav", "kontakte_cache") or [])
+ self._tabelle_fuellen()
+ self.kontakte_geaendert.emit()
+
+ def _kontakte_speichern(self):
+ """Lokale Kontakte in Config speichern."""
+ self._config.kontakte_speichern(self._kontakte)
+
+ def _carddav_sync(self):
+ """CardDAV-Kontakte synchronisieren (alle Accounts)."""
+ accounts = self._config.get("carddav", "accounts") or []
+ if not accounts:
+ return
+
+ from utils.carddav import CardDavSync
+ sync = CardDavSync()
+ kontakte, fehler = sync.alle_accounts_abrufen(accounts)
+
+ if not fehler or kontakte:
+ self._carddav_kontakte = kontakte
+ self._config.set("carddav", "kontakte_cache", kontakte)
+ self._config.speichern()
+ self._tabelle_fuellen()
+ self.kontakte_geaendert.emit()
+
+ def _suche_filtern(self, text):
+ """Tabellenzeilen nach Suchbegriff filtern."""
+ text = text.lower().strip()
+ for zeile in range(self.tabelle.rowCount()):
+ sichtbar = True
+ if text:
+ # In allen Spalten suchen
+ sichtbar = False
+ for spalte in range(self.tabelle.columnCount()):
+ item = self.tabelle.item(zeile, spalte)
+ if item and text in item.text().lower():
+ sichtbar = True
+ break
+ self.tabelle.setRowHidden(zeile, not sichtbar)
+
+ def aktualisieren(self):
+ """Kontakte neu laden (extern aufrufbar)."""
+ self._kontakte_laden()
+
+ def alle_kontakte_fuer_suche(self):
+ """Alle Kontakte für die Suche im Wählfeld bereitstellen."""
+ return self._alle_kontakte
diff --git a/ui/login_dialog.py b/ui/login_dialog.py
new file mode 100644
index 0000000..9fbc5bf
--- /dev/null
+++ b/ui/login_dialog.py
@@ -0,0 +1,130 @@
+"""Login-Dialog - SIP-Zugangsdaten beim ersten Start eingeben."""
+
+from PySide6.QtWidgets import (
+ QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
+ QLineEdit, QPushButton, QCheckBox, QLabel, QSpinBox,
+)
+from PySide6.QtCore import Qt
+from PySide6.QtGui import QIntValidator
+
+
+class LoginDialog(QDialog):
+ """Dialog zur Eingabe der SIP-Zugangsdaten."""
+
+ def __init__(self, config_manager, parent=None):
+ super().__init__(parent)
+ self._config = config_manager
+ self.setWindowTitle("SIP-Anmeldung")
+ self.setMinimumWidth(380)
+ self.setModal(True)
+ self._ui_aufbauen()
+ self._werte_laden()
+
+ def _ui_aufbauen(self):
+ layout = QVBoxLayout(self)
+
+ # Titel
+ titel = QLabel("FreePBX Verbindung einrichten")
+ titel.setStyleSheet("font-size: 14px; font-weight: bold; margin-bottom: 8px;")
+ layout.addWidget(titel)
+
+ # Formular
+ form = QFormLayout()
+
+ self.server_eingabe = QLineEdit()
+ self.server_eingabe.setPlaceholderText("z.B. 192.168.154.242")
+ form.addRow("Server:", self.server_eingabe)
+
+ self.port_eingabe = QSpinBox()
+ self.port_eingabe.setRange(1, 65535)
+ self.port_eingabe.setValue(5060)
+ form.addRow("Port:", self.port_eingabe)
+
+ self.extension_eingabe = QLineEdit()
+ self.extension_eingabe.setPlaceholderText("z.B. 200")
+ self.extension_eingabe.setValidator(QIntValidator(1, 99999))
+ form.addRow("Extension:", self.extension_eingabe)
+
+ self.passwort_eingabe = QLineEdit()
+ self.passwort_eingabe.setEchoMode(QLineEdit.EchoMode.Password)
+ self.passwort_eingabe.setPlaceholderText("SIP-Passwort")
+ form.addRow("Passwort:", self.passwort_eingabe)
+
+ layout.addLayout(form)
+
+ # Speichern-Option
+ self.speichern_check = QCheckBox("Zugangsdaten speichern")
+ self.speichern_check.setChecked(True)
+ layout.addWidget(self.speichern_check)
+
+ # Buttons
+ btn_layout = QHBoxLayout()
+ self.verbinden_btn = QPushButton("Verbinden")
+ self.verbinden_btn.setDefault(True)
+ self.abbrechen_btn = QPushButton("Abbrechen")
+
+ self.verbinden_btn.clicked.connect(self._verbinden)
+ self.abbrechen_btn.clicked.connect(self.reject)
+
+ btn_layout.addStretch()
+ btn_layout.addWidget(self.abbrechen_btn)
+ btn_layout.addWidget(self.verbinden_btn)
+ layout.addLayout(btn_layout)
+
+ # Enter-Taste zum Verbinden
+ self.passwort_eingabe.returnPressed.connect(self._verbinden)
+
+ def _werte_laden(self):
+ """Gespeicherte Werte aus Config laden."""
+ sip = self._config.sip
+ if sip.get("server"):
+ self.server_eingabe.setText(sip["server"])
+ if sip.get("port"):
+ self.port_eingabe.setValue(sip["port"])
+ if sip.get("extension"):
+ self.extension_eingabe.setText(sip["extension"])
+ if sip.get("passwort"):
+ self.passwort_eingabe.setText(sip["passwort"])
+
+ def _verbinden(self):
+ """Eingaben validieren und Dialog schließen."""
+ # Pflichtfelder prüfen
+ if not self.server_eingabe.text().strip():
+ self.server_eingabe.setFocus()
+ self.server_eingabe.setStyleSheet("border: 1px solid red;")
+ return
+ if not self.extension_eingabe.text().strip():
+ self.extension_eingabe.setFocus()
+ self.extension_eingabe.setStyleSheet("border: 1px solid red;")
+ return
+ if not self.passwort_eingabe.text():
+ self.passwort_eingabe.setFocus()
+ self.passwort_eingabe.setStyleSheet("border: 1px solid red;")
+ return
+
+ # Config aktualisieren
+ self._config.set("sip", "server", self.server_eingabe.text().strip())
+ self._config.set("sip", "port", self.port_eingabe.value())
+ self._config.set("sip", "extension", self.extension_eingabe.text().strip())
+ self._config.set("sip", "passwort", self.passwort_eingabe.text())
+
+ if self.speichern_check.isChecked():
+ self._config.speichern()
+
+ self.accept()
+
+ @property
+ def server(self):
+ return self.server_eingabe.text().strip()
+
+ @property
+ def port(self):
+ return self.port_eingabe.value()
+
+ @property
+ def extension(self):
+ return self.extension_eingabe.text().strip()
+
+ @property
+ def passwort(self):
+ return self.passwort_eingabe.text()
diff --git a/ui/waehlfeld.py b/ui/waehlfeld.py
new file mode 100644
index 0000000..05d4184
--- /dev/null
+++ b/ui/waehlfeld.py
@@ -0,0 +1,240 @@
+"""Wählfeld-Widget - Nummerneingabe + DTMF-Tastatur mit Kontaktsuche."""
+
+from PySide6.QtWidgets import (
+ QWidget, QVBoxLayout, QGridLayout, QLineEdit, QPushButton, QSizePolicy,
+ QListWidget, QListWidgetItem,
+)
+from PySide6.QtCore import Signal, Qt
+from PySide6.QtGui import QFont, QColor
+
+
+class Waehlfeld(QWidget):
+ """Wähltastatur mit Nummernfeld, Kontaktsuche und 12 Tasten."""
+
+ # Signale
+ nummer_gewaehlt = Signal(str) # Komplette Nummer zum Anrufen
+ dtmf_gedrueckt = Signal(str) # Einzelne DTMF-Ziffer
+
+ # Tasten-Layout (4 Zeilen x 3 Spalten)
+ TASTEN = [
+ ("1", ""), ("2", "ABC"), ("3", "DEF"),
+ ("4", "GHI"), ("5", "JKL"), ("6", "MNO"),
+ ("7", "PQRS"), ("8", "TUV"), ("9", "WXYZ"),
+ ("*", ""), ("0", "+"), ("#", ""),
+ ]
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._im_gespraech = False
+ self._kontakte_liste = [] # Wird von HauptFenster gesetzt
+ self._ui_aufbauen()
+
+ def _ui_aufbauen(self):
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(8)
+
+ # Nummern-/Suchfeld
+ self.nummer_eingabe = QLineEdit()
+ self.nummer_eingabe.setPlaceholderText("Nummer oder Name...")
+ self.nummer_eingabe.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ eingabe_font = QFont()
+ eingabe_font.setPointSize(18)
+ self.nummer_eingabe.setFont(eingabe_font)
+ self.nummer_eingabe.setMinimumHeight(48)
+ self.nummer_eingabe.setStyleSheet(
+ "QLineEdit { "
+ " border: 2px solid #555; border-radius: 8px; "
+ " padding: 8px 12px; font-size: 18px; "
+ " background: rgba(255,255,255,0.05); "
+ "}"
+ "QLineEdit:focus { border-color: #4CAF50; }"
+ )
+ self.nummer_eingabe.returnPressed.connect(self._nummer_senden)
+ self.nummer_eingabe.textChanged.connect(self._suche_aktualisieren)
+ layout.addWidget(self.nummer_eingabe)
+
+ # Vorschlagsliste (normalerweise versteckt)
+ self.vorschlaege = QListWidget()
+ self.vorschlaege.setMaximumHeight(180)
+ self.vorschlaege.setAlternatingRowColors(True)
+ self.vorschlaege.setStyleSheet(
+ "QListWidget { border: 1px solid #555; border-radius: 4px; }"
+ "QListWidget::item { padding: 4px 8px; }"
+ "QListWidget::item:hover { background: rgba(76,175,80,0.2); }"
+ )
+ self.vorschlaege.itemClicked.connect(self._vorschlag_gewaehlt)
+ self.vorschlaege.itemDoubleClicked.connect(
+ self._vorschlag_anrufen)
+ self.vorschlaege.hide()
+ layout.addWidget(self.vorschlaege)
+
+ # Tastatur-Grid
+ grid = QGridLayout()
+ grid.setSpacing(6)
+
+ # DTMF-Button Styling
+ btn_style = (
+ "QPushButton { "
+ " border: 1px solid #555; border-radius: 8px; "
+ " background: rgba(255,255,255,0.06); "
+ " color: #ddd; font-size: 18px; font-weight: bold; "
+ " padding: 4px; "
+ "}"
+ "QPushButton:hover { "
+ " background: rgba(76,175,80,0.15); "
+ " border-color: #4CAF50; "
+ "}"
+ "QPushButton:pressed { "
+ " background: rgba(76,175,80,0.3); "
+ "}"
+ )
+
+ for index, (ziffer, buchstaben) in enumerate(self.TASTEN):
+ zeile = index // 3
+ spalte = index % 3
+
+ if buchstaben:
+ text = f"{ziffer}\n{buchstaben}"
+ else:
+ text = ziffer
+
+ btn = QPushButton(text)
+ btn.setStyleSheet(btn_style)
+ btn.setMinimumSize(60, 50)
+ btn.setSizePolicy(QSizePolicy.Policy.Expanding,
+ QSizePolicy.Policy.Expanding)
+ btn.setCursor(Qt.CursorShape.PointingHandCursor)
+
+ btn.clicked.connect(
+ lambda checked, z=ziffer: self._taste_gedrueckt(z))
+ grid.addWidget(btn, zeile, spalte)
+
+ layout.addLayout(grid)
+
+ def kontakte_setzen(self, kontakte):
+ """Kontaktliste für die Suche setzen (von HauptFenster aufgerufen)."""
+ self._kontakte_liste = kontakte or []
+
+ def _taste_gedrueckt(self, ziffer):
+ """Taste wurde gedrückt - Ziffer anhängen oder DTMF senden."""
+ if self._im_gespraech:
+ self.dtmf_gedrueckt.emit(ziffer)
+ else:
+ self.nummer_eingabe.setText(self.nummer_eingabe.text() + ziffer)
+ self.nummer_eingabe.setFocus()
+
+ def _nummer_senden(self):
+ """Eingegebene Nummer zum Anrufen senden."""
+ nummer = self.nummer_eingabe.text().strip()
+ if nummer:
+ self.nummer_gewaehlt.emit(nummer)
+
+ def _suche_aktualisieren(self, text):
+ """Eingabe geändert → Vorschläge aktualisieren."""
+ if self._im_gespraech or not text or len(text) < 2:
+ self.vorschlaege.hide()
+ return
+
+ text_lower = text.lower().strip()
+
+ # Prüfen ob es eine reine Nummer ist (dann keine Suche)
+ if all(c in "0123456789+*# " for c in text):
+ self.vorschlaege.hide()
+ return
+
+ self.vorschlaege.clear()
+ treffer = 0
+
+ for kontakt in self._kontakte_liste:
+ name = kontakt.get("name", "")
+ nummern = kontakt.get("nummern", {})
+ firma = kontakt.get("firma", "")
+
+ # Nach Name oder Firma suchen
+ if (text_lower not in name.lower() and
+ text_lower not in firma.lower()):
+ continue
+
+ # Alle Nummern des Kontakts als Vorschläge
+ typ_anzeige = {
+ "telefon": "Tel",
+ "handy": "Handy",
+ "geschaeftlich": "Gesch.",
+ "sonstige": "Sonst.",
+ }
+
+ for typ, nummer in nummern.items():
+ anzeige_typ = typ_anzeige.get(typ, typ)
+ text_anzeige = f"{name} ({anzeige_typ}: {nummer})"
+
+ item = QListWidgetItem(text_anzeige)
+ item.setData(Qt.ItemDataRole.UserRole, nummer)
+ item.setData(Qt.ItemDataRole.UserRole + 1, name)
+
+ # CardDAV-Kontakte blau
+ if kontakt.get("quelle") == "carddav":
+ item.setForeground(QColor("#5599DD"))
+
+ self.vorschlaege.addItem(item)
+ treffer += 1
+
+ if treffer >= 15:
+ break
+ if treffer >= 15:
+ break
+
+ if treffer > 0:
+ self.vorschlaege.show()
+ else:
+ self.vorschlaege.hide()
+
+ def _vorschlag_gewaehlt(self, item):
+ """Einfacher Klick auf Vorschlag → Nummer ins Feld."""
+ nummer = item.data(Qt.ItemDataRole.UserRole)
+ if nummer:
+ self.nummer_eingabe.setText(nummer)
+ self.vorschlaege.hide()
+
+ def _vorschlag_anrufen(self, item):
+ """Doppelklick auf Vorschlag → direkt anrufen."""
+ nummer = item.data(Qt.ItemDataRole.UserRole)
+ if nummer:
+ self.nummer_eingabe.setText(nummer)
+ self.vorschlaege.hide()
+ self.nummer_gewaehlt.emit(nummer)
+
+ def set_im_gespraech(self, aktiv):
+ """Modus wechseln: Nummern-Eingabe ↔ DTMF-Versand."""
+ self._im_gespraech = aktiv
+ if aktiv:
+ self.nummer_eingabe.setPlaceholderText("DTMF-Modus aktiv")
+ self.vorschlaege.hide()
+ else:
+ self.nummer_eingabe.setPlaceholderText(
+ "Name oder Nummer eingeben...")
+
+ def nummer_loeschen(self):
+ """Nummernfeld leeren."""
+ self.nummer_eingabe.clear()
+ self.vorschlaege.hide()
+
+ def nummer_setzen(self, nummer):
+ """Nummer ins Eingabefeld setzen."""
+ self.nummer_eingabe.setText(nummer)
+ self.nummer_eingabe.setFocus()
+
+ def keyPressEvent(self, event):
+ """Tastatureingaben abfangen (Numpad-Support)."""
+ taste = event.text()
+ if taste in "0123456789*#":
+ self._taste_gedrueckt(taste)
+ elif event.key() == Qt.Key.Key_Backspace:
+ text = self.nummer_eingabe.text()
+ self.nummer_eingabe.setText(text[:-1])
+ elif event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
+ self._nummer_senden()
+ elif event.key() == Qt.Key.Key_Escape:
+ self.vorschlaege.hide()
+ else:
+ super().keyPressEvent(event)
diff --git a/utils/__init__.py b/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/utils/audio_manager.py b/utils/audio_manager.py
new file mode 100644
index 0000000..5a6f2dc
--- /dev/null
+++ b/utils/audio_manager.py
@@ -0,0 +1,259 @@
+"""Audio-Manager - Audiogeräte auflisten, wechseln und Lautstärke steuern.
+
+Nutzt PipeWire/PulseAudio-Namen (wie KDE) statt kryptischer ALSA-Bezeichnungen.
+PJSUA2 wird auf das 'pulse'-Device gesetzt, damit PipeWire das Routing übernimmt.
+"""
+
+import subprocess
+import pjsua2 as pj
+
+
+def _pipewire_geraete_holen():
+ """PipeWire/PulseAudio-Geräte mit KDE-freundlichen Namen holen.
+
+ Returns:
+ (sinks, sources): Dicts mit {pw_name: description}
+ """
+ sinks = {} # Wiedergabe
+ sources = {} # Aufnahme
+
+ try:
+ # Sinks (Wiedergabegeräte)
+ result = subprocess.run(
+ ["pactl", "list", "sinks"],
+ capture_output=True, text=True, timeout=3,
+ )
+ aktueller_name = None
+ for zeile in result.stdout.splitlines():
+ zeile = zeile.strip()
+ if zeile.startswith("Name:"):
+ aktueller_name = zeile.split(":", 1)[1].strip()
+ elif zeile.startswith("Description:") and aktueller_name:
+ sinks[aktueller_name] = zeile.split(":", 1)[1].strip()
+ aktueller_name = None
+
+ # Sources (Aufnahmegeräte)
+ result = subprocess.run(
+ ["pactl", "list", "sources"],
+ capture_output=True, text=True, timeout=3,
+ )
+ aktueller_name = None
+ for zeile in result.stdout.splitlines():
+ zeile = zeile.strip()
+ if zeile.startswith("Name:"):
+ aktueller_name = zeile.split(":", 1)[1].strip()
+ elif zeile.startswith("Description:") and aktueller_name:
+ # Monitor-Quellen ausfiltern (sind keine echten Mikrofone)
+ if ".monitor" not in aktueller_name:
+ sources[aktueller_name] = zeile.split(":", 1)[1].strip()
+ aktueller_name = None
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
+ pass
+
+ return sinks, sources
+
+
+def _alsa_name_zu_pw_name(alsa_name):
+ """Versucht einen ALSA-Gerätenamen auf PipeWire-Namen abzubilden.
+
+ PJSUA2 gibt z.B. 'Razer Nari: USB Audio (hw:0,0)' zurück.
+ PipeWire nutzt z.B. 'alsa_output.usb-Razer_Razer_Nari-00.mono-fallback'.
+
+ Matching über Schlüsselwörter im ALSA-Namen.
+ """
+ # Extrahiere Schlüsselwörter aus dem ALSA-Namen (ohne hw:X,Y)
+ bereinigt = alsa_name.split("(")[0].strip().lower()
+ # Wörter extrahieren
+ woerter = [w for w in bereinigt.replace(":", " ").replace("-", " ").split()
+ if len(w) > 2]
+ return woerter
+
+
+class AudioManager:
+ """Verwaltet Audiogeräte über PipeWire/PulseAudio mit PJSUA2-Backend.
+
+ Zeigt PipeWire-Geräte mit KDE-freundlichen Namen an.
+ PJSUA2 nutzt das 'pulse'-ALSA-Plugin für PipeWire-Routing.
+ """
+
+ def __init__(self, engine):
+ self._engine = engine
+ self._pulse_dev_id = None # PJSUA2-ID des 'pulse'-Geräts
+
+ @property
+ def _aud_mgr(self):
+ """Zugriff auf den PJSUA2 AudDevManager."""
+ if self._engine.ep:
+ return self._engine.ep.audDevManager()
+ return None
+
+ def _pjsua2_geraete(self):
+ """Rohe PJSUA2-Geräteliste holen."""
+ if not self._aud_mgr:
+ return []
+ geraete = []
+ try:
+ dev_list = self._aud_mgr.enumDev2()
+ for i, dev in enumerate(dev_list):
+ info = dev.info if hasattr(dev, "info") else dev
+ geraete.append({
+ "id": i,
+ "name": info.name,
+ "eingaenge": info.inputCount,
+ "ausgaenge": info.outputCount,
+ "driver": info.driver,
+ })
+ except (pj.Error, AttributeError):
+ pass
+ return geraete
+
+ def _pulse_device_finden(self):
+ """Finde die PJSUA2-Device-ID für 'pulse' (PipeWire-Routing)."""
+ if self._pulse_dev_id is not None:
+ return self._pulse_dev_id
+
+ geraete = self._pjsua2_geraete()
+ for dev in geraete:
+ if dev["name"] == "pulse":
+ self._pulse_dev_id = dev["id"]
+ return self._pulse_dev_id
+ return None
+
+ def pulse_als_standard_setzen(self):
+ """Setzt 'pulse' als Standard-Audiogerät (PipeWire-Routing).
+
+ Damit übernimmt PipeWire/KDE die Gerätezuordnung.
+ """
+ pulse_id = self._pulse_device_finden()
+ if pulse_id is not None and self._aud_mgr:
+ try:
+ self._aud_mgr.setCaptureDev(pulse_id)
+ self._aud_mgr.setPlaybackDev(pulse_id)
+ return True
+ except pj.Error:
+ pass
+ return False
+
+ def aufnahme_geraete(self):
+ """Aufnahmegeräte mit PipeWire-Beschreibung (wie KDE).
+
+ Returns:
+ Liste von Dicts: {pw_name, beschreibung, pjsua2_id}
+ """
+ _, sources = _pipewire_geraete_holen()
+ pj_geraete = self._pjsua2_geraete()
+
+ geraete = []
+ for pw_name, beschreibung in sources.items():
+ # Versuche passendes PJSUA2-Device zu finden
+ pj_id = self._pw_zu_pjsua2(pw_name, pj_geraete, aufnahme=True)
+ geraete.append({
+ "id": pj_id,
+ "pw_name": pw_name,
+ "name": beschreibung,
+ "eingaenge": 1,
+ "ausgaenge": 0,
+ })
+
+ return geraete
+
+ def wiedergabe_geraete(self):
+ """Wiedergabegeräte mit PipeWire-Beschreibung (wie KDE).
+
+ Returns:
+ Liste von Dicts: {pw_name, beschreibung, pjsua2_id}
+ """
+ sinks, _ = _pipewire_geraete_holen()
+ pj_geraete = self._pjsua2_geraete()
+
+ geraete = []
+ for pw_name, beschreibung in sinks.items():
+ pj_id = self._pw_zu_pjsua2(pw_name, pj_geraete, aufnahme=False)
+ geraete.append({
+ "id": pj_id,
+ "pw_name": pw_name,
+ "name": beschreibung,
+ "eingaenge": 0,
+ "ausgaenge": 1,
+ })
+
+ return geraete
+
+ def _pw_zu_pjsua2(self, pw_name, pj_geraete, aufnahme=False):
+ """Ordnet einen PipeWire-Namen einem PJSUA2-Device-Index zu.
+
+ Da PJSUA2 über das 'pulse'-Device läuft, wird die
+ PipeWire-Device-Auswahl über `pactl set-default-*` gesteuert.
+ Rückgabe: pw_name als Identifikator (kein PJSUA2-Index nötig).
+ """
+ # Wir nutzen den PipeWire-Namen direkt als ID-String
+ # Die tatsächliche Umschaltung passiert über pactl
+ return pw_name
+
+ def aufnahme_geraet_setzen(self, pw_name):
+ """Aufnahmegerät über PipeWire setzen.
+
+ Args:
+ pw_name: PipeWire-Device-Name (z.B. 'alsa_input.usb-...')
+ """
+ if isinstance(pw_name, int):
+ # Fallback: Direkte PJSUA2-ID (Kompatibilität)
+ if self._aud_mgr:
+ try:
+ self._aud_mgr.setCaptureDev(pw_name)
+ return True
+ except pj.Error:
+ return False
+ return False
+
+ # PipeWire-Default-Source setzen
+ try:
+ subprocess.run(
+ ["pactl", "set-default-source", pw_name],
+ capture_output=True, timeout=3,
+ )
+ return True
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
+ return False
+
+ def wiedergabe_geraet_setzen(self, pw_name):
+ """Wiedergabegerät über PipeWire setzen.
+
+ Args:
+ pw_name: PipeWire-Device-Name (z.B. 'alsa_output.usb-...')
+ """
+ if isinstance(pw_name, int):
+ # Fallback: Direkte PJSUA2-ID (Kompatibilität)
+ if self._aud_mgr:
+ try:
+ self._aud_mgr.setPlaybackDev(pw_name)
+ return True
+ except pj.Error:
+ return False
+ return False
+
+ # PipeWire-Default-Sink setzen
+ try:
+ subprocess.run(
+ ["pactl", "set-default-sink", pw_name],
+ capture_output=True, timeout=3,
+ )
+ return True
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
+ return False
+
+ def mikrofon_lautstaerke(self, level):
+ """Mikrofon-Lautstärke setzen (0.0 = stumm, 1.0 = normal, 2.0 = laut)."""
+ if self._aud_mgr:
+ try:
+ self._aud_mgr.getCaptureDevMedia().adjustTxLevel(level)
+ except pj.Error:
+ pass
+
+ def lautsprecher_lautstaerke(self, level):
+ """Lautsprecher-Lautstärke setzen (0.0 = stumm, 1.0 = normal, 2.0 = laut)."""
+ if self._aud_mgr:
+ try:
+ self._aud_mgr.getPlaybackDevMedia().adjustRxLevel(level)
+ except pj.Error:
+ pass
diff --git a/utils/benachrichtigung.py b/utils/benachrichtigung.py
new file mode 100644
index 0000000..c66d76f
--- /dev/null
+++ b/utils/benachrichtigung.py
@@ -0,0 +1,110 @@
+"""Anruf-Benachrichtigung - KDE Plasma Notification mit Annehmen/Ablehnen."""
+
+try:
+ import dbus
+ DBUS_VERFUEGBAR = True
+except ImportError:
+ DBUS_VERFUEGBAR = False
+
+
+class AnrufBenachrichtigung:
+ """KDE-Benachrichtigung mit Action-Buttons über D-Bus.
+
+ Nutzt org.freedesktop.Notifications für native KDE Plasma
+ Benachrichtigungen mit Annehmen/Ablehnen-Buttons.
+ """
+
+ def __init__(self):
+ self._notification_id = 0
+ self._callback_annehmen = None
+ self._callback_ablehnen = None
+ self._bus = None
+ self._interface = None
+
+ if not DBUS_VERFUEGBAR:
+ return
+
+ try:
+ self._bus = dbus.SessionBus()
+ proxy = self._bus.get_object(
+ "org.freedesktop.Notifications",
+ "/org/freedesktop/Notifications",
+ )
+ self._interface = dbus.Interface(
+ proxy, "org.freedesktop.Notifications"
+ )
+
+ # Signal-Handler für Button-Klicks
+ self._bus.add_signal_receiver(
+ self._on_action,
+ signal_name="ActionInvoked",
+ dbus_interface="org.freedesktop.Notifications",
+ )
+ # Signal wenn Notification geschlossen wird
+ self._bus.add_signal_receiver(
+ self._on_closed,
+ signal_name="NotificationClosed",
+ dbus_interface="org.freedesktop.Notifications",
+ )
+ except dbus.DBusException:
+ self._interface = None
+
+ def anruf_anzeigen(self, anrufer, callback_annehmen=None, callback_ablehnen=None):
+ """Benachrichtigung für eingehenden Anruf anzeigen.
+
+ Args:
+ anrufer: Anrufer-Nummer oder Name
+ callback_annehmen: Funktion bei Klick auf "Annehmen"
+ callback_ablehnen: Funktion bei Klick auf "Ablehnen"
+ """
+ if not self._interface:
+ return
+
+ self._callback_annehmen = callback_annehmen
+ self._callback_ablehnen = callback_ablehnen
+
+ try:
+ self._notification_id = self._interface.Notify(
+ "SIP Softphone", # App-Name
+ 0, # Replaces-ID (0 = neue Notification)
+ "call-start", # Icon (Freedesktop-Icon-Name)
+ f"Eingehender Anruf", # Titel
+ f"Anruf von {anrufer}", # Text
+ ["annehmen", "Annehmen", "ablehnen", "Ablehnen"], # Actions
+ {
+ "urgency": dbus.Byte(2), # Critical - bleibt stehen
+ "category": "im.received",
+ "desktop-entry": "sipwebapp",
+ },
+ 0, # Timeout: 0 = kein Auto-Close (Critical)
+ )
+ except dbus.DBusException:
+ pass
+
+ def schliessen(self):
+ """Benachrichtigung schließen (z.B. wenn Anruf endet)."""
+ if self._interface and self._notification_id:
+ try:
+ self._interface.CloseNotification(self._notification_id)
+ except dbus.DBusException:
+ pass
+ self._notification_id = 0
+ self._callback_annehmen = None
+ self._callback_ablehnen = None
+
+ def _on_action(self, notif_id, action_id):
+ """D-Bus Signal: Button wurde geklickt."""
+ if notif_id != self._notification_id:
+ return
+
+ if action_id == "annehmen" and self._callback_annehmen:
+ self._callback_annehmen()
+ elif action_id == "ablehnen" and self._callback_ablehnen:
+ self._callback_ablehnen()
+
+ self._notification_id = 0
+
+ def _on_closed(self, notif_id, reason):
+ """D-Bus Signal: Notification wurde geschlossen."""
+ if notif_id == self._notification_id:
+ self._notification_id = 0
diff --git a/utils/carddav.py b/utils/carddav.py
new file mode 100644
index 0000000..41ecdd6
--- /dev/null
+++ b/utils/carddav.py
@@ -0,0 +1,568 @@
+"""CardDAV-Sync - Kontakte von Nextcloud/CardDAV-Server abrufen und bearbeiten."""
+
+import re
+from urllib.parse import urljoin
+
+try:
+ import requests
+ REQUESTS_VERFUEGBAR = True
+except ImportError:
+ REQUESTS_VERFUEGBAR = False
+
+try:
+ import vobject
+ VOBJECT_VERFUEGBAR = True
+except ImportError:
+ VOBJECT_VERFUEGBAR = False
+
+
+class CardDavSync:
+ """Synchronisiert Kontakte von einem CardDAV-Server (Nextcloud).
+
+ Kontakt-Format (ein Eintrag pro Person):
+ {
+ "name": "Max Mustermann",
+ "email": "max@example.com",
+ "emails": ["max@example.com"],
+ "firma": "Firma GmbH",
+ "titel": "Geschäftsführer",
+ "nummern": {
+ "telefon": "+49405551234",
+ "handy": "+491701234567",
+ "geschaeftlich": "+49405559999",
+ },
+ "adresse": "Musterstr. 1, 12345 Musterstadt",
+ "geburtstag": "01.01.1990",
+ "notiz": "...",
+ "url": "https://...",
+ "quelle": "carddav",
+ "account": "Privat",
+ "_href": "/remote.php/dav/.../UUID.vcf",
+ "_etag": "\"abc123\"",
+ }
+ """
+
+ def kontakte_abrufen(self, url, benutzername, passwort, account_name=""):
+ """Alle Kontakte vom CardDAV-Server abrufen.
+
+ Returns:
+ (kontakte, fehler): Tuple aus Kontaktliste und Fehlerstring
+ """
+ if not REQUESTS_VERFUEGBAR:
+ return [], "requests nicht installiert (pip install requests)"
+ if not VOBJECT_VERFUEGBAR:
+ return [], "vobject nicht installiert (pip install vobject)"
+
+ if not url.endswith("/"):
+ url += "/"
+
+ try:
+ vcards_raw = self._vcards_holen(url, benutzername, passwort)
+ except requests.exceptions.ConnectionError:
+ return [], "Verbindung fehlgeschlagen"
+ except requests.exceptions.Timeout:
+ return [], "Server-Timeout"
+ except requests.exceptions.SSLError as e:
+ return [], f"SSL-Fehler: {e}"
+ except requests.exceptions.HTTPError as e:
+ code = e.response.status_code if e.response else "?"
+ if code == 401:
+ return [], "Authentifizierung fehlgeschlagen"
+ return [], f"HTTP-Fehler {code}"
+ except Exception as e:
+ return [], str(e)
+
+ kontakte = []
+ for eintrag in vcards_raw:
+ # eintrag ist dict mit vcard_text, href, etag
+ if isinstance(eintrag, dict):
+ parsed = self._vcard_parsen(
+ eintrag["vcard_text"], account_name,
+ href=eintrag.get("href", ""),
+ etag=eintrag.get("etag", ""),
+ )
+ else:
+ # Fallback: reiner String (z.B. von _propfind_einzeln)
+ parsed = self._vcard_parsen(eintrag, account_name)
+ if parsed:
+ kontakte.append(parsed)
+
+ kontakte.sort(key=lambda k: k.get("name", "").lower())
+ return kontakte, ""
+
+ def alle_accounts_abrufen(self, accounts):
+ """Kontakte von mehreren CardDAV-Accounts abrufen.
+
+ Args:
+ accounts: Liste von {"name": "...", "url": "...",
+ "benutzername": "...", "passwort": "..."}
+
+ Returns:
+ (alle_kontakte, fehler_liste)
+ """
+ alle = []
+ fehler = []
+ for acc in accounts:
+ if not acc.get("url") or not acc.get("benutzername"):
+ continue
+ kontakte, err = self.kontakte_abrufen(
+ acc["url"], acc["benutzername"], acc["passwort"],
+ account_name=acc.get("name", ""),
+ )
+ if err:
+ fehler.append(f"{acc.get('name', 'Unbekannt')}: {err}")
+ else:
+ alle.extend(kontakte)
+
+ alle.sort(key=lambda k: k.get("name", "").lower())
+ return alle, fehler
+
+ # === Write-Back: Kontakt auf Server aktualisieren ===
+
+ def kontakt_aktualisieren(self, server_url, benutzername, passwort,
+ kontakt):
+ """Kontakt auf dem CardDAV-Server aktualisieren (PUT).
+
+ Args:
+ server_url: Basis-URL des Adressbuchs
+ benutzername, passwort: Auth-Daten
+ kontakt: Kontakt-Dict mit _href und _etag
+
+ Returns:
+ (erfolg, fehler_text)
+ """
+ href = kontakt.get("_href", "")
+ etag = kontakt.get("_etag", "")
+ if not href:
+ return False, "Kein CardDAV-Href vorhanden"
+
+ # vCard aus Kontakt-Daten aufbauen
+ vcard_text = self._kontakt_zu_vcard(kontakt)
+ if not vcard_text:
+ return False, "vCard konnte nicht erstellt werden"
+
+ # Volle URL zusammenbauen
+ base = server_url
+ if "/remote.php" in base:
+ base = base.split("/remote.php")[0]
+ full_url = urljoin(base, href)
+
+ headers = {
+ "Content-Type": "text/vcard; charset=utf-8",
+ }
+ if etag:
+ headers["If-Match"] = etag
+
+ try:
+ response = requests.put(
+ full_url,
+ auth=(benutzername, passwort),
+ headers=headers,
+ data=vcard_text.encode("utf-8"),
+ timeout=30,
+ )
+ if response.status_code in (200, 201, 204):
+ return True, ""
+ elif response.status_code == 412:
+ return False, "Kontakt wurde zwischenzeitlich geändert (Konflikt)"
+ else:
+ return False, f"HTTP {response.status_code}"
+ except requests.exceptions.ConnectionError:
+ return False, "Verbindung fehlgeschlagen"
+ except requests.exceptions.Timeout:
+ return False, "Server-Timeout"
+ except Exception as e:
+ return False, str(e)
+
+ def _kontakt_zu_vcard(self, kontakt):
+ """Kontakt-Dict in vCard-Text umwandeln."""
+ try:
+ card = vobject.vCard()
+
+ # Name
+ name = kontakt.get("name", "")
+ card.add("fn").value = name
+ n = card.add("n")
+ teile = name.split(" ", 1)
+ if len(teile) == 2:
+ n.value = vobject.vcard.Name(
+ family=teile[1], given=teile[0])
+ else:
+ n.value = vobject.vcard.Name(family=name)
+
+ # Telefonnummern
+ nummern = kontakt.get("nummern", {})
+ typ_map = {
+ "telefon": "HOME",
+ "handy": "CELL",
+ "geschaeftlich": "WORK",
+ "sonstige": "VOICE",
+ }
+ for typ, nummer in nummern.items():
+ if nummer:
+ tel = card.add("tel")
+ tel.value = nummer
+ tel.type_param = typ_map.get(typ, "VOICE")
+
+ # E-Mails
+ emails = kontakt.get("emails", [])
+ if not emails and kontakt.get("email"):
+ emails = [kontakt["email"]]
+ for em in emails:
+ if em:
+ email_obj = card.add("email")
+ email_obj.value = em
+
+ # Firma
+ firma = kontakt.get("firma", "")
+ if firma:
+ org = card.add("org")
+ org.value = [firma]
+
+ # Titel
+ titel = kontakt.get("titel", "")
+ if titel:
+ card.add("title").value = titel
+
+ # Adresse (vereinfacht: alles in street)
+ adresse = kontakt.get("adresse", "")
+ if adresse:
+ adr = card.add("adr")
+ adr.value = vobject.vcard.Address(street=adresse)
+
+ # Geburtstag (DD.MM.YYYY → YYYYMMDD)
+ geburtstag = kontakt.get("geburtstag", "")
+ if geburtstag and len(geburtstag) == 10:
+ try:
+ teile = geburtstag.split(".")
+ bday = f"{teile[2]}{teile[1]}{teile[0]}"
+ card.add("bday").value = bday
+ except (IndexError, ValueError):
+ pass
+
+ # Website
+ url = kontakt.get("url", "")
+ if url:
+ card.add("url").value = url
+
+ # Notiz
+ notiz = kontakt.get("notiz", "")
+ if notiz:
+ card.add("note").value = notiz
+
+ return card.serialize()
+ except Exception:
+ return None
+
+ # === Server-Abruf ===
+
+ def _vcards_holen(self, url, benutzername, passwort):
+ """vCards vom Server holen - REPORT (bevorzugt) oder Fallback."""
+ auth = (benutzername, passwort)
+
+ # Methode 1: REPORT addressbook-query (Nextcloud-Standard)
+ vcards = self._report_addressbook_query(url, auth)
+ if vcards:
+ return vcards
+
+ # Methode 2: PROPFIND mit inline address-data
+ vcards = self._propfind_mit_daten(url, auth)
+ if vcards:
+ return vcards
+
+ # Methode 3: PROPFIND nur hrefs, dann einzeln abrufen
+ return self._propfind_einzeln(url, auth)
+
+ def _report_addressbook_query(self, url, auth):
+ """REPORT addressbook-query - holt alle vCards auf einmal."""
+ body = """
+
+
+
+
+
+"""
+
+ try:
+ response = requests.request(
+ "REPORT", url,
+ auth=auth,
+ headers={
+ "Depth": "1",
+ "Content-Type": "application/xml; charset=utf-8",
+ },
+ data=body.encode("utf-8"),
+ timeout=60,
+ )
+ if response.status_code not in (200, 207):
+ return []
+ except requests.RequestException:
+ return []
+
+ return self._vcards_aus_xml(response.text)
+
+ def _propfind_mit_daten(self, url, auth):
+ """PROPFIND mit inline address-data."""
+ body = """
+
+
+
+
+
+"""
+
+ response = requests.request(
+ "PROPFIND", url,
+ auth=auth,
+ headers={
+ "Depth": "1",
+ "Content-Type": "application/xml; charset=utf-8",
+ },
+ data=body.encode("utf-8"),
+ timeout=60,
+ )
+ response.raise_for_status()
+ return self._vcards_aus_xml(response.text)
+
+ def _propfind_einzeln(self, url, auth):
+ """Fallback: PROPFIND für hrefs, dann jede .vcf einzeln abrufen."""
+ body = """
+
+
+
+
+
+"""
+
+ response = requests.request(
+ "PROPFIND", url,
+ auth=auth,
+ headers={
+ "Depth": "1",
+ "Content-Type": "application/xml; charset=utf-8",
+ },
+ data=body.encode("utf-8"),
+ timeout=30,
+ )
+ response.raise_for_status()
+
+ text = response.text
+ href_pattern = r'<[^>]*href[^>]*>([^<]*\.vcf)[^>]*href>'
+ hrefs = re.findall(href_pattern, text, re.IGNORECASE)
+
+ vcards = []
+ base = url.split("/remote.php")[0] if "/remote.php" in url else url
+
+ for href in hrefs:
+ if href.startswith("http"):
+ vcf_url = href
+ else:
+ vcf_url = urljoin(base, href)
+ try:
+ r = requests.get(vcf_url, auth=auth, timeout=10)
+ if r.status_code == 200 and "BEGIN:VCARD" in r.text:
+ vcards.append(r.text)
+ except requests.RequestException:
+ continue
+
+ return vcards
+
+ def _vcards_aus_xml(self, xml_text):
+ """vCard-Daten + Metadaten (href, etag) aus XML-Response extrahieren."""
+ ergebnisse = []
+
+ # Pro -Block parsen
+ response_pattern = (
+ r'<[^>]*?response[^>]*?>(.*?)[^>]*?response>'
+ )
+ responses = re.findall(
+ response_pattern, xml_text, re.DOTALL | re.IGNORECASE)
+
+ for block in responses:
+ # href extrahieren
+ href_match = re.search(
+ r'<[^>]*?href[^>]*?>([^<]+)[^>]*?href>',
+ block, re.IGNORECASE)
+ href = href_match.group(1).strip() if href_match else ""
+
+ # etag extrahieren
+ etag_match = re.search(
+ r'<[^>]*?getetag[^>]*?>([^<]+)[^>]*?getetag>',
+ block, re.IGNORECASE)
+ etag = etag_match.group(1).strip() if etag_match else ""
+
+ # vCard-Daten extrahieren
+ vcard_match = re.search(
+ r'<[^>]*?address-data[^>]*?>(.*?)[^>]*?address-data>',
+ block, re.DOTALL | re.IGNORECASE)
+ if not vcard_match:
+ continue
+
+ vcard = vcard_match.group(1).strip()
+ # XML-Entities dekodieren
+ vcard = vcard.replace("<", "<").replace(">", ">")
+ vcard = vcard.replace("&", "&").replace(""", '"')
+ vcard = vcard.replace("
", "\r")
+
+ if vcard.startswith("BEGIN:VCARD"):
+ ergebnisse.append({
+ "vcard_text": vcard,
+ "href": href,
+ "etag": etag,
+ })
+
+ return ergebnisse
+
+ def _vcard_parsen(self, vcard_text, account_name="",
+ href="", etag=""):
+ """Eine vCard in ein Kontakt-Dict umwandeln."""
+ try:
+ card = vobject.readOne(vcard_text)
+ except Exception:
+ return None
+
+ # Name
+ name = ""
+ if hasattr(card, "fn"):
+ name = card.fn.value
+ elif hasattr(card, "n"):
+ n = card.n.value
+ name = f"{n.given} {n.family}".strip()
+ if not name:
+ return None
+
+ # E-Mail
+ email = ""
+ if hasattr(card, "email"):
+ email = card.email.value
+
+ # Firma
+ firma = ""
+ if hasattr(card, "org"):
+ org = card.org.value
+ if isinstance(org, list):
+ firma = org[0] if org else ""
+ else:
+ firma = str(org)
+
+ # Telefonnummern nach Typ gruppieren
+ nummern = {}
+ tel_list = getattr(card, "tel_list", [])
+ if not tel_list and hasattr(card, "tel"):
+ tel_list = [card.tel]
+
+ for tel in tel_list:
+ nummer = self._nummer_bereinigen(tel.value)
+ if not nummer:
+ continue
+
+ typ = self._tel_typ_bestimmen(tel)
+ if typ == "handy" and "handy" not in nummern:
+ nummern["handy"] = nummer
+ elif typ == "geschaeftlich" and "geschaeftlich" not in nummern:
+ nummern["geschaeftlich"] = nummer
+ elif typ == "telefon" and "telefon" not in nummern:
+ nummern["telefon"] = nummer
+ elif typ not in ("handy", "geschaeftlich", "telefon"):
+ if "sonstige" not in nummern:
+ nummern["sonstige"] = nummer
+
+ if not nummern:
+ return None
+
+ # Adresse
+ adresse = ""
+ if hasattr(card, "adr"):
+ adr = card.adr.value
+ teile = []
+ if adr.street:
+ teile.append(adr.street)
+ plz_ort = " ".join(
+ filter(None, [adr.code or "", adr.city or ""]))
+ if plz_ort:
+ teile.append(plz_ort)
+ if adr.region:
+ teile.append(adr.region)
+ if adr.country:
+ teile.append(adr.country)
+ adresse = ", ".join(teile)
+
+ # Geburtstag
+ geburtstag = ""
+ if hasattr(card, "bday"):
+ bday_raw = card.bday.value
+ if isinstance(bday_raw, str):
+ bday_raw = bday_raw.replace("-", "")
+ if len(bday_raw) >= 8:
+ geburtstag = (
+ f"{bday_raw[6:8]}.{bday_raw[4:6]}.{bday_raw[:4]}")
+ else:
+ try:
+ geburtstag = bday_raw.strftime("%d.%m.%Y")
+ except (AttributeError, ValueError):
+ pass
+
+ # Notizen
+ notiz = ""
+ if hasattr(card, "note"):
+ notiz = card.note.value or ""
+
+ # Berufsbezeichnung
+ titel = ""
+ if hasattr(card, "title"):
+ titel = card.title.value or ""
+
+ # Website
+ url_wert = ""
+ if hasattr(card, "url"):
+ url_wert = card.url.value or ""
+
+ # Alle E-Mails
+ emails = []
+ email_list = getattr(card, "email_list", [])
+ if not email_list and hasattr(card, "email"):
+ email_list = [card.email]
+ for em in email_list:
+ if em.value and em.value not in emails:
+ emails.append(em.value)
+
+ return {
+ "name": name,
+ "email": email,
+ "emails": emails,
+ "firma": firma,
+ "titel": titel,
+ "nummern": nummern,
+ "adresse": adresse,
+ "geburtstag": geburtstag,
+ "notiz": notiz,
+ "url": url_wert,
+ "quelle": "carddav",
+ "account": account_name,
+ "_href": href,
+ "_etag": etag,
+ }
+
+ def _tel_typ_bestimmen(self, tel):
+ """vCard TEL-Typ auf unsere Kategorien abbilden."""
+ params = tel.params if hasattr(tel, "params") else {}
+ typen = params.get("TYPE", [])
+ if isinstance(typen, str):
+ typen = [typen]
+ typen_lower = [t.lower() for t in typen]
+
+ if "cell" in typen_lower or "mobile" in typen_lower:
+ return "handy"
+ elif "work" in typen_lower:
+ return "geschaeftlich"
+ elif "home" in typen_lower:
+ return "telefon"
+ elif "voice" in typen_lower:
+ return "telefon"
+ return "sonstige"
+
+ def _nummer_bereinigen(self, nummer):
+ """Telefonnummer bereinigen."""
+ if not nummer:
+ return ""
+ return re.sub(r"[^\d+*#]", "", nummer)
diff --git a/utils/config_manager.py b/utils/config_manager.py
new file mode 100644
index 0000000..59483d5
--- /dev/null
+++ b/utils/config_manager.py
@@ -0,0 +1,204 @@
+"""Konfigurationsverwaltung - Lädt und speichert Einstellungen in JSON."""
+
+import json
+import os
+from pathlib import Path
+
+
+# XDG-konformer Konfigurations-Pfad
+KONFIG_VERZEICHNIS = Path(
+ os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")
+) / "sipwebapp"
+
+KONFIG_DATEI = KONFIG_VERZEICHNIS / "config.json"
+ANRUFLISTE_DATEI = KONFIG_VERZEICHNIS / "anrufliste.json"
+KONTAKTE_DATEI = KONFIG_VERZEICHNIS / "kontakte.json"
+
+# Standard-Konfiguration
+STANDARD_KONFIG = {
+ "sip": {
+ "server": "192.168.154.242",
+ "port": 55444,
+ "extension": "",
+ "passwort": "",
+ "transport": "udp",
+ },
+ "audio": {
+ "aufnahme_geraet": "", # PipeWire-Name (leer = KDE-Standard)
+ "wiedergabe_geraet": "", # PipeWire-Name (leer = KDE-Standard)
+ "klingelton_geraet": "", # PipeWire-Name fürs Klingeln (leer = Standard)
+ "mikrofon_lautstaerke": 1.0,
+ "lautsprecher_lautstaerke": 1.0,
+ },
+ "allgemein": {
+ "minimieren_in_tray": True,
+ "autostart": False,
+ "klingelton": "", # Pfad zu WAV-Datei
+ },
+ "carddav": {
+ "accounts": [], # [{"name": "Privat", "url": "...", "benutzername": "...", "passwort": "..."}]
+ "auto_sync": True,
+ "sync_intervall": 3600, # Sekunden
+ },
+ "blf": {
+ "extensions": [], # Liste von Extensions die überwacht werden
+ },
+ "favoriten": {
+ "manuell": [], # [{"name": "...", "nummer": "..."}]
+ "max_anzeige": 10, # Max angezeigte Favoriten
+ },
+}
+
+
+class ConfigManager:
+ """Verwaltet die Anwendungs-Konfiguration als JSON-Datei."""
+
+ def __init__(self):
+ self._konfig = {}
+ self.laden()
+
+ def laden(self):
+ """Konfiguration aus JSON laden oder Standard erstellen."""
+ if KONFIG_DATEI.exists():
+ try:
+ with open(KONFIG_DATEI, "r", encoding="utf-8") as f:
+ gespeichert = json.load(f)
+ # Standard-Werte mit gespeicherten zusammenführen
+ self._konfig = self._zusammenfuehren(STANDARD_KONFIG, gespeichert)
+ except (json.JSONDecodeError, OSError):
+ self._konfig = STANDARD_KONFIG.copy()
+ else:
+ self._konfig = STANDARD_KONFIG.copy()
+
+ # Migration: Alter einzelner CardDAV-Account → accounts-Liste
+ cdav = self._konfig.get("carddav", {})
+ if cdav.get("url") and not cdav.get("accounts"):
+ cdav["accounts"] = [{
+ "name": "Standard",
+ "url": cdav.pop("url", ""),
+ "benutzername": cdav.pop("benutzername", ""),
+ "passwort": cdav.pop("passwort", ""),
+ }]
+ if "accounts" not in cdav:
+ cdav["accounts"] = []
+ # Alte Einzelfelder entfernen
+ for feld in ("url", "benutzername", "passwort"):
+ cdav.pop(feld, None)
+
+ def speichern(self):
+ """Konfiguration als JSON speichern."""
+ KONFIG_VERZEICHNIS.mkdir(parents=True, exist_ok=True)
+ with open(KONFIG_DATEI, "w", encoding="utf-8") as f:
+ json.dump(self._konfig, f, indent=2, ensure_ascii=False)
+
+ def get(self, *schluessel):
+ """Wert aus der Konfiguration lesen.
+
+ Beispiel: config.get("sip", "server") → "192.168.154.242"
+ """
+ wert = self._konfig
+ for s in schluessel:
+ if isinstance(wert, dict) and s in wert:
+ wert = wert[s]
+ else:
+ return None
+ return wert
+
+ def set(self, *args):
+ """Wert in der Konfiguration setzen.
+
+ Letztes Argument ist der Wert, davor die Schlüssel-Hierarchie.
+ Beispiel: config.set("sip", "server", "192.168.1.100")
+ """
+ if len(args) < 2:
+ return
+
+ schluessel = args[:-1]
+ wert = args[-1]
+
+ ziel = self._konfig
+ for s in schluessel[:-1]:
+ if s not in ziel:
+ ziel[s] = {}
+ ziel = ziel[s]
+ ziel[schluessel[-1]] = wert
+
+ @property
+ def sip(self):
+ """SIP-Konfiguration."""
+ return self._konfig.get("sip", {})
+
+ @property
+ def audio(self):
+ """Audio-Konfiguration."""
+ return self._konfig.get("audio", {})
+
+ @property
+ def allgemein(self):
+ """Allgemeine Konfiguration."""
+ return self._konfig.get("allgemein", {})
+
+ @property
+ def carddav(self):
+ """CardDAV-Konfiguration."""
+ return self._konfig.get("carddav", {})
+
+ @property
+ def blf(self):
+ """BLF-Konfiguration."""
+ return self._konfig.get("blf", {})
+
+ def hat_sip_zugangsdaten(self):
+ """Prüft ob SIP-Zugangsdaten konfiguriert sind."""
+ sip = self.sip
+ return bool(sip.get("extension") and sip.get("passwort"))
+
+ def _zusammenfuehren(self, standard, gespeichert):
+ """Zusammenführen: Standard-Werte + gespeicherte Werte."""
+ ergebnis = standard.copy()
+ for schluessel, wert in gespeichert.items():
+ if (schluessel in ergebnis and
+ isinstance(ergebnis[schluessel], dict) and
+ isinstance(wert, dict)):
+ ergebnis[schluessel] = self._zusammenfuehren(
+ ergebnis[schluessel], wert
+ )
+ else:
+ ergebnis[schluessel] = wert
+ return ergebnis
+
+ # --- Anrufliste ---
+
+ def anrufliste_laden(self):
+ """Anrufliste aus JSON laden."""
+ if ANRUFLISTE_DATEI.exists():
+ try:
+ with open(ANRUFLISTE_DATEI, "r", encoding="utf-8") as f:
+ return json.load(f)
+ except (json.JSONDecodeError, OSError):
+ pass
+ return []
+
+ def anrufliste_speichern(self, anrufe):
+ """Anrufliste als JSON speichern."""
+ KONFIG_VERZEICHNIS.mkdir(parents=True, exist_ok=True)
+ with open(ANRUFLISTE_DATEI, "w", encoding="utf-8") as f:
+ json.dump(anrufe, f, indent=2, ensure_ascii=False)
+
+ # --- Kontakte ---
+
+ def kontakte_laden(self):
+ """Kontaktliste aus JSON laden."""
+ if KONTAKTE_DATEI.exists():
+ try:
+ with open(KONTAKTE_DATEI, "r", encoding="utf-8") as f:
+ return json.load(f)
+ except (json.JSONDecodeError, OSError):
+ pass
+ return []
+
+ def kontakte_speichern(self, kontakte):
+ """Kontaktliste als JSON speichern."""
+ KONFIG_VERZEICHNIS.mkdir(parents=True, exist_ok=True)
+ with open(KONTAKTE_DATEI, "w", encoding="utf-8") as f:
+ json.dump(kontakte, f, indent=2, ensure_ascii=False)
diff --git a/utils/klingelton.py b/utils/klingelton.py
new file mode 100644
index 0000000..d4d3754
--- /dev/null
+++ b/utils/klingelton.py
@@ -0,0 +1,108 @@
+"""Klingelton-Player - Spielt Klingelton über separates PipeWire-Gerät ab."""
+
+import subprocess
+from pathlib import Path
+
+from PySide6.QtCore import QObject, QTimer
+
+
+# Standard-Klingelton-Pfade
+_STANDARD_KLINGELTON = Path(__file__).parent.parent / "resources" / "sounds" / "klingelton.wav"
+_FREEDESKTOP_KLINGELTON = Path("/usr/share/sounds/freedesktop/stereo/phone-incoming-call.oga")
+
+
+class KlingeltonPlayer(QObject):
+ """Spielt Klingelton über paplay mit optionalem PipeWire-Gerät.
+
+ Nutzt paplay (PulseAudio/PipeWire CLI) mit --device für die
+ Ausgabe über ein dediziertes Klingelton-Gerät (z.B. Lautsprecher
+ statt Headset).
+ """
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._prozess = None
+ self._loop_timer = QTimer(self)
+ self._loop_timer.timeout.connect(self._erneut_abspielen)
+ self._datei = ""
+ self._device = ""
+ self._laeuft = False
+
+ def abspielen(self, datei_pfad="", device_pw_name="", loop=True):
+ """Klingelton abspielen.
+
+ Args:
+ datei_pfad: Pfad zur Audio-Datei (leer = Standard)
+ device_pw_name: PipeWire-Sink-Name (leer = System-Standard)
+ loop: Endlosschleife bis stoppen() aufgerufen wird
+ """
+ self.stoppen()
+
+ # Datei bestimmen
+ if datei_pfad and Path(datei_pfad).exists():
+ self._datei = datei_pfad
+ elif _STANDARD_KLINGELTON.exists():
+ self._datei = str(_STANDARD_KLINGELTON)
+ elif _FREEDESKTOP_KLINGELTON.exists():
+ self._datei = str(_FREEDESKTOP_KLINGELTON)
+ else:
+ return # Keine Audio-Datei verfügbar
+
+ self._device = device_pw_name
+ self._laeuft = True
+ self._paplay_starten()
+
+ if loop:
+ # Nach 5 Sekunden erneut abspielen (Klingelton-Datei ist ~5s)
+ self._loop_timer.start(5500)
+
+ def stoppen(self):
+ """Klingelton stoppen."""
+ self._laeuft = False
+ self._loop_timer.stop()
+ if self._prozess and self._prozess.poll() is None:
+ try:
+ self._prozess.terminate()
+ except OSError:
+ pass
+ self._prozess = None
+
+ def test_abspielen(self, datei_pfad="", device_pw_name=""):
+ """Einmal abspielen zum Testen (kein Loop)."""
+ self.abspielen(datei_pfad, device_pw_name, loop=False)
+
+ @property
+ def laeuft(self):
+ """True wenn Klingelton gerade abgespielt wird."""
+ return self._laeuft
+
+ def _paplay_starten(self):
+ """paplay Subprocess starten."""
+ if not self._laeuft:
+ return
+
+ cmd = ["paplay"]
+ if self._device:
+ cmd.extend(["--device", self._device])
+ cmd.append(self._datei)
+
+ try:
+ self._prozess = subprocess.Popen(
+ cmd,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ except (FileNotFoundError, OSError):
+ self._laeuft = False
+
+ def _erneut_abspielen(self):
+ """Timer-Callback: Klingelton in Schleife abspielen."""
+ if not self._laeuft:
+ self._loop_timer.stop()
+ return
+
+ # Vorherigen Prozess abräumen
+ if self._prozess and self._prozess.poll() is not None:
+ self._prozess = None
+
+ self._paplay_starten()