pymscada 0.2.1__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.

Files changed (110) hide show
  1. {pymscada-0.2.1/src/pymscada.egg-info → pymscada-0.2.2}/PKG-INFO +1 -1
  2. {pymscada-0.2.1 → pymscada-0.2.2}/pyproject.toml +1 -1
  3. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/__init__.py +6 -2
  4. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/alarms.py +6 -2
  5. pymscada-0.2.2/src/pymscada/callout.py +259 -0
  6. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/config.py +20 -1
  7. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/__pycache__/__init__.cpython-311.pyc +0 -0
  8. pymscada-0.2.2/src/pymscada/demo/callout.yaml +26 -0
  9. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/openweather.yaml +1 -1
  10. pymscada-0.2.2/src/pymscada/demo/piapi.yaml +15 -0
  11. pymscada-0.2.2/src/pymscada/demo/pymscada-io-piapi.service +15 -0
  12. pymscada-0.2.2/src/pymscada/demo/pymscada-io-sms.service +18 -0
  13. pymscada-0.2.2/src/pymscada/demo/sms.yaml +11 -0
  14. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/tags.yaml +3 -0
  15. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/witsapi.yaml +2 -2
  16. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/wwwserver.yaml +15 -0
  17. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/history.py +3 -5
  18. pymscada-0.2.2/src/pymscada/iodrivers/piapi.py +133 -0
  19. pymscada-0.2.2/src/pymscada/iodrivers/sms.py +212 -0
  20. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/iodrivers/witsapi.py +26 -35
  21. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/module_config.py +24 -18
  22. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/opnotes.py +4 -2
  23. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/pdf/__pycache__/__init__.cpython-311.pyc +0 -0
  24. pymscada-0.2.2/src/pymscada/tools/get_history.py +147 -0
  25. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/www_server.py +1 -1
  26. {pymscada-0.2.1 → pymscada-0.2.2/src/pymscada.egg-info}/PKG-INFO +1 -1
  27. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada.egg-info/SOURCES.txt +9 -3
  28. {pymscada-0.2.1 → pymscada-0.2.2}/tests/test_callout.py +14 -8
  29. pymscada-0.2.2/tests/test_config.py +53 -0
  30. pymscada-0.2.2/tests/test_sms.py +85 -0
  31. pymscada-0.2.1/src/pymscada/callout.py +0 -206
  32. pymscada-0.2.1/src/pymscada/demo/callout.yaml +0 -17
  33. pymscada-0.2.1/src/pymscada/validate.py +0 -451
  34. pymscada-0.2.1/tests/test_config.py +0 -28
  35. pymscada-0.2.1/tests/test_validate.py +0 -14
  36. {pymscada-0.2.1 → pymscada-0.2.2}/LICENSE +0 -0
  37. {pymscada-0.2.1 → pymscada-0.2.2}/MANIFEST.in +0 -0
  38. {pymscada-0.2.1 → pymscada-0.2.2}/README.md +0 -0
  39. {pymscada-0.2.1 → pymscada-0.2.2}/setup.cfg +0 -0
  40. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/__main__.py +0 -0
  41. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/bus_client.py +0 -0
  42. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/bus_server.py +0 -0
  43. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/checkout.py +0 -0
  44. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/console.py +0 -0
  45. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/README.md +0 -0
  46. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/__init__.py +0 -0
  47. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/accuweather.yaml +0 -0
  48. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/alarms.yaml +0 -0
  49. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/bus.yaml +0 -0
  50. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/files.yaml +0 -0
  51. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/history.yaml +0 -0
  52. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/logixclient.yaml +0 -0
  53. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/modbus_plc.py +0 -0
  54. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/modbusclient.yaml +0 -0
  55. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/modbusserver.yaml +0 -0
  56. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/opnotes.yaml +0 -0
  57. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/ping.yaml +0 -0
  58. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/pymscada-alarms.service +0 -0
  59. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/pymscada-bus.service +0 -0
  60. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/pymscada-callout.service +0 -0
  61. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/pymscada-demo-modbus_plc.service +0 -0
  62. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/pymscada-files.service +0 -0
  63. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/pymscada-history.service +0 -0
  64. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/pymscada-io-logixclient.service +0 -0
  65. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/pymscada-io-modbusclient.service +0 -0
  66. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/pymscada-io-modbusserver.service +0 -0
  67. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/pymscada-io-openweather.service +0 -0
  68. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/pymscada-io-ping.service +0 -0
  69. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/pymscada-io-snmpclient.service +0 -0
  70. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/pymscada-io-witsapi.service +0 -0
  71. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/pymscada-opnotes.service +0 -0
  72. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/pymscada-wwwserver.service +0 -0
  73. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/demo/snmpclient.yaml +0 -0
  74. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/files.py +0 -0
  75. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/iodrivers/__init__.py +0 -0
  76. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/iodrivers/accuweather.py +0 -0
  77. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/iodrivers/logix_client.py +0 -0
  78. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/iodrivers/logix_map.py +0 -0
  79. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/iodrivers/modbus_client.py +0 -0
  80. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/iodrivers/modbus_map.py +0 -0
  81. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/iodrivers/modbus_server.py +0 -0
  82. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/iodrivers/openweather.py +0 -0
  83. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/iodrivers/ping_client.py +0 -0
  84. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/iodrivers/ping_map.py +0 -0
  85. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/iodrivers/snmp_client.py +0 -0
  86. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/iodrivers/snmp_map.py +0 -0
  87. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/iodrivers/witsapi_POC.py +0 -0
  88. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/main.py +0 -0
  89. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/misc.py +0 -0
  90. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/pdf/__init__.py +0 -0
  91. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/pdf/one.pdf +0 -0
  92. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/pdf/two.pdf +0 -0
  93. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/periodic.py +0 -0
  94. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/protocol_constants.py +0 -0
  95. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/samplers.py +0 -0
  96. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/tag.py +0 -0
  97. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/tools/snmp_client2.py +0 -0
  98. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada/tools/walk.py +0 -0
  99. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada.egg-info/dependency_links.txt +0 -0
  100. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada.egg-info/entry_points.txt +0 -0
  101. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada.egg-info/requires.txt +0 -0
  102. {pymscada-0.2.1 → pymscada-0.2.2}/src/pymscada.egg-info/top_level.txt +0 -0
  103. {pymscada-0.2.1 → pymscada-0.2.2}/tests/test_alarms.py +0 -0
  104. {pymscada-0.2.1 → pymscada-0.2.2}/tests/test_bus_server.py +0 -0
  105. {pymscada-0.2.1 → pymscada-0.2.2}/tests/test_history.py +0 -0
  106. {pymscada-0.2.1 → pymscada-0.2.2}/tests/test_misc.py +0 -0
  107. {pymscada-0.2.1 → pymscada-0.2.2}/tests/test_opnotes.py +0 -0
  108. {pymscada-0.2.1 → pymscada-0.2.2}/tests/test_periodic.py +0 -0
  109. {pymscada-0.2.1 → pymscada-0.2.2}/tests/test_samplers.py +0 -0
  110. {pymscada-0.2.1 → 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.1
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pymscada"
3
- version = "0.2.1"
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"},
@@ -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, rta_cb, alarms) -> None:
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]
@@ -2,7 +2,7 @@ bus_ip: 127.0.0.1
2
2
  bus_port: 1324
