jkctl 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. jkctl/__init__.py +64 -0
  2. jkctl/__main__.py +5 -0
  3. jkctl/aes.py +359 -0
  4. jkctl/cli/__init__.py +58 -0
  5. jkctl/cli/commands/__init__.py +51 -0
  6. jkctl/cli/commands/config.py +100 -0
  7. jkctl/cli/commands/controls.py +278 -0
  8. jkctl/cli/commands/devices.py +211 -0
  9. jkctl/cli/commands/diag.py +136 -0
  10. jkctl/cli/commands/firmware.py +357 -0
  11. jkctl/cli/commands/history.py +134 -0
  12. jkctl/cli/commands/protocols.py +264 -0
  13. jkctl/cli/commands/registers.py +171 -0
  14. jkctl/cli/commands/settings.py +274 -0
  15. jkctl/cli/commands/status.py +338 -0
  16. jkctl/cli/commands/ui.py +240 -0
  17. jkctl/cli/exits.py +38 -0
  18. jkctl/cli/fanout.py +57 -0
  19. jkctl/cli/main.py +68 -0
  20. jkctl/cli/parser.py +136 -0
  21. jkctl/cli/report.py +66 -0
  22. jkctl/cli/target.py +191 -0
  23. jkctl/config.py +210 -0
  24. jkctl/controls.py +201 -0
  25. jkctl/device.py +439 -0
  26. jkctl/doctor.py +366 -0
  27. jkctl/errors.py +32 -0
  28. jkctl/firmware.py +363 -0
  29. jkctl/history.py +219 -0
  30. jkctl/identity.py +161 -0
  31. jkctl/logcodes.json +92 -0
  32. jkctl/logcodes.py +74 -0
  33. jkctl/modbus.py +630 -0
  34. jkctl/names.py +300 -0
  35. jkctl/probe.py +457 -0
  36. jkctl/protocol.py +353 -0
  37. jkctl/protocol_en.json +1 -0
  38. jkctl/protocol_zh.json +1 -0
  39. jkctl/protocols.json +299 -0
  40. jkctl/registers.py +553 -0
  41. jkctl/runtime.py +278 -0
  42. jkctl/settings.py +303 -0
  43. jkctl/simulator.py +557 -0
  44. jkctl/tracing.py +200 -0
  45. jkctl/upgrade.py +149 -0
  46. jkctl/values.py +293 -0
  47. jkctl/web/__init__.py +27 -0
  48. jkctl/web/api.py +992 -0
  49. jkctl/web/schema.py +408 -0
  50. jkctl/web/server.py +112 -0
  51. jkctl/web/session.py +449 -0
  52. jkctl/web/static/app.css +423 -0
  53. jkctl/web/static/index.html +30 -0
  54. jkctl/web/static/js/api.js +55 -0
  55. jkctl/web/static/js/app.js +1199 -0
  56. jkctl/web/static/js/bands.js +380 -0
  57. jkctl/web/static/js/bank.js +223 -0
  58. jkctl/web/static/js/chart.js +299 -0
  59. jkctl/web/static/js/dashboard.js +433 -0
  60. jkctl/web/static/js/firmware.js +273 -0
  61. jkctl/web/static/js/history.js +134 -0
  62. jkctl/web/static/js/panels.js +176 -0
  63. jkctl/web/static/js/ports.js +145 -0
  64. jkctl/web/static/js/registers.js +144 -0
  65. jkctl/web/static/js/settings.js +360 -0
  66. jkctl/web/static/js/tools.js +510 -0
  67. jkctl-0.1.0.dist-info/METADATA +278 -0
  68. jkctl-0.1.0.dist-info/RECORD +72 -0
  69. jkctl-0.1.0.dist-info/WHEEL +4 -0
  70. jkctl-0.1.0.dist-info/entry_points.txt +2 -0
  71. jkctl-0.1.0.dist-info/licenses/LICENSE +287 -0
  72. jkctl-0.1.0.dist-info/licenses/NOTICE +34 -0
