23 Commits

Author SHA1 Message Date
ws50529 a36446ce92 Merge branch 'dev/uart_terminal' 2026-06-05 10:51:03 +08:00
roy01 750bea70ec feat: enhance download functionality with selectable log types and command history management 2026-06-05 10:39:17 +08:00
roy01 7991f1fcba feat: implement multi-session support with sidebar management and dynamic terminal switching 2026-06-05 10:39:13 +08:00
roy01 b99af5f734 feat: implement single-port UART 2026-06-05 10:39:05 +08:00
ws50529 5413062eff refactor: implement collapsable sidebar with integrated stage toggle controls 2026-05-27 12:00:42 +08:00
ws50529 6766df8f5e feat: implement drag-and-drop reordering for cascade stages and update UI plot labels 2026-05-27 10:16:36 +08:00
ws50529 b8ec600673 feat: integrate cascade workflow 2026-05-27 08:38:40 +08:00
ws50529 63382f6cf5 Merge origin/cascadedfilters as integrated 2026-05-26 15:34:36 +08:00
ws50529 3edc4702f3 feat: integrate cascade filter workflow 2026-05-26 15:34:24 +08:00
pchang718 8caa8918d0 feat: implement dynamic time-domain LOD zoom, locked axis multipliers, and clean 4-column CSV export 2026-05-26 14:48:04 +08:00
pchang718 0140b0495c feat: 優化時域圖表X軸顯示時間單位,並新增步階訊號測試腳本 2026-05-22 14:18:48 +08:00
pchang718 9182220c1d feat: implement multi-stage cascaded biquad filters support 2026-05-21 22:35:24 +08:00
ws50529 075f20dd81 chore: update .gitignore 2026-05-21 09:48:46 +08:00
ws50529 2f392b13c6 feat: fix CSV upload state and UI indicators to prevent duplicate processing 2026-05-20 17:35:10 +08:00
ws50529 345b4a789a feat: add VS Code workspace configuration file 2026-05-20 13:47:52 +08:00
ws50529 8009a0dd78 Merge remote-tracking branch 'origin/dev-v2-integretimedomain' into integrate/dev-v2-into-main 2026-05-20 13:44:31 +08:00
pchang718 d9841fe7bc docs: update ARCHITECTURE.md deployment notes for HTTPS proxy and URLs 2026-05-17 14:26:26 +08:00
pchang718 c259f91380 docs: update PROJECT_STATUS.md with second-phase refactoring details 2026-05-17 14:23:51 +08:00
pchang718 c4b4a4ced0 docs & refactor: update fixed-point integer lfilter specification, variable naming, A0 normalization docs, and 32-bit MCU C implementation 2026-05-17 14:21:02 +08:00
pchang718 e9dba45626 chore: update branch name in project status 2026-05-17 01:00:46 +08:00
pchang718 3c3bda7daa feat: complete fixed-point Rounding simulation, fix Vite proxy, and update documentation 2026-05-17 00:58:19 +08:00
ws50529 2762d0da86 chore: update code 2026-05-15 16:12:47 +08:00
ws50529 da2cbfe8da refactor: update UI components theme color 2026-05-15 14:50:54 +08:00
36 changed files with 7178 additions and 502279 deletions
+11
View File
@@ -37,8 +37,19 @@ Thumbs.db
ehthumbs.db
Desktop.ini
# Data and Reports
*.csv
*.png
!static/logo.png
*.docx
# Agent Skills
.agents/
.agents
# Local Tracker & AI Workspace
.scratch/
.gemini/
# SSL certs (private keys)
certs/*.pem
-39
View File
@@ -1,39 +0,0 @@
# Difference Equation Analyzer (DEA) - Architecture & Functionality
## Overview
DEA is a specialized tool for designing, visualizing, and validating discrete-time filters. It bridges the gap between theoretical filter design (floating-point) and hardware implementation (fixed-point/MCU).
## 🏗 System Architecture
### 1. Backend (Python 3.12 + FastAPI)
The backend is responsible for heavy mathematical computations and data processing.
- **API Entry (`dea_api.py`)**: Routes requests to appropriate modules and serves the Vue.js frontend from `static/`.
- **Computation Engine (`dea/`)**:
- `filter_design.py`: Uses `scipy.signal` to calculate coefficients for various filter topologies.
- `bode.py`: Computes magnitude and phase response. Supports comparing "Ideal" (float) vs "Fixed" (quantized) responses.
- `csv_processing.py`: Processes time-domain CSV data. It applies the current filter to the signal and downsamples the result for efficient visualization.
- `validation.py`: Protects against unstable filter designs and invalid inputs.
### 2. Frontend (Vue 3 + Vite + Tailwind)
A reactive "Single Page Application" (SPA) approach.
- **Reactive Logic (`src/app-options.js`)**:
- Manages the state of all coefficients.
- Implements "Fine-tuning" sliders that allow real-time adjustment of poles/zeros.
- Handles Fixed-Point conversion logic (Q-format shifting).
- **UI Components (`src/App.vue`)**:
- **Control Sidebar**: Interactive inputs for all filter parameters.
- **Visualization**: Dual-plot system using Plotly.js for Bode plots and Time-domain waveforms.
- **Hardware Bridge**: Uses the **Web Serial API** to send commands directly to connected MCUs (requires HTTPS).
## 🚀 Execution Flow
1. **Design**: User selects a filter type or enters coefficients manually.
2. **Quantize**: User adjusts Q-format bits to see how quantization affects the frequency response.
3. **Verify**: User uploads a CSV to see how the filter behaves with real-world signals.
4. **Deploy**: User clicks "Write to MCU" to send the coefficients to their hardware.
## 🛠 Tech Stack
- **Backend**: FastAPI, NumPy, SciPy, Pandas, Uvicorn.
- **Frontend**: Vue 3, Vite, Tailwind CSS, Plotly.js.
- **Security**: Restricted to LAN/Loopback; implemented security headers and HTTPS support.
-48
View File
@@ -1,48 +0,0 @@
# DSP Bode Plot 專案進度總結 (2026-05-15)
## 1. 核心功能優化 (DSP & 定點數運算)
- **細部微調 (Fine-tuning)**:實施了基於 $\delta$ (Delta) 與 $r$ (Radius) 的參數化微調。
- **定點數精確度修正**:採用「兩倍值整數運算」邏輯,解決了 $0.5$ 步階在浮點數圓整時產生的「按鍵沒反應」Bug。
- **防止漂移 (Oscillation Logic)**:針對數位微調,實施了奇偶震盪平衡邏輯。當調整 $\delta$ 導致 $r$ 必須隨動時,採用震盪補償防止 $r$ 產生單向累積誤差。
- **參數連動策略**:在 $0.5$ 步階時,採取「優先滿足目標變動」原則。若調整 $\delta$,則 $r$ 隨動;反之亦然。
## 2. UI/UX 介面調整
- **側邊欄優化**
- 將 $r$ 與 $\delta$ 的調整區域改為**垂直堆疊**,適應 21rem 的窄邊欄。
- 移除冗餘的 $a1, a2$ 預覽方塊,僅保留帶有數學公式標籤的 $r, \delta$ 預覽。
- **步階序列**:數位微調步階改為自訂序列:`[0.5, 1, 2, 4, 8, 32, 256, 1024, 4096, 16384, 65536]`
- **微調量顯示**:統一使用 `±` 符號並帶空格(例如 `± 0.5`)。
- **智能禁用**:當數位步階為 $0.5$ 時,自動禁用 $r$ 的控制項,確保以 $\delta$ 為主導。
## 3. 繪圖系統 (Bode Plot)
- **幅值圖 (Magnitude)**
- 初始範圍:基於 $systemGain \times 3$,涵蓋 $70 \text{ dB}$。
- 保留手動彈性:設定為 `fixedrange: false`,支援滑鼠縮放與 Auto Scale。
- 狀態保持:使用 `uirevision`,確保微調參數時不會跳回初始縮放。
- **相位圖 (Phase)**
- 絕對鎖死:固定範圍為 $[-180^\circ, 180^\circ]$。
- 禁止縮放:設定為 `fixedrange: true`,提供穩定的絕對參考。
- **座標軸連動**:幅值圖與相位圖的 **X 軸(頻率)已完全同步**。不論在哪一張圖進行橫向縮放,兩者都會連動。
## 4. 數學公式參考
- **理想空間**
- $a1 = -1 - r + \delta$
- $a2 = r + \delta$
- **定點數空間 (Q14 為例)**
- $a0 = 2^{14} = 16384$
- $a1 = -a0 - r + \delta$
- $a2 = r + \delta$
- $2\delta = a1 + a2 + a0$
- $2r = a2 - a1 - a0$
## 5. 目前 Git 狀態
- **當前分支**`dev-ui-fix-20260515` (已於 2026-05-15 17:46 推送至 GitLab)。
- **同步說明**:此版本已安全儲存在獨立分支,不影響 `main` 分支同事的使用。
- **同事試用指令**
```bash
git fetch
git checkout dev-ui-fix-20260515
```
---
*Last Updated: 2026-05-15 17:47*
Executable → Regular
+316 -122
View File
@@ -1,109 +1,326 @@
# Difference Equation Analyzer
Difference Equation Analyzer 是一個用來設計、檢視與驗證離散時間濾波器的工具。後端使用 FastAPI、NumPy、SciPy 與 Pandas;前端使用 Vue 3、Vite、Tailwind CSS 與 Plotly
Difference Equation Analyzer 是用來設計、檢視與驗證離散時間濾波器的工具。它同時支援浮點係數、fixed-point 係數、時域 CSV 驗證,以及透過 Web Serial 將係數寫入 MCU
主要功能:
Backend 使用 FastAPI、NumPy、SciPy 與 PandasFrontend 使用 Vue 3、Vite、Tailwind CSS 與 Plotly。
## Features
- 設計 Lowpass、Highpass、Bandpass、Notch、1P1Z、2P1Z、2P2Z、PID、SOGI 濾波器。
- 比較浮點係數fixed-point 係數的頻率響應。
- 手動微調 b/a 係數、a1/a2 組合(支援 δ/r 參數微調)、Q 格式位元數與 fixed-point 整數係數
- 透過瀏覽器 Web Serial API 直接將 fixed-point 係數命令送到 MCU(需透過 HTTPS 連線)
- 上傳 CSV 時域資料,套用目前差分方程並繪製輸入/輸出波形
- 匯出包含濾波輸出欄位的 CSV。
- 支援深色模式 (Dark Mode) 與響應式 UI
- 比較 Ideal Float Fixed-Point 的 Bode 頻率響應。
- 支援多個 cascade stages 的頻率響應與時域模擬,並可個別啟用或 bypass
- 手動微調 b/a 係數、a1/a2、δ/r、Q-format 位元數與 fixed-point 整數係數
- 上傳 CSV 時域資料,繪製輸入、Ideal Float 輸出與 Mimic Integer 輸出
- 匯出包含理想浮點與定點整數模擬輸出的 CSVcascade workflow 可匯出乾淨的 4 欄 CSV。
- 使用瀏覽器 Web Serial API 將 fixed-point 係數寫入 MCU
- 時域圖表支援 zoom 後以 `start_idx` / `end_idx` 向後端重取可視區間資料,保留 IIR 完整歷史狀態。
- 支援 Dark Mode 與響應式 UI。
## 專案結構
## Quick Start
```bash
cd /home/wisetop/diff-eq-analyzer
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
npm install
npm run build
bash certs/generate_cert.sh
.venv/bin/uvicorn dea_api:app --host 0.0.0.0 --port 8000 --ssl-keyfile certs/key.pem --ssl-certfile certs/cert.pem
```
開啟:
```text
https://192.168.2.58:8000/ui/
```
本專案固定從 FastAPI 提供的 HTTPS UI 使用。不要用 Vite dev server URL 測試 CSV 或 MCU 功能,否則 `/api/...` 可能會回 `Not Found`
自簽憑證會讓瀏覽器顯示安全性警告,第一次開啟時需手動信任或選擇繼續前往。
## Project Layout
```text
dea_api.py FastAPI 入口與 HTTP route
dea/ 後端核心模組
bode.py 頻率響應計算
config.py 限制值與伺服器端 serial 設定
csv_processing.py CSV 取、驗證、濾波與匯出
csv_processing.py CSV 取、驗證、單濾波器/cascade 濾波與匯出
filter_design.py 濾波器設計
mcu.py MCU serial port 列表與命令寫入 (後端備用支援)
mcu.py MCU serial port 列表與命令寫入
schemas.py Pydantic request schema
security.py LAN 存取限制與安全標頭
validation.py 係數、頻率與有限值驗證
src/ Vue 3 前端原始碼
certs/ HTTPS 自簽憑證與產生腳本
static/ FastAPI 對外提供的靜態 UI
static/build/ Vite build 輸出
static/build/ Vite production build 輸出
tests/ unittest 測試
dea.service systemd service 範例
chirp_signal.csv CSV 上傳測試範例
```
## 安裝
## Architecture
在目標主機上建立 Python venv 並安裝依賴:
| Layer | Responsibility |
| --- | --- |
| FastAPI | 提供 API、serve `/ui/`、套用 LAN 存取限制與安全標頭 |
| `dea/` | 濾波器設計、單濾波器/cascade Bode plot、CSV 處理、fixed-point simulation、驗證 |
| Vue UI | 管理係數、Q-format、CSV workflow、Plotly 圖表與 Web Serial 狀態 |
| Vite build | 將 `src/` 打包到 `static/build/`,由 FastAPI 在 production 提供 |
```bash
cd /home/wisetop/diff-eq-analyzer
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
CSV workflow
1. 前端選擇 CSV。
2. 前端只讀取檔案前段內容建立欄位與 preview,避免為了 preview 把大型 CSV 全部載入瀏覽器記憶體。
3. 前端呼叫 `POST /api/csv/upload`,後端將檔案暫存並回傳 `file_id`
4. 上傳完成前,filter 按鈕會保持 disabled,避免尚未取得 `file_id` 就送出處理。
5. 後續 `POST /api/filter``POST /api/filter/download` 使用 `file_id`,避免反覆傳大檔。
CSV 預設暫存在:
```text
/tmp/diff-eq-analyzer-csv/
```
安裝前端依賴並建置
可用環境變數覆寫
```bash
DEA_TEMP_CSV_DIR=/path/to/csv-cache
```
暫存 CSV 預設保留 24 小時。可用環境變數調整,設為 `0` 可停用清理:
```bash
DEA_TEMP_CSV_TTL_SECONDS=86400
```
## Usage
### Filter Design
在左側控制面板選擇濾波器類型並調整參數。系統會更新 b/a 係數,並繪製 Ideal Float 與 Fixed-Point 的頻率響應。
支援濾波器:
```text
Lowpass, Highpass, Bandpass, Notch,
1P1Z, 2P1Z, 2P2Z,
PID, SOGI-Alpha, SOGI-Beta
```
### a1/a2 Fine Tuning
針對二階濾波器,UI 支援 δ/r 參數化微調。
| Mode | Formula |
| --- | --- |
| Float | `a1 = -1 - r`, `a2 = r + δ` |
| Fixed | `a1 = -a0 - r`, `a2 = r + δ` |
反推關係:
| Mode | δ | r |
| --- | --- | --- |
| Float | `a1 + a2 + 1` | `-1 - a1` |
| Fixed | `a1 + a2 + a0` | `-a0 - a1` |
桌機可用 Shift + 滑鼠滾輪微調;觸控裝置可按住數值面板上下拖曳。
### Cascade Stages
UI 以 cascade stage accordion 管理多段濾波器。每個 stage 顯示序號、濾波器型態、Active/Bypass 狀態、係數摘要與 Q-format 摘要;可用上移/下移調整串接順序。
完整的濾波器設計、浮點係數、fixed-point 係數與 MCU 寫值控制只會出現在目前選取的 active stage accordion 內。同時間只有一個 active stage 承載完整 editor,避免多份表單同時修改同一組全域 active-stage state。展開非 active stage 時,UI 會先切換該 stage 為 active stage。
送出 Bode 或 CSV 時,前端會先同步目前 active stage,再把所有 stages 依畫面順序打包送到後端。單一 stage 時仍等同 legacy 單濾波器 workflow。
後端相容兩種路徑:
| Mode | API Behavior |
| --- | --- |
| Legacy single filter | `/api/bode``/api/bode/compare``/api/filter` 仍接受原本的 `b/a/b_int/a_int` 參數 |
| Cascade | `/api/bode/compare_cascade` 接受 stages`/api/filter``/api/filter/download` 可接受 `stages` form payload |
Cascade 設計刻意不直接 merge `origin/cascadedfilters` 的整包檔案,而是在 `main` 上重作功能,避免帶入大型 CSV、暫存檔、log、舊文件與 build noise。
### Fixed-Point Time Simulation
時域分析同時計算兩條路徑:
| Path | Description |
| --- | --- |
| Ideal Float | 使用 SciPy `signal.lfilter()` 作為理想浮點參考 |
| Mimic Integer | 將輸入與係數量化為整數,模擬 MCU / DSP fixed-point 差分方程 |
Q-format 參數:
| Parameter | Meaning |
| --- | --- |
| `shift_in` | 輸入訊號量化位元數 |
| `shift_out` | 輸出訊號量化位元數 |
| `shift_b` | 前饋 b 係數量化位元數 |
| `shift_a` | 回授 a 係數量化位元數 |
| `use_round` | 右移時使用 Floor (SRA) 或 Round (+0.5) |
整數模擬採高精度狀態變數架構:前饋項保留在 `Q_in+b` 精度,回授項除以 `A0` 對齊,再於最終輸出依 `Q_out` 縮放。
### Time-Domain LOD Zoom
時域圖表使用 Plotly zoom / pan 時,前端會把目前可視時間範圍換算成 sample index,透過 `/api/filter` 傳送 `start_idx``end_idx`。後端仍先用完整訊號計算 IIR 輸出,保留濾波器歷史狀態正確性,再只切出可視區間並 downsample 回傳,避免 zoom in 後只能看到粗略的全域降採樣資料。
時間軸單位會在首次完整載入時鎖定為 `s``ms``μs`,避免 zoom 後重新換單位造成 Plotly `uirevision` 座標錯位。前端也使用 `isRedrawingTimePlot` 避免 `Plotly.react()` 觸發 relayout callback 造成重繪迴圈。
### CSV Workflow
限制與行為:
| Item | Value |
| --- | --- |
| 檔案大小上限 | 300 MB |
| 時域運算列數上限 | 1,200,000 rows |
| 圖表點數上限 | 5000 points |
| 格式要求 | 必須有標題列與至少一筆資料 |
若第一欄像 `time``t``sec``x``index`,前端會預設選第二欄作為訊號欄位。
首頁會自動嘗試載入 `.scratch/examples/preset_signals.csv` 作為 preset waveform;若檔案不存在,UI 仍可正常手動上傳 CSV。preset 路徑可用環境變數覆寫:
```bash
DEA_PRESET_CSV_PATH=/path/to/preset_signals.csv
```
範例 CSV 不再放在 Git 追蹤路徑。大型範例請放在本機 ignored 目錄:
```text
.scratch/examples/
```
常用範例欄位格式:
```text
time,value
```
可從歷史 commit 匯出範例到本機 ignored 路徑使用:
```bash
mkdir -p .scratch/examples
git show 075f20d:chirp_signal.csv > .scratch/examples/chirp_signal.csv
git show 0140b049:chirp_signal_small_s.csv > .scratch/examples/chirp_signal_small_s.csv
git show 0140b049:step_signal.csv > .scratch/examples/step_signal.csv
git show 8caa8918:static/preset_signals.csv > .scratch/examples/preset_signals.csv
```
`.scratch/` 已被 `.gitignore` 忽略,適合放大型 CSV、手動測試資料與暫存分析檔。
### CSV Export
`/api/filter/download` 預設保留 legacy 行為:輸出原始 CSV 欄位,並新增 `[欄位]_filtered_ideal``[欄位]_filtered_fixed`
若前端傳送 `compact=true`,後端會輸出專供分析用的 4 欄 CSV:
| Column | Meaning |
| --- | --- |
| `Time (s)` | 由 sample index 與前端傳入的 `fs` 計算 |
| 原輸入欄位 | 選定訊號欄位原始值 |
| `[欄位]_filtered_ideal` | Ideal Float 輸出 |
| `[欄位]_filtered_fixed` | Mimic Integer / fixed-point 輸出 |
### MCU Web Serial
Web Serial 是主要 MCU 寫入方式,伺服器不需要 serial 權限。
| Action | Description |
| --- | --- |
| 連線 | 點擊「選擇連接埠並連線」,在瀏覽器彈窗選擇 MCU |
| 寫值 | 點擊「寫值到 MCU」 |
| 限制 | Chrome / Edge,且必須透過 HTTPS 或 localhost |
寫入命令格式:
```text
bodeplot=b0,b1,b2,a1,a2
```
## API
| Method | Endpoint | Purpose |
| --- | --- | --- |
| `POST` | `/api/design` | 依濾波器參數產生 b/a 係數 |
| `POST` | `/api/bode` | 計算單組 b/a 頻率響應 |
| `POST` | `/api/bode/compare` | 比較單一濾波器 Ideal Float 與 Fixed-Point 頻率響應 |
| `POST` | `/api/bode/compare_cascade` | 比較 cascade stages 的 Ideal Float 與 Fixed-Point 頻率響應 |
| `GET` | `/api/csv/preset` | 讀取本機 ignored preset CSV metadata,供首頁自動載入 |
| `POST` | `/api/csv/upload` | 上傳 CSV 並回傳 `file_id` |
| `POST` | `/api/filter` | 使用 `file_id` 回傳單濾波器或 cascade 時域圖表資料;可傳 `start_idx/end_idx` 做 LOD zoom |
| `POST` | `/api/filter/download` | 使用 `file_id` 匯出結果 CSV;可傳 `compact=true` 匯出 4 欄 CSV |
| `GET` | `/api/mcu/ports` | 列出伺服器端 serial ports |
| `POST` | `/api/mcu/write` | 後端模式寫入 MCU 命令 |
## Development
### Frontend
```bash
npm install
npm run test:frontend
npm run build
```
## HTTPS 設定
為了支援 Web Serial API(瀏覽器直接存取本機連接埠),後端必須透過 HTTPS 連線提供服務。
1. **產生自簽憑證**
```bash
bash certs/generate_cert.sh
```
這會在 `certs/` 目錄下產生 `key.pem` 與 `cert.pem`。
2. **信任憑證**
由於是自簽憑證,瀏覽器會顯示安全性警告。請在「進階」中選擇「繼續前往」或將其加入信任清單。
## 執行
### 手動啟動 (HTTPS)
開發模式:
```bash
npm run dev
```
CSV 與 API 流程保留 `dev-v2` 的兩段式設計:先 upload 取得 `file_id`,再用 `file_id` filter 或 download。`vite.config.js` 仍保留 `main` 的 HTTPS deployment 設定,不在 Vite 內設定 `/api` proxy。因此 `npm run dev` 只適合檢查前端畫面;CSV upload、filter API、MCU API 請固定使用:
```text
https://192.168.2.58:8000/ui/
```
### Backend
```bash
.venv/bin/pip install -r requirements.txt
.venv/bin/uvicorn dea_api:app --host 0.0.0.0 --port 8000 --ssl-keyfile certs/key.pem --ssl-certfile certs/cert.pem
```
瀏覽器開啟:`https://<server-ip>:8000/ui/` 或本機 `https://localhost:8000/ui/`
### HTTPS Certificate
根路徑 `/` 會自動導向 `/ui/`。
```bash
bash certs/generate_cert.sh
```
### 使用 systemd
會在 `certs/` 產生:
1. 檢查 `dea.service` 中的路徑是否正確(需確認 `ExecStart` 已加上 `--ssl-keyfile` 等參數)。
2. 啟用並啟動服務:
```bash
sudo cp dea.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable dea.service
sudo systemctl start dea.service
```
```text
cert.pem
key.pem
```
### systemd
```bash
sudo cp dea.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable dea.service
sudo systemctl start dea.service
```
查看狀態:
查看狀態與 Log
```bash
sudo systemctl status dea.service
sudo journalctl -u dea.service -f
```
## MCU 連接埠 (Web Serial)
### Backend Serial Fallback
本工具優先使用瀏覽器的 Web Serial API 直接與 MCU 通訊,無需伺服器端具備 serial 權限。
- **連線**:點擊 UI 上的「選擇連接埠並連線」,在瀏覽器彈出視窗中選擇正確的 MCU 裝置。
- **寫值**:點擊「寫值到 MCU」,系統會將目前定點數係數格式化為 `bodeplot=b0,b1,b2,a1,a2` 並送出。
- **限制**:需使用 Chrome 或 Edge 瀏覽器,且必須透過 HTTPS (或 localhost) 連線。
## 後端 MCU Serial 設定 (備用)
若無法使用 Web Serial,後端仍保留透過伺服器端實體連線寫入的 API。預設值在 `dea/config.py`,也可用環境變數覆蓋:
若無法使用 Web Serial,可改用後端 serial API。環境變數:
```bash
MCU_SERIAL_PORT=/dev/ttyUSB2
@@ -111,7 +328,7 @@ MCU_BAUD_RATE=115200
MCU_WRITE_TIMEOUT=1.0
```
若使用 systemd可在 `dea.service` 的 `[Service]` 區塊加入:
systemd 可在 `[Service]` 加入:
```ini
Environment="MCU_SERIAL_PORT=/dev/ttyUSB2"
@@ -119,70 +336,27 @@ Environment="MCU_BAUD_RATE=115200"
Environment="MCU_WRITE_TIMEOUT=1.0"
```
## API
## Testing
目前主要 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
```
## a1/a2 微調 (δ/r 參數)
針對二階濾波器 (2P1Z/2P2Z),UI 提供了進階的步階微調功能:
- **δ (Delta)**:控制極點的頻率/阻尼平衡點。
- **r**:控制極點的半徑/頻寬。
- 公式:`a1 = -a0 - r + δ``a2 = r + δ` (定點數模式) 或 `a1 = -1 - r + δ``a2 = r + δ` (浮點數模式)。
- 支援透過 Shift + 滑鼠滾輪或觸控拖曳進行高精度微調。
## CSV 上傳
限制與行為:
- CSV 檔案大小上限為 32 MB。
- CSV 必須有標題列與至少一筆資料。
- 可選擇要濾波的訊號欄位;若第一欄像 `time`、`t`、`sec`、`x`、`index`,前端會預設選第二欄。
- 圖表回應最多回傳 5000 點,避免大型 CSV 讓前端過重。
- 下載 CSV 會保留完整資料,不套用圖表降採樣。
專案根目錄的 `chirp_signal.csv` 是上傳測試範例,約 19 MB、500001 行,欄位為:
```text
time,value
```
## 測試與檢查
Python 語法檢查:
Python syntax check
```bash
.venv/bin/python -m py_compile dea_api.py tests/test_dea_api.py
```
後端測試使用 Python 內建 `unittest`,不需要安裝或執行 `pytest`
Backend tests 使用 Python 內建 `unittest`
```bash
.venv/bin/python -m unittest discover -s tests
```
前端建置檢查
Frontend build check
```bash
npm run build
```
建議完整驗證順序
建議完整驗證:
```bash
.venv/bin/pip install -r requirements.txt
@@ -191,29 +365,47 @@ npm run build
sudo systemctl restart dea.service
```
## 安全與限制
## Security And Limits
- API middleware 只允許 private / loopback IP 存取。
- 回應會加上 CSP、`Referrer-Policy`、`X-Content-Type-Options: nosniff`、`X-Frame-Options: DENY`。
- CSP 的 script 來源允許本機與 Plotly CDN。
- 係數數量上限為 64。
- CSV 上限為 32 MB。
- 頻率、係數、`a[0]` 與 CSV 訊號欄位都有後端驗證。
| Item | Value |
| --- | --- |
| Network access | 只允許 private / loopback IP |
| CSV size | 300 MB |
| Time-domain rows | 1,200,000 rows |
| Plot points | 5000 points |
| Coefficients | 最多 64 個 |
## 常見問題
HTTP responses 會加上 CSP、`Referrer-Policy``X-Content-Type-Options: nosniff``X-Frame-Options: DENY`
### 為什麼無法連線連接埠?
1. 確認是否使用 HTTPS 連線。
2. 確認瀏覽器是否支援 Web Serial API (推薦使用 Chrome/Edge)。
3. 檢查 USB 傳輸線是否連接正確。
## Troubleshooting
### CSV 上傳顯示 `Not Found`
請確認目前網址是:
```text
https://192.168.2.58:8000/ui/
```
如果從 `http://localhost:5173` 或其他 Vite dev server URL 開啟,`/api/csv/upload` 會送到前端 dev server,而不是 FastAPI,因此會回 `Not Found`
### 無法連線 MCU
1. 確認使用 `https://192.168.2.58:8000/ui/`
2. 確認瀏覽器支援 Web Serial API。
3. 使用 Chrome 或 Edge。
4. 檢查 USB 線與 MCU 裝置狀態。
### 頁面沒有更新
1. 瀏覽器按 `Ctrl+F5` 強制重新整理。
2. 執行 `npm run build` 重新建置前端
3. 執行 `sudo systemctl restart dea.service`。
1. 執行 `npm run build`
2. 重啟服務:`sudo systemctl restart dea.service`
3. 瀏覽器按 `Ctrl+F5` 強制重新整理。
### systemd 顯示 `status=203/EXEC`
通常是 `.venv``uvicorn` 路徑不正確。可重建 venv
```bash
rm -rf .venv
python3 -m venv .venv
@@ -222,7 +414,9 @@ sudo systemctl restart dea.service
```
### 找不到 pytest
本專案不使用 pytest。請改用:
```bash
.venv/bin/python -m unittest discover -s tests
```
-500001
View File
File diff suppressed because it is too large Load Diff
+37 -1
View File
@@ -3,7 +3,7 @@ from fastapi import HTTPException
from scipy import signal
from .config import BODE_MAX_MULTIPLIER, BODE_POINTS
from .schemas import BodeCompareParams, BodeParams
from .schemas import BodeCascadeParams, BodeCompareParams, BodeParams
from .validation import require_finite, validate_coefficients
@@ -47,3 +47,39 @@ def calculate_bode_compare_response(params: BodeCompareParams):
"ideal": ideal,
"fixed": fixed,
}
def calculate_bode_cascade_response(params: BodeCascadeParams):
fs_val, f_eval = _frequency_axis(params.fs)
h_total_ideal = np.ones(len(f_eval), dtype=complex)
h_total_fixed = np.ones(len(f_eval), dtype=complex)
any_active = False
for i, stage in enumerate(params.stages):
if not stage.isActive:
continue
any_active = True
b_vals = validate_coefficients(stage.b, f"stages[{i}].b")
a_vals = validate_coefficients(stage.a, f"stages[{i}].a")
b_fixed_vals = validate_coefficients(stage.b_fixed, f"stages[{i}].b_fixed")
a_fixed_vals = validate_coefficients(stage.a_fixed, f"stages[{i}].a_fixed")
_, h_ideal = signal.freqz(b_vals, a_vals, worN=f_eval, fs=fs_val)
_, h_fixed = signal.freqz(b_fixed_vals, a_fixed_vals, worN=f_eval, fs=fs_val)
h_total_ideal *= h_ideal
h_total_fixed *= h_fixed
if not any_active:
h_total_ideal = np.ones(len(f_eval), dtype=complex)
h_total_fixed = np.ones(len(f_eval), dtype=complex)
return {
"freq": f_eval.tolist(),
"ideal": {
"mag": (20 * np.log10(np.abs(h_total_ideal) + 1e-12)).tolist(),
"phase": np.angle(h_total_ideal, deg=True).tolist(),
},
"fixed": {
"mag": (20 * np.log10(np.abs(h_total_fixed) + 1e-12)).tolist(),
"phase": np.angle(h_total_fixed, deg=True).tolist(),
},
}
+2 -1
View File
@@ -1,7 +1,8 @@
import os
MAX_COEFFICIENTS = 64
MAX_CSV_BYTES = 32 * 1024 * 1024
MAX_CSV_BYTES = 300 * 1024 * 1024
MAX_ROWS = 1200000
MAX_PLOT_POINTS = 5000
BODE_POINTS = 500
BODE_MAX_MULTIPLIER = 3.162
+263 -34
View File
@@ -1,22 +1,109 @@
import io
import os
import uuid
import re
import tempfile
import time
import numpy as np
import pandas as pd
from fastapi import HTTPException
from scipy import signal
import math
from .config import MAX_CSV_BYTES, MAX_PLOT_POINTS
from .config import MAX_CSV_BYTES, MAX_PLOT_POINTS, MAX_ROWS
TEMP_CSV_DIR = os.environ.get(
"DEA_TEMP_CSV_DIR",
os.path.join(tempfile.gettempdir(), "diff-eq-analyzer-csv"),
)
TEMP_CSV_TTL_SECONDS = int(os.environ.get("DEA_TEMP_CSV_TTL_SECONDS", str(24 * 60 * 60)))
PRESET_FILE_ID = "00000000-0000-0000-0000-000000000000"
PRESET_CSV_PATH = os.environ.get("DEA_PRESET_CSV_PATH", ".scratch/examples/preset_signals.csv")
os.makedirs(TEMP_CSV_DIR, exist_ok=True)
def downsample_for_plot(index, original, filtered):
def prune_expired_csv_uploads(now=None):
if TEMP_CSV_TTL_SECONDS <= 0:
return
os.makedirs(TEMP_CSV_DIR, exist_ok=True)
cutoff = (now or time.time()) - TEMP_CSV_TTL_SECONDS
for filename in os.listdir(TEMP_CSV_DIR):
if not filename.endswith(".csv"):
continue
path = os.path.join(TEMP_CSV_DIR, filename)
try:
if os.path.isfile(path) and os.path.getmtime(path) < cutoff:
os.remove(path)
except OSError:
pass
def get_cached_file_path(file_id):
if file_id == PRESET_FILE_ID:
if not os.path.exists(PRESET_CSV_PATH):
raise HTTPException(status_code=404, detail="找不到 preset CSV 檔案")
return PRESET_CSV_PATH
if not re.match(r'^[0-9a-f\-]{36}$', file_id):
raise HTTPException(status_code=400, detail="無效的檔案ID")
file_path = os.path.join(TEMP_CSV_DIR, f"{file_id}.csv")
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="找不到快取的 CSV 檔案,請重新上傳")
return file_path
def preset_csv_metadata(preview_rows=5):
if not os.path.exists(PRESET_CSV_PATH):
return {"available": False}
preview_df = pd.read_csv(PRESET_CSV_PATH, nrows=preview_rows)
columns = preview_df.columns.tolist()
with open(PRESET_CSV_PATH, "rb") as f:
rows = max(sum(1 for _ in f) - 1, 0)
default_col_idx = 0
if len(columns) > 1:
first = columns[0].lower()
if any(kw in first for kw in ("time", "t", "sec", "x", "index", "unnamed")):
default_col_idx = 1
return {
"available": True,
"file_id": PRESET_FILE_ID,
"name": os.path.basename(PRESET_CSV_PATH),
"columns": columns,
"preview": preview_df.fillna("").astype(str).values.tolist(),
"rows": rows,
"columns_count": len(columns),
"size": os.path.getsize(PRESET_CSV_PATH),
"default_col_idx": default_col_idx,
}
def downsample_for_plot(index, original, filtered_float, filtered_fixed):
total = len(index)
if total <= MAX_PLOT_POINTS:
return index, original, filtered, 1
return index, original, filtered_float, filtered_fixed, 1
step = int(np.ceil(total / MAX_PLOT_POINTS))
return index[::step], original[::step], filtered[::step], step
return index[::step], original[::step], filtered_float[::step], filtered_fixed[::step], step
async def read_csv_upload(file):
def _numeric_signal(series, col_name):
original_missing = series.isna()
numeric_signal = pd.to_numeric(series, errors="coerce")
if (numeric_signal.isna() & ~original_missing).any():
raise HTTPException(status_code=400, detail=f"欄位 {col_name} 含有非數值資料")
numeric_signal = numeric_signal.dropna()
if numeric_signal.empty:
raise HTTPException(status_code=400, detail=f"欄位 {col_name} 沒有可用的數值資料")
x_values = numeric_signal.to_numpy(dtype=float)
if not np.isfinite(x_values).all():
raise HTTPException(status_code=400, detail=f"欄位 {col_name} 含有非有限數值")
return numeric_signal, x_values
async def save_csv_upload(file):
prune_expired_csv_uploads()
filename = (file.filename or "").lower()
if filename and not filename.endswith(".csv"):
raise HTTPException(status_code=400, detail="請上傳 CSV 檔案")
@@ -25,48 +112,190 @@ async def read_csv_upload(file):
raise HTTPException(status_code=413, detail=f"CSV 檔案不可超過 {MAX_CSV_BYTES // (1024 * 1024)}MB")
if not contents.strip():
raise HTTPException(status_code=400, detail="CSV 不可為空")
try:
df = pd.read_csv(io.BytesIO(contents))
except Exception as e:
raise HTTPException(status_code=400, detail=f"CSV 讀取失敗: {str(e)}")
if df.empty:
raise HTTPException(status_code=400, detail="CSV 不可為空")
return df
file_id = str(uuid.uuid4())
file_path = os.path.join(TEMP_CSV_DIR, f"{file_id}.csv")
with open(file_path, "wb") as f:
f.write(contents)
return file_id
def filtered_csv_data(df, b_vals, a_vals, col_idx):
if col_idx < 0 or len(df.columns) <= col_idx:
def integer_lfilter(b_int, a_int, x_float, shift_in, shift_out, shift_b, shift_a, use_round=False):
"""
全整數差分方程式模擬 (Mimic DSP hardware)
高精度狀態變數架構:前饋不位移,保留小數精度於狀態變數中。
支援硬體 Rounding (四捨五入) vs Floor (無條件捨去向下取整)。
"""
b_int = np.asarray(b_int, dtype=np.int64)
a_int = np.asarray(a_int, dtype=np.int64)
x_int = np.round(x_float * (2**shift_in)).astype(np.int64)
# y_hist 將保留在 Q_{in + b} 格式以降低 Truncation Error
y_hist = np.zeros(len(x_int), dtype=np.int64)
y_out = np.zeros(len(x_int), dtype=np.int64)
nb = len(b_int)
na = len(a_int)
A0 = int(a_int[0])
if A0 == 0: A0 = 1
# 輸出所需的總位移量
out_shift = shift_in + shift_b - shift_out
# 預先計算四捨五入的補償值 (+0.5)
# 韌體開發提示 (C Implementation / RISC-V):
# 1. 標準 RISC-V (RV32I/IMAC) 的 SRA 指令是純 Floor (無條件捨去),沒有硬體 Rounding shift。
# (除非具備 'P' DSP Extension 才可能有 1-cycle 的硬體 rounding shift)。
# 2. 演算法秘技:在 C 語言中要實現 1-clock 的四捨五入,不要呼叫 float 的 round()。
# 請使用 `y = (acc + (1 << (shift - 1))) >> shift`。
# 編譯器會將 (1 << (shift - 1)) 編譯為常數,整體只消耗 1 個 ADD 指令,極大消除了 DC Bias。
round_offset_a = (A0 >> 1) if use_round else 0
round_offset_out = (1 << (out_shift - 1)) if (use_round and out_shift > 0) else 0
for n in range(len(x_int)):
sum_b = 0
# Feedforward: 前饋完全不位移,結果為 Q_{in + b}
for i in range(nb):
if n - i >= 0:
sum_b += b_int[i] * x_int[n - i]
# Feedback: 歷史紀錄為 Q_{in + b},係數為 Q_a,乘積為 Q_{in + b + a}
sum_a = 0
for j in range(1, na):
if n - j >= 0:
sum_a += a_int[j] * y_hist[n - j]
# Feedback 縮放:前提 A0 = 1 (或者代表放大了 Q_a 倍的常數),除以 A0 (即 >> shift_a) 歸一化
# Python 的 // 等同於硬體的 SRA (Arithmetic Right Shift),會向負無窮大 Floor
sum_a_scaled = (sum_a + round_offset_a) // A0
acc = sum_b - sum_a_scaled
# 將超高精度的 acc 直接存入歷史變數
y_hist[n] = acc
# 最終輸出再針對 Q_out 進行位移縮放
if out_shift > 0:
y_out[n] = (acc + round_offset_out) >> out_shift
elif out_shift < 0:
y_out[n] = acc << (-out_shift)
else:
y_out[n] = acc
return y_out.astype(float) / (2**shift_out)
def _run_filter_paths(x_values, b_vals, a_vals, b_int, a_int, shift_in, shift_out, shift_b, shift_a, use_round, stages=None):
if stages is None:
y_float = signal.lfilter(b_vals, a_vals, x_values)
if b_int is not None and a_int is not None:
y_fixed = integer_lfilter(b_int, a_int, x_values, shift_in, shift_out, shift_b, shift_a, use_round)
else:
y_fixed = y_float
return y_float, y_fixed
y_float = x_values
y_fixed = x_values
any_active = False
for stage in stages:
if not stage.get("isActive", True):
continue
any_active = True
y_float = signal.lfilter(stage["b"], stage["a"], y_float)
if stage.get("b_int") is not None and stage.get("a_int") is not None:
y_fixed = integer_lfilter(
stage["b_int"],
stage["a_int"],
y_fixed,
stage["shift_in"],
stage["shift_out"],
stage["shift_b"],
stage["shift_a"],
stage["use_round"],
)
else:
y_fixed = y_float
if not any_active:
return x_values, x_values
return y_float, y_fixed
def filter_preview_response(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=None, shift_in=14, shift_out=14, shift_b=14, shift_a=14, use_round=False, stages=None, start_idx=None, end_idx=None):
path = get_cached_file_path(file_id)
# 預先讀取欄位名稱,避免用 usecols 讀取後找不到原始索引
cols = pd.read_csv(path, nrows=0).columns.tolist()
if col_idx < 0 or col_idx >= len(cols):
raise HTTPException(status_code=400, detail="欄位索引超出範圍")
col_to_filter = df.columns[col_idx]
x_signal = pd.to_numeric(df[col_to_filter], errors="coerce")
if x_signal.isna().any():
raise HTTPException(status_code=400, detail=f"欄位 {col_to_filter} 含有非數值資料")
x_values = x_signal.to_numpy(dtype=float)
if not np.isfinite(x_values).all():
raise HTTPException(status_code=400, detail=f"欄位 {col_to_filter} 含有非有限數值")
y_signal = signal.lfilter(b_vals, a_vals, x_values)
return col_to_filter, x_values, y_signal
col_to_filter = cols[col_idx]
# 精準讀取單一欄位,並加上筆數限制 (極大降低記憶體用量與時間)
df = pd.read_csv(path, usecols=[col_idx], nrows=MAX_ROWS)
x_signal, x_values = _numeric_signal(df[col_to_filter], col_to_filter)
def filter_preview_response(df, b_vals, a_vals, col_idx):
col_to_filter, x_signal, y_signal = filtered_csv_data(df, b_vals, a_vals, col_idx)
index, original, filtered, step = downsample_for_plot(df.index.to_numpy(), x_signal, y_signal)
y_float, y_fixed = _run_filter_paths(
x_values, b_vals, a_vals, b_int, a_int,
shift_in, shift_out, shift_b, shift_a, use_round, stages=stages,
)
total_points = len(x_signal)
start_idx_clean = start_idx if isinstance(start_idx, (int, float, str)) else None
end_idx_clean = end_idx if isinstance(end_idx, (int, float, str)) else None
s_idx = max(0, int(start_idx_clean)) if start_idx_clean is not None else 0
e_idx = min(total_points, int(end_idx_clean)) if end_idx_clean is not None else total_points
if s_idx >= e_idx:
s_idx = 0
e_idx = total_points
full_index = x_signal.index.to_numpy()
index, original, filtered_float, filtered_fixed, step = downsample_for_plot(
full_index[s_idx:e_idx],
x_values[s_idx:e_idx],
y_float[s_idx:e_idx],
y_fixed[s_idx:e_idx],
)
return {
"index": index.tolist(),
"original": original.tolist(),
"filtered": filtered.tolist(),
"filtered": filtered_float.tolist(),
"filtered_fixed": filtered_fixed.tolist(),
"col_name": col_to_filter,
"total_points": int(len(df.index)),
"total_points": int(total_points),
"plot_points": int(len(index)),
"downsample_step": int(step),
}
def filtered_csv_text(df, b_vals, a_vals, col_idx):
col_to_filter, _, y_signal = filtered_csv_data(df, b_vals, a_vals, col_idx)
df[f"{col_to_filter}_filtered"] = y_signal
csv_buffer = io.StringIO()
df.to_csv(csv_buffer, index=False)
return csv_buffer.getvalue()
def filtered_csv_text(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=None, shift_in=14, shift_out=14, shift_b=14, shift_a=14, use_round=False, stages=None, fs=100000.0, compact=False):
path = get_cached_file_path(file_id)
# 匯出時需要原始所有欄位,但仍受限於 MAX_ROWS
df = pd.read_csv(path, nrows=MAX_ROWS)
if col_idx < 0 or col_idx >= len(df.columns):
raise HTTPException(status_code=400, detail="欄位索引超出範圍")
col_to_filter = df.columns[col_idx]
x_signal, x_values = _numeric_signal(df[col_to_filter], col_to_filter)
y_float, y_fixed = _run_filter_paths(
x_values, b_vals, a_vals, b_int, a_int,
shift_in, shift_out, shift_b, shift_a, use_round, stages=stages,
)
if compact:
export_df = pd.DataFrame({
"Time (s)": x_signal.index.to_numpy(dtype=float) / fs,
col_to_filter: x_values,
f"{col_to_filter}_filtered_ideal": y_float,
f"{col_to_filter}_filtered_fixed": y_fixed,
})
else:
export_df = df.copy()
export_df[f"{col_to_filter}_filtered_ideal"] = pd.Series(y_float, index=x_signal.index)
export_df[f"{col_to_filter}_filtered_fixed"] = pd.Series(y_fixed, index=x_signal.index)
csv_buffer = io.StringIO()
export_df.to_csv(csv_buffer, index=False)
return csv_buffer.getvalue()
+13
View File
@@ -47,3 +47,16 @@ class BodeCompareParams(BaseModel):
class MCUWriteParams(BaseModel):
command: str = Field(min_length=1, max_length=128)
port: Optional[str] = Field(default=None, max_length=256)
class CascadeStageParams(BaseModel):
b: List[float] = Field(min_length=1, max_length=MAX_COEFFICIENTS)
a: List[float] = Field(min_length=1, max_length=MAX_COEFFICIENTS)
b_fixed: List[float] = Field(min_length=1, max_length=MAX_COEFFICIENTS)
a_fixed: List[float] = Field(min_length=1, max_length=MAX_COEFFICIENTS)
isActive: bool = True
class BodeCascadeParams(BaseModel):
stages: List[CascadeStageParams] = Field(min_length=1)
fs: float = Field(gt=0)
+13
View File
@@ -27,6 +27,19 @@ def parse_coefficients(raw, name):
return validate_coefficients(values, name)
def parse_int_coefficients(raw, name):
try:
# 先轉 float 再轉 int 以處理 1.0 這種字串
values = [int(float(x.strip())) for x in raw.replace(",", " ").split() if x.strip()]
except ValueError:
raise HTTPException(status_code=400, detail=f"{name} 係數格式錯誤 (預期為整數)")
if len(values) == 0:
raise HTTPException(status_code=400, detail=f"{name} 係數不可為空")
if len(values) > MAX_COEFFICIENTS:
raise HTTPException(status_code=400, detail=f"{name} 係數最多 {MAX_COEFFICIENTS}")
return values
def validate_coefficients(values, name):
if len(values) == 0:
raise HTTPException(status_code=400, detail=f"{name} 係數不可為空")
+122 -10
View File
@@ -2,7 +2,7 @@ from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import RedirectResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from dea.bode import calculate_bode_compare_response, calculate_bode_response
from dea.bode import calculate_bode_cascade_response, calculate_bode_compare_response, calculate_bode_response
from dea.config import (
BODE_MAX_MULTIPLIER,
BODE_POINTS,
@@ -14,22 +14,24 @@ from dea.config import (
from dea.csv_processing import (
downsample_for_plot,
filter_preview_response,
filtered_csv_data,
filtered_csv_text,
read_csv_upload,
preset_csv_metadata,
save_csv_upload,
)
from dea.filter_design import design_response
from dea.mcu import list_mcu_ports_response, write_mcu_command_response
from dea.schemas import BodeCompareParams, BodeParams, DesignParams, MCUWriteParams
from dea.schemas import BodeCascadeParams, BodeCompareParams, BodeParams, DesignParams, MCUWriteParams
from dea.security import (
SECURITY_HEADERS,
add_security_headers,
is_lan_or_loopback,
lan_denied_response,
)
import traceback
from dea.validation import (
normalize_coefficients,
parse_coefficients,
parse_int_coefficients,
require_finite,
validate_coefficients,
validate_frequency,
@@ -49,6 +51,7 @@ async def restrict_to_lan(request: Request, call_next):
# 掛載靜態網頁檔案,將預設首頁導向到 /ui/
app.mount("/ui", StaticFiles(directory="static", html=True), name="static")
app.mount("/uart_terminal", StaticFiles(directory="uart_example", html=True), name="uart_terminal")
@app.get("/")
@@ -56,6 +59,11 @@ def redirect_to_ui():
return RedirectResponse(url="/ui/")
@app.get("/uart_terminal")
def redirect_to_uart_terminal():
return RedirectResponse(url="/uart_terminal/")
@app.post("/api/design")
def design_filter(params: DesignParams):
try:
@@ -86,21 +94,100 @@ def calculate_bode_compare(params: BodeCompareParams):
raise HTTPException(status_code=400, detail=str(e))
@app.post("/api/bode/compare_cascade")
def calculate_bode_compare_cascade(params: BodeCascadeParams):
try:
return calculate_bode_cascade_response(params)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@app.get("/api/csv/preset")
def preset_csv():
try:
return preset_csv_metadata()
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=400, detail=f"preset CSV 讀取失敗: {str(e)}")
@app.post("/api/csv/upload")
async def upload_csv(file: UploadFile = File(...)):
try:
file_id = await save_csv_upload(file)
return {"file_id": file_id}
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=400, detail=f"CSV上傳失敗: {str(e)}")
def parse_stages_list(stages_str: str):
if not isinstance(stages_str, str) or not stages_str:
return None
import json
stages_raw = json.loads(stages_str)
stages_list = []
for i, stage in enumerate(stages_raw):
stages_list.append({
"b": parse_coefficients(stage.get("b_str", ""), f"stages[{i}].b"),
"a": parse_coefficients(stage.get("a_str", ""), f"stages[{i}].a"),
"b_int": parse_int_coefficients(stage.get("b_int_str", ""), f"stages[{i}].b_int") if stage.get("b_int_str") else None,
"a_int": parse_int_coefficients(stage.get("a_int_str", ""), f"stages[{i}].a_int") if stage.get("a_int_str") else None,
"shift_in": int(stage.get("shift_in", 14)),
"shift_out": int(stage.get("shift_out", 14)),
"shift_b": int(stage.get("shift_b", 14)),
"shift_a": int(stage.get("shift_a", 14)),
"use_round": bool(stage.get("use_round", False)),
"isActive": bool(stage.get("isActive", True)),
})
return stages_list
@app.post("/api/filter")
async def filter_csv(
file: UploadFile = File(...),
file_id: str = Form(...),
b: str = Form(...),
a: str = Form(...),
col_idx: int = Form(0),
b_int: str = Form(None),
a_int: str = Form(None),
shift_in: int = Form(14),
shift_out: int = Form(14),
shift_b: int = Form(14),
shift_a: int = Form(14),
use_round: bool = Form(False),
stages: str = Form(None),
start_idx: int = Form(None),
end_idx: int = Form(None),
):
try:
b_vals = parse_coefficients(b, "b")
a_vals = parse_coefficients(a, "a")
df = await read_csv_upload(file)
return filter_preview_response(df, b_vals, a_vals, col_idx)
b_int_vals = parse_int_coefficients(b_int, "b_int") if isinstance(b_int, str) and b_int else None
a_int_vals = parse_int_coefficients(a_int, "a_int") if isinstance(a_int, str) and a_int else None
stages_list = parse_stages_list(stages)
return filter_preview_response(
file_id, b_vals, a_vals, col_idx,
b_int=b_int_vals, a_int=a_int_vals,
shift_in=shift_in, shift_out=shift_out,
shift_b=shift_b, shift_a=shift_a,
use_round=use_round,
stages=stages_list,
start_idx=start_idx,
end_idx=end_idx
)
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=400, detail=f"CSV處理失敗: {str(e)}")
@@ -126,16 +213,40 @@ def write_mcu_command(params: MCUWriteParams):
@app.post("/api/filter/download")
async def filter_csv_download(
file: UploadFile = File(...),
file_id: str = Form(...),
b: str = Form(...),
a: str = Form(...),
col_idx: int = Form(0),
b_int: str = Form(None),
a_int: str = Form(None),
shift_in: int = Form(14),
shift_out: int = Form(14),
shift_b: int = Form(14),
shift_a: int = Form(14),
use_round: bool = Form(False),
stages: str = Form(None),
fs: float = Form(100000.0),
compact: bool = Form(False),
):
try:
b_vals = parse_coefficients(b, "b")
a_vals = parse_coefficients(a, "a")
df = await read_csv_upload(file)
csv_text = filtered_csv_text(df, b_vals, a_vals, col_idx)
b_int_vals = parse_int_coefficients(b_int, "b_int") if isinstance(b_int, str) and b_int else None
a_int_vals = parse_int_coefficients(a_int, "a_int") if isinstance(a_int, str) and a_int else None
stages_list = parse_stages_list(stages)
compact_value = compact if isinstance(compact, bool) else False
csv_text = filtered_csv_text(
file_id, b_vals, a_vals, col_idx,
b_int=b_int_vals, a_int=a_int_vals,
shift_in=shift_in, shift_out=shift_out,
shift_b=shift_b, shift_a=shift_a,
use_round=use_round,
stages=stages_list,
fs=fs,
compact=compact_value
)
return StreamingResponse(
iter([csv_text]),
@@ -145,4 +256,5 @@ async def filter_csv_download(
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=400, detail=f"CSV處理失敗: {str(e)}")
+7
View File
@@ -0,0 +1,7 @@
{
"folders": [
{
"path": "."
}
]
}
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>差分方程式分析 (Difference Equation Analyzer)</title>
<title>級聯差分方程式分析 (Cascade Difference Equation Analyzer)</title>
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
<link rel="icon" type="image/png" href="/ui/logo.png">
</head>
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "bodeplot",
"name": "diff-eq-analyzer",
"lockfileVersion": 3,
"requires": true,
"packages": {
+2 -1
View File
@@ -2,7 +2,8 @@
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite --host 0.0.0.0"
"dev": "vite --host 0.0.0.0",
"test:frontend": "node tests/frontend/cascade-stage-smoke.mjs"
},
"dependencies": {
"@vitejs/plugin-vue": "^6.0.6",
+477 -275
View File
File diff suppressed because it is too large Load Diff
+754 -40
View File
File diff suppressed because it is too large Load Diff
+75
View File
@@ -0,0 +1,75 @@
const DEFAULT_FILTER_TYPE = "Lowpass (低通)";
const DEFAULT_B_STR = "0.5, 0.5, 0.0, 0.0";
const DEFAULT_A_STR = "1.0, 0.0, 0.0, 0.0";
const DEFAULT_SHIFT_BITS = 14;
const ZERO_7 = [0, 0, 0, 0, 0, 0, 0];
const DEFAULT_PARAMS = {
fc: 1000.0, order: 1,
bp_f_low: 500.0, bp_f_high: 2000.0, bp_order: 1,
notch_f0: 120.0, notch_q: 1.0,
opoz_fz: 15000.0, opoz_fp: 10.0,
tp1z_fz: 200.0, tp1z_fp1: 10.0, tp1z_fp2: 5000.0,
tptz_fz1: 200.0, tptz_fz2: 25000.0, tptz_fp1: 10.0, tptz_fp2: 5000.0,
kp: 0.003, ki: 10.0, kd: 0.000016,
sogi_f0: 60.0, sogi_k: 1.414,
};
const cloneDefaultParams = () => ({ ...DEFAULT_PARAMS });
const zero7 = () => [...ZERO_7];
const createStageId = () => `${Date.now()}-${Math.random().toString(36).slice(2)}`;
export const normalizeCascadeStage = (stage = {}, fallback = {}) => ({
id: stage.id ?? fallback.id ?? createStageId(),
isActive: stage.isActive ?? fallback.isActive ?? true,
isExpanded: stage.isExpanded ?? fallback.isExpanded ?? false,
filterType: stage.filterType ?? fallback.filterType ?? DEFAULT_FILTER_TYPE,
b_str: stage.b_str ?? fallback.b_str ?? DEFAULT_B_STR,
a_str: stage.a_str ?? fallback.a_str ?? DEFAULT_A_STR,
systemGain: stage.systemGain ?? fallback.systemGain ?? 1.0,
params: { ...cloneDefaultParams(), ...(fallback.params || {}), ...(stage.params || {}) },
baseB: [...(stage.baseB || fallback.baseB || [0.5, 0.5, 0, 0, 0, 0, 0])],
baseA: [...(stage.baseA || fallback.baseA || [1, 0, 0, 0, 0, 0, 0])],
bSliders: [...(stage.bSliders || fallback.bSliders || zero7())],
aSliders: [...(stage.aSliders || fallback.aSliders || zero7())],
shiftBitsB: stage.shiftBitsB ?? fallback.shiftBitsB ?? DEFAULT_SHIFT_BITS,
shiftBitsA: stage.shiftBitsA ?? fallback.shiftBitsA ?? DEFAULT_SHIFT_BITS,
fixedOverrides: {
a: { ...((fallback.fixedOverrides || {}).a || {}), ...((stage.fixedOverrides || {}).a || {}) },
b: { ...((fallback.fixedOverrides || {}).b || {}), ...((stage.fixedOverrides || {}).b || {}) },
},
outOfRangeB: [...(stage.outOfRangeB || fallback.outOfRangeB || [false, false, false, false, false, false, false])],
outOfRangeA: [...(stage.outOfRangeA || fallback.outOfRangeA || [false, false, false, false, false, false, false])],
aFineStep: stage.aFineStep ?? fallback.aFineStep ?? 0.01,
fixedAFineStep: stage.fixedAFineStep ?? fallback.fixedAFineStep ?? 1,
activeCoeffAdjustment: stage.activeCoeffAdjustment ?? fallback.activeCoeffAdjustment ?? null,
});
export const computeStageFixedCoeffs = (stage, parseCoeffs) => {
const normalized = normalizeCascadeStage(stage);
const scaleB = Math.pow(2, normalized.shiftBitsB);
const scaleA = Math.pow(2, normalized.shiftBitsA);
const bRaw = parseCoeffs(normalized.b_str);
const aRaw = parseCoeffs(normalized.a_str);
return {
b: bRaw.map((x, i) => ((normalized.fixedOverrides.b[i] !== undefined) ? normalized.fixedOverrides.b[i] : Math.round(x * scaleB))),
a: aRaw.map((x, i) => ((normalized.fixedOverrides.a[i] !== undefined) ? normalized.fixedOverrides.a[i] : Math.round(x * scaleA))),
};
};
export const createCascadeFilterPayload = ({ stages, fs, shiftBitsIn, shiftBitsOut, useRound, parseCoeffs, getStageFixedCoeffs }) => (
stages.map((stage, index) => {
const fixed = getStageFixedCoeffs(stage);
return {
b_str: stage.b_str,
a_str: stage.a_str,
b_int_str: fixed.b.join(', '),
a_int_str: fixed.a.join(', '),
shift_in: index === 0 ? shiftBitsIn : stages[index - 1].shiftBitsB,
shift_out: index === stages.length - 1 ? shiftBitsOut : stage.shiftBitsB,
shift_b: stage.shiftBitsB,
shift_a: stage.shiftBitsA,
use_round: useRound,
isActive: stage.isActive,
};
})
);
+21
View File
@@ -0,0 +1,21 @@
<template>
<div class="stage-editor" :data-stage-index="stageIndex" :data-stage-id="stage?.id || ''">
<slot></slot>
</div>
</template>
<script>
export default {
name: 'StageEditor',
props: {
stage: {
type: Object,
default: null,
},
stageIndex: {
type: Number,
default: 0,
},
},
};
</script>
+1588 -1668
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>差分方程式分析 (Difference Equation Analyzer)</title>
<title>級聯差分方程式分析 (Cascade Difference Equation Analyzer)</title>
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
<link rel="icon" type="image/png" href="/ui/logo.png">
<script type="module" crossorigin src="./app.js"></script>
+3 -3
View File
@@ -4,15 +4,15 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>差分方程式分析 (Difference Equation Analyzer)</title>
<title>級聯差分方程式分析 (Cascade Difference Equation Analyzer)</title>
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
<link rel="stylesheet" href="build/app.css">
<link rel="stylesheet" href="build/app.css?v=cascade_ui_low_risk">
<link rel="icon" type="image/png" href="logo.png">
</head>
<body class="bg-slate-50 dark:bg-darker text-slate-900 dark:text-gray-200 min-h-screen transition-colors duration-300">
<div id="dea-root"></div>
<script type="module" src="build/app.js"></script>
<script type="module" src="build/app.js?v=cascade_ui_low_risk"></script>
</body>
</html>
+54
View File
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import { computeStageFixedCoeffs, createCascadeFilterPayload, normalizeCascadeStage } from '../../src/cascade-stage.js';
const legacyStage = normalizeCascadeStage({
id: 'legacy-1',
filterType: 'Lowpass (低通)',
b_str: '1, 0',
a_str: '1',
});
assert.equal(legacyStage.id, 'legacy-1');
assert.equal(legacyStage.isActive, true);
assert.equal(legacyStage.isExpanded, false);
assert.equal(legacyStage.shiftBitsB, 14);
assert.deepEqual(Object.keys(legacyStage.fixedOverrides), ['a', 'b']);
const fixedStage = normalizeCascadeStage({
b_str: '0.5, -0.25',
a_str: '1, -0.5',
shiftBitsB: 4,
shiftBitsA: 5,
fixedOverrides: { b: { 1: -9 }, a: {} },
});
const fixed = computeStageFixedCoeffs(fixedStage, (value) => value.split(',').map(Number));
assert.deepEqual(fixed.b, [8, -9]);
assert.deepEqual(fixed.a, [32, -16]);
const stages = [
normalizeCascadeStage({ id: 'a', b_str: '1', a_str: '1', shiftBitsB: 12, shiftBitsA: 13, isActive: true }),
normalizeCascadeStage({ id: 'b', b_str: '2', a_str: '1', shiftBitsB: 10, shiftBitsA: 11, isActive: false }),
];
const payload = createCascadeFilterPayload({
stages,
fs: 100000,
shiftBitsIn: 9,
shiftBitsOut: 8,
useRound: true,
parseCoeffs: (value) => value.split(',').map(Number),
getStageFixedCoeffs: (stage) => ({
b: stage.b_str.split(',').map(Number),
a: stage.a_str.split(',').map(Number),
}),
});
assert.equal(payload.length, 2);
assert.equal(payload[0].b_str, '1');
assert.equal(payload[1].b_str, '2');
assert.equal(payload[0].shift_in, 9);
assert.equal(payload[0].shift_out, 12);
assert.equal(payload[1].shift_in, 12);
assert.equal(payload[1].shift_out, 8);
assert.equal(payload[1].isActive, false);
assert.equal(payload[0].use_round, true);
+206 -20
View File
@@ -1,11 +1,17 @@
import asyncio
import io
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi import HTTPException, UploadFile
from fastapi import HTTPException
from fastapi.responses import JSONResponse
import dea.csv_processing as csv_processing
from dea.csv_processing import PRESET_FILE_ID
from dea_api import (
BodeCascadeParams,
BodeCompareParams,
BodeParams,
DesignParams,
@@ -13,13 +19,47 @@ from dea_api import (
add_security_headers,
calculate_bode,
calculate_bode_compare,
calculate_bode_compare_cascade,
design_filter,
filter_csv,
filter_csv_download,
preset_csv,
upload_csv,
write_mcu_command,
)
class InMemoryUpload:
def __init__(self, content, filename):
self.content = content
self.filename = filename
async def read(self, _size=None):
return self.content
class DeaApiTest(unittest.TestCase):
async def upload_and_filter_csv(self, upload, b="1", a="1", col_idx=0, **kwargs):
upload_body = await upload_csv(upload)
self.assertIn("file_id", upload_body)
params = {
"b_int": None,
"a_int": None,
"shift_in": 14,
"shift_out": 14,
"shift_b": 14,
"shift_a": 14,
"use_round": False,
}
params.update(kwargs)
return await filter_csv(
file_id=upload_body["file_id"],
b=b,
a=a,
col_idx=col_idx,
**params,
)
def test_design_returns_normalized_coefficients_for_default_lowpass(self):
body = design_filter(
DesignParams(
@@ -88,6 +128,33 @@ 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_bode_compare_cascade_combines_active_stages(self):
body = calculate_bode_compare_cascade(
BodeCascadeParams(
fs=1000,
stages=[
{
"b": [1.0],
"a": [1.0],
"b_fixed": [1.0],
"a_fixed": [1.0],
"isActive": True,
},
{
"b": [0.5, 0.5],
"a": [1.0],
"b_fixed": [0.5, 0.5],
"a_fixed": [1.0],
"isActive": True,
},
],
)
)
self.assertEqual(len(body["freq"]), len(body["ideal"]["mag"]))
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"))
@@ -98,14 +165,52 @@ class DeaApiTest(unittest.TestCase):
raise AssertionError("Expected invalid MCU command validation to fail")
def test_preset_csv_endpoint_returns_local_ignored_file_metadata(self):
with tempfile.TemporaryDirectory() as tmp:
preset_path = Path(tmp) / "preset_signals.csv"
preset_path.write_text("time,value\n0,1\n1,2\n", encoding="utf-8")
with patch.object(csv_processing, "PRESET_CSV_PATH", str(preset_path)):
body = preset_csv()
self.assertTrue(body["available"])
self.assertEqual(body["file_id"], PRESET_FILE_ID)
self.assertEqual(body["columns"], ["time", "value"])
self.assertEqual(body["default_col_idx"], 1)
self.assertEqual(body["rows"], 2)
def test_filter_can_use_preset_file_id_and_skip_blank_signal_cells(self):
with tempfile.TemporaryDirectory() as tmp:
preset_path = Path(tmp) / "preset_signals.csv"
preset_path.write_text("time,value\n0,1\n1,\n2,3\n", encoding="utf-8")
with patch.object(csv_processing, "PRESET_CSV_PATH", str(preset_path)):
body = asyncio.run(
filter_csv(
file_id=PRESET_FILE_ID,
b="1",
a="1",
col_idx=1,
b_int=None,
a_int=None,
shift_in=14,
shift_out=14,
shift_b=14,
shift_a=14,
use_round=False,
)
)
self.assertEqual(body["total_points"], 2)
self.assertEqual(body["index"], [0, 2])
self.assertEqual(body["original"], [1.0, 3.0])
def test_filter_downsamples_plot_response_for_large_csv(self):
rows = ["value"] + [str(i) for i in range(6001)]
upload = UploadFile(
io.BytesIO(("\n".join(rows) + "\n").encode("utf-8")),
filename="input.csv",
)
upload = InMemoryUpload(("\n".join(rows) + "\n").encode("utf-8"), "input.csv")
body = asyncio.run(filter_csv(file=upload, b="1", a="1", col_idx=0))
body = asyncio.run(self.upload_and_filter_csv(upload, b="1", a="1", col_idx=0))
self.assertEqual(body["total_points"], 6001)
self.assertLessEqual(body["plot_points"], 5000)
@@ -113,10 +218,10 @@ class DeaApiTest(unittest.TestCase):
def test_filter_rejects_non_numeric_signal_column(self):
upload = UploadFile(io.BytesIO(b"value\n1\nbad\n3\n"), filename="input.csv")
upload = InMemoryUpload(b"value\n1\nbad\n3\n", "input.csv")
try:
asyncio.run(filter_csv(file=upload, b="1", a="1", col_idx=0))
asyncio.run(self.upload_and_filter_csv(upload, b="1", a="1", col_idx=0))
except HTTPException as exc:
self.assertEqual(exc.status_code, 400)
self.assertIn("非數值", exc.detail)
@@ -124,22 +229,103 @@ class DeaApiTest(unittest.TestCase):
raise AssertionError("Expected non-numeric column validation to fail")
def test_filter_accepts_quoted_csv_fields_and_filters_selected_column(self):
upload = UploadFile(
io.BytesIO('label,value\n"a,1",1\n"a,2",2\n'.encode("utf-8")),
filename="input.csv",
)
upload = InMemoryUpload('label,value\n"a,1",1\n"a,2",2\n'.encode("utf-8"), "input.csv")
body = asyncio.run(filter_csv(file=upload, b="1", a="1", col_idx=1))
body = asyncio.run(self.upload_and_filter_csv(upload, b="1", a="1", col_idx=1))
self.assertEqual(body["col_name"], "value")
self.assertEqual(body["original"], [1.0, 2.0])
self.assertEqual(body["filtered"], [1.0, 2.0])
def test_filter_preview_accepts_index_window_for_lod_zoom(self):
upload = InMemoryUpload(b"value\n0\n1\n2\n3\n4\n", "input.csv")
body = asyncio.run(
self.upload_and_filter_csv(
upload,
b="1",
a="1",
col_idx=0,
start_idx=1,
end_idx=4,
)
)
self.assertEqual(body["total_points"], 5)
self.assertEqual(body["index"], [1, 2, 3])
self.assertEqual(body["original"], [1.0, 2.0, 3.0])
def test_filter_accepts_cascade_stages_payload(self):
upload = InMemoryUpload(b"value\n1\n2\n3\n", "input.csv")
stages = [
{
"b_str": "1",
"a_str": "1",
"b_int_str": "16384",
"a_int_str": "16384",
"shift_in": 14,
"shift_out": 14,
"shift_b": 14,
"shift_a": 14,
"use_round": False,
"isActive": True,
},
{
"b_str": "1",
"a_str": "1",
"b_int_str": "16384",
"a_int_str": "16384",
"shift_in": 14,
"shift_out": 14,
"shift_b": 14,
"shift_a": 14,
"use_round": False,
"isActive": True,
},
]
body = asyncio.run(
self.upload_and_filter_csv(
upload,
b="1",
a="1",
col_idx=0,
stages=json.dumps(stages),
)
)
self.assertEqual(body["original"], [1.0, 2.0, 3.0])
self.assertEqual(body["filtered"], [1.0, 2.0, 3.0])
self.assertEqual(body["filtered_fixed"], [1.0, 2.0, 3.0])
def test_filter_download_can_export_compact_four_column_csv(self):
async def run_case():
upload = InMemoryUpload(b"value\n1\n2\n", "input.csv")
upload_body = await upload_csv(upload)
response = await filter_csv_download(
file_id=upload_body["file_id"],
b="1",
a="1",
col_idx=0,
fs=1000.0,
compact=True,
)
chunks = []
async for chunk in response.body_iterator:
chunks.append(chunk)
return b"".join(chunk if isinstance(chunk, bytes) else chunk.encode() for chunk in chunks).decode()
csv_text = asyncio.run(run_case())
self.assertTrue(csv_text.startswith("Time (s),value,value_filtered_ideal,value_filtered_fixed\n"))
self.assertIn("0.0,1.0,1.0,1.0", csv_text)
self.assertIn("0.001,2.0,2.0,2.0", csv_text)
def test_filter_rejects_non_csv_filename(self):
upload = UploadFile(io.BytesIO(b"value\n1\n"), filename="input.txt")
upload = InMemoryUpload(b"value\n1\n", "input.txt")
try:
asyncio.run(filter_csv(file=upload, b="1", a="1", col_idx=0))
asyncio.run(upload_csv(upload))
except HTTPException as exc:
self.assertEqual(exc.status_code, 400)
self.assertIn("CSV", exc.detail)
@@ -147,10 +333,10 @@ class DeaApiTest(unittest.TestCase):
raise AssertionError("Expected non-CSV upload validation to fail")
def test_filter_rejects_empty_csv_upload(self):
upload = UploadFile(io.BytesIO(b" \n"), filename="input.csv")
upload = InMemoryUpload(b" \n", "input.csv")
try:
asyncio.run(filter_csv(file=upload, b="1", a="1", col_idx=0))
asyncio.run(upload_csv(upload))
except HTTPException as exc:
self.assertEqual(exc.status_code, 400)
self.assertIn("CSV", exc.detail)
@@ -158,10 +344,10 @@ class DeaApiTest(unittest.TestCase):
raise AssertionError("Expected empty CSV validation to fail")
def test_filter_rejects_infinite_signal_values(self):
upload = UploadFile(io.BytesIO(b"value\n1\ninf\n3\n"), filename="input.csv")
upload = InMemoryUpload(b"value\n1\ninf\n3\n", "input.csv")
try:
asyncio.run(filter_csv(file=upload, b="1", a="1", col_idx=0))
asyncio.run(self.upload_and_filter_csv(upload, b="1", a="1", col_idx=0))
except HTTPException as exc:
self.assertEqual(exc.status_code, 400)
self.assertIn("有限", exc.detail)
+55
View File
@@ -0,0 +1,55 @@
# Web UART Terminal
A sleek, premium, self-contained web application that enables serial port communication directly from your browser using the **Web Serial API**. It supports standard UART configuration controls (Baud rate, data bits, stop bits, parity), ASCII/Hex display, Hex data sending, log exporting, and a responsive dark/light themed user interface.
![Web UART Terminal Mockup](file:///home/roy/zData/WTPCode/pec930/pec930_pfc_llc/ui/uart_terminal/index.html)
## Prerequisites & Compatibility
1. **Browser Support**: The Web Serial API is supported in modern Chromium-based browsers, including:
- Google Chrome (version 89+)
- Microsoft Edge (version 89+)
- Opera (version 75+)
- *(Note: Firefox, Safari, and iOS browsers are not supported at this time.)*
2. **Secure Context**: The browser only permits Web Serial API calls in a secure context (`https://`) or from `localhost` (which Vite handles automatically).
## Quick Start
To launch the development server locally:
1. **Install dependencies**:
```bash
npm install
```
2. **Start the local server**:
```bash
cd ui/uart_terminal
# If not create the virtual environment
python3 -m venv .venv
# Activate it
source .venv/bin/activate
# start server
npm run dev
```
3. **Open the browser**:
Navigate to the URL printed in the terminal (usually `http://localhost:3000`).
## Features & Usage
- **Connect / Disconnect**: Click the circular green play icon in the top left to request a COM/USB serial port and open connection. The button changes to a red stop icon when connected.
- **Save Logs**: Click the blue download button next to the connection status to export the accumulated console output log as a `.txt` file.
- **Scroll Modes**: Switch between "Auto-scroll" and "No scroll" to control terminal navigation.
- **Display Formats**:
- `Auto`: Prints incoming data as decoded UTF-8 text string (default).
- `Hex`: Displays incoming bytes as space-separated hexadecimal numbers (e.g., `55 AA 01 02`).
- **Data Transmission**:
- Enter text in the send input at the bottom and click the Send button or press `Enter` to transmit.
- Switch the send format select dropdown between `ASCII` and `Hex` to format data packages.
- **Hex Parsing Rules**: When in Hex mode, you can input hex characters with or without spaces (e.g., `55 AA FF` or `55aaff`). The string must contain an even number of characters and valid hexadecimal characters only.
### Keyboard Shortcuts
- **Send Data**: Press `Enter` (or `Ctrl+Enter`) inside the input bar to send.
- **Insert Tabs**: Press `Ctrl+Tab` in the input bar to insert a literal tab character (`\t`) instead of losing input focus.
+761
View File
@@ -0,0 +1,761 @@
// DOM Elements Selection
const themeToggle = document.getElementById('theme-toggle');
const connectBtn = document.getElementById('connect-btn');
const downloadBtn = document.getElementById('download-btn');
const statusBanner = document.getElementById('status-banner');
const statusText = document.getElementById('status-text');
// Settings Dropdowns
const baudRateSelect = document.getElementById('baud-rate');
const dataBitsSelect = document.getElementById('data-bits');
const stopBitsSelect = document.getElementById('stop-bits');
const paritySelect = document.getElementById('parity');
// Controls & Console
const scrollModeSelect = document.getElementById('scroll-mode');
const displayFormatSelect = document.getElementById('display-format');
const terminalContainer = document.getElementById('terminal-container');
// Sending Inputs
const sendInput = document.getElementById('send-input');
const sendFormatSelect = document.getElementById('send-format');
const sendEolSelect = document.getElementById('send-eol');
const sendBtn = document.getElementById('send-btn');
const clearBtn = document.getElementById('clear-btn');
const addSessionBtn = document.getElementById('add-session-btn');
const sessionListContainer = document.getElementById('session-list');
const downloadTypeSelect = document.getElementById('download-type');
// PortSession Class definition
class PortSession {
constructor(id, name) {
this.id = id;
this.name = name;
// Web Serial State
this.port = null;
this.reader = null;
this.writer = null;
this.keepReading = false;
this.rxLogBuffer = [];
this.txLogBuffer = [];
this.combinedLogBuffer = [];
this.downloadType = 'rx_tx';
this.commandHistory = [];
this.historyIndex = -1;
this.isTerminalEmpty = true;
this.deviceInfo = '';
// Settings state
this.baudRate = '115200';
this.dataBits = '8';
this.stopBits = '1';
this.parity = 'none';
// Console controls
this.scrollMode = 'auto';
this.displayFormat = 'auto';
this.sendFormat = 'ascii';
this.sendEol = 'crlf';
this.sendText = '';
// Dedicated Terminal Element
this.terminalOutputElement = document.createElement('div');
this.terminalOutputElement.id = `terminal-output-${this.id}`;
this.terminalOutputElement.className = 'terminal-output empty';
this.terminalOutputElement.tabIndex = 0;
this.terminalOutputElement.textContent = 'No data received yet...';
}
}
// Global Application State
let sessions = [];
let activeSession = null;
// Initialize theme and browser support on load
document.addEventListener('DOMContentLoaded', () => {
initTheme();
checkWebSerialSupport();
// Wire up main event listeners
addSessionBtn.addEventListener('click', addSession);
baudRateSelect.addEventListener('change', () => {
if (activeSession) {
activeSession.baudRate = baudRateSelect.value;
renderSessionsList();
}
});
dataBitsSelect.addEventListener('change', () => {
if (activeSession) activeSession.dataBits = dataBitsSelect.value;
});
stopBitsSelect.addEventListener('change', () => {
if (activeSession) activeSession.stopBits = stopBitsSelect.value;
});
paritySelect.addEventListener('change', () => {
if (activeSession) activeSession.parity = paritySelect.value;
});
scrollModeSelect.addEventListener('change', () => {
if (activeSession) activeSession.scrollMode = scrollModeSelect.value;
});
displayFormatSelect.addEventListener('change', () => {
if (activeSession) activeSession.displayFormat = displayFormatSelect.value;
});
sendFormatSelect.addEventListener('change', () => {
if (activeSession) activeSession.sendFormat = sendFormatSelect.value;
});
sendEolSelect.addEventListener('change', () => {
if (activeSession) activeSession.sendEol = sendEolSelect.value;
});
sendInput.addEventListener('input', () => {
if (activeSession) activeSession.sendText = sendInput.value;
});
downloadTypeSelect.addEventListener('change', () => {
if (activeSession) activeSession.downloadType = downloadTypeSelect.value;
});
// Initialize with a default session
addSession();
});
// Toast notification helper
function showToast(message, type = 'success') {
const toast = document.createElement('div');
toast.className = `toast-msg show toast-${type}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// -------------------------------------------------------------
// 1. Theme Configuration (Dark / Light Mode)
// -------------------------------------------------------------
function initTheme() {
const savedTheme = localStorage.getItem('theme') || 'dark';
if (savedTheme === 'light') {
document.body.classList.add('light-theme');
} else {
document.body.classList.remove('light-theme');
}
}
themeToggle.addEventListener('click', () => {
document.body.classList.toggle('light-theme');
const activeTheme = document.body.classList.contains('light-theme') ? 'light' : 'dark';
localStorage.setItem('theme', activeTheme);
});
// -------------------------------------------------------------
// 2. Web Serial API Checks & State Updates
// -------------------------------------------------------------
function checkWebSerialSupport() {
if (!('serial' in navigator)) {
statusBanner.className = 'status-banner error';
statusText.innerHTML = '<strong>Web Serial API not supported.</strong> Please use a compatible Chromium-based browser (Chrome, Edge, Opera).';
connectBtn.disabled = true;
connectBtn.style.opacity = '0.5';
connectBtn.style.cursor = 'not-allowed';
addSessionBtn.disabled = true;
addSessionBtn.style.opacity = '0.5';
addSessionBtn.style.cursor = 'not-allowed';
showToast('Web Serial API is not supported in this browser.', 'error');
}
}
function updateConnectionUI(connected, errorMsg = '') {
if (!activeSession) return;
if (connected && activeSession.port) {
connectBtn.className = 'connect-btn connected';
connectBtn.title = 'Disconnect from serial port';
statusBanner.className = 'status-banner connected';
const info = activeSession.port.getInfo();
const portLabel = info.usbVendorId
? `USB Device (VID: 0x${info.usbVendorId.toString(16).toUpperCase()}, PID: 0x${info.usbProductId.toString(16).toUpperCase()})`
: 'Serial Device';
statusText.innerHTML = `Connected to <strong>${portLabel}</strong> at ${activeSession.baudRate} bps.`;
} else {
connectBtn.className = 'connect-btn disconnected';
connectBtn.title = 'Connect to serial port';
if (errorMsg) {
statusBanner.className = 'status-banner error';
statusText.textContent = errorMsg;
} else {
statusBanner.className = 'status-banner warn';
statusText.textContent = 'Not connected. Click the connect button to select a port.';
}
}
}
// -------------------------------------------------------------
// 3. Serial Session Management
// -------------------------------------------------------------
function addSession() {
const id = Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
const name = `Session ${sessions.length + 1}`;
const newSession = new PortSession(id, name);
sessions.push(newSession);
terminalContainer.appendChild(newSession.terminalOutputElement);
selectSession(newSession);
showToast(`${name} created.`, 'success');
}
function selectSession(session) {
if (activeSession) {
activeSession.sendText = sendInput.value;
}
activeSession = session;
renderSessionsList();
// Show only active session's terminal DOM element
const terminals = terminalContainer.querySelectorAll('.terminal-output');
terminals.forEach(el => {
if (el.id === activeSession.terminalOutputElement.id) {
el.classList.remove('hidden');
} else {
el.classList.add('hidden');
}
});
// Sync dropdown UI values
baudRateSelect.value = activeSession.baudRate;
dataBitsSelect.value = activeSession.dataBits;
stopBitsSelect.value = activeSession.stopBits;
paritySelect.value = activeSession.parity;
scrollModeSelect.value = activeSession.scrollMode;
displayFormatSelect.value = activeSession.displayFormat;
sendFormatSelect.value = activeSession.sendFormat;
sendEolSelect.value = activeSession.sendEol;
sendInput.value = activeSession.sendText;
downloadTypeSelect.value = activeSession.downloadType;
downloadBtn.disabled = (activeSession.rxLogBuffer.length === 0 && activeSession.txLogBuffer.length === 0);
// Sync status banner and buttons
const isConnected = activeSession.port !== null && activeSession.port.readable;
updateConnectionUI(isConnected);
}
async function deleteSession(session, event) {
if (event) {
event.stopPropagation();
}
if (sessions.length <= 1) {
showToast('Cannot delete the last remaining session.', 'error');
return;
}
// Disconnect if connected
if (session.port) {
await disconnectSession(session);
}
// Remove terminal from DOM
if (session.terminalOutputElement && session.terminalOutputElement.parentNode) {
session.terminalOutputElement.parentNode.removeChild(session.terminalOutputElement);
}
const index = sessions.indexOf(session);
if (index > -1) {
sessions.splice(index, 1);
}
if (activeSession === session) {
const nextActive = sessions[Math.max(0, index - 1)];
selectSession(nextActive);
} else {
renderSessionsList();
}
showToast(`${session.name} deleted.`, 'success');
}
function renderSessionsList() {
sessionListContainer.innerHTML = '';
sessions.forEach(session => {
const item = document.createElement('div');
item.className = `session-item ${session === activeSession ? 'active' : ''}`;
item.addEventListener('click', () => selectSession(session));
const leftGroup = document.createElement('div');
leftGroup.className = 'session-item-left';
// Status dot
const dot = document.createElement('span');
const isConnected = session.port !== null && session.port.readable;
dot.className = `status-dot ${isConnected ? 'connected' : 'disconnected'}`;
leftGroup.appendChild(dot);
// Session labels
const info = document.createElement('div');
info.className = 'session-info';
const nameEl = document.createElement('span');
nameEl.className = 'session-name';
nameEl.textContent = session.name;
// Double click to rename
nameEl.addEventListener('dblclick', (e) => {
e.stopPropagation();
const input = document.createElement('input');
input.type = 'text';
input.value = session.name;
input.className = 'session-rename-input';
const saveRename = () => {
const val = input.value.trim();
if (val) {
session.name = val;
}
renderSessionsList();
};
input.addEventListener('keydown', (evt) => {
if (evt.key === 'Enter') {
saveRename();
} else if (evt.key === 'Escape') {
renderSessionsList();
}
});
input.addEventListener('blur', saveRename);
info.innerHTML = '';
info.appendChild(input);
input.focus();
input.select();
});
const detailEl = document.createElement('span');
detailEl.className = 'session-detail';
if (isConnected) {
detailEl.textContent = `${session.baudRate} bps | ${session.deviceInfo || 'Connected'}`;
} else {
detailEl.textContent = `${session.baudRate} bps | Disconnected`;
}
info.appendChild(nameEl);
info.appendChild(detailEl);
leftGroup.appendChild(info);
item.appendChild(leftGroup);
// Close / Delete Session button
const deleteBtn = document.createElement('button');
deleteBtn.className = 'session-delete-btn';
deleteBtn.title = 'Delete Session';
deleteBtn.innerHTML = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px;">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
`;
deleteBtn.addEventListener('click', (e) => deleteSession(session, e));
item.appendChild(deleteBtn);
sessionListContainer.appendChild(item);
});
}
// -------------------------------------------------------------
// 4. Serial Port Connection & Reader Loop
// -------------------------------------------------------------
async function connectSession(session) {
try {
session.port = await navigator.serial.requestPort();
const options = {
baudRate: parseInt(session.baudRate, 10),
dataBits: parseInt(session.dataBits, 10),
stopBits: parseInt(session.stopBits, 10),
parity: session.parity
};
await session.port.open(options);
const info = session.port.getInfo();
if (info.usbVendorId) {
session.deviceInfo = `USB VID:0x${info.usbVendorId.toString(16).toUpperCase()}`;
} else {
session.deviceInfo = 'Serial Device';
}
if (session === activeSession) {
updateConnectionUI(true);
}
renderSessionsList();
session.keepReading = true;
startSessionReadingLoop(session);
showToast(`${session.name} connected.`, 'success');
} catch (err) {
console.error('Connection failed:', err);
session.port = null;
if (session === activeSession) {
updateConnectionUI(false, `Connection failed: ${err.message}`);
}
showToast(`Failed to open port for ${session.name}.`, 'error');
}
}
async function disconnectSession(session) {
session.keepReading = false;
if (session.reader) {
try {
await session.reader.cancel();
} catch (e) {
console.error('Error cancelling reader:', e);
}
}
return new Promise((resolve) => {
setTimeout(async () => {
if (session.port) {
try {
await session.port.close();
} catch (e) {
console.error('Error closing port:', e);
}
session.port = null;
}
session.deviceInfo = '';
if (session === activeSession) {
updateConnectionUI(false);
}
renderSessionsList();
showToast(`${session.name} disconnected.`, 'success');
resolve();
}, 100);
});
}
async function startSessionReadingLoop(session) {
const decoder = new TextDecoder();
while (session.port && session.port.readable && session.keepReading) {
try {
session.reader = session.port.readable.getReader();
while (session.keepReading) {
const { value, done } = await session.reader.read();
if (done) break;
if (value) {
handleIncomingSessionData(session, value, decoder);
}
}
} catch (err) {
console.error('Read error:', err);
if (session === activeSession) {
updateConnectionUI(false, `Port disconnected: ${err.message}`);
}
showToast(`${session.name} disconnected unexpectedly.`, 'error');
session.port = null;
renderSessionsList();
break;
} finally {
if (session.reader) {
session.reader.releaseLock();
session.reader = null;
}
}
}
}
function handleIncomingSessionData(session, uint8Array, decoder) {
const format = session.displayFormat;
let formattedText = '';
if (format === 'hex') {
formattedText = Array.from(uint8Array)
.map(b => b.toString(16).padStart(2, '0').toUpperCase())
.join(' ') + ' ';
} else {
formattedText = decoder.decode(uint8Array);
}
session.rxLogBuffer.push(formattedText);
session.combinedLogBuffer.push(formattedText);
appendSessionTerminalText(session, formattedText, 'received');
}
// -------------------------------------------------------------
// 5. Writing Data (Transmission)
// -------------------------------------------------------------
async function sendSessionData() {
if (!activeSession) return;
const session = activeSession;
if (!session.port || !session.port.writable) {
showToast(`Cannot send. ${session.name} is not connected.`, 'error');
return;
}
const rawInput = sendInput.value;
if (!rawInput) return;
// Add command to history (maximum 10 items)
const trimmed = rawInput.trim();
if (trimmed) {
const history = session.commandHistory;
if (history.length === 0 || history[history.length - 1] !== trimmed) {
history.push(trimmed);
if (history.length > 10) {
history.shift();
}
}
session.historyIndex = history.length;
}
const format = session.sendFormat;
const eol = session.sendEol;
let bytesToSend;
let eolText = '';
let eolBytes = new Uint8Array(0);
if (eol === 'lf') {
eolText = '\n';
eolBytes = new Uint8Array([10]);
} else if (eol === 'cr') {
eolText = '\r';
eolBytes = new Uint8Array([13]);
} else if (eol === 'crlf') {
eolText = '\r\n';
eolBytes = new Uint8Array([13, 10]);
}
try {
if (format === 'hex') {
const parsedBytes = parseHexString(rawInput);
bytesToSend = new Uint8Array(parsedBytes.length + eolBytes.length);
bytesToSend.set(parsedBytes);
bytesToSend.set(eolBytes, parsedBytes.length);
} else {
bytesToSend = new TextEncoder().encode(rawInput + eolText);
}
} catch (err) {
showToast(err.message, 'error');
return;
}
try {
session.writer = session.port.writable.getWriter();
await session.writer.write(bytesToSend);
let displaySent = '';
if (format === 'hex') {
displaySent = Array.from(bytesToSend)
.map(b => b.toString(16).padStart(2, '0').toUpperCase())
.join(' ') + ' ';
} else {
displaySent = rawInput;
if (eol === 'lf') displaySent += '\\n';
else if (eol === 'cr') displaySent += '\\r';
else if (eol === 'crlf') displaySent += '\\r\\n';
}
appendSessionTerminalText(session, `\n[Sent] ${displaySent}\n`, 'sent');
// Save sent log
session.txLogBuffer.push(`[Sent] ${displaySent}\n`);
session.combinedLogBuffer.push(`\n[Sent] ${displaySent}\n`);
sendInput.value = '';
session.sendText = '';
} catch (err) {
console.error('Send error:', err);
showToast(`Failed to send: ${err.message}`, 'error');
} finally {
if (session.writer) {
session.writer.releaseLock();
session.writer = null;
}
}
}
function parseHexString(hexString) {
const clean = hexString.replace(/\s+/g, '');
if (!/^[0-9a-fA-F]*$/.test(clean)) {
throw new Error('Hex contains invalid characters (0-9, A-F only)');
}
if (clean.length % 2 !== 0) {
throw new Error('Hex string must have an even number of characters');
}
const result = new Uint8Array(clean.length / 2);
for (let i = 0; i < clean.length; i += 2) {
result[i / 2] = parseInt(clean.substring(i, i + 2), 16);
}
return result;
}
// -------------------------------------------------------------
// 6. Terminal Console Operations
// -------------------------------------------------------------
function appendSessionTerminalText(session, text, className = '') {
if (session.isTerminalEmpty) {
session.terminalOutputElement.textContent = '';
session.terminalOutputElement.classList.remove('empty');
session.isTerminalEmpty = false;
if (session === activeSession) {
downloadBtn.disabled = false;
}
}
const span = document.createElement('span');
if (className) {
span.className = `terminal-line ${className}`;
}
span.textContent = text;
session.terminalOutputElement.appendChild(span);
if (session.scrollMode === 'auto') {
session.terminalOutputElement.scrollTop = session.terminalOutputElement.scrollHeight;
}
}
function clearTerminal() {
if (!activeSession) return;
activeSession.terminalOutputElement.innerHTML = 'No data received yet...';
activeSession.terminalOutputElement.classList.add('empty');
activeSession.isTerminalEmpty = true;
activeSession.rxLogBuffer = [];
activeSession.txLogBuffer = [];
activeSession.combinedLogBuffer = [];
downloadBtn.disabled = true;
showToast('Terminal cleared.', 'success');
}
function downloadLog() {
if (!activeSession) return;
let content = '';
let filenameSuffix = '';
if (activeSession.downloadType === 'rx') {
content = activeSession.rxLogBuffer.join('');
filenameSuffix = 'rx';
} else if (activeSession.downloadType === 'tx') {
content = activeSession.txLogBuffer.join('');
filenameSuffix = 'tx';
} else {
content = activeSession.combinedLogBuffer.join('');
filenameSuffix = 'combined';
}
if (!content) {
showToast('No logs to download for the selected type.', 'error');
return;
}
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const dateStr = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
const a = document.createElement('a');
a.href = url;
a.download = `uart_log_${activeSession.name.replace(/\s+/g, '_')}_${filenameSuffix}_${dateStr}.txt`;
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 100);
showToast('Download started.', 'success');
}
// -------------------------------------------------------------
// 7. Event Listeners & Keyboard Shortcuts
// -------------------------------------------------------------
connectBtn.addEventListener('click', () => {
if (activeSession) {
if (activeSession.port) {
disconnectSession(activeSession);
} else {
connectSession(activeSession);
}
}
});
sendBtn.addEventListener('click', sendSessionData);
clearBtn.addEventListener('click', clearTerminal);
downloadBtn.addEventListener('click', downloadLog);
sendInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
sendSessionData();
}
if (e.key === 'ArrowUp') {
e.preventDefault();
if (activeSession && activeSession.commandHistory.length > 0) {
if (activeSession.historyIndex > 0) {
activeSession.historyIndex--;
sendInput.value = activeSession.commandHistory[activeSession.historyIndex];
activeSession.sendText = sendInput.value;
}
}
}
if (e.key === 'ArrowDown') {
e.preventDefault();
if (activeSession && activeSession.commandHistory.length > 0) {
if (activeSession.historyIndex < activeSession.commandHistory.length - 1) {
activeSession.historyIndex++;
sendInput.value = activeSession.commandHistory[activeSession.historyIndex];
activeSession.sendText = sendInput.value;
} else {
activeSession.historyIndex = activeSession.commandHistory.length;
sendInput.value = '';
activeSession.sendText = '';
}
}
}
if (e.key === 'Tab' && e.ctrlKey) {
e.preventDefault();
const start = sendInput.selectionStart;
const end = sendInput.selectionEnd;
sendInput.value = sendInput.value.substring(0, start) + '\t' + sendInput.value.substring(end);
sendInput.selectionStart = sendInput.selectionEnd = start + 1;
if (activeSession) activeSession.sendText = sendInput.value;
}
});
// Auto-detect browser USB disconnect events globally
if ('serial' in navigator) {
navigator.serial.addEventListener('disconnect', async (event) => {
const disconnectedSession = sessions.find(s => s.port === event.target);
if (disconnectedSession) {
await disconnectSession(disconnectedSession);
showToast(`${disconnectedSession.name} device disconnected.`, 'error');
}
});
}
@@ -0,0 +1,77 @@
# Implementation Plan - Web UART Terminal
This plan details the implementation of a modern, responsive, and feature-rich Web UART Terminal application. The design will match the dark-themed UI provided in the user's reference image and support the Web Serial API for real-time serial communication with hardware.
## User Review Required
> [!IMPORTANT]
> **Browser Compatibility**: The Web Serial API is supported in Chromium-based browsers (Chrome, Edge, Opera). It is not supported by default in Firefox or Safari. A friendly warning banner will be displayed to users accessing the page from unsupported browsers.
> **Local Server Requirement**: The Web Serial API requires a secure context (HTTPS) or localhost to work. To facilitate this, we will configure a simple dev environment using **Vite** so you can run it locally with `npm run dev` (serving at `http://localhost:5173`).
## Proposed Changes
We will create a self-contained, lightweight, and high-performance Vanilla HTML/CSS/JS application within the `/home/roy/zData/WTPCode/pec930/pec930_pfc_llc/ui/uart_terminal` directory.
### UI & Dev Environment Configuration
---
#### [NEW] [package.json](file:///home/roy/zData/WTPCode/pec930/pec930_pfc_llc/ui/uart_terminal/package.json)
- Define a project package with `vite` as a dev dependency.
- Include scripts for local development (`npm run dev`) and building (`npm run build`).
#### [NEW] [vite.config.js](file:///home/roy/zData/WTPCode/pec930/pec930_pfc_llc/ui/uart_terminal/vite.config.js)
- A basic configuration file for Vite to ensure it runs correctly and resolves static assets.
#### [NEW] [index.html](file:///home/roy/zData/WTPCode/pec930/pec930_pfc_llc/ui/uart_terminal/index.html)
- Create the HTML skeleton of the UART Terminal.
- Match the layout in the screenshot:
- Header: App Title, Theme Toggle button.
- Action Control: Connect/Disconnect button (circle with play/stop icon), Download button (square download icon).
- Info Banner: "Not connected" warning banner, dynamically changing based on serial connection status.
- Serial Settings Grid: Dropdowns for Baud Rate, Data Bits, Stop Bits, Parity.
- Received Data Section: Section title, controls (Scroll: Auto-scroll/None, Display: Auto/Hex/Text), console output textarea/container.
- Send Data Footer: Form input with keyboard shortcut tooltip, Send Format dropdown (ASCII/Hex), Send button, and Clear Terminal (trash) button.
- Embed all required SVG icons inline for zero external dependencies and fast load times.
- Include a Google Font link (e.g., "Inter" for UI typography, "JetBrains Mono" for the terminal console).
#### [NEW] [index.css](file:///home/roy/zData/WTPCode/pec930/pec930_pfc_llc/ui/uart_terminal/index.css)
- Implement a premium design system using CSS custom properties (variables) for HSL colors.
- Support Dark Mode (default) and Light Mode.
- Incorporate subtle animations (transitions on hover/active, pulsing connection status, smooth modal fades).
- Implement responsive grids and flex layouts, ensuring pixel-perfect alignment with the reference image.
- Design custom scrollbars for the terminal output.
#### [NEW] [app.js](file:///home/roy/zData/WTPCode/pec930/pec930_pfc_llc/ui/uart_terminal/app.js)
- Implement core UART logic via Web Serial API:
- Request serial port and open connection using selected settings (Baud Rate, Data Bits, Stop Bits, Parity).
- Background reading loop with cancellation support.
- Writing loop supporting ASCII/Hex conversion.
- Implement helper utilities:
- Hex parser (validating and converting hex string inputs like `AA BB CC` or `AABBCC` to Uint8Array).
- Hex formatter (representing received bytes as spaced hex strings e.g. `00 12 AF`).
- Implement UI interactions:
- Terminal text appending with Auto-scroll capability.
- Download log: converts terminal content to a `.txt` blob and triggers a browser download.
- Clear log: clears the screen and reset states.
- Keyboard shortcuts (e.g. `Ctrl+Enter` to send, `Ctrl+Tab` to insert tab in input, etc.).
- Theme toggling with `localStorage` persistence.
#### [NEW] [README.md](file:///home/roy/zData/WTPCode/pec930/pec930_pfc_llc/ui/uart_terminal/README.md)
- Instructions on installing dependencies (`npm install`) and launching the development server (`npm run dev`).
- Quick notes on Web Serial browser compatibility and security requirements.
## Verification Plan
### Manual Verification
1. Launch the local dev server using `npm run dev`.
2. Access the app via Chrome or Edge browser.
3. Verify that the UI matches the reference screenshot (dark theme, layouts, styling, icons, font sizes).
4. Verify the theme toggle (switching from dark to light mode and back).
5. Verify inputs:
- Check if serial configuration dropdowns have correct options.
- Verify that clicking "Connect" triggers the browser's Serial Port prompt.
- Verify Hex transmission (e.g., inputting `48 45 4C 4C 4F` in Hex mode sends `HELLO`).
- Verify Text transmission.
- Test "Clear" (trash bin) and "Download" buttons.
- Test auto-scroll behaviour with mock data.
+61
View File
@@ -0,0 +1,61 @@
# UART Terminal Multi-Port Implementation Plan
The goal is to modify the UART Terminal to support multiple concurrent serial port sessions. We will introduce a sidebar panel on the left to display all sessions (e.g., active/inactive state, baud rate, and custom names) and a main workspace on the right to manage the configuration and terminal log of the currently selected session.
## User Review Required
> [!IMPORTANT]
> - **Default Session**: On app load, a default "Session 1" will be created and selected. This ensures that users who just want to use a single port don't have to perform any additional clicks.
> - **Concurrent Connections**: Each session will maintain its own active connection, serial reader loop, terminal logs, and configurations. This allows the user to have multiple serial devices connected and communicating simultaneously, switching between them at will without losing context or scroll history.
> - **Session Renaming**: Users can double-click a session name in the sidebar to rename it to something descriptive (e.g., "MCU 1", "GPS Module").
## Proposed Changes
### UI / Layout Configuration
#### [MODIFY] [index.html](file:///home/roy/zData/WTPCode/bodeplot/uart_example/index.html)
- Wrap the `<main class="app-main">` and a new `<aside class="app-sidebar">` inside a container `<div class="app-layout">`.
- Adjust container structure:
- Increase `.app-container` max-width to `1200px` to comfortably host the sidebar and terminal split.
- Implement dynamic placeholders for the terminal output elements. Instead of a single `#terminal-output` div, we'll make `#terminal-container` host dynamically created terminal-output elements for each session, toggling their visibility based on the active session.
- Add an "Add Session" (`#add-session-btn`) button to the sidebar.
#### [MODIFY] [index.css](file:///home/roy/zData/WTPCode/bodeplot/uart_example/index.css)
- Increase `.app-container` `max-width` to `1200px`.
- Add rules for `.app-layout` (CSS Grid or Flexbox layout, split `260px` and `1fr`).
- Add styles for the sidebar (`.app-sidebar`):
- `.sidebar-header` (flex row, title, add-session button).
- `.session-list` (flex column, scrolling).
- `.session-item` (states: hover, active, status-dots, title/subtitle, hover delete button).
- `.session-rename-input` (input box replacing title on double-click).
- Add support for displaying/hiding terminal sessions via class `.hidden { display: none !important; }`.
### State & Logic Configuration
#### [MODIFY] [app.js](file:///home/roy/zData/WTPCode/bodeplot/uart_example/app.js)
- Define a `PortSession` class to encapsulate all states of a single UART connection:
- ID, name, connection state, terminal DOM element, log buffers, input text cache.
- Port options: `baudRate`, `dataBits`, `stopBits`, `parity`.
- Terminal configs: `scrollMode`, `displayFormat`, `sendFormat`, `sendEol`.
- Maintain global variables:
- `sessions` array.
- `activeSession` pointer.
- Refactor connection functions (`connectSerial`, `disconnectSerial`, `startReadingLoop`, `sendData`) to execute against the active session's parameters.
- Re-route UI change listeners (Baud Rate selector, display formats, send input, etc.) to update properties on `activeSession` when changed.
- Build functions to:
- Add a session (`addSession()`).
- Select/Activate a session (`activateSession(session)`).
- Delete/Remove a session (`deleteSession(session)`).
- Sync UI selectors with active session states.
## Verification Plan
### Automated Tests
We will run `npm run dev` or a local static files server to review the layout, and use our understanding of the DOM state to ensure multiple concurrent sessions behave correctly.
### Manual Verification
1. **Multiple Tabs**: Add 3 ports in the sidebar. Verify they are named Session 1, Session 2, Session 3.
2. **Settings Persistence**: Change Baud Rate in Session 1 to `9600` and Session 2 to `115200`. Switch between them and verify that the dropdown value updates to reflect the active session's settings.
3. **Session Renaming**: Double-click "Session 1" name in the sidebar, type "Test Port", and press Enter. Verify the label changes.
4. **Delete Session**: Select a session and click the `x` delete icon. Verify it gets removed. If connected, it should disconnect safely first.
5. **Console logs isolation**: Verify that sending data or logs in one session does not bleed into another session's terminal panel.
+952
View File
@@ -0,0 +1,952 @@
/* CSS Variables for Premium Design System */
:root {
/* Dark Theme Variables (Default) */
--bg-app: #0b0f19;
--bg-panel: #111827;
--bg-input: #1f2937;
--border-color: #374151;
--border-hover: #4b5563;
--border-focus: #6366f1;
--text-main: #f9fafb;
--text-muted: #9ca3af;
--terminal-bg: #090d16;
--terminal-text: #e5e7eb;
--terminal-border: #1f2937;
/* Accent Colors */
--accent-green: #10b981;
--accent-green-hover: #059669;
--accent-blue: #3b82f6;
--accent-blue-hover: #2563eb;
--accent-purple: #6366f1;
--accent-purple-hover: #4f46e5;
--accent-warn-bg: #fef3c7;
--accent-warn-text: #b45309;
--accent-warn-border: #f59e0b;
--accent-danger: #ef4444;
--accent-danger-hover: #dc2626;
/* Layout and Styling Details */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 16px;
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', source-code-pro, Menlo, Monaco, Consolas, monospace;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.15);
--transition-fast: 0.15s ease;
--transition-normal: 0.25s ease;
}
/* Light Theme Variables */
body.light-theme {
--bg-app: #f3f4f6;
--bg-panel: #ffffff;
--bg-input: #f9fafb;
--border-color: #d1d5db;
--border-hover: #9ca3af;
--border-focus: #4f46e5;
--text-main: #111827;
--text-muted: #6b7280;
--terminal-bg: #f8fafc;
--terminal-text: #0f172a;
--terminal-border: #e2e8f0;
--accent-warn-bg: #fffbeb;
--accent-warn-text: #b45309;
--accent-warn-border: #f59e0b;
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.05), 0 4px 6px -2px rgba(0, 0, 0, 0.02);
}
/* Base resets & styling */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg-app);
color: var(--text-main);
font-family: var(--font-ui);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: flex-start;
transition: background-color var(--transition-normal), color var(--transition-normal);
}
/* App container */
.app-container {
width: 100%;
max-width: 1200px;
padding: 2.5rem 1.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* Header */
.app-header {
display: flex;
justify-content: center;
align-items: center;
position: relative;
margin-bottom: 0.5rem;
}
.app-header h1 {
font-size: 1.75rem;
font-weight: 700;
letter-spacing: -0.025em;
color: var(--text-main);
text-align: center;
}
#theme-toggle {
position: absolute;
right: 0;
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-main);
padding: 8px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
transition: border-color var(--transition-fast), background-color var(--transition-fast), transform var(--transition-fast);
}
#theme-toggle:hover {
border-color: var(--border-hover);
background-color: rgba(255, 255, 255, 0.05);
transform: scale(1.05);
}
body.light-theme #theme-toggle:hover {
background-color: rgba(0, 0, 0, 0.03);
}
/* Toggle Sun/Moon icons depending on active class */
.sun-icon {
display: none;
width: 20px;
height: 20px;
}
.moon-icon {
display: block;
width: 20px;
height: 20px;
}
body.light-theme .sun-icon {
display: block;
}
body.light-theme .moon-icon {
display: none;
}
/* Main Content Area */
.app-main {
background-color: var(--bg-panel);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 2rem;
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
gap: 1.75rem;
transition: background-color var(--transition-normal), border-color var(--transition-normal);
}
/* Action Controls & Banner Row */
.control-row {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.action-buttons {
display: flex;
gap: 0.75rem;
align-items: center;
}
/* Circular Connect Button */
.connect-btn {
width: 48px;
height: 48px;
border-radius: 50%;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
transition: transform var(--transition-fast), background-color var(--transition-fast), box-shadow var(--transition-fast);
box-shadow: var(--shadow-md);
}
.connect-btn:hover {
transform: scale(1.08);
}
.connect-btn.disconnected {
background-color: var(--accent-green);
}
.connect-btn.disconnected:hover {
background-color: var(--accent-green-hover);
box-shadow: 0 0 12px rgba(16, 185, 129, 0.4);
}
.connect-btn.connected {
background-color: var(--accent-danger);
}
.connect-btn.connected:hover {
background-color: var(--accent-danger-hover);
box-shadow: 0 0 12px rgba(239, 68, 68, 0.4);
}
.connect-btn svg {
width: 20px;
height: 20px;
}
/* Play/Stop SVG displays */
.connect-btn.disconnected .stop-icon {
display: none;
}
.connect-btn.disconnected .play-icon {
display: block;
}
.connect-btn.connected .stop-icon {
display: block;
}
.connect-btn.connected .play-icon {
display: none;
}
/* Download Button */
.download-btn {
width: 48px;
height: 48px;
border-radius: 8px;
border: none;
background-color: var(--accent-blue);
color: #ffffff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform var(--transition-fast), background-color var(--transition-fast), opacity var(--transition-fast);
box-shadow: var(--shadow-md);
}
.download-btn:hover:not(:disabled) {
background-color: var(--accent-blue-hover);
transform: scale(1.08);
}
.download-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.download-btn svg {
width: 22px;
height: 22px;
}
/* Status Banner */
.status-banner {
flex-grow: 1;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.85rem 1.25rem;
border-radius: var(--radius-md);
font-size: 0.9rem;
font-weight: 500;
line-height: 1.4;
border: 1px solid transparent;
transition: background-color var(--transition-fast), border-color var(--transition-fast);
}
.status-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
}
/* Status Banner Themes */
.status-banner.warn {
background-color: rgba(245, 158, 11, 0.1);
border-color: rgba(245, 158, 11, 0.25);
color: var(--accent-warn-border);
}
body.light-theme .status-banner.warn {
background-color: var(--accent-warn-bg);
border-color: var(--accent-warn-border);
color: var(--accent-warn-text);
}
.status-banner.connected {
background-color: rgba(16, 185, 129, 0.1);
border-color: rgba(16, 185, 129, 0.25);
color: var(--accent-green);
}
body.light-theme .status-banner.connected {
background-color: #ecfdf5;
border-color: var(--accent-green);
color: #047857;
}
.status-banner.error {
background-color: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.25);
color: var(--accent-danger);
}
body.light-theme .status-banner.error {
background-color: #fef2f2;
border-color: var(--accent-danger);
color: #b91c1c;
}
/* Port Config Dropdowns Grid */
.settings-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.25rem;
}
@media (max-width: 600px) {
.settings-grid {
grid-template-columns: 1fr;
}
.control-row {
flex-direction: column;
align-items: stretch;
}
.action-buttons {
justify-content: center;
}
}
.settings-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.settings-group label {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted);
}
/* Custom Styled Select Dropdown */
.select-wrapper {
position: relative;
width: 100%;
}
.select-wrapper select {
width: 100%;
padding: 0.75rem 1rem;
font-size: 0.95rem;
font-weight: 500;
font-family: var(--font-ui);
color: var(--text-main);
background-color: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
appearance: none;
cursor: pointer;
outline: none;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background-color var(--transition-fast);
}
.select-wrapper select:hover {
border-color: var(--border-hover);
}
.select-wrapper select:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
}
/* Select arrow icon custom styling */
.select-wrapper::after {
content: "";
position: absolute;
right: 1.25rem;
top: 50%;
transform: translateY(-50%);
width: 10px;
height: 6px;
background-color: var(--text-muted);
clip-path: polygon(100% 0, 0 0, 50% 100%);
pointer-events: none;
}
/* Small Select Dropdowns in UI Controls */
.select-wrapper-sm select {
padding: 0.4rem 2rem 0.4rem 0.75rem;
font-size: 0.85rem;
border-radius: var(--radius-sm);
}
.select-wrapper-sm::after {
right: 0.75rem;
width: 8px;
height: 5px;
}
/* Received Data Terminal Area */
.received-data-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.received-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.received-header h2 {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-main);
}
.received-controls {
display: flex;
gap: 1.25rem;
align-items: center;
}
.control-select-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.control-select-group label {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-muted);
white-space: nowrap;
}
.terminal-wrapper {
position: relative;
width: 100%;
}
.terminal-output {
width: 100%;
height: 400px;
background-color: var(--terminal-bg);
border: 1px solid var(--terminal-border);
border-radius: var(--radius-md);
padding: 1.25rem;
overflow-y: auto;
font-family: var(--font-mono);
font-size: 0.9rem;
line-height: 1.6;
color: var(--terminal-text);
white-space: pre-wrap;
word-break: break-all;
outline: none;
transition: border-color var(--transition-fast), background-color var(--transition-normal), color var(--transition-normal);
}
.terminal-output:focus {
border-color: var(--border-focus);
}
.terminal-output.empty {
color: var(--text-muted);
font-style: italic;
display: flex;
align-items: center;
justify-content: center;
}
/* Custom Scrollbar for Terminal Output */
.terminal-output::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.terminal-output::-webkit-scrollbar-track {
background: var(--terminal-bg);
border-radius: 0 var(--radius-md) var(--radius-md) 0;
}
.terminal-output::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 5px;
border: 2px solid var(--terminal-bg);
}
.terminal-output::-webkit-scrollbar-thumb:hover {
background: var(--border-hover);
}
/* Send Data Controls Footer */
.send-data-section {
margin-top: 0.25rem;
}
.send-bar {
display: flex;
gap: 0.75rem;
align-items: center;
}
.input-container {
flex-grow: 1;
position: relative;
}
.input-container input {
width: 100%;
padding: 0.75rem 1rem;
font-size: 0.95rem;
font-family: var(--font-ui);
color: var(--text-main);
background-color: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
outline: none;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background-color var(--transition-fast);
}
.input-container input:hover {
border-color: var(--border-hover);
}
.input-container input:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
}
.input-container input::placeholder {
color: var(--text-muted);
opacity: 0.7;
}
.send-format-wrapper {
width: 90px;
flex-shrink: 0;
}
.send-eol-wrapper {
width: 120px;
flex-shrink: 0;
}
/* Button UI */
.send-btn, .clear-btn {
width: 44px;
height: 44px;
border-radius: 8px;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
transition: transform var(--transition-fast), background-color var(--transition-fast);
box-shadow: var(--shadow-sm);
flex-shrink: 0;
}
.send-btn {
background-color: var(--accent-purple);
}
.send-btn:hover {
background-color: var(--accent-purple-hover);
transform: scale(1.05);
}
.send-btn:active {
transform: scale(0.98);
}
.clear-btn {
background-color: var(--bg-input);
border: 1px solid var(--border-color);
color: var(--text-muted);
}
.clear-btn:hover {
border-color: var(--accent-danger);
color: var(--accent-danger);
background-color: rgba(239, 68, 68, 0.05);
transform: scale(1.05);
}
body.light-theme .clear-btn:hover {
background-color: #fef2f2;
}
.clear-btn:active {
transform: scale(0.98);
}
.send-btn svg, .clear-btn svg {
width: 18px;
height: 18px;
}
/* Terminal Line Highlights & Formatting */
.terminal-line {
margin-bottom: 2px;
}
.terminal-line.sent {
color: var(--accent-purple);
font-weight: 500;
}
body.light-theme .terminal-line.sent {
color: #4f46e5;
}
.terminal-line.error {
color: var(--accent-danger);
font-weight: 600;
}
.terminal-line.system {
color: var(--text-muted);
font-style: italic;
}
/* Toast Notification Utility */
.toast-msg {
position: fixed;
bottom: 24px;
right: 24px;
background-color: var(--bg-panel);
border: 1px solid var(--border-color);
padding: 0.75rem 1.25rem;
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
font-size: 0.85rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.5rem;
transform: translateY(100px);
opacity: 0;
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275), opacity 0.3s ease;
z-index: 1000;
}
.toast-msg.show {
transform: translateY(0);
opacity: 1;
}
.toast-success {
border-color: var(--accent-green);
color: var(--accent-green);
}
.toast-error {
border-color: var(--accent-danger);
color: var(--accent-danger);
}
/* Sidebar and Split Layout */
.app-layout {
display: grid;
grid-template-columns: 280px 1fr;
gap: 1.5rem;
width: 100%;
align-items: start;
}
@media (max-width: 900px) {
.app-layout {
grid-template-columns: 1fr;
}
}
.app-sidebar {
background-color: var(--bg-panel);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 1.5rem;
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
gap: 1.25rem;
transition: background-color var(--transition-normal), border-color var(--transition-normal);
}
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.sidebar-header h2 {
font-size: 1.15rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text-main);
}
.add-session-btn {
width: 34px;
height: 34px;
border-radius: 50%;
border: none;
background-color: var(--accent-purple);
color: #ffffff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform var(--transition-fast), background-color var(--transition-fast);
box-shadow: var(--shadow-sm);
}
.add-session-btn:hover {
background-color: var(--accent-purple-hover);
transform: scale(1.08);
}
.add-session-btn svg {
width: 16px;
height: 16px;
}
.session-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-height: 500px;
overflow-y: auto;
padding-right: 4px;
}
/* Custom Scrollbar for Session List */
.session-list::-webkit-scrollbar {
width: 6px;
}
.session-list::-webkit-scrollbar-track {
background: transparent;
}
.session-list::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
.session-list::-webkit-scrollbar-thumb:hover {
background: var(--border-hover);
}
.session-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background-color: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
cursor: pointer;
transition: border-color var(--transition-fast), background-color var(--transition-fast), transform var(--transition-fast);
user-select: none;
position: relative;
}
.session-item:hover {
border-color: var(--border-hover);
background-color: rgba(255, 255, 255, 0.02);
}
body.light-theme .session-item:hover {
background-color: rgba(0, 0, 0, 0.01);
}
.session-item.active {
border-color: var(--border-focus);
background-color: rgba(99, 102, 241, 0.08);
box-shadow: 0 0 0 1px var(--border-focus);
}
body.light-theme .session-item.active {
background-color: rgba(99, 102, 241, 0.05);
}
.session-item-left {
display: flex;
align-items: center;
gap: 0.75rem;
flex-grow: 1;
min-width: 0;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
transition: background-color var(--transition-fast), box-shadow var(--transition-fast);
}
.status-dot.disconnected {
background-color: var(--text-muted);
}
.status-dot.connected {
background-color: var(--accent-green);
box-shadow: 0 0 8px var(--accent-green);
}
.session-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.session-name {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-main);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-detail {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-delete-btn {
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: color var(--transition-fast), background-color var(--transition-fast);
opacity: 0.6;
}
.session-item:hover .session-delete-btn {
opacity: 1;
}
.session-delete-btn:hover {
color: var(--accent-danger);
background-color: rgba(239, 68, 68, 0.1);
}
.session-rename-input {
background-color: var(--bg-panel);
border: 1px solid var(--border-focus);
color: var(--text-main);
font-family: var(--font-ui);
font-size: 0.9rem;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
outline: none;
width: 100%;
}
/* Utilities */
.hidden {
display: none !important;
}
/* Download Group Control Styling */
.download-group {
display: flex;
align-items: center;
gap: 0.25rem;
background-color: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 3px 6px 3px 3px;
height: 48px;
box-shadow: var(--shadow-sm);
transition: border-color var(--transition-fast), background-color var(--transition-fast);
}
.download-group:hover {
border-color: var(--border-hover);
}
.download-group .download-btn {
height: 40px;
width: 40px;
border-radius: 6px;
box-shadow: none;
}
.download-group .download-btn:hover:not(:disabled) {
transform: none;
}
.download-select-wrapper {
width: 105px;
flex-shrink: 0;
}
.download-select-wrapper select {
background-color: transparent !important;
border: none !important;
padding: 0.4rem 1.75rem 0.4rem 0.5rem !important;
font-weight: 600;
font-size: 0.85rem;
color: var(--text-main);
box-shadow: none !important;
}
.download-select-wrapper select option {
background-color: var(--bg-panel);
color: var(--text-main);
}
.download-select-wrapper::after {
right: 0.5rem;
}
+252
View File
@@ -0,0 +1,252 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>UART Terminal</title>
<!-- Google Fonts for premium design: Inter for UI, JetBrains Mono for monospace data -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet">
<link rel="stylesheet" href="./index.css">
</head>
<body class="dark-theme">
<div class="app-container">
<!-- Header -->
<header class="app-header">
<h1>UART Terminal</h1>
<button id="theme-toggle" class="icon-btn" aria-label="Toggle Theme">
<!-- Sun Icon -->
<svg class="sun-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
<!-- Moon Icon -->
<svg class="moon-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</button>
</header>
<!-- App Layout Split -->
<div class="app-layout">
<!-- Sidebar Panel -->
<aside class="app-sidebar">
<div class="sidebar-header">
<h2>Sessions</h2>
<button id="add-session-btn" class="add-session-btn" title="Add new session">
<!-- Plus Icon -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
</div>
<div id="session-list" class="session-list">
<!-- Session items dynamically injected -->
</div>
</aside>
<!-- Main Workspace -->
<main class="app-main">
<!-- Connection Controls & Status -->
<section class="control-row">
<div class="action-buttons">
<button id="connect-btn" class="connect-btn disconnected" title="Connect to serial port">
<!-- Play Icon -->
<svg class="play-icon" viewBox="0 0 24 24" fill="currentColor">
<polygon points="6,4 20,12 6,20"></polygon>
</svg>
<!-- Stop Icon -->
<svg class="stop-icon" viewBox="0 0 24 24" fill="currentColor">
<rect x="5" y="5" width="14" height="14" rx="2"></rect>
</svg>
</button>
<div class="download-group">
<button id="download-btn" class="download-btn" title="Download log" disabled>
<!-- Download Icon -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
</button>
<div class="select-wrapper select-wrapper-sm download-select-wrapper">
<select id="download-type" title="Select download content">
<option value="rx_tx" selected>RX & TX</option>
<option value="rx">RX Only</option>
<option value="tx">TX Only</option>
</select>
</div>
</div>
</div>
<!-- Connection Status Banner -->
<div id="status-banner" class="status-banner warn">
<svg class="status-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
<span id="status-text">Not connected. Click the connect button to select a port.</span>
</div>
</section>
<!-- Settings Form Grid -->
<section class="settings-grid">
<div class="settings-group">
<label for="baud-rate">Baud Rate</label>
<div class="select-wrapper">
<select id="baud-rate">
<option value="300">300</option>
<option value="1200">1200</option>
<option value="2400">2400</option>
<option value="4800">4800</option>
<option value="9600">9600</option>
<option value="19200">19200</option>
<option value="38400">38400</option>
<option value="57600">57600</option>
<option value="74880">74880</option>
<option value="115200" selected>115200</option>
<option value="230400">230400</option>
<option value="250000">250000</option>
<option value="500000">500000</option>
<option value="1000000">1000000</option>
<option value="2000000">2000000</option>
</select>
</div>
</div>
<div class="settings-group">
<label for="data-bits">Data Bits</label>
<div class="select-wrapper">
<select id="data-bits">
<option value="8" selected>8</option>
<option value="7">7</option>
<option value="6">6</option>
<option value="5">5</option>
</select>
</div>
</div>
<div class="settings-group">
<label for="stop-bits">Stop Bits</label>
<div class="select-wrapper">
<select id="stop-bits">
<option value="1" selected>1</option>
<option value="2">2</option>
</select>
</div>
</div>
<div class="settings-group">
<label for="parity">Parity</label>
<div class="select-wrapper">
<select id="parity">
<option value="none" selected>none</option>
<option value="even">even</option>
<option value="odd">odd</option>
</select>
</div>
</div>
</section>
<!-- Received Data Section -->
<section class="received-data-section">
<div class="received-header">
<h2>Received Data</h2>
<div class="received-controls">
<div class="control-select-group">
<label for="scroll-mode">Scroll</label>
<div class="select-wrapper select-wrapper-sm">
<select id="scroll-mode">
<option value="auto" selected>Auto-scroll</option>
<option value="none">No scroll</option>
</select>
</div>
</div>
<div class="control-select-group">
<label for="display-format">Display Format</label>
<div class="select-wrapper select-wrapper-sm">
<select id="display-format">
<option value="auto" selected>Auto</option>
<option value="text">Text</option>
<option value="hex">Hex</option>
</select>
</div>
</div>
</div>
</div>
<!-- Terminal Console -->
<div id="terminal-container" class="terminal-wrapper">
<!-- Terminal elements will be dynamically appended here per session -->
</div>
</section>
<!-- Send Data Input Area -->
<section class="send-data-section">
<div class="send-bar">
<div class="input-container">
<input type="text" id="send-input" placeholder="Enter data to send..." autocomplete="off">
</div>
<div class="select-wrapper select-wrapper-sm send-format-wrapper">
<select id="send-format">
<option value="ascii" selected>ASCII</option>
<option value="hex">Hex</option>
</select>
</div>
<div class="select-wrapper select-wrapper-sm send-eol-wrapper">
<select id="send-eol">
<option value="none">None</option>
<option value="lf">LF (\n)</option>
<option value="cr">CR (\r)</option>
<option value="crlf" selected>CRLF (\r\n)</option>
</select>
</div>
<button id="send-btn" class="send-btn" title="Send Data (Ctrl+Enter)">
<!-- Send / Paper Airplane Icon -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
</button>
<button id="clear-btn" class="clear-btn" title="Clear Terminal Console">
<!-- Trash Icon -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</button>
</div>
</section>
</main>
</div>
</div>
<script type="module" src="./app.js"></script>
</body>
</html>
+1026
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
{
"name": "web-uart-terminal",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"vite": "^5.2.11"
}
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vite';
export default defineConfig({
server: {
port: 3000,
open: false, // Don't auto open browser in command-line environments to avoid errors
},
base: './',
});
-9
View File
@@ -4,15 +4,6 @@ import vue from '@vitejs/plugin-vue'
export default defineConfig({
base: './',
plugins: [vue()],
server: {
proxy: {
'/api': {
target: 'https://127.0.0.1:8000',
changeOrigin: true,
secure: false, // 允許自簽憑證
},
},
},
build: {
outDir: 'static/build',
emptyOutDir: true,