3 次代碼提交 e8c0aef11c ... 776f713a97

作者 SHA1 備註 提交日期
  Jerry Liang 776f713a97 feat:fwh-agent-mcp新增MQTT报文量统计、自动升级配置、站点分析结果只读工具 4 天之前
  Jerry Liang ec95f3f415 feat:site_search支持按国家、在线、激活、运行状态、版本、硬件、激活时间等条件筛选 4 天之前
  Jerry Liang a496b3c89c feat:fwh-agent-mcp接入ToolBox直连设备的故障录波 4 天之前

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

@@ -18,11 +18,15 @@ description: FranklinWH(FWH)储能业务数据助手。只要问题涉及 Fr
 
 | 领域 | 工具 |
 | --- | --- |
-| 站点 | `site_search`(搜索/分页)、`site_get`(单站点概况:版本、网络、工作模式、当前告警数)、`site_power_flow`(单日功率流)、`site_power_flow_export`(多日导出 CSV)、`site_info_sync_status`(站点信息表同步状态与最近同步时间) |
+| 站点 | `site_search`(关键字 + 国家、州省、在线、激活、运行状态、联网方式、版本、硬件、激活时间筛选;统计数量用 `size=1` 看 `total`)、`site_get`(单站点概况:版本、网络、工作模式、当前告警数)、`site_power_flow`(单日功率流)、`site_power_flow_export`(多日导出 CSV)、`site_info_sync_status`(站点信息表同步状态与最近同步时间) |
 | 告警 | `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` |
