python-openevse-http 0.4.3__tar.gz → 0.4.4__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 (70) hide show
  1. {python_openevse_http-0.4.3/python_openevse_http.egg-info → python_openevse_http-0.4.4}/PKG-INFO +3 -3
  2. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/README.md +2 -2
  3. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/openevsehttp/client.py +64 -43
  4. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/openevsehttp/commands.py +8 -8
  5. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/openevsehttp/managers.py +1 -1
  6. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/openevsehttp/properties.py +37 -30
  7. python_openevse_http-0.4.4/openevsehttp/py.typed +0 -0
  8. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/openevsehttp/sensors.py +1 -1
  9. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/openevsehttp/websocket.py +34 -31
  10. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/pyproject.toml +3 -0
  11. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4/python_openevse_http.egg-info}/PKG-INFO +3 -3
  12. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/python_openevse_http.egg-info/SOURCES.txt +1 -0
  13. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/tests/test_client.py +44 -4
  14. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/tests/test_properties.py +7 -0
  15. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/tests/test_websocket.py +37 -34
  16. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/.github/dependabot.yml +0 -0
  17. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/.github/release-drafter.yml +0 -0
  18. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/.github/workflows/autolabeler.yml +0 -0
  19. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/.github/workflows/links.yml +0 -0
  20. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/.github/workflows/publish-to-pypi.yml +0 -0
  21. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/.github/workflows/release-drafter.yml +0 -0
  22. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/.github/workflows/test.yml +0 -0
  23. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/.gitignore +0 -0
  24. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/.pre-commit-config.yaml +0 -0
  25. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/.yamllint +0 -0
  26. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/EXTERNAL_SESSION.md +0 -0
  27. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/LICENSE +0 -0
  28. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/codecov.yml +0 -0
  29. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/example_external_session.py +0 -0
  30. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/openevsehttp/__init__.py +0 -0
  31. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/openevsehttp/__main__.py +0 -0
  32. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/openevsehttp/const.py +0 -0
  33. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/openevsehttp/exceptions.py +0 -0
  34. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/openevsehttp/utils.py +0 -0
  35. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/python_openevse_http.egg-info/dependency_links.txt +0 -0
  36. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/python_openevse_http.egg-info/not-zip-safe +0 -0
  37. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/python_openevse_http.egg-info/requires.txt +0 -0
  38. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/python_openevse_http.egg-info/top_level.txt +0 -0
  39. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/requirements.txt +0 -0
  40. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/requirements_lint.txt +0 -0
  41. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/requirements_test.txt +0 -0
  42. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/setup.cfg +0 -0
  43. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/setup.py +0 -0
  44. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/tests/__init__.py +0 -0
  45. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/tests/common.py +0 -0
  46. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/tests/conftest.py +0 -0
  47. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/tests/fixtures/github_v2.json +0 -0
  48. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/tests/fixtures/github_v4.json +0 -0
  49. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/tests/fixtures/v2_json/config.json +0 -0
  50. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/tests/fixtures/v2_json/status.json +0 -0
  51. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/config-broken-semver.json +0 -0
  52. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/config-broken.json +0 -0
  53. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/config-dev.json +0 -0
  54. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/config-extra-version.json +0 -0
  55. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/config-new.json +0 -0
  56. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/config-unknown-semver.json +0 -0
  57. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/config.json +0 -0
  58. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/schedule.json +0 -0
  59. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/status-broken.json +0 -0
  60. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/status-new.json +0 -0
  61. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/status.json +0 -0
  62. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/tests/fixtures/websocket.json +0 -0
  63. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/tests/test_commands.py +0 -0
  64. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/tests/test_external_session.py +0 -0
  65. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/tests/test_main_edge_cases.py +0 -0
  66. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/tests/test_managers.py +0 -0
  67. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/tests/test_mixins.py +0 -0
  68. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/tests/test_sensors.py +0 -0
  69. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/tests/test_shaper.py +0 -0
  70. {python_openevse_http-0.4.3 → python_openevse_http-0.4.4}/tox.ini +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python_openevse_http
3
- Version: 0.4.3
3
+ Version: 0.4.4
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
@@ -97,7 +97,7 @@ if __name__ == "__main__":
97
97
  | `/config` | GET, POST | ✅ | System and WiFi configuration |
98
98
  | `/override` | GET, POST, PATCH, DELETE | ✅ | Manual charging overrides & current limits |
99
99
  | `/claims` | GET, POST, DELETE | ✅ | Client-based charging claims |
100
- | `/schedule` | GET, POST | | Charging schedule management |
100
+ | `/schedule` | GET, POST | ⚠️ | Charging schedule management (Retrieval only) |
101
101
  | `/limit` | GET, POST, DELETE | ✅ | Charge limits (Time, Energy, SoC) |
102
102
  | `/shaper` | POST | ✅ | Grid shaper control (v4.0.0+) |
103
103
  | `/restart` | POST | ✅ | Reboot WiFi gateway or EVSE module |
@@ -111,7 +111,7 @@ if __name__ == "__main__":
111
111
  | `/tesla` | GET | ❌ | Tesla vehicle integration |
112
112
  | `/certificates`| GET, POST, DELETE | ❌ | SSL/TLS certificate management |
113
113
  | `/schedule/plan`| GET | ❌ | Schedule planning and optimization |
114
- | `/update` | POST | | Firmware update interface |
114
+ | `/update` | POST | | Firmware update interface |
115
115
  | `/rfid/add` | POST | ❌ | RFID tag management |
116
116
 
117
117
  ✅ = Fully Supported \| ⚠️ = Partial Support \| ❌ = Not yet implemented
@@ -64,7 +64,7 @@ if __name__ == "__main__":
64
64
  | `/config` | GET, POST | ✅ | System and WiFi configuration |
65
65
  | `/override` | GET, POST, PATCH, DELETE | ✅ | Manual charging overrides & current limits |
