From d7d20f4f6bd76a8c7a2575da0c3d99772d41dbd5 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 2 Jun 2026 07:04:35 +1000 Subject: [PATCH] [cli] Allow state reporting control via env (#16746) --- esphome/__main__.py | 50 +++++++++++++++++++++------ tests/unit_tests/test_main.py | 64 +++++++++++++++++++++++++++++++---- 2 files changed, 96 insertions(+), 18 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index cc179ebf98..47dd8d273c 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1351,6 +1351,19 @@ def _validate_bootloader_binary(binary: Path) -> None: ) +def _should_subscribe_states(args: ArgsProtocol) -> bool: + """Determine whether entity state changes should be shown in log output. + + The ``--states``/``--no-states`` command line flags take precedence. When + neither is given, the ``ESPHOME_LOG_STATES`` environment variable controls + the behavior, defaulting to showing states. + """ + states = getattr(args, "states", None) + if states is not None: + return states + return get_bool_env("ESPHOME_LOG_STATES", True) + + def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: try: module = importlib.import_module("esphome.components." + CORE.target_platform) @@ -1380,7 +1393,7 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int return run_logs( config, network_devices, - subscribe_states=not getattr(args, "no_states", False), + subscribe_states=_should_subscribe_states(args), ) if port_type in (PortType.NETWORK, PortType.MQTT) and has_mqtt_logging(): @@ -2019,6 +2032,29 @@ SIMPLE_CONFIG_ACTIONS = [ ] +def _add_states_args(parser: argparse.ArgumentParser) -> None: + """Add mutually exclusive ``--states``/``--no-states`` flags to a parser. + + When neither flag is given, the ``ESPHOME_LOG_STATES`` environment variable + controls whether entity state changes are shown (defaulting to showing them). + """ + states_group = parser.add_mutually_exclusive_group() + states_group.add_argument( + "--states", + dest="states", + action="store_true", + default=None, + help="Show entity state changes in log output (overrides ESPHOME_LOG_STATES).", + ) + states_group.add_argument( + "--no-states", + dest="states", + action="store_false", + default=None, + help="Do not show entity state changes in log output.", + ) + + def parse_args(argv): options_parser = argparse.ArgumentParser(add_help=False) options_parser.add_argument( @@ -2195,11 +2231,7 @@ def parse_args(argv): help="Reset the device before starting serial logs.", default=os.getenv("ESPHOME_SERIAL_LOGGING_RESET"), ) - parser_logs.add_argument( - "--no-states", - action="store_true", - help="Do not show entity state changes in log output.", - ) + _add_states_args(parser_logs) parser_discover = subparsers.add_parser( "discover", @@ -2231,11 +2263,7 @@ def parse_args(argv): "--no-logs", help="Disable starting logs.", action="store_true" ) - parser_run.add_argument( - "--no-states", - action="store_true", - help="Do not show entity state changes in log output.", - ) + _add_states_args(parser_run) parser_run.add_argument( "--reset", diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 26b550669f..8cce60d351 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -1269,6 +1269,7 @@ class MockArgs: ota_platform: str | None = None partition_table: bool = False bootloader: bool = False + states: bool | None = None def test_upload_program_serial_esp32( @@ -2663,7 +2664,7 @@ def test_show_logs_api_no_states( mock_run_logs.return_value = 0 args = MockArgs() - args.no_states = True + args.states = False devices = ["192.168.1.100"] result = show_logs(CORE.config, args, devices) @@ -5989,19 +5990,68 @@ def test_upload_using_esptool_subprocess_passes_crystal_callback( def test_parse_args_run_no_states() -> None: """Test that --no-states is parsed for the run command.""" args = parse_args(["esphome", "run", "--no-states", "device.yaml"]) - assert args.no_states is True + assert args.states is False -def test_parse_args_run_no_states_default() -> None: - """Test that no_states defaults to False for the run command.""" +def test_parse_args_run_states() -> None: + """Test that --states is parsed for the run command.""" + args = parse_args(["esphome", "run", "--states", "device.yaml"]) + assert args.states is True + + +def test_parse_args_run_states_default() -> None: + """Test that states defaults to None (unset) for the run command.""" args = parse_args(["esphome", "run", "device.yaml"]) - assert args.no_states is False + assert args.states is None def test_parse_args_logs_no_states() -> None: """Test that --no-states is parsed for the logs command.""" args = parse_args(["esphome", "logs", "--no-states", "device.yaml"]) - assert args.no_states is True + assert args.states is False + + +def test_parse_args_logs_states() -> None: + """Test that --states is parsed for the logs command.""" + args = parse_args(["esphome", "logs", "--states", "device.yaml"]) + assert args.states is True + + +def test_should_subscribe_states_default() -> None: + """Test that states are shown by default when nothing is set.""" + from esphome.__main__ import _should_subscribe_states + + args = parse_args(["esphome", "logs", "device.yaml"]) + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("ESPHOME_LOG_STATES", None) + assert _should_subscribe_states(args) is True + + +def test_should_subscribe_states_env_suppresses() -> None: + """Test that ESPHOME_LOG_STATES=false suppresses states by default.""" + from esphome.__main__ import _should_subscribe_states + + args = parse_args(["esphome", "logs", "device.yaml"]) + with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "false"}): + assert _should_subscribe_states(args) is False + + +def test_should_subscribe_states_flag_overrides_env() -> None: + """Test that --states overrides ESPHOME_LOG_STATES=false.""" + from esphome.__main__ import _should_subscribe_states + + args = parse_args(["esphome", "logs", "--states", "device.yaml"]) + with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "false"}): + assert _should_subscribe_states(args) is True + + +def test_should_subscribe_states_no_flag_overrides_env() -> None: + """Test that --no-states overrides ESPHOME_LOG_STATES=true.""" + from esphome.__main__ import _should_subscribe_states + + args = parse_args(["esphome", "logs", "--no-states", "device.yaml"]) + with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "true"}): + assert _should_subscribe_states(args) is False @patch("esphome.components.api.client.run_logs") @@ -6020,7 +6070,7 @@ def test_command_run_passes_no_states_to_show_logs( mock_run_logs.return_value = 0 args = MockArgs() - args.no_states = True + args.states = False args.no_logs = False args.device = None