запущен проект motor identification c терминалкой
This commit is contained in:
55
AD_Keil_Project/tools/README_AD_GUI.md
Normal file
55
AD_Keil_Project/tools/README_AD_GUI.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# AD Modbus GUI
|
||||
|
||||
This is a local browser GUI for the AD Keil project Modbus RTU interface.
|
||||
|
||||
## Install serial dependency
|
||||
|
||||
```powershell
|
||||
python -m pip install -r AD_Keil_Project\tools\requirements-ad-terminal.txt
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```powershell
|
||||
python -B AD_Keil_Project\tools\ad_gui.py
|
||||
```
|
||||
|
||||
Or double-click:
|
||||
|
||||
```text
|
||||
run_ad_gui.bat
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```text
|
||||
AD_Keil_Project\tools\run_ad_gui.bat
|
||||
```
|
||||
|
||||
For remote access from another PC, use:
|
||||
|
||||
```text
|
||||
run_ad_gui_remote.bat
|
||||
```
|
||||
|
||||
Details:
|
||||
|
||||
```text
|
||||
AD_Keil_Project\tools\README_AD_REMOTE_GUI.md
|
||||
```
|
||||
|
||||
Default URL:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8765/
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The GUI uses the same Modbus code as `ad_terminal.py`.
|
||||
- The default connection is ST-LINK VCP `COM31`, `512000 8N1`, slave `1`.
|
||||
- If port listing fails, install `pyserial` using the command above.
|
||||
- The `Status` tab shows `AD_PARAM_ID_STATUS_*` and `AD_PARAM_ID_FAULT_*` bits as vertical square LED lists below the main status fields.
|
||||
- `Run Control -> Apply now` writes changed control parameters, including `Pole pairs` and `Shunt ohm`, to Modbus registers without pressing `Start`.
|
||||
- `Measurements -> Poll measurements` continuously reads measurement registers at the selected interval.
|
||||
- CSV logging is generated in the browser and downloaded when you press `CSV stop`.
|
||||
92
AD_Keil_Project/tools/README_AD_REMOTE_GUI.md
Normal file
92
AD_Keil_Project/tools/README_AD_REMOTE_GUI.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# AD GUI Remote Access
|
||||
|
||||
This starts the AD browser GUI so another computer on the same LAN/VPN can open it.
|
||||
|
||||
## Start
|
||||
|
||||
Double-click from the project root:
|
||||
|
||||
```text
|
||||
run_ad_gui_remote.bat
|
||||
```
|
||||
|
||||
or run:
|
||||
|
||||
```powershell
|
||||
AD_Keil_Project\tools\run_ad_gui_remote.bat
|
||||
```
|
||||
|
||||
The bat asks for a password before the server starts. Remote users enter:
|
||||
|
||||
```text
|
||||
User: ad
|
||||
Password: the password you entered at startup
|
||||
```
|
||||
|
||||
To start without the password prompt, set it in the same terminal first:
|
||||
|
||||
```powershell
|
||||
$env:AD_GUI_PASSWORD = "your-password"
|
||||
AD_Keil_Project\tools\run_ad_gui_remote.bat
|
||||
```
|
||||
|
||||
To use another login name:
|
||||
|
||||
```powershell
|
||||
$env:AD_GUI_USER = "operator"
|
||||
$env:AD_GUI_PASSWORD = "your-password"
|
||||
AD_Keil_Project\tools\run_ad_gui_remote.bat
|
||||
```
|
||||
|
||||
The remote server listens on:
|
||||
|
||||
```text
|
||||
0.0.0.0:8765
|
||||
```
|
||||
|
||||
The bat prints URLs like:
|
||||
|
||||
```text
|
||||
http://192.168.1.50:8765/
|
||||
```
|
||||
|
||||
Open that URL from the remote PC.
|
||||
|
||||
## Requirements
|
||||
|
||||
- The PC running the bat must be connected to the AD board COM port.
|
||||
- The remote PC must be on the same LAN or VPN.
|
||||
- Install serial support once if needed:
|
||||
|
||||
```powershell
|
||||
python -m pip install -r AD_Keil_Project\tools\requirements-ad-terminal.txt
|
||||
```
|
||||
|
||||
## Windows Firewall
|
||||
|
||||
If the remote PC cannot open the page, allow inbound TCP port `8765` on the PC running the server.
|
||||
|
||||
Run PowerShell as Administrator:
|
||||
|
||||
```powershell
|
||||
New-NetFirewallRule -DisplayName "AD GUI Remote 8765" -Direction Inbound -Protocol TCP -LocalPort 8765 -Action Allow
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
Remote access uses HTTP Basic authentication when `AD_GUI_PASSWORD` is set. Use it only on a trusted local network or VPN. Do not expose port `8765` to the internet because HTTP Basic authentication does not encrypt the password without HTTPS.
|
||||
|
||||
For local-only password protection, set `AD_GUI_PASSWORD` before running `run_ad_gui.bat`:
|
||||
|
||||
```powershell
|
||||
$env:AD_GUI_PASSWORD = "your-password"
|
||||
run_ad_gui.bat
|
||||
```
|
||||
|
||||
## Stop
|
||||
|
||||
Close the terminal window or press:
|
||||
|
||||
```text
|
||||
Ctrl+C
|
||||
```
|
||||
1679
AD_Keil_Project/tools/ad_gui.py
Normal file
1679
AD_Keil_Project/tools/ad_gui.py
Normal file
@@ -0,0 +1,1679 @@
|
||||
#!/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"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AD Modbus GUI</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f7fa;
|
||||
--surface: #ffffff;
|
||||
--line: #d8dee8;
|
||||
--text: #17202a;
|
||||
--muted: #617084;
|
||||
--blue: #1f6feb;
|
||||
--blue-dark: #174ea6;
|
||||
--green: #16833a;
|
||||
--red: #b42318;
|
||||
--amber: #946200;
|
||||
--code: #0b253a;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
header {
|
||||
height: 58px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 0 18px;
|
||||
background: #102033;
|
||||
color: #fff;
|
||||
border-bottom: 1px solid #07111d;
|
||||
}
|
||||
.mark {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 6px;
|
||||
background: #2d9cdb;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 650;
|
||||
}
|
||||
.subhead {
|
||||
color: #c6d0dd;
|
||||
font-size: 13px;
|
||||
}
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: 320px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
min-height: calc(100vh - 58px);
|
||||
}
|
||||
aside, main, .panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
aside {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
main {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) 150px;
|
||||
}
|
||||
.panel {
|
||||
padding: 12px;
|
||||
}
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 0 0 10px;
|
||||
color: var(--code);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
input, select {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid #bfccd9;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
input[type="checkbox"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 14px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.button-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
button {
|
||||
min-height: 34px;
|
||||
border: 1px solid #b8c4d2;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { background: #eef3f8; }
|
||||
button.primary {
|
||||
background: var(--blue);
|
||||
border-color: var(--blue);
|
||||
color: #fff;
|
||||
}
|
||||
button.primary:hover { background: var(--blue-dark); }
|
||||
button.danger {
|
||||
color: #fff;
|
||||
background: var(--red);
|
||||
border-color: var(--red);
|
||||
}
|
||||
button.good {
|
||||
color: #fff;
|
||||
background: var(--green);
|
||||
border-color: var(--green);
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
.status-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 22px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #8a95a3;
|
||||
}
|
||||
.dot.on { background: var(--green); }
|
||||
.dot.err { background: var(--red); }
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 10px 10px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.tab {
|
||||
padding: 9px 14px;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: 0;
|
||||
border-radius: 7px 7px 0 0;
|
||||
background: transparent;
|
||||
min-height: 38px;
|
||||
}
|
||||
.tab.active {
|
||||
background: #fff;
|
||||
border-color: var(--line);
|
||||
color: var(--blue-dark);
|
||||
font-weight: 650;
|
||||
}
|
||||
.view {
|
||||
display: none;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
.view.active { display: block; }
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(260px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.metric {
|
||||
border: 1px solid #e0e6ee;
|
||||
border-radius: 7px;
|
||||
padding: 10px;
|
||||
min-height: 62px;
|
||||
background: #fff;
|
||||
}
|
||||
.metric .name {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.metric .value {
|
||||
font-family: Consolas, "Cascadia Mono", monospace;
|
||||
font-size: 15px;
|
||||
color: var(--code);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.led-layout {
|
||||
display: grid;
|
||||
grid-template-columns: max-content max-content;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
align-items: start;
|
||||
}
|
||||
.led-panel {
|
||||
border: 1px solid #e0e6ee;
|
||||
border-radius: 7px;
|
||||
padding: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
.led-title {
|
||||
margin-bottom: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.led-grid {
|
||||
display: grid;
|
||||
grid-template-columns: max-content;
|
||||
grid-auto-rows: 18px;
|
||||
gap: 1px;
|
||||
}
|
||||
.led-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-height: 18px;
|
||||
color: var(--code);
|
||||
font-family: Consolas, "Cascadia Mono", monospace;
|
||||
font-size: 11px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.led-square {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
flex: 0 0 13px;
|
||||
border: 1px solid #b7c3d1;
|
||||
border-radius: 3px;
|
||||
background: #e8edf3;
|
||||
box-shadow: inset 0 0 0 2px rgba(255,255,255,0.55);
|
||||
}
|
||||
.led-square.on {
|
||||
border-color: #0f6b32;
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 0 2px rgba(22,131,58,0.16), inset 0 0 0 2px rgba(255,255,255,0.24);
|
||||
}
|
||||
.led-panel.fault .led-square.on {
|
||||
border-color: #8f1c13;
|
||||
background: var(--red);
|
||||
box-shadow: 0 0 0 2px rgba(180,35,24,0.16), inset 0 0 0 2px rgba(255,255,255,0.2);
|
||||
}
|
||||
.led-bit {
|
||||
color: var(--muted);
|
||||
min-width: 32px;
|
||||
font-family: Consolas, "Cascadia Mono", monospace;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
font-size: 13px;
|
||||
}
|
||||
th, td {
|
||||
border-bottom: 1px solid #e2e7ee;
|
||||
padding: 7px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #f8fafc;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
z-index: 1;
|
||||
}
|
||||
tr.selected { background: #e9f2ff; }
|
||||
.register-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: 110px 70px 110px 110px minmax(0, 1fr) 110px 110px 110px;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
.log {
|
||||
height: 100%;
|
||||
min-height: 120px;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
background: #0f1d2b;
|
||||
color: #dbe7f3;
|
||||
font-family: Consolas, "Cascadia Mono", monospace;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.wide { grid-column: 1 / -1; }
|
||||
.run-toggles {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.check-pill {
|
||||
min-height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #b8c4d2;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
color: var(--text);
|
||||
font-size: 11px;
|
||||
line-height: 1.15;
|
||||
cursor: pointer;
|
||||
}
|
||||
.check-pill:hover { background: #eef3f8; }
|
||||
.quick {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.small-note {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
min-height: 18px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.shell { grid-template-columns: 1fr; }
|
||||
.kv { grid-template-columns: 1fr; }
|
||||
.led-layout { grid-template-columns: 1fr; }
|
||||
.register-toolbar { grid-template-columns: 1fr 1fr; }
|
||||
.run-toggles { grid-template-columns: 1fr; }
|
||||
main { grid-template-rows: auto minmax(420px, 1fr) 150px; }
|
||||
}
|
||||
.view-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.view-toolbar input[type="text"] {
|
||||
width: 80px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="mark">AD</div>
|
||||
<div>
|
||||
<h1>AD Modbus GUI</h1>
|
||||
<div class="subhead">USART2 / ST-LINK VCP / 512000 8N1 / slave 1</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="shell">
|
||||
<aside>
|
||||
<section class="panel">
|
||||
<div class="section-title">Connection</div>
|
||||
<div class="grid">
|
||||
<div class="field wide">
|
||||
<label for="port">Port</label>
|
||||
<input id="port" list="ports" value="COM31" placeholder="COM31">
|
||||
<datalist id="ports"></datalist>
|
||||
</div>
|
||||
<div class="field"><label for="baud">Baud</label><input id="baud" value="512000"></div>
|
||||
<div class="field"><label for="slave">Slave</label><input id="slave" value="1"></div>
|
||||
<div class="field"><label for="timeout">Timeout s</label><input id="timeout" value="0.5"></div>
|
||||
<button id="refreshPorts">Ports</button>
|
||||
<button id="connect" class="primary">Connect</button>
|
||||
<button id="disconnect">Disconnect</button>
|
||||
<button id="ping">Ping</button>
|
||||
</div>
|
||||
<div class="status-line" style="margin-top:10px">
|
||||
<span id="connDot" class="dot"></span>
|
||||
<span id="connText">Disconnected</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">Run Control</div>
|
||||
<div class="grid">
|
||||
<div class="field wide"><label for="mode">Mode</label><select id="mode"></select></div>
|
||||
<div class="field"><label for="duty">Duty</label><input id="duty" value="0.08"></div>
|
||||
<div class="field"><label for="current">Current A</label><input id="current" value="10.0"></div>
|
||||
<div class="field"><label for="overvoltage">Overvolt V</label><input id="overvoltage" value="60.0"></div>
|
||||
<div class="field"><label for="undervoltage">Undervolt V</label><input id="undervoltage" value="0.0"></div>
|
||||
<div class="field"><label for="speed">Speed rpm</label><input id="speed" value="1000"></div>
|
||||
<div class="field"><label for="temp">Temp C</label><input id="temp" value="85.0"></div>
|
||||
<div class="field"><label for="rotFreq">Rot freq Hz</label><input id="rotFreq" value="3.0"></div>
|
||||
<div class="field"><label for="rotMod">Rot mod</label><input id="rotMod" value="0.35"></div>
|
||||
<div class="field"><label for="rotRamp">Ramp ms</label><input id="rotRamp" value="3000"></div>
|
||||
<div class="field"><label for="polarity">Polarity</label><input id="polarity" value="1"></div>
|
||||
<div class="field"><label for="timing">Timing</label><select id="timing"><option>center</option><option>up</option></select></div>
|
||||
<div class="field"><label for="motor">Motor</label><select id="motor"><option>ad</option><option>bldc</option></select></div>
|
||||
<div class="field"><label for="polePairs">Pole pairs</label><input id="polePairs" value="1"></div>
|
||||
<div class="field"><label for="phaseShunt">Shunt ohm</label><input id="phaseShunt" value="0.1"></div>
|
||||
<div class="run-toggles">
|
||||
<label class="check-pill"><input id="locked" type="checkbox"> Locked rotor</label>
|
||||
<label class="check-pill"><input id="applyNow" type="checkbox"> Apply now</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-row" style="margin-top:10px">
|
||||
<button id="start" class="good">Start</button>
|
||||
<button id="stop" class="danger">Stop</button>
|
||||
<button id="reset">Reset faults</button>
|
||||
<button id="refreshAll">Refresh</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">Quick Modes</div>
|
||||
<div class="quick" id="quickModes"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">Auto</div>
|
||||
<div class="grid">
|
||||
<label class="wide"><input id="auto" type="checkbox"> Auto refresh</label>
|
||||
<div class="field"><label for="interval">Interval ms</label><input id="interval" value="500"></div>
|
||||
<button id="csvStart">CSV start</button>
|
||||
<button id="csvStop">CSV stop</button>
|
||||
</div>
|
||||
<div id="csvNote" class="small-note"></div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<nav class="tabs" id="tabs">
|
||||
<button class="tab active" data-tab="statusView">Status</button>
|
||||
<button class="tab" data-tab="measureView">Measurements</button>
|
||||
<button class="tab" data-tab="paramsView">Parameters</button>
|
||||
<button class="tab" data-tab="registerView">Registers</button>
|
||||
</nav>
|
||||
|
||||
<section class="view active" id="statusView">
|
||||
<div class="kv" id="statusGrid"></div>
|
||||
<div class="led-layout">
|
||||
<div class="led-panel">
|
||||
<div class="led-title">H017_status_flags_lo</div>
|
||||
<div class="led-grid" id="statusLedGrid"></div>
|
||||
</div>
|
||||
<div class="led-panel fault">
|
||||
<div class="led-title">H019_fault_flags_lo</div>
|
||||
<div class="led-grid" id="faultLedGrid"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="view" id="measureView">
|
||||
<div class="view-toolbar">
|
||||
<label><input id="measurePoll" type="checkbox"> Poll measurements</label>
|
||||
<label><input id="resetCurrentPeaks" type="checkbox"> Reset current peaks</label>
|
||||
<label for="measureInterval">Interval ms</label>
|
||||
<input id="measureInterval" type="text" value="200">
|
||||
<button id="measureRefresh">Read now</button>
|
||||
</div>
|
||||
<div class="kv" id="measureGrid"></div>
|
||||
</section>
|
||||
<section class="view" id="paramsView"><div class="kv" id="paramsGrid"></div></section>
|
||||
<section class="view" id="registerView">
|
||||
<div class="view-toolbar">
|
||||
<label><input id="registerPoll" type="checkbox"> Poll registers</label>
|
||||
<label for="registerInterval">Interval ms</label>
|
||||
<input id="registerInterval" type="text" value="500">
|
||||
<button id="registerRefresh">Read now</button>
|
||||
</div>
|
||||
<div class="register-toolbar">
|
||||
<div class="field"><label for="readAddr">Read addr</label><input id="readAddr" value="0"></div>
|
||||
<div class="field"><label for="readQty">Qty</label><input id="readQty" value="2"></div>
|
||||
<button id="readRange">Read range</button>
|
||||
<button id="readGroups">Read groups</button>
|
||||
<div></div>
|
||||
<div class="field"><label for="writeAddr">Write addr</label><input id="writeAddr" value="duty"></div>
|
||||
<div class="field"><label for="writeValue">Value</label><input id="writeValue" value="800"></div>
|
||||
<button id="writeOne">Write one</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:70px">Dec</th>
|
||||
<th style="width:90px">Hex</th>
|
||||
<th style="width:230px">Name</th>
|
||||
<th style="width:70px">RW</th>
|
||||
<th style="width:130px">Raw</th>
|
||||
<th>Decoded</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="registerRows"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="log" id="log"></section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const state = {
|
||||
connected: false,
|
||||
registers: [],
|
||||
registerRows: new Map(),
|
||||
autoTimer: null,
|
||||
measureTimer: null,
|
||||
registerTimer: null,
|
||||
applyTimer: null,
|
||||
csvRows: [],
|
||||
csvLogging: false,
|
||||
};
|
||||
|
||||
function log(message, kind = "info") {
|
||||
const ts = new Date().toLocaleTimeString();
|
||||
$("log").textContent += `[${ts}] ${kind.toUpperCase()}: ${message}\n`;
|
||||
$("log").scrollTop = $("log").scrollHeight;
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(path, {
|
||||
headers: {"Content-Type": "application/json"},
|
||||
...options,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.ok) {
|
||||
throw new Error(data.error || response.statusText);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function setConnected(connected, text) {
|
||||
state.connected = connected;
|
||||
$("connText").textContent = text || (connected ? "Connected" : "Disconnected");
|
||||
$("connDot").classList.toggle("on", connected);
|
||||
$("connDot").classList.toggle("err", !connected && text && text.includes("Error"));
|
||||
}
|
||||
|
||||
function metric(parent, name) {
|
||||
const node = document.createElement("div");
|
||||
node.className = "metric";
|
||||
node.innerHTML = `<div class="name"></div><div class="value">-</div>`;
|
||||
node.querySelector(".name").textContent = name;
|
||||
parent.appendChild(node);
|
||||
return node.querySelector(".value");
|
||||
}
|
||||
|
||||
const statusFields = {};
|
||||
const measureFields = {};
|
||||
const paramFields = {};
|
||||
const statusLeds = {};
|
||||
const faultLeds = {};
|
||||
|
||||
function buildMetrics() {
|
||||
["mode","status","faults","power_stage_allowed","test_running","pwm_running","service_output","id_stage","pwm_timing","motor_control"]
|
||||
.forEach(name => statusFields[name] = metric($("statusGrid"), name));
|
||||
["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"]
|
||||
.forEach(name => measureFields[name] = metric($("measureGrid"), name));
|
||||
["valid_mask","Rs_ohm","Ls_H","Ll_H","Rr_ohm","Lr_H","Lm_H","J_kg_m2","B_Nm_s","pole_pairs","phase_shunt_ohm"]
|
||||
.forEach(name => paramFields[name] = metric($("paramsGrid"), name));
|
||||
}
|
||||
|
||||
function buildLedGrid(parent, flags, store) {
|
||||
parent.innerHTML = "";
|
||||
Object.keys(store).forEach(key => delete store[key]);
|
||||
flags.forEach(flag => {
|
||||
const node = document.createElement("div");
|
||||
node.className = "led-item";
|
||||
node.innerHTML = `<span class="led-square"></span><span class="led-bit">bit ${flag.bit}</span><span class="led-label"></span>`;
|
||||
node.querySelector(".led-label").textContent = flag.label;
|
||||
parent.appendChild(node);
|
||||
store[flag.bit] = {
|
||||
root: node,
|
||||
square: node.querySelector(".led-square"),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function updateLedGrid(store, flags) {
|
||||
flags.forEach(flag => {
|
||||
const item = store[flag.bit];
|
||||
if (!item) return;
|
||||
item.square.classList.toggle("on", flag.active);
|
||||
item.root.title = flag.active ? "ON" : "OFF";
|
||||
});
|
||||
}
|
||||
|
||||
async function loadRegistry() {
|
||||
const data = await api("/api/registry");
|
||||
state.registers = data.registers;
|
||||
buildLedGrid($("statusLedGrid"), data.status_flags, statusLeds);
|
||||
buildLedGrid($("faultLedGrid"), data.fault_flags, faultLeds);
|
||||
const mode = $("mode");
|
||||
mode.innerHTML = "";
|
||||
Object.entries(data.modes).forEach(([value, name]) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = value;
|
||||
option.textContent = `${value} ${name}`;
|
||||
if (value === "5") option.selected = true;
|
||||
mode.appendChild(option);
|
||||
});
|
||||
|
||||
const quick = $("quickModes");
|
||||
[
|
||||
["Logging", "logging"], ["Auto ID", "auto"], ["Rotation", "rotation"],
|
||||
["PWM UH", "uh"], ["PWM UL", "ul"], ["PWM VH", "vh"], ["PWM VL", "vl"],
|
||||
["PWM WH", "wh"], ["PWM WL", "wl"], ["PWM ALL", "all"]
|
||||
].forEach(([label, value]) => {
|
||||
const button = document.createElement("button");
|
||||
button.textContent = label;
|
||||
button.onclick = () => startMode(value);
|
||||
quick.appendChild(button);
|
||||
});
|
||||
|
||||
const rows = $("registerRows");
|
||||
rows.innerHTML = "";
|
||||
state.registerRows.clear();
|
||||
data.registers.forEach(reg => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.dataset.addr = reg.addr;
|
||||
tr.innerHTML = `<td>${reg.addr}</td><td>${reg.hex}</td><td>${reg.name}</td><td>${reg.access}</td><td>-</td><td>-</td>`;
|
||||
tr.onclick = () => {
|
||||
document.querySelectorAll("#registerRows tr").forEach(row => row.classList.remove("selected"));
|
||||
tr.classList.add("selected");
|
||||
$("readAddr").value = reg.addr;
|
||||
$("writeAddr").value = reg.name;
|
||||
};
|
||||
rows.appendChild(tr);
|
||||
state.registerRows.set(reg.addr, tr);
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshPorts() {
|
||||
try {
|
||||
const data = await api("/api/ports");
|
||||
const list = $("ports");
|
||||
list.innerHTML = "";
|
||||
data.ports.forEach(port => {
|
||||
const option = document.createElement("option");
|
||||
option.value = port.device;
|
||||
option.label = port.description || port.device;
|
||||
list.appendChild(option);
|
||||
});
|
||||
if (!$("port").value && data.ports.length) $("port").value = data.ports[0].device;
|
||||
log(`Found ${data.ports.length} serial port(s)`);
|
||||
} catch (error) {
|
||||
log(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
try {
|
||||
const payload = {
|
||||
port: $("port").value,
|
||||
baud: Number($("baud").value),
|
||||
slave: Number($("slave").value),
|
||||
timeout: Number($("timeout").value),
|
||||
};
|
||||
const data = await api("/api/connect", {method: "POST", body: JSON.stringify(payload)});
|
||||
setConnected(true, `Connected: ${data.port}`);
|
||||
log(`Connected to ${data.port}`);
|
||||
await refreshAll();
|
||||
} catch (error) {
|
||||
setConnected(false, `Error: ${error.message}`);
|
||||
log(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
try {
|
||||
await api("/api/disconnect", {method: "POST", body: "{}"});
|
||||
} catch (error) {
|
||||
log(error.message, "error");
|
||||
}
|
||||
stopAuto();
|
||||
stopMeasurePoll();
|
||||
stopRegisterPoll();
|
||||
setConnected(false, "Disconnected");
|
||||
log("Disconnected");
|
||||
}
|
||||
|
||||
async function ping() {
|
||||
const data = await api("/api/ping");
|
||||
updateRegisters(data.registers);
|
||||
log(`Ping OK: device=${data.device_id}, protocol=${data.protocol}`);
|
||||
}
|
||||
|
||||
function startPayload(modeOverride = null) {
|
||||
return {
|
||||
mode: modeOverride || $("mode").value,
|
||||
duty: Number($("duty").value),
|
||||
current_limit: Number($("current").value),
|
||||
overvoltage: Number($("overvoltage").value),
|
||||
undervoltage: Number($("undervoltage").value),
|
||||
speed_limit: Number($("speed").value),
|
||||
temp_limit: Number($("temp").value),
|
||||
rotation_freq: Number($("rotFreq").value),
|
||||
rotation_mod: Number($("rotMod").value),
|
||||
rotation_ramp_ms: Number($("rotRamp").value),
|
||||
polarity: Number($("polarity").value),
|
||||
timing: $("timing").value,
|
||||
motor: $("motor").value,
|
||||
pole_pairs: Number($("polePairs").value),
|
||||
phase_shunt_ohm: Number($("phaseShunt").value),
|
||||
locked_rotor: $("locked").checked,
|
||||
};
|
||||
}
|
||||
|
||||
async function startMode(modeOverride = null) {
|
||||
try {
|
||||
const data = await api("/api/start", {method: "POST", body: JSON.stringify(startPayload(modeOverride))});
|
||||
$("mode").value = String(data.mode);
|
||||
log(`Started ${data.mode} ${data.mode_name}`);
|
||||
if (data.warning) log(data.warning, "warn");
|
||||
await refreshStatus();
|
||||
} catch (error) {
|
||||
log(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleApplyRunControl() {
|
||||
if (!$("applyNow").checked) return;
|
||||
if (state.applyTimer) clearTimeout(state.applyTimer);
|
||||
state.applyTimer = setTimeout(applyRunControl, 250);
|
||||
}
|
||||
|
||||
async function applyRunControl() {
|
||||
state.applyTimer = null;
|
||||
if (!$("applyNow").checked) return;
|
||||
try {
|
||||
const data = await api("/api/apply-run-control", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(startPayload()),
|
||||
});
|
||||
updateRegisters(data.registers);
|
||||
log(`Applied run control: ${data.mode} ${data.mode_name}`);
|
||||
} catch (error) {
|
||||
log(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function stopAd() {
|
||||
try {
|
||||
const data = await api("/api/stop", {method: "POST", body: "{}"});
|
||||
log("Stop requested");
|
||||
if (data.warning) log(data.warning, "warn");
|
||||
await refreshStatus();
|
||||
} catch (error) {
|
||||
log(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function resetFaults() {
|
||||
try {
|
||||
const data = await api("/api/reset", {method: "POST", body: "{}"});
|
||||
log("Fault reset requested");
|
||||
if (data.warning) log(data.warning, "warn");
|
||||
await refreshStatus();
|
||||
} catch (error) {
|
||||
log(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function updateRegisters(items) {
|
||||
items.forEach(item => {
|
||||
const row = state.registerRows.get(item.addr);
|
||||
if (!row) return;
|
||||
row.children[4].textContent = `${item.raw} / ${item.hex_value}`;
|
||||
row.children[5].textContent = item.decoded;
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
const data = await api("/api/status");
|
||||
Object.entries(data.status).forEach(([key, value]) => {
|
||||
if (statusFields[key]) statusFields[key].textContent = value;
|
||||
});
|
||||
updateLedGrid(statusLeds, data.status_leds);
|
||||
updateLedGrid(faultLeds, data.fault_leds);
|
||||
updateRegisters(data.registers);
|
||||
}
|
||||
|
||||
async function refreshMeasurements() {
|
||||
const data = await api("/api/measure");
|
||||
Object.entries(data.measurements).forEach(([key, value]) => {
|
||||
if (measureFields[key]) measureFields[key].textContent = value;
|
||||
});
|
||||
updateRegisters(data.registers);
|
||||
appendCsv(data.raw);
|
||||
}
|
||||
|
||||
async function resetCurrentPeaks() {
|
||||
if (!$("resetCurrentPeaks").checked) return;
|
||||
try {
|
||||
const data = await api("/api/reset-current-peaks", {method: "POST", body: "{}"});
|
||||
log("Current peaks reset requested");
|
||||
if (data.warning) log(data.warning, "warn");
|
||||
await refreshMeasurements();
|
||||
} catch (error) {
|
||||
log(error.message, "error");
|
||||
} finally {
|
||||
$("resetCurrentPeaks").checked = false;
|
||||
}
|
||||
}
|
||||
|
||||
function measurePollInterval() {
|
||||
return Math.max(50, Number($("measureInterval").value) || 200);
|
||||
}
|
||||
|
||||
function startMeasurePoll() {
|
||||
stopMeasurePoll();
|
||||
const interval = measurePollInterval();
|
||||
state.measureTimer = setInterval(async () => {
|
||||
try {
|
||||
await refreshMeasurements();
|
||||
} catch (error) {
|
||||
log(error.message, "error");
|
||||
stopMeasurePoll();
|
||||
$("measurePoll").checked = false;
|
||||
}
|
||||
}, interval);
|
||||
refreshMeasurements().catch(error => log(error.message, "error"));
|
||||
log(`Measurement polling ${interval} ms`);
|
||||
}
|
||||
|
||||
function stopMeasurePoll() {
|
||||
if (state.measureTimer) {
|
||||
clearInterval(state.measureTimer);
|
||||
state.measureTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshParams() {
|
||||
const data = await api("/api/params");
|
||||
Object.entries(data.params).forEach(([key, value]) => {
|
||||
if (paramFields[key]) paramFields[key].textContent = value;
|
||||
});
|
||||
updateRegisters(data.registers);
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
await refreshStatus();
|
||||
await refreshMeasurements();
|
||||
await refreshParams();
|
||||
log("Refreshed");
|
||||
}
|
||||
|
||||
async function readRange() {
|
||||
try {
|
||||
const payload = {address: $("readAddr").value, quantity: Number($("readQty").value)};
|
||||
const data = await api("/api/read", {method: "POST", body: JSON.stringify(payload)});
|
||||
updateRegisters(data.registers);
|
||||
log(`Read ${data.registers.length} register(s)`);
|
||||
} catch (error) {
|
||||
log(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRegisterGroups() {
|
||||
const data = await api("/api/read-groups");
|
||||
updateRegisters(data.registers);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function readGroups() {
|
||||
try {
|
||||
await refreshRegisterGroups();
|
||||
log("Register groups refreshed");
|
||||
} catch (error) {
|
||||
log(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function registerPollInterval() {
|
||||
return Math.max(100, Number($("registerInterval").value) || 500);
|
||||
}
|
||||
|
||||
function startRegisterPoll() {
|
||||
stopRegisterPoll();
|
||||
const interval = registerPollInterval();
|
||||
state.registerTimer = setInterval(async () => {
|
||||
try {
|
||||
await refreshRegisterGroups();
|
||||
} catch (error) {
|
||||
log(error.message, "error");
|
||||
stopRegisterPoll();
|
||||
$("registerPoll").checked = false;
|
||||
}
|
||||
}, interval);
|
||||
refreshRegisterGroups().catch(error => log(error.message, "error"));
|
||||
log(`Register polling ${interval} ms`);
|
||||
}
|
||||
|
||||
function stopRegisterPoll() {
|
||||
if (state.registerTimer) {
|
||||
clearInterval(state.registerTimer);
|
||||
state.registerTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeOne() {
|
||||
try {
|
||||
const payload = {address: $("writeAddr").value, value: $("writeValue").value};
|
||||
const data = await api("/api/write", {method: "POST", body: JSON.stringify(payload)});
|
||||
updateRegisters(data.registers);
|
||||
log(`Wrote ${data.address_hex} = ${data.value}`);
|
||||
} catch (error) {
|
||||
log(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function startAuto() {
|
||||
stopAuto();
|
||||
const interval = Math.max(100, Number($("interval").value) || 500);
|
||||
state.autoTimer = setInterval(async () => {
|
||||
try {
|
||||
await refreshStatus();
|
||||
await refreshMeasurements();
|
||||
} catch (error) {
|
||||
log(error.message, "error");
|
||||
stopAuto();
|
||||
$("auto").checked = false;
|
||||
}
|
||||
}, interval);
|
||||
log(`Auto refresh ${interval} ms`);
|
||||
}
|
||||
|
||||
function stopAuto() {
|
||||
if (state.autoTimer) {
|
||||
clearInterval(state.autoTimer);
|
||||
state.autoTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function csvStart() {
|
||||
state.csvRows = [];
|
||||
state.csvLogging = true;
|
||||
$("csvNote").textContent = "CSV logging in browser memory";
|
||||
if (!$("measurePoll").checked) {
|
||||
$("measurePoll").checked = true;
|
||||
startMeasurePoll();
|
||||
}
|
||||
log("CSV logging started");
|
||||
}
|
||||
|
||||
function appendCsv(raw) {
|
||||
if (!state.csvLogging) return;
|
||||
state.csvRows.push({host_time_s: (Date.now() / 1000).toFixed(3), ...raw});
|
||||
$("csvNote").textContent = `${state.csvRows.length} row(s)`;
|
||||
}
|
||||
|
||||
function csvStop() {
|
||||
if (!state.csvLogging) return;
|
||||
state.csvLogging = false;
|
||||
const fields = ["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"];
|
||||
const lines = [fields.join(",")];
|
||||
state.csvRows.forEach(row => lines.push(fields.map(field => row[field]).join(",")));
|
||||
const blob = new Blob([lines.join("\n") + "\n"], {type: "text/csv"});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `ad_log_${new Date().toISOString().replaceAll(":", "-").slice(0, 19)}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
$("csvNote").textContent = `Saved ${state.csvRows.length} row(s)`;
|
||||
log("CSV logging stopped");
|
||||
}
|
||||
|
||||
function setupTabs() {
|
||||
document.querySelectorAll(".tab").forEach(button => {
|
||||
button.addEventListener("click", () => {
|
||||
document.querySelectorAll(".tab").forEach(item => item.classList.remove("active"));
|
||||
document.querySelectorAll(".view").forEach(item => item.classList.remove("active"));
|
||||
button.classList.add("active");
|
||||
$(button.dataset.tab).classList.add("active");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function bind() {
|
||||
$("refreshPorts").onclick = refreshPorts;
|
||||
$("connect").onclick = connect;
|
||||
$("disconnect").onclick = disconnect;
|
||||
$("ping").onclick = () => ping().catch(error => log(error.message, "error"));
|
||||
$("start").onclick = () => startMode();
|
||||
$("stop").onclick = stopAd;
|
||||
$("reset").onclick = resetFaults;
|
||||
$("refreshAll").onclick = () => refreshAll().catch(error => log(error.message, "error"));
|
||||
$("readRange").onclick = readRange;
|
||||
$("readGroups").onclick = readGroups;
|
||||
$("registerRefresh").onclick = readGroups;
|
||||
$("writeOne").onclick = writeOne;
|
||||
$("auto").onchange = () => $("auto").checked ? startAuto() : stopAuto();
|
||||
$("csvStart").onclick = csvStart;
|
||||
$("csvStop").onclick = csvStop;
|
||||
$("measureRefresh").onclick = () => refreshMeasurements().catch(error => log(error.message, "error"));
|
||||
$("measurePoll").onchange = () => $("measurePoll").checked ? startMeasurePoll() : stopMeasurePoll();
|
||||
$("resetCurrentPeaks").onchange = resetCurrentPeaks;
|
||||
$("measureInterval").onchange = () => {
|
||||
if ($("measurePoll").checked) startMeasurePoll();
|
||||
};
|
||||
$("registerPoll").onchange = () => $("registerPoll").checked ? startRegisterPoll() : stopRegisterPoll();
|
||||
$("registerInterval").onchange = () => {
|
||||
if ($("registerPoll").checked) startRegisterPoll();
|
||||
};
|
||||
["mode","duty","current","overvoltage","undervoltage","speed","temp","rotFreq","rotMod","rotRamp","polarity","timing","motor","polePairs","locked"]
|
||||
.forEach(id => $(id).addEventListener("change", scheduleApplyRunControl));
|
||||
$("applyNow").addEventListener("change", () => {
|
||||
if ($("applyNow").checked) scheduleApplyRunControl();
|
||||
});
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
setupTabs();
|
||||
buildMetrics();
|
||||
bind();
|
||||
await loadRegistry();
|
||||
await refreshPorts();
|
||||
log("GUI ready");
|
||||
}
|
||||
|
||||
boot().catch(error => log(error.message, "error"));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
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())
|
||||
1306
AD_Keil_Project/tools/ad_terminal.py
Normal file
1306
AD_Keil_Project/tools/ad_terminal.py
Normal file
@@ -0,0 +1,1306 @@
|
||||
#!/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())
|
||||
1
AD_Keil_Project/tools/requirements-ad-terminal.txt
Normal file
1
AD_Keil_Project/tools/requirements-ad-terminal.txt
Normal file
@@ -0,0 +1 @@
|
||||
pyserial>=3.5
|
||||
11
AD_Keil_Project/tools/run_ad_gui.bat
Normal file
11
AD_Keil_Project/tools/run_ad_gui.bat
Normal file
@@ -0,0 +1,11 @@
|
||||
@echo off
|
||||
cd /d "%~dp0"
|
||||
python -B "%~dp0ad_gui.py"
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo AD GUI failed.
|
||||
echo If pyserial is missing, run:
|
||||
echo python -m pip install -r "%~dp0requirements-ad-terminal.txt"
|
||||
echo.
|
||||
pause
|
||||
)
|
||||
47
AD_Keil_Project/tools/run_ad_gui_remote.bat
Normal file
47
AD_Keil_Project/tools/run_ad_gui_remote.bat
Normal file
@@ -0,0 +1,47 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
|
||||
set AD_GUI_HOST=0.0.0.0
|
||||
set AD_GUI_PORT=8765
|
||||
if not defined AD_GUI_USER set "AD_GUI_USER=ad"
|
||||
|
||||
if not defined AD_GUI_PASSWORD (
|
||||
echo Login user: %AD_GUI_USER%
|
||||
for /f "usebackq delims=" %%P in (`powershell -NoProfile -Command "$p = Read-Host 'Set AD GUI password' -AsSecureString; $b = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($p); try { [Runtime.InteropServices.Marshal]::PtrToStringBSTR($b) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($b) }"`) do set "AD_GUI_PASSWORD=%%P"
|
||||
)
|
||||
|
||||
if not defined AD_GUI_PASSWORD (
|
||||
echo.
|
||||
echo Password is required for remote access.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Starting AD GUI remote server...
|
||||
echo.
|
||||
echo Login user:
|
||||
echo %AD_GUI_USER%
|
||||
echo.
|
||||
echo Local computer URLs:
|
||||
for /f "tokens=2 delims=:" %%A in ('ipconfig ^| findstr /R /C:"IPv4"') do (
|
||||
for /f "tokens=* delims= " %%B in ("%%A") do echo http://%%B:%AD_GUI_PORT%/
|
||||
)
|
||||
echo.
|
||||
echo Remote users must be on the same LAN/VPN and open one of the URLs above.
|
||||
echo Press Ctrl+C to stop the server.
|
||||
echo.
|
||||
|
||||
python -B "%~dp0ad_gui.py" --host %AD_GUI_HOST% --port %AD_GUI_PORT% --no-open --auth-user "%AD_GUI_USER%"
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo AD GUI remote server failed.
|
||||
echo If pyserial is missing, run:
|
||||
echo python -m pip install -r "%~dp0requirements-ad-terminal.txt"
|
||||
echo.
|
||||
echo If remote browser cannot connect, allow TCP port %AD_GUI_PORT% in Windows Firewall.
|
||||
echo See:
|
||||
echo "%~dp0README_AD_REMOTE_GUI.md"
|
||||
echo.
|
||||
pause
|
||||
)
|
||||
Reference in New Issue
Block a user