pymscada 0.2.0rc3__tar.gz → 0.2.0rc6__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 (97) hide show
  1. {pymscada-0.2.0rc3/src/pymscada.egg-info → pymscada-0.2.0rc6}/PKG-INFO +4 -3
  2. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/pyproject.toml +2 -2
  3. pymscada-0.2.0rc6/src/pymscada/alarms.py +354 -0
  4. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/console.py +4 -3
  5. pymscada-0.2.0rc6/src/pymscada/demo/wits.yaml +17 -0
  6. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/history.py +16 -15
  7. pymscada-0.2.0rc6/src/pymscada/iodrivers/witsapi.py +217 -0
  8. pymscada-0.2.0rc6/src/pymscada/iodrivers/witsapi_POC.py +246 -0
  9. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/module_config.py +12 -0
  10. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/tag.py +0 -40
  11. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/www_server.py +35 -13
  12. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6/src/pymscada.egg-info}/PKG-INFO +4 -3
  13. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada.egg-info/SOURCES.txt +3 -1
  14. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada.egg-info/requires.txt +1 -1
  15. pymscada-0.2.0rc6/tests/test_alarms.py +198 -0
  16. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/tests/test_history.py +55 -0
  17. pymscada-0.2.0rc3/src/pymscada/alarms.py +0 -194
  18. pymscada-0.2.0rc3/tests/test_alarms.py +0 -125
  19. pymscada-0.2.0rc3/tests/test_openweather.py +0 -46
  20. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/LICENSE +0 -0
  21. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/MANIFEST.in +0 -0
  22. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/README.md +0 -0
  23. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/setup.cfg +0 -0
  24. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/__init__.py +0 -0
  25. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/__main__.py +0 -0
  26. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/bus_client.py +0 -0
  27. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/bus_server.py +0 -0
  28. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/checkout.py +0 -0
  29. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/config.py +0 -0
  30. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/demo/README.md +0 -0
  31. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/demo/__init__.py +0 -0
  32. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/demo/__pycache__/__init__.cpython-311.pyc +0 -0
  33. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/demo/accuweather.yaml +0 -0
  34. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/demo/alarms.yaml +0 -0
  35. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/demo/bus.yaml +0 -0
  36. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/demo/files.yaml +0 -0
  37. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/demo/history.yaml +0 -0
  38. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/demo/logixclient.yaml +0 -0
  39. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/demo/modbus_plc.py +0 -0
  40. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/demo/modbusclient.yaml +0 -0
  41. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/demo/modbusserver.yaml +0 -0
  42. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/demo/openweather.yaml +0 -0
  43. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/demo/opnotes.yaml +0 -0
  44. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/demo/ping.yaml +0 -0
  45. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/demo/pymscada-alarms.service +0 -0
  46. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/demo/pymscada-bus.service +0 -0
  47. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/demo/pymscada-demo-modbus_plc.service +0 -0
  48. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/demo/pymscada-files.service +0 -0
  49. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/demo/pymscada-history.service +0 -0
  50. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/demo/pymscada-io-logixclient.service +0 -0
  51. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/demo/pymscada-io-modbusclient.service +0 -0
  52. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/demo/pymscada-io-modbusserver.service +0 -0
  53. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/demo/pymscada-io-openweather.service +0 -0
  54. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/demo/pymscada-io-ping.service +0 -0
  55. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/demo/pymscada-io-snmpclient.service +0 -0
  56. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/demo/pymscada-opnotes.service +0 -0
  57. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/demo/pymscada-wwwserver.service +0 -0
  58. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/demo/snmpclient.yaml +0 -0
  59. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/demo/tags.yaml +0 -0
  60. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/demo/wwwserver.yaml +0 -0
  61. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/files.py +0 -0
  62. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/iodrivers/__init__.py +0 -0
  63. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/iodrivers/accuweather.py +0 -0
  64. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/iodrivers/logix_client.py +0 -0
  65. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/iodrivers/logix_map.py +0 -0
  66. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/iodrivers/modbus_client.py +0 -0
  67. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/iodrivers/modbus_map.py +0 -0
  68. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/iodrivers/modbus_server.py +0 -0
  69. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/iodrivers/openweather.py +0 -0
  70. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/iodrivers/ping_client.py +0 -0
  71. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/iodrivers/ping_map.py +0 -0
  72. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/iodrivers/snmp_client.py +0 -0
  73. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/iodrivers/snmp_map.py +0 -0
  74. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/main.py +0 -0
  75. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/misc.py +0 -0
  76. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/opnotes.py +0 -0
  77. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/pdf/__init__.py +0 -0
  78. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/pdf/__pycache__/__init__.cpython-311.pyc +0 -0
  79. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/pdf/one.pdf +0 -0
  80. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/pdf/two.pdf +0 -0
  81. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/periodic.py +0 -0
  82. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/protocol_constants.py +0 -0
  83. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/samplers.py +0 -0
  84. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/tools/snmp_client2.py +0 -0
  85. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/tools/walk.py +0 -0
  86. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada/validate.py +0 -0
  87. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada.egg-info/dependency_links.txt +0 -0
  88. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada.egg-info/entry_points.txt +0 -0
  89. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/src/pymscada.egg-info/top_level.txt +0 -0
  90. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/tests/test_bus_server.py +0 -0
  91. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/tests/test_config.py +0 -0
  92. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/tests/test_misc.py +0 -0
  93. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/tests/test_opnotes.py +0 -0
  94. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/tests/test_periodic.py +0 -0
  95. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/tests/test_samplers.py +0 -0
  96. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/tests/test_tag.py +0 -0
  97. {pymscada-0.2.0rc3 → pymscada-0.2.0rc6}/tests/test_validate.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: pymscada
