python-openevse-http 0.4.1__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.1 → python_openevse_http-0.4.3}/.github/workflows/autolabeler.yml +1 -1
  2. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/.github/workflows/links.yml +1 -1
  3. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/.github/workflows/publish-to-pypi.yml +3 -3
  4. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/.github/workflows/release-drafter.yml +1 -1
  5. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/.github/workflows/test.yml +3 -3
  6. {python_openevse_http-0.4.1/python_openevse_http.egg-info → python_openevse_http-0.4.3}/PKG-INFO +1 -1
  7. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/openevsehttp/client.py +3 -3
  8. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/openevsehttp/commands.py +90 -5
  9. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/pyproject.toml +1 -1
  10. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3/python_openevse_http.egg-info}/PKG-INFO +1 -1
  11. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/requirements_test.txt +0 -1
  12. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/tests/conftest.py +176 -3
  13. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/tests/test_client.py +49 -0
  14. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/tests/test_commands.py +168 -20
  15. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/tests/test_managers.py +58 -15
  16. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/.github/dependabot.yml +0 -0
  17. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/.github/release-drafter.yml +0 -0
  18. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/.gitignore +0 -0
  19. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/.pre-commit-config.yaml +0 -0
  20. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/.yamllint +0 -0
  21. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/EXTERNAL_SESSION.md +0 -0
  22. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/LICENSE +0 -0
  23. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/README.md +0 -0
  24. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/codecov.yml +0 -0
  25. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/example_external_session.py +0 -0
  26. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/openevsehttp/__init__.py +0 -0
  27. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/openevsehttp/__main__.py +0 -0
  28. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/openevsehttp/const.py +0 -0
  29. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/openevsehttp/exceptions.py +0 -0
  30. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/openevsehttp/managers.py +0 -0
  31. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/openevsehttp/properties.py +0 -0
  32. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/openevsehttp/sensors.py +0 -0
  33. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/openevsehttp/utils.py +0 -0
  34. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/openevsehttp/websocket.py +0 -0
  35. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/python_openevse_http.egg-info/SOURCES.txt +0 -0
  36. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/python_openevse_http.egg-info/dependency_links.txt +0 -0
  37. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/python_openevse_http.egg-info/not-zip-safe +0 -0
  38. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/python_openevse_http.egg-info/requires.txt +0 -0
  39. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/python_openevse_http.egg-info/top_level.txt +0 -0
  40. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/requirements.txt +0 -0
  41. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/requirements_lint.txt +0 -0
  42. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/setup.cfg +0 -0
  43. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/setup.py +0 -0
  44. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/tests/__init__.py +0 -0
  45. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/tests/common.py +0 -0
  46. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/tests/fixtures/github_v2.json +0 -0
  47. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/tests/fixtures/github_v4.json +0 -0
  48. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/tests/fixtures/v2_json/config.json +0 -0
  49. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/tests/fixtures/v2_json/status.json +0 -0
  50. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/tests/fixtures/v4_json/config-broken-semver.json +0 -0
  51. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/tests/fixtures/v4_json/config-broken.json +0 -0
  52. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/tests/fixtures/v4_json/config-dev.json +0 -0
  53. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/tests/fixtures/v4_json/config-extra-version.json +0 -0
  54. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/tests/fixtures/v4_json/config-new.json +0 -0
  55. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/tests/fixtures/v4_json/config-unknown-semver.json +0 -0
  56. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/tests/fixtures/v4_json/config.json +0 -0
  57. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/tests/fixtures/v4_json/schedule.json +0 -0
  58. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/tests/fixtures/v4_json/status-broken.json +0 -0
  59. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/tests/fixtures/v4_json/status-new.json +0 -0
  60. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/tests/fixtures/v4_json/status.json +0 -0
  61. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/tests/fixtures/websocket.json +0 -0
  62. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/tests/test_external_session.py +0 -0
  63. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/tests/test_main_edge_cases.py +0 -0
  64. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/tests/test_mixins.py +0 -0
  65. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/tests/test_properties.py +0 -0
  66. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/tests/test_sensors.py +0 -0
  67. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/tests/test_shaper.py +0 -0
  68. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/tests/test_websocket.py +0 -0
  69. {python_openevse_http-0.4.1 → python_openevse_http-0.4.3}/tox.ini +0 -0