66
66
  | `/claims` | GET, POST, DELETE | ✅ | Client-based charging claims |
67
- | `/schedule` | GET, POST | | Charging schedule management |
67
+ | `/schedule` | GET, POST | ⚠️ | Charging schedule management (Retrieval only) |
68
68
  | `/limit` | GET, POST, DELETE | ✅ | Charge limits (Time, Energy, SoC) |
69
69
  | `/shaper` | POST | ✅ | Grid shaper control (v4.0.0+) |
70
70
  | `/restart` | POST | ✅ | Reboot WiFi gateway or EVSE module |
@@ -78,7 +78,7 @@ if __name__ == "__main__":
78
78
  | `/tesla` | GET | ❌ | Tesla vehicle integration |
79
79
  | `/certificates`| GET, POST, DELETE | ❌ | SSL/TLS certificate management |
80
80
  | `/schedule/plan`| GET | ❌ | Schedule planning and optimization |
81
- | `/update` | POST | | Firmware update interface |
81
+ | `/update` | POST | | Firmware update interface |
82
82
  | `/rfid/add` | POST | ❌ | RFID tag management |
83
83
 
84
84
  ✅ = Fully Supported \| ⚠️ = Partial Support \| ❌ = Not yet implemented
@@ -7,10 +7,10 @@ import inspect
7
7
  import json
8
8
  import logging
9
9
  import threading
10
- from collections.abc import Callable, Mapping
10
+ from collections.abc import Callable, Mapping, MutableMapping
11
11
  from typing import Any
12
12
 
13
- import aiohttp # type: ignore
13
+ import aiohttp
14
14
  from aiohttp.client_exceptions import ContentTypeError, ServerTimeoutError
15
15
  from awesomeversion import AwesomeVersion
16
16
  from awesomeversion.exceptions import AwesomeVersionCompareException
@@ -56,15 +56,15 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
56
56
  self._user = user
57
57
  self._pwd = pwd
58
58
  self.url = f"http://{host}/"
59
- self._status: dict = {}
60
- self._config: dict = {}
61
- self._override = None
59
+ self._status: dict[str, Any] = {}
60
+ self._config: dict[str, Any] = {}
61
+ self._override: Any = None
62
62
  self._ws_listening = False
63
63
  self.websocket: OpenEVSEWebsocket | None = None
64
- self.callback: Callable | None = None
64
+ self.callback: Callable[[], Any] | None = None
65
65
  self._loop: asyncio.AbstractEventLoop | None = None
66
- self._ws_listen_task: asyncio.Task | None = None
67
- self._ws_keepalive_task: asyncio.Task | None = None
66
+ self._ws_listen_task: asyncio.Task[Any] | None = None
67
+ self._ws_keepalive_task: asyncio.Task[Any] | None = None
68
68
  self._owns_loop = False
69
69
  self._loop_thread: threading.Thread | None = None
70
70
  self._session = session
@@ -76,7 +76,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
76
76
  method: str = "",
77
77
  data: Any = None,
78
78
  rapi: Any = None,
79
- ) -> Mapping[str, Any] | list[Any] | str:
79
+ ) -> Mapping[str, Any] | list[Any] | str | bool:
80
80
  """Return result of processed HTTP request."""
81
81
  auth = None
82
82
  allowed_methods = ["get", "post", "put", "delete", "patch", "head", "options"]
@@ -112,7 +112,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
112
112
  data: Any,
113
113
  rapi: Any,
114
114
  auth: Any,
115
- ) -> Mapping[str, Any] | list[Any] | str:
115
+ ) -> Mapping[str, Any] | list[Any] | str | bool:
116
116
  """Process a request with a given session."""
117
117
  if not hasattr(session, method):
118
118
  raise MissingMethod
@@ -130,38 +130,51 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
130
130
  kwargs["json"] = data
131
131
  async with http_method(url, **kwargs) as resp:
132
132
  try:
133
- message = await resp.text()
133
+ raw = await resp.text()
134
134
  except UnicodeDecodeError:
135
135
  _LOGGER.debug("Decoding error")
136
- message = await resp.read()
137
- message = message.decode(errors="replace")
136
+ raw = (await resp.read()).decode(errors="replace")
138
137
 
138
+ # JSON responses can sometimes be primitive values (like bools).
139
+ # If json.loads fails with ValueError (e.g. non-JSON text/html),
140
+ # we fall back to treating the raw response as a string.
141
+ response_content: Mapping[str, Any] | list[Any] | str | bool = raw
139
142
  try:
140
- message = json.loads(message)
143
+ response_content = json.loads(raw)
141
144
  except ValueError:
142
- _LOGGER.debug("Non JSON response: %s", message)
145
+ _LOGGER.debug("Non JSON response: %s", raw)
146
+ if not isinstance(response_content, dict | list | str | bool):
147
+ _LOGGER.error(
148
+ "Unexpected JSON primitive response from %s: %r",
149
+ url,
150
+ response_content,
151
+ )
152
+ raise ParseJSONError
143
153
 
144
154
  if resp.status == 400:
145
- if isinstance(message, dict) and "msg" in message:
146
- _LOGGER.error("Error 400: %s", message["msg"])
147
- elif isinstance(message, dict) and "error" in message:
148
- _LOGGER.error("Error 400: %s", message["error"])
155
+ if isinstance(response_content, dict) and "msg" in response_content:
156
+ _LOGGER.error("Error 400: %s", response_content["msg"])
157
+ elif (
158
+ isinstance(response_content, dict)
159
+ and "error" in response_content
160
+ ):
161
+ _LOGGER.error("Error 400: %s", response_content["error"])
149
162
  else:
150
- _LOGGER.error("Error 400: %s", message)
163
+ _LOGGER.error("Error 400: %s", response_content)
151
164
  raise ParseJSONError
152
165
  if resp.status == 401:
153
- _LOGGER.error("Authentication error: %s", message)
166
+ _LOGGER.error("Authentication error: %s", response_content)
154
167
  raise AuthenticationError
