Skip to content

Commit d82b496

Browse files
committed
Break out SmileComm class
1 parent ca72a5c commit d82b496

File tree

4 files changed

+165
-155
lines changed

4 files changed

+165
-155
lines changed

plugwise/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@
2828
ResponseError,
2929
UnsupportedDeviceError,
3030
)
31-
from plugwise.helper import SmileComm
3231
from plugwise.legacy.smile import SmileLegacyAPI
3332
from plugwise.smile import SmileAPI
33+
from plugwise.smilecomm import SmileComm
3434

3535
import aiohttp
3636
from defusedxml import ElementTree as etree

plugwise/helper.py

Lines changed: 0 additions & 153 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
from __future__ import annotations
77

8-
import asyncio
98
import datetime as dt
109
from typing import cast
1110

@@ -44,23 +43,13 @@
4443
ThermoLoc,
4544
ToggleNameType,
4645
)
47-
from plugwise.exceptions import (
48-
ConnectionFailedError,
49-
InvalidAuthentication,
50-
InvalidXMLError,
51-
ResponseError,
52-
)
5346
from plugwise.util import (
5447
check_model,
5548
common_match_cases,
56-
escape_illegal_xml_characters,
5749
format_measure,
5850
skip_obsolete_measurements,
5951
)
6052

61-
# This way of importing aiohttp is because of patch/mocking in testing (aiohttp timeouts)
62-
from aiohttp import BasicAuth, ClientError, ClientResponse, ClientSession, ClientTimeout
63-
6453
# Time related
6554
from dateutil import tz
6655
from dateutil.parser import parse
@@ -78,148 +67,6 @@ def search_actuator_functionalities(appliance: etree, actuator: str) -> etree |
7867
return None
7968

8069

