python-openevse-http 0.2.4__tar.gz → 0.2.6__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 (21) hide show
  1. {python_openevse_http-0.2.4/python_openevse_http.egg-info → python_openevse_http-0.2.6}/PKG-INFO +3 -5
  2. {python_openevse_http-0.2.4 → python_openevse_http-0.2.6}/openevsehttp/__main__.py +132 -172
  3. {python_openevse_http-0.2.4 → python_openevse_http-0.2.6}/openevsehttp/websocket.py +1 -1
  4. python_openevse_http-0.2.6/pyproject.toml +32 -0
  5. {python_openevse_http-0.2.4 → python_openevse_http-0.2.6/python_openevse_http.egg-info}/PKG-INFO +3 -5
  6. {python_openevse_http-0.2.4 → python_openevse_http-0.2.6}/python_openevse_http.egg-info/SOURCES.txt +1 -0
  7. {python_openevse_http-0.2.4 → python_openevse_http-0.2.6}/setup.py +3 -5
  8. {python_openevse_http-0.2.4 → python_openevse_http-0.2.6}/tests/test_external_session.py +1 -2
  9. {python_openevse_http-0.2.4 → python_openevse_http-0.2.6}/tests/test_main.py +53 -4
  10. {python_openevse_http-0.2.4 → python_openevse_http-0.2.6}/tests/test_websocket.py +0 -1
  11. {python_openevse_http-0.2.4 → python_openevse_http-0.2.6}/LICENSE +0 -0
  12. {python_openevse_http-0.2.4 → python_openevse_http-0.2.6}/README.md +0 -0
  13. {python_openevse_http-0.2.4 → python_openevse_http-0.2.6}/openevsehttp/__init__.py +0 -0
  14. {python_openevse_http-0.2.4 → python_openevse_http-0.2.6}/openevsehttp/const.py +0 -0
  15. {python_openevse_http-0.2.4 → python_openevse_http-0.2.6}/openevsehttp/exceptions.py +0 -0
  16. {python_openevse_http-0.2.4 → python_openevse_http-0.2.6}/python_openevse_http.egg-info/dependency_links.txt +0 -0
  17. {python_openevse_http-0.2.4 → python_openevse_http-0.2.6}/python_openevse_http.egg-info/not-zip-safe +0 -0
  18. {python_openevse_http-0.2.4 → python_openevse_http-0.2.6}/python_openevse_http.egg-info/requires.txt +0 -0
  19. {python_openevse_http-0.2.4 → python_openevse_http-0.2.6}/python_openevse_http.egg-info/top_level.txt +0 -0
  20. {python_openevse_http-0.2.4 → python_openevse_http-0.2.6}/setup.cfg +0 -0
  21. {python_openevse_http-0.2.4 → python_openevse_http-0.2.6}/tests/test_main_edge_cases.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python_openevse_http
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: Python wrapper for OpenEVSE HTTP API
5
5
  Home-page: https://github.com/firstof9/python-openevse-http
6
6
  Download-URL: https://github.com/firstof9/python-openevse-http
@@ -11,12 +11,10 @@ Classifier: Development Status :: 4 - Beta
11
11
  Classifier: Intended Audience :: Developers
12
12
  Classifier: Natural Language :: English
13
13
  Classifier: Programming Language :: Python :: 3
14
- Classifier: Programming Language :: Python :: 3.10
15
- Classifier: Programming Language :: Python :: 3.11
16
- Classifier: Programming Language :: Python :: 3.12
17
14
  Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
18
16
  Classifier: License :: OSI Approved :: Apache Software License
19
- Requires-Python: >=3.10
17
+ Requires-Python: >=3.13
20
18
  Description-Content-Type: text/markdown
21
19
  License-File: LICENSE
22
20
  Requires-Dist: aiohttp
@@ -6,8 +6,9 @@ import asyncio
6
6
  import json
7
7
  import logging
8
8
  import re
9
+ from collections.abc import Callable
9
10
  from datetime import datetime, timedelta, timezone
