pymscada 0.2.6b2__tar.gz → 0.2.6b10__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.
Files changed (107) hide show
  1. {pymscada-0.2.6b2/src/pymscada.egg-info → pymscada-0.2.6b10}/PKG-INFO +2 -2
  2. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/pyproject.toml +2 -2
  3. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/__init__.py +2 -2
  4. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/alarms.py +11 -5
  5. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/bus_client.py +2 -7
  6. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/bus_server.py +9 -8
  7. pymscada-0.2.6b10/src/pymscada/callout.py +304 -0
  8. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/iodrivers/modbus_client.py +189 -21
  9. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/iodrivers/modbus_map.py +17 -2
  10. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/opnotes.py +27 -15
  11. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/tag.py +6 -7
  12. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/www_server.py +1 -0
  13. {pymscada-0.2.6b2 → pymscada-0.2.6b10/src/pymscada.egg-info}/PKG-INFO +2 -2
  14. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada.egg-info/requires.txt +1 -1
  15. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/tests/test_alarms.py +36 -45
  16. pymscada-0.2.6b10/tests/test_callout.py +131 -0
  17. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/tests/test_opnotes.py +2 -2
  18. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/tests/test_sms.py +15 -0
  19. pymscada-0.2.6b2/src/pymscada/callout.py +0 -260
  20. pymscada-0.2.6b2/tests/test_callout.py +0 -151
  21. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/LICENSE +0 -0
  22. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/MANIFEST.in +0 -0
  23. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/README.md +0 -0
  24. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/setup.cfg +0 -0
  25. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/__main__.py +0 -0
  26. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/checkout.py +0 -0
  27. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/config.py +0 -0
  28. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/console.py +0 -0
  29. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/README.md +0 -0
  30. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/__init__.py +0 -0
  31. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/__pycache__/__init__.cpython-311.pyc +0 -0
  32. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/accuweather.yaml +0 -0
  33. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/alarms.yaml +0 -0
  34. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/bus.yaml +0 -0
  35. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/callout.yaml +0 -0
  36. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/files.yaml +0 -0
  37. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/history.yaml +0 -0
  38. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/logixclient.yaml +0 -0
  39. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/modbus_plc.py +0 -0
  40. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/modbusclient.yaml +0 -0
  41. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/modbusserver.yaml +0 -0
  42. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/openweather.yaml +0 -0
  43. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/opnotes.yaml +0 -0
  44. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/piapi.yaml +0 -0
  45. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/ping.yaml +0 -0
  46. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-alarms.service +0 -0
  47. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-bus.service +0 -0
  48. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-callout.service +0 -0
  49. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-demo-modbus_plc.service +0 -0
  50. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-files.service +0 -0
  51. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-history.service +0 -0
  52. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-io-logixclient.service +0 -0
  53. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-io-modbusclient.service +0 -0
  54. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-io-modbusserver.service +0 -0
  55. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-io-openweather.service +0 -0
  56. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-io-piapi.service +0 -0
  57. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-io-ping.service +0 -0
  58. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-io-sms.service +0 -0
  59. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-io-snmpclient.service +0 -0
  60. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-io-witsapi.service +0 -0
  61. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-opnotes.service +0 -0
  62. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-wwwserver.service +0 -0
  63. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/sms.yaml +0 -0
  64. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/snmpclient.yaml +0 -0
  65. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/tags.yaml +0 -0
  66. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/witsapi.yaml +0 -0
  67. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/wwwserver.yaml +0 -0
  68. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/files.py +0 -0
  69. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/history.py +0 -0
  70. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/iodrivers/__init__.py +0 -0
  71. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/iodrivers/accuweather.py +0 -0
  72. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/iodrivers/logix_client.py +0 -0
  73. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/iodrivers/logix_map.py +0 -0
  74. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/iodrivers/modbus_server.py +0 -0
  75. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/iodrivers/openweather.py +0 -0
  76. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/iodrivers/piapi.py +0 -0
  77. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/iodrivers/ping_client.py +0 -0
  78. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/iodrivers/ping_map.py +0 -0
  79. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/iodrivers/sms.py +0 -0
  80. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/iodrivers/snmp_client.py +0 -0
  81. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/iodrivers/snmp_map.py +0 -0
  82. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/iodrivers/witsapi.py +0 -0
  83. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/iodrivers/witsapi_POC.py +0 -0
  84. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/main.py +0 -0
  85. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/misc.py +0 -0
  86. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/module_config.py +0 -0
  87. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/pdf/__init__.py +0 -0
  88. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/pdf/__pycache__/__init__.cpython-311.pyc +0 -0
  89. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/pdf/one.pdf +0 -0
  90. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/pdf/two.pdf +0 -0
  91. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/periodic.py +0 -0
  92. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/protocol_constants.py +0 -0
  93. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/samplers.py +0 -0
  94. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/tools/get_history.py +0 -0
  95. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/tools/snmp_client2.py +0 -0
  96. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/tools/walk.py +0 -0
  97. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada.egg-info/SOURCES.txt +0 -0
  98. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada.egg-info/dependency_links.txt +0 -0
  99. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada.egg-info/entry_points.txt +0 -0
  100. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada.egg-info/top_level.txt +0 -0
  101. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/tests/test_bus_server.py +0 -0
  102. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/tests/test_config.py +0 -0
  103. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/tests/test_history.py +0 -0
  104. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/tests/test_misc.py +0 -0
  105. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/tests/test_periodic.py +0 -0
  106. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/tests/test_samplers.py +0 -0
  107. {pymscada-0.2.6b2 → pymscada-0.2.6b10}/tests/test_tag.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pymscada
