pymscada 0.2.0rc3__py3-none-any.whl → 0.2.0rc6__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.

Potentially problematic release.


This version of pymscada might be problematic. Click here for more details.

pymscada/alarms.py CHANGED
@@ -3,15 +3,179 @@ import logging
3
3
  import sqlite3 # note that sqlite3 has blocking calls
4
4
  import socket
5
5
  import time
6
- import atexit
7
6
  from pymscada.bus_client import BusClient
8
- from pymscada.tag import Tag, TagInfo, TYPES
7
+ from pymscada.periodic import Periodic
8
+ from pymscada.tag import Tag, TYPES
9
9
 
10
10
  ALM = 0
11
11
  RTN = 1
12
12
  ACT = 2
13
13
  INF = 3
14
14
 
15
+ KIND = {
16
+ ALM: 'ALM',
17
+ RTN: 'RTN',
18
+ ACT: 'ACT',
19
+ INF: 'INF'
20
+ }
21
+
22
+ NORMAL = 0
23
+ ALARM = 1
24
+
25
+ """
26
+ Database schema:
27
+
28
+ alarms contains an event log of changes as they occur, this
29
+ includes information on actions taken by the alarm system.
30
+
31
+ CREATE TABLE IF NOT EXISTS alarms (
32
+ id INTEGER PRIMARY KEY ASC,
33
+ date_ms INTEGER,
34
+ alarm_string TEXT,
35
+ kind INTEGER, # one of ALM, RTN, ACT, INF
36
+ desc TEXT,
37
+ group TEXT
38
+ )
39
+ """
40
+
41
+
42
+ def standardise_tag_info(tagname: str, tag: dict):
43
+ """Correct tag dictionary in place to be suitable for modules."""
44
+ tag['name'] = tagname
45
+ tag['id'] = None
46
+ if 'desc' not in tag:
47
+ logging.warning(f"Tag {tagname} has no description, using name")
48
+ tag['desc'] = tag['name']
49
+ if 'area' not in tag:
50
+ tag['area'] = ''
51
+ if 'multi' in tag:
52
+ tag['type'] = int
53
+ else:
54
+ if 'type' not in tag:
55
+ tag['type'] = float
56
+ else:
57
+ if tag['type'] not in TYPES:
58
+ tag['type'] = str
59
+ else:
60
+ tag['type'] = TYPES[tag['type']]
61
+ if 'dp' not in tag:
62
+ if tag['type'] == int:
63
+ tag['dp'] = 0
64
+ else:
65
+ tag['dp'] = 2
66
+ if 'units' not in tag:
67
+ tag['units'] = ''
68
+ if 'alarm' in tag:
69
+ if isinstance(tag['alarm'], str):
70
+ tag['alarm'] = [tag['alarm']]
71
+ if not isinstance(tag['alarm'], list):
72
+ logging.warning(f"Tag {tagname} has invalid alarm {tag['alarm']}")
73
+ del tag['alarm']
74
+
75
+
76
+ def split_operator(alarm: str) -> dict:
77
+ """Split alarm string into operator and value."""
78
+ tokens = alarm.split(' ')
79
+ alm_dict = {'for': 0}
80
+ if len(tokens) not in (2, 4):
81
+ raise ValueError(f"Invalid alarm {alarm}")
82
+ if tokens[0] not in ['>', '<', '==', '>=', '<=']:
83
+ raise ValueError(f"Invalid alarm {alarm}")
84
+ alm_dict['operator'] = tokens[0]
85
+ try:
86
+ alm_dict['value'] = float(tokens[1])
87
+ except ValueError:
88
+ raise ValueError(f"Invalid alarm {alarm}")
89
+ if len(tokens) == 4:
90
+ if tokens[2] != 'for':
91
+ raise ValueError(f"Invalid alarm {alarm}")
92
+ try:
93
+ alm_dict['for'] = int(tokens[3])
94
+ except ValueError:
95
+ raise ValueError(f"Invalid alarm {alarm}")
96
+ return alm_dict
97
+
98
+
99
+ class Alarm():
100
+ """
101
+ Single alarm class.
102
+
103
+ Alarms are defined by a tag and a condition. Tags may have multiple
104
+ conditions, each combination of tag and condition is a separate Alarm.
105
+
106
+ Monitors tag value through the Tag callback. Tracks in alarm state.
107
+ Generates the ALM and RTN messages for Alarms to publish via rta_tag.
108
+ """
109
+
110
+ def __init__(self, tagname: str, tag: dict, alarm: str, area: str, rta_cb, alarms) -> None:
111
+ """Initialize alarm with tag and condition(s)."""
112
+ self.alarm_id = f'{tagname} {alarm}'
113
+ self.tag = Tag(tagname, tag['type'])
114
+ self.tag.desc = tag['desc']
115
+ self.tag.dp = tag['dp']
116
+ self.tag.units = tag['units']
117
+ self.tag.add_callback(self.callback)
118
+ self.area = area
119
+ self.rta_cb = rta_cb
120
+ self.alarms = alarms
121
+ self.alarm = split_operator(alarm)
122
+ self.in_alarm = False
123
+ self.checking = False
124
+
125
+ def callback(self, tag: Tag):
126
+ """Handle tag value changes and generate ALM/RTN messages."""
127
+ if tag.value is None:
128
+ return
129
+ value = float(tag.value)
130
+ time_us = tag.time_us
131
+ new_in_alarm = False
132
+ op = self.alarm['operator']
133
+ if op == '>':
134
+ new_in_alarm = value > self.alarm['value']
135
+ elif op == '<':
136
+ new_in_alarm = value < self.alarm['value']
137
+ elif op == '==':
138
+ new_in_alarm = value == self.alarm['value']
139
+ elif op == '>=':
140
+ new_in_alarm = value >= self.alarm['value']
141
+ elif op == '<=':
142
+ new_in_alarm = value <= self.alarm['value']
143
+ if new_in_alarm == self.in_alarm:
144
+ return
145
+ self.in_alarm = new_in_alarm
146
+ if self.in_alarm:
147
+ if self.alarm['for'] > 0:
148
+ if not self.checking:
149
+ self.checking = True
150
+ self.alarms.checking_alarms.append(self)
151
+ else:
152
+ self.generate_alarm(ALM, time_us, value)
153
+ else:
154
+ if self.checking:
155
+ self.checking = False
156
+ self.alarms.checking_alarms.remove(self)
157
+ self.generate_alarm(RTN, time_us, value)
158
+
159
+ def generate_alarm(self, kind: int, time_us: int, value: float):
160
+ """Generate alarm message."""
161
+ logging.warning(f'Alarm {self.alarm_id} {value} {KIND[kind]}')
162
+ self.rta_cb({
163
+ 'action': 'ADD',
164
+ 'date_ms': int(time_us / 1000),
165
+ 'alarm_string': self.alarm_id,
166
+ 'kind': kind,
167
+ 'desc': f'{self.tag.desc} {value:.{self.tag.dp}f}'
168
+ f' {self.tag.units}',
169
+ 'group': self.area
170
+ })
171
+
172
+ def check_duration(self, current_time_us: int):
173
+ """Check if alarm condition has been met for required duration."""
174
+ if current_time_us - self.tag.time_us >= self.alarm['for'] * 1000000:
175
+ self.generate_alarm(ALM, current_time_us, self.tag.value)
176
+ self.checking = False
177
+ self.alarms.checking_alarms.remove(self)
178
+
15
179
 
16
180
  class Alarms:
17
181
  """Connect to bus_ip:bus_port, store and provide Alarms."""
@@ -22,7 +186,7 @@ class Alarms:
22
186
  bus_port: int | None = 1324,
23
187
  db: str | None = None,
24
188
  table: str = 'alarms',
25
- tag_info: TagInfo = {},
189
+ tag_info: dict[str, dict] = {},
26
190
  rta_tag: str = '__alarms__'
27
191
  ) -> None:
28
192
  """
@@ -54,84 +218,56 @@ class Alarms:
54
218
  raise ValueError('table must be a non-empty string')
55
219
 
56
220
  logging.warning(f'Alarms {bus_ip} {bus_port} {db} {rta_tag}')
57
- self.connection = sqlite3.connect(db)
58
- self.tags: dict[str, Tag] = {}
59
- self.alarm_test: dict[str, dict] = {}
60
- self.in_alarm: set[str] = set()
221
+ self.alarms: list[Alarm] = []
222
+ self.checking_alarms: list[Alarm] = []
61
223
  for tagname, tag in tag_info.items():
62
- if 'alarm' not in tag:
224
+ standardise_tag_info(tagname, tag)
225
+ if 'alarm' not in tag or tag['type'] not in (int, float):
63
226
  continue
64
- self.tags[tagname] = Tag(tagname, tag['type'])
65
- self.tags[tagname].desc = tag['desc']
66
- self.tags[tagname].add_callback(self.alarm_cb)
67
- operator, value = tag['alarm'].split(' ')
68
- self.alarm_test[tagname] = [
69
- {
70
- '==': (lambda x, y: x == y),
71
- '<': (lambda x, y: x < y),
72
- '>': (lambda x, y: x > y),
73
- '<=': (lambda x, y: x <= y),
74
- '>=': (lambda x, y: x >= y)
75
- }[operator],
76
- float(value)
77
- ]
78
- self.table = table
79
- self.cursor = self.connection.cursor()
227
+ area = tag['area']
228
+ for alarm in tag['alarm']:
229
+ new_alarm = Alarm(tagname, tag, alarm, area, self.rta_cb, self)
230
+ self.alarms.append(new_alarm)
80
231
  self.busclient = BusClient(bus_ip, bus_port, module='Alarms')
81
232
  self.rta = Tag(rta_tag, dict)
82
233
  self.rta.value = {}
83
234
  self.busclient.add_callback_rta(rta_tag, self.rta_cb)
84
- self._init_table()
85
- atexit.register(self.close)
86
-
87
- def alarm_cb(self, tag):
88
- """Callback for alarm tags."""
89
- operator, value = self.alarm_test[tag.name]
90
- if operator(tag.value, value) and tag.name not in self.in_alarm:
91
- logging.warning(f'Alarm {tag.name} {tag.value}')
92
- alarm_record = {
93
- 'action': 'ADD',
94
- 'date_ms': int(tag.time_us / 1000),
95
- 'tagname': tag.name,
96
- 'transition': ALM,
97
- 'description': f'{tag.desc} {tag.value}'
98
- }
99
- self.rta_cb(alarm_record)
100
- self.in_alarm.add(tag.name)
101
- elif not operator(tag.value, value) and tag.name in self.in_alarm:
102
- logging.info(f'No alarm {tag.name} {tag.value}')
103
- alarm_record = {
104
- 'action': 'ADD',
105
- 'date_ms': int(tag.time_us / 1000),
106
- 'tagname': tag.name,
107
- 'transition': RTN,
108
- 'description': f'{tag.desc} {tag.value}'
109
- }
110
- self.rta_cb(alarm_record)
111
- self.in_alarm.remove(tag.name)
112
-
113
- def _init_table(self):
235
+ self._init_db(db, table)
236
+ self.periodic = Periodic(self.periodic_cb, 1.0)
237
+
238
+ def _init_db(self, db, table):
114
239
  """Initialize the database table schema."""
240
+ self.connection = sqlite3.connect(db)
241
+ self.table = table
242
+ self.cursor = self.connection.cursor()
115
243
  query = (
116
- 'CREATE TABLE IF NOT EXISTS ' + self.table +
244
+ 'CREATE TABLE IF NOT EXISTS ' + self.table + ' '
117
245
  '(id INTEGER PRIMARY KEY ASC, '
118
246
  'date_ms INTEGER, '
119
- 'tagname TEXT, '
120
- 'transition INTEGER, '
121
- 'description TEXT)'
247
+ 'alarm_string TEXT, '
248
+ 'kind INTEGER, '
249
+ 'desc TEXT, '
250
+ '"group" TEXT)'
122
251
  )
123
252
  self.cursor.execute(query)
253
+ self.connection.commit()
124
254
 
125
- # Add startup record using existing ADD functionality
126
255
  startup_record = {
127
256
  'action': 'ADD',
128
257
  'date_ms': int(time.time() * 1000),
129
- 'tagname': self.rta.name,
130
- 'transition': INF,
131
- 'description': 'Alarm logging started'
258
+ 'alarm_string': self.rta.name,
259
+ 'kind': INF,
260
+ 'desc': 'Alarm logging started',
261
+ 'group': '__system__'
132
262
  }
133
263
  self.rta_cb(startup_record)
134
264
 
265
+ async def periodic_cb(self):
266
+ """Periodic callback to check alarms."""
267
+ current_time_us = int(time.time() * 1000000)
268
+ for alarm in self.checking_alarms[:]:
269
+ alarm.check_duration(current_time_us)
270
+
135
271
  def rta_cb(self, request):
136
272
  """Respond to Request to Author and publish on rta_tag as needed."""
137
273
  if 'action' not in request:
@@ -142,20 +278,41 @@ class Alarms:
142
278
  with self.connection:
143
279
  self.cursor.execute(
144
280
  f'INSERT INTO {self.table} '
145
- '(date_ms, tagname, transition, description) '
146
- 'VALUES(:date_ms, :tagname, :transition, :description) '
281
+ '(date_ms, alarm_string, kind, desc, "group") '
282
+ 'VALUES(:date_ms, :alarm_string, :kind, :desc, :group) '
147
283
  'RETURNING *;',
148
284
  request)
149
285
  res = self.cursor.fetchone()
150
286
  self.rta.value = {
151
287
  'id': res[0],
152
288
  'date_ms': res[1],
153
- 'tagname': res[2],
154
- 'transition': res[3],
155
- 'description': res[4]
289
+ 'alarm_string': res[2],
290
+ 'kind': res[3],
291
+ 'desc': res[4],
292
+ 'group': res[5]
156
293
  }
157
294
  except sqlite3.IntegrityError as error:
158
295
  logging.warning(f'Alarms rta_cb {error}')
296
+ elif request['action'] == 'UPDATE':
297
+ try:
298
+ logging.info(f'update {request}')
299
+ with self.connection:
300
+ self.cursor.execute(
301
+ f'UPDATE {self.table} SET in_alm = :in_alm '
302
+ 'WHERE id = :id RETURNING *;',
303
+ request)
304
+ res = self.cursor.fetchone()
305
+ if res:
306
+ self.rta.value = {
307
+ 'id': res[0],
308
+ 'date_ms': res[1],
309
+ 'alarm_string': res[2],
310
+ 'kind': res[3],
311
+ 'desc': res[4],
312
+ 'group': res[5]
313
+ }
314
+ except sqlite3.IntegrityError as error:
315
+ logging.warning(f'Alarms rta_cb update {error}')
159
316
  elif request['action'] == 'HISTORY':
160
317
  try:
161
318
  logging.info(f'history {request}')
@@ -168,27 +325,30 @@ class Alarms:
168
325
  '__rta_id__': request['__rta_id__'],
169
326
  'id': res[0],
170
327
  'date_ms': res[1],
171
- 'tagname': res[2],
172
- 'transition': res[3],
173
- 'description': res[4]
328
+ 'alarm_string': res[2],
329
+ 'kind': res[3],
330
+ 'desc': res[4],
331
+ 'group': res[5]
174
332
  }
175
333
  except sqlite3.IntegrityError as error:
176
334
  logging.warning(f'Alarms rta_cb {error}')
335
+ elif request['action'] == 'BULK HISTORY':
336
+ try:
337
+ logging.info(f'bulk history {request}')
338
+ with self.connection:
339
+ self.cursor.execute(
340
+ f'SELECT * FROM {self.table} WHERE date_ms > :date_ms '
341
+ 'ORDER BY -date_ms;', request)
342
+ results = list(self.cursor.fetchall())
343
+ self.rta.value = {'__rta_id__': request['__rta_id__'],
344
+ 'data': results}
345
+ except sqlite3.IntegrityError as error:
346
+ logging.warning(f'Alarms rta_cb {error}')
347
+ elif request['action'] == 'IN ALARM':
348
+ self.rta.value = {'__rta_id__': request['__rta_id__'],
349
+ 'data': {'in_alarm': list(self.in_alarm)}}
177
350
 
178
351
  async def start(self):
179
352
  """Async startup."""
180
353
  await self.busclient.start()
181
-
182
- def close(self):
183
- """Clean shutdown of alarms logging."""
184
- shutdown_record = {
185
- 'action': 'ADD',
186
- 'date_ms': int(time.time() * 1000),
187
- 'tagname': self.rta.name,
188
- 'transition': INF,
189
- 'description': 'Alarm logging stopped'
190
- }
191
- try:
192
- self.rta_cb(shutdown_record)
193
- except sqlite3.Error as e:
194
- logging.error(f'Error during alarm shutdown: {e}')
354
+ await self.periodic.start()
pymscada/console.py CHANGED
@@ -3,7 +3,8 @@ import asyncio
3
3
  import logging
4
4
  import sys
5
5
  from pymscada.bus_client import BusClient
6
- from pymscada.tag import Tag, tag_for_web, TagInfo
6
+ from pymscada.tag import Tag
7
+ from pymscada.www_server import standardise_tag_info
7
8
  try:
8
9
  import termios
9
10
  import tty
@@ -153,7 +154,7 @@ class Console:
153
154
  """Provide a text console to interact with a Bus."""
154
155
 
155
156
  def __init__(self, bus_ip: str = '127.0.0.1', bus_port: int = 1324,
156
- tag_info: dict[str, TagInfo] = {}) -> None:
157
+ tag_info: dict = {}) -> None:
157
158
  """