155
168
  if resp.status in [404, 405, 500]:
156
- _LOGGER.warning("%s", message)
169
+ _LOGGER.warning("%s", response_content)
157
170
 
158
171
  if (
159
172
  method.lower() != "get"
160
- and isinstance(message, dict)
161
- and any(key in message for key in UPDATE_TRIGGERS)
173
+ and isinstance(response_content, dict)
174
+ and any(key in response_content for key in UPDATE_TRIGGERS)
162
175
  ):
163
176
  await self.update()
164
- return message
177
+ return response_content
165
178
 
166
179
  except (TimeoutError, ServerTimeoutError):
167
180
  _LOGGER.error("%s: %s", ERROR_TIMEOUT, url)
@@ -170,7 +183,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
170
183
  _LOGGER.error("Content error: %s", err.message)
171
184
  raise
172
185
 
173
- async def send_command(self, command: str) -> tuple:
186
+ async def send_command(self, command: str) -> tuple[Any, Any]:
174
187
  """Send a RAPI command to the charger and parses the response."""
175
188
  url = f"{self.url}r"
176
189
  data = {"json": 1, "rapi": command}
@@ -220,13 +233,13 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
220
233
  "Received non-JSON response from /config: %s", response
221
234
  )
222
235
 
223
- async def test_and_get(self) -> dict:
236
+ async def test_and_get(self) -> dict[str, Any]:
224
237
  """Test connection.
225
238
 
226
239
  Return model serial number as dict
227
240
  """
228
241
  url = f"{self.url}config"
229
- data = {}
242
+ data: dict[str, Any] = {}
230
243
 
231
244
  response = await self.process_request(url, method="get")
232
245
  if not isinstance(response, Mapping):
@@ -276,7 +289,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
276
289
 
277
290
  self._start_listening()
278
291
 
279
- def _start_listening(self):
292
+ def _start_listening(self) -> None:
280
293
  """Start the websocket listener."""
281
294
  if not self._loop:
282
295
  try:
@@ -300,7 +313,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
300
313
  )
301
314
  self._loop_thread.start()
302
315
 
303
- async def _update_status(self, msgtype, data, error):
316
+ async def _update_status(self, msgtype: str, data: Any, error: Any) -> None:
304
317
  """Update data from websocket listener."""
305
318
  if msgtype == SIGNAL_CONNECTION_STATE:
306
319
  uri = self.websocket.uri if self.websocket else "Unknown"
@@ -316,18 +329,18 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
316
329
  self._ws_listening = False
317
330
 
318
331
  # Stopped websockets without errors are expected during shutdown
319
- # and ignored
320
- elif data == STATE_STOPPED and error:
321
- _LOGGER.debug(
322
- "Websocket to %s failed, aborting [Error: %s]",
323
- uri,
324
- error,
325
- )
332
+ elif data == STATE_STOPPED:
333
+ if error:
334
+ _LOGGER.debug(
335
+ "Websocket to %s failed, aborting [Error: %s]",
336
+ uri,
337
+ error,
338
+ )
326
339
  self._ws_listening = False
327
340
 
328
341
  elif msgtype == "data":
329
342
  _LOGGER.debug("Websocket data: %s", data)
330
- if not isinstance(data, Mapping):
343
+ if not isinstance(data, MutableMapping):
331
344
  _LOGGER.warning("Received non-Mapping websocket data: %s", data)
332
345
  return
333
346
 
@@ -354,7 +367,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
354
367
  if inspect.isawaitable(result):
355
368
  await result
356
369
 
357
- async def _shutdown(self):
370
+ async def _shutdown(self) -> None:
358
371
  """Shutdown the websocket and tasks on the listener loop."""
359
372
  tasks = []
360
373
  if self._ws_keepalive_task:
@@ -417,7 +430,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
417
430
  # Standard async disconnect for caller loop
418
431
  await self._shutdown()
419
432
 
420
- def is_coroutine_function(self, callback):
433
+ def is_coroutine_function(self, callback: Any) -> bool:
421
434
  """Check if a callback is a coroutine function."""
422
435
  return inspect.iscoroutinefunction(callback)
423
436
 
@@ -428,7 +441,13 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
428
441
  return STATE_STOPPED
429
442
  return self.websocket.state
430
443
 
431
- async def repeat(self, interval, func, *args, **kwargs):
444
+ async def repeat(
445
+ self,
446
+ interval: float,
447
+ func: Callable[..., Any],
448
+ *args: Any,
449
+ **kwargs: Any,
450
+ ) -> None:
432
451
  """Run func every interval seconds.
433
452
 
434
453
  If func has not finished before *interval*, will run again
@@ -436,10 +455,12 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
436
455
 
437
456
  *args and **kwargs are passed as the arguments to func.
438
457
  """
439
- while self.ws_state != STATE_STOPPED and self._ws_listening:
458
+ while self.ws_state != STATE_STOPPED:
440
459
  await asyncio.sleep(interval)
441
- if self.ws_state == STATE_STOPPED or not self._ws_listening:
460
+ if self.ws_state == STATE_STOPPED:
442
461
  break
462
+ if not self._ws_listening:
463
+ continue
443
464
  result = func(*args, **kwargs)
444
465
  if inspect.isawaitable(result):
445
466
  await result
@@ -7,7 +7,7 @@ import logging
7
7
  from collections.abc import Mapping
8
8
  from typing import Any
9
9
 
10
- import aiohttp # type: ignore
10
+ import aiohttp
11
11
  from aiohttp.client_exceptions import ContentTypeError, ServerTimeoutError
12
12
  from awesomeversion import AwesomeVersion
13
13
  from awesomeversion.exceptions import AwesomeVersionCompareException
@@ -23,8 +23,8 @@ class CommandsMixin:
23
23
  """Mixin providing command methods for OpenEVSE."""
24
24
 
25
25
  url: str
