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.
- pymscada/__init__.py +8 -2
- pymscada/alarms.py +179 -60
- pymscada/bus_client.py +12 -2
- pymscada/bus_server.py +18 -9
- pymscada/callout.py +198 -101
- pymscada/config.py +20 -1
- pymscada/console.py +19 -6
- pymscada/demo/__pycache__/__init__.cpython-311.pyc +0 -0
- pymscada/demo/callout.yaml +13 -4
- pymscada/demo/files.yaml +3 -2
- pymscada/demo/openweather.yaml +3 -11
- 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 +6 -8
- pymscada/demo/wwwserver.yaml +15 -0
- pymscada/files.py +1 -0
- pymscada/history.py +4 -5
- pymscada/iodrivers/logix_map.py +1 -1
- pymscada/iodrivers/modbus_client.py +189 -21
- pymscada/iodrivers/modbus_map.py +17 -2
- 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 +38 -16
- pymscada/pdf/__pycache__/__init__.cpython-311.pyc +0 -0
- pymscada/tag.py +6 -7
- pymscada/tools/get_history.py +147 -0
- pymscada/www_server.py +2 -1
- {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/METADATA +2 -2
- {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/RECORD +38 -32
- pymscada/validate.py +0 -451
- {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/WHEEL +0 -0
- {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/entry_points.txt +0 -0
- {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/licenses/LICENSE +0 -0
- {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/top_level.txt +0 -0
|
@@ -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
|
@@ -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
|
|
|
@@ -11,7 +12,7 @@ class OpNotes:
|
|
|
11
12
|
|
|
12
13
|
def __init__(
|
|
13
14
|
self,
|
|
14
|
-
bus_ip: str = '127.0.0.1',
|
|
15
|
+
bus_ip: str|None = '127.0.0.1',
|
|
15
16
|
bus_port: int = 1324,
|
|
16
17
|
db: str | None = None,
|
|
17
18
|
table: str = 'opnotes',
|
|
@@ -52,7 +53,7 @@ class OpNotes:
|
|
|
52
53
|
self._init_table()
|
|
53
54
|
self.busclient = BusClient(bus_ip, bus_port, module='OpNotes')
|
|
54
55
|
self.rta = Tag(rta_tag, dict)
|
|
55
|
-
self.rta.value = {}
|
|
56
|
+
self.rta.value = {'__rta_id__': 0}
|
|
56
57
|
self.busclient.add_callback_rta(rta_tag, self.rta_cb)
|
|
57
58
|
|
|
58
59
|
def _init_table(self):
|
|
@@ -101,12 +102,21 @@ class OpNotes:
|
|
|
101
102
|
|
|
102
103
|
def rta_cb(self, request):
|
|
103
104
|
"""Respond to Request to Author and publish on rta_tag as needed."""
|
|
105
|
+
local_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
106
|
+
logging.info(f'[{local_time}] RTA callback received: {request}')
|
|
104
107
|
if 'action' not in request:
|
|
105
108
|
logging.warning(f'rta_cb malformed {request}')
|
|
106
109
|
elif request['action'] == 'ADD':
|
|
107
110
|
try:
|
|
108
|
-
logging.info(f'add {request}')
|
|
109
111
|
with self.connection:
|
|
112
|
+
if not isinstance(request, dict) or \
|
|
113
|
+
'date_ms' not in request or \
|
|
114
|
+
'site' not in request or \
|
|
115
|
+
'by' not in request or \
|
|
116
|
+
'note' not in request or \
|
|
117
|
+
'abnormal' not in request:
|
|
118
|
+
logging.warning(f'rta_cb ADD malformed {request}')
|
|
119
|
+
return
|
|
110
120
|
self.cursor.execute(
|
|
111
121
|
f'INSERT INTO {self.table} '
|
|
112
122
|
'(date_ms, site, by, note, abnormal) '
|
|
@@ -114,7 +124,8 @@ class OpNotes:
|
|
|
114
124
|
'RETURNING *;',
|
|
115
125
|
request)
|
|
116
126
|
res = self.cursor.fetchone()
|
|
117
|
-
|
|
127
|
+
response = {
|
|
128
|
+
'__rta_id__': 0,
|
|
118
129
|
'id': res[0],
|
|
119
130
|
'date_ms': res[1],
|
|
120
131
|
'site': res[2],
|
|
@@ -122,17 +133,27 @@ class OpNotes:
|
|
|
122
133
|
'note': res[4],
|
|
123
134
|
'abnormal': res[5]
|
|
124
135
|
}
|
|
125
|
-
|
|
136
|
+
self.rta.value = response
|
|
137
|
+
except sqlite3.Error as error:
|
|
126
138
|
logging.warning(f'OpNotes rta_cb {error}')
|
|
127
139
|
elif request['action'] == 'MODIFY':
|
|
128
140
|
try:
|
|
129
|
-
logging.info(f'modify {request}')
|
|
130
141
|
with self.connection:
|
|
142
|
+
if not isinstance(request, dict) or \
|
|
143
|
+
'id' not in request or \
|
|
144
|
+
'date_ms' not in request or \
|
|
145
|
+
'site' not in request or \
|
|
146
|
+
'by' not in request or \
|
|
147
|
+
'note' not in request or \
|
|
148
|
+
'abnormal' not in request:
|
|
149
|
+
logging.warning(f'rta_cb MODIFY malformed {request}')
|
|
150
|
+
return
|
|
131
151
|
self.cursor.execute(
|
|
132
152
|
f'REPLACE INTO {self.table} VALUES(:id, :date_ms, '
|
|
133
153
|
':site, :by, :note, :abnormal) RETURNING *;', request)
|
|
134
154
|
res = self.cursor.fetchone()
|
|
135
155
|
self.rta.value = {
|
|
156
|
+
'__rta_id__': 0,
|
|
136
157
|
'id': res[0],
|
|
137
158
|
'date_ms': res[1],
|
|
138
159
|
'site': res[2],
|
|
@@ -140,20 +161,22 @@ class OpNotes:
|
|
|
140
161
|
'note': res[4],
|
|
141
162
|
'abnormal': res[5]
|
|
142
163
|
}
|
|
143
|
-
except sqlite3.
|
|
164
|
+
except sqlite3.Error as error:
|
|
144
165
|
logging.warning(f'OpNotes rta_cb {error}')
|
|
145
166
|
elif request['action'] == 'DELETE':
|
|
146
167
|
try:
|
|
147
|
-
logging.info(f'delete {request}')
|
|
148
168
|
with self.connection:
|
|
169
|
+
if not isinstance(request, dict) or \
|
|
170
|
+
'id' not in request:
|
|
171
|
+
logging.warning(f'rta_cb DELETE malformed {request}')
|
|
172
|
+
return
|
|
149
173
|
self.cursor.execute(
|
|
150
174
|
f'DELETE FROM {self.table} WHERE id = :id;', request)
|
|
151
|
-
self.rta.value = {'id': request['id']}
|
|
152
|
-
except sqlite3.
|
|
175
|
+
self.rta.value = {'__rta_id__': 0, 'id': request['id']}
|
|
176
|
+
except sqlite3.Error as error:
|
|
153
177
|
logging.warning(f'OpNotes rta_cb {error}')
|
|
154
178
|
elif request['action'] == 'HISTORY':
|
|
155
179
|
try:
|
|
156
|
-
logging.info(f'history {request}')
|
|
157
180
|
with self.connection:
|
|
158
181
|
self.cursor.execute(
|
|
159
182
|
f'SELECT * FROM {self.table} WHERE date_ms > :date_ms '
|
|
@@ -168,19 +191,18 @@ class OpNotes:
|
|
|
168
191
|
'note': res[4],
|
|
169
192
|
'abnormal': res[5]
|
|
170
193
|
}
|
|
171
|
-
except sqlite3.
|
|
194
|
+
except sqlite3.Error as error:
|
|
172
195
|
logging.warning(f'OpNotes rta_cb {error}')
|
|
173
196
|
elif request['action'] == 'BULK HISTORY':
|
|
174
197
|
try:
|
|
175
|
-
logging.info(f'bulk history {request}')
|
|
176
198
|
with self.connection:
|
|
177
199
|
self.cursor.execute(
|
|
178
200
|
f'SELECT * FROM {self.table} WHERE date_ms > :date_ms '
|
|
179
201
|
'ORDER BY -date_ms;', request)
|
|
180
202
|
results = list(self.cursor.fetchall())
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
except sqlite3.
|
|
203
|
+
response = {'__rta_id__': request['__rta_id__'], 'data': results}
|
|
204
|
+
self.rta.value = response
|
|
205
|
+
except sqlite3.Error as error:
|
|
184
206
|
logging.warning(f'OpNotes rta_cb {error}')
|
|
185
207
|
|
|
186
208
|
async def start(self):
|
|
Binary file
|
pymscada/tag.py
CHANGED
|
@@ -21,10 +21,10 @@ TYPES = {
|
|
|
21
21
|
class UniqueTag(type):
|
|
22
22
|
"""Super Tag class only create unique tags for unique tag names."""
|
|
23
23
|
|
|
24
|
-
__cache = {}
|
|
24
|
+
__cache: dict[str, 'Tag'] = {}
|
|
25
25
|
__notify = None
|
|
26
26
|
|
|
27
|
-
def __call__(cls, tagname: str, tagtype: type = None):
|
|
27
|
+
def __call__(cls, tagname: str, tagtype: type | None = None):
|
|
28
28
|
"""Each time a new tag is created, check if it is really new."""
|
|
29
29
|
if tagname in cls.__cache:
|
|
30
30
|
tag = cls.__cache[tagname]
|
|
@@ -33,8 +33,7 @@ class UniqueTag(type):
|
|
|
33
33
|
else:
|
|
34
34
|
if tagtype is None:
|
|
35
35
|
raise TypeError(f"{tagname} type is undefined.")
|
|
36
|
-
tag =
|
|
37
|
-
tag.__init__(tagname, tagtype)
|
|
36
|
+
tag: Tag = super().__call__(tagname, tagtype)
|
|
38
37
|
tag.id = None
|
|
39
38
|
cls.__cache[tagname] = tag
|
|
40
39
|
if cls.__notify is not None:
|
|
@@ -43,11 +42,11 @@ class UniqueTag(type):
|
|
|
43
42
|
|
|
44
43
|
def tagnames(cls) -> list[str]:
|
|
45
44
|
"""Return all tagnames of class Tag."""
|
|
46
|
-
return cls.__cache.keys()
|
|
45
|
+
return cls.__cache.keys() # type: ignore
|
|
47
46
|
|
|
48
|
-
def tags(cls) -> list:
|
|
47
|
+
def tags(cls) -> list['Tag']:
|
|
49
48
|
"""Return all tags of class Tag."""
|
|
50
|
-
return cls.__cache.values()
|
|
49
|
+
return cls.__cache.values() # type: ignore
|
|
51
50
|
|
|
52
51
|
def set_notify(cls, callback):
|
|
53
52
|
"""Set ONE routine to notify when new tags are added."""
|