Port serial layer to pyserial; add Windows COM detection and PyInstaller spec

- serial_port.py: replace termios/fcntl/select with pyserial wrapper,
  same SerialPort interface preserved for all other modules
- workflow_serial.py: detect_serial_device() uses serial.tools.list_ports
  to enumerate COM ports; removes _fix_permissions() (not needed on Windows);
  multi-device table now shows port description
- es24n_conf.py: enable VT/ANSI colour mode on Windows 10+ via ctypes;
  update docstring for Windows edition
- requirements.txt: pyserial >= 3.5, pyinstaller >= 6.0
- es24n_conf.spec: PyInstaller single-file .exe spec

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-16 15:50:17 -04:00
parent a6a0f1b246
commit 75873dd4e0
5 changed files with 103 additions and 97 deletions

View File

@@ -1,14 +1,14 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
ES24N IOM Network Configuration Tool ES24N IOM Network Configuration Tool — Windows Edition
TrueNAS ES24N Expansion Shelf — Serial Configuration Utility TrueNAS ES24N Expansion Shelf — Serial Configuration Utility
Based on ES24N Product Service Guide v.26011 Based on ES24N Product Service Guide v.26011
Zero external dependencies — Python 3 standard library only. Requires: pyserial (pip install pyserial)
Compatible with TrueNAS (FreeBSD) and Linux. Distributed as a standalone .exe via PyInstaller.
Usage: Usage:
python3 es24n_conf.py python3 es24n_conf.py (or run the packaged .exe)
All files in the modules/ subdirectory must be present: All files in the modules/ subdirectory must be present:
ui.py, serial_port.py, models.py, redfish.py, ui.py, serial_port.py, models.py, redfish.py,
@@ -19,6 +19,12 @@ import os
import sys import sys
import time import time
# Enable ANSI/VT colour codes on Windows 10+
if sys.platform == "win32":
import ctypes
kernel32 = ctypes.windll.kernel32
kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7)
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "modules")) sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "modules"))
from ui import _c, C, banner, draw_box, ok, warn, prompt from ui import _c, C, banner, draw_box, ok, warn, prompt

43
es24n_conf.spec Normal file
View File

