18 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
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
35 changed files with 6835 additions and 502793 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
-137
View File
@@ -1,137 +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.
> ⚠️ **開發 vs 部署 (Development vs Production)**
> - **開發模式 (Development)**: 透過 `npm run dev` 啟動 Vite dev server (預設 `http://localhost:5173`)。前端原始碼修改後會自動熱更新 (HMR),**不需要** 手動 build。API 請求透過 `vite.config.js` 的 proxy 設定轉發至後端。
> - **生產模式 (Production)**: 透過 `http://localhost:8000/ui/` 存取。FastAPI 直接 serve `static/build/` 裡的 production bundle。**修改 `src/` 原始碼後,必須執行 `npm run build` 才會生效**。忘記 build 會導致前端程式碼與後端不同步,所有新功能看起來都「沒有作用」。
- **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.
---
## 💎 V2 Feature: Parallel Fixed-Point Simulation
本分支 (`dev-v2-integretimedomain`) 的核心任務是在時域分析中實作「雙路徑對照系統」:
### 1. 雙路徑模擬邏輯 (Dual-Path Logic)
- **理想路徑 (Float Path)**: 使用 64-bit 浮點數進行運算,作為效能基準。
- **硬體模擬路徑 (Integer Path)**:
- **輸入量化**: 將原始訊號依 $Q_{in}$ 轉為整數。
- **整數運算**: 使用整數係數 ($Q_{coeff}$) 進行差分方程式計算。
- **位移校準**: 運算過程中考慮累加器位元數,並依 $Q_{out}$ 進行最終縮放。
- **效果**: 模擬真實 DSP 的捨入誤差 (Rounding Error) 與量化雜訊。
### 2. 資料流更新
- **API `/api/filter`**: 接收檔案 ID 與係數參數,返回兩組數列(Float / Fixed)。
- **UI 圖表**: 同時繪製兩條曲線,讓開發者直觀判斷量化是否導致不穩定 (Instability) 或顯著失真。
### 3. 高效能資料處理架構 (High-Performance Data Processing)
- **檔案快取與防呆**:
- 支援高達 **300 MB** 的 CSV 檔案上傳。
- 當檔案被選擇後,立即背景上傳至後端暫存區 (`temp_csv/`),避免重複傳輸。
- 嚴格限制時域運算最多處理 **1,200,000 筆** 資料點,保護伺服器不因 O(N) 的純 Python 整數運算而死當。
- **精準記憶體讀取**:
- 利用 Pandas 的 `usecols` 參數,僅載入使用者選定的單一訊號欄位,極大幅度降低記憶體佔用。
- **即時預覽 (Live Preview)**:
- 透過前端的 Debounce 機制 (500ms),當使用者點擊 +/- 按鈕微調 Q-format 參數或係數時,系統會自動使用快取的檔案呼叫 API 並重繪圖表,達成無縫且直覺的實驗體驗。
---
## 🛠 Firmware Implementation Guidelines (韌體實作指引)
在將此工具設計出來的濾波器係數移植到 C 語言或 MCU (如 RISC-V) 時,請務必遵守以下實作準則,以確保硬體行為與本系統的模擬結果 100% 一致:
### 1. 高精度狀態變數架構 (Extended Precision State Variables)
為了極小化截斷誤差 (Truncation Error),硬體實作的迴圈請**不要**對前饋 (Feed-forward) 進行位移。
- **作法**:讓歷史陣列 `y_history[]` 保存前饋乘加後的高精度格式 ($Q_{in+b}$)。僅在計算回授 (Feedback) 的乘積後,才對回授項進行右移對齊,並在最終輸出給硬體腳位時做最後的右移。這等於讓 IIR 濾波器內部默默保留了額外的 $Q_b$ bits 小數精度。
### 2. 消除直流偏移 (DC Bias) 的 1-Clock Rounding 演算法秘技
傳統的右移 (`>>`) 會造成無條件捨去向下取整 (Floor),這會引入永遠為負的平均誤差 ($-0.5$ LSB),導致濾波器產生直流偏移。必須改用四捨五入 (Rounding)。
- **硬體現狀**:標準的 RISC-V 指令集 (RV32I / RV32IMAC) **沒有**硬體的 round off shift 指令 (其 `SRA` 指令是純 Floor)。除非晶片具備 DSP 擴充指令集 ('P' Extension)。
- **C 語言實作秘技**:絕對**不要**為了四捨五入去呼叫 C standard library 的 `round()`,這會啟動浮點數運算,吃掉上百個 Clock。請用純整數實作:
```c
// 完美的 1-clock 整數四捨五入寫法 (Round to nearest):
// 編譯器會將 (1 << (shift_bits - 1)) 編譯為常數,只會多消耗一個 ADD 指令。
y_out = (acc + (1 << (shift_bits - 1))) >> shift_bits;
```
這個技巧已實作於本工具前端的「Round (+0.5 補償)」選項中,開啟後可大幅提升信噪比與波形穩定性。
---
## ⚙️ Deployment & Development Notes (部署與開發環境注意事項)
為了支援 **Web Serial API** 直接與外部 MCU 通訊,本專案在生產與正式使用中**全面採用 HTTPS 加密傳輸**。這對於開發代理(Proxy)與存取路徑有以下影響:
### 1. Vite Proxy 與 SSL 協定對齊
當 Uvicorn 以 **HTTPS** 模式啟動時(綁定 `key.pem` 與 `cert.pem`),Vite Dev Server 的 `/api` 代理目標**必須對齊為 `https://`**,並加上 `secure: false` 以允許自簽憑證:
```js
// ✅ 正確 (HTTPS 模式下)
proxy: {
'/api': {
target: 'https://127.0.0.1:8000',
changeOrigin: true,
secure: false
}
}
```
### 2. 生產部署流程(純使用者使用)
如果您是**純使用者**,或者需要進行系統正式展示,**完全不需要啟動 Vite Dev Server (5173)**
您只需要在修改前端後進行一次編譯,然後使用 FastAPI 單獨運行 8000 連接埠即可:
```bash
# 1. 前端編譯 (只需在修改 src/ 代碼後執行一次,若無修改則免)
npm run build # 將 Vue 代碼打包至 static/build/
# 2. 啟動後端 HTTPS 服務 (同時提供 API 與網頁 UI)
.venv/bin/uvicorn dea_api:app --host 0.0.0.0 --port 8000 --ssl-keyfile certs/key.pem --ssl-certfile certs/cert.pem
```
或直接利用 systemd 後台服務啟動(開機自動拉起):
```bash
sudo systemctl restart dea.service
```
### 3. 開發模式 vs 生產模式對照表
| 項目 | 開發模式 (Vite Dev Server) | 生產模式 (FastAPI Static Serve) |
|------|----------------|---------------------|
| **運行連接埠** | `http://localhost:5173` | `https://localhost:8000/ui/` |
| **後端通訊** | 透過 Vite Proxy 轉發至 8000 (HTTPS) | 同一 Port 直接連線,極致穩定且高效 |
| **修改後生效** | 自動熱更新 (HMR),不需手動 build | 必須執行 `npm run build`,並在瀏覽器進行硬重新整理 |
| **主要用途** | 適合前端網頁排版、UI 除錯開發 | **正式使用、Demo 展示、MCU Web Serial 通訊** |
| **是否需 5173**| **是** | **否,完全不需啟用!** |
-293
View File
@@ -1,293 +0,0 @@
# Difference Equation Analyzer (DEA)
## 定點數整數差分方程式實作規格說明書
本文件旨在為嵌入式韌體、DSP 與硬體描述語言(HDL)開發工程師,提供本專案「定點數整數濾波模擬(`integer_lfilter`)」的完整實作細節與移植指引。
本系統所採用的定點數架構為**高精度狀態變數架構 (Extended Precision State Variables)**,並支援 **1-Clock 高效能硬體四捨五入 (Rounding)**,能有效消除直流偏移(DC Bias)並最大化訊噪比(SNR)。
---
## 1. 定點數 Q 格式 (Q-Format) 定義
在實作中,所有浮點數(實數)均以帶符號的整數進行二進位縮放表示:
$$\text{整數值} = \text{round}\left( \text{浮點數值} \times 2^{Q} \right)$$
本專案定義了以下四個獨立的 Q 格式位移參數:
| 參數名稱 | 代號 | 說明 | 格式描述 |
| :--- | :--- | :--- | :--- |
| `shift_in` | $Q_{in}$ | 輸入訊號 $x[n]$ 的量化位元數 | $x_{\text{int}}[n] \in Q_{in}$ |
| `shift_out` | $Q_{out}$ | 最終輸出訊號 $y[n]$ 的量化位元數 | $y_{\text{out}}[n] \in Q_{out}$ |
| `shift_b` | $Q_b$ | 前饋(Feedforward)係數 $b$ 的量化位元數 | $b_{\text{int}}[i] \in Q_b$ |
| `shift_a` | $Q_a$ | 回授(Feedback)係數 $a$ 的量化位元數 | $a_{\text{int}}[j] \in Q_a$ |
---
## 2. 核心架構:高精度狀態變數 (Extended Precision State Variables)
為了極小化乘加運算中的截斷誤差 (Truncation Error) 並防止 IIR 濾波器極點附近因量化產生的極限環震盪(Limit Cycles),本架構採用**前饋完全不右移**的策略:
1. **狀態變數 $y_{\text{hist}}$ 精度保持**:歷史狀態變數 $y_{\text{hist}}[n-j]$ 並非儲存低精度的最終輸出 $y_{\text{out}}$,而是直接儲存前饋與回授累加後、尚未進行輸出位移的**高精度狀態值**。其格式固定為:
$$y_{\text{hist}} \in Q_{in + b}$$
2. **免除中間位移**:在前饋運算中,$b_{\text{int}} \times x_{\text{int}}$ 的結果為 $Q_b \times Q_{in} = Q_{in+b}$,完全不需進行任何位移,即可直接累加至 `sum_b` 中。
3. **回授對齊縮放(歸一化 $Q_a$**:回授運算中,係數 $a_{\text{int}} \in Q_a$,歷史狀態 $y_{\text{hist}} \in Q_{in+b}$,兩者相乘後格式為 $Q_{in+b+a}$。
> 💡 **重要前提與物理意義:假設 $A_0 = 1$**
> 濾波器公式通常已正規化使得回授係數 $A_0 = 1$。當我們使用 $Q_a$ 將 $a$ 係數放大時,$A_0$ 的整數值實際上就是 $2^{Q_a}$。
>
> 在運算完畢後,這項「因為 $a$ 係數被放大了 $Q_a$ 倍」而產生的額外增益必須被**歸一(Normalize)**,否則數值會以指數級別爆炸!因此,我們將回授乘加總和除以 $A_0$(在二進位中等同於**算術右移 `shift_a` 位元**),藉此消去 $Q_a$ 的放大效應,使其完美對齊回 $Q_{in+b}$ 格式,才能與前饋結果相減。
>
> 💡 **關於輸入訊號 $x$ 與前饋係數 $b$ 的歸一化討論**
> 許多工程師會問:既然回授部分需要被歸一以防爆炸,那輸入訊號 $x$(放大了 $Q_{in}$ 倍)與前饋係數 $b$(放大了 $Q_b$ 倍)在運算過程中是否也需要被歸一化?
> **答案是:完全不需要,而且我們也故意沒有做!**
>
> 這是因為:
> 1. **避免資訊截斷(Truncation Noise**:如果我們在累加到 `sum_b` 時就進行右移歸一,會直接把低位元的小數精度給丟棄,造成無法挽回的量化雜訊。
> 2. **最優化運算效能**:在迴圈內部,`sum_b`$Q_{in+b}$)與被歸一後的 `sum_a_scaled`$Q_{in+b}$)可以直接進行減法運算。整個迴圈內部不需要對 $x$ 與 $b$ 進行 any 移位,這在硬體實作中極大提升了運算速度。
> 3. **延遲至最終輸出對齊**:輸入與前饋所累積下來的放大倍率(也就是 $Q_{in+b}$),將會完完整整地保留在狀態變數中,一路以高精度參與未來的回授迭代。直到濾波運算結束、準備將訊號寫入硬體輸出端(如 DAC)時,我們才會一次性地移去這段增益(即右移 $S_{\text{out}} = Q_{in} + Q_b - Q_{out}$ 位元)。
---
## 3. 核心演算法步驟與數學推導
對於每個輸入採樣 $x_{\text{float}}[n]$,整數濾波器的計算流程如下:
### 步驟一:輸入量化 (Input Quantization)
將浮點數輸入訊號量化為整數 $x_{\text{int}}[n]$
$$x_{\text{int}}[n] = \text{round}\left( x_{\text{float}}[n] \times 2^{Q_{in}} \right)$$
### 步驟二:前饋累加 (Feedforward Accumulation - 處理 $b$ 係數)
計算前饋部分,累積至高精度累加器 `sum_b`(規格使用 32-bit 帶符號整數 `int32_t`):
$$sum\_b = \sum_{i=0}^{N_b-1} b_{\text{int}}[i] \cdot x_{\text{int}}[n-i]$$
*此時 $sum\_b \in Q_{in+b}$。*
### 步驟三:回授累加與歸一化 (Feedback Accumulation - 處理 $a$ 係數)
1. 計算高精度的回授乘加值:
$$sum\_a = \sum_{j=1}^{N_a-1} a_{\text{int}}[j] \cdot y_{\text{hist}}[n-j]$$
*此時 $sum\_a \in Q_{in+b+a}$。*
2. **歸一化 $Q_a$ 放大倍數**:將回授項除以 $A_0$(即 $a_{\text{int}}[0]$,通常為 $2^{Q_a}$)以對齊至 $Q_{in+b}$
* **Floor 模式(無條件捨去,非常不建議)**:
$$sum\_a\_scaled = \lfloor \frac{sum\_a}{A_0} \rfloor \approx sum\_a \gg Q_a$$
* **Round 模式(四捨五入,專案預設與推薦)**:
$$round\_offset\_a = A_0 \gg 1$$
$$sum\_a\_scaled = \lfloor \frac{sum\_a + round\_offset\_a}{A_0} \rfloor \approx \left( sum\_a + (1 \ll (Q_a - 1)) \right) \gg Q_a$$
### 步驟四:更新狀態變數 (State Variable Update)
從前饋累加值中減去對齊後的回授值,並直接存入歷史狀態變數:
$$acc = sum\_b - sum\_a\_scaled$$
$$y_{\text{hist}}[n] = acc$$
*歷史狀態變數 $y_{\text{hist}}$ 格式為 $Q_{in+b}$。*
### 步驟五:最終輸出縮放 (Output Quantization & Scale-Out)
因為 $y$ 的實際物理數值與系統增益(System Gain)主要由 $b$ 和 $x$ 決定,這時內部變數已經穩定保持在 $Q_{in+b}$ 格式。
`shift_out` 的作用是將內部的高精度數值轉換為符合實體硬體接口(如 12-bit DAC)的位元寬度。
計算所需的總位移量 $S_{\text{out}}$
$$S_{\text{out}} = Q_{in} + Q_b - Q_{out}$$
* **當 $S_{\text{out}} > 0$(需要右移)**
* **Round 模式**
$$round\_offset\_out = 1 \ll (S_{\text{out}} - 1)$$
$$y_{\text{out}}[n] = (acc + round\_offset\_out) \gg S_{\text{out}}$$
* **當 $S_{\text{out}} < 0$(需要左移)**
$$y_{\text{out}}[n] = acc \ll (-S_{\text{out}})$$
* **當 $S_{\text{out}} == 0$(不需位移)**
$$y_{\text{out}}[n] = acc$$
---
## 4. 消除直流偏移的 1-Clock Rounding 演算法秘技
傳統的算術右移(Arithmetic Right Shift, `SRA`)在二進位中代表的是 **Floor(向負無窮大取整)**
這會帶來平均 $-0.5\text{ LSB}$ 的系統性截斷誤差,在時域訊號中會累積成顯著的**直流偏移(DC Bias)**,在 IIR 濾波器中甚至會導致輸出不斷漂移。
在 C 語言中,千萬不要呼叫 `round()` 等浮點數函式,請使用以下純整數運算:
```c
// 完美的 1-clock 四捨五入右移寫法:
// (1 << (shift - 1)) 在編譯時期會被編譯器直接優化為一個立即常數,
// 整體運算只比普通右移多消耗一個 ADD 指令,完美消除 DC Bias
y_out = (acc + (1 << (shift - 1))) >> shift;
```
---
## 5. 完整 C 語言 (MCU 32-bit 最佳化) 實作程式碼範例
在 32-bit 微處理器(如 ARM Cortex-M 或是 RISC-V RV32I)上,`int32_t` 運算是效能最高、最節省時鐘週期的核心格式(避免了昂貴的 64-bit 軟體乘除模擬)。
```c
#include <stdint.h>
#include <stdbool.h>
#define FILTER_ORDER_MAX 4 // 最大支援濾波器階數
typedef struct {
int32_t b[FILTER_ORDER_MAX + 1]; // Q_b 格式的前饋係數
int32_t a[FILTER_ORDER_MAX + 1]; // Q_a 格式的回授係數 (a[0] 通常為 1<<Q_a)
int32_t nb; // b 係數個數
int32_t na; // a 係數個數
int16_t shift_in; // Q_in
int16_t shift_out; // Q_out
int16_t shift_b; // Q_b
int16_t shift_a; // Q_a
bool use_round; // 是否啟用四捨五入補償
// 歷史狀態變數 (循環緩衝區)
int32_t x_hist[FILTER_ORDER_MAX + 1]; // Q_in 格式的輸入歷史
int32_t y_hist[FILTER_ORDER_MAX + 1]; // Q_{in+b} 格式的高精度狀態歷史
int32_t x_index; // x 歷史緩衝區指針
int32_t y_index; // y 歷史緩衝區指針
} FixedFilter_t;
/**
* @brief 初始化濾波器狀態與係數
*/
void FixedFilter_Init(FixedFilter_t *filter,
const int32_t *b_coeffs, int32_t nb,
const int32_t *a_coeffs, int32_t na,
int16_t shift_in, int16_t shift_out,
int16_t shift_b, int16_t shift_a,
bool use_round)
{
filter->nb = (nb > FILTER_ORDER_MAX + 1) ? FILTER_ORDER_MAX + 1 : nb;
filter->na = (na > FILTER_ORDER_MAX + 1) ? FILTER_ORDER_MAX + 1 : na;
for (int32_t i = 0; i < filter->nb; i++) filter->b[i] = b_coeffs[i];
for (int32_t j = 0; j < filter->na; j++) filter->a[j] = a_coeffs[j];
filter->shift_in = shift_in;
filter->shift_out = shift_out;
filter->shift_b = shift_b;
filter->shift_a = shift_a;
filter->use_round = use_round;
filter->x_index = 0;
filter->y_index = 0;
for (int32_t i = 0; i < FILTER_ORDER_MAX + 1; i++) {
filter->x_hist[i] = 0;
filter->y_hist[i] = 0;
}
}
/**
* @brief 核心定點數差分方程式執行 (32-bit 最佳化版本)
* @param x_in Q_in 格式的整數輸入值
* @return Q_out 格式的整數輸出值
*/
int32_t FixedFilter_Process(FixedFilter_t *filter, int32_t x_in)
{
// 1. 將新輸入寫入循環緩衝區
filter->x_hist[filter->x_index] = x_in;
// 2. 計算前饋 (Feedforward): 處理 b 係數 (Q_b * Q_in -> Q_{in+b})
int32_t sum_b = 0;
int32_t x_ptr = filter->x_index;
for (int32_t i = 0; i < filter->nb; i++) {
sum_b += filter->b[i] * filter->x_hist[x_ptr];
if (--x_ptr < 0) x_ptr = filter->nb - 1; // 循環指針回繞
}
// 3. 計算回授 (Feedback): 處理 a 係數 (Q_a * Q_{in+b} -> Q_{in+b+a})
int32_t sum_a = 0;
int32_t y_ptr = filter->y_index;
for (int32_t j = 1; j < filter->na; j++) {
// y_hist 儲存的是歷史中的高精度狀態變數 (Q_{in+b})
sum_a += filter->a[j] * filter->y_hist[y_ptr];
if (--y_ptr < 0) y_ptr = filter->na - 1;
}
// 4. 歸一化 a 係數的放大倍數 (除以 A0,即算術右移 shift_a)
int32_t A0 = filter->a[0];
if (A0 <= 0) A0 = 1; // 防呆
int32_t sum_a_scaled = 0;
if (filter->use_round) {
int32_t round_offset_a = A0 >> 1;
sum_a_scaled = (sum_a + round_offset_a) / A0;
} else {
sum_a_scaled = sum_a / A0;
}
// 如果 A0 是 2 的冪次方 (例如 2^shift_a),上述除法可被編譯器優化為算術右移:
// sum_a_scaled = (sum_a + (1 << (shift_a - 1))) >> shift_a;
// 5. 更新狀態變數並寫入歷史緩衝區
int32_t acc = sum_b - sum_a_scaled;
if (++(filter->y_index) >= filter->na) filter->y_index = 0;
filter->y_hist[filter->y_index] = acc;
// 6. 計算最終輸出量化 Q_{in+b} -> Q_{out}
int32_t out_shift = filter->shift_in + filter->shift_b - filter->shift_out;
int32_t y_out = 0;
if (out_shift > 0) {
if (filter->use_round) {
int32_t round_offset_out = (1 << (out_shift - 1));
y_out = (acc + round_offset_out) >> out_shift;
} else {
y_out = acc >> out_shift;
}
} else if (out_shift < 0) {
y_out = acc << (-out_shift);
} else {
y_out = acc;
}
if (++(filter->x_index) >= filter->nb) filter->x_index = 0;
return y_out;
}
```
---
## 6. 與 Python 模擬的一致性驗證
本專案後端 `dea/csv_processing.py` 中的 `integer_lfilter` 函數提供完全一致的參考實作。工程師在撰寫 C 語言硬體代碼時,可隨時將硬體輸出的暫存器數值與本專案的 Python 數值進行對齊測試:
```python
# 專案後端 Python 核心運算對照
def integer_lfilter(b_int, a_int, x_float, shift_in, shift_out, shift_b, shift_a, use_round=False):
x_int = np.round(x_float * (2**shift_in)).astype(np.int64)
y_hist = np.zeros(len(x_int), dtype=np.int64) # Q_{in+b} 狀態變數
y_out = np.zeros(len(x_int), dtype=np.int64) # Q_out 輸出變數
out_shift = shift_in + shift_b - shift_out
A0 = int(a_int[0])
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
# 前饋累積處理 b 係數 (Q_{in+b})
for i in range(len(b_int)):
if n - i >= 0:
sum_b += b_int[i] * x_int[n - i]
sum_a = 0
# 回授累積處理 a 係數 (Q_{in+b+a})
for j in range(1, len(a_int)):
if n - j >= 0:
sum_a += a_int[j] * y_hist[n - j]
# 歸一化 a 係數放大倍數,對齊回 Q_{in+b}
sum_a_scaled = (sum_a + round_offset_a) // A0
acc = sum_b - sum_a_scaled
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)
```
-114
View File
@@ -1,114 +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-v2-rounding-fix-20260517` (已推送到 origin)。
- **同步說明**:此版本包含了 Rounding 功能、Vite proxy 修復與 production build。
- **同事試用指令**
```bash
git fetch
git checkout dev-v2-rounding-fix-20260517
```
---
*Last Updated: 2026-05-15 17:47*
---
# DSP Bode Plot 專案進度更新 (2026-05-17)
## 6. 定點數 Rounding 模擬 (V2 Feature)
- **Round (+0.5 補償) 功能**:在 `integer_lfilter()` 中實作了硬體級四捨五入模擬,使用 `(acc + (1 << (shift - 1))) >> shift` 取代傳統的 `acc >> shift` (Floor)。
- **UI 控制**:於「定點數模擬設定」區塊新增 **Floor (SRA) / Round (+0.5)** 互斥按鈕(與「階數 Order」相同風格),放置於 Q_in / Q_out 同列。預設為 Round。
- **API 參數**`/api/filter` 與 `/api/filter/download` 端點已支援 `use_round` 參數。
- **高精度狀態變數架構**:前饋 (Feed-forward) 不右移,保留完整 $Q_{in+b}$ 精度;僅回授 (Feedback) 與最終輸出做位移縮放。
## 7. Vite Proxy 修正
- **Bug 修正**`vite.config.js` 的 proxy target 從 `https://` 修正為 `http://`,與 uvicorn 的實際 HTTP 協議一致。
---
## ⚠️ 踩雷紀錄 (Lessons Learned)
### 🔴 Production Build 未同步 (2026-05-17)
**症狀**:修改了 `src/app-options.js` 與 `src/App.vue` 後,UI 按鈕有出現但功能完全不生效(Round/Floor 切換後濾波器輸出完全沒變化)。花了大量時間除錯。
**根因**:使用者透過 `http://localhost:8000/ui/` 存取系統。FastAPI 直接 serve `static/build/app.js`,而該檔案停留在 5 月 15 日的舊版(距今超過一天)。所有前端修改都只存在於 `src/` 原始碼,從未被 `npm run build` 編譯輸出。
**解法**:修改 `src/` 後務必執行 `npm run build`,再 `Ctrl+Shift+R` 硬重新整理瀏覽器。
**預防措施**
1. 開發時優先使用 `http://localhost:5173/` (Vite dev server,自動 HMR)。
2. 若使用 port 8000,每次修改前端程式碼後必須 build。
3. 詳見 `ARCHITECTURE.md` 的「Development Environment Notes」章節。
### 🟡 Vite Proxy https/http 不匹配 (2026-05-17)
**症狀**Vite dev server 的 API 轉發全部失敗 (502)uvicorn 日誌大量出現 `Invalid HTTP request received.`。
**根因**`vite.config.js` 設定了 `target: 'https://127.0.0.1:8000'`,但 uvicorn 以 HTTP 模式啟動。Vite proxy 嘗試發送 TLS 握手封包到純 HTTP 伺服器。
**解法**:將 proxy target 改為 `http://127.0.0.1:8000`,移除 `secure: false`。
---
*Last Updated: 2026-05-17 00:43*
---
# DSP Bode Plot 專案進度更新 (2026-05-17 - 第二階段重構與優化)
## 8. 回授變數命名重構與物理意義釐清
- **變數命名精確化**:為消除舊版回授項以 `fb_` 開頭容易讓工程師誤解為 `b` 係數的命名混淆,將 `dea/csv_processing.py` 的變數全面更名:
- `fb_sum` $\rightarrow$ **`sum_a`** (回授 a 累加項)
- `fb_shifted` $\rightarrow$ **`sum_a_scaled`** (已除以 $A_0$ 歸一化後的回授項)
- `acc` 運算直接更新為直觀的 `acc = sum_b - sum_a_scaled`,完全與數學公式鏡像對齊。
- **明確白話文檔指引**:在根目錄與 bodeplot 目錄下的 `INTEGER_FILTER_IMPLEMENTATION.md` 文件中,新增了兩項白話物理意義說明:
1. **為什麼回授部分除以 $A_0$**(假設 $A_0 = 1$,放大後即為 $2^{Q_a}$,除以 $A_0$ 等同於算術右移 `shift_a`,旨在消去 $Q_a$ 放大的效應以防數值爆炸)。
2. **為什麼輸入訊號 $x$ 與前饋 $b$ 不用中間歸一化?**(說明為了防止小數精度因中間右移而被截斷丟棄,故意將高精度一路鎖在歷史狀態變數中,延遲至最終輸出時才進行一次性對齊,藉此獲得最大 SNR 與最佳效能)。
3. **加註 Floor 模式的警告**:加註 `(無條件捨去,非常不建議)`,指引工程師優先使用 Round 補償。
## 9. 韌體 C 語言實作最佳化 (32-bit MCU Optimization)
- **32位元整數重構**:為避免 64 位元除法/乘法在 32 位元微處理器(如 ARM Cortex-M 或是 RISC-V RV32I)上引發昂貴的軟體模擬消耗,將 `INTEGER_FILTER_IMPLEMENTATION.md` 中提供的 C 語言 Process 範例全部改為由 **`int32_t`** 為核心的高效能版本。
## 10. Vite HTTPS Proxy & SSL 完全對齊
- **HTTPS 代理修復**:為同時支援 Web Serial API(瀏覽器實體連接埠,要求 HTTPS 協議)與 Vite 5173 的自動熱更新開發,將 `vite.config.js` 的 proxy target 更新為 **`https://127.0.0.1:8000`**,並加上 **`secure: false`**(允許繞過自簽憑證限制),徹底解決了前端對代理伺服器發送明文請求所導致的 `Failed to execute 'json' on 'Response': Unexpected end of JSON input` 報錯。
## 11. 目前 Git 狀態與 GitLab 同步
- **當前分支**`dev-v2-integretimedomain`
- **提交與推送**:已經將最新的變數重構、32-bit C 代碼優化、Vite HTTPS Proxy 更新成功 commit 並 Push 至 GitLab 遠端儲存庫,分支版控狀態極其乾淨。
---
*Last Updated: 2026-05-17 14:26*
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(),
},
}
+167 -47
View File
@@ -2,6 +2,8 @@ import io
import os
import uuid
import re
import tempfile
import time
import numpy as np
import pandas as pd
@@ -11,10 +13,37 @@ import math
from .config import MAX_CSV_BYTES, MAX_PLOT_POINTS, MAX_ROWS
TEMP_CSV_DIR = "temp_csv"
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 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")
@@ -23,6 +52,34 @@ def get_cached_file_path(file_id):
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:
@@ -31,7 +88,22 @@ def downsample_for_plot(index, original, filtered_float, filtered_fixed):
return index[::step], original[::step], filtered_float[::step], filtered_fixed[::step], step
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 檔案")
@@ -40,7 +112,7 @@ async def save_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 不可為空")
file_id = str(uuid.uuid4())
file_path = os.path.join(TEMP_CSV_DIR, f"{file_id}.csv")
with open(file_path, "wb") as f:
@@ -57,20 +129,20 @@ def integer_lfilter(b_int, a_int, x_float, shift_in, shift_out, shift_b, shift_a
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。
@@ -87,22 +159,22 @@ def integer_lfilter(b_int, a_int, x_float, shift_in, shift_out, shift_b, shift_a
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
@@ -114,35 +186,76 @@ def integer_lfilter(b_int, a_int, x_float, shift_in, shift_out, shift_b, shift_a
return y_out.astype(float) / (2**shift_out)
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):
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 = cols[col_idx]
# 精準讀取單一欄位,並加上筆數限制 (極大降低記憶體用量與時間)
df = pd.read_csv(path, usecols=[col_idx], nrows=MAX_ROWS)
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} 含有非有限數值")
x_signal, x_values = _numeric_signal(df[col_to_filter], col_to_filter)
# 路徑 1: 理想浮點數路徑
y_float = signal.lfilter(b_vals, a_vals, x_values)
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,
)
# 路徑 2: 整數模擬路徑
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
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
index, original, filtered_float, filtered_fixed, step = downsample_for_plot(df.index.to_numpy(), x_signal, y_float, y_fixed)
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(),
@@ -150,32 +263,39 @@ def filter_preview_response(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=
"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(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):
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 = pd.to_numeric(df[col_to_filter], errors="coerce")
x_values = x_signal.to_numpy(dtype=float)
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
df[f"{col_to_filter}_filtered_ideal"] = y_float
df[f"{col_to_filter}_filtered_fixed"] = y_fixed
csv_buffer = io.StringIO()
df.to_csv(csv_buffer, index=False)
return csv_buffer.getvalue()
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)
+79 -14
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,
@@ -15,11 +15,12 @@ from dea.csv_processing import (
downsample_for_plot,
filter_preview_response,
filtered_csv_text,
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,
@@ -50,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("/")
@@ -57,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:
@@ -87,6 +94,27 @@ 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:
@@ -99,6 +127,28 @@ async def upload_csv(file: UploadFile = File(...)):
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_id: str = Form(...),
@@ -112,20 +162,27 @@ async def filter_csv(
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")
b_int_vals = parse_int_coefficients(b_int, "b_int") if b_int else None
a_int_vals = parse_int_coefficients(a_int, "a_int") if a_int else None
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,
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_in=shift_in, shift_out=shift_out,
shift_b=shift_b, shift_a=shift_a,
use_round=use_round
use_round=use_round,
stages=stages_list,
start_idx=start_idx,
end_idx=end_idx
)
except HTTPException:
raise
@@ -167,20 +224,28 @@ async def filter_csv_download(
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")
b_int_vals = parse_int_coefficients(b_int, "b_int") if b_int else None
a_int_vals = parse_int_coefficients(a_int, "a_int") if a_int else None
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
use_round=use_round,
stages=stages_list,
fs=fs,
compact=compact_value
)
return StreamingResponse(
+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",
+444 -308
View File
File diff suppressed because it is too large Load Diff
+598 -48
View File
@@ -1,3 +1,5 @@
import StageEditor from './components/StageEditor.vue';
import { computeStageFixedCoeffs, createCascadeFilterPayload, normalizeCascadeStage } from './cascade-stage.js';
const DEFAULT_FILTER_TYPE = "Lowpass (低通)";
const MANUAL_FILTER_TYPE = "(無) 手動自訂";
const DEFAULT_FS = 100000.0;
@@ -18,14 +20,32 @@ const DEFAULT_PARAMS = {
const cloneDefaultParams = () => ({ ...DEFAULT_PARAMS });
const zero7 = () => [...ZERO_7];
const createStageId = () => `${Date.now()}-${Math.random().toString(36).slice(2)}`;
export default {
components: {
StageEditor,
},
data() {
return {
fs: DEFAULT_FS,
b_str: DEFAULT_B_STR,
a_str: DEFAULT_A_STR,
filterType: DEFAULT_FILTER_TYPE,
cascadeStages: [],
activeCascadeStageIndex: 0,
draggingCascadeStageIndex: null,
dragOverCascadeStageIndex: null,
stageDragPointerId: null,
draggedCascadeStageId: null,
stageDragStartX: 0,
stageDragStartY: 0,
stageDragCurrentX: 0,
stageDragCurrentY: 0,
zoomStartIdx: null, zoomEndIdx: null,
timePlotUnit: null,
timePlotMultiplier: null,
isRedrawingTimePlot: false,
filterOptions: [
"Lowpass (低通)", "Highpass (高通)", "Bandpass (帶通)",
"Notch (陷波器)", "1P1Z (一極一零)", "2P1Z (二極一零)", "2P2Z (二極二零)",
@@ -44,6 +64,7 @@ export default {
loadingBode: false, bodeTimeout: null, globalError: null,
csvFile: null, csvFileId: null, csvColumns: [], csvPreview: [], csvInfo: null, csvParseError: null,
csvUploading: false, presetCsvAvailable: false, presetCsvName: '', usingPresetCsv: false,
isCsvPreviewExpanded: true,
selectedColumn: 0, loadingFilter: false, filterDone: false, timePlotData: null,
mobileTab: 'settings', // 手機版頁籤:'settings' | 'chart'
@@ -80,6 +101,8 @@ export default {
webSerialSupported: false,
writingMCU: false,
mcuStatus: '',
saveSettingsTimeout: null,
isSidebarCollapsed: localStorage.getItem('dea_sidebar_collapsed') === 'true',
bodeMagRange: null, // [min, max] for Y-axis
}
},
@@ -221,6 +244,60 @@ export default {
return this.fixedCurrentA1 + this.fixedCurrentA2;
}
},
watch: {
fs: 'debouncedSaveSettings',
b_str() {
this.debouncedSaveSettings();
this.debouncedProcessFilter();
},
a_str() {
this.debouncedSaveSettings();
this.debouncedProcessFilter();
},
filterType: 'debouncedSaveSettings',
systemGain: 'debouncedSaveSettings',
params: { handler: 'debouncedSaveSettings', deep: true },
baseB: { handler: 'debouncedSaveSettings', deep: true },
baseA: { handler: 'debouncedSaveSettings', deep: true },
bSliders: { handler: 'debouncedSaveSettings', deep: true },
aSliders: { handler: 'debouncedSaveSettings', deep: true },
sense_b: 'debouncedSaveSettings',
sense_a: 'debouncedSaveSettings',
shiftBitsB() {
this.debouncedSaveSettings();
this.debouncedProcessFilter();
},
shiftBitsA() {
this.debouncedSaveSettings();
this.debouncedProcessFilter();
},
shiftBitsIn() {
this.debouncedSaveSettings();
this.debouncedProcessFilter();
},
shiftBitsOut() {
this.debouncedSaveSettings();
this.debouncedProcessFilter();
},
useRound() {
this.debouncedSaveSettings();
this.debouncedProcessFilter();
},
fixedOverrides: { handler: 'debouncedSaveSettings', deep: true },
aFineStep: 'debouncedSaveSettings',
fixedAFineStep: 'debouncedSaveSettings',
b_int_str() { this.debouncedProcessFilter(); },
a_int_str() { this.debouncedProcessFilter(); },
selectedColumn(newVal, oldVal) {
if (newVal !== oldVal) {
this.zoomStartIdx = null;
this.zoomEndIdx = null;
this.timePlotUnit = null;
this.timePlotMultiplier = null;
this.debouncedProcessFilter();
}
}
},
mounted() {
this.isTouchInput = window.matchMedia?.('(pointer: coarse)').matches || false;
window.addEventListener('pointerdown', this.rememberPointerType);
@@ -232,12 +309,17 @@ export default {
} else {
document.documentElement.classList.remove('dark');
}
this.loadSettings();
this.webSerialSupported = ('serial' in navigator);
if (!this.webSerialSupported) {
this.mcuStatus = '此瀏覽器不支援 Web Serial(需使用 Chrome 或 Edge,且透過 HTTPS 連線)';
}
this.ensureCascadeStages();
this.updateBodeMagRange();
this.applyFilterDesign();
this.loadPresetWaveforms();
},
beforeUnmount() {
window.removeEventListener('pointerdown', this.rememberPointerType);
@@ -253,6 +335,362 @@ export default {
this.stopShiftOutDrag();
},
methods: {
toggleSidebar() {
this.isSidebarCollapsed = !this.isSidebarCollapsed;
localStorage.setItem('dea_sidebar_collapsed', this.isSidebarCollapsed ? 'true' : 'false');
const startTime = Date.now();
const interval = setInterval(() => {
window.dispatchEvent(new Event('resize'));
if (Date.now() - startTime > 400) {
clearInterval(interval);
window.dispatchEvent(new Event('resize'));
this.resizePlots();
}
}, 30);
},
resizePlots() {
const bode = document.getElementById('bodePlot');
const time = document.getElementById('timePlot');
if (bode && window.Plotly) window.Plotly.Plots.resize(bode);
if (time && window.Plotly) window.Plotly.Plots.resize(time);
},
stageTitle(index) {
const stage = this.cascadeStages[index];
return stage?.filterType || `Stage ${index + 1}`;
},
cloneStage(stage) {
return JSON.parse(JSON.stringify(stage));
},
captureCurrentStage() {
return {
id: this.cascadeStages[this.activeCascadeStageIndex]?.id || createStageId(),
isActive: this.cascadeStages[this.activeCascadeStageIndex]?.isActive ?? true,
isExpanded: this.cascadeStages[this.activeCascadeStageIndex]?.isExpanded ?? true,
filterType: this.filterType,
b_str: this.b_str,
a_str: this.a_str,
systemGain: this.systemGain,
params: { ...this.params },
baseB: [...this.baseB],
baseA: [...this.baseA],
bSliders: [...this.bSliders],
aSliders: [...this.aSliders],
shiftBitsB: this.shiftBitsB,
shiftBitsA: this.shiftBitsA,
fixedOverrides: {
a: { ...this.fixedOverrides.a },
b: { ...this.fixedOverrides.b },
},
outOfRangeB: [...this.outOfRangeB],
outOfRangeA: [...this.outOfRangeA],
aFineStep: this.aFineStep,
fixedAFineStep: this.fixedAFineStep,
activeCoeffAdjustment: this.activeCoeffAdjustment,
};
},
applyStageToControls(stage) {
stage = normalizeCascadeStage(stage);
this.filterType = stage.filterType;
this.b_str = stage.b_str;
this.a_str = stage.a_str;
this.systemGain = stage.systemGain;
this.params = { ...stage.params };
this.baseB = [...stage.baseB];
this.baseA = [...stage.baseA];
this.bSliders = [...stage.bSliders];
this.aSliders = [...stage.aSliders];
this.shiftBitsB = stage.shiftBitsB;
this.shiftBitsA = stage.shiftBitsA;
this.fixedOverrides = {
a: { ...stage.fixedOverrides.a },
b: { ...stage.fixedOverrides.b },
};
this.outOfRangeB = [...stage.outOfRangeB];
this.outOfRangeA = [...stage.outOfRangeA];
this.aFineStep = stage.aFineStep;
this.fixedAFineStep = stage.fixedAFineStep;
this.activeCoeffAdjustment = stage.activeCoeffAdjustment;
},
ensureCascadeStages() {
if (!Array.isArray(this.cascadeStages)) this.cascadeStages = [];
if (this.cascadeStages.length === 0) {
this.cascadeStages = [this.captureCurrentStage()];
this.activeCascadeStageIndex = 0;
}
this.cascadeStages = this.cascadeStages.map((stage, index) => normalizeCascadeStage(stage, {
isExpanded: index === this.activeCascadeStageIndex,
filterType: this.filterType,
b_str: this.b_str,
a_str: this.a_str,
systemGain: this.systemGain,
params: this.params,
baseB: this.baseB,
baseA: this.baseA,
bSliders: this.bSliders,
aSliders: this.aSliders,
shiftBitsB: this.shiftBitsB,
shiftBitsA: this.shiftBitsA,
fixedOverrides: this.fixedOverrides,
outOfRangeB: this.outOfRangeB,
outOfRangeA: this.outOfRangeA,
aFineStep: this.aFineStep,
fixedAFineStep: this.fixedAFineStep,
activeCoeffAdjustment: this.activeCoeffAdjustment,
}));
if (this.activeCascadeStageIndex < 0 || this.activeCascadeStageIndex >= this.cascadeStages.length) {
this.activeCascadeStageIndex = 0;
}
},
saveActiveCascadeStage() {
this.ensureCascadeStages();
this.cascadeStages.splice(this.activeCascadeStageIndex, 1, this.captureCurrentStage());
},
selectCascadeStage(index) {
if (index < 0 || index >= this.cascadeStages.length || index === this.activeCascadeStageIndex) return;
this.saveActiveCascadeStage();
this.activeCascadeStageIndex = index;
this.cascadeStages.forEach((stage, stageIndex) => {
stage.isExpanded = stageIndex === index;
});
this.applyStageToControls(this.cloneStage(this.cascadeStages[index]));
this.updateBodeMagRange();
this.updateBodePlot({ switchToChart: false });
},
addCascadeStage() {
this.saveActiveCascadeStage();
const newStage = this.cloneStage(this.cascadeStages[this.activeCascadeStageIndex]);
newStage.id = createStageId();
newStage.isActive = true;
newStage.isExpanded = true;
this.cascadeStages.push(newStage);
this.selectCascadeStage(this.cascadeStages.length - 1);
},
removeCascadeStage(index) {
if (this.cascadeStages.length <= 1) {
this.globalError = '至少需要保留一個濾波器階段';
return;
}
this.saveActiveCascadeStage();
this.cascadeStages.splice(index, 1);
this.activeCascadeStageIndex = Math.min(this.activeCascadeStageIndex, this.cascadeStages.length - 1);
this.cascadeStages[this.activeCascadeStageIndex].isExpanded = true;
this.applyStageToControls(this.cloneStage(this.cascadeStages[this.activeCascadeStageIndex]));
this.updateBodePlot({ switchToChart: false });
},
toggleCascadeStage(index) {
this.saveActiveCascadeStage();
const stage = this.cascadeStages[index];
stage.isActive = !stage.isActive;
this.updateBodePlot({ switchToChart: false });
this.debouncedProcessFilter();
},
toggleCascadeStageExpanded(index) {
if (index < 0 || index >= this.cascadeStages.length) return;
if (index !== this.activeCascadeStageIndex) {
this.selectCascadeStage(index);
return;
}
this.saveActiveCascadeStage();
this.cascadeStages.forEach((stage, stageIndex) => {
stage.isExpanded = stageIndex === index ? !stage.isExpanded : false;
});
},
cascadeStageSummary(stage) {
const bCount = this.parseCoeffs(stage.b_str || '').length;
const aCount = this.parseCoeffs(stage.a_str || '').length;
return `${bCount}b / ${aCount}a, Qb${stage.shiftBitsB}, Qa${stage.shiftBitsA}`;
},
moveCascadeStageUp(index) {
this.moveCascadeStage(index, index - 1);
},
moveCascadeStageDown(index) {
this.moveCascadeStage(index, index + 1);
},
startCascadeStageDrag(index, event) {
if (index < 0 || index >= this.cascadeStages.length) return;
this.saveActiveCascadeStage();
this.draggingCascadeStageIndex = index;
this.dragOverCascadeStageIndex = index;
this.stageDragPointerId = event.pointerId;
this.draggedCascadeStageId = this.cascadeStages[index]?.id || null;
this.stageDragStartX = event.clientX;
this.stageDragStartY = event.clientY;
this.stageDragCurrentX = event.clientX;
this.stageDragCurrentY = event.clientY;
event.currentTarget?.setPointerCapture?.(event.pointerId);
window.addEventListener('pointermove', this.moveCascadeStageDrag, { passive: false });
window.addEventListener('pointerup', this.endCascadeStageDrag);
window.addEventListener('pointercancel', this.cancelCascadeStageDrag);
},
cascadeStageDragStyle(index) {
if (this.draggingCascadeStageIndex !== index) return null;
const dx = this.stageDragCurrentX - this.stageDragStartX;
const dy = this.stageDragCurrentY - this.stageDragStartY;
return {
transform: `translate3d(${dx}px, ${dy}px, 0) scale(1.015)`,
};
},
moveCascadeStageDrag(event) {
if (this.draggingCascadeStageIndex === null) return;
event.preventDefault();
this.stageDragCurrentX = event.clientX;
this.stageDragCurrentY = event.clientY;
const target = document
.elementsFromPoint(event.clientX, event.clientY)
.find(element => {
const stageEl = element.closest?.('[data-cascade-stage-index]');
return stageEl && Number(stageEl.dataset.cascadeStageIndex) !== this.draggingCascadeStageIndex;
})
?.closest?.('[data-cascade-stage-index]');
if (!target) return;
const index = Number(target.dataset.cascadeStageIndex);
if (Number.isInteger(index) && index >= 0 && index < this.cascadeStages.length) {
this.dragOverCascadeStageIndex = index;
this.reorderCascadeStageDuringDrag(index);
}
},
reorderCascadeStageDuringDrag(toIndex) {
const fromIndex = this.draggingCascadeStageIndex;
if (fromIndex === null || fromIndex === toIndex || !this.draggedCascadeStageId) return;
const draggedEl = document.querySelector(`[data-cascade-stage-index="${fromIndex}"]`);
const beforeRect = draggedEl?.getBoundingClientRect();
const activeStageId = this.cascadeStages[this.activeCascadeStageIndex]?.id;
const [stage] = this.cascadeStages.splice(fromIndex, 1);
this.cascadeStages.splice(toIndex, 0, stage);
this.draggingCascadeStageIndex = toIndex;
this.dragOverCascadeStageIndex = toIndex;
this.activeCascadeStageIndex = Math.max(0, this.cascadeStages.findIndex(item => item.id === activeStageId));
this.$nextTick(() => {
const nextEl = document.querySelector(`[data-cascade-stage-index="${toIndex}"]`);
const afterRect = nextEl?.getBoundingClientRect();
if (!beforeRect || !afterRect) return;
this.stageDragStartX += afterRect.left - beforeRect.left;
this.stageDragStartY += afterRect.top - beforeRect.top;
});
},
endCascadeStageDrag() {
const activeStageId = this.cascadeStages[this.activeCascadeStageIndex]?.id;
this.clearCascadeStageDrag();
this.collapseCascadeStages({ skipSave: true });
this.activeCascadeStageIndex = Math.max(0, this.cascadeStages.findIndex(item => item.id === activeStageId));
this.applyStageToControls(this.cloneStage(this.cascadeStages[this.activeCascadeStageIndex]));
this.updateBodePlot({ switchToChart: false });
this.debouncedProcessFilter();
},
cancelCascadeStageDrag() {
this.clearCascadeStageDrag();
this.collapseCascadeStages();
},
clearCascadeStageDrag() {
window.removeEventListener('pointermove', this.moveCascadeStageDrag);
window.removeEventListener('pointerup', this.endCascadeStageDrag);
window.removeEventListener('pointercancel', this.cancelCascadeStageDrag);
this.draggingCascadeStageIndex = null;
this.dragOverCascadeStageIndex = null;
this.stageDragPointerId = null;
this.draggedCascadeStageId = null;
this.stageDragStartX = 0;
this.stageDragStartY = 0;
this.stageDragCurrentX = 0;
this.stageDragCurrentY = 0;
},
collapseCascadeStages({ skipSave = false } = {}) {
if (!skipSave) this.saveActiveCascadeStage();
this.cascadeStages.forEach(stage => {
stage.isExpanded = false;
});
},
moveCascadeStage(fromIndex, toIndex, { preserveExpansion = false, collapseAll = false } = {}) {
if (fromIndex < 0 || fromIndex >= this.cascadeStages.length || toIndex < 0 || toIndex >= this.cascadeStages.length) return;
this.saveActiveCascadeStage();
const activeStageId = this.cascadeStages[this.activeCascadeStageIndex]?.id;
const expansionById = new Map(this.cascadeStages.map(stage => [stage.id, !!stage.isExpanded]));
const [stage] = this.cascadeStages.splice(fromIndex, 1);
this.cascadeStages.splice(toIndex, 0, stage);
this.activeCascadeStageIndex = Math.max(0, this.cascadeStages.findIndex(item => item.id === activeStageId));
this.cascadeStages.forEach((item, index) => {
if (collapseAll) {
item.isExpanded = false;
} else {
item.isExpanded = preserveExpansion ? !!expansionById.get(item.id) : index === this.activeCascadeStageIndex;
}
});
this.applyStageToControls(this.cloneStage(this.cascadeStages[this.activeCascadeStageIndex]));
this.updateBodePlot({ switchToChart: false });
this.debouncedProcessFilter();
},
getStageFixedCoeffs(stage) {
return computeStageFixedCoeffs(stage, this.parseCoeffs);
},
getCascadeStagesSnapshot() {
this.saveActiveCascadeStage();
return this.cascadeStages.map(stage => this.cloneStage(stage));
},
getCascadeBodePayload() {
return {
fs: this.fs,
stages: this.getCascadeStagesSnapshot().map(stage => {
const fixed = this.getStageFixedCoeffs(stage);
const scaleB = Math.pow(2, stage.shiftBitsB);
const scaleA = Math.pow(2, stage.shiftBitsA);
return {
b: this.parseCoeffs(stage.b_str),
a: this.parseCoeffs(stage.a_str),
b_fixed: fixed.b.map(v => v / scaleB),
a_fixed: fixed.a.map(v => v / scaleA),
isActive: stage.isActive,
};
}),
};
},
getCascadeFilterPayload() {
return createCascadeFilterPayload({
stages: this.getCascadeStagesSnapshot(),
fs: this.fs,
shiftBitsIn: this.shiftBitsIn,
shiftBitsOut: this.shiftBitsOut,
useRound: this.useRound,
parseCoeffs: this.parseCoeffs,
getStageFixedCoeffs: this.getStageFixedCoeffs,
});
},
loadSettings() {
try {
const saved = localStorage.getItem('dea_settings');
if (saved) {
const parsed = JSON.parse(saved);
const keys = [
'fs', 'b_str', 'a_str', 'filterType', 'systemGain', 'params',
'baseB', 'baseA', 'bSliders', 'aSliders', 'sense_b', 'sense_a',
'shiftBitsB', 'shiftBitsA', 'shiftBitsIn', 'shiftBitsOut', 'useRound',
'fixedOverrides', 'aFineStep', 'fixedAFineStep', 'cascadeStages', 'activeCascadeStageIndex'
];
keys.forEach(k => {
if (parsed[k] !== undefined) {
this[k] = parsed[k];
}
});
}
} catch (e) {
console.error('Failed to load settings:', e);
}
},
saveSettings() {
this.saveActiveCascadeStage();
const keys = [
'fs', 'b_str', 'a_str', 'filterType', 'systemGain', 'params',
'baseB', 'baseA', 'bSliders', 'aSliders', 'sense_b', 'sense_a',
'shiftBitsB', 'shiftBitsA', 'shiftBitsIn', 'shiftBitsOut', 'useRound',
'fixedOverrides', 'aFineStep', 'fixedAFineStep', 'cascadeStages', 'activeCascadeStageIndex'
];
const settings = {};
keys.forEach(k => settings[k] = this[k]);
localStorage.setItem('dea_settings', JSON.stringify(settings));
},
debouncedSaveSettings() {
clearTimeout(this.saveSettingsTimeout);
this.saveSettingsTimeout = setTimeout(() => this.saveSettings(), 500);
},
rememberPointerType(event) {
if (event.pointerType === 'touch') {
this.isTouchInput = true;
@@ -734,7 +1172,7 @@ export default {
this.outOfRangeB[i] = false;
if (base !== 0 && val !== 0 && (val / (base * gain)) > 0) {
let logVal = Math.log10(val / (base * gain));
if (Math.abs(logVal) < 1e-12) logVal = 0; // 消除浮點數微小誤差
if (Math.abs(logVal) < 1e-8) logVal = 0; // 消除浮點數微小誤差
if (Math.abs(logVal) > this.maxLogB) this.outOfRangeB[i] = true;
this.bSliders[i] = Math.max(-this.maxLogB, Math.min(this.maxLogB, logVal));
} else {
@@ -749,7 +1187,7 @@ export default {
this.outOfRangeA[i] = false;
if (base !== 0 && val !== 0 && (val / base) > 0) {
let logVal = Math.log10(val / base);
if (Math.abs(logVal) < 1e-12) logVal = 0; // 消除浮點數微小誤差
if (Math.abs(logVal) < 1e-8) logVal = 0; // 消除浮點數微小誤差
if (Math.abs(logVal) > this.maxLogA) this.outOfRangeA[i] = true;
this.aSliders[i] = Math.max(-this.maxLogA, Math.min(this.maxLogA, logVal));
} else {
@@ -920,13 +1358,11 @@ export default {
return intVal / scaleA;
});
const res = await fetch('/api/bode/compare', {
const cascadePayload = this.getCascadeBodePayload();
const res = await fetch('/api/bode/compare_cascade', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ideal: { b: b_ideal, a: a_ideal, fs: this.fs },
fixed: { b: b_fixed, a: a_fixed, fs: this.fs }
})
body: JSON.stringify(cascadePayload)
});
let data;
@@ -1052,9 +1488,9 @@ export default {
// 手動 Magnitude 圖例 (位於上方圖表下方)
{ xref: 'paper', yref: 'paper', x: 0.35, y: isSmallScreen ? 0.52 : 0.54, text: '一一一', font: { color: isDark ? '#c8cee0' : '#566075', size: 10 }, showarrow: false },
{ xref: 'paper', yref: 'paper', x: 0.42, y: isSmallScreen ? 0.52 : 0.54, text: 'Ideal (Float)', font: { color: textColor, size: 11 }, showarrow: false, xanchor: 'left' },
{ xref: 'paper', yref: 'paper', x: 0.42, y: isSmallScreen ? 0.52 : 0.54, text: 'Ideal Cascade (Float)', font: { color: textColor, size: 11 }, showarrow: false, xanchor: 'left' },
{ xref: 'paper', yref: 'paper', x: 0.58, y: isSmallScreen ? 0.52 : 0.54, text: '· · · ·', font: { color: isDark ? '#b9b2ff' : '#7a6689', size: 14 }, showarrow: false },
{ xref: 'paper', yref: 'paper', x: 0.63, y: isSmallScreen ? 0.52 : 0.54, text: `Fixed (b Q${this.shiftBitsB}, a Q${this.shiftBitsA})`, font: { color: textColor, size: 11 }, showarrow: false, xanchor: 'left' },
{ xref: 'paper', yref: 'paper', x: 0.63, y: isSmallScreen ? 0.52 : 0.54, text: 'Fixed Cascade (Mimic)', font: { color: textColor, size: 11 }, showarrow: false, xanchor: 'left' },
// 手動 Phase 圖例 (位於下方圖表下方)
{ xref: 'paper', yref: 'paper', x: 0.35, y: -0.15, text: '一一一', font: { color: isDark ? '#64d2ff' : '#2d6f8f', size: 10 }, showarrow: false },
@@ -1067,38 +1503,38 @@ export default {
xaxis: { ...xAxisCommon, anchor: 'y' },
yaxis: {
title: { text: 'Mag (dB)', font: { size: isSmallScreen ? 12 : 14 } },
range: this.bodeMagRange || [-70, 0],
range: this.bodeMagRange || [-70, 0],
gridcolor: gridColor,
zerolinecolor: zeroLineColor,
tickfont: { color: textColor, size: isSmallScreen ? 11 : 12 },
fixedrange: false // 允許使用者手動縮放或點擊 Auto Scale
},
xaxis2: {
type: 'log',
xaxis2: {
type: 'log',
title: { text: 'Freq (Hz)', font: { size: isSmallScreen ? 12 : 14 } },
tickvals: xTicks,
tickvals: xTicks,
ticktext: xTexts,
showgrid: true,
showgrid: true,
gridcolor: gridColor,
tickfont: { color: textColor, size: isSmallScreen ? 11 : 12 },
nticks: isSmallScreen ? 6 : 10,
matches: 'x',
anchor: 'y2'
},
yaxis2: {
title: { text: 'Phase (°)', font: { size: isSmallScreen ? 12 : 14 } },
range: [-180, 180],
tickvals: [-180, -90, 0, 90, 180],
gridcolor: gridColor,
zerolinecolor: zeroLineColor,
matches: 'x',
anchor: 'y2'
},
yaxis2: {
title: { text: 'Phase (°)', font: { size: isSmallScreen ? 12 : 14 } },
range: [-180, 180],
tickvals: [-180, -90, 0, 90, 180],
gridcolor: gridColor,
zerolinecolor: zeroLineColor,
tickfont: { color: textColor, size: isSmallScreen ? 11 : 12 },
fixedrange: true // 相位縱軸鎖死,不可縮放
},
shapes: shapes
};
const traceMagIdeal = { x: freq, y: magIdeal, name: 'Ideal (Float)', type: 'scatter', line: { color: isDark ? '#c8cee0' : '#566075', width: 2.8 } };
const traceMagFixed = { x: freq, y: magFixed, name: `Fixed (b Q${this.shiftBitsB}, a Q${this.shiftBitsA})`, type: 'scatter', line: { color: isDark ? '#b9b2ff' : '#7a6689', width: 2.4, dash: 'dot' } };
const traceMagIdeal = { x: freq, y: magIdeal, name: 'Ideal Cascade (Float)', type: 'scatter', line: { color: isDark ? '#c8cee0' : '#566075', width: 2.8 } };
const traceMagFixed = { x: freq, y: magFixed, name: 'Fixed Cascade (Mimic)', type: 'scatter', line: { color: isDark ? '#b9b2ff' : '#7a6689', width: 2.4, dash: 'dot' } };
const tracePhaseIdeal = { x: freq, y: phaseIdeal, name: 'Ideal Phase', type: 'scatter', line: { color: isDark ? '#64d2ff' : '#2d6f8f', width: 2.8 }, xaxis: 'x2', yaxis: 'y2' };
const tracePhaseFixed = { x: freq, y: phaseFixed, name: 'Fixed Phase', type: 'scatter', line: { color: isDark ? '#ff9bd8' : '#96527b', width: 2.4, dash: 'dot' }, xaxis: 'x2', yaxis: 'y2' };
@@ -1157,10 +1593,56 @@ export default {
this.csvPreview = [];
this.csvInfo = null;
this.csvParseError = null;
this.csvUploading = false;
this.usingPresetCsv = false;
this.isCsvPreviewExpanded = true;
this.selectedColumn = 0;
this.filterDone = false;
this.timePlotData = null;
this.zoomStartIdx = null;
this.zoomEndIdx = null;
this.timePlotUnit = null;
this.timePlotMultiplier = null;
},
async loadPresetWaveforms() {
try {
const res = await fetch('/api/csv/preset');
if (!res.ok) return;
const data = await res.json();
this.presetCsvAvailable = !!data.available;
if (!data.available) return;
this.csvFile = null;
this.csvFileId = data.file_id;
this.csvColumns = data.columns || [];
this.csvPreview = data.preview || [];
this.csvInfo = {
rows: data.rows || 0,
columns: data.columns_count || (data.columns || []).length,
size: data.size || 0,
};
this.csvParseError = null;
this.csvUploading = false;
this.presetCsvName = data.name || 'preset_signals.csv';
this.usingPresetCsv = true;
this.selectedColumn = Number.isInteger(data.default_col_idx) ? data.default_col_idx : 0;
this.filterDone = false;
this.timePlotData = null;
this.zoomStartIdx = null;
this.zoomEndIdx = null;
this.timePlotUnit = null;
this.timePlotMultiplier = null;
this.isCsvPreviewExpanded = true;
this.$nextTick(() => this.processFilter());
} catch (_) {
this.presetCsvAvailable = false;
}
},
restorePresetWaveforms() {
if (this.$refs.fileInput) this.$refs.fileInput.value = '';
this.loadPresetWaveforms();
},
handleFileUpload(event) {
@@ -1181,6 +1663,7 @@ export default {
return;
}
const previewBytes = Math.min(file.size, 1024 * 1024);
const reader = new FileReader();
reader.onload = async (e) => {
try {
@@ -1203,6 +1686,7 @@ export default {
});
this.csvInfo = {
rows: Math.max(totalRows - 1, 0),
rowsLabel: file.size > previewBytes ? `預覽 ${Math.max(rows.length - 1, 0)}+` : `${Math.max(totalRows - 1, 0)}`,
columns: columns.length,
size: file.size,
};
@@ -1213,15 +1697,23 @@ export default {
this.selectedColumn = 1;
}
}
// 背景上傳至暫存區
this.csvUploading = true;
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/csv/upload', { method: 'POST', body: formData });
if (!res.ok) { const err = await res.json(); throw new Error(err.detail || '背景上傳失敗'); }
if (!res.ok) {
const errText = await res.text();
let detail = errText || '背景上傳失敗';
try {
detail = JSON.parse(errText).detail || detail;
} catch (_) {}
throw new Error(detail);
}
const data = await res.json();
this.csvFileId = data.file_id;
} catch (err) {
this.csvParseError = err.message || 'CSV 解析失敗';
this.csvColumns = [];
@@ -1229,13 +1721,16 @@ export default {
this.csvInfo = null;
this.csvFileId = null;
event.target.value = '';
} finally {
this.csvUploading = false;
}
};
reader.onerror = () => {
this.csvParseError = 'CSV 讀取失敗';
this.csvUploading = false;
event.target.value = '';
};
reader.readAsText(file);
reader.readAsText(file.slice(0, previewBytes));
},
debouncedProcessFilter() {
@@ -1247,10 +1742,18 @@ export default {
},
async processFilter() {
if (this.csvUploading) {
this.globalError = "CSV 還在上傳中,請稍候...";
return;
}
if (!this.csvFileId) {
if (this.csvFile) this.globalError = "檔案還在上傳中,請稍候...";
return;
}
if (this.zoomStartIdx === null && this.zoomEndIdx === null) {
this.timePlotUnit = null;
this.timePlotMultiplier = null;
}
this.loadingFilter = true;
this.globalError = null;
const formData = new FormData();
@@ -1265,6 +1768,11 @@ export default {
formData.append('shift_b', this.shiftBitsB);
formData.append('shift_a', this.shiftBitsA);
formData.append('use_round', this.useRound);
formData.append('stages', JSON.stringify(this.getCascadeFilterPayload()));
if (this.zoomStartIdx !== null && this.zoomEndIdx !== null) {
formData.append('start_idx', this.zoomStartIdx);
formData.append('end_idx', this.zoomEndIdx);
}
try {
const res = await fetch('/api/filter', { method: 'POST', body: formData });
if (!res.ok) { const err = await res.json(); throw new Error(err.detail || '濾波處理失敗'); }
@@ -1288,19 +1796,66 @@ export default {
const textColor = isDark ? '#b4b4bd' : '#525663';
const plotBgColor = isDark ? '#090a0d' : '#ffffff';
if (!this.timePlotUnit) {
const totalDurationS = (data.total_points || (data.index.length > 0 ? Math.max(...data.index) : 0)) / this.fs;
if (totalDurationS > 0 && totalDurationS < 1e-3) {
this.timePlotMultiplier = 1e6;
this.timePlotUnit = 'μs';
} else if (totalDurationS > 0 && totalDurationS < 1) {
this.timePlotMultiplier = 1e3;
this.timePlotUnit = 'ms';
} else {
this.timePlotMultiplier = 1;
this.timePlotUnit = 's';
}
}
const timeMultiplier = this.timePlotMultiplier || 1;
const timeUnit = this.timePlotUnit || 's';
const xData = data.index.map(i => (i / this.fs) * timeMultiplier);
const layout = {
paper_bgcolor: plotBgColor, plot_bgcolor: plotBgColor,
uirevision: 'time-domain-lod',
margin: isSmallScreen ? { t: 60, b: 60, l: 45, r: 15 } : { t: 40, b: 55, l: 60, r: 20 },
font: { color: textColor, family: 'Google Sans, Roboto, sans-serif' },
xaxis: { title: { text: 'Sample Index', font: { size: isSmallScreen ? 10 : 11 } }, gridcolor: gridColor, zerolinecolor: zeroLineColor, tickfont: { color: textColor, size: isSmallScreen ? 9 : 11 } },
xaxis: { title: { text: `Time (${timeUnit})`, font: { size: isSmallScreen ? 10 : 11 } }, gridcolor: gridColor, zerolinecolor: zeroLineColor, tickfont: { color: textColor, size: isSmallScreen ? 9 : 11 } },
yaxis: { title: { text: 'Amplitude', font: { size: isSmallScreen ? 10 : 11 } }, gridcolor: gridColor, zerolinecolor: zeroLineColor, tickfont: { color: textColor, size: isSmallScreen ? 9 : 11 } },
legend: { orientation: 'h', y: 1.02, yanchor: 'bottom', x: 1, xanchor: 'right', font: { color: textColor, size: isSmallScreen ? 9 : 10 } }
};
this.isRedrawingTimePlot = true;
Plotly.react('timePlot', [
{ x: data.index, y: data.original, name: '原始輸入 (Input)', type: 'scatter', line: { color: isDark ? '#c8cee0' : '#566075', width: 2.4 }, opacity: 0.5 },
{ x: data.index, y: data.filtered, name: '理想路徑 (Ideal Float)', type: 'scatter', line: { color: isDark ? '#64d2ff' : '#2d6f8f', width: 2.8 }, opacity: 0.95 },
{ x: data.index, y: data.filtered_fixed, name: '定點路徑 (Mimic Integer)', type: 'scatter', line: { color: isDark ? '#ff7c7c' : '#c0392b', width: 2.8, dash: 'dot' }, opacity: 0.95 }
{ x: xData, y: data.original, name: '原始輸入 (Input)', type: 'scatter', line: { color: isDark ? '#c8cee0' : '#566075', width: 2.4 }, opacity: 0.5 },
{ x: xData, y: data.filtered, name: '理想串聯路徑 (Cascade Ideal)', type: 'scatter', line: { color: isDark ? '#64d2ff' : '#2d6f8f', width: 2.8 }, opacity: 0.95 },
{ x: xData, y: data.filtered_fixed, name: '定點串聯路徑 (Cascade Fixed)', type: 'scatter', line: { color: isDark ? '#ff7c7c' : '#c0392b', width: 2.8, dash: 'dot' }, opacity: 0.95 }
], layout, { responsive: true });
this.$nextTick(() => setTimeout(() => { this.isRedrawingTimePlot = false; }, 50));
const gd = document.getElementById('timePlot');
if (gd && !gd._hasRelayoutListener) {
gd.on('plotly_relayout', this.handleTimePlotRelayout);
gd._hasRelayoutListener = true;
}
},
handleTimePlotRelayout(eventData) {
if (this.isRedrawingTimePlot) return;
if (!this.timePlotData || !this.timePlotData.total_points) return;
let startIdx = null;
let endIdx = null;
if (eventData['xaxis.range[0]'] !== undefined && eventData['xaxis.range[1]'] !== undefined) {
const timeMultiplier = this.timePlotMultiplier || 1;
startIdx = Math.max(0, Math.floor((eventData['xaxis.range[0]'] * this.fs) / timeMultiplier));
endIdx = Math.min(this.timePlotData.total_points, Math.ceil((eventData['xaxis.range[1]'] * this.fs) / timeMultiplier));
if (endIdx - startIdx <= 10) return;
} else if (eventData['xaxis.autorange'] === true) {
startIdx = null;
endIdx = null;
} else {
return;
}
this.zoomStartIdx = startIdx;
this.zoomEndIdx = endIdx;
if (this._zoomTimeout) clearTimeout(this._zoomTimeout);
this._zoomTimeout = setTimeout(() => this.processFilter(), 150);
},
async downloadCsv() {
@@ -1317,13 +1872,19 @@ export default {
formData.append('shift_b', this.shiftBitsB);
formData.append('shift_a', this.shiftBitsA);
formData.append('use_round', this.useRound);
formData.append('stages', JSON.stringify(this.getCascadeFilterPayload()));
formData.append('compact', 'true');
if (this.zoomStartIdx !== null && this.zoomEndIdx !== null) {
formData.append('start_idx', this.zoomStartIdx);
formData.append('end_idx', this.zoomEndIdx);
}
try {
const res = await fetch('/api/filter/download', { method: 'POST', body: formData });
if (!res.ok) throw new Error('下載失敗');
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'filtered_output.csv';
a.href = url; a.download = 'cascade_filtered_output.csv';
document.body.appendChild(a); a.click();
window.URL.revokeObjectURL(url);
} catch (e) { this.globalError = e.message; }
@@ -1372,6 +1933,7 @@ export default {
},
async writeToMCU() {
const stageNumber = this.activeCascadeStageIndex + 1;
if (!this.mcuSerialPort || !this.mcuConnected) {
this.mcuStatus = '請先連線 MCU 連接埠';
return;
@@ -1389,25 +1951,13 @@ export default {
} finally {
writer.releaseLock();
}
this.mcuStatus = `已送出 ${command}`;
this.mcuStatus = `已送出 Stage ${stageNumber}: ${command}`;
} catch (e) {
this.globalError = e.message;
this.mcuStatus = `寫入失敗: ${e.message}`;
this.mcuStatus = `Stage ${stageNumber} 寫入失敗: ${e.message}`;
} finally {
this.writingMCU = false;
}
}
},
watch: {
useRound() { this.debouncedProcessFilter(); },
shiftBitsIn() { this.debouncedProcessFilter(); },
shiftBitsOut() { this.debouncedProcessFilter(); },
shiftBitsB() { this.debouncedProcessFilter(); },
shiftBitsA() { this.debouncedProcessFilter(); },
b_int_str() { this.debouncedProcessFilter(); },
a_int_str() { this.debouncedProcessFilter(); },
b_str() { this.debouncedProcessFilter(); },
a_str() { this.debouncedProcessFilter(); }
}
}
+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,