cable-station 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.
- cable_station/__init__.py +3 -0
- cable_station/cli.py +182 -0
- cable_station/config.py +124 -0
- cable_station/flag.py +125 -0
- cable_station/mac_reader.py +200 -0
- cable_station/records.py +226 -0
- cable_station/station.py +577 -0
- cable_station/tester_capture.py +83 -0
- cable_station/throughput.py +65 -0
- cable_station-0.1.0.dist-info/METADATA +202 -0
- cable_station-0.1.0.dist-info/RECORD +14 -0
- cable_station-0.1.0.dist-info/WHEEL +4 -0
- cable_station-0.1.0.dist-info/entry_points.txt +2 -0
- cable_station-0.1.0.dist-info/licenses/LICENSE +21 -0
cable_station/cli.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""The `cable-station` command.
|
|
2
|
+
|
|
3
|
+
Subcommands:
|
|
4
|
+
station the full-screen station (default when no subcommand is given)
|
|
5
|
+
snapshot [--json] what every USB-C port of this Mac sees right now: chip, link, partner
|
|
6
|
+
decode LOG decode a `km003c pd capture --raw` log and print the cable's e-marker fields
|
|
7
|
+
print ID reprint the flag of a recorded cable (--dry-run renders without printing)
|
|
8
|
+
doctor check the tools and folders the station needs
|
|
9
|
+
config print the settings in force and where they came from
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import shutil
|
|
16
|
+
import sys
|
|
17
|
+
from dataclasses import asdict
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from . import __version__, config, records
|
|
21
|
+
from . import tester_capture as em
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def cmd_station(s: config.Settings, a) -> int:
|
|
25
|
+
from .station import CableStation
|
|
26
|
+
if a.hours:
|
|
27
|
+
s.capture_hours = a.hours
|
|
28
|
+
if a.connector:
|
|
29
|
+
s.connector = a.connector
|
|
30
|
+
CableStation(s).run()
|
|
31
|
+
return 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def cmd_snapshot(s: config.Settings, a) -> int:
|
|
35
|
+
if not config.is_macos():
|
|
36
|
+
print("snapshot reads the Mac's port registry (ioreg) and needs macOS.", file=sys.stderr)
|
|
37
|
+
return 2
|
|
38
|
+
from . import mac_reader
|
|
39
|
+
ports = mac_reader.read_ports()
|
|
40
|
+
if a.json:
|
|
41
|
+
out = {}
|
|
42
|
+
for p, st in sorted(ports.items()):
|
|
43
|
+
d = asdict(st)
|
|
44
|
+
d["link_class"], d["link"] = st.link()
|
|
45
|
+
if st.emarker:
|
|
46
|
+
d["emarker"]["label_lines"] = list(st.emarker.label_lines())
|
|
47
|
+
d["emarker"]["watts"] = st.emarker.watts
|
|
48
|
+
out[p] = d
|
|
49
|
+
print(json.dumps(out, indent=2, default=str))
|
|
50
|
+
else:
|
|
51
|
+
for p, st in sorted(ports.items()):
|
|
52
|
+
print(st.summary())
|
|
53
|
+
if a.read_test:
|
|
54
|
+
from . import throughput
|
|
55
|
+
vol = Path("/Volumes") / a.read_test if not a.read_test.startswith("/") else Path(a.read_test)
|
|
56
|
+
if not vol.is_dir():
|
|
57
|
+
print(f"volume {vol} is not mounted", file=sys.stderr)
|
|
58
|
+
return 2
|
|
59
|
+
res = throughput.read_throughput(vol, a.seconds)
|
|
60
|
+
if res is None:
|
|
61
|
+
print(f"no files of 256 MB or more on {vol} to read")
|
|
62
|
+
else:
|
|
63
|
+
print(f"read test (experimental): {res[0]:,.0f} MB/s over {res[1] / 1e6:,.0f} MB from {vol}")
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def cmd_decode(s: config.Settings, a) -> int:
|
|
68
|
+
text = Path(a.log).read_text(errors="replace")
|
|
69
|
+
res = em.decode(text)
|
|
70
|
+
if not res:
|
|
71
|
+
print("no cable Discover Identity ACK on SOP' found in the capture", file=sys.stderr)
|
|
72
|
+
return 1
|
|
73
|
+
if a.json:
|
|
74
|
+
print(json.dumps(res, indent=2))
|
|
75
|
+
else:
|
|
76
|
+
for r in res:
|
|
77
|
+
print(em.format_result(r))
|
|
78
|
+
print()
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cmd_print(s: config.Settings, a) -> int:
|
|
83
|
+
from .station import Printer
|
|
84
|
+
store = records.Store(s.data_dir)
|
|
85
|
+
rec = store.get(a.id if a.id.startswith("C") else f"C{a.id}")
|
|
86
|
+
if rec is None:
|
|
87
|
+
print(f"no record {a.id} in {s.data_dir}", file=sys.stderr)
|
|
88
|
+
return 1
|
|
89
|
+
s.ensure_dirs()
|
|
90
|
+
line1, line2 = rec["data_label"], rec["power_label"].split(" · #")[0]
|
|
91
|
+
printer = Printer(s)
|
|
92
|
+
png, desc = printer.render(rec["id"], line1, line2, rec.get("label_note", ""))
|
|
93
|
+
print(f"{rec['id']}: {line1} / {line2} · #{rec['id'][1:]} -> {png} ({desc})")
|
|
94
|
+
if a.preview:
|
|
95
|
+
printer.open_preview(png)
|
|
96
|
+
if not a.render_only:
|
|
97
|
+
print(printer.print(rec["id"], line1, line2, rec.get("label_note", ""), dry_run=a.dry_run))
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def cmd_doctor(s: config.Settings, a) -> int:
|
|
102
|
+
ok = True
|
|
103
|
+
|
|
104
|
+
def row(label, good, detail):
|
|
105
|
+
nonlocal ok
|
|
106
|
+
ok = ok and (good is not False)
|
|
107
|
+
mark = {True: "ok ", False: "MISSING", None: "info"}[good]
|
|
108
|
+
print(f"{mark:8}{label:28}{detail}")
|
|
109
|
+
|
|
110
|
+
row("config file", True if Path(s.source).is_file() else None, s.source if s.source != "defaults" else f"none (defaults in force; create {config.config_path()})")
|
|
111
|
+
row("data dir (records)", s.data_dir.is_dir() or None, f"{s.data_dir} ({len(records.Store(s.data_dir).read_all()) if s.data_dir.is_dir() else 0} cables)" if s.data_dir.is_dir() else f"{s.data_dir} (created on first run)")
|
|
112
|
+
row("state dir", s.state_dir.is_dir() or None, str(s.state_dir))
|
|
113
|
+
km = shutil.which(s.km003c) or (s.km003c if Path(s.km003c).is_file() else None)
|
|
114
|
+
row("km003c CLI (tester path)", bool(km), km or f"{s.km003c} not on PATH: build okhsunrog/km003c-rs (see README); modes 2 and 3 still work")
|
|
115
|
+
pt = shutil.which("ptouch-label")
|
|
116
|
+
row("ptouch-label (printing)", bool(pt), pt or "not on PATH: `uv tool install git+https://github.com/johntrandall/ptouch-label`; flags still render to the state dir")
|
|
117
|
+
row("printer model", True, f"{s.printer_model}, {s.tape_mm} mm tape")
|
|
118
|
+
if config.is_macos():
|
|
119
|
+
from . import mac_reader
|
|
120
|
+
row("tester on USB", mac_reader.tester_present() or None, "a POWER-Z device is on the USB tree" if mac_reader.tester_present() else "no POWER-Z device seen on USB (plug the tester's PC port into this Mac for mode 1)")
|
|
121
|
+
try:
|
|
122
|
+
ports = mac_reader.read_ports()
|
|
123
|
+
row("Mac port registry", True, f"{len(ports)} USB-C ports readable (modes 2 and 3)")
|
|
124
|
+
except Exception as e:
|
|
125
|
+
row("Mac port registry", False, f"ioreg failed: {e}")
|
|
126
|
+
else:
|
|
127
|
+
row("Mac port registry", None, "not macOS: modes 2 and 3 (the Mac reader) are unavailable; the tester path and records work")
|
|
128
|
+
row("partners", True, ", ".join(f"{k}: {', '.join(v) or 'any device'}" for k, v in s.partners.items()))
|
|
129
|
+
print("\nall required tools present" if ok else "\nsomething is missing; the lines marked MISSING say what to install")
|
|
130
|
+
return 0 if ok else 1
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def cmd_config(s: config.Settings, a) -> int:
|
|
134
|
+
d = asdict(s)
|
|
135
|
+
d["data_dir"], d["state_dir"] = str(s.data_dir), str(s.state_dir)
|
|
136
|
+
d["config_path"] = str(config.config_path())
|
|
137
|
+
print(json.dumps(d, indent=2))
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
142
|
+
ap = argparse.ArgumentParser(prog="cable-station", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
143
|
+
ap.add_argument("--version", action="version", version=f"cable-station {__version__}")
|
|
144
|
+
ap.add_argument("--config", help="settings file (default ~/.config/cable-station/config.toml)")
|
|
145
|
+
sub = ap.add_subparsers(dest="cmd")
|
|
146
|
+
p = sub.add_parser("station", help="the full-screen station (default)")
|
|
147
|
+
p.add_argument("--hours", type=float, help="length of one tester capture before it restarts")
|
|
148
|
+
p.add_argument("--connector", help='connector type recorded for new cables, e.g. "USB-C to USB-C"')
|
|
149
|
+
p.set_defaults(func=cmd_station)
|
|
150
|
+
p = sub.add_parser("snapshot", help="what every USB-C port sees right now (macOS)")
|
|
151
|
+
p.add_argument("--json", action="store_true")
|
|
152
|
+
p.add_argument("--read-test", metavar="VOLUME", help="experimental: also read files from this mounted volume and report MB/s")
|
|
153
|
+
p.add_argument("--seconds", type=float, default=8.0)
|
|
154
|
+
p.set_defaults(func=cmd_snapshot)
|
|
155
|
+
p = sub.add_parser("decode", help="decode a `km003c pd capture --raw` log")
|
|
156
|
+
p.add_argument("log")
|
|
157
|
+
p.add_argument("--json", action="store_true")
|
|
158
|
+
p.set_defaults(func=cmd_decode)
|
|
159
|
+
p = sub.add_parser("print", help="reprint a recorded cable's flag")
|
|
160
|
+
p.add_argument("id", help="C17 or 17")
|
|
161
|
+
p.add_argument("--dry-run", action="store_true", help="pass --dry-run to ptouch-label")
|
|
162
|
+
p.add_argument("--render-only", action="store_true", help="render the PNG, do not call ptouch-label")
|
|
163
|
+
p.add_argument("--preview", action="store_true", help="open the rendered PNG")
|
|
164
|
+
p.set_defaults(func=cmd_print)
|
|
165
|
+
p = sub.add_parser("doctor", help="check tools and folders")
|
|
166
|
+
p.set_defaults(func=cmd_doctor)
|
|
167
|
+
p = sub.add_parser("config", help="print the settings in force")
|
|
168
|
+
p.set_defaults(func=cmd_config)
|
|
169
|
+
return ap
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def main(argv=None) -> int:
|
|
173
|
+
ap = build_parser()
|
|
174
|
+
a = ap.parse_args(sys.argv[1:] if argv is None else argv)
|
|
175
|
+
s = config.load(Path(a.config).expanduser() if a.config else None)
|
|
176
|
+
if a.cmd is None: # bare `cable-station` opens the station
|
|
177
|
+
return cmd_station(s, argparse.Namespace(hours=None, connector=None))
|
|
178
|
+
return a.func(s, a)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
if __name__ == "__main__":
|
|
182
|
+
sys.exit(main())
|
cable_station/config.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Settings for cable-station: a TOML file plus environment variables.
|
|
2
|
+
|
|
3
|
+
Resolution order for every setting: environment variable, then the config file, then the default.
|
|
4
|
+
The config file is `~/.config/cable-station/config.toml` (override the path with `CABLE_STATION_CONFIG`).
|
|
5
|
+
Nothing site-specific is hard-coded here: printer, partner device names, and the data folder all come
|
|
6
|
+
from the file or the environment.
|
|
7
|
+
|
|
8
|
+
Example `config.toml`:
|
|
9
|
+
|
|
10
|
+
data_dir = "~/cable-station/cables" # one Markdown note per cable, plus the Obsidian Base
|
|
11
|
+
state_dir = "~/.local/state/cable-station" # log, per-cable captures, rendered flag previews
|
|
12
|
+
printer_model = "PT-P950NW" # ptouch-label --model
|
|
13
|
+
tape_mm = 9 # tape loaded in the printer: 6, 9, 12, 18 or 24
|
|
14
|
+
km003c = "km003c" # the km003c CLI (name on PATH or a full path)
|
|
15
|
+
capture_hours = 3 # how long one tester capture runs before it restarts
|
|
16
|
+
connector = "USB-C to USB-C"
|
|
17
|
+
|
|
18
|
+
[partners]
|
|
19
|
+
drive = ["Envoy", "Sabrent"] # drive-mode partner names as macOS reports them (empty: any device)
|
|
20
|
+
dock = ["TS5"] # dock-mode partner names
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
import tomllib
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
ENV_PREFIX = "CABLE_STATION_"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _env(name: str) -> str | None:
|
|
34
|
+
v = os.environ.get(ENV_PREFIX + name)
|
|
35
|
+
return v if v else None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def config_path() -> Path:
|
|
39
|
+
return Path(_env("CONFIG") or "~/.config/cable-station/config.toml").expanduser()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Settings:
|
|
44
|
+
data_dir: Path = Path("~/cable-station/cables").expanduser()
|
|
45
|
+
state_dir: Path = Path("~/.local/state/cable-station").expanduser()
|
|
46
|
+
printer_model: str = "PT-P950NW"
|
|
47
|
+
tape_mm: int = 9
|
|
48
|
+
km003c: str = "km003c"
|
|
49
|
+
capture_hours: float = 3.0
|
|
50
|
+
connector: str = "USB-C to USB-C"
|
|
51
|
+
# Vertical calibration offset of the printer, in mm: ink pushed off the band by this shift is clipped
|
|
52
|
+
# by ptouch-label (it shrinks the label), so the renderer keeps all ink inside the reachable rows.
|
|
53
|
+
vertical_offset_mm: float = 1.45
|
|
54
|
+
# Partner device names (as macOS reports them) that modes 2 and 3 watch for; empty means any device.
|
|
55
|
+
partners: dict[str, list[str]] = field(default_factory=lambda: {"drive": [], "dock": []})
|
|
56
|
+
source: str = "defaults"
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def captures_dir(self) -> Path:
|
|
60
|
+
return self.state_dir / "captures"
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def previews_dir(self) -> Path:
|
|
64
|
+
return self.state_dir / "previews"
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def log_path(self) -> Path:
|
|
68
|
+
return self.state_dir / "cable_watch.log"
|
|
69
|
+
|
|
70
|
+
def ensure_dirs(self) -> None:
|
|
71
|
+
for d in (self.data_dir, self.state_dir, self.captures_dir, self.previews_dir):
|
|
72
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def load(path: Path | None = None) -> Settings:
|
|
76
|
+
"""Settings from the environment, the TOML file, and the defaults, in that order of precedence."""
|
|
77
|
+
s = Settings()
|
|
78
|
+
p = path or config_path()
|
|
79
|
+
file: dict = {}
|
|
80
|
+
if p.is_file():
|
|
81
|
+
with open(p, "rb") as f:
|
|
82
|
+
file = tomllib.load(f)
|
|
83
|
+
s.source = str(p)
|
|
84
|
+
if "data_dir" in file:
|
|
85
|
+
s.data_dir = Path(file["data_dir"]).expanduser()
|
|
86
|
+
if "state_dir" in file:
|
|
87
|
+
s.state_dir = Path(file["state_dir"]).expanduser()
|
|
88
|
+
for key in ("printer_model", "km003c", "connector"):
|
|
89
|
+
if key in file:
|
|
90
|
+
setattr(s, key, str(file[key]))
|
|
91
|
+
if s.km003c.startswith("~"):
|
|
92
|
+
s.km003c = str(Path(s.km003c).expanduser())
|
|
93
|
+
if "tape_mm" in file:
|
|
94
|
+
s.tape_mm = int(file["tape_mm"])
|
|
95
|
+
if "capture_hours" in file:
|
|
96
|
+
s.capture_hours = float(file["capture_hours"])
|
|
97
|
+
if "vertical_offset_mm" in file:
|
|
98
|
+
s.vertical_offset_mm = float(file["vertical_offset_mm"])
|
|
99
|
+
if isinstance(file.get("partners"), dict):
|
|
100
|
+
s.partners = {k: [str(x) for x in v] for k, v in file["partners"].items()}
|
|
101
|
+
# Environment overrides.
|
|
102
|
+
if _env("DATA"):
|
|
103
|
+
s.data_dir = Path(_env("DATA")).expanduser()
|
|
104
|
+
if _env("STATE"):
|
|
105
|
+
s.state_dir = Path(_env("STATE")).expanduser()
|
|
106
|
+
if _env("PRINTER"):
|
|
107
|
+
s.printer_model = _env("PRINTER")
|
|
108
|
+
if _env("TAPE"):
|
|
109
|
+
s.tape_mm = int(_env("TAPE"))
|
|
110
|
+
if _env("KM003C"):
|
|
111
|
+
s.km003c = _env("KM003C")
|
|
112
|
+
if _env("CONNECTOR"):
|
|
113
|
+
s.connector = _env("CONNECTOR")
|
|
114
|
+
if _env("CAPTURE_HOURS"):
|
|
115
|
+
s.capture_hours = float(_env("CAPTURE_HOURS"))
|
|
116
|
+
if _env("DRIVE_PARTNERS"):
|
|
117
|
+
s.partners["drive"] = [x.strip() for x in _env("DRIVE_PARTNERS").split(",") if x.strip()]
|
|
118
|
+
if _env("DOCK_PARTNERS"):
|
|
119
|
+
s.partners["dock"] = [x.strip() for x in _env("DOCK_PARTNERS").split(",") if x.strip()]
|
|
120
|
+
return s
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def is_macos() -> bool:
|
|
124
|
+
return sys.platform == "darwin"
|
cable_station/flag.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Render a two-line USB cable wrap-flag as a PNG for `ptouch-label --image`.
|
|
2
|
+
|
|
3
|
+
A wrap-flag is one strip whose middle wraps around the cable and whose two ends stick together as a flag.
|
|
4
|
+
The same two lines are printed on each end so both flag faces read correctly. Line 2 ends with the cable's
|
|
5
|
+
number as " · #N" in the same type as the specs; the middle dot alone is the separator.
|
|
6
|
+
|
|
7
|
+
The reachable-band rule: the printer may print with a vertical calibration offset (1.45 mm on one PT-P950NW).
|
|
8
|
+
`ptouch-label` shrinks any label whose ink would be pushed off the band by that shift, so every row of ink,
|
|
9
|
+
including the fold line, stays inside H - 2 * offset rows. The tests check this.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
16
|
+
|
|
17
|
+
DPI = 360
|
|
18
|
+
PRINTABLE_PX = {6: 64, 9: 106, 12: 150, 18: 234, 24: 320} # printable rows per tape width (PT-P950NW family)
|
|
19
|
+
FONT_CANDIDATES = [
|
|
20
|
+
("/System/Library/Fonts/HelveticaNeue.ttc", 1), # macOS, bold face
|
|
21
|
+
("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 0), # Debian and Ubuntu
|
|
22
|
+
("/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", 0), # Fedora
|
|
23
|
+
("/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", 0), # Arch
|
|
24
|
+
]
|
|
25
|
+
MIN_FONT = 30 # the reachable band (64 px at 9 mm under a 1.45 mm offset) caps two lines near 30 px; longer flags only help width
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def mm(v: float) -> int:
|
|
29
|
+
return round(v / 25.4 * DPI)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _font_file() -> tuple[str, int] | None:
|
|
33
|
+
for path, index in FONT_CANDIDATES:
|
|
34
|
+
if Path(path).exists():
|
|
35
|
+
return path, index
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_font(size: int):
|
|
40
|
+
f = _font_file()
|
|
41
|
+
if f is None:
|
|
42
|
+
return ImageFont.load_default(size=size)
|
|
43
|
+
return ImageFont.truetype(f[0], size, index=f[1])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def fit_font(draw, lines, max_w, max_h):
|
|
47
|
+
size = 200
|
|
48
|
+
while size > 8:
|
|
49
|
+
f = load_font(size)
|
|
50
|
+
widths = [draw.textbbox((0, 0), t.replace("\u26a1", "MM"), font=f)[2] for t in lines]
|
|
51
|
+
heights = [draw.textbbox((0, 0), t, font=f)[3] - draw.textbbox((0, 0), t, font=f)[1] for t in lines]
|
|
52
|
+
if max(widths) <= max_w and sum(heights) + (len(lines) - 1) * size * 0.15 <= max_h:
|
|
53
|
+
return f
|
|
54
|
+
size -= 2
|
|
55
|
+
return load_font(8)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def render(out, line1: str, line2: str, tape: int = 9, length: int = 70, wrap: int = 20, pad: float = 1.5,
|
|
59
|
+
cable_id: str | None = None, vertical_offset_mm: float = 1.45) -> str:
|
|
60
|
+
"""Render the flag to `out` and return a one-line description of its geometry."""
|
|
61
|
+
H = PRINTABLE_PX[tape]
|
|
62
|
+
id_text = f" · #{cable_id.lstrip('C#')}" if cable_id else ""
|
|
63
|
+
lines = [line1, line2 + id_text]
|
|
64
|
+
reach = H - 2 * (mm(vertical_offset_mm) + 1) # the rows the shifted band can still print
|
|
65
|
+
while True: # grow the flag, 10 mm at a time up to 130 mm, until the text fits at a readable size
|
|
66
|
+
W = mm(length)
|
|
67
|
+
face_w = (W - mm(wrap)) // 2
|
|
68
|
+
img = Image.new("L", (W, H), 255)
|
|
69
|
+
d = ImageDraw.Draw(img)
|
|
70
|
+
font = fit_font(d, lines, face_w - mm(pad) * 2, reach)
|
|
71
|
+
if font.size >= MIN_FONT or length >= 130:
|
|
72
|
+
break
|
|
73
|
+
length += 10
|
|
74
|
+
id_font = font # same weight and size as the specs; the separator alone marks it
|
|
75
|
+
gap = font.size * 0.08
|
|
76
|
+
boxes = [d.textbbox((0, 0), line1.replace("\u26a1", "MM"), font=font), d.textbbox((0, 0), line2, font=font)]
|
|
77
|
+
id_w = (d.textlength(" · ", font=font) + d.textlength(id_text[3:], font=id_font)) if id_text else 0
|
|
78
|
+
total_h = sum(b[3] - b[1] for b in boxes) + gap
|
|
79
|
+
|
|
80
|
+
def draw_with_bolt(x, y_top, text, f, box):
|
|
81
|
+
"""Draw text; a \u26a1 in it becomes a drawn lightning bolt (the label font has no such glyph).
|
|
82
|
+
The bolt spans exactly the text's ink height (box), so it never leaves the reachable band."""
|
|
83
|
+
parts = text.split("\u26a1")
|
|
84
|
+
for i, part in enumerate(parts):
|
|
85
|
+
if i:
|
|
86
|
+
h = box[3] - box[1]
|
|
87
|
+
w = h * 0.72
|
|
88
|
+
bx, by = x, y_top + box[1]
|
|
89
|
+
pts = [(bx + w * 0.70, by), (bx + w * 0.05, by + h * 0.58), (bx + w * 0.42, by + h * 0.58),
|
|
90
|
+
(bx + w * 0.25, by + h), (bx + w * 0.95, by + h * 0.38), (bx + w * 0.55, by + h * 0.38), (bx + w * 0.80, by)]
|
|
91
|
+
d.polygon(pts, fill=0)
|
|
92
|
+
x += w + f.size * 0.10
|
|
93
|
+
d.text((x, y_top), part, font=f, fill=0)
|
|
94
|
+
x += d.textlength(part, font=f)
|
|
95
|
+
|
|
96
|
+
for face_x in (0, W - face_w):
|
|
97
|
+
y = (H - total_h) / 2
|
|
98
|
+
b = boxes[0]
|
|
99
|
+
draw_with_bolt(face_x + (face_w - (b[2] - b[0])) / 2 - b[0], y - b[1], line1, font, b)
|
|
100
|
+
y += (b[3] - b[1]) + gap
|
|
101
|
+
b = boxes[1]
|
|
102
|
+
w2 = (b[2] - b[0]) + id_w
|
|
103
|
+
x = face_x + (face_w - w2) / 2 - b[0]
|
|
104
|
+
baseline = y - b[1] + font.getmetrics()[0]
|
|
105
|
+
d.text((x, baseline), line2, font=font, fill=0, anchor="ls")
|
|
106
|
+
if id_text:
|
|
107
|
+
sep = " · "
|
|
108
|
+
d.text((x + (b[2] - b[0]), baseline), sep, font=font, fill=0, anchor="ls")
|
|
109
|
+
d.text((x + (b[2] - b[0]) + d.textlength(sep, font=font), baseline), id_text[len(sep):], font=id_font, fill=0, anchor="ls")
|
|
110
|
+
# Fold line: a dotted vertical line at the exact centre of the wrap zone, kept clear of the tape edges.
|
|
111
|
+
cx = W // 2
|
|
112
|
+
margin = mm(vertical_offset_mm) + 2
|
|
113
|
+
for y in range(margin, H - margin, 6):
|
|
114
|
+
d.line([(cx, y), (cx, min(H - margin - 1, y + 2))], fill=0, width=2)
|
|
115
|
+
img.save(out)
|
|
116
|
+
return f"{W}x{H}px, tape {tape} mm, {length} mm long, font {font.size}px, faces {face_w / DPI * 25.4:.0f} mm, wrap {wrap} mm"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def ink_rows(path) -> tuple[int, int]:
|
|
120
|
+
"""(first, last) row that carries ink in a rendered flag; used to check the reachable-band rule."""
|
|
121
|
+
img = Image.open(path).convert("L")
|
|
122
|
+
w, h = img.size
|
|
123
|
+
px = img.load()
|
|
124
|
+
rows = [y for y in range(h) if any(px[x, y] < 128 for x in range(w))]
|
|
125
|
+
return (rows[0], rows[-1]) if rows else (-1, -1)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Read a USB-C cable's e-marker and trained link from the Mac's own port registry (macOS only).
|
|
2
|
+
|
|
3
|
+
The Mac's port controller runs the PD Discover Identity itself and macOS exposes the cable's VDOs under
|
|
4
|
+
`Port-USB-C@N / CC / SOP'` (IOObjectClass IOPortTransportComponentCCUSBPDSOPp). So with a partner device
|
|
5
|
+
on the far end, the Mac reads the chip the way the tester does, and also reports the link that trained
|
|
6
|
+
(USB2, USB3, CIO/Thunderbolt). Verified on an M3 Max MacBook Pro running macOS 15.
|
|
7
|
+
|
|
8
|
+
API: read_ports() -> {port_number: PortState}; parse_ports(plist_bytes) does the same from a saved
|
|
9
|
+
`ioreg -a -l -r -n Port-USB-C` plist (used by the tests). PortState.emarker is an EMarker or None (no chip
|
|
10
|
+
answered on SOP'); PortState.link() -> (class, label) for the trained link.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import plistlib
|
|
16
|
+
import subprocess
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
|
|
19
|
+
SPEED_LABEL = {0: "USB 2.0 480M", 1: "USB 3.2 5G", 2: "USB4 20G · USB 3.2 10G", 3: "USB4 ≥40G · USB 3.2 10G", 4: "USB4 80G · USB 3.2 10G"}
|
|
20
|
+
LATENCY_M = {1: "~1 m", 2: "~2 m", 3: "~3 m", 4: "~4 m", 5: "~5 m", 6: "~6 m", 7: "~7 m", 8: ">7 m"}
|
|
21
|
+
MAX_VBUS_V = {0: 20, 1: 30, 2: 40, 3: 50}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class EMarker:
|
|
26
|
+
vendor_id: int
|
|
27
|
+
product_id: int
|
|
28
|
+
xid: int
|
|
29
|
+
kind: str # passive | active | other
|
|
30
|
+
speed_code: int
|
|
31
|
+
current_a: int
|
|
32
|
+
max_vbus_v: int | None
|
|
33
|
+
epr: bool
|
|
34
|
+
length: str
|
|
35
|
+
cable_vdo: int
|
|
36
|
+
raw_vdos: list[str]
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def watts(self) -> int:
|
|
40
|
+
return 240 if (self.epr and self.current_a >= 5) else 20 * self.current_a
|
|
41
|
+
|
|
42
|
+
def label_lines(self) -> tuple[str, str]:
|
|
43
|
+
line1 = SPEED_LABEL.get(self.speed_code, f"speed code {self.speed_code}") + (" Active" if self.kind == "active" else "")
|
|
44
|
+
line2 = f"{self.watts}W {self.current_a}A" + (" EPR" if self.epr else "")
|
|
45
|
+
return line1, line2
|
|
46
|
+
|
|
47
|
+
def as_reading(self) -> dict:
|
|
48
|
+
"""The same dict shape tester_capture.decode() returns, so records.from_decode() takes either."""
|
|
49
|
+
line1, line2 = self.label_lines()
|
|
50
|
+
return {"product_type": f"{self.kind} cable", "vendor_id": f"0x{self.vendor_id:04X}", "xid": f"0x{self.xid:08X}",
|
|
51
|
+
"product_id": f"0x{self.product_id:04X}", "cable_vdo": f"0x{self.cable_vdo:08X}", "speed": SPEED_LABEL.get(self.speed_code, f"speed code {self.speed_code}"),
|
|
52
|
+
"current": f"{self.current_a}A", "max_vbus": f"{self.max_vbus_v}V" if self.max_vbus_v else "", "epr": self.epr,
|
|
53
|
+
"active": self.kind == "active", "label_line1": line1, "label_line2": line2}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def decode_emarker(meta: dict, spec_revision: int | None) -> EMarker | None:
|
|
57
|
+
vdos = [int.from_bytes(b, "little") for b in meta.get("VDOs", [])]
|
|
58
|
+
if len(vdos) < 4:
|
|
59
|
+
return None
|
|
60
|
+
id_header, cert, product, cable = vdos[0], vdos[1], vdos[2], vdos[3]
|
|
61
|
+
kind = {3: "passive", 4: "active"}.get((id_header >> 27) & 7, "other")
|
|
62
|
+
pd3 = (spec_revision or 0) >= 3
|
|
63
|
+
return EMarker(vendor_id=id_header & 0xFFFF, product_id=(product >> 16) & 0xFFFF, xid=cert, kind=kind,
|
|
64
|
+
speed_code=cable & 7, current_a={1: 3, 2: 5}.get((cable >> 5) & 3, 3),
|
|
65
|
+
max_vbus_v=MAX_VBUS_V[(cable >> 9) & 3] if pd3 else None,
|
|
66
|
+
epr=bool((cable >> 17) & 1) if pd3 else False, # PD 3.1 passive cable VDO bit 17, EPR Mode Capable
|
|
67
|
+
length=LATENCY_M.get((cable >> 13) & 0xF, "?"), cable_vdo=cable, raw_vdos=[f"0x{v:08x}" for v in vdos])
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class PortState:
|
|
72
|
+
port: int
|
|
73
|
+
connected: bool = False
|
|
74
|
+
transports_active: list = field(default_factory=list)
|
|
75
|
+
partner_vid: int | None = None
|
|
76
|
+
emarker: EMarker | None = None
|
|
77
|
+
usb2_rate: str | None = None
|
|
78
|
+
usb2_product: str | None = None
|
|
79
|
+
usb3_rate: str | None = None
|
|
80
|
+
usb3_product: str | None = None
|
|
81
|
+
cio_active: bool = False
|
|
82
|
+
cio_device: str | None = None
|
|
83
|
+
cio_cable_speed: int | None = None
|
|
84
|
+
tb_speed: str | None = None
|
|
85
|
+
dp_rate: str | None = None
|
|
86
|
+
|
|
87
|
+
def devices(self) -> str:
|
|
88
|
+
return " / ".join(x for x in (self.cio_device, self.usb3_product, self.usb2_product) if x) or "nothing"
|
|
89
|
+
|
|
90
|
+
def link(self) -> tuple[str, str]:
|
|
91
|
+
"""(class, text) of the link that trained through this cable."""
|
|
92
|
+
if self.cio_active:
|
|
93
|
+
g = (self.tb_speed or "").replace("Up to ", "")
|
|
94
|
+
cls = "tb40" if "40" in g else ("tb20" if "20" in g else "tb")
|
|
95
|
+
return cls, f"Thunderbolt/USB4 {g or 'link'}"
|
|
96
|
+
if self.usb3_rate:
|
|
97
|
+
return ("usb10" if "10" in self.usb3_rate else "usb5"), f"USB3 {self.usb3_rate}"
|
|
98
|
+
if self.usb2_rate:
|
|
99
|
+
return "usb2", f"USB2 {self.usb2_rate}"
|
|
100
|
+
if self.dp_rate:
|
|
101
|
+
return "dp", f"DisplayPort {self.dp_rate}"
|
|
102
|
+
return "none", "no link"
|
|
103
|
+
|
|
104
|
+
def summary(self) -> str:
|
|
105
|
+
e = self.emarker
|
|
106
|
+
chip = (f"chip vendor 0x{e.vendor_id:04X} product 0x{e.product_id:04X} {e.kind} {e.label_lines()} VDO 0x{e.cable_vdo:08X}"
|
|
107
|
+
if e else "no e-marker answered")
|
|
108
|
+
return f"port {self.port}: connected {self.connected} · {self.devices()} · link {self.link()[1]} · {chip}"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _walk(node, out):
|
|
112
|
+
out.append(node)
|
|
113
|
+
for c in node.get("IORegistryEntryChildren", []):
|
|
114
|
+
_walk(c, out)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def parse_ports(raw: bytes) -> dict[int, PortState]:
|
|
118
|
+
"""Parse the output of `ioreg -a -l -r -n Port-USB-C`."""
|
|
119
|
+
roots = plistlib.loads(raw) if raw.strip() else []
|
|
120
|
+
ports: dict[int, PortState] = {}
|
|
121
|
+
for root in roots:
|
|
122
|
+
num = root.get("PortNumber")
|
|
123
|
+
if num is None:
|
|
124
|
+
continue
|
|
125
|
+
nodes: list[dict] = []
|
|
126
|
+
_walk(root, nodes)
|
|
127
|
+
st = PortState(port=num, connected=bool(root.get("ConnectionActive")), transports_active=list(root.get("TransportsActive", [])))
|
|
128
|
+
for n in nodes:
|
|
129
|
+
cls = n.get("IOObjectClass", "")
|
|
130
|
+
desc = n.get("TransportDescription") or n.get("Description") or ""
|
|
131
|
+
if cls == "IOPortTransportComponentCCUSBPDSOPp":
|
|
132
|
+
st.emarker = decode_emarker(n.get("Metadata", {}), n.get("Specification Revision"))
|
|
133
|
+
elif cls == "IOPortTransportComponentCCUSBPDSOP":
|
|
134
|
+
st.partner_vid = n.get("Vendor ID")
|
|
135
|
+
elif cls == "IOPortTransportStateUSB2" and n.get("Active"):
|
|
136
|
+
st.usb2_rate, st.usb2_product = n.get("DataRateDescription"), n.get("Product")
|
|
137
|
+
elif cls == "IOPortTransportStateUSB3" and n.get("Active"):
|
|
138
|
+
st.usb3_rate, st.usb3_product = n.get("DataRateDescription"), n.get("Product")
|
|
139
|
+
elif cls == "IOPortTransportStateCIO" and n.get("Active"):
|
|
140
|
+
st.cio_active, st.cio_device, st.cio_cable_speed = True, n.get("Device Model Name") or n.get("Device Vendor Name"), n.get("CableSpeed")
|
|
141
|
+
elif cls == "IOPortTransportStateDisplayPort" and n.get("Active") and "/CIO/" not in desc:
|
|
142
|
+
st.dp_rate = n.get("LinkRateDescription")
|
|
143
|
+
ports[num] = st
|
|
144
|
+
return ports
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def add_thunderbolt_speeds(ports: dict[int, PortState]) -> None:
|
|
148
|
+
"""Fill tb_speed from `system_profiler SPThunderboltDataType` (the trained Thunderbolt rate per receptacle)."""
|
|
149
|
+
try:
|
|
150
|
+
out = subprocess.run(["system_profiler", "-json", "SPThunderboltDataType"], capture_output=True, check=True, timeout=20).stdout
|
|
151
|
+
for bus in json.loads(out).get("SPThunderboltDataType", []):
|
|
152
|
+
rec = bus.get("receptacle_1_tag", {})
|
|
153
|
+
try:
|
|
154
|
+
num = int(rec.get("receptacle_id_key", ""))
|
|
155
|
+
except ValueError:
|
|
156
|
+
continue
|
|
157
|
+
if num in ports and rec.get("receptacle_status_key") == "receptacle_connected":
|
|
158
|
+
ports[num].tb_speed = rec.get("current_speed_key")
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def read_ports() -> dict[int, PortState]:
|
|
164
|
+
raw = subprocess.run(["ioreg", "-a", "-l", "-r", "-n", "Port-USB-C"], capture_output=True, check=True, timeout=60).stdout
|
|
165
|
+
ports = parse_ports(raw)
|
|
166
|
+
add_thunderbolt_speeds(ports)
|
|
167
|
+
return ports
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def usb_devices() -> dict:
|
|
171
|
+
"""{location_id: (name, speed_text, [disks])} for every device on the Mac's USB tree right now."""
|
|
172
|
+
try:
|
|
173
|
+
out = subprocess.run(["system_profiler", "SPUSBDataType", "-json"], capture_output=True, text=True, timeout=20).stdout
|
|
174
|
+
tree = json.loads(out).get("SPUSBDataType", [])
|
|
175
|
+
except Exception:
|
|
176
|
+
return {}
|
|
177
|
+
found: dict = {}
|
|
178
|
+
speeds = {"low_speed": "1.5 Mb/s", "full_speed": "12 Mb/s", "high_speed": "480 Mb/s", "super_speed": "5 Gb/s",
|
|
179
|
+
"super_speed_plus": "10 Gb/s", "super_speed_plus_20": "20 Gb/s", "super_speed_plus_40": "40 Gb/s"}
|
|
180
|
+
|
|
181
|
+
def walk(items):
|
|
182
|
+
for it in items:
|
|
183
|
+
if "location_id" in it:
|
|
184
|
+
disks = [m.get("bsd_name") for m in it.get("Media", []) if m.get("bsd_name")]
|
|
185
|
+
speed = speeds.get(it.get("device_speed", ""), it.get("device_speed", it.get("speed", "")))
|
|
186
|
+
found[it["location_id"]] = (it.get("_name", "?"), speed, disks)
|
|
187
|
+
walk(it.get("_items", []))
|
|
188
|
+
walk(tree)
|
|
189
|
+
return found
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def tester_present() -> bool:
|
|
193
|
+
"""Is a POWER-Z tester on the host's USB tree? (macOS: system_profiler; elsewhere: assume yes.)"""
|
|
194
|
+
try:
|
|
195
|
+
import sys
|
|
196
|
+
if sys.platform != "darwin":
|
|
197
|
+
return True
|
|
198
|
+
return any(any(k in v[0].upper() for k in ("POWER-Z", "POWER_Z", "POWERZ", "KM003C", "CHARGERLAB")) for v in usb_devices().values())
|
|
199
|
+
except Exception:
|
|
200
|
+
return True
|