python-openevse-http 0.4.2__tar.gz → 0.4.3__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 (69) hide show
  1. {python_openevse_http-0.4.2/python_openevse_http.egg-info → python_openevse_http-0.4.3}/PKG-INFO +1 -1
  2. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/openevsehttp/commands.py +72 -2
  3. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3/python_openevse_http.egg-info}/PKG-INFO +1 -1
  4. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/requirements_test.txt +1 -2
  5. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/tests/conftest.py +176 -3
  6. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/tests/test_commands.py +154 -25
  7. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/tests/test_managers.py +58 -15
  8. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/.github/dependabot.yml +0 -0
  9. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/.github/release-drafter.yml +0 -0
  10. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/.github/workflows/autolabeler.yml +0 -0
  11. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/.github/workflows/links.yml +0 -0
  12. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/.github/workflows/publish-to-pypi.yml +0 -0
  13. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/.github/workflows/release-drafter.yml +0 -0
  14. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/.github/workflows/test.yml +0 -0
  15. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/.gitignore +0 -0
  16. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/.pre-commit-config.yaml +0 -0
  17. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/.yamllint +0 -0
  18. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/EXTERNAL_SESSION.md +0 -0
  19. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/LICENSE +0 -0
  20. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/README.md +0 -0
  21. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/codecov.yml +0 -0
  22. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/example_external_session.py +0 -0
  23. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/openevsehttp/__init__.py +0 -0
  24. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/openevsehttp/__main__.py +0 -0
  25. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/openevsehttp/client.py +0 -0
  26. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/openevsehttp/const.py +0 -0
  27. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/openevsehttp/exceptions.py +0 -0
  28. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/openevsehttp/managers.py +0 -0
  29. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/openevsehttp/properties.py +0 -0
  30. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/openevsehttp/sensors.py +0 -0
  31. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/openevsehttp/utils.py +0 -0
  32. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/openevsehttp/websocket.py +0 -0
  33. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/pyproject.toml +0 -0
  34. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/python_openevse_http.egg-info/SOURCES.txt +0 -0
  35. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/python_openevse_http.egg-info/dependency_links.txt +0 -0
  36. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/python_openevse_http.egg-info/not-zip-safe +0 -0
  37. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/python_openevse_http.egg-info/requires.txt +0 -0
  38. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/python_openevse_http.egg-info/top_level.txt +0 -0
  39. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/requirements.txt +0 -0
  40. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/requirements_lint.txt +0 -0
  41. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/setup.cfg +0 -0
  42. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/setup.py +0 -0
  43. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/tests/__init__.py +0 -0
  44. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/tests/common.py +0 -0
  45. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/tests/fixtures/github_v2.json +0 -0
  46. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/tests/fixtures/github_v4.json +0 -0
  47. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/tests/fixtures/v2_json/config.json +0 -0
  48. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/tests/fixtures/v2_json/status.json +0 -0
  49. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/tests/fixtures/v4_json/config-broken-semver.json +0 -0
  50. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/tests/fixtures/v4_json/config-broken.json +0 -0
  51. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/tests/fixtures/v4_json/config-dev.json +0 -0
  52. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/tests/fixtures/v4_json/config-extra-version.json +0 -0
  53. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/tests/fixtures/v4_json/config-new.json +0 -0
  54. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/tests/fixtures/v4_json/config-unknown-semver.json +0 -0
  55. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/tests/fixtures/v4_json/config.json +0 -0
  56. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/tests/fixtures/v4_json/schedule.json +0 -0
  57. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/tests/fixtures/v4_json/status-broken.json +0 -0
  58. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/tests/fixtures/v4_json/status-new.json +0 -0
  59. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/tests/fixtures/v4_json/status.json +0 -0
  60. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/tests/fixtures/websocket.json +0 -0
  61. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/tests/test_client.py +0 -0
  62. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/tests/test_external_session.py +0 -0
  63. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/tests/test_main_edge_cases.py +0 -0
  64. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/tests/test_mixins.py +0 -0
  65. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/tests/test_properties.py +0 -0
  66. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/tests/test_sensors.py +0 -0
  67. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/tests/test_shaper.py +0 -0
  68. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/tests/test_websocket.py +0 -0
  69. {python_openevse_http-0.4.2 → python_openevse_http-0.4.3}/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.3
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
@@ -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
 
