Skip to content

Commit 9e4241e

Browse files
committed
feat: make api testable
1 parent f6655ae commit 9e4241e

File tree

7 files changed

+163
-58
lines changed

7 files changed

+163
-58
lines changed

custom_components/solis_cloud_control/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from custom_components.solis_cloud_control.coordinator import SolisCloudControlCoordinator
88

99
from .api import SolisCloudControlApiClient
10-
from .const import CONF_INVERTER_SN
10+
from .const import API_BASE_URL, CONF_INVERTER_SN
1111

1212
PLATFORMS: list[Platform] = [Platform.SELECT, Platform.TEXT, Platform.NUMBER, Platform.SWITCH, Platform.SENSOR]
1313

@@ -18,7 +18,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
1818
inverter_sn = entry.data[CONF_INVERTER_SN]
1919

2020
session = aiohttp_client.async_get_clientsession(hass)
21-
api_client = SolisCloudControlApiClient(api_key, api_token, session)
21+
api_client = SolisCloudControlApiClient(API_BASE_URL, api_key, api_token, session)
2222

2323
coordinator = SolisCloudControlCoordinator(hass, entry, api_client)
2424
entry.runtime_data = coordinator

custom_components/solis_cloud_control/api.py

Lines changed: 29 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,10 @@
44
from datetime import datetime
55

66
import aiohttp
7-
import backoff
87

98
from custom_components.solis_cloud_control.utils import current_date, digest, format_date, sign_authorization
109

