something-x-dev 1.2.3.dev1__py3-none-any.whl

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.
@@ -0,0 +1,650 @@
1
+ import os
2
+ import socket
3
+ import struct
4
+ import subprocess
5
+ import threading
6
+ import time
7
+ from dataclasses import dataclass
8
+ from gi.repository import GLib, GObject
9
+
10
+ _DEBUG = bool(os.getenv("SOMETHING_X_DEBUG"))
11
+ _QUIET = False
12
+
13
+
14
+ def _log(*args, **kwargs):
15
+ if not _QUIET:
16
+ print(*args, **kwargs)
17
+
18
+
19
+ # ── 0x55 protocol (from APK decompilation: Nothing Ear 3 / Donphan) ────────────
20
+ #
21
+ # Frame layout (both directions):
22
+ # [SOF:1=0x55][ctrl:2 LE][cmd:2 LE][len:2 LE][fsn:1][payload:len][crc:2 if ctrl&0x20]
23
+ #
24
+ # All outgoing frames use ctrl=0x0160 with CRC16-ARC appended.
25
+ # APK: sendDataNeedCrc()=true for all commands; device silently drops SETs if any
26
+ # non-CRC frames were sent in the session.
27
+ #
28
+ # TX: send raw 16-bit cmd ID as-is (bit 15 preserved).
29
+ # RX: device sends responses with bit 15 cleared; normalize: received_cmd | 0x8000.
30
+ #
31
+ # CRC covers: SOF + ctrl(2) + cmd(2) + len(2) + FSN + payload
32
+ # CRC16-IBM/ARC: init=0xFFFF, poly=0xA001 (reflected 0x8005)
33
+
34
+ _SOF = 0x55
35
+ _CTRL_HOST_CRC = 0x0160 # all outgoing frames: CRC + multiFrames + deviceType=1
36
+
37
+ # Query commands (0xC0xx) – app→device, response has bit15 cleared
38
+ _CMD_PROTO_VERSION = 0xC001 # activation handshake (isNeedActivate=true)
39
+ _CMD_REMOTE_CONF = 0xC006 # GET_REMOTE_CONFIGURATION — serial number (UTF-8 string)
40
+ _CMD_BATTERY = 0xC007
41
+ _CMD_EARPHONE = 0xC00A
42
+ _CMD_NOISE_RED = 0xC01E # get ANC state; payload [0x03] = request 3 entries
43
+ _CMD_EQ_MODE = 0xC01F
44
+ _CMD_HOST_VERSION = 0xC042 # GET_HOST_VERSION_DEVICE — firmware version (UTF-8 string)
45
+
46
+ # SET commands (0xF0xx) – app→device, ACK has bit15 cleared
47
+ _CMD_SET_ACTIVATED = 0xF001 # activation response; no payload
48
+ _CMD_SET_NOISE_RED = 0xF00F # payload: [0x01, anc_val, 0x00]
49
+ _CMD_SET_EQ = 0xF010 # payload: [eq_val]
50
+
51
+
52
+ def _crc16(data: bytes) -> int:
53
+ crc = 0xFFFF
54
+ for b in data:
55
+ crc ^= b & 0xFF
56
+ for _ in range(8):
57
+ crc = (crc >> 1) ^ 0xA001 if (crc & 1) else crc >> 1
58
+ return crc & 0xFFFF
59
+
60
+
61
+ # Device event notifications (0xE0xx) – device→app, bit15 always set
62
+ _EVT_BATTERY = 0xE001
63
+ _EVT_STATUS = 0xE002
64
+ _EVT_NOISE_RED = 0xE003
65
+
66
+ # Battery payload: [type:1][val:1] pairs
67
+ # type 2=left 3=right 4=case
68
+ # val: bit7=charging, bits[6:0]=percent
69
+ _BAT_LEFT = 2
70
+ _BAT_RIGHT = 3
71
+ _BAT_CASE = 4
72
+
73
+ # ANC wire values for SET_NOISE_RED payload byte [1] (type=1 = NOISE_REDUCTION_MODE triplet)
74
+ # These are MODE constants from DeviceNoiseReduction.java, NOT the VALUE constants.
75
+ # VALUE_NOISE_REDUCTION_CLOSE=0 and VALUE_PASS_THROUGH=0xFE are for type=2 (level) entries.
76
+ _ANC_OFF = 5 # MODE_NOISE_REDUCTION_CLOSE
77
+ _ANC_STRONG = 1 # MODE_NOISE_REDUCTION_STRONG (confirmed working)
78
+ _ANC_MEDIUM = 2 # MODE_NOISE_REDUCTION_MEDIUM
79
+ _ANC_WEAK = 3 # MODE_NOISE_REDUCTION_WEAK
80
+ _ANC_TRANSPARENCY = 7 # MODE_PASS_THROUGH
81
+
82
+ # ── Legacy 0x03/0x02 protocol (ch17 status-only stream) ──────────────────────
83
+ # Still used for battery parsing from the old status channel fallback.
84
+ _L_DEV_HDR = 0x03
85
+ _L_HOST_HDR = 0x02
86
+ _L_INIT = 0x01 # init handshake, echo back
87
+ _L_STATE = 0x02
88
+ _L_BATTERY = 0x03
89
+
90
+ # ── Channel probe priority ───────────────────────────────────────────────────
91
+ _PROBE_CHANNELS = [15, 17, 16, 18, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
92
+
93
+
94
+ # ── Public enumerations ──────────────────────────────────────────────────────
95
+
96
+
97
+ class ANCMode:
98
+ OFF = 0
99
+ NOISE_CANCELLATION = 1
100
+ TRANSPARENCY = 2
101
+ LABELS = {OFF: "Off", NOISE_CANCELLATION: "ANC", TRANSPARENCY: "Transparency"}
102
+
103
+
104
+ EQ_PRESETS = {
105
+ "Balanced": 0,
106
+ "More Bass": 1,
107
+ "More Treble": 2,
108
+ "Voice": 3,
109
+ }
110
+ EQ_PRESET_NAMES = {v: k for k, v in EQ_PRESETS.items()}
111
+
112
+
113
+ @dataclass
114
+ class DeviceState:
115
+ left_battery: int = -1
116
+ right_battery: int = -1
117
+ case_battery: int = -1
118
+ anc_mode: int = ANCMode.OFF
119
+ eq_preset: str = "Balanced"
120
+ in_ear_detection: bool = True
121
+ auto_pause: bool = True
122
+ firmware_version: str = "—"
123
+ serial_number: str = "—"
124
+ left_wearing: bool = False
125
+ right_wearing: bool = False
126
+
127
+
128
+ # ── Device class ─────────────────────────────────────────────────────────────
129
+
130
+
131
+ class NothingDevice(GObject.Object):
132
+ __gsignals__ = {
133
+ "state-changed": (GObject.SignalFlags.RUN_FIRST, None, ()),
134
+ "connected": (GObject.SignalFlags.RUN_FIRST, None, ()),
135
+ "disconnected": (GObject.SignalFlags.RUN_FIRST, None, ()),
136
+ }
137
+
138
+ def __init__(self, address: str):
139
+ super().__init__()
140
+ self.address = address
141
+ self.state = DeviceState()
142
+ self._sock: socket.socket | None = None
143
+ self._rfcomm_connected = False
144
+ self._fsn = 0
145
+ self._activated = False
146
+ self._anc_debounce_id: int | None = None
147
+ self._anc_pending_mode: int = ANCMode.OFF
148
+ self._last_anc_level: int = _ANC_STRONG
149
+ self._thread: threading.Thread | None = None
150
+ self._low_bat_notified: set[str] = set()
151
+
152
+ # ── Public API ────────────────────────────────────────────────────────────
153
+
154
+ def connect_rfcomm(self):
155
+ if self._thread and self._thread.is_alive():
156
+ return
157
+
158
+ def _run():
159
+ channels = self._discover_channels()
160
+ for ch in channels:
161
+ result = self._try_channel(ch)
162
+ if result is None:
163
+ continue
164
+ sock, initial = result
165
+ self._sock = sock
166
+ self._rfcomm_connected = True
167
+ _log(f"[protocol] using ch{ch}")
168
+ GLib.idle_add(self.emit, "connected")
169
+ # The probe already sent GET_PROTOCOL_VERSION; the response is
170
+ # in `initial` and will trigger activation + queries inside recv_loop.
171
+ self._recv_loop(initial)
172
+ return
173
+ _log(
174
+ f"[protocol] no responsive channel found for {self.address}\n"
175
+ "[protocol] tip: sudo usermod -aG bluetooth $USER and re-login"
176
+ )
177
+
178
+ self._thread = threading.Thread(target=_run, daemon=True)
179
+ self._thread.start()
180
+
181
+ def disconnect_rfcomm(self):
182
+ self._rfcomm_connected = False
183
+ if self._sock:
184
+ try:
185
+ self._sock.shutdown(socket.SHUT_RDWR)
186
+ except OSError:
187
+ pass
188
+ try:
189
+ self._sock.close()
190
+ except OSError:
191
+ pass
192
+ self._sock = None
193
+
194
+ @property
195
+ def rfcomm_connected(self) -> bool:
196
+ return self._rfcomm_connected
197
+
198
+ def set_anc_mode(self, mode: int):
199
+ self.state.anc_mode = mode
200
+ GLib.idle_add(self.emit, "state-changed")
201
+ if self._anc_debounce_id is not None:
202
+ GLib.source_remove(self._anc_debounce_id)
203
+ self._anc_pending_mode = mode
204
+ self._anc_debounce_id = GLib.timeout_add(300, self._do_set_anc)
205
+ from . import profiles
206
+
207
+ profiles.save(self.address, mode, self.state.eq_preset)
208
+
209
+ def _do_set_anc(self):
210
+ self._anc_debounce_id = None
211
+ if not self._activated:
212
+ return False
213
+ mode = self._anc_pending_mode
214
+ val = (
215
+ _ANC_TRANSPARENCY
216
+ if mode == ANCMode.TRANSPARENCY
217
+ else (_ANC_OFF if mode == ANCMode.OFF else _ANC_STRONG)
218
+ )
219
+ label = ANCMode.LABELS.get(mode, mode)
220
+ self._x55_send(_CMD_SET_NOISE_RED, bytes([0x01, val, 0x00]), label=f"ANC={label}")
221
+ return False
222
+
223
+ def set_eq_preset(self, preset: str):
224
+ self.state.eq_preset = preset
225
+ GLib.idle_add(self.emit, "state-changed")
226
+ if not self._activated:
227
+ return
228
+ eq_val = EQ_PRESETS.get(preset, 0)
229
+ self._x55_send(_CMD_SET_EQ, bytes([eq_val]), label=f"EQ={preset}")
230
+ from . import profiles
231
+
232
+ profiles.save(self.address, self.state.anc_mode, preset)
233
+
234
+ def set_in_ear_detection(self, enabled: bool):
235
+ self.state.in_ear_detection = enabled
236
+ GLib.idle_add(self.emit, "state-changed")
237
+
238
+ # ── Channel discovery ─────────────────────────────────────────────────────
239
+
240
+ def _discover_channels(self) -> list[int]:
241
+ try:
242
+ import bluetooth as pybluez # type: ignore
243
+
244
+ services = pybluez.find_service(address=self.address)
245
+ channels = [s["port"] for s in services if isinstance(s.get("port"), int)]
246
+ if channels:
247
+ _log(f"[protocol] PyBluez SDP channels: {channels}")
248
+ return _prioritise(channels)
249
+ except ImportError:
250
+ _log("[protocol] PyBluez not installed; falling back to channel probe")
251
+ except Exception as exc:
252
+ _log(f"[protocol] PyBluez SDP failed: {exc}")
253
+
254
+ try:
255
+ out = subprocess.run(
256
+ ["sdptool", "browse", self.address],
257
+ capture_output=True,
258
+ text=True,
259
+ timeout=10,
260
+ ).stdout
261
+ channels = []
262
+ for line in out.splitlines():
263
+ line = line.strip()
264
+ if line.startswith("Channel:"):
265
+ try:
266
+ channels.append(int(line.split(":")[1].strip()))
267
+ except ValueError:
268
+ pass
269
+ if channels:
270
+ _log(f"[protocol] sdptool channels: {channels}")
271
+ return _prioritise(channels)
272
+ _log("[protocol] sdptool returned no channels")
273
+ except FileNotFoundError:
274
+ _log("[protocol] sdptool not found; install bluez-utils or python-pybluez")
275
+ except Exception as exc:
276
+ _log(f"[protocol] sdptool failed: {exc}")
277
+
278
+ _log(f"[protocol] probing channels {_PROBE_CHANNELS}")
279
+ return _PROBE_CHANNELS
280
+
281
+ def _try_channel(self, ch: int) -> tuple[socket.socket, bytes] | None:
282
+ for attempt in range(2):
283
+ try:
284
+ sock = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_STREAM, socket.BTPROTO_RFCOMM)
285
+ sock.settimeout(5)
286
+ sock.connect((self.address, ch))
287
+ break
288
+ except OSError as exc:
289
+ import errno as _errno
290
+
291
+ if exc.errno == _errno.EBUSY and attempt == 0:
292
+ _log(f"[protocol] ch{ch}: busy — waiting 3s for stale connection to release")
293
+ sock.close()
294
+ time.sleep(3)
295
+ continue
296
+ _log(f"[protocol] ch{ch}: connect failed: {exc}")
297
+ return None
298
+ else:
299
+ return None
300
+
301
+ # Probe with GET_PROTOCOL_VERSION — use CRC to match official app (sendDataNeedCrc=true)
302
+ _probe_hdr = struct.pack("<BHHH", _SOF, _CTRL_HOST_CRC, _CMD_PROTO_VERSION, 0) + bytes([0x01])
303
+ probe_x55 = _probe_hdr + struct.pack("<H", _crc16(_probe_hdr))
304
+ probe_leg = bytes([_L_HOST_HDR, _L_BATTERY, 0x00, 0x00])
305
+ try:
306
+ sock.sendall(probe_x55 + probe_leg)
307
+ except OSError:
308
+ sock.close()
309
+ return None
310
+
311
+ sock.settimeout(2.0)
312
+ data = b""
313
+ deadline = time.monotonic() + 2.0
314
+ try:
315
+ while time.monotonic() < deadline:
316
+ chunk = sock.recv(256)
317
+ if not chunk:
318
+ break
319
+ data += chunk
320
+ if len(data) >= 4:
321
+ break
322
+ except TimeoutError:
323
+ pass
324
+
325
+ if not data:
326
+ _log(f"[protocol] ch{ch}: no response (skipping)")
327
+ sock.close()
328
+ return None
329
+
330
+ proto = (
331
+ "0x55" if data[0] == _SOF else ("0x03-legacy" if data[0] == _L_DEV_HDR else f"0x{data[0]:02x}")
332
+ )
333
+ _log(f"[protocol] ch{ch}: {proto} — {data.hex()}")
334
+ sock.settimeout(6)
335
+ return sock, data
336
+
337
+ # ── Receive loop ──────────────────────────────────────────────────────────
338
+
339
+ def _recv_loop(self, initial: bytes = b""):
340
+ buf = initial
341
+ if buf:
342
+ buf = self._process_buf(buf)
343
+ while self._rfcomm_connected and self._sock:
344
+ try:
345
+ chunk = self._sock.recv(256)
346
+ if not chunk:
347
+ break
348
+ if _DEBUG:
349
+ _log(f"[RX RAW] {chunk.hex()}")
350
+ buf += chunk
351
+ buf = self._process_buf(buf)
352
+ except TimeoutError:
353
+ continue
354
+ except OSError:
355
+ break
356
+ self._handle_disconnect()
357
+
358
+ def _process_buf(self, buf: bytes) -> bytes:
359
+ while buf:
360
+ if buf[0] == _SOF:
361
+ buf = self._process_x55(buf)
362
+ if not buf or buf[0] == _SOF:
363
+ continue
364
+ if buf and buf[0] == _L_DEV_HDR:
365
+ buf = self._process_legacy(buf)
366
+ if not buf or buf[0] == _L_DEV_HDR:
367
+ continue
368
+ if buf:
369
+ buf = buf[1:] # skip unknown byte
370
+ return buf
371
+
372
+ # ── 0x55 frame handling ───────────────────────────────────────────────────
373
+
374
+ def _x55_send(self, cmd_id: int, payload: bytes = b"", *, label: str = ""):
375
+ if not self._rfcomm_connected or not self._sock:
376
+ return
377
+ self._fsn = (self._fsn + 1) & 0xFF
378
+ header = struct.pack("<BHHH", _SOF, _CTRL_HOST_CRC, cmd_id, len(payload)) + bytes([self._fsn])
379
+ frame = header + payload + struct.pack("<H", _crc16(header + payload))
380
+ desc = f" ({label})" if label else f" {payload.hex()}" if payload else ""
381
+ _log(f"[TX] cmd=0x{cmd_id:04X}{desc}")
382
+ if _DEBUG:
383
+ _log(f"[TX RAW] {frame.hex()}")
384
+ try:
385
+ self._sock.sendall(frame)
386
+ except OSError as exc:
387
+ _log(f"[TX ERR] {exc}")
388
+ self._handle_disconnect()
389
+
390
+ def _process_x55(self, buf: bytes) -> bytes:
391
+ while buf and buf[0] == _SOF:
392
+ if len(buf) < 8:
393
+ break
394
+ _, ctrl, cmd_raw, length = struct.unpack_from("<BHHH", buf)
395
+ # ctrl bit 5 = CRC flag: device appends 2 CRC bytes after payload
396
+ crc_size = 2 if (ctrl & 0x20) else 0
397
+ total = 8 + length + crc_size
398
+ if len(buf) < total:
399
+ break
400
+ if crc_size:
401
+ rx_crc = struct.unpack_from("<H", buf, 8 + length)[0]
402
+ ok_crc = _crc16(buf[: 8 + length])
403
+ if rx_crc != ok_crc:
404
+ _log(f"[RX CRC ERR] got 0x{rx_crc:04X} expected 0x{ok_crc:04X}")
405
+ payload = buf[8 : 8 + length]
406
+ cmd_id = cmd_raw | 0x8000 # normalize response→request ID
407
+ self._dispatch_x55(cmd_id, payload)
408
+ buf = buf[total:]
409
+ return buf
410
+
411
+ def _dispatch_x55(self, cmd_id: int, payload: bytes):
412
+ changed = False
413
+ if cmd_id == _CMD_PROTO_VERSION:
414
+ ver = payload.decode(errors="replace").strip()
415
+ _log(f"[RX INFO] proto version={ver!r}")
416
+ self._x55_send(_CMD_SET_ACTIVATED)
417
+ GLib.timeout_add(3000, self._activation_fallback)
418
+ elif cmd_id == _CMD_SET_ACTIVATED:
419
+ _log(f"[RX INFO] activation ACK payload={payload.hex()}")
420
+ self._activated = True
421
+ from . import profiles
422
+
423
+ profiles.set_last_device(self.address)
424
+ # Always resend on real ACK — fallback may have sent queries before
425
+ # the device finished activating and silently dropped them.
426
+ self._x55_send(_CMD_BATTERY)
427
+ self._x55_send(_CMD_NOISE_RED, bytes([0x03]))
428
+ self._x55_send(_CMD_EARPHONE)
429
+ self._x55_send(_CMD_HOST_VERSION)
430
+ self._x55_send(_CMD_REMOTE_CONF)
431
+ self._restore_profile()
432
+ elif cmd_id in (_CMD_BATTERY, _EVT_BATTERY):
433
+ changed = self._parse_battery(payload)
434
+ elif cmd_id in (_CMD_NOISE_RED, _EVT_NOISE_RED):
435
+ changed = self._parse_anc(payload)
436
+ elif cmd_id in (_CMD_EARPHONE, _EVT_STATUS):
437
+ changed = self._parse_earphone_status(payload)
438
+ elif cmd_id == _CMD_HOST_VERSION:
439
+ ver = payload.decode(errors="replace").strip("\x00").strip()
440
+ if ver and ver != self.state.firmware_version:
441
+ self.state.firmware_version = ver
442
+ _log(f"[protocol] firmware={ver!r}")
443
+ changed = True
444
+ elif cmd_id == _CMD_REMOTE_CONF:
445
+ sn = payload.decode(errors="replace").strip("\x00").strip()
446
+ if sn and sn != self.state.serial_number:
447
+ self.state.serial_number = sn
448
+ _log(f"[protocol] serial={sn!r}")
449
+ changed = True
450
+ elif cmd_id == _CMD_SET_NOISE_RED:
451
+ _log(f"[RX INFO] ANC set ACK: {payload.hex()}")
452
+ elif cmd_id == _CMD_SET_EQ:
453
+ _log(f"[RX INFO] EQ set ACK: {payload.hex()}")
454
+ else:
455
+ _log(f"[RX ] cmd=0x{cmd_id:04X} payload={payload.hex()}")
456
+ if changed:
457
+ GLib.idle_add(self.emit, "state-changed")
458
+
459
+ def _parse_battery(self, payload: bytes) -> bool:
460
+ # payload: [count:1][type:1][val:1]... (DataExtKt.toPairs with leading count byte)
461
+ # val byte: bit7=charging, bits[6:0]=percent
462
+ if len(payload) < 3:
463
+ return False
464
+ count = payload[0]
465
+ changed = False
466
+ for i in range(1, 1 + count * 2, 2):
467
+ if i + 1 >= len(payload):
468
+ break
469
+ btype = payload[i]
470
+ bval = payload[i + 1]
471
+ pct = bval & 0x7F
472
+ if btype == _BAT_LEFT and pct != self.state.left_battery:
473
+ self.state.left_battery = pct
474
+ self._check_low_battery("left", pct, "Left earbud")
475
+ changed = True
476
+ elif btype == _BAT_RIGHT and pct != self.state.right_battery:
477
+ self.state.right_battery = pct
478
+ self._check_low_battery("right", pct, "Right earbud")
479
+ changed = True
480
+ elif btype == _BAT_CASE and pct != self.state.case_battery:
481
+ self.state.case_battery = pct
482
+ self._check_low_battery("case", pct, "Case")
483
+ changed = True
484
+ if changed:
485
+ _log(
486
+ f"[protocol] battery L={self.state.left_battery}% "
487
+ f"R={self.state.right_battery}% C={self.state.case_battery}%"
488
+ )
489
+ return changed
490
+
491
+ def _parse_anc(self, payload: bytes) -> bool:
492
+ # Payload: [type:1][value:1][pad:1] triplets
493
+ # type=1: NOISE_REDUCTION_MODE type=2: NOISE_REDUCTION_LEVEL (last active level)
494
+ if len(payload) < 3:
495
+ return False
496
+ changed = False
497
+ for i in range(0, len(payload) - 2, 3):
498
+ t, val = payload[i], payload[i + 1]
499
+ if t == 1: # NOISE_REDUCTION_MODE
500
+ if val == _ANC_TRANSPARENCY:
501
+ mode = ANCMode.TRANSPARENCY
502
+ elif val == _ANC_OFF or val == 0:
503
+ mode = ANCMode.OFF
504
+ else:
505
+ mode = ANCMode.NOISE_CANCELLATION
506
+ if mode != self.state.anc_mode:
507
+ self.state.anc_mode = mode
508
+ _log(f"[protocol] ANC mode → {ANCMode.LABELS.get(mode, mode)} (wire val {val})")
509
+ changed = True
510
+ elif t == 2 and 1 <= val <= 4: # NOISE_REDUCTION_LEVEL (ANC strength 1–4)
511
+ self._last_anc_level = val
512
+ return changed
513
+
514
+ def _parse_earphone_status(self, payload: bytes) -> bool:
515
+ # payload: [count:1][type:1][val:1]...
516
+ # EarphoneStatus.java: bit2=inEar, bit7=isConnect, bit0=inCase/caseOpen
517
+ # type: 2=left, 3=right, 4=case
518
+ if len(payload) < 3:
519
+ return False
520
+ count = payload[0]
521
+ changed = False
522
+ for i in range(1, 1 + count * 2, 2):
523
+ if i + 1 >= len(payload):
524
+ break
525
+ etype = payload[i]
526
+ val = payload[i + 1]
527
+ in_ear = bool(val & 0x04)
528
+ connected = bool(val & 0x80)
529
+ wearing = in_ear and connected
530
+ if etype == 2 and wearing != self.state.left_wearing:
531
+ self.state.left_wearing = wearing
532
+ changed = True
533
+ elif etype == 3 and wearing != self.state.right_wearing:
534
+ self.state.right_wearing = wearing
535
+ changed = True
536
+ if changed:
537
+ _log(f"[protocol] wearing L={self.state.left_wearing} R={self.state.right_wearing}")
538
+ return changed
539
+
540
+ # ── Legacy 0x03 frame handling (status-only fallback) ────────────────────
541
+
542
+ def _process_legacy(self, buf: bytes) -> bytes:
543
+ while buf and buf[0] == _L_DEV_HDR:
544
+ if len(buf) < 4:
545
+ break
546
+ msg_type = buf[1]
547
+ length = struct.unpack(">H", buf[2:4])[0]
548
+ if len(buf) < 4 + length:
549
+ break
550
+ self._dispatch_legacy(msg_type, buf[4 : 4 + length])
551
+ buf = buf[4 + length :]
552
+ return buf
553
+
554
+ def _dispatch_legacy(self, msg_type: int, payload: bytes):
555
+ if msg_type == _L_BATTERY and len(payload) >= 2:
556
+ self.state.left_battery = payload[0] if payload[0] <= 100 else -1
557
+ self.state.right_battery = payload[1] if payload[1] <= 100 else -1
558
+ self.state.case_battery = payload[2] if len(payload) >= 3 and payload[2] <= 100 else -1
559
+ _log(
560
+ f"[protocol] legacy battery L={self.state.left_battery}% "
561
+ f"R={self.state.right_battery}% C={self.state.case_battery}%"
562
+ )
563
+ self._check_low_battery("left", self.state.left_battery, "Left earbud")
564
+ self._check_low_battery("right", self.state.right_battery, "Right earbud")
565
+ self._check_low_battery("case", self.state.case_battery, "Case")
566
+ GLib.idle_add(self.emit, "state-changed")
567
+ elif msg_type == _L_INIT and payload:
568
+ _log(f"[protocol] legacy init: {payload.hex()} — echoing back")
569
+ # Echo init to complete handshake
570
+ frame = bytes([_L_HOST_HDR, _L_INIT]) + struct.pack(">H", len(payload)) + payload
571
+ try:
572
+ if self._sock:
573
+ self._sock.sendall(frame)
574
+ except OSError:
575
+ pass
576
+ elif msg_type == _L_STATE and payload:
577
+ _log(f"[protocol] legacy state: {payload.hex()}")
578
+ else:
579
+ _log(f"[protocol] legacy type=0x{msg_type:02x} payload={payload.hex()}")
580
+
581
+ # ── Utilities ─────────────────────────────────────────────────────────────
582
+
583
+ def _restore_profile(self):
584
+ from . import profiles
585
+
586
+ p = profiles.load(self.address)
587
+ if not p:
588
+ return
589
+ if "anc" in p:
590
+ anc = p["anc"]
591
+ wire = (
592
+ _ANC_TRANSPARENCY
593
+ if anc == ANCMode.TRANSPARENCY
594
+ else _ANC_OFF
595
+ if anc == ANCMode.OFF
596
+ else _ANC_STRONG
597
+ )
598
+ self._x55_send(
599
+ _CMD_SET_NOISE_RED, bytes([0x01, wire, 0x00]), label=f"restore ANC={ANCMode.LABELS.get(anc)}"
600
+ )
601
+ if "eq" in p:
602
+ eq_val = EQ_PRESETS.get(p["eq"], 0)
603
+ self._x55_send(_CMD_SET_EQ, bytes([eq_val]), label=f"restore EQ={p['eq']}")
604
+
605
+ def _check_low_battery(self, slot: str, pct: int, label: str):
606
+ if pct < 0:
607
+ return
608
+ if pct <= 20 and slot not in self._low_bat_notified:
609
+ self._low_bat_notified.add(slot)
610
+ threading.Thread(
611
+ target=subprocess.run,
612
+ args=(
613
+ [
614
+ "notify-send",
615
+ "-u",
616
+ "critical",
617
+ "-i",
618
+ "battery-caution",
619
+ "Something X",
620
+ f"{label}: {pct}% battery remaining",
621
+ ],
622
+ ),
623
+ kwargs={"capture_output": True},
624
+ daemon=True,
625
+ ).start()
626
+ elif pct > 25:
627
+ self._low_bat_notified.discard(slot)
628
+
629
+ def _activation_fallback(self):
630
+ if not self._activated and self._rfcomm_connected:
631
+ _log("[protocol] activation ACK not received within 3s — sending GET queries")
632
+ self._activated = True
633
+ self._x55_send(_CMD_BATTERY)
634
+ self._x55_send(_CMD_NOISE_RED, bytes([0x03]))
635
+ self._x55_send(_CMD_EARPHONE)
636
+ self._x55_send(_CMD_HOST_VERSION)
637
+ self._x55_send(_CMD_REMOTE_CONF)
638
+ return False
639
+
640
+ def _handle_disconnect(self):
641
+ self._rfcomm_connected = False
642
+ self._sock = None
643
+ self._activated = False
644
+ GLib.idle_add(self.emit, "disconnected")
645
+
646
+
647
+ def _prioritise(channels: list[int]) -> list[int]:
648
+ priority = [c for c in [15, 17, 16] if c in channels]
649
+ rest = [c for c in channels if c not in priority]
650
+ return priority + rest