pymscada 0.2.0rc6__tar.gz → 0.2.0rc8__tar.gz
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-0.2.0rc6/src/pymscada.egg-info → pymscada-0.2.0rc8}/PKG-INFO +1 -1
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/pyproject.toml +1 -1
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/alarms.py +8 -9
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/bus_client.py +2 -2
- pymscada-0.2.0rc8/src/pymscada/callout.py +203 -0
- pymscada-0.2.0rc8/src/pymscada/demo/callout.yaml +16 -0
- pymscada-0.2.0rc8/src/pymscada/demo/pymscada-io-witsapi.service +15 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/demo/tags.yaml +4 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/module_config.py +9 -3
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8/src/pymscada.egg-info}/PKG-INFO +1 -1
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada.egg-info/SOURCES.txt +5 -1
- pymscada-0.2.0rc8/tests/test_callout.py +145 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/LICENSE +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/MANIFEST.in +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/README.md +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/setup.cfg +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/__init__.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/__main__.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/bus_server.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/checkout.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/config.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/console.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/demo/README.md +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/demo/__init__.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/demo/__pycache__/__init__.cpython-311.pyc +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/demo/accuweather.yaml +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/demo/alarms.yaml +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/demo/bus.yaml +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/demo/files.yaml +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/demo/history.yaml +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/demo/logixclient.yaml +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/demo/modbus_plc.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/demo/modbusclient.yaml +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/demo/modbusserver.yaml +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/demo/openweather.yaml +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/demo/opnotes.yaml +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/demo/ping.yaml +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/demo/pymscada-alarms.service +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/demo/pymscada-bus.service +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/demo/pymscada-demo-modbus_plc.service +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/demo/pymscada-files.service +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/demo/pymscada-history.service +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/demo/pymscada-io-logixclient.service +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/demo/pymscada-io-modbusclient.service +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/demo/pymscada-io-modbusserver.service +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/demo/pymscada-io-openweather.service +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/demo/pymscada-io-ping.service +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/demo/pymscada-io-snmpclient.service +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/demo/pymscada-opnotes.service +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/demo/pymscada-wwwserver.service +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/demo/snmpclient.yaml +0 -0
- /pymscada-0.2.0rc6/src/pymscada/demo/wits.yaml → /pymscada-0.2.0rc8/src/pymscada/demo/witsapi.yaml +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/demo/wwwserver.yaml +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/files.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/history.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/iodrivers/__init__.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/iodrivers/accuweather.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/iodrivers/logix_client.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/iodrivers/logix_map.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/iodrivers/modbus_client.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/iodrivers/modbus_map.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/iodrivers/modbus_server.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/iodrivers/openweather.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/iodrivers/ping_client.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/iodrivers/ping_map.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/iodrivers/snmp_client.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/iodrivers/snmp_map.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/iodrivers/witsapi.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/iodrivers/witsapi_POC.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/main.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/misc.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/opnotes.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/pdf/__init__.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/pdf/__pycache__/__init__.cpython-311.pyc +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/pdf/one.pdf +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/pdf/two.pdf +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/periodic.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/protocol_constants.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/samplers.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/tag.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/tools/snmp_client2.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/tools/walk.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/validate.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/www_server.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada.egg-info/dependency_links.txt +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada.egg-info/entry_points.txt +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada.egg-info/requires.txt +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada.egg-info/top_level.txt +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/tests/test_alarms.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/tests/test_bus_server.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/tests/test_config.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/tests/test_history.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/tests/test_misc.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/tests/test_opnotes.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/tests/test_periodic.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/tests/test_samplers.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/tests/test_tag.py +0 -0
- {pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/tests/test_validate.py +0 -0
|
@@ -11,14 +11,12 @@ ALM = 0
|
|
|
11
11
|
RTN = 1
|
|
12
12
|
ACT = 2
|
|
13
13
|
INF = 3
|
|
14
|
-
|
|
15
14
|
KIND = {
|
|
16
15
|
ALM: 'ALM',
|
|
17
16
|
RTN: 'RTN',
|
|
18
17
|
ACT: 'ACT',
|
|
19
18
|
INF: 'INF'
|
|
20
19
|
}
|
|
21
|
-
|
|
22
20
|
NORMAL = 0
|
|
23
21
|
ALARM = 1
|
|
24
22
|
|
|
@@ -46,8 +44,8 @@ def standardise_tag_info(tagname: str, tag: dict):
|
|
|
46
44
|
if 'desc' not in tag:
|
|
47
45
|
logging.warning(f"Tag {tagname} has no description, using name")
|
|
48
46
|
tag['desc'] = tag['name']
|
|
49
|
-
if '
|
|
50
|
-
tag['
|
|
47
|
+
if 'group' not in tag:
|
|
48
|
+
tag['group'] = ''
|
|
51
49
|
if 'multi' in tag:
|
|
52
50
|
tag['type'] = int
|
|
53
51
|
else:
|
|
@@ -107,7 +105,7 @@ class Alarm():
|
|
|
107
105
|
Generates the ALM and RTN messages for Alarms to publish via rta_tag.
|
|
108
106
|
"""
|
|
109
107
|
|
|
110
|
-
def __init__(self, tagname: str, tag: dict, alarm: str,
|
|
108
|
+
def __init__(self, tagname: str, tag: dict, alarm: str, group: str, rta_cb, alarms) -> None:
|
|
111
109
|
"""Initialize alarm with tag and condition(s)."""
|
|
112
110
|
self.alarm_id = f'{tagname} {alarm}'
|
|
113
111
|
self.tag = Tag(tagname, tag['type'])
|
|
@@ -115,7 +113,7 @@ class Alarm():
|
|
|
115
113
|
self.tag.dp = tag['dp']
|
|
116
114
|
self.tag.units = tag['units']
|
|
117
115
|
self.tag.add_callback(self.callback)
|
|
118
|
-
self.
|
|
116
|
+
self.group = group
|
|
119
117
|
self.rta_cb = rta_cb
|
|
120
118
|
self.alarms = alarms
|
|
121
119
|
self.alarm = split_operator(alarm)
|
|
@@ -166,7 +164,7 @@ class Alarm():
|
|
|
166
164
|
'kind': kind,
|
|
167
165
|
'desc': f'{self.tag.desc} {value:.{self.tag.dp}f}'
|
|
168
166
|
f' {self.tag.units}',
|
|
169
|
-
'group': self.
|
|
167
|
+
'group': self.group
|
|
170
168
|
})
|
|
171
169
|
|
|
172
170
|
def check_duration(self, current_time_us: int):
|
|
@@ -224,9 +222,10 @@ class Alarms:
|
|
|
224
222
|
standardise_tag_info(tagname, tag)
|
|
225
223
|
if 'alarm' not in tag or tag['type'] not in (int, float):
|
|
226
224
|
continue
|
|
227
|
-
|
|
225
|
+
group = tag['group']
|
|
228
226
|
for alarm in tag['alarm']:
|
|
229
|
-
new_alarm = Alarm(tagname, tag, alarm,
|
|
227
|
+
new_alarm = Alarm(tagname, tag, alarm, group, self.rta_cb,
|
|
228
|
+
self)
|
|
230
229
|
self.alarms.append(new_alarm)
|
|
231
230
|
self.busclient = BusClient(bus_ip, bus_port, module='Alarms')
|
|
232
231
|
self.rta = Tag(rta_tag, dict)
|
|
@@ -17,8 +17,8 @@ class BusClient:
|
|
|
17
17
|
the client dies. A connection is mandatory for the client to run.
|
|
18
18
|
"""
|
|
19
19
|
|
|
20
|
-
def __init__(self, ip: str = '127.0.0.1', port: int = 1324,
|
|
21
|
-
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_'):
|
|
22
22
|
"""Create bus server."""
|
|
23
23
|
self.ip = ip
|
|
24
24
|
self.port = port
|
|
@@ -0,0 +1,203 @@
|
|
|
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
|
+
if 'action' not in request:
|
|
163
|
+
logging.warning(f'rta_cb malformed {request}')
|
|
164
|
+
elif request['action'] == 'MODIFY':
|
|
165
|
+
for callee in self.callees:
|
|
166
|
+
if callee['name'] == request['name']:
|
|
167
|
+
if 'delay' in request:
|
|
168
|
+
callee['delay_ms'] = request['delay'] * 1000
|
|
169
|
+
if 'group' in request:
|
|
170
|
+
callee['group'] = request['group']
|
|
171
|
+
logging.info(f'Modified callee with {request}')
|
|
172
|
+
|
|
173
|
+
def check_callouts(self):
|
|
174
|
+
"""Check alarms and send callouts. Can be called independently for testing."""
|
|
175
|
+
time_ms = int(time.time() * 1000)
|
|
176
|
+
for callee in self.callees:
|
|
177
|
+
message = ''
|
|
178
|
+
count = 0
|
|
179
|
+
group = callee['group']
|
|
180
|
+
notify_ms = time_ms - callee['delay_ms']
|
|
181
|
+
for alarm in self.alarms:
|
|
182
|
+
if alarm['date_ms'] < notify_ms:
|
|
183
|
+
alarm_group = alarm_in_callee_group(alarm['group'], group)
|
|
184
|
+
if alarm_group is not None and callee['name'] not in alarm['sent']:
|
|
185
|
+
count += 1
|
|
186
|
+
message += f"{alarm['alarm_string']}\n"
|
|
187
|
+
alarm['sent'][callee['name']] = time_ms
|
|
188
|
+
if count > 0:
|
|
189
|
+
send_message = f"{alarm_group} {count} unack alarms\n{message}"
|
|
190
|
+
logging.warning(f'Callout to {callee["name"]}: {send_message}')
|
|
191
|
+
self.rta.value = {'action': 'SMS', 'sms': callee['sms'],
|
|
192
|
+
'message': send_message}
|
|
193
|
+
if self.status is not None:
|
|
194
|
+
self.status.value = CALLOUT
|
|
195
|
+
|
|
196
|
+
async def periodic_cb(self):
|
|
197
|
+
"""Periodic callback to check alarms and send callouts."""
|
|
198
|
+
self.check_callouts()
|
|
199
|
+
|
|
200
|
+
async def start(self):
|
|
201
|
+
"""Async startup."""
|
|
202
|
+
await self.busclient.start()
|
|
203
|
+
await self.periodic.start()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
bus_ip: 127.0.0.1
|
|
2
|
+
bus_port: 1324
|
|
3
|
+
rta_tag: __callout__
|
|
4
|
+
alarms: __alarms__
|
|
5
|
+
ack: SI_Alarm_Ack
|
|
6
|
+
status: SO_Alarm_Status
|
|
7
|
+
callees:
|
|
8
|
+
- name: A name
|
|
9
|
+
sms: A number
|
|
10
|
+
- name: B name
|
|
11
|
+
sms: B number
|
|
12
|
+
groups:
|
|
13
|
+
- name: Aniwhenua Station
|
|
14
|
+
group:
|
|
15
|
+
- name: Aniwhenua System
|
|
16
|
+
group: System
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=pymscada - WITS client
|
|
3
|
+
BindsTo=pymscada-bus.service
|
|
4
|
+
After=pymscada-bus.service
|
|
5
|
+
|
|
6
|
+
[Service]
|
|
7
|
+
WorkingDirectory=__DIR__
|
|
8
|
+
ExecStart=__PYMSCADA__ witsapiclient --config __DIR__/config/witsapi.yaml
|
|
9
|
+
Restart=always
|
|
10
|
+
RestartSec=5
|
|
11
|
+
User=__USER__
|
|
12
|
+
Group=__USER__
|
|
13
|
+
|
|
14
|
+
[Install]
|
|
15
|
+
WantedBy=multi-user.target
|
|
@@ -113,10 +113,14 @@ localhost_ping:
|
|
|
113
113
|
desc: Ping time to localhost
|
|
114
114
|
type: float
|
|
115
115
|
units: ms
|
|
116
|
+
alarm: '> 500 for 300'
|
|
117
|
+
group: System
|
|
116
118
|
google_ping:
|
|
117
119
|
desc: Ping time to google
|
|
118
120
|
type: float
|
|
119
121
|
units: ms
|
|
122
|
+
alarm: '> 100 for 30'
|
|
123
|
+
group: System
|
|
120
124
|
Murupara_Temp:
|
|
121
125
|
desc: Murupara Temperature
|
|
122
126
|
type: float
|
|
@@ -9,14 +9,14 @@ from pymscada.config import Config
|
|
|
9
9
|
class ModuleArgument:
|
|
10
10
|
def __init__(self, args: tuple[str, ...], kwargs: dict[str, Any]):
|
|
11
11
|
self.args = args
|
|
12
|
-
self.kwargs = kwargs
|
|
12
|
+
self.kwargs = kwargs
|
|
13
13
|
|
|
14
14
|
class ModuleDefinition:
|
|
15
15
|
"""Defines a module's configuration and behavior."""
|
|
16
|
-
def __init__(self, name: str, help: str, module_class:
|
|
16
|
+
def __init__(self, name: str, help: str, module_class: Any, *,
|
|
17
17
|
config: bool = True, tags: bool = True,
|
|
18
18
|
epilog: Optional[str] = None,
|
|
19
|
-
extra_args: list[ModuleArgument] = None,
|
|
19
|
+
extra_args: Optional[list[ModuleArgument]] = None,
|
|
20
20
|
await_future: bool = True):
|
|
21
21
|
self.name = name
|
|
22
22
|
self.help = help
|
|
@@ -63,6 +63,12 @@ def create_module_registry():
|
|
|
63
63
|
help='alarms',
|
|
64
64
|
module_class='pymscada.alarms:Alarms'
|
|
65
65
|
),
|
|
66
|
+
ModuleDefinition(
|
|
67
|
+
name='callout',
|
|
68
|
+
help='alarm callout notifications',
|
|
69
|
+
module_class='pymscada.callout:Callout',
|
|
70
|
+
tags=False
|
|
71
|
+
),
|
|
66
72
|
ModuleDefinition(
|
|
67
73
|
name='validate',
|
|
68
74
|
help='validate config files',
|
|
@@ -7,6 +7,7 @@ src/pymscada/__main__.py
|
|
|
7
7
|
src/pymscada/alarms.py
|
|
8
8
|
src/pymscada/bus_client.py
|
|
9
9
|
src/pymscada/bus_server.py
|
|
10
|
+
src/pymscada/callout.py
|
|
10
11
|
src/pymscada/checkout.py
|
|
11
12
|
src/pymscada/config.py
|
|
12
13
|
src/pymscada/console.py
|
|
@@ -33,6 +34,7 @@ src/pymscada/demo/__init__.py
|
|
|
33
34
|
src/pymscada/demo/accuweather.yaml
|
|
34
35
|
src/pymscada/demo/alarms.yaml
|
|
35
36
|
src/pymscada/demo/bus.yaml
|
|
37
|
+
src/pymscada/demo/callout.yaml
|
|
36
38
|
src/pymscada/demo/files.yaml
|
|
37
39
|
src/pymscada/demo/history.yaml
|
|
38
40
|
src/pymscada/demo/logixclient.yaml
|
|
@@ -53,11 +55,12 @@ src/pymscada/demo/pymscada-io-modbusserver.service
|
|
|
53
55
|
src/pymscada/demo/pymscada-io-openweather.service
|
|
54
56
|
src/pymscada/demo/pymscada-io-ping.service
|
|
55
57
|
src/pymscada/demo/pymscada-io-snmpclient.service
|
|
58
|
+
src/pymscada/demo/pymscada-io-witsapi.service
|
|
56
59
|
src/pymscada/demo/pymscada-opnotes.service
|
|
57
60
|
src/pymscada/demo/pymscada-wwwserver.service
|
|
58
61
|
src/pymscada/demo/snmpclient.yaml
|
|
59
62
|
src/pymscada/demo/tags.yaml
|
|
60
|
-
src/pymscada/demo/
|
|
63
|
+
src/pymscada/demo/witsapi.yaml
|
|
61
64
|
src/pymscada/demo/wwwserver.yaml
|
|
62
65
|
src/pymscada/demo/__pycache__/__init__.cpython-311.pyc
|
|
63
66
|
src/pymscada/iodrivers/__init__.py
|
|
@@ -82,6 +85,7 @@ src/pymscada/tools/snmp_client2.py
|
|
|
82
85
|
src/pymscada/tools/walk.py
|
|
83
86
|
tests/test_alarms.py
|
|
84
87
|
tests/test_bus_server.py
|
|
88
|
+
tests/test_callout.py
|
|
85
89
|
tests/test_config.py
|
|
86
90
|
tests/test_history.py
|
|
87
91
|
tests/test_misc.py
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Test Callout."""
|
|
2
|
+
import pytest
|
|
3
|
+
from pymscada.callout import Callout, alarm_in_callee_group, ALM
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
BUS_ID = 123
|
|
7
|
+
CALLEES = [
|
|
8
|
+
{
|
|
9
|
+
'name': 'Name Lazy',
|
|
10
|
+
'sms': 'Lazy number',
|
|
11
|
+
'delay': 1000,
|
|
12
|
+
'group': ['NoZone']
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
'name': 'Name System',
|
|
16
|
+
'sms': 'System number',
|
|
17
|
+
'delay': 1000,
|
|
18
|
+
'group': ['System']
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
'name': 'Name All',
|
|
22
|
+
'sms': 'All number',
|
|
23
|
+
'delay': 1000,
|
|
24
|
+
'group': []
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
GROUPS = [
|
|
28
|
+
{
|
|
29
|
+
'name': 'My Group',
|
|
30
|
+
'group': 'System'
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
@pytest.fixture(scope='function')
|
|
35
|
+
def callout():
|
|
36
|
+
"""Create a fixture for Callout."""
|
|
37
|
+
return Callout(bus_ip=None, callees=CALLEES, groups=GROUPS)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_alarm_in_callee_group(callout):
|
|
41
|
+
"""Test group in group."""
|
|
42
|
+
# obvious match
|
|
43
|
+
assert alarm_in_callee_group(['Test'], ['Test']) == 'Test'
|
|
44
|
+
# empty callee_group should match all, otherwise match explicit
|
|
45
|
+
assert alarm_in_callee_group([], []) == ''
|
|
46
|
+
assert alarm_in_callee_group(['Test'], []) == 'Test'
|
|
47
|
+
assert alarm_in_callee_group([], ['Test']) == None
|
|
48
|
+
# return the first matching group from alarms if there is a match
|
|
49
|
+
assert alarm_in_callee_group(['Test', '2'], ['Test', '2']) == 'Test'
|
|
50
|
+
# return None is there is no match
|
|
51
|
+
assert alarm_in_callee_group(['Other'], ['Test']) is None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_callout(callout):
|
|
55
|
+
"""Basic tests."""
|
|
56
|
+
co = callout
|
|
57
|
+
# test callees and groups setup from config variables
|
|
58
|
+
assert co.callees[0]['name'] == 'Name Lazy'
|
|
59
|
+
assert co.callees[0]['delay_ms'] == 1000000
|
|
60
|
+
assert co.callees[0]['group'] == ['NoZone']
|
|
61
|
+
assert co.groups[0]['name'] == 'My Group'
|
|
62
|
+
assert co.groups[0]['group'] == 'System'
|
|
63
|
+
# test update in the callee group
|
|
64
|
+
callee_update = {
|
|
65
|
+
'action': 'MODIFY',
|
|
66
|
+
'name': 'Name Lazy',
|
|
67
|
+
'group': ['Still No Zone']
|
|
68
|
+
}
|
|
69
|
+
co.rta_cb(callee_update)
|
|
70
|
+
assert co.callees[0]['delay_ms'] == 1000000
|
|
71
|
+
assert co.callees[0]['group'] == ['Still No Zone']
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_new_alarm(callout):
|
|
75
|
+
"""Test new alarm."""
|
|
76
|
+
values = []
|
|
77
|
+
|
|
78
|
+
def rta_cb(tag):
|
|
79
|
+
# the SMS modem module should monitor this tag to send SMSs
|
|
80
|
+
values.append(tag.value)
|
|
81
|
+
|
|
82
|
+
co = callout
|
|
83
|
+
co.rta.add_callback(rta_cb, BUS_ID)
|
|
84
|
+
# alarm should go to All and System
|
|
85
|
+
alarm = {
|
|
86
|
+
'date_ms': 12345,
|
|
87
|
+
'alarm_string': 'sys alarm',
|
|
88
|
+
'kind': ALM,
|
|
89
|
+
'desc': 'System Alarm',
|
|
90
|
+
'group': ['System']
|
|
91
|
+
}
|
|
92
|
+
co.alarms_cb(alarm)
|
|
93
|
+
co.check_callouts()
|
|
94
|
+
# doesn't have to be in the order in CALLEES, but it is so ...
|
|
95
|
+
assert values[0]['action'] == 'SMS'
|
|
96
|
+
assert values[0]['sms'] == 'System number'
|
|
97
|
+
assert values[1]['action'] == 'SMS'
|
|
98
|
+
assert values[1]['sms'] == 'All number'
|
|
99
|
+
assert 'Name Lazy' not in co.alarms[0]['sent']
|
|
100
|
+
assert 'Name System' in co.alarms[0]['sent']
|
|
101
|
+
assert 'Name All' in co.alarms[0]['sent']
|
|
102
|
+
# Alarm should go to All
|
|
103
|
+
alarm = {
|
|
104
|
+
'date_ms': 12345,
|
|
105
|
+
'alarm_string': 'all alarm',
|
|
106
|
+
'kind': ALM,
|
|
107
|
+
'desc': 'Broadcast Alarm',
|
|
108
|
+
'group': []
|
|
109
|
+
}
|
|
110
|
+
co.alarms_cb(alarm)
|
|
111
|
+
co.check_callouts()
|
|
112
|
+
assert values[2]['action'] == 'SMS'
|
|
113
|
+
assert values[2]['sms'] == 'All number'
|
|
114
|
+
assert 'Name Lazy' not in co.alarms[1]['sent']
|
|
115
|
+
assert 'Name All' in co.alarms[1]['sent']
|
|
116
|
+
assert 'Name System' not in co.alarms[1]['sent']
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_ack_functionality(callout):
|
|
120
|
+
"""Test ACK functionality with sent hash."""
|
|
121
|
+
co = callout
|
|
122
|
+
alarm = {
|
|
123
|
+
'date_ms': 12345,
|
|
124
|
+
'alarm_string': 'sys alarm',
|
|
125
|
+
'kind': ALM,
|
|
126
|
+
'desc': 'System Alarm',
|
|
127
|
+
'group': ['System']
|
|
128
|
+
}
|
|
129
|
+
co.alarms_cb(alarm)
|
|
130
|
+
co.check_callouts()
|
|
131
|
+
assert 'Name Lazy' not in co.alarms[0]['sent']
|
|
132
|
+
assert 'Name System' in co.alarms[0]['sent']
|
|
133
|
+
assert 'Name All' in co.alarms[0]['sent']
|
|
134
|
+
co.ack_cb('__all')
|
|
135
|
+
assert len(co.alarms) == 0
|
|
136
|
+
co.alarms_cb(alarm)
|
|
137
|
+
co.check_callouts()
|
|
138
|
+
co.ack_cb('Lazy number')
|
|
139
|
+
assert len(co.alarms) == 1
|
|
140
|
+
co.ack_cb('System number')
|
|
141
|
+
assert len(co.alarms) == 0
|
|
142
|
+
co.alarms_cb(alarm)
|
|
143
|
+
co.check_callouts()
|
|
144
|
+
co.ack_cb('All number')
|
|
145
|
+
assert len(co.alarms) == 0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/demo/__pycache__/__init__.cpython-311.pyc
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
/pymscada-0.2.0rc6/src/pymscada/demo/wits.yaml → /pymscada-0.2.0rc8/src/pymscada/demo/witsapi.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pymscada-0.2.0rc6 → pymscada-0.2.0rc8}/src/pymscada/pdf/__pycache__/__init__.cpython-311.pyc
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|