From 509e9ee03a821eb97e83675e80dc292a137636f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 18:17:54 +0100 Subject: [PATCH] Quote number-like device names in generated YAML --- .../helpers/device_yaml/_generation.py | 16 +++++++------- tests/controllers/devices/test_create.py | 17 +++++++++++++++ tests/test_device_yaml.py | 21 +++++++++++++++++++ 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/esphome_device_builder/helpers/device_yaml/_generation.py b/esphome_device_builder/helpers/device_yaml/_generation.py index b5a622755..77d007b0d 100644 --- a/esphome_device_builder/helpers/device_yaml/_generation.py +++ b/esphome_device_builder/helpers/device_yaml/_generation.py @@ -169,7 +169,7 @@ def generate_adoption_yaml( splices *api_encryption_key* in; ``None`` leaves the device's own key alone. """ lines: list[str] = ["substitutions:"] - lines.append(f" name: {name}") + lines.append(f" name: {_safe_yaml_scalar(name)}") if friendly_name: lines.append(f" friendly_name: {_safe_yaml_scalar(friendly_name)}") lines.append("") @@ -216,14 +216,12 @@ def generate_device_yaml( # Board reference comment so users can find the source manifest lines: list[str] = [*_board_header_lines(board)] - # ESPHome core. ``name`` arrives already slug-safe (see - # ``mutations_create``), but ``friendly_name`` is raw user - # input that may contain ``:``, ``#``, leading indicators, or - # other YAML metacharacters — route it through the safe-scalar - # renderer so a label like ``Bedroom #2`` doesn't truncate at - # the comment marker on round trip. + # ESPHome core. Both values go through the safe-scalar renderer: + # an all-digit ``name`` would parse back as an int, and a + # ``friendly_name`` like ``Bedroom #2`` would truncate at the + # comment marker. lines.append("esphome:") - lines.append(f" name: {name}") + lines.append(f" name: {_safe_yaml_scalar(name)}") lines.append(f" friendly_name: {_safe_yaml_scalar(friendly_name)}") lines.append("") @@ -496,7 +494,7 @@ def generate_minimal_stub_yaml( about to edit. """ header = ( - f"esphome:\n name: {name}\n" + f"esphome:\n name: {_safe_yaml_scalar(name)}\n" f" friendly_name: {_safe_yaml_scalar(friendly_name)}\n\n" "# Replace this with your actual platform if you aren't using ESP32.\n" "esp32:\n board: esp32dev\n\n" diff --git a/tests/controllers/devices/test_create.py b/tests/controllers/devices/test_create.py index 7e2dd36d6..3f0c9d4f8 100644 --- a/tests/controllers/devices/test_create.py +++ b/tests/controllers/devices/test_create.py @@ -514,6 +514,23 @@ async def test_create_device_quotes_friendly_name_with_yaml_metachars( assert storage.friendly_name == "Bedroom #2: lamp" +@pytest.mark.usefixtures("stub_create_device_metadata_helpers") +async def test_create_device_quotes_all_digit_name( + tmp_path: Path, make_controller: MakeControllerFactory +) -> None: + """An all-digit name is written quoted so it parses back as a string.""" + ctrl = make_controller(tmp_path, with_state_monitor=True, with_boards=True) + boards = StubBoardLookups(ctrl) + boards.find_by_pio_board_returns(None) + boards.find_by_platform_variant_returns(None) + + result = await ctrl.create_device(name="8266") + + assert result.configuration == "8266.yaml" + content = (tmp_path / "8266.yaml").read_text("utf-8") + assert ' name: "8266"\n' in content + + @pytest.mark.usefixtures("stub_create_device_metadata_helpers") async def test_create_device_rejects_name_with_no_hostname_safe_characters( tmp_path: Path, make_controller: MakeControllerFactory diff --git a/tests/test_device_yaml.py b/tests/test_device_yaml.py index 580856dd9..7d690a155 100644 --- a/tests/test_device_yaml.py +++ b/tests/test_device_yaml.py @@ -146,6 +146,27 @@ def test_generate_minimal_stub_yaml_has_required_blocks() -> None: assert "\ncaptive_portal:\n" in out +@pytest.mark.parametrize("name", ["8266", "0x1f", "017", "esp-8266"]) +def test_generators_emit_name_as_string(name: str) -> None: + """Every generator's ``name`` parses back as a string, even when number-like.""" + stub = generate_minimal_stub_yaml(name, "Lamp", wifi_secrets_available=False) + full = generate_device_yaml( + name, "Lamp", _make_esp32_board(), ssid="", psk="", wifi_secrets_available=False + ) + adopt = generate_adoption_yaml( + name, + "Lamp", + "k", + "github://x/y.yaml@main", + wifi_secrets_available=False, + api_encryption_key=None, + ) + for text, section in ((stub, "esphome"), (full, "esphome"), (adopt, "substitutions")): + parsed = yaml_util.parse_yaml(Path("x.yaml"), text)[section]["name"] + assert isinstance(parsed, str) + assert parsed == name + + def test_generate_minimal_stub_yaml_emits_per_device_encryption_key() -> None: """API encryption key is freshly generated each call.