pymscada 0.2.0rc1__tar.gz → 0.2.0rc3__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.0rc1/src/pymscada.egg-info → pymscada-0.2.0rc3}/PKG-INFO +2 -2
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/pyproject.toml +2 -2
- pymscada-0.2.0rc3/src/pymscada/alarms.py +194 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/bus_client.py +4 -3
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/bus_server.py +14 -2
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/console.py +2 -2
- pymscada-0.2.0rc3/src/pymscada/demo/alarms.yaml +5 -0
- pymscada-0.2.0rc3/src/pymscada/demo/pymscada-alarms.service +16 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/files.py +3 -3
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/history.py +26 -3
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/iodrivers/openweather.py +21 -2
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/module_config.py +5 -1
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/opnotes.py +27 -3
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/tag.py +1 -1
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/www_server.py +43 -10
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3/src/pymscada.egg-info}/PKG-INFO +2 -2
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada.egg-info/SOURCES.txt +4 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada.egg-info/requires.txt +1 -1
- pymscada-0.2.0rc3/tests/test_alarms.py +125 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/tests/test_openweather.py +1 -2
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/tests/test_opnotes.py +1 -2
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/tests/test_periodic.py +2 -2
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/LICENSE +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/MANIFEST.in +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/README.md +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/setup.cfg +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/__init__.py +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/__main__.py +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/checkout.py +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/config.py +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/demo/README.md +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/demo/__init__.py +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/demo/__pycache__/__init__.cpython-311.pyc +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/demo/accuweather.yaml +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/demo/bus.yaml +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/demo/files.yaml +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/demo/history.yaml +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/demo/logixclient.yaml +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/demo/modbus_plc.py +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/demo/modbusclient.yaml +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/demo/modbusserver.yaml +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/demo/openweather.yaml +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/demo/opnotes.yaml +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/demo/ping.yaml +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/demo/pymscada-bus.service +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/demo/pymscada-demo-modbus_plc.service +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/demo/pymscada-files.service +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/demo/pymscada-history.service +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/demo/pymscada-io-logixclient.service +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/demo/pymscada-io-modbusclient.service +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/demo/pymscada-io-modbusserver.service +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/demo/pymscada-io-openweather.service +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/demo/pymscada-io-ping.service +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/demo/pymscada-io-snmpclient.service +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/demo/pymscada-opnotes.service +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/demo/pymscada-wwwserver.service +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/demo/snmpclient.yaml +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/demo/tags.yaml +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/demo/wwwserver.yaml +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/iodrivers/__init__.py +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/iodrivers/accuweather.py +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/iodrivers/logix_client.py +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/iodrivers/logix_map.py +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/iodrivers/modbus_client.py +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/iodrivers/modbus_map.py +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/iodrivers/modbus_server.py +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/iodrivers/ping_client.py +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/iodrivers/ping_map.py +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/iodrivers/snmp_client.py +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/iodrivers/snmp_map.py +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/main.py +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/misc.py +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/pdf/__init__.py +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/pdf/__pycache__/__init__.cpython-311.pyc +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/pdf/one.pdf +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/pdf/two.pdf +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/periodic.py +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/protocol_constants.py +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/samplers.py +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/tools/snmp_client2.py +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/tools/walk.py +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada/validate.py +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada.egg-info/dependency_links.txt +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada.egg-info/entry_points.txt +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/src/pymscada.egg-info/top_level.txt +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/tests/test_bus_server.py +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/tests/test_config.py +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/tests/test_history.py +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/tests/test_misc.py +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/tests/test_samplers.py +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/tests/test_tag.py +0 -0
- {pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/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.0rc3
|
|
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.0rc2
|
|
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.0rc3"
|
|
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.0rc2", # 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,194 @@
|
|
|
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, TagInfo, TYPES
|
|
9
|
+
|
|
10
|
+
ALM = 0
|
|
11
|
+
RTN = 1
|
|
12
|
+
ACT = 2
|
|
13
|
+
INF = 3
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Alarms:
|
|
17
|
+
"""Connect to bus_ip:bus_port, store and provide Alarms."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
bus_ip: str | None = '127.0.0.1',
|
|
22
|
+
bus_port: int | None = 1324,
|
|
23
|
+
db: str | None = None,
|
|
24
|
+
table: str = 'alarms',
|
|
25
|
+
tag_info: TagInfo = {},
|
|
26
|
+
rta_tag: str = '__alarms__'
|
|
27
|
+
) -> None:
|
|
28
|
+
"""
|
|
29
|
+
Connect to bus_ip:bus_port, serve and update alarms database.
|
|
30
|
+
|
|
31
|
+
Open an Alarms table, creating if necessary. Provide additions
|
|
32
|
+
and history requests via the rta_tag.
|
|
33
|
+
|
|
34
|
+
Event loop must be running.
|
|
35
|
+
|
|
36
|
+
For testing only: bus_ip can be None to skip connection.
|
|
37
|
+
"""
|
|
38
|
+
if db is None:
|
|
39
|
+
raise SystemExit('Alarms db must be defined')
|
|
40
|
+
if bus_ip is None:
|
|
41
|
+
logging.warning('Alarms has bus_ip=None, only use for testing')
|
|
42
|
+
else:
|
|
43
|
+
try:
|
|
44
|
+
socket.gethostbyname(bus_ip)
|
|
45
|
+
except socket.gaierror as e:
|
|
46
|
+
raise ValueError(f'Cannot resolve IP/hostname: {e}')
|
|
47
|
+
if not isinstance(bus_port, int):
|
|
48
|
+
raise TypeError('bus_port must be an integer')
|
|
49
|
+
if not 1024 <= bus_port <= 65535:
|
|
50
|
+
raise ValueError('bus_port must be between 1024 and 65535')
|
|
51
|
+
if not isinstance(rta_tag, str) or not rta_tag:
|
|
52
|
+
raise ValueError('rta_tag must be a non-empty string')
|
|
53
|
+
if not isinstance(table, str) or not table:
|
|
54
|
+
raise ValueError('table must be a non-empty string')
|
|
55
|
+
|
|
56
|
+
logging.warning(f'Alarms {bus_ip} {bus_port} {db} {rta_tag}')
|
|
57
|
+
self.connection = sqlite3.connect(db)
|
|
58
|
+
self.tags: dict[str, Tag] = {}
|
|
59
|
+
self.alarm_test: dict[str, dict] = {}
|
|
60
|
+
self.in_alarm: set[str] = set()
|
|
61
|
+
for tagname, tag in tag_info.items():
|
|
62
|
+
if 'alarm' not in tag:
|
|
63
|
+
continue
|
|
64
|
+
self.tags[tagname] = Tag(tagname, tag['type'])
|
|
65
|
+
self.tags[tagname].desc = tag['desc']
|
|
66
|
+
self.tags[tagname].add_callback(self.alarm_cb)
|
|
67
|
+
operator, value = tag['alarm'].split(' ')
|
|
68
|
+
self.alarm_test[tagname] = [
|
|
69
|
+
{
|
|
70
|
+
'==': (lambda x, y: x == y),
|
|
71
|
+
'<': (lambda x, y: x < y),
|
|
72
|
+
'>': (lambda x, y: x > y),
|
|
73
|
+
'<=': (lambda x, y: x <= y),
|
|
74
|
+
'>=': (lambda x, y: x >= y)
|
|
75
|
+
}[operator],
|
|
76
|
+
float(value)
|
|
77
|
+
]
|
|
78
|
+
self.table = table
|
|
79
|
+
self.cursor = self.connection.cursor()
|
|
80
|
+
self.busclient = BusClient(bus_ip, bus_port, module='Alarms')
|
|
81
|
+
self.rta = Tag(rta_tag, dict)
|
|
82
|
+
self.rta.value = {}
|
|
83
|
+
self.busclient.add_callback_rta(rta_tag, self.rta_cb)
|
|
84
|
+
self._init_table()
|
|
85
|
+
atexit.register(self.close)
|
|
86
|
+
|
|
87
|
+
def alarm_cb(self, tag):
|
|
88
|
+
"""Callback for alarm tags."""
|
|
89
|
+
operator, value = self.alarm_test[tag.name]
|
|
90
|
+
if operator(tag.value, value) and tag.name not in self.in_alarm:
|
|
91
|
+
logging.warning(f'Alarm {tag.name} {tag.value}')
|
|
92
|
+
alarm_record = {
|
|
93
|
+
'action': 'ADD',
|
|
94
|
+
'date_ms': int(tag.time_us / 1000),
|
|
95
|
+
'tagname': tag.name,
|
|
96
|
+
'transition': ALM,
|
|
97
|
+
'description': f'{tag.desc} {tag.value}'
|
|
98
|
+
}
|
|
99
|
+
self.rta_cb(alarm_record)
|
|
100
|
+
self.in_alarm.add(tag.name)
|
|
101
|
+
elif not operator(tag.value, value) and tag.name in self.in_alarm:
|
|
102
|
+
logging.info(f'No alarm {tag.name} {tag.value}')
|
|
103
|
+
alarm_record = {
|
|
104
|
+
'action': 'ADD',
|
|
105
|
+
'date_ms': int(tag.time_us / 1000),
|
|
106
|
+
'tagname': tag.name,
|
|
107
|
+
'transition': RTN,
|
|
108
|
+
'description': f'{tag.desc} {tag.value}'
|
|
109
|
+
}
|
|
110
|
+
self.rta_cb(alarm_record)
|
|
111
|
+
self.in_alarm.remove(tag.name)
|
|
112
|
+
|
|
113
|
+
def _init_table(self):
|
|
114
|
+
"""Initialize the database table schema."""
|
|
115
|
+
query = (
|
|
116
|
+
'CREATE TABLE IF NOT EXISTS ' + self.table +
|
|
117
|
+
'(id INTEGER PRIMARY KEY ASC, '
|
|
118
|
+
'date_ms INTEGER, '
|
|
119
|
+
'tagname TEXT, '
|
|
120
|
+
'transition INTEGER, '
|
|
121
|
+
'description TEXT)'
|
|
122
|
+
)
|
|
123
|
+
self.cursor.execute(query)
|
|
124
|
+
|
|
125
|
+
# Add startup record using existing ADD functionality
|
|
126
|
+
startup_record = {
|
|
127
|
+
'action': 'ADD',
|
|
128
|
+
'date_ms': int(time.time() * 1000),
|
|
129
|
+
'tagname': self.rta.name,
|
|
130
|
+
'transition': INF,
|
|
131
|
+
'description': 'Alarm logging started'
|
|
132
|
+
}
|
|
133
|
+
self.rta_cb(startup_record)
|
|
134
|
+
|
|
135
|
+
def rta_cb(self, request):
|
|
136
|
+
"""Respond to Request to Author and publish on rta_tag as needed."""
|
|
137
|
+
if 'action' not in request:
|
|
138
|
+
logging.warning(f'rta_cb malformed {request}')
|
|
139
|
+
elif request['action'] == 'ADD':
|
|
140
|
+
try:
|
|
141
|
+
logging.info(f'add {request}')
|
|
142
|
+
with self.connection:
|
|
143
|
+
self.cursor.execute(
|
|
144
|
+
f'INSERT INTO {self.table} '
|
|
145
|
+
'(date_ms, tagname, transition, description) '
|
|
146
|
+
'VALUES(:date_ms, :tagname, :transition, :description) '
|
|
147
|
+
'RETURNING *;',
|
|
148
|
+
request)
|
|
149
|
+
res = self.cursor.fetchone()
|
|
150
|
+
self.rta.value = {
|
|
151
|
+
'id': res[0],
|
|
152
|
+
'date_ms': res[1],
|
|
153
|
+
'tagname': res[2],
|
|
154
|
+
'transition': res[3],
|
|
155
|
+
'description': res[4]
|
|
156
|
+
}
|
|
157
|
+
except sqlite3.IntegrityError as error:
|
|
158
|
+
logging.warning(f'Alarms rta_cb {error}')
|
|
159
|
+
elif request['action'] == 'HISTORY':
|
|
160
|
+
try:
|
|
161
|
+
logging.info(f'history {request}')
|
|
162
|
+
with self.connection:
|
|
163
|
+
self.cursor.execute(
|
|
164
|
+
f'SELECT * FROM {self.table} WHERE date_ms > :date_ms '
|
|
165
|
+
'ORDER BY (date_ms - :date_ms);', request)
|
|
166
|
+
for res in self.cursor.fetchall():
|
|
167
|
+
self.rta.value = {
|
|
168
|
+
'__rta_id__': request['__rta_id__'],
|
|
169
|
+
'id': res[0],
|
|
170
|
+
'date_ms': res[1],
|
|
171
|
+
'tagname': res[2],
|
|
172
|
+
'transition': res[3],
|
|
173
|
+
'description': res[4]
|
|
174
|
+
}
|
|
175
|
+
except sqlite3.IntegrityError as error:
|
|
176
|
+
logging.warning(f'Alarms rta_cb {error}')
|
|
177
|
+
|
|
178
|
+
async def start(self):
|
|
179
|
+
"""Async startup."""
|
|
180
|
+
await self.busclient.start()
|
|
181
|
+
|
|
182
|
+
def close(self):
|
|
183
|
+
"""Clean shutdown of alarms logging."""
|
|
184
|
+
shutdown_record = {
|
|
185
|
+
'action': 'ADD',
|
|
186
|
+
'date_ms': int(time.time() * 1000),
|
|
187
|
+
'tagname': self.rta.name,
|
|
188
|
+
'transition': INF,
|
|
189
|
+
'description': 'Alarm logging stopped'
|
|
190
|
+
}
|
|
191
|
+
try:
|
|
192
|
+
self.rta_cb(shutdown_record)
|
|
193
|
+
except sqlite3.Error as e:
|
|
194
|
+
logging.error(f'Error during alarm shutdown: {e}')
|
|
@@ -12,8 +12,9 @@ class BusClient:
|
|
|
12
12
|
"""
|
|
13
13
|
Connects to a Bus Server.
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
The client is created without a connection. client.start() creates the
|
|
16
|
+
connection and checks the tags at that time. When the connection fails
|
|
17
|
+
the client dies. A connection is mandatory for the client to run.
|
|
17
18
|
"""
|
|
18
19
|
|
|
19
20
|
def __init__(self, ip: str = '127.0.0.1', port: int = 1324, tag_info=None,
|
|
@@ -125,7 +126,6 @@ class BusClient:
|
|
|
125
126
|
|
|
126
127
|
async def read(self):
|
|
127
128
|
"""Read forever."""
|
|
128
|
-
await self.open_connection()
|
|
129
129
|
while True:
|
|
130
130
|
try:
|
|
131
131
|
head = await self.reader.readexactly(14)
|
|
@@ -223,4 +223,5 @@ class BusClient:
|
|
|
223
223
|
|
|
224
224
|
async def start(self):
|
|
225
225
|
"""Start async."""
|
|
226
|
+
await self.open_connection()
|
|
226
227
|
self.read_task = asyncio.create_task(self.read())
|
|
@@ -3,6 +3,7 @@ import asyncio
|
|
|
3
3
|
from struct import pack, unpack
|
|
4
4
|
import time
|
|
5
5
|
import logging
|
|
6
|
+
import socket
|
|
6
7
|
import pymscada.protocol_constants as pc
|
|
7
8
|
|
|
8
9
|
|
|
@@ -143,8 +144,12 @@ class BusServer:
|
|
|
143
144
|
|
|
144
145
|
__slots__ = ('ip', 'port', 'server', 'connections', 'bus_tag')
|
|
145
146
|
|
|
146
|
-
def __init__(
|
|
147
|
-
|
|
147
|
+
def __init__(
|
|
148
|
+
self,
|
|
149
|
+
ip: str = '127.0.0.1',
|
|
150
|
+
port: int = 1324,
|
|
151
|
+
bus_tag: str = '__bus__'
|
|
152
|
+
) -> None:
|
|
148
153
|
"""
|
|
149
154
|
Serve Tags on ip:port, echoing changes to any subscribers.
|
|
150
155
|
|
|
@@ -154,6 +159,13 @@ class BusServer:
|
|
|
154
159
|
|
|
155
160
|
Event loop must be running.
|
|
156
161
|
"""
|
|
162
|
+
try:
|
|
163
|
+
socket.gethostbyname(ip)
|
|
164
|
+
except socket.gaierror as e:
|
|
165
|
+
raise ValueError(f'Cannot resolve IP/hostname: {e}')
|
|
166
|
+
if not isinstance(bus_tag, str):
|
|
167
|
+
raise ValueError('bus_tag must be a string')
|
|
168
|
+
|
|
157
169
|
self.ip = ip
|
|
158
170
|
self.port = port
|
|
159
171
|
self.server = None
|
|
@@ -3,7 +3,7 @@ import asyncio
|
|
|
3
3
|
import logging
|
|
4
4
|
import sys
|
|
5
5
|
from pymscada.bus_client import BusClient
|
|
6
|
-
from pymscada.tag import Tag, tag_for_web
|
|
6
|
+
from pymscada.tag import Tag, tag_for_web, TagInfo
|
|
7
7
|
try:
|
|
8
8
|
import termios
|
|
9
9
|
import tty
|
|
@@ -153,7 +153,7 @@ class Console:
|
|
|
153
153
|
"""Provide a text console to interact with a Bus."""
|
|
154
154
|
|
|
155
155
|
def __init__(self, bus_ip: str = '127.0.0.1', bus_port: int = 1324,
|
|
156
|
-
tag_info: dict = {}):
|
|
156
|
+
tag_info: dict[str, TagInfo] = {}) -> None:
|
|
157
157
|
"""
|
|
158
158
|
Connect to bus_ip:bus_port and provide console interaction with a Bus.
|
|
159
159
|
|
|
@@ -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
|
|
@@ -10,13 +10,13 @@ class Files():
|
|
|
10
10
|
"""Connect to bus_ip:bus_port, store and provide a value history."""
|
|
11
11
|
|
|
12
12
|
def __init__(self, bus_ip: str = '127.0.0.1', bus_port: int = 1324,
|
|
13
|
-
path: str = '', files: list =
|
|
13
|
+
path: str = '', files: list[dict] = [],
|
|
14
14
|
rta_tag: str = '__files__') -> None:
|
|
15
15
|
"""
|
|
16
16
|
Connect to bus_ip:bus_port, serve and update files.
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
Scans paths in files at the root defined by path. These should
|
|
19
|
+
be readable by wwwserver so that download links work.
|
|
20
20
|
|
|
21
21
|
Event loop must be running.
|
|
22
22
|
"""
|
|
@@ -25,6 +25,7 @@ import logging
|
|
|
25
25
|
from pathlib import Path
|
|
26
26
|
from struct import pack, pack_into, unpack_from, error
|
|
27
27
|
import time
|
|
28
|
+
import socket
|
|
28
29
|
from typing import TypedDict, Optional
|
|
29
30
|
from pymscada.bus_client import BusClient
|
|
30
31
|
from pymscada.tag import Tag, TagInfo, TYPES
|
|
@@ -220,9 +221,14 @@ class TagHistory():
|
|
|
220
221
|
class History():
|
|
221
222
|
"""Connect to bus_ip:bus_port, store and provide a value history."""
|
|
222
223
|
|
|
223
|
-
def __init__(
|
|
224
|
-
|
|
225
|
-
|
|
224
|
+
def __init__(
|
|
225
|
+
self,
|
|
226
|
+
bus_ip: str = '127.0.0.1',
|
|
227
|
+
bus_port: int = 1324,
|
|
228
|
+
path: str = 'history',
|
|
229
|
+
tag_info: TagInfo = {},
|
|
230
|
+
rta_tag: str | None = '__history__'
|
|
231
|
+
) -> None:
|
|
226
232
|
"""
|
|
227
233
|
Connect to bus_ip:bus_port, store and provide a value history.
|
|
228
234
|
|
|
@@ -232,6 +238,23 @@ class History():
|
|
|
232
238
|
|
|
233
239
|
Event loop must be running.
|
|
234
240
|
"""
|
|
241
|
+
if not isinstance(bus_ip, str):
|
|
242
|
+
raise ValueError("bus_ip must be a string")
|
|
243
|
+
try:
|
|
244
|
+
socket.gethostbyname(bus_ip)
|
|
245
|
+
except socket.gaierror:
|
|
246
|
+
raise ValueError(f"Invalid bus_ip: {bus_ip}")
|
|
247
|
+
if not isinstance(bus_port, int):
|
|
248
|
+
raise ValueError("bus_port must be an integer")
|
|
249
|
+
if not 1024 <= bus_port <= 65535:
|
|
250
|
+
raise ValueError(f"bus_port must be between 1024 and 65535")
|
|
251
|
+
if not isinstance(path, str):
|
|
252
|
+
raise ValueError("path must be a string")
|
|
253
|
+
if not isinstance(tag_info, dict):
|
|
254
|
+
raise ValueError("tag_info must be a dictionary")
|
|
255
|
+
if not isinstance(rta_tag, (str, type(None))):
|
|
256
|
+
raise ValueError("rta_tag must be a string or None")
|
|
257
|
+
|
|
235
258
|
self.busclient = BusClient(bus_ip, bus_port, module='History')
|
|
236
259
|
self.path = path
|
|
237
260
|
self.tags: dict[str, Tag] = {}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import asyncio
|
|
3
3
|
import aiohttp
|
|
4
4
|
import logging
|
|
5
|
+
import socket
|
|
5
6
|
from time import time
|
|
6
7
|
from pymscada.bus_client import BusClient
|
|
7
8
|
from pymscada.periodic import Periodic
|
|
@@ -10,8 +11,14 @@ from pymscada.tag import Tag
|
|
|
10
11
|
class OpenWeatherClient:
|
|
11
12
|
"""Get weather data from OpenWeather Current and Forecast APIs."""
|
|
12
13
|
|
|
13
|
-
def __init__(
|
|
14
|
-
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
bus_ip: str | None = '127.0.0.1',
|
|
17
|
+
bus_port: int = 1324,
|
|
18
|
+
proxy: str | None = None,
|
|
19
|
+
api: dict = {},
|
|
20
|
+
tags: list = []
|
|
21
|
+
) -> None:
|
|
15
22
|
"""
|
|
16
23
|
Connect to bus on bus_ip:bus_port.
|
|
17
24
|
|
|
@@ -21,6 +28,18 @@ class OpenWeatherClient:
|
|
|
21
28
|
- times: list of hours ahead to fetch forecast data for
|
|
22
29
|
- units: optional units (standard, metric, imperial)
|
|
23
30
|
"""
|
|
31
|
+
if bus_ip is not None:
|
|
32
|
+
try:
|
|
33
|
+
socket.gethostbyname(bus_ip)
|
|
34
|
+
except socket.gaierror:
|
|
35
|
+
raise ValueError(f"Invalid bus_ip: {bus_ip}")
|
|
36
|
+
if not isinstance(proxy, str) and proxy is not None:
|
|
37
|
+
raise ValueError("proxy must be a string or None")
|
|
38
|
+
if not isinstance(api, dict):
|
|
39
|
+
raise ValueError("api must be a dictionary")
|
|
40
|
+
if not isinstance(tags, list):
|
|
41
|
+
raise ValueError("tags must be a list")
|
|
42
|
+
|
|
24
43
|
self.busclient = None
|
|
25
44
|
if bus_ip is not None:
|
|
26
45
|
self.busclient = BusClient(bus_ip, bus_port, module='OpenWeather')
|
|
@@ -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',
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Operator Notes."""
|
|
2
2
|
import logging
|
|
3
3
|
import sqlite3 # note that sqlite3 has blocking calls
|
|
4
|
+
import socket
|
|
4
5
|
from pymscada.bus_client import BusClient
|
|
5
6
|
from pymscada.tag import Tag
|
|
6
7
|
|
|
@@ -8,9 +9,14 @@ from pymscada.tag import Tag
|
|
|
8
9
|
class OpNotes:
|
|
9
10
|
"""Connect to bus_ip:bus_port, store and provide Operator Notes."""
|
|
10
11
|
|
|
11
|
-
def __init__(
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
bus_ip: str = '127.0.0.1',
|
|
15
|
+
bus_port: int = 1324,
|
|
16
|
+
db: str | None = None,
|
|
17
|
+
table: str = 'opnotes',
|
|
18
|
+
rta_tag: str = '__opnotes__'
|
|
19
|
+
) -> None:
|
|
14
20
|
"""
|
|
15
21
|
Connect to bus_ip:bus_port, serve and update operator notes database.
|
|
16
22
|
|
|
@@ -18,9 +24,27 @@ class OpNotes:
|
|
|
18
24
|
updates, deletions and history requests via the rta_tag.
|
|
19
25
|
|
|
20
26
|
Event loop must be running.
|
|
27
|
+
|
|
28
|
+
For testing only: bus_ip can be None to skip connection.
|
|
21
29
|
"""
|
|
22
30
|
if db is None:
|
|
23
31
|
raise SystemExit('OpNotes db must be defined')
|
|
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')
|
|
43
|
+
if not isinstance(rta_tag, str) or not rta_tag:
|
|
44
|
+
raise ValueError('rta_tag must be a non-empty string')
|
|
45
|
+
if not isinstance(table, str) or not table:
|
|
46
|
+
raise ValueError('table must be a non-empty string')
|
|
47
|
+
|
|
24
48
|
logging.warning(f'OpNotes {bus_ip} {bus_port} {db} {rta_tag}')
|
|
25
49
|
self.connection = sqlite3.connect(db)
|
|
26
50
|
self.table = table
|
|
@@ -4,6 +4,7 @@ from aiohttp import web, WSMsgType
|
|
|
4
4
|
import logging
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
from struct import pack, unpack_from
|
|
7
|
+
import socket
|
|
7
8
|
import time
|
|
8
9
|
from pymscada.bus_client import BusClient
|
|
9
10
|
import pymscada.protocol_constants as pc
|
|
@@ -34,11 +35,13 @@ class WSHandler():
|
|
|
34
35
|
ids = set(range(1, 1000))
|
|
35
36
|
|
|
36
37
|
def __init__(self, ws: web.WebSocketResponse, pages: dict,
|
|
37
|
-
tag_info: dict[str, Tag], do_rta, interface: Interface
|
|
38
|
+
tag_info: dict[str, Tag], do_rta, interface: Interface,
|
|
39
|
+
webclient: dict):
|
|
38
40
|
"""Create callbacks to monitor tag values."""
|
|
39
41
|
self.ws = ws
|
|
40
42
|
self.pages = pages
|
|
41
43
|
self.tag_info = tag_info
|
|
44
|
+
self.webclient = webclient
|
|
42
45
|
self.tag_by_id: dict[int, Tag] = {}
|
|
43
46
|
self.tag_by_name: dict[str, Tag] = {}
|
|
44
47
|
self.queue = asyncio.Queue()
|
|
@@ -168,6 +171,8 @@ class WSHandler():
|
|
|
168
171
|
async def connection_active(self):
|
|
169
172
|
"""Run while the connection is active and don't return."""
|
|
170
173
|
send_queue = asyncio.create_task(self.send_queue())
|
|
174
|
+
self.queue.put_nowait(
|
|
175
|
+
(False, {'type': 'webclient', 'payload': self.webclient}))
|
|
171
176
|
self.queue.put_nowait(
|
|
172
177
|
(False, {'type': 'pages', 'payload': self.pages}))
|
|
173
178
|
async for msg in self.ws:
|
|
@@ -210,11 +215,19 @@ class WSHandler():
|
|
|
210
215
|
class WwwServer:
|
|
211
216
|
"""Connect to bus on bus_ip:bus_port, serve on ip:port for webclient."""
|
|
212
217
|
|
|
213
|
-
def __init__(
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
+
def __init__(
|
|
219
|
+
self,
|
|
220
|
+
bus_ip: str = '127.0.0.1',
|
|
221
|
+
bus_port: int = 1324,
|
|
222
|
+
ip: str = '127.0.0.1',
|
|
223
|
+
port: int = 8324,
|
|
224
|
+
get_path: str | None = None,
|
|
225
|
+
tag_info: dict = {},
|
|
226
|
+
pages: dict = {},
|
|
227
|
+
webclient: dict = {},
|
|
228
|
+
serve_path: str | None = None,
|
|
229
|
+
www_tag: str = '__wwwserver__'
|
|
230
|
+
) -> None:
|
|
218
231
|
"""
|
|
219
232
|
Connect to bus on bus_ip:bus_port, serve on ip:port for webclient.
|
|
220
233
|
|
|
@@ -224,16 +237,36 @@ class WwwServer:
|
|
|
224
237
|
|
|
225
238
|
Event loop must be running.
|
|
226
239
|
"""
|
|
240
|
+
try:
|
|
241
|
+
socket.gethostbyname(bus_ip)
|
|
242
|
+
except socket.gaierror as e:
|
|
243
|
+
raise ValueError(f'Cannot resolve bus IP/hostname: {e}')
|
|
244
|
+
if not isinstance(bus_port, int):
|
|
245
|
+
raise TypeError('bus_port must be an integer')
|
|
246
|
+
if not 1024 <= bus_port <= 65535:
|
|
247
|
+
raise ValueError('bus_port must be between 1024 and 65535')
|
|
248
|
+
try:
|
|
249
|
+
socket.gethostbyname(ip)
|
|
250
|
+
except socket.gaierror as e:
|
|
251
|
+
raise ValueError(f'Cannot resolve IP/hostname: {e}')
|
|
252
|
+
if not isinstance(port, int):
|
|
253
|
+
raise TypeError('port must be an integer')
|
|
254
|
+
if not 1024 <= port <= 65535:
|
|
255
|
+
raise ValueError('port must be between 1024 and 65535')
|
|
256
|
+
if not isinstance(www_tag, str) or not www_tag:
|
|
257
|
+
raise ValueError('www_tag must be a non-empty string')
|
|
258
|
+
|
|
227
259
|
self.busclient = BusClient(bus_ip, bus_port, tag_info,
|
|
228
260
|
module='WWW Server')
|
|
229
261
|
self.ip = ip
|
|
230
262
|
self.port = port
|
|
231
263
|
self.get_path = get_path
|
|
232
|
-
self.serve_path = Path(serve_path)
|
|
264
|
+
self.serve_path = Path(serve_path) if serve_path else None
|
|
233
265
|
for tagname, tag in tag_info.items():
|
|
234
266
|
tag_for_web(tagname, tag)
|
|
235
267
|
self.tag_info = tag_info
|
|
236
268
|
self.pages = pages
|
|
269
|
+
self.webclient = webclient
|
|
237
270
|
self.interface = Interface(www_tag)
|
|
238
271
|
|
|
239
272
|
async def redirect_handler(self, _request: web.Request):
|
|
@@ -256,14 +289,14 @@ class WwwServer:
|
|
|
256
289
|
async def path_handler(self, request: web.Request):
|
|
257
290
|
"""Plain files."""
|
|
258
291
|
logging.info(f"path {request.match_info['path']}")
|
|
292
|
+
if self.serve_path is None:
|
|
293
|
+
return web.HTTPForbidden(reason='path not configured')
|
|
259
294
|
path = self.serve_path.joinpath(request.match_info['path'])
|
|
260
295
|
if path.is_dir():
|
|
261
296
|
return web.HTTPForbidden(reason='folder not permitted')
|
|
262
297
|
if not path.exists():
|
|
263
298
|
return web.HTTPNotFound(reason='no such file in path')
|
|
264
299
|
return web.FileResponse(path)
|
|
265
|
-
# logging.warning(f"path not configured {request.match_info['path']}")
|
|
266
|
-
# return web.HTTPForbidden(reason='path not permitted')
|
|
267
300
|
|
|
268
301
|
async def websocket_handler(self, request: web.Request):
|
|
269
302
|
"""Wait for connections. Create a new one each time."""
|
|
@@ -272,7 +305,7 @@ class WwwServer:
|
|
|
272
305
|
ws = web.WebSocketResponse(max_msg_size=0) # disables max message size
|
|
273
306
|
await ws.prepare(request)
|
|
274
307
|
await WSHandler(ws, self.pages, self.tag_info, self.busclient.rta,
|
|
275
|
-
self.interface).connection_active()
|
|
308
|
+
self.interface, self.webclient).connection_active()
|
|
276
309
|
await ws.close()
|
|
277
310
|
logging.info(f"WS closed {peer}")
|
|
278
311
|
return ws
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: pymscada
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.0rc3
|
|
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.0rc2
|
|
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,125 @@
|
|
|
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
|
+
# Verify startup record was created
|
|
43
|
+
assert tag.value['transition'] == 3 # INF
|
|
44
|
+
assert 'Alarm logging started' in tag.value['description']
|
|
45
|
+
|
|
46
|
+
# Test adding an alarm
|
|
47
|
+
record = {
|
|
48
|
+
'action': 'ADD',
|
|
49
|
+
'tagname': 'TEST_TAG',
|
|
50
|
+
'date_ms': 1234567890123,
|
|
51
|
+
'transition': ALM,
|
|
52
|
+
'description': 'Test alarm condition'
|
|
53
|
+
}
|
|
54
|
+
db.rta_cb(record)
|
|
55
|
+
assert tag.value['id'] == 2 # Second record after startup
|
|
56
|
+
assert tag.value['transition'] == 0
|
|
57
|
+
assert tag.value['description'] == 'Test alarm condition'
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_history_queries(alarms_db, alarms_tag, reply_tag):
|
|
61
|
+
"""Test history queries."""
|
|
62
|
+
BUSID = 999
|
|
63
|
+
db = alarms_db
|
|
64
|
+
a_tag: Tag = alarms_tag
|
|
65
|
+
a_values = []
|
|
66
|
+
|
|
67
|
+
def a_cb(tag):
|
|
68
|
+
a_values.append(tag.value)
|
|
69
|
+
|
|
70
|
+
a_tag.add_callback(a_cb, BUSID)
|
|
71
|
+
|
|
72
|
+
# Add some test records
|
|
73
|
+
record = {
|
|
74
|
+
'action': 'ADD',
|
|
75
|
+
'tagname': 'TEST_TAG',
|
|
76
|
+
'date_ms': 12345,
|
|
77
|
+
'transition': ALM,
|
|
78
|
+
'description': 'Test alarm'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for i in range(10):
|
|
82
|
+
record['date_ms'] -= 1
|
|
83
|
+
record['transition'] = i % 4 # Cycle through transitions
|
|
84
|
+
db.rta_cb(record)
|
|
85
|
+
|
|
86
|
+
rq = {
|
|
87
|
+
'__rta_id__': BUSID,
|
|
88
|
+
'action': 'HISTORY',
|
|
89
|
+
'date_ms': 12345 - 5.1,
|
|
90
|
+
'reply_tag': '__wwwserver__'
|
|
91
|
+
}
|
|
92
|
+
db.rta_cb(rq)
|
|
93
|
+
assert a_values[10]['date_ms'] == 12340
|
|
94
|
+
assert a_values[-1]['description'] == 'Alarm logging started'
|
|
95
|
+
|
|
96
|
+
db.close()
|
|
97
|
+
assert a_values[-1]['transition'] == INF
|
|
98
|
+
assert a_values[-1]['description'] == 'Alarm logging stopped'
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_alarm_tag(alarms_db, alarms_tag):
|
|
102
|
+
"""Test alarm tag callback."""
|
|
103
|
+
BUSID = 999
|
|
104
|
+
db = alarms_db
|
|
105
|
+
a_tag = alarms_tag
|
|
106
|
+
a_values = []
|
|
107
|
+
|
|
108
|
+
def a_cb(tag):
|
|
109
|
+
a_values.append(tag.value)
|
|
110
|
+
|
|
111
|
+
a_tag.add_callback(a_cb, BUSID)
|
|
112
|
+
|
|
113
|
+
ping_tag = db.tags['localhost_ping']
|
|
114
|
+
ping_tag.value = (3.0, 12345000, BUSID)
|
|
115
|
+
rq = {
|
|
116
|
+
'__rta_id__': BUSID,
|
|
117
|
+
'action': 'HISTORY',
|
|
118
|
+
'date_ms': 10000,
|
|
119
|
+
'reply_tag': '__wwwserver__'
|
|
120
|
+
}
|
|
121
|
+
db.rta_cb(rq)
|
|
122
|
+
for record in a_values:
|
|
123
|
+
if record['tagname'] == 'localhost_ping':
|
|
124
|
+
assert record['transition'] == ALM
|
|
125
|
+
assert record['description'] == 'Ping time to localhost 3.0'
|
|
@@ -19,8 +19,7 @@ async def test_openweather_temperature():
|
|
|
19
19
|
}
|
|
20
20
|
tags = ['London_Temp', 'London_Temp_01', 'London_Temp_02',
|
|
21
21
|
'London_Temp_03']
|
|
22
|
-
client = OpenWeatherClient(bus_ip=None,
|
|
23
|
-
tags=tags)
|
|
22
|
+
client = OpenWeatherClient(bus_ip=None, api=api_config, tags=tags)
|
|
24
23
|
try:
|
|
25
24
|
handle_task = asyncio.create_task(client.handle_response())
|
|
26
25
|
await client.fetch_current_data()
|
|
@@ -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
|
{pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/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
|
{pymscada-0.2.0rc1 → pymscada-0.2.0rc3}/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
|