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.
- pysd10-0.2.0.dist-info/METADATA +124 -0
- pysd10-0.2.0.dist-info/RECORD +15 -0
- pysd10-0.2.0.dist-info/WHEEL +5 -0
- pysd10-0.2.0.dist-info/entry_points.txt +2 -0
- pysd10-0.2.0.dist-info/top_level.txt +1 -0
- sd10/__init__.py +44 -0
- sd10/__main__.py +3 -0
- sd10/cli.py +320 -0
- sd10/client.py +736 -0
- sd10/color.py +287 -0
- sd10/data/lab_weights.json +1 -0
- sd10/export.py +67 -0
- sd10/protocol.py +448 -0
- sd10/py.typed +0 -0
- sd10/sync.py +140 -0
|
@@ -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 @@
|
|
|
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
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()
|