faultrecorddev.go 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641
  1. package tools
  2. // 故障录波(ToolBox 直连设备,经 MQTT 与设备交互;与云平台的 cloud_fault_record_* 是两套数据)。
  3. // 操作由主服务的执行器收发 MQTT,状态保存在 Mongo(FaultRecordOperations);本服务只提交操作、读取状态与文件。
  4. // 设备上的操作以长任务(kit.TaskDef)执行:任务 id 携带操作 id,状态直接取自操作记录。
  5. import (
  6. "context"
  7. "errors"
  8. "fmt"
  9. "strings"
  10. "time"
  11. "github.com/mark3labs/mcp-go/mcp"
  12. "fwh-toolbox-server/agentmcp/kit"
  13. "fwh-toolbox-server/domain"
  14. "fwh-toolbox-server/dto"
  15. faultRecordFile "fwh-toolbox-server/function/FaultRecordFile"
  16. proto "fwh-toolbox-server/function/FaultRecordProtocol"
  17. mongoRepo "fwh-toolbox-server/repositories/mongo"
  18. "fwh-toolbox-server/services"
  19. )
  20. const (
  21. frEnvDesc = "服务器环境:prod(生产,默认)/ test(测试)"
  22. frDeviceKindDesc = "设备:aGate(默认)、aPowerPE、aPowerMPPT、aHub;站点有哪些设备、是否支持见 fault_record_devices"
  23. frDeviceSnDesc = "设备 SN;aGate 可不填(即站点 SN),其他设备必填(见 fault_record_devices)"
  24. // frSyncWait 普通调用(非任务方式)时等待设备回复的最长时间,低于单次调用 60 秒超时
  25. frSyncWait = 45 * time.Second
  26. )
  27. var frDeviceProps = map[string]map[string]any{
  28. "env": {"enum": []string{"prod", "test"}},
  29. "deviceKind": {"enum": []string{proto.DeviceKindAGate, proto.DeviceKindAPowerPE, proto.DeviceKindAPowerMPPT, proto.DeviceKindAHub}},
  30. }
  31. // frStageMessages 进行中各阶段的说明(显示在平台的后台调用栏)
  32. var frStageMessages = map[domain.FaultRecordOpStatus]string{
  33. domain.FaultRecordStatusPending: "等待发送到设备",
  34. domain.FaultRecordStatusSent: "已发送,等待设备确认",
  35. domain.FaultRecordStatusAccepted: "设备已接受,等待结果",
  36. domain.FaultRecordStatusReady: "设备已准备录波文件,等待上传",
  37. domain.FaultRecordStatusUploading: "设备正在上传录波文件",
  38. domain.FaultRecordStatusUploaded: "上传完成,正在下载",
  39. domain.FaultRecordStatusDownloaded: "文件已保存,正在解析",
  40. }
  41. // frFailMessages 失败终态的说明(写给模型)
  42. var frFailMessages = map[domain.FaultRecordOpStatus]string{
  43. domain.FaultRecordStatusBusy: "设备正在处理其他命令,稍后重试",
  44. domain.FaultRecordStatusRejected: "设备拒绝了请求",
  45. domain.FaultRecordStatusTimeout: "等待设备超时(设备可能离线或无响应)",
  46. domain.FaultRecordStatusUploadFailed: "设备上传录波文件失败",
  47. domain.FaultRecordStatusDownloadFailed: "录波文件下载失败,可以用 fault_record_retry_download 重试下载",
  48. domain.FaultRecordStatusParseFailed: "录波文件已保存但解析失败,可以用 fault_record_export 导出原始文件",
  49. domain.FaultRecordStatusInterrupted: "服务重启时操作中断",
  50. domain.FaultRecordStatusGatewayLocked: "该网关已有其他录波操作在进行,等它结束后再试",
  51. domain.FaultRecordStatusError: "操作出错(如主服务 MQTT 不可用)",
  52. }
  53. type frDeviceArgs struct {
  54. SN string `json:"sn" jsonschema:"站点 SN(aGate 序列号)"`
  55. Env string `json:"env,omitempty" jsonschema:"服务器环境:prod(生产,默认)/ test(测试)"`
  56. DeviceKind string `json:"deviceKind,omitempty" jsonschema:"设备:aGate(默认)、aPowerPE、aPowerMPPT、aHub;站点有哪些设备、是否支持见 fault_record_devices"`
  57. DeviceSn string `json:"deviceSn,omitempty" jsonschema:"设备 SN;aGate 可不填(即站点 SN),其他设备必填(见 fault_record_devices)"`
  58. }
  59. // frRequest 由设备参数构造操作请求
  60. func frRequest(a frDeviceArgs, kind domain.FaultRecordOpKind) (dto.FaultRecordCreateOpReq, error) {
  61. sn, err := kit.Required("sn", a.SN)
  62. if err != nil {
  63. return dto.FaultRecordCreateOpReq{}, err
  64. }
  65. env, err := kit.Enum("env", a.Env, "prod", "prod", "test")
  66. if err != nil {
  67. return dto.FaultRecordCreateOpReq{}, err
  68. }
  69. devKind := a.DeviceKind
  70. if devKind == "" {
  71. devKind = proto.DeviceKindAGate
  72. }
  73. devType, ok := proto.DeviceKindProtocolType[devKind]
  74. if !ok || devType == "" {
  75. return dto.FaultRecordCreateOpReq{}, fmt.Errorf("不支持的设备:%s(可选 aGate、aPowerPE、aPowerMPPT、aHub)", devKind)
  76. }
  77. devSn := strings.TrimSpace(a.DeviceSn)
  78. if devKind != proto.DeviceKindAGate && devSn == "" {
  79. return dto.FaultRecordCreateOpReq{}, fmt.Errorf("%s 需要 deviceSn,可用 fault_record_devices 查看", devKind)
  80. }
  81. return dto.FaultRecordCreateOpReq{Env: env, GatewaySn: sn, DeviceType: devType, DeviceSn: devSn, Kind: string(kind)}, nil
  82. }
  83. // frSubmit 提交操作(由主服务执行),返回操作 id
  84. func frSubmit(ctx context.Context, req dto.FaultRecordCreateOpReq) (string, *mcp.CallToolResult) {
  85. id, err := services.SubmitFaultRecordOperation(ctx, req, ServiceAccount)
  86. if errors.Is(err, services.ErrFaultRecordInvalid) {
  87. return "", kit.Fail(err)
  88. }
  89. if err != nil {
  90. return "", kit.Upstream("ToolBox 故障录波", err)
  91. }
  92. return id, nil
  93. }
  94. // frOpView 写给模型的操作结果:逐项结果,不含原始报文
  95. func frOpView(d *dto.FaultRecordOpDetail) map[string]any {
  96. out := map[string]any{
  97. "operationId": d.ID,
  98. "kind": d.Kind,
  99. "env": d.Env,
  100. "gatewaySn": d.GatewaySn,
  101. "deviceType": d.DeviceType,
  102. "deviceSn": d.DeviceSn,
  103. "request": d.Summary,
  104. "status": d.Status,
  105. "operator": d.Operator,
  106. "createdAt": fmtBeijing(d.CreatedAt),
  107. }
  108. if d.Error != "" {
  109. out["error"] = d.Error
  110. }
  111. if d.TimeoutStage != "" {
  112. out["timeoutStage"] = d.TimeoutStage
  113. }
  114. if d.ReplyDesc != "" {
  115. out["reply"] = d.ReplyDesc
  116. }
  117. if len(d.Items) > 0 {
  118. items := make([]map[string]any, 0, len(d.Items))
  119. for _, it := range d.Items {
  120. m := map[string]any{"name": it.Name, "ok": it.Ok, "result": it.ResultDesc}
  121. if it.Code != 0 {
  122. m["code"] = it.Code
  123. }
  124. if it.Id != 0 {
  125. m["id"] = it.Id
  126. }
  127. if it.SetVal != "" {
  128. m["setVal"] = it.SetVal
  129. }
  130. if it.Decoded != nil {
  131. m["value"] = it.Decoded
  132. } else if it.Raw != "" {
  133. m["raw"] = it.Raw
  134. }
  135. if it.DecodeErr != "" {
  136. m["decodeError"] = it.DecodeErr
  137. }
  138. items = append(items, m)
  139. }
  140. out["items"] = items
  141. }
  142. if d.HasFile {
  143. out["hasFile"] = true
  144. out["next"] = "录波数据:fault_record_data 查看故障信息与波形概要,fault_record_export 导出 CSV"
  145. }
  146. return out
  147. }
  148. // frTaskState 把操作记录翻译为任务状态
  149. func frTaskState(d *dto.FaultRecordOpDetail) kit.TaskState {
  150. st := kit.TaskState{CreatedAt: d.CreatedAt}
  151. status := domain.FaultRecordOpStatus(d.Status)
  152. switch {
  153. case !status.Terminal():
  154. st.Status = mcp.TaskStatusWorking
  155. st.Message = frStageMessages[status]
  156. if st.Message == "" {
  157. st.Message = d.Status
  158. }
  159. case status == domain.FaultRecordStatusDone || status == domain.FaultRecordStatusItemFailed:
  160. // item_failed:设备已回复,但部分子项失败,逐项结果中有说明
  161. st.Status, st.Message = mcp.TaskStatusCompleted, "完成"
  162. if status == domain.FaultRecordStatusItemFailed {
  163. st.Message = "完成,部分子项失败"
  164. }
  165. st.Result = kit.Object(frOpView(d))
  166. case status == domain.FaultRecordStatusCancelled:
  167. st.Status, st.Message = mcp.TaskStatusCancelled, "已取消"
  168. st.Result = kit.Failf("故障录波操作已取消(操作 id %s)。", d.ID)
  169. default:
  170. st.Status = mcp.TaskStatusFailed
  171. reason := frFailMessages[status]
  172. if reason == "" {
  173. reason = d.Status
  174. }
  175. st.Message = reason
  176. view := frOpView(d)
  177. view["reason"] = reason
  178. st.Result = kit.Object(view)
  179. st.Result.IsError = true
  180. }
  181. return st
  182. }
  183. // frStatus 读取操作并翻译为任务状态(kit.TaskDef.Status)
  184. func frStatus(ctx context.Context, ref kit.TaskRef) (kit.TaskState, error) {
  185. d, err := services.GetFaultRecordOperation(ctx, ref.Key)
  186. if errors.Is(err, mongoRepo.ErrFaultRecordOpNotFound) || errors.Is(err, services.ErrFaultRecordInvalid) {
  187. return kit.TaskState{}, kit.ErrTaskNotFound
  188. }
  189. if err != nil {
  190. return kit.TaskState{}, err
  191. }
  192. return frTaskState(d), nil
  193. }
  194. // frCancel 请求取消操作(执行器 1 秒内响应);已结束的操作无需取消
  195. func frCancel(ctx context.Context, ref kit.TaskRef) error {
  196. err := services.CancelFaultRecordOperation(ctx, ref.Key)
  197. if errors.Is(err, services.ErrFaultRecordInvalid) {
  198. return nil
  199. }
  200. return err
  201. }
  202. // frWait 普通调用时等待操作结束(最多 frSyncWait),未结束时返回进度与操作 id
  203. func frWait(ctx context.Context, id string) *mcp.CallToolResult {
  204. deadline := time.Now().Add(frSyncWait)
  205. for {
  206. d, err := services.GetFaultRecordOperation(ctx, id)
  207. if err != nil {
  208. return kit.Upstream("ToolBox 故障录波", err)
  209. }
  210. st := frTaskState(d)
  211. if st.Status.IsTerminal() {
  212. return st.Result
  213. }
  214. if time.Now().After(deadline) {
  215. return kit.Object(map[string]any{"operationId": id, "status": d.Status, "message": st.Message + ";设备还没有回复完,稍后用 fault_record_operation_get 查看结果"})
  216. }
  217. select {
  218. case <-ctx.Done():
  219. return kit.Object(map[string]any{"operationId": id, "status": d.Status, "message": "等待中断,稍后用 fault_record_operation_get 查看结果"})
  220. case <-time.After(time.Second):
  221. }
  222. }
  223. }
  224. // frSubmitted 普通调用提交耗时较长的操作后立即返回
  225. func frSubmitted(id, what string) *mcp.CallToolResult {
  226. return kit.Object(map[string]any{"operationId": id, "status": "pending", "message": "已提交" + what + ",由主服务与设备交互;用 fault_record_operation_get 查看进度与结果"})
  227. }
  228. // ---- fault_record_devices ----
  229. type frDevicesArgs struct {
  230. SN string `json:"sn" jsonschema:"站点 SN(aGate 序列号)"`
  231. Env string `json:"env,omitempty" jsonschema:"服务器环境:prod(生产,默认)/ test(测试)"`
  232. }
  233. var FaultRecordDevices = kit.Def[frDevicesArgs]{
  234. Name: "fault_record_devices",
  235. Title: "查询站点可做故障录波的设备",
  236. Description: "列出站点中可以做故障录波操作的设备:aGate、各 aPower-PE、带 MPPT 的 aPower-MPPT、各 aHub,以及站点是否在线。" +
  237. "返回 devices:kind(设备种类,作为其他 fault_record_* 工具的 deviceKind)、sn(作为 deviceSn)、model、supported(是否可操作)、note(不可操作的原因)。" +
  238. "对设备做读取、召唤、设置前先用本工具确认设备与 SN。",
  239. ReadOnly: true,
  240. Properties: map[string]map[string]any{"env": {"enum": []string{"prod", "test"}}},
  241. Handler: func(ctx context.Context, a frDevicesArgs) (*mcp.CallToolResult, error) {
  242. sn, err := kit.Required("sn", a.SN)
  243. if err != nil {
  244. return kit.Fail(err), nil
  245. }
  246. env, err := kit.Enum("env", a.Env, "prod", "prod", "test")
  247. if err != nil {
  248. return kit.Fail(err), nil
  249. }
  250. d, err := services.GetFaultRecordSiteDevices(env, sn)
  251. if errors.Is(err, services.ErrFaultRecordInvalid) {
  252. return kit.Fail(err), nil
  253. }
  254. if err != nil {
  255. return kit.Upstream("FWH 平台设备信息接口", err), nil
  256. }
  257. return kit.Object(d), nil
  258. },
  259. }
  260. // ---- fault_record_device_info ----
  261. type frDeviceInfoArgs struct {
  262. frDeviceArgs
  263. Codes []int `json:"codes,omitempty" jsonschema:"要读取的信息类别:1 故障总体信息(含快照、可配通道当前值)、2 录波功能基本信息(含采样频率)、3 故障录波总目录(设备上已有的录波记录序号,召唤录波前先读);默认全部"`
  264. }
  265. var FaultRecordDeviceInfo = kit.TaskDef[frDeviceInfoArgs]{
  266. Def: kit.Def[frDeviceInfoArgs]{
  267. Name: "fault_record_device_info",
  268. Title: "读取设备故障录波信息",
  269. Description: "通过 MQTT 从设备实时读取故障录波相关信息(不修改设备):故障总体信息、录波功能基本信息、故障录波总目录。" +
  270. "召唤录波(fault_record_fetch)前用类别 3 查看设备上有哪些录波记录序号。设备通常几秒到几十秒内回复;" +
  271. "返回 items(逐项结果,value 为解析后的内容)。设备离线时会超时失败。",
  272. ReadOnly: true,
  273. Properties: mergeProps(frDeviceProps, map[string]map[string]any{"codes": {"items": map[string]any{"type": "integer", "enum": []int{1, 2, 3}}}}),
  274. Handler: func(ctx context.Context, a frDeviceInfoArgs) (*mcp.CallToolResult, error) {
  275. id, fail := frStartDeviceInfo(ctx, a)
  276. if fail != nil {
  277. return fail, nil
  278. }
  279. return frWait(ctx, id), nil
  280. },
  281. },
  282. Poll: 5 * time.Second,
  283. Start: func(ctx context.Context, a frDeviceInfoArgs) (kit.TaskStart, *mcp.CallToolResult) {
  284. id, fail := frStartDeviceInfo(ctx, a)
  285. return kit.TaskStart{Key: id, Flag: "op", Note: "已向设备发送读取请求,设备回复后平台会把结果发给你"}, fail
  286. },
  287. Status: frStatus,
  288. Cancel: frCancel,
  289. }
  290. func frStartDeviceInfo(ctx context.Context, a frDeviceInfoArgs) (string, *mcp.CallToolResult) {
  291. req, err := frRequest(a.frDeviceArgs, domain.FaultRecordOpDeviceInfo)
  292. if err != nil {
  293. return "", kit.Fail(err)
  294. }
  295. req.CodeList = a.Codes
  296. if len(req.CodeList) == 0 {
  297. req.CodeList = []int{1, 2, 3}
  298. }
  299. return frSubmit(ctx, req)
  300. }
  301. // ---- fault_record_param_read ----
  302. type frParamReadArgs struct {
  303. frDeviceArgs
  304. Ids []int `json:"ids,omitempty" jsonschema:"要读取的功能码:4 设备时间、7 录波开关;默认全部。采样频率(5)、可配采样通道(6)的当前值在 fault_record_device_info 的类别 2、1 中"`
  305. }
  306. var FaultRecordParamRead = kit.TaskDef[frParamReadArgs]{
  307. Def: kit.Def[frParamReadArgs]{
  308. Name: "fault_record_param_read",
  309. Title: "读取设备故障录波参数",
  310. Description: "通过 MQTT 从设备读取故障录波参数的当前值(不修改设备):设备时间(功能码 4)、录波开关(功能码 7)。" +
  311. "返回 items(逐项结果,value 为解析后的值)。设备离线时会超时失败。",
  312. ReadOnly: true,
  313. Properties: mergeProps(frDeviceProps, map[string]map[string]any{"ids": {"items": map[string]any{"type": "integer", "enum": []int{4, 7}}}}),
  314. Handler: func(ctx context.Context, a frParamReadArgs) (*mcp.CallToolResult, error) {
  315. id, fail := frStartParamRead(ctx, a)
  316. if fail != nil {
  317. return fail, nil
  318. }
  319. return frWait(ctx, id), nil
  320. },
  321. },
  322. Poll: 5 * time.Second,
  323. Start: func(ctx context.Context, a frParamReadArgs) (kit.TaskStart, *mcp.CallToolResult) {
  324. id, fail := frStartParamRead(ctx, a)
  325. return kit.TaskStart{Key: id, Flag: "op", Note: "已向设备发送读取请求,设备回复后平台会把结果发给你"}, fail
  326. },
  327. Status: frStatus,
  328. Cancel: frCancel,
  329. }
  330. func frStartParamRead(ctx context.Context, a frParamReadArgs) (string, *mcp.CallToolResult) {
  331. req, err := frRequest(a.frDeviceArgs, domain.FaultRecordOpParamRead)
  332. if err != nil {
  333. return "", kit.Fail(err)
  334. }
  335. req.IdList = a.Ids
  336. if len(req.IdList) == 0 {
  337. req.IdList = []int{4, 7}
  338. }
  339. return frSubmit(ctx, req)
  340. }
  341. // ---- fault_record_operations ----
  342. type frOperationsArgs struct {
  343. SN string `json:"sn,omitempty" jsonschema:"站点 SN;不填查全部站点"`
  344. Env string `json:"env,omitempty" jsonschema:"服务器环境:prod / test;不填查全部"`
  345. Kind string `json:"kind,omitempty" jsonschema:"操作类型,多个用逗号分隔:device_info(读取信息)、record_fetch(召唤录波)、param_read(读取参数)、param_write(设置参数)"`
  346. Page int `json:"page,omitempty" jsonschema:"页码,默认 1"`
  347. Size int `json:"size,omitempty" jsonschema:"每页条数,1~100,默认 20"`
  348. }
  349. var FaultRecordOperations = kit.Def[frOperationsArgs]{
  350. Name: "fault_record_operations",
  351. Title: "查询故障录波操作记录",
  352. Description: "分页查询 ToolBox 对设备做过的故障录波操作(读取信息、召唤录波、读取/设置参数),按时间倒序,包括页面上的操作与 AI 助手的操作。" +
  353. "返回 total 与 items:id(操作 id,用于 fault_record_operation_get、fault_record_data、fault_record_export)、kind、gatewaySn、deviceSn、summary(请求摘要)、" +
  354. "status、operator、hasFile(是否有已保存的录波文件)、createdAt。",
  355. ReadOnly: true,
  356. Properties: map[string]map[string]any{"env": {"enum": []string{"prod", "test"}}},
  357. Handler: func(ctx context.Context, a frOperationsArgs) (*mcp.CallToolResult, error) {
  358. size := kit.Limit(a.Size, 20, 100)
  359. page := kit.Page(a.Page)
  360. res, err := services.ListFaultRecordOperations(ctx, mongoRepo.FaultRecordOpFilter{
  361. Env: a.Env, GatewaySn: strings.TrimSpace(a.SN), Kind: a.Kind,
  362. }, int64(page), int64(size))
  363. if err != nil {
  364. return kit.Upstream("ToolBox 故障录波", err), nil
  365. }
  366. items := make([]map[string]any, 0, len(res.Data))
  367. for _, s := range res.Data {
  368. m := map[string]any{
  369. "id": s.ID, "env": s.Env, "kind": s.Kind, "gatewaySn": s.GatewaySn, "deviceType": s.DeviceType, "deviceSn": s.DeviceSn,
  370. "summary": s.Summary, "status": s.Status, "operator": s.Operator, "hasFile": s.HasFile, "createdAt": fmtBeijing(s.CreatedAt),
  371. }
  372. if s.Error != "" {
  373. m["error"] = s.Error
  374. }
  375. items = append(items, m)
  376. }
  377. return kit.List(items, int(res.Total), size, "用 page 翻页,或用 sn、kind 缩小范围"), nil
  378. },
  379. }
  380. // ---- fault_record_operation_get ----
  381. type frOperationGetArgs struct {
  382. ID string `json:"id" jsonschema:"操作 id(fault_record_operations 或各操作工具返回的 operationId)"`
  383. }
  384. var FaultRecordOperationGet = kit.Def[frOperationGetArgs]{
  385. Name: "fault_record_operation_get",
  386. Title: "查询故障录波操作结果",
  387. Description: "查询一次故障录波操作的状态与逐项结果(设备回复的解析内容,不含原始报文)。" +
  388. "status 为 done 表示成功、item_failed 表示部分子项失败,其他结束状态见 error。召唤录波成功后 hasFile=true,可用 fault_record_data、fault_record_export 查看数据。",
  389. ReadOnly: true,
  390. Handler: func(ctx context.Context, a frOperationGetArgs) (*mcp.CallToolResult, error) {
  391. d, err := services.GetFaultRecordOperation(ctx, strings.TrimSpace(a.ID))
  392. if errors.Is(err, mongoRepo.ErrFaultRecordOpNotFound) || errors.Is(err, services.ErrFaultRecordInvalid) {
  393. return kit.Failf("没有找到操作 %s,可以用 fault_record_operations 查看操作记录", a.ID), nil
  394. }
  395. if err != nil {
  396. return kit.Upstream("ToolBox 故障录波", err), nil
  397. }
  398. view := frOpView(d)
  399. if st := frTaskState(d); !st.Status.IsTerminal() {
  400. view["progress"] = st.Message
  401. }
  402. return kit.Object(view), nil
  403. },
  404. }
  405. // ---- fault_record_data ----
  406. var FaultRecordData = kit.Def[frOperationGetArgs]{
  407. Name: "fault_record_data",
  408. Title: "查看故障录波数据概要",
  409. Description: "解析一次召唤录波(record_fetch)保存的录波文件,返回每条记录的故障信息与波形概要:" +
  410. "failure(故障时间、触发源、软件版本、快照通道等)、waveform(采样点数、触发前点数、采样频率,以及每个通道的 min、max 与触发点的值)。" +
  411. "不含逐点采样值;需要逐点分析时用 fault_record_export 导出 CSV。",
  412. ReadOnly: true,
  413. Handler: func(ctx context.Context, a frOperationGetArgs) (*mcp.CallToolResult, error) {
  414. v, err := services.GetFaultRecordRecord(ctx, strings.TrimSpace(a.ID))
  415. if errors.Is(err, mongoRepo.ErrFaultRecordOpNotFound) || errors.Is(err, services.ErrFaultRecordInvalid) {
  416. return kit.Fail(err), nil
  417. }
  418. if err != nil {
  419. return kit.Upstream("ToolBox 故障录波文件", err), nil
  420. }
  421. records := make([]map[string]any, 0, len(v.Records))
  422. for _, r := range v.Records {
  423. records = append(records, map[string]any{
  424. "batch": r.Batch, "sn": r.Sn, "recordIndex": r.RecordIndex,
  425. "failure": r.Failure, "waveform": waveformSummary(r.Waveform),
  426. })
  427. }
  428. return kit.Object(map[string]any{"operationId": v.OperationID, "records": records}), nil
  429. },
  430. }
  431. // waveformSummary 波形概要:通道统计,不含逐点数据
  432. func waveformSummary(w faultRecordFile.Waveform) map[string]any {
  433. channels := make([]map[string]any, 0, len(w.Channels))
  434. for _, ch := range w.Channels {
  435. c := map[string]any{"channel": fmt.Sprintf("ch%d 0x%04X", ch.Number, ch.Meaning)}
  436. if ch.Configurable != nil {
  437. c["configurable"] = ch.Configurable.TypeName
  438. }
  439. if len(ch.Values) > 0 {
  440. lo, hi := ch.Values[0], ch.Values[0]
  441. for _, x := range ch.Values {
  442. lo, hi = min(lo, x), max(hi, x)
  443. }
  444. c["min"], c["max"] = lo, hi
  445. if w.PrePoints >= 0 && w.PrePoints < len(ch.Values) {
  446. c["atTrigger"] = ch.Values[w.PrePoints]
  447. }
  448. }
  449. channels = append(channels, c)
  450. }
  451. return map[string]any{"points": w.Points, "prePoints": w.PrePoints, "sampleFrequency": w.SampleFrequency, "channels": channels}
  452. }
  453. // ---- fault_record_export ----
  454. var FaultRecordExport = kit.Def[frOperationGetArgs]{
  455. Name: "fault_record_export",
  456. Title: "导出故障录波数据",
  457. Description: "把一次召唤录波保存的录波文件解析为 CSV(每条记录的故障信息、快照与逐点波形,打包为 zip),以文件形式保存到会话工作区。" +
  458. "返回文件路径;请用 Python 解压后用 pandas 分析、作图,不要把全文读入上下文。",
  459. ReadOnly: true,
  460. Handler: func(ctx context.Context, a frOperationGetArgs) (*mcp.CallToolResult, error) {
  461. name, data, err := services.ExportFaultRecordFile(ctx, strings.TrimSpace(a.ID), "csv")
  462. if errors.Is(err, mongoRepo.ErrFaultRecordOpNotFound) || errors.Is(err, services.ErrFaultRecordInvalid) {
  463. return kit.Fail(err), nil
  464. }
  465. if err != nil {
  466. return kit.Upstream("ToolBox 故障录波文件", err), nil
  467. }
  468. return kit.ExportFile("fwh://fault-record/"+name, "application/zip", data, "zip 内每条记录一组 CSV(故障信息、快照、逐点波形,通道列名为 ch<序号> 0x<含义码>)。"), nil
  469. },
  470. }
  471. // ---- 写操作(ops):fault_record_fetch、fault_record_param_write、fault_record_cancel、fault_record_retry_download ----
  472. type frFetchArgs struct {
  473. frDeviceArgs
  474. RecordIndexes []int `json:"recordIndexes" jsonschema:"要召唤的录波记录序号(从 fault_record_device_info 类别 3 的故障录波总目录中选),可多条,设备打成一个包上传"`
  475. }
  476. var FaultRecordFetch = kit.TaskDef[frFetchArgs]{
  477. Def: kit.Def[frFetchArgs]{
  478. Name: "fault_record_fetch",
  479. Title: "召唤设备故障录波",
  480. Description: "通过 MQTT 让设备上传指定的故障录波记录,下载后保存并解析。耗时通常几分钟(设备打包、上传、下载)。" +
  481. "以长任务方式执行:提交后立即返回,完成后平台会把结果发给你;之后用 fault_record_data 查看概要、fault_record_export 导出 CSV。" +
  482. "先用 fault_record_device_info(类别 3)确认记录序号。这是写操作,执行前会请求确认。",
  483. ReadOnly: false,
  484. Properties: mergeProps(frDeviceProps, map[string]map[string]any{"recordIndexes": {"minItems": 1, "items": map[string]any{"type": "integer", "minimum": 1}}}),
  485. Handler: func(ctx context.Context, a frFetchArgs) (*mcp.CallToolResult, error) {
  486. id, fail := frStartFetch(ctx, a)
  487. if fail != nil {
  488. return fail, nil
  489. }
  490. return frSubmitted(id, "召唤录波"), nil
  491. },
  492. },
  493. Poll: 15 * time.Second,
  494. Start: func(ctx context.Context, a frFetchArgs) (kit.TaskStart, *mcp.CallToolResult) {
  495. id, fail := frStartFetch(ctx, a)
  496. return kit.TaskStart{Key: id, Flag: "op", Note: "已提交召唤录波,设备上传与下载通常需要几分钟,完成后平台会把结果发给你"}, fail
  497. },
  498. Status: frStatus,
  499. Cancel: frCancel,
  500. }
  501. func frStartFetch(ctx context.Context, a frFetchArgs) (string, *mcp.CallToolResult) {
  502. req, err := frRequest(a.frDeviceArgs, domain.FaultRecordOpRecordFetch)
  503. if err != nil {
  504. return "", kit.Fail(err)
  505. }
  506. req.RecordIndexes = a.RecordIndexes
  507. return frSubmit(ctx, req)
  508. }
  509. type frParamWriteArgs struct {
  510. frDeviceArgs
  511. 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 开)"`
  512. }
  513. var FaultRecordParamWrite = kit.TaskDef[frParamWriteArgs]{
  514. Def: kit.Def[frParamWriteArgs]{
  515. Name: "fault_record_param_write",
  516. Title: "设置设备故障录波参数",
  517. Description: "通过 MQTT 设置设备的故障录波参数或执行动作(清除波形、手动触发录波、对时、采样频率、通道配置、录波开关)。" +
  518. "会修改设备状态,其中清除波形不可恢复:调用前向用户说明要设置的设备、功能码与值。设置前可用 fault_record_param_read、fault_record_device_info 读取当前值。" +
  519. "返回逐项设置结果。这是破坏性写操作,每次执行前都会请求确认。",
  520. ReadOnly: false,
  521. Destructive: true,
  522. Properties: mergeProps(frDeviceProps, map[string]map[string]any{"params": {"minItems": 1}}),
  523. Handler: func(ctx context.Context, a frParamWriteArgs) (*mcp.CallToolResult, error) {
  524. id, fail := frStartParamWrite(ctx, a)
  525. if fail != nil {
  526. return fail, nil
  527. }
  528. return frWait(ctx, id), nil
  529. },
  530. },
  531. Poll: 5 * time.Second,
  532. Start: func(ctx context.Context, a frParamWriteArgs) (kit.TaskStart, *mcp.CallToolResult) {
  533. id, fail := frStartParamWrite(ctx, a)
  534. return kit.TaskStart{Key: id, Flag: "op", Note: "已向设备发送设置请求,设备回复后平台会把结果发给你"}, fail
  535. },
  536. Status: frStatus,
  537. Cancel: frCancel,
  538. }
  539. func frStartParamWrite(ctx context.Context, a frParamWriteArgs) (string, *mcp.CallToolResult) {
  540. req, err := frRequest(a.frDeviceArgs, domain.FaultRecordOpParamWrite)
  541. if err != nil {
  542. return "", kit.Fail(err)
  543. }
  544. req.ParaList = a.Params
  545. return frSubmit(ctx, req)
  546. }
  547. var FaultRecordCancel = kit.Def[frOperationGetArgs]{
  548. Name: "fault_record_cancel",
  549. Title: "取消故障录波操作",
  550. Description: "取消一次进行中的故障录波操作(在页面或其他会话中发起的也可以取消),执行器 1 秒内停止。已结束的操作无法取消。" +
  551. "本会话以长任务方式发起的操作,也可以在平台的后台调用栏中取消。这是写操作,执行前会请求确认。",
  552. ReadOnly: false,
  553. // 取消已取消的操作会报“已结束”,不会产生额外影响
  554. Idempotent: true,
  555. Handler: func(ctx context.Context, a frOperationGetArgs) (*mcp.CallToolResult, error) {
  556. err := services.CancelFaultRecordOperation(ctx, strings.TrimSpace(a.ID))
  557. if errors.Is(err, services.ErrFaultRecordInvalid) || errors.Is(err, mongoRepo.ErrFaultRecordOpNotFound) {
  558. return kit.Fail(err), nil
  559. }
  560. if err != nil {
  561. return kit.Upstream("ToolBox 故障录波", err), nil
  562. }
  563. return mcp.NewToolResultText("已请求取消操作 " + a.ID + ",用 fault_record_operation_get 确认状态。"), nil
  564. },
  565. }
  566. var FaultRecordRetryDownload = kit.Def[frOperationGetArgs]{
  567. Name: "fault_record_retry_download",
  568. Title: "重试下载故障录波文件",
  569. Description: "设备已上传录波文件、但下载失败或超时的召唤录波操作,重新下载(按 15 秒间隔重试到总超时)。" +
  570. "只在 fault_record_operation_get 显示上传已完成且下载失败时使用。这是写操作,执行前会请求确认。",
  571. ReadOnly: false,
  572. Handler: func(ctx context.Context, a frOperationGetArgs) (*mcp.CallToolResult, error) {
  573. err := services.RetryFaultRecordDownload(ctx, strings.TrimSpace(a.ID), ServiceAccount)
  574. if errors.Is(err, services.ErrFaultRecordInvalid) || errors.Is(err, mongoRepo.ErrFaultRecordOpNotFound) {
  575. return kit.Fail(err), nil
  576. }
  577. if err != nil {
  578. return kit.Upstream("ToolBox 故障录波", err), nil
  579. }
  580. return mcp.NewToolResultText("已重新提交下载,用 fault_record_operation_get 查看结果。"), nil
  581. },
  582. }
  583. // mergeProps 合并多组字段补充约束
  584. func mergeProps(groups ...map[string]map[string]any) map[string]map[string]any {
  585. out := map[string]map[string]any{}
  586. for _, g := range groups {
  587. for k, v := range g {
  588. out[k] = v
  589. }
  590. }
  591. return out
  592. }