pymscada 0.1.11b10__py3-none-any.whl → 0.2.0__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/alarms.py +353 -0
- pymscada/bus_client.py +6 -5
- pymscada/bus_server.py +14 -2
- pymscada/callout.py +206 -0
- pymscada/checkout.py +87 -89
- pymscada/console.py +4 -3
- pymscada/demo/__pycache__/__init__.cpython-311.pyc +0 -0
- pymscada/demo/alarms.yaml +5 -0
- pymscada/demo/callout.yaml +17 -0
- pymscada/demo/openweather.yaml +1 -1
- pymscada/demo/pymscada-alarms.service +16 -0
- pymscada/demo/pymscada-callout.service +16 -0
- pymscada/demo/pymscada-io-openweather.service +15 -0
- pymscada/demo/{pymscada-io-accuweather.service → pymscada-io-witsapi.service} +2 -2
- pymscada/demo/tags.yaml +4 -0
- pymscada/demo/witsapi.yaml +17 -0
- pymscada/files.py +3 -3
- pymscada/history.py +64 -8
- pymscada/iodrivers/openweather.py +131 -50
- pymscada/iodrivers/witsapi.py +217 -0
- pymscada/iodrivers/witsapi_POC.py +246 -0
- pymscada/main.py +1 -1
- pymscada/module_config.py +40 -14
- pymscada/opnotes.py +81 -16
- pymscada/pdf/__pycache__/__init__.cpython-311.pyc +0 -0
- pymscada/protocol_constants.py +51 -33
- pymscada/tag.py +0 -22
- pymscada/www_server.py +72 -17
- {pymscada-0.1.11b10.dist-info → pymscada-0.2.0.dist-info}/METADATA +9 -7
- {pymscada-0.1.11b10.dist-info → pymscada-0.2.0.dist-info}/RECORD +38 -25
- {pymscada-0.1.11b10.dist-info → pymscada-0.2.0.dist-info}/WHEEL +2 -1
- {pymscada-0.1.11b10.dist-info → pymscada-0.2.0.dist-info}/entry_points.txt +0 -3
- pymscada-0.2.0.dist-info/top_level.txt +1 -0
- {pymscada-0.1.11b10.dist-info → pymscada-0.2.0.dist-info}/licenses/LICENSE +0 -0
pymscada/alarms.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Alarms handling."""
|
|
2
|
+
import logging
|
|
3
|
+
import sqlite3 # note that sqlite3 has blocking calls
|
|
4
|
+
import socket
|
|
5
|
+
import time
|
|
6
|
+
from pymscada.bus_client import BusClient
|
|
7
|
+
from pymscada.periodic import Periodic
|
|
8
|
+
from pymscada.tag import Tag, TYPES
|
|
9
|
+
|
|
10
|
+
ALM = 0
|
|
11
|
+
RTN = 1
|
|
12
|
+
ACT = 2
|
|
13
|
+
INF = 3
|
|
14
|
+
KIND = {
|
|
15
|
+
ALM: 'ALM',
|
|
16
|
+
RTN: 'RTN',
|
|
17
|
+
ACT: 'ACT',
|
|
18
|
+
INF: 'INF'
|
|
19
|
+
}
|
|
20
|
+
NORMAL = 0
|
|
21
|
+
ALARM = 1
|
|
22
|
+
|
|
23
|
+
"""
|
|
24
|
+
Database schema:
|
|
25
|
+
|
|
26
|
+
alarms contains an event log of changes as they occur, this
|
|
27
|
+
includes information on actions taken by the alarm system.
|
|
28
|
+
|
|
29
|
+
CREATE TABLE IF NOT EXISTS alarms (
|
|
30
|
+
id INTEGER PRIMARY KEY ASC,
|
|
31
|
+
date_ms INTEGER,
|
|
32
|
+
alarm_string TEXT,
|
|
33
|
+
kind INTEGER, # one of ALM, RTN, ACT, INF
|
|
34
|
+
desc TEXT,
|
|
35
|
+
group TEXT
|
|
36
|
+
)
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def standardise_tag_info(tagname: str, tag: dict):
|
|
41
|
+
"""Correct tag dictionary in place to be suitable for modules."""
|
|
42
|
+
tag['name'] = tagname
|
|
43
|
+
tag['id'] = None
|
|
44
|
+
if 'desc' not in tag:
|
|
45
|
+
logging.warning(f"Tag {tagname} has no description, using name")
|
|
46
|
+
tag['desc'] = tag['name']
|
|
47
|
+
if 'group' not in tag:
|
|
48
|
+
tag['group'] = ''
|
|
49
|
+
if 'multi' in tag:
|
|
50
|
+
tag['type'] = int
|
|
51
|
+
else:
|
|
52
|
+
if 'type' not in tag:
|
|
53
|
+
tag['type'] = float
|
|
54
|
+
else:
|
|
55
|
+
if tag['type'] not in TYPES:
|
|
56
|
+
tag['type'] = str
|
|
57
|
+
else:
|
|
58
|
+
tag['type'] = TYPES[tag['type']]
|
|
59
|
+
if 'dp' not in tag:
|
|
60
|
+
if tag['type'] == int:
|
|
61
|
+
tag['dp'] = 0
|
|
62
|
+
else:
|
|
63
|
+
tag['dp'] = 2
|
|
64
|
+
if 'units' not in tag:
|
|
65
|
+
tag['units'] = ''
|
|
66
|
+
if 'alarm' in tag:
|
|
67
|
+
if isinstance(tag['alarm'], str):
|
|
68
|
+
tag['alarm'] = [tag['alarm']]
|
|
69
|
+
if not isinstance(tag['alarm'], list):
|
|
70
|
+
logging.warning(f"Tag {tagname} has invalid alarm {tag['alarm']}")
|
|
71
|
+
del tag['alarm']
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def split_operator(alarm: str) -> dict:
|
|
75
|
+
"""Split alarm string into operator and value."""
|
|
76
|
+
tokens = alarm.split(' ')
|
|
77
|
+
alm_dict = {'for': 0}
|
|
78
|
+
if len(tokens) not in (2, 4):
|
|
79
|
+
raise ValueError(f"Invalid alarm {alarm}")
|
|
80
|
+
if tokens[0] not in ['>', '<', '==', '>=', '<=']:
|
|
81
|
+
raise ValueError(f"Invalid alarm {alarm}")
|
|
82
|
+
alm_dict['operator'] = tokens[0]
|
|
83
|
+
try:
|
|
84
|
+
alm_dict['value'] = float(tokens[1])
|
|
85
|
+
except ValueError:
|
|
86
|
+
raise ValueError(f"Invalid alarm {alarm}")
|
|
87
|
+
if len(tokens) == 4:
|
|
88
|
+
if tokens[2] != 'for':
|
|
89
|
+
raise ValueError(f"Invalid alarm {alarm}")
|
|
90
|
+
try:
|
|
91
|
+
alm_dict['for'] = int(tokens[3])
|
|
92
|
+
except ValueError:
|
|
93
|
+
raise ValueError(f"Invalid alarm {alarm}")
|
|
94
|
+
return alm_dict
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Alarm():
|
|
98
|
+
"""
|
|
99
|
+
Single alarm class.
|
|
100
|
+
|
|
101
|
+
Alarms are defined by a tag and a condition. Tags may have multiple
|
|
102
|
+
conditions, each combination of tag and condition is a separate Alarm.
|
|
103
|
+
|
|
104
|
+
Monitors tag value through the Tag callback. Tracks in alarm state.
|
|
105
|
+
Generates the ALM and RTN messages for Alarms to publish via rta_tag.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(self, tagname: str, tag: dict, alarm: str, group: str, rta_cb, alarms) -> None:
|
|
109
|
+
"""Initialize alarm with tag and condition(s)."""
|
|
110
|
+
self.alarm_id = f'{tagname} {alarm}'
|
|
111
|
+
self.tag = Tag(tagname, tag['type'])
|
|
112
|
+
self.tag.desc = tag['desc']
|
|
113
|
+
self.tag.dp = tag['dp']
|
|
114
|
+
self.tag.units = tag['units']
|
|
115
|
+
self.tag.add_callback(self.callback)
|
|
116
|
+
self.group = group
|
|
117
|
+
self.rta_cb = rta_cb
|
|
118
|
+
self.alarms = alarms
|
|
119
|
+
self.alarm = split_operator(alarm)
|
|
120
|
+
self.in_alarm = False
|
|
121
|
+
self.checking = False
|
|
122
|
+
|
|
123
|
+
def callback(self, tag: Tag):
|
|
124
|
+
"""Handle tag value changes and generate ALM/RTN messages."""
|
|
125
|
+
if tag.value is None:
|
|
126
|
+
return
|
|
127
|
+
value = float(tag.value)
|
|
128
|
+
time_us = tag.time_us
|
|
129
|
+
new_in_alarm = False
|
|
130
|
+
op = self.alarm['operator']
|
|
131
|
+
if op == '>':
|
|
132
|
+
new_in_alarm = value > self.alarm['value']
|
|
133
|
+
elif 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
|
+
if new_in_alarm == self.in_alarm:
|
|
142
|
+
return
|
|
143
|
+
self.in_alarm = new_in_alarm
|
|
144
|
+
if self.in_alarm:
|
|
145
|
+
if self.alarm['for'] > 0:
|
|
146
|
+
if not self.checking:
|
|
147
|
+
self.checking = True
|
|
148
|
+
self.alarms.checking_alarms.append(self)
|
|
149
|
+
else:
|
|
150
|
+
self.generate_alarm(ALM, time_us, value)
|
|
151
|
+
else:
|
|
152
|
+
if self.checking:
|
|
153
|
+
self.checking = False
|
|
154
|
+
self.alarms.checking_alarms.remove(self)
|
|
155
|
+
self.generate_alarm(RTN, time_us, value)
|
|
156
|
+
|
|
157
|
+
def generate_alarm(self, kind: int, time_us: int, value: float):
|
|
158
|
+
"""Generate alarm message."""
|
|
159
|
+
logging.warning(f'Alarm {self.alarm_id} {value} {KIND[kind]}')
|
|
160
|
+
self.rta_cb({
|
|
161
|
+
'action': 'ADD',
|
|
162
|
+
'date_ms': int(time_us / 1000),
|
|
163
|
+
'alarm_string': self.alarm_id,
|
|
164
|
+
'kind': kind,
|
|
165
|
+
'desc': f'{self.tag.desc} {value:.{self.tag.dp}f}'
|
|
166
|
+
f' {self.tag.units}',
|
|
167
|
+
'group': self.group
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
def check_duration(self, current_time_us: int):
|
|
171
|
+
"""Check if alarm condition has been met for required duration."""
|
|
172
|
+
if current_time_us - self.tag.time_us >= self.alarm['for'] * 1000000:
|
|
173
|
+
self.generate_alarm(ALM, current_time_us, self.tag.value)
|
|
174
|
+
self.checking = False
|
|
175
|
+
self.alarms.checking_alarms.remove(self)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class Alarms:
|
|
179
|
+
"""Connect to bus_ip:bus_port, store and provide Alarms."""
|
|
180
|
+
|
|
181
|
+
def __init__(
|
|
182
|
+
self,
|
|
183
|
+
bus_ip: str | None = '127.0.0.1',
|
|
184
|
+
bus_port: int | None = 1324,
|
|
185
|
+
db: str | None = None,
|
|
186
|
+
table: str = 'alarms',
|
|
187
|
+
tag_info: dict[str, dict] = {},
|
|
188
|
+
rta_tag: str = '__alarms__'
|
|
189
|
+
) -> None:
|
|
190
|
+
"""
|
|
191
|
+
Connect to bus_ip:bus_port, serve and update alarms database.
|
|
192
|
+
|
|
193
|
+
Open an Alarms table, creating if necessary. Provide additions
|
|
194
|
+
and history requests via the rta_tag.
|
|
195
|
+
|
|
196
|
+
Event loop must be running.
|
|
197
|
+
|
|
198
|
+
For testing only: bus_ip can be None to skip connection.
|
|
199
|
+
"""
|
|
200
|
+
if db is None:
|
|
201
|
+
raise SystemExit('Alarms db must be defined')
|
|
202
|
+
if bus_ip is None:
|
|
203
|
+
logging.warning('Alarms has bus_ip=None, only use for testing')
|
|
204
|
+
else:
|
|
205
|
+
try:
|
|
206
|
+
socket.gethostbyname(bus_ip)
|
|
207
|
+
except socket.gaierror as e:
|
|
208
|
+
raise ValueError(f'Cannot resolve IP/hostname: {e}')
|
|
209
|
+
if not isinstance(bus_port, int):
|
|
210
|
+
raise TypeError('bus_port must be an integer')
|
|
211
|
+
if not 1024 <= bus_port <= 65535:
|
|
212
|
+
raise ValueError('bus_port must be between 1024 and 65535')
|
|
213
|
+
if not isinstance(rta_tag, str) or not rta_tag:
|
|
214
|
+
raise ValueError('rta_tag must be a non-empty string')
|
|
215
|
+
if not isinstance(table, str) or not table:
|
|
216
|
+
raise ValueError('table must be a non-empty string')
|
|
217
|
+
|
|
218
|
+
logging.warning(f'Alarms {bus_ip} {bus_port} {db} {rta_tag}')
|
|
219
|
+
self.alarms: list[Alarm] = []
|
|
220
|
+
self.checking_alarms: list[Alarm] = []
|
|
221
|
+
for tagname, tag in tag_info.items():
|
|
222
|
+
standardise_tag_info(tagname, tag)
|
|
223
|
+
if 'alarm' not in tag or tag['type'] not in (int, float):
|
|
224
|
+
continue
|
|
225
|
+
group = tag['group']
|
|
226
|
+
for alarm in tag['alarm']:
|
|
227
|
+
new_alarm = Alarm(tagname, tag, alarm, group, self.rta_cb,
|
|
228
|
+
self)
|
|
229
|
+
self.alarms.append(new_alarm)
|
|
230
|
+
self.busclient = BusClient(bus_ip, bus_port, module='Alarms')
|
|
231
|
+
self.rta = Tag(rta_tag, dict)
|
|
232
|
+
self.rta.value = {}
|
|
233
|
+
self.busclient.add_callback_rta(rta_tag, self.rta_cb)
|
|
234
|
+
self._init_db(db, table)
|
|
235
|
+
self.periodic = Periodic(self.periodic_cb, 1.0)
|
|
236
|
+
|
|
237
|
+
def _init_db(self, db, table):
|
|
238
|
+
"""Initialize the database table schema."""
|
|
239
|
+
self.connection = sqlite3.connect(db)
|
|
240
|
+
self.table = table
|
|
241
|
+
self.cursor = self.connection.cursor()
|
|
242
|
+
query = (
|
|
243
|
+
'CREATE TABLE IF NOT EXISTS ' + self.table + ' '
|
|
244
|
+
'(id INTEGER PRIMARY KEY ASC, '
|
|
245
|
+
'date_ms INTEGER, '
|
|
246
|
+
'alarm_string TEXT, '
|
|
247
|
+
'kind INTEGER, '
|
|
248
|
+
'desc TEXT, '
|
|
249
|
+
'"group" TEXT)'
|
|
250
|
+
)
|
|
251
|
+
self.cursor.execute(query)
|
|
252
|
+
self.connection.commit()
|
|
253
|
+
|
|
254
|
+
startup_record = {
|
|
255
|
+
'action': 'ADD',
|
|
256
|
+
'date_ms': int(time.time() * 1000),
|
|
257
|
+
'alarm_string': self.rta.name,
|
|
258
|
+
'kind': INF,
|
|
259
|
+
'desc': 'Alarm logging started',
|
|
260
|
+
'group': '__system__'
|
|
261
|
+
}
|
|
262
|
+
self.rta_cb(startup_record)
|
|
263
|
+
|
|
264
|
+
async def periodic_cb(self):
|
|
265
|
+
"""Periodic callback to check alarms."""
|
|
266
|
+
current_time_us = int(time.time() * 1000000)
|
|
267
|
+
for alarm in self.checking_alarms[:]:
|
|
268
|
+
alarm.check_duration(current_time_us)
|
|
269
|
+
|
|
270
|
+
def rta_cb(self, request):
|
|
271
|
+
"""Respond to Request to Author and publish on rta_tag as needed."""
|
|
272
|
+
if 'action' not in request:
|
|
273
|
+
logging.warning(f'rta_cb malformed {request}')
|
|
274
|
+
elif request['action'] == 'ADD':
|
|
275
|
+
try:
|
|
276
|
+
logging.info(f'add {request}')
|
|
277
|
+
with self.connection:
|
|
278
|
+
self.cursor.execute(
|
|
279
|
+
f'INSERT INTO {self.table} '
|
|
280
|
+
'(date_ms, alarm_string, kind, desc, "group") '
|
|
281
|
+
'VALUES(:date_ms, :alarm_string, :kind, :desc, :group) '
|
|
282
|
+
'RETURNING *;',
|
|
283
|
+
request)
|
|
284
|
+
res = self.cursor.fetchone()
|
|
285
|
+
self.rta.value = {
|
|
286
|
+
'id': res[0],
|
|
287
|
+
'date_ms': res[1],
|
|
288
|
+
'alarm_string': res[2],
|
|
289
|
+
'kind': res[3],
|
|
290
|
+
'desc': res[4],
|
|
291
|
+
'group': res[5]
|
|
292
|
+
}
|
|
293
|
+
except sqlite3.IntegrityError as error:
|
|
294
|
+
logging.warning(f'Alarms rta_cb {error}')
|
|
295
|
+
elif request['action'] == 'UPDATE':
|
|
296
|
+
try:
|
|
297
|
+
logging.info(f'update {request}')
|
|
298
|
+
with self.connection:
|
|
299
|
+
self.cursor.execute(
|
|
300
|
+
f'UPDATE {self.table} SET in_alm = :in_alm '
|
|
301
|
+
'WHERE id = :id RETURNING *;',
|
|
302
|
+
request)
|
|
303
|
+
res = self.cursor.fetchone()
|
|
304
|
+
if res:
|
|
305
|
+
self.rta.value = {
|
|
306
|
+
'id': res[0],
|
|
307
|
+
'date_ms': res[1],
|
|
308
|
+
'alarm_string': res[2],
|
|
309
|
+
'kind': res[3],
|
|
310
|
+
'desc': res[4],
|
|
311
|
+
'group': res[5]
|
|
312
|
+
}
|
|
313
|
+
except sqlite3.IntegrityError as error:
|
|
314
|
+
logging.warning(f'Alarms rta_cb update {error}')
|
|
315
|
+
elif request['action'] == 'HISTORY':
|
|
316
|
+
try:
|
|
317
|
+
logging.info(f'history {request}')
|
|
318
|
+
with self.connection:
|
|
319
|
+
self.cursor.execute(
|
|
320
|
+
f'SELECT * FROM {self.table} WHERE date_ms > :date_ms '
|
|
321
|
+
'ORDER BY (date_ms - :date_ms);', request)
|
|
322
|
+
for res in self.cursor.fetchall():
|
|
323
|
+
self.rta.value = {
|
|
324
|
+
'__rta_id__': request['__rta_id__'],
|
|
325
|
+
'id': res[0],
|
|
326
|
+
'date_ms': res[1],
|
|
327
|
+
'alarm_string': res[2],
|
|
328
|
+
'kind': res[3],
|
|
329
|
+
'desc': res[4],
|
|
330
|
+
'group': res[5]
|
|
331
|
+
}
|
|
332
|
+
except sqlite3.IntegrityError as error:
|
|
333
|
+
logging.warning(f'Alarms rta_cb {error}')
|
|
334
|
+
elif request['action'] == 'BULK HISTORY':
|
|
335
|
+
try:
|
|
336
|
+
logging.info(f'bulk history {request}')
|
|
337
|
+
with self.connection:
|
|
338
|
+
self.cursor.execute(
|
|
339
|
+
f'SELECT * FROM {self.table} WHERE date_ms > :date_ms '
|
|
340
|
+
'ORDER BY -date_ms;', request)
|
|
341
|
+
results = list(self.cursor.fetchall())
|
|
342
|
+
self.rta.value = {'__rta_id__': request['__rta_id__'],
|
|
343
|
+
'data': results}
|
|
344
|
+
except sqlite3.IntegrityError as error:
|
|
345
|
+
logging.warning(f'Alarms rta_cb {error}')
|
|
346
|
+
elif request['action'] == 'IN ALARM':
|
|
347
|
+
self.rta.value = {'__rta_id__': request['__rta_id__'],
|
|
348
|
+
'data': {'in_alarm': list(self.in_alarm)}}
|
|
349
|
+
|
|
350
|
+
async def start(self):
|
|
351
|
+
"""Async startup."""
|
|
352
|
+
await self.busclient.start()
|
|
353
|
+
await self.periodic.start()
|
pymscada/bus_client.py
CHANGED
|
@@ -12,12 +12,13 @@ class BusClient:
|
|
|
12
12
|
"""
|
|
13
13
|
Connects to a Bus Server.
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
The client is created without a connection. client.start() creates the
|
|
16
|
+
connection and checks the tags at that time. When the connection fails
|
|
17
|
+
the client dies. A connection is mandatory for the client to run.
|
|
17
18
|
"""
|
|
18
19
|
|
|
19
|
-
def __init__(self, ip: str = '127.0.0.1', port: int = 1324,
|
|
20
|
-
module: str = '_unset_'):
|
|
20
|
+
def __init__(self, ip: str | None = '127.0.0.1', port: int | None = 1324,
|
|
21
|
+
tag_info=None, module: str = '_unset_'):
|
|
21
22
|
"""Create bus server."""
|
|
22
23
|
self.ip = ip
|
|
23
24
|
self.port = port
|
|
@@ -125,7 +126,6 @@ class BusClient:
|
|
|
125
126
|
|
|
126
127
|
async def read(self):
|
|
127
128
|
"""Read forever."""
|
|
128
|
-
await self.open_connection()
|
|
129
129
|
while True:
|
|
130
130
|
try:
|
|
131
131
|
head = await self.reader.readexactly(14)
|
|
@@ -223,4 +223,5 @@ class BusClient:
|
|
|
223
223
|
|
|
224
224
|
async def start(self):
|
|
225
225
|
"""Start async."""
|
|
226
|
+
await self.open_connection()
|
|
226
227
|
self.read_task = asyncio.create_task(self.read())
|
pymscada/bus_server.py
CHANGED
|
@@ -3,6 +3,7 @@ import asyncio
|
|
|
3
3
|
from struct import pack, unpack
|
|
4
4
|
import time
|
|
5
5
|
import logging
|
|
6
|
+
import socket
|
|
6
7
|
import pymscada.protocol_constants as pc
|
|
7
8
|
|
|
8
9
|
|
|
@@ -143,8 +144,12 @@ class BusServer:
|
|
|
143
144
|
|
|
144
145
|
__slots__ = ('ip', 'port', 'server', 'connections', 'bus_tag')
|
|
145
146
|
|
|
146
|
-
def __init__(
|
|
147
|
-
|
|
147
|
+
def __init__(
|
|
148
|
+
self,
|
|
149
|
+
ip: str = '127.0.0.1',
|
|
150
|
+
port: int = 1324,
|
|
151
|
+
bus_tag: str = '__bus__'
|
|
152
|
+
) -> None:
|
|
148
153
|
"""
|
|
149
154
|
Serve Tags on ip:port, echoing changes to any subscribers.
|
|
150
155
|
|
|
@@ -154,6 +159,13 @@ class BusServer:
|
|
|
154
159
|
|
|
155
160
|
Event loop must be running.
|
|
156
161
|
"""
|
|
162
|
+
try:
|
|
163
|
+
socket.gethostbyname(ip)
|
|
164
|
+
except socket.gaierror as e:
|
|
165
|
+
raise ValueError(f'Cannot resolve IP/hostname: {e}')
|
|
166
|
+
if not isinstance(bus_tag, str):
|
|
167
|
+
raise ValueError('bus_tag must be a string')
|
|
168
|
+
|
|
157
169
|
self.ip = ip
|
|
158
170
|
self.port = port
|
|
159
171
|
self.server = None
|
pymscada/callout.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Callout handling."""
|
|
2
|
+
import logging
|
|
3
|
+
import socket
|
|
4
|
+
import time
|
|
5
|
+
from pymscada.bus_client import BusClient
|
|
6
|
+
from pymscada.periodic import Periodic
|
|
7
|
+
from pymscada.tag import Tag
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
"""
|
|
11
|
+
Callout monitors alarms and sends SMS notifications to configured callees.
|
|
12
|
+
|
|
13
|
+
Configuration:
|
|
14
|
+
- callees: list of recipients with SMS numbers, delays, and group filters
|
|
15
|
+
- alarms_tag: RTA tag receiving alarms from alarms.py
|
|
16
|
+
- ack_tag: tag for receiving acknowledgments
|
|
17
|
+
- rta_tag: tag for configuration updates and SMS requests
|
|
18
|
+
|
|
19
|
+
Operation:
|
|
20
|
+
1. Collect alarms from alarms_tag (kind=ALM)
|
|
21
|
+
2. For each callee, check if alarms match their group filter and delay
|
|
22
|
+
3. Send SMS via rta_tag when conditions are met
|
|
23
|
+
4. Track sent notifications in alarm['sent'] hash
|
|
24
|
+
5. Remove alarms when callee acknowledges (matches their group)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
ALM = 0
|
|
28
|
+
IDLE = 0
|
|
29
|
+
NEW_ALM = 1
|
|
30
|
+
CALLOUT = 2
|
|
31
|
+
|
|
32
|
+
|
|
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
|
+
|
|
50
|
+
|
|
51
|
+
def alarm_in_callee_group(alarm_group: list, callee_group: list):
|
|
52
|
+
"""Check if alarm_group is in callee_group."""
|
|
53
|
+
if not callee_group:
|
|
54
|
+
if not alarm_group:
|
|
55
|
+
return ''
|
|
56
|
+
return alarm_group[0]
|
|
57
|
+
if not alarm_group:
|
|
58
|
+
return None
|
|
59
|
+
for group in alarm_group:
|
|
60
|
+
if group in callee_group:
|
|
61
|
+
return group
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Callout:
|
|
66
|
+
"""Connect to bus_ip:bus_port, monitor alarms and manage callouts."""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
bus_ip: str | None = '127.0.0.1',
|
|
71
|
+
bus_port: int | None = 1324,
|
|
72
|
+
rta_tag: str = '__callout__',
|
|
73
|
+
alarms_tag: str | None = None,
|
|
74
|
+
ack_tag: str | None = None,
|
|
75
|
+
status_tag: str | None = None,
|
|
76
|
+
callees: list | None = None,
|
|
77
|
+
groups: list | None = None
|
|
78
|
+
) -> None:
|
|
79
|
+
"""
|
|
80
|
+
Connect to bus_ip:bus_port, monitor alarms and manage callouts.
|
|
81
|
+
|
|
82
|
+
Monitor alarms via alarms_tag and manage callout messages to callees
|
|
83
|
+
based on configured delays and area filters.
|
|
84
|
+
|
|
85
|
+
Event loop must be running.
|
|
86
|
+
|
|
87
|
+
For testing only: bus_ip can be None to skip connection.
|
|
88
|
+
"""
|
|
89
|
+
if bus_ip is None:
|
|
90
|
+
logging.warning('Callout has bus_ip=None, only use for testing')
|
|
91
|
+
else:
|
|
92
|
+
try:
|
|
93
|
+
socket.gethostbyname(bus_ip)
|
|
94
|
+
except socket.gaierror as e:
|
|
95
|
+
raise ValueError(f'Cannot resolve IP/hostname: {e}')
|
|
96
|
+
if not isinstance(bus_port, int):
|
|
97
|
+
raise TypeError('bus_port must be an integer')
|
|
98
|
+
if not 1024 <= bus_port <= 65535:
|
|
99
|
+
raise ValueError('bus_port must be between 1024 and 65535')
|
|
100
|
+
if not isinstance(rta_tag, str) or not rta_tag:
|
|
101
|
+
raise ValueError('rta_tag must be a non-empty string')
|
|
102
|
+
|
|
103
|
+
logging.warning(f'Callout {bus_ip} {bus_port} {rta_tag}')
|
|
104
|
+
self.alarms = []
|
|
105
|
+
self.callees = normalise_callees(callees)
|
|
106
|
+
self.groups = []
|
|
107
|
+
if groups is not None:
|
|
108
|
+
self.groups = groups
|
|
109
|
+
self.status = None
|
|
110
|
+
if status_tag is not None:
|
|
111
|
+
self.status = Tag(status_tag, int)
|
|
112
|
+
self.status.value = IDLE
|
|
113
|
+
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
|
+
self.rta = Tag(rta_tag, dict)
|
|
117
|
+
self.rta.value = {}
|
|
118
|
+
self.busclient.add_callback_rta(rta_tag, self.rta_cb)
|
|
119
|
+
self.periodic = Periodic(self.periodic_cb, 1.0)
|
|
120
|
+
|
|
121
|
+
def alarms_cb(self, request):
|
|
122
|
+
"""Handle alarm messages from alarms.py."""
|
|
123
|
+
if request['kind'] != ALM:
|
|
124
|
+
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
|
+
}
|
|
132
|
+
self.alarms.append(alarm)
|
|
133
|
+
logging.info(f'Added alarm to list: {alarm}')
|
|
134
|
+
|
|
135
|
+
def ack_cb(self, ack: str):
|
|
136
|
+
"""Handle ACK requests for alarm acknowledgment."""
|
|
137
|
+
if ack == '__all':
|
|
138
|
+
self.alarms = []
|
|
139
|
+
return
|
|
140
|
+
callee = None
|
|
141
|
+
for c in self.callees:
|
|
142
|
+
if ack == c['sms']:
|
|
143
|
+
callee = c
|
|
144
|
+
break
|
|
145
|
+
if ack == c['name']:
|
|
146
|
+
callee = c
|
|
147
|
+
break
|
|
148
|
+
if callee is None:
|
|
149
|
+
logging.warning(f'ACK rejected: {ack}')
|
|
150
|
+
return
|
|
151
|
+
logging.info(f'ACK accepted: {ack}')
|
|
152
|
+
group = callee['group']
|
|
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
|
|
159
|
+
|
|
160
|
+
def rta_cb(self, request):
|
|
161
|
+
"""Handle RTA requests for callout configuration."""
|
|
162
|
+
logging.info(f'rta_cb {request}')
|
|
163
|
+
if 'action' not in request:
|
|
164
|
+
logging.warning(f'rta_cb malformed {request}')
|
|
165
|
+
elif request['action'] == 'ALL':
|
|
166
|
+
self.rta.value = {'callees': self.callees, 'groups': self.groups}
|
|
167
|
+
elif request['action'] == 'MODIFY':
|
|
168
|
+
for callee in self.callees:
|
|
169
|
+
if callee['name'] == request['name']:
|
|
170
|
+
if 'delay' in request:
|
|
171
|
+
callee['delay_ms'] = request['delay'] * 1000
|
|
172
|
+
if 'group' in request:
|
|
173
|
+
callee['group'] = request['group']
|
|
174
|
+
logging.info(f'Modified callee with {request}')
|
|
175
|
+
|
|
176
|
+
def check_callouts(self):
|
|
177
|
+
"""Check alarms and send callouts. Can be called independently for testing."""
|
|
178
|
+
time_ms = int(time.time() * 1000)
|
|
179
|
+
for callee in self.callees:
|
|
180
|
+
message = ''
|
|
181
|
+
count = 0
|
|
182
|
+
group = callee['group']
|
|
183
|
+
notify_ms = time_ms - callee['delay_ms']
|
|
184
|
+
for alarm in self.alarms:
|
|
185
|
+
if alarm['date_ms'] < notify_ms:
|
|
186
|
+
alarm_group = alarm_in_callee_group(alarm['group'], group)
|
|
187
|
+
if alarm_group is not None and callee['name'] not in alarm['sent']:
|
|
188
|
+
count += 1
|
|
189
|
+
message += f"{alarm['alarm_string']}\n"
|
|
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
|
|
198
|
+
|
|
199
|
+
async def periodic_cb(self):
|
|
200
|
+
"""Periodic callback to check alarms and send callouts."""
|
|
201
|
+
self.check_callouts()
|
|
202
|
+
|
|
203
|
+
async def start(self):
|
|
204
|
+
"""Async startup."""
|
|
205
|
+
await self.busclient.start()
|
|
206
|
+
await self.periodic.start()
|