site.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. package tools
  2. import (
  3. "context"
  4. "fmt"
  5. "regexp"
  6. "strconv"
  7. "strings"
  8. "time"
  9. "github.com/mark3labs/mcp-go/mcp"
  10. "fwh-toolbox-server/agentmcp/kit"
  11. "fwh-toolbox-server/domain"
  12. "fwh-toolbox-server/dto"
  13. "fwh-toolbox-server/repositories"
  14. "fwh-toolbox-server/services"
  15. )
  16. // ---- site_search ----
  17. type siteSearchArgs struct {
  18. Keyword string `json:"keyword,omitempty" jsonschema:"关键字,匹配 SN、站点名称、经销商/安装商、aPower SN、ICCID、版本、国家/州省/城市、备注等(不区分大小写);不填则只按筛选条件"`
  19. Country string `json:"country,omitempty" jsonschema:"国家:us(美国)、au(澳大利亚)"`
  20. Province string `json:"province,omitempty" jsonschema:"州/省(英文,包含匹配),如 California"`
  21. Online string `json:"online,omitempty" jsonschema:"网络状态:online(在线)、offline(离线)"`
  22. Active string `json:"active,omitempty" jsonschema:"激活状态:active(已激活)、inactive(未激活)"`
  23. RunStatus string `json:"runStatus,omitempty" jsonschema:"运行状态:standby(待机)、charging(充电)、discharging(放电)、fault(故障)"`
  24. ConnType string `json:"connType,omitempty" jsonschema:"联网方式:none(未连接)、eth0、eth1、wifi、4g"`
  25. Version string `json:"version,omitempty" jsonschema:"系统固件版本(包含匹配),如 V10R11B42"`
  26. ProtocolVer string `json:"protocolVer,omitempty" jsonschema:"协议版本(包含匹配),如 V1.12.45"`
  27. Hardware string `json:"hardware,omitempty" jsonschema:"aGate 系统硬件名称(包含匹配),如 System1.5"`
  28. ActiveFrom string `json:"activeFrom,omitempty" jsonschema:"激活时间起(含),yyyy-MM-dd 或 yyyy-MM-dd HH:mm:ss,北京时间"`
  29. ActiveTo string `json:"activeTo,omitempty" jsonschema:"激活时间止(含),格式同上"`
  30. Page int `json:"page,omitempty" jsonschema:"页码,从 1 开始,默认 1"`
  31. Size int `json:"size,omitempty" jsonschema:"每页条数,1~100,默认 20;只需要数量时用 1,看 total"`
  32. }
  33. var (
  34. siteCountryIDs = map[string]int64{"us": 2, "au": 3}
  35. siteOnline = map[string]int64{"offline": 0, "online": 1}
  36. siteActive = map[string]int64{"inactive": 0, "active": 1}
  37. siteRunStatus = map[string]int64{"standby": 0, "charging": 1, "discharging": 2, "fault": 3}
  38. siteConnTypes = map[string]int64{"none": 0, "eth0": 1, "eth1": 2, "wifi": 3, "4g": 4}
  39. )
  40. var SiteSearch = kit.Def[siteSearchArgs]{
  41. Name: "site_search",
  42. Title: "搜索站点",
  43. Description: "在 ToolBox 站点信息表(约 6 万个站点,每天从 FWH 平台同步)中按关键字与条件搜索站点,分页返回;多个条件同时满足。" +
  44. "可按国家、州/省、在线、激活、运行状态、联网方式、固件/协议版本、aGate 硬件、激活时间筛选。统计数量时 size=1 看 total," +
  45. "如“美国在线的 System1.5 站点有多少”。返回字段:sn(aGate SN)、name、国家/州省/城市、激活状态与时间、在线状态、版本、aPower SN 等(个人信息已脱敏)。" +
  46. "数据是否最新见 site_info_sync_status;查看单个站点完整概况用 site_get。",
  47. ReadOnly: true,
  48. Properties: map[string]map[string]any{
  49. "country": {"enum": []string{"us", "au"}},
  50. "online": {"enum": []string{"online", "offline"}},
  51. "active": {"enum": []string{"active", "inactive"}},
  52. "runStatus": {"enum": []string{"standby", "charging", "discharging", "fault"}},
  53. "connType": {"enum": []string{"none", "eth0", "eth1", "wifi", "4g"}},
  54. },
  55. Handler: func(ctx context.Context, a siteSearchArgs) (*mcp.CallToolResult, error) {
  56. page := kit.Page(a.Page)
  57. size := kit.Limit(a.Size, 20, 100)
  58. filter, err := siteSearchFilter(a)
  59. if err != nil {
  60. return kit.Fail(err), nil
  61. }
  62. keyword := strings.TrimSpace(a.Keyword)
  63. data, err := services.QuerySiteInfoDataList(dto.SiteInfoRequest{
  64. Search: regexp.QuoteMeta(keyword), // 仓库按正则匹配,转义后按字面匹配
  65. Filter: filter,
  66. PageNum: uint(page),
  67. PageSize: uint(size),
  68. })
  69. if err != nil {
  70. return kit.Upstream("站点信息表", err), nil
  71. }
  72. if data == nil || data.Total == 0 {
  73. return kit.Empty("没有符合条件的站点。"), nil
  74. }
  75. return kit.List(data.Data, int(data.Total), size, fmt.Sprintf("共 %d 条,当前第 %d 页,可用 page 翻页或增加条件", data.Total, page)), nil
  76. },
  77. }
  78. // activeTimeBound 解析激活时间范围的一端:接受日期或日期时间;只给日期时,止时间取当天最后一秒
  79. func activeTimeBound(name, v string, end bool) (string, error) {
  80. v = strings.TrimSpace(v)
  81. if t, err := time.Parse(time.DateTime, v); err == nil {
  82. return t.Format(time.DateTime), nil
  83. }
  84. t, err := time.Parse(time.DateOnly, v)
  85. if err != nil {
  86. return "", fmt.Errorf("参数 %s 格式错误,需要 yyyy-MM-dd 或 yyyy-MM-dd HH:mm:ss:%s", name, v)
  87. }
  88. if end {
  89. t = t.Add(24*time.Hour - time.Second)
  90. }
  91. return t.Format(time.DateTime), nil
  92. }
  93. // siteSearchFilter 把筛选参数转为站点信息表的过滤条件(全部为“且”);没有条件时返回 nil
  94. func siteSearchFilter(a siteSearchArgs) (*domain.SiteInfoFilter, error) {
  95. var conds []domain.SiteInfoFilterConfig
  96. eqInt := func(key string, v int64) {
  97. conds = append(conds, domain.SiteInfoFilterConfig{Key: key, Type: domain.FilterEqualInt, Para1: domain.FlexiblePara{Int: &v}})
  98. }
  99. contains := func(key, v string) {
  100. if v = strings.TrimSpace(v); v != "" {
  101. q := regexp.QuoteMeta(v)
  102. conds = append(conds, domain.SiteInfoFilterConfig{Key: key, Type: domain.FilterIncludeString, Para1: domain.FlexiblePara{String: &q}})
  103. }
  104. }
  105. enums := []struct {
  106. key, name, val string
  107. m map[string]int64
  108. }{
  109. {"countryId", "country", a.Country, siteCountryIDs},
  110. {"status", "online", a.Online, siteOnline},
  111. {"activeStatus", "active", a.Active, siteActive},
  112. {"runStatus", "runStatus", a.RunStatus, siteRunStatus},
  113. {"connType", "connType", a.ConnType, siteConnTypes},
  114. }
  115. for _, e := range enums {
  116. if e.val == "" {
  117. continue
  118. }
  119. v, ok := e.m[strings.ToLower(e.val)]
  120. if !ok {
  121. return nil, fmt.Errorf("参数 %s 的值 %q 无效", e.name, e.val)
  122. }
  123. eqInt(e.key, v)
  124. }
  125. contains("province", a.Province)
  126. contains("version", a.Version)
  127. contains("protocolVer", a.ProtocolVer)
  128. contains("realSysHdVersion", a.Hardware)
  129. if a.ActiveFrom != "" || a.ActiveTo != "" {
  130. // activeTime 以 "yyyy-MM-dd HH:mm:ss" 字符串保存,按字符串比较
  131. from, to := "0000-00-00 00:00:00", "9999-12-31 23:59:59"
  132. var err error
  133. if a.ActiveFrom != "" {
  134. if from, err = activeTimeBound("activeFrom", a.ActiveFrom, false); err != nil {
  135. return nil, err
  136. }
  137. }
  138. if a.ActiveTo != "" {
  139. if to, err = activeTimeBound("activeTo", a.ActiveTo, true); err != nil {
  140. return nil, err
  141. }
  142. }
  143. conds = append(conds, domain.SiteInfoFilterConfig{Key: "activeTime", Type: domain.FilterRangeTime,
  144. Para1: domain.FlexiblePara{String: &from}, Para2: &domain.FlexiblePara{String: &to}})
  145. }
  146. if len(conds) == 0 {
  147. return nil, nil
  148. }
  149. return &domain.SiteInfoFilter{Logic: "and", SubFilter: []domain.SiteInfoSubFilter{{Logic: "and", Config: conds}}}, nil
  150. }
  151. // ---- site_get ----
  152. type siteGetArgs struct {
  153. SN string `json:"sn" jsonschema:"站点 SN(aGate 序列号)"`
  154. }
  155. var SiteGet = kit.Def[siteGetArgs]{
  156. Name: "site_get",
  157. Title: "查询站点概况",
  158. Description: "查询单个站点的概况,合并三部分:info(站点信息表中的基本信息、版本、网络、激活状态等)、" +
  159. "mode(当前工作模式与相关设置)、currentAlarms(当前未结束的告警,最多 20 条)。" +
  160. "任一部分查询失败时在 errors 中说明,不影响其他部分。排查站点问题时通常先调用本工具。",
  161. ReadOnly: true,
  162. Handler: func(ctx context.Context, a siteGetArgs) (*mcp.CallToolResult, error) {
  163. sn, err := kit.Required("sn", a.SN)
  164. if err != nil {
  165. return kit.Fail(err), nil
  166. }
  167. out := map[string]any{"sn": sn}
  168. var errs []string
  169. if info, err := services.GetSiteInfoData(sn); err != nil {
  170. errs = append(errs, "info: "+kit.UpstreamText("站点信息表", err))
  171. } else {
  172. out["info"] = info
  173. }
  174. if mode, err := repositories.QuerySiteModeInfo(sn); err != nil {
  175. errs = append(errs, "mode: "+kit.UpstreamText("FWH 平台工作模式接口", err))
  176. } else if mode != nil {
  177. out["mode"] = mode.Result
  178. }
  179. if alarms, err := repositories.QuerySiteAlarmList(sn); err != nil {
  180. errs = append(errs, "currentAlarms: "+kit.UpstreamText("FWH 平台实时告警接口", err))
  181. } else if alarms != nil && alarms.Result != nil {
  182. list := *alarms.Result
  183. out["currentAlarmTotal"] = len(list)
  184. if len(list) > 20 {
  185. list = list[:20]
  186. }
  187. out["currentAlarms"] = list
  188. }
  189. if len(errs) > 0 {
  190. out["errors"] = errs
  191. }
  192. return kit.Object(out), nil
  193. },
  194. }
  195. // ---- site_power_flow ----
  196. type sitePowerFlowArgs struct {
  197. SN string `json:"sn" jsonschema:"站点 SN(aGate 序列号)"`
  198. Date string `json:"date" jsonschema:"日期,yyyy-MM-dd,站点当地日期"`
  199. }
  200. // powerFlowCols 功率流表格的列(功率单位 kW,SOC 单位 %,电量单位 kWh)
  201. var powerFlowCols = []string{"time", "pv_kw", "grid_kw", "apower_kw", "load_kw", "gen_kw", "soc_pct", "run_status", "kwh_total"}
  202. var SitePowerFlow = kit.Def[sitePowerFlowArgs]{
  203. Name: "site_power_flow",
  204. Title: "查询站点单日功率流",
  205. Description: "查询单个站点某一天的功率流(约 5 分钟一个点),以 TSV 表格返回。" +
  206. "列:time(设备当地时间)、pv_kw(光伏)、grid_kw(电网,正为取电、负为馈网,以平台定义为准)、apower_kw(aPower,正为放电、负为充电,以平台定义为准)、" +
  207. "load_kw(家庭负载)、gen_kw(发电机)、soc_pct(电池 SOC,%)、run_status(0 待机 1 充电 2 放电 3 故障)、kwh_total(可用电量 kWh)。" +
  208. "需要多天数据时用 site_power_flow_export。",
  209. ReadOnly: true,
  210. Handler: func(ctx context.Context, a sitePowerFlowArgs) (*mcp.CallToolResult, error) {
  211. sn, err := kit.Required("sn", a.SN)
  212. if err != nil {
  213. return kit.Fail(err), nil
  214. }
  215. day, err := kit.ParseDate("date", a.Date, false)
  216. if err != nil {
  217. return kit.Fail(err), nil
  218. }
  219. rows, err := powerFlowRows(sn, *day)
  220. if err != nil {
  221. return kit.Upstream("FWH 平台功率流接口", err), nil
  222. }
  223. return kit.Table(powerFlowCols, rows, 0, "数据过多已截断,请用 site_power_flow_export 导出完整数据"), nil
  224. },
  225. }
  226. // ---- site_power_flow_export ----
  227. type sitePowerFlowExportArgs struct {
  228. SN string `json:"sn" jsonschema:"站点 SN(aGate 序列号)"`
  229. From string `json:"from" jsonschema:"开始日期,yyyy-MM-dd(含)"`
  230. To string `json:"to" jsonschema:"结束日期,yyyy-MM-dd(含),与开始日期相差不超过 31 天"`
  231. }
  232. var SitePowerFlowExport = kit.Def[sitePowerFlowExportArgs]{
  233. Name: "site_power_flow_export",
  234. Title: "导出站点多日功率流",
  235. Description: "导出单个站点一段日期(最多 31 天)的功率流明细为 CSV 文件,列与 site_power_flow 相同,另加 date 列。" +
  236. "文件保存到会话工作区,返回文件信息与前 5 行预览;请用 Python(pandas)读取分析。某天查询失败时跳过并在摘要中说明。",
  237. ReadOnly: true,
  238. Handler: func(ctx context.Context, a sitePowerFlowExportArgs) (*mcp.CallToolResult, error) {
  239. sn, err := kit.Required("sn", a.SN)
  240. if err != nil {
  241. return kit.Fail(err), nil
  242. }
  243. from, err := kit.ParseDate("from", a.From, false)
  244. if err != nil {
  245. return kit.Fail(err), nil
  246. }
  247. to, err := kit.ParseDate("to", a.To, false)
  248. if err != nil {
  249. return kit.Fail(err), nil
  250. }
  251. if to.Before(*from) {
  252. return kit.Failf("结束日期不能早于开始日期"), nil
  253. }
  254. if to.Sub(*from) > 30*24*time.Hour {
  255. return kit.Failf("日期范围不能超过 31 天,请分段导出"), nil
  256. }
  257. cols := append([]string{"date"}, powerFlowCols...)
  258. var rows [][]string
  259. var failed []string
  260. for d := *from; !d.After(*to); d = d.AddDate(0, 0, 1) {
  261. if ctx.Err() != nil {
  262. return kit.Failf("导出超时,已完成到 %s,请缩短日期范围", d.Format(kit.DateLayout)), nil
  263. }
  264. dayRows, err := powerFlowRows(sn, d)
  265. if err != nil {
  266. failed = append(failed, d.Format(kit.DateLayout))
  267. continue
  268. }
  269. for _, r := range dayRows {
  270. rows = append(rows, append([]string{d.Format(kit.DateLayout)}, r...))
  271. }
  272. }
  273. summary := fmt.Sprintf("站点 %s,%s ~ %s。", sn, from.Format(kit.DateLayout), to.Format(kit.DateLayout))
  274. if len(failed) > 0 {
  275. summary += fmt.Sprintf("以下日期查询失败已跳过:%s。", strings.Join(failed, ", "))
  276. }
  277. uri := fmt.Sprintf("fwh://export/site_power_flow/%s_%s_%s.csv", sn, from.Format("20060102"), to.Format("20060102"))
  278. return kit.Export(uri, cols, rows, summary), nil
  279. },
  280. }
  281. // powerFlowRows 查询单日功率流并转为表格行(列顺序见 powerFlowCols)。
  282. func powerFlowRows(sn string, day time.Time) ([][]string, error) {
  283. res, err := services.QuerySitePowerFlow(sn, day)
  284. if err != nil {
  285. return nil, err
  286. }
  287. if res == nil || res.Result == nil {
  288. return nil, nil
  289. }
  290. return PowerFlowToRows(res.Result), nil
  291. }
  292. // PowerFlowToRows 把平台的列式功率流数组转为行;数组长度不一致时缺失值留空。
  293. func PowerFlowToRows(p *dto.SitePowerFlow) [][]string {
  294. n := len(p.DeviceTimeArray)
  295. rows := make([][]string, 0, n)
  296. for i := 0; i < n; i++ {
  297. rows = append(rows, []string{
  298. p.DeviceTimeArray[i],
  299. f32At(p.PowerSunArray, i),
  300. f32At(p.PowerUtiArray, i),
  301. f32At(p.PowerFhpArray, i),
  302. f32At(p.PowerLoadArray, i),
  303. f32At(p.PowerGenArray, i),
  304. f32At(p.SocArray, i),
  305. uintAt(p.RunStatusArray, i),
  306. f32At(p.KwhTotalArray, i),
  307. })
  308. }
  309. return rows
  310. }
  311. func f32At(a []float32, i int) string {
  312. if i >= len(a) {
  313. return ""
  314. }
  315. return strconv.FormatFloat(float64(a[i]), 'f', -1, 32)
  316. }
  317. func uintAt(a []uint, i int) string {
  318. if i >= len(a) {
  319. return ""
  320. }
  321. return strconv.FormatUint(uint64(a[i]), 10)
  322. }