[core] Add stable esphome.upload_targets module for port classification (#16346)

This commit is contained in:
J. Nick Koston
2026-05-11 08:13:16 -05:00
committed by GitHub
parent b967adeb9d
commit 4d9d6e02e5
4 changed files with 151 additions and 31 deletions
+1 -29
View File
@@ -64,6 +64,7 @@ from esphome.enum import StrEnum
from esphome.helpers import get_bool_env, indent, is_ip_address
from esphome.log import AnsiFore, color, setup_log
from esphome.types import ConfigType
from esphome.upload_targets import PortType, get_port_type
from esphome.util import (
PICOTOOL_PACKAGE,
FlashImage,
@@ -194,14 +195,6 @@ class Purpose(StrEnum):
LOGGING = "logging"
class PortType(StrEnum):
SERIAL = "SERIAL"
NETWORK = "NETWORK"
MQTT = "MQTT"
MQTTIP = "MQTTIP"
BOOTSEL = "BOOTSEL"
# Magic MQTT port types that require special handling
_MQTT_PORT_TYPES = frozenset({PortType.MQTT, PortType.MQTTIP})
@@ -597,27 +590,6 @@ def _resolve_network_devices(
return network_devices
def get_port_type(port: str) -> PortType:
"""Determine the type of port/device identifier.
Returns:
PortType.SERIAL for serial ports (/dev/ttyUSB0, COM1, etc.)
PortType.BOOTSEL for RP2040 BOOTSEL upload via picotool
PortType.MQTT for MQTT logging
PortType.MQTTIP for MQTT IP lookup
PortType.NETWORK for IP addresses, hostnames, or mDNS names
"""
if port == "BOOTSEL":
return PortType.BOOTSEL
if port.startswith("/") or port.startswith("COM"):
return PortType.SERIAL
if port == "MQTT":
return PortType.MQTT
if port == "MQTTIP":
return PortType.MQTTIP
return PortType.NETWORK
def run_miniterm(config: ConfigType, port: str, args) -> int:
from aioesphomeapi import LogParser
import serial
+3 -2
View File
@@ -405,11 +405,12 @@ def _upload_using_platformio(
def upload_program(config: ConfigType, args, host: str) -> bool:
from esphome.__main__ import check_permissions, get_port_type
from esphome.__main__ import check_permissions
from esphome.upload_targets import PortType, get_port_type
mcumgr_device: str | None = None
if get_port_type(host) == "SERIAL":
if get_port_type(host) == PortType.SERIAL:
check_permissions(host)
if zephyr_data()[KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT:
mcumgr_device = host
+66
View File
@@ -0,0 +1,66 @@
"""Stable classification of ``--device`` / port strings.
External tooling (the device-builder dashboard at
esphome/device-builder, and other consumers) needs to decide whether
a user-supplied port string names a local serial device, an OTA
network target, an MQTT magic string, or an RP2040 BOOTSEL upload.
This module is the single stable home for that classification. The
upstream CLI (``esphome.__main__``) re-exports ``PortType`` and
``get_port_type`` from here for its own use; external callers should
import directly from ``esphome.upload_targets`` so the surface stays
stable across releases (``esphome/__main__`` is a CLI entrypoint and
not a stable import path).
Please keep ``PortType`` member names / values and the
``get_port_type`` signature stable — see the docstrings on each for
the contract.
"""
from __future__ import annotations
from esphome.enum import StrEnum
class PortType(StrEnum):
"""Port classification returned by :func:`get_port_type`.
Used by device-builder (esphome/device-builder) and other
external tooling to route a user-supplied ``--device`` value to
the right upload / log path. Member names and string values are
part of the stable surface — adding new members is fine, but
existing names / values must not be renamed or changed.
"""
SERIAL = "SERIAL"
NETWORK = "NETWORK"
MQTT = "MQTT"
MQTTIP = "MQTTIP"
BOOTSEL = "BOOTSEL"
def get_port_type(port: str) -> PortType:
"""Determine the type of port/device identifier.
Used by device-builder (esphome/device-builder)'s dashboard to
decide whether a user-supplied ``--device`` value names a local
serial port (must build / flash locally), an OTA network target
(eligible for remote builds), an MQTT magic string, or an RP2040
BOOTSEL upload. Please keep the signature stable.
Returns:
PortType.SERIAL for serial ports (/dev/ttyUSB0, COM1, etc.)
PortType.BOOTSEL for RP2040 BOOTSEL upload via picotool
PortType.MQTT for MQTT logging
PortType.MQTTIP for MQTT IP lookup
PortType.NETWORK for IP addresses, hostnames, or mDNS names
"""
if port == "BOOTSEL":
return PortType.BOOTSEL
if port.startswith("/") or port.startswith("COM"):
return PortType.SERIAL
if port == "MQTT":
return PortType.MQTT
if port == "MQTTIP":
return PortType.MQTTIP
return PortType.NETWORK
+81
View File
@@ -0,0 +1,81 @@
"""Tests for the stable upload-targets classification helpers."""
import pytest
from esphome.upload_targets import PortType, get_port_type
@pytest.mark.parametrize(
"port",
[
"/dev/ttyUSB0",
"/dev/ttyACM0",
"/dev/cu.usbserial-1410",
"/dev/tty.usbmodem1101",
"COM1",
"COM23",
],
)
def test_get_port_type_serial(port: str) -> None:
"""Local serial devices classify as SERIAL."""
assert get_port_type(port) is PortType.SERIAL
def test_get_port_type_bootsel() -> None:
"""``BOOTSEL`` magic string classifies as BOOTSEL."""
assert get_port_type("BOOTSEL") is PortType.BOOTSEL
def test_get_port_type_mqtt() -> None:
"""``MQTT`` magic string classifies as MQTT."""
assert get_port_type("MQTT") is PortType.MQTT
def test_get_port_type_mqttip() -> None:
"""``MQTTIP`` magic string classifies as MQTTIP."""
assert get_port_type("MQTTIP") is PortType.MQTTIP
@pytest.mark.parametrize(
"port",
[
"192.168.1.10",
"fe80::1",
"device.local",
"my-esp.example.com",
],
)
def test_get_port_type_network(port: str) -> None:
"""IP addresses, mDNS, and hostnames classify as NETWORK."""
assert get_port_type(port) is PortType.NETWORK
def test_port_type_values_are_stable() -> None:
"""Member values are part of the stable surface.
External tooling (device-builder, etc.) may compare against the
string values directly. Renaming or changing these breaks
downstream consumers — guard against accidental edits.
"""
assert PortType.SERIAL.value == "SERIAL"
assert PortType.NETWORK.value == "NETWORK"
assert PortType.MQTT.value == "MQTT"
assert PortType.MQTTIP.value == "MQTTIP"
assert PortType.BOOTSEL.value == "BOOTSEL"
def test_main_re_exports_for_backwards_compat() -> None:
"""``esphome.__main__`` re-exports the stable surface.
The CLI entry point pre-dated the stable module and existing
internal callers (and any third-party code that snuck in via
``__main__``) still import from there. The re-export must
resolve to the same objects.
"""
from esphome.__main__ import (
PortType as MainPortType,
get_port_type as main_get_port_type,
)
assert MainPortType is PortType
assert main_get_port_type is get_port_type