Skip to content

Commit 50cf7ce

Browse files
committed
Crack the frame checksum; generate frames programmatically
Recovered the protocol's checksum from Gadgetbridge: CRC-16/MODBUS (poly 0xA001, init 0xFFFF, reflected, little-endian) over the whole frame minus the trailing two CRC bytes. The frame format is 0x55 | control | command | length | fsn | payload | crc. Verified by reproducing three independent known-good frames byte-for-byte (ANC Off, Transparency, status query). - protocol.py: add crc16ansi() + build_frame(); generate ANC, EQ and query frames from semantic fields instead of hardcoded hex (some of the previously hardcoded CRCs were transcription-corrupted). - Controller now uses a frame sequence number per command. - docs/PROTOCOL.md: document the solved frame format and checksum. https://claude.ai/code/session_014KrpftzUgVFGFXVUU3DUTT
1 parent 1b2d677 commit 50cf7ce

2 files changed

Lines changed: 114 additions & 38 deletions

File tree

docs/PROTOCOL.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,32 @@ ANC frames below are verified to work on a real Nothing Ear (3).**
2222
| Socket (Linux) | `socket(AF_BLUETOOTH, SOCK_STREAM, BTPROTO_RFCOMM)``connect((mac, 15))` |
2323
| Battery | available directly via BlueZ/`upower` (no protocol needed) — already implemented |
2424

25+
### Frame format and checksum (solved)
26+
27+
```
28+
0x55 | control(2, LE) | command(2, LE) | length(2, LE) | fsn(1) | payload | crc(2, LE)
29+
```
30+
31+
- `control` `0x0160` sets the increment-counter and CRC-present flags.
32+
- `fsn` is a frame sequence number (this is the byte that "randomly" varied
33+
across captured frames — it is not data).
34+
- `crc` is **CRC-16/MODBUS** (poly `0xA001`, init `0xFFFF`, reflected) over the
35+
whole frame except the trailing two CRC bytes. Verified: it reproduces three
36+
independent known-good frames byte-for-byte (ANC Off, ANC Transparency, ANC
37+
status query), and the MODBUS residue over a full frame is `0`.
38+
39+
This is implemented as `crc16ansi()` + `build_frame()` in
40+
[`../src/nothing_ear/bluetooth/protocol.py`](../src/nothing_ear/bluetooth/protocol.py),
41+
so frames are now generated programmatically instead of hardcoded.
42+
43+
Source of the algorithm: Gadgetbridge `CheckSums.getCRC16ansi` and
44+
`NothingProtocol`. Note: the real Ear (3) appears to **not strictly validate**
45+
the CRC (frames with a wrong CRC still took effect), but we compute it correctly
46+
anyway for compatibility.
47+
48+
Commands: `0xf00f` = ANC set, `0xc01e` = ANC status query, `0x4006` = EQ preset
49+
(EQ command/payload is a reconstruction, pending hardware confirmation).
50+
2551
### Known ANC frames (13-byte frames)
2652

2753
Structure: `55 60 01 0f f0 03 00 <a> 01 <mode> 00 <crc16>`
Lines changed: 88 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,34 @@
11
"""Nothing control protocol over Bluetooth Classic RFCOMM.
22
3-
The command frames were reverse-engineered by the community and verified to
4-
work on the Nothing Ear (3).
5-
6-
Protocol summary:
7-
- Transport: Bluetooth Classic, RFCOMM (SPP), channel 15.
8-
- Every frame starts with the preamble byte 0x55.
9-
- ANC "set" frames are 13 bytes; byte index 9 selects the mode
10-
(01=High, 02=Mid, 03=Low, 04=Adaptive, 05=Off). The trailing two bytes are
11-
a CRC-16, so we ship full precomputed frames.
12-
13-
Sources: bharadwaj-raju.github.io/posts/nothing-ear-2-on-linux,
14-
LuanAdemi/nothing-ear-controller, OPIYOdev/Nothing-Ear-Linux, DaanHessen/earctl.
3+
Frame format (verified against Gadgetbridge and real Ear (3) frames):
4+
5+
0x55 | control(2, LE) | command(2, LE) | length(2, LE) | fsn(1) | payload | crc(2, LE)
6+
7+
- control 0x0160 sets the "increment counter" and "CRC present" flags.
8+
- fsn is a frame sequence number.
9+
- crc is CRC-16/MODBUS (poly 0xA001, init 0xFFFF, reflected) over the whole
10+
frame except the trailing two CRC bytes.
11+
12+
Transport: Bluetooth Classic, RFCOMM (SPP), channel 15.
13+
14+
Sources: Freeyourgadget/Gadgetbridge (NothingProtocol, CheckSums.getCRC16ansi),
15+
plus frames verified on real Ear (3) hardware.
1516
"""
1617

