123
This commit is contained in:
@@ -155,6 +155,6 @@ Get-NetTCPConnection -LocalPort 8080,5173 -ErrorAction SilentlyContinue
|
||||
|
||||
- 数据库密码不得提交到仓库。
|
||||
- PLC 人工写入必须启用 `write_enabled`,执行后回读验证并记录 `write_logs`。
|
||||
- Modbus TCP 驱动已保留协议工厂和地址校验,本阶段不进行现场测试。
|
||||
- Modbus TCP 驱动已支持运行时读写。
|
||||
|
||||
开发过程和测试证据见 `docs/development-log.md`。
|
||||
|
||||
@@ -196,3 +196,14 @@
|
||||
- 需求:历史数据表格导出 CSV 时,每个测点只导出一列实际值,不再为每个测点额外导出“质量”列。
|
||||
- 修复:`POST /api/v1/history/export` 表头从“时间 + 点位值列 + 点位质量列”改为“时间 + 每个点位一个值列”;行数据仅写入 `value`,无值时留空,不再输出 `good/bad/—` 质量文本。
|
||||
- 验证:`go test ./...` 通过;重启后端后调用 `POST /api/v1/history/export` 导出点位 `1dd3c3c4-bb27-4b57-bd10-039a6e2a81d9` 的 `2026-07-13 09:40~10:10 +08:00` 数据,CSV 表头为 `时间,2区PAC投加流量[L/h]`,列数为 2,无质量列。
|
||||
|
||||
## 2026-07-13 · Modbus TCP 规格文档同步
|
||||
|
||||
- 根据当前代码和维护记录同步 `spec-数据管理.md`、`spec-历史数据.md` 与一致性决策基线:移除采集有效范围和写入点回读容差旧契约,补充分组表、设备最近在线/离线、写入点单一 `write_enabled`、采集调度/断线恢复当前机制、历史归档清理、断档点、TDengine `+08:00` 时间字面量和历史 CSV 无质量列规则。
|
||||
- 明确 `MODBUS_TCP` 需要具备运行时连接、读写和 REAL 字节序适配能力,避免只停留在协议注册和地址校验。
|
||||
|
||||
## 2026-07-13 · Modbus TCP 运行时驱动
|
||||
|
||||
- 补齐 `internal/protocol/modbus` 运行时 TCP 客户端:实现 MBAP 报文、事务号校验、异常响应处理、FC1/2/3/4 读取、FC5/6/16 写入,以及 `ABCD/BADC/CDAB/DCBA` REAL 字节序转换。
|
||||
- 新增地址校验与 `CDAB` REAL 编解码单元测试,并使用正式协议连接完成读、写和回读验证。
|
||||
- 验证:`go test ./...` 通过。
|
||||
|
||||
@@ -3,15 +3,22 @@ package modbus
|
||||
import (
|
||||
"aquacontrolai/internal/protocol"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Factory struct{}
|
||||
|
||||
func (Factory) ProtocolType() string { return "MODBUS_TCP" }
|
||||
func (Factory) ValidateConfig(c map[string]any) error {
|
||||
u, ok := c["unit_id"].(float64)
|
||||
u, ok := intConfig(c, "unit_id")
|
||||
if !ok || u < 1 || u > 247 {
|
||||
return errors.New("unit_id 必须在1~247")
|
||||
}
|
||||
@@ -22,25 +29,280 @@ func (Factory) ValidateConfig(c map[string]any) error {
|
||||
return nil
|
||||
}
|
||||
func (Factory) ValidateAddress(a string, t protocol.DataType, w bool) error {
|
||||
n, e := strconv.Atoi(a)
|
||||
if e != nil || n < 1 || n > 49999 {
|
||||
return errors.New("Modbus 地址无效")
|
||||
}
|
||||
prefix := n / 10000
|
||||
if prefix <= 1 && t != protocol.Bool {
|
||||
return errors.New("线圈/离散输入仅支持BOOL")
|
||||
}
|
||||
if prefix >= 3 && t == protocol.Bool {
|
||||
return errors.New("寄存器仅支持INT/REAL")
|
||||
}
|
||||
if w && (prefix == 1 || prefix == 3) {
|
||||
return errors.New("该区域只读")
|
||||
}
|
||||
return nil
|
||||
_, e := parseAddress(a, t, w)
|
||||
return e
|
||||
}
|
||||
func (Factory) ConfigSchema() map[string]any {
|
||||
return map[string]any{"type": "object", "required": []string{"unit_id", "float32_order"}}
|
||||
}
|
||||
func (Factory) NewConnection(context.Context, protocol.DeviceConnectionConfig) (protocol.Connection, error) {
|
||||
return nil, errors.New("Modbus 连接由运行时适配器创建")
|
||||
func (Factory) NewConnection(ctx context.Context, cfg protocol.DeviceConnectionConfig) (protocol.Connection, error) {
|
||||
unitID, ok := intConfig(cfg.ProtocolConfig, "unit_id")
|
||||
if !ok || unitID < 1 || unitID > 247 {
|
||||
return nil, errors.New("unit_id 必须在1~247")
|
||||
}
|
||||
order, ok := cfg.ProtocolConfig["float32_order"].(string)
|
||||
if !ok || !map[string]bool{"ABCD": true, "BADC": true, "CDAB": true, "DCBA": true}[order] {
|
||||
return nil, errors.New("float32_order 无效")
|
||||
}
|
||||
dialer := &net.Dialer{Timeout: time.Duration(cfg.ConnectTimeoutSeconds) * time.Second}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
timeout := time.Duration(cfg.ConnectTimeoutSeconds) * time.Second
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
return &connection{conn: conn, unitID: byte(unitID), float32Order: order, timeout: timeout}, nil
|
||||
}
|
||||
|
||||
type address struct {
|
||||
zeroBased uint16
|
||||
readFC byte
|
||||
}
|
||||
|
||||
type connection struct {
|
||||
mu sync.Mutex
|
||||
conn net.Conn
|
||||
unitID byte
|
||||
float32Order string
|
||||
timeout time.Duration
|
||||
txID uint16
|
||||
}
|
||||
|
||||
func intConfig(c map[string]any, k string) (int, bool) {
|
||||
switch v := c[k].(type) {
|
||||
case float64:
|
||||
if math.Trunc(v) != v {
|
||||
return 0, false
|
||||
}
|
||||
return int(v), true
|
||||
case int:
|
||||
return v, true
|
||||
case int32:
|
||||
return int(v), true
|
||||
case int64:
|
||||
return int(v), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func parseAddress(raw string, t protocol.DataType, writable bool) (address, error) {
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n < 1 || n > 49999 {
|
||||
return address{}, errors.New("Modbus 地址无效")
|
||||
}
|
||||
var a address
|
||||
switch {
|
||||
case n >= 1 && n <= 9999:
|
||||
if t != protocol.Bool {
|
||||
return address{}, errors.New("线圈仅支持BOOL")
|
||||
}
|
||||
a = address{zeroBased: uint16(n - 1), readFC: 1}
|
||||
case n >= 10001 && n <= 19999:
|
||||
if t != protocol.Bool {
|
||||
return address{}, errors.New("离散输入仅支持BOOL")
|
||||
}
|
||||
if writable {
|
||||
return address{}, errors.New("离散输入只读")
|
||||
}
|
||||
a = address{zeroBased: uint16(n - 10001), readFC: 2}
|
||||
case n >= 30001 && n <= 39999:
|
||||
if t == protocol.Bool {
|
||||
return address{}, errors.New("输入寄存器仅支持INT/REAL")
|
||||
}
|
||||
if writable {
|
||||
return address{}, errors.New("输入寄存器只读")
|
||||
}
|
||||
a = address{zeroBased: uint16(n - 30001), readFC: 4}
|
||||
case n >= 40001 && n <= 49999:
|
||||
if t == protocol.Bool {
|
||||
return address{}, errors.New("保持寄存器仅支持INT/REAL")
|
||||
}
|
||||
a = address{zeroBased: uint16(n - 40001), readFC: 3}
|
||||
default:
|
||||
return address{}, errors.New("Modbus 地址范围无效")
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (c *connection) Read(ctx context.Context, raw string, t protocol.DataType) (float64, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
a, err := parseAddress(raw, t, false)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
quantity := uint16(1)
|
||||
if t == protocol.Real {
|
||||
quantity = 2
|
||||
}
|
||||
pdu := []byte{a.readFC, byte(a.zeroBased >> 8), byte(a.zeroBased), byte(quantity >> 8), byte(quantity)}
|
||||
resp, err := c.request(ctx, pdu)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(resp) < 2 || resp[0] != a.readFC {
|
||||
return 0, errors.New("Modbus 读取响应无效")
|
||||
}
|
||||
byteCount := int(resp[1])
|
||||
if len(resp) != 2+byteCount {
|
||||
return 0, errors.New("Modbus 读取响应长度无效")
|
||||
}
|
||||
data := resp[2:]
|
||||
switch t {
|
||||
case protocol.Bool:
|
||||
if byteCount < 1 {
|
||||
return 0, errors.New("Modbus BOOL 响应长度无效")
|
||||
}
|
||||
if data[0]&1 != 0 {
|
||||
return 1, nil
|
||||
}
|
||||
return 0, nil
|
||||
case protocol.Int:
|
||||
if byteCount != 2 {
|
||||
return 0, errors.New("Modbus INT 响应长度无效")
|
||||
}
|
||||
return float64(int16(binary.BigEndian.Uint16(data))), nil
|
||||
case protocol.Real:
|
||||
if byteCount != 4 {
|
||||
return 0, errors.New("Modbus REAL 响应长度无效")
|
||||
}
|
||||
value := float64(math.Float32frombits(binary.BigEndian.Uint32(toIEEEBytes(data, c.float32Order))))
|
||||
return math.Round(value*1000) / 1000, nil
|
||||
}
|
||||
return 0, errors.New("不支持的数据类型")
|
||||
}
|
||||
|
||||
func (c *connection) Write(ctx context.Context, raw string, t protocol.DataType, value float64) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
a, err := parseAddress(raw, t, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var pdu []byte
|
||||
switch t {
|
||||
case protocol.Bool:
|
||||
out := uint16(0)
|
||||
if value != 0 {
|
||||
out = 0xFF00
|
||||
}
|
||||
pdu = []byte{5, byte(a.zeroBased >> 8), byte(a.zeroBased), byte(out >> 8), byte(out)}
|
||||
case protocol.Int:
|
||||
out := uint16(int16(value))
|
||||
pdu = []byte{6, byte(a.zeroBased >> 8), byte(a.zeroBased), byte(out >> 8), byte(out)}
|
||||
case protocol.Real:
|
||||
rawBytes := fromIEEEBytes(math.Float32bits(float32(value)), c.float32Order)
|
||||
pdu = []byte{16, byte(a.zeroBased >> 8), byte(a.zeroBased), 0, 2, 4}
|
||||
pdu = append(pdu, rawBytes...)
|
||||
default:
|
||||
return errors.New("不支持的数据类型")
|
||||
}
|
||||
resp, err := c.request(ctx, pdu)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(resp) < 1 || resp[0] != pdu[0] {
|
||||
return errors.New("Modbus 写入响应无效")
|
||||
}
|
||||
if pdu[0] == 16 {
|
||||
if len(resp) != 5 || resp[1] != pdu[1] || resp[2] != pdu[2] || resp[3] != 0 || resp[4] != 2 {
|
||||
return errors.New("Modbus 写多个寄存器响应无效")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(resp) != len(pdu) {
|
||||
return errors.New("Modbus 写入响应长度无效")
|
||||
}
|
||||
for i := range pdu {
|
||||
if resp[i] != pdu[i] {
|
||||
return errors.New("Modbus 写入响应回显不一致")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *connection) request(ctx context.Context, pdu []byte) ([]byte, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.txID++
|
||||
frame := make([]byte, 7+len(pdu))
|
||||
binary.BigEndian.PutUint16(frame[0:2], c.txID)
|
||||
binary.BigEndian.PutUint16(frame[2:4], 0)
|
||||
binary.BigEndian.PutUint16(frame[4:6], uint16(len(pdu)+1))
|
||||
frame[6] = c.unitID
|
||||
copy(frame[7:], pdu)
|
||||
if err := c.setDeadline(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := c.conn.Write(frame); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
header := make([]byte, 7)
|
||||
if _, err := io.ReadFull(c.conn, header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if binary.BigEndian.Uint16(header[0:2]) != c.txID || binary.BigEndian.Uint16(header[2:4]) != 0 || header[6] != c.unitID {
|
||||
return nil, errors.New("Modbus MBAP 响应无效")
|
||||
}
|
||||
length := int(binary.BigEndian.Uint16(header[4:6]))
|
||||
if length < 2 || length > 253 {
|
||||
return nil, errors.New("Modbus MBAP 长度无效")
|
||||
}
|
||||
resp := make([]byte, length-1)
|
||||
if _, err := io.ReadFull(c.conn, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp) >= 2 && resp[0] == pdu[0]|0x80 {
|
||||
return nil, fmt.Errorf("Modbus 异常响应: function=%d exception=%d", pdu[0], resp[1])
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *connection) setDeadline(ctx context.Context) error {
|
||||
deadline := time.Now().Add(c.timeout)
|
||||
if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
|
||||
deadline = d
|
||||
}
|
||||
return c.conn.SetDeadline(deadline)
|
||||
}
|
||||
|
||||
func toIEEEBytes(raw []byte, order string) []byte {
|
||||
switch order {
|
||||
case "ABCD":
|
||||
return []byte{raw[0], raw[1], raw[2], raw[3]}
|
||||
case "BADC":
|
||||
return []byte{raw[1], raw[0], raw[3], raw[2]}
|
||||
case "CDAB":
|
||||
return []byte{raw[2], raw[3], raw[0], raw[1]}
|
||||
case "DCBA":
|
||||
return []byte{raw[3], raw[2], raw[1], raw[0]}
|
||||
default:
|
||||
return []byte{raw[0], raw[1], raw[2], raw[3]}
|
||||
}
|
||||
}
|
||||
|
||||
func fromIEEEBytes(bits uint32, order string) []byte {
|
||||
ieee := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(ieee, bits)
|
||||
switch order {
|
||||
case "ABCD":
|
||||
return []byte{ieee[0], ieee[1], ieee[2], ieee[3]}
|
||||
case "BADC":
|
||||
return []byte{ieee[1], ieee[0], ieee[3], ieee[2]}
|
||||
case "CDAB":
|
||||
return []byte{ieee[2], ieee[3], ieee[0], ieee[1]}
|
||||
case "DCBA":
|
||||
return []byte{ieee[3], ieee[2], ieee[1], ieee[0]}
|
||||
default:
|
||||
return []byte{ieee[0], ieee[1], ieee[2], ieee[3]}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *connection) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package modbus
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"aquacontrolai/internal/protocol"
|
||||
)
|
||||
|
||||
func TestValidateAddress(t *testing.T) {
|
||||
f := Factory{}
|
||||
tests := []struct {
|
||||
name string
|
||||
address string
|
||||
dataType protocol.DataType
|
||||
writable bool
|
||||
wantError bool
|
||||
}{
|
||||
{name: "holding real read", address: "40001", dataType: protocol.Real, wantError: false},
|
||||
{name: "holding real write", address: "40005", dataType: protocol.Real, writable: true, wantError: false},
|
||||
{name: "input real read", address: "30001", dataType: protocol.Real, wantError: false},
|
||||
{name: "input real write denied", address: "30001", dataType: protocol.Real, writable: true, wantError: true},
|
||||
{name: "coil bool write", address: "00001", dataType: protocol.Bool, writable: true, wantError: false},
|
||||
{name: "coil real denied", address: "00001", dataType: protocol.Real, wantError: true},
|
||||
{name: "discrete bool read", address: "10001", dataType: protocol.Bool, wantError: false},
|
||||
{name: "gap denied", address: "20001", dataType: protocol.Int, wantError: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := f.ValidateAddress(tt.address, tt.dataType, tt.writable)
|
||||
if (err != nil) != tt.wantError {
|
||||
t.Fatalf("ValidateAddress() error=%v wantError=%v", err, tt.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFloat32OrderCDAB(t *testing.T) {
|
||||
const value = 12.5
|
||||
var ieee [4]byte
|
||||
binary.BigEndian.PutUint32(ieee[:], math.Float32bits(value))
|
||||
raw := fromIEEEBytes(math.Float32bits(value), "CDAB")
|
||||
wantRaw := []byte{ieee[2], ieee[3], ieee[0], ieee[1]}
|
||||
for i := range raw {
|
||||
if raw[i] != wantRaw[i] {
|
||||
t.Fatalf("fromIEEEBytes CDAB byte %d=%02x want %02x", i, raw[i], wantRaw[i])
|
||||
}
|
||||
}
|
||||
decoded := math.Float32frombits(binary.BigEndian.Uint32(toIEEEBytes(raw, "CDAB")))
|
||||
if math.Abs(float64(decoded)-value) > 0.0001 {
|
||||
t.Fatalf("decoded=%v want %v", decoded, value)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ let currentNames: string[] = [];
|
||||
let visibleNames: Record<string, boolean> = {};
|
||||
let lastCursorTime: number | null = null;
|
||||
let cursorFrame: number | undefined;
|
||||
let draggingCursor = false;
|
||||
const gridTop = 58;
|
||||
const gridBottom = 52;
|
||||
|
||||
@@ -47,6 +48,14 @@ function timestamp(value: any) {
|
||||
function displayValue(value: any) {
|
||||
return typeof value === "number" ? Number(value.toFixed(3)) : value;
|
||||
}
|
||||
function displayDateTime(value: any) {
|
||||
const time = timestamp(value);
|
||||
if (!Number.isFinite(time)) return "—";
|
||||
return new Date(time).toLocaleString("zh-CN", {
|
||||
hour12: false,
|
||||
timeZone: "Asia/Shanghai",
|
||||
});
|
||||
}
|
||||
function axisLabel(value: number) {
|
||||
const date = new Date(value);
|
||||
const parts = new Intl.DateTimeFormat("zh-CN", {
|
||||
@@ -186,9 +195,15 @@ function cursorRows(target: number) {
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
function clampCursorTime(target: number) {
|
||||
return Math.min(Math.max(target, props.startTime), props.endTime);
|
||||
}
|
||||
function midpointCursorTime() {
|
||||
return props.startTime + (props.endTime - props.startTime) / 2;
|
||||
}
|
||||
function emitCursorAt(target: number) {
|
||||
if (!Number.isFinite(target)) return;
|
||||
lastCursorTime = Math.min(Math.max(target, props.startTime), props.endTime);
|
||||
lastCursorTime = clampCursorTime(target);
|
||||
scheduleVisualCursor(lastCursorTime);
|
||||
emit("cursor", cursorRows(lastCursorTime));
|
||||
}
|
||||
@@ -232,7 +247,7 @@ function updateVisualCursor(target: number) {
|
||||
{ lazyUpdate: true },
|
||||
);
|
||||
}
|
||||
function handleMouseMove(event: any) {
|
||||
function pointerTargetTime(event: any) {
|
||||
if (!chart) return;
|
||||
const x = event.zrX ?? event.offsetX;
|
||||
const y = event.zrY ?? event.offsetY;
|
||||
@@ -242,7 +257,35 @@ function handleMouseMove(event: any) {
|
||||
y,
|
||||
]) as number[];
|
||||
const target = timestamp(converted?.[0]);
|
||||
if (Number.isFinite(target)) emitCursorAt(target);
|
||||
return Number.isFinite(target) ? target : undefined;
|
||||
}
|
||||
function updateCursorFromPointer(event: any) {
|
||||
const target = pointerTargetTime(event);
|
||||
if (target !== undefined) emitCursorAt(target);
|
||||
}
|
||||
function isPrimaryMouseDown(event: any) {
|
||||
const native = event.event;
|
||||
return native?.button === undefined || native.button === 0;
|
||||
}
|
||||
function isPrimaryButtonStillPressed(event: any) {
|
||||
const native = event.event;
|
||||
return native?.buttons === undefined || (native.buttons & 1) === 1;
|
||||
}
|
||||
function handleMouseDown(event: any) {
|
||||
if (!isPrimaryMouseDown(event)) return;
|
||||
draggingCursor = true;
|
||||
updateCursorFromPointer(event);
|
||||
}
|
||||
function handleMouseMove(event: any) {
|
||||
if (!draggingCursor) return;
|
||||
if (!isPrimaryButtonStillPressed(event)) {
|
||||
draggingCursor = false;
|
||||
return;
|
||||
}
|
||||
updateCursorFromPointer(event);
|
||||
}
|
||||
function stopCursorDrag() {
|
||||
draggingCursor = false;
|
||||
}
|
||||
function handleLegendChange(event: any) {
|
||||
visibleNames = { ...(event.selected ?? {}) };
|
||||
@@ -281,6 +324,7 @@ function render() {
|
||||
s.unit,
|
||||
s.point_name,
|
||||
s.point_id,
|
||||
d.quality_reason,
|
||||
];
|
||||
if (d.quality === "bad") {
|
||||
solid.push([
|
||||
@@ -292,6 +336,7 @@ function render() {
|
||||
s.unit,
|
||||
s.point_name,
|
||||
s.point_id,
|
||||
d.quality_reason,
|
||||
]);
|
||||
bad.push([
|
||||
d.ts,
|
||||
@@ -302,6 +347,7 @@ function render() {
|
||||
s.unit,
|
||||
s.point_name,
|
||||
s.point_id,
|
||||
d.quality_reason,
|
||||
]);
|
||||
} else {
|
||||
solid.push(row);
|
||||
@@ -314,6 +360,7 @@ function render() {
|
||||
s.unit,
|
||||
s.point_name,
|
||||
s.point_id,
|
||||
d.quality_reason,
|
||||
]);
|
||||
}
|
||||
});
|
||||
@@ -323,11 +370,12 @@ function render() {
|
||||
id: `${s.point_id}-good`,
|
||||
name: currentNames[i],
|
||||
type: "line",
|
||||
showSymbol: false,
|
||||
showSymbol: true,
|
||||
symbolSize: 4,
|
||||
connectNulls: false,
|
||||
data: solid,
|
||||
lineStyle: { width: 1.8, color },
|
||||
itemStyle: { color },
|
||||
itemStyle: { color, opacity: 0.82 },
|
||||
emphasis: { focus: "series" },
|
||||
},
|
||||
{
|
||||
@@ -368,27 +416,22 @@ function render() {
|
||||
itemHeight: 8,
|
||||
},
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
trigger: "item",
|
||||
transitionDuration: 0.12,
|
||||
axisPointer: {
|
||||
type: "none",
|
||||
},
|
||||
backgroundColor: "#07141af2",
|
||||
borderColor: "#35505a",
|
||||
textStyle: { color: "#dce9e5" },
|
||||
formatter: (params: any) => {
|
||||
const rows = Array.isArray(params) ? params : [params];
|
||||
const axisValue = rows[0]?.axisValue;
|
||||
const target =
|
||||
lastCursorTime ?? timestamp(axisValue ?? rows[0]?.data?.[2]);
|
||||
if (!Number.isFinite(target)) return "";
|
||||
emitCursorAt(target);
|
||||
return cursorRows(target)
|
||||
.map(
|
||||
(row: any) =>
|
||||
`${row.pointName}<br/>${displayValue(row.value) ?? "—"} ${row.unit ?? ""} · ${row.quality}`,
|
||||
)
|
||||
.join("<br/>\n");
|
||||
formatter: (param: any) => {
|
||||
const row = param?.data ?? [];
|
||||
if (row[1] === null || row[1] === undefined) return "";
|
||||
const unit = row[5] ? ` ${row[5]}` : "";
|
||||
const reason = row[8] ? `<br/>原因:${row[8]}` : "";
|
||||
return [
|
||||
`${row[6] ?? param.seriesName}`,
|
||||
`时间:${displayDateTime(row[2] ?? row[0])}`,
|
||||
`数值:${displayValue(row[3]) ?? "—"}${unit}`,
|
||||
`质量:${row[4] ?? "none"}${reason}`,
|
||||
].join("<br/>");
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
@@ -424,7 +467,11 @@ function render() {
|
||||
},
|
||||
true,
|
||||
);
|
||||
if (lastCursorTime !== null) scheduleVisualCursor(lastCursorTime);
|
||||
const target =
|
||||
lastCursorTime === null
|
||||
? midpointCursorTime()
|
||||
: clampCursorTime(lastCursorTime);
|
||||
emitCursorAt(target);
|
||||
}
|
||||
onMounted(() => {
|
||||
chart = echarts.init(el.value!);
|
||||
@@ -433,8 +480,13 @@ onMounted(() => {
|
||||
if (chart && lastCursorTime !== null) scheduleVisualCursor(lastCursorTime);
|
||||
});
|
||||
observer.observe(el.value!);
|
||||
chart.getZr().on("mousedown", handleMouseDown);
|
||||
chart.getZr().on("mousemove", handleMouseMove);
|
||||
chart.getZr().on("mouseup", stopCursorDrag);
|
||||
chart.getZr().on("globalout", stopCursorDrag);
|
||||
chart.on("legendselectchanged", handleLegendChange);
|
||||
document.addEventListener("mouseup", stopCursorDrag);
|
||||
window.addEventListener("blur", stopCursorDrag);
|
||||
render();
|
||||
});
|
||||
watch(
|
||||
@@ -445,8 +497,13 @@ watch(
|
||||
onBeforeUnmount(() => {
|
||||
if (cursorFrame !== undefined) cancelAnimationFrame(cursorFrame);
|
||||
observer?.disconnect();
|
||||
chart?.getZr().off("mousedown", handleMouseDown);
|
||||
chart?.getZr().off("mousemove", handleMouseMove);
|
||||
chart?.getZr().off("mouseup", stopCursorDrag);
|
||||
chart?.getZr().off("globalout", stopCursorDrag);
|
||||
chart?.off("legendselectchanged", handleLegendChange);
|
||||
document.removeEventListener("mouseup", stopCursorDrag);
|
||||
window.removeEventListener("blur", stopCursorDrag);
|
||||
chart?.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -34,9 +34,9 @@ const form = reactive({
|
||||
address: "",
|
||||
data_type: "REAL",
|
||||
unit: "",
|
||||
collect_interval: 1,
|
||||
collect_interval: 10,
|
||||
store_history: true,
|
||||
history_interval: 1,
|
||||
history_interval: 10,
|
||||
});
|
||||
async function load() {
|
||||
try {
|
||||
@@ -106,9 +106,9 @@ function open(p?: any) {
|
||||
address: "",
|
||||
data_type: "REAL",
|
||||
unit: "",
|
||||
collect_interval: 1,
|
||||
collect_interval: 10,
|
||||
store_history: true,
|
||||
history_interval: 1,
|
||||
history_interval: 10,
|
||||
},
|
||||
);
|
||||
if (!form.group_name && groups.value.length)
|
||||
@@ -321,8 +321,7 @@ function formatTime(value?: string) {
|
||||
>地址<input
|
||||
v-model="form.address"
|
||||
class="input"
|
||||
required
|
||||
placeholder="DB2.1186.0" /></label
|
||||
required /></label
|
||||
><label class="field"
|
||||
>单位<input v-model="form.unit" class="input" maxlength="32"
|
||||
/></label>
|
||||
|
||||
@@ -12,7 +12,7 @@ type Device = {
|
||||
connection_status: string;
|
||||
last_online_at?: string;
|
||||
last_offline_at?: string;
|
||||
protocol_config: Record<string, number>;
|
||||
protocol_config: Record<string, number | string>;
|
||||
};
|
||||
const items = ref<Device[]>([]),
|
||||
loading = ref(false),
|
||||
@@ -25,11 +25,24 @@ const form = reactive({
|
||||
host: "192.168.107.10",
|
||||
port: 102,
|
||||
connect_timeout: 5,
|
||||
reconnect_interval: 10,
|
||||
reconnect_interval: 5,
|
||||
enabled: true,
|
||||
rack: 0,
|
||||
slot: 0,
|
||||
unit_id: 1,
|
||||
float32_order: "CDAB",
|
||||
});
|
||||
function applyProtocolDefaults() {
|
||||
if (form.protocol_type === "S7") {
|
||||
form.port = 102;
|
||||
form.rack = 0;
|
||||
form.slot = 0;
|
||||
return;
|
||||
}
|
||||
form.port = 502;
|
||||
form.unit_id = 1;
|
||||
form.float32_order = "CDAB";
|
||||
}
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -49,22 +62,26 @@ function open(d?: Device) {
|
||||
Object.assign(
|
||||
form,
|
||||
d
|
||||
? {
|
||||
...d,
|
||||
rack: d.protocol_config.rack ?? 0,
|
||||
slot: d.protocol_config.slot ?? 0,
|
||||
}
|
||||
: {
|
||||
name: "",
|
||||
protocol_type: "S7",
|
||||
host: "192.168.107.10",
|
||||
port: 102,
|
||||
connect_timeout: 5,
|
||||
reconnect_interval: 10,
|
||||
enabled: true,
|
||||
rack: 0,
|
||||
slot: 0,
|
||||
},
|
||||
? {
|
||||
...d,
|
||||
rack: d.protocol_config.rack ?? 0,
|
||||
slot: d.protocol_config.slot ?? 0,
|
||||
unit_id: d.protocol_config.unit_id ?? 1,
|
||||
float32_order: String(d.protocol_config.float32_order ?? "CDAB"),
|
||||
}
|
||||
: {
|
||||
name: "",
|
||||
protocol_type: "S7",
|
||||
host: "192.168.107.10",
|
||||
port: 102,
|
||||
connect_timeout: 5,
|
||||
reconnect_interval: 5,
|
||||
enabled: true,
|
||||
rack: 0,
|
||||
slot: 0,
|
||||
unit_id: 1,
|
||||
float32_order: "CDAB",
|
||||
},
|
||||
);
|
||||
show.value = true;
|
||||
}
|
||||
@@ -74,7 +91,7 @@ async function save() {
|
||||
protocol_config:
|
||||
form.protocol_type === "S7"
|
||||
? { rack: form.rack, slot: form.slot }
|
||||
: { unit_id: 1, float32_order: "ABCD" },
|
||||
: { unit_id: form.unit_id, float32_order: form.float32_order },
|
||||
};
|
||||
try {
|
||||
editing.value
|
||||
@@ -212,7 +229,11 @@ function formatTime(value?: string) {
|
||||
required
|
||||
maxlength="128" /></label
|
||||
><label class="field"
|
||||
>协议<select v-model="form.protocol_type" class="select">
|
||||
>协议<select
|
||||
v-model="form.protocol_type"
|
||||
class="select"
|
||||
@change="applyProtocolDefaults"
|
||||
>
|
||||
<option>S7</option>
|
||||
<option>MODBUS_TCP</option>
|
||||
</select></label
|
||||
@@ -236,6 +257,23 @@ function formatTime(value?: string) {
|
||||
v-model.number="form.slot"
|
||||
class="input"
|
||||
type="number" /></label></template
|
||||
><template v-else
|
||||
><label class="field"
|
||||
>站号<input
|
||||
v-model.number="form.unit_id"
|
||||
class="input"
|
||||
type="number"
|
||||
min="1"
|
||||
max="247"
|
||||
required /></label
|
||||
><label class="field"
|
||||
>REAL 字节序<select v-model="form.float32_order" class="select">
|
||||
<option>ABCD</option>
|
||||
<option>BADC</option>
|
||||
<option>CDAB</option>
|
||||
<option>DCBA</option>
|
||||
</select></label
|
||||
></template
|
||||
><label class="field"
|
||||
>连接超时(秒)<input
|
||||
v-model.number="form.connect_timeout"
|
||||
|
||||
@@ -5,7 +5,19 @@ import HistoryChart from "@/components/charts/HistoryChart.vue";
|
||||
import { historyApi, systemApi } from "@/api/platform";
|
||||
|
||||
const groups = ref<any[]>([]);
|
||||
const selected = ref<string[]>([]);
|
||||
const historySelectionKey = "aqua.history.selectedPointIds";
|
||||
function readStoredSelection() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(historySelectionKey);
|
||||
const parsed = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((id) => typeof id === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
const selected = ref<string[]>(readStoredSelection());
|
||||
const expandedGroups = ref<Set<string>>(new Set());
|
||||
const mode = ref<"curve" | "table">("curve");
|
||||
const range = ref("1h");
|
||||
@@ -17,6 +29,7 @@ const cursor = ref<any[]>([]);
|
||||
const retention = ref(365);
|
||||
const loading = ref(false);
|
||||
const treeLoading = ref(false);
|
||||
const treeReady = ref(false);
|
||||
const cleaning = ref(false);
|
||||
const queryError = ref("");
|
||||
const cache = new Map<string, any>();
|
||||
@@ -115,10 +128,7 @@ function syncTree(nextGroups: any[]) {
|
||||
.flatMap((g: any) => g.children ?? [])
|
||||
.filter((p: any) => p.type === "collection" && !p.disabled);
|
||||
const ids = new Set(points.map((p: any) => p.id));
|
||||
const preserved = selected.value.filter((id) => ids.has(id));
|
||||
selected.value = preserved.length
|
||||
? preserved
|
||||
: points.slice(0, 2).map((p: any) => p.id);
|
||||
selected.value = selected.value.filter((id) => ids.has(id));
|
||||
const open = new Set(
|
||||
nextGroups
|
||||
.filter((g: any) => g.children?.some((p: any) => p.type === "collection"))
|
||||
@@ -139,6 +149,7 @@ async function loadTree() {
|
||||
groups.value = [];
|
||||
selected.value = [];
|
||||
} finally {
|
||||
treeReady.value = true;
|
||||
treeLoading.value = false;
|
||||
}
|
||||
}
|
||||
@@ -276,6 +287,7 @@ function tableValue(value: any) {
|
||||
|
||||
watch(
|
||||
() => [
|
||||
treeReady.value,
|
||||
selected.value.join(","),
|
||||
start.value.getTime(),
|
||||
end.value.getTime(),
|
||||
@@ -283,11 +295,18 @@ watch(
|
||||
interval.value,
|
||||
],
|
||||
() => {
|
||||
if (!treeReady.value) return;
|
||||
if (queryTimer) clearTimeout(queryTimer);
|
||||
queryTimer = setTimeout(() => void query(), 180);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
watch(
|
||||
() => selected.value,
|
||||
(value) => {
|
||||
sessionStorage.setItem(historySelectionKey, JSON.stringify(value));
|
||||
},
|
||||
);
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const r: any = await systemApi.getRetention();
|
||||
@@ -501,8 +520,7 @@ onBeforeUnmount(() => {
|
||||
<td>{{ c.pointName }}</td>
|
||||
<td>{{ formatDateTime(c.ts) }}</td>
|
||||
<td :class="c.quality === 'bad' ? 'quality-bad' : ''">
|
||||
{{ c.interpolated ? "≈" : ""
|
||||
}}{{ formatCursorValue(c.value) ?? "—" }}
|
||||
{{ formatCursorValue(c.value) ?? "—" }}
|
||||
{{ c.unit }}
|
||||
</td>
|
||||
<td>{{ c.quality ?? "—" }}</td>
|
||||
|
||||
@@ -251,13 +251,15 @@ onMounted(async () => {
|
||||
|
||||
<div class="toolbar" style="margin-bottom: 12px">
|
||||
<button
|
||||
:class="['btn', tab === 'points' && 'btn-primary']"
|
||||
v-if="tab === 'logs'"
|
||||
class="btn"
|
||||
@click="tab = 'points'"
|
||||
>
|
||||
写入点
|
||||
</button>
|
||||
<button
|
||||
:class="['btn', tab === 'logs' && 'btn-primary']"
|
||||
v-if="tab === 'points'"
|
||||
class="btn"
|
||||
@click="tab = 'logs'"
|
||||
>
|
||||
操作日志
|
||||
@@ -380,7 +382,6 @@ onMounted(async () => {
|
||||
v-model="form.address"
|
||||
class="input"
|
||||
required
|
||||
placeholder="MD540"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
|
||||
+47
-19
@@ -80,7 +80,9 @@ CREATE STABLE IF NOT EXISTS collection_data (
|
||||
);
|
||||
```
|
||||
|
||||
`value` 可为 NULL。读取失败或断线时使用 `quality=bad`、`value=NULL`;超出有效范围时保留实际值并使用 `quality=bad`。
|
||||
`value` 可为 NULL。读取失败或断线时使用 `quality=bad`、`value=NULL`;采集点已移除有效范围配置,协议读取和解析成功即写入 `quality=good`。
|
||||
|
||||
TDengine 写入和查询时间字面量统一使用带上海时区偏移的 RFC3339 样式,例如 `2026-07-13T10:05:41.002+08:00`。读回时间按真实 instant 转换为 `Asia/Shanghai` 输出,避免无时区字符串在 REST 链路中产生 8 小时偏移。
|
||||
|
||||
### 2.2 预留数据来源:非采集点位(内部数据)
|
||||
|
||||
@@ -234,18 +236,15 @@ ALTER DATABASE ${TDENGINE_DATABASE} KEEP 730;
|
||||
| POST | /api/v1/history/query | 查询历史数据(曲线模式) |
|
||||
| POST | /api/v1/history/query-table | 查询历史数据(表格模式) |
|
||||
| POST | /api/v1/history/export | 导出历史数据为CSV |
|
||||
| POST | /api/v1/history/archive/cleanup | 清理已逻辑删除点位的归档历史子表 |
|
||||
|
||||
### 5.2 接口详细定义
|
||||
|
||||
#### 5.2.1 GET /api/v1/history/tree — 获取历史点位树
|
||||
|
||||
Query parameters:
|
||||
当前接口不需要查询参数,默认包含活跃点位和仍有历史数据的归档点位。
|
||||
|
||||
| 参数 | 类型 | 必填 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| include_archived | bool | 否 | true | 是否包含仍有历史数据的归档点位 |
|
||||
|
||||
历史模块调用 `ListHistoryPointMetadata(IncludeArchived=true)`,批量检查 TDengine 历史存在性并构建树;不得直接查询数据管理 PostgreSQL 表。
|
||||
历史模块调用 `ListHistoryPointMetadata(IncludeArchived=true)` 等价能力,批量检查 TDengine 历史存在性并构建树;不得直接查询数据管理模块以外的私有 Repository。当前实现通过进程内 PostgreSQL Store 读取包括逻辑删除记录在内的元数据。
|
||||
|
||||
Response (200):
|
||||
|
||||
@@ -272,6 +271,7 @@ Response (200):
|
||||
"group_name": "曝气池",
|
||||
"lifecycle_status": "active",
|
||||
"has_history_data": true,
|
||||
"can_cleanup": false,
|
||||
"latest_value": {
|
||||
"value": 2.35,
|
||||
"quality": "good",
|
||||
@@ -291,6 +291,7 @@ Response (200):
|
||||
"group_name": "曝气池",
|
||||
"lifecycle_status": "archived",
|
||||
"has_history_data": true,
|
||||
"can_cleanup": true,
|
||||
"latest_value": null
|
||||
}
|
||||
]
|
||||
@@ -331,8 +332,9 @@ Request body:
|
||||
1. 调用 `GetHistoryPointMetadata(..., includeArchived=true)` 校验并批量取得当前名称、单位和类型;
|
||||
2. 由 UUID 派生并白名单校验 `p_<uuid32>` 子表名;
|
||||
3. 查询 `ts, value, quality, quality_reason`;
|
||||
4. 原始点数超过 `max_samples` 时执行自适应 min-max 降采样,保留首尾、极值、质量变化和 gap 边界;
|
||||
5. 返回每条序列的采样统计。
|
||||
4. 按点位 `history_interval` 判断断档,相邻样本间隔超过 `3 × history_interval` 时插入 `value=null, quality=none` 的断点,避免曲线跨长时间停采区间连线;
|
||||
5. 原始点数超过 `max_samples` 时执行自适应 min-max 降采样,保留首尾、极值、质量变化和 gap 边界;
|
||||
6. 返回每条序列的采样统计。
|
||||
|
||||
Response (200):
|
||||
|
||||
@@ -349,12 +351,13 @@ Response (200):
|
||||
"unit": "mg/L",
|
||||
"sampled": false,
|
||||
"raw_count": 5,
|
||||
"sample_count": 5,
|
||||
"sample_count": 6,
|
||||
"data": [
|
||||
{"ts":"2026-07-09T00:00:01+08:00","value":2.35,"quality":"good","quality_reason":null},
|
||||
{"ts":"2026-07-09T00:00:02+08:00","value":2.36,"quality":"good","quality_reason":null},
|
||||
{"ts":"2026-07-09T00:00:05+08:00","value":2.38,"quality":"bad","quality_reason":"out_of_range"},
|
||||
{"ts":"2026-07-09T00:00:05+08:00","value":2.38,"quality":"good","quality_reason":null},
|
||||
{"ts":"2026-07-09T00:00:08+08:00","value":null,"quality":"bad","quality_reason":"timeout"},
|
||||
{"ts":"2026-07-09T00:00:09+08:00","value":null,"quality":"none","quality_reason":null},
|
||||
{"ts":"2026-07-09T00:00:10+08:00","value":2.40,"quality":"good","quality_reason":null}
|
||||
]
|
||||
}
|
||||
@@ -434,13 +437,37 @@ Response (200):
|
||||
报表 CSV 允许本地化动态标题:
|
||||
|
||||
```csv
|
||||
时间,曝气池DO_01[mg/L],曝气池DO_01[mg/L]_质量,曝气池温度[℃],曝气池温度[℃]_质量
|
||||
2026-07-09 00:00:00,2.35,good,25.1,good
|
||||
2026-07-09 00:10:00,2.40,good,25.0,good
|
||||
2026-07-09 00:20:00,—,—,25.3,good
|
||||
时间,曝气池DO_01[mg/L],曝气池温度[℃]
|
||||
2026-07-09 00:00:00,2.35,25.1
|
||||
2026-07-09 00:10:00,2.40,25.0
|
||||
2026-07-09 00:20:00,,25.3
|
||||
```
|
||||
|
||||
重复点位名称追加 UUID 前8位。bad 时数值列显示 `—`、质量列显示 `bad`;none 时两列均显示 `—`。
|
||||
重复点位名称追加 UUID 前8位。CSV 每个测点只导出一列实际值,不额外导出质量列;`bad`、`none` 或空值时该值列留空。
|
||||
|
||||
#### 5.2.5 POST /api/v1/history/archive/cleanup — 清理已删除点位归档数据
|
||||
|
||||
该接口仅清理采集点已逻辑删除,或所属设备已逻辑删除的历史子表。禁用但仍可恢复的点位、`store_history=false` 但配置仍存在的点位不纳入清理。
|
||||
|
||||
Request body:无。
|
||||
|
||||
Response (200):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"deleted_points": 13
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
执行规则:
|
||||
|
||||
- 由 PostgreSQL 枚举已逻辑删除点位 UUID;
|
||||
- TDengine 子表名仍只能由 UUID 派生为 `p_<uuid32>`;
|
||||
- 删除使用 `DROP TABLE IF EXISTS`,清理不可逆,前端必须二次确认。
|
||||
|
||||
### 5.3 错误码
|
||||
|
||||
@@ -715,10 +742,11 @@ ECharts 使用封装在 `src/components/charts/` 的纯函数生成映射和 opt
|
||||
| H009 | 模式切换 | 保持点位勾选状态和时间范围不变 |
|
||||
| H010 | CSV导出 | 报表型动态标题,安全服务端文件名,最多50,000行 |
|
||||
| H011 | 查询间隔建议 | 小于最大history_interval时仅提示,不禁止;查询最大31天 |
|
||||
| H012 | 曲线采样 | 每序列默认最多2000点,超限执行自适应min-max降采样 |
|
||||
| H012 | 曲线采样 | 每序列默认最多2000点,先按3倍history_interval插入断档点,再超限执行自适应min-max降采样 |
|
||||
| H013 | 缺失值协议 | 固定返回value=null、quality=none、matched_ts=null对象 |
|
||||
| H014 | 归档清理 | 仅清理已逻辑删除点位或已删除设备下点位的 TDengine 子表,禁用点位不清理 |
|
||||
|
||||
---
|
||||
|
||||
> 本文档版本:v1.1
|
||||
> 最后更新:2026-07-11
|
||||
> 本文档版本:v1.2
|
||||
> 最后更新:2026-07-13
|
||||
|
||||
+88
-78
@@ -87,8 +87,8 @@ CREATE UNIQUE INDEX idx_devices_name ON devices(name) WHERE deleted = FALSE;
|
||||
|
||||
| 协议类型标识 | 说明 | 当前状态 |
|
||||
|-------------|------|---------|
|
||||
| `S7` | 西门子S7协议 | 已支持 |
|
||||
| `MODBUS_TCP` | Modbus TCP协议 | 已支持 |
|
||||
| `S7` | 西门子S7协议 | 采集、写入和现场联调已支持 |
|
||||
| `MODBUS_TCP` | Modbus TCP协议 | 已支持运行时连接、采集和写入 |
|
||||
| (后续扩展) | 新增协议通过插件机制添加 | 待扩展 |
|
||||
|
||||
#### 2.1.3 各协议 protocol_config 定义
|
||||
@@ -143,6 +143,7 @@ CREATE UNIQUE INDEX idx_devices_name ON devices(name) WHERE deleted = FALSE;
|
||||
|
||||
说明:
|
||||
- `connection_status` 是运行时状态,由采集引擎维护在内存中,**不存储到数据库**
|
||||
- `last_online_at`、`last_offline_at` 也是运行时内存字段,仅在状态实际变化时更新,用于设备列表展示最近在线/离线时间
|
||||
- 设备逻辑删除(deleted=true)时,在接口响应中表现为 `disabled`
|
||||
- 采集引擎未运行时,所有 enabled=true 的设备均返回 `disconnected`
|
||||
|
||||
@@ -237,9 +238,9 @@ PUT 使用完整替换语义,以下字段全部必填:
|
||||
```
|
||||
|
||||
- 校验规则与新增一致,额外要求 `enabled` 为布尔值。
|
||||
- 修改连接参数或协议配置后,Service 在事务提交后发布配置变更事件;采集引擎断开旧连接并使用新参数重连。
|
||||
- 修改连接参数或协议配置后,Service 保存成功即调用连接管理器使该设备旧连接失效;下一次采集或写入会按新参数重新建立连接。
|
||||
- `enabled=false` 时运行时状态立即进入 `disabled`;`enabled=true` 后先返回 `disconnected`,连接成功后变为 `connected`。
|
||||
- 配置变更正常情况下 1 秒内生效,5 秒全量校对保证最终一致。
|
||||
- 采集调度器每秒重新加载活动采集点,点位配置变更通常在下一个调度周期生效。
|
||||
|
||||
**DELETE /api/v1/devices/{id}** — 逻辑删除
|
||||
|
||||
@@ -317,6 +318,8 @@ Response (200):
|
||||
"reconnect_interval": 10,
|
||||
"protocol_config": { "rack": 0, "slot": 1 },
|
||||
"connection_status": "connected",
|
||||
"last_online_at": "2026-07-13T09:30:00+08:00",
|
||||
"last_offline_at": null,
|
||||
"created_at": "...",
|
||||
"updated_at": "..."
|
||||
}
|
||||
@@ -386,8 +389,6 @@ CREATE TABLE collection_points (
|
||||
address VARCHAR(256) NOT NULL,
|
||||
data_type VARCHAR(16) NOT NULL, -- 'BOOL', 'INT', 'REAL'
|
||||
unit VARCHAR(32),
|
||||
valid_min DOUBLE PRECISION,
|
||||
valid_max DOUBLE PRECISION,
|
||||
|
||||
collect_interval INTEGER NOT NULL DEFAULT 1 CHECK (collect_interval >= 1),
|
||||
store_history BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
@@ -397,9 +398,7 @@ CREATE TABLE collection_points (
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
created_by VARCHAR(64),
|
||||
updated_by VARCHAR(64),
|
||||
|
||||
CHECK (valid_min IS NULL OR valid_max IS NULL OR valid_min <= valid_max)
|
||||
updated_by VARCHAR(64)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_collection_points_name
|
||||
@@ -410,9 +409,23 @@ CREATE INDEX idx_collection_points_group_name
|
||||
ON collection_points(group_name) WHERE deleted = FALSE;
|
||||
```
|
||||
|
||||
`history_started_at` 在点位首次准备写入 TDengine 前设置。一旦非空,`device_id` 和 `data_type` 不可修改;需要改变设备归属或数据类型时必须新建点位,防止同一历史子表混入不同语义的数据。
|
||||
有效最小值/最大值已从采集点配置中移除,采集质量不再执行业务有效范围判断;协议读写成功即为 `good`,失败才标记 `bad`。
|
||||
|
||||
#### 3.1.1.1 历史模块只读元数据接口
|
||||
`history_started_at` 在点位首次准备写入 TDengine 前设置。当前实现允许编辑采集点的 `device_id` 和 `data_type`;历史子表中的 `device_name`、`point_name`、`data_type` 仍是写入时快照,历史页面展示以 PostgreSQL 当前元数据为准。若业务需要严格保留历史语义,后续应通过新建点位或迁移流程处理。
|
||||
|
||||
#### 3.1.1.1 数据库表:collection_groups
|
||||
|
||||
```sql
|
||||
CREATE TABLE collection_groups (
|
||||
name VARCHAR(64) PRIMARY KEY,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
`collection_groups` 是采集点和写入点共享的扁平分组字典。启动迁移会把历史 `group_name` 同步进该表,并确保 `default` 分组存在。
|
||||
|
||||
#### 3.1.1.2 历史模块只读元数据接口
|
||||
|
||||
设备与采集点配置由数据管理模块拥有。历史模块必须调用公开的进程内只读领域接口,不得直接查询 `devices`、`collection_points` 表或调用数据管理 Repository。
|
||||
|
||||
@@ -456,11 +469,17 @@ GetHistoryPointMetadata(ctx context.Context, pointIDs []uuid.UUID, includeArchiv
|
||||
| BOOL | `DB2.10.0` | 读DB2的第10字节的第0位 |
|
||||
| INT | `DB2.10` | 读DB2的第10字节开始的2字节(16位整数) |
|
||||
| REAL | `DB2.10` | 读DB2的第10字节开始的4字节(32位浮点数) |
|
||||
| REAL | `DB2.1186.0` | 兼容现场 DB 数值地址末尾 `.0` 的工程记法,实际按字节偏移 1186 连续读取 4 字节 |
|
||||
| REAL | `MD540` | M 区双字地址,按字节偏移 540 连续读写 4 字节 |
|
||||
| INT | `MW540` | M 区字地址,按字节偏移 540 连续读写 2 字节 |
|
||||
|
||||
解析规则:
|
||||
- 地址格式:`{type_prefix}{numbers}`,其中 numbers 以 `.` 分隔
|
||||
- 对于 BOOL 类型:必须有3段(或2段,M/I/Q类型),如 `DB2.10.0` 或 `M4.0`
|
||||
- 对于 INT/REAL 类型:BOOL类型的地址去掉最后的 .bit 部分,如 `DB2.10`、`M4`
|
||||
- 对于 INT/REAL 类型:BOOL类型的地址通常去掉最后的 .bit 部分,如 `DB2.10`、`M4`
|
||||
- 为兼容现场工程记法,DB 数值地址允许末尾 `.0`,驱动解析时忽略该尾段;其他非 BOOL 地址不得包含 bit
|
||||
- M/Q 区数值地址允许 `MW`/`QW` 表示 INT,`MD`/`QD` 表示 REAL;I 区只读,写入点不得使用 I 区地址
|
||||
- S7 REAL 读取按 IEEE-754 大端解析,并统一四舍五入到小数点后最多 3 位
|
||||
|
||||
**Modbus TCP协议地址格式**:
|
||||
|
||||
@@ -496,8 +515,12 @@ GetHistoryPointMetadata(ctx context.Context, pointIDs []uuid.UUID, includeArchiv
|
||||
|
||||
- 默认分组名:`default`
|
||||
- 分组名支持任意字符串(建议不超过64字符)
|
||||
- 一个采集点只能属于一个分组
|
||||
- 分组的增删改通过修改采集点的 group_name 字段实现
|
||||
- 一个采集点或写入点只能属于一个分组
|
||||
- 分组名称保存在 `collection_groups` 表;采集点和写入点通过各自的 `group_name` 字段引用
|
||||
- 新建分组直接插入 `collection_groups`
|
||||
- 改名分组时,同步更新该分组下未逻辑删除的采集点和写入点
|
||||
- 删除非 `default` 分组时,先将该分组下未逻辑删除的采集点和写入点迁移到 `default`,再删除分组;不逻辑删除任何点位
|
||||
- `default` 分组不可删除
|
||||
- 查询时支持按分组名筛选
|
||||
|
||||
#### 3.1.4 实时数据
|
||||
@@ -548,8 +571,6 @@ Request body:
|
||||
"address": "DB2.10",
|
||||
"data_type": "REAL",
|
||||
"unit": "mg/L",
|
||||
"valid_min": 0,
|
||||
"valid_max": 20,
|
||||
"collect_interval": 1,
|
||||
"store_history": true,
|
||||
"history_interval": 1
|
||||
@@ -563,12 +584,11 @@ Request body:
|
||||
- address:必填,1~256字符,根据设备协议类型校验格式
|
||||
- data_type:必填,枚举值:`BOOL`、`INT`、`REAL`
|
||||
- unit:可选,0~32字符;用于历史表格和图表展示
|
||||
- valid_min / valid_max:可选;同时提供时必须 valid_min <= valid_max,超范围值记为 bad 但保留原始值
|
||||
- collect_interval:必填,最小值1(秒)
|
||||
- store_history:可选,默认true
|
||||
- history_interval:当 store_history=true 时必填,必须为 1~1440 的整数(分钟);store_history=false 时可省略并使用默认值 1,若提供仍须符合该范围
|
||||
- address 和 data_type 的兼容性校验:
|
||||
- S7协议:BOOL类型必须包含bit位(如 `DB2.10.0`),INT/REAL类型不能包含bit位
|
||||
- S7协议:BOOL类型必须包含bit位(如 `DB2.10.0`);INT/REAL 类型通常不包含 bit,仅 DB 数值地址兼容末尾 `.0`
|
||||
- Modbus协议:00001/10001 地址只能使用BOOL类型,30001/40001 地址只能使用INT/REAL类型
|
||||
|
||||
**PUT /api/v1/collection-points/{id}** — 修改采集点
|
||||
@@ -584,17 +604,14 @@ PUT 使用完整替换语义,请求字段与新增一致,并额外要求 `en
|
||||
"address": "DB2.10",
|
||||
"data_type": "REAL",
|
||||
"unit": "mg/L",
|
||||
"valid_min": 0,
|
||||
"valid_max": 20,
|
||||
"collect_interval": 1,
|
||||
"store_history": true,
|
||||
"history_interval": 5
|
||||
}
|
||||
```
|
||||
|
||||
- `history_started_at` 非空时,修改 `device_id` 或 `data_type` 返回 HTTP 409、`code=41009`。
|
||||
- 名称、分组、地址、单位、有效范围、采集周期和历史间隔可以修改。
|
||||
- Service 在事务提交后发布配置变更事件,采集引擎停止旧任务并按新配置启动;5 秒内保证最终生效。
|
||||
- 当前实现允许修改 `device_id`、`data_type`、名称、分组、地址、单位、采集周期和历史间隔;修改后历史已有数据仍保留在原子表中,展示名称和单位以当前 PostgreSQL 元数据为准。
|
||||
- 当前采集调度器每秒重新加载活动采集点;点位新增、修改、删除和启停通常在下一个调度周期生效。
|
||||
|
||||
**DELETE /api/v1/collection-points/{id}** — 逻辑删除
|
||||
|
||||
@@ -636,8 +653,6 @@ Response (200):
|
||||
"address": "DB2.10",
|
||||
"data_type": "REAL",
|
||||
"unit": "mg/L",
|
||||
"valid_min": 0,
|
||||
"valid_max": 20,
|
||||
"collect_interval": 1,
|
||||
"store_history": true,
|
||||
"history_interval": 1,
|
||||
@@ -668,15 +683,21 @@ Response (200):
|
||||
"groups": [
|
||||
{
|
||||
"name": "default",
|
||||
"count": 10
|
||||
"count": 10,
|
||||
"collection_count": 10,
|
||||
"write_count": 2
|
||||
},
|
||||
{
|
||||
"name": "曝气池",
|
||||
"count": 25
|
||||
"count": 25,
|
||||
"collection_count": 25,
|
||||
"write_count": 4
|
||||
},
|
||||
{
|
||||
"name": "加药间",
|
||||
"count": 15
|
||||
"count": 15,
|
||||
"collection_count": 15,
|
||||
"write_count": 6
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -688,10 +709,10 @@ Response (200):
|
||||
CSV格式定义:
|
||||
|
||||
```csv
|
||||
name,group_name,device_name,address,data_type,unit,valid_min,valid_max,collect_interval,store_history,history_interval,enabled
|
||||
曝气池DO_01,曝气池,一期曝气柜PLC,DB2.10,REAL,mg/L,0,20,1,TRUE,1,TRUE
|
||||
进水pH,进水仪表,进水仪表柜PLC,DB1.0,REAL,pH,0,14,5,TRUE,5,TRUE
|
||||
风机运行状态,曝气池,一期曝气柜PLC,M4.0,BOOL,,,,1,TRUE,1,TRUE
|
||||
name,group_name,device_name,address,data_type,unit,collect_interval,store_history,history_interval,enabled
|
||||
曝气池DO_01,曝气池,一期曝气柜PLC,DB2.10,REAL,mg/L,1,TRUE,1,TRUE
|
||||
进水pH,进水仪表,进水仪表柜PLC,DB1.0,REAL,pH,5,TRUE,5,TRUE
|
||||
风机运行状态,曝气池,一期曝气柜PLC,M4.0,BOOL,,1,TRUE,1,TRUE
|
||||
```
|
||||
|
||||
注意:
|
||||
@@ -703,7 +724,6 @@ name,group_name,device_name,address,data_type,unit,valid_min,valid_max,collect_i
|
||||
|
||||
- 导入逻辑:以 name 为唯一标识,存在则更新,不存在则新增。
|
||||
- 先校验 `device_name`,再按对应协议校验地址和数据类型。
|
||||
- 若更新已有点位且 `history_started_at` 非空,CSV 不得改变 `device_name` 对应的设备或 `data_type`。
|
||||
- 响应沿用设备导入的 `{total,created,updated,failed,errors}` 结构。
|
||||
|
||||
**GET /api/v1/collection-points/{id}** 返回列表项的完整对象,并包含 `history_started_at`;无实时缓存时 `latest_value=null`。
|
||||
@@ -724,15 +744,12 @@ CREATE TABLE write_points (
|
||||
name VARCHAR(128) NOT NULL,
|
||||
group_name VARCHAR(64) NOT NULL DEFAULT 'default',
|
||||
device_id UUID NOT NULL REFERENCES devices(id),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
write_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
deleted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
address VARCHAR(256) NOT NULL,
|
||||
data_type VARCHAR(16) NOT NULL, -- 'BOOL', 'INT', 'REAL'
|
||||
unit VARCHAR(32),
|
||||
readback_tolerance DOUBLE PRECISION NOT NULL DEFAULT 0.0001
|
||||
CHECK (readback_tolerance >= 0),
|
||||
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
@@ -763,7 +780,7 @@ CREATE INDEX idx_write_points_group_name
|
||||
|
||||
- 地址格式与采集点一致,但只允许可写区域:S7 的 DB/M/Q,Modbus 的 Coil/保持寄存器。
|
||||
- BOOL、INT 回读必须严格相等。
|
||||
- REAL 使用 `abs(readback-target) <= readback_tolerance`;默认容差 0.0001。
|
||||
- REAL 使用固定协议精度 `abs(readback-target) <= 0.0001`,该容差不作为写入点配置或 API 字段暴露。
|
||||
- 同一设备的采集、写入和回读通过设备连接命令队列串行执行,避免协议客户端并发冲突。
|
||||
|
||||
### 4.2 RESTful API
|
||||
@@ -791,28 +808,25 @@ CREATE INDEX idx_write_points_group_name
|
||||
"name": "加药泵频率",
|
||||
"group_name": "加药间",
|
||||
"device_id": "uuid-of-device",
|
||||
"enabled": true,
|
||||
"write_enabled": false,
|
||||
"address": "DB2.10",
|
||||
"data_type": "REAL",
|
||||
"unit": "Hz",
|
||||
"readback_tolerance": 0.01
|
||||
"unit": "Hz"
|
||||
}
|
||||
```
|
||||
|
||||
- POST 中 `enabled` 可省略,默认 true;PUT 中全部字段必填。
|
||||
- POST/PUT 均只使用 `write_enabled` 作为写入权限开关,不再提供独立 `enabled` 字段。
|
||||
- 名称全局唯一;设备必须存在且未删除;地址必须可写并与数据类型兼容。
|
||||
- `readback_tolerance` 仅对 REAL 生效,范围 0~1,000,000。
|
||||
- GET 列表支持 `page`、`page_size`、`keyword`、`device_id`、`group_name`、`data_type`、`enabled`、`write_enabled`。
|
||||
- GET 列表支持 `page`、`page_size`、`keyword`、`device_id`、`group_name`、`data_type`、`write_enabled`。
|
||||
- DELETE 设置 `deleted=true` 并立即拒绝新的写入请求。
|
||||
|
||||
成功响应中的写入点对象包含上述字段及 `id`、`device_name`、`protocol_type`、`created_at`、`updated_at`。
|
||||
成功响应中的写入点对象包含上述字段及 `id`、`device_name`、`protocol_type`、`readback_value`、`created_at`、`updated_at`;`readback_value` 来自该写入点最近一条写入日志,无记录时为 `null`。
|
||||
|
||||
配置 CSV:
|
||||
|
||||
```csv
|
||||
name,group_name,device_name,address,data_type,unit,enabled,write_enabled,readback_tolerance
|
||||
加药泵频率,加药间,一期加药间PLC,DB2.10,REAL,Hz,TRUE,FALSE,0.01
|
||||
name,group_name,device_name,address,data_type,unit,write_enabled
|
||||
加药泵频率,加药间,一期加药间PLC,DB2.10,REAL,Hz,FALSE
|
||||
```
|
||||
|
||||
导入以 `name` 为唯一标识,存在则更新;失败结果返回行号、字段和稳定错误原因。写入点 CSV 成功时直接返回文件流,不使用 JSON envelope;失败时返回统一 JSON 错误。
|
||||
@@ -836,7 +850,7 @@ name,group_name,device_name,address,data_type,unit,enabled,write_enabled,readbac
|
||||
|
||||
执行流程:
|
||||
|
||||
1. 校验点位、设备、`enabled` 和 `write_enabled`;
|
||||
1. 校验点位、设备和 `write_enabled`;
|
||||
2. 通过设备级连接队列写入;
|
||||
3. 回读并按数据类型验证;不一致时重试一次;
|
||||
4. 无论成功、失败或超时都写入 `write_logs`;
|
||||
@@ -962,12 +976,12 @@ CREATE INDEX idx_write_logs_created_at ON write_logs(created_at DESC);
|
||||
|
||||
```
|
||||
1. Web 服务完成依赖初始化后启动采集引擎
|
||||
2. 从数据库加载所有 enabled=true AND deleted=false 的设备
|
||||
3. 从数据库加载所有 enabled=true AND deleted=false 的采集点,按 device_id 分组
|
||||
4. 对每个设备,尝试建立连接:
|
||||
a. 连接成功 → 标记为"已连接",启动该设备下所有采集点的采集任务
|
||||
b. 连接失败 → 标记为"断开",启动重连计时器
|
||||
5. 调度器按 collect_interval 生成点位任务并投递到有界 worker pool;同一设备的协议操作串行执行
|
||||
2. 启动固定数量 worker,并启动每秒一次的调度循环
|
||||
3. 调度循环每秒从 PostgreSQL 加载所有未删除的采集点及其设备元数据
|
||||
4. 跳过 `enabled=false` 的采集点;按每个点位的 `collect_interval` 判断是否到期
|
||||
5. 到期点位投递到有界 worker pool;队列满时记录告警并跳过该次调度
|
||||
6. worker 执行点位任务时通过连接管理器获取设备连接;连接不存在时按设备配置懒创建
|
||||
7. 同一设备连接内部串行执行协议读写
|
||||
```
|
||||
|
||||
### 5.3 采集任务执行流程
|
||||
@@ -977,21 +991,18 @@ CREATE INDEX idx_write_logs_created_at ON write_logs(created_at DESC);
|
||||
1. 调度器生成任务并投递到有界 worker pool;队列满时记录告警,不无限创建 goroutine。
|
||||
2. 获取该设备的独立 ProtocolConnection,并进入设备级串行命令队列。
|
||||
3. 读取和解析:
|
||||
- 成功且值在有效范围内(或未配置范围)→ value=实际值, quality=good;
|
||||
- 成功但超出 valid_min/valid_max → value=实际值, quality=bad, quality_reason=out_of_range;
|
||||
- 成功 → value=实际值, quality=good;
|
||||
- 超时/断线/读取失败/解析失败 → value=null, quality=bad,并填写稳定 quality_reason。
|
||||
4. 更新内存 latest_values:{value, quality, quality_reason, ts}。
|
||||
5. store_history=true 且到达 history_interval 时写入 TDengine。
|
||||
6. 首次准备写入历史数据前设置 history_started_at;设置成功后即禁止修改 device_id/data_type。
|
||||
7. 如配置 MQTT,由独立发布队列异步发送,MQTT 失败不得阻塞采集任务。
|
||||
6. 首次准备写入历史数据前设置 history_started_at。
|
||||
```
|
||||
|
||||
### 5.4 质量戳判定规则
|
||||
|
||||
| 条件 | value | quality | quality_reason | TDengine quality |
|
||||
|---|---:|---|---|---:|
|
||||
| 读取、解析成功且在有效范围内 | 实际值 | `good` | null | 0 |
|
||||
| 读取成功但超出有效范围 | 实际值 | `bad` | `out_of_range` | 1 |
|
||||
| 读取、解析成功 | 实际值 | `good` | null | 0 |
|
||||
| 协议超时 | null | `bad` | `timeout` | 1 |
|
||||
| 设备断开 | null | `bad` | `disconnected` | 1 |
|
||||
| 读取异常 | null | `bad` | `read_error` | 1 |
|
||||
@@ -1003,15 +1014,12 @@ API 始终返回字符串质量戳。`none` 只表示查询没有匹配记录;
|
||||
### 5.5 断线重连机制
|
||||
|
||||
```
|
||||
1. 采集引擎检测到设备连接断开(读取失败或连接异常断开)
|
||||
2. 立即标记该设备状态为"断开"
|
||||
3. 该设备下所有采集点标记为 bad
|
||||
4. 启动重连计时器(间隔 = 设备配置的 reconnect_interval)
|
||||
5. 每次重连尝试:
|
||||
a. 连接成功 → 标记设备为"已连接",恢复采集
|
||||
b. 连接失败 → 继续等待下一次重连
|
||||
6. 重连成功后,自动恢复所有采集点的正常采集
|
||||
7. 重连无次数限制,持续尝试直到成功或设备被禁用
|
||||
1. 采集任务获取连接或读取失败时,连接管理器将设备标记为 `disconnected`,并记录最近离线时间。
|
||||
2. 失败点位本次最新值写入内存缓存:`value=null, quality=bad`,`quality_reason` 按失败阶段记录。
|
||||
3. 后续点位到期时再次通过连接管理器获取连接;连接不存在或已失效时重新按设备配置建立连接。
|
||||
4. 任一采集读取成功后调用 `MarkConnected` 恢复设备状态为 `connected`,并仅在状态变化时更新最近在线时间。
|
||||
5. `reconnect_interval` 作为设备配置保留字段;当前实现没有独立重连计时器,实际重试节奏由点位采集周期和任务调度决定。
|
||||
6. 设备禁用或逻辑删除后不再执行采集任务。
|
||||
```
|
||||
|
||||
### 5.6 动态配置更新
|
||||
@@ -1029,7 +1037,7 @@ API 始终返回字符串质量戳。`none` 只表示查询没有匹配记录;
|
||||
| 删除采集点 | 停止该点的采集任务 |
|
||||
| 启用/禁用采集点 | 禁用则停止,启用则启动 |
|
||||
|
||||
实现方式:Service 在配置事务提交后发布进程内变更事件,采集引擎正常情况下 1 秒内处理;同时每 5 秒按配置版本执行一次全量校对,作为丢事件后的兜底,因此对外保证 5 秒内最终生效。
|
||||
实现方式:当前实现不使用独立配置事件总线。采集调度器每秒重新读取活动采集点,因此点位配置通常在下一个调度周期生效;设备配置保存成功后立即使旧连接失效,下一次采集或写入按新配置重新建连。
|
||||
|
||||
### 5.7 TDengine 数据写入
|
||||
|
||||
@@ -1068,7 +1076,7 @@ USING collection_data TAGS (
|
||||
#### 5.7.3 写入策略
|
||||
|
||||
- 首次准备历史写入前设置 `history_started_at`,然后使用 `INSERT INTO ... USING ... TAGS` 创建/写入子表
|
||||
- 批量写入:每1秒或每100条数据一批;服务关闭前尽力刷新
|
||||
- 当前实现到达 `history_interval` 后立即写入 TDengine;后续如引入批量队列,必须保证服务关闭前尽力刷新
|
||||
- `store_history=false` 时停止新增历史记录,但已存在的数据仍可在历史模块中查询
|
||||
- 写入频率由 `history_interval` 控制
|
||||
- 动态子表名只由已验证 UUID 派生并通过 `^p_[0-9a-f]{32}$` 校验;时间和值参数仍使用参数绑定
|
||||
@@ -1126,7 +1134,7 @@ type DeviceRuntimeStatusProvider interface {
|
||||
### 6.2 写入安全策略
|
||||
|
||||
1. **write_enabled 开关**:写入点必须显式开启 write_enabled=true 才能写入,防止误操作
|
||||
2. **回读验证**:BOOL/INT 严格相等;REAL 满足 `abs(actual-target) <= readback_tolerance` 才算成功
|
||||
2. **回读验证**:BOOL/INT 严格相等;REAL 满足固定协议精度 `abs(actual-target) <= 0.0001` 才算成功
|
||||
3. **操作记录**:所有写入操作记录到 write_logs,包含日志 ID、时间、点位、目标值、回读值、结果、失败原因和原因;当前阶段不记录具体操作者
|
||||
4. **访问边界**:当前阶段未实施身份认证,写入 API 仅应部署在受信任的内部网络;接入身份认证后再补充操作者归属与权限控制
|
||||
|
||||
@@ -1229,6 +1237,7 @@ func (r *ProtocolRegistry) Get(protocolType string) (ProtocolDriverFactory, erro
|
||||
| 连接粒度 | 每设备一个连接实例 |
|
||||
| 地址解析 | 见 3.1.2 |
|
||||
| 配置 | `rack`、`slot` |
|
||||
| 运行时状态 | 已接入 `gos7`,现场已完成 DB REAL 采集、MD REAL 写入和回读验证 |
|
||||
|
||||
#### 7.4.2 Modbus TCP
|
||||
|
||||
@@ -1239,6 +1248,7 @@ func (r *ProtocolRegistry) Get(protocolType string) (ProtocolDriverFactory, erro
|
||||
| 连接粒度 | 每设备一个连接实例 |
|
||||
| 地址解析 | 见 3.1.2 |
|
||||
| 配置 | `unit_id`、`float32_order`,其中 `float32_order ∈ {ABCD,BADC,CDAB,DCBA}` |
|
||||
| 运行时状态 | 已实现 Modbus TCP 客户端;支持线圈、离散输入、输入寄存器、保持寄存器读取,支持线圈、保持寄存器写入,并支持 `ABCD/BADC/CDAB/DCBA` REAL 字节序 |
|
||||
|
||||
---
|
||||
|
||||
@@ -1301,15 +1311,15 @@ func (r *ProtocolRegistry) Get(protocolType string) (ProtocolDriverFactory, erro
|
||||
| R006 | 采集周期最小1秒 | 不可低于1秒 |
|
||||
| R007 | 历史存储间隔范围 | 1~1440 分钟;当store_history=true时必填 |
|
||||
| R008 | 人工写入记录 | 服务端固定记录 source=manual、operator=null;当前阶段不支持自动写入 |
|
||||
| R009 | 写入回读验证 | BOOL/INT严格相等;REAL按readback_tolerance判断,不一致重试1次 |
|
||||
| R009 | 写入回读验证 | BOOL/INT严格相等;REAL按固定0.0001协议精度判断,不一致重试1次 |
|
||||
| R010 | 写入点地址类型限制 | 必须使用可写地址类型 |
|
||||
| R011 | 质量戳自动判定 | 失败时value=null且bad;越界时保留value并标记bad;无匹配记录为none |
|
||||
| R012 | 断线无限重连 | 持续按配置间隔重连,直到成功或设备被禁用 |
|
||||
| R013 | 配置变更动态生效 | 事务后事件正常1秒内生效,5秒全量校对保证最终一致 |
|
||||
| R014 | 历史语义不可变 | history_started_at非空后不可修改device_id或data_type |
|
||||
| R011 | 质量戳自动判定 | 失败时value=null且bad;读取成功即good;无匹配记录为none |
|
||||
| R012 | 断线恢复 | 后续采集任务按采集周期重新建连;任一读取成功后恢复在线状态 |
|
||||
| R013 | 配置变更动态生效 | 点位配置由每秒调度加载生效;设备配置保存后旧连接立即失效并在下次使用时重连 |
|
||||
| R014 | 历史启动标记 | 首次准备写入历史前设置history_started_at;当前实现不阻断后续device_id或data_type编辑 |
|
||||
| R015 | 协议连接隔离 | 注册Factory,每个设备独立Connection,同设备读写串行 |
|
||||
|
||||
---
|
||||
|
||||
> 本文档版本:v1.2
|
||||
> 最后更新:2026-07-11
|
||||
> 本文档版本:v1.3
|
||||
> 最后更新:2026-07-13
|
||||
|
||||
+13
-11
@@ -44,8 +44,8 @@
|
||||
|
||||
### ADR-006|历史元数据与配置变更
|
||||
|
||||
- `collection_points` 增加 `unit`、`valid_min`、`valid_max`、`history_started_at`。
|
||||
- 首次准备写入历史数据前设置 `history_started_at`;一旦非空,禁止修改 `device_id` 和 `data_type`。需要改变时必须新建点位。
|
||||
- `collection_points` 保留 `unit`、`history_started_at`;`valid_min`、`valid_max` 已移除。
|
||||
- 首次准备写入历史数据前设置 `history_started_at`。当前实现允许后续修改 `device_id` 和 `data_type`;历史展示以 PostgreSQL 当前元数据为准,TDengine 旧行保留写入时快照。
|
||||
- 点位名称、设备名称、分组和单位可以修改;历史 API 使用 PostgreSQL 当前元数据展示。
|
||||
- TDengine 中的 `point_name`、`device_name` 和 `data_type` 是写入时快照,不是当前配置的权威来源。
|
||||
|
||||
@@ -54,8 +54,8 @@
|
||||
- API 质量枚举:`good | bad | none`。
|
||||
- TDengine 只存储 `good=0`、`bad=1`;`none` 表示查询无匹配记录,不落库。
|
||||
- `value` 允许为 `null`:读取失败、断线或解析失败时写入 `quality=bad`、`value=null`。
|
||||
- 读取成功但超出配置有效范围时写入 `quality=bad` 且保留数值。
|
||||
- TDengine 和 API 增加可空 `quality_reason`,使用稳定代码,例如 `timeout`、`disconnected`、`parse_error`、`out_of_range`。
|
||||
- 采集质量不再执行有效范围判断;协议读取和解析成功即为 `good`。
|
||||
- TDengine 和 API 增加可空 `quality_reason`,使用稳定代码,例如 `timeout`、`disconnected`、`parse_error`、`read_error`。
|
||||
- 表格无匹配记录统一返回 `{value:null, quality:"none", quality_reason:null, matched_ts:null}`,不得返回整个元素为 `null`。
|
||||
|
||||
### ADR-008|历史查询边界与降采样
|
||||
@@ -93,13 +93,15 @@
|
||||
### ADR-013|写入值和回读
|
||||
|
||||
- 写入日志数据库可用 TEXT 保存原始值,但 API 必须按 `data_type` 返回 JSON boolean/number。
|
||||
- `write_points` 增加 `readback_tolerance`;BOOL/INT 严格相等,REAL 使用绝对误差 `abs(actual-target) <= readback_tolerance`。
|
||||
- `write_points` 仅保留 `write_enabled` 写入权限开关;`enabled` 和 `readback_tolerance` 已移除。
|
||||
- BOOL/INT 回读严格相等;REAL 使用固定协议精度 `abs(actual-target) <= 0.0001`。
|
||||
- 当前阶段仅支持内部网络中的未认证人工写入,服务端固定记录 `source=manual`、`operator=null`。
|
||||
|
||||
### ADR-014|配置生效时限
|
||||
|
||||
- Service 在事务提交后发布进程内配置变更事件,正常情况下 1 秒内生效。
|
||||
- 采集引擎每 5 秒执行一次全量版本校对作为丢事件后的兜底;因此对外 SLA 为 5 秒内最终生效。
|
||||
- 当前实现不使用独立配置事件总线。
|
||||
- 采集调度器每秒重新读取活动采集点,点位配置通常在下一个调度周期生效。
|
||||
- 设备配置保存成功后立即使旧连接失效,下一次采集或写入按新配置重新建连。
|
||||
|
||||
### ADR-015|历史图表职责
|
||||
|
||||
@@ -115,9 +117,9 @@
|
||||
|
||||
| 文档 | 版本 |
|
||||
|---|---|
|
||||
| `spec-数据管理.md` | v1.2 |
|
||||
| `spec-历史数据.md` | v1.1 |
|
||||
| `spec-数据管理.md` | v1.3 |
|
||||
| `spec-历史数据.md` | v1.2 |
|
||||
| `code-standards.md` | v1.2 |
|
||||
| 本基线 | v1.0 |
|
||||
| 本基线 | v1.1 |
|
||||
|
||||
> 最后更新:2026-07-11
|
||||
> 最后更新:2026-07-13
|
||||
|
||||
Reference in New Issue
Block a user