Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 72 additions & 2 deletions openevsehttp/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,12 @@ def _flag_ota_if_started(self, response: Any) -> None:
normalized.get("msg") == "started"
or normalized.get("msg") in SUCCESS_ANSWERS
):
_LOGGER.debug("Firmware update started, setting ota_update flag.")
self._status["ota_update"] = 1
else:
_LOGGER.debug(
"Firmware update response did not indicate start: %s", normalized
)

async def get_schedule(self) -> Mapping[str, Any] | list[Any]:
"""Return the current schedule."""
Expand Down Expand Up @@ -131,7 +136,12 @@ async def set_override(
time_limit: int | None = None,
auto_release: bool | None = None,
) -> Any:
"""Set the manual override status."""
"""Set the manual override status.

Fetches the current override payload first and merges existing values
into the request payload. This prevents the firmware from clearing/resetting
previously configured properties that are not passed in the function call.
"""
if not self._version_check("4.0.1"):
_LOGGER.debug("Feature not supported for older firmware.")
raise UnsupportedFeature
Expand All @@ -149,6 +159,18 @@ async def set_override(
raise ValueError

data: dict[str, Any] = {}
if isinstance(response, Mapping):
for key in (
"state",
"charge_current",
"max_current",
"energy_limit",
"time_limit",
"auto_release",
):
if key in response:
data[key] = response[key]

if auto_release is not None:
data["auto_release"] = auto_release

Expand Down Expand Up @@ -381,6 +403,7 @@ async def firmware_check(self) -> dict | None:
url = f"{base_url}ESP32_WiFi_V4.x/releases/latest"
else:
url = f"{base_url}ESP8266_WiFi_v2.x/releases/latest"
_LOGGER.debug("Firmware check URL: %s", url)
except AwesomeVersionCompareException:
_LOGGER.debug("Non-semver firmware version detected.")
return None
Expand Down Expand Up @@ -412,6 +435,7 @@ async def _firmware_check_with_session(
method,
)
async with http_method(url) as resp:
_LOGGER.debug("Firmware check response status: %d", resp.status)
if resp.status != 200:
return None
message = await resp.text()
Expand All @@ -422,19 +446,51 @@ async def _firmware_check_with_session(
return None

if not isinstance(message, dict):
_LOGGER.debug(
"Invalid JSON response type from GitHub: %s", type(message)
)
return None

_LOGGER.debug(
"GitHub release metadata successfully fetched for version: %s",
message.get("tag_name"),
)

# Match browser_download_url based on buildenv
download_url = None
buildenv = self._config.get("buildenv")
assets = message.get("assets", [])

if buildenv and assets:
if not buildenv:
_LOGGER.debug(
"Cannot resolve firmware asset: missing buildenv in config."
)
assets = []
elif not isinstance(assets, list):
_LOGGER.debug("Invalid GitHub assets payload: %r", assets)
assets = []
else:
_LOGGER.debug("Matching buildenv '%s' against assets", buildenv)
target_filename = f"{buildenv}.bin"
for asset in assets:
if not isinstance(asset, Mapping):
continue
if asset.get("name") == target_filename:
download_url = asset.get("browser_download_url")
_LOGGER.debug("Found matching firmware asset: %s", download_url)
break
if buildenv and not download_url:
_LOGGER.debug(
"Could not find asset matching target filename '%s.bin' in assets: %s",
buildenv,
[
asset.get("name")
for asset in assets
if isinstance(asset, Mapping)
]
if assets
else "None",
)

return {
"latest_version": message.get("tag_name"),
Expand All @@ -461,10 +517,16 @@ async def update_firmware(
raise UnsupportedFeature

if firmware_bytes is not None and firmware_url is not None:
_LOGGER.error("Cannot specify both firmware_bytes and firmware_url")
raise ValueError("Cannot specify both firmware_bytes and firmware_url")

if firmware_bytes is not None and len(firmware_bytes) == 0:
_LOGGER.error("Empty firmware bytes provided")
raise ValueError("Empty firmware bytes provided")

if firmware_url is not None:
if not isinstance(firmware_url, str) or not firmware_url.strip():
_LOGGER.error("Invalid firmware_url: %s", firmware_url)
raise ValueError("Invalid firmware_url")

url = f"{self.url}update"
Expand All @@ -485,13 +547,20 @@ async def update_firmware(
response = await self.process_request(
url=url, method="post", rapi=form_data
)
_LOGGER.debug("Firmware upload request completed. Response: %s", response)
self._flag_ota_if_started(response)
return response

# 2. Resolve URL from GitHub if not specified
if firmware_url is None:
_LOGGER.debug(
"No firmware URL provided. Resolving latest matching firmware from GitHub."
)
check_result = await self.firmware_check()
if not check_result or not check_result.get("browser_download_url"):
_LOGGER.error(
"Could not resolve latest firmware download URL from GitHub."
)
raise RuntimeError(
"Could not resolve latest firmware download URL from GitHub."
)
Expand All @@ -503,6 +572,7 @@ async def update_firmware(
"Requesting OpenEVSE to download and update from: %s", firmware_url
)
response = await self.process_request(url=url, method="post", data=data)
_LOGGER.debug("Firmware update request completed. Response: %s", response)
self._flag_ota_if_started(response)
return response

Expand Down
2 changes: 2 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ class RegisteredMock(NamedTuple):
class AiohttpClientMocker:
def __init__(self):
self.mocks = []
self.requests = []
self._patcher = None

def __enter__(self):
Expand Down Expand Up @@ -358,6 +359,7 @@ def options(self, *args, **kwargs):
async def _request_mock(self, method: str, str_or_url: Any, **kwargs: Any):
url_str = str(str_or_url)
method_upper = method.upper()
self.requests.append((method_upper, url_str, kwargs))

matching_mock = None
matching_index = -1
Expand Down
Loading
Loading