#!/usr/bin/env python3
"""Command line Modbus RTU terminal for the AD Keil project."""

from __future__ import annotations

import argparse
import cmd
import csv
import dataclasses
import re
import shlex
import sys
import time
from pathlib import Path
from typing import Iterable


DEFAULT_BAUD = 512000
DEFAULT_SLAVE = 1
DEFAULT_TIMEOUT = 0.5
DEFAULT_POLARITY = 1
DEFAULT_POLE_PAIRS = 1
DEFAULT_PHASE_SHUNT_OHM = 0.1
DEVICE_ID = 0xAD01


class TerminalError(Exception):
    """User-facing terminal error."""


class ModbusError(TerminalError):
    """Modbus transport or protocol error."""


class ModbusExceptionError(ModbusError):
    def __init__(self, function: int, code: int) -> None:
        super().__init__(
            f"Modbus exception: function=0x{function:02X}, code={code} "
            f"({MODBUS_EXCEPTIONS.get(code, 'unknown')})"
        )
        self.function = function
        self.code = code


@dataclasses.dataclass(frozen=True)
class Register:
    addr: int
    name: str
    access: str
    factor: float | None = None
    unit: str = ""
    signed: bool = False
    enum: dict[int, str] | None = None
    flags: dict[int, str] | None = None
    note: str = ""


MODBUS_EXCEPTIONS = {
    1: "illegal function",
    2: "illegal data address",
    3: "illegal data value",
    4: "server device failure",
}

MODES = {
    0: "IDLE",
    1: "STATOR_RESISTANCE",
    2: "NO_LOAD_MAGNETIZING",
    3: "LOCKED_ROTOR_LEAKAGE",
    4: "INERTIA_FRICTION",
    5: "DATA_LOGGING",
    6: "PWM_TEST_UH",
    7: "PWM_TEST_UL",
    8: "PWM_TEST_VH",
    9: "PWM_TEST_VL",
    10: "PWM_TEST_WH",
    11: "PWM_TEST_WL",
    12: "PWM_TEST_ALL",
    13: "AUTO_IDENTIFICATION",
    14: "ROTATION_3HZ",
}

MODE_ALIASES = {
    "idle": 0,
    "stop": 0,
    "rs": 1,
    "stator": 1,
    "stator_resistance": 1,
    "no_load": 2,
    "magnetizing": 2,
    "locked": 3,
    "locked_rotor": 3,
    "inertia": 4,
    "friction": 4,
    "log": 5,
    "logging": 5,
    "data": 5,
    "data_logging": 5,
    "uh": 6,
    "ul": 7,
    "vh": 8,
    "vl": 9,
    "wh": 10,
    "wl": 11,
    "all": 12,
    "pwm_all": 12,
    "auto": 13,
    "auto_id": 13,
    "auto_identification": 13,
    "rotation": 14,
    "rot": 14,
    "rotation_3hz": 14,
}

STATUS_FLAGS = {
    0: "ACTIVE",
    1: "POWER_TEST_BLOCKED",
    2: "POWER_STAGE_ARMED",
    3: "FAULT_LATCHED",
    4: "TIMEOUT",
    5: "LOCKED_ROTOR_BLOCKED",
    6: "SAFETY_LIMITS_UNKNOWN",
    7: "DATA_VALID",
    8: "COMPLETE",
}

FAULT_FLAGS = {
    0: "OVERCURRENT",
    1: "OVERVOLTAGE",
    2: "UNDERVOLTAGE",
    3: "OVERTEMPERATURE",
    4: "DRIVER",
    5: "EMERGENCY_STOP",
    6: "TIMEOUT",
    7: "NULL_INPUT",
}

MEAS_STATUS_FLAGS = {
    0: "OVERCURRENT",
    1: "OVERVOLTAGE",
    2: "UNDERVOLTAGE",
    3: "OVERTEMPERATURE",
    4: "DRIVER_FAULT",
    5: "EMERGENCY_STOP",
}

VALID_FLAGS = {
    0: "RS",
    1: "RR",
    2: "LS",
    3: "LR",
    4: "LM",
    5: "LL",
    6: "J",
    7: "B",
    8: "NOMINALS",
    9: "POLE_PAIRS",
}

PWM_OUTPUTS = {
    0: "NONE",
    1: "UH",
    2: "UL",
    3: "VH",
    4: "VL",
    5: "WH",
    6: "WL",
    7: "ALL",
}

ID_STAGES = {
    0: "IDLE",
    1: "SETTLE",
    2: "MEASURE",
    3: "PULSE",
    4: "ACCEL",
    5: "COAST",
}

TIMING_MODES = {0: "UP", 1: "CENTER"}
MOTOR_TYPES = {0: "AD_SINE", 1: "BLDC_6STEP"}
POLARITY_FLAGS = {0: "MAIN_INVERTED", 1: "COMP_INVERTED"}


