Files
esphome/esphome/wizard.py
dependabot[bot] 4d05cd3059 Bump ruff from 0.14.14 to 0.15.0 (#13752)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-02-04 09:24:05 +00:00

584 lines
18 KiB
Python

import base64
from pathlib import Path
import random
import secrets
import string
from typing import Literal, NotRequired, TypedDict, Unpack
import unicodedata
import voluptuous as vol
import esphome.config_validation as cv
from esphome.const import ALLOWED_NAME_CHARS, ENV_QUICKWIZARD
from esphome.core import CORE
from esphome.helpers import get_bool_env, write_file
from esphome.log import AnsiFore, color
from esphome.storage_json import StorageJSON, ext_storage_path
from esphome.util import safe_input, safe_print
CORE_BIG = r""" _____ ____ _____ ______
/ ____/ __ \| __ \| ____|
| | | | | | |__) | |__
| | | | | | _ /| __|
| |___| |__| | | \ \| |____
\_____\____/|_| \_\______|
"""
ESP_BIG = r""" ______ _____ _____
| ____|/ ____| __ \\
| |__ | (___ | |__) |
| __| \___ \| ___/
| |____ ____) | |
|______|_____/|_|
"""
WIFI_BIG = r""" __ ___ ______ _
\ \ / (_) ____(_)
\ \ /\ / / _| |__ _
\ \/ \/ / | | __| | |
\ /\ / | | | | |
\/ \/ |_|_| |_|
"""
OTA_BIG = r""" ____ _______
/ __ \__ __|/\\
| | | | | | / \\
| | | | | | / /\ \\
| |__| | | |/ ____ \\
\____/ |_/_/ \_\\
"""
BASE_CONFIG = """esphome:
name: {name}
"""
BASE_CONFIG_FRIENDLY = """esphome:
name: {name}
friendly_name: {friendly_name}
"""
LOGGER_CONFIG = """
# Enable logging
logger:
"""
API_CONFIG = """
# Enable Home Assistant API
api:
"""
ESP8266_CONFIG = """
esp8266:
board: {board}
"""
ESP32_CONFIG = """
esp32:
board: {board}
framework:
type: esp-idf
"""
RP2040_CONFIG = """
rp2040:
board: {board}
"""
BK72XX_CONFIG = """
bk72xx:
board: {board}
"""
LN882X_CONFIG = """
ln882x:
board: {board}
"""
RTL87XX_CONFIG = """
rtl87xx:
board: {board}
"""
HARDWARE_BASE_CONFIGS = {
"ESP8266": ESP8266_CONFIG,
"ESP32": ESP32_CONFIG,
"RP2040": RP2040_CONFIG,
"BK72XX": BK72XX_CONFIG,
"LN882X": LN882X_CONFIG,
"RTL87XX": RTL87XX_CONFIG,
}
def sanitize_double_quotes(value: str) -> str:
return value.replace("\\", "\\\\").replace('"', '\\"')
class WizardFileKwargs(TypedDict):
"""Keyword arguments for wizard_file function."""
name: str
platform: Literal["ESP8266", "ESP32", "RP2040", "BK72XX", "LN882X", "RTL87XX"]
board: str
ssid: NotRequired[str]
psk: NotRequired[str]
ota_password: NotRequired[str]
api_encryption_key: NotRequired[str]
friendly_name: NotRequired[str]
def wizard_file(**kwargs: Unpack[WizardFileKwargs]) -> str:
letters = string.ascii_letters + string.digits
ap_name_base = kwargs["name"].replace("_", " ").title()
ap_name = f"{ap_name_base} Fallback Hotspot"
if len(ap_name) > 32:
ap_name = ap_name_base
kwargs["fallback_name"] = ap_name
kwargs["fallback_psk"] = "".join(random.choice(letters) for _ in range(12))
base = BASE_CONFIG_FRIENDLY if kwargs.get("friendly_name") else BASE_CONFIG
config = base.format(**kwargs)
config += HARDWARE_BASE_CONFIGS[kwargs["platform"]].format(**kwargs)
config += LOGGER_CONFIG
if kwargs["board"] == "rpipico":
return config
config += API_CONFIG
# Configure API encryption
if "api_encryption_key" in kwargs:
config += f' encryption:\n key: "{kwargs["api_encryption_key"]}"\n'
# Configure OTA
config += "\nota:\n"
config += " - platform: esphome\n"
if "ota_password" in kwargs:
config += f' password: "{kwargs["ota_password"]}"'
# Configuring wifi
config += "\n\nwifi:\n"
if "ssid" in kwargs:
if kwargs["ssid"].startswith("!secret"):
template = " ssid: {ssid}\n password: {psk}\n"
else:
template = """ ssid: "{ssid}"\n password: "{psk}"\n"""
config += template.format(**kwargs)
else:
config += """ # ssid: "My SSID"
# password: "mypassword"
networks:
"""
# pylint: disable=consider-using-f-string
if kwargs["platform"] in ["ESP8266", "ESP32", "BK72XX", "LN882X", "RTL87XX"]:
config += """
# Enable fallback hotspot (captive portal) in case wifi connection fails
ap:
ssid: "{fallback_name}"
password: "{fallback_psk}"
captive_portal:
""".format(**kwargs)
else:
config += """
# Enable fallback hotspot in case wifi connection fails
ap:
ssid: "{fallback_name}"
password: "{fallback_psk}"
""".format(**kwargs)
return config
class WizardWriteKwargs(TypedDict):
"""Keyword arguments for wizard_write function."""
name: str
type: Literal["basic", "empty", "upload"]
# Required for "basic" type
board: NotRequired[str]
platform: NotRequired[str]
ssid: NotRequired[str]
psk: NotRequired[str]
ota_password: NotRequired[str]
api_encryption_key: NotRequired[str]
friendly_name: NotRequired[str]
# Required for "upload" type
file_text: NotRequired[str]
def wizard_write(path: Path, **kwargs: Unpack[WizardWriteKwargs]) -> bool:
from esphome.components.bk72xx import boards as bk72xx_boards
from esphome.components.esp32 import boards as esp32_boards
from esphome.components.esp8266 import boards as esp8266_boards
from esphome.components.ln882x import boards as ln882x_boards
from esphome.components.rp2040 import boards as rp2040_boards
from esphome.components.rtl87xx import boards as rtl87xx_boards
name = kwargs["name"]
if kwargs["type"] == "empty":
file_text = ""
# Will be updated later after editing the file
hardware = "UNKNOWN"
elif kwargs["type"] == "upload":
file_text = kwargs["file_text"]
hardware = "UNKNOWN"
else: # "basic"
board = kwargs["board"]
for key in ("ssid", "psk", "ota_password"):
if key in kwargs:
kwargs[key] = sanitize_double_quotes(kwargs[key])
if "platform" not in kwargs:
if board in esp8266_boards.BOARDS:
platform = "ESP8266"
elif board in esp32_boards.BOARDS:
platform = "ESP32"
elif board in rp2040_boards.BOARDS:
platform = "RP2040"
elif board in bk72xx_boards.BOARDS:
platform = "BK72XX"
elif board in ln882x_boards.BOARDS:
platform = "LN882X"
elif board in rtl87xx_boards.BOARDS:
platform = "RTL87XX"
else:
safe_print(color(AnsiFore.RED, f'The board "{board}" is unknown.'))
return False
kwargs["platform"] = platform
hardware = kwargs["platform"]
file_text = wizard_file(**kwargs)
# Check if file already exists to prevent overwriting
if path.exists() and path.is_file():
safe_print(color(AnsiFore.RED, f'The file "{path}" already exists.'))
return False
write_file(path, file_text)
storage = StorageJSON.from_wizard(name, name, f"{name}.local", hardware)
storage_path = ext_storage_path(path.name)
storage.save(storage_path)
return True
if get_bool_env(ENV_QUICKWIZARD):
def sleep(time: float) -> None:
pass
else:
from time import sleep
def safe_print_step(step: int, big: str) -> None:
safe_print()
safe_print()
safe_print(f"============= STEP {step} =============")
safe_print(big)
safe_print("===================================")
sleep(0.25)
def default_input(text: str, default: str) -> str:
safe_print()
safe_print(f"Press ENTER for default ({default})")
return safe_input(text.format(default)) or default
# From https://stackoverflow.com/a/518232/8924614
def strip_accents(value: str) -> str:
return "".join(
c
for c in unicodedata.normalize("NFD", str(value))
if unicodedata.category(c) != "Mn"
)
def wizard(path: Path) -> int:
from esphome.components.bk72xx import boards as bk72xx_boards
from esphome.components.esp32 import boards as esp32_boards
from esphome.components.esp8266 import boards as esp8266_boards
from esphome.components.ln882x import boards as ln882x_boards
from esphome.components.rp2040 import boards as rp2040_boards
from esphome.components.rtl87xx import boards as rtl87xx_boards
if path.suffix not in (".yaml", ".yml"):
safe_print(
f"Please make your configuration file {color(AnsiFore.CYAN, str(path))} have the extension .yaml or .yml"
)
return 1
if path.exists():
safe_print(
f"Uh oh, it seems like {color(AnsiFore.CYAN, str(path))} already exists, please delete that file first or chose another configuration file."
)
return 2
CORE.config_path = path
safe_print("Hi there!")
sleep(1.5)
safe_print("I'm the wizard of ESPHome :)")
sleep(1.25)
safe_print("And I'm here to help you get started with ESPHome.")
sleep(2.0)
safe_print(
"In 4 steps I'm going to guide you through creating a basic "
"configuration file for your custom firmware. Yay!"
)
sleep(3.0)
safe_print()
safe_print_step(1, CORE_BIG)
safe_print(
f"First up, please choose a {color(AnsiFore.GREEN, 'name')} for your node."
)
safe_print(
"It should be a unique name that can be used to identify the device later."
)
sleep(1)
safe_print(
f"For example, I like calling the node in my living room {color(AnsiFore.BOLD_WHITE, 'livingroom')}."
)
safe_print()
sleep(1)
name = safe_input(color(AnsiFore.BOLD_WHITE, "(name): "))
while True:
try:
name = cv.valid_name(name)
break
except vol.Invalid:
safe_print(
color(
AnsiFore.RED,
f'Oh noes, "{name}" isn\'t a valid name. Names can only '
f"include numbers, lower-case letters and hyphens. ",
)
)
name = strip_accents(name).lower().replace(" ", "-")
name = strip_accents(name).lower().replace("_", "-")
name = "".join(c for c in name if c in ALLOWED_NAME_CHARS)
safe_print(
f'Shall I use "{color(AnsiFore.CYAN, name)}" as the name instead?'
)
sleep(0.5)
name = default_input("(name [{}]): ", name)
safe_print(f'Great! Your node is now called "{color(AnsiFore.CYAN, name)}".')
sleep(1)
safe_print_step(2, ESP_BIG)
safe_print(
"Now I'd like to know what microcontroller you're using so that I can compile "
"firmwares for it."
)
wizard_platforms = ["ESP32", "ESP8266", "BK72XX", "LN882X", "RTL87XX", "RP2040"]
safe_print(
"Please choose one of the supported microcontrollers "
"(Use ESP8266 for Sonoff devices)."
)
while True:
sleep(0.5)
safe_print()
platform = safe_input(
color(AnsiFore.BOLD_WHITE, f"({'/'.join(wizard_platforms)}): ")
)
try:
platform = vol.All(vol.Upper, vol.Any(*wizard_platforms))(platform.upper())
break
except vol.Invalid:
safe_print(
f'Unfortunately, I can\'t find an espressif microcontroller called "{platform}". Please try again.'
)
safe_print(
f"Thanks! You've chosen {color(AnsiFore.CYAN, platform)} as your platform."
)
safe_print()
sleep(1)
if platform == "ESP32":
board_link = (
"https://docs.platformio.org/en/latest/platforms/espressif32.html#boards"
)
elif platform == "ESP8266":
board_link = (
"https://docs.platformio.org/en/latest/platforms/espressif8266.html#boards"
)
elif platform == "RP2040":
board_link = "https://www.raspberrypi.com/documentation/microcontrollers/silicon.html#rp2040"
elif platform in ["BK72XX", "LN882X", "RTL87XX"]:
board_link = "https://docs.libretiny.eu/docs/status/supported/"
else:
raise NotImplementedError("Unknown platform!")
safe_print(
f"Next, I need to know what {color(AnsiFore.GREEN, 'board')} you're using."
)
sleep(0.5)
safe_print(f"Please go to {color(AnsiFore.GREEN, board_link)} and choose a board.")
if platform == "ESP32":
safe_print(f"(Type {color(AnsiFore.GREEN, 'esp01_1m')} for Sonoff devices)")
safe_print()
# Don't sleep because user needs to copy link
if platform == "ESP32":
safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "nodemcu-32s")}".')
boards_list = esp32_boards.BOARDS.items()
elif platform == "ESP8266":
safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "nodemcuv2")}".')
boards_list = esp8266_boards.BOARDS.items()
elif platform == "BK72XX":
safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "cb2s")}".')
boards_list = bk72xx_boards.BOARDS.items()
elif platform == "LN882X":
safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "wl2s")}".')
boards_list = ln882x_boards.BOARDS.items()
elif platform == "RTL87XX":
safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "wr3")}".')
boards_list = rtl87xx_boards.BOARDS.items()
elif platform == "RP2040":
safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "rpipicow")}".')
boards_list = rp2040_boards.BOARDS.items()
else:
raise NotImplementedError("Unknown platform!")
boards = []
safe_print("Options:")
for board_id, board_data in boards_list:
safe_print(f" - {board_id} - {board_data['name']}")
boards.append(board_id.lower())
while True:
board = safe_input(color(AnsiFore.BOLD_WHITE, "(board): "))
try:
board = vol.All(vol.Lower, vol.Any(*boards))(board)
break
except vol.Invalid:
safe_print(
color(
AnsiFore.RED, f'Sorry, I don\'t think the board "{board}" exists.'
)
)
safe_print()
sleep(0.25)
safe_print()
safe_print(f"Way to go! You've chosen {color(AnsiFore.CYAN, board)} as your board.")
safe_print()
sleep(1)
# Do not create wifi if the board does not support it
if board != "rpipico":
safe_print_step(3, WIFI_BIG)
safe_print("In this step, I'm going to create the configuration for WiFi.")
safe_print()
sleep(1)
safe_print(
f"First, what's the {color(AnsiFore.GREEN, 'SSID')} (the name) of the WiFi network {name} should connect to?"
)
sleep(1.5)
safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "Abraham Linksys")}".')
while True:
ssid = safe_input(color(AnsiFore.BOLD_WHITE, "(ssid): "))
try:
ssid = cv.ssid(ssid)
break
except vol.Invalid:
safe_print(
color(
AnsiFore.RED,
f'Unfortunately, "{ssid}" doesn\'t seem to be a valid SSID. Please try again.',
)
)
safe_print()
sleep(1)
safe_print(
f'Thank you very much! You\'ve just chosen "{color(AnsiFore.CYAN, ssid)}" as your SSID.'
)
safe_print()
sleep(0.75)
safe_print(
f"Now please state the {color(AnsiFore.GREEN, 'password')} of the WiFi network so that I can connect to it (Leave empty for no password)"
)
safe_print()
safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "PASSWORD42")}"')
sleep(0.5)
psk = safe_input(color(AnsiFore.BOLD_WHITE, "(PSK): "))
safe_print(
"Perfect! WiFi is now set up (you can create static IPs and so on later)."
)
sleep(1.5)
safe_print_step(4, OTA_BIG)
safe_print(
"Almost there! ESPHome can automatically upload custom firmwares over WiFi "
"(over the air) and integrates into Home Assistant with a native API."
)
safe_print()
sleep(0.5)
# Generate encryption key (32 bytes, base64 encoded) for secure API communication
noise_psk = secrets.token_bytes(32)
api_encryption_key = base64.b64encode(noise_psk).decode()
safe_print(
"For secure API communication, I've generated a random encryption key."
)
safe_print()
safe_print(
f"Your {color(AnsiFore.GREEN, 'API encryption key')} is: "
f"{color(AnsiFore.BOLD_WHITE, api_encryption_key)}"
)
safe_print()
safe_print("You'll need this key when adding the device to Home Assistant.")
sleep(1)
safe_print()
safe_print(
f"Do you want to set a {color(AnsiFore.GREEN, 'password')} for OTA updates? "
"This can be insecure if you do not trust the WiFi network."
)
safe_print()
sleep(0.25)
safe_print("Press ENTER for no password")
ota_password = safe_input(color(AnsiFore.BOLD_WHITE, "(password): "))
else:
ssid, psk = "", ""
api_encryption_key = None
ota_password = ""
kwargs = {
"path": path,
"name": name,
"platform": platform,
"board": board,
"ssid": ssid,
"psk": psk,
"type": "basic",
}
if api_encryption_key:
kwargs["api_encryption_key"] = api_encryption_key
if ota_password:
kwargs["ota_password"] = ota_password
if not wizard_write(**kwargs):
return 1
safe_print()
safe_print(
color(AnsiFore.CYAN, "DONE! I've now written a new configuration file to ")
+ color(AnsiFore.BOLD_CYAN, str(path))
)
safe_print()
safe_print("Next steps:")
safe_print(" > Follow the rest of the getting started guide:")
safe_print(
" > https://esphome.io/guides/getting_started_command_line/#adding-some-features"
)
safe_print(" > to learn how to customize ESPHome and install it to your device.")
return 0