python-openevse-http 0.4.2__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.2/python_openevse_http.egg-info → python_openevse_http-0.4.4}/PKG-INFO +3 -3
  2. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/README.md +2 -2
  3. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/openevsehttp/client.py +64 -43
  4. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/openevsehttp/commands.py +80 -10
  5. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/openevsehttp/managers.py +1 -1
  6. {python_openevse_http-0.4.2 → 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.2 → python_openevse_http-0.4.4}/openevsehttp/sensors.py +1 -1
  9. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/openevsehttp/websocket.py +34 -31
  10. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/pyproject.toml +3 -0
  11. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4/python_openevse_http.egg-info}/PKG-INFO +3 -3
  12. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/python_openevse_http.egg-info/SOURCES.txt +1 -0
  13. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/requirements_test.txt +1 -2
  14. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/conftest.py +176 -3
  15. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/test_client.py +44 -4
  16. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/test_commands.py +154 -25
  17. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/test_managers.py +58 -15
  18. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/test_properties.py +7 -0
  19. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/test_websocket.py +37 -34
  20. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/.github/dependabot.yml +0 -0
  21. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/.github/release-drafter.yml +0 -0
  22. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/.github/workflows/autolabeler.yml +0 -0
  23. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/.github/workflows/links.yml +0 -0
  24. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/.github/workflows/publish-to-pypi.yml +0 -0
  25. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/.github/workflows/release-drafter.yml +0 -0
  26. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/.github/workflows/test.yml +0 -0
  27. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/.gitignore +0 -0
  28. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/.pre-commit-config.yaml +0 -0
  29. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/.yamllint +0 -0
  30. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/EXTERNAL_SESSION.md +0 -0
  31. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/LICENSE +0 -0
  32. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/codecov.yml +0 -0
  33. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/example_external_session.py +0 -0
  34. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/openevsehttp/__init__.py +0 -0
  35. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/openevsehttp/__main__.py +0 -0
  36. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/openevsehttp/const.py +0 -0
  37. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/openevsehttp/exceptions.py +0 -0
  38. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/openevsehttp/utils.py +0 -0
  39. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/python_openevse_http.egg-info/dependency_links.txt +0 -0
  40. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/python_openevse_http.egg-info/not-zip-safe +0 -0
  41. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/python_openevse_http.egg-info/requires.txt +0 -0
  42. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/python_openevse_http.egg-info/top_level.txt +0 -0
  43. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/requirements.txt +0 -0
  44. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/requirements_lint.txt +0 -0
  45. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/setup.cfg +0 -0
  46. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/setup.py +0 -0
  47. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/__init__.py +0 -0
  48. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/common.py +0 -0
  49. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/fixtures/github_v2.json +0 -0
  50. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/fixtures/github_v4.json +0 -0
  51. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/fixtures/v2_json/config.json +0 -0
  52. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/fixtures/v2_json/status.json +0 -0
  53. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/config-broken-semver.json +0 -0
  54. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/config-broken.json +0 -0
  55. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/config-dev.json +0 -0
  56. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/config-extra-version.json +0 -0
  57. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/config-new.json +0 -0
  58. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/config-unknown-semver.json +0 -0
  59. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/config.json +0 -0
  60. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/schedule.json +0 -0
  61. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/status-broken.json +0 -0
  62. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/status-new.json +0 -0
  63. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/fixtures/v4_json/status.json +0 -0
  64. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/fixtures/websocket.json +0 -0
  65. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/test_external_session.py +0 -0
  66. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/test_main_edge_cases.py +0 -0
  67. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/test_mixins.py +0 -0
  68. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/test_sensors.py +0 -0
  69. {python_openevse_http-0.4.2 → python_openevse_http-0.4.4}/tests/test_shaper.py +0 -0
  70. {python_openevse_http-0.4.2 → 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.2
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:
@@ -53,7 +53,12 @@ class CommandsMixin:
53
53
  normalized.get("msg") == "started"
54
54
  or normalized.get("msg") in SUCCESS_ANSWERS
55
55
  ):