REGISTERS = [
    Register(0x0000, "device_id", "R", enum={DEVICE_ID: "AD device"}),
    Register(0x0001, "protocol_version", "R"),
    Register(0x0002, "control_enable", "R/W", enum={0: "disabled", 1: "enabled"}),
    Register(0x0003, "control_mode", "R/W", enum=MODES),
    Register(0x0004, "control_reset_faults", "W", note="write 1 to reset faults"),
    Register(0x0005, "control_stop", "W", note="write 1 to stop"),
    Register(0x0006, "control_locked_rotor_allowed", "R/W", enum={0: "no", 1: "yes"}),
    Register(0x0007, "control_pwm_duty", "R/W", factor=0.0001, unit="duty"),
    Register(0x0008, "limit_current", "R/W", factor=0.01, unit="A"),
    Register(0x0009, "limit_overvoltage", "R/W", factor=0.1, unit="V"),
    Register(0x000A, "limit_undervoltage", "R/W", factor=0.1, unit="V"),
    Register(0x000B, "limit_speed", "R/W", unit="rpm"),
    Register(0x000C, "limit_temperature", "R/W", factor=0.1, unit="C", signed=True),
    Register(0x000D, "control_rotation_freq", "R/W", factor=0.1, unit="Hz"),
    Register(0x000E, "control_rotation_mod", "R/W", factor=0.0001, unit="mod"),
    Register(0x000F, "control_pwm_polarity_flags", "R/W", flags=POLARITY_FLAGS),
    Register(0x0010, "status_mode", "R", enum=MODES),
    Register(0x0011, "status_flags_lo", "R", flags=STATUS_FLAGS),
    Register(0x0012, "status_flags_hi", "R"),
    Register(0x0013, "fault_flags_lo", "R", flags=FAULT_FLAGS),
    Register(0x0014, "fault_flags_hi", "R"),
    Register(0x0015, "power_stage_allowed", "R", enum={0: "no", 1: "yes"}),
    Register(0x0016, "test_running", "R", enum={0: "no", 1: "yes"}),
    Register(0x0017, "inverter_pwm_running", "R", enum={0: "no", 1: "yes"}),
    Register(0x0018, "inverter_service_output", "R", enum=PWM_OUTPUTS),
    Register(0x0019, "inverter_id_stage", "R", enum=ID_STAGES),
    Register(0x001A, "control_pwm_timing_mode", "R/W", enum=TIMING_MODES),
    Register(0x001B, "control_motor_control_type", "R/W", enum=MOTOR_TYPES),
    Register(0x001E, "control_rotation_ramp_ms", "R/W", unit="ms"),
    Register(0x001F, "control_reset_current_peaks", "W", note="write 1 to reset phase current peaks"),
    Register(0x0020, "meas_ia", "R", factor=0.001, unit="A", signed=True),
    Register(0x0021, "meas_ib", "R", factor=0.001, unit="A", signed=True),
    Register(0x0022, "meas_ic", "R", factor=0.001, unit="A", signed=True),
    Register(0x0023, "meas_vdc", "R", factor=0.1, unit="V"),
    Register(0x0024, "meas_temp", "R", factor=0.1, unit="C", signed=True),
    Register(0x0025, "meas_status_lo", "R", flags=MEAS_STATUS_FLAGS),
    Register(0x0026, "meas_status_hi", "R"),
    Register(0x0027, "meas_speed", "R", unit="rpm", signed=True),
    Register(0x0028, "meas_ia_rms", "R", factor=0.001, unit="A"),
    Register(0x0029, "meas_ib_rms", "R", factor=0.001, unit="A"),
    Register(0x002A, "meas_ic_rms", "R", factor=0.001, unit="A"),
    Register(0x002B, "meas_torque", "R", factor=0.001, unit="Nm", signed=True),
    Register(0x002C, "meas_ia_peak", "R", factor=0.001, unit="A"),
    Register(0x002D, "meas_ib_peak", "R", factor=0.001, unit="A"),
    Register(0x002E, "meas_ic_peak", "R", factor=0.001, unit="A"),
    Register(0x002F, "meas_slip", "R", factor=0.01, unit="%", signed=True),
    Register(0x0030, "param_valid_mask_lo", "R", flags=VALID_FLAGS),
    Register(0x0031, "param_valid_mask_hi", "R"),
    Register(0x0032, "param_rs", "R", factor=0.001, unit="ohm"),
    Register(0x0033, "param_ls_lo", "R", unit="uH"),
    Register(0x0034, "param_ls_hi", "R", unit="uH"),
    Register(0x0035, "param_ll_lo", "R", unit="uH"),
    Register(0x0036, "param_ll_hi", "R", unit="uH"),
    Register(0x0037, "param_rr", "R", factor=0.001, unit="ohm"),
    Register(0x0038, "param_lr_lo", "R", unit="uH"),
    Register(0x0039, "param_lr_hi", "R", unit="uH"),
    Register(0x003A, "param_lm_lo", "R", unit="uH"),
    Register(0x003B, "param_lm_hi", "R", unit="uH"),
    Register(0x003C, "param_j_lo", "R", unit="nkg*m2"),
    Register(0x003D, "param_j_hi", "R", unit="nkg*m2"),
    Register(0x003E, "param_b_lo", "R", unit="nNm*s"),
    Register(0x003F, "param_b_hi", "R", unit="nNm*s"),
    Register(0x0040, "param_pole_pairs", "R/W", unit="pairs"),
    Register(0x0041, "param_phase_shunt", "R/W", factor=0.001, unit="ohm"),
]

REGISTER_BY_ADDR = {reg.addr: reg for reg in REGISTERS}
REGISTER_BY_NAME = {reg.name: reg.addr for reg in REGISTERS}
REGISTER_ALIASES = {
    "enable": 0x0002,
    "mode": 0x0003,
    "reset": 0x0004,
    "reset_faults": 0x0004,
    "stop": 0x0005,
    "locked": 0x0006,
    "locked_rotor": 0x0006,
    "duty": 0x0007,
    "current": 0x0008,
    "overvoltage": 0x0009,
    "undervoltage": 0x000A,
    "speed_limit": 0x000B,
    "temp_limit": 0x000C,
    "freq": 0x000D,
    "rotation_freq": 0x000D,
    "rotation_mod": 0x000E,
    "ramp": 0x001E,
    "ramp_ms": 0x001E,
    "rotation_ramp": 0x001E,
    "rotation_ramp_ms": 0x001E,
    "reset_peaks": 0x001F,
    "reset_current_peaks": 0x001F,
    "polarity": 0x000F,
    "status": 0x0011,
    "faults": 0x0013,
    "ia": 0x0020,
    "ib": 0x0021,
    "ic": 0x0022,
    "vdc": 0x0023,
    "temp": 0x0024,
    "rpm": 0x0027,
    "torque": 0x002B,
    "ia_peak": 0x002C,
    "ib_peak": 0x002D,
    "ic_peak": 0x002E,
    "slip": 0x002F,
    "slip_percent": 0x002F,
    "valid": 0x0030,
    "rs": 0x0032,
    "rr": 0x0037,
    "pole_pairs": 0x0040,
    "poles": 0x0040,
    "pairs": 0x0040,
    "phase_shunt": 0x0041,
    "phase_shunt_ohm": 0x0041,
    "phase_shunt_mohm": 0x0041,
    "param_phase_shunt_mohm": 0x0041,
    "rshunt": 0x0041,
    "shunt": 0x0041,
}


