pymscada 0.2.0__py3-none-any.whl → 0.2.6b9__py3-none-any.whl

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.
Files changed (39) hide show
  1. pymscada/__init__.py +8 -2
  2. pymscada/alarms.py +179 -60
  3. pymscada/bus_client.py +12 -2
  4. pymscada/bus_server.py +18 -9
  5. pymscada/callout.py +198 -101
  6. pymscada/config.py +20 -1
  7. pymscada/console.py +19 -6
  8. pymscada/demo/__pycache__/__init__.cpython-311.pyc +0 -0
  9. pymscada/demo/callout.yaml +13 -4
  10. pymscada/demo/files.yaml +3 -2
  11. pymscada/demo/openweather.yaml +3 -11
  12. pymscada/demo/piapi.yaml +15 -0
  13. pymscada/demo/pymscada-io-piapi.service +15 -0
  14. pymscada/demo/pymscada-io-sms.service +18 -0
  15. pymscada/demo/sms.yaml +11 -0
  16. pymscada/demo/tags.yaml +3 -0
  17. pymscada/demo/witsapi.yaml +6 -8
  18. pymscada/demo/wwwserver.yaml +15 -0
  19. pymscada/files.py +1 -0
  20. pymscada/history.py +4 -5
  21. pymscada/iodrivers/logix_map.py +1 -1
  22. pymscada/iodrivers/modbus_client.py +189 -21
  23. pymscada/iodrivers/modbus_map.py +17 -2
  24. pymscada/iodrivers/piapi.py +133 -0
  25. pymscada/iodrivers/sms.py +212 -0
  26. pymscada/iodrivers/witsapi.py +26 -35
  27. pymscada/module_config.py +24 -18
  28. pymscada/opnotes.py +38 -16
  29. pymscada/pdf/__pycache__/__init__.cpython-311.pyc +0 -0
  30. pymscada/tag.py +6 -7
  31. pymscada/tools/get_history.py +147 -0
  32. pymscada/www_server.py +2 -1
  33. {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/METADATA +2 -2
  34. {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/RECORD +38 -32
  35. pymscada/validate.py +0 -451
  36. {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/WHEEL +0 -0
  37. {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/entry_points.txt +0 -0
  38. {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/licenses/LICENSE +0 -0
  39. {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/top_level.txt +0 -0
pymscada/callout.py CHANGED
@@ -25,41 +25,72 @@ Operation:
25
25
  """
26
26
 
27
27
  ALM = 0
28
+ RTN = 1
29
+
28
30
  IDLE = 0
29
31
  NEW_ALM = 1
30
32
  CALLOUT = 2
31
33
 
34
+ SENT = 0
35
+ REMIND = 1
32
36
 
33
- def normalise_callees(callees: list | None):
34
- """Normalise callees to include delay_ms and remove delay."""
35
- if callees is None:
36
- return []
37
- for callee in callees:
38
- if 'sms' not in callee:
39
- raise ValueError(f'Callee {callee["name"]} has no sms number')
40
- if 'delay' in callee:
41
- callee['delay_ms'] = callee['delay'] * 1000
42
- del callee['delay']
43
- else:
44
- callee['delay_ms'] = 0
45
- if 'group' not in callee:
46
- callee['group'] = []
47
- callees.sort(key=lambda x: x['delay_ms'])
48
- return callees
49
37
 
38
+ class CalloutCallee:
39
+ """Track status of callee for callout."""
50
40
 
51
- def alarm_in_callee_group(alarm_group: list, callee_group: list):
52
- """Check if alarm_group is in callee_group."""
53
- if not callee_group:
54
- if not alarm_group:
55
- return ''
56
- return alarm_group[0]
57
- if not alarm_group:
58
- return None
59
- for group in alarm_group:
60
- if group in callee_group:
61
- return group
62
- return None
41
+ def __init__(self, callee: dict):
42
+ if not isinstance(callee, dict) or 'name' not in callee or \
43
+ 'sms' not in callee:
44
+ logging.warning(f'Callee malformed {callee}')
45
+ return
46
+ self.name = callee['name']
47
+ self.sms = callee['sms']
48
+ self.role = callee.get('role', '')
49
+ self.group = callee.get('group', '')
50
+ self.delay_ms = 0
51
+
52
+ def set_role(self, role: str, delay_ms: int):
53
+ self.role = role
54
+ self.delay_ms = delay_ms
55
+
56
+ def set_group(self, group: str):
57
+ self.group = group
58
+
59
+
60
+ class CalloutAlarm:
61
+ """Track status of alarm for callout."""
62
+
63
+ def __init__(self, alm_tag_value):
64
+ if not isinstance(alm_tag_value, dict) or \
65
+ 'date_ms' not in alm_tag_value or \
66
+ 'alarm_string' not in alm_tag_value or \
67
+ 'desc' not in alm_tag_value or \
68
+ 'group' not in alm_tag_value or \
69
+ 'kind' not in alm_tag_value:
70
+ logging.warning(f'alarms_cb malformed {alm_tag_value}')
71
+ raise ValueError(f'alarms_cb malformed {alm_tag_value}')
72
+ self.date_ms = alm_tag_value['date_ms']
73
+ self.alarm_string = alm_tag_value['alarm_string']
74
+ self.desc = alm_tag_value['desc']
75
+ self.group = alm_tag_value['group']
76
+ self.sent: set[CalloutCallee] = set()
77
+ self.remind: set[CalloutCallee] = set()
78
+
79
+ def callee_in_group(self, callee: CalloutCallee, groups: dict):
80
+ if callee.group == '':
81
+ return True
82
+ if callee.group not in groups:
83
+ return False
84
+ group = groups[callee.group]
85
+ if 'tagnames' in group:
86
+ for tagname in group['tagnames']:
87
+ if self.alarm_string.startswith(tagname):
88
+ return True
89
+ if 'groups' in group:
90
+ for group in group['groups']:
91
+ if self.group in group:
92
+ return True
93
+ return False
63
94
 
64
95
 
65
96
  class Callout:
@@ -71,10 +102,13 @@ class Callout:
71
102
  bus_port: int | None = 1324,
72
103
  rta_tag: str = '__callout__',
73
104
  alarms_tag: str | None = None,
105
+ sms_send_tag: str | None = None,
106
+ sms_recv_tag: str | None = None,
74
107
  ack_tag: str | None = None,
75
108
  status_tag: str | None = None,
76
- callees: list | None = None,
77
- groups: list | None = None
109
+ callees: list = [],
110
+ groups: dict = {},
111
+ escalation: dict = {}
78
112
  ) -> None:
79
113
  """
80
114
  Connect to bus_ip:bus_port, monitor alarms and manage callouts.
@@ -99,106 +133,169 @@ class Callout:
99
133
  raise ValueError('bus_port must be between 1024 and 65535')
100
134
  if not isinstance(rta_tag, str) or not rta_tag:
101
135
  raise ValueError('rta_tag must be a non-empty string')
136
+ if alarms_tag is None:
137
+ raise ValueError('alarms_tag must be defined')
138
+ if sms_send_tag is None:
139
+ raise ValueError('sms_send_tag must be defined')
140
+ if sms_recv_tag is None:
141
+ raise ValueError('sms_recv_tag must be defined')
102
142
 
103
- logging.warning(f'Callout {bus_ip} {bus_port} {rta_tag}')
104
- self.alarms = []
105
- self.callees = normalise_callees(callees)
106
- self.groups = []
107
- if groups is not None:
108
- self.groups = groups
109
- self.status = None
143
+ logging.warning(f'Callout {bus_ip} {bus_port} {rta_tag} '
144
+ f'{sms_send_tag} {sms_recv_tag}')
145
+ self.callees: list[CalloutCallee] = []
146
+ for callee in callees:
147
+ self.callees.append(CalloutCallee(callee))
148
+ self.groups = groups
149
+ self.escalation = escalation
150
+ self.alarms: list[CalloutAlarm] = []
151
+ self.alarms_tag = Tag(alarms_tag, dict)
152
+ self.alarms_tag.add_callback(self.alarms_cb)
153
+ self.sms_recv_tag = Tag(sms_recv_tag, dict)
154
+ self.sms_recv_tag.add_callback(self.sms_recv_cb)
155
+ self.sms_send_tag = Tag(sms_send_tag, dict)
156
+ if ack_tag is not None:
157
+ self.ack_tag = Tag(ack_tag, int)
158
+ self.ack_tag.add_callback(self.ack_cb)
110
159
  if status_tag is not None:
111
160
  self.status = Tag(status_tag, int)
112
- self.status.value = IDLE
161
+ else:
162
+ self.status = None
113
163
  self.busclient = BusClient(bus_ip, bus_port, module='Callout')
114
- self.busclient.add_callback_rta(alarms_tag, self.alarms_cb)
115
- self.busclient.add_callback_rta(ack_tag, self.ack_cb)
116
164
  self.rta = Tag(rta_tag, dict)
117
- self.rta.value = {}
165
+ self.set_rta_value(rta_id=0)
118
166
  self.busclient.add_callback_rta(rta_tag, self.rta_cb)
119
167
  self.periodic = Periodic(self.periodic_cb, 1.0)
120
168
 
121
- def alarms_cb(self, request):
169
+ def set_rta_value(self, rta_id: int):
170
+ """Publish the current configuration to the RTA tag."""
171
+ callees = [{'name': callee.name, 'sms': callee.sms, 'role': callee.role,
172
+ 'group': callee.group, 'delay_ms': callee.delay_ms}
173
+ for callee in self.callees]
174
+ self.rta.value = {'__rta_id__': rta_id,
175
+ 'callees': callees,
176
+ 'groups': self.groups,
177
+ 'escalation': list(self.escalation.keys())}
178
+
179
+ def alarms_cb(self, alm_tag):
122
180
  """Handle alarm messages from alarms.py."""
123
- if request['kind'] != ALM:
181
+ if alm_tag.value['kind'] != ALM:
124
182
  return
125
- alarm = {
126
- 'date_ms': request['date_ms'],
127
- 'alarm_string': request['alarm_string'],
128
- 'desc': request['desc'],
129
- 'group': request['group'],
130
- 'sent': {}
131
- }
183
+ alarm = CalloutAlarm(alm_tag.value)
132
184
  self.alarms.append(alarm)
133
185
  logging.info(f'Added alarm to list: {alarm}')
186
+ if self.status is not None and self.status.value == IDLE:
187
+ self.status.value = NEW_ALM
134
188
 
135
- def ack_cb(self, ack: str):
189
+ def ack_cb(self, ack_tag):
136
190
  """Handle ACK requests for alarm acknowledgment."""
137
- if ack == '__all':
191
+ if ack_tag.value == 1:
138
192
  self.alarms = []
193
+ if self.status is not None:
194
+ self.status.value = IDLE
195
+ logging.info('ACK: all alarms cleared')
196
+
197
+ def sms_recv_cb(self, sms_recv_tag: Tag):
198
+ """Handle SMS messages from the modem."""
199
+ logging.info(f'sms_recv_cb {sms_recv_tag.value}')
200
+ if not isinstance(sms_recv_tag.value, dict) or \
201
+ 'number' not in sms_recv_tag.value or \
202
+ 'message' not in sms_recv_tag.value:
203
+ logging.warning(f'sms_recv_cb invalid {sms_recv_tag.value}')
139
204
  return
140
- callee = None
141
- for c in self.callees:
142
- if ack == c['sms']:
143
- callee = c
144
- break
145
- if ack == c['name']:
146
- callee = c
147
- break
148
- if callee is None:
149
- logging.warning(f'ACK rejected: {ack}')
150
- return
151
- logging.info(f'ACK accepted: {ack}')
152
- group = callee['group']
153
- remaining_alarms = []
154
- for alarm in self.alarms:
155
- alarm_group = alarm_in_callee_group(alarm['group'], group)
156
- if alarm_group is None:
157
- remaining_alarms.append(alarm)
158
- self.alarms = remaining_alarms
205
+ number = sms_recv_tag.value['number']
206
+ name = [callee.name for callee in self.callees if callee.sms == number][0]
207
+ message = sms_recv_tag.value['message'][:2].upper()
208
+ if message in ['OK', 'AC', 'TH']:
209
+ new_alarms = []
210
+ for alarm in self.alarms:
211
+ if name in alarm.sent:
212
+ continue
213
+ new_alarms.append(alarm)
214
+ self.alarms = new_alarms
215
+ if self.status is not None:
216
+ self.status.value = IDLE
217
+ logging.info('ACK: all alarms cleared')
159
218
 
160
219
  def rta_cb(self, request):
161
220
  """Handle RTA requests for callout configuration."""
162
221
  logging.info(f'rta_cb {request}')
163
222
  if 'action' not in request:
164
223
  logging.warning(f'rta_cb malformed {request}')
165
- elif request['action'] == 'ALL':
166
- self.rta.value = {'callees': self.callees, 'groups': self.groups}
224
+ return
225
+ if request['action'] == 'GET CONFIG':
226
+ self.set_rta_value(rta_id=request['__rta_id__'])
167
227
  elif request['action'] == 'MODIFY':
168
228
  for callee in self.callees:
169
- if callee['name'] == request['name']:
170
- if 'delay' in request:
171
- callee['delay_ms'] = request['delay'] * 1000
172
- if 'group' in request:
173
- callee['group'] = request['group']
174
- logging.info(f'Modified callee with {request}')
175
-
176
- def check_callouts(self):
177
- """Check alarms and send callouts. Can be called independently for testing."""
229
+ if callee.name == request['name']:
230
+ if not 'role' in request and not 'group' in request:
231
+ logging.warning(f'rta_cb invalid request: {request}')
232
+ return
233
+ role = request['role']
234
+ group = request['group']
235
+ valid_role = role == '' or role in self.escalation
236
+ valid_group = group == '' or group in self.groups
237
+ if not valid_role or not valid_group:
238
+ logging.warning(f'rta_cb MODIFY invalid: {request}')
239
+ return
240
+ callee.set_role(role, self.escalation.get(role, 0))
241
+ callee.set_group(group)
242
+ self.set_rta_value(rta_id=0)
243
+
244
+ def check_callee_messages(self, callee: CalloutCallee, time_ms):
245
+ if callee.role == '':
246
+ return ''
247
+ callee_alarms = set()
248
+ for alarm in self.alarms:
249
+ if alarm.callee_in_group(callee, self.groups):
250
+ callee_alarms.add(alarm)
251
+ notify_message = ''
252
+ remind_message = ''
253
+ notify_ms = time_ms - callee.delay_ms
254
+ remind_ms = notify_ms - 60000
255
+ notify = []
256
+ remind = []
257
+ for alarm in callee_alarms:
258
+ if not callee.name in alarm.sent and notify_ms > alarm.date_ms:
259
+ notify_message += f'{alarm.desc}\n'
260
+ alarm.sent.add(callee.name)
261
+ else:
262
+ notify.append(alarm)
263
+ if not callee.name in alarm.remind and remind_ms > alarm.date_ms:
264
+ remind_message += f'{alarm.desc}\n'
265
+ alarm.remind.add(callee.name)
266
+ else:
267
+ remind.append(alarm)
268
+ message = ''
269
+ if notify_message != '':
270
+ message += f'ALARMS\n{notify_message}'
271
+ for alarm in notify:
272
+ message += f'{alarm.desc}\n'
273
+ alarm.sent.add(callee.name)
274
+ if remind_message != '':
275
+ message += f'REMINDERS\n{remind_message}'
276
+ for alarm in remind:
277
+ message += f'{alarm.desc}\n'
278
+ alarm.remind.add(callee.name)
279
+ return message
280
+
281
+ def check_alarms(self):
282
+ """Check alarms for each callee."""
178
283
  time_ms = int(time.time() * 1000)
179
284
  for callee in self.callees:
180
- message = ''
181
- count = 0
182
- group = callee['group']
183
- notify_ms = time_ms - callee['delay_ms']
184
- for alarm in self.alarms:
185
- if alarm['date_ms'] < notify_ms:
186
- alarm_group = alarm_in_callee_group(alarm['group'], group)
187
- if alarm_group is not None and callee['name'] not in alarm['sent']:
188
- count += 1
189
- message += f"{alarm['alarm_string']}\n"
190
- alarm['sent'][callee['name']] = time_ms
191
- if count > 0:
192
- send_message = f"{alarm_group} {count} unack alarms\n{message}"
193
- logging.warning(f'Callout to {callee["name"]}: {send_message}')
194
- self.rta.value = {'action': 'SMS', 'sms': callee['sms'],
195
- 'message': send_message}
196
- if self.status is not None:
197
- self.status.value = CALLOUT
285
+ message = self.check_callee_messages(callee, time_ms)
286
+ if message == '':
287
+ continue
288
+ logging.info(f'Sending message to {callee.name}: {message}')
289
+ self.sms_send_tag.value = {
290
+ 'number': callee.sms,
291
+ 'message': message
292
+ }
293
+ if self.status is not None:
294
+ self.status.value = CALLOUT
198
295
 
199
296
  async def periodic_cb(self):
200
297
  """Periodic callback to check alarms and send callouts."""
201
- self.check_callouts()
298
+ self.check_alarms()
202
299
 
203
300
  async def start(self):
204
301
  """Async startup."""
pymscada/config.py CHANGED
@@ -2,6 +2,8 @@
2
2
  import importlib.resources
3
3
  from importlib.abc import Traversable
4
4
  import logging
5
+ import os
6
+ import re
5
7
  from pathlib import Path
6
8
  from yaml import safe_load_all, YAMLError
7
9
  from pymscada import demo, pdf
@@ -38,6 +40,21 @@ def get_pdf_files() -> list[Traversable]:
38
40
  return files
39
41
 
40
42
 
43
+ def _expand_env_vars(value):
44
+ """Recursively expand environment variables in config values."""
45
+ if isinstance(value, str):
46
+ pattern = re.compile(r'\$\{([^}]+)\}')
47
+ def replace_env(match):
48
+ env_var = match.group(1)
49
+ return os.environ.get(env_var, match.group(0))
50
+ return pattern.sub(replace_env, value)
51
+ elif isinstance(value, dict):
52
+ return {k: _expand_env_vars(v) for k, v in value.items()}
53
+ elif isinstance(value, list):
54
+ return [_expand_env_vars(item) for item in value]
55
+ return value
56
+
57
+
41
58
  class Config(dict):
42
59
  """Read config from yaml file."""
43
60
 
@@ -55,7 +72,9 @@ class Config(dict):
55
72
  with open(fp, 'r') as fh:
56
73
  try:
57
74
  for data in safe_load_all(fh):
75
+ if '__vars__' in data:
76
+ del data['__vars__']
58
77
  for x in data:
59
- self[x] = data[x]
78
+ self[x] = _expand_env_vars(data[x])
60
79
  except YAMLError as e:
61
80
  raise SystemExit(f'failed to load {filename} {e}')
pymscada/console.py CHANGED
@@ -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:
@@ -2,8 +2,15 @@ bus_ip: 127.0.0.1
2
2
  bus_port: 1324
3
3
  rta_tag: __callout__
4
4
  alarms_tag: __alarms__
5
+ sms_send_tag: __sms_send__
6
+ sms_recv_tag: __sms_recv__
5
7
  ack_tag: SI_Alarm_Ack
6
8
  status_tag: SO_Alarm_Status
9
+ escalation:
10
+ - OnCall: 60000
11
+ - Backup 1: 180000
12
+ - Backup 2: 300000
13
+ - Escalate: 600000
7
14
  callees:
8
15
  - name: A name
9
16
  sms: A number
@@ -11,7 +18,9 @@ callees:
11
18
  - name: B name
12
19
  sms: B number
13
20
  groups:
14
- - name: Aniwhenua Station
15
- group:
16
- - name: Aniwhenua System
17
- group: System
21
+ Wind:
22
+ tagnames: [Mt, Fh]
23
+ System:
24
+ groups: [__system__, Comms]
25
+ Test:
26
+ tagnames: [alarm]
pymscada/demo/files.yaml CHANGED
@@ -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,7 +2,8 @@ bus_ip: 127.0.0.1
2
2
  bus_port: 1324
3
3
  proxy:
4
4
  api:
5
- api_key: ${OPENWEATHERMAP_API_KEY}
5
+ # tagnames are assigned by location_parameter, i.e. Murupara_Temp
6
+ api_key: ${MSCADA_OPENWEATHERMAP_API_KEY}
6
7
  units: metric
7
8
  locations:
8
9
  Murupara:
@@ -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
@@ -0,0 +1,15 @@
1
+ bus_ip: 127.0.0.1
2
+ bus_port: 1324
3
+ proxy:
4
+ api:
5
+ url: ${MSCADA_PI_URL}
6
+ webid: ${MSCADA_PI_WEBID}
7
+ averaging: 300
8
+ tags:
9
+ I_An_G1_MW:
10
+ pitag: AnG1.AIMw
11
+ I_An_G2_MW:
12
+ pitag: AnG2.AIMw
13
+ I_Ko_Gn_MW:
14
+ pitag: KoGn.AIkW
15
+ scale: 1000
@@ -0,0 +1,15 @@
1
+ [Unit]
2
+ Description=pymscada - OSI PI client
3
+ BindsTo=pymscada-bus.service
4
+ After=pymscada-bus.service
5
+
6
+ [Service]
7
+ WorkingDirectory=__DIR__
8
+ ExecStart=__PYMSCADA__ witsapiclient --config __DIR__/config/piapi.yaml
9
+ Restart=always
10
+ RestartSec=5
11
+ User=__USER__
12
+ Group=__USER__
13
+
14
+ [Install]
15
+ WantedBy=multi-user.target
@@ -0,0 +1,18 @@
1
+ [Unit]
2
+ Description=pymscada - SMS client
3
+ BindsTo=pymscada-bus.service
4
+ After=pymscada-bus.service
5
+
6
+ [Service]
7
+ Environment="MSCADA_SMS_IP=an_ip_address"
8
+ Environment="MSCADA_SMS_USERNAME=a_user"
9
+ Environment="MSCADA_SMS_PASSWORD=a_password"
10
+ WorkingDirectory=__DIR__
11
+ ExecStart=__PYMSCADA__ sms --config __DIR__/config/sms.yaml
12
+ Restart=always
13
+ RestartSec=5
14
+ User=root
15
+ Group=root
16
+
17
+ [Install]
18
+ WantedBy=multi-user.target
pymscada/demo/sms.yaml ADDED
@@ -0,0 +1,11 @@
1
+ bus_ip: 127.0.0.1
2
+ bus_port: 1324
3
+ sms_send_tag: __sms_send__
4
+ sms_recv_tag: __sms_recv__
5
+ modem: rut241
6
+ modem_ip: ${MSCADA_SMS_IP}
7
+ username: ${MSCADA_SMS_USERNAME}
8
+ password: ${MSCADA_SMS_PASSWORD}
9
+ listen_port: 8080
10
+ info:
11
+ __default__: info
pymscada/demo/tags.yaml CHANGED
@@ -13,6 +13,9 @@ __opnotes__:
13
13
  __alarms__:
14
14
  desc: Alarms
15
15
  type: dict
16
+ info:
17
+ desc: Info
18
+ type: str
16
19
  IntSet:
17
20
  desc: Integer Setpoint
18
21
  type: int
@@ -2,16 +2,14 @@ bus_ip: 127.0.0.1
2
2
  bus_port: 1324
3
3
  proxy:
4
4
  api:
5
- url: 'https://api.electricityinfo.co.nz'
6
- client_id: ${WITS_CLIENT_ID}
7
- client_secret: ${WITS_CLIENT_SECRET}
5
+ # tagnames are assigned by gxp_list
6
+ # i.e. MAT1101, MAT1101_Realtime, MAT1101_Forecast
7
+ url: ${MSCADA_WITS_URL}
8
+ client_id: ${MSCADA_WITS_CLIENT_ID}
9
+ client_secret: ${MSCADA_WITS_CLIENT_SECRET}
8
10
  gxp_list:
9
11
  - MAT1101
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
@@ -1,9 +1,20 @@
1
+ __vars__:
2
+ - &primary_color darkmagenta
3
+ - &secondary_color green
1
4
  bus_ip: 127.0.0.1
2
5
  bus_port: 1324
3
6
  ip: 0.0.0.0
4
7
  port: 8324
5
8
  get_path:
6
9
  serve_path: __HOME__
10
+ config:
11
+ primary_color: *primary_color
12
+ secondary_color: *secondary_color
13
+ mscada_link: http://127.0.0.1/
14
+ people:
15
+ - name: John Smith
16
+ email: john.smith@example.com
17
+ phone: '+64not_a_number'
7
18
  pages:
8
19
  - name: Notes
9
20
  parent:
@@ -78,6 +89,10 @@ pages:
78
89
  scale: mS
79
90
  color: red
80
91
  dp: 1
92
+ bands:
93
+ - series: [localhost_ping, google_ping]
94
+ fill: [green, 0.3]
95
+ dir: 1
81
96
  - name: Weather
82
97
  parent:
83
98
  items:
pymscada/files.py CHANGED
@@ -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."""