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. + +

+ Hauptfenster mit Modus, Codec und Verbindung + Auracast-Einstellungen und Infobereich-Optionen +

+ +## 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 +

+ BTD 700 Control icon +

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

+ MIT license + Linux + English and German interface + Tests +

+ +

+ 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. + +

+ English app: active codec, audio mode and headphone connection + English app: Auracast name, password protection, broadcast quality and startup settings +

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