1110
from .const import (
12-
API_BASE_URL,
1311
API_CONCURRENT_REQUESTS,
1412
API_CONTROL_ENDPOINT,
1513
API_READ_BATCH_ENDPOINT,
@@ -43,10 +41,12 @@ def __init__(
4341
class SolisCloudControlApiClient:
4442
def __init__(
4543
self,
44+
base_url: str,
4645
api_key: str,
4746
api_token: str,
4847
session: aiohttp.ClientSession,
4948
) -> None:
49+
self._base_url = base_url
5050
self._api_key = api_key
5151
self._api_secret = api_token
5252
self._session = session
@@ -72,7 +72,7 @@ async def _request(self, date: datetime, endpoint: str, payload: dict[str, any]
7272
"Authorization": authorization,
7373
}
7474

75-
url = f"{API_BASE_URL}{endpoint}"
75+
url = f"{self._base_url}{endpoint}"
7676

7777
_LOGGER.debug("API request '%s': %s", endpoint, json.dumps(payload, indent=2))
7878

@@ -99,18 +99,14 @@ async def _request(self, date: datetime, endpoint: str, payload: dict[str, any]
9999
except aiohttp.ClientError as err:
100100
raise SolisCloudControlApiError(f"Error accessing {url}: {str(err)}") from err
101101

102-
@backoff.on_exception(
103-
backoff.constant,
104-
SolisCloudControlApiError,
105-
max_tries=API_RETRY_COUNT,
106-
interval=API_RETRY_DELAY_SECONDS,
107-
logger=_LOGGER,
108-
)
109102
async def read(self, inverter_sn: str, cid: int) -> str:
110103
date = current_date()
111104
payload = {"inverterSn": inverter_sn, "cid": cid}
112105

113-
data = await self._request(date, API_READ_ENDPOINT, payload)
106+
async def request() -> str:
107+
return await self._request(date, API_READ_ENDPOINT, payload)
108+
109+
data = await _retry_request(request)
114110

115111
if data is None:
116112
raise SolisCloudControlApiError("Read failed: 'data' field is missing in response")
@@ -120,18 +116,14 @@ async def read(self, inverter_sn: str, cid: int) -> str:
120116

121117
return data["msg"]
122118

123-
@backoff.on_exception(
124-
backoff.constant,
125-
SolisCloudControlApiError,
126-
max_tries=API_RETRY_COUNT,
127-
interval=API_RETRY_DELAY_SECONDS,
128-
logger=_LOGGER,
129-
)
130119
async def read_batch(self, inverter_sn: str, cids: list[int]) -> dict[int, str]:
131120
date = current_date()
132121
payload = {"inverterSn": inverter_sn, "cids": ",".join(map(str, cids))}
133122

134-
data = await self._request(date, API_READ_BATCH_ENDPOINT, payload)
123+
async def request() -> dict[str, any]:
124+
return await self._request(date, API_READ_BATCH_ENDPOINT, payload)
125+
126+
data = await _retry_request(request)
135127

136128
if data is None:
137129
raise SolisCloudControlApiError("ReadBatch failed: 'data' field is missing in response")
@@ -157,20 +149,16 @@ async def read_batch(self, inverter_sn: str, cids: list[int]) -> dict[int, str]:
157149

158150
return result
159151

160-
@backoff.on_exception(
161-
backoff.constant,
162-
SolisCloudControlApiError,
163-
max_tries=API_RETRY_COUNT,
164-
interval=API_RETRY_DELAY_SECONDS,
165-
logger=_LOGGER,
166-
)
167152
async def control(self, inverter_sn: str, cid: int, value: str, old_value: str | None = None) -> None:
168153
date = current_date()
169154
payload = {"inverterSn": inverter_sn, "cid": cid, "value": value}
170155
if old_value is not None:
171156
payload["yuanzhi"] = old_value
172157

173-
data_array = await self._request(date, API_CONTROL_ENDPOINT, payload)
158+
async def request() -> None:
159+
return await self._request(date, API_CONTROL_ENDPOINT, payload)
160+
161+
data_array = _retry_request(request)
174162

175163
if data_array is None:
176164
return
@@ -184,3 +172,17 @@ async def control(self, inverter_sn: str, cid: int, value: str, old_value: str |
184172
raise SolisCloudControlApiError(f"Control failed: {error_msg}", response_code=str(code))
185173

186174
return
175+
176+
177+
async def _retry_request(request: callable) -> any:
178+
attempt = 0
179+
while attempt < API_RETRY_COUNT:
180+
try:
181+
return await request()
182+
except SolisCloudControlApiError as err:
183+
attempt += 1
184+
if attempt < API_RETRY_COUNT:
185+
_LOGGER.warning("Retrying due to error: %s (attempt %d/%d)", str(err), attempt, API_RETRY_COUNT)
186+
await asyncio.sleep(API_RETRY_DELAY_SECONDS)
187+
else:
188+
raise

custom_components/solis_cloud_control/config_flow.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
from custom_components.solis_cloud_control.api import SolisCloudControlApiClient, SolisCloudControlApiError
99

10-
from .const import CID_STORAGE_MODE, CONF_INVERTER_SN, DOMAIN
10+
from .const import API_BASE_URL, CID_STORAGE_MODE, CONF_INVERTER_SN, DOMAIN
1111

1212
_LOGGER = logging.getLogger(__name__)
1313

@@ -59,5 +59,5 @@ async def async_step_user(self, user_input: dict[str, any] | None = None) -> Con
5959

6060
async def _test_credentials(self, api_key: str, api_token: str, inverter_sn: str) -> None:
6161
session = aiohttp_client.async_get_clientsession(self.hass)
62-
api_client = SolisCloudControlApiClient(api_key, api_token, session)
62+
api_client = SolisCloudControlApiClient(API_BASE_URL, api_key, api_token, session)
6363
await api_client.read(inverter_sn, CID_STORAGE_MODE)
Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
{
22
"domain": "solis_cloud_control",
33
"name": "Solis Cloud Control",
4-
"version": "0.0.1",
4+
"version": "0.0.2",
55
"documentation": "https://github.com/mkuthan/solis-cloud-control",
66
"codeowners": [
77
"@mkuthan"
88
],
9-
"requirements": [
10-
"backoff==2.2.1"
11-
],
129
"config_flow": true,
1310
"iot_class": "cloud_polling"
1411
}

pyproject.toml

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
[project]
22
name = "solis-cloud-control"
3-
version = "0.0.1"
3+
version = "0.0.2"
44
requires-python = "~=3.13"
55
dependencies = [
6-
"homeassistant==2025.3.3",
7-
"backoff==2.2.1"
6+
"homeassistant==2025.3.3",
87
]
98

109

1110
[tool.uv]
1211
dev-dependencies = [
1312
"pytest==8.3.3",
13+
"pytest-aiohttp==1.1.0",
1414
"pytest-asyncio==0.25.3",
1515
"pytest-cov==6.0.0",
1616
"ruff==0.7.4",
@@ -20,7 +20,8 @@ dev-dependencies = [
2020
[tool.pytest.ini_options]
2121
pythonpath = "."
2222
testpaths = ["tests"]
23-
addopts = "--cov=example --cov-branch"
23+
addopts = "--cov=custom_components --cov-branch"
24+
asyncio_mode = "auto"
2425

2526
[tool.ruff]
2627
line-length = 120
@@ -42,7 +43,11 @@ select = [
4243
"N", # pep8-naming
4344
]
4445
per-file-ignores = { "custom_components/**/*.py" = [
45-
"ANN101",
46-
], "tests/*.py" = [
47-
"ANN101",
48-
] }
46+
"ANN101",
47+
], "tests/*.py" = [
48+
"ANN001",
49+
"ANN101",
50+
"ANN201",
51+
"ANN202",
52+
"ARG001",
53+
]}

tests/test_api.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
from unittest.mock import patch
2+
3+
import pytest
4+
from aiohttp import web
5+
6+
from custom_components.solis_cloud_control.api import SolisCloudControlApiClient, SolisCloudControlApiError
7+
from custom_components.solis_cloud_control.const import API_READ_ENDPOINT
8+
9+
10+
@pytest.fixture
11+
def create_api_client():
12+
def _create_api_client(session):
13+
return SolisCloudControlApiClient(
14+
base_url="",
15+
api_key="any key",
16+
api_token="any token",
17+
session=session,
18+
)
19+
20+
return _create_api_client
21+
22+
23+
async def test_api_read(create_api_client, aiohttp_client):
24+
any_inverter_sn = "any inverter sn"
25+
any_cid = -1
26+
any_result = "any result"
27+
28+
async def mock_read_endpoint(request):
29+
body = await request.json()
30+
assert body.get("inverterSn") == any_inverter_sn
31+
assert body.get("cid") == any_cid
32+
return web.json_response({"code": "0", "msg": "Success", "data": {"msg": any_result}})
33+
34+
app = web.Application()
35+
app.router.add_route("POST", API_READ_ENDPOINT, mock_read_endpoint)
36+
37+
client = await aiohttp_client(app)
38+
api_client = create_api_client(client)
39+
40+
result = await api_client.read(inverter_sn=any_inverter_sn, cid=any_cid)
41+
42+
assert result == any_result
43+
44+
45+
async def mock_read_endpoint_api_error(request):
46+
return web.json_response({"code": "100", "msg": "API Error"})
47+
48+
49+
async def mock_read_endpoint_missing_data_field(request):
50+
return web.json_response({"code": "0", "msg": "Success"})
51+
52+
53+
async def mock_read_endpoint_missing_msg_field(request):
54+
return web.json_response({"code": "0", "msg": "Success", "data": {"unknown field": "any value"}})
55+
56+
57+
async def mock_read_endpoint_http_error(request):
58+
return web.Response(status=500, text="Internal Server Error")
59+
60+
61+
@pytest.fixture
62+
def mock_retry_request():
63+
async def no_retry_request(request):
64+
return await request()
65+
66+
with patch("custom_components.solis_cloud_control.api._retry_request", new=no_retry_request):
67+
yield
68+
69+
70+
@pytest.fixture(
71+
params=[
72+
(mock_read_endpoint_http_error, SolisCloudControlApiError("Internal Server Error", status_code=500)),
73+
(
74+
mock_read_endpoint_api_error,
75+
SolisCloudControlApiError("API operation failed: API Error", response_code="100"),
76+
),
77+
(
78+
mock_read_endpoint_missing_data_field,
79+
SolisCloudControlApiError("Read failed: 'data' field is missing in response"),
80+
),
81+
(
82+
mock_read_endpoint_missing_msg_field,
83+
SolisCloudControlApiError("Read failed: 'msg' field is missing in response"),
84+
),
85+
]
86+
)
87+
async def test_api_read_errors(request, create_api_client, aiohttp_client, mock_retry_request) -> None:
88+
mock_endpoint, expected_error = request.param
89+
app = web.Application()
90+
app.router.add_route("POST", API_READ_ENDPOINT, mock_endpoint)
91+
92+
client = await aiohttp_client(app)
93+
api_client = create_api_client(client)
94+
95+
with pytest.raises(SolisCloudControlApiError) as excinfo:
96+
await api_client.read(inverter_sn="any inverter", cid=-1)
97+
98+
assert str(excinfo.value) == str(expected_error)

uv.lock

Lines changed: 18 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)