pymscada 0.2.5__tar.gz → 0.2.6b1__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 (105) hide show
  1. {pymscada-0.2.5/src/pymscada.egg-info → pymscada-0.2.6b1}/PKG-INFO +1 -1
  2. {pymscada-0.2.5 → pymscada-0.2.6b1}/pyproject.toml +1 -1
  3. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/__init__.py +2 -0
  4. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/alarms.py +102 -37
  5. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/bus_client.py +7 -1
  6. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/bus_server.py +7 -1
  7. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/callout.py +1 -0
  8. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/console.py +19 -6
  9. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/files.yaml +3 -2
  10. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/openweather.yaml +2 -10
  11. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/piapi.yaml +2 -2
  12. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/witsapi.yaml +4 -6
  13. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/files.py +1 -0
  14. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/history.py +1 -0
  15. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/opnotes.py +16 -8
  16. {pymscada-0.2.5 → pymscada-0.2.6b1/src/pymscada.egg-info}/PKG-INFO +1 -1
  17. {pymscada-0.2.5 → pymscada-0.2.6b1}/tests/test_callout.py +1 -1
  18. {pymscada-0.2.5 → pymscada-0.2.6b1}/LICENSE +0 -0
  19. {pymscada-0.2.5 → pymscada-0.2.6b1}/MANIFEST.in +0 -0
  20. {pymscada-0.2.5 → pymscada-0.2.6b1}/README.md +0 -0
  21. {pymscada-0.2.5 → pymscada-0.2.6b1}/setup.cfg +0 -0
  22. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/__main__.py +0 -0
  23. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/checkout.py +0 -0
  24. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/config.py +0 -0
  25. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/README.md +0 -0
  26. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/__init__.py +0 -0
  27. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/__pycache__/__init__.cpython-311.pyc +0 -0
  28. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/accuweather.yaml +0 -0
  29. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/alarms.yaml +0 -0
  30. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/bus.yaml +0 -0
  31. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/callout.yaml +0 -0
  32. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/history.yaml +0 -0
  33. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/logixclient.yaml +0 -0
  34. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/modbus_plc.py +0 -0
  35. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/modbusclient.yaml +0 -0
  36. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/modbusserver.yaml +0 -0
  37. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/opnotes.yaml +0 -0
  38. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/ping.yaml +0 -0
  39. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/pymscada-alarms.service +0 -0
  40. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/pymscada-bus.service +0 -0
  41. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/pymscada-callout.service +0 -0
  42. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/pymscada-demo-modbus_plc.service +0 -0
  43. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/pymscada-files.service +0 -0
  44. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/pymscada-history.service +0 -0
  45. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/pymscada-io-logixclient.service +0 -0
  46. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/pymscada-io-modbusclient.service +0 -0
  47. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/pymscada-io-modbusserver.service +0 -0
  48. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/pymscada-io-openweather.service +0 -0
  49. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/pymscada-io-piapi.service +0 -0
  50. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/pymscada-io-ping.service +0 -0
  51. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/pymscada-io-sms.service +0 -0
  52. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/pymscada-io-snmpclient.service +0 -0
  53. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/pymscada-io-witsapi.service +0 -0
  54. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/pymscada-opnotes.service +0 -0
  55. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/pymscada-wwwserver.service +0 -0
  56. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/sms.yaml +0 -0
  57. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/snmpclient.yaml +0 -0
  58. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/tags.yaml +0 -0
  59. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/demo/wwwserver.yaml +0 -0
  60. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/iodrivers/__init__.py +0 -0
  61. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/iodrivers/accuweather.py +0 -0
  62. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/iodrivers/logix_client.py +0 -0
  63. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/iodrivers/logix_map.py +0 -0
  64. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/iodrivers/modbus_client.py +0 -0
  65. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/iodrivers/modbus_map.py +0 -0
  66. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/iodrivers/modbus_server.py +0 -0
  67. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/iodrivers/openweather.py +0 -0
  68. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/iodrivers/piapi.py +0 -0
  69. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/iodrivers/ping_client.py +0 -0
  70. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/iodrivers/ping_map.py +0 -0
  71. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/iodrivers/sms.py +0 -0
  72. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/iodrivers/snmp_client.py +0 -0
  73. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/iodrivers/snmp_map.py +0 -0
  74. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/iodrivers/witsapi.py +0 -0
  75. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/iodrivers/witsapi_POC.py +0 -0
  76. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/main.py +0 -0
  77. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/misc.py +0 -0
  78. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/module_config.py +0 -0
  79. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/pdf/__init__.py +0 -0
  80. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/pdf/__pycache__/__init__.cpython-311.pyc +0 -0
  81. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/pdf/one.pdf +0 -0
  82. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/pdf/two.pdf +0 -0
  83. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/periodic.py +0 -0
  84. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/protocol_constants.py +0 -0
  85. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/samplers.py +0 -0
  86. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/tag.py +0 -0
  87. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/tools/get_history.py +0 -0
  88. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/tools/snmp_client2.py +0 -0
  89. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/tools/walk.py +0 -0
  90. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada/www_server.py +0 -0
  91. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada.egg-info/SOURCES.txt +0 -0
  92. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada.egg-info/dependency_links.txt +0 -0
  93. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada.egg-info/entry_points.txt +0 -0
  94. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada.egg-info/requires.txt +0 -0
  95. {pymscada-0.2.5 → pymscada-0.2.6b1}/src/pymscada.egg-info/top_level.txt +0 -0
  96. {pymscada-0.2.5 → pymscada-0.2.6b1}/tests/test_alarms.py +0 -0
  97. {pymscada-0.2.5 → pymscada-0.2.6b1}/tests/test_bus_server.py +0 -0
  98. {pymscada-0.2.5 → pymscada-0.2.6b1}/tests/test_config.py +0 -0
  99. {pymscada-0.2.5 → pymscada-0.2.6b1}/tests/test_history.py +0 -0
  100. {pymscada-0.2.5 → pymscada-0.2.6b1}/tests/test_misc.py +0 -0
  101. {pymscada-0.2.5 → pymscada-0.2.6b1}/tests/test_opnotes.py +0 -0
  102. {pymscada-0.2.5 → pymscada-0.2.6b1}/tests/test_periodic.py +0 -0
  103. {pymscada-0.2.5 → pymscada-0.2.6b1}/tests/test_samplers.py +0 -0
  104. {pymscada-0.2.5 → pymscada-0.2.6b1}/tests/test_sms.py +0 -0
  105. {pymscada-0.2.5 → pymscada-0.2.6b1}/tests/test_tag.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pymscada