81-
class SmileComm:
82-
"""The SmileComm class."""
83-
84-
def __init__(
85-
self,
86-
host: str,
87-
password: str,
88-
port: int,
89-
timeout: int,
90-
username: str,
91-
websession: ClientSession | None,
92-
) -> None:
93-
"""Set the constructor for this class."""
94-
if not websession:
95-
aio_timeout = ClientTimeout(total=timeout)
96-
97-
async def _create_session() -> ClientSession:
98-
return ClientSession(timeout=aio_timeout) # pragma: no cover
99-
100-
loop = asyncio.get_event_loop()
101-
if loop.is_running():
102-
self._websession = ClientSession(timeout=aio_timeout)
103-
else:
104-
self._websession = loop.run_until_complete(
105-
_create_session()
106-
) # pragma: no cover
107-
else:
108-
self._websession = websession
109-
110-
# Quickfix IPv6 formatting, not covering
111-
if host.count(":") > 2: # pragma: no cover
112-
host = f"[{host}]"
113-
114-
self._auth = BasicAuth(username, password=password)
115-
self._endpoint = f"http://{host}:{str(port)}"
116-
117-
async def _request(
118-
self,
119-
command: str,
120-
retry: int = 3,
121-
method: str = "get",
122-
data: str | None = None,
123-
headers: dict[str, str] | None = None,
124-
) -> etree:
125-
"""Get/put/delete data from a give URL."""
126-
resp: ClientResponse
127-
url = f"{self._endpoint}{command}"
128-
use_headers = headers
129-
130-
try:
131-
match method:
132-
case "delete":
133-
resp = await self._websession.delete(url, auth=self._auth)
134-
case "get":
135-
# Work-around for Stretchv2, should not hurt the other smiles
136-
use_headers = {"Accept-Encoding": "gzip"}
137-
resp = await self._websession.get(
138-
url, headers=use_headers, auth=self._auth
139-
)
140-
case "post":
141-
use_headers = {"Content-type": "text/xml"}
142-
resp = await self._websession.post(
143-
url,
144-
headers=use_headers,
145-
data=data,
146-
auth=self._auth,
147-
)
148-
case "put":
149-
use_headers = {"Content-type": "text/xml"}
150-
resp = await self._websession.put(
151-
url,
152-
headers=use_headers,
153-
data=data,
154-
auth=self._auth,
155-
)
156-
except (
157-
ClientError
158-
) as exc: # ClientError is an ancestor class of ServerTimeoutError
159-
if retry < 1:
160-
LOGGER.warning(
161-
"Failed sending %s %s to Plugwise Smile, error: %s",
162-
method,
163-
command,
164-
exc,
165-
)
166-
raise ConnectionFailedError from exc
167-
return await self._request(command, retry - 1)
168-
169-
if resp.status == 504:
170-
if retry < 1:
171-
LOGGER.warning(
172-
"Failed sending %s %s to Plugwise Smile, error: %s",
173-
method,
174-
command,
175-
"504 Gateway Timeout",
176-
)
177-
raise ConnectionFailedError
178-
return await self._request(command, retry - 1)
179-
180-
return await self._request_validate(resp, method)
181-
182-
async def _request_validate(self, resp: ClientResponse, method: str) -> etree:
183-
"""Helper-function for _request(): validate the returned data."""
184-
match resp.status:
185-
case 200:
186-
# Cornercases for server not responding with 202
187-
if method in ("post", "put"):
188-
return
189-
case 202:
190-
# Command accepted gives empty body with status 202
191-
return
192-
case 401:
193-
msg = (
194-
"Invalid Plugwise login, please retry with the correct credentials."
195-
)
196-
LOGGER.error("%s", msg)
197-
raise InvalidAuthentication
198-
case 405:
199-
msg = "405 Method not allowed."
200-
LOGGER.error("%s", msg)
201-
raise ConnectionFailedError
202-
203-
if not (result := await resp.text()) or (
204-
"<error>" in result and "Not started" not in result
205-
):
206-
LOGGER.warning("Smile response empty or error in %s", result)
207-
raise ResponseError
208-
209-
try:
210-
# Encode to ensure utf8 parsing
211-
xml = etree.XML(escape_illegal_xml_characters(result).encode())
212-
except etree.ParseError as exc:
213-
LOGGER.warning("Smile returns invalid XML for %s", self._endpoint)
214-
raise InvalidXMLError from exc
215-
216-
return xml
217-
218-
async def close_connection(self) -> None:
219-
"""Close the Plugwise connection."""
220-
await self._websession.close()
221-
222-
22370
class SmileHelper(SmileCommon):
22471
"""The SmileHelper class."""
22572