def require_serial_module():
    try:
        import serial  # type: ignore[import-not-found]
    except ModuleNotFoundError as exc:
        raise TerminalError(
            "pyserial is not installed. Install it with: "
            "python -m pip install -r AD_Keil_Project\\tools\\requirements-ad-terminal.txt"
        ) from exc
    return serial


def crc16_modbus(data: bytes | bytearray) -> int:
    crc = 0xFFFF
    for byte in data:
        crc ^= byte
        for _ in range(8):
            if crc & 0x0001:
                crc = (crc >> 1) ^ 0xA001
            else:
                crc >>= 1
            crc &= 0xFFFF
    return crc


def add_crc(frame: bytes | bytearray) -> bytes:
    crc = crc16_modbus(frame)
    return bytes(frame) + bytes((crc & 0xFF, (crc >> 8) & 0xFF))


def check_crc(frame: bytes | bytearray) -> bool:
    if len(frame) < 4:
        return False
    expected = frame[-2] | (frame[-1] << 8)
    return crc16_modbus(frame[:-2]) == expected


def to_s16(value: int) -> int:
    value &= 0xFFFF
    return value - 0x10000 if value & 0x8000 else value


def u16(value: int) -> int:
    if not 0 <= value <= 0xFFFF:
        raise TerminalError(f"value out of uint16 range: {value}")
    return value


def u32_from_words(lo: int, hi: int) -> int:
    return (lo & 0xFFFF) | ((hi & 0xFFFF) << 16)


def parse_int(text: str) -> int:
    try:
        return int(text, 0)
    except ValueError as exc:
        raise TerminalError(f"not an integer: {text}") from exc


def argparse_int(text: str) -> int:
    try:
        return parse_int(text)
    except TerminalError as exc:
        raise argparse.ArgumentTypeError(str(exc)) from exc


def normalize_key(text: str) -> str:
    return text.strip().lower().replace("-", "_").replace(".", "_")


def parse_register_address(text: str) -> int:
    key = normalize_key(text)
    if key in REGISTER_BY_NAME:
        return REGISTER_BY_NAME[key]
    if key in REGISTER_ALIASES:
        return REGISTER_ALIASES[key]
    if key.startswith("ad_modbus_reg_"):
        key = key.removeprefix("ad_modbus_reg_")
        if key in REGISTER_BY_NAME:
            return REGISTER_BY_NAME[key]
        if key in REGISTER_ALIASES:
            return REGISTER_ALIASES[key]
    match = re.fullmatch(r"h?0*([0-9]{1,3})", key)
    if match:
        value = int(match.group(1), 10)
    else:
        value = parse_int(text)
    if 40001 <= value <= 40066:
        value -= 40001
    if not 0 <= value <= 0xFFFF:
        raise TerminalError(f"register address out of range: {text}")
    return value


def parse_u16_value(text: str) -> int:
    return u16(parse_int(text))


def parse_mode(text: str) -> int:
    key = normalize_key(text)
    if key in MODE_ALIASES:
        return MODE_ALIASES[key]
    for value, name in MODES.items():
        if key == name.lower():
            return value
    value = parse_int(text)
    if value not in MODES:
        raise TerminalError(f"unknown AD mode: {text}")
    return value


def parse_timing(text: str) -> int:
    key = normalize_key(text)
    if key in ("up", "edge", "0"):
        return 0
    if key in ("center", "centre", "1"):
        return 1
    raise TerminalError(f"unknown PWM timing mode: {text}")


def parse_motor_type(text: str) -> int:
    key = normalize_key(text)
    if key in ("ad", "sine", "ad_sine", "0"):
        return 0
    if key in ("bldc", "6step", "six_step", "bldc_6step", "1"):
        return 1
    raise TerminalError(f"unknown motor control type: {text}")


def scaled_u16(value: float, factor: float, field: str) -> int:
    raw = int(round(value / factor))
    if not 0 <= raw <= 0xFFFF:
        raise TerminalError(f"{field} out of register range: {value}")
    return raw


def pole_pairs_u16(value: float) -> int:
    raw = float(value)
    rounded = int(raw)
    if raw != float(rounded):
        raise TerminalError(f"pole pairs must be an integer: {value}")
    if not 0 <= rounded <= 64:
        raise TerminalError(f"pole pairs out of range 0..64: {value}")
    return rounded


def format_float(value: float, factor: float) -> str:
    if factor >= 1:
        decimals = 0
    else:
        decimals = min(6, max(0, len(str(factor).split(".")[-1].rstrip("0"))))
    return f"{value:.{decimals}f}"


def decode_flags(value: int, names: dict[int, str]) -> str:
    active = [name for bit, name in sorted(names.items()) if value & (1 << bit)]
    return ", ".join(active) if active else "none"


def format_reg_value(addr: int, value: int) -> str:
    reg = REGISTER_BY_ADDR.get(addr)
    if reg is None:
        return f"0x{value:04X}"
    raw = to_s16(value) if reg.signed else value
    parts: list[str] = []
    if reg.factor is not None:
        parts.append(f"{format_float(raw * reg.factor, reg.factor)} {reg.unit}".rstrip())
    elif reg.unit and reg.unit not in ("uH", "nkg*m2", "nNm*s"):
        parts.append(f"{raw} {reg.unit}")
    if reg.enum:
        parts.append(reg.enum.get(value, "unknown"))
    if reg.flags:
        parts.append(decode_flags(value, reg.flags))
    if not parts:
        if reg.name == "device_id":
            parts.append(f"0x{value:04X}")
        else:
            parts.append(str(raw))
    return " | ".join(parts)


