diff --git a/esphome/components/external_components/__init__.py b/esphome/components/external_components/__init__.py index d0153f6104..53fd337ed8 100644 --- a/esphome/components/external_components/__init__.py +++ b/esphome/components/external_components/__init__.py @@ -98,7 +98,7 @@ async def to_code(config): def _process_git_config(config: dict, refresh) -> str: - repo_dir = git.clone_or_update( + repo_dir, _ = git.clone_or_update( url=config[CONF_URL], ref=config.get(CONF_REF), refresh=refresh, diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 67220cae08..3b5a6a5908 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -5,14 +5,17 @@ from esphome.config_helpers import merge_config from esphome import git, yaml_util from esphome.const import ( + CONF_ESPHOME, CONF_FILE, CONF_FILES, + CONF_MIN_VERSION, CONF_PACKAGES, CONF_REF, CONF_REFRESH, CONF_URL, CONF_USERNAME, CONF_PASSWORD, + __version__ as ESPHOME_VERSION, ) import esphome.config_validation as cv @@ -104,7 +107,7 @@ CONFIG_SCHEMA = cv.All( def _process_base_package(config: dict) -> dict: - repo_dir = git.clone_or_update( + repo_dir, revert = git.clone_or_update( url=config[CONF_URL], ref=config.get(CONF_REF), refresh=config[CONF_REFRESH], @@ -112,21 +115,51 @@ def _process_base_package(config: dict) -> dict: username=config.get(CONF_USERNAME), password=config.get(CONF_PASSWORD), ) - files: str = config[CONF_FILES] + files: list[str] = config[CONF_FILES] + + def get_packages(files) -> dict: + packages = {} + for file in files: + yaml_file: Path = repo_dir / file + + if not yaml_file.is_file(): + raise cv.Invalid( + f"{file} does not exist in repository", path=[CONF_FILES] + ) + + try: + new_yaml = yaml_util.load_yaml(yaml_file) + if ( + CONF_ESPHOME in new_yaml + and CONF_MIN_VERSION in new_yaml[CONF_ESPHOME] + ): + min_version = new_yaml[CONF_ESPHOME][CONF_MIN_VERSION] + if cv.Version.parse(min_version) > cv.Version.parse( + ESPHOME_VERSION + ): + raise cv.Invalid( + f"Current ESPHome Version is too old to use this package: {ESPHOME_VERSION} < {min_version}" + ) + + packages[file] = new_yaml + except EsphomeError as e: + raise cv.Invalid( + f"{file} is not a valid YAML file. Please check the file contents." + ) from e + return packages packages = {} - for file in files: - yaml_file: Path = repo_dir / file - if not yaml_file.is_file(): - raise cv.Invalid(f"{file} does not exist in repository", path=[CONF_FILES]) + try: + packages = get_packages(files) + except cv.Invalid: + if revert is not None: + revert() + packages = get_packages(files) + finally: + if packages is None: + raise cv.Invalid("Failed to load packages") - try: - packages[file] = yaml_util.load_yaml(yaml_file) - except EsphomeError as e: - raise cv.Invalid( - f"{file} is not a valid YAML file. Please check the file contents." - ) from e return {"packages": packages} diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 0ff0ba83d9..09436c1fbf 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1689,7 +1689,7 @@ class Version: @classmethod def parse(cls, value: str) -> "Version": - match = re.match(r"(\d+).(\d+).(\d+)", value) + match = re.match(r"^(\d+).(\d+).(\d+)-?\w*$", value) if match is None: raise ValueError(f"Not a valid version number {value}") major = int(match[1]) @@ -1703,7 +1703,7 @@ def version_number(value): try: return str(Version.parse(value)) except ValueError as e: - raise Invalid("Not a version number") from e + raise Invalid("Not a valid version number") from e def platformio_version_constraint(value): diff --git a/esphome/const.py b/esphome/const.py index d12708cdd6..0f056498e4 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -396,6 +396,7 @@ CONF_MIN_POWER = "min_power" CONF_MIN_RANGE = "min_range" CONF_MIN_TEMPERATURE = "min_temperature" CONF_MIN_VALUE = "min_value" +CONF_MIN_VERSION = "min_version" CONF_MINUTE = "minute" CONF_MINUTES = "minutes" CONF_MISO_PIN = "miso_pin" diff --git a/esphome/core/config.py b/esphome/core/config.py index 82cf37d44d..733cdcd66e 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -15,6 +15,7 @@ from esphome.const import ( CONF_FRAMEWORK, CONF_INCLUDES, CONF_LIBRARIES, + CONF_MIN_VERSION, CONF_NAME, CONF_ON_BOOT, CONF_ON_LOOP, @@ -30,6 +31,7 @@ from esphome.const import ( KEY_CORE, TARGET_PLATFORMS, PLATFORM_ESP8266, + __version__ as ESPHOME_VERSION, ) from esphome.core import CORE, coroutine_with_priority from esphome.helpers import copy_file_if_changed, walk_files @@ -96,6 +98,16 @@ def valid_project_name(value: str): return value +def validate_version(value: str): + min_version = cv.Version.parse(value) + current_version = cv.Version.parse(ESPHOME_VERSION) + if current_version < min_version: + raise cv.Invalid( + f"Your ESPHome version is too old. Please update to at least {min_version}" + ) + return value + + CONF_ESP8266_RESTORE_FROM_FLASH = "esp8266_restore_from_flash" CONFIG_SCHEMA = cv.All( cv.Schema( @@ -136,6 +148,9 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_VERSION): cv.string_strict, } ), + cv.Optional(CONF_MIN_VERSION, default=ESPHOME_VERSION): cv.All( + cv.version_number, validate_version + ), } ), validate_hostname, diff --git a/esphome/git.py b/esphome/git.py index 64c8d6a6b7..54fedc035f 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -2,6 +2,7 @@ from pathlib import Path import subprocess import hashlib import logging +from typing import Callable, Optional import urllib.parse from datetime import datetime @@ -12,7 +13,7 @@ import esphome.config_validation as cv _LOGGER = logging.getLogger(__name__) -def run_git_command(cmd, cwd=None): +def run_git_command(cmd, cwd=None) -> str: try: ret = subprocess.run(cmd, cwd=cwd, capture_output=True, check=False) except FileNotFoundError as err: @@ -28,6 +29,8 @@ def run_git_command(cmd, cwd=None): raise cv.Invalid(lines[-1][len("fatal: ") :]) raise cv.Invalid(err_str) + return ret.stdout.decode("utf-8").strip() + def _compute_destination_path(key: str, domain: str) -> Path: base_dir = Path(CORE.config_dir) / ".esphome" / domain @@ -44,7 +47,7 @@ def clone_or_update( domain: str, username: str = None, password: str = None, -) -> Path: +) -> tuple[Path, Optional[Callable[[], None]]]: key = f"{url}@{ref}" if username is not None and password is not None: @@ -78,6 +81,7 @@ def clone_or_update( file_timestamp = Path(repo_dir / ".git" / "HEAD") age = datetime.now() - datetime.fromtimestamp(file_timestamp.stat().st_mtime) if age.total_seconds() > refresh.total_seconds: + old_sha = run_git_command(["git", "rev-parse", "HEAD"], str(repo_dir)) _LOGGER.info("Updating %s", key) _LOGGER.debug("Location: %s", repo_dir) # Stash local changes (if any) @@ -92,4 +96,10 @@ def clone_or_update( # Hard reset to FETCH_HEAD (short-lived git ref corresponding to most recent fetch) run_git_command(["git", "reset", "--hard", "FETCH_HEAD"], str(repo_dir)) - return repo_dir + def revert(): + _LOGGER.info("Reverting changes to %s -> %s", key, old_sha) + run_git_command(["git", "reset", "--hard", old_sha], str(repo_dir)) + + return repo_dir, revert + + return repo_dir, None