pymscada 0.2.0rc6__tar.gz → 0.2.0rc7__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 (98) hide show
  1. {pymscada-0.2.0rc6/src/pymscada.egg-info → pymscada-0.2.0rc7}/PKG-INFO +1 -1
  2. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/pyproject.toml +1 -1
  3. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/alarms.py +8 -9
  4. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/bus_client.py +2 -2
  5. pymscada-0.2.0rc7/src/pymscada/callout.py +203 -0
  6. pymscada-0.2.0rc7/src/pymscada/demo/callout.yaml +16 -0
  7. pymscada-0.2.0rc7/src/pymscada/demo/pymscada-io-witsapi.service +15 -0
  8. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/demo/tags.yaml +4 -0
  9. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7/src/pymscada.egg-info}/PKG-INFO +1 -1
  10. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada.egg-info/SOURCES.txt +5 -1
  11. pymscada-0.2.0rc7/tests/test_callout.py +146 -0
  12. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/LICENSE +0 -0
  13. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/MANIFEST.in +0 -0
  14. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/README.md +0 -0
  15. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/setup.cfg +0 -0
  16. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/__init__.py +0 -0
  17. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/__main__.py +0 -0
  18. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/bus_server.py +0 -0
  19. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/checkout.py +0 -0
  20. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/config.py +0 -0
  21. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/console.py +0 -0
  22. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/demo/README.md +0 -0
  23. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/demo/__init__.py +0 -0
  24. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/demo/__pycache__/__init__.cpython-311.pyc +0 -0
  25. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/demo/accuweather.yaml +0 -0
  26. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/demo/alarms.yaml +0 -0
  27. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/demo/bus.yaml +0 -0
  28. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/demo/files.yaml +0 -0
  29. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/demo/history.yaml +0 -0
  30. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/demo/logixclient.yaml +0 -0
  31. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/demo/modbus_plc.py +0 -0
  32. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/demo/modbusclient.yaml +0 -0
  33. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/demo/modbusserver.yaml +0 -0
  34. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/demo/openweather.yaml +0 -0
  35. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/demo/opnotes.yaml +0 -0
  36. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/demo/ping.yaml +0 -0
  37. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/demo/pymscada-alarms.service +0 -0
  38. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/demo/pymscada-bus.service +0 -0
  39. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/demo/pymscada-demo-modbus_plc.service +0 -0
  40. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/demo/pymscada-files.service +0 -0
  41. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/demo/pymscada-history.service +0 -0
  42. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/demo/pymscada-io-logixclient.service +0 -0
  43. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/demo/pymscada-io-modbusclient.service +0 -0
  44. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/demo/pymscada-io-modbusserver.service +0 -0
  45. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/demo/pymscada-io-openweather.service +0 -0
  46. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/demo/pymscada-io-ping.service +0 -0
  47. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/demo/pymscada-io-snmpclient.service +0 -0
  48. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/demo/pymscada-opnotes.service +0 -0
  49. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/demo/pymscada-wwwserver.service +0 -0
  50. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/demo/snmpclient.yaml +0 -0
  51. /pymscada-0.2.0rc6/src/pymscada/demo/wits.yaml → /pymscada-0.2.0rc7/src/pymscada/demo/witsapi.yaml +0 -0
  52. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/demo/wwwserver.yaml +0 -0
  53. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/files.py +0 -0
  54. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/history.py +0 -0
  55. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/iodrivers/__init__.py +0 -0
  56. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/iodrivers/accuweather.py +0 -0
  57. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/iodrivers/logix_client.py +0 -0
  58. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/iodrivers/logix_map.py +0 -0
  59. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/iodrivers/modbus_client.py +0 -0
  60. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/iodrivers/modbus_map.py +0 -0
  61. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/iodrivers/modbus_server.py +0 -0
  62. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/iodrivers/openweather.py +0 -0
  63. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/iodrivers/ping_client.py +0 -0
  64. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/iodrivers/ping_map.py +0 -0
  65. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/iodrivers/snmp_client.py +0 -0
  66. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/iodrivers/snmp_map.py +0 -0
  67. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/iodrivers/witsapi.py +0 -0
  68. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/iodrivers/witsapi_POC.py +0 -0
  69. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/main.py +0 -0
  70. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/misc.py +0 -0
  71. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/module_config.py +0 -0
  72. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/opnotes.py +0 -0
  73. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/pdf/__init__.py +0 -0
  74. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/pdf/__pycache__/__init__.cpython-311.pyc +0 -0
  75. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/pdf/one.pdf +0 -0
  76. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/pdf/two.pdf +0 -0
  77. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/periodic.py +0 -0
  78. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/protocol_constants.py +0 -0
  79. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/samplers.py +0 -0
  80. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/tag.py +0 -0
  81. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/tools/snmp_client2.py +0 -0
  82. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/tools/walk.py +0 -0
  83. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/validate.py +0 -0
  84. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada/www_server.py +0 -0
  85. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada.egg-info/dependency_links.txt +0 -0
  86. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada.egg-info/entry_points.txt +0 -0
  87. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada.egg-info/requires.txt +0 -0
  88. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/src/pymscada.egg-info/top_level.txt +0 -0
  89. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/tests/test_alarms.py +0 -0
  90. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/tests/test_bus_server.py +0 -0
  91. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/tests/test_config.py +0 -0
  92. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/tests/test_history.py +0 -0
  93. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/tests/test_misc.py +0 -0
  94. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/tests/test_opnotes.py +0 -0
  95. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/tests/test_periodic.py +0 -0
  96. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/tests/test_samplers.py +0 -0
  97. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/tests/test_tag.py +0 -0
  98. {pymscada-0.2.0rc6 → pymscada-0.2.0rc7}/tests/test_validate.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pymscada
3
- Version: 0.2.0rc6
3
+ Version: 0.2.0rc7
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.0rc6"
3
+ version = "0.2.0rc7"
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"},
@@ -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 'area' not in tag:
50
- tag['area'] = ''
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, area: str, rta_cb, alarms) -> None:
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.area = area
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.area
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
- area = tag['area']
225
+ group = tag['group']
228
226
  for alarm in tag['alarm']:
229
- new_alarm = Alarm(tagname, tag, alarm, area, self.rta_cb, self)
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, tag_info=None,
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pymscada
3
- Version: 0.2.0rc6
3
+ Version: 0.2.0rc7
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
@@ -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/wits.yaml
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,146 @@
1
+ """Test Callout."""
2
+ import pytest
3
+ from pymscada.callout import Callout, alarm_in_callee_group, ALM
4
+ import time
5
+
6
+
7
+ BUS_ID = 123
8
+ CALLEES = [
9
+ {
10
+ 'name': 'Name Lazy',
11
+ 'sms': 'Lazy number',
12
+ 'delay': 1000,
13
+ 'group': ['NoZone']
14
+ },
15
+ {
16
+ 'name': 'Name System',
17
+ 'sms': 'System number',
18
+ 'delay': 1000,
19
+ 'group': ['System']
20
+ },
21
+ {
22
+ 'name': 'Name All',
23
+ 'sms': 'All number',
24
+ 'delay': 1000,
25
+ 'group': []
26
+ }
27
+ ]
28
+ GROUPS = [
29
+ {
30
+ 'name': 'My Group',
31
+ 'group': 'System'
32
+ }
33
+ ]
34
+
35
+ @pytest.fixture(scope='function')
36
+ def callout():
37
+ """Create a fixture for Callout."""
38
+ return Callout(bus_ip=None, callees=CALLEES, groups=GROUPS)
39
+
40
+
41
+ def test_alarm_in_callee_group(callout):
42
+ """Test group in group."""
43
+ # obvious match
44
+ assert alarm_in_callee_group(['Test'], ['Test']) == 'Test'
45
+ # empty callee_group should match all, otherwise match explicit
46
+ assert alarm_in_callee_group([], []) == ''
47
+ assert alarm_in_callee_group(['Test'], []) == 'Test'
48
+ assert alarm_in_callee_group([], ['Test']) == None
49
+ # return the first matching group from alarms if there is a match
50
+ assert alarm_in_callee_group(['Test', '2'], ['Test', '2']) == 'Test'
51
+ # return None is there is no match
52
+ assert alarm_in_callee_group(['Other'], ['Test']) is None
53
+
54
+
55
+ def test_callout(callout):
56
+ """Basic tests."""
57
+ co = callout
58
+ # test callees and groups setup from config variables
59
+ assert co.callees[0]['name'] == 'Name Lazy'
60
+ assert co.callees[0]['delay_ms'] == 1000000
61
+ assert co.callees[0]['group'] == ['NoZone']
62
+ assert co.groups[0]['name'] == 'My Group'
63
+ assert co.groups[0]['group'] == 'System'
64
+ # test update in the callee group
65
+ callee_update = {
66
+ 'action': 'MODIFY',
67
+ 'name': 'Name Lazy',
68
+ 'group': ['Still No Zone']
69
+ }
70
+ co.rta_cb(callee_update)
71
+ assert co.callees[0]['delay_ms'] == 1000000
72
+ assert co.callees[0]['group'] == ['Still No Zone']
73
+
74
+
75
+ def test_new_alarm(callout):
76
+ """Test new alarm."""
77
+ values = []
78
+
79
+ def rta_cb(tag):
80
+ # the SMS modem module should monitor this tag to send SMSs
81
+ values.append(tag.value)
82
+
83
+ co = callout
84
+ co.rta.add_callback(rta_cb, BUS_ID)
85
+ # alarm should go to All and System
86
+ alarm = {
87
+ 'date_ms': 12345,
88
+ 'alarm_string': 'sys alarm',
89
+ 'kind': ALM,
90
+ 'desc': 'System Alarm',
91
+ 'group': ['System']
92
+ }
93
+ co.alarms_cb(alarm)
94
+ co.check_callouts()
95
+ # doesn't have to be in the order in CALLEES, but it is so ...
96
+ assert values[0]['action'] == 'SMS'
97
+ assert values[0]['sms'] == 'System number'
98
+ assert values[1]['action'] == 'SMS'
99
+ assert values[1]['sms'] == 'All number'
100
+ assert 'Name Lazy' not in co.alarms[0]['sent']
101
+ assert 'Name System' in co.alarms[0]['sent']
102
+ assert 'Name All' in co.alarms[0]['sent']
103
+ # Alarm should go to All
104
+ alarm = {
105
+ 'date_ms': 12345,
106
+ 'alarm_string': 'all alarm',
107
+ 'kind': ALM,
108
+ 'desc': 'Broadcast Alarm',
109
+ 'group': []
110
+ }
111
+ co.alarms_cb(alarm)
112
+ co.check_callouts()
113
+ assert values[2]['action'] == 'SMS'
114
+ assert values[2]['sms'] == 'All number'
115
+ assert 'Name Lazy' not in co.alarms[1]['sent']
116
+ assert 'Name All' in co.alarms[1]['sent']
117
+ assert 'Name System' not in co.alarms[1]['sent']
118
+
119
+
120
+ def test_ack_functionality(callout):
121
+ """Test ACK functionality with sent hash."""
122
+ co = callout
123
+ alarm = {
124
+ 'date_ms': 12345,
125
+ 'alarm_string': 'sys alarm',
126
+ 'kind': ALM,
127
+ 'desc': 'System Alarm',
128
+ 'group': ['System']
129
+ }
130
+ co.alarms_cb(alarm)
131
+ co.check_callouts()
132
+ assert 'Name Lazy' not in co.alarms[0]['sent']
133
+ assert 'Name System' in co.alarms[0]['sent']
134
+ assert 'Name All' in co.alarms[0]['sent']
135
+ co.ack_cb('__all')
136
+ assert len(co.alarms) == 0
137
+ co.alarms_cb(alarm)
138
+ co.check_callouts()
139
+ co.ack_cb('Lazy number')
140
+ assert len(co.alarms) == 1
141
+ co.ack_cb('System number')
142
+ assert len(co.alarms) == 0
143
+ co.alarms_cb(alarm)
144
+ co.check_callouts()
145
+ co.ack_cb('All number')
146
+ assert len(co.alarms) == 0
File without changes
File without changes
File without changes
File without changes