pymscada 0.2.0rc4__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 (100) hide show
  1. {pymscada-0.2.0rc4/src/pymscada.egg-info → pymscada-0.2.0rc7}/PKG-INFO +3 -2
  2. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/pyproject.toml +1 -1
  3. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/alarms.py +166 -171
  4. {pymscada-0.2.0rc4 → 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.0rc4 → pymscada-0.2.0rc7}/src/pymscada/console.py +4 -3
  7. pymscada-0.2.0rc7/src/pymscada/demo/callout.yaml +16 -0
  8. pymscada-0.2.0rc7/src/pymscada/demo/pymscada-io-witsapi.service +15 -0
  9. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/demo/tags.yaml +4 -0
  10. pymscada-0.2.0rc7/src/pymscada/demo/witsapi.yaml +17 -0
  11. pymscada-0.2.0rc7/src/pymscada/iodrivers/witsapi.py +217 -0
  12. pymscada-0.2.0rc7/src/pymscada/iodrivers/witsapi_POC.py +246 -0
  13. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/module_config.py +12 -0
  14. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7/src/pymscada.egg-info}/PKG-INFO +3 -2
  15. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada.egg-info/SOURCES.txt +7 -1
  16. pymscada-0.2.0rc7/tests/test_alarms.py +198 -0
  17. pymscada-0.2.0rc7/tests/test_callout.py +146 -0
  18. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/tests/test_history.py +55 -0
  19. pymscada-0.2.0rc4/tests/test_alarms.py +0 -128
  20. pymscada-0.2.0rc4/tests/test_openweather.py +0 -46
  21. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/LICENSE +0 -0
  22. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/MANIFEST.in +0 -0
  23. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/README.md +0 -0
  24. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/setup.cfg +0 -0
  25. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/__init__.py +0 -0
  26. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/__main__.py +0 -0
  27. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/bus_server.py +0 -0
  28. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/checkout.py +0 -0
  29. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/config.py +0 -0
  30. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/demo/README.md +0 -0
  31. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/demo/__init__.py +0 -0
  32. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/demo/__pycache__/__init__.cpython-311.pyc +0 -0
  33. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/demo/accuweather.yaml +0 -0
  34. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/demo/alarms.yaml +0 -0
  35. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/demo/bus.yaml +0 -0
  36. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/demo/files.yaml +0 -0
  37. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/demo/history.yaml +0 -0
  38. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/demo/logixclient.yaml +0 -0
  39. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/demo/modbus_plc.py +0 -0
  40. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/demo/modbusclient.yaml +0 -0
  41. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/demo/modbusserver.yaml +0 -0
  42. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/demo/openweather.yaml +0 -0
  43. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/demo/opnotes.yaml +0 -0
  44. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/demo/ping.yaml +0 -0
  45. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/demo/pymscada-alarms.service +0 -0
  46. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/demo/pymscada-bus.service +0 -0
  47. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/demo/pymscada-demo-modbus_plc.service +0 -0
  48. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/demo/pymscada-files.service +0 -0
  49. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/demo/pymscada-history.service +0 -0
  50. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/demo/pymscada-io-logixclient.service +0 -0
  51. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/demo/pymscada-io-modbusclient.service +0 -0
  52. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/demo/pymscada-io-modbusserver.service +0 -0
  53. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/demo/pymscada-io-openweather.service +0 -0
  54. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/demo/pymscada-io-ping.service +0 -0
  55. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/demo/pymscada-io-snmpclient.service +0 -0
  56. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/demo/pymscada-opnotes.service +0 -0
  57. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/demo/pymscada-wwwserver.service +0 -0
  58. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/demo/snmpclient.yaml +0 -0
  59. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/demo/wwwserver.yaml +0 -0
  60. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/files.py +0 -0
  61. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/history.py +0 -0
  62. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/iodrivers/__init__.py +0 -0
  63. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/iodrivers/accuweather.py +0 -0
  64. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/iodrivers/logix_client.py +0 -0
  65. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/iodrivers/logix_map.py +0 -0
  66. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/iodrivers/modbus_client.py +0 -0
  67. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/iodrivers/modbus_map.py +0 -0
  68. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/iodrivers/modbus_server.py +0 -0
  69. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/iodrivers/openweather.py +0 -0
  70. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/iodrivers/ping_client.py +0 -0
  71. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/iodrivers/ping_map.py +0 -0
  72. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/iodrivers/snmp_client.py +0 -0
  73. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/iodrivers/snmp_map.py +0 -0
  74. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/main.py +0 -0
  75. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/misc.py +0 -0
  76. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/opnotes.py +0 -0
  77. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/pdf/__init__.py +0 -0
  78. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/pdf/__pycache__/__init__.cpython-311.pyc +0 -0
  79. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/pdf/one.pdf +0 -0
  80. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/pdf/two.pdf +0 -0
  81. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/periodic.py +0 -0
  82. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/protocol_constants.py +0 -0
  83. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/samplers.py +0 -0
  84. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/tag.py +0 -0
  85. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/tools/snmp_client2.py +0 -0
  86. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/tools/walk.py +0 -0
  87. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/validate.py +0 -0
  88. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada/www_server.py +0 -0
  89. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada.egg-info/dependency_links.txt +0 -0
  90. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada.egg-info/entry_points.txt +0 -0
  91. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada.egg-info/requires.txt +0 -0
  92. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/src/pymscada.egg-info/top_level.txt +0 -0
  93. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/tests/test_bus_server.py +0 -0
  94. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/tests/test_config.py +0 -0
  95. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/tests/test_misc.py +0 -0
  96. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/tests/test_opnotes.py +0 -0
  97. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/tests/test_periodic.py +0 -0
  98. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/tests/test_samplers.py +0 -0
  99. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/tests/test_tag.py +0 -0
  100. {pymscada-0.2.0rc4 → pymscada-0.2.0rc7}/tests/test_validate.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: pymscada
