Skip to content

Commit 9ad1868

Browse files
committed
Add has_publish_command to device metadata
1 parent ffbe134 commit 9ad1868

7 files changed

Lines changed: 81 additions & 31 deletions

File tree

esphome_device_builder/controllers/devices/archive.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ def list_archived_sync(controller: DevicesController) -> list[dict[str, Any]]:
119119
except OSError:
120120
_LOGGER.debug("Failed to read archived YAML %s", path, exc_info=True)
121121
continue
122-
name, friendly_name, comment, _ = parse_esphome_meta(content)
122+
name, friendly_name, comment, _, _ = parse_esphome_meta(content)
123123
if not name or not friendly_name or comment is None:
124124
# Sparse ``esphome:`` block; fall back to StorageJSON so legacy
125125
# archives (and externally-dropped files) still surface a label.

esphome_device_builder/controllers/devices/mutations_simple.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ def _read() -> str | None:
300300
# ``# Board:`` at column 0, treated it as a fresh top-level
301301
# key, dropped the ``esphome:`` context, and silently lost
302302
# ``friendly_name`` on every load.
303-
_, parsed_friendly, _, _ = parse_esphome_meta(new_content)
303+
_, parsed_friendly, _, _, _ = parse_esphome_meta(new_content)
304304
if parsed_friendly != new_friendly_name:
305305
raise CommandError(
306306
ErrorCode.INTERNAL_ERROR,

esphome_device_builder/helpers/device_yaml/_loading.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,9 @@ def load_device_from_storage(
182182
# cover older builds and never-compiled devices.
183183
storage_area = getattr(storage, "area", None) if storage else None
184184
area = _pick_meta(yaml_meta.area, cfg_meta.area, storage_area) or ""
185+
has_publish_command = bool(
186+
_pick_meta(yaml_meta.publish_shell_command, cfg_meta.publish_shell_command, None)
187+
)
185188

186189
yaml_mtime = path.stat().st_mtime if path.exists() else None
187190
bin_mtime: float | None = None
@@ -307,6 +310,7 @@ def load_device_from_storage(
307310
configuration=filename,
308311
comment=comment,
309312
area=area,
313+
has_publish_command=has_publish_command,
310314
board_id=board_id,
311315
target_platform=target_platform,
312316
# StorageJSON only exists after a successful compile, so a

esphome_device_builder/helpers/device_yaml/_parsing.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class EsphomeMeta(NamedTuple):
1616
friendly_name: str | None
1717
comment: str | None
1818
area: str | None
19+
publish_shell_command: str | None
1920

2021

2122
_PLATFORM_KEYS = frozenset({"esp32", "esp8266", "rp2040", "bk72xx", "rtl87xx", "ln882x", "nrf52"})
@@ -327,7 +328,13 @@ def parse_esphome_meta(
327328
for field in _ESPHOME_META_FIELDS:
328329
meta[field] = _resolve_substitutions(meta[field], merged)
329330

330-
return EsphomeMeta(meta["name"], meta["friendly_name"], meta["comment"], meta["area"])
331+
return EsphomeMeta(
332+
meta["name"],
333+
meta["friendly_name"],
334+
meta["comment"],
335+
meta["area"],
336+
meta["publish_shell_command"],
337+
)
331338

332339

333340
def extract_esphome_meta_from_config(
@@ -341,10 +348,10 @@ def extract_esphome_meta_from_config(
341348
*extra_substitutions*; tokens the resolver can't expand are left intact.
342349
"""
343350
if not isinstance(config, dict):
344-
return EsphomeMeta(None, None, None, None)
351+
return EsphomeMeta(None, None, None, None, None)
345352
esphome = config.get(const.CONF_ESPHOME)
346353
if not isinstance(esphome, dict):
347-
return EsphomeMeta(None, None, None, None)
354+
return EsphomeMeta(None, None, None, None, None)
348355
subs = extra_substitutions or {}
349356

350357
def _resolved(value: str | None) -> str | None:
@@ -355,6 +362,8 @@ def _resolved(value: str | None) -> str | None:
355362
_resolved(_str_or_none(esphome.get(const.CONF_FRIENDLY_NAME))),
356363
_resolved(_str_or_none(esphome.get(const.CONF_COMMENT))),
357364
_resolved(_area_name_from_config(esphome.get(const.CONF_AREA))),
365+
# TODO: replace with const.CONF_PUBLISH_SHELL_COMMAND once the esphome change is merged
366+
_resolved(_str_or_none(esphome.get("publish_shell_command"))),
358367
)
359368

360369

@@ -431,7 +440,7 @@ def _parse_inline_value(raw: str) -> str:
431440

432441
_FLOW_AREA_NAME_RE = re.compile(r"""\bname\s*:\s*("[^"]*"|'[^']*'|[^,}]+)""")
433442

434-
_ESPHOME_META_FIELDS = ("name", "friendly_name", "comment", "area")
443+
_ESPHOME_META_FIELDS = ("name", "friendly_name", "comment", "area", "publish_shell_command")
435444

436445

437446
def _parse_flow_area_name(raw: str) -> str | None:

esphome_device_builder/models/devices.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ class Device(DataClassORJSONMixin):
102102
has_pending_changes: bool = True # True until successfully compiled + deployed
103103
update_available: bool = False # True if compiled with older ESPHome version
104104
uses_mqtt: bool = False # True if the YAML declares a top-level mqtt: block
105+
has_publish_command: bool = False # Whether `esphome.publish_shell_command` is set in the YAML
105106
# Native API surface flags — drive the lock-icon indicator in
106107
# the device list. Both fields are computed in
107108
# ``helpers.device_yaml.load_device_from_storage`` as the

tests/controllers/devices/test_edit_friendly_name.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -638,7 +638,7 @@ async def test_edit_friendly_name_raises_internal_error_on_round_trip_mismatch(
638638

639639
monkeypatch.setattr(
640640
"esphome_device_builder.controllers.devices.mutations_simple.parse_esphome_meta",
641-
lambda _content: (None, None, None, None),
641+
lambda _content: (None, None, None, None, None),
642642
)
643643

644644
with pytest.raises(CommandError) as excinfo:

tests/test_device_yaml.py

Lines changed: 60 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ def test_parse_meta_plain_values() -> None:
141141
"My Device",
142142
"A useful little box",
143143
None,
144+
None,
144145
)
145146

146147

@@ -150,7 +151,7 @@ def test_parse_meta_missing_keys_return_none() -> None:
150151
esphome:
151152
name: my-device
152153
"""
153-
assert parse_esphome_meta(yaml_content) == ("my-device", None, None, None)
154+
assert parse_esphome_meta(yaml_content) == ("my-device", None, None, None, None)
154155

155156

156157
def test_parse_meta_resolves_dollar_substitution() -> None:
@@ -162,7 +163,7 @@ def test_parse_meta_resolves_dollar_substitution() -> None:
162163
name: living-room-lamp
163164
friendly_name: $friendly_name
164165
"""
165-
_, friendly_name, _, _ = parse_esphome_meta(yaml_content)
166+
_, friendly_name, _, _, _ = parse_esphome_meta(yaml_content)
166167
assert friendly_name == "Living Room Lamp"
167168

168169

@@ -175,7 +176,7 @@ def test_parse_meta_resolves_brace_substitution() -> None:
175176
name: kitchen
176177
friendly_name: ${friendly_name}
177178
"""
178-
_, friendly_name, _, _ = parse_esphome_meta(yaml_content)
179+
_, friendly_name, _, _, _ = parse_esphome_meta(yaml_content)
179180
assert friendly_name == "Kitchen"
180181

181182

@@ -187,7 +188,7 @@ def test_parse_meta_resolves_substitution_inside_string() -> None:
187188
esphome:
188189
friendly_name: "${room} Lamp"
189190
"""
190-
_, friendly_name, _, _ = parse_esphome_meta(yaml_content)
191+
_, friendly_name, _, _, _ = parse_esphome_meta(yaml_content)
191192
assert friendly_name == "Bedroom Lamp"
192193

193194

@@ -199,7 +200,7 @@ def test_parse_meta_substitutions_block_after_esphome() -> None:
199200
substitutions:
200201
friendly_name: "Office"
201202
"""
202-
_, friendly_name, _, _ = parse_esphome_meta(yaml_content)
203+
_, friendly_name, _, _, _ = parse_esphome_meta(yaml_content)
203204
assert friendly_name == "Office"
204205

205206

@@ -211,7 +212,7 @@ def test_parse_meta_unknown_reference_left_untouched() -> None:
211212
esphome:
212213
friendly_name: $missing
213214
"""
214-
_, friendly_name, _, _ = parse_esphome_meta(yaml_content)
215+
_, friendly_name, _, _, _ = parse_esphome_meta(yaml_content)
215216
assert friendly_name == "$missing"
216217

217218

@@ -224,7 +225,7 @@ def test_parse_meta_resolves_substitution_in_comment() -> None:
224225
name: well
225226
comment: "${area} sensor"
226227
"""
227-
_, _, comment, _ = parse_esphome_meta(yaml_content)
228+
_, _, comment, _, _ = parse_esphome_meta(yaml_content)
228229
assert comment == "Outside sensor"
229230

230231

@@ -243,7 +244,7 @@ def test_parse_meta_resolves_chained_substitutions() -> None:
243244
name: well
244245
comment: ${comment}
245246
"""
246-
_, _, comment, _ = parse_esphome_meta(yaml_content)
247+
_, _, comment, _, _ = parse_esphome_meta(yaml_content)
247248
assert comment == "Outside, Well | Irrigation A"
248249

249250

@@ -259,7 +260,7 @@ def test_parse_meta_circular_substitutions_terminate() -> None:
259260
"""
260261
# Should return without hanging; the exact stuck value is irrelevant
261262
# — what matters is that the resolver terminates safely.
262-
_, friendly_name, _, _ = parse_esphome_meta(yaml_content)
263+
_, friendly_name, _, _, _ = parse_esphome_meta(yaml_content)
263264
assert friendly_name in {"${a}", "${b}"}
264265

265266

@@ -276,7 +277,7 @@ def test_parse_meta_resolves_substitution_from_extras() -> None:
276277
name: living-room-lamp
277278
friendly_name: $room
278279
"""
279-
_, friendly_name, _, _ = parse_esphome_meta(
280+
_, friendly_name, _, _, _ = parse_esphome_meta(
280281
yaml_content,
281282
extra_substitutions={"room": "Living Room"},
282283
)
@@ -295,7 +296,7 @@ def test_parse_meta_file_substitution_overrides_extras() -> None:
295296
esphome:
296297
friendly_name: $room
297298
"""
298-
_, friendly_name, _, _ = parse_esphome_meta(
299+
_, friendly_name, _, _, _ = parse_esphome_meta(
299300
yaml_content,
300301
extra_substitutions={"room": "Living Room"},
301302
)
@@ -314,7 +315,7 @@ def test_parse_meta_extras_fill_gaps_in_file_substitutions() -> None:
314315
esphome:
315316
friendly_name: "${room} ${suffix}"
316317
"""
317-
_, friendly_name, _, _ = parse_esphome_meta(
318+
_, friendly_name, _, _, _ = parse_esphome_meta(
318319
yaml_content,
319320
extra_substitutions={"suffix": "Lamp"},
320321
)
@@ -503,7 +504,7 @@ def test_parse_meta_comment_field() -> None:
503504
name: my-device
504505
comment: Hand-built controller
505506
"""
506-
name, friendly_name, comment, _ = parse_esphome_meta(yaml_content)
507+
name, friendly_name, comment, _, _ = parse_esphome_meta(yaml_content)
507508
assert name == "my-device"
508509
assert friendly_name is None
509510
assert comment == "Hand-built controller"
@@ -523,7 +524,7 @@ def test_parse_meta_skips_blank_and_comment_lines_inside_block() -> None:
523524
# friendly_name: this is just a comment, ignore me
524525
comment: real comment
525526
"""
526-
name, friendly_name, comment, _ = parse_esphome_meta(yaml_content)
527+
name, friendly_name, comment, _, _ = parse_esphome_meta(yaml_content)
527528
assert name == "my-device"
528529
assert friendly_name is None # comment line wasn't picked up
529530
assert comment == "real comment"
@@ -542,7 +543,7 @@ def test_parse_meta_area_field() -> None:
542543
friendly_name: Kitchen Lamp
543544
area: Kitchen
544545
"""
545-
name, friendly_name, _, area = parse_esphome_meta(yaml_content)
546+
name, friendly_name, _, area, _ = parse_esphome_meta(yaml_content)
546547
assert name == "kitchen-lamp"
547548
assert friendly_name == "Kitchen Lamp"
548549
assert area == "Kitchen"
@@ -554,7 +555,7 @@ def test_parse_meta_area_absent_returns_none() -> None:
554555
esphome:
555556
name: my-device
556557
"""
557-
*_, area = parse_esphome_meta(yaml_content)
558+
_, _, _, area, _ = parse_esphome_meta(yaml_content)
558559
assert area is None
559560

560561

@@ -567,7 +568,7 @@ def test_parse_meta_area_resolves_substitution() -> None:
567568
name: lamp
568569
area: ${room}
569570
"""
570-
*_, area = parse_esphome_meta(yaml_content)
571+
_, _, _, area, _ = parse_esphome_meta(yaml_content)
571572
assert area == "Living Room"
572573

573574

@@ -679,7 +680,7 @@ def test_parse_meta_area_resolves_substitution() -> None:
679680
)
680681
def test_parse_meta_area_object_shapes(yaml_content: str, expected_area: str | None) -> None:
681682
"""``esphome.area`` resolves across every shape the schema accepts."""
682-
*_, area = parse_esphome_meta(yaml_content)
683+
_, _, _, area, _ = parse_esphome_meta(yaml_content)
683684
assert area == expected_area
684685

685686

@@ -693,31 +694,66 @@ def test_parse_meta_area_block_form_isolates_nested_keys() -> None:
693694
name: "Kitchen"
694695
comment: "real comment"
695696
"""
696-
name, _, comment, area = parse_esphome_meta(yaml_content)
697+
name, _, comment, area, _ = parse_esphome_meta(yaml_content)
697698
assert name == "lamp"
698699
assert area == "Kitchen"
699700
assert comment == "real comment"
700701

701702

703+
# parse_esphome_meta — publish_shell_command field
704+
# ----------------------------------------------------------------------
705+
706+
707+
def test_parse_meta_publish_shell_command_field() -> None:
708+
"""``esphome.publish_shell_command`` is captured as a plain string."""
709+
yaml_content = """
710+
esphome:
711+
name: my-device
712+
publish_shell_command: python3 publish.py
713+
"""
714+
*_, publish_shell_command = parse_esphome_meta(yaml_content)
715+
assert publish_shell_command == "python3 publish.py"
716+
717+
718+
def test_parse_meta_publish_shell_command_absent_returns_none() -> None:
719+
"""Without a ``publish_shell_command:`` line, the field is ``None``."""
720+
yaml_content = """
721+
esphome:
722+
name: my-device
723+
"""
724+
*_, publish_shell_command = parse_esphome_meta(yaml_content)
725+
assert publish_shell_command is None
726+
727+
728+
def test_extract_meta_from_config_publish_shell_command() -> None:
729+
"""``publish_shell_command`` is read from the resolved config."""
730+
config = {"esphome": {"name": "my-device", "publish_shell_command": "python3 publish.py"}}
731+
*_, publish_shell_command = extract_esphome_meta_from_config(config)
732+
assert publish_shell_command == "python3 publish.py"
733+
734+
735+
# ----------------------------------------------------------------------
736+
737+
702738
def test_extract_meta_from_config_string_area() -> None:
703739
"""String-form ``area`` resolves against the supplied substitutions."""
704740
config = {"esphome": {"name": "lamp", "area": "${room}"}}
705-
name, _, _, area = extract_esphome_meta_from_config(config, {"room": "Kitchen"})
741+
name, _, _, area, _ = extract_esphome_meta_from_config(config, {"room": "Kitchen"})
706742
assert name == "lamp"
707743
assert area == "Kitchen"
708744

709745

710746
def test_extract_meta_from_config_dict_area_uses_name() -> None:
711747
"""The ``{id, name}`` mapping form surfaces its ``name``, resolved."""
712748
config = {"esphome": {"area": {"id": "${aid}", "name": "${room}"}}}
713-
*_, area = extract_esphome_meta_from_config(config, {"aid": "k", "room": "Kitchen"})
749+
_, _, _, area, _ = extract_esphome_meta_from_config(config, {"aid": "k", "room": "Kitchen"})
714750
assert area == "Kitchen"
715751

716752

717753
@pytest.mark.parametrize("config", [None, {}, {"esphome": "not-a-dict"}, "nope"])
718754
def test_extract_meta_from_config_no_esphome_block(config: Any) -> None:
719755
"""Missing / malformed config or ``esphome:`` block yields all-``None``."""
720-
assert extract_esphome_meta_from_config(config) == (None, None, None, None)
756+
assert extract_esphome_meta_from_config(config) == (None, None, None, None, None)
721757

722758

723759
def test_parse_meta_top_level_comment_does_not_close_esphome_block() -> None:
@@ -729,7 +765,7 @@ def test_parse_meta_top_level_comment_does_not_close_esphome_block() -> None:
729765
friendly_name: Kitchen Lamp
730766
area: Kitchen
731767
"""
732-
name, friendly_name, _, area = parse_esphome_meta(yaml_content)
768+
name, friendly_name, _, area, _ = parse_esphome_meta(yaml_content)
733769
assert name == "lamp"
734770
assert friendly_name == "Kitchen Lamp"
735771
assert area == "Kitchen"
@@ -746,7 +782,7 @@ def test_parse_meta_ignores_unknown_esphome_keys_and_their_children() -> None:
746782
- -DSOMETHING
747783
comment: after-block comment
748784
"""
749-
name, _, comment, area = parse_esphome_meta(yaml_content)
785+
name, _, comment, area, _ = parse_esphome_meta(yaml_content)
750786
assert name == "lamp"
751787
assert comment == "after-block comment"
752788
assert area is None

0 commit comments

Comments
 (0)