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:
parent
bd539751ad
commit
48ddb4b4af
28 changed files with 5547 additions and 150 deletions
168
.gitignore
vendored
168
.gitignore
vendored
|
|
@ -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
244
README.md
|
|
@ -1,2 +1,244 @@
|
|||
# linux.sipsoftphone
|
||||
# SIP Softphone
|
||||
|
||||
Desktop-SIP-Softphone für FreePBX/Asterisk auf Linux (KDE/PipeWire).
|
||||
|
||||
Basiert auf **PJSUA2** (SIP-Stack) und **PySide6** (Qt-GUI). Verbindet sich über SIP/UDP direkt mit der Telefonanlage.
|
||||
|
||||
## Features
|
||||
|
||||
- **SIP-Telefonie**: Anrufe tätigen/annehmen, DTMF, Halten, Transfer, Konferenz
|
||||
- **Kontaktverwaltung**: Lokale Kontakte + CardDAV-Sync (Nextcloud, Multi-Account)
|
||||
- **CardDAV Write-Back**: Kontakte bearbeiten und auf den Server zurückschreiben
|
||||
- **Anrufliste**: Verlauf mit Richtung, Dauer, Kontaktnamens-Auflösung
|
||||
- **Favoriten**: Manuelle Favoriten + häufig angerufene Nummern (automatisch)
|
||||
- **BLF-Panel**: Besetzt-Lampenfeld (Presence-Monitoring via SIP SUBSCRIBE)
|
||||
- **Kontaktsuche**: Live-Suche im Wählfeld nach Name/Firma
|
||||
- **Responsives Layout**: Kompakt (nur Wählfeld) oder Breit (Wählfeld + Tabs)
|
||||
- **Click-to-Call**: `tel:` und `sip:` URI-Handler via D-Bus Single-Instance
|
||||
- **KDE-Integration**: System-Tray, D-Bus-Benachrichtigungen mit Annehmen/Ablehnen
|
||||
- **PipeWire-Audio**: Natives PipeWire/PulseAudio-Routing, separates Klingelton-Gerät
|
||||
- **AppImage**: Komplett unabhängiges Paket (kein Python/Qt auf dem Zielsystem nötig)
|
||||
|
||||
## Screenshots
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ [Registriert] 00:42 SIP Softphone │
|
||||
├──────────────┬───────────────────────────────┤
|
||||
│ │ Favoriten│Verlauf│Kontakte│BLF │
|
||||
│ [Nummer...] ├───────────────────────────────┤
|
||||
│ │ ★ Max Mustermann │
|
||||
│ [1] [2] [3]│ +49 40 555 1234 │
|
||||
│ [4] [5] [6]│ ★ Büro Hauptnummer │
|
||||
│ [7] [8] [9]│ +49 40 555 0000 │
|
||||
│ [*] [0] [#]│ │
|
||||
│ │ Häufig angerufen │
|
||||
│ │ Lieferant Meyer │
|
||||
├──────────────┴───────────────────────────────┤
|
||||
│ [ Anrufen ] │
|
||||
│ Mik: ═══════════ Lsp: ═══════════ │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
### System
|
||||
|
||||
- Linux x86_64 (getestet: Manjaro KDE)
|
||||
- PipeWire oder PulseAudio
|
||||
- Python 3.10+ (für Entwicklung)
|
||||
|
||||
### Python-Pakete
|
||||
|
||||
```
|
||||
PySide6>=6.6.0
|
||||
vobject>=0.9.6 # vCard-Parsing für CardDAV
|
||||
requests>=2.28.0 # HTTP-Client für CardDAV
|
||||
```
|
||||
|
||||
### pjsua2 (SIP-Stack)
|
||||
|
||||
Auf Arch/Manjaro via AUR:
|
||||
```bash
|
||||
yay -S python-pjproject
|
||||
```
|
||||
|
||||
### Optional
|
||||
|
||||
- `dbus-python`: Vorinstalliert auf KDE-Systemen (für Benachrichtigungen + Click-to-Call)
|
||||
|
||||
## Installation
|
||||
|
||||
### Aus Quellcode (Entwicklung)
|
||||
|
||||
```bash
|
||||
git clone https://git.data-it-solution.de/data/linux.sipsoftphone.git
|
||||
cd linux.sipsoftphone
|
||||
pip install -r requirements.txt
|
||||
python3 main.py
|
||||
```
|
||||
|
||||
### Als AppImage (empfohlen)
|
||||
|
||||
```bash
|
||||
# AppImage bauen
|
||||
./build_appimage.sh
|
||||
|
||||
# Starten
|
||||
chmod +x dist/SipSoftphone-x86_64.AppImage
|
||||
./dist/SipSoftphone-x86_64.AppImage
|
||||
```
|
||||
|
||||
Das AppImage bündelt Python, Qt, pjsua2 und alle Abhängigkeiten. Auf dem Zielsystem wird nichts weiter benötigt.
|
||||
|
||||
## Verwendung
|
||||
|
||||
### Erster Start
|
||||
|
||||
1. App starten → Login-Dialog erscheint
|
||||
2. SIP-Zugangsdaten eingeben (Server, Extension, Passwort, Port)
|
||||
3. Nach erfolgreicher Registrierung: Status wechselt zu grün "Registriert"
|
||||
|
||||
### Anrufe
|
||||
|
||||
| Aktion | Bedienung |
|
||||
|--------|-----------|
|
||||
| Anruf starten | Nummer eingeben + Enter oder "Anrufen"-Button |
|
||||
| Kontakt anrufen | Name im Wählfeld tippen → Vorschlag doppelklicken |
|
||||
| Annehmen | "Annehmen"-Button, F5, oder KDE-Benachrichtigung |
|
||||
| Auflegen | "Auflegen"-Button oder F6 |
|
||||
| Stummschalten | F7 |
|
||||
| Halten | F8 |
|
||||
| DTMF im Gespräch | Zifferntasten drücken (Wählfeld wechselt automatisch) |
|
||||
|
||||
### CardDAV-Kontakte
|
||||
|
||||
Über **Einstellungen → CardDAV** können mehrere Accounts konfiguriert werden (z.B. Nextcloud):
|
||||
|
||||
- URL: `https://nextcloud.example.de/remote.php/dav`
|
||||
- Benutzername + Passwort
|
||||
- Automatischer Sync im Hintergrund
|
||||
|
||||
Kontakte können im Detail-Dialog bearbeitet und per CardDAV zurückgeschrieben werden.
|
||||
|
||||
### Favoriten
|
||||
|
||||
- Im Kontakt-Detail-Dialog: "Favorit"-Button zum Pinnen/Entpinnen
|
||||
- Häufig angerufene Nummern werden automatisch angezeigt
|
||||
- Klick auf Favorit → Anruf wird gestartet
|
||||
|
||||
### Click-to-Call (URI-Handler)
|
||||
|
||||
```bash
|
||||
# tel: URI
|
||||
python3 main.py tel:+49123456789
|
||||
|
||||
# sip: URI
|
||||
python3 main.py sip:200@server
|
||||
|
||||
# Als Standard-Handler registrieren (KDE)
|
||||
xdg-mime default sipwebapp.desktop x-scheme-handler/tel
|
||||
xdg-mime default sipwebapp.desktop x-scheme-handler/sip
|
||||
```
|
||||
|
||||
Bei laufender Instanz wird der Anruf per D-Bus an die bestehende App weitergeleitet.
|
||||
|
||||
### Responsives Layout
|
||||
|
||||
- **Schmal (<650px)**: Nur Wählfeld mit Kontaktsuche
|
||||
- **Breit (≥650px)**: Wählfeld links + Tabs rechts (Favoriten, Verlauf, Kontakte, BLF)
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
SipWebApp/
|
||||
├── main.py # Einstiegspunkt, D-Bus Single-Instance
|
||||
├── requirements.txt # Python-Abhängigkeiten
|
||||
├── sipwebapp.spec # PyInstaller Build-Konfiguration
|
||||
├── build_appimage.sh # AppImage Build-Skript
|
||||
├── sip/
|
||||
│ ├── engine.py # PJSUA2-Endpoint mit Qt-Integration
|
||||
│ ├── account.py # SIP-Account (Registrierung, Presence)
|
||||
│ └── call.py # SIP-Call (Medien, DTMF, Transfer)
|
||||
├── ui/
|
||||
│ ├── hauptfenster.py # Hauptfenster (Layout, Signale, SIP-Events)
|
||||
│ ├── waehlfeld.py # Wähltastatur + Kontaktsuche
|
||||
│ ├── kontakte.py # Kontakttabelle + Detail/Bearbeiten-Dialoge
|
||||
│ ├── anrufliste.py # Anrufverlauf mit Namensauflösung
|
||||
│ ├── favoriten_panel.py # Manuelle + automatische Favoriten
|
||||
│ ├── blf_panel.py # Besetzt-Lampenfeld (SIP Presence)
|
||||
│ ├── einstellungen.py # Einstellungs-Dialog (Audio, CardDAV, BLF)
|
||||
│ └── login_dialog.py # SIP-Login-Dialog
|
||||
├── utils/
|
||||
│ ├── config_manager.py # JSON-Konfiguration (~/.config/sipwebapp/)
|
||||
│ ├── carddav.py # CardDAV-Sync + Write-Back (vobject/requests)
|
||||
│ ├── audio_manager.py # PipeWire/PulseAudio Geräte-Verwaltung
|
||||
│ ├── klingelton.py # Klingelton über separates Audio-Gerät
|
||||
│ └── benachrichtigung.py # KDE D-Bus Desktop-Benachrichtigungen
|
||||
└── resources/
|
||||
├── sipwebapp.desktop # XDG Desktop-Entry
|
||||
├── icons/phone.svg # App-Icon
|
||||
└── sounds/klingelton.wav # Standard-Klingelton
|
||||
```
|
||||
|
||||
## Architektur
|
||||
|
||||
### SIP-Integration (PJSUA2)
|
||||
|
||||
PJSUA2 wird im **Single-Thread-Modus** betrieben:
|
||||
- `threadCnt=0`: Keine eigenen Worker-Threads
|
||||
- `mainThreadOnly=True`: Alle Callbacks im Qt-Hauptthread
|
||||
- `QTimer` pollt `libHandleEvents(0)` alle 50ms
|
||||
|
||||
So werden Race-Conditions und Qt-Crash-Probleme vermieden.
|
||||
|
||||
### Audio-Routing
|
||||
|
||||
PJSUA2 nutzt das `pulse`-Device. PipeWire übernimmt das Routing:
|
||||
- Mikrofon/Lautsprecher: Konfigurierbar in den Einstellungen
|
||||
- Klingelton: Separates Ausgabegerät (z.B. eingebaute Lautsprecher)
|
||||
- Lautstärke: Mikrofon + Lautsprecher über Slider (0-200%)
|
||||
|
||||
### Konfiguration
|
||||
|
||||
Gespeichert unter `~/.config/sipwebapp/config.json`:
|
||||
- SIP-Zugangsdaten (Server, Extension, Passwort)
|
||||
- Audio-Geräte (Aufnahme, Wiedergabe, Klingelton)
|
||||
- CardDAV-Accounts (Multi-Account)
|
||||
- BLF-Extensions
|
||||
- Favoriten (manuell gepinnt)
|
||||
|
||||
## AppImage bauen
|
||||
|
||||
Voraussetzungen auf dem Build-System:
|
||||
- Python 3.14+
|
||||
- `python-pjproject` (AUR)
|
||||
- Internetzugang (lädt appimagetool + pip-Pakete)
|
||||
|
||||
```bash
|
||||
./build_appimage.sh
|
||||
```
|
||||
|
||||
Das Skript:
|
||||
1. Erstellt ein Python-venv mit allen Abhängigkeiten
|
||||
2. Kopiert pjsua2-Bindings + alle nativen Shared Libraries
|
||||
3. Bündelt alles mit PyInstaller
|
||||
4. Packt als AppImage (appimagetool)
|
||||
|
||||
Ergebnis: `dist/SipSoftphone-x86_64.AppImage` (~90 MB)
|
||||
|
||||
## Tastatur-Shortcuts
|
||||
|
||||
| Taste | Aktion |
|
||||
|-------|--------|
|
||||
| F5 | Anruf annehmen |
|
||||
| F6 | Auflegen |
|
||||
| F7 | Stummschalten |
|
||||
| F8 | Halten/Fortsetzen |
|
||||
| Escape | Nummernfeld leeren |
|
||||
| Enter | Nummer wählen |
|
||||
| 0-9, *, # | DTMF-Ziffern (Numpad-kompatibel) |
|
||||
|
||||
## Lizenz
|
||||
|
||||
Proprietär - ALLES WATT LÄUFT (Eduard Wisch)
|
||||
|
|
|
|||
142
build_appimage.sh
Normal file
142
build_appimage.sh
Normal 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
141
main.py
Normal 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
5
requirements.txt
Normal 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
21
resources/icons/phone.svg
Normal 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 |
13
resources/sipwebapp.desktop
Normal file
13
resources/sipwebapp.desktop
Normal 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
|
||||
BIN
resources/sounds/klingelton.wav
Normal file
BIN
resources/sounds/klingelton.wav
Normal file
Binary file not shown.
0
sip/__init__.py
Normal file
0
sip/__init__.py
Normal file
82
sip/account.py
Normal file
82
sip/account.py
Normal 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
152
sip/call.py
Normal 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 (183→200 Neuverhandlung).
|
||||
"""
|
||||
ci = self.getInfo()
|
||||
for i, mi in enumerate(ci.media):
|
||||
if mi.type == pj.PJMEDIA_TYPE_AUDIO and \
|
||||
mi.status == pj.PJSUA_CALL_MEDIA_ACTIVE:
|
||||
try:
|
||||
call_audio = self.getAudioMedia(i)
|
||||
aud_mgr = pj.Endpoint.instance().audDevManager()
|
||||
# Mikrofon → Anruf
|
||||
aud_mgr.getCaptureDevMedia().startTransmit(call_audio)
|
||||
# Anruf → Lautsprecher
|
||||
call_audio.startTransmit(
|
||||
aud_mgr.getPlaybackDevMedia()
|
||||
)
|
||||
except pj.Error:
|
||||
pass
|
||||
|
||||
def onCallTransferRequest(self, prm):
|
||||
"""Eingehende Weiterleitungs-Anfrage."""
|
||||
if self._callback:
|
||||
self._callback("transfer_anfrage", {"ziel": prm.currentTarget})
|
||||
|
||||
def onDtmfDigit(self, prm):
|
||||
"""DTMF-Ton empfangen."""
|
||||
if self._callback:
|
||||
self._callback("dtmf_empfangen", {"digit": prm.digit})
|
||||
|
||||
def dtmf_senden(self, ziffern):
|
||||
"""DTMF-Töne senden (z.B. für IVR-Menüs)."""
|
||||
try:
|
||||
prm = pj.CallSendDtmfParam()
|
||||
prm.digits = ziffern
|
||||
prm.method = pj.PJSUA_DTMF_METHOD_RFC2833
|
||||
self.sendDtmf(prm)
|
||||
except pj.Error as e:
|
||||
if self._callback:
|
||||
self._callback("fehler", {"text": f"DTMF-Fehler: {e}"})
|
||||
|
||||
def halten(self):
|
||||
"""Anruf auf Halten setzen."""
|
||||
try:
|
||||
prm = pj.CallOpParam(True)
|
||||
self.setHold(prm)
|
||||
except pj.Error as e:
|
||||
if self._callback:
|
||||
self._callback("fehler", {"text": f"Hold-Fehler: {e}"})
|
||||
|
||||
def fortsetzen(self):
|
||||
"""Gehaltenen Anruf fortsetzen."""
|
||||
try:
|
||||
prm = pj.CallOpParam(True)
|
||||
prm.opt.flag = pj.PJSUA_CALL_UNHOLD
|
||||
prm.opt.audioCount = 1
|
||||
prm.opt.videoCount = 0
|
||||
self.reinvite(prm)
|
||||
except pj.Error as e:
|
||||
if self._callback:
|
||||
self._callback("fehler", {"text": f"Unhold-Fehler: {e}"})
|
||||
|
||||
def stummschalten(self, stumm):
|
||||
"""Mikrofon stumm schalten / wieder aktivieren.
|
||||
|
||||
Nutzt adjustTxLevel() statt stopTransmit() - zuverlässiger
|
||||
weil es die Conference-Bridge-Verbindung nicht unterbricht.
|
||||
"""
|
||||
try:
|
||||
aud_mgr = pj.Endpoint.instance().audDevManager()
|
||||
if stumm:
|
||||
# TX-Level auf 0 = Stille senden
|
||||
aud_mgr.getCaptureDevMedia().adjustTxLevel(0.0)
|
||||
else:
|
||||
# TX-Level auf 1 = normale Lautstärke
|
||||
aud_mgr.getCaptureDevMedia().adjustTxLevel(1.0)
|
||||
except pj.Error:
|
||||
pass
|
||||
|
||||
def blind_transfer(self, ziel_uri):
|
||||
"""Blinde Weiterleitung an eine andere Nummer."""
|
||||
try:
|
||||
prm = pj.CallOpParam()
|
||||
self.xfer(ziel_uri, prm)
|
||||
except pj.Error as e:
|
||||
if self._callback:
|
||||
self._callback("fehler", {"text": f"Transfer-Fehler: {e}"})
|
||||
|
||||
def attended_transfer(self, anderer_anruf):
|
||||
"""Vermittelte Weiterleitung (Attended Transfer)."""
|
||||
try:
|
||||
prm = pj.CallOpParam()
|
||||
self.xferReplaces(anderer_anruf, prm)
|
||||
except pj.Error as e:
|
||||
if self._callback:
|
||||
self._callback("fehler", {"text": f"Attended-Transfer-Fehler: {e}"})
|
||||
|
||||
def auflegen(self):
|
||||
"""Anruf beenden."""
|
||||
try:
|
||||
prm = pj.CallOpParam()
|
||||
prm.statusCode = pj.PJSIP_SC_DECLINE
|
||||
self.hangup(prm)
|
||||
except pj.Error:
|
||||
pass # Anruf war bereits beendet
|
||||
292
sip/engine.py
Normal file
292
sip/engine.py
Normal 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
127
sipwebapp.spec
Normal 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
0
ui/__init__.py
Normal file
234
ui/anrufliste.py
Normal file
234
ui/anrufliste.py
Normal 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
157
ui/blf_panel.py
Normal 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
482
ui/einstellungen.py
Normal 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
188
ui/favoriten_panel.py
Normal 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
767
ui/hauptfenster.py
Normal 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
863
ui/kontakte.py
Normal 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
130
ui/login_dialog.py
Normal 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
240
ui/waehlfeld.py
Normal 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
0
utils/__init__.py
Normal file
259
utils/audio_manager.py
Normal file
259
utils/audio_manager.py
Normal 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
110
utils/benachrichtigung.py
Normal 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
568
utils/carddav.py
Normal 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("<", "<").replace(">", ">")
|
||||
vcard = vcard.replace("&", "&").replace(""", '"')
|
||||
vcard = vcard.replace(" ", "\r")
|
||||
|
||||
if vcard.startswith("BEGIN:VCARD"):
|
||||
ergebnisse.append({
|
||||
"vcard_text": vcard,
|
||||
"href": href,
|
||||
"etag": etag,
|
||||
})
|
||||
|
||||
return ergebnisse
|
||||
|
||||
def _vcard_parsen(self, vcard_text, account_name="",
|
||||
href="", etag=""):
|
||||
"""Eine vCard in ein Kontakt-Dict umwandeln."""
|
||||
try:
|
||||
card = vobject.readOne(vcard_text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# Name
|
||||
name = ""
|
||||
if hasattr(card, "fn"):
|
||||
name = card.fn.value
|
||||
elif hasattr(card, "n"):
|
||||
n = card.n.value
|
||||
name = f"{n.given} {n.family}".strip()
|
||||
if not name:
|
||||
return None
|
||||
|
||||
# E-Mail
|
||||
email = ""
|
||||
if hasattr(card, "email"):
|
||||
email = card.email.value
|
||||
|
||||
# Firma
|
||||
firma = ""
|
||||
if hasattr(card, "org"):
|
||||
org = card.org.value
|
||||
if isinstance(org, list):
|
||||
firma = org[0] if org else ""
|
||||
else:
|
||||
firma = str(org)
|
||||
|
||||
# Telefonnummern nach Typ gruppieren
|
||||
nummern = {}
|
||||
tel_list = getattr(card, "tel_list", [])
|
||||
if not tel_list and hasattr(card, "tel"):
|
||||
tel_list = [card.tel]
|
||||
|
||||
for tel in tel_list:
|
||||
nummer = self._nummer_bereinigen(tel.value)
|
||||
if not nummer:
|
||||
continue
|
||||
|
||||
typ = self._tel_typ_bestimmen(tel)
|
||||
if typ == "handy" and "handy" not in nummern:
|
||||
nummern["handy"] = nummer
|
||||
elif typ == "geschaeftlich" and "geschaeftlich" not in nummern:
|
||||
nummern["geschaeftlich"] = nummer
|
||||
elif typ == "telefon" and "telefon" not in nummern:
|
||||
nummern["telefon"] = nummer
|
||||
elif typ not in ("handy", "geschaeftlich", "telefon"):
|
||||
if "sonstige" not in nummern:
|
||||
nummern["sonstige"] = nummer
|
||||
|
||||
if not nummern:
|
||||
return None
|
||||
|
||||
# Adresse
|
||||
adresse = ""
|
||||
if hasattr(card, "adr"):
|
||||
adr = card.adr.value
|
||||
teile = []
|
||||
if adr.street:
|
||||
teile.append(adr.street)
|
||||
plz_ort = " ".join(
|
||||
filter(None, [adr.code or "", adr.city or ""]))
|
||||
if plz_ort:
|
||||
teile.append(plz_ort)
|
||||
if adr.region:
|
||||
teile.append(adr.region)
|
||||
if adr.country:
|
||||
teile.append(adr.country)
|
||||
adresse = ", ".join(teile)
|
||||
|
||||
# Geburtstag
|
||||
geburtstag = ""
|
||||
if hasattr(card, "bday"):
|
||||
bday_raw = card.bday.value
|
||||
if isinstance(bday_raw, str):
|
||||
bday_raw = bday_raw.replace("-", "")
|
||||
if len(bday_raw) >= 8:
|
||||
geburtstag = (
|
||||
f"{bday_raw[6:8]}.{bday_raw[4:6]}.{bday_raw[:4]}")
|
||||
else:
|
||||
try:
|
||||
geburtstag = bday_raw.strftime("%d.%m.%Y")
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
|
||||
# Notizen
|
||||
notiz = ""
|
||||
if hasattr(card, "note"):
|
||||
notiz = card.note.value or ""
|
||||
|
||||
# Berufsbezeichnung
|
||||
titel = ""
|
||||
if hasattr(card, "title"):
|
||||
titel = card.title.value or ""
|
||||
|
||||
# Website
|
||||
url_wert = ""
|
||||
if hasattr(card, "url"):
|
||||
url_wert = card.url.value or ""
|
||||
|
||||
# Alle E-Mails
|
||||
emails = []
|
||||
email_list = getattr(card, "email_list", [])
|
||||
if not email_list and hasattr(card, "email"):
|
||||
email_list = [card.email]
|
||||
for em in email_list:
|
||||
if em.value and em.value not in emails:
|
||||
emails.append(em.value)
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"email": email,
|
||||
"emails": emails,
|
||||
"firma": firma,
|
||||
"titel": titel,
|
||||
"nummern": nummern,
|
||||
"adresse": adresse,
|
||||
"geburtstag": geburtstag,
|
||||
"notiz": notiz,
|
||||
"url": url_wert,
|
||||
"quelle": "carddav",
|
||||
"account": account_name,
|
||||
"_href": href,
|
||||
"_etag": etag,
|
||||
}
|
||||
|
||||
def _tel_typ_bestimmen(self, tel):
|
||||
"""vCard TEL-Typ auf unsere Kategorien abbilden."""
|
||||
params = tel.params if hasattr(tel, "params") else {}
|
||||
typen = params.get("TYPE", [])
|
||||
if isinstance(typen, str):
|
||||
typen = [typen]
|
||||
typen_lower = [t.lower() for t in typen]
|
||||
|
||||
if "cell" in typen_lower or "mobile" in typen_lower:
|
||||
return "handy"
|
||||
elif "work" in typen_lower:
|
||||
return "geschaeftlich"
|
||||
elif "home" in typen_lower:
|
||||
return "telefon"
|
||||
elif "voice" in typen_lower:
|
||||
return "telefon"
|
||||
return "sonstige"
|
||||
|
||||
def _nummer_bereinigen(self, nummer):
|
||||
"""Telefonnummer bereinigen."""
|
||||
if not nummer:
|
||||
return ""
|
||||
return re.sub(r"[^\d+*#]", "", nummer)
|
||||
204
utils/config_manager.py
Normal file
204
utils/config_manager.py
Normal 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
108
utils/klingelton.py
Normal 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()
|
||||
Loading…
Reference in a new issue