something-x-dev 1.3.0.dev6__tar.gz → 1.5.0.dev8__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.
- something_x_dev-1.5.0.dev8/PKG-INFO +252 -0
- something_x_dev-1.5.0.dev8/README.md +229 -0
- {something_x_dev-1.3.0.dev6 → something_x_dev-1.5.0.dev8}/nothing_app/pages/device.py +32 -6
- {something_x_dev-1.3.0.dev6 → something_x_dev-1.5.0.dev8}/nothing_app/protocol.py +32 -10
- {something_x_dev-1.3.0.dev6 → something_x_dev-1.5.0.dev8}/pyproject.toml +1 -1
- something_x_dev-1.5.0.dev8/something_x_dev.egg-info/PKG-INFO +252 -0
- something_x_dev-1.3.0.dev6/PKG-INFO +0 -201
- something_x_dev-1.3.0.dev6/README.md +0 -178
- something_x_dev-1.3.0.dev6/something_x_dev.egg-info/PKG-INFO +0 -201
- {something_x_dev-1.3.0.dev6 → something_x_dev-1.5.0.dev8}/LICENSE +0 -0
- {something_x_dev-1.3.0.dev6 → something_x_dev-1.5.0.dev8}/nothing_app/__init__.py +0 -0
- {something_x_dev-1.3.0.dev6 → something_x_dev-1.5.0.dev8}/nothing_app/application.py +0 -0
- {something_x_dev-1.3.0.dev6 → something_x_dev-1.5.0.dev8}/nothing_app/bluetooth.py +0 -0
- {something_x_dev-1.3.0.dev6 → something_x_dev-1.5.0.dev8}/nothing_app/data/__init__.py +0 -0
- {something_x_dev-1.3.0.dev6 → something_x_dev-1.5.0.dev8}/nothing_app/data/com.something.x.omarchy.desktop +0 -0
- {something_x_dev-1.3.0.dev6 → something_x_dev-1.5.0.dev8}/nothing_app/data/style.css +0 -0
- {something_x_dev-1.3.0.dev6 → something_x_dev-1.5.0.dev8}/nothing_app/pages/__init__.py +0 -0
- {something_x_dev-1.3.0.dev6 → something_x_dev-1.5.0.dev8}/nothing_app/pages/home.py +0 -0
- {something_x_dev-1.3.0.dev6 → something_x_dev-1.5.0.dev8}/nothing_app/profiles.py +0 -0
- {something_x_dev-1.3.0.dev6 → something_x_dev-1.5.0.dev8}/nothing_app/splash.py +0 -0
- {something_x_dev-1.3.0.dev6 → something_x_dev-1.5.0.dev8}/nothing_app/tray.py +0 -0
- {something_x_dev-1.3.0.dev6 → something_x_dev-1.5.0.dev8}/nothing_app/window.py +0 -0
- {something_x_dev-1.3.0.dev6 → something_x_dev-1.5.0.dev8}/setup.cfg +0 -0
- {something_x_dev-1.3.0.dev6 → something_x_dev-1.5.0.dev8}/something_x_dev.egg-info/SOURCES.txt +0 -0
- {something_x_dev-1.3.0.dev6 → something_x_dev-1.5.0.dev8}/something_x_dev.egg-info/dependency_links.txt +0 -0
- {something_x_dev-1.3.0.dev6 → something_x_dev-1.5.0.dev8}/something_x_dev.egg-info/entry_points.txt +0 -0
- {something_x_dev-1.3.0.dev6 → something_x_dev-1.5.0.dev8}/something_x_dev.egg-info/requires.txt +0 -0
- {something_x_dev-1.3.0.dev6 → something_x_dev-1.5.0.dev8}/something_x_dev.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: something-x-dev
|
|
3
|
+
Version: 1.5.0.dev8
|
|
4
|
+
Summary: Something X device manager for Omarchy / Linux
|
|
5
|
+
Author: Raphael
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: nothing,bluetooth,gtk4,linux,omarchy,ear
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Environment :: X11 Applications :: GTK
|
|
10
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Utilities
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: PyGObject>=3.42
|
|
19
|
+
Requires-Dist: dbus-python>=1.3
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: ruff; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
<div align="center">
|
|
25
|
+
|
|
26
|
+
# Something X
|
|
27
|
+
|
|
28
|
+
**A Linux-native companion app for Nothing and CMF Bluetooth devices.**
|
|
29
|
+
Built for [Omarchy](https://omarchy.org) · GTK4 · Pure black · JetBrains Mono · Nothing Red
|
|
30
|
+
|
|
31
|
+
[](https://pypi.org/project/something-x/)
|
|
32
|
+
[](https://aur.archlinux.org/packages/something-x)
|
|
33
|
+
[](LICENSE)
|
|
34
|
+
[](https://github.com/SoaOaoS/something-x)
|
|
35
|
+
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Features
|
|
41
|
+
|
|
42
|
+
| | Feature | Details |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| 🎧 | **Earbud visual** | Cairo-rendered glowing battery rings for L / R / Case, live updates |
|
|
45
|
+
| 🔇 | **ANC control** | Off · Noise Cancellation · Transparency over real RFCOMM protocol |
|
|
46
|
+
| 🎵 | **EQ presets** | Balanced · More Bass · More Treble · Voice |
|
|
47
|
+
| 🔊 | **Volume slider** | Direct PulseAudio / PipeWire A2DP sink control via `pactl` |
|
|
48
|
+
| 💾 | **Per-device profiles** | ANC + EQ saved per device address, restored automatically on reconnect |
|
|
49
|
+
| 🔋 | **Battery notifications** | Desktop alerts at 20 %, 15 %, 10 %, and 5 % per earbud and case |
|
|
50
|
+
| 🔗 | **Auto-connect RFCOMM** | Connects to the device protocol as soon as BlueZ reports it paired |
|
|
51
|
+
| 🏃 | **Background mode** | Closing the window keeps the app running; relaunch to reopen |
|
|
52
|
+
| 💻 | **CLI** | Control your earbuds from the terminal without opening the GUI |
|
|
53
|
+
| 📱 | **Device discovery** | BlueZ D-Bus scan with Nothing / CMF devices highlighted |
|
|
54
|
+
| ℹ️ | **Device info** | Firmware version and serial number read over RFCOMM |
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Device support
|
|
59
|
+
|
|
60
|
+
| Device | Battery | ANC | EQ | Volume | Firmware |
|
|
61
|
+
|---|:---:|:---:|:---:|:---:|:---:|
|
|
62
|
+
| Nothing Ear (1) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
63
|
+
| Nothing Ear (2) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
64
|
+
| Nothing Ear (a) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
65
|
+
| Nothing Ear (stick) | ✅ | — | ✅ | ✅ | ✅ |
|
|
66
|
+
| CMF Buds / Buds Pro | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
67
|
+
| Nothing Phone (1/2) | ✅ | — | — | — | — |
|
|
68
|
+
| Other Bluetooth devices | ✅* | — | — | ✅ | — |
|
|
69
|
+
|
|
70
|
+
<sub>* via BlueZ `Battery1` interface · RFCOMM features require an active connection</sub>
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Installation
|
|
75
|
+
|
|
76
|
+
### Arch / Omarchy (recommended)
|
|
77
|
+
|
|
78
|
+
Install system dependencies first:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
sudo pacman -S python-gobject python-dbus python-cairo gtk4 libadwaita
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Then install from **AUR**:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
yay -S something-x
|
|
88
|
+
# or: paru -S something-x
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Or via **pip**:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
pip install something-x
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Other distros
|
|
98
|
+
|
|
99
|
+
<details>
|
|
100
|
+
<summary>Ubuntu 24.04+</summary>
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
sudo apt install python3-gi python3-dbus python3-cairo gir1.2-gtk-4.0 gir1.2-adw-1
|
|
104
|
+
pip install something-x
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
</details>
|
|
108
|
+
|
|
109
|
+
<details>
|
|
110
|
+
<summary>Fedora 39+</summary>
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
sudo dnf install python3-gobject python3-dbus python3-cairo gtk4 libadwaita
|
|
114
|
+
pip install something-x
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
</details>
|
|
118
|
+
|
|
119
|
+
<details>
|
|
120
|
+
<summary>NixOS</summary>
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
nix run github:SoaOaoS/something-x
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
A `flake.nix` is included for reproducible builds.
|
|
127
|
+
|
|
128
|
+
</details>
|
|
129
|
+
|
|
130
|
+
### Run from source
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
git clone https://github.com/SoaOaoS/something-x
|
|
134
|
+
cd something-x
|
|
135
|
+
pip install -e .
|
|
136
|
+
something-x
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Usage
|
|
142
|
+
|
|
143
|
+
### GUI
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
something-x
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
1. **Splash** — animated intro, main window appears after ~2 s
|
|
150
|
+
2. **Home** — lists all paired Bluetooth devices; Nothing / CMF devices get a `NOTHING` badge
|
|
151
|
+
3. **Scan** — tap `SCAN FOR DEVICES` to run a 30 s BlueZ discovery
|
|
152
|
+
4. **Device page** — tap a card to open controls:
|
|
153
|
+
- Battery rings (L / R / Case) update in real time
|
|
154
|
+
- ANC and EQ apply immediately over RFCOMM and are saved to your profile
|
|
155
|
+
- Volume slider drives the A2DP sink via `pactl`
|
|
156
|
+
- Firmware version and serial number appear after RFCOMM connects
|
|
157
|
+
5. **Close** — hides to background; run `something-x` again to reopen
|
|
158
|
+
|
|
159
|
+
### CLI
|
|
160
|
+
|
|
161
|
+
Control your earbuds without opening the GUI:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
# Battery levels
|
|
165
|
+
something-x --battery
|
|
166
|
+
|
|
167
|
+
# ANC mode
|
|
168
|
+
something-x --anc off
|
|
169
|
+
something-x --anc on
|
|
170
|
+
something-x --anc transparency
|
|
171
|
+
|
|
172
|
+
# EQ preset
|
|
173
|
+
something-x --eq balanced
|
|
174
|
+
something-x --eq bass
|
|
175
|
+
something-x --eq treble
|
|
176
|
+
something-x --eq voice
|
|
177
|
+
|
|
178
|
+
# Combine
|
|
179
|
+
something-x --anc on --eq bass
|
|
180
|
+
|
|
181
|
+
# Target a specific device by address
|
|
182
|
+
something-x --device AA:BB:CC:DD:EE:FF --battery
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## Development releases
|
|
188
|
+
|
|
189
|
+
The `develop` branch publishes pre-release builds to PyPI automatically as `something-x-dev`:
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
pip install something-x-dev
|
|
193
|
+
something-x-dev
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Dev builds use version numbers like `1.3.0.dev42`. Not for production use.
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## Releases & versioning
|
|
201
|
+
|
|
202
|
+
Pushing to `main` triggers automatic versioning, a GitHub Release, a PyPI publish, and an AUR update — all from Conventional Commits:
|
|
203
|
+
|
|
204
|
+
| Commit prefix | Version bump |
|
|
205
|
+
|---|---|
|
|
206
|
+
| `feat!:` / `BREAKING CHANGE:` | Major `x.0.0` |
|
|
207
|
+
| `feat:` | Minor `1.x.0` |
|
|
208
|
+
| `fix:` / `perf:` / `refactor:` | Patch `1.0.x` |
|
|
209
|
+
| `docs:` / `chore:` / `style:` / `ci:` / `test:` | No release |
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## Architecture
|
|
214
|
+
|
|
215
|
+
```
|
|
216
|
+
nothing_app/
|
|
217
|
+
├── application.py Adw.Application — lifecycle, CSS, CLI arg handling, background mode
|
|
218
|
+
├── window.py AdwNavigationView — home ↔ device routing, RFCOMM auto-connect manager
|
|
219
|
+
├── bluetooth.py BlueZ D-Bus manager — device discovery, connect/disconnect signals
|
|
220
|
+
├── protocol.py Nothing Ear 0x55 RFCOMM protocol (reverse-engineered from APK)
|
|
221
|
+
├── profiles.py Per-device ANC/EQ persistence (~/.config/something-x/profiles.json)
|
|
222
|
+
├── splash.py Animated splash screen (Cairo, typewriter, ripple rings)
|
|
223
|
+
├── data/
|
|
224
|
+
│ └── style.css Glass-morphism CSS theme
|
|
225
|
+
└── pages/
|
|
226
|
+
├── home.py Device list + scan button
|
|
227
|
+
└── device.py ANC / EQ / volume / settings + Cairo earbud visual
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Protocol
|
|
231
|
+
|
|
232
|
+
Frame format: `[SOF=0x55][ctrl:2 LE][cmd:2 LE][len:2 LE][FSN:1][payload][CRC16:2 LE]`
|
|
233
|
+
|
|
234
|
+
All outgoing frames use `ctrl=0x0160` with CRC16-ARC. The device silently drops SET commands if any frame in the session was sent without CRC.
|
|
235
|
+
|
|
236
|
+
Enable raw frame logging:
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
SOMETHING_X_DEBUG=1 something-x
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## Contributing
|
|
245
|
+
|
|
246
|
+
The RFCOMM protocol in [`nothing_app/protocol.py`](nothing_app/protocol.py) is reverse-engineered from the official Android APK. If your device uses different command IDs, channels, or ANC values, patches are very welcome — please include the raw RFCOMM dump (`SOMETHING_X_DEBUG=1`) in your issue or PR.
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## License
|
|
251
|
+
|
|
252
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# Something X
|
|
4
|
+
|
|
5
|
+
**A Linux-native companion app for Nothing and CMF Bluetooth devices.**
|
|
6
|
+
Built for [Omarchy](https://omarchy.org) · GTK4 · Pure black · JetBrains Mono · Nothing Red
|
|
7
|
+
|
|
8
|
+
[](https://pypi.org/project/something-x/)
|
|
9
|
+
[](https://aur.archlinux.org/packages/something-x)
|
|
10
|
+
[](LICENSE)
|
|
11
|
+
[](https://github.com/SoaOaoS/something-x)
|
|
12
|
+
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Features
|
|
18
|
+
|
|
19
|
+
| | Feature | Details |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| 🎧 | **Earbud visual** | Cairo-rendered glowing battery rings for L / R / Case, live updates |
|
|
22
|
+
| 🔇 | **ANC control** | Off · Noise Cancellation · Transparency over real RFCOMM protocol |
|
|
23
|
+
| 🎵 | **EQ presets** | Balanced · More Bass · More Treble · Voice |
|
|
24
|
+
| 🔊 | **Volume slider** | Direct PulseAudio / PipeWire A2DP sink control via `pactl` |
|
|
25
|
+
| 💾 | **Per-device profiles** | ANC + EQ saved per device address, restored automatically on reconnect |
|
|
26
|
+
| 🔋 | **Battery notifications** | Desktop alerts at 20 %, 15 %, 10 %, and 5 % per earbud and case |
|
|
27
|
+
| 🔗 | **Auto-connect RFCOMM** | Connects to the device protocol as soon as BlueZ reports it paired |
|
|
28
|
+
| 🏃 | **Background mode** | Closing the window keeps the app running; relaunch to reopen |
|
|
29
|
+
| 💻 | **CLI** | Control your earbuds from the terminal without opening the GUI |
|
|
30
|
+
| 📱 | **Device discovery** | BlueZ D-Bus scan with Nothing / CMF devices highlighted |
|
|
31
|
+
| ℹ️ | **Device info** | Firmware version and serial number read over RFCOMM |
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Device support
|
|
36
|
+
|
|
37
|
+
| Device | Battery | ANC | EQ | Volume | Firmware |
|
|
38
|
+
|---|:---:|:---:|:---:|:---:|:---:|
|
|
39
|
+
| Nothing Ear (1) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
40
|
+
| Nothing Ear (2) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
41
|
+
| Nothing Ear (a) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
42
|
+
| Nothing Ear (stick) | ✅ | — | ✅ | ✅ | ✅ |
|
|
43
|
+
| CMF Buds / Buds Pro | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
44
|
+
| Nothing Phone (1/2) | ✅ | — | — | — | — |
|
|
45
|
+
| Other Bluetooth devices | ✅* | — | — | ✅ | — |
|
|
46
|
+
|
|
47
|
+
<sub>* via BlueZ `Battery1` interface · RFCOMM features require an active connection</sub>
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
### Arch / Omarchy (recommended)
|
|
54
|
+
|
|
55
|
+
Install system dependencies first:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
sudo pacman -S python-gobject python-dbus python-cairo gtk4 libadwaita
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Then install from **AUR**:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
yay -S something-x
|
|
65
|
+
# or: paru -S something-x
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Or via **pip**:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install something-x
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Other distros
|
|
75
|
+
|
|
76
|
+
<details>
|
|
77
|
+
<summary>Ubuntu 24.04+</summary>
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
sudo apt install python3-gi python3-dbus python3-cairo gir1.2-gtk-4.0 gir1.2-adw-1
|
|
81
|
+
pip install something-x
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
</details>
|
|
85
|
+
|
|
86
|
+
<details>
|
|
87
|
+
<summary>Fedora 39+</summary>
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
sudo dnf install python3-gobject python3-dbus python3-cairo gtk4 libadwaita
|
|
91
|
+
pip install something-x
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
</details>
|
|
95
|
+
|
|
96
|
+
<details>
|
|
97
|
+
<summary>NixOS</summary>
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
nix run github:SoaOaoS/something-x
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
A `flake.nix` is included for reproducible builds.
|
|
104
|
+
|
|
105
|
+
</details>
|
|
106
|
+
|
|
107
|
+
### Run from source
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
git clone https://github.com/SoaOaoS/something-x
|
|
111
|
+
cd something-x
|
|
112
|
+
pip install -e .
|
|
113
|
+
something-x
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Usage
|
|
119
|
+
|
|
120
|
+
### GUI
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
something-x
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
1. **Splash** — animated intro, main window appears after ~2 s
|
|
127
|
+
2. **Home** — lists all paired Bluetooth devices; Nothing / CMF devices get a `NOTHING` badge
|
|
128
|
+
3. **Scan** — tap `SCAN FOR DEVICES` to run a 30 s BlueZ discovery
|
|
129
|
+
4. **Device page** — tap a card to open controls:
|
|
130
|
+
- Battery rings (L / R / Case) update in real time
|
|
131
|
+
- ANC and EQ apply immediately over RFCOMM and are saved to your profile
|
|
132
|
+
- Volume slider drives the A2DP sink via `pactl`
|
|
133
|
+
- Firmware version and serial number appear after RFCOMM connects
|
|
134
|
+
5. **Close** — hides to background; run `something-x` again to reopen
|
|
135
|
+
|
|
136
|
+
### CLI
|
|
137
|
+
|
|
138
|
+
Control your earbuds without opening the GUI:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
# Battery levels
|
|
142
|
+
something-x --battery
|
|
143
|
+
|
|
144
|
+
# ANC mode
|
|
145
|
+
something-x --anc off
|
|
146
|
+
something-x --anc on
|
|
147
|
+
something-x --anc transparency
|
|
148
|
+
|
|
149
|
+
# EQ preset
|
|
150
|
+
something-x --eq balanced
|
|
151
|
+
something-x --eq bass
|
|
152
|
+
something-x --eq treble
|
|
153
|
+
something-x --eq voice
|
|
154
|
+
|
|
155
|
+
# Combine
|
|
156
|
+
something-x --anc on --eq bass
|
|
157
|
+
|
|
158
|
+
# Target a specific device by address
|
|
159
|
+
something-x --device AA:BB:CC:DD:EE:FF --battery
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## Development releases
|
|
165
|
+
|
|
166
|
+
The `develop` branch publishes pre-release builds to PyPI automatically as `something-x-dev`:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
pip install something-x-dev
|
|
170
|
+
something-x-dev
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Dev builds use version numbers like `1.3.0.dev42`. Not for production use.
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## Releases & versioning
|
|
178
|
+
|
|
179
|
+
Pushing to `main` triggers automatic versioning, a GitHub Release, a PyPI publish, and an AUR update — all from Conventional Commits:
|
|
180
|
+
|
|
181
|
+
| Commit prefix | Version bump |
|
|
182
|
+
|---|---|
|
|
183
|
+
| `feat!:` / `BREAKING CHANGE:` | Major `x.0.0` |
|
|
184
|
+
| `feat:` | Minor `1.x.0` |
|
|
185
|
+
| `fix:` / `perf:` / `refactor:` | Patch `1.0.x` |
|
|
186
|
+
| `docs:` / `chore:` / `style:` / `ci:` / `test:` | No release |
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Architecture
|
|
191
|
+
|
|
192
|
+
```
|
|
193
|
+
nothing_app/
|
|
194
|
+
├── application.py Adw.Application — lifecycle, CSS, CLI arg handling, background mode
|
|
195
|
+
├── window.py AdwNavigationView — home ↔ device routing, RFCOMM auto-connect manager
|
|
196
|
+
├── bluetooth.py BlueZ D-Bus manager — device discovery, connect/disconnect signals
|
|
197
|
+
├── protocol.py Nothing Ear 0x55 RFCOMM protocol (reverse-engineered from APK)
|
|
198
|
+
├── profiles.py Per-device ANC/EQ persistence (~/.config/something-x/profiles.json)
|
|
199
|
+
├── splash.py Animated splash screen (Cairo, typewriter, ripple rings)
|
|
200
|
+
├── data/
|
|
201
|
+
│ └── style.css Glass-morphism CSS theme
|
|
202
|
+
└── pages/
|
|
203
|
+
├── home.py Device list + scan button
|
|
204
|
+
└── device.py ANC / EQ / volume / settings + Cairo earbud visual
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Protocol
|
|
208
|
+
|
|
209
|
+
Frame format: `[SOF=0x55][ctrl:2 LE][cmd:2 LE][len:2 LE][FSN:1][payload][CRC16:2 LE]`
|
|
210
|
+
|
|
211
|
+
All outgoing frames use `ctrl=0x0160` with CRC16-ARC. The device silently drops SET commands if any frame in the session was sent without CRC.
|
|
212
|
+
|
|
213
|
+
Enable raw frame logging:
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
SOMETHING_X_DEBUG=1 something-x
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## Contributing
|
|
222
|
+
|
|
223
|
+
The RFCOMM protocol in [`nothing_app/protocol.py`](nothing_app/protocol.py) is reverse-engineered from the official Android APK. If your device uses different command IDs, channels, or ANC values, patches are very welcome — please include the raw RFCOMM dump (`SOMETHING_X_DEBUG=1`) in your issue or PR.
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## License
|
|
228
|
+
|
|
229
|
+
[MIT](LICENSE)
|
|
@@ -93,19 +93,24 @@ class EarbudVisual(Gtk.DrawingArea):
|
|
|
93
93
|
self._left = -1
|
|
94
94
|
self._right = -1
|
|
95
95
|
self._case = -1
|
|
96
|
+
self._left_wearing = False
|
|
97
|
+
self._right_wearing = False
|
|
96
98
|
|
|
97
|
-
def update(
|
|
99
|
+
def update(
|
|
100
|
+
self, left: int, right: int, case: int, left_wearing: bool = False, right_wearing: bool = False
|
|
101
|
+
):
|
|
98
102
|
self._left, self._right, self._case = left, right, case
|
|
103
|
+
self._left_wearing, self._right_wearing = left_wearing, right_wearing
|
|
99
104
|
self.queue_draw()
|
|
100
105
|
|
|
101
106
|
def _draw(self, _area, cr, width, height):
|
|
102
107
|
cx = width / 2
|
|
103
108
|
cy = height / 2 - 8
|
|
104
|
-
self._draw_bud(cr, cx - 92, cy, self._left, "L")
|
|
105
|
-
self._draw_bud(cr, cx + 92, cy, self._right, "R")
|
|
109
|
+
self._draw_bud(cr, cx - 92, cy, self._left, "L", self._left_wearing)
|
|
110
|
+
self._draw_bud(cr, cx + 92, cy, self._right, "R", self._right_wearing)
|
|
106
111
|
self._draw_case(cr, cx, cy + 54, self._case)
|
|
107
112
|
|
|
108
|
-
def _draw_bud(self, cr, cx, cy, pct, label):
|
|
113
|
+
def _draw_bud(self, cr, cx, cy, pct, label, wearing: bool = False):
|
|
109
114
|
R = 42
|
|
110
115
|
r = 29
|
|
111
116
|
bc = _battery_color(pct) if pct >= 0 else (0.18, 0.18, 0.18)
|
|
@@ -166,12 +171,27 @@ class EarbudVisual(Gtk.DrawingArea):
|
|
|
166
171
|
cr.move_to(cx - te.width / 2 - te.x_bearing, cy - te.height / 2 - te.y_bearing)
|
|
167
172
|
cr.show_text(text)
|
|
168
173
|
|
|
174
|
+
# in-ear indicator dot (always rendered; glows red when wearing)
|
|
175
|
+
dot_y = cy + R + 8
|
|
176
|
+
if wearing:
|
|
177
|
+
rg = cairo.RadialGradient(cx, dot_y, 0, cx, dot_y, 9)
|
|
178
|
+
rg.add_color_stop_rgba(0, 0.87, 0.18, 0.18, 0.30)
|
|
179
|
+
rg.add_color_stop_rgba(1, 0.87, 0.18, 0.18, 0.0)
|
|
180
|
+
cr.set_source(rg)
|
|
181
|
+
cr.arc(cx, dot_y, 9, 0, 2 * math.pi)
|
|
182
|
+
cr.fill()
|
|
183
|
+
cr.set_source_rgba(0.87, 0.18, 0.18, 0.9)
|
|
184
|
+
else:
|
|
185
|
+
cr.set_source_rgba(1.0, 1.0, 1.0, 0.07)
|
|
186
|
+
cr.arc(cx, dot_y, 3, 0, 2 * math.pi)
|
|
187
|
+
cr.fill()
|
|
188
|
+
|
|
169
189
|
# L / R label below
|
|
170
190
|
cr.set_source_rgba(1.0, 1.0, 1.0, 0.20)
|
|
171
191
|
cr.select_font_face(_MONO, cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD)
|
|
172
192
|
cr.set_font_size(9)
|
|
173
193
|
te = cr.text_extents(label)
|
|
174
|
-
cr.move_to(cx - te.width / 2 - te.x_bearing, cy + R +
|
|
194
|
+
cr.move_to(cx - te.width / 2 - te.x_bearing, cy + R + 20)
|
|
175
195
|
cr.show_text(label)
|
|
176
196
|
|
|
177
197
|
def _draw_case(self, cr, cx, cy, pct):
|
|
@@ -508,7 +528,13 @@ class DevicePage(Gtk.Box):
|
|
|
508
528
|
|
|
509
529
|
def _on_state_changed(self, dev: NothingDevice):
|
|
510
530
|
state = dev.state
|
|
511
|
-
self._visual.update(
|
|
531
|
+
self._visual.update(
|
|
532
|
+
state.left_battery,
|
|
533
|
+
state.right_battery,
|
|
534
|
+
state.case_battery,
|
|
535
|
+
state.left_wearing,
|
|
536
|
+
state.right_wearing,
|
|
537
|
+
)
|
|
512
538
|
self._sync_anc_ui(state.anc_mode)
|
|
513
539
|
self._sync_eq_ui(state.eq_preset)
|
|
514
540
|
self._updating_ui = True
|
|
@@ -417,6 +417,8 @@ class NothingDevice(GObject.Object):
|
|
|
417
417
|
GLib.timeout_add(3000, self._activation_fallback)
|
|
418
418
|
elif cmd_id == _CMD_SET_ACTIVATED:
|
|
419
419
|
_log(f"[RX INFO] activation ACK payload={payload.hex()}")
|
|
420
|
+
if not self._activated:
|
|
421
|
+
GLib.timeout_add(2000, self._poll_earphone_status)
|
|
420
422
|
self._activated = True
|
|
421
423
|
from . import profiles
|
|
422
424
|
|
|
@@ -433,8 +435,15 @@ class NothingDevice(GObject.Object):
|
|
|
433
435
|
changed = self._parse_battery(payload)
|
|
434
436
|
elif cmd_id in (_CMD_NOISE_RED, _EVT_NOISE_RED):
|
|
435
437
|
changed = self._parse_anc(payload)
|
|
436
|
-
elif cmd_id
|
|
438
|
+
elif cmd_id == _CMD_EARPHONE:
|
|
437
439
|
changed = self._parse_earphone_status(payload)
|
|
440
|
+
elif cmd_id == _EVT_STATUS:
|
|
441
|
+
# The pushed event only carries accurate data for the bud that
|
|
442
|
+
# changed; the other entries are stale placeholders. Use it purely
|
|
443
|
+
# as a trigger and re-query for a fresh full snapshot.
|
|
444
|
+
if _DEBUG:
|
|
445
|
+
_log(f"[protocol] EVT_STATUS {payload.hex()} → re-query GET_EARPHONE")
|
|
446
|
+
self._x55_send(_CMD_EARPHONE)
|
|
438
447
|
elif cmd_id == _CMD_HOST_VERSION:
|
|
439
448
|
ver = payload.decode(errors="replace").strip("\x00").strip()
|
|
440
449
|
if ver and ver != self.state.firmware_version:
|
|
@@ -512,26 +521,29 @@ class NothingDevice(GObject.Object):
|
|
|
512
521
|
return changed
|
|
513
522
|
|
|
514
523
|
def _parse_earphone_status(self, payload: bytes) -> bool:
|
|
515
|
-
# payload: [count:1][type:1][val:1]...
|
|
516
|
-
#
|
|
517
|
-
#
|
|
524
|
+
# payload: [count:1][type:1][val:1]... (only GET responses reach here;
|
|
525
|
+
# they are a fresh full snapshot, unlike the EVT push frames)
|
|
526
|
+
# EarphoneStatus.java: bit0=inCase, bit2=inEar, bit7=isConnect
|
|
527
|
+
# type: 2=left, 3=right, 4=case, 5=tws, 6=stereo
|
|
518
528
|
if len(payload) < 3:
|
|
519
529
|
return False
|
|
520
530
|
count = payload[0]
|
|
521
531
|
changed = False
|
|
532
|
+
if _DEBUG:
|
|
533
|
+
_log(f"[protocol] earphone raw={payload.hex()}")
|
|
522
534
|
for i in range(1, 1 + count * 2, 2):
|
|
523
535
|
if i + 1 >= len(payload):
|
|
524
536
|
break
|
|
525
537
|
etype = payload[i]
|
|
526
538
|
val = payload[i + 1]
|
|
539
|
+
if etype not in (2, 3):
|
|
540
|
+
continue
|
|
527
541
|
in_ear = bool(val & 0x04)
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
if etype == 2 and wearing != self.state.left_wearing:
|
|
531
|
-
self.state.left_wearing = wearing
|
|
542
|
+
if etype == 2 and in_ear != self.state.left_wearing:
|
|
543
|
+
self.state.left_wearing = in_ear
|
|
532
544
|
changed = True
|
|
533
|
-
elif etype == 3 and
|
|
534
|
-
self.state.right_wearing =
|
|
545
|
+
elif etype == 3 and in_ear != self.state.right_wearing:
|
|
546
|
+
self.state.right_wearing = in_ear
|
|
535
547
|
changed = True
|
|
536
548
|
if changed:
|
|
537
549
|
_log(f"[protocol] wearing L={self.state.left_wearing} R={self.state.right_wearing}")
|
|
@@ -632,10 +644,20 @@ class NothingDevice(GObject.Object):
|
|
|
632
644
|
).start()
|
|
633
645
|
break
|
|
634
646
|
|
|
647
|
+
def _poll_earphone_status(self):
|
|
648
|
+
# The firmware only computes a fresh per-bud snapshot when asked; the
|
|
649
|
+
# pushed EVT frames carry stale placeholder entries for the bud that
|
|
650
|
+
# didn't change. Polling keeps both buds' wearing state accurate.
|
|
651
|
+
if not self._rfcomm_connected:
|
|
652
|
+
return False
|
|
653
|
+
self._x55_send(_CMD_EARPHONE)
|
|
654
|
+
return True
|
|
655
|
+
|
|
635
656
|
def _activation_fallback(self):
|
|
636
657
|
if not self._activated and self._rfcomm_connected:
|
|
637
658
|
_log("[protocol] activation ACK not received within 3s — sending GET queries")
|
|
638
659
|
self._activated = True
|
|
660
|
+
GLib.timeout_add(2000, self._poll_earphone_status)
|
|
639
661
|
self._x55_send(_CMD_BATTERY)
|
|
640
662
|
self._x55_send(_CMD_NOISE_RED, bytes([0x03]))
|
|
641
663
|
self._x55_send(_CMD_EARPHONE)
|