solarlog-cli 0.4.0__tar.gz → 0.6.0__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.4.0 → solarlog_cli-0.6.0}/.coverage +0 -0
  2. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/PKG-INFO +4 -2
  3. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/pyproject.toml +2 -1
  4. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/requirements.txt +1 -0
  5. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/src/solarlog_cli/solarlog_client.py +45 -4
  6. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/src/solarlog_cli/solarlog_connector.py +30 -3
  7. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/src/solarlog_cli/solarlog_models.py +11 -0
  8. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/tests/__snapshots__/test_solarlog_cli.ambr +6 -0
  9. solarlog_cli-0.6.0/tests/fixtures/battery_data.json +3 -0
  10. solarlog_cli-0.6.0/tests/fixtures/battery_data_without_battery.json +3 -0
  11. solarlog_cli-0.6.0/tests/fixtures/user_salts.json +16 -0
  12. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/tests/test_solarlog_cli.py +117 -7
  13. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/.devcontainer/devcontainer.json +0 -0
  14. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/.github/workflows/pylint.yml +0 -0
  15. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/.github/workflows/python-publish.yml +0 -0
  16. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/.github/workflows/ruff.yml +0 -0
  17. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/.github/workflows/tests.yml +0 -0
  18. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/.github/workflows/typing.yml +0 -0
  19. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/.gitignore +0 -0
  20. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/.vscode/settings.json +0 -0
  21. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/.vscode/tasks.json +0 -0
  22. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/LICENSE +0 -0
  23. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/README.md +0 -0
  24. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/pytest.ini +0 -0
  25. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/src/solarlog_cli/__init__.py +0 -0
  26. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/src/solarlog_cli/py.typed +0 -0
  27. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/src/solarlog_cli/solarlog_exceptions.py +0 -0
  28. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/tests/__init__.py +0 -0
  29. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/tests/conftest.py +0 -0
  30. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/tests/fixtures/basic_data.json +0 -0
  31. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/tests/fixtures/basic_data_during_update.json +0 -0
  32. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/tests/fixtures/basic_data_no_power.json +0 -0
  33. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/tests/fixtures/device_data_1.json +0 -0
  34. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/tests/fixtures/device_data_2.json +0 -0
  35. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/tests/fixtures/device_data_3.json +0 -0
  36. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/tests/fixtures/device_data_4.json +0 -0
  37. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/tests/fixtures/device_list.json +0 -0
  38. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/tests/fixtures/device_list_access_denied.json +0 -0
  39. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/tests/fixtures/energy_per_inverter.json +0 -0
  40. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/tests/fixtures/extended_data.json +0 -0
  41. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/tests/fixtures/power_per_inverter.json +0 -0
  42. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/tests/ruff.toml +0 -0
  43. {solarlog_cli-0.4.0 → solarlog_cli-0.6.0}/tests/syrupy.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: solarlog_cli
3
- Version: 0.4.0
3
+ Version: 0.6.0
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
@@ -8,12 +8,14 @@ Project-URL: Changelog, https://github.com/dontinelli/solarlog_cli/blob/master/C
8
8
  Project-URL: Solar-Log homepage, https://www.solar-log.com/
9
9
  Project-URL: API Documentation (inofficial), https://github.com/iobroker-community-adapters/ioBroker.solarlog/blob/master/docs/solarlog_dataobjects.md
10
10
  Author-email: dontinelli <73341522+dontinelli@users.noreply.github.com>
11
+ License-File: LICENSE
11
12
  Keywords: IoT,hass,home assistant,sensor,smart home,solar
12
13
  Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
13
14
  Classifier: Operating System :: OS Independent
14
15
  Classifier: Programming Language :: Python :: 3
15
16
  Requires-Python: >=3.12
16
17
  Requires-Dist: aiohttp
18
+ Requires-Dist: bcrypt>=4.3.0
17
19
  Requires-Dist: mashumaro>=3.13
18
20
  Description-Content-Type: text/markdown
19
21
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "solarlog_cli"
3
- version = "0.4.0"
3
+ version = "0.6.0"
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,
@@ -70,13 +71,42 @@ class Client:
70
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
76
  #Response means, that no password is required
77
77
  self.password = ""
78
78
  return False
79
79
 
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 }'
86
+
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(self.password.encode(), salt.encode())
99
+ payload = f"u=user&p={hashed_password.decode('utf-8')}"
100
+ response = await self.execute_http_request(payload,"login")
101
+ text = await response.text()
102
+ _LOGGER.debug("Response of login with hashed pwd: %s",text)
103
+ if text.count("FAILED - Password was wrong"):
104
+ _LOGGER.debug("Wrong password (hashed)")
105
+ raise SolarLogAuthenticationError
106
+ self.password = hashed_password.decode('utf-8')
107
+ except Exception as exception:
108
+ raise SolarLogAuthenticationError from exception
109
+
80
110
  self.token = response.cookies["SolarLog"].value
81
111
 
82
112
  _LOGGER.debug("response: %s",text)
@@ -93,7 +123,7 @@ class Client:
93
123
 
94
124
  url = f"{self.host}/{path}"
95
125
 