plugwise/smilecomm.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
"""Use of this source code is governed by the MIT license found in the LICENSE file.
2+
3+
Plugwise Smile communication protocol helpers.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
import asyncio
9+
10+
from plugwise.constants import LOGGER
11+
from plugwise.exceptions import (
12+
ConnectionFailedError,
13+
InvalidAuthentication,
14+
InvalidXMLError,
15+
ResponseError,
16+
)
17+
from plugwise.util import escape_illegal_xml_characters
18+
19+
# This way of importing aiohttp is because of patch/mocking in testing (aiohttp timeouts)
20+
from aiohttp import BasicAuth, ClientError, ClientResponse, ClientSession, ClientTimeout
21+
from defusedxml import ElementTree as etree
22+
23+
24+
class SmileComm:
25+
"""The SmileComm class."""
26+
27+
def __init__(
28+
self,
29+
host: str,
30+
password: str,
31+
port: int,
32+
timeout: int,
33+
username: str,
34+
websession: ClientSession | None,
35+
) -> None:
36+
"""Set the constructor for this class."""
37+
if not websession:
38+
aio_timeout = ClientTimeout(total=timeout)
39+
40+
async def _create_session() -> ClientSession:
41+
return ClientSession(timeout=aio_timeout) # pragma: no cover
42+
43+
loop = asyncio.get_event_loop()
44+
if loop.is_running():
45+
self._websession = ClientSession(timeout=aio_timeout)
46+
else:
47+
self._websession = loop.run_until_complete(
48+
_create_session()
49+
) # pragma: no cover
50+
else:
51+
self._websession = websession
52+
53+
# Quickfix IPv6 formatting, not covering
54+
if host.count(":") > 2: # pragma: no cover
55+
host = f"[{host}]"
56+
57+
self._auth = BasicAuth(username, password=password)
58+
self._endpoint = f"http://{host}:{str(port)}"
59+
60+
async def _request(
61+
self,
62+
command: str,
63+
retry: int = 3,
64+
method: str = "get",
65+
data: str | None = None,
66+
headers: dict[str, str] | None = None,
67+
) -> etree:
68+
"""Get/put/delete data from a give URL."""
69+
resp: ClientResponse
70+
url = f"{self._endpoint}{command}"
71+
use_headers = headers
72+
73+
try:
74+
match method:
75+
case "delete":
76+
resp = await self._websession.delete(url, auth=self._auth)
77+
case "get":
78+
# Work-around for Stretchv2, should not hurt the other smiles
79+
use_headers = {"Accept-Encoding": "gzip"}
80+
resp = await self._websession.get(
81+
url, headers=use_headers, auth=self._auth
82+
)
83+
case "post":
84+
use_headers = {"Content-type": "text/xml"}
85+
resp = await self._websession.post(
86+
url,
87+
headers=use_headers,
88+
data=data,
89+
auth=self._auth,
90+
)
91+
case "put":
92+
use_headers = {"Content-type": "text/xml"}
93+
resp = await self._websession.put(
94+
url,
95+
headers=use_headers,
96+
data=data,
97+
auth=self._auth,
98+
)
99+
except (
100+
ClientError
101+
) as exc: # ClientError is an ancestor class of ServerTimeoutError
102+
if retry < 1:
103+
LOGGER.warning(
104+
"Failed sending %s %s to Plugwise Smile, error: %s",
105+
method,
106+
command,
107+
exc,
108+
)
109+
raise ConnectionFailedError from exc
110+
return await self._request(command, retry - 1)
111+
112+
if resp.status == 504:
113+
if retry < 1:
114+
LOGGER.warning(
115+
"Failed sending %s %s to Plugwise Smile, error: %s",
116+
method,
117+
command,
118+
"504 Gateway Timeout",
119+
)
120+
raise ConnectionFailedError
121+
return await self._request(command, retry - 1)
122+
123+
return await self._request_validate(resp, method)
124+
125+
async def _request_validate(self, resp: ClientResponse, method: str) -> etree:
126+
"""Helper-function for _request(): validate the returned data."""
127+
match resp.status:
128+
case 200:
129+
# Cornercases for server not responding with 202
130+
if method in ("post", "put"):
131+
return
132+
case 202:
133+
# Command accepted gives empty body with status 202
134+
return
135+
case 401:
136+
msg = (
137+
"Invalid Plugwise login, please retry with the correct credentials."
138+
)
139+
LOGGER.error("%s", msg)
140+
raise InvalidAuthentication
141+
case 405:
142+
msg = "405 Method not allowed."
143+
LOGGER.error("%s", msg)
144+
raise ConnectionFailedError
145+
146+
if not (result := await resp.text()) or (
147+
"<error>" in result and "Not started" not in result
148+
):
149+
LOGGER.warning("Smile response empty or error in %s", result)
150+
raise ResponseError
151+
152+
try:
153+
# Encode to ensure utf8 parsing
154+
xml = etree.XML(escape_illegal_xml_characters(result).encode())
155+
except etree.ParseError as exc:
156+
LOGGER.warning("Smile returns invalid XML for %s", self._endpoint)
157+
raise InvalidXMLError from exc
158+
159+
return xml
160+
161+
async def close_connection(self) -> None:
162+
"""Close the Plugwise connection."""
163+
await self._websession.close()

tests/test_generic.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ async def test_connect_fail_firmware(self):
4848

4949
# Test connect for timeout
5050
@patch(
51-
"plugwise.helper.ClientSession.get",
51+
"plugwise.smilecomm.ClientSession.get",
5252
side_effect=aiohttp.ServerTimeoutError,
5353
)
5454
@pytest.mark.asyncio

0 commit comments

Comments
 (0)