pymscada 0.2.0rc3__py3-none-any.whl → 0.2.0rc4__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 +218 -54
- pymscada/history.py +16 -15
- pymscada/tag.py +0 -40
- pymscada/www_server.py +35 -13
- {pymscada-0.2.0rc3.dist-info → pymscada-0.2.0rc4.dist-info}/METADATA +2 -2
- {pymscada-0.2.0rc3.dist-info → pymscada-0.2.0rc4.dist-info}/RECORD +10 -10
- {pymscada-0.2.0rc3.dist-info → pymscada-0.2.0rc4.dist-info}/WHEEL +1 -1
- {pymscada-0.2.0rc3.dist-info → pymscada-0.2.0rc4.dist-info}/LICENSE +0 -0
- {pymscada-0.2.0rc3.dist-info → pymscada-0.2.0rc4.dist-info}/entry_points.txt +0 -0
- {pymscada-0.2.0rc3.dist-info → pymscada-0.2.0rc4.dist-info}/top_level.txt +0 -0
pymscada/alarms.py
CHANGED
|
@@ -5,13 +5,87 @@ import socket
|
|
|
5
5
|
import time
|
|
6
6
|
import atexit
|
|
7
7
|
from pymscada.bus_client import BusClient
|
|
8
|
-
from pymscada.tag import Tag,
|
|
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
|
+
NORMAL = 0
|
|
16
|
+
ALARM = 1
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def standardise_tag_info(tagname: str, tag: dict):
|
|
20
|
+
"""Correct tag dictionary in place to be suitable for modules."""
|
|
21
|
+
tag['name'] = tagname
|
|
22
|
+
tag['id'] = None
|
|
23
|
+
if 'desc' not in tag:
|
|
24
|
+
logging.warning(f"Tag {tagname} has no description, using name")
|
|
25
|
+
tag['desc'] = tag['name']
|
|
26
|
+
if 'multi' in tag:
|
|
27
|
+
tag['type'] = int
|
|
28
|
+
else:
|
|
29
|
+
if 'type' not in tag:
|
|
30
|
+
tag['type'] = float
|
|
31
|
+
else:
|
|
32
|
+
if tag['type'] not in TYPES:
|
|
33
|
+
tag['type'] = str
|
|
34
|
+
else:
|
|
35
|
+
tag['type'] = TYPES[tag['type']]
|
|
36
|
+
if 'dp' not in tag:
|
|
37
|
+
if tag['type'] == int:
|
|
38
|
+
tag['dp'] = 0
|
|
39
|
+
else:
|
|
40
|
+
tag['dp'] = 2
|
|
41
|
+
if 'units' not in tag:
|
|
42
|
+
tag['units'] = ''
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Alarm:
|
|
46
|
+
"""Manages multiple alarm conditions for a single tag."""
|
|
47
|
+
def __init__(self, tag: Tag, conditions: str | list[str]):
|
|
48
|
+
"""Initialize alarm with tag and condition(s)."""
|
|
49
|
+
if tag.type not in (int, float):
|
|
50
|
+
raise ValueError(f"Alarms only supported for numeric types, not {tag.type}")
|
|
51
|
+
|
|
52
|
+
self.tag = tag
|
|
53
|
+
self.tests: list[tuple[str, callable, float]] = []
|
|
54
|
+
|
|
55
|
+
# Handle both string and list conditions
|
|
56
|
+
if isinstance(conditions, str):
|
|
57
|
+
conditions = [conditions]
|
|
58
|
+
|
|
59
|
+
for condition in conditions:
|
|
60
|
+
operator_str, value = condition.split(' ')
|
|
61
|
+
self.tests.append((
|
|
62
|
+
condition,
|
|
63
|
+
{
|
|
64
|
+
'==': (lambda x, y: x == y),
|
|
65
|
+
'<': (lambda x, y: x < y),
|
|
66
|
+
'>': (lambda x, y: x > y),
|
|
67
|
+
'<=': (lambda x, y: x <= y),
|
|
68
|
+
'>=': (lambda x, y: x >= y)
|
|
69
|
+
}[operator_str],
|
|
70
|
+
float(value)
|
|
71
|
+
))
|
|
72
|
+
|
|
73
|
+
def check_conditions(self, in_alarm: set[str]) -> list[tuple[str, bool, float, int]]:
|
|
74
|
+
"""Check all conditions and return list of changes.
|
|
75
|
+
Returns list of (alarm_ref, is_in_alarm, value, timestamp) for changed states."""
|
|
76
|
+
changes = []
|
|
77
|
+
for condition_str, test, value in self.tests:
|
|
78
|
+
alarm_ref = f"{self.tag.name} {condition_str}"
|
|
79
|
+
is_in_alarm = test(self.tag.value, value)
|
|
80
|
+
|
|
81
|
+
if is_in_alarm and alarm_ref not in in_alarm:
|
|
82
|
+
changes.append((alarm_ref, True, self.tag.value, self.tag.time_us))
|
|
83
|
+
|
|
84
|
+
elif not is_in_alarm and alarm_ref in in_alarm:
|
|
85
|
+
changes.append((alarm_ref, False, self.tag.value, self.tag.time_us))
|
|
86
|
+
|
|
87
|
+
return changes
|
|
88
|
+
|
|
15
89
|
|
|
16
90
|
class Alarms:
|
|
17
91
|
"""Connect to bus_ip:bus_port, store and provide Alarms."""
|
|
@@ -22,7 +96,7 @@ class Alarms:
|
|
|
22
96
|
bus_port: int | None = 1324,
|
|
23
97
|
db: str | None = None,
|
|
24
98
|
table: str = 'alarms',
|
|
25
|
-
tag_info:
|
|
99
|
+
tag_info: dict[str, dict] = {},
|
|
26
100
|
rta_tag: str = '__alarms__'
|
|
27
101
|
) -> None:
|
|
28
102
|
"""
|
|
@@ -56,59 +130,79 @@ class Alarms:
|
|
|
56
130
|
logging.warning(f'Alarms {bus_ip} {bus_port} {db} {rta_tag}')
|
|
57
131
|
self.connection = sqlite3.connect(db)
|
|
58
132
|
self.tags: dict[str, Tag] = {}
|
|
59
|
-
self.
|
|
60
|
-
self.in_alarm:
|
|
133
|
+
self.alarms: dict[str, Alarm] = {}
|
|
134
|
+
self.in_alarm: dict[str, int] = {}
|
|
61
135
|
for tagname, tag in tag_info.items():
|
|
62
|
-
|
|
136
|
+
standardise_tag_info(tagname, tag)
|
|
137
|
+
if 'alarm' not in tag or tag['type'] not in (int, float):
|
|
63
138
|
continue
|
|
64
139
|
self.tags[tagname] = Tag(tagname, tag['type'])
|
|
65
140
|
self.tags[tagname].desc = tag['desc']
|
|
141
|
+
self.tags[tagname].dp = tag['dp']
|
|
142
|
+
self.tags[tagname].units = tag['units']
|
|
66
143
|
self.tags[tagname].add_callback(self.alarm_cb)
|
|
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
|
-
]
|
|
144
|
+
self.alarms[tagname] = Alarm(self.tags[tagname], tag['alarm'])
|
|
78
145
|
self.table = table
|
|
79
146
|
self.cursor = self.connection.cursor()
|
|
80
147
|
self.busclient = BusClient(bus_ip, bus_port, module='Alarms')
|
|
81
148
|
self.rta = Tag(rta_tag, dict)
|
|
82
149
|
self.rta.value = {}
|
|
83
150
|
self.busclient.add_callback_rta(rta_tag, self.rta_cb)
|
|
84
|
-
self._init_table()
|
|
85
151
|
atexit.register(self.close)
|
|
86
152
|
|
|
87
|
-
def alarm_cb(self, tag):
|
|
153
|
+
def alarm_cb(self, tag: Tag):
|
|
88
154
|
"""Callback for alarm tags."""
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
155
|
+
if tag.name not in self.alarms:
|
|
156
|
+
return
|
|
157
|
+
changes = self.alarms[tag.name].check_conditions(self.in_alarm)
|
|
158
|
+
for alarm_ref, is_in_alarm, value, time_us in changes:
|
|
159
|
+
self._handle_alarm_change(
|
|
160
|
+
alarm_ref,
|
|
161
|
+
is_in_alarm,
|
|
162
|
+
tag,
|
|
163
|
+
value,
|
|
164
|
+
time_us
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def _handle_alarm_change(self, alarm_ref: str, is_in_alarm: bool,
|
|
168
|
+
tag: Tag, value: float, time_us: int):
|
|
169
|
+
"""Handle alarm state changes and database updates."""
|
|
170
|
+
if is_in_alarm:
|
|
171
|
+
logging.warning(f'Alarm {alarm_ref} {value}')
|
|
172
|
+
kind = ALM
|
|
173
|
+
state = ALARM
|
|
103
174
|
alarm_record = {
|
|
104
175
|
'action': 'ADD',
|
|
105
|
-
'date_ms': int(
|
|
106
|
-
'
|
|
107
|
-
'
|
|
108
|
-
'
|
|
176
|
+
'date_ms': int(time_us / 1000),
|
|
177
|
+
'tag_alm': alarm_ref,
|
|
178
|
+
'kind': kind,
|
|
179
|
+
'desc': f'{tag.desc} {value:.{tag.dp}f} {tag.units}',
|
|
180
|
+
'in_alm': state
|
|
109
181
|
}
|
|
110
182
|
self.rta_cb(alarm_record)
|
|
111
|
-
self.in_alarm.
|
|
183
|
+
self.in_alarm[alarm_ref] = self.rta.value['id']
|
|
184
|
+
else:
|
|
185
|
+
logging.info(f'No alarm {alarm_ref} {value}')
|
|
186
|
+
if alarm_ref in self.in_alarm:
|
|
187
|
+
# First update the existing alarm record to NORMAL
|
|
188
|
+
update_record = {
|
|
189
|
+
'action': 'UPDATE',
|
|
190
|
+
'id': self.in_alarm[alarm_ref],
|
|
191
|
+
'in_alm': NORMAL
|
|
192
|
+
}
|
|
193
|
+
self.rta_cb(update_record)
|
|
194
|
+
|
|
195
|
+
# Then add the RTN record
|
|
196
|
+
rtn_record = {
|
|
197
|
+
'action': 'ADD',
|
|
198
|
+
'date_ms': int(time_us / 1000),
|
|
199
|
+
'tag_alm': alarm_ref,
|
|
200
|
+
'kind': RTN,
|
|
201
|
+
'desc': f'{tag.desc} {value:.{tag.dp}f} {tag.units}',
|
|
202
|
+
'in_alm': NORMAL
|
|
203
|
+
}
|
|
204
|
+
self.rta_cb(rtn_record)
|
|
205
|
+
del self.in_alarm[alarm_ref]
|
|
112
206
|
|
|
113
207
|
def _init_table(self):
|
|
114
208
|
"""Initialize the database table schema."""
|
|
@@ -116,19 +210,39 @@ class Alarms:
|
|
|
116
210
|
'CREATE TABLE IF NOT EXISTS ' + self.table +
|
|
117
211
|
'(id INTEGER PRIMARY KEY ASC, '
|
|
118
212
|
'date_ms INTEGER, '
|
|
119
|
-
'
|
|
120
|
-
'
|
|
121
|
-
'
|
|
213
|
+
'tag_alm TEXT, '
|
|
214
|
+
'kind INTEGER, '
|
|
215
|
+
'desc TEXT, '
|
|
216
|
+
'in_alm INTEGER)'
|
|
122
217
|
)
|
|
123
218
|
self.cursor.execute(query)
|
|
124
219
|
|
|
220
|
+
# Clear any existing ALARM states
|
|
221
|
+
try:
|
|
222
|
+
with self.connection:
|
|
223
|
+
# Update all alarm records to NORMAL
|
|
224
|
+
self.cursor.execute(
|
|
225
|
+
f'SELECT id, tag_alm FROM {self.table} WHERE in_alm = ?',
|
|
226
|
+
(ALARM,))
|
|
227
|
+
alarm_records = self.cursor.fetchall()
|
|
228
|
+
for record_id, tag_alm in alarm_records:
|
|
229
|
+
update_record = {
|
|
230
|
+
'action': 'UPDATE',
|
|
231
|
+
'id': record_id,
|
|
232
|
+
'in_alm': NORMAL
|
|
233
|
+
}
|
|
234
|
+
self.rta_cb(update_record)
|
|
235
|
+
except sqlite3.Error as e:
|
|
236
|
+
logging.error(f'Error clearing alarm states during startup: {e}')
|
|
237
|
+
|
|
125
238
|
# Add startup record using existing ADD functionality
|
|
126
239
|
startup_record = {
|
|
127
240
|
'action': 'ADD',
|
|
128
241
|
'date_ms': int(time.time() * 1000),
|
|
129
|
-
'
|
|
130
|
-
'
|
|
131
|
-
'
|
|
242
|
+
'tag_alm': self.rta.name,
|
|
243
|
+
'kind': INF,
|
|
244
|
+
'desc': 'Alarm logging started',
|
|
245
|
+
'in_alm': NORMAL
|
|
132
246
|
}
|
|
133
247
|
self.rta_cb(startup_record)
|
|
134
248
|
|
|
@@ -142,20 +256,41 @@ class Alarms:
|
|
|
142
256
|
with self.connection:
|
|
143
257
|
self.cursor.execute(
|
|
144
258
|
f'INSERT INTO {self.table} '
|
|
145
|
-
'(date_ms,
|
|
146
|
-
'VALUES(:date_ms, :
|
|
259
|
+
'(date_ms, tag_alm, kind, desc, in_alm) '
|
|
260
|
+
'VALUES(:date_ms, :tag_alm, :kind, :desc, :in_alm) '
|
|
147
261
|
'RETURNING *;',
|
|
148
262
|
request)
|
|
149
263
|
res = self.cursor.fetchone()
|
|
150
264
|
self.rta.value = {
|
|
151
265
|
'id': res[0],
|
|
152
266
|
'date_ms': res[1],
|
|
153
|
-
'
|
|
154
|
-
'
|
|
155
|
-
'
|
|
267
|
+
'tag_alm': res[2],
|
|
268
|
+
'kind': res[3],
|
|
269
|
+
'desc': res[4],
|
|
270
|
+
'in_alm': res[5]
|
|
156
271
|
}
|
|
157
272
|
except sqlite3.IntegrityError as error:
|
|
158
273
|
logging.warning(f'Alarms rta_cb {error}')
|
|
274
|
+
elif request['action'] == 'UPDATE':
|
|
275
|
+
try:
|
|
276
|
+
logging.info(f'update {request}')
|
|
277
|
+
with self.connection:
|
|
278
|
+
self.cursor.execute(
|
|
279
|
+
f'UPDATE {self.table} SET in_alm = :in_alm '
|
|
280
|
+
'WHERE id = :id RETURNING *;',
|
|
281
|
+
request)
|
|
282
|
+
res = self.cursor.fetchone()
|
|
283
|
+
if res:
|
|
284
|
+
self.rta.value = {
|
|
285
|
+
'id': res[0],
|
|
286
|
+
'date_ms': res[1],
|
|
287
|
+
'tag_alm': res[2],
|
|
288
|
+
'kind': res[3],
|
|
289
|
+
'desc': res[4],
|
|
290
|
+
'in_alm': res[5]
|
|
291
|
+
}
|
|
292
|
+
except sqlite3.IntegrityError as error:
|
|
293
|
+
logging.warning(f'Alarms rta_cb update {error}')
|
|
159
294
|
elif request['action'] == 'HISTORY':
|
|
160
295
|
try:
|
|
161
296
|
logging.info(f'history {request}')
|
|
@@ -168,25 +303,54 @@ class Alarms:
|
|
|
168
303
|
'__rta_id__': request['__rta_id__'],
|
|
169
304
|
'id': res[0],
|
|
170
305
|
'date_ms': res[1],
|
|
171
|
-
'
|
|
172
|
-
'
|
|
173
|
-
'
|
|
306
|
+
'tag_alm': res[2],
|
|
307
|
+
'kind': res[3],
|
|
308
|
+
'desc': res[4],
|
|
309
|
+
'in_alm': res[5]
|
|
174
310
|
}
|
|
175
311
|
except sqlite3.IntegrityError as error:
|
|
176
312
|
logging.warning(f'Alarms rta_cb {error}')
|
|
313
|
+
elif request['action'] == 'BULK HISTORY':
|
|
314
|
+
try:
|
|
315
|
+
logging.info(f'bulk history {request}')
|
|
316
|
+
with self.connection:
|
|
317
|
+
self.cursor.execute(
|
|
318
|
+
f'SELECT * FROM {self.table} WHERE date_ms > :date_ms '
|
|
319
|
+
'ORDER BY -date_ms;', request)
|
|
320
|
+
results = list(self.cursor.fetchall())
|
|
321
|
+
self.rta.value = {'__rta_id__': request['__rta_id__'],
|
|
322
|
+
'data': results}
|
|
323
|
+
except sqlite3.IntegrityError as error:
|
|
324
|
+
logging.warning(f'Alarms rta_cb {error}')
|
|
325
|
+
elif request['action'] == 'IN ALARM':
|
|
326
|
+
self.rta.value = {'__rta_id__': request['__rta_id__'],
|
|
327
|
+
'data': {'in_alarm': list(self.in_alarm)}}
|
|
177
328
|
|
|
178
329
|
async def start(self):
|
|
179
330
|
"""Async startup."""
|
|
180
331
|
await self.busclient.start()
|
|
332
|
+
self._init_table()
|
|
181
333
|
|
|
182
334
|
def close(self):
|
|
183
335
|
"""Clean shutdown of alarms logging."""
|
|
336
|
+
for alarm_ref, record_id in self.in_alarm.items():
|
|
337
|
+
update_record = {
|
|
338
|
+
'action': 'UPDATE',
|
|
339
|
+
'id': record_id,
|
|
340
|
+
'in_alm': NORMAL
|
|
341
|
+
}
|
|
342
|
+
try:
|
|
343
|
+
self.rta_cb(update_record)
|
|
344
|
+
except sqlite3.Error as e:
|
|
345
|
+
logging.error(f'Error clearing alarm {alarm_ref}: {e}')
|
|
346
|
+
|
|
184
347
|
shutdown_record = {
|
|
185
348
|
'action': 'ADD',
|
|
186
349
|
'date_ms': int(time.time() * 1000),
|
|
187
|
-
'
|
|
188
|
-
'
|
|
189
|
-
'
|
|
350
|
+
'tag_alm': self.rta.name,
|
|
351
|
+
'kind': INF,
|
|
352
|
+
'desc': 'Alarm logging stopped',
|
|
353
|
+
'in_alm': NORMAL
|
|
190
354
|
}
|
|
191
355
|
try:
|
|
192
356
|
self.rta_cb(shutdown_record)
|
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(
|
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
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: pymscada
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.0rc4
|
|
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,7 +17,7 @@ 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
|
|
@@ -1,13 +1,13 @@
|
|
|
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=KiGws42xu8Bcmy_hhZkKoYhfNzUzICSV7f8xQcY7M8c,13715
|
|
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
8
|
pymscada/console.py,sha256=zM6TNXJY8ROcVzO3UBzi1qDSiit7mfLr2YprRoxv2oQ,8824
|
|
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
13
|
pymscada/module_config.py,sha256=r1JBjOXtMk7n09kvlvnmA3d_BySEmJpdefFhRNKPdAY,8824
|
|
@@ -15,9 +15,9 @@ 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
|
|
@@ -67,9 +67,9 @@ pymscada/pdf/two.pdf,sha256=TAuW5yLU1_wfmTH_I5ezHwY0pxhCVuZh3ixu0kwmJwE,1516
|
|
|
67
67
|
pymscada/pdf/__pycache__/__init__.cpython-311.pyc,sha256=4KTfXrV9bGDbTIEv-zgIj_LvzLbVTj77lEC1wzMh9e0,194
|
|
68
68
|
pymscada/tools/snmp_client2.py,sha256=pdn5dYyEv4q-ubA0zQ8X-3tQDYxGC7f7Xexa7QPaL40,1675
|
|
69
69
|
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.
|
|
70
|
+
pymscada-0.2.0rc4.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
71
|
+
pymscada-0.2.0rc4.dist-info/METADATA,sha256=F9_ab9hHNXQhJaoVfC7CtuEPKgMh5eDAaZOt8GdIr1M,2371
|
|
72
|
+
pymscada-0.2.0rc4.dist-info/WHEEL,sha256=A3WOREP4zgxI0fKrHUG8DC8013e3dK3n7a6HDbcEIwE,91
|
|
73
|
+
pymscada-0.2.0rc4.dist-info/entry_points.txt,sha256=2UJBi8jrqujnerrcXcq4F8GHJYVDt26sacXl94t3sd8,56
|
|
74
|
+
pymscada-0.2.0rc4.dist-info/top_level.txt,sha256=LxIB-zrtgObJg0fgdGZXBkmNKLDYHfaH1Hw2YP2ZMms,9
|
|
75
|
+
pymscada-0.2.0rc4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|