@@ -32,7 +32,7 @@ jobs:
32
32
  runs-on: ubuntu-latest
33
33
  timeout-minutes: 3
34
34
  steps:
35
- - uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
35
+ - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
36
36
  with:
37
37
  egress-policy: audit
38
38
 
@@ -15,7 +15,7 @@ jobs:
15
15
  linkChecker:
16
16
  runs-on: ubuntu-latest
17
17
  steps:
18
- - uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
18
+ - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
19
19
  with:
20
20
  egress-policy: audit
21
21
 
@@ -21,7 +21,7 @@ jobs:
21
21
  contents: read
22
22
  id-token: write
23
23
  steps:
24
- - uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
24
+ - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
25
25
  with:
26
26
  egress-policy: audit
27
27
 
@@ -31,7 +31,7 @@ jobs:
31
31
  fetch-depth: 0
32
32
 
33
33
  - name: Install uv
34
- uses: astral-sh/setup-uv@1edb52594c857e2b5b13128931090f0640537287 # v5.3.0
34
+ uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
35
35
  with:
36
36
  enable-cache: true
37
37
  version: "0.10.9"
@@ -43,4 +43,4 @@ jobs:
43
43
  run: uv build
44
44
 
45
45
  - name: Publish release to PyPI
46
- uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4
46
+ uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
@@ -13,7 +13,7 @@ jobs:
13
13
  update_release_draft:
14
14
  runs-on: ubuntu-latest
15
15
  steps:
16
- - uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
16
+ - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
17
17
  with:
18
18
  egress-policy: audit
19
19
 
@@ -18,7 +18,7 @@ jobs:
18
18
  prek:
19
19
  runs-on: ubuntu-latest
20
20
  steps:
21
- - uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
21
+ - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
22
22
  with:
23
23
  egress-policy: audit
24
24
 
@@ -41,7 +41,7 @@ jobs:
41
41
  - "3.14"
42
42
 
43
43
  steps:
44
- - uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
44
+ - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
45
45
  with:
46
46
  egress-policy: audit
47
47
 
@@ -69,7 +69,7 @@ jobs:
69
69
  runs-on: ubuntu-latest
70
70
  needs: build
71
71
  steps:
72
- - uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
72
+ - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
73
73
  with:
74
74
  egress-policy: audit
75
75
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python_openevse_http
3
- Version: 0.4.1
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
@@ -183,12 +183,12 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
183
183
  return (False, "")
184
184
  return (value["cmd"], value["ret"])
185
185
 
186
- async def update(self) -> None:
186
+ async def update(self, force_status: bool = False) -> None:
187
187
  """Update the values."""
188
188
  # TODO: add addiontal endpoints to update
189
189
  urls = [f"{self.url}config"]
190
190
 
191
- if not self._ws_listening:
191
+ if not self._ws_listening or force_status or self.ota_update:
192
192
  urls = [f"{self.url}status", f"{self.url}config"]
193
193
 
194
194
  for url in urls:
@@ -196,7 +196,7 @@ class OpenEVSE(CommandsMixin, ManagersMixin, SensorsMixin, PropertiesMixin):
196
196
  response = await self.process_request(url, method="get")
197
197
  if "/status" in url:
198
198
  if isinstance(response, Mapping) and "error" not in response:
199
- self._status = dict(response)
199
+ self._status.update(dict(response))
200
200
  _LOGGER.debug("Status update: %s", self._status)
201
201
  elif isinstance(response, Mapping):