158
159
  Connect to bus_ip:bus_port and provide console interaction with a Bus.
159
160
 
@@ -177,7 +178,7 @@ class Console:
177
178
  self.busclient = BusClient(bus_ip, bus_port, module='Console')
178
179
  self.tags: dict[str, Tag] = {}
179
180
  for tagname, tag in tag_info.items():
180
- tag_for_web(tagname, tag)
181
+ standardise_tag_info(tagname, tag)
181
182
  self.tags[tagname] = Tag(tagname, tag['type'])
182
183
 
183
184
  def write_tag(self, tag: Tag):
@@ -0,0 +1,17 @@
1
+ bus_ip: 127.0.0.1
2
+ bus_port: 1324
3
+ proxy:
4
+ api:
5
+ url: 'https://api.electricityinfo.co.nz'
6
+ client_id: ${WITS_CLIENT_ID}
7
+ client_secret: ${WITS_CLIENT_SECRET}
8
+ gxp_list:
9
+ - MAT1101
10
+ - CYD2201
11
+ - BEN2201
12
+ back: 1
13
+ forward: 12
14
+ tags:
15
+ - MAT1101_RTD
16
+ - CYD2201_RTD
17
+ - BEN2201_RTD
pymscada/history.py CHANGED
@@ -28,7 +28,7 @@ import time
28
28
  import socket
29
29
  from typing import TypedDict, Optional
30
30
  from pymscada.bus_client import BusClient
31
- from pymscada.tag import Tag, TagInfo, TYPES
31
+ from pymscada.tag import Tag, TYPES
32
32
 
33
33
 
34
34
  ITEM_SIZE = 16 # Q + q, Q or d
@@ -37,21 +37,12 @@ CHUNK_SIZE = ITEM_COUNT * ITEM_SIZE
37
37
  FILE_CHUNKS = 64
38
38
 
39
39
 
40
- class Request(TypedDict, total=False):
41
- """Type definition for request dictionary."""
42
- tagname: str
43
- start_ms: Optional[int] # Allow web client to use native ms
44
- start_us: Optional[int] # Native for pymscada server
45
- end_ms: Optional[int]
46
- end_us: Optional[int]
47
- __rta_id__: Optional[int] # Empty for a change that must be broadcast
48
-
49
-
50
- def tag_for_history(tagname: str, tag: dict):
51
- """Correct tag dictionary in place to be suitable for web client."""
40
+ def standardise_tag_info(tagname: str, tag: dict):
41
+ """Correct tag dictionary in place to be suitable for modules."""
52
42
  tag['name'] = tagname
53
43
  tag['id'] = None
54
44
  if 'desc' not in tag:
45
+ logging.warning(f"Tag {tagname} has no description, using name")
55
46
  tag['desc'] = tag['name']
56
47
  if 'multi' in tag:
57
48
  tag['type'] = int
@@ -71,6 +62,16 @@ def tag_for_history(tagname: str, tag: dict):
71
62
  tag['deadband'] = None
72
63
 
73
64
 
65
+ class Request(TypedDict, total=False):
66
+ """Type definition for request dictionary."""
67
+ tagname: str
68
+ start_ms: Optional[int] # Allow web client to use native ms
69
+ start_us: Optional[int] # Native for pymscada server
70
+ end_ms: Optional[int]
71
+ end_us: Optional[int]
72
+ __rta_id__: Optional[int] # Empty for a change that must be broadcast
73
+
74
+
74
75
  def get_tag_hist_files(path: Path, tagname: str) -> dict[int, Path]:
75
76
  """Parse path for history files matching tagname."""
76
77
  files_us = {}
@@ -226,7 +227,7 @@ class History():
226
227
  bus_ip: str = '127.0.0.1',
227
228
  bus_port: int = 1324,
228
229
  path: str = 'history',
229
- tag_info: TagInfo = {},
230
+ tag_info: dict[str, dict] = {},
230
231
  rta_tag: str | None = '__history__'
231
232
  ) -> None:
232
233
  """
@@ -260,7 +261,7 @@ class History():
260
261
  self.tags: dict[str, Tag] = {}
261
262
  self.hist_tags: dict[str, TagHistory] = {}
262
263
  for tagname, tag in tag_info.items():
263
- tag_for_history(tagname, tag)
264
+ standardise_tag_info(tagname, tag)
264
265
  if tag['type'] not in [float, int]:
265
266
  continue
266
267
  self.hist_tags[tagname] = TagHistory(
@@ -0,0 +1,217 @@
1
+ """Poll WITS GXP pricing real time dispatch and forecast."""
2
+ import asyncio
3
+ import aiohttp
4
+ import datetime
5
+ import logging
6
+ import socket
7
+ from time import time
8
+ from pymscada.bus_client import BusClient
9
+ from pymscada.periodic import Periodic
10
+ from pymscada.tag import Tag
11
+
12
+ class WitsAPIClient:
13
+ """Get pricing data from WITS GXP APIs."""
14
+
15
+ def __init__(
16
+ self,
17
+ bus_ip: str | None = '127.0.0.1',
18
+ bus_port: int = 1324,
19
+ proxy: str | None = None,
20
+ api: dict = {},
21
+ tags: list = []
22
+ ) -> None:
23
+ """
24
+ Connect to bus on bus_ip:bus_port.
25
+
26
+ api dict should contain:
27
+ - client_id: WITS API client ID
28
+ - client_secret: WITS API client secret
29
+ - url: WITS API base URL
30
+ - gxp_list: list of GXP nodes to fetch prices for
31
+ - schedules: list of schedule types to fetch
32
+ - back: number of periods to look back
33
+ - forward: number of periods to look forward
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, list):
45
+ raise ValueError("tags must be a list")
46
+
47
+ self.busclient = None
48
+ if bus_ip is not None:
49
+ self.busclient = BusClient(bus_ip, bus_port, module='WitsAPI')
50
+ self.proxy = proxy
51
+ self.map_bus = id(self)
52
+ self.tags = {tagname: Tag(tagname, float) for tagname in tags}
53
+
54
+ # API configuration
55
+ self.client_id = api['client_id']
56
+ self.client_secret = api['client_secret']
57
+ self.base_url = api['url']
58
+ self.gxp_list = api.get('gxp_list', [])
59
+ self.back = api.get('back', 2)
60
+ self.forward = api.get('forward', 72)
61
+
62
+ self.session = None
63
+ self.handle = None
64
+ self.periodic = None
65
+ self.queue = asyncio.Queue()
66
+
67
+ async def get_token(self):
68
+ """Get a new OAuth token"""
69
+ auth_url = f"{self.base_url}/login/oauth2/token"
70
+ data = {
71
+ "grant_type": "client_credentials",
72
+ "client_id": self.client_id,
73
+ "client_secret": self.client_secret
74
+ }
75
+ headers = {"Content-Type": "application/x-www-form-urlencoded"}
76
+ try:
77
+ async with self.session.post(auth_url, data=data, headers=headers) as response:
78
+ if response.status == 200:
79
+ result = await response.json()
80
+ self.session.headers.update({
81
+ "Authorization": f"Bearer {result['access_token']}"
82
+ })
83
+ return result["access_token"]
84
+ else:
85
+ error_text = await response.text()
86
+ logging.error(f'WITS API auth error: {error_text}')
87
+ return None
88
+ except Exception as e:
89
+ logging.error(f'WITS API auth error: {type(e).__name__} - {str(e)}')
90
+ return None
91
+
92
+ async def get_multi_schedule_prices(self):
93
+ """Get prices across multiple schedules"""
94
+ endpoint = "api/market-prices/v1/prices"
95
+ params = {
96
+ 'schedules': 'RTD,PRSS,PRSL',
97
+ 'marketType': 'E',
98
+ 'offset': 0
99
+ }
100
+ if self.gxp_list:
101
+ params['nodes'] = ','.join(self.gxp_list)
102
+ if self.back:
103
+ params['back'] = min(self.back, 48)
104
+ if self.forward:
105
+ params['forward'] = min(self.forward, 48)
106
+
107
+ query = '&'.join(f"{k}={v}" for k, v in params.items())
108
+ url = f"{self.base_url}/{endpoint}?{query}"
109
+
110
+ try:
111
+ async with self.session.get(url, proxy=self.proxy) as response:
112
+ if response.status == 200:
113
+ return await response.json()
114
+ else:
115
+ error_text = await response.text()
116
+ logging.error(f'WITS API error: {error_text}')
117
+ return None
118
+ except Exception as e:
119
+ logging.error(f'WITS API error: {type(e).__name__} - {str(e)}')
120
+ return None
121
+
122
+ def parse_prices(self, response):
123
+ """Parse API response into structured price dictionary"""
124
+ if not response:
125
+ return {}
126
+ prices = {}
127
+ for schedule_data in response:
128
+ schedule = schedule_data['schedule']
129
+ if 'prices' not in schedule_data:
130
+ continue
131
+ for price in schedule_data['prices']:
132
+ node = price['node']
133
+ trading_time = int(datetime.datetime.fromisoformat(
134
+ price['tradingDateTime'].replace('Z', '+00:00')
135
+ ).timestamp())
136
+ last_run = int(datetime.datetime.fromisoformat(
137
+ price['lastRunTime'].replace('Z', '+00:00')
138
+ ).timestamp())
139
+
140
+ if node not in prices:
141
+ prices[node] = {}
142
+ if trading_time not in prices[node]:
143
+ prices[node][trading_time] = {}
144
+ prices[node][trading_time][schedule] = [price['price'], last_run]
145
+ return prices
146
+
147
+ def update_tags(self, prices):
148
+ """Update tags with price data"""
149
+ for node in prices:
150
+ rtd = {}
151
+ for trading_time in prices[node]:
152
+ if 'RTD' in prices[node][trading_time]:
153
+ rtd_price, _ = prices[node][trading_time]['RTD']
154
+ rtd[trading_time] = rtd_price
155
+ continue
156
+ prss_price = None
157
+ prsl_price = None
158
+ if 'PRSS' in prices[node][trading_time]:
159
+ prss_price, prss_last_run = prices[node][trading_time]['PRSS']
160
+ if 'PRSL' in prices[node][trading_time]:
161
+ prsl_price, prsl_last_run = prices[node][trading_time]['PRSL']
162
+ if prsl_price is not None and prss_price is not None:
163
+ if prss_last_run > prsl_last_run:
164
+ rtd[trading_time] = prss_price
165
+ else:
166
+ rtd[trading_time] = prsl_price
167
+ continue
168
+ if prss_price is not None:
169
+ rtd[trading_time] = prss_price
170
+ elif prsl_price is not None:
171
+ rtd[trading_time] = prsl_price
172
+ tagname = f"{node}_RTD"
173
+ if tagname in self.tags:
174
+ for trading_time in sorted(rtd.keys()):
175
+ time_us = int(trading_time * 1_000_000)
176
+ self.tags[tagname].value = rtd[trading_time], time_us, self.map_bus
177
+
178
+ async def handle_response(self):
179
+ """Handle responses from the API."""
180
+ while True:
181
+ try:
182
+ prices = await self.queue.get()
183
+ if prices:
184
+ parsed_prices = self.parse_prices(prices)
185
+ self.update_tags(parsed_prices)
186
+ self.queue.task_done()
187
+ except Exception as e:
188
+ logging.error(f'Error handling response: {type(e).__name__} - {str(e)}')
189
+
190
+ async def fetch_data(self):
191
+ """Fetch price data from WITS API."""
192
+ try:
193
+ if self.session is None:
194
+ self.session = aiohttp.ClientSession()
195
+ token = await self.get_token()
196
+ if token:
197
+ prices = await self.get_multi_schedule_prices()
198
+ if prices:
199
+ await self.queue.put(prices)
200
+ except Exception as e:
201
+ logging.error(f'Error fetching data: {type(e).__name__} - {str(e)}')
202
+
203
+ async def poll(self):
204
+ """Poll WITS API every 5 minutes."""
205
+ now = int(time())
206
+ if now % 300 == 0: # Every 5 minutes
207
+ asyncio.create_task(self.fetch_data())
208
+
209
+ async def start(self):
210
+ """Start bus connection and API polling."""
211
+ if self.busclient is not None:
212
+ await self.busclient.start()
213
+ self.handle = asyncio.create_task(self.handle_response())
214
+ self.periodic = Periodic(self.poll, 1.0)
215
+ await self.periodic.start()
216
+
217
+
@@ -0,0 +1,246 @@
1
+ import aiohttp
2
+ import asyncio
3
+ import datetime
4
+
5
+ class WitsAPIClient:
6
+ def __init__(self, url, client_id, client_secret):
7
+ self.client_id = client_id
8
+ self.client_secret = client_secret
9
+ self.base_url = url
10
+ self.session = None
11
+
12
+ async def __aenter__(self):
13
+ """Create session and get token on entry"""
14
+ self.session = aiohttp.ClientSession()
15
+ return self
16
+
17
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
18
+ """Close session on exit"""
19
+ if self.session:
20
+ await self.session.close()
21
+
22
+ async def get_token(self):
23
+ """Get a new OAuth token"""
24
+ auth_url = f"{self.base_url}/login/oauth2/token"
25
+ data = {
26
+ "grant_type": "client_credentials",
27
+ "client_id": self.client_id,
28
+ "client_secret": self.client_secret
29
+ }
30
+
31
+ headers = {
32
+ "Content-Type": "application/x-www-form-urlencoded"
33
+ }
34
+
35
+ try:
36
+ async with self.session.post(auth_url, data=data, headers=headers) as response:
37
+ if response.status == 200:
38
+ result = await response.json()
39
+ self.session.headers.update({
40
+ "Authorization": f"Bearer {result['access_token']}"
41
+ })
42
+ return result["access_token"]
43
+ else:
44
+ error_text = await response.text()
45
+ return None
46
+ except Exception as e:
47
+ return None
48
+
49
+ async def get(self, endpoint):
50
+ """Make a GET request to the WITS API"""
51
+ url = f"{self.base_url}/{endpoint}"
52
+ try:
53
+ async with self.session.get(url) as response:
54
+ if response.status == 200:
55
+ result = await response.json()
56
+ return result
57
+ else:
58
+ error_text = await response.text()
59
+ return None
60
+ except Exception as e:
61
+ return None
62
+
63
+ async def get_schedules(self):
64
+ """Get list of schedules for which pricing data is available"""
65
+ endpoint = "api/market-prices/v1/schedules"
66
+ return await self.get(endpoint)
67
+
68
+ async def get_nodes(self):
69
+ """Get list of GXP/GIP nodes supported by this API"""
70
+ endpoint = "api/market-prices/v1/nodes"
71
+ return await self.get(endpoint)
72
+
73
+ async def get_schedule_prices(self, schedule='RTD', market_type='E', nodes=None,
74
+ back=None, forward=None, from_date=None, to_date=None,
75
+ island=None, offset=0):
76
+ """Get prices for a single schedule
77
+ Args:
78
+ schedule: Schedule type (e.g. 'RTD' for Real Time Dispatch)
79
+ market_type: 'E' for energy prices, 'R' for reserve prices
80
+ nodes: List of node IDs to filter by
81
+ back: Number of trading periods to look back (1-48)
82
+ forward: Number of trading periods to look ahead (1-48)
83
+ from_date: Start datetime (RFC3339 format)
84
+ to_date: End datetime (RFC3339 format)
85
+ island: Filter by island ('NI' or 'SI')
86
+ offset: Pagination offset
87
+ """
88
+ endpoint = f"api/market-prices/v1/schedules/{schedule}/prices"
89
+ params = {
90
+ 'marketType': market_type,
91
+ 'offset': offset
92
+ }
93
+ if nodes:
94
+ params['nodes'] = ','.join(nodes) if isinstance(nodes, list) else nodes
95
+ if back:
96
+ params['back'] = min(back, 48)
97
+ if forward:
98
+ params['forward'] = min(forward, 48)
99
+ if from_date:
100
+ params['from'] = from_date
101
+ if to_date:
102
+ params['to'] = to_date
103
+ if island:
104
+ params['island'] = island
105
+ query = '&'.join(f"{k}={v}" for k, v in params.items())
106
+ return await self.get(f"{endpoint}?{query}")
107
+
108
+ async def get_multi_schedule_prices(self, schedules, market_type='E', nodes=None,
109
+ back=None, forward=None, from_date=None,
110
+ to_date=None, island=None, offset=0):
111
+ """Get prices across multiple schedules
112
+ Args:
113
+ schedules: List of schedule types
114
+ market_type: 'E' for energy prices, 'R' for reserve prices
115
+ nodes: List of node IDs to filter by
116
+ back: Number of trading periods to look back (1-48)
117
+ forward: Number of trading periods to look ahead (1-48)
118
+ from_date: Start datetime (RFC3339 format)
119
+ to_date: End datetime (RFC3339 format)
120
+ island: Filter by island ('NI' or 'SI')
121
+ offset: Pagination offset
122
+ """
123
+ endpoint = "api/market-prices/v1/prices"
124
+ params = {
125
+ 'schedules': ','.join(schedules) if isinstance(schedules, list) else schedules,
126
+ 'marketType': market_type,
127
+ 'offset': offset
128
+ }
129
+ if nodes:
130
+ params['nodes'] = ','.join(nodes) if isinstance(nodes, list) else nodes
131
+ if back:
132
+ params['back'] = min(back, 48)
133
+ if forward:
134
+ params['forward'] = min(forward, 48)
135
+ if from_date:
136
+ params['from'] = from_date
137
+ if to_date:
138
+ params['to'] = to_date
139
+ if island:
140
+ params['island'] = island
141
+ query = '&'.join(f"{k}={v}" for k, v in params.items())
142
+ return await self.get(f"{endpoint}?{query}")
143
+
144
+ def parse_prices(self, response):
145
+ """Parse API response into structured price dictionary
146
+ Returns dict in format:
147
+ {node: {trading_time_utc: {schedule: [price, last_run_utc]}}}
148
+ """
149
+ if not response:
150
+ return {}
151
+ prices = {}
152
+ for schedule_data in response:
153
+ schedule = schedule_data['schedule']
154
+ if 'prices' not in schedule_data:
155
+ continue
156
+ for price in schedule_data['prices']:
157
+ node = price['node']
158
+ trading_time = int(datetime.datetime.fromisoformat(
159
+ price['tradingDateTime'].replace('Z', '+00:00')
160
+ ).timestamp())
161
+ last_run = int(datetime.datetime.fromisoformat(
162
+ price['lastRunTime'].replace('Z', '+00:00')
163
+ ).timestamp())
164
+
165
+ if node not in prices:
166
+ prices[node] = {}
167
+ if trading_time not in prices[node]:
168
+ prices[node][trading_time] = {}
169
+ prices[node][trading_time][schedule] = [price['price'], last_run]
170
+
171
+ # Create RTD_forecast schedule
172
+ for node in prices:
173
+ for trading_time in prices[node]:
174
+ if 'RTD' in prices[node][trading_time]:
175
+ prices[node][trading_time]['RTD_forecast'] = prices[node][trading_time]['RTD']
176
+ else:
177
+ # Find most recent schedule by last run time
178
+ latest_schedule = None
179
+ latest_last_run = 0
180
+ for schedule in prices[node][trading_time]:
181
+ if prices[node][trading_time][schedule][1] > latest_last_run:
182
+ latest_last_run = prices[node][trading_time][schedule][1]
183
+ latest_schedule = schedule
184
+ if latest_schedule:
185
+ prices[node][trading_time]['RTD_forecast'] = \
186
+ prices[node][trading_time][latest_schedule]
187
+
188
+ return prices
189
+
190
+ def print_prices(self, prices):
191
+ """Print prices in structured format with time information"""
192
+ now = datetime.datetime.now(datetime.timezone.utc)
193
+ now_ts = now.timestamp()
194
+ for node in sorted(prices.keys()):
195
+ print(f" - {node}:")
196
+ for trading_time in sorted(prices[node].keys()):
197
+ time_diff = trading_time - now_ts
198
+ # For future times on 30 minute boundaries, show half-hour intervals
199
+ if time_diff > 0 and trading_time % 1800 == 0:
200
+ half_hours = int(time_diff / 1800)
201
+ time_str = f"(+{half_hours})"
202
+ else:
203
+ # For past times or non-30min intervals, show actual time
204
+ dt = datetime.datetime.fromtimestamp(trading_time,
205
+ datetime.timezone.utc)
206
+ time_str = f"({dt.strftime('%Y-%m-%d %H:%M:%S')})"
207
+
208
+ print(f" - Trading Time UTC: {trading_time} {time_str}")
209
+ for schedule in sorted(prices[node][trading_time].keys()):
210
+ if schedule in ['RTD', 'PRSS', 'PRSL', 'RTD_forecast']:
211
+ price, last_run = prices[node][trading_time][schedule]
212
+ last_run_dt = datetime.datetime.fromtimestamp(
213
+ last_run, datetime.timezone.utc)
214
+ print(f" {schedule:12} Price: {price:8.2f}, "
215
+ f"Last Run: {last_run_dt.strftime('%H:%M:%S')}")
216
+
217
+
218
+ async def main(config):
219
+ async with WitsAPIClient(url=config['url'],client_id=config['client_id'],
220
+ client_secret=config['client_secret']) as client:
221
+ token = await client.get_token()
222
+ if token:
223
+ multi_prices = await client.get_multi_schedule_prices(
224
+ schedules=config['schedules'],
225
+ nodes=config['gxp_list'],
226
+ back=config['back'],
227
+ forward=config['forward']
228
+ )
229
+ if multi_prices:
230
+ prices_dict = client.parse_prices(multi_prices)
231
+ client.print_prices(prices_dict)
232
+ await asyncio.sleep(1)
233
+
234
+
235
+ CONFIG = {
236
+ 'url': 'https://api.electricityinfo.co.nz',
237
+ 'gxp_list': ['MAT1101', 'CYD2201', 'BEN2201'],
238
+ 'schedules': ['RTD', 'PRSS', 'PRSL'],
239
+ 'back': 2,
240
+ 'forward': 72,
241
+ 'client_id': 'xx',
242
+ 'client_secret': 'xx'
243
+ }
244
+
245
+ if __name__ == "__main__":
246
+ asyncio.run(main(CONFIG))
pymscada/module_config.py CHANGED
@@ -150,6 +150,18 @@ def create_module_registry():
150
150
  module_class='pymscada.iodrivers.snmp_client:SnmpClient',
