Skip to content

Commit fca8626

Browse files
committed
Add Nothing X share codec and device read commands
- share.py: decode/encode Nothing X "Advanced equaliser profile" QR payloads (gzip+base64, 8 bands of gain/frequency/Q + name). Verified with exact binary round-trip against two real QR samples. - protocol.py: REQUEST_COMMANDS and query_raw() to read device settings. - CLI --read {anc,eq,custom-eq,advanced-eq,battery,firmware,serial} to dump the raw device response, so we can learn the on-wire advanced-EQ format needed to apply imported profiles. - Tests for the share codec. https://claude.ai/code/session_014KrpftzUgVFGFXVUU3DUTT
1 parent 8b2a253 commit fca8626

5 files changed

Lines changed: 164 additions & 0 deletions

File tree

src/nothing_ear/app.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ def _build_parser() -> argparse.ArgumentParser:
4747
"--raw",
4848
help="Send a raw hex control frame and exit (for protocol testing).",
4949
)
50+
parser.add_argument(
51+
"--read",
52+
choices=["anc", "eq", "custom-eq", "advanced-eq", "battery", "firmware", "serial"],
53+
help="Read a setting from the device and print the raw response (no GUI).",
54+
)
5055
parser.add_argument(
5156
"--address",
5257
help="Device MAC address (auto-detected if omitted).",
@@ -101,6 +106,10 @@ def main() -> int:
101106
from .cli import cmd_raw
102107

103108
return cmd_raw(args.address, args.raw, args.channel)
109+
if args.read is not None:
110+
from .cli import cmd_read
111+
112+
return cmd_read(args.address, args.read, args.channel)
104113

105114
return _run_gui(args.mock)
106115

src/nothing_ear/bluetooth/protocol.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,16 @@
3030
CMD_ANC_STATUS = 0xC01E
3131
CMD_EQ = 0xF010
3232

33+
REQUEST_COMMANDS = {
34+
"anc": 0xC01E,
35+
"eq": 0xC01F,
36+
"custom-eq": 0xC044,
37+
"advanced-eq": 0xC04C,
38+
"battery": 0xC007,
39+
"firmware": 0xC042,
40+
"serial": 0xC006,
41+
}
42+
3343

3444
class ANCMode(Enum):
3545
HIGH = "high"
@@ -174,3 +184,13 @@ def query_anc(self) -> ANCMode | None:
174184
if len(resp) > ANC_RESPONSE_MODE_INDEX:
175185
return _ANC_BY_VALUE.get(resp[ANC_RESPONSE_MODE_INDEX])
176186
return None
187+
188+
def query_raw(self, command: int, payload: bytes = b"") -> bytes:
189+
if self._sock is None:
190+
raise RuntimeError("Not connected. Call connect() first.")
191+
self._sock.sendall(build_frame(command, payload, self._next_fsn()))
192+
self._sock.settimeout(2.0)
193+
try:
194+
return self._sock.recv(512)
195+
except TimeoutError:
196+
return b""

src/nothing_ear/bluetooth/share.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Nothing X "Advanced equaliser profile" share codec.
2+
3+
A shared profile (the content of a Nothing X EQ QR code) is gzip + base64 of:
4+
5+
0x00 | band_bytes_len(1) | bands | 0x01 | name_len(1) | name (ASCII)
6+
7+
Each band is three little-endian float32 values: gain (dB), frequency (Hz), Q.
8+
This lets us import and export Nothing-X-compatible profiles.
9+
10+
Decoded and verified against real Nothing X QR samples.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import base64
16+
import gzip
17+
import struct
18+
from dataclasses import dataclass
19+
20+
_BAND_SIZE = 12
21+
22+
23+
@dataclass(frozen=True)
24+
class EqBand:
25+
gain: float
26+
frequency: float
27+
q: float
28+
29+
30+
@dataclass(frozen=True)
31+
class AdvancedEqProfile:
32+
name: str
33+
bands: list[EqBand]
34+
35+
36+
def decode_binary(raw: bytes) -> AdvancedEqProfile:
37+
band_len = raw[1]
38+
count = band_len // _BAND_SIZE
39+
bands = []
40+
offset = 2
41+
for _ in range(count):
42+
gain, frequency, q = struct.unpack_from("<3f", raw, offset)
43+
bands.append(EqBand(gain, frequency, q))
44+
offset += _BAND_SIZE
45+
name_len = raw[offset + 1]
46+
name = raw[offset + 2 : offset + 2 + name_len].decode("ascii", errors="replace")
47+
return AdvancedEqProfile(name=name, bands=bands)
48+
49+
50+
def encode_binary(profile: AdvancedEqProfile) -> bytes:
51+
body = bytes([0x00, len(profile.bands) * _BAND_SIZE])
52+
for band in profile.bands:
53+
body += struct.pack("<3f", band.gain, band.frequency, band.q)
54+
name = profile.name.encode("ascii", errors="replace")
55+
body += bytes([0x01, len(name)]) + name
56+
return body
57+
58+
59+
def decode_profile(code: str) -> AdvancedEqProfile:
60+
raw = gzip.decompress(base64.b64decode(code))
61+
return decode_binary(raw)
62+
63+
64+
def encode_profile(profile: AdvancedEqProfile) -> str:
65+
raw = encode_binary(profile)
66+
return base64.b64encode(gzip.compress(raw)).decode("ascii")

src/nothing_ear/cli.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,28 @@ def cmd_eq(address: str | None, preset: str, channel: int) -> int:
108108
return 0
109109

110110

111+
def cmd_read(address: str | None, name: str, channel: int) -> int:
112+
addr = _resolve_address(address)
113+
if addr is None:
114+
return 1
115+
116+
from .bluetooth.protocol import REQUEST_COMMANDS, NothingController
117+
118+
command = REQUEST_COMMANDS[name]
119+
try:
120+
with NothingController(addr, channel=channel) as ctrl:
121+
resp = ctrl.query_raw(command)
122+
except OSError as exc:
123+
print(f"RFCOMM connection on channel {channel} failed: {exc}")
124+
return 1
125+
if not resp:
126+
print(f"{name}: no response (command 0x{command:04x})")
127+
return 1
128+
print(f"{name} (request 0x{command:04x}) response:")
129+
print(resp.hex())
130+
return 0
131+
132+
111133
def cmd_raw(address: str | None, hex_frame: str, channel: int) -> int:
112134
addr = _resolve_address(address)
113135
if addr is None:

tests/test_share.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Tests for the Nothing X share codec, verified against real QR samples."""
2+
3+
import base64
4+
import gzip
5+
6+
from nothing_ear.bluetooth.share import (
7+
decode_binary,
8+
decode_profile,
9+
encode_binary,
10+
encode_profile,
11+
)
12+
13+
D_TUNE = (
14+
"H4sIAAAAAAAAE2NIYGBgsGdgEHBKS3sGpBn2MzCccDI2/mwPEe93TktjcwCzG3RcZs28CRJ"
15+
"nYCiwdmVg2AAUb9jPoFDrOmtmpwNYb8NuV7g5NYVuZ8/42DGyueiWlOalAgBsFv0DagAAAA=="
16+
)
17+
BALANCED_BASS = (
18+
"H4sIAAAAAAAAAGNIYGA44MDA8MHx7JkztlC2U8McZ3sGBgcgu8r5zh59IPsAEP9yTktbBhF"
19+
"vWO4SmSkCZC9wYHDwc82zrrcH60145TprpiSQDQQJWW72m2faM/I6JeYk5iWnpig4JRYXAw"
20+
"ClSEOdcQAAAA=="
21+
)
22+
23+
24+
def test_decode_real_profiles():
25+
d = decode_profile(D_TUNE)
26+
assert d.name == "D-tune"
27+
assert len(d.bands) == 8
28+
assert round(d.bands[0].gain, 2) == 0.5
29+
assert round(d.bands[0].frequency) == 36
30+
assert round(d.bands[0].q, 1) == 1.8
31+
32+
b = decode_profile(BALANCED_BASS)
33+
assert b.name == "Balanced Bass"
34+
assert round(b.bands[0].gain) == 6
35+
assert round(b.bands[0].frequency) == 30
36+
37+
38+
def test_binary_roundtrip_is_exact():
39+
for code in (D_TUNE, BALANCED_BASS):
40+
raw = gzip.decompress(base64.b64decode(code))
41+
assert encode_binary(decode_binary(raw)) == raw
42+
43+
44+
def test_code_roundtrip():
45+
for code in (D_TUNE, BALANCED_BASS):
46+
profile = decode_profile(code)
47+
assert decode_profile(encode_profile(profile)) == profile

0 commit comments

Comments
 (0)