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.
@@ -0,0 +1,3 @@
1
+ """cable-station: read USB-C cable e-markers with a POWER-Z KM003C or a Mac, record each cable, and print a wrap-flag."""
2
+
3
+ __version__ = "0.1.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())
@@ -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