forked from esphome/device-builder
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcommon.py
More file actions
773 lines (658 loc) · 34.7 KB
/
Copy pathcommon.py
File metadata and controls
773 lines (658 loc) · 34.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
"""Common/shared data models.
Hosts shared types referenced from multiple domains (boards, components,
devices) — ConfigEntry, EventType, hardware pin enums, paged-response
base, etc. Anything in this module must remain free of imports from
sibling models to keep the dependency graph acyclic.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Any
from mashumaro.mixins.orjson import DataClassORJSONMixin
# ---------------------------------------------------------------------------
# Paged response base
# ---------------------------------------------------------------------------
@dataclass
class PagedResponse(DataClassORJSONMixin):
"""Base for paginated API responses."""
total: int = 0
offset: int = 0
limit: int = 50
# ---------------------------------------------------------------------------
# Event types
# ---------------------------------------------------------------------------
class EventType(StrEnum):
"""Events pushed to connected clients via subscribe_events."""
# Device config file changes (detected by disk scanner)
DEVICE_ADDED = "device_added"
DEVICE_REMOVED = "device_removed"
DEVICE_UPDATED = "device_updated"
# Device online/offline state changes
DEVICE_STATE_CHANGED = "device_state_changed"
# Per-device reachability detail change — fired alongside
# ``DEVICE_STATE_CHANGED`` (and on its own when only the per-signal
# last-seen / rtt move) for any subscriber filtering by device.
# The drawer subscribes to these via the ``devices/subscribe_reachability``
# WS command; the broadcast ``subscribe_events`` excludes this
# type explicitly (see ``_cmd_subscribe_events``) so a connected
# client doesn't receive a freshness event for every device on
# every mDNS announce.
DEVICE_REACHABILITY = "device_reachability"
# Discoverable device changes
IMPORTABLE_DEVICE_ADDED = "importable_device_added"
IMPORTABLE_DEVICE_REMOVED = "importable_device_removed"
# Label catalog mutations. Per-device label assignment changes
# ride the existing ``DEVICE_UPDATED`` event (fired automatically
# by the scanner reload after the sidecar write).
LABEL_CREATED = "label_created"
LABEL_UPDATED = "label_updated"
LABEL_DELETED = "label_deleted"
# Firmware job lifecycle
JOB_QUEUED = "job_queued"
JOB_STARTED = "job_started"
JOB_OUTPUT = "job_output"
JOB_PROGRESS = "job_progress"
JOB_COMPLETED = "job_completed"
JOB_FAILED = "job_failed"
JOB_CANCELLED = "job_cancelled"
# Receiver rotated its X25519 peer-link identity via
# ``remote_build/rotate_identity``. Payload carries
# ``{dashboard_id, pin_sha256}``: subscribers (the offloader-
# side peer-link, the receiver Settings UI) can refresh
# their cached pin without polling ``get_identity``. The
# event fires after the on-disk rotation succeeds; the
# listener rebuild may still fail-soft, in which case the
# rotater's ``IdentityView`` response carries
# ``listener_bound=False`` while the event itself reflects
# only that the persistent key on disk changed.
REMOTE_BUILD_IDENTITY_ROTATED = "remote_build_identity_rotated"
# A pair_request Noise frame landed for a previously-unknown
# peer while the receiver's pairing window was open (see
# issue #106 design choice (b)/(c)). Payload:
# ``{dashboard_id, pin_sha256, label, peer_ip}``. The receiver
# Settings UI surfaces this in the Pairing requests inbox.
# Fires only when the pairing window is open; closed-window
# pair_requests are rejected at the listener with
# ``intent_response=no_pairing_window`` and don't create a
# row. The peer-link listener is the actual emitter.
REMOTE_BUILD_PAIR_REQUEST_RECEIVED = "remote_build_pair_request_received"
# A peer entry's status changed. Payload:
# ``{dashboard_id, status: "approved" | "removed"}``. Fires
# from three paths: (a) ``remote_build/approve_peer``
# promoting a PENDING in-memory dict entry to APPROVED on
# disk (``status="approved"``), (b) ``remote_build/remove_peer``
# dropping either a PENDING dict entry or an APPROVED list
# row (``status="removed"``), (c) pairing-window-close
# clearing the in-memory PENDING dict (``status="removed"``
# per cleared entry). The ``status="removed"`` event is
# what wakes any in-flight ``intent="pair_status"`` long-poll
# on a paired offloader so its listener task drops the
# offloader's local state.
#
# Receiver Settings UI updates the inbox + approved-peers
# list on this event. The offloader-side counterpart event
# is :attr:`OFFLOADER_PAIR_STATUS_CHANGED`, fired on the
# offloader's local bus by the offloader's pair-status
# listener task after observing the receiver's response; the
# two share a wire shape but live on different buses
# (receiver vs offloader) and carry different identifiers
# (offloader's dashboard_id vs the offloader's own
# ``(receiver_hostname, receiver_port)`` coordinates).
REMOTE_BUILD_PAIR_STATUS_CHANGED = "remote_build_pair_status_changed"
# Offloader-side counterpart to ``REMOTE_BUILD_PAIR_STATUS_CHANGED``.
# Payload: ``{receiver_hostname, receiver_port,
# status: "approved" | "removed"}``. Fired by the offloader's
# per-row pair-status listener task
# (``OffloaderController._fire_offloader_pair_status_changed``,
# called from ``_apply_pair_status_result`` once an
# ``intent="pair_status"`` round-trip resolves), and also by
# ``OffloaderController.unpair`` when the user removes a
# row. Delivered to clients via the existing global
# ``subscribe_events`` stream — no separate subscription
# channel. Receiver-side keys aren't carried because the
# offloader's :class:`StoredPairing` never stores the
# receiver's ``dashboard_id`` — the receiver coordinates the
# offloader knows are the ``(hostname, port)`` it dialled.
OFFLOADER_PAIR_STATUS_CHANGED = "offloader_pair_status_changed"
# Offloader-side mDNS auto-rebind. Payload: ``{pin_sha256,
# receiver_hostname, receiver_port}``. Fired when the
# offloader's mDNS browser observes a known paired receiver
# broadcasting from a different ``(hostname, port)`` than the
# ``StoredPairing`` records, and a probe-before-mutate Noise
# XX handshake against the new endpoint confirmed the
# responder's static pubkey matches the stored pin. The
# controller has already mutated ``StoredPairing`` in place,
# cancelled the old peer-link client, and respawned a fresh
# one against the new coordinates by the time this fires;
# frontends update the row's display fields off the new
# ``receiver_hostname`` / ``receiver_port`` without a
# re-fetch.
OFFLOADER_PAIR_ENDPOINT_REBOUND = "offloader_pair_endpoint_rebound"
# Pairing window opened, extended, or closed. Payload:
# ``{open: bool, expires_in_seconds: float | None}``. Fires
# on every state transition: window open (open=true,
# expires_in_seconds=300), activity-driven extension
# (open=true, expires_in_seconds=300 again, deadline
# bumped), explicit close from the frontend (open=false,
# expires_in_seconds=null), or auto-close on deadline reach
# (open=false). The receiver frontend renders a live
# countdown from ``expires_in_seconds``; idempotent calls
# that don't change state (e.g. close-while-already-closed)
# do NOT fire the event.
REMOTE_BUILD_PAIRING_WINDOW_CHANGED = "remote_build_pairing_window_changed"
# An mDNS-discovered peer dashboard appeared (or its TXT /
# SRV info was refreshed). Payload: a full
# :class:`RemoteBuildPeer` dict. The receiver-side controller
# holds the discovered set in RAM (``self._peers``) and fires
# this event from ``_on_service_state_change`` /
# ``_resolve_and_apply`` whenever the row is upserted; the
# frontend's pair-dialog "discovered dashboards" list mutates
# against this stream rather than re-polling. The
# ``subscribe_events`` initial-state push carries the full
# current set under ``hosts`` so a fresh tab paints without a
# round-trip.
REMOTE_BUILD_HOST_ADDED = "remote_build_host_added"
# An mDNS-discovered peer dashboard left the LAN (zeroconf
# ``Removed`` callback — TTL expiry without renewal, or an
# explicit goodbye). Payload: ``{name: str}`` matching the
# service-instance name carried on the corresponding
# ``REMOTE_BUILD_HOST_ADDED`` event. The frontend drops the
# row from its discovered set on this event.
REMOTE_BUILD_HOST_REMOVED = "remote_build_host_removed"
# An offloader-side ``PeerLinkClient`` successfully
# established a long-lived peer-link Noise WS session against
# an APPROVED receiver — handshake completed, post-handshake
# ``intent_response: ok`` landed, the dispatch loop is
# parked waiting for application frames. Payload:
# ``{receiver_hostname, receiver_port}``. Receiver
# coordinates rather than ``dashboard_id`` because the
# offloader's :class:`StoredPairing` keys on
# ``(hostname, port)`` (the user dialled those) and the
# offloader-side frontend Settings UI shows one row per
# paired receiver. Subscribers update their per-receiver
# ``connected`` indicator on this event.
OFFLOADER_PEER_LINK_OPENED = "offloader_peer_link_opened"
# Counterpart to :attr:`OFFLOADER_PEER_LINK_OPENED`. Fires
# on every clean exit of a peer-link session: WS close,
# heartbeat timeout, receiver-side ``terminate`` frame,
# transport error during the receive loop. Payload:
# ``{receiver_hostname, receiver_port, reason}`` where
# ``reason`` is one of the receiver-side
# :class:`TerminateReason` wire values
# (``"superseded"`` / ``"server_shutting_down"`` /
# ``"heartbeat_timeout"`` / ``"malformed_frame"``) when
# the close came from a structured ``terminate`` frame,
# or one of the offloader-side reasons
# (``"transport_error"`` / ``"heartbeat_timeout"`` /
# ``"client_stopped"`` / ``"peer_hung_up"`` /
# ``"auth_rejected"``) when our side initiated. The
# reconnect logic in
# :class:`PeerLinkClient` branches on this so a
# ``superseded`` close doesn't trigger a reconnect storm.
OFFLOADER_PEER_LINK_CLOSED = "offloader_peer_link_closed"
# Receiver-side counterpart to :attr:`OFFLOADER_PEER_LINK_OPENED`.
# Fires from :meth:`ReceiverController.register_peer_link_session`
# the moment a peer-link Noise WS session lands in the
# receiver's ``_peer_link_sessions`` registry — i.e. the
# post-handshake ``_run_peer_link_session`` has installed the
# session and is about to enter its dispatch loop. Payload:
# ``{dashboard_id}`` (the offloader's stable identity, as
# captured during the Noise XX handshake).
#
# Subscribers: 5b's ``queue_status`` push uses this as the
# signal to send the initial queue snapshot to a freshly-
# connected offloader (no lookup-then-push race window where
# a queue transition fires between handshake completion and
# session registration). The receiver-side frontend Settings
# UI uses it to render a "connected" indicator per offloader
# in the approved-peers list.
RECEIVER_PEER_LINK_SESSION_OPENED = "receiver_peer_link_session_opened"
# Counterpart to :attr:`RECEIVER_PEER_LINK_SESSION_OPENED`.
# Fires from :meth:`ReceiverController.unregister_peer_link_session`
# when the receiver's session loop unwinds (offloader
# disconnects, heartbeat timeout, controller shutdown,
# ``superseded`` eviction). Payload: ``{dashboard_id}``.
#
# No ``reason`` field on this side: the receiver sees only
# "the session loop returned"; the rich reason classification
# lives on the offloader side (``OFFLOADER_PEER_LINK_CLOSED``)
# because that's where the close path's branches diverge
# (transport error vs heartbeat timeout vs structured
# terminate frame). Receiver-side subscribers don't need to
# discriminate.
RECEIVER_PEER_LINK_SESSION_CLOSED = "receiver_peer_link_session_closed"
# Offloader-side detection: the receiver's static X25519
# pubkey hash observed during a pair-status / peer-link
# handshake doesn't match the ``StoredPairing.pin_sha256``
# the offloader recorded at pair time. The receiver's
# identity rotated under us (legitimate
# ``rotate_peer_link_identity`` from the receiver-side
# admin, or someone replacing the receiver). Payload:
# ``{receiver_hostname, receiver_port, receiver_label,
# expected_pin, observed_pin}``. Fires alongside
# ``OFFLOADER_PAIR_STATUS_CHANGED status="removed"`` (the
# row drops either way); subscribers use this event to
# surface the "re-pair to confirm the new identity" alert
# in the offloader's UI distinct from a peer-revocation
# alert. No receiver-side counterpart — the receiver never
# sees its own pin drift.
OFFLOADER_PAIR_PIN_MISMATCH = "offloader_pair_pin_mismatch"
# Offloader-side detection: the receiver actively returned
# ``intent_response="rejected"`` on a pair-status long-poll
# for a row the offloader had as PENDING / APPROVED.
# Receiver admin clicked Reject, the pairing window closed
# clearing the receiver's pending dict, the offloader's
# identity rotated, or the receiver never had this row.
# Payload: ``{receiver_hostname, receiver_port,
# receiver_label}``. Fires alongside
# ``OFFLOADER_PAIR_STATUS_CHANGED status="removed"``;
# subscribers use this event for the "the receiver removed
# us; reach out if this was a mistake" alert distinct from
# a pin-mismatch alert (different operator response —
# pin-mismatch can be re-paired right away, peer-revoked
# needs receiver-side admin coordination).
OFFLOADER_PAIR_PEER_REVOKED = "offloader_pair_peer_revoked"
# An offloader-side pair alert was cleared by one of the
# two resolution paths that fix the underlying broken
# state: a successful ``request_pair`` against the same
# ``(hostname, port)`` (re-pair auto-resolved the alert),
# or ``unpair`` removing the row outright. There is no
# operator-driven dismiss — clicking "OK got it" without
# acting would just hide a broken pairing the next peer-
# link session would still fail against, so re-pair and
# unpair are the only ways the alert clears. Payload:
# ``{receiver_hostname, receiver_port}``. RAM-only state
# on the controller's ``_offloader_alerts`` dict; the
# event keeps other tabs / clients on the global
# ``subscribe_events`` stream in sync without re-fetching
# the alerts snapshot. Late-subscribing clients pick up
# the canonical state via
# ``subscribe_events.initial_state.offloader_alerts``.
OFFLOADER_PAIR_ALERT_DISMISSED = "offloader_pair_alert_dismissed"
# Offloader-side cache update: a paired receiver pushed a
# fresh ``queue_status`` snapshot over its peer-link
# session. Payload: ``{receiver_hostname, receiver_port,
# idle, running, queue_depth}``. Fired from the
# offloader-side ``PeerLinkClient`` receive loop on every
# inbound ``queue_status`` application frame; the
# remote-build controller listens, updates its
# ``_peer_queue_status`` cache (RAM-only, keyed on
# ``(host, port)``), and re-broadcasts via the global
# ``subscribe_events`` stream so frontend clients can
# render the per-peer queue depth live without polling.
# The scheduler reads the same cache.
OFFLOADER_QUEUE_STATUS_CHANGED = "offloader_queue_status_changed"
# Offloader-side: a paired receiver pushed a
# ``job_state_changed`` application frame for a job we
# submitted. Payload:
# ``{receiver_hostname, receiver_port, pin_sha256, job_id,
# status, error_message}``. ``status`` mirrors the wire
# frame's literal (``queued`` / ``running`` / ``completed`` /
# ``failed`` / ``cancelled``). The remote-build controller
# re-broadcasts via the global ``subscribe_events`` stream
# so frontend tabs see the lifecycle of a remote build live.
# Distinct from the local :attr:`JOB_STARTED` /
# :attr:`JOB_COMPLETED` family because remote-driven jobs
# don't have a corresponding :class:`FirmwareJob` row on
# the offloader — the receiver owns the queue state and we
# only see the wire reflection.
OFFLOADER_JOB_STATE_CHANGED = "offloader_job_state_changed"
# Offloader-side: a paired receiver pushed a ``job_output``
# application frame for a job we submitted. Payload:
# ``{receiver_hostname, receiver_port, pin_sha256, job_id,
# stream, line}`` — ``stream`` is ``stdout`` / ``stderr``,
# ``line`` preserves its trailing terminator (carriage-return
# vs newline carries semantic info; the same contract the
# local :class:`JobOutputData` event holds). Frames flow at
# high rate during an active build (one per line of compiler
# / linker output); subscribers should debounce / batch
# downstream rendering rather than re-rendering per event.
OFFLOADER_JOB_OUTPUT = "offloader_job_output"
# Offloader-side master toggle changed. Fires from
# :meth:`OffloaderController.set_offloader_settings`
# whenever the operator flips the "Remote builds enabled"
# switch in the offloader Settings UI. Payload:
# ``{remote_builds_enabled: bool}``. Subscribers are the
# Settings UI (renders the live switch state) — the
# scheduler doesn't need an event because it reads
# :attr:`OffloaderController._remote_builds_enabled` on
# every install via :meth:`build_scheduler_snapshot`. The
# event still fires so a second open tab sees the
# cross-tab toggle without polling.
OFFLOADER_REMOTE_BUILDS_TOGGLED = "offloader_remote_builds_toggled"
# Offloader-side per-pairing toggle changed. Fires
# from :meth:`OffloaderController.set_pairing_enabled`
# whenever the operator flips an individual paired
# receiver's enable switch. Payload:
# ``{pin_sha256: str, enabled: bool}``. Subscribers
# update the matching row's switch in the offloader
# Settings UI; the scheduler reads
# :attr:`StoredPairing.enabled` directly off the in-RAM
# ``_pairings`` dict via the snapshot.
OFFLOADER_PAIRING_ENABLED_CHANGED = "offloader_pairing_enabled_changed"
class StreamEvent(StrEnum):
"""Per-stream frame names sent via ``WebSocketClient.send_event``.
Distinct from :class:`EventType` (the global event-bus channel
name): a ``StreamEvent`` is the ``event`` field of a single
streaming command's response frames (``follow_job``,
``stream_logs``, ``validate_config``, ``follow_jobs``'s initial
snapshot). Two-tier model — bus events get fanned out to per-
connection streams, where the controller may relabel them
(e.g. ``EventType.JOB_OUTPUT`` becomes ``StreamEvent.OUTPUT``
inside a ``follow_job`` stream that's already scoped to a
specific job_id).
The wire bytes coincide with some ``EventType`` values
(``"job_output"``, ``"job_progress"``) for the all-jobs
follower path, where the stream simply forwards the bus event
name through. Those call sites pass the ``EventType`` member
directly (it's a ``StrEnum``, so it serialises to the same
string) rather than redeclaring the constant here.
"""
# Per-line subprocess output (``follow_job`` / ``stream_logs`` /
# ``validate_config``).
OUTPUT = "output"
# Terminal frame — final status / exit code, ends a streaming
# command. Sent priority so a backlog of output frames can't
# drop the close signal.
RESULT = "result"
# Initial replay of buffered state at the start of a stream
# (``follow_jobs`` snapshots the job table; ``follow_job``
# replays the job's output ring before live tail).
SNAPSHOT = "snapshot"
# ---------------------------------------------------------------------------
# Hardware enums (shared between board metadata and config-entry constraints)
# ---------------------------------------------------------------------------
class PinFeature(StrEnum):
"""Known GPIO pin features/capabilities.
Used in two places:
1. Board manifests describe which features each physical pin exposes.
2. ConfigEntry of type PIN declares which features it requires;
the frontend filters board pins to those that match.
"""
ADC = "adc"
DAC = "dac"
TOUCH = "touch"
PWM = "pwm"
I2C_SDA = "i2c_sda"
I2C_SCL = "i2c_scl"
SPI_MOSI = "spi_mosi"
SPI_MISO = "spi_miso"
SPI_CLK = "spi_clk"
SPI_CS = "spi_cs"
UART_TX = "uart_tx"
UART_RX = "uart_rx"
USB_DP = "usb_dp"
USB_DM = "usb_dm"
RGB_LED = "rgb_led"
JTAG = "jtag"
STRAPPING = "strapping"
INPUT_ONLY = "input_only"
BOOT_BUTTON = "boot_button"
class PinMode(StrEnum):
"""Direction a GPIO pin will be used in.
Used by ConfigEntry of type PIN to constrain pin selection.
"""
INPUT = "input"
OUTPUT = "output"
INPUT_OUTPUT = "input_output"
# ---------------------------------------------------------------------------
# Config entries
# ---------------------------------------------------------------------------
class ConfigEntryType(StrEnum):
"""Primitive value type of a config entry.
Drives the base UI control. Two flags layer additional behaviour on
top without needing extra enum values:
- `options` populated → render a dropdown of the listed values; the
value type still reflects what those values are (usually STRING).
- `multi_value=True` → render an add/remove list of inputs of the
base type (e.g. STRING + multi_value = list of strings).
"""
# Single-line text input
STRING = "string"
# Single-line text input that masks the value (passwords, API keys)
SECURE_STRING = "secure_string"
# Whole-number spinner / numeric input
INTEGER = "integer"
# Decimal-number spinner / numeric input
FLOAT = "float"
# Toggle / checkbox
BOOLEAN = "boolean"
# GPIO pin picker — see `pin_features` and `pin_mode` to filter choices
PIN = "pin"
# Duration like "30s", "5min" — frontend renders a value+unit input
TIME_PERIOD = "time_period"
# Numeric value carrying a unit: frequency ("50kHz"), data size
# ("500KB"), framerate ("10 fps"), voltage ("3.3V"), distance
# ("2m"), temperature ("4°C"), etc. ESPHome's coercer multiplies
# by the unit at compile time, but the YAML shape the user types
# is a string — so the frontend renders a number input plus a
# unit picker, round-trips the value as ``"<value><unit>"``, and
# validates the numeric portion against ``range``. Unit choices
# come from ``unit_options`` on the entry. ``TIME_PERIOD`` is
# kept separate because its grammar (``1h30s``) and unit set are
# richer; this type is for the simpler single-unit measurements.
FLOAT_WITH_UNIT = "float_with_unit"
# Material Design icon picker (mdi:foo)
ICON = "icon"
# Component ID reference — links to another component instance
ID = "id"
# Automation trigger reference (rare, advanced)
TRIGGER = "trigger"
# Color picker — accepts hex (#RRGGBB) or named color
COLOR = "color"
# MAC address input (xx:xx:xx:xx:xx:xx)
MAC_ADDRESS = "mac_address"
# Multi-line code editor for raw `!lambda |- C++` blocks
LAMBDA = "lambda"
# Multi-line JSON editor (HTTP request bodies, custom payloads)
JSON = "json"
# Structured value: the entry's value is itself a YAML mapping
# whose own fields are described by ``config_entries``. Frontend
# renders the field as a collapsible group containing the nested
# form. Used for nested config blocks (e.g.
# ``esp32_ble_tracker.scan_parameters``) and entity sub-readings
# (e.g. ``dht.temperature`` and ``dht.humidity``).
NESTED = "nested"
# User-keyed mapping: the value is a YAML dict whose keys are
# supplied by the user (component names, substitution names, ...)
# and whose values all follow the same template schema. The single
# entry inside ``config_entries`` describes that value template.
# Frontend renders this as a dynamic list of (key, value) rows
# with an "Add entry" button. Used for ``logger.logs`` (per-
# component log levels), ``substitutions:``, ``globals:`` etc.
MAP = "map"
# Layout / decoration entries (no value, used to structure the form)
LABEL = "label"
DIVIDER = "divider"
ALERT = "alert"
# Fallback for fields whose type couldn't be determined during sync
UNKNOWN = "unknown"
# Primitive values that can appear as defaults, current values, and
# constants in the visibility predicate. Excludes containers.
ConfigPrimitive = str | int | float | bool
@dataclass
class ConfigValueOption(DataClassORJSONMixin):
"""A single choice for a SELECT-type config entry."""
label: str
value: str
@dataclass
class ConfigEntry(DataClassORJSONMixin):
"""A single field in a component's configuration schema.
Drives both the visual editor (rendering, validation, conditional
visibility) and YAML serialization. Inspired by the Music Assistant
ConfigEntry pattern.
"""
# === core ===
# YAML key name (e.g. "update_interval", "ssid", "pin"). This is
# what gets serialized into the user's config file.
key: str
# Primitive type drives the UI control: text input, number spinner,
# select dropdown, pin picker, lambda editor, etc.
type: ConfigEntryType
# Short human-readable label shown next to the input. When empty,
# the frontend should derive one from `key` (e.g. "update_interval"
# → "Update Interval").
label: str
# Longer help text shown as a tooltip or below the input. Often
# extracted from the component documentation. May contain markdown.
description: str | None = None
# When True the YAML is invalid without this field set. Frontend
# marks the input with a required indicator.
required: bool = False
# Default value used when the field is omitted from YAML. For
# `multi_value` entries this is the default *list* of values.
default_value: ConfigPrimitive | list[ConfigPrimitive] | None = None
# Per-target-platform default values for fields that use
# ``cv.SplitDefault`` (e.g. wifi.power_save_mode is "light" on
# ESP32 but "none" on ESP8266). Frontend should look up the
# device's target platform here and fall back to ``default_value``
# when the platform isn't listed (which means the field has no
# built-in default for that platform — usually because it isn't
# commonly used there).
platform_defaults: dict[str, ConfigPrimitive] | None = None
# Target chips this field is valid on. Empty list = no
# restriction (the common case); non-empty = the field is
# restricted to the listed chips and the frontend's form
# renderer hides it on incompatible boards. Same wire shape
# as ``ComponentCatalogEntry.supported_platforms`` (which
# carries the *whole component*'s restriction) — this one
# gates a single field within an otherwise platform-portable
# component, e.g. ``sensor.debug.psram`` which is ESP32-only
# while the rest of the debug sensors are platform-portable.
# Recovered from upstream's declarative ``cv.only_on``
# validators by the sync script's schema introspection.
supported_platforms: list[str] = field(default_factory=list)
# === value constraints ===
# Constrains the value to a fixed set of choices. When populated the
# frontend renders a dropdown rather than a free-form input — the
# underlying value type (`type`) is unchanged.
options: list[ConfigValueOption] | None = None
# When True, ``options`` are treated as suggestions rather than a
# closed enum: the frontend should render an autocomplete /
# combobox that allows typing arbitrary values in addition to
# picking from the list. Used for fields like
# ``unit_of_measurement`` where ESPHome ships canonical unit
# symbols but accepts any string.
allow_custom_value: bool = False
# Min/max bounds for INTEGER / FLOAT entries. None = unbounded.
range: tuple[int | float, int | float] | None = None
# Display-formatting hint for INTEGER entries. Currently only
# ``"hex"`` is defined, applied to fields whose upstream
# validator is one of the ``cv.hex_uint*_t`` family
# (``i2c_address`` is the canonical case — every i2c-platform
# component sets ``address`` to ``cv.hex_uint8_t`` because i2c
# addresses are conventionally written as ``0x76`` / ``0x77``,
# and decimal display is borderline unreadable). Frontend
# renders the input as hex (``0x76``) and accepts both
# ``0x76`` and ``118`` on entry. None = decimal display
# (the default for plain ``cv.int_range`` integers).
display_format: str | None = None
# Unit choices for ``FLOAT_WITH_UNIT`` entries. The frontend
# renders a unit picker populated from this list; each option's
# string is what the YAML serialization appends after the
# numeric value (e.g. ``["Hz", "kHz", "MHz", "GHz"]`` for
# ``cv.frequency``). The first entry is the canonical unit —
# range bounds and any user-typed bare number default to it.
# None for non-FLOAT_WITH_UNIT entries.
unit_options: list[str] | None = None
# When True the field accepts a list of values rather than a single
# value (e.g. multiple SSIDs, multiple radar targets). Frontend
# renders an add/remove list of inputs of the declared `type`.
multi_value: bool = False
# When True the field accepts either a literal value of the
# declared `type` OR a `!lambda |- ...` block returning that type.
# Most ESPHome fields are templatable.
templatable: bool = False
# === featured-component overlays ===
# Populated only on materialised featured components — the regular
# catalog never sets these. ``locked=True`` tells the frontend to
# disable the input (the value comes from a board-side preset and
# the backend rejects deviating user input on add). ``suggestions``,
# when non-None, limits the user's choice to this list — most often
# used on PIN entries for addon modules whose pin can land on one
# of a few GPIOs.
locked: bool = False
suggestions: list[ConfigPrimitive] | None = None
# === conditional visibility ===
# `depends_on_value` and `depends_on_value_not` are mutually
# exclusive — set at most one. Frontend hides the entry when the
# predicate fails.
# Key of another entry in the same component this entry depends on.
# When None the entry is always visible.
depends_on: str | None = None
# Show this entry only when the dependency's current value equals
# this. Ignored if `depends_on` is None.
depends_on_value: ConfigPrimitive | None = None
# Show this entry only when the dependency's current value does NOT
# equal this. Ignored if `depends_on` is None.
depends_on_value_not: ConfigPrimitive | None = None
# Hide this entry unless the named component is configured on the
# same device. Used for cross-cutting fields that are only
# meaningful when a specific transport / gateway is configured —
# e.g. ``qos`` / ``retain`` are only relevant when the device has
# an ``mqtt:`` block; ``zigbee_*`` fields require a ``zigbee:``
# block. None = always visible (the default).
depends_on_component: str | None = None
# When ``type`` is ID, identifies the component domain the value
# must reference. The frontend renders a dropdown of existing
# components of that domain in the device's YAML — e.g.
# ``rtttl.output`` references "output", ``integration.sensor``
# references "sensor", many sensors reference "i2c" / "spi" /
# "uart" buses. None when the field is a free-form ID.
references_component: str | None = None
# === pin selection (only meaningful when type == PIN) ===
# Pin capabilities required for this field. Frontend filters the
# board's pin map to entries whose features include all of these.
pin_features: list[PinFeature] = field(default_factory=list)
# Direction the pin will be used in. None = no constraint.
pin_mode: PinMode | None = None
# === UI / i18n ===
# When True frontend collapses this entry under an "Advanced" section.
advanced: bool = False
# When True frontend hides the entry entirely (used for fields the
# backend tracks but the user shouldn't edit directly).
hidden: bool = False
# Optional URL pointing to documentation specific to this field
# (often an anchor inside the component's docs page).
help_link: str | None = None
# i18n override key. None means the frontend should fall back to
# `component.{component_id}.config.{key}` at render time.
translation_key: str | None = None
# Substitution params for the translation string (e.g.
# `{"min": 0, "max": 100}` for a range message).
translation_params: dict[str, Any] | None = None
# === nested entries (only meaningful when type == NESTED) ===
# Inner config entries when this entry's value is a structured YAML
# mapping (e.g. ``esp32_ble_tracker.scan_parameters`` →
# duration / interval / window / active / continuous, or DHT's
# temperature / humidity readings). Frontend renders the parent
# field as a collapsible group containing the inner form.
config_entries: list[ConfigEntry] | None = None
# Set when the nested entry represents an ESPHome entity (sensor,
# binary_sensor, ...) rather than a plain config group. The
# frontend should apply platform-default fields (name,
# device_class, ...) on top of `config_entries` for these. None
# means a plain structured group.
platform_type: str | None = None
# ---------------------------------------------------------------------------
# Featured-component presets (board-side)
# ---------------------------------------------------------------------------
@dataclass
class FieldPreset(DataClassORJSONMixin):
"""
Pre-filled value for a single config-entry on a featured component.
Three modes, expressed by which fields are populated:
- ``value`` only: pre-filled default, user can change it.
- ``value`` + ``locked=True``: fixed value. Frontend disables the input;
backend rejects deviating user input on add.
- ``suggestions``: short list of allowed values (frontend renders a
picker). ``value`` (if also set) is the initial selection.
``locked`` and ``suggestions`` are mutually exclusive. ``value`` can be
a primitive, list, or dict — the latter for nested config entries.
"""
# ``dict[str, Any]`` must precede ``list[Any]`` in the union:
# mashumaro dispatches in declaration order and ``list(some_dict)``
# would otherwise win for dict inputs (returning the keys).
value: ConfigPrimitive | dict[str, Any] | list[Any] | None = None
locked: bool = False
suggestions: list[ConfigPrimitive] | None = None