diff --git a/CLAUDE.md b/CLAUDE.md
index 13786e4..c39176a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -10,6 +10,8 @@ ETTerms 是一個 **C# .NET 8 WinForms** 的原生 Windows 終端機工作台,
**進度:** 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.4.0:** 效能 / 穩定性總整理(來自全 codebase 審視)。**(1) 終端機繪製熱路徑去配置** — `TerminalView` 快取 4 種 Font 變體(Bold/Underline 組合)與單一可變色 `SolidBrush`,run 合併時每 cell 顏色只解析一次,`Dispose` 收掉 GDI 資源;**(2) scrollback 改環形緩衝** — `ScreenBuffer` 從 `List+RemoveRange(0,…)`(滿了每行 O(n) 搬移)改 O(1) ring buffer;**(3) UTF-8 跨 chunk 亂碼修正(3 處)** — `TTLInterpreter.OnData`、`SerialBridgeServer` rx 轉發、`SessionLogger.Write` 改用 stateful `Decoder`(比照 `AnsiParser` 原本的正確做法);**(4) PDU SNMP 批次查詢** — 新共用專案 **`src/ETTerms.PduCore/`** 取代 GUI 與 PduMcp 兩份複製的 `PduController`(log 走建構子委派),新增 `GetAllPortsStatus()` 一個 SNMP GET 帶 12 個 varbind,12 port 輪詢從 36 個 UDP 來回縮成 3 個(StatusView 與 `pdu_status` 都改用);**(5) ShellChannel 資源修正** — `DeleteProcThreadAttributeList`+`FreeHGlobal` 釋放 attribute list、`CloseHandle(pi.hProcess)`,shell 自行 exit 時顯示灰色 `[ETTerms] shell process exited` 提示;**(6) SSH 斷線可見** — 訂 `ErrorOccurred`/`ShellStream.Closed` 顯示提示,`Write` 例外不再炸 UI thread;**(7) 終端機行為** — 新輸出只在已貼底時跟隨(看歷史不被拉回底部)、alt screen 滾輪轉方向鍵(vim/htop 可滾)、回應 DSR `ESC[6n`/`ESC[5n` 與 DA `ESC[c`(TUI 查游標位置不再卡住);**(8) TTL** — `_recv` 上限 1MB、wait 輪詢長度沒變不重掃;**(9) 去重複** — `ScriptRunner` 兩個 Run 合併、`TtlScript` 共用選檔/group 指令檢查(SessionPage/WorkspaceView 三份流程收斂)、`ConnectionStore` 讀取改 InvariantCulture+RoundtripKind。註:group `waitall` 一個成員失敗其他成員停在 barrier 是**刻意行為**(使用者要求整組停下),勿「修」。
+
**v0.3.2:** **(1) 新增左側 `Status` rail view** — 位於 Terminal 與 Settings 之間(圖示 `⚡`,新增 `ActivityRail.RailView.Status` 列舉與 `Items`),獨立 `App/StatusView.cs`,沿用 `SettingsView` 的自繪 tab strip + panel 切換風格(避免 TabControl 白邊);`MainForm` 加 `_statusView` 欄位、佈局與 rail 切換可見性。目前只有 **PDU 分頁**,未來會再加分頁(`MakeTab(...)` 即可擴充)。**(2) PDU 搬到 Status 並改自動輪詢** — PDU 從 `SettingsView` 移到 `StatusView`,**移除手動 Refresh 按鈕**,連線成功後以 `System.Threading.Timer`(period 3000ms)在**背景執行緒**讀 SNMP、再 `BeginInvoke` 回 UI 更新表格(不卡 UI);`Interlocked` 旗標防止前一輪未讀完就重入;斷線 / 控制項 Dispose 時自動停掉 timer 與 `PduController`;表格下方顯示「last update HH:mm:ss」。`SettingsView` 移除 `BuildPduTab`/`RefreshPduGrid` 與 `ETTerms.Scripting.Pdu` import,現只剩 **Terminal / AI MCP** 兩分頁。**(2b) PDU 分頁加手動開關鈕** — 狀態表格新增 **Control** 欄(`DataGridViewButtonColumn`),每個 Port 一顆鈕,文字隨狀態切換(ON→「Turn OFF」、OFF→「Turn ON」、未知→「—」);點擊在背景執行緒呼叫 `PduController.SetPortOn/SetPortOff`(不卡 UI),等 ~400ms 後回讀刷新整表,失敗跳 `MessageBox`;未連線點擊會提示先 Connect,斷線時清空表格避免顯示過時狀態。**(3) 修 `ShellChannel` 啟動目錄 fallback** — 本機 Shell 的 `StartupDirectory` 若已不存在(外接碟拔除 / 資料夾被刪)會 `CreateProcess failed: 267 (ERROR_DIRECTORY)`;改為「為空 **或** `Directory.Exists` 為 false」即 fallback 到使用者家目錄(`Environment.SpecialFolder.UserProfile`,隨登入者變動,例如 `C:\Users\et_wen`),與 Windows PowerShell 行為一致。
**v0.3.1:** 終端機體驗修正(皆在 `src/ETTerms/Terminal/`)。**(1) 深色垂直捲軸** — 新增自繪 `DarkScrollBar`(細長、無箭頭、圓角滑塊,配合 KKTerm 深色主題),`TerminalView` 右側 `Dock=Right` 掛上,`ContentWidth` 扣掉捲軸寬避免文字被蓋,`UpdateScrollBar()` 在 `Feed` / 滾輪 / resize 時同步滑塊範圍與位置,滾輪與拖曳互通。**(2) 多行貼上修正** — `AnsiParser` 新增 `BracketedPaste`(DEC mode 2004);`TerminalView.Paste()` 在對方啟用 bracketed paste(PSReadLine / Kiro CLI 等)時以 `ESC[200~ … ESC[201~` 包夾整段,視為「單次貼上」而非逐行 Enter 立即送出;未啟用時退回原本逐字送出。**(3) 右鍵複製清反白** — 右鍵複製後清掉 `_hasSel` 並重繪,讓使用者知道已複製。
@@ -86,11 +88,14 @@ kiro-cli mcp add --name serial --command dotnet --args "run --project src\ETTerm
- ⚠️ VT 相容性以常見情境(VT100 / 常見 ANSI)為主,冷門 escape 後補,不阻塞 GUI 進度。
- ⚠️ 本專案**無伺服端祕密 / 無 DB 密碼 / 無 EC2 / 無 VM**,因此不套用 AWS / VirtualBox 部署流程。
- ⚠️ Group 同步指令(`waitall` / `sendlnall` / `sendlngroup`)**只能在 Run Group 模式**使用;`▶ Script` 和 `▶ Run All` 須拒絕含這些指令的腳本。
+- ⚠️ Group `waitall` 的 barrier 行為:**一個成員失敗/停止,其他成員會停在 barrier 等** —— 這是刻意設計(整組一起停下來),不是 bug,勿改成 RemoveParticipant。
+- ⚠️ `SshChannel.Resize` 用反射挖 SSH.NET 私有 `_channel` 呼叫 `SendWindowChangeRequest`(該 API 未公開)。**升級 SSH.NET 版本時必須驗證 resize 仍有效**(連上後拉視窗大小看遠端 TUI 是否跟著變)。
## 資料夾用途
- **`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.PduMcp/`** — ✅ stdio MCP server(給 AI agent 控制 SNMP PDU,v0.3.0)。獨立行程,**直接打 SNMP**(內含精簡版 `PduController`),不經 GUI、GUI 不開著也能用;net8.0 console + `ModelContextProtocol` + `SnmpSharpNet`。
+- **`src/ETTerms.PduMcp/`** — ✅ stdio MCP server(給 AI agent 控制 SNMP PDU,v0.3.0)。獨立行程,**直接打 SNMP**,不經 GUI、GUI 不開著也能用;net8.0 console + `ModelContextProtocol`,PDU 邏輯用 `ETTerms.PduCore`。
+- **`src/ETTerms.PduCore/`** — ✅ PDU SNMP 控制共用庫(v0.4.0)。GUI 與 PduMcp 共用的唯一 `PduController`(先前兩份複製已移除);診斷 log 走建構子注入委派(GUI→AppLogger、MCP→stderr);含批次查詢 `GetAllPortsStatus()`。
- **`For_AI/`** — AI 協作素材與**參考專案**(`KKTerm-main` UI 參考、`MyTeraTerm` Script 參考)。整個資料夾 gitignored,僅供開發對照。
- 本專案**無 `secret/` 資料夾**:沒有伺服端祕密 / DB 密碼 / compile-time secret,連線密碼一律走 Windows Credential Manager。
diff --git a/ETTerms.slnx b/ETTerms.slnx
index 2a1a08d..4ad397e 100644
--- a/ETTerms.slnx
+++ b/ETTerms.slnx
@@ -3,5 +3,6 @@
+
diff --git a/docs/release-notes/v0.4.0.md b/docs/release-notes/v0.4.0.md
new file mode 100644
index 0000000..b22d4d9
--- /dev/null
+++ b/docs/release-notes/v0.4.0.md
@@ -0,0 +1,61 @@
+# v0.4.0 — Performance & stability overhaul
+
+## ✨ New features
+
+**Terminal performance overhaul**
+* Heavy output (long boot logs, big file dumps) now renders much more smoothly — drawing was
+ reworked to stop re-creating fonts and brushes on every frame.
+* Scroll-back history storage was rebuilt so the terminal stays fast no matter how much output
+ has accumulated; previously it got slower and slower once the history buffer filled up.
+
+**Full-screen apps (vim / htop) behave like a real terminal**
+* The mouse wheel now scrolls inside full-screen tools like `vim` and `htop` instead of doing nothing.
+* Tools that ask the terminal "where is the cursor?" now get an answer — fixes full-screen apps
+ that could hang on startup or draw at the wrong position.
+
+**Connection status you can actually see**
+* When an SSH connection drops or hits an error, a message now appears right in the tab —
+ no more sessions that silently stop responding.
+* When the local shell exits (e.g. you type `exit`), the tab shows
+ `[ETTerms] shell process exited` instead of just going dead.
+
+**Faster PDU status (GUI and AI)**
+* Outlet polling now bundles all 12 ports into a few SNMP requests instead of 36 separate ones —
+ the Status page refreshes faster, and an unreachable PDU no longer stalls the poll for minutes.
+* The AI `pdu_status` tool gets the same speed-up.
+
+## 🐛 Bug fixes
+
+**Garbled Chinese / non-English text**
+* Fixed: multi-byte characters could occasionally turn into `�` garbage when text was split
+ across data chunks — this affected TTL script `wait` matching, what the AI reads over the
+ serial bridge, and saved session log files. All three now decode correctly.
+
+**Scrolling**
+* Fixed: while you were scrolled back reading history, any new output yanked the view down to
+ the bottom. The view now stays where you are and only follows when you're already at the bottom.
+
+**Resource & stability**
+* Fixed small memory and handle leaks when opening and closing many local shell tabs over a
+ long session.
+* Fixed: typing into an SSH session after the connection died could crash the window — it now
+ just prints a short "write failed" note in the tab.
+
+## 📦 Downloads
+
+Two builds are produced:
+
+| Build | Needs .NET 8 Desktop Runtime? | Notes |
+|-------|-------------------------------|-------|
+| **Standard** (`ETTerms_v0.4.0`) | ✅ Yes | Smaller; for machines that already have the runtime |
+| **Portable** (`ETTerms_v0.4.0_portable`) | ❌ No | Runtime bundled — unzip and run, no install / admin needed |
+
+Run `ETTerms v0.4.0.exe`.
+
+## 🔗 Links
+* PDU MCP & Serial MCP setup guide:
+ [docs/serial-mcp-guide.md](https://github.com/ETWen/ETTerms/blob/main/docs/serial-mcp-guide.md)
+* TTL reference:
+ [docs/ttl-script-reference.md](https://github.com/ETWen/ETTerms/blob/main/docs/ttl-script-reference.md)
+
+**Full changelog:** [v0.3.3...v0.4.0](https://github.com/ETWen/ETTerms/compare/v0.3.3...v0.4.0)
diff --git a/src/ETTerms.PduCore/ETTerms.PduCore.csproj b/src/ETTerms.PduCore/ETTerms.PduCore.csproj
new file mode 100644
index 0000000..6c6a5a9
--- /dev/null
+++ b/src/ETTerms.PduCore/ETTerms.PduCore.csproj
@@ -0,0 +1,17 @@
+
+
+
+ net8.0
+ enable
+ enable
+ ETTerms.PduCore
+ ETTerms.PduCore
+
+
+
+
+ NU1701
+
+
+
+
diff --git a/src/ETTerms.PduCore/PduController.cs b/src/ETTerms.PduCore/PduController.cs
new file mode 100644
index 0000000..bbb14c9
--- /dev/null
+++ b/src/ETTerms.PduCore/PduController.cs
@@ -0,0 +1,124 @@
+using System.Net;
+using SnmpSharpNet;
+
+namespace ETTerms.PduCore;
+
+///
+/// PDU controller for iPoMan II/III models via SNMP。
+/// GUI 與 ETTerms.PduMcp 共用的唯一實作(v0.4.0 起取代兩份複製的版本)。
+/// 診斷輸出走建構子注入的 log 委派:GUI 給 AppLogger、MCP server 給 stderr
+/// (stdio MCP 的 stdout 專供 JSON-RPC,不可污染)。
+/// SNMP 走 UDP、非獨佔,多個行程可同時對同一台 PDU 操作。
+///
+public sealed class PduController : IDisposable
+{
+ private readonly string _ip;
+ private readonly Action? _logInfo;
+ private readonly Action? _logWarn;
+
+ private const string Community = "private";
+ private const int SnmpPort = 161;
+ private const int Timeout = 3000;
+
+ public string Ip => _ip;
+
+ public PduController(string ip, Action? logInfo = null, Action? logWarn = null)
+ {
+ _ip = ip;
+ _logInfo = logInfo;
+ _logWarn = logWarn;
+ }
+
+ /// 讀 PDU 型號/名稱 OID;非空且含 "PDU" 代表連得上。
+ public string? GetModelName() => SnmpGet(".1.3.6.1.4.1.2468.1.4.2.1.1.4");
+
+ public bool CheckConnection()
+ {
+ var name = GetModelName();
+ _logInfo?.Invoke($"[PDU] CheckConnection {_ip}: name='{name ?? ""}'");
+ return !string.IsNullOrEmpty(name) && name.Contains("PDU");
+ }
+
+ public bool SetPortOn(int port) => SnmpSet(PortControlOid(port), new Integer32(3));
+ public bool SetPortOff(int port) => SnmpSet(PortControlOid(port), new Integer32(4));
+
+ /// true = on, false = off, null = unknown/unreachable.
+ public bool? GetPortState(int port) => ParseState(SnmpGet(PortStateOid(port)));
+
+ public int? GetPortCurrent(int port) => int.TryParse(SnmpGet(PortCurrentOid(port)), out int v) ? v : null;
+ public double? GetPortPowerWatts(int port) => int.TryParse(SnmpGet(PortPowerOid(port)), out int v) ? v / 10.0 : null;
+
+ ///
+ /// 一次讀回所有插座的狀態 / 電流(mA) / 功率(W)。
+ /// 以 3 個批次 SNMP GET(每個 PDU 含 個 varbind)取代
+ /// 逐 port 逐 OID 的 3×N 個請求 —— 12 port 從 36 個 UDP 來回縮成 3 個,
+ /// 逾時時的最壞情況也從 36×Timeout 縮到 3×Timeout。
+ ///
+ public (bool? State, int? CurrentMilliAmps, double? PowerWatts)[] GetAllPortsStatus(int portCount)
+ {
+ var ports = Enumerable.Range(1, portCount).ToArray();
+ var states = SnmpGetMany(ports.Select(PortStateOid).ToArray());
+ var currents = SnmpGetMany(ports.Select(PortCurrentOid).ToArray());
+ var powers = SnmpGetMany(ports.Select(PortPowerOid).ToArray());
+
+ var result = new (bool?, int?, double?)[portCount];
+ for (int i = 0; i < portCount; i++)
+ {
+ result[i] = (
+ ParseState(states[i]),
+ int.TryParse(currents[i], out int c) ? c : null,
+ int.TryParse(powers[i], out int p) ? p / 10.0 : null);
+ }
+ return result;
+ }
+
+ private static bool? ParseState(string? r) => r == "3" ? true : (r == "2" || r == "4") ? false : null;
+
+ private static string PortControlOid(int port) => $".1.3.6.1.4.1.2468.1.4.2.1.3.2.4.1.2.{port}";
+ private static string PortStateOid(int port) => $".1.3.6.1.4.1.2468.1.4.2.1.3.2.3.1.2.{port}";
+ private static string PortCurrentOid(int port) => $".1.3.6.1.4.1.2468.1.4.2.1.3.2.3.1.3.{port}";
+ private static string PortPowerOid(int port) => $".1.3.6.1.4.1.2468.1.4.2.1.3.2.3.1.5.{port}";
+
+ private bool SnmpSet(string oid, AsnType value)
+ {
+ try
+ {
+ var param = new AgentParameters(new OctetString(Community)) { Version = SnmpVersion.Ver1 };
+ 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);
+ return result?.Pdu.ErrorStatus == 0;
+ }
+ catch (Exception ex) { _logWarn?.Invoke($"[PDU] SNMP SET {oid} exception: {ex.Message}"); return false; }
+ }
+
+ private string? SnmpGet(string oid)
+ {
+ var r = SnmpGetMany(new[] { oid });
+ return r[0];
+ }
+
+ /// 一個 SNMP GET 帶多個 OID(varbind),回傳同序的值;失敗整批回 null。
+ private string?[] SnmpGetMany(string[] oids)
+ {
+ var result = new string?[oids.Length];
+ try
+ {
+ var param = new AgentParameters(new OctetString(Community)) { Version = SnmpVersion.Ver1 };
+ using var target = new UdpTarget((IPAddress)new IpAddress(_ip), SnmpPort, Timeout, 1);
+ var pdu = new SnmpSharpNet.Pdu(PduType.Get);
+ foreach (var oid in oids) pdu.VbList.Add(new Oid(oid));
+ var resp = (SnmpV1Packet)target.Request(pdu, param);
+ if (resp == null) { _logWarn?.Invoke($"[PDU] SNMP GET ({oids.Length} oids): no response (timeout)"); return result; }
+ if (resp.Pdu.ErrorStatus != 0) { _logWarn?.Invoke($"[PDU] SNMP GET ({oids.Length} oids): ErrorStatus={resp.Pdu.ErrorStatus}"); return result; }
+ // SNMP GET 回應的 varbind 順序與請求一致,直接依 index 對回去。
+ int n = Math.Min(oids.Length, resp.Pdu.VbList.Count);
+ for (int i = 0; i < n; i++) result[i] = resp.Pdu.VbList[i].Value.ToString();
+ }
+ catch (Exception ex) { _logWarn?.Invoke($"[PDU] SNMP GET ({oids.Length} oids) exception: {ex.Message}"); }
+ return result;
+ }
+
+ public void Dispose() { }
+}
diff --git a/src/ETTerms.PduMcp/ETTerms.PduMcp.csproj b/src/ETTerms.PduMcp/ETTerms.PduMcp.csproj
index 47115f0..a912930 100644
--- a/src/ETTerms.PduMcp/ETTerms.PduMcp.csproj
+++ b/src/ETTerms.PduMcp/ETTerms.PduMcp.csproj
@@ -12,9 +12,10 @@
-
- NU1701
-
+
+
+
+
diff --git a/src/ETTerms.PduMcp/PduController.cs b/src/ETTerms.PduMcp/PduController.cs
deleted file mode 100644
index a521524..0000000
--- a/src/ETTerms.PduMcp/PduController.cs
+++ /dev/null
@@ -1,92 +0,0 @@
-using System.Net;
-using SnmpSharpNet;
-
-namespace ETTerms.PduMcp;
-
-///
-/// PDU controller for iPoMan II/III models via SNMP (slim copy of the GUI's
-/// ETTerms.Scripting.Pdu.PduController, ported into the standalone MCP server).
-///
-/// Differences from the GUI version:
-/// • No dependency on ETTerms.Infrastructure.AppLogger — diagnostics go to stderr
-/// (stdout is reserved for the MCP JSON-RPC stream).
-/// • Adds so tools can surface the device identity.
-///
-/// SNMP is connectionless (UDP) and non-exclusive, so this talks to the PDU directly
-/// without bridging through the GUI.
-///
-public sealed class PduController : IDisposable
-{
- private readonly string _ip;
- private const string Community = "private";
- private const int SnmpPort = 161;
- private const int Timeout = 3000;
-
- public string Ip => _ip;
-
- public PduController(string ip) => _ip = ip;
-
- /// Reads the PDU model/name OID; non-empty containing "PDU" means reachable.
- public string? GetModelName() => SnmpGet(".1.3.6.1.4.1.2468.1.4.2.1.1.4");
-
- public bool CheckConnection()
- {
- var name = GetModelName();
- Log($"CheckConnection {_ip}: name='{name ?? ""}'");
- return !string.IsNullOrEmpty(name) && name.Contains("PDU");
- }
-
- public bool SetPortOn(int port) => SnmpSet(PortControlOid(port), new Integer32(3));
- public bool SetPortOff(int port) => SnmpSet(PortControlOid(port), new Integer32(4));
-
- /// true = on, false = off, null = unknown/unreachable.
- public bool? GetPortState(int port)
- {
- var r = SnmpGet(PortStateOid(port));
- return r == "3" ? true : (r == "2" || r == "4") ? false : null;
- }
-
- public int? GetPortCurrent(int port) => int.TryParse(SnmpGet(PortCurrentOid(port)), out int v) ? v : null;
- public double? GetPortPowerWatts(int port) => int.TryParse(SnmpGet(PortPowerOid(port)), out int v) ? v / 10.0 : null;
-
- private static string PortControlOid(int port) => $".1.3.6.1.4.1.2468.1.4.2.1.3.2.4.1.2.{port}";
- private static string PortStateOid(int port) => $".1.3.6.1.4.1.2468.1.4.2.1.3.2.3.1.2.{port}";
- private static string PortCurrentOid(int port) => $".1.3.6.1.4.1.2468.1.4.2.1.3.2.3.1.3.{port}";
- private static string PortPowerOid(int port) => $".1.3.6.1.4.1.2468.1.4.2.1.3.2.3.1.5.{port}";
-
- private bool SnmpSet(string oid, AsnType value)
- {
- try
- {
- var param = new AgentParameters(new OctetString(Community)) { Version = SnmpVersion.Ver1 };
- 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);
- return result?.Pdu.ErrorStatus == 0;
- }
- catch (Exception ex) { Log($"SNMP SET {oid} exception: {ex.Message}"); return false; }
- }
-
- private string? SnmpGet(string oid)
- {
- try
- {
- var param = new AgentParameters(new OctetString(Community)) { Version = SnmpVersion.Ver1 };
- 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 == null) { Log($"SNMP GET {oid}: no response (timeout)"); return null; }
- if (result.Pdu.ErrorStatus != 0) { Log($"SNMP GET {oid}: ErrorStatus={result.Pdu.ErrorStatus}"); return null; }
- foreach (var v in result.Pdu.VbList) return v.Value.ToString();
- }
- catch (Exception ex) { Log($"SNMP GET {oid} exception: {ex.Message}"); }
- return null;
- }
-
- // stdio MCP server:stdout 專供 JSON-RPC,診斷一律走 stderr。
- private static void Log(string msg) => Console.Error.WriteLine($"[PDU] {msg}");
-
- public void Dispose() { }
-}
diff --git a/src/ETTerms.PduMcp/PduRegistry.cs b/src/ETTerms.PduMcp/PduRegistry.cs
index a1286ff..5eea9c1 100644
--- a/src/ETTerms.PduMcp/PduRegistry.cs
+++ b/src/ETTerms.PduMcp/PduRegistry.cs
@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
+using ETTerms.PduCore;
namespace ETTerms.PduMcp;
@@ -16,7 +17,10 @@ public sealed class PduRegistry
private readonly ConcurrentDictionary _pdus = new(StringComparer.OrdinalIgnoreCase);
public PduController GetOrAdd(string ip) =>
- _pdus.GetOrAdd(ip.Trim(), static k => new PduController(k));
+ _pdus.GetOrAdd(ip.Trim(), static k => new PduController(k, Log, Log));
+
+ // stdio MCP server:stdout 專供 JSON-RPC,診斷一律走 stderr。
+ private static void Log(string msg) => Console.Error.WriteLine(msg);
public bool TryGet(string ip, out PduController pdu) =>
_pdus.TryGetValue(ip.Trim(), out pdu!);
diff --git a/src/ETTerms.PduMcp/PduTools.cs b/src/ETTerms.PduMcp/PduTools.cs
index 837bed1..47d4eb8 100644
--- a/src/ETTerms.PduMcp/PduTools.cs
+++ b/src/ETTerms.PduMcp/PduTools.cs
@@ -1,6 +1,7 @@
using System.ComponentModel;
using System.Text;
using System.Text.Json;
+using ETTerms.PduCore;
using ModelContextProtocol.Server;
namespace ETTerms.PduMcp;
@@ -65,9 +66,20 @@ public static class PduTools
{
if (!PduRegistry.Instance.TryGet(ip, out var pdu))
return Err($"PDU {ip} not connected. Call pdu_connect first.");
+ // 批次 SNMP GET:12 port 只需 3 個 UDP 來回,而非逐 port 逐 OID 36 個。
+ var all = pdu.GetAllPortsStatus(PduRegistry.PortCount);
var ports = new List