+| 日志 | `mqtt_log_search`(单站点报文内容,站点当地时间)、`ems_log_records`、`update_log_system`(传记录 ID 看详情)、`update_log_ahub` |
+| MQTT 报文量(UTC) | `mqtt_traffic_trend`(月总量与每日量)、`mqtt_traffic_sites`(某日站点报文量排行)、`mqtt_traffic_site_cmd`(站点某日命令类型分布)、`mqtt_message_search`(跨站点按命令类型与时间检索,多数无报文内容) |
+| 故障录波(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` |
+| 自动升级 | `auto_update_catalog`(aGate 硬件类型、升级包、aPower 版本)、`auto_update_config_get`(某硬件类型的升级条件与步骤) |
+| 站点分析 | `site_analysis_list`(页面创建的分析任务)、`site_analysis_timeline`(时间线)、`site_analysis_export`(时间线与运行数据 CSV) |
 | 调度 | `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 +35,16 @@ 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_*`。
+- **报文量异常**:`mqtt_traffic_trend` 看哪天异常 → `mqtt_traffic_sites` 找报文最多的站点 → `mqtt_traffic_site_cmd` 看是哪类命令 → 需要报文内容用 `mqtt_log_search`。注意报文量统计按 UTC 日。
+- **自动升级**:`auto_update_catalog` → `auto_update_config_get`(站点的 aGate 硬件类型可从 `site_get` 获得),结合站点当前 EMS 版本、aPower 硬件判断会走标准还是特殊升级、按什么步骤升级。
+- **站点分析**:`site_analysis_list`(按 SN)→ 已完成的用 `site_analysis_timeline` 看上下线、模式切换、告警;运行数据多,用 `site_analysis_export` 后用 Python 分析。
 - **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` | 录波已上传但下载失败时重试下载 | 只在操作结果显示上传完成、下载失败时使用 |
 
 ## 站点信息同步
 

+ 17 - 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、FWH ES、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"
@@ -46,6 +54,14 @@ FWH_TEST_OSS="test.franklinwh.com"
 FWH_TEST_USER="SuperAdmin"
 FWH_TEST_PASSWORD="CHANGE_ME"
 
+# ---------- FWH ES(MQTT 报文统计,iotcore-stat)----------
+# mqtt_traffic_*、mqtt_message_search 使用,与主服务 config.env 相同
+FWH_ES_HOST="CHANGE_ME"
+FWH_ES_USER="CHANGE_ME"
+FWH_ES_PASSWORD="CHANGE_ME"
+FWH_ES_INDEX_PREFIX="iotcore-stat"
+FWH_ES_INSECURE_SKIP_VERIFY=true
+
 # ---------- Plane Web ----------
 # plane_issue_search / plane_issue_get,以及写工具 feedback_create 使用
 PLANE_WEB_HOST="http://localhost:8600"

+ 20 - 6
docs/ai-assistant-agentserver/agent-mcp.md

@@ -168,11 +168,11 @@ schema 由 mcp-go 依赖的 `google/jsonschema-go` 生成:`jsonschema` 标签
 
 ## 4. 工具清单(一期,只读)
 
-按领域组织,约 30 个。“来源”列是复用的现有数据访问函数,实现时以代码为准。
+按领域组织,只读服务 45 个、写服务 7 个(工具数量超过接入指南建议的 20 个,全部完成后再统一考虑分组拆分)。“来源”列是复用的现有数据访问函数,实现时以代码为准。
 
 | 领域 | 工具 | 说明 | 来源 |
 | --- | --- | --- | --- |
-| 站点 | `site_search` | 按 SN 片段、国家、激活状态、版本搜索站点,分页 | `QuerySiteInfoList`、站点信息表 |
+| 站点 | `site_search` | 按关键字与条件(国家、州省、在线、激活、运行状态、联网方式、固件/协议版本、aGate 硬件、激活时间,全部为“且”)搜索站点,分页;关键字与包含匹配按字面值(转义正则) | `QuerySiteInfoDataList`、站点信息表 |
 | | `site_get` | 单站点概况:基本信息、版本、网络、工作模式、当前告警数 | `QuerySiteBaseInfoList`、`QuerySiteModeInfo`、`QuerySiteAlarmList` |
 | | `site_power_flow` | 单站点单日功率流(5 分钟粒度,`Table`) | `QuerySitePowerFlow` |
 | | `site_power_flow_export` | 单站点多日功率流导出(≤ 31 天,CSV) | 同上,按天循环 |
@@ -188,10 +188,21 @@ 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 |
+| MQTT 报文量 | `mqtt_traffic_trend` | 月总量与每日量(UTC) | iotcore-stat ES |
+| | `mqtt_traffic_sites` / `mqtt_traffic_site_cmd` | 某日站点报文量排行、站点命令类型分布 | 同上 |
+| | `mqtt_message_search` | 跨站点按命令类型、时间检索报文元数据 | 同上 |
+| 自动升级 | `auto_update_catalog` / `auto_update_config_get` | 升级目录;某 aGate 硬件类型的升级条件与步骤(附升级包标题) | 自动升级配置集合 |
+| 站点分析 | `site_analysis_list` / `site_analysis_timeline` / `site_analysis_export` | 页面创建的分析任务、时间线、结果 CSV(只读) | 站点分析集合 |
 | 调度 | `tou_template_list` | TOU 模版列表,`env=prod/test` | 调度配置服务 |
 | | `tou_template_get` | TOU 模版详情:按季节 × 日类型整理时段,附调度模式识别结果 | 同上 + 调度配置文档中的解析规则 |
 | SIM | `sim_get` | 按 ICCID 查询,自动判断平台(SmartHub / Acceleronix) | SIM 卡管理服务 |
@@ -288,6 +299,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。
 

+ 6 - 1
service/agentmcp/agentmcp_test.go

@@ -84,7 +84,12 @@ 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",
+		"mqtt_traffic_trend", "mqtt_traffic_sites", "mqtt_traffic_site_cmd", "mqtt_message_search",
+		"auto_update_catalog", "auto_update_config_get",
+		"site_analysis_list", "site_analysis_timeline", "site_analysis_export",
+		"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",

+ 11 - 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、FWH ES、Plane、任务队列入队客户端。
 func InitInfra(configFile string) error {
 	cfg, err := config.LoadConfig(configFile)
 	if err != nil {
@@ -112,9 +114,15 @@ func InitInfra(configFile string) error {
 	}
 	repositories.FwhHttpInit(&cfg.FwhApi)
 	repositories.FwhTestHttpInit(&cfg.FwhTestApi)
+	// MQTT 报文统计(iotcore-stat Elasticsearch):mqtt_traffic_*、mqtt_message_search
+	repositories.EsHttpInit(&cfg.FwhEs)
 	// 必须用 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 {

+ 111 - 0
service/agentmcp/tools/autoupdate.go

@@ -0,0 +1,111 @@
+package tools
+
+// 自动升级配置(只读,与 ToolBox“自动升级”页面同源):按 aGate 硬件类型配置可自动升级的 EMS 版本、aPower 硬件与升级步骤(升级包)。
+
+import (
+	"context"
+	"errors"
+	"strings"
+
+	"github.com/mark3labs/mcp-go/mcp"
+	"go.mongodb.org/mongo-driver/mongo"
+
+	"fwh-toolbox-server/agentmcp/kit"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/services"
+)
+
+// ---- auto_update_catalog ----
+
+var AutoUpdateCatalog = kit.Def[kit.NoArgs]{
+	Name:  "auto_update_catalog",
+	Title: "查询自动升级目录",
+	Description: "列出自动升级配置的目录:aGateHardware(已配置的 aGate 硬件类型,作为 auto_update_config_get 的参数)、" +
+		"packages(升级包文件名,按 system 系统包、aGate、subDevice 子设备包、others 分类)、aPowerVersions(aPower 硬件版本号与名称)。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, _ kit.NoArgs) (*mcp.CallToolResult, error) {
+		opt, err := services.GetAutoUpdateConfigOption()
+		if err != nil {
+			return kit.Upstream("ToolBox 自动升级配置", err), nil
+		}
+		ap, err := services.GetAutoUpdateAPowerVersion()
+		if err != nil {
+			return kit.Upstream("ToolBox 自动升级配置", err), nil
+		}
+		return kit.Object(map[string]any{"aGateHardware": opt.AGateVersions, "packages": opt.Packages, "aPowerVersions": ap}), nil
+	},
+}
+
+// ---- auto_update_config_get ----
+
+type autoUpdateConfigArgs struct {
+	AGate string `json:"aGate" jsonschema:"aGate 硬件类型(auto_update_catalog 的 aGateHardware 中的值)"`
+}
+
+var AutoUpdateConfigGet = kit.Def[autoUpdateConfigArgs]{
+	Name:  "auto_update_config_get",
+	Title: "查询 aGate 自动升级配置",
+	Description: "查询某个 aGate 硬件类型的自动升级配置:default(标准升级)与 special(备件换件、休眠等特殊场景)。" +
+		"每项含 emsVersions(满足条件的当前 EMS 版本)、aPowerVersions(满足条件的 aPower 硬件版本)、steps(按顺序执行的升级包:file、title、invVersion)。" +
+		"special 中 useDefaultUpdate=true 表示先执行特殊步骤再追加标准步骤,否则只执行特殊步骤。用于回答“某类站点会升级到什么版本”。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a autoUpdateConfigArgs) (*mcp.CallToolResult, error) {
+		hw, err := kit.Required("aGate", a.AGate)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		cfg, err := services.GetAutoUpdateConfig(hw)
+		if errors.Is(err, mongo.ErrNoDocuments) {
+			return kit.Failf("没有 aGate 硬件类型 %s 的自动升级配置,可用 auto_update_catalog 查看已配置的类型", hw), nil
+		}
+		if err != nil {
+			return kit.Upstream("ToolBox 自动升级配置", err), nil
+		}
+		packages := map[string]map[string]any{}
+		steps := func(files []string) []map[string]any {
+			out := make([]map[string]any, 0, len(files))
+			for _, f := range files {
+				p, ok := packages[f]
+				if !ok {
+					p = map[string]any{"file": f}
+					if info, err := services.GetAutoUpdatePackageInfo(f); err == nil && info != nil {
+						p["title"], p["invVersion"] = info.Title, info.InvVersion
+					} else {
+						p["note"] = "升级包信息缺失"
+					}
+					packages[f] = p
+				}
+				out = append(out, p)
+			}
+			return out
+		}
+		def := cfg.Config.DefaultType
+		special := make([]map[string]any, 0, len(cfg.Config.SpecialType))
+		for _, s := range cfg.Config.SpecialType {
+			special = append(special, map[string]any{
+				"title": s.Title, "emsVersions": versionList(s.EmsVersions), "aPowerVersions": s.APowerVersions,
+				"steps": steps(s.SpecialUpdateOrder), "useDefaultUpdate": s.UseDefaultUpdate,
+			})
+		}
+		return kit.Object(map[string]any{
+			"aGate": cfg.Id,
+			"default": map[string]any{
+				"emsVersions": versionList(def.EmsVersions), "aPowerVersions": def.APowerVersions, "steps": steps(def.UpdateOrder),
+			},
+			"special": special,
+		}), nil
+	},
+}
+
+// versionList EMS 版本条件:版本号(带备注时附在后面)
+func versionList(vs []domain.AutoUpdateSoftwareVersionInfo[string]) []string {
+	out := make([]string, 0, len(vs))
+	for _, v := range vs {
+		s := v.Version
+		if r := strings.TrimSpace(v.Remark); r != "" {
+			s += "(" + r + ")"
+		}
+		out = append(out, s)
+	}
+	return out
+}

+ 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("概要不应包含逐点数据")
+	}
+}

