This commit is contained in:
qsc
2026-07-12 19:18:26 +08:00
parent dd588b5daa
commit 34ec65c8b5
52 changed files with 10859 additions and 0 deletions
+150
View File
@@ -0,0 +1,150 @@
package collector
import (
"aquacontrolai/internal/protocol"
pg "aquacontrolai/internal/repository/postgres"
td "aquacontrolai/internal/repository/tdengine"
"context"
"github.com/google/uuid"
"log/slog"
"sync"
"time"
)
type LatestValue struct {
Value *float64 `json:"value"`
Quality string `json:"quality"`
QualityReason *string `json:"quality_reason"`
TS time.Time `json:"ts"`
}
type Engine struct {
manager *Manager
pg *pg.Store
td *td.Store
workers int
jobs chan pg.PointRow
mu sync.RWMutex
latest map[uuid.UUID]LatestValue
lastHistory map[uuid.UUID]time.Time
cancel context.CancelFunc
wg sync.WaitGroup
}
func NewEngine(manager *Manager, pgStore *pg.Store, tdStore *td.Store, workers int) *Engine {
if workers < 1 {
workers = 1
}
return &Engine{manager: manager, pg: pgStore, td: tdStore, workers: workers, jobs: make(chan pg.PointRow, workers*4), latest: map[uuid.UUID]LatestValue{}, lastHistory: map[uuid.UUID]time.Time{}}
}
func (e *Engine) Start(parent context.Context) {
ctx, cancel := context.WithCancel(parent)
e.cancel = cancel
for i := 0; i < e.workers; i++ {
e.wg.Add(1)
go e.worker(ctx)
}
e.wg.Add(1)
go e.schedule(ctx)
slog.Info("采集引擎已启动", "workers", e.workers)
}
func (e *Engine) Stop() {
if e.cancel != nil {
e.cancel()
}
e.wg.Wait()
slog.Info("采集引擎已停止")
}
func (e *Engine) Latest(id uuid.UUID) *LatestValue {
e.mu.RLock()
defer e.mu.RUnlock()
v, ok := e.latest[id]
if !ok {
return nil
}
return &v
}
func (e *Engine) schedule(ctx context.Context) {
defer e.wg.Done()
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
last := map[uuid.UUID]time.Time{}
for {
select {
case <-ctx.Done():
return
case now := <-ticker.C:
points, err := e.pg.ListPoints(ctx, "collection", "", false)
if err != nil {
slog.Error("加载采集点失败", "error", err)
continue
}
for _, p := range points {
if !p.Enabled {
continue
}
if now.Sub(last[p.ID]) < time.Duration(p.CollectInterval)*time.Second {
continue
}
select {
case e.jobs <- p:
last[p.ID] = now
default:
slog.Warn("采集任务队列已满", "point_id", p.ID)
}
}
}
}
}
func (e *Engine) worker(ctx context.Context) {
defer e.wg.Done()
for {
select {
case <-ctx.Done():
return
case p := <-e.jobs:
e.collect(ctx, p)
}
}
}
func (e *Engine) collect(ctx context.Context, p pg.PointRow) {
now := time.Now()
var value *float64
quality := 1
reasonText := "disconnected"
reason := &reasonText
c, err := e.manager.Connection(ctx, p.DeviceID)
if err == nil {
v, readErr := c.Read(ctx, p.Address, protocol.DataType(p.DataType))
if readErr == nil {
value = &v
quality = 0
reason = nil
} else {
reasonText = "read_error"
reason = &reasonText
e.manager.MarkDisconnected(p.DeviceID)
}
}
q := "good"
if quality == 1 {
q = "bad"
}
e.mu.Lock()
e.latest[p.ID] = LatestValue{value, q, reason, now}
last := e.lastHistory[p.ID]
shouldStore := p.StoreHistory && now.Sub(last) >= time.Duration(p.HistoryInterval)*time.Minute
if shouldStore {
e.lastHistory[p.ID] = now
}
e.mu.Unlock()
if shouldStore {
d, deviceErr := e.pg.GetDevice(ctx, p.DeviceID)
if deviceErr == nil {
if markErr := e.pg.MarkHistoryStarted(ctx, p.ID); markErr == nil {
if insertErr := e.td.Insert(ctx, p, d, value, quality, reason, now); insertErr != nil {
slog.Error("写入TDengine失败", "point_id", p.ID, "error", insertErr)
}
}
}
}
}
+128
View File
@@ -0,0 +1,128 @@
package collector
import (
"aquacontrolai/internal/protocol"
pg "aquacontrolai/internal/repository/postgres"
"context"
"encoding/json"
"github.com/google/uuid"
"sync"
"time"
)
type Manager struct {
mu sync.RWMutex
connections map[uuid.UUID]protocol.Connection
statuses map[uuid.UUID]string
lastOnline map[uuid.UUID]time.Time
lastOffline map[uuid.UUID]time.Time
store *pg.Store
registry *protocol.Registry
}
func NewManager(store *pg.Store, registry *protocol.Registry) *Manager {
return &Manager{connections: map[uuid.UUID]protocol.Connection{}, statuses: map[uuid.UUID]string{}, lastOnline: map[uuid.UUID]time.Time{}, lastOffline: map[uuid.UUID]time.Time{}, store: store, registry: registry}
}
func (m *Manager) Connection(ctx context.Context, id uuid.UUID) (protocol.Connection, error) {
m.mu.RLock()
c := m.connections[id]
m.mu.RUnlock()
if c != nil {
return c, nil
}
d, e := m.store.GetDevice(ctx, id)
if e != nil {
return nil, e
}
if !d.Enabled {
m.setStatus(id, "disabled")
return nil, context.Canceled
}
f, e := m.registry.Get(d.ProtocolType)
if e != nil {
return nil, e
}
var cfg map[string]any
if e = json.Unmarshal(d.ProtocolConfig, &cfg); e != nil {
return nil, e
}
connectCtx, cancel := context.WithTimeout(ctx, time.Duration(d.ConnectTimeout)*time.Second)
defer cancel()
c, e = f.NewConnection(connectCtx, protocol.DeviceConnectionConfig{DeviceID: id, Host: d.Host, Port: d.Port, ConnectTimeoutSeconds: d.ConnectTimeout, ProtocolConfig: cfg})
if e != nil {
m.setStatus(id, "disconnected")
m.markOffline(id)
return nil, e
}
m.mu.Lock()
if existing := m.connections[id]; existing != nil {
m.mu.Unlock()
_ = c.Close()
return existing, nil
}
m.connections[id] = c
m.statuses[id] = "connected"
m.lastOnline[id] = time.Now()
m.mu.Unlock()
return c, nil
}
func (m *Manager) Invalidate(id uuid.UUID) {
m.mu.Lock()
c := m.connections[id]
delete(m.connections, id)
m.statuses[id] = "disconnected"
m.lastOffline[id] = time.Now()
m.mu.Unlock()
if c != nil {
_ = c.Close()
}
}
func (m *Manager) Status(id uuid.UUID) string {
m.mu.RLock()
defer m.mu.RUnlock()
if s := m.statuses[id]; s != "" {
return s
}
return "disconnected"
}
func (m *Manager) Times(id uuid.UUID) (online, offline *time.Time) {
m.mu.RLock()
defer m.mu.RUnlock()
if t, ok := m.lastOnline[id]; ok {
v := t
online = &v
}
if t, ok := m.lastOffline[id]; ok {
v := t
offline = &v
}
return
}
func (m *Manager) MarkDisconnected(id uuid.UUID) {
m.mu.Lock()
m.statuses[id] = "disconnected"
m.lastOffline[id] = time.Now()
m.mu.Unlock()
}
func (m *Manager) setStatus(id uuid.UUID, s string) {
m.mu.Lock()
m.statuses[id] = s
if s == "disconnected" {
m.lastOffline[id] = time.Now()
}
m.mu.Unlock()
}
func (m *Manager) markOffline(id uuid.UUID) {
m.mu.Lock()
m.lastOffline[id] = time.Now()
m.mu.Unlock()
}
func (m *Manager) Close() {
m.mu.Lock()
all := m.connections
m.connections = map[uuid.UUID]protocol.Connection{}
m.mu.Unlock()
for _, c := range all {
_ = c.Close()
}
}
+50
View File
@@ -0,0 +1,50 @@
package writer
import (
"aquacontrolai/internal/engine/collector"
"aquacontrolai/internal/protocol"
pg "aquacontrolai/internal/repository/postgres"
"context"
"errors"
"math"
"time"
)
type Engine struct {
Manager *collector.Manager
Store *pg.Store
}
type Result struct {
Value, Readback float64
TS time.Time
}
func (e *Engine) Execute(ctx context.Context, p pg.PointRow, value float64) (Result, error) {
if !p.Enabled || !p.WriteEnabled {
return Result{}, errors.New("写入点未启用或写入开关关闭")
}
c, err := e.Manager.Connection(ctx, p.DeviceID)
if err != nil {
return Result{}, err
}
dt := protocol.DataType(p.DataType)
for attempt := 0; attempt < 2; attempt++ {
if err = c.Write(ctx, p.Address, dt, value); err != nil {
continue
}
actual, readErr := c.Read(ctx, p.Address, dt)
if readErr != nil {
err = readErr
continue
}
ok := actual == value
if dt == protocol.Real {
ok = math.Abs(actual-value) <= p.ReadbackTolerance
}
if ok {
return Result{value, actual, time.Now()}, nil
}
err = errors.New("回读值与目标值不一致")
}
return Result{}, err
}