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 +6 -2
- pymscada/alarms.py +6 -2
- pymscada/callout.py +127 -74
- pymscada/config.py +20 -1
- pymscada/demo/__pycache__/__init__.cpython-311.pyc +0 -0
- pymscada/demo/callout.yaml +13 -4
- pymscada/demo/openweather.yaml +1 -1
- pymscada/demo/piapi.yaml +15 -0
- pymscada/demo/pymscada-io-piapi.service +15 -0
- pymscada/demo/pymscada-io-sms.service +18 -0
- pymscada/demo/sms.yaml +11 -0
- pymscada/demo/tags.yaml +3 -0
- pymscada/demo/witsapi.yaml +2 -2
- pymscada/demo/wwwserver.yaml +15 -0
- pymscada/history.py +3 -5
- pymscada/iodrivers/piapi.py +133 -0
- pymscada/iodrivers/sms.py +212 -0
- pymscada/iodrivers/witsapi.py +26 -35
- pymscada/module_config.py +24 -18
- pymscada/opnotes.py +4 -2
- pymscada/pdf/__pycache__/__init__.cpython-311.pyc +0 -0
- pymscada/tools/get_history.py +147 -0
- pymscada/www_server.py +1 -1
- {pymscada-0.2.1.dist-info → pymscada-0.2.2.dist-info}/METADATA +1 -1
- {pymscada-0.2.1.dist-info → pymscada-0.2.2.dist-info}/RECORD +29 -23
- pymscada/validate.py +0 -451
- {pymscada-0.2.1.dist-info → pymscada-0.2.2.dist-info}/WHEEL +0 -0
- {pymscada-0.2.1.dist-info → pymscada-0.2.2.dist-info}/entry_points.txt +0 -0
- {pymscada-0.2.1.dist-info → pymscada-0.2.2.dist-info}/licenses/LICENSE +0 -0
- {pymscada-0.2.1.dist-info → pymscada-0.2.2.dist-info}/top_level.txt +0 -0
|
@@ -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()
|
pymscada/iodrivers/witsapi.py
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
169
|
-
export
|
|
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':
|
|
Binary file
|