202
202
  _LOGGER.warning(
@@ -39,13 +39,27 @@ class CommandsMixin:
39
39
  async def send_command(self, command: str) -> tuple:
40
40
  raise NotImplementedError
41
41
 
42
- async def update(self) -> None:
42
+ async def update(self, force_status: bool = False) -> None:
43
43
  raise NotImplementedError
44
44
 
45
45
  def _normalize_response(self, response: Any) -> dict[str, Any] | list[Any]:
46
46
  """Normalize response to a dict or list."""
47
47
  raise NotImplementedError
48
48
 
49
+ def _flag_ota_if_started(self, response: Any) -> None:
50
+ """Flag OTA as active if response indicates firmware update has started."""
51
+ normalized = self._normalize_response(response)
52
+ if isinstance(normalized, dict) and (
53
+ normalized.get("msg") == "started"
54
+ or normalized.get("msg") in SUCCESS_ANSWERS
55
+ ):
56
+ _LOGGER.debug("Firmware update started, setting ota_update flag.")
57
+ self._status["ota_update"] = 1
58
+ else:
59
+ _LOGGER.debug(
60
+ "Firmware update response did not indicate start: %s", normalized
61
+ )
62
+
49
63
  async def get_schedule(self) -> Mapping[str, Any] | list[Any]:
50
64
  """Return the current schedule."""
51
65
  url = f"{self.url}schedule"
@@ -122,7 +136,12 @@ class CommandsMixin:
122
136
  time_limit: int | None = None,
123
137
  auto_release: bool | None = None,
124
138
  ) -> Any:
125
- """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
+ """
126
145
  if not self._version_check("4.0.1"):
127
146
  _LOGGER.debug("Feature not supported for older firmware.")
128
147
  raise UnsupportedFeature
@@ -140,6 +159,18 @@ class CommandsMixin:
140
159
  raise ValueError
141
160
 
142
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
+
143
174
  if auto_release is not None:
144
175
  data["auto_release"] = auto_release
145
176
 
@@ -372,6 +403,7 @@ class CommandsMixin:
372
403
  url = f"{base_url}ESP32_WiFi_V4.x/releases/latest"
373
404
  else:
374
405
  url = f"{base_url}ESP8266_WiFi_v2.x/releases/latest"
406
+ _LOGGER.debug("Firmware check URL: %s", url)
375
407
  except AwesomeVersionCompareException:
376
408
  _LOGGER.debug("Non-semver firmware version detected.")
377
409
  return None
@@ -403,6 +435,7 @@ class CommandsMixin:
403
435
  method,
404
436
  )
405
437
  async with http_method(url) as resp:
438
+ _LOGGER.debug("Firmware check response status: %d", resp.status)
406
439
  if resp.status != 200:
407
440
  return None
408
441
  message = await resp.text()
@@ -413,19 +446,51 @@ class CommandsMixin:
413
446
  return None
414
447
 
415
448
  if not isinstance(message, dict):
449
+ _LOGGER.debug(
450
+ "Invalid JSON response type from GitHub: %s", type(message)
451
+ )
416
452
  return None
417
453
 
454
+ _LOGGER.debug(
455
+ "GitHub release metadata successfully fetched for version: %s",
456
+ message.get("tag_name"),
457
+ )
458
+
418
459
  # Match browser_download_url based on buildenv
419
460
  download_url = None
420
461
  buildenv = self._config.get("buildenv")
421
462
  assets = message.get("assets", [])
422
463
 
423
- 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)
424
474
  target_filename = f"{buildenv}.bin"
425
475
  for asset in assets:
476
+ if not isinstance(asset, Mapping):
477
+ continue
426
478
  if asset.get("name") == target_filename:
427
479
  download_url = asset.get("browser_download_url")
480
+ _LOGGER.debug("Found matching firmware asset: %s", download_url)
428
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
+ )
429
494
 
430
495
  return {
431
496
  "latest_version": message.get("tag_name"),
@@ -452,10 +517,16 @@ class CommandsMixin:
452
517
  raise UnsupportedFeature
453
518
 
454
519
  if firmware_bytes is not None and firmware_url is not None:
520
+ _LOGGER.error("Cannot specify both firmware_bytes and firmware_url")
455
521
  raise ValueError("Cannot specify both firmware_bytes and firmware_url")
456
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
+
457
527
  if firmware_url is not None:
458
528
  if not isinstance(firmware_url, str) or not firmware_url.strip():
529
+ _LOGGER.error("Invalid firmware_url: %s", firmware_url)
459
530
  raise ValueError("Invalid firmware_url")
460
531
 
461
532
  url = f"{self.url}update"
@@ -473,12 +544,23 @@ class CommandsMixin:
473
544
  "Uploading firmware binary to %s (%d bytes)", url, len(firmware_bytes)
474
545
  )
475
546
  # Rapi is mapped to http request's data kwarg in process_request