@@ -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
@@ -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"),
@@ -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
 
@@ -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.3
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
@@ -5,7 +5,6 @@ pytest-cov==7.1.0
5
5
  pytest-timeout==2.4.0
6
6
  pytest-asyncio
7
7
  requests_mock
8
- aiohttp<3.11
9
- aioresponses
8
+ aiohttp
10
9
  tox==4.55.0
11
10
  freezegun
@@ -1,9 +1,12 @@
1
1
  """Provide common pytest fixtures."""
2
2
 
3
3
  import json
4
+ from typing import Any, NamedTuple
5
+ from unittest.mock import patch
4
6
 
7
+ import aiohttp
5
8
  import pytest
6
- from aioresponses import aioresponses
9
+ from multidict import CIMultiDict
7
10
 
8
11
  import openevsehttp as main
9
12
  from tests.common import load_fixture
@@ -226,10 +229,180 @@ def test_charger_v4_0(mock_aioclient):
226
229
  return main.OpenEVSE(TEST_TLD)
227
230
 
228
231
 
232
+ class MockResponse:
233
+ def __init__(
234
+ self,
235
+ method: str,
236
+ url: str,
237
+ status: int,
238
+ body: Any,
239
+ headers: dict | None,
240
+ content_type: str,
241
+ ):
242
+ self.method = method
243
+ self.url = url
244
+ self.status = status
245
+ self._body = body
246
+ self.headers = CIMultiDict(headers or {})
247
+ if content_type:
248
+ self.headers.setdefault("content-type", content_type)
249
+
250
+ async def text(self, encoding: str | None = None, errors: str = "strict") -> str:
251
+ if isinstance(self._body, bytes):
252
+ return self._body.decode(encoding or "utf-8", errors=errors)
253
+ if isinstance(self._body, str):
254
+ return self._body
255
+
256
+ return json.dumps(self._body)
257
+
258
+ async def read(self) -> bytes:
259
+ if isinstance(self._body, bytes):
260
+ return self._body
261
+ if isinstance(self._body, str):
262
+ return self._body.encode("utf-8")
263
+
264
+ return json.dumps(self._body).encode("utf-8")
265
+
266
+ async def json(self, *args, **kwargs) -> Any:
267
+ if isinstance(self._body, bytes):
268
+ return json.loads(self._body.decode("utf-8"))
269
+ if isinstance(self._body, str):
270
+ return json.loads(self._body)
271
+ return self._body
272
+
273
+ async def __aenter__(self):
274
+ return self
275
+
276
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
277
+ pass
278
+
279
+
280
+ class RegisteredMock(NamedTuple):
281
+ method: str
282
+ url_pattern: Any
283
+ status: int
284
+ body: Any
285
+ exception: Any
286
+ repeat: bool
287
+ content_type: str
288
+ headers: dict | None
289
+
290
+
291
+ class AiohttpClientMocker:
292
+ def __init__(self):
293
+ self.mocks = []
294
+ self.requests = []
295
+ self._patcher = None
296
+
297
+ def __enter__(self):
298
+ self.start()
299
+ return self
300
+
301
+ def __exit__(self, exc_type, exc_val, exc_tb):
302
+ self.stop()
303
+
304
+ def start(self):
305
+ self._patcher = patch.object(
306
+ aiohttp.ClientSession, "_request", new=self._request_mock
307
+ )
308
+ self._patcher.start()
309
+
310
+ def stop(self):
311
+ if self._patcher:
312
+ self._patcher.stop()
313
+
314
+ def add(
315
+ self,
316
+ method: str,
317
+ url: Any,
318
+ status: int = 200,
319
+ body: Any = "",
320
+ exception: Any = None,
321
+ repeat: bool = False,
322
+ content_type: str = "application/json",
323
+ headers: dict | None = None,
324
+ ):
325
+ self.mocks.append(
326
+ RegisteredMock(
327
+ method=method.upper(),
328
+ url_pattern=url,
329
+ status=status,
330
+ body=body,
331
+ exception=exception,
332
+ repeat=repeat,
333
+ content_type=content_type,
334
+ headers=headers,
335
+ )
336
+ )
337
+
338
+ def get(self, *args, **kwargs):
339
+ self.add("GET", *args, **kwargs)
340
+
341
+ def post(self, *args, **kwargs):
342
+ self.add("POST", *args, **kwargs)
343
+
344
+ def put(self, *args, **kwargs):
345
+ self.add("PUT", *args, **kwargs)
346
+
347
+ def delete(self, *args, **kwargs):
348
+ self.add("DELETE", *args, **kwargs)
349
+
350
+ def patch(self, *args, **kwargs):
351
+ self.add("PATCH", *args, **kwargs)
352
+
353
+ def head(self, *args, **kwargs):
354
+ self.add("HEAD", *args, **kwargs)
355
+
356
+ def options(self, *args, **kwargs):
357
+ self.add("OPTIONS", *args, **kwargs)
358
+
359
+ async def _request_mock(self, method: str, str_or_url: Any, **kwargs: Any):
360
+ url_str = str(str_or_url)
361
+ method_upper = method.upper()
362
+ self.requests.append((method_upper, url_str, kwargs))
363
+
364
+ matching_mock = None
365
+ matching_index = -1
366
+
367
+ for i, mock in enumerate(self.mocks):
368
+ if mock.method != method_upper:
369
+ continue
370
+ matched = False
371
+ if hasattr(mock.url_pattern, "match"):
372
+ if mock.url_pattern.match(url_str):
373
+ matched = True
374
+ elif isinstance(mock.url_pattern, str):
375
+ if mock.url_pattern == url_str:
376
+ matched = True
377
+
378
+ if matched:
379
+ matching_mock = mock
380
+ matching_index = i
381
+ break
382
+
383
+ if not matching_mock:
384
+ raise AssertionError(f"No mock registered for {method_upper} {url_str}")
385
+
386
+ if not matching_mock.repeat:
387
+ self.mocks.pop(matching_index)
388
+
389
+ if matching_mock.exception:
390
+ raise matching_mock.exception
391
+
392
+ return MockResponse(
393
+ method=method_upper,
394
+ url=url_str,
395
+ status=matching_mock.status,
396
+ body=matching_mock.body,
397
+ headers=matching_mock.headers,
398
+ content_type=matching_mock.content_type,
399
+ )
400
+
401
+
229
402
  @pytest.fixture