3
- Version: 0.2.5
3
+ Version: 0.2.6b1
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pymscada"
3
- version = "0.2.5"
3
+ version = "0.2.6b1"
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"},
@@ -2,6 +2,7 @@
2
2
  from pymscada.bus_client import BusClient
3
3
  from pymscada.bus_server import BusServer
4
4
  from pymscada.config import Config
5
+ from pymscada.callout import Callout, alarm_in_group, ALM
5
6
  from pymscada.iodrivers.accuweather import AccuWeatherClient
6
7
  from pymscada.iodrivers.logix_client import LogixClient
7
8
  from pymscada.iodrivers.modbus_client import ModbusClient
@@ -17,6 +18,7 @@ __all__ = [
17
18
  'BusClient',
18
19
  'BusServer',
19
20
  'Config',
21
+ 'Callout', 'alarm_in_group', ALM,
20
22
  'AccuWeatherClient',
21
23
  'LogixClient',
22
24
  'ModbusClient',
@@ -11,6 +11,7 @@ ALM = 0
11
11
  RTN = 1
12
12
  ACT = 2
13
13
  INF = 3
14
+ TIMING = 4
14
15
  KIND = {
15
16
  ALM: 'ALM',
16
17
  RTN: 'RTN',
@@ -75,12 +76,11 @@ def standardise_tag_info(tagname: str, tag: dict):
75
76
  def split_operator(alarm: str) -> dict:
76
77
  """Split alarm string into operator and value."""
77
78
  tokens = alarm.split(' ')
78
- alm_dict = {'for': 0}
79
79
  if len(tokens) not in (2, 4):
80
80
  raise ValueError(f"Invalid alarm {alarm}")
81
81
  if tokens[0] not in ['>', '<', '==', '>=', '<=']:
82
82
  raise ValueError(f"Invalid alarm {alarm}")
83
- alm_dict['operator'] = tokens[0]
83
+ alm_dict = {'for': 0, 'operator': tokens[0], 'value': None}
84
84
  try:
85
85
  alm_dict['value'] = float(tokens[1])
86
86
  except ValueError:
@@ -103,11 +103,11 @@ class Alarm():
103
103
  conditions, each combination of tag and condition is a separate Alarm.
104
104
 
105
105
  Monitors tag value through the Tag callback. Tracks in alarm state.
106
- Generates the ALM and RTN messages for Alarms to publish via rta_tag.
106
+ Notifies Alarms of state changes via state callback.
107
107
  """
108
108
 
109
109
  def __init__(self, tagname: str, tag: dict, alarm: str, group: str,
110
- rta_cb, alarms) -> None:
110
+ state_cb) -> None:
111
111
  """Initialize alarm with tag and condition(s)."""
112
112
  self.alarm_id = f'{tagname} {alarm}'
113
113
  self.tag = Tag(tagname, tag['type'])
@@ -116,18 +116,16 @@ class Alarm():
116
116
  self.tag.units = tag['units']
117
117
  self.tag.add_callback(self.callback)
118
118
  self.group = group
119
- self.rta_cb = rta_cb
120
- self.alarms = alarms
119
+ self.state_cb = state_cb
121
120
  self.alarm = split_operator(alarm)
122
121
  self.in_alarm = False
123
- self.checking = False
122
+ self.disabled_until = 0
124
123
 
125
124
  def callback(self, tag: Tag):
126
125
  """Handle tag value changes and generate ALM/RTN messages."""
127
- if tag.value is None:
126
+ if tag.value is None or tag.time_us < self.disabled_until:
128
127
  return
129
128
  value = float(tag.value)
130
- time_us = tag.time_us
131
129
  new_in_alarm = False
132
130
  op = self.alarm['operator']
133
131
  if op == '>':
@@ -142,39 +140,19 @@ class Alarm():
142
140
  new_in_alarm = value <= self.alarm['value']
143
141
  if new_in_alarm == self.in_alarm:
144
142
  return
145
- self.in_alarm = new_in_alarm
146
- if self.in_alarm:
143
+ if new_in_alarm:
147
144
  if self.alarm['for'] > 0:
148
- if not self.checking:
149
- self.checking = True
150
- self.alarms.checking_alarms.append(self)
145
+ self.state_cb(self, TIMING)
151
146
  else:
152
- self.generate_alarm(ALM, time_us, value)
147
+ self.state_cb(self, ALM)
153
148
  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.group
170
- })
149
+ self.state_cb(self, RTN)
150
+ self.in_alarm = new_in_alarm
171
151
 
172
152
  def check_duration(self, current_time_us: int):
173
153
  """Check if alarm condition has been met for required duration."""
174
154
  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)
155
+ self.state_cb(self, ALM)
178
156
 
179
157
 
180
158
  class Alarms:
@@ -220,19 +198,20 @@ class Alarms:
220
198
  logging.warning(f'Alarms {bus_ip} {bus_port} {db} {rta_tag}')
221
199
  self.alarms: list[Alarm] = []
222
200
  self.checking_alarms: list[Alarm] = []
201
+ self.in_alarm: list[Alarm] = []
223
202
  for tagname, tag in tag_info.items():
224
203
  standardise_tag_info(tagname, tag)
225
204
  if 'alarm' not in tag or tag['type'] not in (int, float):
226
205
  continue
227
206
  group = tag['group']
228
207
  for alarm in tag['alarm']:
229
- new_alarm = Alarm(tagname, tag, alarm, group, self.rta_cb,
230
- self)
208
+ new_alarm = Alarm(tagname, tag, alarm, group, self.state_cb)
231
209
  self.alarms.append(new_alarm)
232
210
  self.busclient = BusClient(bus_ip, bus_port, module='Alarms')
233
211
  self.rta = Tag(rta_tag, dict)
234
212
  self.rta.value = {'__rta_id__': 0}
235
213
  self.busclient.add_callback_rta(rta_tag, self.rta_cb)
214
+ self.busclient.add_tag(self.rta)
236
215
  self._init_db(db, table)
237
216
  self.periodic = Periodic(self.periodic_cb, 1.0)
238
217
 
@@ -280,6 +259,37 @@ class Alarms:
280
259
  for alarm in self.checking_alarms[:]:
281
260
  alarm.check_duration(current_time_us)
282
261
 
262
+ def state_cb(self, alarm: Alarm, state: int):
263
+ """Handle alarm state changes."""
264
+ if state == TIMING:
265
+ self.checking_alarms.append(alarm)
266
+ elif state == ALM:
267
+ if alarm in self.checking_alarms:
268
+ self.checking_alarms.remove(alarm)
269
+ self.in_alarm.append(alarm)
270
+ self.generate_alarm(alarm, ALM)
271
+ elif state == RTN:
272
+ if alarm in self.checking_alarms:
273
+ self.checking_alarms.remove(alarm)
274
+ if alarm in self.in_alarm:
275
+ self.in_alarm.remove(alarm)
276
+ self.generate_alarm(alarm, RTN)
277
+
278
+ def generate_alarm(self, alarm: Alarm, kind: int):
279
+ """Generate alarm message."""
280
+ value = alarm.tag.value
281
+ time_us = alarm.tag.time_us
282
+ logging.warning(f'Alarm {alarm.alarm_id} {value} {KIND[kind]}')
283
+ self.rta_cb({
284
+ 'action': 'ADD',
285
+ 'date_ms': int(time_us / 1000),
286
+ 'alarm_string': alarm.alarm_id,
287
+ 'kind': kind,
288
+ 'desc': f'{alarm.tag.desc} {value:.{alarm.tag.dp}f}'
289
+ f' {alarm.tag.units}',
290
+ 'group': alarm.group
291
+ })
292
+
283
293
  def rta_cb(self, request):
284
294
  """Respond to Request to Author and publish on rta_tag as needed."""
285
295
  if 'action' not in request:
@@ -394,6 +404,61 @@ class Alarms:
394
404
  elif request['action'] == 'IN ALARM':
395
405
  self.rta.value = {'__rta_id__': request['__rta_id__'],
396
406
  'data': {'in_alarm': list(self.in_alarm)}}
407
+ elif request['action'] == 'ENABLE':
408
+ time_us = int(time.time() * 1000000)
409
+ local_time = time.localtime(time_us / 1000000)
410
+ for alarm in self.alarms:
411
+ if alarm.alarm_id == request['alarm id']:
412
+ enable = request['enable']
413
+ if enable == 'Enable':
414
+ disabled_until_us = 0
415
+ else:
416
+ if enable == 'Disable until 8am':
417
+ target_hour = 8
418
+ target_day_offset = 0
419
+ next_offset = 1
420
+ elif enable == 'Disable until 4pm':
421
+ target_hour = 16
422
+ target_day_offset = 0
423
+ next_offset = 1
424
+ elif enable == 'Disable until Monday 8am':
425
+ target_hour = 8
426
+ target_day_offset = (0 - local_time.tm_wday) % 7
427
+ next_offset = 7
428
+ else:
429
+ disabled_until_us = 0
430
+ break
431
+ target_s = time.mktime((
432
+ local_time.tm_year, local_time.tm_mon,
433
+ local_time.tm_mday + target_day_offset,
434
+ target_hour, 0, 0, 0, 0, -1
435
+ ))
436
+ if target_s * 1000000 <= time_us:
437
+ target_s = time.mktime((
438
+ local_time.tm_year, local_time.tm_mon,
439
+ local_time.tm_mday + next_offset,
440
+ target_hour, 0, 0, 0, 0, -1
441
+ ))
442
+ disabled_until_us = int(target_s * 1000000)
443
+ alarm.disabled_until = disabled_until_us
444
+ ts = time.strftime(
445
+ "%Y-%m-%d %H:%M:%S",
446
+ time.localtime(disabled_until_us / 1000000)
447
+ )
448
+ if disabled_until_us == 0:
449
+ desc = 'Enable'
450
+ else:
451
+ desc = f'Disable until {ts}'
452
+ self.rta_cb({
453
+ 'action': 'ADD',
454
+ 'date_ms': int(time_us / 1000),
455
+ 'alarm_string': alarm.alarm_id,
456
+ 'kind': ACT,
457
+ 'desc': desc,
458
+ 'group': alarm.group
459
+ })
460
+ break
461
+
397
462
 
398
463
  async def start(self):
399
464
  """Async startup."""
@@ -77,6 +77,9 @@ class BusClient:
77
77
  jsonstr = json.dumps(request).encode()
78
78
  size = len(jsonstr)
79
79
  data = struct.pack(f'>B{size}s', pc.TYPE.JSON, jsonstr)
80
+ action = request.get("action", "unknown")
81
+ tag_id = self.tag_by_name[tagname].id
82
+ logging.info(f'{self.module}: RTA sending {tagname} {action} to tag_id {tag_id}')
80
83
  self.write(pc.COMMAND.RTA, self.tag_by_name[tagname].id, time_us, data)
81
84
 
82
85
  def write(self, command: pc.COMMAND, tag_id: int, time_us: int,
@@ -209,10 +212,13 @@ class BusClient:
209
212
  data = struct.unpack_from(f'!{len(value) - 1}s', value, offset=1
210
213
  )[0].decode()
211
214
  data = json.loads(data)
215
+ action = data.get("action", "unknown")
216
+ logging.info(f'{self.module}: RTA received {tag.name} {action} '
217
+ f'from tag_id {tag_id}')
212
218
  try:
213
219
  self.rta_handlers[tag.name](data)
214
220
  except KeyError:
215
- logging.warning(f'unhandled RTA for {tag.name} {data}')
221
+ logging.warning(f'{self.module}: unhandled RTA for {tag.name} {data}')
216
222
  else:
217
223
  raise SystemExit(f'Invalid message {cmd}')
218
224
 
@@ -201,15 +201,21 @@ class BusServer:
201
201
  try:
202
202
  tag = BusTags._tag_by_id[tag_id]
203
203
  except KeyError:
204
+ logging.warning(f'RTA KeyError {tag_id}')
204
205
  self.connections[bus_id].write(
205
206
  pc.COMMAND.ERR, tag_id, time_us,
206
207
  f"RTA KeyError {tag_id}".encode())
208
+ return
207
209
  try:
210
+ logging.info(f'RTA forwarding {tag.name} from_bus={tag.from_bus} '
211
+ f'to bus_id={tag.from_bus}')
208
212
  self.connections[tag.from_bus].write(
209
213
  pc.COMMAND.RTA, tag_id, tag.time_us, data)
210
214
  except KeyError:
211
- logging.warning(f'likely busclient for {tag.name} is gone')
215
+ logging.warning(f'RTA forwarding failed: busclient for '
216
+ f'{tag.name} (from_bus={tag.from_bus}) is gone')
212
217
  except Exception as e:
218
+ logging.warning(f'RTA forwarding error {tag.name}: {e}')
213
219
  self.connections[bus_id].write(
214
220
  pc.COMMAND.ERR, tag_id, time_us,
215
221
  f"RTA {tag_id} {e}".encode())
@@ -145,6 +145,7 @@ class Callout:
145
145
  'groups': self.groups,
146
146
  'escalation': self.escalation}
147
147
  self.busclient.add_callback_rta(rta_tag, self.rta_cb)
148
+ self.busclient.add_tag(self.rta)
148
149
  self.periodic = Periodic(self.periodic_cb, 1.0)
149
150
 
150
151
  def alarms_cb(self, alm_tag):
@@ -1,6 +1,7 @@
1
1
  """Interactive console."""
2
2
  import asyncio
3
3
  import logging
4
+ import os
4
5
  import sys
5
6
  from pymscada.bus_client import BusClient
6
7
  from pymscada.tag import Tag
@@ -123,6 +124,10 @@ class ConsoleWriter:
123
124
  """Init."""
124
125
  self.edit = None
125
126
  self.cursor = 0
127
+ # Make stdout non-blocking to avoid BlockingIOError in async context
128
+ import fcntl
129
+ flags = fcntl.fcntl(sys.stdout.fileno(), fcntl.F_GETFL)
130
+ fcntl.fcntl(sys.stdout.fileno(), fcntl.F_SETFL, flags | os.O_NONBLOCK)
126
131
 
127
132
  def write(self, data: bytes):
128
133
  """Stream writer, primarily for logging."""
@@ -132,22 +137,30 @@ class ConsoleWriter:
132
137
  ln = EC.cr_clr + data + b'\r\n'
133
138
  if self.edit is not None:
134
139
  ln += EC.cr_clr + self.edit + EC.mv_left + cursor_str
135
- sys.stdout.buffer.write(ln)
136
- sys.stdout.flush()
140
+ try:
141
+ os.write(sys.stdout.fileno(), ln)
142
+ except BlockingIOError:
143
+ # If stdout buffer is full, skip this write to avoid blocking
144
+ pass
137
145
 
138
146
  def edit_line(self, edit: bytes, cursor: int):
139
147
  """Update the edit line and cursor position."""
140
148
  self.edit = edit
141
149
  if self.edit is None:
142
- sys.stdout.buffer.write(b'\r\n')
143
- sys.stdout.flush()
150
+ try:
151
+ os.write(sys.stdout.fileno(), b'\r\n')
152
+ except BlockingIOError:
153
+ pass
144
154
  return
145
155
  self.cursor = cursor
146
156
  ln = EC.cr_clr + self.edit + EC.mv_left
147
157
  if self.cursor > 0:
148
158
  ln += b'\x1b[' + str(self.cursor).encode() + b'C'
149
- sys.stdout.buffer.write(ln)
150
- sys.stdout.flush()
159
+ try:
160
+ os.write(sys.stdout.fileno(), ln)
161
+ except BlockingIOError:
162
+ # If stdout buffer is full, skip this write to avoid blocking
163
+ pass
151
164
 
152
165
 
153
166
  class Console:
@@ -3,8 +3,9 @@ bus_port: 1324
3
3
  path: __HOME__
4
4
  files:
5
5
  - path: pdf/one.pdf
6
- desc: PDF
6
+ desc: One PDF
7
7
  - path: pdf/two.pdf
8
- desc: PDF
8
+ desc: Two PDF
9
+ - path: pdf/*.pdf
9
10
  - path: config/*.yaml
10
11
  - path: history/*.dat
@@ -2,6 +2,7 @@ bus_ip: 127.0.0.1
2
2
  bus_port: 1324
3
3
  proxy:
4
4
  api:
5
+ # tagnames are assigned by location_parameter, i.e. Murupara_Temp
5
6
  api_key: ${MSCADA_OPENWEATHERMAP_API_KEY}
6
7
  units: metric
7
8
  locations:
@@ -13,13 +14,4 @@ api:
13
14
  - Temp
14
15
  - WindSpeed
15
16
  - WindDir
16
- - Rain
17
- tags:
18
- - Murupara_Temp
19
- - Murupara_WindSpeed
20
- - Murupara_WindDir
21
- - Murupara_Rain
22
- - Murupara_Temp_03
23
- - Murupara_WindSpeed_03
24
- - Murupara_WindDir_03
25
- - Murupara_Rain_03
17
+ - Rain
@@ -2,8 +2,8 @@ bus_ip: 127.0.0.1
2
2
  bus_port: 1324
3
3
  proxy:
4
4
  api:
5
- url: 'https://192.168.15.1/'
6
- webid: F1Em9DA80Xftdkec1gdWFtX7NAm1eiSAyV8BG1mAAMKQIjRQUEdQSVxQSU9ORUVSXFdFQkFQSVxNT0JJTEVTQ0FEQQ
5
+ url: ${MSCADA_PI_URL}
6
+ webid: ${MSCADA_PI_WEBID}
7
7
  averaging: 300
8
8
  tags:
9
9
  I_An_G1_MW:
@@ -2,7 +2,9 @@ bus_ip: 127.0.0.1
2
2
  bus_port: 1324
3
3
  proxy:
4
4
  api:
5
- url: 'https://api.electricityinfo.co.nz'
5
+ # tagnames are assigned by gxp_list
6
+ # i.e. MAT1101, MAT1101_Realtime, MAT1101_Forecast
7
+ url: ${MSCADA_WITS_URL}
6
8
  client_id: ${MSCADA_WITS_CLIENT_ID}
7
9
  client_secret: ${MSCADA_WITS_CLIENT_SECRET}
8
10
  gxp_list:
@@ -10,8 +12,4 @@ api:
10
12
  - CYD2201
11
13
  - BEN2201
12
14
  back: 1
13
- forward: 12
14
- tags:
15
- - MAT1101_RTD
16
- - CYD2201_RTD
17
- - BEN2201_RTD
15
+ forward: 12
@@ -33,6 +33,7 @@ class Files():
33
33
  self.rta = Tag(rta_tag, dict)
34
34
  self.rta.value = {}
35
35
  self.busclient.add_callback_rta(rta_tag, self.rta_cb)
36
+ self.busclient.add_tag(self.rta)
36
37
 
37
38
  def rta_cb(self, request):
38
39
  """Respond to Request to Author and publish on rta_tag as needed."""
@@ -272,6 +272,7 @@ class History():
272
272
  self.rta = Tag(rta_tag, bytes)
273
273
  self.rta.value = b'\x00\x00\x00\x00\x00\x00' # rta_id is 0
274
274
  self.busclient.add_callback_rta(rta_tag, self.rta_cb)
275
+ self.busclient.add_tag(self.rta)
275
276
 
276
277
  def rta_cb(self, request: Request):
277
278
  """Respond to bus requests for data to publish on rta."""
@@ -2,6 +2,7 @@
2
2
  import logging
3
3
  import sqlite3 # note that sqlite3 has blocking calls
4
4
  import socket
5
+ from datetime import datetime
5
6
  from pymscada.bus_client import BusClient
6
7
  from pymscada.tag import Tag
7
8
 
@@ -54,6 +55,7 @@ class OpNotes:
54
55
  self.rta = Tag(rta_tag, dict)
55
56
  self.rta.value = {'__rta_id__': 0}
56
57
  self.busclient.add_callback_rta(rta_tag, self.rta_cb)
58
+ self.busclient.add_tag(self.rta)
57
59
 
58
60
  def _init_table(self):
59
61
  """Initialize or upgrade the database table schema."""
@@ -101,11 +103,14 @@ class OpNotes:
101
103
 
102
104
  def rta_cb(self, request):
103
105
  """Respond to Request to Author and publish on rta_tag as needed."""
106
+ local_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
107
+ logging.info(f'[{local_time}] RTA callback received: {request}')
108
+
104
109
  if 'action' not in request:
105
110
  logging.warning(f'rta_cb malformed {request}')
106
111
  elif request['action'] == 'ADD':
107
112
  try:
108
- logging.info(f'add {request}')
113
+ logging.info(f'[{local_time}] ADD request: {request}')
109
114
  with self.connection:
110
115
  self.cursor.execute(
111
116
  f'INSERT INTO {self.table} '
@@ -114,7 +119,7 @@ class OpNotes:
114
119
  'RETURNING *;',
115
120
  request)
116
121
  res = self.cursor.fetchone()
117
- self.rta.value = {
122
+ response = {
118
123
  '__rta_id__': 0,
119
124
  'id': res[0],
120
125
  'date_ms': res[1],
@@ -123,11 +128,13 @@ class OpNotes:
123
128
  'note': res[4],
124
129
  'abnormal': res[5]
125
130
  }
131
+ self.rta.value = response
132
+ logging.info(f'[{local_time}] ADD response sent: {response}')
126
133
  except sqlite3.IntegrityError as error:
127
134
  logging.warning(f'OpNotes rta_cb {error}')
128
135
  elif request['action'] == 'MODIFY':
129
136
  try:
130
- logging.info(f'modify {request}')
137
+ logging.info(f'[{local_time}] MODIFY request: {request}')
131
138
  with self.connection:
132
139
  self.cursor.execute(
133
140
  f'REPLACE INTO {self.table} VALUES(:id, :date_ms, '
@@ -146,7 +153,7 @@ class OpNotes:
146
153
  logging.warning(f'OpNotes rta_cb {error}')
147
154
  elif request['action'] == 'DELETE':
148
155
  try:
149
- logging.info(f'delete {request}')
156
+ logging.info(f'[{local_time}] DELETE request: {request}')
150
157
  with self.connection:
151
158
  self.cursor.execute(
152
159
  f'DELETE FROM {self.table} WHERE id = :id;', request)
@@ -155,7 +162,7 @@ class OpNotes:
155
162
  logging.warning(f'OpNotes rta_cb {error}')
156
163
  elif request['action'] == 'HISTORY':
157
164
  try:
158
- logging.info(f'history {request}')
165
+ logging.info(f'[{local_time}] HISTORY request: {request}')
159
166
  with self.connection:
160
167
  self.cursor.execute(
161
168
  f'SELECT * FROM {self.table} WHERE date_ms > :date_ms '
@@ -174,14 +181,15 @@ class OpNotes:
174
181
  logging.warning(f'OpNotes rta_cb {error}')
175
182
  elif request['action'] == 'BULK HISTORY':
176
183
  try:
177
- logging.info(f'bulk history {request}')
184
+ logging.info(f'[{local_time}] BULK HISTORY request: {request}')
178
185
  with self.connection:
179
186
  self.cursor.execute(
180
187
  f'SELECT * FROM {self.table} WHERE date_ms > :date_ms '
181
188
  'ORDER BY -date_ms;', request)
182
189
  results = list(self.cursor.fetchall())
183
- self.rta.value = {'__rta_id__': request['__rta_id__'],
184
- 'data': results}
190
+ response = {'__rta_id__': request['__rta_id__'], 'data': results}
191
+ self.rta.value = response
192
+ logging.info(f'[{local_time}] BULK HISTORY response sent: {len(results)} records for rta_id {request["__rta_id__"]}')
185
193
  except sqlite3.IntegrityError as error:
186
194
  logging.warning(f'OpNotes rta_cb {error}')
187
195
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pymscada
3
- Version: 0.2.5
3
+ Version: 0.2.6b1
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
@@ -1,6 +1,6 @@
1
1
  """Test Callout."""
2
2
  import pytest
3
- from pymscada.callout import Callout, alarm_in_callee_group, ALM
3
+ from pymscada.callout import Callout, alarm_in_group, ALM
4
4
 
5
5
 
6
6
  BUS_ID = 123
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes