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.
Files changed (39) hide show
  1. pymscada/__init__.py +8 -2
  2. pymscada/alarms.py +179 -60
  3. pymscada/bus_client.py +12 -2
  4. pymscada/bus_server.py +18 -9
  5. pymscada/callout.py +198 -101
  6. pymscada/config.py +20 -1
  7. pymscada/console.py +19 -6
  8. pymscada/demo/__pycache__/__init__.cpython-311.pyc +0 -0
  9. pymscada/demo/callout.yaml +13 -4
  10. pymscada/demo/files.yaml +3 -2
  11. pymscada/demo/openweather.yaml +3 -11
  12. pymscada/demo/piapi.yaml +15 -0
  13. pymscada/demo/pymscada-io-piapi.service +15 -0
  14. pymscada/demo/pymscada-io-sms.service +18 -0
  15. pymscada/demo/sms.yaml +11 -0
  16. pymscada/demo/tags.yaml +3 -0
  17. pymscada/demo/witsapi.yaml +6 -8
  18. pymscada/demo/wwwserver.yaml +15 -0
  19. pymscada/files.py +1 -0
  20. pymscada/history.py +4 -5
  21. pymscada/iodrivers/logix_map.py +1 -1
  22. pymscada/iodrivers/modbus_client.py +189 -21
  23. pymscada/iodrivers/modbus_map.py +17 -2
  24. pymscada/iodrivers/piapi.py +133 -0
  25. pymscada/iodrivers/sms.py +212 -0
  26. pymscada/iodrivers/witsapi.py +26 -35
  27. pymscada/module_config.py +24 -18
  28. pymscada/opnotes.py +38 -16
  29. pymscada/pdf/__pycache__/__init__.cpython-311.pyc +0 -0
  30. pymscada/tag.py +6 -7
  31. pymscada/tools/get_history.py +147 -0
  32. pymscada/www_server.py +2 -1
  33. {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/METADATA +2 -2
  34. {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/RECORD +38 -32
  35. pymscada/validate.py +0 -451
  36. {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/WHEEL +0 -0
  37. {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/entry_points.txt +0 -0
  38. {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/licenses/LICENSE +0 -0
  39. {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 = 0
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
 
@@ -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 > 0 and elm_offset < len(poll.value):
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.iodrivers.modbus_map import ModbusMaps
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
- mbap_tr, mbap_pr, mbap_len = unpack_from(">3H", self.buffer, start)
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: ModbusMaps):
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
- del self.sent[mbap_tr]
117
- elif pdu_fc == 16:
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.warn(f'start_connection {e}')
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.warn(f"no support for {file}")
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.warn(f"no support for {file}")
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, serve on ip:port for webclient.
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 = ModbusMaps(tags)
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 web server."""
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:
@@ -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.split(':')
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()