151
151
  tags=False
152
152
  ),
153
+ ModuleDefinition(
154
+ name='witsapi',
155
+ help='poll WITS GXP pricing real time dispatch and forecast',
156
+ module_class='pymscada.iodrivers.witsapi:WitsAPIClient',
157
+ tags=False,
158
+ epilog=dedent("""
159
+ WITS_CLIENT_ID and WITS_CLIENT_SECRET can be set in the wits.yaml
160
+ or as environment variables:
161
+ vi ~/.bashrc
162
+ export WITS_CLIENT_ID='your_client_id'
163
+ export WITS_CLIENT_SECRET='your_client_secret'""")
164
+ ),
153
165
  ModuleDefinition(
154
166
  name='console',
155
167
  help='interactive bus console',
pymscada/tag.py CHANGED
@@ -7,7 +7,6 @@ bus_id is python id(), 0 is null pointer in c, 0 is local bus.
7
7
  import time
8
8
  import array
9
9
  import logging
10
- from typing import TypedDict, Union, Optional, Type, List
11
10
 
12
11
  TYPES = {
13
12
  'int': int,
@@ -19,28 +18,6 @@ TYPES = {
19
18
  }
20
19
 
21
20
 
22
- def tag_for_web(tagname: str, tag: dict):
23
- """Correct tag dictionary in place to be suitable for web client."""
24
- tag['name'] = tagname
25
- tag['id'] = None
26
- if 'desc' not in tag:
27
- tag['desc'] = tagname
28
- if 'multi' in tag:
29
- tag['type'] = 'int'
30
- else:
31
- if 'type' not in tag:
32
- tag['type'] = 'float'
33
- else:
34
- if tag['type'] not in TYPES:
35
- tag['type'] = 'str'
36
- if tag['type'] == 'int':
37
- tag['dp'] = 0
38
- elif tag['type'] == 'float' and 'dp' not in tag:
39
- tag['dp'] = 2
40
- elif tag['type'] == 'str' and 'dp' in tag:
41
- del tag['dp']
42
-
43
-
44
21
  class UniqueTag(type):
45
22
  """Super Tag class only create unique tags for unique tag names."""
46
23
 
@@ -311,20 +288,3 @@ class Tag(metaclass=UniqueTag):
311
288
  self.values = array.array('d')
312
289
  else:
313
290
  raise TypeError(f"shard invalid {self.name} not int, float")
314
-
315
-
316
- class TagInfo(TypedDict, total=False):
317
- """Type definition for tag information dictionary."""
318
- name: str
319
- id: Optional[int]
320
- desc: str
321
- type: Union[str, Type[int], Type[float], Type[str], Type[list],
322
- Type[dict], Type[bytes]]
323
- multi: Optional[List[str]]
324
- min: Optional[Union[float, int]]
325
- max: Optional[Union[float, int]]
326
- deadband: Optional[Union[float, int]]
327
- units: Optional[str]
328
- dp: Optional[int]
329
- format: Optional[str]
330
- init: Optional[Union[int, float, str]]
pymscada/www_server.py CHANGED
@@ -8,10 +8,32 @@ import socket
8
8
  import time
9
9
  from pymscada.bus_client import BusClient
10
10
  import pymscada.protocol_constants as pc
11
- from pymscada.tag import Tag, tag_for_web, TYPES
11
+ from pymscada.tag import Tag, TYPES
12
12
  from pymscada_html import get_html_file
13
13
 
14
14
 
15
+ def standardise_tag_info(tagname: str, tag: dict):
16
+ """Correct tag dictionary in place to be suitable for web client."""
17
+ tag['name'] = tagname
18
+ tag['id'] = None
19
+ if 'desc' not in tag:
20
+ tag['desc'] = tagname
21
+ if 'multi' in tag:
22
+ tag['type'] = 'int'
23
+ else:
24
+ if 'type' not in tag:
25
+ tag['type'] = 'float'
26
+ else:
27
+ if tag['type'] not in TYPES:
28
+ tag['type'] = 'str'
29
+ if tag['type'] == 'int':
30
+ tag['dp'] = 0
31
+ elif tag['type'] == 'float' and 'dp' not in tag:
32
+ tag['dp'] = 2
33
+ elif tag['type'] == 'str' and 'dp' in tag:
34
+ del tag['dp']
35
+
36
+
15
37
  class Interface():
16
38
  """Provide an interface between web client rta and the action."""
17
39
 
@@ -36,12 +58,12 @@ class WSHandler():
36
58
 
37
59
  def __init__(self, ws: web.WebSocketResponse, pages: dict,
38
60
  tag_info: dict[str, Tag], do_rta, interface: Interface,
39
- webclient: dict):
61
+ config: dict):
40
62
  """Create callbacks to monitor tag values."""
41
63
  self.ws = ws
42
64
  self.pages = pages
43
65
  self.tag_info = tag_info
44
- self.webclient = webclient
66
+ self.config = config
45
67
  self.tag_by_id: dict[int, Tag] = {}
46
68
  self.tag_by_name: dict[str, Tag] = {}
47
69
  self.queue = asyncio.Queue()
@@ -143,7 +165,7 @@ class WSHandler():
143
165
 
144
166
  def notify_id(self, tag: Tag):
145
167
  """Must be done here."""
146
- logging.info(f'{self.rta_id}: send id to webclient for {tag.name}')
168
+ logging.info(f'{self.rta_id}: send id to browser for {tag.name}')
147
169
  self.tag_info[tag.name]['id'] = tag.id
148
170
  self.tag_by_id[tag.id] = tag
149
171
  self.tag_by_name[tag.name] = tag
@@ -172,7 +194,7 @@ class WSHandler():
172
194
  """Run while the connection is active and don't return."""
173
195
  send_queue = asyncio.create_task(self.send_queue())
174
196
  self.queue.put_nowait(
175
- (False, {'type': 'webclient', 'payload': self.webclient}))
197
+ (False, {'type': 'config', 'payload': self.config}))
176
198
  self.queue.put_nowait(
177
199
  (False, {'type': 'pages', 'payload': self.pages}))
