Add English localization and MIT-licensed public release documentation
Tests / unit (de, 3.10) (push) Has been cancelled
Tests / unit (de, 3.14) (push) Has been cancelled
Tests / unit (en, 3.14) (push) Has been cancelled
Tests / unit (en, 3.10) (push) Has been cancelled

This commit is contained in:
Justin
2026-09-05 21:02:14 +02:00
parent 3579667c6e
commit dd0809e877
31 changed files with 1261 additions and 377 deletions
+24
View File
@@ -0,0 +1,24 @@
---
name: Bug or compatibility report
about: Report a reproducible problem or a tested hardware combination
---
**System**
- Distribution and version:
- Desktop environment:
- App version or commit:
- BTD 700 firmware:
- Headphones and firmware:
**What happened?**
Describe your steps, expected result and actual result.
**Demo mode**
Does the issue also occur with `./run.sh --demo`?
**Checks performed**
List which controls you tested and whether this was a real dongle or a simulation.
**Logs or screenshots**
Remove passwords, serial numbers and personal broadcast names. Do not upload
vendor executables or firmware images.
+31
View File
@@ -0,0 +1,31 @@
name: Tests
on:
push:
branches: [main]
tags: ['v*']
pull_request:
permissions:
contents: read
jobs:
unit:
runs-on: ubuntu-24.04
strategy:
matrix:
python-version: ['3.10', '3.14']
language: [en, de]
env:
BTD700_LANGUAGE: ${{ matrix.language }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: ${{ matrix.python-version }}
- name: Run tests without USB access
run: python -m unittest discover -s tests -v
- name: Check CLI startup
run: python -m btd700 --language en --demo status
+17
View File
@@ -0,0 +1,17 @@
# Changelog
## 0.2.0 — 2026-09-05
- English and German app, tray, CLI help and error messages, selected by system
locale or `--language` / `BTD700_LANGUAGE`.
- English README with genuine app screenshots and a separate German guide.
- MIT license, AI-development disclosure and explicit no-warranty statement.
- Public installation, uninstall, contribution and compatibility documentation.
- Automated protocol/localization tests on GitHub; no hardware writes in CI.
## 0.1.0 — 2026-09-05
- Initial GTK 4 / libadwaita control app and StatusNotifier tray menu.
- Audio modes, codec/transport selection, connection and Auracast settings.
- Independent HID control protocol implementation; no firmware updater.
- Real-device status reads and demo-backed UI verification on Bazzite/GNOME.
+61
View File
@@ -0,0 +1,61 @@
# Contributing
This is an experimental, AI-developed project. Independent review and real-world
compatibility reports are particularly useful. English and German contributions
are welcome.
## Report a problem
Open a [GitHub issue](https://github.com/justin-eckenweber/btd700linux/issues) with:
- Distribution, desktop environment and application version/commit.
- BTD 700 firmware version and headphone model/firmware, if known.
- The steps you took, what you expected, and what actually happened.
- Whether the issue also occurs in `./run.sh --demo`.
- Relevant terminal output after removing private information.
Do not post broadcast passwords, Bluetooth/device identifiers, original vendor
executables or firmware images. `devices` includes a serial number: redact it.
The regular `status` output excludes passwords, but its broadcast name can still
contain personal information. Never say a hardware write is tested if it was
only simulated.
## Make a change
1. Fork the repository and create a focused branch.
2. Keep firmware download, update and bootloader functionality out of scope.
3. Preserve the control-report allowlist and validate input before writing to USB.
4. Keep blocking USB operations off the GTK main loop.
5. Add a meaningful regression test for protocol or behavior changes.
6. Run the checks below and explain what was tested in your pull request.
```bash
BTD700_LANGUAGE=en python3 -m unittest discover -s tests -v
BTD700_LANGUAGE=de python3 -m unittest discover -s tests -v
# Optional, with GTK libraries and a working desktop tray:
BTD700_LANGUAGE=en python3 tools/check_gui.py
BTD700_LANGUAGE=de python3 tools/check_gui.py
```
The GUI test uses a simulated device and D-Bus menu events, not the real USB dongle.
Hardware tests must be deliberate: the optional `tools/check_hardware.py --run`
changes settings and can interrupt audio. Do not add it to unattended CI.
## Translations and screenshots
`btd700/i18n.py` contains the English/German catalog. German source strings are
retained from the first version. Use `tr()` for user-facing messages; preserve
format placeholders and keep numeric protocol values independent of translation.
The CLI accepts `--language en|de`; `BTD700_LANGUAGE` also controls test runs.
Regenerate genuine English screenshots with `python3 tools/capture_screenshots.py`.
The helper uses demo data and writes to `docs/screenshots/`. Inspect the resulting
images before committing them. Do not substitute generated mockups for screenshots.
## Licensing and AI disclosure
Contributions are accepted under this project's MIT license. Do not submit code
or assets you are not permitted to share. Explain any AI-generated portions of
substantial contributions and review them before submission. The project's
AI-development and no-warranty notices remain part of its public documentation.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Justin Eckenweber and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+6
View File
@@ -0,0 +1,6 @@
include README.md README.de.md LICENSE THIRD_PARTY_NOTICES.md CONTRIBUTING.md CHANGELOG.md
include run.sh install.py
recursive-include packaging *.svg *.rules
recursive-include docs *.md *.png
recursive-include tools *.py
recursive-include tests *.py
+203
View File
@@ -0,0 +1,203 @@
# BTD 700 Control für Linux
[English](README.md) · [Quellcode v0.2.0 herunterladen](https://github.com/justin-eckenweber/btd700linux/archive/refs/tags/v0.2.0.zip) · [Fehler melden](https://github.com/justin-eckenweber/btd700linux/issues)
Native GTK-4-App zur Steuerung des **Sennheiser BTD 700**, mit einem Menü im
Infobereich von GNOME/KDE. Eigenständige Implementierung des HID-Steuerprotokolls,
analysiert anhand der offiziellen Windows-Anwendung **Dongle Control 1.0.5**.
**Keine Firmware-Updates, Firmware-Downloads oder Update-Schnittstellen.** Die App
benötigt weder Windows/Wine noch eine Netzwerkverbindung. Sennheiser-Programmcode,
Logos und Firmware werden nicht mitgeliefert. Dies ist ein unabhängiges Projekt.
> **Mit KI entwickelt · Experimentell · Ohne Gewähr**
>
> Dieses Projekt entstand mit KI-Unterstützung durch OpenAI Codex, einschließlich
> Protokollanalyse, Implementierung, Dokumentation und Tests. Es gibt **keine
> Garantie, dass es mit deinem Dongle, deiner Firmware oder deinem Linux-System
> funktioniert**. Nutzung auf eigenes Risiko; die Software wird im vorhandenen
> Zustand ohne Gewährleistung unter der [MIT-Lizenz](LICENSE) bereitgestellt.
> Das Projekt ist unabhängig von Sennheiser und Sonova.
## Bilder der App
Echte Fensteraufnahmen unter Linux mit ausdrücklich simulierten Demo-Daten.
Die Bilder zeigen die englische Oberfläche; Deutsch ist ebenfalls enthalten.
<p>
<img src="docs/screenshots/main-window.png" width="46%" alt="Hauptfenster mit Modus, Codec und Verbindung">
<img src="docs/screenshots/auracast-settings.png" width="46%" alt="Auracast-Einstellungen und Infobereich-Optionen">
</p>
## Herunterladen
```bash
git clone https://github.com/justin-eckenweber/btd700linux.git
cd btd700linux
```
Alternativ die oben verlinkte ZIP-Datei entpacken und `bash run.sh` im Projektordner
ausführen. Die aktuelle Version wird als Quellcode verteilt; es gibt noch kein
AppImage, Flatpak oder eigenständiges Binärpaket.
## Starten
```bash
./run.sh
```
Das Fenster zeigt den echten Dongle-Status. Beim Schließen bleibt die App im
Infobereich aktiv. „Beenden“ im Symbolmenü oder das Beenden-Symbol im Fenster
beendet die App vollständig. Wenn der Desktop keinen Infobereich anbietet,
beendet das Schließen des Fensters die App.
```bash
python3 install.py # Anwendungsmenü-Eintrag für diesen Projektordner
./run.sh --background # Nur im Infobereich starten
./run.sh --demo # Simulierter Dongle, keinerlei USB-Zugriff
```
Mit `python3 install.py --uninstall` entfernst du den Menüeintrag und Autostart.
Den Projektordner nach der Installation behalten. „Beim Anmelden starten“ in der
App aktiviert bei Bedarf den Autostart im Infobereich. Er ist standardmäßig aus.
## Sprache
Die App, das Infobereich-Menü, CLI-Hilfe und Fehlermeldungen verwenden auf deutschen
Systemen Deutsch und sonst Englisch. Bei Bedarf explizit festlegen:
```bash
./run.sh --language de
./run.sh --language en
BTD700_LANGUAGE=de ./run.sh --background
```
Eine bereits laufende Instanz vorher über „Beenden“ schließen. Ein weiterer Start
öffnet sonst das bestehende Fenster in seiner bisherigen Sprache.
## Steuerung
- **Standard / Gaming / Auracast** direkt im Fenster oder Symbolmenü wählen.
- Aktiven **Codec, Auflösung, Abtastrate und Verbindungsstatus** ablesen.
- **Codecs** auswählen, die der Dongle für die aktuelle Verbindung meldet.
Die Auswahl steht im Standard-Modus zur Verfügung; Gaming setzt seinen Codec selbst.
- **Bluetooth Classic / LE Audio / Automatisch** auswählen, entsprechend den
vom angeschlossenen Kopfhörer gemeldeten Möglichkeiten.
- Bereits gekoppelte Kopfhörer **verbinden / trennen**.
- **Auracast:** Name, Passwortschutz, öffentliche Auffindbarkeit und Qualität.
Das Symbolmenü bietet Schalter und Untermenüs; für Name/Passwort öffnet es die Eingabe im Fenster.
- **Werkseinstellungen** nur nach ausdrücklicher Bestätigung im Dialog. Dabei
werden die Kopplungen gelöscht. Die App führt niemals automatisch einen Reset aus.
Das Passwort wird verdeckt eingegeben und weder gespeichert noch protokolliert.
Ein leeres Passwortfeld behält das bisherige Passwort; „Passwortschutz“ ausschalten
sendet ohne Passwortschutz. Der Name erlaubt 416 ASCII-Buchstaben/Ziffern mit
inneren Leerzeichen; ein leerer Name stellt den Gerätenamen wieder her. Passwörter
verwenden 416 ASCII-Buchstaben/Ziffern, entsprechend den Original-Eingabegrenzen.
**„Öffentlich auffindbar“ ist die Auracast-Ankündigung, kein Ein/Aus-Schalter für
Audio.** Zum Starten/Beenden der Übertragung den Audiomodus umstellen.
Die angezeigten Bit-/kHz-Werte stammen vom Dongle. Die USB-Abtastrate wird vom
Audiosystem (z. B. PipeWire) bestimmt und ist kein hier nachgewiesener Stellbefehl.
Koppeln erfolgt über die Taste am Dongle; „Verbinden“ versucht die bestehende Kopplung.
## Voraussetzungen
Python ≥ 3.10, PyGObject, GTK ≥ 4.10, libadwaita ≥ 1.5 und libdbusmenu mit
GObject-Introspection. Auf dem hier verwendeten Bazzite sind die benötigten
Bibliotheken bereits vorhanden; keine zusätzlichen Pakete waren erforderlich.
Für andere Systeme typischerweise:
```bash
# Fedora (klassisch, veränderliches System)
sudo dnf install python3-gobject gtk4 libadwaita libdbusmenu
# Debian / Ubuntu
sudo apt install python3-gi gir1.2-gtk-4.0 gir1.2-adw-1 gir1.2-dbusmenu-glib-0.4
```
Auf GNOME muss der Desktop StatusNotifier/AppIndicator-Symbole unterstützen;
beispielsweise mit „AppIndicator and KStatusNotifierItem Support“. Auf dem
getesteten GNOME ist derselbe Dienst bereits für JetBrains Toolbox und andere Apps aktiv.
KDE stellt diese Schnittstelle über seinen Infobereich bereit.
Fehlt die Menü-Bibliothek, bleibt das Fenster nutzbar. Das Terminalinterface
benötigt ausschließlich die Python-Standardbibliothek.
## USB-Zugriff
Es wird ausschließlich die passende HID-Steuerschnittstelle des Geräts
`3542:3001` geöffnet, identifiziert über ihren Report-Deskriptor. Die zweite
Schnittstelle für Updates bleibt geschlossen. Audiotreiber werden nicht getrennt.
Falls die App fehlende Berechtigungen meldet:
```bash
sudo install -m 0644 packaging/70-btd700-control.rules /etc/udev/rules.d/70-btd700-control.rules
sudo udevadm control --reload-rules
```
Anschließend den Dongle aus- und wieder einstecken. Die Regel beschränkt den Zugriff
auf die Steuerungsschnittstelle und den aktiven lokalen Benutzer. Die App nicht
als root starten. Auf dem getesteten System ist bereits Zugriff vorhanden.
## Terminal
```bash
./run.sh devices
./run.sh status
./run.sh mode standard
./run.sh mode gaming
./run.sh mode standard --transport auto
./run.sh codec adaptive
./run.sh disconnect
./run.sh connect
./run.sh auracast --name 'Wohnzimmer' --quality high --public on
./run.sh auracast --password --encryption on # Passwort verdeckt abfragen
./run.sh mode auracast
```
`status` gibt JSON aus und liest kein Passwort. Nur eine Instanz darf den Dongle
halten: Vor CLI-Befehlen eine laufende GUI über „Beenden“ schließen. Mehrere Dongles
lassen sich mit `--device /dev/hidrawN` vor dem Unterbefehl auswählen.
## Verifikation
```bash
python3 -m unittest discover -s tests -v
python3 tools/check_gui.py
```
`check_gui.py` verwendet **ausschließlich einen simulierten Dongle**, öffnet eine
Demo-Oberfläche in der bestehenden Sitzung und prüft die Menüaktionen über den
echten D-Bus-Menüexport. Es speichert Fensterbilder unter `/tmp/btd700-gui-test*.png`.
Nachgewiesen am angeschlossenen BTD 700 mit Firmware **3.11.0**:
- Erkennung und Öffnen der korrekten HID-Schnittstelle ohne Treiberwechsel.
- Echte Antworten auf Status-, Modus-, Codec-, Audioqualitäts-, LE-Zustands-,
Transport-, Auracast-Konfigurations-, Namens- und Firmwareversionsabfragen.
- Anzeige im GTK-Fenster und Registrierung im GNOME-Infobereich.
Schreibbefehle wurden aus der Originalsoftware rekonstruiert und in Simulation
geprüft. Der Nutzer hat die Funktion der App am eigenen Dongle bestätigt. Eine
Aufschlüsselung aller dabei getesteten Funktionen liegt nicht vor; der automatisierte
Hardware-Umschalttest wurde bisher nicht ausgeführt.
Das vorbereitete `tools/check_hardware.py --run` schaltet Modi und Codec um,
prüft Auracast-Werte und versucht anschließend die gesicherten Originalwerte
wiederherzustellen. Es führt weder Firmware-Updates noch einen Werksreset aus.
Den Hardwaretest nur bewusst starten: Er unterbricht kurz die Audiowiedergabe
und verändert vorübergehend gespeicherte Einstellungen einschließlich des Passworts. Bei USB-Trennung während
einer Änderung ist eine Wiederherstellung nicht garantiert.
Protokoll, Herkunft und bekannte Grenzen: [docs/PROTOCOL.md](docs/PROTOCOL.md).
## Lizenz und Mitarbeit
Eigener Projektcode und Dokumentation stehen unter [MIT](LICENSE). Drittsoftware
und Herstellerkennzeichen behalten ihre eigenen Rechte; siehe
[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Beiträge, Übersetzungen und
Kompatibilitätsberichte sind willkommen: [CONTRIBUTING.md](CONTRIBUTING.md).
+201 -100
View File
@@ -1,101 +1,186 @@
# BTD 700 Control für Linux <p align="center">
<img src="packaging/btd700-control.svg" width="80" alt="BTD 700 Control icon">
</p>
Native GTK-4-App zur Steuerung des **Sennheiser BTD 700**, mit einem Menü im <h1 align="center">BTD 700 Control for Linux</h1>
Infobereich von GNOME/KDE. Eigenständige Implementierung des HID-Steuerprotokolls,
analysiert anhand der offiziellen Windows-Anwendung **Dongle Control 1.0.5**.
**Keine Firmware-Updates, Firmware-Downloads oder Update-Schnittstellen.** Die App <p align="center">Your dongle. Your codecs. A native Linux app and system tray menu.</p>
benötigt weder Windows/Wine noch eine Netzwerkverbindung. Sennheiser-Programmcode,
Logos und Firmware werden nicht mitgeliefert. Dies ist ein unabhängiges Projekt.
## Starten <p align="center">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
<img src="https://img.shields.io/badge/platform-Linux-informational" alt="Linux">
<img src="https://img.shields.io/badge/UI-English%20%2F%20Deutsch-green" alt="English and German interface">
<a href="https://github.com/justin-eckenweber/btd700linux/actions/workflows/tests.yml"><img src="https://github.com/justin-eckenweber/btd700linux/actions/workflows/tests.yml/badge.svg" alt="Tests"></a>
</p>
<p align="center">
<a href="#install">Install</a> ·
<a href="https://github.com/justin-eckenweber/btd700linux/archive/refs/tags/v0.2.0.zip">Download source v0.2.0</a> ·
<a href="README.de.md">Deutsche Anleitung</a> ·
<a href="https://github.com/justin-eckenweber/btd700linux/issues">Report an issue</a>
</p>
An independent, open-source control app for the **Sennheiser BTD 700** USB Bluetooth
transmitter. Choose audio modes and codecs, manage the headphone connection, and
configure Auracast without Windows or Wine. Built with Python, GTK 4 and libadwaita.
**Controls only. No firmware updates, firmware downloads or update mode.** Once
installed, the app works offline and does not send telemetry.
> **AI-developed · Experimental · No warranty**
>
> This project was developed with AI (OpenAI Codex), including protocol research,
> implementation, documentation and tests. It is an independent community project,
> not official Sennheiser software. **There is no guarantee that it will work with
> your hardware, firmware or Linux setup.** It is provided as is, without warranty,
> under the [MIT license](LICENSE). Use it at your own risk.
## Screenshots
Actual application windows on Linux, using **fictional demo data**. No mockups or
AI-generated images. The app follows your desktop's light/dark appearance.
<p align="center">
<img src="docs/screenshots/main-window.png" width="46%" alt="English app: active codec, audio mode and headphone connection">
<img src="docs/screenshots/auracast-settings.png" width="46%" alt="English app: Auracast name, password protection, broadcast quality and startup settings">
</p>
## What you can control
| Feature | Window | System tray |
|---|:---:|:---:|
| Standard, Gaming and Auracast modes | ✓ | ✓ |
| Active codec, audio format and connection status | ✓ | ✓ |
| Codec selection supported by the current connection | ✓ | ✓ |
| Bluetooth Classic, LE Audio or automatic transport | ✓ | ✓ |
| Connect / disconnect previously paired headphones | ✓ | ✓ |
| Auracast discovery, quality and password protection | ✓ | ✓ |
| Auracast name and password | ✓ | Opens the editor |
| Factory reset, with confirmation | ✓ | Opens confirmation |
The tray menu stays available when you close the window. On GNOME, it uses the
same StatusNotifier/AppIndicator mechanism as apps such as Discord and JetBrains
Toolbox. Use **Quit** to close the app completely. Without a tray host, closing the
window quits the app instead.
## Install
Version **0.2.0** is distributed as source. You do not need to compile the app or
install Python packages with pip. There is currently no AppImage, Flatpak or
self-contained binary download.
### 1. Install the system libraries
Requirements: **Python 3.10+**, PyGObject, **GTK 4.10+**, **libadwaita 1.5+** and
libdbusmenu with GObject introspection. A graphical desktop session is needed for
the window and tray; the CLI only needs Python's standard library.
**Ubuntu 24.04+ / Debian with sufficiently recent GTK and libadwaita:**
```bash ```bash
sudo apt install git python3 python3-gi gir1.2-gtk-4.0 gir1.2-adw-1 gir1.2-dbusmenu-glib-0.4
```
**Fedora Workstation:**
```bash
sudo dnf install git python3 python3-gobject gtk4 libadwaita libdbusmenu
```
**Bazzite / other immutable desktops:** try running the app first. The tested
Bazzite system already included every required library. The Fedora command above
is for a mutable Fedora installation, not an instruction to layer packages on Bazzite.
On **GNOME**, enable a StatusNotifier/AppIndicator extension if you do not already
have a working tray, for example
[AppIndicator and KStatusNotifierItem Support](https://extensions.gnome.org/extension/615/appindicator-support/).
KDE Plasma provides a tray host. Other desktops may work but have not been tested.
Package references: [Fedora libdbusmenu](https://packages.fedoraproject.org/pkgs/libdbusmenu/libdbusmenu/),
[Ubuntu introspection package](https://packages.ubuntu.com/noble/gir1.2-dbusmenu-glib-0.4).
### 2. Download and run
```bash
git clone https://github.com/justin-eckenweber/btd700linux.git
cd btd700linux
./run.sh ./run.sh
``` ```
Das Fenster zeigt den echten Dongle-Status. Beim Schließen bleibt die App im Or [download the v0.2.0 source ZIP](https://github.com/justin-eckenweber/btd700linux/archive/refs/tags/v0.2.0.zip),
Infobereich aktiv. „Beenden“ im Symbolmenü oder das Beenden-Symbol im Fenster extract it, and run `bash run.sh` inside the extracted folder.
beendet die App vollständig. Wenn der Desktop keinen Infobereich anbietet,
beendet das Schließen des Fensters die App. Want to explore without touching any hardware?
```bash ```bash
python3 install.py # Anwendungsmenü-Eintrag für diesen Projektordner ./run.sh --demo
./run.sh --background # Nur im Infobereich starten
./run.sh --demo # Simulierter Dongle, keinerlei USB-Zugriff
``` ```
Den Projektordner nach der Installation behalten. „Beim Anmelden starten“ in der ### 3. Add it to your application menu
App aktiviert bei Bedarf den Autostart im Infobereich. Er ist standardmäßig aus.
## Steuerung
- **Standard / Gaming / Auracast** direkt im Fenster oder Symbolmenü wählen.
- Aktiven **Codec, Auflösung, Abtastrate und Verbindungsstatus** ablesen.
- **Codecs** auswählen, die der Dongle für die aktuelle Verbindung meldet.
Die Auswahl steht im Standard-Modus zur Verfügung; Gaming setzt seinen Codec selbst.
- **Bluetooth Classic / LE Audio / Automatisch** auswählen, entsprechend den
vom angeschlossenen Kopfhörer gemeldeten Möglichkeiten.
- Bereits gekoppelte Kopfhörer **verbinden / trennen**.
- **Auracast:** Name, Passwortschutz, öffentliche Auffindbarkeit und Qualität.
Das Symbolmenü bietet Schalter und Untermenüs; für Name/Passwort öffnet es die Eingabe im Fenster.
- **Werkseinstellungen** nur nach ausdrücklicher Bestätigung im Dialog. Dabei
werden die Kopplungen gelöscht. Die App führt niemals automatisch einen Reset aus.
Das Passwort wird verdeckt eingegeben und weder gespeichert noch protokolliert.
Ein leeres Passwortfeld behält das bisherige Passwort; „Passwortschutz“ ausschalten
sendet ohne Passwortschutz. Der Name erlaubt 416 ASCII-Buchstaben/Ziffern mit
inneren Leerzeichen; ein leerer Name stellt den Gerätenamen wieder her. Passwörter
verwenden 416 ASCII-Buchstaben/Ziffern, entsprechend den Original-Eingabegrenzen.
**„Öffentlich auffindbar“ ist die Auracast-Ankündigung, kein Ein/Aus-Schalter für
Audio.** Zum Starten/Beenden der Übertragung den Audiomodus umstellen.
Die angezeigten Bit-/kHz-Werte stammen vom Dongle. Die USB-Abtastrate wird vom
Audiosystem (z. B. PipeWire) bestimmt und ist kein hier nachgewiesener Stellbefehl.
Koppeln erfolgt über die Taste am Dongle; „Verbinden“ versucht die bestehende Kopplung.
## Voraussetzungen
Python ≥ 3.10, PyGObject, GTK ≥ 4.10, libadwaita ≥ 1.5 und libdbusmenu mit
GObject-Introspection. Auf dem hier verwendeten Bazzite sind die benötigten
Bibliotheken bereits vorhanden; keine zusätzlichen Pakete waren erforderlich.
Für andere Systeme typischerweise:
```bash ```bash
# Fedora (klassisch, veränderliches System) python3 install.py
sudo dnf install python3-gobject gtk4 libadwaita libdbusmenu
# Debian / Ubuntu
sudo apt install python3-gi gir1.2-gtk-4.0 gir1.2-adw-1 gir1.2-dbusmenu-glib-0.4
``` ```
Auf GNOME muss der Desktop StatusNotifier/AppIndicator-Symbole unterstützen; Open **BTD 700 Control** from the application menu. Keep the downloaded project
beispielsweise mit „AppIndicator and KStatusNotifierItem Support“. Auf dem folder in place: the launcher points to it. Enable **Start at login** inside the
getesteten GNOME ist derselbe Dienst bereits für JetBrains Toolbox und andere Apps aktiv. app if you want it to start quietly in the tray. Autostart is off by default.
KDE stellt diese Schnittstelle über seinen Infobereich bereit.
Fehlt die Menü-Bibliothek, bleibt das Fenster nutzbar. Das Terminalinterface ```bash
benötigt ausschließlich die Python-Standardbibliothek. ./run.sh --background # Start directly in the tray
python3 install.py --uninstall # Remove launcher and autostart entry
```
## USB-Zugriff ### USB permissions
Es wird ausschließlich die passende HID-Steuerschnittstelle des Geräts If the app reports that USB access is denied:
`3542:3001` geöffnet, identifiziert über ihren Report-Deskriptor. Die zweite
Schnittstelle für Updates bleibt geschlossen. Audiotreiber werden nicht getrennt.
Falls die App fehlende Berechtigungen meldet:
```bash ```bash
sudo install -m 0644 packaging/70-btd700-control.rules /etc/udev/rules.d/70-btd700-control.rules sudo install -m 0644 packaging/70-btd700-control.rules /etc/udev/rules.d/70-btd700-control.rules
sudo udevadm control --reload-rules sudo udevadm control --reload-rules
``` ```
Anschließend den Dongle aus- und wieder einstecken. Die Regel beschränkt den Zugriff Unplug and reconnect the dongle afterwards. The rule grants the active local user
auf die Steuerungsschnittstelle und den aktiven lokalen Benutzer. Die App nicht access to the **control interface** of USB device `3542:3001`; it excludes the second
als root starten. Auf dem getesteten System ist bereits Zugriff vorhanden. interface used for updates. Run the app as your normal user, not with `sudo`.
No audio driver is detached or replaced.
## Terminal ## English and German
The app, tray, CLI help and application error messages follow the system language:
German for German locales, English otherwise. You can override this explicitly:
```bash
./run.sh --language en
./run.sh --language de
BTD700_LANGUAGE=en ./run.sh --background
```
Quit an already running instance before changing the language; launching it again
normally brings the existing window to the front.
## Using the controls
- **Codecs:** the dongle reports the choices available for the current connection.
Select a codec in Standard mode; Gaming mode manages its own codec. A codec
supported in principle is not necessarily offered with every pair of headphones.
- **Audio format:** bit depth and sample rate are read from the dongle. Set the USB
output sample rate in your audio system, such as PipeWire; this app does not
force 96 kHz or claim bit-perfect or lossless transmission.
- **Pairing:** use the physical dongle button to pair headphones. **Connect** reuses
an existing pairing.
- **Auracast:** select Auracast mode to start broadcasting. **Publicly discoverable**
controls advertising/discovery, not whether audio is transmitted.
- **Name and password:** 416 ASCII letters/digits; names may contain internal
spaces. An empty name restores the device's default name. Leave the password
field blank to keep the current password; turn off Password protection to
broadcast without it. Passwords are not stored on disk or included in status output.
- **Factory reset:** deletes saved settings and pairings. It always requires
confirmation in the UI.
## Command line
Quit the tray app before using the CLI: one instance owns the dongle at a time.
```bash ```bash
./run.sh devices ./run.sh devices
@@ -106,43 +191,59 @@ als root starten. Auf dem getesteten System ist bereits Zugriff vorhanden.
./run.sh codec adaptive ./run.sh codec adaptive
./run.sh disconnect ./run.sh disconnect
./run.sh connect ./run.sh connect
./run.sh auracast --name 'Wohnzimmer' --quality high --public on ./run.sh auracast --name 'Living Room' --quality high --public on
./run.sh auracast --password --encryption on # Passwort verdeckt abfragen ./run.sh auracast --password --encryption on # Hidden password prompt
./run.sh mode auracast ./run.sh mode auracast
``` ```
`status` gibt JSON aus und liest kein Passwort. Nur eine Instanz darf den Dongle `status` prints JSON without reading a password. With multiple dongles, place
halten: Vor CLI-Befehlen eine laufende GUI über „Beenden“ schließen. Mehrere Dongles `--device /dev/hidrawN` before the subcommand; use the path reported by `devices`.
lassen sich mit `--device /dev/hidrawN` vor dem Unterbefehl auswählen. The numeric fields in JSON retain their protocol meanings across languages.
## Verifikation ## Compatibility and limitations
Developed and tested on **Bazzite / GNOME with a BTD 700 running firmware 3.11.0**.
The owner confirmed that the app works with their dongle. That is not a complete
compatibility matrix or a guarantee for other firmware and receivers.
- Real hardware: device discovery, state/configuration reads, native window and
tray integration verified.
- Automated tests: protocol parsing, allowed commands, readback, input validation,
localization and demo-backed GTK/D-Bus menu interactions.
- Still needed: systematic hardware write tests, more receivers/firmware versions,
other desktops, and independent testing by more users.
- BTD 600 and other Sennheiser devices are **not supported** by this driver.
- Reconfiguring modes or broadcasts can interrupt audio. Multi-step settings are
not atomic; unplugging the dongle midway can leave partially applied changes.
[Validation notes](docs/VALIDATION.md) · [Protocol and research sources](docs/PROTOCOL.md)
## Contribute
Bug reports, compatibility reports, translations and reviewed patches are welcome.
See [CONTRIBUTING.md](CONTRIBUTING.md) for what to include and how to run tests.
Please do not attach broadcast passwords, device serial numbers, original vendor
executables or firmware images.
```bash ```bash
python3 -m unittest discover -s tests -v python3 -m unittest discover -s tests -v
python3 tools/check_gui.py BTD700_LANGUAGE=en python3 tools/check_gui.py # Desktop session; simulated dongle
BTD700_LANGUAGE=de python3 tools/check_gui.py
python3 tools/capture_screenshots.py # Regenerate the README screenshots
``` ```
`check_gui.py` verwendet **ausschließlich einen simulierten Dongle**, öffnet eine The separate `tools/check_hardware.py --run` is an opt-in hardware test that changes
Demo-Oberfläche in der bestehenden Sitzung und prüft die Menüaktionen über den settings, including the broadcast password, and attempts to restore the originals.
echten D-Bus-Menüexport. Es speichert Fensterbilder unter `/tmp/btd700-gui-test*.png`. It is never run by CI or the demo tests. Read it before deliberately using it.
Nachgewiesen am angeschlossenen BTD 700 mit Firmware **3.11.0**: ## License and independence
- Erkennung und Öffnen der korrekten HID-Schnittstelle ohne Treiberwechsel. The project's own code and documentation are licensed under **[MIT](LICENSE)**.
- Echte Antworten auf Status-, Modus-, Codec-, Audioqualitäts-, LE-Zustands-, The AI-development and no-warranty notice above is intentional; please keep it
Transport-, Auracast-Konfigurations-, Namens- und Firmwareversionsabfragen. visible when describing this project.
- Anzeige im GTK-Fenster und Registrierung im GNOME-Infobereich.
Schreibbefehle wurden aus der Originalsoftware rekonstruiert und in Simulation Sennheiser, BTD 700, aptX and Auracast names identify the compatible product and
geprüft. Der Nutzer hat die Funktion der App am eigenen Dongle bestätigt. Eine technologies. This project is not affiliated with, endorsed by or supported by
Aufschlüsselung aller dabei getesteten Funktionen liegt nicht vor; der automatisierte Sennheiser, Sonova or Qualcomm. No vendor code, firmware or logos are included.
Hardware-Umschalttest wurde bisher nicht ausgeführt. System libraries and original vendor software retain their own licenses;
see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
Das vorbereitete `tools/check_hardware.py --run` schaltet Modi und Codec um,
prüft Auracast-Werte und versucht anschließend die gesicherten Originalwerte
wiederherzustellen. Es führt weder Firmware-Updates noch einen Werksreset aus.
Den Hardwaretest nur bewusst starten: Er unterbricht kurz die Audiowiedergabe
und verändert vorübergehend gespeicherte Einstellungen einschließlich des Passworts. Bei USB-Trennung während
einer Änderung ist eine Wiederherstellung nicht garantiert.
Protokoll, Herkunft und bekannte Grenzen: [docs/PROTOCOL.md](docs/PROTOCOL.md).
+21
View File
@@ -0,0 +1,21 @@
# Third-party notices
The MIT license in this repository covers this project's own implementation,
documentation and artwork. It does not relicense third-party software or trademarks.
- Python, PyGObject, GTK, libadwaita and libdbusmenu are provided by your system,
not bundled here. Their respective licenses continue to apply.
- The control protocol was reconstructed by examining the official Windows
Sennheiser Dongle Control 1.0.5 application. The vendor application, decompiled
sources, firmware and vendor graphics are not included. Provenance and references
are recorded in [docs/PROTOCOL.md](docs/PROTOCOL.md).
- The optional extraction helper reads the documented .NET bundle format. Its
references are linked in the protocol documentation. Extracted assemblies are
private analysis material, not part of this distribution.
- Sennheiser, Sonova, Qualcomm, BTD 700, aptX and Auracast names are used to describe
compatibility and functionality. Their use does not imply endorsement.
- The app icon is original project artwork. README screenshots capture this app
with demo data, using system-provided GTK/libadwaita styling and icons.
The project was developed using AI (OpenAI Codex). The source and tests are available
for review; passing tests does not guarantee operation with any particular device.
+1 -1
View File
@@ -1,3 +1,3 @@
"""Independent Linux controls for the Sennheiser BTD 700.""" """Independent Linux controls for the Sennheiser BTD 700."""
__version__ = "0.1.0" __version__ = "0.2.0"
+25 -18
View File
@@ -1,3 +1,4 @@
from .i18n import tr, set_language
import argparse import argparse
import getpass import getpass
import json import json
@@ -5,28 +6,34 @@ import sys
def main(): def main():
parser = argparse.ArgumentParser(description='Native Linux-Steuerung für den BTD 700; ohne Firmware-Updates.') language_parser = argparse.ArgumentParser(add_help=False)
parser.add_argument('--device', help='Steuergerät aus dem Befehl devices, z. B. /dev/hidraw6') language_parser.add_argument('--language', choices=['en', 'de'])
parser.add_argument('--background', action='store_true', help='Nur im Infobereich starten') language_args, _ = language_parser.parse_known_args()
parser.add_argument('--demo', action='store_true', help='Vorführmodus ohne USB-Zugriff') if language_args.language:
set_language(language_args.language)
parser = argparse.ArgumentParser(description=tr('Native Linux-Steuerung für den BTD 700; ohne Firmware-Updates.'))
parser.add_argument('--language', choices=['en', 'de'], help=tr('Sprache der App (Standard: Systemsprache)'))
parser.add_argument('--device', help=tr('Steuergerät aus dem Befehl devices, z. B. /dev/hidraw6'))
parser.add_argument('--background', action='store_true', help=tr('Nur im Infobereich starten'))
parser.add_argument('--demo', action='store_true', help=tr('Vorführmodus ohne USB-Zugriff'))
parser.add_argument('--smoke-seconds', type=int, default=0, help=argparse.SUPPRESS) parser.add_argument('--smoke-seconds', type=int, default=0, help=argparse.SUPPRESS)
subs = parser.add_subparsers(dest='command') subs = parser.add_subparsers(dest='command')
subs.add_parser('devices', help='BTD-700-Steuerschnittstellen auflisten') subs.add_parser('devices', help=tr('BTD-700-Steuerschnittstellen auflisten'))
subs.add_parser('status', help='Aktuellen Status als JSON ausgeben (ohne Passwort)') subs.add_parser('status', help=tr('Aktuellen Status als JSON ausgeben (ohne Passwort)'))
mode = subs.add_parser('mode', help='Audiomodus wählen') mode = subs.add_parser('mode', help=tr('Audiomodus wählen'))
mode.add_argument('value', choices=['standard', 'gaming', 'auracast']) mode.add_argument('value', choices=['standard', 'gaming', 'auracast'])
mode.add_argument('--transport', choices=['auto', 'classic', 'le']) mode.add_argument('--transport', choices=['auto', 'classic', 'le'])
codec = subs.add_parser('codec', help='Codec im Standard-Modus wählen') codec = subs.add_parser('codec', help=tr('Codec im Standard-Modus wählen'))
codec.add_argument('value', choices=['sbc', 'aptx', 'adaptive', 'lossless', 'qmap', 'lc3']) codec.add_argument('value', choices=['sbc', 'aptx', 'adaptive', 'lossless', 'qmap', 'lc3'])
subs.add_parser('connect', help='Gekoppelte Kopfhörer verbinden') subs.add_parser('connect', help=tr('Gekoppelte Kopfhörer verbinden'))
subs.add_parser('disconnect', help='Kopfhörer trennen') subs.add_parser('disconnect', help=tr('Kopfhörer trennen'))
broadcast = subs.add_parser('auracast', help='Auracast-Einstellungen speichern') broadcast = subs.add_parser('auracast', help=tr('Auracast-Einstellungen speichern'))
broadcast.add_argument('--name') broadcast.add_argument('--name')
broadcast.add_argument('--password', action='store_true', help='Neues Passwort verdeckt abfragen') broadcast.add_argument('--password', action='store_true', help=tr('Neues Passwort verdeckt abfragen'))
broadcast.add_argument('--public', choices=['on', 'off']) broadcast.add_argument('--public', choices=['on', 'off'])
broadcast.add_argument('--encryption', choices=['on', 'off']) broadcast.add_argument('--encryption', choices=['on', 'off'])
broadcast.add_argument('--quality', choices=['16k', '24k', 'high']) broadcast.add_argument('--quality', choices=['16k', '24k', 'high'])
reset = subs.add_parser('reset', help='Alle Kopplungen und Einstellungen löschen') reset = subs.add_parser('reset', help=tr('Alle Kopplungen und Einstellungen löschen'))
reset.add_argument('--confirm', action='store_true') reset.add_argument('--confirm', action='store_true')
args = parser.parse_args() args = parser.parse_args()
if not args.command: if not args.command:
@@ -34,7 +41,7 @@ def main():
from .app import run from .app import run
return run(background=args.background, demo=args.demo, device=args.device, smoke_seconds=args.smoke_seconds) return run(background=args.background, demo=args.demo, device=args.device, smoke_seconds=args.smoke_seconds)
except (ImportError, ValueError) as exc: except (ImportError, ValueError) as exc:
print(f'GTK-Oberfläche nicht verfügbar: {exc}\nAbhängigkeiten: siehe README.md. CLI: ./run.sh status', file=sys.stderr) print(tr('GTK-Oberfläche nicht verfügbar: {error}\nAbhängigkeiten: siehe README.md. CLI: ./run.sh status').format(error=exc), file=sys.stderr)
return 1 return 1
from .transport import DeviceError, Hidraw, discover from .transport import DeviceError, Hidraw, discover
from .controller import Controller from .controller import Controller
@@ -51,7 +58,7 @@ def main():
else: else:
devices = [d for d in discover() if not args.device or d.path == args.device] devices = [d for d in discover() if not args.device or d.path == args.device]
if len(devices) != 1: if len(devices) != 1:
raise DeviceError('Kein eindeutiger BTD 700 gefunden. devices ausführen; bei mehreren Dongles --device angeben.') raise DeviceError(tr('Kein eindeutiger BTD 700 gefunden. devices ausführen; bei mehreren Dongles --device angeben.'))
transport = Hidraw(devices[0]) transport = Hidraw(devices[0])
controller = Controller(transport) controller = Controller(transport)
if args.command == 'mode': if args.command == 'mode':
@@ -63,18 +70,18 @@ def main():
controller.set_connection(args.command == 'connect') controller.set_connection(args.command == 'connect')
elif args.command == 'auracast': elif args.command == 'auracast':
controller.set_broadcast(name=args.name, controller.set_broadcast(name=args.name,
password=getpass.getpass('Neues Auracast-Passwort: ') if args.password else None, password=getpass.getpass(tr('Neues Auracast-Passwort: ')) if args.password else None,
public=None if args.public is None else args.public == 'on', public=None if args.public is None else args.public == 'on',
encrypted=None if args.encryption is None else args.encryption == 'on', encrypted=None if args.encryption is None else args.encryption == 'on',
quality={'16k': 0, '24k': 1, 'high': 2}.get(args.quality)) quality={'16k': 0, '24k': 1, 'high': 2}.get(args.quality))
elif args.command == 'reset': elif args.command == 'reset':
controller.factory_reset(confirmed=args.confirm) controller.factory_reset(confirmed=args.confirm)
print('Zurücksetzen angefordert. Kopfhörer danach neu koppeln.') print(tr('Zurücksetzen angefordert. Kopfhörer danach neu koppeln.'))
return 0 return 0
print(json.dumps(controller.snapshot().public_dict(), indent=2, ensure_ascii=False)) print(json.dumps(controller.snapshot().public_dict(), indent=2, ensure_ascii=False))
return 0 return 0
except (OSError, DeviceError, ProtocolError, ValueError) as exc: except (OSError, DeviceError, ProtocolError, ValueError) as exc:
print(f'Fehler: {exc}', file=sys.stderr) print(tr('Fehler: {error}').format(error=exc), file=sys.stderr)
return 1 return 1
finally: finally:
if transport: if transport:
+49 -45
View File
@@ -1,4 +1,5 @@
"""Native GTK 4 / libadwaita controls and a persistent notification-area menu.""" """Native GTK 4 / libadwaita controls and a persistent notification-area menu."""
from .i18n import tr, set_language
import sys import sys
import gi import gi
@@ -11,7 +12,9 @@ from .worker import Worker
class Application(Adw.Application): class Application(Adw.Application):
def __init__(self, *, background=False, demo=False, device=None, smoke_seconds=0): def __init__(self, *, background=False, demo=False, device=None, smoke_seconds=0, language=None):
if language:
set_language(language)
super().__init__(application_id=APP_ID + ('.Demo' if demo else ''), flags=Gio.ApplicationFlags.DEFAULT_FLAGS) super().__init__(application_id=APP_ID + ('.Demo' if demo else ''), flags=Gio.ApplicationFlags.DEFAULT_FLAGS)
self.background = background self.background = background
self.demo = demo self.demo = demo
@@ -39,7 +42,7 @@ class Application(Adw.Application):
from .tray import Tray from .tray import Tray
self.tray = Tray(self) self.tray = Tray(self)
except (ImportError, ValueError, GLib.Error) as exc: except (ImportError, ValueError, GLib.Error) as exc:
self.last_error = f'Infobereich nicht verfügbar: {exc}' self.last_error = tr('Infobereich nicht verfügbar: {error}').format(error=exc)
self.worker = Worker(self._on_status, demo=self.demo, device_path=self.device) self.worker = Worker(self._on_status, demo=self.demo, device_path=self.device)
self.worker.start() self.worker.start()
self.worker.commands.put(None) self.worker.commands.put(None)
@@ -79,11 +82,11 @@ class Application(Adw.Application):
toolbar = Adw.ToolbarView() toolbar = Adw.ToolbarView()
header = Adw.HeaderBar() header = Adw.HeaderBar()
header.set_title_widget(Adw.WindowTitle(title='BTD 700', subtitle='Linux Control' + (' · DEMO' if self.demo else ''))) header.set_title_widget(Adw.WindowTitle(title='BTD 700', subtitle='Linux Control' + (' · DEMO' if self.demo else '')))
refresh = Gtk.Button(icon_name='view-refresh-symbolic', tooltip_text='Status aktualisieren') refresh = Gtk.Button(icon_name='view-refresh-symbolic', tooltip_text=tr('Status aktualisieren'))
refresh.connect('clicked', lambda *_: self.worker.commands.put(None)) refresh.connect('clicked', lambda *_: self.worker.commands.put(None))
header.pack_start(refresh) header.pack_start(refresh)
self.refresh_button = refresh self.refresh_button = refresh
quit_button = Gtk.Button(icon_name='application-exit-symbolic', tooltip_text='App vollständig beenden') quit_button = Gtk.Button(icon_name='application-exit-symbolic', tooltip_text=tr('App vollständig beenden'))
quit_button.connect('clicked', lambda *_: self.quit()) quit_button.connect('clicked', lambda *_: self.quit())
header.pack_end(quit_button) header.pack_end(quit_button)
toolbar.add_top_bar(header) toolbar.add_top_bar(header)
@@ -102,10 +105,10 @@ class Application(Adw.Application):
heading = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) heading = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
icon = Gtk.Image(icon_name='audio-headphones-symbolic', pixel_size=48) icon = Gtk.Image(icon_name='audio-headphones-symbolic', pixel_size=48)
heading.append(icon) heading.append(icon)
self.codec_label = Gtk.Label(label='BTD 700 verbinden') self.codec_label = Gtk.Label(label=tr('BTD 700 verbinden'))
self.codec_label.add_css_class('title-1') self.codec_label.add_css_class('title-1')
heading.append(self.codec_label) heading.append(self.codec_label)
self.state_label = Gtk.Label(label='Suche nach deinem USB-Dongle …', wrap=True) self.state_label = Gtk.Label(label=tr('Suche nach deinem USB-Dongle …'), wrap=True)
self.state_label.add_css_class('dim-label') self.state_label.add_css_class('dim-label')
heading.append(self.state_label) heading.append(self.state_label)
self.quality_label = Gtk.Label(label='') self.quality_label = Gtk.Label(label='')
@@ -120,7 +123,7 @@ class Application(Adw.Application):
self.device_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=22, sensitive=False) self.device_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=22, sensitive=False)
content.append(self.device_box) content.append(self.device_box)
mode_group = Adw.PreferencesGroup(title='Audiomodus', description='Der Modus wird direkt am Dongle umgeschaltet.') mode_group = Adw.PreferencesGroup(title=tr('Audiomodus'), description=tr('Der Modus wird direkt am Dongle umgeschaltet.'))
mode_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0, homogeneous=True) mode_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0, homogeneous=True)
mode_box.add_css_class('linked') mode_box.add_css_class('linked')
self.mode_buttons = [] self.mode_buttons = []
@@ -134,50 +137,50 @@ class Application(Adw.Application):
mode_group.add(mode_box) mode_group.add(mode_box)
self.device_box.append(mode_group) self.device_box.append(mode_group)
audio = Adw.PreferencesGroup(title='Kopfhörer') audio = Adw.PreferencesGroup(title=tr('Kopfhörer'))
self.codec_row = Adw.ComboRow(title='Codec') self.codec_row = Adw.ComboRow(title='Codec')
self.codec_row.connect('notify::selected', self._codec_selected) self.codec_row.connect('notify::selected', self._codec_selected)
audio.add(self.codec_row) audio.add(self.codec_row)
self.transport_row = Adw.ComboRow(title='Bluetooth-Transport') self.transport_row = Adw.ComboRow(title=tr('Bluetooth-Transport'))
self.transport_row.connect('notify::selected', self._transport_selected) self.transport_row.connect('notify::selected', self._transport_selected)
audio.add(self.transport_row) audio.add(self.transport_row)
self.connection_row = Adw.ActionRow(title='Verbindung') self.connection_row = Adw.ActionRow(title=tr('Verbindung'))
self.connection_button = Gtk.Button(label='Verbinden', valign=Gtk.Align.CENTER) self.connection_button = Gtk.Button(label=tr('Verbinden'), valign=Gtk.Align.CENTER)
self.connection_button.connect('clicked', lambda *_: self.perform('set_connection', self.status.state < 2) if self.status else None) self.connection_button.connect('clicked', lambda *_: self.perform('set_connection', self.status.state < 2) if self.status else None)
self.connection_row.add_suffix(self.connection_button) self.connection_row.add_suffix(self.connection_button)
audio.add(self.connection_row) audio.add(self.connection_row)
self.device_box.append(audio) self.device_box.append(audio)
self.auracast_group = Adw.PreferencesGroup(title='Auracast', self.auracast_group = Adw.PreferencesGroup(title='Auracast',
description='Der Audiomodus „Auracast“ startet die Übertragung an mehrere Empfänger.') description=tr('Der Audiomodus „Auracast“ startet die Übertragung an mehrere Empfänger.'))
self.name_entry = Adw.EntryRow(title='Name der Übertragung') self.name_entry = Adw.EntryRow(title=tr('Name der Übertragung'))
self.name_entry.connect('changed', self._form_changed) self.name_entry.connect('changed', self._form_changed)
self.auracast_group.add(self.name_entry) self.auracast_group.add(self.name_entry)
self.public_row = Adw.SwitchRow(title='Öffentlich auffindbar', self.public_row = Adw.SwitchRow(title=tr('Öffentlich auffindbar'),
subtitle='Übertragung in der Auracast-Suche anzeigen') subtitle=tr('Übertragung in der Auracast-Suche anzeigen'))
self.public_row.connect('notify::active', self._form_changed) self.public_row.connect('notify::active', self._form_changed)
self.auracast_group.add(self.public_row) self.auracast_group.add(self.public_row)
self.encryption_row = Adw.SwitchRow(title='Passwortschutz') self.encryption_row = Adw.SwitchRow(title=tr('Passwortschutz'))
self.encryption_row.connect('notify::active', self._form_changed) self.encryption_row.connect('notify::active', self._form_changed)
self.auracast_group.add(self.encryption_row) self.auracast_group.add(self.encryption_row)
self.password_entry = Adw.PasswordEntryRow(title='Neues Passwort') self.password_entry = Adw.PasswordEntryRow(title=tr('Neues Passwort'))
self.password_entry.set_tooltip_text('416 Buchstaben oder Ziffern. Leer lassen behält das bisherige Passwort.') self.password_entry.set_tooltip_text(tr('416 Buchstaben oder Ziffern. Leer lassen behält das bisherige Passwort.'))
self.password_entry.connect('changed', self._password_changed) self.password_entry.connect('changed', self._password_changed)
self.auracast_group.add(self.password_entry) self.auracast_group.add(self.password_entry)
self.broadcast_quality = Adw.ComboRow(title='Übertragungsqualität', self.broadcast_quality = Adw.ComboRow(title=tr('Übertragungsqualität'),
model=Gtk.StringList.new(['Standard · 16 kHz', 'Standard · 24 kHz', 'Hohe Qualität'])) model=Gtk.StringList.new([tr('Standard · 16 kHz'), tr('Standard · 24 kHz'), tr('Hohe Qualität')]))
self.broadcast_quality.connect('notify::selected', self._form_changed) self.broadcast_quality.connect('notify::selected', self._form_changed)
self.auracast_group.add(self.broadcast_quality) self.auracast_group.add(self.broadcast_quality)
actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8, halign=Gtk.Align.END, margin_top=12) actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8, halign=Gtk.Align.END, margin_top=12)
self.discard_button = Gtk.Button(label='Verwerfen', sensitive=False) self.discard_button = Gtk.Button(label=tr('Verwerfen'), sensitive=False)
self.discard_button.connect('clicked', self._discard) self.discard_button.connect('clicked', self._discard)
self.save_button = Gtk.Button(label='Speichern', sensitive=False) self.save_button = Gtk.Button(label=tr('Speichern'), sensitive=False)
self.save_button.add_css_class('suggested-action') self.save_button.add_css_class('suggested-action')
self.save_button.connect('clicked', self._save_broadcast) self.save_button.connect('clicked', self._save_broadcast)
actions.append(self.discard_button) actions.append(self.discard_button)
actions.append(self.save_button) actions.append(self.save_button)
self.auracast_group.add(actions) self.auracast_group.add(actions)
hint = Gtk.Label(label='Passwort leer lassen, um das bisherige zu behalten.', hint = Gtk.Label(label=tr('Passwort leer lassen, um das bisherige zu behalten.'),
wrap=True, xalign=0, margin_top=8) wrap=True, xalign=0, margin_top=8)
hint.add_css_class('caption') hint.add_css_class('caption')
hint.add_css_class('dim-label') hint.add_css_class('dim-label')
@@ -185,19 +188,19 @@ class Application(Adw.Application):
self.device_box.append(self.auracast_group) self.device_box.append(self.auracast_group)
integration = Adw.PreferencesGroup(title='App') integration = Adw.PreferencesGroup(title='App')
self.tray_row = Adw.ActionRow(title='Im Infobereich weiterlaufen', subtitle='Infobereich wird verbunden …') self.tray_row = Adw.ActionRow(title=tr('Im Infobereich weiterlaufen'), subtitle=tr('Infobereich wird verbunden …'))
integration.add(self.tray_row) integration.add(self.tray_row)
autostart = Adw.SwitchRow(title='Beim Anmelden starten', subtitle='Startet unauffällig im Infobereich', autostart = Adw.SwitchRow(title=tr('Beim Anmelden starten'), subtitle=tr('Startet unauffällig im Infobereich'),
active=autostart_path().exists()) active=False if self.demo else autostart_path().exists())
autostart.set_sensitive(not self.demo) autostart.set_sensitive(not self.demo)
autostart.connect('notify::active', self._autostart_changed) autostart.connect('notify::active', self._autostart_changed)
integration.add(autostart) integration.add(autostart)
content.append(integration) content.append(integration)
self.firmware_label = Gtk.Label(label='Unabhängige Steuerungs-App · Keine Firmware-Updates', wrap=True) self.firmware_label = Gtk.Label(label=tr('Unabhängige Steuerungs-App · Keine Firmware-Updates'), wrap=True)
self.firmware_label.add_css_class('caption') self.firmware_label.add_css_class('caption')
self.firmware_label.add_css_class('dim-label') self.firmware_label.add_css_class('dim-label')
content.append(self.firmware_label) content.append(self.firmware_label)
reset = Gtk.Button(label='Dongle auf Werkseinstellungen zurücksetzen …', halign=Gtk.Align.CENTER) reset = Gtk.Button(label=tr('Dongle auf Werkseinstellungen zurücksetzen …'), halign=Gtk.Align.CENTER)
reset.add_css_class('flat') reset.add_css_class('flat')
reset.connect('clicked', lambda *_: self.confirm_reset()) reset.connect('clicked', lambda *_: self.confirm_reset())
self.reset_button = reset self.reset_button = reset
@@ -220,8 +223,8 @@ class Application(Adw.Application):
def tray_changed(self): def tray_changed(self):
available = bool(self.tray and self.tray.available) available = bool(self.tray and self.tray.available)
self.tray_row.set_subtitle('Beim Schließen bleibt das Symbol in der oberen Leiste.' if available else self.tray_row.set_subtitle(tr('Beim Schließen bleibt das Symbol in der oberen Leiste.') if available else
'Kein Infobereich verfügbar; Schließen beendet die App.') tr('Kein Infobereich verfügbar; Schließen beendet die App.'))
def perform(self, method, *args, **kwargs): def perform(self, method, *args, **kwargs):
if self.busy or not self.status: if self.busy or not self.status:
@@ -233,6 +236,7 @@ class Application(Adw.Application):
self._render() self._render()
def _on_status(self, status, error, completed): def _on_status(self, status, error, completed):
was_disconnected = self.status is None
self.status = status self.status = status
self.received_status |= status is not None self.received_status |= status is not None
if completed: if completed:
@@ -245,10 +249,10 @@ class Application(Adw.Application):
self.password_entry.set_text('') self.password_entry.set_text('')
self.updating = False self.updating = False
self.form_submitted = False self.form_submitted = False
self.toast.add_toast(Adw.Toast.new(error or 'Vom Dongle bestätigt')) self.toast.add_toast(Adw.Toast.new(error or tr('Vom Dongle bestätigt')))
elif not status: elif not status:
self.last_error = error self.last_error = error
elif self.last_error.startswith(('Kein BTD 700', 'USB-Zugriff fehlt', '[Errno')): elif was_disconnected:
self.last_error = '' self.last_error = ''
self._render() self._render()
return False return False
@@ -262,7 +266,7 @@ class Application(Adw.Application):
self.error_label.set_visible(bool(self.last_error)) self.error_label.set_visible(bool(self.last_error))
if s: if s:
self.codec_label.set_label('Auracast' if s.mode == 2 else s.codec_name) self.codec_label.set_label('Auracast' if s.mode == 2 else s.codec_name)
self.state_label.set_label('Einstellung wird übernommen …' if self.busy else STATES.get(s.state, f'Status {s.state}')) self.state_label.set_label(tr('Einstellung wird übernommen …') if self.busy else tr(STATES.get(s.state, f'Status {s.state}')))
self.quality_label.set_label(s.quality) self.quality_label.set_label(s.quality)
for mode, button in enumerate(self.mode_buttons): for mode, button in enumerate(self.mode_buttons):
button.set_active(mode == s.mode) button.set_active(mode == s.mode)
@@ -273,28 +277,28 @@ class Application(Adw.Application):
self.codec_row.set_model(Gtk.StringList.new([CODECS[v] for v in values])) self.codec_row.set_model(Gtk.StringList.new([CODECS[v] for v in values]))
self.codec_row.set_selected(next((i for i, v in enumerate(values) if v & s.codec), Gtk.INVALID_LIST_POSITION)) self.codec_row.set_selected(next((i for i, v in enumerate(values) if v & s.codec), Gtk.INVALID_LIST_POSITION))
self.codec_row.set_sensitive(s.mode == 0 and s.state >= 2 and len(values) > 1) self.codec_row.set_sensitive(s.mode == 0 and s.state >= 2 and len(values) > 1)
self.codec_row.set_subtitle('Auswahl im Standard-Modus' if s.mode != 0 else 'Vom Dongle angebotene Codecs') self.codec_row.set_subtitle(tr('Auswahl im Standard-Modus') if s.mode != 0 else tr('Vom Dongle angebotene Codecs'))
values = [3] + [v for v in (1, 2) if s.transports & v] values = [3] + [v for v in (1, 2) if s.transports & v]
if s.transport in (1, 2) and s.transport not in values: if s.transport in (1, 2) and s.transport not in values:
values.append(s.transport) values.append(s.transport)
if values != self.transport_values: if values != self.transport_values:
self.transport_values = values self.transport_values = values
names = {3: 'Automatisch', 1: 'Bluetooth Classic', 2: 'LE Audio'} names = {3: tr('Automatisch'), 1: 'Bluetooth Classic', 2: 'LE Audio'}
self.transport_row.set_model(Gtk.StringList.new([names[v] for v in values])) self.transport_row.set_model(Gtk.StringList.new([names[v] for v in values]))
self.transport_row.set_selected(values.index(s.transport) if s.transport in values else Gtk.INVALID_LIST_POSITION) self.transport_row.set_selected(values.index(s.transport) if s.transport in values else Gtk.INVALID_LIST_POSITION)
self.transport_row.set_sensitive(s.mode == 0) self.transport_row.set_sensitive(s.mode == 0)
self.connection_row.set_subtitle(STATES.get(s.state, 'Unbekannt')) self.connection_row.set_subtitle(tr(STATES.get(s.state, 'Unbekannt')))
self.connection_button.set_label('Trennen' if s.state >= 2 else 'Verbinden') self.connection_button.set_label(tr('Trennen') if s.state >= 2 else tr('Verbinden'))
self.connection_button.set_sensitive(s.mode != 2) self.connection_button.set_sensitive(s.mode != 2)
if not self.dirty: if not self.dirty:
self.name_entry.set_text(s.broadcast_name) self.name_entry.set_text(s.broadcast_name)
self.public_row.set_active(bool(s.broadcast_public)) self.public_row.set_active(bool(s.broadcast_public))
self.encryption_row.set_active(bool(s.broadcast_encrypted)) self.encryption_row.set_active(bool(s.broadcast_encrypted))
self.broadcast_quality.set_selected(s.broadcast_quality) self.broadcast_quality.set_selected(s.broadcast_quality)
self.firmware_label.set_label(f'Firmware {s.firmware} · Unabhängige App · Keine Firmware-Updates') self.firmware_label.set_label(tr('Firmware {version} · Unabhängige App · Keine Firmware-Updates').format(version=s.firmware))
else: else:
self.codec_label.set_label('BTD 700 verbinden') self.codec_label.set_label(tr('BTD 700 verbinden'))
self.state_label.set_label('Warte auf den USB-Dongle …') self.state_label.set_label(tr('Warte auf den USB-Dongle …'))
self.quality_label.set_label('') self.quality_label.set_label('')
self.save_button.set_sensitive(self.dirty and not self.busy) self.save_button.set_sensitive(self.dirty and not self.busy)
self.discard_button.set_sensitive(self.dirty and not self.busy) self.discard_button.set_sensitive(self.dirty and not self.busy)
@@ -356,10 +360,10 @@ class Application(Adw.Application):
self.show_window() self.show_window()
if not self.status or self.busy: if not self.status or self.busy:
return return
dialog = Adw.AlertDialog(heading='BTD 700 zurücksetzen?', dialog = Adw.AlertDialog(heading=tr('BTD 700 zurücksetzen?'),
body='Alle Kopplungen und gespeicherten Einstellungen werden gelöscht. Danach musst du die Kopfhörer erneut koppeln.') body=tr('Alle Kopplungen und gespeicherten Einstellungen werden gelöscht. Danach musst du die Kopfhörer erneut koppeln.'))
dialog.add_response('cancel', 'Abbrechen') dialog.add_response('cancel', tr('Abbrechen'))
dialog.add_response('reset', 'Zurücksetzen') dialog.add_response('reset', tr('Zurücksetzen'))
dialog.set_response_appearance('reset', Adw.ResponseAppearance.DESTRUCTIVE) dialog.set_response_appearance('reset', Adw.ResponseAppearance.DESTRUCTIVE)
dialog.set_default_response('cancel') dialog.set_default_response('cancel')
dialog.set_close_response('cancel') dialog.set_close_response('cancel')
@@ -370,7 +374,7 @@ class Application(Adw.Application):
try: try:
set_autostart(row.get_active()) set_autostart(row.get_active())
except OSError as exc: except OSError as exc:
self.last_error = f'Autostart konnte nicht gespeichert werden: {exc}' self.last_error = tr('Autostart konnte nicht gespeichert werden: {error}').format(error=exc)
self._render() self._render()
+21 -20
View File
@@ -1,4 +1,5 @@
"""Device operations with readback and capability checks.""" """Device operations with readback and capability checks."""
from .i18n import tr
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
import time import time
@@ -31,8 +32,8 @@ class Status:
@property @property
def quality(self): def quality(self):
bits = {1: '16 Bit', 2: '24 Bit'}.get(self.resolution, '') bits = {1: tr('16 Bit'), 2: tr('24 Bit')}.get(self.resolution, '')
frequency = {1: '44,1 kHz', 2: '48 kHz', 3: '96 kHz'}.get(self.frequency, '') frequency = {1: tr('44,1 kHz'), 2: '48 kHz', 3: '96 kHz'}.get(self.frequency, '')
return f'{bits} / {frequency}' return f'{bits} / {frequency}'
@property @property
@@ -43,10 +44,10 @@ class Status:
def public_dict(self): def public_dict(self):
result = asdict(self) result = asdict(self)
result.update(state_name=STATES.get(self.state, f'Unbekannt ({self.state})'), result.update(state_name=tr(STATES.get(self.state, tr('Unbekannt ({value})').format(value=self.state))),
mode_name=MODES.get(self.mode, f'Unbekannt ({self.mode})'), mode_name=tr(MODES.get(self.mode, tr('Unbekannt ({value})').format(value=self.mode))),
codec_name=self.codec_name, audio_quality=self.quality, codec_name=self.codec_name, audio_quality=self.quality,
transport_name=TRANSPORTS.get(self.transport, f'Unbekannt ({self.transport})')) transport_name=tr(TRANSPORTS.get(self.transport, tr('Unbekannt ({value})').format(value=self.transport))))
return result return result
@@ -70,7 +71,7 @@ class Controller:
try: try:
self.firmware = '.'.join(map(str, q(C.GET_VERSION)[:3])) self.firmware = '.'.join(map(str, q(C.GET_VERSION)[:3]))
except TimeoutError: except TimeoutError:
self.firmware = 'Unbekannt' self.firmware = tr('Unbekannt')
# Firmware 3.11 does not respond to GET_GAMING; use events/fallback, # Firmware 3.11 does not respond to GET_GAMING; use events/fallback,
# matching the original application's capability logic. # matching the original application's capability logic.
event = self.transport.events.get(23, b'') event = self.transport.events.get(23, b'')
@@ -87,20 +88,20 @@ class Controller:
if matches: if matches:
return return
if time.monotonic() >= deadline: if time.monotonic() >= deadline:
raise DeviceError('Der Dongle hat die angeforderte Einstellung nicht bestätigt. Bitte den aktuellen Status prüfen.') raise DeviceError(tr('Der Dongle hat die angeforderte Einstellung nicht bestätigt. Bitte den aktuellen Status prüfen.'))
time.sleep(0.15) time.sleep(0.15)
def set_mode(self, mode: int, transport: int | None = None): def set_mode(self, mode: int, transport: int | None = None):
status = self.snapshot() status = self.snapshot()
if mode not in MODES: if mode not in MODES:
raise ValueError('Unbekannter Audiomodus.') raise ValueError(tr('Unbekannter Audiomodus.'))
if mode == 1 and not status.gaming_allowed: if mode == 1 and not status.gaming_allowed:
raise ValueError('Gaming ist für die aktuelle Verbindung nicht verfügbar.') raise ValueError(tr('Gaming ist für die aktuelle Verbindung nicht verfügbar.'))
selected = status.transport if transport is None else transport selected = status.transport if transport is None else transport
if selected not in (1, 2, 3): if selected not in (1, 2, 3):
raise ValueError('Unbekannter Bluetooth-Transport.') raise ValueError(tr('Unbekannter Bluetooth-Transport.'))
if transport is not None and selected in (1, 2) and status.transports and not status.transports & selected: if transport is not None and selected in (1, 2) and status.transports and not status.transports & selected:
raise ValueError('Die Kopfhörer unterstützen diesen Transport nicht.') raise ValueError(tr('Die Kopfhörer unterstützen diesen Transport nicht.'))
payload = bytes((mode, selected)) payload = bytes((mode, selected))
self.transport.request(C.SET_MODE, payload) self.transport.request(C.SET_MODE, payload)
self._verify(C.GET_MODE, payload, exact=False) self._verify(C.GET_MODE, payload, exact=False)
@@ -108,9 +109,9 @@ class Controller:
def set_codec(self, codec: int): def set_codec(self, codec: int):
status = self.snapshot() status = self.snapshot()
if status.mode != 0 or status.state < 2: if status.mode != 0 or status.state < 2:
raise ValueError('Codec-Auswahl benötigt verbundene Kopfhörer im Standard-Modus.') raise ValueError(tr('Codec-Auswahl benötigt verbundene Kopfhörer im Standard-Modus.'))
if codec not in CODECS or not status.codecs & codec: if codec not in CODECS or not status.codecs & codec:
raise ValueError('Dieser Codec wird für die aktuelle Verbindung nicht angeboten.') raise ValueError(tr('Dieser Codec wird für die aktuelle Verbindung nicht angeboten.'))
payload = bytes((codec,)) payload = bytes((codec,))
self.transport.request(C.SET_CODEC, payload) self.transport.request(C.SET_CODEC, payload)
self._verify(C.GET_CODEC, payload) self._verify(C.GET_CODEC, payload)
@@ -123,7 +124,7 @@ class Controller:
if (state >= 2) == connected: if (state >= 2) == connected:
return return
if time.monotonic() >= deadline: if time.monotonic() >= deadline:
raise DeviceError('Verbindung wurde angefordert, aber noch nicht bestätigt. Sind die gekoppelten Kopfhörer eingeschaltet?') raise DeviceError(tr('Verbindung wurde angefordert, aber noch nicht bestätigt. Sind die gekoppelten Kopfhörer eingeschaltet?'))
time.sleep(0.25) time.sleep(0.25)
def set_broadcast(self, *, name=None, password=None, public=None, quality=None, encrypted=None): def set_broadcast(self, *, name=None, password=None, public=None, quality=None, encrypted=None):
@@ -131,9 +132,9 @@ class Controller:
name_data = validate_text(name) if name is not None else None name_data = validate_text(name) if name is not None else None
key_data = validate_text(password, key=True) if password is not None else None key_data = validate_text(password, key=True) if password is not None else None
if any(value is not None and not isinstance(value, bool) for value in (public, encrypted)): if any(value is not None and not isinstance(value, bool) for value in (public, encrypted)):
raise ValueError('Auracast-Schalter benötigen einen booleschen Wert.') raise ValueError(tr('Auracast-Schalter benötigen einen booleschen Wert.'))
if quality is not None and quality not in (0, 1, 2): if quality is not None and quality not in (0, 1, 2):
raise ValueError('Unbekannte Auracast-Qualität.') raise ValueError(tr('Unbekannte Auracast-Qualität.'))
before = self.snapshot() before = self.snapshot()
info = [before.broadcast_public, before.broadcast_quality, before.broadcast_encrypted] info = [before.broadcast_public, before.broadcast_quality, before.broadcast_encrypted]
if public is not None: if public is not None:
@@ -145,11 +146,11 @@ class Controller:
if encrypted is not None: if encrypted is not None:
info[2] = int(encrypted) info[2] = int(encrypted)
if info[2] and key_data == b'': if info[2] and key_data == b'':
raise ValueError('Verschlüsselung benötigt ein Passwort mit 416 Zeichen.') raise ValueError(tr('Verschlüsselung benötigt ein Passwort mit 416 Zeichen.'))
if info[2] and key_data is None: if info[2] and key_data is None:
existing = self.transport.request(C.GET_KEY).rstrip(b'\0') existing = self.transport.request(C.GET_KEY).rstrip(b'\0')
if len(existing) < 4: if len(existing) < 4:
raise ValueError('Bitte zuerst ein Passwort mit 416 Zeichen eingeben.') raise ValueError(tr('Bitte zuerst ein Passwort mit 416 Zeichen eingeben.'))
# Preserve the original settings while changing a key, then write # Preserve the original settings while changing a key, then write
# key, name, and final settings in the order used by the original app. # key, name, and final settings in the order used by the original app.
try: try:
@@ -169,9 +170,9 @@ class Controller:
except Exception as exc: except Exception as exc:
# Do not silently repeat writes or claim atomicity: settings persist # Do not silently repeat writes or claim atomicity: settings persist
# independently on the dongle. # independently on the dongle.
raise DeviceError('Auracast wurde nur teilweise oder nicht übernommen. Aktuellen Namen und Passwortschutz prüfen.') from exc raise DeviceError(tr('Auracast wurde nur teilweise oder nicht übernommen. Aktuellen Namen und Passwortschutz prüfen.')) from exc
def factory_reset(self, *, confirmed=False): def factory_reset(self, *, confirmed=False):
if not confirmed: if not confirmed:
raise ValueError('Zurücksetzen muss ausdrücklich bestätigt werden; alle Kopplungen werden gelöscht.') raise ValueError(tr('Zurücksetzen muss ausdrücklich bestätigt werden; alle Kopplungen werden gelöscht.'))
self.transport.request(C.FACTORY_RESET) self.transport.request(C.FACTORY_RESET)
+2 -1
View File
@@ -1,4 +1,5 @@
"""Explicit, isolated demonstration device; never opens USB.""" """Explicit, isolated demonstration device; never opens USB."""
from .i18n import tr
from .protocol import Command as C, encode from .protocol import Command as C, encode
@@ -30,7 +31,7 @@ class DemoTransport:
elif command == C.FACTORY_RESET: elif command == C.FACTORY_RESET:
self.__init__() self.__init__()
else: else:
raise TimeoutError('Nicht unterstützte Demo-Abfrage') raise TimeoutError(tr('Nicht unterstützte Demo-Abfrage'))
return b'' return b''
def close(self): def close(self):
+157
View File
@@ -0,0 +1,157 @@
"""Small, dependency-free English/German catalog shared by GUI, tray and CLI."""
import os
def detect_language(environ=None):
env = os.environ if environ is None else environ
override = env.get('BTD700_LANGUAGE', '')
if override in ('en', 'de'):
return override
language = (env.get('LANGUAGE') or env.get('LC_ALL') or env.get('LC_MESSAGES') or env.get('LANG') or 'en')
return 'de' if language.split(':', 1)[0].lower().startswith('de') else 'en'
_language = detect_language()
def set_language(language):
global _language
if language not in ('en', 'de'):
raise ValueError('Supported languages: en, de')
_language = language
def get_language():
return _language
def tr(message):
return ENGLISH.get(message, message) if _language == 'en' else message
# German source messages are retained for compatibility with the first release.
# Keep placeholders identical in both languages; never translate protocol values.
ENGLISH = {
'Menüeintrag und Autostart entfernt. Der Projektordner bleibt erhalten.': 'Removed the launcher and start-at-login entry. The project folder is unchanged.',
'Status aktualisieren': 'Refresh status',
'App vollständig beenden': 'Quit application',
'BTD 700 verbinden': 'Connect your BTD 700',
'Suche nach deinem USB-Dongle …': 'Looking for your USB dongle…',
'Audiomodus': 'Audio mode',
'Der Modus wird direkt am Dongle umgeschaltet.': 'Switch the audio mode directly on your dongle.',
'Kopfhörer': 'Headphones',
'Bluetooth-Transport': 'Bluetooth transport',
'Verbindung': 'Connection',
'Verbinden': 'Connect',
'Trennen': 'Disconnect',
'Der Audiomodus „Auracast“ startet die Übertragung an mehrere Empfänger.': 'Choose Auracast mode to broadcast audio to multiple receivers.',
'Name der Übertragung': 'Broadcast name',
'Öffentlich auffindbar': 'Publicly discoverable',
'Übertragung in der Auracast-Suche anzeigen': 'Show this broadcast in Auracast discovery',
'Passwortschutz': 'Password protection',
'Neues Passwort': 'New password',
'416 Buchstaben oder Ziffern. Leer lassen behält das bisherige Passwort.': '416 letters or digits. Leave blank to keep the current password.',
'Übertragungsqualität': 'Broadcast quality',
'Standard · 16 kHz': 'Standard · 16 kHz',
'Standard · 24 kHz': 'Standard · 24 kHz',
'Hohe Qualität': 'High quality',
'Verwerfen': 'Discard',
'Speichern': 'Save',
'Passwort leer lassen, um das bisherige zu behalten.': 'Leave the password blank to keep the current one.',
'Im Infobereich weiterlaufen': 'Keep running in the system tray',
'Infobereich wird verbunden …': 'Connecting to the system tray…',
'Beim Anmelden starten': 'Start at login',
'Startet unauffällig im Infobereich': 'Start quietly in the system tray',
'Unabhängige Steuerungs-App · Keine Firmware-Updates': 'Independent control app · No firmware updates',
'Dongle auf Werkseinstellungen zurücksetzen …': 'Reset dongle to factory settings…',
'Beim Schließen bleibt das Symbol in der oberen Leiste.': 'Closing the window keeps the tray icon available.',
'Kein Infobereich verfügbar; Schließen beendet die App.': 'No system tray available; closing the window quits the app.',
'Vom Dongle bestätigt': 'Confirmed by the dongle',
'Einstellung wird übernommen …': 'Applying setting…',
'Auswahl im Standard-Modus': 'Available in Standard mode',
'Vom Dongle angebotene Codecs': 'Codecs reported by the dongle',
'Automatisch': 'Automatic',
'Nicht verbunden': 'Disconnected',
'Bereit': 'Ready',
'Kopfhörer getrennt': 'Headphones disconnected',
'Kopfhörer verbunden': 'Headphones connected',
'Musikwiedergabe': 'Playing audio',
'Sprachanruf': 'Voice call',
'Unbekannt': 'Unknown',
'Unbekannter Status': 'Unknown status',
'Warte auf den USB-Dongle …': 'Waiting for the USB dongle…',
'BTD 700 zurücksetzen?': 'Reset BTD 700?',
'Alle Kopplungen und gespeicherten Einstellungen werden gelöscht. Danach musst du die Kopfhörer erneut koppeln.': 'This removes all pairings and saved settings. You will need to pair your headphones again.',
'Abbrechen': 'Cancel',
'Zurücksetzen': 'Reset',
'Infobereich nicht verfügbar: {error}': 'System tray unavailable: {error}',
'Autostart konnte nicht gespeichert werden: {error}': 'Could not save the start-at-login setting: {error}',
'Firmware {version} · Unabhängige App · Keine Firmware-Updates': 'Firmware {version} · Independent app · No firmware updates',
'BTD 700 anschließen': 'Connect a BTD 700',
'Keine verbundenen Kopfhörer': 'No headphones connected',
'Name und Passwort …': 'Name and password…',
'Kopfhörer trennen': 'Disconnect headphones',
'Kopfhörer verbinden': 'Connect headphones',
'Werkseinstellungen …': 'Factory reset…',
'Dongle nicht verbunden': 'Dongle disconnected',
'USB-Zugriff prüfen Fenster öffnen': 'Check USB access — open window',
'Fehler anzeigen …': 'Show error…',
'Fenster öffnen': 'Open window',
'Beenden': 'Quit',
'16 Bit': '16-bit',
'24 Bit': '24-bit',
'44,1 kHz': '44.1 kHz',
'Unbekannt ({value})': 'Unknown ({value})',
'Der Dongle hat die angeforderte Einstellung nicht bestätigt. Bitte den aktuellen Status prüfen.': 'The dongle did not confirm the requested setting. Please check its current status.',
'Unbekannter Audiomodus.': 'Unknown audio mode.',
'Gaming ist für die aktuelle Verbindung nicht verfügbar.': 'Gaming mode is unavailable for this connection.',
'Unbekannter Bluetooth-Transport.': 'Unknown Bluetooth transport.',
'Die Kopfhörer unterstützen diesen Transport nicht.': 'Your headphones do not support this transport.',
'Codec-Auswahl benötigt verbundene Kopfhörer im Standard-Modus.': 'Codec selection requires connected headphones in Standard mode.',
'Dieser Codec wird für die aktuelle Verbindung nicht angeboten.': 'This codec is not offered for the current connection.',
'Verbindung wurde angefordert, aber noch nicht bestätigt. Sind die gekoppelten Kopfhörer eingeschaltet?': 'Connection requested but not yet confirmed. Are your paired headphones switched on?',
'Auracast-Schalter benötigen einen booleschen Wert.': 'Auracast switches require a boolean value.',
'Unbekannte Auracast-Qualität.': 'Unknown Auracast quality.',
'Verschlüsselung benötigt ein Passwort mit 416 Zeichen.': 'Encryption requires a password of 416 characters.',
'Bitte zuerst ein Passwort mit 416 Zeichen eingeben.': 'Enter a password of 416 characters first.',
'Auracast wurde nur teilweise oder nicht übernommen. Aktuellen Namen und Passwortschutz prüfen.': 'Auracast settings were only partially applied or not applied. Check the current name and password protection.',
'Zurücksetzen muss ausdrücklich bestätigt werden; alle Kopplungen werden gelöscht.': 'Reset requires explicit confirmation; all pairings will be removed.',
'Passwort: 416 Buchstaben oder Ziffern.': 'Password: 416 letters or digits.',
'Name: 416 Buchstaben, Ziffern oder innere Leerzeichen.': 'Name: 416 letters, digits or internal spaces.',
'Unbekannter Steuerbefehl; kein Zugriff auf Update-Befehle.': 'Unknown control command; update commands are not accessible.',
'Ungültige Antwort des Dongles.': 'Invalid response from the dongle.',
'Ungültige Parameter für {command}.': 'Invalid parameters for {command}.',
'Unbekannte Benachrichtigung: {event:#x}': 'Unknown notification: {event:#x}',
'Gerätepfad gehört nicht mehr zum BTD 700.': 'The device path no longer belongs to a BTD 700.',
'Der Dongle ist bereits in einer anderen Instanz geöffnet.': 'Another instance already has this dongle open.',
'USB-Zugriff fehlt. Die mitgelieferte udev-Regel installieren und den Dongle neu einstecken.': 'USB access denied. Install the included udev rule and reconnect the dongle.',
'Dongle ist nicht geöffnet.': 'The dongle is not open.',
'USB-Schreibzugriff hat nicht geantwortet.': 'USB write timed out.',
'Unvollständiger USB-Schreibvorgang.': 'Incomplete USB write.',
'Dongle wurde entfernt.': 'The dongle was unplugged.',
'Unvollständige Antwort auf {command}.': 'Incomplete response to {command}.',
'Der Dongle antwortet nicht auf {command}.': 'The dongle did not respond to {command}.',
'Kein BTD 700 gefunden. Bitte den Dongle einstecken.': 'No BTD 700 found. Please connect the dongle.',
'Mehrere BTD 700 gefunden. Mit --device /dev/hidrawN auswählen.': 'Multiple BTD 700 dongles found. Select one with --device /dev/hidrawN.',
'Nicht unterstützte Demo-Abfrage': 'Unsupported demo query',
'Native Linux-Steuerung für den BTD 700; ohne Firmware-Updates.': 'Native Linux controls for the BTD 700; no firmware updates.',
'Steuergerät aus dem Befehl devices, z. B. /dev/hidraw6': 'Control device listed by devices, e.g. /dev/hidraw6',
'Nur im Infobereich starten': 'Start in the system tray only',
'Vorführmodus ohne USB-Zugriff': 'Demo mode without USB access',
'BTD-700-Steuerschnittstellen auflisten': 'List BTD 700 control interfaces',
'Aktuellen Status als JSON ausgeben (ohne Passwort)': 'Print current status as JSON (without passwords)',
'Audiomodus wählen': 'Select an audio mode',
'Codec im Standard-Modus wählen': 'Select a codec in Standard mode',
'Gekoppelte Kopfhörer verbinden': 'Connect paired headphones',
'Auracast-Einstellungen speichern': 'Save Auracast settings',
'Neues Passwort verdeckt abfragen': 'Prompt for a new password without echoing it',
'Alle Kopplungen und Einstellungen löschen': 'Remove all pairings and settings',
'Kein eindeutiger BTD 700 gefunden. devices ausführen; bei mehreren Dongles --device angeben.': 'No unique BTD 700 found. Run devices; use --device if multiple dongles are connected.',
'GTK-Oberfläche nicht verfügbar: {error}\nAbhängigkeiten: siehe README.md. CLI: ./run.sh status': 'GTK interface unavailable: {error}\nDependencies: see README.md. CLI: ./run.sh status',
'Neues Auracast-Passwort: ': 'New Auracast password: ',
'Zurücksetzen angefordert. Kopfhörer danach neu koppeln.': 'Reset requested. Pair your headphones again afterwards.',
'Fehler: {error}': 'Error: {error}',
'Installiert: {path}': 'Installed: {path}',
'Im Anwendungsmenü „BTD 700 Control“ öffnen. Der Projektordner muss erhalten bleiben.': 'Open “BTD 700 Control” from your application menu. Keep the project folder in place.',
'Sprache der App (Standard: Systemsprache)': 'App language (default: system language)',
}
+2 -1
View File
@@ -15,7 +15,8 @@ def desktop_entry(*, background=False):
# Desktop Entry Exec quoting is not shell quoting. # Desktop Entry Exec quoting is not shell quoting.
escaped = path.replace('\\', '\\\\').replace('"', '\\"').replace('`', '\\`').replace('$', '\\$').replace('%', '%%') escaped = path.replace('\\', '\\\\').replace('"', '\\"').replace('`', '\\`').replace('$', '\\$').replace('%', '%%')
return ('[Desktop Entry]\nType=Application\nName=BTD 700 Control\n' return ('[Desktop Entry]\nType=Application\nName=BTD 700 Control\n'
'Comment=Linux-Steuerung für den Sennheiser BTD 700\n' 'Comment=Linux controls for the Sennheiser BTD 700\n'
'Comment[de]=Linux-Steuerung für den Sennheiser BTD 700\n'
f'Exec="{escaped}"{module_args}' + (' --background' if background else '') + '\n' f'Exec="{escaped}"{module_args}' + (' --background' if background else '') + '\n'
f'Icon={icon}\nTerminal=false\n' f'Icon={icon}\nTerminal=false\n'
'Categories=AudioVideo;Audio;\n' 'Categories=AudioVideo;Audio;\n'
+7 -6
View File
@@ -1,4 +1,5 @@
"""BTD 700 control protocol. No updater transport or arbitrary commands.""" """BTD 700 control protocol. No updater transport or arbitrary commands."""
from .i18n import tr
from dataclasses import dataclass from dataclasses import dataclass
from enum import IntEnum from enum import IntEnum
import re import re
@@ -51,8 +52,8 @@ def validate_text(value: str, *, key: bool = False) -> bytes:
"""Match the original app's 416 ASCII character input bounds; empty resets.""" """Match the original app's 416 ASCII character input bounds; empty resets."""
pattern = r'[A-Za-z0-9]{4,16}' if key else r'[A-Za-z0-9 ]{4,16}' pattern = r'[A-Za-z0-9]{4,16}' if key else r'[A-Za-z0-9 ]{4,16}'
if value and (not re.fullmatch(pattern, value) or value != value.strip()): if value and (not re.fullmatch(pattern, value) or value != value.strip()):
what = 'Passwort: 416 Buchstaben oder Ziffern.' if key else ( what = tr('Passwort: 416 Buchstaben oder Ziffern.') if key else (
'Name: 416 Buchstaben, Ziffern oder innere Leerzeichen.') tr('Name: 416 Buchstaben, Ziffern oder innere Leerzeichen.'))
raise ValueError(what) raise ValueError(what)
return value.encode('ascii') return value.encode('ascii')
@@ -61,7 +62,7 @@ def encode(command: Command, payload: bytes = b'') -> bytes:
try: try:
command = Command(command) command = Command(command)
except ValueError as exc: except ValueError as exc:
raise ValueError('Unbekannter Steuerbefehl; kein Zugriff auf Update-Befehle.') from exc raise ValueError(tr('Unbekannter Steuerbefehl; kein Zugriff auf Update-Befehle.')) from exc
if command in READ_COMMANDS or command == Command.FACTORY_RESET: if command in READ_COMMANDS or command == Command.FACTORY_RESET:
valid = not payload valid = not payload
elif command == Command.SET_MODE: elif command == Command.SET_MODE:
@@ -79,13 +80,13 @@ def encode(command: Command, payload: bytes = b'') -> bytes:
else: else:
valid = False valid = False
if not valid: if not valid:
raise ValueError(f'Ungültige Parameter für {command.name}.') raise ValueError(tr('Ungültige Parameter für {command}.').format(command=command.name))
return bytes((REPORT_ID, 0xFE, command, len(payload))) + payload + bytes(60 - len(payload)) return bytes((REPORT_ID, 0xFE, command, len(payload))) + payload + bytes(60 - len(payload))
def acknowledge(event: int) -> bytes: def acknowledge(event: int) -> bytes:
if event not in EVENTS: if event not in EVENTS:
raise ProtocolError(f'Unbekannte Benachrichtigung: {event:#x}') raise ProtocolError(tr('Unbekannte Benachrichtigung: {event:#x}').format(event=event))
return bytes((REPORT_ID, 0xFD, event, 0)) + bytes(60) return bytes((REPORT_ID, 0xFD, event, 0)) + bytes(60)
@@ -100,5 +101,5 @@ def decode(report: bytes) -> Message | None:
if not report or report[0] != REPORT_ID: if not report or report[0] != REPORT_ID:
return None # Media keys share the same interface. return None # Media keys share the same interface.
if len(report) < 4 or report[1] not in (0xFC, 0xFF) or report[3] > 60 or len(report) < 4 + report[3]: if len(report) < 4 or report[1] not in (0xFC, 0xFF) or report[3] > 60 or len(report) < 4 + report[3]:
raise ProtocolError('Ungültige Antwort des Dongles.') raise ProtocolError(tr('Ungültige Antwort des Dongles.'))
return Message(report[1], report[2], report[4:4 + report[3]]) return Message(report[1], report[2], report[4:4 + report[3]])
+10 -9
View File
@@ -1,4 +1,5 @@
"""Linux hidraw access, limited to the BTD 700 control interface.""" """Linux hidraw access, limited to the BTD 700 control interface."""
from .i18n import tr
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
import fcntl import fcntl
@@ -49,14 +50,14 @@ class Hidraw:
fcntl.ioctl(self.fd, 0x80084803, info, True) # HIDIOCGRAWINFO fcntl.ioctl(self.fd, 0x80084803, info, True) # HIDIOCGRAWINFO
bus, vendor, product = struct.unpack('IHH', info) bus, vendor, product = struct.unpack('IHH', info)
if (bus, vendor, product) != (3, 0x3542, 0x3001): if (bus, vendor, product) != (3, 0x3542, 0x3001):
raise DeviceError('Gerätepfad gehört nicht mehr zum BTD 700.') raise DeviceError(tr('Gerätepfad gehört nicht mehr zum BTD 700.'))
try: try:
fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB) fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as exc: except BlockingIOError as exc:
raise DeviceError('Der Dongle ist bereits in einer anderen Instanz geöffnet.') from exc raise DeviceError(tr('Der Dongle ist bereits in einer anderen Instanz geöffnet.')) from exc
except PermissionError as exc: except PermissionError as exc:
self.close() self.close()
raise DeviceError('USB-Zugriff fehlt. Die mitgelieferte udev-Regel installieren und den Dongle neu einstecken.') from exc raise DeviceError(tr('USB-Zugriff fehlt. Die mitgelieferte udev-Regel installieren und den Dongle neu einstecken.')) from exc
except BaseException: except BaseException:
self.close() self.close()
raise raise
@@ -74,16 +75,16 @@ class Hidraw:
def _write(self, report): def _write(self, report):
if self.fd is None: if self.fd is None:
raise DeviceError('Dongle ist nicht geöffnet.') raise DeviceError(tr('Dongle ist nicht geöffnet.'))
if not select.select([], [self.fd], [], self.timeout)[1]: if not select.select([], [self.fd], [], self.timeout)[1]:
raise TimeoutError('USB-Schreibzugriff hat nicht geantwortet.') raise TimeoutError(tr('USB-Schreibzugriff hat nicht geantwortet.'))
if os.write(self.fd, report) != len(report): if os.write(self.fd, report) != len(report):
raise DeviceError('Unvollständiger USB-Schreibvorgang.') raise DeviceError(tr('Unvollständiger USB-Schreibvorgang.'))
def _receive(self): def _receive(self):
report = os.read(self.fd, 1024) report = os.read(self.fd, 1024)
if not report: if not report:
raise DeviceError('Dongle wurde entfernt.') raise DeviceError(tr('Dongle wurde entfernt.'))
message = decode(report) message = decode(report)
if message and message.kind == 0xFC and message.command in EVENTS: if message and message.kind == 0xFC and message.command in EVENTS:
self.events[message.command] = message.payload self.events[message.command] = message.payload
@@ -107,9 +108,9 @@ class Hidraw:
message = self._receive() message = self._receive()
if message and message.kind == 0xFF and message.command == command: if message and message.kind == 0xFF and message.command == command:
if len(message.payload) < MIN_LENGTHS.get(command, 0): if len(message.payload) < MIN_LENGTHS.get(command, 0):
raise ProtocolError(f'Unvollständige Antwort auf {command.name}.') raise ProtocolError(tr('Unvollständige Antwort auf {command}.').format(command=command.name))
return message.payload return message.payload
raise TimeoutError(f'Der Dongle antwortet nicht auf {command.name}.') raise TimeoutError(tr('Der Dongle antwortet nicht auf {command}.').format(command=command.name))
except (OSError, ProtocolError): except (OSError, ProtocolError):
self.close() self.close()
raise raise
+19 -18
View File
@@ -1,4 +1,5 @@
"""StatusNotifierItem + libdbusmenu, compatible with GNOME AppIndicator and KDE.""" """StatusNotifierItem + libdbusmenu, compatible with GNOME AppIndicator and KDE."""
from .i18n import tr
import gi import gi
from gi.repository import Gio, GLib from gi.repository import Gio, GLib
from .protocol import CODECS, MODES, STATES from .protocol import CODECS, MODES, STATES
@@ -65,7 +66,7 @@ class Tray:
self.app.tray_changed() self.app.tray_changed()
def _property(self, _bus, _sender, _path, _iface, name): def _property(self, _bus, _sender, _path, _iface, name):
tooltip = 'BTD 700 anschließen' tooltip = tr('BTD 700 anschließen')
if self.status: if self.status:
tooltip = f'{MODES.get(self.status.mode, "")} · {self.status.codec_name} · {self.status.quality}' tooltip = f'{MODES.get(self.status.mode, "")} · {self.status.codec_name} · {self.status.quality}'
props = { props = {
@@ -117,8 +118,8 @@ class Tray:
add(self._item('BTD 700 Control · DEMO' if self.app.demo else 'BTD 700 Control', self.app.show_window)) add(self._item('BTD 700 Control · DEMO' if self.app.demo else 'BTD 700 Control', self.app.show_window))
if status: if status:
add(self._item(f'{status.codec_name} · {status.quality}', enabled=False)) add(self._item(f'{status.codec_name} · {status.quality}', enabled=False))
add(self._item(STATES.get(status.state, 'Unbekannter Status'), enabled=False)) add(self._item(tr(STATES.get(status.state, 'Unbekannter Status')), enabled=False))
modes = self._submenu(root, 'Audiomodus') modes = self._submenu(root, tr('Audiomodus'))
for value, label in MODES.items(): for value, label in MODES.items():
modes.child_append(self._item(label, modes.child_append(self._item(label,
lambda v=value: self.app.perform('set_mode', v), lambda v=value: self.app.perform('set_mode', v),
@@ -132,41 +133,41 @@ class Tray:
enabled=not busy and status.mode == 0 and status.state >= 2, enabled=not busy and status.mode == 0 and status.state >= 2,
checked=bool(status.codec & value), radio=True)) checked=bool(status.codec & value), radio=True))
if not codecs.get_children(): if not codecs.get_children():
codecs.child_append(self._item('Keine verbundenen Kopfhörer', enabled=False)) codecs.child_append(self._item(tr('Keine verbundenen Kopfhörer'), enabled=False))
transports = self._submenu(root, 'Bluetooth-Transport') transports = self._submenu(root, tr('Bluetooth-Transport'))
for value, label in ((3, 'Automatisch'), (1, 'Bluetooth Classic'), (2, 'LE Audio')): for value, label in ((3, tr('Automatisch')), (1, 'Bluetooth Classic'), (2, 'LE Audio')):
transports.child_append(self._item(label, transports.child_append(self._item(label,
lambda v=value: self.app.perform('set_mode', status.mode, transport=v), lambda v=value: self.app.perform('set_mode', status.mode, transport=v),
enabled=not busy and status.mode == 0 and (value == 3 or bool(status.transports & value)), enabled=not busy and status.mode == 0 and (value == 3 or bool(status.transports & value)),
checked=status.transport == value, radio=True)) checked=status.transport == value, radio=True))
auracast = self._submenu(root, 'Auracast') auracast = self._submenu(root, 'Auracast')
auracast.child_append(self._item('Name und Passwort …', lambda: self.app.show_window('auracast'))) auracast.child_append(self._item(tr('Name und Passwort …'), lambda: self.app.show_window('auracast')))
auracast.child_append(self._item('Öffentlich auffindbar', auracast.child_append(self._item(tr('Öffentlich auffindbar'),
lambda: self.app.perform('set_broadcast', public=not status.broadcast_public), lambda: self.app.perform('set_broadcast', public=not status.broadcast_public),
enabled=not busy, checked=bool(status.broadcast_public))) enabled=not busy, checked=bool(status.broadcast_public)))
auracast.child_append(self._item('Passwortschutz', auracast.child_append(self._item(tr('Passwortschutz'),
lambda: self.app.toggle_encryption(), enabled=not busy, lambda: self.app.toggle_encryption(), enabled=not busy,
checked=bool(status.broadcast_encrypted))) checked=bool(status.broadcast_encrypted)))
quality = self._submenu(auracast, 'Übertragungsqualität') quality = self._submenu(auracast, tr('Übertragungsqualität'))
for value, label in enumerate(('Standard · 16 kHz', 'Standard · 24 kHz', 'Hohe Qualität')): for value, label in enumerate((tr('Standard · 16 kHz'), tr('Standard · 24 kHz'), tr('Hohe Qualität'))):
quality.child_append(self._item(label, quality.child_append(self._item(label,
lambda v=value: self.app.perform('set_broadcast', quality=v), lambda v=value: self.app.perform('set_broadcast', quality=v),
enabled=not busy, checked=status.broadcast_quality == value, radio=True)) enabled=not busy, checked=status.broadcast_quality == value, radio=True))
add(self._item('Kopfhörer trennen' if status.state >= 2 else 'Kopfhörer verbinden', add(self._item(tr('Kopfhörer trennen') if status.state >= 2 else tr('Kopfhörer verbinden'),
lambda: self.app.perform('set_connection', status.state < 2), enabled=not busy and status.mode != 2)) lambda: self.app.perform('set_connection', status.state < 2), enabled=not busy and status.mode != 2))
add(self._item('Werkseinstellungen …', self.app.confirm_reset, enabled=not busy)) add(self._item(tr('Werkseinstellungen …'), self.app.confirm_reset, enabled=not busy))
else: else:
add(self._item('Dongle nicht verbunden' if not error else 'USB-Zugriff prüfen Fenster öffnen', add(self._item(tr('Dongle nicht verbunden') if not error else tr('USB-Zugriff prüfen Fenster öffnen'),
self.app.show_window)) self.app.show_window))
if busy: if busy:
add(self._item('Einstellung wird übernommen …', enabled=False)) add(self._item(tr('Einstellung wird übernommen …'), enabled=False))
if error: if error:
add(self._item('Fehler anzeigen …', self.app.show_window)) add(self._item(tr('Fehler anzeigen …'), self.app.show_window))
separator = Dbusmenu.Menuitem.new() separator = Dbusmenu.Menuitem.new()
separator.property_set('type', 'separator') separator.property_set('type', 'separator')
add(separator) add(separator)
add(self._item('Fenster öffnen', self.app.show_window)) add(self._item(tr('Fenster öffnen'), self.app.show_window))
add(self._item('Beenden', self.app.quit)) add(self._item(tr('Beenden'), self.app.quit))
self.root = root # Keep Python signal closures alive. self.root = root # Keep Python signal closures alive.
self.server.set_root(root) self.server.set_root(root)
self.bus.emit_signal(None, '/StatusNotifierItem', INTERFACE, 'NewToolTip', None) self.bus.emit_signal(None, '/StatusNotifierItem', INTERFACE, 'NewToolTip', None)
+3 -2
View File
@@ -1,4 +1,5 @@
"""One thread owns the USB handle; the GTK main loop never waits for USB.""" """One thread owns the USB handle; the GTK main loop never waits for USB."""
from .i18n import tr
import queue import queue
import threading import threading
from gi.repository import GLib from gi.repository import GLib
@@ -44,9 +45,9 @@ class Worker(threading.Thread):
if self.device_path: if self.device_path:
devices = [d for d in devices if d.path == self.device_path] devices = [d for d in devices if d.path == self.device_path]
if not devices: if not devices:
raise DeviceError('Kein BTD 700 gefunden. Bitte den Dongle einstecken.') raise DeviceError(tr('Kein BTD 700 gefunden. Bitte den Dongle einstecken.'))
if len(devices) > 1: if len(devices) > 1:
raise DeviceError('Mehrere BTD 700 gefunden. Mit --device /dev/hidrawN auswählen.') raise DeviceError(tr('Mehrere BTD 700 gefunden. Mit --device /dev/hidrawN auswählen.'))
self.transport = Hidraw(devices[0]) self.transport = Hidraw(devices[0])
self.controller = Controller(self.transport) self.controller = Controller(self.transport)
if action: if action:
+112 -109
View File
@@ -1,142 +1,145 @@
# Rekonstruiertes BTD-700-Steuerprotokoll # Reconstructed BTD 700 control protocol
Stand der Untersuchung: 5. September 2026. Zweck: unabhängige Linux-Steuerung des Investigation date: 5 September 2026. Purpose: independently control a user's
eigenen USB-Dongles. Die Windows-Anwendung wurde statisch untersucht, nicht ausgeführt. USB dongle on Linux. The official Windows application was examined statically;
it was not executed. Research and implementation were carried out with AI
(OpenAI Codex). This is a working protocol reconstruction, not a vendor specification.
## Herkunft und Reproduzierbarkeit ## Provenance and reproducibility
- [Offizielle Produktseite](https://uk.sennheiser-hearing.com/products/btd-700) - [Official product page](https://uk.sennheiser-hearing.com/products/btd-700)
- [Offizieller Dongle-Control-Download](https://uk.sennheiser-hearing.com/pages/sennheiser-dongle-control) - [Official Dongle Control download page](https://uk.sennheiser-hearing.com/pages/sennheiser-dongle-control)
- [Dort verlinktes Windows-ZIP](https://eu-central-1-akqa.graphassets.com/AGz66yvUcQ42Ggm7CrXdgz/cmgrvi8excrci07uu3ivz166x) - [Windows ZIP linked from that page](https://eu-central-1-akqa.graphassets.com/AGz66yvUcQ42Ggm7CrXdgz/cmgrvi8excrci07uu3ivz166x)
- Archiv: `windows-signed-v1.0.5/Sennheiser Dongle Control.exe`, Version 1.0.5.0, - Archive entry: `windows-signed-v1.0.5/Sennheiser Dongle Control.exe`, version 1.0.5.0,
ProductVersion `1.0.5+eac62d73c43c8572999e7e68cbaec6e4536a1318`. ProductVersion `1.0.5+eac62d73c43c8572999e7e68cbaec6e4536a1318`.
- SHA256 ZIP: `1d1057b7eb64ab08e41d76723c343c691196f0affe1d5fc9168f01f9908a8cc7` - ZIP SHA256: `1d1057b7eb64ab08e41d76723c343c691196f0affe1d5fc9168f01f9908a8cc7`
- SHA256 EXE: `e176f1ab7d4aae40308c152b0bd95227ac5a16fedb99efef7e9229345d8014c0` - EXE SHA256: `e176f1ab7d4aae40308c152b0bd95227ac5a16fedb99efef7e9229345d8014c0`
- SHA256 eingebettete App-Assembly: - Embedded application assembly SHA256:
`2e8c89ea0333b0a9dd5bb0e851cd685810edaa6e32caee7645511d646ee970b4` `2e8c89ea0333b0a9dd5bb0e851cd685810edaa6e32caee7645511d646ee970b4`
Die EXE ist ein .NET-Single-File-Bundle, Manifestversion 6 mit 451 Einträgen. The executable is a .NET single-file bundle, manifest version 6, with 451 entries.
Die benötigte Assembly kann mit `tools/extract_control_assembly.py` aus einer `tools/extract_control_assembly.py` can extract only the application assembly from
lokalen Original-EXE extrahiert werden. Das Bundleformat wurde mit den a locally supplied original EXE. Its format was checked against the .NET runtime's
[Manifest](https://github.com/dotnet/runtime/blob/main/src/installer/managed/Microsoft.NET.HostModel/Bundle/Manifest.cs)- [Manifest](https://github.com/dotnet/runtime/blob/main/src/installer/managed/Microsoft.NET.HostModel/Bundle/Manifest.cs)
und [FileEntry](https://github.com/dotnet/runtime/blob/main/src/installer/managed/Microsoft.NET.HostModel/Bundle/FileEntry.cs)- and [FileEntry](https://github.com/dotnet/runtime/blob/main/src/installer/managed/Microsoft.NET.HostModel/Bundle/FileEntry.cs)
Definitionen des .NET-Runtimes abgeglichen. definitions.
Analysewerkzeug: ILSpy CLI 11.0.0.9375. Relevante Typen: Analysis tool: ILSpy CLI 11.0.0.9375. Relevant original types:
`BTDTool.BTD700Tool`, `_BTD700_HOSTCMD`, `_BTD700_DONGLECMD`, `_BTD700_*`-Enums, `BTDTool.BTD700Tool`, `_BTD700_HOSTCMD`, `_BTD700_DONGLECMD`, the `_BTD700_*` enums,
`BTD700Context`, `HidDeviceExt.sendGenericCommand`, `BTD700Context`, `HidDeviceExt.sendGenericCommand`,
`ViewModels.MainAppWindowViewModel`, `Views.AppBtd700Features`. `ViewModels.MainAppWindowViewModel` and `Views.AppBtd700Features`.
Die neue Implementierung enthält nur rekonstruierte Protokollfakten und eigenen This repository contains protocol facts and an independent implementation.
Code. Original-Binaries, dekompilierte Originalquellen und Ressourcen gehören Original executables, decompiled original sources and vendor resources are not
nicht zum Projekt. Die Herstellerlizenz der Originalsoftware bleibt davon getrennt. included. The original software retains its own license. Extracted assemblies
are not required to run this app and should not be added to the repository.
## USB und Framing ## USB interface and framing
VID `0x3542`, PID `0x3001`, USB-HID-Interface 0. Die Steuersammlung verwendet VID `0x3542`, PID `0x3001`, USB HID interface 0. The control collection uses vendor
Vendor Usage Page `0xFFA2`, Report ID **0x34**. Auf dem Testsystem liegt sie unter usage page `0xFFA2` and report ID **0x34**. It appeared as `/dev/hidraw6` on the test
`/dev/hidraw6`; die Nummer wird dynamisch ermittelt. Interface 1 ist für diese system; the application discovers the path dynamically from its descriptor.
Steuer-App nicht erforderlich und wird nicht geöffnet. Interface 1 is not opened by this control app.
64-Byte-Output-Report, ungenutzte Bytes mit Null gefüllt: Output reports are 64 bytes, with unused bytes padded with zero:
| Byte | Bedeutung | | Byte | Meaning |
|---|---| |---|---|
| 0 | Report-ID `34` | | 0 | Report ID `34` |
| 1 | `FE` Host-Befehl; `FF` Dongle-Antwort; `FC` Dongle-Ereignis; `FD` Ereignisbestätigung | | 1 | `FE`: host command; `FF`: dongle response; `FC`: dongle event; `FD`: event acknowledgement |
| 2 | Befehls- oder Ereignisnummer | | 2 | Command or event number |
| 3 | Nutzdatenlänge, maximal 60 | | 3 | Payload length, at most 60 |
| 4… | Nutzdaten | | 4… | Payload |
Host-Abfrage Beispiel: `34 FE 06 00` + 60 Nullbytes. Example query: `34 FE 06 00` followed by 60 zero bytes.
Antwort Beispiel: `34 FF 06 01 03` (Audio läuft). Example reply: `34 FF 06 01 03` (audio is playing).
Bestätigung des Ereignisses 15: `34 FD 0F 00` + 60 Nullbytes. Acknowledgement for event 15: `34 FD 0F 00` followed by 60 zero bytes.
Ereignisse 2/3/4/15/16/17/22/23 werden bestätigt. Andere Reports können von Events 2/3/4/15/16/17/22/23 are acknowledged. Other report IDs can carry media keys
Medientasten auf derselben Schnittstelle stammen und werden ignoriert. Antworten on the same interface and are ignored. Replies are matched by direction and
werden nach Richtung und Befehlsnummer zugeordnet; Längen werden geprüft. command number, with length validation. Only one request is outstanding at a time.
Nur eine Anfrage ist gleichzeitig aktiv. Schreibbefehle werden nicht blind Setters are not blindly retried; settings are queried again for confirmation.
wiederholt; für Einstellungen wird anschließend der Wert erneut abgefragt. There is no transaction ID or guarantee of atomic multi-setting updates.
Es gibt keine Transaktions-ID und keine garantierte Atomizität mehrerer Einstellungen.
## Befehle ## Commands
Alle Nummern hexadezimal. Bei Lesezugriffen ist die Anfrage-Nutzlast leer. IDs below are hexadecimal. GET requests have an empty payload. The payload column
Die nachfolgende Nutzlast beschreibt bei `GET` die Antwort, bei `SET` die Anfrage. means the response for GET operations and the request for SET operations.
| ID | Operation | Nutzlast | | ID | Operation | Payload |
|---|---|---| |---|---|---|
| 01 | GET Modus/Transport | Modus, konfigurierter Transport, optional aktuell verbundener Transport | | 01 | GET mode/transport | Mode, configured transport, optional connected transport |
| 02 | SET Modus/Transport | Modus, Transport | | 02 | SET mode/transport | Mode, transport |
| 03 | GET verfügbare Codecs | Codec-Bitmaske | | 03 | GET available codecs | Codec bitmask |
| 04 | SET Codec | Einzelnes Codec-Bit, nicht Bitindex | | 04 | SET codec | One codec bit, not its bit index |
| 05 | GET aktiver Codec | Codec-Bitmaske | | 05 | GET active codec | Codec bitmask |
| 06 | GET Dongle-Zustand | Zustand | | 06 | GET dongle state | State |
| 07 | GET LE-Audio-Zustand | LE-Zustand | | 07 | GET LE Audio state | LE state |
| 08 | GET Audioqualität | Auflösung, Frequenz | | 08 | GET audio quality | Resolution, frequency |
| 09 | GET Auracast-Konfiguration | öffentlich, Qualität, Verschlüsselung | | 09 | GET Auracast configuration | Public discovery, quality, encryption |
| 0A | SET Auracast-Konfiguration | öffentlich, Qualität, Verschlüsselung | | 0A | SET Auracast configuration | Public discovery, quality, encryption |
| 0B | GET Auracast-Schlüssel | Zeichenbytes; nur bei expliziter Passwort-Operation gelesen | | 0B | GET Auracast key | Character bytes; only read by explicit password operations |
| 0C | SET Auracast-Schlüssel | 016 Bytes; UI beschränkt auf leer oder 416 ASCII-Zeichen | | 0C | SET Auracast key | 016 bytes; UI accepts empty or 416 ASCII characters |
| 0D | GET Auracast-Name | Zeichenbytes; am Gerät 32 Bytes, mit Null aufgefüllt | | 0D | GET Auracast name | Character bytes; 32 bytes with zero padding on the tested device |
| 0E | SET Auracast-Name | bis 16 Zeichenbytes; leer setzt Gerätenamen zurück | | 0E | SET Auracast name | Up to 16 character bytes; empty restores the device default |
| 12 | GET Firmwareversion | drei Versionsbytes, nur Anzeige | | 12 | GET firmware version | Three version bytes, display only |
| 13 | Werksreset | leer; nur nach Nutzerbestätigung | | 13 | Factory reset | Empty; requires user confirmation |
| 14 | Bluetooth verbinden/trennen | 1 / 0 | | 14 | Bluetooth connect/disconnect | 1 / 0 |
| 15 | GET Kopfhörer-Transportmöglichkeiten | Bitmaske | | 15 | GET supported headphone transports | Bitmask |
| 17 | GET Gaming-Verfügbarkeit | im Original definiert; Firmware 3.11 antwortet nicht, daher nicht regelmäßig abgefragt | | 17 | GET Gaming availability | Defined by the original app; firmware 3.11 did not reply, so not polled |
Modus: 0 Standard, 1 Gaming, 2 Auracast. Mode: 0 Standard, 1 Gaming, 2 Auracast.
Transport: 0 getrennt, 1 BR/EDR, 2 LE Audio, 3 Dual/automatisch. Setzen von 0 wird Transport: 0 disconnected, 1 BR/EDR, 2 LE Audio, 3 dual/automatic. The app does not
in der neuen App nicht angeboten; Trennen hat einen eigenen Befehl. set transport 0; disconnection has a separate command.
Codec-Bits: `01` SBC, `02` aptX Classic, `04` aptX Adaptive/Low Latency, Codec bits: `01` SBC, `02` aptX Classic, `04` aptX Adaptive/Low Latency,
`08` aptX Lossless, `10` aptX Lite/QMAP, `20` LC3. Nur die vom Dongle angebotenen `08` aptX Lossless, `10` aptX Lite/QMAP, `20` LC3. Only offered bits appear as
Bits werden im Menü angezeigt. Die Firmware kann die Liste abhängig vom Modus ändern. choices. Firmware can change the available list depending on the current mode.
Dongle-Zustand: 0 keiner/bereit, 1 getrennt, 2 verbunden, 3 Audio, 4 Sprache. Dongle state: 0 none/ready, 1 disconnected, 2 connected, 3 audio, 4 voice.
LE-Zustand: 0 keiner, 1 getrennt, 2 verbunden, 3 Unicast, 4 Broadcast. LE state: 0 none, 1 disconnected, 2 connected, 3 unicast, 4 broadcast.
Auflösung: 1 = 16 Bit, 2 = 24 Bit. Resolution: 1 = 16-bit, 2 = 24-bit.
Frequenz: 1 = 44,1 kHz, 2 = 48 kHz, 3 = 96 kHz. Frequency: 1 = 44.1 kHz, 2 = 48 kHz, 3 = 96 kHz.
Auracast: öffentlich 0/1; Qualität 0 = SQ 16 kHz, 1 = SQ 24 kHz, 2 = HQ; Auracast: public discovery 0/1; quality 0 = SQ 16 kHz, 1 = SQ 24 kHz, 2 = HQ;
Verschlüsselung 0/1. „Öffentlich“ bezeichnet die Ankündigung/Auffindbarkeit, encryption 0/1. Public discovery advertises the broadcast; it does not select
nicht das Aktivieren des Audiomodus. the audio mode or start/stop audio by itself.
Gaming-Verfügbarkeit fällt wie in der Original-App auf den Verbindungsstatus, Gaming availability falls back to the current connection, aptX Adaptive bit and
aptX-Adaptive-Bit und LE-Transport zurück, solange kein Ereignis 23 eingetroffen ist. LE transport, following the original app, unless event 23 has supplied a value.
Display strings may be translated; command IDs and numeric values never are.
## Tatsächlich gelesene Antworten (Firmware 3.11.0) ## Observed hardware responses: firmware 3.11.0
| Anfrage | Antwort ohne Null-Padding | Interpretation | | Query | Response without padding | Interpretation |
|---|---|---| |---|---|---|
| 06 | `34 FF 06 01 03` | Audio läuft | | 06 | `34 FF 06 01 03` | Audio playing |
| 01 | `34 FF 01 03 01 03 01` | Gaming, automatisch, verbunden per Classic | | 01 | `34 FF 01 03 01 03 01` | Gaming, automatic, connected using Classic |
| 03 | `34 FF 03 01 04` | aktuell aptX Adaptive angeboten | | 03 | `34 FF 03 01 04` | aptX Adaptive currently offered |
| 05 | `34 FF 05 01 04` | aptX Adaptive aktiv | | 05 | `34 FF 05 01 04` | aptX Adaptive active |
| 07 | `34 FF 07 01 01` | LE getrennt | | 07 | `34 FF 07 01 01` | LE disconnected |
| 08 | `34 FF 08 02 02 02` | 24 Bit / 48 kHz | | 08 | `34 FF 08 02 02 02` | 24-bit / 48 kHz |
| 09 | `34 FF 09 03 01 02 00` | öffentlich, HQ, unverschlüsselt konfiguriert | | 09 | `34 FF 09 03 01 02 00` | Public discovery, HQ, no encryption configured |
| 12 | `34 FF 12 03 03 0B 00` | Firmware 3.11.0 | | 12 | `34 FF 12 03 03 0B 00` | Firmware 3.11.0 |
| 15 | `34 FF 15 01 01` | Kopfhörer unterstützt Classic | | 15 | `34 FF 15 01 01` | Headphones support Classic |
Auch der Auracast-Name wurde erfolgreich gelesen. Private Kennungen/Schlüssel The broadcast name was also read successfully. Private device identifiers and keys
werden hier nicht dokumentiert. Die verfügbare Audiokonfiguration belegt nicht, are omitted. The reported audio configuration does not prove bit-perfect playback.
dass Audio mit diesem Profil bitgenau übertragen wird.
## Getrennte Update-Funktion und Grenzen ## Update exclusion and remaining limitations
Die App implementiert ausschließlich Report `0x34` auf der Kontrollschnittstelle. Only control report `0x34` is implemented. The runtime has no DFU/upgrade commands,
Sie enthält keine DFU-/Upgrade-Kommandos, Firmwaredateien, Firmwareparser, firmware files, firmware parser, firmware-download endpoints or update-mode switch.
Firmware-Download-URLs oder Umschaltung in einen Update-Modus. Firmware version is read using control command `0x12` solely for display.
Die Firmwareversion wird nur über den Kontrollbefehl `0x12` gelesen.
Der Nutzer hat die Funktion der App am eigenen Dongle bestätigt. Der automatisierte The device owner reported that the app works. The automated hardware mutation test
Hardware-Umschalttest wurde noch nicht ausgeführt; die Rückmeldung belegt keine has not been run, and that feedback is not a complete verification of every setter.
vollständige Prüfung jedes Schreibbefehls. Insbesondere Wiederverbindung, echte Reconnection, real Auracast receivers and differing firmware require more systematic
Auracast-Empfänger und Firmwareunterschiede benötigen weitere systematische Tests. testing. The original app and simulator are not substitutes for device validation.
Infobereich: `org.kde.StatusNotifierItem` plus `com.canonical.dbusmenu` über ## Desktop tray
libdbusmenu. Grundlage ist die
[StatusNotifier-Spezifikation](https://specifications.freedesktop.org/status-notifier-item/latest-single/). `org.kde.StatusNotifierItem` with `com.canonical.dbusmenu`, exported using libdbusmenu,
GNOME-Registrierung und D-Bus-Menüaktionen wurden lokal geprüft. Name/Passwort following the [StatusNotifier specification](https://specifications.freedesktop.org/status-notifier-item/latest-single/).
verwenden fokussierte GTK-Eingaben, da D-Bus-Menüs keine Texteingabefelder vorsehen. Registration with GNOME and menu actions over D-Bus were tested locally in English
and German. Name/password actions focus the GTK editor because the menu protocol
does not provide text-entry fields.
+49 -33
View File
@@ -1,37 +1,53 @@
# Validierung am 5. September 2026 # Validation — 5 September 2026
Getestetes System: Bazzite, GNOME, Python 3.14, GTK 4.22, libadwaita 1.9. This document separates observed hardware behavior from simulated tests.
Angeschlossener BTD 700: USB `3542:3001`, Firmware 3.11.0. The project was developed using AI (OpenAI Codex); these results are not a warranty.
- 21 Tests mit `python3 -m unittest discover -s tests -q`: bestanden. ## Environment
- Fenster-/Infobereich-Test `python3 tools/check_gui.py`: bestanden, ausschließlich
mit einem simulierten Dongle. Aktionen wurden über `com.canonical.dbusmenu.Event`
ausgelöst, nicht nur direkt gegen Controller-Methoden.
- Geprüfte Demo-Abläufe: Standard/Gaming/Auracast, Codec, Transport, Trennen/Verbinden,
öffentliche Auffindbarkeit, Broadcastqualität, Name/Passwort speichern,
Passwortschutz ausschalten, Fenster schließen/aus Menü wieder öffnen,
ungespeicherte Eingaben bei Statusabfragen erhalten und verwerfen.
- Eigene Fensterbilder bei 620 × 800 und 420 × 600 Pixeln gerendert und visuell
geprüft. Oberfläche scrollbar, Bedienelemente erreichbar. Die zuvor überlange
Passwortbeschriftung wurde gekürzt und um einen sichtbaren Hinweis ergänzt.
- Echte Hardwareabfragen: erfolgreich, siehe PROTOCOL.md. Anzeige:
Gaming / aptX Adaptive / 24 Bit / 48 kHz / laufende Musikwiedergabe.
- Live-Symbol exportiert StatusNotifierItem mit aktuellem Tooltip und einem
D-Bus-Menü. GNOME-StatusNotifierWatcher nimmt die Registrierung an.
- App über persönlichen Desktop-Eintrag installierbar; Desktop-Datei validiert.
Dauerhafter Start in der Benutzersitzung mit transienter Unit
`btd700-control.service`; kein Systemdienst und kein aktivierter Autostart.
- Zweiter USB-Zugriff während laufender App: korrekt mit verständlicher Meldung
abgelehnt, ohne die laufende Instanz zu beeinträchtigen.
- Extraktionsskript gegen die offizielle Windows-EXE geprüft; SHA256 der
extrahierten Assembly entspricht der bei der Analyse verwendeten Datei.
Der Nutzer hat anschließend bestätigt, dass die App am eigenen Dongle funktioniert. Bazzite / GNOME, Python 3.14, GTK 4.22, libadwaita 1.9.
Welche einzelnen Funktionen dabei getestet wurden, wurde nicht näher aufgeschlüsselt. Connected BTD 700: USB `3542:3001`, firmware 3.11.0.
**Noch offen:** automatisierte Hardware-Schreibtests sowie eine systematische ## Observed on the real device
Prüfung mit Audio-/Auracast-Empfängern. `tools/check_hardware.py --run` wurde bisher
nicht ausgeführt. Dieser Test unterbricht kurz Audio und verändert vorübergehend - Correct control-interface discovery and opening without detaching audio drivers.
Auracast-Einstellungen einschließlich des Passworts; anschließend versucht er, - Successful state, mode, codec, quality, transport, Auracast configuration/name
die ursprünglichen Werte wiederherzustellen. Ein Werksreset wird dabei nicht and firmware-version reads. See [PROTOCOL.md](PROTOCOL.md) for response bytes.
ausgeführt; Firmware-Updates sind nicht implementiert. - Reported live state: Gaming, aptX Adaptive, 24-bit / 48 kHz, audio playing.
- Native GTK window and registered GNOME tray item with a live status tooltip.
- A second USB client is rejected with a clear message while the app owns the device.
- The device owner subsequently confirmed that the app works. The individual
functions exercised by the owner were not enumerated.
## Automated verification
- Protocol/controller/transport tests: command allowlist, malformed frames, response
correlation, event acknowledgements, readback, capability checks, input validation,
reset confirmation and password handling using a simulated device.
- Localization tests: English/German detection, explicit CLI override, message
placeholders, localized status/errors and unchanged numeric protocol data.
- Desktop integration tests: isolated installation/uninstallation and autostart
behavior without changing the real user's launchers.
- `tools/check_gui.py` exercised English and German windows and actual D-Bus menu
events against a demo device: modes, codec, transport, connection, broadcast
discovery/quality, name/password saving, encryption, closing/reopening and
preserving/discarding unsaved edits.
- Window layouts rendered and inspected at 620 × 800 and 420 × 600 pixels.
- README screenshots captured from the real English GTK window at 620 × 880 with
fictional demo data. No real USB access or generated mockups were used.
- Extraction helper output matched the SHA256 of the original assembly used for
protocol research. No vendor binaries were added to the distribution.
The CI workflow runs the unit tests on Python 3.10 and 3.14, in both languages.
It does not run real hardware tests or claim that a particular receiver works.
## Not yet systematically verified
- Hardware writes across different firmware revisions and all individual commands.
- Real Auracast receiver compatibility, audio quality and reconnect behavior.
- Other distributions/desktops beyond the tested Bazzite/GNOME setup.
`tools/check_hardware.py --run` has not been executed. It deliberately interrupts
audio, changes settings including the broadcast password, and attempts to restore
the original values. It does not perform a factory reset and cannot perform
firmware updates. Unplugging midway can prevent restoration.
Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

+25 -10
View File
@@ -1,13 +1,28 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Install a launcher for this checkout, without root or system packages.""" """Install/remove a launcher for this checkout, without root or system packages."""
from pathlib import Path import argparse
import os import os
from btd700.integration import APP_ID, desktop_entry from pathlib import Path
from btd700.i18n import tr
from btd700.integration import APP_ID, autostart_path, desktop_entry
base = Path(os.environ.get('XDG_DATA_HOME', Path.home() / '.local/share'))
applications = base / 'applications' def main():
applications.mkdir(parents=True, exist_ok=True) parser = argparse.ArgumentParser(description='Add or remove the BTD 700 Control application-menu entry.')
destination = applications / f'{APP_ID}.desktop' parser.add_argument('--uninstall', action='store_true', help='Remove the launcher and start-at-login entry')
destination.write_text(desktop_entry()) args = parser.parse_args()
print(f'Installiert: {destination}') base = Path(os.environ.get('XDG_DATA_HOME', Path.home() / '.local/share'))
print('Im Anwendungsmenü „BTD 700 Control“ öffnen. Der Projektordner muss erhalten bleiben.') destination = base / 'applications' / f'{APP_ID}.desktop'
if args.uninstall:
destination.unlink(missing_ok=True)
autostart_path().unlink(missing_ok=True)
print(tr('Menüeintrag und Autostart entfernt. Der Projektordner bleibt erhalten.'))
return
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(desktop_entry())
print(tr('Installiert: {path}').format(path=destination))
print(tr('Im Anwendungsmenü „BTD 700 Control“ öffnen. Der Projektordner muss erhalten bleiben.'))
if __name__ == '__main__':
main()
+17 -2
View File
@@ -1,13 +1,28 @@
[build-system] [build-system]
requires = ["setuptools>=68"] requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
name = "btd700-control" name = "btd700-control"
version = "0.1.0" version = "0.2.0"
description = "Independent native Linux controls and tray menu for the Sennheiser BTD 700" description = "Independent native Linux controls and tray menu for the Sennheiser BTD 700"
requires-python = ">=3.10" requires-python = ">=3.10"
readme = "README.md" readme = "README.md"
license = "MIT"
license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md"]
authors = [{ name = "Justin Eckenweber" }]
keywords = ["sennheiser", "btd700", "linux", "bluetooth", "auracast", "gtk"]
classifiers = [
"Development Status :: 3 - Alpha",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3",
"Topic :: Multimedia :: Sound/Audio",
]
[project.urls]
Homepage = "https://github.com/justin-eckenweber/btd700linux"
Issues = "https://github.com/justin-eckenweber/btd700linux/issues"
Changelog = "https://github.com/justin-eckenweber/btd700linux/blob/main/CHANGELOG.md"
[project.scripts] [project.scripts]
btd700-control = "btd700.__main__:main" btd700-control = "btd700.__main__:main"
+2 -1
View File
@@ -1,4 +1,5 @@
import unittest import unittest
from btd700.i18n import tr
from btd700.controller import Controller from btd700.controller import Controller
from btd700.demo import DemoTransport from btd700.demo import DemoTransport
from btd700.protocol import Command as C from btd700.protocol import Command as C
@@ -25,7 +26,7 @@ class ControllerTests(unittest.TestCase):
self.device.values.pop(C.GET_KEY) self.device.values.pop(C.GET_KEY)
status = self.controller.snapshot() status = self.controller.snapshot()
self.assertEqual(status.codec_name, 'aptX Adaptive') self.assertEqual(status.codec_name, 'aptX Adaptive')
self.assertEqual(status.quality, '24 Bit / 48 kHz') self.assertEqual(status.quality, tr('24 Bit') + ' / 48 kHz')
self.assertEqual(status.firmware, '3.11.0') self.assertEqual(status.firmware, '3.11.0')
self.assertEqual(self.device.writes, []) self.assertEqual(self.device.writes, [])
self.assertNotIn('password', status.public_dict()) self.assertNotIn('password', status.public_dict())
+67
View File
@@ -0,0 +1,67 @@
import json
import os
from pathlib import Path
import string
import subprocess
import sys
import unittest
from btd700.controller import Controller
from btd700.demo import DemoTransport
from btd700.i18n import ENGLISH, detect_language, get_language, set_language, tr
ROOT = Path(__file__).resolve().parents[1]
class TranslationTests(unittest.TestCase):
def setUp(self):
self.language = get_language()
def tearDown(self):
set_language(self.language)
def test_language_detection(self):
for env, expected in (({}, 'en'), ({'LANG': 'de_DE.UTF-8'}, 'de'),
({'LANG': 'fr_FR.UTF-8'}, 'en'),
({'LANGUAGE': 'de:en', 'LANG': 'en_GB.UTF-8'}, 'de'),
({'BTD700_LANGUAGE': 'en', 'LANG': 'de_DE.UTF-8'}, 'en')):
self.assertEqual(detect_language(env), expected)
def test_templates_keep_all_fields(self):
formatter = string.Formatter()
def fields(text):
return sorted((field, spec, conversion) for _, field, spec, conversion in formatter.parse(text) if field is not None)
for source, translated in ENGLISH.items():
self.assertTrue(translated)
self.assertEqual(fields(source), fields(translated), source)
def test_status_translates_without_changing_device_values(self):
controller = Controller(DemoTransport())
set_language('en')
english = controller.snapshot().public_dict()
self.assertEqual(english['state_name'], 'Playing audio')
self.assertEqual(english['transport_name'], 'Automatic')
self.assertEqual(english['audio_quality'], '24-bit / 48 kHz')
set_language('de')
german = controller.snapshot().public_dict()
self.assertEqual(german['state_name'], 'Musikwiedergabe')
self.assertEqual(german['audio_quality'], '24 Bit / 48 kHz')
for field in ('mode', 'transport', 'codec', 'firmware', 'broadcast_name'):
self.assertEqual(english[field], german[field])
def test_english_error(self):
set_language('en')
with self.assertRaisesRegex(ValueError, 'Unknown audio mode'):
Controller(DemoTransport()).set_mode(255)
set_language('de')
self.assertEqual(tr('Speichern'), 'Speichern')
self.assertEqual(tr('aptX Adaptive'), 'aptX Adaptive')
def test_cli_override_and_help(self):
env = dict(os.environ, BTD700_LANGUAGE='de')
command = [sys.executable, '-m', 'btd700', '--language', 'en']
result = subprocess.run(command + ['--demo', 'status'], cwd=ROOT, env=env, check=True, text=True, capture_output=True)
self.assertEqual(json.loads(result.stdout)['state_name'], 'Playing audio')
result = subprocess.run(command + ['--help'], cwd=ROOT, env=env, check=True, text=True, capture_output=True)
self.assertIn('Start in the system tray only', result.stdout)
self.assertNotIn('Nur im Infobereich', result.stdout)
+46
View File
@@ -0,0 +1,46 @@
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
from btd700.integration import APP_ID, autostart_path, desktop_entry, set_autostart
ROOT = Path(__file__).resolve().parents[1]
class IntegrationTests(unittest.TestCase):
def test_install_and_remove_only_own_entries(self):
with tempfile.TemporaryDirectory() as tmp:
env = dict(os.environ, XDG_DATA_HOME=tmp+'/data', XDG_CONFIG_HOME=tmp+'/config', BTD700_LANGUAGE='en')
desktop = Path(tmp) / 'data/applications' / f'{APP_ID}.desktop'
other = desktop.parent / 'other-app.desktop'
subprocess.run([sys.executable, 'install.py'], cwd=ROOT, env=env, check=True, capture_output=True)
self.assertIn('Comment=Linux controls', desktop.read_text())
self.assertIn('Comment[de]=', desktop.read_text())
other.write_text('do not remove')
with patch.dict(os.environ, env):
set_autostart(True)
startup = autostart_path()
self.assertTrue(startup.exists())
self.assertIn('--background', startup.read_text())
subprocess.run([sys.executable, 'install.py', '--uninstall'], cwd=ROOT, env=env, check=True, capture_output=True)
self.assertFalse(desktop.exists())
self.assertFalse(startup.exists())
self.assertEqual(other.read_text(), 'do not remove')
def test_launcher_quotes_special_path_characters(self):
with patch('btd700.integration.ROOT', Path('/tmp/BTD "Control" $home 100%')):
with patch.object(Path, 'is_file', return_value=True):
text = desktop_entry()
self.assertIn('\\"Control\\"', text)
self.assertIn('\\$home', text)
self.assertIn('100%%', text)
def test_installed_package_launcher_uses_python_module(self):
with patch.object(Path, 'is_file', return_value=False):
text = desktop_entry(background=True)
self.assertIn(' -m btd700 --background', text)
self.assertIn('Icon=audio-headphones', text)
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Capture the actual GTK application with fictional demo data, without USB access."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from btd700.app import Application
from gi.repository import GLib, Gtk
output = Path(__file__).resolve().parents[1] / 'docs/screenshots'
output.mkdir(parents=True, exist_ok=True)
app = Application(demo=True, language='en')
phase = 0
attempts = 0
success = False
def capture(name):
window = app.window
snapshot = Gtk.Snapshot.new()
Gtk.WidgetPaintable.new(window).snapshot(snapshot, window.get_width(), window.get_height())
node = snapshot.to_node()
if node is None:
return False
texture = window.get_renderer().render_texture(node, None)
if not texture.save_to_png(str(output / name)):
raise RuntimeError('Could not save screenshot')
print(f'{name}: {window.get_width()} × {window.get_height()}', flush=True)
return True
def tick():
global phase, attempts, success
attempts += 1
try:
if attempts > 40:
raise TimeoutError('Screenshot window did not become ready')
if not app.status or app.busy:
return True
if phase == 0:
app.window.set_default_size(620, 880)
phase = 1
elif phase == 1:
if capture('main-window.png'):
adjustment = app.scroll.get_vadjustment()
adjustment.set_value(adjustment.get_upper() - adjustment.get_page_size())
phase = 2
elif phase == 2:
if capture('auracast-settings.png'):
success = True
app.quit()
return False
except Exception as exc:
print(f'Capture failed: {exc}', file=sys.stderr)
app.quit()
return False
return True
GLib.timeout_add(500, tick)
app.run(['btd700-screenshots'])
raise SystemExit(0 if success else 1)
+2 -1
View File
@@ -4,6 +4,7 @@ import sys
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from btd700.app import Application from btd700.app import Application
from btd700.i18n import tr
from gi.repository import Gio, GLib, Gtk from gi.repository import Gio, GLib, Gtk
app = Application(demo=True) app = Application(demo=True)
@@ -16,7 +17,7 @@ bus_pending = False
def menu_item(labels): def menu_item(labels):
root = app.tray.root root = app.tray.root
for label in labels: for label in labels:
root = next(item for item in root.get_children() if item.property_get('label') == label) root = next(item for item in root.get_children() if item.property_get('label') == tr(label))
return root return root