feat: add MCU communication support to write filter coefficients via serial port
This commit is contained in:
@@ -1,37 +1,40 @@
|
||||
# Difference Equation Analyzer
|
||||
|
||||
差分方程式與數位濾波器分析工具。後端使用 FastAPI,前端使用 Vue 3 + Vite 建置後由 FastAPI 以靜態檔案服務。
|
||||
Difference Equation Analyzer 是一個用來設計、檢視與驗證離散時間濾波器的工具。後端使用 FastAPI、NumPy、SciPy 與 Pandas;前端使用 Vue 3、Vite、Tailwind CSS 與 Plotly。
|
||||
|
||||
主要功能:
|
||||
|
||||
- 設計 Lowpass、Highpass、Bandpass、Notch、1P1Z、2P1Z、2P2Z、PID、SOGI 濾波器。
|
||||
- 比較浮點係數與 fixed-point 係數的頻率響應。
|
||||
- 手動微調 b/a 係數、a1/a2 組合、Q 格式位元數與 fixed-point 整數係數。
|
||||
- 上傳 CSV 時域資料,套用目前差分方程並繪製輸入/輸出波形。
|
||||
- 匯出包含濾波輸出欄位的 CSV。
|
||||
- 透過 USB serial 將 fixed-point 係數命令送到 MCU。
|
||||
|
||||
## 專案結構
|
||||
|
||||
- `dea_api.py`:FastAPI 後端、API 端點、LAN 存取限制、安全標頭。
|
||||
- `src/`:Vue 前端原始碼。
|
||||
- `static/index.html`:正式 UI 入口,載入建置後的前端資源。
|
||||
- `static/build/app.js`、`static/build/app.css`:前端建置產物,正式服務會載入這兩個檔案。
|
||||
- `static/style.css`:既有自訂 CSS,由 `src/style.css` 引入。
|
||||
- `tests/`:後端回歸測試。
|
||||
- `dea.service`:systemd 服務設定範本。
|
||||
- `dea_legacy.py`:舊 Streamlit 版本,僅供參考。
|
||||
|
||||
## 使用網址
|
||||
|
||||
本機:
|
||||
|
||||
```bash
|
||||
http://localhost:8000/ui/
|
||||
```text
|
||||
dea_api.py FastAPI 入口與 HTTP route
|
||||
dea/ 後端核心模組
|
||||
bode.py 頻率響應計算
|
||||
config.py 限制值與 MCU serial 設定
|
||||
csv_processing.py CSV 讀取、驗證、濾波與匯出
|
||||
filter_design.py 濾波器設計
|
||||
mcu.py MCU serial port 列表與命令寫入
|
||||
schemas.py Pydantic request schema
|
||||
security.py LAN 存取限制與安全標頭
|
||||
validation.py 係數、頻率與有限值驗證
|
||||
src/ Vue 3 前端原始碼
|
||||
static/ FastAPI 對外提供的靜態 UI
|
||||
static/build/ Vite build 輸出
|
||||
tests/ unittest 測試
|
||||
dea.service systemd service 範例
|
||||
chirp_signal.csv CSV 上傳測試範例
|
||||
```
|
||||
|
||||
區網其他裝置:
|
||||
## 安裝
|
||||
|
||||
```bash
|
||||
http://192.168.2.58:8000/ui/
|
||||
```
|
||||
|
||||
根路徑 `/` 會自動導向 `/ui/`。
|
||||
|
||||
## 後端環境
|
||||
|
||||
建立或重建 Python 虛擬環境:
|
||||
在目標主機上建立 Python venv 並安裝依賴:
|
||||
|
||||
```bash
|
||||
cd /home/wisetop/diff-eq-analyzer
|
||||
@@ -39,38 +42,113 @@ python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
```
|
||||
|
||||
本機開發啟動:
|
||||
|
||||
```bash
|
||||
.venv/bin/uvicorn dea_api:app --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
## 前端建置
|
||||
|
||||
前端依賴由 `package.json` / `package-lock.json` 管理。首次安裝:
|
||||
安裝前端依賴:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
重新建置正式前端:
|
||||
建置前端:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
建置後會更新:
|
||||
Vite 會輸出到:
|
||||
|
||||
```text
|
||||
static/build/app.js
|
||||
static/build/app.css
|
||||
```
|
||||
|
||||
注意:正式頁面不再載入 Vue CDN 或 Tailwind CDN;Vue 會被打包到 `static/build/app.js`。目前仍使用 Plotly CDN。
|
||||
## 執行
|
||||
|
||||
## systemd 服務
|
||||
本機開發可直接啟動 FastAPI:
|
||||
|
||||
查看狀態:
|
||||
```bash
|
||||
.venv/bin/uvicorn dea_api:app --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
瀏覽器開啟:
|
||||
|
||||
```text
|
||||
http://localhost:8000/ui/
|
||||
```
|
||||
|
||||
部署在區網主機時可用:
|
||||
|
||||
```text
|
||||
http://<server-ip>:8000/ui/
|
||||
```
|
||||
|
||||
根路徑 `/` 會自動導向 `/ui/`。
|
||||
|
||||
## API
|
||||
|
||||
目前主要 API:
|
||||
|
||||
- `POST /api/design`:依濾波器參數產生 b/a 係數。
|
||||
- `POST /api/bode`:計算單組 b/a 係數的頻率響應。
|
||||
- `POST /api/bode/compare`:一次計算浮點與 fixed-point 兩組頻率響應。
|
||||
- `POST /api/filter`:上傳 CSV 並回傳圖表用的濾波結果。
|
||||
- `POST /api/filter/download`:上傳 CSV 並下載包含 `<欄位>_filtered` 的結果 CSV。
|
||||
- `GET /api/mcu/ports`:列出可用 serial ports。
|
||||
- `POST /api/mcu/write`:送出 MCU 命令。
|
||||
|
||||
MCU 寫入命令格式固定為:
|
||||
|
||||
```text
|
||||
bodeplot=b0,b1,b2,a1,a2
|
||||
```
|
||||
|
||||
前端會使用目前 fixed-point 係數自動組出此命令。
|
||||
|
||||
## CSV 上傳
|
||||
|
||||
限制與行為:
|
||||
|
||||
- CSV 檔案大小上限為 32 MB。
|
||||
- CSV 必須有標題列與至少一筆資料。
|
||||
- 可選擇要濾波的訊號欄位;若第一欄像 `time`、`t`、`sec`、`x`、`index`,前端會預設選第二欄。
|
||||
- 圖表回應最多回傳 5000 點,避免大型 CSV 讓前端過重。
|
||||
- 下載 CSV 會保留完整資料,不套用圖表降採樣。
|
||||
|
||||
專案根目錄的 `chirp_signal.csv` 是上傳測試範例,約 19 MB、500001 行,欄位為:
|
||||
|
||||
```text
|
||||
time,value
|
||||
```
|
||||
|
||||
## MCU Serial 設定
|
||||
|
||||
MCU serial 預設值在 `dea/config.py`,也可用環境變數覆蓋:
|
||||
|
||||
```bash
|
||||
MCU_SERIAL_PORT=/dev/ttyUSB2
|
||||
MCU_BAUD_RATE=115200
|
||||
MCU_WRITE_TIMEOUT=1.0
|
||||
```
|
||||
|
||||
若使用 systemd,可在 `dea.service` 的 `[Service]` 區塊加入:
|
||||
|
||||
```ini
|
||||
Environment="MCU_SERIAL_PORT=/dev/ttyUSB2"
|
||||
Environment="MCU_BAUD_RATE=115200"
|
||||
Environment="MCU_WRITE_TIMEOUT=1.0"
|
||||
```
|
||||
|
||||
更新 service 後重新載入:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart dea.service
|
||||
```
|
||||
|
||||
注意:MCU 功能需要 `pyserial>=3.5`,已列在 `requirements.txt`。
|
||||
|
||||
## systemd
|
||||
|
||||
查看服務狀態:
|
||||
|
||||
```bash
|
||||
sudo systemctl status dea.service
|
||||
@@ -84,13 +162,13 @@ sudo systemctl stop dea.service
|
||||
sudo systemctl restart dea.service
|
||||
```
|
||||
|
||||
查看即時日誌:
|
||||
查看 log:
|
||||
|
||||
```bash
|
||||
sudo journalctl -u dea.service -f
|
||||
```
|
||||
|
||||
修改 `dea.service` 或 `/etc/systemd/system/dea.service` 後:
|
||||
若修改 `/etc/systemd/system/dea.service`:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
@@ -105,7 +183,7 @@ Python 語法檢查:
|
||||
.venv/bin/python -m py_compile dea_api.py tests/test_dea_api.py
|
||||
```
|
||||
|
||||
後端測試:
|
||||
後端測試使用 Python 內建 `unittest`,不需要安裝或執行 `pytest`:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m unittest discover -s tests
|
||||
@@ -117,52 +195,50 @@ Python 語法檢查:
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 安全與效能設定
|
||||
建議完整驗證順序:
|
||||
|
||||
後端目前包含:
|
||||
```bash
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
.venv/bin/python -m unittest discover -s tests
|
||||
npm run build
|
||||
sudo systemctl restart dea.service
|
||||
```
|
||||
|
||||
- 僅允許 private / loopback IP 存取。
|
||||
- CSP、安全回應標頭、`X-Frame-Options: DENY`、`nosniff`。
|
||||
- CSV 上傳大小限制:32 MB。
|
||||
- 圖表用 CSV 回傳最多 5000 點,下載 CSV 仍保留完整資料。
|
||||
- 係數數量上限:64。
|
||||
- 濾波器頻率、係數有限值、`a[0]` 等輸入驗證。
|
||||
## 安全與限制
|
||||
|
||||
目前 CSP 的 `script-src` 僅允許:
|
||||
- API middleware 只允許 private / loopback IP 存取。
|
||||
- 回應會加上 CSP、`Referrer-Policy`、`X-Content-Type-Options: nosniff`、`X-Frame-Options: DENY`。
|
||||
- CSP 的 script 來源允許本機與 Plotly CDN:
|
||||
|
||||
```text
|
||||
'self' https://cdn.plot.ly
|
||||
```
|
||||
|
||||
因此不再需要 `'unsafe-eval'`。
|
||||
- 係數數量上限為 64。
|
||||
- CSV 上限為 32 MB。
|
||||
- 頻率、係數、`a[0]` 與 CSV 訊號欄位都有後端驗證。
|
||||
|
||||
## 常見問題
|
||||
|
||||
### 網頁空白
|
||||
### 頁面沒有更新
|
||||
|
||||
1. 強制刷新瀏覽器:`Ctrl+F5`。
|
||||
2. 確認服務狀態:
|
||||
|
||||
```bash
|
||||
sudo systemctl status dea.service
|
||||
```
|
||||
|
||||
3. 確認靜態檔案存在:
|
||||
1. 瀏覽器按 `Ctrl+F5` 強制重新整理。
|
||||
2. 確認前端 build 檔存在:
|
||||
|
||||
```bash
|
||||
ls static/build/app.js static/build/app.css
|
||||
```
|
||||
|
||||
4. 若剛改前端,請重新建置並重啟:
|
||||
3. 重新 build 並重啟服務:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
sudo systemctl restart dea.service
|
||||
```
|
||||
|
||||
### 服務無法啟動,出現 status=203/EXEC
|
||||
### systemd 顯示 `status=203/EXEC`
|
||||
|
||||
通常是 `.venv` 路徑或 `uvicorn` 腳本路徑失效。重建虛擬環境後再重啟:
|
||||
通常是 `.venv` 或 `uvicorn` 路徑不正確。可重建 venv:
|
||||
|
||||
```bash
|
||||
rm -rf .venv
|
||||
@@ -170,3 +246,23 @@ python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
sudo systemctl restart dea.service
|
||||
```
|
||||
|
||||
### 找不到 pytest
|
||||
|
||||
本專案不使用 pytest。請改用:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m unittest discover -s tests
|
||||
```
|
||||
|
||||
### MCU 串口清單是空的
|
||||
|
||||
1. 確認 USB 裝置已插入。
|
||||
2. 確認執行服務的使用者有 serial port 權限。
|
||||
3. 檢查預設 port:
|
||||
|
||||
```bash
|
||||
echo $MCU_SERIAL_PORT
|
||||
```
|
||||
|
||||
4. 可在頁面按「刷新」重新讀取 serial ports。
|
||||
|
||||
+5
-1
@@ -1,7 +1,11 @@
|
||||
import os
|
||||
|
||||
MAX_COEFFICIENTS = 64
|
||||
MAX_CSV_BYTES = 32 * 1024 * 1024
|
||||
MAX_PLOT_POINTS = 5000
|
||||
BODE_POINTS = 500
|
||||
BODE_MAX_MULTIPLIER = 3.162
|
||||
NYQUIST_MARGIN = 0.999999
|
||||
|
||||
MCU_SERIAL_PORT = os.getenv("MCU_SERIAL_PORT", "/dev/ttyUSB2")
|
||||
MCU_BAUD_RATE = int(os.getenv("MCU_BAUD_RATE", "115200"))
|
||||
MCU_WRITE_TIMEOUT = float(os.getenv("MCU_WRITE_TIMEOUT", "1.0"))
|
||||
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
import re
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from .config import MCU_BAUD_RATE, MCU_SERIAL_PORT, MCU_WRITE_TIMEOUT
|
||||
from .schemas import MCUWriteParams
|
||||
|
||||
|
||||
MCU_CMD_PATTERN = re.compile(r"^bodeplot=-?\d+,-?\d+,-?\d+,-?\d+,-?\d+$")
|
||||
|
||||
|
||||
def _serial_module():
|
||||
try:
|
||||
import serial
|
||||
except ImportError as exc:
|
||||
raise HTTPException(status_code=500, detail="MCU 串口功能需要安裝 pyserial") from exc
|
||||
return serial
|
||||
|
||||
|
||||
def write_mcu_command_response(params: MCUWriteParams):
|
||||
cmd = params.command.strip()
|
||||
if not MCU_CMD_PATTERN.fullmatch(cmd):
|
||||
raise HTTPException(status_code=400, detail="MCU 指令格式錯誤")
|
||||
|
||||
target_port = (params.port or MCU_SERIAL_PORT).strip()
|
||||
if not target_port:
|
||||
raise HTTPException(status_code=400, detail="MCU 串口不可為空")
|
||||
|
||||
serial = _serial_module()
|
||||
payload = (cmd + "\r\n").encode("ascii", errors="strict")
|
||||
|
||||
try:
|
||||
with serial.Serial(
|
||||
port=target_port,
|
||||
baudrate=MCU_BAUD_RATE,
|
||||
timeout=MCU_WRITE_TIMEOUT,
|
||||
write_timeout=MCU_WRITE_TIMEOUT,
|
||||
) as ser:
|
||||
written = ser.write(payload)
|
||||
ser.flush()
|
||||
except serial.SerialException as exc:
|
||||
raise HTTPException(status_code=500, detail=f"MCU 串口傳送失敗: {str(exc)}") from exc
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"command": cmd,
|
||||
"port": target_port,
|
||||
"baudrate": MCU_BAUD_RATE,
|
||||
"bytes_written": int(written),
|
||||
}
|
||||
|
||||
|
||||
def list_mcu_ports_response():
|
||||
_serial_module()
|
||||
try:
|
||||
from serial.tools import list_ports
|
||||
except ImportError as exc:
|
||||
raise HTTPException(status_code=500, detail="MCU 串口清單功能需要安裝 pyserial") from exc
|
||||
|
||||
ports = [
|
||||
{
|
||||
"device": port.device,
|
||||
"description": port.description or "",
|
||||
"hwid": port.hwid or "",
|
||||
}
|
||||
for port in list_ports.comports()
|
||||
]
|
||||
|
||||
return {
|
||||
"default_port": MCU_SERIAL_PORT,
|
||||
"baudrate": MCU_BAUD_RATE,
|
||||
"ports": ports,
|
||||
}
|
||||
+6
-1
@@ -1,4 +1,4 @@
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -42,3 +42,8 @@ class BodeParams(BaseModel):
|
||||
class BodeCompareParams(BaseModel):
|
||||
ideal: BodeParams
|
||||
fixed: BodeParams
|
||||
|
||||
|
||||
class MCUWriteParams(BaseModel):
|
||||
command: str = Field(min_length=1, max_length=128)
|
||||
port: Optional[str] = Field(default=None, max_length=256)
|
||||
|
||||
+22
-1
@@ -19,7 +19,8 @@ from dea.csv_processing import (
|
||||
read_csv_upload,
|
||||
)
|
||||
from dea.filter_design import design_response
|
||||
from dea.schemas import BodeCompareParams, BodeParams, DesignParams
|
||||
from dea.mcu import list_mcu_ports_response, write_mcu_command_response
|
||||
from dea.schemas import BodeCompareParams, BodeParams, DesignParams, MCUWriteParams
|
||||
from dea.security import (
|
||||
SECURITY_HEADERS,
|
||||
add_security_headers,
|
||||
@@ -103,6 +104,26 @@ async def filter_csv(
|
||||
raise HTTPException(status_code=400, detail=f"CSV處理失敗: {str(e)}")
|
||||
|
||||
|
||||
@app.get("/api/mcu/ports")
|
||||
def list_mcu_ports():
|
||||
try:
|
||||
return list_mcu_ports_response()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"MCU 串口清單讀取失敗: {str(e)}")
|
||||
|
||||
|
||||
@app.post("/api/mcu/write")
|
||||
def write_mcu_command(params: MCUWriteParams):
|
||||
try:
|
||||
return write_mcu_command_response(params)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"MCU 指令傳送失敗: {str(e)}")
|
||||
|
||||
|
||||
@app.post("/api/filter/download")
|
||||
async def filter_csv_download(
|
||||
file: UploadFile = File(...),
|
||||
|
||||
@@ -5,3 +5,4 @@ pydantic>=2.0.0
|
||||
numpy>=1.24.0
|
||||
pandas>=2.0.0
|
||||
scipy>=1.10.0
|
||||
pyserial>=3.5
|
||||
|
||||
+67
-46
@@ -72,9 +72,35 @@
|
||||
</svg>
|
||||
濾波器設計
|
||||
</div>
|
||||
<button @click="resetFilterParams"
|
||||
class="text-[10px] font-bold role-bg-secondary-soft role-border-secondary-soft role-text-secondary role-hover-secondary-soft border px-2 py-1 rounded transition-colors uppercase">重設設計</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="writeToMCU"
|
||||
:disabled="writingMCU"
|
||||
class="primary-action text-[10px] font-bold role-bg-primary role-hover-primary role-text-on-fill role-border-primary border px-2 py-1 rounded transition-colors uppercase disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{{ writingMCU ? '寫入中' : '寫值到 MCU' }}
|
||||
</button>
|
||||
<button @click="resetFilterParams"
|
||||
class="text-[10px] font-bold role-bg-secondary-soft role-border-secondary-soft role-text-secondary role-hover-secondary-soft border px-2 py-1 rounded transition-colors uppercase">重設設計</button>
|
||||
</div>
|
||||
</h2>
|
||||
<div class="mb-3 space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<select v-model="mcuPort"
|
||||
class="flex-1 bg-slate-50 dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-2 text-xs role-focus-primary focus:ring-1 role-focus-ring-primary outline-none transition-all text-slate-900 dark:text-gray-100 truncate">
|
||||
<option v-if="mcuPorts.length === 0" :value="mcuPort">
|
||||
{{ loadingMCUPorts ? '偵測串口中...' : (mcuPort || '無可用串口') }}
|
||||
</option>
|
||||
<option v-for="port in mcuPorts" :key="port.device" :value="port.device">
|
||||
{{ port.device }} - {{ port.description || 'Unknown' }}
|
||||
</option>
|
||||
</select>
|
||||
<button @click="fetchMCUPorts"
|
||||
:disabled="loadingMCUPorts"
|
||||
class="text-[10px] font-bold bg-slate-200 dark:bg-gray-800 text-slate-600 dark:text-gray-300 hover:bg-slate-300 dark:hover:bg-gray-700 px-2 py-2 rounded transition-colors uppercase disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="mcuStatus" class="text-xs text-slate-500 dark:text-gray-400">{{ mcuStatus }}</p>
|
||||
</div>
|
||||
<select v-model="filterType" @change="onFilterTypeChange"
|
||||
class="w-full bg-slate-50 dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base role-focus-primary focus:ring-1 role-focus-ring-primary outline-none transition-all mb-3 text-slate-900 dark:text-gray-100 truncate">
|
||||
<option v-for="opt in filterOptions" :key="opt" :value="opt">{{ opt }}</option>
|
||||
@@ -93,10 +119,10 @@
|
||||
<input type="number" v-model.number="params.fc" @change="applyFilterDesign"
|
||||
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base role-focus-primary outline-none text-slate-900 dark:text-gray-100">
|
||||
<label class="text-sm text-slate-500 dark:text-gray-400 mt-3 mb-2 block">階數 Order</label>
|
||||
<div class="flex gap-2">
|
||||
<div class="selector-button-group flex gap-2">
|
||||
<button v-for="o in [1,2,3]" :key="o" @click="params.order = o; applyFilterDesign()"
|
||||
:class="params.order === o ? 'role-bg-primary role-text-on-fill role-border-primary' : 'bg-slate-200 dark:bg-gray-800 text-slate-600 dark:text-gray-400 border-slate-300 dark:border-gray-700'"
|
||||
class="flex-1 rounded py-2.5 text-base font-medium border transition-colors">{{ o
|
||||
:class="params.order === o ? 'primary-action role-bg-primary role-text-on-fill role-border-primary' : 'bg-slate-200 dark:bg-gray-800 text-slate-600 dark:text-gray-400 border-slate-300 dark:border-gray-700'"
|
||||
class="selector-button flex-1 rounded py-2.5 text-base font-medium border transition-colors">{{ o
|
||||
}}</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,10 +139,10 @@
|
||||
<input type="number" v-model.number="params.bp_f_high" @change="applyFilterDesign"
|
||||
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base mb-2 text-slate-900 dark:text-gray-100">
|
||||
<label class="text-sm text-slate-500 dark:text-gray-400 mt-2 mb-2 block">階數 Order</label>
|
||||
<div class="flex gap-2">
|
||||
<div class="selector-button-group flex gap-2">
|
||||
<button v-for="o in [1,2,3]" :key="o" @click="params.bp_order = o; applyFilterDesign()"
|
||||
:class="params.bp_order === o ? 'role-bg-primary role-text-on-fill role-border-primary' : 'bg-slate-200 dark:bg-gray-800 text-slate-600 dark:text-gray-400 border-slate-300 dark:border-gray-700'"
|
||||
class="flex-1 rounded py-1.5 text-sm border transition-colors">{{ o }}</button>
|
||||
:class="params.bp_order === o ? 'primary-action role-bg-primary role-text-on-fill role-border-primary' : 'bg-slate-200 dark:bg-gray-800 text-slate-600 dark:text-gray-400 border-slate-300 dark:border-gray-700'"
|
||||
class="selector-button flex-1 rounded py-1.5 text-sm border transition-colors">{{ o }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Notch -->
|
||||
@@ -212,11 +238,6 @@
|
||||
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-2 text-sm text-slate-900 dark:text-gray-100">
|
||||
</div>
|
||||
</div>
|
||||
<!-- 提示資訊 -->
|
||||
<div
|
||||
class="role-bg-primary-soft border role-border-primary-soft rounded-lg p-3 text-sm role-text-primary mt-2">
|
||||
調整上方參數會自動覆寫 a, b 係數。你也可以隨時手動修改下方係數進行微調。
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -256,7 +277,7 @@
|
||||
<hr class="border-slate-200 dark:border-gray-800">
|
||||
|
||||
<!-- 係數手動設定 -->
|
||||
<section>
|
||||
<section class="coeff-section">
|
||||
<h2
|
||||
class="text-sm font-semibold text-slate-500 dark:text-gray-400 uppercase tracking-wider mb-3 flex justify-between items-center">
|
||||
<span class="flex items-center gap-2">
|
||||
@@ -292,11 +313,11 @@
|
||||
class="text-sm text-slate-500 dark:text-gray-400 px-3 py-3 cursor-pointer hover:text-slate-600 dark:hover:text-gray-300">
|
||||
b 係數倍率微調</summary>
|
||||
<div class="px-3 pb-3">
|
||||
<div class="flex items-center gap-2 mb-2 mt-1">
|
||||
<div class="selector-button-group flex items-center gap-2 mb-2 mt-1">
|
||||
<span class="text-xs text-slate-400 dark:text-gray-500">靈敏度:</span>
|
||||
<button v-for="s in ['1%','2x','10x']" :key="s" @click="sense_b = s"
|
||||
:class="sense_b === s ? 'role-bg-primary role-text-on-fill' : 'bg-slate-200 dark:bg-gray-800 text-slate-500 dark:text-gray-400'"
|
||||
class="btn-small px-3 py-1.5 rounded text-xs font-medium border border-slate-300 dark:border-gray-700">{{
|
||||
:class="sense_b === s ? 'primary-action role-bg-primary role-text-on-fill role-border-primary' : 'bg-slate-200 dark:bg-gray-800 text-slate-500 dark:text-gray-400'"
|
||||
class="selector-button btn-small px-3 py-1.5 rounded text-xs font-medium border border-slate-300 dark:border-gray-700">{{
|
||||
s }}</button>
|
||||
</div>
|
||||
<template v-for="i in 7" :key="'bs'+i">
|
||||
@@ -336,11 +357,11 @@
|
||||
class="text-sm text-slate-500 dark:text-gray-400 px-3 py-3 cursor-pointer hover:text-slate-600 dark:hover:text-gray-300">
|
||||
a 係數倍率微調</summary>
|
||||
<div class="px-3 pb-3">
|
||||
<div class="flex items-center gap-2 mb-2 mt-1">
|
||||
<div class="selector-button-group flex items-center gap-2 mb-2 mt-1">
|
||||
<span class="text-xs text-slate-400 dark:text-gray-500">靈敏度:</span>
|
||||
<button v-for="s in ['1%','2x','10x']" :key="s" @click="sense_a = s"
|
||||
:class="sense_a === s ? 'role-bg-primary role-text-on-fill' : 'bg-slate-200 dark:bg-gray-800 text-slate-500 dark:text-gray-400'"
|
||||
class="px-3 py-1.5 rounded text-xs font-medium border border-slate-300 dark:border-gray-700">{{
|
||||
:class="sense_a === s ? 'primary-action role-bg-primary role-text-on-fill role-border-primary' : 'bg-slate-200 dark:bg-gray-800 text-slate-500 dark:text-gray-400'"
|
||||
class="selector-button px-3 py-1.5 rounded text-xs font-medium border border-slate-300 dark:border-gray-700">{{
|
||||
s }}</button>
|
||||
</div>
|
||||
<template v-for="i in 6" :key="'as'+i">
|
||||
@@ -371,9 +392,9 @@
|
||||
class="rounded-lg border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-600 dark:text-gray-300 py-2 text-xs font-bold transition-colors hover:bg-slate-100 dark:hover:bg-gray-700">
|
||||
粗
|
||||
</button>
|
||||
<div class="min-w-0 rounded-lg border role-border-tertiary-soft role-bg-tertiary-soft px-3 py-2 text-center">
|
||||
<span class="block text-[9px] role-text-tertiary-muted role-text-tertiary font-bold">微調量</span>
|
||||
<span class="block truncate font-mono text-sm font-bold role-text-tertiary" :title="formatAFineDelta()">
|
||||
<div class="min-w-0 rounded-lg border role-border-secondary-soft role-bg-secondary-soft px-3 py-2 text-center">
|
||||
<span class="block text-[9px] role-text-secondary-muted role-text-secondary font-bold">微調量</span>
|
||||
<span class="block truncate font-mono text-sm font-bold role-text-secondary" :title="formatAFineDelta()">
|
||||
+/-{{ formatAFineDelta() }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -390,37 +411,37 @@
|
||||
<span class="text-[9px] text-slate-400 block font-bold mb-1">δ</span>
|
||||
<div class="grid grid-cols-[2rem_1fr_2rem] gap-1">
|
||||
<button type="button" @pointerdown.prevent="startA1A2Repeat('delta', -1)" @pointerup="stopA1A2Repeat" @pointerleave="stopA1A2Repeat" @pointercancel="stopA1A2Repeat"
|
||||
class="rounded-lg bg-slate-200 dark:bg-gray-800 hover:bg-slate-300 dark:hover:bg-gray-700 text-slate-700 dark:text-gray-300 text-sm font-bold transition-colors">-</button>
|
||||
class="stepper-button rounded-lg role-bg-secondary-soft role-text-secondary text-sm font-bold transition-colors">-</button>
|
||||
<input type="number" :value="formatFineCoeff(currentDelta)" :step="aFineStep"
|
||||
@change="setAFineDirect('delta', $event.target.value)" @keydown="onlyNumberKey"
|
||||
@wheel="handleAFineWheel('delta', $event)"
|
||||
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-xs font-mono text-center role-text-tertiary">
|
||||
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-xs font-mono text-center role-text-secondary">
|
||||
<button type="button" @pointerdown.prevent="startA1A2Repeat('delta', 1)" @pointerup="stopA1A2Repeat" @pointerleave="stopA1A2Repeat" @pointercancel="stopA1A2Repeat"
|
||||
class="rounded-lg bg-slate-200 dark:bg-gray-800 hover:bg-slate-300 dark:hover:bg-gray-700 text-slate-700 dark:text-gray-300 text-sm font-bold transition-colors">+</button>
|
||||
class="stepper-button rounded-lg role-bg-secondary-soft role-text-secondary text-sm font-bold transition-colors">+</button>
|
||||
</div>
|
||||
</label>
|
||||
<button v-else type="button" @pointerdown.prevent="startAFineDrag('delta', $event)"
|
||||
class="touch-none select-none w-28 mx-auto bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center role-active-border-tertiary active:ring-2 role-active-ring-tertiary">
|
||||
class="touch-none select-none w-28 mx-auto bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center role-active-border-secondary active:ring-2 role-active-ring-secondary">
|
||||
<span class="text-[9px] text-slate-400 block font-bold mb-1">δ</span>
|
||||
<span class="block max-w-full truncate text-base font-mono font-bold role-text-tertiary" :title="formatFineCoeff(currentDelta)">{{ formatFineCoeff(currentDelta) }}</span>
|
||||
<span class="block max-w-full truncate text-base font-mono font-bold role-text-secondary" :title="formatFineCoeff(currentDelta)">{{ formatFineCoeff(currentDelta) }}</span>
|
||||
</button>
|
||||
<label v-if="!isTouchInput" class="block">
|
||||
<span class="text-[9px] text-slate-400 block font-bold mb-1">r</span>
|
||||
<div class="grid grid-cols-[2rem_1fr_2rem] gap-1">
|
||||
<button type="button" @pointerdown.prevent="startA1A2Repeat('r', -1)" @pointerup="stopA1A2Repeat" @pointerleave="stopA1A2Repeat" @pointercancel="stopA1A2Repeat"
|
||||
class="rounded-lg role-bg-tertiary-soft role-hover-tertiary-soft role-text-tertiary text-sm font-bold transition-colors">-</button>
|
||||
class="stepper-button rounded-lg bg-slate-200 dark:bg-gray-800 text-slate-700 dark:text-gray-300 text-sm font-bold transition-colors">-</button>
|
||||
<input type="number" :value="formatFineCoeff(currentR)" :step="aFineStep"
|
||||
@change="setAFineDirect('r', $event.target.value)" @keydown="onlyNumberKey"
|
||||
@wheel="handleAFineWheel('r', $event)"
|
||||
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-xs font-mono text-center role-text-primary">
|
||||
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-xs font-mono text-center text-slate-700 dark:text-gray-300">
|
||||
<button type="button" @pointerdown.prevent="startA1A2Repeat('r', 1)" @pointerup="stopA1A2Repeat" @pointerleave="stopA1A2Repeat" @pointercancel="stopA1A2Repeat"
|
||||
class="rounded-lg role-bg-primary-soft role-hover-primary-soft role-text-primary text-sm font-bold transition-colors">+</button>
|
||||
class="stepper-button rounded-lg bg-slate-200 dark:bg-gray-800 text-slate-700 dark:text-gray-300 text-sm font-bold transition-colors">+</button>
|
||||
</div>
|
||||
</label>
|
||||
<button v-else type="button" @pointerdown.prevent="startAFineDrag('r', $event)"
|
||||
class="touch-none select-none w-28 mx-auto bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center role-active-border-primary active:ring-2 role-active-ring-primary">
|
||||
class="touch-none select-none w-28 mx-auto bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center active:border-slate-400 active:ring-2 active:ring-slate-400/20">
|
||||
<span class="text-[9px] text-slate-400 block font-bold mb-1">r</span>
|
||||
<span class="block max-w-full truncate text-base font-mono font-bold role-text-primary" :title="formatFineCoeff(currentR)">{{ formatFineCoeff(currentR) }}</span>
|
||||
<span class="block max-w-full truncate text-base font-mono font-bold text-slate-700 dark:text-gray-300" :title="formatFineCoeff(currentR)">{{ formatFineCoeff(currentR) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -428,11 +449,11 @@
|
||||
<div class="grid grid-cols-2 gap-2 mb-4">
|
||||
<div class="min-w-0 bg-white dark:bg-gray-800 border border-slate-100 dark:border-gray-700 rounded-lg p-2 text-center shadow-sm">
|
||||
<span class="text-[9px] text-slate-400 block font-bold mb-1">a1 = -1 - r + δ</span>
|
||||
<span class="block max-w-full truncate text-sm font-mono font-bold role-text-tertiary" :title="formatFineCoeff(currentA1)">{{ formatFineCoeff(currentA1) }}</span>
|
||||
<span class="block max-w-full truncate text-sm font-mono font-bold role-text-secondary-muted" :title="formatFineCoeff(currentA1)">{{ formatFineCoeff(currentA1) }}</span>
|
||||
</div>
|
||||
<div class="min-w-0 bg-white dark:bg-gray-800 border border-slate-100 dark:border-gray-700 rounded-lg p-2 text-center shadow-sm">
|
||||
<span class="text-[9px] text-slate-400 block font-bold mb-1">a2 = r + δ</span>
|
||||
<span class="block max-w-full truncate text-sm font-mono font-bold role-text-primary" :title="formatFineCoeff(currentA2)">{{ formatFineCoeff(currentA2) }}</span>
|
||||
<span class="block max-w-full truncate text-sm font-mono font-bold role-text-secondary-muted" :title="formatFineCoeff(currentA2)">{{ formatFineCoeff(currentA2) }}</span>
|
||||
</div>
|
||||
<div class="min-w-0 bg-white dark:bg-gray-800 border border-slate-100 dark:border-gray-700 rounded-lg p-2 text-center shadow-sm">
|
||||
<span class="text-[9px] text-slate-400 block font-bold mb-1">差值 a1-a2</span>
|
||||
@@ -457,7 +478,7 @@
|
||||
<hr class="border-slate-200 dark:border-gray-800">
|
||||
|
||||
<!-- 定點數轉換 -->
|
||||
<section>
|
||||
<section class="fixed-section">
|
||||
<h2
|
||||
class="text-sm font-semibold text-slate-500 dark:text-gray-400 uppercase tracking-wider mb-3 flex justify-between items-center">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -550,6 +571,7 @@
|
||||
</div>
|
||||
<textarea v-model="a_int_str" @keydown="onlyNumberKey($event, false)" rows="2"
|
||||
class="w-full bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-sm role-focus-primary outline-none font-mono resize-none leading-relaxed role-text-secondary"></textarea>
|
||||
</div>
|
||||
<details class="mt-3 bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-800 rounded-lg">
|
||||
<summary class="text-sm text-slate-500 dark:text-gray-400 px-3 py-3 cursor-pointer hover:text-slate-600 dark:hover:text-gray-300">
|
||||
a1/a2 步階微調</summary>
|
||||
@@ -573,13 +595,13 @@
|
||||
<span class="text-[9px] text-slate-400 block font-bold mb-1">δ</span>
|
||||
<div class="grid grid-cols-[2rem_1fr_2rem] gap-1">
|
||||
<button type="button" @pointerdown.prevent="startFixedA1A2Repeat('delta', -1)" @pointerup="stopFixedA1A2Repeat" @pointerleave="stopFixedA1A2Repeat" @pointercancel="stopFixedA1A2Repeat"
|
||||
class="rounded-lg role-bg-secondary-soft role-hover-secondary-soft role-text-secondary text-sm font-bold transition-colors">-</button>
|
||||
class="stepper-button rounded-lg role-bg-secondary-soft role-text-secondary text-sm font-bold transition-colors">-</button>
|
||||
<input type="number" :value="formatFixedFineCoeff(fixedCurrentDelta)" :step="fixedAFineStep"
|
||||
@change="setFixedAFineDirect('delta', $event.target.value)" @keydown="onlyNumberKey"
|
||||
@wheel="handleFixedAFineWheel('delta', $event)"
|
||||
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-xs font-mono text-center role-text-secondary">
|
||||
<button type="button" @pointerdown.prevent="startFixedA1A2Repeat('delta', 1)" @pointerup="stopFixedA1A2Repeat" @pointerleave="stopFixedA1A2Repeat" @pointercancel="stopFixedA1A2Repeat"
|
||||
class="rounded-lg role-bg-secondary-soft role-hover-secondary-soft role-text-secondary text-sm font-bold transition-colors">+</button>
|
||||
class="stepper-button rounded-lg role-bg-secondary-soft role-text-secondary text-sm font-bold transition-colors">+</button>
|
||||
</div>
|
||||
</label>
|
||||
<button v-else type="button" @pointerdown.prevent="startFixedAFineDrag('delta', $event)"
|
||||
@@ -591,13 +613,13 @@
|
||||
<span class="text-[9px] text-slate-400 block font-bold mb-1">r</span>
|
||||
<div class="grid grid-cols-[2rem_1fr_2rem] gap-1">
|
||||
<button type="button" @pointerdown.prevent="startFixedA1A2Repeat('r', -1)" @pointerup="stopFixedA1A2Repeat" @pointerleave="stopFixedA1A2Repeat" @pointercancel="stopFixedA1A2Repeat"
|
||||
class="rounded-lg bg-slate-200 dark:bg-gray-800 hover:bg-slate-300 dark:hover:bg-gray-700 text-slate-700 dark:text-gray-300 text-sm font-bold transition-colors">-</button>
|
||||
class="stepper-button rounded-lg bg-slate-200 dark:bg-gray-800 text-slate-700 dark:text-gray-300 text-sm font-bold transition-colors">-</button>
|
||||
<input type="number" :value="formatFixedFineCoeff(fixedCurrentR)" :step="fixedAFineStep"
|
||||
@change="setFixedAFineDirect('r', $event.target.value)" @keydown="onlyNumberKey"
|
||||
@wheel="handleFixedAFineWheel('r', $event)"
|
||||
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-xs font-mono text-center text-slate-700 dark:text-gray-300">
|
||||
<button type="button" @pointerdown.prevent="startFixedA1A2Repeat('r', 1)" @pointerup="stopFixedA1A2Repeat" @pointerleave="stopFixedA1A2Repeat" @pointercancel="stopFixedA1A2Repeat"
|
||||
class="rounded-lg bg-slate-200 dark:bg-gray-800 hover:bg-slate-300 dark:hover:bg-gray-700 text-slate-700 dark:text-gray-300 text-sm font-bold transition-colors">+</button>
|
||||
class="stepper-button rounded-lg bg-slate-200 dark:bg-gray-800 text-slate-700 dark:text-gray-300 text-sm font-bold transition-colors">+</button>
|
||||
</div>
|
||||
</label>
|
||||
<button v-else type="button" @pointerdown.prevent="startFixedAFineDrag('r', $event)"
|
||||
@@ -633,7 +655,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[10px] text-slate-400 dark:text-gray-500 mt-3 italic">註:b 係數乘以 2^{{ shiftBitsB }},a
|
||||
係數乘以 2^{{ shiftBitsA }} 後四捨五入,可用於定點數 DSP 實作。</p>
|
||||
@@ -657,12 +678,12 @@
|
||||
|
||||
<!-- Bode Plot -->
|
||||
<div
|
||||
class="bg-white dark:bg-dark lg:rounded-xl border-b lg:border border-slate-100 lg:border-slate-200 dark:border-gray-800 lg:dark:border-gray-800 lg:shadow-md p-0 lg:p-1 flex-shrink-0 relative transition-colors duration-300">
|
||||
class="chart-panel bg-white dark:bg-dark lg:rounded-xl border-b lg:border border-slate-100 lg:border-slate-200 dark:border-gray-800 lg:dark:border-gray-800 lg:shadow-md p-0 lg:p-1 flex-shrink-0 relative transition-colors duration-300">
|
||||
<div
|
||||
class="hidden lg:block absolute inset-0 bg-gradient-to-br role-surface-gradient pointer-events-none">
|
||||
</div>
|
||||
<div
|
||||
class="px-5 py-4 border-b border-slate-100 dark:border-gray-800 flex justify-between items-center bg-white dark:bg-dark backdrop-blur-sm z-10 relative">
|
||||
class="chart-panel-header px-5 py-4 border-b border-slate-100 dark:border-gray-800 flex justify-between items-center bg-white dark:bg-dark backdrop-blur-sm z-10 relative">
|
||||
<h3 class="font-bold text-slate-800 dark:text-gray-200 tracking-wide flex items-center gap-2">
|
||||
<svg class="w-5 h-5 role-text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
@@ -676,7 +697,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- 只有圖表繪圖區可水平滾動,卡片外框不動 -->
|
||||
<div class="overflow-x-auto bg-white dark:bg-dark">
|
||||
<div class="chart-panel-body overflow-x-auto bg-white dark:bg-dark">
|
||||
<div id="bodePlot" class="min-w-[580px] w-full h-[800px] z-10 relative bg-white dark:bg-dark">
|
||||
</div>
|
||||
</div>
|
||||
@@ -684,9 +705,9 @@
|
||||
|
||||
<!-- 時域分析 -->
|
||||
<div
|
||||
class="bg-white dark:bg-white lg:dark:bg-dark lg:rounded-xl border-b lg:border border-slate-100 lg:border-slate-200 dark:border-gray-100 lg:dark:border-gray-800 lg:shadow-md p-0 lg:p-1 flex-shrink-0 relative transition-colors duration-300">
|
||||
class="chart-panel bg-white dark:bg-white lg:dark:bg-dark lg:rounded-xl border-b lg:border border-slate-100 lg:border-slate-200 dark:border-gray-100 lg:dark:border-gray-800 lg:shadow-md p-0 lg:p-1 flex-shrink-0 relative transition-colors duration-300">
|
||||
<div
|
||||
class="px-5 py-4 border-b border-slate-100 dark:border-gray-800 bg-white dark:bg-dark backdrop-blur-sm z-10 relative">
|
||||
class="chart-panel-header px-5 py-4 border-b border-slate-100 dark:border-gray-800 bg-white dark:bg-dark backdrop-blur-sm z-10 relative">
|
||||
<h3 class="font-bold text-slate-800 dark:text-gray-200 tracking-wide flex items-center gap-2">
|
||||
<svg class="w-5 h-5 role-text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
|
||||
+112
-26
@@ -61,11 +61,16 @@ export default {
|
||||
fixedAFineStep: 1,
|
||||
fixedAFineDrag: null,
|
||||
fixedAFineRepeatDelayTimer: null,
|
||||
fixedAFineRepeatTimer: null,
|
||||
isTouchInput: false,
|
||||
activeCoeffAdjustment: null,
|
||||
}
|
||||
},
|
||||
fixedAFineRepeatTimer: null,
|
||||
isTouchInput: false,
|
||||
activeCoeffAdjustment: null,
|
||||
mcuPorts: [],
|
||||
mcuPort: '',
|
||||
loadingMCUPorts: false,
|
||||
writingMCU: false,
|
||||
mcuStatus: '',
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
maxLogB() {
|
||||
@@ -204,13 +209,14 @@ export default {
|
||||
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
this.isDarkMode = savedTheme ? savedTheme === 'dark' : true;
|
||||
if (this.isDarkMode) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
this.applyFilterDesign();
|
||||
},
|
||||
if (this.isDarkMode) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
this.fetchMCUPorts();
|
||||
this.applyFilterDesign();
|
||||
},
|
||||
beforeUnmount() {
|
||||
window.removeEventListener('pointerdown', this.rememberPointerType);
|
||||
this.stopA1A2Repeat();
|
||||
@@ -548,16 +554,16 @@ export default {
|
||||
} else if (mode === 'r') {
|
||||
this.setDeltaR(this.currentDelta, this.currentR + step);
|
||||
}
|
||||
},
|
||||
startA1A2Repeat(mode, direction) {
|
||||
this.stopA1A2Repeat();
|
||||
this.adjustA1A2(mode, direction);
|
||||
this.aFineRepeatDelayTimer = setTimeout(() => {
|
||||
this.aFineRepeatTimer = setInterval(() => {
|
||||
this.adjustA1A2(mode, direction);
|
||||
}, 80);
|
||||
}, 350);
|
||||
},
|
||||
},
|
||||
startA1A2Repeat(mode, direction) {
|
||||
this.stopA1A2Repeat();
|
||||
this.adjustAFineValue(mode, direction);
|
||||
this.aFineRepeatDelayTimer = setTimeout(() => {
|
||||
this.aFineRepeatTimer = setInterval(() => {
|
||||
this.adjustAFineValue(mode, direction);
|
||||
}, 80);
|
||||
}, 350);
|
||||
},
|
||||
stopA1A2Repeat() {
|
||||
clearTimeout(this.aFineRepeatDelayTimer);
|
||||
clearInterval(this.aFineRepeatTimer);
|
||||
@@ -1101,7 +1107,87 @@ export default {
|
||||
a.href = url; a.download = 'filtered_output.csv';
|
||||
document.body.appendChild(a); a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (e) { this.globalError = e.message; }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) { this.globalError = e.message; }
|
||||
},
|
||||
|
||||
async fetchMCUPorts() {
|
||||
this.loadingMCUPorts = true;
|
||||
this.mcuStatus = '';
|
||||
try {
|
||||
const res = await fetch('/api/mcu/ports');
|
||||
if (!res.ok) {
|
||||
let err = { detail: '無法取得 MCU 串口清單' };
|
||||
try {
|
||||
err = await res.json();
|
||||
} catch {
|
||||
// Keep the default message when the server did not return JSON.
|
||||
}
|
||||
throw new Error(err.detail || '無法取得 MCU 串口清單');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
this.mcuPorts = Array.isArray(data.ports) ? data.ports : [];
|
||||
const devices = this.mcuPorts.map(port => port.device);
|
||||
if (!this.mcuPort || !devices.includes(this.mcuPort)) {
|
||||
if (data.default_port && devices.includes(data.default_port)) {
|
||||
this.mcuPort = data.default_port;
|
||||
} else if (devices.length > 0) {
|
||||
this.mcuPort = devices[0];
|
||||
} else {
|
||||
this.mcuPort = data.default_port || '';
|
||||
}
|
||||
}
|
||||
this.mcuStatus = devices.length > 0 ? `已偵測 ${devices.length} 個串口` : '未偵測到串口,將使用預設串口';
|
||||
} catch (e) {
|
||||
this.mcuPorts = [];
|
||||
this.mcuStatus = e.message;
|
||||
} finally {
|
||||
this.loadingMCUPorts = false;
|
||||
}
|
||||
},
|
||||
|
||||
mcuCommandFromFixedCoeffs() {
|
||||
const bVals = this.fixedPointCoeffs.b.map(item => item.val);
|
||||
const aVals = this.fixedPointCoeffs.a.map(item => item.val);
|
||||
const b0 = bVals[0] ?? 0;
|
||||
const b1 = bVals[1] ?? 0;
|
||||
const b2 = bVals[2] ?? 0;
|
||||
const a1 = aVals[1] ?? 0;
|
||||
const a2 = aVals[2] ?? 0;
|
||||
return `bodeplot=${b0},${b1},${b2},${a1},${a2}`;
|
||||
},
|
||||
|
||||
async writeToMCU() {
|
||||
this.writingMCU = true;
|
||||
this.globalError = null;
|
||||
this.mcuStatus = '';
|
||||
const command = this.mcuCommandFromFixedCoeffs();
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/mcu/write', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command, port: this.mcuPort || null }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let err = { detail: 'MCU 寫入失敗' };
|
||||
try {
|
||||
err = await res.json();
|
||||
} catch {
|
||||
// Keep the default message when the server did not return JSON.
|
||||
}
|
||||
throw new Error(err.detail || 'MCU 寫入失敗');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
this.mcuStatus = `已送出 ${data.command} 到 ${data.port}`;
|
||||
} catch (e) {
|
||||
this.globalError = e.message;
|
||||
this.mcuStatus = e.message;
|
||||
} finally {
|
||||
this.writingMCU = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1491
-754
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
@@ -9,11 +9,13 @@ from dea_api import (
|
||||
BodeCompareParams,
|
||||
BodeParams,
|
||||
DesignParams,
|
||||
MCUWriteParams,
|
||||
add_security_headers,
|
||||
calculate_bode,
|
||||
calculate_bode_compare,
|
||||
design_filter,
|
||||
filter_csv,
|
||||
write_mcu_command,
|
||||
)
|
||||
|
||||
|
||||
@@ -86,6 +88,15 @@ class DeaApiTest(unittest.TestCase):
|
||||
self.assertEqual(len(body["freq"]), len(body["fixed"]["mag"]))
|
||||
self.assertEqual(len(body["ideal"]["phase"]), len(body["fixed"]["phase"]))
|
||||
|
||||
def test_mcu_write_rejects_invalid_command_format(self):
|
||||
try:
|
||||
write_mcu_command(MCUWriteParams(command="hello"))
|
||||
except HTTPException as exc:
|
||||
self.assertEqual(exc.status_code, 400)
|
||||
self.assertIn("MCU", exc.detail)
|
||||
else:
|
||||
raise AssertionError("Expected invalid MCU command validation to fail")
|
||||
|
||||
|
||||
def test_filter_downsamples_plot_response_for_large_csv(self):
|
||||
rows = ["value"] + [str(i) for i in range(6001)]
|
||||
|
||||
Reference in New Issue
Block a user