+ 32 - 0
service/agentmcp/tools/misc_helpers_test.go

@@ -0,0 +1,32 @@
+package tools
+
+import (
+	"testing"
+
+	"fwh-toolbox-server/domain"
+)
+
+func TestEsDate(t *testing.T) {
+	if d, err := esDate("date", "2026-10-29"); err != nil || d != "2026.10.29" {
+		t.Errorf("esDate = %q %v", d, err)
+	}
+	if _, err := esDate("date", "2026.10.29"); err == nil {
+		t.Error("应拒绝非 yyyy-MM-dd 格式")
+	}
+}
+
+func TestTruncate(t *testing.T) {
+	if got := truncate("站点信息", 10); got != "站点信息" {
+		t.Errorf("未超长不应截断:%q", got)
+	}
+	if got := truncate("站点信息表", 2); got != "站点…(已截断)" {
+		t.Errorf("按字符截断:%q", got)
+	}
+}
+
+func TestVersionList(t *testing.T) {
+	got := versionList([]domain.AutoUpdateSoftwareVersionInfo[string]{{Version: "V10R11B42"}, {Version: "V10R11B60", Remark: " 北美 "}})
+	if len(got) != 2 || got[0] != "V10R11B42" || got[1] != "V10R11B60(北美)" {
+		t.Errorf("versionList = %v", got)
+	}
+}

+ 186 - 0
service/agentmcp/tools/mqttstats.go