96
- header = {"Content-Type": "text/html"}
126
+ header = {"Content-Type": "text/html", "X-SL-CSRF-PROTECTION": "1"}
97
127
  if self.token != "":
98
128
  header |= {"Cookie": f"SolarLog={self.token}"}
99
129
  body = f"token={self.token}; " + body
@@ -135,7 +165,7 @@ class Client:
135
165
  if text.count('{"QUERY IMPOSSIBLE 000"}'):
136
166
  raise SolarLogUpdateError(f"Server response: {text}")
137
167
 
138
- if text.count("ACCESS DENIED"):
168
+ if text.count("ACCESS DENIED") and not text.startswith('{"550":{'):
139
169
  raise SolarLogAuthenticationError(f"Server response: {text}")
140
170
 
141
171
  try:
@@ -179,6 +209,17 @@ class Client:
179
209
 
180
210
  return data
181
211
 
212
+ async def get_battery_data(self) -> list[float]:
213
+ """Get power data from Solar-Log"""
214
+
215
+ raw_data: dict = await self.parse_http_response(
216
+ await self.execute_http_request('{ "858": null }')
217
+ )
218
+
219
+ data: list[float] = raw_data["858"]
220
+
221
+ return data
222
+
182
223
  async def get_power_per_inverter(self) -> dict[int, float]:
183
224
  """Get power data from Solar-Log"""
184
225
 
@@ -12,7 +12,7 @@ from .solarlog_exceptions import(
12
12
  SolarLogConnectionError,
13
13
  SolarLogUpdateError,
14
14
  )
15
- from .solarlog_models import SolarlogData, InverterData
15
+ from .solarlog_models import BatteryData, InverterData, SolarlogData
16
16
 
17
17
  _LOGGER = logging.getLogger(__name__)
18
18
 
@@ -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
@@ -95,6 +100,8 @@ class SolarLogConnector:
95
100
  if self._device_list != {}:
96
101
  data.inverter_data = await self.update_inverter_data()
97
102
 
103
+ data.battery_data = await self.update_battery_data()
104
+
98
105
  _LOGGER.debug("Extended data updated: %s",data)
99
106
 
100
107
  #calculated values (for downward compatibility)
@@ -112,6 +119,26 @@ class SolarLogConnector:
112
119
 
113
120
  return data
114
121
 
122
+
123
+ async def update_battery_data(self) -> BatteryData | None:
124
+ """Update device specific data."""
125
+
126
+ raw_data = await self.client.get_battery_data()
127
+
128
+ if raw_data == []:
129
+ return None
130
+
131
+ battery_data = BatteryData(
132
+ voltage=raw_data[0],
133
+ level=raw_data[1],
134
+ charge_power=raw_data[2],
135
+ discharge_power=raw_data[3],
136
+ )
137
+
138
+ _LOGGER.debug("Battery data updated: %s",battery_data)
139
+
140
+ return battery_data
141
+
115
142
  async def update_device_list(self) -> dict[int, InverterData]:
116
143
  """Update list of devices."""
117
144
  if not self.extended_data:
@@ -4,6 +4,16 @@ from datetime import datetime
4
4
 
5
5
  from mashumaro import DataClassDictMixin
6
6
 
7
+ @dataclass
8
+ class BatteryData():
9
+ """Battery Data model."""
10
+
11
+ charge_power: float = 0
12
+ discharge_power: float = 0
13
+ level: float = 0
14
+ voltage: float = 0
15
+
16
+
7
17
  @dataclass
8
18
  class InverterData():
9
19
  """Inverter Data model."""
@@ -46,6 +56,7 @@ class SolarlogData(DataClassDictMixin):
46
56
  usage: float | None = None
47
57
 
48
58
  #extended data
59
+ battery_data: BatteryData | None = None
49
60
  inverter_data: dict[int, InverterData] = field(default_factory=dict)
50
61
  production_year: float | None = None
51
62
  self_consumption_year: float | None = None
@@ -2,6 +2,12 @@
2
2
  # name: test_update_data[asyncio]
