diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..e820166
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -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.
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..ca03461
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..34b1fe4
--- /dev/null
+++ b/CHANGELOG.md
@@ -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.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..17ddeca
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -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.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..e4950cb
--- /dev/null
+++ b/LICENSE
@@ -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.
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..3081f93
--- /dev/null
+++ b/MANIFEST.in
@@ -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
diff --git a/README.de.md b/README.de.md
new file mode 100644
index 0000000..a1b3c4e
--- /dev/null
+++ b/README.de.md
@@ -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.
+
+
+
+
+
+
+## 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 4–16 ASCII-Buchstaben/Ziffern mit
+inneren Leerzeichen; ein leerer Name stellt den Gerätenamen wieder her. Passwörter
+verwenden 4–16 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).
diff --git a/README.md b/README.md
index 11312dd..64a97a0 100644
--- a/README.md
+++ b/README.md
@@ -1,101 +1,186 @@
-# BTD 700 Control für Linux
+
+
+
-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**.
+BTD 700 Control for Linux
-**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.
+Your dongle. Your codecs. A native Linux app and system tray menu.
-## Starten
+
+
+
+
+
+
+
+
+ Install ·
+ Download source v0.2.0 ·
+ Deutsche Anleitung ·
+ Report an issue
+
+
+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.
+
+
+
+
+
+
+## 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
+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
```
-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.
+Or [download the v0.2.0 source ZIP](https://github.com/justin-eckenweber/btd700linux/archive/refs/tags/v0.2.0.zip),
+extract it, and run `bash run.sh` inside the extracted folder.
+
+Want to explore without touching any hardware?
```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
+./run.sh --demo
```
-Den Projektordner nach der Installation behalten. „Beim Anmelden starten“ in der
-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 4–16 ASCII-Buchstaben/Ziffern mit
-inneren Leerzeichen; ein leerer Name stellt den Gerätenamen wieder her. Passwörter
-verwenden 4–16 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:
+### 3. Add it to your application menu
```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
+python3 install.py
```
-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.
+Open **BTD 700 Control** from the application menu. Keep the downloaded project
+folder in place: the launcher points to it. Enable **Start at login** inside the
+app if you want it to start quietly in the tray. Autostart is off by default.
-Fehlt die Menü-Bibliothek, bleibt das Fenster nutzbar. Das Terminalinterface
-benötigt ausschließlich die Python-Standardbibliothek.
+```bash
+./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
-`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:
+If the app reports that USB access is denied:
```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.
+Unplug and reconnect the dongle afterwards. The rule grants the active local user
+access to the **control interface** of USB device `3542:3001`; it excludes the second
+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:** 4–16 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
./run.sh devices
@@ -106,43 +191,59 @@ als root starten. Auf dem getesteten System ist bereits Zugriff vorhanden.
./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 auracast --name 'Living Room' --quality high --public on
+./run.sh auracast --password --encryption on # Hidden password prompt
./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.
+`status` prints JSON without reading a password. With multiple dongles, place
+`--device /dev/hidrawN` before the subcommand; use the path reported by `devices`.
+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
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
-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`.
+The separate `tools/check_hardware.py --run` is an opt-in hardware test that changes
+settings, including the broadcast password, and attempts to restore the originals.
+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.
-- 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.
+The project's own code and documentation are licensed under **[MIT](LICENSE)**.
+The AI-development and no-warranty notice above is intentional; please keep it
+visible when describing this project.
-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).
+Sennheiser, BTD 700, aptX and Auracast names identify the compatible product and
+technologies. This project is not affiliated with, endorsed by or supported by
+Sennheiser, Sonova or Qualcomm. No vendor code, firmware or logos are included.
+System libraries and original vendor software retain their own licenses;
+see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
new file mode 100644
index 0000000..cf025b4
--- /dev/null
+++ b/THIRD_PARTY_NOTICES.md
@@ -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.
diff --git a/btd700/__init__.py b/btd700/__init__.py
index c1f1ef6..6e56d1a 100644
--- a/btd700/__init__.py
+++ b/btd700/__init__.py
@@ -1,3 +1,3 @@
"""Independent Linux controls for the Sennheiser BTD 700."""
-__version__ = "0.1.0"
+__version__ = "0.2.0"
diff --git a/btd700/__main__.py b/btd700/__main__.py
index d877b4d..8526509 100644
--- a/btd700/__main__.py
+++ b/btd700/__main__.py
@@ -1,3 +1,4 @@
+from .i18n import tr, set_language
import argparse
import getpass
import json
@@ -5,28 +6,34 @@ import sys
def main():
- parser = argparse.ArgumentParser(description='Native Linux-Steuerung für den BTD 700; ohne Firmware-Updates.')
- parser.add_argument('--device', help='Steuergerät aus dem Befehl devices, z. B. /dev/hidraw6')
- parser.add_argument('--background', action='store_true', help='Nur im Infobereich starten')
- parser.add_argument('--demo', action='store_true', help='Vorführmodus ohne USB-Zugriff')
+ language_parser = argparse.ArgumentParser(add_help=False)
+ language_parser.add_argument('--language', choices=['en', 'de'])
+ language_args, _ = language_parser.parse_known_args()
+ 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)
subs = parser.add_subparsers(dest='command')
- subs.add_parser('devices', help='BTD-700-Steuerschnittstellen auflisten')
- subs.add_parser('status', help='Aktuellen Status als JSON ausgeben (ohne Passwort)')
- mode = subs.add_parser('mode', help='Audiomodus wählen')
+ subs.add_parser('devices', help=tr('BTD-700-Steuerschnittstellen auflisten'))
+ subs.add_parser('status', help=tr('Aktuellen Status als JSON ausgeben (ohne Passwort)'))
+ mode = subs.add_parser('mode', help=tr('Audiomodus wählen'))
mode.add_argument('value', choices=['standard', 'gaming', 'auracast'])
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'])
- subs.add_parser('connect', help='Gekoppelte Kopfhörer verbinden')
- subs.add_parser('disconnect', help='Kopfhörer trennen')
- broadcast = subs.add_parser('auracast', help='Auracast-Einstellungen speichern')
+ subs.add_parser('connect', help=tr('Gekoppelte Kopfhörer verbinden'))
+ subs.add_parser('disconnect', help=tr('Kopfhörer trennen'))
+ broadcast = subs.add_parser('auracast', help=tr('Auracast-Einstellungen speichern'))
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('--encryption', choices=['on', 'off'])
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')
args = parser.parse_args()
if not args.command:
@@ -34,7 +41,7 @@ def main():
from .app import run
return run(background=args.background, demo=args.demo, device=args.device, smoke_seconds=args.smoke_seconds)
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
from .transport import DeviceError, Hidraw, discover
from .controller import Controller
@@ -51,7 +58,7 @@ def main():
else:
devices = [d for d in discover() if not args.device or d.path == args.device]
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])
controller = Controller(transport)
if args.command == 'mode':
@@ -63,18 +70,18 @@ def main():
controller.set_connection(args.command == 'connect')
elif args.command == 'auracast':
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',
encrypted=None if args.encryption is None else args.encryption == 'on',
quality={'16k': 0, '24k': 1, 'high': 2}.get(args.quality))
elif args.command == 'reset':
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
print(json.dumps(controller.snapshot().public_dict(), indent=2, ensure_ascii=False))
return 0
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
finally:
if transport:
diff --git a/btd700/app.py b/btd700/app.py
index b6d7eea..b2ccc3c 100644
--- a/btd700/app.py
+++ b/btd700/app.py
@@ -1,4 +1,5 @@
"""Native GTK 4 / libadwaita controls and a persistent notification-area menu."""
+from .i18n import tr, set_language
import sys
import gi
@@ -11,7 +12,9 @@ from .worker import Worker
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)
self.background = background
self.demo = demo
@@ -39,7 +42,7 @@ class Application(Adw.Application):
from .tray import Tray
self.tray = Tray(self)
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.start()
self.worker.commands.put(None)
@@ -79,11 +82,11 @@ class Application(Adw.Application):
toolbar = Adw.ToolbarView()
header = Adw.HeaderBar()
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))
header.pack_start(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())
header.pack_end(quit_button)
toolbar.add_top_bar(header)
@@ -102,10 +105,10 @@ class Application(Adw.Application):
heading = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
icon = Gtk.Image(icon_name='audio-headphones-symbolic', pixel_size=48)
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')
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')
heading.append(self.state_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)
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.add_css_class('linked')
self.mode_buttons = []
@@ -134,50 +137,50 @@ class Application(Adw.Application):
mode_group.add(mode_box)
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.connect('notify::selected', self._codec_selected)
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)
audio.add(self.transport_row)
- self.connection_row = Adw.ActionRow(title='Verbindung')
- self.connection_button = Gtk.Button(label='Verbinden', valign=Gtk.Align.CENTER)
+ self.connection_row = Adw.ActionRow(title=tr('Verbindung'))
+ 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_row.add_suffix(self.connection_button)
audio.add(self.connection_row)
self.device_box.append(audio)
self.auracast_group = Adw.PreferencesGroup(title='Auracast',
- description='Der Audiomodus „Auracast“ startet die Übertragung an mehrere Empfänger.')
- self.name_entry = Adw.EntryRow(title='Name der Übertragung')
+ description=tr('Der Audiomodus „Auracast“ startet die Übertragung an mehrere Empfänger.'))
+ self.name_entry = Adw.EntryRow(title=tr('Name der Übertragung'))
self.name_entry.connect('changed', self._form_changed)
self.auracast_group.add(self.name_entry)
- self.public_row = Adw.SwitchRow(title='Öffentlich auffindbar',
- subtitle='Übertragung in der Auracast-Suche anzeigen')
+ self.public_row = Adw.SwitchRow(title=tr('Öffentlich auffindbar'),
+ subtitle=tr('Übertragung in der Auracast-Suche anzeigen'))
self.public_row.connect('notify::active', self._form_changed)
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.auracast_group.add(self.encryption_row)
- self.password_entry = Adw.PasswordEntryRow(title='Neues Passwort')
- self.password_entry.set_tooltip_text('4–16 Buchstaben oder Ziffern. Leer lassen behält das bisherige Passwort.')
+ self.password_entry = Adw.PasswordEntryRow(title=tr('Neues Passwort'))
+ self.password_entry.set_tooltip_text(tr('4–16 Buchstaben oder Ziffern. Leer lassen behält das bisherige Passwort.'))
self.password_entry.connect('changed', self._password_changed)
self.auracast_group.add(self.password_entry)
- self.broadcast_quality = Adw.ComboRow(title='Übertragungsqualität',
- model=Gtk.StringList.new(['Standard · 16 kHz', 'Standard · 24 kHz', 'Hohe Qualität']))
+ self.broadcast_quality = Adw.ComboRow(title=tr('Übertragungsqualitä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.auracast_group.add(self.broadcast_quality)
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.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.connect('clicked', self._save_broadcast)
actions.append(self.discard_button)
actions.append(self.save_button)
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)
hint.add_css_class('caption')
hint.add_css_class('dim-label')
@@ -185,19 +188,19 @@ class Application(Adw.Application):
self.device_box.append(self.auracast_group)
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)
- autostart = Adw.SwitchRow(title='Beim Anmelden starten', subtitle='Startet unauffällig im Infobereich',
- active=autostart_path().exists())
+ autostart = Adw.SwitchRow(title=tr('Beim Anmelden starten'), subtitle=tr('Startet unauffällig im Infobereich'),
+ active=False if self.demo else autostart_path().exists())
autostart.set_sensitive(not self.demo)
autostart.connect('notify::active', self._autostart_changed)
integration.add(autostart)
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('dim-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.connect('clicked', lambda *_: self.confirm_reset())
self.reset_button = reset
@@ -220,8 +223,8 @@ class Application(Adw.Application):
def tray_changed(self):
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
- 'Kein Infobereich verfügbar; Schließen beendet die App.')
+ self.tray_row.set_subtitle(tr('Beim Schließen bleibt das Symbol in der oberen Leiste.') if available else
+ tr('Kein Infobereich verfügbar; Schließen beendet die App.'))
def perform(self, method, *args, **kwargs):
if self.busy or not self.status:
@@ -233,6 +236,7 @@ class Application(Adw.Application):
self._render()
def _on_status(self, status, error, completed):
+ was_disconnected = self.status is None
self.status = status
self.received_status |= status is not None
if completed:
@@ -245,10 +249,10 @@ class Application(Adw.Application):
self.password_entry.set_text('')
self.updating = 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:
self.last_error = error
- elif self.last_error.startswith(('Kein BTD 700', 'USB-Zugriff fehlt', '[Errno')):
+ elif was_disconnected:
self.last_error = ''
self._render()
return False
@@ -262,7 +266,7 @@ class Application(Adw.Application):
self.error_label.set_visible(bool(self.last_error))
if s:
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)
for mode, button in enumerate(self.mode_buttons):
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_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_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]
if s.transport in (1, 2) and s.transport not in values:
values.append(s.transport)
if values != self.transport_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_selected(values.index(s.transport) if s.transport in values else Gtk.INVALID_LIST_POSITION)
self.transport_row.set_sensitive(s.mode == 0)
- self.connection_row.set_subtitle(STATES.get(s.state, 'Unbekannt'))
- self.connection_button.set_label('Trennen' if s.state >= 2 else 'Verbinden')
+ self.connection_row.set_subtitle(tr(STATES.get(s.state, 'Unbekannt')))
+ self.connection_button.set_label(tr('Trennen') if s.state >= 2 else tr('Verbinden'))
self.connection_button.set_sensitive(s.mode != 2)
if not self.dirty:
self.name_entry.set_text(s.broadcast_name)
self.public_row.set_active(bool(s.broadcast_public))
self.encryption_row.set_active(bool(s.broadcast_encrypted))
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:
- self.codec_label.set_label('BTD 700 verbinden')
- self.state_label.set_label('Warte auf den USB-Dongle …')
+ self.codec_label.set_label(tr('BTD 700 verbinden'))
+ self.state_label.set_label(tr('Warte auf den USB-Dongle …'))
self.quality_label.set_label('')
self.save_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()
if not self.status or self.busy:
return
- dialog = Adw.AlertDialog(heading='BTD 700 zurücksetzen?',
- body='Alle Kopplungen und gespeicherten Einstellungen werden gelöscht. Danach musst du die Kopfhörer erneut koppeln.')
- dialog.add_response('cancel', 'Abbrechen')
- dialog.add_response('reset', 'Zurücksetzen')
+ dialog = Adw.AlertDialog(heading=tr('BTD 700 zurücksetzen?'),
+ body=tr('Alle Kopplungen und gespeicherten Einstellungen werden gelöscht. Danach musst du die Kopfhörer erneut koppeln.'))
+ dialog.add_response('cancel', tr('Abbrechen'))
+ dialog.add_response('reset', tr('Zurücksetzen'))
dialog.set_response_appearance('reset', Adw.ResponseAppearance.DESTRUCTIVE)
dialog.set_default_response('cancel')
dialog.set_close_response('cancel')
@@ -370,7 +374,7 @@ class Application(Adw.Application):
try:
set_autostart(row.get_active())
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()
diff --git a/btd700/controller.py b/btd700/controller.py
index 6a16051..55ebf8b 100644
--- a/btd700/controller.py
+++ b/btd700/controller.py
@@ -1,4 +1,5 @@
"""Device operations with readback and capability checks."""
+from .i18n import tr
from dataclasses import asdict, dataclass
import time
@@ -31,8 +32,8 @@ class Status:
@property
def quality(self):
- bits = {1: '16 Bit', 2: '24 Bit'}.get(self.resolution, '—')
- frequency = {1: '44,1 kHz', 2: '48 kHz', 3: '96 kHz'}.get(self.frequency, '—')
+ bits = {1: tr('16 Bit'), 2: tr('24 Bit')}.get(self.resolution, '—')
+ frequency = {1: tr('44,1 kHz'), 2: '48 kHz', 3: '96 kHz'}.get(self.frequency, '—')
return f'{bits} / {frequency}'
@property
@@ -43,10 +44,10 @@ class Status:
def public_dict(self):
result = asdict(self)
- result.update(state_name=STATES.get(self.state, f'Unbekannt ({self.state})'),
- mode_name=MODES.get(self.mode, f'Unbekannt ({self.mode})'),
+ result.update(state_name=tr(STATES.get(self.state, tr('Unbekannt ({value})').format(value=self.state))),
+ mode_name=tr(MODES.get(self.mode, tr('Unbekannt ({value})').format(value=self.mode))),
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
@@ -70,7 +71,7 @@ class Controller:
try:
self.firmware = '.'.join(map(str, q(C.GET_VERSION)[:3]))
except TimeoutError:
- self.firmware = 'Unbekannt'
+ self.firmware = tr('Unbekannt')
# Firmware 3.11 does not respond to GET_GAMING; use events/fallback,
# matching the original application's capability logic.
event = self.transport.events.get(23, b'')
@@ -87,20 +88,20 @@ class Controller:
if matches:
return
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)
def set_mode(self, mode: int, transport: int | None = None):
status = self.snapshot()
if mode not in MODES:
- raise ValueError('Unbekannter Audiomodus.')
+ raise ValueError(tr('Unbekannter Audiomodus.'))
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
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:
- 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))
self.transport.request(C.SET_MODE, payload)
self._verify(C.GET_MODE, payload, exact=False)
@@ -108,9 +109,9 @@ class Controller:
def set_codec(self, codec: int):
status = self.snapshot()
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:
- 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,))
self.transport.request(C.SET_CODEC, payload)
self._verify(C.GET_CODEC, payload)
@@ -123,7 +124,7 @@ class Controller:
if (state >= 2) == connected:
return
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)
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
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)):
- 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):
- raise ValueError('Unbekannte Auracast-Qualität.')
+ raise ValueError(tr('Unbekannte Auracast-Qualität.'))
before = self.snapshot()
info = [before.broadcast_public, before.broadcast_quality, before.broadcast_encrypted]
if public is not None:
@@ -145,11 +146,11 @@ class Controller:
if encrypted is not None:
info[2] = int(encrypted)
if info[2] and key_data == b'':
- raise ValueError('Verschlüsselung benötigt ein Passwort mit 4–16 Zeichen.')
+ raise ValueError(tr('Verschlüsselung benötigt ein Passwort mit 4–16 Zeichen.'))
if info[2] and key_data is None:
existing = self.transport.request(C.GET_KEY).rstrip(b'\0')
if len(existing) < 4:
- raise ValueError('Bitte zuerst ein Passwort mit 4–16 Zeichen eingeben.')
+ raise ValueError(tr('Bitte zuerst ein Passwort mit 4–16 Zeichen eingeben.'))
# Preserve the original settings while changing a key, then write
# key, name, and final settings in the order used by the original app.
try:
@@ -169,9 +170,9 @@ class Controller:
except Exception as exc:
# Do not silently repeat writes or claim atomicity: settings persist
# 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):
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)
diff --git a/btd700/demo.py b/btd700/demo.py
index cc3d3db..8eb175f 100644
--- a/btd700/demo.py
+++ b/btd700/demo.py
@@ -1,4 +1,5 @@
"""Explicit, isolated demonstration device; never opens USB."""
+from .i18n import tr
from .protocol import Command as C, encode
@@ -30,7 +31,7 @@ class DemoTransport:
elif command == C.FACTORY_RESET:
self.__init__()
else:
- raise TimeoutError('Nicht unterstützte Demo-Abfrage')
+ raise TimeoutError(tr('Nicht unterstützte Demo-Abfrage'))
return b''
def close(self):
diff --git a/btd700/i18n.py b/btd700/i18n.py
new file mode 100644
index 0000000..c26b776
--- /dev/null
+++ b/btd700/i18n.py
@@ -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',
+ '4–16 Buchstaben oder Ziffern. Leer lassen behält das bisherige Passwort.': '4–16 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 4–16 Zeichen.': 'Encryption requires a password of 4–16 characters.',
+ 'Bitte zuerst ein Passwort mit 4–16 Zeichen eingeben.': 'Enter a password of 4–16 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: 4–16 Buchstaben oder Ziffern.': 'Password: 4–16 letters or digits.',
+ 'Name: 4–16 Buchstaben, Ziffern oder innere Leerzeichen.': 'Name: 4–16 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)',
+}
diff --git a/btd700/integration.py b/btd700/integration.py
index dba10d8..a75d411 100644
--- a/btd700/integration.py
+++ b/btd700/integration.py
@@ -15,7 +15,8 @@ def desktop_entry(*, background=False):
# Desktop Entry Exec quoting is not shell quoting.
escaped = path.replace('\\', '\\\\').replace('"', '\\"').replace('`', '\\`').replace('$', '\\$').replace('%', '%%')
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'Icon={icon}\nTerminal=false\n'
'Categories=AudioVideo;Audio;\n'
diff --git a/btd700/protocol.py b/btd700/protocol.py
index 3f792a6..dfdd9ea 100644
--- a/btd700/protocol.py
+++ b/btd700/protocol.py
@@ -1,4 +1,5 @@
"""BTD 700 control protocol. No updater transport or arbitrary commands."""
+from .i18n import tr
from dataclasses import dataclass
from enum import IntEnum
import re
@@ -51,8 +52,8 @@ def validate_text(value: str, *, key: bool = False) -> bytes:
"""Match the original app's 4–16 ASCII character input bounds; empty resets."""
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()):
- what = 'Passwort: 4–16 Buchstaben oder Ziffern.' if key else (
- 'Name: 4–16 Buchstaben, Ziffern oder innere Leerzeichen.')
+ what = tr('Passwort: 4–16 Buchstaben oder Ziffern.') if key else (
+ tr('Name: 4–16 Buchstaben, Ziffern oder innere Leerzeichen.'))
raise ValueError(what)
return value.encode('ascii')
@@ -61,7 +62,7 @@ def encode(command: Command, payload: bytes = b'') -> bytes:
try:
command = Command(command)
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:
valid = not payload
elif command == Command.SET_MODE:
@@ -79,13 +80,13 @@ def encode(command: Command, payload: bytes = b'') -> bytes:
else:
valid = False
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))
def acknowledge(event: int) -> bytes:
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)
@@ -100,5 +101,5 @@ def decode(report: bytes) -> Message | None:
if not report or report[0] != REPORT_ID:
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]:
- 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]])
diff --git a/btd700/transport.py b/btd700/transport.py
index 507e94d..0cb5c9b 100644
--- a/btd700/transport.py
+++ b/btd700/transport.py
@@ -1,4 +1,5 @@
"""Linux hidraw access, limited to the BTD 700 control interface."""
+from .i18n import tr
from dataclasses import dataclass
from pathlib import Path
import fcntl
@@ -49,14 +50,14 @@ class Hidraw:
fcntl.ioctl(self.fd, 0x80084803, info, True) # HIDIOCGRAWINFO
bus, vendor, product = struct.unpack('IHH', info)
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:
fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
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:
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:
self.close()
raise
@@ -74,16 +75,16 @@ class Hidraw:
def _write(self, report):
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]:
- raise TimeoutError('USB-Schreibzugriff hat nicht geantwortet.')
+ raise TimeoutError(tr('USB-Schreibzugriff hat nicht geantwortet.'))
if os.write(self.fd, report) != len(report):
- raise DeviceError('Unvollständiger USB-Schreibvorgang.')
+ raise DeviceError(tr('Unvollständiger USB-Schreibvorgang.'))
def _receive(self):
report = os.read(self.fd, 1024)
if not report:
- raise DeviceError('Dongle wurde entfernt.')
+ raise DeviceError(tr('Dongle wurde entfernt.'))
message = decode(report)
if message and message.kind == 0xFC and message.command in EVENTS:
self.events[message.command] = message.payload
@@ -107,9 +108,9 @@ class Hidraw:
message = self._receive()
if message and message.kind == 0xFF and message.command == command:
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
- 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):
self.close()
raise
diff --git a/btd700/tray.py b/btd700/tray.py
index daabbf1..aeae4d7 100644
--- a/btd700/tray.py
+++ b/btd700/tray.py
@@ -1,4 +1,5 @@
"""StatusNotifierItem + libdbusmenu, compatible with GNOME AppIndicator and KDE."""
+from .i18n import tr
import gi
from gi.repository import Gio, GLib
from .protocol import CODECS, MODES, STATES
@@ -65,7 +66,7 @@ class Tray:
self.app.tray_changed()
def _property(self, _bus, _sender, _path, _iface, name):
- tooltip = 'BTD 700 anschließen'
+ tooltip = tr('BTD 700 anschließen')
if self.status:
tooltip = f'{MODES.get(self.status.mode, "—")} · {self.status.codec_name} · {self.status.quality}'
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))
if status:
add(self._item(f'{status.codec_name} · {status.quality}', enabled=False))
- add(self._item(STATES.get(status.state, 'Unbekannter Status'), enabled=False))
- modes = self._submenu(root, 'Audiomodus')
+ add(self._item(tr(STATES.get(status.state, 'Unbekannter Status')), enabled=False))
+ modes = self._submenu(root, tr('Audiomodus'))
for value, label in MODES.items():
modes.child_append(self._item(label,
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,
checked=bool(status.codec & value), radio=True))
if not codecs.get_children():
- codecs.child_append(self._item('Keine verbundenen Kopfhörer', enabled=False))
- transports = self._submenu(root, 'Bluetooth-Transport')
- for value, label in ((3, 'Automatisch'), (1, 'Bluetooth Classic'), (2, 'LE Audio')):
+ codecs.child_append(self._item(tr('Keine verbundenen Kopfhörer'), enabled=False))
+ transports = self._submenu(root, tr('Bluetooth-Transport'))
+ for value, label in ((3, tr('Automatisch')), (1, 'Bluetooth Classic'), (2, 'LE Audio')):
transports.child_append(self._item(label,
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)),
checked=status.transport == value, radio=True))
auracast = self._submenu(root, 'Auracast')
- auracast.child_append(self._item('Name und Passwort …', lambda: self.app.show_window('auracast')))
- auracast.child_append(self._item('Öffentlich auffindbar',
+ auracast.child_append(self._item(tr('Name und Passwort …'), lambda: self.app.show_window('auracast')))
+ auracast.child_append(self._item(tr('Öffentlich auffindbar'),
lambda: self.app.perform('set_broadcast', public=not 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,
checked=bool(status.broadcast_encrypted)))
- quality = self._submenu(auracast, 'Übertragungsqualität')
- for value, label in enumerate(('Standard · 16 kHz', 'Standard · 24 kHz', 'Hohe Qualität')):
+ quality = self._submenu(auracast, tr('Übertragungsqualität'))
+ for value, label in enumerate((tr('Standard · 16 kHz'), tr('Standard · 24 kHz'), tr('Hohe Qualität'))):
quality.child_append(self._item(label,
lambda v=value: self.app.perform('set_broadcast', quality=v),
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))
- add(self._item('Werkseinstellungen …', self.app.confirm_reset, enabled=not busy))
+ add(self._item(tr('Werkseinstellungen …'), self.app.confirm_reset, enabled=not busy))
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))
if busy:
- add(self._item('Einstellung wird übernommen …', enabled=False))
+ add(self._item(tr('Einstellung wird übernommen …'), enabled=False))
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.property_set('type', 'separator')
add(separator)
- add(self._item('Fenster öffnen', self.app.show_window))
- add(self._item('Beenden', self.app.quit))
+ add(self._item(tr('Fenster öffnen'), self.app.show_window))
+ add(self._item(tr('Beenden'), self.app.quit))
self.root = root # Keep Python signal closures alive.
self.server.set_root(root)
self.bus.emit_signal(None, '/StatusNotifierItem', INTERFACE, 'NewToolTip', None)
diff --git a/btd700/worker.py b/btd700/worker.py
index ebc8f34..f70c21d 100644
--- a/btd700/worker.py
+++ b/btd700/worker.py
@@ -1,4 +1,5 @@
"""One thread owns the USB handle; the GTK main loop never waits for USB."""
+from .i18n import tr
import queue
import threading
from gi.repository import GLib
@@ -44,9 +45,9 @@ class Worker(threading.Thread):
if self.device_path:
devices = [d for d in devices if d.path == self.device_path]
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:
- 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.controller = Controller(self.transport)
if action:
diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md
index 65a30f4..edb54c1 100644
--- a/docs/PROTOCOL.md
+++ b/docs/PROTOCOL.md
@@ -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
-eigenen USB-Dongles. Die Windows-Anwendung wurde statisch untersucht, nicht ausgeführt.
+Investigation date: 5 September 2026. Purpose: independently control a user's
+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)
-- [Offizieller Dongle-Control-Download](https://uk.sennheiser-hearing.com/pages/sennheiser-dongle-control)
-- [Dort verlinktes Windows-ZIP](https://eu-central-1-akqa.graphassets.com/AGz66yvUcQ42Ggm7CrXdgz/cmgrvi8excrci07uu3ivz166x)
-- Archiv: `windows-signed-v1.0.5/Sennheiser Dongle Control.exe`, Version 1.0.5.0,
+- [Official product page](https://uk.sennheiser-hearing.com/products/btd-700)
+- [Official Dongle Control download page](https://uk.sennheiser-hearing.com/pages/sennheiser-dongle-control)
+- [Windows ZIP linked from that page](https://eu-central-1-akqa.graphassets.com/AGz66yvUcQ42Ggm7CrXdgz/cmgrvi8excrci07uu3ivz166x)
+- Archive entry: `windows-signed-v1.0.5/Sennheiser Dongle Control.exe`, version 1.0.5.0,
ProductVersion `1.0.5+eac62d73c43c8572999e7e68cbaec6e4536a1318`.
-- SHA256 ZIP: `1d1057b7eb64ab08e41d76723c343c691196f0affe1d5fc9168f01f9908a8cc7`
-- SHA256 EXE: `e176f1ab7d4aae40308c152b0bd95227ac5a16fedb99efef7e9229345d8014c0`
-- SHA256 eingebettete App-Assembly:
+- ZIP SHA256: `1d1057b7eb64ab08e41d76723c343c691196f0affe1d5fc9168f01f9908a8cc7`
+- EXE SHA256: `e176f1ab7d4aae40308c152b0bd95227ac5a16fedb99efef7e9229345d8014c0`
+- Embedded application assembly SHA256:
`2e8c89ea0333b0a9dd5bb0e851cd685810edaa6e32caee7645511d646ee970b4`
-Die EXE ist ein .NET-Single-File-Bundle, Manifestversion 6 mit 451 Einträgen.
-Die benötigte Assembly kann mit `tools/extract_control_assembly.py` aus einer
-lokalen Original-EXE extrahiert werden. Das Bundleformat wurde mit den
-[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)-
-Definitionen des .NET-Runtimes abgeglichen.
+The executable is a .NET single-file bundle, manifest version 6, with 451 entries.
+`tools/extract_control_assembly.py` can extract only the application assembly from
+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)
+and [FileEntry](https://github.com/dotnet/runtime/blob/main/src/installer/managed/Microsoft.NET.HostModel/Bundle/FileEntry.cs)
+definitions.
-Analysewerkzeug: ILSpy CLI 11.0.0.9375. Relevante Typen:
-`BTDTool.BTD700Tool`, `_BTD700_HOSTCMD`, `_BTD700_DONGLECMD`, `_BTD700_*`-Enums,
+Analysis tool: ILSpy CLI 11.0.0.9375. Relevant original types:
+`BTDTool.BTD700Tool`, `_BTD700_HOSTCMD`, `_BTD700_DONGLECMD`, the `_BTD700_*` enums,
`BTD700Context`, `HidDeviceExt.sendGenericCommand`,
-`ViewModels.MainAppWindowViewModel`, `Views.AppBtd700Features`.
+`ViewModels.MainAppWindowViewModel` and `Views.AppBtd700Features`.
-Die neue Implementierung enthält nur rekonstruierte Protokollfakten und eigenen
-Code. Original-Binaries, dekompilierte Originalquellen und Ressourcen gehören
-nicht zum Projekt. Die Herstellerlizenz der Originalsoftware bleibt davon getrennt.
+This repository contains protocol facts and an independent implementation.
+Original executables, decompiled original sources and vendor resources are not
+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
-Vendor Usage Page `0xFFA2`, Report ID **0x34**. Auf dem Testsystem liegt sie unter
-`/dev/hidraw6`; die Nummer wird dynamisch ermittelt. Interface 1 ist für diese
-Steuer-App nicht erforderlich und wird nicht geöffnet.
+VID `0x3542`, PID `0x3001`, USB HID interface 0. The control collection uses vendor
+usage page `0xFFA2` and report ID **0x34**. It appeared as `/dev/hidraw6` on the test
+system; the application discovers the path dynamically from its descriptor.
+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` |
-| 1 | `FE` Host-Befehl; `FF` Dongle-Antwort; `FC` Dongle-Ereignis; `FD` Ereignisbestätigung |
-| 2 | Befehls- oder Ereignisnummer |
-| 3 | Nutzdatenlänge, maximal 60 |
-| 4… | Nutzdaten |
+| 0 | Report ID `34` |
+| 1 | `FE`: host command; `FF`: dongle response; `FC`: dongle event; `FD`: event acknowledgement |
+| 2 | Command or event number |
+| 3 | Payload length, at most 60 |
+| 4… | Payload |
-Host-Abfrage Beispiel: `34 FE 06 00` + 60 Nullbytes.
-Antwort Beispiel: `34 FF 06 01 03` (Audio läuft).
-Bestätigung des Ereignisses 15: `34 FD 0F 00` + 60 Nullbytes.
+Example query: `34 FE 06 00` followed by 60 zero bytes.
+Example reply: `34 FF 06 01 03` (audio is playing).
+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
-Medientasten auf derselben Schnittstelle stammen und werden ignoriert. Antworten
-werden nach Richtung und Befehlsnummer zugeordnet; Längen werden geprüft.
-Nur eine Anfrage ist gleichzeitig aktiv. Schreibbefehle werden nicht blind
-wiederholt; für Einstellungen wird anschließend der Wert erneut abgefragt.
-Es gibt keine Transaktions-ID und keine garantierte Atomizität mehrerer Einstellungen.
+Events 2/3/4/15/16/17/22/23 are acknowledged. Other report IDs can carry media keys
+on the same interface and are ignored. Replies are matched by direction and
+command number, with length validation. Only one request is outstanding at a time.
+Setters are not blindly retried; settings are queried again for confirmation.
+There is no transaction ID or guarantee of atomic multi-setting updates.
-## Befehle
+## Commands
-Alle Nummern hexadezimal. Bei Lesezugriffen ist die Anfrage-Nutzlast leer.
-Die nachfolgende Nutzlast beschreibt bei `GET` die Antwort, bei `SET` die Anfrage.
+IDs below are hexadecimal. GET requests have an empty payload. The payload column
+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 |
-| 02 | SET Modus/Transport | Modus, Transport |
-| 03 | GET verfügbare Codecs | Codec-Bitmaske |
-| 04 | SET Codec | Einzelnes Codec-Bit, nicht Bitindex |
-| 05 | GET aktiver Codec | Codec-Bitmaske |
-| 06 | GET Dongle-Zustand | Zustand |
-| 07 | GET LE-Audio-Zustand | LE-Zustand |
-| 08 | GET Audioqualität | Auflösung, Frequenz |
-| 09 | GET Auracast-Konfiguration | öffentlich, Qualität, Verschlüsselung |
-| 0A | SET Auracast-Konfiguration | öffentlich, Qualität, Verschlüsselung |
-| 0B | GET Auracast-Schlüssel | Zeichenbytes; nur bei expliziter Passwort-Operation gelesen |
-| 0C | SET Auracast-Schlüssel | 0–16 Bytes; UI beschränkt auf leer oder 4–16 ASCII-Zeichen |
-| 0D | GET Auracast-Name | Zeichenbytes; am Gerät 32 Bytes, mit Null aufgefüllt |
-| 0E | SET Auracast-Name | bis 16 Zeichenbytes; leer setzt Gerätenamen zurück |
-| 12 | GET Firmwareversion | drei Versionsbytes, nur Anzeige |
-| 13 | Werksreset | leer; nur nach Nutzerbestätigung |
-| 14 | Bluetooth verbinden/trennen | 1 / 0 |
-| 15 | GET Kopfhörer-Transportmöglichkeiten | Bitmaske |
-| 17 | GET Gaming-Verfügbarkeit | im Original definiert; Firmware 3.11 antwortet nicht, daher nicht regelmäßig abgefragt |
+| 01 | GET mode/transport | Mode, configured transport, optional connected transport |
+| 02 | SET mode/transport | Mode, transport |
+| 03 | GET available codecs | Codec bitmask |
+| 04 | SET codec | One codec bit, not its bit index |
+| 05 | GET active codec | Codec bitmask |
+| 06 | GET dongle state | State |
+| 07 | GET LE Audio state | LE state |
+| 08 | GET audio quality | Resolution, frequency |
+| 09 | GET Auracast configuration | Public discovery, quality, encryption |
+| 0A | SET Auracast configuration | Public discovery, quality, encryption |
+| 0B | GET Auracast key | Character bytes; only read by explicit password operations |
+| 0C | SET Auracast key | 0–16 bytes; UI accepts empty or 4–16 ASCII characters |
+| 0D | GET Auracast name | Character bytes; 32 bytes with zero padding on the tested device |
+| 0E | SET Auracast name | Up to 16 character bytes; empty restores the device default |
+| 12 | GET firmware version | Three version bytes, display only |
+| 13 | Factory reset | Empty; requires user confirmation |
+| 14 | Bluetooth connect/disconnect | 1 / 0 |
+| 15 | GET supported headphone transports | Bitmask |
+| 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.
-Transport: 0 getrennt, 1 BR/EDR, 2 LE Audio, 3 Dual/automatisch. Setzen von 0 wird
-in der neuen App nicht angeboten; Trennen hat einen eigenen Befehl.
+Mode: 0 Standard, 1 Gaming, 2 Auracast.
+Transport: 0 disconnected, 1 BR/EDR, 2 LE Audio, 3 dual/automatic. The app does not
+set transport 0; disconnection has a separate command.
-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
-Bits werden im Menü angezeigt. Die Firmware kann die Liste abhängig vom Modus ändern.
+Codec bits: `01` SBC, `02` aptX Classic, `04` aptX Adaptive/Low Latency,
+`08` aptX Lossless, `10` aptX Lite/QMAP, `20` LC3. Only offered bits appear as
+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.
-LE-Zustand: 0 keiner, 1 getrennt, 2 verbunden, 3 Unicast, 4 Broadcast.
-Auflösung: 1 = 16 Bit, 2 = 24 Bit.
-Frequenz: 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;
-Verschlüsselung 0/1. „Öffentlich“ bezeichnet die Ankündigung/Auffindbarkeit,
-nicht das Aktivieren des Audiomodus.
+Dongle state: 0 none/ready, 1 disconnected, 2 connected, 3 audio, 4 voice.
+LE state: 0 none, 1 disconnected, 2 connected, 3 unicast, 4 broadcast.
+Resolution: 1 = 16-bit, 2 = 24-bit.
+Frequency: 1 = 44.1 kHz, 2 = 48 kHz, 3 = 96 kHz.
+Auracast: public discovery 0/1; quality 0 = SQ 16 kHz, 1 = SQ 24 kHz, 2 = HQ;
+encryption 0/1. Public discovery advertises the broadcast; it does not select
+the audio mode or start/stop audio by itself.
-Gaming-Verfügbarkeit fällt wie in der Original-App auf den Verbindungsstatus,
-aptX-Adaptive-Bit und LE-Transport zurück, solange kein Ereignis 23 eingetroffen ist.
+Gaming availability falls back to the current connection, aptX Adaptive bit and
+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 |
-| 01 | `34 FF 01 03 01 03 01` | Gaming, automatisch, verbunden per Classic |
-| 03 | `34 FF 03 01 04` | aktuell aptX Adaptive angeboten |
-| 05 | `34 FF 05 01 04` | aptX Adaptive aktiv |
-| 07 | `34 FF 07 01 01` | LE getrennt |
-| 08 | `34 FF 08 02 02 02` | 24 Bit / 48 kHz |
-| 09 | `34 FF 09 03 01 02 00` | öffentlich, HQ, unverschlüsselt konfiguriert |
+| 06 | `34 FF 06 01 03` | Audio playing |
+| 01 | `34 FF 01 03 01 03 01` | Gaming, automatic, connected using Classic |
+| 03 | `34 FF 03 01 04` | aptX Adaptive currently offered |
+| 05 | `34 FF 05 01 04` | aptX Adaptive active |
+| 07 | `34 FF 07 01 01` | LE disconnected |
+| 08 | `34 FF 08 02 02 02` | 24-bit / 48 kHz |
+| 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 |
-| 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
-werden hier nicht dokumentiert. Die verfügbare Audiokonfiguration belegt nicht,
-dass Audio mit diesem Profil bitgenau übertragen wird.
+The broadcast name was also read successfully. Private device identifiers and keys
+are omitted. The reported audio configuration does not prove bit-perfect playback.
-## Getrennte Update-Funktion und Grenzen
+## Update exclusion and remaining limitations
-Die App implementiert ausschließlich Report `0x34` auf der Kontrollschnittstelle.
-Sie enthält keine DFU-/Upgrade-Kommandos, Firmwaredateien, Firmwareparser,
-Firmware-Download-URLs oder Umschaltung in einen Update-Modus.
-Die Firmwareversion wird nur über den Kontrollbefehl `0x12` gelesen.
+Only control report `0x34` is implemented. The runtime has no DFU/upgrade commands,
+firmware files, firmware parser, firmware-download endpoints or update-mode switch.
+Firmware version is read using control command `0x12` solely for display.
-Der Nutzer hat die Funktion der App am eigenen Dongle bestätigt. Der automatisierte
-Hardware-Umschalttest wurde noch nicht ausgeführt; die Rückmeldung belegt keine
-vollständige Prüfung jedes Schreibbefehls. Insbesondere Wiederverbindung, echte
-Auracast-Empfänger und Firmwareunterschiede benötigen weitere systematische Tests.
+The device owner reported that the app works. The automated hardware mutation test
+has not been run, and that feedback is not a complete verification of every setter.
+Reconnection, real Auracast receivers and differing firmware require more systematic
+testing. The original app and simulator are not substitutes for device validation.
-Infobereich: `org.kde.StatusNotifierItem` plus `com.canonical.dbusmenu` über
-libdbusmenu. Grundlage ist die
-[StatusNotifier-Spezifikation](https://specifications.freedesktop.org/status-notifier-item/latest-single/).
-GNOME-Registrierung und D-Bus-Menüaktionen wurden lokal geprüft. Name/Passwort
-verwenden fokussierte GTK-Eingaben, da D-Bus-Menüs keine Texteingabefelder vorsehen.
+## Desktop tray
+
+`org.kde.StatusNotifierItem` with `com.canonical.dbusmenu`, exported using libdbusmenu,
+following the [StatusNotifier specification](https://specifications.freedesktop.org/status-notifier-item/latest-single/).
+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.
diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md
index cf0ec0a..3c649f7 100644
--- a/docs/VALIDATION.md
+++ b/docs/VALIDATION.md
@@ -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.
-Angeschlossener BTD 700: USB `3542:3001`, Firmware 3.11.0.
+This document separates observed hardware behavior from simulated tests.
+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.
-- 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.
+## Environment
-Der Nutzer hat anschließend bestätigt, dass die App am eigenen Dongle funktioniert.
-Welche einzelnen Funktionen dabei getestet wurden, wurde nicht näher aufgeschlüsselt.
+Bazzite / GNOME, Python 3.14, GTK 4.22, libadwaita 1.9.
+Connected BTD 700: USB `3542:3001`, firmware 3.11.0.
-**Noch offen:** automatisierte Hardware-Schreibtests sowie eine systematische
-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
-Auracast-Einstellungen einschließlich des Passworts; anschließend versucht er,
-die ursprünglichen Werte wiederherzustellen. Ein Werksreset wird dabei nicht
-ausgeführt; Firmware-Updates sind nicht implementiert.
+## Observed on the real device
+
+- Correct control-interface discovery and opening without detaching audio drivers.
+- Successful state, mode, codec, quality, transport, Auracast configuration/name
+ and firmware-version reads. See [PROTOCOL.md](PROTOCOL.md) for response bytes.
+- 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.
diff --git a/docs/screenshots/auracast-settings.png b/docs/screenshots/auracast-settings.png
new file mode 100644
index 0000000..87c9406
Binary files /dev/null and b/docs/screenshots/auracast-settings.png differ
diff --git a/docs/screenshots/main-window.png b/docs/screenshots/main-window.png
new file mode 100644
index 0000000..d62b026
Binary files /dev/null and b/docs/screenshots/main-window.png differ
diff --git a/install.py b/install.py
index 6482540..89185e3 100644
--- a/install.py
+++ b/install.py
@@ -1,13 +1,28 @@
#!/usr/bin/env python3
-"""Install a launcher for this checkout, without root or system packages."""
-from pathlib import Path
+"""Install/remove a launcher for this checkout, without root or system packages."""
+import argparse
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'
-applications.mkdir(parents=True, exist_ok=True)
-destination = applications / f'{APP_ID}.desktop'
-destination.write_text(desktop_entry())
-print(f'Installiert: {destination}')
-print('Im Anwendungsmenü „BTD 700 Control“ öffnen. Der Projektordner muss erhalten bleiben.')
+
+def main():
+ parser = argparse.ArgumentParser(description='Add or remove the BTD 700 Control application-menu entry.')
+ parser.add_argument('--uninstall', action='store_true', help='Remove the launcher and start-at-login entry')
+ args = parser.parse_args()
+ base = Path(os.environ.get('XDG_DATA_HOME', Path.home() / '.local/share'))
+ 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()
diff --git a/pyproject.toml b/pyproject.toml
index 98450fb..2221c4b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,13 +1,28 @@
[build-system]
-requires = ["setuptools>=68"]
+requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
[project]
name = "btd700-control"
-version = "0.1.0"
+version = "0.2.0"
description = "Independent native Linux controls and tray menu for the Sennheiser BTD 700"
requires-python = ">=3.10"
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]
btd700-control = "btd700.__main__:main"
diff --git a/tests/test_controller.py b/tests/test_controller.py
index 70ed30b..d66e43c 100644
--- a/tests/test_controller.py
+++ b/tests/test_controller.py
@@ -1,4 +1,5 @@
import unittest
+from btd700.i18n import tr
from btd700.controller import Controller
from btd700.demo import DemoTransport
from btd700.protocol import Command as C
@@ -25,7 +26,7 @@ class ControllerTests(unittest.TestCase):
self.device.values.pop(C.GET_KEY)
status = self.controller.snapshot()
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(self.device.writes, [])
self.assertNotIn('password', status.public_dict())
diff --git a/tests/test_i18n.py b/tests/test_i18n.py
new file mode 100644
index 0000000..574b601
--- /dev/null
+++ b/tests/test_i18n.py
@@ -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)
diff --git a/tests/test_integration.py b/tests/test_integration.py
new file mode 100644
index 0000000..8b1fe81
--- /dev/null
+++ b/tests/test_integration.py
@@ -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)
diff --git a/tools/capture_screenshots.py b/tools/capture_screenshots.py
new file mode 100644
index 0000000..763de27
--- /dev/null
+++ b/tools/capture_screenshots.py
@@ -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)
diff --git a/tools/check_gui.py b/tools/check_gui.py
index b3523f7..0c09e7a 100644
--- a/tools/check_gui.py
+++ b/tools/check_gui.py
@@ -4,6 +4,7 @@ import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from btd700.app import Application
+from btd700.i18n import tr
from gi.repository import Gio, GLib, Gtk
app = Application(demo=True)
@@ -16,7 +17,7 @@ bus_pending = False
def menu_item(labels):
root = app.tray.root
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