jkctl/__init__.py ADDED
@@ -0,0 +1,64 @@
1
+ """jkctl -- a cross-platform command-line tool for JK BMS battery management systems.
2
+
3
+ Everything here was derived from JK BMS Monitor 3.11.0 (the vendor's Windows
4
+ application), the encrypted protocol datasource shipped with it, and JK's own
5
+ RS485 Modbus register-map documents. The reverse-engineering record lives in
6
+ ``research/windows/windows-findings.md``; ``docs/how-it-works.md`` is the
7
+ readable summary.
8
+
9
+ The package is one module per subject, and none of them prints:
10
+
11
+ * :mod:`jkctl.modbus` -- the Modbus RTU link and the JK register bases;
12
+ * :mod:`jkctl.protocol` -- the frame/field layout, from the datasource;
13
+ * :mod:`jkctl.registers` -- the catalog: which field lives where, and whether
14
+ it may be written;
15
+ * :mod:`jkctl.values` -- turning typed input into wire values and back;
16
+ * :mod:`jkctl.device` -- one BMS on the bus, read and written by field name;
17
+ * :mod:`jkctl.identity` / :mod:`jkctl.runtime` / :mod:`jkctl.settings` --
18
+ a snapshot of each of the three readable tables;
19
+ * :mod:`jkctl.controls` -- the write-only action registers;
20
+ * :mod:`jkctl.firmware` / :mod:`jkctl.upgrade` -- the ``.jkbms`` container and
21
+ the XMODEM transfer that flashes it;
22
+ * :mod:`jkctl.aes` -- AES-256-CBC with no package to install;
23
+ * :mod:`jkctl.probe` -- read-only bus reconnaissance;
24
+ * :mod:`jkctl.simulator` -- a fake BMS on a serial port, for testing;
25
+ * :mod:`jkctl.config` -- the optional TOML settings file.
26
+
27
+ What is not specific to a BMS lives in ``devicectl-core`` and is shared with
28
+ the other programs of this shape: where slow work reports to
29
+ (:mod:`devicectl.report`, :mod:`devicectl.progress`), the subcommand table
30
+ (:mod:`devicectl.cli.command`), the event broadcaster
31
+ (:mod:`devicectl.web.events`) and the HTTP primitives
32
+ (:mod:`devicectl.web.http`).
33
+
34
+ Printing, prompting and exit codes belong to :mod:`jkctl.cli` alone.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ __version__ = "0.1.0"
40
+
41
+ from jkctl.device import Device
42
+ from jkctl.errors import JkError
43
+ from jkctl.firmware import Firmware, FirmwareError
44
+ from jkctl.modbus import Bus, ModbusError
45
+ from jkctl.protocol import Protocol
46
+ from jkctl.registers import Action, Catalog, Register
47
+ from jkctl.upgrade import UpgradeError
48
+ from jkctl.values import RegisterValueError
49
+
50
+ __all__ = [
51
+ "Action",
52
+ "Bus",
53
+ "Catalog",
54
+ "Device",
55
+ "Firmware",
56
+ "FirmwareError",
57
+ "JkError",
58
+ "ModbusError",
59
+ "Protocol",
60
+ "Register",
61
+ "RegisterValueError",
62
+ "UpgradeError",
63
+ "__version__",
64
+ ]
jkctl/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Entry point for ``python -m jkctl``."""
2
+
3
+ from jkctl.cli import main
4
+
5
+ raise SystemExit(main())
jkctl/aes.py ADDED
@@ -0,0 +1,359 @@
1
+ """AES-256-CBC for the .jkbms / .jsonds containers, with nothing to install.
2
+
3
+ The Windows app uses AES-256-CBC (IV = all zero) over the container payload.
4
+ Decrypting it is the only cryptographic operation the tool performs, and it runs
5
+ exactly once per firmware file, on a blob of at most 20 MiB. So a pure-Python
6
+ implementation is fast enough, and shipping one means jkctl imports and
7
+ runs on any architecture -- notably aarch64 -- without needing pycrypto (which
8
+ has no prebuilt aarch64 wheel and requires a C toolchain to build).
9
+
10
+ ``decrypt_cbc`` prefers a fast native backend when one is available and silently
11
+ falls back to the bundled pure-Python cipher otherwise:
12
+
13
+ 1. PyCryptodome / PyCrypto (``from Crypto.Cipher import AES``)
14
+ 2. cryptography (OpenSSL, via its Python wheel)
15
+ 3. the system libcrypto (OpenSSL, via stdlib ctypes -- no install)
16
+ 4. the pure-Python AES below
17
+
18
+ Backend 3 is the important one for small/weak/exotic hosts (e.g. a 32-bit
19
+ armv7l Raspberry Pi): OpenSSL's ``libcrypto`` is already present on virtually
20
+ every system, and ctypes is in the standard library, so it gives C-speed AES
21
+ with nothing to install and no compiler -- whereas ``cryptography`` needs Rust
22
+ and ``pycryptodome`` a C toolchain when no wheel exists for the architecture.
23
+
24
+ All backends are byte-for-byte identical; ``backend_name()`` reports which is in
25
+ use, and the self-test at the bottom checks the fallbacks against FIPS-197.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ # --------------------------------------------------------------------------- #
31
+ # Pure-Python AES-256 (decrypt path only -- that is all the tool needs) #
32
+ # --------------------------------------------------------------------------- #
33
+
34
+ # AES-256 with a 16-byte block: the key is 32 bytes and the schedule works in
35
+ # four-byte words.
36
+ KEY_BYTES = 32
37
+ BLOCK_BYTES = 16
38
+ WORD = 4
39
+
40
+ _SBOX = bytes.fromhex(
41
+ "637c777bf26b6fc53001672bfed7ab76ca82c97dfa5947f0add4a2af9ca472c0"
42
+ "b7fd9326363ff7cc34a5e5f171d8311504c723c31896059a071280e2eb27b275"
43
+ "09832c1a1b6e5aa0523bd6b329e32f8453d100ed20fcb15b6acbbe394a4c58cf"
44
+ "d0efaafb434d338545f9027f503c9fa851a3408f929d38f5bcb6da2110fff3d2"
45
+ "cd0c13ec5f974417c4a77e3d645d197360814fdc222a908846eeb814de5e0bdb"
46
+ "e0323a0a4906245cc2d3ac629195e479e7c8376d8dd54ea96c56f4ea657aae08"
47
+ "ba78252e1ca6b4c6e8dd741f4bbd8b8a703eb5664803f60e613557b986c11d9e"
48
+ "e1f8981169d98e949b1e87e9ce5528df8ca1890dbfe6426841992d0fb054bb16"
49
+ )
50
+ _INV_SBOX = bytearray(256)
51
+ for _i, _v in enumerate(_SBOX):
52
+ _INV_SBOX[_v] = _i
53
+ _INV_SBOX = bytes(_INV_SBOX)
54
+
55
+ _RCON = (
56
+ 0x01,
57
+ 0x02,
58
+ 0x04,
59
+ 0x08,
60
+ 0x10,
61
+ 0x20,
62
+ 0x40,
63
+ 0x80,
64
+ 0x1B,
65
+ 0x36,
66
+ 0x6C,
67
+ 0xD8,
68
+ 0xAB,
69
+ 0x4D,
70
+ )
71
+
72
+
73
+ def _mul(a: int, b: int) -> int:
74
+ """Multiply two bytes in GF(2^8) with the AES reduction polynomial."""
75
+ p = 0
76
+ for _ in range(8):
77
+ if b & 1:
78
+ p ^= a
79
+ hi = a & 0x80
80
+ a = (a << 1) & 0xFF
81
+ if hi:
82
+ a ^= 0x1B
83
+ b >>= 1
84
+ return p
85
+
86
+
87
+ def _expand_key(key: bytes) -> list[list[int]]:
88
+ """AES-256 key schedule -> 60 four-byte words."""
89
+ if len(key) != KEY_BYTES:
90
+ raise ValueError("AES-256 needs a 32-byte key, got %d" % len(key))
91
+ nk, nr = 8, 14
92
+ w = [list(key[WORD * i : WORD * i + WORD]) for i in range(nk)]
93
+ for i in range(nk, WORD * (nr + 1)):
94
+ t = list(w[i - 1])
95
+ if i % nk == 0:
96
+ t = t[1:] + t[:1] # RotWord
97
+ t = [_SBOX[b] for b in t] # SubWord
98
+ t[0] ^= _RCON[i // nk - 1]
99
+ elif i % nk == WORD:
100
+ t = [_SBOX[b] for b in t] # SubWord (AES-256 only)
101
+ w.append([a ^ b for a, b in zip(w[i - nk], t)])
102
+ return w
103
+
104
+
105
+ # The four inverse-cipher steps, named as FIPS-197 names them. Each works on
106
+ # the 16-byte state in place, column-major, so `s[r + 4 * c]` is row r of
107
+ # column c.
108
+
109
+
110
+ def _inv_shift_rows(s: list[int]) -> None:
111
+ """Rotate row r right by r bytes."""
112
+ for r in range(1, 4):
113
+ row = [s[r + 4 * c] for c in range(4)]
114
+ row = row[-r:] + row[:-r]
115
+ for c in range(4):
116
+ s[r + 4 * c] = row[c]
117
+
118
+
119
+ def _inv_sub_bytes(s: list[int]) -> None:
120
+ """Substitute every byte through the inverse S-box."""
121
+ for i in range(16):
122
+ s[i] = _INV_SBOX[s[i]]
123
+
124
+
125
+ def _inv_mix_columns(s: list[int]) -> None:
126
+ """Multiply each column by the inverse MDS matrix, over GF(2^8)."""
127
+ for c in range(4):
128
+ a = [s[4 * c + r] for r in range(4)]
129
+ s[4 * c + 0] = _mul(a[0], 14) ^ _mul(a[1], 11) ^ _mul(a[2], 13) ^ _mul(a[3], 9)
130
+ s[4 * c + 1] = _mul(a[0], 9) ^ _mul(a[1], 14) ^ _mul(a[2], 11) ^ _mul(a[3], 13)
131
+ s[4 * c + 2] = _mul(a[0], 13) ^ _mul(a[1], 9) ^ _mul(a[2], 14) ^ _mul(a[3], 11)
132
+ s[4 * c + 3] = _mul(a[0], 11) ^ _mul(a[1], 13) ^ _mul(a[2], 9) ^ _mul(a[3], 14)
133
+
134
+
135
+ def _add_round_key(s: list[int], w: list[list[int]], rnd: int) -> None:
136
+ """XOR in the four round-key words for round ``rnd``."""
137
+ for c in range(4):
138
+ k = w[rnd * 4 + c]
139
+ for r in range(4):
140
+ s[r + 4 * c] ^= k[r]
141
+
142
+
143
+ def _decrypt_block(block: bytes, w: list[list[int]]) -> bytes:
144
+ """Run the AES-256 inverse cipher over one 16-byte block."""
145
+ nr = 14 # rounds, for a 256-bit key
146
+ s = list(block)
147
+ _add_round_key(s, w, nr)
148
+ for rnd in range(nr - 1, 0, -1):
149
+ _inv_shift_rows(s)
150
+ _inv_sub_bytes(s)
151
+ _add_round_key(s, w, rnd)
152
+ _inv_mix_columns(s)
153
+ # The final round is the same without InvMixColumns.
154
+ _inv_shift_rows(s)
155
+ _inv_sub_bytes(s)
156
+ _add_round_key(s, w, 0)
157
+ return bytes(s)
158
+
159
+
160
+ def _pure_decrypt_cbc(key: bytes, iv: bytes, data: bytes) -> bytes:
161
+ if len(data) % 16:
162
+ raise ValueError("ciphertext not a multiple of 16 bytes")
163
+ w = _expand_key(key)
164
+ out = bytearray()
165
+ prev = iv
166
+ for i in range(0, len(data), 16):
167
+ block = data[i : i + 16]
168
+ clear = _decrypt_block(block, w)
169
+ out += bytes(a ^ b for a, b in zip(clear, prev))
170
+ prev = block
171
+ return bytes(out)
172
+
173
+
174
+ # --------------------------------------------------------------------------- #
175
+ # Backend selection #
176
+ # --------------------------------------------------------------------------- #
177
+
178
+
179
+ def _try_crypto(key, iv, data):
180
+ # pycryptodome or pycrypto; an optional backend, so the checker cannot
181
+ # be expected to resolve it.
182
+ from Crypto.Cipher import AES # ty: ignore[unresolved-import]
183
+
184
+ return AES.new(key, AES.MODE_CBC, iv).decrypt(data)
185
+
186
+
187
+ def _try_cryptography(key, iv, data):
188
+ from cryptography.hazmat.primitives.ciphers import ( # ty: ignore[unresolved-import]
189
+ Cipher,
190
+ algorithms,
191
+ modes,
192
+ )
193
+
194
+ dec = Cipher(algorithms.AES(key), modes.CBC(iv)).decryptor()
195
+ return dec.update(data) + dec.finalize()
196
+
197
+
198
+ # -- system libcrypto via ctypes (no install, no compiler) ------------------- #
199
+
200
+ _LIBCRYPTO = None
201
+ _LIBCRYPTO_TRIED = False
202
+
203
+
204
+ def _load_libcrypto():
205
+ """Locate and load OpenSSL's libcrypto, or return None. Cached."""
206
+ global _LIBCRYPTO, _LIBCRYPTO_TRIED
207
+ if _LIBCRYPTO_TRIED:
208
+ return _LIBCRYPTO
209
+ _LIBCRYPTO_TRIED = True
210
+ import ctypes
211
+ import ctypes.util
212
+
213
+ names = []
214
+ found = ctypes.util.find_library("crypto")
215
+ if found:
216
+ names.append(found)
217
+ names += [
218
+ "libcrypto.so.3",
219
+ "libcrypto.so.1.1",
220
+ "libcrypto.so.1.0.0",
221
+ "libcrypto.so",
222
+ "libcrypto.dylib",
223
+ "libcrypto-3.dll",
224
+ "libcrypto-1_1.dll",
225
+ "libeay32.dll",
226
+ ]
227
+ for n in names:
228
+ try:
229
+ lib = ctypes.CDLL(n)
230
+ except OSError:
231
+ continue
232
+ if hasattr(lib, "EVP_DecryptUpdate") and hasattr(lib, "EVP_aes_256_cbc"):
233
+ _LIBCRYPTO = lib
234
+ break
235
+ return _LIBCRYPTO
236
+
237
+
238
+ def _try_openssl(key, iv, data):
239
+ import ctypes as c
240
+
241
+ lib = _load_libcrypto()
242
+ if lib is None:
243
+ raise ImportError("libcrypto not available")
244
+ if len(key) != KEY_BYTES:
245
+ raise ValueError("AES-256 needs a 32-byte key, got %d" % len(key))
246
+ if len(data) % 16:
247
+ raise ValueError("ciphertext not a multiple of 16 bytes")
248
+
249
+ lib.EVP_CIPHER_CTX_new.restype = c.c_void_p
250
+ lib.EVP_aes_256_cbc.restype = c.c_void_p
251
+ lib.EVP_DecryptInit_ex.argtypes = [
252
+ c.c_void_p,
253
+ c.c_void_p,
254
+ c.c_void_p,
255
+ c.c_char_p,
256
+ c.c_char_p,
257
+ ]
258
+ lib.EVP_DecryptInit_ex.restype = c.c_int
259
+ lib.EVP_CIPHER_CTX_set_padding.argtypes = [c.c_void_p, c.c_int]
260
+ lib.EVP_CIPHER_CTX_set_padding.restype = c.c_int
261
+ lib.EVP_DecryptUpdate.argtypes = [
262
+ c.c_void_p,
263
+ c.c_char_p,
264
+ c.POINTER(c.c_int),
265
+ c.c_char_p,
266
+ c.c_int,
267
+ ]
268
+ lib.EVP_DecryptUpdate.restype = c.c_int
269
+ lib.EVP_DecryptFinal_ex.argtypes = [c.c_void_p, c.c_char_p, c.POINTER(c.c_int)]
270
+ lib.EVP_DecryptFinal_ex.restype = c.c_int
271
+ lib.EVP_CIPHER_CTX_free.argtypes = [c.c_void_p]
272
+ lib.EVP_CIPHER_CTX_free.restype = None
273
+
274
+ ctx = lib.EVP_CIPHER_CTX_new()
275
+ if not ctx:
276
+ raise RuntimeError("EVP_CIPHER_CTX_new failed")
277
+ try:
278
+ if lib.EVP_DecryptInit_ex(ctx, lib.EVP_aes_256_cbc(), None, key, iv) != 1:
279
+ raise RuntimeError("EVP_DecryptInit_ex failed")
280
+ lib.EVP_CIPHER_CTX_set_padding(ctx, 0) # container manages its own length
281
+ out = c.create_string_buffer(len(data) + 16)
282
+ outlen = c.c_int(0)
283
+ if lib.EVP_DecryptUpdate(ctx, out, c.byref(outlen), data, len(data)) != 1:
284
+ raise RuntimeError("EVP_DecryptUpdate failed")
285
+ total = outlen.value
286
+ fin = c.create_string_buffer(16)
287
+ finlen = c.c_int(0)
288
+ if lib.EVP_DecryptFinal_ex(ctx, fin, c.byref(finlen)) != 1:
289
+ raise RuntimeError("EVP_DecryptFinal_ex failed")
290
+ return out.raw[:total] + fin.raw[: finlen.value]
291
+ finally:
292
+ lib.EVP_CIPHER_CTX_free(ctx)
293
+
294
+
295
+ def _mod_available(mod: str) -> bool:
296
+ import importlib.util
297
+
298
+ try:
299
+ return importlib.util.find_spec(mod) is not None
300
+ except Exception: # noqa: BLE001 - a probe that raises is a backend that is absent
301
+ return False
302
+
303
+
304
+ # (name, availability probe, decrypt fn) in preference order.
305
+ _BACKENDS = (
306
+ ("pycryptodome", lambda: _mod_available("Crypto"), _try_crypto),
307
+ ("cryptography", lambda: _mod_available("cryptography"), _try_cryptography),
308
+ ("openssl", lambda: _load_libcrypto() is not None, _try_openssl),
309
+ ("pure-python", lambda: True, _pure_decrypt_cbc),
310
+ )
311
+
312
+
313
+ def backend_name() -> str:
314
+ """Name of the backend that decrypt_cbc will use, without decrypting."""
315
+ for name, avail, _fn in _BACKENDS:
316
+ try:
317
+ if avail():
318
+ return name
319
+ except Exception: # noqa: BLE001 - an unusable backend is not the one to name
320
+ continue
321
+ return "pure-python"
322
+
323
+
324
+ def decrypt_cbc(key: bytes, iv: bytes, data: bytes) -> bytes:
325
+ """AES-256-CBC decrypt, using the first backend that is available."""
326
+ last = None
327
+ for _name, avail, fn in _BACKENDS:
328
+ try:
329
+ if not avail():
330
+ continue
331
+ return fn(key, iv, data)
332
+ except ImportError:
333
+ continue
334
+ except Exception as exc: # noqa: BLE001 # pragma: no cover - the next backend gets its turn
335
+ last = exc
336
+ continue
337
+ raise RuntimeError("no AES backend succeeded: %r" % last)
338
+
339
+
340
+ if __name__ == "__main__":
341
+ # FIPS-197 Appendix C.3 known-answer test for AES-256, run against every
342
+ # backend that is available on this host (all must agree with the vector).
343
+ key = bytes.fromhex(
344
+ "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
345
+ )
346
+ ct = bytes.fromhex("8ea2b7ca516745bfeafc49904b496089")
347
+ pt = bytes.fromhex("00112233445566778899aabbccddeeff")
348
+ for name, avail, fn in _BACKENDS:
349
+ try:
350
+ ok = avail()
351
+ except Exception: # noqa: BLE001 - a self-test reports on backends, it does not need them
352
+ ok = False
353
+ if not ok:
354
+ print("%-12s: not available" % name)
355
+ continue
356
+ got = fn(key, b"\x00" * 16, ct)
357
+ assert got == pt, "%s KAT FAIL: %s" % (name, got.hex())
358
+ print("%-12s: AES-256 KAT OK" % name)
359
+ print("selected backend:", backend_name())
jkctl/cli/__init__.py ADDED
@@ -0,0 +1,58 @@
1
+ """The command-line interface.
2
+
3
+ The package is one module per concern, and one module per group of commands:
4
+
5
+ * :mod:`jkctl.cli.main` -- parse, open what the command needs, run it;
6
+ * :mod:`jkctl.cli.parser` -- the root parser and the shared options;
7
+ * :mod:`jkctl.cli.commands` -- the command groups, and the table that maps a
8
+ typed word to the function that runs it;
9
+ * :mod:`jkctl.cli.target` -- which port, and which unit on it;
10
+ * :mod:`jkctl.cli.output` -- tables, shared formats, the one prompt;
11
+ * :mod:`jkctl.cli.report` -- progress on a terminal;
12
+ * :mod:`jkctl.cli.exits` -- the process exit codes.
13
+
14
+ What a subcommand *is* -- :class:`~devicectl.cli.command.Command` and the
15
+ :class:`~devicectl.cli.command.Need` it declares -- comes from
16
+ ``devicectl-core``, along with the three exit codes that mean the same thing
17
+ in every program of this shape.
18
+
19
+ To add a command: write the handler in the right module of
20
+ :mod:`jkctl.cli.commands`, describe it in that module's ``add_parsers``, and
21
+ name it in that module's ``COMMANDS``.
22
+
23
+ Messages on stderr open with ``error:`` when the command failed, ``warning:``
24
+ when it carried on regardless, and ``note:`` for an advisory -- the same two
25
+ words argparse prints, so a script can grep for one prefix instead of several.
26
+ A declined prompt says ``Aborted.``, and a command that simply found nothing
27
+ says so in a sentence; neither is a malfunction.
28
+ """
29
+
30
+ from jkctl.cli.exits import (
31
+ EXIT_ABORTED,
32
+ EXIT_ERROR,
33
+ EXIT_INCOMPATIBLE,
34
+ EXIT_INTERRUPTED,
35
+ EXIT_OK,
36
+ EXIT_UPDATE_FAILED,
37
+ )
38
+ from jkctl.cli.main import main
39
+ from jkctl.cli.parser import (
40
+ DEFAULT_ACTIONS,
41
+ build_parser,
42
+ insert_default_action,
43
+ insert_default_command,
44
+ )
45
+
46
+ __all__ = [
47
+ "DEFAULT_ACTIONS",
48
+ "EXIT_ABORTED",
49
+ "EXIT_ERROR",
50
+ "EXIT_INCOMPATIBLE",
51
+ "EXIT_INTERRUPTED",
52
+ "EXIT_OK",
53
+ "EXIT_UPDATE_FAILED",
54
+ "build_parser",
55
+ "insert_default_action",
56
+ "insert_default_command",
57
+ "main",
58
+ ]
@@ -0,0 +1,51 @@
1
+ """The command groups, and the table that maps a typed word to a handler.
2
+
3
+ Each module here owns one group: the handlers, and the ``add_parsers`` that
4
+ describes them to argparse. Adding a command means adding a function and two
5
+ lines to the module's own ``COMMANDS`` -- nothing outside this package changes.
6
+
7
+ ``GROUPS`` is ordered, and that order is the order of ``jkctl --help``: find
8
+ the unit first, then read it, then change its configuration, then the acts
9
+ that reconfigure or restart the board itself, then the tools for when it will
10
+ not answer at all.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from devicectl.cli.command import Command
16
+
17
+ from jkctl.cli.commands import (
18
+ config,
19
+ controls,
20
+ devices,
21
+ diag,
22
+ firmware,
23
+ history,
24
+ protocols,
25
+ registers,
26
+ settings,
27
+ status,
28
+ ui,
29
+ )
30
+
31
+ GROUPS = (
32
+ ui,
33
+ devices,
34
+ status,
35
+ registers,
36
+ history,
37
+ settings,
38
+ controls,
39
+ protocols,
40
+ firmware,
41
+ diag,
42
+ config,
43
+ )
44
+
45
+ COMMANDS: dict[str, Command] = {}
46
+ for _group in GROUPS:
47
+ for _name, _command in _group.COMMANDS.items():
48
+ assert _name not in COMMANDS, f"two groups claim the command {_name!r}"
49
+ COMMANDS[_name] = _command
50
+
51
+ __all__ = ["COMMANDS", "GROUPS"]
@@ -0,0 +1,100 @@
1
+ """The configuration file: showing it, finding it, and writing a first one."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from devicectl.cli.command import Command, Need
9
+ from devicectl.cli.output import may_overwrite, print_rows, print_table
10
+
11
+ from jkctl.cli.exits import EXIT_ERROR, EXIT_OK
12
+ from jkctl.config import (
13
+ EXAMPLE_CONFIG,
14
+ default_config_path,
15
+ load_config,
16
+ )
17
+ from jkctl.device import Device
18
+
19
+
20
+ def cmd_config(device: Device | None, args: argparse.Namespace) -> int:
21
+ """Dispatch one ``jkctl config`` action."""
22
+ return {"show": _show, "path": _path, "init": _init}[args.action](args)
23
+
24
+
25
+ def _show(args: argparse.Namespace) -> int:
26
+ """Print what the configuration file actually says, ready to be pasted."""
27
+ config = load_config(args.config)
28
+ print(
29
+ f"file: {config.path}"
30
+ f"{'' if config.path and config.path.is_file() else ' (does not exist)'}\n"
31
+ )
32
+ rows = [
33
+ (name, str(value))
34
+ for name, value in (
35
+ ("port", config.port),
36
+ ("baud", config.baud),
37
+ ("id", config.id),
38
+ ("timeout", config.timeout),
39
+ ("retries", config.retries),
40
+ ("addr_offset", config.addr_offset),
41
+ )
42
+ if value is not None
43
+ ]
44
+ if rows:
45
+ print_rows("defaults", rows)
46
+ print()
47
+ if config.devices:
48
+ print_table(
49
+ ["DEVICE", "PORT", "BAUD", "ID"],
50
+ [
51
+ [d.name, d.port or "-", str(d.baud or "-"), str(d.id or "-")]
52
+ for d in config.devices.values()
53
+ ],
54
+ )
55
+ elif not rows:
56
+ print("Nothing configured; every command uses the built-in defaults.")
57
+ return EXIT_OK
58
+
59
+
60
+ def _path(args: argparse.Namespace) -> int:
61
+ """Print the path the configuration is read from."""
62
+ print(args.config or default_config_path())
63
+ return EXIT_OK
64
+
65
+
66
+ def _init(args: argparse.Namespace) -> int:
67
+ """Write a commented example configuration."""
68
+ path = args.config or default_config_path()
69
+ if not may_overwrite(path, yes=args.yes):
70
+ return EXIT_ERROR
71
+ path.parent.mkdir(parents=True, exist_ok=True)
72
+ path.write_text(EXAMPLE_CONFIG, encoding="utf-8")
73
+ print(f"Wrote {path}", file=sys.stderr)
74
+ return EXIT_OK
75
+
76
+
77
+ def add_parsers(
78
+ sub: argparse._SubParsersAction, common: argparse.ArgumentParser
79
+ ) -> None:
80
+ """Add this group's commands to the root parser."""
81
+ p = sub.add_parser("config", help="the optional jk.toml settings file")
82
+ actions = p.add_subparsers(dest="action", metavar="ACTION", required=True)
83
+ for name, help_text in (
84
+ ("show", "print what the file configures"),
85
+ ("path", "print where the file is read from"),
86
+ ("init", "write a commented example file"),
87
+ ):
88
+ sp = actions.add_parser(name, help=help_text, parents=[common])
89
+ if name == "init":
90
+ sp.add_argument(
91
+ "-y",
92
+ "--yes",
93
+ action="store_true",
94
+ help="overwrite an existing file without asking",
95
+ )
96
+
97
+
98
+ COMMANDS: dict[str, Command] = {
99
+ "config": Command(cmd_config, Need.NOTHING, default_action="show"),
100
+ }