feat(scripting): add sprintf2 command and fix prompt-wait race (v0.1.2)
- Add TeraTerm-compatible sprintf2 (C printf formatting into a string var) - Fix SVOS power-cycle script racing ahead by gating prompt waits behind output-completion markers - Bump version to 0.1.2; docs and README updates
This commit is contained in:
+65
-1
@@ -32,6 +32,7 @@ ETTerms 是一個給工程師 / 韌體 / 硬體驗證人員用的**單一視窗
|
||||
| 祕密儲存 | **Windows Credential Manager**(DPAPI / CredMan) | 連線密碼、SSH key passphrase,不落地明碼 |
|
||||
| PDU 控制(選用) | **SnmpSharpNet** | 沿用 MyTeraTerm PDU 控制(`pductrl` / `pduconnect`) |
|
||||
| 日誌 | 自製 **AppLogger**(從 MyTeraTerm 移植) | 檔案 + Debug 雙輸出 |
|
||||
| AI / MCP 整合(選用) | **stdio MCP server**(官方 C# SDK `ModelContextProtocol`) | 獨立行程把 serial port 暴露成 AI 可呼叫工具(Kiro CLI / Claude CLI),見 [AI / MCP Integration](#ai--mcp-integrationserial-mcp-server) |
|
||||
| 打包 | `dotnet publish` + (選用)Inno Setup / MSIX | 單機安裝,current-user |
|
||||
|
||||
> **與舊版 MyTeraTerm 的關鍵差異:** 舊版是把真正的 `ttermpro.exe`(TeraTerm)嵌進 Panel,靠 **com0com 虛擬 COM 對**攔截 serial 來跑腳本。ETTerms 改走**全原生**:SSH.NET 做 SSH、`System.IO.Ports` 做 serial、自繪 VT100 控制項做終端機畫面,**不再依賴外部 TeraTerm exe,也不再需要 com0com**。腳本引擎從「驅動 com0com bridge」改成「驅動原生 `ISessionChannel`」。
|
||||
@@ -108,7 +109,7 @@ ETTerms/
|
||||
│ └── MyTeraTerm/ # Script 參考專案(舊版 WinForms)
|
||||
│
|
||||
└── src/
|
||||
└── ETTerms/ # 主應用程式(WinForms)
|
||||
├── ETTerms/ # 主應用程式(WinForms)
|
||||
│
|
||||
├── Program.cs # 進入點
|
||||
├── ETTerms.csproj # net8.0-windows, UseWindowsForms
|
||||
@@ -158,6 +159,11 @@ ETTerms/
|
||||
├── AppLogger.cs # 日誌 (port 自 MyTeraTerm)
|
||||
├── AppSettings.cs # 使用者偏好 (JSON, %LocalAppData%\ETTerms\settings.json)
|
||||
└── NativeTheme.cs # 深色標題列 (DWM)
|
||||
│
|
||||
└── ETTerms.SerialMcp/ # 🔜 Serial MCP server(stdio,給 AI agent 直接操作 serial)
|
||||
├── Program.cs # stdio MCP host 進入點
|
||||
├── SerialTools.cs # serial_list / open / write / read / close 工具
|
||||
└── ETTerms.SerialMcp.csproj# net8.0 console + ModelContextProtocol SDK
|
||||
```
|
||||
|
||||
> **`For_AI/` 內含兩份參考專案**:`KKTerm-main`(UI 參考,Tauri+React 的 Windows 工作台)與 `MyTeraTerm`(Script 參考,舊版嵌 TeraTerm 的 WinForms)。整個 `For_AI/` 已 gitignore,僅供開發時對照,不進 repo。
|
||||
@@ -342,6 +348,54 @@ ScriptRunner.RunAsync(scriptText, activeChannel)
|
||||
|
||||
---
|
||||
|
||||
## AI / MCP Integration(Serial MCP Server)
|
||||
|
||||
> 讓 **Kiro CLI / Claude CLI** 等 AI agent 直接對 serial port 下指令、讀輸出。ETTerms 額外提供一支獨立的 **stdio MCP server**(`src/ETTerms.SerialMcp/`),把 serial port 包成 AI 可呼叫的工具。它與 WinForms 主程式**各自獨立行程**:由 MCP client(Kiro CLI / Claude CLI)啟動並維持整個 session 存活,因此能**持續持有 COM port**——連線狀態可跨多次工具呼叫保留,也能接收裝置主動推送的非同步輸出。
|
||||
|
||||
**為何要獨立常駐行程?** CLI agent 的每條 shell 指令都是一個新行程,`open→write→read` 無法跨呼叫保留狀態(port 一關就斷)。常駐的 MCP server 才能維持一條連線、累積 RX。
|
||||
|
||||
```
|
||||
┌── Kiro CLI / Claude CLI (MCP client) ──┐
|
||||
│ AI 呼叫工具:serial_open / write... │
|
||||
└───────────────┬─────────────────────────┘
|
||||
│ stdio (JSON-RPC 2.0)
|
||||
┌───────────────┴─────────────────────────┐
|
||||
│ ETTerms.SerialMcp (常駐行程) │
|
||||
│ 背景 reader 累積 RX → serial_read 取出 │
|
||||
│ │ System.IO.Ports.SerialPort │
|
||||
└────────┼─────────────────────────────────┘
|
||||
│ ← COM 互斥:與 GUI 不可同開同一 port →
|
||||
[ 實體 COM port / UART / 開發板 ]
|
||||
```
|
||||
|
||||
### 暴露的工具
|
||||
|
||||
| 工具 | 參數 | 說明 |
|
||||
|------|------|------|
|
||||
| `serial_list` | — | 列出可用 COM port(`SerialPort.GetPortNames()`) |
|
||||
| `serial_open` | portName, baudRate, dataBits, parity, stopBits, handshake, newLine | 開啟並持有 port(啟動背景 reader 累積 RX) |
|
||||
| `serial_write` | text, appendNewLine? | 送出文字(可選附加換行) |
|
||||
| `serial_read` | waitFor?, timeoutMs? | 取出 RX 緩衝;可等待特定字串或逾時 |
|
||||
| `serial_close` | — | 關閉 port |
|
||||
|
||||
### 設計重點
|
||||
|
||||
- **技術:** .NET 8 console(`net8.0`,無 WinForms)+ 官方 C# MCP SDK(`ModelContextProtocol`),stdio / JSON-RPC 2.0。
|
||||
- **參數語意:** 直接用 `System.IO.Ports.SerialPort`,與主程式的 `SerialSettings` 同一組參數(PortName / BaudRate / DataBits / Parity / StopBits / Handshake / NewLine)。
|
||||
- **COM 互斥:** MCP server 開了某 port 時,ETTerms GUI 不可同時開同一 port(反之亦然)——一個 COM 同時只能被一個行程開啟。
|
||||
- **安全:** 本機、無雲、不碰 credential;僅操作硬體 serial。
|
||||
|
||||
### 註冊(Kiro CLI)
|
||||
|
||||
```powershell
|
||||
kiro-cli mcp add --name serial --command dotnet `
|
||||
--args "run --project src\ETTerms.SerialMcp\ETTerms.SerialMcp.csproj"
|
||||
```
|
||||
|
||||
或寫進 agent.json 的 `mcpServers`;Claude CLI 則用其對應的 `mcpServers` 設定。註冊後直接對 AI 說「列出 COM port、開 COM3 115200、送 AT 看回應」即可。
|
||||
|
||||
---
|
||||
|
||||
## Key Constraints & Business Rules
|
||||
|
||||
1. **單機、無雲:** 所有連線 metadata 存本機 SQLite,密碼存 Windows Credential Manager,不回傳任何遙測。
|
||||
@@ -505,12 +559,22 @@ dotnet publish src\ETTerms\ETTerms.csproj -c Release -r win-x64 --self-contained
|
||||
- [ ] `dotnet publish` + Inno Setup / MSIX 安裝程式(待使用者指示)
|
||||
**驗收條件:** ✅ PDU 指令可用;Settings 重啟保留;Local Shell 行為正常(ConPTY);SFTP 可瀏覽遠端目錄。打包待後續。
|
||||
|
||||
### Phase 9 — AI 整合:Serial MCP Server(工作量:S)🔜 規劃中
|
||||
**目標:** 提供獨立 stdio MCP server,讓 Kiro CLI / Claude CLI 等 AI agent 直接對 serial port 下指令、讀輸出。
|
||||
**包含:**
|
||||
- [ ] `src/ETTerms.SerialMcp/`:.NET 8 console + 官方 C# MCP SDK(`ModelContextProtocol`),stdio / JSON-RPC
|
||||
- [ ] 工具:`serial_list` / `serial_open` / `serial_write` / `serial_read`(含 `waitFor` + `timeout`)/ `serial_close`
|
||||
- [ ] 常駐持有 COM port + 背景 reader 累積 RX(跨呼叫保留狀態、可收 async 輸出)
|
||||
- [ ] 註冊說明(`kiro-cli mcp add` / agent.json `mcpServers`)寫入 README
|
||||
**驗收條件:** 在 Kiro CLI 註冊後,能透過 AI 對話「列 COM port → 開 COM3 115200 → 送指令 → 讀回應」完成一輪 serial 互動;同一 COM port 不與 GUI 同時開啟。
|
||||
|
||||
---
|
||||
|
||||
## Future Extensions
|
||||
|
||||
這個版本**不做、但未來可能加**:
|
||||
|
||||
- **AI / MCP 整合**(🔜 已列為 [Phase 9](#development-phases):Serial MCP Server,讓 AI agent 直接操作 serial;未來可再擴充 SSH / Shell MCP 工具)
|
||||
- ~~**SFTP 檔案瀏覽**~~(✅ 已於 Phase 8 實作:sidebar SFTP 分頁)
|
||||
- **Telnet** session 類型(補一個 `TelnetChannel : ISessionChannel`)
|
||||
- **RDP / VNC** 分頁(KKTerm 用 mstscax.dll;ETTerms 可後期評估)
|
||||
|
||||
@@ -8,7 +8,7 @@ ETTerms 是一個 **C# .NET 8 WinForms** 的原生 Windows 終端機工作台,
|
||||
|
||||
**開發策略:GUI 先行** — 先把視窗外殼 + 分頁 + 連線清單做出來,再逐步補 Serial → SSH → VT100 → 腳本引擎 → Settings/About → PDU/Shell/SFTP。
|
||||
|
||||
**進度:** Phase 1–5 ✅、Phase 6 ✅(TTL 引擎 + Group 同步,SSH 待驗收)、Phase 7 ✅(Settings/About)、Phase 8 ✅(PDU + Shell/ConPTY + SFTP + Settings 擴充)。打包待指示。
|
||||
**進度:** Phase 1–5 ✅、Phase 6 ✅(TTL 引擎 + Group 同步,SSH 待驗收)、Phase 7 ✅(Settings/About)、Phase 8 ✅(PDU + Shell/ConPTY + SFTP + Settings 擴充)、Phase 9 🔜(Serial MCP server,讓 AI 直接操作 serial)。打包待指示。
|
||||
|
||||
## 技術棧
|
||||
|
||||
@@ -21,6 +21,7 @@ ETTerms 是一個 **C# .NET 8 WinForms** 的原生 Windows 終端機工作台,
|
||||
- **連線儲存:** SQLite(`Microsoft.Data.Sqlite`)
|
||||
- **密碼儲存:** Windows Credential Manager(不落地明碼)
|
||||
- **PDU:** SnmpSharpNet(iPoMan II/III via SNMP)
|
||||
- **AI / MCP(選用):** 獨立 stdio MCP server(`ETTerms.SerialMcp`,官方 C# SDK `ModelContextProtocol`)把 serial port 暴露給 Kiro CLI / Claude CLI
|
||||
- **設定持久化:** JSON → `%LocalAppData%\ETTerms\settings.json`
|
||||
|
||||
## 常用指令
|
||||
@@ -35,6 +36,9 @@ dotnet add src\ETTerms package SSH.NET
|
||||
|
||||
# 打包
|
||||
dotnet publish src\ETTerms\ETTerms.csproj -c Release -r win-x64 --self-contained false
|
||||
|
||||
# 註冊 Serial MCP server(給 AI agent 操作 serial)
|
||||
kiro-cli mcp add --name serial --command dotnet --args "run --project src\ETTerms.SerialMcp\ETTerms.SerialMcp.csproj"
|
||||
```
|
||||
|
||||
## 開發慣例
|
||||
@@ -51,11 +55,14 @@ dotnet publish src\ETTerms\ETTerms.csproj -c Release -r win-x64 --self-contained
|
||||
- 🚫 **不嵌 TeraTerm、不依賴 com0com** —— ETTerms 走全原生(這是與舊版 MyTeraTerm 的關鍵差異)。
|
||||
- 🚫 不要把 `For_AI/` 內容 commit 進 git。
|
||||
- ⚠️ Serial COM port 同時只能被一個 session 開啟,開啟前檢查可用性。
|
||||
- ⚠️ Serial MCP server(`ETTerms.SerialMcp`)與 GUI **不可同時開同一個 COM port**;AI 操作該 port 前,先關掉 GUI 對它的連線(反之亦然)。
|
||||
- ⚠️ VT 相容性以常見情境(VT100 / 常見 ANSI)為主,冷門 escape 後補,不阻塞 GUI 進度。
|
||||
- ⚠️ 本專案**無伺服端祕密 / 無 DB 密碼 / 無 EC2 / 無 VM**,因此不套用 AWS / VirtualBox 部署流程。
|
||||
- ⚠️ Group 同步指令(`waitall` / `sendlnall` / `sendlngroup`)**只能在 Run Group 模式**使用;`▶ Script` 和 `▶ Run All` 須拒絕含這些指令的腳本。
|
||||
|
||||
## 資料夾用途
|
||||
|
||||
- **`src/ETTerms/`** — 主應用程式(WinForms 視窗外殼 + 連線 / 終端機 / 腳本引擎)。
|
||||
- **`src/ETTerms.SerialMcp/`** — 🔜 獨立 stdio MCP server(給 AI agent 直接操作 serial),與 WinForms 主程式各自獨立行程;net8.0 console + `ModelContextProtocol` SDK。
|
||||
- **`For_AI/`** — AI 協作素材與**參考專案**(`KKTerm-main` UI 參考、`MyTeraTerm` Script 參考)。整個資料夾 gitignored,僅供開發對照。
|
||||
- 本專案**無 `secret/` 資料夾**:沒有伺服端祕密 / DB 密碼 / compile-time secret,連線密碼一律走 Windows Credential Manager。
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 ETTerms Project
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,43 +1,422 @@
|
||||
# ETTerms
|
||||
|
||||
原生 Windows 終端機工作台(C# .NET 8 WinForms),支援 **SSH** 與 **Serial Port** 連線,
|
||||
並內建從 MyTeraTerm 移植的 **TTL 腳本引擎**做自動化。單機、無雲、無登入系統。
|
||||
[](README.md) [](README.zh-TW.md)
|
||||
|
||||
詳細架構見 [ARCHITECTURE.md](ARCHITECTURE.md)。
|
||||
> A native Windows terminal workspace (C# .NET 8 WinForms) — **SSH**, **Serial Port**, and **local Shell (ConPTY)** in one window, with a **TTL scripting engine** ported from MyTeraTerm for automation, plus an optional **Serial MCP server** that lets AI agents (Kiro CLI / Claude CLI) drive the serial port directly. Standalone, no cloud, no login.
|
||||
|
||||
## 建置 / 執行
|
||||
       
|
||||
|
||||
---
|
||||
|
||||
## 📖 Table of Contents
|
||||
|
||||
- [✨ Features](#-features)
|
||||
- [🖼️ Layout](#️-layout)
|
||||
- [💻 System Requirements](#-system-requirements)
|
||||
- [📥 Installation](#-installation)
|
||||
- [🚀 Quick Start](#-quick-start)
|
||||
- [📚 Usage Guide](#-usage-guide)
|
||||
- [🤖 TTL Scripting](#-ttl-scripting)
|
||||
- [👥 Group Sync Execution](#-group-sync-execution)
|
||||
- [⚡ PDU Power Control](#-pdu-power-control)
|
||||
- [🤖 AI / MCP Integration](#-ai--mcp-integration)
|
||||
- [🔐 Data & Security](#-data--security)
|
||||
- [🔧 Troubleshooting](#-troubleshooting)
|
||||
- [🔨 Building from Source](#-building-from-source)
|
||||
- [📁 Project Structure](#-project-structure)
|
||||
- [🤝 Contributing](#-contributing)
|
||||
- [📜 Version History](#-version-history)
|
||||
- [📄 License](#-license)
|
||||
- [🙏 Acknowledgments](#-acknowledgments)
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
### Core Functionality
|
||||
|
||||
- 🖥️ **Multiple protocols in one workspace**
|
||||
- **SSH** (`SSH.NET`): password / private key / keyboard-interactive auth, with built-in SFTP
|
||||
- **Serial Port** (`System.IO.Ports`): configurable COM port, baud, data bits, parity, stop bits, handshake
|
||||
- **Local Shell** (Windows ConPTY): launch PowerShell / Cmd / Bash directly
|
||||
|
||||
- 🪟 **Tiling tabbed workspace**
|
||||
- One-click grid layouts: `1×1 / 1×2 / 2×1 / 2×2 / 2×3 / 3×3`
|
||||
- Per-pane manual split: `↔` horizontal / `↕` vertical, drag splitters to resize proportionally
|
||||
- Each pane hosts its own mini tab strip for multiple connections
|
||||
|
||||
- 🗂️ **Editable connection sidebar (Saved Connections)**
|
||||
- Nested folders with connection-count badges, expand / collapse all
|
||||
- Live filter by name / host, matching branches auto-expand
|
||||
- Context menu & toolbar: add / rename / delete, drag to re-categorize
|
||||
- Double-click to open in the active pane; ad-hoc quick connect (not saved)
|
||||
|
||||
- 🤖 **TTL script automation**
|
||||
- TTL (Tera Term Language) interpreter ported and extended from MyTeraTerm
|
||||
- Runs `.ttl` against the active session, showing file / line / current command live
|
||||
- Supports `send` / `sendln` / `wait` / `if` / `while` / variable math / file logging
|
||||
- Long-running `wait` / `while` can be aborted anytime with **■ Stop**
|
||||
|
||||
- 👥 **Group sync execution**
|
||||
- Assign tabs to Group 1/2/3 and run one script across the whole group at once
|
||||
- Sync commands: `waitall` (barrier), `sendlnall` (send after all arrive), `sendlngroup` (target a member)
|
||||
|
||||
- ⚡ **PDU power control (optional)**
|
||||
- Control PDU outlets over SNMP (`SnmpSharpNet`) to power-cycle devices during tests
|
||||
- Script commands: `pduconnect` / `pductrl`
|
||||
|
||||
- 🤖 **AI / MCP integration (optional, planned)**
|
||||
- A standalone stdio **Serial MCP server** exposes the serial port as AI-callable tools for **Kiro CLI / Claude CLI**
|
||||
- Tools: `serial_list` / `serial_open` / `serial_write` / `serial_read` / `serial_close`
|
||||
- Just tell the AI to open a COM port, send a command, and read the reply
|
||||
|
||||
- 📊 **Session RX logging**
|
||||
- `logopen` / `logwrite` / `logclose` write session output to file
|
||||
|
||||
### Terminal Rendering
|
||||
|
||||
- 🎨 Owner-drawn VT100 / ANSI control, double-buffered cell grid
|
||||
- 🌑 KKTerm-style dark theme (incl. DWM dark title bar)
|
||||
- 🔤 Configurable font / size / palette / scrollback
|
||||
- 📋 Select / copy / paste
|
||||
|
||||
---
|
||||
|
||||
## 🖼️ Layout
|
||||
|
||||
```
|
||||
┌──────┬─────────────────────┬───────────────────────────────────────┐
|
||||
│ ▣ T │ Saved Connections │ Workspace (tiling) │
|
||||
│ ▣ S │ 🔍 search │ ┌─────────────────┬─────────────────┐ │
|
||||
│ ▣ ⚙ │ ▾ 📁 Servers (2) │ │ [tab1][tab2] + │ [tab1] + │ │
|
||||
│ │ ▸ SSH srv-01 │ │ │ │ │
|
||||
│ icon │ ▸ SSH nas │ │ TerminalView │ TerminalView │ │
|
||||
│ rail │ ▾ 📁 Boards (2) │ │ (owner-drawn) │ (owner-drawn) │ │
|
||||
│ │ ▸ COM3 @115200 │ │ │ │ │
|
||||
│ │ ▸ COM7 @9600 │ ├─────────────────┴─────────────────┤ │
|
||||
│ │ │ │ [Group1-A] ▶ Script ■ Stop │ │
|
||||
└──────┴─────────────────────┴───────────────────────────────────────┘
|
||||
Activity Rail Sidebar Tabbed / Tiling Workspace
|
||||
```
|
||||
|
||||
> The Activity Rail switches the three main views — **Terminal / Scripts / Settings**. The sidebar manages saved connections; the workspace holds multiple sessions across tabs and tiled panes.
|
||||
|
||||
---
|
||||
|
||||
## 💻 System Requirements
|
||||
|
||||
| Item | Requirement |
|
||||
|------|-------------|
|
||||
| OS | Windows 10 (1809+) / Windows 11 |
|
||||
| Runtime | .NET 8 Desktop Runtime (`Microsoft.WindowsDesktop.App 8.0.x`) |
|
||||
| Display | 1600×900 or higher recommended |
|
||||
| Optional hardware | USB-to-Serial adapter (Serial), SNMP-capable PDU (power control) |
|
||||
|
||||
Check the runtime:
|
||||
|
||||
```powershell
|
||||
dotnet --list-runtimes | findstr WindowsDesktop
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📥 Installation
|
||||
|
||||
Source build for now (no binary release yet).
|
||||
|
||||
```powershell
|
||||
git clone <repo-url> ETTerms
|
||||
cd ETTerms
|
||||
dotnet build
|
||||
dotnet run --project src\ETTerms\ETTerms.csproj
|
||||
```
|
||||
|
||||
## TTL 腳本
|
||||
Full steps in [Building from Source](#-building-from-source).
|
||||
|
||||
在任一連線分頁頂部的腳本列按 **▶ Script** 載入 `.ttl` 對該連線執行;左側狀態列即時顯示
|
||||
執行到的行號與指令,**■ Stop** 可中止。完整語法與範例見
|
||||
[docs/ttl-script-reference.md](docs/ttl-script-reference.md),範例腳本在 `tools/scripts/`。
|
||||
---
|
||||
|
||||
### 目前支援的指令
|
||||
## 🚀 Quick Start
|
||||
|
||||
| 指令 | 說明 |
|
||||
|------|------|
|
||||
| `send '文字'` | 送出文字(不加換行) |
|
||||
| `sendln '文字'` | 送出文字並附加 `\r\n` |
|
||||
| `wait '字串'` | 一直等到接收緩衝出現該字串才往下(預設無限等待,可按 Stop 取消);命中後 `result=1` |
|
||||
| `flushrecv` | 清空接收緩衝區 |
|
||||
| `pause 秒數` | 暫停 N 秒(可被 Stop 中止) |
|
||||
| `timeout = 秒數` | 設定 `wait` 逾時秒數;`0`=無限等待,`N>0` 超時會中止腳本並報錯 |
|
||||
| `if … then` / `elseif … then` / `else` / `endif` | 條件分支(可巢狀) |
|
||||
| `while …` / `endwhile` | 迴圈(可巢狀,可被 Stop 中止) |
|
||||
| `名稱 = 值` | 變數指派;支援 `+ - * /` 整數運算與字串;內建變數 `result` |
|
||||
| `logopen '檔名'` | 開啟 log 檔(覆寫) |
|
||||
| `logwrite '文字'` | 寫一行到 log |
|
||||
| `logclose` | 關閉 log(腳本結束自動關閉) |
|
||||
| `messagebox '訊息'` | 跳出對話框(會顯示在最上層) |
|
||||
| `; 註解` | 行內註解(`;` 之後到行尾) |
|
||||
| `:label` | 標籤行(會被略過) |
|
||||
### Create an SSH connection
|
||||
|
||||
**條件運算子**(`if` / `elseif` / `while`):`>=` `<=` `>` `<` `==` `!=` `=`,或無運算子(非零為真)。
|
||||
1. Launch ETTerms; on the left Activity Rail switch to **Terminal**
|
||||
2. In the sidebar click **+ New Connection** and pick type **SSH**
|
||||
3. Enter `Host` / `Port` (default 22) / `Username`, choose auth (password / private key)
|
||||
4. Double-click the connection → opens a tab in the active pane
|
||||
|
||||
> `pductrl` / `pduconnect`(SNMP PDU 控制)屬 Phase 7,目前尚未支援。
|
||||
### Create a Serial connection
|
||||
|
||||
1. Sidebar **+ New Connection**, type **Serial**
|
||||
2. Pick `COM port` and `BaudRate` (e.g. `COM3` / `115200`)
|
||||
3. Double-click to open
|
||||
|
||||
### Run a TTL script
|
||||
|
||||
1. With a tab connected, click **▶ Script** on the script bar and pick a `.ttl`
|
||||
2. The left status bar shows the running line and command live
|
||||
3. Press **■ Stop** to abort if needed
|
||||
|
||||
---
|
||||
|
||||
## 📚 Usage Guide
|
||||
|
||||
### Activity Rail
|
||||
|
||||
Switches the three main views: **Terminal** (workspace), **Scripts** (edit & run), **Settings** (preferences).
|
||||
|
||||
### Connection Sidebar (Saved Connections)
|
||||
|
||||
| Action | How |
|
||||
|--------|-----|
|
||||
| Add folder / connection | Toolbar button or context menu |
|
||||
| Rename / delete | Context menu |
|
||||
| Categorize | Drag a connection or folder into a target folder (cannot drop into its own descendant) |
|
||||
| Search | Top search box, live filter by name / host |
|
||||
| Open connection | Double-click → opens in the active pane |
|
||||
|
||||
### Workspace Tiling
|
||||
|
||||
- One-click grid layouts: `1×1 / 1×2 / 2×1 / 2×2 / 2×3 / 3×3`
|
||||
- Per-pane actions (top-right): `↔` split horizontally, `↕` split vertically, `+` new tab, `✕` close
|
||||
- Closing a pane collapses the split so siblings fill the space; at least one pane remains
|
||||
- The active pane is outlined with an accent border
|
||||
|
||||
### Terminal
|
||||
|
||||
- Incoming channel bytes → ANSI parser → screen buffer → painted
|
||||
- Keyboard input is encoded to byte sequences sent to the remote
|
||||
- Supports scrollback, select / copy / paste; font and palette under **Settings**
|
||||
|
||||
---
|
||||
|
||||
## 🤖 TTL Scripting
|
||||
|
||||
On any connection tab's script bar, click **▶ Script** to load a `.ttl` and run it against that connection. Full syntax and examples in
|
||||
[docs/ttl-script-reference.md](docs/ttl-script-reference.md); sample scripts in `tools/scripts/`.
|
||||
|
||||
### Supported Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `send 'text'` | Send text (no newline) |
|
||||
| `sendln 'text'` | Send text plus `\r\n` |
|
||||
| `wait 'string'` | Wait until the string appears in the RX buffer (infinite by default, cancellable via Stop); sets `result=1` on match |
|
||||
| `flushrecv` | Clear the RX buffer |
|
||||
| `pause seconds` | Pause N seconds (cancellable via Stop) |
|
||||
| `timeout = seconds` | `wait` timeout; `0` = infinite, `N>0` aborts the script with an error on timeout |
|
||||
| `if … then` / `elseif … then` / `else` / `endif` | Conditional branching (nestable) |
|
||||
| `while …` / `endwhile` | Loop (nestable, cancellable via Stop) |
|
||||
| `name = value` | Variable assignment; integer `+ - * /` and strings; built-in `result` |
|
||||
| `logopen 'file'` | Open a log file (overwrite) |
|
||||
| `logwrite 'text'` | Write one line to the log |
|
||||
| `logclose` | Close the log (auto-closed at script end) |
|
||||
| `messagebox 'msg'` | Show a topmost dialog |
|
||||
| `; comment` | Inline comment (from `;` to end of line) |
|
||||
| `:label` | Label line (skipped) |
|
||||
|
||||
**Comparison operators** (`if` / `elseif` / `while`): `>=` `<=` `>` `<` `==` `!=` `=`, or none (non-zero = true).
|
||||
|
||||
### Example: auto login
|
||||
|
||||
```ttl
|
||||
; tools/scripts/login.ttl
|
||||
timeout = 10
|
||||
wait 'login: '
|
||||
sendln 'root'
|
||||
wait 'Password: '
|
||||
sendln 'toor'
|
||||
wait '# '
|
||||
sendln 'uname -a'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 👥 Group Sync Execution
|
||||
|
||||
Assign multiple tabs to one Group and run a single script across all of them — ideal for syncing multiple DUTs.
|
||||
|
||||
1. **Right-click a tab** → set as Group 1 / 2 / 3 (or clear); the cell footer shows a `[Group1-A]` label
|
||||
2. Click **▶ Group1 / ▶ Group2 / ▶ Group3** on the toolbar to run across the whole group
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `waitall 'string'` | All members wait for the keyword, then continue together (`System.Threading.Barrier`) |
|
||||
| `sendlnall 'text'` | Each member sends once all have arrived |
|
||||
| `sendlngroup N 'text'` | Only the specified member sends |
|
||||
|
||||
> ⚠️ Group sync commands work **only in Run Group mode**; `▶ Script` and `▶ Run All` reject scripts containing them and show a warning.
|
||||
|
||||
---
|
||||
|
||||
## ⚡ PDU Power Control
|
||||
|
||||
Control PDU outlets over SNMP to power-cycle a DUT from within a script.
|
||||
|
||||
```ttl
|
||||
; connect PDU (device 2 = iPoMan II 1202, IP 192.168.1.21)
|
||||
pduconnect 2 192.168.1.21
|
||||
|
||||
pductrl 2 1 0 ; turn port 1 off
|
||||
pause 5
|
||||
pductrl 2 1 1 ; turn port 1 on
|
||||
wait 'login: '
|
||||
```
|
||||
|
||||
| Command | Syntax | Description |
|
||||
|---------|--------|-------------|
|
||||
| `pduconnect` | `pduconnect <device> <ip>` | Connect to a PDU |
|
||||
| `pductrl` | `pductrl <device> <port> <0\|1>` | Set a port off(0) / on(1) |
|
||||
|
||||
---
|
||||
|
||||
## 🤖 AI / MCP Integration
|
||||
|
||||
> 🔜 **Planned (Phase 9).** Let AI agents (**Kiro CLI / Claude CLI**) drive the serial port directly — issue commands and read output.
|
||||
|
||||
ETTerms ships a standalone **stdio MCP server** (`src/ETTerms.SerialMcp/`) that wraps the serial port as AI-callable tools. It runs as its own process, kept alive by the MCP client for the whole session, so it holds the COM port open across calls and can capture asynchronous device output.
|
||||
|
||||
> **Why a separate process?** Each CLI shell command is a fresh process, so `open→write→read` can't keep state across calls. A long-lived MCP server maintains one connection and accumulates received bytes.
|
||||
|
||||
| Tool | Params | Description |
|
||||
|------|--------|-------------|
|
||||
| `serial_list` | — | List available COM ports |
|
||||
| `serial_open` | portName, baudRate, dataBits, parity, stopBits, handshake, newLine | Open and hold the port |
|
||||
| `serial_write` | text, appendNewLine? | Send text (optional newline) |
|
||||
| `serial_read` | waitFor?, timeoutMs? | Drain the RX buffer; optionally wait for a string / timeout |
|
||||
| `serial_close` | — | Close the port |
|
||||
|
||||
Register in Kiro CLI:
|
||||
|
||||
```powershell
|
||||
kiro-cli mcp add --name serial --command dotnet --args "run --project src\ETTerms.SerialMcp\ETTerms.SerialMcp.csproj"
|
||||
```
|
||||
|
||||
Or add it to `agent.json` under `mcpServers` (Claude CLI uses an equivalent `mcpServers` config). Then just tell the AI: *"list COM ports, open COM3 at 115200, send `AT` and read the reply."*
|
||||
|
||||
> ⚠️ A COM port can be opened by only one process at a time — don't open the same port in both the GUI and the MCP server.
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Data & Security
|
||||
|
||||
- **Standalone, no cloud:** connection metadata is stored in a local SQLite file (`%LocalAppData%\ETTerms\ettermsdb.sqlite`); no telemetry is sent.
|
||||
- **Passwords never stored in plaintext:** SQLite holds only a `CredentialKey` pointing to **Windows Credential Manager**; actual passwords / key passphrases are accessed via `CredentialVault`.
|
||||
- **SSH host keys:** the fingerprint is shown for confirmation on first connect (trust-on-first-use), then compared; mismatches warn.
|
||||
- **Logs contain no secrets:** `AppLogger` and `logopen` record only host / port, never credentials.
|
||||
- **No `secret/` directory:** a single-user desktop app with no server-side secrets, DB passwords, or compile-time secrets.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
| Issue | Likely cause | Fix |
|
||||
|-------|--------------|-----|
|
||||
| Startup reports missing runtime | .NET 8 Desktop Runtime not installed | Install `Microsoft.WindowsDesktop.App 8.0.x`; verify with `dotnet --list-runtimes` |
|
||||
| Serial connect fails / port busy | COM port held by another app | Close other terminal apps; a COM port can be opened by only one session at a time |
|
||||
| SSH auth fails | Wrong credentials / key or host-key mismatch | Check auth settings; verify the host-key fingerprint |
|
||||
| Script stuck on `wait` | Expected string never received | Check connection & baud, verify the `wait` string; set `timeout` or press ■ Stop |
|
||||
| Group script rejected | Ran a script with Group commands via `▶ Script` | Use **▶ GroupN** instead |
|
||||
|
||||
More runbooks in [docs/runbooks/](docs/runbooks/).
|
||||
|
||||
---
|
||||
|
||||
## 🔨 Building from Source
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- .NET 8 SDK (or SDK 9/10 + .NET 8 Desktop Runtime)
|
||||
- Visual Studio 2022 or VS Code + C# extension
|
||||
- Git
|
||||
|
||||
### Build & Run
|
||||
|
||||
```powershell
|
||||
dotnet --list-sdks
|
||||
dotnet build
|
||||
dotnet run --project src\ETTerms\ETTerms.csproj
|
||||
```
|
||||
|
||||
### Key NuGet Packages
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `SSH.NET` | SSH shell + SFTP |
|
||||
| `System.IO.Ports` | Serial connections |
|
||||
| `Microsoft.Data.Sqlite` | Connection metadata storage |
|
||||
| `SnmpSharpNet` | PDU control (optional) |
|
||||
|
||||
### Publish (self-contained=false)
|
||||
|
||||
```powershell
|
||||
dotnet publish src\ETTerms\ETTerms.csproj -c Release -r win-x64 --self-contained false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
ETTerms/
|
||||
├── README.md # English (default)
|
||||
├── README.zh-TW.md # Traditional Chinese
|
||||
├── ARCHITECTURE.md # Full architecture doc
|
||||
├── CLAUDE.md # Project memory & dev commands
|
||||
├── ETTerms.slnx
|
||||
├── docs/
|
||||
│ ├── ttl-script-reference.md # TTL command reference
|
||||
│ └── runbooks/ # Runbooks / troubleshooting
|
||||
├── tools/scripts/ # Sample .ttl scripts
|
||||
├── src/ETTerms/ # Main application (WinForms)
|
||||
│ ├── App/ # Window shell: MainForm / ActivityRail / ConnectionSidebar / Workspace
|
||||
│ ├── Terminal/ # Owner-drawn VT100: TerminalView / AnsiParser / ScreenBuffer / TerminalInput
|
||||
│ ├── Sessions/ # Connection abstraction: ISessionChannel / SshChannel / SerialChannel / ShellChannel
|
||||
│ ├── Connections/ # Connection data: Connection / ConnectionStore(SQLite) / CredentialVault
|
||||
│ ├── Scripting/ # TTL engine: TTLInterpreter / ScriptRunner / GroupSyncContext / Pdu
|
||||
│ └── Infrastructure/ # AppLogger / AppSettings / NativeTheme
|
||||
└── src/ETTerms.SerialMcp/ # 🔜 Serial MCP server (stdio) — lets AI agents drive the serial port
|
||||
```
|
||||
|
||||
**Core design:** every connection implements `ISessionChannel` (`Write(byte[])` + `event DataReceived`). `TerminalView` and `TTLInterpreter` only know this abstraction, so SSH / Serial / Shell look identical to upper layers — the key to "one script engine driving multiple connection types." See [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Fork and create a feature branch: `git checkout -b feature/your-feature`
|
||||
2. Follow [Conventional Commits](https://www.conventionalcommits.org/): `feat(serial): add auto-reconnect`
|
||||
3. Push and open a Pull Request
|
||||
|
||||
**Conventions:** PascalCase types / methods, `_camelCase` private fields, filename = class name; the UI layer depends only on the `ISessionChannel` abstraction; channel I/O runs in the background and all UI updates go back to the UI thread via `Control.Invoke`.
|
||||
|
||||
---
|
||||
|
||||
## 📜 Version History
|
||||
|
||||
### v0.1.0 (in development)
|
||||
|
||||
- Phases 1–8 complete: window shell, connection sidebar, tiling workspace, Serial / SSH / local Shell (ConPTY) / SFTP
|
||||
- Owner-drawn VT100 terminal rendering
|
||||
- TTL scripting engine (ported from MyTeraTerm) + Group sync execution
|
||||
- PDU power control (SNMP)
|
||||
- Settings / About
|
||||
- Packaging TBD
|
||||
|
||||
**Planned**
|
||||
|
||||
- Phase 9: Serial MCP server (`ETTerms.SerialMcp`) — let AI agents (Kiro CLI / Claude CLI) drive the serial port directly
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
Licensed under the **MIT License** — see [LICENSE](LICENSE).
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
- **[KKTerm](https://github.com/)** — UI design reference (Activity Rail + tabbed workspace + Saved Connections)
|
||||
- **MyTeraTerm** — source of the TTL scripting engine and `AppLogger`
|
||||
- **[SSH.NET](https://github.com/sshnet/SSH.NET)**, **[SnmpSharpNet](http://www.snmpsharpnet.com/)** — open-source connectivity / SNMP libraries
|
||||
- **Microsoft** — .NET 8, WinForms, ConPTY, Credential Manager
|
||||
|
||||
+422
@@ -0,0 +1,422 @@
|
||||
# ETTerms
|
||||
|
||||
[](README.md) [](README.zh-TW.md)
|
||||
|
||||
> 原生 Windows 終端機工作台(C# .NET 8 WinForms)—— 一個視窗整合 **SSH**、**Serial Port**、**本機 Shell (ConPTY)** 連線,內建從 MyTeraTerm 移植的 **TTL 腳本引擎**做自動化,並提供選用的 **Serial MCP server**,讓 AI agent(Kiro CLI / Claude CLI)直接操作 serial port。單機、無雲、無登入系統。
|
||||
|
||||
       
|
||||
|
||||
---
|
||||
|
||||
## 📖 目錄
|
||||
|
||||
- [✨ 功能特色](#-功能特色)
|
||||
- [🖼️ 介面配置](#️-介面配置)
|
||||
- [💻 系統需求](#-系統需求)
|
||||
- [📥 安裝](#-安裝)
|
||||
- [🚀 快速開始](#-快速開始)
|
||||
- [📚 使用指南](#-使用指南)
|
||||
- [🤖 TTL 腳本](#-ttl-腳本)
|
||||
- [👥 Group 同步執行](#-group-同步執行)
|
||||
- [⚡ PDU 電源控制](#-pdu-電源控制)
|
||||
- [🤖 AI / MCP 整合](#-ai--mcp-整合)
|
||||
- [🔐 連線資料與安全](#-連線資料與安全)
|
||||
- [🔧 疑難排解](#-疑難排解)
|
||||
- [🔨 從原始碼建置](#-從原始碼建置)
|
||||
- [📁 專案結構](#-專案結構)
|
||||
- [🤝 貢獻](#-貢獻)
|
||||
- [📜 版本紀錄](#-版本紀錄)
|
||||
- [📄 授權](#-授權)
|
||||
- [🙏 致謝](#-致謝)
|
||||
|
||||
---
|
||||
|
||||
## ✨ 功能特色
|
||||
|
||||
### 核心功能
|
||||
|
||||
- 🖥️ **多協定連線,單一工作台**
|
||||
- **SSH**(`SSH.NET`):password / private key / keyboard-interactive 認證,內建 SFTP
|
||||
- **Serial Port**(`System.IO.Ports`):COM port、baud、data bits、parity、stop bits、handshake 全可調
|
||||
- **本機 Shell**(Windows ConPTY):直接開 PowerShell / Cmd / Bash
|
||||
|
||||
- 🪟 **可平鋪 (tiling) 的分頁工作區**
|
||||
- 一鍵切換 `1×1 / 1×2 / 2×1 / 2×2 / 2×3 / 3×3` 格狀佈局
|
||||
- 每個 pane 可左右 `↔` / 上下 `↕` 手動分割,拖格線按比例縮放
|
||||
- 每個 pane 內可再開多條連線分頁(pane 自己的迷你 tab strip)
|
||||
|
||||
- 🗂️ **可編輯的連線側欄(Saved Connections)**
|
||||
- 自建巢狀資料夾分類、連線數量徽章、全部展開 / 收合
|
||||
- 名稱 / 主機即時搜尋過濾,命中分支自動展開
|
||||
- 右鍵與工具列:新增 / 改名 / 刪除、拖曳分類
|
||||
- 雙擊連線 → 開進 active pane;支援不存檔的快速連線
|
||||
|
||||
- 🤖 **TTL 腳本自動化**
|
||||
- 沿用並擴充 MyTeraTerm 的 TTL(Tera Term Language)直譯器
|
||||
- 對 active session 執行 `.ttl`,即時顯示「檔名 / 行號 / 當前指令」
|
||||
- 支援 `send` / `sendln` / `wait` / `if` / `while` / 變數運算 / log 寫檔等
|
||||
- 長時間 `wait` / `while` 可隨時 **■ Stop** 中止
|
||||
|
||||
- 👥 **Group 同步執行**
|
||||
- 把多個分頁設為 Group 1/2/3,對整組同時跑同一份腳本
|
||||
- 同步指令:`waitall`(等齊)、`sendlnall`(到齊後各自送)、`sendlngroup`(指定成員送)
|
||||
|
||||
- ⚡ **PDU 電源控制(選用)**
|
||||
- 透過 SNMP(`SnmpSharpNet`)控制 PDU 插座,測試中遠端電源循環
|
||||
- 腳本指令:`pduconnect` / `pductrl`
|
||||
|
||||
- 🤖 **AI / MCP 整合(選用,規劃中)**
|
||||
- 獨立的 stdio **Serial MCP server** 把 serial port 暴露成 AI 可呼叫工具,供 **Kiro CLI / Claude CLI** 使用
|
||||
- 工具:`serial_list` / `serial_open` / `serial_write` / `serial_read` / `serial_close`
|
||||
- 直接對 AI 說「開某個 COM port、送指令、讀回應」即可
|
||||
|
||||
- 📊 **連線 RX 日誌**
|
||||
- `logopen` / `logwrite` / `logclose` 將工作階段輸出寫檔
|
||||
|
||||
### 終端機渲染
|
||||
|
||||
- 🎨 自繪 VT100 / ANSI 控制項(owner-drawn),雙緩衝繪字格
|
||||
- 🌑 KKTerm 風格深色主題(含 DWM 深色標題列)
|
||||
- 🔤 可調字型 / 字級 / 配色 / scrollback 行數
|
||||
- 📋 選取 / 複製 / 貼上
|
||||
|
||||
---
|
||||
|
||||
## 🖼️ 介面配置
|
||||
|
||||
```
|
||||
┌──────┬─────────────────────┬───────────────────────────────────────┐
|
||||
│ ▣ T │ Saved Connections │ Workspace (可平鋪) │
|
||||
│ ▣ S │ 🔍 search │ ┌─────────────────┬─────────────────┐ │
|
||||
│ ▣ ⚙ │ ▾ 📁 Servers (2) │ │ [tab1][tab2] + │ [tab1] + │ │
|
||||
│ │ ▸ SSH srv-01 │ │ │ │ │
|
||||
│ 圖示 │ ▸ SSH nas │ │ TerminalView │ TerminalView │ │
|
||||
│ 列 │ ▾ 📁 Boards (2) │ │ (VT100 自繪) │ (VT100 自繪) │ │
|
||||
│ │ ▸ COM3 @115200 │ │ │ │ │
|
||||
│ │ ▸ COM7 @9600 │ ├─────────────────┴─────────────────┤ │
|
||||
│ │ │ │ [Group1-A] ▶ Script ■ Stop │ │
|
||||
└──────┴─────────────────────┴───────────────────────────────────────┘
|
||||
Activity Rail Sidebar Tabbed / Tiling Workspace
|
||||
```
|
||||
|
||||
> Activity Rail(左側圖示列)切換 **Terminal / Scripts / Settings** 三大檢視;側欄管理已存連線;工作區以分頁與平鋪 pane 容納多條 session。
|
||||
|
||||
---
|
||||
|
||||
## 💻 系統需求
|
||||
|
||||
| 項目 | 需求 |
|
||||
|------|------|
|
||||
| 作業系統 | Windows 10 (1809+) / Windows 11 |
|
||||
| 執行階段 | .NET 8 Desktop Runtime(`Microsoft.WindowsDesktop.App 8.0.x`) |
|
||||
| 顯示 | 建議 1600×900 以上 |
|
||||
| 選用硬體 | USB-to-Serial 轉接器(Serial 連線)、支援 SNMP 的 PDU(電源控制) |
|
||||
|
||||
確認執行階段:
|
||||
|
||||
```powershell
|
||||
dotnet --list-runtimes | findstr WindowsDesktop
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📥 安裝
|
||||
|
||||
目前以原始碼建置為主(尚未提供 binary release)。
|
||||
|
||||
```powershell
|
||||
git clone <repo-url> ETTerms
|
||||
cd ETTerms
|
||||
dotnet build
|
||||
dotnet run --project src\ETTerms\ETTerms.csproj
|
||||
```
|
||||
|
||||
完整建置步驟見 [從原始碼建置](#-從原始碼建置)。
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速開始
|
||||
|
||||
### 建立一條 SSH 連線
|
||||
|
||||
1. 啟動 ETTerms,左側 Activity Rail 切到 **Terminal**
|
||||
2. 側欄按 **+ 新增連線**,選類型 **SSH**
|
||||
3. 填入 `Host` / `Port`(預設 22)/ `Username`,選認證方式(密碼 / 私鑰)
|
||||
4. 雙擊該連線 → 在目前 active pane 開啟分頁
|
||||
|
||||
### 建立一條 Serial 連線
|
||||
|
||||
1. 側欄 **+ 新增連線**,類型選 **Serial**
|
||||
2. 選 `COM port` 與 `BaudRate`(如 `COM3` / `115200`)
|
||||
3. 雙擊開啟
|
||||
|
||||
### 執行 TTL 腳本
|
||||
|
||||
1. 連上任一分頁後,在腳本列按 **▶ Script** 選 `.ttl`
|
||||
2. 左側狀態列即時顯示執行行號與指令
|
||||
3. 需要時按 **■ Stop** 中止
|
||||
|
||||
---
|
||||
|
||||
## 📚 使用指南
|
||||
|
||||
### Activity Rail(左側圖示列)
|
||||
|
||||
切換三大主檢視:**Terminal**(連線工作區)、**Scripts**(腳本編輯與執行)、**Settings**(偏好設定)。
|
||||
|
||||
### 連線側欄(Saved Connections)
|
||||
|
||||
| 操作 | 方式 |
|
||||
|------|------|
|
||||
| 新增資料夾 / 連線 | 工具列按鈕或右鍵選單 |
|
||||
| 重新命名 / 刪除 | 右鍵選單 |
|
||||
| 分類 | 拖曳連線或資料夾到目標資料夾(禁止拖入自身子孫) |
|
||||
| 搜尋 | 上方搜尋框,依名稱 / 主機即時過濾 |
|
||||
| 開啟連線 | 雙擊 → 開進 active pane |
|
||||
|
||||
### 工作區平鋪(Tiling)
|
||||
|
||||
- 工具列一鍵套用 grid 佈局:`1×1 / 1×2 / 2×1 / 2×2 / 2×3 / 3×3`
|
||||
- 每個 pane 右上角:`↔` 左右分割、`↕` 上下分割、`+` 新分頁、`✕` 關閉
|
||||
- 關閉某格時兄弟節點自動補位,至少保留一格
|
||||
- active pane 以強調色外框標示
|
||||
|
||||
### 終端機
|
||||
|
||||
- 接收 channel bytes → ANSI 解析 → 字格緩衝 → 繪製
|
||||
- 鍵盤輸入轉 byte 序列送往遠端
|
||||
- 支援 scrollback、選取 / 複製 / 貼上;字型與配色見 **Settings**
|
||||
|
||||
---
|
||||
|
||||
## 🤖 TTL 腳本
|
||||
|
||||
在任一連線分頁頂部腳本列按 **▶ Script** 載入 `.ttl` 對該連線執行。完整語法與範例見
|
||||
[docs/ttl-script-reference.md](docs/ttl-script-reference.md),範例腳本在 `tools/scripts/`。
|
||||
|
||||
### 支援的指令
|
||||
|
||||
| 指令 | 說明 |
|
||||
|------|------|
|
||||
| `send '文字'` | 送出文字(不加換行) |
|
||||
| `sendln '文字'` | 送出文字並附加 `\r\n` |
|
||||
| `wait '字串'` | 等到接收緩衝出現該字串才往下(預設無限等待,可按 Stop 取消);命中後 `result=1` |
|
||||
| `flushrecv` | 清空接收緩衝區 |
|
||||
| `pause 秒數` | 暫停 N 秒(可被 Stop 中止) |
|
||||
| `timeout = 秒數` | 設定 `wait` 逾時秒數;`0`=無限等待,`N>0` 超時中止並報錯 |
|
||||
| `if … then` / `elseif … then` / `else` / `endif` | 條件分支(可巢狀) |
|
||||
| `while …` / `endwhile` | 迴圈(可巢狀,可被 Stop 中止) |
|
||||
| `名稱 = 值` | 變數指派;支援 `+ - * /` 整數運算與字串;內建變數 `result` |
|
||||
| `logopen '檔名'` | 開啟 log 檔(覆寫) |
|
||||
| `logwrite '文字'` | 寫一行到 log |
|
||||
| `logclose` | 關閉 log(腳本結束自動關閉) |
|
||||
| `messagebox '訊息'` | 跳出對話框(最上層顯示) |
|
||||
| `; 註解` | 行內註解(`;` 之後到行尾) |
|
||||
| `:label` | 標籤行(會被略過) |
|
||||
|
||||
**條件運算子**(`if` / `elseif` / `while`):`>=` `<=` `>` `<` `==` `!=` `=`,或無運算子(非零為真)。
|
||||
|
||||
### 範例:自動登入
|
||||
|
||||
```ttl
|
||||
; tools/scripts/login.ttl
|
||||
timeout = 10
|
||||
wait 'login: '
|
||||
sendln 'root'
|
||||
wait 'Password: '
|
||||
sendln 'toor'
|
||||
wait '# '
|
||||
sendln 'uname -a'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 👥 Group 同步執行
|
||||
|
||||
把多個分頁編成同一 Group,對整組同時跑同一份腳本——適合多台 DUT 同步測試。
|
||||
|
||||
1. **右鍵分頁** → 設為 Group 1 / 2 / 3(或取消);cell footer 顯示 `[Group1-A]` 標籤
|
||||
2. 工具列按 **▶ Group1 / ▶ Group2 / ▶ Group3** 對整組執行
|
||||
|
||||
| 指令 | 說明 |
|
||||
|------|------|
|
||||
| `waitall '字串'` | 全員都 wait 到關鍵字後才一起繼續(`System.Threading.Barrier` 同步) |
|
||||
| `sendlnall '文字'` | 全員到齊後各自 sendln |
|
||||
| `sendlngroup N '文字'` | 僅指定 member 送出 |
|
||||
|
||||
> ⚠️ Group 同步指令**只能在 Run Group 模式**使用;`▶ Script` 與 `▶ Run All` 會拒絕含這些指令的腳本並彈出警告。
|
||||
|
||||
---
|
||||
|
||||
## ⚡ PDU 電源控制
|
||||
|
||||
透過 SNMP 控制 PDU 插座,可在腳本中對 DUT 做電源循環。
|
||||
|
||||
```ttl
|
||||
; 連線 PDU(裝置編號 2 = iPoMan II 1202, IP 192.168.1.21)
|
||||
pduconnect 2 192.168.1.21
|
||||
|
||||
pductrl 2 1 0 ; 關閉 port 1
|
||||
pause 5
|
||||
pductrl 2 1 1 ; 開啟 port 1
|
||||
wait 'login: '
|
||||
```
|
||||
|
||||
| 指令 | 語法 | 說明 |
|
||||
|------|------|------|
|
||||
| `pduconnect` | `pduconnect <device> <ip>` | 連線 PDU |
|
||||
| `pductrl` | `pductrl <device> <port> <0\|1>` | 指定 port 關(0) / 開(1) |
|
||||
|
||||
---
|
||||
|
||||
## 🤖 AI / MCP 整合
|
||||
|
||||
> 🔜 **規劃中(Phase 9)。** 讓 AI agent(**Kiro CLI / Claude CLI**)直接對 serial port 下指令、讀輸出。
|
||||
|
||||
ETTerms 額外提供一支獨立的 **stdio MCP server**(`src/ETTerms.SerialMcp/`),把 serial port 包成 AI 可呼叫的工具。它與 WinForms 主程式**各自獨立行程**:由 MCP client(Kiro CLI / Claude CLI)啟動並維持整個 session 存活,因此能**持續持有 COM port**——連線狀態可跨多次工具呼叫保留,也能接收裝置主動推送的非同步輸出。
|
||||
|
||||
> **為何要獨立常駐行程?** CLI agent 的每條 shell 指令都是新行程,`open→write→read` 無法跨呼叫保留狀態(port 一關就斷)。常駐的 MCP server 才能維持一條連線、累積接收緩衝。
|
||||
|
||||
| 工具 | 參數 | 說明 |
|
||||
|------|------|------|
|
||||
| `serial_list` | — | 列出可用 COM port |
|
||||
| `serial_open` | portName, baudRate, dataBits, parity, stopBits, handshake, newLine | 開啟並持有 port |
|
||||
| `serial_write` | text, appendNewLine? | 送出文字(可選附加換行) |
|
||||
| `serial_read` | waitFor?, timeoutMs? | 取出 RX 緩衝;可等待特定字串或逾時 |
|
||||
| `serial_close` | — | 關閉 port |
|
||||
|
||||
在 Kiro CLI 註冊:
|
||||
|
||||
```powershell
|
||||
kiro-cli mcp add --name serial --command dotnet --args "run --project src\ETTerms.SerialMcp\ETTerms.SerialMcp.csproj"
|
||||
```
|
||||
|
||||
或寫進 `agent.json` 的 `mcpServers`(Claude CLI 用其對應的 `mcpServers` 設定)。註冊後直接對 AI 說:*「列出 COM port、開 COM3 115200、送 `AT` 看回應」*。
|
||||
|
||||
> ⚠️ 一個 COM port 同時只能被一個行程開啟——別在 GUI 與 MCP server 同時開同一個 port。
|
||||
|
||||
---
|
||||
|
||||
## 🔐 連線資料與安全
|
||||
|
||||
- **單機、無雲:** 連線 metadata 存本機 SQLite(`%LocalAppData%\ETTerms\ettermsdb.sqlite`),不回傳任何遙測。
|
||||
- **密碼絕不落地明碼:** SQLite 只存指向 **Windows Credential Manager** 的 `CredentialKey`;實際密碼 / 私鑰 passphrase 由 `CredentialVault` 透過 Credential Manager 存取。
|
||||
- **SSH host key:** 首次連線顯示指紋供確認(trust-on-first-use),之後比對;不符會警告。
|
||||
- **日誌不含密碼:** `AppLogger` 與 `logopen` 輸出只記主機 / port,不寫入任何 credential。
|
||||
- **無 `secret/` 目錄:** 桌面單機 App,無伺服端祕密 / DB 密碼 / compile-time secret。
|
||||
|
||||
---
|
||||
|
||||
## 🔧 疑難排解
|
||||
|
||||
| 問題 | 可能原因 | 解法 |
|
||||
|------|---------|------|
|
||||
| 啟動報缺少 runtime | 未裝 .NET 8 Desktop Runtime | 安裝 `Microsoft.WindowsDesktop.App 8.0.x`,以 `dotnet --list-runtimes` 確認 |
|
||||
| Serial 連線失敗 / port 被佔用 | COM port 被其他程式開啟 | 關閉其他終端機程式;一個 COM port 同時只能被一個 session 開啟 |
|
||||
| SSH 認證失敗 | 帳密 / 私鑰錯誤或 host key 不符 | 檢查認證設定;確認 host key 指紋 |
|
||||
| 腳本卡在 `wait` | 未收到預期字串 | 確認連線與 baud、`wait` 字串正確;設 `timeout` 或按 ■ Stop |
|
||||
| Group 腳本被拒 | 用 `▶ Script` 跑含 Group 指令的腳本 | 改用 **▶ GroupN** 執行 |
|
||||
|
||||
更多操作手冊見 [docs/runbooks/](docs/runbooks/)。
|
||||
|
||||
---
|
||||
|
||||
## 🔨 從原始碼建置
|
||||
|
||||
### 前置需求
|
||||
|
||||
- .NET 8 SDK(或 SDK 9/10 + .NET 8 Desktop Runtime)
|
||||
- Visual Studio 2022 或 VS Code + C# 擴充
|
||||
- Git
|
||||
|
||||
### 建置與執行
|
||||
|
||||
```powershell
|
||||
dotnet --list-sdks
|
||||
dotnet build
|
||||
dotnet run --project src\ETTerms\ETTerms.csproj
|
||||
```
|
||||
|
||||
### 主要 NuGet 套件
|
||||
|
||||
| 套件 | 用途 |
|
||||
|------|------|
|
||||
| `SSH.NET` | SSH Shell + SFTP |
|
||||
| `System.IO.Ports` | Serial 連線 |
|
||||
| `Microsoft.Data.Sqlite` | 連線 metadata 儲存 |
|
||||
| `SnmpSharpNet` | PDU 控制(選用) |
|
||||
|
||||
### 打包(self-contained=false)
|
||||
|
||||
```powershell
|
||||
dotnet publish src\ETTerms\ETTerms.csproj -c Release -r win-x64 --self-contained false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 專案結構
|
||||
|
||||
```
|
||||
ETTerms/
|
||||
├── README.md # 英文說明(預設)
|
||||
├── README.zh-TW.md # 本文件(繁體中文)
|
||||
├── ARCHITECTURE.md # 完整架構文件
|
||||
├── CLAUDE.md # 專案記憶與開發指令
|
||||
├── ETTerms.slnx
|
||||
├── docs/
|
||||
│ ├── ttl-script-reference.md # TTL 指令對照
|
||||
│ └── runbooks/ # 操作手冊 / 疑難排解
|
||||
├── tools/scripts/ # 範例 .ttl 腳本
|
||||
├── src/ETTerms/ # 主應用程式(WinForms)
|
||||
│ ├── App/ # 視窗外殼:MainForm / ActivityRail / ConnectionSidebar / Workspace
|
||||
│ ├── Terminal/ # 自繪 VT100:TerminalView / AnsiParser / ScreenBuffer / TerminalInput
|
||||
│ ├── Sessions/ # 連線抽象:ISessionChannel / SshChannel / SerialChannel / ShellChannel
|
||||
│ ├── Connections/ # 連線資料:Connection / ConnectionStore(SQLite) / CredentialVault
|
||||
│ ├── Scripting/ # TTL 引擎:TTLInterpreter / ScriptRunner / GroupSyncContext / Pdu
|
||||
│ └── Infrastructure/ # AppLogger / AppSettings / NativeTheme
|
||||
└── src/ETTerms.SerialMcp/ # 🔜 Serial MCP server(stdio)—— 讓 AI agent 直接操作 serial port
|
||||
```
|
||||
|
||||
**核心設計:** 所有連線都實作 `ISessionChannel`(`Write(byte[])` + `event DataReceived`)。`TerminalView` 與 `TTLInterpreter` 只認得這個抽象,因此 SSH / Serial / Shell 對上層完全一致——這是「同一套腳本引擎驅動多種連線」的關鍵。詳見 [ARCHITECTURE.md](ARCHITECTURE.md)。
|
||||
|
||||
---
|
||||
|
||||
## 🤝 貢獻
|
||||
|
||||
1. Fork 並建立 feature 分支:`git checkout -b feature/your-feature`
|
||||
2. 遵循 [Conventional Commits](https://www.conventionalcommits.org/):`feat(serial): add auto-reconnect`
|
||||
3. Push 後開 Pull Request
|
||||
|
||||
**慣例:** PascalCase 類別 / 方法、`_camelCase` 私有欄位、檔名=類別名;UI 層只相依 `ISessionChannel` 抽象;channel I/O 在背景,UI 更新一律 `Control.Invoke` 回 UI thread。
|
||||
|
||||
---
|
||||
|
||||
## 📜 版本紀錄
|
||||
|
||||
### v0.1.0(開發中)
|
||||
|
||||
- Phase 1–8 完成:視窗外殼、連線側欄、平鋪工作區、Serial / SSH / 本機 Shell (ConPTY) / SFTP
|
||||
- 自繪 VT100 終端機渲染
|
||||
- TTL 腳本引擎(移植自 MyTeraTerm)+ Group 同步執行
|
||||
- PDU 電源控制(SNMP)
|
||||
- Settings / About
|
||||
- 打包待規劃
|
||||
|
||||
**規劃中**
|
||||
|
||||
- Phase 9:Serial MCP server(`ETTerms.SerialMcp`)—— 讓 AI agent(Kiro CLI / Claude CLI)直接操作 serial port
|
||||
|
||||
---
|
||||
|
||||
## 📄 授權
|
||||
|
||||
本專案採 **MIT License**,詳見 [LICENSE](LICENSE)。
|
||||
|
||||
---
|
||||
|
||||
## 🙏 致謝
|
||||
|
||||
- **[KKTerm](https://github.com/)** — UI 設計參考(Activity Rail + 分頁工作區 + Saved Connections)
|
||||
- **MyTeraTerm** — TTL 腳本引擎與 `AppLogger` 來源
|
||||
- **[SSH.NET](https://github.com/sshnet/SSH.NET)**、**[SnmpSharpNet](http://www.snmpsharpnet.com/)** — 開源連線 / SNMP 函式庫
|
||||
- **Microsoft** — .NET 8、WinForms、ConPTY、Credential Manager
|
||||
@@ -33,6 +33,7 @@
|
||||
| `logwrite` | `logwrite '文字'` | 寫一行到 log 檔。 |
|
||||
| `logclose` | `logclose` | 關閉 log 檔(腳本結束時自動關閉)。 |
|
||||
| `messagebox` | `messagebox '訊息'` | 跳出訊息對話框。 |
|
||||
| `sprintf2` | `sprintf2 變數 格式 [引數 ...]` | C `printf` 風格格式化,結果存入字串變數。詳見下方說明。 |
|
||||
| `if` / `elseif` / `else` / `endif` | 見下 | 條件分支(可巢狀)。 |
|
||||
| `while` / `endwhile` | 見下 | 迴圈(可巢狀,可被 Stop 中止)。 |
|
||||
| `名稱 = 值` | `idx = 0` | 變數指派,支援 `+ - * /` 整數運算與字串。 |
|
||||
@@ -98,6 +99,37 @@ sendlnall 'y'
|
||||
|
||||
---
|
||||
|
||||
## sprintf2 格式化
|
||||
|
||||
`sprintf2 變數 格式字串 [引數 ...]` 以 C 語言 `printf` 規則格式化,結果存入指定字串變數,與
|
||||
Tera Term 的 `sprintf2` 行為一致。
|
||||
|
||||
```ttl
|
||||
sprintf2 ver 'Tera Term 4.%d' 51 ; ver = "Tera Term 4.51"
|
||||
sprintf2 win 'Windows %d (+%s)' 2000 'SP4' ; win = "Windows 2000 (+SP4)"
|
||||
sprintf2 test '%s=%d %s=0x%x' 'dec' 10 'hex' 33 ; test = "dec=10 hex=0x21"
|
||||
messagebox test
|
||||
```
|
||||
|
||||
- **轉換型別**:`c d i o u x X e E f g G a A s`
|
||||
- **旗標**:`-`(靠左)、`+`(顯示正負號)、`0`(補零)、`#`(替代格式,如 `0x`)、空白(正數前空格)
|
||||
- **寬度/精度**:十進位整數,或用 `*` 從引數動態取得(如 `%*d`、`%.*f`)
|
||||
- 浮點數須以**字串**傳入(TTL 無浮點型別),例 `sprintf2 s '%.2f' '3.14159'`
|
||||
- **格式字串不展開變數**(保持字面值),僅各引數會展開變數,故可累加自身:
|
||||
`sprintf2 s '%s,' s` 會把 `s` 接上 `,`
|
||||
|
||||
執行後設定系統變數 `result`:
|
||||
|
||||
| 值 | 狀態 |
|
||||
|----|------|
|
||||
| 0 | 格式化成功 |
|
||||
| 1 | 缺少格式字串 |
|
||||
| 2 | 格式無效 |
|
||||
| 3 | 引數無效(缺少或無法解析) |
|
||||
| 4 | 目的變數名稱無效 |
|
||||
|
||||
---
|
||||
|
||||
## 範例:SSH 自動登入 + 收 log
|
||||
|
||||
```ttl
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<AssemblyName>ETTerms</AssemblyName>
|
||||
|
||||
<!-- 版本資訊 -->
|
||||
<Version>0.1.0</Version>
|
||||
<Version>0.1.2</Version>
|
||||
<Product>ETTerms</Product>
|
||||
<Company>ETTerms Project</Company>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Net;
|
||||
using ETTerms.Infrastructure;
|
||||
using SnmpSharpNet;
|
||||
|
||||
namespace ETTerms.Scripting.Pdu;
|
||||
@@ -19,6 +20,7 @@ public sealed class PduController : IDisposable
|
||||
public bool CheckConnection()
|
||||
{
|
||||
var name = SnmpGet(".1.3.6.1.4.1.2468.1.4.2.1.1.4");
|
||||
AppLogger.Info($"[PDU] CheckConnection {_ip}: name='{name ?? "<null>"}'");
|
||||
return !string.IsNullOrEmpty(name) && name.Contains("PDU");
|
||||
}
|
||||
|
||||
@@ -44,7 +46,7 @@ public sealed class PduController : IDisposable
|
||||
try
|
||||
{
|
||||
var param = new AgentParameters(new OctetString(Community)) { Version = SnmpVersion.Ver1 };
|
||||
var target = new UdpTarget((IPAddress)new IpAddress(_ip), SnmpPort, Timeout, 1);
|
||||
using var target = new UdpTarget((IPAddress)new IpAddress(_ip), SnmpPort, Timeout, 1);
|
||||
var pdu = new SnmpSharpNet.Pdu(PduType.Set);
|
||||
pdu.VbList.Add(new Oid(oid), value);
|
||||
var result = (SnmpV1Packet)target.Request(pdu, param);
|
||||
@@ -58,14 +60,15 @@ public sealed class PduController : IDisposable
|
||||
try
|
||||
{
|
||||
var param = new AgentParameters(new OctetString(Community)) { Version = SnmpVersion.Ver1 };
|
||||
var target = new UdpTarget((IPAddress)new IpAddress(_ip), SnmpPort, Timeout, 1);
|
||||
using var target = new UdpTarget((IPAddress)new IpAddress(_ip), SnmpPort, Timeout, 1);
|
||||
var pdu = new SnmpSharpNet.Pdu(PduType.Get);
|
||||
pdu.VbList.Add(new Oid(oid));
|
||||
var result = (SnmpV1Packet)target.Request(pdu, param);
|
||||
if (result?.Pdu.ErrorStatus == 0)
|
||||
foreach (var v in result.Pdu.VbList) return v.Value.ToString();
|
||||
if (result == null) { AppLogger.LogWarning($"[PDU] SNMP GET {oid}: no response (timeout)"); return null; }
|
||||
if (result.Pdu.ErrorStatus != 0) { AppLogger.LogWarning($"[PDU] SNMP GET {oid}: ErrorStatus={result.Pdu.ErrorStatus}"); return null; }
|
||||
foreach (var v in result.Pdu.VbList) return v.Value.ToString();
|
||||
}
|
||||
catch { }
|
||||
catch (Exception ex) { AppLogger.LogError($"[PDU] SNMP GET {oid} exception", ex); }
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using ETTerms.Scripting.Pdu;
|
||||
@@ -105,7 +106,7 @@ public sealed class TTLInterpreter : IDisposable
|
||||
&& !original.StartsWith("if ") && !original.StartsWith("elseif ");
|
||||
|
||||
string command = GetCommand(line).ToLower();
|
||||
if (!isAssignment && command != "while" && command != "if")
|
||||
if (!isAssignment && command != "while" && command != "if" && command != "sprintf2")
|
||||
line = ReplaceVariables(line);
|
||||
|
||||
if (line.StartsWith(":")) return; // label
|
||||
@@ -124,6 +125,7 @@ public sealed class TTLInterpreter : IDisposable
|
||||
case "logwrite": LogWrite(args); break;
|
||||
case "logclose": LogClose(); break;
|
||||
case "messagebox": ShowMessageBox(args); break;
|
||||
case "sprintf2": ExecuteSprintf2(args); break;
|
||||
case "waitall": ExecuteWaitAll(args); break;
|
||||
case "sendlnall": ExecuteSendlnAll(args); break;
|
||||
case "sendlngroup": ExecuteSendlnGroup(args); break;
|
||||
@@ -520,6 +522,310 @@ public sealed class TTLInterpreter : IDisposable
|
||||
|
||||
#endregion
|
||||
|
||||
#region sprintf2
|
||||
|
||||
/// <summary>
|
||||
/// <c>sprintf2 strvar FORMAT [ARG ...]</c> — C printf 風格格式化,結果存入字串變數 <c>strvar</c>。
|
||||
/// 與 Tera Term sprintf2 相同:支援 c d i o u x X e E f g G a A s 與旗標 - + 0 # 空白、寬度/精度(含 <c>*</c>)。
|
||||
/// 設定系統變數 result:0 成功、1 缺少格式字串、2 格式無效、3 引數無效、4 目的變數無效。
|
||||
/// 格式字串保持字面值(不展開變數),僅對各引數做變數展開,故 <c>sprintf2 s '%s.' s</c> 可累加自身。
|
||||
/// </summary>
|
||||
private void ExecuteSprintf2(string args)
|
||||
{
|
||||
var tokens = TokenizeArgs(args);
|
||||
if (tokens.Count < 1) { _result = 4; Output?.Invoke("[sprintf2] invalid destination variable"); return; }
|
||||
|
||||
string dest = tokens[0];
|
||||
if (!Regex.IsMatch(dest, @"^[a-zA-Z_][a-zA-Z0-9_]*$"))
|
||||
{ _result = 4; Output?.Invoke("[sprintf2] invalid destination variable"); return; }
|
||||
|
||||
if (tokens.Count < 2) { _result = 1; Output?.Invoke("[sprintf2] no format string"); return; }
|
||||
|
||||
string format = StripQuotes(tokens[1]);
|
||||
var fmtArgs = new List<string>();
|
||||
for (int k = 2; k < tokens.Count; k++) fmtArgs.Add(ReplaceVariables(StripQuotes(tokens[k])));
|
||||
|
||||
try
|
||||
{
|
||||
_vars[dest] = CFormat(format, fmtArgs);
|
||||
_result = 0;
|
||||
}
|
||||
catch (FormatException) { _result = 2; Output?.Invoke("[sprintf2] invalid format"); }
|
||||
catch (ArgumentException) { _result = 3; Output?.Invoke("[sprintf2] invalid argument"); }
|
||||
}
|
||||
|
||||
private static string CFormat(string format, IReadOnlyList<string> args)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
int ai = 0, i = 0;
|
||||
while (i < format.Length)
|
||||
{
|
||||
char ch = format[i++];
|
||||
if (ch != '%') { sb.Append(ch); continue; }
|
||||
if (i < format.Length && format[i] == '%') { sb.Append('%'); i++; continue; }
|
||||
|
||||
bool left = false, plus = false, space = false, zero = false, alt = false;
|
||||
for (; i < format.Length; i++)
|
||||
{
|
||||
char f = format[i];
|
||||
if (f == '-') left = true;
|
||||
else if (f == '+') plus = true;
|
||||
else if (f == ' ') space = true;
|
||||
else if (f == '0') zero = true;
|
||||
else if (f == '#') alt = true;
|
||||
else break;
|
||||
}
|
||||
|
||||
int width = 0;
|
||||
if (i < format.Length && format[i] == '*')
|
||||
{
|
||||
i++;
|
||||
width = (int)ParseLong(NextArg(args, ref ai));
|
||||
if (width < 0) { left = true; width = -width; }
|
||||
}
|
||||
else while (i < format.Length && char.IsDigit(format[i])) width = width * 10 + (format[i++] - '0');
|
||||
|
||||
int prec = -1;
|
||||
if (i < format.Length && format[i] == '.')
|
||||
{
|
||||
i++;
|
||||
if (i < format.Length && format[i] == '*')
|
||||
{ i++; prec = (int)ParseLong(NextArg(args, ref ai)); if (prec < 0) prec = -1; }
|
||||
else { prec = 0; while (i < format.Length && char.IsDigit(format[i])) prec = prec * 10 + (format[i++] - '0'); }
|
||||
}
|
||||
|
||||
if (i >= format.Length) throw new FormatException();
|
||||
char type = format[i++];
|
||||
if (type == '%') { sb.Append('%'); continue; }
|
||||
|
||||
string body = ConvertSpec(type, plus, space, alt, prec,
|
||||
NextArg(args, ref ai), out string prefix, out bool allowZero);
|
||||
|
||||
int pad = width - prefix.Length - body.Length;
|
||||
if (pad < 0) pad = 0;
|
||||
if (left) { sb.Append(prefix).Append(body).Append(' ', pad); }
|
||||
else if (zero && allowZero) { sb.Append(prefix).Append('0', pad).Append(body); }
|
||||
else { sb.Append(' ', pad).Append(prefix).Append(body); }
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string ConvertSpec(char type, bool plus, bool space, bool alt, int prec,
|
||||
string arg, out string prefix, out bool allowZero)
|
||||
{
|
||||
prefix = ""; allowZero = false;
|
||||
switch (type)
|
||||
{
|
||||
case 'd': case 'i':
|
||||
{
|
||||
long v = ParseLong(arg);
|
||||
ulong mag = v < 0 ? (ulong)(-(v + 1)) + 1UL : (ulong)v;
|
||||
string digits = mag.ToString(CultureInfo.InvariantCulture);
|
||||
allowZero = prec < 0;
|
||||
if (prec >= 0) digits = (prec == 0 && mag == 0) ? "" : digits.PadLeft(prec, '0');
|
||||
prefix = v < 0 ? "-" : plus ? "+" : space ? " " : "";
|
||||
return digits;
|
||||
}
|
||||
case 'u':
|
||||
{
|
||||
ulong v = ParseULong(arg);
|
||||
allowZero = prec < 0;
|
||||
string digits = v.ToString(CultureInfo.InvariantCulture);
|
||||
if (prec >= 0) digits = (prec == 0 && v == 0) ? "" : digits.PadLeft(prec, '0');
|
||||
return digits;
|
||||
}
|
||||
case 'o':
|
||||
{
|
||||
ulong v = ParseULong(arg);
|
||||
string digits = (prec == 0 && v == 0) ? "" : ToBase(v, 8, false);
|
||||
if (prec > 0) digits = digits.PadLeft(prec, '0');
|
||||
if (alt && (digits.Length == 0 || digits[0] != '0')) digits = "0" + digits;
|
||||
allowZero = prec < 0;
|
||||
return digits;
|
||||
}
|
||||
case 'x': case 'X':
|
||||
{
|
||||
ulong v = ParseULong(arg);
|
||||
string digits = (prec == 0 && v == 0) ? "" : ToBase(v, 16, type == 'X');
|
||||
if (prec > 0) digits = digits.PadLeft(prec, '0');
|
||||
if (alt && v != 0) prefix = type == 'X' ? "0X" : "0x";
|
||||
allowZero = prec < 0;
|
||||
return digits;
|
||||
}
|
||||
case 'c':
|
||||
return long.TryParse(arg, NumberStyles.Integer, CultureInfo.InvariantCulture, out long code)
|
||||
? ((char)code).ToString()
|
||||
: (arg.Length > 0 ? arg[0].ToString() : "");
|
||||
case 's':
|
||||
return (prec >= 0 && arg.Length > prec) ? arg.Substring(0, prec) : arg;
|
||||
case 'f': case 'F':
|
||||
{
|
||||
double d = ParseDouble(arg);
|
||||
int p = prec < 0 ? 6 : prec;
|
||||
string digits = Math.Abs(d).ToString("F" + p, CultureInfo.InvariantCulture);
|
||||
if (alt && p == 0) digits += ".";
|
||||
prefix = Sign(d, plus, space); allowZero = true;
|
||||
return digits;
|
||||
}
|
||||
case 'e': case 'E':
|
||||
{
|
||||
double d = ParseDouble(arg);
|
||||
int p = prec < 0 ? 6 : prec;
|
||||
string s = FixExponent(Math.Abs(d).ToString((type == 'e' ? "e" : "E") + p, CultureInfo.InvariantCulture));
|
||||
if (alt && p == 0 && !s.Contains('.'))
|
||||
{
|
||||
int ei = s.IndexOfAny(new[] { 'e', 'E' });
|
||||
s = s.Substring(0, ei) + "." + s.Substring(ei);
|
||||
}
|
||||
prefix = Sign(d, plus, space); allowZero = true;
|
||||
return s;
|
||||
}
|
||||
case 'g': case 'G':
|
||||
{
|
||||
double d = ParseDouble(arg);
|
||||
int p = prec < 0 ? 6 : (prec == 0 ? 1 : prec);
|
||||
double ad = Math.Abs(d);
|
||||
string etmp = ad.ToString("E" + (p - 1), CultureInfo.InvariantCulture);
|
||||
int x = ad == 0 ? 0 : int.Parse(etmp.Substring(etmp.IndexOf('E') + 1), CultureInfo.InvariantCulture);
|
||||
string s;
|
||||
if (x < -4 || x >= p)
|
||||
{
|
||||
s = ad.ToString((type == 'g' ? "e" : "E") + (p - 1), CultureInfo.InvariantCulture);
|
||||
if (!alt) s = TrimGExp(s);
|
||||
s = FixExponent(s);
|
||||
}
|
||||
else
|
||||
{
|
||||
int fp = Math.Max(0, p - 1 - x);
|
||||
s = ad.ToString("F" + fp, CultureInfo.InvariantCulture);
|
||||
if (!alt) s = TrimTrailingZeros(s);
|
||||
}
|
||||
prefix = Sign(d, plus, space); allowZero = true;
|
||||
return s;
|
||||
}
|
||||
case 'a': case 'A':
|
||||
{
|
||||
double d = ParseDouble(arg);
|
||||
prefix = Sign(d, plus, space); allowZero = true;
|
||||
return HexFloat(Math.Abs(d), type == 'A', prec);
|
||||
}
|
||||
default:
|
||||
throw new FormatException();
|
||||
}
|
||||
}
|
||||
|
||||
private static string Sign(double d, bool plus, bool space) =>
|
||||
double.IsNegative(d) ? "-" : plus ? "+" : space ? " " : "";
|
||||
|
||||
private static string ToBase(ulong v, int b, bool upper)
|
||||
{
|
||||
if (v == 0) return "0";
|
||||
const string digs = "0123456789abcdef";
|
||||
var sb = new StringBuilder();
|
||||
ulong ub = (ulong)b;
|
||||
while (v > 0) { sb.Insert(0, digs[(int)(v % ub)]); v /= ub; }
|
||||
return upper ? sb.ToString().ToUpperInvariant() : sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>.NET 指數為 3 位數,C 最少 2 位數 — 修剪多餘前導 0 至最少 2 位。</summary>
|
||||
private static string FixExponent(string s)
|
||||
{
|
||||
int ei = s.IndexOfAny(new[] { 'e', 'E' });
|
||||
if (ei < 0 || ei + 2 >= s.Length) return s;
|
||||
string exp = s.Substring(ei + 2).TrimStart('0');
|
||||
if (exp.Length < 2) exp = exp.PadLeft(2, '0');
|
||||
return s.Substring(0, ei + 2) + exp;
|
||||
}
|
||||
|
||||
private static string TrimTrailingZeros(string s)
|
||||
{
|
||||
if (!s.Contains('.')) return s;
|
||||
s = s.TrimEnd('0');
|
||||
return s.EndsWith(".") ? s.Substring(0, s.Length - 1) : s;
|
||||
}
|
||||
|
||||
private static string TrimGExp(string s)
|
||||
{
|
||||
int ei = s.IndexOfAny(new[] { 'e', 'E' });
|
||||
return TrimTrailingZeros(s.Substring(0, ei)) + s.Substring(ei);
|
||||
}
|
||||
|
||||
private static string HexFloat(double d, bool upper, int prec)
|
||||
{
|
||||
long bits = BitConverter.DoubleToInt64Bits(d);
|
||||
int rawExp = (int)((bits >> 52) & 0x7FF);
|
||||
long mant = bits & 0xFFFFFFFFFFFFFL;
|
||||
string lead; int e2;
|
||||
if (rawExp == 0) { lead = "0"; e2 = mant == 0 ? 0 : -1022; }
|
||||
else { lead = "1"; e2 = rawExp - 1023; }
|
||||
string frac = mant.ToString("x13", CultureInfo.InvariantCulture);
|
||||
if (prec >= 0) frac = prec < frac.Length ? frac.Substring(0, prec) : frac.PadRight(prec, '0');
|
||||
else frac = frac.TrimEnd('0');
|
||||
string body = "0x" + lead + (frac.Length > 0 ? "." + frac : "") + "p" + (e2 >= 0 ? "+" : "-") + Math.Abs(e2);
|
||||
return upper ? body.ToUpperInvariant() : body;
|
||||
}
|
||||
|
||||
private static string TokenizeArgsNext(string s, ref int i, int n)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
if (s[i] == '\'' || s[i] == '"')
|
||||
{
|
||||
char q = s[i++];
|
||||
sb.Append(q);
|
||||
while (i < n && s[i] != q) sb.Append(s[i++]);
|
||||
if (i < n) sb.Append(s[i++]);
|
||||
}
|
||||
else while (i < n && !char.IsWhiteSpace(s[i])) sb.Append(s[i++]);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static List<string> TokenizeArgs(string s)
|
||||
{
|
||||
var list = new List<string>();
|
||||
int i = 0, n = s.Length;
|
||||
while (i < n)
|
||||
{
|
||||
while (i < n && char.IsWhiteSpace(s[i])) i++;
|
||||
if (i >= n) break;
|
||||
list.Add(TokenizeArgsNext(s, ref i, n));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private static string StripQuotes(string s) =>
|
||||
s.Length >= 2 && (s[0] == '\'' || s[0] == '"') && s[^1] == s[0]
|
||||
? s.Substring(1, s.Length - 2) : s;
|
||||
|
||||
private static string NextArg(IReadOnlyList<string> args, ref int ai)
|
||||
{
|
||||
if (ai >= args.Count) throw new ArgumentException("not enough arguments");
|
||||
return args[ai++];
|
||||
}
|
||||
|
||||
private static long ParseLong(string s)
|
||||
{
|
||||
s = s.Trim();
|
||||
if (long.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out long v)) return v;
|
||||
if ((s.StartsWith("0x") || s.StartsWith("0X")) &&
|
||||
long.TryParse(s.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out long h)) return h;
|
||||
if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out double d)) return (long)d;
|
||||
throw new ArgumentException("invalid integer argument");
|
||||
}
|
||||
|
||||
private static ulong ParseULong(string s)
|
||||
{
|
||||
long v = ParseLong(s);
|
||||
return unchecked((ulong)v);
|
||||
}
|
||||
|
||||
private static double ParseDouble(string s)
|
||||
{
|
||||
if (double.TryParse(s.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out double d)) return d;
|
||||
throw new ArgumentException("invalid floating-point argument");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static string GetCommand(string line)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
|
||||
prompt_productOS = "6000#"
|
||||
|
||||
ip = "192.168.1.21" ; PDU IP
|
||||
device = 1 ; PDU 裝置編號(iPoMan II/III 以 1 為預設)
|
||||
port_1 = 1 ; 要測試的插座埠
|
||||
port_2 = 2 ; 要測試的插座埠
|
||||
act_off = 0
|
||||
act_on = 1
|
||||
|
||||
; ── 連線 ──
|
||||
|
||||
|
||||
idx = 1
|
||||
while idx
|
||||
pduconnect device ip
|
||||
if result == 0 then
|
||||
messagebox 'PDU connect failed — 檢查 IP / 網路 / community'
|
||||
endif
|
||||
; ── 單埠 OFF → ON 電源循環 ──
|
||||
pause 3
|
||||
pductrl device port_1 act_off
|
||||
pductrl device port_2 act_off
|
||||
pause 3
|
||||
pductrl device port_1 act_on
|
||||
pause 1
|
||||
pductrl device port_2 act_on
|
||||
pause 3
|
||||
|
||||
wait "6000 login:"
|
||||
sendln "admin"
|
||||
wait "Password:"
|
||||
sendln ""
|
||||
|
||||
waitall prompt
|
||||
sendlngroup A "show version"
|
||||
sendlngroup B "show int b"
|
||||
waitall prompt
|
||||
sendlngroup A "show version"
|
||||
sendlngroup B "show int b"
|
||||
waitall prompt
|
||||
sendlngroup A "show int b"
|
||||
sendlngroup B "show version"
|
||||
endwhile
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
; test-pdu.ttl — PDU 控制指令測試(iPoMan II/III via SNMP)
|
||||
; 不需 active session 的回應;pduconnect / pductrl 走 SNMP 直連 PDU。
|
||||
; 執行前請改下方 ip / device / port 為實際值。執行中可按 Stop 中止。
|
||||
|
||||
ip = "192.168.1.21" ; PDU IP
|
||||
device = 1 ; PDU 裝置編號(iPoMan II/III 以 1 為預設)
|
||||
port = 1 ; 要測試的插座埠
|
||||
act_off = 0
|
||||
act_on = 1
|
||||
|
||||
|
||||
; ── 連線 ──
|
||||
pduconnect device ip
|
||||
if result == 0 then
|
||||
messagebox 'PDU connect failed — 檢查 IP / 網路 / community'
|
||||
endif
|
||||
|
||||
; ── 單埠 OFF → ON 電源循環 ──
|
||||
pductrl device port act_off
|
||||
pause 3
|
||||
pductrl device port act_on
|
||||
pause 3
|
||||
@@ -0,0 +1,47 @@
|
||||
|
||||
prompt_SVOS = "SVOS> "
|
||||
|
||||
ip = "192.168.1.21" ; PDU IP
|
||||
device = 1 ; PDU 裝置編號(iPoMan II/III 以 1 為預設)
|
||||
port = 1 ; 要測試的插座埠
|
||||
act_off = 0
|
||||
act_on = 1
|
||||
|
||||
; ── 連線 ──
|
||||
pduconnect device ip
|
||||
if result == 0 then
|
||||
messagebox 'PDU connect failed — 檢查 IP / 網路 / community'
|
||||
endif
|
||||
|
||||
cnt_total = 0
|
||||
while 1
|
||||
; ── 單埠 OFF → ON 電源循環 ──
|
||||
flushrecv ; 清掉上一輪殘留輸出,等待 boot 訊息更乾淨
|
||||
pause 3
|
||||
pductrl device port act_off
|
||||
pause 5
|
||||
pductrl device port act_on
|
||||
pause 3
|
||||
|
||||
wait "Select profile(primary):"
|
||||
sendln "0"
|
||||
wait "ServiceOS login:"
|
||||
sendln "admin"
|
||||
|
||||
wait prompt_SVOS ; 登入後第一個就緒提示(此處無指令輸出,安全)
|
||||
sendln "version"
|
||||
wait "SHA:" ; 先等 version 輸出跑完(最後一行),避免比對到回顯的 "SVOS> version"
|
||||
wait prompt_SVOS
|
||||
sendln "help"
|
||||
wait "more info" ; 先等 help 輸出跑完,再等就緒提示
|
||||
wait prompt_SVOS
|
||||
sendln "help"
|
||||
wait "more info"
|
||||
wait prompt_SVOS
|
||||
|
||||
cnt_total = cnt_total + 1
|
||||
sprintf2 total_print "====== Total Power Cycle :%d ======" cnt_total
|
||||
sendln total_print
|
||||
wait prompt_SVOS
|
||||
endwhile
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
|
||||
prompt_ProductOS = "6000# "
|
||||
|
||||
ip = "192.168.1.21" ; PDU IP
|
||||
device = 1 ; PDU 裝置編號(iPoMan II/III 以 1 為預設)
|
||||
port = 1 ; 要測試的插座埠
|
||||
act_off = 0
|
||||
act_on = 1
|
||||
|
||||
; ── 連線 ──
|
||||
pduconnect device ip
|
||||
if result == 0 then
|
||||
messagebox 'PDU connect failed — 檢查 IP / 網路 / community'
|
||||
endif
|
||||
|
||||
cnt_total = 0
|
||||
while 1
|
||||
; ── 單埠 OFF → ON 電源循環 ──
|
||||
flushrecv ; 清掉上一輪殘留輸出,等待 boot 訊息更乾淨
|
||||
pause 3
|
||||
pductrl device port act_off
|
||||
pause 5
|
||||
pductrl device port act_on
|
||||
pause 3
|
||||
|
||||
wait "6000 login:"
|
||||
sendln "admin"
|
||||
wait "Password: "
|
||||
sendln ""
|
||||
|
||||
wait prompt_ProductOS
|
||||
sendln "show version"
|
||||
wait prompt_ProductOS
|
||||
sendln "show int b"
|
||||
wait prompt_ProductOS
|
||||
sendln "show int b"
|
||||
wait prompt_ProductOS
|
||||
|
||||
cnt_total = cnt_total + 1
|
||||
sprintf2 total_print "====== Total Power Cycle :%d ======" cnt_total
|
||||
sendln total_print
|
||||
wait prompt_ProductOS
|
||||
endwhile
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
; sprintf2 範例 / 自我測試
|
||||
; 在任一已連線 session 執行;結果以 messagebox 顯示
|
||||
|
||||
sprintf2 ver 'Tera Term 4.%d' 51
|
||||
messagebox ver
|
||||
|
||||
sprintf2 win 'Windows %d (+%s)' 2000 'SP4'
|
||||
messagebox win
|
||||
|
||||
sprintf2 test '%s=%d %s=0x%x' 'dec' 10 'hex' 33
|
||||
messagebox test
|
||||
|
||||
; 寬度 / 補零 / 精度
|
||||
sprintf2 pad '[%05d] [%-8s] [%+.2f]' -42 'hi' '3.14159'
|
||||
messagebox pad
|
||||
|
||||
; 累加自身(格式字串保持字面值,引數才展開變數)
|
||||
sprintf2 acc '%s' 'start'
|
||||
sprintf2 acc '%s-A' acc
|
||||
sprintf2 acc '%s-B' acc
|
||||
messagebox acc
|
||||
|
||||
; result 狀態:0=成功
|
||||
sprintf2 ok '%d' 1
|
||||
messagebox result
|
||||
Reference in New Issue
Block a user