@@ -0,0 +1,186 @@
+package tools
+
+// MQTT 报文统计(FWH iotcore-stat Elasticsearch,与 ToolBox“MQTT 报文管理 / MQTT 数据看板”同源)。
+// ES 按 UTC 日建索引:日期、时间参数一律按 UTC 解释。
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/mark3labs/mcp-go/mcp"
+
+	"fwh-toolbox-server/agentmcp/kit"
+	"fwh-toolbox-server/repositories"
+	"fwh-toolbox-server/services"
+)
+
+const mqttEsSource = "FWH MQTT 报文统计(ES)"
+
+// esDate 把 yyyy-MM-dd 转为 ES 索引日期 yyyy.MM.dd
+func esDate(name, v string) (string, error) {
+	t, err := kit.ParseDate(name, v, false)
+	if err != nil {
+		return "", err
+	}
+	return t.Format("2006.01.02"), nil
+}
+
+// ---- mqtt_traffic_trend ----
+
+type mqttTrendArgs struct {
+	Month string `json:"month" jsonschema:"月份,yyyy-MM(UTC)"`
+}
+
+var MqttTrafficTrend = kit.Def[mqttTrendArgs]{
+	Name:  "mqtt_traffic_trend",
+	Title: "查询 MQTT 报文月度趋势",
+	Description: "查询全部站点某月的 MQTT 报文总量与每日总量(UTC 日,整月补齐,缺失的天为 0)。" +
+		"用于观察报文量的整体趋势、定位异常的日期;某天哪些站点报文最多用 mqtt_traffic_sites。返回 month、total、days(date、total)。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a mqttTrendArgs) (*mcp.CallToolResult, error) {
+		t, err := time.Parse("2006-01", strings.TrimSpace(a.Month))
+		if err != nil {
+			return kit.Failf("参数 month 格式错误,需要 yyyy-MM:%s", a.Month), nil
+		}
+		total, err := services.GetMqttMonthlyTotal(t.Year(), int(t.Month()))
+		if err != nil {
+			return kit.Upstream(mqttEsSource, err), nil
+		}
+		days, err := services.GetMqttMonthlyDailyTrend(t.Year(), int(t.Month()))
+		if err != nil {
+			return kit.Upstream(mqttEsSource, err), nil
+		}
+		return kit.Object(map[string]any{"month": t.Format("2006-01"), "total": total.Total, "days": days}), nil
+	},
+}
+
+// ---- mqtt_traffic_sites ----
+
+type mqttSitesArgs struct {
+	Date string `json:"date" jsonschema:"日期,yyyy-MM-dd(UTC)"`
+	Page int    `json:"page,omitempty" jsonschema:"页码,默认 1"`
+	Size int    `json:"size,omitempty" jsonschema:"每页条数,1~100,默认 20"`
+}
+
+var MqttTrafficSites = kit.Def[mqttSitesArgs]{
+	Name:  "mqtt_traffic_sites",
+	Title: "查询站点 MQTT 报文量排行",
+	Description: "查询某一天(UTC)各站点的 MQTT 报文量,按报文量从多到少分页返回。用于找出报文量异常偏高的站点。" +
+		"返回 siteCount(当天有报文的站点数)与 items(sn、total)。某站点各命令类型的分布用 mqtt_traffic_site_cmd。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a mqttSitesArgs) (*mcp.CallToolResult, error) {
+		date, err := esDate("date", a.Date)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		page, size := kit.Page(a.Page), kit.Limit(a.Size, 20, 100)
+		res, err := services.GetMqttSiteDailyTotals(date, page, size)
+		if err != nil {
+			return kit.Upstream(mqttEsSource, err), nil
+		}
+		items := make([]map[string]any, 0, len(res.Items))
+		for _, it := range res.Items {
+			items = append(items, map[string]any{"sn": it.EquipNo, "total": it.Total})
+		}
+		return kit.List(items, int(res.SiteCount), size, fmt.Sprintf("第 %d 页,按报文量降序,可用 page 翻页", page)), nil
+	},
+}
+
+// ---- mqtt_traffic_site_cmd ----
+
+type mqttSiteCmdArgs struct {
+	SN   string `json:"sn" jsonschema:"站点 SN(aGate 序列号)"`
+	Date string `json:"date" jsonschema:"日期,yyyy-MM-dd(UTC)"`
+}
+
+var MqttTrafficSiteCmd = kit.Def[mqttSiteCmdArgs]{
+	Name:  "mqtt_traffic_site_cmd",
+	Title: "查询站点 MQTT 命令类型分布",
+	Description: "查询单个站点某一天(UTC)各 MQTT 命令类型(cmdType)的报文量,按报文量降序。用于分析某站点报文量异常的来源。" +
+		"返回 cmdTypeCount 与 items(cmdType、total)。具体报文用 mqtt_message_search 或 mqtt_log_search。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a mqttSiteCmdArgs) (*mcp.CallToolResult, error) {
+		sn, err := kit.Required("sn", a.SN)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		date, err := esDate("date", a.Date)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		res, err := services.GetMqttSiteCmdTypeTotals(sn, date, 1, 200)
+		if err != nil {
+			return kit.Upstream(mqttEsSource, err), nil
+		}
+		return kit.List(res.Items, int(res.CmdTypeCount), 200, ""), nil
+	},
+}
+
+// ---- mqtt_message_search ----
+
+type mqttMessageSearchArgs struct {
+	SN      string `json:"sn,omitempty" jsonschema:"站点 SN;不填查全部站点(此时建议指定 cmdType 并缩短时间范围)"`
+	CmdType *int   `json:"cmdType,omitempty" jsonschema:"MQTT 命令类型,如 0、101、105、107、201、211"`
+	From    string `json:"from" jsonschema:"开始时间,yyyy-MM-dd HH:mm:ss(UTC)"`
+	To      string `json:"to" jsonschema:"结束时间,同上;与开始时间相差不超过 7 天"`
+	Limit   int    `json:"limit,omitempty" jsonschema:"最多返回条数,1~200,默认 50"`
+}
+
+var MqttMessageSearch = kit.Def[mqttMessageSearchArgs]{
+	Name:  "mqtt_message_search",
+	Title: "检索 MQTT 报文(ES)",
+	Description: "在 MQTT 报文统计库(ES)中按站点、命令类型、时间(UTC)检索逐条报文,按时间倒序。可以不指定站点,跨站点查找某类报文。" +
+		"返回 total(命中数,可能为下界)与 items:time(UTC)、sn、cmdType,有内容时附 event(超过 1500 字截断)。" +
+		"统计库主要保存报文的时间、站点与命令类型,多数报文没有 event 内容,适合统计与定位时间点;需要报文内容时用 mqtt_log_search(FWH 平台日志,时间为站点当地时间)。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a mqttMessageSearchArgs) (*mcp.CallToolResult, error) {
+		from, to, err := kit.ParseRange(a.From, a.To, 7*24*time.Hour)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		// ParseRange 按本地时区解析;ES 的时间是 UTC,按字面值换为 UTC
+		from = time.Date(from.Year(), from.Month(), from.Day(), from.Hour(), from.Minute(), from.Second(), 0, time.UTC)
+		to = time.Date(to.Year(), to.Month(), to.Day(), to.Hour(), to.Minute(), to.Second(), 0, time.UTC)
+		limit := kit.Limit(a.Limit, 50, 200)
+		var sn *string
+		if v := strings.TrimSpace(a.SN); v != "" {
+			sn = &v
+		}
+		var cmd *int64
+		if a.CmdType != nil {
+			v := int64(*a.CmdType)
+			cmd = &v
+		}
+		res, err := services.QueryMqttMessages(sn, cmd, &from, &to, 1, limit)
+		if errors.Is(err, repositories.ErrEsDeepPaging) {
+			return kit.Failf("命中的报文太多,请缩短时间范围或指定站点、命令类型。"), nil
+		}
+		if err != nil {
+			return kit.Upstream(mqttEsSource, err), nil
+		}
+		items := make([]map[string]any, 0, len(res.Items))
+		for _, m := range res.Items {
+			item := map[string]any{"time": m.Timestamp, "sn": m.EquipNo, "cmdType": strconv.FormatInt(m.CmdType, 10)}
+			if len(m.Event) > 0 {
+				if b, err := json.Marshal(m.Event); err == nil {
+					item["event"] = truncate(string(b), 1500)
+				}
+			}
+			items = append(items, item)
+		}
+		return kit.List(items, int(res.Total), limit, "超过 limit 时请缩短时间范围或增加条件"), nil
+	},
+}
+
+func truncate(s string, n int) string {
+	r := []rune(s)
+	if len(r) <= n {
+		return s
+	}
+	return string(r[:n]) + "…(已截断)"
+}

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

