something-x-dev 1.6.0.dev13__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.6.0.dev13/something_x_dev.egg-info → something_x_dev-1.7.0.dev14}/PKG-INFO +5 -2
- {something_x_dev-1.6.0.dev13 → 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.6.0.dev13 → something_x_dev-1.7.0.dev14}/pyproject.toml +5 -3
- {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14/something_x_dev.egg-info}/PKG-INFO +5 -2
- {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/something_x_dev.egg-info/SOURCES.txt +5 -1
- {something_x_dev-1.6.0.dev13 → 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.6.0.dev13/nothing_app/bluetooth.py +0 -246
- {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/LICENSE +0 -0
- {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/nothing_app/__init__.py +0 -0
- {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/nothing_app/application.py +0 -0
- {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/nothing_app/data/__init__.py +0 -0
- {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/nothing_app/data/com.something.x.omarchy.desktop +0 -0
- {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/nothing_app/data/style.css +0 -0
- {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/nothing_app/pages/__init__.py +0 -0
- {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/nothing_app/pages/device.py +0 -0
- {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/nothing_app/pages/home.py +0 -0
- {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/nothing_app/profiles.py +0 -0
- {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/nothing_app/protocol.py +0 -0
- {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/nothing_app/splash.py +0 -0
- {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/nothing_app/tray.py +0 -0
- {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/nothing_app/window.py +0 -0
- {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/setup.cfg +0 -0
- {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/something_x_dev.egg-info/dependency_links.txt +0 -0
- {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/something_x_dev.egg-info/entry_points.txt +0 -0
- {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/something_x_dev.egg-info/top_level.txt +0 -0
{something_x_dev-1.6.0.dev13/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
|
|
@@ -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.6.0.dev13 → 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.6.0.dev13 → 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
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Tests for BluetoothDevice data class and device_icon_name helper."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from nothing_app.bluetooth import BluetoothDevice, device_icon_name
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def make_device(name="Nothing Ear (2)", icon="audio-headphones", connected=False):
|
|
9
|
+
return BluetoothDevice(
|
|
10
|
+
"/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
|
|
11
|
+
{
|
|
12
|
+
"Address": "AA:BB:CC:DD:EE:FF",
|
|
13
|
+
"Name": name,
|
|
14
|
+
"Icon": icon,
|
|
15
|
+
"Connected": connected,
|
|
16
|
+
"Paired": True,
|
|
17
|
+
},
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ── BluetoothDevice construction ──────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_address_stored():
|
|
25
|
+
dev = make_device()
|
|
26
|
+
assert dev.address == "AA:BB:CC:DD:EE:FF"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_name_stored():
|
|
30
|
+
dev = make_device(name="CMF Buds Pro 2")
|
|
31
|
+
assert dev.name == "CMF Buds Pro 2"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_connected_flag():
|
|
35
|
+
assert make_device(connected=True).connected is True
|
|
36
|
+
assert make_device(connected=False).connected is False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_is_nothing_true_for_known_patterns():
|
|
40
|
+
for name in [
|
|
41
|
+
"Nothing Ear (2)",
|
|
42
|
+
"Nothing Ear (1)",
|
|
43
|
+
"Nothing Ear (A)",
|
|
44
|
+
"Ear (Stick)",
|
|
45
|
+
"CMF Buds Pro 2",
|
|
46
|
+
"CMF Earphone 1",
|
|
47
|
+
"Nothing Phone (2)",
|
|
48
|
+
]:
|
|
49
|
+
assert make_device(name=name).is_nothing is True, f"{name!r} should be recognised"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_is_nothing_false_for_unknown():
|
|
53
|
+
for name in ["AirPods Pro", "Galaxy Buds2", "Sony WH-1000XM5", "Jabra Elite 7"]:
|
|
54
|
+
assert make_device(name=name).is_nothing is False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_battery_default_none():
|
|
58
|
+
assert make_device().battery is None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_repr_connected():
|
|
62
|
+
dev = make_device(connected=True)
|
|
63
|
+
assert "●" in repr(dev)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_repr_disconnected():
|
|
67
|
+
dev = make_device(connected=False)
|
|
68
|
+
assert "○" in repr(dev)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ── BluetoothDevice.update ────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_update_connected():
|
|
75
|
+
dev = make_device(connected=False)
|
|
76
|
+
dev.update({"Connected": True})
|
|
77
|
+
assert dev.connected is True
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_update_name():
|
|
81
|
+
dev = make_device(name="Old Name")
|
|
82
|
+
dev.update({"Name": "New Name"})
|
|
83
|
+
assert dev.name == "New Name"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_update_ignores_unknown_keys():
|
|
87
|
+
dev = make_device()
|
|
88
|
+
dev.update({"UnknownKey": "value"}) # should not raise
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_update_alias_only_when_name_empty():
|
|
92
|
+
dev = BluetoothDevice("/path", {"Address": "AA:BB:CC:DD:EE:FF", "Name": "", "Alias": "My Device"})
|
|
93
|
+
dev.update({"Alias": "Better Name"})
|
|
94
|
+
assert dev.name == "Better Name"
|
|
95
|
+
|
|
96
|
+
dev2 = make_device(name="Existing")
|
|
97
|
+
dev2.update({"Alias": "Should Not Replace"})
|
|
98
|
+
assert dev2.name == "Existing"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ── device_icon_name ──────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_none_device_returns_default():
|
|
105
|
+
assert device_icon_name(None) == "audio-headphones-symbolic"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_headphones_icon_mapped():
|
|
109
|
+
dev = make_device(icon="audio-headphones")
|
|
110
|
+
assert device_icon_name(dev) == "audio-headphones-symbolic"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_headset_icon_mapped():
|
|
114
|
+
dev = make_device(icon="audio-headset")
|
|
115
|
+
assert device_icon_name(dev) == "audio-headset-symbolic"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_watch_name_returns_alarm():
|
|
119
|
+
for name in ["Galaxy Watch 5", "Amazfit GTR", "Fenix 7"]:
|
|
120
|
+
dev = make_device(name=name)
|
|
121
|
+
assert device_icon_name(dev) == "alarm-symbolic", f"expected alarm for {name!r}"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_ear_stick_returns_microphone():
|
|
125
|
+
dev = make_device(name="Nothing Ear (Stick)")
|
|
126
|
+
assert device_icon_name(dev) == "audio-input-microphone-symbolic"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_phone_name_returns_phone():
|
|
130
|
+
dev = make_device(name="Nothing Phone (2)", icon="unknown-icon")
|
|
131
|
+
assert device_icon_name(dev) == "phone-symbolic"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_unknown_icon_falls_back_to_headphones():
|
|
135
|
+
dev = make_device(name="Random Gadget", icon="unknown-xyz")
|
|
136
|
+
assert device_icon_name(dev) == "audio-headphones-symbolic"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import struct
|
|
2
|
+
|
|
3
|
+
from nothing_app.protocol import _CMD_PROTO_VERSION, _CTRL_HOST_CRC, _SOF, _crc16
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_returns_uint16():
|
|
7
|
+
for data in [b"", b"\x00", b"\xff", b"\x55" * 8, b"hello world"]:
|
|
8
|
+
result = _crc16(data)
|
|
9
|
+
assert isinstance(result, int)
|
|
10
|
+
assert 0 <= result <= 0xFFFF
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_deterministic():
|
|
14
|
+
data = b"\x55\x60\x01\x01\xc0\x00\x00\x01"
|
|
15
|
+
assert _crc16(data) == _crc16(data)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_empty_is_init_value():
|
|
19
|
+
# With no bytes processed the accumulator stays at the init value 0xFFFF.
|
|
20
|
+
assert _crc16(b"") == 0xFFFF
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_single_bit_flip_detected():
|
|
24
|
+
data = b"\x55\x60\x01\x01\xc0\x00\x00\x01\x42\xab"
|
|
25
|
+
flipped = bytes([data[0] ^ 0x01]) + data[1:]
|
|
26
|
+
assert _crc16(data) != _crc16(flipped)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_different_inputs_differ():
|
|
30
|
+
assert _crc16(b"\x00\x00") != _crc16(b"\x00\x01")
|
|
31
|
+
assert _crc16(b"\x55\x60\x01") != _crc16(b"\x55\x60\x02")
|
|
32
|
+
assert _crc16(b"\xaa") != _crc16(b"\xab")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_probe_frame_crc_is_stable():
|
|
36
|
+
# The probe frame sent during channel discovery must always produce the same CRC.
|
|
37
|
+
header = struct.pack("<BHHH", _SOF, _CTRL_HOST_CRC, _CMD_PROTO_VERSION, 0) + bytes([0x01])
|
|
38
|
+
crc1 = _crc16(header)
|
|
39
|
+
crc2 = _crc16(header)
|
|
40
|
+
assert crc1 == crc2
|
|
41
|
+
assert 0 <= crc1 <= 0xFFFF
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_payload_changes_crc():
|
|
45
|
+
header = struct.pack("<BHHH", _SOF, _CTRL_HOST_CRC, _CMD_PROTO_VERSION, 3) + bytes([0x01])
|
|
46
|
+
crc_a = _crc16(header + b"\x01\x02\x03")
|
|
47
|
+
crc_b = _crc16(header + b"\x01\x02\x04")
|
|
48
|
+
assert crc_a != crc_b
|