Files
esphome/script/ci-custom.py
T
J. Nick Koston deb63ea092 [esphome.ota] Compress OTA uploads with deflate on platforms without gzip support
ESP32, RP2040, LibreTiny and host could not receive a compressed image;
only the ESP8266 can, because its bootloader inflates a gzip file at reboot.
This inflates a raw deflate stream on the fly through a 4 KB ring window that
also serves as the output buffer, so the device never holds the whole image.

The CLI offers a new client feature bit; a device whose backend has no gzip
support and has the inflater compiled answers with a new server bit once the
session memory is in hand, then the CLI sends a 4 KB window deflate stream and
the MD5 of the inflated image. On allocation failure the device declines the
bit and the upload stays uncompressed. Old CLIs and old devices never set the
bits, so both directions stay compatible; the ESP8266 keeps its gzip path and
the CLI prefers gzip when a device offers both.

The decoder is uzlib's tinflate.c (zlib licence) trimmed to raw deflate.
2026-09-08 09:59:04 +02:00

1339 lines
46 KiB
Python
Executable File

#!/usr/bin/env python3
import argparse
import codecs
import collections
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",
# Shared C wire header (byte-identical with the co-processor firmware);
# these are protocol constants and constexpr is C++-only.
"esphome/components/esp32_hosted/esp_now_hosted_rpc.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."
)
# An if/else/for/while whose only body is an unbraced ESP_LOG*() call. When the build's compile-time
# log level drops that macro, the body expands to nothing and the compiler warns (-Wempty-body).
# clang-tidy's brace check does not catch these (ShortStatementLines allows short unbraced bodies), so
# this fills that gap. Matched against comment/string-masked content, so commented-out or quoted code
# is ignored. Both spellings are covered: core/log.h defines the uppercase ESP_LOG*() macros and
# the lowercase esph_log_*() ones, and both expand to nothing below their log level.
# 'for' allows ';' inside its parentheses (the classic C-style header); 'if'/'while' do not, so their
# condition cannot run past the statement it guards. The 'for' header permits one level of nested
# parens so it stays bounded to its own statement: without that, it can run past the loop body and
# latch onto a later ')', mis-reporting the line and skipping the '#' preprocessor check below.
ESP_LOG_NEEDS_BRACES_RE = re.compile(
r"(?:\bif\s*\([^{};]*\)|\bwhile\s*\([^{};]*\)|\bfor\s*\((?:[^{}()]|\([^{}()]*\))*\)|\belse\b)"
r"[ \t]*\n?[ \t]*(?:ESP_LOG[A-Z]*|esph_log_[a-z]+)\s*\(",
re.MULTILINE,
)
def _mask_cpp_comments_strings(s):
"""Return s with // and /* */ comments and string/char/raw-string literals blanked to spaces
(length and newlines preserved) so a regex only matches real code. Parentheses in real code are
kept, so callers can still balance them on the masked text."""
out = list(s)
i = 0
n = len(s)
while i < n:
c = s[i]
# Raw string literal: an optional encoding prefix, then R"delim( ... )delim". The body may
# contain quotes, //, /* and unbalanced parens, so it must be consumed as one unit.
if c == "R" and i + 1 < n and s[i + 1] == '"':
j = i + 2
delim = ""
while j < n and s[j] not in "( \t\r\n\\" and len(delim) < 16:
delim += s[j]
j += 1
if j < n and s[j] == "(":
closing = ")" + delim + '"'
end = s.find(closing, j + 1)
end = n if end == -1 else end + len(closing)
for k in range(i, end):
if s[k] != "\n":
out[k] = " "
i = end
continue
i += 1
elif c == "/" and i + 1 < n and s[i + 1] == "/":
while i < n and s[i] != "\n":
out[i] = " "
i += 1
elif c == "/" and i + 1 < n and s[i + 1] == "*":
out[i] = out[i + 1] = " "
i += 2
while i < n and not (s[i] == "*" and i + 1 < n and s[i + 1] == "/"):
if s[i] != "\n":
out[i] = " "
i += 1
if i < n:
out[i] = " "
if i + 1 < n:
out[i + 1] = " "
i += 2
# A "'" after an alphanumeric or '_' is a C++ digit separator (1'000), not a literal opener.
elif c == '"' or (
c == "'" and not (i and (s[i - 1].isalnum() or s[i - 1] == "_"))
):
quote = c
out[i] = " "
i += 1
while i < n:
if s[i] == "\\":
out[i] = " "
if i + 1 < n:
out[i + 1] = " "
i += 2
continue
if s[i] == quote:
out[i] = " "
i += 1
break
if s[i] != "\n":
out[i] = " "
i += 1
else:
i += 1
return "".join(out)
def _log_statement_end(masked, open_paren):
"""Index of the ';' ending the ESP_LOG call whose '(' is at open_paren, or None. Balanced on the
masked text so quotes/comments inside the arguments do not confuse the paren count."""
depth = 0
i = open_paren
n = len(masked)
while i < n:
ch = masked[i]
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if depth == 0:
j = i + 1
while j < n and masked[j] != ";":
if not masked[j].isspace():
return None
j += 1
return j if j < n else None
i += 1
return None
@lint_content_check(include=cpp_include)
def lint_esp_log_needs_braces(fname, content):
# Cheap bailout: no log call means nothing to flag, and skips masking the file entirely.
if "ESP_LOG" not in content and "esph_log_" not in content:
return []
masked = _mask_cpp_comments_strings(content)
errors = []
for match in ESP_LOG_NEEDS_BRACES_RE.finditer(masked):
pos = match.start()
line_start = content.rfind("\n", 0, pos) + 1
# Skip preprocessor conditionals (#if/#else/#elif): not C++ control statements.
if content[line_start:pos].lstrip().startswith("#"):
continue
# A '// NOLINT' may sit at the end of the log line (where the message says to put it) or on the
# control-statement line, so scan the whole statement rather than only up to the ESP_LOG token.
stmt_end = _log_statement_end(masked, match.end() - 1)
nolint_end = (
content.find("\n", stmt_end) if stmt_end is not None else match.end()
)
if nolint_end == -1:
nolint_end = len(content)
if "NOLINT" in content[pos:nolint_end]:
continue
snippet = content[pos : match.end()].replace("\n", " ").strip()
errors.append(
(
content.count("\n", 0, pos) + 1,
pos - line_start + 1,
(
f"{highlight(snippet)} - an if/else/for/while body that is a single log "
"call must be wrapped in braces. When the log level compiles the macro out, the "
"body becomes empty and the compiler warns (-Wempty-body). Add { } around the "
"log call (or a '// NOLINT' comment if this is genuinely intended)."
),
)
)
return errors
@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*<string_view>)" + 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",
# Global extern "C" esp_now_* linker symbols + shared C wire header;
# neither can live in a C++ namespace.
"esphome/components/esp32_hosted/esp_now_hosted.cpp",
"esphome/components/esp32_hosted/esp_now_hosted_rpc.h",
# C header shared with the vendored decoder
"esphome/components/esphome/ota/ota_esphome_inflate.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",
# Shared C wire header; uses a classic #ifndef guard for portability
# across the co-processor firmware repo it stays byte-identical with.
"esphome/components/esp32_hosted/esp_now_hosted_rpc.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"(?:(?<![*&.\w>:])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<T>(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_MULTILINE_RE = re.compile(r"ESP_LOG\w+\s*\(.*?;", re.DOTALL)
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_match in LOG_MULTILINE_RE.finditer(content):
log_text = log_match.group(0)
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_match.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
@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())