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
"""
ES24N IOM Network Configuration Tool
ES24N IOM Network Configuration Tool — Windows Edition
TrueNAS ES24N Expansion Shelf — Serial Configuration Utility
Based on ES24N Product Service Guide v.26011
Zero external dependencies — Python 3 standard library only.
Compatible with TrueNAS (FreeBSD) and Linux.
Requires: pyserial (pip install pyserial)
Distributed as a standalone .exe via PyInstaller.
Usage:
python3 es24n_conf.py
python3 es24n_conf.py (or run the packaged .exe)
All files in the modules/ subdirectory must be present:
ui.py, serial_port.py, models.py, redfish.py,
@@ -19,6 +19,12 @@ import os
import sys
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"))
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,89 +1,65 @@
"""
serial_port.py — Minimal 8N1 serial port using only the Python standard library.
Replaces pyserial for TrueNAS environments where pip is unavailable.
serial_port.py — pyserial-backed 8N1 serial port for the Windows edition.
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
from typing import Optional
import serial
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):
self.port = port
self.baudrate = baudrate
self.timeout = timeout
self._fd: Optional[int] = None
self._saved_attrs = None
self._ser: Optional[serial.Serial] = None
# ── Open / close ──────────────────────────────────────────────────────────
def open(self):
try:
self._fd = os.open(self.port, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
except OSError as e:
self._ser = serial.Serial(
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
# 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):
if self._fd is not None:
if self._ser is not None:
try:
if self._saved_attrs:
termios.tcsetattr(self._fd, termios.TCSADRAIN, self._saved_attrs)
os.close(self._fd)
except OSError:
self._ser.close()
except Exception:
pass
finally:
self._fd = None
self._ser = None
@property
def is_open(self) -> bool:
return self._fd is not None
return self._ser is not None and self._ser.is_open
# ── Read / write ──────────────────────────────────────────────────────────
def write(self, data: bytes):
if self._fd is None:
if self._ser is None:
raise OSError("Port is not open")
os.write(self._fd, data)
self._ser.write(data)
def read_chunk(self, size: int = 4096) -> bytes:
if self._fd is None:
if self._ser is None:
raise OSError("Port is not open")
try:
return os.read(self._fd, size)
except OSError:
return b""
waiting = self._ser.in_waiting
if waiting:
return self._ser.read(min(size, waiting))
return b""
def read_until_quiet(self, quiet_period: float = 0.5,
timeout: Optional[float] = None) -> str:
@@ -103,13 +79,12 @@ class SerialPort:
if output and (now - last_rx) >= quiet_period:
break
wait = min(deadline - now, quiet_period)
ready, _, _ = select.select([self._fd], [], [], wait)
if ready:
chunk = self.read_chunk()
if chunk:
output += chunk
last_rx = time.monotonic()
chunk = self.read_chunk()
if chunk:
output += chunk
last_rx = time.monotonic()
else:
time.sleep(0.05)
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.
"""
import glob
import json
import os
import re
import subprocess
import time
from typing import Optional
import serial.tools.list_ports
from models import IOMConfig, ShelfConfig
from serial_port import SerialPort
from ui import (
@@ -235,7 +234,7 @@ def detect_serial_device() -> Optional[str]:
rule("Step 1 of 5 -- Serial Cable & Device Detection")
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()
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)...")
time.sleep(1)
# FreeBSD: /dev/ttyU* Linux: /dev/ttyUSB*, /dev/ttyACM*
patterns = ["/dev/ttyUSB*", "/dev/ttyACM*", "/dev/ttyU*"]
ports = sorted({p for pat in patterns for p in glob.glob(pat)})
all_ports = serial.tools.list_ports.comports()
usb_ports = [p for p in all_ports if p.hwid and "USB" in p.hwid.upper()]
ports = sorted(p.device for p in usb_ports)
if ports:
break
@@ -259,21 +258,22 @@ def detect_serial_device() -> Optional[str]:
print()
print(" Troubleshooting:")
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(" - Check Device Manager for the COM port assignment.")
return None
if len(ports) == 1:
ok(f"Device found: {_c(C.BOLD, ports[0])}")
_fix_permissions(ports[0])
return ports[0]
# Multiple devices — let the user choose
port_objs = {p.device: p for p in usb_ports}
print()
draw_table(
["#", "Device"],
[[str(i), p] for i, p in enumerate(ports, 1)],
[4, 24],
["#", "Port", "Description"],
[[str(i), p, port_objs[p].description or ""] for i, p in enumerate(ports, 1)],
[4, 8, 40],
)
print()
@@ -282,30 +282,10 @@ def detect_serial_device() -> Optional[str]:
if val.isdigit() and 1 <= int(val) <= len(ports):
selected = ports[int(val) - 1]
ok(f"Selected: {_c(C.BOLD, selected)}")
_fix_permissions(selected)
return selected
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 ─────────────────────────
def open_serial_connection(device: str) -> Optional[SerialPort]:
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