mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-31 15:12:06 +00:00 
			
		
		
		
	Merge branch 'dev' into platformio_cache_tests
This commit is contained in:
		
							
								
								
									
										19
									
								
								.github/workflows/ci-memory-impact-comment.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										19
									
								
								.github/workflows/ci-memory-impact-comment.yml
									
									
									
									
										vendored
									
									
								
							| @@ -28,20 +28,23 @@ jobs: | |||||||
|         run: | |         run: | | ||||||
|           # Get PR details by searching for PR with matching head SHA |           # Get PR details by searching for PR with matching head SHA | ||||||
|           # The workflow_run.pull_requests field is often empty for forks |           # The workflow_run.pull_requests field is often empty for forks | ||||||
|  |           # Use paginate to handle repos with many open PRs | ||||||
|           head_sha="${{ github.event.workflow_run.head_sha }}" |           head_sha="${{ github.event.workflow_run.head_sha }}" | ||||||
|           pr_data=$(gh api "/repos/${{ github.repository }}/commits/$head_sha/pulls" \ |           pr_data=$(gh api --paginate "/repos/${{ github.repository }}/pulls" \ | ||||||
|             --jq '.[0] | {number: .number, base_ref: .base.ref}') |             --jq ".[] | select(.head.sha == \"$head_sha\") | {number: .number, base_ref: .base.ref}" \ | ||||||
|           if [ -z "$pr_data" ] || [ "$pr_data" == "null" ]; then |             | head -n 1) | ||||||
|  |  | ||||||
|  |           if [ -z "$pr_data" ]; then | ||||||
|             echo "No PR found for SHA $head_sha, skipping" |             echo "No PR found for SHA $head_sha, skipping" | ||||||
|             echo "skip=true" >> $GITHUB_OUTPUT |             echo "skip=true" >> "$GITHUB_OUTPUT" | ||||||
|             exit 0 |             exit 0 | ||||||
|           fi |           fi | ||||||
|  |  | ||||||
|           pr_number=$(echo "$pr_data" | jq -r '.number') |           pr_number=$(echo "$pr_data" | jq -r '.number') | ||||||
|           base_ref=$(echo "$pr_data" | jq -r '.base_ref') |           base_ref=$(echo "$pr_data" | jq -r '.base_ref') | ||||||
|  |  | ||||||
|           echo "pr_number=$pr_number" >> $GITHUB_OUTPUT |           echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT" | ||||||
|           echo "base_ref=$base_ref" >> $GITHUB_OUTPUT |           echo "base_ref=$base_ref" >> "$GITHUB_OUTPUT" | ||||||
|           echo "Found PR #$pr_number targeting base branch: $base_ref" |           echo "Found PR #$pr_number targeting base branch: $base_ref" | ||||||
|  |  | ||||||
|       - name: Check out code from base repository |       - name: Check out code from base repository | ||||||
| @@ -87,9 +90,9 @@ jobs: | |||||||
|         if: steps.pr.outputs.skip != 'true' |         if: steps.pr.outputs.skip != 'true' | ||||||
|         run: | |         run: | | ||||||
|           if [ -f ./memory-analysis/memory-analysis-target.json ] && [ -f ./memory-analysis/memory-analysis-pr.json ]; then |           if [ -f ./memory-analysis/memory-analysis-target.json ] && [ -f ./memory-analysis/memory-analysis-pr.json ]; then | ||||||
|             echo "found=true" >> $GITHUB_OUTPUT |             echo "found=true" >> "$GITHUB_OUTPUT" | ||||||
|           else |           else | ||||||
|             echo "found=false" >> $GITHUB_OUTPUT |             echo "found=false" >> "$GITHUB_OUTPUT" | ||||||
|             echo "Memory analysis artifacts not found, skipping comment" |             echo "Memory analysis artifacts not found, skipping comment" | ||||||
|           fi |           fi | ||||||
|  |  | ||||||
|   | |||||||
| @@ -62,6 +62,40 @@ from esphome.util import ( | |||||||
|  |  | ||||||
| _LOGGER = logging.getLogger(__name__) | _LOGGER = logging.getLogger(__name__) | ||||||
|  |  | ||||||
|  | # Special non-component keys that appear in configs | ||||||
|  | _NON_COMPONENT_KEYS = frozenset( | ||||||
|  |     { | ||||||
|  |         CONF_ESPHOME, | ||||||
|  |         "substitutions", | ||||||
|  |         "packages", | ||||||
|  |         "globals", | ||||||
|  |         "external_components", | ||||||
|  |         "<<", | ||||||
|  |     } | ||||||
|  | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def detect_external_components(config: ConfigType) -> set[str]: | ||||||
|  |     """Detect external/custom components in the configuration. | ||||||
|  |  | ||||||
|  |     External components are those that appear in the config but are not | ||||||
|  |     part of ESPHome's built-in components and are not special config keys. | ||||||
|  |  | ||||||
|  |     Args: | ||||||
|  |         config: The ESPHome configuration dictionary | ||||||
|  |  | ||||||
|  |     Returns: | ||||||
|  |         A set of external component names | ||||||
|  |     """ | ||||||
|  |     from esphome.analyze_memory.helpers import get_esphome_components | ||||||
|  |  | ||||||
|  |     builtin_components = get_esphome_components() | ||||||
|  |     return { | ||||||
|  |         key | ||||||
|  |         for key in config | ||||||
|  |         if key not in builtin_components and key not in _NON_COMPONENT_KEYS | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
| class ArgsProtocol(Protocol): | class ArgsProtocol(Protocol): | ||||||
|     device: list[str] | None |     device: list[str] | None | ||||||
| @@ -892,6 +926,54 @@ def command_idedata(args: ArgsProtocol, config: ConfigType) -> int: | |||||||
|     return 0 |     return 0 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: | ||||||
|  |     """Analyze memory usage by component. | ||||||
|  |  | ||||||
|  |     This command compiles the configuration and performs memory analysis. | ||||||
|  |     Compilation is fast if sources haven't changed (just relinking). | ||||||
|  |     """ | ||||||
|  |     from esphome import platformio_api | ||||||
|  |     from esphome.analyze_memory.cli import MemoryAnalyzerCLI | ||||||
|  |  | ||||||
|  |     # Always compile to ensure fresh data (fast if no changes - just relinks) | ||||||
|  |     exit_code = write_cpp(config) | ||||||
|  |     if exit_code != 0: | ||||||
|  |         return exit_code | ||||||
|  |     exit_code = compile_program(args, config) | ||||||
|  |     if exit_code != 0: | ||||||
|  |         return exit_code | ||||||
|  |     _LOGGER.info("Successfully compiled program.") | ||||||
|  |  | ||||||
|  |     # Get idedata for analysis | ||||||
|  |     idedata = platformio_api.get_idedata(config) | ||||||
|  |     if idedata is None: | ||||||
|  |         _LOGGER.error("Failed to get IDE data for memory analysis") | ||||||
|  |         return 1 | ||||||
|  |  | ||||||
|  |     firmware_elf = Path(idedata.firmware_elf_path) | ||||||
|  |  | ||||||
|  |     # Extract external components from config | ||||||
|  |     external_components = detect_external_components(config) | ||||||
|  |     _LOGGER.debug("Detected external components: %s", external_components) | ||||||
|  |  | ||||||
|  |     # Perform memory analysis | ||||||
|  |     _LOGGER.info("Analyzing memory usage...") | ||||||
|  |     analyzer = MemoryAnalyzerCLI( | ||||||
|  |         str(firmware_elf), | ||||||
|  |         idedata.objdump_path, | ||||||
|  |         idedata.readelf_path, | ||||||
|  |         external_components, | ||||||
|  |     ) | ||||||
|  |     analyzer.analyze() | ||||||
|  |  | ||||||
|  |     # Generate and display report | ||||||
|  |     report = analyzer.generate_report() | ||||||
|  |     print() | ||||||
|  |     print(report) | ||||||
|  |  | ||||||
|  |     return 0 | ||||||
|  |  | ||||||
|  |  | ||||||
| def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: | def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: | ||||||
|     new_name = args.name |     new_name = args.name | ||||||
|     for c in new_name: |     for c in new_name: | ||||||
| @@ -1007,6 +1089,7 @@ POST_CONFIG_ACTIONS = { | |||||||
|     "idedata": command_idedata, |     "idedata": command_idedata, | ||||||
|     "rename": command_rename, |     "rename": command_rename, | ||||||
|     "discover": command_discover, |     "discover": command_discover, | ||||||
|  |     "analyze-memory": command_analyze_memory, | ||||||
| } | } | ||||||
|  |  | ||||||
| SIMPLE_CONFIG_ACTIONS = [ | SIMPLE_CONFIG_ACTIONS = [ | ||||||
| @@ -1292,6 +1375,14 @@ def parse_args(argv): | |||||||
|     ) |     ) | ||||||
|     parser_rename.add_argument("name", help="The new name for the device.", type=str) |     parser_rename.add_argument("name", help="The new name for the device.", type=str) | ||||||
|  |  | ||||||
|  |     parser_analyze_memory = subparsers.add_parser( | ||||||
|  |         "analyze-memory", | ||||||
|  |         help="Analyze memory usage by component.", | ||||||
|  |     ) | ||||||
|  |     parser_analyze_memory.add_argument( | ||||||
|  |         "configuration", help="Your YAML configuration file(s).", nargs="+" | ||||||
|  |     ) | ||||||
|  |  | ||||||
|     # Keep backward compatibility with the old command line format of |     # Keep backward compatibility with the old command line format of | ||||||
|     # esphome <config> <command>. |     # esphome <config> <command>. | ||||||
|     # |     # | ||||||
|   | |||||||
| @@ -6,7 +6,7 @@ import esphome.config_validation as cv | |||||||
| from esphome.const import CONF_SUBSTITUTIONS, VALID_SUBSTITUTIONS_CHARACTERS | from esphome.const import CONF_SUBSTITUTIONS, VALID_SUBSTITUTIONS_CHARACTERS | ||||||
| from esphome.yaml_util import ESPHomeDataBase, ESPLiteralValue, make_data_base | from esphome.yaml_util import ESPHomeDataBase, ESPLiteralValue, make_data_base | ||||||
|  |  | ||||||
| from .jinja import Jinja, JinjaStr, TemplateError, TemplateRuntimeError, has_jinja | from .jinja import Jinja, JinjaError, JinjaStr, has_jinja | ||||||
|  |  | ||||||
| CODEOWNERS = ["@esphome/core"] | CODEOWNERS = ["@esphome/core"] | ||||||
| _LOGGER = logging.getLogger(__name__) | _LOGGER = logging.getLogger(__name__) | ||||||
| @@ -57,17 +57,12 @@ def _expand_jinja(value, orig_value, path, jinja, ignore_missing): | |||||||
|                     "->".join(str(x) for x in path), |                     "->".join(str(x) for x in path), | ||||||
|                     err.message, |                     err.message, | ||||||
|                 ) |                 ) | ||||||
|         except ( |         except JinjaError as err: | ||||||
|             TemplateError, |  | ||||||
|             TemplateRuntimeError, |  | ||||||
|             RuntimeError, |  | ||||||
|             ArithmeticError, |  | ||||||
|             AttributeError, |  | ||||||
|             TypeError, |  | ||||||
|         ) as err: |  | ||||||
|             raise cv.Invalid( |             raise cv.Invalid( | ||||||
|                 f"{type(err).__name__} Error evaluating jinja expression '{value}': {str(err)}." |                 f"{err.error_name()} Error evaluating jinja expression '{value}': {str(err.parent())}." | ||||||
|                 f" See {'->'.join(str(x) for x in path)}", |                 f"\nEvaluation stack: (most recent evaluation last)\n{err.stack_trace_str()}" | ||||||
|  |                 f"\nRelevant context:\n{err.context_trace_str()}" | ||||||
|  |                 f"\nSee {'->'.join(str(x) for x in path)}", | ||||||
|                 path, |                 path, | ||||||
|             ) |             ) | ||||||
|     return value |     return value | ||||||
|   | |||||||
| @@ -6,6 +6,8 @@ import re | |||||||
| import jinja2 as jinja | import jinja2 as jinja | ||||||
| from jinja2.sandbox import SandboxedEnvironment | from jinja2.sandbox import SandboxedEnvironment | ||||||
|  |  | ||||||
|  | from esphome.yaml_util import ESPLiteralValue | ||||||
|  |  | ||||||
| TemplateError = jinja.TemplateError | TemplateError = jinja.TemplateError | ||||||
| TemplateSyntaxError = jinja.TemplateSyntaxError | TemplateSyntaxError = jinja.TemplateSyntaxError | ||||||
| TemplateRuntimeError = jinja.TemplateRuntimeError | TemplateRuntimeError = jinja.TemplateRuntimeError | ||||||
| @@ -26,18 +28,20 @@ def has_jinja(st): | |||||||
|     return detect_jinja_re.search(st) is not None |     return detect_jinja_re.search(st) is not None | ||||||
|  |  | ||||||
|  |  | ||||||
| # SAFE_GLOBAL_FUNCTIONS defines a allowlist of built-in functions that are considered safe to expose | # SAFE_GLOBALS defines a allowlist of built-in functions or modules that are considered safe to expose | ||||||
| # in Jinja templates or other sandboxed evaluation contexts. Only functions that do not allow | # in Jinja templates or other sandboxed evaluation contexts. Only functions that do not allow | ||||||
| # arbitrary code execution, file access, or other security risks are included. | # arbitrary code execution, file access, or other security risks are included. | ||||||
| # | # | ||||||
| # The following functions are considered safe: | # The following functions are considered safe: | ||||||
|  | #   - math: The entire math module is injected, allowing access to mathematical functions like sin, cos, sqrt, etc. | ||||||
| #   - ord: Converts a character to its Unicode code point integer. | #   - ord: Converts a character to its Unicode code point integer. | ||||||
| #   - chr: Converts an integer to its corresponding Unicode character. | #   - chr: Converts an integer to its corresponding Unicode character. | ||||||
| #   - len: Returns the length of a sequence or collection. | #   - len: Returns the length of a sequence or collection. | ||||||
| # | # | ||||||
| # These functions were chosen because they are pure, have no side effects, and do not provide access | # These functions were chosen because they are pure, have no side effects, and do not provide access | ||||||
| # to the file system, environment, or other potentially sensitive resources. | # to the file system, environment, or other potentially sensitive resources. | ||||||
| SAFE_GLOBAL_FUNCTIONS = { | SAFE_GLOBALS = { | ||||||
|  |     "math": math,  # Inject entire math module | ||||||
|     "ord": ord, |     "ord": ord, | ||||||
|     "chr": chr, |     "chr": chr, | ||||||
|     "len": len, |     "len": len, | ||||||
| @@ -56,22 +60,62 @@ class JinjaStr(str): | |||||||
|     later in the main substitutions pass. |     later in the main substitutions pass. | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|  |     Undefined = object() | ||||||
|  |  | ||||||
|     def __new__(cls, value: str, upvalues=None): |     def __new__(cls, value: str, upvalues=None): | ||||||
|         obj = super().__new__(cls, value) |         if isinstance(value, JinjaStr): | ||||||
|         obj.upvalues = upvalues or {} |             base = str(value) | ||||||
|  |             merged = {**value.upvalues, **(upvalues or {})} | ||||||
|  |         else: | ||||||
|  |             base = value | ||||||
|  |             merged = dict(upvalues or {}) | ||||||
|  |         obj = super().__new__(cls, base) | ||||||
|  |         obj.upvalues = merged | ||||||
|  |         obj.result = JinjaStr.Undefined | ||||||
|         return obj |         return obj | ||||||
|  |  | ||||||
|     def __init__(self, value: str, upvalues=None): |  | ||||||
|         self.upvalues = upvalues or {} | class JinjaError(Exception): | ||||||
|  |     def __init__(self, context_trace: dict, expr: str): | ||||||
|  |         self.context_trace = context_trace | ||||||
|  |         self.eval_stack = [expr] | ||||||
|  |  | ||||||
|  |     def parent(self): | ||||||
|  |         return self.__context__ | ||||||
|  |  | ||||||
|  |     def error_name(self): | ||||||
|  |         return type(self.parent()).__name__ | ||||||
|  |  | ||||||
|  |     def context_trace_str(self): | ||||||
|  |         return "\n".join( | ||||||
|  |             f"  {k} = {repr(v)} ({type(v).__name__})" | ||||||
|  |             for k, v in self.context_trace.items() | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def stack_trace_str(self): | ||||||
|  |         return "\n".join( | ||||||
|  |             f" {len(self.eval_stack) - i}: {expr}{i == 0 and ' <-- ' + self.error_name() or ''}" | ||||||
|  |             for i, expr in enumerate(self.eval_stack) | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class Jinja: | class TrackerContext(jinja.runtime.Context): | ||||||
|  |     def resolve_or_missing(self, key): | ||||||
|  |         val = super().resolve_or_missing(key) | ||||||
|  |         if isinstance(val, JinjaStr): | ||||||
|  |             self.environment.context_trace[key] = val | ||||||
|  |             val, _ = self.environment.expand(val) | ||||||
|  |         self.environment.context_trace[key] = val | ||||||
|  |         return val | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Jinja(SandboxedEnvironment): | ||||||
|     """ |     """ | ||||||
|     Wraps a Jinja environment |     Wraps a Jinja environment | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     def __init__(self, context_vars): |     def __init__(self, context_vars): | ||||||
|         self.env = SandboxedEnvironment( |         super().__init__( | ||||||
|             trim_blocks=True, |             trim_blocks=True, | ||||||
|             lstrip_blocks=True, |             lstrip_blocks=True, | ||||||
|             block_start_string="<%", |             block_start_string="<%", | ||||||
| @@ -82,13 +126,20 @@ class Jinja: | |||||||
|             variable_end_string="}", |             variable_end_string="}", | ||||||
|             undefined=jinja.StrictUndefined, |             undefined=jinja.StrictUndefined, | ||||||
|         ) |         ) | ||||||
|         self.env.add_extension("jinja2.ext.do") |         self.context_class = TrackerContext | ||||||
|         self.env.globals["math"] = math  # Inject entire math module |         self.add_extension("jinja2.ext.do") | ||||||
|  |         self.context_trace = {} | ||||||
|         self.context_vars = {**context_vars} |         self.context_vars = {**context_vars} | ||||||
|         self.env.globals = { |         for k, v in self.context_vars.items(): | ||||||
|             **self.env.globals, |             if isinstance(v, ESPLiteralValue): | ||||||
|  |                 continue | ||||||
|  |             if isinstance(v, str) and not isinstance(v, JinjaStr) and has_jinja(v): | ||||||
|  |                 self.context_vars[k] = JinjaStr(v, self.context_vars) | ||||||
|  |  | ||||||
|  |         self.globals = { | ||||||
|  |             **self.globals, | ||||||
|             **self.context_vars, |             **self.context_vars, | ||||||
|             **SAFE_GLOBAL_FUNCTIONS, |             **SAFE_GLOBALS, | ||||||
|         } |         } | ||||||
|  |  | ||||||
|     def safe_eval(self, expr): |     def safe_eval(self, expr): | ||||||
| @@ -110,23 +161,43 @@ class Jinja: | |||||||
|         result = None |         result = None | ||||||
|         override_vars = {} |         override_vars = {} | ||||||
|         if isinstance(content_str, JinjaStr): |         if isinstance(content_str, JinjaStr): | ||||||
|  |             if content_str.result is not JinjaStr.Undefined: | ||||||
|  |                 return content_str.result, None | ||||||
|             # If `value` is already a JinjaStr, it means we are trying to evaluate it again |             # If `value` is already a JinjaStr, it means we are trying to evaluate it again | ||||||
|             # in a parent pass. |             # in a parent pass. | ||||||
|             # Hopefully, all required variables are visible now. |             # Hopefully, all required variables are visible now. | ||||||
|             override_vars = content_str.upvalues |             override_vars = content_str.upvalues | ||||||
|  |  | ||||||
|  |         old_trace = self.context_trace | ||||||
|  |         self.context_trace = {} | ||||||
|         try: |         try: | ||||||
|             template = self.env.from_string(content_str) |             template = self.from_string(content_str) | ||||||
|             result = self.safe_eval(template.render(override_vars)) |             result = self.safe_eval(template.render(override_vars)) | ||||||
|             if isinstance(result, Undefined): |             if isinstance(result, Undefined): | ||||||
|                 # This happens when the expression is simply an undefined variable. Jinja does not |                 print("" + result)  # force a UndefinedError exception | ||||||
|                 # raise an exception, instead we get "Undefined". |  | ||||||
|                 # Trigger an UndefinedError exception so we skip to below. |  | ||||||
|                 print("" + result) |  | ||||||
|         except (TemplateSyntaxError, UndefinedError) as err: |         except (TemplateSyntaxError, UndefinedError) as err: | ||||||
|             # `content_str` contains a Jinja expression that refers to a variable that is undefined |             # `content_str` contains a Jinja expression that refers to a variable that is undefined | ||||||
|             # in this scope. Perhaps it refers to a root substitution that is not visible yet. |             # in this scope. Perhaps it refers to a root substitution that is not visible yet. | ||||||
|             # Therefore, return the original `content_str` as a JinjaStr, which contains the variables |             # Therefore, return `content_str` as a JinjaStr, which contains the variables | ||||||
|             # that are actually visible to it at this point to postpone evaluation. |             # that are actually visible to it at this point to postpone evaluation. | ||||||
|             return JinjaStr(content_str, {**self.context_vars, **override_vars}), err |             return JinjaStr(content_str, {**self.context_vars, **override_vars}), err | ||||||
|  |         except JinjaError as err: | ||||||
|  |             err.context_trace = {**self.context_trace, **err.context_trace} | ||||||
|  |             err.eval_stack.append(content_str) | ||||||
|  |             raise err | ||||||
|  |         except ( | ||||||
|  |             TemplateError, | ||||||
|  |             TemplateRuntimeError, | ||||||
|  |             RuntimeError, | ||||||
|  |             ArithmeticError, | ||||||
|  |             AttributeError, | ||||||
|  |             TypeError, | ||||||
|  |         ) as err: | ||||||
|  |             raise JinjaError(self.context_trace, content_str) from err | ||||||
|  |         finally: | ||||||
|  |             self.context_trace = old_trace | ||||||
|  |  | ||||||
|  |         if isinstance(content_str, JinjaStr): | ||||||
|  |             content_str.result = result | ||||||
|  |  | ||||||
|         return result, None |         return result, None | ||||||
|   | |||||||
| @@ -273,6 +273,8 @@ | |||||||
|  |  | ||||||
| #ifdef USE_NRF52 | #ifdef USE_NRF52 | ||||||
| #define USE_NRF52_DFU | #define USE_NRF52_DFU | ||||||
|  | #define USE_SOFTDEVICE_ID 7 | ||||||
|  | #define USE_SOFTDEVICE_VERSION 1 | ||||||
| #endif | #endif | ||||||
|  |  | ||||||
| // Disabled feature flags | // Disabled feature flags | ||||||
|   | |||||||
| @@ -8,6 +8,7 @@ substitutions: | |||||||
|   area: 25 |   area: 25 | ||||||
|   numberOne: 1 |   numberOne: 1 | ||||||
|   var1: 79 |   var1: 79 | ||||||
|  |   double_width: 14 | ||||||
| test_list: | test_list: | ||||||
|   - The area is 56 |   - The area is 56 | ||||||
|   - 56 |   - 56 | ||||||
| @@ -25,3 +26,4 @@ test_list: | |||||||
|   - ord("a") = 97 |   - ord("a") = 97 | ||||||
|   - chr(97) = a |   - chr(97) = a | ||||||
|   - len([1,2,3]) = 3 |   - len([1,2,3]) = 3 | ||||||
|  |   - width = 7, double_width = 14 | ||||||
|   | |||||||
| @@ -8,6 +8,7 @@ substitutions: | |||||||
|   area: 25 |   area: 25 | ||||||
|   numberOne: 1 |   numberOne: 1 | ||||||
|   var1: 79 |   var1: 79 | ||||||
|  |   double_width: ${width * 2} | ||||||
|  |  | ||||||
| test_list: | test_list: | ||||||
|   - "The area is ${width * height}" |   - "The area is ${width * height}" | ||||||
| @@ -23,3 +24,4 @@ test_list: | |||||||
|   - ord("a") = ${ ord("a") } |   - ord("a") = ${ ord("a") } | ||||||
|   - chr(97) = ${ chr(97) } |   - chr(97) = ${ chr(97) } | ||||||
|   - len([1,2,3]) = ${ len([1,2,3]) } |   - len([1,2,3]) = ${ len([1,2,3]) } | ||||||
|  |   - width = ${width}, double_width = ${double_width} | ||||||
|   | |||||||
| @@ -17,10 +17,12 @@ from esphome import platformio_api | |||||||
| from esphome.__main__ import ( | from esphome.__main__ import ( | ||||||
|     Purpose, |     Purpose, | ||||||
|     choose_upload_log_host, |     choose_upload_log_host, | ||||||
|  |     command_analyze_memory, | ||||||
|     command_clean_all, |     command_clean_all, | ||||||
|     command_rename, |     command_rename, | ||||||
|     command_update_all, |     command_update_all, | ||||||
|     command_wizard, |     command_wizard, | ||||||
|  |     detect_external_components, | ||||||
|     get_port_type, |     get_port_type, | ||||||
|     has_ip_address, |     has_ip_address, | ||||||
|     has_mqtt, |     has_mqtt, | ||||||
| @@ -226,13 +228,47 @@ def mock_run_external_process() -> Generator[Mock]: | |||||||
|  |  | ||||||
|  |  | ||||||
| @pytest.fixture | @pytest.fixture | ||||||
| def mock_run_external_command() -> Generator[Mock]: | def mock_run_external_command_main() -> Generator[Mock]: | ||||||
|     """Mock run_external_command for testing.""" |     """Mock run_external_command in __main__ module (different from platformio_api).""" | ||||||
|     with patch("esphome.__main__.run_external_command") as mock: |     with patch("esphome.__main__.run_external_command") as mock: | ||||||
|         mock.return_value = 0  # Default to success |         mock.return_value = 0  # Default to success | ||||||
|         yield mock |         yield mock | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.fixture | ||||||
|  | def mock_write_cpp() -> Generator[Mock]: | ||||||
|  |     """Mock write_cpp for testing.""" | ||||||
|  |     with patch("esphome.__main__.write_cpp") as mock: | ||||||
|  |         mock.return_value = 0  # Default to success | ||||||
|  |         yield mock | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.fixture | ||||||
|  | def mock_compile_program() -> Generator[Mock]: | ||||||
|  |     """Mock compile_program for testing.""" | ||||||
|  |     with patch("esphome.__main__.compile_program") as mock: | ||||||
|  |         mock.return_value = 0  # Default to success | ||||||
|  |         yield mock | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.fixture | ||||||
|  | def mock_get_esphome_components() -> Generator[Mock]: | ||||||
|  |     """Mock get_esphome_components for testing.""" | ||||||
|  |     with patch("esphome.analyze_memory.helpers.get_esphome_components") as mock: | ||||||
|  |         mock.return_value = {"logger", "api", "ota"} | ||||||
|  |         yield mock | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.fixture | ||||||
|  | def mock_memory_analyzer_cli() -> Generator[Mock]: | ||||||
|  |     """Mock MemoryAnalyzerCLI for testing.""" | ||||||
|  |     with patch("esphome.analyze_memory.cli.MemoryAnalyzerCLI") as mock_class: | ||||||
|  |         mock_analyzer = MagicMock() | ||||||
|  |         mock_analyzer.generate_report.return_value = "Mock Memory Report" | ||||||
|  |         mock_class.return_value = mock_analyzer | ||||||
|  |         yield mock_class | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_choose_upload_log_host_with_string_default() -> None: | def test_choose_upload_log_host_with_string_default() -> None: | ||||||
|     """Test with a single string default device.""" |     """Test with a single string default device.""" | ||||||
|     setup_core() |     setup_core() | ||||||
| @@ -839,7 +875,7 @@ def test_upload_program_serial_esp8266_with_file( | |||||||
|  |  | ||||||
| def test_upload_using_esptool_path_conversion( | def test_upload_using_esptool_path_conversion( | ||||||
|     tmp_path: Path, |     tmp_path: Path, | ||||||
|     mock_run_external_command: Mock, |     mock_run_external_command_main: Mock, | ||||||
|     mock_get_idedata: Mock, |     mock_get_idedata: Mock, | ||||||
| ) -> None: | ) -> None: | ||||||
|     """Test upload_using_esptool properly converts Path objects to strings for esptool. |     """Test upload_using_esptool properly converts Path objects to strings for esptool. | ||||||
| @@ -875,10 +911,10 @@ def test_upload_using_esptool_path_conversion( | |||||||
|     assert result == 0 |     assert result == 0 | ||||||
|  |  | ||||||
|     # Verify that run_external_command was called |     # Verify that run_external_command was called | ||||||
|     assert mock_run_external_command.call_count == 1 |     assert mock_run_external_command_main.call_count == 1 | ||||||
|  |  | ||||||
|     # Get the actual call arguments |     # Get the actual call arguments | ||||||
|     call_args = mock_run_external_command.call_args[0] |     call_args = mock_run_external_command_main.call_args[0] | ||||||
|  |  | ||||||
|     # The first argument should be esptool.main function, |     # The first argument should be esptool.main function, | ||||||
|     # followed by the command arguments |     # followed by the command arguments | ||||||
| @@ -917,7 +953,7 @@ def test_upload_using_esptool_path_conversion( | |||||||
|  |  | ||||||
| def test_upload_using_esptool_with_file_path( | def test_upload_using_esptool_with_file_path( | ||||||
|     tmp_path: Path, |     tmp_path: Path, | ||||||
|     mock_run_external_command: Mock, |     mock_run_external_command_main: Mock, | ||||||
| ) -> None: | ) -> None: | ||||||
|     """Test upload_using_esptool with a custom file that's a Path object.""" |     """Test upload_using_esptool with a custom file that's a Path object.""" | ||||||
|     setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test") |     setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test") | ||||||
| @@ -934,10 +970,10 @@ def test_upload_using_esptool_with_file_path( | |||||||
|     assert result == 0 |     assert result == 0 | ||||||
|  |  | ||||||
|     # Verify that run_external_command was called |     # Verify that run_external_command was called | ||||||
|     mock_run_external_command.assert_called_once() |     mock_run_external_command_main.assert_called_once() | ||||||
|  |  | ||||||
|     # Get the actual call arguments |     # Get the actual call arguments | ||||||
|     call_args = mock_run_external_command.call_args[0] |     call_args = mock_run_external_command_main.call_args[0] | ||||||
|     cmd_list = list(call_args[1:])  # Skip the esptool.main function |     cmd_list = list(call_args[1:])  # Skip the esptool.main function | ||||||
|  |  | ||||||
|     # Find the firmware path in the command |     # Find the firmware path in the command | ||||||
| @@ -2273,3 +2309,226 @@ def test_show_logs_api_mqtt_timeout_fallback( | |||||||
|  |  | ||||||
|     # Verify run_logs was called with only the static IP (MQTT failed) |     # Verify run_logs was called with only the static IP (MQTT failed) | ||||||
|     mock_run_logs.assert_called_once_with(CORE.config, ["192.168.1.100"]) |     mock_run_logs.assert_called_once_with(CORE.config, ["192.168.1.100"]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_detect_external_components_no_external( | ||||||
|  |     mock_get_esphome_components: Mock, | ||||||
|  | ) -> None: | ||||||
|  |     """Test detect_external_components with no external components.""" | ||||||
|  |     config = { | ||||||
|  |         CONF_ESPHOME: {CONF_NAME: "test_device"}, | ||||||
|  |         "logger": {}, | ||||||
|  |         "api": {}, | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     result = detect_external_components(config) | ||||||
|  |  | ||||||
|  |     assert result == set() | ||||||
|  |     mock_get_esphome_components.assert_called_once() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_detect_external_components_with_external( | ||||||
|  |     mock_get_esphome_components: Mock, | ||||||
|  | ) -> None: | ||||||
|  |     """Test detect_external_components detects external components.""" | ||||||
|  |     config = { | ||||||
|  |         CONF_ESPHOME: {CONF_NAME: "test_device"}, | ||||||
|  |         "logger": {},  # Built-in | ||||||
|  |         "api": {},  # Built-in | ||||||
|  |         "my_custom_sensor": {},  # External | ||||||
|  |         "another_custom": {},  # External | ||||||
|  |         "external_components": [],  # Special key, not a component | ||||||
|  |         "substitutions": {},  # Special key, not a component | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     result = detect_external_components(config) | ||||||
|  |  | ||||||
|  |     assert result == {"my_custom_sensor", "another_custom"} | ||||||
|  |     mock_get_esphome_components.assert_called_once() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_detect_external_components_filters_special_keys( | ||||||
|  |     mock_get_esphome_components: Mock, | ||||||
|  | ) -> None: | ||||||
|  |     """Test detect_external_components filters out special config keys.""" | ||||||
|  |     config = { | ||||||
|  |         CONF_ESPHOME: {CONF_NAME: "test_device"}, | ||||||
|  |         "substitutions": {"key": "value"}, | ||||||
|  |         "packages": {}, | ||||||
|  |         "globals": [], | ||||||
|  |         "external_components": [], | ||||||
|  |         "<<": {},  # YAML merge key | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     result = detect_external_components(config) | ||||||
|  |  | ||||||
|  |     assert result == set() | ||||||
|  |     mock_get_esphome_components.assert_called_once() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_command_analyze_memory_success( | ||||||
|  |     tmp_path: Path, | ||||||
|  |     capfd: CaptureFixture[str], | ||||||
|  |     mock_write_cpp: Mock, | ||||||
|  |     mock_compile_program: Mock, | ||||||
|  |     mock_get_idedata: Mock, | ||||||
|  |     mock_get_esphome_components: Mock, | ||||||
|  |     mock_memory_analyzer_cli: Mock, | ||||||
|  | ) -> None: | ||||||
|  |     """Test command_analyze_memory with successful compilation and analysis.""" | ||||||
|  |     setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device") | ||||||
|  |  | ||||||
|  |     # Create firmware.elf file | ||||||
|  |     firmware_path = ( | ||||||
|  |         tmp_path / ".esphome" / "build" / "test_device" / ".pioenvs" / "test_device" | ||||||
|  |     ) | ||||||
|  |     firmware_path.mkdir(parents=True, exist_ok=True) | ||||||
|  |     firmware_elf = firmware_path / "firmware.elf" | ||||||
|  |     firmware_elf.write_text("mock elf file") | ||||||
|  |  | ||||||
|  |     # Mock idedata | ||||||
|  |     mock_idedata_obj = MagicMock(spec=platformio_api.IDEData) | ||||||
|  |     mock_idedata_obj.firmware_elf_path = str(firmware_elf) | ||||||
|  |     mock_idedata_obj.objdump_path = "/path/to/objdump" | ||||||
|  |     mock_idedata_obj.readelf_path = "/path/to/readelf" | ||||||
|  |     mock_get_idedata.return_value = mock_idedata_obj | ||||||
|  |  | ||||||
|  |     config = { | ||||||
|  |         CONF_ESPHOME: {CONF_NAME: "test_device"}, | ||||||
|  |         "logger": {}, | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     args = MockArgs() | ||||||
|  |  | ||||||
|  |     result = command_analyze_memory(args, config) | ||||||
|  |  | ||||||
|  |     assert result == 0 | ||||||
|  |  | ||||||
|  |     # Verify compilation was done | ||||||
|  |     mock_write_cpp.assert_called_once_with(config) | ||||||
|  |     mock_compile_program.assert_called_once_with(args, config) | ||||||
|  |  | ||||||
|  |     # Verify analyzer was created with correct parameters | ||||||
|  |     mock_memory_analyzer_cli.assert_called_once_with( | ||||||
|  |         str(firmware_elf), | ||||||
|  |         "/path/to/objdump", | ||||||
|  |         "/path/to/readelf", | ||||||
|  |         set(),  # No external components | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     # Verify analysis was run | ||||||
|  |     mock_analyzer = mock_memory_analyzer_cli.return_value | ||||||
|  |     mock_analyzer.analyze.assert_called_once() | ||||||
|  |     mock_analyzer.generate_report.assert_called_once() | ||||||
|  |  | ||||||
|  |     # Verify report was printed | ||||||
|  |     captured = capfd.readouterr() | ||||||
|  |     assert "Mock Memory Report" in captured.out | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_command_analyze_memory_with_external_components( | ||||||
|  |     tmp_path: Path, | ||||||
|  |     mock_write_cpp: Mock, | ||||||
|  |     mock_compile_program: Mock, | ||||||
|  |     mock_get_idedata: Mock, | ||||||
|  |     mock_get_esphome_components: Mock, | ||||||
|  |     mock_memory_analyzer_cli: Mock, | ||||||
|  | ) -> None: | ||||||
|  |     """Test command_analyze_memory detects external components.""" | ||||||
|  |     setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device") | ||||||
|  |  | ||||||
|  |     # Create firmware.elf file | ||||||
|  |     firmware_path = ( | ||||||
|  |         tmp_path / ".esphome" / "build" / "test_device" / ".pioenvs" / "test_device" | ||||||
|  |     ) | ||||||
|  |     firmware_path.mkdir(parents=True, exist_ok=True) | ||||||
|  |     firmware_elf = firmware_path / "firmware.elf" | ||||||
|  |     firmware_elf.write_text("mock elf file") | ||||||
|  |  | ||||||
|  |     # Mock idedata | ||||||
|  |     mock_idedata_obj = MagicMock(spec=platformio_api.IDEData) | ||||||
|  |     mock_idedata_obj.firmware_elf_path = str(firmware_elf) | ||||||
|  |     mock_idedata_obj.objdump_path = "/path/to/objdump" | ||||||
|  |     mock_idedata_obj.readelf_path = "/path/to/readelf" | ||||||
|  |     mock_get_idedata.return_value = mock_idedata_obj | ||||||
|  |  | ||||||
|  |     config = { | ||||||
|  |         CONF_ESPHOME: {CONF_NAME: "test_device"}, | ||||||
|  |         "logger": {}, | ||||||
|  |         "my_custom_component": {"param": "value"},  # External component | ||||||
|  |         "external_components": [{"source": "github://user/repo"}],  # Not a component | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     args = MockArgs() | ||||||
|  |  | ||||||
|  |     result = command_analyze_memory(args, config) | ||||||
|  |  | ||||||
|  |     assert result == 0 | ||||||
|  |  | ||||||
|  |     # Verify analyzer was created with external components detected | ||||||
|  |     mock_memory_analyzer_cli.assert_called_once_with( | ||||||
|  |         str(firmware_elf), | ||||||
|  |         "/path/to/objdump", | ||||||
|  |         "/path/to/readelf", | ||||||
|  |         {"my_custom_component"},  # External component detected | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_command_analyze_memory_write_cpp_fails( | ||||||
|  |     tmp_path: Path, | ||||||
|  |     mock_write_cpp: Mock, | ||||||
|  | ) -> None: | ||||||
|  |     """Test command_analyze_memory when write_cpp fails.""" | ||||||
|  |     setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device") | ||||||
|  |  | ||||||
|  |     config = {CONF_ESPHOME: {CONF_NAME: "test_device"}} | ||||||
|  |     args = MockArgs() | ||||||
|  |  | ||||||
|  |     mock_write_cpp.return_value = 1  # Failure | ||||||
|  |  | ||||||
|  |     result = command_analyze_memory(args, config) | ||||||
|  |  | ||||||
|  |     assert result == 1 | ||||||
|  |     mock_write_cpp.assert_called_once_with(config) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_command_analyze_memory_compile_fails( | ||||||
|  |     tmp_path: Path, | ||||||
|  |     mock_write_cpp: Mock, | ||||||
|  |     mock_compile_program: Mock, | ||||||
|  | ) -> None: | ||||||
|  |     """Test command_analyze_memory when compilation fails.""" | ||||||
|  |     setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device") | ||||||
|  |  | ||||||
|  |     config = {CONF_ESPHOME: {CONF_NAME: "test_device"}} | ||||||
|  |     args = MockArgs() | ||||||
|  |  | ||||||
|  |     mock_compile_program.return_value = 1  # Compilation failed | ||||||
|  |  | ||||||
|  |     result = command_analyze_memory(args, config) | ||||||
|  |  | ||||||
|  |     assert result == 1 | ||||||
|  |     mock_write_cpp.assert_called_once_with(config) | ||||||
|  |     mock_compile_program.assert_called_once_with(args, config) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_command_analyze_memory_no_idedata( | ||||||
|  |     tmp_path: Path, | ||||||
|  |     caplog: pytest.LogCaptureFixture, | ||||||
|  |     mock_write_cpp: Mock, | ||||||
|  |     mock_compile_program: Mock, | ||||||
|  |     mock_get_idedata: Mock, | ||||||
|  | ) -> None: | ||||||
|  |     """Test command_analyze_memory when idedata cannot be retrieved.""" | ||||||
|  |     setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device") | ||||||
|  |  | ||||||
|  |     config = {CONF_ESPHOME: {CONF_NAME: "test_device"}} | ||||||
|  |     args = MockArgs() | ||||||
|  |  | ||||||
|  |     mock_get_idedata.return_value = None  # Failed to get idedata | ||||||
|  |  | ||||||
|  |     with caplog.at_level(logging.ERROR): | ||||||
|  |         result = command_analyze_memory(args, config) | ||||||
|  |  | ||||||
|  |     assert result == 1 | ||||||
|  |     assert "Failed to get IDE data for memory analysis" in caplog.text | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user