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.
- nothing_app/__init__.py +2 -0
- nothing_app/application.py +244 -0
- nothing_app/bluetooth.py +212 -0
- nothing_app/data/__init__.py +0 -0
- nothing_app/data/com.something.x.omarchy.desktop +13 -0
- nothing_app/data/style.css +530 -0
- nothing_app/pages/__init__.py +0 -0
- nothing_app/pages/device.py +599 -0
- nothing_app/pages/home.py +210 -0
- nothing_app/profiles.py +41 -0
- nothing_app/protocol.py +650 -0
- nothing_app/splash.py +181 -0
- nothing_app/window.py +89 -0
- something_x_dev-1.2.3.dev1.dist-info/METADATA +201 -0
- something_x_dev-1.2.3.dev1.dist-info/RECORD +19 -0
- something_x_dev-1.2.3.dev1.dist-info/WHEEL +5 -0
- something_x_dev-1.2.3.dev1.dist-info/entry_points.txt +2 -0
- something_x_dev-1.2.3.dev1.dist-info/licenses/LICENSE +21 -0
- something_x_dev-1.2.3.dev1.dist-info/top_level.txt +1 -0
nothing_app/protocol.py
ADDED
|
@@ -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
|