476
- return await self.process_request(url=url, method="post", rapi=form_data)
547
+ response = await self.process_request(
548
+ url=url, method="post", rapi=form_data
549
+ )
550
+ _LOGGER.debug("Firmware upload request completed. Response: %s", response)
551
+ self._flag_ota_if_started(response)
552
+ return response
477
553
 
478
554
  # 2. Resolve URL from GitHub if not specified
479
555
  if firmware_url is None:
556
+ _LOGGER.debug(
557
+ "No firmware URL provided. Resolving latest matching firmware from GitHub."
558
+ )
480
559
  check_result = await self.firmware_check()
481
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
+ )
482
564
  raise RuntimeError(
483
565
  "Could not resolve latest firmware download URL from GitHub."
484
566
  )
@@ -489,7 +571,10 @@ class CommandsMixin:
489
571
  _LOGGER.debug(
490
572
  "Requesting OpenEVSE to download and update from: %s", firmware_url
491
573
  )
492
- return await self.process_request(url=url, method="post", data=data)
574
+ response = await self.process_request(url=url, method="post", data=data)
575
+ _LOGGER.debug("Firmware update request completed. Response: %s", response)
576
+ self._flag_ota_if_started(response)
577
+ return response
493
578
 
494
579
  async def set_led_brightness(self, level: int) -> None:
495
580
  """Set LED brightness level."""
@@ -32,7 +32,7 @@ ignore = [
32
32
  max-complexity = 18
33
33
 
34
34
  [build-system]
35
- requires = ["setuptools>=61.0.0", "setuptools-scm>=8.0"]
35
+ requires = ["setuptools>=82.0.1", "setuptools-scm>=10.0.5"]
36
36
  build-backend = "setuptools.build_meta"
37
37
 
38
38
  [tool.setuptools_scm]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python_openevse_http
3
- Version: 0.4.1
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
@@ -6,6 +6,5 @@ pytest-timeout==2.4.0
6
6
  pytest-asyncio
7
7
  requests_mock
8
8
  aiohttp
9
- aioresponses
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
@@ -117,6 +117,55 @@ async def test_get_status_auth_err(test_charger_auth_err):
117
117
  await test_charger_auth_err.update()
118
118
 
119
119
 
120
+ async def test_update_force_status(mock_aioclient):
121
+ """Test force_status parameter when ws is listening."""
122
+ unique_host = "force-status.test.tld"
123
+ charger = OpenEVSE(unique_host)
124
+ url_status = f"http://{unique_host}/status"
125
+ url_config = f"http://{unique_host}/config"
126
+
127
+ mock_aioclient.get(
128
+ url_status,
129
+ status=200,
130
+ body='{"state": 2, "wifi_serial": "123"}',
131
+ )
132
+ mock_aioclient.get(
133
+ url_config,
134
+ status=200,
135
+ body='{"wifi_serial": "123", "version": "4.0.1"}',
136
+ )
137
+ charger._ws_listening = True
138
+ charger._status = {"transient_key": "preserved"}
139
+ await charger.update(force_status=True)
140
+ assert charger._status["state"] == 2
141
+ assert charger._status["transient_key"] == "preserved"
142
+
143
+
144
+ async def test_update_ota_active(mock_aioclient):
145
+ """Test automatic status polling when ota_update is active."""
146
+ unique_host = "ota-active.test.tld"
147
+ charger = OpenEVSE(unique_host)
148
+ url_status = f"http://{unique_host}/status"
149
+ url_config = f"http://{unique_host}/config"
150
+
151
+ mock_aioclient.get(
152
+ url_status,
153
+ status=200,
154
+ body='{"state": 2, "ota_update": 1}',
155
+ )
156
+ mock_aioclient.get(
157
+ url_config,
158
+ status=200,
159
+ body='{"wifi_serial": "123", "version": "4.0.1"}',
160
+ )
161
+ charger._ws_listening = True
162
+ charger._status = {"ota_update": 1, "ota_progress": 50}
163
+ await charger.update()
164
+ assert charger._status["state"] == 2
165
+ assert charger._status["ota_progress"] == 50
166
+ assert charger.ota_update is True
167
+
168
+
120
169
  # ── send_command ──────────────────────────────────────────────────────
121
170
 
122
171
 
@@ -1060,6 +1060,9 @@ 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
1065
+ assert test_charger.ota_update is True
1063
1066
 
1064
1067
 
1065
1068
  async def test_update_firmware_url(test_charger, mock_aioclient, caplog):
@@ -1079,6 +1082,12 @@ async def test_update_firmware_url(test_charger, mock_aioclient, caplog):
1079
1082
  "Requesting OpenEVSE to download and update from: http://github.com/release.bin"
1080
1083
  in caplog.text
1081
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
1090
+ assert test_charger.ota_update is True
1082
1091
 
1083
1092
 
1084
1093
  async def test_update_firmware_auto(test_charger, mock_aioclient, caplog):
@@ -1118,13 +1127,43 @@ async def test_update_firmware_auto(test_charger, mock_aioclient, caplog):
1118
1127
  with caplog.at_level(logging.DEBUG):
1119
1128
  response = await test_charger.update_firmware()
1120
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
+ )
1121
1152
  assert (
1122
1153
  "Requesting OpenEVSE to download and update from: https://github.com/OpenEVSE/releases/download/v4.1.2/openevse_esp32-gateway.bin"
1123
1154
  in caplog.text
1124
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
1161
+ assert test_charger.ota_update is True
1125
1162
 
1126
1163
 
1127
- 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
+ ):
1128
1167
  """Test update_firmware raises RuntimeError when buildenv asset is missing."""
