Initiales Release: SIP Softphone für FreePBX/Asterisk

PySide6/PJSUA2-basiertes Desktop-Softphone mit:
- SIP-Telefonie (Anrufe, DTMF, Halten, Transfer, Konferenz)
- CardDAV-Kontaktsync mit Write-Back (Multi-Account)
- Kontaktverwaltung mit Suche und Bearbeiten-Dialog
- Anrufliste mit Namensauflösung
- Favoriten-Panel (manuell + häufig angerufen)
- BLF-Panel (SIP Presence Monitoring)
- Responsives Layout (kompakt/erweitert)
- Click-to-Call (tel:/sip: URI via D-Bus)
- KDE-Integration (Tray, Benachrichtigungen)
- PipeWire/PulseAudio Audio-Routing
- AppImage Build-Support (PyInstaller + appimagetool)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-02-18 13:18:12 +01:00
parent bd539751ad
commit 48ddb4b4af
28 changed files with 5547 additions and 150 deletions

168
.gitignore vendored
View file

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

244
README.md
View file

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

142
build_appimage.sh Normal file
View file

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

141
main.py Normal file
View file

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

5
requirements.txt Normal file
View file

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

21
resources/icons/phone.svg Normal file
View file

@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2196F3"/>
<stop offset="100%" style="stop-color:#1565C0"/>
</linearGradient>
</defs>
<!-- Hintergrund -->
<rect width="256" height="256" rx="48" ry="48" fill="url(#bg)"/>
<!-- Telefonhoerer -->
<g transform="translate(128,128) rotate(-30) translate(-128,-128)">
<path d="M80,88 C80,88 60,78 50,98 C40,118 55,148 75,168
C95,188 125,208 145,198 C165,188 155,168 155,168
L135,148 C135,148 125,158 115,148 C105,138 95,118 95,108
C95,98 105,88 105,88 Z"
fill="white" stroke="none"/>
</g>
<!-- Signal-Wellen -->
<path d="M170,60 C190,40 210,55 200,75" fill="none" stroke="rgba(255,255,255,0.7)" stroke-width="5" stroke-linecap="round"/>
<path d="M180,45 C205,20 230,40 215,65" fill="none" stroke="rgba(255,255,255,0.4)" stroke-width="5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

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

Binary file not shown.

0
sip/__init__.py Normal file
View file

82
sip/account.py Normal file
View file

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

152
sip/call.py Normal file
View file

@ -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 (183200 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

292
sip/engine.py Normal file
View file

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

127
sipwebapp.spec Normal file
View file

@ -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",
)

0
ui/__init__.py Normal file
View file

234
ui/anrufliste.py Normal file
View file

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

157
ui/blf_panel.py Normal file
View file

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

482
ui/einstellungen.py Normal file
View file

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

188
ui/favoriten_panel.py Normal file
View file

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

767
ui/hauptfenster.py Normal file
View file

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

863
ui/kontakte.py Normal file
View file

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

130
ui/login_dialog.py Normal file
View file

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

240
ui/waehlfeld.py Normal file
View file

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

0
utils/__init__.py Normal file
View file

259
utils/audio_manager.py Normal file
View file

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

110
utils/benachrichtigung.py Normal file
View file

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

568
utils/carddav.py Normal file
View file

@ -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 = """<?xml version="1.0" encoding="UTF-8"?>
<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
<d:prop>
<d:getetag/>
<card:address-data/>
</d:prop>
</card:addressbook-query>"""
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 = """<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
<d:prop>
<d:getetag/>
<card:address-data/>
</d:prop>
</d:propfind>"""
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 = """<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:getetag/>
<d:getcontenttype/>
</d:prop>
</d:propfind>"""
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 <d:response>-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("&lt;", "<").replace("&gt;", ">")
vcard = vcard.replace("&amp;", "&").replace("&quot;", '"')
vcard = vcard.replace("&#13;", "\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)

204
utils/config_manager.py Normal file
View file

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

108
utils/klingelton.py Normal file
View file

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