OK
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
collector "aquacontrolai/internal/engine/collector"
|
||||
pg "aquacontrolai/internal/repository/postgres"
|
||||
td "aquacontrolai/internal/repository/tdengine"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"github.com/google/uuid"
|
||||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
type History struct {
|
||||
PG *pg.Store
|
||||
TD *td.Store
|
||||
Collector interface {
|
||||
Latest(uuid.UUID) *collector.LatestValue
|
||||
}
|
||||
}
|
||||
type Series struct {
|
||||
PointID uuid.UUID `json:"point_id"`
|
||||
PointName string `json:"point_name"`
|
||||
DataType string `json:"data_type"`
|
||||
Unit *string `json:"unit"`
|
||||
Sampled bool `json:"sampled"`
|
||||
RawCount int `json:"raw_count"`
|
||||
SampleCount int `json:"sample_count"`
|
||||
Data []td.Sample `json:"data"`
|
||||
}
|
||||
|
||||
func (h *History) Tree(ctx context.Context) ([]map[string]any, error) {
|
||||
points, e := h.PG.ListPoints(ctx, "collection", "", true)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
groups := map[string][]map[string]any{}
|
||||
for _, p := range points {
|
||||
active := p.Enabled && p.StoreHistory
|
||||
has := h.TD.HasData(ctx, p.ID)
|
||||
if !active && !has {
|
||||
continue
|
||||
}
|
||||
life := "active"
|
||||
if !active {
|
||||
life = "archived"
|
||||
}
|
||||
var latest any
|
||||
if h.Collector != nil && life == "active" {
|
||||
latest = h.Collector.Latest(p.ID)
|
||||
}
|
||||
groups[p.GroupName] = append(groups[p.GroupName], map[string]any{"id": p.ID, "name": p.Name, "type": "collection", "data_type": p.DataType, "unit": p.Unit, "history_interval": p.HistoryInterval, "device_id": p.DeviceID, "device_name": p.DeviceName, "group_name": p.GroupName, "lifecycle_status": life, "has_history_data": has, "latest_value": latest})
|
||||
}
|
||||
names := make([]string, 0, len(groups))
|
||||
for n := range groups {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
tree := []map[string]any{}
|
||||
for _, n := range names {
|
||||
hash := sha256.Sum256([]byte(n))
|
||||
tree = append(tree, map[string]any{"id": fmt.Sprintf("group_%x", hash[:8]), "name": n, "type": "group", "children": groups[n]})
|
||||
}
|
||||
tree = append(tree, map[string]any{"id": "internal-data", "name": "内部数据", "type": "reserved", "children": []map[string]any{{"id": "placeholder", "name": "暂无数据", "type": "placeholder", "disabled": true}}})
|
||||
return tree, nil
|
||||
}
|
||||
func (h *History) Query(ctx context.Context, ids []uuid.UUID, start, end time.Time, max int) ([]Series, error) {
|
||||
meta, e := h.PG.ListPoints(ctx, "collection", "", true)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
byID := map[uuid.UUID]pg.PointRow{}
|
||||
for _, p := range meta {
|
||||
byID[p.ID] = p
|
||||
}
|
||||
out := make([]Series, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
p, ok := byID[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("点位元数据不存在: %s", id)
|
||||
}
|
||||
data, e := h.TD.Query(ctx, id, start, end)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
raw := len(data)
|
||||
if raw > max {
|
||||
data = minMax(data, max)
|
||||
}
|
||||
out = append(out, Series{id, p.Name, p.DataType, p.Unit, raw > len(data), raw, len(data), data})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func minMax(data []td.Sample, max int) []td.Sample {
|
||||
if len(data) <= max {
|
||||
return data
|
||||
}
|
||||
keep := map[int]bool{0: true, len(data) - 1: true}
|
||||
bucket := float64(len(data)) / float64(max/2)
|
||||
for b := 0; b < max/2; b++ {
|
||||
lo, hi := int(float64(b)*bucket), int(float64(b+1)*bucket)
|
||||
if hi > len(data) {
|
||||
hi = len(data)
|
||||
}
|
||||
minI, maxI := -1, -1
|
||||
for i := lo; i < hi; i++ {
|
||||
if data[i].Value == nil {
|
||||
keep[i] = true
|
||||
continue
|
||||
}
|
||||
if minI < 0 || *data[i].Value < *data[minI].Value {
|
||||
minI = i
|
||||
}
|
||||
if maxI < 0 || *data[i].Value > *data[maxI].Value {
|
||||
maxI = i
|
||||
}
|
||||
if i > 0 && data[i].Quality != data[i-1].Quality {
|
||||
keep[i-1] = true
|
||||
keep[i] = true
|
||||
}
|
||||
}
|
||||
if minI >= 0 {
|
||||
keep[minI] = true
|
||||
keep[maxI] = true
|
||||
}
|
||||
}
|
||||
idx := make([]int, 0, len(keep))
|
||||
for i := range keep {
|
||||
idx = append(idx, i)
|
||||
}
|
||||
sort.Ints(idx)
|
||||
if len(idx) > max {
|
||||
idx = idx[:max]
|
||||
}
|
||||
out := make([]td.Sample, 0, len(idx))
|
||||
for _, i := range idx {
|
||||
out = append(out, data[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type TableValue struct {
|
||||
Value *float64 `json:"value"`
|
||||
Quality string `json:"quality"`
|
||||
QualityReason *string `json:"quality_reason"`
|
||||
MatchedTS *time.Time `json:"matched_ts"`
|
||||
}
|
||||
type TableColumn struct {
|
||||
PointID uuid.UUID `json:"point_id"`
|
||||
PointName string `json:"point_name"`
|
||||
Unit *string `json:"unit"`
|
||||
Data []TableValue `json:"data"`
|
||||
}
|
||||
type TableResult struct {
|
||||
TimeColumn []time.Time `json:"time_column"`
|
||||
Columns []TableColumn `json:"columns"`
|
||||
}
|
||||
|
||||
func (h *History) QueryTable(ctx context.Context, ids []uuid.UUID, start, end time.Time, minutes int) (TableResult, error) {
|
||||
step := time.Duration(minutes) * time.Minute
|
||||
times := []time.Time{}
|
||||
for t := start; !t.After(end); t = t.Add(step) {
|
||||
times = append(times, t)
|
||||
}
|
||||
series, e := h.Query(ctx, ids, start.Add(-step/2), end.Add(step/2), 10000)
|
||||
if e != nil {
|
||||
return TableResult{}, e
|
||||
}
|
||||
res := TableResult{TimeColumn: times, Columns: []TableColumn{}}
|
||||
for _, s := range series {
|
||||
col := TableColumn{s.PointID, s.PointName, s.Unit, make([]TableValue, 0, len(times))}
|
||||
for _, target := range times {
|
||||
best := -1
|
||||
bestDist := time.Duration(math.MaxInt64)
|
||||
for i, x := range s.Data {
|
||||
d := x.TS.Sub(target)
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
if d <= step/2 && (d < bestDist || (d == bestDist && best >= 0 && x.TS.Before(s.Data[best].TS))) {
|
||||
best, bestDist = i, d
|
||||
}
|
||||
}
|
||||
if best < 0 {
|
||||
col.Data = append(col.Data, TableValue{nil, "none", nil, nil})
|
||||
} else {
|
||||
x := s.Data[best]
|
||||
ts := x.TS
|
||||
col.Data = append(col.Data, TableValue{x.Value, x.Quality, x.QualityReason, &ts})
|
||||
}
|
||||
}
|
||||
res.Columns = append(res.Columns, col)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
collectorengine "aquacontrolai/internal/engine/collector"
|
||||
writerengine "aquacontrolai/internal/engine/writer"
|
||||
"aquacontrolai/internal/model"
|
||||
"aquacontrolai/internal/protocol"
|
||||
pg "aquacontrolai/internal/repository/postgres"
|
||||
td "aquacontrolai/internal/repository/tdengine"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"math"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
Store *pg.Store
|
||||
Registry *protocol.Registry
|
||||
Connections *collectorengine.Manager
|
||||
Writer *writerengine.Engine
|
||||
Collector *collectorengine.Engine
|
||||
TD *td.Store
|
||||
}
|
||||
|
||||
func (s *Service) ListDevices(ctx context.Context, keyword string) ([]model.Device, error) {
|
||||
items, e := s.Store.ListDevices(ctx, keyword)
|
||||
if e == nil && s.Connections != nil {
|
||||
for i := range items {
|
||||
if items[i].Enabled {
|
||||
items[i].ConnectionStatus = s.Connections.Status(items[i].ID)
|
||||
}
|
||||
items[i].LastOnlineAt, items[i].LastOfflineAt = s.Connections.Times(items[i].ID)
|
||||
}
|
||||
}
|
||||
return items, e
|
||||
}
|
||||
func (s *Service) SaveDevice(ctx context.Context, id *uuid.UUID, d *model.Device) error {
|
||||
d.Name = strings.TrimSpace(d.Name)
|
||||
if d.Name == "" || len([]rune(d.Name)) > 128 {
|
||||
return errors.New("设备名称长度必须为1~128")
|
||||
}
|
||||
if net.ParseIP(d.Host) == nil && !validHost(d.Host) {
|
||||
return errors.New("无效的IP地址或域名")
|
||||
}
|
||||
if d.Port < 1 || d.Port > 65535 || d.ConnectTimeout < 1 || d.ConnectTimeout > 60 || d.ReconnectInterval < 1 || d.ReconnectInterval > 3600 {
|
||||
return errors.New("连接参数超出范围")
|
||||
}
|
||||
factory, e := s.Registry.Get(d.ProtocolType)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
var cfg map[string]any
|
||||
if e = json.Unmarshal(d.ProtocolConfig, &cfg); e != nil {
|
||||
return errors.New("protocol_config必须为对象")
|
||||
}
|
||||
if e = factory.ValidateConfig(cfg); e != nil {
|
||||
return e
|
||||
}
|
||||
if id == nil {
|
||||
return s.Store.CreateDevice(ctx, d)
|
||||
}
|
||||
d.ID = *id
|
||||
e = s.Store.UpdateDevice(ctx, d)
|
||||
if e == nil && s.Connections != nil {
|
||||
s.Connections.Invalidate(d.ID)
|
||||
}
|
||||
return e
|
||||
}
|
||||
func validHost(h string) bool {
|
||||
if len(h) < 1 || len(h) > 253 {
|
||||
return false
|
||||
}
|
||||
for _, p := range strings.Split(h, ".") {
|
||||
if len(p) < 1 || len(p) > 63 {
|
||||
return false
|
||||
}
|
||||
for _, r := range p {
|
||||
if !(r == '-' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func (s *Service) DeleteDevice(ctx context.Context, id uuid.UUID) error {
|
||||
e := s.Store.DeleteDevice(ctx, id)
|
||||
if e == nil && s.Connections != nil {
|
||||
s.Connections.Invalidate(id)
|
||||
}
|
||||
return e
|
||||
}
|
||||
func (s *Service) ListPoints(ctx context.Context, kind, keyword string) ([]pg.PointRow, error) {
|
||||
items, e := s.Store.ListPoints(ctx, kind, keyword, false)
|
||||
if e == nil && kind == "collection" && s.Collector != nil {
|
||||
for i := range items {
|
||||
items[i].LatestValue = s.Collector.Latest(items[i].ID)
|
||||
}
|
||||
}
|
||||
return items, e
|
||||
}
|
||||
func (s *Service) ListLogs(ctx context.Context) ([]map[string]any, error) {
|
||||
return s.Store.ListLogs(ctx)
|
||||
}
|
||||
func (s *Service) GetDevice(ctx context.Context, id uuid.UUID) (model.Device, error) {
|
||||
d, e := s.Store.GetDevice(ctx, id)
|
||||
if e == nil {
|
||||
if d.Enabled && s.Connections != nil {
|
||||
d.ConnectionStatus = s.Connections.Status(id)
|
||||
d.LastOnlineAt, d.LastOfflineAt = s.Connections.Times(id)
|
||||
} else {
|
||||
d.ConnectionStatus = "disabled"
|
||||
}
|
||||
}
|
||||
return d, e
|
||||
}
|
||||
func (s *Service) GetPoint(ctx context.Context, kind string, id uuid.UUID) (pg.PointRow, error) {
|
||||
p, e := s.Store.GetPoint(ctx, kind, id)
|
||||
if e == nil && kind == "collection" && s.Collector != nil {
|
||||
p.LatestValue = s.Collector.Latest(id)
|
||||
}
|
||||
return p, e
|
||||
}
|
||||
func (s *Service) Groups(ctx context.Context) ([]map[string]any, error) { return s.Store.Groups(ctx) }
|
||||
func (s *Service) CreateGroup(ctx context.Context, name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || len([]rune(name)) > 64 {
|
||||
return errors.New("分组名称长度必须为1~64")
|
||||
}
|
||||
return s.Store.CreateGroup(ctx, name)
|
||||
}
|
||||
func (s *Service) UpdateGroup(ctx context.Context, oldName, newName string) error {
|
||||
newName = strings.TrimSpace(newName)
|
||||
if newName == "" || len([]rune(newName)) > 64 {
|
||||
return errors.New("分组名称长度必须为1~64")
|
||||
}
|
||||
return s.Store.UpdateGroup(ctx, oldName, newName)
|
||||
}
|
||||
func (s *Service) DeleteGroup(ctx context.Context, name string) error {
|
||||
return s.Store.DeleteGroup(ctx, name)
|
||||
}
|
||||
func (s *Service) FindDeviceByName(ctx context.Context, name string) (model.Device, error) {
|
||||
return s.Store.FindDeviceByName(ctx, name)
|
||||
}
|
||||
func (s *Service) FindPointByName(ctx context.Context, kind, name string) (pg.PointRow, error) {
|
||||
return s.Store.FindPointByName(ctx, kind, name)
|
||||
}
|
||||
func (s *Service) GetRetention(ctx context.Context) (int, error) { return s.Store.GetRetention(ctx) }
|
||||
func (s *Service) SetRetention(ctx context.Context, days int) error {
|
||||
if days < 1 || days > 730 {
|
||||
return errors.New("history_retention_days必须为1~730")
|
||||
}
|
||||
if e := s.TD.SetRetention(ctx, days); e != nil {
|
||||
return fmt.Errorf("更新TDengine保留策略失败: %w", e)
|
||||
}
|
||||
return s.Store.SetRetention(ctx, days)
|
||||
}
|
||||
func (s *Service) SavePoint(ctx context.Context, kind string, p *pg.PointRow) error {
|
||||
p.Name = strings.TrimSpace(p.Name)
|
||||
p.GroupName = strings.TrimSpace(p.GroupName)
|
||||
if p.Name == "" || len([]rune(p.Name)) > 128 {
|
||||
return errors.New("点位名称长度必须为1~128")
|
||||
}
|
||||
if p.GroupName == "" {
|
||||
p.GroupName = "default"
|
||||
}
|
||||
d, e := s.Store.GetDevice(ctx, p.DeviceID)
|
||||
if e != nil {
|
||||
return errors.New("所属设备不存在")
|
||||
}
|
||||
f, e := s.Registry.Get(d.ProtocolType)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if e = f.ValidateAddress(p.Address, protocol.DataType(p.DataType), kind == "write"); e != nil {
|
||||
return e
|
||||
}
|
||||
if p.DataType != "BOOL" && p.DataType != "INT" && p.DataType != "REAL" {
|
||||
return errors.New("data_type无效")
|
||||
}
|
||||
if kind == "collection" {
|
||||
if p.CollectInterval < 1 {
|
||||
return errors.New("collect_interval必须至少1秒")
|
||||
}
|
||||
if p.HistoryInterval < 1 || p.HistoryInterval > 1440 {
|
||||
return errors.New("history_interval必须为1~1440")
|
||||
}
|
||||
} else if p.ReadbackTolerance < 0 || p.ReadbackTolerance > 1000000 {
|
||||
return errors.New("readback_tolerance超出范围")
|
||||
}
|
||||
return s.Store.SavePoint(ctx, kind, p)
|
||||
}
|
||||
func (s *Service) DeletePoint(ctx context.Context, kind string, id uuid.UUID) error {
|
||||
return s.Store.DeletePoint(ctx, kind, id)
|
||||
}
|
||||
func (s *Service) ExecuteWrite(ctx context.Context, id uuid.UUID, value any, reason *string) (map[string]any, int, error) {
|
||||
p, e := s.Store.GetPoint(ctx, "write", id)
|
||||
if e != nil {
|
||||
return nil, 42004, e
|
||||
}
|
||||
numeric, e := typedNumber(p.DataType, value)
|
||||
if e != nil {
|
||||
return nil, 42001, e
|
||||
}
|
||||
if reason != nil && len([]rune(*reason)) > 500 {
|
||||
return nil, 42001, errors.New("reason最多500字符")
|
||||
}
|
||||
result, e := s.Writer.Execute(ctx, p, numeric)
|
||||
target := fmt.Sprintf("%v", value)
|
||||
status := "success"
|
||||
var readback, errorMessage *string
|
||||
if e != nil {
|
||||
status = "failed"
|
||||
m := "设备写入或回读失败"
|
||||
errorMessage = &m
|
||||
} else {
|
||||
r := fmt.Sprintf("%v", result.Readback)
|
||||
readback = &r
|
||||
}
|
||||
logID, logErr := s.Store.InsertWriteLog(ctx, p, target, readback, status, errorMessage, reason)
|
||||
if logErr != nil {
|
||||
return nil, 51001, logErr
|
||||
}
|
||||
data := map[string]any{"write_log_id": logID, "point_name": p.Name, "data_type": p.DataType, "value": value, "readback_value": nil, "result": status, "ts": time.Now()}
|
||||
if readback != nil {
|
||||
data["readback_value"] = result.Readback
|
||||
}
|
||||
if e != nil {
|
||||
data["error_message"] = *errorMessage
|
||||
return data, 51001, e
|
||||
}
|
||||
return data, 0, nil
|
||||
}
|
||||
func typedNumber(dt string, v any) (float64, error) {
|
||||
switch dt {
|
||||
case "BOOL":
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return 0, errors.New("BOOL只接受JSON boolean")
|
||||
}
|
||||
if b {
|
||||
return 1, nil
|
||||
}
|
||||
return 0, nil
|
||||
case "INT":
|
||||
n, ok := v.(float64)
|
||||
if !ok || math.Trunc(n) != n || n < -32768 || n > 32767 {
|
||||
return 0, errors.New("INT只接受16位整数")
|
||||
}
|
||||
return n, nil
|
||||
case "REAL":
|
||||
n, ok := v.(float64)
|
||||
if !ok {
|
||||
return 0, errors.New("REAL只接受JSON number")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
return 0, errors.New("数据类型无效")
|
||||
}
|
||||
func IsNotFound(e error) bool { return errors.Is(e, pgx.ErrNoRows) }
|
||||
Reference in New Issue
Block a user