commit a6a0f1b2468a2c91345c6cdf6b3b558478427544 Author: scott Date: Thu Apr 16 15:44:28 2026 -0400 Initial commit — bootstrapped from es24n-conf (TrueNAS/Linux edition) Starting point for Windows packaging. Serial backend will be replaced with pyserial; PyInstaller used to produce a standalone .exe. Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e9231fe --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,92 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Running the Tool + +```bash +python3 es24n_conf.py +``` + +The script requires root or appropriate serial device permissions. If the serial device is inaccessible, re-run with `sudo`. + +## What This Is + +An interactive CLI tool for configuring and updating TrueNAS ES24N expansion shelf IOM (I/O Module) controllers via the Redfish API. It supports two connection methods: + +- **Serial (loopback):** Connects via USB serial cable to the IOM1 console port, then reaches the Redfish API at `https://127.0.0.1` using `root` credentials. Used for initial network configuration when the IOM has no IP address yet. +- **Network:** Connects directly to the IOM's management IP address using `Admin` credentials. Used for firmware updates and (planned) network reconfiguration once the IOM is reachable on the network. + +No external dependencies — Python 3 standard library only. Compatible with TrueNAS (FreeBSD) and Linux. + +## File Structure + +``` +es24n_conf.py ← Entry point and main menu (run this) +firmware/ ← Firmware files (.bin, .img, .fw, etc.) — scanned at update time +modules/ + ui.py ← ANSI colours, display helpers, input prompts + serial_port.py ← SerialPort class (termios/fcntl/select, no pyserial) + models.py ← IOMConfig and ShelfConfig dataclasses + redfish.py ← Redfish API client (shared by all workflows) + workflow_serial.py ← Serial-based IOM network configuration workflow + workflow_firmware.py ← IOM and Fabric Card firmware update workflow + workflow_check.py ← Read-only system check (network settings + firmware versions) + workflow_restart.py ← Restart IOM via network or serial +``` + +`es24n_conf.py` adds `modules/` to `sys.path` at startup so all inter-module imports work without a package structure. + +## Workflows + +### 1 — Serial Network Configuration (`workflow_serial.py`) +5-step workflow using the USB serial cable. The serial cable connects to one IOM's console port at a time — the user is prompted to select IOM1 or IOM2 after login, and all Redfish paths use that selection. +1. Detect USB serial device (`/dev/ttyUSB*`, `/dev/ttyACM*`, `/dev/ttyU*`) +2. Open 115200-baud 8N1 connection and wake the IOM console +3. Prompt for IOM1 or IOM2, then query current network settings via Redfish (`GET` over `127.0.0.1`) +4. Collect new settings from user (Static IP or DHCP) +5. Apply via Redfish `PATCH` over `127.0.0.1` + +### 2 — Firmware Update (`workflow_firmware.py`) +Updates IOM firmware and/or Fabric Card firmware over the network: +- Prompts for which IOM(s) to update (IOM1, IOM2, or both) +- Uploads firmware via `POST` to `/redfish/v1/UpdateService` (multipart form) +- Triggers update via Redfish `SimpleUpdate` action +- Polls `/redfish/v1/TaskService/Tasks/` until completion +- Restarts the IOM (and fabric card if applicable) after each update +- The firmware file must be re-uploaded between the IOM and Fabric Card steps — it does not persist after the first update (firmware quirk, documented in `_update_fabric_fw()`) +- Scans the `firmware/` directory (next to `es24n_conf.py`) for firmware files (`.bin`, `.img`, `.fw`, `.fwc`, `.hex`, `.zip`, `.tar`, `.tgz`, `.gz`) and presents them as a numbered list before falling back to manual path entry + +### 3 — System Check (`workflow_check.py`) +Read-only diagnostic workflow — queries current network settings and firmware versions, makes no changes: +- **Serial:** prompts for IOM1 or IOM2, logs in via serial console, queries the selected IOM using `_serial_redfish_request()` (curl over the serial session); covers network settings, IOM firmware, and Fabric Card firmware. +- **Network:** prompts for management IP(s), queries via direct HTTPS using `Admin` credentials; reuses `_redfish_request()`, `_get_iom_fw_version()`, `_get_fabric_fw_version()` from `redfish.py` +- Displays results in two tables: network configuration and firmware versions +- User selects Serial, Network, or Cancel at the sub-menu prompt + +## Reference Documentation + +The `docs/` directory contains reference documentation for this project. Check all files there for any Redfish API commands, request/response formats, field names, or configuration procedures when working on this project. + +## Key Design Notes + +**Static IP firmware bug workaround:** Setting a static IP requires two sequential PATCH requests: +- Pass 1: set `IPv4StaticAddresses` (while DHCP is still enabled) +- Pass 2: disable `DHCPv4` (after the address is committed) + +A single PATCH combining both fields fails due to a known ES24N firmware bug. Documented in `_apply_iom()` in `workflow_serial.py`. + +**Authentication:** +- Serial loopback (`127.0.0.1`): username `root`, password auto-detected from hostname +- Network IP: username `Admin`, password prompted from user + +**Redfish API paths:** +- Network config: `/redfish/v1/Managers/{IOM1|IOM2}/EthernetInterfaces/1` +- Firmware update: `/redfish/v1/UpdateService` +- Fabric card: `/redfish/v1/Chassis/{IOM1|IOM2}/NetworkAdapters/1` + +**Serial password auto-detection:** The IOM BMC serial number is embedded in the hostname shown on the serial login prompt (e.g. `ves-ves-vds2249r-MXE3000048LHA03C-mgr1 login:`). The `MXE...` segment is the root password. `_login_serial_console()` extracts it automatically — from the login prompt hostname if a login is needed, or by running `hostname` on the shell if already logged in. Falls back to `prompt_password()` if extraction fails. + +## Planned Features + +- Network-based IOM network configuration (`workflow_network.py`) — same config workflow as serial but connecting via the IOM's existing IP address using `Admin` credentials, for cases where the IOM is already reachable on the network diff --git a/README.md b/README.md new file mode 100644 index 0000000..b734065 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# ES24N IOM Configuration Tool + +An interactive CLI tool for configuring network settings and updating firmware on TrueNAS ES24N expansion shelf IOM (I/O Module) controllers via the Redfish API. + +## Requirements + +- Python 3 (standard library only — no external dependencies) +- Root or serial device group permissions (for serial-based configuration) + +## Usage + +```bash +python3 es24n_conf.py +``` + +If the serial device is inaccessible, re-run with `sudo`: + +```bash +sudo python3 es24n_conf.py +``` + +## Features + +### Serial Network Configuration + +Connects to the IOM over a USB serial cable and configures each IOM's management network interface via the Redfish API over the serial loopback (`127.0.0.1`). Used for initial setup when the IOM has no IP address yet. + +- Supports both **Static IP** and **DHCP** configuration +- Configures IOM1 and IOM2 independently in a single session +- Displays current network settings before making any changes +- Supports configuring multiple shelves back-to-back in one session + +### Firmware Updates + +Connects to each IOM directly over the network and updates IOM and/or Fabric Card firmware via the Redfish API. + +- Select IOM1 only, IOM2 only, or both +- Update IOM firmware, Fabric Card firmware, or both in a single run +- Scans the current working directory for firmware files automatically +- Displays current firmware versions before and after the update +- Polls update task progress automatically + +## Workflow + +### Configure a Shelf (Serial) + +1. Connect the serial cable from the ES24N IOM1 port to the controller's USB port +2. Run the tool and select **Configure a new ES24N shelf** +3. The tool detects the serial device, opens the connection, and prompts for the BMC admin password +4. Current network settings for IOM1 and IOM2 are displayed +5. Choose to apply a new Static IP or DHCP configuration, or leave settings unchanged +6. Changes are applied via Redfish PATCH over the serial loopback (`127.0.0.1`) +7. Verify each expander appears in TrueNAS under **System Settings > Enclosure > NVMe-oF Expansion Shelves** before disconnecting the serial cable + +### Update Firmware + +1. Ensure this system has network access to the IOM management interfaces +2. Run the tool and select **Update IOM / Fabric Card Firmware** +3. Enter the admin password and select which IOM(s) to update +4. Enter the IP address(es) for the selected IOM(s) +5. Select what to update (IOM firmware, Fabric Card firmware, or both) +6. Select firmware file(s) from the auto-detected list or enter a custom path +7. The tool uploads, applies, and monitors each update, then restarts the affected components + +> **HA Systems:** Update the passive IOM first. After updating both IOMs on one controller, initiate a TrueNAS failover and re-run the tool for the other controller. + +## File Structure + +``` +es24n_conf.py ← Entry point — run this +modules/ + ui.py ← Display helpers and input prompts + serial_port.py ← Serial port driver (no pyserial required) + models.py ← IOMConfig and ShelfConfig data classes + redfish.py ← Redfish API client (shared by all workflows) + workflow_serial.py ← Serial-based network configuration workflow + workflow_firmware.py ← Firmware update workflow +``` + +## Notes + +- Setting a static IP requires two sequential Redfish PATCH requests due to a known ES24N firmware bug. The tool handles this automatically. +- Firmware uploads are performed over the network — uploading over the 115200-baud serial connection would be impractically slow. +- The firmware file must be re-uploaded between the IOM firmware and Fabric Card firmware steps as it does not persist after the first update. diff --git a/docs/.gitkeep b/docs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/ES24NPSG26031.pdf b/docs/ES24NPSG26031.pdf new file mode 100644 index 0000000..3040fbd Binary files /dev/null and b/docs/ES24NPSG26031.pdf differ diff --git a/es24n_conf.py b/es24n_conf.py new file mode 100755 index 0000000..bb0b09b --- /dev/null +++ b/es24n_conf.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +""" +ES24N IOM Network Configuration Tool +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. + +Usage: + python3 es24n_conf.py + +All files in the modules/ subdirectory must be present: + ui.py, serial_port.py, models.py, redfish.py, + workflow_serial.py, workflow_firmware.py, workflow_check.py +""" + +import os +import sys +import time + +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 workflow_check import system_check_workflow +from workflow_firmware import firmware_update_workflow +from workflow_restart import restart_iom_workflow +from workflow_serial import configure_shelf + + +def main(): + while True: + banner() + draw_box([ + f" {_c(C.BOLD, '1')} Configure Network Settings", + f" {_c(C.BOLD, '2')} Update IOM / Fabric Card Firmware", + f" {_c(C.BOLD, '3')} Query Current Configuration", + f" {_c(C.BOLD, '4')} Restart IOM", + f" {_c(C.BOLD, '5')} Exit", + ]) + print() + + choice = prompt("Select [1-5]") + if choice == "1": + another = True + while another: + another = configure_shelf() + elif choice == "2": + firmware_update_workflow() + elif choice == "3": + system_check_workflow() + elif choice == "4": + restart_iom_workflow() + elif choice == "5": + break + else: + warn("Please enter 1, 2, 3, 4, or 5.") + time.sleep(1) + + print() + ok("Exiting ES24N IOM Configuration Tool. Goodbye.") + print() + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print() + warn("Interrupted. Exiting.") + sys.exit(0) diff --git a/modules/models.py b/modules/models.py new file mode 100644 index 0000000..5a74355 --- /dev/null +++ b/modules/models.py @@ -0,0 +1,23 @@ +""" +models.py — Shared data classes for ES24N IOM configuration. +Used by both the serial and network configuration workflows. +""" + +from dataclasses import dataclass, field + + +@dataclass +class IOMConfig: + iom: str + dhcp: bool = True + ip: str = "" + gateway: str = "" + netmask: str = "" + + +@dataclass +class ShelfConfig: + device: str = "" + password: str = "" + iom1: IOMConfig = field(default_factory=lambda: IOMConfig("IOM1")) + iom2: IOMConfig = field(default_factory=lambda: IOMConfig("IOM2")) diff --git a/modules/redfish.py b/modules/redfish.py new file mode 100644 index 0000000..1eada80 --- /dev/null +++ b/modules/redfish.py @@ -0,0 +1,266 @@ +""" +redfish.py — Redfish API client functions shared across all ES24N workflows. +Handles GET/PATCH requests, firmware upload, task polling, and version queries. +""" + +import json +import os +import ssl +import time +import urllib.error +import urllib.request +from base64 import b64encode +from typing import Optional + +from ui import _c, C, info, draw_table + + +def _redfish_request(password: str, method: str, path: str, + payload: Optional[dict] = None, + host: str = "127.0.0.1") -> tuple: + """ + Issue a Redfish HTTP request. host defaults to the serial loopback (127.0.0.1) + but can be set to an IOM's network IP for firmware update operations. + Returns (success: bool, data: dict|str). + """ + url = f"https://{host}{path}" + username = "root" if host == "127.0.0.1" else "Admin" + credentials = b64encode(f"{username}:{password}".encode()).decode() + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + body = json.dumps(payload).encode("utf-8") if payload else None + headers = {"Authorization": f"Basic {credentials}"} + if body: + headers["Content-Type"] = "application/json" + + req = urllib.request.Request(url, data=body, method=method, headers=headers) + + try: + with urllib.request.urlopen(req, context=ctx, timeout=10) as resp: + raw = resp.read().decode("utf-8", errors="replace") + try: + return True, json.loads(raw) if raw.strip() else {} + except json.JSONDecodeError: + return True, {} + + except urllib.error.HTTPError as e: + raw = e.read().decode("utf-8", errors="replace") + try: + msg = json.loads(raw).get("error", {}).get("message", raw) + except json.JSONDecodeError: + msg = raw + return False, f"HTTP {e.code}: {msg[:120]}" + + except OSError as e: + return False, f"Connection error: {e}" + + +def _redfish_upload_firmware(password: str, host: str, fw_path: str) -> tuple: + """ + Upload a firmware file to /redfish/v1/UpdateService using multipart/form-data. + Equivalent to: curl -k -u Admin: https:///redfish/v1/UpdateService + -X POST -F "software=@" + """ + try: + with open(fw_path, "rb") as f: + file_data = f.read() + except OSError as e: + return False, f"Cannot read file: {e}" + + filename = os.path.basename(fw_path) + boundary = f"FormBoundary{int(time.time() * 1000)}" + + body = ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="software"; filename="{filename}"\r\n' + "Content-Type: application/octet-stream\r\n" + "\r\n" + ).encode() + file_data + f"\r\n--{boundary}--\r\n".encode() + + url = f"https://{host}/redfish/v1/UpdateService" + credentials = b64encode(f"Admin:{password}".encode()).decode() # always network + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + req = urllib.request.Request( + url, data=body, method="POST", + headers={ + "Authorization": f"Basic {credentials}", + "Content-Type": f"multipart/form-data; boundary={boundary}", + }, + ) + try: + with urllib.request.urlopen(req, context=ctx, timeout=600) as resp: + raw = resp.read().decode("utf-8", errors="replace") + try: + return True, json.loads(raw) if raw.strip() else {} + except json.JSONDecodeError: + return True, {} + except urllib.error.HTTPError as e: + raw = e.read().decode("utf-8", errors="replace") + try: + msg = json.loads(raw).get("error", {}).get("message", raw) + except json.JSONDecodeError: + msg = raw + return False, f"HTTP {e.code}: {msg[:120]}" + except OSError as e: + return False, f"Connection error: {e}" + + +def _redfish_trigger_update(password: str, host: str, target: str) -> tuple: + """ + Trigger a Redfish SimpleUpdate for the given target resource path. + target: e.g. "/redfish/v1/Managers/IOM1" + or "/redfish/v1/Chassis/IOM1/NetworkAdapters/1" + """ + return _redfish_request( + password, "POST", + "/redfish/v1/UpdateService/Actions/SimpleUpdate", + payload={ + "ImageURI": "/redfish/v1/UpdateService/software", + "Targets": [target], + }, + host=host, + ) + + +def _redfish_poll_tasks(password: str, host: str, timeout: int = 600) -> tuple: + """ + Poll /redfish/v1/TaskService/Tasks/ until all tasks reach a terminal state + or timeout is exceeded. Fetches each task's detail URL to obtain + PercentComplete and displays live progress. + Returns (success: bool, message: str). + """ + TERMINAL = {"Completed", "Killed", "Exception"} + deadline = time.monotonic() + timeout + elapsed = 0 + + while time.monotonic() < deadline: + ok_flag, data = _redfish_request( + password, "GET", "/redfish/v1/TaskService/Tasks/", host=host, + ) + if not ok_flag: + return False, f"Task service error: {data}" + + members = data.get("Members", []) + if not members: + return True, "No pending tasks." + + running = [] + for member in members: + # Always fetch the individual task to get PercentComplete + task_path = member.get("@odata.id", "") + state = member.get("TaskState") + pct = member.get("PercentComplete") + + if task_path: + t_ok, t_data = _redfish_request( + password, "GET", task_path, host=host, + ) + if t_ok and isinstance(t_data, dict): + state = t_data.get("TaskState", state) + pct = t_data.get("PercentComplete", pct) + + if state is None: + state = "Running" + + if state not in TERMINAL: + pct_str = f" {pct}%" if pct is not None else "" + running.append(f"{state}{pct_str}") + + if not running: + return True, "All tasks completed." + + info(f" Tasks in progress: {', '.join(running)} [{elapsed}s elapsed]") + time.sleep(10) + elapsed += 10 + + return False, f"Timeout after {timeout}s waiting for tasks." + + +def _wait_for_iom_online(password: str, host: str, timeout: int = 300) -> bool: + """ + Poll GET /redfish/v1/ until the IOM responds successfully, indicating it + has finished rebooting. Waits up to `timeout` seconds. + Returns True if the IOM came back online, False if timeout was exceeded. + """ + deadline = time.monotonic() + timeout + attempt = 0 + while time.monotonic() < deadline: + attempt += 1 + ok_flag, _ = _redfish_request(password, "GET", "/redfish/v1/", host=host) + if ok_flag: + return True + elapsed = int(time.monotonic() - (deadline - timeout)) + info(f" Waiting for IOM to come back online... [{elapsed}s elapsed]") + time.sleep(15) + return False + + +def _redfish_restart_iom(password: str, host: str, iom: str) -> tuple: + return _redfish_request( + password, "POST", + f"/redfish/v1/Managers/{iom}/Actions/Manager.Reset", + payload={"ResetType": "GracefulRestart"}, + host=host, + ) + + +def _redfish_reset_fabric(password: str, host: str, iom: str) -> tuple: + return _redfish_request( + password, "POST", + f"/redfish/v1/Chassis/{iom}/NetworkAdapters/1/Actions/NetworkAdapter.Reset", + payload={"ResetType": "GracefulRestart"}, + host=host, + ) + + +def _get_iom_fw_version(password: str, host: str, iom: str) -> str: + ok_flag, data = _redfish_request( + password, "GET", f"/redfish/v1/Managers/{iom}", host=host, + ) + if ok_flag and isinstance(data, dict): + return data.get("FirmwareVersion", "Unknown") + return _c(C.RED, "Unreachable") + + +def _get_fabric_fw_version(password: str, host: str, iom: str) -> str: + ok_flag, data = _redfish_request( + password, "GET", + f"/redfish/v1/Chassis/{iom}/NetworkAdapters/1", + host=host, + ) + if ok_flag and isinstance(data, dict): + version = (data.get("Oem", {}) + .get("VikingEnterpriseSolutions", {}) + .get("Version", {}) + .get("ActiveFirmwareVersion")) + return version or "Unknown" + return _c(C.RED, "Unreachable") + + +def _show_fw_versions(targets: list): + """ + Query and display firmware versions. + targets: list of (label, iom, ip, password) tuples where + label: display string (e.g. "IOM1", "S1 / IOM1") + iom: actual IOM name used in Redfish paths ("IOM1" or "IOM2") + ip: management IP address + password: Admin password for this shelf + """ + info("Querying firmware versions...") + rows = [] + for label, iom, ip, password in targets: + iom_ver = _get_iom_fw_version(password, ip, iom) + fabric_ver = _get_fabric_fw_version(password, ip, iom) + rows.append([label, ip, iom_ver, fabric_ver]) + print() + draw_table( + ["IOM", "IP Address", "IOM Firmware", "Fabric Firmware"], + rows, + [12, 16, 32, 20], + ) + print() diff --git a/modules/serial_port.py b/modules/serial_port.py new file mode 100644 index 0000000..57db5c5 --- /dev/null +++ b/modules/serial_port.py @@ -0,0 +1,126 @@ +""" +serial_port.py — Minimal 8N1 serial port using only the Python standard library. +Replaces pyserial for TrueNAS environments where pip is unavailable. +""" + +import fcntl +import os +import select +import termios +import time +from typing import Optional + + +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 + + # ── 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: + 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: + try: + if self._saved_attrs: + termios.tcsetattr(self._fd, termios.TCSADRAIN, self._saved_attrs) + os.close(self._fd) + except OSError: + pass + finally: + self._fd = None + + @property + def is_open(self) -> bool: + return self._fd is not None + + # ── Read / write ────────────────────────────────────────────────────────── + def write(self, data: bytes): + if self._fd is None: + raise OSError("Port is not open") + os.write(self._fd, data) + + def read_chunk(self, size: int = 4096) -> bytes: + if self._fd is None: + raise OSError("Port is not open") + try: + return os.read(self._fd, size) + except OSError: + return b"" + + def read_until_quiet(self, quiet_period: float = 0.5, + timeout: Optional[float] = None) -> str: + """ + Read until no new bytes arrive for `quiet_period` seconds, + or until `timeout` (default: self.timeout) seconds have elapsed. + Pass a longer timeout for operations like curl that take more time. + """ + output = b"" + deadline = time.monotonic() + (timeout if timeout is not None else self.timeout) + last_rx = time.monotonic() + + while True: + now = time.monotonic() + if now >= deadline: + break + 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() + + return output.decode("utf-8", errors="replace") + + def send_line(self, cmd: str = "", delay: float = 0.3): + self.write((cmd + "\r\n").encode("utf-8")) + time.sleep(delay) + + # ── Context manager ─────────────────────────────────────────────────────── + def __enter__(self): + self.open() + return self + + def __exit__(self, *_): + self.close() diff --git a/modules/ui.py b/modules/ui.py new file mode 100644 index 0000000..163c014 --- /dev/null +++ b/modules/ui.py @@ -0,0 +1,120 @@ +""" +ui.py — ANSI colour helpers, display primitives, and input prompts. +Shared by all ES24N workflows. +""" + +import ipaddress +import sys + + +# ── ANSI colour helpers ─────────────────────────────────────────────────────── +class C: + RED = "\033[0;31m" + GRN = "\033[0;32m" + YEL = "\033[1;33m" + CYN = "\033[0;36m" + WHT = "\033[1;37m" + DIM = "\033[2m" + BOLD = "\033[1m" + RESET = "\033[0m" + CLEAR = "\033[2J\033[H" + + +def _c(colour: str, text: str) -> str: + return f"{colour}{text}{C.RESET}" + + +def info(msg: str): print(f" {_c(C.CYN, 'i')} {msg}") +def ok(msg: str): print(f" {_c(C.GRN, 'OK')} {msg}") +def warn(msg: str): print(f" {_c(C.YEL, '!')} {msg}") +def error(msg: str): print(f" {_c(C.RED, 'X')} {msg}") + + +def banner(): + print(C.CLEAR, end="") + w = 60 + print(_c(C.CYN, " +" + "-" * w + "+")) + print(_c(C.CYN, " |") + _c(C.WHT + C.BOLD, + " TrueNAS ES24N IOM Configuration Tool ") + _c(C.CYN, " |")) + print(_c(C.CYN, " |") + _c(C.DIM, + " Serial Config & Firmware Updates ") + _c(C.CYN, " |")) + print(_c(C.CYN, " +" + "-" * w + "+")) + print() + + +def rule(title: str = ""): + width = 60 + if title: + pad = max(0, width - len(title) - 2) + left = pad // 2 + right = pad - left + line = f"{'-' * left} {title} {'-' * right}" + else: + line = "-" * width + print(f"\n {_c(C.YEL, line)}\n") + + +def draw_table(headers: list, rows: list, col_widths: list): + sep = " +-" + "-+-".join("-" * w for w in col_widths) + "-+" + + def fmt_row(cells): + return " | " + " | ".join( + str(c).ljust(w) for c, w in zip(cells, col_widths) + ) + " |" + + print(_c(C.DIM, sep)) + print(_c(C.BOLD, fmt_row(headers))) + print(_c(C.DIM, sep)) + for row in rows: + print(fmt_row(row)) + print(_c(C.DIM, sep)) + + +def draw_box(lines: list, colour: str = C.CYN): + width = max(len(l) for l in lines) + 4 + print(f" {_c(colour, '+' + '-' * width + '+')}") + for line in lines: + pad = width - len(line) - 2 + print(f" {_c(colour, '|')} {line}{' ' * pad} {_c(colour, '|')}") + print(f" {_c(colour, '+' + '-' * width + '+')}") + + +# ── Input helpers ───────────────────────────────────────────────────────────── +def prompt(label: str, default: str = "") -> str: + display = f" {_c(C.CYN, label)}" + if default: + display += f" {_c(C.DIM, f'[{default}]')}" + display += ": " + + sys.stdout.write(display) + sys.stdout.flush() + val = sys.stdin.readline().strip() + return val if val else default + + +def prompt_ip(label: str) -> str: + while True: + val = prompt(label) + try: + ipaddress.IPv4Address(val) + return val + except ValueError: + warn(f"'{val}' is not a valid IPv4 address — please try again.") + + +def prompt_yn(label: str, default: bool = True) -> bool: + hint = "Y/n" if default else "y/N" + val = prompt(f"{label} [{hint}]").strip().lower() + if not val: + return default + return val in ("y", "yes") + + +def prompt_password() -> str: + while True: + val = prompt( + "Admin password (BMC/chassis serial, e.g. MXE3000043CHA007)", + ) + if val: + return val + warn("Password cannot be empty.") diff --git a/modules/workflow_check.py b/modules/workflow_check.py new file mode 100644 index 0000000..2db008a --- /dev/null +++ b/modules/workflow_check.py @@ -0,0 +1,253 @@ +""" +workflow_check.py — System check workflow for ES24N IOM controllers. + +Queries current network settings and firmware versions from one or both IOMs +via either a serial connection (for IOMs not yet on the network) or a direct +network connection (for IOMs already reachable via their management IP). +No changes are made — this is a read-only diagnostic workflow. +""" + +import time + +from redfish import _redfish_request, _get_iom_fw_version, _get_fabric_fw_version +from workflow_serial import ( + detect_serial_device, + open_serial_connection, + close_serial_connection, + _login_serial_console, + _serial_redfish_request, +) +from ui import ( + _c, C, + banner, rule, draw_table, + info, ok, warn, error, + prompt, prompt_ip, prompt_password, +) + + +# ── Shared helpers ───────────────────────────────────────────────────────────── + +def _sanitize(value: str) -> str: + """Strip non-printable and non-ASCII characters from a string field. + IOM Redfish responses occasionally contain binary artifacts in text fields.""" + return "".join(c for c in value if 32 <= ord(c) < 128) + + +def _parse_network_data(data: dict) -> tuple: + """ + Extract network details from a Redfish EthernetInterfaces/1 response dict. + Returns (dhcp_enabled, ip, gateway, netmask, mac, hostname, link_status). + """ + dhcp_enabled = ( + data.get("DHCPv4", {}).get("DHCPEnabled", False) or + data.get("DHCPv6", {}).get("DHCPEnabled", False) + ) + + # Prefer IPv4StaticAddresses; fall back to IPv4Addresses + addrs = data.get("IPv4StaticAddresses") or data.get("IPv4Addresses", []) + if addrs: + addr = addrs[0] + ip = _sanitize(addr.get("Address", "--")) + gateway = _sanitize(addr.get("Gateway", "--")) + netmask = _sanitize(addr.get("SubnetMask", "--")) + else: + ip = gateway = netmask = "--" + + mac = _sanitize(data.get("MACAddress", "--")) + hostname = _sanitize(data.get("HostName", "--")) + link_status = _sanitize(data.get("LinkStatus", "--")) + + return dhcp_enabled, ip, gateway, netmask, mac, hostname, link_status + + +def _print_results(net_rows: list, iface_rows: list, fw_rows: list): + """ + Display network settings, interface identity, and firmware versions. + + net_rows: [IOM, Mode, IP Address, Gateway, Subnet Mask, Link Status] + iface_rows: [IOM, MAC Address, Hostname] + fw_rows: [IOM, IOM Firmware, Fabric Firmware] + """ + rule("Network Settings") + draw_table( + ["IOM", "Mode", "IP Address", "Gateway", "Subnet Mask", "Link"], + net_rows, + [5, 8, 15, 15, 15, 8], + ) + print() + + rule("Interface Details") + draw_table( + ["IOM", "MAC Address", "Hostname"], + iface_rows, + [5, 19, 42], + ) + print() + + rule("Firmware Versions") + draw_table( + ["IOM", "IOM Firmware", "Fabric Firmware"], + fw_rows, + [5, 32, 24], + ) + print() + + +# ── Serial check ─────────────────────────────────────────────────────────────── + +def _check_via_serial(): + banner() + rule("System Check -- Serial Connection") + + device = detect_serial_device() + if not device: + error("Could not detect a serial device. Returning to main menu.") + time.sleep(2) + return + + ser = open_serial_connection(device) + if not ser: + error("Could not open serial port. Returning to main menu.") + time.sleep(2) + return + + print() + logged_in, password = _login_serial_console(ser) + if not logged_in: + error("Could not log in to IOM console. Returning to main menu.") + close_serial_connection(ser, device) + time.sleep(2) + return + + # Prompt for which IOM the serial cable is connected to + print() + print(" Which IOM is the serial cable connected to?") + print(f" {_c(C.BOLD, '1')} IOM1") + print(f" {_c(C.BOLD, '2')} IOM2") + print() + while True: + iom_choice = prompt("Select [1/2]") + if iom_choice in ("1", "2"): + break + warn("Please enter 1 or 2.") + iom = "IOM1" if iom_choice == "1" else "IOM2" + + rule(f"Querying {iom} Status") + info(f"Querying {iom} network settings and firmware versions via serial console...") + print() + + # ── Network settings ─────────────────────────────────────────────────────── + net_ok, net_data = _serial_redfish_request( + ser, password, "GET", + f"/redfish/v1/Managers/{iom}/EthernetInterfaces/1", + ) + if net_ok and isinstance(net_data, dict): + dhcp, ip, gw, nm, mac, hostname, link = _parse_network_data(net_data) + mode = _c(C.CYN, "DHCP") if dhcp else _c(C.GRN, "Static") + net_rows = [[iom, mode, ip, gw, nm, link]] + iface_rows = [[iom, mac, hostname]] + else: + net_rows = [[iom, _c(C.RED, "No response"), "--", "--", "--", "--"]] + iface_rows = [[iom, "--", "--"]] + error(f"{iom} network query failed: {net_data}") + + # ── IOM firmware version ─────────────────────────────────────────────────── + iom_ok, iom_data = _serial_redfish_request( + ser, password, "GET", + f"/redfish/v1/Managers/{iom}", + ) + iom_ver = ( + iom_data.get("FirmwareVersion", "Unknown") + if (iom_ok and isinstance(iom_data, dict)) + else _c(C.RED, "Unreachable") + ) + + # ── Fabric card firmware version ─────────────────────────────────────────── + fab_ok, fab_data = _serial_redfish_request( + ser, password, "GET", + f"/redfish/v1/Chassis/{iom}/NetworkAdapters/1", + ) + fab_ver = ( + (fab_data.get("Oem", {}) + .get("VikingEnterpriseSolutions", {}) + .get("Version", {}) + .get("ActiveFirmwareVersion", "Unknown")) + if (fab_ok and isinstance(fab_data, dict)) + else _c(C.RED, "Unreachable") + ) + + _print_results(net_rows, iface_rows, [[iom, iom_ver, fab_ver]]) + close_serial_connection(ser, device) + + +# ── Network check ────────────────────────────────────────────────────────────── + +def _check_via_network(): + banner() + rule("System Check -- Network Connection") + + print() + password = prompt_password() + print() + ip = prompt_ip(" IOM IP address (either IOM1 or IOM2)") + + iom_list = [("IOM1", ip), ("IOM2", ip)] + + rule("Querying IOM Status") + info("Querying network settings and firmware versions over the network...") + print() + + net_rows = [] + iface_rows = [] + fw_rows = [] + + for iom, host in iom_list: + # ── Network settings ─────────────────────────────────────────────────── + net_ok, net_data = _redfish_request( + password, "GET", + f"/redfish/v1/Managers/{iom}/EthernetInterfaces/1", + host=host, + ) + if net_ok and isinstance(net_data, dict): + dhcp, ip_addr, gw, nm, mac, hostname, link = _parse_network_data(net_data) + mode = _c(C.CYN, "DHCP") if dhcp else _c(C.GRN, "Static") + net_rows.append([iom, mode, ip_addr, gw, nm, link]) + iface_rows.append([iom, mac, hostname]) + else: + net_rows.append([iom, _c(C.RED, "No response"), "--", "--", "--", "--"]) + iface_rows.append([iom, "--", "--"]) + error(f"{iom} network query failed: {net_data}") + + # ── Firmware versions (reuse shared redfish helpers) ─────────────────── + iom_ver = _get_iom_fw_version(password, host, iom) + fab_ver = _get_fabric_fw_version(password, host, iom) + fw_rows.append([iom, iom_ver, fab_ver]) + + _print_results(net_rows, iface_rows, fw_rows) + prompt("Press Enter to return to main menu") + + +# ── Top-level entry point ────────────────────────────────────────────────────── + +def system_check_workflow(): + banner() + rule("System Check") + + print(" How do you want to connect to the IOM(s)?") + print() + print(f" {_c(C.BOLD, '1')} Serial connection (IOM not yet on the network)") + print(f" {_c(C.BOLD, '2')} Network connection (IOM reachable via management IP)") + print(f" {_c(C.BOLD, '3')} Cancel") + print() + + while True: + choice = prompt("Select [1/2/3]") + if choice in ("1", "2", "3"): + break + warn("Please enter 1, 2, or 3.") + + if choice == "1": + _check_via_serial() + elif choice == "2": + _check_via_network() + # choice == "3" returns to main menu diff --git a/modules/workflow_firmware.py b/modules/workflow_firmware.py new file mode 100644 index 0000000..af9e846 --- /dev/null +++ b/modules/workflow_firmware.py @@ -0,0 +1,327 @@ +""" +workflow_firmware.py — IOM and Fabric Card firmware update workflow. +Connects to each IOM via its network IP address using the Redfish API. +""" + +import os +import time + +from redfish import ( + _redfish_upload_firmware, + _redfish_trigger_update, + _redfish_poll_tasks, + _redfish_restart_iom, + _redfish_reset_fabric, + _wait_for_iom_online, + _show_fw_versions, +) +from ui import ( + _c, C, + banner, rule, draw_box, + info, ok, warn, error, + prompt, prompt_ip, prompt_yn, prompt_password, +) + + +# ── Firmware file selection helper ──────────────────────────────────────────── +def _prompt_fw_file(label: str) -> str: + """ + Scan the firmware/ directory (next to es24n_conf.py) for firmware files + and let the user pick one by number, or enter a custom path as the last + option. Files are sorted most-recently-modified first. + """ + fw_dir = os.path.normpath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "firmware") + ) + FW_EXTS = {".bin", ".img", ".fw", ".fwc", ".hex", ".zip", ".tar", ".tgz", ".gz"} + + try: + candidates = sorted( + [f for f in os.listdir(fw_dir) + if not f.startswith(".") + and os.path.isfile(os.path.join(fw_dir, f)) + and os.path.splitext(f)[1].lower() in FW_EXTS], + key=lambda f: os.path.getmtime(os.path.join(fw_dir, f)), + reverse=True, + ) + except OSError: + candidates = [] + + print() + if candidates: + info(f"Firmware files found in {fw_dir}:") + for i, fname in enumerate(candidates, 1): + sz = os.path.getsize(os.path.join(fw_dir, fname)) + print(f" {_c(C.BOLD, str(i))} {fname} {_c(C.DIM, f'({sz // 1024} KB)')}") + custom_idx = len(candidates) + 1 + print(f" {_c(C.BOLD, str(custom_idx))} Enter a custom file path") + print() + + while True: + choice = prompt(f"Select {label} [1-{custom_idx}]") + if choice.isdigit(): + idx = int(choice) + if 1 <= idx <= len(candidates): + path = os.path.join(fw_dir, candidates[idx - 1]) + sz = os.path.getsize(path) + ok(f"Selected: {candidates[idx - 1]} ({sz // 1024} KB)") + return path + if idx == custom_idx: + break + warn(f"Please enter a number between 1 and {custom_idx}.") + else: + info(f"No firmware files found in {fw_dir}.") + + # Manual path entry + while True: + path = prompt(f"Path to {label}") + if os.path.isfile(path): + sz = os.path.getsize(path) + ok(f"File: {path} ({sz // 1024} KB)") + return path + warn(f"File not found: {path}") + + +# ── Multi-shelf IP collection ───────────────────────────────────────────────── +def _collect_shelves() -> list: + """ + Prompt for the password and a single IOM IP address per shelf, + offering to add more shelves after each entry. + + Either IOM's IP can be used — both IOMs share the same Redfish endpoint + and can reach each other's resources. Each shelf has its own password + because the Admin password is the BMC serial number, unique per shelf. + + Returns a list of (password, ip) tuples, one per shelf. + """ + shelves = [] + shelf_num = 1 + + while True: + info(f"Shelf {shelf_num} — enter password and IOM IP address.") + password = prompt_password() + ip = prompt_ip(f" Shelf {shelf_num} IOM IP address (IOM1 or IOM2)") + shelves.append((password, ip)) + print() + + if not prompt_yn("Add another shelf?", default=False): + break + shelf_num += 1 + print() + + return shelves + + +def _make_targets(shelves: list, iom_choice: str) -> list: + """ + Convert the shelves structure into a flat list of (label, iom, ip, password) + tuples suitable for _show_fw_versions(). When there is only one shelf the + label is just the IOM name; for multiple shelves it includes the shelf number. + All IOMs in a shelf share the same IP connection. + """ + ioms = [] + if iom_choice in ("1", "3"): + ioms.append("IOM1") + if iom_choice in ("2", "3"): + ioms.append("IOM2") + + multi = len(shelves) > 1 + return [ + (f"S{i} / {iom}" if multi else iom, iom, ip, password) + for i, (password, ip) in enumerate(shelves, 1) + for iom in ioms + ] + + +# ── Per-IOM update helpers ──────────────────────────────────────────────────── +def _update_iom_fw(password: str, ip: str, iom: str, fw_path: str) -> bool: + """Upload and apply IOM firmware for one IOM, then restart it.""" + sz = os.path.getsize(fw_path) + info(f"Uploading IOM firmware ({sz // 1024} KB) to {iom} at {ip}...") + ok_flag, data = _redfish_upload_firmware(password, ip, fw_path) + if not ok_flag: + error(f"Upload failed: {data}") + return False + ok("Firmware file uploaded.") + + info(f"Triggering {iom} firmware update...") + ok_flag, data = _redfish_trigger_update( + password, ip, f"/redfish/v1/Managers/{iom}", + ) + if not ok_flag: + error(f"Update trigger failed: {data}") + return False + ok("Update triggered.") + + info("Monitoring update progress (this may take several minutes)...") + ok_flag, msg = _redfish_poll_tasks(password, ip) + if not ok_flag: + warn(f"Task monitoring ended: {msg}") + else: + ok(msg) + + info(f"Restarting {iom}...") + _redfish_restart_iom(password, ip, iom) # connection drop on restart is normal + ok(f"{iom} restart initiated. Waiting for IOM to come back online...") + time.sleep(30) # allow time for the IOM to begin shutting down before polling + if _wait_for_iom_online(password, ip): + ok(f"{iom} is back online.") + info("Allowing IOM services to fully initialize before next step...") + time.sleep(60) + else: + warn(f"{iom} did not respond within 5 minutes — proceeding anyway.") + return True + + +def _update_fabric_fw(password: str, ip: str, iom: str, fw_path: str) -> bool: + """ + Upload and apply Fabric Card firmware for one IOM. + Per the service guide, the firmware file must be re-uploaded even if it was + already uploaded during the IOM firmware step. + After the update: restart fabric card, then restart IOM. + """ + sz = os.path.getsize(fw_path) + + # Retry the upload — after an IOM reboot the inter-IOM services can take + # time to initialize, causing the first upload attempt to fail with + # "Failed to send update package to other IOM". + MAX_UPLOAD_ATTEMPTS = 3 + ok_flag, data = False, "" + for attempt in range(1, MAX_UPLOAD_ATTEMPTS + 1): + info(f"Uploading Fabric Card firmware ({sz // 1024} KB) to {iom} at {ip}" + + (f" (attempt {attempt}/{MAX_UPLOAD_ATTEMPTS})" if attempt > 1 else "") + "...") + ok_flag, data = _redfish_upload_firmware(password, ip, fw_path) + if ok_flag: + break + if attempt < MAX_UPLOAD_ATTEMPTS: + warn(f"Upload failed: {data}") + info("Waiting 60s for IOM services to finish initializing...") + time.sleep(60) + + if not ok_flag: + error(f"Upload failed after {MAX_UPLOAD_ATTEMPTS} attempts: {data}") + return False + ok("Firmware file uploaded.") + + info(f"Triggering {iom} Fabric Card firmware update...") + ok_flag, data = _redfish_trigger_update( + password, ip, f"/redfish/v1/Chassis/{iom}/NetworkAdapters/1", + ) + if not ok_flag: + error(f"Update trigger failed: {data}") + return False + ok("Update triggered.") + + info("Monitoring update progress...") + ok_flag, msg = _redfish_poll_tasks(password, ip) + if not ok_flag: + warn(f"Task monitoring ended: {msg}") + else: + ok(msg) + + info(f"Restarting {iom} Fabric Card...") + _redfish_reset_fabric(password, ip, iom) + ok("Fabric Card restart initiated. Waiting 15s...") + time.sleep(15) + + info(f"Restarting {iom} after Fabric Card update...") + _redfish_restart_iom(password, ip, iom) + ok(f"{iom} restart initiated. Waiting for IOM to come back online...") + time.sleep(30) # allow time for the IOM to begin shutting down before polling + if _wait_for_iom_online(password, ip): + ok(f"{iom} is back online.") + else: + warn(f"{iom} did not respond within 5 minutes — proceeding anyway.") + return True + + +# ── Firmware Update Workflow ────────────────────────────────────────────────── +def firmware_update_workflow(): + """ + Standalone firmware update for IOM and Fabric Card firmware. + Connects to each IOM via its network IP (not serial loopback) — uploading + firmware over 115200-baud serial would be impractically slow. + Supports updating multiple shelves in a single run. + """ + banner() + rule("IOM & Fabric Card Firmware Update") + info("This procedure connects to each IOM via its network IP address.") + info("Ensure this system has network access to the IOM management interface.") + print() + + print(" Which IOM(s) would you like to update?") + print(f" {_c(C.BOLD, '1')} IOM1 only") + print(f" {_c(C.BOLD, '2')} IOM2 only") + print(f" {_c(C.BOLD, '3')} Both IOM1 and IOM2 {_c(C.YEL, '(Do not use on production systems)')}") + print() + + while True: + iom_choice = prompt("Select option [1-3]") + if iom_choice in ("1", "2", "3"): + break + warn("Please enter 1, 2, or 3.") + print() + + # ── Collect IPs for one or more shelves ─────────────────────────────────── + shelves = _collect_shelves() + targets = _make_targets(shelves, iom_choice) + + rule("Current Firmware Versions") + _show_fw_versions(targets) + + print(" What would you like to update?") + print(f" {_c(C.BOLD, '1')} IOM Firmware only") + print(f" {_c(C.BOLD, '2')} Fabric Card Firmware only") + print(f" {_c(C.BOLD, '3')} Both IOM and Fabric Card Firmware") + print(f" {_c(C.BOLD, '4')} Cancel") + print() + + while True: + choice = prompt("Select option [1-4]") + if choice in ("1", "2", "3", "4"): + break + warn("Please enter 1, 2, 3, or 4.") + + if choice == "4": + info("Firmware update cancelled.") + return + + update_iom = choice in ("1", "3") + update_fabric = choice in ("2", "3") + + fw_path = _prompt_fw_file("firmware file") + + print() + warn("For HA systems: update the passive IOM first.") + if len(shelves) > 1: + warn(f"Updating {len(shelves)} shelves sequentially — Shelf 1 first.") + elif iom_choice == "3": + warn("IOM1 will be updated first — adjust order if IOM2 is passive.") + print() + + if not prompt_yn("Proceed with firmware update?", default=True): + info("Firmware update cancelled.") + return + + # ── Run updates per target (shelf × IOM) ───────────────────────────────── + for label, iom, ip, password in targets: + rule(f"{label} ({ip})") + if update_iom: + _update_iom_fw(password, ip, iom, fw_path) + if update_fabric: + _update_fabric_fw(password, ip, iom, fw_path) + + rule("Post-Update Firmware Validation") + _show_fw_versions(targets) + + ok("Firmware update complete.") + print() + draw_box([ + f"{_c(C.YEL, 'IMPORTANT -- For HA (Dual-Controller) Systems:')}", + "", + "After updating this controller's IOMs:", + " 1. Log into TrueNAS and initiate a failover.", + " 2. Re-run this tool to update the other controller.", + ], colour=C.YEL) + print() + prompt("Press Enter to return to main menu") diff --git a/modules/workflow_restart.py b/modules/workflow_restart.py new file mode 100644 index 0000000..5dc66b8 --- /dev/null +++ b/modules/workflow_restart.py @@ -0,0 +1,150 @@ +""" +workflow_restart.py — Restart IOM workflow. +Sends a GracefulRestart to a selected IOM via either a direct network +Redfish connection or a serial console curl command. +""" + +import time + +from redfish import _redfish_restart_iom +from workflow_serial import ( + detect_serial_device, + open_serial_connection, + _login_serial_console, + _serial_redfish_request, +) +from ui import ( + _c, C, + banner, rule, + info, ok, warn, error, + prompt, prompt_ip, prompt_password, prompt_yn, +) + + +def _iom_prompt() -> str: + """Ask which IOM to target and return 'IOM1' or 'IOM2'.""" + print() + print(" Which IOM would you like to restart?") + print(f" {_c(C.BOLD, '1')} IOM1") + print(f" {_c(C.BOLD, '2')} IOM2") + print() + while True: + choice = prompt("Select [1/2]") + if choice in ("1", "2"): + break + warn("Please enter 1 or 2.") + return "IOM1" if choice == "1" else "IOM2" + + +# ── Network restart ──────────────────────────────────────────────────────────── + +def _restart_via_network(): + banner() + rule("Restart IOM -- Network Connection") + + print() + password = prompt_password() + print() + ip = prompt_ip(" IOM IP address (either IOM1 or IOM2)") + iom = _iom_prompt() + + print() + if not prompt_yn(f"Restart {iom} at {ip}?", default=False): + info("Restart cancelled.") + print() + prompt("Press Enter to return to main menu") + return + + info(f"Sending restart command to {iom}...") + ok_flag, data = _redfish_restart_iom(password, ip, iom) + if ok_flag: + ok(f"{iom} restart initiated.") + info("The IOM will be temporarily unreachable while rebooting.") + else: + error(f"Restart failed: {data}") + + print() + prompt("Press Enter to return to main menu") + + +# ── Serial restart ───────────────────────────────────────────────────────────── + +def _restart_via_serial(): + banner() + rule("Restart IOM -- Serial Connection") + + device = detect_serial_device() + if not device: + error("Could not detect a serial device. Returning to main menu.") + time.sleep(2) + return + + ser = open_serial_connection(device) + if not ser: + error("Could not open serial port. Returning to main menu.") + time.sleep(2) + return + + print() + logged_in, password = _login_serial_console(ser) + if not logged_in: + error("Could not log in to IOM console. Returning to main menu.") + if ser.is_open: + ser.close() + time.sleep(2) + return + + iom = _iom_prompt() + + print() + if not prompt_yn(f"Restart {iom} via serial?", default=False): + info("Restart cancelled.") + if ser.is_open: + ser.close() + print() + prompt("Press Enter to return to main menu") + return + + info(f"Sending restart command to {iom}...") + ok_flag, data = _serial_redfish_request( + ser, password, "POST", + f"/redfish/v1/Managers/{iom}/Actions/Manager.Reset", + {"ResetType": "GracefulRestart"}, + ) + if ok_flag: + ok(f"{iom} restart initiated.") + info("The IOM will be temporarily unreachable while rebooting.") + else: + error(f"Restart failed: {data}") + + if ser.is_open: + ser.close() + ok(f"Serial port {device} closed.") + + print() + prompt("Press Enter to return to main menu") + + +# ── Top-level entry point ────────────────────────────────────────────────────── + +def restart_iom_workflow(): + banner() + rule("Restart IOM") + + print(" How do you want to connect to the IOM?") + print() + print(f" {_c(C.BOLD, '1')} Network connection") + print(f" {_c(C.BOLD, '2')} Serial connection") + print(f" {_c(C.BOLD, '3')} Cancel") + print() + + while True: + choice = prompt("Select [1/2/3]") + if choice in ("1", "2", "3"): + break + warn("Please enter 1, 2, or 3.") + + if choice == "1": + _restart_via_network() + elif choice == "2": + _restart_via_serial() diff --git a/modules/workflow_serial.py b/modules/workflow_serial.py new file mode 100644 index 0000000..804190a --- /dev/null +++ b/modules/workflow_serial.py @@ -0,0 +1,648 @@ +""" +workflow_serial.py — Serial-based ES24N IOM network configuration workflow. +Connects via USB serial cable, logs in to the IOM console, and configures +network settings by running curl commands through the shell session. + +The IOM's Redfish API is only accessible locally at https://127.0.0.1 from +within the IOM's own shell — not directly from the host over the serial cable. +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 + +from models import IOMConfig, ShelfConfig +from serial_port import SerialPort +from ui import ( + _c, C, + banner, rule, draw_table, draw_box, + info, ok, warn, error, + prompt, prompt_ip, prompt_yn, prompt_password, +) + +# Strip ANSI escape sequences from serial terminal output. +# Catches CSI sequences (ESC[...X) and other two-character ESC sequences. +_ANSI_RE = re.compile(r'\x1b(?:\[[0-9;]*[A-Za-z]|[^[])') + + +def _at_shell_prompt(text: str) -> bool: + """ + Return True if any line in text looks like a root shell prompt. + Checks whether a non-empty line ends with '#' or '# ' — the pattern + produced by prompts like 'root@hostname:~#' — without being tripped + up by the word 'login' appearing elsewhere in the output (e.g. in + 'Last login: ...' messages shown after a session is resumed). + """ + for line in text.splitlines(): + stripped = line.rstrip() + if stripped.endswith("#") or stripped.endswith("# "): + return True + return False + + +def _extract_serial_from_hostname(hostname: str) -> Optional[str]: + """ + Extract the BMC serial number from an IOM hostname. + The serial number (which doubles as the root password) is a + dash-separated segment matching MXE followed by alphanumerics. + e.g. 'ves-ves-vds2249r-MXE3000048LHA03C-mgr1' → 'MXE3000048LHA03C' + Returns None if no matching segment is found. + """ + for segment in hostname.split("-"): + if re.match(r"^MXE[A-Z0-9]+$", segment, re.IGNORECASE): + return segment + return None + + +def _detect_password_from_shell(ser: SerialPort) -> Optional[str]: + """ + When already logged in, run 'hostname' on the IOM shell and extract + the BMC serial number from the output. + """ + ser.send_line("hostname", delay=0.3) + out = _ANSI_RE.sub("", ser.read_until_quiet(quiet_period=0.5)) + for line in out.splitlines(): + pw = _extract_serial_from_hostname(line.strip()) + if pw: + return pw + return None + + +# ── Serial Redfish transport ─────────────────────────────────────────────────── +def _login_serial_console(ser: SerialPort) -> tuple: + """ + Perform the root login sequence on the IOM serial console. + + Auto-detects the root password from the IOM hostname — the BMC serial + number (e.g. MXE3000048LHA03C) is embedded in both the login prompt + hostname and the output of 'hostname', so no manual entry is needed. + + - Login prompt present: extracts serial from 'hostname login:' line. + - Already logged in: runs 'hostname' on the shell to extract the serial. + - Falls back to prompting the user if auto-detection fails either way. + + Returns (success: bool, password: str). + """ + info("Logging in to IOM serial console...") + + # Send a newline to get the current state of the console + ser.send_line("", delay=0.5) + response = _ANSI_RE.sub("", ser.read_until_quiet(quiet_period=0.5)) + + # Already at a shell prompt — extract password via 'hostname' command + if _at_shell_prompt(response): + ok("Already at shell prompt.") + password = _detect_password_from_shell(ser) + if password: + ok(f"Password auto-detected: {_c(C.BOLD, password)}") + return True, password + warn("Could not auto-detect password from hostname.") + return True, prompt_password() + + low = response.lower() + + # Try to extract the password from the login prompt hostname before + # sending credentials — format: 'ves-ves-model-SERIAL-mgr1 login:' + password = None + login_match = re.search(r"(\S+)\s+login:", response, re.IGNORECASE) + if login_match: + password = _extract_serial_from_hostname(login_match.group(1)) + if password: + ok(f"Password auto-detected from login prompt: {_c(C.BOLD, password)}") + + # Send username if we see a login prompt (or an empty/unknown response) + if "login" in low or not response.strip(): + ser.send_line("root", delay=0.5) + response = _ANSI_RE.sub("", ser.read_until_quiet(quiet_period=0.5)) + + # Fall back to asking the user if auto-detection failed + if not password: + warn("Could not auto-detect password from login prompt.") + password = prompt_password() + + # Send password + if "password" in response.lower(): + ser.send_line(password, delay=0.5) + response = _ANSI_RE.sub("", ser.read_until_quiet(quiet_period=2.0)) + + if _at_shell_prompt(response): + ok("Logged in to IOM console.") + return True, password + + # "Last login:" in the response confirms the password was accepted — + # the shell prompt just hasn't arrived yet (slow shell init or motd). + # Give it up to 10 more seconds to appear. + if "last login" in response.lower(): + response += _ANSI_RE.sub("", ser.read_until_quiet(quiet_period=1.5, timeout=10.0)) + if _at_shell_prompt(response): + ok("Logged in to IOM console.") + return True, password + + error(f"Login failed. Console response: {response.strip()[:120]}") + return False, "" + + +def _serial_redfish_request(ser: SerialPort, password: str, method: str, + path: str, payload: Optional[dict] = None) -> tuple: + """ + Issue a Redfish request by running curl on the IOM's serial console. + The Redfish API is available at https://127.0.0.1 from within the IOM shell. + + Appends -w '\\nHTTP_CODE:%{http_code}' to every curl call so the HTTP + status can be parsed independently of the response body. + + Returns (success: bool, data: dict | str). + """ + url = f"https://127.0.0.1{path}" + cmd = f"curl -sk -u root:{password} -X {method}" + if payload: + body = json.dumps(payload, separators=(",", ":")) + cmd += f" -H 'Content-Type: application/json' -d '{body}'" + cmd += f" -w '\\nHTTP_CODE:%{{http_code}}' '{url}'" + + ser.send_line(cmd, delay=0.3) + raw = _ANSI_RE.sub("", ser.read_until_quiet(quiet_period=1.5, timeout=30.0)) + + # ── Parse HTTP status code ───────────────────────────────────────────────── + # Use the LAST occurrence of HTTP_CODE: so the echoed curl -w argument + # (which also contains the literal text "HTTP_CODE:") doesn't interfere. + http_code = 0 + if "HTTP_CODE:" in raw: + try: + http_code = int(raw.split("HTTP_CODE:")[-1].strip()[:3]) + except ValueError: + pass + + # ── Extract JSON body ────────────────────────────────────────────────────── + # The terminal echoes the curl command before executing it, and the + # -w format string contains '%{http_code}' which includes a literal '{'. + # When the terminal wraps long command lines, that '{' can land at a + # line boundary, causing find("{") to return the wrong position. + # + # Strategy: narrow the search to the text before HTTP_CODE: (the actual + # curl response body appears there), then find the LAST newline-prefixed + # '{' — because the JSON response always starts on its own line, while + # the echoed '{http_code}' is embedded mid-line in the curl command echo. + # Use rfind (not find) — the -w argument in the echoed curl command also + # contains the literal text "HTTP_CODE:", so find() would land there instead + # of at the actual HTTP_CODE:200 output that curl appends at the end. + http_code_pos = raw.rfind("HTTP_CODE:") + search_area = raw[:http_code_pos].rstrip() if http_code_pos >= 0 else raw + + data: dict = {} + json_end = search_area.rfind("}") + 1 + if json_end > 0: + json_start = -1 + for nl in ("\r\n", "\n"): + pos = search_area.rfind(nl + "{") + if pos >= 0: + json_start = pos + len(nl) + break + if json_start < 0: + json_start = search_area.find("{") # fallback if no newline found + + if json_start >= 0: + try: + data = json.loads(search_area[json_start:json_end]) + except json.JSONDecodeError: + pass + + # ── Determine success ────────────────────────────────────────────────────── + if http_code >= 400: + msg = (data.get("error", {}).get("message", "") + if isinstance(data, dict) else "") + snippet = search_area[json_start:json_start + 120] if json_start >= 0 else search_area[-120:] + return False, f"HTTP {http_code}: {msg or snippet}" + + if http_code >= 200 or data: + return True, data + + # No HTTP code and no JSON — check for curl-level error + if "curl:" in raw.lower(): + return False, f"curl error: {raw.strip()[:120]}" + + return True, data + + +# ── Step 1: Detect serial device ────────────────────────────────────────────── +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() + prompt("Press Enter when the cable is connected") + + for attempt in range(1, 4): + 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)}) + + if ports: + break + + if attempt < 3: + warn("No device found yet — retrying in 2 seconds...") + time.sleep(2) + + if not ports: + error("No USB serial device detected after 3 attempts.") + print() + print(" Troubleshooting:") + print(" - Ensure the serial cable is fully seated at both ends.") + print(" - Try a different USB port on the controller.") + print(" - Confirm the ES24N is powered on.") + 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 + print() + draw_table( + ["#", "Device"], + [[str(i), p] for i, p in enumerate(ports, 1)], + [4, 24], + ) + print() + + while True: + val = prompt(f"Select device number [1-{len(ports)}]") + 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") + + info(f"Opening {device} at 115200 baud (8N1)...") + ser = SerialPort(device, baudrate=115200, timeout=5.0) + + try: + ser.open() + except OSError as e: + error(str(e)) + return None + + ok(f"Port opened: {device}") + info("Sending wake signal to IOM console...") + + ser.send_line("", delay=0.5) + ser.send_line("", delay=0.5) + response = _ANSI_RE.sub("", ser.read_until_quiet(quiet_period=0.5)) + + print() + if response.strip(): + print(f" {_c(C.DIM, '+-- IOM Console Response ' + '-' * 31)}") + for line in response.strip().splitlines(): + print(f" {_c(C.DIM, '|')} {line}") + print(f" {_c(C.DIM, '+' + '-' * 56)}") + print() + + low = response.lower() + if any(kw in low for kw in ("login", "$", "#", "password")): + ok("IOM console is responsive.") + else: + warn("Unexpected response — the IOM may still be booting.") + warn("You can continue; login will be attempted next.") + else: + warn("No response received from IOM console.") + warn("Login will be attempted after the password is entered.") + + print() + return ser + + +# ── Step 3: Fetch & display current IOM network settings ───────────────────── +def fetch_current_config(cfg: ShelfConfig, ser: SerialPort, iom: str) -> bool: + """ + Query Redfish for the current network config of the connected IOM via curl + over the serial console session. Only the IOM whose console port the serial + cable is plugged into is reachable. + Populates cfg.iom1 with live data (regardless of whether IOM1 or IOM2). + Returns True if the IOM responded successfully. + """ + rule(f"Step 3 of 5 -- Current {iom} Network Settings") + info(f"Querying {iom} Redfish API via serial console...") + print() + + path = f"/redfish/v1/Managers/{iom}/EthernetInterfaces/1" + ok_flag, data = _serial_redfish_request(ser, cfg.password, "GET", path) + + if ok_flag and isinstance(data, dict): + dhcp_enabled = ( + data.get("DHCPv4", {}).get("DHCPEnabled", False) or + data.get("DHCPv6", {}).get("DHCPEnabled", False) + ) + + # Prefer IPv4StaticAddresses; fall back to IPv4Addresses + addrs = data.get("IPv4StaticAddresses") or data.get("IPv4Addresses", []) + if addrs: + addr_rec = addrs[0] + ip = addr_rec.get("Address", "--") + gateway = addr_rec.get("Gateway", "--") + netmask = addr_rec.get("SubnetMask", "--") + else: + ip = gateway = netmask = "--" + + cfg.iom1 = IOMConfig( + iom = iom, + dhcp = dhcp_enabled, + ip = ip if ip != "--" else "", + gateway = gateway if gateway != "--" else "", + netmask = netmask if netmask != "--" else "", + ) + + mode_str = _c(C.CYN, "DHCP") if dhcp_enabled else _c(C.GRN, "Static") + draw_table( + ["IOM", "Mode", "IP Address", "Gateway", "Subnet Mask"], + [[iom, mode_str, ip, gateway, netmask]], + [5, 8, 15, 15, 15], + ) + print() + return True + else: + draw_table( + ["IOM", "Mode", "IP Address", "Gateway", "Subnet Mask"], + [[iom, _c(C.RED, "No response"), "--", "--", "--"]], + [5, 8, 15, 15, 15], + ) + print() + error(f"{iom} query failed: {data}") + error(f"Check that the serial cable is connected and {iom} is booted.") + print() + return False + + +# ── Step 4: Prompt user — change config or exit ─────────────────────────────── +def collect_network_config(cfg: ShelfConfig, iom: str) -> bool: + """ + Show current settings, ask user what to do. + Returns True to proceed with applying changes, False to skip. + """ + rule("Step 4 of 5 -- Change Configuration?") + + print(f" {_c(C.BOLD, '1')} Change network configuration") + print(f" {_c(C.BOLD, '2')} Leave settings as-is and disconnect") + print() + + while True: + choice = prompt("Select option [1/2]") + if choice in ("1", "2"): + break + warn("Please enter 1 or 2.") + + if choice == "2": + info("No changes requested.") + return False + + # ── User wants to change settings ───────────────────────────────────────── + print() + print(f" How should {iom} be configured?") + print(f" {_c(C.BOLD, '1')} Static IP address") + print(f" {_c(C.BOLD, '2')} DHCP") + print() + + while True: + mode = prompt("Select mode [1/2]") + if mode in ("1", "2"): + break + warn("Please enter 1 or 2.") + + use_dhcp = (mode == "2") + print() + + if use_dhcp: + cfg.iom1 = IOMConfig(iom, dhcp=True) + ok(f"{iom} will be set to DHCP.") + return True + + # Static + info(f"Static network details for {_c(C.BOLD, iom)}:") + ip = prompt_ip(f" {iom} IP address ") + gw = prompt_ip(f" {iom} Gateway ") + nm = prompt_ip(f" {iom} Subnet Mask") + cfg.iom1 = IOMConfig(iom, dhcp=False, ip=ip, gateway=gw, netmask=nm) + return True + + +# ── Step 5a: Apply configuration via Redfish ────────────────────────────────── +def apply_configuration(cfg: ShelfConfig, ser: SerialPort) -> bool: + rule("Step 5 of 5 -- Applying Configuration via Redfish API") + + info("Sending Redfish PATCH request to IOM1 via serial console curl...") + print() + + success, detail = _apply_iom(cfg.password, cfg.iom1, ser) + status = _c(C.GRN, "OK") if success else _c(C.RED, "FAIL") + draw_table(["IOM", "Result", "Detail"], [["IOM1", status, detail]], [6, 8, 50]) + print() + return success + + +def _apply_iom(password: str, iom_cfg: IOMConfig, ser: SerialPort) -> tuple: + """ + Apply network config to a single IOM via curl over the serial console. + + DHCP: single PATCH enabling DHCPv4. + + Static: two sequential PATCHes to work around a firmware bug in the + current ES24N release that prevents disabling DHCP and setting a static + address in the same request. + Pass 1 -- set the static IP/gateway/netmask (DHCP still on) + Pass 2 -- disable DHCP (address is already committed) + """ + path = f"/redfish/v1/Managers/{iom_cfg.iom}/EthernetInterfaces/1" + + if iom_cfg.dhcp: + ok_flag, data = _serial_redfish_request( + ser, password, "PATCH", path, + {"DHCPv4": {"DHCPEnabled": True}}, + ) + if ok_flag: + return True, "Configured: DHCP" + return False, str(data)[:80] + + # Static -- Pass 1: set address while DHCP is still enabled + info(f" {iom_cfg.iom} pass 1/2 -- setting static address {iom_cfg.ip}...") + ok_flag, data = _serial_redfish_request( + ser, password, "PATCH", path, + { + "IPv4StaticAddresses": [{ + "Address": iom_cfg.ip, + "Gateway": iom_cfg.gateway, + "SubnetMask": iom_cfg.netmask, + }] + }, + ) + if not ok_flag: + return False, f"Pass 1 failed: {str(data)[:70]}" + + # Brief pause to allow the IOM to commit the address before the next call + time.sleep(1) + + # Static -- Pass 2: disable DHCP now that the static address is committed + info(f" {iom_cfg.iom} pass 2/2 -- disabling DHCP...") + ok_flag, data = _serial_redfish_request( + ser, password, "PATCH", path, + {"DHCPv4": {"DHCPEnabled": False}}, + ) + if not ok_flag: + return False, f"Pass 2 failed: {str(data)[:70]}" + + return True, f"Configured: Static {iom_cfg.ip}" + + +# ── Step 5b: Print applied-settings summary ─────────────────────────────────── +def print_summary(cfg: ShelfConfig, changed: bool): + rule("Summary") + + iom = cfg.iom1 + if iom.dhcp: + mode, ip, gateway, netmask = "DHCP", "-- (DHCP)", "-- (DHCP)", "-- (DHCP)" + else: + mode, ip, gateway, netmask = "Static", iom.ip, iom.gateway, iom.netmask + + draw_table( + ["Setting", "IOM1"], + [ + ["Mode", mode], + ["IP Address", ip], + ["Gateway", gateway], + ["Subnet Mask", netmask], + ["Serial Port", cfg.device], + ["Changes", "Yes" if changed else "None"], + ], + [12, 28], + ) + + if changed: + print() + draw_box([ + f"{_c(C.YEL, 'IMPORTANT -- Per the ES24N Service Guide:')}", + "", + "Remove the serial cable ONLY after verifying the", + "expander appears in TrueNAS with matching drives.", + "", + "TrueNAS > System Settings > Enclosure >", + "NVMe-oF Expansion Shelves", + ], colour=C.YEL) + print() + + +# ── Disconnect ──────────────────────────────────────────────────────────────── +def close_serial_connection(ser: SerialPort, device: str): + if ser and ser.is_open: + ser.close() + ok(f"Serial port {device} closed.") + + print() + prompt("Disconnect the serial cable, then press Enter to continue") + ok("Serial cable disconnected. Shelf complete.") + + +# ── Full shelf configuration cycle ──────────────────────────────────────────── +def configure_shelf() -> bool: + """Run one complete shelf cycle. Returns True if user wants another shelf.""" + banner() + cfg = ShelfConfig() + + # 1 — Detect device + device = detect_serial_device() + if not device: + error("Could not detect a serial device. Returning to main menu.") + time.sleep(2) + return True + cfg.device = device + + # 2 — Open serial port & wake IOM console + ser = open_serial_connection(device) + if not ser: + error("Could not open serial port. Returning to main menu.") + time.sleep(2) + return True + + # Log in to the IOM console (auto-detects password from hostname) + print() + logged_in, cfg.password = _login_serial_console(ser) + if not logged_in: + error("Could not log in to IOM console. Returning to main menu.") + close_serial_connection(ser, device) + time.sleep(2) + return True + + # Prompt for which IOM the serial cable is connected to + print() + print(" Which IOM is the serial cable connected to?") + print(f" {_c(C.BOLD, '1')} IOM1") + print(f" {_c(C.BOLD, '2')} IOM2") + print() + while True: + iom_choice = prompt("Select [1/2]") + if iom_choice in ("1", "2"): + break + warn("Please enter 1 or 2.") + iom = "IOM1" if iom_choice == "1" else "IOM2" + + # 3 — Fetch & display current settings + fetch_current_config(cfg, ser, iom) + + # 4 — Ask user: change or leave alone? + apply_changes = collect_network_config(cfg, iom) + + # 5 — Apply if requested + changed = False + if apply_changes: + print() + rule("Ready to Apply") + info("Redfish PATCH requests will be sent via curl on the IOM console.") + print() + if prompt_yn("Apply configuration now?", default=True): + apply_configuration(cfg, ser) + changed = True + else: + warn("Configuration skipped — no changes were made.") + + # Summary + print_summary(cfg, changed) + + # Disconnect + close_serial_connection(ser, device) + + print() + return prompt_yn("Configure another ES24N shelf?", default=False)