pymscada 0.2.0rc8__tar.gz → 0.2.2__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.0rc8/src/pymscada.egg-info → pymscada-0.2.2}/PKG-INFO +2 -2
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/pyproject.toml +2 -2
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/__init__.py +6 -2
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/alarms.py +6 -2
- pymscada-0.2.2/src/pymscada/callout.py +259 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/config.py +20 -1
- pymscada-0.2.2/src/pymscada/demo/callout.yaml +26 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/openweather.yaml +1 -1
- pymscada-0.2.2/src/pymscada/demo/piapi.yaml +15 -0
- pymscada-0.2.2/src/pymscada/demo/pymscada-callout.service +16 -0
- pymscada-0.2.2/src/pymscada/demo/pymscada-io-piapi.service +15 -0
- pymscada-0.2.2/src/pymscada/demo/pymscada-io-sms.service +18 -0
- pymscada-0.2.2/src/pymscada/demo/sms.yaml +11 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/tags.yaml +3 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/witsapi.yaml +2 -2
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/wwwserver.yaml +15 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/history.py +3 -5
- pymscada-0.2.2/src/pymscada/iodrivers/piapi.py +133 -0
- pymscada-0.2.2/src/pymscada/iodrivers/sms.py +212 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/iodrivers/witsapi.py +26 -35
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/module_config.py +24 -18
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/opnotes.py +4 -2
- pymscada-0.2.2/src/pymscada/tools/get_history.py +147 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/www_server.py +1 -1
- {pymscada-0.2.0rc8 → pymscada-0.2.2/src/pymscada.egg-info}/PKG-INFO +2 -2
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada.egg-info/SOURCES.txt +10 -3
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada.egg-info/requires.txt +1 -1
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/tests/test_callout.py +14 -8
- pymscada-0.2.2/tests/test_config.py +53 -0
- pymscada-0.2.2/tests/test_sms.py +85 -0
- pymscada-0.2.0rc8/src/pymscada/callout.py +0 -203
- pymscada-0.2.0rc8/src/pymscada/demo/callout.yaml +0 -16
- pymscada-0.2.0rc8/src/pymscada/validate.py +0 -451
- pymscada-0.2.0rc8/tests/test_config.py +0 -28
- pymscada-0.2.0rc8/tests/test_validate.py +0 -14
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/LICENSE +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/MANIFEST.in +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/README.md +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/setup.cfg +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/__main__.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/bus_client.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/bus_server.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/checkout.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/console.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/README.md +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/__init__.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/__pycache__/__init__.cpython-311.pyc +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/accuweather.yaml +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/alarms.yaml +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/bus.yaml +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/files.yaml +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/history.yaml +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/logixclient.yaml +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/modbus_plc.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/modbusclient.yaml +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/modbusserver.yaml +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/opnotes.yaml +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/ping.yaml +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/pymscada-alarms.service +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/pymscada-bus.service +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/pymscada-demo-modbus_plc.service +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/pymscada-files.service +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/pymscada-history.service +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/pymscada-io-logixclient.service +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/pymscada-io-modbusclient.service +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/pymscada-io-modbusserver.service +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/pymscada-io-openweather.service +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/pymscada-io-ping.service +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/pymscada-io-snmpclient.service +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/pymscada-io-witsapi.service +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/pymscada-opnotes.service +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/pymscada-wwwserver.service +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/demo/snmpclient.yaml +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/files.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/iodrivers/__init__.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/iodrivers/accuweather.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/iodrivers/logix_client.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/iodrivers/logix_map.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/iodrivers/modbus_client.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/iodrivers/modbus_map.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/iodrivers/modbus_server.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/iodrivers/openweather.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/iodrivers/ping_client.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/iodrivers/ping_map.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/iodrivers/snmp_client.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/iodrivers/snmp_map.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/iodrivers/witsapi_POC.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/main.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/misc.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/pdf/__init__.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/pdf/__pycache__/__init__.cpython-311.pyc +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/pdf/one.pdf +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/pdf/two.pdf +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/periodic.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/protocol_constants.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/samplers.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/tag.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/tools/snmp_client2.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada/tools/walk.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada.egg-info/dependency_links.txt +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada.egg-info/entry_points.txt +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/src/pymscada.egg-info/top_level.txt +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/tests/test_alarms.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/tests/test_bus_server.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/tests/test_history.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/tests/test_misc.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/tests/test_opnotes.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/tests/test_periodic.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/tests/test_samplers.py +0 -0
- {pymscada-0.2.0rc8 → pymscada-0.2.2}/tests/test_tag.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pymscada
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
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.0
|
|
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,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pymscada"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.2"
|
|
4
4
|
description = "Shared tag value SCADA with python backup and Angular UI"
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "Jamie Walton", email = "jamie@walton.net.nz"},
|
|
@@ -8,7 +8,7 @@ authors = [
|
|
|
8
8
|
dependencies = [
|
|
9
9
|
"PyYAML>=6.0.1", # all
|
|
10
10
|
"aiohttp>=3.8.5", # www_server
|
|
11
|
-
"pymscada-html==0.2.
|
|
11
|
+
"pymscada-html==0.2.0", # www_server
|
|
12
12
|
"cerberus>=1.3.5", # validate
|
|
13
13
|
"pycomm3>=1.2.14", # logix_client
|
|
14
14
|
"pysnmplib>=5.0.24", # DON'T use pysnmp, dead
|
|
@@ -6,10 +6,12 @@ from pymscada.iodrivers.accuweather import AccuWeatherClient
|
|
|
6
6
|
from pymscada.iodrivers.logix_client import LogixClient
|
|
7
7
|
from pymscada.iodrivers.modbus_client import ModbusClient
|
|
8
8
|
from pymscada.iodrivers.modbus_server import ModbusServer
|
|
9
|
+
from pymscada.iodrivers.piapi import PIWebAPIClient
|
|
10
|
+
from pymscada.iodrivers.sms import SMS
|
|
11
|
+
from pymscada.iodrivers.witsapi import WitsAPIClient
|
|
9
12
|
from pymscada.misc import find_nodes, ramp
|
|
10
13
|
from pymscada.periodic import Periodic
|
|
11
14
|
from pymscada.tag import Tag
|
|
12
|
-
from pymscada.validate import validate
|
|
13
15
|
|
|
14
16
|
__all__ = [
|
|
15
17
|
'BusClient',
|
|
@@ -19,8 +21,10 @@ __all__ = [
|
|
|
19
21
|
'LogixClient',
|
|
20
22
|
'ModbusClient',
|
|
21
23
|
'ModbusServer',
|
|
24
|
+
'PIWebAPIClient',
|
|
25
|
+
'SMS',
|
|
26
|
+
'WitsAPIClient',
|
|
22
27
|
'find_nodes', 'ramp',
|
|
23
28
|
'Periodic',
|
|
24
29
|
'Tag',
|
|
25
|
-
'validate',
|
|
26
30
|
]
|
|
@@ -17,6 +17,7 @@ KIND = {
|
|
|
17
17
|
ACT: 'ACT',
|
|
18
18
|
INF: 'INF'
|
|
19
19
|
}
|
|
20
|
+
|
|
20
21
|
NORMAL = 0
|
|
21
22
|
ALARM = 1
|
|
22
23
|
|
|
@@ -105,7 +106,8 @@ class Alarm():
|
|
|
105
106
|
Generates the ALM and RTN messages for Alarms to publish via rta_tag.
|
|
106
107
|
"""
|
|
107
108
|
|
|
108
|
-
def __init__(self, tagname: str, tag: dict, alarm: str, group: str,
|
|
109
|
+
def __init__(self, tagname: str, tag: dict, alarm: str, group: str,
|
|
110
|
+
rta_cb, alarms) -> None:
|
|
109
111
|
"""Initialize alarm with tag and condition(s)."""
|
|
110
112
|
self.alarm_id = f'{tagname} {alarm}'
|
|
111
113
|
self.tag = Tag(tagname, tag['type'])
|
|
@@ -229,7 +231,7 @@ class Alarms:
|
|
|
229
231
|
self.alarms.append(new_alarm)
|
|
230
232
|
self.busclient = BusClient(bus_ip, bus_port, module='Alarms')
|
|
231
233
|
self.rta = Tag(rta_tag, dict)
|
|
232
|
-
self.rta.value = {}
|
|
234
|
+
self.rta.value = {'__rta_id__': 0}
|
|
233
235
|
self.busclient.add_callback_rta(rta_tag, self.rta_cb)
|
|
234
236
|
self._init_db(db, table)
|
|
235
237
|
self.periodic = Periodic(self.periodic_cb, 1.0)
|
|
@@ -283,6 +285,7 @@ class Alarms:
|
|
|
283
285
|
request)
|
|
284
286
|
res = self.cursor.fetchone()
|
|
285
287
|
self.rta.value = {
|
|
288
|
+
'__rta_id__': 0,
|
|
286
289
|
'id': res[0],
|
|
287
290
|
'date_ms': res[1],
|
|
288
291
|
'alarm_string': res[2],
|
|
@@ -303,6 +306,7 @@ class Alarms:
|
|
|
303
306
|
res = self.cursor.fetchone()
|
|
304
307
|
if res:
|
|
305
308
|
self.rta.value = {
|
|
309
|
+
'__rta_id__': 0,
|
|
306
310
|
'id': res[0],
|
|
307
311
|
'date_ms': res[1],
|
|
308
312
|
'alarm_string': res[2],
|
|
@@ -0,0 +1,259 @@
|
|
|
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
|
+
|
|
29
|
+
IDLE = 0
|
|
30
|
+
NEW_ALM = 1
|
|
31
|
+
CALLOUT = 2
|
|
32
|
+
|
|
33
|
+
SENT = 0
|
|
34
|
+
REMIND = 1
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def normalise_callees(callees: list | None, escalation: dict):
|
|
38
|
+
"""Normalise callees to include delay_ms and remove role."""
|
|
39
|
+
if callees is None:
|
|
40
|
+
return []
|
|
41
|
+
for callee in callees:
|
|
42
|
+
if 'sms' not in callee:
|
|
43
|
+
raise ValueError(f'Callee {callee["name"]} has no sms number')
|
|
44
|
+
if 'role' in callee:
|
|
45
|
+
if callee['role'] not in escalation:
|
|
46
|
+
raise ValueError(f'Invalid role: {callee["role"]}. Must be'
|
|
47
|
+
f' one of: {list(escalation.keys())}')
|
|
48
|
+
callee['delay_ms'] = escalation[callee['role']]
|
|
49
|
+
else:
|
|
50
|
+
callee['delay_ms'] = 0
|
|
51
|
+
callee['role'] = ''
|
|
52
|
+
if 'group' not in callee:
|
|
53
|
+
callee['group'] = ''
|
|
54
|
+
callees.sort(key=lambda x: x['delay_ms'])
|
|
55
|
+
return callees
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def alarm_in_group(alarm, callee_group, groups):
|
|
59
|
+
"""Check if alarm_group matches callee_group."""
|
|
60
|
+
if callee_group == '':
|
|
61
|
+
return True
|
|
62
|
+
if callee_group not in groups:
|
|
63
|
+
return False
|
|
64
|
+
group = groups[callee_group]
|
|
65
|
+
if 'tagnames' in group:
|
|
66
|
+
for tagname in group['tagnames']:
|
|
67
|
+
if alarm['alarm_string'].startswith(tagname):
|
|
68
|
+
return True
|
|
69
|
+
if 'groups' in group:
|
|
70
|
+
for group in group['groups']:
|
|
71
|
+
if group in alarm['group']:
|
|
72
|
+
return True
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Callout:
|
|
77
|
+
"""Connect to bus_ip:bus_port, monitor alarms and manage callouts."""
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
bus_ip: str | None = '127.0.0.1',
|
|
82
|
+
bus_port: int | None = 1324,
|
|
83
|
+
rta_tag: str = '__callout__',
|
|
84
|
+
alarms_tag: str | None = None,
|
|
85
|
+
sms_send_tag: str | None = None,
|
|
86
|
+
sms_recv_tag: str | None = None,
|
|
87
|
+
ack_tag: str | None = None,
|
|
88
|
+
status_tag: str | None = None,
|
|
89
|
+
callees: list | None = None,
|
|
90
|
+
groups: dict = {},
|
|
91
|
+
escalation: list | None = None
|
|
92
|
+
) -> None:
|
|
93
|
+
"""
|
|
94
|
+
Connect to bus_ip:bus_port, monitor alarms and manage callouts.
|
|
95
|
+
|
|
96
|
+
Monitor alarms via alarms_tag and manage callout messages to callees
|
|
97
|
+
based on configured delays and area filters.
|
|
98
|
+
|
|
99
|
+
Event loop must be running.
|
|
100
|
+
|
|
101
|
+
For testing only: bus_ip can be None to skip connection.
|
|
102
|
+
"""
|
|
103
|
+
if bus_ip is None:
|
|
104
|
+
logging.warning('Callout has bus_ip=None, only use for testing')
|
|
105
|
+
else:
|
|
106
|
+
try:
|
|
107
|
+
socket.gethostbyname(bus_ip)
|
|
108
|
+
except socket.gaierror as e:
|
|
109
|
+
raise ValueError(f'Cannot resolve IP/hostname: {e}')
|
|
110
|
+
if not isinstance(bus_port, int):
|
|
111
|
+
raise TypeError('bus_port must be an integer')
|
|
112
|
+
if not 1024 <= bus_port <= 65535:
|
|
113
|
+
raise ValueError('bus_port must be between 1024 and 65535')
|
|
114
|
+
if not isinstance(rta_tag, str) or not rta_tag:
|
|
115
|
+
raise ValueError('rta_tag must be a non-empty string')
|
|
116
|
+
if alarms_tag is None:
|
|
117
|
+
raise ValueError('alarms_tag must be defined')
|
|
118
|
+
if sms_send_tag is None:
|
|
119
|
+
raise ValueError('sms_send_tag must be defined')
|
|
120
|
+
if sms_recv_tag is None:
|
|
121
|
+
raise ValueError('sms_recv_tag must be defined')
|
|
122
|
+
|
|
123
|
+
self.escalation = [list(d.keys())[0] for d in escalation]
|
|
124
|
+
self.delay_ms = {list(d.keys())[0]: list(d.values())[0]
|
|
125
|
+
for d in escalation}
|
|
126
|
+
logging.warning(f'Callout {bus_ip} {bus_port} {rta_tag} '
|
|
127
|
+
f'{sms_send_tag} {sms_recv_tag}')
|
|
128
|
+
self.alarms = []
|
|
129
|
+
self.callees = normalise_callees(callees, self.delay_ms)
|
|
130
|
+
self.groups = groups
|
|
131
|
+
self.alarms_tag = Tag(alarms_tag, dict)
|
|
132
|
+
self.alarms_tag.add_callback(self.alarms_cb)
|
|
133
|
+
self.sms_recv_tag = Tag(sms_recv_tag, dict)
|
|
134
|
+
self.sms_recv_tag.add_callback(self.sms_recv_cb)
|
|
135
|
+
self.sms_send_tag = Tag(sms_send_tag, dict)
|
|
136
|
+
if ack_tag is not None:
|
|
137
|
+
self.ack_tag = Tag(ack_tag, int)
|
|
138
|
+
self.ack_tag.add_callback(self.ack_cb)
|
|
139
|
+
if status_tag is not None:
|
|
140
|
+
self.status = Tag(status_tag, int)
|
|
141
|
+
self.busclient = BusClient(bus_ip, bus_port, module='Callout')
|
|
142
|
+
self.rta = Tag(rta_tag, dict)
|
|
143
|
+
self.rta.value = {'__rta_id__': 0,
|
|
144
|
+
'callees': self.callees,
|
|
145
|
+
'groups': self.groups,
|
|
146
|
+
'escalation': self.escalation}
|
|
147
|
+
self.busclient.add_callback_rta(rta_tag, self.rta_cb)
|
|
148
|
+
self.periodic = Periodic(self.periodic_cb, 1.0)
|
|
149
|
+
|
|
150
|
+
def alarms_cb(self, alm_tag):
|
|
151
|
+
"""Handle alarm messages from alarms.py."""
|
|
152
|
+
alarm = alm_tag.value
|
|
153
|
+
if 'kind' not in alarm or alarm['kind'] != ALM:
|
|
154
|
+
return
|
|
155
|
+
alarm = {
|
|
156
|
+
'date_ms': alarm['date_ms'],
|
|
157
|
+
'alarm_string': alarm['alarm_string'],
|
|
158
|
+
'desc': alarm['desc'],
|
|
159
|
+
'group': alarm['group'],
|
|
160
|
+
'sent': {}
|
|
161
|
+
}
|
|
162
|
+
self.alarms.append(alarm)
|
|
163
|
+
logging.info(f'Added alarm to list: {alarm}')
|
|
164
|
+
if self.status is not None and self.status.value == IDLE:
|
|
165
|
+
self.status.value = NEW_ALM
|
|
166
|
+
|
|
167
|
+
def ack_cb(self, ack_tag):
|
|
168
|
+
"""Handle ACK requests for alarm acknowledgment."""
|
|
169
|
+
if ack_tag.value == 1:
|
|
170
|
+
self.alarms = []
|
|
171
|
+
if self.status is not None:
|
|
172
|
+
self.status.value = IDLE
|
|
173
|
+
logging.info('ACK: all alarms cleared')
|
|
174
|
+
|
|
175
|
+
def sms_recv_cb(self, sms_recv_tag: dict):
|
|
176
|
+
"""Handle SMS messages from the modem."""
|
|
177
|
+
logging.info(f'sms_recv_cb {sms_recv_tag.value}')
|
|
178
|
+
_number = sms_recv_tag.value['number']
|
|
179
|
+
message = sms_recv_tag.value['message'][:2].upper()
|
|
180
|
+
if message in ['OK', 'AC', 'TH']:
|
|
181
|
+
self.alarms = []
|
|
182
|
+
if self.status is not None:
|
|
183
|
+
self.status.value = IDLE
|
|
184
|
+
logging.info('ACK: all alarms cleared')
|
|
185
|
+
|
|
186
|
+
def rta_cb(self, request):
|
|
187
|
+
"""Handle RTA requests for callout configuration."""
|
|
188
|
+
logging.info(f'rta_cb {request}')
|
|
189
|
+
if 'action' not in request:
|
|
190
|
+
logging.warning(f'rta_cb malformed {request}')
|
|
191
|
+
return
|
|
192
|
+
if request['action'] == 'GET CONFIG':
|
|
193
|
+
self.rta.value = {'__rta_id__': request['__rta_id__'],
|
|
194
|
+
'callees': self.callees,
|
|
195
|
+
'groups': self.groups,
|
|
196
|
+
'escalation': self.escalation}
|
|
197
|
+
elif request['action'] == 'MODIFY':
|
|
198
|
+
send_update = False
|
|
199
|
+
for callee in self.callees:
|
|
200
|
+
if callee['name'] == request['name']:
|
|
201
|
+
if 'role' in request:
|
|
202
|
+
callee['role'] = request['role']
|
|
203
|
+
callee['delay_ms'] = self.delay_ms.get(
|
|
204
|
+
request['role'], 30000)
|
|
205
|
+
if 'group' in request:
|
|
206
|
+
callee['group'] = request['group']
|
|
207
|
+
logging.info(f'Modified callee with {request}')
|
|
208
|
+
send_update = True
|
|
209
|
+
if send_update:
|
|
210
|
+
self.rta.value = {'__rta_id__': 0,
|
|
211
|
+
'callees': self.callees,
|
|
212
|
+
'groups': self.groups,
|
|
213
|
+
'escalation': self.escalation}
|
|
214
|
+
|
|
215
|
+
def check_callouts(self):
|
|
216
|
+
"""Check alarms and send callouts."""
|
|
217
|
+
time_ms = int(time.time() * 1000)
|
|
218
|
+
for callee in self.callees:
|
|
219
|
+
if not callee['role']:
|
|
220
|
+
continue
|
|
221
|
+
count = 0
|
|
222
|
+
message = ''
|
|
223
|
+
notify_ms = time_ms - callee['delay_ms']
|
|
224
|
+
remind_ms = notify_ms - 60000
|
|
225
|
+
for alarm in self.alarms:
|
|
226
|
+
if not alarm_in_group(alarm, callee['group'], self.groups):
|
|
227
|
+
continue
|
|
228
|
+
if notify_ms < alarm['date_ms']:
|
|
229
|
+
continue
|
|
230
|
+
if callee['name'] not in alarm['sent']:
|
|
231
|
+
message += f"{alarm['desc']}\n"
|
|
232
|
+
alarm['sent'][callee['name']] = SENT
|
|
233
|
+
count += 1
|
|
234
|
+
continue
|
|
235
|
+
if remind_ms < alarm['date_ms']:
|
|
236
|
+
continue
|
|
237
|
+
if alarm['sent'][callee['name']] != REMIND:
|
|
238
|
+
message += f"REMIND {alarm['desc']}\n"
|
|
239
|
+
alarm['sent'][callee['name']] = REMIND
|
|
240
|
+
count += 1
|
|
241
|
+
if count > 0:
|
|
242
|
+
if len(message) > 200:
|
|
243
|
+
message = message[:200] + '\n...'
|
|
244
|
+
send_message = f"{count} Alarms\n{message}"
|
|
245
|
+
self.sms_send_tag.value = {
|
|
246
|
+
'number': callee['sms'],
|
|
247
|
+
'message': send_message
|
|
248
|
+
}
|
|
249
|
+
if self.status is not None:
|
|
250
|
+
self.status.value = CALLOUT
|
|
251
|
+
|
|
252
|
+
async def periodic_cb(self):
|
|
253
|
+
"""Periodic callback to check alarms and send callouts."""
|
|
254
|
+
self.check_callouts()
|
|
255
|
+
|
|
256
|
+
async def start(self):
|
|
257
|
+
"""Async startup."""
|
|
258
|
+
await self.busclient.start()
|
|
259
|
+
await self.periodic.start()
|
|
@@ -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}')
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
bus_ip: 127.0.0.1
|
|
2
|
+
bus_port: 1324
|
|
3
|
+
rta_tag: __callout__
|
|
4
|
+
alarms_tag: __alarms__
|
|
5
|
+
sms_send_tag: __sms_send__
|
|
6
|
+
sms_recv_tag: __sms_recv__
|
|
7
|
+
ack_tag: SI_Alarm_Ack
|
|
8
|
+
status_tag: SO_Alarm_Status
|
|
9
|
+
escalation:
|
|
10
|
+
- OnCall: 60000
|
|
11
|
+
- Backup 1: 180000
|
|
12
|
+
- Backup 2: 300000
|
|
13
|
+
- Escalate: 600000
|
|
14
|
+
callees:
|
|
15
|
+
- name: A name
|
|
16
|
+
sms: A number
|
|
17
|
+
group: System
|
|
18
|
+
- name: B name
|
|
19
|
+
sms: B number
|
|
20
|
+
groups:
|
|
21
|
+
Wind:
|
|
22
|
+
tagnames: [Mt, Fh]
|
|
23
|
+
System:
|
|
24
|
+
groups: [__system__, Comms]
|
|
25
|
+
Test:
|
|
26
|
+
tagnames: [alarm]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
bus_ip: 127.0.0.1
|
|
2
|
+
bus_port: 1324
|
|
3
|
+
proxy:
|
|
4
|
+
api:
|
|
5
|
+
url: 'https://192.168.15.1/'
|
|
6
|
+
webid: F1Em9DA80Xftdkec1gdWFtX7NAm1eiSAyV8BG1mAAMKQIjRQUEdQSVxQSU9ORUVSXFdFQkFQSVxNT0JJTEVTQ0FEQQ
|
|
7
|
+
averaging: 300
|
|
8
|
+
tags:
|
|
9
|
+
I_An_G1_MW:
|
|
10
|
+
pitag: AnG1.AIMw
|
|
11
|
+
I_An_G2_MW:
|
|
12
|
+
pitag: AnG2.AIMw
|
|
13
|
+
I_Ko_Gn_MW:
|
|
14
|
+
pitag: KoGn.AIkW
|
|
15
|
+
scale: 1000
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=pymscada - callout
|
|
3
|
+
BindsTo=pymscada-bus.service
|
|
4
|
+
After=pymscada-bus.service
|
|
5
|
+
|
|
6
|
+
[Service]
|
|
7
|
+
WorkingDirectory=__DIR__
|
|
8
|
+
ExecStart=__PYMSCADA__ callout --config __DIR__/config/callout.yaml --tags __DIR__/config/tags.yaml
|
|
9
|
+
Restart=always
|
|
10
|
+
RestartSec=5
|
|
11
|
+
User=__USER__
|
|
12
|
+
Group=__USER__
|
|
13
|
+
KillSignal=SIGINT
|
|
14
|
+
|
|
15
|
+
[Install]
|
|
16
|
+
WantedBy=multi-user.target
|
|
@@ -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
|
|
@@ -3,8 +3,8 @@ bus_port: 1324
|
|
|
3
3
|
proxy:
|
|
4
4
|
api:
|
|
5
5
|
url: 'https://api.electricityinfo.co.nz'
|
|
6
|
-
client_id: ${
|
|
7
|
-
client_secret: ${
|
|
6
|
+
client_id: ${MSCADA_WITS_CLIENT_ID}
|
|
7
|
+
client_secret: ${MSCADA_WITS_CLIENT_SECRET}
|
|
8
8
|
gxp_list:
|
|
9
9
|
- MAT1101
|
|
10
10
|
- CYD2201
|
|
@@ -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:
|
|
@@ -270,7 +270,7 @@ class History():
|
|
|
270
270
|
self.tags[tagname] = Tag(tagname, tag['type'])
|
|
271
271
|
self.tags[tagname].add_callback(self.hist_tags[tagname].callback)
|
|
272
272
|
self.rta = Tag(rta_tag, bytes)
|
|
273
|
-
self.rta.value = b'\x00\x00\x00\x00\x00\x00'
|
|
273
|
+
self.rta.value = b'\x00\x00\x00\x00\x00\x00' # rta_id is 0
|
|
274
274
|
self.busclient.add_callback_rta(rta_tag, self.rta_cb)
|
|
275
275
|
|
|
276
276
|
def rta_cb(self, request: Request):
|
|
@@ -278,9 +278,7 @@ class History():
|
|
|
278
278
|
if 'start_ms' in request:
|
|
279
279
|
request['start_us'] = request['start_ms'] * 1000
|
|
280
280
|
request['end_us'] = request['end_ms'] * 1000
|
|
281
|
-
rta_id =
|
|
282
|
-
if '__rta_id__' in request:
|
|
283
|
-
rta_id = request['__rta_id__']
|
|
281
|
+
rta_id = request['__rta_id__']
|
|
284
282
|
tagname = request['tagname']
|
|
285
283
|
start_time = time.asctime(time.localtime(
|
|
286
284
|
request['start_us'] / 1000000))
|
|
@@ -299,7 +297,7 @@ class History():
|
|
|
299
297
|
packtype = 2
|
|
300
298
|
self.rta.value = pack('>HHH', rta_id, tagid, packtype) + data
|
|
301
299
|
logging.info(f'sent {len(data)} bytes for {request["tagname"]}')
|
|
302
|
-
self.rta.value = b'\x00\x00\x00\x00\x00\x00'
|
|
300
|
+
self.rta.value = b'\x00\x00\x00\x00\x00\x00' # rta_id is 0
|
|
303
301
|
except Exception as e:
|
|
304
302
|
logging.error(f'history rta_cb {e}')
|
|
305
303
|
|