26
- _status: dict
27
- _config: dict
26
+ _status: dict[str, Any]
27
+ _config: dict[str, Any]
28
28
  _session: Any
29
29
 
30
30
  # These are defined in client.py
@@ -33,10 +33,10 @@ class CommandsMixin:
33
33
 
34
34
  async def process_request(
35
35
  self, url: str, method: str = "", data: Any = None, rapi: Any = None
36
- ) -> Mapping[str, Any] | list[Any] | str:
36
+ ) -> Mapping[str, Any] | list[Any] | str | bool:
37
37
  raise NotImplementedError
38
38
 
39
- async def send_command(self, command: str) -> tuple:
39
+ async def send_command(self, command: str) -> tuple[Any, Any]:
40
40
  raise NotImplementedError
41
41
 
42
42
  async def update(self, force_status: bool = False) -> None:
@@ -382,7 +382,7 @@ class CommandsMixin:
382
382
  _LOGGER.debug("EVSE Restart response: %s", response)
383
383
 
384
384
  # Firmware version
385
- async def firmware_check(self) -> dict | None:
385
+ async def firmware_check(self) -> dict[str, Any] | None:
386
386
  """Return the latest firmware version."""
387
387
  if "version" not in self._config:
388
388
  # Throw warning if we can't find the version
@@ -426,7 +426,7 @@ class CommandsMixin:
426
426
 
427
427
  async def _firmware_check_with_session(
428
428
  self, session: aiohttp.ClientSession, url: str, method: str
429
- ) -> dict | None:
429
+ ) -> dict[str, Any] | None:
430
430
  """Process a firmware check request with a given session."""
431
431
  http_method = getattr(session, method)
