diff --git a/README.md b/README.md index 2c42dd4..186189f 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,6 @@ Get-NetTCPConnection -LocalPort 8080,5173 -ErrorAction SilentlyContinue - 数据库密码不得提交到仓库。 - PLC 人工写入必须启用 `write_enabled`,执行后回读验证并记录 `write_logs`。 -- Modbus TCP 驱动已保留协议工厂和地址校验,本阶段不进行现场测试。 +- Modbus TCP 驱动已支持运行时读写。 开发过程和测试证据见 `docs/development-log.md`。 diff --git a/docs/development-log.md b/docs/development-log.md index 6186507..360af4d 100644 --- a/docs/development-log.md +++ b/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 ./...` 通过。 diff --git a/internal/protocol/modbus/factory.go b/internal/protocol/modbus/factory.go index 0c81e2c..8b0d026 100644 --- a/internal/protocol/modbus/factory.go +++ b/internal/protocol/modbus/factory.go @@ -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() } diff --git a/internal/protocol/modbus/factory_test.go b/internal/protocol/modbus/factory_test.go new file mode 100644 index 0000000..2ab760b --- /dev/null +++ b/internal/protocol/modbus/factory_test.go @@ -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) + } +} diff --git a/web/src/components/charts/HistoryChart.vue b/web/src/components/charts/HistoryChart.vue index 817a590..b809102 100644 --- a/web/src/components/charts/HistoryChart.vue +++ b/web/src/components/charts/HistoryChart.vue @@ -17,6 +17,7 @@ let currentNames: string[] = []; let visibleNames: Record = {}; 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}
${displayValue(row.value) ?? "—"} ${row.unit ?? ""} · ${row.quality}`, - ) - .join("
\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] ? `
原因:${row[8]}` : ""; + return [ + `${row[6] ?? param.seriesName}`, + `时间:${displayDateTime(row[2] ?? row[0])}`, + `数值:${displayValue(row[3]) ?? "—"}${unit}`, + `质量:${row[4] ?? "none"}${reason}`, + ].join("
"); }, }, 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(); }); diff --git a/web/src/views/collection/CollectionView.vue b/web/src/views/collection/CollectionView.vue index 2f4b6c1..6a6740c 100644 --- a/web/src/views/collection/CollectionView.vue +++ b/web/src/views/collection/CollectionView.vue @@ -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) { >地址 diff --git a/web/src/views/device/DeviceView.vue b/web/src/views/device/DeviceView.vue index cc171e3..644b5d6 100644 --- a/web/src/views/device/DeviceView.vue +++ b/web/src/views/device/DeviceView.vue @@ -12,7 +12,7 @@ type Device = { connection_status: string; last_online_at?: string; last_offline_at?: string; - protocol_config: Record; + protocol_config: Record; }; const items = ref([]), 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" />