def print_table(headers: list[str], rows: Iterable[Iterable[object]]) -> None:
    materialized = [[str(cell) for cell in row] for row in rows]
    widths = [len(header) for header in headers]
    for row in materialized:
        for idx, cell in enumerate(row):
            widths[idx] = max(widths[idx], len(cell))
    print("  ".join(header.ljust(widths[idx]) for idx, header in enumerate(headers)))
    print("  ".join("-" * width for width in widths))
    for row in materialized:
        print("  ".join(cell.ljust(widths[idx]) for idx, cell in enumerate(row)))


class ModbusRTUClient:
    def __init__(
        self,
        port: str,
        baudrate: int = DEFAULT_BAUD,
        slave: int = DEFAULT_SLAVE,
        timeout: float = DEFAULT_TIMEOUT,
        debug: bool = False,
    ) -> None:
        self.port = port
        self.baudrate = baudrate
        self.slave = slave
        self.timeout = timeout
        self.debug = debug
        self.serial = None

    def __enter__(self) -> "ModbusRTUClient":
        serial = require_serial_module()
        self.serial = serial.Serial(
            port=self.port,
            baudrate=self.baudrate,
            bytesize=8,
            parity=serial.PARITY_NONE,
            stopbits=serial.STOPBITS_ONE,
            timeout=self.timeout,
            write_timeout=self.timeout,
        )
        return self

    def __exit__(self, exc_type, exc, tb) -> None:
        if self.serial is not None:
            self.serial.close()
            self.serial = None

    def _read_exact(self, size: int) -> bytes:
        assert self.serial is not None
        data = self.serial.read(size)
        if len(data) != size:
            raise ModbusError(f"timeout waiting for {size} bytes, got {len(data)}")
        return bytes(data)

    def _request(self, function: int, payload: bytes) -> bytes:
        assert self.serial is not None
        request = add_crc(bytes((self.slave, function)) + payload)
        if self.debug:
            print(f"> {request.hex(' ')}")
        self.serial.reset_input_buffer()
        self.serial.write(request)
        self.serial.flush()

        head = self._read_exact(2)
        response_function = head[1]
        if response_function & 0x80:
            response = head + self._read_exact(3)
        elif response_function == 0x03:
            byte_count = self._read_exact(1)
            response = head + byte_count + self._read_exact(byte_count[0] + 2)
        elif response_function in (0x06, 0x10):
            response = head + self._read_exact(6)
        else:
            raise ModbusError(f"unexpected response function: 0x{response_function:02X}")
        if self.debug:
            print(f"< {response.hex(' ')}")
        if not check_crc(response):
            raise ModbusError(f"bad CRC in response: {response.hex(' ')}")
        if response[0] != self.slave:
            raise ModbusError(f"unexpected slave address: {response[0]}")
        if response_function & 0x80:
            raise ModbusExceptionError(response_function & 0x7F, response[2])
        if response_function != function:
            raise ModbusError(
                f"unexpected response function: expected 0x{function:02X}, "
                f"got 0x{response_function:02X}"
            )
        return response

    def read_holding(self, address: int, quantity: int) -> list[int]:
        if not 1 <= quantity <= 60:
            raise TerminalError("quantity must be 1..60")
        payload = bytes(
            (
                (address >> 8) & 0xFF,
                address & 0xFF,
                (quantity >> 8) & 0xFF,
                quantity & 0xFF,
            )
        )
        response = self._request(0x03, payload)
        byte_count = response[2]
        if byte_count != quantity * 2:
            raise ModbusError(f"unexpected byte count: {byte_count}")
        data = response[3 : 3 + byte_count]
        return [(data[i] << 8) | data[i + 1] for i in range(0, len(data), 2)]

    def write_single(self, address: int, value: int) -> None:
        value = u16(value)
        payload = bytes(
            (
                (address >> 8) & 0xFF,
                address & 0xFF,
                (value >> 8) & 0xFF,
                value & 0xFF,
            )
        )
        response = self._request(0x06, payload)
        if response[2:6] != payload:
            raise ModbusError("write echo does not match request")

    def write_multiple(self, address: int, values: list[int]) -> None:
        if not values:
            raise TerminalError("write-many needs at least one value")
        if len(values) > 59:
            raise TerminalError("too many registers for one write")
        words = bytearray()
        for value in values:
            value = u16(value)
            words.extend(((value >> 8) & 0xFF, value & 0xFF))
        quantity = len(values)
        payload = bytes(
            (
                (address >> 8) & 0xFF,
                address & 0xFF,
                (quantity >> 8) & 0xFF,
                quantity & 0xFF,
                len(words),
            )
        ) + bytes(words)
        response = self._request(0x10, payload)
        if response[2] != ((address >> 8) & 0xFF) or response[3] != (address & 0xFF):
            raise ModbusError("write-many address echo does not match request")
        echoed_quantity = (response[4] << 8) | response[5]
        if echoed_quantity != quantity:
            raise ModbusError("write-many quantity echo does not match request")


def list_ports() -> None:
    require_serial_module()
    from serial.tools import list_ports as pyserial_list_ports  # type: ignore[import-not-found]

    ports = list(pyserial_list_ports.comports())
    if not ports:
        print("No serial ports found.")
        return
    rows = []
    for port in ports:
        rows.append([port.device, port.description, port.hwid])
    print_table(["port", "description", "hwid"], rows)


def resolve_port(args: argparse.Namespace) -> str:
    port = getattr(args, "port", None)
    if not port:
        raise TerminalError("serial port is required; use --port COMx or --port auto")
    if port.lower() != "auto":
        return port

    require_serial_module()
    from serial.tools import list_ports as pyserial_list_ports  # type: ignore[import-not-found]

    candidates = [item.device for item in pyserial_list_ports.comports()]
    for candidate in candidates:
        try:
            with ModbusRTUClient(
                candidate,
                baudrate=args.baud,
                slave=args.slave,
                timeout=args.timeout,
                debug=False,
            ) as client:
                regs = client.read_holding(0, 2)
            if regs and regs[0] == DEVICE_ID:
                print(f"Using {candidate}")
                return candidate
        except TerminalError:
            continue
        except Exception:
            continue
    raise TerminalError("AD board was not found on available serial ports")


