something-x-dev 1.5.0.dev12__tar.gz → 1.7.0.dev14__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.dev12/something_x_dev.egg-info → something_x_dev-1.7.0.dev14}/PKG-INFO +5 -2
- {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/README.md +2 -0
- something_x_dev-1.7.0.dev14/nothing_app/bluetooth.py +384 -0
- {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/nothing_app/pages/device.py +2 -0
- {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/nothing_app/protocol.py +33 -3
- {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/pyproject.toml +5 -3
- {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14/something_x_dev.egg-info}/PKG-INFO +5 -2
- {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/something_x_dev.egg-info/SOURCES.txt +5 -1
- {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/something_x_dev.egg-info/requires.txt +2 -1
- something_x_dev-1.7.0.dev14/tests/test_bluetooth.py +136 -0
- something_x_dev-1.7.0.dev14/tests/test_crc.py +48 -0
- something_x_dev-1.7.0.dev14/tests/test_profiles.py +90 -0
- something_x_dev-1.7.0.dev14/tests/test_protocol.py +273 -0
- something_x_dev-1.5.0.dev12/nothing_app/bluetooth.py +0 -246
- {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/LICENSE +0 -0
- {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/nothing_app/__init__.py +0 -0
- {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/nothing_app/application.py +0 -0
- {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/nothing_app/data/__init__.py +0 -0
- {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/nothing_app/data/com.something.x.omarchy.desktop +0 -0
- {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/nothing_app/data/style.css +0 -0
- {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/nothing_app/pages/__init__.py +0 -0
- {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/nothing_app/pages/home.py +0 -0
- {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/nothing_app/profiles.py +0 -0
- {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/nothing_app/splash.py +0 -0
- {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/nothing_app/tray.py +0 -0
- {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/nothing_app/window.py +0 -0
- {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/setup.cfg +0 -0
- {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/something_x_dev.egg-info/dependency_links.txt +0 -0
- {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/something_x_dev.egg-info/entry_points.txt +0 -0
- {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/something_x_dev.egg-info/top_level.txt +0 -0
{something_x_dev-1.5.0.dev12/something_x_dev.egg-info → something_x_dev-1.7.0.dev14}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: something-x-dev
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.7.0.dev14
|
|
4
4
|
Summary: Something X device manager for Omarchy / Linux
|
|
5
5
|
Author: Raphael
|
|
6
6
|
License: MIT
|
|
@@ -16,9 +16,10 @@ Requires-Python: >=3.11
|
|
|
16
16
|
Description-Content-Type: text/markdown
|
|
17
17
|
License-File: LICENSE
|
|
18
18
|
Requires-Dist: PyGObject>=3.42
|
|
19
|
-
Requires-Dist: dbus-python>=1.3
|
|
20
19
|
Provides-Extra: dev
|
|
21
20
|
Requires-Dist: ruff; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
22
23
|
Dynamic: license-file
|
|
23
24
|
|
|
24
25
|
<div align="center">
|
|
@@ -32,6 +33,8 @@ Built for [Omarchy](https://omarchy.org) · GTK4 · Pure black · JetBrains Mono
|
|
|
32
33
|
[](https://aur.archlinux.org/packages/something-x)
|
|
33
34
|
[](LICENSE)
|
|
34
35
|
[](https://github.com/SoaOaoS/something-x)
|
|
36
|
+
[](https://github.com/SoaOaoS/something-x/actions/workflows/ci.yml)
|
|
37
|
+
[](https://github.com/SoaOaoS/something-x/actions/workflows/ci.yml)
|
|
35
38
|
|
|
36
39
|
</div>
|
|
37
40
|
|
|
@@ -9,6 +9,8 @@ Built for [Omarchy](https://omarchy.org) · GTK4 · Pure black · JetBrains Mono
|
|
|
9
9
|
[](https://aur.archlinux.org/packages/something-x)
|
|
10
10
|
[](LICENSE)
|
|
11
11
|
[](https://github.com/SoaOaoS/something-x)
|
|
12
|
+
[](https://github.com/SoaOaoS/something-x/actions/workflows/ci.yml)
|
|
13
|
+
[](https://github.com/SoaOaoS/something-x/actions/workflows/ci.yml)
|
|
12
14
|
|
|
13
15
|
</div>
|
|
14
16
|
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
from gi.repository import Gio, GLib, GObject
|
|
2
|
+
|
|
3
|
+
BLUEZ_SERVICE = "org.bluez"
|
|
4
|
+
ADAPTER_IFACE = "org.bluez.Adapter1"
|
|
5
|
+
DEVICE_IFACE = "org.bluez.Device1"
|
|
6
|
+
BATTERY_IFACE = "org.bluez.Battery1"
|
|
7
|
+
OBJ_MANAGER_IFACE = "org.freedesktop.DBus.ObjectManager"
|
|
8
|
+
PROPERTIES_IFACE = "org.freedesktop.DBus.Properties"
|
|
9
|
+
|
|
10
|
+
NOTHING_PATTERNS = (
|
|
11
|
+
"nothing ear",
|
|
12
|
+
"ear (1)",
|
|
13
|
+
"ear (2)",
|
|
14
|
+
"ear (a)",
|
|
15
|
+
"ear (stick)",
|
|
16
|
+
"cmf buds",
|
|
17
|
+
"cmf earphone",
|
|
18
|
+
"nothing phone",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Lightweight proxy flags: no property caching, no auto-signal wiring.
|
|
22
|
+
# Used for proxies that only need to call methods.
|
|
23
|
+
_FLAGS_METHOD = Gio.DBusProxyFlags.DO_NOT_LOAD_PROPERTIES | Gio.DBusProxyFlags.DO_NOT_CONNECT_SIGNALS
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BluetoothDevice:
|
|
27
|
+
def __init__(self, path: str, props: dict):
|
|
28
|
+
self.path = path
|
|
29
|
+
self.address: str = str(props.get("Address", ""))
|
|
30
|
+
self.name: str = str(props.get("Name", props.get("Alias", "Unknown Device")))
|
|
31
|
+
self.connected: bool = bool(props.get("Connected", False))
|
|
32
|
+
self.paired: bool = bool(props.get("Paired", False))
|
|
33
|
+
self.battery: int | None = None
|
|
34
|
+
self.icon: str = str(props.get("Icon", "audio-headphones"))
|
|
35
|
+
self.is_nothing: bool = any(p in self.name.lower() for p in NOTHING_PATTERNS)
|
|
36
|
+
|
|
37
|
+
def update(self, changed: dict):
|
|
38
|
+
if "Connected" in changed:
|
|
39
|
+
self.connected = bool(changed["Connected"])
|
|
40
|
+
if "Name" in changed:
|
|
41
|
+
self.name = str(changed["Name"])
|
|
42
|
+
if "Alias" in changed and not self.name:
|
|
43
|
+
self.name = str(changed["Alias"])
|
|
44
|
+
|
|
45
|
+
def __repr__(self):
|
|
46
|
+
return f"<BTDevice {self.name!r} {'●' if self.connected else '○'}>"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# BlueZ Device1.Icon → GTK symbolic icon name
|
|
50
|
+
_BLUEZ_ICON_MAP: dict[str, str] = {
|
|
51
|
+
"audio-headphones": "audio-headphones-symbolic",
|
|
52
|
+
"audio-headset": "audio-headset-symbolic",
|
|
53
|
+
"audio-card": "audio-card-symbolic",
|
|
54
|
+
"input-mouse": "input-mouse-symbolic",
|
|
55
|
+
"input-keyboard": "input-keyboard-symbolic",
|
|
56
|
+
"input-gaming": "input-gaming-symbolic",
|
|
57
|
+
"input-tablet": "input-tablet-symbolic",
|
|
58
|
+
"phone": "phone-symbolic",
|
|
59
|
+
"computer": "computer-symbolic",
|
|
60
|
+
"printer": "printer-symbolic",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
_WATCH_NAME_PATTERNS = ("watch", "band", "gear", "amazfit", "fenix", "vivoactive", "galaxy fit")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def device_icon_name(device: "BluetoothDevice | None") -> str:
|
|
67
|
+
"""Return a GTK symbolic icon name for a Bluetooth device."""
|
|
68
|
+
if device is None:
|
|
69
|
+
return "audio-headphones-symbolic"
|
|
70
|
+
name_lower = device.name.lower()
|
|
71
|
+
if any(p in name_lower for p in _WATCH_NAME_PATTERNS):
|
|
72
|
+
return "alarm-symbolic"
|
|
73
|
+
if "ear (stick)" in name_lower:
|
|
74
|
+
return "audio-input-microphone-symbolic"
|
|
75
|
+
mapped = _BLUEZ_ICON_MAP.get(device.icon)
|
|
76
|
+
if mapped:
|
|
77
|
+
return mapped
|
|
78
|
+
if "phone" in name_lower:
|
|
79
|
+
return "phone-symbolic"
|
|
80
|
+
return "audio-headphones-symbolic"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class BluetoothManager(GObject.Object):
|
|
84
|
+
__gsignals__ = {
|
|
85
|
+
"devices-changed": (GObject.SignalFlags.RUN_FIRST, None, ()),
|
|
86
|
+
"device-connected": (GObject.SignalFlags.RUN_FIRST, None, (str,)),
|
|
87
|
+
"device-disconnected": (GObject.SignalFlags.RUN_FIRST, None, (str,)),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
def __init__(self):
|
|
91
|
+
super().__init__()
|
|
92
|
+
self.devices: dict[str, BluetoothDevice] = {}
|
|
93
|
+
self._connection: Gio.DBusConnection | None = None
|
|
94
|
+
self._adapter_path: str | None = None
|
|
95
|
+
self._subs: list[int] = []
|
|
96
|
+
self._available = False
|
|
97
|
+
self._init_dbus()
|
|
98
|
+
|
|
99
|
+
def _init_dbus(self):
|
|
100
|
+
try:
|
|
101
|
+
self._connection = Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
|
|
102
|
+
mgr = Gio.DBusProxy.new_sync(
|
|
103
|
+
self._connection,
|
|
104
|
+
_FLAGS_METHOD,
|
|
105
|
+
None,
|
|
106
|
+
BLUEZ_SERVICE,
|
|
107
|
+
"/",
|
|
108
|
+
OBJ_MANAGER_IFACE,
|
|
109
|
+
None,
|
|
110
|
+
)
|
|
111
|
+
# Non-blocking: populates devices and emits devices-changed when ready
|
|
112
|
+
mgr.call(
|
|
113
|
+
"GetManagedObjects",
|
|
114
|
+
None,
|
|
115
|
+
Gio.DBusCallFlags.NONE,
|
|
116
|
+
-1,
|
|
117
|
+
None,
|
|
118
|
+
self._on_managed_objects,
|
|
119
|
+
None,
|
|
120
|
+
)
|
|
121
|
+
self._subscribe()
|
|
122
|
+
except Exception as exc:
|
|
123
|
+
print(f"[bluetooth] D-Bus init failed: {exc}")
|
|
124
|
+
|
|
125
|
+
def _on_managed_objects(self, proxy, result, _user_data):
|
|
126
|
+
try:
|
|
127
|
+
variant = proxy.call_finish(result)
|
|
128
|
+
objects = variant.unpack()[0]
|
|
129
|
+
self._populate(objects)
|
|
130
|
+
self._available = True
|
|
131
|
+
GLib.idle_add(self.emit, "devices-changed")
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
print(f"[bluetooth] GetManagedObjects failed: {exc}")
|
|
134
|
+
|
|
135
|
+
def _populate(self, objects: dict):
|
|
136
|
+
self.devices = {}
|
|
137
|
+
self._adapter_path = None
|
|
138
|
+
for path, ifaces in objects.items():
|
|
139
|
+
if ADAPTER_IFACE in ifaces and self._adapter_path is None:
|
|
140
|
+
self._adapter_path = path
|
|
141
|
+
if DEVICE_IFACE not in ifaces:
|
|
142
|
+
continue
|
|
143
|
+
props = dict(ifaces[DEVICE_IFACE])
|
|
144
|
+
dev = BluetoothDevice(path, props)
|
|
145
|
+
if BATTERY_IFACE in ifaces:
|
|
146
|
+
dev.battery = int(ifaces[BATTERY_IFACE].get("Percentage", 0))
|
|
147
|
+
self.devices[path] = dev
|
|
148
|
+
|
|
149
|
+
def _subscribe(self):
|
|
150
|
+
if not self._connection:
|
|
151
|
+
return
|
|
152
|
+
try:
|
|
153
|
+
self._subs.append(
|
|
154
|
+
self._connection.signal_subscribe(
|
|
155
|
+
BLUEZ_SERVICE,
|
|
156
|
+
PROPERTIES_IFACE,
|
|
157
|
+
"PropertiesChanged",
|
|
158
|
+
None,
|
|
159
|
+
None,
|
|
160
|
+
Gio.DBusSignalFlags.NONE,
|
|
161
|
+
self._on_props_changed,
|
|
162
|
+
None,
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
self._subs.append(
|
|
166
|
+
self._connection.signal_subscribe(
|
|
167
|
+
BLUEZ_SERVICE,
|
|
168
|
+
OBJ_MANAGER_IFACE,
|
|
169
|
+
"InterfacesAdded",
|
|
170
|
+
None,
|
|
171
|
+
None,
|
|
172
|
+
Gio.DBusSignalFlags.NONE,
|
|
173
|
+
self._on_ifaces_added,
|
|
174
|
+
None,
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
self._subs.append(
|
|
178
|
+
self._connection.signal_subscribe(
|
|
179
|
+
BLUEZ_SERVICE,
|
|
180
|
+
OBJ_MANAGER_IFACE,
|
|
181
|
+
"InterfacesRemoved",
|
|
182
|
+
None,
|
|
183
|
+
None,
|
|
184
|
+
Gio.DBusSignalFlags.NONE,
|
|
185
|
+
self._on_ifaces_removed,
|
|
186
|
+
None,
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
except Exception as exc:
|
|
190
|
+
print(f"[bluetooth] Signal subscribe failed: {exc}")
|
|
191
|
+
|
|
192
|
+
def _on_props_changed(self, _conn, _sender, path, _iface, _signal, params, _user_data):
|
|
193
|
+
iface_name, changed, _invalidated = params.unpack()
|
|
194
|
+
if iface_name != DEVICE_IFACE:
|
|
195
|
+
return
|
|
196
|
+
if path not in self.devices:
|
|
197
|
+
return
|
|
198
|
+
dev = self.devices[path]
|
|
199
|
+
old_connected = dev.connected
|
|
200
|
+
dev.update(dict(changed))
|
|
201
|
+
if dev.connected != old_connected:
|
|
202
|
+
sig = "device-connected" if dev.connected else "device-disconnected"
|
|
203
|
+
GLib.idle_add(self.emit, sig, path)
|
|
204
|
+
GLib.idle_add(self.emit, "devices-changed")
|
|
205
|
+
|
|
206
|
+
def _on_ifaces_added(self, _conn, _sender, _path, _iface, _signal, params, _user_data):
|
|
207
|
+
obj_path, ifaces = params.unpack()
|
|
208
|
+
if ADAPTER_IFACE in ifaces and self._adapter_path is None:
|
|
209
|
+
self._adapter_path = obj_path
|
|
210
|
+
if DEVICE_IFACE not in ifaces:
|
|
211
|
+
return
|
|
212
|
+
props = dict(ifaces[DEVICE_IFACE])
|
|
213
|
+
dev = BluetoothDevice(obj_path, props)
|
|
214
|
+
if BATTERY_IFACE in ifaces:
|
|
215
|
+
dev.battery = int(ifaces[BATTERY_IFACE].get("Percentage", 0))
|
|
216
|
+
self.devices[obj_path] = dev
|
|
217
|
+
GLib.idle_add(self.emit, "devices-changed")
|
|
218
|
+
|
|
219
|
+
def _on_ifaces_removed(self, _conn, _sender, _path, _iface, _signal, params, _user_data):
|
|
220
|
+
obj_path, ifaces = params.unpack()
|
|
221
|
+
if DEVICE_IFACE in ifaces and obj_path in self.devices:
|
|
222
|
+
del self.devices[obj_path]
|
|
223
|
+
GLib.idle_add(self.emit, "devices-changed")
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def available(self) -> bool:
|
|
227
|
+
return self._available
|
|
228
|
+
|
|
229
|
+
def get_all(self) -> list[BluetoothDevice]:
|
|
230
|
+
return sorted(self.devices.values(), key=lambda d: (not d.connected, d.name))
|
|
231
|
+
|
|
232
|
+
def get_nothing_devices(self) -> list[BluetoothDevice]:
|
|
233
|
+
return [d for d in self.get_all() if d.is_nothing]
|
|
234
|
+
|
|
235
|
+
def refresh(self):
|
|
236
|
+
if not self._connection:
|
|
237
|
+
return
|
|
238
|
+
try:
|
|
239
|
+
mgr = Gio.DBusProxy.new_sync(
|
|
240
|
+
self._connection,
|
|
241
|
+
_FLAGS_METHOD,
|
|
242
|
+
None,
|
|
243
|
+
BLUEZ_SERVICE,
|
|
244
|
+
"/",
|
|
245
|
+
OBJ_MANAGER_IFACE,
|
|
246
|
+
None,
|
|
247
|
+
)
|
|
248
|
+
mgr.call(
|
|
249
|
+
"GetManagedObjects",
|
|
250
|
+
None,
|
|
251
|
+
Gio.DBusCallFlags.NONE,
|
|
252
|
+
-1,
|
|
253
|
+
None,
|
|
254
|
+
self._on_managed_objects,
|
|
255
|
+
None,
|
|
256
|
+
)
|
|
257
|
+
except Exception as exc:
|
|
258
|
+
print(f"[bluetooth] refresh: {exc}")
|
|
259
|
+
|
|
260
|
+
def connect_device(self, path: str, on_error=None):
|
|
261
|
+
if not self._connection:
|
|
262
|
+
return
|
|
263
|
+
Gio.DBusProxy.new(
|
|
264
|
+
self._connection,
|
|
265
|
+
_FLAGS_METHOD,
|
|
266
|
+
None,
|
|
267
|
+
BLUEZ_SERVICE,
|
|
268
|
+
path,
|
|
269
|
+
DEVICE_IFACE,
|
|
270
|
+
None,
|
|
271
|
+
self._on_connect_proxy_ready,
|
|
272
|
+
on_error,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def _on_connect_proxy_ready(self, _source, result, on_error):
|
|
276
|
+
try:
|
|
277
|
+
proxy = Gio.DBusProxy.new_finish(result)
|
|
278
|
+
proxy.call(
|
|
279
|
+
"Connect",
|
|
280
|
+
None,
|
|
281
|
+
Gio.DBusCallFlags.NONE,
|
|
282
|
+
-1,
|
|
283
|
+
None,
|
|
284
|
+
self._on_connect_done,
|
|
285
|
+
on_error,
|
|
286
|
+
)
|
|
287
|
+
except Exception as exc:
|
|
288
|
+
print(f"[bluetooth] connect proxy failed: {exc}")
|
|
289
|
+
if on_error:
|
|
290
|
+
GLib.idle_add(on_error)
|
|
291
|
+
|
|
292
|
+
def _on_connect_done(self, proxy, result, on_error):
|
|
293
|
+
try:
|
|
294
|
+
proxy.call_finish(result)
|
|
295
|
+
except Exception as exc:
|
|
296
|
+
print(f"[BT] connect error: {exc}")
|
|
297
|
+
if on_error:
|
|
298
|
+
GLib.idle_add(on_error)
|
|
299
|
+
|
|
300
|
+
def disconnect_device(self, path: str):
|
|
301
|
+
if not self._connection:
|
|
302
|
+
return
|
|
303
|
+
Gio.DBusProxy.new(
|
|
304
|
+
self._connection,
|
|
305
|
+
_FLAGS_METHOD,
|
|
306
|
+
None,
|
|
307
|
+
BLUEZ_SERVICE,
|
|
308
|
+
path,
|
|
309
|
+
DEVICE_IFACE,
|
|
310
|
+
None,
|
|
311
|
+
self._on_disconnect_proxy_ready,
|
|
312
|
+
None,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def _on_disconnect_proxy_ready(self, _source, result, _user_data):
|
|
316
|
+
try:
|
|
317
|
+
proxy = Gio.DBusProxy.new_finish(result)
|
|
318
|
+
proxy.call(
|
|
319
|
+
"Disconnect",
|
|
320
|
+
None,
|
|
321
|
+
Gio.DBusCallFlags.NONE,
|
|
322
|
+
-1,
|
|
323
|
+
None,
|
|
324
|
+
self._on_disconnect_done,
|
|
325
|
+
None,
|
|
326
|
+
)
|
|
327
|
+
except Exception as exc:
|
|
328
|
+
print(f"[bluetooth] disconnect proxy failed: {exc}")
|
|
329
|
+
|
|
330
|
+
def _on_disconnect_done(self, proxy, result, _user_data):
|
|
331
|
+
try:
|
|
332
|
+
proxy.call_finish(result)
|
|
333
|
+
except Exception as exc:
|
|
334
|
+
print(f"[BT] disconnect error: {exc}")
|
|
335
|
+
|
|
336
|
+
def start_discovery(self):
|
|
337
|
+
if not self._connection or not self._adapter_path:
|
|
338
|
+
return
|
|
339
|
+
try:
|
|
340
|
+
proxy = Gio.DBusProxy.new_sync(
|
|
341
|
+
self._connection,
|
|
342
|
+
_FLAGS_METHOD,
|
|
343
|
+
None,
|
|
344
|
+
BLUEZ_SERVICE,
|
|
345
|
+
self._adapter_path,
|
|
346
|
+
ADAPTER_IFACE,
|
|
347
|
+
None,
|
|
348
|
+
)
|
|
349
|
+
proxy.call(
|
|
350
|
+
"StartDiscovery",
|
|
351
|
+
None,
|
|
352
|
+
Gio.DBusCallFlags.NONE,
|
|
353
|
+
-1,
|
|
354
|
+
None,
|
|
355
|
+
self._on_start_discovery_done,
|
|
356
|
+
None,
|
|
357
|
+
)
|
|
358
|
+
except Exception as exc:
|
|
359
|
+
print(f"[bluetooth] discovery start: {exc}")
|
|
360
|
+
|
|
361
|
+
def _on_start_discovery_done(self, proxy, result, _user_data):
|
|
362
|
+
try:
|
|
363
|
+
proxy.call_finish(result)
|
|
364
|
+
GLib.timeout_add_seconds(30, self._stop_discovery)
|
|
365
|
+
except Exception as exc:
|
|
366
|
+
print(f"[bluetooth] start discovery error: {exc}")
|
|
367
|
+
|
|
368
|
+
def _stop_discovery(self):
|
|
369
|
+
if not self._connection or not self._adapter_path:
|
|
370
|
+
return False
|
|
371
|
+
try:
|
|
372
|
+
proxy = Gio.DBusProxy.new_sync(
|
|
373
|
+
self._connection,
|
|
374
|
+
_FLAGS_METHOD,
|
|
375
|
+
None,
|
|
376
|
+
BLUEZ_SERVICE,
|
|
377
|
+
self._adapter_path,
|
|
378
|
+
ADAPTER_IFACE,
|
|
379
|
+
None,
|
|
380
|
+
)
|
|
381
|
+
proxy.call_sync("StopDiscovery", None, Gio.DBusCallFlags.NONE, -1, None)
|
|
382
|
+
except Exception:
|
|
383
|
+
pass
|
|
384
|
+
return False
|
|
@@ -582,7 +582,9 @@ class DevicePage(Gtk.Box):
|
|
|
582
582
|
return False
|
|
583
583
|
|
|
584
584
|
def _sync_anc_ui(self, active_mode: int):
|
|
585
|
+
supported = self._nothing_dev.state.supported_anc_modes if self._nothing_dev else None
|
|
585
586
|
for mode, btn in self._anc_buttons:
|
|
587
|
+
btn.set_visible(supported is None or mode in supported)
|
|
586
588
|
if mode == active_mode:
|
|
587
589
|
btn.add_css_class("active")
|
|
588
590
|
else:
|
|
@@ -123,6 +123,8 @@ class DeviceState:
|
|
|
123
123
|
serial_number: str = "—"
|
|
124
124
|
left_wearing: bool = False
|
|
125
125
|
right_wearing: bool = False
|
|
126
|
+
# None = not yet determined (show all); frozenset = confirmed supported modes
|
|
127
|
+
supported_anc_modes: frozenset | None = None
|
|
126
128
|
|
|
127
129
|
|
|
128
130
|
# ── Device class ─────────────────────────────────────────────────────────────
|
|
@@ -451,7 +453,15 @@ class NothingDevice(GObject.Object):
|
|
|
451
453
|
_log(f"[protocol] firmware={ver!r}")
|
|
452
454
|
changed = True
|
|
453
455
|
elif cmd_id == _CMD_REMOTE_CONF:
|
|
454
|
-
|
|
456
|
+
# Payload is newline-separated "device_id,field_id,value" entries.
|
|
457
|
+
# field 4 = serial number (e.g. SH10212543006451)
|
|
458
|
+
raw = payload.decode(errors="replace").strip("\x00")
|
|
459
|
+
sn = None
|
|
460
|
+
for line in raw.splitlines():
|
|
461
|
+
parts = line.split(",", 2)
|
|
462
|
+
if len(parts) == 3 and parts[1] == "4" and parts[2].strip():
|
|
463
|
+
sn = parts[2].strip()
|
|
464
|
+
break
|
|
455
465
|
if sn and sn != self.state.serial_number:
|
|
456
466
|
self.state.serial_number = sn
|
|
457
467
|
_log(f"[protocol] serial={sn!r}")
|
|
@@ -500,9 +510,12 @@ class NothingDevice(GObject.Object):
|
|
|
500
510
|
def _parse_anc(self, payload: bytes) -> bool:
|
|
501
511
|
# Payload: [type:1][value:1][pad:1] triplets
|
|
502
512
|
# type=1: NOISE_REDUCTION_MODE type=2: NOISE_REDUCTION_LEVEL (last active level)
|
|
513
|
+
# level val 1–4 = ANC strength → ANC is supported
|
|
514
|
+
# level val 0 or 0xFE = no ANC strength → only Off + Transparency
|
|
503
515
|
if len(payload) < 3:
|
|
504
516
|
return False
|
|
505
517
|
changed = False
|
|
518
|
+
level_val: int | None = None
|
|
506
519
|
for i in range(0, len(payload) - 2, 3):
|
|
507
520
|
t, val = payload[i], payload[i + 1]
|
|
508
521
|
if t == 1: # NOISE_REDUCTION_MODE
|
|
@@ -516,8 +529,25 @@ class NothingDevice(GObject.Object):
|
|
|
516
529
|
self.state.anc_mode = mode
|
|
517
530
|
_log(f"[protocol] ANC mode → {ANCMode.LABELS.get(mode, mode)} (wire val {val})")
|
|
518
531
|
changed = True
|
|
519
|
-
elif t == 2
|
|
520
|
-
|
|
532
|
+
elif t == 2: # NOISE_REDUCTION_LEVEL
|
|
533
|
+
level_val = val
|
|
534
|
+
if 1 <= val <= 4:
|
|
535
|
+
self._last_anc_level = val
|
|
536
|
+
|
|
537
|
+
# First time we see a level entry, lock in supported modes.
|
|
538
|
+
# val 1–4 = ANC strength present → all three modes are available.
|
|
539
|
+
# val 0 or 0xFE = no ANC strength → device only supports Off + Transparency.
|
|
540
|
+
if level_val is not None and self.state.supported_anc_modes is None:
|
|
541
|
+
if 1 <= level_val <= 4:
|
|
542
|
+
modes = frozenset([ANCMode.OFF, ANCMode.NOISE_CANCELLATION, ANCMode.TRANSPARENCY])
|
|
543
|
+
else:
|
|
544
|
+
modes = frozenset([ANCMode.OFF, ANCMode.TRANSPARENCY])
|
|
545
|
+
self.state.supported_anc_modes = modes
|
|
546
|
+
_log(
|
|
547
|
+
f"[protocol] supported ANC modes detected: {[ANCMode.LABELS.get(m, m) for m in sorted(modes)]}"
|
|
548
|
+
)
|
|
549
|
+
changed = True
|
|
550
|
+
|
|
521
551
|
return changed
|
|
522
552
|
|
|
523
553
|
def _parse_earphone_status(self, payload: bytes) -> bool:
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "something-x-dev"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.7.0.dev14"
|
|
8
8
|
description = "Something X device manager for Omarchy / Linux"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -27,7 +27,6 @@ classifiers = [
|
|
|
27
27
|
|
|
28
28
|
dependencies = [
|
|
29
29
|
"PyGObject>=3.42",
|
|
30
|
-
"dbus-python>=1.3",
|
|
31
30
|
]
|
|
32
31
|
|
|
33
32
|
[project.scripts]
|
|
@@ -41,7 +40,10 @@ include = ["nothing_app*"]
|
|
|
41
40
|
nothing_app = ["data/*.css", "data/*.desktop"]
|
|
42
41
|
|
|
43
42
|
[project.optional-dependencies]
|
|
44
|
-
dev = ["ruff"]
|
|
43
|
+
dev = ["ruff", "pytest", "pytest-cov"]
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
testpaths = ["tests"]
|
|
45
47
|
|
|
46
48
|
[tool.ruff]
|
|
47
49
|
line-length = 110
|
{something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14/something_x_dev.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: something-x-dev
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.7.0.dev14
|
|
4
4
|
Summary: Something X device manager for Omarchy / Linux
|
|
5
5
|
Author: Raphael
|
|
6
6
|
License: MIT
|
|
@@ -16,9 +16,10 @@ Requires-Python: >=3.11
|
|
|
16
16
|
Description-Content-Type: text/markdown
|
|
17
17
|
License-File: LICENSE
|
|
18
18
|
Requires-Dist: PyGObject>=3.42
|
|
19
|
-
Requires-Dist: dbus-python>=1.3
|
|
20
19
|
Provides-Extra: dev
|
|
21
20
|
Requires-Dist: ruff; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
22
23
|
Dynamic: license-file
|
|
23
24
|
|
|
24
25
|
<div align="center">
|
|
@@ -32,6 +33,8 @@ Built for [Omarchy](https://omarchy.org) · GTK4 · Pure black · JetBrains Mono
|
|
|
32
33
|
[](https://aur.archlinux.org/packages/something-x)
|
|
33
34
|
[](LICENSE)
|
|
34
35
|
[](https://github.com/SoaOaoS/something-x)
|
|
36
|
+
[](https://github.com/SoaOaoS/something-x/actions/workflows/ci.yml)
|
|
37
|
+
[](https://github.com/SoaOaoS/something-x/actions/workflows/ci.yml)
|
|
35
38
|
|
|
36
39
|
</div>
|
|
37
40
|
|
{something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/something_x_dev.egg-info/SOURCES.txt
RENAMED
|
@@ -20,4 +20,8 @@ something_x_dev.egg-info/SOURCES.txt
|
|
|
20
20
|
something_x_dev.egg-info/dependency_links.txt
|
|
21
21
|
something_x_dev.egg-info/entry_points.txt
|
|
22
22
|
something_x_dev.egg-info/requires.txt
|
|
23
|
-
something_x_dev.egg-info/top_level.txt
|
|
23
|
+
something_x_dev.egg-info/top_level.txt
|
|
24
|
+
tests/test_bluetooth.py
|
|
25
|
+
tests/test_crc.py
|
|
26
|
+
tests/test_profiles.py
|
|
27
|
+
tests/test_protocol.py
|