3
- Version: 0.2.0rc4
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
@@ -21,6 +21,7 @@ Requires-Dist: pymscada-html==0.2.0rc4
21
21
  Requires-Dist: cerberus>=1.3.5
22
22
  Requires-Dist: pycomm3>=1.2.14
23
23
  Requires-Dist: pysnmplib>=5.0.24
24
+ Dynamic: license-file
24
25
 
25
26
  # pymscada
26
27
  #### [Docs](https://github.com/jamie0walton/pymscada/blob/main/docs/README.md)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pymscada"
3
- version = "0.2.0rc4"
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"},
@@ -3,18 +3,39 @@ import logging
3
3
  import sqlite3 # note that sqlite3 has blocking calls
4
4
  import socket
5
5
  import time
6
- import atexit
7
6
  from pymscada.bus_client import BusClient
7
+ from pymscada.periodic import Periodic
8
8
  from pymscada.tag import Tag, TYPES
9
9
 
10
10
  ALM = 0
11
11
  RTN = 1
12
12
  ACT = 2
13
13
  INF = 3
14
-
14
+ KIND = {
15
+ ALM: 'ALM',
16
+ RTN: 'RTN',
17
+ ACT: 'ACT',
18
+ INF: 'INF'
19
+ }
15
20
  NORMAL = 0
16
21
  ALARM = 1
17
22
 
23
+ """
24
+ Database schema:
25
+
26
+ alarms contains an event log of changes as they occur, this
27
+ includes information on actions taken by the alarm system.
28
+
29
+ CREATE TABLE IF NOT EXISTS alarms (
30
+ id INTEGER PRIMARY KEY ASC,
31
+ date_ms INTEGER,
32
+ alarm_string TEXT,
33
+ kind INTEGER, # one of ALM, RTN, ACT, INF
34
+ desc TEXT,
35
+ group TEXT
36
+ )
37
+ """
38
+
18
39
 
19
40
  def standardise_tag_info(tagname: str, tag: dict):
20
41
  """Correct tag dictionary in place to be suitable for modules."""
@@ -23,6 +44,8 @@ def standardise_tag_info(tagname: str, tag: dict):
23
44
  if 'desc' not in tag:
24
45
  logging.warning(f"Tag {tagname} has no description, using name")
25
46
  tag['desc'] = tag['name']
47
+ if 'group' not in tag:
48
+ tag['group'] = ''
26
49
  if 'multi' in tag:
27
50
  tag['type'] = int
28
51
  else:
@@ -40,51 +63,116 @@ def standardise_tag_info(tagname: str, tag: dict):
40
63
  tag['dp'] = 2
41
64
  if 'units' not in tag:
42
65
  tag['units'] = ''
66
+ if 'alarm' in tag:
67
+ if isinstance(tag['alarm'], str):
68
+ tag['alarm'] = [tag['alarm']]
69
+ if not isinstance(tag['alarm'], list):
70
+ logging.warning(f"Tag {tagname} has invalid alarm {tag['alarm']}")
71
+ del tag['alarm']
72
+
43
73
 
74
+ def split_operator(alarm: str) -> dict:
75
+ """Split alarm string into operator and value."""
76
+ tokens = alarm.split(' ')
77
+ alm_dict = {'for': 0}
78
+ if len(tokens) not in (2, 4):
79
+ raise ValueError(f"Invalid alarm {alarm}")
80
+ if tokens[0] not in ['>', '<', '==', '>=', '<=']:
81
+ raise ValueError(f"Invalid alarm {alarm}")
82
+ alm_dict['operator'] = tokens[0]
83
+ try:
84
+ alm_dict['value'] = float(tokens[1])
85
+ except ValueError:
86
+ raise ValueError(f"Invalid alarm {alarm}")
87
+ if len(tokens) == 4:
88
+ if tokens[2] != 'for':
89
+ raise ValueError(f"Invalid alarm {alarm}")
90
+ try:
91
+ alm_dict['for'] = int(tokens[3])
92
+ except ValueError:
93
+ raise ValueError(f"Invalid alarm {alarm}")
94
+ return alm_dict
44
95
 
45
- class Alarm:
46
- """Manages multiple alarm conditions for a single tag."""
47
- def __init__(self, tag: Tag, conditions: str | list[str]):
96
+
97
+ class Alarm():
98
+ """
99
+ Single alarm class.
100
+
101
+ Alarms are defined by a tag and a condition. Tags may have multiple
102
+ conditions, each combination of tag and condition is a separate Alarm.
103
+
104
+ Monitors tag value through the Tag callback. Tracks in alarm state.
105
+ Generates the ALM and RTN messages for Alarms to publish via rta_tag.
106
+ """
107
+
108
+ def __init__(self, tagname: str, tag: dict, alarm: str, group: str, rta_cb, alarms) -> None:
48
109
  """Initialize alarm with tag and condition(s)."""
49
- if tag.type not in (int, float):
50
- raise ValueError(f"Alarms only supported for numeric types, not {tag.type}")
51
-
52
- self.tag = tag
53
- self.tests: list[tuple[str, callable, float]] = []
54
-
55
- # Handle both string and list conditions
56
- if isinstance(conditions, str):
57
- conditions = [conditions]
58
-
59
- for condition in conditions:
60
- operator_str, value = condition.split(' ')
61
- self.tests.append((
62
- condition,
63
- {
64
- '==': (lambda x, y: x == y),
65
- '<': (lambda x, y: x < y),
66
- '>': (lambda x, y: x > y),
67
- '<=': (lambda x, y: x <= y),
68
- '>=': (lambda x, y: x >= y)
69
- }[operator_str],
70
- float(value)
71
- ))
72
-
73
- def check_conditions(self, in_alarm: set[str]) -> list[tuple[str, bool, float, int]]:
74
- """Check all conditions and return list of changes.
75
- Returns list of (alarm_ref, is_in_alarm, value, timestamp) for changed states."""
76
- changes = []
77
- for condition_str, test, value in self.tests:
78
- alarm_ref = f"{self.tag.name} {condition_str}"
79
- is_in_alarm = test(self.tag.value, value)
80
-
81
- if is_in_alarm and alarm_ref not in in_alarm:
82
- changes.append((alarm_ref, True, self.tag.value, self.tag.time_us))
83
-
84
- elif not is_in_alarm and alarm_ref in in_alarm:
85
- changes.append((alarm_ref, False, self.tag.value, self.tag.time_us))
86
-
87
- return changes
110
+ self.alarm_id = f'{tagname} {alarm}'
111
+ self.tag = Tag(tagname, tag['type'])
112
+ self.tag.desc = tag['desc']
113
+ self.tag.dp = tag['dp']
114
+ self.tag.units = tag['units']
115
+ self.tag.add_callback(self.callback)
116
+ self.group = group
117
+ self.rta_cb = rta_cb
118
+ self.alarms = alarms
119
+ self.alarm = split_operator(alarm)
120
+ self.in_alarm = False
121
+ self.checking = False
122
+
123
+ def callback(self, tag: Tag):
124
+ """Handle tag value changes and generate ALM/RTN messages."""
125
+ if tag.value is None:
126
+ return
127
+ value = float(tag.value)
128
+ time_us = tag.time_us
129
+ new_in_alarm = False
130
+ op = self.alarm['operator']
131
+ if op == '>':
132
+ new_in_alarm = value > self.alarm['value']
133
+ elif op == '<':
134
+ new_in_alarm = value < self.alarm['value']
135
+ elif op == '==':
136
+ new_in_alarm = value == self.alarm['value']
137
+ elif op == '>=':
138
+ new_in_alarm = value >= self.alarm['value']
139
+ elif op == '<=':
140
+ new_in_alarm = value <= self.alarm['value']
141
+ if new_in_alarm == self.in_alarm:
142
+ return
143
+ self.in_alarm = new_in_alarm
144
+ if self.in_alarm:
145
+ if self.alarm['for'] > 0:
146
+ if not self.checking:
147
+ self.checking = True
148
+ self.alarms.checking_alarms.append(self)
149
+ else:
150
+ self.generate_alarm(ALM, time_us, value)
151
+ else:
152
+ if self.checking:
153
+ self.checking = False
154
+ self.alarms.checking_alarms.remove(self)
155
+ self.generate_alarm(RTN, time_us, value)
156
+
157
+ def generate_alarm(self, kind: int, time_us: int, value: float):
158
+ """Generate alarm message."""
159
+ logging.warning(f'Alarm {self.alarm_id} {value} {KIND[kind]}')
160
+ self.rta_cb({
161
+ 'action': 'ADD',
162
+ 'date_ms': int(time_us / 1000),
163
+ 'alarm_string': self.alarm_id,
164
+ 'kind': kind,
165
+ 'desc': f'{self.tag.desc} {value:.{self.tag.dp}f}'
166
+ f' {self.tag.units}',
167
+ 'group': self.group
168
+ })
169
+
170
+ def check_duration(self, current_time_us: int):
171
+ """Check if alarm condition has been met for required duration."""
172
+ if current_time_us - self.tag.time_us >= self.alarm['for'] * 1000000:
173
+ self.generate_alarm(ALM, current_time_us, self.tag.value)
174
+ self.checking = False
175
+ self.alarms.checking_alarms.remove(self)
88
176
 
89
177
 
90
178
  class Alarms:
@@ -128,124 +216,57 @@ class Alarms:
128
216
  raise ValueError('table must be a non-empty string')
129
217
 
130
218
  logging.warning(f'Alarms {bus_ip} {bus_port} {db} {rta_tag}')
131
- self.connection = sqlite3.connect(db)
132
- self.tags: dict[str, Tag] = {}
133
- self.alarms: dict[str, Alarm] = {}
134
- self.in_alarm: dict[str, int] = {}
219
+ self.alarms: list[Alarm] = []
220
+ self.checking_alarms: list[Alarm] = []
135
221
  for tagname, tag in tag_info.items():
136
222
  standardise_tag_info(tagname, tag)
137
223
  if 'alarm' not in tag or tag['type'] not in (int, float):
138
224
  continue
139
- self.tags[tagname] = Tag(tagname, tag['type'])
140
- self.tags[tagname].desc = tag['desc']
141
- self.tags[tagname].dp = tag['dp']
142
- self.tags[tagname].units = tag['units']
143
- self.tags[tagname].add_callback(self.alarm_cb)
144
- self.alarms[tagname] = Alarm(self.tags[tagname], tag['alarm'])
145
- self.table = table
146
- self.cursor = self.connection.cursor()
225
+ group = tag['group']
226
+ for alarm in tag['alarm']:
227
+ new_alarm = Alarm(tagname, tag, alarm, group, self.rta_cb,
228
+ self)
229
+ self.alarms.append(new_alarm)
147
230
  self.busclient = BusClient(bus_ip, bus_port, module='Alarms')
148
231
  self.rta = Tag(rta_tag, dict)
149
232
  self.rta.value = {}
150
233
  self.busclient.add_callback_rta(rta_tag, self.rta_cb)
151
- atexit.register(self.close)
152
-
153
- def alarm_cb(self, tag: Tag):
154
- """Callback for alarm tags."""
155
- if tag.name not in self.alarms:
156
- return
157
- changes = self.alarms[tag.name].check_conditions(self.in_alarm)
158
- for alarm_ref, is_in_alarm, value, time_us in changes:
159
- self._handle_alarm_change(
160
- alarm_ref,
161
- is_in_alarm,
162
- tag,
163
- value,
164
- time_us
165
- )
166
-
167
- def _handle_alarm_change(self, alarm_ref: str, is_in_alarm: bool,
168
- tag: Tag, value: float, time_us: int):
169
- """Handle alarm state changes and database updates."""
170
- if is_in_alarm:
171
- logging.warning(f'Alarm {alarm_ref} {value}')
172
- kind = ALM
173
- state = ALARM
174
- alarm_record = {
175
- 'action': 'ADD',
176
- 'date_ms': int(time_us / 1000),
177
- 'tag_alm': alarm_ref,
178
- 'kind': kind,
179
- 'desc': f'{tag.desc} {value:.{tag.dp}f} {tag.units}',
180
- 'in_alm': state
181
- }
182
- self.rta_cb(alarm_record)
183
- self.in_alarm[alarm_ref] = self.rta.value['id']
184
- else:
185
- logging.info(f'No alarm {alarm_ref} {value}')
186
- if alarm_ref in self.in_alarm:
187
- # First update the existing alarm record to NORMAL
188
- update_record = {
189
- 'action': 'UPDATE',
190
- 'id': self.in_alarm[alarm_ref],
191
- 'in_alm': NORMAL
192
- }
193
- self.rta_cb(update_record)
194
-
195
- # Then add the RTN record
196
- rtn_record = {
197
- 'action': 'ADD',
198
- 'date_ms': int(time_us / 1000),
199
- 'tag_alm': alarm_ref,
200
- 'kind': RTN,
201
- 'desc': f'{tag.desc} {value:.{tag.dp}f} {tag.units}',
202
- 'in_alm': NORMAL
203
- }
204
- self.rta_cb(rtn_record)
205
- del self.in_alarm[alarm_ref]
234
+ self._init_db(db, table)
235
+ self.periodic = Periodic(self.periodic_cb, 1.0)
206
236
 
207
- def _init_table(self):
237
+ def _init_db(self, db, table):
208
238
  """Initialize the database table schema."""
239
+ self.connection = sqlite3.connect(db)
240
+ self.table = table
241
+ self.cursor = self.connection.cursor()
209
242
  query = (
210
- 'CREATE TABLE IF NOT EXISTS ' + self.table +
243
+ 'CREATE TABLE IF NOT EXISTS ' + self.table + ' '
211
244
  '(id INTEGER PRIMARY KEY ASC, '
212
245
  'date_ms INTEGER, '
213
- 'tag_alm TEXT, '
246
+ 'alarm_string TEXT, '
214
247
  'kind INTEGER, '
215
248
  'desc TEXT, '
216
- 'in_alm INTEGER)'
249
+ '"group" TEXT)'
217
250
  )
218
251
  self.cursor.execute(query)
252
+ self.connection.commit()
219
253
 
220
- # Clear any existing ALARM states
221
- try:
222
- with self.connection:
223
- # Update all alarm records to NORMAL
224
- self.cursor.execute(
225
- f'SELECT id, tag_alm FROM {self.table} WHERE in_alm = ?',
226
- (ALARM,))
227
- alarm_records = self.cursor.fetchall()
228
- for record_id, tag_alm in alarm_records:
229
- update_record = {
230
- 'action': 'UPDATE',
231
- 'id': record_id,
232
- 'in_alm': NORMAL
233
- }
234
- self.rta_cb(update_record)
235
- except sqlite3.Error as e:
236
- logging.error(f'Error clearing alarm states during startup: {e}')
237
-
238
- # Add startup record using existing ADD functionality
239
254
  startup_record = {
240
255
  'action': 'ADD',
241
256
  'date_ms': int(time.time() * 1000),
242
- 'tag_alm': self.rta.name,
257
+ 'alarm_string': self.rta.name,
243
258
  'kind': INF,
244
259
  'desc': 'Alarm logging started',
245
- 'in_alm': NORMAL
260
+ 'group': '__system__'
246
261
  }
247
262
  self.rta_cb(startup_record)
248
263
 
264
+ async def periodic_cb(self):
265
+ """Periodic callback to check alarms."""
266
+ current_time_us = int(time.time() * 1000000)
267
+ for alarm in self.checking_alarms[:]:
268
+ alarm.check_duration(current_time_us)
269
+
249
270
  def rta_cb(self, request):
250
271
  """Respond to Request to Author and publish on rta_tag as needed."""
251
272
  if 'action' not in request:
@@ -256,18 +277,18 @@ class Alarms:
256
277
  with self.connection:
257
278
  self.cursor.execute(
258
279
  f'INSERT INTO {self.table} '
259
- '(date_ms, tag_alm, kind, desc, in_alm) '
260
- 'VALUES(:date_ms, :tag_alm, :kind, :desc, :in_alm) '
280
+ '(date_ms, alarm_string, kind, desc, "group") '
281
+ 'VALUES(:date_ms, :alarm_string, :kind, :desc, :group) '
261
282
  'RETURNING *;',
262
283
  request)
263
284
  res = self.cursor.fetchone()
264
285
  self.rta.value = {
265
286
  'id': res[0],
266
287
  'date_ms': res[1],
267
- 'tag_alm': res[2],
288
+ 'alarm_string': res[2],
268
289
  'kind': res[3],
269
290
  'desc': res[4],
270
- 'in_alm': res[5]
291
+ 'group': res[5]
271
292
  }
272
293
  except sqlite3.IntegrityError as error:
273
294
  logging.warning(f'Alarms rta_cb {error}')
@@ -284,10 +305,10 @@ class Alarms:
284
305
  self.rta.value = {
285
306
  'id': res[0],
286
307
  'date_ms': res[1],
287
- 'tag_alm': res[2],
308
+ 'alarm_string': res[2],
288
309
  'kind': res[3],
289
310
  'desc': res[4],
290
- 'in_alm': res[5]
311
+ 'group': res[5]
291
312
  }
292
313
  except sqlite3.IntegrityError as error:
293
314
  logging.warning(f'Alarms rta_cb update {error}')
@@ -303,10 +324,10 @@ class Alarms:
303
324
  '__rta_id__': request['__rta_id__'],
304
325
  'id': res[0],
305
326
  'date_ms': res[1],
306
- 'tag_alm': res[2],
327
+ 'alarm_string': res[2],
307
328
  'kind': res[3],
308
329
  'desc': res[4],
309
- 'in_alm': res[5]
330
+ 'group': res[5]
310
331
  }
311
332
  except sqlite3.IntegrityError as error:
312
333
  logging.warning(f'Alarms rta_cb {error}')
@@ -329,30 +350,4 @@ class Alarms:
329
350
  async def start(self):
330
351
  """Async startup."""
331
352
  await self.busclient.start()
332
- self._init_table()
333
-
334
- def close(self):
335
- """Clean shutdown of alarms logging."""
336
- for alarm_ref, record_id in self.in_alarm.items():
337
- update_record = {
338
- 'action': 'UPDATE',
339
- 'id': record_id,
340
- 'in_alm': NORMAL
341
- }
342
- try:
343
- self.rta_cb(update_record)
344
- except sqlite3.Error as e:
345
- logging.error(f'Error clearing alarm {alarm_ref}: {e}')
346
-
347
- shutdown_record = {
348
- 'action': 'ADD',
349
- 'date_ms': int(time.time() * 1000),
350
- 'tag_alm': self.rta.name,
351
- 'kind': INF,
352
- 'desc': 'Alarm logging stopped',
353
- 'in_alm': NORMAL
354
- }
355
- try:
356
- self.rta_cb(shutdown_record)
357
- except sqlite3.Error as e:
358
- logging.error(f'Error during alarm shutdown: {e}')
353
+ await self.periodic.start()
@@ -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()