Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,12 @@ defib list-chips
# List available serial ports
defib ports

# Recover a device via UART
# Recover a device via UART using a raw device path
defib burn -c hi3516ev300 -f u-boot.bin -p /dev/ttyUSB0

# Recover a device via UART using a stable alias
defib burn -c hi3516ev300 -f u-boot.bin -p /dev/uart-orangepi5plus

# Interactive TUI
defib tui

Expand All @@ -49,6 +52,7 @@ defib burn -c gk7205v200 -f u-boot.bin --output json
- Multiple interfaces: CLI, TUI, Web UI, JSON for automation
- Network recovery (async TFTP server, broadcast discovery)
- UART session capture/replay (.dcap format)
- Friendly serial-port discovery for multi-UART setups
- macOS serial workaround (ACK byte correction)
- Cross-platform: Linux, macOS, Windows

Expand Down
38 changes: 28 additions & 10 deletions src/defib/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,18 +226,27 @@ def ports(
import json as json_mod
from rich.console import Console
from rich.table import Table
from serial.tools.list_ports import comports
from defib.serial_ports import list_serial_ports

# Filter out ghost/placeholder ports (no USB vendor ID = not a real adapter)
port_list = sorted(
[p for p in comports() if p.vid is not None],
key=lambda p: p.device,
)
port_list = list_serial_ports()

if output == "json":
print(json_mod.dumps({
"ports": [
{"device": p.device, "description": p.description, "hwid": p.hwid}
{
"device": p.device,
"description": p.description,
"hwid": p.hwid,
"alias_device": p.alias_device,
"open_path": p.open_path,
"display_name": p.display_name,
"manufacturer": p.manufacturer,
"product": p.product,
"serial_number": p.serial_number,
"location": p.location,
"vid": p.vid,
"pid": p.pid,
}
for p in port_list
]
}))
Expand All @@ -248,11 +257,20 @@ def ports(
return

table = Table(title="Serial Ports")
table.add_column("Alias", style="green")
table.add_column("Device", style="cyan")
table.add_column("Description")
table.add_column("Hardware ID", style="dim")
table.add_column("Identity")
table.add_column("Location", style="magenta")
table.add_column("Serial", style="yellow")
for p in port_list:
table.add_row(p.device, p.description, p.hwid)
identity = " ".join(part for part in (p.manufacturer, p.product) if part) or p.description
table.add_row(
p.alias_device or "",
p.device,
identity,
p.location or "",
p.serial_number or "",
)
console.print(table)


Expand Down
153 changes: 153 additions & 0 deletions src/defib/serial_ports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"""Shared serial port discovery and display formatting."""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
import sys
from typing import Any

from serial.tools.list_ports import comports


@dataclass(frozen=True)
class SerialPortInfo:
"""Normalized serial port metadata for CLI and TUI surfaces."""

device: str
open_path: str
display_name: str
alias_device: str | None
description: str
manufacturer: str | None
product: str | None
serial_number: str | None
location: str | None
vid: int | None
pid: int | None
hwid: str


def _discover_aliases() -> dict[str, list[str]]:
"""Return symlink aliases keyed by resolved device path."""
alias_map: dict[str, list[str]] = {}

if sys.platform == "win32":
return alias_map

alias_patterns = (
"/dev/uart-*",
"/dev/serial/by-id/*",
"/dev/serial/by-path/*",
)

for pattern in alias_patterns:
for path in sorted(Path("/").glob(pattern.lstrip("/"))):
try:
if not path.is_symlink():
continue
target = str(path.resolve())
except OSError:
continue
alias_map.setdefault(target, []).append(str(path))

return alias_map


def _alias_priority(alias: str) -> tuple[int, str]:
if alias.startswith("/dev/uart-"):
return (0, alias)
if alias.startswith("/dev/serial/by-id/"):
return (1, alias)
if alias.startswith("/dev/serial/by-path/"):
return (2, alias)
return (3, alias)


def _preferred_alias(aliases: list[str]) -> str | None:
if not aliases:
return None
return sorted(aliases, key=_alias_priority)[0]


def _port_identity(manufacturer: str | None, product: str | None, description: str) -> str:
parts = [part for part in (manufacturer, product) if part]
if parts:
return " ".join(parts)
return description or "Unknown serial adapter"


def _port_display_name(
*,
alias_device: str | None,
device: str,
manufacturer: str | None,
product: str | None,
description: str,
location: str | None,
serial_number: str | None,
) -> str:
segments = []
if alias_device:
segments.append(f"{alias_device} -> {device}")
else:
segments.append(device)

segments.append(_port_identity(manufacturer, product, description))

if location:
segments.append(f"loc {location}")
if serial_number:
segments.append(f"ser {serial_number}")

return " | ".join(segments)


def _coerce_attr(port: Any, name: str) -> Any:
return getattr(port, name, None)


def list_serial_ports() -> list[SerialPortInfo]:
"""List USB serial adapters with stable display metadata."""
alias_map = _discover_aliases()
ports = sorted([p for p in comports() if p.vid is not None], key=lambda p: p.device)

entries: list[SerialPortInfo] = []
for port in ports:
device = str(port.device)
alias_device = _preferred_alias(alias_map.get(device, []))
description = str(port.description)
manufacturer = _coerce_attr(port, "manufacturer")
product = _coerce_attr(port, "product")
serial_number = _coerce_attr(port, "serial_number")
location = _coerce_attr(port, "location")
vid = _coerce_attr(port, "vid")
pid = _coerce_attr(port, "pid")
hwid = str(port.hwid)

entries.append(
SerialPortInfo(
device=device,
open_path=alias_device or device,
display_name=_port_display_name(
alias_device=alias_device,
device=device,
manufacturer=manufacturer,
product=product,
description=description,
location=location,
serial_number=serial_number,
),
alias_device=alias_device,
description=description,
manufacturer=manufacturer,
product=product,
serial_number=serial_number,
location=location,
vid=vid,
pid=pid,
hwid=hwid,
)
)

return entries
9 changes: 3 additions & 6 deletions src/defib/tui/screens/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,15 @@

from defib.firmware import has_firmware, download_firmware, get_cached_path
from defib.profiles.loader import list_all_chips
from defib.serial_ports import list_serial_ports


def _get_serial_ports() -> list[tuple[str, str]]:
"""Get available serial ports as (label, value) tuples."""
try:
from serial.tools.list_ports import comports
ports = sorted(
[p for p in comports() if p.vid is not None],
key=lambda p: p.device,
)
ports = list_serial_ports()
if ports:
return [(f"{p.device} - {p.description}", p.device) for p in ports]
return [(p.display_name, p.open_path) for p in ports]
except Exception:
pass
return [("No ports found", "")]
Expand Down
51 changes: 51 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for the CLI interface."""

import re
from types import SimpleNamespace

from typer.testing import CliRunner

Expand Down Expand Up @@ -55,6 +56,56 @@ def test_ports_json(self):
data = json.loads(result.stdout)
assert "ports" in data

def test_ports_json_includes_rich_metadata(self, monkeypatch):
import json

fake_port = SimpleNamespace(
device="/dev/ttyUSB1",
description="FT232R USB UART - FT232R USB UART",
hwid="USB VID:PID=0403:6001 SER=A50285BI LOCATION=5-2",
alias_device="/dev/uart-orangepi5plus",
open_path="/dev/uart-orangepi5plus",
display_name="/dev/uart-orangepi5plus -> /dev/ttyUSB1 | FTDI FT232R USB UART | loc 5-2 | ser A50285BI",
manufacturer="FTDI",
product="FT232R USB UART",
serial_number="A50285BI",
location="5-2",
vid=0x0403,
pid=0x6001,
)
monkeypatch.setattr("defib.serial_ports.list_serial_ports", lambda: [fake_port])

result = runner.invoke(app, ["ports", "--output", "json"])
assert result.exit_code == 0
data = json.loads(result.stdout)
assert data["ports"][0]["device"] == "/dev/ttyUSB1"
assert data["ports"][0]["alias_device"] == "/dev/uart-orangepi5plus"
assert data["ports"][0]["display_name"].startswith("/dev/uart-orangepi5plus -> /dev/ttyUSB1")

def test_ports_human_shows_alias_and_location(self, monkeypatch):
fake_port = SimpleNamespace(
device="/dev/ttyUSB1",
description="FT232R USB UART - FT232R USB UART",
hwid="USB VID:PID=0403:6001 SER=A50285BI LOCATION=5-2",
alias_device="/dev/uart-orangepi5plus",
open_path="/dev/uart-orangepi5plus",
display_name="/dev/uart-orangepi5plus -> /dev/ttyUSB1 | FTDI FT232R USB UART | loc 5-2 | ser A50285BI",
manufacturer="FTDI",
product="FT232R USB UART",
serial_number="A50285BI",
location="5-2",
vid=0x0403,
pid=0x6001,
)
monkeypatch.setattr("defib.serial_ports.list_serial_ports", lambda: [fake_port])

result = runner.invoke(app, ["ports"])
assert result.exit_code == 0
output = _strip_ansi(result.stdout)
assert "/dev/uart-" in output
assert "/dev/ttyUSB1" in output
assert "5-2" in output


class TestDetectHelp:
def test_detect_help(self):
Expand Down
Loading
Loading