hyperheadset 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,160 @@
1
+ Metadata-Version: 2.4
2
+ Name: hyperheadset
3
+ Version: 0.1.0
4
+ Summary: Astro A50 base station HID stats client
5
+ Author: Hyper
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: hidapi>=0.14.0
10
+
11
+ # Hyperheadset
12
+
13
+ A small Python CLI and library for reading live status data from the Astro
14
+ A50 base station over HID.
15
+
16
+ This started as a side project because I wanted to see battery %, dock
17
+ state, and sidetone levels without running the official Astro software
18
+ in the background. That turned into reverse-engineering the HID
19
+ protocol... which turned into this.
20
+
21
+ It talks directly to the base station, parses the response frames, and
22
+ exposes the useful bits in a clean way.
23
+
24
+ ---
25
+
26
+ ## What it can do
27
+ Right now it focuses on reading states.
28
+
29
+ - Read battery percentage + charging state
30
+ - Detect whether the headset is docked / powered
31
+ - Read sidetone slider values (active + saved)
32
+ - Output as JSON (compact or pretty)
33
+ - CSV logging
34
+ - Watch mode (with change detection)
35
+ - Filter specific fields (`--fields`)
36
+ - List matching HID devices
37
+ - Usable as both a CLI tool and a Python module
38
+
39
+ ## Install
40
+
41
+ Not on PyPI (yet).
42
+
43
+ Local install:
44
+
45
+ ```bash
46
+ pip install .
47
+ ```
48
+
49
+ Editable dev install:
50
+
51
+ ```bash
52
+ pip install -e .
53
+ ```
54
+
55
+ ## CLI Usage
56
+
57
+ Default snapshot:
58
+
59
+ ```bash
60
+ hyperheadset
61
+ ```
62
+
63
+ Some common flags:
64
+
65
+ - Battery only → `--battery`
66
+ - JSON output → `--json`
67
+ - Pretty JSON → `--json --p`
68
+ - Watch mode → `--watch`
69
+ - Only show changes (watch mode) → `--changes-only`
70
+ - List matching HID devices → `--device-list`
71
+
72
+ ### Examples
73
+
74
+ Watch only sidetone, and only print when it changes:
75
+ ``` bash
76
+ hyperheadset --fields sidetone --watch --changes-only
77
+ ```
78
+
79
+ Log everything to CSV every 2 seconds:
80
+ ``` bash
81
+ hyperheadset --csv --watch --interval 2 > log.csv
82
+ ```
83
+
84
+ Pretty JSON snapshot without timestamp:
85
+ ``` bash
86
+ hyperheadset --json --p --no-timestamp
87
+ ```
88
+
89
+ ## Python Usage
90
+ You can also use it directly in code:
91
+
92
+ ```python
93
+ from hyperheadset import AstroA50Client, SliderType
94
+
95
+ client = AstroA50Client()
96
+
97
+ battery = client.getBatteryStatus()
98
+ print(battery.chargePercent, battery.isCharging)
99
+
100
+ sidetone = client.getSliderValue(SliderType.sidetone)
101
+ print(sidetone)
102
+ ```
103
+
104
+ Snapshot helper:
105
+ ```python
106
+ snap = client.getSnapshot(battery=True, headset=True, sidetone=True)
107
+ print(snap)
108
+ ```
109
+
110
+
111
+
112
+ ## Requirements
113
+ - Python 3.10+
114
+ - `hidapi` bindings (`pip install hidapi`)
115
+ - Astro A50 base station connected over USB
116
+ - OS must expose the device as a standard HID interface
117
+
118
+ If your OS doesn't see it as HID, this won't work. No custom drivers
119
+ included here.
120
+
121
+ ## Scope (and what this is *not*)
122
+
123
+ This project focuses on:
124
+
125
+ - Reading device state
126
+ - Protocol framing
127
+ - Simple query commands
128
+
129
+ It's **not**:
130
+ - A replacement for the official Astro software
131
+ - A firmware updater
132
+ - An EQ editor
133
+
134
+ Write/set commands *might* be added later, but only after making sure
135
+ they're safe. Bricking a headset is not on the roadmap.
136
+
137
+ ## Credits / Prior Work
138
+ Huge credit to the [eh-fifty](https://github.com/tdryer/eh-fifty) project by Tom Dryer.
139
+
140
+ That project documents and reverse-engineers large parts of the Astro
141
+ A50 USB/HID protocol. I studied their research to understand the framing
142
+ and command structure, then re-implemented what I needed in a smaller
143
+ codebase focused purely on stats and queries.
144
+
145
+ No source code from [eh-fifty](https://github.com/tdryer/eh-fifty) is included here but their work made
146
+ this possible.
147
+
148
+ If you want broader device coverage or deeper protocol exploration,
149
+ definitely check that repository out.
150
+
151
+
152
+ ## License
153
+
154
+ MIT
155
+
156
+
157
+ ## Disclaimer
158
+
159
+ Not affiliated with or endorsed by Astro, Logitech, or the eh-fifty
160
+ project authors.
@@ -0,0 +1,150 @@
1
+ # Hyperheadset
2
+
3
+ A small Python CLI and library for reading live status data from the Astro
4
+ A50 base station over HID.
5
+
6
+ This started as a side project because I wanted to see battery %, dock
7
+ state, and sidetone levels without running the official Astro software
8
+ in the background. That turned into reverse-engineering the HID
9
+ protocol... which turned into this.
10
+
11
+ It talks directly to the base station, parses the response frames, and
12
+ exposes the useful bits in a clean way.
13
+
14
+ ---
15
+
16
+ ## What it can do
17
+ Right now it focuses on reading states.
18
+
19
+ - Read battery percentage + charging state
20
+ - Detect whether the headset is docked / powered
21
+ - Read sidetone slider values (active + saved)
22
+ - Output as JSON (compact or pretty)
23
+ - CSV logging
24
+ - Watch mode (with change detection)
25
+ - Filter specific fields (`--fields`)
26
+ - List matching HID devices
27
+ - Usable as both a CLI tool and a Python module
28
+
29
+ ## Install
30
+
31
+ Not on PyPI (yet).
32
+
33
+ Local install:
34
+
35
+ ```bash
36
+ pip install .
37
+ ```
38
+
39
+ Editable dev install:
40
+
41
+ ```bash
42
+ pip install -e .
43
+ ```
44
+
45
+ ## CLI Usage
46
+
47
+ Default snapshot:
48
+
49
+ ```bash
50
+ hyperheadset
51
+ ```
52
+
53
+ Some common flags:
54
+
55
+ - Battery only → `--battery`
56
+ - JSON output → `--json`
57
+ - Pretty JSON → `--json --p`
58
+ - Watch mode → `--watch`
59
+ - Only show changes (watch mode) → `--changes-only`
60
+ - List matching HID devices → `--device-list`
61
+
62
+ ### Examples
63
+
64
+ Watch only sidetone, and only print when it changes:
65
+ ``` bash
66
+ hyperheadset --fields sidetone --watch --changes-only
67
+ ```
68
+
69
+ Log everything to CSV every 2 seconds:
70
+ ``` bash
71
+ hyperheadset --csv --watch --interval 2 > log.csv
72
+ ```
73
+
74
+ Pretty JSON snapshot without timestamp:
75
+ ``` bash
76
+ hyperheadset --json --p --no-timestamp
77
+ ```
78
+
79
+ ## Python Usage
80
+ You can also use it directly in code:
81
+
82
+ ```python
83
+ from hyperheadset import AstroA50Client, SliderType
84
+
85
+ client = AstroA50Client()
86
+
87
+ battery = client.getBatteryStatus()
88
+ print(battery.chargePercent, battery.isCharging)
89
+
90
+ sidetone = client.getSliderValue(SliderType.sidetone)
91
+ print(sidetone)
92
+ ```
93
+
94
+ Snapshot helper:
95
+ ```python
96
+ snap = client.getSnapshot(battery=True, headset=True, sidetone=True)
97
+ print(snap)
98
+ ```
99
+
100
+
101
+
102
+ ## Requirements
103
+ - Python 3.10+
104
+ - `hidapi` bindings (`pip install hidapi`)
105
+ - Astro A50 base station connected over USB
106
+ - OS must expose the device as a standard HID interface
107
+
108
+ If your OS doesn't see it as HID, this won't work. No custom drivers
109
+ included here.
110
+
111
+ ## Scope (and what this is *not*)
112
+
113
+ This project focuses on:
114
+
115
+ - Reading device state
116
+ - Protocol framing
117
+ - Simple query commands
118
+
119
+ It's **not**:
120
+ - A replacement for the official Astro software
121
+ - A firmware updater
122
+ - An EQ editor
123
+
124
+ Write/set commands *might* be added later, but only after making sure
125
+ they're safe. Bricking a headset is not on the roadmap.
126
+
127
+ ## Credits / Prior Work
128
+ Huge credit to the [eh-fifty](https://github.com/tdryer/eh-fifty) project by Tom Dryer.
129
+
130
+ That project documents and reverse-engineers large parts of the Astro
131
+ A50 USB/HID protocol. I studied their research to understand the framing
132
+ and command structure, then re-implemented what I needed in a smaller
133
+ codebase focused purely on stats and queries.
134
+
135
+ No source code from [eh-fifty](https://github.com/tdryer/eh-fifty) is included here but their work made
136
+ this possible.
137
+
138
+ If you want broader device coverage or deeper protocol exploration,
139
+ definitely check that repository out.
140
+
141
+
142
+ ## License
143
+
144
+ MIT
145
+
146
+
147
+ ## Disclaimer
148
+
149
+ Not affiliated with or endorsed by Astro, Logitech, or the eh-fifty
150
+ project authors.
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "hyperheadset"
7
+ version = "0.1.0"
8
+ description = "Astro A50 base station HID stats client"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Hyper" }]
13
+ dependencies = [
14
+ "hidapi>=0.14.0"
15
+ ]
16
+
17
+ [project.scripts]
18
+ hyperheadset = "hyperheadset.cli:main"
19
+
20
+ [tool.setuptools]
21
+ package-dir = {"" = "src"}
22
+
23
+ [tool.setuptools.packages.find]
24
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,11 @@
1
+ from .client import AstroA50Client
2
+ from .enums import Command, SliderType
3
+ from .models import BatteryStatus, HeadsetStatus
4
+
5
+ __all__ = [
6
+ "AstroA50Client",
7
+ "Command",
8
+ "SliderType",
9
+ "BatteryStatus",
10
+ "HeadsetStatus",
11
+ ]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,206 @@
1
+ import argparse
2
+ import csv
3
+ import json
4
+ import sys
5
+ import time
6
+ from importlib.metadata import version as packageVersion
7
+ from typing import Any, Dict, Optional, Set
8
+
9
+ import hid
10
+
11
+ from .client import AstroA50Client
12
+
13
+ #*Helpers
14
+ def _stableSignatureForChangeDetection(snapshot: Dict[str, Any]) -> str:
15
+ comparable = dict(snapshot)
16
+ comparable.pop("timestamp", None)
17
+ return json.dumps(comparable, sort_keys=True, separators=(",", ":"))
18
+
19
+
20
+ def _printSnapshot(snapshot: Dict[str, Any], asJson: bool, prettyJson: bool, asCsv: bool, csvWriter: Optional[csv.DictWriter]) -> None:
21
+ if asCsv:
22
+ row: Dict[str, Any] = {}
23
+
24
+ if "timestamp" in snapshot:
25
+ row["timestamp"] = snapshot["timestamp"]
26
+
27
+ battery = snapshot.get("battery")
28
+ if isinstance(battery, dict):
29
+ row["batteryChargePercent"] = battery.get("chargePercent")
30
+ row["batteryIsCharging"] = battery.get("isCharging")
31
+
32
+ headset = snapshot.get("headset")
33
+ if isinstance(headset, dict):
34
+ row["headsetIsDocked"] = headset.get("isDocked")
35
+ row["headsetIsOn"] = headset.get("isOn")
36
+
37
+ sidetone = snapshot.get("sidetone")
38
+ if isinstance(sidetone, dict):
39
+ row["sidetoneActivePercent"] = sidetone.get("activePercent")
40
+ row["sidetoneSavedPercent"] = sidetone.get("savedPercent")
41
+
42
+ if csvWriter is None:
43
+ raise RuntimeError("csvWriter is None in CSV mode")
44
+
45
+ csvWriter.writerow(row)
46
+ sys.stdout.flush()
47
+ return
48
+
49
+ if asJson:
50
+ if prettyJson:
51
+ print(json.dumps(snapshot, indent=2, sort_keys=True))
52
+ else:
53
+ print(json.dumps(snapshot, separators=(",", ":"), sort_keys=True))
54
+ return
55
+
56
+ #*Human Readable
57
+ battery = snapshot.get("battery")
58
+ if isinstance(battery, dict):
59
+ print(f"Battery: {battery.get('chargePercent')}% charging={battery.get('isCharging')}")
60
+
61
+ headset = snapshot.get("headset")
62
+ if isinstance(headset, dict):
63
+ print(f"Headset: docked={headset.get('isDocked')} on={headset.get('isOn')}")
64
+
65
+ sidetone = snapshot.get("sidetone")
66
+ if isinstance(sidetone, dict):
67
+ print(f"Sidetone: active={sidetone.get('activePercent')}% saved={sidetone.get('savedPercent')}%")
68
+
69
+
70
+ def _printDeviceList(vendorId: int) -> int:
71
+ devices = [d for d in hid.enumerate() if d.get("vendor_id") == vendorId]
72
+
73
+ if not devices:
74
+ print(f"No HID devices found for vendor_id=0x{vendorId:04X}")
75
+ return 1
76
+
77
+ for i, device in enumerate(devices, start=1):
78
+ print(
79
+ f"[{i}] "
80
+ f"vendor=0x{vendorId:04X} "
81
+ f"product=0x{int(device.get('product_id')):04X} "
82
+ f"mfg={device.get('manufacturer_string')!r} "
83
+ f"product={device.get('product_string')!r} "
84
+ f"path={device.get('path')!r}"
85
+ )
86
+
87
+ return 0
88
+
89
+
90
+ def main() -> int:
91
+ parser = argparse.ArgumentParser(prog="hyperheadset", description="Hyper's headset tooling: Astro A50 base station stats via HID")
92
+
93
+ #?Meta
94
+ parser.add_argument("--version", action="store_true")
95
+ parser.add_argument("--device-list", action="store_true")
96
+ parser.add_argument("--vendor-id", type=lambda s: int(s, 0), default=0x9886)
97
+
98
+ #?Field selection
99
+ parser.add_argument("--battery", action="store_true")
100
+ parser.add_argument("--headset", action="store_true")
101
+ parser.add_argument("--sidetone", action="store_true")
102
+ parser.add_argument("--fields", type=str, default="")
103
+
104
+ #?Output
105
+ parser.add_argument("--json", action="store_true")
106
+ parser.add_argument("--p", action="store_true", help="Pretty JSON")
107
+ parser.add_argument("--csv", action="store_true")
108
+ parser.add_argument("--no-timestamp", action="store_true")
109
+
110
+ #?Watch
111
+ parser.add_argument("--watch", action="store_true")
112
+ parser.add_argument("--changes-only", action="store_true")
113
+ parser.add_argument("--interval", type=float, default=2.0)
114
+ parser.add_argument("--count", type=int, default=0)
115
+
116
+ args = parser.parse_args()
117
+
118
+ #?version
119
+ if args.version:
120
+ print(packageVersion("hyperheadset"))
121
+ return 0
122
+
123
+ vendorId = int(args.vendor_id)
124
+
125
+ if args.device_list:
126
+ return _printDeviceList(vendorId)
127
+
128
+ if args.interval <= 0:
129
+ raise SystemExit("--interval must be > 0")
130
+
131
+ intervalSeconds = max(args.interval, 0.25)
132
+
133
+ allowedFields: Set[str] = {"battery", "headset", "sidetone"}
134
+
135
+ if args.fields.strip():
136
+ requested = {x.strip().lower() for x in args.fields.split(",") if x.strip()}
137
+ unknown = requested - allowedFields
138
+ if unknown:
139
+ raise SystemExit(f"Unknown fields: {', '.join(sorted(unknown))}")
140
+
141
+ includeBattery = "battery" in requested
142
+ includeHeadset = "headset" in requested
143
+ includeSidetone = "sidetone" in requested
144
+ else:
145
+ includeBattery = args.battery
146
+ includeHeadset = args.headset
147
+ includeSidetone = args.sidetone
148
+
149
+ if not (includeBattery or includeHeadset or includeSidetone):
150
+ includeBattery = True
151
+ includeHeadset = True
152
+
153
+ asCsv = bool(args.csv)
154
+ asJson = bool(args.json) and not asCsv
155
+ prettyJson = bool(args.p)
156
+ includeTimestamp = not args.no_timestamp
157
+
158
+ csvWriter: Optional[csv.DictWriter] = None
159
+ if asCsv:
160
+ fieldnames = [
161
+ "timestamp",
162
+ "batteryChargePercent",
163
+ "batteryIsCharging",
164
+ "headsetIsDocked",
165
+ "headsetIsOn",
166
+ "sidetoneActivePercent",
167
+ "sidetoneSavedPercent"
168
+ ]
169
+ csvWriter = csv.DictWriter(sys.stdout, fieldnames=fieldnames)
170
+ csvWriter.writeheader()
171
+
172
+ lastSignature: Optional[str] = None
173
+
174
+ with AstroA50Client(vendorId=vendorId) as client:
175
+
176
+ def emitOnce() -> bool:
177
+ nonlocal lastSignature
178
+
179
+ snapshot = client.getSnapshot(battery=includeBattery, headset=includeHeadset, sidetone=includeSidetone, includeTimestamp=includeTimestamp)
180
+
181
+ if args.watch and args.changes_only:
182
+ sig = _stableSignatureForChangeDetection(snapshot)
183
+ if sig == lastSignature:
184
+ return False
185
+ lastSignature = sig
186
+
187
+ _printSnapshot(snapshot, asJson, prettyJson, asCsv, csvWriter)
188
+ return True
189
+
190
+ if args.watch:
191
+ emitted = 0
192
+ try:
193
+ while True:
194
+ if emitOnce():
195
+ emitted += 1
196
+ if args.count and emitted >= args.count:
197
+ return 0
198
+ time.sleep(intervalSeconds)
199
+ except KeyboardInterrupt:
200
+ if not asCsv:
201
+ print("\nStopped.")
202
+ return 0
203
+
204
+ emitOnce()
205
+ return 0
206
+ return 0
@@ -0,0 +1,229 @@
1
+ import time
2
+ from typing import Any, Dict, Optional, Sequence
3
+
4
+ import hid
5
+
6
+ from .enums import Command, SliderType
7
+ from .models import BatteryStatus, HeadsetStatus
8
+
9
+
10
+ class AstroA50Client:
11
+ """
12
+ Minimal HID client for Astro A50 base station.
13
+ """
14
+
15
+ def __init__(self, vendorId: int = 0x9886, reportLengths: Sequence[int] = (64, 65), commandDelaySeconds: float = 0.08) -> None:
16
+ self.vendorId = vendorId
17
+ self.reportLengths = tuple(int(value) for value in reportLengths)
18
+ self.commandDelaySeconds = float(commandDelaySeconds)
19
+ self.lastGoodBatteryStatus: Optional[BatteryStatus] = None
20
+
21
+ def __enter__(self) -> "AstroA50Client":
22
+ return self
23
+
24
+ def __exit__(self, excType, exc, tb) -> bool:
25
+ #TODO not implemented, may do one day
26
+ return False
27
+
28
+ #?HID Helpers
29
+ def _findDevicePath(self) -> bytes:
30
+ devices = [device for device in hid.enumerate() if device.get("vendor_id") == self.vendorId]
31
+ if not devices:
32
+ raise RuntimeError("Astro A50 HID interface not found (driver should be 'USB Input Device').")
33
+ return devices[0]["path"]
34
+
35
+ def _buildRequestFrame(self, commandId: int, payloadBytes: Optional[Sequence[int]], reportLength: int) -> bytes:
36
+
37
+ frameBytes = [0x02, commandId & 0xFF, 0x00]
38
+
39
+ if payloadBytes:
40
+ frameBytes[2] = len(payloadBytes) & 0xFF
41
+ frameBytes.extend([(value & 0xFF) for value in payloadBytes])
42
+
43
+ if reportLength == 65:
44
+ paddedBody = frameBytes + [0] * (64 - len(frameBytes))
45
+ return bytes([0x00]) + bytes(paddedBody)
46
+
47
+ paddedBody = frameBytes + [0] * (reportLength - len(frameBytes))
48
+ return bytes(paddedBody)
49
+
50
+ def _normalizeResponseFrame(self, responseFrame: bytes) -> bytes:
51
+ if (responseFrame and responseFrame[0] == 0x00 and len(responseFrame) > 1 and responseFrame[1] == 0x02):
52
+ return responseFrame[1:]
53
+ return responseFrame
54
+
55
+ def _extractPayload(self, responseFrame: bytes) -> Optional[bytes]:
56
+ if len(responseFrame) < 3:
57
+ return None
58
+
59
+ frameStartByte = responseFrame[0]
60
+ statusCodeByte = responseFrame[1]
61
+
62
+ if frameStartByte != 0x02 or statusCodeByte != 0x02:
63
+ return None
64
+
65
+ payloadLength = responseFrame[2]
66
+ payloadLength = min(payloadLength, max(0, len(responseFrame) - 3))
67
+ return responseFrame[3 : 3 + payloadLength]
68
+
69
+ def _sendCommandOnce(self, devicePath: bytes, commandId: int, payloadBytes: Optional[Sequence[int]]) -> Optional[bytes]:
70
+ for reportLength in self.reportLengths:
71
+ deviceHandle = hid.device()
72
+ try:
73
+ deviceHandle.open_path(devicePath)
74
+ deviceHandle.set_nonblocking(0)
75
+
76
+ requestFrame = self._buildRequestFrame(commandId, payloadBytes, reportLength)
77
+
78
+ try:
79
+ deviceHandle.send_feature_report(requestFrame)
80
+ time.sleep(0.03)
81
+ featureResponse = deviceHandle.get_feature_report(0, reportLength)
82
+ if featureResponse:
83
+ return self._normalizeResponseFrame(bytes(featureResponse))
84
+ except OSError:
85
+ pass
86
+
87
+ try:
88
+ deviceHandle.write(requestFrame)
89
+ interruptResponse = deviceHandle.read(reportLength, timeout_ms=250)
90
+ if interruptResponse:
91
+ return self._normalizeResponseFrame(bytes(interruptResponse))
92
+ except OSError:
93
+ pass
94
+
95
+ finally:
96
+ try:
97
+ deviceHandle.close()
98
+ except Exception:
99
+ pass
100
+
101
+ return None
102
+
103
+ def _query(self, commandId: int, payloadBytes: Optional[Sequence[int]] = None, retries: int = 4) -> bytes:
104
+ devicePath = self._findDevicePath()
105
+ lastError: Optional[Exception] = None
106
+
107
+ for _ in range(int(retries)):
108
+ try:
109
+ responseFrame = self._sendCommandOnce(devicePath, int(commandId), payloadBytes)
110
+ if responseFrame:
111
+ payloadFromDevice = self._extractPayload(responseFrame)
112
+ if payloadFromDevice is not None:
113
+ time.sleep(self.commandDelaySeconds)
114
+ return payloadFromDevice
115
+ except Exception as error:
116
+ lastError = error
117
+
118
+ time.sleep(0.06)
119
+
120
+ raise RuntimeError(f"No valid response for cmd 0x{int(commandId):02X}") from lastError
121
+
122
+ #*Main Public API
123
+ def getBatteryStatus(self, retries: int = 6) -> BatteryStatus:
124
+ """
125
+ Battery payload is 1 byte:
126
+ isCharging = bool(byte & 0x80)
127
+ percent = byte & 0x7F
128
+ """
129
+ for _ in range(int(retries)):
130
+ payloadBytes = self._query(Command.getBatteryStatus)
131
+
132
+ if len(payloadBytes) >= 1:
133
+ statusByte = payloadBytes[0]
134
+ batteryStatus = BatteryStatus(isCharging=bool(statusByte & 0x80), chargePercent=int(statusByte & 0x7F))
135
+
136
+ if 0 <= batteryStatus.chargePercent <= 100:
137
+ self.lastGoodBatteryStatus = batteryStatus
138
+ return batteryStatus
139
+
140
+ time.sleep(0.05)
141
+
142
+ if self.lastGoodBatteryStatus is not None:
143
+ return self.lastGoodBatteryStatus
144
+
145
+ raise RuntimeError("Could not read a sane battery value")
146
+
147
+ def getHeadsetStatus(self) -> HeadsetStatus:
148
+ payloadBytes = self._query(Command.getHeadsetStatus)
149
+ if len(payloadBytes) < 1:
150
+ raise RuntimeError(f"Unexpected headset status payload: {payloadBytes!r}")
151
+
152
+ statusByte = payloadBytes[0]
153
+ return HeadsetStatus(isDocked=bool(statusByte & 0x01), isOn=bool(statusByte & 0x02))
154
+
155
+ def getSliderValue(self, sliderType: int | SliderType, saved: bool = False) -> int:
156
+ """
157
+ Expects payload like:
158
+ [0x68, sliderType, activeValue, savedValue]
159
+ """
160
+ sliderId = int(sliderType) & 0xFF
161
+ payloadBytes = self._query(Command.getSliderValue, [sliderId])
162
+
163
+ if (len(payloadBytes) < 4 or payloadBytes[0] != int(Command.getSliderValue) or payloadBytes[1] != sliderId):
164
+ raise RuntimeError(f"Unexpected slider payload: {payloadBytes!r}")
165
+
166
+ valueIndex = 2 + int(saved)
167
+ return int(payloadBytes[valueIndex])
168
+
169
+ def getActiveEqPreset(self) -> int:
170
+ payloadBytes = self._query(Command.getActiveEqPreset)
171
+ if not payloadBytes:
172
+ raise RuntimeError(f"Unexpected EQ payload: {payloadBytes!r}")
173
+ return int(payloadBytes[0])
174
+
175
+ def getBalance(self) -> int:
176
+ payloadBytes = self._query(Command.getBalance)
177
+ if not payloadBytes:
178
+ raise RuntimeError(f"Unexpected balance payload: {payloadBytes!r}")
179
+ return int(payloadBytes[0])
180
+
181
+ def getDefaultBalance(self, saved: bool = False) -> int:
182
+ payloadBytes = self._query(Command.getDefaultBalance, [int(saved)])
183
+ if not payloadBytes:
184
+ raise RuntimeError(f"Unexpected default balance payload: {payloadBytes!r}")
185
+ return int(payloadBytes[0])
186
+
187
+ def getAlertVolume(self, saved: bool = False) -> int:
188
+ payloadBytes = self._query(Command.getAlertVolume, [int(saved)])
189
+ if not payloadBytes:
190
+ raise RuntimeError(f"Unexpected alert volume payload: {payloadBytes!r}")
191
+ return int(payloadBytes[0])
192
+
193
+ def getMicEq(self, saved: bool = False) -> int:
194
+ payloadBytes = self._query(Command.getMicEq, [int(saved)])
195
+ if not payloadBytes:
196
+ raise RuntimeError(f"Unexpected mic EQ payload: {payloadBytes!r}")
197
+ return int(payloadBytes[0])
198
+
199
+ def getNoiseGateMode(self, saved: bool = False) -> int:
200
+ """
201
+ Payload like: [0x6A, activeMode, savedMode]
202
+ """
203
+ payloadBytes = self._query(Command.getNoiseGateMode)
204
+
205
+ if len(payloadBytes) < 3 or payloadBytes[0] != int(Command.getNoiseGateMode):
206
+ raise RuntimeError(f"Unexpected noise gate payload: {payloadBytes!r}")
207
+
208
+ return int(payloadBytes[1 + int(saved)])
209
+
210
+ def getSnapshot(self, battery: bool = True, headset: bool = True, sidetone: bool = False, includeTimestamp: bool = True) -> Dict[str, Any]:
211
+ snapshot: Dict[str, Any] = {}
212
+
213
+ if includeTimestamp:
214
+ snapshot["timestamp"] = time.time()
215
+
216
+ if battery:
217
+ batteryStatus = self.getBatteryStatus()
218
+ snapshot["battery"] = {"isCharging": batteryStatus.isCharging, "chargePercent": batteryStatus.chargePercent}
219
+
220
+ if headset:
221
+ headsetStatus = self.getHeadsetStatus()
222
+ snapshot["headset"] = {"isDocked": headsetStatus.isDocked, "isOn": headsetStatus.isOn}
223
+
224
+ if sidetone:
225
+ sidetoneActive = self.getSliderValue(SliderType.sidetone, saved=False)
226
+ sidetoneSaved = self.getSliderValue(SliderType.sidetone, saved=True)
227
+ snapshot["sidetone"] = {"activePercent": int(sidetoneActive), "savedPercent": int(sidetoneSaved)}
228
+
229
+ return snapshot
@@ -0,0 +1,22 @@
1
+ from enum import IntEnum
2
+
3
+
4
+ class Command(IntEnum):
5
+ getHeadsetStatus = 0x54
6
+ getSliderValue = 0x68
7
+ getNoiseGateMode = 0x6A
8
+ getActiveEqPreset = 0x6C
9
+ getBalance = 0x72
10
+ getDefaultBalance = 0x77
11
+ getAlertVolume = 0x7A
12
+ getMicEq = 0x7B
13
+ getBatteryStatus = 0x7C
14
+
15
+
16
+ class SliderType(IntEnum):
17
+ streamMic = 0x00
18
+ streamChat = 0x01
19
+ streamGame = 0x02
20
+ streamAux = 0x03
21
+ mic = 0x04
22
+ sidetone = 0x05
@@ -0,0 +1,13 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class BatteryStatus:
6
+ isCharging: bool
7
+ chargePercent: int
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class HeadsetStatus:
12
+ isDocked: bool
13
+ isOn: bool
@@ -0,0 +1,160 @@
1
+ Metadata-Version: 2.4
2
+ Name: hyperheadset
3
+ Version: 0.1.0
4
+ Summary: Astro A50 base station HID stats client
5
+ Author: Hyper
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: hidapi>=0.14.0
10
+
11
+ # Hyperheadset
12
+
13
+ A small Python CLI and library for reading live status data from the Astro
14
+ A50 base station over HID.
15
+
16
+ This started as a side project because I wanted to see battery %, dock
17
+ state, and sidetone levels without running the official Astro software
18
+ in the background. That turned into reverse-engineering the HID
19
+ protocol... which turned into this.
20
+
21
+ It talks directly to the base station, parses the response frames, and
22
+ exposes the useful bits in a clean way.
23
+
24
+ ---
25
+
26
+ ## What it can do
27
+ Right now it focuses on reading states.
28
+
29
+ - Read battery percentage + charging state
30
+ - Detect whether the headset is docked / powered
31
+ - Read sidetone slider values (active + saved)
32
+ - Output as JSON (compact or pretty)
33
+ - CSV logging
34
+ - Watch mode (with change detection)
35
+ - Filter specific fields (`--fields`)
36
+ - List matching HID devices
37
+ - Usable as both a CLI tool and a Python module
38
+
39
+ ## Install
40
+
41
+ Not on PyPI (yet).
42
+
43
+ Local install:
44
+
45
+ ```bash
46
+ pip install .
47
+ ```
48
+
49
+ Editable dev install:
50
+
51
+ ```bash
52
+ pip install -e .
53
+ ```
54
+
55
+ ## CLI Usage
56
+
57
+ Default snapshot:
58
+
59
+ ```bash
60
+ hyperheadset
61
+ ```
62
+
63
+ Some common flags:
64
+
65
+ - Battery only → `--battery`
66
+ - JSON output → `--json`
67
+ - Pretty JSON → `--json --p`
68
+ - Watch mode → `--watch`
69
+ - Only show changes (watch mode) → `--changes-only`
70
+ - List matching HID devices → `--device-list`
71
+
72
+ ### Examples
73
+
74
+ Watch only sidetone, and only print when it changes:
75
+ ``` bash
76
+ hyperheadset --fields sidetone --watch --changes-only
77
+ ```
78
+
79
+ Log everything to CSV every 2 seconds:
80
+ ``` bash
81
+ hyperheadset --csv --watch --interval 2 > log.csv
82
+ ```
83
+
84
+ Pretty JSON snapshot without timestamp:
85
+ ``` bash
86
+ hyperheadset --json --p --no-timestamp
87
+ ```
88
+
89
+ ## Python Usage
90
+ You can also use it directly in code:
91
+
92
+ ```python
93
+ from hyperheadset import AstroA50Client, SliderType
94
+
95
+ client = AstroA50Client()
96
+
97
+ battery = client.getBatteryStatus()
98
+ print(battery.chargePercent, battery.isCharging)
99
+
100
+ sidetone = client.getSliderValue(SliderType.sidetone)
101
+ print(sidetone)
102
+ ```
103
+
104
+ Snapshot helper:
105
+ ```python
106
+ snap = client.getSnapshot(battery=True, headset=True, sidetone=True)
107
+ print(snap)
108
+ ```
109
+
110
+
111
+
112
+ ## Requirements
113
+ - Python 3.10+
114
+ - `hidapi` bindings (`pip install hidapi`)
115
+ - Astro A50 base station connected over USB
116
+ - OS must expose the device as a standard HID interface
117
+
118
+ If your OS doesn't see it as HID, this won't work. No custom drivers
119
+ included here.
120
+
121
+ ## Scope (and what this is *not*)
122
+
123
+ This project focuses on:
124
+
125
+ - Reading device state
126
+ - Protocol framing
127
+ - Simple query commands
128
+
129
+ It's **not**:
130
+ - A replacement for the official Astro software
131
+ - A firmware updater
132
+ - An EQ editor
133
+
134
+ Write/set commands *might* be added later, but only after making sure
135
+ they're safe. Bricking a headset is not on the roadmap.
136
+
137
+ ## Credits / Prior Work
138
+ Huge credit to the [eh-fifty](https://github.com/tdryer/eh-fifty) project by Tom Dryer.
139
+
140
+ That project documents and reverse-engineers large parts of the Astro
141
+ A50 USB/HID protocol. I studied their research to understand the framing
142
+ and command structure, then re-implemented what I needed in a smaller
143
+ codebase focused purely on stats and queries.
144
+
145
+ No source code from [eh-fifty](https://github.com/tdryer/eh-fifty) is included here but their work made
146
+ this possible.
147
+
148
+ If you want broader device coverage or deeper protocol exploration,
149
+ definitely check that repository out.
150
+
151
+
152
+ ## License
153
+
154
+ MIT
155
+
156
+
157
+ ## Disclaimer
158
+
159
+ Not affiliated with or endorsed by Astro, Logitech, or the eh-fifty
160
+ project authors.
@@ -0,0 +1,14 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/hyperheadset/__init__.py
4
+ src/hyperheadset/__main__.py
5
+ src/hyperheadset/cli.py
6
+ src/hyperheadset/client.py
7
+ src/hyperheadset/enums.py
8
+ src/hyperheadset/models.py
9
+ src/hyperheadset.egg-info/PKG-INFO
10
+ src/hyperheadset.egg-info/SOURCES.txt
11
+ src/hyperheadset.egg-info/dependency_links.txt
12
+ src/hyperheadset.egg-info/entry_points.txt
13
+ src/hyperheadset.egg-info/requires.txt
14
+ src/hyperheadset.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ hyperheadset = hyperheadset.cli:main
@@ -0,0 +1 @@
1
+ hidapi>=0.14.0
@@ -0,0 +1 @@
1
+ hyperheadset