648 lines
23 KiB
Python
Executable File
648 lines
23 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Resolve ESP32/ESP8266 serial ports deterministically for local hardware runs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import fnmatch
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
import csv
|
|
from typing import Dict, List, Optional, Tuple
|
|
|
|
PY_SERIAL_ERROR = ""
|
|
try:
|
|
import serial
|
|
from serial.tools import list_ports
|
|
except ImportError as exc:
|
|
serial = None
|
|
list_ports = None
|
|
PY_SERIAL_ERROR = str(exc)
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
PORTS_MAP_PATH = REPO_ROOT / "hardware" / "firmware" / "tools" / "dev" / "ports_map.json"
|
|
|
|
USB_HINTS = ("slab", "usbserial", "wch", "ch340", "cp210", "usbmodem")
|
|
ROLE_HINTS = {
|
|
"esp32": ("esp32",),
|
|
"esp8266_usb": ("esp8266", "nodemcu", "ch340", "cp210"),
|
|
}
|
|
REQUEST_TO_CANONICAL = {"esp32": "esp32", "esp8266": "esp8266_usb"}
|
|
CANONICAL_TO_REQUEST = {"esp32": "esp32", "esp8266_usb": "esp8266"}
|
|
LEARNED_MAP_PATH = REPO_ROOT / "hardware" / "firmware" / ".local" / "ports_map.learned.json"
|
|
ESP32_SIGNATURE = re.compile(r"(BOOT_PROTO|U-SON|U_LOCK|STORY_V2|MP3_DBG)", re.IGNORECASE)
|
|
ESP8266_SIGNATURE = re.compile(r"(ets Jan|Exception \(|Stack smashing|\[SCREEN\])", re.IGNORECASE)
|
|
FINGERPRINT_BAUDS = (115200, 19200)
|
|
FINGERPRINT_TIMEOUT = 2.0
|
|
|
|
DEFAULT_PORTS_FIXTURE = REPO_ROOT / "tools" / "test" / "fixtures" / "ports_list_macos.txt"
|
|
|
|
|
|
class MockPort:
|
|
def __init__(
|
|
self,
|
|
device: str,
|
|
location: str = "",
|
|
description: str = "",
|
|
manufacturer: str = "",
|
|
product: str = "",
|
|
vid: int = 0,
|
|
pid: int = 0,
|
|
) -> None:
|
|
self.device = device
|
|
self.location = location
|
|
self.description = description
|
|
self.manufacturer = manufacturer
|
|
self.product = product
|
|
self.vid = vid
|
|
self.pid = pid
|
|
|
|
@property
|
|
def hwid(self) -> str:
|
|
parts = []
|
|
if self.vid is not None and self.pid is not None:
|
|
parts.append(f"USB VID:PID={self.vid:04X}:{self.pid:04X}")
|
|
if self.location:
|
|
parts.append(f"LOCATION={self.location}")
|
|
return " ".join(parts)
|
|
|
|
|
|
FIXTURE_PORTS: List["MockPort"] = []
|
|
|
|
|
|
def is_bluetooth_port(device: str) -> bool:
|
|
lower = device.lower()
|
|
return "bluetooth" in lower or lower.endswith(".blth")
|
|
|
|
|
|
def normalize_role(value: Optional[str]) -> Optional[str]:
|
|
if not value:
|
|
return None
|
|
lower = value.strip().lower()
|
|
if lower in ("ui", "oled", "esp8266", "esp8266_usb", "port-ui"):
|
|
return "esp8266_usb"
|
|
if lower == "esp32":
|
|
return "esp32"
|
|
return None
|
|
|
|
|
|
def parse_location(port) -> str:
|
|
location = getattr(port, "location", None)
|
|
if location:
|
|
return str(location).lower()
|
|
hwid = str(getattr(port, "hwid", "") or "")
|
|
m = re.search(r"LOCATION=([\w\-.]+)", hwid)
|
|
if m:
|
|
return m.group(1).lower()
|
|
return ""
|
|
|
|
|
|
def load_ports_fixture(fixture_path: Path) -> List[MockPort]:
|
|
if not fixture_path.exists():
|
|
return []
|
|
ports: List[MockPort] = []
|
|
with fixture_path.open(encoding="utf-8") as handle:
|
|
reader = csv.reader(handle)
|
|
for row in reader:
|
|
if not row or row[0].strip().startswith("#"):
|
|
continue
|
|
device = row[0].strip()
|
|
if not device:
|
|
continue
|
|
location = row[1].strip() if len(row) > 1 else ""
|
|
description = row[2].strip() if len(row) > 2 else "mock"
|
|
vidpid = row[3].strip() if len(row) > 3 else ""
|
|
vid = pid = 0
|
|
if vidpid and ":" in vidpid:
|
|
parts = vidpid.split(":", 1)
|
|
try:
|
|
vid = int(parts[0], 16)
|
|
pid = int(parts[1], 16)
|
|
except Exception:
|
|
vid = pid = 0
|
|
ports.append(
|
|
MockPort(
|
|
device=device,
|
|
location=location,
|
|
description=description,
|
|
manufacturer="mock",
|
|
product="mock",
|
|
vid=vid,
|
|
pid=pid,
|
|
)
|
|
)
|
|
return ports
|
|
|
|
|
|
def load_map_file(path: Path) -> Dict[str, Dict[str, str]]:
|
|
data = {"location": {}, "vidpid": {}}
|
|
if not path.exists():
|
|
return data
|
|
try:
|
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return data
|
|
for key, value in (raw.get("location") or {}).items():
|
|
role = normalize_role(str(value))
|
|
if role:
|
|
data["location"][str(key).lower()] = role
|
|
for key, value in (raw.get("vidpid") or {}).items():
|
|
role = normalize_role(str(value))
|
|
if role:
|
|
data["vidpid"][str(key).lower()] = role
|
|
return data
|
|
|
|
|
|
def sort_patterns(patterns: List[dict]) -> List[dict]:
|
|
return sorted(
|
|
patterns,
|
|
key=lambda entry: (
|
|
-len(entry["pattern"]),
|
|
0 if entry["source"] == "learned-map" else 1,
|
|
entry["pattern"],
|
|
),
|
|
)
|
|
|
|
|
|
def load_ports_map() -> Dict[str, object]:
|
|
base = load_map_file(PORTS_MAP_PATH)
|
|
learned = load_map_file(LEARNED_MAP_PATH)
|
|
patterns = []
|
|
for pattern, role in base["location"].items():
|
|
patterns.append({"pattern": pattern, "role": role, "source": "location-map"})
|
|
for pattern, role in learned["location"].items():
|
|
patterns.append({"pattern": pattern, "role": role, "source": "learned-map"})
|
|
patterns = sort_patterns(patterns)
|
|
vidpid: Dict[str, str] = {}
|
|
vidpid.update(learned["vidpid"])
|
|
vidpid.update(base["vidpid"])
|
|
return {"patterns": patterns, "vidpid": vidpid, "learned": learned}
|
|
|
|
|
|
def record_learned_mapping(ports_map: Dict[str, object], location: str, role: str) -> bool:
|
|
if not location:
|
|
return False
|
|
normalized = location.lower()
|
|
learned = ports_map.get("learned") or {"location": {}, "vidpid": {}}
|
|
location_map = learned.setdefault("location", {})
|
|
canonical_role = normalize_role(role)
|
|
if not canonical_role:
|
|
return False
|
|
if location_map.get(normalized) == canonical_role:
|
|
return False
|
|
location_map[normalized] = canonical_role
|
|
patterns = [
|
|
entry
|
|
for entry in ports_map["patterns"]
|
|
if not (entry["pattern"] == normalized and entry["source"] == "learned-map")
|
|
]
|
|
patterns.append({"pattern": normalized, "role": canonical_role, "source": "learned-map"})
|
|
ports_map["patterns"] = sort_patterns(patterns)
|
|
ports_map["learned"] = learned
|
|
return True
|
|
|
|
|
|
def persist_learned_map(learned_map: Dict[str, Dict[str, str]]) -> None:
|
|
if not learned_map:
|
|
return
|
|
try:
|
|
LEARNED_MAP_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
payload = {
|
|
"location": {k: learned_map["location"][k] for k in sorted(learned_map.get("location", {}))},
|
|
"vidpid": {k: learned_map["vidpid"][k] for k in sorted(learned_map.get("vidpid", {}))},
|
|
}
|
|
LEARNED_MAP_PATH.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def is_usb_like(port) -> bool:
|
|
text = " ".join(
|
|
[
|
|
str(getattr(port, "device", "") or ""),
|
|
str(getattr(port, "description", "") or ""),
|
|
str(getattr(port, "manufacturer", "") or ""),
|
|
str(getattr(port, "product", "") or ""),
|
|
str(getattr(port, "hwid", "") or ""),
|
|
]
|
|
).lower()
|
|
if "bluetooth" in text or ".blth" in text:
|
|
return False
|
|
return any(token in text for token in USB_HINTS)
|
|
|
|
|
|
def is_candidate_port(port) -> bool:
|
|
device = str(getattr(port, "device", "") or "")
|
|
if not device:
|
|
return False
|
|
if is_bluetooth_port(device):
|
|
return False
|
|
if device.startswith("/dev/cu."):
|
|
return True
|
|
return is_usb_like(port)
|
|
|
|
|
|
def score_port(port, prefer_cu: bool) -> int:
|
|
device = str(getattr(port, "device", "") or "")
|
|
score = 50
|
|
if prefer_cu and device.startswith("/dev/cu."):
|
|
score -= 20
|
|
elif device.startswith("/dev/cu."):
|
|
score -= 10
|
|
if "slab" in device.lower() or "usbserial" in device.lower() or "wch" in device.lower():
|
|
score -= 6
|
|
if is_usb_like(port):
|
|
score -= 8
|
|
return score
|
|
|
|
|
|
def role_from_map(port, ports_map: Dict[str, object]) -> Tuple[Optional[str], str, str]:
|
|
location = parse_location(port)
|
|
for entry in ports_map.get("patterns", []):
|
|
if location and fnmatch.fnmatch(location, entry["pattern"]):
|
|
return entry["role"], entry["source"], entry["pattern"]
|
|
vid = getattr(port, "vid", None)
|
|
pid = getattr(port, "pid", None)
|
|
if vid is not None and pid is not None:
|
|
key = f"{vid:04x}:{pid:04x}".lower()
|
|
vidpid_map = ports_map.get("vidpid") or {}
|
|
if key in vidpid_map:
|
|
return vidpid_map[key], "vidpid-map", ""
|
|
return None, "", ""
|
|
|
|
|
|
def role_from_hint(port) -> Optional[str]:
|
|
text = " ".join(
|
|
[
|
|
str(getattr(port, "description", "") or ""),
|
|
str(getattr(port, "manufacturer", "") or ""),
|
|
str(getattr(port, "product", "") or ""),
|
|
str(getattr(port, "hwid", "") or ""),
|
|
]
|
|
).lower()
|
|
for role, hints in ROLE_HINTS.items():
|
|
if any(h in text for h in hints):
|
|
return role
|
|
return None
|
|
|
|
|
|
def fingerprint_port(
|
|
device: str,
|
|
bauds: Tuple[int, ...] = FINGERPRINT_BAUDS,
|
|
timeout: float = FINGERPRINT_TIMEOUT,
|
|
) -> Tuple[Optional[str], Optional[int]]:
|
|
if serial is None:
|
|
return None, None
|
|
for baud in bauds:
|
|
try:
|
|
with serial.Serial(device, baud, timeout=0.1) as ser:
|
|
ser.dtr = False
|
|
ser.rts = False
|
|
time.sleep(0.05)
|
|
ser.dtr = True
|
|
ser.rts = True
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
line = ser.readline().decode("utf-8", errors="ignore").strip()
|
|
if not line:
|
|
continue
|
|
text = line
|
|
if ESP32_SIGNATURE.search(text):
|
|
return "esp32", baud
|
|
if ESP8266_SIGNATURE.search(text):
|
|
return "esp8266", baud
|
|
except Exception:
|
|
continue
|
|
return None, None
|
|
|
|
|
|
|
|
def choose_interactive(candidates: List[dict], role: str) -> Optional[str]:
|
|
print(f"Select port for {role}:", file=sys.stderr)
|
|
for idx, item in enumerate(candidates, start=1):
|
|
print(f" {idx}) {item['device']} [{item['reason']}]", file=sys.stderr)
|
|
try:
|
|
raw = input(f"{role} port number [1-{len(candidates)}]: ").strip()
|
|
except EOFError:
|
|
return None
|
|
if not raw.isdigit():
|
|
return None
|
|
pos = int(raw)
|
|
if pos < 1 or pos > len(candidates):
|
|
return None
|
|
return candidates[pos - 1]["device"]
|
|
|
|
|
|
def gather_ports(wait_port: int) -> List:
|
|
deadline = time.monotonic() + max(1, wait_port)
|
|
if FIXTURE_PORTS:
|
|
return FIXTURE_PORTS
|
|
if list_ports is None:
|
|
return []
|
|
while True:
|
|
ports = list(list_ports.comports())
|
|
if ports:
|
|
return ports
|
|
if time.monotonic() >= deadline:
|
|
return []
|
|
time.sleep(0.4)
|
|
|
|
|
|
def classify(ports: List, prefer_cu: bool, ports_map: Dict[str, object], allow_probe: bool) -> Tuple[Dict[str, str], Dict[str, str], Dict[str, str], Dict[str, dict], List[str], bool]:
|
|
filtered = [p for p in ports if is_candidate_port(p)]
|
|
usb_ports = [p for p in filtered if is_usb_like(p)]
|
|
ports = usb_ports if usb_ports else filtered
|
|
candidates: List[dict] = []
|
|
for port in ports:
|
|
device = str(getattr(port, "device", "") or "")
|
|
if not device:
|
|
continue
|
|
mapped_role, mapped_source, _ = role_from_map(port, ports_map)
|
|
hinted_role = role_from_hint(port)
|
|
role = mapped_role or hinted_role
|
|
location = parse_location(port)
|
|
reason = ""
|
|
if mapped_role:
|
|
reason = f"{mapped_source}:{location or 'unknown'}"
|
|
elif hinted_role:
|
|
reason = "usb-hint"
|
|
else:
|
|
reason = "fallback"
|
|
candidates.append(
|
|
{
|
|
"device": device,
|
|
"role": role,
|
|
"location": location,
|
|
"reason": reason,
|
|
"score": score_port(port, prefer_cu),
|
|
}
|
|
)
|
|
|
|
candidates.sort(key=lambda x: (x["score"], x["device"]))
|
|
by_role: Dict[str, List[dict]] = {"esp32": [], "esp8266": []}
|
|
fallback: List[dict] = []
|
|
for cand in candidates:
|
|
canonical_role = normalize_role(cand.get("role"))
|
|
requested_role = CANONICAL_TO_REQUEST.get(canonical_role or "", "")
|
|
if requested_role in by_role:
|
|
by_role[requested_role].append(cand)
|
|
else:
|
|
fallback.append(cand)
|
|
|
|
selected: Dict[str, str] = {}
|
|
reasons: Dict[str, str] = {}
|
|
probe_baud: Dict[str, str] = {}
|
|
details: Dict[str, dict] = {}
|
|
notes: List[str] = []
|
|
learned_dirty = False
|
|
|
|
used = set()
|
|
for role in ("esp32", "esp8266"):
|
|
if by_role[role]:
|
|
selected[role] = by_role[role][0]["device"]
|
|
reasons[role] = by_role[role][0]["reason"]
|
|
details[role] = {
|
|
"port": by_role[role][0]["device"],
|
|
"location": by_role[role][0]["location"] or "unknown",
|
|
"role": REQUEST_TO_CANONICAL[role],
|
|
"reason": by_role[role][0]["reason"],
|
|
}
|
|
used.add(by_role[role][0]["device"])
|
|
|
|
if allow_probe:
|
|
unresolved = [r for r in ("esp32", "esp8266") if r not in selected]
|
|
for cand in candidates:
|
|
if not unresolved:
|
|
break
|
|
if cand["device"] in used:
|
|
continue
|
|
fingerprint_role, fingerprint_baud = fingerprint_port(cand["device"])
|
|
if not fingerprint_role:
|
|
continue
|
|
if fingerprint_role not in unresolved:
|
|
continue
|
|
selected[fingerprint_role] = cand["device"]
|
|
reasons[fingerprint_role] = f"fingerprint:{fingerprint_baud}"
|
|
probe_baud[fingerprint_role] = str(fingerprint_baud)
|
|
details[fingerprint_role] = {
|
|
"port": cand["device"],
|
|
"location": cand["location"] or "unknown",
|
|
"role": REQUEST_TO_CANONICAL[fingerprint_role],
|
|
"reason": f"fingerprint:{fingerprint_baud}",
|
|
}
|
|
notes.append(f"fingerprint:{fingerprint_role}={cand['device']}@{fingerprint_baud}")
|
|
learned_dirty = learned_dirty or record_learned_mapping(
|
|
ports_map, cand["location"], fingerprint_role
|
|
)
|
|
used.add(cand["device"])
|
|
unresolved = [r for r in ("esp32", "esp8266") if r not in selected]
|
|
|
|
used = set(selected.values())
|
|
for role in ("esp32", "esp8266"):
|
|
if role in selected:
|
|
continue
|
|
for cand in candidates:
|
|
if cand["device"] in used:
|
|
continue
|
|
selected[role] = cand["device"]
|
|
reasons[role] = f"deterministic:{cand['reason']}"
|
|
details[role] = {
|
|
"port": cand["device"],
|
|
"location": cand["location"] or "unknown",
|
|
"role": REQUEST_TO_CANONICAL[role],
|
|
"reason": f"deterministic:{cand['reason']}",
|
|
}
|
|
used.add(cand["device"])
|
|
break
|
|
|
|
if len(candidates) > 2:
|
|
notes.append(f"multiple candidates: {len(candidates)}")
|
|
|
|
return selected, reasons, probe_baud, details, notes, learned_dirty
|
|
|
|
|
|
def output_result(result: dict, json_path: str, exit_code: int) -> int:
|
|
if json_path:
|
|
try:
|
|
dest = Path(json_path)
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
dest.write_text(json.dumps(result, indent=2), encoding="utf-8")
|
|
except Exception:
|
|
pass
|
|
print(json.dumps(result))
|
|
return exit_code
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Resolve serial ports for ESP32 + ESP8266")
|
|
parser.add_argument("--port-esp32", default="")
|
|
parser.add_argument("--port-esp8266", default="")
|
|
parser.add_argument("--wait-port", type=int, default=20)
|
|
parser.add_argument("--need-esp32", action="store_true")
|
|
parser.add_argument("--need-esp8266", action="store_true")
|
|
parser.add_argument("--allow-no-hardware", action="store_true")
|
|
parser.add_argument("--auto-ports", dest="auto_ports", action="store_true", default=True)
|
|
parser.add_argument("--no-auto-ports", dest="auto_ports", action="store_false")
|
|
parser.add_argument("--prefer-cu", action="store_true")
|
|
parser.add_argument("--mock", action="store_true")
|
|
parser.add_argument("--ports-fixture", default="")
|
|
parser.add_argument("--interactive", action="store_true")
|
|
parser.add_argument("--ports-resolve-json", default="")
|
|
args = parser.parse_args()
|
|
|
|
prefer_cu = args.prefer_cu or sys.platform == "darwin"
|
|
|
|
env_override = {
|
|
"esp32": False,
|
|
"esp8266": False,
|
|
}
|
|
env_esp32 = os.environ.get("ZACUS_ESP32_PORT", "").strip()
|
|
env_esp8266 = os.environ.get("ZACUS_ESP8266_PORT", "").strip()
|
|
if env_esp32 and not args.port_esp32:
|
|
args.port_esp32 = env_esp32
|
|
env_override["esp32"] = True
|
|
if env_esp8266 and not args.port_esp8266:
|
|
args.port_esp8266 = env_esp8266
|
|
env_override["esp8266"] = True
|
|
|
|
env_mock = os.environ.get("ZACUS_MOCK_PORTS", "0") == "1"
|
|
fixture_arg = args.ports_fixture or os.environ.get("ZACUS_PORTS_FIXTURE", "")
|
|
use_mock = args.mock or env_mock or bool(fixture_arg)
|
|
mock_notes: List[str] = []
|
|
if use_mock:
|
|
fixture_path = Path(fixture_arg) if fixture_arg else DEFAULT_PORTS_FIXTURE
|
|
if fixture_path.exists():
|
|
FIXTURE_PORTS.clear()
|
|
FIXTURE_PORTS.extend(load_ports_fixture(fixture_path))
|
|
if FIXTURE_PORTS:
|
|
mock_notes.append(f"mock fixture={fixture_path.name}")
|
|
else:
|
|
mock_notes.append(f"mock fixture missing: {fixture_path}")
|
|
|
|
ports_map = load_ports_map()
|
|
|
|
initial_ports = {"esp32": args.port_esp32, "esp8266": args.port_esp8266}
|
|
|
|
def reason_label(role: str) -> str:
|
|
if env_override.get(role):
|
|
return "env"
|
|
return "explicit" if initial_ports.get(role) else ""
|
|
|
|
def make_detail(role: str, port: str, reason: str) -> Dict[str, str]:
|
|
return {
|
|
"port": port,
|
|
"location": "explicit" if port else "",
|
|
"role": REQUEST_TO_CANONICAL[role],
|
|
"reason": reason,
|
|
}
|
|
|
|
result = {
|
|
"status": "pass",
|
|
"ports": initial_ports.copy(),
|
|
"reasons": {role: reason_label(role) for role in ("esp32", "esp8266")},
|
|
"probe_baud": {"esp32": "", "esp8266": ""},
|
|
"details": {
|
|
"esp32": make_detail("esp32", initial_ports["esp32"], reason_label("esp32")),
|
|
"esp8266": make_detail("esp8266", initial_ports["esp8266"], reason_label("esp8266")),
|
|
},
|
|
"notes": [],
|
|
}
|
|
result["notes"].extend(mock_notes)
|
|
if PY_SERIAL_ERROR and not use_mock:
|
|
result["notes"].append(f"pyserial missing: {PY_SERIAL_ERROR}")
|
|
|
|
missing_roles = []
|
|
if args.need_esp32 and not result["ports"]["esp32"]:
|
|
missing_roles.append("esp32")
|
|
if args.need_esp8266 and not result["ports"]["esp8266"]:
|
|
missing_roles.append("esp8266")
|
|
|
|
candidates = []
|
|
if missing_roles and args.auto_ports:
|
|
ports = gather_ports(args.wait_port)
|
|
if not ports:
|
|
if args.allow_no_hardware:
|
|
result["status"] = "skip"
|
|
result["notes"].append("no serial ports detected")
|
|
return output_result(result, args.ports_resolve_json, 0)
|
|
result["status"] = "fail"
|
|
result["notes"].append("no serial ports detected")
|
|
return output_result(result, args.ports_resolve_json, 1)
|
|
|
|
selected, reasons, probe_baud, details, notes, learned_dirty = classify(
|
|
ports=ports,
|
|
prefer_cu=prefer_cu,
|
|
ports_map=ports_map,
|
|
allow_probe=True,
|
|
)
|
|
result["notes"].extend(notes)
|
|
|
|
if learned_dirty:
|
|
persist_learned_map(ports_map.get("learned", {}))
|
|
|
|
for role in missing_roles:
|
|
if role in selected:
|
|
result["ports"][role] = selected[role]
|
|
result["reasons"][role] = reasons.get(role, "auto")
|
|
if role in probe_baud:
|
|
result["probe_baud"][role] = probe_baud[role]
|
|
result["details"][role] = details.get(role, result["details"][role])
|
|
|
|
# optional interactive disambiguation when both ports still unresolved or duplicated
|
|
if args.interactive and sys.stdin.isatty():
|
|
if result["ports"].get("esp32") and result["ports"].get("esp8266") and result["ports"]["esp32"] == result["ports"]["esp8266"]:
|
|
candidates = []
|
|
for p in list_ports.comports():
|
|
candidates.append(
|
|
{
|
|
"device": p.device,
|
|
"reason": f"location={parse_location(p) or 'unknown'}",
|
|
}
|
|
)
|
|
candidates.sort(key=lambda x: x["device"])
|
|
if candidates:
|
|
pick1 = choose_interactive(candidates, "esp32")
|
|
if pick1:
|
|
result["ports"]["esp32"] = pick1
|
|
result["reasons"]["esp32"] = "interactive"
|
|
remaining = [c for c in candidates if c["device"] != result["ports"]["esp32"]]
|
|
if remaining:
|
|
pick2 = choose_interactive(remaining, "esp8266")
|
|
if pick2:
|
|
result["ports"]["esp8266"] = pick2
|
|
result["reasons"]["esp8266"] = "interactive"
|
|
|
|
unresolved = []
|
|
if args.need_esp32 and not result["ports"].get("esp32"):
|
|
unresolved.append("esp32")
|
|
if args.need_esp8266 and not result["ports"].get("esp8266"):
|
|
unresolved.append("esp8266")
|
|
|
|
if unresolved:
|
|
if args.allow_no_hardware:
|
|
result["status"] = "skip"
|
|
result["notes"].append(f"unresolved roles: {','.join(unresolved)}")
|
|
else:
|
|
result["status"] = "fail"
|
|
result["notes"].append(f"unresolved roles: {','.join(unresolved)}")
|
|
result["notes"].append("check USB data cable, CP210x/CH340 driver, and press BOOT for ESP32 upload")
|
|
return output_result(result, args.ports_resolve_json, 1)
|
|
|
|
if result["status"] == "pass":
|
|
if result["ports"].get("esp32") and result["ports"].get("esp8266") and result["ports"]["esp32"] == result["ports"]["esp8266"]:
|
|
result["status"] = "fail"
|
|
result["notes"].append("esp32 and esp8266 resolved to the same device")
|
|
if not args.allow_no_hardware:
|
|
return output_result(result, args.ports_resolve_json, 1)
|
|
|
|
return output_result(result, args.ports_resolve_json, 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|