vinsynlib 0.2.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.
- vinsynlib/__init__.py +107 -0
- vinsynlib/cli.py +256 -0
- vinsynlib/config.py +454 -0
- vinsynlib/conformance.py +274 -0
- vinsynlib/devchecks.py +148 -0
- vinsynlib/favorites.py +718 -0
- vinsynlib/keys.py +267 -0
- vinsynlib/midi.py +329 -0
- vinsynlib/py.typed +0 -0
- vinsynlib/spec.py +341 -0
- vinsynlib/terms.py +234 -0
- vinsynlib/ui/__init__.py +24 -0
- vinsynlib/ui/hints.py +84 -0
- vinsynlib-0.2.0.dist-info/METADATA +284 -0
- vinsynlib-0.2.0.dist-info/RECORD +19 -0
- vinsynlib-0.2.0.dist-info/WHEEL +5 -0
- vinsynlib-0.2.0.dist-info/licenses/COPYING +339 -0
- vinsynlib-0.2.0.dist-info/licenses/LICENSE +58 -0
- vinsynlib-0.2.0.dist-info/top_level.txt +1 -0
vinsynlib/config.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (C) 2026 vinsynlib contributors
|
|
3
|
+
#
|
|
4
|
+
# This file is part of vinsynlib.
|
|
5
|
+
#
|
|
6
|
+
# Assembled from the settings-cache handling that every browser in the
|
|
7
|
+
# family carried its own copy of:
|
|
8
|
+
# Copyright (C) 2026 emorphed contributors - GPL-2.0-or-later
|
|
9
|
+
# Copyright (C) 2026 ensqsqed contributors - GPL-2.0-or-later
|
|
10
|
+
# Copyright (C) 2026 eosed contributors - GPL-2.0-or-later
|
|
11
|
+
# Copyright (C) 2026 kwsed contributors - GPL-2.0-or-later
|
|
12
|
+
# Copyright (C) 2026 nanosyned contributors - GPL-2.0-or-later
|
|
13
|
+
# Copyright (C) 2026 p2ked contributors - GPL-2.0-or-later
|
|
14
|
+
# Copyright (C) 2026 rxved contributors - GPL-2.0-or-later
|
|
15
|
+
# Copyright (C) 2026 s3ked contributors - GPL-2.0-or-later
|
|
16
|
+
# Copyright (C) 2026 x5ded contributors - GPL-2.0-or-later
|
|
17
|
+
#
|
|
18
|
+
# vinsynlib is free software: you can redistribute it and/or modify it under
|
|
19
|
+
# the terms of the GNU General Public License as published by the Free
|
|
20
|
+
# Software Foundation, either version 2 of the License, or (at your option)
|
|
21
|
+
# any later version.
|
|
22
|
+
#
|
|
23
|
+
# vinsynlib is distributed in the hope that it will be useful, but WITHOUT
|
|
24
|
+
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
25
|
+
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
|
26
|
+
# more details.
|
|
27
|
+
|
|
28
|
+
"""The local settings cache: which port answered, and which channel.
|
|
29
|
+
|
|
30
|
+
**Disposable on purpose.** Unlike the favourites database, nothing here is
|
|
31
|
+
the user's own work -- it is a note of what was true last time, so that
|
|
32
|
+
starting the program again does not mean setting the same things again.
|
|
33
|
+
Deleting it costs one re-entry of each. That is why it lives in the working
|
|
34
|
+
directory rather than the data directory, why every failure to write it is
|
|
35
|
+
swallowed, and why it is gitignored.
|
|
36
|
+
|
|
37
|
+
Only :class:`OSError` is swallowed, mind: a read-only directory or a full
|
|
38
|
+
disk, where forgetting a preference beats refusing to run. Anything else is
|
|
39
|
+
a bug and should be heard. rxved's first attempt at remembering a channel
|
|
40
|
+
caught everything, and so never noticed that it was raising ``NameError``
|
|
41
|
+
on every single call and writing nothing at all.
|
|
42
|
+
|
|
43
|
+
One :class:`Settings` per application. The application name is only used in
|
|
44
|
+
the one warning message, but it is required, because that message has to
|
|
45
|
+
say which of the family is refusing to save -- otherwise a user with six of
|
|
46
|
+
these open has no way to tell which one is unhappy.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
import contextlib
|
|
52
|
+
import os
|
|
53
|
+
import sys
|
|
54
|
+
from dataclasses import dataclass, field
|
|
55
|
+
from typing import Any
|
|
56
|
+
|
|
57
|
+
# Every project in the family requires 3.11 or later, and this library
|
|
58
|
+
# only ever imports under a newer interpreter than that, so the fallback
|
|
59
|
+
# below is unreachable in practice and is not tested. It is here because
|
|
60
|
+
# two of the copies it replaces had it and a reader would wonder where it
|
|
61
|
+
# went.
|
|
62
|
+
tomllib: Any
|
|
63
|
+
try:
|
|
64
|
+
import tomllib
|
|
65
|
+
except ModuleNotFoundError: # pragma: no cover - Python < 3.11
|
|
66
|
+
tomllib = None
|
|
67
|
+
|
|
68
|
+
__all__ = [
|
|
69
|
+
"DEFAULT_PATH",
|
|
70
|
+
"Settings",
|
|
71
|
+
"bind",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
#: Where the cache lives unless told otherwise. Relative on purpose: it is a
|
|
75
|
+
#: per-checkout note, gitignored, and one checkout per checkout.
|
|
76
|
+
DEFAULT_PATH = "config.toml"
|
|
77
|
+
|
|
78
|
+
#: MIDI has sixteen channels, zero-based on the wire and one-based on every
|
|
79
|
+
#: command line in this family. Both numbers are named because confusing
|
|
80
|
+
#: them costs a channel with no error anywhere.
|
|
81
|
+
MIDI_CHANNELS = 16
|
|
82
|
+
MAX_MIDI_CHANNEL = MIDI_CHANNELS - 1
|
|
83
|
+
|
|
84
|
+
#: A SysEx device ID is a byte. 127 is broadcast in some protocols and a
|
|
85
|
+
#: real device in others, which is why reading one back is range-checked
|
|
86
|
+
#: against a number rather than trusted.
|
|
87
|
+
MAX_DEVICE_ID = 127
|
|
88
|
+
|
|
89
|
+
#: DEL. The one control character with no TOML escape of its own.
|
|
90
|
+
DEL = 0x7F
|
|
91
|
+
|
|
92
|
+
_ESCAPES = {
|
|
93
|
+
"\\": "\\\\",
|
|
94
|
+
'"': '\\"',
|
|
95
|
+
"\n": "\\n",
|
|
96
|
+
"\r": "\\r",
|
|
97
|
+
"\t": "\\t",
|
|
98
|
+
"\b": "\\b",
|
|
99
|
+
"\f": "\\f",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#: The end of the first line written by :meth:`Settings.update`, after the
|
|
103
|
+
#: application's name. One string, because two things now read it: the writer
|
|
104
|
+
#: that puts it there, and the reader that refuses to treat another
|
|
105
|
+
#: application's file as its own. The prefix is deliberately loose -- a build
|
|
106
|
+
#: before this one wrote an em dash where this writes a hyphen, and a file
|
|
107
|
+
#: that old still has to read as ours rather than as a stranger's.
|
|
108
|
+
_HEADER = " local config - gitignored, safe to delete."
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _owner(first_line: str | None) -> str | None:
|
|
112
|
+
"""The application named in a settings file's first line, or ``None``.
|
|
113
|
+
|
|
114
|
+
``None`` means "not a family header": a hand-written file, an old build's
|
|
115
|
+
em dash, or no comment at all. Only a file that names *another* tool is
|
|
116
|
+
treated as foreign, so an unrecognised header stays the caller's own and
|
|
117
|
+
the lenient upgrade path keeps working.
|
|
118
|
+
"""
|
|
119
|
+
if first_line is None:
|
|
120
|
+
return None
|
|
121
|
+
line = first_line.strip()
|
|
122
|
+
if not line.startswith("#"):
|
|
123
|
+
return None
|
|
124
|
+
body = line[1:].strip()
|
|
125
|
+
if not body.endswith(_HEADER):
|
|
126
|
+
return None
|
|
127
|
+
name = body[: -len(_HEADER)].strip()
|
|
128
|
+
return name or None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class Settings:
|
|
133
|
+
"""One application's settings cache.
|
|
134
|
+
|
|
135
|
+
Construct once per process with the application's name, then call the
|
|
136
|
+
methods. Every method takes an optional ``path`` so a test can hand in
|
|
137
|
+
a temporary file; the default is this application's :data:`DEFAULT_PATH`.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
app_name: str
|
|
141
|
+
default_path: str = DEFAULT_PATH
|
|
142
|
+
#: Which refusals have already been announced, so a launch prints each
|
|
143
|
+
#: at most once. Module level in the per-project copies this replaces,
|
|
144
|
+
#: which is to say it was shared by every ``Settings`` in the process;
|
|
145
|
+
#: per instance is closer to the intent and behaves the same for a
|
|
146
|
+
#: single application. A set rather than a bool because there are now
|
|
147
|
+
#: three reasons to refuse to write, and one must not silence another.
|
|
148
|
+
_warned: set[str] = field(default_factory=set, init=False, repr=False)
|
|
149
|
+
|
|
150
|
+
# --- reading -------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def read(self, path: str | None = None) -> tuple[dict[str, Any], str]:
|
|
153
|
+
"""The file's keys, and ``"ok"`` or ``"unreadable"``.
|
|
154
|
+
|
|
155
|
+
A file that does not exist reads as empty and is not an error: the
|
|
156
|
+
first run of a program has no settings yet, and that is the normal
|
|
157
|
+
case rather than a fault to report.
|
|
158
|
+
|
|
159
|
+
A file that exists but cannot be parsed reports ``"unreadable"``,
|
|
160
|
+
which :meth:`update` turns into a refusal rather than a blind
|
|
161
|
+
overwrite. Collapsing the two cases is what made a whole class of
|
|
162
|
+
bug invisible, so they are kept apart.
|
|
163
|
+
|
|
164
|
+
**Decoded leniently, then parsed as TOML.** Three of the copies this
|
|
165
|
+
replaces decoded the bytes themselves and fell back to cp1252 for
|
|
166
|
+
anything that was not valid UTF-8, and dropping that on the way into
|
|
167
|
+
the library was a regression rather than a simplification. The cause
|
|
168
|
+
was real: a writer that opened the file in text mode with no
|
|
169
|
+
``encoding=`` used the locale codec, which on Windows is cp1252, and
|
|
170
|
+
the em dash in the writer's own header comment then landed as a byte
|
|
171
|
+
``tomllib`` rejects. The whole file was refused, every setting in it
|
|
172
|
+
silently read back as unset, and -- because refusing to overwrite an
|
|
173
|
+
unparseable file is itself correct -- the settings cache could not
|
|
174
|
+
heal until somebody deleted it by hand.
|
|
175
|
+
|
|
176
|
+
Fixing the writer removed that one cause. It did not remove the
|
|
177
|
+
upgrade path: somebody who ran the broken build still has the file
|
|
178
|
+
it wrote. Decoding leniently lets their hand-edited keys survive, and
|
|
179
|
+
the next save rewrites the file as UTF-8, so it stays readable from
|
|
180
|
+
then on.
|
|
181
|
+
|
|
182
|
+
**A file that belongs to another tool reads as empty.** Nine of
|
|
183
|
+
these tools default to the same relative ``config.toml`` in whatever
|
|
184
|
+
directory they are launched from. When two of them meet in one
|
|
185
|
+
directory, the second must not read the first's remembered port: on
|
|
186
|
+
a bench that means probing the wrong instrument, and for the two
|
|
187
|
+
tools whose hardware shares a manufacturer it means a reply that
|
|
188
|
+
looks right. The file's own header says whose it is; see
|
|
189
|
+
:func:`_owner`.
|
|
190
|
+
"""
|
|
191
|
+
target = path or self.default_path
|
|
192
|
+
data, status, first_line = self._read_document(target)
|
|
193
|
+
if status != "ok":
|
|
194
|
+
return data, status
|
|
195
|
+
owner = _owner(first_line)
|
|
196
|
+
if owner is not None and owner != self.app_name:
|
|
197
|
+
return {}, "ok"
|
|
198
|
+
return data, "ok"
|
|
199
|
+
|
|
200
|
+
def _read_document(
|
|
201
|
+
self, target: str
|
|
202
|
+
) -> tuple[dict[str, Any], str, str | None]:
|
|
203
|
+
"""The parsed file, its status, and its first line.
|
|
204
|
+
|
|
205
|
+
The unfiltered form of :meth:`read`: :meth:`update` needs the header
|
|
206
|
+
and any foreign content to decide whether writing is safe, while
|
|
207
|
+
callers of :meth:`read` only ever want this application's keys.
|
|
208
|
+
"""
|
|
209
|
+
if not os.path.exists(target) or tomllib is None:
|
|
210
|
+
return {}, "ok", None
|
|
211
|
+
try:
|
|
212
|
+
with open(target, "rb") as handle:
|
|
213
|
+
raw = handle.read()
|
|
214
|
+
except OSError:
|
|
215
|
+
return {}, "unreadable", None
|
|
216
|
+
try:
|
|
217
|
+
text = raw.decode("utf-8")
|
|
218
|
+
except UnicodeDecodeError:
|
|
219
|
+
# Not valid UTF-8, so it was almost certainly written by a build
|
|
220
|
+
# using the locale codec. Decoded leniently so a user's
|
|
221
|
+
# hand-edited keys survive the upgrade; the next save repairs the
|
|
222
|
+
# encoding for good.
|
|
223
|
+
text = raw.decode("cp1252", errors="replace")
|
|
224
|
+
try:
|
|
225
|
+
data = tomllib.loads(text)
|
|
226
|
+
except ValueError:
|
|
227
|
+
return {}, "unreadable", None
|
|
228
|
+
first_line = text.split("\n", 1)[0]
|
|
229
|
+
return data, "ok", first_line
|
|
230
|
+
|
|
231
|
+
# --- writing -------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
def update(self, path: str | None = None, **changes: Any) -> None:
|
|
234
|
+
"""Merge ``changes`` into the file, rewriting what is there.
|
|
235
|
+
|
|
236
|
+
Refuses, and says so once, in three cases, because in all three the
|
|
237
|
+
alternative is destroying something this tool did not write:
|
|
238
|
+
|
|
239
|
+
* the file cannot be parsed -- the refusal that is also why the
|
|
240
|
+
escaping below matters (a port name is an arbitrary string, and a
|
|
241
|
+
quote in one would otherwise produce a file that never heals);
|
|
242
|
+
* the file's header names a **different** tool in the family -- two
|
|
243
|
+
of these in one directory would otherwise overwrite each other's
|
|
244
|
+
remembered port on every launch, for ever;
|
|
245
|
+
* the file holds tables or lists -- this tool writes only scalars,
|
|
246
|
+
so anything nested belongs to somebody else (a foreign project's
|
|
247
|
+
``config.toml`` looks exactly like this) and the old writer turned
|
|
248
|
+
it into Python ``repr`` and destroyed the file.
|
|
249
|
+
|
|
250
|
+
The write itself is atomic: a temporary file beside the target, then
|
|
251
|
+
``os.replace``. The old writer truncated the target and refilled it,
|
|
252
|
+
so a reader running at the same time could see half a file. Only
|
|
253
|
+
:class:`OSError` is swallowed, as everywhere here.
|
|
254
|
+
|
|
255
|
+
To unset a key, set its value to ``None``.
|
|
256
|
+
"""
|
|
257
|
+
target = path or self.default_path
|
|
258
|
+
data, status, first_line = self._read_document(target)
|
|
259
|
+
if status == "unreadable":
|
|
260
|
+
self._warn_once(
|
|
261
|
+
"unreadable",
|
|
262
|
+
f"{self.app_name}: {target} could not be parsed, so "
|
|
263
|
+
f"settings are not being saved. Fix or delete it; "
|
|
264
|
+
f"nothing has been overwritten.",
|
|
265
|
+
)
|
|
266
|
+
return
|
|
267
|
+
owner = _owner(first_line)
|
|
268
|
+
if owner is not None and owner != self.app_name:
|
|
269
|
+
self._warn_once(
|
|
270
|
+
"foreign",
|
|
271
|
+
f"{self.app_name}: {target} is {owner}'s settings file, so "
|
|
272
|
+
f"{self.app_name} is leaving it alone. Use --config to name "
|
|
273
|
+
f"a path of its own.",
|
|
274
|
+
)
|
|
275
|
+
return
|
|
276
|
+
if any(isinstance(value, (dict, list)) for value in data.values()):
|
|
277
|
+
self._warn_once(
|
|
278
|
+
"complex",
|
|
279
|
+
f"{self.app_name}: {target} holds tables or lists, which "
|
|
280
|
+
f"{self.app_name} does not write, so it is leaving it alone. "
|
|
281
|
+
f"Use --config to name a path of its own.",
|
|
282
|
+
)
|
|
283
|
+
return
|
|
284
|
+
for key, value in changes.items():
|
|
285
|
+
if value is None:
|
|
286
|
+
data.pop(key, None) # Remove key if present
|
|
287
|
+
else:
|
|
288
|
+
data[key] = value
|
|
289
|
+
lines = [f"# {self.app_name}{_HEADER}"]
|
|
290
|
+
for key, value in data.items():
|
|
291
|
+
if isinstance(value, bool):
|
|
292
|
+
lines.append(f"{key} = {'true' if value else 'false'}")
|
|
293
|
+
elif isinstance(value, str):
|
|
294
|
+
lines.append(f"{key} = {self._toml_string(value)}")
|
|
295
|
+
else:
|
|
296
|
+
lines.append(f"{key} = {value}")
|
|
297
|
+
self._write_atomic(target, "\n".join(lines) + "\n")
|
|
298
|
+
|
|
299
|
+
def _warn_once(self, reason: str, message: str) -> None:
|
|
300
|
+
"""Print ``message`` once per reason, per instance."""
|
|
301
|
+
if reason in self._warned:
|
|
302
|
+
return
|
|
303
|
+
self._warned.add(reason)
|
|
304
|
+
print(message, file=sys.stderr)
|
|
305
|
+
|
|
306
|
+
@staticmethod
|
|
307
|
+
def _write_atomic(target: str, text: str) -> None:
|
|
308
|
+
"""Write ``text`` to ``target`` through a temporary beside it.
|
|
309
|
+
|
|
310
|
+
``encoding=`` is not optional: without it Python uses the locale
|
|
311
|
+
codec, and TOML is UTF-8 by spec. Both ends must say so.
|
|
312
|
+
"""
|
|
313
|
+
tmp = f"{target}.{os.getpid()}.tmp"
|
|
314
|
+
try:
|
|
315
|
+
with open(tmp, "w", encoding="utf-8") as handle:
|
|
316
|
+
handle.write(text)
|
|
317
|
+
os.replace(tmp, target)
|
|
318
|
+
except OSError:
|
|
319
|
+
with contextlib.suppress(OSError):
|
|
320
|
+
os.unlink(tmp)
|
|
321
|
+
|
|
322
|
+
@staticmethod
|
|
323
|
+
def _toml_string(value: str) -> str:
|
|
324
|
+
"""One TOML basic string, escaped.
|
|
325
|
+
|
|
326
|
+
TOML basic strings interpret the usual backslash escapes as well,
|
|
327
|
+
so a literal tab or newline is written as an escape rather than
|
|
328
|
+
embedded, and a control character that has no escape becomes a
|
|
329
|
+
``\\uXXXX``.
|
|
330
|
+
"""
|
|
331
|
+
out = ['"']
|
|
332
|
+
for char in value:
|
|
333
|
+
if char in _ESCAPES:
|
|
334
|
+
out.append(_ESCAPES[char])
|
|
335
|
+
elif ord(char) < 0x20 or ord(char) == DEL:
|
|
336
|
+
out.append(f"\\u{ord(char):04X}")
|
|
337
|
+
else:
|
|
338
|
+
out.append(char)
|
|
339
|
+
out.append('"')
|
|
340
|
+
return "".join(out)
|
|
341
|
+
|
|
342
|
+
# --- channel -------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
def load_channel(self, path: str | None = None) -> int | None:
|
|
345
|
+
"""The channel last sent on, zero-based, or None.
|
|
346
|
+
|
|
347
|
+
The bool check is not decoration. ``isinstance(True, int)`` is True
|
|
348
|
+
and ``0 <= True <= 15`` is true, so a hand-edited ``channel = true``
|
|
349
|
+
used to come back as ``True`` and pass every range check downstream
|
|
350
|
+
-- and then ``0xC0 | True`` is ``0xC1``, which is MIDI **channel
|
|
351
|
+
2**. A wrong channel is not an error anywhere: the instrument
|
|
352
|
+
simply plays nothing, or something else does.
|
|
353
|
+
"""
|
|
354
|
+
value = self.read(path)[0].get("channel")
|
|
355
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
356
|
+
return None
|
|
357
|
+
return value if 0 <= value <= MAX_MIDI_CHANNEL else None
|
|
358
|
+
|
|
359
|
+
def save_channel(self, channel: int, path: str | None = None) -> None:
|
|
360
|
+
self.update(path, channel=int(channel))
|
|
361
|
+
|
|
362
|
+
# --- ports ---------------------------------------------------------------
|
|
363
|
+
|
|
364
|
+
def load_port(self, path: str | None = None) -> str | None:
|
|
365
|
+
"""The port name last used for output."""
|
|
366
|
+
value = self.read(path)[0].get("port")
|
|
367
|
+
return value if isinstance(value, str) and value else None
|
|
368
|
+
|
|
369
|
+
def save_port(self, port: str, path: str | None = None) -> None:
|
|
370
|
+
self.update(path, port=str(port))
|
|
371
|
+
|
|
372
|
+
def load_recv_port(self, path: str | None = None) -> str | None:
|
|
373
|
+
"""The input port last opened, when it differed from the output one.
|
|
374
|
+
|
|
375
|
+
A separate key rather than a second port: most of these instruments
|
|
376
|
+
take their input from the same USB port they output to, and a
|
|
377
|
+
second key that is empty most of the time is the cheapest way to
|
|
378
|
+
remember the case where it is not.
|
|
379
|
+
"""
|
|
380
|
+
value = self.read(path)[0].get("recv_port")
|
|
381
|
+
return value if isinstance(value, str) and value else None
|
|
382
|
+
|
|
383
|
+
def save_recv_port(self, port: str, path: str | None = None) -> None:
|
|
384
|
+
self.update(path, recv_port=str(port))
|
|
385
|
+
|
|
386
|
+
def load_ports(self, path: str | None = None) -> tuple[str, str] | None:
|
|
387
|
+
"""Both remembered ports as a pair, or None.
|
|
388
|
+
|
|
389
|
+
Only when *both* are known. A half-remembered pair is not a pair:
|
|
390
|
+
three of the tools in this family open one port and read the other
|
|
391
|
+
from the same name, and treat an output-only or input-only memory
|
|
392
|
+
as nothing remembered at all rather than as half of what they need.
|
|
393
|
+
"""
|
|
394
|
+
data = self.read(path)[0]
|
|
395
|
+
send = data.get("port")
|
|
396
|
+
recv = data.get("recv_port")
|
|
397
|
+
if isinstance(send, str) and send and isinstance(recv, str) and recv:
|
|
398
|
+
return send, recv
|
|
399
|
+
return None
|
|
400
|
+
|
|
401
|
+
def save_ports(
|
|
402
|
+
self, send_port: str, recv_port: str, path: str | None = None
|
|
403
|
+
) -> None:
|
|
404
|
+
"""Remember both ports at once, in one write to the file.
|
|
405
|
+
|
|
406
|
+
One write rather than two because the file is rewritten whole every
|
|
407
|
+
time: writing ``port`` and then ``recv_port`` separately reads and
|
|
408
|
+
rewrites between them, and a failure in between leaves the first
|
|
409
|
+
saved and the second lost.
|
|
410
|
+
"""
|
|
411
|
+
self.update(path, port=str(send_port), recv_port=str(recv_port))
|
|
412
|
+
|
|
413
|
+
# --- device id -----------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
def load_device_id(
|
|
416
|
+
self,
|
|
417
|
+
path: str | None = None,
|
|
418
|
+
*,
|
|
419
|
+
minimum: int = 0,
|
|
420
|
+
maximum: int = MAX_DEVICE_ID,
|
|
421
|
+
) -> int | None:
|
|
422
|
+
"""The device ID last used, or None.
|
|
423
|
+
|
|
424
|
+
``isinstance(True, int)`` is True, so a hand-edited
|
|
425
|
+
``device_id = true`` would come back as device 1 -- a real device,
|
|
426
|
+
and the wrong one. A wrong device ID is answered with silence by
|
|
427
|
+
these instruments, which is the failure mode that hides behind
|
|
428
|
+
every other.
|
|
429
|
+
|
|
430
|
+
``minimum``/``maximum`` because a device ID is not one range
|
|
431
|
+
everywhere. It is a byte for most of these instruments, 0-127, and
|
|
432
|
+
on a Roland XV-2020 the synth *displays* it as the panel number
|
|
433
|
+
17-32, which is the wire byte 0x10-0x1F plus one. That tool
|
|
434
|
+
therefore checks its own range, and it is right to: a value of 5
|
|
435
|
+
there is not a device ID that happens to be unused, it is a value
|
|
436
|
+
its own front panel cannot show, so returning it would hand back
|
|
437
|
+
something the user cannot confirm on the hardware.
|
|
438
|
+
"""
|
|
439
|
+
value = self.read(path)[0].get("device_id")
|
|
440
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
441
|
+
return None
|
|
442
|
+
return value if minimum <= value <= maximum else None
|
|
443
|
+
|
|
444
|
+
def save_device_id(self, device_id: int, path: str | None = None) -> None:
|
|
445
|
+
self.update(path, device_id=int(device_id))
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def bind(app_name: str, default_path: str = DEFAULT_PATH) -> Settings:
|
|
449
|
+
"""A :class:`Settings` for one application.
|
|
450
|
+
|
|
451
|
+
The shape every project used to hand-roll: one settings object, named
|
|
452
|
+
after the tool, with the tool's name in its one warning message.
|
|
453
|
+
"""
|
|
454
|
+
return Settings(app_name=app_name, default_path=default_path)
|