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.
@@ -0,0 +1,7 @@
1
+ """
2
+ Import class from file
3
+ """
4
+
5
+ from controlmyspa.controlmyspa import ControlMySpa, SpaOfflineError
6
+
7
+ __all__ = ["ControlMySpa", "SpaOfflineError"]
@@ -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)
@@ -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())
@@ -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
+ [![Tests](https://github.com/arska/controlmyspa/actions/workflows/main.yml/badge.svg)](https://github.com/arska/controlmyspa/actions/workflows/main.yml)
23
+ [![codecov](https://codecov.io/gh/arska/controlmyspa/branch/main/graph/badge.svg?token=H2107AXHOX)](https://codecov.io/gh/arska/controlmyspa)
24
+ [![PyPI version](https://badge.fury.io/py/controlmyspa.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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