pymscada 0.2.0rc2__tar.gz → 0.2.0rc4__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.
- {pymscada-0.2.0rc2/src/pymscada.egg-info → pymscada-0.2.0rc4}/PKG-INFO +2 -2
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/pyproject.toml +2 -2
- pymscada-0.2.0rc4/src/pymscada/alarms.py +358 -0
- pymscada-0.2.0rc4/src/pymscada/demo/alarms.yaml +5 -0
- pymscada-0.2.0rc4/src/pymscada/demo/pymscada-alarms.service +16 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/history.py +16 -15
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/module_config.py +5 -1
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/opnotes.py +13 -8
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/tag.py +0 -40
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/www_server.py +35 -13
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4/src/pymscada.egg-info}/PKG-INFO +2 -2
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada.egg-info/SOURCES.txt +4 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada.egg-info/requires.txt +1 -1
- pymscada-0.2.0rc4/tests/test_alarms.py +128 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/tests/test_opnotes.py +1 -2
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/tests/test_periodic.py +2 -2
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/LICENSE +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/MANIFEST.in +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/README.md +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/setup.cfg +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/__init__.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/__main__.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/bus_client.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/bus_server.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/checkout.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/config.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/console.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/README.md +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/__init__.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/__pycache__/__init__.cpython-311.pyc +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/accuweather.yaml +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/bus.yaml +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/files.yaml +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/history.yaml +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/logixclient.yaml +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/modbus_plc.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/modbusclient.yaml +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/modbusserver.yaml +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/openweather.yaml +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/opnotes.yaml +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/ping.yaml +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/pymscada-bus.service +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/pymscada-demo-modbus_plc.service +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/pymscada-files.service +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/pymscada-history.service +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/pymscada-io-logixclient.service +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/pymscada-io-modbusclient.service +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/pymscada-io-modbusserver.service +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/pymscada-io-openweather.service +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/pymscada-io-ping.service +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/pymscada-io-snmpclient.service +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/pymscada-opnotes.service +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/pymscada-wwwserver.service +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/snmpclient.yaml +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/tags.yaml +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/wwwserver.yaml +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/files.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/iodrivers/__init__.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/iodrivers/accuweather.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/iodrivers/logix_client.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/iodrivers/logix_map.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/iodrivers/modbus_client.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/iodrivers/modbus_map.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/iodrivers/modbus_server.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/iodrivers/openweather.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/iodrivers/ping_client.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/iodrivers/ping_map.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/iodrivers/snmp_client.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/iodrivers/snmp_map.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/main.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/misc.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/pdf/__init__.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/pdf/__pycache__/__init__.cpython-311.pyc +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/pdf/one.pdf +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/pdf/two.pdf +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/periodic.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/protocol_constants.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/samplers.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/tools/snmp_client2.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/tools/walk.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/validate.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada.egg-info/dependency_links.txt +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada.egg-info/entry_points.txt +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada.egg-info/top_level.txt +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/tests/test_bus_server.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/tests/test_config.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/tests/test_history.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/tests/test_misc.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/tests/test_openweather.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/tests/test_samplers.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/tests/test_tag.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/tests/test_validate.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: pymscada
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.0rc4
|
|
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.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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pymscada"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.0rc4"
|
|
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.0rc4", # 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
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Alarms handling."""
|
|
2
|
+
import logging
|
|
3
|
+
import sqlite3 # note that sqlite3 has blocking calls
|
|
4
|
+
import socket
|
|
5
|
+
import time
|
|
6
|
+
import atexit
|
|
7
|
+
from pymscada.bus_client import BusClient
|
|
8
|
+
from pymscada.tag import Tag, TYPES
|
|
9
|
+
|
|
10
|
+
ALM = 0
|
|
11
|
+
RTN = 1
|
|
12
|
+
ACT = 2
|
|
13
|
+
INF = 3
|
|
14
|
+
|
|
15
|
+
NORMAL = 0
|
|
16
|
+
ALARM = 1
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def standardise_tag_info(tagname: str, tag: dict):
|
|
20
|
+
"""Correct tag dictionary in place to be suitable for modules."""
|
|
21
|
+
tag['name'] = tagname
|
|
22
|
+
tag['id'] = None
|
|
23
|
+
if 'desc' not in tag:
|
|
24
|
+
logging.warning(f"Tag {tagname} has no description, using name")
|
|
25
|
+
tag['desc'] = tag['name']
|
|
26
|
+
if 'multi' in tag:
|
|
27
|
+
tag['type'] = int
|
|
28
|
+
else:
|
|
29
|
+
if 'type' not in tag:
|
|
30
|
+
tag['type'] = float
|
|
31
|
+
else:
|
|
32
|
+
if tag['type'] not in TYPES:
|
|
33
|
+
tag['type'] = str
|
|
34
|
+
else:
|
|
35
|
+
tag['type'] = TYPES[tag['type']]
|
|
36
|
+
if 'dp' not in tag:
|
|
37
|
+
if tag['type'] == int:
|
|
38
|
+
tag['dp'] = 0
|
|
39
|
+
else:
|
|
40
|
+
tag['dp'] = 2
|
|
41
|
+
if 'units' not in tag:
|
|
42
|
+
tag['units'] = ''
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Alarm:
|
|
46
|
+
"""Manages multiple alarm conditions for a single tag."""
|
|
47
|
+
def __init__(self, tag: Tag, conditions: str | list[str]):
|
|
48
|
+
"""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
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Alarms:
|
|
91
|
+
"""Connect to bus_ip:bus_port, store and provide Alarms."""
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
bus_ip: str | None = '127.0.0.1',
|
|
96
|
+
bus_port: int | None = 1324,
|
|
97
|
+
db: str | None = None,
|
|
98
|
+
table: str = 'alarms',
|
|
99
|
+
tag_info: dict[str, dict] = {},
|
|
100
|
+
rta_tag: str = '__alarms__'
|
|
101
|
+
) -> None:
|
|
102
|
+
"""
|
|
103
|
+
Connect to bus_ip:bus_port, serve and update alarms database.
|
|
104
|
+
|
|
105
|
+
Open an Alarms table, creating if necessary. Provide additions
|
|
106
|
+
and history requests via the rta_tag.
|
|
107
|
+
|
|
108
|
+
Event loop must be running.
|
|
109
|
+
|
|
110
|
+
For testing only: bus_ip can be None to skip connection.
|
|
111
|
+
"""
|
|
112
|
+
if db is None:
|
|
113
|
+
raise SystemExit('Alarms db must be defined')
|
|
114
|
+
if bus_ip is None:
|
|
115
|
+
logging.warning('Alarms has bus_ip=None, only use for testing')
|
|
116
|
+
else:
|
|
117
|
+
try:
|
|
118
|
+
socket.gethostbyname(bus_ip)
|
|
119
|
+
except socket.gaierror as e:
|
|
120
|
+
raise ValueError(f'Cannot resolve IP/hostname: {e}')
|
|
121
|
+
if not isinstance(bus_port, int):
|
|
122
|
+
raise TypeError('bus_port must be an integer')
|
|
123
|
+
if not 1024 <= bus_port <= 65535:
|
|
124
|
+
raise ValueError('bus_port must be between 1024 and 65535')
|
|
125
|
+
if not isinstance(rta_tag, str) or not rta_tag:
|
|
126
|
+
raise ValueError('rta_tag must be a non-empty string')
|
|
127
|
+
if not isinstance(table, str) or not table:
|
|
128
|
+
raise ValueError('table must be a non-empty string')
|
|
129
|
+
|
|
130
|
+
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] = {}
|
|
135
|
+
for tagname, tag in tag_info.items():
|
|
136
|
+
standardise_tag_info(tagname, tag)
|
|
137
|
+
if 'alarm' not in tag or tag['type'] not in (int, float):
|
|
138
|
+
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()
|
|
147
|
+
self.busclient = BusClient(bus_ip, bus_port, module='Alarms')
|
|
148
|
+
self.rta = Tag(rta_tag, dict)
|
|
149
|
+
self.rta.value = {}
|
|
150
|
+
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]
|
|
206
|
+
|
|
207
|
+
def _init_table(self):
|
|
208
|
+
"""Initialize the database table schema."""
|
|
209
|
+
query = (
|
|
210
|
+
'CREATE TABLE IF NOT EXISTS ' + self.table +
|
|
211
|
+
'(id INTEGER PRIMARY KEY ASC, '
|
|
212
|
+
'date_ms INTEGER, '
|
|
213
|
+
'tag_alm TEXT, '
|
|
214
|
+
'kind INTEGER, '
|
|
215
|
+
'desc TEXT, '
|
|
216
|
+
'in_alm INTEGER)'
|
|
217
|
+
)
|
|
218
|
+
self.cursor.execute(query)
|
|
219
|
+
|
|
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
|
+
startup_record = {
|
|
240
|
+
'action': 'ADD',
|
|
241
|
+
'date_ms': int(time.time() * 1000),
|
|
242
|
+
'tag_alm': self.rta.name,
|
|
243
|
+
'kind': INF,
|
|
244
|
+
'desc': 'Alarm logging started',
|
|
245
|
+
'in_alm': NORMAL
|
|
246
|
+
}
|
|
247
|
+
self.rta_cb(startup_record)
|
|
248
|
+
|
|
249
|
+
def rta_cb(self, request):
|
|
250
|
+
"""Respond to Request to Author and publish on rta_tag as needed."""
|
|
251
|
+
if 'action' not in request:
|
|
252
|
+
logging.warning(f'rta_cb malformed {request}')
|
|
253
|
+
elif request['action'] == 'ADD':
|
|
254
|
+
try:
|
|
255
|
+
logging.info(f'add {request}')
|
|
256
|
+
with self.connection:
|
|
257
|
+
self.cursor.execute(
|
|
258
|
+
f'INSERT INTO {self.table} '
|
|
259
|
+
'(date_ms, tag_alm, kind, desc, in_alm) '
|
|
260
|
+
'VALUES(:date_ms, :tag_alm, :kind, :desc, :in_alm) '
|
|
261
|
+
'RETURNING *;',
|
|
262
|
+
request)
|
|
263
|
+
res = self.cursor.fetchone()
|
|
264
|
+
self.rta.value = {
|
|
265
|
+
'id': res[0],
|
|
266
|
+
'date_ms': res[1],
|
|
267
|
+
'tag_alm': res[2],
|
|
268
|
+
'kind': res[3],
|
|
269
|
+
'desc': res[4],
|
|
270
|
+
'in_alm': res[5]
|
|
271
|
+
}
|
|
272
|
+
except sqlite3.IntegrityError as error:
|
|
273
|
+
logging.warning(f'Alarms rta_cb {error}')
|
|
274
|
+
elif request['action'] == 'UPDATE':
|
|
275
|
+
try:
|
|
276
|
+
logging.info(f'update {request}')
|
|
277
|
+
with self.connection:
|
|
278
|
+
self.cursor.execute(
|
|
279
|
+
f'UPDATE {self.table} SET in_alm = :in_alm '
|
|
280
|
+
'WHERE id = :id RETURNING *;',
|
|
281
|
+
request)
|
|
282
|
+
res = self.cursor.fetchone()
|
|
283
|
+
if res:
|
|
284
|
+
self.rta.value = {
|
|
285
|
+
'id': res[0],
|
|
286
|
+
'date_ms': res[1],
|
|
287
|
+
'tag_alm': res[2],
|
|
288
|
+
'kind': res[3],
|
|
289
|
+
'desc': res[4],
|
|
290
|
+
'in_alm': res[5]
|
|
291
|
+
}
|
|
292
|
+
except sqlite3.IntegrityError as error:
|
|
293
|
+
logging.warning(f'Alarms rta_cb update {error}')
|
|
294
|
+
elif request['action'] == 'HISTORY':
|
|
295
|
+
try:
|
|
296
|
+
logging.info(f'history {request}')
|
|
297
|
+
with self.connection:
|
|
298
|
+
self.cursor.execute(
|
|
299
|
+
f'SELECT * FROM {self.table} WHERE date_ms > :date_ms '
|
|
300
|
+
'ORDER BY (date_ms - :date_ms);', request)
|
|
301
|
+
for res in self.cursor.fetchall():
|
|
302
|
+
self.rta.value = {
|
|
303
|
+
'__rta_id__': request['__rta_id__'],
|
|
304
|
+
'id': res[0],
|
|
305
|
+
'date_ms': res[1],
|
|
306
|
+
'tag_alm': res[2],
|
|
307
|
+
'kind': res[3],
|
|
308
|
+
'desc': res[4],
|
|
309
|
+
'in_alm': res[5]
|
|
310
|
+
}
|
|
311
|
+
except sqlite3.IntegrityError as error:
|
|
312
|
+
logging.warning(f'Alarms rta_cb {error}')
|
|
313
|
+
elif request['action'] == 'BULK HISTORY':
|
|
314
|
+
try:
|
|
315
|
+
logging.info(f'bulk history {request}')
|
|
316
|
+
with self.connection:
|
|
317
|
+
self.cursor.execute(
|
|
318
|
+
f'SELECT * FROM {self.table} WHERE date_ms > :date_ms '
|
|
319
|
+
'ORDER BY -date_ms;', request)
|
|
320
|
+
results = list(self.cursor.fetchall())
|
|
321
|
+
self.rta.value = {'__rta_id__': request['__rta_id__'],
|
|
322
|
+
'data': results}
|
|
323
|
+
except sqlite3.IntegrityError as error:
|
|
324
|
+
logging.warning(f'Alarms rta_cb {error}')
|
|
325
|
+
elif request['action'] == 'IN ALARM':
|
|
326
|
+
self.rta.value = {'__rta_id__': request['__rta_id__'],
|
|
327
|
+
'data': {'in_alarm': list(self.in_alarm)}}
|
|
328
|
+
|
|
329
|
+
async def start(self):
|
|
330
|
+
"""Async startup."""
|
|
331
|
+
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}')
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=pymscada - alarms
|
|
3
|
+
BindsTo=pymscada-bus.service
|
|
4
|
+
After=pymscada-bus.service
|
|
5
|
+
|
|
6
|
+
[Service]
|
|
7
|
+
WorkingDirectory=__DIR__
|
|
8
|
+
ExecStart=__PYMSCADA__ alarms --config __DIR__/config/alarms.yaml --tags __DIR__/config/tags.yaml
|
|
9
|
+
Restart=always
|
|
10
|
+
RestartSec=5
|
|
11
|
+
User=__USER__
|
|
12
|
+
Group=__USER__
|
|
13
|
+
KillSignal=SIGINT
|
|
14
|
+
|
|
15
|
+
[Install]
|
|
16
|
+
WantedBy=multi-user.target
|
|
@@ -28,7 +28,7 @@ import time
|
|
|
28
28
|
import socket
|
|
29
29
|
from typing import TypedDict, Optional
|
|
30
30
|
from pymscada.bus_client import BusClient
|
|
31
|
-
from pymscada.tag import Tag,
|
|
31
|
+
from pymscada.tag import Tag, TYPES
|
|
32
32
|
|
|
33
33
|
|
|
34
34
|
ITEM_SIZE = 16 # Q + q, Q or d
|
|
@@ -37,21 +37,12 @@ CHUNK_SIZE = ITEM_COUNT * ITEM_SIZE
|
|
|
37
37
|
FILE_CHUNKS = 64
|
|
38
38
|
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
"""
|
|
42
|
-
tagname: str
|
|
43
|
-
start_ms: Optional[int] # Allow web client to use native ms
|
|
44
|
-
start_us: Optional[int] # Native for pymscada server
|
|
45
|
-
end_ms: Optional[int]
|
|
46
|
-
end_us: Optional[int]
|
|
47
|
-
__rta_id__: Optional[int] # Empty for a change that must be broadcast
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def tag_for_history(tagname: str, tag: dict):
|
|
51
|
-
"""Correct tag dictionary in place to be suitable for web client."""
|
|
40
|
+
def standardise_tag_info(tagname: str, tag: dict):
|
|
41
|
+
"""Correct tag dictionary in place to be suitable for modules."""
|
|
52
42
|
tag['name'] = tagname
|
|
53
43
|
tag['id'] = None
|
|
54
44
|
if 'desc' not in tag:
|
|
45
|
+
logging.warning(f"Tag {tagname} has no description, using name")
|
|
55
46
|
tag['desc'] = tag['name']
|
|
56
47
|
if 'multi' in tag:
|
|
57
48
|
tag['type'] = int
|
|
@@ -71,6 +62,16 @@ def tag_for_history(tagname: str, tag: dict):
|
|
|
71
62
|
tag['deadband'] = None
|
|
72
63
|
|
|
73
64
|
|
|
65
|
+
class Request(TypedDict, total=False):
|
|
66
|
+
"""Type definition for request dictionary."""
|
|
67
|
+
tagname: str
|
|
68
|
+
start_ms: Optional[int] # Allow web client to use native ms
|
|
69
|
+
start_us: Optional[int] # Native for pymscada server
|
|
70
|
+
end_ms: Optional[int]
|
|
71
|
+
end_us: Optional[int]
|
|
72
|
+
__rta_id__: Optional[int] # Empty for a change that must be broadcast
|
|
73
|
+
|
|
74
|
+
|
|
74
75
|
def get_tag_hist_files(path: Path, tagname: str) -> dict[int, Path]:
|
|
75
76
|
"""Parse path for history files matching tagname."""
|
|
76
77
|
files_us = {}
|
|
@@ -226,7 +227,7 @@ class History():
|
|
|
226
227
|
bus_ip: str = '127.0.0.1',
|
|
227
228
|
bus_port: int = 1324,
|
|
228
229
|
path: str = 'history',
|
|
229
|
-
tag_info:
|
|
230
|
+
tag_info: dict[str, dict] = {},
|
|
230
231
|
rta_tag: str | None = '__history__'
|
|
231
232
|
) -> None:
|
|
232
233
|
"""
|
|
@@ -260,7 +261,7 @@ class History():
|
|
|
260
261
|
self.tags: dict[str, Tag] = {}
|
|
261
262
|
self.hist_tags: dict[str, TagHistory] = {}
|
|
262
263
|
for tagname, tag in tag_info.items():
|
|
263
|
-
|
|
264
|
+
standardise_tag_info(tagname, tag)
|
|
264
265
|
if tag['type'] not in [float, int]:
|
|
265
266
|
continue
|
|
266
267
|
self.hist_tags[tagname] = TagHistory(
|
|
@@ -5,7 +5,6 @@ from textwrap import dedent
|
|
|
5
5
|
from importlib.metadata import version
|
|
6
6
|
import logging
|
|
7
7
|
from pymscada.config import Config
|
|
8
|
-
from pymscada.console import Console
|
|
9
8
|
|
|
10
9
|
class ModuleArgument:
|
|
11
10
|
def __init__(self, args: tuple[str, ...], kwargs: dict[str, Any]):
|
|
@@ -59,6 +58,11 @@ def create_module_registry():
|
|
|
59
58
|
module_class='pymscada.opnotes:OpNotes',
|
|
60
59
|
tags=False
|
|
61
60
|
),
|
|
61
|
+
ModuleDefinition(
|
|
62
|
+
name='alarms',
|
|
63
|
+
help='alarms',
|
|
64
|
+
module_class='pymscada.alarms:Alarms'
|
|
65
|
+
),
|
|
62
66
|
ModuleDefinition(
|
|
63
67
|
name='validate',
|
|
64
68
|
help='validate config files',
|
|
@@ -24,17 +24,22 @@ class OpNotes:
|
|
|
24
24
|
updates, deletions and history requests via the rta_tag.
|
|
25
25
|
|
|
26
26
|
Event loop must be running.
|
|
27
|
+
|
|
28
|
+
For testing only: bus_ip can be None to skip connection.
|
|
27
29
|
"""
|
|
28
30
|
if db is None:
|
|
29
31
|
raise SystemExit('OpNotes db must be defined')
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
32
|
+
if bus_ip is None:
|
|
33
|
+
logging.warning('OpNotes has bus_ip=None, only use for testing')
|
|
34
|
+
else:
|
|
35
|
+
try:
|
|
36
|
+
socket.gethostbyname(bus_ip)
|
|
37
|
+
except socket.gaierror as e:
|
|
38
|
+
raise ValueError(f'Cannot resolve IP/hostname: {e}')
|
|
39
|
+
if not isinstance(bus_port, int):
|
|
40
|
+
raise TypeError('bus_port must be an integer')
|
|
41
|
+
if not 1024 <= bus_port <= 65535:
|
|
42
|
+
raise ValueError('bus_port must be between 1024 and 65535')
|
|
38
43
|
if not isinstance(rta_tag, str) or not rta_tag:
|
|
39
44
|
raise ValueError('rta_tag must be a non-empty string')
|
|
40
45
|
if not isinstance(table, str) or not table:
|
|
@@ -7,7 +7,6 @@ bus_id is python id(), 0 is null pointer in c, 0 is local bus.
|
|
|
7
7
|
import time
|
|
8
8
|
import array
|
|
9
9
|
import logging
|
|
10
|
-
from typing import TypedDict, Union, Optional, Type, List
|
|
11
10
|
|
|
12
11
|
TYPES = {
|
|
13
12
|
'int': int,
|
|
@@ -19,28 +18,6 @@ TYPES = {
|
|
|
19
18
|
}
|
|
20
19
|
|
|
21
20
|
|
|
22
|
-
def tag_for_web(tagname: str, tag: dict):
|
|
23
|
-
"""Correct tag dictionary in place to be suitable for web client."""
|
|
24
|
-
tag['name'] = tagname
|
|
25
|
-
tag['id'] = None
|
|
26
|
-
if 'desc' not in tag:
|
|
27
|
-
tag['desc'] = tag.name
|
|
28
|
-
if 'multi' in tag:
|
|
29
|
-
tag['type'] = 'int'
|
|
30
|
-
else:
|
|
31
|
-
if 'type' not in tag:
|
|
32
|
-
tag['type'] = 'float'
|
|
33
|
-
else:
|
|
34
|
-
if tag['type'] not in TYPES:
|
|
35
|
-
tag['type'] = 'str'
|
|
36
|
-
if tag['type'] == 'int':
|
|
37
|
-
tag['dp'] = 0
|
|
38
|
-
elif tag['type'] == 'float' and 'dp' not in tag:
|
|
39
|
-
tag['dp'] = 2
|
|
40
|
-
elif tag['type'] == 'str' and 'dp' in tag:
|
|
41
|
-
del tag['dp']
|
|
42
|
-
|
|
43
|
-
|
|
44
21
|
class UniqueTag(type):
|
|
45
22
|
"""Super Tag class only create unique tags for unique tag names."""
|
|
46
23
|
|
|
@@ -311,20 +288,3 @@ class Tag(metaclass=UniqueTag):
|
|
|
311
288
|
self.values = array.array('d')
|
|
312
289
|
else:
|
|
313
290
|
raise TypeError(f"shard invalid {self.name} not int, float")
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
class TagInfo(TypedDict, total=False):
|
|
317
|
-
"""Type definition for tag information dictionary."""
|
|
318
|
-
name: str
|
|
319
|
-
id: Optional[int]
|
|
320
|
-
desc: str
|
|
321
|
-
type: Union[str, Type[int], Type[float], Type[str], Type[list],
|
|
322
|
-
Type[dict], Type[bytes]]
|
|
323
|
-
multi: Optional[List[str]]
|
|
324
|
-
min: Optional[Union[float, int]]
|
|
325
|
-
max: Optional[Union[float, int]]
|
|
326
|
-
deadband: Optional[Union[float, int]]
|
|
327
|
-
units: Optional[str]
|
|
328
|
-
dp: Optional[int]
|
|
329
|
-
format: Optional[str]
|
|
330
|
-
init: Optional[Union[int, float, str]]
|
|
@@ -8,10 +8,32 @@ import socket
|
|
|
8
8
|
import time
|
|
9
9
|
from pymscada.bus_client import BusClient
|
|
10
10
|
import pymscada.protocol_constants as pc
|
|
11
|
-
from pymscada.tag import Tag,
|
|
11
|
+
from pymscada.tag import Tag, TYPES
|
|
12
12
|
from pymscada_html import get_html_file
|
|
13
13
|
|
|
14
14
|
|
|
15
|
+
def standardise_tag_info(tagname: str, tag: dict):
|
|
16
|
+
"""Correct tag dictionary in place to be suitable for web client."""
|
|
17
|
+
tag['name'] = tagname
|
|
18
|
+
tag['id'] = None
|
|
19
|
+
if 'desc' not in tag:
|
|
20
|
+
tag['desc'] = tagname
|
|
21
|
+
if 'multi' in tag:
|
|
22
|
+
tag['type'] = 'int'
|
|
23
|
+
else:
|
|
24
|
+
if 'type' not in tag:
|
|
25
|
+
tag['type'] = 'float'
|
|
26
|
+
else:
|
|
27
|
+
if tag['type'] not in TYPES:
|
|
28
|
+
tag['type'] = 'str'
|
|
29
|
+
if tag['type'] == 'int':
|
|
30
|
+
tag['dp'] = 0
|
|
31
|
+
elif tag['type'] == 'float' and 'dp' not in tag:
|
|
32
|
+
tag['dp'] = 2
|
|
33
|
+
elif tag['type'] == 'str' and 'dp' in tag:
|
|
34
|
+
del tag['dp']
|
|
35
|
+
|
|
36
|
+
|
|
15
37
|
class Interface():
|
|
16
38
|
"""Provide an interface between web client rta and the action."""
|
|
17
39
|
|
|
@@ -36,12 +58,12 @@ class WSHandler():
|
|
|
36
58
|
|
|
37
59
|
def __init__(self, ws: web.WebSocketResponse, pages: dict,
|
|
38
60
|
tag_info: dict[str, Tag], do_rta, interface: Interface,
|
|
39
|
-
|
|
61
|
+
config: dict):
|
|
40
62
|
"""Create callbacks to monitor tag values."""
|
|
41
63
|
self.ws = ws
|
|
42
64
|
self.pages = pages
|
|
43
65
|
self.tag_info = tag_info
|
|
44
|
-
self.
|
|
66
|
+
self.config = config
|
|
45
67
|
self.tag_by_id: dict[int, Tag] = {}
|
|
46
68
|
self.tag_by_name: dict[str, Tag] = {}
|
|
47
69
|
self.queue = asyncio.Queue()
|
|
@@ -143,7 +165,7 @@ class WSHandler():
|
|
|
143
165
|
|
|
144
166
|
def notify_id(self, tag: Tag):
|
|
145
167
|
"""Must be done here."""
|
|
146
|
-
logging.info(f'{self.rta_id}: send id to
|
|
168
|
+
logging.info(f'{self.rta_id}: send id to browser for {tag.name}')
|
|
147
169
|
self.tag_info[tag.name]['id'] = tag.id
|
|
148
170
|
self.tag_by_id[tag.id] = tag
|
|
149
171
|
self.tag_by_name[tag.name] = tag
|
|
@@ -172,7 +194,7 @@ class WSHandler():
|
|
|
172
194
|
"""Run while the connection is active and don't return."""
|
|
173
195
|
send_queue = asyncio.create_task(self.send_queue())
|
|
174
196
|
self.queue.put_nowait(
|
|
175
|
-
(False, {'type': '
|
|
197
|
+
(False, {'type': 'config', 'payload': self.config}))
|
|
176
198
|
self.queue.put_nowait(
|
|
177
199
|
(False, {'type': 'pages', 'payload': self.pages}))
|
|
178
200
|
async for msg in self.ws:
|
|
@@ -213,7 +235,7 @@ class WSHandler():
|
|
|
213
235
|
|
|
214
236
|
|
|
215
237
|
class WwwServer:
|
|
216
|
-
"""Connect to bus on bus_ip:bus_port, serve on ip:port for
|
|
238
|
+
"""Connect to bus on bus_ip:bus_port, serve on ip:port for webserver."""
|
|
217
239
|
|
|
218
240
|
def __init__(
|
|
219
241
|
self,
|
|
@@ -224,15 +246,15 @@ class WwwServer:
|
|
|
224
246
|
get_path: str | None = None,
|
|
225
247
|
tag_info: dict = {},
|
|
226
248
|
pages: dict = {},
|
|
227
|
-
|
|
249
|
+
config: dict = {},
|
|
228
250
|
serve_path: str | None = None,
|
|
229
251
|
www_tag: str = '__wwwserver__'
|
|
230
252
|
) -> None:
|
|
231
253
|
"""
|
|
232
|
-
Connect to bus on bus_ip:bus_port, serve on ip:port for
|
|
254
|
+
Connect to bus on bus_ip:bus_port, serve on ip:port for webserver.
|
|
233
255
|
|
|
234
|
-
Serves the
|
|
235
|
-
|
|
256
|
+
Serves the files at /, as a relative path. The browser uses a
|
|
257
|
+
websocket connection to request and set tag values and subscribe to
|
|
236
258
|
changes.
|
|
237
259
|
|
|
238
260
|
Event loop must be running.
|
|
@@ -263,10 +285,10 @@ class WwwServer:
|
|
|
263
285
|
self.get_path = get_path
|
|
264
286
|
self.serve_path = Path(serve_path) if serve_path else None
|
|
265
287
|
for tagname, tag in tag_info.items():
|
|
266
|
-
|
|
288
|
+
standardise_tag_info(tagname, tag)
|
|
267
289
|
self.tag_info = tag_info
|
|
268
290
|
self.pages = pages
|
|
269
|
-
self.
|
|
291
|
+
self.config = config
|
|
270
292
|
self.interface = Interface(www_tag)
|
|
271
293
|
|
|
272
294
|
async def redirect_handler(self, _request: web.Request):
|
|
@@ -305,7 +327,7 @@ class WwwServer:
|
|
|
305
327
|
ws = web.WebSocketResponse(max_msg_size=0) # disables max message size
|
|
306
328
|
await ws.prepare(request)
|
|
307
329
|
await WSHandler(ws, self.pages, self.tag_info, self.busclient.rta,
|
|
308
|
-
self.interface, self.
|
|
330
|
+
self.interface, self.config).connection_active()
|
|
309
331
|
await ws.close()
|
|
310
332
|
logging.info(f"WS closed {peer}")
|
|
311
333
|
return ws
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: pymscada
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.0rc4
|
|
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.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
|
|
@@ -4,6 +4,7 @@ README.md
|
|
|
4
4
|
pyproject.toml
|
|
5
5
|
src/pymscada/__init__.py
|
|
6
6
|
src/pymscada/__main__.py
|
|
7
|
+
src/pymscada/alarms.py
|
|
7
8
|
src/pymscada/bus_client.py
|
|
8
9
|
src/pymscada/bus_server.py
|
|
9
10
|
src/pymscada/checkout.py
|
|
@@ -30,6 +31,7 @@ src/pymscada.egg-info/top_level.txt
|
|
|
30
31
|
src/pymscada/demo/README.md
|
|
31
32
|
src/pymscada/demo/__init__.py
|
|
32
33
|
src/pymscada/demo/accuweather.yaml
|
|
34
|
+
src/pymscada/demo/alarms.yaml
|
|
33
35
|
src/pymscada/demo/bus.yaml
|
|
34
36
|
src/pymscada/demo/files.yaml
|
|
35
37
|
src/pymscada/demo/history.yaml
|
|
@@ -40,6 +42,7 @@ src/pymscada/demo/modbusserver.yaml
|
|
|
40
42
|
src/pymscada/demo/openweather.yaml
|
|
41
43
|
src/pymscada/demo/opnotes.yaml
|
|
42
44
|
src/pymscada/demo/ping.yaml
|
|
45
|
+
src/pymscada/demo/pymscada-alarms.service
|
|
43
46
|
src/pymscada/demo/pymscada-bus.service
|
|
44
47
|
src/pymscada/demo/pymscada-demo-modbus_plc.service
|
|
45
48
|
src/pymscada/demo/pymscada-files.service
|
|
@@ -74,6 +77,7 @@ src/pymscada/pdf/two.pdf
|
|
|
74
77
|
src/pymscada/pdf/__pycache__/__init__.cpython-311.pyc
|
|
75
78
|
src/pymscada/tools/snmp_client2.py
|
|
76
79
|
src/pymscada/tools/walk.py
|
|
80
|
+
tests/test_alarms.py
|
|
77
81
|
tests/test_bus_server.py
|
|
78
82
|
tests/test_config.py
|
|
79
83
|
tests/test_history.py
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Test Alarms."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import pytest
|
|
4
|
+
from pymscada.alarms import Alarms, ALM, RTN, ACT, INF
|
|
5
|
+
from pymscada.tag import Tag
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture(scope='module')
|
|
9
|
+
def alarms_db():
|
|
10
|
+
"""Create a fixture for DB access."""
|
|
11
|
+
tag_info = {
|
|
12
|
+
'localhost_ping': {
|
|
13
|
+
'desc': 'Ping time to localhost',
|
|
14
|
+
'units': 'ms',
|
|
15
|
+
'alarm': '> 2',
|
|
16
|
+
'type': 'float',
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
Path('tests/test_assets/alarms.sqlite').unlink(missing_ok=True)
|
|
20
|
+
return Alarms(bus_ip=None, bus_port=None,
|
|
21
|
+
db='tests/test_assets/alarms.sqlite',
|
|
22
|
+
tag_info=tag_info)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture(scope='module')
|
|
26
|
+
def alarms_tag():
|
|
27
|
+
"""Create the RTA tag."""
|
|
28
|
+
return Tag('__alarms__', dict)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.fixture(scope='module')
|
|
32
|
+
def reply_tag():
|
|
33
|
+
"""Create the reply tag."""
|
|
34
|
+
return Tag('__wwwserver__', dict)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_db_and_tag(alarms_db, alarms_tag):
|
|
38
|
+
"""Basic tests."""
|
|
39
|
+
db = alarms_db
|
|
40
|
+
tag = alarms_tag # Alarms sets the tag value for www clients.
|
|
41
|
+
|
|
42
|
+
db._init_table()
|
|
43
|
+
|
|
44
|
+
# Verify startup record was created
|
|
45
|
+
assert tag.value['kind'] == 3 # INF
|
|
46
|
+
assert 'Alarm logging started' in tag.value['desc']
|
|
47
|
+
|
|
48
|
+
# Test adding an alarm
|
|
49
|
+
record = {
|
|
50
|
+
'action': 'ADD',
|
|
51
|
+
'tagname': 'TEST_TAG',
|
|
52
|
+
'date_ms': 1234567890123,
|
|
53
|
+
'kind': ALM,
|
|
54
|
+
'desc': 'Test alarm condition'
|
|
55
|
+
}
|
|
56
|
+
db.rta_cb(record)
|
|
57
|
+
assert tag.value['id'] == 2 # Second record after startup
|
|
58
|
+
assert tag.value['kind'] == 0
|
|
59
|
+
assert tag.value['desc'] == 'Test alarm condition'
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_history_queries(alarms_db, alarms_tag, reply_tag):
|
|
63
|
+
"""Test history queries."""
|
|
64
|
+
BUSID = 999
|
|
65
|
+
db = alarms_db
|
|
66
|
+
a_tag: Tag = alarms_tag
|
|
67
|
+
a_values = []
|
|
68
|
+
|
|
69
|
+
def a_cb(tag):
|
|
70
|
+
a_values.append(tag.value)
|
|
71
|
+
|
|
72
|
+
a_tag.add_callback(a_cb, BUSID)
|
|
73
|
+
|
|
74
|
+
# Add some test records
|
|
75
|
+
record = {
|
|
76
|
+
'action': 'ADD',
|
|
77
|
+
'tagname': 'TEST_TAG',
|
|
78
|
+
'date_ms': 12345,
|
|
79
|
+
'kind': ALM,
|
|
80
|
+
'desc': 'Test alarm'
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for i in range(10):
|
|
84
|
+
record['date_ms'] -= 1
|
|
85
|
+
record['transition'] = i % 4 # Cycle through transitions
|
|
86
|
+
db.rta_cb(record)
|
|
87
|
+
|
|
88
|
+
rq = {
|
|
89
|
+
'__rta_id__': BUSID,
|
|
90
|
+
'action': 'HISTORY',
|
|
91
|
+
'date_ms': 12345 - 5.1,
|
|
92
|
+
'reply_tag': '__wwwserver__'
|
|
93
|
+
}
|
|
94
|
+
db.rta_cb(rq)
|
|
95
|
+
assert a_values[10]['date_ms'] == 12340
|
|
96
|
+
assert a_values[-1]['desc'] == 'Alarm logging started'
|
|
97
|
+
|
|
98
|
+
db.close()
|
|
99
|
+
assert a_values[-1]['kind'] == INF
|
|
100
|
+
assert a_values[-1]['desc'] == 'Alarm logging stopped'
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_alarm_tag(alarms_db, alarms_tag):
|
|
104
|
+
"""Test alarm tag callback."""
|
|
105
|
+
BUSID = 999
|
|
106
|
+
db = alarms_db
|
|
107
|
+
db._init_table()
|
|
108
|
+
a_tag = alarms_tag
|
|
109
|
+
a_values = []
|
|
110
|
+
|
|
111
|
+
def a_cb(tag):
|
|
112
|
+
a_values.append(tag.value)
|
|
113
|
+
|
|
114
|
+
a_tag.add_callback(a_cb, BUSID)
|
|
115
|
+
|
|
116
|
+
ping_tag = db.tags['localhost_ping']
|
|
117
|
+
ping_tag.value = (3.0, 12345000, BUSID)
|
|
118
|
+
rq = {
|
|
119
|
+
'__rta_id__': BUSID,
|
|
120
|
+
'action': 'HISTORY',
|
|
121
|
+
'date_ms': 10000,
|
|
122
|
+
'reply_tag': '__wwwserver__'
|
|
123
|
+
}
|
|
124
|
+
db.rta_cb(rq)
|
|
125
|
+
for record in a_values:
|
|
126
|
+
if record['tagname'] == 'localhost_ping':
|
|
127
|
+
assert record['kind'] == ALM
|
|
128
|
+
assert record['desc'] == 'Ping time to localhost 3.0'
|
|
@@ -11,8 +11,7 @@ from pymscada.tag import Tag
|
|
|
11
11
|
def opnotes_db():
|
|
12
12
|
"""Create a fixture for DB access."""
|
|
13
13
|
Path('tests/test_assets/db.sqlite').unlink(missing_ok=True)
|
|
14
|
-
return OpNotes(bus_ip=None,
|
|
15
|
-
db='tests/test_assets/db.sqlite')
|
|
14
|
+
return OpNotes(bus_ip=None, db='tests/test_assets/db.sqlite')
|
|
16
15
|
|
|
17
16
|
|
|
18
17
|
@pytest.fixture(scope='module')
|
|
@@ -34,11 +34,11 @@ async def test_periodic():
|
|
|
34
34
|
|
|
35
35
|
@pytest.mark.asyncio()
|
|
36
36
|
async def test_timechange():
|
|
37
|
-
"""Periodic should run four times in 0.
|
|
37
|
+
"""Periodic should run four times in 0.42 seconds."""
|
|
38
38
|
resetflag()
|
|
39
39
|
periodic = Periodic(do_period, 0.1)
|
|
40
40
|
await periodic.start()
|
|
41
|
-
await asyncio.sleep(0.
|
|
41
|
+
await asyncio.sleep(0.22)
|
|
42
42
|
periodic.period = 0.3
|
|
43
43
|
await asyncio.sleep(0.2)
|
|
44
44
|
await periodic.stop()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/__pycache__/__init__.cpython-311.pyc
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/pdf/__pycache__/__init__.cpython-311.pyc
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|