3
3
  dict({
4
4
  'alternator_loss': 100,
5
+ 'battery_data': dict({
6
+ 'charge_power': 1074,
7
+ 'discharge_power': 0,
8
+ 'level': 79,
9
+ 'voltage': 0,
10
+ }),
5
11
  'capacity': 27.901119402985074,
6
12
  'consumption_ac': 3110,
7
13
  'consumption_day': 47819,
@@ -0,0 +1,3 @@
1
+ {
2
+ "858": [0,79,1074,0]
3
+ }
@@ -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,7 +140,8 @@ 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
147
  assert solarlog_connector.client.token == "token"
@@ -129,15 +155,52 @@ async def test_login_and_data_retreival(responses: aioresponses) -> None:
129
155
  await solarlog_connector.client.close()
130
156
  assert solarlog_connector.client.session.closed
131
157
 
158
+
159
+ async def test_login_hashed_pwd(responses: aioresponses) -> None:
160
+ """Test login into Solar-Log."""
161
+
162
+ responses.post(
163
+ "http://solarlog.com/login",
164
+ body="FAILED - Password was wrong",
165
+ )
166
+ responses.post(
167
+ "http://solarlog.com/getjp",
168
+ body=load_fixture("user_salts.json"),
169
+ )
170
+ responses.post(
171
+ "http://solarlog.com/login",
172
+ headers={"Set-Cookie": "SolarLog=token"},
173
+ body="SUCCESS - Password was correct, you are now logged in",
174
+ )
175
+ solarlog_connector = SolarLogConnector(
176
+ "http://solarlog.com", password="pwd")
177
+
178
+ assert await solarlog_connector.login()
179
+ assert solarlog_connector.client.token == "token"
180
+
181
+ await solarlog_connector.client.close()
182
+ assert solarlog_connector.client.session.closed
183
+
184
+
132
185
  async def test_login_exceptions(responses: aioresponses) -> None:
133
186
  """Test exceptions at login into Solar-Log."""
134
- solarlog_connector = SolarLogConnector("http://solarlog.com", password="pwd")
187
+ solarlog_connector = SolarLogConnector(
188
+ "http://solarlog.com", password="pwd")
135
189
 
136
190
  responses.post(
137
191
  "http://solarlog.com/login",
138
192
  body="FAILED - Password was wrong",
139
193
  )
140
- with pytest.raises(SolarLogAuthenticationError): # type: ignore [call-overload]
194
+ responses.post(
195
+ "http://solarlog.com/getjp",
196
+ body=load_fixture("user_salts.json"),
197
+ )
198
+ responses.post(
199
+ "http://solarlog.com/login",
200
+ body="FAILED - Password was wrong",
201
+ )
202
+ # type: ignore [call-overload]
203
+ with pytest.raises(SolarLogAuthenticationError):
141
204
  await solarlog_connector.client.login()
142
205
 
143
206
  responses.post(
@@ -155,6 +218,7 @@ async def test_login_exceptions(responses: aioresponses) -> None:
155
218
  await solarlog_connector.client.close()
156
219
  assert solarlog_connector.client.session.closed
157
220
 
221
+
158
222
  async def test_update_data(
159
223
  responses: aioresponses,
160
224
  snapshot: SnapshotAssertion
@@ -176,6 +240,10 @@ async def test_update_data(
176
240
  "http://solarlog.com/getjp",
177
241
  body=load_fixture("energy_per_inverter.json"),
178
242
  )
243
+ responses.post(
244
+ "http://solarlog.com/getjp",
245
+ body=load_fixture("battery_data.json"),
246
+ )
179
247
 
180
248
  solarlog_connector = SolarLogConnector(
181
249
  "http://solarlog.com",
@@ -192,6 +260,46 @@ async def test_update_data(
192
260
  assert solarlog_connector.client.session.closed
193
261
 
194
262
 
263
+ async def test_update_data_without_battery(
264
+ responses: aioresponses,
265
+ ) -> None:
266
+ """Test update data."""
267
+ responses.post(
268
+ "http://solarlog.com/getjp",
269
+ body=load_fixture("basic_data.json"),
270
+ )
271
+ responses.post(
272
+ "http://solarlog.com/getjp",
273
+ body=load_fixture("extended_data.json"),
274
+ )
275
+ responses.post(
276
+ "http://solarlog.com/getjp",
277
+ body=load_fixture("power_per_inverter.json"),
278
+ )
279
+ responses.post(
280
+ "http://solarlog.com/getjp",
281
+ body=load_fixture("energy_per_inverter.json"),
282
+ )
283
+ responses.post(
284
+ "http://solarlog.com/getjp",
285
+ body=load_fixture("battery_data_without_battery.json"),
286
+ )
287
+
288
+ solarlog_connector = SolarLogConnector(
289
+ "http://solarlog.com",
290
+ True,
291
+ "UTC",
292
+ {0: True, 1: True, 2: False, 3: True},
293
+ )
294
+
295
+ data = await solarlog_connector.update_data()
296
+
297
+ assert data.battery_data is None
298
+
299
+ await solarlog_connector.client.close()
300
+ assert solarlog_connector.client.session.closed
301
+
302
+
195
303
  @pytest.mark.parametrize(
196
304
  ("status", "request_timeout", "error"),
197
305
  [
@@ -322,11 +430,13 @@ async def test_enabled_devices(responses: aioresponses) -> None:
322
430
  body=load_fixture("device_list.json"),
323
431
  )
324
432
 
325
- solarlog_connector = SolarLogConnector("http://solarlog.com",device_enabled={})
433
+ solarlog_connector = SolarLogConnector(
434
+ "http://solarlog.com", device_enabled={})
326
435
  assert solarlog_connector.device_enabled() == {}
327
436
  assert await solarlog_connector.update_device_list() == {}
328
437
 
329
- solarlog_connector.set_enabled_devices({0: True, 1: False, 2: False, 3: True})
438
+ solarlog_connector.set_enabled_devices(
439
+ {0: True, 1: False, 2: False, 3: True})
330
440
 
331
441
  assert solarlog_connector.device_enabled(0)
332
442
  assert not solarlog_connector.device_enabled(1)
File without changes
File without changes
File without changes
File without changes