432
432
  _LOGGER.debug(
@@ -504,7 +504,7 @@ class CommandsMixin:
504
504
  firmware_url: str | None = None,
505
505
  firmware_bytes: bytes | None = None,
506
506
  filename: str = "firmware.bin",
507
- ) -> Mapping[str, Any] | list[Any] | str:
507
+ ) -> Mapping[str, Any] | list[Any] | str | bool:
508
508
  """Instruct the device to update its firmware.
509
509
 
510
510
  You can either:
@@ -23,7 +23,7 @@ class ManagersMixin:
23
23
 
24
24
  async def process_request(
25
25
  self, url: str, method: str = "", data: Any = None, rapi: Any = None
26
- ) -> Mapping[str, Any] | list[Any] | str:
26
+ ) -> Mapping[str, Any] | list[Any] | str | bool:
27
27
  raise NotImplementedError
28
28
 
29
29
  def _normalize_response(self, response: Any) -> dict[str, Any] | list[Any]:
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import logging
6
6
  from collections.abc import Mapping
7
7
  from datetime import datetime, timedelta, timezone
8
- from typing import Any
8
+ from typing import Any, cast
9
9
 
10
10
  from .const import MAX_AMPS, MIN_AMPS, states
11
11
  from .exceptions import UnsupportedFeature
@@ -17,8 +17,8 @@ _LOGGER = logging.getLogger(__name__)
17
17
  class PropertiesMixin:
18
18
  """Mixin providing all @property accessors for OpenEVSE."""
19
19
 
20
- _status: dict
21
- _config: dict
20
+ _status: dict[str, Any]
21
+ _config: dict[str, Any]
22
22
 
23
23
  # These are used by properties but defined in client.py
24
24
  def _version_check(self, min_version: str, max_version: str = "") -> bool:
@@ -125,15 +125,15 @@ class PropertiesMixin:
125
125
  @property
126
126
  def max_current(self) -> int | None:
127
127
  """Return the max current."""
128
- return self._status.get("max_current", None)
128
+ return cast(int | None, self._status.get("max_current", None))
129
129
 
130
130
  @property
131
131
  def wifi_firmware(self) -> str | None:
132
132
  """Return the ESP firmware version."""
133
133
  value = self._config.get("version")
134
- if value is not None:
135
- value = normalize_version(value)
136
- return value
134
+ if value is None:
135
+ return None
136
+ return normalize_version(value)
137
137
 
138
138
  @property
139
139
  def ip_address(self) -> str | None:
@@ -303,27 +303,30 @@ class PropertiesMixin:
303
303
  @property
304
304
  def total_day(self) -> float | None:
305
305
  """Get the total day energy usage."""
306
- return self._status.get("total_day", None)
306
+ return cast(float | None, self._status.get("total_day", None))
307
307
 
308
308
  @property
309
309
  def total_week(self) -> float | None:
310
310
  """Get the total week energy usage."""
311
- return self._status.get("total_week", None)
311
+ return cast(float | None, self._status.get("total_week", None))
312
312
 
313
313
  @property
314
314
  def total_month(self) -> float | None:
315
315
  """Get the total month energy usage."""
316
- return self._status.get("total_month", None)
316
+ return cast(float | None, self._status.get("total_month", None))
317
317
 
318
318
  @property
319
319
  def total_year(self) -> float | None:
320
320
  """Get the total year energy usage."""
321
- return self._status.get("total_year", None)
321
+ return cast(float | None, self._status.get("total_year", None))
322
322
 
323
323
  @property
324
324
  def has_limit(self) -> bool | None:
325
325
  """Return if a limit has been set."""
326
- return self._status.get("has_limit", self._status.get("limit", None))
326
+ return cast(
327
+ "bool | None",
328
+ self._status.get("has_limit", self._status.get("limit", None)),
329
+ )
327
330
 
328
331
  @property
329
332
  def protocol_version(self) -> str | None:
@@ -336,7 +339,7 @@ class PropertiesMixin:
336
339
  @property
337
340
  def vehicle(self) -> bool:
338
341
  """Return if a vehicle is connected to the EVSE."""
339
- return self._status.get("vehicle", False)
342
+ return bool(self._status.get("vehicle", False))
340
343
 
341
344
  @property
342
345
  def ota_update(self) -> bool:
@@ -356,7 +359,7 @@ class PropertiesMixin:
356
359
  @property
357
360
  def manual_override(self) -> bool:
358
361
  """Return if Manual Override is set."""
359
- return self._status.get("manual_override", False)
362
+ return bool(self._status.get("manual_override", False))
360
363
 
361
364
  @property
362
365
  def divertmode(self) -> str:
@@ -394,7 +397,7 @@ class PropertiesMixin:
394
397
  @property
395
398
  def wifi_serial(self) -> str | None:
396
399
  """Return wifi serial."""
397
- return self._config.get("wifi_serial", None)
400
+ return cast(str | None, self._config.get("wifi_serial", None))
398
401
 
399
402
  @property
400
403
  def charging_power(self) -> float | None:
@@ -415,12 +418,12 @@ class PropertiesMixin:
415
418
  @property
416
419
  def shaper_active(self) -> bool | None:
417
420
  """Return if shaper is active."""
418
- return self._status.get("shaper", None)
421
+ return cast(bool | None, self._status.get("shaper", None))
419
422
 
420
423
  @property
421
424
  def shaper_live_power(self) -> int | None:
422
425
  """Return shaper live power reading."""
423
- return self._status.get("shaper_live_pwr", None)
426
+ return cast(int | None, self._status.get("shaper_live_pwr", None))
424
427
 
425
428
  @property
426
429
  def shaper_available_current(self) -> float | None:
@@ -433,7 +436,7 @@ class PropertiesMixin:
433
436
  @property
434
437
  def shaper_max_power(self) -> int | None:
435
438
  """Return shaper live power reading."""
436
- return self._status.get("shaper_max_pwr", None)
439
+ return cast(int | None, self._status.get("shaper_max_pwr", None))
437
440
 
438
441
  @property
439
442
  def shaper_updated(self) -> bool:
@@ -444,13 +447,17 @@ class PropertiesMixin:
444
447
  @property
445
448
  def vehicle_soc(self) -> int | None:
446
449
  """Return battery level."""
447
- return self._status.get("vehicle_soc", self._status.get("battery_level", None))
450
+ return cast(
451
+ "int | None",
452
+ self._status.get("vehicle_soc", self._status.get("battery_level", None)),
453
+ )
448
454
 
449
455
  @property
450
456
  def vehicle_range(self) -> int | None:
451
457
  """Return battery range."""
452
- return self._status.get(
453
- "vehicle_range", self._status.get("battery_range", None)
458
+ return cast(
459
+ "int | None",
460
+ self._status.get("vehicle_range", self._status.get("battery_range", None)),
454
461
  )
455
462
 
456
463
  @property
@@ -472,12 +479,12 @@ class PropertiesMixin:
472
479
  @property
473
480
  def min_amps(self) -> int:
474
481
  """Return the minimum amps."""
475
- return self._config.get("min_current_hard", MIN_AMPS)
482
+ return int(self._config.get("min_current_hard", MIN_AMPS))
476
483
 
477
484
  @property
478
485
  def max_amps(self) -> int:
479
486
  """Return the maximum amps."""
480
- return self._config.get("max_current_hard", MAX_AMPS)
487
+ return int(self._config.get("max_current_hard", MAX_AMPS))
481
488
 
482
489
  @property
483
490
  def mqtt_connected(self) -> bool:
@@ -487,29 +494,29 @@ class PropertiesMixin:
487
494
  @property
488
495
  def emoncms_connected(self) -> bool | None:
489
496
  """Return the status of the emoncms connection."""
490
- return self._status.get("emoncms_connected", None)
497
+ return cast(bool | None, self._status.get("emoncms_connected", None))
491
498
 
492
499
  @property
493
500
  def ocpp_connected(self) -> bool | None:
494
501
  """Return the status of the ocpp connection."""
495
- return self._status.get("ocpp_connected", None)
502
+ return cast(bool | None, self._status.get("ocpp_connected", None))
496
503
 
497
504
  @property
498
505
  def uptime(self) -> int | None:
499
506
  """Return the unit uptime."""
500
- return self._status.get("uptime", None)
507
+ return cast(int | None, self._status.get("uptime", None))
501
508
 
502
509
  @property
503
510
  def freeram(self) -> int | None:
504
511
  """Return the unit freeram."""
505
- return self._status.get("freeram", None)
512
+ return cast(int | None, self._status.get("freeram", None))
506
513
 
507
514
  # Safety counts
508
515
  @property
509
- def checks_count(self) -> dict:
516
+ def checks_count(self) -> dict[str, Any]:
510
517
  """Return the safety checks counts."""
511
518
  attributes = ("gfcicount", "nogndcount", "stuckcount")
512
- counts = {}
519
+ counts: dict[str, Any] = {}
513
520
  if self._status is not None and set(attributes).issubset(self._status.keys()):
514
521
  counts["gfcicount"] = self._status["gfcicount"]
515
522
  counts["nogndcount"] = self._status["nogndcount"]
@@ -534,4 +541,4 @@ class PropertiesMixin:
534
541
  if not self._version_check("4.2.2"):
535
542
  _LOGGER.debug("Feature not supported for older firmware.")
536
543
  raise UnsupportedFeature
537
- return self._status.get("power", 0)
544
+ return int(self._status.get("power", 0))
File without changes
@@ -23,7 +23,7 @@ class SensorsMixin:
23
23
 
24
24
  async def process_request(
25
25
  self, url: str, method: str = "", data: Any = None, rapi: Any = None
26
- ) -> Mapping[str, Any] | list[Any] | str:
26
+ ) -> Mapping[str, Any] | list[Any] | str | bool:
27
27
  raise NotImplementedError
28
28
 
29
29
  def _normalize_response(self, response: Any) -> dict[str, Any] | list[Any]:
@@ -1,9 +1,13 @@
1
1
  """Websocket class for OpenEVSE HTTP."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  import asyncio
