libmdr-cli 0.1.0__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,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: libmdr-cli
3
+ Version: 0.1.0
4
+ Summary: libmdr CLI Frontend
5
+ License-Expression: MIT
6
+ Project-URL: Source, https://github.com/mos9527/libmdr-py
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/plain
9
+ License-File: LICENSE
10
+ Requires-Dist: libmdr
11
+ Requires-Dist: libmdr-bt
12
+ Dynamic: license-file
13
+
14
+ libmdr CLI Frontend. Provides the `mdr` command, see `mdr help`.
@@ -0,0 +1,8 @@
1
+ libmdr_cli-0.1.0.dist-info/licenses/LICENSE,sha256=bT3oz2JQVaq6bCmlGlJq7PyheArCwNYYXR3HpBymnnE,1099
2
+ mdr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ mdr/__main__.py,sha256=tKvXXIaDpmSNreghCQn9XYJq3DU_4oLPbHFS6A_YAnk,28657
4
+ libmdr_cli-0.1.0.dist-info/METADATA,sha256=Qqmp55exhFkbroq9mJ-TyvjpWBZ5IsRdNdDDeBaYNPM,383
5
+ libmdr_cli-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
6
+ libmdr_cli-0.1.0.dist-info/entry_points.txt,sha256=qapXXFWaB3ioQkpHxA3wXfOCVdf77t5wkB-OuRbiJeY,42
7
+ libmdr_cli-0.1.0.dist-info/top_level.txt,sha256=4sANZj3WtOjoI85mZaTqfc3xvecoxjvCTLvyEc2rLZ8,4
8
+ libmdr_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mdr = mdr.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mos9527, Amr Satrio and other contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ mdr
mdr/__init__.py ADDED
File without changes
mdr/__main__.py ADDED
@@ -0,0 +1,787 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ usage:
4
+ mdr [option=value ...] <context> [show]
5
+ mdr [option=value ...] <context> [set] key=value [key=value ...]
6
+ mdr [option=value ...] <context> <action>
7
+ mdr <context> help
8
+
9
+ examples:
10
+ mdr anc set mode=amb level=15 voice=on
11
+ mdr anc mode=nc
12
+ mdr device=XM6 eq preset=bright clearbass=3
13
+ mdr playback volume+=2
14
+ mdr playback toggle
15
+ mdr json=on battery
16
+
17
+ fuzzy matching is also supported, with prefix matching unique ones, the above is equivalent to:
18
+ mdr a set m=amb l=15 v=on
19
+ mdr a m=nc
20
+ mdr d=XM6 e p=b c=3
21
+ mdr pl v+=2
22
+ mdr pl t
23
+ mdr j=on b
24
+
25
+ use the verb 'help' to show all options for a context:
26
+ mdr playback help
27
+ ...
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import dataclasses
33
+ import difflib
34
+ import json
35
+ import os
36
+ import re
37
+ import sys
38
+ import time
39
+ from contextlib import contextmanager
40
+ from dataclasses import dataclass, field
41
+ from typing import Annotated, Any, Callable, Iterator, Literal, get_args, get_origin, get_type_hints
42
+
43
+ from libmdr import Headphones, MDRError
44
+ from libmdr import constants as C
45
+ from libmdr import result as R
46
+ from libmdr_bt import create_connection
47
+
48
+ CURRENT, DESIRED = C.PROPERTY_CURRENT, C.PROPERTY_DESIRED
49
+ DISCOVERY = C.HEADPHONES_INIT_DISCOVERY
50
+ RO = "read-only"
51
+ MAC_RE = re.compile(r"^[0-9A-Fa-f]{2}([:-][0-9A-Fa-f]{2}){5}$")
52
+ KV_RE = re.compile(r"^(\w+)([+-]?=)(.*)$")
53
+
54
+
55
+ class UsageError(Exception):
56
+ pass
57
+
58
+
59
+ class Unsupported(Exception):
60
+ pass
61
+
62
+
63
+ def resolve(word: str, choices: list[str], what: str, see: str = "") -> str:
64
+ """Match ``word`` exactly or by unique prefix; otherwise suggest close matches."""
65
+ w = word.lower()
66
+ if w in choices:
67
+ return w
68
+ hits = [c for c in choices if c.startswith(w)]
69
+ if len(hits) == 1:
70
+ return hits[0]
71
+ if hits:
72
+ message = f"ambiguous {what} {word!r}, could be: {', '.join(hits)}"
73
+ else:
74
+ message = f"unknown {what} {word!r}"
75
+ close = difflib.get_close_matches(w, choices, n=3, cutoff=0.6)
76
+ if close:
77
+ message += f"\n\ndid you mean {' or '.join(map(repr, close))}?"
78
+ if see:
79
+ message += f"\nsee '{see}'"
80
+ raise UsageError(message)
81
+
82
+
83
+ # --------------------------------------------------------------------------- #
84
+ # Models. Field names are the keys accepted on the command line; Annotated
85
+ # notes show up in `mdr <context> help`.
86
+ # --------------------------------------------------------------------------- #
87
+
88
+
89
+ @dataclass
90
+ class Options:
91
+ device: Annotated[str, "MAC address or part of the name; default $MDR_DEVICE"] = ""
92
+ ble: Annotated[bool, "connect over BLE (Windows)"] = False
93
+ protocol: Literal["auto", "v1", "v2"] = "auto"
94
+ timeout: Annotated[float, "seconds per step"] = 10.0
95
+ json: Annotated[bool, "JSON output"] = False
96
+ verbose: Annotated[bool, "connection and init timings on stderr"] = False
97
+
98
+
99
+ @dataclass
100
+ class Info:
101
+ model: str
102
+ series: str
103
+ firmware: str
104
+ protocol: str
105
+ features: list[str]
106
+
107
+
108
+ @dataclass
109
+ class Battery:
110
+ main: int | None = None
111
+ left: int | None = None
112
+ right: int | None = None
113
+ case: int | None = None
114
+ charging: list[str] = field(default_factory=list)
115
+
116
+
117
+ @dataclass
118
+ class NoiseControl:
119
+ mode: Literal["off", "nc", "amb"]
120
+ level: Annotated[int, "ambient level 0..20"]
121
+ voice: Annotated[bool, "focus on voice"]
122
+ adaptive: Annotated[bool, "adaptive ambient sound"]
123
+
124
+
125
+ @dataclass
126
+ class SpeakToChat:
127
+ state: bool
128
+
129
+
130
+ @dataclass
131
+ class Equalizer:
132
+ preset: Annotated[str, "name from 'presets'"]
133
+ bands: Annotated[list[int], "5 bands: -10..10, 10 bands: -6..6"]
134
+ clearbass: Annotated[int, "-10..10"]
135
+ dsee: bool
136
+ presets: Annotated[list[str], RO] = field(default_factory=list)
137
+
138
+
139
+ @dataclass
140
+ class Playback:
141
+ volume: Annotated[int, "0..30"]
142
+ status: Annotated[str, RO] = ""
143
+ title: Annotated[str, RO] = ""
144
+ artist: Annotated[str, RO] = ""
145
+ album: Annotated[str, RO] = ""
146
+
147
+
148
+ @dataclass
149
+ class Power:
150
+ autooff: Annotated[int, "minutes, 0 = never"]
151
+
152
+
153
+ @dataclass
154
+ class Listening:
155
+ mode: Literal["standard", "background", "cinema", "voiceboost", "leakage"]
156
+ room: Literal["unknown", "small", "medium", "large"]
157
+ available: Annotated[bool, RO] = True
158
+ blockers: Annotated[list[str], RO] = field(default_factory=list)
159
+ modes: Annotated[list[str], RO] = field(default_factory=list)
160
+
161
+
162
+ @dataclass
163
+ class VoiceAssistant:
164
+ assistant: Literal["none", "recognition", "google", "alexa", "xiaowei", "sony", "other"]
165
+ available: Annotated[bool, RO] = False
166
+ key: Annotated[str, RO] = ""
167
+ options: Annotated[list[str], RO] = field(default_factory=list)
168
+ unavailable: Annotated[list[str], RO] = field(default_factory=list)
169
+
170
+
171
+ # --------------------------------------------------------------------------- #
172
+ # key=value <-> dataclass
173
+ # --------------------------------------------------------------------------- #
174
+
175
+
176
+ def unwrap(tp: Any) -> tuple[Any, list[str]]:
177
+ if get_origin(tp) is Annotated:
178
+ base, *notes = get_args(tp)
179
+ return base, list(notes)
180
+ return tp, []
181
+
182
+
183
+ def parse_value(tp: Any, text: str, key: str) -> Any:
184
+ tp, _ = unwrap(tp)
185
+ if get_origin(tp) is Literal:
186
+ return resolve(text, list(get_args(tp)), f"{key} value")
187
+ if get_origin(tp) is list:
188
+ (item,) = get_args(tp)
189
+ return [parse_value(item, t.strip(), key) for t in text.split(",") if t.strip()]
190
+ if tp is bool:
191
+ if text.lower() in ("on", "yes", "true", "1"):
192
+ return True
193
+ if text.lower() in ("off", "no", "false", "0"):
194
+ return False
195
+ raise UsageError(f"{key} must be on or off")
196
+ try:
197
+ return tp(text)
198
+ except ValueError:
199
+ raise UsageError(f"{key} expects {tp.__name__}, got {text!r}") from None
200
+
201
+
202
+ def apply(obj: Any, pairs: list[str]) -> Any:
203
+ """Return a copy of ``obj`` with ``key=value`` pairs applied."""
204
+ hints = get_type_hints(type(obj), include_extras=True)
205
+ writable = [k for k, tp in hints.items() if RO not in unwrap(tp)[1]]
206
+ changes: dict[str, Any] = {}
207
+ for pair in pairs:
208
+ m = KV_RE.match(pair)
209
+ if not m:
210
+ raise UsageError(f"expected key=value, got {pair!r}")
211
+ key, op, text = m.group(1).lower(), m.group(2), m.group(3)
212
+ if key in hints and key not in writable:
213
+ raise UsageError(f"key {key!r} is read-only")
214
+ key = resolve(key, writable, "key")
215
+ base, _ = unwrap(hints[key])
216
+ value = parse_value(base, text, key)
217
+ if op != "=":
218
+ if base not in (int, float):
219
+ raise UsageError(f"{key}{op} only works on numbers")
220
+ current = changes.get(key, getattr(obj, key))
221
+ value = current + value if op == "+=" else current - value
222
+ changes[key] = value
223
+ return dataclasses.replace(obj, **changes)
224
+
225
+
226
+ def describe(tp: Any) -> tuple[str, str]:
227
+ tp, notes = unwrap(tp)
228
+ if get_origin(tp) is Literal:
229
+ kind = "|".join(get_args(tp))
230
+ elif tp is bool:
231
+ kind = "on|off"
232
+ elif get_origin(tp) is list:
233
+ kind = f"{describe(get_args(tp)[0])[0]},..."
234
+ else:
235
+ kind = tp.__name__
236
+ return kind, ", ".join(n for n in notes if n != RO)
237
+
238
+
239
+ def print_keys(model: type, writable_only: bool = True) -> None:
240
+ hints = get_type_hints(model, include_extras=True)
241
+ for f in dataclasses.fields(model):
242
+ if writable_only and RO in unwrap(hints[f.name])[1]:
243
+ continue
244
+ kind, note = describe(hints[f.name])
245
+ print(f" {f.name + '=' + kind:<28} {note}".rstrip())
246
+
247
+
248
+ def fmt(value: Any) -> str:
249
+ if isinstance(value, bool):
250
+ return "on" if value else "off"
251
+ if isinstance(value, list):
252
+ return ",".join(map(str, value)) if all(isinstance(v, int) for v in value) else ", ".join(value)
253
+ return str(value)
254
+
255
+
256
+ def as_dict(obj: Any) -> dict[str, Any]:
257
+ return {k: v for k, v in dataclasses.asdict(obj).items() if v is not None and v != ""}
258
+
259
+
260
+ def emit(obj: Any, opts: Options) -> None:
261
+ data = as_dict(obj)
262
+ if opts.json:
263
+ print(json.dumps(data, ensure_ascii=False))
264
+ else:
265
+ for key, value in data.items():
266
+ print(f"{key}: {fmt(value)}")
267
+
268
+
269
+ # --------------------------------------------------------------------------- #
270
+ # Device access
271
+ # --------------------------------------------------------------------------- #
272
+
273
+
274
+ def flag(enabled: bool) -> int:
275
+ return C.TRUE if enabled else C.FALSE
276
+
277
+
278
+ def require(hp: Headphones, *features: int) -> None:
279
+ for f in features:
280
+ if not hp.feature_available(f):
281
+ raise Unsupported(f"device does not support {C.FEATURE_NAMES.get(f, f)}")
282
+
283
+
284
+ def log(opts: Options, message: str) -> None:
285
+ if opts.verbose:
286
+ print(message, file=sys.stderr)
287
+
288
+
289
+ def find_device(pc, query: str) -> str:
290
+ if MAC_RE.match(query):
291
+ return query.replace("-", ":").upper()
292
+ devices = pc.devices()
293
+ q = query.lower()
294
+ matches = [d for d in devices if d.name.lower() == q] or [d for d in devices if q in d.name.lower()]
295
+ if len(matches) == 1:
296
+ return matches[0].address
297
+ if not matches:
298
+ raise UsageError(f"no paired device matches {query!r}" if query else "no paired devices")
299
+ listing = "\n".join(f" {d.address} {d.name}" for d in matches)
300
+ raise UsageError(f"several devices match, pass device=...\n{listing}")
301
+
302
+
303
+ def open_link(pc, address: str, opts: Options) -> int:
304
+ v2, v1 = (R.SERVICE_UUID_V2, C.PROTOCOL_V2), (R.SERVICE_UUID_V1, C.PROTOCOL_V1)
305
+ services = [(R.SERVICE_UUID_BLE, C.PROTOCOL_V2)] if opts.ble else {
306
+ "auto": [v2, v1], "v2": [v2], "v1": [v1]
307
+ }[opts.protocol]
308
+ errors = []
309
+ for uuid, protocol in services:
310
+ try:
311
+ pc.connect(address, uuid)
312
+ deadline = time.monotonic() + opts.timeout
313
+ while (code := pc.poll(100)) != R.OK:
314
+ if code not in (R.INPROGRESS, R.ERROR_TIMEOUT):
315
+ raise MDRError(code, pc.last_error() or None)
316
+ if time.monotonic() > deadline:
317
+ raise MDRError(R.ERROR_TIMEOUT)
318
+ if code == R.INPROGRESS:
319
+ time.sleep(0.01)
320
+ return protocol
321
+ except MDRError as e:
322
+ errors.append(f"{C.PROTOCOL_NAMES[protocol]}: {e}")
323
+ pc.disconnect()
324
+ raise MDRError(R.ERROR_NO_CONNECTION, f"cannot connect to {address} ({'; '.join(errors)})")
325
+
326
+
327
+ def wait(hp: Headphones, target: int, timeout: float) -> None:
328
+ deadline = time.monotonic() + timeout
329
+ while (event := hp.poll()) != target:
330
+ if event == C.EVENT_ALERT:
331
+ print(f"device asks: {hp.get_text(C.TEXT_LAST_ALERT)}", file=sys.stderr)
332
+ if time.monotonic() > deadline:
333
+ raise MDRError(R.ERROR_TIMEOUT, f"timed out waiting for {C.EVENT_NAMES[target]}")
334
+ if event == C.EVENT_NONE:
335
+ time.sleep(0.005)
336
+
337
+
338
+ def commit(hp: Headphones, timeout: float) -> None:
339
+ if hp.is_dirty():
340
+ hp.request_commit()
341
+ wait(hp, C.EVENT_APPLY_COMPLETE, timeout)
342
+
343
+
344
+ def wait_voice_settings(hp: Headphones, timeout: float) -> None:
345
+ """Wait for the parameter report, not just initialization's final GET ACK.
346
+
347
+ Capability/status events also use EVENT_VOICE_ASSISTANT_CHANGED, so waiting
348
+ for that event alone does not guarantee the selected assistant is cached.
349
+ """
350
+ deadline = time.monotonic() + timeout
351
+ while hp.get_voice_assistant(CURRENT).assistant == C.VOICE_ASSISTANT_UNKNOWN:
352
+ if time.monotonic() >= deadline:
353
+ raise MDRError(R.ERROR_TIMEOUT, "timed out waiting for readable voice-assistant settings")
354
+ event = hp.poll()
355
+ if event == C.EVENT_ALERT:
356
+ print(f"device asks: {hp.get_text(C.TEXT_LAST_ALERT)}", file=sys.stderr)
357
+ if event == C.EVENT_NONE:
358
+ time.sleep(0.005)
359
+
360
+
361
+ @contextmanager
362
+ def connect(opts: Options, init: int) -> Iterator[tuple[Headphones, int]]:
363
+ """Connect, run the requested init and yield ``(headphones, protocol)``."""
364
+ with create_connection(ble=opts.ble) as pc:
365
+ t0 = time.monotonic()
366
+ address = find_device(pc, opts.device)
367
+ protocol = open_link(pc, address, opts)
368
+ log(opts, f"connected to {address} ({C.PROTOCOL_NAMES[protocol]}) in {time.monotonic() - t0:.2f}s")
369
+ try:
370
+ with Headphones(pc.connection, protocol_version=protocol) as hp:
371
+ t0 = time.monotonic()
372
+ hp.request_init(init)
373
+ wait(hp, C.EVENT_INITIALIZE_COMPLETE, opts.timeout)
374
+ if init & C.HEADPHONES_INIT_VOICE_ASSISTANT and hp.feature_available(C.FEATURE_VOICE_ASSISTANT):
375
+ wait_voice_settings(hp, opts.timeout)
376
+ log(opts, f"init 0x{init:05x} in {time.monotonic() - t0:.2f}s")
377
+ yield hp, protocol
378
+ finally:
379
+ pc.disconnect()
380
+
381
+
382
+ # --------------------------------------------------------------------------- #
383
+ # Contexts: read(hp, protocol, view) -> model, write(hp, protocol, old, new)
384
+ # --------------------------------------------------------------------------- #
385
+
386
+
387
+ def read_info(hp: Headphones, proto: int, view: int) -> Info:
388
+ return Info(
389
+ model=hp.get_text(C.TEXT_MODEL_NAME),
390
+ series=hp.get_text(C.TEXT_MODEL_SERIES),
391
+ firmware=hp.get_text(C.TEXT_FIRMWARE_VERSION),
392
+ protocol=C.PROTOCOL_NAMES[proto],
393
+ features=[
394
+ C.FEATURE_NAMES.get(f, str(f))
395
+ for f in range(1, C.FEATURE_MAX_VALUE + 1)
396
+ if hp.feature_available(f)
397
+ ],
398
+ )
399
+
400
+
401
+ def read_battery(hp: Headphones, proto: int, view: int) -> Battery:
402
+ out = Battery()
403
+ for b in hp.get_batteries():
404
+ part = C.BATTERY_PART_NAMES.get(b.part)
405
+ if b.present and part:
406
+ setattr(out, part, int(b.level_percent))
407
+ if b.charging == C.CHARGING_YES:
408
+ out.charging.append(part)
409
+ return out
410
+
411
+
412
+ def read_anc(hp: Headphones, proto: int, view: int) -> NoiseControl:
413
+ nc = hp.get_noise_control(view)
414
+ level = int(nc.ambient_level)
415
+ if nc.mode == C.NOISE_MODE_OFF:
416
+ mode = "off"
417
+ elif proto == C.PROTOCOL_V1:
418
+ mode = "nc" if level == C.NOISE_AMBIENT_LEVEL_V1_NOISE_CANCELLING else "amb"
419
+ else:
420
+ mode = "nc" if nc.mode == C.NOISE_MODE_CANCELLING else "amb"
421
+ return NoiseControl(mode, min(level, 20), bool(nc.focus_on_voice), bool(nc.adaptive_ambient))
422
+
423
+
424
+ def write_anc(hp: Headphones, proto: int, old: NoiseControl, new: NoiseControl) -> None:
425
+ if new.mode == "nc":
426
+ require(hp, C.FEATURE_NOISE_CANCELLING)
427
+ if new.mode == "amb":
428
+ require(hp, C.FEATURE_AMBIENT_SOUND)
429
+ if new.adaptive != old.adaptive:
430
+ require(hp, C.FEATURE_ADAPTIVE_AMBIENT_SOUND)
431
+ v1 = proto == C.PROTOCOL_V1
432
+ nc = hp.get_noise_control(DESIRED)
433
+ if new.mode == "off":
434
+ nc.mode = C.NOISE_MODE_OFF
435
+ elif v1:
436
+ nc.mode = C.NOISE_MODE_V1_ON
437
+ else:
438
+ nc.mode = C.NOISE_MODE_CANCELLING if new.mode == "nc" else C.NOISE_MODE_AMBIENT
439
+ nc.ambient_level = C.NOISE_AMBIENT_LEVEL_V1_NOISE_CANCELLING if v1 and new.mode == "nc" else new.level
440
+ nc.focus_on_voice = flag(new.voice)
441
+ nc.adaptive_ambient = flag(new.adaptive)
442
+ nc.changing_asm_level = C.FALSE
443
+ hp.set_noise_control(nc)
444
+
445
+
446
+ def read_stc(hp: Headphones, proto: int, view: int) -> SpeakToChat:
447
+ return SpeakToChat(bool(hp.get_speak_to_chat(view).enabled))
448
+
449
+
450
+ def write_stc(hp: Headphones, proto: int, old: SpeakToChat, new: SpeakToChat) -> None:
451
+ value = hp.get_speak_to_chat(DESIRED)
452
+ value.enabled = flag(new.state)
453
+ hp.set_speak_to_chat(value)
454
+
455
+
456
+ def eq_presets(hp: Headphones) -> dict[int, str]:
457
+ ids = hp.get_equalizer_presets()
458
+ if not ids:
459
+ return {k: v for k, v in C.EQUALIZER_PRESET_NAMES.items() if k != C.EQ_UNKNOWN}
460
+ return {
461
+ pid: hp.equalizer_preset_name(i) or C.EQUALIZER_PRESET_NAMES.get(pid, str(pid))
462
+ for i, pid in enumerate(ids)
463
+ }
464
+
465
+
466
+ def read_eq(hp: Headphones, proto: int, view: int) -> Equalizer:
467
+ eq = hp.get_equalizer(view)
468
+ names = eq_presets(hp)
469
+ preset = int(eq.preset)
470
+ return Equalizer(
471
+ preset=names.get(preset) or C.EQUALIZER_PRESET_NAMES.get(preset, str(preset)),
472
+ bands=hp.get_equalizer_bands(view),
473
+ clearbass=int(eq.clear_bass),
474
+ dsee=bool(eq.dsee_enabled),
475
+ presets=list(names.values()),
476
+ )
477
+
478
+
479
+ def write_eq(hp: Headphones, proto: int, old: Equalizer, new: Equalizer) -> None:
480
+ if new.preset != old.preset:
481
+ def norm(s: str) -> str:
482
+ return re.sub(r"[^a-z0-9]", "", s.lower())
483
+
484
+ ids = [pid for pid, name in eq_presets(hp).items() if norm(name) == norm(new.preset)]
485
+ if not ids:
486
+ raise UsageError(f"unknown preset {new.preset!r}, see 'mdr eq'")
487
+ hp.set_equalizer_preset(ids[0])
488
+ if new.bands != old.bands:
489
+ hp.set_equalizer_bands(new.bands)
490
+ if new.clearbass != old.clearbass:
491
+ hp.set_equalizer_clear_bass(new.clearbass)
492
+ if new.dsee != old.dsee:
493
+ require(hp, C.FEATURE_DSEE)
494
+ hp.set_dsee(new.dsee)
495
+
496
+
497
+ def read_playback(hp: Headphones, proto: int, view: int) -> Playback:
498
+ pb = hp.get_playback(view)
499
+ return Playback(
500
+ volume=int(pb.volume),
501
+ status=C.PLAYBACK_STATUS_NAMES.get(pb.status, "Unknown").lower(),
502
+ title=hp.get_text(C.TEXT_TRACK_TITLE),
503
+ artist=hp.get_text(C.TEXT_TRACK_ARTIST),
504
+ album=hp.get_text(C.TEXT_TRACK_ALBUM),
505
+ )
506
+
507
+
508
+ def write_playback(hp: Headphones, proto: int, old: Playback, new: Playback) -> None:
509
+ if new.volume != old.volume:
510
+ require(hp, C.FEATURE_PLAYBACK_VOLUME)
511
+ hp.set_volume(new.volume)
512
+
513
+
514
+ def playback_action(name: str) -> Callable[[Headphones, Callable[[], None]], None]:
515
+ def run(hp: Headphones, done: Callable[[], None]) -> None:
516
+ require(hp, C.FEATURE_PLAYBACK_CONTROL)
517
+ if name == "toggle":
518
+ hp.toggle_playback()
519
+ else:
520
+ hp.playback_command(getattr(C, f"PLAYBACK_{name.upper()}"))
521
+ done()
522
+
523
+ return run
524
+
525
+
526
+ def read_power(hp: Headphones, proto: int, view: int) -> Power:
527
+ return Power(int(hp.get_power(view).auto_power_off_minutes))
528
+
529
+
530
+ def write_power(hp: Headphones, proto: int, old: Power, new: Power) -> None:
531
+ require(hp, C.FEATURE_AUTO_POWER_OFF)
532
+ power = hp.get_power(DESIRED)
533
+ power.auto_power_off_minutes = new.autooff
534
+ hp.set_power(power)
535
+
536
+
537
+ def power_off(hp: Headphones, done: Callable[[], None]) -> None:
538
+ require(hp, C.FEATURE_SHUTDOWN)
539
+ hp.shutdown()
540
+ try:
541
+ done()
542
+ except MDRError:
543
+ pass # The link usually drops before the ACK arrives.
544
+
545
+
546
+ LISTENING_MODES = {
547
+ "standard": C.LISTENING_STANDARD,
548
+ "background": C.LISTENING_BACKGROUND_MUSIC,
549
+ "cinema": C.LISTENING_CINEMA,
550
+ "voiceboost": C.LISTENING_VOICE_BOOST,
551
+ "leakage": C.LISTENING_SOUND_LEAKAGE_REDUCTION,
552
+ }
553
+ LISTENING_FEATURES = {
554
+ C.LISTENING_BACKGROUND_MUSIC: C.FEATURE_LISTENING_BACKGROUND_MUSIC,
555
+ C.LISTENING_CINEMA: C.FEATURE_LISTENING_CINEMA,
556
+ C.LISTENING_VOICE_BOOST: C.FEATURE_LISTENING_VOICE_BOOST,
557
+ C.LISTENING_SOUND_LEAKAGE_REDUCTION: C.FEATURE_LISTENING_SOUND_LEAKAGE_REDUCTION,
558
+ }
559
+ ROOM_SIZES = {name.lower(): value for value, name in C.ROOM_SIZE_NAMES.items()}
560
+ VOICE_ASSISTANTS = {
561
+ "none": C.VOICE_ASSISTANT_NONE,
562
+ "recognition": C.VOICE_ASSISTANT_VOICE_RECOGNITION,
563
+ "google": C.VOICE_ASSISTANT_GOOGLE_ASSISTANT,
564
+ "alexa": C.VOICE_ASSISTANT_AMAZON_ALEXA,
565
+ "xiaowei": C.VOICE_ASSISTANT_TENCENT_XIAOWEI,
566
+ "sony": C.VOICE_ASSISTANT_SONY_VOICE_CONTROL,
567
+ "other": C.VOICE_ASSISTANT_OTHER_DEVICE,
568
+ }
569
+
570
+
571
+ def read_listening(hp: Headphones, proto: int, view: int) -> Listening:
572
+ value = hp.get_listening(view)
573
+ status = hp.get_listening_status(int(value.mode))
574
+ mode = next((name for name, ident in LISTENING_MODES.items() if ident == value.mode), "unknown")
575
+ room = C.ROOM_SIZE_NAMES.get(value.background_room, "Unknown").lower()
576
+ blockers = [name for bit, name in C.LISTENING_BLOCKER_NAMES.items() if bit and status.blockers & bit]
577
+ modes = [name for name, ident in LISTENING_MODES.items()
578
+ if ident == C.LISTENING_STANDARD or hp.feature_available(LISTENING_FEATURES[ident])]
579
+ return Listening(mode, room, bool(status.available), blockers, modes)
580
+
581
+
582
+ def write_listening(hp: Headphones, proto: int, old: Listening, new: Listening) -> None:
583
+ if (old.mode, old.room) == (new.mode, new.room):
584
+ return
585
+ value = hp.get_listening(DESIRED)
586
+ value.mode = LISTENING_MODES[new.mode]
587
+ value.background_room = ROOM_SIZES[new.room]
588
+ hp.set_listening(value)
589
+
590
+
591
+ def read_voice(hp: Headphones, proto: int, view: int) -> VoiceAssistant:
592
+ value = hp.get_voice_assistant(view)
593
+ names = {ident: name for name, ident in VOICE_ASSISTANTS.items()}
594
+ options = hp.get_voice_assistant_options()
595
+
596
+ def label(ident: int) -> str:
597
+ return names.get(ident, C.VOICE_ASSISTANT_NAMES.get(ident, str(ident)))
598
+
599
+ return VoiceAssistant(
600
+ assistant=label(int(value.assistant)),
601
+ available=bool(value.available),
602
+ key=C.VOICE_ASSISTANT_KEY_NAMES.get(value.key, str(value.key)),
603
+ options=[label(int(option.assistant)) for option in options],
604
+ unavailable=[label(int(option.assistant)) for option in options if not option.available],
605
+ )
606
+
607
+
608
+ def write_voice(hp: Headphones, proto: int, old: VoiceAssistant, new: VoiceAssistant) -> None:
609
+ if new.assistant != old.assistant:
610
+ hp.set_voice_assistant(VOICE_ASSISTANTS[new.assistant])
611
+
612
+
613
+ @dataclass
614
+ class Context:
615
+ help: str
616
+ model: type
617
+ init: int
618
+ read: Callable[[Headphones, int, int], Any]
619
+ write: Callable[[Headphones, int, Any, Any], None] | None = None
620
+ actions: dict[str, Callable[[Headphones, Callable[[], None]], None]] = field(default_factory=dict)
621
+ feature: int | None = None
622
+
623
+
624
+ CONTEXTS: dict[str, Context] = {
625
+ "info": Context("model, firmware and supported features", Info, DISCOVERY, read_info),
626
+ "battery": Context("battery levels", Battery, DISCOVERY | C.HEADPHONES_INIT_BATTERY, read_battery),
627
+ "anc": Context("noise cancelling / ambient sound", NoiseControl,
628
+ DISCOVERY | C.HEADPHONES_INIT_NOISE_CONTROL, read_anc, write_anc),
629
+ "stc": Context("speak-to-chat", SpeakToChat, DISCOVERY | C.HEADPHONES_INIT_SPEAK_TO_CHAT,
630
+ read_stc, write_stc, feature=C.FEATURE_SPEAK_TO_CHAT),
631
+ "eq": Context("equalizer and DSEE", Equalizer,
632
+ DISCOVERY | C.HEADPHONES_INIT_EQUALIZER | C.HEADPHONES_INIT_DSEE,
633
+ read_eq, write_eq, feature=C.FEATURE_EQUALIZER),
634
+ "playback": Context("volume and media keys", Playback, DISCOVERY | C.HEADPHONES_INIT_PLAYBACK,
635
+ read_playback, write_playback,
636
+ actions={a: playback_action(a) for a in ("play", "pause", "toggle", "next", "prev")}),
637
+ "power": Context("auto power off and shutdown", Power, DISCOVERY | C.HEADPHONES_INIT_POWER,
638
+ read_power, write_power, actions={"off": power_off}),
639
+ "listening": Context("listening mode, room size and blockers", Listening,
640
+ DISCOVERY | C.HEADPHONES_INIT_LISTENING_MODE,
641
+ read_listening, write_listening, feature=C.FEATURE_LISTENING_MODE),
642
+ "voice": Context("voice assistant and available options", VoiceAssistant,
643
+ DISCOVERY | C.HEADPHONES_INIT_VOICE_ASSISTANT,
644
+ read_voice, write_voice, feature=C.FEATURE_VOICE_ASSISTANT),
645
+ }
646
+
647
+ WATCH_EVENTS = {
648
+ C.EVENT_BATTERY_CHANGED: "battery",
649
+ C.EVENT_NOISE_CONTROL_CHANGED: "anc",
650
+ C.EVENT_SPEAK_TO_CHAT_CHANGED: "stc",
651
+ C.EVENT_EQUALIZER_CHANGED: "eq",
652
+ C.EVENT_PLAYBACK_CHANGED: "playback",
653
+ C.EVENT_POWER_CHANGED: "power",
654
+ C.EVENT_LISTENING_MODE_CHANGED: "listening",
655
+ C.EVENT_VOICE_ASSISTANT_CHANGED: "voice",
656
+ }
657
+
658
+
659
+ # --------------------------------------------------------------------------- #
660
+ # Commands
661
+ # --------------------------------------------------------------------------- #
662
+
663
+
664
+ def cmd_devices(opts: Options) -> None:
665
+ with create_connection(ble=opts.ble) as pc:
666
+ devices = pc.devices()
667
+ if opts.json:
668
+ print(json.dumps([dataclasses.asdict(d) for d in devices], ensure_ascii=False))
669
+ for d in [] if opts.json else devices:
670
+ print(f"{d.address} {d.name}")
671
+
672
+
673
+ def cmd_watch(opts: Options) -> None:
674
+ with connect(opts, C.HEADPHONES_INIT_DEFAULT) as (hp, proto):
675
+ print("watching, Ctrl+C to stop", file=sys.stderr)
676
+ need_sync = C.HEADPHONES_SYNC_NONE
677
+ while True:
678
+ if need_sync and hp.is_ready():
679
+ hp.request_sync(need_sync)
680
+ need_sync = C.HEADPHONES_SYNC_NONE
681
+ event = hp.poll()
682
+ if event == C.EVENT_NONE:
683
+ time.sleep(0.01)
684
+ continue
685
+ if event == C.EVENT_NEED_SYNC:
686
+ need_sync |= hp.get_sync()
687
+ name = WATCH_EVENTS.get(event)
688
+ data = as_dict(CONTEXTS[name].read(hp, proto, CURRENT)) if name else {}
689
+ data.pop("presets", None)
690
+ stamp, label = time.strftime("%H:%M:%S"), name or C.EVENT_NAMES.get(event, str(event))
691
+ if opts.json:
692
+ print(json.dumps({"time": stamp, "event": label, **data}, ensure_ascii=False), flush=True)
693
+ else:
694
+ pairs = " ".join(f"{k}={fmt(v)}" for k, v in data.items())
695
+ print(f"{stamp} {label} {pairs}".rstrip(), flush=True)
696
+
697
+
698
+ def cmd_context(name: str, ctx: Context, verb: str, pairs: list[str], opts: Options) -> None:
699
+ if verb == "set" and not pairs:
700
+ raise UsageError("set needs key=value")
701
+ if verb != "set" and pairs:
702
+ raise UsageError(f"'{name} {verb}' takes no key=value")
703
+
704
+ with connect(opts, ctx.init) as (hp, proto):
705
+ if ctx.feature:
706
+ require(hp, ctx.feature)
707
+ if verb == "show":
708
+ emit(ctx.read(hp, proto, CURRENT), opts)
709
+ elif verb == "set":
710
+ old = ctx.read(hp, proto, DESIRED)
711
+ ctx.write(hp, proto, old, apply(old, pairs))
712
+ commit(hp, opts.timeout)
713
+ emit(ctx.read(hp, proto, DESIRED), opts)
714
+ else:
715
+ ctx.actions[verb](hp, lambda: commit(hp, opts.timeout))
716
+
717
+
718
+ def print_usage() -> None:
719
+ print(__doc__.strip())
720
+ print("\ncontexts:")
721
+ print(f" {'devices':<10} paired Bluetooth devices")
722
+ for name, ctx in CONTEXTS.items():
723
+ print(f" {name:<10} {ctx.help}")
724
+ print(f" {'watch':<10} stay connected and print device events")
725
+ print("\noptions:")
726
+ print_keys(Options)
727
+
728
+
729
+ def context_verbs(ctx: Context) -> list[str]:
730
+ return ["show"] + (["set"] if ctx.write else []) + list(ctx.actions)
731
+
732
+
733
+ def print_context_help(name: str, ctx: Context) -> None:
734
+ print(f"mdr {name} - {ctx.help}\n\nverbs: {' | '.join(context_verbs(ctx))}")
735
+ if ctx.write:
736
+ print("\nkeys:")
737
+ print_keys(ctx.model)
738
+
739
+
740
+ def main(argv: list[str] | None = None) -> int:
741
+ args = list(sys.argv[1:] if argv is None else argv)
742
+ try:
743
+ split = next((i for i, a in enumerate(args) if "=" not in a), len(args))
744
+ opts = apply(Options(device=os.environ.get("MDR_DEVICE", "")), args[:split])
745
+ rest = args[split:]
746
+ if not rest or rest[0].lower() in ("help", "-h", "--help", "/?"):
747
+ print_usage()
748
+ return 0 if rest else 2
749
+
750
+ name = resolve(rest.pop(0), ["devices", *CONTEXTS, "watch", "help"], "context", "mdr help")
751
+ if name == "help":
752
+ print_usage()
753
+ elif name == "devices":
754
+ cmd_devices(opts)
755
+ elif name == "watch":
756
+ cmd_watch(opts)
757
+ else:
758
+ ctx = CONTEXTS[name]
759
+ if rest and "=" not in rest[0]:
760
+ word = rest.pop(0)
761
+ if ctx.write is None and word.lower() == "set":
762
+ raise UsageError(f"'{name}' is read-only")
763
+ verb = resolve(word, [*context_verbs(ctx), "help"], "verb", f"mdr {name} help")
764
+ else:
765
+ verb = "set" if rest else "show"
766
+ if verb == "set" and ctx.write is None:
767
+ raise UsageError(f"'{name}' is read-only")
768
+ if verb == "help":
769
+ print_context_help(name, ctx)
770
+ else:
771
+ cmd_context(name, ctx, verb, rest, opts)
772
+ return 0
773
+ except UsageError as e:
774
+ print(f"error: {e}", file=sys.stderr)
775
+ return 2
776
+ except Unsupported as e:
777
+ print(f"error: {e}", file=sys.stderr)
778
+ return 3
779
+ except MDRError as e:
780
+ print(f"error: {e}", file=sys.stderr)
781
+ return {R.ERROR_INVALID_ARGUMENT: 2, R.ERROR_NOT_SUPPORTED: 3}.get(e.code, 1)
782
+ except KeyboardInterrupt:
783
+ return 0
784
+
785
+
786
+ if __name__ == "__main__":
787
+ sys.exit(main())