Skip to content

Commit 5c7451b

Browse files
authored
Extract subprocess composition into cli.py (esphome#735)
1 parent eda134c commit 5c7451b

2 files changed

Lines changed: 179 additions & 202 deletions

File tree

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"""Subprocess composition: env, command, cache args, chip verification."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import logging
7+
import os
8+
import sys
9+
from pathlib import Path
10+
from typing import TYPE_CHECKING
11+
12+
from esphome.core import CORE
13+
from esphome.storage_json import StorageJSON
14+
15+
from ...helpers.remote_build_layout import parse_from_configuration as parse_remote_build_path
16+
from ...helpers.storage_path import resolve_storage_path
17+
from ...models import FirmwareJob, JobType
18+
from .constants import _OTA_ADDRESS_CACHE_JOB_TYPES, ESPHOME_SUBPROCESS_ENV
19+
20+
if TYPE_CHECKING:
21+
from .controller import FirmwareController
22+
23+
_LOGGER = logging.getLogger(__name__)
24+
25+
26+
def compose_subprocess_env(job: FirmwareJob) -> dict[str, str]:
27+
"""
28+
Return the env dict for *job*'s ``esphome`` subprocess.
29+
30+
Layers ``os.environ`` + :data:`ESPHOME_SUBPROCESS_ENV`, then
31+
for receiver-side remote-build jobs pins ``ESPHOME_DATA_DIR``
32+
to the per-build subtree under ``CORE.data_dir`` so per-config
33+
artefacts land in one ``(dashboard_id, device)``-keyed dir.
34+
"""
35+
env = {**os.environ, **ESPHOME_SUBPROCESS_ENV}
36+
remote_build_path = parse_remote_build_path(job.configuration)
37+
if remote_build_path is not None:
38+
env["ESPHOME_DATA_DIR"] = str(remote_build_path.data_dir(Path(CORE.data_dir)))
39+
return env
40+
41+
42+
def build_command(
43+
esphome_cmd: list[str],
44+
job_type: JobType,
45+
config_path: str,
46+
port: str,
47+
cache_args: list[str] | None = None,
48+
new_name: str = "",
49+
) -> list[str]:
50+
"""Build the esphome CLI command for a given job type."""
51+
cmd_map = {
52+
JobType.COMPILE: "compile",
53+
JobType.UPLOAD: "upload",
54+
JobType.INSTALL: "run",
55+
JobType.CLEAN: "clean",
56+
JobType.RENAME: "rename",
57+
# ``clean-all`` takes the config *directory* as its positional,
58+
# not a YAML file. ``reset_build_env`` queues with
59+
# ``configuration=""`` so ``rel_path("")`` resolves back to
60+
# the config_dir at the call site — same shape as the legacy
61+
# dashboard's ``EsphomeCleanAllHandler``.
62+
JobType.RESET_BUILD_ENV: "clean-all",
63+
}
64+
# cache_args go before the subcommand — esphome's argparse
65+
# parses them on the top-level parser, not the per-subcommand
66+
# one. ``--dashboard`` flips ESPHome's log formatter into
67+
# "escape ANSI as literal text" mode, which survives the
68+
# colorama strip when stdout is piped to us; the frontend's
69+
# ansi-log component then un-escapes and renders the colours.
70+
cmd = [
71+
*esphome_cmd,
72+
"--dashboard",
73+
*(cache_args or []),
74+
cmd_map[job_type],
75+
config_path,
76+
]
77+
if job_type == JobType.INSTALL:
78+
# Without --no-logs the CLI tails logs forever after the
79+
# upload, never returning — the job would never complete.
80+
cmd.append("--no-logs")
81+
if job_type in (JobType.UPLOAD, JobType.INSTALL) and port:
82+
cmd.extend(["--device", port])
83+
if job_type == JobType.RENAME:
84+
# ``esphome rename`` takes the new name as a positional
85+
# arg. The CLI handles the inner compile + install + old
86+
# YAML cleanup itself; we let the queue runner stream its
87+
# output the same way it does for any other build.
88+
cmd.append(new_name)
89+
return cmd
90+
91+
92+
def build_cache_args(controller: FirmwareController, job: FirmwareJob) -> list[str]:
93+
"""Return ``--mdns/--dns-address-cache`` args for *job*, or empty."""
94+
if job.job_type not in _OTA_ADDRESS_CACHE_JOB_TYPES or controller._db.devices is None:
95+
return []
96+
# ``rename``'s ``port`` is the post-rename re-install target;
97+
# the inner ``esphome run`` against the *old* address is
98+
# always OTA, so skip the gate with ``None``.
99+
port: str | None = None if job.job_type == JobType.RENAME else job.port
100+
return controller._db.devices.get_ota_address_cache_args(job.configuration, port)
101+
102+
103+
async def verify_chip(controller: FirmwareController, job: FirmwareJob) -> None:
104+
"""
105+
Run ``esptool chip-id`` against *job*'s port and raise on mismatch.
106+
107+
Skipped for OTA / non-``/dev`` ports and when no
108+
``StorageJSON.target_platform`` is recorded yet. Reads
109+
``StorageJSON.target_platform`` (upstream-canonical chip
110+
variant — ``ESP32S3``, ``ESP32C3``, etc.) rather than
111+
``Device.target_platform`` (lowercase platform *key* like
112+
``esp32``, which would false-positive on a chip-vs-variant
113+
mismatch).
114+
"""
115+
if not job.port or job.port.upper() == "OTA" or not job.port.startswith("/dev"):
116+
return # only check serial ports
117+
118+
loop = asyncio.get_running_loop()
119+
storage = await loop.run_in_executor(
120+
None, lambda: StorageJSON.load(resolve_storage_path(job.configuration))
121+
)
122+
if storage is None or not storage.target_platform:
123+
return # never compiled or no platform recorded — nothing to verify
124+
125+
expected_platform = storage.target_platform.lower()
126+
127+
async with controller._tracked_subprocess(
128+
sys.executable,
129+
"-m",
130+
"esptool",
131+
"--port",
132+
job.port,
133+
"chip-id",
134+
stdout=asyncio.subprocess.PIPE,
135+
stderr=asyncio.subprocess.STDOUT,
136+
start_new_session=True,
137+
) as proc:
138+
assert proc.stdout is not None # type narrowing
139+
output = (await proc.stdout.read()).decode("utf-8", errors="replace")
140+
await proc.wait()
141+
142+
# Honour an early cancel that arrived during chip detection
143+
# (the main install hasn't spawned yet, so the post-wait check
144+
# in ``_execute_job`` would otherwise let the full install run
145+
# before reporting CANCELLED). Reusing the ``ValueError`` shape
146+
# keeps the error path identical to a chip mismatch.
147+
controller._raise_if_cancelled(job, "chip verification")
148+
149+
# Parse "Detecting chip type... ESP32-C3"
150+
detected = ""
151+
for line in output.splitlines():
152+
if "Detecting chip type" in line:
153+
detected = line.split("...")[-1].strip().lower().replace("-", "")
154+
break
155+
156+
if not detected:
157+
_LOGGER.warning("Could not detect chip type on %s", job.port)
158+
return
159+
160+
# Normalise: "esp32c3" matches "esp32c3", "esp32" matches "esp32".
161+
# The target_platform from StorageJSON might be "ESP32S3" (uppercase).
162+
expected_normalized = expected_platform.lower().replace("-", "").replace("_", "")
163+
detected_normalized = detected.replace(" ", "")
164+
165+
if expected_normalized != detected_normalized:
166+
msg = (
167+
f"Chip mismatch: config expects {expected_platform} "
168+
f"but {job.port} has {detected}. Wrong board selected?"
169+
)
170+
raise ValueError(msg)
171+
172+
_LOGGER.debug("Chip verified: %s on %s", detected, job.port)

0 commit comments

Comments
 (0)