def open_client(args: argparse.Namespace) -> ModbusRTUClient:
    return ModbusRTUClient(
        resolve_port(args),
        baudrate=args.baud,
        slave=args.slave,
        timeout=args.timeout,
        debug=args.debug,
    )


def print_registers(values: list[int], start: int) -> None:
    rows = []
    for offset, value in enumerate(values):
        addr = start + offset
        reg = REGISTER_BY_ADDR.get(addr)
        rows.append(
            [
                f"{addr:04d}",
                f"0x{addr:04X}",
                f"400{addr + 1:02d}",
                reg.name if reg else "?",
                reg.access if reg else "?",
                f"{value}",
                f"0x{value:04X}",
                format_reg_value(addr, value),
            ]
        )
    print_table(["dec", "hex", "4x", "name", "rw", "raw", "hexval", "value"], rows)


def command_ping(client: ModbusRTUClient) -> None:
    regs = client.read_holding(0, 2)
    print_registers(regs, 0)
    if regs[0] != DEVICE_ID:
        raise TerminalError(f"unexpected device id: 0x{regs[0]:04X}")
    print("OK")


def command_read(client: ModbusRTUClient, address_text: str, quantity: int) -> None:
    address = parse_register_address(address_text)
    values = client.read_holding(address, quantity)
    print_registers(values, address)


def command_write(client: ModbusRTUClient, address_text: str, value_text: str) -> None:
    address = parse_register_address(address_text)
    value = parse_u16_value(value_text)
    client.write_single(address, value)
    print(f"Wrote {REGISTER_BY_ADDR.get(address, Register(address, '?', '?')).name} "
          f"(0x{address:04X}) = {value} / 0x{value:04X}")


def command_write_many(
    client: ModbusRTUClient, address_text: str, value_texts: list[str]
) -> None:
    address = parse_register_address(address_text)
    values = [parse_u16_value(value) for value in value_texts]
    client.write_multiple(address, values)
    print(f"Wrote {len(values)} registers from 0x{address:04X}")


def print_status(client: ModbusRTUClient) -> None:
    values = client.read_holding(0x0010, 12)
    status = u32_from_words(values[1], values[2])
    faults = u32_from_words(values[3], values[4])
    rows = [
        ["mode", f"{values[0]} ({MODES.get(values[0], 'unknown')})"],
        ["status", f"0x{status:08X} ({decode_flags(status, STATUS_FLAGS)})"],
        ["faults", f"0x{faults:08X} ({decode_flags(faults, FAULT_FLAGS)})"],
        ["power_stage_allowed", format_reg_value(0x0015, values[5])],
        ["test_running", format_reg_value(0x0016, values[6])],
        ["pwm_running", format_reg_value(0x0017, values[7])],
        ["service_output", format_reg_value(0x0018, values[8])],
        ["id_stage", format_reg_value(0x0019, values[9])],
        ["pwm_timing", format_reg_value(0x001A, values[10])],
        ["motor_control", format_reg_value(0x001B, values[11])],
    ]
    print_table(["field", "value"], rows)


def read_measurement_registers(client: ModbusRTUClient) -> list[int]:
    try:
        return client.read_holding(0x0020, 16)
    except ModbusExceptionError as exc:
        if exc.code not in (2, 3):
            raise
    try:
        return client.read_holding(0x0020, 15) + [0]
    except ModbusExceptionError as exc:
        if exc.code not in (2, 3):
            raise
    return client.read_holding(0x0020, 12) + [0, 0, 0, 0]


def read_measurement_dict(client: ModbusRTUClient) -> dict[str, object]:
    values = read_measurement_registers(client)
    meas_status = u32_from_words(values[5], values[6])
    return {
        "ia_A": to_s16(values[0]) * 0.001,
        "ib_A": to_s16(values[1]) * 0.001,
        "ic_A": to_s16(values[2]) * 0.001,
        "vdc_V": values[3] * 0.1,
        "temp_C": to_s16(values[4]) * 0.1,
        "meas_status": meas_status,
        "speed_rpm": to_s16(values[7]),
        "ia_rms_A": values[8] * 0.001,
        "ib_rms_A": values[9] * 0.001,
        "ic_rms_A": values[10] * 0.001,
        "torque_Nm": to_s16(values[11]) * 0.001,
        "ia_peak_A": values[12] * 0.001,
        "ib_peak_A": values[13] * 0.001,
        "ic_peak_A": values[14] * 0.001,
        "slip_percent": to_s16(values[15]) * 0.01,
    }


def print_measurements(client: ModbusRTUClient) -> None:
    data = read_measurement_dict(client)
    rows = [
        ["ia_A", f"{data['ia_A']:.3f}"],
        ["ib_A", f"{data['ib_A']:.3f}"],
        ["ic_A", f"{data['ic_A']:.3f}"],
        ["vdc_V", f"{data['vdc_V']:.1f}"],
        ["temp_C", f"{data['temp_C']:.1f}"],
        ["speed_rpm", data["speed_rpm"]],
        ["ia_rms_A", f"{data['ia_rms_A']:.3f}"],
        ["ib_rms_A", f"{data['ib_rms_A']:.3f}"],
        ["ic_rms_A", f"{data['ic_rms_A']:.3f}"],
        ["ia_peak_A", f"{data['ia_peak_A']:.3f}"],
        ["ib_peak_A", f"{data['ib_peak_A']:.3f}"],
        ["ic_peak_A", f"{data['ic_peak_A']:.3f}"],
        ["torque_Nm", f"{data['torque_Nm']:.3f}"],
        ["slip_percent", f"{data['slip_percent']:.2f}"],
        [
            "meas_status",
            f"0x{int(data['meas_status']):08X} "
            f"({decode_flags(int(data['meas_status']), MEAS_STATUS_FLAGS)})",
        ],
    ]
    print_table(["measurement", "value"], rows)