230
403
  def aioclient_mock():
231
404
  """Fixture to mock aioclient calls."""
232
- with aioresponses() as mock_aiohttp:
405
+ with AiohttpClientMocker() as mock_aiohttp:
233
406
  mock_headers = {"content-type": "application/json"}
234
407
  mock_aiohttp.get(
235
408
  "ws://openevse.test.tld/ws",
@@ -244,5 +417,5 @@ def aioclient_mock():
244
417
  @pytest.fixture
245
418
  def mock_aioclient():
246
419
  """Fixture to mock aioclient calls."""
247
- with aioresponses() as m:
420
+ with AiohttpClientMocker() as m:
248
421
  yield m
@@ -1060,6 +1060,8 @@ async def test_update_firmware_bytes(test_charger, mock_aioclient, caplog):
1060
1060
  "Uploading firmware binary to http://openevse.test.tld/update (14 bytes)"
1061
1061
  in caplog.text
1062
1062
  )
1063
+ assert "Firmware upload request completed. Response: OK" in caplog.text
1064
+ assert "Firmware update started, setting ota_update flag." in caplog.text
1063
1065
  assert test_charger.ota_update is True
1064
1066
 
1065
1067
 
@@ -1080,6 +1082,11 @@ async def test_update_firmware_url(test_charger, mock_aioclient, caplog):
1080
1082
  "Requesting OpenEVSE to download and update from: http://github.com/release.bin"
1081
1083
  in caplog.text
1082
1084
  )
1085
+ assert (
1086
+ "Firmware update request completed. Response: {'msg': 'started'}"
1087
+ in caplog.text
1088
+ )
1089
+ assert "Firmware update started, setting ota_update flag." in caplog.text
1083
1090
  assert test_charger.ota_update is True
1084
1091
 
1085
1092
 
@@ -1120,14 +1127,43 @@ async def test_update_firmware_auto(test_charger, mock_aioclient, caplog):
1120
1127
  with caplog.at_level(logging.DEBUG):
1121
1128
  response = await test_charger.update_firmware()
1122
1129
  assert response == {"msg": "started"}
1130
+ assert (
1131
+ "No firmware URL provided. Resolving latest matching firmware from GitHub."
1132
+ in caplog.text
1133
+ )
1134
+ assert "Detected firmware: 4.1.7" in caplog.text
1135
+ assert "Using version: 4.1.7" in caplog.text
1136
+ assert (
1137
+ "Firmware check URL: https://api.github.com/repos/OpenEVSE/ESP32_WiFi_V4.x/releases/latest"
1138
+ in caplog.text
1139
+ )
1140
+ assert "Firmware check response status: 200" in caplog.text
1141
+ assert (
1142
+ "GitHub release metadata successfully fetched for version: v4.1.2"
1143
+ in caplog.text
1144
+ )
1145
+ assert (
1146
+ "Matching buildenv 'openevse_esp32-gateway' against assets" in caplog.text
1147
+ )
1148
+ assert (
1149
+ "Found matching firmware asset: https://github.com/OpenEVSE/releases/download/v4.1.2/openevse_esp32-gateway.bin"
1150
+ in caplog.text
1151
+ )
1123
1152
  assert (
1124
1153
  "Requesting OpenEVSE to download and update from: https://github.com/OpenEVSE/releases/download/v4.1.2/openevse_esp32-gateway.bin"
1125
1154
  in caplog.text
1126
1155
  )
1156
+ assert (
1157
+ "Firmware update request completed. Response: {'msg': 'started'}"
1158
+ in caplog.text
1159
+ )
1160
+ assert "Firmware update started, setting ota_update flag." in caplog.text
1127
1161
  assert test_charger.ota_update is True
1128
1162
 
1129
1163
 
1130
- async def test_update_firmware_auto_missing_buildenv(test_charger, mock_aioclient):
1164
+ async def test_update_firmware_auto_missing_buildenv(
1165
+ test_charger, mock_aioclient, caplog
1166
+ ):
1131
1167
  """Test update_firmware raises RuntimeError when buildenv asset is missing."""
1132
1168
  test_charger._config = {"version": "4.1.7", "buildenv": "openevse_esp32-gateway"}
1133
1169
 
@@ -1150,35 +1186,47 @@ async def test_update_firmware_auto_missing_buildenv(test_charger, mock_aioclien
1150
1186
  body=json.dumps(github_response),
1151
1187
  )
1152
1188
 
1153
- with pytest.raises(
1154
- RuntimeError,
1155
- match="Could not resolve latest firmware download URL from GitHub.",
1156
- ):
1157
- await test_charger.update_firmware()
1189
+ with caplog.at_level(logging.DEBUG):
1190
+ with pytest.raises(
1191
+ RuntimeError,
1192
+ match=r"Could not resolve latest firmware download URL from GitHub\.",
1193
+ ):
1194
+ await test_charger.update_firmware()
1195
+ assert (
1196
+ "Could not find asset matching target filename 'openevse_esp32-gateway.bin' in assets: ['other_env.bin']"
1197
+ in caplog.text
1198
+ )
1199
+ assert (
1200
+ "Could not resolve latest firmware download URL from GitHub." in caplog.text
1201
+ )
1158
1202
 
