pymscada 0.2.0rc8__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.

@@ -0,0 +1,133 @@
1
+ """Poll OSI PI WebAPI for tag values."""
2
+ import asyncio
3
+ import aiohttp
4
+ from datetime import datetime
5
+ import logging
6
+ import socket
7
+ from time import time
8
+ from pymscada.misc import find_nodes
9
+ from pymscada.bus_client import BusClient
10
+ from pymscada.periodic import Periodic
11
+ from pymscada.tag import Tag
12
+
13
+ class PIWebAPIClient:
14
+ """Get tag data from OSI PI WebAPI."""
15
+
16
+ def __init__(
17
+ self,
18
+ bus_ip: str | None = '127.0.0.1',
19
+ bus_port: int = 1324,
20
+ proxy: str | None = None,
21
+ api: dict = {},
22
+ tags: dict = {}
23
+ ) -> None:
24
+ """
25
+ Connect to bus on bus_ip:bus_port.
26
+
27
+ api dict should contain:
28
+ - url: PI WebAPI base URL
29
+ - webid: PI WebID for the stream set
30
+ - averaging: averaging period in seconds
31
+
32
+ tags dict should contain:
33
+ - tagname: pitag mapping for each tag
34
+ """
35
+ if bus_ip is not None:
36
+ try:
37
+ socket.gethostbyname(bus_ip)
38
+ except socket.gaierror:
39
+ raise ValueError(f"Invalid bus_ip: {bus_ip}")
40
+ if not isinstance(proxy, str) and proxy is not None:
41
+ raise ValueError("proxy must be a string or None")
42
+ if not isinstance(api, dict):
43
+ raise ValueError("api must be a dictionary")
44
+ if not isinstance(tags, dict):
45
+ raise ValueError("tags must be a dictionary")
46
+
47
+ self.busclient = None
48
+ if bus_ip is not None:
49
+ self.busclient = BusClient(bus_ip, bus_port, module='PIWebAPI')
50
+ self.proxy = proxy
51
+ self.base_url = api['url'].rstrip('/')
52
+ self.webid = api['webid']
53
+ self.averaging = api.get('averaging', 300)
54
+ self.tags = {}
55
+ self.pitag_map = {}
56
+ self.scale = {}
57
+ for tagname, config in tags.items():
58
+ self.tags[tagname] = Tag(tagname, float)
59
+ self.pitag_map[config['pitag']] = tagname
60
+ if 'scale' in config:
61
+ self.scale[tagname] = config['scale']
62
+ self.session = None
63
+ self.handle = None
64
+ self.periodic = None
65
+ self.queue = asyncio.Queue()
66
+
67
+ def update_tags(self, pitag: str, values: list):
68
+ tag = self.tags[self.pitag_map[pitag]]
69
+ scale = None
70
+ if tag.name in self.scale:
71
+ scale = self.scale[tag.name]
72
+ data = {}
73
+ for item in values:
74
+ value = item['Value']
75
+ dt = datetime.fromisoformat(value['Timestamp'].replace('Z', '+00:00'))
76
+ time_us = int(dt.timestamp() * 1e6)
77
+ data[time_us] = value['Value']
78
+ times_us = sorted(data.keys())
79
+ for time_us in times_us:
80
+ if time_us > tag.time_us:
81
+ if data[time_us] is None:
82
+ logging.error(f'{tag.name} is None at {time_us}')
83
+ continue
84
+ if scale is not None:
85
+ data[time_us] = data[time_us] / scale
86
+ tag.value = data[time_us], time_us
87
+
88
+ async def handle_response(self):
89
+ """Handle responses from the API."""
90
+ while True:
91
+ values = await self.queue.get()
92
+ for value in find_nodes('Name' , values):
93
+ if value['Name'] in self.pitag_map:
94
+ self.update_tags(value['Name'], value['Items'])
95
+ self.queue.task_done()
96
+
97
+ async def get_pi_data(self, now):
98
+ """Get PI data from WebAPI."""
99
+ time = now - (now % self.averaging)
100
+ start_time = datetime.fromtimestamp(time - self.averaging * 12).isoformat()
101
+ end_time = datetime.fromtimestamp(time).isoformat()
102
+ url = f"{self.base_url}/piwebapi/streamsets/{self.webid}/summary?" \
103
+ f"startTime={start_time}&endTime={end_time}" \
104
+ "&summaryType=Average&calculationBasis=TimeWeighted" \
105
+ f"&summaryDuration={self.averaging}s"
106
+ async with self.session.get(url) as response:
107
+ return await response.json()
108
+
109
+ async def fetch_data(self, now):
110
+ """Fetch values from PI Web API."""
111
+ try:
112
+ if self.session is None:
113
+ connector = aiohttp.TCPConnector(ssl=False)
114
+ self.session = aiohttp.ClientSession(connector=connector)
115
+ json_data = await self.get_pi_data(now)
116
+ if json_data:
117
+ await self.queue.put(json_data)
118
+ except Exception as e:
119
+ logging.error(f'Error fetching data: {type(e).__name__} - {str(e)}')
120
+
121
+ async def poll(self):
122
+ """Poll PI API."""
123
+ now = int(time())
124
+ if now % self.averaging == 15:
125
+ asyncio.create_task(self.fetch_data(now))
126
+
127
+ async def start(self):
128
+ """Start bus connection and API polling."""
129
+ if self.busclient is not None:
130
+ await self.busclient.start()
131
+ self.handle = asyncio.create_task(self.handle_response())
132
+ self.periodic = Periodic(self.poll, 1.0)
133
+ await self.periodic.start()
@@ -0,0 +1,212 @@
1
+ """SMS to RUT241"""
2
+ import asyncio
3
+ from aiohttp import web, ClientSession
4
+ import logging
5
+ import socket
6
+ from typing import Callable
7
+
8
+ from pymscada.bus_client import BusClient
9
+ from pymscada.misc import find_nodes
10
+ from pymscada.periodic import Periodic
11
+ from pymscada.tag import Tag
12
+
13
+
14
+ class RUT241:
15
+ """RUT241 SMS modem."""
16
+ # disable RMS settings
17
+ # just use admin for api call, too damned obscure to configure
18
+ # need to configure SMS to http gateway for incoming SMS
19
+ # point to this server, use aiohttp to serve, awkward but
20
+ # by exception so fast and light
21
+ # set the http forward to use sms_number and sms_message
22
+ # If keeping it simple (i.e. limit to SMS and LAN)
23
+ # Network WAN - turn all off, LAN - static, Wireless - turn off
24
+
25
+
26
+ def __init__(self, ip: str = None, username: str = None,
27
+ password: str = None, port: int = 8080,
28
+ recv_cb: Callable = None, info: dict = {}):
29
+ if ip is None:
30
+ raise ValueError('ip is required')
31
+ if username is None:
32
+ raise ValueError('username is required')
33
+ if password is None:
34
+ raise ValueError('password is required')
35
+
36
+ self.ip = ip
37
+ self.username = username
38
+ self.password = password
39
+ self.port = port
40
+ self.recv_cb = recv_cb
41
+ self.tags = {}
42
+ for info, tagname in info.items():
43
+ self.tags[info] = Tag(tagname, str)
44
+ self.token = None
45
+ self.modem = None
46
+ self.carrier = None
47
+ self.webapp = None
48
+
49
+ async def login(self):
50
+ url = f'https://{self.ip}/api/login'
51
+ json = {'username': self.username,
52
+ 'password': self.password}
53
+ headers = {'Content-Type': 'application/json'}
54
+ async with ClientSession() as session:
55
+ async with session.post(url, json=json, headers=headers,
56
+ ssl=False) as response:
57
+ if response.status == 200:
58
+ resp = await response.json()
59
+ self.token = resp['data']['token']
60
+ logging.info(f'RUT241 login token {self.token}')
61
+ else:
62
+ logging.error(f'RUT241 login error {response.status} '
63
+ f'{response.text}')
64
+
65
+ async def get_modem_info(self):
66
+ if self.token is None:
67
+ await self.login()
68
+ if self.token is None:
69
+ return
70
+ url = f'https://{self.ip}/api/modems/apns/status'
71
+ headers = {'Authorization': f'Bearer {self.token}'}
72
+ async with ClientSession() as session:
73
+ async with session.get(url, headers=headers, ssl=False) as response:
74
+ if response.status == 200:
75
+ resp = await response.json()
76
+ self.modem = next(find_nodes('modem', resp))['modem']
77
+ self.carrier = next(find_nodes('carrier', resp))['carrier']
78
+ logging.info(f'RUT241 {self.modem} on {self.carrier}')
79
+ else:
80
+ self.token = None
81
+ logging.error(f'RUT241 lost token {response.status} '
82
+ f'{response.text}')
83
+
84
+ async def send_sms(self, phone: str, message: str):
85
+ url = f'https://{self.ip}/api/messages/actions/send'
86
+ headers = {'Authorization': f'Bearer {self.token}',
87
+ 'Content-Type': 'application/json'}
88
+ json = {'data': {
89
+ 'number': phone,
90
+ 'message': message,
91
+ 'modem': self.modem
92
+ }}
93
+ logging.info(f'RUT241 {json}')
94
+ async with ClientSession() as session:
95
+ async with session.post(url, headers=headers, json=json, ssl=False) as response:
96
+ if response.status == 200:
97
+ resp = await response.json()
98
+ logging.info(f'RUT241 {resp}')
99
+ else:
100
+ logging.error(f'RUT241 {response.status} {response.text}')
101
+
102
+ async def listen_sms(self):
103
+ webserver = web.Application()
104
+
105
+ async def post_handler(request):
106
+ data = await request.post()
107
+ if data['sms_message'][:2].upper() == 'IN':
108
+ tag = self.tags['__default__']
109
+ if tag.value is None:
110
+ message = '__default__'
111
+ else:
112
+ message = tag.value
113
+ asyncio.create_task(self.send_sms(data['sms_number'], message))
114
+ return web.Response(text='OK', status=200)
115
+ if self.recv_cb is not None:
116
+ self.recv_cb(dict(data))
117
+ return web.Response(text='OK', status=200)
118
+
119
+ webserver.router.add_post('/', post_handler)
120
+ self.webapp = web.AppRunner(webserver)
121
+ await self.webapp.setup()
122
+ site = web.TCPSite(self.webapp, '0.0.0.0', self.port)
123
+ await site.start()
124
+
125
+ async def stop_listening(self):
126
+ if self.webapp is not None:
127
+ await self.webapp.cleanup()
128
+
129
+
130
+ class SMS:
131
+ """Connect to SMS modem."""
132
+
133
+ def __init__(
134
+ self,
135
+ bus_ip: str | None = '127.0.0.1',
136
+ bus_port: int | None = 1324,
137
+ sms_send_tag: str = '__sms_send__',
138
+ sms_recv_tag: str = '__sms_recv__',
139
+ modem: str = 'rut241',
140
+ modem_ip: str | None = None,
141
+ username: str | None = None,
142
+ password: str | None = None,
143
+ listen_port: int | None = 8080,
144
+ info: dict = {}
145
+ ) -> None:
146
+ """
147
+ Connect to SMS, only RUT240 at the moment.
148
+ For testing bus_ip can be None to skip connection.
149
+ ip must be valid and reachable, check.
150
+ """
151
+ if bus_ip is None:
152
+ logging.warning('Callout has bus_ip=None, only use for testing')
153
+ else:
154
+ try:
155
+ socket.gethostbyname(bus_ip)
156
+ except socket.gaierror as e:
157
+ raise ValueError(f'Cannot resolve IP/hostname: {e}')
158
+ if not isinstance(bus_port, int):
159
+ raise TypeError('bus_port must be an integer')
160
+ if not 1024 <= bus_port <= 65535:
161
+ raise ValueError('bus_port must be between 1024 and 65535')
162
+ if not isinstance(sms_send_tag, str) or not sms_send_tag:
163
+ raise ValueError('sms_send_tag must be a non-empty string')
164
+ if not isinstance(sms_recv_tag, str) or not sms_recv_tag:
165
+ raise ValueError('sms_recv_tag must be a non-empty string')
166
+ if modem == 'rut241':
167
+ self.modem = RUT241(ip=modem_ip, username=username,
168
+ password=password, port=listen_port,
169
+ recv_cb=self.sms_recv_cb, info=info)
170
+ else:
171
+ raise ValueError(f'Unknown modem type: {type}')
172
+
173
+ logging.warning(f'SMS {bus_ip} {bus_port} {sms_send_tag} '
174
+ f'{sms_recv_tag}')
175
+ self.sms_send = Tag(sms_send_tag, dict)
176
+ self.sms_send.add_callback(self.sms_send_cb)
177
+ self.sms_recv = Tag(sms_recv_tag, dict)
178
+ self.tags = {}
179
+ for info, tagname in info.items():
180
+ self.tags[info] = Tag(tagname, str)
181
+ self.busclient = None
182
+ if bus_ip is not None:
183
+ self.busclient = BusClient(bus_ip, bus_port, module='SMS')
184
+ self.periodic = Periodic(self.periodic_cb, 60.0)
185
+
186
+ def sms_send_cb(self, tag: Tag):
187
+ """Handle SMS messages from the modem."""
188
+ if tag.value is None:
189
+ return
190
+ number = tag.value['number']
191
+ message = tag.value['message']
192
+ logging.info(f'Sending SMS to {number}: {message}')
193
+ asyncio.create_task(self.modem.send_sms(number, message))
194
+
195
+ def sms_recv_cb(self, value: dict):
196
+ """Handle SMS messages from the modem."""
197
+ self.sms_recv.value = {
198
+ 'number': value['sms_number'],
199
+ 'message': value['sms_message']
200
+ }
201
+
202
+ async def periodic_cb(self):
203
+ """Periodic callback to check alarms and send callouts."""
204
+ await self.modem.get_modem_info()
205
+
206
+ async def start(self):
207
+ """Async startup."""
208
+ if self.busclient is not None:
209
+ await self.busclient.start()
210
+ await self.modem.get_modem_info()
211
+ await self.modem.listen_sms()
212
+ await self.periodic.start()
@@ -17,8 +17,7 @@ class WitsAPIClient:
17
17
  bus_ip: str | None = '127.0.0.1',