4
6
  import datetime
5
7
  import inspect
6
8
  import logging
9
+ from collections.abc import Awaitable, Callable
10
+ from typing import Any
7
11
 
8
12
  import aiohttp
9
13
 
@@ -28,12 +32,12 @@ class OpenEVSEWebsocket:
28
32
 
29
33
  def __init__(
30
34
  self,
31
- server,
32
- callback,
33
- user=None,
34
- password=None,
35
+ server: str,
36
+ callback: Callable[[str, Any, Any], Any] | None,
37
+ user: str | None = None,
38
+ password: str | None = None,
35
39
  session: aiohttp.ClientSession | None = None,
36
- ):
40
+ ) -> None:
37
41
  """Initialize a OpenEVSEWebsocket instance."""
38
42
  self.session = session
39
43
  self._session_external = session is not None
@@ -43,20 +47,20 @@ class OpenEVSEWebsocket:
43
47
  self.callback = callback
44
48
  self._state = STATE_DISCONNECTED
45
49
  self.failed_attempts = 0
46
- self._error_reason = None
47
- self._client = None
48
- self._ping = None
49
- self._pong = None
50
- self._tasks: set[asyncio.Task] = set()
50
+ self._error_reason: Any = None
51
+ self._client: aiohttp.ClientWebSocketResponse | None = None
52
+ self._ping: datetime.datetime | None = None
53
+ self._pong: datetime.datetime | None = None
54
+ self._tasks: set[asyncio.Task[Any]] = set()
51
55
  self._listener_loop: asyncio.AbstractEventLoop | None = None
52
56
 
53
57
  @property
54
- def state(self):
58
+ def state(self) -> str:
55
59
  """Return the current state."""
56
60
  return self._state
57
61
 
58
62
  @state.setter
59
- def state(self, value):
63
+ def state(self, value: str) -> None:
60
64
  """Setter that schedules the callback."""
61
65
  self._state = value
62
66
  _LOGGER.debug("Websocket %s", value)
@@ -76,21 +80,16 @@ class OpenEVSEWebsocket:
76
80
  if self._listener_loop:
77
81
  self._listener_loop.call_soon_threadsafe(self._schedule_task, coro)
78
82
  else:
79
- try:
80
- task = asyncio.ensure_future(coro)
81
- self._tasks.add(task)
82
- task.add_done_callback(self._tasks.discard)
83
- except RuntimeError:
84
- # Fallback to get_event_loop if ensure_future fails and no _listener_loop
85
- loop = asyncio.get_event_loop()
86
- loop.call_soon_threadsafe(self._schedule_task, coro)
83
+ task = asyncio.ensure_future(coro)
84
+ self._tasks.add(task)
85
+ task.add_done_callback(self._tasks.discard)
87
86
  except RuntimeError:
88
87
  _LOGGER.error("Failed to schedule callback from sync context: %s", coro)
89
88
  if hasattr(coro, "close"):
90
89
  coro.close()
91
90
  self._error_reason = None
92
91
 
93
- def _schedule_task(self, coro):
92
+ def _schedule_task(self, coro: Awaitable[Any]) -> None:
94
93
  """Schedule a task from a thread-safe context."""
95
94
  try:
96
95
  task = asyncio.ensure_future(coro)
@@ -103,7 +102,7 @@ class OpenEVSEWebsocket:
103
102
  if hasattr(coro, "close"):
104
103
  coro.close()
105
104
 
106
- async def _set_state(self, value):
105
+ async def _set_state(self, value: str) -> None:
107
106
  """Async helper to set the state and await the callback."""
108
107
  self._state = value
109
108
  _LOGGER.debug("Websocket %s", value)
@@ -114,11 +113,11 @@ class OpenEVSEWebsocket:
114
113
  self._error_reason = None
115
114
 
116
115
  @staticmethod
117
- def _get_uri(server):
116
+ def _get_uri(server: str) -> str:
118
117
  """Generate the websocket URI."""
119
118
  return server[: server.rfind("/")].replace("http", "ws") + "/ws"
120
119
 
121
- async def running(self):
120
+ async def running(self) -> None:
122
121
  """Open a persistent websocket connection and act on events."""
123
122
  await self._ensure_session()
124
123
  await self._set_state(STATE_STARTING)
@@ -128,6 +127,8 @@ class OpenEVSEWebsocket:
128
127
  auth = aiohttp.BasicAuth(self._user, self._password)
129
128
 
130
129
  try:
130
+ # Narrow type for mypy since _ensure_session sets self.session
131
+ assert self.session is not None
131
132
  async with self.session.ws_connect(
132
133
  self.uri,
133
134
  heartbeat=15,
@@ -156,7 +157,9 @@ class OpenEVSEWebsocket:
156
157
  await self._client.close()
157
158
  self._client = None
158
159
 
159
- async def _handle_messages(self, ws_client):
160
+ async def _handle_messages(
161
+ self, ws_client: aiohttp.ClientWebSocketResponse
162
+ ) -> None:
160
163
  """Handle incoming websocket messages."""
161
164
  async for message in ws_client:
162
165
  if self.state == STATE_STOPPED:
@@ -183,7 +186,7 @@ class OpenEVSEWebsocket:
183
186
  _LOGGER.error("Websocket error")
184
187
  break
185
188
 
186
- async def _handle_response_error(self, error):
189
+ async def _handle_response_error(self, error: aiohttp.ClientResponseError) -> None:
187
190
  """Handle ClientResponseError."""
188
191
  if error.status == 401:
189
192
  _LOGGER.error("Credentials rejected: %s", error)
@@ -193,7 +196,7 @@ class OpenEVSEWebsocket:
193
196
  self._error_reason = error
194
197
  await self._set_state(STATE_STOPPED)
195
198
 
196
- async def _handle_connection_error(self, error):
199
+ async def _handle_connection_error(self, error: BaseException) -> None:
197
200
  """Handle connection errors."""
198
201
  self.failed_attempts += 1
199
202
  if self.failed_attempts > MAX_FAILED_ATTEMPTS:
@@ -209,7 +212,7 @@ class OpenEVSEWebsocket:
209
212
  await self._set_state(STATE_DISCONNECTED)
210
213
  await asyncio.sleep(retry_delay)
211
214
 
212
- async def listen(self):
215
+ async def listen(self) -> None:
213
216
  """Start the listening websocket."""
214
217
  await self._ensure_session()
215
218
  self.failed_attempts = 0
@@ -220,13 +223,13 @@ class OpenEVSEWebsocket:
220
223
  finally:
221
224
  self._listener_loop = None
222
225
 
223
- async def _ensure_session(self):
226
+ async def _ensure_session(self) -> None:
224
227
  """Ensure aiohttp.ClientSession exists."""
225
228
  if self.session is None:
226
229
  self.session = aiohttp.ClientSession()
227
230
  self._session_external = False
228
231
 
229
- async def close(self):
232
+ async def close(self) -> None:
230
233
  """Close the listening websocket."""
231
234
  await self._set_state(STATE_STOPPED)
232
235
 
@@ -244,7 +247,7 @@ class OpenEVSEWebsocket:
244
247
  await self.session.close()
245
248
  self.session = None
246
249
 
247
- async def keepalive(self):
250
+ async def keepalive(self) -> None:
248
251
  """Send ping requests to websocket."""
249
252
  if self._ping and self._pong:
250
253
  time_delta = self._pong - self._ping
@@ -36,3 +36,6 @@ requires = ["setuptools>=82.0.1", "setuptools-scm>=10.0.5"]
36
36
  build-backend = "setuptools.build_meta"
37
37
 
38
38
  [tool.setuptools_scm]
39
+
40
+ [tool.mypy]
41
+ strict = true
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python_openevse_http
3
- Version: 0.4.3
3
+ Version: 0.4.4
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
@@ -97,7 +97,7 @@ if __name__ == "__main__":
97
97
  | `/config` | GET, POST | ✅ | System and WiFi configuration |
98
98
  | `/override` | GET, POST, PATCH, DELETE | ✅ | Manual charging overrides & current limits |
99
99
  | `/claims` | GET, POST, DELETE | ✅ | Client-based charging claims |
100
- | `/schedule` | GET, POST | | Charging schedule management |
100
+ | `/schedule` | GET, POST | ⚠️ | Charging schedule management (Retrieval only) |
101
101
  | `/limit` | GET, POST, DELETE | ✅ | Charge limits (Time, Energy, SoC) |
102
102
  | `/shaper` | POST | ✅ | Grid shaper control (v4.0.0+) |
103
103
  | `/restart` | POST | ✅ | Reboot WiFi gateway or EVSE module |
@@ -111,7 +111,7 @@ if __name__ == "__main__":
111
111
  | `/tesla` | GET | ❌ | Tesla vehicle integration |
112
112
  | `/certificates`| GET, POST, DELETE | ❌ | SSL/TLS certificate management |
113
113
  | `/schedule/plan`| GET | ❌ | Schedule planning and optimization |
114
- | `/update` | POST | | Firmware update interface |
114
+ | `/update` | POST | | Firmware update interface |
115
115
  | `/rfid/add` | POST | ❌ | RFID tag management |
116
116
 
117
117
  ✅ = Fully Supported \| ⚠️ = Partial Support \| ❌ = Not yet implemented
@@ -27,6 +27,7 @@ openevsehttp/const.py
27
27
  openevsehttp/exceptions.py
28
28
  openevsehttp/managers.py
29
29
  openevsehttp/properties.py
30
+ openevsehttp/py.typed
30
31
  openevsehttp/sensors.py
31
32
  openevsehttp/utils.py
32
33
  openevsehttp/websocket.py
@@ -742,15 +742,30 @@ async def test_repeat():
742
742
  """Test repeat helper."""
743
743
  charger = OpenEVSE(SERVER_URL)
744
744
  charger.websocket = MagicMock()
745
- charger._ws_listening = True
746
- # Mock ws_state to stop after one iteration
745
+ charger._ws_listening = False
746
+ # Mock ws_state to stop after second iteration (one continue, one run)
747
747
  with patch(
748
748
  "openevsehttp.__main__.OpenEVSE.ws_state", new_callable=PropertyMock
749
749
  ) as mock_state:
750
- mock_state.side_effect = ["connected", "connected", "stopped", "stopped"]
750
+ mock_state.side_effect = [
751
+ "connected",
752
+ "connected",
753
+ "connected",
754
+ "connected",
755
+ "stopped",
756
+ "stopped",
757
+ ]
751
758
 
752
759
  mock_func = AsyncMock()
753
- with patch("asyncio.sleep", AsyncMock()):
760
+ sleep_count = 0
761
+
762
+ async def mock_sleep(interval):
763
+ nonlocal sleep_count
764
+ sleep_count += 1
765
+ if sleep_count > 1:
766
+ charger._ws_listening = True
767
+
768
+ with patch("asyncio.sleep", side_effect=mock_sleep):
754
769
  await charger.repeat(1, mock_func, "test")
755
770
  mock_func.assert_called_once_with("test")
756
771
 
@@ -1723,3 +1738,28 @@ async def test_update_status_ota():
1723
1738
  assert charger.ota_update is False
1724
1739
  assert charger.ota_progress is None
1725
1740
  assert charger.ota_state == "completed"
1741
+
1742
+
1743
+ @pytest.mark.parametrize("body", ["123", "null"])
1744
+ async def test_process_request_invalid_json_primitive(mock_aioclient, body):
1745
+ """Test process_request with an unexpected JSON primitive (e.g., int or null)."""
1746
+ charger = OpenEVSE(SERVER_URL)
1747
+ mock_aioclient.get(
1748
+ TEST_URL_STATUS,
1749
+ status=200,
1750
+ body=body,
1751
+ )
1752
+ with pytest.raises(ParseJSONError):
1753
+ await charger.process_request(TEST_URL_STATUS, method="get")
1754
+
1755
+
1756
+ async def test_process_request_boolean_primitive(mock_aioclient):
1757
+ """Test process_request allows boolean JSON primitives (e.g., false)."""
1758
+ charger = OpenEVSE(SERVER_URL)
1759
+ mock_aioclient.get(
1760
+ TEST_URL_STATUS,
1761
+ status=200,
1762
+ body="false",
1763
+ )
1764
+ result = await charger.process_request(TEST_URL_STATUS, method="get")
1765
+ assert result is False
@@ -1227,3 +1227,10 @@ async def test_config_boolean_coercion():
1227
1227
  assert charger.vent_required_enabled is True
1228
1228
  assert charger.ground_check_enabled is True
1229
1229
  assert charger.stuck_relay_check_enabled is True
1230
+
1231
+
1232
+ async def test_wifi_firmware_none():
1233
+ """Test wifi_firmware returns None when version is missing."""
1234
+ charger = OpenEVSE(SERVER_URL)
1235
+ charger._config = {}
1236
+ assert charger.wifi_firmware is None
@@ -289,42 +289,34 @@ async def test_keepalive_send_exceptions(ws_client_auth):
289
289
 
290
290
 
291
291
  @pytest.mark.asyncio
292
- async def test_state_setter_threadsafe_fallback(ws_client):
293
- """Test state setter falls back to call_soon_threadsafe on RuntimeError."""
294
- mock_loop = MagicMock()
295
- ws_client._error_reason = "Previous Error"
296
-
297
- with (
298
- patch("asyncio.ensure_future", side_effect=RuntimeError("No running loop")),
299
- patch("asyncio.get_event_loop", return_value=mock_loop),
300
- ):
301
- ws_client.state = STATE_CONNECTED
302
- assert ws_client.state == STATE_CONNECTED
303
-
304
- mock_loop.call_soon_threadsafe.assert_called_once()
305
-
306
- args, _ = mock_loop.call_soon_threadsafe.call_args
307
- assert args[0] == ws_client._schedule_task
308
- # Cover _schedule_task by manual invocation
309
- with patch("asyncio.ensure_future") as mock_ct:
310
- task = mock_ct.return_value
311
- args[0](args[1])
312
- mock_ct.assert_called_once_with(args[1])
313
- assert task in ws_client._tasks
314
- # Trigger cleanup
315
- mock_ct.call_args[0][0].close() # close mock coro to avoid warning
316
- # Manually trigger the done callback to cover discard
317
- task.add_done_callback.call_args[0][0](task)
318
- assert task not in ws_client._tasks
319
-
320
- assert ws_client._error_reason is None
321
-
322
- # Test state setter without callback coverage
292
+ async def test_state_setter_no_callback(ws_client):
293
+ """Test state setter without callback coverage."""
323
294
  ws_client.callback = None
324
295
  ws_client.state = STATE_STOPPED
325
296
  assert ws_client.state == STATE_STOPPED
326
297
 
327
298
 
299
+ @pytest.mark.asyncio
300
+ async def test_websocket_schedule_success_sync(ws_client):
301
+ """Test state setter schedules the callback successfully when outside listener loop."""
302
+ # Ensure no listener loop is set, so ensure_future path is taken
303
+ ws_client._listener_loop = None
304
+
305
+ # Trigger state change, which schedules callback
306
+ ws_client.state = STATE_CONNECTED
307
+
308
+ # We should have scheduled 1 task
309
+ assert len(ws_client._tasks) == 1
310
+
311
+ # Let the loop run to execute the callback
312
+ await asyncio.gather(*ws_client._tasks)
313
+
314
+ ws_client.callback.assert_called_with(
315
+ SIGNAL_CONNECTION_STATE, STATE_CONNECTED, None
316
+ )
317
+ assert len(ws_client._tasks) == 0
318
+
319
+
328
320
  @pytest.mark.asyncio
329
321
  async def test_websocket_sync_callback(ws_client):
330
322
  """Test state setter with a synchronous callback."""
@@ -339,14 +331,13 @@ async def test_websocket_sync_callback(ws_client):
339
331
 
340
332
  @pytest.mark.asyncio
341
333
  async def test_websocket_schedule_failure_sync(ws_client, mock_callback):
342
- """Test state setter handles RuntimeError during call_soon_threadsafe."""
334
+ """Test state setter handles RuntimeError during scheduling."""
343
335
  # Use AsyncMock to ensure it's awaitable and triggers the try...except block
344
336
  async_mock = AsyncMock()
345
337
 
346
- # Trigger RuntimeError in both create_task and get_event_loop/call_soon_threadsafe
338
+ # Trigger RuntimeError in create_task
347
339
  with (
348
340
  patch("asyncio.ensure_future", side_effect=RuntimeError("No loop")),
349
- patch("asyncio.get_event_loop", side_effect=RuntimeError("Loop closed")),
350
341
  patch("openevsehttp.websocket._LOGGER") as mock_logger,
351
342
  ):
352
343
  ws_client.callback = async_mock
@@ -533,3 +524,15 @@ async def test_websocket_state_task_management(ws_client):
533
524
  # Wait for task to complete
534
525
  await asyncio.gather(*ws_client._tasks)
535
526
  assert len(ws_client._tasks) == 0
527
+
528
+
529
+ @pytest.mark.asyncio
530
+ async def test_websocket_close_cancels_pending_tasks(ws_client):
531
+ """Test close() cancels pending callback tasks."""
532
+ # Trigger a task creation
533
+ ws_client.state = STATE_CONNECTED
534
+ assert len(ws_client._tasks) == 1
535
+
536
+ # Close should cancel and drain tasks
537
+ await ws_client.close()
538
+ assert len(ws_client._tasks) == 0