@@ -14,8 +14,17 @@ func ReadOnly() []kit.Registrar {
 		ShutdownStats, ShutdownSites,
 		// 日志
 		MqttLogSearch, EmsLogRecords, UpdateLogSystem, UpdateLogAhub,
-		// 故障录波
-		FaultRecordList, FaultRecordDetail, FaultRecordCallable, FaultRecordCallHistory,
+		// MQTT 报文统计(ES)
+		MqttTrafficTrend, MqttTrafficSites, MqttTrafficSiteCmd, MqttMessageSearch,
+		// 故障录波(ToolBox 直连设备)
+		FaultRecordDevices, FaultRecordDeviceInfo, FaultRecordParamRead, FaultRecordOperations, FaultRecordOperationGet,
+		FaultRecordData, FaultRecordExport,
+		// 故障录波(云平台)
+		CloudFaultRecordList, CloudFaultRecordDetail, CloudFaultRecordCallable, CloudFaultRecordCallHistory,
+		// 自动升级配置
+		AutoUpdateCatalog, AutoUpdateConfigGet,
+		// 站点分析结果
+		SiteAnalysisList, SiteAnalysisTimeline, SiteAnalysisExport,
 		// 调度
 		TouTemplateList, TouTemplateGet,
 		// SIM
@@ -27,5 +36,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,
+	}
 }

+ 121 - 10
service/agentmcp/tools/site.go