@@ -0,0 +1,43 @@
# PyInstaller spec for es24n_conf Windows .exe
# Build with: pyinstaller es24n_conf.spec
from PyInstaller.building.build_main import Analysis, PYZ, EXE
a = Analysis(
["es24n_conf.py"],
pathex=[],
binaries=[],
datas=[
("modules", "modules"),
],
hiddenimports=[
"serial",
"serial.tools",
"serial.tools.list_ports",
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name="es24n_conf",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=True, # console app — must stay True for terminal UI
disable_windowed_traceback=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)

View File

@@ -1,88 +1,64 @@
""" """
serial_port.py — Minimal 8N1 serial port using only the Python standard library. serial_port.py — pyserial-backed 8N1 serial port for the Windows edition.
Replaces pyserial for TrueNAS environments where pip is unavailable. Exposes the same SerialPort interface as the TrueNAS/Linux edition so all
other modules work without modification.
""" """
import fcntl
import os
import select
import termios
import time import time
from typing import Optional from typing import Optional
import serial
class SerialPort: class SerialPort:
BAUD_MAP = {
9600: termios.B9600,
19200: termios.B19200,
38400: termios.B38400,
57600: termios.B57600,
115200: termios.B115200,
}
def __init__(self, port: str, baudrate: int = 115200, timeout: float = 5.0): def __init__(self, port: str, baudrate: int = 115200, timeout: float = 5.0):
self.port = port self.port = port
self.baudrate = baudrate self.baudrate = baudrate
self.timeout = timeout self.timeout = timeout
self._fd: Optional[int] = None self._ser: Optional[serial.Serial] = None
self._saved_attrs = None
# ── Open / close ────────────────────────────────────────────────────────── # ── Open / close ──────────────────────────────────────────────────────────
def open(self): def open(self):
try: try:
self._fd = os.open(self.port, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK) self._ser = serial.Serial(
except OSError as e: port = self.port,
baudrate = self.baudrate,
bytesize = serial.EIGHTBITS,
parity = serial.PARITY_NONE,
stopbits = serial.STOPBITS_ONE,
timeout = 0, # non-blocking reads; we poll manually
xonxoff = False,
rtscts = False,
dsrdtr = False,
)
except serial.SerialException as e:
raise OSError(f"Cannot open {self.port}: {e}") from e raise OSError(f"Cannot open {self.port}: {e}") from e
# Switch back to blocking mode now that O_NOCTTY is set
flags = fcntl.fcntl(self._fd, fcntl.F_GETFL)
fcntl.fcntl(self._fd, fcntl.F_SETFL, flags & ~os.O_NONBLOCK)
self._saved_attrs = termios.tcgetattr(self._fd)
# Raw 8N1: iflag, oflag, cflag, lflag, ispeed, ospeed, cc
attrs = list(termios.tcgetattr(self._fd))
baud = self.BAUD_MAP.get(self.baudrate, termios.B115200)
attrs[0] = termios.IGNBRK # iflag
attrs[1] = 0 # oflag
attrs[2] = termios.CS8 | termios.CREAD | termios.CLOCAL # cflag
attrs[3] = 0 # lflag (raw)
attrs[4] = baud # ispeed
attrs[5] = baud # ospeed
attrs[6][termios.VMIN] = 0
attrs[6][termios.VTIME] = min(int(self.timeout * 10), 255)
termios.tcsetattr(self._fd, termios.TCSANOW, attrs)
termios.tcflush(self._fd, termios.TCIOFLUSH)
def close(self): def close(self):
if self._fd is not None: if self._ser is not None:
try: try:
if self._saved_attrs: self._ser.close()
termios.tcsetattr(self._fd, termios.TCSADRAIN, self._saved_attrs) except Exception:
os.close(self._fd)
except OSError:
pass pass
finally: finally:
self._fd = None self._ser = None
@property @property
def is_open(self) -> bool: def is_open(self) -> bool:
return self._fd is not None return self._ser is not None and self._ser.is_open
# ── Read / write ────────────────────────────────────────────────────────── # ── Read / write ──────────────────────────────────────────────────────────
def write(self, data: bytes): def write(self, data: bytes):
if self._fd is None: if self._ser is None:
raise OSError("Port is not open") raise OSError("Port is not open")
os.write(self._fd, data) self._ser.write(data)
def read_chunk(self, size: int = 4096) -> bytes: def read_chunk(self, size: int = 4096) -> bytes:
if self._fd is None: if self._ser is None:
raise OSError("Port is not open") raise OSError("Port is not open")
try: waiting = self._ser.in_waiting
return os.read(self._fd, size) if waiting:
except OSError: return self._ser.read(min(size, waiting))
return b"" return b""
def read_until_quiet(self, quiet_period: float = 0.5, def read_until_quiet(self, quiet_period: float = 0.5,
@@ -103,13 +79,12 @@ class SerialPort:
if output and (now - last_rx) >= quiet_period: if output and (now - last_rx) >= quiet_period:
break break
wait = min(deadline - now, quiet_period)
ready, _, _ = select.select([self._fd], [], [], wait)
if ready:
chunk = self.read_chunk() chunk = self.read_chunk()
if chunk: if chunk:
output += chunk output += chunk
last_rx = time.monotonic() last_rx = time.monotonic()
else:
time.sleep(0.05)
return output.decode("utf-8", errors="replace") return output.decode("utf-8", errors="replace")

View File

@@ -9,14 +9,13 @@ All Redfish operations are therefore performed by issuing curl commands over
the serial connection and parsing the JSON responses. the serial connection and parsing the JSON responses.
""" """
import glob
import json import json
import os
import re import re
import subprocess
import time import time
from typing import Optional from typing import Optional
import serial.tools.list_ports
from models import IOMConfig, ShelfConfig from models import IOMConfig, ShelfConfig
from serial_port import SerialPort from serial_port import SerialPort
from ui import ( from ui import (
@@ -235,7 +234,7 @@ def detect_serial_device() -> Optional[str]:
rule("Step 1 of 5 -- Serial Cable & Device Detection") rule("Step 1 of 5 -- Serial Cable & Device Detection")
print(" Connect the serial cable from the ES24N IOM port") print(" Connect the serial cable from the ES24N IOM port")
print(" to the active F-Series controller USB port.") print(" to a USB port on this Windows machine.")
print() print()
prompt("Press Enter when the cable is connected") prompt("Press Enter when the cable is connected")
@@ -243,9 +242,9 @@ def detect_serial_device() -> Optional[str]:
info(f"Scanning for USB serial devices (attempt {attempt}/3)...") info(f"Scanning for USB serial devices (attempt {attempt}/3)...")
time.sleep(1) time.sleep(1)
# FreeBSD: /dev/ttyU* Linux: /dev/ttyUSB*, /dev/ttyACM* all_ports = serial.tools.list_ports.comports()
patterns = ["/dev/ttyUSB*", "/dev/ttyACM*", "/dev/ttyU*"] usb_ports = [p for p in all_ports if p.hwid and "USB" in p.hwid.upper()]
ports = sorted({p for pat in patterns for p in glob.glob(pat)}) ports = sorted(p.device for p in usb_ports)
if ports: if ports:
break break
@@ -259,21 +258,22 @@ def detect_serial_device() -> Optional[str]:
print() print()
print(" Troubleshooting:") print(" Troubleshooting:")
print(" - Ensure the serial cable is fully seated at both ends.") print(" - Ensure the serial cable is fully seated at both ends.")
print(" - Try a different USB port on the controller.") print(" - Try a different USB port on this machine.")
print(" - Confirm the ES24N is powered on.") print(" - Confirm the ES24N is powered on.")
print(" - Check Device Manager for the COM port assignment.")
return None return None
if len(ports) == 1: if len(ports) == 1:
ok(f"Device found: {_c(C.BOLD, ports[0])}") ok(f"Device found: {_c(C.BOLD, ports[0])}")
_fix_permissions(ports[0])
return ports[0] return ports[0]
# Multiple devices — let the user choose # Multiple devices — let the user choose
port_objs = {p.device: p for p in usb_ports}
print() print()
draw_table( draw_table(
["#", "Device"], ["#", "Port", "Description"],
[[str(i), p] for i, p in enumerate(ports, 1)], [[str(i), p, port_objs[p].description or ""] for i, p in enumerate(ports, 1)],
[4, 24], [4, 8, 40],
) )
print() print()
@@ -282,30 +282,10 @@ def detect_serial_device() -> Optional[str]:
if val.isdigit() and 1 <= int(val) <= len(ports): if val.isdigit() and 1 <= int(val) <= len(ports):
selected = ports[int(val) - 1] selected = ports[int(val) - 1]
ok(f"Selected: {_c(C.BOLD, selected)}") ok(f"Selected: {_c(C.BOLD, selected)}")
_fix_permissions(selected)
return selected return selected
warn(f"Please enter a number between 1 and {len(ports)}.") warn(f"Please enter a number between 1 and {len(ports)}.")
def _fix_permissions(device: str):
try:
result = subprocess.run(
["sudo", "chown", ":wheel", device],
capture_output=True, timeout=5,
)
if result.returncode == 0:
ok(f"Permissions updated on {device}")
return
except Exception:
pass
try:
os.chmod(device, 0o666)
ok(f"Permissions updated on {device}")
except PermissionError:
warn("Could not update device permissions automatically.")
warn("If the connection fails, re-run this script with sudo.")
# ── Step 2: Open serial connection & wake IOM console ───────────────────────── # ── Step 2: Open serial connection & wake IOM console ─────────────────────────
def open_serial_connection(device: str) -> Optional[SerialPort]: def open_serial_connection(device: str) -> Optional[SerialPort]:
rule("Step 2 of 5 -- Opening Serial Connection") rule("Step 2 of 5 -- Opening Serial Connection")

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
pyserial>=3.5
pyinstaller>=6.0