pymscada 0.2.0rc2__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.0rc2/src/pymscada.egg-info → pymscada-0.2.0rc3}/PKG-INFO +1 -1
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/pyproject.toml +1 -1
- pymscada-0.2.0rc3/src/pymscada/alarms.py +194 -0
- 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.0rc2 → pymscada-0.2.0rc3}/src/pymscada/module_config.py +5 -1
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/opnotes.py +13 -8
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/tag.py +1 -1
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3/src/pymscada.egg-info}/PKG-INFO +1 -1
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada.egg-info/SOURCES.txt +4 -0
- pymscada-0.2.0rc3/tests/test_alarms.py +125 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/tests/test_opnotes.py +1 -2
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/tests/test_periodic.py +2 -2
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/LICENSE +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/MANIFEST.in +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/README.md +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/setup.cfg +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/__init__.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/__main__.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/bus_client.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/bus_server.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/checkout.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/config.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/console.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/demo/README.md +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/demo/__init__.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/demo/__pycache__/__init__.cpython-311.pyc +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/demo/accuweather.yaml +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/demo/bus.yaml +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/demo/files.yaml +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/demo/history.yaml +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/demo/logixclient.yaml +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/demo/modbus_plc.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/demo/modbusclient.yaml +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/demo/modbusserver.yaml +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/demo/openweather.yaml +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/demo/opnotes.yaml +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/demo/ping.yaml +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/demo/pymscada-bus.service +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/demo/pymscada-demo-modbus_plc.service +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/demo/pymscada-files.service +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/demo/pymscada-history.service +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/demo/pymscada-io-logixclient.service +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/demo/pymscada-io-modbusclient.service +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/demo/pymscada-io-modbusserver.service +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/demo/pymscada-io-openweather.service +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/demo/pymscada-io-ping.service +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/demo/pymscada-io-snmpclient.service +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/demo/pymscada-opnotes.service +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/demo/pymscada-wwwserver.service +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/demo/snmpclient.yaml +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/demo/tags.yaml +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/demo/wwwserver.yaml +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/files.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/history.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/iodrivers/__init__.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/iodrivers/accuweather.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/iodrivers/logix_client.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/iodrivers/logix_map.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/iodrivers/modbus_client.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/iodrivers/modbus_map.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/iodrivers/modbus_server.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/iodrivers/openweather.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/iodrivers/ping_client.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/iodrivers/ping_map.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/iodrivers/snmp_client.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/iodrivers/snmp_map.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/main.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/misc.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/pdf/__init__.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/pdf/__pycache__/__init__.cpython-311.pyc +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/pdf/one.pdf +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/pdf/two.pdf +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/periodic.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/protocol_constants.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/samplers.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/tools/snmp_client2.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/tools/walk.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/validate.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada/www_server.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada.egg-info/dependency_links.txt +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada.egg-info/entry_points.txt +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada.egg-info/requires.txt +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/src/pymscada.egg-info/top_level.txt +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/tests/test_bus_server.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/tests/test_config.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/tests/test_history.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/tests/test_misc.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/tests/test_openweather.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/tests/test_samplers.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/tests/test_tag.py +0 -0
- {pymscada-0.2.0rc2 → pymscada-0.2.0rc3}/tests/test_validate.py +0 -0
|
@@ -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}')
|
|
@@ -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
|
|
@@ -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:
|
|
@@ -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'
|
|
@@ -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.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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pymscada-0.2.0rc2 → 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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|