libmdr-cli 0.1.0__tar.gz
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.
- libmdr_cli-0.1.0/LICENSE +21 -0
- libmdr_cli-0.1.0/PKG-INFO +14 -0
- libmdr_cli-0.1.0/pyproject.toml +22 -0
- libmdr_cli-0.1.0/setup.cfg +4 -0
- libmdr_cli-0.1.0/src/libmdr_cli.egg-info/PKG-INFO +14 -0
- libmdr_cli-0.1.0/src/libmdr_cli.egg-info/SOURCES.txt +10 -0
- libmdr_cli-0.1.0/src/libmdr_cli.egg-info/dependency_links.txt +1 -0
- libmdr_cli-0.1.0/src/libmdr_cli.egg-info/entry_points.txt +2 -0
- libmdr_cli-0.1.0/src/libmdr_cli.egg-info/requires.txt +2 -0
- libmdr_cli-0.1.0/src/libmdr_cli.egg-info/top_level.txt +1 -0
- libmdr_cli-0.1.0/src/mdr/__init__.py +0 -0
- libmdr_cli-0.1.0/src/mdr/__main__.py +787 -0
libmdr_cli-0.1.0/LICENSE
ADDED
|
@@ -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,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,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "libmdr-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "libmdr CLI Frontend"
|
|
9
|
+
readme = { text = "libmdr CLI Frontend. Provides the `mdr` command, see `mdr help`.", content-type = "text/plain" }
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
dependencies = ["libmdr", "libmdr-bt"]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Source = "https://github.com/mos9527/libmdr-py"
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
mdr = "mdr.__main__:main"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.packages.find]
|
|
22
|
+
where = ["src"]
|
|
@@ -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,10 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/libmdr_cli.egg-info/PKG-INFO
|
|
4
|
+
src/libmdr_cli.egg-info/SOURCES.txt
|
|
5
|
+
src/libmdr_cli.egg-info/dependency_links.txt
|
|
6
|
+
src/libmdr_cli.egg-info/entry_points.txt
|
|
7
|
+
src/libmdr_cli.egg-info/requires.txt
|
|
8
|
+
src/libmdr_cli.egg-info/top_level.txt
|
|
9
|
+
src/mdr/__init__.py
|
|
10
|
+
src/mdr/__main__.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mdr
|
|
File without changes
|
|
@@ -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())
|