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.

Files changed (92) hide show
  1. {pymscada-0.2.0rc2/src/pymscada.egg-info → pymscada-0.2.0rc4}/PKG-INFO +2 -2
  2. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/pyproject.toml +2 -2
  3. pymscada-0.2.0rc4/src/pymscada/alarms.py +358 -0
  4. pymscada-0.2.0rc4/src/pymscada/demo/alarms.yaml +5 -0
  5. pymscada-0.2.0rc4/src/pymscada/demo/pymscada-alarms.service +16 -0
  6. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/history.py +16 -15
  7. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/module_config.py +5 -1
  8. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/opnotes.py +13 -8
  9. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/tag.py +0 -40
  10. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/www_server.py +35 -13
  11. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4/src/pymscada.egg-info}/PKG-INFO +2 -2
  12. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada.egg-info/SOURCES.txt +4 -0
  13. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada.egg-info/requires.txt +1 -1
  14. pymscada-0.2.0rc4/tests/test_alarms.py +128 -0
  15. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/tests/test_opnotes.py +1 -2
  16. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/tests/test_periodic.py +2 -2
  17. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/LICENSE +0 -0
  18. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/MANIFEST.in +0 -0
  19. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/README.md +0 -0
  20. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/setup.cfg +0 -0
  21. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/__init__.py +0 -0
  22. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/__main__.py +0 -0
  23. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/bus_client.py +0 -0
  24. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/bus_server.py +0 -0
  25. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/checkout.py +0 -0
  26. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/config.py +0 -0
  27. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/console.py +0 -0
  28. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/README.md +0 -0
  29. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/__init__.py +0 -0
  30. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/__pycache__/__init__.cpython-311.pyc +0 -0
  31. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/accuweather.yaml +0 -0
  32. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/bus.yaml +0 -0
  33. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/files.yaml +0 -0
  34. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/history.yaml +0 -0
  35. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/logixclient.yaml +0 -0
  36. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/modbus_plc.py +0 -0
  37. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/modbusclient.yaml +0 -0
  38. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/modbusserver.yaml +0 -0
  39. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/openweather.yaml +0 -0
  40. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/opnotes.yaml +0 -0
  41. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/ping.yaml +0 -0
  42. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/pymscada-bus.service +0 -0
  43. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/pymscada-demo-modbus_plc.service +0 -0
  44. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/pymscada-files.service +0 -0
  45. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/pymscada-history.service +0 -0
  46. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/pymscada-io-logixclient.service +0 -0
  47. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/pymscada-io-modbusclient.service +0 -0
  48. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/pymscada-io-modbusserver.service +0 -0
  49. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/pymscada-io-openweather.service +0 -0
  50. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/pymscada-io-ping.service +0 -0
  51. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/pymscada-io-snmpclient.service +0 -0
  52. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/pymscada-opnotes.service +0 -0
  53. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/pymscada-wwwserver.service +0 -0
  54. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/snmpclient.yaml +0 -0
  55. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/tags.yaml +0 -0
  56. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/demo/wwwserver.yaml +0 -0
  57. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/files.py +0 -0
  58. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/iodrivers/__init__.py +0 -0
  59. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/iodrivers/accuweather.py +0 -0
  60. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/iodrivers/logix_client.py +0 -0
  61. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/iodrivers/logix_map.py +0 -0
  62. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/iodrivers/modbus_client.py +0 -0
  63. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/iodrivers/modbus_map.py +0 -0
  64. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/iodrivers/modbus_server.py +0 -0
  65. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/iodrivers/openweather.py +0 -0
  66. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/iodrivers/ping_client.py +0 -0
  67. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/iodrivers/ping_map.py +0 -0
  68. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/iodrivers/snmp_client.py +0 -0
  69. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/iodrivers/snmp_map.py +0 -0
  70. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/main.py +0 -0
  71. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/misc.py +0 -0
  72. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/pdf/__init__.py +0 -0
  73. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/pdf/__pycache__/__init__.cpython-311.pyc +0 -0
  74. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/pdf/one.pdf +0 -0
  75. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/pdf/two.pdf +0 -0
  76. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/periodic.py +0 -0
  77. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/protocol_constants.py +0 -0
  78. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/samplers.py +0 -0
  79. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/tools/snmp_client2.py +0 -0
  80. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/tools/walk.py +0 -0
  81. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada/validate.py +0 -0
  82. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada.egg-info/dependency_links.txt +0 -0
  83. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada.egg-info/entry_points.txt +0 -0
  84. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/src/pymscada.egg-info/top_level.txt +0 -0
  85. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/tests/test_bus_server.py +0 -0
  86. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/tests/test_config.py +0 -0
  87. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/tests/test_history.py +0 -0
  88. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/tests/test_misc.py +0 -0
  89. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/tests/test_openweather.py +0 -0
  90. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/tests/test_samplers.py +0 -0
  91. {pymscada-0.2.0rc2 → pymscada-0.2.0rc4}/tests/test_tag.py +0 -0
  92. {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.0rc2
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.0rc2
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.0rc2"
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.0rc2", # www_server
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,5 @@
1
+ bus_ip: 127.0.0.1
2
+ bus_port: 1324
3
+ rta_tag: __alarms__
4
+ db: __HOME__/config/pymscada-alarms.sqlite
5
+ table: alarms
@@ -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, TagInfo, TYPES
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
- class Request(TypedDict, total=False):
41
- """Type definition for request dictionary."""
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: TagInfo = {},
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
- tag_for_history(tagname, tag)
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
- try:
31
- socket.gethostbyname(bus_ip)
32
- except socket.gaierror as e:
33
- raise ValueError(f'Cannot resolve IP/hostname: {e}')
34
- if not isinstance(bus_port, int):
35
- raise TypeError('bus_port must be an integer')
36
- if not 1024 <= bus_port <= 65535:
37
- raise ValueError('bus_port must be between 1024 and 65535')
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, tag_for_web, TYPES
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
- webclient: dict):
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.webclient = webclient
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 webclient for {tag.name}')
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': 'webclient', 'payload': self.webclient}))
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 webclient."""
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
- webclient: dict = {},
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 webclient.
254
+ Connect to bus on bus_ip:bus_port, serve on ip:port for webserver.
233
255
 
234
- Serves the webclient files at /, as a relative path. The webclient uses
235
- a websocket connection to request and set tag values and subscribe to
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
- tag_for_web(tagname, tag)
288
+ standardise_tag_info(tagname, tag)
267
289
  self.tag_info = tag_info
268
290
  self.pages = pages
269
- self.webclient = webclient
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.webclient).connection_active()
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.0rc2
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.0rc2
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
@@ -1,6 +1,6 @@
1
1
  PyYAML>=6.0.1
2
2
  aiohttp>=3.8.5
3
- pymscada-html==0.2.0rc2
3
+ pymscada-html==0.2.0rc4
4
4
  cerberus>=1.3.5
5
5
  pycomm3>=1.2.14
6
6
  pysnmplib>=5.0.24
@@ -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, bus_port=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.41 seconds."""
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.21)
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