pymscada 0.1.11b10__py3-none-any.whl → 0.2.0__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/alarms.py +353 -0
- pymscada/bus_client.py +6 -5
- pymscada/bus_server.py +14 -2
- pymscada/callout.py +206 -0
- pymscada/checkout.py +87 -89
- pymscada/console.py +4 -3
- pymscada/demo/__pycache__/__init__.cpython-311.pyc +0 -0
- pymscada/demo/alarms.yaml +5 -0
- pymscada/demo/callout.yaml +17 -0
- pymscada/demo/openweather.yaml +1 -1
- pymscada/demo/pymscada-alarms.service +16 -0
- pymscada/demo/pymscada-callout.service +16 -0
- pymscada/demo/pymscada-io-openweather.service +15 -0
- pymscada/demo/{pymscada-io-accuweather.service → pymscada-io-witsapi.service} +2 -2
- pymscada/demo/tags.yaml +4 -0
- pymscada/demo/witsapi.yaml +17 -0
- pymscada/files.py +3 -3
- pymscada/history.py +64 -8
- pymscada/iodrivers/openweather.py +131 -50
- pymscada/iodrivers/witsapi.py +217 -0
- pymscada/iodrivers/witsapi_POC.py +246 -0
- pymscada/main.py +1 -1
- pymscada/module_config.py +40 -14
- pymscada/opnotes.py +81 -16
- pymscada/pdf/__pycache__/__init__.cpython-311.pyc +0 -0
- pymscada/protocol_constants.py +51 -33
- pymscada/tag.py +0 -22
- pymscada/www_server.py +72 -17
- {pymscada-0.1.11b10.dist-info → pymscada-0.2.0.dist-info}/METADATA +9 -7
- {pymscada-0.1.11b10.dist-info → pymscada-0.2.0.dist-info}/RECORD +38 -25
- {pymscada-0.1.11b10.dist-info → pymscada-0.2.0.dist-info}/WHEEL +2 -1
- {pymscada-0.1.11b10.dist-info → pymscada-0.2.0.dist-info}/entry_points.txt +0 -3
- pymscada-0.2.0.dist-info/top_level.txt +1 -0
- {pymscada-0.1.11b10.dist-info → pymscada-0.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import aiohttp
|
|
2
|
+
import asyncio
|
|
3
|
+
import datetime
|
|
4
|
+
|
|
5
|
+
class WitsAPIClient:
|
|
6
|
+
def __init__(self, url, client_id, client_secret):
|
|
7
|
+
self.client_id = client_id
|
|
8
|
+
self.client_secret = client_secret
|
|
9
|
+
self.base_url = url
|
|
10
|
+
self.session = None
|
|
11
|
+
|
|
12
|
+
async def __aenter__(self):
|
|
13
|
+
"""Create session and get token on entry"""
|
|
14
|
+
self.session = aiohttp.ClientSession()
|
|
15
|
+
return self
|
|
16
|
+
|
|
17
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
18
|
+
"""Close session on exit"""
|
|
19
|
+
if self.session:
|
|
20
|
+
await self.session.close()
|
|
21
|
+
|
|
22
|
+
async def get_token(self):
|
|
23
|
+
"""Get a new OAuth token"""
|
|
24
|
+
auth_url = f"{self.base_url}/login/oauth2/token"
|
|
25
|
+
data = {
|
|
26
|
+
"grant_type": "client_credentials",
|
|
27
|
+
"client_id": self.client_id,
|
|
28
|
+
"client_secret": self.client_secret
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
headers = {
|
|
32
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
async with self.session.post(auth_url, data=data, headers=headers) as response:
|
|
37
|
+
if response.status == 200:
|
|
38
|
+
result = await response.json()
|
|
39
|
+
self.session.headers.update({
|
|
40
|
+
"Authorization": f"Bearer {result['access_token']}"
|
|
41
|
+
})
|
|
42
|
+
return result["access_token"]
|
|
43
|
+
else:
|
|
44
|
+
error_text = await response.text()
|
|
45
|
+
return None
|
|
46
|
+
except Exception as e:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
async def get(self, endpoint):
|
|
50
|
+
"""Make a GET request to the WITS API"""
|
|
51
|
+
url = f"{self.base_url}/{endpoint}"
|
|
52
|
+
try:
|
|
53
|
+
async with self.session.get(url) as response:
|
|
54
|
+
if response.status == 200:
|
|
55
|
+
result = await response.json()
|
|
56
|
+
return result
|
|
57
|
+
else:
|
|
58
|
+
error_text = await response.text()
|
|
59
|
+
return None
|
|
60
|
+
except Exception as e:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
async def get_schedules(self):
|
|
64
|
+
"""Get list of schedules for which pricing data is available"""
|
|
65
|
+
endpoint = "api/market-prices/v1/schedules"
|
|
66
|
+
return await self.get(endpoint)
|
|
67
|
+
|
|
68
|
+
async def get_nodes(self):
|
|
69
|
+
"""Get list of GXP/GIP nodes supported by this API"""
|
|
70
|
+
endpoint = "api/market-prices/v1/nodes"
|
|
71
|
+
return await self.get(endpoint)
|
|
72
|
+
|
|
73
|
+
async def get_schedule_prices(self, schedule='RTD', market_type='E', nodes=None,
|
|
74
|
+
back=None, forward=None, from_date=None, to_date=None,
|
|
75
|
+
island=None, offset=0):
|
|
76
|
+
"""Get prices for a single schedule
|
|
77
|
+
Args:
|
|
78
|
+
schedule: Schedule type (e.g. 'RTD' for Real Time Dispatch)
|
|
79
|
+
market_type: 'E' for energy prices, 'R' for reserve prices
|
|
80
|
+
nodes: List of node IDs to filter by
|
|
81
|
+
back: Number of trading periods to look back (1-48)
|
|
82
|
+
forward: Number of trading periods to look ahead (1-48)
|
|
83
|
+
from_date: Start datetime (RFC3339 format)
|
|
84
|
+
to_date: End datetime (RFC3339 format)
|
|
85
|
+
island: Filter by island ('NI' or 'SI')
|
|
86
|
+
offset: Pagination offset
|
|
87
|
+
"""
|
|
88
|
+
endpoint = f"api/market-prices/v1/schedules/{schedule}/prices"
|
|
89
|
+
params = {
|
|
90
|
+
'marketType': market_type,
|
|
91
|
+
'offset': offset
|
|
92
|
+
}
|
|
93
|
+
if nodes:
|
|
94
|
+
params['nodes'] = ','.join(nodes) if isinstance(nodes, list) else nodes
|
|
95
|
+
if back:
|
|
96
|
+
params['back'] = min(back, 48)
|
|
97
|
+
if forward:
|
|
98
|
+
params['forward'] = min(forward, 48)
|
|
99
|
+
if from_date:
|
|
100
|
+
params['from'] = from_date
|
|
101
|
+
if to_date:
|
|
102
|
+
params['to'] = to_date
|
|
103
|
+
if island:
|
|
104
|
+
params['island'] = island
|
|
105
|
+
query = '&'.join(f"{k}={v}" for k, v in params.items())
|
|
106
|
+
return await self.get(f"{endpoint}?{query}")
|
|
107
|
+
|
|
108
|
+
async def get_multi_schedule_prices(self, schedules, market_type='E', nodes=None,
|
|
109
|
+
back=None, forward=None, from_date=None,
|
|
110
|
+
to_date=None, island=None, offset=0):
|
|
111
|
+
"""Get prices across multiple schedules
|
|
112
|
+
Args:
|
|
113
|
+
schedules: List of schedule types
|
|
114
|
+
market_type: 'E' for energy prices, 'R' for reserve prices
|
|
115
|
+
nodes: List of node IDs to filter by
|
|
116
|
+
back: Number of trading periods to look back (1-48)
|
|
117
|
+
forward: Number of trading periods to look ahead (1-48)
|
|
118
|
+
from_date: Start datetime (RFC3339 format)
|
|
119
|
+
to_date: End datetime (RFC3339 format)
|
|
120
|
+
island: Filter by island ('NI' or 'SI')
|
|
121
|
+
offset: Pagination offset
|
|
122
|
+
"""
|
|
123
|
+
endpoint = "api/market-prices/v1/prices"
|
|
124
|
+
params = {
|
|
125
|
+
'schedules': ','.join(schedules) if isinstance(schedules, list) else schedules,
|
|
126
|
+
'marketType': market_type,
|
|
127
|
+
'offset': offset
|
|
128
|
+
}
|
|
129
|
+
if nodes:
|
|
130
|
+
params['nodes'] = ','.join(nodes) if isinstance(nodes, list) else nodes
|
|
131
|
+
if back:
|
|
132
|
+
params['back'] = min(back, 48)
|
|
133
|
+
if forward:
|
|
134
|
+
params['forward'] = min(forward, 48)
|
|
135
|
+
if from_date:
|
|
136
|
+
params['from'] = from_date
|
|
137
|
+
if to_date:
|
|
138
|
+
params['to'] = to_date
|
|
139
|
+
if island:
|
|
140
|
+
params['island'] = island
|
|
141
|
+
query = '&'.join(f"{k}={v}" for k, v in params.items())
|
|
142
|
+
return await self.get(f"{endpoint}?{query}")
|
|
143
|
+
|
|
144
|
+
def parse_prices(self, response):
|
|
145
|
+
"""Parse API response into structured price dictionary
|
|
146
|
+
Returns dict in format:
|
|
147
|
+
{node: {trading_time_utc: {schedule: [price, last_run_utc]}}}
|
|
148
|
+
"""
|
|
149
|
+
if not response:
|
|
150
|
+
return {}
|
|
151
|
+
prices = {}
|
|
152
|
+
for schedule_data in response:
|
|
153
|
+
schedule = schedule_data['schedule']
|
|
154
|
+
if 'prices' not in schedule_data:
|
|
155
|
+
continue
|
|
156
|
+
for price in schedule_data['prices']:
|
|
157
|
+
node = price['node']
|
|
158
|
+
trading_time = int(datetime.datetime.fromisoformat(
|
|
159
|
+
price['tradingDateTime'].replace('Z', '+00:00')
|
|
160
|
+
).timestamp())
|
|
161
|
+
last_run = int(datetime.datetime.fromisoformat(
|
|
162
|
+
price['lastRunTime'].replace('Z', '+00:00')
|
|
163
|
+
).timestamp())
|
|
164
|
+
|
|
165
|
+
if node not in prices:
|
|
166
|
+
prices[node] = {}
|
|
167
|
+
if trading_time not in prices[node]:
|
|
168
|
+
prices[node][trading_time] = {}
|
|
169
|
+
prices[node][trading_time][schedule] = [price['price'], last_run]
|
|
170
|
+
|
|
171
|
+
# Create RTD_forecast schedule
|
|
172
|
+
for node in prices:
|
|
173
|
+
for trading_time in prices[node]:
|
|
174
|
+
if 'RTD' in prices[node][trading_time]:
|
|
175
|
+
prices[node][trading_time]['RTD_forecast'] = prices[node][trading_time]['RTD']
|
|
176
|
+
else:
|
|
177
|
+
# Find most recent schedule by last run time
|
|
178
|
+
latest_schedule = None
|
|
179
|
+
latest_last_run = 0
|
|
180
|
+
for schedule in prices[node][trading_time]:
|
|
181
|
+
if prices[node][trading_time][schedule][1] > latest_last_run:
|
|
182
|
+
latest_last_run = prices[node][trading_time][schedule][1]
|
|
183
|
+
latest_schedule = schedule
|
|
184
|
+
if latest_schedule:
|
|
185
|
+
prices[node][trading_time]['RTD_forecast'] = \
|
|
186
|
+
prices[node][trading_time][latest_schedule]
|
|
187
|
+
|
|
188
|
+
return prices
|
|
189
|
+
|
|
190
|
+
def print_prices(self, prices):
|
|
191
|
+
"""Print prices in structured format with time information"""
|
|
192
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
193
|
+
now_ts = now.timestamp()
|
|
194
|
+
for node in sorted(prices.keys()):
|
|
195
|
+
print(f" - {node}:")
|
|
196
|
+
for trading_time in sorted(prices[node].keys()):
|
|
197
|
+
time_diff = trading_time - now_ts
|
|
198
|
+
# For future times on 30 minute boundaries, show half-hour intervals
|
|
199
|
+
if time_diff > 0 and trading_time % 1800 == 0:
|
|
200
|
+
half_hours = int(time_diff / 1800)
|
|
201
|
+
time_str = f"(+{half_hours})"
|
|
202
|
+
else:
|
|
203
|
+
# For past times or non-30min intervals, show actual time
|
|
204
|
+
dt = datetime.datetime.fromtimestamp(trading_time,
|
|
205
|
+
datetime.timezone.utc)
|
|
206
|
+
time_str = f"({dt.strftime('%Y-%m-%d %H:%M:%S')})"
|
|
207
|
+
|
|
208
|
+
print(f" - Trading Time UTC: {trading_time} {time_str}")
|
|
209
|
+
for schedule in sorted(prices[node][trading_time].keys()):
|
|
210
|
+
if schedule in ['RTD', 'PRSS', 'PRSL', 'RTD_forecast']:
|
|
211
|
+
price, last_run = prices[node][trading_time][schedule]
|
|
212
|
+
last_run_dt = datetime.datetime.fromtimestamp(
|
|
213
|
+
last_run, datetime.timezone.utc)
|
|
214
|
+
print(f" {schedule:12} Price: {price:8.2f}, "
|
|
215
|
+
f"Last Run: {last_run_dt.strftime('%H:%M:%S')}")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def main(config):
|
|
219
|
+
async with WitsAPIClient(url=config['url'],client_id=config['client_id'],
|
|
220
|
+
client_secret=config['client_secret']) as client:
|
|
221
|
+
token = await client.get_token()
|
|
222
|
+
if token:
|
|
223
|
+
multi_prices = await client.get_multi_schedule_prices(
|
|
224
|
+
schedules=config['schedules'],
|
|
225
|
+
nodes=config['gxp_list'],
|
|
226
|
+
back=config['back'],
|
|
227
|
+
forward=config['forward']
|
|
228
|
+
)
|
|
229
|
+
if multi_prices:
|
|
230
|
+
prices_dict = client.parse_prices(multi_prices)
|
|
231
|
+
client.print_prices(prices_dict)
|
|
232
|
+
await asyncio.sleep(1)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
CONFIG = {
|
|
236
|
+
'url': 'https://api.electricityinfo.co.nz',
|
|
237
|
+
'gxp_list': ['MAT1101', 'CYD2201', 'BEN2201'],
|
|
238
|
+
'schedules': ['RTD', 'PRSS', 'PRSL'],
|
|
239
|
+
'back': 2,
|
|
240
|
+
'forward': 72,
|
|
241
|
+
'client_id': 'xx',
|
|
242
|
+
'client_secret': 'xx'
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if __name__ == "__main__":
|
|
246
|
+
asyncio.run(main(CONFIG))
|
pymscada/main.py
CHANGED
|
@@ -39,7 +39,7 @@ async def run():
|
|
|
39
39
|
if not options.verbose:
|
|
40
40
|
root_logger.setLevel(logging.WARNING)
|
|
41
41
|
factory = ModuleFactory()
|
|
42
|
-
module = factory.create_module(options
|
|
42
|
+
module = factory.create_module(options)
|
|
43
43
|
if module is not None:
|
|
44
44
|
if hasattr(module, 'start'):
|
|
45
45
|
await module.start()
|
pymscada/module_config.py
CHANGED
|
@@ -5,19 +5,18 @@ from textwrap import dedent
|
|
|
5
5
|
from importlib.metadata import version
|
|
6
6
|
import logging
|
|
7
7
|
from pymscada.config import Config
|
|
8
|
-
from pymscada.console import Console
|
|
9
8
|
|
|
10
9
|
class ModuleArgument:
|
|
11
10
|
def __init__(self, args: tuple[str, ...], kwargs: dict[str, Any]):
|
|
12
11
|
self.args = args
|
|
13
|
-
self.kwargs = kwargs
|
|
12
|
+
self.kwargs = kwargs
|
|
14
13
|
|
|
15
14
|
class ModuleDefinition:
|
|
16
15
|
"""Defines a module's configuration and behavior."""
|
|
17
|
-
def __init__(self, name: str, help: str, module_class:
|
|
16
|
+
def __init__(self, name: str, help: str, module_class: Any, *,
|
|
18
17
|
config: bool = True, tags: bool = True,
|
|
19
18
|
epilog: Optional[str] = None,
|
|
20
|
-
extra_args: list[ModuleArgument] = None,
|
|
19
|
+
extra_args: Optional[list[ModuleArgument]] = None,
|
|
21
20
|
await_future: bool = True):
|
|
22
21
|
self.name = name
|
|
23
22
|
self.help = help
|
|
@@ -59,6 +58,17 @@ def create_module_registry():
|
|
|
59
58
|
module_class='pymscada.opnotes:OpNotes',
|
|
60
59
|
tags=False
|
|
61
60
|
),
|
|
61
|
+
ModuleDefinition(
|
|
62
|
+
name='alarms',
|
|
63
|
+
help='alarms',
|
|
64
|
+
module_class='pymscada.alarms:Alarms'
|
|
65
|
+
),
|
|
66
|
+
ModuleDefinition(
|
|
67
|
+
name='callout',
|
|
68
|
+
help='alarm callout notifications',
|
|
69
|
+
module_class='pymscada.callout:Callout',
|
|
70
|
+
tags=False
|
|
71
|
+
),
|
|
62
72
|
ModuleDefinition(
|
|
63
73
|
name='validate',
|
|
64
74
|
help='validate config files',
|
|
@@ -75,9 +85,10 @@ def create_module_registry():
|
|
|
75
85
|
ModuleDefinition(
|
|
76
86
|
name='checkout',
|
|
77
87
|
help='create example config files',
|
|
78
|
-
module_class='pymscada.checkout:
|
|
88
|
+
module_class='pymscada.checkout:Checkout',
|
|
79
89
|
config=False,
|
|
80
90
|
tags=False,
|
|
91
|
+
await_future=False,
|
|
81
92
|
epilog=dedent("""
|
|
82
93
|
To add to systemd:
|
|
83
94
|
su -
|
|
@@ -145,6 +156,18 @@ def create_module_registry():
|
|
|
145
156
|
module_class='pymscada.iodrivers.snmp_client:SnmpClient',
|
|
146
157
|
tags=False
|
|
147
158
|
),
|
|
159
|
+
ModuleDefinition(
|
|
160
|
+
name='witsapi',
|
|
161
|
+
help='poll WITS GXP pricing real time dispatch and forecast',
|
|
162
|
+
module_class='pymscada.iodrivers.witsapi:WitsAPIClient',
|
|
163
|
+
tags=False,
|
|
164
|
+
epilog=dedent("""
|
|
165
|
+
WITS_CLIENT_ID and WITS_CLIENT_SECRET can be set in the wits.yaml
|
|
166
|
+
or as environment variables:
|
|
167
|
+
vi ~/.bashrc
|
|
168
|
+
export WITS_CLIENT_ID='your_client_id'
|
|
169
|
+
export WITS_CLIENT_SECRET='your_client_secret'""")
|
|
170
|
+
),
|
|
148
171
|
ModuleDefinition(
|
|
149
172
|
name='console',
|
|
150
173
|
help='interactive bus console',
|
|
@@ -156,12 +179,12 @@ def create_module_registry():
|
|
|
156
179
|
like to see correctly typed values and set values."""),
|
|
157
180
|
extra_args=[
|
|
158
181
|
ModuleArgument(
|
|
159
|
-
('-p', '--port'),
|
|
182
|
+
('-p', '--bus-port'),
|
|
160
183
|
{'action': 'store', 'type': int, 'default': 1324,
|
|
161
184
|
'help': 'connect to port (default: 1324)'}
|
|
162
185
|
),
|
|
163
186
|
ModuleArgument(
|
|
164
|
-
('-i', '--ip'),
|
|
187
|
+
('-i', '--bus-ip'),
|
|
165
188
|
{'action': 'store', 'default': 'localhost',
|
|
166
189
|
'help': 'connect to ip address (default: localhost)'}
|
|
167
190
|
)
|
|
@@ -208,9 +231,11 @@ class ModuleFactory:
|
|
|
208
231
|
parser.add_argument(*arg.args, **arg.kwargs)
|
|
209
232
|
return parser
|
|
210
233
|
|
|
211
|
-
def create_module(self,
|
|
234
|
+
def create_module(self, options: argparse.Namespace):
|
|
212
235
|
"""Create a module instance based on configuration and options."""
|
|
213
|
-
|
|
236
|
+
if options.module_name not in self.modules:
|
|
237
|
+
raise ValueError(f'{options.module_name} does not exist')
|
|
238
|
+
module_def = self.modules[options.module_name]
|
|
214
239
|
logging.info(f'Python Mobile SCADA {version("pymscada")} '
|
|
215
240
|
f'starting {module_def.name}')
|
|
216
241
|
# Import the module class only when needed
|
|
@@ -220,13 +245,14 @@ class ModuleFactory:
|
|
|
220
245
|
actual_class = getattr(module, class_name)
|
|
221
246
|
else:
|
|
222
247
|
actual_class = module_def.module_class
|
|
223
|
-
|
|
224
248
|
kwargs = {}
|
|
225
249
|
if module_def.config:
|
|
226
|
-
kwargs.update(Config(options.config))
|
|
250
|
+
kwargs.update(Config(options.config))
|
|
227
251
|
if module_def.tags:
|
|
228
252
|
kwargs['tag_info'] = dict(Config(options.tags))
|
|
229
|
-
if
|
|
230
|
-
|
|
231
|
-
|
|
253
|
+
if module_def.extra_args:
|
|
254
|
+
for arg in module_def.extra_args:
|
|
255
|
+
arg_name = arg.args[-1].lstrip('-').replace('-', '_')
|
|
256
|
+
if hasattr(options, arg_name):
|
|
257
|
+
kwargs[arg_name] = getattr(options, arg_name)
|
|
232
258
|
return actual_class(**kwargs)
|
pymscada/opnotes.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Operator Notes."""
|
|
2
2
|
import logging
|
|
3
3
|
import sqlite3 # note that sqlite3 has blocking calls
|
|
4
|
+
import socket
|
|
4
5
|
from pymscada.bus_client import BusClient
|
|
5
6
|
from pymscada.tag import Tag
|
|
6
7
|
|
|
@@ -8,36 +9,95 @@ from pymscada.tag import Tag
|
|
|
8
9
|
class OpNotes:
|
|
9
10
|
"""Connect to bus_ip:bus_port, store and provide Operator Notes."""
|
|
10
11
|
|
|
11
|
-
def __init__(
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
bus_ip: str = '127.0.0.1',
|
|
15
|
+
bus_port: int = 1324,
|
|
16
|
+
db: str | None = None,
|
|
17
|
+
table: str = 'opnotes',
|
|
18
|
+
rta_tag: str = '__opnotes__'
|
|
19
|
+
) -> None:
|
|
14
20
|
"""
|
|
15
21
|
Connect to bus_ip:bus_port, serve and update operator notes database.
|
|
16
22
|
|
|
17
|
-
|
|
18
|
-
|
|
23
|
+
Open an Operator notes table, creating if necessary. Provide additions,
|
|
24
|
+
updates, deletions and history requests via the rta_tag.
|
|
19
25
|
|
|
20
26
|
Event loop must be running.
|
|
27
|
+
|
|
28
|
+
For testing only: bus_ip can be None to skip connection.
|
|
21
29
|
"""
|
|
22
30
|
if db is None:
|
|
23
31
|
raise SystemExit('OpNotes db must be defined')
|
|
32
|
+
if bus_ip is None:
|
|
33
|
+
logging.warning('OpNotes has bus_ip=None, only use for testing')
|
|
34
|
+
else:
|
|
35
|
+
try:
|
|
36
|
+
socket.gethostbyname(bus_ip)
|
|
37
|
+
except socket.gaierror as e:
|
|
38
|
+
raise ValueError(f'Cannot resolve IP/hostname: {e}')
|
|
39
|
+
if not isinstance(bus_port, int):
|
|
40
|
+
raise TypeError('bus_port must be an integer')
|
|
41
|
+
if not 1024 <= bus_port <= 65535:
|
|
42
|
+
raise ValueError('bus_port must be between 1024 and 65535')
|
|
43
|
+
if not isinstance(rta_tag, str) or not rta_tag:
|
|
44
|
+
raise ValueError('rta_tag must be a non-empty string')
|
|
45
|
+
if not isinstance(table, str) or not table:
|
|
46
|
+
raise ValueError('table must be a non-empty string')
|
|
47
|
+
|
|
24
48
|
logging.warning(f'OpNotes {bus_ip} {bus_port} {db} {rta_tag}')
|
|
25
49
|
self.connection = sqlite3.connect(db)
|
|
26
50
|
self.table = table
|
|
27
51
|
self.cursor = self.connection.cursor()
|
|
52
|
+
self._init_table()
|
|
53
|
+
self.busclient = BusClient(bus_ip, bus_port, module='OpNotes')
|
|
54
|
+
self.rta = Tag(rta_tag, dict)
|
|
55
|
+
self.rta.value = {}
|
|
56
|
+
self.busclient.add_callback_rta(rta_tag, self.rta_cb)
|
|
57
|
+
|
|
58
|
+
def _init_table(self):
|
|
59
|
+
"""Initialize or upgrade the database table schema."""
|
|
28
60
|
query = (
|
|
29
61
|
'CREATE TABLE IF NOT EXISTS ' + self.table +
|
|
30
62
|
'(id INTEGER PRIMARY KEY ASC, '
|
|
31
63
|
'date_ms INTEGER, '
|
|
32
64
|
'site TEXT, '
|
|
33
65
|
'by TEXT, '
|
|
34
|
-
'note TEXT
|
|
66
|
+
'note TEXT, '
|
|
67
|
+
'abnormal INTEGER)'
|
|
35
68
|
)
|
|
36
69
|
self.cursor.execute(query)
|
|
37
|
-
self.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
70
|
+
self.cursor.execute(f"PRAGMA table_info({self.table})")
|
|
71
|
+
columns = {col[1]: col[2] for col in self.cursor.fetchall()}
|
|
72
|
+
if 'abnormal' not in columns:
|
|
73
|
+
# Add abnormal as INTEGER from original schema
|
|
74
|
+
logging.warning(f'Upgrading {self.table} schema to include '
|
|
75
|
+
'abnormal INTEGER, CANNOT revert automatically!')
|
|
76
|
+
self.cursor.execute(
|
|
77
|
+
f'ALTER TABLE {self.table} ADD COLUMN abnormal INTEGER')
|
|
78
|
+
elif columns['abnormal'].upper() == 'BOOLEAN':
|
|
79
|
+
# Change abnormal from BOOLEAN to INTEGER
|
|
80
|
+
logging.warning(f'Upgrading {self.table} abnormal from BOOLEAN to '
|
|
81
|
+
'INTEGER, CANNOT revert automatically!')
|
|
82
|
+
self.cursor.execute(
|
|
83
|
+
f'CREATE TABLE {self.table}_new '
|
|
84
|
+
'(id INTEGER PRIMARY KEY ASC, '
|
|
85
|
+
'date_ms INTEGER, '
|
|
86
|
+
'site TEXT, '
|
|
87
|
+
'by TEXT, '
|
|
88
|
+
'note TEXT, '
|
|
89
|
+
'abnormal INTEGER)'
|
|
90
|
+
)
|
|
91
|
+
self.cursor.execute(
|
|
92
|
+
f'INSERT INTO {self.table}_new '
|
|
93
|
+
f'SELECT id, date_ms, site, by, note, '
|
|
94
|
+
f'CASE WHEN abnormal THEN 1 ELSE 0 END '
|
|
95
|
+
f'FROM {self.table}'
|
|
96
|
+
)
|
|
97
|
+
self.cursor.execute(f'DROP TABLE {self.table}')
|
|
98
|
+
self.cursor.execute(
|
|
99
|
+
f'ALTER TABLE {self.table}_new RENAME TO {self.table}'
|
|
100
|
+
)
|
|
41
101
|
|
|
42
102
|
def rta_cb(self, request):
|
|
43
103
|
"""Respond to Request to Author and publish on rta_tag as needed."""
|
|
@@ -48,8 +108,10 @@ class OpNotes:
|
|
|
48
108
|
logging.info(f'add {request}')
|
|
49
109
|
with self.connection:
|
|
50
110
|
self.cursor.execute(
|
|
51
|
-
f'INSERT INTO {self.table}
|
|
52
|
-
'
|
|
111
|
+
f'INSERT INTO {self.table} '
|
|
112
|
+
'(date_ms, site, by, note, abnormal) '
|
|
113
|
+
'VALUES(:date_ms, :site, :by, :note, :abnormal) '
|
|
114
|
+
'RETURNING *;',
|
|
53
115
|
request)
|
|
54
116
|
res = self.cursor.fetchone()
|
|
55
117
|
self.rta.value = {
|
|
@@ -57,7 +119,8 @@ class OpNotes:
|
|
|
57
119
|
'date_ms': res[1],
|
|
58
120
|
'site': res[2],
|
|
59
121
|
'by': res[3],
|
|
60
|
-
'note': res[4]
|
|
122
|
+
'note': res[4],
|
|
123
|
+
'abnormal': res[5]
|
|
61
124
|
}
|
|
62
125
|
except sqlite3.IntegrityError as error:
|
|
63
126
|
logging.warning(f'OpNotes rta_cb {error}')
|
|
@@ -67,14 +130,15 @@ class OpNotes:
|
|
|
67
130
|
with self.connection:
|
|
68
131
|
self.cursor.execute(
|
|
69
132
|
f'REPLACE INTO {self.table} VALUES(:id, :date_ms, '
|
|
70
|
-
':site, :by, :note) RETURNING *;', request)
|
|
133
|
+
':site, :by, :note, :abnormal) RETURNING *;', request)
|
|
71
134
|
res = self.cursor.fetchone()
|
|
72
135
|
self.rta.value = {
|
|
73
136
|
'id': res[0],
|
|
74
137
|
'date_ms': res[1],
|
|
75
138
|
'site': res[2],
|
|
76
139
|
'by': res[3],
|
|
77
|
-
'note': res[4]
|
|
140
|
+
'note': res[4],
|
|
141
|
+
'abnormal': res[5]
|
|
78
142
|
}
|
|
79
143
|
except sqlite3.IntegrityError as error:
|
|
80
144
|
logging.warning(f'OpNotes rta_cb {error}')
|
|
@@ -101,7 +165,8 @@ class OpNotes:
|
|
|
101
165
|
'date_ms': res[1],
|
|
102
166
|
'site': res[2],
|
|
103
167
|
'by': res[3],
|
|
104
|
-
'note': res[4]
|
|
168
|
+
'note': res[4],
|
|
169
|
+
'abnormal': res[5]
|
|
105
170
|
}
|
|
106
171
|
except sqlite3.IntegrityError as error:
|
|
107
172
|
logging.warning(f'OpNotes rta_cb {error}')
|
|
Binary file
|
pymscada/protocol_constants.py
CHANGED
|
@@ -1,44 +1,62 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Protocol description and protocol constants.
|
|
3
3
|
|
|
4
|
-
Bus holds a tag forever,
|
|
5
|
-
< 1000 forever, otherwise wipes periodically. So assign tagnames once, at the
|
|
6
|
-
start of your program; use RTA as an update messenger, share whole structures
|
|
7
|
-
rarely.
|
|
4
|
+
Bus holds a tag forever, assigning a new ID for each new tagname.
|
|
8
5
|
|
|
9
|
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
-
|
|
16
|
-
- data size of 8-bit char
|
|
6
|
+
Bus protocol message format:
|
|
7
|
+
- version: 8-bit unsigned int == 0x01
|
|
8
|
+
- command: 8-bit unsigned int
|
|
9
|
+
- tag_id: 16-bit unsigned int (0 is valid for ID requests)
|
|
10
|
+
- size: 16-bit unsigned int
|
|
11
|
+
- time_us: 64-bit unsigned int, UTC microseconds
|
|
12
|
+
- data: variable length bytes of size
|
|
17
13
|
|
|
18
|
-
|
|
19
|
-
- CMD.ID
|
|
20
|
-
-
|
|
21
|
-
- CMD.
|
|
22
|
-
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
27
|
-
- CMD.
|
|
28
|
-
|
|
29
|
-
- CMD.
|
|
30
|
-
-
|
|
31
|
-
|
|
32
|
-
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
-
|
|
37
|
-
- CMD.
|
|
14
|
+
Commands:
|
|
15
|
+
- CMD.ID: Query/inform tag ID
|
|
16
|
+
- Request: data is tagname as bytes
|
|
17
|
+
- Reply: CMD.ID with assigned tag_id and tagname as data
|
|
18
|
+
- Error: CMD.ERR if tagname undefined
|
|
19
|
+
|
|
20
|
+
- CMD.SET: Set a tag value
|
|
21
|
+
- Data format: TYPE byte + typed value
|
|
22
|
+
- No reply
|
|
23
|
+
- Error: CMD.ERR if tag_id invalid
|
|
24
|
+
|
|
25
|
+
- CMD.GET: Get a tag value
|
|
26
|
+
- Request: empty data
|
|
27
|
+
- Reply: CMD.SET with current value
|
|
28
|
+
- Error: CMD.ERR if tag_id invalid
|
|
29
|
+
|
|
30
|
+
- CMD.RTA: Request to author
|
|
31
|
+
- Request: JSON encoded request
|
|
32
|
+
- Reply: Comes from target client, not server
|
|
33
|
+
- Error: CMD.ERR if tag_id invalid or target gone
|
|
34
|
+
|
|
35
|
+
- CMD.SUB: Subscribe to tag updates
|
|
36
|
+
- Request: empty data
|
|
37
|
+
- Reply: CMD.SET with current value
|
|
38
|
+
- Error: CMD.ERR if tag_id invalid
|
|
39
|
+
|
|
40
|
+
- CMD.UNSUB: Unsubscribe from tag
|
|
41
|
+
- No reply
|
|
42
|
+
- Error: CMD.ERR if tag_id invalid
|
|
43
|
+
|
|
44
|
+
- CMD.LIST: List tags
|
|
45
|
+
- Empty data: tags newer than time_us
|
|
46
|
+
- ^text: tags starting with text
|
|
47
|
+
- text$: tags ending with text
|
|
48
|
+
- text: tags containing text
|
|
49
|
+
- Reply: CMD.LIST with space-separated tagnames
|
|
50
|
+
|
|
51
|
+
- CMD.LOG: Log message
|
|
52
|
+
- Data: Message to log (max 300 bytes)
|
|
53
|
+
- Updates __bus__ tag with client address and message
|
|
54
|
+
|
|
55
|
+
Large messages are split into MAX_LEN chunks. Final chunk size < MAX_LEN.
|
|
38
56
|
"""
|
|
39
57
|
|
|
40
58
|
# Tuning constants
|
|
41
|
-
MAX_LEN = 65535 - 14 #
|
|
59
|
+
MAX_LEN = 65535 - 14 # Maximum data size per message
|
|
42
60
|
|
|
43
61
|
from enum import IntEnum
|
|
44
62
|
|
pymscada/tag.py
CHANGED
|
@@ -18,28 +18,6 @@ TYPES = {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
def tag_for_web(tagname: str, tag: dict):
|
|
22
|
-
"""Correct tag dictionary in place to be suitable for web client."""
|
|
23
|
-
tag['name'] = tagname
|
|
24
|
-
tag['id'] = None
|
|
25
|
-
if 'desc' not in tag:
|
|
26
|
-
tag['desc'] = tag.name
|
|
27
|
-
if 'multi' in tag:
|
|
28
|
-
tag['type'] = 'int'
|
|
29
|
-
else:
|
|
30
|
-
if 'type' not in tag:
|
|
31
|
-
tag['type'] = 'float'
|
|
32
|
-
else:
|
|
33
|
-
if tag['type'] not in TYPES:
|
|
34
|
-
tag['type'] = 'str'
|
|
35
|
-
if tag['type'] == 'int':
|
|
36
|
-
tag['dp'] = 0
|
|
37
|
-
elif tag['type'] == 'float' and 'dp' not in tag:
|
|
38
|
-
tag['dp'] = 2
|
|
39
|
-
elif tag['type'] == 'str' and 'dp' in tag:
|
|
40
|
-
del tag['dp']
|
|
41
|
-
|
|
42
|
-
|
|
43
21
|
class UniqueTag(type):
|
|
44
22
|
"""Super Tag class only create unique tags for unique tag names."""
|
|
45
23
|
|