diff --git a/backend/main.py b/backend/main.py index 969dde9..3ce5b18 100644 --- a/backend/main.py +++ b/backend/main.py @@ -373,6 +373,9 @@ def get_opc_config(): "write_counter_node": opc_service.write_counter_node, "write_source_node": opc_service.write_source_node, "write_target_node": opc_service.write_target_node, + "write_s7_endpoint": opc_service.write_s7_endpoint, + "write_s7_rack": opc_service.write_s7_rack, + "write_s7_slot": opc_service.write_s7_slot, "write_nodes": opc_service.write_nodes, "running": opc_service.running, "last_counter": opc_service.last_counter, @@ -392,6 +395,9 @@ async def save_opc_config(config: OpcConfig): opc_service.write_counter_node = config.write_counter_node opc_service.write_source_node = config.write_source_node opc_service.write_target_node = config.write_target_node + opc_service.write_s7_endpoint = config.write_s7_endpoint + opc_service.write_s7_rack = config.write_s7_rack + opc_service.write_s7_slot = config.write_s7_slot opc_service.write_nodes = config.write_nodes opc_service.write_counter_last = None try: diff --git a/backend/models.py b/backend/models.py index b3ad92e..a761d1c 100644 --- a/backend/models.py +++ b/backend/models.py @@ -187,4 +187,7 @@ class OpcConfig(BaseModel): write_counter_node: str = "" write_source_node: str = "" write_target_node: str = "" + write_s7_endpoint: str = "" + write_s7_rack: int = 0 + write_s7_slot: int = 1 write_nodes: Dict[str, Dict[str, str]] = {} diff --git a/backend/opc_service.py b/backend/opc_service.py index 82cb851..bb97f9e 100644 --- a/backend/opc_service.py +++ b/backend/opc_service.py @@ -67,6 +67,9 @@ class OpcService: self.write_counter_node: str = os.getenv("OPC_WRITE_COUNTER_NODE", "") self.write_source_node: str = os.getenv("OPC_WRITE_SOURCE_NODE", "") self.write_target_node: str = os.getenv("OPC_WRITE_TARGET_NODE", "") + self.write_s7_endpoint: str = os.getenv("OPC_WRITE_S7_ENDPOINT", "") + self.write_s7_rack: int = int(os.getenv("OPC_WRITE_S7_RACK", "0")) + self.write_s7_slot: int = int(os.getenv("OPC_WRITE_S7_SLOT", "1")) self.write_counter_last: Optional[Any] = None # 写入字段映射(按目标开卷机 1/2 分组) self.write_nodes: Dict[str, Dict[str, str]] = {} @@ -116,6 +119,9 @@ class OpcService: self.write_counter_node = cfg.get("write_counter_node", self.write_counter_node) self.write_source_node = cfg.get("write_source_node", self.write_source_node) self.write_target_node = cfg.get("write_target_node", self.write_target_node) + self.write_s7_endpoint = cfg.get("write_s7_endpoint", self.write_s7_endpoint) + self.write_s7_rack = int(cfg.get("write_s7_rack", self.write_s7_rack)) + self.write_s7_slot = int(cfg.get("write_s7_slot", self.write_s7_slot)) self.write_nodes = cfg.get("write_nodes", self.write_nodes) or {} self._log(f"Loaded OPC config from {self.config_path}") except Exception as exc: @@ -133,6 +139,9 @@ class OpcService: "write_counter_node": self.write_counter_node, "write_source_node": self.write_source_node, "write_target_node": self.write_target_node, + "write_s7_endpoint": self.write_s7_endpoint, + "write_s7_rack": self.write_s7_rack, + "write_s7_slot": self.write_s7_slot, "write_nodes": self.write_nodes, } os.makedirs(os.path.dirname(self.config_path), exist_ok=True) @@ -323,10 +332,10 @@ class OpcService: if source_value != 100 or target_value not in (1, 2): return - await self._write_entry_coil_to_uncoiler(client, target_value) + await self._write_entry_coil_to_uncoiler(target_value) - async def _write_entry_coil_to_uncoiler(self, client, target_uncoiler: int): - """Write the smallest COILID plan to target uncoiler OPC nodes.""" + async def _write_entry_coil_to_uncoiler(self, target_uncoiler: int): + """Write the smallest COILID plan to target uncoiler via S7.""" def _load_next_plan(): from database import get_connection @@ -377,43 +386,66 @@ class OpcService: if not target_cfg: self._log(f"Write nodes for uncoiler {target_uncoiler} not configured") return + if not self.write_s7_endpoint: + self._log("Write S7 endpoint is empty, skip write") + return - # setup_data_revision: 每次写入 +1 - revision_node = target_cfg.get("setup_data_revision") - if revision_node: + field_meta = { + "setup_data_revision": {"data_type": "INT32", "data_length": 4}, + "coilid": {"data_type": "S7STRING", "data_length": 20}, + "entry_coil_weight": {"data_type": "FLOAT", "data_length": 4}, + "entry_of_coil_length": {"data_type": "FLOAT", "data_length": 4}, + "entry_coil_width": {"data_type": "FLOAT", "data_length": 4}, + "entry_coil_thickness": {"data_type": "FLOAT", "data_length": 4}, + "entry_of_coil_inner_diameter": {"data_type": "FLOAT", "data_length": 4}, + "entry_of_coil_outer_diameter": {"data_type": "FLOAT", "data_length": 4}, + "alloy_code": {"data_type": "S7STRING", "data_length": 4}, + "material": {"data_type": "S7STRING", "data_length": 20}, + } + + revision_address = target_cfg.get("setup_data_revision") + if revision_address: try: - rev_value = client.get_node(revision_node).get_value() - next_rev = int(rev_value or 0) + 1 - await self._write_node_value_by_client(client, revision_node, next_rev, "Int32") + next_rev = self._read_s7_int32( + endpoint=self.write_s7_endpoint, + address=revision_address, + rack=self.write_s7_rack, + slot=self.write_s7_slot, + ) + 1 + await self.write_s7_value( + endpoint=self.write_s7_endpoint, + address=revision_address, + data_length=4, + data_type="INT32", + value=next_rev, + rack=self.write_s7_rack, + slot=self.write_s7_slot, + ) except Exception as exc: self._log(f"Write setup_data_revision failed (U{target_uncoiler}): {exc}") - field_variant = { - "coilid": "String", - "entry_coil_weight": "Float", - "entry_of_coil_length": "Float", - "entry_coil_width": "Float", - "entry_coil_thickness": "Float", - "entry_of_coil_inner_diameter": "Float", - "entry_of_coil_outer_diameter": "Float", - "alloy_code": "String", - "material": "String", - } - - for field, value in plan.items(): - node_id = target_cfg.get(field) - if not node_id or value is None: + for field, value in {"coilid": plan["coilid"], **plan}.items(): + if field == "setup_data_revision": + continue + address = target_cfg.get(field) + if not address or value is None: + continue + meta = field_meta.get(field) + if not meta: continue try: - await self._write_node_value_by_client( - client, - node_id, - value, - field_variant.get(field), + await self.write_s7_value( + endpoint=self.write_s7_endpoint, + address=address, + data_length=meta["data_length"], + data_type=meta["data_type"], + value=value, + rack=self.write_s7_rack, + slot=self.write_s7_slot, ) except Exception as exc: self._log( - f"Write field failed (U{target_uncoiler}, {field}, node={node_id}): {exc}" + f"Write field failed (U{target_uncoiler}, {field}, address={address}): {exc}" ) self._log( @@ -431,11 +463,105 @@ class OpcService: from opcua import ua # type: ignore node = client.get_node(node_id) + # 1) Try direct write first (some OPC servers do their own coercion better) + if variant_type is None: + try: + node.set_value(value) + return + except Exception: + pass + vt = self._normalize_variant_type(variant_type) + if vt is None: + try: + vt = node.get_data_type_as_variant_type() + except Exception: + vt = None + if vt is None: node.set_value(value) - else: - node.set_value(ua.DataValue(ua.Variant(value, vt))) + return + + # 2) Coerce by OPC variant type + try: + write_value = self._coerce_value_for_variant(value, vt) + node.set_value(ua.DataValue(ua.Variant(write_value, vt))) + return + except Exception: + pass + + # 3) Coerce by current value runtime shape (list/bytes/str/int...) + current_val = node.get_value() + write_value = self._coerce_value_by_current_value(value, current_val) + node.set_value(write_value) + + def _coerce_value_for_variant(self, value: Any, vt): + """Coerce python value to match OPC ua.VariantType.""" + vt_name = getattr(vt, "name", str(vt)) + + if vt_name in ("Boolean",): + return bool(value) + if vt_name in ("SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32", "Int64", "UInt64"): + if value is None or value == "": + return 0 + if isinstance(value, str): + raw = value.strip() + if raw == "": + return 0 + if raw.lstrip("-").isdigit(): + return int(raw) + # Non-numeric text to numeric scalar is invalid; let caller try another strategy + raise ValueError(f"Cannot coerce non-numeric text '{value}' to {vt_name}") + return int(value) + if vt_name in ("Float", "Double"): + if value is None or value == "": + return 0.0 + return float(value) + if vt_name in ("String",): + return "" if value is None else str(value) + if vt_name in ("ByteString",): + if value is None: + return b"" + if isinstance(value, bytes): + return value + return str(value).encode("utf-8") + + return value + + def _coerce_value_by_current_value(self, value: Any, current_val: Any): + """Coerce value according to current node value runtime structure.""" + if isinstance(current_val, str): + return "" if value is None else str(value) + + if isinstance(current_val, (bytes, bytearray)): + raw = b"" if value is None else str(value).encode("utf-8") + length = len(current_val) + if length > 0: + raw = raw[:length].ljust(length, b"\x00") + return raw if isinstance(current_val, bytes) else bytearray(raw) + + if isinstance(current_val, (list, tuple)) and current_val: + # Common PLC string representation: array of bytes/ints + if all(isinstance(x, int) for x in current_val): + arr_len = len(current_val) + raw = b"" if value is None else str(value).encode("ascii", errors="ignore") + raw = raw[:arr_len].ljust(arr_len, b"\x00") + return [int(b) for b in raw] + + if isinstance(current_val, bool): + return bool(value) + if isinstance(current_val, int): + if value is None or value == "": + return 0 + if isinstance(value, str) and not value.strip().lstrip("-").isdigit(): + raise ValueError(f"Cannot write non-numeric '{value}' to int node") + return int(value) + if isinstance(current_val, float): + if value is None or value == "": + return 0.0 + return float(value) + + return value async def _handle_signal1(self): """Handle signal1: fetch next 4 coils from Oracle PDI table and save to SQLite temp table.""" @@ -839,6 +965,14 @@ class OpcService: raise ValueError(f"{dt} data_length must be 8") return struct.pack(">d", float(value)) + if dt == "S7STRING": + if data_length < 1 or data_length > 254: + raise ValueError("S7STRING data_length must be 1..254") + text = "" if value is None else str(value) + raw = text.encode("ascii", errors="ignore")[:data_length] + # Siemens STRING layout: [max_len][cur_len][chars...] + return bytes([data_length, len(raw)]) + raw.ljust(data_length, b"\x00") + if dt in ("STRING", "BYTES"): if data_length < 1: raise ValueError(f"{dt} data_length must be >= 1") @@ -851,7 +985,7 @@ class OpcService: raise ValueError( "Unsupported S7 data_type. " - "Example: BOOL/BYTE/INT16/UINT16/INT32/UINT32/REAL/LREAL/STRING" + "Example: BOOL/BYTE/INT16/UINT16/INT32/UINT32/REAL/LREAL/STRING/S7STRING" ) async def write_s7_value( @@ -1053,6 +1187,13 @@ class OpcService: raise ValueError(f"{dt} data_length must be 8") return struct.pack(">d", float(value)) + if dt == "S7STRING": + if data_length < 1 or data_length > 254: + raise ValueError("S7STRING data_length must be 1..254") + text = "" if value is None else str(value) + raw = text.encode("ascii", errors="ignore")[:data_length] + return bytes([data_length, len(raw)]) + raw.ljust(data_length, b"\x00") + if dt in ("STRING", "BYTES"): if isinstance(value, bytes): raw = value @@ -1064,9 +1205,35 @@ class OpcService: raise ValueError( f"Unsupported data_type: {data_type}. " - "Supported: BOOL/BYTE/INT8/UINT16/WORD/INT16/UINT32/DWORD/INT32/DINT/REAL/FLOAT/LREAL/DOUBLE/STRING/BYTES" + "Supported: BOOL/BYTE/INT8/UINT16/WORD/INT16/UINT32/DWORD/INT32/DINT/REAL/FLOAT/LREAL/DOUBLE/STRING/S7STRING/BYTES" ) + def _read_s7_int32(self, endpoint: str, address: str, rack: int = 0, slot: int = 1) -> int: + """Read a 4-byte signed int from S7 DB address.""" + try: + import snap7 # type: ignore + except ImportError as exc: + raise RuntimeError("python-snap7 package not installed") from exc + + ip, tcp_port = self._parse_s7_endpoint(endpoint) + db_number, area_code, byte_offset, bit_index = self._parse_s7_address(address) + if area_code not in ("D",): + raise ValueError(f"INT32 read expects D/DBD address, got: {address}") + + client = snap7.client.Client() + try: + client.set_connection_type(3) + client.connect(ip, int(rack), int(slot), int(tcp_port)) + if not client.get_connected(): + raise RuntimeError(f"S7 connect failed: {endpoint}") + raw = client.db_read(db_number, byte_offset, 4) + return int.from_bytes(raw, byteorder="big", signed=True) + finally: + try: + client.disconnect() + except Exception: + pass + async def write_s7_value( self, endpoint: str, diff --git a/frontend/src/views/OpcConfig.vue b/frontend/src/views/OpcConfig.vue index 061cc05..1c2624a 100644 --- a/frontend/src/views/OpcConfig.vue +++ b/frontend/src/views/OpcConfig.vue @@ -24,7 +24,7 @@ - 焊接完成信号,0→1保持2秒触发 + 焊接完成信号,0→1且计数器变化触发 @@ -42,16 +42,24 @@ 1=一号开卷机, 2=二号开卷机 + + + 计划写入采用 S7 直写,格式 IP:PORT + + + + + -
一号开卷机字段映射
+
一号开卷机字段地址映射(S7)
{{ field.label }}
-
二号开卷机字段映射
+
二号开卷机字段地址映射(S7)
{{ field.label }}
@@ -109,19 +117,22 @@ export default { signal2_node: '', write_counter_node: '', write_source_node: '', - write_target_node: '' + write_target_node: '', + write_s7_endpoint: '', + write_s7_rack: 0, + write_s7_slot: 1 }, writeFieldDefs: [ - { key: 'setup_data_revision', label: '设置数据修改', placeholder: 'ns=2;s=PL.Setup.DataRevision' }, - { key: 'coilid', label: '钢卷ID', placeholder: 'ns=2;s=PL.Entry1.CoilId' }, - { key: 'entry_coil_weight', label: '入口钢卷重量', placeholder: 'ns=2;s=PL.Entry1.Weight' }, - { key: 'entry_of_coil_length', label: '长度', placeholder: 'ns=2;s=PL.Entry1.Length' }, - { key: 'entry_coil_width', label: '宽度', placeholder: 'ns=2;s=PL.Entry1.Width' }, - { key: 'entry_coil_thickness', label: '厚度', placeholder: 'ns=2;s=PL.Entry1.Thickness' }, - { key: 'entry_of_coil_inner_diameter', label: '钢卷内径', placeholder: 'ns=2;s=PL.Entry1.InnerDiameter' }, - { key: 'entry_of_coil_outer_diameter', label: '钢卷外径', placeholder: 'ns=2;s=PL.Entry1.OuterDiameter' }, - { key: 'alloy_code', label: '合金代码', placeholder: 'ns=2;s=PL.Entry1.AlloyCode' }, - { key: 'material', label: '材质', placeholder: 'ns=2;s=PL.Entry1.Material' } + { key: 'setup_data_revision', label: '设置数据修改', placeholder: 'DB35501.DINT0' }, + { key: 'coilid', label: '钢卷ID', placeholder: 'DB35501.Byte4 (A20)' }, + { key: 'entry_coil_weight', label: '入口钢卷重量', placeholder: 'DB35501.DBD26' }, + { key: 'entry_of_coil_length', label: '长度', placeholder: 'DB35501.DBD30' }, + { key: 'entry_coil_width', label: '宽度', placeholder: 'DB35501.DBD34' }, + { key: 'entry_coil_thickness', label: '厚度', placeholder: 'DB35501.DBD38' }, + { key: 'entry_of_coil_inner_diameter', label: '钢卷内径', placeholder: 'DB35501.DBD42' }, + { key: 'entry_of_coil_outer_diameter', label: '钢卷外径', placeholder: 'DB35501.DBD46' }, + { key: 'alloy_code', label: '合金代码', placeholder: 'DB35501.Byte50 (A4)' }, + { key: 'material', label: '材质', placeholder: 'DB35501.Byte54 (A20)' } ], writeNodes: { '1': {}, '2': {} }, nodeList: [], @@ -148,6 +159,9 @@ export default { this.form.write_counter_node = cfg.write_counter_node || '' this.form.write_source_node = cfg.write_source_node || '' this.form.write_target_node = cfg.write_target_node || '' + this.form.write_s7_endpoint = cfg.write_s7_endpoint || '' + this.form.write_s7_rack = cfg.write_s7_rack ?? 0 + this.form.write_s7_slot = cfg.write_s7_slot ?? 1 this.writeNodes = { '1': { ...(cfg.write_nodes?.['1'] || {}) }, '2': { ...(cfg.write_nodes?.['2'] || {}) }