3
- Version: 0.2.6b2
3
+ Version: 0.2.6b10
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.2
20
+ Requires-Dist: pymscada-html>=0.2.6b0
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.6b2"
3
+ version = "0.2.6b10"
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.2", # www_server
11
+ "pymscada-html>=0.2.6b0", # 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
@@ -2,7 +2,7 @@
2
2
  from pymscada.bus_client import BusClient
3
3
  from pymscada.bus_server import BusServer
4
4
  from pymscada.config import Config
5
- from pymscada.callout import Callout, alarm_in_group, ALM
5
+ from pymscada.callout import Callout, ALM
6
6
  from pymscada.iodrivers.accuweather import AccuWeatherClient
7
7
  from pymscada.iodrivers.logix_client import LogixClient
8
8
  from pymscada.iodrivers.modbus_client import ModbusClient
@@ -18,7 +18,7 @@ __all__ = [
18
18
  'BusClient',
19
19
  'BusServer',
20
20
  'Config',
21
- 'Callout', 'alarm_in_group', ALM,
21
+ 'Callout', 'ALM',
22
22
  'AccuWeatherClient',
23
23
  'LogixClient',
24
24
  'ModbusClient',
@@ -117,6 +117,8 @@ class Alarm():
117
117
  self.tag.add_callback(self.callback)
118
118
  self.group = group
119
119
  self.state_cb = state_cb
120
+ self.timing_value = None
121
+ self.timing_us = None
120
122
  self.alarm = split_operator(alarm)
121
123
  self.in_alarm = False
122
124
  self.disabled_until = 0
@@ -143,9 +145,15 @@ class Alarm():
143
145
  if new_in_alarm:
144
146
  if self.alarm['for'] > 0:
145
147
  self.state_cb(self, TIMING)
148
+ self.timing_us = tag.time_us
149
+ self.timing_value = value
146
150
  else:
147
151
  self.state_cb(self, ALM)
148
152
  else:
153
+ if self.timing_us is not None:
154
+ if tag.time_us - self.timing_us >= self.alarm['for'] * 1000000:
155
+ self.state_cb(self, ALM)
156
+ self.timing_us = None
149
157
  self.state_cb(self, RTN)
150
158
  self.in_alarm = new_in_alarm
151
159
 
@@ -220,17 +228,14 @@ class Alarms:
220
228
  self.connection = sqlite3.connect(db)
221
229
  self.table = table
222
230
  self.cursor = self.connection.cursor()
223
-
224
- # Check SQLite version for RETURNING clause support (requires >= 3.35.0)
225
231
  sqlite_version = sqlite3.sqlite_version_info
226
232
  self.has_returning = sqlite_version >= (3, 35, 0)
227
233
  if not self.has_returning:
228
234
  logging.warning(
229
- f'SQLite version {sqlite3.sqlite_version} is older than 3.35.0. '
235
+ f'SQLite {sqlite3.sqlite_version} is older than 3.35.0. '
230
236
  f'RETURNING clause not supported, using fallback method. '
231
237
  f'Consider upgrading SQLite for better performance.'
232
238
  )
233
-
234
239
  query = (
235
240
  'CREATE TABLE IF NOT EXISTS ' + self.table + ' '
236
241
  '(id INTEGER PRIMARY KEY ASC, '
@@ -242,7 +247,6 @@ class Alarms:
242
247
  )
243
248
  self.cursor.execute(query)
244
249
  self.connection.commit()
245
-
246
250
  startup_record = {
247
251
  'action': 'ADD',
248
252
  'date_ms': int(time.time() * 1000),
@@ -278,6 +282,8 @@ class Alarms:
278
282
  def generate_alarm(self, alarm: Alarm, kind: int):
279
283
  """Generate alarm message."""
280
284
  value = alarm.tag.value
285
+ if alarm.timing_us is not None:
286
+ value = alarm.timing_value
281
287
  time_us = alarm.tag.time_us
282
288
  logging.warning(f'Alarm {alarm.alarm_id} {value} {KIND[kind]}')
283
289
  self.rta_cb({
@@ -77,10 +77,6 @@ class BusClient:
77
77
  jsonstr = json.dumps(request).encode()
78
78
  size = len(jsonstr)
79
79
  data = struct.pack(f'>B{size}s', pc.TYPE.JSON, jsonstr)
80
- action = request.get("action", "unknown")
81
- tag_id = self.tag_by_name[tagname].id
82
- logging.info(f'{self.module}: RTA sending {tagname} {action} to'
83
- f' tag_id {tag_id}')
84
80
  self.write(pc.COMMAND.RTA, self.tag_by_name[tagname].id, time_us, data)
85
81
 
86
82
  def write(self, command: pc.COMMAND, tag_id: int, time_us: int,
@@ -221,9 +217,8 @@ class BusClient:
221
217
  data = struct.unpack_from(f'!{len(value) - 1}s', value, offset=1
222
218
  )[0].decode()
223
219
  data = json.loads(data)
224
- action = data.get("action", "unknown")
225
- logging.info(f'{self.module}: RTA received {tag.name} {action} '
226
- f'from tag_id {tag_id}')
220
+ logging.info(f'{self.module}: RTA received {tag.name} {data} '
221
+ f'from tag_id {tag_id}')
227
222
  try:
228
223
  self.rta_handlers[tag.name](data)
229
224
  except KeyError:
@@ -18,8 +18,7 @@ class BusTags(type):
18
18
  """Return existing tag if tagname already exists."""
19
19
  if tagname in cls._tag_by_name:
20
20
  return cls._tag_by_name[tagname]
21
- tag: 'BusTag' = cls.__new__(cls, tagname)
22
- tag.__init__(tagname)
21
+ tag: 'BusTag' = super().__call__(tagname)
23
22
  tag.id = cls._next_id
24
23
  cls._next_id += 1
25
24
  cls._tag_by_name[tagname] = tag
@@ -35,10 +34,10 @@ class BusTag(metaclass=BusTags):
35
34
  def __init__(self, name: bytes):
36
35
  """Name and id must be unique."""
37
36
  self.name = name
38
- self.id = None
37
+ self.id = 0
39
38
  self.time_us: int = 0
40
39
  self.value: bytes = b''
41
- self.from_bus: 'BusConnection' = None
40
+ self.from_bus: int = 0
42
41
  self.pub = []
43
42
 
44
43
  def add_callback(self, callback, bus_id):
@@ -51,7 +50,7 @@ class BusTag(metaclass=BusTags):
51
50
  if (callback, bus_id) in self.pub:
52
51
  self.pub.remove((callback, bus_id))
53
52
 
54
- def update(self, data: bytes, time_us: int, from_bus: 'BusConnection'):
53
+ def update(self, data: bytes, time_us: int, from_bus: int):
55
54
  """Assign value and update subscribers."""
56
55
  self.value = data
57
56
  self.time_us = time_us
@@ -111,7 +110,8 @@ class BusConnection():
111
110
  head = await self.reader.readexactly(14)
112
111
  _, cmd, tag_id, size, time_us = unpack('!BBHHQ', head)
113
112
  except (ConnectionResetError, asyncio.IncompleteReadError,
114
- asyncio.CancelledError):
113
+ asyncio.CancelledError) as e:
114
+ logging.warning(f'{self.addr} read error: {e}')
115
115
  break
116
116
  # if the command packet indicates data, get that too
117
117
  if size == 0:
@@ -121,7 +121,8 @@ class BusConnection():
121
121
  payload = await self.reader.readexactly(size)
122
122
  data = unpack(f'!{size}s', payload)[0]
123
123
  except (ConnectionResetError, asyncio.IncompleteReadError,
124
- asyncio.CancelledError):
124
+ asyncio.CancelledError) as e:
125
+ logging.warning(f'{self.addr} read payload error: {e}')
125
126
  break
126
127
  # if MAX_LEN then a continuation packet is required
127
128
  if size == pc.MAX_LEN:
@@ -289,7 +290,7 @@ class BusServer:
289
290
  self.bus_tag.value = f'\x03{client_addr}: {log_msg}'.encode()
290
291
  self.bus_tag.time_us = int(time.time() * 1e6)
291
292
  else: # consider disconnecting
292
- logging.warn(f'invalid message {cmd}')
293
+ logging.warning(f'invalid message {cmd}')
293
294
 
294
295
  def read_callback(self, command):
295
296
  """Process read messages, delete broken connections."""
@@ -0,0 +1,304 @@
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
+ RTN = 1
29
+
30
+ IDLE = 0
31
+ NEW_ALM = 1
32
+ CALLOUT = 2
33
+
34
+ SENT = 0
35
+ REMIND = 1
36
+
37
+
38
+ class CalloutCallee:
39
+ """Track status of callee for callout."""
40
+
41
+ def __init__(self, callee: dict):
42
+ if not isinstance(callee, dict) or 'name' not in callee or \
43
+ 'sms' not in callee:
44
+ logging.warning(f'Callee malformed {callee}')
45
+ return
46
+ self.name = callee['name']
47
+ self.sms = callee['sms']
48
+ self.role = callee.get('role', '')
49
+ self.group = callee.get('group', '')
50
+ self.delay_ms = 0
51
+
52
+ def set_role(self, role: str, delay_ms: int):
53
+ self.role = role
54
+ self.delay_ms = delay_ms
55
+
56
+ def set_group(self, group: str):
57
+ self.group = group
58
+
59
+
60
+ class CalloutAlarm:
61
+ """Track status of alarm for callout."""
62
+
63
+ def __init__(self, alm_tag_value):
64
+ if not isinstance(alm_tag_value, dict) or \
65
+ 'date_ms' not in alm_tag_value or \
66
+ 'alarm_string' not in alm_tag_value or \
67
+ 'desc' not in alm_tag_value or \
68
+ 'group' not in alm_tag_value or \
69
+ 'kind' not in alm_tag_value:
70
+ logging.warning(f'alarms_cb malformed {alm_tag_value}')
71
+ raise ValueError(f'alarms_cb malformed {alm_tag_value}')
72
+ self.date_ms = alm_tag_value['date_ms']
73
+ self.alarm_string = alm_tag_value['alarm_string']
74
+ self.desc = alm_tag_value['desc']
75
+ self.group = alm_tag_value['group']
76
+ self.sent: set[CalloutCallee] = set()
77
+ self.remind: set[CalloutCallee] = set()
78
+
79
+ def callee_in_group(self, callee: CalloutCallee, groups: dict):
80
+ if callee.group == '':
81
+ return True
82
+ if callee.group not in groups:
83
+ return False
84
+ group = groups[callee.group]
85
+ if 'tagnames' in group:
86
+ for tagname in group['tagnames']:
87
+ if self.alarm_string.startswith(tagname):
88
+ return True
89
+ if 'groups' in group:
90
+ for group in group['groups']:
91
+ if self.group in group:
92
+ return True
93
+ return False
94
+
95
+
96
+ class Callout:
97
+ """Connect to bus_ip:bus_port, monitor alarms and manage callouts."""
98
+
99
+ def __init__(
100
+ self,
101
+ bus_ip: str | None = '127.0.0.1',
102
+ bus_port: int | None = 1324,
103
+ rta_tag: str = '__callout__',
104
+ alarms_tag: str | None = None,
105
+ sms_send_tag: str | None = None,
106
+ sms_recv_tag: str | None = None,
107
+ ack_tag: str | None = None,
108
+ status_tag: str | None = None,
109
+ callees: list = [],
110
+ groups: dict = {},
111
+ escalation: dict = {}
112
+ ) -> None:
113
+ """
114
+ Connect to bus_ip:bus_port, monitor alarms and manage callouts.
115
+
116
+ Monitor alarms via alarms_tag and manage callout messages to callees
117
+ based on configured delays and area filters.
118
+
119
+ Event loop must be running.
120
+
121
+ For testing only: bus_ip can be None to skip connection.
122
+ """
123
+ if bus_ip is None:
124
+ logging.warning('Callout has bus_ip=None, only use for testing')
125
+ else:
126
+ try:
127
+ socket.gethostbyname(bus_ip)
128
+ except socket.gaierror as e:
129
+ raise ValueError(f'Cannot resolve IP/hostname: {e}')
130
+ if not isinstance(bus_port, int):
131
+ raise TypeError('bus_port must be an integer')
132
+ if not 1024 <= bus_port <= 65535:
133
+ raise ValueError('bus_port must be between 1024 and 65535')
134
+ if not isinstance(rta_tag, str) or not rta_tag:
135
+ raise ValueError('rta_tag must be a non-empty string')
136
+ if alarms_tag is None:
137
+ raise ValueError('alarms_tag must be defined')
138
+ if sms_send_tag is None:
139
+ raise ValueError('sms_send_tag must be defined')
140
+ if sms_recv_tag is None:
141
+ raise ValueError('sms_recv_tag must be defined')
142
+
143
+ logging.warning(f'Callout {bus_ip} {bus_port} {rta_tag} '
144
+ f'{sms_send_tag} {sms_recv_tag}')
145
+ self.callees: list[CalloutCallee] = []
146
+ for callee in callees:
147
+ self.callees.append(CalloutCallee(callee))
148
+ self.groups = groups
149
+ self.escalation = escalation
150
+ self.alarms: list[CalloutAlarm] = []
151
+ self.alarms_tag = Tag(alarms_tag, dict)
152
+ self.alarms_tag.add_callback(self.alarms_cb)
153
+ self.sms_recv_tag = Tag(sms_recv_tag, dict)
154
+ self.sms_recv_tag.add_callback(self.sms_recv_cb)
155
+ self.sms_send_tag = Tag(sms_send_tag, dict)
156
+ if ack_tag is not None:
157
+ self.ack_tag = Tag(ack_tag, int)
158
+ self.ack_tag.add_callback(self.ack_cb)
159
+ if status_tag is not None:
160
+ self.status = Tag(status_tag, int)
161
+ else:
162
+ self.status = None
163
+ self.busclient = BusClient(bus_ip, bus_port, module='Callout')
164
+ self.rta = Tag(rta_tag, dict)
165
+ self.set_rta_value(rta_id=0)
166
+ self.busclient.add_callback_rta(rta_tag, self.rta_cb)
167
+ self.periodic = Periodic(self.periodic_cb, 1.0)
168
+
169
+ def set_rta_value(self, rta_id: int):
170
+ """Publish the current configuration to the RTA tag."""
171
+ callees = [{'name': callee.name, 'sms': callee.sms, 'role': callee.role,
172
+ 'group': callee.group, 'delay_ms': callee.delay_ms}
173
+ for callee in self.callees]
174
+ self.rta.value = {'__rta_id__': rta_id,
175
+ 'callees': callees,
176
+ 'groups': self.groups,
177
+ 'escalation': list(self.escalation.keys())}
178
+
179
+ def alarms_cb(self, alm_tag):
180
+ """Handle alarm messages from alarms.py."""
181
+ if alm_tag.value['kind'] != ALM:
182
+ return
183
+ alarm = CalloutAlarm(alm_tag.value)
184
+ self.alarms.append(alarm)
185
+ logging.info(f'Added alarm to list: {alarm}')
186
+ if self.status is not None and self.status.value == IDLE:
187
+ self.status.value = NEW_ALM
188
+
189
+ def ack_cb(self, ack_tag):
190
+ """Handle ACK requests for alarm acknowledgment."""
191
+ if ack_tag.value == 1:
192
+ self.alarms = []
193
+ if self.status is not None:
194
+ self.status.value = IDLE
195
+ logging.info('ACK: all alarms cleared')
196
+
197
+ def sms_recv_cb(self, sms_recv_tag: Tag):
198
+ """Handle SMS messages from the modem."""
199
+ logging.info(f'sms_recv_cb {sms_recv_tag.value}')
200
+ if not isinstance(sms_recv_tag.value, dict) or \
201
+ 'number' not in sms_recv_tag.value or \
202
+ 'message' not in sms_recv_tag.value:
203
+ logging.warning(f'sms_recv_cb invalid {sms_recv_tag.value}')
204
+ return
205
+ number = sms_recv_tag.value['number']
206
+ name = [callee.name for callee in self.callees if callee.sms == number][0]
207
+ message = sms_recv_tag.value['message'][:2].upper()
208
+ if message in ['OK', 'AC', 'TH', 'YE']:
209
+ new_alarms = []
210
+ for alarm in self.alarms:
211
+ if name in alarm.sent:
212
+ continue
213
+ new_alarms.append(alarm)
214
+ self.alarms = new_alarms
215
+ if self.status is not None:
216
+ self.status.value = IDLE
217
+ logging.info(f'ACK: remaining alarms {len(new_alarms)}')
218
+
219
+ def rta_cb(self, request):
220
+ """Handle RTA requests for callout configuration."""
221
+ logging.info(f'rta_cb {request}')
222
+ if 'action' not in request:
223
+ logging.warning(f'rta_cb malformed {request}')
224
+ return
225
+ if request['action'] == 'GET CONFIG':
226
+ self.set_rta_value(rta_id=request['__rta_id__'])
227
+ elif request['action'] == 'MODIFY':
228
+ for callee in self.callees:
229
+ if callee.name == request['name']:
230
+ if not 'role' in request and not 'group' in request:
231
+ logging.warning(f'rta_cb invalid request: {request}')
232
+ return
233
+ role = request['role']
234
+ group = request['group']
235
+ valid_role = role == '' or role in self.escalation
236
+ valid_group = group == '' or group in self.groups
237
+ if not valid_role or not valid_group:
238
+ logging.warning(f'rta_cb MODIFY invalid: {request}')
239
+ return
240
+ callee.set_role(role, self.escalation.get(role, 0))
241
+ callee.set_group(group)
242
+ self.set_rta_value(rta_id=0)
243
+
244
+ def check_callee_messages(self, callee: CalloutCallee, time_ms):
245
+ if callee.role == '':
246
+ return ''
247
+ callee_alarms = set()
248
+ for alarm in self.alarms:
249
+ if alarm.callee_in_group(callee, self.groups):
250
+ callee_alarms.add(alarm)
251
+ notify_message = ''
252
+ remind_message = ''
253
+ notify_ms = time_ms - callee.delay_ms
254
+ remind_ms = notify_ms - 60000
255
+ notify = []
256
+ remind = []
257
+ for alarm in callee_alarms:
258
+ if not callee.name in alarm.sent and notify_ms > alarm.date_ms:
259
+ notify_message += f'{alarm.desc}\n'
260
+ alarm.sent.add(callee.name)
261
+ else:
262
+ notify.append(alarm)
263
+ if not callee.name in alarm.remind and remind_ms > alarm.date_ms:
264
+ remind_message += f'{alarm.desc}\n'
265
+ alarm.remind.add(callee.name)
266
+ else:
267
+ remind.append(alarm)
268
+ message = ''
269
+ if notify_message != '':
270
+ message += f'ALARMS\n{notify_message}'
271
+ for alarm in notify:
272
+ message += f'{alarm.desc}\n'
273
+ alarm.sent.add(callee.name)
274
+ if remind_message != '':
275
+ message += f'REMINDERS\n{remind_message}'
276
+ for alarm in remind:
277
+ message += f'{alarm.desc}\n'
278
+ alarm.remind.add(callee.name)
279
+ message = message.rstrip('\n')
280
+ return message
281
+
282
+ def check_alarms(self):
283
+ """Check alarms for each callee."""
284
+ time_ms = int(time.time() * 1000)
285
+ for callee in self.callees:
286
+ message = self.check_callee_messages(callee, time_ms)
287
+ if message == '':
288
+ continue
289
+ logging.info(f'Sending message to {callee.name}: {message}')
290
+ self.sms_send_tag.value = {
291
+ 'number': callee.sms,
292
+ 'message': message
293
+ }
294
+ if self.status is not None:
295
+ self.status.value = CALLOUT
296
+
297
+ async def periodic_cb(self):
298
+ """Periodic callback to check alarms and send callouts."""
299
+ self.check_alarms()
300
+
301
+ async def start(self):
302
+ """Async startup."""
303
+ await self.busclient.start()
304
+ await self.periodic.start()