56
+ _LOGGER.debug("Firmware update started, setting ota_update flag.")
56
57
  self._status["ota_update"] = 1
58
+ else:
59
+ _LOGGER.debug(
60
+ "Firmware update response did not indicate start: %s", normalized
61
+ )
57
62
 
58
63
  async def get_schedule(self) -> Mapping[str, Any] | list[Any]:
59
64
  """Return the current schedule."""
@@ -131,7 +136,12 @@ class CommandsMixin:
131
136
  time_limit: int | None = None,
132
137
  auto_release: bool | None = None,
133
138
  ) -> Any:
134
- """Set the manual override status."""
139
+ """Set the manual override status.
140
+
141
+ Fetches the current override payload first and merges existing values
142
+ into the request payload. This prevents the firmware from clearing/resetting
143
+ previously configured properties that are not passed in the function call.
144
+ """
135
145
  if not self._version_check("4.0.1"):
136
146
  _LOGGER.debug("Feature not supported for older firmware.")
137
147
  raise UnsupportedFeature
@@ -149,6 +159,18 @@ class CommandsMixin:
149
159
  raise ValueError
150
160
 
151
161
  data: dict[str, Any] = {}
162
+ if isinstance(response, Mapping):
163
+ for key in (
164
+ "state",
165
+ "charge_current",
166
+ "max_current",
167
+ "energy_limit",
168
+ "time_limit",
169
+ "auto_release",
170
+ ):
171
+ if key in response:
172
+ data[key] = response[key]
173
+
152
174
  if auto_release is not None:
153
175
  data["auto_release"] = auto_release
154
176
 
@@ -360,7 +382,7 @@ class CommandsMixin:
360
382
  _LOGGER.debug("EVSE Restart response: %s", response)
361
383
 
362
384
  # Firmware version
363
- async def firmware_check(self) -> dict | None:
385
+ async def firmware_check(self) -> dict[str, Any] | None:
364
386
  """Return the latest firmware version."""
365
387
  if "version" not in self._config:
366
388
  # Throw warning if we can't find the version
@@ -381,6 +403,7 @@ class CommandsMixin:
381
403
  url = f"{base_url}ESP32_WiFi_V4.x/releases/latest"
382
404
  else:
383
405
  url = f"{base_url}ESP8266_WiFi_v2.x/releases/latest"
406
+ _LOGGER.debug("Firmware check URL: %s", url)
384
407
  except AwesomeVersionCompareException:
385
408
  _LOGGER.debug("Non-semver firmware version detected.")
386
409
  return None
@@ -403,7 +426,7 @@ class CommandsMixin:
403
426
 
404
427
  async def _firmware_check_with_session(
405
428
  self, session: aiohttp.ClientSession, url: str, method: str
406
- ) -> dict | None:
429
+ ) -> dict[str, Any] | None:
407
430
  """Process a firmware check request with a given session."""
408
431
  http_method = getattr(session, method)
409
432
  _LOGGER.debug(
@@ -412,6 +435,7 @@ class CommandsMixin:
412
435
  method,
413
436
  )
414
437
  async with http_method(url) as resp:
438
+ _LOGGER.debug("Firmware check response status: %d", resp.status)
415
439
  if resp.status != 200:
416
440
  return None
417
441
  message = await resp.text()
@@ -422,19 +446,51 @@ class CommandsMixin:
422
446
  return None
423
447
 
424
448
  if not isinstance(message, dict):
449
+ _LOGGER.debug(
450
+ "Invalid JSON response type from GitHub: %s", type(message)
451
+ )
425
452
  return None
426
453
 
454
+ _LOGGER.debug(
455
+ "GitHub release metadata successfully fetched for version: %s",
456
+ message.get("tag_name"),
457
+ )
458
+
427
459
  # Match browser_download_url based on buildenv
428
460
  download_url = None
429
461
  buildenv = self._config.get("buildenv")
430
462
  assets = message.get("assets", [])
431
463
 
