feat: Phase 9 Serial MCP server + app icon & version title (v0.2.0)
Phase 9 (AI integration) complete -- AI agents can send/receive serial while the user watches live in the GUI, with the GUI as sole COM-port owner. - ETTerms.SerialMcp: stdio MCP server (net8.0 + ModelContextProtocol SDK) exposing serial_list / serial_attach / serial_write / serial_read (waitFor + timeoutMs) / serial_detach; forwards over named pipe. - GUI SerialBridgeServer (pipe etterms-serial) + SerialBridge endpoint; SessionPage.WriteFromAi echoes MCP-sourced TX tagged [AI] (magenta). - App icon: window title bar / taskbar / exe now use Choco_256x256.ico (embedded via AppAssets); title bar shows ETTerms Version vX.Y.Z. - About page: large Choco icon (Zoom, no crop). - Docs: ARCHITECTURE.md + CLAUDE.md mark Phase 9 done; docs/serial-mcp-guide.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+12
-11
@@ -142,7 +142,8 @@ ETTerms/
|
||||
│ ├── ShellChannel.cs # Windows ConPTY 本機 Shell
|
||||
│ ├── SessionPage.cs # 一個分頁 = TerminalView + Channel + 狀態
|
||||
│ ├── SessionManager.cs # 開 / 關 / 列舉所有 active session
|
||||
│ └── SerialBridgeServer.cs# 🔜 本機 named pipe server:把 serial session 的讀寫橋接給 MCP(Phase 9)
|
||||
│ ├── SerialBridgeServer.cs# ✅ 本機 named pipe server:把 serial session 的讀寫橋接給 MCP(Phase 9)
|
||||
│ └── SerialBridge.cs # ✅ SerialBridgeEndpoint:單一 serial session 與 pipe 的橋接點
|
||||
│
|
||||
├── Connections/ # ── 連線資料 ──
|
||||
│ ├── Connection.cs # 連線 metadata 模型
|
||||
@@ -161,7 +162,7 @@ ETTerms/
|
||||
├── AppSettings.cs # 使用者偏好 (JSON, %LocalAppData%\ETTerms\settings.json)
|
||||
└── NativeTheme.cs # 深色標題列 (DWM)
|
||||
│
|
||||
└── ETTerms.SerialMcp/ # 🔜 Serial MCP server(stdio)——不自己開 port,經 named pipe 橋接 GUI
|
||||
└── ETTerms.SerialMcp/ # ✅ Serial MCP server(stdio)——不自己開 port,經 named pipe 橋接 GUI
|
||||
├── Program.cs # stdio MCP host 進入點
|
||||
├── SerialBridgeClient.cs # 連 GUI 的 named pipe,轉發 write / 接收 RX
|
||||
├── SerialTools.cs # serial_list / attach / write / read / detach 工具(轉發到 pipe)
|
||||
@@ -584,16 +585,16 @@ 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(GUI 橋接模式)(工作量:M)🔜 規劃中
|
||||
### Phase 9 — AI 整合:Serial MCP Server(GUI 橋接模式)(工作量:M)✅ 已完成
|
||||
**目標:** 讓 Kiro CLI / Claude CLI 等 AI agent 收發 serial,且**使用者能在 GUI 即時看到 AI 的收發**。COM port 由 GUI 唯一持有,MCP 經本機 named pipe 橋接(見 [AI / MCP Integration](#ai--mcp-integrationserial-mcp-server))。
|
||||
**包含:**
|
||||
- [ ] GUI:`Sessions/SerialBridgeServer.cs`——本機 named pipe server,暴露 list / attach / write / read,並把 serial session 的 RX 廣播給 client
|
||||
- [ ] GUI:TerminalView 支援把 MCP 來源的 TX 以 `[AI]` 標色 echo,使用者可區分 AI 與手打輸入
|
||||
- [ ] `src/ETTerms.SerialMcp/`:.NET 8 console + 官方 C# MCP SDK(`ModelContextProtocol`),stdio / JSON-RPC
|
||||
- [ ] `SerialBridgeClient.cs`:連 GUI pipe、轉發 write、累積 RX
|
||||
- [ ] 工具:`serial_list` / `serial_attach` / `serial_write` / `serial_read`(含 `waitFor` + `timeoutMs`)/ `serial_detach`
|
||||
- [ ] 註冊說明(`kiro-cli mcp add` / agent.json `mcpServers`)寫入 README,含「需先在 GUI 開好 port」前提
|
||||
**驗收條件:** GUI 開一條 Serial(COM3)→ 另一分頁 PowerShell 跑 kiro → AI 經 MCP `serial_attach` COM3 → `serial_write` 送指令、`serial_read` 讀回應,**整個過程在 GUI Tab1 即時可見(AI 的 TX 有 `[AI]` 標色)**;全程只有 GUI 開該 port。
|
||||
- [x] GUI:`Sessions/SerialBridgeServer.cs`——本機 named pipe server(`\\.\pipe\etterms-serial`),暴露 list / attach / write / read,並把 serial session 的 RX 廣播給 client;`SerialBridge.cs` 為單一 session 的橋接端點
|
||||
- [x] GUI:`SessionPage.WriteFromAi` 把 MCP 來源的 TX 以 `[AI]` 標色(`\x1b[35m`)echo,使用者可區分 AI 與手打輸入
|
||||
- [x] `src/ETTerms.SerialMcp/`:.NET 8 console + 官方 C# MCP SDK(`ModelContextProtocol`),stdio / JSON-RPC
|
||||
- [x] `SerialBridgeClient.cs`:連 GUI pipe、轉發 write、背景累積 RX
|
||||
- [x] 工具:`serial_list` / `serial_attach` / `serial_write` / `serial_read`(含 `waitFor` + `timeoutMs`)/ `serial_detach`
|
||||
- [x] 註冊說明(`kiro-cli mcp add` / agent.json `mcpServers`)寫入 [docs/serial-mcp-guide.md](docs/serial-mcp-guide.md),含「需先在 GUI 開好 port」前提
|
||||
**驗收條件:** ✅ GUI 開一條 Serial(COM3)→ 另一分頁 PowerShell 跑 kiro → AI 經 MCP `serial_attach` COM3 → `serial_write` 送指令、`serial_read` 讀回應,**整個過程在 GUI Tab1 即時可見(AI 的 TX 有 `[AI]` 標色)**;全程只有 GUI 開該 port。
|
||||
|
||||
---
|
||||
|
||||
@@ -601,7 +602,7 @@ dotnet publish src\ETTerms\ETTerms.csproj -c Release -r win-x64 --self-contained
|
||||
|
||||
這個版本**不做、但未來可能加**:
|
||||
|
||||
- **AI / MCP 整合**(🔜 已列為 [Phase 9](#development-phases):Serial MCP Server,讓 AI agent 直接操作 serial;未來可再擴充 SSH / Shell MCP 工具)
|
||||
- ~~**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,9 @@ 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 9 🔜(Serial MCP server:**GUI 持有 COM port,MCP 經本機 named pipe 橋接**,AI 收發的資料即時顯示在 GUI)。打包待指示。
|
||||
**進度:** Phase 1–5 ✅、Phase 6 ✅(TTL 引擎 + Group 同步,SSH 待驗收)、Phase 7 ✅(Settings/About)、Phase 8 ✅(PDU + Shell/ConPTY + SFTP + Settings 擴充)、Phase 9 ✅(Serial MCP server:**GUI 持有 COM port,MCP 經本機 named pipe 橋接**,AI 收發的資料即時以 `[AI]` 標色顯示在 GUI)。打包待指示。
|
||||
|
||||
**v0.2.0:** Phase 9 完成 — `ETTerms.SerialMcp`(stdio MCP server)+ GUI `SerialBridgeServer`(named pipe `\\.\pipe\etterms-serial`)上線,提供 `serial_list` / `serial_attach` / `serial_write` / `serial_read` / `serial_detach` 五個工具,AI 的 TX 在 GUI 以 `[AI]` 標色即時 echo;視窗 / 工作列 / About 改用 Choco 圖示,標題列顯示版本號。見 [docs/serial-mcp-guide.md](docs/serial-mcp-guide.md)。
|
||||
|
||||
**v0.1.2:** 新增 `sprintf2`(TeraTerm 相容 C printf 格式化);`wait` 改為命中關鍵字後須等裝置安靜(`SettleMs` 預設 300ms)才接受並取「最後一次」出現,排除輸出中途的指令回顯(避免腳本搶跑)。
|
||||
|
||||
@@ -65,6 +67,6 @@ kiro-cli mcp add --name serial --command dotnet --args "run --project src\ETTerm
|
||||
## 資料夾用途
|
||||
|
||||
- **`src/ETTerms/`** — 主應用程式(WinForms 視窗外殼 + 連線 / 終端機 / 腳本引擎)。
|
||||
- **`src/ETTerms.SerialMcp/`** — 🔜 stdio MCP server(給 AI agent 收發 serial)。獨立行程,但**不直接開 COM port**:經本機 named pipe 連到 GUI 的 `SerialBridgeServer`,由 GUI 代為讀寫實體 port;net8.0 console + `ModelContextProtocol` SDK。
|
||||
- **`src/ETTerms.SerialMcp/`** — ✅ stdio MCP server(給 AI agent 收發 serial)。獨立行程,但**不直接開 COM port**:經本機 named pipe 連到 GUI 的 `SerialBridgeServer`,由 GUI 代為讀寫實體 port;net8.0 console + `ModelContextProtocol` SDK。
|
||||
- **`For_AI/`** — AI 協作素材與**參考專案**(`KKTerm-main` UI 參考、`MyTeraTerm` Script 參考)。整個資料夾 gitignored,僅供開發對照。
|
||||
- 本專案**無 `secret/` 資料夾**:沒有伺服端祕密 / DB 密碼 / compile-time secret,連線密碼一律走 Windows Credential Manager。
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/ETTerms/ETTerms.csproj" />
|
||||
<Project Path="src/ETTerms.SerialMcp/ETTerms.SerialMcp.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
@@ -66,9 +66,9 @@
|
||||
- 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
|
||||
- The **GUI owns the COM port**; a standalone stdio **Serial MCP server** bridges to it over a local named pipe for **Kiro CLI / Claude CLI**
|
||||
- Tools: `serial_list` / `serial_attach` / `serial_write` / `serial_read` / `serial_detach`
|
||||
- AI's serial TX/RX shows live in the GUI tagged `[AI]` — open the port in the GUI first, then let the AI attach
|
||||
|
||||
- 📊 **Session RX logging**
|
||||
- `logopen` / `logwrite` / `logclose` write session output to file
|
||||
@@ -271,19 +271,21 @@ wait 'login: '
|
||||
|
||||
## 🤖 AI / MCP Integration
|
||||
|
||||
> 🔜 **Planned (Phase 9).** Let AI agents (**Kiro CLI / Claude CLI**) drive the serial port directly — issue commands and read output.
|
||||
> 🔜 **Planned (Phase 9).** Let AI agents (**Kiro CLI / Claude CLI**) send/receive on a serial port **while you watch it live in the ETTerms GUI**.
|
||||
|
||||
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.
|
||||
**Key design: the GUI owns the COM port; the MCP server never opens it.** A COM port can be opened by only one process at a time, so rather than the MCP server grabbing it, the GUI is the sole owner and runs a local **named pipe server** (`SerialBridgeServer`). The standalone `ETTerms.SerialMcp` (launched by Kiro/Claude CLI) is a thin client: every `write` / `read` is forwarded over the pipe to the GUI, which does the real port I/O. AI's TX is echoed into the terminal tagged `[AI]`, so RX/TX flow through the GUI channel and what you see is exactly what the AI sees.
|
||||
|
||||
> **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.
|
||||
Full closed loop — all inside the ETTerms GUI:
|
||||
- **Tab 1:** a Serial session holding e.g. COM3 (shows traffic live; AI's writes tagged `[AI]`)
|
||||
- **Tab 2:** a PowerShell (ConPTY) tab running `kiro-cli`, which spawns `ETTerms.SerialMcp`, which connects back to the pipe
|
||||
|
||||
| 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 |
|
||||
| `serial_list` | — | List serial sessions currently **open in the GUI** (name + baud) |
|
||||
| `serial_attach` | portName | Bind to a GUI serial session by COM name (required before write/read) |
|
||||
| `serial_write` | text, appendNewline? | Send text via the GUI (shown live tagged `[AI]`) |
|
||||
| `serial_read` | waitFor?, timeoutMs? | Drain accumulated RX; optionally wait for a substring / timeout |
|
||||
| `serial_detach` | — | Unbind (does **not** close the GUI's port) |
|
||||
|
||||
Register in Kiro CLI:
|
||||
|
||||
@@ -291,9 +293,11 @@ Register in Kiro CLI:
|
||||
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."*
|
||||
Or add it to `agent.json` under `mcpServers` (Claude CLI uses an equivalent `mcpServers` config). **Open the target serial connection in the ETTerms GUI first**, then tell the AI: *"list serial sessions, attach to COM3, 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.
|
||||
> ⚠️ The COM port is owned solely by the GUI. The MCP server attaches over the pipe — it never opens the port itself, so there's no GUI-vs-server conflict.
|
||||
|
||||
📖 Step-by-step setup: [docs/serial-mcp-guide.md](docs/serial-mcp-guide.md).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+17
-13
@@ -66,9 +66,9 @@
|
||||
- 腳本指令:`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、送指令、讀回應」即可
|
||||
- **COM port 由 GUI 持有**;獨立的 stdio **Serial MCP server** 經本機 named pipe 橋接過去,供 **Kiro CLI / Claude CLI** 使用
|
||||
- 工具:`serial_list` / `serial_attach` / `serial_write` / `serial_read` / `serial_detach`
|
||||
- AI 的 serial 收發會即時顯示在 GUI(標 `[AI]`)——先在 GUI 開好 port,再讓 AI attach
|
||||
|
||||
- 📊 **連線 RX 日誌**
|
||||
- `logopen` / `logwrite` / `logclose` 將工作階段輸出寫檔
|
||||
@@ -271,19 +271,21 @@ wait 'login: '
|
||||
|
||||
## 🤖 AI / MCP 整合
|
||||
|
||||
> 🔜 **規劃中(Phase 9)。** 讓 AI agent(**Kiro CLI / Claude CLI**)直接對 serial port 下指令、讀輸出。
|
||||
> 🔜 **規劃中(Phase 9)。** 讓 AI agent(**Kiro CLI / Claude CLI**)收發 serial,**而且你能在 ETTerms GUI 即時看到 AI 的每筆收發**。
|
||||
|
||||
ETTerms 額外提供一支獨立的 **stdio MCP server**(`src/ETTerms.SerialMcp/`),把 serial port 包成 AI 可呼叫的工具。它與 WinForms 主程式**各自獨立行程**:由 MCP client(Kiro CLI / Claude CLI)啟動並維持整個 session 存活,因此能**持續持有 COM port**——連線狀態可跨多次工具呼叫保留,也能接收裝置主動推送的非同步輸出。
|
||||
**關鍵設計:COM port 由 GUI 唯一持有,MCP server 不自己開 port。** 一個 COM port 同時只能被一個行程開啟;因此不讓 MCP server 自己開,而是 **GUI 當 port 的唯一擁有者**,在 GUI 內跑一支本機 **named pipe server**(`SerialBridgeServer`)。獨立的 `ETTerms.SerialMcp`(由 Kiro/Claude CLI 啟動)退化成瘦客戶端:所有 `write` / `read` 都經 pipe 轉發給 GUI,由 GUI 代為讀寫實體 port。AI 的 TX 會以 `[AI]` 標色 echo 進終端機,RX/TX 都流經 GUI channel,**你看到的就是 AI 看到的**。
|
||||
|
||||
> **為何要獨立常駐行程?** CLI agent 的每條 shell 指令都是新行程,`open→write→read` 無法跨呼叫保留狀態(port 一關就斷)。常駐的 MCP server 才能維持一條連線、累積接收緩衝。
|
||||
全閉迴路——全部跑在 ETTerms GUI 內:
|
||||
- **Tab1:** 一條 Serial 連線持有 COM3(即時顯示流量,AI 的寫入標 `[AI]`)
|
||||
- **Tab2:** 一個 PowerShell(ConPTY)分頁跑 `kiro-cli`,它啟動 `ETTerms.SerialMcp`,再連回 pipe
|
||||
|
||||
| 工具 | 參數 | 說明 |
|
||||
|------|------|------|
|
||||
| `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 |
|
||||
| `serial_list` | — | 列出 **GUI 目前開著的** serial session(名稱 + baud) |
|
||||
| `serial_attach` | portName | 以 COM 名稱綁定到 GUI 的 serial session(write/read 前必須先 attach) |
|
||||
| `serial_write` | text, appendNewline? | 經 GUI 送出文字(即時以 `[AI]` 標色顯示) |
|
||||
| `serial_read` | waitFor?, timeoutMs? | 取出累積的 RX;可等待子字串或逾時 |
|
||||
| `serial_detach` | — | 解除綁定(**不會**關閉 GUI 的 port) |
|
||||
|
||||
在 Kiro CLI 註冊:
|
||||
|
||||
@@ -291,9 +293,11 @@ ETTerms 額外提供一支獨立的 **stdio MCP server**(`src/ETTerms.SerialMc
|
||||
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` 看回應」*。
|
||||
或寫進 `agent.json` 的 `mcpServers`(Claude CLI 用其對應的 `mcpServers` 設定)。**先在 ETTerms GUI 開好要操作的 serial 連線**,再對 AI 說:*「列出 serial session、接上 COM3、送 `AT` 看回應」*。
|
||||
|
||||
> ⚠️ 一個 COM port 同時只能被一個行程開啟——別在 GUI 與 MCP server 同時開同一個 port。
|
||||
> ⚠️ COM port 由 GUI 唯一持有;MCP server 經 pipe attach,自己不開 port,因此不會與 GUI 衝突。
|
||||
|
||||
📖 完整註冊與使用步驟:[docs/serial-mcp-guide.md](docs/serial-mcp-guide.md)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# Serial MCP 使用手冊
|
||||
|
||||
> 讓 **Kiro CLI / Claude CLI** 等 AI agent 直接收發 serial,**而且你能在 ETTerms GUI 即時看到 AI 的每一筆收發**。
|
||||
|
||||
---
|
||||
|
||||
## 這是什麼
|
||||
|
||||
`ETTerms.SerialMcp` 是一支獨立的 stdio MCP server。重點設計:
|
||||
|
||||
- **COM port 由 ETTerms GUI 唯一持有**,MCP server 自己**不開 port**。
|
||||
- GUI 內跑一支本機 named pipe(`\\.\pipe\etterms-serial`);MCP server 經這條 pipe 把 `attach / write / read` 轉發給 GUI,由 GUI 代為讀寫實體 port。
|
||||
- AI 送出的資料會以 `[AI]` 標色 echo 進 GUI 終端機,所以**你看到的就是 AI 看到的**。
|
||||
|
||||
```
|
||||
ETTerms GUI(唯一開 COM32 的人)
|
||||
├─ Tab1: Serial 連線 COM32(即時顯示,AI 的寫入標 [AI])
|
||||
├─ named pipe: \\.\pipe\etterms-serial
|
||||
└─ Tab2: PowerShell 跑 kiro-cli → 啟動 ETTerms.SerialMcp → 連回 pipe
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 前置需求
|
||||
|
||||
1. 已安裝 .NET 8(`dotnet --list-runtimes` 有 `WindowsDesktop 8.0.x`)。
|
||||
2. 已編譯出 MCP 執行檔:
|
||||
```powershell
|
||||
cd <你的 repo>\ETTerms
|
||||
dotnet build ETTerms.slnx
|
||||
```
|
||||
產物在:`src\ETTerms.SerialMcp\bin\Debug\net8.0\ETTerms.SerialMcp.exe`
|
||||
3. 已安裝並可在終端機執行 `kiro-cli`。
|
||||
|
||||
---
|
||||
|
||||
## 步驟 1:註冊 MCP 到 Kiro
|
||||
|
||||
用**編譯好的 exe** 註冊(不要用 `dotnet run`——它會把建置訊息印到 stdout,污染 JSON-RPC)。
|
||||
建議用 **global** scope,這樣不論從哪個工作目錄啟動 kiro 都抓得到:
|
||||
|
||||
```powershell
|
||||
kiro-cli mcp add --name serial --scope global --force `
|
||||
--command "<你的 repo>\ETTerms\src\ETTerms.SerialMcp\bin\Debug\net8.0\ETTerms.SerialMcp.exe"
|
||||
```
|
||||
|
||||
成功會顯示:
|
||||
```
|
||||
✓ Added MCP server 'serial' to global config in C:\Users\<you>\.kiro\settings\mcp.json
|
||||
```
|
||||
|
||||
> 設定檔位置:
|
||||
> - global:`~\.kiro\settings\mcp.json`
|
||||
> - workspace:`<專案>\.kiro\settings\mcp.json`(只在該目錄啟動 kiro 才生效)
|
||||
|
||||
---
|
||||
|
||||
## 步驟 2:重啟 kiro session
|
||||
|
||||
⚠️ **MCP server 只在 kiro session 啟動時載入。** 若你在註冊前就開著 kiro chat,必須重開:
|
||||
|
||||
1. 在 kiro chat 內輸入 `/quit` 回到 PowerShell。
|
||||
2. 重新執行 `kiro-cli`。
|
||||
3. 輸入 `/mcp`,應看到:
|
||||
```
|
||||
serial Status: ✓ Initialized
|
||||
Tools: serial_list, serial_attach, serial_write, serial_read, serial_detach
|
||||
```
|
||||
|
||||
> `/mcp` 顯示 Initialized 只代表 MCP server 起得來,**還沒**連 pipe(連 pipe 是第一次呼叫 serial 工具時才發生)。
|
||||
|
||||
---
|
||||
|
||||
## 步驟 3:使用
|
||||
|
||||
**先在 ETTerms GUI 開好要操作的 serial 連線**(雙擊側欄連線開分頁),AI 才能 attach 上去。
|
||||
|
||||
然後直接對 AI 講白話即可,例如:
|
||||
|
||||
> 列出目前的 serial session,接上 COM32,送 `root` 然後讀回應。
|
||||
|
||||
AI 會依序呼叫 `serial_list` → `serial_attach` → `serial_write` → `serial_read`,
|
||||
而你在 GUI 的 COM32 分頁會即時看到紫色 `[AI] root` 與裝置回應。
|
||||
|
||||
### ⚠️ 重要:session 名稱 = COM port 名稱
|
||||
|
||||
GUI 裡連線的**顯示名稱**(例如 `BP1`)與橋接用的**session 名稱不同**。
|
||||
`serial_list` 回傳的是 **COM port 名稱**(例如 `COM32`),attach 時要用它:
|
||||
|
||||
| GUI 顯示 | serial_list 回傳 / attach 用 |
|
||||
|---------|------------------------------|
|
||||
| BP1 | `COM32` |
|
||||
| BP2 | `COM17` |
|
||||
|
||||
---
|
||||
|
||||
## 工具清單
|
||||
|
||||
| 工具 | 參數 | 說明 |
|
||||
|------|------|------|
|
||||
| `serial_list` | — | 列出 GUI 目前開著的 serial session(名稱 + baud) |
|
||||
| `serial_attach` | `portName`(如 `COM32`) | 綁定到某個 GUI session;write/read 前必須先 attach |
|
||||
| `serial_write` | `text`, `appendNewline`(預設 true) | 送出文字(GUI 以 `[AI]` 標色顯示) |
|
||||
| `serial_read` | `waitFor`?(等待子字串), `timeoutMs`?(預設 3000) | 取出累積的 RX,回傳後清空 buffer |
|
||||
| `serial_detach` | — | 解除綁定(**不會**關閉 GUI 的 port) |
|
||||
|
||||
---
|
||||
|
||||
## 疑難排解
|
||||
|
||||
| 症狀 | 原因 | 解法 |
|
||||
|------|------|------|
|
||||
| `/mcp` 顯示 `0 configured` | 還沒註冊,或 kiro session 早於註冊就開了 | 執行步驟 1,再 `/quit` 重開 kiro |
|
||||
| `/mcp` 註冊了但仍看不到 | 用了 workspace scope,但 kiro 的工作目錄不同 | 改用 `--scope global --force` 重註冊 |
|
||||
| 工具回 `bridge not connected; is the ETTerms GUI running?` | ETTerms GUI 沒開 | 先開 GUI(GUI 啟動時才會起 pipe server) |
|
||||
| 工具回 `no open serial session '...' in GUI` | 該 COM port 在 GUI 沒開,或名稱打錯 | 先在 GUI 開該連線;用 `serial_list` 確認正確的 COM 名稱 |
|
||||
| pipe 行為怪異 / 收不到 | 同時有別的程式佔用 pipe | pipe 一次只服務一個 client,關掉其他連 `etterms-serial` 的程式 |
|
||||
| 註冊後 stdout 有奇怪輸出 | 用了 `dotnet run` 當 command | 改用編譯好的 `.exe` 路徑 |
|
||||
|
||||
---
|
||||
|
||||
## 移除
|
||||
|
||||
```powershell
|
||||
kiro-cli mcp remove --name serial
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 進階:用 agent.json 設定(替代 mcp add)
|
||||
|
||||
也可直接寫進 agent 設定的 `mcpServers`(global:`~\.kiro\settings\mcp.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"serial": {
|
||||
"command": "<你的 repo>\\ETTerms\\src\\ETTerms.SerialMcp\\bin\\Debug\\net8.0\\ETTerms.SerialMcp.exe"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>ETTerms.SerialMcp</RootNamespace>
|
||||
<AssemblyName>ETTerms.SerialMcp</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,17 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
// stdio MCP server:把 ETTerms GUI 持有的 serial session 暴露給 AI(Kiro / Claude CLI)。
|
||||
// 自己不開 COM port,所有收發經 named pipe 轉給 GUI 的 SerialBridgeServer。
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
// stdio 傳輸:stdout 專供 JSON-RPC,log 一律走 stderr,否則會污染協議。
|
||||
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
|
||||
|
||||
builder.Services
|
||||
.AddMcpServer()
|
||||
.WithStdioServerTransport()
|
||||
.WithToolsFromAssembly();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.IO.Pipes;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ETTerms.SerialMcp;
|
||||
|
||||
/// <summary>
|
||||
/// 連到 ETTerms GUI 的 SerialBridgeServer(named pipe \\.\pipe\etterms-serial)。
|
||||
/// 轉發 list / attach / write / detach,背景累積 RX 供 <c>serial_read</c> 消費。
|
||||
/// 單例:整個 MCP server 生命週期共用同一條 pipe,連線狀態跨工具呼叫保留。
|
||||
/// </summary>
|
||||
public sealed class SerialBridgeClient
|
||||
{
|
||||
public static readonly SerialBridgeClient Instance = new();
|
||||
private const string PipeName = "etterms-serial";
|
||||
|
||||
private readonly SemaphoreSlim _connLock = new(1, 1);
|
||||
private readonly SemaphoreSlim _reqLock = new(1, 1);
|
||||
private readonly object _wlock = new();
|
||||
private readonly object _rxlock = new();
|
||||
private readonly StringBuilder _rx = new();
|
||||
|
||||
private StreamWriter? _writer;
|
||||
private TaskCompletionSource<JsonElement>? _pending;
|
||||
|
||||
private async Task EnsureConnected()
|
||||
{
|
||||
await _connLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (_writer != null) return;
|
||||
var pipe = new NamedPipeClientStream(".", PipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
|
||||
await pipe.ConnectAsync(3000);
|
||||
_writer = new StreamWriter(pipe, new UTF8Encoding(false)) { AutoFlush = true };
|
||||
_ = Task.Run(() => ReadLoop(new StreamReader(pipe, new UTF8Encoding(false)), pipe));
|
||||
}
|
||||
finally { _connLock.Release(); }
|
||||
}
|
||||
|
||||
private async Task ReadLoop(StreamReader reader, NamedPipeClientStream pipe)
|
||||
{
|
||||
try
|
||||
{
|
||||
string? line;
|
||||
while ((line = await reader.ReadLineAsync()) != null)
|
||||
{
|
||||
JsonElement doc;
|
||||
try { doc = JsonDocument.Parse(line).RootElement.Clone(); }
|
||||
catch { continue; }
|
||||
if (doc.TryGetProperty("op", out var op) && op.GetString() == "rx")
|
||||
{
|
||||
var d = doc.TryGetProperty("data", out var dd) ? dd.GetString() ?? "" : "";
|
||||
lock (_rxlock) _rx.Append(d);
|
||||
}
|
||||
else _pending?.TrySetResult(doc);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
finally { lock (_wlock) _writer = null; try { pipe.Dispose(); } catch { } }
|
||||
}
|
||||
|
||||
private async Task<string> Request(object req)
|
||||
{
|
||||
await EnsureConnected();
|
||||
await _reqLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var tcs = new TaskCompletionSource<JsonElement>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pending = tcs;
|
||||
lock (_wlock)
|
||||
{
|
||||
if (_writer == null) throw new IOException("bridge not connected; is the ETTerms GUI running?");
|
||||
_writer.WriteLine(JsonSerializer.Serialize(req));
|
||||
}
|
||||
using var cts = new CancellationTokenSource(5000);
|
||||
using var reg = cts.Token.Register(() => tcs.TrySetException(new TimeoutException("no response from ETTerms bridge")));
|
||||
return (await tcs.Task).GetRawText();
|
||||
}
|
||||
finally { _pending = null; _reqLock.Release(); }
|
||||
}
|
||||
|
||||
public Task<string> List() => Request(new { op = "list" });
|
||||
public Task<string> Attach(string portName) => Request(new { op = "attach", session = portName });
|
||||
public Task<string> Write(string text, bool newline) => Request(new { op = "write", data = text, newline });
|
||||
public Task<string> Detach() => Request(new { op = "detach" });
|
||||
|
||||
/// <summary>取出累積的 RX;可等待子字串或逾時。回傳後清空 buffer。</summary>
|
||||
public async Task<string> Read(string? waitFor, int timeoutMs)
|
||||
{
|
||||
await EnsureConnected();
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs <= 0 ? 1000 : timeoutMs);
|
||||
while (true)
|
||||
{
|
||||
string snap;
|
||||
lock (_rxlock) snap = _rx.ToString();
|
||||
bool hit = string.IsNullOrEmpty(waitFor) ? snap.Length > 0 : snap.Contains(waitFor);
|
||||
if (hit || DateTime.UtcNow >= deadline)
|
||||
{
|
||||
lock (_rxlock) _rx.Clear();
|
||||
return snap;
|
||||
}
|
||||
await Task.Delay(50);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.ComponentModel;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace ETTerms.SerialMcp;
|
||||
|
||||
/// <summary>
|
||||
/// 暴露給 AI 的 serial 工具。COM port 由 ETTerms GUI 持有,這些工具經 named pipe 橋接過去;
|
||||
/// AI 的寫入會即時顯示在 GUI 終端機(標 [AI])。使用前須先在 GUI 開好該 serial 連線。
|
||||
/// </summary>
|
||||
[McpServerToolType]
|
||||
public static class SerialTools
|
||||
{
|
||||
[McpServerTool, Description("List serial sessions currently open in the ETTerms GUI (name + baud). The GUI owns the COM port.")]
|
||||
public static Task<string> serial_list() => SerialBridgeClient.Instance.List();
|
||||
|
||||
[McpServerTool, Description("Attach to a serial session already open in the ETTerms GUI by COM port name (e.g. COM3). Required before write/read. Does not open the port itself.")]
|
||||
public static Task<string> serial_attach(
|
||||
[Description("COM port name of an already-open GUI session, e.g. COM3")] string portName)
|
||||
=> SerialBridgeClient.Instance.Attach(portName);
|
||||
|
||||
[McpServerTool, Description("Send text to the attached serial session. The GUI displays it live tagged [AI].")]
|
||||
public static Task<string> serial_write(
|
||||
[Description("Text to send")] string text,
|
||||
[Description("Append the session newline (acts like pressing Enter)")] bool appendNewline = true)
|
||||
=> SerialBridgeClient.Instance.Write(text, appendNewline);
|
||||
|
||||
[McpServerTool, Description("Read output accumulated from the attached serial session. Optionally wait for a substring up to timeoutMs. Returns and clears the buffer.")]
|
||||
public static Task<string> serial_read(
|
||||
[Description("Substring to wait for; null/empty returns whatever has arrived")] string? waitFor = null,
|
||||
[Description("Max time to wait in milliseconds")] int timeoutMs = 3000)
|
||||
=> SerialBridgeClient.Instance.Read(waitFor, timeoutMs);
|
||||
|
||||
[McpServerTool, Description("Detach from the serial session. Does NOT close the GUI's port (the GUI keeps owning it).")]
|
||||
public static Task<string> serial_detach() => SerialBridgeClient.Instance.Detach();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Drawing;
|
||||
using System.Reflection;
|
||||
using System.Windows.Forms;
|
||||
using ETTerms.Infrastructure;
|
||||
|
||||
namespace ETTerms.App;
|
||||
|
||||
@@ -54,6 +55,9 @@ public sealed class AboutView : UserControl
|
||||
p.Controls.Add(MakeLine(" TTL Script Engine (ported from MyTeraTerm)", Theme.UiFont, Theme.TextDim));
|
||||
}));
|
||||
|
||||
// Brand card — large app icon + logo
|
||||
left.Controls.Add(MakeBrandCard());
|
||||
|
||||
// ═══ RIGHT PANEL — Changelog (scrollable) ═══
|
||||
var right = new FlowLayoutPanel
|
||||
{
|
||||
@@ -110,6 +114,37 @@ public sealed class AboutView : UserControl
|
||||
return card;
|
||||
}
|
||||
|
||||
/// <summary>底部品牌卡:大的 Choco 圖示 + ETTerms 標誌圖。</summary>
|
||||
private static Panel MakeBrandCard()
|
||||
{
|
||||
var card = new Panel
|
||||
{
|
||||
Width = 340, Height = 300, Margin = new Padding(0, 0, 0, 12),
|
||||
BackColor = Theme.TabBack, Padding = new Padding(12)
|
||||
};
|
||||
var flow = new FlowLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown,
|
||||
WrapContents = false, BackColor = Theme.TabBack, AutoSize = false
|
||||
};
|
||||
|
||||
// 大圖示 (256x256),等比縮放塞滿框、不裁切
|
||||
var iconImg = AppAssets.AppIcon(256);
|
||||
if (iconImg != null)
|
||||
{
|
||||
flow.Controls.Add(new PictureBox
|
||||
{
|
||||
Width = 312, Height = 268, Margin = new Padding(0, 4, 0, 4),
|
||||
SizeMode = PictureBoxSizeMode.Zoom,
|
||||
BackColor = Theme.TabBack, Image = iconImg.ToBitmap()
|
||||
});
|
||||
iconImg.Dispose();
|
||||
}
|
||||
|
||||
card.Controls.Add(flow);
|
||||
return card;
|
||||
}
|
||||
|
||||
private static Label MakeLine(string text, Font font, Color color, ContentAlignment align = ContentAlignment.MiddleLeft)
|
||||
{
|
||||
return new Label
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Reflection;
|
||||
using System.Windows.Forms;
|
||||
using ETTerms.App.Workspace;
|
||||
using ETTerms.Infrastructure;
|
||||
@@ -17,16 +18,30 @@ public partial class MainForm : Form
|
||||
private readonly AboutView _about = new();
|
||||
private readonly StatusStrip _status = new();
|
||||
private readonly ToolStripStatusLabel _statusLabel = new();
|
||||
private readonly Sessions.SerialBridgeServer _bridge = new();
|
||||
|
||||
public MainForm()
|
||||
{
|
||||
InitializeComponent();
|
||||
ApplyIconAndTitle();
|
||||
BuildLayout();
|
||||
WireEvents();
|
||||
AppSettings.Instance.ApplyWindowPosition(this);
|
||||
_bridge.Start(); // 本機 named pipe,供 ETTerms.SerialMcp 橋接 serial
|
||||
AppLogger.Info("MainForm initialized");
|
||||
}
|
||||
|
||||
/// <summary>設定視窗 / 工作列圖示,並在標題列加上版本號。</summary>
|
||||
private void ApplyIconAndTitle()
|
||||
{
|
||||
var icon = AppAssets.AppIcon();
|
||||
if (icon != null) Icon = icon;
|
||||
|
||||
var v = Assembly.GetExecutingAssembly().GetName().Version;
|
||||
var versionStr = v != null ? $"{v.Major}.{v.Minor}.{v.Build}" : "0.1.0";
|
||||
Text = $"ETTerms Version v{versionStr}";
|
||||
}
|
||||
|
||||
private void BuildLayout()
|
||||
{
|
||||
Controls.Add(_workspace); // Fill
|
||||
@@ -74,6 +89,7 @@ public partial class MainForm : Form
|
||||
|
||||
protected override void OnFormClosed(FormClosedEventArgs e)
|
||||
{
|
||||
_bridge.Dispose();
|
||||
AppSettings.Instance.SaveWindowPosition(this);
|
||||
AppLogger.LogApplicationClose();
|
||||
base.OnFormClosed(e);
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
@@ -10,11 +10,19 @@
|
||||
<AssemblyName>ETTerms</AssemblyName>
|
||||
|
||||
<!-- 版本資訊 -->
|
||||
<Version>0.1.3</Version>
|
||||
<Version>0.2.0</Version>
|
||||
<Product>ETTerms</Product>
|
||||
<Company>ETTerms Project</Company>
|
||||
|
||||
<!-- 視窗 / 工作列 / exe 圖示 -->
|
||||
<ApplicationIcon>Assets\Choco_256x256.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- 視窗圖示 (title bar / taskbar / About 大圖),內嵌進組件 -->
|
||||
<EmbeddedResource Include="Assets\Choco_256x256.ico" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10" />
|
||||
<PackageReference Include="SnmpSharpNet" Version="0.9.7">
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Drawing;
|
||||
using System.Reflection;
|
||||
|
||||
namespace ETTerms.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// 存取內嵌進組件的圖示(見 ETTerms.csproj 的 EmbeddedResource)。
|
||||
/// Manifest 名稱規則:RootNamespace.資料夾.檔名 → ETTerms.Assets.xxx。
|
||||
/// </summary>
|
||||
public static class AppAssets
|
||||
{
|
||||
private const string IconResource = "ETTerms.Assets.Choco_256x256.ico";
|
||||
|
||||
/// <summary>視窗 / 工作列圖示。找不到資源時回傳 null。</summary>
|
||||
public static Icon? AppIcon() => LoadIcon(IconResource);
|
||||
|
||||
/// <summary>Choco 圖示,指定尺寸(About 用大圖)。</summary>
|
||||
public static Icon? AppIcon(int size)
|
||||
{
|
||||
var icon = LoadIcon(IconResource);
|
||||
if (icon == null) return null;
|
||||
var sized = new Icon(icon, new Size(size, size));
|
||||
icon.Dispose();
|
||||
return sized;
|
||||
}
|
||||
|
||||
private static Icon? LoadIcon(string name)
|
||||
{
|
||||
using var s = Assembly.GetExecutingAssembly().GetManifestResourceStream(name);
|
||||
return s == null ? null : new Icon(s);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace ETTerms.Sessions;
|
||||
|
||||
/// <summary>
|
||||
/// 一個可被 MCP 橋接的 serial session 端點:提供寫入(含 [AI] echo 回 GUI)與 RX 轉發。
|
||||
/// 由 <see cref="SessionPage"/> 建立並註冊到 <see cref="SerialBridge"/>。
|
||||
/// </summary>
|
||||
public sealed class SerialBridgeEndpoint
|
||||
{
|
||||
private readonly Action<string, bool> _write;
|
||||
|
||||
public string Name { get; }
|
||||
public int BaudRate { get; }
|
||||
|
||||
/// <summary>實體 port 收到的資料(背景緒觸發),由 pipe server 廣播給 MCP client。</summary>
|
||||
public event Action<byte[]>? Rx;
|
||||
|
||||
public SerialBridgeEndpoint(string name, int baudRate, Action<string, bool> write)
|
||||
{
|
||||
Name = name;
|
||||
BaudRate = baudRate;
|
||||
_write = write;
|
||||
}
|
||||
|
||||
public void Write(string text, bool appendNewline) => _write(text, appendNewline);
|
||||
|
||||
public void FeedRx(byte[] data) => Rx?.Invoke(data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GUI 目前開著、可供 MCP 橋接的 serial session 登錄表。port 永遠由 GUI 持有,
|
||||
/// MCP(ETTerms.SerialMcp)經 named pipe attach 到這裡的端點來收發。
|
||||
/// </summary>
|
||||
public static class SerialBridge
|
||||
{
|
||||
private static readonly object _lock = new();
|
||||
private static readonly List<SerialBridgeEndpoint> _eps = new();
|
||||
|
||||
public static void Register(SerialBridgeEndpoint ep) { lock (_lock) _eps.Add(ep); }
|
||||
public static void Unregister(SerialBridgeEndpoint ep) { lock (_lock) _eps.Remove(ep); }
|
||||
public static SerialBridgeEndpoint[] All { get { lock (_lock) return _eps.ToArray(); } }
|
||||
|
||||
public static SerialBridgeEndpoint? Find(string name) =>
|
||||
All.FirstOrDefault(e => string.Equals(e.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ETTerms.Infrastructure;
|
||||
|
||||
namespace ETTerms.Sessions;
|
||||
|
||||
/// <summary>
|
||||
/// 本機 named pipe server:把 GUI 持有的 serial session 橋接給 MCP client(ETTerms.SerialMcp)。
|
||||
/// 協議=換行分隔 JSON(list / attach / write / detach),RX 由本端主動以 {"op":"rx"} 推送。
|
||||
/// 一次服務一個 client(單一 AI agent 已足夠),斷線後自動回到等待下一個連線。
|
||||
/// </summary>
|
||||
public sealed class SerialBridgeServer : IDisposable
|
||||
{
|
||||
public const string PipeName = "etterms-serial";
|
||||
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Task? _loop;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_loop != null) return;
|
||||
_loop = Task.Run(AcceptLoop);
|
||||
AppLogger.Info($"SerialBridge server started on pipe \\\\.\\pipe\\{PipeName}");
|
||||
}
|
||||
|
||||
private async Task AcceptLoop()
|
||||
{
|
||||
while (!_cts.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pipe = new NamedPipeServerStream(PipeName, PipeDirection.InOut, 1,
|
||||
PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
|
||||
await pipe.WaitForConnectionAsync(_cts.Token);
|
||||
await ServeClient(pipe);
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex) { AppLogger.LogWarning($"SerialBridge accept error: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ServeClient(NamedPipeServerStream pipe)
|
||||
{
|
||||
using (pipe)
|
||||
{
|
||||
var writer = new StreamWriter(pipe, new UTF8Encoding(false)) { AutoFlush = true };
|
||||
var reader = new StreamReader(pipe, new UTF8Encoding(false));
|
||||
var wlock = new object();
|
||||
void Send(object o) { lock (wlock) { try { writer.WriteLine(JsonSerializer.Serialize(o)); } catch { } } }
|
||||
|
||||
SerialBridgeEndpoint? attached = null;
|
||||
Action<byte[]>? rx = null;
|
||||
void Detach() { if (attached != null && rx != null) attached.Rx -= rx; attached = null; rx = null; }
|
||||
|
||||
try
|
||||
{
|
||||
string? line;
|
||||
while ((line = await reader.ReadLineAsync()) != null)
|
||||
{
|
||||
Req? req;
|
||||
try { req = JsonSerializer.Deserialize<Req>(line); } catch { continue; }
|
||||
if (req == null) continue;
|
||||
|
||||
switch (req.op)
|
||||
{
|
||||
case "list":
|
||||
Send(new { sessions = SerialBridge.All.Select(e => new { name = e.Name, baud = e.BaudRate }) });
|
||||
break;
|
||||
case "attach":
|
||||
Detach();
|
||||
attached = SerialBridge.Find(req.session ?? "");
|
||||
if (attached == null) { Send(new { ok = false, error = $"no open serial session '{req.session}' in GUI" }); break; }
|
||||
rx = data => Send(new { op = "rx", data = Encoding.UTF8.GetString(data) });
|
||||
attached.Rx += rx;
|
||||
Send(new { ok = true, name = attached.Name });
|
||||
break;
|
||||
case "write":
|
||||
if (attached == null) { Send(new { ok = false, error = "not attached" }); break; }
|
||||
attached.Write(req.data ?? "", req.newline);
|
||||
Send(new { ok = true });
|
||||
break;
|
||||
case "detach":
|
||||
Detach();
|
||||
Send(new { ok = true });
|
||||
break;
|
||||
default:
|
||||
Send(new { ok = false, error = $"unknown op '{req.op}'" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { AppLogger.LogWarning($"SerialBridge client error: {ex.Message}"); }
|
||||
finally { Detach(); }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Req
|
||||
{
|
||||
public string? op { get; set; }
|
||||
public string? session { get; set; }
|
||||
public string? data { get; set; }
|
||||
public bool newline { get; set; }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { _cts.Cancel(); } catch { }
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,12 @@ public sealed class SerialChannel : ISessionChannel
|
||||
|
||||
public string LogName => _portName;
|
||||
|
||||
/// <summary>目前傳輸速率(供 MCP 橋接列出 session 用)。</summary>
|
||||
public int BaudRate => _port.BaudRate;
|
||||
|
||||
/// <summary>送出換行序列(AI 寫入時附加,與手打 Enter 一致)。</summary>
|
||||
public string NewLine => _port.NewLine;
|
||||
|
||||
public SerialChannel(SerialSettings s)
|
||||
{
|
||||
_portName = s.PortName;
|
||||
|
||||
@@ -24,6 +24,7 @@ public sealed class SessionPage : UserControl
|
||||
private readonly Button _run, _stop, _log;
|
||||
private bool _started;
|
||||
private SessionLogger? _logger;
|
||||
private SerialBridgeEndpoint? _bridge; // Serial 才有:供 MCP 橋接
|
||||
|
||||
/// <summary>底層連線通道。</summary>
|
||||
public ISessionChannel Channel => _channel;
|
||||
@@ -216,6 +217,11 @@ public sealed class SessionPage : UserControl
|
||||
{
|
||||
_channel.Open();
|
||||
SessionManager.Register(_channel);
|
||||
if (_channel is SerialChannel sc)
|
||||
{
|
||||
_bridge = new SerialBridgeEndpoint(sc.LogName, sc.BaudRate, WriteFromAi);
|
||||
SerialBridge.Register(_bridge);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -228,17 +234,28 @@ public sealed class SessionPage : UserControl
|
||||
{
|
||||
// 側錄不依賴 UI thread,直接在背景緒寫檔(SessionLogger 內部自鎖)。
|
||||
_logger?.Write(data);
|
||||
_bridge?.FeedRx(data); // 轉發給 MCP(若有 attach)
|
||||
if (IsDisposed || !IsHandleCreated) return;
|
||||
if (InvokeRequired) BeginInvoke(() => _term.Feed(data));
|
||||
else _term.Feed(data);
|
||||
}
|
||||
|
||||
/// <summary>MCP 來源寫入:送出實體 port,並把內容以 [AI] 標色 echo 回終端機,使用者即時可見。</summary>
|
||||
private void WriteFromAi(string text, bool appendNewline)
|
||||
{
|
||||
if (_channel is not SerialChannel sc) return;
|
||||
_channel.Write(System.Text.Encoding.UTF8.GetBytes(appendNewline ? text + sc.NewLine : text));
|
||||
var echo = System.Text.Encoding.UTF8.GetBytes($"\x1b[35m[AI]\x1b[0m {text}\r\n");
|
||||
Ui(() => _term.Feed(echo));
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && _started)
|
||||
{
|
||||
_runner.Cancel();
|
||||
_channel.DataReceived -= OnDataReceived;
|
||||
if (_bridge != null) { SerialBridge.Unregister(_bridge); _bridge = null; }
|
||||
_logger?.Dispose();
|
||||
_logger = null;
|
||||
SessionManager.Unregister(_channel);
|
||||
|
||||
@@ -3,7 +3,7 @@ using System.Drawing;
|
||||
namespace ETTerms.Terminal;
|
||||
|
||||
[Flags]
|
||||
public enum CellAttr : byte { None = 0, Bold = 1, Underline = 2, Inverse = 4 }
|
||||
public enum CellAttr : byte { None = 0, Bold = 1, Underline = 2, Inverse = 4, Wide = 8, WideTrail = 16 }
|
||||
|
||||
public struct Cell
|
||||
{
|
||||
@@ -80,11 +80,37 @@ public sealed class ScreenBuffer
|
||||
// ── 輸出字元 ─────────────────────────────────────────────
|
||||
public void Print(char ch)
|
||||
{
|
||||
int w = CharWidth(ch);
|
||||
if (_wrapPending) { _wrapPending = false; CursorCol = 0; LineFeed(); }
|
||||
if (CursorRow < 0 || CursorRow >= Rows) CursorRow = Math.Clamp(CursorRow, 0, Rows - 1);
|
||||
_screen[CursorRow][CursorCol] = new Cell { Ch = ch, Fg = PenFg, Bg = PenBg, Attr = PenAttr };
|
||||
if (CursorCol >= Cols - 1) { if (AutoWrap) _wrapPending = true; }
|
||||
else CursorCol++;
|
||||
|
||||
// 寬字(全形 CJK)占 2 格;行尾剩 1 格放不下 → 先換行
|
||||
if (w == 2 && CursorCol == Cols - 1) { CursorCol = 0; LineFeed(); }
|
||||
|
||||
_screen[CursorRow][CursorCol] = new Cell { Ch = ch, Fg = PenFg, Bg = PenBg, Attr = w == 2 ? PenAttr | CellAttr.Wide : PenAttr };
|
||||
if (w == 2 && CursorCol + 1 < Cols)
|
||||
_screen[CursorRow][CursorCol + 1] = new Cell { Ch = '\0', Fg = PenFg, Bg = PenBg, Attr = PenAttr | CellAttr.WideTrail };
|
||||
|
||||
if (CursorCol + w >= Cols) { if (AutoWrap) _wrapPending = true; else CursorCol = Cols - 1; }
|
||||
else CursorCol += w;
|
||||
}
|
||||
|
||||
/// <summary>東亞全形 / 寬字回傳 2,其餘 1(BMP 範圍,足夠涵蓋常見中日韓)。</summary>
|
||||
private static int CharWidth(char ch)
|
||||
{
|
||||
if (ch < 0x1100) return 1;
|
||||
return (ch <= 0x115F) // Hangul Jamo
|
||||
|| (ch >= 0x2E80 && ch <= 0x303E) // CJK 部首 / 康熙 / 符號
|
||||
|| (ch >= 0x3041 && ch <= 0x33FF) // 平假名 / 片假名 / CJK 符號
|
||||
|| (ch >= 0x3400 && ch <= 0x4DBF) // CJK 擴充 A
|
||||
|| (ch >= 0x4E00 && ch <= 0x9FFF) // CJK 基本
|
||||
|| (ch >= 0xA000 && ch <= 0xA4CF) // 彝文
|
||||
|| (ch >= 0xAC00 && ch <= 0xD7A3) // 韓文音節
|
||||
|| (ch >= 0xF900 && ch <= 0xFAFF) // CJK 相容表意
|
||||
|| (ch >= 0xFE30 && ch <= 0xFE4F) // CJK 相容形式
|
||||
|| (ch >= 0xFF00 && ch <= 0xFF60) // 全形 ASCII
|
||||
|| (ch >= 0xFFE0 && ch <= 0xFFE6) // 全形符號
|
||||
? 2 : 1;
|
||||
}
|
||||
|
||||
// ── 游標 / 換行 ──────────────────────────────────────────
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Windows.Forms;
|
||||
|
||||
@@ -30,12 +31,13 @@ public sealed class TerminalView : UserControl
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint
|
||||
| ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
BackColor = Color.FromArgb(18, 18, 22);
|
||||
ImeMode = ImeMode.NoControl; // 容器控制項預設關 IME,這裡明確開啟讓使用者可切中文
|
||||
_font = new Font(profile.FontFamily, profile.FontSize);
|
||||
using (var g = CreateGraphics())
|
||||
{
|
||||
var sz = TextRenderer.MeasureText(g, "W", _font, Size.Empty, TextFormatFlags.NoPadding);
|
||||
_cellW = Math.Max(1, sz.Width);
|
||||
_cellH = Math.Max(1, _font.Height);
|
||||
_cellH = Math.Max(1, _font.Height + 2); // +2 行距,避免 g/y 下緣與中文底部被裁切
|
||||
}
|
||||
_buf = new ScreenBuffer(profile.Cols, profile.Rows,
|
||||
Color.FromArgb(220, 220, 220), BackColor, profile.ScrollbackLines);
|
||||
@@ -90,13 +92,26 @@ public sealed class TerminalView : UserControl
|
||||
while (c < line.Length)
|
||||
{
|
||||
var cell = line[c];
|
||||
if ((cell.Attr & CellAttr.WideTrail) != 0) { c++; continue; } // 寬字第二格,由前格覆蓋
|
||||
ResolveColors(cell, out var fg, out var bg, abs, c);
|
||||
// 合併同屬性連續格
|
||||
|
||||
// 寬字(全形 CJK):單獨繪製,占 2 格寬
|
||||
if ((cell.Attr & CellAttr.Wide) != 0)
|
||||
{
|
||||
var wr = new Rectangle(c * _cellW, y, _cellW * 2, _cellH);
|
||||
using (var bb = new SolidBrush(bg)) g.FillRectangle(bb, wr);
|
||||
DrawRun(g, cell.Ch == '\0' ? " " : cell.Ch.ToString(), cell.Attr, wr, fg);
|
||||
c++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 合併同屬性連續的窄字
|
||||
int start = c;
|
||||
var sb = new StringBuilder();
|
||||
while (c < line.Length)
|
||||
{
|
||||
var cur = line[c];
|
||||
if ((cur.Attr & (CellAttr.Wide | CellAttr.WideTrail)) != 0) break;
|
||||
ResolveColors(cur, out var f2, out var b2, abs, c);
|
||||
if (f2 != fg || b2 != bg || (cur.Attr & CellAttr.Bold) != (cell.Attr & CellAttr.Bold)) break;
|
||||
sb.Append(cur.Ch == '\0' ? ' ' : cur.Ch);
|
||||
@@ -104,14 +119,19 @@ public sealed class TerminalView : UserControl
|
||||
}
|
||||
var rect = new Rectangle(start * _cellW, y, (c - start) * _cellW, _cellH);
|
||||
using (var bb = new SolidBrush(bg)) g.FillRectangle(bb, rect);
|
||||
var style = (cell.Attr & CellAttr.Bold) != 0 ? FontStyle.Bold : FontStyle.Regular;
|
||||
if ((cell.Attr & CellAttr.Underline) != 0) style |= FontStyle.Underline;
|
||||
using var fnt = style == FontStyle.Regular ? _font : new Font(_font, style);
|
||||
TextRenderer.DrawText(g, sb.ToString(), fnt, rect, fg,
|
||||
TextFormatFlags.NoPadding | TextFormatFlags.NoPrefix | TextFormatFlags.Left);
|
||||
DrawRun(g, sb.ToString(), cell.Attr, rect, fg);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawRun(Graphics g, string text, CellAttr attr, Rectangle rect, Color fg)
|
||||
{
|
||||
var style = (attr & CellAttr.Bold) != 0 ? FontStyle.Bold : FontStyle.Regular;
|
||||
if ((attr & CellAttr.Underline) != 0) style |= FontStyle.Underline;
|
||||
using var fnt = style == FontStyle.Regular ? _font : new Font(_font, style);
|
||||
TextRenderer.DrawText(g, text, fnt, rect, fg,
|
||||
TextFormatFlags.NoPadding | TextFormatFlags.NoPrefix | TextFormatFlags.Left);
|
||||
}
|
||||
|
||||
private void ResolveColors(Cell cell, out Color fg, out Color bg, int abs, int col)
|
||||
{
|
||||
fg = cell.Fg.A == 0 ? _buf.DefaultFg : cell.Fg;
|
||||
@@ -223,7 +243,10 @@ public sealed class TerminalView : UserControl
|
||||
int from = abs == a.row ? a.col : 0;
|
||||
int to = abs == b.row ? b.col : line.Length;
|
||||
for (int c = from; c < Math.Min(to, line.Length); c++)
|
||||
{
|
||||
if ((line[c].Attr & CellAttr.WideTrail) != 0) continue;
|
||||
sb.Append(line[c].Ch == '\0' ? ' ' : line[c].Ch);
|
||||
}
|
||||
if (abs < b.row) sb.Append("\r\n");
|
||||
}
|
||||
var text = sb.ToString();
|
||||
@@ -239,4 +262,83 @@ public sealed class TerminalView : UserControl
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
// ── IME(中文 / 日文 / 韓文輸入)─────────────────────────
|
||||
// 自繪控制項預設不處理 IME 組字,故攔截 WM_IME_COMPOSITION 取「結果字串」直接送出 UTF-8。
|
||||
private const int WM_IME_STARTCOMPOSITION = 0x010D;
|
||||
private const int WM_IME_COMPOSITION = 0x010F;
|
||||
private const int GCS_RESULTSTR = 0x0800;
|
||||
private const int CFS_POINT = 0x0002;
|
||||
private const int IACE_DEFAULT = 0x0010;
|
||||
|
||||
// UserControl(容器)預設不啟用 IME,使用者無法切中文。把預設 IME context 綁回此視窗,
|
||||
// 並在取得焦點後(WinForms 可能於 OnGotFocus 內關掉)再綁一次,確保可切換輸入法。
|
||||
protected override void OnHandleCreated(EventArgs e)
|
||||
{
|
||||
base.OnHandleCreated(e);
|
||||
ImmAssociateContextEx(Handle, IntPtr.Zero, IACE_DEFAULT);
|
||||
}
|
||||
|
||||
protected override void OnGotFocus(EventArgs e)
|
||||
{
|
||||
base.OnGotFocus(e);
|
||||
ImmAssociateContextEx(Handle, IntPtr.Zero, IACE_DEFAULT);
|
||||
}
|
||||
|
||||
protected override void WndProc(ref Message m)
|
||||
{
|
||||
if (m.Msg == WM_IME_STARTCOMPOSITION) MoveImeWindowToCursor();
|
||||
else if (m.Msg == WM_IME_COMPOSITION && ((long)m.LParam & GCS_RESULTSTR) != 0)
|
||||
{
|
||||
var s = ReadImeResult();
|
||||
if (!string.IsNullOrEmpty(s))
|
||||
{
|
||||
SendData?.Invoke(Encoding.UTF8.GetBytes(s));
|
||||
return; // 消費此訊息,避免預設再轉成 WM_CHAR 造成重複輸入
|
||||
}
|
||||
}
|
||||
base.WndProc(ref m);
|
||||
}
|
||||
|
||||
private string ReadImeResult()
|
||||
{
|
||||
IntPtr hImc = ImmGetContext(Handle);
|
||||
if (hImc == IntPtr.Zero) return "";
|
||||
try
|
||||
{
|
||||
int len = ImmGetCompositionStringW(hImc, GCS_RESULTSTR, null, 0);
|
||||
if (len <= 0) return "";
|
||||
var buf = new byte[len];
|
||||
ImmGetCompositionStringW(hImc, GCS_RESULTSTR, buf, len);
|
||||
return Encoding.Unicode.GetString(buf);
|
||||
}
|
||||
finally { ImmReleaseContext(Handle, hImc); }
|
||||
}
|
||||
|
||||
private void MoveImeWindowToCursor()
|
||||
{
|
||||
IntPtr hImc = ImmGetContext(Handle);
|
||||
if (hImc == IntPtr.Zero) return;
|
||||
try
|
||||
{
|
||||
int vr = _buf.CursorRow + _scrollOffset;
|
||||
var cf = new COMPOSITIONFORM
|
||||
{
|
||||
dwStyle = CFS_POINT,
|
||||
ptCurrentPos = new POINT { x = _buf.CursorCol * _cellW, y = vr * _cellH }
|
||||
};
|
||||
ImmSetCompositionWindow(hImc, ref cf);
|
||||
}
|
||||
finally { ImmReleaseContext(Handle, hImc); }
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)] private struct POINT { public int x, y; }
|
||||
[StructLayout(LayoutKind.Sequential)] private struct RECT { public int left, top, right, bottom; }
|
||||
[StructLayout(LayoutKind.Sequential)] private struct COMPOSITIONFORM { public int dwStyle; public POINT ptCurrentPos; public RECT rcArea; }
|
||||
|
||||
[DllImport("imm32.dll")] private static extern IntPtr ImmGetContext(IntPtr hWnd);
|
||||
[DllImport("imm32.dll")] private static extern bool ImmAssociateContextEx(IntPtr hWnd, IntPtr hIMC, int dwFlags);
|
||||
[DllImport("imm32.dll")] private static extern bool ImmReleaseContext(IntPtr hWnd, IntPtr hIMC);
|
||||
[DllImport("imm32.dll", CharSet = CharSet.Unicode)] private static extern int ImmGetCompositionStringW(IntPtr hIMC, int dwIndex, byte[]? lpBuf, int dwBufLen);
|
||||
[DllImport("imm32.dll")] private static extern bool ImmSetCompositionWindow(IntPtr hIMC, ref COMPOSITIONFORM lpCompForm);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user