#!/usr/bin/env python3
"""Browser GUI for the AD Modbus RTU terminal."""
from __future__ import annotations
import argparse
import base64
import hmac
import json
import os
import threading
import time
import webbrowser
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
import ad_terminal as ad
DEFAULT_WEB_PORT = 8765
DEFAULT_AUTH_USER = "ad"
AUTH_REALM = "AD GUI"
INDEX_HTML = r"""
AD Modbus GUI
"""
class ServerState:
def __init__(self) -> None:
self.lock = threading.RLock()
self.client: ad.ModbusRTUClient | None = None
self.port: str | None = None
def disconnect(self) -> None:
with self.lock:
if self.client is not None:
self.client.__exit__(None, None, None)
self.client = None
self.port = None
def require_client(self) -> ad.ModbusRTUClient:
if self.client is None:
raise ad.TerminalError("Not connected")
return self.client
def register_payload(start: int, values: list[int]) -> list[dict[str, object]]:
rows = []
for offset, value in enumerate(values):
addr = start + offset
reg = ad.REGISTER_BY_ADDR.get(addr)
rows.append(
{
"addr": addr,
"hex": f"0x{addr:04X}",
"name": reg.name if reg else "?",
"raw": value,
"hex_value": f"0x{value:04X}",
"decoded": ad.format_reg_value(addr, value),
}
)
return rows
def status_payload(values: list[int]) -> dict[str, str]:
status = ad.u32_from_words(values[1], values[2])
faults = ad.u32_from_words(values[3], values[4])
return {
"mode": f"{values[0]} ({ad.MODES.get(values[0], 'unknown')})",
"status": f"0x{status:08X} ({ad.decode_flags(status, ad.STATUS_FLAGS)})",
"faults": f"0x{faults:08X} ({ad.decode_flags(faults, ad.FAULT_FLAGS)})",
"power_stage_allowed": ad.format_reg_value(0x0015, values[5]),
"test_running": ad.format_reg_value(0x0016, values[6]),
"pwm_running": ad.format_reg_value(0x0017, values[7]),
"service_output": ad.format_reg_value(0x0018, values[8]),
"id_stage": ad.format_reg_value(0x0019, values[9]),
"pwm_timing": ad.format_reg_value(0x001A, values[10]),
"motor_control": ad.format_reg_value(0x001B, values[11]),
}
def flag_definitions(names: dict[int, str], prefix: str, bit_count: int = 16) -> list[dict[str, object]]:
return [
{
"bit": bit,
"label": f"{prefix}_{names[bit]}" if bit in names else "reserved",
}
for bit in range(bit_count)
]
def flag_payload(value: int, names: dict[int, str], prefix: str, bit_count: int = 16) -> list[dict[str, object]]:
return [
{
"bit": bit,
"label": f"{prefix}_{names[bit]}" if bit in names else "reserved",
"active": (value & (1 << bit)) != 0,
}
for bit in range(bit_count)
]
def measurement_payload(data: dict[str, object]) -> tuple[dict[str, str], dict[str, object]]:
raw = {
"ia_A": f"{float(data['ia_A']):.6g}",
"ib_A": f"{float(data['ib_A']):.6g}",
"ic_A": f"{float(data['ic_A']):.6g}",
"vdc_V": f"{float(data['vdc_V']):.6g}",
"temp_C": f"{float(data['temp_C']):.6g}",
"speed_rpm": str(data["speed_rpm"]),
"ia_rms_A": f"{float(data['ia_rms_A']):.6g}",
"ib_rms_A": f"{float(data['ib_rms_A']):.6g}",
"ic_rms_A": f"{float(data['ic_rms_A']):.6g}",
"ia_peak_A": f"{float(data['ia_peak_A']):.6g}",
"ib_peak_A": f"{float(data['ib_peak_A']):.6g}",
"ic_peak_A": f"{float(data['ic_peak_A']):.6g}",
"torque_Nm": f"{float(data['torque_Nm']):.6g}",
"slip_percent": f"{float(data['slip_percent']):.6g}",
"meas_status": f"0x{int(data['meas_status']):08X}",
}
shown = dict(raw)
shown["meas_status"] += f" ({ad.decode_flags(int(data['meas_status']), ad.MEAS_STATUS_FLAGS)})"
return shown, raw
def params_payload(values: list[int]) -> dict[str, str]:
valid = ad.u32_from_words(values[0], values[1])
params = {
"valid_mask": f"0x{valid:08X} ({ad.decode_flags(valid, ad.VALID_FLAGS)})",
"Rs_ohm": f"{values[2] * 0.001:.9g}",
"Ls_H": f"{ad.u32_from_words(values[3], values[4]) * 1e-6:.9g}",
"Ll_H": f"{ad.u32_from_words(values[5], values[6]) * 1e-6:.9g}",
"Rr_ohm": f"{values[7] * 0.001:.9g}",
"Lr_H": f"{ad.u32_from_words(values[8], values[9]) * 1e-6:.9g}",
"Lm_H": f"{ad.u32_from_words(values[10], values[11]) * 1e-6:.9g}",
"J_kg_m2": f"{ad.u32_from_words(values[12], values[13]) * 1e-9:.9g}",
"B_Nm_s": f"{ad.u32_from_words(values[14], values[15]) * 1e-9:.9g}",
"pole_pairs": str(values[16]),
"phase_shunt_ohm": f"{values[17] * 0.001:.9g}",
}
return params
def try_write_optional_ramp(client: ad.ModbusRTUClient, ramp_value: int) -> bool:
try:
client.write_single(0x001E, ramp_value)
return True
except ad.ModbusExceptionError as exc:
if exc.code in (2, 3):
return False
raise
def try_write_optional_single(client: ad.ModbusRTUClient, address: int, value: int) -> bool:
try:
client.write_single(address, value)
return True
except ad.ModbusExceptionError as exc:
if exc.code in (2, 3):
return False
raise
def stop_compatible(client: ad.ModbusRTUClient) -> bool:
client.write_single(0x0002, 0)
return try_write_optional_single(client, 0x0005, 1)
def optional_register_warning(
stop_supported: bool,
reset_supported: bool,
ramp_supported: bool,
pole_pairs_supported: bool,
phase_shunt_supported: bool,
) -> str | 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 not missing:
return None
return f"Firmware skipped optional register(s): {', '.join(missing)}"
def parse_json_body(handler: BaseHTTPRequestHandler) -> dict[str, object]:
size = int(handler.headers.get("Content-Length", "0"))
if size <= 0:
return {}
data = handler.rfile.read(size)
return json.loads(data.decode("utf-8"))
def parse_basic_auth(value: str | None) -> tuple[str, str] | None:
if not value or not value.startswith("Basic "):
return None
try:
decoded = base64.b64decode(value[6:].strip(), validate=True).decode("utf-8")
except (ValueError, UnicodeDecodeError):
return None
if ":" not in decoded:
return None
user, password = decoded.split(":", 1)
return user, password
def make_handler(state: ServerState, auth_user: str | None = None, auth_password: str | None = None):
class Handler(BaseHTTPRequestHandler):
server_version = "ADGui/1.0"
def log_message(self, format: str, *args) -> None:
return
def auth_enabled(self) -> bool:
return bool(auth_password)
def is_authorized(self) -> bool:
if not auth_password:
return True
credentials = parse_basic_auth(self.headers.get("Authorization"))
if credentials is None:
return False
user, password = credentials
expected_user = auth_user or DEFAULT_AUTH_USER
return hmac.compare_digest(user, expected_user) and hmac.compare_digest(password, auth_password)
def require_auth(self) -> bool:
if self.is_authorized():
return True
body = b"Authentication required\n"
self.send_response(HTTPStatus.UNAUTHORIZED)
self.send_header("WWW-Authenticate", f'Basic realm="{AUTH_REALM}", charset="UTF-8"')
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return False
def send_json(self, payload: dict[str, object], status: int = 200) -> None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def send_error_json(self, exc: Exception, status: int = 400) -> None:
self.send_json({"ok": False, "error": str(exc)}, status)
def do_GET(self) -> None:
try:
if not self.require_auth():
return
parsed = urlparse(self.path)
if parsed.path == "/":
body = INDEX_HTML.encode("utf-8")
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
elif parsed.path == "/api/registry":
self.send_json(
{
"ok": True,
"modes": {str(key): value for key, value in ad.MODES.items()},
"status_flags": flag_definitions(ad.STATUS_FLAGS, "AD_PARAM_ID_STATUS"),
"fault_flags": flag_definitions(ad.FAULT_FLAGS, "AD_PARAM_ID_FAULT"),
"registers": [
{
"addr": reg.addr,
"hex": f"0x{reg.addr:04X}",
"name": reg.name,
"access": reg.access,
"unit": reg.unit,
"note": reg.note,
}
for reg in ad.REGISTERS
],
}
)
elif parsed.path == "/api/ports":
serial = ad.require_serial_module()
from serial.tools import list_ports # type: ignore[import-not-found]
ports = [
{
"device": port.device,
"description": port.description,
"hwid": port.hwid,
}
for port in list_ports.comports()
]
self.send_json({"ok": True, "ports": ports})
elif parsed.path == "/api/state":
with state.lock:
self.send_json({"ok": True, "connected": state.client is not None, "port": state.port})
elif parsed.path == "/api/ping":
with state.lock:
client = state.require_client()
values = client.read_holding(0, 2)
if values[0] != ad.DEVICE_ID:
raise ad.TerminalError(f"Unexpected device id: 0x{values[0]:04X}")
self.send_json(
{
"ok": True,
"device_id": f"0x{values[0]:04X}",
"protocol": values[1],
"registers": register_payload(0, values),
}
)
elif parsed.path == "/api/status":
with state.lock:
values = state.require_client().read_holding(0x0010, 12)
status_value = ad.u32_from_words(values[1], values[2])
fault_value = ad.u32_from_words(values[3], values[4])
self.send_json(
{
"ok": True,
"status": status_payload(values),
"status_leds": flag_payload(
status_value, ad.STATUS_FLAGS, "AD_PARAM_ID_STATUS"
),
"fault_leds": flag_payload(
fault_value, ad.FAULT_FLAGS, "AD_PARAM_ID_FAULT"
),
"registers": register_payload(0x0010, values),
}
)
elif parsed.path == "/api/measure":
with state.lock:
client = state.require_client()
values = ad.read_measurement_registers(client)
data = {
"ia_A": ad.to_s16(values[0]) * 0.001,
"ib_A": ad.to_s16(values[1]) * 0.001,
"ic_A": ad.to_s16(values[2]) * 0.001,
"vdc_V": values[3] * 0.1,
"temp_C": ad.to_s16(values[4]) * 0.1,
"meas_status": ad.u32_from_words(values[5], values[6]),
"speed_rpm": ad.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": ad.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": ad.to_s16(values[15]) * 0.01,
}
shown, raw = measurement_payload(data)
self.send_json(
{
"ok": True,
"measurements": shown,
"raw": raw,
"registers": register_payload(0x0020, values),
}
)
elif parsed.path == "/api/params":
with state.lock:
values = ad.read_parameter_registers(state.require_client())
self.send_json(
{
"ok": True,
"params": params_payload(values),
"registers": register_payload(0x0030, values),
}
)
elif parsed.path == "/api/read-groups":
rows = []
with state.lock:
client = state.require_client()
for start, qty in [(0x0000, 16), (0x0010, 12)]:
rows.extend(register_payload(start, client.read_holding(start, qty)))
rows.extend(register_payload(0x0020, ad.read_measurement_registers(client)))
rows.extend(register_payload(0x0030, ad.read_parameter_registers(client)))
self.send_json({"ok": True, "registers": rows})
else:
self.send_error_json(Exception("Not found"), HTTPStatus.NOT_FOUND)
except Exception as exc:
self.send_error_json(exc)
def do_POST(self) -> None:
try:
if not self.require_auth():
return
parsed = urlparse(self.path)
payload = parse_json_body(self)
if parsed.path == "/api/connect":
port = str(payload.get("port", "")).strip()
if not port:
raise ad.TerminalError("Port is required")
baud = int(payload.get("baud", ad.DEFAULT_BAUD))
slave = int(payload.get("slave", ad.DEFAULT_SLAVE))
timeout = float(payload.get("timeout", ad.DEFAULT_TIMEOUT))
client = ad.ModbusRTUClient(port, baudrate=baud, slave=slave, timeout=timeout)
client.__enter__()
try:
values = client.read_holding(0, 2)
if values[0] != ad.DEVICE_ID:
raise ad.TerminalError(f"Unexpected device id: 0x{values[0]:04X}")
except Exception:
client.__exit__(None, None, None)
raise
with state.lock:
state.disconnect()
state.client = client
state.port = port
self.send_json({"ok": True, "port": port})
elif parsed.path == "/api/disconnect":
state.disconnect()
self.send_json({"ok": True})
elif parsed.path == "/api/read":
address = ad.parse_register_address(str(payload.get("address", "0")))
quantity = int(payload.get("quantity", 1))
with state.lock:
values = state.require_client().read_holding(address, quantity)
self.send_json({"ok": True, "registers": register_payload(address, values)})
elif parsed.path == "/api/write":
address = ad.parse_register_address(str(payload.get("address", "0")))
value = ad.parse_u16_value(str(payload.get("value", "0")))
with state.lock:
client = state.require_client()
client.write_single(address, value)
self.send_json(
{
"ok": True,
"address": address,
"address_hex": f"0x{address:04X}",
"value": value,
"registers": register_payload(address, [value]),
}
)
elif parsed.path == "/api/start":
mode_text = str(payload.get("mode", "logging")).split()[0]
args = argparse.Namespace(
mode=mode_text,
duty=float(payload.get("duty", 0.08)),
current_limit=float(payload.get("current_limit", 10.0)),
overvoltage=float(payload.get("overvoltage", 60.0)),
undervoltage=float(payload.get("undervoltage", 0.0)),
speed_limit=float(payload.get("speed_limit", 1000.0)),
temp_limit=float(payload.get("temp_limit", 85.0)),
rotation_freq=float(payload.get("rotation_freq", 3.0)),
rotation_mod=float(payload.get("rotation_mod", 0.35)),
rotation_ramp_ms=int(payload.get("rotation_ramp_ms", 3000)),
polarity=int(payload.get("polarity", ad.DEFAULT_POLARITY)),
timing=str(payload.get("timing", "center")),
motor=str(payload.get("motor", "ad")),
pole_pairs=float(payload.get("pole_pairs", ad.DEFAULT_POLE_PAIRS)),
phase_shunt_ohm=float(payload.get("phase_shunt_ohm", ad.DEFAULT_PHASE_SHUNT_OHM)),
locked_rotor=bool(payload.get("locked_rotor", False)),
)
mode, locked_values, values, ramp_value, pole_pairs_value, phase_shunt_value = ad.start_values(args)
with state.lock:
client = state.require_client()
stop_supported = stop_compatible(client)
reset_supported = try_write_optional_single(client, 0x0004, 1)
client.write_single(0x0006, locked_values[0])
client.write_multiple(0x0007, values[:9])
client.write_multiple(0x001A, values[9:])
ramp_supported = try_write_optional_ramp(client, ramp_value)
pole_pairs_supported = try_write_optional_single(client, 0x0040, pole_pairs_value)
phase_shunt_supported = try_write_optional_single(client, 0x0041, phase_shunt_value)
client.write_single(0x0003, mode)
client.write_single(0x0002, 1)
self.send_json(
{
"ok": True,
"mode": mode,
"mode_name": ad.MODES.get(mode, "unknown"),
"ramp_supported": ramp_supported,
"warning": optional_register_warning(
stop_supported,
reset_supported,
ramp_supported,
pole_pairs_supported,
phase_shunt_supported,
),
}
)
elif parsed.path == "/api/apply-run-control":
mode_text = str(payload.get("mode", "logging")).split()[0]
args = argparse.Namespace(
mode=mode_text,
duty=float(payload.get("duty", 0.08)),
current_limit=float(payload.get("current_limit", 10.0)),
overvoltage=float(payload.get("overvoltage", 60.0)),
undervoltage=float(payload.get("undervoltage", 0.0)),
speed_limit=float(payload.get("speed_limit", 1000.0)),
temp_limit=float(payload.get("temp_limit", 85.0)),
rotation_freq=float(payload.get("rotation_freq", 3.0)),
rotation_mod=float(payload.get("rotation_mod", 0.35)),
rotation_ramp_ms=int(payload.get("rotation_ramp_ms", 3000)),
polarity=int(payload.get("polarity", ad.DEFAULT_POLARITY)),
timing=str(payload.get("timing", "center")),
motor=str(payload.get("motor", "ad")),
pole_pairs=float(payload.get("pole_pairs", ad.DEFAULT_POLE_PAIRS)),
phase_shunt_ohm=float(payload.get("phase_shunt_ohm", ad.DEFAULT_PHASE_SHUNT_OHM)),
locked_rotor=bool(payload.get("locked_rotor", False)),
)
mode, locked_values, values, ramp_value, pole_pairs_value, phase_shunt_value = ad.start_values(args)
with state.lock:
client = state.require_client()
client.write_single(0x0006, locked_values[0])
client.write_multiple(0x0007, values[:9])
client.write_multiple(0x001A, values[9:])
ramp_supported = try_write_optional_ramp(client, ramp_value)
pole_pairs_supported = try_write_optional_single(client, 0x0040, pole_pairs_value)
phase_shunt_supported = try_write_optional_single(client, 0x0041, phase_shunt_value)
client.write_single(0x0003, mode)
registers = []
registers.extend(register_payload(0x0003, [mode]))
registers.extend(register_payload(0x0006, locked_values))
registers.extend(register_payload(0x0007, values[:9]))
registers.extend(register_payload(0x001A, values[9:]))
if ramp_supported:
registers.extend(register_payload(0x001E, [ramp_value]))
if pole_pairs_supported:
registers.extend(register_payload(0x0040, [pole_pairs_value]))
if phase_shunt_supported:
registers.extend(register_payload(0x0041, [phase_shunt_value]))
self.send_json(
{
"ok": True,
"mode": mode,
"mode_name": ad.MODES.get(mode, "unknown"),
"ramp_supported": ramp_supported,
"warning": optional_register_warning(
True,
True,
ramp_supported,
pole_pairs_supported,
phase_shunt_supported,
),
"registers": registers,
}
)
elif parsed.path == "/api/stop":
with state.lock:
stop_supported = stop_compatible(state.require_client())
self.send_json(
{
"ok": True,
"stop_supported": stop_supported,
"registers": register_payload(0x0002, [0]),
"warning": None if stop_supported else "0x0005 stop register is not supported by firmware; used control_enable=0",
}
)
elif parsed.path == "/api/reset":
with state.lock:
reset_supported = try_write_optional_single(state.require_client(), 0x0004, 1)
self.send_json(
{
"ok": True,
"reset_supported": reset_supported,
"warning": None if reset_supported else "0x0004 reset register is not supported by firmware",
}
)
elif parsed.path == "/api/reset-current-peaks":
with state.lock:
reset_supported = try_write_optional_single(state.require_client(), 0x001F, 1)
self.send_json(
{
"ok": True,
"reset_supported": reset_supported,
"warning": None if reset_supported else "0x001F current peak reset register is not supported by firmware",
}
)
else:
self.send_error_json(Exception("Not found"), HTTPStatus.NOT_FOUND)
except Exception as exc:
self.send_error_json(exc)
return Handler
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="AD project browser GUI.")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=DEFAULT_WEB_PORT)
parser.add_argument("--no-open", action="store_true", help="do not open the browser")
parser.add_argument("--auth-user", default=os.environ.get("AD_GUI_USER", DEFAULT_AUTH_USER))
parser.add_argument("--password", default=os.environ.get("AD_GUI_PASSWORD"), help="enable HTTP Basic auth")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
state = ServerState()
server = ThreadingHTTPServer((args.host, args.port), make_handler(state, args.auth_user, args.password))
host, port = server.server_address
url = f"http://{host}:{port}/"
print(f"AD GUI running at {url}")
if args.password:
print(f"Authentication enabled. User: {args.auth_user}")
print("Press Ctrl+C to stop.")
if not args.no_open:
webbrowser.open(url)
try:
server.serve_forever()
except KeyboardInterrupt:
print()
finally:
state.disconnect()
server.server_close()
return 0
if __name__ == "__main__":
raise SystemExit(main())