This commit is contained in:
2026-07-13 14:31:49 +08:00
parent dac7457c60
commit 06fd99fcf9
12 changed files with 666 additions and 186 deletions
+1 -1
View File
@@ -155,6 +155,6 @@ Get-NetTCPConnection -LocalPort 8080,5173 -ErrorAction SilentlyContinue
- 数据库密码不得提交到仓库。
- PLC 人工写入必须启用 `write_enabled`,执行后回读验证并记录 `write_logs`
- Modbus TCP 驱动已保留协议工厂和地址校验,本阶段不进行现场测试
- Modbus TCP 驱动已支持运行时读写
开发过程和测试证据见 `docs/development-log.md`
+11
View File
@@ -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 ./...` 通过。
+280 -18
View File
@@ -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()
}
+54
View File
@@ -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)
}
}
+80 -23
View File
@@ -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>
+5 -6
View File
@@ -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>
+43 -5
View File
@@ -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 {
@@ -53,6 +66,8 @@ function open(d?: Device) {
...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: "",
@@ -60,10 +75,12 @@ function open(d?: Device) {
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",
},
);
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"
+25 -7
View File
@@ -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>
+4 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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/QModbus 的 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
View File
@@ -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