-
Notifications
You must be signed in to change notification settings - Fork 0
/
scx00.py
356 lines (253 loc) · 11.4 KB
/
scx00.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
"""SCx00.py
Python module for reading and writing data from/to Eaton Power SC200 and SC300 Controllers.
I don't know whether it also works with the SC100 model as I don't have access to one.
The module implements a simple wrapper for the Eaton SCx00 XMLRPC interface. The SCx00 class
provides dictionary style access to all data items.
No error checking is done and all XMLRPC errors are passed on to the caller.
Status: Works for me ;-)
Markus Juenemann
Big thanks to 'The-Godfather' for having figured out how to make xmlrpc calls with
cookies and 'GermainZ' for having asked the question on Stackoverflow.
The (Safe)CookieTransport implementations below are based on their code.
"""
__author__ = 'Markus Juenemann <[email protected]>'
__version__ = '0.1.0'
__license__ = 'BSD 2-Clause'
# Things that are needed somewhere else.
#
import urllib.parse
import xmlrpc.client
import ssl
DEBUG = False
TIMEOUT = 10
# The Eaton SCx00 Controller wants authenication credentials sent as cookies.
# As the default transports do not support this we have to create custom ones.
#
class SafeCookieTransport(xmlrpc.client.SafeTransport):
def __init__(self, context=None, username='', password='', **kwargs):
self.username = username
self.password = password
super().__init__(context=context, **kwargs)
def send_headers(self, connection ,headers):
cookie = 'delimiter=%2C'
if self.username and self.password:
cookie = f'name={self.username}; pwd={self.password}; ' + cookie
connection.putheader('Cookie', cookie)
connection.timeout = TIMEOUT
super().send_headers(connection, headers)
class CookieTransport(xmlrpc.client.Transport):
def __init__(self, username='', password='', **kwargs):
self.username = username
self.password = password
super().__init__(**kwargs)
def send_headers(self, connection ,headers):
cookie = 'delimiter=%2C'
if self.username and self.password:
cookie = f'name={self.username}; pwd={self.password}; ' + cookie
connection.putheader('Cookie', cookie)
connection.timeout = TIMEOUT
super().send_headers(connection, headers)
class SCx00:
"""Represents a connection to an Eaton Powerware SCx00 Controller.
>>> client = SCx00('10.1.2.3')
Instances provides dictionary-style access to the data items of the conroller.
>>> client['Site-Name']
Site-12345
Write access probbaly requires to authenticate first. Either provide username
and password as keywork arguments when creating an instance or call the
``SCx00.login()`` method.
>>> client.login('myusername', 'mypassword')
>>> client['Site-Name']
Site-12345
>>> client['Site-Name'] = 'Site-98765'
>>> client['Site-Name']
Site-98765
Returned values may have to be cast into their appropriate type. Converting
an integer into an IPv4 address actually works as IPv4 addresses area really
just 32 bit integers.
>>> client['IP-Address']
167838211
>>> import ipaddress
>>> ipaddress.IPv4Address(client['IP-Address'])
IPv4Address('10.1.2.3')
Enumerations are returned as their integer value (1=Disable, 1=All, 2=Read Only, 3=V3 Only).
>>> client['SNMP-Access']
3
Boolean values are enumerations (0=Disabled, 1=Enabled) which fortunately can be directly
converted into the proper Python type and value.
>>> client['HTTPS-Access']
1
>>> bool(client['HTTPS-Access'])
True
>>> client['HTTP-Access']
0
>>> bool(client['HTTP-Access'])
False
Date/time values are easy to convert, too.
>>> client['Next-Battery-Test-Start-Time']
1639261885
>>> import datetime
>>> datetime.datetime.fromtimestamp(client['Next-Battery-Test-Start-Time'])
datetime.datetime(2021, 12, 12, 9, 31, 25)
Tabular can be accessed through a special slicing notation.
>>> client['DI-Name:1-8']
['Surge Diverter Fault', 'Surge Filter Fault', 'UPS Fault', 'Battery Isolated',
'Panel Temp High', 'Door Alarm', 'UPS Mains Power Fail', 'Battery Fuse Fail']
It is up to the user to provide valid values for the lower and upper bounds of
a slice as these are passed varbatim to the controller's XMLRPC interface.
Please note that some tables index data starting with 0, others with 1. For
example, 'DI-Name' is indexed starting with 1.
>>> client['DI-Name:0-8']
Traceback (most recent call last):
File "scx00.py", line 331, in <module>
print(client['DI-Name:0-10'])
File "scx00.py", line 291, in __getitem__
raise KeyError(f"{item} -> {resp}")
KeyError: "DI-Name:0-10 -> [[{'faultCode': 4, 'faultString': 'Exception: Index out of range on access to item DI-Name'}]]"
Individual entries can be accessed directly which is the only way to update an
item in a table.
>>> client['DI-Name:6']
Door Alarm
>>> client['DI-Name:6'] = 'Door Open Alarm'
>>> client['DI-Name:6']
Door Open Alarm
Unknown items will raise a KeyError exception.
>>> client['No-Such-Item']
Traceback (most recent call last):
File "scx00.py", line 334, in <module>
print(client['No-Such-Item'])
File "scx00.py", line 256, in __getitem__
raise KeyError(f"{item} -> {resp}")
KeyError: "No-Such-Item -> [{'faultCode': 3, 'faultString': 'Unknown Item'}]"
Arguments:
controller -- Hostname or IP Address of the controller.
Keyword Arguments:
username -- Username for authentication.
password -- Password for authentication.
port -- The port the controller listens on. Defaults to 443.
proto -- Either 'http' or 'https'. Defaults to 'https'.
If 'username' and 'password' are not given, communications with the
SCx000 controller will be unauthenticated. Depending on the
configuration of the SCx00 controller this may be limited to
read-only access or no access at all.
"""
def __init__(self, controller, proto=None, port=0, username='', password=''):
assert isinstance(port, int)
assert proto in ("http", "https", None)
self.controller = controller
self.login(username, password)
if port == 80 and proto is None:
self.port = port
self.proto = 'http'
elif port == 443 and proto is None:
self.port = port
self.proto = 'https'
elif proto == 'https' and port is 0:
self.port = 443
self.proto = proto
elif proto == 'http' and port is 0:
self.port = 80
self.proto = proto
else:
self.port = 443
self.proto = 'https'
def login(self, username, password):
"""Store username and password for later(!) authentication."""
self.username = username
self.password = password
@property
def url(self):
return urllib.parse.urlunparse((self.proto, f"{self.controller}:{self.port}", "xmlrpc", None, None, None))
@property
def transport(self):
if self.proto == 'https':
return SafeCookieTransport(context=ssl._create_unverified_context(), username=self.username, password=self.password)
else:
return CookieTransport(username=self.username, password=self.password)
def __getitem__(self, item):
"""Read data item(s) from the controller.
"""
assert isinstance(item ,str)
with xmlrpc.client.ServerProxy(self.url, transport=self.transport, verbose=DEBUG) as proxy:
resp = proxy.db.get([item])
"""The response will always be a list. Examples:
Single items:
[171027852]
['CTRL-12345']
List of items, possible with "repeat" markers.
[['Door Alarm', 'Cabinet Fan Active', 'Surge Filter Alarm', 'Surge Diverter Alarm', 'Digital Input 5']]
[[0, {'repeatLast': 4}]]
Single items or lists contain fault messages.
[['Low Float', ..., 'Normal Charge', {'faultCode': 4, 'faultString': 'Exception: Index out of range on access to item Alarm-Name'}]]
[{'faultCode': 3, 'faultString': 'Unknown Item'}]
"""
# I don't know what an empty response could mean. Does the controller ever return
# an empty response?
#
if not resp:
raise KeyError(f"{item} -> {resp}")
# Ensure that the resopnse is a list.
#
if not isinstance(resp, list):
raise KeyError(f"{item} -> {resp}")
# Extract the first item from the list as it contains the value.
#
value = resp[0]
# The server returned an error if the first item in the list is a dictionary.
#
if isinstance(value, dict):
raise KeyError(f"{item} -> {resp}")
# Return elementary types
#
elif isinstance(value, str):
return value
elif isinstance(value, int):
return value
elif isinstance(value, float):
return value
# The result may be a list. If so, every lement of the list must be
# checked for fault messages and repeat markers.
#
elif isinstance(value, list):
value2 = []
last = None
for v in value:
# Dictionaries are special messages returned by the controller but everything
# else are regular values.
#
if not isinstance(v, dict):
value2.append(v)
last = v
# Handle {'repeatLast': 58}
#
elif 'repeatLast' in v:
value2 += [last] * v['repeatLast']
# Any faults cause a KeyError.
# {'faultCode': 4, 'faultString': 'Exception: Index out of range on access to item ...'}
#
elif 'faultCode' in v:
raise KeyError(f"{item} -> {resp}")
# Any other error.
#
else:
raise KeyError(f"{item} -> {resp}")
# Return the result list if it has not been done yet.
#
return value2
def __setitem__(self, item, value):
with xmlrpc.client.ServerProxy(self.url, transport=self.transport, verbose=DEBUG) as proxy:
resp = proxy.db.set({item: value})
if not isinstance(resp, dict):
raise KeyError(f"{item} -> {resp}")
elif resp.get('resultString', '') != 'OK':
raise KeyError(f"{item} -> {resp}")
def get(self, item, default=None):
# WARNING: It is the caller's responsibility to set `default` correclt for tables:
# client.get('Di-Name:1-10', [None]*10)
try:
return self[item[0]]
except KeyError:
return default
# Aliases
#
SCX00 = SC200 = SC300 = SCx00