def read_parameter_registers(client: ModbusRTUClient) -> list[int]:
    try:
        return client.read_holding(0x0030, 18)
    except ModbusExceptionError as exc:
        if exc.code not in (2, 3):
            raise
    try:
        return client.read_holding(0x0030, 17) + [0]
    except ModbusExceptionError as exc:
        if exc.code not in (2, 3):
            raise
    return client.read_holding(0x0030, 16) + [0, 0]


def print_params(client: ModbusRTUClient) -> None:
    values = read_parameter_registers(client)
    valid = u32_from_words(values[0], values[1])
    params = {
        "Rs_ohm": values[2] * 0.001,
        "Ls_H": u32_from_words(values[3], values[4]) * 1e-6,
        "Ll_H": u32_from_words(values[5], values[6]) * 1e-6,
        "Rr_ohm": values[7] * 0.001,
        "Lr_H": u32_from_words(values[8], values[9]) * 1e-6,
        "Lm_H": u32_from_words(values[10], values[11]) * 1e-6,
        "J_kg_m2": u32_from_words(values[12], values[13]) * 1e-9,
        "B_Nm_s": u32_from_words(values[14], values[15]) * 1e-9,
        "pole_pairs": values[16],
        "phase_shunt_ohm": values[17] * 0.001,
    }
    rows = [["valid_mask", f"0x{valid:08X} ({decode_flags(valid, VALID_FLAGS)})"]]
    rows.extend([key, f"{value:.9g}"] for key, value in params.items())
    print_table(["parameter", "value"], rows)


def start_values(args: argparse.Namespace) -> tuple[int, list[int], list[int], int, int, int]:
    mode = parse_mode(args.mode)
    locked = 1 if args.locked_rotor or mode == 3 else 0
    main_block = [
        scaled_u16(args.duty, 0.0001, "duty"),
        scaled_u16(args.current_limit, 0.01, "current limit"),
        scaled_u16(args.overvoltage, 0.1, "overvoltage limit"),
        scaled_u16(args.undervoltage, 0.1, "undervoltage limit"),
        scaled_u16(args.speed_limit, 1.0, "speed limit"),
        scaled_u16(args.temp_limit, 0.1, "temperature limit"),
        scaled_u16(args.rotation_freq, 0.1, "rotation frequency"),
        scaled_u16(args.rotation_mod, 0.0001, "rotation modulation"),
        u16(args.polarity & 0x0003),
    ]
    control_block = [parse_timing(args.timing), parse_motor_type(args.motor)]
    pole_pairs = pole_pairs_u16(args.pole_pairs)
    phase_shunt = scaled_u16(args.phase_shunt_ohm, 0.001, "phase shunt")
    return mode, [locked], main_block + control_block, u16(args.rotation_ramp_ms), pole_pairs, phase_shunt


def write_optional_single(client: ModbusRTUClient, address: int, value: int) -> bool:
    try:
        client.write_single(address, value)
        return True
    except ModbusExceptionError as exc:
        if exc.code in (2, 3):
            return False
        raise


def stop_compatible(client: ModbusRTUClient) -> bool:
    client.write_single(0x0002, 0)
    return write_optional_single(client, 0x0005, 1)


def print_optional_register_warning(
    stop_supported: bool,
    reset_supported: bool,
    ramp_supported: bool,
    pole_pairs_supported: bool,
    phase_shunt_supported: bool,
) -> None:
    missing = []
    if not stop_supported:
        missing.append("0x0005 stop")
    if not reset_supported:
        missing.append("0x0004 reset")
    if not ramp_supported:
        missing.append("0x001E ramp")
    if not pole_pairs_supported:
        missing.append("0x0040 pole_pairs")
    if not phase_shunt_supported:
        missing.append("0x0041 phase_shunt")
    if missing:
        print(f"Warning: firmware skipped optional register(s): {', '.join(missing)}")


def command_start(client: ModbusRTUClient, args: argparse.Namespace) -> None:
    mode, locked_values, values, ramp_value, pole_pairs_value, phase_shunt_value = start_values(args)
    main_values = values[:9]
    tail_values = values[9:]
    stop_supported = stop_compatible(client)
    reset_supported = write_optional_single(client, 0x0004, 1)
    client.write_single(0x0006, locked_values[0])
    client.write_multiple(0x0007, main_values)
    client.write_multiple(0x001A, tail_values)
    ramp_supported = write_optional_single(client, 0x001E, ramp_value)
    pole_pairs_supported = write_optional_single(client, 0x0040, pole_pairs_value)
    phase_shunt_supported = write_optional_single(client, 0x0041, phase_shunt_value)
    client.write_single(0x0003, mode)
    client.write_single(0x0002, 1)
    print(f"Started mode {mode} ({MODES.get(mode, 'unknown')})")
    print_optional_register_warning(
        stop_supported,
        reset_supported,
        ramp_supported,
        pole_pairs_supported,
        phase_shunt_supported,
    )


def command_stop(client: ModbusRTUClient) -> None:
    stop_supported = stop_compatible(client)
    print("Stop requested")
    if not stop_supported:
        print("Warning: 0x0005 stop register is not supported; used control_enable=0")


def command_reset(client: ModbusRTUClient) -> None:
    reset_supported = write_optional_single(client, 0x0004, 1)
    print("Fault reset requested")
    if not reset_supported:
        print("Warning: 0x0004 reset register is not supported")


