controlmyspa 4.0.1__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.
- controlmyspa/__init__.py +7 -0
- controlmyspa/controlmyspa.py +480 -0
- controlmyspa/debug.py +47 -0
- controlmyspa/example.py +68 -0
- controlmyspa/noxfile.py +32 -0
- controlmyspa-4.0.1.dist-info/METADATA +48 -0
- controlmyspa-4.0.1.dist-info/RECORD +10 -0
- controlmyspa-4.0.1.dist-info/WHEEL +5 -0
- controlmyspa-4.0.1.dist-info/licenses/LICENSE +21 -0
- controlmyspa-4.0.1.dist-info/top_level.txt +1 -0
controlmyspa/__init__.py
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Python module to get metrics from and control Balboa ControlMySpa whirlpools
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SpaOfflineError(Exception):
|
|
12
|
+
"""Raised when the spa API response does not contain 'currentState',
|
|
13
|
+
which typically indicates the spa gateway is offline."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ControlMySpa:
|
|
17
|
+
"""
|
|
18
|
+
Class representing Balboa ControlMySpa whirlpools
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
_email = None
|
|
22
|
+
_password = None
|
|
23
|
+
|
|
24
|
+
def __init__(self, email, password, spa_offset=0):
|
|
25
|
+
"""
|
|
26
|
+
Initialize connection to Balboa ControlMySpa cloud API
|
|
27
|
+
:param email: email address used to log in
|
|
28
|
+
:param password: password used to log in
|
|
29
|
+
:param spa_offset: which spa to use if the user has access to multiple.
|
|
30
|
+
Starts and defaults to zero.
|
|
31
|
+
"""
|
|
32
|
+
self._email = email
|
|
33
|
+
self._password = password
|
|
34
|
+
self._spa_offset = spa_offset
|
|
35
|
+
|
|
36
|
+
"""
|
|
37
|
+
2023-12-13: iot.controlmyspa.com has a new TLS certificate, probably since
|
|
38
|
+
June 2023. This certificate is signed by digicert, but there is an intermediate
|
|
39
|
+
certificate missing in the python certifi trust store and the server does not
|
|
40
|
+
provide it (anymore?). Instead of disabling the TLS certificate validation, we
|
|
41
|
+
download the intermediate certificate from digicert over a successfully
|
|
42
|
+
verified TLS connection and add it to the local trust store. Sorry for the hack."""
|
|
43
|
+
"""
|
|
44
|
+
try:
|
|
45
|
+
self._get_idm()
|
|
46
|
+
except requests.exceptions.SSLError:
|
|
47
|
+
print("TLS certificate missing, downloading to " + certifi.where())
|
|
48
|
+
customca = requests.get(
|
|
49
|
+
"https://cacerts.digicert.com/RapidSSLTLSRSACAG1.crt.pem", timeout=10
|
|
50
|
+
).content
|
|
51
|
+
cafile = certifi.where()
|
|
52
|
+
with open(cafile, "ab") as outfile:
|
|
53
|
+
outfile.write(b"\n")
|
|
54
|
+
outfile.write(customca)
|
|
55
|
+
outfile.close()
|
|
56
|
+
"""
|
|
57
|
+
# log in and fetch pool info
|
|
58
|
+
self._get_idm()
|
|
59
|
+
self._do_login()
|
|
60
|
+
self._get_info()
|
|
61
|
+
|
|
62
|
+
def _get_idm(self):
|
|
63
|
+
"""
|
|
64
|
+
Get URL and basic auth to log in to IDM
|
|
65
|
+
"""
|
|
66
|
+
response = requests.get(
|
|
67
|
+
"https://iot.controlmyspa.com/idm/tokenEndpoint", timeout=10
|
|
68
|
+
)
|
|
69
|
+
if response.status_code != requests.codes.ok:
|
|
70
|
+
logging.error("error from controlmyspa API: %s", response.text)
|
|
71
|
+
response.raise_for_status()
|
|
72
|
+
self._idm = response.json()
|
|
73
|
+
return self._idm
|
|
74
|
+
|
|
75
|
+
def _do_login(self):
|
|
76
|
+
"""
|
|
77
|
+
Log in and get API access tokens
|
|
78
|
+
"""
|
|
79
|
+
response = requests.post(
|
|
80
|
+
self._idm["_links"]["tokenEndpoint"]["href"],
|
|
81
|
+
data={
|
|
82
|
+
"grant_type": "password",
|
|
83
|
+
"password": self._password,
|
|
84
|
+
"scope": "openid user_name",
|
|
85
|
+
"email": self._email,
|
|
86
|
+
},
|
|
87
|
+
auth=(
|
|
88
|
+
self._idm["mobileClientId"],
|
|
89
|
+
self._idm["mobileClientSecret"],
|
|
90
|
+
),
|
|
91
|
+
timeout=10,
|
|
92
|
+
)
|
|
93
|
+
if response.status_code != requests.codes.ok:
|
|
94
|
+
logging.error("error from controlmyspa API: %s", response.text)
|
|
95
|
+
response.raise_for_status()
|
|
96
|
+
self._iam = response.json()
|
|
97
|
+
self._token = self._iam["data"]["accessToken"]
|
|
98
|
+
return self._iam
|
|
99
|
+
|
|
100
|
+
def _get_info(self, retries=3, retry_delay=5):
|
|
101
|
+
"""
|
|
102
|
+
Get all the details for the whirlpool of the logged in user.
|
|
103
|
+
Retries a few times if currentState is missing (gateway may be temporarily offline).
|
|
104
|
+
"""
|
|
105
|
+
for attempt in range(retries):
|
|
106
|
+
response = requests.get(
|
|
107
|
+
"https://iot.controlmyspa.com/spas",
|
|
108
|
+
params={"username": self._email},
|
|
109
|
+
headers={"Authorization": "Bearer " + self._token},
|
|
110
|
+
timeout=10,
|
|
111
|
+
)
|
|
112
|
+
if response.status_code != requests.codes.ok:
|
|
113
|
+
logging.error("error from controlmyspa API: %s", response.text)
|
|
114
|
+
response.raise_for_status()
|
|
115
|
+
self._list = response.json()
|
|
116
|
+
self._info = self._list["data"]["spas"][self._spa_offset]
|
|
117
|
+
if "currentState" in self._info:
|
|
118
|
+
return self._info
|
|
119
|
+
if attempt < retries - 1:
|
|
120
|
+
logging.warning(
|
|
121
|
+
"Spa data missing 'currentState', retrying in %ds (%d/%d)",
|
|
122
|
+
retry_delay,
|
|
123
|
+
attempt + 1,
|
|
124
|
+
retries,
|
|
125
|
+
)
|
|
126
|
+
time.sleep(retry_delay)
|
|
127
|
+
raise SpaOfflineError(
|
|
128
|
+
f"Spa data does not contain 'currentState' after {retries} attempts"
|
|
129
|
+
" — the spa gateway may be offline"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def current_temp(self):
|
|
134
|
+
"""
|
|
135
|
+
Get current pool temperature, in celsius or farenheit according to spa settings
|
|
136
|
+
"""
|
|
137
|
+
# update fresh info
|
|
138
|
+
# self._get_info()
|
|
139
|
+
if self._info["currentState"]["celsius"]:
|
|
140
|
+
return round(
|
|
141
|
+
(float(self._info["currentState"]["currentTemp"]) - 32) * 5 / 9, 1
|
|
142
|
+
)
|
|
143
|
+
return float(self._info["currentState"]["currentTemp"])
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def desired_temp(self):
|
|
147
|
+
"""
|
|
148
|
+
Get desired pool temperature, in celsius or farenheit according to spa settings
|
|
149
|
+
"""
|
|
150
|
+
# update fresh info
|
|
151
|
+
# self._get_info()
|
|
152
|
+
if self._info["currentState"]["celsius"]:
|
|
153
|
+
return round(
|
|
154
|
+
(float(self._info["currentState"]["desiredTemp"]) - 32) * 5 / 9, 1
|
|
155
|
+
)
|
|
156
|
+
return float(self._info["currentState"]["desiredTemp"])
|
|
157
|
+
|
|
158
|
+
@desired_temp.setter
|
|
159
|
+
def desired_temp(self, temperature):
|
|
160
|
+
"""
|
|
161
|
+
Set the desired temperature of the whirlpool
|
|
162
|
+
:param temperature: temperature, in celsius if the whirlpool is set to celsius
|
|
163
|
+
or in fahrenheit if the whirlpool is set to fahrenheit
|
|
164
|
+
"""
|
|
165
|
+
# TODO: check high/low ranges and adjust range accordingly
|
|
166
|
+
if self._info["currentState"]["celsius"]:
|
|
167
|
+
# convert to fahrenheit since the API always expects fahrenheit
|
|
168
|
+
temperature = round(temperature / 5 * 9 + 32, 1)
|
|
169
|
+
response = requests.post(
|
|
170
|
+
"https://iot.controlmyspa.com/spa-commands/temperature/value",
|
|
171
|
+
json={"value": temperature, "spaId": self._info["_id"], "via": "MOBILE"},
|
|
172
|
+
headers={"Authorization": "Bearer " + self._token},
|
|
173
|
+
timeout=10,
|
|
174
|
+
)
|
|
175
|
+
if response.status_code != requests.codes.ok:
|
|
176
|
+
logging.error("error from controlmyspa API: %s", response.text)
|
|
177
|
+
response.raise_for_status()
|
|
178
|
+
# update the local info
|
|
179
|
+
self._get_info()
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def temp_range(self):
|
|
183
|
+
"""
|
|
184
|
+
Get temp range HIGH (True) or LOW (False)
|
|
185
|
+
"""
|
|
186
|
+
# update fresh info
|
|
187
|
+
# self._get_info()
|
|
188
|
+
return self._info["currentState"]["tempRange"] == "HIGH"
|
|
189
|
+
|
|
190
|
+
@temp_range.setter
|
|
191
|
+
def temp_range(self, temp_range=True):
|
|
192
|
+
"""
|
|
193
|
+
Set temp range HIGH or LOW
|
|
194
|
+
:param temp_range: True for HIGH, False for LOW
|
|
195
|
+
"""
|
|
196
|
+
response = requests.post(
|
|
197
|
+
"https://iot.controlmyspa.com/spa-commands/temperature/range",
|
|
198
|
+
json={
|
|
199
|
+
"range": ("HIGH" if temp_range else "LOW"),
|
|
200
|
+
"spaId": self._info["_id"],
|
|
201
|
+
"via": "MOBILE",
|
|
202
|
+
},
|
|
203
|
+
headers={"Authorization": "Bearer " + self._token},
|
|
204
|
+
timeout=10,
|
|
205
|
+
)
|
|
206
|
+
if response.status_code != requests.codes.ok:
|
|
207
|
+
logging.error("error from controlmyspa API: %s", response.text)
|
|
208
|
+
response.raise_for_status()
|
|
209
|
+
# update the local info
|
|
210
|
+
self._get_info()
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def heater_mode(self):
|
|
214
|
+
"""
|
|
215
|
+
Get heater mode of spa READY (True) or REST (False)
|
|
216
|
+
"""
|
|
217
|
+
# update fresh info
|
|
218
|
+
# self._get_info()
|
|
219
|
+
return self._info["currentState"]["heaterMode"] == "READY"
|
|
220
|
+
|
|
221
|
+
@heater_mode.setter
|
|
222
|
+
def heater_mode(self, heater_mode=True):
|
|
223
|
+
"""
|
|
224
|
+
Set heater mode READY or REST
|
|
225
|
+
:param heater_mode: True for READY, False for REST
|
|
226
|
+
"""
|
|
227
|
+
response = requests.post(
|
|
228
|
+
"https://iot.controlmyspa.com/spa-commands/temperature/heater-mode",
|
|
229
|
+
json={
|
|
230
|
+
"mode": ("READY" if heater_mode else "REST"),
|
|
231
|
+
"spaId": self._info["_id"],
|
|
232
|
+
"via": "MOBILE",
|
|
233
|
+
},
|
|
234
|
+
headers={"Authorization": "Bearer " + self._token},
|
|
235
|
+
timeout=10,
|
|
236
|
+
)
|
|
237
|
+
if response.status_code != requests.codes.ok:
|
|
238
|
+
logging.error("error from controlmyspa API: %s", response.text)
|
|
239
|
+
response.raise_for_status()
|
|
240
|
+
# update the local info
|
|
241
|
+
self._get_info()
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def panel_lock(self):
|
|
245
|
+
"""
|
|
246
|
+
Get panel lock status, Locked = True, unlocked = False
|
|
247
|
+
"""
|
|
248
|
+
# update fresh info
|
|
249
|
+
# self._get_info()
|
|
250
|
+
return self._info["currentState"]["panelLock"]
|
|
251
|
+
|
|
252
|
+
@panel_lock.setter
|
|
253
|
+
def panel_lock(self, lock=True):
|
|
254
|
+
"""
|
|
255
|
+
Set panel lock
|
|
256
|
+
:param lock: True for locked, False for unlocked
|
|
257
|
+
"""
|
|
258
|
+
response = requests.post(
|
|
259
|
+
"https://iot.controlmyspa.com/spa-commands/panel/state",
|
|
260
|
+
json={
|
|
261
|
+
"state": ("LOCK_PANEL" if lock else "UNLOCK_PANEL"),
|
|
262
|
+
"spaId": self._info["_id"],
|
|
263
|
+
"via": "MOBILE",
|
|
264
|
+
},
|
|
265
|
+
headers={"Authorization": "Bearer " + self._token},
|
|
266
|
+
timeout=10,
|
|
267
|
+
)
|
|
268
|
+
if response.status_code != requests.codes.ok:
|
|
269
|
+
logging.error("error from controlmyspa API: %s", response.text)
|
|
270
|
+
response.raise_for_status()
|
|
271
|
+
# update the local info
|
|
272
|
+
self._get_info()
|
|
273
|
+
|
|
274
|
+
def get_jet(self, jet_number=0):
|
|
275
|
+
"""
|
|
276
|
+
get jet state HIGH = True, OFF = False
|
|
277
|
+
:param jet_number: My pool has jets 0, 1 and 2
|
|
278
|
+
"""
|
|
279
|
+
# update fresh info
|
|
280
|
+
# self._get_info()
|
|
281
|
+
return [
|
|
282
|
+
x["value"] == "HIGH"
|
|
283
|
+
for x in self._info["currentState"]["components"]
|
|
284
|
+
if x["componentType"] == "PUMP" and x["port"] == str(jet_number)
|
|
285
|
+
][0]
|
|
286
|
+
|
|
287
|
+
def set_jet(self, jet_number=0, state=False):
|
|
288
|
+
"""
|
|
289
|
+
Enable/disable jet
|
|
290
|
+
:param jet_number: My pool has jets 0, 1 and 2
|
|
291
|
+
:param state: False to furn off, True to turn on
|
|
292
|
+
"""
|
|
293
|
+
response = requests.post(
|
|
294
|
+
"https://iot.controlmyspa.com/spa-command/component-state",
|
|
295
|
+
json={
|
|
296
|
+
"state": ("HIGH" if state else "OFF"),
|
|
297
|
+
"deviceNumber": jet_number,
|
|
298
|
+
"componentType": "jet",
|
|
299
|
+
"spaId": self._info["_id"],
|
|
300
|
+
"via": "MOBILE",
|
|
301
|
+
},
|
|
302
|
+
headers={"Authorization": "Bearer " + self._token},
|
|
303
|
+
timeout=10,
|
|
304
|
+
)
|
|
305
|
+
if response.status_code != requests.codes.ok:
|
|
306
|
+
logging.error("error from controlmyspa API: %s", response.text)
|
|
307
|
+
response.raise_for_status()
|
|
308
|
+
# update the local info
|
|
309
|
+
self._get_info()
|
|
310
|
+
|
|
311
|
+
@property
|
|
312
|
+
def jets(self):
|
|
313
|
+
"""
|
|
314
|
+
get an array of jets True/False (ON/OFF) status
|
|
315
|
+
"""
|
|
316
|
+
return [
|
|
317
|
+
x["value"] == "HIGH"
|
|
318
|
+
for x in self._info["currentState"]["components"]
|
|
319
|
+
if x["componentType"] == "PUMP"
|
|
320
|
+
]
|
|
321
|
+
|
|
322
|
+
@jets.setter
|
|
323
|
+
def jets(self, array):
|
|
324
|
+
"""
|
|
325
|
+
set jets ON/OFF based on array of True/False
|
|
326
|
+
:param array: array of True/False
|
|
327
|
+
"""
|
|
328
|
+
for i, state in enumerate(array):
|
|
329
|
+
self.set_jet(i, state)
|
|
330
|
+
|
|
331
|
+
@property
|
|
332
|
+
def circulation_pumps(self):
|
|
333
|
+
"""
|
|
334
|
+
get an array of circulation pumps True/False (ON/OFF) status
|
|
335
|
+
(just information, cannot be set)
|
|
336
|
+
"""
|
|
337
|
+
return [
|
|
338
|
+
x["value"] == "HIGH"
|
|
339
|
+
for x in self._info["currentState"]["components"]
|
|
340
|
+
if x["componentType"] == "CIRCULATION_PUMP"
|
|
341
|
+
]
|
|
342
|
+
|
|
343
|
+
@property
|
|
344
|
+
def ozone_generators(self):
|
|
345
|
+
"""
|
|
346
|
+
get an array of ozone generators True/False (ON/OFF) status
|
|
347
|
+
(just information, cannot be set)
|
|
348
|
+
"""
|
|
349
|
+
return [
|
|
350
|
+
x["value"] == "ON"
|
|
351
|
+
for x in self._info["currentState"]["components"]
|
|
352
|
+
if x["componentType"] == "OZONE"
|
|
353
|
+
]
|
|
354
|
+
|
|
355
|
+
def get_blower(self, blower_number=0):
|
|
356
|
+
"""
|
|
357
|
+
get blower state HIGH = True, OFF = False
|
|
358
|
+
:param blower_number: My pool has no blowers
|
|
359
|
+
"""
|
|
360
|
+
# update fresh info
|
|
361
|
+
# self._get_info()
|
|
362
|
+
return [
|
|
363
|
+
x["value"] == "HIGH"
|
|
364
|
+
for x in self._info["currentState"]["components"]
|
|
365
|
+
if x["componentType"] == "BLOWER" and x["port"] == str(blower_number)
|
|
366
|
+
][0]
|
|
367
|
+
|
|
368
|
+
def set_blower(self, blower_number=0, state=False):
|
|
369
|
+
"""
|
|
370
|
+
Enable/disable blower. Untested as I don't have blowers.
|
|
371
|
+
:param blower_number: blower number starting at 0
|
|
372
|
+
:param state: False to furn off, True to turn on
|
|
373
|
+
"""
|
|
374
|
+
response = requests.post(
|
|
375
|
+
"https://iot.controlmyspa.com/spa-command/component-state",
|
|
376
|
+
json={
|
|
377
|
+
"state": ("HIGH" if state else "OFF"),
|
|
378
|
+
"deviceNumber": blower_number,
|
|
379
|
+
"componentType": "blower",
|
|
380
|
+
"spaId": self._info["_id"],
|
|
381
|
+
"via": "MOBILE",
|
|
382
|
+
},
|
|
383
|
+
headers={"Authorization": "Bearer " + self._token},
|
|
384
|
+
timeout=10,
|
|
385
|
+
)
|
|
386
|
+
if response.status_code != requests.codes.ok:
|
|
387
|
+
logging.error("error from controlmyspa API: %s", response.text)
|
|
388
|
+
response.raise_for_status()
|
|
389
|
+
# update the local info
|
|
390
|
+
self._get_info()
|
|
391
|
+
|
|
392
|
+
@property
|
|
393
|
+
def blowers(self):
|
|
394
|
+
"""
|
|
395
|
+
get an array of blowers True/False (ON/OFF) status
|
|
396
|
+
"""
|
|
397
|
+
return [
|
|
398
|
+
x["value"] == "HIGH"
|
|
399
|
+
for x in self._info["currentState"]["components"]
|
|
400
|
+
if x["componentType"] == "BLOWER"
|
|
401
|
+
]
|
|
402
|
+
|
|
403
|
+
@blowers.setter
|
|
404
|
+
def blowers(self, array):
|
|
405
|
+
"""
|
|
406
|
+
set blowers ON/OFF based on array of True/False
|
|
407
|
+
:param array: array of True/False
|
|
408
|
+
"""
|
|
409
|
+
for i, state in enumerate(array):
|
|
410
|
+
self.set_blower(i, state)
|
|
411
|
+
|
|
412
|
+
def get_light(self, light_number=0):
|
|
413
|
+
"""
|
|
414
|
+
get light state HIGH = True, OFF = False
|
|
415
|
+
:param light_number: My pool has light 0
|
|
416
|
+
"""
|
|
417
|
+
# update fresh info
|
|
418
|
+
# self._get_info()
|
|
419
|
+
return [
|
|
420
|
+
x["value"] == "HIGH"
|
|
421
|
+
for x in self._info["currentState"]["components"]
|
|
422
|
+
if x["componentType"] == "LIGHT" and x["port"] == str(light_number)
|
|
423
|
+
][0]
|
|
424
|
+
|
|
425
|
+
def set_light(self, light_number=0, state=False):
|
|
426
|
+
"""
|
|
427
|
+
Enable/disable light
|
|
428
|
+
:param jet_number: My pool has lights 0, 1 and 2
|
|
429
|
+
:param state: False to furn off, True to turn on
|
|
430
|
+
"""
|
|
431
|
+
response = requests.post(
|
|
432
|
+
"https://iot.controlmyspa.com/spa-command/component-state",
|
|
433
|
+
json={
|
|
434
|
+
"state": ("HIGH" if state else "OFF"),
|
|
435
|
+
"deviceNumber": light_number,
|
|
436
|
+
"componentType": "light",
|
|
437
|
+
"spaId": self._info["_id"],
|
|
438
|
+
"via": "MOBILE",
|
|
439
|
+
},
|
|
440
|
+
headers={"Authorization": "Bearer " + self._token},
|
|
441
|
+
timeout=10,
|
|
442
|
+
)
|
|
443
|
+
if response.status_code != requests.codes.ok:
|
|
444
|
+
logging.error("error from controlmyspa API: %s", response.text)
|
|
445
|
+
response.raise_for_status()
|
|
446
|
+
# update the local info
|
|
447
|
+
self._get_info()
|
|
448
|
+
|
|
449
|
+
@property
|
|
450
|
+
def lights(self):
|
|
451
|
+
"""
|
|
452
|
+
get an array of lights True/False (ON/OFF) status
|
|
453
|
+
"""
|
|
454
|
+
return [
|
|
455
|
+
x["value"] == "HIGH"
|
|
456
|
+
for x in self._info["currentState"]["components"]
|
|
457
|
+
if x["componentType"] == "LIGHT"
|
|
458
|
+
]
|
|
459
|
+
|
|
460
|
+
@lights.setter
|
|
461
|
+
def lights(self, array):
|
|
462
|
+
"""
|
|
463
|
+
set lights ON/OFF based on array of True/False
|
|
464
|
+
:param array: array of True/False
|
|
465
|
+
"""
|
|
466
|
+
for i, state in enumerate(array):
|
|
467
|
+
self.set_light(i, state)
|
|
468
|
+
|
|
469
|
+
def get_serial(self):
|
|
470
|
+
"""
|
|
471
|
+
Get spa serial number
|
|
472
|
+
"""
|
|
473
|
+
return self._info["serialNumber"]
|
|
474
|
+
|
|
475
|
+
@property
|
|
476
|
+
def online(self):
|
|
477
|
+
"""
|
|
478
|
+
Get the spa online status
|
|
479
|
+
"""
|
|
480
|
+
return self._info["currentState"]["online"]
|
controlmyspa/debug.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dump the details for a spa for debugging
|
|
3
|
+
|
|
4
|
+
use e.g. with "python debug.py user@example.com myverysecretpassword"
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
from controlmyspa import ControlMySpa
|
|
11
|
+
import pprint
|
|
12
|
+
|
|
13
|
+
PARSER = argparse.ArgumentParser(description="Get metrics from Balboa Controlmyspa")
|
|
14
|
+
PARSER.add_argument(
|
|
15
|
+
"-v",
|
|
16
|
+
"--verbose",
|
|
17
|
+
help="enable debug logging",
|
|
18
|
+
action="store_true",
|
|
19
|
+
default=False,
|
|
20
|
+
)
|
|
21
|
+
PARSER.add_argument("email", help="email to log in to controlmyspa.com")
|
|
22
|
+
PARSER.add_argument("password", help="password to log in to controlmyspa.com")
|
|
23
|
+
ARGS = PARSER.parse_args()
|
|
24
|
+
|
|
25
|
+
LOGFORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
26
|
+
|
|
27
|
+
if ARGS.verbose:
|
|
28
|
+
logging.basicConfig(level=logging.DEBUG, format=LOGFORMAT)
|
|
29
|
+
else:
|
|
30
|
+
logging.basicConfig(level=logging.INFO, format=LOGFORMAT)
|
|
31
|
+
logging.getLogger("requests.packages.urllib3.connectionpool").setLevel(
|
|
32
|
+
logging.WARNING
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
logging.debug("starting with arguments: %s", ARGS)
|
|
36
|
+
|
|
37
|
+
API = ControlMySpa(ARGS.email, ARGS.password)
|
|
38
|
+
info = API._info
|
|
39
|
+
|
|
40
|
+
# remove potentially sensitive information
|
|
41
|
+
del info["ownerId"]
|
|
42
|
+
del info["p2pAPSSID"]
|
|
43
|
+
del info["serialNumber"]
|
|
44
|
+
del info["_id"]
|
|
45
|
+
|
|
46
|
+
# print remaining data
|
|
47
|
+
pprint.pprint(API._info)
|
controlmyspa/example.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Example usage of controlmyspa module
|
|
3
|
+
|
|
4
|
+
use e.g. with "python example.py user@example.com myverysecretpassword"
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
from controlmyspa import ControlMySpa
|
|
11
|
+
|
|
12
|
+
PARSER = argparse.ArgumentParser(description="Get metrics from Balboa Controlmyspa")
|
|
13
|
+
PARSER.add_argument(
|
|
14
|
+
"-v",
|
|
15
|
+
"--verbose",
|
|
16
|
+
help="enable debug logging",
|
|
17
|
+
action="store_true",
|
|
18
|
+
default=False,
|
|
19
|
+
)
|
|
20
|
+
PARSER.add_argument("email", help="email to log in to controlmyspa.com")
|
|
21
|
+
PARSER.add_argument("password", help="password to log in to controlmyspa.com")
|
|
22
|
+
ARGS = PARSER.parse_args()
|
|
23
|
+
|
|
24
|
+
LOGFORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
25
|
+
|
|
26
|
+
if ARGS.verbose:
|
|
27
|
+
logging.basicConfig(level=logging.DEBUG, format=LOGFORMAT)
|
|
28
|
+
else:
|
|
29
|
+
logging.basicConfig(level=logging.INFO, format=LOGFORMAT)
|
|
30
|
+
logging.getLogger("requests.packages.urllib3.connectionpool").setLevel(
|
|
31
|
+
logging.WARNING
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
logging.debug("starting with arguments: %s", ARGS)
|
|
35
|
+
|
|
36
|
+
API = ControlMySpa(ARGS.email, ARGS.password)
|
|
37
|
+
|
|
38
|
+
print("online", API.online)
|
|
39
|
+
|
|
40
|
+
print("current_temp", API.current_temp)
|
|
41
|
+
print("desired_temp", API.desired_temp)
|
|
42
|
+
|
|
43
|
+
API.desired_temp = 27 if API.desired_temp == 37 else 37
|
|
44
|
+
|
|
45
|
+
print("temp_range", API.temp_range)
|
|
46
|
+
print("panel_lock", API.panel_lock)
|
|
47
|
+
|
|
48
|
+
print("lights", API.lights)
|
|
49
|
+
|
|
50
|
+
# toggle lights
|
|
51
|
+
# API.lights = [not x for x in API.lights]
|
|
52
|
+
|
|
53
|
+
print("jets", API.jets)
|
|
54
|
+
|
|
55
|
+
# toggle jets
|
|
56
|
+
# API.set_jet(0, not API.get_jet(0))
|
|
57
|
+
# API.set_jet(1, not API.get_jet(1))
|
|
58
|
+
# API.set_jet(2, not API.get_jet(2))
|
|
59
|
+
|
|
60
|
+
print("blowers", API.blowers)
|
|
61
|
+
|
|
62
|
+
print("heater", API.heater_mode)
|
|
63
|
+
|
|
64
|
+
print("circulation_pumps", API.circulation_pumps)
|
|
65
|
+
|
|
66
|
+
print("ozone_generators", API.ozone_generators)
|
|
67
|
+
|
|
68
|
+
print("serial", API.get_serial())
|
controlmyspa/noxfile.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import nox
|
|
2
|
+
|
|
3
|
+
nox.options.default_venv_backend = "uv"
|
|
4
|
+
nox.options.reuse_existing_virtualenvs = True
|
|
5
|
+
|
|
6
|
+
PYTHON_VERSIONS = ["3.11", "3.12", "3.13", "3.14"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@nox.session(python=PYTHON_VERSIONS)
|
|
10
|
+
def tests(session):
|
|
11
|
+
session.install("-e", ".", "pytest", "pytest-cov", "responses")
|
|
12
|
+
session.run("pytest", "--cov", "--ignore", ".nox")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@nox.session(python="3.14")
|
|
16
|
+
def ruff(session):
|
|
17
|
+
session.install("ruff")
|
|
18
|
+
session.run("ruff", "check", ".")
|
|
19
|
+
session.run("ruff", "format", "--check", ".")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@nox.session(python="3.14")
|
|
23
|
+
def pylint(session):
|
|
24
|
+
session.install(".", "pylint")
|
|
25
|
+
session.run("pylint", "controlmyspa")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@nox.session
|
|
29
|
+
def report(session):
|
|
30
|
+
session.install("coverage")
|
|
31
|
+
session.run("coverage", "report", "--omit", ".nox/*")
|
|
32
|
+
session.run("coverage", "html", "--omit", ".nox/*")
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: controlmyspa
|
|
3
|
+
Version: 4.0.1
|
|
4
|
+
Summary: Balboa ControlMySpa™ cloud API for hot tub spa systems
|
|
5
|
+
Author-email: Aarno Aukia <aarno@aukia.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/arska/controlmyspa
|
|
8
|
+
Project-URL: Issues, https://github.com/arska/controlmyspa/issues
|
|
9
|
+
Keywords: Balboa,Controlmyspa,Whirlpool,API
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: requests>2
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# Balboa ControlMySpa™ cloud API for hot tub spa systems
|
|
21
|
+
|
|
22
|
+
[](https://github.com/arska/controlmyspa/actions/workflows/main.yml)
|
|
23
|
+
[](https://codecov.io/gh/arska/controlmyspa)
|
|
24
|
+
[](https://badge.fury.io/py/controlmyspa)
|
|
25
|
+
|
|
26
|
+
Python API for ControlMySpa.com cloud-controlled of Balboa spa control systems for hot tubs.
|
|
27
|
+
|
|
28
|
+
- https://www.balboawatergroup.com/ControlMySpa
|
|
29
|
+
- https://controlmyspa.com
|
|
30
|
+
|
|
31
|
+
## 2023-12-13: iot.controlmyspa.com missing intermediate certificate
|
|
32
|
+
|
|
33
|
+
Since approximately June 2023 iot.controlmyspa.com has a new TLS certificate. This certificate is signed by digicert, but the intermediate certificate chain is not served by iot.controlmyspa.com and is also missing in the python certifi trust store. Instead of disabling the TLS certificate validation, we download the intermediate certificate from digicert over a successfully verified TLS connection and add it to the local trust store on first run. This does, however, not work for read-only runtimes like Docker containers. See https://github.com/arska/controlmyspa-porssari/blob/main/Dockerfile and https://github.com/arska/controlmyspa-porssari/blob/main/get_certificate.py for an example how to download the certificate at Docker image build time instead.
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
see example.py for a runnable example
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from controlmyspa import ControlMySpa
|
|
41
|
+
|
|
42
|
+
API = ControlMySpa("user@example.com", "myverysecretpassword")
|
|
43
|
+
pprint.pprint(API._info)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## References
|
|
47
|
+
|
|
48
|
+
Based on the JavaScript library [https://gitlab.com/VVlasy/controlmyspajs](https://github.com/VVlasy/controlmyspajs)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
controlmyspa/__init__.py,sha256=VaFWUF-V7SYPYIG2psA6YviTKTXhwYHsBQEnZUTdtN4,147
|
|
2
|
+
controlmyspa/controlmyspa.py,sha256=lmC9yrsuLUSr_5Sxyf7dEi7hkyYIemAEhEOBrkaawN8,16369
|
|
3
|
+
controlmyspa/debug.py,sha256=EfqDnEz_UGTqU808sbmYS0d-hUdm4GnDH-e1KS__buc,1212
|
|
4
|
+
controlmyspa/example.py,sha256=NXMtOs0jN7fDRbXIBQoREwccnua0MlaYC7zXECKjMHw,1683
|
|
5
|
+
controlmyspa/noxfile.py,sha256=Qj8ivD5khvplJBXRvlsl4lNeoVGTy0Rqmx-Hc5ZquCc,807
|
|
6
|
+
controlmyspa-4.0.1.dist-info/licenses/LICENSE,sha256=_R2GgVt89WW9Sp2_A6jsTF2MCu1wtO8f6I0GQleF0mE,1068
|
|
7
|
+
controlmyspa-4.0.1.dist-info/METADATA,sha256=PKLpX0wx6D_e1Z4vDKC_WSoDBbx1q-MZZGgWIcYH8iM,2458
|
|
8
|
+
controlmyspa-4.0.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
controlmyspa-4.0.1.dist-info/top_level.txt,sha256=ZHJ2CDgLYHNELarDIh27KzC2HQLHSeCgoBub6-fhTJQ,13
|
|
10
|
+
controlmyspa-4.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2021 Aarno Aukia
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
controlmyspa
|