diff --git a/esphome/__main__.py b/esphome/__main__.py index 561391708ee..bca86729171 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1415,6 +1415,15 @@ def command_config(args: ArgsProtocol, config: ConfigType) -> int | None: return 0 +def command_config_hash(args: ArgsProtocol, config: ConfigType) -> int | None: + # generating code might modify config, so it must be done in order to generate + # a hash that will match what was generated when compiling and then running + # on the device + generate_cpp_contents(config) + safe_print(f"0x{CORE.config_hash:08x}") + return 0 + + def command_vscode(args: ArgsProtocol) -> int | None: from esphome import vscode @@ -1950,6 +1959,7 @@ PRE_CONFIG_ACTIONS = { POST_CONFIG_ACTIONS = { "config": command_config, + "config-hash": command_config_hash, "compile": command_compile, "upload": command_upload, "logs": command_logs, @@ -2063,6 +2073,13 @@ def parse_args(argv): "--show-secrets", help="Show secrets in output.", action="store_true" ) + parser_config_hash = subparsers.add_parser( + "config-hash", help="Calculate the hash of the configuration." + ) + parser_config_hash.add_argument( + "configuration", help="Your YAML configuration file(s).", nargs="+" + ) + parser_compile = subparsers.add_parser( "compile", help="Read the configuration and compile a program." ) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 0104854e1f5..6ec0069b3a1 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -29,6 +29,7 @@ from esphome.__main__ import ( command_analyze_memory, command_bundle, command_clean_all, + command_config_hash, command_rename, command_run, command_update_all, @@ -3439,6 +3440,33 @@ def test_command_wizard(tmp_path: Path) -> None: mock_wizard.assert_called_once_with(config_file) +def test_command_config_hash( + tmp_path: Path, + capfd: CaptureFixture[str], +) -> None: + """command_config_hash runs codegen then prints CORE.config_hash. + + The printed format must match `0x{config_hash:08x}` used by + generate_build_info_data_cpp so the value can be compared byte-for-byte + against the ESPHOME_CONFIG_HASH embedded in firmware. + """ + setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}}) + args = MockArgs() + + # generate_cpp_contents requires real components to be loaded; mock it out + # so this test isolates the command's output contract. The command must + # still call it (codegen can mutate config, which affects the hash). + with patch("esphome.__main__.generate_cpp_contents") as mock_generate: + result = command_config_hash(args, CORE.config) + + assert result == 0 + mock_generate.assert_called_once_with(CORE.config) + + output = strip_ansi_codes(capfd.readouterr().out).strip() + assert re.fullmatch(r"0x[0-9a-f]{8}", output) + assert output == f"0x{CORE.config_hash:08x}" + + def test_command_rename_invalid_characters( tmp_path: Path, capfd: CaptureFixture[str] ) -> None: