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
1718from __future__ import annotations
1819
1920import socket
21+ import struct
2022import time
2123from enum import Enum
2224
2325RFCOMM_CHANNEL = 15
2426PACKET_PREAMBLE = 0x55
27+ DEFAULT_CONTROL = 0x0160
28+
29+ CMD_ANC = 0xF00F
30+ CMD_ANC_STATUS = 0xC01E
31+ CMD_EQ = 0x4006
2532
2633
2734class 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-
6167ANC_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
72110class 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