432
- if buildenv and assets:
464
+ if not buildenv:
465
+ _LOGGER.debug(
466
+ "Cannot resolve firmware asset: missing buildenv in config."
467
+ )
468
+ assets = []
469
+ elif not isinstance(assets, list):
470
+ _LOGGER.debug("Invalid GitHub assets payload: %r", assets)
471
+ assets = []
472
+ else:
473
+ _LOGGER.debug("Matching buildenv '%s' against assets", buildenv)
433
474
  target_filename = f"{buildenv}.bin"
434
475
  for asset in assets:
476
+ if not isinstance(asset, Mapping):
477
+ continue
435
478
  if asset.get("name") == target_filename:
436
479
  download_url = asset.get("browser_download_url")
480
+ _LOGGER.debug("Found matching firmware asset: %s", download_url)
437
481
  break
482
+ if buildenv and not download_url:
483
+ _LOGGER.debug(
484
+ "Could not find asset matching target filename '%s.bin' in assets: %s",
485
+ buildenv,
486
+ [
487
+ asset.get("name")
488
+ for asset in assets
489
+ if isinstance(asset, Mapping)
490
+ ]
491
+ if assets
492
+ else "None",
493
+ )
438
494
 
439
495
  return {
440
496
  "latest_version": message.get("tag_name"),
@@ -448,7 +504,7 @@ class CommandsMixin:
448
504
  firmware_url: str | None = None,
449
505
  firmware_bytes: bytes | None = None,
450
506
  filename: str = "firmware.bin",
451
- ) -> Mapping[str, Any] | list[Any] | str:
507
+ ) -> Mapping[str, Any] | list[Any] | str | bool:
452
508
  """Instruct the device to update its firmware.
453
509
 
454
510
  You can either:
@@ -461,10 +517,16 @@ class CommandsMixin:
461
517
  raise UnsupportedFeature
462
518
 
463
519
  if firmware_bytes is not None and firmware_url is not None:
520
+ _LOGGER.error("Cannot specify both firmware_bytes and firmware_url")
464
521
  raise ValueError("Cannot specify both firmware_bytes and firmware_url")
465
522
 
523
+ if firmware_bytes is not None and len(firmware_bytes) == 0:
524
+ _LOGGER.error("Empty firmware bytes provided")
525
+ raise ValueError("Empty firmware bytes provided")
526
+
466
527
  if firmware_url is not None:
467
528
  if not isinstance(firmware_url, str) or not firmware_url.strip():
529
+ _LOGGER.error("Invalid firmware_url: %s", firmware_url)
468
530
  raise ValueError("Invalid firmware_url")
469
531
 
470
532
  url = f"{self.url}update"
@@ -485,13 +547,20 @@ class CommandsMixin:
485
547
  response = await self.process_request(
486
548
  url=url, method="post", rapi=form_data
487
549
  )
550
+ _LOGGER.debug("Firmware upload request completed. Response: %s", response)
488
551
  self._flag_ota_if_started(response)
489
552
  return response
490
553
 
491
554
  # 2. Resolve URL from GitHub if not specified
492
555
  if firmware_url is None:
556
+ _LOGGER.debug(
557
+ "No firmware URL provided. Resolving latest matching firmware from GitHub."
558
+ )
493
559
  check_result = await self.firmware_check()
494
560
  if not check_result or not check_result.get("browser_download_url"):
561
+ _LOGGER.error(
562
+ "Could not resolve latest firmware download URL from GitHub."
563
+ )
495
564
  raise RuntimeError(
496
565
  "Could not resolve latest firmware download URL from GitHub."
497
566
  )
@@ -503,6 +572,7 @@ class CommandsMixin:
503
572
  "Requesting OpenEVSE to download and update from: %s", firmware_url
504
573
  )
505
574
  response = await self.process_request(url=url, method="post", data=data)
575
+ _LOGGER.debug("Firmware update request completed. Response: %s", response)
506
576
  self._flag_ota_if_started(response)
507
577
  return response
508
578
 
@@ -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]: