pysd10 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.
@@ -0,0 +1,124 @@
1
+ Metadata-Version: 2.4
2
+ Name: pysd10
3
+ Version: 0.2.0
4
+ Summary: Python client for the Epson SD-10 spectrophotometer over Bluetooth LE (spectrum and L*a*b*)
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: bleak>=0.21
8
+ Provides-Extra: test
9
+ Requires-Dist: pytest; extra == "test"
10
+
11
+ # sd10
12
+
13
+ Python client for the **Epson SD-10** spectrophotometer over Bluetooth LE: take measurements and get
14
+ the 31-band spectrum (400–700 nm) and CIE L\*a\*b\* exactly as the Epson Spectrometer app computes it.
15
+
16
+ ## Install
17
+
18
+ ```
19
+ pip install -e path\to\sd10_client
20
+ ```
21
+
22
+ Requires Python 3.9+ and [bleak](https://github.com/hbldh/bleak) (installed automatically).
23
+
24
+ ## Quick start (blocking — scripts, Jupyter)
25
+
26
+ ```python
27
+ import sd10
28
+
29
+ result = sd10.measure_once() # connect, measure, disconnect
30
+ print(result.lab) # Lab(L=..., a=..., b=...)
31
+
32
+ with sd10.connect_sync() as dev: # keep the connection open
33
+ r = dev.measure(illuminant="D65", observer=10, m_factor=0)
34
+ print(r.lab, r.lch, r.spectrum)
35
+ print(r.lab_under("A", 2)) # same reading, other illuminant
36
+
37
+ for r in dev.button_measurements(): # react to the device's own button
38
+ print(r.lab)
39
+ ```
40
+
41
+ ## Async
42
+
43
+ ```python
44
+ import asyncio, sd10
45
+
46
+ async def main():
47
+ async with sd10.connect() as dev: # strongest SD-10, or connect("40:84:32:52:FD:0F")
48
+ print(await dev.get_info())
49
+ r = await dev.measure()
50
+ print(r.lab)
51
+ async for r in dev.button_measurements():
52
+ print(r.lab)
53
+
54
+ asyncio.run(main())
55
+ ```
56
+
57
+ ## API overview
58
+
59
+ | | |
60
+ |---|---|
61
+ | `scan()` / `scan_sync()` | nearby SD-10s (`address`, `name`, `rssi`) |
62
+ | `connect(address=None)` / `connect_sync(...)` | connect and open a session; on exit the device is handed back to its own UI |
63
+ | `dev.measure(illuminant, observer, m_factor, save)` | one spot measurement → `MeasurementResult` |
64
+ | `dev.button_measurements()` | iterator of results taken with the device button |
65
+ | `dev.get_history(after_id=0, limit=None, progress=None)` | download readings stored on the device (it keeps its last 100), oldest first; pass the last `identifier` you have as `after_id` to fetch only new ones |
66
+ | `dev.history_count(after_id=0)` | how many stored readings are newer than `after_id` |
67
+ | `dev.get_groups()` | colour groups (palettes) stored on the device: 4 slots × up to 80 L\*a\*b\* colours → `[ColorGroup]` |
68
+ | `dev.set_group(ColorGroup(slot, name, colors, illuminant, observer, m_factor, diff_formula, tolerance))` | store/replace a palette in slot 1–4 |
69
+ | `dev.delete_group(slot)` | clear a slot |
70
+ | `dev.calibrate()` | white calibration with the cover **closed** (raises `CoverOpen` otherwise) → `Status` |
71
+ | `dev.scan_row(expected=None, patches=None, tolerance=None, ...)` | scan one row of ≤18 patches by sliding the device; with `expected` + `tolerance` each patch is judged → `RowResult` |
72
+ | `dev.scan_palette_row(slot, row, patches_per_row=10)` | scan one row of a printed chart of a stored palette and compare with it → `RowResult` |
73
+ | `dev.scan_palette(slot, patches_per_row=10)` | whole chart, row by row (async iterator of `RowResult`), then `finish_chart()` |
74
+ | `dev.measure_palette_patch(slot, patch)` | spot-measure one patch against palette colour `patch` → `PatchResult` |
75
+ | `dev.get_info()`, `get_status()`, `get_name()`, `get_serial()`, `get_firmware_version()` | device info |
76
+ | `dev.get_setting(Setting.X)` / `set_setting(...)` | buzzer, panel direction, auto power-off, language |
77
+ | `dev.set_clock()`, `dev.set_name(...)` | |
78
+ | `dev.transact(frame)` | raw command (see `sd10.protocol`) |
79
+ | `MeasurementResult` | `.lab`, `.lch`, `.lab_under(ill, obs)`, `.spectrum`, `.wavelengths`, `.timestamp`, `.to_dict()` |
80
+ | `spectrum_to_lab(spectrum, illuminant, observer)` | offline conversion |
81
+ | `delta_e(ref, sample, "DE2000" / "DE94GA" / "DE94T" / "DE76")`, `delta_e_2000`, `delta_e_94`, `delta_e_76` | colour difference (matches Epson's library) |
82
+ | `median_lab(labs)` | per-component median of several readings (the app's "Median") |
83
+ | `color_scheme(lab, "complementary" / "split_complementary" / "analogous" / "triadic" / "tetradic", illuminant, observer)` | the app's "Color Scheme" |
84
+ | `lab_to_hex(lab, illuminant, observer)`, `lab_to_srgb`, `srgb_to_lab` | sRGB / HEX as the app shows it |
85
+ | `export_aco(path, colors, names)`, `read_aco(path)` | Adobe Color Swatch (.aco) files for Photoshop / Illustrator |
86
+
87
+ Illuminants: D50, D65, A, F1, F2, F3, F7–F12. Observers: 2, 10.
88
+
89
+ Errors derive from `sd10.SD10Error` (`DeviceNotFound`, `DeviceRefused`, `SD10Timeout`, `MeasurementFailed`,
90
+ `CoverClosed` — raised instead of measuring when the cover is shut, because the device would just repeat its
91
+ previous reading).
92
+ Enable `logging.getLogger("sd10").setLevel(logging.DEBUG)` to see all traffic.
93
+
94
+ ## Command line
95
+
96
+ ```
97
+ sd10 scan
98
+ sd10 measure --illuminant D65 --observer 10 [--spectrum] [--json]
99
+ sd10 listen # L*a*b* for every button press
100
+ sd10 history [--after ID] [--last N] [--json | --csv FILE] # readings stored on the device
101
+ sd10 history --aco FILE # stored readings as an Adobe Color Swatch
102
+ sd10 scan-row --patches 6 | --expected row.json [--tolerance 2]
103
+ sd10 chart SLOT [--per-row 10] # scan a printed chart of a stored palette
104
+ sd10 groups list | delete SLOT | export SLOT FILE(.json|.aco) | import FILE [--slot N]
105
+ sd10 groups from-history SLOT NAME --ids 951,952,953 [--illuminant D65 --tolerance 2]
106
+ sd10 info | calibrate | set-time | set-name NAME | raw ID TYPE [HEX]
107
+ ```
108
+
109
+ (`python -m sd10 ...` works without installing the script; global options such as `-a ADDRESS`
110
+ and `-v` go before the command.)
111
+
112
+ ## Notes
113
+
114
+ - The SD-10 accepts one host at a time — close the Epson app on your phone first.
115
+ - A random 16-byte host id is kept in `~/.sd10_app_id`, like the app does.
116
+ - L\*a\*b\* reproduces Epson's `librmcc` conversion to ~1e-6 ΔE; bands outside 400–700 nm count as
117
+ zero, so a perfect white reads L\* ≈ 99.99, same as the app.
118
+
119
+ ## Tests
120
+
121
+ ```
122
+ pip install -e path\to\sd10_client[test]
123
+ pytest path\to\sd10_client\tests
124
+ ```
@@ -0,0 +1,15 @@
1
+ sd10/__init__.py,sha256=Q1XmPlpIG5aaa17xk474vaNIq_-h3ZOli-8LKoH246s,1972
2
+ sd10/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
3
+ sd10/cli.py,sha256=eOqnA9WI6hW-oRihvIOHZlTTPN9NYYALg6RIlKZZtlg,16253
4
+ sd10/client.py,sha256=ht0GRiivSJDqFrBEctCIdhsnlLSyGSLhpmCeFWgv_so,36004
5
+ sd10/color.py,sha256=D2GikTdqYvjmd9HtD5mMduxGP2lHk-H_woCHmCyQV-U,12215
6
+ sd10/export.py,sha256=fwpht66RllHtAVUTMH9NXX7XBURBOKuV-uPJD7gpkz4,2794
7
+ sd10/protocol.py,sha256=MNWmsZP79A7YfFhS8FW7ml5m_g4vN3WMHgEwONfQzdU,20101
8
+ sd10/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ sd10/sync.py,sha256=uDjnhy8vimuB8iKRd194mVRsT-TP8rXH-TIyhidiuDE,5031
10
+ sd10/data/lab_weights.json,sha256=TLP5_F5_l_eMDYMo0xitQtTkkv1Gw6EnS3E5ENe-Ci8,24927
11
+ pysd10-0.2.0.dist-info/METADATA,sha256=9fEmex_TU4TUJR_zqDVSw6op6494cdOqRDFMHvRuxyM,6250
12
+ pysd10-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
13
+ pysd10-0.2.0.dist-info/entry_points.txt,sha256=q1BuDruXR9NDFLtzZANqg3NNadcA0RIyxZ02zDIuWrA,39
14
+ pysd10-0.2.0.dist-info/top_level.txt,sha256=qWCvSHY9W5PTyJp2m1ZkxfDPbc8sdjHgo13B5jxujHM,5
15
+ pysd10-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sd10 = sd10.cli:main
@@ -0,0 +1 @@
1
+ sd10
sd10/__init__.py ADDED
@@ -0,0 +1,44 @@
1
+ """Python client for the Epson SD-10 spectrophotometer over Bluetooth LE.
2
+
3
+ Async::
4
+
5
+ import asyncio, sd10
6
+
7
+ async def main():
8
+ async with sd10.connect() as dev:
9
+ result = await dev.measure(illuminant="D65", observer=10)
10
+ print(result.lab, result.lch)
11
+
12
+ asyncio.run(main())
13
+
14
+ Blocking::
15
+
16
+ import sd10
17
+ print(sd10.measure_once().lab)
18
+
19
+ with sd10.connect_sync() as dev:
20
+ for result in dev.button_measurements():
21
+ print(result.lab)
22
+ """
23
+ from .client import (SD10, CalibrationNeeded, CoverClosed, CoverOpen, DeviceInfo, DeviceNotFound, DeviceRefused,
24
+ FoundDevice, MeasurementFailed, PatchResult, RowResult, SD10Error, SD10Timeout, connect,
25
+ find_device, load_app_id, scan)
26
+ from .color import (COLOR_SCHEMES, DELTA_E_FORMULAS, ILLUMINANTS, OBSERVERS, WAVELENGTHS, Lab, LCh, color_scheme,
27
+ delta_e, delta_e_76, delta_e_94, delta_e_2000, lab_to_hex, lab_to_srgb, median_lab,
28
+ spectrum_to_lab, srgb_to_lab)
29
+ from .export import aco_bytes, export_aco, read_aco
30
+ from .protocol import ColorGroup, Event, MeasurementResult, Method, Setting, Status
31
+ from .sync import SyncSD10, connect_sync, measure_once, scan_sync
32
+
33
+ __version__ = "0.2.0"
34
+
35
+ __all__ = [
36
+ "SD10", "connect", "scan", "find_device", "load_app_id", "DeviceInfo", "FoundDevice",
37
+ "SyncSD10", "connect_sync", "scan_sync", "measure_once",
38
+ "MeasurementResult", "Status", "Setting", "Event", "Method", "ColorGroup", "PatchResult", "RowResult",
39
+ "Lab", "LCh", "spectrum_to_lab", "delta_e", "delta_e_76", "delta_e_94", "delta_e_2000", "median_lab",
40
+ "color_scheme", "lab_to_srgb", "srgb_to_lab", "lab_to_hex", "COLOR_SCHEMES", "DELTA_E_FORMULAS",
41
+ "ILLUMINANTS", "OBSERVERS", "WAVELENGTHS", "export_aco", "aco_bytes", "read_aco",
42
+ "SD10Error", "SD10Timeout", "DeviceNotFound", "DeviceRefused", "MeasurementFailed", "CoverClosed",
43
+ "CalibrationNeeded", "CoverOpen",
44
+ ]
sd10/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
sd10/cli.py ADDED
@@ -0,0 +1,320 @@
1
+ """Command-line interface: ``python -m sd10 ...`` or ``sd10 ...`` once installed."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import asyncio
6
+ import csv
7
+ import json
8
+ import logging
9
+ import sys
10
+ from dataclasses import asdict
11
+
12
+ from . import protocol as p
13
+ from .client import SD10, SD10Error, connect, scan
14
+ from .color import ILLUMINANTS, WAVELENGTHS
15
+ from .export import export_aco
16
+ from .protocol import ColorGroup, MeasurementResult, Setting
17
+
18
+
19
+ def _print_results(results: list[MeasurementResult], args) -> None:
20
+ ill = getattr(args, "illuminant", None)
21
+ obs = getattr(args, "observer", None)
22
+ if args.json:
23
+ rows = []
24
+ for r in results:
25
+ d = r.to_dict()
26
+ if ill or obs:
27
+ lab = r.lab_under(ill or r.illuminant, obs or r.observer)
28
+ d.update(illuminant=ill or r.illuminant, observer=obs or r.observer,
29
+ lab={"L": lab.L, "a": lab.a, "b": lab.b}, lch=dict(zip("LCh", lab.lch)))
30
+ rows.append(d)
31
+ print(json.dumps(rows, indent=2))
32
+ return
33
+ for r in results:
34
+ i, o = ill or r.illuminant, obs or r.observer
35
+ lab = r.lab_under(i, o)
36
+ lch = lab.lch
37
+ when = r.timestamp.isoformat() if r.timestamp else "?"
38
+ print(f"L*={lab.L:7.2f} a*={lab.a:7.2f} b*={lab.b:7.2f} (C*={lch.C:.2f} h={lch.h:.1f}) "
39
+ f"{i}/{o}deg M{r.m_factor} {when}" + ("" if r.ok else " [device flagged failure]"))
40
+ if args.spectrum:
41
+ for nm, v in zip(WAVELENGTHS, r.spectrum):
42
+ print(f" {nm} nm {v:.5f}")
43
+
44
+
45
+ async def _run(args) -> None:
46
+ if args.cmd == "scan":
47
+ found = await scan(args.timeout)
48
+ if not found:
49
+ print("no devices advertising", p.ADV_SERVICE_UUID)
50
+ for f in found:
51
+ weak = " (weak signal; the app ignores < -75 dBm)" if f.rssi < -75 else ""
52
+ print(f"{f.address} {f.rssi:4d} dBm {f.name}{weak}")
53
+ return
54
+
55
+ async with connect(args.address, scan_timeout=args.timeout) as dev:
56
+ await _COMMANDS[args.cmd](dev, args)
57
+
58
+
59
+ async def _info(dev: SD10, args) -> None:
60
+ info = await dev.get_info()
61
+ out = {"name": info.name, "firmware": info.firmware, "serial": info.serial, "status": asdict(info.status),
62
+ "settings": {s.name.lower(): await dev.get_setting(s) for s in Setting},
63
+ "measurement_mode_tlv": {f"0x{k:02X}": v.hex() for k, v in (await dev.get_measurement_mode()).items()}}
64
+ print(json.dumps(out, indent=2))
65
+
66
+
67
+ async def _measure(dev: SD10, args) -> None:
68
+ r = await dev.measure(illuminant=args.illuminant, observer=args.observer, m_factor=args.m,
69
+ save=not args.no_save, timeout=args.wait)
70
+ _print_results([r], args)
71
+
72
+
73
+ async def _calibrate(dev: SD10, args) -> None:
74
+ st = await dev.calibrate(timeout=args.wait)
75
+ print("calibration finished; calibrated =", st.calibrated, st.errors or "")
76
+
77
+
78
+ async def _listen(dev: SD10, args) -> None:
79
+ print("listening; press the measure button on the device (Ctrl+C to stop)", file=sys.stderr)
80
+ async for r in dev.button_measurements(illuminant=args.illuminant, observer=args.observer):
81
+ _print_results([r], args)
82
+
83
+
84
+ async def _history(dev: SD10, args) -> None:
85
+ results = await dev.get_history(after_id=args.after,
86
+ progress=lambda n: print(f" downloaded {n}...", file=sys.stderr))
87
+ if args.last:
88
+ results = results[-args.last:]
89
+ if args.aco:
90
+ export_aco(args.aco, [r.lab for r in results], [f"#{r.identifier}" for r in results])
91
+ print(f"wrote {len(results)} swatches to {args.aco}", file=sys.stderr)
92
+ elif args.csv:
93
+ with open(args.csv, "w", newline="", encoding="utf-8") as f:
94
+ w = csv.writer(f)
95
+ w.writerow(["id", "timestamp", "illuminant", "observer", "m_factor", "L", "a", "b", "C", "h"]
96
+ + [f"{nm}nm" for nm in WAVELENGTHS])
97
+ for r in results:
98
+ lab = r.lab
99
+ w.writerow([r.identifier, r.timestamp.isoformat() if r.timestamp else "", r.illuminant, r.observer,
100
+ f"M{r.m_factor}", *(f"{v:.4f}" for v in (*lab, lab.lch.C, lab.lch.h))]
101
+ + [f"{v:.6f}" for v in r.spectrum])
102
+ print(f"wrote {len(results)} readings to {args.csv}", file=sys.stderr)
103
+ elif args.json:
104
+ print(json.dumps([r.to_dict() for r in results], indent=2))
105
+ else:
106
+ for r in results:
107
+ lab = r.lab
108
+ when = r.timestamp.isoformat(sep=" ") if r.timestamp else "?"
109
+ print(f"#{r.identifier:<6} {when} L*={lab.L:7.2f} a*={lab.a:7.2f} b*={lab.b:7.2f} "
110
+ f"{r.illuminant}/{r.observer}deg M{r.m_factor}")
111
+ print(f"{len(results)} readings", file=sys.stderr)
112
+
113
+
114
+ def _print_group(g: ColorGroup) -> None:
115
+ print(f"slot {g.slot}: {g.name!r} {len(g.colors)} colours {g.illuminant}/{g.observer}deg M{g.m_factor} "
116
+ f"{g.diff_formula} tolerance {g.tolerance:.2f}")
117
+ for i, c in enumerate(g.colors, 1):
118
+ print(f" {i:2} L*={c.L:7.2f} a*={c.a:7.2f} b*={c.b:7.2f}")
119
+
120
+
121
+ async def _groups(dev: SD10, args) -> None:
122
+ action = args.action
123
+ if action == "list":
124
+ groups = await dev.get_groups()
125
+ if args.json:
126
+ print(json.dumps([g.to_dict() for g in groups], indent=2, ensure_ascii=False))
127
+ elif not groups:
128
+ print("no groups stored (slots 1-4 are empty)")
129
+ for g in groups if not args.json else ():
130
+ _print_group(g)
131
+ elif action == "delete":
132
+ await dev.delete_group(args.slot)
133
+ print(f"slot {args.slot} cleared")
134
+ elif action == "export":
135
+ match = [g for g in await dev.get_groups() if g.slot == args.slot]
136
+ if not match:
137
+ raise SD10Error(f"slot {args.slot} is empty")
138
+ if args.file.lower().endswith(".aco"):
139
+ g = match[0]
140
+ export_aco(args.file, g.colors, [f"{g.name} {i}" for i in range(1, len(g.colors) + 1)])
141
+ else:
142
+ with open(args.file, "w", encoding="utf-8") as f:
143
+ json.dump(match[0].to_dict(), f, indent=2, ensure_ascii=False)
144
+ print(f"wrote slot {args.slot} to {args.file}")
145
+ elif action == "import":
146
+ with open(args.file, encoding="utf-8") as f:
147
+ g = ColorGroup.from_dict(json.load(f))
148
+ if args.slot:
149
+ g.slot = args.slot
150
+ _print_group(await dev.set_group(g))
151
+ print("stored")
152
+ elif action == "add":
153
+ ids = [int(x) for x in args.ids.split(",")]
154
+ readings = {r.identifier: r for r in await dev.get_history() if r.identifier in set(ids)}
155
+ missing = sorted(set(ids) - readings.keys())
156
+ if missing:
157
+ raise SD10Error(f"readings not on the device: {missing}")
158
+ _print_group(await dev.add_to_group(args.slot, [readings[i] for i in ids]))
159
+ print(f"added {len(ids)} colour(s)")
160
+ elif action == "remove":
161
+ _print_group(await dev.remove_from_group(args.slot, [int(x) - 1 for x in args.colors.split(",")]))
162
+ print("removed")
163
+ elif action == "from-history":
164
+ ids = {int(x) for x in args.ids.split(",")}
165
+ readings = {r.identifier: r for r in await dev.get_history() if r.identifier in ids}
166
+ missing = sorted(ids - readings.keys())
167
+ if missing:
168
+ raise SD10Error(f"readings not on the device: {missing}")
169
+ colors = [readings[i].lab_under(args.illuminant, args.observer) for i in sorted(ids)]
170
+ g = ColorGroup(args.slot, args.name, colors, illuminant=args.illuminant, observer=args.observer,
171
+ m_factor=args.m, diff_formula=args.formula, tolerance=args.tolerance)
172
+ _print_group(await dev.set_group(g))
173
+ print("stored")
174
+
175
+
176
+ def _print_row(r) -> None:
177
+ head = f"row {r.row}: {len(r.patches)} patches {r.illuminant}/{r.observer}deg"
178
+ if r.tolerance is not None:
179
+ head += (f" mean {r.formula} {r.mean_delta_e:.2f} (max {r.max_delta_e:.2f}, tolerance {r.tolerance:.2f}) "
180
+ + ("PASS" if r.passed else "FAIL"))
181
+ print(head)
182
+ for x in r.patches:
183
+ line = f" patch {x.patch:2} L*={x.lab.L:7.2f} a*={x.lab.a:7.2f} b*={x.lab.b:7.2f}"
184
+ if x.delta_e is not None:
185
+ line += f" dE {x.delta_e:6.2f}" + ("" if x.ok is None else (" ok" if x.ok else " NG"))
186
+ print(line)
187
+
188
+
189
+ async def _scan_row(dev: SD10, args) -> None:
190
+ expected = None
191
+ if args.expected:
192
+ with open(args.expected, encoding="utf-8") as f:
193
+ expected = [tuple(c[:3]) for c in json.load(f)]
194
+ print("Place the SD-10 on the first patch of the row and press its button, then slide it to the margin.",
195
+ file=sys.stderr)
196
+ r = await dev.scan_row(expected, patches=args.patches, row=args.row, illuminant=args.illuminant,
197
+ observer=args.observer, m_factor=args.m, formula=args.formula, tolerance=args.tolerance)
198
+ _print_row(r)
199
+
200
+
201
+ async def _chart(dev: SD10, args) -> None:
202
+ print("For each row: place the SD-10 on the row's first patch, press its button, slide to the margin.",
203
+ file=sys.stderr)
204
+ async for r in dev.scan_palette(args.slot, patches_per_row=args.per_row):
205
+ _print_row(r)
206
+
207
+
208
+ async def _set_time(dev: SD10, args) -> None:
209
+ await dev.set_clock()
210
+ print("clock set")
211
+
212
+
213
+ async def _set_name(dev: SD10, args) -> None:
214
+ await dev.set_name(args.name)
215
+ print("name set to", await dev.get_name())
216
+
217
+
218
+ async def _raw(dev: SD10, args) -> None:
219
+ cid, typ, payload = await dev.transact(p.frame(int(args.id, 16), int(args.type, 16), bytes.fromhex(args.payload)))
220
+ print(f"id=0x{cid:02X} type=0x{typ:02X} len={len(payload)} payload={payload.hex(' ')}")
221
+ try:
222
+ print(" ascii:", payload.decode("ascii"))
223
+ except UnicodeDecodeError:
224
+ pass
225
+
226
+
227
+ _COMMANDS = {"info": _info, "measure": _measure, "calibrate": _calibrate, "listen": _listen,
228
+ "history": _history, "groups": _groups, "scan-row": _scan_row, "chart": _chart,
229
+ "set-time": _set_time, "set-name": _set_name, "raw": _raw}
230
+
231
+
232
+ def main(argv=None) -> None:
233
+ ap = argparse.ArgumentParser(prog="sd10", description="Epson SD-10 spectrophotometer over Bluetooth LE")
234
+ ap.add_argument("-a", "--address", help="BLE address (default: strongest SD-10 found)")
235
+ ap.add_argument("-t", "--timeout", type=float, default=8.0, help="scan timeout (s)")
236
+ ap.add_argument("-v", "--verbose", action="store_true", help="hex-dump all traffic to stderr")
237
+ sub = ap.add_subparsers(dest="cmd", required=True)
238
+ sub.add_parser("scan", help="list nearby SD-10s")
239
+ sub.add_parser("info", help="status, firmware, serial, settings")
240
+ m = sub.add_parser("measure", help="single spot measurement -> L*a*b*")
241
+ m.add_argument("--m", type=int, choices=(0, 1, 2), default=0, help="M-factor (M0/M1/M2)")
242
+ m.add_argument("--illuminant", choices=sorted(ILLUMINANTS), default="D50")
243
+ m.add_argument("--observer", type=int, choices=(2, 10), default=2)
244
+ m.add_argument("--no-save", action="store_true", help="don't store the result in device history")
245
+ m.add_argument("--wait", type=float, default=20.0, help="seconds to wait for the result")
246
+ m.add_argument("--spectrum", action="store_true", help="also print the 31-band reflectance")
247
+ m.add_argument("--json", action="store_true")
248
+ c = sub.add_parser("calibrate", help="run white calibration")
249
+ c.add_argument("--wait", type=float, default=30.0)
250
+ lst = sub.add_parser("listen", help="show L*a*b* for measurements taken with the device button")
251
+ lst.add_argument("--illuminant", choices=sorted(ILLUMINANTS), help="default: as recorded by the device")
252
+ lst.add_argument("--observer", type=int, choices=(2, 10))
253
+ lst.add_argument("--spectrum", action="store_true")
254
+ lst.add_argument("--json", action="store_true")
255
+ h = sub.add_parser("history", help="download readings stored on the device (its last 100)")
256
+ h.add_argument("--after", type=int, default=0, metavar="ID", help="only readings with a larger record id")
257
+ h.add_argument("--last", type=int, metavar="N", help="only the N most recent")
258
+ h.add_argument("--json", action="store_true")
259
+ h.add_argument("--csv", metavar="FILE", help="write CSV (with spectra) instead of printing")
260
+ h.add_argument("--aco", metavar="FILE", help="write an Adobe Color Swatch file (Photoshop/Illustrator)")
261
+ sr = sub.add_parser("scan-row", help="scan one row of colour patches (slide the device along the row)")
262
+ sr.add_argument("--patches", type=int, help="number of patches in the row (1-18), if no --expected")
263
+ sr.add_argument("--expected", metavar="JSON", help="file with the row's target L*a*b* colours [[L,a,b],...]")
264
+ sr.add_argument("--tolerance", type=float, help="judge each patch against --expected with this dE")
265
+ sr.add_argument("--formula", choices=sorted(p.DIFF_FORMULAS), default="DE2000")
266
+ sr.add_argument("--row", type=int, default=1)
267
+ sr.add_argument("--illuminant", choices=sorted(ILLUMINANTS), default="D50")
268
+ sr.add_argument("--observer", type=int, choices=(2, 10), default=2)
269
+ sr.add_argument("--m", type=int, choices=(0, 1, 2), default=0, help="M-factor")
270
+ ch = sub.add_parser("chart", help="scan a printed chart of a stored palette row by row and compare")
271
+ ch.add_argument("slot", type=int, choices=(1, 2, 3, 4))
272
+ ch.add_argument("--per-row", type=int, default=10, help="patches per row on the chart (1-18, app uses 10)")
273
+ g = sub.add_parser("groups", help="colour groups (palettes) stored on the device: 4 slots, up to 80 colours")
274
+ gs = g.add_subparsers(dest="action", required=True)
275
+ gl = gs.add_parser("list", help="show stored groups")
276
+ gl.add_argument("--json", action="store_true")
277
+ gd = gs.add_parser("delete", help="clear a slot")
278
+ gd.add_argument("slot", type=int, choices=(1, 2, 3, 4))
279
+ ge = gs.add_parser("export", help="save a slot to a JSON file")
280
+ ge.add_argument("slot", type=int, choices=(1, 2, 3, 4))
281
+ ge.add_argument("file", help="JSON file, or .aco for an Adobe Color Swatch")
282
+ gi = gs.add_parser("import", help="store a group from a JSON file (format as 'export')")
283
+ gi.add_argument("file")
284
+ gi.add_argument("--slot", type=int, choices=(1, 2, 3, 4), help="override the slot in the file")
285
+ ga = gs.add_parser("add", help="append readings stored on the device to an existing group")
286
+ ga.add_argument("slot", type=int, choices=(1, 2, 3, 4))
287
+ ga.add_argument("--ids", required=True, help="comma-separated record ids (see 'history')")
288
+ gr = gs.add_parser("remove", help="remove colours from a group")
289
+ gr.add_argument("slot", type=int, choices=(1, 2, 3, 4))
290
+ gr.add_argument("--colors", required=True, help="comma-separated colour numbers as shown by 'groups list'")
291
+ gh = gs.add_parser("from-history", help="build a group from readings stored on the device")
292
+ gh.add_argument("slot", type=int, choices=(1, 2, 3, 4))
293
+ gh.add_argument("name")
294
+ gh.add_argument("--ids", required=True, help="comma-separated record ids (see 'history')")
295
+ gh.add_argument("--illuminant", choices=sorted(ILLUMINANTS), default="D50")
296
+ gh.add_argument("--observer", type=int, choices=(2, 10), default=2)
297
+ gh.add_argument("--m", type=int, choices=(0, 1, 2), default=0, help="M-factor")
298
+ gh.add_argument("--formula", choices=sorted(p.DIFF_FORMULAS), default="DE2000")
299
+ gh.add_argument("--tolerance", type=float, default=1.0)
300
+ sub.add_parser("set-time", help="set the device clock to local time")
301
+ n = sub.add_parser("set-name", help="rename the device (max 16 bytes)")
302
+ n.add_argument("name")
303
+ r = sub.add_parser("raw", help="send a raw framed command")
304
+ r.add_argument("id", help="command id, hex (e.g. 0b)")
305
+ r.add_argument("type", help="type, hex (10/11/12/13)")
306
+ r.add_argument("payload", nargs="?", default="", help="payload hex")
307
+ args = ap.parse_args(argv)
308
+
309
+ logging.basicConfig(format=" [%(name)s] %(message)s", stream=sys.stderr)
310
+ logging.getLogger("sd10").setLevel(logging.DEBUG if args.verbose else logging.WARNING)
311
+ try:
312
+ asyncio.run(_run(args))
313
+ except SD10Error as ex:
314
+ sys.exit(f"error: {ex}")
315
+ except KeyboardInterrupt:
316
+ pass
317
+
318
+
319
+ if __name__ == "__main__":
320
+ main()