@@ -3,6 +3,7 @@ package tools
 import (
 	"context"
 	"fmt"
+	"regexp"
 	"strconv"
 	"strings"
 	"time"
@@ -10,6 +11,7 @@ import (
 	"github.com/mark3labs/mcp-go/mcp"
 
 	"fwh-toolbox-server/agentmcp/kit"
+	"fwh-toolbox-server/domain"
 	"fwh-toolbox-server/dto"
 	"fwh-toolbox-server/repositories"
 	"fwh-toolbox-server/services"
@@ -18,36 +20,145 @@ import (
 // ---- site_search ----
 
 type siteSearchArgs struct {
-	Keyword string `json:"keyword,omitempty" jsonschema:"搜索关键字,匹配 SN、站点名称等;不填则按默认排序列出"`
-	Page    int    `json:"page,omitempty" jsonschema:"页码,从 1 开始,默认 1"`
-	Size    int    `json:"size,omitempty" jsonschema:"每页条数,1~100,默认 20"`
+	Keyword     string `json:"keyword,omitempty" jsonschema:"关键字,匹配 SN、站点名称、经销商/安装商、aPower SN、ICCID、版本、国家/州省/城市、备注等(不区分大小写);不填则只按筛选条件"`
+	Country     string `json:"country,omitempty" jsonschema:"国家:us(美国)、au(澳大利亚)"`
+	Province    string `json:"province,omitempty" jsonschema:"州/省(英文,包含匹配),如 California"`
+	Online      string `json:"online,omitempty" jsonschema:"网络状态:online(在线)、offline(离线)"`
+	Active      string `json:"active,omitempty" jsonschema:"激活状态:active(已激活)、inactive(未激活)"`
+	RunStatus   string `json:"runStatus,omitempty" jsonschema:"运行状态:standby(待机)、charging(充电)、discharging(放电)、fault(故障)"`
+	ConnType    string `json:"connType,omitempty" jsonschema:"联网方式:none(未连接)、eth0、eth1、wifi、4g"`
+	Version     string `json:"version,omitempty" jsonschema:"系统固件版本(包含匹配),如 V10R11B42"`
+	ProtocolVer string `json:"protocolVer,omitempty" jsonschema:"协议版本(包含匹配),如 V1.12.45"`
+	Hardware    string `json:"hardware,omitempty" jsonschema:"aGate 系统硬件名称(包含匹配),如 System1.5"`
+	ActiveFrom  string `json:"activeFrom,omitempty" jsonschema:"激活时间起(含),yyyy-MM-dd 或 yyyy-MM-dd HH:mm:ss,北京时间"`
+	ActiveTo    string `json:"activeTo,omitempty" jsonschema:"激活时间止(含),格式同上"`
+	Page        int    `json:"page,omitempty" jsonschema:"页码,从 1 开始,默认 1"`
+	Size        int    `json:"size,omitempty" jsonschema:"每页条数,1~100,默认 20;只需要数量时用 1,看 total"`
 }
 
+var (
+	siteCountryIDs = map[string]int64{"us": 2, "au": 3}
+	siteOnline     = map[string]int64{"offline": 0, "online": 1}
+	siteActive     = map[string]int64{"inactive": 0, "active": 1}
+	siteRunStatus  = map[string]int64{"standby": 0, "charging": 1, "discharging": 2, "fault": 3}
+	siteConnTypes  = map[string]int64{"none": 0, "eth0": 1, "eth1": 2, "wifi": 3, "4g": 4}
+)
+
 var SiteSearch = kit.Def[siteSearchArgs]{
 	Name:  "site_search",
 	Title: "搜索站点",
-	Description: "在 ToolBox 站点信息表中搜索站点(按 SN 片段、站点名称等关键字),分页返回。" +
-		"返回字段:sn(aGate SN)、name、国家/州省/城市、激活状态与时间、在线状态、系统与固件版本、aPower 数量等(以实际返回为准,个人信息已脱敏)。" +
-		"返回 total 为符合条件的总数,用 page 翻页。查看单个站点完整概况请用 site_get。",
+	Description: "在 ToolBox 站点信息表(约 6 万个站点,每天从 FWH 平台同步)中按关键字与条件搜索站点,分页返回;多个条件同时满足。" +
+		"可按国家、州/省、在线、激活、运行状态、联网方式、固件/协议版本、aGate 硬件、激活时间筛选。统计数量时 size=1 看 total," +
+		"如“美国在线的 System1.5 站点有多少”。返回字段:sn(aGate SN)、name、国家/州省/城市、激活状态与时间、在线状态、版本、aPower SN 等(个人信息已脱敏)。" +
+		"数据是否最新见 site_info_sync_status;查看单个站点完整概况用 site_get。",
 	ReadOnly: true,
+	Properties: map[string]map[string]any{
+		"country":   {"enum": []string{"us", "au"}},
+		"online":    {"enum": []string{"online", "offline"}},
+		"active":    {"enum": []string{"active", "inactive"}},
+		"runStatus": {"enum": []string{"standby", "charging", "discharging", "fault"}},
+		"connType":  {"enum": []string{"none", "eth0", "eth1", "wifi", "4g"}},
+	},
 	Handler: func(ctx context.Context, a siteSearchArgs) (*mcp.CallToolResult, error) {
 		page := kit.Page(a.Page)
 		size := kit.Limit(a.Size, 20, 100)
+		filter, err := siteSearchFilter(a)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		keyword := strings.TrimSpace(a.Keyword)
 		data, err := services.QuerySiteInfoDataList(dto.SiteInfoRequest{
-			Search:   strings.TrimSpace(a.Keyword),
+			Search:   regexp.QuoteMeta(keyword), // 仓库按正则匹配,转义后按字面匹配
+			Filter:   filter,
 			PageNum:  uint(page),
 			PageSize: uint(size),
 		})
 		if err != nil {
 			return kit.Upstream("站点信息表", err), nil
 		}
-		if data == nil {
-			return kit.Empty(""), nil
+		if data == nil || data.Total == 0 {
+			return kit.Empty("没有符合条件的站点。"), nil
 		}
-		return kit.List(data.Data, int(data.Total), size, fmt.Sprintf("共 %d 条,当前第 %d 页,可用 page 翻页", data.Total, page)), nil
+		return kit.List(data.Data, int(data.Total), size, fmt.Sprintf("共 %d 条,当前第 %d 页,可用 page 翻页或增加条件", data.Total, page)), nil
 	},
 }
 
+// activeTimeBound 解析激活时间范围的一端:接受日期或日期时间;只给日期时,止时间取当天最后一秒
+func activeTimeBound(name, v string, end bool) (string, error) {
+	v = strings.TrimSpace(v)
+	if t, err := time.Parse(time.DateTime, v); err == nil {
+		return t.Format(time.DateTime), nil
+	}
+	t, err := time.Parse(time.DateOnly, v)
+	if err != nil {
+		return "", fmt.Errorf("参数 %s 格式错误,需要 yyyy-MM-dd 或 yyyy-MM-dd HH:mm:ss:%s", name, v)
+	}
+	if end {
+		t = t.Add(24*time.Hour - time.Second)
+	}
+	return t.Format(time.DateTime), nil
+}
+
+// siteSearchFilter 把筛选参数转为站点信息表的过滤条件(全部为“且”);没有条件时返回 nil
+func siteSearchFilter(a siteSearchArgs) (*domain.SiteInfoFilter, error) {
+	var conds []domain.SiteInfoFilterConfig
+	eqInt := func(key string, v int64) {
+		conds = append(conds, domain.SiteInfoFilterConfig{Key: key, Type: domain.FilterEqualInt, Para1: domain.FlexiblePara{Int: &v}})
+	}
+	contains := func(key, v string) {
+		if v = strings.TrimSpace(v); v != "" {
+			q := regexp.QuoteMeta(v)
+			conds = append(conds, domain.SiteInfoFilterConfig{Key: key, Type: domain.FilterIncludeString, Para1: domain.FlexiblePara{String: &q}})
+		}
+	}
+	enums := []struct {
+		key, name, val string
+		m              map[string]int64
+	}{
+		{"countryId", "country", a.Country, siteCountryIDs},
+		{"status", "online", a.Online, siteOnline},
+		{"activeStatus", "active", a.Active, siteActive},
+		{"runStatus", "runStatus", a.RunStatus, siteRunStatus},
+		{"connType", "connType", a.ConnType, siteConnTypes},
+	}
+	for _, e := range enums {
+		if e.val == "" {
+			continue
+		}
+		v, ok := e.m[strings.ToLower(e.val)]
+		if !ok {
+			return nil, fmt.Errorf("参数 %s 的值 %q 无效", e.name, e.val)
+		}
+		eqInt(e.key, v)
+	}
+	contains("province", a.Province)
+	contains("version", a.Version)
+	contains("protocolVer", a.ProtocolVer)
+	contains("realSysHdVersion", a.Hardware)
+
+	if a.ActiveFrom != "" || a.ActiveTo != "" {
+		// activeTime 以 "yyyy-MM-dd HH:mm:ss" 字符串保存,按字符串比较
+		from, to := "0000-00-00 00:00:00", "9999-12-31 23:59:59"
+		var err error
+		if a.ActiveFrom != "" {
+			if from, err = activeTimeBound("activeFrom", a.ActiveFrom, false); err != nil {
+				return nil, err
+			}
+		}
+		if a.ActiveTo != "" {
+			if to, err = activeTimeBound("activeTo", a.ActiveTo, true); err != nil {
+				return nil, err
+			}
+		}
+		conds = append(conds, domain.SiteInfoFilterConfig{Key: "activeTime", Type: domain.FilterRangeTime,
+			Para1: domain.FlexiblePara{String: &from}, Para2: &domain.FlexiblePara{String: &to}})
+	}
+	if len(conds) == 0 {
+		return nil, nil
+	}
+	return &domain.SiteInfoFilter{Logic: "and", SubFilter: []domain.SiteInfoSubFilter{{Logic: "and", Config: conds}}}, nil
+}
+
 // ---- site_get ----
 
 type siteGetArgs struct {

+ 45 - 0
service/agentmcp/tools/site_test.go

@@ -0,0 +1,45 @@
+package tools
+
+import (
+	"testing"
+
+	"fwh-toolbox-server/domain"
+)
+
+func TestSiteSearchFilter(t *testing.T) {
+	if f, err := siteSearchFilter(siteSearchArgs{Keyword: "x"}); err != nil || f != nil {
+		t.Errorf("没有筛选条件时应返回 nil:%+v %v", f, err)
+	}
+
+	f, err := siteSearchFilter(siteSearchArgs{Country: "us", Online: "online", Version: "V10R11(B42", ActiveFrom: "2026-01-01", ActiveTo: "2026-01-31"})
+	if err != nil || f == nil || f.Logic != "and" || len(f.SubFilter) != 1 {
+		t.Fatalf("应生成一组“且”条件:%+v %v", f, err)
+	}
+	got := map[string]domain.SiteInfoFilterConfig{}
+	for _, c := range f.SubFilter[0].Config {
+		got[c.Key] = c
+	}
+	if c := got["countryId"]; c.Type != domain.FilterEqualInt || *c.Para1.Int != 2 {
+		t.Errorf("us 应为 countryId=2:%+v", c)
+	}
+	if c := got["status"]; *c.Para1.Int != 1 {
+		t.Errorf("online 应为 status=1:%+v", c)
+	}
+	if c := got["version"]; c.Type != domain.FilterIncludeString || *c.Para1.String != `V10R11\(B42` {
+		t.Errorf("版本应转义为字面匹配:%q", *c.Para1.String)
+	}
+	if c := got["activeTime"]; *c.Para1.String != "2026-01-01 00:00:00" || *c.Para2.String != "2026-01-31 23:59:59" {
+		t.Errorf("激活时间范围不对:%s ~ %s", *c.Para1.String, *c.Para2.String)
+	}
+
+	f, _ = siteSearchFilter(siteSearchArgs{ActiveFrom: "2026-01-01 08:00:00"})
+	if c := f.SubFilter[0].Config[0]; *c.Para1.String != "2026-01-01 08:00:00" || *c.Para2.String != "9999-12-31 23:59:59" {
+		t.Errorf("只给起始时间时止时间不限:%s ~ %s", *c.Para1.String, *c.Para2.String)
+	}
+
+	for _, bad := range []siteSearchArgs{{Country: "cn"}, {RunStatus: "busy"}, {ActiveTo: "2026/01/01"}} {
+		if _, err := siteSearchFilter(bad); err == nil {
+			t.Errorf("应拒绝:%+v", bad)
+		}
+	}
+}

+ 143 - 0
service/agentmcp/tools/siteanalysis.go

@@ -0,0 +1,143 @@
+package tools
+
+// 站点分析结果(只读,与 ToolBox“站点分析管理”同源):分析任务由页面创建,这里只读取结果。
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"strings"
+
+	"github.com/mark3labs/mcp-go/mcp"
+	"go.mongodb.org/mongo-driver/bson/primitive"
+	"go.mongodb.org/mongo-driver/mongo"
+
+	"fwh-toolbox-server/agentmcp/kit"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/dto"
+	"fwh-toolbox-server/services"
+)
+
+const siteAnalysisSource = "ToolBox 站点分析"
+
+// ---- site_analysis_list ----
+
+type siteAnalysisListArgs struct {
+	SN       string `json:"sn,omitempty" jsonschema:"站点 SN;不填查全部"`
+	Progress string `json:"progress,omitempty" jsonschema:"进度:pending、running、complete、error"`
+	Page     int    `json:"page,omitempty" jsonschema:"页码,默认 1"`
+	Size     int    `json:"size,omitempty" jsonschema:"每页条数,1~100,默认 20"`
+}
+
+var SiteAnalysisList = kit.Def[siteAnalysisListArgs]{
+	Name:  "site_analysis_list",
+	Title: "查询站点分析任务",
+	Description: "查询 ToolBox 中已创建的站点分析任务(页面创建,分析一段时间内站点的上下线、模式切换、告警、事件与运行数据)。" +
+		"返回 total 与 items:id(用于 site_analysis_timeline、site_analysis_export)、sn、description、timePeriod(分析时间段)、item(分析项)、progress、trigger、user。",
+	ReadOnly:   true,
+	Properties: map[string]map[string]any{"progress": {"enum": []string{"pending", "running", "complete", "error"}}},
+	Handler: func(ctx context.Context, a siteAnalysisListArgs) (*mcp.CallToolResult, error) {
+		page, size := kit.Page(a.Page), kit.Limit(a.Size, 20, 100)
+		var sn, progress *string
+		if v := strings.TrimSpace(a.SN); v != "" {
+			sn = &v
+		}
+		if a.Progress != "" {
+			progress = &a.Progress
+		}
+		res, err := services.GetSiteAnalysisManageList(uint(page), uint(size), sn, nil, progress, nil)
+		if err != nil {
+			return kit.Upstream(siteAnalysisSource, err), nil
+		}
+		return kit.List(res.Data, int(res.Total), size, "用 page 翻页,或用 sn 缩小范围"), nil
+	},
+}
+
+// ---- site_analysis_timeline ----
+
+type siteAnalysisTimelineArgs struct {
+	ID    string `json:"id" jsonschema:"分析任务 id(site_analysis_list 返回)"`
+	Type  string `json:"type,omitempty" jsonschema:"只看某类:offline(离线)、online(上线)、mode_change(模式切换)、alarm(告警)、event(事件);默认全部"`
+	Limit int    `json:"limit,omitempty" jsonschema:"最多返回条数,1~500,默认 200"`
+}
+
+var SiteAnalysisTimeline = kit.Def[siteAnalysisTimelineArgs]{
+	Name:  "site_analysis_timeline",
+	Title: "查看站点分析时间线",
+	Description: "查看一个已完成的站点分析任务的时间线:上下线、模式切换、告警、事件,按时间排序,以表格返回(time、type、info、data,data 超过 300 字截断)。" +
+		"时间线很长或需要运行数据(201 报文)时用 site_analysis_export 导出 CSV 分析。",
+	ReadOnly:   true,
+	Properties: map[string]map[string]any{"type": {"enum": []string{"offline", "online", "mode_change", "alarm", "event"}}},
+	Handler: func(ctx context.Context, a siteAnalysisTimelineArgs) (*mcp.CallToolResult, error) {
+		data, fail := siteAnalysisData(a.ID)
+		if fail != nil {
+			return fail, nil
+		}
+		limit := kit.Limit(a.Limit, 200, 500)
+		rows := [][]string{}
+		for _, it := range data.Data.Timeline {
+			if a.Type != "" && string(it.Type) != a.Type {
+				continue
+			}
+			rows = append(rows, []string{it.Time, string(it.Type), deref(it.Info), truncate(deref(it.Data), 300)})
+		}
+		if len(rows) == 0 {
+			return kit.Empty("时间线中没有符合条件的记录(任务进度:" + string(data.Progress) + ")。"), nil
+		}
+		return kit.Table([]string{"time", "type", "info", "data"}, rows, limit, "超过 limit 时请指定 type,或用 site_analysis_export 导出"), nil
+	},
+}
+
+// ---- site_analysis_export ----
+
+type siteAnalysisExportArgs struct {
+	ID string `json:"id" jsonschema:"分析任务 id(site_analysis_list 返回)"`
+}
+
+var SiteAnalysisExport = kit.Def[siteAnalysisExportArgs]{
+	Name:  "site_analysis_export",
+	Title: "导出站点分析结果",
+	Description: "把一个站点分析任务的全部结果导出为 CSV 文件保存到会话工作区:section=timeline 为时间线(type、info、data)," +
+		"section=runtime 为运行数据(data 为 201 报文 JSON)。请用 Python(pandas)读取分析,不要把全文读入上下文。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a siteAnalysisExportArgs) (*mcp.CallToolResult, error) {
+		data, fail := siteAnalysisData(a.ID)
+		if fail != nil {
+			return fail, nil
+		}
+		rows := make([][]string, 0, len(data.Data.Timeline)+len(data.Data.Runtime))
+		for _, it := range data.Data.Timeline {
+			rows = append(rows, []string{"timeline", it.Time, string(it.Type), deref(it.Info), deref(it.Data)})
+		}
+		for _, it := range data.Data.Runtime {
+			rows = append(rows, []string{"runtime", it.Time, "", "", it.Data_201})
+		}
+		summary := fmt.Sprintf("站点 %s:时间线 %d 条,运行数据 %d 条。", data.Sn, len(data.Data.Timeline), len(data.Data.Runtime))
+		return kit.Export("fwh://site-analysis/"+data.Sn+"_"+data.Id.Hex()+".csv", []string{"section", "time", "type", "info", "data"}, rows, summary), nil
+	},
+}
+
+func siteAnalysisData(idStr string) (*dto.SiteAnalysisDataInfo, *mcp.CallToolResult) {
+	id, err := primitive.ObjectIDFromHex(strings.TrimSpace(idStr))
+	if err != nil {
+		return nil, kit.Failf("参数 id 格式错误:%s", idStr)
+	}
+	data, err := services.GetSiteAnalysisData(id)
+	if errors.Is(err, mongo.ErrNoDocuments) || (err == nil && data == nil) {
+		return nil, kit.Failf("没有找到分析任务 %s,可以用 site_analysis_list 查看", idStr)
+	}
+	if err != nil {
+		return nil, kit.Upstream(siteAnalysisSource, err)
+	}
+	if data.Progress != domain.ProgressComplete {
+		return nil, kit.Failf("分析任务 %s 尚未完成(进度:%s),完成后再查看结果", idStr, data.Progress)
+	}
+	return data, nil
+}
+
+func deref(s *string) string {
+	if s == nil {
+		return ""
+	}
+	return *s
+}

+ 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 {