summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Kolset <ckolset@colostate.edu>2026-08-13 11:09:03 -0600
committerChristian Kolset <ckolset@colostate.edu>2026-08-13 11:09:03 -0600
commit4d08be2656d5599caf60ab5163a9d2305b397daf (patch)
treed78e764e21995b47eca79d184460f14eb52cdd22
parent012a1093e3cb9a1b376fc3beee4df859ccfb8da5 (diff)
Re-detect serial device ports by identity when a profile loads on another machine
COM/tty numbers aren't stable across machines or replugs. Devices now save their USB serial number and OS description alongside the port; on profile load, if the saved port isn't present, ArduinoLayer.find_port_by_identity() looks for a match and remaps it. Falls back to the saved port (device shows ERROR) if nothing matches. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
-rw-r--r--api_layers/arduino_layer.py40
-rw-r--r--core/profile.py28
-rw-r--r--devices/arduino_device.py8
-rw-r--r--devices/serial_device.py5
4 files changed, 78 insertions, 3 deletions
diff --git a/api_layers/arduino_layer.py b/api_layers/arduino_layer.py
index a3c3584..07520d6 100644
--- a/api_layers/arduino_layer.py
+++ b/api_layers/arduino_layer.py
@@ -575,6 +575,46 @@ class ArduinoLayer:
return desc
return ""
+ @staticmethod
+ def port_serial_number(port: str) -> str:
+ """USB serial number for a currently-connected port, if the OS/driver
+ exposes one — empty string if unavailable or the port isn't enumerable."""
+ if not _SERIAL_AVAILABLE:
+ return ""
+ try:
+ for p in serial.tools.list_ports.comports():
+ if p.device == port:
+ return getattr(p, "serial_number", None) or ""
+ except Exception as e:
+ print(f"[ArduinoLayer] port_serial_number() error: {e}")
+ return ""
+
+ @staticmethod
+ def find_port_by_identity(description: str = "", serial_number: str = "") -> Optional[str]:
+ """Re-map a device saved in a profile to whatever port it enumerates
+ as on *this* machine — COM/tty numbers aren't stable across machines
+ (or even across replugs on Linux). Prefers USB serial number (unique
+ even between two identical board models); falls back to an exact
+ description-string match. Returns None if nothing matches — caller
+ should fall back to the saved port as-is (device will show ERROR
+ until the operator fixes it manually)."""
+ if not _SERIAL_AVAILABLE:
+ return None
+ try:
+ ports = list(serial.tools.list_ports.comports())
+ except Exception as e:
+ print(f"[ArduinoLayer] find_port_by_identity() error: {e}")
+ return None
+ if serial_number:
+ for p in ports:
+ if (getattr(p, "serial_number", None) or "") == serial_number:
+ return p.device
+ if description:
+ for p in ports:
+ if (p.description or "") == description:
+ return p.device
+ return None
+
def __repr__(self):
mode = "SIM" if self.simulate else f"HW:{self.port}@{self.baud}"
return f"<ArduinoLayer {mode} pins={self.analog_pins}>"
diff --git a/core/profile.py b/core/profile.py
index 1fa667a..c0ae5f1 100644
--- a/core/profile.py
+++ b/core/profile.py
@@ -325,9 +325,33 @@ class ProfileManager:
# "name" is a display label, not a constructor arg — every device
# factory builds its own default name internally, so apply it
# after construction instead of passing it through.
- custom_name = dev_cfg.get("name")
+ # "port_desc"/"port_serial" are identity metadata for port
+ # re-detection below, not constructor args either.
+ custom_name = dev_cfg.get("name")
+ port_desc = dev_cfg.get("port_desc", "")
+ port_serial = dev_cfg.get("port_serial", "")
kwargs = {k: v for k, v in dev_cfg.items()
- if k not in ("device_type", "name")}
+ if k not in ("device_type", "name", "port_desc", "port_serial")}
+
+ # Re-map the saved port to whatever it enumerates as on *this*
+ # machine — COM/tty numbers aren't stable across machines (or
+ # even across replugs on some OSes). Only if the saved port
+ # isn't already present verbatim (same-machine reload — nothing
+ # to fix) and we have something to match against.
+ if not kwargs.get("simulate", True) and "port" in kwargs and (port_desc or port_serial):
+ from api_layers.arduino_layer import ArduinoLayer
+ current_ports = {dev for dev, _ in ArduinoLayer.list_ports()}
+ if kwargs["port"] not in current_ports:
+ found = ArduinoLayer.find_port_by_identity(port_desc, port_serial)
+ if found:
+ print(f"[Profile] '{dev_id}': port {kwargs['port']!r} not found, "
+ f"re-mapped to {found!r} by device identity")
+ kwargs["port"] = found
+ else:
+ print(f"[Profile] '{dev_id}': port {kwargs['port']!r} not found and "
+ f"no matching device on this machine — will show ERROR "
+ f"until reconfigured")
+
dev = factory(**kwargs)
if custom_name:
dev.info.name = custom_name
diff --git a/devices/arduino_device.py b/devices/arduino_device.py
index 3ad0c7b..1641eaa 100644
--- a/devices/arduino_device.py
+++ b/devices/arduino_device.py
@@ -206,7 +206,7 @@ class ArduinoDevice(BaseDevice):
return False
def get_save_config(self) -> dict:
- return {
+ cfg = {
"device_type": self.DEVICE_TYPE,
"device_id": self.info.device_id,
"name": self.info.name,
@@ -217,6 +217,12 @@ class ArduinoDevice(BaseDevice):
"port": self._port,
"baud": self._baud,
}
+ if not self.simulate:
+ # Saved so a profile loaded on another machine (different COM/tty
+ # numbering) can still find this device — see ProfileManager.apply().
+ cfg["port_desc"] = ArduinoLayer.describe_port(self._port)
+ cfg["port_serial"] = ArduinoLayer.port_serial_number(self._port)
+ return cfg
def get_config_widget(self) -> QWidget:
return ArduinoConfigWidget(self)
diff --git a/devices/serial_device.py b/devices/serial_device.py
index cef34e8..d402f09 100644
--- a/devices/serial_device.py
+++ b/devices/serial_device.py
@@ -210,6 +210,11 @@ class SerialDevice(BaseDevice):
"parse_format": self._fmt,
"simulate": self.simulate,
}
+ if not self.simulate:
+ # Saved so a profile loaded on another machine (different COM/tty
+ # numbering) can still find this device — see ProfileManager.apply().
+ cfg["port_desc"] = ArduinoLayer.describe_port(self._port)
+ cfg["port_serial"] = ArduinoLayer.port_serial_number(self._port)
if self._fmt in _GENERIC_FORMATS:
cfg.update({
"num_channels": len(self.info.channels),