diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 8d9565ad5f..86f35cc47b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -31,7 +31,7 @@ "ms-python.python", "ms-python.pylint", "ms-python.flake8", - "ms-python.black-formatter", + "charliermarsh.ruff", "visualstudioexptteam.vscodeintellicode", // yaml "redhat.vscode-yaml", @@ -49,14 +49,11 @@ "flake8.args": [ "--config=${workspaceFolder}/.flake8" ], - "black-formatter.args": [ - "--config", - "${workspaceFolder}/pyproject.toml" - ], + "ruff.configuration": "${workspaceFolder}/pyproject.toml", "[python]": { // VS will say "Value is not accepted" before building the devcontainer, but the warning // should go away after build is completed. - "editor.defaultFormatter": "ms-python.black-formatter" + "editor.defaultFormatter": "charliermarsh.ruff" }, "editor.formatOnPaste": false, "editor.formatOnSave": true, diff --git a/.github/actions/build-image/action.yaml b/.github/actions/build-image/action.yaml index 25ae21fbd0..ee115252e8 100644 --- a/.github/actions/build-image/action.yaml +++ b/.github/actions/build-image/action.yaml @@ -46,7 +46,7 @@ runs: - name: Build and push to ghcr by digest id: build-ghcr - uses: docker/build-push-action@v6.13.0 + uses: docker/build-push-action@v6.14.0 env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false @@ -72,7 +72,7 @@ runs: - name: Build and push to dockerhub by digest id: build-dockerhub - uses: docker/build-push-action@v6.13.0 + uses: docker/build-push-action@v6.14.0 env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false diff --git a/.github/workflows/matchers/lint-python.json b/.github/workflows/matchers/lint-python.json index 6a09f04770..6f750f209a 100644 --- a/.github/workflows/matchers/lint-python.json +++ b/.github/workflows/matchers/lint-python.json @@ -1,11 +1,11 @@ { "problemMatcher": [ { - "owner": "black", + "owner": "ruff", "severity": "error", "pattern": [ { - "regexp": "^(.*): (Please format this file with the black formatter)", + "regexp": "^(.*): (Please format this file with the ruff formatter)", "file": 1, "message": 2 } diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index e762c89b94..425ce54b9f 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -238,6 +238,12 @@ async def to_code(config): else: add_idf_sdkconfig_option("CONFIG_BTU_TASK_STACK_SIZE", 8192) add_idf_sdkconfig_option("CONFIG_BT_ACL_CONNECTIONS", 9) + # CONFIG_BT_GATTC_NOTIF_REG_MAX controls the number of + # max notifications in 5.x, setting CONFIG_BT_ACL_CONNECTIONS + # is enough in 4.x + # https://github.com/esphome/issues/issues/6808 + if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(5, 0, 0): + add_idf_sdkconfig_option("CONFIG_BT_GATTC_NOTIF_REG_MAX", 9) cg.add_define("USE_OTA_STATE_CALLBACK") # To be notified when an OTA update starts cg.add_define("USE_ESP32_BLE_CLIENT") diff --git a/esphome/core/defines.h b/esphome/core/defines.h index dc0ac3c1e8..7bc84a22dc 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -71,7 +71,7 @@ #define USE_OTA #define USE_OTA_PASSWORD #define USE_OTA_STATE_CALLBACK -#define USE_OTA_VERSION 1 +#define USE_OTA_VERSION 2 #define USE_OUTPUT #define USE_POWER_SUPPLY #define USE_QR_CODE diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index b8562aaccb..f78f17b093 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -33,6 +33,7 @@ import tornado.process import tornado.queues import tornado.web import tornado.websocket +import voluptuous as vol import yaml from yaml.nodes import Node @@ -52,7 +53,6 @@ from .util.text import friendly_name_slugify if TYPE_CHECKING: from requests import Response - _LOGGER = logging.getLogger(__name__) ENV_DEV = "ESPHOME_DASHBOARD_DEV" @@ -592,16 +592,39 @@ class IgnoreDeviceRequestHandler(BaseHandler): class DownloadListRequestHandler(BaseHandler): @authenticated @bind_config - def get(self, configuration: str | None = None) -> None: + async def get(self, configuration: str | None = None) -> None: + loop = asyncio.get_running_loop() + try: + downloads_json = await loop.run_in_executor(None, self._get, configuration) + except vol.Invalid: + self.send_error(404) + return + if downloads_json is None: + self.send_error(404) + return + self.set_status(200) + self.set_header("content-type", "application/json") + self.write(downloads_json) + self.finish() + + def _get(self, configuration: str | None = None) -> dict[str, Any] | None: storage_path = ext_storage_path(configuration) storage_json = StorageJSON.load(storage_path) if storage_json is None: - self.send_error(404) - return + return None + + config = yaml_util.load_yaml(settings.rel_path(configuration)) + + if const.CONF_EXTERNAL_COMPONENTS in config: + from esphome.components.external_components import ( + do_external_components_pass, + ) + + do_external_components_pass(config) from esphome.components.esp32 import VARIANTS as ESP32_VARIANTS - downloads = [] + downloads: list[dict[str, Any]] = [] platform: str = storage_json.target_platform.lower() if platform.upper() in ESP32_VARIANTS: @@ -615,12 +638,7 @@ class DownloadListRequestHandler(BaseHandler): except AttributeError as exc: raise ValueError(f"Unknown platform {platform}") from exc downloads = get_download_types(storage_json) - - self.set_status(200) - self.set_header("content-type", "application/json") - self.write(json.dumps(downloads)) - self.finish() - return + return json.dumps(downloads) class DownloadBinaryRequestHandler(BaseHandler): diff --git a/pyproject.toml b/pyproject.toml index 7789f6d645..5cdf4a77b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,10 +51,6 @@ version = {attr = "esphome.const.__version__"} [tool.setuptools.packages.find] include = ["esphome*"] -[tool.black] -target-version = ["py39", "py310"] -exclude = 'generated' - [tool.pytest.ini_options] testpaths = [ "tests", @@ -108,6 +104,8 @@ expected-line-ending-format = "LF" [tool.ruff] required-version = ">=0.5.0" +target-version = "py39" +exclude = ['generated'] [tool.ruff.lint] select = [ diff --git a/requirements.txt b/requirements.txt index 1de6e3dd06..44e7669fdb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ platformio==6.1.16 # When updating platformio, also update Dockerfile esptool==4.7.0 click==8.1.7 esphome-dashboard==20250212.0 -aioesphomeapi==29.1.0 +aioesphomeapi==29.1.1 zeroconf==0.145.1 puremagic==1.27 ruamel.yaml==0.18.6 # dashboard_import diff --git a/script/lint-python b/script/lint-python index 01e5e76190..c9f1789160 100755 --- a/script/lint-python +++ b/script/lint-python @@ -19,7 +19,7 @@ curfile = None def print_error(file, lineno, msg): - global curfile + global curfile # noqa: PLW0603 if curfile != file: print_error_for_file(file, None) @@ -31,6 +31,22 @@ def print_error(file, lineno, msg): print(f"{styled(colorama.Style.BRIGHT, f'{file}:')} {msg}") +def split_args_platform_compatible(args): + if os.name == "posix": + return [args] + + char_length = 0 + argsets = [] + for index, arg in enumerate(args): + # Windows is techincally 8191, but we need to leave some room for the command itself + if char_length + len(arg) > 8000: + argsets.append(args[:index]) + args = args[index:] + char_length = 0 + char_length += len(arg) + return argsets + + def main(): colorama.init() @@ -69,61 +85,70 @@ def main(): errors = 0 - cmd = ["black", "--verbose"] + ([] if args.apply else ["--check"]) + files - print("Running black...") - print() - log = get_err(*cmd) - for line in log.splitlines(): - WOULD_REFORMAT = "would reformat" - if line.startswith(WOULD_REFORMAT): - file_ = line[len(WOULD_REFORMAT) + 1 :] - print_error(file_, None, "Please format this file with the black formatter") - errors += 1 + # Needed to get around command-line string limits in Windows. + filesets = split_args_platform_compatible(files) + + print("Running ruff...") + print() + for fileset in filesets: + cmd = ["ruff", "format"] + ([] if args.apply else ["--check"]) + fileset + log = get_err(*cmd) + for line in log.splitlines(): + WOULD_REFORMAT = "would reformat" + if line.startswith(WOULD_REFORMAT): + file_ = line[len(WOULD_REFORMAT) + 1 :] + print_error( + file_, None, "Please format this file with the ruff formatter" + ) + errors += 1 - cmd = ["flake8"] + files print() print("Running flake8...") print() - log = get_output(*cmd) - for line in log.splitlines(): - line = line.split(":", 4) - if len(line) < 4: - continue - file_ = line[0] - linno = line[1] - msg = (":".join(line[3:])).strip() - print_error(file_, linno, msg) - errors += 1 + for files in filesets: + cmd = ["flake8"] + files + log = get_output(*cmd) + for line in log.splitlines(): + line = line.split(":", 4) + if len(line) < 4: + continue + file_ = line[0] + linno = line[1] + msg = (":".join(line[3:])).strip() + print_error(file_, linno, msg) + errors += 1 - cmd = ["pylint", "-f", "parseable", "--persistent=n"] + files print() print("Running pylint...") print() - log = get_output(*cmd) - for line in log.splitlines(): - line = line.split(":", 3) - if len(line) < 3: - continue - file_ = line[0] - linno = line[1] - msg = (":".join(line[2:])).strip() - print_error(file_, linno, msg) - errors += 1 + for files in filesets: + cmd = ["pylint", "-f", "parseable", "--persistent=n"] + files + log = get_output(*cmd) + for line in log.splitlines(): + line = line.split(":", 3) + if len(line) < 3: + continue + file_ = line[0] + linno = line[1] + msg = (":".join(line[2:])).strip() + print_error(file_, linno, msg) + errors += 1 - PYUPGRADE_TARGET = "--py39-plus" - cmd = ["pyupgrade", PYUPGRADE_TARGET] + files print() print("Running pyupgrade...") print() - log = get_err(*cmd) - for line in log.splitlines(): - REWRITING = "Rewriting" - if line.startswith(REWRITING): - file_ = line[len(REWRITING) + 1 :] - print_error( - file_, None, f"Please run pyupgrade {PYUPGRADE_TARGET} on this file" - ) - errors += 1 + PYUPGRADE_TARGET = "--py39-plus" + for files in filesets: + cmd = ["pyupgrade", PYUPGRADE_TARGET] + files + log = get_err(*cmd) + for line in log.splitlines(): + REWRITING = "Rewriting" + if line.startswith(REWRITING): + file_ = line[len(REWRITING) + 1 :] + print_error( + file_, None, f"Please run pyupgrade {PYUPGRADE_TARGET} on this file" + ) + errors += 1 sys.exit(errors)