|
|
@@ -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
|
|
|
+}
|