Ver código fonte

feat:fwh-agent-mcp接入ToolBox直连设备的故障录波

- 只读:fault_record_devices、fault_record_device_info、fault_record_param_read(读取类以长任务执行)、
  fault_record_operations、fault_record_operation_get、fault_record_data(故障信息与波形概要)、fault_record_export(CSV zip)
- 写:fault_record_fetch(召唤录波,长任务)、fault_record_param_write(破坏性)、fault_record_cancel、fault_record_retry_download
- MQTT 仍由主服务执行器收发:新增 SubmitFaultRecordOperation(不要求本进程有 MQTT 客户端)、faultRecordQueue 只入队客户端
- 任务状态直接取自 Mongo 中的操作记录;录波文件从 MinIO 读取,kit.ExportFile 以 blob 资源交付
- 云平台录波工具改名为 cloud_fault_record_*,与直连设备的工具区分

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 4 dias atrás
pai
commit
a496b3c89c

+ 4 - 2
deploy/agent-assistant/skills/fwh-assistant/SKILL.md

@@ -22,7 +22,8 @@ description: FranklinWH(FWH)储能业务数据助手。只要问题涉及 Fr
 | 告警 | `alarm_current`(当前告警)、`alarm_events`(时间范围内告警/事件)、`alarm_snapshot`(某日快照)、`alarm_stats`(全量统计)、`alarm_sites_by_code`(触发某告警码的站点) |
 | 停机 | `shutdown_stats`(某日汇总,不传日期取最新)、`shutdown_sites`(某日停机站点) |
 | 日志 | `mqtt_log_search`、`ems_log_records`、`update_log_system`(传记录 ID 看详情)、`update_log_ahub` |
-| 故障录波 | `fault_record_list`、`fault_record_detail`、`fault_record_callable`、`fault_record_call_history` |
+| 故障录波(ToolBox 直连设备) | `fault_record_devices`(站点可操作的设备)、`fault_record_device_info`(读取设备录波信息,类别 3 为录波总目录)、`fault_record_param_read`(读取设备时间、录波开关)、`fault_record_operations`(操作记录)、`fault_record_operation_get`(操作结果)、`fault_record_data`(录波故障信息与波形概要)、`fault_record_export`(录波 CSV) |
+| 故障录波(云平台) | `cloud_fault_record_list`、`cloud_fault_record_detail`、`cloud_fault_record_callable`、`cloud_fault_record_call_history` |
 | 调度 | `tou_template_list`、`tou_template_get`(`env=prod/test`,详情含调度模式识别) |
 | SIM | `sim_get`(自动判断平台)、`sim_search`、`sim_usage_monthly` |
 | 其他 | `ems_param_lookup`(PID/VID 宏名与描述,编号为十进制整数)、`plane_issue_search`、`plane_issue_get`、`sn_parse` |
@@ -31,12 +32,13 @@ description: FranklinWH(FWH)储能业务数据助手。只要问题涉及 Fr
 
 ## 常用流程
 
-- **站点故障排查**:`site_get` → `alarm_current` → `alarm_events`(故障前后各几小时到几天)→ 需要时 `mqtt_log_search`、`fault_record_list` / `fault_record_detail` → 结合资料库中的告警说明给出判断。
+- **站点故障排查**:`site_get` → `alarm_current` → `alarm_events`(故障前后各几小时到几天)→ 需要时 `mqtt_log_search`、故障录波(见下)→ 结合资料库中的告警说明给出判断。
 - **告警码分析**:资料库查告警码含义(`System/AlarmTable.md` 等)→ `alarm_sites_by_code` 看影响面 → 抽样 `site_get` / `alarm_events`。
 - **停机分析**:`shutdown_stats` → `shutdown_sites` → 按国家、版本等维度汇总(站点多时导出或分批,再用 Python 统计)。
 - **功率流分析**:单日用 `site_power_flow`;多日用 `site_power_flow_export`,在工作区用 pandas 分析并出图。
 - **升级问题**:`update_log_system` 找到记录 → 传记录 ID 看详情;aHub 用 `update_log_ahub`。
 - **TOU 模版**:`tou_template_list` → `tou_template_get`;字段与调度模式见 references/tou-template.md。对比生产服与测试服时分别用 `env=prod`、`env=test` 取详情再逐策略、逐时段比较。
+- **故障录波**:`fault_record_operations` 先看是否已有召唤好的录波(`hasFile=true`)→ 有则 `fault_record_data` 看故障时间、触发源与波形概要,需要逐点分析时 `fault_record_export` 后用 Python 解压分析、作图。没有时:`fault_record_devices` 确认设备 → `fault_record_device_info`(`codes=[3]`)看设备上的录波记录序号 → 召唤用写工具 `fault_record_fetch`(见 references/write-operations.md)。读取设备信息、参数会实际向设备发送 MQTT 指令,设备离线时会超时。云平台记录的录波用 `cloud_fault_record_*`。
 - **SIM 卡**:`sim_get` / `sim_search`;用量单位以工具说明为准,月度明细用 `sim_usage_monthly`。
 - **站点信息是否最新**:`site_search` 的数据来自定期同步的站点信息表。用户质疑数据过旧、找不到新激活的站点时,先用 `site_info_sync_status` 查看最近一次同步时间与状态,在回答中说明数据截至何时;需要重新同步见 references/write-operations.md。
 

+ 4 - 0
deploy/agent-assistant/skills/fwh-assistant/references/write-operations.md

@@ -9,6 +9,10 @@
 | `feedback_create` | 在 Plane 创建问题反馈 / 需求 | 调用前先向用户摘要标题、类型(bug / feature)、优先级和描述;描述中写清背景、复现步骤或期望效果 |
 | `tou_template_save` | 新增或更新**测试服** TOU 模版 | 只支持测试服;先按 tou-template.md 的规则构建并自检(时段覆盖整天、日类型成对、数值类型正确),向用户摘要“新增/更新、模版名、地区、策略与关键时段”,保存后用 `mcp__gateway__fwh__tou_template_get`(`env=test`)回读核对 |
 | `site_info_refresh` | 触发站点信息表从 FWH 平台重新同步 | 见下方“站点信息同步” |
+| `fault_record_fetch` | 召唤设备故障录波(设备上传、ToolBox 下载并解析) | 长任务,提交后结束本轮,完成后平台发来结果。先用 `fault_record_device_info`(`codes=[3]`)确认记录序号,向用户说明站点、设备、序号 |
+| `fault_record_param_write` | 设置设备故障录波参数或执行动作 | **破坏性,每次都审批。** 功能码 1 清除设备上全部故障波形(不可恢复)、3 立即触发一次录波、4 修改设备时间,调用前必须向用户写明设备与功能码、值并说明影响;设置前先读取当前值 |
+| `fault_record_cancel` | 取消进行中的故障录波操作 | 只取消用户要求取消的操作 |
+| `fault_record_retry_download` | 录波已上传但下载失败时重试下载 | 只在操作结果显示上传完成、下载失败时使用 |
 
 ## 站点信息同步
 

+ 9 - 1
deploy/config.agent-mcp.env.example

@@ -6,7 +6,7 @@
 #           与主服务的 config.env、旧 MCP 的 config.mcp.env 互不共用;环境变量可覆盖文件内配置。
 #           文件不存在时服务拒绝启动。
 #
-# 说明:只包含 fwh-agent-mcp 用到的配置子集(数据库 / 缓存、FWH 生产与测试 API、Plane Web)。
+# 说明:只包含 fwh-agent-mcp 用到的配置子集(数据库 / 缓存、MinIO 只读、FWH 生产与测试 API、Plane Web)。
 #       设计见 docs/ai-assistant-agentserver/agent-mcp.md 第 6 节。
 # =============================================================================
 
@@ -30,6 +30,14 @@ MONGO_DSN="mongodb://127.0.0.1:27017/FwhToolboxDb"
 # Redis 地址(缓存)
 REDIS_ADDR="localhost:6379"
 
