pymscada 0.2.1__py3-none-any.whl → 0.2.2__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.

Potentially problematic release.


This version of pymscada might be problematic. Click here for more details.

pymscada/__init__.py CHANGED
@@ -6,10 +6,12 @@ from pymscada.iodrivers.accuweather import AccuWeatherClient
6
6
  from pymscada.iodrivers.logix_client import LogixClient
7
7
  from pymscada.iodrivers.modbus_client import ModbusClient
8
8
  from pymscada.iodrivers.modbus_server import ModbusServer
9
+ from pymscada.iodrivers.piapi import PIWebAPIClient
10
+ from pymscada.iodrivers.sms import SMS
11
+ from pymscada.iodrivers.witsapi import WitsAPIClient
9
12
  from pymscada.misc import find_nodes, ramp
10
13
  from pymscada.periodic import Periodic
11
14
  from pymscada.tag import Tag
12
- from pymscada.validate import validate
13
15
 
14
16
  __all__ = [
15
17
  'BusClient',
@@ -19,8 +21,10 @@ __all__ = [
19
21
  'LogixClient',
20
22
  'ModbusClient',
21
23
  'ModbusServer',
24
+ 'PIWebAPIClient',
25
+ 'SMS',
26
+ 'WitsAPIClient',
22
27
  'find_nodes', 'ramp',
23
28
  'Periodic',
24
29
  'Tag',
25
- 'validate',
26
30
  ]
pymscada/alarms.py CHANGED
@@ -17,6 +17,7 @@ KIND = {
17
17
  ACT: 'ACT',
18
18
  INF: 'INF'
19
19
  }
20
+
20
21
  NORMAL = 0
21
22
  ALARM = 1
22
23
 
@@ -105,7 +106,8 @@ class Alarm():
105
106
  Generates the ALM and RTN messages for Alarms to publish via rta_tag.
106
107
  """
107
108
 
108
- def __init__(self, tagname: str, tag: dict, alarm: str, group: str, rta_cb, alarms) -> None:
109
+ def __init__(self, tagname: str, tag: dict, alarm: str, group: str,
110
+ rta_cb, alarms) -> None:
109
111
  """Initialize alarm with tag and condition(s)."""
110
112
  self.alarm_id = f'{tagname} {alarm}'
111
113
  self.tag = Tag(tagname, tag['type'])
@@ -229,7 +231,7 @@ class Alarms:
229
231
  self.alarms.append(new_alarm)
230
232
  self.busclient = BusClient(bus_ip, bus_port, module='Alarms')
231
233
  self.rta = Tag(rta_tag, dict)
232
- self.rta.value = {}
234
+ self.rta.value = {'__rta_id__': 0}
233
235
  self.busclient.add_callback_rta(rta_tag, self.rta_cb)
234
236
  self._init_db(db, table)
235
237
  self.periodic = Periodic(self.periodic_cb, 1.0)
@@ -283,6 +285,7 @@ class Alarms:
283
285
  request)
284
286
  res = self.cursor.fetchone()
285
287
  self.rta.value = {
288
+ '__rta_id__': 0,
286
289
  'id': res[0],
287
290
  'date_ms': res[1],
288
291
  'alarm_string': res[2],
@@ -303,6 +306,7 @@ class Alarms:
303
306
  res = self.cursor.fetchone()
304
307
  if res:
305
308
  self.rta.value = {
309
+ '__rta_id__': 0,
306
310
  'id': res[0],
307
311
  'date_ms': res[1],
308
312
  'alarm_string': res[2],
pymscada/callout.py CHANGED
@@ -25,41 +25,52 @@ Operation:
25
25
  """
26
26
 
27
27
  ALM = 0
28
+
28
29
  IDLE = 0
29
30
  NEW_ALM = 1
30
31
  CALLOUT = 2
31
32
 
33
+ SENT = 0
34
+ REMIND = 1
35
+
32
36
 
33
- def normalise_callees(callees: list | None):
34
- """Normalise callees to include delay_ms and remove delay."""
37
+ def normalise_callees(callees: list | None, escalation: dict):
38
+ """Normalise callees to include delay_ms and remove role."""
35
39
  if callees is None:
36
40
  return []
37
41
  for callee in callees:
38
42
  if 'sms' not in callee:
39
43
  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']
44
+ if 'role' in callee:
45
+ if callee['role'] not in escalation:
46
+ raise ValueError(f'Invalid role: {callee["role"]}. Must be'
47
+ f' one of: {list(escalation.keys())}')
48
+ callee['delay_ms'] = escalation[callee['role']]
43
49
  else:
44
50
  callee['delay_ms'] = 0
51
+ callee['role'] = ''
45
52
  if 'group' not in callee:
46
- callee['group'] = []
53
+ callee['group'] = ''
47
54
  callees.sort(key=lambda x: x['delay_ms'])
48
55
  return callees
49
56
 
50
57
 
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
58
+ def alarm_in_group(alarm, callee_group, groups):
59
+ """Check if alarm_group matches callee_group."""
60
+ if callee_group == '':
61
+ return True
62
+ if callee_group not in groups:
63
+ return False
64
+ group = groups[callee_group]
65
+ if 'tagnames' in group:
66
+ for tagname in group['tagnames']:
67
+ if alarm['alarm_string'].startswith(tagname):
68
+ return True
69
+ if 'groups' in group:
70
+ for group in group['groups']:
71
+ if group in alarm['group']:
72
+ return True
73
+ return False
63
74
 
64
75
 
65
76
  class Callout:
@@ -71,10 +82,13 @@ class Callout:
71
82
  bus_port: int | None = 1324,
72
83
  rta_tag: str = '__callout__',
73
84
  alarms_tag: str | None = None,
85
+ sms_send_tag: str | None = None,
86
+ sms_recv_tag: str | None = None,
74
87
  ack_tag: str | None = None,
75
88
  status_tag: str | None = None,
76
89
  callees: list | None = None,
77
- groups: list | None = None
90
+ groups: dict = {},
91
+ escalation: list | None = None
78
92
  ) -> None:
79
93
  """
80
94
  Connect to bus_ip:bus_port, monitor alarms and manage callouts.
@@ -99,100 +113,139 @@ class Callout:
99
113
  raise ValueError('bus_port must be between 1024 and 65535')
100
114
  if not isinstance(rta_tag, str) or not rta_tag:
101
115
  raise ValueError('rta_tag must be a non-empty string')
116
+ if alarms_tag is None:
117
+ raise ValueError('alarms_tag must be defined')
118
+ if sms_send_tag is None:
119
+ raise ValueError('sms_send_tag must be defined')
120
+ if sms_recv_tag is None:
121
+ raise ValueError('sms_recv_tag must be defined')
102
122
 
103
- logging.warning(f'Callout {bus_ip} {bus_port} {rta_tag}')
123
+ self.escalation = [list(d.keys())[0] for d in escalation]
124
+ self.delay_ms = {list(d.keys())[0]: list(d.values())[0]
125
+ for d in escalation}
126
+ logging.warning(f'Callout {bus_ip} {bus_port} {rta_tag} '
127
+ f'{sms_send_tag} {sms_recv_tag}')
104
128
  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
129
+ self.callees = normalise_callees(callees, self.delay_ms)
130
+ self.groups = groups
131
+ self.alarms_tag = Tag(alarms_tag, dict)
132
+ self.alarms_tag.add_callback(self.alarms_cb)
133
+ self.sms_recv_tag = Tag(sms_recv_tag, dict)
134
+ self.sms_recv_tag.add_callback(self.sms_recv_cb)
135
+ self.sms_send_tag = Tag(sms_send_tag, dict)
136
+ if ack_tag is not None:
137
+ self.ack_tag = Tag(ack_tag, int)
138
+ self.ack_tag.add_callback(self.ack_cb)
110
139
  if status_tag is not None:
111
140
  self.status = Tag(status_tag, int)
112
- self.status.value = IDLE
113
141
  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
142
  self.rta = Tag(rta_tag, dict)
117
- self.rta.value = {}
143
+ self.rta.value = {'__rta_id__': 0,
144
+ 'callees': self.callees,
145
+ 'groups': self.groups,
146
+ 'escalation': self.escalation}
118
147
  self.busclient.add_callback_rta(rta_tag, self.rta_cb)
119
148
  self.periodic = Periodic(self.periodic_cb, 1.0)
120
149
 
121
- def alarms_cb(self, request):
150
+ def alarms_cb(self, alm_tag):
122
151
  """Handle alarm messages from alarms.py."""
123
- if request['kind'] != ALM:
152
+ alarm = alm_tag.value
153
+ if 'kind' not in alarm or alarm['kind'] != ALM:
124
154
  return
125
155
  alarm = {
126
- 'date_ms': request['date_ms'],
127
- 'alarm_string': request['alarm_string'],
128
- 'desc': request['desc'],
129
- 'group': request['group'],
156
+ 'date_ms': alarm['date_ms'],
157
+ 'alarm_string': alarm['alarm_string'],
158
+ 'desc': alarm['desc'],
159
+ 'group': alarm['group'],
130
160
  'sent': {}
131
161
  }
132
162
  self.alarms.append(alarm)
133
163
  logging.info(f'Added alarm to list: {alarm}')
164
+ if self.status is not None and self.status.value == IDLE:
165
+ self.status.value = NEW_ALM
134
166
 
135
- def ack_cb(self, ack: str):
167
+ def ack_cb(self, ack_tag):
136
168
  """Handle ACK requests for alarm acknowledgment."""
137
- if ack == '__all':
169
+ if ack_tag.value == 1:
138
170
  self.alarms = []
139
- 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
171
+ if self.status is not None:
172
+ self.status.value = IDLE
173
+ logging.info('ACK: all alarms cleared')
174
+
175
+ def sms_recv_cb(self, sms_recv_tag: dict):
176
+ """Handle SMS messages from the modem."""
177
+ logging.info(f'sms_recv_cb {sms_recv_tag.value}')
178
+ _number = sms_recv_tag.value['number']
179
+ message = sms_recv_tag.value['message'][:2].upper()
180
+ if message in ['OK', 'AC', 'TH']:
181
+ self.alarms = []
182
+ if self.status is not None:
183
+ self.status.value = IDLE
184
+ logging.info('ACK: all alarms cleared')
159
185
 
160
186
  def rta_cb(self, request):
161
187
  """Handle RTA requests for callout configuration."""
162
188
  logging.info(f'rta_cb {request}')
163
189
  if 'action' not in request:
164
190
  logging.warning(f'rta_cb malformed {request}')
165
- elif request['action'] == 'ALL':
166
- self.rta.value = {'callees': self.callees, 'groups': self.groups}
191
+ return
192
+ if request['action'] == 'GET CONFIG':
193
+ self.rta.value = {'__rta_id__': request['__rta_id__'],
194
+ 'callees': self.callees,
195
+ 'groups': self.groups,
196
+ 'escalation': self.escalation}
167
197
  elif request['action'] == 'MODIFY':
198
+ send_update = False
168
199
  for callee in self.callees:
169
200
  if callee['name'] == request['name']:
170
- if 'delay' in request:
171
- callee['delay_ms'] = request['delay'] * 1000
201
+ if 'role' in request:
202
+ callee['role'] = request['role']
203
+ callee['delay_ms'] = self.delay_ms.get(
204
+ request['role'], 30000)
172
205
  if 'group' in request:
173
206
  callee['group'] = request['group']
174
207
  logging.info(f'Modified callee with {request}')
208
+ send_update = True
209
+ if send_update:
210
+ self.rta.value = {'__rta_id__': 0,
211
+ 'callees': self.callees,
212
+ 'groups': self.groups,
213
+ 'escalation': self.escalation}
175
214
 
176
215
  def check_callouts(self):
177
- """Check alarms and send callouts. Can be called independently for testing."""
216
+ """Check alarms and send callouts."""
178
217
  time_ms = int(time.time() * 1000)
179
218
  for callee in self.callees:
180
- message = ''
219
+ if not callee['role']:
220
+ continue
181
221
  count = 0
182
- group = callee['group']
222
+ message = ''
183
223
  notify_ms = time_ms - callee['delay_ms']
224
+ remind_ms = notify_ms - 60000
184
225
  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
226
+ if not alarm_in_group(alarm, callee['group'], self.groups):
227
+ continue
228
+ if notify_ms < alarm['date_ms']:
229
+ continue
230
+ if callee['name'] not in alarm['sent']:
231
+ message += f"{alarm['desc']}\n"
232
+ alarm['sent'][callee['name']] = SENT
233
+ count += 1
234
+ continue
235
+ if remind_ms < alarm['date_ms']:
236
+ continue
237
+ if alarm['sent'][callee['name']] != REMIND:
238
+ message += f"REMIND {alarm['desc']}\n"
239
+ alarm['sent'][callee['name']] = REMIND
240
+ count += 1
191
241
  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}
242
+ if len(message) > 200:
243
+ message = message[:200] + '\n...'
244
+ send_message = f"{count} Alarms\n{message}"
245
+ self.sms_send_tag.value = {
246
+ 'number': callee['sms'],
247
+ 'message': send_message
248
+ }
196
249
  if self.status is not None:
197
250
  self.status.value = CALLOUT
198
251
 
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}')
@@ -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]
@@ -2,7 +2,7 @@ bus_ip: 127.0.0.1
2
2
  bus_port: 1324
3
3
  proxy:
4
4
  api:
5
- api_key: ${OPENWEATHERMAP_API_KEY}
5
+ api_key: ${MSCADA_OPENWEATHERMAP_API_KEY}
6
6
  units: metric
7
7
  locations:
8
8
  Murupara:
@@ -0,0 +1,15 @@
1
+ bus_ip: 127.0.0.1
2
+ bus_port: 1324
3
+ proxy:
4
+ api:
5
+ url: 'https://192.168.15.1/'
6
+ webid: F1Em9DA80Xftdkec1gdWFtX7NAm1eiSAyV8BG1mAAMKQIjRQUEdQSVxQSU9ORUVSXFdFQkFQSVxNT0JJTEVTQ0FEQQ
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
@@ -3,8 +3,8 @@ bus_port: 1324
3
3
  proxy:
4
4
  api:
5
5
  url: 'https://api.electricityinfo.co.nz'
6
- client_id: ${WITS_CLIENT_ID}
7
- client_secret: ${WITS_CLIENT_SECRET}
6
+ client_id: ${MSCADA_WITS_CLIENT_ID}
7
+ client_secret: ${MSCADA_WITS_CLIENT_SECRET}
8
8
  gxp_list:
9
9
  - MAT1101
10
10
  - CYD2201
@@ -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/history.py CHANGED
@@ -270,7 +270,7 @@ class History():
270
270
  self.tags[tagname] = Tag(tagname, tag['type'])
271
271
  self.tags[tagname].add_callback(self.hist_tags[tagname].callback)
272
272
  self.rta = Tag(rta_tag, bytes)
273
- self.rta.value = b'\x00\x00\x00\x00\x00\x00'
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
275
 
276
276
  def rta_cb(self, request: Request):
@@ -278,9 +278,7 @@ class History():
278
278
  if 'start_ms' in request:
279
279
  request['start_us'] = request['start_ms'] * 1000
280
280
  request['end_us'] = request['end_ms'] * 1000
281
- rta_id = 0
282
- if '__rta_id__' in request:
283
- rta_id = request['__rta_id__']
281
+ rta_id = request['__rta_id__']
284
282
  tagname = request['tagname']
285
283
  start_time = time.asctime(time.localtime(
286
284
  request['start_us'] / 1000000))
@@ -299,7 +297,7 @@ class History():
299
297
  packtype = 2
300
298
  self.rta.value = pack('>HHH', rta_id, tagid, packtype) + data
301
299
  logging.info(f'sent {len(data)} bytes for {request["tagname"]}')
302
- self.rta.value = b'\x00\x00\x00\x00\x00\x00'
300
+ self.rta.value = b'\x00\x00\x00\x00\x00\x00' # rta_id is 0
303
301
  except Exception as e:
304
302
  logging.error(f'history rta_cb {e}')
305
303