1159
1203
 
1160
- async def test_update_firmware_both_provided(test_charger):
1204
+ async def test_update_firmware_both_provided(test_charger, caplog):
1161
1205
  """Test update_firmware raises ValueError when both bytes and URL are provided."""
1162
1206
  test_charger._config["version"] = "4.1.7"
1163
- with pytest.raises(
1164
- ValueError, match="Cannot specify both firmware_bytes and firmware_url"
1165
- ):
1166
- await test_charger.update_firmware(
1167
- firmware_url="http://url", firmware_bytes=b"bytes"
1168
- )
1207
+ with caplog.at_level(logging.DEBUG):
1208
+ with pytest.raises(ValueError):
1209
+ await test_charger.update_firmware(
1210
+ firmware_url="http://url", firmware_bytes=b"bytes"
1211
+ )
1212
+ assert "Cannot specify both firmware_bytes and firmware_url" in caplog.text
1169
1213
 
1170
1214
 
1171
- async def test_update_firmware_url_invalid(test_charger):
1215
+ async def test_update_firmware_url_invalid(test_charger, caplog):
1172
1216
  """Test update_firmware raises ValueError when firmware_url is empty or invalid type."""
1173
1217
  test_charger._config["version"] = "4.1.7"
1174
- with pytest.raises(ValueError, match="Invalid firmware_url"):
1175
- await test_charger.update_firmware(firmware_url="")
1218
+ with caplog.at_level(logging.DEBUG):
1219
+ with pytest.raises(ValueError):
1220
+ await test_charger.update_firmware(firmware_url="")
1221
+ assert "Invalid firmware_url: " in caplog.text
1176
1222
 
1177
- with pytest.raises(ValueError, match="Invalid firmware_url"):
1178
- await test_charger.update_firmware(firmware_url=" ")
1223
+ with pytest.raises(ValueError):
1224
+ await test_charger.update_firmware(firmware_url=" ")
1225
+ assert "Invalid firmware_url: " in caplog.text
1179
1226
 
1180
- with pytest.raises(ValueError, match="Invalid firmware_url"):
1181
- await test_charger.update_firmware(firmware_url=123) # type: ignore
1227
+ with pytest.raises(ValueError):
1228
+ await test_charger.update_firmware(firmware_url=123) # type: ignore
1229
+ assert "Invalid firmware_url: 123" in caplog.text
1182
1230
 
1183
1231
 
1184
1232
  async def test_update_firmware_unsupported(test_charger):
@@ -1188,7 +1236,7 @@ async def test_update_firmware_unsupported(test_charger):
1188
1236
  await test_charger.update_firmware(firmware_url="http://url")
1189
1237
 
1190
1238
 
1191
- async def test_update_firmware_error_response(test_charger, mock_aioclient):
1239
+ async def test_update_firmware_error_response(test_charger, mock_aioclient, caplog):
1192
1240
  """Test update_firmware doesn't set ota_update on error responses."""
1193
1241
  test_charger._config["version"] = "4.1.7"
1194
1242
  test_charger._status = {}
@@ -1197,8 +1245,89 @@ async def test_update_firmware_error_response(test_charger, mock_aioclient):
1197
1245
  status=200,
1198
1246
  body='{"msg":"error"}',
1199
1247
  )