3
3
  proxy:
4
4
  api:
5
- api_key: ${OPENWEATHERMAP_API_KEY}
5
+ api_key: ${MSCADA_OPENWEATHERMAP_API_KEY}
6
6
  units: metric
7
7
  locations:
8
8
  Murupara:
@@ -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,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
@@ -0,0 +1,11 @@
1
+ bus_ip: 127.0.0.1
2
+ bus_port: 1324
3
+ sms_send_tag: __sms_send__
4
+ sms_recv_tag: __sms_recv__
5
+ modem: rut241
6
+ modem_ip: ${MSCADA_SMS_IP}
7
+ username: ${MSCADA_SMS_USERNAME}
8
+ password: ${MSCADA_SMS_PASSWORD}
9
+ listen_port: 8080
10
+ info:
11
+ __default__: info
@@ -13,6 +13,9 @@ __opnotes__:
13
13
  __alarms__:
14
14
  desc: Alarms
15
15
  type: dict
16
+ info:
17
+ desc: Info
18
+ type: str
16
19
  IntSet:
17
20
  desc: Integer Setpoint
18
21
  type: int
@@ -3,8 +3,8 @@ bus_port: 1324
3
3
  proxy:
4
4
  api:
5
5
  url: 'https://api.electricityinfo.co.nz'
6
- client_id: ${WITS_CLIENT_ID}
7
- client_secret: ${WITS_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 = 0
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