#!/usr/bin/env python3 import argparse import codecs import collections from collections.abc import Iterator import fnmatch import functools import os.path from pathlib import Path import re import sys import time import colorama from helpers import filter_changed, git_ls_files, print_error_for_file, styled sys.path.append(str(Path(__file__).parent)) def find_all(a_str, sub): if not a_str.find(sub): # Optimization: If str is not in whole text, then do not try # on each line return for i, line in enumerate(a_str.split("\n")): column = 0 while True: column = line.find(sub, column) if column == -1: break yield i, column column += len(sub) file_types = ( ".h", ".c", ".cpp", ".tcc", ".yaml", ".yml", ".ini", ".txt", ".ico", ".svg", ".png", ".py", ".html", ".js", ".md", ".sh", ".css", ".proto", ".conf", ".cfg", ".woff", ".woff2", "", ) cpp_include = ("*.h", "*.c", "*.cpp", "*.tcc") py_include = ("*.py",) ignore_types = ( ".ico", ".png", ".woff", ".woff2", "", ".ttf", ".otf", ".pcf", ".apng", ".gif", ".webp", ".bin", ".wav", ) LINT_FILE_CHECKS = [] LINT_CONTENT_CHECKS = [] LINT_POST_CHECKS = [] EXECUTABLE_BIT: dict[str, int] = {} errors: collections.defaultdict[Path, list] = collections.defaultdict(list) def add_errors(fname: Path, errs: list[tuple[int, int, str] | None]) -> None: if not isinstance(errs, list): errs = [errs] for err in errs: if err is None: continue try: lineno, col, msg = err except ValueError: lineno = 1 col = 1 msg = err if not isinstance(msg, str): raise ValueError("Error is not instance of string!") if not isinstance(lineno, int): raise ValueError("Line number is not an int!") if not isinstance(col, int): raise ValueError("Column number is not an int!") errors[fname].append((lineno, col, msg)) def run_check(lint_obj, fname, *args): include = lint_obj["include"] exclude = lint_obj["exclude"] func = lint_obj["func"] if include is not None: for incl in include: if fnmatch.fnmatch(fname, incl): break else: return None for excl in exclude: if fnmatch.fnmatch(fname, excl): return None return func(*args) def run_checks(lints, fname, *args): for lint in lints: start = time.process_time() try: add_errors(fname, run_check(lint, fname, *args)) except Exception: print(f"Check {lint['func'].__name__} on file {fname} failed:") raise duration = time.process_time() - start lint.setdefault("durations", []).append(duration) def _add_check(checks, func, include=None, exclude=None): checks.append( { "include": include, "exclude": exclude or [], "func": func, } ) def lint_file_check(**kwargs): def decorator(func): _add_check(LINT_FILE_CHECKS, func, **kwargs) return func return decorator def lint_content_check(**kwargs): def decorator(func): _add_check(LINT_CONTENT_CHECKS, func, **kwargs) return func return decorator def lint_post_check(func): _add_check(LINT_POST_CHECKS, func) return func def lint_re_check(regex, **kwargs): flags = kwargs.pop("flags", re.MULTILINE) prog = re.compile(regex, flags) decor = lint_content_check(**kwargs) def decorator(func): @functools.wraps(func) def new_func(fname, content): errs = [] for match in prog.finditer(content): if "NOLINT" in match.group(0): continue lineno = content.count("\n", 0, match.start()) + 1 substr = content[: match.start()] col = len(substr) - substr.rfind("\n") err = func(fname, match) if err is None: continue errs.append((lineno, col + 1, err)) return errs return decor(new_func) return decorator def lint_content_find_check(find, only_first=False, **kwargs): decor = lint_content_check(**kwargs) def decorator(func): @functools.wraps(func) def new_func(fname, content): find_ = find if callable(find): find_ = find(fname, content) errs = [] for line, col in find_all(content, find_): err = func(fname, line, col, content) errs.append((line + 1, col + 1, err)) if only_first: break return errs return decor(new_func) return decorator @lint_file_check(include=["*.ino"]) def lint_ino(fname): return "This file extension (.ino) is not allowed. Please use either .cpp or .h" @lint_file_check( exclude=[f"*{f}" for f in file_types] + [ ".clang-*", ".dockerignore", ".editorconfig", "*.gitignore", "LICENSE", "pylintrc", "MANIFEST.in", "docker/Dockerfile*", "docker/rootfs/*", "script/*", ] ) def lint_ext_check(fname): return ( "This file extension is not a registered file type. If this is an error, please " "update the script/ci-custom.py script." ) @lint_file_check( exclude=[ "**.sh", "docker/ha-addon-rootfs/**", "docker/*.py", "script/*", "CLAUDE.md", "GEMINI.md", ".github/copilot-instructions.md", # Symlink to the real wifi scan_list.h so the test stub cannot drift "tests/integration/fixtures/external_components/wifi/scan_list.h", ] ) def lint_executable_bit(fname: Path) -> str | None: ex = EXECUTABLE_BIT[fname.as_posix()] if ex != 100644: return ( f"File has invalid executable bit {ex}. If running from a windows machine please " "see disabling executable bit in git." ) return None @lint_content_find_check("\t", only_first=True) def lint_tabs(fname, line, col, content): return "File contains tab character. Please convert tabs to spaces." @lint_content_find_check("\r", only_first=True) def lint_newline(fname, line, col, content): return "File contains Windows newline. Please set your editor to Unix newline mode." @lint_content_check(exclude=["*.svg"]) def lint_end_newline(fname, content): if content and not content.endswith("\n"): return "File does not end with a newline, please add an empty line at the end of the file." return None CPP_RE_EOL = r".*?(?://.*?)?$" PY_RE_EOL = r".*?(?:#.*?)?$" def highlight(s): return f"\033[36m{s}\033[0m" @lint_re_check( r"^#define\s+([a-zA-Z0-9_]+)\s+(0b[10]+|0x[0-9a-fA-F]+|\d+)\s*?(?:\/\/.*?)?$", include=cpp_include, exclude=[ "esphome/core/log.h", "esphome/components/socket/headers.h", "esphome/core/defines.h", "esphome/components/http_request/httplib.h", ], ) def lint_no_defines(fname, match): s = highlight(f"static constexpr uint8_t {match.group(1)} = {match.group(2)};") return ( "#define macros for integer constants are not allowed, please use " f"{s} style instead (replace uint8_t with the appropriate " "datatype). See also Google style guide." ) @lint_re_check(r"^\s*delay\((\d+)\);" + CPP_RE_EOL, include=cpp_include) def lint_no_long_delays(fname, match): duration_ms = int(match.group(1)) if duration_ms < 50: return None return ( f"{highlight(match.group(0).strip())} - long calls to delay() are not allowed " "in ESPHome because everything executes in one thread. Calling delay() will " "block the main thread and slow down ESPHome.\n" "If there's no way to work around the delay() and it doesn't execute often, please add " "a '// NOLINT' comment to the line." ) @lint_content_check( include=[ "esphome/const.py", "esphome/components/const/__init__.py", ] ) def lint_const_ordered(fname, content): """Lint that value in const.py are ordered. Reason: Otherwise people add it to the end, and then that results in merge conflicts. """ lines = content.splitlines() errs = [] for start in ["CONF_", "ICON_", "UNIT_"]: matching = [ (i + 1, line) for i, line in enumerate(lines) if line.startswith(start) ] ordered = sorted(matching, key=lambda x: x[1].replace("_", " ")) ordered = [(mi, ol) for (mi, _), (_, ol) in zip(matching, ordered, strict=True)] for (mi, mline), (_, ol) in zip(matching, ordered, strict=True): if mline == ol: continue target = next(i for i, line in ordered if line == mline) target_text = next(line for i, line in matching if target == i) errs.append( ( mi, 1, ( f"Constant {highlight(mline)} is not ordered, please make sure all " f"constants are ordered. See line {mi} (should go to line {target}, " f"{target_text})" ), ) ) return errs @lint_re_check(r'^\s*CONF_([A-Z_0-9a-z]+)\s+=\s+[\'"](.*?)[\'"]\s*?$', include=["*.py"]) def lint_conf_matches(fname, match): const = match.group(1) value = match.group(2) const_norm = const.lower() value_norm = value.replace(".", "_") if const_norm == value_norm: return None return ( f"Constant {highlight('CONF_' + const)} does not match value {highlight(value)}! " "Please make sure the constant's name matches its value!" ) CONF_RE = r'^(CONF_[a-zA-Z0-9_]+)\s*=\s*[\'"].*?[\'"]\s*?$' with codecs.open("esphome/const.py", "r", encoding="utf-8") as const_f_handle: constants_content = const_f_handle.read() CONSTANTS = [m.group(1) for m in re.finditer(CONF_RE, constants_content, re.MULTILINE)] CONSTANTS_USES = collections.defaultdict(list) @lint_re_check(CONF_RE, include=["*.py"], exclude=["esphome/const.py"]) def lint_conf_from_const_py(fname, match): name = match.group(1) if name not in CONSTANTS: CONSTANTS_USES[name].append(fname) return None return ( f"Constant {highlight(name)} has already been defined in const.py - " "please import the constant from const.py directly." ) RAW_PIN_ACCESS_RE = ( r"^\s(pinMode|digitalWrite|digitalRead)\((.*)->get_pin\(\),\s*([^)]+).*\)" ) @lint_re_check(RAW_PIN_ACCESS_RE, include=cpp_include) def lint_no_raw_pin_access(fname, match): func = match.group(1) pin = match.group(2) mode = match.group(3) new_func = { "pinMode": "pin_mode", "digitalWrite": "digital_write", "digitalRead": "digital_read", }[func] new_code = highlight(f"{pin}->{new_func}({mode})") return f"Don't use raw {func} calls. Instead, use the `->{new_func}` function: {new_code}" # Functions from Arduino framework that are forbidden to use directly ARDUINO_FORBIDDEN = [ "digitalWrite", "digitalRead", "pinMode", "shiftOut", "shiftIn", "radians", "degrees", "interrupts", "noInterrupts", "lowByte", "highByte", "bitRead", "bitSet", "bitClear", "bitWrite", "bit", "analogRead", "analogWrite", "pulseIn", "pulseInLong", "tone", ] ARDUINO_FORBIDDEN_RE = r"[^\w\d](" + r"|".join(ARDUINO_FORBIDDEN) + r")\(.*" @lint_re_check( ARDUINO_FORBIDDEN_RE, include=cpp_include, exclude=[ "esphome/components/mqtt/custom_mqtt_device.h", "esphome/components/sun/sun.cpp", ], ) def lint_no_arduino_framework_functions(fname, match): nolint = highlight("// NOLINT") return ( f"The function {highlight(match.group(1))} from the Arduino framework is forbidden to be " f"used directly in the ESPHome codebase. Please use ESPHome's abstractions and equivalent " f"C++ instead.\n" f"\n" f"(If the function is strictly necessary, please add `{nolint}` to the end of the line)" ) IDF_CONVERSION_FORBIDDEN = { "ARDUINO_ARCH_ESP32": "USE_ESP32", "ARDUINO_ARCH_ESP8266": "USE_ESP8266", "pgm_read_byte": "progmem_read_byte", "ICACHE_RAM_ATTR": "IRAM_ATTR", "esphome/core/esphal.h": "esphome/core/hal.h", } IDF_CONVERSION_FORBIDDEN_RE = r"(" + r"|".join(IDF_CONVERSION_FORBIDDEN) + r").*" @lint_re_check( IDF_CONVERSION_FORBIDDEN_RE, include=cpp_include, ) def lint_no_removed_in_idf_conversions(fname, match): replacement = IDF_CONVERSION_FORBIDDEN[match.group(1)] return ( f"The macro {highlight(match.group(1))} can no longer be used in ESPHome directly. " f"Please use {highlight(replacement)} instead." ) @lint_re_check( r"[^\w\d]byte +[\w\d]+\s*=", include=cpp_include, exclude={ "esphome/components/tuya/tuya.h", }, ) def lint_no_byte_datatype(fname, match): return ( f"The datatype {highlight('byte')} is not allowed to be used in ESPHome. " f"Please use {highlight('uint8_t')} instead." ) @lint_re_check( r"(?:std\s*::\s*string_view|#include\s*)" + CPP_RE_EOL, include=cpp_include, ) def lint_no_std_string_view(fname, match): return ( f"{highlight('std::string_view')} is not allowed in ESPHome. " f"It pulls in significant STL template machinery that bloats flash on " f"resource-constrained embedded targets, does not work well with ArduinoJson, " f"and duplicates functionality already provided by {highlight('StringRef')}.\n" f"Please use {highlight('StringRef')} from {highlight('esphome/core/string_ref.h')} " f"for non-owning string references, or {highlight('const char *')} for simple cases.\n" f"(If strictly necessary, add `{highlight('// NOLINT')}` to the end of the line)" ) @lint_re_check( r"(?:" # `from esphome.components.const import ...` r"from\s+esphome\.components\.const\s+import" r"|" # `import esphome.components.const` (with optional `as` alias) r"import\s+esphome\.components\.const\b" r"|" # `from esphome.components import [(] ... const ... [)]` # Handles parenthesized + multiline import lists by allowing newlines inside # the parens via [^)]*. Single-line form falls back to the [^#\n]* branch. r"from\s+esphome\.components\s+import\s*" r"(?:\([^)]*\bconst\b[^)]*\)|(?:[^#\n]*[\s,])?\bconst\b)" r")", include=["*.py"], exclude=[ "esphome/components/*", "tests/*", "script/ci-custom.py", ], ) def lint_no_components_const_outside_components(fname, match): return ( f"Constants in {highlight('esphome/components/const/__init__.py')} are intended " f"to be shared only between components in {highlight('esphome/components/')}. " f"Code outside this folder must not import from " f"{highlight('esphome.components.const')}.\n" f"For core code (used outside {highlight('esphome/components/')}), define the " f"constant in {highlight('esphome/const.py')} instead. When adding a new " f"{highlight('CONF_')} constant there, bump {highlight('CONST_PY_MAX_CONF')} " f"in this file accordingly (see {highlight('lint_const_py_frozen')})." ) @lint_post_check def lint_constants_usage(): errs = [] for constant, uses in CONSTANTS_USES.items(): if len(uses) < 3: continue errs.append( f"Constant {highlight(constant)} is defined in {len(uses)} files. Please move all definitions of the " f"constant to esphome/components/const/__init__.py (Uses: {', '.join(str(u) for u in uses)}) in a separate PR. " "See https://developers.esphome.io/contributing/code/#python" ) return errs # Maximum allowed CONF_ constants in esphome/const.py. # This file is frozen — new constants go in esphome/components/const/__init__.py. # Decrease this number when constants are moved out of const.py. CONST_PY_MAX_CONF = 1017 @lint_content_check(include=["esphome/const.py"]) def lint_const_py_frozen(fname, content): """Block new CONF_ constants from being added to esphome/const.py. New constants should go in esphome/components/const/__init__.py instead. """ count = sum(1 for line in content.splitlines() if line.startswith("CONF_")) if count > CONST_PY_MAX_CONF: return ( "esphome/const.py is frozen. " "Add new constants to esphome/components/const/__init__.py instead." ) if count < CONST_PY_MAX_CONF: return f"CONST_PY_MAX_CONF in ci-custom.py should be updated to {count}." return None def relative_cpp_search_text(fname: Path, content) -> str: parts = fname.parts integration = parts[2] return f'#include "esphome/components/{integration}' @lint_content_find_check(relative_cpp_search_text, include=["esphome/components/*.cpp"]) def lint_relative_cpp_import(fname, line, col, content): return ( "Component contains absolute import - Components must always use " "relative imports.\n" "Change:\n" ' #include "esphome/components/abc/abc.h"\n' "to:\n" ' #include "abc.h"\n\n' ) def relative_py_search_text(fname: Path, content: str) -> str: parts = fname.parts integration = parts[2] return f"esphome.components.{integration}" def convert_path_to_relative(abspath, current): """Convert an absolute path to a relative import path.""" if abspath == current: return "." absparts = abspath.split(".") curparts = current.split(".") uplen = len(curparts) while absparts and curparts and absparts[0] == curparts[0]: absparts.pop(0) curparts.pop(0) uplen -= 1 return "." * uplen + ".".join(absparts) @lint_content_find_check( relative_py_search_text, include=["esphome/components/*.py"], exclude=[ "esphome/components/libretiny/generate_components.py", "esphome/components/web_server/__init__.py", # const.py has absolute import in docstring example for external components "esphome/components/esp8266/const.py", # rp2040/__init__.py is the deprecation shim that documents the canonical # rp2 module path and its own legacy import paths in docstrings/comments. "esphome/components/rp2040/__init__.py", ], ) def lint_relative_py_import(fname: Path, line, col, content): import_line = content.splitlines()[line] abspath = import_line[col:].split(" ")[0] current = str(fname).removesuffix(".py").replace(os.path.sep, ".") replacement = convert_path_to_relative(abspath, current) newline = import_line.replace(abspath, replacement) return ( "Component contains absolute import - Components must always use " "relative imports within the integration.\n" "Change:\n" f" {import_line}\n" "to:\n" f" {newline}\n" ) @lint_content_check( include=[ "esphome/components/*.h", "esphome/components/*.cpp", "esphome/components/*.tcc", ], exclude=[ "esphome/components/socket/headers.h", "esphome/components/async_tcp/async_tcp.h", "esphome/components/esp32/core.cpp", "esphome/components/esp8266/core.cpp", "esphome/components/rp2/core.cpp", "esphome/components/libretiny/core.cpp", "esphome/components/host/core.cpp", "esphome/components/zephyr/core.cpp", "esphome/components/esp32/helpers.cpp", "esphome/components/esp8266/helpers.cpp", "esphome/components/rp2/helpers.cpp", "esphome/components/libretiny/helpers.cpp", "esphome/components/host/helpers.cpp", "esphome/components/zephyr/helpers.cpp", "esphome/components/http_request/httplib.h", ], ) def lint_namespace(fname: Path, content: str) -> str | None: expected_name = fname.parts[2] # Check for both old style and C++17 nested namespace syntax search_old = f"namespace {expected_name}" search_new = f"namespace esphome::{expected_name}" if search_old in content or search_new in content: return None return ( "Invalid namespace found in C++ file. All integration C++ files should put all " "functions in a separate namespace that matches the integration's name. " f"Please make sure the file contains {highlight(search_old)} or {highlight(search_new)}" ) @lint_content_find_check('"esphome.h"', include=cpp_include, exclude=["tests/custom.h"]) def lint_esphome_h(fname, line, col, content): return ( "File contains reference to 'esphome.h' - This file is " "auto-generated and should only be used for *custom* " "components. Please replace with references to the direct files." ) @lint_content_check(include=["*.h"], exclude=["esphome/core/entity_types.h"]) def lint_pragma_once(fname, content): if "#pragma once" not in content: return ( "Header file contains no 'pragma once' header guard. Please add a " "'#pragma once' line at the top of the file." ) return None def lint_inclusive_language(fname, match): # From https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=49decddd39e5f6132ccd7d9fdc3d7c470b0061bb return ( "Avoid the use of whitelist/blacklist/slave.\n" "Recommended replacements for 'master / slave' are:\n" " '{primary,main} / {secondary,replica,subordinate}\n" " '{initiator,requester} / {target,responder}'\n" " '{controller,host} / {device,worker,proxy}'\n" " 'leader / follower'\n" " 'director / performer'\n" "\n" "Recommended replacements for 'blacklist/whitelist' are:\n" " 'denylist / allowlist'\n" " 'blocklist / passlist'" ) lint_re_check( r"(whitelist|blacklist|slave)" + PY_RE_EOL, include=py_include, exclude=["script/ci-custom.py"], flags=re.IGNORECASE | re.MULTILINE, )(lint_inclusive_language) lint_re_check( r"(whitelist|blacklist|slave)" + CPP_RE_EOL, include=cpp_include, flags=re.IGNORECASE | re.MULTILINE, )(lint_inclusive_language) @lint_re_check(r"[\t\r\f\v ]+$") def lint_trailing_whitespace(fname, match): return "Trailing whitespace detected" # Heap-allocating helpers that cause fragmentation on long-running embedded devices. # These return std::string and should be replaced with stack-based alternatives. HEAP_ALLOCATING_HELPERS = { "base64_encode": "base64_encode_to() with a pre-allocated buffer", "format_bin": "format_bin_to() with a stack buffer", "format_hex": "format_hex_to() with a stack buffer", "format_hex_pretty": "format_hex_pretty_to() with a stack buffer", "format_mac_address_pretty": "format_mac_addr_upper() with a stack buffer", "get_mac_address": "get_mac_address_into_buffer() with a stack buffer", "get_mac_address_pretty": "get_mac_address_pretty_into_buffer() with a stack buffer", "str_lower_case": "manual tolower() with a stack buffer", "str_sanitize": "str_sanitize_to() with a stack buffer", "str_truncate": "removal (function is unused)", "str_until": "manual strchr()/find() with a StringRef or stack buffer", "str_upper_case": "removal (function is unused)", "str_snake_case": "removal (function is unused)", "str_sprintf": "snprintf() with a stack buffer", "str_snprintf": "snprintf() with a stack buffer", "value_accuracy_to_string": "value_accuracy_to_buf() with a stack buffer", } @lint_re_check( # Use negative lookahead to exclude _to/_into_buffer variants # format_hex(?!_) ensures we don't match format_hex_to, format_hex_pretty_to, etc. # get_mac_address(?!_) ensures we don't match get_mac_address_into_buffer, etc. # CPP_RE_EOL captures rest of line so NOLINT comments are detected r"[^\w](" r"base64_encode(?!_)|" r"format_bin(?!_)|" r"format_hex(?!_)|" r"format_hex_pretty(?!_)|" r"format_mac_address_pretty|" r"get_mac_address_pretty(?!_)|" r"get_mac_address(?!_)|" r"str_lower_case|" r"str_sanitize(?!_)|" r"str_truncate|" r"str_until|" r"str_upper_case|" r"str_snake_case|" r"str_sprintf|" r"str_snprintf|" r"value_accuracy_to_string" r")\s*\(" + CPP_RE_EOL, include=cpp_include, exclude=[ # The definitions themselves "esphome/core/alloc_helpers.h", "esphome/core/alloc_helpers.cpp", # Backward compatibility re-exports (remove before 2026.11.0) "esphome/core/helpers.h", "esphome/core/helpers.cpp", # Vendored third-party library "esphome/components/http_request/httplib.h", ], ) def lint_no_heap_allocating_helpers(fname, match): func = match.group(1) replacement = HEAP_ALLOCATING_HELPERS.get(func, "a stack-based alternative") return ( f"{highlight(func + '()')} allocates heap memory. On long-running embedded devices, " f"repeated heap allocations fragment memory over time. Even infrequent allocations " f"become time bombs - the heap eventually cannot satisfy requests even with free " f"memory available.\n" f"Please use {replacement} instead.\n" f"(If strictly necessary, add `// NOLINT` to the end of the line)" ) @lint_re_check( # Match sprintf/vsprintf but not snprintf/vsnprintf # [^\w] ensures we don't match the safe variants r"[^\w](v?sprintf)\s*\(" + CPP_RE_EOL, include=cpp_include, ) def lint_no_sprintf(fname, match): func = match.group(1) safe_func = func.replace("sprintf", "snprintf") return ( f"{highlight(func + '()')} is not allowed in ESPHome. It has no buffer size limit " f"and can cause buffer overflows.\n" f"Please use one of these alternatives:\n" f" - {highlight(safe_func + '(buf, sizeof(buf), fmt, ...)')} for general formatting\n" f" - {highlight('buf_append_printf(buf, sizeof(buf), pos, fmt, ...)')} for " f"offset-based formatting (also stores format strings in flash on ESP8266)\n" f"(If strictly necessary, add `// NOLINT` to the end of the line)" ) @lint_re_check( # Match std::to_string() or unqualified to_string() calls # The esphome namespace has "using std::to_string;" so unqualified calls resolve to std::to_string # Use negative lookbehind for unqualified calls to avoid matching: # - Function definitions: "const char *to_string(" or "std::string to_string(" # - Method definitions: "Class::to_string(" # - Method calls: ".to_string(" or "->to_string(" # - Other identifiers: "_to_string(" # Also explicitly match std::to_string since : is in the lookbehind r"(?:(?:])to_string|std\s*::\s*to_string)\s*\(" + CPP_RE_EOL, include=cpp_include, exclude=[ # Vendored library "esphome/components/http_request/httplib.h", # Deprecated helpers that return std::string "esphome/core/helpers.cpp", "esphome/core/alloc_helpers.cpp", # The using declaration itself "esphome/core/helpers.h", # Test fixtures - not production embedded code "tests/integration/fixtures/*", ], ) def lint_no_std_to_string(fname, match): return ( f"{highlight('std::to_string()')} (including unqualified {highlight('to_string()')}) " f"allocates heap memory. On long-running embedded devices, repeated heap allocations " f"fragment memory over time.\n" f"\n" f"For plain integer formatting, prefer the dedicated helpers in helpers.h over " f"{highlight('snprintf()')} — they avoid pulling in printf formatting code and are " f"smaller and faster:\n" f" int8_t: {highlight('int8_to_str(buf, val)')} (buf >= 5 bytes)\n" f" uint8_t/uint16_t/uint32_t: {highlight('uint32_to_str(buf, val)')} (buf = UINT32_MAX_STR_SIZE; smaller types auto-widen)\n" f"Example: {highlight('char buf[UINT32_MAX_STR_SIZE]; uint32_to_str(buf, value);')}\n" f"For sensor values, use {highlight('value_accuracy_to_buf()')} from helpers.h.\n" f"\n" f"Otherwise use {highlight('snprintf()')} with a stack buffer.\n" f"\n" f"Buffer sizes and format specifiers (sizes include sign and null terminator):\n" f" uint8_t: 4 chars - %u (or PRIu8)\n" f" int8_t: 5 chars - %d (or PRId8)\n" f" uint16_t: 6 chars - %u (or PRIu16)\n" f" int16_t: 7 chars - %d (or PRId16)\n" f" uint32_t: 11 chars - %" + "PRIu32\n" " int32_t: 12 chars - %" + "PRId32\n" " uint64_t: 21 chars - %" + "PRIu64\n" " int64_t: 21 chars - %" + "PRId64\n" f" float/double: 24 chars - %.8g (15 digits + sign + decimal + e+XXX)\n" f" 317 chars - %f (for DBL_MAX: 309 int digits + decimal + 6 frac + sign)\n" f"\n" f'Example: char buf[11]; snprintf(buf, sizeof(buf), "%" PRIu32, value);\n' f"(If strictly necessary, add `{highlight('// NOLINT')}` to the end of the line)" ) @lint_re_check( # Match scanf family functions: scanf, sscanf, fscanf, vscanf, vsscanf, vfscanf # Also match std:: prefixed versions # [^\w] ensures we match function calls, not substrings r"[^\w]((?:std::)?v?[fs]?scanf)\s*\(" + CPP_RE_EOL, include=cpp_include, ) def lint_no_scanf(fname, match): func = match.group(1) return ( f"{highlight(func + '()')} is not allowed in new ESPHome code. The scanf family " f"pulls in ~7KB flash on ESP8266 and ~9KB on ESP32, and ESPHome doesn't otherwise " f"need this code.\n" f"Please use alternatives:\n" f" - {highlight('parse_number(str)')} for parsing integers/floats from strings\n" f" - {highlight('strtol()/strtof()')} for C-style number parsing with error checking\n" f" - {highlight('parse_hex()')} for hex string parsing\n" f" - Manual parsing for simple fixed formats\n" f"(If strictly necessary, add `// NOLINT` to the end of the line)" ) # Base entity platforms - these are linked into most builds and should not # pull in powf/__ieee754_powf (~2.3KB flash). BASE_ENTITY_PLATFORMS = [ "alarm_control_panel", "binary_sensor", "button", "climate", "cover", "datetime", "event", "fan", "light", "lock", "media_player", "number", "select", "sensor", "switch", "text", "text_sensor", "update", "valve", "water_heater", ] # Directories protected from powf: core + all base entity platforms POWF_PROTECTED_DIRS = ["esphome/core"] + [ f"esphome/components/{p}" for p in BASE_ENTITY_PLATFORMS ] @lint_re_check( r"[^\w]powf\s*\(" + CPP_RE_EOL, include=[ f"{d}/*.{ext}" for d in POWF_PROTECTED_DIRS for ext in ["h", "cpp", "tcc"] ], ) def lint_no_powf_in_core(fname, match): return ( f"{highlight('powf()')} pulls in __ieee754_powf (~2.3KB flash) and is not allowed in " f"core or base entity platform code. These files are linked into every build.\n" f"Please use alternatives:\n" f" - {highlight('pow10_int(exp)')} for integer powers of 10 (from helpers.h)\n" f" - Precomputed lookup tables for gamma/non-integer exponents\n" f"(If powf is strictly necessary, add `// NOLINT` to the line)" ) @lint_re_check( r"[^\w]std\s*::\s*bind\s*\(" + CPP_RE_EOL, include=cpp_include, ) def lint_no_std_bind(fname, match): return ( f"{highlight('std::bind()')} is not allowed in new ESPHome code. " f"Lambdas are clearer, produce smaller binaries, and are more likely to fit within " f"the {highlight('std::function')} small-buffer optimization (avoiding heap allocation).\n" f"Please use a lambda instead.\n" f" Before: {highlight('std::bind(&Class::method, this, std::placeholders::_1)')}\n" f" After: {highlight('[this](auto arg) { this->method(arg); }')}\n" f"(If strictly necessary, add `// NOLINT` to the end of the line)" ) LOG_CALL_START_RE = re.compile(r"ESP_LOG\w+\s*\(") # A C++ string or char literal, escapes included, so ; ( ) ? : inside one are never seen. CPP_LITERAL_RE = r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'' LOG_CALL_TOKEN_RE = re.compile(CPP_LITERAL_RE + r"|[()]", re.DOTALL) # Alt 2 matches a ? or : whose next non-space char opens a literal; that literal is a ternary branch. LOG_TERNARY_LITERAL_RE = re.compile(CPP_LITERAL_RE + r'|[?:]\s*(?=")', re.DOTALL) def _iter_log_calls(content: str) -> Iterator[tuple[int, str]]: """Yield (start, text) for every ESP_LOG*(...) call, text running to the matching close paren.""" for head in LOG_CALL_START_RE.finditer(content): depth = 1 for tok in LOG_CALL_TOKEN_RE.finditer(content, head.end()): if tok.group(0) == "(": depth += 1 elif tok.group(0) == ")": depth -= 1 if depth == 0: yield head.start(), content[head.start() : tok.end()] break LOG_BAD_CONTINUATION_RE = re.compile(r'\\n(?:[^ \\"\r\n\t]|"\s*\n\s*"[^ \\])') LOG_PERCENT_S_CONTINUATION_RE = re.compile(r'\\n(?:%s|"\s*\n\s*"%s)') @lint_content_check(include=cpp_include) def lint_log_multiline_continuation(fname, content): errs = [] for log_start, log_text in _iter_log_calls(content): for bad_match in LOG_BAD_CONTINUATION_RE.finditer(log_text): # %s may expand to a whitespace prefix at runtime, skip those if LOG_PERCENT_S_CONTINUATION_RE.match(log_text, bad_match.start()): continue # Calculate line number from position in full content abs_pos = log_start + bad_match.start() lineno = content.count("\n", 0, abs_pos) + 1 col = abs_pos - content.rfind("\n", 0, abs_pos) errs.append( ( lineno, col, ( "Multi-line log message has a continuation line that does " "not start with a space. The log viewer uses leading " "whitespace to detect continuation lines and re-add the " f"log tag prefix (e.g. {highlight('[C][component:042]:')}).\n" "Either start the continuation with a space/indent, or " "split into separate ESP_LOG* calls." ), ) ) return errs def _find_ternary_literals(text: str) -> Iterator[int]: """Yield the start offset of every string literal used as a ternary branch.""" for m in LOG_TERNARY_LITERAL_RE.finditer(text): if text[m.start()] in "?:": yield m.end() @lint_content_check(include=cpp_include, exclude=["tests/integration/fixtures/*"]) def lint_log_no_bare_literal_ternary( fname: Path, content: str ) -> list[tuple[int, int, str]]: errs = [] for log_start, log_text in _iter_log_calls(content): for offset in _find_ternary_literals(log_text): abs_pos = log_start + offset if "NOLINT" in content[abs_pos:].partition("\n")[0]: continue lineno = content.count("\n", 0, abs_pos) + 1 col = abs_pos - content.rfind("\n", 0, abs_pos) errs.append( ( lineno, col, ( "String literal used as a ternary branch in a log call. On ESP8266 the " "log macro moves the format string to flash, but bare literal arguments " "stay in RAM. Wrap each branch in " f"{highlight('LOG_STR_LITERAL(...)')}:\n" f" Before: {highlight('x ? "on" : "off"')}\n" f" After: {highlight('x ? LOG_STR_LITERAL("on") : LOG_STR_LITERAL("off")')}\n" f"(If strictly necessary, add `{highlight('// NOLINT')}` to the end of the line)" ), ) ) return errs @lint_content_find_check( "ESP_LOG", include=["*.h", "*.tcc"], exclude=[ "esphome/components/binary_sensor/binary_sensor.h", "esphome/components/button/button.h", "esphome/components/climate/climate.h", "esphome/components/cover/cover.h", "esphome/components/datetime/date_entity.h", "esphome/components/datetime/time_entity.h", "esphome/components/datetime/datetime_entity.h", "esphome/components/display/display.h", "esphome/components/event/event.h", "esphome/components/fan/fan.h", "esphome/components/i2c/i2c.h", "esphome/components/lock/lock.h", "esphome/components/mqtt/mqtt_component.h", "esphome/components/number/number.h", "esphome/components/one_wire/one_wire.h", "esphome/components/output/binary_output.h", "esphome/components/output/float_output.h", "esphome/components/nextion/nextion_base.h", "esphome/components/select/select.h", "esphome/components/sensor/sensor.h", "esphome/components/spi/spi.h", "esphome/components/stepper/stepper.h", "esphome/components/switch/switch.h", "esphome/components/text/text.h", "esphome/components/text_sensor/text_sensor.h", "esphome/components/valve/valve.h", "esphome/core/component.h", "esphome/core/gpio.h", "esphome/core/log_const_en.h", "esphome/core/log.h", "tests/custom.h", ], ) def lint_log_in_header(fname, line, col, content): return ( "Found reference to ESP_LOG in header file. Using ESP_LOG* in header files " "is currently not possible - please move the definition to a source file (.cpp)" ) PACKAGE_BUS_RE = re.compile( r"^\s+(\w+):\s*!include\s+\S*test_build_components/common/(\w+)/", re.MULTILINE, ) @lint_content_check( include=[ "tests/components/*/test.*.yaml", "tests/components/*/validate.*.yaml", ] ) def lint_test_package_key_matches_bus(fname, content): """Ensure package keys match the common bus directory name. For example, a package using uart_115200 includes must use 'uart_115200' as the key, not 'uart'. """ errs: list[tuple[int, int, str]] = [] for match in PACKAGE_BUS_RE.finditer(content): pkg_key = match.group(1) bus_dir = match.group(2) if pkg_key != bus_dir: lineno = content.count("\n", 0, match.start()) + 1 errs.append( ( lineno, 1, ( f"Package key {highlight(pkg_key)} does not match bus directory " f"{highlight(bus_dir)}. The package key must match the directory " f"name under tests/test_build_components/common/. " f"Change {highlight(pkg_key)} to {highlight(bus_dir)}." ), ) ) return errs @lint_content_find_check( "FINAL_VALIDATE_SCHEMA", include=["esphome/core/*.py"], exclude=["esphome/core/entity_helpers.py"], ) def lint_final_validate_in_core(fname, line, col, content): return ( "FINAL_VALIDATE_SCHEMA in esphome/core/ is not picked up by the component loader. " "Use CoreFinalValidateStep in esphome/config.py instead." ) def main(): colorama.init() parser = argparse.ArgumentParser() parser.add_argument( "files", nargs="*", default=[], help="files to be processed (regex on path)" ) parser.add_argument( "-c", "--changed", action="store_true", help="Only run on changed files" ) parser.add_argument( "--print-slowest", action="store_true", help="Print the slowest checks" ) args = parser.parse_args() EXECUTABLE_BIT.update(git_ls_files()) files = list(EXECUTABLE_BIT.keys()) # Match against re file_name_re = re.compile("|".join(args.files)) files = [p for p in files if file_name_re.search(p)] if args.changed: files = filter_changed(files) files.sort() for fname in files: fname = Path(fname) run_checks(LINT_FILE_CHECKS, fname, fname) if fname.suffix in ignore_types: continue try: with codecs.open(fname, "r", encoding="utf-8") as f_handle: content = f_handle.read() except UnicodeDecodeError: add_errors( fname, "File is not readable as UTF-8. Please set your editor to UTF-8 mode.", ) continue run_checks(LINT_CONTENT_CHECKS, fname, fname, content) run_checks(LINT_POST_CHECKS, Path("POST")) for f, errs in sorted(errors.items()): bold = functools.partial(styled, colorama.Style.BRIGHT) bold_red = functools.partial(styled, (colorama.Style.BRIGHT, colorama.Fore.RED)) err_str = ( f"{bold(f'{f}:{lineno}:{col}:')} {bold_red('lint:')} {msg}\n" for lineno, col, msg in errs ) print_error_for_file(f, "\n".join(err_str)) if args.print_slowest: lint_times = [] for lint in LINT_FILE_CHECKS + LINT_CONTENT_CHECKS + LINT_POST_CHECKS: durations = lint.get("durations", []) lint_times.append((sum(durations), len(durations), lint["func"].__name__)) lint_times.sort(key=lambda x: -x[0]) for i in range(min(len(lint_times), 10)): dur, invocations, name = lint_times[i] print(f" - '{name}' took {dur:.2f}s total (ran on {invocations} files)") print(f"Total time measured: {sum(x[0] for x in lint_times):.2f}s") return len(errors) if __name__ == "__main__": sys.exit(main())