pymscada 0.2.0__py3-none-any.whl → 0.2.6b9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pymscada/__init__.py +8 -2
- pymscada/alarms.py +179 -60
- pymscada/bus_client.py +12 -2
- pymscada/bus_server.py +18 -9
- pymscada/callout.py +198 -101
- pymscada/config.py +20 -1
- pymscada/console.py +19 -6
- pymscada/demo/__pycache__/__init__.cpython-311.pyc +0 -0
- pymscada/demo/callout.yaml +13 -4
- pymscada/demo/files.yaml +3 -2
- pymscada/demo/openweather.yaml +3 -11
- pymscada/demo/piapi.yaml +15 -0
- pymscada/demo/pymscada-io-piapi.service +15 -0
- pymscada/demo/pymscada-io-sms.service +18 -0
- pymscada/demo/sms.yaml +11 -0
- pymscada/demo/tags.yaml +3 -0
- pymscada/demo/witsapi.yaml +6 -8
- pymscada/demo/wwwserver.yaml +15 -0
- pymscada/files.py +1 -0
- pymscada/history.py +4 -5
- pymscada/iodrivers/logix_map.py +1 -1
- pymscada/iodrivers/modbus_client.py +189 -21
- pymscada/iodrivers/modbus_map.py +17 -2
- pymscada/iodrivers/piapi.py +133 -0
- pymscada/iodrivers/sms.py +212 -0
- pymscada/iodrivers/witsapi.py +26 -35
- pymscada/module_config.py +24 -18
- pymscada/opnotes.py +38 -16
- pymscada/pdf/__pycache__/__init__.cpython-311.pyc +0 -0
- pymscada/tag.py +6 -7
- pymscada/tools/get_history.py +147 -0
- pymscada/www_server.py +2 -1
- {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/METADATA +2 -2
- {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/RECORD +38 -32
- pymscada/validate.py +0 -451
- {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/WHEEL +0 -0
- {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/entry_points.txt +0 -0
- {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/licenses/LICENSE +0 -0
- {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/top_level.txt +0 -0
pymscada/history.py
CHANGED
|
@@ -270,17 +270,16 @@ class History():
|
|
|
270
270
|
self.tags[tagname] = Tag(tagname, tag['type'])
|
|
271
271
|
self.tags[tagname].add_callback(self.hist_tags[tagname].callback)
|
|
272
272
|
self.rta = Tag(rta_tag, bytes)
|
|
273
|
-
self.rta.value = b'\x00\x00\x00\x00\x00\x00'
|
|
273
|
+
self.rta.value = b'\x00\x00\x00\x00\x00\x00' # rta_id is 0
|
|
274
274
|
self.busclient.add_callback_rta(rta_tag, self.rta_cb)
|
|
275
|
+
self.busclient.add_tag(self.rta)
|
|
275
276
|
|
|
276
277
|
def rta_cb(self, request: Request):
|
|
277
278
|
"""Respond to bus requests for data to publish on rta."""
|
|
278
279
|
if 'start_ms' in request:
|
|
279
280
|
request['start_us'] = request['start_ms'] * 1000
|
|
280
281
|
request['end_us'] = request['end_ms'] * 1000
|
|
281
|
-
rta_id =
|
|
282
|
-
if '__rta_id__' in request:
|
|
283
|
-
rta_id = request['__rta_id__']
|
|
282
|
+
rta_id = request['__rta_id__']
|
|
284
283
|
tagname = request['tagname']
|
|
285
284
|
start_time = time.asctime(time.localtime(
|
|
286
285
|
request['start_us'] / 1000000))
|
|
@@ -299,7 +298,7 @@ class History():
|
|
|
299
298
|
packtype = 2
|
|
300
299
|
self.rta.value = pack('>HHH', rta_id, tagid, packtype) + data
|
|
301
300
|
logging.info(f'sent {len(data)} bytes for {request["tagname"]}')
|
|
302
|
-
self.rta.value = b'\x00\x00\x00\x00\x00\x00'
|
|
301
|
+
self.rta.value = b'\x00\x00\x00\x00\x00\x00' # rta_id is 0
|
|
303
302
|
except Exception as e:
|
|
304
303
|
logging.error(f'history rta_cb {e}')
|
|
305
304
|
|
pymscada/iodrivers/logix_map.py
CHANGED
|
@@ -140,5 +140,5 @@ class LogixMaps:
|
|
|
140
140
|
elm = int(poll.tag[arr_start_loc + 1: -1])
|
|
141
141
|
for map in self.read_var_map[plcname][var]:
|
|
142
142
|
elm_offset = map.read_elm - elm
|
|
143
|
-
if elm_offset
|
|
143
|
+
if elm_offset >= 0 and elm_offset < len(poll.value):
|
|
144
144
|
map.set_tag_value(poll.value[elm_offset], time_us)
|
|
@@ -2,18 +2,186 @@
|
|
|
2
2
|
import asyncio
|
|
3
3
|
import logging
|
|
4
4
|
from struct import pack, unpack_from
|
|
5
|
+
from time import time
|
|
5
6
|
from pymscada.bus_client import BusClient
|
|
6
|
-
from pymscada.
|
|
7
|
+
from pymscada.tag import Tag
|
|
7
8
|
from pymscada.periodic import Periodic
|
|
8
9
|
|
|
9
10
|
|
|
11
|
+
# Modbus
|
|
12
|
+
#
|
|
13
|
+
# Transaction ID 2 bytes incrementing
|
|
14
|
+
# Protocol 2 bytes always 0
|
|
15
|
+
# Length 2 bytes number of following bytes
|
|
16
|
+
# Unit address 1 byte PLC address
|
|
17
|
+
# Message N bytes max size, 253 bytes
|
|
18
|
+
# max overall 260 bytes
|
|
19
|
+
# Function code 1 byte 3 Read registers
|
|
20
|
+
# First address 2 bytes 0 == 40001
|
|
21
|
+
# Register count 2 bytes 125 is the largest
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# data types for PLCs
|
|
25
|
+
DTYPES = {
|
|
26
|
+
'int16': [int, -32768, 32767, 1],
|
|
27
|
+
'int32': [int, -2147483648, 2147483647, 2],
|
|
28
|
+
'int64': [int, -2**63, 2**63 - 1, 4],
|
|
29
|
+
'uint16': [int, 0, 65535, 1],
|
|
30
|
+
'uint32': [int, 0, 4294967295, 2],
|
|
31
|
+
'uint64': [int, 0, 2**64 - 1, 4],
|
|
32
|
+
'float32': [float, None, None, 2],
|
|
33
|
+
'float64': [float, None, None, 4],
|
|
34
|
+
'bool': [int, 0, 1, 1]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def tag_split(modbus_tag: str):
|
|
39
|
+
"""Split the address into rtu, variable, element and bit."""
|
|
40
|
+
name, unit, file, word = modbus_tag.split(':')
|
|
41
|
+
bit_loc = word.find('.')
|
|
42
|
+
if bit_loc == -1:
|
|
43
|
+
bit = None
|
|
44
|
+
word = word
|
|
45
|
+
else:
|
|
46
|
+
bit = word[bit_loc + 1:]
|
|
47
|
+
word = word[:bit_loc]
|
|
48
|
+
return name, unit, file, word, bit
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ModbusClientMap:
|
|
52
|
+
"""Map the data table to a Tag."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, tagname: str, src_type: str, addr: str, data: dict,
|
|
55
|
+
value_chg: dict):
|
|
56
|
+
"""Initialise modbus map and Tag."""
|
|
57
|
+
name, unit, file, word, bit = tag_split(addr)
|
|
58
|
+
self.data_file = f'{name}:{unit}:{file}'
|
|
59
|
+
self.data = data[self.data_file]
|
|
60
|
+
self.value_chg = value_chg[self.data_file]
|
|
61
|
+
self.src_type = src_type
|
|
62
|
+
self.bit = bit
|
|
63
|
+
dtype, dmin, dmax = DTYPES[src_type][0:3]
|
|
64
|
+
self.tag = Tag(tagname, dtype)
|
|
65
|
+
self.map_bus = id(self)
|
|
66
|
+
self.tag.add_callback(self.tag_value_ext, self.map_bus)
|
|
67
|
+
if dmin is not None:
|
|
68
|
+
self.tag.value_min = dmin
|
|
69
|
+
if dmax is not None:
|
|
70
|
+
self.tag.value_max = dmax
|
|
71
|
+
self.byte = (int(word) - 1) * 2
|
|
72
|
+
|
|
73
|
+
def update_tag(self, time_us):
|
|
74
|
+
"""Unpack from modbus registers to tag value if different."""
|
|
75
|
+
if self.bit is not None:
|
|
76
|
+
word_value = unpack_from('>H', self.data, self.byte)[0]
|
|
77
|
+
bit_num = int(self.bit)
|
|
78
|
+
value = (word_value >> bit_num) & 1
|
|
79
|
+
elif self.src_type == 'int16':
|
|
80
|
+
value = unpack_from('>h', self.data, self.byte)[0]
|
|
81
|
+
elif self.src_type == 'uint16':
|
|
82
|
+
value = unpack_from('>H', self.data, self.byte)[0]
|
|
83
|
+
elif self.src_type == 'int32':
|
|
84
|
+
value = unpack_from('>i', self.data, self.byte)[0]
|
|
85
|
+
elif self.src_type == 'uint32':
|
|
86
|
+
value = unpack_from('>I', self.data, self.byte)[0]
|
|
87
|
+
elif self.src_type == 'int64':
|
|
88
|
+
value = unpack_from('>q', self.data, self.byte)[0]
|
|
89
|
+
elif self.src_type == 'uint64':
|
|
90
|
+
value = unpack_from('>Q', self.data, self.byte)[0]
|
|
91
|
+
elif self.src_type == 'float32':
|
|
92
|
+
value = unpack_from('>f', self.data, self.byte)[0]
|
|
93
|
+
elif self.src_type == 'float64':
|
|
94
|
+
value = unpack_from('>d', self.data, self.byte)[0]
|
|
95
|
+
else:
|
|
96
|
+
return
|
|
97
|
+
if value != self.tag.value:
|
|
98
|
+
logging.info(f'updating {self.tag.name} from {self.tag.value}'
|
|
99
|
+
f' to {value}')
|
|
100
|
+
self.tag.value = value, time_us, self.map_bus
|
|
101
|
+
|
|
102
|
+
def tag_value_ext(self, tag: Tag):
|
|
103
|
+
"""Call external tag value update to write remote table."""
|
|
104
|
+
logging.info(f'tag_value_changed {tag.name} {tag.value}')
|
|
105
|
+
if self.src_type == 'int16':
|
|
106
|
+
self.value_chg(self.data_file, self.byte, pack('>h', tag.value))
|
|
107
|
+
elif self.src_type == 'uint16':
|
|
108
|
+
self.value_chg(self.data_file, self.byte, pack('>H', tag.value))
|
|
109
|
+
elif self.src_type == 'int32':
|
|
110
|
+
self.value_chg(self.data_file, self.byte, pack('>i', tag.value))
|
|
111
|
+
elif self.src_type == 'uint32':
|
|
112
|
+
self.value_chg(self.data_file, self.byte, pack('>I', tag.value))
|
|
113
|
+
elif self.src_type == 'int64':
|
|
114
|
+
self.value_chg(self.data_file, self.byte, pack('>q', tag.value))
|
|
115
|
+
elif self.src_type == 'uint64':
|
|
116
|
+
self.value_chg(self.data_file, self.byte, pack('>Q', tag.value))
|
|
117
|
+
elif self.src_type == 'float32':
|
|
118
|
+
self.value_chg(self.data_file, self.byte, pack('>f', tag.value))
|
|
119
|
+
elif self.src_type == 'float64':
|
|
120
|
+
self.value_chg(self.data_file, self.byte, pack('>d', tag.value))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class ModbusClientMaps():
|
|
124
|
+
"""Shared modbus mapping."""
|
|
125
|
+
|
|
126
|
+
def __init__(self, tags):
|
|
127
|
+
"""Singular please."""
|
|
128
|
+
self.tags = tags
|
|
129
|
+
self.data = {}
|
|
130
|
+
self.value_chg = {}
|
|
131
|
+
self.maps = {}
|
|
132
|
+
|
|
133
|
+
def add_data_table(self, tables, value_chg):
|
|
134
|
+
"""Add a bytes data table."""
|
|
135
|
+
for table in tables:
|
|
136
|
+
self.data[table] = bytearray(2 * (tables[table] - 1))
|
|
137
|
+
self.value_chg[table] = value_chg
|
|
138
|
+
|
|
139
|
+
def make_map(self):
|
|
140
|
+
"""Make the maps."""
|
|
141
|
+
for tagname, v in self.tags.items():
|
|
142
|
+
dtype = v['type']
|
|
143
|
+
try:
|
|
144
|
+
addr = v['read']
|
|
145
|
+
except KeyError:
|
|
146
|
+
addr = v['addr']
|
|
147
|
+
map = ModbusClientMap(tagname, dtype, addr, self.data, self.value_chg)
|
|
148
|
+
size = DTYPES[dtype][3]
|
|
149
|
+
name, unit, file, word, _bit = tag_split(addr)
|
|
150
|
+
for i in range(0, size):
|
|
151
|
+
word_addr = f'{name}:{unit}:{file}:{int(word) + i}'
|
|
152
|
+
if word_addr not in self.maps:
|
|
153
|
+
self.maps[word_addr] = []
|
|
154
|
+
self.maps[word_addr].append(map)
|
|
155
|
+
|
|
156
|
+
def set_data(self, name: str, unit: int, file: str, pdu_start: int,
|
|
157
|
+
pdu_count: int, data: bytearray):
|
|
158
|
+
"""Set data, start and end in byte count."""
|
|
159
|
+
time_us = int(time() * 1e6)
|
|
160
|
+
start = pdu_start * 2
|
|
161
|
+
end = start + pdu_count * 2
|
|
162
|
+
data_file = f'{name}:{unit}:{file}'
|
|
163
|
+
self.data[data_file][start:end] = data
|
|
164
|
+
maps: set[ModbusClientMap] = set()
|
|
165
|
+
for word_count in range(1, pdu_count + 1):
|
|
166
|
+
word = word_count + pdu_start
|
|
167
|
+
word_addr = f'{name}:{unit}:{file}:{word}'
|
|
168
|
+
try:
|
|
169
|
+
word_maps = self.maps[word_addr]
|
|
170
|
+
maps.update(word_maps)
|
|
171
|
+
except KeyError:
|
|
172
|
+
pass
|
|
173
|
+
logging.debug(f'set_data {name} {unit} {file} {start} {end}')
|
|
174
|
+
for map in maps:
|
|
175
|
+
map.update_tag(time_us)
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
|
|
10
179
|
class ModbusClientProtocol(asyncio.Protocol):
|
|
11
180
|
"""Modbus TCP and UDP client."""
|
|
12
181
|
|
|
13
182
|
def __init__(self, process):
|
|
14
183
|
"""Modbus client protocol."""
|
|
15
184
|
self.process = process
|
|
16
|
-
self._mbap_tr = 0 # start at 0
|
|
17
185
|
self.buffer = b""
|
|
18
186
|
self.peername = None
|
|
19
187
|
self.sockname = None
|
|
@@ -45,11 +213,12 @@ class ModbusClientProtocol(asyncio.Protocol):
|
|
|
45
213
|
def unpack_mb(self):
|
|
46
214
|
"""Return complete modbus packets and trim the buffer."""
|
|
47
215
|
start = 0
|
|
216
|
+
end = 0
|
|
48
217
|
while True:
|
|
49
218
|
buf_len = len(self.buffer)
|
|
50
219
|
if buf_len < 6 + start: # enough to unpack length
|
|
51
220
|
break
|
|
52
|
-
|
|
221
|
+
_mbap_tr, _mbap_pr, mbap_len = unpack_from(">3H", self.buffer, start)
|
|
53
222
|
if buf_len < start + 6 + mbap_len: # there is a complete message
|
|
54
223
|
break
|
|
55
224
|
end = start + 6 + mbap_len
|
|
@@ -75,7 +244,7 @@ class ModbusClientConnector:
|
|
|
75
244
|
"""Poll Modbus device, write on change in write range."""
|
|
76
245
|
|
|
77
246
|
def __init__(self, name: str, ip: str, port: int, rate: int, tcp_udp: str,
|
|
78
|
-
poll: list, mapping:
|
|
247
|
+
poll: list, mapping: ModbusClientMaps):
|
|
79
248
|
"""
|
|
80
249
|
Set up polling client.
|
|
81
250
|
|
|
@@ -88,7 +257,6 @@ class ModbusClientConnector:
|
|
|
88
257
|
self.transport = None
|
|
89
258
|
self.protocol = None
|
|
90
259
|
self.read = poll
|
|
91
|
-
self.writeok = None
|
|
92
260
|
self.periodic = Periodic(self.poll, rate)
|
|
93
261
|
self.mapping = mapping
|
|
94
262
|
self.sent = {}
|
|
@@ -113,8 +281,11 @@ class ModbusClientConnector:
|
|
|
113
281
|
data = msg[9:]
|
|
114
282
|
self.mapping.set_data(name=self.name, data=data,
|
|
115
283
|
**self.sent[mbap_tr])
|
|
116
|
-
|
|
117
|
-
|
|
284
|
+
try:
|
|
285
|
+
del self.sent[mbap_tr]
|
|
286
|
+
except KeyError:
|
|
287
|
+
logging.warning(f"mbap_tr {mbap_tr} not found in sent")
|
|
288
|
+
elif pdu_fc == 16: # provision for future
|
|
118
289
|
pdu_start, pdu_count = unpack_from(">2H", msg, 8)
|
|
119
290
|
pass
|
|
120
291
|
elif pdu_fc > 128:
|
|
@@ -142,7 +313,7 @@ class ModbusClientConnector:
|
|
|
142
313
|
lambda: ModbusClientProtocol(self.process),
|
|
143
314
|
self.ip, self.port)
|
|
144
315
|
except Exception as e:
|
|
145
|
-
logging.
|
|
316
|
+
logging.warning(f'start_connection {e}')
|
|
146
317
|
|
|
147
318
|
def mbap_tr(self):
|
|
148
319
|
"""Global transaction number provider."""
|
|
@@ -164,15 +335,15 @@ class ModbusClientConnector:
|
|
|
164
335
|
pdu = pack(">B2H", pdu_fc, pdu_start, pdu_count)
|
|
165
336
|
pdu_len = 5
|
|
166
337
|
else:
|
|
167
|
-
logging.
|
|
338
|
+
logging.warning(f"no support for {file}")
|
|
168
339
|
return
|
|
169
340
|
mbap_len = pdu_len + 1
|
|
170
341
|
mbap = pack(">3H1B", mbap_tr, mbap_pr, mbap_len, mbap_unit)
|
|
171
342
|
msg = mbap + pdu
|
|
172
343
|
if self.tcp_udp == "udp":
|
|
173
|
-
self.transport.sendto(msg)
|
|
344
|
+
self.transport.sendto(msg) # type: ignore
|
|
174
345
|
else:
|
|
175
|
-
self.transport.write(msg)
|
|
346
|
+
self.transport.write(msg) # type: ignore
|
|
176
347
|
self.sent[mbap_tr] = {"unit": mbap_unit, "file": file,
|
|
177
348
|
"pdu_start": pdu_start, "pdu_count": pdu_count}
|
|
178
349
|
|
|
@@ -192,17 +363,17 @@ class ModbusClientConnector:
|
|
|
192
363
|
# logging.info(f"{pdu_fc} {start} {count} "
|
|
193
364
|
# f"{count * 2} {data} {pdu.hex()}")
|
|
194
365
|
else:
|
|
195
|
-
logging.
|
|
366
|
+
logging.warning(f"no support for {file}")
|
|
196
367
|
return
|
|
197
368
|
mbap_len = pdu_len + 1
|
|
198
369
|
mbap = pack(">3H1B", mbap_tr, mbap_pr, mbap_len, mbap_unit)
|
|
199
370
|
msg = mbap + pdu
|
|
200
371
|
if self.tcp_udp == "udp":
|
|
201
372
|
logging.info(f"UDP write {mbap_unit} {file} {start} {end}")
|
|
202
|
-
self.transport.sendto(msg)
|
|
373
|
+
self.transport.sendto(msg) # type: ignore
|
|
203
374
|
else:
|
|
204
375
|
logging.info(f"TCP write {mbap_unit} {file} {start} {end}")
|
|
205
|
-
self.transport.write(msg)
|
|
376
|
+
self.transport.write(msg) # type: ignore
|
|
206
377
|
|
|
207
378
|
def write_tag_update(self, addr: str, byte: int, data: bytes):
|
|
208
379
|
"""Write out any tag updates."""
|
|
@@ -239,19 +410,16 @@ class ModbusClient:
|
|
|
239
410
|
def __init__(self, bus_ip: str = '127.0.0.1', bus_port: int = 1324,
|
|
240
411
|
rtus: dict = {}, tags: dict = {}) -> None:
|
|
241
412
|
"""
|
|
242
|
-
Connect to bus on bus_ip:bus_port
|
|
243
|
-
|
|
244
|
-
Serves the webclient files at /, as a relative path. The webclient uses
|
|
245
|
-
a websocket connection to request and set tag values and subscribe to
|
|
246
|
-
changes.
|
|
413
|
+
Connect to bus on bus_ip:bus_port.
|
|
247
414
|
|
|
415
|
+
Makes connections to Modbus PLCs to read and write data.
|
|
248
416
|
Event loop must be running.
|
|
249
417
|
"""
|
|
250
418
|
self.busclient = None
|
|
251
419
|
if bus_ip is not None:
|
|
252
420
|
self.busclient = BusClient(bus_ip, bus_port,
|
|
253
421
|
module='Modbus Client')
|
|
254
|
-
self.mapping =
|
|
422
|
+
self.mapping = ModbusClientMaps(tags)
|
|
255
423
|
self.connections: list[ModbusClientConnector] = []
|
|
256
424
|
for rtu in rtus:
|
|
257
425
|
connection = ModbusClientConnector(**rtu, mapping=self.mapping)
|
|
@@ -259,7 +427,7 @@ class ModbusClient:
|
|
|
259
427
|
self.mapping.make_map()
|
|
260
428
|
|
|
261
429
|
async def start(self):
|
|
262
|
-
"""Provide a
|
|
430
|
+
"""Provide a modbus client."""
|
|
263
431
|
if self.busclient is not None:
|
|
264
432
|
await self.busclient.start()
|
|
265
433
|
for connection in self.connections:
|
pymscada/iodrivers/modbus_map.py
CHANGED
|
@@ -27,21 +27,36 @@ DTYPES = {
|
|
|
27
27
|
'uint32': [int, 0, 4294967295, 2],
|
|
28
28
|
'uint64': [int, 0, 2**64 - 1, 4],
|
|
29
29
|
'float32': [float, None, None, 2],
|
|
30
|
-
'float64': [float, None, None, 4]
|
|
30
|
+
'float64': [float, None, None, 4],
|
|
31
|
+
'bool': [int, 0, 1, 1]
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
|
|
35
|
+
def tag_split(modbus_tag: str):
|
|
36
|
+
"""Split the address into rtu, variable, element and bit."""
|
|
37
|
+
name, unit, file, word = modbus_tag.split(':')
|
|
38
|
+
bit_loc = word.find('.')
|
|
39
|
+
if bit_loc == -1:
|
|
40
|
+
bit = None
|
|
41
|
+
word = word
|
|
42
|
+
else:
|
|
43
|
+
bit = word[bit_loc + 1:]
|
|
44
|
+
word = word[:bit_loc]
|
|
45
|
+
return name, unit, file, word, bit
|
|
46
|
+
|
|
47
|
+
|
|
34
48
|
class ModbusMap:
|
|
35
49
|
"""Map the data table to a Tag."""
|
|
36
50
|
|
|
37
51
|
def __init__(self, tagname: str, src_type: str, addr: str, data: dict,
|
|
38
52
|
value_chg: dict):
|
|
39
53
|
"""Initialise modbus map and Tag."""
|
|
40
|
-
name, unit, file, word = addr
|
|
54
|
+
name, unit, file, word, bit = tag_split(addr)
|
|
41
55
|
self.data_file = f'{name}:{unit}:{file}'
|
|
42
56
|
self.data = data[self.data_file]
|
|
43
57
|
self.value_chg = value_chg[self.data_file]
|
|
44
58
|
self.src_type = src_type
|
|
59
|
+
self.bit = bit
|
|
45
60
|
dtype, dmin, dmax = DTYPES[src_type][0:3]
|
|
46
61
|
self.tag = Tag(tagname, dtype)
|
|
47
62
|
self.map_bus = id(self)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Poll OSI PI WebAPI for tag values."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import aiohttp
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
import logging
|
|
6
|
+
import socket
|
|
7
|
+
from time import time
|
|
8
|
+
from pymscada.misc import find_nodes
|
|
9
|
+
from pymscada.bus_client import BusClient
|
|
10
|
+
from pymscada.periodic import Periodic
|
|
11
|
+
from pymscada.tag import Tag
|
|
12
|
+
|
|
13
|
+
class PIWebAPIClient:
|
|
14
|
+
"""Get tag data from OSI PI WebAPI."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
bus_ip: str | None = '127.0.0.1',
|
|
19
|
+
bus_port: int = 1324,
|
|
20
|
+
proxy: str | None = None,
|
|
21
|
+
api: dict = {},
|
|
22
|
+
tags: dict = {}
|
|
23
|
+
) -> None:
|
|
24
|
+
"""
|
|
25
|
+
Connect to bus on bus_ip:bus_port.
|
|
26
|
+
|
|
27
|
+
api dict should contain:
|
|
28
|
+
- url: PI WebAPI base URL
|
|
29
|
+
- webid: PI WebID for the stream set
|
|
30
|
+
- averaging: averaging period in seconds
|
|
31
|
+
|
|
32
|
+
tags dict should contain:
|
|
33
|
+
- tagname: pitag mapping for each tag
|
|
34
|
+
"""
|
|
35
|
+
if bus_ip is not None:
|
|
36
|
+
try:
|
|
37
|
+
socket.gethostbyname(bus_ip)
|
|
38
|
+
except socket.gaierror:
|
|
39
|
+
raise ValueError(f"Invalid bus_ip: {bus_ip}")
|
|
40
|
+
if not isinstance(proxy, str) and proxy is not None:
|
|
41
|
+
raise ValueError("proxy must be a string or None")
|
|
42
|
+
if not isinstance(api, dict):
|
|
43
|
+
raise ValueError("api must be a dictionary")
|
|
44
|
+
if not isinstance(tags, dict):
|
|
45
|
+
raise ValueError("tags must be a dictionary")
|
|
46
|
+
|
|
47
|
+
self.busclient = None
|
|
48
|
+
if bus_ip is not None:
|
|
49
|
+
self.busclient = BusClient(bus_ip, bus_port, module='PIWebAPI')
|
|
50
|
+
self.proxy = proxy
|
|
51
|
+
self.base_url = api['url'].rstrip('/')
|
|
52
|
+
self.webid = api['webid']
|
|
53
|
+
self.averaging = api.get('averaging', 300)
|
|
54
|
+
self.tags = {}
|
|
55
|
+
self.pitag_map = {}
|
|
56
|
+
self.scale = {}
|
|
57
|
+
for tagname, config in tags.items():
|
|
58
|
+
self.tags[tagname] = Tag(tagname, float)
|
|
59
|
+
self.pitag_map[config['pitag']] = tagname
|
|
60
|
+
if 'scale' in config:
|
|
61
|
+
self.scale[tagname] = config['scale']
|
|
62
|
+
self.session = None
|
|
63
|
+
self.handle = None
|
|
64
|
+
self.periodic = None
|
|
65
|
+
self.queue = asyncio.Queue()
|
|
66
|
+
|
|
67
|
+
def update_tags(self, pitag: str, values: list):
|
|
68
|
+
tag = self.tags[self.pitag_map[pitag]]
|
|
69
|
+
scale = None
|
|
70
|
+
if tag.name in self.scale:
|
|
71
|
+
scale = self.scale[tag.name]
|
|
72
|
+
data = {}
|
|
73
|
+
for item in values:
|
|
74
|
+
value = item['Value']
|
|
75
|
+
dt = datetime.fromisoformat(value['Timestamp'].replace('Z', '+00:00'))
|
|
76
|
+
time_us = int(dt.timestamp() * 1e6)
|
|
77
|
+
data[time_us] = value['Value']
|
|
78
|
+
times_us = sorted(data.keys())
|
|
79
|
+
for time_us in times_us:
|
|
80
|
+
if time_us > tag.time_us:
|
|
81
|
+
if data[time_us] is None:
|
|
82
|
+
logging.error(f'{tag.name} is None at {time_us}')
|
|
83
|
+
continue
|
|
84
|
+
if scale is not None:
|
|
85
|
+
data[time_us] = data[time_us] / scale
|
|
86
|
+
tag.value = data[time_us], time_us
|
|
87
|
+
|
|
88
|
+
async def handle_response(self):
|
|
89
|
+
"""Handle responses from the API."""
|
|
90
|
+
while True:
|
|
91
|
+
values = await self.queue.get()
|
|
92
|
+
for value in find_nodes('Name' , values):
|
|
93
|
+
if value['Name'] in self.pitag_map:
|
|
94
|
+
self.update_tags(value['Name'], value['Items'])
|
|
95
|
+
self.queue.task_done()
|
|
96
|
+
|
|
97
|
+
async def get_pi_data(self, now):
|
|
98
|
+
"""Get PI data from WebAPI."""
|
|
99
|
+
time = now - (now % self.averaging)
|
|
100
|
+
start_time = datetime.fromtimestamp(time - self.averaging * 12).isoformat()
|
|
101
|
+
end_time = datetime.fromtimestamp(time).isoformat()
|
|
102
|
+
url = f"{self.base_url}/piwebapi/streamsets/{self.webid}/summary?" \
|
|
103
|
+
f"startTime={start_time}&endTime={end_time}" \
|
|
104
|
+
"&summaryType=Average&calculationBasis=TimeWeighted" \
|
|
105
|
+
f"&summaryDuration={self.averaging}s"
|
|
106
|
+
async with self.session.get(url) as response:
|
|
107
|
+
return await response.json()
|
|
108
|
+
|
|
109
|
+
async def fetch_data(self, now):
|
|
110
|
+
"""Fetch values from PI Web API."""
|
|
111
|
+
try:
|
|
112
|
+
if self.session is None:
|
|
113
|
+
connector = aiohttp.TCPConnector(ssl=False)
|
|
114
|
+
self.session = aiohttp.ClientSession(connector=connector)
|
|
115
|
+
json_data = await self.get_pi_data(now)
|
|
116
|
+
if json_data:
|
|
117
|
+
await self.queue.put(json_data)
|
|
118
|
+
except Exception as e:
|
|
119
|
+
logging.error(f'Error fetching data: {type(e).__name__} - {str(e)}')
|
|
120
|
+
|
|
121
|
+
async def poll(self):
|
|
122
|
+
"""Poll PI API."""
|
|
123
|
+
now = int(time())
|
|
124
|
+
if now % self.averaging == 15:
|
|
125
|
+
asyncio.create_task(self.fetch_data(now))
|
|
126
|
+
|
|
127
|
+
async def start(self):
|
|
128
|
+
"""Start bus connection and API polling."""
|
|
129
|
+
if self.busclient is not None:
|
|
130
|
+
await self.busclient.start()
|
|
131
|
+
self.handle = asyncio.create_task(self.handle_response())
|
|
132
|
+
self.periodic = Periodic(self.poll, 1.0)
|
|
133
|
+
await self.periodic.start()
|