18
18
  bus_port: int = 1324,
19
19
  proxy: str | None = None,
20
- api: dict = {},
21
- tags: list = []
20
+ api: dict = {}
22
21
  ) -> None:
23
22
  """
24
23
  Connect to bus on bus_ip:bus_port.
@@ -41,21 +40,21 @@ class WitsAPIClient:
41
40
  raise ValueError("proxy must be a string or None")
42
41
  if not isinstance(api, dict):
43
42
  raise ValueError("api must be a dictionary")
44
- if not isinstance(tags, list):
45
- raise ValueError("tags must be a list")
46
43
 
47
44
  self.busclient = None
48
45
  if bus_ip is not None:
49
46
  self.busclient = BusClient(bus_ip, bus_port, module='WitsAPI')
50
47
  self.proxy = proxy
51
- self.map_bus = id(self)
52
- self.tags = {tagname: Tag(tagname, float) for tagname in tags}
53
-
48
+ self.tags = {}
54
49
  # API configuration
55
50
  self.client_id = api['client_id']
56
51
  self.client_secret = api['client_secret']
57
52
  self.base_url = api['url']
58
53
  self.gxp_list = api.get('gxp_list', [])
54
+ for gxp in self.gxp_list:
55
+ self.tags[gxp] = Tag(gxp, float)
56
+ self.tags[f"{gxp}_Realtime"] = Tag(f"{gxp}_Realtime", float)
57
+ self.tags[f"{gxp}_Forecast"] = Tag(f"{gxp}_Forecast", float)
59
58
  self.back = api.get('back', 2)
60
59
  self.forward = api.get('forward', 72)
61
60
 
@@ -146,34 +145,26 @@ class WitsAPIClient:
146
145
 
147
146
  def update_tags(self, prices):
148
147
  """Update tags with price data"""
149
- for node in prices:
150
- rtd = {}
151
- for trading_time in prices[node]:
152
- if 'RTD' in prices[node][trading_time]:
153
- rtd_price, _ = prices[node][trading_time]['RTD']
154
- rtd[trading_time] = rtd_price
155
- continue
156
- prss_price = None
157
- prsl_price = None
158
- if 'PRSS' in prices[node][trading_time]:
159
- prss_price, prss_last_run = prices[node][trading_time]['PRSS']
160
- if 'PRSL' in prices[node][trading_time]:
161
- prsl_price, prsl_last_run = prices[node][trading_time]['PRSL']
162
- if prsl_price is not None and prss_price is not None:
163
- if prss_last_run > prsl_last_run:
164
- rtd[trading_time] = prss_price
165
- else:
166
- rtd[trading_time] = prsl_price
167
- continue
168
- if prss_price is not None:
169
- rtd[trading_time] = prss_price
170
- elif prsl_price is not None:
171
- rtd[trading_time] = prsl_price
172
- tagname = f"{node}_RTD"
173
- if tagname in self.tags:
174
- for trading_time in sorted(rtd.keys()):
175
- time_us = int(trading_time * 1_000_000)
176
- self.tags[tagname].value = rtd[trading_time], time_us, self.map_bus
148
+ tags = self.tags
149
+ for tagname, data in prices.items():
150
+ realtime = f"{tagname}_Realtime"
151
+ forecast = f"{tagname}_Forecast"
152
+ times = sorted(prices[tagname].keys())
153
+ for time_s in times:
154
+ time_us = int(time_s * 1_000_000)
155
+ if 'RTD' in data[time_s]:
156
+ value, _ = data[time_s]['RTD']
157
+ if True or time_us > tags[realtime].time_us:
158
+ tags[realtime].value = value, time_us
159
+ tags[tagname].value = value, time_us
160
+ if 'PRSS' in data[time_s]:
161
+ value, _ = data[time_s]['PRSS']
162
+ tags[forecast].value = value, time_us
163
+ tags[tagname].value = value, time_us
164
+ elif 'PRSL' in data[time_s]:
165
+ value, _ = data[time_s]['PRSL']
166
+ tags[forecast].value = value, time_us
167
+ tags[tagname].value = value, time_us
177
168
 
178
169
  async def handle_response(self):
179
170
  """Handle responses from the API."""
pymscada/module_config.py CHANGED
@@ -69,19 +69,6 @@ def create_module_registry():
69
69
  module_class='pymscada.callout:Callout',
70
70
  tags=False
71
71
  ),
72
- ModuleDefinition(
73
- name='validate',
74
- help='validate config files',
75
- module_class='pymscada.validate:validate',
76
- config=False,
77
- tags=False,
78
- extra_args=[
79
- ModuleArgument(
80
- ('--path',),
81
- {'metavar': 'file', 'help': 'default is current working directory'}
82
- )
83
- ]
84
- ),
85
72
  ModuleDefinition(
86
73
  name='checkout',
87
74
  help='create example config files',
@@ -139,10 +126,23 @@ def create_module_registry():
139
126
  module_class='pymscada.iodrivers.openweather:OpenWeatherClient',
140
127
  tags=False,
141
128
  epilog=dedent("""
