package tools import ( "context" "fmt" "regexp" "strconv" "strings" "time" "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" ) // ---- site_search ---- type siteSearchArgs struct { 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 站点信息表(约 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: regexp.QuoteMeta(keyword), // 仓库按正则匹配,转义后按字面匹配 Filter: filter, PageNum: uint(page), PageSize: uint(size), }) if err != nil { return kit.Upstream("站点信息表", err), 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 }, } // 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 { SN string `json:"sn" jsonschema:"站点 SN(aGate 序列号)"` } var SiteGet = kit.Def[siteGetArgs]{ Name: "site_get", Title: "查询站点概况", Description: "查询单个站点的概况,合并三部分:info(站点信息表中的基本信息、版本、网络、激活状态等)、" + "mode(当前工作模式与相关设置)、currentAlarms(当前未结束的告警,最多 20 条)。" + "任一部分查询失败时在 errors 中说明,不影响其他部分。排查站点问题时通常先调用本工具。", ReadOnly: true, Handler: func(ctx context.Context, a siteGetArgs) (*mcp.CallToolResult, error) { sn, err := kit.Required("sn", a.SN) if err != nil { return kit.Fail(err), nil } out := map[string]any{"sn": sn} var errs []string if info, err := services.GetSiteInfoData(sn); err != nil { errs = append(errs, "info: "+kit.UpstreamText("站点信息表", err)) } else { out["info"] = info } if mode, err := repositories.QuerySiteModeInfo(sn); err != nil { errs = append(errs, "mode: "+kit.UpstreamText("FWH 平台工作模式接口", err)) } else if mode != nil { out["mode"] = mode.Result } if alarms, err := repositories.QuerySiteAlarmList(sn); err != nil { errs = append(errs, "currentAlarms: "+kit.UpstreamText("FWH 平台实时告警接口", err)) } else if alarms != nil && alarms.Result != nil { list := *alarms.Result out["currentAlarmTotal"] = len(list) if len(list) > 20 { list = list[:20] } out["currentAlarms"] = list } if len(errs) > 0 { out["errors"] = errs } return kit.Object(out), nil }, } // ---- site_power_flow ---- type sitePowerFlowArgs struct { SN string `json:"sn" jsonschema:"站点 SN(aGate 序列号)"` Date string `json:"date" jsonschema:"日期,yyyy-MM-dd,站点当地日期"` } // powerFlowCols 功率流表格的列(功率单位 kW,SOC 单位 %,电量单位 kWh) var powerFlowCols = []string{"time", "pv_kw", "grid_kw", "apower_kw", "load_kw", "gen_kw", "soc_pct", "run_status", "kwh_total"} var SitePowerFlow = kit.Def[sitePowerFlowArgs]{ Name: "site_power_flow", Title: "查询站点单日功率流", Description: "查询单个站点某一天的功率流(约 5 分钟一个点),以 TSV 表格返回。" + "列:time(设备当地时间)、pv_kw(光伏)、grid_kw(电网,正为取电、负为馈网,以平台定义为准)、apower_kw(aPower,正为放电、负为充电,以平台定义为准)、" + "load_kw(家庭负载)、gen_kw(发电机)、soc_pct(电池 SOC,%)、run_status(0 待机 1 充电 2 放电 3 故障)、kwh_total(可用电量 kWh)。" + "需要多天数据时用 site_power_flow_export。", ReadOnly: true, Handler: func(ctx context.Context, a sitePowerFlowArgs) (*mcp.CallToolResult, error) { sn, err := kit.Required("sn", a.SN) if err != nil { return kit.Fail(err), nil } day, err := kit.ParseDate("date", a.Date, false) if err != nil { return kit.Fail(err), nil } rows, err := powerFlowRows(sn, *day) if err != nil { return kit.Upstream("FWH 平台功率流接口", err), nil } return kit.Table(powerFlowCols, rows, 0, "数据过多已截断,请用 site_power_flow_export 导出完整数据"), nil }, } // ---- site_power_flow_export ---- type sitePowerFlowExportArgs struct { SN string `json:"sn" jsonschema:"站点 SN(aGate 序列号)"` From string `json:"from" jsonschema:"开始日期,yyyy-MM-dd(含)"` To string `json:"to" jsonschema:"结束日期,yyyy-MM-dd(含),与开始日期相差不超过 31 天"` } var SitePowerFlowExport = kit.Def[sitePowerFlowExportArgs]{ Name: "site_power_flow_export", Title: "导出站点多日功率流", Description: "导出单个站点一段日期(最多 31 天)的功率流明细为 CSV 文件,列与 site_power_flow 相同,另加 date 列。" + "文件保存到会话工作区,返回文件信息与前 5 行预览;请用 Python(pandas)读取分析。某天查询失败时跳过并在摘要中说明。", ReadOnly: true, Handler: func(ctx context.Context, a sitePowerFlowExportArgs) (*mcp.CallToolResult, error) { sn, err := kit.Required("sn", a.SN) if err != nil { return kit.Fail(err), nil } from, err := kit.ParseDate("from", a.From, false) if err != nil { return kit.Fail(err), nil } to, err := kit.ParseDate("to", a.To, false) if err != nil { return kit.Fail(err), nil } if to.Before(*from) { return kit.Failf("结束日期不能早于开始日期"), nil } if to.Sub(*from) > 30*24*time.Hour { return kit.Failf("日期范围不能超过 31 天,请分段导出"), nil } cols := append([]string{"date"}, powerFlowCols...) var rows [][]string var failed []string for d := *from; !d.After(*to); d = d.AddDate(0, 0, 1) { if ctx.Err() != nil { return kit.Failf("导出超时,已完成到 %s,请缩短日期范围", d.Format(kit.DateLayout)), nil } dayRows, err := powerFlowRows(sn, d) if err != nil { failed = append(failed, d.Format(kit.DateLayout)) continue } for _, r := range dayRows { rows = append(rows, append([]string{d.Format(kit.DateLayout)}, r...)) } } summary := fmt.Sprintf("站点 %s,%s ~ %s。", sn, from.Format(kit.DateLayout), to.Format(kit.DateLayout)) if len(failed) > 0 { summary += fmt.Sprintf("以下日期查询失败已跳过:%s。", strings.Join(failed, ", ")) } uri := fmt.Sprintf("fwh://export/site_power_flow/%s_%s_%s.csv", sn, from.Format("20060102"), to.Format("20060102")) return kit.Export(uri, cols, rows, summary), nil }, } // powerFlowRows 查询单日功率流并转为表格行(列顺序见 powerFlowCols)。 func powerFlowRows(sn string, day time.Time) ([][]string, error) { res, err := services.QuerySitePowerFlow(sn, day) if err != nil { return nil, err } if res == nil || res.Result == nil { return nil, nil } return PowerFlowToRows(res.Result), nil } // PowerFlowToRows 把平台的列式功率流数组转为行;数组长度不一致时缺失值留空。 func PowerFlowToRows(p *dto.SitePowerFlow) [][]string { n := len(p.DeviceTimeArray) rows := make([][]string, 0, n) for i := 0; i < n; i++ { rows = append(rows, []string{ p.DeviceTimeArray[i], f32At(p.PowerSunArray, i), f32At(p.PowerUtiArray, i), f32At(p.PowerFhpArray, i), f32At(p.PowerLoadArray, i), f32At(p.PowerGenArray, i), f32At(p.SocArray, i), uintAt(p.RunStatusArray, i), f32At(p.KwhTotalArray, i), }) } return rows } func f32At(a []float32, i int) string { if i >= len(a) { return "" } return strconv.FormatFloat(float64(a[i]), 'f', -1, 32) } func uintAt(a []uint, i int) string { if i >= len(a) { return "" } return strconv.FormatUint(uint64(a[i]), 10) }