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