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 +244 -84
- pymscada/console.py +4 -3
- pymscada/demo/wits.yaml +17 -0
- pymscada/history.py +16 -15
- pymscada/iodrivers/witsapi.py +217 -0
- pymscada/iodrivers/witsapi_POC.py +246 -0
- pymscada/module_config.py +12 -0
- pymscada/tag.py +0 -40
- pymscada/www_server.py +35 -13
- {pymscada-0.2.0rc3.dist-info → pymscada-0.2.0rc6.dist-info}/METADATA +4 -3
- {pymscada-0.2.0rc3.dist-info → pymscada-0.2.0rc6.dist-info}/RECORD +15 -12
- {pymscada-0.2.0rc3.dist-info → pymscada-0.2.0rc6.dist-info}/WHEEL +1 -1
- {pymscada-0.2.0rc3.dist-info → pymscada-0.2.0rc6.dist-info}/entry_points.txt +0 -0
- {pymscada-0.2.0rc3.dist-info → pymscada-0.2.0rc6.dist-info/licenses}/LICENSE +0 -0
- {pymscada-0.2.0rc3.dist-info → pymscada-0.2.0rc6.dist-info}/top_level.txt +0 -0
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.
|
|
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:
|
|
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.
|
|
58
|
-
self.
|
|
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
|
-
|
|
224
|
+
standardise_tag_info(tagname, tag)
|
|
225
|
+
if 'alarm' not in tag or tag['type'] not in (int, float):
|
|
63
226
|
continue
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def
|
|
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
|
-
'
|
|
120
|
-
'
|
|
121
|
-
'
|
|
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
|
-
'
|
|
130
|
-
'
|
|
131
|
-
'
|
|
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,
|
|
146
|
-
'VALUES(:date_ms, :
|
|
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
|
-
'
|
|
154
|
-
'
|
|
155
|
-
'
|
|
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
|
-
'
|
|
172
|
-
'
|
|
173
|
-
'
|
|
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
|
|
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
|
|
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
|
-
|
|
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):
|
pymscada/demo/wits.yaml
ADDED
|
@@ -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,
|
|
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
|
-
|
|
41
|
-
"""
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
|
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': '
|
|
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
|
|
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
|
-
|
|
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
|
|
254
|
+
Connect to bus on bus_ip:bus_port, serve on ip:port for webserver.
|
|
233
255
|
|
|
234
|
-
Serves the
|
|
235
|
-
|
|
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
|
-
|
|
288
|
+
standardise_tag_info(tagname, tag)
|
|
267
289
|
self.tag_info = tag_info
|
|
268
290
|
self.pages = pages
|
|
269
|
-
self.
|
|
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.
|
|
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
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: pymscada
|
|
3
|
-
Version: 0.2.
|
|
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.
|
|
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=
|
|
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=
|
|
8
|
+
pymscada/console.py,sha256=EEsJLCvn8AFimN8qGNilX0ks6t3OFcGW5nw6OVAXfac,8850
|
|
9
9
|
pymscada/files.py,sha256=iouEOPfEkVI0Qbbf1p-L324Y04zSrynVypLW0-1MThA,2499
|
|
10
|
-
pymscada/history.py,sha256=
|
|
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=
|
|
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=
|
|
18
|
+
pymscada/tag.py,sha256=hTRxogw8BXAi1OJpM1Lhx4KKMqZ53y7D5KcCycO7fRQ,9471
|
|
19
19
|
pymscada/validate.py,sha256=fPMlP6RscYuTIgdEJjJ0ZZI0OyVSat1lpqg254wqpdE,13140
|
|
20
|
-
pymscada/www_server.py,sha256=
|
|
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.
|
|
71
|
-
pymscada-0.2.
|
|
72
|
-
pymscada-0.2.
|
|
73
|
-
pymscada-0.2.
|
|
74
|
-
pymscada-0.2.
|
|
75
|
-
pymscada-0.2.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|