3
- Version: 0.2.0rc3
3
+ Version: 0.2.0rc6
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,10 +17,11 @@ 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
24
+ Dynamic: license-file
24
25
 
25
26
  # pymscada
26
27
  #### [Docs](https://github.com/jamie0walton/pymscada/blob/main/docs/README.md)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pymscada"
3
- version = "0.2.0rc3"
3
+ version = "0.2.0rc6"
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,354 @@
1
+ """Alarms handling."""
2
+ import logging
3
+ import sqlite3 # note that sqlite3 has blocking calls
4
+ import socket
5
+ import time
6
+ from pymscada.bus_client import BusClient
7
+ from pymscada.periodic import Periodic
8
+ from pymscada.tag import Tag, TYPES
9
+
10
+ ALM = 0
11
+ RTN = 1
12
+ ACT = 2
13
+ INF = 3
14
+
15
+ KIND = {
16
+ ALM: 'ALM',
17
+ RTN: 'RTN',
18
+ ACT: 'ACT',
19
+ INF: 'INF'
20
+ }
21
+
22
+ NORMAL = 0
23
+ ALARM = 1
24
+
25
+ """
26
+ Database schema:
27
+
28
+ alarms contains an event log of changes as they occur, this
29
+ includes information on actions taken by the alarm system.
30
+
31
+ CREATE TABLE IF NOT EXISTS alarms (
32
+ id INTEGER PRIMARY KEY ASC,
33
+ date_ms INTEGER,
34
+ alarm_string TEXT,
35
+ kind INTEGER, # one of ALM, RTN, ACT, INF
36
+ desc TEXT,
37
+ group TEXT
38
+ )
39
+ """
40
+
41
+
42
+ def standardise_tag_info(tagname: str, tag: dict):
43
+ """Correct tag dictionary in place to be suitable for modules."""
44
+ tag['name'] = tagname
45
+ tag['id'] = None
46
+ if 'desc' not in tag:
47
+ logging.warning(f"Tag {tagname} has no description, using name")
48
+ tag['desc'] = tag['name']
49
+ if 'area' not in tag:
50
+ tag['area'] = ''
51
+ if 'multi' in tag:
52
+ tag['type'] = int
53
+ else:
54
+ if 'type' not in tag:
55
+ tag['type'] = float
56
+ else:
57
+ if tag['type'] not in TYPES:
58
+ tag['type'] = str
59
+ else:
60
+ tag['type'] = TYPES[tag['type']]
61
+ if 'dp' not in tag:
62
+ if tag['type'] == int:
63
+ tag['dp'] = 0
64
+ else:
65
+ tag['dp'] = 2
66
+ if 'units' not in tag:
67
+ tag['units'] = ''
68
+ if 'alarm' in tag:
69
+ if isinstance(tag['alarm'], str):
70
+ tag['alarm'] = [tag['alarm']]
71
+ if not isinstance(tag['alarm'], list):
72
+ logging.warning(f"Tag {tagname} has invalid alarm {tag['alarm']}")
73
+ del tag['alarm']
74
+
75
+
76
+ def split_operator(alarm: str) -> dict:
77
+ """Split alarm string into operator and value."""
78
+ tokens = alarm.split(' ')
79
+ alm_dict = {'for': 0}
80
+ if len(tokens) not in (2, 4):
81
+ raise ValueError(f"Invalid alarm {alarm}")
82
+ if tokens[0] not in ['>', '<', '==', '>=', '<=']:
83
+ raise ValueError(f"Invalid alarm {alarm}")
84
+ alm_dict['operator'] = tokens[0]
85
+ try:
86
+ alm_dict['value'] = float(tokens[1])
87
+ except ValueError:
88
+ raise ValueError(f"Invalid alarm {alarm}")
89
+ if len(tokens) == 4:
90
+ if tokens[2] != 'for':
91
+ raise ValueError(f"Invalid alarm {alarm}")
92
+ try:
93
+ alm_dict['for'] = int(tokens[3])
94
+ except ValueError:
95
+ raise ValueError(f"Invalid alarm {alarm}")
96
+ return alm_dict
97
+
98
+
99
+ class Alarm():
100
+ """
101
+ Single alarm class.
102
+
103
+ Alarms are defined by a tag and a condition. Tags may have multiple
104
+ conditions, each combination of tag and condition is a separate Alarm.
105
+
106
+ Monitors tag value through the Tag callback. Tracks in alarm state.
107
+ Generates the ALM and RTN messages for Alarms to publish via rta_tag.
108
+ """
109
+
110
+ def __init__(self, tagname: str, tag: dict, alarm: str, area: str, rta_cb, alarms) -> None:
111
+ """Initialize alarm with tag and condition(s)."""
112
+ self.alarm_id = f'{tagname} {alarm}'
113
+ self.tag = Tag(tagname, tag['type'])
114
+ self.tag.desc = tag['desc']
115
+ self.tag.dp = tag['dp']
116
+ self.tag.units = tag['units']
117
+ self.tag.add_callback(self.callback)
118
+ self.area = area
119
+ self.rta_cb = rta_cb
120
+ self.alarms = alarms
121
+ self.alarm = split_operator(alarm)
122
+ self.in_alarm = False
123
+ self.checking = False
124
+
125
+ def callback(self, tag: Tag):
126
+ """Handle tag value changes and generate ALM/RTN messages."""
127
+ if tag.value is None:
128
+ return
129
+ value = float(tag.value)
130
+ time_us = tag.time_us
131
+ new_in_alarm = False
132
+ op = self.alarm['operator']
133
+ if op == '>':
134
+ new_in_alarm = value > self.alarm['value']
135
+ elif op == '<':
136
+ new_in_alarm = value < self.alarm['value']
137
+ elif op == '==':
138
+ new_in_alarm = value == self.alarm['value']
139
+ elif op == '>=':
140
+ new_in_alarm = value >= self.alarm['value']
141
+ elif op == '<=':
142
+ new_in_alarm = value <= self.alarm['value']
143
+ if new_in_alarm == self.in_alarm:
144
+ return
145
+ self.in_alarm = new_in_alarm
146
+ if self.in_alarm:
147
+ if self.alarm['for'] > 0:
148
+ if not self.checking:
149
+ self.checking = True
150
+ self.alarms.checking_alarms.append(self)
151
+ else:
152
+ self.generate_alarm(ALM, time_us, value)
153
+ else:
154
+ if self.checking:
155
+ self.checking = False
156
+ self.alarms.checking_alarms.remove(self)
157
+ self.generate_alarm(RTN, time_us, value)
158
+
159
+ def generate_alarm(self, kind: int, time_us: int, value: float):
160
+ """Generate alarm message."""
161
+ logging.warning(f'Alarm {self.alarm_id} {value} {KIND[kind]}')
162
+ self.rta_cb({
163
+ 'action': 'ADD',
164
+ 'date_ms': int(time_us / 1000),
165
+ 'alarm_string': self.alarm_id,
166
+ 'kind': kind,
167
+ 'desc': f'{self.tag.desc} {value:.{self.tag.dp}f}'
168
+ f' {self.tag.units}',
169
+ 'group': self.area
170
+ })
171
+
172
+ def check_duration(self, current_time_us: int):
173
+ """Check if alarm condition has been met for required duration."""
174
+ if current_time_us - self.tag.time_us >= self.alarm['for'] * 1000000:
175
+ self.generate_alarm(ALM, current_time_us, self.tag.value)
176
+ self.checking = False
177
+ self.alarms.checking_alarms.remove(self)
178
+
179
+
180
+ class Alarms:
181
+ """Connect to bus_ip:bus_port, store and provide Alarms."""
182
+
183
+ def __init__(
184
+ self,
185
+ bus_ip: str | None = '127.0.0.1',
186
+ bus_port: int | None = 1324,
187
+ db: str | None = None,
188
+ table: str = 'alarms',
189
+ tag_info: dict[str, dict] = {},
190
+ rta_tag: str = '__alarms__'
191
+ ) -> None:
192
+ """
193
+ Connect to bus_ip:bus_port, serve and update alarms database.
194
+
195
+ Open an Alarms table, creating if necessary. Provide additions
196
+ and history requests via the rta_tag.
197
+
198
+ Event loop must be running.
199
+
200
+ For testing only: bus_ip can be None to skip connection.
201
+ """
202
+ if db is None:
203
+ raise SystemExit('Alarms db must be defined')
204
+ if bus_ip is None:
205
+ logging.warning('Alarms has bus_ip=None, only use for testing')
206
+ else:
207
+ try:
208
+ socket.gethostbyname(bus_ip)
209
+ except socket.gaierror as e:
210
+ raise ValueError(f'Cannot resolve IP/hostname: {e}')
211
+ if not isinstance(bus_port, int):
212
+ raise TypeError('bus_port must be an integer')
213
+ if not 1024 <= bus_port <= 65535:
214
+ raise ValueError('bus_port must be between 1024 and 65535')
215
+ if not isinstance(rta_tag, str) or not rta_tag:
216
+ raise ValueError('rta_tag must be a non-empty string')
217
+ if not isinstance(table, str) or not table:
218
+ raise ValueError('table must be a non-empty string')
219
+
220
+ logging.warning(f'Alarms {bus_ip} {bus_port} {db} {rta_tag}')
221
+ self.alarms: list[Alarm] = []
222
+ self.checking_alarms: list[Alarm] = []
223
+ for tagname, tag in tag_info.items():
224
+ standardise_tag_info(tagname, tag)
225
+ if 'alarm' not in tag or tag['type'] not in (int, float):
226
+ continue
227
+ area = tag['area']
228
+ for alarm in tag['alarm']:
229
+ new_alarm = Alarm(tagname, tag, alarm, area, self.rta_cb, self)
230
+ self.alarms.append(new_alarm)
231
+ self.busclient = BusClient(bus_ip, bus_port, module='Alarms')
232
+ self.rta = Tag(rta_tag, dict)
233
+ self.rta.value = {}
234
+ self.busclient.add_callback_rta(rta_tag, self.rta_cb)
235
+ self._init_db(db, table)
236
+ self.periodic = Periodic(self.periodic_cb, 1.0)
237
+
238
+ def _init_db(self, db, table):
239
+ """Initialize the database table schema."""
240
+ self.connection = sqlite3.connect(db)
241
+ self.table = table
242
+ self.cursor = self.connection.cursor()
243
+ query = (
244
+ 'CREATE TABLE IF NOT EXISTS ' + self.table + ' '
245
+ '(id INTEGER PRIMARY KEY ASC, '
246
+ 'date_ms INTEGER, '
247
+ 'alarm_string TEXT, '
248
+ 'kind INTEGER, '
249
+ 'desc TEXT, '
250
+ '"group" TEXT)'
251
+ )
252
+ self.cursor.execute(query)
253
+ self.connection.commit()
254
+
255
+ startup_record = {
256
+ 'action': 'ADD',
257
+ 'date_ms': int(time.time() * 1000),
258
+ 'alarm_string': self.rta.name,
259
+ 'kind': INF,
260
+ 'desc': 'Alarm logging started',
261
+ 'group': '__system__'
262
+ }
263
+ self.rta_cb(startup_record)
264
+
265
+ async def periodic_cb(self):
266
+ """Periodic callback to check alarms."""
267
+ current_time_us = int(time.time() * 1000000)
268
+ for alarm in self.checking_alarms[:]:
269
+ alarm.check_duration(current_time_us)
270
+
271
+ def rta_cb(self, request):
272
+ """Respond to Request to Author and publish on rta_tag as needed."""
273
+ if 'action' not in request:
274
+ logging.warning(f'rta_cb malformed {request}')
275
+ elif request['action'] == 'ADD':
276
+ try:
277
+ logging.info(f'add {request}')
278
+ with self.connection:
279
+ self.cursor.execute(
280
+ f'INSERT INTO {self.table} '
281
+ '(date_ms, alarm_string, kind, desc, "group") '
282
+ 'VALUES(:date_ms, :alarm_string, :kind, :desc, :group) '
283
+ 'RETURNING *;',
284
+ request)
285
+ res = self.cursor.fetchone()
286
+ self.rta.value = {
287
+ 'id': res[0],
288
+ 'date_ms': res[1],
289
+ 'alarm_string': res[2],
290
+ 'kind': res[3],
291
+ 'desc': res[4],
292
+ 'group': res[5]
293
+ }
294
+ except sqlite3.IntegrityError as error:
295
+ logging.warning(f'Alarms rta_cb {error}')
296
+ elif request['action'] == 'UPDATE':
297
+ try:
298
+ logging.info(f'update {request}')
299
+ with self.connection:
300
+ self.cursor.execute(
301
+ f'UPDATE {self.table} SET in_alm = :in_alm '
302
+ 'WHERE id = :id RETURNING *;',
303
+ request)
304
+ res = self.cursor.fetchone()
305
+ if res:
306
+ self.rta.value = {
307
+ 'id': res[0],
308
+ 'date_ms': res[1],
309
+ 'alarm_string': res[2],
310
+ 'kind': res[3],
311
+ 'desc': res[4],
312
+ 'group': res[5]
313
+ }
314
+ except sqlite3.IntegrityError as error:
315
+ logging.warning(f'Alarms rta_cb update {error}')
316
+ elif request['action'] == 'HISTORY':
317
+ try:
318
+ logging.info(f'history {request}')
319
+ with self.connection:
320
+ self.cursor.execute(
321
+ f'SELECT * FROM {self.table} WHERE date_ms > :date_ms '
322
+ 'ORDER BY (date_ms - :date_ms);', request)
323
+ for res in self.cursor.fetchall():
324
+ self.rta.value = {
325
+ '__rta_id__': request['__rta_id__'],
326
+ 'id': res[0],
327
+ 'date_ms': res[1],
328
+ 'alarm_string': res[2],
329
+ 'kind': res[3],
330
+ 'desc': res[4],
331
+ 'group': res[5]
332
+ }
333
+ except sqlite3.IntegrityError as error:
334
+ logging.warning(f'Alarms rta_cb {error}')
335
+ elif request['action'] == 'BULK HISTORY':
336
+ try:
337
+ logging.info(f'bulk history {request}')
338
+ with self.connection:
339
+ self.cursor.execute(
340
+ f'SELECT * FROM {self.table} WHERE date_ms > :date_ms '
341
+ 'ORDER BY -date_ms;', request)
342
+ results = list(self.cursor.fetchall())
343
+ self.rta.value = {'__rta_id__': request['__rta_id__'],
344
+ 'data': results}
345
+ except sqlite3.IntegrityError as error:
346
+ logging.warning(f'Alarms rta_cb {error}')
347
+ elif request['action'] == 'IN ALARM':
348
+ self.rta.value = {'__rta_id__': request['__rta_id__'],
349
+ 'data': {'in_alarm': list(self.in_alarm)}}
350
+
351
+ async def start(self):
352
+ """Async startup."""
353
+ await self.busclient.start()
354
+ await self.periodic.start()
@@ -3,7 +3,8 @@ 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, TagInfo
6
+ from pymscada.tag import Tag
7
+ from pymscada.www_server import standardise_tag_info
7
8
  try:
8
9
  import termios
9
10
  import tty
@@ -153,7 +154,7 @@ class Console:
153
154
  """Provide a text console to interact with a Bus."""
154
155
 
155
156
  def __init__(self, bus_ip: str = '127.0.0.1', bus_port: int = 1324,
156
- tag_info: dict[str, TagInfo] = {}) -> None:
157
+ tag_info: dict = {}) -> None:
157
158
  """
158
159
  Connect to bus_ip:bus_port and provide console interaction with a Bus.
159
160
 
@@ -177,7 +178,7 @@ class Console:
177
178
  self.busclient = BusClient(bus_ip, bus_port, module='Console')
178
179
  self.tags: dict[str, Tag] = {}
179
180
  for tagname, tag in tag_info.items():
180
- tag_for_web(tagname, tag)
181
+ standardise_tag_info(tagname, tag)
181
182
  self.tags[tagname] = Tag(tagname, tag['type'])
182
183
 
183
184
  def write_tag(self, tag: Tag):
@@ -0,0 +1,17 @@
1
+ bus_ip: 127.0.0.1
2
+ bus_port: 1324
3
+ proxy:
4
+ api:
5
+ url: 'https://api.electricityinfo.co.nz'
6
+ client_id: ${WITS_CLIENT_ID}
7
+ client_secret: ${WITS_CLIENT_SECRET}
8
+ gxp_list:
9
+ - MAT1101
10
+ - CYD2201
11
+ - BEN2201
12
+ back: 1
13
+ forward: 12
14
+ tags:
15
+ - MAT1101_RTD
16
+ - CYD2201_RTD
17
+ - BEN2201_RTD
@@ -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(