solarlog-cli 0.5.0__tar.gz → 0.6.1__tar.gz

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.
Files changed (43) hide show
  1. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/.coverage +0 -0
  2. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/PKG-INFO +2 -1
  3. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/pyproject.toml +2 -1
  4. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/requirements.txt +1 -0
  5. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/src/solarlog_cli/solarlog_client.py +71 -38
  6. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/src/solarlog_cli/solarlog_connector.py +7 -2
  7. solarlog_cli-0.6.1/tests/fixtures/user_salts.json +16 -0
  8. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/tests/test_solarlog_cli.py +74 -8
  9. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/.devcontainer/devcontainer.json +0 -0
  10. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/.github/workflows/pylint.yml +0 -0
  11. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/.github/workflows/python-publish.yml +0 -0
  12. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/.github/workflows/ruff.yml +0 -0
  13. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/.github/workflows/tests.yml +0 -0
  14. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/.github/workflows/typing.yml +0 -0
  15. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/.gitignore +0 -0
  16. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/.vscode/settings.json +0 -0
  17. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/.vscode/tasks.json +0 -0
  18. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/LICENSE +0 -0
  19. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/README.md +0 -0
  20. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/pytest.ini +0 -0
  21. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/src/solarlog_cli/__init__.py +0 -0
  22. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/src/solarlog_cli/py.typed +0 -0
  23. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/src/solarlog_cli/solarlog_exceptions.py +0 -0
  24. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/src/solarlog_cli/solarlog_models.py +0 -0
  25. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/tests/__init__.py +0 -0
  26. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/tests/__snapshots__/test_solarlog_cli.ambr +0 -0
  27. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/tests/conftest.py +0 -0
  28. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/tests/fixtures/basic_data.json +0 -0
  29. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/tests/fixtures/basic_data_during_update.json +0 -0
  30. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/tests/fixtures/basic_data_no_power.json +0 -0
  31. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/tests/fixtures/battery_data.json +0 -0
  32. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/tests/fixtures/battery_data_without_battery.json +0 -0
  33. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/tests/fixtures/device_data_1.json +0 -0
  34. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/tests/fixtures/device_data_2.json +0 -0
  35. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/tests/fixtures/device_data_3.json +0 -0
  36. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/tests/fixtures/device_data_4.json +0 -0
  37. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/tests/fixtures/device_list.json +0 -0
  38. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/tests/fixtures/device_list_access_denied.json +0 -0
  39. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/tests/fixtures/energy_per_inverter.json +0 -0
  40. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/tests/fixtures/extended_data.json +0 -0
  41. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/tests/fixtures/power_per_inverter.json +0 -0
  42. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/tests/ruff.toml +0 -0
  43. {solarlog_cli-0.5.0 → solarlog_cli-0.6.1}/tests/syrupy.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: solarlog_cli
3
- Version: 0.5.0
3
+ Version: 0.6.1
4
4
  Summary: Python library to access the Solar-Log
5
5
  Project-URL: Repository, https://github.com/dontinelli/solarlog_cli.git
6
6
  Project-URL: Issues, https://github.com/dontinelli/solarlog_cli/issues
@@ -15,6 +15,7 @@ Classifier: Operating System :: OS Independent
15
15
  Classifier: Programming Language :: Python :: 3
16
16
  Requires-Python: >=3.12
17
17
  Requires-Dist: aiohttp
18
+ Requires-Dist: bcrypt>=4.3.0
18
19
  Requires-Dist: mashumaro>=3.13
19
20
  Description-Content-Type: text/markdown
20
21
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "solarlog_cli"
3
- version = "0.5.0"
3
+ version = "0.6.1"
4
4
  authors = [
5
5
  { name="dontinelli", email="73341522+dontinelli@users.noreply.github.com" },
6
6
  ]
@@ -9,6 +9,7 @@ readme = "README.md"
9
9
  requires-python = ">=3.12"
10
10
  dependencies = [
11
11
  "aiohttp",
12
+ "bcrypt>=4.3.0",
12
13
  "mashumaro>=3.13",
13
14
  ]