142
- OPENWEATHERMAP_API_KEY can be set in the openweathermap.yaml
129
+ MSCADA_OPENWEATHERMAP_API_KEY can be set in the openweathermap.yaml
130
+ or as an environment variable:
131
+ vi ~/.bashrc
132
+ export MSCADA_OPENWEATHERMAP_API_KEY='1234567890'""")
133
+ ),
134
+ ModuleDefinition(
135
+ name='sms',
136
+ help='send and receive SMS messages through an RUT241 modem',
137
+ module_class='pymscada.iodrivers.sms:SMS',
138
+ tags=False,
139
+ epilog=dedent("""
140
+ MSCADA_SMS_IP, MSCADA_SMS_USERNAME, MSCADA_SMS_PASSWORD can be set in the sms.yaml
143
141
  or as an environment variable:
144
142
  vi ~/.bashrc
145
- export OPENWEATHERMAP_API_KEY='1234567890'""")
143
+ export MSCADA_SMS_IP='192.168.1.2'
144
+ export MSCADA_SMS_USERNAME='smsuser'
145
+ export MSCADA_SMS_PASSWORD='smspass'""")
146
146
  ),
147
147
  ModuleDefinition(
148
148
  name='pingclient',
@@ -162,11 +162,17 @@ def create_module_registry():
162
162
  module_class='pymscada.iodrivers.witsapi:WitsAPIClient',
163
163
  tags=False,
164
164
  epilog=dedent("""
165
- WITS_CLIENT_ID and WITS_CLIENT_SECRET can be set in the wits.yaml
165
+ MSCADA_WITS_CLIENT_ID and MSCADA_WITS_CLIENT_SECRET can be set in the wits.yaml
166
166
  or as environment variables:
167
167
  vi ~/.bashrc
168
- export WITS_CLIENT_ID='your_client_id'
169
- export WITS_CLIENT_SECRET='your_client_secret'""")
168
+ export MSCADA_WITS_CLIENT_ID='your_client_id'
169
+ export MSCADA_WITS_CLIENT_SECRET='your_client_secret'""")
170
+ ),
171
+ ModuleDefinition(
172
+ name='piapi',
173
+ help='poll WITS GXP pricing real time dispatch and forecast',
174
+ module_class='pymscada.iodrivers.piapi:PIWebAPIClient',
175
+ tags=False
170
176
  ),
171
177
  ModuleDefinition(
172
178
  name='console',
pymscada/opnotes.py CHANGED
@@ -52,7 +52,7 @@ class OpNotes:
52
52
  self._init_table()
53
53
  self.busclient = BusClient(bus_ip, bus_port, module='OpNotes')
54
54
  self.rta = Tag(rta_tag, dict)
55
- self.rta.value = {}
55
+ self.rta.value = {'__rta_id__': 0}
56
56
  self.busclient.add_callback_rta(rta_tag, self.rta_cb)
57
57
 
58
58
  def _init_table(self):
@@ -115,6 +115,7 @@ class OpNotes:
115
115
  request)
116
116
  res = self.cursor.fetchone()
117
117
  self.rta.value = {
118
+ '__rta_id__': 0,
118
119
  'id': res[0],
119
120
  'date_ms': res[1],
120
121
  'site': res[2],
@@ -133,6 +134,7 @@ class OpNotes:
133
134
  ':site, :by, :note, :abnormal) RETURNING *;', request)
134
135
  res = self.cursor.fetchone()
135
136
  self.rta.value = {
137
+ '__rta_id__': 0,
136
138
  'id': res[0],
137
139
  'date_ms': res[1],
138
140
  'site': res[2],
@@ -148,7 +150,7 @@ class OpNotes:
148
150
  with self.connection:
149
151
  self.cursor.execute(
150
152
  f'DELETE FROM {self.table} WHERE id = :id;', request)
151
- self.rta.value = {'id': request['id']}
153
+ self.rta.value = {'__rta_id__': 0, 'id': request['id']}
152
154
  except sqlite3.IntegrityError as error:
153
155
  logging.warning(f'OpNotes rta_cb {error}')
154
156
  elif request['action'] == 'HISTORY':