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.
- {pymscada-0.2.6b2/src/pymscada.egg-info → pymscada-0.2.6b10}/PKG-INFO +2 -2
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/pyproject.toml +2 -2
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/__init__.py +2 -2
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/alarms.py +11 -5
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/bus_client.py +2 -7
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/bus_server.py +9 -8
- pymscada-0.2.6b10/src/pymscada/callout.py +304 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/iodrivers/modbus_client.py +189 -21
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/iodrivers/modbus_map.py +17 -2
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/opnotes.py +27 -15
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/tag.py +6 -7
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/www_server.py +1 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10/src/pymscada.egg-info}/PKG-INFO +2 -2
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada.egg-info/requires.txt +1 -1
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/tests/test_alarms.py +36 -45
- pymscada-0.2.6b10/tests/test_callout.py +131 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/tests/test_opnotes.py +2 -2
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/tests/test_sms.py +15 -0
- pymscada-0.2.6b2/src/pymscada/callout.py +0 -260
- pymscada-0.2.6b2/tests/test_callout.py +0 -151
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/LICENSE +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/MANIFEST.in +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/README.md +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/setup.cfg +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/__main__.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/checkout.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/config.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/console.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/README.md +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/__init__.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/__pycache__/__init__.cpython-311.pyc +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/accuweather.yaml +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/alarms.yaml +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/bus.yaml +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/callout.yaml +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/files.yaml +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/history.yaml +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/logixclient.yaml +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/modbus_plc.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/modbusclient.yaml +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/modbusserver.yaml +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/openweather.yaml +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/opnotes.yaml +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/piapi.yaml +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/ping.yaml +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-alarms.service +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-bus.service +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-callout.service +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-demo-modbus_plc.service +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-files.service +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-history.service +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-io-logixclient.service +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-io-modbusclient.service +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-io-modbusserver.service +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-io-openweather.service +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-io-piapi.service +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-io-ping.service +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-io-sms.service +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-io-snmpclient.service +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-io-witsapi.service +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-opnotes.service +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/pymscada-wwwserver.service +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/sms.yaml +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/snmpclient.yaml +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/tags.yaml +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/witsapi.yaml +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/demo/wwwserver.yaml +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/files.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/history.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/iodrivers/__init__.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/iodrivers/accuweather.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/iodrivers/logix_client.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/iodrivers/logix_map.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/iodrivers/modbus_server.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/iodrivers/openweather.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/iodrivers/piapi.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/iodrivers/ping_client.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/iodrivers/ping_map.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/iodrivers/sms.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/iodrivers/snmp_client.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/iodrivers/snmp_map.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/iodrivers/witsapi.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/iodrivers/witsapi_POC.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/main.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/misc.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/module_config.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/pdf/__init__.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/pdf/__pycache__/__init__.cpython-311.pyc +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/pdf/one.pdf +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/pdf/two.pdf +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/periodic.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/protocol_constants.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/samplers.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/tools/get_history.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/tools/snmp_client2.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada/tools/walk.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada.egg-info/SOURCES.txt +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada.egg-info/dependency_links.txt +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada.egg-info/entry_points.txt +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/src/pymscada.egg-info/top_level.txt +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/tests/test_bus_server.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/tests/test_config.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/tests/test_history.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/tests/test_misc.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/tests/test_periodic.py +0 -0
- {pymscada-0.2.6b2 → pymscada-0.2.6b10}/tests/test_samplers.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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', '
|
|
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
|
|
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
|
-
|
|
225
|
-
|
|
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' =
|
|
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 =
|
|
37
|
+
self.id = 0
|
|
39
38
|
self.time_us: int = 0
|
|
40
39
|
self.value: bytes = b''
|
|
41
|
-
self.from_bus:
|
|
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:
|
|
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.
|
|
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()
|