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.
Files changed (30) hide show
  1. {something_x_dev-1.6.0.dev13/something_x_dev.egg-info → something_x_dev-1.7.0.dev14}/PKG-INFO +5 -2
  2. {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/README.md +2 -0
  3. something_x_dev-1.7.0.dev14/nothing_app/bluetooth.py +384 -0
  4. {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/pyproject.toml +5 -3
  5. {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14/something_x_dev.egg-info}/PKG-INFO +5 -2
  6. {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/something_x_dev.egg-info/SOURCES.txt +5 -1
  7. {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/something_x_dev.egg-info/requires.txt +2 -1
  8. something_x_dev-1.7.0.dev14/tests/test_bluetooth.py +136 -0
  9. something_x_dev-1.7.0.dev14/tests/test_crc.py +48 -0
  10. something_x_dev-1.7.0.dev14/tests/test_profiles.py +90 -0
  11. something_x_dev-1.7.0.dev14/tests/test_protocol.py +273 -0
  12. something_x_dev-1.6.0.dev13/nothing_app/bluetooth.py +0 -246
  13. {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/LICENSE +0 -0
  14. {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/nothing_app/__init__.py +0 -0
  15. {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/nothing_app/application.py +0 -0
  16. {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/nothing_app/data/__init__.py +0 -0
  17. {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/nothing_app/data/com.something.x.omarchy.desktop +0 -0
  18. {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/nothing_app/data/style.css +0 -0
  19. {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/nothing_app/pages/__init__.py +0 -0
  20. {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/nothing_app/pages/device.py +0 -0
  21. {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/nothing_app/pages/home.py +0 -0
  22. {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/nothing_app/profiles.py +0 -0
  23. {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/nothing_app/protocol.py +0 -0
  24. {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/nothing_app/splash.py +0 -0
  25. {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/nothing_app/tray.py +0 -0
  26. {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/nothing_app/window.py +0 -0
  27. {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/setup.cfg +0 -0
  28. {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/something_x_dev.egg-info/dependency_links.txt +0 -0
  29. {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/something_x_dev.egg-info/entry_points.txt +0 -0
  30. {something_x_dev-1.6.0.dev13 → something_x_dev-1.7.0.dev14}/something_x_dev.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: something-x-dev
3
- Version: 1.6.0.dev13
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
  [![AUR](https://img.shields.io/aur/version/something-x?color=red)](https://aur.archlinux.org/packages/something-x)
33
34
  [![License: MIT](https://img.shields.io/badge/license-MIT-red.svg)](LICENSE)
34
35
  [![Platform](https://img.shields.io/badge/platform-Linux-lightgrey)](https://github.com/SoaOaoS/something-x)
36
+ [![CI](https://github.com/SoaOaoS/something-x/actions/workflows/ci.yml/badge.svg)](https://github.com/SoaOaoS/something-x/actions/workflows/ci.yml)
37
+ [![Coverage](https://img.shields.io/badge/coverage-tracked-red)](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
  [![AUR](https://img.shields.io/aur/version/something-x?color=red)](https://aur.archlinux.org/packages/something-x)
10
10
  [![License: MIT](https://img.shields.io/badge/license-MIT-red.svg)](LICENSE)
11
11
  [![Platform](https://img.shields.io/badge/platform-Linux-lightgrey)](https://github.com/SoaOaoS/something-x)
12
+ [![CI](https://github.com/SoaOaoS/something-x/actions/workflows/ci.yml/badge.svg)](https://github.com/SoaOaoS/something-x/actions/workflows/ci.yml)
13
+ [![Coverage](https://img.shields.io/badge/coverage-tracked-red)](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.6.0.dev13"
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: something-x-dev
3
- Version: 1.6.0.dev13
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
  [![AUR](https://img.shields.io/aur/version/something-x?color=red)](https://aur.archlinux.org/packages/something-x)
33
34
  [![License: MIT](https://img.shields.io/badge/license-MIT-red.svg)](LICENSE)
34
35
  [![Platform](https://img.shields.io/badge/platform-Linux-lightgrey)](https://github.com/SoaOaoS/something-x)
36
+ [![CI](https://github.com/SoaOaoS/something-x/actions/workflows/ci.yml/badge.svg)](https://github.com/SoaOaoS/something-x/actions/workflows/ci.yml)
37
+ [![Coverage](https://img.shields.io/badge/coverage-tracked-red)](https://github.com/SoaOaoS/something-x/actions/workflows/ci.yml)
35
38
 
36
39
  </div>
37
40
 
@@ -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
@@ -1,5 +1,6 @@
1
1
  PyGObject>=3.42
2
- dbus-python>=1.3
3
2
 
4
3
  [dev]
5
4
  ruff
5
+ pytest
6
+ pytest-cov
@@ -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