14
15
  classifiers = [
@@ -1,5 +1,6 @@
1
1
  aiohttp>=3.9.5
2
2
  aioresponses == 0.7.6
3
+ bcrypt >= 4.3.0
3
4
  mashumaro>=3.13
4
5
  pytest-aio==1.9.0
5
6
  pytest-cov==5.0.0
@@ -9,6 +9,7 @@ import logging
9
9
  from typing import Any
10
10
 
11
11
  from aiohttp import ClientResponse, ClientSession, ClientTimeout
12
+ import bcrypt
12
13
 
13
14
  from .solarlog_exceptions import (
14
15
  SolarLogAuthenticationError,
@@ -21,6 +22,7 @@ from .solarlog_models import SolarlogData
21
22
  SOLARLOG_REQUEST_PAYLOAD = '{ "801": { "170": null } }'
22
23
  _LOGGER = logging.getLogger(__name__)
23
24
 
25
+
24
26
  class Client:
25
27
  """Client class to access Solar-Log."""
26
28
 
@@ -29,7 +31,6 @@ class Client:
29
31
  def __init__(self, host: str, session: ClientSession | None, password: str = "") -> None:
30
32
  self.host: str = host
31
33
  self.password: str = password
32
- self.token: str = ""
33
34
 
34
35
  self.request_timeout = 30
35
36
 
@@ -38,9 +39,9 @@ class Client:
38
39
  else:
39
40
  self.session = session
40
41
 
42
+ self._hashed_pwd: bool = False
41
43
  self._close_session: bool = True
42
44
 
43
-
44
45
  async def test_connection(self) -> bool:
45
46
  """Test the connection to Solar-Log."""
46
47
  if self.session is None:
@@ -67,21 +68,54 @@ class Client:
67
68
 
68
69
  payload: str = f"u=user&p={self.password}"
69
70
 
70
- response = await self.execute_http_request(payload,"login")
71
+ response = await self.execute_http_request(payload, "login")
71
72
 
72
73
  text = await response.text()
73
- if text.count("FAILED - Password was wrong"):
74
- raise SolarLogAuthenticationError
74
+ _LOGGER.debug("Response: %s", text)
75
75
  if text.count("FAILED - User was wrong"):
76
- #Response means, that no password is required
76
+ # Response means, that no password is required
77
77
  self.password = ""
78
78
  return False
79
79
 
80
- self.token = response.cookies["SolarLog"].value
80
+ if text.count("FAILED - Password was wrong"):
81
+ # For newer firmware, login with encrypted PWD is required.
82
+ # Therefore test with encrypted PWD and only raise authentication error,
83
+ # if login with encrypted PWD fails as well.
84
+
85
+ payload = '{ "550": None }'
81
86
 
82
- _LOGGER.debug("response: %s",text)
83
- _LOGGER.debug("cookies: %s",response.cookies)
84
- _LOGGER.debug("Login successful, token: %s",self.token)
87
+ response = await self.execute_http_request(payload)
88
+
89
+ text = await response.text()
90
+ _LOGGER.debug("Response to request for user salts: %s", text)
91
+ r_dict: dict[str, Any] = json.loads(text)
92
+
93
+ salt: str = r_dict.get('550', {}).get('104')
94
+ _LOGGER.debug("Salt to hash pwd: %s", salt)
95
+
96
+ try:
97
+ if salt != 'QUERY IMPOSSIBLE 000' and salt is not None:
98
+ hashed_password = bcrypt.hashpw(
99
+ self.password.encode(), salt.encode())
100
+ payload = f"u=user&p={hashed_password.decode('utf-8')}"
101
+ response = await self.execute_http_request(payload, "login")
102
+ text = await response.text()
103
+ _LOGGER.debug(
104
+ "Response of login with hashed pwd: %s", text)
105
+ if text.count("FAILED - Password was wrong"):
106
+ _LOGGER.debug("Wrong password (hashed)")
107
+ raise SolarLogAuthenticationError
108
+ self.password = hashed_password.decode('utf-8')
109
+ except Exception as exception:
110
+ raise SolarLogAuthenticationError from exception
111
+
112
+ self._hashed_pwd = True
113
+
114
+ self.session.cookie_jar.update_cookies({"SolarLog": response.cookies["SolarLog"].value})
115
+
116
+ _LOGGER.debug("response: %s", text)
117
+ _LOGGER.debug("cookies: %s", response.cookies)
118
+ _LOGGER.debug("Login successful, token: %s", response.cookies["SolarLog"].value)
85
119
 
86
120
  return True
87
121
 
@@ -93,12 +127,9 @@ class Client:
93
127
 
94
128
  url = f"{self.host}/{path}"
95
129
 
96
- header = {"Content-Type": "text/html"}
97
- if self.token != "":
98
- header |= {"Cookie": f"SolarLog={self.token}"}
99
- body = f"token={self.token}; " + body
130
+ header = {"Content-Type": "text/html", "X-SL-CSRF-PROTECTION": "1"}
100
131
 
101
- _LOGGER.debug("HTTP-request header: %s",header)
132
+ _LOGGER.debug("HTTP-request header: %s", header)
102
133
  _LOGGER.debug("HTTP-request body: %s", body)
103
134
 
104
135
  try:
@@ -123,19 +154,19 @@ class Client:
123
154
  {"Content-Type": content_type, "response": text},
124
155
  )
125
156
 
126
- _LOGGER.debug("HTTP-request successful: %s",response)
157
+ _LOGGER.debug("HTTP-request successful: %s", response)
127
158
  return response
128
159
 
129
160
  async def parse_http_response(self, response: ClientResponse) -> dict[str, Any]:
130
161
  """Helper function to parse the HTTP response."""
131
162
 
132
- text = await response.text()
133
- _LOGGER.debug("Parsing http response: %s",text)
163
+ text = await response.text(errors="replace")
164
+ _LOGGER.debug("Parsing http response: %s", text)
134
165
 
135
166
  if text.count('{"QUERY IMPOSSIBLE 000"}'):
136
167
  raise SolarLogUpdateError(f"Server response: {text}")
137
168
 
138
- if text.count("ACCESS DENIED"):
169
+ if text.count("ACCESS DENIED") and not text.startswith('{"550":{'):
139
170
  raise SolarLogAuthenticationError(f"Server response: {text}")
140
171
 
141
172
  try:
@@ -158,29 +189,30 @@ class Client:
158
189
  raw_data = raw_data["801"]["170"]
159
190
 
160
191
  data = SolarlogData(
161
- last_updated = datetime.strptime(raw_data["100"], "%d.%m.%y %H:%M:%S"),
162
- power_ac = raw_data["101"],
163
- power_dc = raw_data["102"],
164
- voltage_ac = raw_data["103"],
165
- voltage_dc = raw_data["104"],
166
- yield_day = raw_data["105"],
167
- yield_yesterday = raw_data["106"],
168
- yield_month = raw_data["107"],
169
- yield_year = raw_data["108"],
170
- yield_total = raw_data["109"],
171
- consumption_ac = raw_data["110"],
172
- consumption_day = raw_data["111"],
173
- consumption_yesterday = raw_data["112"],
174
- consumption_month = raw_data["113"],
175
- consumption_year = raw_data["114"],
176
- consumption_total = raw_data["115"],
177
- total_power = raw_data["116"],
192
+ last_updated=datetime.strptime(
193
+ raw_data["100"], "%d.%m.%y %H:%M:%S"),
194
+ power_ac=raw_data["101"],
195
+ power_dc=raw_data["102"],
196
+ voltage_ac=raw_data["103"],
197
+ voltage_dc=raw_data["104"],
198
+ yield_day=raw_data["105"],
199
+ yield_yesterday=raw_data["106"],
200
+ yield_month=raw_data["107"],
201
+ yield_year=raw_data["108"],
202
+ yield_total=raw_data["109"],
203
+ consumption_ac=raw_data["110"],
204
+ consumption_day=raw_data["111"],
205
+ consumption_yesterday=raw_data["112"],
206
+ consumption_month=raw_data["113"],
207
+ consumption_year=raw_data["114"],
208
+ consumption_total=raw_data["115"],
209
+ total_power=raw_data["116"],
178
210
  )
179
211
 
180
212
  return data
181
213
 
182
214
  async def get_battery_data(self) -> list[float]:
183
- """Get power data from Solar-Log"""
215
+ """Get battery data from Solar-Log"""
184
216
 
185
217
  raw_data: dict = await self.parse_http_response(
186
218
  await self.execute_http_request('{ "858": null }')
@@ -197,7 +229,8 @@ class Client:
197
229
  await self.execute_http_request('{ "782": null }')
198
230
  )
199
231
 
200
- data = {int(key): val for key, val in raw_data["782"].items() if val != "0"}
232
+ data = {int(key): val for key,
233
+ val in raw_data["782"].items() if val != "0"}
201
234
 
202
235
  return data
203
236
 
@@ -63,8 +63,13 @@ class SolarLogConnector:
63
63
  return False
64
64
  except SolarLogAuthenticationError as err:
65
65
  #User has no unprotected access to extended API, try to log in
66
- _LOGGER.debug("Authentication error: %s", err)
67
- self.extended_data = await self.login()
66
+ #(login returns false, if no pwd is set)
67
+ _LOGGER.debug("Auth error during test for extended data: %s", err)
68
+ try:
69
+ self.extended_data = await self.login()
70
+ except SolarLogAuthenticationError as error:
71
+ _LOGGER.debug("Auth error during login in test for extended data: %s", err)
72
+ raise SolarLogAuthenticationError from error
68
73
  _LOGGER.debug("Login successful?: %s", self.extended_data)
69
74
  else:
70
75
  self.extended_data = True
@@ -0,0 +1,16 @@
1
+ {
2
+ "550": {
3
+ "100":"ACCESS DENIED",
4
+ "101":"ACCESS DENIED",
5
+ "102":"ACCESS DENIED",
6
+ "103":0,
7
+ "104":"$2b$08$yw.OTuzhjCHukKjYO0diL.",
8
+ "105":"ACCESS DENIED",
9
+ "106":1,
10
+ "107":"SALT2",
11
+ "108":"ACCESS DENIED",
12
+ "109":0,"110":"SALT3",
13
+ "111":"ACCESS DENIED",
14
+ "112":1
15
+ }
16
+ }
@@ -58,7 +58,8 @@ async def test_existing_session(
58
58
  status=200,
59
59
  )
60
60
 
61
- solarlog_connector = SolarLogConnector("localhost", session=ClientSession())
61
+ solarlog_connector = SolarLogConnector(
62
+ "localhost", session=ClientSession())
62
63
 
63
64
  assert await solarlog_connector.test_connection()
64
65
 
@@ -69,12 +70,14 @@ async def test_existing_session(
69
70
  await solarlog_connector.client.close()
70
71
  assert solarlog_connector.client.session.closed
71
72
 
73
+
72
74
  async def test_extended_data_available(
73
75
  responses: aioresponses,
74
76
  ) -> None:
75
77
  """Test extended data available."""
76
78
 
77
- solarlog_connector = SolarLogConnector("http://solarlog.com", password="pwd")
79
+ solarlog_connector = SolarLogConnector(
80
+ "http://solarlog.com", password="pwd")
78
81
 
79
82
  responses.post(
80
83
  "http://solarlog.com/getjp",
@@ -99,6 +102,27 @@ async def test_extended_data_available(
99
102
  assert not await solarlog_connector.test_extended_data_available()
100
103
  assert solarlog_connector.password == ""
101
104
 
105
+ responses.post(
106
+ "http://solarlog.com/getjp",
107
+ body=load_fixture("device_list_access_denied.json"),
108
+ )
109
+ responses.post(
110
+ "http://solarlog.com/login",
111
+ body="FAILED - Password was wrong",
112
+ )
113
+ responses.post(
114
+ "http://solarlog.com/getjp",
115
+ body=load_fixture("user_salts.json"),
116
+ )
117
+ responses.post(
118
+ "http://solarlog.com/login",
119
+ exception=SolarLogAuthenticationError('test'),
120
+ )
121
+ solarlog_connector.client.password = "pwd"
122
+ # type: ignore [call-overload]
123
+ with pytest.raises(SolarLogAuthenticationError):
124
+ await solarlog_connector.test_extended_data_available()
125
+
102
126
  responses.post(
103
127
  "http://solarlog.com/getjp",
104
128
  body=load_fixture("device_list.json"),
@@ -108,6 +132,7 @@ async def test_extended_data_available(
108
132
  await solarlog_connector.client.close()
109
133
  assert solarlog_connector.client.session.closed
110
134
 
135
+
111
136
  async def test_login_and_data_retreival(responses: aioresponses) -> None:
112
137
  """Test login into Solar-Log."""
113
138
  responses.post(
@@ -115,10 +140,10 @@ async def test_login_and_data_retreival(responses: aioresponses) -> None:
115
140
  headers={"Set-Cookie": "SolarLog=token"},
116
141
  body="SUCCESS - Password was correct, you are now logged in",
117
142
  )
118
- solarlog_connector = SolarLogConnector("http://solarlog.com", password="pwd")
143
+ solarlog_connector = SolarLogConnector(
144
+ "http://solarlog.com", password="pwd")
119
145
 
120
146
  assert await solarlog_connector.login()
121
- assert solarlog_connector.client.token == "token"
122
147
 
123
148
  responses.post(
124
149
  "http://solarlog.com/getjp",
@@ -129,15 +154,51 @@ async def test_login_and_data_retreival(responses: aioresponses) -> None:
129
154
  await solarlog_connector.client.close()
130
155
  assert solarlog_connector.client.session.closed
131
156
 
157
+
158
+ async def test_login_hashed_pwd(responses: aioresponses) -> None:
159
+ """Test login into Solar-Log."""
160
+
161
+ responses.post(
162
+ "http://solarlog.com/login",
163
+ body="FAILED - Password was wrong",
164
+ )
165
+ responses.post(
166
+ "http://solarlog.com/getjp",
167
+ body=load_fixture("user_salts.json"),
168
+ )
169
+ responses.post(
170
+ "http://solarlog.com/login",
171
+ headers={"Set-Cookie": "SolarLog=token"},
172
+ body="SUCCESS - Password was correct, you are now logged in",
173
+ )
174
+ solarlog_connector = SolarLogConnector(
175
+ "http://solarlog.com", password="pwd")
176
+
177
+ assert await solarlog_connector.login()
178
+
179
+ await solarlog_connector.client.close()
180
+ assert solarlog_connector.client.session.closed
181
+
182
+
132
183
  async def test_login_exceptions(responses: aioresponses) -> None:
133
184
  """Test exceptions at login into Solar-Log."""
134
- solarlog_connector = SolarLogConnector("http://solarlog.com", password="pwd")
185
+ solarlog_connector = SolarLogConnector(
186
+ "http://solarlog.com", password="pwd")
135
187
 
136
188
  responses.post(
137
189
  "http://solarlog.com/login",
138
190
  body="FAILED - Password was wrong",
139
191
  )
140
- with pytest.raises(SolarLogAuthenticationError): # type: ignore [call-overload]
192
+ responses.post(
193
+ "http://solarlog.com/getjp",
194
+ body=load_fixture("user_salts.json"),
195
+ )
196
+ responses.post(
197
+ "http://solarlog.com/login",
198
+ body="FAILED - Password was wrong",
199
+ )
200
+ # type: ignore [call-overload]
201
+ with pytest.raises(SolarLogAuthenticationError):
141
202
  await solarlog_connector.client.login()
142
203
 
143
204
  responses.post(
@@ -155,6 +216,7 @@ async def test_login_exceptions(responses: aioresponses) -> None:
155
216
  await solarlog_connector.client.close()
156
217
  assert solarlog_connector.client.session.closed
157
218
 
219
+
158
220
  async def test_update_data(
159
221
  responses: aioresponses,
160
222
  snapshot: SnapshotAssertion
@@ -195,6 +257,7 @@ async def test_update_data(
195
257
  await solarlog_connector.client.close()
196
258
  assert solarlog_connector.client.session.closed
197
259
 
260
+
198
261
  async def test_update_data_without_battery(
199
262
  responses: aioresponses,
200
263
  ) -> None:
@@ -234,6 +297,7 @@ async def test_update_data_without_battery(
234
297
  await solarlog_connector.client.close()
235
298
  assert solarlog_connector.client.session.closed
236
299
 
300
+
237
301
  @pytest.mark.parametrize(
238
302
  ("status", "request_timeout", "error"),
239
303
  [
@@ -364,11 +428,13 @@ async def test_enabled_devices(responses: aioresponses) -> None:
364
428
  body=load_fixture("device_list.json"),
365
429
  )
366
430
 
367
- solarlog_connector = SolarLogConnector("http://solarlog.com",device_enabled={})
431
+ solarlog_connector = SolarLogConnector(
432
+ "http://solarlog.com", device_enabled={})
368
433
  assert solarlog_connector.device_enabled() == {}
369
434
  assert await solarlog_connector.update_device_list() == {}
370
435
 
371
- solarlog_connector.set_enabled_devices({0: True, 1: False, 2: False, 3: True})
436
+ solarlog_connector.set_enabled_devices(
437
+ {0: True, 1: False, 2: False, 3: True})
372
438
 
373
439
  assert solarlog_connector.device_enabled(0)
374
440
  assert not solarlog_connector.device_enabled(1)
File without changes
File without changes
File without changes
File without changes