1200
- response = await test_charger.update_firmware(
1201
- firmware_url="http://github.com/release.bin"
1248
+ with caplog.at_level(logging.DEBUG):
1249
+ response = await test_charger.update_firmware(
1250
+ firmware_url="http://github.com/release.bin"
1251
+ )
1252
+ assert response == {"msg": "error"}
1253
+ assert (
1254
+ "Firmware update response did not indicate start: {'msg': 'error'}"
1255
+ in caplog.text
1256
+ )
1257
+ assert test_charger.ota_update is False
1258
+
1259
+
1260
+ async def test_update_firmware_assets_invalid_type(
1261
+ test_charger, mock_aioclient, caplog
1262
+ ):
1263
+ """Test update_firmware handles non-list assets gracefully."""
1264
+ test_charger._config = {"version": "4.1.7", "buildenv": "openevse_esp32-gateway"}
1265
+
1266
+ # Mock GitHub releases but with assets as a dict (invalid type)
1267
+ github_response = {
1268
+ "tag_name": "v4.1.2",
1269
+ "body": "release notes",
1270
+ "html_url": "https://github.com/OpenEVSE/releases/v4.1.2",
1271
+ "assets": {"name": "not_a_list"},
1272
+ }
1273
+
1274
+ mock_aioclient.get(
1275
+ "https://api.github.com/repos/OpenEVSE/ESP32_WiFi_V4.x/releases/latest",
1276
+ status=200,
1277
+ body=json.dumps(github_response),
1202
1278
  )
1203
- assert response == {"msg": "error"}
1204
- assert test_charger.ota_update is False
1279
+
1280
+ with caplog.at_level(logging.DEBUG):
1281
+ with pytest.raises(
1282
+ RuntimeError,
1283
+ match=r"Could not resolve latest firmware download URL from GitHub\.",
1284
+ ):
1285
+ await test_charger.update_firmware()
1286
+ assert "Invalid GitHub assets payload: {'name': 'not_a_list'}" in caplog.text
1287
+
1288
+
1289
+ async def test_update_firmware_assets_invalid_item(
1290
+ test_charger, mock_aioclient, caplog
1291
+ ):
1292
+ """Test update_firmware handles non-mapping assets gracefully."""
1293
+ test_charger._config = {"version": "4.1.7", "buildenv": "openevse_esp32-gateway"}
1294
+
1295
+ # Mock GitHub releases with assets list containing non-mapping elements (e.g., a string)
1296
+ github_response = {
1297
+ "tag_name": "v4.1.2",
1298
+ "body": "release notes",
1299
+ "html_url": "https://github.com/OpenEVSE/releases/v4.1.2",
1300
+ "assets": [
1301
+ "invalid_asset_string",
1302
+ {
1303
+ "name": "openevse_esp32-gateway.bin",
1304
+ "browser_download_url": "http://url",
1305
+ },
1306
+ ],
1307
+ }
1308
+
1309
+ mock_aioclient.get(
1310
+ "https://api.github.com/repos/OpenEVSE/ESP32_WiFi_V4.x/releases/latest",
1311
+ status=200,
1312
+ body=json.dumps(github_response),
1313
+ )
1314
+
1315
+ mock_aioclient.post(
1316
+ "http://openevse.test.tld/update",
1317
+ status=200,
1318
+ body='{"msg":"started"}',
1319
+ )
1320
+
1321
+ with caplog.at_level(logging.DEBUG):
1322
+ response = await test_charger.update_firmware()
1323
+ assert response == {"msg": "started"}
1324
+ assert "Found matching firmware asset: http://url" in caplog.text
1325
+
1326
+
1327
+ async def test_update_firmware_bytes_empty(test_charger, caplog):
1328
+ """Test update_firmware raises ValueError when empty firmware_bytes are provided."""
1329
+ test_charger._config["version"] = "4.1.7"
1330
+ with caplog.at_level(logging.DEBUG):
1331
+ with pytest.raises(ValueError):
1332
+ await test_charger.update_firmware(firmware_bytes=b"")
1333
+ assert "Empty firmware bytes provided" in caplog.text
@@ -48,7 +48,15 @@ async def test_set_override(
48
48
  with caplog.at_level(logging.DEBUG):
49
49
  status = await test_charger.set_override("active")
50
50
  assert status == {"msg": "OK"}
51
- assert "Override data: {'state': 'active'}" in caplog.text
51
+ assert mock_aioclient.requests[-1][2]["json"] == {
52
+ "state": "active",
53
+ "charge_current": 0,
54
+ "max_current": 0,
55
+ "energy_limit": 0,
56
+ "time_limit": 0,
57
+ "auto_release": True,
58
+ }
59
+ caplog.clear()
52
60
 
53
61
  mock_aioclient.post(
54
62
  TEST_URL_OVERRIDE,
@@ -56,44 +64,79 @@ async def test_set_override(
56
64
  body='{"msg": "OK"}',
57
65
  )
58
66
  status = await test_charger.set_override("active", 30)
59
- assert "Override data: {'state': 'active', 'charge_current': 30}" in caplog.text
67
+ assert mock_aioclient.requests[-1][2]["json"] == {
68
+ "state": "active",
69
+ "charge_current": 30,
70
+ "max_current": 0,
71
+ "energy_limit": 0,
72
+ "time_limit": 0,
73
+ "auto_release": True,
74
+ }
75
+ caplog.clear()
76
+
60
77
  mock_aioclient.post(
61
78
  TEST_URL_OVERRIDE,
62
79
  status=200,
63
80
  body='{"msg": "OK"}',
64
81
  )
65
82
  status = await test_charger.set_override(charge_current=30)
66
- assert "Override data: {'charge_current': 30}" in caplog.text
83
+ assert mock_aioclient.requests[-1][2]["json"] == {
84
+ "state": "active",
85
+ "charge_current": 30,
86
+ "max_current": 0,
87
+ "energy_limit": 0,
88
+ "time_limit": 0,
89
+ "auto_release": True,
90
+ }
91
+ caplog.clear()
92
+
67
93
  mock_aioclient.post(
68
94
  TEST_URL_OVERRIDE,
69
95
  status=200,
70
96
  body='{"msg": "OK"}',
71
97
  )
72
98
  status = await test_charger.set_override("active", 30, 32)
73
- assert (
74
- "Override data: {'state': 'active', 'charge_current': 30, 'max_current': 32}"
75
- in caplog.text
76
- )
99
+ assert mock_aioclient.requests[-1][2]["json"] == {
100
+ "state": "active",
101
+ "charge_current": 30,
102
+ "max_current": 32,
103
+ "energy_limit": 0,
104
+ "time_limit": 0,
105
+ "auto_release": True,
106
+ }
107
+ caplog.clear()
108
+
77
109
  mock_aioclient.post(
78
110
  TEST_URL_OVERRIDE,
79
111
  status=200,
80
112
  body='{"msg": "OK"}',
81
113
  )
82
114
  status = await test_charger.set_override("active", 30, 32, 2000)
83
- assert (
84
- "Override data: {'state': 'active', 'charge_current': 30, 'max_current': 32, 'energy_limit': 2000}"
85
- in caplog.text
86
- )
115
+ assert mock_aioclient.requests[-1][2]["json"] == {
116
+ "state": "active",
117
+ "charge_current": 30,
118
+ "max_current": 32,
119
+ "energy_limit": 2000,
120
+ "time_limit": 0,
121
+ "auto_release": True,
122
+ }
123
+ caplog.clear()
124
+
87
125
  mock_aioclient.post(
88
126
  TEST_URL_OVERRIDE,
89
127
  status=200,
90
128
  body='{"msg": "OK"}',
91
129
  )
92
130
  status = await test_charger.set_override("active", 30, 32, 2000, 5000)
93
- assert (
94
- "Override data: {'state': 'active', 'charge_current': 30, 'max_current': 32, 'energy_limit': 2000, 'time_limit': 5000}"
95
- in caplog.text
96
- )
131
+ assert mock_aioclient.requests[-1][2]["json"] == {
132
+ "state": "active",
133
+ "charge_current": 30,
134
+ "max_current": 32,
135
+ "energy_limit": 2000,
136
+ "time_limit": 5000,
137
+ "auto_release": True,
138
+ }
139
+ caplog.clear()
97
140
 
98
141
  with pytest.raises(ValueError):
99
142
  with caplog.at_level(logging.DEBUG):