diff --git a/es24n_conf.py b/es24n_conf.py index bb0b09b..486622d 100755 --- a/es24n_conf.py +++ b/es24n_conf.py @@ -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 diff --git a/es24n_conf.spec b/es24n_conf.spec new file mode 100644 index 0000000..3de198a --- /dev/null +++ b/es24n_conf.spec @@ -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, +) diff --git a/modules/serial_port.py b/modules/serial_port.py index 57db5c5..1f25a64 100644 --- a/modules/serial_port.py +++ b/modules/serial_port.py @@ -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") diff --git a/modules/workflow_serial.py b/modules/workflow_serial.py index 804190a..181b35d 100644 --- a/modules/workflow_serial.py +++ b/modules/workflow_serial.py @@ -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") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..30cc5a2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +pyserial>=3.5 +pyinstaller>=6.0