857 lines
33 KiB
Python
857 lines
33 KiB
Python
"""
|
||
OPC-UA polling service.
|
||
|
||
# 调用示例(OPC-UA 写入)
|
||
# await opc_service.write_node_value("ns=2;s=PL.TEST.COUNT", 123, "Int32")
|
||
#
|
||
# 调用示例(S7 写入)
|
||
# await opc_service.write_s7_value(
|
||
# endpoint="192.168.0.10:102",
|
||
# address="DB1.DBW4",
|
||
# data_length=2,
|
||
# data_type="INT16",
|
||
# value=123,
|
||
# )
|
||
|
||
Logic:
|
||
1. Connect to the OPC-UA server at `opc_url`.
|
||
2. Read the counter node (`counter_node`) every `poll_interval` seconds.
|
||
3. When the counter value changes, read the trackmap nodes listed in
|
||
`trackmap_nodes` (a dict: {oracle_column -> node_id}).
|
||
4. For each row returned by the trackmap nodes build an UPDATE statement
|
||
and apply it to PLTM.CMPT_PL_TRACKMAP.
|
||
5. Monitor two signal nodes:
|
||
- Signal1 (Entry): When 0->1, fetch next 5 coils from PDI and save to SQLite temp table
|
||
- Signal2 (WeldDone): When 0->1 and held for 2s, update CMPT_PL_TRACKMAP
|
||
|
||
`trackmap_nodes` example (stored in .env or configured via UI):
|
||
{
|
||
"COILID": "ns=2;s=PL.TRACKMAP.P01.COILID",
|
||
"BEF_ES": "ns=2;s=PL.TRACKMAP.P01.BEF_ES",
|
||
...
|
||
}
|
||
For multi-position setups, define one entry per position column and handle
|
||
positional logic accordingly. The current implementation reads a flat set of
|
||
nodes and stores them against the POSITION value also read from OPC.
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
from datetime import datetime
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
from dotenv import load_dotenv
|
||
|
||
load_dotenv()
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class OpcService:
|
||
def __init__(self):
|
||
self.config_path = os.path.join(os.path.dirname(__file__), "opc_config.json")
|
||
self.opc_url: str = os.getenv("OPC_URL", "opc.tcp://192.168.1.100:4840")
|
||
self.counter_node: str = os.getenv(
|
||
"OPC_COUNTER_NODE", "ns=2;s=PL.TRACKMAP.COUNTER"
|
||
)
|
||
self.poll_interval: int = int(os.getenv("OPC_POLL_INTERVAL", "2"))
|
||
self.signal1_node: str = os.getenv("OPC_SIGNAL1_NODE", "ns=2;s=PL.Signal.EntryCoil")
|
||
self.signal2_node: str = os.getenv("OPC_SIGNAL2_NODE", "ns=2;s=PL.Signal.WeldDone")
|
||
self.signal1_last: Optional[int] = None
|
||
self.signal2_last: Optional[int] = None
|
||
self.signal2_rise_time: Optional[datetime] = None
|
||
self.signal1_coils: List[Dict[str, Any]] = []
|
||
self.current_seq_start: int = 1
|
||
# 状态机: WAIT_S1=等待信号1, WAIT_S2=等待信号2
|
||
self.track_state: str = "WAIT_S1"
|
||
self.last_counter_at_state_change: Optional[Any] = None
|
||
# Mapping: oracle_column_name -> OPC node id
|
||
# Populated from .env or via API
|
||
self.trackmap_nodes: Dict[str, str] = self._load_trackmap_nodes()
|
||
# Load persisted config if present
|
||
self._load_persisted_config()
|
||
self.running: bool = False
|
||
self.last_counter: Optional[Any] = None
|
||
self.last_update: Optional[str] = None
|
||
self.event_log: List[str] = []
|
||
self._stop_event = asyncio.Event()
|
||
self._task: Optional[asyncio.Task] = None
|
||
|
||
# ------------------------------------------------------------------
|
||
def _load_trackmap_nodes(self) -> Dict[str, str]:
|
||
"""Load trackmap node mapping from env vars prefixed OPC_NODE_."""
|
||
nodes = {}
|
||
for key, val in os.environ.items():
|
||
if key.startswith("OPC_NODE_"):
|
||
col = key[len("OPC_NODE_"):].lower()
|
||
nodes[col] = val
|
||
return nodes
|
||
|
||
def _load_persisted_config(self):
|
||
"""Load OPC config from local json file if exists."""
|
||
if not os.path.exists(self.config_path):
|
||
return
|
||
try:
|
||
with open(self.config_path, "r", encoding="utf-8") as f:
|
||
cfg = json.load(f)
|
||
self.opc_url = cfg.get("opc_url", self.opc_url)
|
||
self.counter_node = cfg.get("counter_node", self.counter_node)
|
||
self.poll_interval = int(cfg.get("poll_interval", self.poll_interval))
|
||
self.trackmap_nodes = cfg.get("trackmap_nodes", self.trackmap_nodes) or {}
|
||
self.signal1_node = cfg.get("signal1_node", self.signal1_node)
|
||
self.signal2_node = cfg.get("signal2_node", self.signal2_node)
|
||
self._log(f"Loaded OPC config from {self.config_path}")
|
||
except Exception as exc:
|
||
logger.warning("Failed to load OPC config %s: %s", self.config_path, exc)
|
||
|
||
def save_config(self):
|
||
"""Persist current OPC config to local json file."""
|
||
cfg = {
|
||
"opc_url": self.opc_url,
|
||
"counter_node": self.counter_node,
|
||
"poll_interval": self.poll_interval,
|
||
"trackmap_nodes": self.trackmap_nodes,
|
||
"signal1_node": self.signal1_node,
|
||
"signal2_node": self.signal2_node,
|
||
}
|
||
os.makedirs(os.path.dirname(self.config_path), exist_ok=True)
|
||
with open(self.config_path, "w", encoding="utf-8") as f:
|
||
json.dump(cfg, f, ensure_ascii=False, indent=2)
|
||
|
||
def _log(self, msg: str):
|
||
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
entry = f"[{ts}] {msg}"
|
||
logger.info(entry)
|
||
self.event_log.append(entry)
|
||
if len(self.event_log) > 500:
|
||
self.event_log = self.event_log[-500:]
|
||
|
||
# ------------------------------------------------------------------
|
||
async def start_polling(self):
|
||
if self.running:
|
||
return
|
||
self._stop_event.clear()
|
||
self._task = asyncio.create_task(self._poll_loop())
|
||
|
||
async def stop_polling(self):
|
||
self._stop_event.set()
|
||
if self._task:
|
||
self._task.cancel()
|
||
try:
|
||
await self._task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
self.running = False
|
||
|
||
# ------------------------------------------------------------------
|
||
async def _poll_loop(self):
|
||
self.running = True
|
||
self._log(f"OPC polling started: {self.opc_url}")
|
||
try:
|
||
from opcua import Client # type: ignore
|
||
except ImportError:
|
||
self._log("opcua package not installed – running in SIMULATION mode")
|
||
await self._simulate_loop()
|
||
return
|
||
|
||
while not self._stop_event.is_set():
|
||
try:
|
||
client = Client(self.opc_url)
|
||
client.connect()
|
||
self._log(f"Connected to OPC server: {self.opc_url}")
|
||
try:
|
||
while not self._stop_event.is_set():
|
||
current_counter = await self._tick_with_counter(client)
|
||
await self._check_signals(client, current_counter)
|
||
await asyncio.sleep(self.poll_interval)
|
||
finally:
|
||
client.disconnect()
|
||
self._log("Disconnected from OPC server")
|
||
except Exception as exc:
|
||
self._log(f"OPC connection error: {exc}. Retrying in 5s...")
|
||
await asyncio.sleep(5)
|
||
|
||
self.running = False
|
||
self._log("OPC polling stopped")
|
||
|
||
# ------------------------------------------------------------------
|
||
async def _tick_with_counter(self, client):
|
||
"""Read counter and update trackmap; return current_counter."""
|
||
try:
|
||
counter_node = client.get_node(self.counter_node)
|
||
current_counter = counter_node.get_value()
|
||
except Exception as exc:
|
||
self._log(f"Failed to read counter node: {exc}")
|
||
return None
|
||
|
||
if current_counter == self.last_counter:
|
||
return current_counter
|
||
|
||
self._log(
|
||
f"Counter changed: {self.last_counter} -> {current_counter}. "
|
||
"Fetching trackmap data..."
|
||
)
|
||
self.last_counter = current_counter
|
||
self.last_update = datetime.now().isoformat()
|
||
|
||
if not self.trackmap_nodes:
|
||
self._log("No trackmap nodes configured – skipping DB update")
|
||
return current_counter
|
||
|
||
# Read all configured nodes
|
||
data: Dict[str, Any] = {}
|
||
for col, node_id in self.trackmap_nodes.items():
|
||
try:
|
||
node = client.get_node(node_id)
|
||
data[col] = node.get_value()
|
||
except Exception as exc:
|
||
self._log(f"Failed to read node {node_id}: {exc}")
|
||
|
||
if not data:
|
||
return current_counter
|
||
|
||
# Determine POSITION from data (must be one of the mapped columns)
|
||
position = data.get("position")
|
||
if position is None:
|
||
self._log("'position' not in trackmap_nodes data – cannot update row")
|
||
return current_counter
|
||
|
||
await self._update_oracle(position, data)
|
||
|
||
return current_counter
|
||
|
||
# ------------------------------------------------------------------
|
||
async def _check_signals(self, client, current_counter):
|
||
"""Check signal1 and signal2 with state machine and counter validation."""
|
||
try:
|
||
signal1_node = client.get_node(self.signal1_node)
|
||
signal1_value = signal1_node.get_value()
|
||
except Exception as exc:
|
||
self._log(f"Failed to read signal1 node: {exc}")
|
||
signal1_value = None
|
||
|
||
try:
|
||
signal2_node = client.get_node(self.signal2_node)
|
||
signal2_value = signal2_node.get_value()
|
||
except Exception as exc:
|
||
self._log(f"Failed to read signal2 node: {exc}")
|
||
signal2_value = None
|
||
|
||
counter_changed = current_counter != self.last_counter_at_state_change
|
||
|
||
# State machine for signal processing
|
||
if self.track_state == "WAIT_S1":
|
||
if signal1_value is not None and self.signal1_last is not None:
|
||
if self.signal1_last == 0 and signal1_value == 1 and counter_changed:
|
||
self._log(f"Signal1: Entry coil triggered (0->1) with counter change, state={self.track_state}")
|
||
await self._handle_signal1()
|
||
self.track_state = "WAIT_S2"
|
||
self.last_counter_at_state_change = current_counter
|
||
|
||
elif self.track_state == "WAIT_S2":
|
||
if signal2_value is not None and self.signal2_last is not None:
|
||
if self.signal2_last == 0 and signal2_value == 1 and counter_changed:
|
||
self.signal2_rise_time = datetime.now()
|
||
self._log("Signal2: Weld done rising edge (0->1) with counter change")
|
||
elif self.signal2_last == 1 and signal2_value == 0:
|
||
self.signal2_rise_time = None
|
||
|
||
if signal2_value == 1 and self.signal2_rise_time and counter_changed:
|
||
elapsed = (datetime.now() - self.signal2_rise_time).total_seconds()
|
||
if elapsed >= 2.0:
|
||
self._log(f"Signal2: Held {elapsed:.1f}s, triggering trackmap update")
|
||
await self._handle_signal2()
|
||
self.track_state = "WAIT_S1"
|
||
self.signal2_rise_time = None
|
||
self.last_counter_at_state_change = current_counter
|
||
|
||
if signal1_value is not None:
|
||
self.signal1_last = signal1_value
|
||
if signal2_value is not None:
|
||
self.signal2_last = signal2_value
|
||
|
||
async def _handle_signal1(self):
|
||
"""Handle signal1: fetch next 5 coils from PDI and save to SQLite temp table."""
|
||
from sqlite_sync import (
|
||
sqlite_get_max_sequencenb,
|
||
sqlite_get_coils_by_sequencenb_range,
|
||
sqlite_save_coils_to_track
|
||
)
|
||
|
||
def _do_fetch():
|
||
max_seq = sqlite_get_max_sequencenb()
|
||
if max_seq is None or max_seq < 1:
|
||
self._log("Signal1: No PDI data available")
|
||
return
|
||
|
||
start_seq = self.current_seq_start
|
||
end_seq = min(start_seq + 4, max_seq)
|
||
|
||
if end_seq < start_seq:
|
||
self._log(f"Signal1: Insufficient PDI data (max_seq={max_seq}, start={start_seq})")
|
||
return
|
||
|
||
coils = sqlite_get_coils_by_sequencenb_range(start_seq, end_seq)
|
||
if len(coils) == 0:
|
||
self._log(f"Signal1: No coils found")
|
||
return
|
||
|
||
self.signal1_coils = coils
|
||
sqlite_save_coils_to_track(coils)
|
||
|
||
self._log(f"Signal1: Saved {len(coils)} coils (seq {start_seq}-{end_seq}) to temp table")
|
||
|
||
self.current_seq_start = start_seq + 1
|
||
|
||
loop = asyncio.get_event_loop()
|
||
await loop.run_in_executor(None, _do_fetch)
|
||
|
||
async def _handle_signal2(self):
|
||
"""Handle signal2: update CMPT_PL_TRACKMAP with temp table data."""
|
||
from sqlite_sync import sqlite_get_coil_track
|
||
from database import get_connection
|
||
|
||
def _do_update():
|
||
coils = sqlite_get_coil_track()
|
||
if not coils:
|
||
self._log("Signal2: No coils in temp track table")
|
||
return
|
||
|
||
try:
|
||
conn = get_connection()
|
||
cursor = conn.cursor()
|
||
try:
|
||
coil_count = len(coils)
|
||
for i in range(5):
|
||
target_position = i + 1
|
||
coil_index = target_position - 1
|
||
if coil_index >= 0 and coil_index < coil_count:
|
||
coil = coils[coil_index]
|
||
cursor.execute("SELECT COUNT(*) FROM PLTM.CMPT_PL_TRACKMAP WHERE POSITION = :pos", {"pos": target_position})
|
||
exists = cursor.fetchone()[0] > 0
|
||
if exists:
|
||
cursor.execute("""
|
||
UPDATE PLTM.CMPT_PL_TRACKMAP
|
||
SET COILID = :coilid, TOM = SYSDATE
|
||
WHERE POSITION = :position
|
||
""", {"coilid": coil["coilid"], "position": target_position})
|
||
else:
|
||
cursor.execute("""
|
||
INSERT INTO PLTM.CMPT_PL_TRACKMAP (POSITION, COILID, TOM)
|
||
VALUES (:position, :coilid, SYSDATE)
|
||
""", {"position": target_position, "coilid": coil["coilid"]})
|
||
else:
|
||
cursor.execute("SELECT COUNT(*) FROM PLTM.CMPT_PL_TRACKMAP WHERE POSITION = :pos", {"pos": target_position})
|
||
exists = cursor.fetchone()[0] > 0
|
||
if exists:
|
||
cursor.execute("""
|
||
UPDATE PLTM.CMPT_PL_TRACKMAP
|
||
SET COILID = NULL, TOM = SYSDATE
|
||
WHERE POSITION = :position
|
||
""", {"position": target_position})
|
||
else:
|
||
cursor.execute("""
|
||
INSERT INTO PLTM.CMPT_PL_TRACKMAP (POSITION, COILID, TOM)
|
||
VALUES (:position, NULL, SYSDATE)
|
||
""", {"position": target_position})
|
||
conn.commit()
|
||
self._log(f"Signal2: Updated 5 positions (coils: {coil_count})")
|
||
finally:
|
||
cursor.close()
|
||
conn.close()
|
||
except Exception as exc:
|
||
self._log(f"Signal2: Oracle update failed: {exc}")
|
||
import traceback
|
||
self._log(f"Signal2 traceback: {traceback.format_exc()}")
|
||
|
||
loop = asyncio.get_event_loop()
|
||
await loop.run_in_executor(None, _do_update)
|
||
|
||
# ------------------------------------------------------------------
|
||
async def _update_oracle(self, position: Any, data: Dict[str, Any]):
|
||
"""Write fetched OPC values into PLTM.CMPT_PL_TRACKMAP."""
|
||
import threading
|
||
|
||
def _do_update():
|
||
try:
|
||
from database import get_connection
|
||
|
||
conn = get_connection()
|
||
cursor = conn.cursor()
|
||
try:
|
||
updatable = {k: v for k, v in data.items() if k != "position"}
|
||
if not updatable:
|
||
return
|
||
set_clause = ", ".join(
|
||
[f"{k.upper()} = :{k}" for k in updatable.keys()]
|
||
)
|
||
updatable["position_"] = position
|
||
sql = (
|
||
f"UPDATE PLTM.CMPT_PL_TRACKMAP "
|
||
f"SET {set_clause} WHERE POSITION = :position_"
|
||
)
|
||
cursor.execute(sql, updatable)
|
||
conn.commit()
|
||
self._log(
|
||
f"Updated CMPT_PL_TRACKMAP POSITION={position}: "
|
||
+ ", ".join(f"{k}={v}" for k, v in updatable.items() if k != "position_")
|
||
)
|
||
finally:
|
||
cursor.close()
|
||
conn.close()
|
||
except Exception as exc:
|
||
self._log(f"Oracle update failed: {exc}")
|
||
|
||
loop = asyncio.get_event_loop()
|
||
await loop.run_in_executor(None, _do_update)
|
||
|
||
# ------------------------------------------------------------------
|
||
def _normalize_variant_type(self, variant_type: Optional[str]):
|
||
"""Convert a variant type string to opcua.ua.VariantType."""
|
||
if not variant_type:
|
||
return None
|
||
from opcua import ua # type: ignore
|
||
|
||
vt_name = str(variant_type).strip()
|
||
if not vt_name:
|
||
return None
|
||
|
||
try:
|
||
return getattr(ua.VariantType, vt_name)
|
||
except AttributeError:
|
||
raise ValueError(
|
||
f"Unsupported OPC VariantType: {variant_type}. "
|
||
"Example: Boolean/Int16/Int32/Float/Double/String"
|
||
)
|
||
|
||
async def write_node_value(self, node_id: str, value: Any, variant_type: Optional[str] = None):
|
||
"""Connect to OPC server and write value to the specified node."""
|
||
if not node_id:
|
||
raise ValueError("node_id is required")
|
||
|
||
try:
|
||
from opcua import Client, ua # type: ignore
|
||
except ImportError as exc:
|
||
raise RuntimeError("opcua package not installed") from exc
|
||
|
||
def _do_write():
|
||
client = Client(self.opc_url)
|
||
try:
|
||
client.connect()
|
||
node = client.get_node(node_id)
|
||
|
||
vt = self._normalize_variant_type(variant_type)
|
||
if vt is None:
|
||
node.set_value(value)
|
||
else:
|
||
node.set_value(ua.DataValue(ua.Variant(value, vt)))
|
||
|
||
self._log(
|
||
f"Wrote OPC value success: node={node_id}, value={value}, "
|
||
f"variant_type={variant_type or 'auto'}"
|
||
)
|
||
return {
|
||
"ok": True,
|
||
"node_id": node_id,
|
||
"value": value,
|
||
"variant_type": variant_type or "auto",
|
||
"opc_url": self.opc_url,
|
||
"timestamp": datetime.now().isoformat(),
|
||
}
|
||
except Exception as exc:
|
||
self._log(f"Write OPC value failed: node={node_id}, error={exc}")
|
||
raise
|
||
finally:
|
||
try:
|
||
client.disconnect()
|
||
except Exception:
|
||
pass
|
||
|
||
loop = asyncio.get_event_loop()
|
||
return await loop.run_in_executor(None, _do_write)
|
||
|
||
def _parse_s7_address(self, address: str) -> Tuple[int, int]:
|
||
"""Parse S7 DB address: DB{db}.{type}{byte}[.{bit}] -> (db_number, start_byte)."""
|
||
if not address:
|
||
raise ValueError("address is required")
|
||
|
||
# Supported examples:
|
||
# DB1.DBX0.0 / DB1.DBB2 / DB1.DBW4 / DB1.DBD8
|
||
# DB1.X0.0 / DB1.B2 / DB1.W4 / DB1.D8
|
||
pattern = r"^DB(\d+)\.(?:DB)?(?:X|B|W|D)(\d+)(?:\.\d+)?$"
|
||
m = re.match(pattern, address.strip(), re.IGNORECASE)
|
||
if not m:
|
||
raise ValueError(
|
||
"Unsupported S7 address format. "
|
||
"Examples: DB1.DBX0.0, DB1.DBB2, DB1.DBW4, DB1.DBD8"
|
||
)
|
||
|
||
db_number = int(m.group(1))
|
||
start_byte = int(m.group(2))
|
||
return db_number, start_byte
|
||
|
||
def _encode_s7_value(self, value: Any, data_type: str, data_length: int) -> bytes:
|
||
"""Encode python value to bytes for snap7 DB write."""
|
||
import struct
|
||
|
||
dt = (data_type or "").strip().upper()
|
||
if not dt:
|
||
raise ValueError("data_type is required")
|
||
|
||
if dt in ("BOOL", "BOOLEAN"):
|
||
if data_length != 1:
|
||
raise ValueError("BOOL data_length must be 1")
|
||
return bytes([1 if bool(value) else 0])
|
||
|
||
if dt in ("BYTE", "UINT8", "INT8"):
|
||
if data_length != 1:
|
||
raise ValueError(f"{dt} data_length must be 1")
|
||
iv = int(value)
|
||
if dt == "INT8":
|
||
return struct.pack(">b", iv)
|
||
return struct.pack(">B", iv)
|
||
|
||
if dt in ("INT16", "WORD", "UINT16"):
|
||
if data_length != 2:
|
||
raise ValueError(f"{dt} data_length must be 2")
|
||
iv = int(value)
|
||
if dt == "INT16":
|
||
return struct.pack(">h", iv)
|
||
return struct.pack(">H", iv)
|
||
|
||
if dt in ("INT32", "DINT", "UINT32"):
|
||
if data_length != 4:
|
||
raise ValueError(f"{dt} data_length must be 4")
|
||
iv = int(value)
|
||
if dt == "INT32" or dt == "DINT":
|
||
return struct.pack(">i", iv)
|
||
return struct.pack(">I", iv)
|
||
|
||
if dt in ("REAL", "FLOAT"):
|
||
if data_length != 4:
|
||
raise ValueError(f"{dt} data_length must be 4")
|
||
return struct.pack(">f", float(value))
|
||
|
||
if dt in ("LREAL", "DOUBLE"):
|
||
if data_length != 8:
|
||
raise ValueError(f"{dt} data_length must be 8")
|
||
return struct.pack(">d", float(value))
|
||
|
||
if dt in ("STRING", "BYTES"):
|
||
if data_length < 1:
|
||
raise ValueError(f"{dt} data_length must be >= 1")
|
||
raw = value.encode("utf-8") if isinstance(value, str) else bytes(value)
|
||
if len(raw) > data_length:
|
||
raise ValueError(
|
||
f"value bytes length {len(raw)} exceeds data_length {data_length}"
|
||
)
|
||
return raw.ljust(data_length, b"\x00")
|
||
|
||
raise ValueError(
|
||
"Unsupported S7 data_type. "
|
||
"Example: BOOL/BYTE/INT16/UINT16/INT32/UINT32/REAL/LREAL/STRING"
|
||
)
|
||
|
||
async def write_s7_value(
|
||
self,
|
||
ip: str,
|
||
address: str,
|
||
data_length: int,
|
||
data_type: str,
|
||
value: Any,
|
||
rack: int = 0,
|
||
slot: int = 1,
|
||
tcp_port: int = 102,
|
||
):
|
||
"""Connect via S7 protocol and write value to a DB address."""
|
||
if not ip:
|
||
raise ValueError("ip is required")
|
||
if not address:
|
||
raise ValueError("address is required")
|
||
if not data_type:
|
||
raise ValueError("data_type is required")
|
||
if not isinstance(data_length, int) or data_length <= 0:
|
||
raise ValueError("data_length must be a positive integer")
|
||
|
||
try:
|
||
import snap7 # type: ignore
|
||
except ImportError as exc:
|
||
raise RuntimeError("python-snap7 package not installed") from exc
|
||
|
||
db_number, start_byte = self._parse_s7_address(address)
|
||
payload = self._encode_s7_value(value, data_type, data_length)
|
||
|
||
def _do_write_s7():
|
||
client = snap7.client.Client()
|
||
try:
|
||
client.set_connection_params(ip, 0x0100, 0x0100)
|
||
client.set_connection_type(3)
|
||
client.connect(ip, rack, slot, tcp_port)
|
||
|
||
if not client.get_connected():
|
||
raise RuntimeError("S7 connect failed")
|
||
|
||
client.db_write(db_number, start_byte, payload)
|
||
|
||
self._log(
|
||
"Wrote S7 value success: "
|
||
f"ip={ip}, address={address}, data_type={data_type}, "
|
||
f"data_length={data_length}, value={value}"
|
||
)
|
||
return {
|
||
"ok": True,
|
||
"ip": ip,
|
||
"address": address,
|
||
"data_type": data_type,
|
||
"data_length": data_length,
|
||
"value": value,
|
||
"rack": rack,
|
||
"slot": slot,
|
||
"tcp_port": tcp_port,
|
||
"timestamp": datetime.now().isoformat(),
|
||
}
|
||
except Exception as exc:
|
||
self._log(f"Write S7 value failed: ip={ip}, address={address}, error={exc}")
|
||
raise
|
||
finally:
|
||
try:
|
||
client.disconnect()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
client.destroy()
|
||
except Exception:
|
||
pass
|
||
|
||
loop = asyncio.get_event_loop()
|
||
return await loop.run_in_executor(None, _do_write_s7)
|
||
|
||
# ------------------------------------------------------------------
|
||
def _parse_s7_endpoint(self, endpoint: str):
|
||
"""Parse S7 endpoint in format 'ip:port'."""
|
||
if not endpoint:
|
||
raise ValueError("endpoint is required, format: ip:port")
|
||
|
||
raw = str(endpoint).strip()
|
||
if ":" not in raw:
|
||
raise ValueError("endpoint format invalid, expected ip:port")
|
||
|
||
ip, port_str = raw.rsplit(":", 1)
|
||
ip = ip.strip()
|
||
port_str = port_str.strip()
|
||
|
||
if not ip:
|
||
raise ValueError("endpoint ip is empty")
|
||
if not port_str.isdigit():
|
||
raise ValueError("endpoint port must be numeric")
|
||
|
||
port = int(port_str)
|
||
if port < 1 or port > 65535:
|
||
raise ValueError("endpoint port out of range: 1-65535")
|
||
|
||
return ip, port
|
||
|
||
def _parse_s7_address(self, address: str):
|
||
"""
|
||
Parse S7 DB address.
|
||
|
||
Supported examples:
|
||
- DB1.DBX0.0
|
||
- DB1.DBB2
|
||
- DB1.DBW4
|
||
- DB1.DBD8
|
||
- DB1.X0.0 / DB1.B2 / DB1.W4 / DB1.D8
|
||
"""
|
||
if not address:
|
||
raise ValueError("address is required")
|
||
|
||
raw = str(address).strip().upper()
|
||
if "." not in raw or not raw.startswith("DB"):
|
||
raise ValueError(f"Unsupported S7 address format: {address}")
|
||
|
||
db_part, area_part = raw.split(".", 1)
|
||
try:
|
||
db_number = int(db_part[2:])
|
||
except ValueError as exc:
|
||
raise ValueError(f"Invalid DB number in address: {address}") from exc
|
||
|
||
if area_part.startswith("DB"):
|
||
area_part = area_part[2:]
|
||
|
||
area_code = area_part[:1]
|
||
offset_part = area_part[1:]
|
||
|
||
bit_index = None
|
||
if area_code == "X":
|
||
if "." not in offset_part:
|
||
raise ValueError(f"BOOL address requires bit index, e.g. DB1.DBX0.0: {address}")
|
||
byte_str, bit_str = offset_part.split(".", 1)
|
||
byte_offset = int(byte_str)
|
||
bit_index = int(bit_str)
|
||
if bit_index < 0 or bit_index > 7:
|
||
raise ValueError("bit index out of range: 0-7")
|
||
else:
|
||
if "." in offset_part:
|
||
raise ValueError(f"Only X area may include bit index: {address}")
|
||
byte_offset = int(offset_part)
|
||
|
||
if byte_offset < 0:
|
||
raise ValueError("byte offset must be >= 0")
|
||
|
||
return db_number, area_code, byte_offset, bit_index
|
||
|
||
def _encode_s7_value(self, value: Any, data_type: str, data_length: int) -> bytes:
|
||
"""Encode python value into S7 byte payload."""
|
||
import struct
|
||
|
||
if data_length <= 0:
|
||
raise ValueError("data_length must be > 0")
|
||
|
||
dt = str(data_type or "").strip().upper()
|
||
if not dt:
|
||
raise ValueError("data_type is required")
|
||
|
||
if dt == "BOOL":
|
||
if data_length != 1:
|
||
raise ValueError("BOOL data_length must be 1")
|
||
return b"\x01" if bool(value) else b"\x00"
|
||
|
||
if dt in ("BYTE", "INT8"):
|
||
if data_length != 1:
|
||
raise ValueError(f"{dt} data_length must be 1")
|
||
return int(value).to_bytes(1, byteorder="big", signed=(dt == "INT8"))
|
||
|
||
if dt in ("UINT16", "WORD"):
|
||
if data_length != 2:
|
||
raise ValueError(f"{dt} data_length must be 2")
|
||
return int(value).to_bytes(2, byteorder="big", signed=False)
|
||
|
||
if dt == "INT16":
|
||
if data_length != 2:
|
||
raise ValueError("INT16 data_length must be 2")
|
||
return int(value).to_bytes(2, byteorder="big", signed=True)
|
||
|
||
if dt in ("UINT32", "DWORD"):
|
||
if data_length != 4:
|
||
raise ValueError(f"{dt} data_length must be 4")
|
||
return int(value).to_bytes(4, byteorder="big", signed=False)
|
||
|
||
if dt in ("INT32", "DINT"):
|
||
if data_length != 4:
|
||
raise ValueError(f"{dt} data_length must be 4")
|
||
return int(value).to_bytes(4, byteorder="big", signed=True)
|
||
|
||
if dt in ("REAL", "FLOAT"):
|
||
if data_length != 4:
|
||
raise ValueError(f"{dt} data_length must be 4")
|
||
return struct.pack(">f", float(value))
|
||
|
||
if dt in ("LREAL", "DOUBLE"):
|
||
if data_length != 8:
|
||
raise ValueError(f"{dt} data_length must be 8")
|
||
return struct.pack(">d", float(value))
|
||
|
||
if dt in ("STRING", "BYTES"):
|
||
if isinstance(value, bytes):
|
||
raw = value
|
||
else:
|
||
raw = str(value).encode("utf-8")
|
||
if len(raw) > data_length:
|
||
raise ValueError(f"value bytes length {len(raw)} exceeds data_length {data_length}")
|
||
return raw.ljust(data_length, b"\x00")
|
||
|
||
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"
|
||
)
|
||
|
||
async def write_s7_value(
|
||
self,
|
||
endpoint: str,
|
||
address: str,
|
||
data_length: int,
|
||
data_type: str,
|
||
value: Any,
|
||
rack: int = 0,
|
||
slot: int = 1,
|
||
):
|
||
"""Connect via S7 and write value using endpoint(ip:port), address, length, and type."""
|
||
try:
|
||
import snap7 # type: ignore
|
||
from snap7.type import Areas # 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)
|
||
|
||
payload = self._encode_s7_value(value, data_type, int(data_length))
|
||
|
||
def _do_write():
|
||
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}")
|
||
|
||
if area_code == "X":
|
||
current = client.db_read(db_number, byte_offset, 1)
|
||
mask = 1 << int(bit_index)
|
||
if bool(value):
|
||
current[0] = current[0] | mask
|
||
else:
|
||
current[0] = current[0] & (0xFF ^ mask)
|
||
client.db_write(db_number, byte_offset, current)
|
||
else:
|
||
client.db_write(db_number, byte_offset, payload)
|
||
|
||
self._log(
|
||
f"Wrote S7 value success: endpoint={endpoint}, address={address}, "
|
||
f"value={value}, data_type={data_type}, data_length={data_length}"
|
||
)
|
||
|
||
return {
|
||
"ok": True,
|
||
"endpoint": endpoint,
|
||
"ip": ip,
|
||
"port": tcp_port,
|
||
"address": address,
|
||
"data_type": data_type,
|
||
"data_length": data_length,
|
||
"value": value,
|
||
"timestamp": datetime.now().isoformat(),
|
||
}
|
||
except Exception as exc:
|
||
self._log(
|
||
f"Write S7 value failed: endpoint={endpoint}, address={address}, error={exc}"
|
||
)
|
||
raise
|
||
finally:
|
||
try:
|
||
client.disconnect()
|
||
except Exception:
|
||
pass
|
||
|
||
loop = asyncio.get_event_loop()
|
||
return await loop.run_in_executor(None, _do_write)
|
||
|
||
# ------------------------------------------------------------------
|
||
async def _simulate_loop(self):
|
||
"""Simulation mode when opcua is not installed."""
|
||
counter = 0
|
||
while not self._stop_event.is_set():
|
||
await asyncio.sleep(self.poll_interval)
|
||
counter += 1
|
||
self.last_counter = counter
|
||
self.last_update = datetime.now().isoformat()
|
||
self._log(f"[SIM] Counter tick: {counter}")
|
||
self.running = False
|
||
|
||
|
||
opc_service = OpcService()
|