1129
1168
  test_charger._config = {"version": "4.1.7", "buildenv": "openevse_esp32-gateway"}
1130
1169
 
@@ -1147,35 +1186,47 @@ async def test_update_firmware_auto_missing_buildenv(test_charger, mock_aioclien
1147
1186
  body=json.dumps(github_response),
1148
1187
  )
1149
1188
 
1150
- with pytest.raises(
1151
- RuntimeError,
1152
- match="Could not resolve latest firmware download URL from GitHub.",
1153
- ):
1154
- 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
+ )
1155
1202
 
1156
1203
 
1157
- async def test_update_firmware_both_provided(test_charger):
1204
+ async def test_update_firmware_both_provided(test_charger, caplog):
1158
1205
  """Test update_firmware raises ValueError when both bytes and URL are provided."""
1159
1206
  test_charger._config["version"] = "4.1.7"
1160
- with pytest.raises(
1161
- ValueError, match="Cannot specify both firmware_bytes and firmware_url"
1162
- ):
1163
- await test_charger.update_firmware(
1164
- firmware_url="http://url", firmware_bytes=b"bytes"
1165
- )
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
1166
1213
 
1167
1214
 
1168
- async def test_update_firmware_url_invalid(test_charger):
1215
+ async def test_update_firmware_url_invalid(test_charger, caplog):
1169
1216
  """Test update_firmware raises ValueError when firmware_url is empty or invalid type."""
1170
1217
  test_charger._config["version"] = "4.1.7"
1171
- with pytest.raises(ValueError, match="Invalid firmware_url"):
1172
- 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
1173
1222
 
1174
- with pytest.raises(ValueError, match="Invalid firmware_url"):
1175
- 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
1176
1226
 
1177
- with pytest.raises(ValueError, match="Invalid firmware_url"):
1178
- 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
1179
1230
 
1180
1231
 
1181
1232
  async def test_update_firmware_unsupported(test_charger):
@@ -1183,3 +1234,100 @@ async def test_update_firmware_unsupported(test_charger):
1183
1234
  test_charger._config["version"] = "4.1.2"
1184
1235
  with pytest.raises(UnsupportedFeature):
1185
1236
  await test_charger.update_firmware(firmware_url="http://url")
1237
+
1238
+
1239
+ async def test_update_firmware_error_response(test_charger, mock_aioclient, caplog):
1240
+ """Test update_firmware doesn't set ota_update on error responses."""
1241
+ test_charger._config["version"] = "4.1.7"
1242
+ test_charger._status = {}
1243
+ mock_aioclient.post(
1244
+ "http://openevse.test.tld/update",
1245
+ status=200,
1246
+ body='{"msg":"error"}',
1247
+ )
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),
1278
+ )
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):