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.
- hyperheadset-0.1.0/PKG-INFO +160 -0
- hyperheadset-0.1.0/README.md +150 -0
- hyperheadset-0.1.0/pyproject.toml +24 -0
- hyperheadset-0.1.0/setup.cfg +4 -0
- hyperheadset-0.1.0/src/hyperheadset/__init__.py +11 -0
- hyperheadset-0.1.0/src/hyperheadset/__main__.py +3 -0
- hyperheadset-0.1.0/src/hyperheadset/cli.py +206 -0
- hyperheadset-0.1.0/src/hyperheadset/client.py +229 -0
- hyperheadset-0.1.0/src/hyperheadset/enums.py +22 -0
- hyperheadset-0.1.0/src/hyperheadset/models.py +13 -0
- hyperheadset-0.1.0/src/hyperheadset.egg-info/PKG-INFO +160 -0
- hyperheadset-0.1.0/src/hyperheadset.egg-info/SOURCES.txt +14 -0
- hyperheadset-0.1.0/src/hyperheadset.egg-info/dependency_links.txt +1 -0
- hyperheadset-0.1.0/src/hyperheadset.egg-info/entry_points.txt +2 -0
- hyperheadset-0.1.0/src/hyperheadset.egg-info/requires.txt +1 -0
- hyperheadset-0.1.0/src/hyperheadset.egg-info/top_level.txt +1 -0
|
@@ -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,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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hidapi>=0.14.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hyperheadset
|