+# ---------- MinIO(只读)----------
+# 故障录波文件(fault-record/ 前缀)的读取:fault_record_data、fault_record_export。
+# 使用 AI 专用账号 fwh-toolbox-ai(对 fwh-toolbox 桶只读,由 provision_minio.sh 创建),与旧 MCP 的 config.mcp.env 相同
+MINIO_ENDPOINT="localhost:9198"
+MINIO_ACCESS_KEY="fwh-toolbox-ai"
+MINIO_SECRET_KEY="CHANGE_ME"
+MINIO_BUCKET_NAME="fwh-toolbox"
+
 # ---------- FWH API(生产)----------
 FWH_ENERGY="energy.franklinwh.com"
 FWH_OSS="oss.franklinwh.com"

+ 13 - 4
docs/ai-assistant-agentserver/agent-mcp.md

@@ -188,10 +188,16 @@ schema 由 mcp-go 依赖的 `google/jsonschema-go` 生成:`jsonschema` 标签
 | | `ems_log_records` | EMS 日志召唤记录 | FWH Pro API |
 | | `update_log_system` | 系统升级记录;传记录 ID 时返回详情 | FWH Pro API |
 | | `update_log_ahub` | aHub 升级记录 | FWH Pro API |
-| 故障录波 | `fault_record_list` | 故障录波日志列表 | FWH Pro API |
-| | `fault_record_detail` | 录波子记录 | FWH Pro API |
-| | `fault_record_callable` | 设备可召唤的录波列表 | FWH Pro API |
-| | `fault_record_call_history` | 录波召唤记录 | FWH Pro API |
+| 故障录波(直连设备) | `fault_record_devices` | 站点可做录波操作的设备(aGate、aPower-PE/MPPT、aHub) | `GetFaultRecordSiteDevices` |
+| | `fault_record_device_info` | 读取设备录波信息(故障总体信息、录波功能基本信息、录波总目录);长任务,经主服务 MQTT 收发 | `SubmitFaultRecordOperation`(device_info) |
+| | `fault_record_param_read` | 读取设备时间、录波开关;长任务 | 同上(param_read) |
+| | `fault_record_operations` / `fault_record_operation_get` | 操作记录、操作结果(逐项解析,不含原始报文) | `FaultRecordOperations` 集合 |
+| | `fault_record_data` | 解析录波文件:故障信息与波形概要(各通道 min、max、触发点值,不含逐点数据) | MinIO `fault-record/` |
+| | `fault_record_export` | 录波 CSV(zip,blob 资源) | 同上 |
+| 故障录波(云平台) | `cloud_fault_record_list` | 云平台故障录波日志列表 | FWH Pro API |
+| | `cloud_fault_record_detail` | 录波子记录 | FWH Pro API |
+| | `cloud_fault_record_callable` | 设备可召唤的录波列表 | FWH Pro API |
+| | `cloud_fault_record_call_history` | 录波召唤记录 | FWH Pro API |
 | 调度 | `tou_template_list` | TOU 模版列表,`env=prod/test` | 调度配置服务 |
 | | `tou_template_get` | TOU 模版详情:按季节 × 日类型整理时段,附调度模式识别结果 | 同上 + 调度配置文档中的解析规则 |
 | SIM | `sim_get` | 按 ICCID 查询,自动判断平台(SmartHub / Acceleronix) | SIM 卡管理服务 |
