Base URL: http://localhost:6052
The primary API. A single multiplexed WebSocket handles all 44 commands.
Connect: ws://localhost:6052/ws
On connect, the server sends a ServerInfoMessage:
{"server_version": "0.0.0", "esphome_version": "2026.3.1", "port": 6052, "ha_addon": false, "requires_auth": false}Send a CommandMessage:
{"command": "devices/list", "message_id": "1", "args": {}}Receive a ResultMessage:
{"message_id": "1", "result": { ... }}Streaming output (EventMessage):
{"message_id": "1", "event": "output", "data": "Compiling...\n"}
{"message_id": "1", "event": "result", "data": {"success": true, "code": 0}}Error (ErrorMessage):
{"message_id": "1", "error_code": "unknown_command", "details": "..."}Error Codes (ErrorCode)
| Code | Description |
|---|---|
invalid_message |
Malformed JSON or missing fields |
unknown_command |
Command not found |
invalid_args |
Missing or invalid arguments |
not_found |
Resource not found |
internal_error |
Server error |
not_authenticated |
Connection has not authenticated; only auth/login is accepted |
rate_limited |
Too many failed login attempts from this IP |
| Enum | Values | Description |
|---|---|---|
DeviceState |
unknown, online, offline |
Device connectivity state (mDNS + ping) |
Controller:
AuthController
When the dashboard is started with --username/--password (or $ESPHOME_USERNAME/$ESPHOME_PASSWORD env vars), every WebSocket connection on the public port must authenticate before any other command will be accepted.
The handshake:
- Server sends
ServerInfoMessagewithrequires_auth: true. - Client sends
auth/login(or its aliasauth) with either{username, password}or a previously issued{token}. - Server replies with
{token, expires_at}. - Subsequent commands on the same connection are accepted normally.
Tokens are opaque random strings, persisted to <config>/.device-builder-sessions.json, and auto-refresh on each use (sliding 30-day window). Frontends should store the token in localStorage and reuse it on reconnect — only fall back to the password form on not_authenticated.
Connections that arrive on the trusted ingress site (HA add-on supervisor proxy) get requires_auth: false and skip the handshake entirely.
| Command | Args | Response | Description |
|---|---|---|---|
auth/login (alias: auth) |
{username, password} or {token} |
{token, expires_at} |
Authenticate this connection |
auth/logout |
— | {logged_out: true} |
Revoke the current token; closes the connection |
auth/refresh |
— | {token, expires_at} |
Slide the expiry forward without making another API call |
Bearer header (non-browser clients). Anything that can set HTTP headers — the HA esphome-dashboard-api client, CLI tools, scripts — may pass Authorization: Bearer <token> on the WS handshake or on a REST request. The server treats that as equivalent to a successful in-band auth/login {token} call.
Basic auth (REST only). Legacy REST endpoints also accept Authorization: Basic <base64(user:pass)>. WebSocket clients can't use this because browsers don't allow setting headers on new WebSocket(...).
Rate limiting. After 10 failed login attempts from one IP within a 5-minute window, that IP is locked out for 5 minutes. A successful login clears the failure history immediately. Token-based logins (replays) are exempt — brute-forcing 256 bits of token entropy is infeasible, and rate-limiting valid replays would lock legitimate clients out after a network blip.
Models:
Device,DevicesResponseController:
DevicesController
| Command | Args | Response | Description |
|---|---|---|---|
devices/list |
— | DevicesResponse |
List configured + importable devices |
devices/get_states |
— | dict |
Get device online/offline states |
devices/create |
{name, board_id, config_type?, ssid?, psk?, file_content?} |
WizardResponse |
Create device from board definition |
devices/update |
{name, friendly_name?, comment?, board_id?} |
UpdateDeviceResponse |
Update device metadata |
devices/rename |
{configuration, new_name} |
— | Rename device via ESPHome CLI |
devices/delete |
{configuration} |
— | Delete device and associated files |
devices/delete_bulk |
{configurations: string[]} |
[{configuration, success, error?}] |
Delete multiple devices |
devices/archive |
{configuration} |
— | Soft-delete: move YAML to <config_dir>/archive/, wipe build dir, wipe StorageJSON + device-metadata sidecars. Reversible via devices/unarchive (cached IP/version/hash refill from the next mDNS broadcast). |
devices/archive_bulk |
{configurations: string[]} |
[{configuration, success, error?}] |
Archive multiple devices at once. Same per-item shape as devices/delete_bulk. |
devices/unarchive |
{configuration} |
— | Move an archived YAML back into the active config directory. Errors with INVALID_ARGS if an active config with the same filename already exists. |
devices/list_archived |
— | [{configuration, name, friendly_name, comment}] |
List archived devices for the dashboard's archived-devices dialog. |
devices/delete_archived |
{configuration} |
— | Permanently delete an archived YAML and its sidecars. The companion to unarchive for "I really don't want this back". |
devices/get_config |
{configuration} |
string |
Read device YAML config |
devices/update_config |
{configuration, content} |
— | Write device YAML config |
devices/add_component |
{configuration, component_id, fields?, sub_entities?} |
AddComponentResponse |
Add component to device config |
devices/import |
{name, project_name?, package_import_url?, ...} |
dict |
Import/adopt discovered device |
devices/ignore |
{name, ignore?} |
— | Toggle device visibility |
devices/validate |
{configuration} |
Streaming | Validate YAML config |
devices/logs |
{configuration, port?} |
Streaming | Stream live device logs |
Device.state: DeviceState — unknown, online, or offline (discovered via mDNS + ping).
Device.has_pending_changes: true = config changed since last compile, false = up to date, null = never compiled.
Device.update_available: true = device was compiled with a different ESPHome version than the server.
Models:
FirmwareJob,JobStatus,JobTypeController:
FirmwareController
| Command | Args | Response | Description |
|---|---|---|---|
firmware/compile |
{configuration} |
FirmwareJob |
Queue compile job |
firmware/upload |
{configuration, port?: ""} |
FirmwareJob |
Queue upload of existing binary. port defaults to "" (no --device arg — CLI auto-detects). Also accepts "OTA", a serial path (/dev/ttyUSB0, COM3), or an explicit IP / hostname for "install to a specific address" — the address-cache shortcut is bypassed when a target is named directly. |
firmware/install |
{configuration, port?: "OTA" | serial | ip | hostname} |
FirmwareJob |
Queue compile + upload. port defaults to "OTA" (let the CLI resolve the configured host). Same port semantics as firmware/upload for non-default values. |
firmware/clean |
{configuration} |
FirmwareJob |
Queue build clean for one device |
firmware/reset_build_env |
— | FirmwareJob |
Queue full reset of .esphome/ build dirs and PIO cache |
firmware/compile_bulk |
{configurations: string[]} |
[FirmwareJob] |
Queue multiple compiles |
firmware/install_bulk |
{configurations: string[], port?: "OTA" | serial | ip | hostname} |
[FirmwareJob] |
Queue multiple installs. port defaults to "OTA" and is shared across every queued job — almost always callers want that default rather than a single explicit target across the fleet. Same port validation as firmware/install. |
firmware/get_jobs |
{status?, configuration?} |
[FirmwareJob] |
List jobs with filters |
firmware/get_job |
{job_id} |
FirmwareJob |
Get job with full output |
firmware/follow_job |
{job_id} |
Streaming | Historical output + live stream for one job |
firmware/follow_jobs |
{snapshot?: true} |
Streaming | All jobs' lifecycle + output + progress |
firmware/get_binaries |
{configuration} |
[{title, file}] |
List compiled firmware files |
firmware/download |
{configuration, file, compressed?} |
{filename, data, size} |
Download binary (base64) |
firmware/cancel |
{job_id} |
— | Cancel queued or running job |
firmware/clear |
{status?} |
— | Remove finished jobs |
Job queue: one job runs at a time, others wait. Jobs persist across server restarts. Output buffered in FirmwareJob.output — clients can reconnect via firmware/follow_job.
One active job per device: queuing a new job for a device cancels any existing queued or running job with the same configuration first. The cancelled job fires JOB_CANCELLED as usual, then the new job fires JOB_QUEUED — frontends following lifecycle events stay consistent with the "show the latest result" UX. firmware/reset_build_env is global (empty configuration) and is exempt from this rule.
History retention: terminal compile/upload/install jobs are kept in a global pool capped at 50, deduplicated to one entry per configuration (newest wins). Terminal clean/reset_build_env jobs sit in a separate pool capped at 5 so they don't crowd device history. Active (queued/running) jobs are exempt from pruning. Each retained job's output is trimmed to the last 2000 lines on terminal transition; a synthetic first line ... [output trimmed: N earlier line(s) elided] indicates how many lines were dropped. firmware/clear still wipes terminal jobs on demand.
firmware/reset_build_env: wipes .esphome/build/, .esphome/external_components/, and .esphome/platformio_cache/ so the next compile re-fetches external components and re-downloads PlatformIO toolchains. Returns a FirmwareJob with empty configuration and job_type: "reset_build_env". Streams progress through the same JOB_OUTPUT event as compile jobs. Mid-run cancellation is honoured between the three target directories, not during a single removal.
Cancel semantics:
- Queued jobs flip to
cancelledimmediately. - Running jobs receive SIGTERM, with SIGKILL escalation after a 3 s grace period. The job's status becomes
cancelled(notfailed) andJOB_CANCELLEDfires.
Progress: FirmwareJob.progress is an int | null 0–100 latched from the highest percentage seen in [ 17%] Compiling … (PlatformIO) or Writing at 0x… (45 %) (esptool) lines. null means the tooling hasn't emitted a percentage yet — most early compile output is opaque. The value is monotonically non-decreasing within a job so the UI doesn't appear to regress between phases.
Job events (broadcast to all subscribed clients):
job_queued,job_started,job_output,job_progress,job_completed,job_failed,job_cancelled
firmware/follow_jobs stream events (per WebSocket subscription):
snapshot— initial replay of every retained job (one event per job, payload is the fullFirmwareJob). Includes both active and the trimmed terminal history, so a client gets the complete picture from a single subscription with no extrafirmware/get_jobscall. Skipped whensnapshot: false.job_queued/job_started/job_completed/job_failed/job_cancelled— fullFirmwareJobpayload.job_output—{job_id, line}(line keeps its\nor\rterminator).job_progress—{job_id, progress}(0–100 integer).
The subscription stays open for the connection's lifetime; closing the WebSocket cancels the stream.
Controller:
BoardCatalogEnums:
Platform,Esp32Variant,BoardTag
| Command | Args | Response | Description |
|---|---|---|---|
boards/get_boards |
{query?, platform?, variant?, tag?, offset?, limit?} |
PagedBoardsResponse |
Search/list boards |
boards/get_board |
{board_id} |
BoardCatalogEntry |
Get board with pin map |
BoardCatalogEntry carries two recommendation lists for the Add Component dialog:
featured_components: list[FeaturedComponent]— components recommended for this board, surfaced in the catalog API asfeatured.<board_id>.<local_id>under categoryfeatured. Each entry can override the catalogname/descriptionand pre-fill any subset of the underlying component'sconfig_entriesvia afieldsmap keyed byConfigEntry.key. Three preset modes per field:- default: a primitive value the frontend pre-fills; user can change it.
- locked:
{value, locked: true}— frontend disables the input anddevices/add_componentrejects deviating user values. - suggestions:
{suggestions: [...]}— frontend renders a picker, user must pick from the list.
featured_bundles: list[FeaturedBundle]—{id, name, description, component_ids}groups of featured components (e.g. "Status LED" =output.gpio+light.binary). The frontend triggers sequentialdevices/add_componentcalls for eachcomponent_idwhen the user adds a bundle.
Controller:
ComponentCatalogEnums:
ComponentCategory,ConfigEntryType
| Command | Args | Response | Description |
|---|---|---|---|
components/get_categories |
{board_id?} |
[{id, name, count}] |
List categories with counts |
components/get_components |
{query?, category?, exclude_category?, platform?, board_id?, offset?, limit?} |
PagedComponentsResponse |
Search/list components |
components/get_component |
{component_id, platform?, board_id?} |
ComponentCatalogEntry |
Get component with config entries |
platform filters to components compatible with the given target platform; components with an empty supported_platforms list are platform-agnostic and always included. board_id is a convenience — the boards catalog resolves it to a platform; platform wins when both are passed. The platform is also used to materialise each entry's platform_defaults into default_value.
category / exclude_category accept either a single category or a list. Use exclude_category for the regular catalog selector to hide entries that belong to the dedicated "Add core configuration" dialog.
Featured components. The board catalog's featured_components are surfaced through this same API under the synthetic category featured and ID prefix featured.<board_id>.<local_id>. They are only returned when category explicitly includes featured and board_id is supplied — the regular catalog listing never mixes them in. get_categories adds a featured entry with the board's recommended-count when board_id is set. A featured ComponentCatalogEntry carries the board overrides baked into its config_entries: default_value reflects the preset, and the new locked: bool and suggestions: list[ConfigPrimitive] | None fields tell the frontend to disable the input or render a picker. devices/add_component recognises featured.* ids — the wire shape doesn't change, but the backend resolves the underlying component, validates user input against the locked/suggestion constraints, and merges presets before delegating to the regular merge logic.
Controller:
AutomationsController
| Command | Args | Response | Description |
|---|---|---|---|
automations/get_triggers |
{platform_type?} |
[AutomationTrigger] |
List triggers by platform type |
automations/get_actions |
— | [AutomationAction] |
List all actions |
automations/get_available |
{configuration} |
{triggers, actions, present_platform_types} |
Context-aware for a device |
Controller:
ConfigControllerModels:
UserPreferences
| Command | Args | Response | Description |
|---|---|---|---|
config/version |
— | {server_version, esphome_version} |
Get versions |
config/serial_ports |
— | [{port, desc}] |
List serial ports |
config/get_preferences |
— | UserPreferences |
Get user preferences |
config/set_preferences |
{theme?, dashboard_view?, ...} |
UserPreferences |
Update preferences (partial) |
config/get_secrets |
— | [string] |
List secret key names |
| Command | Args | Response | Description |
|---|---|---|---|
ping |
— | {pong: true} |
Health check |
subscribe_events |
— | Streaming | Subscribe to real-time events |
subscribe_events events:
device_added,device_removed,device_updated,device_state_changedimportable_device_added,importable_device_removedjob_queued,job_started,job_output,job_completed,job_failed
For Home Assistant ESPHome integration backward compat only.
| Endpoint | Description |
|---|---|
GET /devices |
List devices |
GET /json-config?configuration=... |
Get parsed YAML as JSON |
GET /compile (WebSocket) |
Compile via spawn protocol |
GET /upload (WebSocket) |
Upload via spawn protocol |