Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a36446ce92 | |||
| 750bea70ec | |||
| 7991f1fcba | |||
| b99af5f734 | |||
| 5413062eff | |||
| 6766df8f5e | |||
| b8ec600673 | |||
| 63382f6cf5 | |||
| 3edc4702f3 | |||
| 8caa8918d0 | |||
| 0140b0495c | |||
| 9182220c1d | |||
| 075f20dd81 | |||
| 2f392b13c6 | |||
| 345b4a789a | |||
| 8009a0dd78 | |||
| d9841fe7bc | |||
| c259f91380 | |||
| c4b4a4ced0 | |||
| e9dba45626 | |||
| 3c3bda7daa | |||
| 2762d0da86 | |||
| da2cbfe8da |
+11
@@ -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
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
# Difference Equation Analyzer (DEA) - Architecture & Functionality
|
||||
|
||||
## Overview
|
||||
DEA is a specialized tool for designing, visualizing, and validating discrete-time filters. It bridges the gap between theoretical filter design (floating-point) and hardware implementation (fixed-point/MCU).
|
||||
|
||||
## 🏗 System Architecture
|
||||
|
||||
### 1. Backend (Python 3.12 + FastAPI)
|
||||
The backend is responsible for heavy mathematical computations and data processing.
|
||||
|
||||
- **API Entry (`dea_api.py`)**: Routes requests to appropriate modules and serves the Vue.js frontend from `static/`.
|
||||
- **Computation Engine (`dea/`)**:
|
||||
- `filter_design.py`: Uses `scipy.signal` to calculate coefficients for various filter topologies.
|
||||
- `bode.py`: Computes magnitude and phase response. Supports comparing "Ideal" (float) vs "Fixed" (quantized) responses.
|
||||
- `csv_processing.py`: Processes time-domain CSV data. It applies the current filter to the signal and downsamples the result for efficient visualization.
|
||||
- `validation.py`: Protects against unstable filter designs and invalid inputs.
|
||||
|
||||
### 2. Frontend (Vue 3 + Vite + Tailwind)
|
||||
A reactive "Single Page Application" (SPA) approach.
|
||||
|
||||
- **Reactive Logic (`src/app-options.js`)**:
|
||||
- Manages the state of all coefficients.
|
||||
- Implements "Fine-tuning" sliders that allow real-time adjustment of poles/zeros.
|
||||
- Handles Fixed-Point conversion logic (Q-format shifting).
|
||||
- **UI Components (`src/App.vue`)**:
|
||||
- **Control Sidebar**: Interactive inputs for all filter parameters.
|
||||
- **Visualization**: Dual-plot system using Plotly.js for Bode plots and Time-domain waveforms.
|
||||
- **Hardware Bridge**: Uses the **Web Serial API** to send commands directly to connected MCUs (requires HTTPS).
|
||||
|
||||
## 🚀 Execution Flow
|
||||
1. **Design**: User selects a filter type or enters coefficients manually.
|
||||
2. **Quantize**: User adjusts Q-format bits to see how quantization affects the frequency response.
|
||||
3. **Verify**: User uploads a CSV to see how the filter behaves with real-world signals.
|
||||
4. **Deploy**: User clicks "Write to MCU" to send the coefficients to their hardware.
|
||||
|
||||
## 🛠 Tech Stack
|
||||
- **Backend**: FastAPI, NumPy, SciPy, Pandas, Uvicorn.
|
||||
- **Frontend**: Vue 3, Vite, Tailwind CSS, Plotly.js.
|
||||
- **Security**: Restricted to LAN/Loopback; implemented security headers and HTTPS support.
|
||||
@@ -1,48 +0,0 @@
|
||||
# DSP Bode Plot 專案進度總結 (2026-05-15)
|
||||
|
||||
## 1. 核心功能優化 (DSP & 定點數運算)
|
||||
- **細部微調 (Fine-tuning)**:實施了基於 $\delta$ (Delta) 與 $r$ (Radius) 的參數化微調。
|
||||
- **定點數精確度修正**:採用「兩倍值整數運算」邏輯,解決了 $0.5$ 步階在浮點數圓整時產生的「按鍵沒反應」Bug。
|
||||
- **防止漂移 (Oscillation Logic)**:針對數位微調,實施了奇偶震盪平衡邏輯。當調整 $\delta$ 導致 $r$ 必須隨動時,採用震盪補償防止 $r$ 產生單向累積誤差。
|
||||
- **參數連動策略**:在 $0.5$ 步階時,採取「優先滿足目標變動」原則。若調整 $\delta$,則 $r$ 隨動;反之亦然。
|
||||
|
||||
## 2. UI/UX 介面調整
|
||||
- **側邊欄優化**:
|
||||
- 將 $r$ 與 $\delta$ 的調整區域改為**垂直堆疊**,適應 21rem 的窄邊欄。
|
||||
- 移除冗餘的 $a1, a2$ 預覽方塊,僅保留帶有數學公式標籤的 $r, \delta$ 預覽。
|
||||
- **步階序列**:數位微調步階改為自訂序列:`[0.5, 1, 2, 4, 8, 32, 256, 1024, 4096, 16384, 65536]`。
|
||||
- **微調量顯示**:統一使用 `±` 符號並帶空格(例如 `± 0.5`)。
|
||||
- **智能禁用**:當數位步階為 $0.5$ 時,自動禁用 $r$ 的控制項,確保以 $\delta$ 為主導。
|
||||
|
||||
## 3. 繪圖系統 (Bode Plot)
|
||||
- **幅值圖 (Magnitude)**:
|
||||
- 初始範圍:基於 $systemGain \times 3$,涵蓋 $70 \text{ dB}$。
|
||||
- 保留手動彈性:設定為 `fixedrange: false`,支援滑鼠縮放與 Auto Scale。
|
||||
- 狀態保持:使用 `uirevision`,確保微調參數時不會跳回初始縮放。
|
||||
- **相位圖 (Phase)**:
|
||||
- 絕對鎖死:固定範圍為 $[-180^\circ, 180^\circ]$。
|
||||
- 禁止縮放:設定為 `fixedrange: true`,提供穩定的絕對參考。
|
||||
- **座標軸連動**:幅值圖與相位圖的 **X 軸(頻率)已完全同步**。不論在哪一張圖進行橫向縮放,兩者都會連動。
|
||||
|
||||
## 4. 數學公式參考
|
||||
- **理想空間**:
|
||||
- $a1 = -1 - r + \delta$
|
||||
- $a2 = r + \delta$
|
||||
- **定點數空間 (Q14 為例)**:
|
||||
- $a0 = 2^{14} = 16384$
|
||||
- $a1 = -a0 - r + \delta$
|
||||
- $a2 = r + \delta$
|
||||
- $2\delta = a1 + a2 + a0$
|
||||
- $2r = a2 - a1 - a0$
|
||||
|
||||
## 5. 目前 Git 狀態
|
||||
- **當前分支**:`dev-ui-fix-20260515` (已於 2026-05-15 17:46 推送至 GitLab)。
|
||||
- **同步說明**:此版本已安全儲存在獨立分支,不影響 `main` 分支同事的使用。
|
||||
- **同事試用指令**:
|
||||
```bash
|
||||
git fetch
|
||||
git checkout dev-ui-fix-20260515
|
||||
```
|
||||
|
||||
---
|
||||
*Last Updated: 2026-05-15 17:47*
|
||||
@@ -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 與 Pandas;Frontend 使用 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 輸出。
|
||||
- 匯出包含理想浮點與定點整數模擬輸出的 CSV;cascade 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
File diff suppressed because it is too large
Load Diff
+37
-1
@@ -3,7 +3,7 @@ from fastapi import HTTPException
|
||||
from scipy import signal
|
||||
|
||||
from .config import BODE_MAX_MULTIPLIER, BODE_POINTS
|
||||
from .schemas import BodeCompareParams, BodeParams
|
||||
from .schemas import BodeCascadeParams, BodeCompareParams, BodeParams
|
||||
from .validation import require_finite, validate_coefficients
|
||||
|
||||
|
||||
@@ -47,3 +47,39 @@ def calculate_bode_compare_response(params: BodeCompareParams):
|
||||
"ideal": ideal,
|
||||
"fixed": fixed,
|
||||
}
|
||||
|
||||
|
||||
def calculate_bode_cascade_response(params: BodeCascadeParams):
|
||||
fs_val, f_eval = _frequency_axis(params.fs)
|
||||
h_total_ideal = np.ones(len(f_eval), dtype=complex)
|
||||
h_total_fixed = np.ones(len(f_eval), dtype=complex)
|
||||
any_active = False
|
||||
|
||||
for i, stage in enumerate(params.stages):
|
||||
if not stage.isActive:
|
||||
continue
|
||||
any_active = True
|
||||
b_vals = validate_coefficients(stage.b, f"stages[{i}].b")
|
||||
a_vals = validate_coefficients(stage.a, f"stages[{i}].a")
|
||||
b_fixed_vals = validate_coefficients(stage.b_fixed, f"stages[{i}].b_fixed")
|
||||
a_fixed_vals = validate_coefficients(stage.a_fixed, f"stages[{i}].a_fixed")
|
||||
_, h_ideal = signal.freqz(b_vals, a_vals, worN=f_eval, fs=fs_val)
|
||||
_, h_fixed = signal.freqz(b_fixed_vals, a_fixed_vals, worN=f_eval, fs=fs_val)
|
||||
h_total_ideal *= h_ideal
|
||||
h_total_fixed *= h_fixed
|
||||
|
||||
if not any_active:
|
||||
h_total_ideal = np.ones(len(f_eval), dtype=complex)
|
||||
h_total_fixed = np.ones(len(f_eval), dtype=complex)
|
||||
|
||||
return {
|
||||
"freq": f_eval.tolist(),
|
||||
"ideal": {
|
||||
"mag": (20 * np.log10(np.abs(h_total_ideal) + 1e-12)).tolist(),
|
||||
"phase": np.angle(h_total_ideal, deg=True).tolist(),
|
||||
},
|
||||
"fixed": {
|
||||
"mag": (20 * np.log10(np.abs(h_total_fixed) + 1e-12)).tolist(),
|
||||
"phase": np.angle(h_total_fixed, deg=True).tolist(),
|
||||
},
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
import os
|
||||
|
||||
MAX_COEFFICIENTS = 64
|
||||
MAX_CSV_BYTES = 32 * 1024 * 1024
|
||||
MAX_CSV_BYTES = 300 * 1024 * 1024
|
||||
MAX_ROWS = 1200000
|
||||
MAX_PLOT_POINTS = 5000
|
||||
BODE_POINTS = 500
|
||||
BODE_MAX_MULTIPLIER = 3.162
|
||||
|
||||
+263
-34
@@ -1,22 +1,109 @@
|
||||
import io
|
||||
import os
|
||||
import uuid
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from fastapi import HTTPException
|
||||
from scipy import signal
|
||||
import math
|
||||
|
||||
from .config import MAX_CSV_BYTES, MAX_PLOT_POINTS
|
||||
from .config import MAX_CSV_BYTES, MAX_PLOT_POINTS, MAX_ROWS
|
||||
|
||||
TEMP_CSV_DIR = os.environ.get(
|
||||
"DEA_TEMP_CSV_DIR",
|
||||
os.path.join(tempfile.gettempdir(), "diff-eq-analyzer-csv"),
|
||||
)
|
||||
TEMP_CSV_TTL_SECONDS = int(os.environ.get("DEA_TEMP_CSV_TTL_SECONDS", str(24 * 60 * 60)))
|
||||
PRESET_FILE_ID = "00000000-0000-0000-0000-000000000000"
|
||||
PRESET_CSV_PATH = os.environ.get("DEA_PRESET_CSV_PATH", ".scratch/examples/preset_signals.csv")
|
||||
os.makedirs(TEMP_CSV_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def downsample_for_plot(index, original, filtered):
|
||||
def prune_expired_csv_uploads(now=None):
|
||||
if TEMP_CSV_TTL_SECONDS <= 0:
|
||||
return
|
||||
os.makedirs(TEMP_CSV_DIR, exist_ok=True)
|
||||
cutoff = (now or time.time()) - TEMP_CSV_TTL_SECONDS
|
||||
for filename in os.listdir(TEMP_CSV_DIR):
|
||||
if not filename.endswith(".csv"):
|
||||
continue
|
||||
path = os.path.join(TEMP_CSV_DIR, filename)
|
||||
try:
|
||||
if os.path.isfile(path) and os.path.getmtime(path) < cutoff:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def get_cached_file_path(file_id):
|
||||
if file_id == PRESET_FILE_ID:
|
||||
if not os.path.exists(PRESET_CSV_PATH):
|
||||
raise HTTPException(status_code=404, detail="找不到 preset CSV 檔案")
|
||||
return PRESET_CSV_PATH
|
||||
if not re.match(r'^[0-9a-f\-]{36}$', file_id):
|
||||
raise HTTPException(status_code=400, detail="無效的檔案ID")
|
||||
file_path = os.path.join(TEMP_CSV_DIR, f"{file_id}.csv")
|
||||
if not os.path.exists(file_path):
|
||||
raise HTTPException(status_code=404, detail="找不到快取的 CSV 檔案,請重新上傳")
|
||||
return file_path
|
||||
|
||||
|
||||
def preset_csv_metadata(preview_rows=5):
|
||||
if not os.path.exists(PRESET_CSV_PATH):
|
||||
return {"available": False}
|
||||
|
||||
preview_df = pd.read_csv(PRESET_CSV_PATH, nrows=preview_rows)
|
||||
columns = preview_df.columns.tolist()
|
||||
with open(PRESET_CSV_PATH, "rb") as f:
|
||||
rows = max(sum(1 for _ in f) - 1, 0)
|
||||
|
||||
default_col_idx = 0
|
||||
if len(columns) > 1:
|
||||
first = columns[0].lower()
|
||||
if any(kw in first for kw in ("time", "t", "sec", "x", "index", "unnamed")):
|
||||
default_col_idx = 1
|
||||
|
||||
return {
|
||||
"available": True,
|
||||
"file_id": PRESET_FILE_ID,
|
||||
"name": os.path.basename(PRESET_CSV_PATH),
|
||||
"columns": columns,
|
||||
"preview": preview_df.fillna("").astype(str).values.tolist(),
|
||||
"rows": rows,
|
||||
"columns_count": len(columns),
|
||||
"size": os.path.getsize(PRESET_CSV_PATH),
|
||||
"default_col_idx": default_col_idx,
|
||||
}
|
||||
|
||||
|
||||
def downsample_for_plot(index, original, filtered_float, filtered_fixed):
|
||||
total = len(index)
|
||||
if total <= MAX_PLOT_POINTS:
|
||||
return index, original, filtered, 1
|
||||
return index, original, filtered_float, filtered_fixed, 1
|
||||
step = int(np.ceil(total / MAX_PLOT_POINTS))
|
||||
return index[::step], original[::step], filtered[::step], step
|
||||
return index[::step], original[::step], filtered_float[::step], filtered_fixed[::step], step
|
||||
|
||||
|
||||
async def read_csv_upload(file):
|
||||
def _numeric_signal(series, col_name):
|
||||
original_missing = series.isna()
|
||||
numeric_signal = pd.to_numeric(series, errors="coerce")
|
||||
if (numeric_signal.isna() & ~original_missing).any():
|
||||
raise HTTPException(status_code=400, detail=f"欄位 {col_name} 含有非數值資料")
|
||||
numeric_signal = numeric_signal.dropna()
|
||||
if numeric_signal.empty:
|
||||
raise HTTPException(status_code=400, detail=f"欄位 {col_name} 沒有可用的數值資料")
|
||||
x_values = numeric_signal.to_numpy(dtype=float)
|
||||
if not np.isfinite(x_values).all():
|
||||
raise HTTPException(status_code=400, detail=f"欄位 {col_name} 含有非有限數值")
|
||||
return numeric_signal, x_values
|
||||
|
||||
|
||||
async def save_csv_upload(file):
|
||||
prune_expired_csv_uploads()
|
||||
filename = (file.filename or "").lower()
|
||||
if filename and not filename.endswith(".csv"):
|
||||
raise HTTPException(status_code=400, detail="請上傳 CSV 檔案")
|
||||
@@ -25,48 +112,190 @@ async def read_csv_upload(file):
|
||||
raise HTTPException(status_code=413, detail=f"CSV 檔案不可超過 {MAX_CSV_BYTES // (1024 * 1024)}MB")
|
||||
if not contents.strip():
|
||||
raise HTTPException(status_code=400, detail="CSV 不可為空")
|
||||
try:
|
||||
df = pd.read_csv(io.BytesIO(contents))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"CSV 讀取失敗: {str(e)}")
|
||||
if df.empty:
|
||||
raise HTTPException(status_code=400, detail="CSV 不可為空")
|
||||
return df
|
||||
|
||||
file_id = str(uuid.uuid4())
|
||||
file_path = os.path.join(TEMP_CSV_DIR, f"{file_id}.csv")
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(contents)
|
||||
return file_id
|
||||
|
||||
|
||||
def filtered_csv_data(df, b_vals, a_vals, col_idx):
|
||||
if col_idx < 0 or len(df.columns) <= col_idx:
|
||||
def integer_lfilter(b_int, a_int, x_float, shift_in, shift_out, shift_b, shift_a, use_round=False):
|
||||
"""
|
||||
全整數差分方程式模擬 (Mimic DSP hardware)
|
||||
高精度狀態變數架構:前饋不位移,保留小數精度於狀態變數中。
|
||||
支援硬體 Rounding (四捨五入) vs Floor (無條件捨去向下取整)。
|
||||
"""
|
||||
b_int = np.asarray(b_int, dtype=np.int64)
|
||||
a_int = np.asarray(a_int, dtype=np.int64)
|
||||
x_int = np.round(x_float * (2**shift_in)).astype(np.int64)
|
||||
|
||||
# y_hist 將保留在 Q_{in + b} 格式以降低 Truncation Error
|
||||
y_hist = np.zeros(len(x_int), dtype=np.int64)
|
||||
y_out = np.zeros(len(x_int), dtype=np.int64)
|
||||
|
||||
nb = len(b_int)
|
||||
na = len(a_int)
|
||||
|
||||
A0 = int(a_int[0])
|
||||
if A0 == 0: A0 = 1
|
||||
|
||||
# 輸出所需的總位移量
|
||||
out_shift = shift_in + shift_b - shift_out
|
||||
|
||||
# 預先計算四捨五入的補償值 (+0.5)
|
||||
# 韌體開發提示 (C Implementation / RISC-V):
|
||||
# 1. 標準 RISC-V (RV32I/IMAC) 的 SRA 指令是純 Floor (無條件捨去),沒有硬體 Rounding shift。
|
||||
# (除非具備 'P' DSP Extension 才可能有 1-cycle 的硬體 rounding shift)。
|
||||
# 2. 演算法秘技:在 C 語言中要實現 1-clock 的四捨五入,不要呼叫 float 的 round()。
|
||||
# 請使用 `y = (acc + (1 << (shift - 1))) >> shift`。
|
||||
# 編譯器會將 (1 << (shift - 1)) 編譯為常數,整體只消耗 1 個 ADD 指令,極大消除了 DC Bias。
|
||||
round_offset_a = (A0 >> 1) if use_round else 0
|
||||
round_offset_out = (1 << (out_shift - 1)) if (use_round and out_shift > 0) else 0
|
||||
|
||||
for n in range(len(x_int)):
|
||||
sum_b = 0
|
||||
# Feedforward: 前饋完全不位移,結果為 Q_{in + b}
|
||||
for i in range(nb):
|
||||
if n - i >= 0:
|
||||
sum_b += b_int[i] * x_int[n - i]
|
||||
|
||||
# Feedback: 歷史紀錄為 Q_{in + b},係數為 Q_a,乘積為 Q_{in + b + a}
|
||||
sum_a = 0
|
||||
for j in range(1, na):
|
||||
if n - j >= 0:
|
||||
sum_a += a_int[j] * y_hist[n - j]
|
||||
|
||||
# Feedback 縮放:前提 A0 = 1 (或者代表放大了 Q_a 倍的常數),除以 A0 (即 >> shift_a) 歸一化
|
||||
# Python 的 // 等同於硬體的 SRA (Arithmetic Right Shift),會向負無窮大 Floor
|
||||
sum_a_scaled = (sum_a + round_offset_a) // A0
|
||||
|
||||
acc = sum_b - sum_a_scaled
|
||||
|
||||
# 將超高精度的 acc 直接存入歷史變數
|
||||
y_hist[n] = acc
|
||||
|
||||
# 最終輸出再針對 Q_out 進行位移縮放
|
||||
if out_shift > 0:
|
||||
y_out[n] = (acc + round_offset_out) >> out_shift
|
||||
elif out_shift < 0:
|
||||
y_out[n] = acc << (-out_shift)
|
||||
else:
|
||||
y_out[n] = acc
|
||||
|
||||
return y_out.astype(float) / (2**shift_out)
|
||||
|
||||
|
||||
def _run_filter_paths(x_values, b_vals, a_vals, b_int, a_int, shift_in, shift_out, shift_b, shift_a, use_round, stages=None):
|
||||
if stages is None:
|
||||
y_float = signal.lfilter(b_vals, a_vals, x_values)
|
||||
if b_int is not None and a_int is not None:
|
||||
y_fixed = integer_lfilter(b_int, a_int, x_values, shift_in, shift_out, shift_b, shift_a, use_round)
|
||||
else:
|
||||
y_fixed = y_float
|
||||
return y_float, y_fixed
|
||||
|
||||
y_float = x_values
|
||||
y_fixed = x_values
|
||||
any_active = False
|
||||
for stage in stages:
|
||||
if not stage.get("isActive", True):
|
||||
continue
|
||||
any_active = True
|
||||
y_float = signal.lfilter(stage["b"], stage["a"], y_float)
|
||||
if stage.get("b_int") is not None and stage.get("a_int") is not None:
|
||||
y_fixed = integer_lfilter(
|
||||
stage["b_int"],
|
||||
stage["a_int"],
|
||||
y_fixed,
|
||||
stage["shift_in"],
|
||||
stage["shift_out"],
|
||||
stage["shift_b"],
|
||||
stage["shift_a"],
|
||||
stage["use_round"],
|
||||
)
|
||||
else:
|
||||
y_fixed = y_float
|
||||
|
||||
if not any_active:
|
||||
return x_values, x_values
|
||||
return y_float, y_fixed
|
||||
|
||||
|
||||
def filter_preview_response(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=None, shift_in=14, shift_out=14, shift_b=14, shift_a=14, use_round=False, stages=None, start_idx=None, end_idx=None):
|
||||
path = get_cached_file_path(file_id)
|
||||
|
||||
# 預先讀取欄位名稱,避免用 usecols 讀取後找不到原始索引
|
||||
cols = pd.read_csv(path, nrows=0).columns.tolist()
|
||||
if col_idx < 0 or col_idx >= len(cols):
|
||||
raise HTTPException(status_code=400, detail="欄位索引超出範圍")
|
||||
col_to_filter = df.columns[col_idx]
|
||||
x_signal = pd.to_numeric(df[col_to_filter], errors="coerce")
|
||||
if x_signal.isna().any():
|
||||
raise HTTPException(status_code=400, detail=f"欄位 {col_to_filter} 含有非數值資料")
|
||||
x_values = x_signal.to_numpy(dtype=float)
|
||||
if not np.isfinite(x_values).all():
|
||||
raise HTTPException(status_code=400, detail=f"欄位 {col_to_filter} 含有非有限數值")
|
||||
y_signal = signal.lfilter(b_vals, a_vals, x_values)
|
||||
return col_to_filter, x_values, y_signal
|
||||
col_to_filter = cols[col_idx]
|
||||
|
||||
# 精準讀取單一欄位,並加上筆數限制 (極大降低記憶體用量與時間)
|
||||
df = pd.read_csv(path, usecols=[col_idx], nrows=MAX_ROWS)
|
||||
x_signal, x_values = _numeric_signal(df[col_to_filter], col_to_filter)
|
||||
|
||||
def filter_preview_response(df, b_vals, a_vals, col_idx):
|
||||
col_to_filter, x_signal, y_signal = filtered_csv_data(df, b_vals, a_vals, col_idx)
|
||||
index, original, filtered, step = downsample_for_plot(df.index.to_numpy(), x_signal, y_signal)
|
||||
y_float, y_fixed = _run_filter_paths(
|
||||
x_values, b_vals, a_vals, b_int, a_int,
|
||||
shift_in, shift_out, shift_b, shift_a, use_round, stages=stages,
|
||||
)
|
||||
|
||||
total_points = len(x_signal)
|
||||
start_idx_clean = start_idx if isinstance(start_idx, (int, float, str)) else None
|
||||
end_idx_clean = end_idx if isinstance(end_idx, (int, float, str)) else None
|
||||
s_idx = max(0, int(start_idx_clean)) if start_idx_clean is not None else 0
|
||||
e_idx = min(total_points, int(end_idx_clean)) if end_idx_clean is not None else total_points
|
||||
if s_idx >= e_idx:
|
||||
s_idx = 0
|
||||
e_idx = total_points
|
||||
|
||||
full_index = x_signal.index.to_numpy()
|
||||
index, original, filtered_float, filtered_fixed, step = downsample_for_plot(
|
||||
full_index[s_idx:e_idx],
|
||||
x_values[s_idx:e_idx],
|
||||
y_float[s_idx:e_idx],
|
||||
y_fixed[s_idx:e_idx],
|
||||
)
|
||||
|
||||
return {
|
||||
"index": index.tolist(),
|
||||
"original": original.tolist(),
|
||||
"filtered": filtered.tolist(),
|
||||
"filtered": filtered_float.tolist(),
|
||||
"filtered_fixed": filtered_fixed.tolist(),
|
||||
"col_name": col_to_filter,
|
||||
"total_points": int(len(df.index)),
|
||||
"total_points": int(total_points),
|
||||
"plot_points": int(len(index)),
|
||||
"downsample_step": int(step),
|
||||
}
|
||||
|
||||
|
||||
def filtered_csv_text(df, b_vals, a_vals, col_idx):
|
||||
col_to_filter, _, y_signal = filtered_csv_data(df, b_vals, a_vals, col_idx)
|
||||
df[f"{col_to_filter}_filtered"] = y_signal
|
||||
csv_buffer = io.StringIO()
|
||||
df.to_csv(csv_buffer, index=False)
|
||||
return csv_buffer.getvalue()
|
||||
def filtered_csv_text(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=None, shift_in=14, shift_out=14, shift_b=14, shift_a=14, use_round=False, stages=None, fs=100000.0, compact=False):
|
||||
path = get_cached_file_path(file_id)
|
||||
# 匯出時需要原始所有欄位,但仍受限於 MAX_ROWS
|
||||
df = pd.read_csv(path, nrows=MAX_ROWS)
|
||||
|
||||
if col_idx < 0 or col_idx >= len(df.columns):
|
||||
raise HTTPException(status_code=400, detail="欄位索引超出範圍")
|
||||
col_to_filter = df.columns[col_idx]
|
||||
x_signal, x_values = _numeric_signal(df[col_to_filter], col_to_filter)
|
||||
|
||||
y_float, y_fixed = _run_filter_paths(
|
||||
x_values, b_vals, a_vals, b_int, a_int,
|
||||
shift_in, shift_out, shift_b, shift_a, use_round, stages=stages,
|
||||
)
|
||||
|
||||
if compact:
|
||||
export_df = pd.DataFrame({
|
||||
"Time (s)": x_signal.index.to_numpy(dtype=float) / fs,
|
||||
col_to_filter: x_values,
|
||||
f"{col_to_filter}_filtered_ideal": y_float,
|
||||
f"{col_to_filter}_filtered_fixed": y_fixed,
|
||||
})
|
||||
else:
|
||||
export_df = df.copy()
|
||||
export_df[f"{col_to_filter}_filtered_ideal"] = pd.Series(y_float, index=x_signal.index)
|
||||
export_df[f"{col_to_filter}_filtered_fixed"] = pd.Series(y_fixed, index=x_signal.index)
|
||||
|
||||
csv_buffer = io.StringIO()
|
||||
export_df.to_csv(csv_buffer, index=False)
|
||||
return csv_buffer.getvalue()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -27,6 +27,19 @@ def parse_coefficients(raw, name):
|
||||
return validate_coefficients(values, name)
|
||||
|
||||
|
||||
def parse_int_coefficients(raw, name):
|
||||
try:
|
||||
# 先轉 float 再轉 int 以處理 1.0 這種字串
|
||||
values = [int(float(x.strip())) for x in raw.replace(",", " ").split() if x.strip()]
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail=f"{name} 係數格式錯誤 (預期為整數)")
|
||||
if len(values) == 0:
|
||||
raise HTTPException(status_code=400, detail=f"{name} 係數不可為空")
|
||||
if len(values) > MAX_COEFFICIENTS:
|
||||
raise HTTPException(status_code=400, detail=f"{name} 係數最多 {MAX_COEFFICIENTS} 個")
|
||||
return values
|
||||
|
||||
|
||||
def validate_coefficients(values, name):
|
||||
if len(values) == 0:
|
||||
raise HTTPException(status_code=400, detail=f"{name} 係數不可為空")
|
||||
|
||||
+122
-10
@@ -2,7 +2,7 @@ from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import RedirectResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from dea.bode import calculate_bode_compare_response, calculate_bode_response
|
||||
from dea.bode import calculate_bode_cascade_response, calculate_bode_compare_response, calculate_bode_response
|
||||
from dea.config import (
|
||||
BODE_MAX_MULTIPLIER,
|
||||
BODE_POINTS,
|
||||
@@ -14,22 +14,24 @@ from dea.config import (
|
||||
from dea.csv_processing import (
|
||||
downsample_for_plot,
|
||||
filter_preview_response,
|
||||
filtered_csv_data,
|
||||
filtered_csv_text,
|
||||
read_csv_upload,
|
||||
preset_csv_metadata,
|
||||
save_csv_upload,
|
||||
)
|
||||
from dea.filter_design import design_response
|
||||
from dea.mcu import list_mcu_ports_response, write_mcu_command_response
|
||||
from dea.schemas import BodeCompareParams, BodeParams, DesignParams, MCUWriteParams
|
||||
from dea.schemas import BodeCascadeParams, BodeCompareParams, BodeParams, DesignParams, MCUWriteParams
|
||||
from dea.security import (
|
||||
SECURITY_HEADERS,
|
||||
add_security_headers,
|
||||
is_lan_or_loopback,
|
||||
lan_denied_response,
|
||||
)
|
||||
import traceback
|
||||
from dea.validation import (
|
||||
normalize_coefficients,
|
||||
parse_coefficients,
|
||||
parse_int_coefficients,
|
||||
require_finite,
|
||||
validate_coefficients,
|
||||
validate_frequency,
|
||||
@@ -49,6 +51,7 @@ async def restrict_to_lan(request: Request, call_next):
|
||||
|
||||
# 掛載靜態網頁檔案,將預設首頁導向到 /ui/
|
||||
app.mount("/ui", StaticFiles(directory="static", html=True), name="static")
|
||||
app.mount("/uart_terminal", StaticFiles(directory="uart_example", html=True), name="uart_terminal")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
@@ -56,6 +59,11 @@ def redirect_to_ui():
|
||||
return RedirectResponse(url="/ui/")
|
||||
|
||||
|
||||
@app.get("/uart_terminal")
|
||||
def redirect_to_uart_terminal():
|
||||
return RedirectResponse(url="/uart_terminal/")
|
||||
|
||||
|
||||
@app.post("/api/design")
|
||||
def design_filter(params: DesignParams):
|
||||
try:
|
||||
@@ -86,21 +94,100 @@ def calculate_bode_compare(params: BodeCompareParams):
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/api/bode/compare_cascade")
|
||||
def calculate_bode_compare_cascade(params: BodeCascadeParams):
|
||||
try:
|
||||
return calculate_bode_cascade_response(params)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/api/csv/preset")
|
||||
def preset_csv():
|
||||
try:
|
||||
return preset_csv_metadata()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
raise HTTPException(status_code=400, detail=f"preset CSV 讀取失敗: {str(e)}")
|
||||
|
||||
|
||||
@app.post("/api/csv/upload")
|
||||
async def upload_csv(file: UploadFile = File(...)):
|
||||
try:
|
||||
file_id = await save_csv_upload(file)
|
||||
return {"file_id": file_id}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
raise HTTPException(status_code=400, detail=f"CSV上傳失敗: {str(e)}")
|
||||
|
||||
|
||||
def parse_stages_list(stages_str: str):
|
||||
if not isinstance(stages_str, str) or not stages_str:
|
||||
return None
|
||||
import json
|
||||
stages_raw = json.loads(stages_str)
|
||||
stages_list = []
|
||||
for i, stage in enumerate(stages_raw):
|
||||
stages_list.append({
|
||||
"b": parse_coefficients(stage.get("b_str", ""), f"stages[{i}].b"),
|
||||
"a": parse_coefficients(stage.get("a_str", ""), f"stages[{i}].a"),
|
||||
"b_int": parse_int_coefficients(stage.get("b_int_str", ""), f"stages[{i}].b_int") if stage.get("b_int_str") else None,
|
||||
"a_int": parse_int_coefficients(stage.get("a_int_str", ""), f"stages[{i}].a_int") if stage.get("a_int_str") else None,
|
||||
"shift_in": int(stage.get("shift_in", 14)),
|
||||
"shift_out": int(stage.get("shift_out", 14)),
|
||||
"shift_b": int(stage.get("shift_b", 14)),
|
||||
"shift_a": int(stage.get("shift_a", 14)),
|
||||
"use_round": bool(stage.get("use_round", False)),
|
||||
"isActive": bool(stage.get("isActive", True)),
|
||||
})
|
||||
return stages_list
|
||||
|
||||
|
||||
@app.post("/api/filter")
|
||||
async def filter_csv(
|
||||
file: UploadFile = File(...),
|
||||
file_id: str = Form(...),
|
||||
b: str = Form(...),
|
||||
a: str = Form(...),
|
||||
col_idx: int = Form(0),
|
||||
b_int: str = Form(None),
|
||||
a_int: str = Form(None),
|
||||
shift_in: int = Form(14),
|
||||
shift_out: int = Form(14),
|
||||
shift_b: int = Form(14),
|
||||
shift_a: int = Form(14),
|
||||
use_round: bool = Form(False),
|
||||
stages: str = Form(None),
|
||||
start_idx: int = Form(None),
|
||||
end_idx: int = Form(None),
|
||||
):
|
||||
try:
|
||||
b_vals = parse_coefficients(b, "b")
|
||||
a_vals = parse_coefficients(a, "a")
|
||||
df = await read_csv_upload(file)
|
||||
return filter_preview_response(df, b_vals, a_vals, col_idx)
|
||||
|
||||
b_int_vals = parse_int_coefficients(b_int, "b_int") if isinstance(b_int, str) and b_int else None
|
||||
a_int_vals = parse_int_coefficients(a_int, "a_int") if isinstance(a_int, str) and a_int else None
|
||||
stages_list = parse_stages_list(stages)
|
||||
|
||||
return filter_preview_response(
|
||||
file_id, b_vals, a_vals, col_idx,
|
||||
b_int=b_int_vals, a_int=a_int_vals,
|
||||
shift_in=shift_in, shift_out=shift_out,
|
||||
shift_b=shift_b, shift_a=shift_a,
|
||||
use_round=use_round,
|
||||
stages=stages_list,
|
||||
start_idx=start_idx,
|
||||
end_idx=end_idx
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
raise HTTPException(status_code=400, detail=f"CSV處理失敗: {str(e)}")
|
||||
|
||||
|
||||
@@ -126,16 +213,40 @@ def write_mcu_command(params: MCUWriteParams):
|
||||
|
||||
@app.post("/api/filter/download")
|
||||
async def filter_csv_download(
|
||||
file: UploadFile = File(...),
|
||||
file_id: str = Form(...),
|
||||
b: str = Form(...),
|
||||
a: str = Form(...),
|
||||
col_idx: int = Form(0),
|
||||
b_int: str = Form(None),
|
||||
a_int: str = Form(None),
|
||||
shift_in: int = Form(14),
|
||||
shift_out: int = Form(14),
|
||||
shift_b: int = Form(14),
|
||||
shift_a: int = Form(14),
|
||||
use_round: bool = Form(False),
|
||||
stages: str = Form(None),
|
||||
fs: float = Form(100000.0),
|
||||
compact: bool = Form(False),
|
||||
):
|
||||
try:
|
||||
b_vals = parse_coefficients(b, "b")
|
||||
a_vals = parse_coefficients(a, "a")
|
||||
df = await read_csv_upload(file)
|
||||
csv_text = filtered_csv_text(df, b_vals, a_vals, col_idx)
|
||||
|
||||
b_int_vals = parse_int_coefficients(b_int, "b_int") if isinstance(b_int, str) and b_int else None
|
||||
a_int_vals = parse_int_coefficients(a_int, "a_int") if isinstance(a_int, str) and a_int else None
|
||||
stages_list = parse_stages_list(stages)
|
||||
compact_value = compact if isinstance(compact, bool) else False
|
||||
|
||||
csv_text = filtered_csv_text(
|
||||
file_id, b_vals, a_vals, col_idx,
|
||||
b_int=b_int_vals, a_int=a_int_vals,
|
||||
shift_in=shift_in, shift_out=shift_out,
|
||||
shift_b=shift_b, shift_a=shift_a,
|
||||
use_round=use_round,
|
||||
stages=stages_list,
|
||||
fs=fs,
|
||||
compact=compact_value
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
iter([csv_text]),
|
||||
@@ -145,4 +256,5 @@ async def filter_csv_download(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
raise HTTPException(status_code=400, detail=f"CSV處理失敗: {str(e)}")
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
]
|
||||
}
|
||||
+1
-1
@@ -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>
|
||||
|
||||
Generated
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "bodeplot",
|
||||
"name": "diff-eq-analyzer",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
|
||||
+2
-1
@@ -2,7 +2,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite --host 0.0.0.0"
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"test:frontend": "node tests/frontend/cascade-stage-smoke.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
|
||||
+477
-275
File diff suppressed because it is too large
Load Diff
+754
-40
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+3
-3
File diff suppressed because one or more lines are too long
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
Generated
+1026
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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: './',
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user