"""Firmware-related methods for OPNsenseClient."""
from collections.abc import MutableMapping
from datetime import datetime, timedelta
from typing import Any
import awesomeversion
from dateutil.parser import ParserError, UnknownTimezoneWarning, parse
from ._typing import AiopnsenseClientProtocol
from .const import AMBIGUOUS_TZINFOS
from .helpers import _LOGGER, _log_errors, dict_get
class FirmwareMixin(AiopnsenseClientProtocol):
"""Firmware methods for OPNsenseClient."""
_firmware_version: str | None
async def _store_host_firmware_version(self) -> None:
"""Store host firmware version."""
status_endpoint = "/api/core/firmware/status"
if not await self.is_endpoint_available(status_endpoint):
_LOGGER.debug("Firmware status endpoint unavailable")
self._firmware_version = None
return
firmware_info = await self._safe_dict_get(status_endpoint)
firmware: str | None = dict_get(firmware_info, "product.product_version")
if not firmware or not awesomeversion.AwesomeVersion(firmware).valid:
old = firmware
firmware = dict_get(firmware_info, "product.product_series", old)
if firmware != old:
_LOGGER.debug(
"[get_host_firmware_version] firmware: %s not valid SemVer, using %s",
old,
firmware,
)
else:
_LOGGER.debug("[get_host_firmware_version] firmware: %s", firmware)
self._firmware_version = firmware
@_log_errors
async def get_host_firmware_version(self) -> None | str:
"""Return the OPNsense Firmware version.
Returns:
None | str: Normalized data returned by the related OPNsense endpoint.
"""
if self._firmware_version is None:
await self._store_host_firmware_version()
return self._firmware_version
@_log_errors
async def get_firmware_update_info(self) -> MutableMapping[str, Any]:
"""Get the details of available firmware updates.
Returns:
MutableMapping[str, Any]: Normalized data returned by the related OPNsense endpoint.
"""
status_endpoint = "/api/core/firmware/status"
if not await self.is_endpoint_available(status_endpoint):
_LOGGER.debug("Firmware status endpoint unavailable")
return {}
status = await self._safe_dict_get(status_endpoint)
# if error or too old trigger check (only if check is not already in progress)
# {'status_msg': 'Firmware status check was aborted internally. Please try again.', 'status': 'error'}
# error could be because data has not been refreshed at all OR an upgrade is currently in progress
if error_status := bool(status.get("status") == "error"):
_LOGGER.debug("Last firmware status check returned an error")
product_version = dict_get(status, "product.product_version")
product_latest = dict_get(status, "product.product_latest")
missing_data = False
if (
not product_version
or not product_latest
or not isinstance(dict_get(status, "product.product_check"), MutableMapping)
or not dict_get(status, "product.product_check")
):
_LOGGER.debug("Missing data in firmware status")
missing_data = True
update_needs_info = False
try:
if (
awesomeversion.AwesomeVersion(product_latest)
> awesomeversion.AwesomeVersion(product_version)
and status.get("status_msg", "").strip()
== "There are no updates available on the selected mirror."
):
_LOGGER.debug("Update available but missing details")
update_needs_info = True
except (
awesomeversion.exceptions.AwesomeVersionCompareException,
TypeError,
ValueError,
) as e:
_LOGGER.debug("Error checking firmware versions. %s: %s", type(e).__name__, e)
update_needs_info = True
last_check_str = status.get("last_check")
last_check_expired = True
if last_check_str:
try:
last_check_dt = parse(last_check_str, tzinfos=AMBIGUOUS_TZINFOS)
if last_check_dt.tzinfo is None:
opnsense_tz = await self._get_opnsense_timezone()
last_check_dt = last_check_dt.replace(tzinfo=opnsense_tz)
last_check_expired = (datetime.now().astimezone() - last_check_dt) > timedelta(
days=1
)
if last_check_expired:
_LOGGER.debug("Firmware status last check > 1 day ago")
except (ValueError, TypeError, ParserError, UnknownTimezoneWarning) as e:
_LOGGER.debug(
"Error getting firmware status last check. %s: %s", type(e).__name__, e
)
else:
_LOGGER.debug("Firmware status last check is missing")
if error_status or last_check_expired or missing_data or update_needs_info:
_LOGGER.info("Triggering firmware check")
self._firmware_version = None
await self._post("/api/core/firmware/check")
return status
@_log_errors
async def upgrade_firmware(self, type: str = "update") -> MutableMapping[str, Any] | None:
"""Trigger a firmware upgrade.
Args:
type (str): Requested firmware upgrade type.
Returns:
MutableMapping[str, Any] | None: Mapping containing normalized fields for downstream use.
"""
# update = minor updates of the same opnsense version
# upgrade = major updates to a new opnsense version
if type in ("update", "upgrade"):
self._firmware_version = None
return await self._safe_dict_post(f"/api/core/firmware/{type}")
return None
@_log_errors
async def upgrade_status(self) -> MutableMapping[str, Any]:
"""Return the status of the firmware upgrade.
Returns:
MutableMapping[str, Any]: The status of the firmware upgrade.
"""
status_endpoint = "/api/core/firmware/upgradestatus"
if not await self.is_endpoint_available(status_endpoint):
_LOGGER.debug("Firmware upgrade status endpoint unavailable")
return {}
return await self._safe_dict_get(status_endpoint)
@_log_errors
async def firmware_changelog(self, version: str) -> MutableMapping[str, Any]:
"""Return the changelog for the firmware upgrade.
Args:
version (str): Firmware version string to cache.
Returns:
MutableMapping[str, Any]: The changelog for the firmware upgrade.
"""
return await self._safe_dict_post(f"/api/core/firmware/changelog/{version}")