def command_log(client: ModbusRTUClient, args: argparse.Namespace) -> None:
    headers = [
        "host_time_s",
        "ia_A",
        "ib_A",
        "ic_A",
        "vdc_V",
        "temp_C",
        "speed_rpm",
        "ia_rms_A",
        "ib_rms_A",
        "ic_rms_A",
        "ia_peak_A",
        "ib_peak_A",
        "ic_peak_A",
        "torque_Nm",
        "slip_percent",
        "meas_status",
    ]
    csv_file = None
    writer = None
    if args.csv:
        csv_path = Path(args.csv)
        csv_file = csv_path.open("w", newline="", encoding="utf-8")
        writer = csv.DictWriter(csv_file, fieldnames=headers)
        writer.writeheader()
        print(f"Writing CSV to {csv_path}")
    else:
        print(",".join(headers))
    try:
        count = 0
        while args.count == 0 or count < args.count:
            data = read_measurement_dict(client)
            row = {
                "host_time_s": f"{time.time():.3f}",
                "ia_A": f"{float(data['ia_A']):.3f}",
                "ib_A": f"{float(data['ib_A']):.3f}",
                "ic_A": f"{float(data['ic_A']):.3f}",
                "vdc_V": f"{float(data['vdc_V']):.1f}",
                "temp_C": f"{float(data['temp_C']):.1f}",
                "speed_rpm": data["speed_rpm"],
                "ia_rms_A": f"{float(data['ia_rms_A']):.3f}",
                "ib_rms_A": f"{float(data['ib_rms_A']):.3f}",
                "ic_rms_A": f"{float(data['ic_rms_A']):.3f}",
                "ia_peak_A": f"{float(data['ia_peak_A']):.3f}",
                "ib_peak_A": f"{float(data['ib_peak_A']):.3f}",
                "ic_peak_A": f"{float(data['ic_peak_A']):.3f}",
                "torque_Nm": f"{float(data['torque_Nm']):.3f}",
                "slip_percent": f"{float(data['slip_percent']):.2f}",
                "meas_status": f"0x{int(data['meas_status']):08X}",
            }
            if writer:
                writer.writerow(row)
                csv_file.flush()
            else:
                print(",".join(str(row[key]) for key in headers))
            count += 1
            time.sleep(args.interval)
    except KeyboardInterrupt:
        print("\nLog stopped")
    finally:
        if csv_file:
            csv_file.close()


def command_regs(filter_text: str | None) -> None:
    rows = []
    needle = normalize_key(filter_text) if filter_text else None
    for reg in REGISTERS:
        haystack = f"{reg.addr} {reg.name} {reg.access} {reg.note}".lower()
        if needle and needle not in haystack:
            continue
        rows.append(
            [
                f"{reg.addr:04d}",
                f"0x{reg.addr:04X}",
                f"400{reg.addr + 1:02d}",
                reg.name,
                reg.access,
                reg.unit,
                reg.note,
            ]
        )
    print_table(["dec", "hex", "4x", "name", "rw", "unit", "note"], rows)


class ADShell(cmd.Cmd):
    intro = "AD Modbus terminal. Type help or ? to list commands."
    prompt = "ad> "

    def __init__(self, client: ModbusRTUClient) -> None:
        super().__init__()
        self.client = client

    def _run(self, func, *args) -> None:
        try:
            func(*args)
        except TerminalError as exc:
            print(f"error: {exc}")

    def do_ping(self, line: str) -> None:
        "ping: read device id and protocol version"
        self._run(command_ping, self.client)

    def do_read(self, line: str) -> None:
        "read ADDR [QTY]: read holding registers"
        args = shlex.split(line)
        if not args:
            print("usage: read ADDR [QTY]")
            return
        try:
            quantity = parse_int(args[1]) if len(args) > 1 else 1
        except TerminalError as exc:
            print(f"error: {exc}")
            return
        self._run(command_read, self.client, args[0], quantity)

    def do_write(self, line: str) -> None:
        "write ADDR VALUE: write one holding register"
        args = shlex.split(line)
        if len(args) != 2:
            print("usage: write ADDR VALUE")
            return
        self._run(command_write, self.client, args[0], args[1])

    def do_status(self, line: str) -> None:
        "status: show AD mode, flags and inverter state"
        self._run(print_status, self.client)

    def do_measure(self, line: str) -> None:
        "measure: show current measurements"
        self._run(print_measurements, self.client)

    def do_params(self, line: str) -> None:
        "params: show identified motor parameters"
        self._run(print_params, self.client)

    def do_stop(self, line: str) -> None:
        "stop: request stop"
        self._run(command_stop, self.client)

    def do_reset(self, line: str) -> None:
        "reset: reset latched AD faults"
        self._run(command_reset, self.client)

    def do_regs(self, line: str) -> None:
        "regs [FILTER]: list known holding registers"
        args = shlex.split(line)
        command_regs(args[0] if args else None)

    def do_start(self, line: str) -> None:
        "start MODE [key=value ...]: start AD test mode"
        try:
            args = parse_shell_start(line)
        except TerminalError as exc:
            print(f"error: {exc}")
            return
        self._run(command_start, self.client, args)

    def do_log(self, line: str) -> None:
        "log [INTERVAL] [COUNT]: stream measurements to stdout"
        parts = shlex.split(line)
        try:
            args = argparse.Namespace(
                interval=float(parts[0]) if len(parts) >= 1 else 0.5,
                count=int(parts[1]) if len(parts) >= 2 else 0,
                csv=None,
            )
        except ValueError as exc:
            print(f"error: {exc}")
            return
        self._run(command_log, self.client, args)

    def do_quit(self, line: str) -> bool:
        "quit: leave terminal"
        return True

    def do_exit(self, line: str) -> bool:
        "exit: leave terminal"
        return True

    def do_EOF(self, line: str) -> bool:
        print()
        return True


def parse_shell_start(line: str) -> argparse.Namespace:
    parts = shlex.split(line)
    if not parts:
        raise TerminalError("usage: start MODE [duty=0.08] [current=10] [...]")
    values = {
        "mode": parts[0],
        "duty": 0.08,
        "current_limit": 10.0,
        "overvoltage": 60.0,
        "undervoltage": 0.0,
        "speed_limit": 1000.0,
        "temp_limit": 85.0,
        "rotation_freq": 3.0,
        "rotation_mod": 0.35,
        "rotation_ramp_ms": 3000,
        "polarity": DEFAULT_POLARITY,
        "timing": "center",
        "motor": "ad",
        "pole_pairs": DEFAULT_POLE_PAIRS,
        "phase_shunt_ohm": DEFAULT_PHASE_SHUNT_OHM,
        "locked_rotor": False,
    }
    for token in parts[1:]:
        if "=" not in token:
            values["duty"] = float(token)
            continue
        key, raw_value = token.split("=", 1)
        key = normalize_key(key)
        if key in ("current", "current_limit"):
            values["current_limit"] = float(raw_value)
        elif key in ("ov", "overvoltage"):
            values["overvoltage"] = float(raw_value)
        elif key in ("uv", "undervoltage"):
            values["undervoltage"] = float(raw_value)
        elif key in ("speed", "speed_limit"):
            values["speed_limit"] = float(raw_value)
        elif key in ("temp", "temperature", "temp_limit"):
            values["temp_limit"] = float(raw_value)
        elif key in ("freq", "rotation_freq"):
            values["rotation_freq"] = float(raw_value)
        elif key in ("mod", "rotation_mod"):
            values["rotation_mod"] = float(raw_value)
        elif key in ("ramp", "ramp_ms", "rotation_ramp", "rotation_ramp_ms"):
            values["rotation_ramp_ms"] = parse_int(raw_value)
        elif key == "duty":
            values["duty"] = float(raw_value)
        elif key == "polarity":
            values["polarity"] = parse_int(raw_value)
        elif key == "timing":
            values["timing"] = raw_value
        elif key == "motor":
            values["motor"] = raw_value
        elif key in ("pole_pairs", "poles", "pairs"):
            values["pole_pairs"] = float(raw_value)
        elif key in ("phase_shunt", "phase_shunt_ohm", "rshunt", "shunt"):
            values["phase_shunt_ohm"] = float(raw_value)
        elif key in ("locked", "locked_rotor"):
            values["locked_rotor"] = raw_value.lower() in ("1", "yes", "true", "on")
        else:
            raise TerminalError(f"unknown start option: {key}")
    return argparse.Namespace(**values)


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="AD project Modbus RTU terminal (USART2/ST-LINK VCP, 115200 8N1)."
    )
    parser.add_argument("--port", help="serial port, for example COM7; use auto to probe")
    parser.add_argument("--baud", type=int, default=DEFAULT_BAUD, help="baudrate")
    parser.add_argument("--slave", type=int, default=DEFAULT_SLAVE, help="Modbus slave id")
    parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT, help="serial timeout, s")
    parser.add_argument("--debug", action="store_true", help="print raw Modbus frames")

    sub = parser.add_subparsers(dest="command")
    sub.add_parser("ports", help="list serial ports")
    regs = sub.add_parser("regs", help="list known AD holding registers")
    regs.add_argument("filter", nargs="?")
    sub.add_parser("ping", help="read device id and protocol version")
    read = sub.add_parser("read", help="read holding registers")
    read.add_argument("address")
    read.add_argument("quantity", nargs="?", type=int, default=1)
    write = sub.add_parser("write", help="write one holding register")
    write.add_argument("address")
    write.add_argument("value")
    write_many = sub.add_parser("write-many", help="write sequential holding registers")
    write_many.add_argument("address")
    write_many.add_argument("values", nargs="+")
    sub.add_parser("status", help="show AD status and faults")
    sub.add_parser("measure", help="show measurements")
    sub.add_parser("params", help="show identified motor parameters")
    sub.add_parser("stop", help="request stop")
    sub.add_parser("reset", help="reset faults")
    start = sub.add_parser("start", help="prepare limits and start mode")
    start.add_argument("mode", help="mode number/name, e.g. logging, uh, auto, rotation")
    start.add_argument("--duty", type=float, default=0.08)
    start.add_argument("--current-limit", type=float, default=10.0)
    start.add_argument("--overvoltage", type=float, default=60.0)
    start.add_argument("--undervoltage", type=float, default=0.0)
    start.add_argument("--speed-limit", type=float, default=1000.0)
    start.add_argument("--temp-limit", type=float, default=85.0)
    start.add_argument("--rotation-freq", type=float, default=3.0)
    start.add_argument("--rotation-mod", type=float, default=0.35)
    start.add_argument("--rotation-ramp-ms", type=argparse_int, default=3000)
    start.add_argument("--polarity", type=argparse_int, default=DEFAULT_POLARITY)
    start.add_argument("--timing", default="center", help="up or center")
    start.add_argument("--motor", default="ad", help="ad or bldc")
    start.add_argument("--pole-pairs", type=float, default=DEFAULT_POLE_PAIRS)
    start.add_argument("--phase-shunt-ohm", type=float, default=DEFAULT_PHASE_SHUNT_OHM)
    start.add_argument("--locked-rotor", action="store_true")
    log = sub.add_parser("log", help="stream measurements")
    log.add_argument("--interval", type=float, default=0.5)
    log.add_argument("--count", type=int, default=0, help="0 means forever")
    log.add_argument("--csv", help="write CSV file instead of stdout")
    sub.add_parser("shell", help="interactive terminal")
    return parser


def main(argv: list[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    try:
        if args.command == "ports":
            list_ports()
            return 0
        if args.command == "regs":
            command_regs(args.filter)
            return 0
        if args.command is None:
            if args.port:
                args.command = "shell"
            else:
                parser.print_help()
                return 0
        with open_client(args) as client:
            if args.command == "ping":
                command_ping(client)
            elif args.command == "read":
                command_read(client, args.address, args.quantity)
            elif args.command == "write":
                command_write(client, args.address, args.value)
            elif args.command == "write-many":
                command_write_many(client, args.address, args.values)
            elif args.command == "status":
                print_status(client)
            elif args.command == "measure":
                print_measurements(client)
            elif args.command == "params":
                print_params(client)
            elif args.command == "start":
                command_start(client, args)
            elif args.command == "stop":
                command_stop(client)
            elif args.command == "reset":
                command_reset(client)
            elif args.command == "log":
                command_log(client, args)
            elif args.command == "shell":
                ADShell(client).cmdloop()
            else:
                parser.error(f"unknown command: {args.command}")
    except TerminalError as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 2
    except KeyboardInterrupt:
        print("\nInterrupted", file=sys.stderr)
        return 130
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
