dx5ii-cli 0.0.1__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.
dx5ii/__init__.py ADDED
@@ -0,0 +1,114 @@
1
+ """Control a Topping DX5 II over its HID interface.
2
+
3
+ Public API::
4
+
5
+ from dx5ii import Dx5ii, Dx5iiError, apply_changes
6
+
7
+ with Dx5ii() as dev:
8
+ state = dev.snapshot() # full read
9
+ dev.set_output("hp_balanced") # switch output port
10
+ dev.set_config_index(2) # switch EQ preset
11
+
12
+ Everything the CLI does is available here; the CLI is a thin wrapper around
13
+ :class:`Dx5ii`, :func:`dx5ii.fields.encode_field` and
14
+ :func:`dx5ii.actions.apply_changes`.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from .actions import apply_changes, state_summary
20
+ from .bands import (
21
+ Band,
22
+ decode_block,
23
+ encode_block,
24
+ encode_name_words,
25
+ parse_apo_filters,
26
+ )
27
+ from .constants import (
28
+ PID,
29
+ PROTOCOL_INTERFACE,
30
+ PT_READ,
31
+ PT_WRITE,
32
+ VID,
33
+ )
34
+ from .device import Dx5ii, list_interfaces
35
+ from .errors import Dx5iiError
36
+ from .fields import (
37
+ FIELD_ACTIONS,
38
+ FIELD_SPECS,
39
+ encode_field,
40
+ field_raw,
41
+ field_state,
42
+ resolve_config,
43
+ resolve_output,
44
+ )
45
+ from .interpreter import (
46
+ collect_words,
47
+ interpret_config_blocks,
48
+ interpret_config_upload,
49
+ interpret_peq_preview,
50
+ interpret_sampling,
51
+ interpret_settings,
52
+ )
53
+ from .peq import (
54
+ create_preset,
55
+ delete_preset,
56
+ find_preset,
57
+ read_presets,
58
+ rename_preset,
59
+ )
60
+ from .protocol import Frame, build_frame, db_to_volume, decode_name, format_firmware, mask_to_items, parse_frame, volume_to_db
61
+
62
+ __version__ = "0.0.1"
63
+
64
+ __all__ = [
65
+ "__version__",
66
+ # identity
67
+ "VID",
68
+ "PID",
69
+ "PROTOCOL_INTERFACE",
70
+ "PT_READ",
71
+ "PT_WRITE",
72
+ # device
73
+ "Dx5ii",
74
+ "Dx5iiError",
75
+ "list_interfaces",
76
+ # actions
77
+ "apply_changes",
78
+ "state_summary",
79
+ # fields
80
+ "FIELD_SPECS",
81
+ "FIELD_ACTIONS",
82
+ "encode_field",
83
+ "field_state",
84
+ "field_raw",
85
+ "resolve_output",
86
+ "resolve_config",
87
+ # EQ presets
88
+ "Band",
89
+ "decode_block",
90
+ "encode_block",
91
+ "encode_name_words",
92
+ "parse_apo_filters",
93
+ "read_presets",
94
+ "find_preset",
95
+ "create_preset",
96
+ "delete_preset",
97
+ "rename_preset",
98
+ # protocol
99
+ "Frame",
100
+ "build_frame",
101
+ "parse_frame",
102
+ "decode_name",
103
+ "volume_to_db",
104
+ "format_firmware",
105
+ "db_to_volume",
106
+ "mask_to_items",
107
+ # interpreters
108
+ "collect_words",
109
+ "interpret_settings",
110
+ "interpret_sampling",
111
+ "interpret_peq_preview",
112
+ "interpret_config_upload",
113
+ "interpret_config_blocks",
114
+ ]
dx5ii/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """``python -m dx5ii`` entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
dx5ii/actions.py ADDED
@@ -0,0 +1,146 @@
1
+ """High-level switches: "make the device look like X", with before/after.
2
+
3
+ ``apply_changes`` is what the game watcher uses: it switches the output port
4
+ first, then the EQ preset, and reports both the state it found and the state
5
+ it left behind. The ``before`` block is what a caller needs in order to roll
6
+ back later.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Optional
12
+
13
+ from .device import Dx5ii
14
+ from .errors import Dx5iiError
15
+ from .fields import resolve_config, resolve_output
16
+
17
+
18
+ def state_summary(snap: dict[str, Any]) -> dict[str, Any]:
19
+ """Condense a snapshot to the four values a caller cares about."""
20
+ settings = snap.get("settings") or {}
21
+ configs = snap.get("configs") or {}
22
+ preview = snap.get("peq") or {}
23
+
24
+ index = configs.get("current_index")
25
+ if index is None and preview.get("preview_active") is False:
26
+ index = preview.get("local_index")
27
+ return {
28
+ "output": settings.get("output"),
29
+ "output_raw": settings.get("output_raw"),
30
+ "input": settings.get("input"),
31
+ "input_raw": settings.get("input_raw"),
32
+ "config_index": index,
33
+ "config_name": configs.get("current_name"),
34
+ "preview_active": preview.get("preview_active"),
35
+ }
36
+
37
+
38
+ def apply_changes(
39
+ dev: Dx5ii,
40
+ snap: dict[str, Any],
41
+ want_output: Optional[str] = None,
42
+ want_config: Optional[Any] = None,
43
+ ) -> dict[str, Any]:
44
+ """Switch the output port and/or the EQ preset, then read the state back.
45
+
46
+ The output switch runs first because each port remembers its own EQ preset:
47
+ switching the port may change the active preset, so the preset check has to
48
+ happen afterwards. Both switches are idempotent -- a value that already
49
+ matches is left alone.
50
+
51
+ Returns a report with ``ok``, ``changed``, ``before``, ``after``,
52
+ ``actions`` and ``errors``.
53
+ """
54
+ configs = snap.get("configs") or {}
55
+ known = {item["index"]: item["name"] for item in (configs.get("configs") or [])}
56
+ settings = snap.get("settings") or {}
57
+
58
+ report: dict[str, Any] = {
59
+ "ok": True,
60
+ "changed": False,
61
+ "before": state_summary(snap),
62
+ "after": None,
63
+ "actions": [],
64
+ "errors": [],
65
+ }
66
+ before = report["before"]
67
+
68
+ if want_output is not None:
69
+ try:
70
+ raw = resolve_output(want_output)
71
+ except Dx5iiError as exc:
72
+ report["ok"] = False
73
+ report["errors"].append(str(exc))
74
+ else:
75
+ start = settings.get("output_raw")
76
+ if start == raw:
77
+ report["actions"].append({
78
+ "what": "output", "from": start, "to": raw, "changed": False})
79
+ else:
80
+ got = dev.set_output(raw)
81
+ ok = bool(got) and got.get("output_raw") == raw
82
+ report["changed"] = report["changed"] or ok
83
+ report["ok"] = report["ok"] and ok
84
+ report["actions"].append({
85
+ "what": "output", "from": start, "to": raw, "changed": ok,
86
+ "observed": (got or {}).get("output"),
87
+ })
88
+ if ok:
89
+ # Each port remembers its own preset, so the value worth
90
+ # restoring is the one *after* the switch.
91
+ new_index = dev.current_config_index()
92
+ before.update({
93
+ "output": (got or {}).get("output"),
94
+ "output_raw": raw,
95
+ "config_index": new_index,
96
+ "config_name": known.get(new_index),
97
+ })
98
+ else:
99
+ report["errors"].append(
100
+ f"output did not change to {raw!r} "
101
+ f"(observed {(got or {}).get('output_raw')!r})")
102
+
103
+ if want_config is not None:
104
+ try:
105
+ target = resolve_config(want_config, configs.get("configs") or [])
106
+ except Dx5iiError as exc:
107
+ report["ok"] = False
108
+ report["errors"].append(str(exc))
109
+ else:
110
+ start = before.get("config_index")
111
+ if start is None:
112
+ start = dev.current_config_index()
113
+ if start == target:
114
+ report["actions"].append({
115
+ "what": "config", "from": start, "to": target, "changed": False})
116
+ else:
117
+ got = dev.set_config_index(target)
118
+ ok = got == target
119
+ report["changed"] = report["changed"] or ok
120
+ report["ok"] = report["ok"] and ok
121
+ report["actions"].append({
122
+ "what": "config", "from": start, "to": target, "changed": ok,
123
+ "observed": got,
124
+ })
125
+ if not ok:
126
+ report["errors"].append(
127
+ f"config did not change to {target!r} (observed {got!r})")
128
+
129
+ final = dev.snapshot(include_configs=False, keep_raw=False)
130
+ summary = state_summary(final)
131
+ if summary["config_index"] is None:
132
+ try:
133
+ summary["config_index"] = dev.current_config_index()
134
+ except Dx5iiError:
135
+ pass
136
+ if summary["config_index"] is not None:
137
+ summary["config_name"] = known.get(summary["config_index"])
138
+ report["after"] = summary
139
+
140
+ if summary["output"] is None:
141
+ report["ok"] = False
142
+ report["errors"].append("could not read state back after the switch")
143
+ return report
144
+
145
+
146
+ __all__ = ["apply_changes", "state_summary"]
dx5ii/bands.py ADDED
@@ -0,0 +1,218 @@
1
+ """EQ preset blocks: the 78-word payload the device stores per preset.
2
+
3
+ Layout (verified 2026-11-09 against the five factory presets; the
4
+ ``IER-M7.Harman`` block matches ``harman_10band.txt`` exactly):
5
+
6
+ word 0..3 preset name, UTF-8, little-endian, NUL padded
7
+ word 4 enabled flag (1)
8
+ word 5 content hash / revision
9
+ word 6 enabled flag (1)
10
+ word 7 content hash / revision (same as word 5)
11
+ word 8..40 left channel, 11 bands x 3 words
12
+ word 41..73 right channel, 11 bands x 3 words
13
+ word 74..77 zero
14
+
15
+ Each band is three words::
16
+
17
+ packed = enabled | type << 8 | gain_tenth_db << 16 (gain is signed 8-bit)
18
+ freq_hz
19
+ q * 10000
20
+
21
+ ``type`` is 1 = PK, 4 = LS (low shelf), 5 = HS (high shelf); 3 also appears in
22
+ the factory ``SPEAKER`` preset and is kept verbatim. Band 11 is the device's
23
+ spare slot: disabled, 632 Hz, Q 0.707.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import re
29
+ from dataclasses import dataclass
30
+ from typing import Iterable, Sequence
31
+
32
+ from .constants import (
33
+ BAND_TYPE_IDS,
34
+ BAND_TYPES,
35
+ BANDS_PER_CHANNEL,
36
+ BAND_WORDS,
37
+ CONFIG_BLOCK_WORDS,
38
+ CONFIG_HEAD_WORDS,
39
+ )
40
+ from .protocol import decode_name
41
+
42
+ DEFAULT_Q = 0.707
43
+ DEFAULT_FREQ = 632
44
+ MIN_FREQ = 20
45
+ MAX_FREQ = 20000
46
+ MIN_Q = 0.1
47
+ MAX_Q = 15.0
48
+ MAX_GAIN_DB = 12.0
49
+
50
+
51
+ @dataclass
52
+ class Band:
53
+ """One parametric EQ band."""
54
+
55
+ enabled: bool = True
56
+ type: str = "PK"
57
+ gain_db: float = 0.0
58
+ freq_hz: float = DEFAULT_FREQ
59
+ q: float = DEFAULT_Q
60
+
61
+ def __post_init__(self) -> None:
62
+ self.gain_db = max(-MAX_GAIN_DB, min(MAX_GAIN_DB, round(self.gain_db, 1)))
63
+ self.freq_hz = float(max(MIN_FREQ, min(MAX_FREQ, round(self.freq_hz))))
64
+ self.q = max(MIN_Q, min(MAX_Q, round(self.q, 4)))
65
+
66
+ def as_dict(self) -> dict:
67
+ return {
68
+ "enabled": self.enabled,
69
+ "type": self.type,
70
+ "gain_db": self.gain_db,
71
+ "freq_hz": self.freq_hz,
72
+ "q": self.q,
73
+ }
74
+
75
+ def to_ap_filter(self, index: int) -> str:
76
+ """Render as an Equalizer APO line (``PK``/``LSC``/``HSC``)."""
77
+ kind = {"PK": "PK", "LS": "LSC", "HS": "HSC"}.get(self.type, "PK")
78
+ state = "ON" if self.enabled else "OFF"
79
+ return (
80
+ f"Filter {index}: {state} {kind} Fc {self.freq_hz:g} Hz "
81
+ f"Gain {self.gain_db:+g} dB Q {self.q:g}"
82
+ )
83
+
84
+
85
+ SPARE_BAND = Band(enabled=False, type="PK", gain_db=0.0, freq_hz=DEFAULT_FREQ)
86
+ SPARE_WORDS: list[int] = [0x00000100, DEFAULT_FREQ, round(DEFAULT_Q * 10000)]
87
+
88
+
89
+ def _signed_byte(value: int) -> int:
90
+ return value - 256 if value & 0x80 else value
91
+
92
+
93
+ def decode_band(packed: int, freq: int, q: int) -> Band:
94
+ type_id = (packed >> 8) & 0xFF
95
+ return Band(
96
+ enabled=bool(packed & 0xFF),
97
+ type=BAND_TYPES.get(type_id, f"type{type_id}"),
98
+ gain_db=_signed_byte((packed >> 16) & 0xFF) / 10.0,
99
+ freq_hz=float(freq),
100
+ q=q / 10000.0,
101
+ )
102
+
103
+
104
+ def encode_band(band: Band) -> list[int]:
105
+ type_id = BAND_TYPE_IDS.get(band.type, 1)
106
+ gain = max(-128, min(127, int(round(band.gain_db * 10)))) & 0xFF
107
+ packed = (gain << 16) | (type_id << 8) | (1 if band.enabled else 0)
108
+ return [
109
+ packed & 0xFFFFFFFF,
110
+ int(round(band.freq_hz)),
111
+ int(round(band.q * 10000)),
112
+ ]
113
+
114
+
115
+ _APO_FILTER = re.compile(
116
+ r"^Filter\s+\d+\s*:\s*(ON|OFF)\s+(PK|LSC|HSC|LS|HS)\s+"
117
+ r"Fc\s+([0-9.]+)\s*Hz\s+Gain\s+([+-]?[0-9.]+)\s*dB(?:\s+Q\s+([0-9.]+))?",
118
+ re.IGNORECASE,
119
+ )
120
+ _APO_KIND = {"PK": "PK", "LSC": "LS", "LS": "LS", "HSC": "HS", "HS": "HS"}
121
+
122
+
123
+ def parse_apo_filters(text: str) -> list[Band]:
124
+ """Parse ``Filter N: ON PK Fc ... Hz Gain ... dB Q ...`` lines.
125
+
126
+ Shelf filters without an explicit ``Q`` fall back to the device default.
127
+ Unknown filter kinds are skipped rather than guessed.
128
+ """
129
+ bands: list[Band] = []
130
+ for line in text.splitlines():
131
+ match = _APO_FILTER.match(line.strip())
132
+ if not match:
133
+ continue
134
+ state, kind, freq, gain, q = match.groups()
135
+ if kind.upper() not in _APO_KIND:
136
+ continue
137
+ bands.append(Band(
138
+ enabled=state.upper() == "ON",
139
+ type=_APO_KIND[kind.upper()],
140
+ gain_db=float(gain),
141
+ freq_hz=float(freq),
142
+ q=float(q) if q else DEFAULT_Q,
143
+ ))
144
+ return bands
145
+
146
+
147
+ def encode_name_words(name: str) -> list[int]:
148
+ """Encode a preset name as the eight UTF-8 words used by rename/upload."""
149
+ raw = name.encode("utf-8")[:32].ljust(32, b"\x00")
150
+ return [int.from_bytes(raw[i * 4:(i + 1) * 4], "little") for i in range(8)]
151
+
152
+
153
+ def decode_bands(words: Sequence[int], channel: int) -> list[Band]:
154
+ base = CONFIG_HEAD_WORDS + channel * BANDS_PER_CHANNEL * BAND_WORDS
155
+ out: list[Band] = []
156
+ for i in range(BANDS_PER_CHANNEL):
157
+ offset = base + i * BAND_WORDS
158
+ out.append(decode_band(words[offset], words[offset + 1], words[offset + 2]))
159
+ return out
160
+
161
+
162
+ def decode_block(words: Sequence[int]) -> dict:
163
+ """Decode one 78-word preset block."""
164
+ if len(words) < CONFIG_BLOCK_WORDS:
165
+ raise ValueError(f"need {CONFIG_BLOCK_WORDS} words, got {len(words)}")
166
+ return {
167
+ "name": decode_name(list(words[0:4])),
168
+ "enabled": bool(words[4]),
169
+ "hash": words[5],
170
+ "bands_left": decode_bands(words, 0),
171
+ "bands_right": decode_bands(words, 1),
172
+ }
173
+
174
+
175
+ def _pad_bands(bands: Iterable[Band]) -> list[Band]:
176
+ items = list(bands)[:BANDS_PER_CHANNEL]
177
+ while len(items) < BANDS_PER_CHANNEL:
178
+ items.append(SPARE_BAND)
179
+ return items
180
+
181
+
182
+ def encode_block(
183
+ name: str,
184
+ bands_left: Iterable[Band],
185
+ bands_right: Iterable[Band],
186
+ head: Sequence[int] | None = None,
187
+ ) -> list[int]:
188
+ """Build a 78-word preset block ready for ``CMD_DOWNLOAD_CONFIG``.
189
+
190
+ ``head`` supplies words 4..7 (enabled flag + hash, twice). When omitted a
191
+ zero hash is sent; the device fills it in on save.
192
+ """
193
+ words = [0] * CONFIG_BLOCK_WORDS
194
+ raw = name.encode("utf-8")[:16].ljust(16, b"\x00")
195
+ for i in range(4):
196
+ words[i] = int.from_bytes(raw[i * 4:(i + 1) * 4], "little")
197
+
198
+ words[4:8] = list(head)[:4] if head else [1, 0, 1, 0]
199
+
200
+ for channel, bands in enumerate((bands_left, bands_right)):
201
+ base = CONFIG_HEAD_WORDS + channel * BANDS_PER_CHANNEL * BAND_WORDS
202
+ for i, band in enumerate(_pad_bands(bands)):
203
+ words[base + i * BAND_WORDS: base + (i + 1) * BAND_WORDS] = encode_band(band)
204
+ return words
205
+
206
+
207
+ __all__ = [
208
+ "Band",
209
+ "SPARE_BAND",
210
+ "SPARE_WORDS",
211
+ "decode_band",
212
+ "decode_bands",
213
+ "decode_block",
214
+ "encode_band",
215
+ "encode_block",
216
+ "encode_name_words",
217
+ "parse_apo_filters",
218
+ ]