#!/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
AD

AD Modbus GUI

USART2 / ST-LINK VCP / 512000 8N1 / slave 1
H017_status_flags_lo
H019_fault_flags_lo
Dec Hex Name RW Raw Decoded
""" 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())