1718
from __future__ import annotations
1819

1920
import socket
21+
import struct
2022
import time
2123
from enum import Enum
2224

2325
RFCOMM_CHANNEL = 15
2426
PACKET_PREAMBLE = 0x55
27+
DEFAULT_CONTROL = 0x0160
28+
29+
CMD_ANC = 0xF00F
30+
CMD_ANC_STATUS = 0xC01E
31+
CMD_EQ = 0x4006
2532

2633

2734
class ANCMode(Enum):
@@ -40,33 +47,64 @@ class EQPreset(Enum):
4047
VOICE = "voice"
4148

4249

43-
ANC_COMMANDS: dict[ANCMode, str] = {
44-
ANCMode.HIGH: "5560010ff00300cf010100e66f",
45-
ANCMode.MID: "5560010ff00300d5010200e69f",
46-
ANCMode.LOW: "5560010ff00300d7010300e70f",
47-
ANCMode.ADAPTIVE: "5560010ff00300dd010400e53f",
48-
ANCMode.OFF: "5560010ff00300cd010500c447",
49-
ANCMode.TRANSPARENCY: "5560010ff00300cb010700c5af",
50+
_ANC_VALUE = {
51+
ANCMode.HIGH: 0x01,
52+
ANCMode.MID: 0x02,
53+
ANCMode.LOW: 0x03,
54+
ANCMode.ADAPTIVE: 0x04,
55+
ANCMode.OFF: 0x05,
56+
ANCMode.TRANSPARENCY: 0x07,
5057
}
58+
_ANC_BY_VALUE = {v: k for k, v in _ANC_VALUE.items()}
5159

52-
EQ_COMMANDS: dict[EQPreset, str] = {
53-
EQPreset.BALANCED: "55600106400000060101009f3c",
54-
EQPreset.MORE_BASS: "55600106400000060101019e8c",
55-
EQPreset.MORE_TREBLE: "55600106400000060101029e1c",
56-
EQPreset.VOICE: "55600106400000060101039eac",
60+
_EQ_VALUE = {
61+
EQPreset.BALANCED: 0x00,
62+
EQPreset.MORE_BASS: 0x01,
63+
EQPreset.MORE_TREBLE: 0x02,
64+
EQPreset.VOICE: 0x03,
5765
}
5866

59-
QUERY_ANC = "5560011ec001000c039819"
60-
6167
ANC_RESPONSE_MODE_INDEX = 9
62-
_MODE_BY_VALUE = {
63-
1: ANCMode.HIGH,
64-
2: ANCMode.MID,
65-
3: ANCMode.LOW,
66-
4: ANCMode.ADAPTIVE,
67-
5: ANCMode.OFF,
68-
7: ANCMode.TRANSPARENCY,
69-
}
68+
69+
70+
def crc16ansi(data: bytes) -> int:
71+
"""CRC-16/MODBUS (poly 0xA001, init 0xFFFF, reflected)."""
72+
crc = 0xFFFF
73+
for byte in data:
74+
crc ^= byte & 0xFF
75+
for _ in range(8):
76+
crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
77+
return crc & 0xFFFF
78+
79+
80+
def build_frame(command: int, payload: bytes, fsn: int = 0, control: int = DEFAULT_CONTROL) -> bytes:
81+
"""Assemble a full control frame including the trailing CRC."""
82+
body = (
83+
bytes([PACKET_PREAMBLE])
84+
+ struct.pack("<H", control)
85+
+ struct.pack("<H", command)
86+
+ struct.pack("<H", len(payload))
87+
+ bytes([fsn & 0xFF])
88+
+ payload
89+
)
90+
return body + struct.pack("<H", crc16ansi(body))
91+
92+
93+
def anc_frame(mode: ANCMode, fsn: int = 0) -> bytes:
94+
return build_frame(CMD_ANC, bytes([0x01, _ANC_VALUE[mode], 0x00]), fsn)
95+
96+
97+
def eq_preset_frame(preset: EQPreset, fsn: int = 0) -> bytes:
98+
return build_frame(CMD_EQ, bytes([0x01, 0x01, _EQ_VALUE[preset]]), fsn)
99+
100+
101+
def query_anc_frame(fsn: int = 0) -> bytes:
102+
return build_frame(CMD_ANC_STATUS, bytes([0x03]), fsn)
103+
104+
105+
ANC_COMMANDS = {mode: anc_frame(mode).hex() for mode in ANCMode}
106+
EQ_COMMANDS = {preset: eq_preset_frame(preset).hex() for preset in EQPreset}
107+
QUERY_ANC = query_anc_frame().hex()
70108

71109

72110
class NothingController:
@@ -82,6 +120,7 @@ def __init__(self, address: str, channel: int = RFCOMM_CHANNEL):
82120
self.address = address
83121
self.channel = channel
84122
self._sock: socket.socket | None = None
123+
self._fsn = 0
85124

86125
def connect(self) -> None:
87126
sock = socket.socket(
@@ -104,23 +143,34 @@ def __enter__(self) -> "NothingController":
104143
def __exit__(self, *exc) -> None:
105144
self.close()
106145

146+
def _next_fsn(self) -> int:
147+
fsn = self._fsn
148+
self._fsn = (self._fsn + 1) & 0xFF
149+
return fsn
150+
107151
def send_raw(self, hex_frame: str) -> None:
108152
if self._sock is None:
109153
raise RuntimeError("Not connected. Call connect() first.")
110154
self._sock.sendall(bytes.fromhex(hex_frame))
111155
time.sleep(0.3)
112156

157+
def send_frame(self, frame: bytes) -> None:
158+
if self._sock is None:
159+
raise RuntimeError("Not connected. Call connect() first.")
160+
self._sock.sendall(frame)
161+
time.sleep(0.3)
162+
113163
def set_anc(self, mode: ANCMode) -> None:
114-
self.send_raw(ANC_COMMANDS[mode])
164+
self.send_frame(anc_frame(mode, self._next_fsn()))
115165

116166
def set_eq(self, preset: EQPreset) -> None:
117-
self.send_raw(EQ_COMMANDS[preset])
167+
self.send_frame(eq_preset_frame(preset, self._next_fsn()))
118168

119169
def query_anc(self) -> ANCMode | None:
120170
if self._sock is None:
121171
raise RuntimeError("Not connected. Call connect() first.")
122-
self._sock.sendall(bytes.fromhex(QUERY_ANC))
172+
self._sock.sendall(query_anc_frame(self._next_fsn()))
123173
resp = self._sock.recv(64)
124174
if len(resp) > ANC_RESPONSE_MODE_INDEX:
125-
return _MODE_BY_VALUE.get(resp[ANC_RESPONSE_MODE_INDEX])
175+
return _ANC_BY_VALUE.get(resp[ANC_RESPONSE_MODE_INDEX])
126176
return None

0 commit comments

Comments
 (0)