From dd0809e877c898c552377be47e19460301363609 Mon Sep 17 00:00:00 2001
From: Justin <39440218+justin-eckenweber@users.noreply.github.com>
Date: Sat, 5 Sep 2026 21:02:14 +0200
Subject: [PATCH] Add English localization and MIT-licensed public release
documentation
---
.github/ISSUE_TEMPLATE/bug_report.md | 24 ++
.github/workflows/tests.yml | 31 +++
CHANGELOG.md | 17 ++
CONTRIBUTING.md | 61 +++++
LICENSE | 21 ++
MANIFEST.in | 6 +
README.de.md | 203 +++++++++++++++++
README.md | 301 +++++++++++++++++--------
THIRD_PARTY_NOTICES.md | 21 ++
btd700/__init__.py | 2 +-
btd700/__main__.py | 43 ++--
btd700/app.py | 94 ++++----
btd700/controller.py | 41 ++--
btd700/demo.py | 3 +-
btd700/i18n.py | 157 +++++++++++++
btd700/integration.py | 3 +-
btd700/protocol.py | 13 +-
btd700/transport.py | 19 +-
btd700/tray.py | 37 +--
btd700/worker.py | 5 +-
docs/PROTOCOL.md | 221 +++++++++---------
docs/VALIDATION.md | 82 ++++---
docs/screenshots/auracast-settings.png | Bin 0 -> 77249 bytes
docs/screenshots/main-window.png | Bin 0 -> 70756 bytes
install.py | 35 ++-
pyproject.toml | 19 +-
tests/test_controller.py | 3 +-
tests/test_i18n.py | 67 ++++++
tests/test_integration.py | 46 ++++
tools/capture_screenshots.py | 60 +++++
tools/check_gui.py | 3 +-
31 files changed, 1261 insertions(+), 377 deletions(-)
create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md
create mode 100644 .github/workflows/tests.yml
create mode 100644 CHANGELOG.md
create mode 100644 CONTRIBUTING.md
create mode 100644 LICENSE
create mode 100644 MANIFEST.in
create mode 100644 README.de.md
create mode 100644 THIRD_PARTY_NOTICES.md
create mode 100644 btd700/i18n.py
create mode 100644 docs/screenshots/auracast-settings.png
create mode 100644 docs/screenshots/main-window.png
create mode 100644 tests/test_i18n.py
create mode 100644 tests/test_integration.py
create mode 100644 tools/capture_screenshots.py
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 0000000000000000000000000000000000000000..87c9406aa2c5f85251e3f131079e21b708ed9d6b
GIT binary patch
literal 77249
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