10
- from typing import Any, Callable, Dict, Union
11
+ from typing import Any
11
12
 
12
13
  import aiohttp # type: ignore
13
14
  from aiohttp.client_exceptions import ContentTypeError, ServerTimeoutError
@@ -122,112 +123,79 @@ class OpenEVSE:
122
123
  auth = aiohttp.BasicAuth(self._user, self._pwd)
123
124
 
124
125
  # Use provided session or create a temporary one
125
- if self._session is not None:
126
- session = self._session
127
- http_method = getattr(session, method)
128
- _LOGGER.debug(
129
- "Connecting to %s with data: %s rapi: %s using method %s",
130
- url,
131
- data,
132
- rapi,
133
- method,
134
- )
135
- try:
136
- async with http_method(
137
- url,
138
- data=rapi,
139
- json=data,
140
- auth=auth,
141
- ) as resp:
142
- try:
143
- message = await resp.text()
144
- except UnicodeDecodeError:
145
- _LOGGER.debug("Decoding error")
146
- message = await resp.read()
147
- message = message.decode(errors="replace")
148
-
149
- try:
150
- message = json.loads(message)
151
- except ValueError:
152
- _LOGGER.warning("Non JSON response: %s", message)
153
-
154
- if resp.status == 400:
155
- index = ""
156
- if "msg" in message.keys():
157
- index = "msg"
158
- elif "error" in message.keys():
159
- index = "error"
160
- _LOGGER.error("Error 400: %s", message[index])
161
- raise ParseJSONError
162
- if resp.status == 401:
163
- _LOGGER.error("Authentication error: %s", message)
164
- raise AuthenticationError
165
- if resp.status in [404, 405, 500]:
166
- _LOGGER.warning("%s", message)
167
-
168
- if method == "post" and "config_version" in message:
169
- await self.update()
170
- return message
171
-
172
- except (TimeoutError, ServerTimeoutError) as err:
173
- _LOGGER.error("%s: %s", ERROR_TIMEOUT, url)
174
- raise err
175
- except ContentTypeError as err:
176
- _LOGGER.error("Content error: %s", err.message)
177
- raise err
178
- else:
126
+ if (session := self._session) is None:
179
127
  async with aiohttp.ClientSession() as session:
180
- http_method = getattr(session, method)
181
- _LOGGER.debug(
182
- "Connecting to %s with data: %s rapi: %s using method %s",
183
- url,
184
- data,
185
- rapi,
186
- method,
128
+ return await self._process_request_with_session(
129
+ session, url, method, data, rapi, auth
187
130
  )
131
+ else:
132
+ return await self._process_request_with_session(
133
+ session, url, method, data, rapi, auth
134
+ )
135
+
136
+ async def _process_request_with_session(
137
+ self,
138
+ session: aiohttp.ClientSession,
139
+ url: str,
140
+ method: str,
141
+ data: Any,
142
+ rapi: Any,
143
+ auth: Any,
144
+ ) -> dict[str, str] | dict[str, Any]:
145
+ """Process a request with a given session."""
146
+ http_method = getattr(session, method)
147
+ _LOGGER.debug(
148
+ "Connecting to %s with data: %s rapi: %s using method %s",
149
+ url,
150
+ data,
151
+ rapi,
152
+ method,
153
+ )
154
+ try:
155
+ kwargs = {"data": rapi, "auth": auth}
156
+ if data is not None:
157
+ kwargs["json"] = data
158
+ async with http_method(url, **kwargs) as resp:
188
159
  try:
189
- async with http_method(
190
- url,
191
- data=rapi,
192
- json=data,
193
- auth=auth,
194
- ) as resp:
195
- try:
196
- message = await resp.text()
197
- except UnicodeDecodeError:
198
- _LOGGER.debug("Decoding error")
199
- message = await resp.read()
200
- message = message.decode(errors="replace")
201
-
202
- try:
203
- message = json.loads(message)
204
- except ValueError:
205
- _LOGGER.warning("Non JSON response: %s", message)
206
-
207
- if resp.status == 400:
208
- index = ""
209
- if "msg" in message.keys():
210
- index = "msg"
211
- elif "error" in message.keys():
212
- index = "error"
213
- _LOGGER.error("Error 400: %s", message[index])
214
- raise ParseJSONError
215
- if resp.status == 401:
216
- _LOGGER.error("Authentication error: %s", message)
217
- raise AuthenticationError
218
- if resp.status in [404, 405, 500]:
219
- _LOGGER.warning("%s", message)
220
-
221
- if method == "post" and "config_version" in message:
222
- await self.update()
223
- return message
224
-
225
- except (TimeoutError, ServerTimeoutError) as err:
226
- _LOGGER.error("%s: %s", ERROR_TIMEOUT, url)
227
- raise err
228
- except ContentTypeError as err:
229
- _LOGGER.error("Content error: %s", err.message)
230
- raise err
160
+ message = await resp.text()
161
+ except UnicodeDecodeError:
162
+ _LOGGER.debug("Decoding error")
163
+ message = await resp.read()
164
+ message = message.decode(errors="replace")
165
+
166
+ try:
167
+ message = json.loads(message)
168
+ except ValueError:
169
+ _LOGGER.warning("Non JSON response: %s", message)
170
+
171
+ if resp.status == 400:
172
+ if isinstance(message, dict) and "msg" in message:
173
+ _LOGGER.error("Error 400: %s", message["msg"])
174
+ elif isinstance(message, dict) and "error" in message:
175
+ _LOGGER.error("Error 400: %s", message["error"])
176
+ else:
177
+ _LOGGER.error("Error 400: %s", message)
178
+ raise ParseJSONError
179
+ if resp.status == 401:
180
+ _LOGGER.error("Authentication error: %s", message)
181
+ raise AuthenticationError
182
+ if resp.status in [404, 405, 500]:
183
+ _LOGGER.warning("%s", message)
184
+
185
+ if (
186
+ method == "post"
187
+ and isinstance(message, dict)
188
+ and "config_version" in message
189
+ ):
190
+ await self.update()
191
+ return message
192
+
193
+ except (TimeoutError, ServerTimeoutError):
194
+ _LOGGER.error("%s: %s", ERROR_TIMEOUT, url)
195
+ raise
196
+ except ContentTypeError as err:
197
+ _LOGGER.error("Content error: %s", err.message)
198
+ raise
231
199
 
232
200
  async def send_command(self, command: str) -> tuple:
233
201
  """Send a RAPI command to the charger and parses the response."""
@@ -387,7 +355,7 @@ class OpenEVSE:
387
355
  await asyncio.sleep(interval)
388
356
  await func(*args, **kwargs)
389
357
 
390
- async def get_schedule(self) -> Union[Dict[str, str], Dict[str, Any]]:
358
+ async def get_schedule(self) -> dict[str, str] | dict[str, Any]:
391
359
  """Return the current schedule."""
392
360
  url = f"{self.url}schedule"
393
361
 
@@ -406,9 +374,7 @@ class OpenEVSE:
406
374
  data = {"charge_mode": mode}
407
375
 
408
376
  _LOGGER.debug("Setting charge mode to %s", mode)
409
- response = await self.process_request(
410
- url=url, method="post", data=data
411
- ) # noqa: E501
377
+ response = await self.process_request(url=url, method="post", data=data)
412
378
  result = response["msg"]
413
379
  if result not in ["done", "no change"]:
414
380
  _LOGGER.error("Problem issuing command: %s", response["msg"])
@@ -433,13 +399,11 @@ class OpenEVSE:
433
399
  data = {"divert_enabled": mode}
434
400
 
435
401
  _LOGGER.debug("Toggling divert: %s", mode)
436
- response = await self.process_request(
437
- url=url, method="post", data=data
438
- ) # noqa: E501
402
+ response = await self.process_request(url=url, method="post", data=data)
439
403
  _LOGGER.debug("divert_mode response: %s", response)
440
404
  return response
441
405
 
442
- async def get_override(self) -> Union[Dict[str, str], Dict[str, Any]]:
406
+ async def get_override(self) -> dict[str, str] | dict[str, Any]:
443
407
  """Get the manual override status."""
444
408
  if not self._version_check("4.0.1"):
445
409
  _LOGGER.debug("Feature not supported for older firmware.")
@@ -486,9 +450,7 @@ class OpenEVSE:
486
450
 
487
451
  _LOGGER.debug("Override data: %s", data)
488
452
  _LOGGER.debug("Setting override config on %s", url)
489
- response = await self.process_request(
490
- url=url, method="post", data=data
491
- ) # noqa: E501
453
+ response = await self.process_request(url=url, method="post", data=data)
492
454
  return response
493
455
 
494
456
  async def toggle_override(self) -> None:
@@ -559,9 +521,7 @@ class OpenEVSE:
559
521
  data = {"service": level}
560
522
 
561
523
  _LOGGER.debug("Set service level to: %s", level)
562
- response = await self.process_request(
563
- url=url, method="post", data=data
564
- ) # noqa: E501
524
+ response = await self.process_request(url=url, method="post", data=data)
565
525
  _LOGGER.debug("service response: %s", response)
566
526
  result = response["msg"]
567
527
  if result not in ["done", "no change"]:
@@ -633,42 +593,11 @@ class OpenEVSE:
633
593
  return None
634
594
 
635
595
  try:
636
- if self._session:
637
- session = self._session
638
- http_method = getattr(session, method)
639
- _LOGGER.debug(
640
- "Connecting to %s using method %s",
641
- url,
642
- method,
643
- )
644
- async with http_method(url) as resp:
645
- if resp.status != 200:
646
- return None
647
- message = await resp.text()
648
- message = json.loads(message)
649
- response = {}
650
- response["latest_version"] = message["tag_name"]
651
- response["release_notes"] = message["body"]
652
- response["release_url"] = message["html_url"]
653
- return response
654
- else:
596
+ if (session := self._session) is None:
655
597
  async with aiohttp.ClientSession() as session:
656
- http_method = getattr(session, method)
657
- _LOGGER.debug(
658
- "Connecting to %s using method %s",
659
- url,
660
- method,
661
- )
662
- async with http_method(url) as resp:
663
- if resp.status != 200:
664
- return None
665
- message = await resp.text()
666
- message = json.loads(message)
667
- response = {}
668
- response["latest_version"] = message["tag_name"]
669
- response["release_notes"] = message["body"]
670
- response["release_url"] = message["html_url"]
671
- return response
598
+ return await self._firmware_check_with_session(session, url, method)
599
+ else:
600
+ return await self._firmware_check_with_session(session, url, method)
672
601
 
673
602
  except (TimeoutError, ServerTimeoutError):
674
603
  _LOGGER.error("%s: %s", ERROR_TIMEOUT, url)
@@ -679,6 +608,33 @@ class OpenEVSE:
679
608
 
680
609
  return None
681
610
 
611
+ async def _firmware_check_with_session(
612
+ self, session: aiohttp.ClientSession, url: str, method: str
613
+ ) -> dict | None:
614
+ """Process a firmware check request with a given session."""
615
+ http_method = getattr(session, method)
616
+ _LOGGER.debug(
617
+ "Connecting to %s using method %s",
618
+ url,
619
+ method,
620
+ )
621
+ async with http_method(url) as resp:
622
+ if resp.status != 200:
623
+ return None
624
+ message = await resp.text()
625
+ try:
626
+ message = json.loads(message)
627
+ except json.JSONDecodeError:
628
+ _LOGGER.error("Failed to parse JSON response: %s", message)
629
+ return None
630
+
631
+ response = {}
632
+ if isinstance(message, dict):
633
+ response["latest_version"] = message.get("tag_name")
634
+ response["release_notes"] = message.get("body")
635
+ response["release_url"] = message.get("html_url")
636
+ return response
637
+
682
638
  def _version_check(self, min_version: str, max_version: str = "") -> bool:
683
639
  """Return bool if minimum version is met."""
684
640
  if "version" not in self._config:
@@ -831,7 +787,7 @@ class OpenEVSE:
831
787
  raise UnsupportedFeature
832
788
 
833
789
  url = f"{self.url}limit"
834
- data: Dict[str, Any] = await self.get_limit()
790
+ data: dict[str, Any] = await self.get_limit()
835
791
  valid_types = ["time", "energy", "soc", "range"]
836
792
 
837
793
  if limit_type not in valid_types:
@@ -844,9 +800,7 @@ class OpenEVSE:
844
800
 
845
801
  _LOGGER.debug("Limit data: %s", data)
846
802
  _LOGGER.debug("Setting limit config on %s", url)
847
- response = await self.process_request(
848
- url=url, method="post", data=data
849
- ) # noqa: E501
803
+ response = await self.process_request(url=url, method="post", data=data)
850
804
  return response
851
805
 
852
806
  async def clear_limit(self) -> Any:
@@ -856,12 +810,9 @@ class OpenEVSE:
856
810
  raise UnsupportedFeature
857
811
 
858
812
  url = f"{self.url}limit"
859
- data: Dict[str, Any] = {}
860
813
 
861
814
  _LOGGER.debug("Clearing limit config on %s", url)
862
- response = await self.process_request(
863
- url=url, method="delete", data=data
864
- ) # noqa: E501
815
+ response = await self.process_request(url=url, method="delete")
865
816
  return response
866
817
 
867
818
  async def get_limit(self) -> Any:
@@ -871,12 +822,9 @@ class OpenEVSE:
871
822
  raise UnsupportedFeature
872
823
 
873
824
  url = f"{self.url}limit"
874
- data: Dict[str, Any] = {}
875
825
 
876
826
  _LOGGER.debug("Getting limit config on %s", url)
877
- response = await self.process_request(
878
- url=url, method="get", data=data
879
- ) # noqa: E501
827
+ response = await self.process_request(url=url, method="get")
880
828
  return response
881
829
 
882
830
  async def make_claim(
@@ -911,9 +859,7 @@ class OpenEVSE:
911
859
 
912
860
  _LOGGER.debug("Claim data: %s", data)
913
861
  _LOGGER.debug("Setting up claim on %s", url)
914
- response = await self.process_request(
915
- url=url, method="post", data=data
916
- ) # noqa: E501
862
+ response = await self.process_request(url=url, method="post", data=data)
917
863
  return response
918
864
 
919
865
  async def release_claim(self, client: int = CLIENT) -> Any:
@@ -925,7 +871,7 @@ class OpenEVSE:
925
871
  url = f"{self.url}claims/{client}"
926
872
 
927
873
  _LOGGER.debug("Releasing claim on %s", url)
928
- response = await self.process_request(url=url, method="delete") # noqa: E501
874
+ response = await self.process_request(url=url, method="delete")
929
875
  return response
930
876
 
931
877
  async def list_claims(self, target: bool | None = None) -> Any:
@@ -941,7 +887,7 @@ class OpenEVSE:
941
887
  url = f"{self.url}claims{target_check}"
942
888
 
943
889
  _LOGGER.debug("Getting claims on %s", url)
944
- response = await self.process_request(url=url, method="get") # noqa: E501
890
+ response = await self.process_request(url=url, method="get")
945
891
  return response
946
892
 
947
893
  async def set_led_brightness(self, level: int) -> None:
@@ -955,7 +901,7 @@ class OpenEVSE:
955
901
 
956
902
  data["led_brightness"] = level
957
903
  _LOGGER.debug("Setting LED brightness to %s", level)
958
- await self.process_request(url=url, method="post", data=data) # noqa: E501
904
+ await self.process_request(url=url, method="post", data=data)
959
905
 
960
906
  async def set_divert_mode(self, mode: str = "fast") -> None:
961
907
  """Set the divert mode."""
@@ -1312,6 +1258,20 @@ class OpenEVSE:
1312
1258
  return round(self._status["voltage"] * self._status["amp"], 2)
1313
1259
  return None
1314
1260
 
1261
+ # Shaper HTTP Posting
1262
+ async def set_shaper_live_pwr(self, power: int) -> None:
1263
+ """Send pushed sensor data to shaper."""
1264
+ if not self._version_check("4.0.0"):
1265
+ _LOGGER.debug("Feature not supported for older firmware.")
1266
+ raise UnsupportedFeature
1267
+
1268
+ url = f"{self.url}status"
1269
+ data = {"shaper_live_pwr": power}
1270
+
1271
+ _LOGGER.debug("Posting shaper data: %s", data)
1272
+ response = await self.process_request(url=url, method="post", data=data)
1273
+ _LOGGER.debug("Shaper response: %s", response)
1274
+
1315
1275
  # Shaper values
1316
1276
  @property
1317
1277
  def shaper_active(self) -> bool | None:
@@ -1404,7 +1364,7 @@ class OpenEVSE:
1404
1364
  # Safety counts
1405
1365
  @property
1406
1366
  def checks_count(self) -> dict:
1407
- """Return the saftey checks counts."""
1367
+ """Return the safety checks counts."""
1408
1368
  attributes = ("gfcicount", "nogndcount", "stuckcount")
1409
1369
  counts = {}
1410
1370
  if self._status is not None and set(attributes).issubset(self._status.keys()):
@@ -170,7 +170,7 @@ class OpenEVSEWebsocket:
170
170
  if self._ping and self._pong:
171
171
  time_delta = self._pong - self._ping
172
172
  if time_delta < datetime.timedelta(0):
173
- # Negitive time should indicate no pong reply so consider the
173
+ # Negative time should indicate no pong reply so consider the
174
174
  # websocket disconnected.
175
175
  self._error_reason = ERROR_PING_TIMEOUT
176
176
  await self._set_state(STATE_DISCONNECTED)
@@ -0,0 +1,32 @@
1
+ [tool.ruff]
2
+ target-version = "py310"
3
+ line-length = 88
4
+
5
+ [tool.ruff.lint]
6
+ select = [
7
+ "E", # pycodestyle errors
8
+ "W", # pycodestyle warnings
9
+ "F", # pyflakes
10
+ "I", # isort
11
+ "C", # flake8-comprehensions
12
+ "B", # flake8-bugbear
13
+ "UP", # pyupgrade
14
+ "D", # pydocstyle
15
+ ]
16
+ ignore = [
17
+ "D100", # missing docstring in public module
18
+ "D101", # missing docstring in public class
19
+ "D102", # missing docstring in public method
20
+ "D103", # missing docstring in public function
21
+ "D104", # missing docstring in public package
22
+ "D105", # missing docstring in magic method
23
+ "D106", # missing docstring in public nested class
24
+ "D107", # missing docstring in __init__
25
+ "D202", # No blank lines allowed after function docstring
26
+ "D203", # 1 blank line required before class docstring
27
+ "D213", # Multi-line docstring summary should start at the second line
28
+ "E501", # line too long
29
+ ]
30
+
31
+ [tool.ruff.lint.mccabe]
32
+ max-complexity = 18
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python_openevse_http
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: Python wrapper for OpenEVSE HTTP API
5
5
  Home-page: https://github.com/firstof9/python-openevse-http
6
6
  Download-URL: https://github.com/firstof9/python-openevse-http
@@ -11,12 +11,10 @@ Classifier: Development Status :: 4 - Beta
11
11
  Classifier: Intended Audience :: Developers
12
12
  Classifier: Natural Language :: English
13
13
  Classifier: Programming Language :: Python :: 3
14
- Classifier: Programming Language :: Python :: 3.10
15
- Classifier: Programming Language :: Python :: 3.11
16
- Classifier: Programming Language :: Python :: 3.12
17
14
  Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
18
16
  Classifier: License :: OSI Approved :: Apache Software License
19
- Requires-Python: >=3.10
17
+ Requires-Python: >=3.13
20
18
  Description-Content-Type: text/markdown
21
19
  License-File: LICENSE
22
20
  Requires-Dist: aiohttp
@@ -1,5 +1,6 @@
1
1
  LICENSE
2
2
  README.md
3
+ pyproject.toml
3
4
  setup.py
4
5
  openevsehttp/__init__.py
5
6
  openevsehttp/__main__.py
@@ -6,7 +6,7 @@ from setuptools import find_packages, setup
6
6
 
7
7
  PROJECT_DIR = Path(__file__).parent.resolve()
8
8
  README_FILE = PROJECT_DIR / "README.md"
9
- VERSION = "0.2.4"
9
+ VERSION = "0.2.6"
10
10
 
11
11
  setup(
12
12
  name="python_openevse_http",
@@ -19,7 +19,7 @@ setup(
19
19
  long_description=README_FILE.read_text(encoding="utf-8"),
20
20
  long_description_content_type="text/markdown",
21
21
  packages=find_packages(exclude=["test.*", "tests"]),
22
- python_requires=">=3.10",
22
+ python_requires=">=3.13",
23
23
  install_requires=["aiohttp"],
24
24
  license="Apache-2.0",
25
25
  entry_points={},
@@ -30,10 +30,8 @@ setup(
30
30
  "Intended Audience :: Developers",
31
31
  "Natural Language :: English",
32
32
  "Programming Language :: Python :: 3",
33
- "Programming Language :: Python :: 3.10",
34
- "Programming Language :: Python :: 3.11",
35
- "Programming Language :: Python :: 3.12",
36
33
  "Programming Language :: Python :: 3.13",
34
+ "Programming Language :: Python :: 3.14",
37
35
  "License :: OSI Approved :: Apache Software License",
38
36
  ],
39
37
  )
@@ -1,12 +1,11 @@
1
1
  """Test external session management."""
2
2
 
3
3
  import json
4
- from unittest.mock import AsyncMock, MagicMock, patch
4
+ from unittest.mock import AsyncMock, MagicMock
5
5
 
6
6
  import aiohttp
7
7
  import pytest
8
8
 
9
- import openevsehttp.__main__ as main
10
9
  from openevsehttp.__main__ import OpenEVSE
11
10
  from tests.common import load_fixture
12
11
 
@@ -11,7 +11,6 @@ import aiohttp
11
11
  import pytest
12
12
  from aiohttp.client_exceptions import ContentTypeError, ServerTimeoutError
13
13
  from aiohttp.client_reqrep import ConnectionKey
14
- from awesomeversion import AwesomeVersion
15
14
  from awesomeversion.exceptions import AwesomeVersionCompareException
16
15
  from freezegun import freeze_time
17
16
 
@@ -151,6 +150,16 @@ async def test_send_command_parse_err(test_charger_auth, mock_aioclient):
151
150
  status = await test_charger_auth.send_command("test")
152
151
  assert status is None
153
152
 
153
+ mock_aioclient.post(TEST_URL_RAPI, status=400, body='{"other": "Something else"}')
154
+ with pytest.raises(main.ParseJSONError):
155
+ status = await test_charger_auth.send_command("test")
156
+ assert status is None
157
+
158
+ mock_aioclient.post(TEST_URL_RAPI, status=400, body='"Just a string response"')
159
+ with pytest.raises(main.ParseJSONError):
160
+ status = await test_charger_auth.send_command("test")
161
+ assert status is None
162
+
154
163
 
155
164
  async def test_send_command_auth_err(test_charger_auth, mock_aioclient):
156
165
  """Test v4 Status reply."""
@@ -855,7 +864,7 @@ async def test_toggle_override_v2_err(test_charger_v2, mock_aioclient, caplog):
855
864
  """Test v4 Status reply."""
856
865
  await test_charger_v2.update()
857
866
  content_error = mock.Mock()
858
- setattr(content_error, "real_url", f"{TEST_URL_RAPI}")
867
+ content_error.real_url = f"{TEST_URL_RAPI}"
859
868
  mock_aioclient.post(
860
869
  TEST_URL_RAPI,
861
870
  exception=ContentTypeError(
@@ -1070,7 +1079,7 @@ async def test_set_divertmode(
1070
1079
 
1071
1080
 
1072
1081
  async def test_test_and_get(test_charger, test_charger_v2, mock_aioclient, caplog):
1073
- """Test v4 Status reply"""
1082
+ """Test v4 Status reply."""
1074
1083
  data = await test_charger.test_and_get()
1075
1084
  mock_aioclient.get(
1076
1085
  TEST_URL_CONFIG,
@@ -1109,7 +1118,7 @@ async def test_firmware_check(
1109
1118
  mock_aioclient,
1110
1119
  caplog,
1111
1120
  ):
1112
- """Test v4 Status reply"""
1121
+ """Test v4 Status reply."""
1113
1122
  await test_charger.update()
1114
1123
  mock_aioclient.get(
1115
1124
  TEST_URL_GITHUB_v4,
@@ -1271,6 +1280,38 @@ async def test_shaper_max_power(fixture, expected, request):
1271
1280
  await charger.ws_disconnect()
1272
1281
 
1273
1282
 
1283
+ async def test_set_shaper_live_power(
1284
+ test_charger, test_charger_v2, mock_aioclient, caplog
1285
+ ):
1286
+ """Test setting shaper live power."""
1287
+ await test_charger.update()
1288
+ mock_aioclient.post(
1289
+ TEST_URL_STATUS,
1290
+ status=200,
1291
+ body='{"shaper_live_pwr": 210}',
1292
+ )
1293
+ with caplog.at_level(logging.DEBUG):
1294
+ await test_charger.set_shaper_live_pwr(210)
1295
+ assert "Posting shaper data: {'shaper_live_pwr': 210}" in caplog.text
1296
+ assert "Shaper response: {'shaper_live_pwr': 210}" in caplog.text
1297
+
1298
+ mock_aioclient.post(
1299
+ TEST_URL_STATUS,
1300
+ status=200,
1301
+ body='{"shaper_live_pwr": 0}',
1302
+ )
1303
+ with caplog.at_level(logging.DEBUG):
1304
+ await test_charger.set_shaper_live_pwr(0)
1305
+ assert "Posting shaper data: {'shaper_live_pwr': 0}" in caplog.text
1306
+
1307
+ await test_charger_v2.update()
1308
+ with pytest.raises(UnsupportedFeature):
1309
+ with caplog.at_level(logging.DEBUG):
1310
+ await test_charger_v2.set_shaper_live_pwr(210)
1311
+ assert "Feature not supported for older firmware." in caplog.text
1312
+ await test_charger_v2.ws_disconnect()
1313
+
1314
+
1274
1315
  @pytest.mark.parametrize(
1275
1316
  "fixture, expected", [("test_charger", 75), ("test_charger_v2", None)]
1276
1317
  )
@@ -3133,6 +3174,14 @@ async def test_firmware_check_errors(mock_aioclient):
3133
3174
  )
3134
3175
  assert await charger.firmware_check() is None
3135
3176
 
3177
+ # JSONDecodeError from github
3178
+ mock_aioclient.get(url, status=200, body="not json")
3179
+ assert await charger.firmware_check() is None
3180
+
3181
+ # Non-dict JSON from github
3182
+ mock_aioclient.get(url, status=200, body='"just a string"')
3183
+ assert await charger.firmware_check() == {}
3184
+
3136
3185
 
3137
3186
  async def test_websocket_pong():
3138
3187
  """Test websocket handles pong message."""
@@ -294,7 +294,6 @@ async def test_state_setter_threadsafe_fallback(ws_client):
294
294
  ) as mock_create_task,
295
295
  patch("asyncio.get_event_loop", return_value=mock_loop),
296
296
  ):
297
-
298
297
  ws_client.state = STATE_CONNECTED
299
298
  assert ws_client.state == STATE_CONNECTED
300
299