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.
Files changed (30) hide show
  1. {something_x_dev-1.5.0.dev12/something_x_dev.egg-info → something_x_dev-1.7.0.dev14}/PKG-INFO +5 -2
  2. {something_x_dev-1.5.0.dev12 → 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.5.0.dev12 → something_x_dev-1.7.0.dev14}/nothing_app/pages/device.py +2 -0
  5. {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/nothing_app/protocol.py +33 -3
  6. {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/pyproject.toml +5 -3
  7. {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14/something_x_dev.egg-info}/PKG-INFO +5 -2
  8. {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/something_x_dev.egg-info/SOURCES.txt +5 -1
  9. {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/something_x_dev.egg-info/requires.txt +2 -1
  10. something_x_dev-1.7.0.dev14/tests/test_bluetooth.py +136 -0
  11. something_x_dev-1.7.0.dev14/tests/test_crc.py +48 -0
  12. something_x_dev-1.7.0.dev14/tests/test_profiles.py +90 -0
  13. something_x_dev-1.7.0.dev14/tests/test_protocol.py +273 -0
  14. something_x_dev-1.5.0.dev12/nothing_app/bluetooth.py +0 -246
  15. {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/LICENSE +0 -0
  16. {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/nothing_app/__init__.py +0 -0
  17. {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/nothing_app/application.py +0 -0
  18. {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/nothing_app/data/__init__.py +0 -0
  19. {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/nothing_app/data/com.something.x.omarchy.desktop +0 -0
  20. {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/nothing_app/data/style.css +0 -0
  21. {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/nothing_app/pages/__init__.py +0 -0
  22. {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/nothing_app/pages/home.py +0 -0
  23. {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/nothing_app/profiles.py +0 -0
  24. {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/nothing_app/splash.py +0 -0
  25. {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/nothing_app/tray.py +0 -0
  26. {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/nothing_app/window.py +0 -0
  27. {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/setup.cfg +0 -0
  28. {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/something_x_dev.egg-info/dependency_links.txt +0 -0
  29. {something_x_dev-1.5.0.dev12 → something_x_dev-1.7.0.dev14}/something_x_dev.egg-info/entry_points.txt +0 -0
  30. {something_x_dev-1.5.0.dev12 → 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.5.0.dev12
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
@@ -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
- sn = payload.decode(errors="replace").strip("\x00").strip()
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 and 1 <= val <= 4: # NOISE_REDUCTION_LEVEL (ANC strength 1–4)
520
- self._last_anc_level = val
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.5.0.dev12"
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.5.0.dev12
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