@@ -288,6 +294,9 @@ Profile `fwh-assistant` 勾选 `fwh`。工具使用的业务说明(字段含
 | `feedback_create` | 在 Plane 创建 Intake Issue | 提交人固定为服务账号“AI 助手”;描述中由 Agent 写明背景。需要真实提交人时由 ToolBox 页面提交,不经过 MCP |
 | `tou_template_save` | 保存测试服 TOU 模版 | 只允许测试服;写入前按调度配置需求中的规则校验,返回差异 |
 | `site_info_refresh` | 触发站点信息表同步,以长任务执行(5.4) | 触发前检查同步状态(与 `site_info_sync_status` 相同的判断):同步中改为跟踪,不重复触发;最近 24 小时内已成功、或超过 6 小时未完成疑似中断时不触发,`force=true` 可强制。同步结束后平台把结果发给模型。任务投递到主服务的队列执行(MCP 只创建入队客户端与 Inspector,Redis 库号与主服务一致) |
+| `fault_record_fetch` | 召唤设备故障录波,长任务 | 经主服务执行器收发 MQTT(MCP 不连 MQTT,只提交与读取 Mongo 中的操作);任务跟随操作记录 |
+| `fault_record_param_write` | 设置设备录波参数或执行动作,长任务 | 破坏性(清除波形不可恢复),每次审批 |
+| `fault_record_cancel` / `fault_record_retry_download` | 取消操作;上传完成但下载失败时重试下载 | |
 
 无用户模型下,写工具的“谁做的”只记录在 ToolBox 会话归属和 AgentServer 审计中,业务系统里看到的是服务账号。如果某个写操作在业务上必须归属到具体的人,就不放进 MCP。
 

+ 3 - 1
service/agentmcp/agentmcp_test.go

@@ -84,7 +84,9 @@ func TestReadOnlyToolList(t *testing.T) {
 		"alarm_current", "alarm_events", "alarm_snapshot", "alarm_stats", "alarm_sites_by_code",
 		"shutdown_stats", "shutdown_sites",
 		"mqtt_log_search", "ems_log_records", "update_log_system", "update_log_ahub",
-		"fault_record_list", "fault_record_detail", "fault_record_callable", "fault_record_call_history",
+		"fault_record_devices", "fault_record_device_info", "fault_record_param_read", "fault_record_operations", "fault_record_operation_get",
+		"fault_record_data", "fault_record_export",
+		"cloud_fault_record_list", "cloud_fault_record_detail", "cloud_fault_record_callable", "cloud_fault_record_call_history",
 		"tou_template_list", "tou_template_get",
 		"sim_get", "sim_search", "sim_usage_monthly",
 		"ems_param_lookup", "plane_issue_search", "plane_issue_get", "sn_parse",

+ 9 - 3
service/agentmcp/bootstrap.go

@@ -17,8 +17,10 @@ import (
 	"fwh-toolbox-server/logger"
 	"fwh-toolbox-server/repositories"
 	task_core "fwh-toolbox-server/task/core"
+	"fwh-toolbox-server/task/queue/faultRecordQueue"
 	"fwh-toolbox-server/task/queue/fwhQueue"
 	"fwh-toolbox-server/utils/emspara"
+	"fwh-toolbox-server/utils/minio"
 )
 
 // DefaultConfigFile fwh-agent-mcp 的独立配置文件(相对工作目录),与主服务的 config.env、旧 MCP 的 config.mcp.env 互不共用。
@@ -95,7 +97,7 @@ func InitLogger(path string) *zap.Logger {
 	return l
 }
 
-// InitInfra 从 configFile 加载配置并初始化工具用到的基础设施:数据库、Redis、FWH 生产/测试 API、Plane。
+// InitInfra 从 configFile 加载配置并初始化工具用到的基础设施:数据库、Redis、MinIO、FWH 生产/测试 API、Plane、任务队列入队客户端。
 func InitInfra(configFile string) error {
 	cfg, err := config.LoadConfig(configFile)
 	if err != nil {
@@ -114,7 +116,11 @@ func InitInfra(configFile string) error {
 	repositories.FwhTestHttpInit(&cfg.FwhTestApi)
 	// 必须用 InitPlane(含 Feedback 专用实例 repositories.Plane),否则 feedback_create 会空指针
 	repositories.InitPlane(&cfg.PlaneWebApi)
-	// 只入队、不启动 worker:site_info_refresh 投递的任务由主服务执行,否则 TaskClient 为空会 panic
-	fwhQueue.InitEnqueueClient(asynq.RedisClientOpt{Addr: cfg.App.RedisAddr, DB: task_core.RedisDB})
+	// 只入队、不启动 worker:site_info_refresh、故障录波操作投递的任务由主服务执行(含 MQTT 收发)
+	taskRedis := asynq.RedisClientOpt{Addr: cfg.App.RedisAddr, DB: task_core.RedisDB}
+	fwhQueue.InitEnqueueClient(taskRedis)
+	faultRecordQueue.InitEnqueueClient(taskRedis)
+	// 故障录波文件(fault-record/ 前缀)的读取;使用 AI 只读账号即可
+	minio.InitClient(&cfg.App)
 	return nil
 }

+ 24 - 0
service/agentmcp/kit/result.go

@@ -4,12 +4,14 @@ import (
 	"bytes"
 	"context"
 	"crypto/rand"
+	"encoding/base64"
 	"encoding/csv"
 	"encoding/hex"
 	"encoding/json"
 	"errors"
 	"fmt"
 	"net"
+	"path"
 	"reflect"
 	"strings"
 	"unicode/utf8"
@@ -203,6 +205,28 @@ func tsvLine(cols []string, row []string) string {
 	return strings.Join(cells, "\t")
 }
 
+// ExportFile 导出二进制文件(如 zip),以 EmbeddedResource(blob,base64)返回并附文本摘要。
+// AgentServer 网关把资源保存到会话工作区 .agent/mcp/,文件名取 URI 最后一段,所以 URI 要以文件名结尾。
+// 内容不做 PII 脱敏,只用于不含个人信息的数据(如设备录波)。
+func ExportFile(uri, mimeType string, data []byte, summary string) *mcp.CallToolResult {
+	if len(data) == 0 {
+		return Empty("没有可导出的数据。")
+	}
+	if len(data) > MaxExportBytes {
+		return Failf("导出文件超过 %d MB,无法交付", MaxExportBytes>>20)
+	}
+	text := fmt.Sprintf("已导出 %s(%s,%.1f KB)。", path.Base(uri), mimeType, float64(len(data))/1024)
+	if summary != "" {
+		text += "\n" + summary
+	}
+	return &mcp.CallToolResult{
+		Content: []mcp.Content{
+			mcp.NewTextContent(text),
+			mcp.NewEmbeddedResource(mcp.BlobResourceContents{URI: uri, MIMEType: mimeType, Blob: base64.StdEncoding.EncodeToString(data)}),
+		},
+	}
+}
+
 // Export 导出:CSV 以 EmbeddedResource(text/csv)返回,并附文本摘要。
 // AgentServer 网关会把资源内容保存到会话工作区(agent-mcp.md 5.2)。
 func Export(uri string, cols []string, rows [][]string, summary string) *mcp.CallToolResult {

+ 19 - 19
service/agentmcp/tools/faultrecord.go

@@ -13,7 +13,7 @@ import (
 // deviceTypeDesc 故障录波设备类型说明
 const deviceTypeDesc = "deviceType:0 aPower-PE、1 aGate/MCA、2 aPower-MPPT、3 aHub"
 
-// ---- fault_record_list ----
+// ---- cloud_fault_record_list ----
 
 type faultRecordListArgs struct {
 	SN    string `json:"sn" jsonschema:"站点 SN(aGate 序列号)"`
@@ -22,10 +22,10 @@ type faultRecordListArgs struct {
 	Limit int    `json:"limit,omitempty" jsonschema:"最多返回条数,1~300,默认 100"`
 }
 
-var FaultRecordList = kit.Def[faultRecordListArgs]{
-	Name:  "fault_record_list",
-	Title: "查询故障录波记录",
-	Description: "查询站点在一段时间内的故障录波日志(FWH 平台)。返回字段:id(录波记录 ID,用于 fault_record_detail)、" + deviceTypeDesc +
+var CloudFaultRecordList = kit.Def[faultRecordListArgs]{
+	Name:  "cloud_fault_record_list",
+	Title: "查询云平台故障录波记录",
+	Description: "查询站点在一段时间内由 FWH 云平台记录的故障录波日志(云平台通道,与 ToolBox 直连设备的 fault_record_* 是两套数据)。返回字段:id(录波记录 ID,用于 cloud_fault_record_detail)、" + deviceTypeDesc +
 		"、apowerId / powerlinkId(对应设备 SN)、gatewayId、triggerType(1 手动、2 自动)、触发时间与状态等(以实际返回为准)。",
 	ReadOnly: true,
 	Handler: func(ctx context.Context, a faultRecordListArgs) (*mcp.CallToolResult, error) {
@@ -45,15 +45,15 @@ var FaultRecordList = kit.Def[faultRecordListArgs]{
 	},
 }
 
-// ---- fault_record_detail ----
+// ---- cloud_fault_record_detail ----
 
 type faultRecordDetailArgs struct {
-	FaultID int64 `json:"faultId" jsonschema:"故障录波记录 ID(fault_record_list 返回的 id)"`
+	FaultID int64 `json:"faultId" jsonschema:"故障录波记录 ID(cloud_fault_record_list 返回的 id)"`
 }
 
-var FaultRecordDetail = kit.Def[faultRecordDetailArgs]{
-	Name:  "fault_record_detail",
-	Title: "查询故障录波子记录",
+var CloudFaultRecordDetail = kit.Def[faultRecordDetailArgs]{
+	Name:  "cloud_fault_record_detail",
+	Title: "查询云平台故障录波子记录",
 	Description: "查询一条故障录波记录下的子记录(各设备的录波文件)。返回字段:id、faultId、" + deviceTypeDesc +
 		"、apowerId、gatewayId、fileName(不含后缀)、文件状态与时间等(以实际返回为准)。",
 	ReadOnly: true,
@@ -69,16 +69,16 @@ var FaultRecordDetail = kit.Def[faultRecordDetailArgs]{
 	},
 }
 
-// ---- fault_record_callable ----
+// ---- cloud_fault_record_callable ----
 
 type faultRecordCallableArgs struct {
 	SN         string `json:"sn" jsonschema:"设备 SN(aGate / aPower / MPPT / aHub)"`
 	DeviceType *int   `json:"deviceType,omitempty" jsonschema:"设备类型:0 aPower、1 aGate(默认)、2 MPPT、3 aHub"`
 }
 
-var FaultRecordCallable = kit.Def[faultRecordCallableArgs]{
-	Name:  "fault_record_callable",
-	Title: "查询可召唤的故障录波",
+var CloudFaultRecordCallable = kit.Def[faultRecordCallableArgs]{
+	Name:  "cloud_fault_record_callable",
+	Title: "查询云平台可召唤的故障录波",
 	Description: "查询设备上可召唤(尚未上传)的自动故障录波数据列表(FWH 平台)。" +
 		"返回字段:idx / indexNumber(数据序号)、failureTime(故障时间)、paramVersionNumber(故障信息)、triggerSourceCode(故障码)。",
 	ReadOnly: true,
@@ -105,18 +105,18 @@ var FaultRecordCallable = kit.Def[faultRecordCallableArgs]{
 	},
 }
 
-// ---- fault_record_call_history ----
+// ---- cloud_fault_record_call_history ----
 
 type faultRecordCallHistoryArgs struct {
 	Page int `json:"page,omitempty" jsonschema:"页码,默认 1"`
 	Size int `json:"size,omitempty" jsonschema:"每页条数,1~100,默认 20"`
 }
 
-var FaultRecordCallHistory = kit.Def[faultRecordCallHistoryArgs]{
-	Name:  "fault_record_call_history",
-	Title: "查询故障录波召唤记录",
+var CloudFaultRecordCallHistory = kit.Def[faultRecordCallHistoryArgs]{
+	Name:  "cloud_fault_record_call_history",
+	Title: "查询云平台故障录波召唤记录",
 	Description: "分页查询 FWH 平台上全部站点的故障录波召唤记录(手动召唤的历史),按时间倒序。" +
-		"返回字段与 fault_record_list 相同。查看单个站点的录波请用 fault_record_list。",
+		"返回字段与 cloud_fault_record_list 相同。查看单个站点的录波请用 cloud_fault_record_list。",
 	ReadOnly: true,
 	Handler: func(ctx context.Context, a faultRecordCallHistoryArgs) (*mcp.CallToolResult, error) {
 		size := kit.Limit(a.Size, 20, 100)

+ 641 - 0
service/agentmcp/tools/faultrecorddev.go

@@ -0,0 +1,641 @@
+package tools
+
+// 故障录波(ToolBox 直连设备,经 MQTT 与设备交互;与云平台的 cloud_fault_record_* 是两套数据)。
+// 操作由主服务的执行器收发 MQTT,状态保存在 Mongo(FaultRecordOperations);本服务只提交操作、读取状态与文件。
+// 设备上的操作以长任务(kit.TaskDef)执行:任务 id 携带操作 id,状态直接取自操作记录。
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/mark3labs/mcp-go/mcp"
+
+	"fwh-toolbox-server/agentmcp/kit"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/dto"
+	faultRecordFile "fwh-toolbox-server/function/FaultRecordFile"
+	proto "fwh-toolbox-server/function/FaultRecordProtocol"
+	mongoRepo "fwh-toolbox-server/repositories/mongo"
+	"fwh-toolbox-server/services"
+)
+
+const (
+	frEnvDesc        = "服务器环境:prod(生产,默认)/ test(测试)"
+	frDeviceKindDesc = "设备:aGate(默认)、aPowerPE、aPowerMPPT、aHub;站点有哪些设备、是否支持见 fault_record_devices"
+	frDeviceSnDesc   = "设备 SN;aGate 可不填(即站点 SN),其他设备必填(见 fault_record_devices)"
+	// frSyncWait 普通调用(非任务方式)时等待设备回复的最长时间,低于单次调用 60 秒超时
+	frSyncWait = 45 * time.Second
+)
+
+var frDeviceProps = map[string]map[string]any{
+	"env":        {"enum": []string{"prod", "test"}},
+	"deviceKind": {"enum": []string{proto.DeviceKindAGate, proto.DeviceKindAPowerPE, proto.DeviceKindAPowerMPPT, proto.DeviceKindAHub}},
+}
+
+// frStageMessages 进行中各阶段的说明(显示在平台的后台调用栏)
+var frStageMessages = map[domain.FaultRecordOpStatus]string{
+	domain.FaultRecordStatusPending:    "等待发送到设备",
+	domain.FaultRecordStatusSent:       "已发送,等待设备确认",
+	domain.FaultRecordStatusAccepted:   "设备已接受,等待结果",
+	domain.FaultRecordStatusReady:      "设备已准备录波文件,等待上传",
+	domain.FaultRecordStatusUploading:  "设备正在上传录波文件",
+	domain.FaultRecordStatusUploaded:   "上传完成,正在下载",
+	domain.FaultRecordStatusDownloaded: "文件已保存,正在解析",
+}
+
+// frFailMessages 失败终态的说明(写给模型)
+var frFailMessages = map[domain.FaultRecordOpStatus]string{
+	domain.FaultRecordStatusBusy:           "设备正在处理其他命令,稍后重试",
+	domain.FaultRecordStatusRejected:       "设备拒绝了请求",
+	domain.FaultRecordStatusTimeout:        "等待设备超时(设备可能离线或无响应)",
+	domain.FaultRecordStatusUploadFailed:   "设备上传录波文件失败",
+	domain.FaultRecordStatusDownloadFailed: "录波文件下载失败,可以用 fault_record_retry_download 重试下载",
+	domain.FaultRecordStatusParseFailed:    "录波文件已保存但解析失败,可以用 fault_record_export 导出原始文件",
+	domain.FaultRecordStatusInterrupted:    "服务重启时操作中断",
+	domain.FaultRecordStatusGatewayLocked:  "该网关已有其他录波操作在进行,等它结束后再试",
+	domain.FaultRecordStatusError:          "操作出错(如主服务 MQTT 不可用)",
+}
+
+type frDeviceArgs struct {
+	SN         string `json:"sn" jsonschema:"站点 SN(aGate 序列号)"`
+	Env        string `json:"env,omitempty" jsonschema:"服务器环境:prod(生产,默认)/ test(测试)"`
+	DeviceKind string `json:"deviceKind,omitempty" jsonschema:"设备:aGate(默认)、aPowerPE、aPowerMPPT、aHub;站点有哪些设备、是否支持见 fault_record_devices"`
+	DeviceSn   string `json:"deviceSn,omitempty" jsonschema:"设备 SN;aGate 可不填(即站点 SN),其他设备必填(见 fault_record_devices)"`
+}
+
+// frRequest 由设备参数构造操作请求
+func frRequest(a frDeviceArgs, kind domain.FaultRecordOpKind) (dto.FaultRecordCreateOpReq, error) {
+	sn, err := kit.Required("sn", a.SN)
+	if err != nil {
+		return dto.FaultRecordCreateOpReq{}, err
+	}
+	env, err := kit.Enum("env", a.Env, "prod", "prod", "test")
+	if err != nil {
+		return dto.FaultRecordCreateOpReq{}, err
+	}
+	devKind := a.DeviceKind
+	if devKind == "" {
+		devKind = proto.DeviceKindAGate
+	}
+	devType, ok := proto.DeviceKindProtocolType[devKind]
+	if !ok || devType == "" {
+		return dto.FaultRecordCreateOpReq{}, fmt.Errorf("不支持的设备:%s(可选 aGate、aPowerPE、aPowerMPPT、aHub)", devKind)
+	}
+	devSn := strings.TrimSpace(a.DeviceSn)
+	if devKind != proto.DeviceKindAGate && devSn == "" {
+		return dto.FaultRecordCreateOpReq{}, fmt.Errorf("%s 需要 deviceSn,可用 fault_record_devices 查看", devKind)
+	}
+	return dto.FaultRecordCreateOpReq{Env: env, GatewaySn: sn, DeviceType: devType, DeviceSn: devSn, Kind: string(kind)}, nil
+}
+
+// frSubmit 提交操作(由主服务执行),返回操作 id
+func frSubmit(ctx context.Context, req dto.FaultRecordCreateOpReq) (string, *mcp.CallToolResult) {
+	id, err := services.SubmitFaultRecordOperation(ctx, req, ServiceAccount)
+	if errors.Is(err, services.ErrFaultRecordInvalid) {
+		return "", kit.Fail(err)
+	}
+	if err != nil {
+		return "", kit.Upstream("ToolBox 故障录波", err)
+	}
+	return id, nil
+}
+
+// frOpView 写给模型的操作结果:逐项结果,不含原始报文
+func frOpView(d *dto.FaultRecordOpDetail) map[string]any {
+	out := map[string]any{
+		"operationId": d.ID,
+		"kind":        d.Kind,
+		"env":         d.Env,
+		"gatewaySn":   d.GatewaySn,
+		"deviceType":  d.DeviceType,
+		"deviceSn":    d.DeviceSn,
+		"request":     d.Summary,
+		"status":      d.Status,
+		"operator":    d.Operator,
+		"createdAt":   fmtBeijing(d.CreatedAt),
+	}
+	if d.Error != "" {
+		out["error"] = d.Error
+	}
+	if d.TimeoutStage != "" {
+		out["timeoutStage"] = d.TimeoutStage
+	}
+	if d.ReplyDesc != "" {
+		out["reply"] = d.ReplyDesc
+	}
+	if len(d.Items) > 0 {
+		items := make([]map[string]any, 0, len(d.Items))
+		for _, it := range d.Items {
+			m := map[string]any{"name": it.Name, "ok": it.Ok, "result": it.ResultDesc}
+			if it.Code != 0 {
+				m["code"] = it.Code
+			}
+			if it.Id != 0 {
+				m["id"] = it.Id
+			}
+			if it.SetVal != "" {
+				m["setVal"] = it.SetVal
+			}
+			if it.Decoded != nil {
+				m["value"] = it.Decoded
+			} else if it.Raw != "" {
+				m["raw"] = it.Raw
+			}
+			if it.DecodeErr != "" {
+				m["decodeError"] = it.DecodeErr
+			}
+			items = append(items, m)
+		}
+		out["items"] = items
+	}
+	if d.HasFile {
+		out["hasFile"] = true
+		out["next"] = "录波数据:fault_record_data 查看故障信息与波形概要,fault_record_export 导出 CSV"
+	}
+	return out
+}
+
+// frTaskState 把操作记录翻译为任务状态
+func frTaskState(d *dto.FaultRecordOpDetail) kit.TaskState {
+	st := kit.TaskState{CreatedAt: d.CreatedAt}
+	status := domain.FaultRecordOpStatus(d.Status)
+	switch {
+	case !status.Terminal():
+		st.Status = mcp.TaskStatusWorking
+		st.Message = frStageMessages[status]
+		if st.Message == "" {
+			st.Message = d.Status
+		}
+	case status == domain.FaultRecordStatusDone || status == domain.FaultRecordStatusItemFailed:
+		// item_failed:设备已回复,但部分子项失败,逐项结果中有说明
+		st.Status, st.Message = mcp.TaskStatusCompleted, "完成"
+		if status == domain.FaultRecordStatusItemFailed {
+			st.Message = "完成,部分子项失败"
+		}
+		st.Result = kit.Object(frOpView(d))
+	case status == domain.FaultRecordStatusCancelled:
+		st.Status, st.Message = mcp.TaskStatusCancelled, "已取消"
+		st.Result = kit.Failf("故障录波操作已取消(操作 id %s)。", d.ID)
+	default:
+		st.Status = mcp.TaskStatusFailed
+		reason := frFailMessages[status]
+		if reason == "" {
+			reason = d.Status
+		}
+		st.Message = reason
+		view := frOpView(d)
+		view["reason"] = reason
+		st.Result = kit.Object(view)
+		st.Result.IsError = true
+	}
+	return st
+}
+
+// frStatus 读取操作并翻译为任务状态(kit.TaskDef.Status)
+func frStatus(ctx context.Context, ref kit.TaskRef) (kit.TaskState, error) {
+	d, err := services.GetFaultRecordOperation(ctx, ref.Key)
+	if errors.Is(err, mongoRepo.ErrFaultRecordOpNotFound) || errors.Is(err, services.ErrFaultRecordInvalid) {
+		return kit.TaskState{}, kit.ErrTaskNotFound
+	}
+	if err != nil {
+		return kit.TaskState{}, err
+	}
+	return frTaskState(d), nil
+}
+
+// frCancel 请求取消操作(执行器 1 秒内响应);已结束的操作无需取消
+func frCancel(ctx context.Context, ref kit.TaskRef) error {
+	err := services.CancelFaultRecordOperation(ctx, ref.Key)
+	if errors.Is(err, services.ErrFaultRecordInvalid) {
+		return nil
+	}
+	return err
+}
+
+// frWait 普通调用时等待操作结束(最多 frSyncWait),未结束时返回进度与操作 id
+func frWait(ctx context.Context, id string) *mcp.CallToolResult {
+	deadline := time.Now().Add(frSyncWait)
+	for {
+		d, err := services.GetFaultRecordOperation(ctx, id)
+		if err != nil {
+			return kit.Upstream("ToolBox 故障录波", err)
+		}
+		st := frTaskState(d)
+		if st.Status.IsTerminal() {
+			return st.Result
+		}
+		if time.Now().After(deadline) {
+			return kit.Object(map[string]any{"operationId": id, "status": d.Status, "message": st.Message + ";设备还没有回复完,稍后用 fault_record_operation_get 查看结果"})
+		}
+		select {
+		case <-ctx.Done():
+			return kit.Object(map[string]any{"operationId": id, "status": d.Status, "message": "等待中断,稍后用 fault_record_operation_get 查看结果"})
+		case <-time.After(time.Second):
+		}
+	}
+}
+
+// frSubmitted 普通调用提交耗时较长的操作后立即返回
+func frSubmitted(id, what string) *mcp.CallToolResult {
+	return kit.Object(map[string]any{"operationId": id, "status": "pending", "message": "已提交" + what + ",由主服务与设备交互;用 fault_record_operation_get 查看进度与结果"})
+}
+
+// ---- fault_record_devices ----
+
+type frDevicesArgs struct {
+	SN  string `json:"sn" jsonschema:"站点 SN(aGate 序列号)"`
+	Env string `json:"env,omitempty" jsonschema:"服务器环境:prod(生产,默认)/ test(测试)"`
+}
+
+var FaultRecordDevices = kit.Def[frDevicesArgs]{
+	Name:  "fault_record_devices",
+	Title: "查询站点可做故障录波的设备",
+	Description: "列出站点中可以做故障录波操作的设备:aGate、各 aPower-PE、带 MPPT 的 aPower-MPPT、各 aHub,以及站点是否在线。" +
+		"返回 devices:kind(设备种类,作为其他 fault_record_* 工具的 deviceKind)、sn(作为 deviceSn)、model、supported(是否可操作)、note(不可操作的原因)。" +
+		"对设备做读取、召唤、设置前先用本工具确认设备与 SN。",
+	ReadOnly:   true,
+	Properties: map[string]map[string]any{"env": {"enum": []string{"prod", "test"}}},
+	Handler: func(ctx context.Context, a frDevicesArgs) (*mcp.CallToolResult, error) {
+		sn, err := kit.Required("sn", a.SN)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		env, err := kit.Enum("env", a.Env, "prod", "prod", "test")
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		d, err := services.GetFaultRecordSiteDevices(env, sn)
+		if errors.Is(err, services.ErrFaultRecordInvalid) {
+			return kit.Fail(err), nil
+		}
+		if err != nil {
+			return kit.Upstream("FWH 平台设备信息接口", err), nil
+		}
+		return kit.Object(d), nil
+	},
+}
+
+// ---- fault_record_device_info ----
+
+type frDeviceInfoArgs struct {
+	frDeviceArgs
+	Codes []int `json:"codes,omitempty" jsonschema:"要读取的信息类别:1 故障总体信息(含快照、可配通道当前值)、2 录波功能基本信息(含采样频率)、3 故障录波总目录(设备上已有的录波记录序号,召唤录波前先读);默认全部"`
+}
+
+var FaultRecordDeviceInfo = kit.TaskDef[frDeviceInfoArgs]{
+	Def: kit.Def[frDeviceInfoArgs]{
+		Name:  "fault_record_device_info",
+		Title: "读取设备故障录波信息",
+		Description: "通过 MQTT 从设备实时读取故障录波相关信息(不修改设备):故障总体信息、录波功能基本信息、故障录波总目录。" +
+			"召唤录波(fault_record_fetch)前用类别 3 查看设备上有哪些录波记录序号。设备通常几秒到几十秒内回复;" +
+			"返回 items(逐项结果,value 为解析后的内容)。设备离线时会超时失败。",
+		ReadOnly:   true,
+		Properties: mergeProps(frDeviceProps, map[string]map[string]any{"codes": {"items": map[string]any{"type": "integer", "enum": []int{1, 2, 3}}}}),
+		Handler: func(ctx context.Context, a frDeviceInfoArgs) (*mcp.CallToolResult, error) {
+			id, fail := frStartDeviceInfo(ctx, a)
+			if fail != nil {
+				return fail, nil
+			}
+			return frWait(ctx, id), nil
+		},
+	},
+	Poll: 5 * time.Second,
+	Start: func(ctx context.Context, a frDeviceInfoArgs) (kit.TaskStart, *mcp.CallToolResult) {
+		id, fail := frStartDeviceInfo(ctx, a)
+		return kit.TaskStart{Key: id, Flag: "op", Note: "已向设备发送读取请求,设备回复后平台会把结果发给你"}, fail
+	},
+	Status: frStatus,
+	Cancel: frCancel,
+}
+
+func frStartDeviceInfo(ctx context.Context, a frDeviceInfoArgs) (string, *mcp.CallToolResult) {
+	req, err := frRequest(a.frDeviceArgs, domain.FaultRecordOpDeviceInfo)
+	if err != nil {
+		return "", kit.Fail(err)
+	}
+	req.CodeList = a.Codes
+	if len(req.CodeList) == 0 {
+		req.CodeList = []int{1, 2, 3}
+	}
+	return frSubmit(ctx, req)
+}
+
+// ---- fault_record_param_read ----
+
+type frParamReadArgs struct {
+	frDeviceArgs
+	Ids []int `json:"ids,omitempty" jsonschema:"要读取的功能码:4 设备时间、7 录波开关;默认全部。采样频率(5)、可配采样通道(6)的当前值在 fault_record_device_info 的类别 2、1 中"`
+}
+
+var FaultRecordParamRead = kit.TaskDef[frParamReadArgs]{
+	Def: kit.Def[frParamReadArgs]{
+		Name:  "fault_record_param_read",
+		Title: "读取设备故障录波参数",
+		Description: "通过 MQTT 从设备读取故障录波参数的当前值(不修改设备):设备时间(功能码 4)、录波开关(功能码 7)。" +
+			"返回 items(逐项结果,value 为解析后的值)。设备离线时会超时失败。",
+		ReadOnly:   true,
+		Properties: mergeProps(frDeviceProps, map[string]map[string]any{"ids": {"items": map[string]any{"type": "integer", "enum": []int{4, 7}}}}),
+		Handler: func(ctx context.Context, a frParamReadArgs) (*mcp.CallToolResult, error) {
+			id, fail := frStartParamRead(ctx, a)
+			if fail != nil {
+				return fail, nil
+			}
+			return frWait(ctx, id), nil
+		},
+	},
+	Poll: 5 * time.Second,
+	Start: func(ctx context.Context, a frParamReadArgs) (kit.TaskStart, *mcp.CallToolResult) {
+		id, fail := frStartParamRead(ctx, a)
+		return kit.TaskStart{Key: id, Flag: "op", Note: "已向设备发送读取请求,设备回复后平台会把结果发给你"}, fail
+	},
+	Status: frStatus,
+	Cancel: frCancel,
+}
+
+func frStartParamRead(ctx context.Context, a frParamReadArgs) (string, *mcp.CallToolResult) {
+	req, err := frRequest(a.frDeviceArgs, domain.FaultRecordOpParamRead)
+	if err != nil {
+		return "", kit.Fail(err)
+	}
+	req.IdList = a.Ids
+	if len(req.IdList) == 0 {
+		req.IdList = []int{4, 7}
+	}
+	return frSubmit(ctx, req)
+}
+
+// ---- fault_record_operations ----
+
+type frOperationsArgs struct {
+	SN   string `json:"sn,omitempty" jsonschema:"站点 SN;不填查全部站点"`
+	Env  string `json:"env,omitempty" jsonschema:"服务器环境:prod / test;不填查全部"`
+	Kind string `json:"kind,omitempty" jsonschema:"操作类型,多个用逗号分隔:device_info(读取信息)、record_fetch(召唤录波)、param_read(读取参数)、param_write(设置参数)"`
+	Page int    `json:"page,omitempty" jsonschema:"页码,默认 1"`
+	Size int    `json:"size,omitempty" jsonschema:"每页条数,1~100,默认 20"`
+}
+
+var FaultRecordOperations = kit.Def[frOperationsArgs]{
+	Name:  "fault_record_operations",
+	Title: "查询故障录波操作记录",
+	Description: "分页查询 ToolBox 对设备做过的故障录波操作(读取信息、召唤录波、读取/设置参数),按时间倒序,包括页面上的操作与 AI 助手的操作。" +
+		"返回 total 与 items:id(操作 id,用于 fault_record_operation_get、fault_record_data、fault_record_export)、kind、gatewaySn、deviceSn、summary(请求摘要)、" +
+		"status、operator、hasFile(是否有已保存的录波文件)、createdAt。",
+	ReadOnly:   true,
+	Properties: map[string]map[string]any{"env": {"enum": []string{"prod", "test"}}},
+	Handler: func(ctx context.Context, a frOperationsArgs) (*mcp.CallToolResult, error) {
+		size := kit.Limit(a.Size, 20, 100)
+		page := kit.Page(a.Page)
+		res, err := services.ListFaultRecordOperations(ctx, mongoRepo.FaultRecordOpFilter{
+			Env: a.Env, GatewaySn: strings.TrimSpace(a.SN), Kind: a.Kind,
+		}, int64(page), int64(size))
+		if err != nil {
+			return kit.Upstream("ToolBox 故障录波", err), nil
+		}
+		items := make([]map[string]any, 0, len(res.Data))
+		for _, s := range res.Data {
+			m := map[string]any{
+				"id": s.ID, "env": s.Env, "kind": s.Kind, "gatewaySn": s.GatewaySn, "deviceType": s.DeviceType, "deviceSn": s.DeviceSn,
+				"summary": s.Summary, "status": s.Status, "operator": s.Operator, "hasFile": s.HasFile, "createdAt": fmtBeijing(s.CreatedAt),
+			}
+			if s.Error != "" {
+				m["error"] = s.Error
+			}
+			items = append(items, m)
+		}
+		return kit.List(items, int(res.Total), size, "用 page 翻页,或用 sn、kind 缩小范围"), nil
+	},
+}
+
+// ---- fault_record_operation_get ----
+
+type frOperationGetArgs struct {
+	ID string `json:"id" jsonschema:"操作 id(fault_record_operations 或各操作工具返回的 operationId)"`
+}
+
+var FaultRecordOperationGet = kit.Def[frOperationGetArgs]{
+	Name:  "fault_record_operation_get",
+	Title: "查询故障录波操作结果",
+	Description: "查询一次故障录波操作的状态与逐项结果(设备回复的解析内容,不含原始报文)。" +
+		"status 为 done 表示成功、item_failed 表示部分子项失败,其他结束状态见 error。召唤录波成功后 hasFile=true,可用 fault_record_data、fault_record_export 查看数据。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a frOperationGetArgs) (*mcp.CallToolResult, error) {
+		d, err := services.GetFaultRecordOperation(ctx, strings.TrimSpace(a.ID))
+		if errors.Is(err, mongoRepo.ErrFaultRecordOpNotFound) || errors.Is(err, services.ErrFaultRecordInvalid) {
+			return kit.Failf("没有找到操作 %s,可以用 fault_record_operations 查看操作记录", a.ID), nil
+		}
+		if err != nil {
+			return kit.Upstream("ToolBox 故障录波", err), nil
+		}
+		view := frOpView(d)
+		if st := frTaskState(d); !st.Status.IsTerminal() {
+			view["progress"] = st.Message
+		}
+		return kit.Object(view), nil
+	},
+}
+
+// ---- fault_record_data ----
+
+var FaultRecordData = kit.Def[frOperationGetArgs]{
+	Name:  "fault_record_data",
+	Title: "查看故障录波数据概要",
+	Description: "解析一次召唤录波(record_fetch)保存的录波文件,返回每条记录的故障信息与波形概要:" +
+		"failure(故障时间、触发源、软件版本、快照通道等)、waveform(采样点数、触发前点数、采样频率,以及每个通道的 min、max 与触发点的值)。" +
+		"不含逐点采样值;需要逐点分析时用 fault_record_export 导出 CSV。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a frOperationGetArgs) (*mcp.CallToolResult, error) {
+		v, err := services.GetFaultRecordRecord(ctx, strings.TrimSpace(a.ID))
+		if errors.Is(err, mongoRepo.ErrFaultRecordOpNotFound) || errors.Is(err, services.ErrFaultRecordInvalid) {
+			return kit.Fail(err), nil
+		}
+		if err != nil {
+			return kit.Upstream("ToolBox 故障录波文件", err), nil
+		}
+		records := make([]map[string]any, 0, len(v.Records))
+		for _, r := range v.Records {
+			records = append(records, map[string]any{
+				"batch": r.Batch, "sn": r.Sn, "recordIndex": r.RecordIndex,
+				"failure": r.Failure, "waveform": waveformSummary(r.Waveform),
+			})
+		}
+		return kit.Object(map[string]any{"operationId": v.OperationID, "records": records}), nil
+	},
+}
+
+// waveformSummary 波形概要:通道统计,不含逐点数据
+func waveformSummary(w faultRecordFile.Waveform) map[string]any {
+	channels := make([]map[string]any, 0, len(w.Channels))
+	for _, ch := range w.Channels {
+		c := map[string]any{"channel": fmt.Sprintf("ch%d 0x%04X", ch.Number, ch.Meaning)}
+		if ch.Configurable != nil {
+			c["configurable"] = ch.Configurable.TypeName
+		}
+		if len(ch.Values) > 0 {
+			lo, hi := ch.Values[0], ch.Values[0]
+			for _, x := range ch.Values {
+				lo, hi = min(lo, x), max(hi, x)
+			}
+			c["min"], c["max"] = lo, hi
+			if w.PrePoints >= 0 && w.PrePoints < len(ch.Values) {
+				c["atTrigger"] = ch.Values[w.PrePoints]
+			}
+		}
+		channels = append(channels, c)
+	}
+	return map[string]any{"points": w.Points, "prePoints": w.PrePoints, "sampleFrequency": w.SampleFrequency, "channels": channels}
+}
+
+// ---- fault_record_export ----
+
+var FaultRecordExport = kit.Def[frOperationGetArgs]{
+	Name:  "fault_record_export",
+	Title: "导出故障录波数据",
+	Description: "把一次召唤录波保存的录波文件解析为 CSV(每条记录的故障信息、快照与逐点波形,打包为 zip),以文件形式保存到会话工作区。" +
+		"返回文件路径;请用 Python 解压后用 pandas 分析、作图,不要把全文读入上下文。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a frOperationGetArgs) (*mcp.CallToolResult, error) {
+		name, data, err := services.ExportFaultRecordFile(ctx, strings.TrimSpace(a.ID), "csv")
+		if errors.Is(err, mongoRepo.ErrFaultRecordOpNotFound) || errors.Is(err, services.ErrFaultRecordInvalid) {
+			return kit.Fail(err), nil
+		}
+		if err != nil {
+			return kit.Upstream("ToolBox 故障录波文件", err), nil
+		}
+		return kit.ExportFile("fwh://fault-record/"+name, "application/zip", data, "zip 内每条记录一组 CSV(故障信息、快照、逐点波形,通道列名为 ch<序号> 0x<含义码>)。"), nil
+	},
+}
+
+// ---- 写操作(ops):fault_record_fetch、fault_record_param_write、fault_record_cancel、fault_record_retry_download ----
+
+type frFetchArgs struct {
+	frDeviceArgs
+	RecordIndexes []int `json:"recordIndexes" jsonschema:"要召唤的录波记录序号(从 fault_record_device_info 类别 3 的故障录波总目录中选),可多条,设备打成一个包上传"`
+}
+
+var FaultRecordFetch = kit.TaskDef[frFetchArgs]{
+	Def: kit.Def[frFetchArgs]{
+		Name:  "fault_record_fetch",
+		Title: "召唤设备故障录波",
+		Description: "通过 MQTT 让设备上传指定的故障录波记录,下载后保存并解析。耗时通常几分钟(设备打包、上传、下载)。" +
+			"以长任务方式执行:提交后立即返回,完成后平台会把结果发给你;之后用 fault_record_data 查看概要、fault_record_export 导出 CSV。" +
+			"先用 fault_record_device_info(类别 3)确认记录序号。这是写操作,执行前会请求确认。",
+		ReadOnly:   false,
+		Properties: mergeProps(frDeviceProps, map[string]map[string]any{"recordIndexes": {"minItems": 1, "items": map[string]any{"type": "integer", "minimum": 1}}}),
+		Handler: func(ctx context.Context, a frFetchArgs) (*mcp.CallToolResult, error) {
+			id, fail := frStartFetch(ctx, a)
+			if fail != nil {
+				return fail, nil
+			}
+			return frSubmitted(id, "召唤录波"), nil
+		},
+	},
+	Poll: 15 * time.Second,
+	Start: func(ctx context.Context, a frFetchArgs) (kit.TaskStart, *mcp.CallToolResult) {
+		id, fail := frStartFetch(ctx, a)
+		return kit.TaskStart{Key: id, Flag: "op", Note: "已提交召唤录波,设备上传与下载通常需要几分钟,完成后平台会把结果发给你"}, fail
+	},
+	Status: frStatus,
+	Cancel: frCancel,
+}
+
+func frStartFetch(ctx context.Context, a frFetchArgs) (string, *mcp.CallToolResult) {
+	req, err := frRequest(a.frDeviceArgs, domain.FaultRecordOpRecordFetch)
+	if err != nil {
+		return "", kit.Fail(err)
+	}
+	req.RecordIndexes = a.RecordIndexes
+	return frSubmit(ctx, req)
+}
+
+type frParamWriteArgs struct {
+	frDeviceArgs
+	Params []proto.ParaItem `json:"params" jsonschema:"要设置的参数:id 为功能码,setVal 为十六进制字节(可带空格)。1 清除设备故障波形(不可恢复,setVal 为空);2 采样通道配置(1 字节个数 + 每通道 2 字节);3 手动触发故障录波(setVal 为空);4 设备对时(7 字节:年 2B 大端、月、日、时、分、秒);5 采样频率(2 字节);6 可配采样通道(6 字节);7 录波开关(00 关、01 开)"`
+}
+
+var FaultRecordParamWrite = kit.TaskDef[frParamWriteArgs]{
+	Def: kit.Def[frParamWriteArgs]{
+		Name:  "fault_record_param_write",
+		Title: "设置设备故障录波参数",
+		Description: "通过 MQTT 设置设备的故障录波参数或执行动作(清除波形、手动触发录波、对时、采样频率、通道配置、录波开关)。" +
+			"会修改设备状态,其中清除波形不可恢复:调用前向用户说明要设置的设备、功能码与值。设置前可用 fault_record_param_read、fault_record_device_info 读取当前值。" +
+			"返回逐项设置结果。这是破坏性写操作,每次执行前都会请求确认。",
+		ReadOnly:    false,
+		Destructive: true,
+		Properties:  mergeProps(frDeviceProps, map[string]map[string]any{"params": {"minItems": 1}}),
+		Handler: func(ctx context.Context, a frParamWriteArgs) (*mcp.CallToolResult, error) {
+			id, fail := frStartParamWrite(ctx, a)
+			if fail != nil {
+				return fail, nil
+			}
+			return frWait(ctx, id), nil
+		},
+	},
+	Poll: 5 * time.Second,
+	Start: func(ctx context.Context, a frParamWriteArgs) (kit.TaskStart, *mcp.CallToolResult) {
+		id, fail := frStartParamWrite(ctx, a)
+		return kit.TaskStart{Key: id, Flag: "op", Note: "已向设备发送设置请求,设备回复后平台会把结果发给你"}, fail
+	},
+	Status: frStatus,
+	Cancel: frCancel,
+}
+
+func frStartParamWrite(ctx context.Context, a frParamWriteArgs) (string, *mcp.CallToolResult) {
+	req, err := frRequest(a.frDeviceArgs, domain.FaultRecordOpParamWrite)
+	if err != nil {
+		return "", kit.Fail(err)
+	}
+	req.ParaList = a.Params
+	return frSubmit(ctx, req)
+}
+
+var FaultRecordCancel = kit.Def[frOperationGetArgs]{
+	Name:  "fault_record_cancel",
+	Title: "取消故障录波操作",
+	Description: "取消一次进行中的故障录波操作(在页面或其他会话中发起的也可以取消),执行器 1 秒内停止。已结束的操作无法取消。" +
+		"本会话以长任务方式发起的操作,也可以在平台的后台调用栏中取消。这是写操作,执行前会请求确认。",
+	ReadOnly: false,
+	// 取消已取消的操作会报“已结束”,不会产生额外影响
+	Idempotent: true,
+	Handler: func(ctx context.Context, a frOperationGetArgs) (*mcp.CallToolResult, error) {
+		err := services.CancelFaultRecordOperation(ctx, strings.TrimSpace(a.ID))
+		if errors.Is(err, services.ErrFaultRecordInvalid) || errors.Is(err, mongoRepo.ErrFaultRecordOpNotFound) {
+			return kit.Fail(err), nil
+		}
+		if err != nil {
+			return kit.Upstream("ToolBox 故障录波", err), nil
+		}
+		return mcp.NewToolResultText("已请求取消操作 " + a.ID + ",用 fault_record_operation_get 确认状态。"), nil
+	},
+}
+
+var FaultRecordRetryDownload = kit.Def[frOperationGetArgs]{
+	Name:  "fault_record_retry_download",
+	Title: "重试下载故障录波文件",
+	Description: "设备已上传录波文件、但下载失败或超时的召唤录波操作,重新下载(按 15 秒间隔重试到总超时)。" +
+		"只在 fault_record_operation_get 显示上传已完成且下载失败时使用。这是写操作,执行前会请求确认。",
+	ReadOnly: false,
+	Handler: func(ctx context.Context, a frOperationGetArgs) (*mcp.CallToolResult, error) {
+		err := services.RetryFaultRecordDownload(ctx, strings.TrimSpace(a.ID), ServiceAccount)
+		if errors.Is(err, services.ErrFaultRecordInvalid) || errors.Is(err, mongoRepo.ErrFaultRecordOpNotFound) {
+			return kit.Fail(err), nil
+		}
+		if err != nil {
+			return kit.Upstream("ToolBox 故障录波", err), nil
+		}
+		return mcp.NewToolResultText("已重新提交下载,用 fault_record_operation_get 查看结果。"), nil
+	},
+}
+
+// mergeProps 合并多组字段补充约束
+func mergeProps(groups ...map[string]map[string]any) map[string]map[string]any {
+	out := map[string]map[string]any{}
+	for _, g := range groups {
+		for k, v := range g {
+			out[k] = v
+		}
+	}
+	return out
+}

+ 78 - 0
service/agentmcp/tools/faultrecorddev_test.go

@@ -0,0 +1,78 @@
+package tools
+
+import (
+	"testing"
+	"time"
+
+	"github.com/mark3labs/mcp-go/mcp"
+
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/dto"
+	faultRecordFile "fwh-toolbox-server/function/FaultRecordFile"
+	proto "fwh-toolbox-server/function/FaultRecordProtocol"
+)
+
+func TestFrTaskState(t *testing.T) {
+	cases := []struct {
+		status  domain.FaultRecordOpStatus
+		want    mcp.TaskStatus
+		isError bool
+	}{
+		{domain.FaultRecordStatusPending, mcp.TaskStatusWorking, false},
+		{domain.FaultRecordStatusUploading, mcp.TaskStatusWorking, false},
+		{domain.FaultRecordStatusDone, mcp.TaskStatusCompleted, false},
+		{domain.FaultRecordStatusItemFailed, mcp.TaskStatusCompleted, false},
+		{domain.FaultRecordStatusCancelled, mcp.TaskStatusCancelled, true},
+		{domain.FaultRecordStatusTimeout, mcp.TaskStatusFailed, true},
+		{domain.FaultRecordStatusGatewayLocked, mcp.TaskStatusFailed, true},
+		{domain.FaultRecordStatusError, mcp.TaskStatusFailed, true},
+	}
+	for _, c := range cases {
+		d := &dto.FaultRecordOpDetail{FaultRecordOpSummary: dto.FaultRecordOpSummary{ID: "op1", Kind: "device_info", Status: string(c.status), CreatedAt: time.Now()}}
+		st := frTaskState(d)
+		if st.Status != c.want {
+			t.Errorf("%s: status = %s,期望 %s", c.status, st.Status, c.want)
+		}
+		if st.Message == "" {
+			t.Errorf("%s: 缺少状态说明", c.status)
+		}
+		if st.Status.IsTerminal() && (st.Result == nil || st.Result.IsError != c.isError) {
+			t.Errorf("%s: 终态结果不符合预期(isError 期望 %v):%+v", c.status, c.isError, st.Result)
+		}
+	}
+}
+
+func TestFrRequest(t *testing.T) {
+	req, err := frRequest(frDeviceArgs{SN: "G1"}, domain.FaultRecordOpDeviceInfo)
+	if err != nil || req.Env != "prod" || req.DeviceType != proto.DeviceTypeAGate || req.GatewaySn != "G1" || req.Kind != "device_info" {
+		t.Errorf("默认 aGate、prod:%+v %v", req, err)
+	}
+	req, err = frRequest(frDeviceArgs{SN: "G1", Env: "test", DeviceKind: proto.DeviceKindAPowerMPPT, DeviceSn: "P1"}, domain.FaultRecordOpParamRead)
+	if err != nil || req.DeviceType != proto.DeviceTypeMPPT || req.DeviceSn != "P1" || req.Env != "test" {
+		t.Errorf("aPowerMPPT 应映射为 mppt:%+v %v", req, err)
+	}
+	for _, bad := range []frDeviceArgs{
+		{},                                 // 缺 sn
+		{SN: "G1", Env: "dev"},             // 环境不对
+		{SN: "G1", DeviceKind: "aPowerPE"}, // 非 aGate 缺 deviceSn
+		{SN: "G1", DeviceKind: "dif"},      // 不支持的设备
+	} {
+		if _, err := frRequest(bad, domain.FaultRecordOpDeviceInfo); err == nil {
+			t.Errorf("应拒绝:%+v", bad)
+		}
+	}
+}
+
+func TestWaveformSummary(t *testing.T) {
+	w := faultRecordFile.Waveform{Points: 4, PrePoints: 2, SampleFrequency: 1000, Channels: []faultRecordFile.WaveformChannel{
+		{Number: 0, Meaning: 0x0101, Values: []int16{3, -5, 7, 1}},
+	}}
+	s := waveformSummary(w)
+	ch := s["channels"].([]map[string]any)[0]
+	if ch["channel"] != "ch0 0x0101" || ch["min"] != int16(-5) || ch["max"] != int16(7) || ch["atTrigger"] != int16(7) {
+		t.Errorf("通道统计不符合预期:%+v", ch)
+	}
+	if _, hasValues := ch["values"]; hasValues {
+		t.Error("概要不应包含逐点数据")
+	}
+}

+ 9 - 3
service/agentmcp/tools/registry.go

@@ -14,8 +14,11 @@ func ReadOnly() []kit.Registrar {
 		ShutdownStats, ShutdownSites,
 		// 日志
 		MqttLogSearch, EmsLogRecords, UpdateLogSystem, UpdateLogAhub,
-		// 故障录波
-		FaultRecordList, FaultRecordDetail, FaultRecordCallable, FaultRecordCallHistory,
+		// 故障录波(ToolBox 直连设备)
+		FaultRecordDevices, FaultRecordDeviceInfo, FaultRecordParamRead, FaultRecordOperations, FaultRecordOperationGet,
+		FaultRecordData, FaultRecordExport,
+		// 故障录波(云平台)
+		CloudFaultRecordList, CloudFaultRecordDetail, CloudFaultRecordCallable, CloudFaultRecordCallHistory,
 		// 调度
 		TouTemplateList, TouTemplateGet,
 		// SIM
@@ -27,5 +30,8 @@ func ReadOnly() []kit.Registrar {
 
 // Ops 写工具集(-set=ops)
 func Ops() []kit.Registrar {
-	return []kit.Registrar{FeedbackCreate, TouTemplateSave, SiteInfoRefresh}
+	return []kit.Registrar{
+		FeedbackCreate, TouTemplateSave, SiteInfoRefresh,
+		FaultRecordFetch, FaultRecordParamWrite, FaultRecordCancel, FaultRecordRetryDownload,
+	}
 }

+ 14 - 2
service/services/faultRecordServices.go

@@ -150,12 +150,24 @@ func BuildFaultRecordDevices(gatewaySn string, devInfo *domain.FwhDeviceInfo, po
 
 // CreateFaultRecordOperation 校验并创建操作,提交异步执行
 func CreateFaultRecordOperation(ctx context.Context, req dto.FaultRecordCreateOpReq, operator string) (string, error) {
+	return createFaultRecordOperation(ctx, req, operator, true)
+}
+
+// SubmitFaultRecordOperation 供不连接 MQTT 的独立进程(fwh-agent-mcp)提交操作:只校验参数、保存并入队,
+// 由主服务的执行器收发 MQTT;主服务 MQTT 不可用时执行器把操作记为 error
+func SubmitFaultRecordOperation(ctx context.Context, req dto.FaultRecordCreateOpReq, operator string) (string, error) {
+	return createFaultRecordOperation(ctx, req, operator, false)
+}
+
+func createFaultRecordOperation(ctx context.Context, req dto.FaultRecordCreateOpReq, operator string, localMQTT bool) (string, error) {
 	env, err := fwh_mqtt.ParseEnv(req.Env)
 	if err != nil {
 		return "", faultRecordInvalid("%v", err)
 	}
-	if _, err := fwh_mqtt.GetClient(env); err != nil {
-		return "", fmt.Errorf("%s 环境 MQTT 不可用:%w", env, err)
+	if localMQTT {
+		if _, err := fwh_mqtt.GetClient(env); err != nil {
+			return "", fmt.Errorf("%s 环境 MQTT 不可用:%w", env, err)
+		}
 	}
 	gatewaySn := strings.TrimSpace(req.GatewaySn)
 	if gatewaySn == "" {

+ 5 - 0
service/task/queue/faultRecordQueue/queue.go

@@ -43,6 +43,11 @@ func Init(tc *task_core.TaskCore) {
 	})
 }
 
+// InitEnqueueClient 只创建入队客户端,不启动执行器。供 fwh-agent-mcp 等独立进程提交操作,由主服务执行
+func InitEnqueueClient(redisOpt asynq.RedisClientOpt) {
+	client = asynq.NewClient(redisOpt)
+}
+
 // Enqueue 提交操作。TaskID 固定为操作 ID,重复提交(如启动恢复)不会重复执行。
 // 业务失败由执行器写入操作状态,不依赖 asynq 重试;MaxRetry 只用于进程崩溃后重新投递。
 func Enqueue(id primitive.ObjectID) error {