178
200
  async for msg in self.ws:
@@ -213,7 +235,7 @@ class WSHandler():
213
235
 
214
236
 
215
237
  class WwwServer:
216
- """Connect to bus on bus_ip:bus_port, serve on ip:port for webclient."""
238
+ """Connect to bus on bus_ip:bus_port, serve on ip:port for webserver."""
217
239
 
218
240
  def __init__(
219
241
  self,
@@ -224,15 +246,15 @@ class WwwServer:
224
246
  get_path: str | None = None,
225
247
  tag_info: dict = {},
226
248
  pages: dict = {},
227
- webclient: dict = {},
249
+ config: dict = {},
228
250
  serve_path: str | None = None,
229
251
  www_tag: str = '__wwwserver__'
230
252
  ) -> None:
231
253
  """
232
- Connect to bus on bus_ip:bus_port, serve on ip:port for webclient.
254
+ Connect to bus on bus_ip:bus_port, serve on ip:port for webserver.
233
255
 
234
- Serves the webclient files at /, as a relative path. The webclient uses
235
- a websocket connection to request and set tag values and subscribe to
256
+ Serves the files at /, as a relative path. The browser uses a
257
+ websocket connection to request and set tag values and subscribe to
236
258
  changes.
237
259
 
238
260
  Event loop must be running.
@@ -263,10 +285,10 @@ class WwwServer:
263
285
  self.get_path = get_path
264
286
  self.serve_path = Path(serve_path) if serve_path else None
265
287
  for tagname, tag in tag_info.items():
266
- tag_for_web(tagname, tag)
288
+ standardise_tag_info(tagname, tag)
267
289
  self.tag_info = tag_info
268
290
  self.pages = pages
269
- self.webclient = webclient
291
+ self.config = config
270
292
  self.interface = Interface(www_tag)
271
293
 
272
294
  async def redirect_handler(self, _request: web.Request):
@@ -305,7 +327,7 @@ class WwwServer:
305
327
  ws = web.WebSocketResponse(max_msg_size=0) # disables max message size
306
328
  await ws.prepare(request)
307
329
  await WSHandler(ws, self.pages, self.tag_info, self.busclient.rta,
308
- self.interface, self.webclient).connection_active()
330
+ self.interface, self.config).connection_active()
309
331
  await ws.close()
310
332
  logging.info(f"WS closed {peer}")
311
333
  return ws
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: pymscada
3
- Version: 0.2.0rc3
3
+ Version: 0.2.0rc6
4
4
  Summary: Shared tag value SCADA with python backup and Angular UI
5
5
  Author-email: Jamie Walton <jamie@walton.net.nz>
6
6
  License: GPL-3.0-or-later
@@ -17,10 +17,11 @@ Description-Content-Type: text/markdown
17
17
  License-File: LICENSE
18
18
  Requires-Dist: PyYAML>=6.0.1
19
19
  Requires-Dist: aiohttp>=3.8.5
20
- Requires-Dist: pymscada-html==0.2.0rc2
20
+ Requires-Dist: pymscada-html==0.2.0rc4
21
21
  Requires-Dist: cerberus>=1.3.5
22
22
  Requires-Dist: pycomm3>=1.2.14
23
23
  Requires-Dist: pysnmplib>=5.0.24
24
+ Dynamic: license-file
24
25
 
25
26
  # pymscada
26
27
  #### [Docs](https://github.com/jamie0walton/pymscada/blob/main/docs/README.md)
@@ -1,23 +1,23 @@
1
1
  pymscada/__init__.py,sha256=NV_cIIwe66Ugp8ns426rtfJIIyskWbqwImD9p_5p0bQ,739
2
2
  pymscada/__main__.py,sha256=WcyVlrYOoDdktJhOoyubTOycMwpayksFdxwelRU5xpQ,272
3
- pymscada/alarms.py,sha256=BM6R-PFf7EazhGdGHJ1XeAcCoi83Ro9XvEl_9cp-iCI,7326
3
+ pymscada/alarms.py,sha256=iGRf_ybe2KGiFpN7EwAHzqiCyyPYaWgL5zd34SSdrU8,12895
4
4
  pymscada/bus_client.py,sha256=ROShMcR2-y_i5CIvPxRdCRr-NpnMANjKFdLjKjMTRwo,9117
5
5
  pymscada/bus_server.py,sha256=k7ht2SAr24Oab0hBOPeW4NRDF_RK-F46iE0cMzh7K4w,12323
6
6
  pymscada/checkout.py,sha256=RLuCMTEuUI7pp1hIRAUPbo8xYFta8TjArelx0SD4gOY,3897
7
7
  pymscada/config.py,sha256=vwGxieaJBYXiHNQEOYVDFaPuGmnUlCnbNm_W9bugKlc,1851
8
- pymscada/console.py,sha256=zM6TNXJY8ROcVzO3UBzi1qDSiit7mfLr2YprRoxv2oQ,8824
8
+ pymscada/console.py,sha256=EEsJLCvn8AFimN8qGNilX0ks6t3OFcGW5nw6OVAXfac,8850
9
9
  pymscada/files.py,sha256=iouEOPfEkVI0Qbbf1p-L324Y04zSrynVypLW0-1MThA,2499
10
- pymscada/history.py,sha256=qXbCkHQC_j7HF9b1PvnF93d4HvW9ODJeSc9tvy63o10,11513
10
+ pymscada/history.py,sha256=7UEOeMnlSMv0LoWTqLWx7QwOW1FZZ4wAvzH6v6b0_vI,11592
11
11
  pymscada/main.py,sha256=d6EnK4-tEcvM5AqMHYhvqlnSh-E_wd0Tuxk-kXYSiDw,1854
12
12
  pymscada/misc.py,sha256=0Cj6OFhQonyhyk9x0BG5MiS-6EPk_w6zvavt8o_Hlf0,622
13
- pymscada/module_config.py,sha256=r1JBjOXtMk7n09kvlvnmA3d_BySEmJpdefFhRNKPdAY,8824
13
+ pymscada/module_config.py,sha256=sEoLUhMUFJfalH3CbhNPIqQd1bAL7bWCyPSMUKs6HJ4,9370
14
14
  pymscada/opnotes.py,sha256=MKM51IrB93B2-kgoTzlpOLpaMYs-7rPQHWmRLME-hQQ,7952
15
15
  pymscada/periodic.py,sha256=MLlL93VLvFqBBgjO1Us1t0aLHTZ5BFdW0B__G02T1nQ,1235
16
16
  pymscada/protocol_constants.py,sha256=lPJ4JEgFJ_puJjTym83EJIOw3UTUFbuFMwg3ohyUAGY,2414
17
17
  pymscada/samplers.py,sha256=t0IscgsCm5YByioOZ6aOKMO_guDFS_wxnJSiOGKI4Nw,2583
18
- pymscada/tag.py,sha256=G0P9t3G6heQ1kBWPnc0u8qN6UvjG4xtyI9w_KqGr4Q0,10682
18
+ pymscada/tag.py,sha256=hTRxogw8BXAi1OJpM1Lhx4KKMqZ53y7D5KcCycO7fRQ,9471
19
19
  pymscada/validate.py,sha256=fPMlP6RscYuTIgdEJjJ0ZZI0OyVSat1lpqg254wqpdE,13140
20
- pymscada/www_server.py,sha256=rz6Yictg_Ony8zW76An5bv_cZN5aLi-QW3B85uWMgXQ,13152
20
+ pymscada/www_server.py,sha256=NfvX9jbVWY2qxWM6TfWUcwsCY7lR-dkty1nCOXyoWTA,13747
21
21
  pymscada/demo/README.md,sha256=iNcVbCTkq-d4agLV-979lNRaqf_hbJCn3OFzY-6qfU8,880
22
22
  pymscada/demo/__init__.py,sha256=WsDDgkWnZBJbt2-cJCdc2NvRAv_T4a7WOC1Q0k_l0gI,29
23
23
  pymscada/demo/accuweather.yaml,sha256=Fk4rV0S8jCau0173QCzKW8TdUbc4crYVi0aD8fPLNgU,322
@@ -47,6 +47,7 @@ pymscada/demo/pymscada-opnotes.service,sha256=TlrTRgP3rzrlXT8isAGT_Wy38ScDjT1Vvn
47
47
  pymscada/demo/pymscada-wwwserver.service,sha256=7Qy2wsMmFEsQn-b5mgAcsrAQZgXynkv8SpHD6hLvRGc,370
48
48
  pymscada/demo/snmpclient.yaml,sha256=z8iACrFvMftYUtqGrRjPZYZTpn7aOXI-Kp675NAM8cU,2013
49
49
  pymscada/demo/tags.yaml,sha256=9xydsQriKT0lNAW533rz-FMVgoedn6Lwc50AnNig7-k,2733
50
+ pymscada/demo/wits.yaml,sha256=B8F136jvLIYU8t-pOdsEU_j97qMo3RgGQ1Rs4ExhmeE,289
50
51
  pymscada/demo/wwwserver.yaml,sha256=mmwvSLUXUDCIPaHeCJdCETAp9Cc4wb5CuK_aGv01KWk,2759
51
52
  pymscada/demo/__pycache__/__init__.cpython-311.pyc,sha256=tpxZoW429YA-2mbwzOlhBmbSTcbvTJqgKCfDRMrhEJE,195
52
53
  pymscada/iodrivers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -61,15 +62,17 @@ pymscada/iodrivers/ping_client.py,sha256=UOQgUfoIcYqy5VvKyJ8XGHHjeSRTfjmrhyWEojh
61
62
  pymscada/iodrivers/ping_map.py,sha256=EbOteqfEYKIOMqPymROJ4now2If-ekEj6jnM5hthoSA,1403
62
63
  pymscada/iodrivers/snmp_client.py,sha256=66-IDzddeKcSnqOzNXIZ8wuuAqhIxZjyLNrDwDvHCvw,2708
63
64
  pymscada/iodrivers/snmp_map.py,sha256=sDdIR5ZPAETpozDfBt_XQiZ-f4t99UCPlzj7BxFxQyM,2369
65
+ pymscada/iodrivers/witsapi.py,sha256=Ga6JpEQRUciT_LxWW36LsVGkUeWjModtzPoWYIzyzHs,8381
66
+ pymscada/iodrivers/witsapi_POC.py,sha256=dQcR2k1wsLb_cnNqvAB4kJ7FdY0BlcnxiMoepr28Ars,10132
64
67
  pymscada/pdf/__init__.py,sha256=WsDDgkWnZBJbt2-cJCdc2NvRAv_T4a7WOC1Q0k_l0gI,29
65
68
  pymscada/pdf/one.pdf,sha256=eoJ45DrAjVZrwmwdA_EAz1fwmT44eRnt_tkc2pmMrKY,1488
66
69
  pymscada/pdf/two.pdf,sha256=TAuW5yLU1_wfmTH_I5ezHwY0pxhCVuZh3ixu0kwmJwE,1516
67
70
  pymscada/pdf/__pycache__/__init__.cpython-311.pyc,sha256=4KTfXrV9bGDbTIEv-zgIj_LvzLbVTj77lEC1wzMh9e0,194
68
71
  pymscada/tools/snmp_client2.py,sha256=pdn5dYyEv4q-ubA0zQ8X-3tQDYxGC7f7Xexa7QPaL40,1675
69
72
  pymscada/tools/walk.py,sha256=OgpprUbKLhEWMvJGfU1ckUt_PFEpwZVOD8HucCgzmOc,1625
70
- pymscada-0.2.0rc3.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
71
- pymscada-0.2.0rc3.dist-info/METADATA,sha256=ezP-4JEdjL8qwHVyi0q9xrpgaft2BJNez1Ai96k1e5k,2371
72
- pymscada-0.2.0rc3.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
73
- pymscada-0.2.0rc3.dist-info/entry_points.txt,sha256=2UJBi8jrqujnerrcXcq4F8GHJYVDt26sacXl94t3sd8,56
74
- pymscada-0.2.0rc3.dist-info/top_level.txt,sha256=LxIB-zrtgObJg0fgdGZXBkmNKLDYHfaH1Hw2YP2ZMms,9
75
- pymscada-0.2.0rc3.dist-info/RECORD,,
73
+ pymscada-0.2.0rc6.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
74
+ pymscada-0.2.0rc6.dist-info/METADATA,sha256=QdAhMpE2X7HmhVO0H6XvXjnjQO7JZka7zlWoXKle2vg,2393
75
+ pymscada-0.2.0rc6.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
76
+ pymscada-0.2.0rc6.dist-info/entry_points.txt,sha256=2UJBi8jrqujnerrcXcq4F8GHJYVDt26sacXl94t3sd8,56
77
+ pymscada-0.2.0rc6.dist-info/top_level.txt,sha256=LxIB-zrtgObJg0fgdGZXBkmNKLDYHfaH1Hw2YP2ZMms,9
78
+ pymscada-0.2.0rc6.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.6.0)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5