Skip to content

About

Linux Qt GUI for the Astro A50 wireless headset (Gen 4) — built on top of the eh-fifty library

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

126 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Astro A50 GUI

A PyQt6 GUI to configure the Astro A50 Gen 4 wireless headset from Linux, built on top of the eh-fifty library.

Features

  • Headset status (power, dock, battery) — polled asynchronously, never blocks the UI thread
  • Game/voice balance, noise gate (with mode icons), alert volume
  • Stream mix sliders (mic / chat / game / aux) for the analog Stream-out port
  • EQ presets with the 5 builtin Astro Command Center templates (A50 MOD KIT, ASTRO, MEDIA, PRO, STUDIO), reverse-engineered via USB capture
  • Per-band gain editing via a draggable bargraph (orange = modified)
  • Per-band centre frequency and bandwidth in the EQ's advanced settings
  • User-defined EQ presets:
    • Create from any modified state (asks for a name)
    • Update in place (only enabled for user presets)
    • Delete via the trash icon next to the combo
    • Persisted to $XDG_CONFIG_HOME/astro-a50-gui/user-templates.json
  • Batch sync: every edit is local until you click Synchroniser le dispositif. The button turns orange to signal pending changes.
  • KDE menu entry installation
  • Base station / headset firmware info dialog
  • Import / export EQ presets as Astro Command Center .astroeq files (Tools menu), to move presets between Windows and Linux
  • French, English and Spanish UI (autodetected via locale, override with A50_LANG=fr|en|es). Each language is a locales/<code>.json file: drop in a new one and it shows up in the menu. A regional variant (es_MX.json) only holds the strings it changes and falls back to its base language, then English. File names are matched like locale names (pt_br.json is pt_BR); each file needs its own language_name. A file that isn't valid JSON is skipped, a non-text value drops only its key, and a string whose placeholders don't fit falls back to English; each case logs a warning
  • Language and colour theme selectable from the Tools menu: Automatic (follows the desktop's colours), Light and Dark (official Breeze palettes). Themes are colour palettes in themes/ (JSON, Qt palette role → colour) that keep the desktop's look and only change its colours: drop in a new file and it shows up in the menu

Install

Arch Linux

packaging/aur/PKGBUILD builds a package that installs the app, a menu entry and the udev rule below, from the latest release:

cd packaging/aur
makepkg -si

Then launch Astro A50 Config from the menu, or astro-a50-gui. Re-plug the base station once so the udev rule applies.

From source

Python 3.10+ and a virtualenv with PyUSB and PyQt6. eh-fifty, the underlying HID library, is vendored in vendor/:

python3 -m venv .venv
.venv/bin/pip install pyusb PyQt6

A udev rule is required to access the A50 USB device as a non-root user (once, then re-plug the base station):

echo 'SUBSYSTEM=="usb", ATTR{idVendor}=="9886", ATTR{idProduct}=="002c", TAG+="uaccess"' \
    | sudo tee /etc/udev/rules.d/50-astro-a50.rules

TAG+="uaccess" grants access only to the user of the active local session, instead of every local user and process as MODE:="0666" would. Access follows that session: over SSH, from another user's session or after fast user switching, the base station is not accessible.

The file name must sort before systemd's 71-seat.rules, so keep the 50- prefix. On systems without systemd-logind (e.g. Void, Alpine, or Artix with OpenRC), uaccess has no effect: use GROUP="plugdev", MODE="0660" instead of TAG+="uaccess" and add your user to the plugdev group.

The file name is the same as before, so re-running the command replaces an older 0666 rule. A base station that is already plugged in keeps the old permissions until you re-plug it or run:

sudo udevadm trigger --action=change --attr-match=idVendor=9886 --attr-match=idProduct=002c

Run

.venv/bin/python gui.py

Or install a KDE menu entry from inside the GUI: Outils → Installer dans le menu KDE. The package ships its own entry, so run from the package the GUI only offers Retirer du menu KDE, and only while an entry installed earlier from a checkout still hides the package's.

Architecture

The application is split into focused modules:

Module Responsibility
gui.py A50Window (Qt main window), wiring, main()
eq_widget.py EqTemplatesWidget — radios + combos + meter + buttons + state for the EQ preset feature
eq_meter.py _EqMeter — interactive 5-band bargraph widget
templates.py Builtin _EQ_TEMPLATES + user-template JSON persistence
i18n.py Loads locales/*.json into TRANSLATIONS, t(), _detect_lang()
settings.py Preferences (language, theme) in settings.json
themes.py Colour themes: Qt palettes loaded from themes/*.json
raw_request.py Raw HID opcodes outside eh-fifty's public API (FW info etc.)
process_lock.py Single-instance helper (/proc scan, kill stale GUIs)
status_worker.py QObject worker polling status on a QThread
device_handle.py Reopenable handle on the base, shared by all of the above
base_info_dialog.py Formats the "Informations base" dialog content
menu_install.py KDE menu entry install / remove (.desktop file)

Threading model

The A50 device handle is shared between the main UI thread and a background QThread running StatusWorker. The worker performs the periodic status polls (get_headset_status + get_battery_status) so they never freeze the UI. A threading.RLock serialises every USB access — the worker takes it during its poll, and the main thread takes it around its own bursts (reload_all, _on_save, template save / push, firmware-info dialog).

EQ workflow

The headset stores 3 slots, each with a {name, gain[5], (freq, bw)[5]} record. Names of builtin templates are app-side fiction (in _EQ_TEMPLATES) but get written verbatim into the slot's name field when synced — that's how _match_template recognises a slot at reload time. User presets live in a JSON library on disk; only their content gets pushed to a slot.

  • Radio buttons (EQ 1/2/3) select which slot the bargraph displays. The selection becomes the device's active preset on the next Synchroniser.
  • Combo box assigns a template to a slot. Builtins are immutable (headset icon). User presets show a star icon and a trash button which is invisible (opacity 0) on builtins.
  • Drag a bar vertically in the bargraph to change a band's gain. Modified bars turn orange. Bandwidths the base holds (set in Command Center, say) are kept; picking a template or Reset brings back the template's.
  • Réinitialiser: discard local edits, reload the template's original values. Enabled only when bars are modified.
  • Avancé…: set each band's centre frequency (80-15000 Hz) and, for bands 2-4, its bandwidth (0.1-3.0; bands 1 and 5 are shelves). Changed bands turn orange and are pushed on the next sync, like a dragged bar.
  • Sauvegarder: update the current user preset in place (only enabled when the slot points at a user preset and has modified bars). Also writes the new state to the device immediately.
  • Créer un préréglage: prompt for a new name and store the current bands as a new user template, push to device, save.
  • Synchroniser le dispositif (action bar): pushes the visible state of every pending slot (the bargraph values, edits and all) plus scalar settings and the active-slot radio, then calls save_values() so it persists across reboots. Turns orange when there is anything to push.

Sync semantics for modified presets

  • Modified user preset → sync: the user preset is overwritten locally with the visible bands (so the library entry tracks what you sync), then pushed to the slot under its name. No prompt. The orange "modified" indication clears because the saved definition now matches what's shown.
  • Modified builtin → sync: pushed as-is to the slot — name on the device stays as the builtin name ("MEDIA", "PRO"…), bands are the edited ones. The builtin library is never overwritten. At the next reload, _match_template won't match (gain differs), the combo falls back on the builtin name and the off-template bars stay orange. To name and reuse that state, click Créer un préréglage.

Files

  • gui.py, eq_widget.py, eq_meter.py, templates.py, i18n.py, settings.py, themes.py, raw_request.py, process_lock.py, status_worker.py, device_handle.py, base_info_dialog.py, menu_install.py, advanced_eq.py — application code
  • themes/ — colour palettes for the Theme menu (JSON)
  • locales/ — one translation file per language (JSON, key → text)
  • icons/ — the app's icons, from Lucide (SVG, drawn in the palette's text colour so they follow the theme); licence in LICENSE-lucide.txt
  • tests.py — unit tests (run with .venv/bin/python -m unittest tests.py)
  • scripts/ — reverse-engineering helpers (USB sniffing, one-shot probes, libvirt USB-passthrough udev wrapper). Not required to run the GUI; see scripts/ for individual purpose.

Tests

.venv/bin/python -m unittest tests.py

Covers:

  • Shape and bounds of every builtin EQ template
  • User-template JSON round-trip and malformed-entry tolerance
  • Base info dialog: every value and raw dump shown, a failed read shown as n/a with its reason
  • i18n.t() lookups, fallback from a regional variant to its base language and then English, kwargs formatting, same keys and placeholders in every language, variants only using keys of their base, every locale file naming its own language
  • Locale loading: file names normalised like locale names, broken files and non-text keys skipped with one warning each, BOM accepted, a missing folder still gives English, a bad placeholder falls back to English
  • Language priority (A50_LANG, menu choice, system locale) and when a restart is needed
  • settings.json: round-trip, atomic writes, corrupt or mistyped content
  • Theme palettes: listing, labels, malformed files fall back to Automatic
  • Menu slots never let an exception escape (PyQt6 would abort)
  • EqTemplatesWidget.has_pending() dirty-state logic (radio vs device, pending bands)
  • EqTemplatesWidget._match_template() (builtin / user / no-match)
  • EqTemplatesWidget.push_pending_to_device() end-to-end: builtin pushed as-is, user preset overwritten locally and pushed, combo change pushes template values, no-pending skips, active-slot-only push, batched disk save for multiple user presets
  • EqTemplatesWidget.reload_under_lock() regressions: modified-builtin reload leaves _slot_pending empty (root cause of the "Synchronisé" button never flipping to dirty); combo signals are blocked around setCurrentIndex during reload; clean reload matches templates with no orange flag
  • _on_band_modified / _on_template_combo_changed per-event state transitions (off-template marking, back-to-template clearing, drag ignored while loading or with no bands, combo switch pulls template values and pendings all bands, combo back to device template clears pending)
  • _persist_and_push save/create flow: user preset added or overwritten, one _save_user_templates call, device receives name/gain/5 bands/ save_values, slot state reset, the bandwidths the slot shows (shelf bands 1 and 5 forced to 0)
  • Advanced EQ settings: the dialog uses eh-fifty's limits and gives back what it showed when nothing is touched (exact raw bandwidths kept, values out of range included); changed bands are pending, marked and synced; bandwidths the base holds are kept, by the dialog, a gain drag and Create preset alike; Reset, a reload, and a reload while the dialog is open drop them

Limitations

  • The headset's firmware update path is not implemented (no Linux tooling exists to flash the A50 base/headset; use the official Windows Astro Command Center for that).

About

Linux Qt GUI for the Astro A50 wireless headset (Gen 4) — built on top of the eh-fifty library

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages