pymscada 0.2.0__py3-none-any.whl → 0.2.6b9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pymscada/__init__.py +8 -2
- pymscada/alarms.py +179 -60
- pymscada/bus_client.py +12 -2
- pymscada/bus_server.py +18 -9
- pymscada/callout.py +198 -101
- pymscada/config.py +20 -1
- pymscada/console.py +19 -6
- pymscada/demo/__pycache__/__init__.cpython-311.pyc +0 -0
- pymscada/demo/callout.yaml +13 -4
- pymscada/demo/files.yaml +3 -2
- pymscada/demo/openweather.yaml +3 -11
- pymscada/demo/piapi.yaml +15 -0
- pymscada/demo/pymscada-io-piapi.service +15 -0
- pymscada/demo/pymscada-io-sms.service +18 -0
- pymscada/demo/sms.yaml +11 -0
- pymscada/demo/tags.yaml +3 -0
- pymscada/demo/witsapi.yaml +6 -8
- pymscada/demo/wwwserver.yaml +15 -0
- pymscada/files.py +1 -0
- pymscada/history.py +4 -5
- pymscada/iodrivers/logix_map.py +1 -1
- pymscada/iodrivers/modbus_client.py +189 -21
- pymscada/iodrivers/modbus_map.py +17 -2
- pymscada/iodrivers/piapi.py +133 -0
- pymscada/iodrivers/sms.py +212 -0
- pymscada/iodrivers/witsapi.py +26 -35
- pymscada/module_config.py +24 -18
- pymscada/opnotes.py +38 -16
- pymscada/pdf/__pycache__/__init__.cpython-311.pyc +0 -0
- pymscada/tag.py +6 -7
- pymscada/tools/get_history.py +147 -0
- pymscada/www_server.py +2 -1
- {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/METADATA +2 -2
- {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/RECORD +38 -32
- pymscada/validate.py +0 -451
- {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/WHEEL +0 -0
- {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/entry_points.txt +0 -0
- {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/licenses/LICENSE +0 -0
- {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/top_level.txt +0 -0
pymscada/callout.py
CHANGED
|
@@ -25,41 +25,72 @@ Operation:
|
|
|
25
25
|
"""
|
|
26
26
|
|
|
27
27
|
ALM = 0
|
|
28
|
+
RTN = 1
|
|
29
|
+
|
|
28
30
|
IDLE = 0
|
|
29
31
|
NEW_ALM = 1
|
|
30
32
|
CALLOUT = 2
|
|
31
33
|
|
|
34
|
+
SENT = 0
|
|
35
|
+
REMIND = 1
|
|
32
36
|
|
|
33
|
-
def normalise_callees(callees: list | None):
|
|
34
|
-
"""Normalise callees to include delay_ms and remove delay."""
|
|
35
|
-
if callees is None:
|
|
36
|
-
return []
|
|
37
|
-
for callee in callees:
|
|
38
|
-
if 'sms' not in callee:
|
|
39
|
-
raise ValueError(f'Callee {callee["name"]} has no sms number')
|
|
40
|
-
if 'delay' in callee:
|
|
41
|
-
callee['delay_ms'] = callee['delay'] * 1000
|
|
42
|
-
del callee['delay']
|
|
43
|
-
else:
|
|
44
|
-
callee['delay_ms'] = 0
|
|
45
|
-
if 'group' not in callee:
|
|
46
|
-
callee['group'] = []
|
|
47
|
-
callees.sort(key=lambda x: x['delay_ms'])
|
|
48
|
-
return callees
|
|
49
37
|
|
|
38
|
+
class CalloutCallee:
|
|
39
|
+
"""Track status of callee for callout."""
|
|
50
40
|
|
|
51
|
-
def
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
41
|
+
def __init__(self, callee: dict):
|
|
42
|
+
if not isinstance(callee, dict) or 'name' not in callee or \
|
|
43
|
+
'sms' not in callee:
|
|
44
|
+
logging.warning(f'Callee malformed {callee}')
|
|
45
|
+
return
|
|
46
|
+
self.name = callee['name']
|
|
47
|
+
self.sms = callee['sms']
|
|
48
|
+
self.role = callee.get('role', '')
|
|
49
|
+
self.group = callee.get('group', '')
|
|
50
|
+
self.delay_ms = 0
|
|
51
|
+
|
|
52
|
+
def set_role(self, role: str, delay_ms: int):
|
|
53
|
+
self.role = role
|
|
54
|
+
self.delay_ms = delay_ms
|
|
55
|
+
|
|
56
|
+
def set_group(self, group: str):
|
|
57
|
+
self.group = group
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class CalloutAlarm:
|
|
61
|
+
"""Track status of alarm for callout."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, alm_tag_value):
|
|
64
|
+
if not isinstance(alm_tag_value, dict) or \
|
|
65
|
+
'date_ms' not in alm_tag_value or \
|
|
66
|
+
'alarm_string' not in alm_tag_value or \
|
|
67
|
+
'desc' not in alm_tag_value or \
|
|
68
|
+
'group' not in alm_tag_value or \
|
|
69
|
+
'kind' not in alm_tag_value:
|
|
70
|
+
logging.warning(f'alarms_cb malformed {alm_tag_value}')
|
|
71
|
+
raise ValueError(f'alarms_cb malformed {alm_tag_value}')
|
|
72
|
+
self.date_ms = alm_tag_value['date_ms']
|
|
73
|
+
self.alarm_string = alm_tag_value['alarm_string']
|
|
74
|
+
self.desc = alm_tag_value['desc']
|
|
75
|
+
self.group = alm_tag_value['group']
|
|
76
|
+
self.sent: set[CalloutCallee] = set()
|
|
77
|
+
self.remind: set[CalloutCallee] = set()
|
|
78
|
+
|
|
79
|
+
def callee_in_group(self, callee: CalloutCallee, groups: dict):
|
|
80
|
+
if callee.group == '':
|
|
81
|
+
return True
|
|
82
|
+
if callee.group not in groups:
|
|
83
|
+
return False
|
|
84
|
+
group = groups[callee.group]
|
|
85
|
+
if 'tagnames' in group:
|
|
86
|
+
for tagname in group['tagnames']:
|
|
87
|
+
if self.alarm_string.startswith(tagname):
|
|
88
|
+
return True
|
|
89
|
+
if 'groups' in group:
|
|
90
|
+
for group in group['groups']:
|
|
91
|
+
if self.group in group:
|
|
92
|
+
return True
|
|
93
|
+
return False
|
|
63
94
|
|
|
64
95
|
|
|
65
96
|
class Callout:
|
|
@@ -71,10 +102,13 @@ class Callout:
|
|
|
71
102
|
bus_port: int | None = 1324,
|
|
72
103
|
rta_tag: str = '__callout__',
|
|
73
104
|
alarms_tag: str | None = None,
|
|
105
|
+
sms_send_tag: str | None = None,
|
|
106
|
+
sms_recv_tag: str | None = None,
|
|
74
107
|
ack_tag: str | None = None,
|
|
75
108
|
status_tag: str | None = None,
|
|
76
|
-
callees: list
|
|
77
|
-
groups:
|
|
109
|
+
callees: list = [],
|
|
110
|
+
groups: dict = {},
|
|
111
|
+
escalation: dict = {}
|
|
78
112
|
) -> None:
|
|
79
113
|
"""
|
|
80
114
|
Connect to bus_ip:bus_port, monitor alarms and manage callouts.
|
|
@@ -99,106 +133,169 @@ class Callout:
|
|
|
99
133
|
raise ValueError('bus_port must be between 1024 and 65535')
|
|
100
134
|
if not isinstance(rta_tag, str) or not rta_tag:
|
|
101
135
|
raise ValueError('rta_tag must be a non-empty string')
|
|
136
|
+
if alarms_tag is None:
|
|
137
|
+
raise ValueError('alarms_tag must be defined')
|
|
138
|
+
if sms_send_tag is None:
|
|
139
|
+
raise ValueError('sms_send_tag must be defined')
|
|
140
|
+
if sms_recv_tag is None:
|
|
141
|
+
raise ValueError('sms_recv_tag must be defined')
|
|
102
142
|
|
|
103
|
-
logging.warning(f'Callout {bus_ip} {bus_port} {rta_tag}'
|
|
104
|
-
|
|
105
|
-
self.callees =
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
self.
|
|
143
|
+
logging.warning(f'Callout {bus_ip} {bus_port} {rta_tag} '
|
|
144
|
+
f'{sms_send_tag} {sms_recv_tag}')
|
|
145
|
+
self.callees: list[CalloutCallee] = []
|
|
146
|
+
for callee in callees:
|
|
147
|
+
self.callees.append(CalloutCallee(callee))
|
|
148
|
+
self.groups = groups
|
|
149
|
+
self.escalation = escalation
|
|
150
|
+
self.alarms: list[CalloutAlarm] = []
|
|
151
|
+
self.alarms_tag = Tag(alarms_tag, dict)
|
|
152
|
+
self.alarms_tag.add_callback(self.alarms_cb)
|
|
153
|
+
self.sms_recv_tag = Tag(sms_recv_tag, dict)
|
|
154
|
+
self.sms_recv_tag.add_callback(self.sms_recv_cb)
|
|
155
|
+
self.sms_send_tag = Tag(sms_send_tag, dict)
|
|
156
|
+
if ack_tag is not None:
|
|
157
|
+
self.ack_tag = Tag(ack_tag, int)
|
|
158
|
+
self.ack_tag.add_callback(self.ack_cb)
|
|
110
159
|
if status_tag is not None:
|
|
111
160
|
self.status = Tag(status_tag, int)
|
|
112
|
-
|
|
161
|
+
else:
|
|
162
|
+
self.status = None
|
|
113
163
|
self.busclient = BusClient(bus_ip, bus_port, module='Callout')
|
|
114
|
-
self.busclient.add_callback_rta(alarms_tag, self.alarms_cb)
|
|
115
|
-
self.busclient.add_callback_rta(ack_tag, self.ack_cb)
|
|
116
164
|
self.rta = Tag(rta_tag, dict)
|
|
117
|
-
self.
|
|
165
|
+
self.set_rta_value(rta_id=0)
|
|
118
166
|
self.busclient.add_callback_rta(rta_tag, self.rta_cb)
|
|
119
167
|
self.periodic = Periodic(self.periodic_cb, 1.0)
|
|
120
168
|
|
|
121
|
-
def
|
|
169
|
+
def set_rta_value(self, rta_id: int):
|
|
170
|
+
"""Publish the current configuration to the RTA tag."""
|
|
171
|
+
callees = [{'name': callee.name, 'sms': callee.sms, 'role': callee.role,
|
|
172
|
+
'group': callee.group, 'delay_ms': callee.delay_ms}
|
|
173
|
+
for callee in self.callees]
|
|
174
|
+
self.rta.value = {'__rta_id__': rta_id,
|
|
175
|
+
'callees': callees,
|
|
176
|
+
'groups': self.groups,
|
|
177
|
+
'escalation': list(self.escalation.keys())}
|
|
178
|
+
|
|
179
|
+
def alarms_cb(self, alm_tag):
|
|
122
180
|
"""Handle alarm messages from alarms.py."""
|
|
123
|
-
if
|
|
181
|
+
if alm_tag.value['kind'] != ALM:
|
|
124
182
|
return
|
|
125
|
-
alarm =
|
|
126
|
-
'date_ms': request['date_ms'],
|
|
127
|
-
'alarm_string': request['alarm_string'],
|
|
128
|
-
'desc': request['desc'],
|
|
129
|
-
'group': request['group'],
|
|
130
|
-
'sent': {}
|
|
131
|
-
}
|
|
183
|
+
alarm = CalloutAlarm(alm_tag.value)
|
|
132
184
|
self.alarms.append(alarm)
|
|
133
185
|
logging.info(f'Added alarm to list: {alarm}')
|
|
186
|
+
if self.status is not None and self.status.value == IDLE:
|
|
187
|
+
self.status.value = NEW_ALM
|
|
134
188
|
|
|
135
|
-
def ack_cb(self,
|
|
189
|
+
def ack_cb(self, ack_tag):
|
|
136
190
|
"""Handle ACK requests for alarm acknowledgment."""
|
|
137
|
-
if
|
|
191
|
+
if ack_tag.value == 1:
|
|
138
192
|
self.alarms = []
|
|
193
|
+
if self.status is not None:
|
|
194
|
+
self.status.value = IDLE
|
|
195
|
+
logging.info('ACK: all alarms cleared')
|
|
196
|
+
|
|
197
|
+
def sms_recv_cb(self, sms_recv_tag: Tag):
|
|
198
|
+
"""Handle SMS messages from the modem."""
|
|
199
|
+
logging.info(f'sms_recv_cb {sms_recv_tag.value}')
|
|
200
|
+
if not isinstance(sms_recv_tag.value, dict) or \
|
|
201
|
+
'number' not in sms_recv_tag.value or \
|
|
202
|
+
'message' not in sms_recv_tag.value:
|
|
203
|
+
logging.warning(f'sms_recv_cb invalid {sms_recv_tag.value}')
|
|
139
204
|
return
|
|
140
|
-
|
|
141
|
-
for
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
remaining_alarms = []
|
|
154
|
-
for alarm in self.alarms:
|
|
155
|
-
alarm_group = alarm_in_callee_group(alarm['group'], group)
|
|
156
|
-
if alarm_group is None:
|
|
157
|
-
remaining_alarms.append(alarm)
|
|
158
|
-
self.alarms = remaining_alarms
|
|
205
|
+
number = sms_recv_tag.value['number']
|
|
206
|
+
name = [callee.name for callee in self.callees if callee.sms == number][0]
|
|
207
|
+
message = sms_recv_tag.value['message'][:2].upper()
|
|
208
|
+
if message in ['OK', 'AC', 'TH']:
|
|
209
|
+
new_alarms = []
|
|
210
|
+
for alarm in self.alarms:
|
|
211
|
+
if name in alarm.sent:
|
|
212
|
+
continue
|
|
213
|
+
new_alarms.append(alarm)
|
|
214
|
+
self.alarms = new_alarms
|
|
215
|
+
if self.status is not None:
|
|
216
|
+
self.status.value = IDLE
|
|
217
|
+
logging.info('ACK: all alarms cleared')
|
|
159
218
|
|
|
160
219
|
def rta_cb(self, request):
|
|
161
220
|
"""Handle RTA requests for callout configuration."""
|
|
162
221
|
logging.info(f'rta_cb {request}')
|
|
163
222
|
if 'action' not in request:
|
|
164
223
|
logging.warning(f'rta_cb malformed {request}')
|
|
165
|
-
|
|
166
|
-
|
|
224
|
+
return
|
|
225
|
+
if request['action'] == 'GET CONFIG':
|
|
226
|
+
self.set_rta_value(rta_id=request['__rta_id__'])
|
|
167
227
|
elif request['action'] == 'MODIFY':
|
|
168
228
|
for callee in self.callees:
|
|
169
|
-
if callee
|
|
170
|
-
if '
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
229
|
+
if callee.name == request['name']:
|
|
230
|
+
if not 'role' in request and not 'group' in request:
|
|
231
|
+
logging.warning(f'rta_cb invalid request: {request}')
|
|
232
|
+
return
|
|
233
|
+
role = request['role']
|
|
234
|
+
group = request['group']
|
|
235
|
+
valid_role = role == '' or role in self.escalation
|
|
236
|
+
valid_group = group == '' or group in self.groups
|
|
237
|
+
if not valid_role or not valid_group:
|
|
238
|
+
logging.warning(f'rta_cb MODIFY invalid: {request}')
|
|
239
|
+
return
|
|
240
|
+
callee.set_role(role, self.escalation.get(role, 0))
|
|
241
|
+
callee.set_group(group)
|
|
242
|
+
self.set_rta_value(rta_id=0)
|
|
243
|
+
|
|
244
|
+
def check_callee_messages(self, callee: CalloutCallee, time_ms):
|
|
245
|
+
if callee.role == '':
|
|
246
|
+
return ''
|
|
247
|
+
callee_alarms = set()
|
|
248
|
+
for alarm in self.alarms:
|
|
249
|
+
if alarm.callee_in_group(callee, self.groups):
|
|
250
|
+
callee_alarms.add(alarm)
|
|
251
|
+
notify_message = ''
|
|
252
|
+
remind_message = ''
|
|
253
|
+
notify_ms = time_ms - callee.delay_ms
|
|
254
|
+
remind_ms = notify_ms - 60000
|
|
255
|
+
notify = []
|
|
256
|
+
remind = []
|
|
257
|
+
for alarm in callee_alarms:
|
|
258
|
+
if not callee.name in alarm.sent and notify_ms > alarm.date_ms:
|
|
259
|
+
notify_message += f'{alarm.desc}\n'
|
|
260
|
+
alarm.sent.add(callee.name)
|
|
261
|
+
else:
|
|
262
|
+
notify.append(alarm)
|
|
263
|
+
if not callee.name in alarm.remind and remind_ms > alarm.date_ms:
|
|
264
|
+
remind_message += f'{alarm.desc}\n'
|
|
265
|
+
alarm.remind.add(callee.name)
|
|
266
|
+
else:
|
|
267
|
+
remind.append(alarm)
|
|
268
|
+
message = ''
|
|
269
|
+
if notify_message != '':
|
|
270
|
+
message += f'ALARMS\n{notify_message}'
|
|
271
|
+
for alarm in notify:
|
|
272
|
+
message += f'{alarm.desc}\n'
|
|
273
|
+
alarm.sent.add(callee.name)
|
|
274
|
+
if remind_message != '':
|
|
275
|
+
message += f'REMINDERS\n{remind_message}'
|
|
276
|
+
for alarm in remind:
|
|
277
|
+
message += f'{alarm.desc}\n'
|
|
278
|
+
alarm.remind.add(callee.name)
|
|
279
|
+
return message
|
|
280
|
+
|
|
281
|
+
def check_alarms(self):
|
|
282
|
+
"""Check alarms for each callee."""
|
|
178
283
|
time_ms = int(time.time() * 1000)
|
|
179
284
|
for callee in self.callees:
|
|
180
|
-
message =
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
alarm['sent'][callee['name']] = time_ms
|
|
191
|
-
if count > 0:
|
|
192
|
-
send_message = f"{alarm_group} {count} unack alarms\n{message}"
|
|
193
|
-
logging.warning(f'Callout to {callee["name"]}: {send_message}')
|
|
194
|
-
self.rta.value = {'action': 'SMS', 'sms': callee['sms'],
|
|
195
|
-
'message': send_message}
|
|
196
|
-
if self.status is not None:
|
|
197
|
-
self.status.value = CALLOUT
|
|
285
|
+
message = self.check_callee_messages(callee, time_ms)
|
|
286
|
+
if message == '':
|
|
287
|
+
continue
|
|
288
|
+
logging.info(f'Sending message to {callee.name}: {message}')
|
|
289
|
+
self.sms_send_tag.value = {
|
|
290
|
+
'number': callee.sms,
|
|
291
|
+
'message': message
|
|
292
|
+
}
|
|
293
|
+
if self.status is not None:
|
|
294
|
+
self.status.value = CALLOUT
|
|
198
295
|
|
|
199
296
|
async def periodic_cb(self):
|
|
200
297
|
"""Periodic callback to check alarms and send callouts."""
|
|
201
|
-
self.
|
|
298
|
+
self.check_alarms()
|
|
202
299
|
|
|
203
300
|
async def start(self):
|
|
204
301
|
"""Async startup."""
|
pymscada/config.py
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import importlib.resources
|
|
3
3
|
from importlib.abc import Traversable
|
|
4
4
|
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
5
7
|
from pathlib import Path
|
|
6
8
|
from yaml import safe_load_all, YAMLError
|
|
7
9
|
from pymscada import demo, pdf
|
|
@@ -38,6 +40,21 @@ def get_pdf_files() -> list[Traversable]:
|
|
|
38
40
|
return files
|
|
39
41
|
|
|
40
42
|
|
|
43
|
+
def _expand_env_vars(value):
|
|
44
|
+
"""Recursively expand environment variables in config values."""
|
|
45
|
+
if isinstance(value, str):
|
|
46
|
+
pattern = re.compile(r'\$\{([^}]+)\}')
|
|
47
|
+
def replace_env(match):
|
|
48
|
+
env_var = match.group(1)
|
|
49
|
+
return os.environ.get(env_var, match.group(0))
|
|
50
|
+
return pattern.sub(replace_env, value)
|
|
51
|
+
elif isinstance(value, dict):
|
|
52
|
+
return {k: _expand_env_vars(v) for k, v in value.items()}
|
|
53
|
+
elif isinstance(value, list):
|
|
54
|
+
return [_expand_env_vars(item) for item in value]
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
41
58
|
class Config(dict):
|
|
42
59
|
"""Read config from yaml file."""
|
|
43
60
|
|
|
@@ -55,7 +72,9 @@ class Config(dict):
|
|
|
55
72
|
with open(fp, 'r') as fh:
|
|
56
73
|
try:
|
|
57
74
|
for data in safe_load_all(fh):
|
|
75
|
+
if '__vars__' in data:
|
|
76
|
+
del data['__vars__']
|
|
58
77
|
for x in data:
|
|
59
|
-
self[x] = data[x]
|
|
78
|
+
self[x] = _expand_env_vars(data[x])
|
|
60
79
|
except YAMLError as e:
|
|
61
80
|
raise SystemExit(f'failed to load {filename} {e}')
|
pymscada/console.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Interactive console."""
|
|
2
2
|
import asyncio
|
|
3
3
|
import logging
|
|
4
|
+
import os
|
|
4
5
|
import sys
|
|
5
6
|
from pymscada.bus_client import BusClient
|
|
6
7
|
from pymscada.tag import Tag
|
|
@@ -123,6 +124,10 @@ class ConsoleWriter:
|
|
|
123
124
|
"""Init."""
|
|
124
125
|
self.edit = None
|
|
125
126
|
self.cursor = 0
|
|
127
|
+
# Make stdout non-blocking to avoid BlockingIOError in async context
|
|
128
|
+
import fcntl
|
|
129
|
+
flags = fcntl.fcntl(sys.stdout.fileno(), fcntl.F_GETFL)
|
|
130
|
+
fcntl.fcntl(sys.stdout.fileno(), fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
|
126
131
|
|
|
127
132
|
def write(self, data: bytes):
|
|
128
133
|
"""Stream writer, primarily for logging."""
|
|
@@ -132,22 +137,30 @@ class ConsoleWriter:
|
|
|
132
137
|
ln = EC.cr_clr + data + b'\r\n'
|
|
133
138
|
if self.edit is not None:
|
|
134
139
|
ln += EC.cr_clr + self.edit + EC.mv_left + cursor_str
|
|
135
|
-
|
|
136
|
-
|
|
140
|
+
try:
|
|
141
|
+
os.write(sys.stdout.fileno(), ln)
|
|
142
|
+
except BlockingIOError:
|
|
143
|
+
# If stdout buffer is full, skip this write to avoid blocking
|
|
144
|
+
pass
|
|
137
145
|
|
|
138
146
|
def edit_line(self, edit: bytes, cursor: int):
|
|
139
147
|
"""Update the edit line and cursor position."""
|
|
140
148
|
self.edit = edit
|
|
141
149
|
if self.edit is None:
|
|
142
|
-
|
|
143
|
-
|
|
150
|
+
try:
|
|
151
|
+
os.write(sys.stdout.fileno(), b'\r\n')
|
|
152
|
+
except BlockingIOError:
|
|
153
|
+
pass
|
|
144
154
|
return
|
|
145
155
|
self.cursor = cursor
|
|
146
156
|
ln = EC.cr_clr + self.edit + EC.mv_left
|
|
147
157
|
if self.cursor > 0:
|
|
148
158
|
ln += b'\x1b[' + str(self.cursor).encode() + b'C'
|
|
149
|
-
|
|
150
|
-
|
|
159
|
+
try:
|
|
160
|
+
os.write(sys.stdout.fileno(), ln)
|
|
161
|
+
except BlockingIOError:
|
|
162
|
+
# If stdout buffer is full, skip this write to avoid blocking
|
|
163
|
+
pass
|
|
151
164
|
|
|
152
165
|
|
|
153
166
|
class Console:
|
|
Binary file
|
pymscada/demo/callout.yaml
CHANGED
|
@@ -2,8 +2,15 @@ bus_ip: 127.0.0.1
|
|
|
2
2
|
bus_port: 1324
|
|
3
3
|
rta_tag: __callout__
|
|
4
4
|
alarms_tag: __alarms__
|
|
5
|
+
sms_send_tag: __sms_send__
|
|
6
|
+
sms_recv_tag: __sms_recv__
|
|
5
7
|
ack_tag: SI_Alarm_Ack
|
|
6
8
|
status_tag: SO_Alarm_Status
|
|
9
|
+
escalation:
|
|
10
|
+
- OnCall: 60000
|
|
11
|
+
- Backup 1: 180000
|
|
12
|
+
- Backup 2: 300000
|
|
13
|
+
- Escalate: 600000
|
|
7
14
|
callees:
|
|
8
15
|
- name: A name
|
|
9
16
|
sms: A number
|
|
@@ -11,7 +18,9 @@ callees:
|
|
|
11
18
|
- name: B name
|
|
12
19
|
sms: B number
|
|
13
20
|
groups:
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
Wind:
|
|
22
|
+
tagnames: [Mt, Fh]
|
|
23
|
+
System:
|
|
24
|
+
groups: [__system__, Comms]
|
|
25
|
+
Test:
|
|
26
|
+
tagnames: [alarm]
|
pymscada/demo/files.yaml
CHANGED
pymscada/demo/openweather.yaml
CHANGED
|
@@ -2,7 +2,8 @@ bus_ip: 127.0.0.1
|
|
|
2
2
|
bus_port: 1324
|
|
3
3
|
proxy:
|
|
4
4
|
api:
|
|
5
|
-
|
|
5
|
+
# tagnames are assigned by location_parameter, i.e. Murupara_Temp
|
|
6
|
+
api_key: ${MSCADA_OPENWEATHERMAP_API_KEY}
|
|
6
7
|
units: metric
|
|
7
8
|
locations:
|
|
8
9
|
Murupara:
|
|
@@ -13,13 +14,4 @@ api:
|
|
|
13
14
|
- Temp
|
|
14
15
|
- WindSpeed
|
|
15
16
|
- WindDir
|
|
16
|
-
- Rain
|
|
17
|
-
tags:
|
|
18
|
-
- Murupara_Temp
|
|
19
|
-
- Murupara_WindSpeed
|
|
20
|
-
- Murupara_WindDir
|
|
21
|
-
- Murupara_Rain
|
|
22
|
-
- Murupara_Temp_03
|
|
23
|
-
- Murupara_WindSpeed_03
|
|
24
|
-
- Murupara_WindDir_03
|
|
25
|
-
- Murupara_Rain_03
|
|
17
|
+
- Rain
|
pymscada/demo/piapi.yaml
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=pymscada - OSI PI client
|
|
3
|
+
BindsTo=pymscada-bus.service
|
|
4
|
+
After=pymscada-bus.service
|
|
5
|
+
|
|
6
|
+
[Service]
|
|
7
|
+
WorkingDirectory=__DIR__
|
|
8
|
+
ExecStart=__PYMSCADA__ witsapiclient --config __DIR__/config/piapi.yaml
|
|
9
|
+
Restart=always
|
|
10
|
+
RestartSec=5
|
|
11
|
+
User=__USER__
|
|
12
|
+
Group=__USER__
|
|
13
|
+
|
|
14
|
+
[Install]
|
|
15
|
+
WantedBy=multi-user.target
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=pymscada - SMS client
|
|
3
|
+
BindsTo=pymscada-bus.service
|
|
4
|
+
After=pymscada-bus.service
|
|
5
|
+
|
|
6
|
+
[Service]
|
|
7
|
+
Environment="MSCADA_SMS_IP=an_ip_address"
|
|
8
|
+
Environment="MSCADA_SMS_USERNAME=a_user"
|
|
9
|
+
Environment="MSCADA_SMS_PASSWORD=a_password"
|
|
10
|
+
WorkingDirectory=__DIR__
|
|
11
|
+
ExecStart=__PYMSCADA__ sms --config __DIR__/config/sms.yaml
|
|
12
|
+
Restart=always
|
|
13
|
+
RestartSec=5
|
|
14
|
+
User=root
|
|
15
|
+
Group=root
|
|
16
|
+
|
|
17
|
+
[Install]
|
|
18
|
+
WantedBy=multi-user.target
|
pymscada/demo/sms.yaml
ADDED
pymscada/demo/tags.yaml
CHANGED
pymscada/demo/witsapi.yaml
CHANGED
|
@@ -2,16 +2,14 @@ bus_ip: 127.0.0.1
|
|
|
2
2
|
bus_port: 1324
|
|
3
3
|
proxy:
|
|
4
4
|
api:
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
# tagnames are assigned by gxp_list
|
|
6
|
+
# i.e. MAT1101, MAT1101_Realtime, MAT1101_Forecast
|
|
7
|
+
url: ${MSCADA_WITS_URL}
|
|
8
|
+
client_id: ${MSCADA_WITS_CLIENT_ID}
|
|
9
|
+
client_secret: ${MSCADA_WITS_CLIENT_SECRET}
|
|
8
10
|
gxp_list:
|
|
9
11
|
- MAT1101
|
|
10
12
|
- CYD2201
|
|
11
13
|
- BEN2201
|
|
12
14
|
back: 1
|
|
13
|
-
forward: 12
|
|
14
|
-
tags:
|
|
15
|
-
- MAT1101_RTD
|
|
16
|
-
- CYD2201_RTD
|
|
17
|
-
- BEN2201_RTD
|
|
15
|
+
forward: 12
|
pymscada/demo/wwwserver.yaml
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
|
+
__vars__:
|
|
2
|
+
- &primary_color darkmagenta
|
|
3
|
+
- &secondary_color green
|
|
1
4
|
bus_ip: 127.0.0.1
|
|
2
5
|
bus_port: 1324
|
|
3
6
|
ip: 0.0.0.0
|
|
4
7
|
port: 8324
|
|
5
8
|
get_path:
|
|
6
9
|
serve_path: __HOME__
|
|
10
|
+
config:
|
|
11
|
+
primary_color: *primary_color
|
|
12
|
+
secondary_color: *secondary_color
|
|
13
|
+
mscada_link: http://127.0.0.1/
|
|
14
|
+
people:
|
|
15
|
+
- name: John Smith
|
|
16
|
+
email: john.smith@example.com
|
|
17
|
+
phone: '+64not_a_number'
|
|
7
18
|
pages:
|
|
8
19
|
- name: Notes
|
|
9
20
|
parent:
|
|
@@ -78,6 +89,10 @@ pages:
|
|
|
78
89
|
scale: mS
|
|
79
90
|
color: red
|
|
80
91
|
dp: 1
|
|
92
|
+
bands:
|
|
93
|
+
- series: [localhost_ping, google_ping]
|
|
94
|
+
fill: [green, 0.3]
|
|
95
|
+
dir: 1
|
|
81
96
|
- name: Weather
|
|
82
97
|
parent:
|
|
83
98
|
items:
|
pymscada/files.py
CHANGED
|
@@ -33,6 +33,7 @@ class Files():
|
|
|
33
33
|
self.rta = Tag(rta_tag, dict)
|
|
34
34
|
self.rta.value = {}
|
|
35
35
|
self.busclient.add_callback_rta(rta_tag, self.rta_cb)
|
|
36
|
+
self.busclient.add_tag(self.rta)
|
|
36
37
|
|
|
37
38
|
def rta_cb(self, request):
|
|
38
39
|
"""Respond to Request to Author and publish on rta_tag as needed."""
|