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 +114 -0
- dx5ii/__main__.py +10 -0
- dx5ii/actions.py +146 -0
- dx5ii/bands.py +218 -0
- dx5ii/cli.py +579 -0
- dx5ii/constants.py +270 -0
- dx5ii/device.py +296 -0
- dx5ii/errors.py +10 -0
- dx5ii/fields.py +369 -0
- dx5ii/formatting.py +147 -0
- dx5ii/interpreter.py +261 -0
- dx5ii/peq.py +108 -0
- dx5ii/protocol.py +153 -0
- dx5ii/py.typed +0 -0
- dx5ii_cli-0.0.1.dist-info/METADATA +237 -0
- dx5ii_cli-0.0.1.dist-info/RECORD +20 -0
- dx5ii_cli-0.0.1.dist-info/WHEEL +5 -0
- dx5ii_cli-0.0.1.dist-info/entry_points.txt +2 -0
- dx5ii_cli-0.0.1.dist-info/licenses/LICENSE +21 -0
- dx5ii_cli-0.0.1.dist-info/top_level.txt +1 -0
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
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
|
+
]
|