Compare commits

...
Author SHA1 Message Date
clydebarrowandClaude Opus 5.5 1f67fdbd4c [espnow] Warn now and then when a packet interface link's sends keep failing
A failure that repeats on every send, such as a peer that could not be
added, was logged only at verbose level. The error name helper is now
shared so the link can name the error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:41 +11:00
clydebarrowandClaude Opus 5.5 9f6bde2436 [espnow][udp] Log oversized ESP-NOW packets, and name any sender in the UDP drop warning
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:41 +11:00
clydebarrowandClaude Opus 5.5 d77c30f0bc [espnow][udp] Fixes from code review
- Add point_to_point to the UDP link, to override the guess that an
  address ending in .255 is a broadcast address, for subnets other than
  /24
- Warn, at most once a minute, when a point to point UDP link drops a
  packet from another sender
- Say that on ESP8266 an ESP-NOW peer link also receives the peer's
  broadcasts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:41 +11:00
clydebarrowandClaude Code 06a9153011 [espnow][udp] Take the packet size from the copy buffer
The ESP-NOW link derives max_packet_size from the span it is given, as
the UDP point-to-point link already does.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-23 13:39:41 +11:00
clydebarrowandClaude Opus 5.5 3aebba2cdf [espnow][udp] Fixes from code review
- Reject reliability, session and the rolling code on a broadcast UDP or
  ESP-NOW link without addressing, as every device hears the link
- Test the UDP and ESP-NOW final validation

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:41 +11:00
clydebarrowandClaude Opus 5.5 e6e1cb7540 [espnow] Keep the copy buffer in static memory
Code generation declares the ESP-NOW link's buffer for packets split
across buffers as a static array of max_packet_size bytes, so it is not
allocated at run time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:41 +11:00
clydebarrowandClaude Opus 5.5 0c60a15d12 [espnow][udp] Add ESP-NOW packet interface link, and UDP point to point
The ESP-NOW link sends to one peer, which also provides addressing, or
broadcasts to every device on the channel. The UDP link accepts packets
only from the peer when the udp component sends to one unicast IPv4
address, and then provides addressing; the udp component now reports the
sender of each packet to its listeners.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:41 +11:00
clydeandClaude Opus 5.5 fe19e264ab [entity_transport] Reject a timeout on a link that does the session's job
TCP provides the session itself, so its config has no session key and
the timeout check let it through. update_interval then defaults to
never, and unchanged states timed out on a working link. Record the
layers each link class provides and check those too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:40 +11:00
clydeandClaude Opus 5.5 a77653a0b3 [tcp] Apply clang-format to the packet interface test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:40 +11:00
clydebarrowandClaude Opus 5.5 861e0aa0da [packet_interface] Share the search for a layer that needs a session between both checks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:40 +11:00
clydebarrowandClaude Opus 5.5 681d93ddba [tcp] Explain why the rolling code cannot be used on a TCP link
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:40 +11:00
clydebarrowandClaude Opus 5.5 dd26efb0ca [tcp] Fixes from code review
- Publish the connected sensor as off at setup, so a link that never
  comes up does not show as unknown
- Add a timeout option, defaulting to three keep-alive intervals and
  longer than one, as the peer's keep_alive sets how often packets
  arrive; limit keep_alive to 1 h and timeout to 3 h
- Load binary_sensor for an empty config from tooling
- Name the layer in the error when a provided layer is configured

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:40 +11:00
clydebarrowandClaude Code 8b5e3a1739 [tcp] Take the packet size from the receive buffer
The link takes the buffer as a std::span and derives max_packet_size
from its size, so a length beyond the buffer is refused and the
connection is closed.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-23 13:39:40 +11:00
clydebarrowandClaude Opus 5.5 df58794be0 [packet_interface] Keep App alive across tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:40 +11:00
clydebarrowandClaude Opus 5.5 01e7827898 [packet_interface] Fix clang-tidy findings in tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:40 +11:00
clydebarrowandClaude Opus 5.5 de5652f845 [tcp] Fixes from code review
- Reserve the link's sockets, so the socket pool is sized for them
- Test the server role: accepting a client, receiving from it, and
  accepting the next after it disconnects
- Test a frame assembled across several reads, after a keep-alive

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:40 +11:00
clydebarrowandClaude Opus 5.5 4a26a38cee [tcp] Keep the receive buffer in static memory
Code generation declares the TCP link's receive buffer as a static array
of max_packet_size bytes, so it is not allocated at run time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:40 +11:00
clydebarrowandClaude Opus 5.5 4e3f2b0e0b [packet_interface] Use a local to_vector() in tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:40 +11:00
clydebarrowandClaude Opus 5.5 b04c8947e0 [tcp] Add TCP packet interface link
Packets go over one TCP connection, as a client that connects to a host
or a server that accepts one client at a time. TCP resends what is lost
and joins exactly two devices, so the link provides the reliability,
addressing and session layers itself, and those cannot be configured on
it. A link platform names such layers with the new provides argument.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:40 +11:00
clydebarrowandClaude Opus 5.5 9f57216b9d [sx126x][sx127x] Stop reading a radio that fails while sends are held back
Also drop a leftover preamble_detect key from a test case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:40 +11:00
clydebarrowandClaude Opus 5.5 6d3b256191 [sx127x] Count only the sync word match as a packet arriving without LoRa
The preamble flag stays set when no sync word follows, so a packet from
another network held every send back until max_busy ran out. A link
without LoRa now needs a sync_value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:40 +11:00
clydebarrowandClaude Opus 5.5 f3e727dc48 [sx126x] Require a sync word for a link with FSK
Without one the radio cannot tell that a packet is arriving, so listen
before talk does nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:40 +11:00
clydebarrowandClaude Opus 5.5 36799051e4 [sx126x][sx127x] Fixes from code review
- Without LoRa, require the SX127x radio to have a sync word or preamble
  detection, as listen before talk cannot see a packet arriving otherwise
- Warn once when a send finds the radio failed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:40 +11:00
clydebarrowandClaude Opus 5.5 d4d91bc9d1 [sx126x][sx127x] Fixes from code review
- Warn when a send goes ahead over a busy period at its limit, as that
  means a stuck busy indication or a jammed channel
- Log an empty packet at verbose level, and say why it counts as sent
- Name the radio id if code generation cannot find its configuration

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:40 +11:00
clydebarrowandClaude Opus 5.5 ff6d33b39b [sx126x][sx127x] Say which callers send a refused packet again
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:40 +11:00
clydebarrowandClaude Opus 5.5 b0632892c5 [sx126x][sx127x] Require rx_start for layers that need replies
- Reject a radio with rx_start false when the link or a peer has a layer
  that needs packets back from the peer, as the radio sleeps after sending
- Log a send over a long busy period at debug level, so a lost reception
  can be traced

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:40 +11:00
clydebarrowandClaude Opus 5.5 dcc7c92a71 [sx126x][sx127x] Share the radio link code, and fixes from code review
- Make both links one RadioLink template in packet_interface, with shared
  code generation in radio_link.py, so a fix applies to both radios
- While sending is held back, watch the radio from loop() and end the
  busy period once it is free, so a later reception starts a new period
  instead of being sent over
- Reject an SX127x link without LoRa unless the radio is in packet mode

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:40 +11:00
clydebarrowandClaude Code 4614562856 [sx126x][sx127x] Take the packet size at construction
The radio links have no buffer of their own, so code generation passes
the configured max_packet_size to the constructor.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-23 13:39:40 +11:00
clydebarrowandClaude Opus 5.5 de6a9d5820 [sx126x][sx127x] Fixes from code review
- Hold a send back for at most the airtime of the largest packet, worked
  out from the radio settings and at least 2 s, instead of a fixed 2 s
  that a LoRa packet at a high spreading factor outlasts
- End a busy period when a send finds the radio free, so a busy flag that
  never clears is released however rarely sends are tried
- Detect an FSK reception on the SX127x from its preamble and sync word
  flags
- Test the radio links' final validation

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:40 +11:00
clydebarrowandClaude Opus 5.5 cb28c2fc86 [sx126x][sx127x] Add packet interface links for SX126x and SX127x radios
Each packet is one radio packet, with the RSSI and SNR of received
packets in the metadata. A radio cannot hear while it sends, so a link
refuses to send while its radio is receiving a packet, for up to two
seconds, and the layers above send again shortly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:40 +11:00
clydebarrowandClaude Opus 5.5 923893f6df [packet_interface] Name a peer in the error for a link used with peers
The link may have only one peer, so the error no longer says several.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:40 +11:00
clydebarrowandClaude Opus 5.5 72f767cb4f [packet_interface] Log packets and fragments too short for their header
Every other drop in these layers is logged at verbose level.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:40 +11:00
clydebarrowandClaude Opus 5.5 b007bbb01c [packet_interface] Check at validation that fragments fit the reliability queue
- Work out each layer's packet size during validation, as the C++ does,
  and reject a fragmentation max_packet_size whose fragments do not all
  fit in the reliability queue at once, or need more than 128 fragments
- Fit the test peer's largest packet in its queue
- Reuse interface_layers() in final_validate_interface()
- Keep the earlier tests in place in test_packet_interface.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:39 +11:00
clydebarrowandClaude Opus 5.5 7ac9ba6daf [packet_interface] Use the shared max_packet_size constant in the fragmentation layer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:39 +11:00
clydebarrowandClaude Opus 5.5 8b752525f5 [entity_transport] Find an addressing peer's layers for the timeout check
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:39 +11:00
clydebarrowandClaude Opus 5.5 07e1195ffa [packet_interface] Route broadcasts to the broadcast port, and keep a join
A broadcast from a device with its own port went to that port, whose
layers drop it, so a broadcast port could not reach reliable peers. It now
goes to the broadcast port when there is one.

Fragmentation allocates its buffers with RAMAllocator and fails setup when
they cannot be allocated. A fragment from another source no longer drops
the packet being joined.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:39 +11:00
clydebarrowandClaude Code 5ef072d03d [packet_interface] Take the addressing and fragmentation sizes at construction
AddressingPort derives max_packet_size from the link in its constructor,
and the fragmentation layer passes its configured size to the base
constructor.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-23 13:39:39 +11:00
clydebarrowandClaude Opus 5.5 4d61784ba6 [packet_interface] Keep App alive across tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:39 +11:00
clydebarrowandClaude Opus 5.5 be4ea37f21 [packet_interface] Fix clang-tidy findings in tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:39 +11:00
clydebarrowandClaude Opus 5.5 9915cf6827 [packet_interface] Fixes from code review
- Reject an addressing destination or peer that is this device's own
  address, whose packets would be dropped as echoes
- Show 0 fragments, not SIZE_MAX, for a link with no room
- Test the check that applications use a peer's id

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:39 +11:00
clydebarrowandClaude Opus 5.5 beeeee07e8 [packet_interface] Use a local to_vector() in tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:21 +11:00
clydebarrowandClaude Opus 5.5 e9c386f57e [packet_interface] Leave no room, instead of underflowing, on a small link
As in the other layers, addressing ports and fragmentation get no room
when max_packet_size is smaller than their header, and fragmentation
fails its setup instead of dividing by zero.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:21 +11:00
clydebarrowandClaude Opus 5.5 01fbfeb16a [packet_interface] Add addressing and fragmentation layers
The addressing layer lets several devices share a channel such as an
RS485 bus or a radio, with 16-bit addresses. A link can carry several
peers, each with its own stack of layers and its own id. Layers that work
between two devices only are rejected with broadcast addressing. The
fragmentation layer splits packets larger than the layers below allow,
and joins them again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:21 +11:00
pre-commit-ci-lite[bot] 369354a116 apply automatic formatting fixes 2026-10-23 13:39:03 +11:00
clydebarrowandClaude Opus 5.5 105d1bb088 [entity_transport] Warn when packets are refused for long, and check every complete dictionary
- Set a warning once the interface has accepted no packets for a minute,
  and log refused dictionary packets as data packets are
- Clear remote entities that a provider's dictionary does not name also
  when the provider goes back to a dictionary already seen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:03 +11:00
clydebarrowandClaude Opus 5.5 1488af2b32 [entity_transport] Fixes from code review
- Check a data packet before asking for its dictionary, so a damaged
  packet does not make the provider resend everything
- When a provider's complete dictionary does not name a remote entity,
  warn and clear that entity's state, as a typo in provider or remote_id,
  or an entity the provider no longer sends, was otherwise silent

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:03 +11:00
clydebarrowandClaude Opus 5.5 74b8de8064 [entity_transport] Ask again for a dictionary with a lost part, and warn on dropped packets
A dictionary packet now carries its part index and a last-part flag. A dictionary counts as received, and is not
asked for again, only once its last part follows every other part in order. Until then, data under it sends a
request even when some remote entities are already known.

A refused request no longer starts the request interval. A short or malformed packet, or one with another format
version, is logged as a warning at most once a minute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:03 +11:00
clydebarrowandClaude Opus 5.5 5393d192b4 [entity_transport] Reject a timeout on a packet interface with a session layer
With a session and a reliability layer, the update interval defaults to
never, and unchanged states are not sent again, so a receiver's timeout
cleared them on a working link. The session layer already clears the
states when the other device cannot be reached.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:03 +11:00
clydebarrowandClaude Opus 5.5 670934b3d8 [entity_transport] Tag each data record, and check whole packets before use
Each data record now starts with a 16-bit tag holding the entity's number
and type, and the form of its payload, so that more entity types can be
added and a receiver can skip records of types it does not know. A binary
sensor's state is in the tag; a sensor has a 4 byte state, or none.

Data and dictionary packets are checked in full before anything in them
is used, so a damaged or forged packet changes no state and teaches nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:03 +11:00
clydebarrowandClaude Opus 5.5 f76dce46aa [entity_transport] Use the shared provider and remote id constants
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:03 +11:00
clydebarrowandClaude Opus 5.5 d44bccba3f [entity_transport] Ask only for dictionaries not yet received
A dictionary that names none of the remote entities was asked for again
every 5 seconds while any remote entity was unresolved, and each request
made its provider resend everything. A provider's new dictionary was not
asked for once every remote entity was resolved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:03 +11:00
clydebarrowandClaude Opus 5.5 5b35282546 [entity_transport] Fail when the packet buffer cannot be allocated
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:03 +11:00
clydebarrowandClaude Code aa1d7fa26b [entity_transport] Set the test link's size at construction
The mock link takes max_packet_size in its constructor, so a test that
needs a different size builds a local link.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-23 13:39:03 +11:00
clydebarrowandClaude Opus 5.5 3862504cdd [packet_interface] Keep App alive across tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:03 +11:00
clydebarrowandClaude Opus 5.5 ee3efdf15e [entity_transport] Brace multi-line if bodies
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:03 +11:00
clydebarrowandClaude Opus 5.5 db46ed33a2 [packet_interface] Fix clang-tidy findings in tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:03 +11:00
clydebarrowandClaude Opus 5.5 e09257a992 [packet_interface] Use a local to_vector() in tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:03 +11:00
clydebarrowandClaude Opus 5.5 1fa3dd37fe [entity_transport] Fixes from code review
- Compare received ids by length and bytes, so an id with an embedded NUL
  cannot read past the configured string
- Fail setup when max_packet_size cannot hold the device name and the
  longest entity id, instead of skipping the id at every resend
- Reject two entities of one type sent with the same id
- Avoid an unused variable without remote entities
- Use matching ids in the test configuration

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:03 +11:00
clydebarrowandClaude Opus 5.5 a7ca6a9608 [entity_transport] Add entity transport component
Sends sensor and binary sensor states to other devices over a packet
interface. A dictionary sent on connect, on request and at an interval
names the entities, so data packets refer to them by number. The update
interval and the handling of a lost peer follow the layers the interface
has, and remote states can time out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:03 +11:00
clydeandClaude Opus 5.5 e046551825 [packet_interface] Stretch the XXTEA key text with PBKDF2
A single SHA-256 of the key text let a captured packet check about 2^30
guesses for every one that passes, so a weak key text fell quickly to
an offline search. Derive the key with PBKDF2-HMAC-SHA256 at 600,000
iterations instead; this runs at build time, so the device pays nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:39:00 +11:00
pre-commit-ci-lite[bot] 18b5b7a24a apply automatic formatting fixes 2026-10-23 12:28:10 +10:00
clydebarrowandClaude Opus 5.5 179d20ba77 [packet_interface] Limit the session keep_alive to 1 h and timeout to 3 h
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:28:10 +10:00
clydebarrowandClaude Opus 5.5 1bc4779d96 [packet_interface] Fail the XXTEA layer when its buffers cannot be allocated
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:28:10 +10:00
clydebarrowandClaude Opus 5.5 366713ba05 [packet_interface] Check the layers in the link schema
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:28:10 +10:00
clydebarrowandClaude Opus 5.5 47b8aae1b5 [packet_interface] Require a session layer with the rolling code
The rolling code accepts a packet only once the peer has sent back this
device's boot number, so a device that only receives never gets a packet.
The session's keep-alives make sure packets go both ways.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:28:10 +10:00
pre-commit-ci-lite[bot] a9c155e4b4 apply automatic formatting fixes 2026-10-23 12:28:10 +10:00
clydebarrowandClaude Code 708e117a20 [packet_interface] Take the session and encryption sizes at construction
Both layers derive max_packet_size from the layer below in their
constructors instead of assigning it in the body.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-23 12:28:10 +10:00
clydebarrowandClaude Opus 5.5 c6d1c63884 [packet_interface] Keep App alive across tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:28:10 +10:00
clydebarrowandClaude Opus 5.5 faef5a98bf [packet_interface] Fix clang-tidy findings in tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:28:10 +10:00
clydebarrowandClaude Opus 5.5 7c86318c1a [packet_interface] Use a local to_vector() in tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:28:10 +10:00
clydebarrowandClaude Opus 5.5 58ef6ef3d9 [packet_interface] Fixes from code review
- Fail setup, instead of underflowing, when max_packet_size leaves the
  encryption or session layer no room
- Warn when the boot number cannot be saved
- Warn once when the peer's packets carry an older boot number, which
  happens when its flash is erased
- Test the counter wrapping to a new boot number

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:28:10 +10:00
clydebarrowandClaude Opus 5.5 a6d2ae15a1 [packet_interface] Add session and XXTEA encryption layers
The session layer sends keep-alives and reports whether the peer can be
reached, including after it restarts or a reliability layer below loses
data. The encryption layer encrypts and checks each packet with XXTEA,
with an optional rolling code against replays. Components that a layer
needs, such as binary_sensor, are loaded only when it is configured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:28:10 +10:00
clydebarrowandClaude Opus 5.5 63cfdf2b4d [packet_interface] Reject a layer type registered after the schemas are built
Such a type would be missing from the link schema, and selecting it would
fail with an unrelated error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:28:10 +10:00
clydebarrowandClaude Opus 5.5 6411a8062f [packet_interface] Log a full queue during a link reset only once
While the peer is away the layers above keep sending, so the message
repeated about once a second until the link came up again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:28:10 +10:00
clydebarrowandClaude Opus 5.5 c18ac5bac9 [packet_interface] Count the link reset tries outside the condition
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:28:10 +10:00
clydebarrowandClaude Opus 5.5 78396dd142 [packet_interface] Warn when the HDLC link cannot come up
A frame larger than max_packet_size, usually from a peer with a larger
setting, is warned about once. A link reset that the peer does not answer
is warned about after max_retries tries, and a full queue is logged at
debug level while the link is down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:28:10 +10:00
clydebarrowandClaude Code 557e3c3e9e [packet_interface] Reject a duplicate layer type name
Every module in layers/ is imported automatically, so two modules
registering the same name silently replaced one with the other, in an
order decided by pkgutil. Registration now raises, so the mistake is
found when the configuration is loaded.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-23 12:28:10 +10:00
clydebarrowandClaude Code 8fd05e93b4 [packet_interface] Answer a repeated SABM without resetting the link
A peer that has not heard the UA repeats its SABM, and cannot have sent a
numbered frame meanwhile. Treating that as a restart threw away queued
frames and reported the session lost for a single dropped UA. A SABM is
now a restart only if the peer has sent a numbered frame since the link
came up.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-23 12:28:10 +10:00
clydebarrowandClaude Code f81c6847f3 [packet_interface] Take the reliability layer's size at construction
max_packet_size is derived from the layer below in the constructor
instead of being assigned in the body.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-23 12:28:10 +10:00
clydebarrowandClaude Opus 5.5 d555d64300 [packet_interface] Fixes from code review
- Keep App alive across the HDLC tests, as the test main requires
- Run a layer's loop in the tests only while it has asked for it, so
  the loop's disable and request bookkeeping is tested, and wait for the
  retry timer in the tests of refused sends
- Report no send capacity while the layer has no send queue

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:28:10 +10:00
clydebarrowandClaude Opus 5.5 267e3d5d70 [packet_interface] Fix clang-tidy findings in tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:28:10 +10:00
clydebarrowandClaude Opus 5.5 2da760ea4f [packet_interface] Use a local to_vector() in tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:28:10 +10:00
clydebarrowandClaude Opus 5.5 0b34f811a4 [packet_interface] Fixes from code review
- Reset the link after max_retries even when it is 255, where the retry
  counter used to wrap
- Require a reliability timeout of at least 50 ms
- Drop received frames larger than the layer's max_packet_size
- Fail setup, instead of aborting, when the send queue cannot be
  allocated or max_packet_size leaves no room
- Test the ack delay timer and a refused REJ

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:28:10 +10:00
clydebarrowandClaude Opus 5.5 28833fe0f3 [packet_interface] Add layer registry and HDLC reliability layer
Layers stack on a link and are configured on its packet_interface entry.
Each layer type registers itself with its kind, so a new type needs only
a new module. The first is a reliability layer in the style of HDLC
asynchronous balanced mode, with go-back-N retransmission.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:28:10 +10:00
clydebarrowandClaude Code 929aac8fac [udp] Take the packet size from the copy buffer
The link derives max_packet_size from the span it is given, so a packet
larger than the copy buffer cannot be copied into it.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-23 13:28:08 +11:00
clydebarrowandClaude Opus 5.5 11cc5bb008 [udp] Limit a split packet to the copy buffer
The copy buffer is sized by code generation to max_packet_size, but
set_max_packet_size() can raise the limit afterwards, and copying a split
packet would then overflow it. The link now takes the buffer as a span
and refuses a split packet larger than it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:28:08 +11:00
clydebarrowandClaude Opus 5.5 2d6be6c27a [udp] Drop datagrams longer than the receive buffer
A read cuts a datagram longer than the buffer to fit, so a 600 byte
datagram arrived as a valid looking 508 byte packet. The buffer is now one
byte longer, and a read that fills it is dropped. The split packet test
also checks the bytes joined in the copy buffer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:28:08 +11:00
clydebarrowandClaude Opus 5.5 bfa6e68039 [packet_interface] Fix clang-tidy findings in tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:28:08 +11:00
clydebarrowandClaude Opus 5.5 c39566f312 [udp] Keep the copy buffer in static memory
Code generation declares the UDP link's buffer for packets split across
buffers as a static array of max_packet_size bytes, so it is not
allocated at run time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:28:08 +11:00
clydebarrowandClaude Opus 5.5 5197140f2c [packet_interface] Use a local to_vector() in tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:28:08 +11:00
clydebarrowandClaude Opus 5.5 ee0c6a412e [udp] Fixes from code review
- Enforce max_packet_size on send and receive
- Copy split packets into a buffer allocated in setup, not on the stack
- Add unit tests for the link's send and receive paths
- Explain why the broadcast socket is always enabled

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:28:08 +11:00
clydebarrowandClaude Opus 5.5 373c6398b2 [udp] Add UDP packet interface link
Each packet is one UDP datagram, using the settings of the udp component.
The udp component now reports whether a packet was sent, and an ENOMEM
refusal from lwIP is logged at verbose level as the packet can be sent
again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:28:08 +11:00
8156de9613 [packet_interface] Add packet interface component (#21037)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-23 13:28:03 +11:00
Jonathan Swoboda 89c2cdb592 [core][hmac_sha256] Compare digests in constant time (#21443) 2026-10-22 22:24:30 -04:00
Jonathan Swoboda cb57148178 [syslog] Redact LOG_SECRET values before sending a message (#21444) 2026-10-22 22:13:30 -04:00
Clyde StubbsandClaude Opus 5.5 dcfad5c439 [runtime_image] Decode at the smallest possible JPEGDEC scale (#21386)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:57:05 +11:00
Clyde StubbsandClaude Opus 5.5 e08cbc5661 [online_image] Add on_download_started and on_download_progress (#21355)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:38:13 +11:00
Clyde StubbsandClaude Opus 5.5 2b183be544 [online_image] Decode a JPEG as it downloads (#21342)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:38:13 +11:00
J. Nick Koston 6a18470ccb [tests] Let LineWaiter wait for several log patterns at once (#21153) 2026-10-22 15:05:30 -10:00
J. Nick Koston e9aaf84ee5 [tests] Add helpers to subscribe after initial states and wait for the next state (#21151) 2026-10-22 15:05:04 -10:00
rwrozelleandClaude Opus 5.5 69785f56f6 [ota] Fit UDP OTA datagrams in Thread's 1280-byte IPv6 MTU (#21429)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:10:31 -10:00
J. Nick Koston 61b52c0a34 [esp32_hosted] Accept a verified image another build cached when Windows refuses the rename (#21431) 2026-10-23 00:10:00 +00:00
esphome[bot] 54c4cc81c5 Bump aioesphomeapi from 46.21.0 to 46.22.0 (#21434) 2026-10-23 00:09:49 +00:00
02ea1b2696 [seeed_multi_channel_relay] Add the Seeed Studio Grove multi-channel relay boards (#7233)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-22 14:03:45 -10:00
Bonne EgglestonandJ. Nick Koston 16023df4fd [cw2017] Add CW2017 battery fuel gauge (#21369)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-22 13:58:54 -10:00
0fd72f74f1 [islandaire] Add Islandaire PTAC climate IR component (#20153)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-22 13:43:32 -10:00
sebo-b 4254d76e5c [ln882x_spi_led_strip] Add SPI DMA addressable LED strip for LN882x (#21409) 2026-10-22 13:30:06 -10:00
7605958db5 [spi] Add nRF52 (Zephyr) hardware SPI support (#18214)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: tomaszduda23 <tomaszduda23@gmail.com>
2026-10-22 13:20:26 -10:00
puddlyandJ. Nick Koston 35ea89859b [usb_uart] Implement hardware flow control (#20823)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-22 22:51:54 +00:00
J. Nick Koston fd7a0d3679 [components] Restore switches with Switch::restore() (#21428) 2026-10-22 12:48:39 -10:00
backcountrymountainsandJ. Nick Koston dc37e06143 [light] Add `effect to initial_state` (#18795)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-22 12:44:36 -10:00
9226e03496 [m5stack_4relay] m5stack 4relay switch implementation (#7234)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-22 12:40:37 -10:00
J. Nick Koston a4fff7738b [ethernet] Check the RGMII ESP-IDF floor with the shared validator (#21430) 2026-10-22 18:29:56 -04:00
4242bcb084 [vnc] Add the Hextile encoding and skip unchanged tiles (#20625)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-10-22 21:52:53 +00:00
J. Nick Koston 8a88e97497 [weikai] Share print_buffer between the SPI and I2C drivers (#20721) 2026-10-22 21:21:41 +00:00
Frédéric MetrichandClaude d586e20a31 [teleinfo] Fix default units of unitless, text and PMAX tags (#21367)
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-22 17:21:09 -04:00
e6161e64b1 [rx8111] Add alarm and wake-up timer support (#21217)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-22 21:14:37 +00:00
J. Nick Koston 431843c27e [tests] Use LineWaiter for the integration tests that parse or count log lines (#21306) 2026-10-22 11:14:34 -10:00
J. Nick Koston a1da3d6b90 [unicode] Stop reading past a truncated UTF-8 sequence (#21374) 2026-10-22 11:14:17 -10:00
J. Nick Koston 930429061a [tests] Use LineWaiter for the integration tests that only wait on log lines (#21305) 2026-10-22 11:14:01 -10:00
J. Nick Kostonandpre-commit-ci-lite[bot] afceb91d90 [tests] Use wait_or_fail for the remaining integration test timeouts (#21304)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-10-22 11:13:55 -10:00
J. Nick Koston 7d741361a3 [weikai] Keep the register name tables in flash (#20712) 2026-10-22 11:13:04 -10:00
miloit 9c70ffd826 [ds3231] Add time platform, alarms, binary sensors and output control (#21054) 2026-10-22 10:58:28 -10:00
J. Nick Koston dac6a9c8cc [cover] Pack the cover traits flags into one byte (#21339) 2026-10-22 16:53:20 -04:00
J. Nick Koston a3a4144e4f [valve] Pack the valve traits flags into one byte (#21343) 2026-10-22 16:52:35 -04:00
J. Nick Koston 29e9ae008d [lock] Pack the lock traits flags into one byte (#21344) 2026-10-22 16:52:05 -04:00
Bonne Eggleston c92c4d7f99 [deep_sleep] Add woke_from_deep_sleep() and build get_wakeup_cause() without on_wake (#21425) 2026-10-22 10:49:15 -10:00
5bcc3f42e9 [switch] Add initial_state and restore_state, with flash or RTC storage (#20978)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-22 10:35:23 -10:00
Jonathan Swoboda d6aa5ba14c [esp32] Share one validator for options that need a newer ESP-IDF (#21422) 2026-10-22 10:20:22 -10:00
Clyde StubbsandClaude Opus 5.5 59c7178910 [runtime_image] Decode JPEG a step at a time (#21341)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 06:52:41 +11:00
J. Nick Koston e55741a26b [usb_uart] Read the channel limit when the config is validated, not at import (#21331) 2026-10-22 09:34:55 -10:00
dependabot[bot] 0bdc14154b Bump prek from 0.5.5 to 0.5.6 (#21419)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-22 19:32:52 +00:00
dependabot[bot] 1d6f839ee7 Bump platformdirs from 4.13.4 to 4.13.5 (#21418)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-22 19:32:20 +00:00
dependabot[bot] 2544518aca Bump filelock from 4.2.2 to 4.2.4 (#21417)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-22 19:32:17 +00:00
dependabot[bot] 70e56df630 Bump j178/prek-action from 3.0.1 to 3.0.2 (#21420)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-22 19:29:54 +00:00
Jonathan Swoboda 7d62aae996 [wifi] Allow the lease cache and a second WISP DNS server on the ESP32 Arduino framework (#21412) 2026-10-22 15:27:16 -04:00
c12baaa337 [mqtt] Support stop tilt on covers (#20251)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-22 19:14:24 +00:00
Jonathan Swoboda cb9eda3681 [spi] Gate psram_dma on the ESP-IDF version rather than the framework (#21411) 2026-10-22 14:48:04 -04:00
J. Nick Koston 4e27957c74 [ci] Add the UART loopback, WISP bridge, raw API action and LD2412 flows to the hardware-test skill (#21356) 2026-10-22 08:46:37 -10:00
Roman PriesolandJ. Nick Koston 0856211b9b [voice_assistant] Allow IPv4 to be disabled (#21389)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-22 08:46:26 -10:00
J. Nick Koston ff4ff5c5f5 [restart][shutdown][factory_reset][safe_mode] Use Apply buttons (#21158) 2026-10-22 08:37:53 -10:00
J. Nick Koston 980b336668 [core] Let Apply entities run a free statement without a parent hub (#21157) 2026-10-22 08:37:52 -10:00
J. Nick Kostonandpre-commit-ci-lite[bot] a3cb3732ee [sx127x][sx126x][cc1101][udp][uart] Send byte payloads with ApplyBytes (#21263)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-10-22 08:19:09 -10:00
Jonathan Swoboda ab50b3830a [wifi] Allow WISP mode on the ESP32 Arduino framework (#21410) 2026-10-22 18:10:47 +00:00
Roman Priesol 3131cf28a3 [esphome] Fix OTA build with IPv4 disabled (#21388) 2026-10-22 08:07:17 -10:00
Jonathan Swoboda 13683fa163 [tests] Match the format strings in test lambdas to their argument types (#21406) 2026-10-22 17:59:25 +00:00
J. Nick Koston 01d29001bb [core] Add assign_bits to set or clear register bits from a bool (#21184) 2026-10-22 07:58:37 -10:00
Jonathan Swoboda 9a5fcbce89 [esp32_hosted] Move to esp_hosted 3.0.10 (#21394) 2026-10-22 13:44:04 -04:00
Jonathan Swoboda ce83a625c1 [ms8607] Interpolate the humidity in float (#21405) 2026-10-22 07:42:32 -10:00
Jonathan Swoboda 43732e382e [tmp1075] Write the configuration register in datasheet bit order (#21399) 2026-10-22 13:40:33 -04:00
Jonathan Swoboda 6d1f68af8d [ledc] Reset the peripheral through the HAL on targets without the legacy periph API (#21397) 2026-10-22 13:39:32 -04:00
Jonathan Swoboda adeea9da98 [core] Keep the color codes of a filtered build output line (#21396) 2026-10-22 07:37:21 -10:00
Jonathan Swoboda 946de4223f [esp32] Require ESP-IDF 5.1 and set software coexistence through its Kconfig name (#21398) 2026-10-22 07:36:22 -10:00
Jonathan Swoboda 0ccac0c291 [wifi] Log the lease seconds with PRIu32 (#21403) 2026-10-22 07:33:50 -10:00
Jonathan Swoboda fc1e4bf816 [psram] Allow external task stacks through the Kconfig name ESP-IDF 5.4 uses (#21401) 2026-10-22 07:33:38 -10:00
Jonathan Swoboda 5df921b4e0 [emontx] Initialise the line length before the reader fills it (#21404) 2026-10-22 07:32:50 -10:00
J. Nick Koston fb27e003f3 [time] Make the BCD calendar helpers a template over the chip's layout (#21190) 2026-10-22 07:29:56 -10:00
J. Nick Koston f277476de0 [core] Add ApplyBytes for apply actions that forward a byte payload (#21262) 2026-10-22 07:29:28 -10:00
J. Nick Koston 9673e73893 [light][homeassistant] Drop a held light report when any call comes after it (#21144) 2026-10-22 07:28:52 -10:00
Jonathan Swoboda b722812bd8 [hdmi_cec] Declare the always_inline atomic helpers inline (#21402) 2026-10-22 13:09:43 -04:00
Jonathan Swoboda 8fde15e789 [nrf52] Silence double promotion warnings for the app's variadic log arguments (#21400) 2026-10-22 13:09:10 -04:00
J. Nick KostonandJonathan Swoboda c48d219dc0 [climate] Add StringRef overloads for ClimateCall::set_fan_mode and set_preset (#21359)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-10-22 16:48:44 +00:00
J. Nick Koston 3bed3f3d4f [http_request] Copy a manifest URL set at run time and keep the config literal by pointer (#21373) 2026-10-22 12:40:26 -04:00
J. Nick Koston 4220cbe63e [mqtt] Resend the fan state when its first publish fails (#21328) 2026-10-22 12:36:49 -04:00
d7bc501095 [esp32_hosted] Download and cache pinned embedded firmware (#20065)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-10-22 12:20:49 -04:00
J. Nick Koston a04032b345 [light] Add a StringRef overload for LightCall::set_effect (#21358) 2026-10-22 06:54:49 -04:00
Clyde Stubbs 611d8cfb4d [core] Keep a library's optimisation flags to its own component (#21375) 2026-10-22 21:11:10 +11:00
7dace44d61 [api] Add optional user-defined action variables with defaults (#18882)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-21 22:48:28 -10:00
esphome[bot] 491f98f982 Bump aioesphomeapi from 46.20.0 to 46.21.0 (#21371) 2026-10-22 07:43:14 +00:00
Clyde StubbsandClaude Opus 5.5 619283d74a [mipi_spi] Run the T-Display S3 Pro panel at 40 MHz (#21370)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:26:30 -10:00
18348b8794 [vnc] Add VNC client component (#20623)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-10-21 21:14:37 -10:00
f7dc2ecb51 [wifi] [ethernet] Bridge the WISP access point to an ethernet upstream (#7147)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-22 07:02:25 +00:00
ddcc5874ed [api] Let a media_player ask for a constant bitrate (#19962)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-22 06:17:59 +00:00
esphome[bot] 28013a60e1 Bump aioesphomeapi from 46.19.0 to 46.20.0 (#21362) 2026-10-21 19:19:35 -10:00
546 changed files with 30626 additions and 4541 deletions

No files matched your search

+1 -1
View File
@@ -115,7 +115,7 @@ Per-topic scripts, each described in the matching `reference/recipes/<component>
(host driver, Zephyr driver probe), `rtsp_server/` (protocol client, raw rate reader, measurement set,
API image grab), `cse7766/` (API observer with ping), `zephyr_coredump/` (reader flow, decode with the
Zephyr tools), `libretiny_prefs/` (rtc preference harness header), `libretiny_crash/` (BK72xx crash probe header and
capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `ld2450/` (zone number driver), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player), `speaker_media_player/` (API player driver, pipeline task case runner), `web_server/` (SSE client, request mix, SSE and HTTPD case runners), `mqtt/` (broker config with TLS, client-certificate and WebSocket listeners, test CA with server and client certificates, transport case runner, flash-and-drive runner, CLI TLS check without a board), `udp/` (datagram recorder, `udp.write` payload checker and case driver), `entity_icons/` (icon and device class readback over the API, MQTT discovery, web_server and dump_config, with a comparer).
capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `ld2450/` (zone number driver, loopback harness driver with number and binary sensor states and TX frame summarizer), `api/` (raw plaintext native API client for user defined actions), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player), `speaker_media_player/` (API player driver, pipeline task case runner), `web_server/` (SSE client, request mix, SSE and HTTPD case runners), `mqtt/` (broker config with TLS, client-certificate and WebSocket listeners, test CA with server and client certificates, transport case runner, flash-and-drive runner, CLI TLS check without a board), `udp/` (datagram recorder, `udp.write` payload checker and case driver), `entity_icons/` (icon and device class readback over the API, MQTT discovery, web_server and dump_config, with a comparer).
## Templates in `$SKILL/templates`
@@ -7,6 +7,9 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
- Opening the port usually resets the board through DTR, but not always; `serial_capture.py --reset`
pulses RTS to capture from boot. 74880 baud shows the ROM boot messages (`rst cause`, `boot mode`).
- UART0 can be looped back onto itself (`USC0(0) |= (1 << UCLBE)` in an `on_boot` lambda) so a UART
component is testable without its device: frames written with `uart.write` arrive on RX. Log over the
API with `logger: baud_rate: 0`, since the serial port carries the component's bytes.
- `esphome logs` on some CH340s emits thousands of duplicated garbage lines right after the RTS reset;
a plain pyserial capture at 115200 is reliable.
- A failed esptool write can wedge the adapter (`termios` error 22, or an open that hangs) until replug.
@@ -4,12 +4,13 @@ Each flow names the pull requests it was used on, so their result tables show wh
One file per component, named after it:
- [`api.md`](api.md): noise handshake and OTA encryption on the ESP8266, outgoing connection wake timing on deep sleep and after a Wi-Fi drop
- [`api.md`](api.md): noise handshake and OTA encryption on the ESP8266, outgoing connection wake timing on deep sleep and after a Wi-Fi drop, user defined action listing and execution with a raw plaintext client
- [`captive_portal.md`](captive_portal.md): fallback AP and /wifisave hammer
- [`cse7766.md`](cse7766.md): Sonoff S31 parser check over the API
- [`espnow.md`](espnow.md): ESP8266 broadcast receive
- [`hdmi_cec.md`](hdmi_cec.md): two-board wire
- [`ld2450.md`](ld2450.md): polygon zones, zone number round trip through the radar's readback
- [`ld2450.md`](ld2450.md): polygon zones, zone number round trip through the radar's readback, ESP8266 loopback harness without a radar
- [`ld2412.md`](ld2412.md): output pin level through the basic configuration command, held back writes
- [`libretiny.md`](libretiny.md): rtc preference emulation, BK72xx crash report on the next boot
- [`mqtt.md`](mqtt.md): broker bench, QoS, reconnect, crash log over MQTT, WebSocket transports with a local and a public broker, TLS trust sources (own CA, client certificate, certificate bundle, no verification) with the CLI check
- [`nrf52.md`](nrf52.md): dongle board support
@@ -20,7 +21,7 @@ One file per component, named after it:
- [`scheduler.md`](scheduler.md): loop wake A/B
- [`udp.md`](udp.md): datagrams, multicast, sockets, `udp.write` payload shapes
- [`usb_cdc_acm.md`](usb_cdc_acm.md): nRF52840 dongle host driver
- [`wifi.md`](wifi.md): reboot_timeout with a fallback AP, DHCP lease cache, Easy Connect (DPP)
- [`wifi.md`](wifi.md): reboot_timeout with a fallback AP, DHCP lease cache, Easy Connect (DPP), WISP bridge with an ethernet upstream
- [`zephyr.md`](zephyr.md): preferences on the nRF52840 dongle
- [`zephyr_coredump.md`](zephyr_coredump.md): crash and decode on nRF52 boards
- [`zwave_proxy.md`](zwave_proxy.md): frame checksums on a live proxy
@@ -28,3 +28,15 @@ connected first, and the loop-time maxima. For the loop cost of polling, repeat
so after a drop every dial is closed and HA's incoming reconnect wins 11 to 33 s later; the measurable effect is the
first dial landing in the loop the network flag flips.
Results: https://github.com/esphome/esphome/pull/21085#issuecomment-6254742791
## User defined action listing and execution without a client library (#18882)
`scripts/api/rawactions.py HOST list` speaks the plaintext native API over a raw socket and decodes every
`ListEntitiesServicesResponse` by field number (argument fields 1 name, 2 type, 3 description, 4 example, 5 optional,
6 default_value), so new wire fields show up without waiting for the client library. `rawactions.py HOST exec ACTION
k=v ...` sends `ExecuteServiceRequest` with `sint32` ints (field 5, not the legacy int32 field 2), fixed32 floats,
length delimited strings and `int[]` as repeated sint32 (field 7); `ALLOW_MISSING=1` leaves arguments out on purpose
(the device then drops the call). `templates/api-actions-dut.yaml` (D1 mini, three actions, optional variables with
defaults) and `templates/api-actions-strings-dut.yaml` (required strings) log what arrived. String arguments are
`StringRef`s that are not null terminated: copy them with `snprintf("%.*s", size, c_str())` into a buffer before
logging, and do not pass `%.*s` to `logger.log`, whose validator counts it as one pattern.
@@ -0,0 +1,13 @@
# ld2412
## Output pin level and the basic configuration command (#21353)
`templates/ld2412-select-only-dut.yaml` (ESP32-S3, the module on GPIO43 and GPIO44 at 115200, only the output pin
level select, the module's OUT pin on GPIO38 as a binary sensor plus the presence sensor) with `uart: debug:
direction: BOTH`. Drive the select with `scripts/ld2450/lddrive.py HOST KEY LOGFILE sel:NAME=OPTION` and read the TX
dump for command `02` carrying the level byte, then the OUT pin binary sensor against presence: with level high the
pin follows presence, with level low it is inverted. A module that never answers the boot query (no `<<<` lines)
only shows the held back path: `Command 02 held back until the module reports its settings`, a query `12`, and after
2 s `Command 02 not sent, the module did not report its settings` with the select republished to the last known
level. With the full set of numbers configured a select change also needs every number to have a state, so wait
for the boot query answer before judging.
@@ -13,3 +13,30 @@ selects over the API and prints every LD2450 state update. After each set the hu
the radar and republishes all twelve coordinates from the answer, so set each coordinate of each zone to a distinct
value and check the readbacks; no target is needed. Readbacks can arrive late over Wi-Fi, so match them by content.
Results: https://github.com/esphome/esphome/pull/21146#issuecomment-6278026922
## ESP8266 loopback harness, no radar needed (#21349)
`templates/ld2450-loopback.yaml` (package; substitutions `name` and `baud`) runs the hub on UART0 (GPIO1 and GPIO3)
with `logger: baud_rate: 0` and sets the UART0 loopback bit in `on_boot` (`USC0(0) |= (1 << UCLBE)`), so everything
the hub or a `uart.write` action sends comes back on RX. Template buttons inject periodic frames (all four direction
strings, targets 2 and 3), query zone acks (zone type 0, 1 and 2 for the select readback) and call the name based
setters with a valid and a bogus name. `uart: debug: direction: TX` delimited on the command footer dumps every
command the hub sends. Drive it with `scripts/ld2450/lddrive.py HOST KEY LOGFILE press:NAME sel:NAME=OPTION wait:S`
(logs over the API at VERBOSE, 2 s between steps because the API log stream drops lines while `send_command_`
blocks), then `txsum.py LOGFILE` lists the TX frames per step; diff the summaries of the two builds. The hub reads its
own commands back and logs `Invalid status` for each, which is harmless. The boot readback of the baud rate select
comes from the UART setting, so a second build at `baud: "9600"` checks the flash table lookup.
Results: https://github.com/esphome/esphome/pull/21349#issuecomment-6332320026
## Frame splitter collisions (#21357)
The loopback template also carries the zone 1 numbers and seven collision buttons: a periodic frame with the
command footer inside target 1, zone acks with `55 CC` and with `04 03 02 01` inside the coordinates, a periodic
frame cut after 12 bytes followed by a zone ack, a frame missing its last byte followed by a complete one, a bogus
255 byte length then a frame, and garbage then a frame. Judge by the state updates plus the `Handling Periodic` and
`Handling Ack` lines. Through a real radar the set zone ack is sometimes corrupted by the radar itself (its tail
replaced by the periodic frame it was sending, for example `04 03 03 03 00 ... 55 CC`), so count readbacks over at
least 24 selects per build and read the raw bytes with `uart: debug: direction: RX` (`!extend` the package's uart
id) before blaming the parser; the API log stream drops lines under that dump volume, so some misses stay
unclassified.
Results: https://github.com/esphome/esphome/pull/21357#issuecomment-6333244886
@@ -23,3 +23,17 @@ Only an Android phone configures: Settings, network details, Add device, Share W
QR scanner makes the phone an enrollee). Render the URI with the `qrcode` module and open the PNG.
`esp32: framework: log_level: DEBUG` plus `sdkconfig_options: CONFIG_WPA_DEBUG_PRINT: y` prints the
supplicant state machine.
## WISP bridge with an ethernet upstream (#7147)
`templates/wisp-ethernet-dut.yaml` (ESP32-S3 with a W5500, `wifi: ap:` with `ap_mode: WISP` and no station) and
`templates/wisp-client.yaml` (D1 mini joining the bridge, `http_request` to a web server on the wired LAN and to
`http://example.com/` every 15 s, `time: sntp`). Secrets: `wisp_psk` and `lan_http` (serve a text file on the host
with `python -m http.server 8787 --bind 0.0.0.0` from a scratch directory). Judge by the client's `GET status 200`
lines, the web server log showing the device's ethernet address as the source (NAT), the client's DHCP info (DNS1 is
the LAN resolver) and `SNTP synced`. The device logs `Starting AP` and `DHCP DNS server: <resolver>` on its USB
console; flashing over the air reboots it for repeat boots. Before the AP address and DHCP server moved ahead of the
AP start (the fix in that PR), the first AP start failed on every boot with `esp_netif_dhcps_option failed: 20484`
(DHCP server already started by the AP start event) and `Failed to enable NAT`, and the bridge only came up on the
retry 90 s later; a client that cannot find the SSID for the first minute on an older build is that. With the fix
the bridge is up 6 s after boot. Any change to the ESP-IDF AP start should also rerun the captive portal hammer.
@@ -0,0 +1,211 @@
"""Plaintext native API client that lists user actions with their raw argument fields and executes one.
usage: rawactions.py HOST list
rawactions.py HOST exec ACTION key=value [...] (every argument must be given; types from the listing)
Fields of ListEntitiesServicesArgument: 1 name, 2 type, 3 description, 4 example, 5 optional, 6 default_value.
"""
import os
import socket
import struct
import sys
HOST = sys.argv[1]
CMD = sys.argv[2]
ARGS = sys.argv[3:]
TYPE_NAMES = {
0: "bool",
1: "int",
2: "float",
3: "string",
4: "bool[]",
5: "int[]",
6: "float[]",
7: "string[]",
}
def varint(v: int) -> bytes:
out = bytearray()
while True:
b = v & 0x7F
v >>= 7
if v:
out.append(b | 0x80)
else:
out.append(b)
return bytes(out)
def svarint(v: int) -> bytes:
return varint((v << 1) ^ (v >> 63))
def decode_varint(buf: bytes, pos: int) -> tuple[int, int]:
value = shift = 0
while True:
b = buf[pos]
pos += 1
value |= (b & 0x7F) << shift
if not b & 0x80:
return value, pos
shift += 7
def fields(payload: bytes) -> list[tuple[int, int, object]]:
out = []
pos = 0
while pos < len(payload):
key, pos = decode_varint(payload, pos)
fnum, wt = key >> 3, key & 7
if wt == 0:
v, pos = decode_varint(payload, pos)
elif wt == 2:
ln, pos = decode_varint(payload, pos)
v = payload[pos : pos + ln]
pos += ln
elif wt == 5:
v = payload[pos : pos + 4] # fixed32, caller unpacks
pos += 4
else:
raise ValueError(f"wire type {wt}")
out.append((fnum, wt, v))
return out
def frame(mtype: int, payload: bytes) -> bytes:
return b"\x00" + varint(len(payload)) + varint(mtype) + payload
def field_bytes(fnum: int, data: bytes) -> bytes:
return varint((fnum << 3) | 2) + varint(len(data)) + data
def field_varint(fnum: int, v: int) -> bytes:
return varint((fnum << 3) | 0) + varint(v)
def recv_frames(
sock: socket.socket, until: set[int], limit: float = 5.0
) -> list[tuple[int, bytes]]:
sock.settimeout(limit)
buf = bytearray()
frames = []
while True:
try:
chunk = sock.recv(4096)
except TimeoutError:
return frames
if not chunk:
return frames
buf += chunk
while True:
if len(buf) < 3:
break
if buf[0] != 0:
raise ValueError("not plaintext")
try:
ln, p = decode_varint(buf, 1)
mt, p = decode_varint(buf, p)
except IndexError:
break
if len(buf) < p + ln:
break
frames.append((mt, bytes(buf[p : p + ln])))
del buf[: p + ln]
if mt in until:
return frames
def main() -> None:
sock = socket.create_connection((HOST, 6053), timeout=5)
sock.sendall(
frame(
1, field_bytes(1, b"rawactions") + field_varint(2, 1) + field_varint(3, 10)
)
) # HelloRequest
recv_frames(sock, {2})
sock.sendall(frame(3, b"")) # ConnectRequest, no password
recv_frames(sock, {4})
sock.sendall(frame(11, b"")) # ListEntitiesRequest
frames = recv_frames(sock, {19})
services = {}
for mt, payload in frames:
if mt != 41:
continue
name = key = None
args = []
for fnum, _, v in fields(payload):
if fnum == 1:
name = v.decode()
elif fnum == 2:
key = struct.unpack("<I", v)[0]
elif fnum == 3:
a = {
"name": None,
"type": 0,
"description": None,
"example": None,
"optional": None,
"default": None,
}
for af, _, av in fields(v):
if af == 1:
a["name"] = av.decode()
elif af == 2:
a["type"] = av
elif af == 3:
a["description"] = av.decode()
elif af == 4:
a["example"] = av.decode()
elif af == 5:
a["optional"] = bool(av)
elif af == 6:
a["default"] = av.decode()
else:
a[f"field{af}"] = av
args.append(a)
services[name] = (key, args)
if CMD == "list":
for name, (key, args) in services.items():
print(f"action {name} key={key}")
for a in args:
extra = {
k: v
for k, v in a.items()
if k not in ("name", "type") and v is not None
}
print(f" {a['name']}: {TYPE_NAMES.get(a['type'], a['type'])} {extra}")
return
name = ARGS[0]
key, args = services[name]
payload = varint((1 << 3) | 5) + struct.pack("<I", key)
given = dict(kv.split("=", 1) for kv in ARGS[1:])
for a in args:
if a["name"] not in given:
if os.environ.get("ALLOW_MISSING"):
continue # leave the argument out of the request on purpose
sys.exit(f"missing {a['name']}")
raw = given[a["name"]]
t = a["type"]
if t == 0:
av = field_varint(1, 1 if raw.lower() == "true" else 0)
elif t == 1:
av = varint((5 << 3) | 0) + svarint(int(raw)) # sint32 int_
elif t == 2:
av = varint((3 << 3) | 5) + struct.pack("<f", float(raw))
elif t == 3:
av = field_bytes(4, raw.encode())
elif t == 5:
av = b"".join(
varint((7 << 3) | 0) + svarint(int(x)) for x in raw.split(",") if x
) # repeated sint32 int_array
else:
sys.exit(f"type {t} not supported here")
payload += field_bytes(2, av)
sock.sendall(frame(42, payload)) # ExecuteServiceRequest
print(f"sent {name} with {given}")
sock.close()
main()
@@ -0,0 +1,123 @@
"""Drive an LD24xx harness over the API: press buttons, set selects and numbers, print state updates, save the log.
usage: lddrive.py HOST KEY LOGFILE OP [OP ...] (KEY of - means a plaintext API)
OP: list | press:NAME | sel:NAME=OPTION | set:NAME=VALUE | wait:SECONDS
Steps are 2 s apart: the API log stream drops lines while the hub blocks in send_command_.
"""
import asyncio
from pathlib import Path
import sys
import time
from aioesphomeapi import (
APIClient,
BinarySensorState,
ButtonInfo,
LogLevel,
NumberInfo,
NumberState,
SelectInfo,
SelectState,
SensorInfo,
SensorState,
TextSensorInfo,
TextSensorState,
)
HOST, KEY, LOGFILE = sys.argv[1], sys.argv[2], sys.argv[3]
OPS = sys.argv[4:]
T0 = time.time()
STEP_GAP_S = 2.0
def stamp() -> str:
return f"{time.time() - T0:7.3f}"
async def run(c: APIClient, log) -> None:
info = await c.device_info()
print(
f"device {info.name} esphome {info.esphome_version} compiled {info.compilation_time}"
)
ents, _ = await c.list_entities_services()
by_key = {e.key: e for e in ents}
buttons = {e.name: e for e in ents if isinstance(e, ButtonInfo)}
selects = {e.name: e for e in ents if isinstance(e, SelectInfo)}
numbers = {e.name: e for e in ents if isinstance(e, NumberInfo)}
def on_state(s) -> None:
e = by_key.get(s.key)
if e is None:
return
missing = " (missing)" if getattr(s, "missing_state", False) else ""
if isinstance(s, TextSensorState):
print(f"{stamp()} TXT {e.name} = {s.state!r}{missing}", flush=True)
elif isinstance(s, BinarySensorState):
print(f"{stamp()} BIN {e.name} = {s.state}{missing}", flush=True)
elif isinstance(s, NumberState):
print(f"{stamp()} NUM {e.name} = {s.state:.0f}{missing}", flush=True)
elif isinstance(s, SelectState):
print(f"{stamp()} SEL {e.name} = {s.state!r}{missing}", flush=True)
elif (
isinstance(s, SensorState)
and isinstance(e, SensorInfo)
and "Count" in e.name
):
print(f"{stamp()} CNT {e.name} = {s.state:.0f}", flush=True)
def on_log(msg) -> None:
log.write(f"{stamp()} {msg.message.decode(errors='replace')}\n")
c.subscribe_states(on_state)
c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERBOSE)
await asyncio.sleep(1.5)
for op in OPS:
if op == "list":
for n in sorted(buttons):
print("button:", n)
for n in sorted(selects):
print("select:", n, selects[n].options)
for e in ents:
if isinstance(e, TextSensorInfo):
print("text_sensor:", e.name)
continue
kind, _, rest = op.partition(":")
if kind == "wait":
await asyncio.sleep(float(rest))
continue
if kind == "press":
if rest not in buttons:
sys.exit(f"no button named {rest!r}; have {sorted(buttons)}")
print(f"{stamp()} >>> press {rest}", flush=True)
log.write(f"{stamp()} >>> press {rest}\n")
c.button_command(buttons[rest].key)
elif kind == "set":
name, _, value = rest.partition("=")
if name not in numbers:
sys.exit(f"no number named {name!r}; have {sorted(numbers)}")
print(f"{stamp()} >>> set {name} = {value}", flush=True)
log.write(f"{stamp()} >>> set {name} = {value}\n")
c.number_command(numbers[name].key, float(value))
elif kind == "sel":
name, _, value = rest.partition("=")
if name not in selects:
sys.exit(f"no select named {name!r}; have {sorted(selects)}")
print(f"{stamp()} >>> select {name} = {value}", flush=True)
log.write(f"{stamp()} >>> select {name} = {value}\n")
c.select_command(selects[name].key, value)
else:
sys.exit(f"unknown op {op!r}")
await asyncio.sleep(STEP_GAP_S)
await asyncio.sleep(1)
async def main() -> None:
c = APIClient(HOST, 6053, None, noise_psk=None if KEY == "-" else KEY)
await c.connect(login=True)
with Path(LOGFILE).open("w", encoding="utf-8") as log:
await run(c, log)
await c.disconnect()
asyncio.run(main())
@@ -0,0 +1,33 @@
"""Summarize an lddrive.py log: for each step, the LD2450 TX frames, commands and publishes.
usage: txsum.py LOGFILE (diff the output of two builds)
"""
from pathlib import Path
import re
import sys
ANSI = re.compile(r"\x1b\[[0-9;]*m")
out: list[str] = []
with Path(sys.argv[1]).open(encoding="utf-8", errors="replace") as log:
for raw in log:
line = ANSI.sub("", raw.rstrip("\n"))
m = re.match(r"\s*[\d.]+ (.*)", line)
if not m:
continue
text = m.group(1)
if text.startswith(">>>"):
out.append(f"\n# {text[4:]}")
elif "[uart_debug" in text:
out.append(f" TX {text.split('>>> ')[1]}")
elif "[ld2450" in text and (
"Sending COMMAND" in text
or "zone type" in text.lower()
or "Query zone" in text
):
out.append(f" LOG {text.split(']: ', 1)[1]}")
elif "[select:" in text and ">>" in text:
out.append(f" SEL {text.split(']: ', 1)[1]}")
elif "[text_sensor:098]" in text:
out.append(f" TXT {text.split(']: ', 1)[1]}")
print("\n".join(out))
@@ -0,0 +1,65 @@
esphome:
name: ${name}
esp8266:
board: d1_mini
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none
logger:
level: DEBUG
api:
reboot_timeout: 0s
actions:
- action: play_buzzer
description: Play a song or build one from parts
variables:
song_str:
type: string
description: Full RTTTL string
example: "scale:d=4,o=5,b=100:c,d,e"
required: false
notes:
type: string
required: false
octave:
type: int
default: 5
bpm:
type: int
default: 100
tempo:
type: float
default: 1.5
loud:
type: bool
default: true
label:
type: string
default: ""
then:
- lambda: |-
char s[40], n[40], l[40];
snprintf(s, sizeof(s), "%.*s", (int) song_str.size(), song_str.c_str());
snprintf(n, sizeof(n), "%.*s", (int) notes.size(), notes.c_str());
snprintf(l, sizeof(l), "%.*s", (int) label.size(), label.c_str());
ESP_LOGD("main", "play_buzzer song=%s(%d) notes=%s(%d) octave=%d bpm=%d tempo=%.1f loud=%d label=%s(%d)", s,
(int) song_str.size(), n, (int) notes.size(), octave, bpm, tempo, loud, l, (int) label.size());
- action: plain_action
variables:
value: int
then:
- logger.log:
format: "plain_action value=%d"
args: ["value"]
- action: list_action
variables:
values: int[]
then:
- logger.log:
format: "list_action n=%d"
args: ["values.size()"]
@@ -0,0 +1,28 @@
esphome:
name: ${name}
esp8266:
board: d1_mini
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none
logger:
level: DEBUG
api:
reboot_timeout: 0s
actions:
- action: str_action
variables:
song_str: string
notes: string
octave: int
then:
- lambda: |-
char s[40], n[40];
snprintf(s, sizeof(s), "%.*s", (int) song_str.size(), song_str.c_str());
snprintf(n, sizeof(n), "%.*s", (int) notes.size(), notes.c_str());
ESP_LOGD("main", "str_action song=%s(%d) notes=%s(%d) octave=%d", s, (int) song_str.size(), n, (int) notes.size(), octave);
@@ -0,0 +1,57 @@
substitutions:
name: ld2412sel
esphome:
name: ${name}
esp32:
variant: esp32s3
board: esp32-s3-devkitc-1
flash_size: 8MB
framework:
type: esp-idf
logger:
level: VERBOSE
api:
reboot_timeout: 0s
ota:
- platform: esphome
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none
uart:
- id: uart_ld2412
tx_pin: GPIO43
rx_pin: GPIO44
baud_rate: 115200
debug:
direction: BOTH
dummy_receiver: false
after:
timeout: 10ms
bytes: 120
ld2412:
id: ld2412_radar
uart_id: uart_ld2412
select:
- platform: ld2412
ld2412_id: ld2412_radar
out_pin_level:
name: "LD2412 Hardware output pin level"
binary_sensor:
- platform: gpio
name: "LD2412 Out Pin"
pin: GPIO38
- platform: ld2412
ld2412_id: ld2412_radar
has_target:
name: "LD2412 Presence"
@@ -0,0 +1,258 @@
esphome:
name: ${name}
on_boot:
priority: -100
then:
- lambda: |-
// UART0 TX looped back into its own RX so injected frames reach the hub
USC0(0) |= (1 << UCLBE);
ESP_LOGI("harness", "UART0 loopback on");
esp8266:
board: d1_mini
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
reboot_timeout: 0s
logger:
baud_rate: 0
level: VERBOSE
uart:
id: radar_uart
tx_pin: GPIO1
rx_pin: GPIO3
baud_rate: ${baud}
debug:
direction: TX
dummy_receiver: false
after:
delimiter: [0x04, 0x03, 0x02, 0x01]
ld2450:
id: radar
uart_id: radar_uart
select:
- platform: ld2450
baud_rate:
name: Baud Rate
zone_type:
name: Zone Type
text_sensor:
- platform: ld2450
version:
name: Radar Version
target_1:
direction:
name: T1 Direction
target_2:
direction:
name: T2 Direction
target_3:
direction:
name: T3 Direction
sensor:
- platform: ld2450
target_count:
name: Target Count
button:
# periodic frames: header, 3 x (x, y, speed, resolution), footer
- platform: template
name: Frame Approach
on_press:
- uart.write:
id: radar_uart
data: [0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0xE8, 0x83, 0x14, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC]
- platform: template
name: Frame Away
on_press:
- uart.write:
id: radar_uart
data: [0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0xE8, 0x83, 0x14, 0x80, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC]
- platform: template
name: Frame Stationary
on_press:
- uart.write:
id: radar_uart
data: [0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0xE8, 0x83, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC]
- platform: template
name: Frame None
on_press:
- uart.write:
id: radar_uart
data: [0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC]
- platform: template
name: Frame T2 T3
on_press:
- uart.write:
id: radar_uart
data: [0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xF4, 0x81, 0x20, 0x83, 0x1E, 0x00, 0x64, 0x00,
0x2C, 0x01, 0xB0, 0x84, 0x0A, 0x80, 0x64, 0x00,
0x55, 0xCC]
# query zone acks: header, length, command C1, status 01, 00 00, zone type, 00, 24 coordinate bytes, footer
- platform: template
name: Zone Ack Disabled
on_press:
- uart.write:
id: radar_uart
data: [0xFD, 0xFC, 0xFB, 0xFA, 0x1E, 0x00, 0xC1, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x04, 0x03, 0x02, 0x01]
- platform: template
name: Zone Ack Detection
on_press:
- uart.write:
id: radar_uart
data: [0xFD, 0xFC, 0xFB, 0xFA, 0x1E, 0x00, 0xC1, 0x01, 0x00, 0x00, 0x01, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x04, 0x03, 0x02, 0x01]
- platform: template
name: Zone Ack Filter
on_press:
- uart.write:
id: radar_uart
data: [0xFD, 0xFC, 0xFB, 0xFA, 0x1E, 0x00, 0xC1, 0x01, 0x00, 0x00, 0x02, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x04, 0x03, 0x02, 0x01]
# name based setters, the path lambdas use
- platform: template
name: Set Zone Filter By Name
on_press:
- lambda: id(radar).set_zone_type("Filter");
- platform: template
name: Set Zone Bogus By Name
on_press:
- lambda: id(radar).set_zone_type("Bogus");
- platform: template
name: Set Baud 57600 By Name
on_press:
- lambda: id(radar).set_baud_rate("57600");
- platform: template
name: Set Baud Bogus By Name
on_press:
- lambda: id(radar).set_baud_rate("Bogus");
# target 1 at x 772, y 258: bytes 04 03 02 01 are the command footer
- platform: template
name: Frame CmdFooter In Payload
on_press:
- uart.write:
id: radar_uart
data: [0xAA, 0xFF, 0x03, 0x00,
0x04, 0x03, 0x02, 0x01, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC]
# zone ack, type Detection, zone 1 x1 = 0xCC55: bytes 55 CC are the periodic footer
- platform: template
name: Zone Ack DataFooter In Payload
on_press:
- uart.write:
id: radar_uart
data: [0xFD, 0xFC, 0xFB, 0xFA, 0x1E, 0x00, 0xC1, 0x01, 0x00, 0x00, 0x01, 0x00,
0x55, 0xCC, 0xC8, 0x00, 0xDC, 0x05, 0xB8, 0x0B, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x04, 0x03, 0x02, 0x01]
# zone ack, type Filter, zone 1 x1 = 0x0304 and y1 = 0x0102: bytes 04 03 02 01 are the command footer
- platform: template
name: Zone Ack CmdFooter In Payload
on_press:
- uart.write:
id: radar_uart
data: [0xFD, 0xFC, 0xFB, 0xFA, 0x1E, 0x00, 0xC1, 0x01, 0x00, 0x00, 0x02, 0x00,
0x04, 0x03, 0x02, 0x01, 0xDC, 0x05, 0xB8, 0x0B, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x04, 0x03, 0x02, 0x01]
# a periodic frame cut after 12 bytes, then a complete zone ack (type Disabled, zeros)
- platform: template
name: Cut Frame Then Zone Ack
on_press:
- uart.write:
id: radar_uart
data: [0xAA, 0xFF, 0x03, 0x00, 0x00, 0x00, 0xE8, 0x83, 0x14, 0x00, 0x64, 0x00,
0xFD, 0xFC, 0xFB, 0xFA, 0x1E, 0x00, 0xC1, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x04, 0x03, 0x02, 0x01]
# a periodic frame missing its last byte, then a complete one (target 1 moving away)
- platform: template
name: Cut Frame Then Frame
on_press:
- uart.write:
id: radar_uart
data: [0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0xE8, 0x83, 0x14, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55,
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0xE8, 0x83, 0x14, 0x80, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC]
# a command header claiming a 255 byte payload, then a complete periodic frame (target 1 stationary)
- platform: template
name: Bogus Length Then Frame
on_press:
- uart.write:
id: radar_uart
data: [0xFD, 0xFC, 0xFB, 0xFA, 0xFF, 0x00,
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0xE8, 0x83, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC]
# five garbage bytes, then a complete periodic frame (target 1 approaching)
- platform: template
name: Garbage Then Frame
on_press:
- uart.write:
id: radar_uart
data: [0x01, 0x02, 0x03, 0x42, 0x99,
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0xE8, 0x83, 0x14, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC]
number:
- platform: ld2450
presence_timeout:
name: Presence Timeout
zone_1:
x1:
name: Z1 X1
y1:
name: Z1 Y1
x2:
name: Z1 X2
y2:
name: Z1 Y2
@@ -0,0 +1,57 @@
substitutions:
name: wispcli
ap_ssid: wisp-bridge
esphome:
name: ${name}
esp8266:
board: d1_mini
logger:
level: DEBUG
wifi:
ssid: ${ap_ssid}
password: !secret wisp_psk
power_save_mode: none
reboot_timeout: 0s
http_request:
esp8266_disable_ssl_support: true
timeout: 8s
time:
- platform: sntp
id: sntp_time
on_time_sync:
- logger.log:
format: "SNTP synced"
level: INFO
interval:
- interval: 15s
then:
- http_request.get:
url: !secret lan_http
capture_response: true
on_response:
- logger.log:
format: "LAN GET status %d body %s"
args: ["response->status_code", "body.c_str()"]
level: INFO
on_error:
- logger.log:
format: "LAN GET failed"
level: WARN
- http_request.get:
url: http://example.com/
on_response:
- logger.log:
format: "WAN GET status %d"
args: ["response->status_code"]
level: INFO
on_error:
- logger.log:
format: "WAN GET failed"
level: WARN
@@ -0,0 +1,40 @@
substitutions:
name: wispeth
ap_ssid: wisp-bridge
esphome:
name: ${name}
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
flash_size: 16MB
framework:
type: esp-idf
logger:
hardware_uart: USB_SERIAL_JTAG
level: VERBOSE
api:
encryption:
key: !secret api_key
reboot_timeout: 0s
ota:
- platform: esphome
ethernet:
type: W5500
clk_pin: GPIO13
mosi_pin: GPIO11
miso_pin: GPIO12
cs_pin: GPIO14
interrupt_pin: GPIO10
reset_pin: GPIO9
wifi:
ap:
ssid: ${ap_ssid}
password: !secret wisp_psk
ap_mode: WISP
+1 -1
View File
@@ -286,7 +286,7 @@ jobs:
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Run prek
uses: j178/prek-action@1382ad72dea72f9d53832f2bcb65b3e87a947204 # v3.0.1
uses: j178/prek-action@af4d4fcb6922b8e6f65c0426947125fcc38794ce # v3.0.2
with:
prek-version: ${{ steps.prek.outputs.version }}
# This job only runs on pull requests, so nothing ever populates
+11 -6
View File
@@ -205,15 +205,16 @@ file does, and it is the authority when they disagree. The most useful starting
for a table of structs copy the whole entry out and return the copy, never a pointer into flash.
On other platforms they are a plain `memcpy` and dereference, so no `#ifdef` is needed. Declare log tags with
`ESPHOME_LOG_TAG(TAG, "name")`, pass status text as `LOG_STR`, e.g.
`this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL))`, and give an action whose payload is a byte array
a `TEMPLATABLE_BYTES` field so a static code stays in flash.
`this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL))`. An action that only forwards a byte payload is
registered with `automation.ApplyBytes` (see Actions below), which keeps a constant payload in flash;
`TEMPLATABLE_BYTES` is for a `play()` that does more with the bytes.
* **Fixed sets of names** (status words, model names): a `PROGMEM_STRING_TABLE` (`esphome/core/progmem.h`),
logged with `get_log_str` and `LOG_STR_ARG`, copied out with `ESPHOME_strncpy_P` before `publish_state`.
A `dump_config` helper that picks a name returns `const LogString *` from `LOG_STR(...)`.
* **CRCs and bit tricks** come from `esphome/core/helpers.h` (`crc8`, `crc16`, `xor_bytes`, `sum16_bytes`,
`reverse_bits`, `encode_uint16`, `encode_value_le`, `sign_extend<BITS>`, `bcd_to_uint8`); pick the helper's
arguments instead of writing a loop. Epson style 7-byte RTC calendars (one-hot weekday) use
`time::decode_bcd_calendar`.
`reverse_bits`, `encode_uint16`, `encode_value_le`, `sign_extend<BITS>`, `bcd_to_uint8`, `assign_bits`); pick
the helper's arguments instead of writing a loop. 7-byte BCD RTC calendars use `time::decode_bcd_calendar<LAYOUT>`
and `encode_bcd_calendar<LAYOUT>` with `EPSON_BCD_CALENDAR`, `DS_BCD_CALENDAR` or `PCF_BCD_CALENDAR`.
* **Bus helpers:** UART drains with `read_available(on_byte)` and `discard_available()`, text lines with
`LineReader<N>`; I2C has `update_register`, `write_registers` (PROGMEM `I2CRegValue` table), `check_chip_id`
and `read_bytes_16_le`. `if (!this->status_comm_result(ok)) return;` sets or clears the warning.
@@ -445,6 +446,8 @@ file does, and it is the authority when they disagree. The most useful starting
switches = switch.sub_switches(config, parent=hub)
await switches(CONF_BLUETOOTH, None, apply="set_bluetooth(state)", publish="after")
```
Restore an owned or standalone switch in `setup()` with `sw->restore()`, or `restore(default)`
to still drive a state when the restore mode is disabled.
- **Other codegen helpers:** `uart.new_uart_component(config, *ctor_args)` (and `i2c.new_i2c_component`,
`spi.new_spi_component`, `modbus.new_modbus_client_component`, `ble_client.new_ble_node_component`)
@@ -604,7 +607,9 @@ file does, and it is the authority when they disagree. The most useful starting
)
```
The `ApplyField`, `ApplyCall` and `register_apply_action` docstrings in `esphome/automation.py` cover
the rest; `cover.control` and `cover.template.publish` are in-tree examples. `TEMPLATABLE_VALUE` with
the rest; `cover.control` and `cover.template.publish` are in-tree examples. An action whose input is a
byte payload passes `automation.ApplyBytes(CONF_DATA, "send({})")` in the same call; a constant payload
stays in flash and a lambda's vector is forwarded as data and size. `TEMPLATABLE_VALUE` with
`cg.templatable` stays for actions whose `play()` has real logic beyond forwarding values.
* **Conditions:**
+16
View File
@@ -147,6 +147,7 @@ esphome/components/cst816/* @clydebarrow
esphome/components/cst9220/* @clydebarrow
esphome/components/ct_clamp/* @jesserockz
esphome/components/current_based/* @djwmarcx
esphome/components/cw2017/* @exciton
esphome/components/d01/* @ch604
esphome/components/dac7678/* @NickB1
esphome/components/daikin_arc/* @MagicBear
@@ -186,6 +187,7 @@ esphome/components/ens160_base/* @latonita @vincentscode
esphome/components/ens160_i2c/* @latonita
esphome/components/ens160_spi/* @latonita
esphome/components/ens210/* @itn3rd77
esphome/components/entity_transport/* @clydebarrow
esphome/components/epaper_spi/* @esphome/core
esphome/components/es7210/* @kahrendt
esphome/components/es7243e/* @kbx81
@@ -211,6 +213,7 @@ esphome/components/esp_ldo/* @clydebarrow
esphome/components/espectre/* @francescopace
esphome/components/esphome/ota/* @esphome/core
esphome/components/espnow/* @jesserockz
esphome/components/espnow/packet_interface/* @clydebarrow
esphome/components/espnow/packet_transport/* @EasilyBoredEngineer
esphome/components/ethernet_info/* @gtjadsonsantos
esphome/components/event/* @nohat
@@ -325,6 +328,7 @@ esphome/components/internal_temperature/* @Mat931
esphome/components/interval/* @esphome/core
esphome/components/ir_rf_base/* @bdraco @kbx81
esphome/components/ir_rf_proxy/* @kbx81
esphome/components/islandaire/* @ipopov
esphome/components/it8951/* @koosoli @limengdu @Passific
esphome/components/jsn_sr04t/* @Mafus1
esphome/components/json/* @esphome/core
@@ -356,6 +360,7 @@ esphome/components/lm75b/* @beormund
esphome/components/ln882h_ble/* @Bl00d-B0b
esphome/components/ln882h_ble_tracker/* @Bl00d-B0b
esphome/components/ln882x/* @lamauny
esphome/components/ln882x_spi_led_strip/* @sebo-b
esphome/components/lock/* @esphome/core
esphome/components/logger/* @esphome/core
esphome/components/logger/select/* @clydebarrow
@@ -366,6 +371,7 @@ esphome/components/ltr390/* @latonita @sjtrny
esphome/components/ltr501/* @latonita
esphome/components/ltr_als_ps/* @latonita
esphome/components/lvgl/* @clydebarrow
esphome/components/m5stack_4relay/* @KoenBreeman
esphome/components/m5stack_8angle/* @rnauber
esphome/components/mapping/* @clydebarrow
esphome/components/matrix_keypad/* @ssieb
@@ -458,6 +464,8 @@ esphome/components/openthread/* @mrene
esphome/components/opt3001/* @ccutrer
esphome/components/ota/* @esphome/core
esphome/components/output/* @esphome/core
esphome/components/packet_interface/* @clydebarrow
esphome/components/packet_interface/packet_transport/* @clydebarrow
esphome/components/packet_transport/* @clydebarrow
esphome/components/pca6416a/* @Mat931
esphome/components/pca9505/* @ajfriesen @ssieb
@@ -537,6 +545,7 @@ esphome/components/sdp3x/* @Azimath
esphome/components/seeed_mr24hpc1/* @limengdu
esphome/components/seeed_mr60bha2/* @limengdu
esphome/components/seeed_mr60fda2/* @limengdu
esphome/components/seeed_multi_channel_relay/* @KoenBreeman
esphome/components/selec_meter/* @sourabhjaiswal
esphome/components/select/* @esphome/core
esphome/components/sen0321/* @notjj
@@ -622,7 +631,9 @@ esphome/components/sun_gtil2/* @Mat931
esphome/components/switch/* @esphome/core
esphome/components/switch/binary_sensor/* @ssieb
esphome/components/sx126x/* @swoboda1337
esphome/components/sx126x/packet_interface/* @clydebarrow
esphome/components/sx127x/* @swoboda1337
esphome/components/sx127x/packet_interface/* @clydebarrow
esphome/components/sy6970/* @linkedupbits
esphome/components/syslog/* @clydebarrow
esphome/components/systa_bus/* @Mat931
@@ -634,6 +645,8 @@ esphome/components/tca8418/* @zebble
esphome/components/tca9548a/* @andreashergert1984
esphome/components/tca9555/* @mobrembski
esphome/components/tcl112/* @glmnet
esphome/components/tcp/* @clydebarrow
esphome/components/tcp/packet_interface/* @clydebarrow
esphome/components/tcp_uart/* @Bascht74
esphome/components/tee501/* @Stock-M
esphome/components/teleinfo/* @0hax @FredM67
@@ -677,10 +690,12 @@ esphome/components/tuya/water_heater/* @iago-veiga
esphome/components/uart/* @esphome/core
esphome/components/uart/button/* @ssieb
esphome/components/uart/event/* @eoasmxd
esphome/components/uart/packet_interface/* @clydebarrow
esphome/components/uart/packet_transport/* @clydebarrow
esphome/components/uart_mux/* @kbx81
esphome/components/uart_tcp/* @Bascht74
esphome/components/udp/* @clydebarrow
esphome/components/udp/packet_interface/* @clydebarrow
esphome/components/ufire_ec/* @pvizeli
esphome/components/ufire_ise/* @pvizeli
esphome/components/ufm01/* @ljungqvist
@@ -700,6 +715,7 @@ esphome/components/veml7700/* @latonita
esphome/components/version/* @esphome/core
esphome/components/vevor_dtx/* @Bofr5555
esphome/components/vl53l1x/* @will-tm
esphome/components/vnc/* @MindFreeze
esphome/components/voice_assistant/* @jesserockz @kahrendt
esphome/components/wake_on_lan/* @clydebarrow @willwill2will54
esphome/components/watchdog/* @oarcher
+124 -16
View File
@@ -24,6 +24,7 @@ from esphome.const import (
)
from esphome.core import CORE, ID, EsphomeError, HexInt, Lambda
from esphome.cpp_generator import (
CallExpression,
Expression,
LambdaExpression,
MockObj,
@@ -59,10 +60,15 @@ async def templatable_bytes(
if cg.is_template(value):
fn = await cg.templatable(value, args, cg.std_vector.template(cg.uint8))
cg.add(set_template(fn))
elif len(value) > 0xFFFF:
raise EsphomeError(f"Byte payload is {len(value)} bytes; the maximum is 65535")
else:
cg.add(set_static(progmem_bytes(table_name, value), len(value)))
cg.add(set_static(*_static_bytes(table_name, value)))
def _static_bytes(table_name: str, value: bytes | list[int]) -> tuple[MockObj, int]:
"""The shared PROGMEM table for a constant payload and its length."""
if (size := len(value)) > 0xFFFF:
raise EsphomeError(f"Byte payload is {size} bytes; the maximum is 65535")
return progmem_bytes(table_name, value), size
def maybe_simple_id(*validators):
@@ -356,6 +362,33 @@ class ApplyField:
return ApplyCall(target, ((self.conf_key, self.type_, self.const_fn),))
@dataclass(frozen=True)
class ApplyBytes:
"""A byte payload key forwarded as ``target``, whose ``{}`` stands for ``data, len``.
A constant payload is a shared PROGMEM table passed as is; on ESP8266 it is first copied
out, to the stack up to ``stack`` bytes and to the heap beyond, unless ``progmem_target``
names a call that reads flash itself. A lambda's ``std::vector<uint8_t>`` is passed as
its data and size. The template may name the parent as ``{parent}``. An absent key
emits nothing.
"""
conf_key: str | tuple[str, ...]
target: str
stack: int = 64
progmem_target: str | None = None
def __post_init__(self) -> None:
for target in (self.target, self.progmem_target):
if target is None:
continue
fields = [
f for _, f, _, _ in string.Formatter().parse(target) if f is not None
]
if fields.count("") != 1 or any(f not in ("", "parent") for f in fields):
raise ValueError(f"apply bytes target {target!r} needs one {{}}")
def _config_lookup(config: ConfigType, key: str | tuple[str, ...]) -> Any:
if isinstance(key, str):
return config.get(key)
@@ -414,9 +447,17 @@ async def _apply_parent(config: ConfigType, id_key: str = CONF_ID) -> str:
def _apply_lambda_args(args: TemplateArgsType) -> TemplateArgsType:
# The generated function's parameters; a std::string arg is never copied.
# The generated function's parameters: a reference keeps its spelling, so a mutable
# one stays mutable; a value is taken by const reference, so a std::string is never copied.
return [
(cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), arg)
(
(
t
if str(cg.safe_exp(t)).rstrip().endswith("&")
else cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &")
),
arg,
)
for t, arg in args
]
@@ -488,7 +529,7 @@ def _apply_values(config: ConfigType, members: list[tuple[Any, Any, Any]]) -> li
def register_apply_action(
name: str,
schema: cv.Schema,
*fields: ApplyField | ApplyCall,
*fields: ApplyField | ApplyCall | ApplyBytes,
call: str | None = None,
id_key: str = CONF_ID,
) -> None:
@@ -504,7 +545,7 @@ def register_apply_action(
def apply_action_builder(
name: str,
schema: cv.Schema,
*fields: ApplyField | ApplyCall,
*fields: ApplyField | ApplyCall | ApplyBytes,
call: str | None = None,
id_key: str = CONF_ID,
) -> Callable[..., Awaitable[MockObj]]:
@@ -517,17 +558,20 @@ def apply_action_builder(
and ``apply_call.perform()`` is appended.
"""
# An action stores the value, so a std::string constant stays in flash on ESP8266.
statements_spec = [
(
c.target,
_default_std_string(c.members, flash_string),
)
for c in (f if isinstance(f, ApplyCall) else f.call() for f in fields)
]
_check_key_in_schema(name, schema, id_key)
for _, members in statements_spec:
statements_spec: list[ApplyBytes | tuple[str, list[tuple[Any, Any, Any]]]] = []
for f in fields:
if isinstance(f, ApplyBytes):
_check_key_in_schema(name, schema, f.conf_key)
statements_spec.append(f)
continue
c = f if isinstance(f, ApplyCall) else f.call()
members = _default_std_string(c.members, flash_string)
for conf_key, _, _ in members:
_check_key_in_schema(name, schema, conf_key)
statements_spec.append((c.target, members))
# Named like the generated function, so it cannot clash with an SDK symbol
table_name = f"esphome__{name.replace('.', '_')}__bytes"
async def builder(
config: ConfigType,
@@ -539,7 +583,15 @@ def apply_action_builder(
lambda_args = _apply_lambda_args(args)
receiver = "apply_call." if call else f"{parent}->"
statements: list[str] = []
for target, members in statements_spec:
for spec in statements_spec:
if isinstance(spec, ApplyBytes):
statements.extend(
await _render_bytes(
spec, config, receiver, parent, args, table_name
)
)
continue
target, members = spec
values = _apply_values(config, members)
if not _apply_call_active(members, values):
continue
@@ -560,6 +612,62 @@ def apply_action_builder(
return builder
async def _render_bytes(
spec: ApplyBytes,
config: ConfigType,
receiver: str,
parent: str,
args: TemplateArgsType,
table_name: str,
) -> list[str]:
"""The statements for one ``ApplyBytes``: what ``TemplatableBytes::visit`` does, inlined.
The payload lambda keeps the automation's own argument types, as ``TemplatableBytes``
gave it, and is always called, so a body that modifies an argument still compiles.
"""
value = _config_lookup(config, spec.conf_key)
if value is None:
return []
# The temporary carries the generated-name prefix so a trigger argument cannot shadow it
def send(target: str, data: str) -> str:
return f"{receiver}{target.format(data, parent=parent)};"
if isinstance(value, Lambda):
inner = await cg.process_lambda(
value, args, return_type=cg.std_vector.template(cg.uint8)
)
# Never reduced into the outer function, whose own parameters are const
call = CallExpression(inner, *[cg.MockObj(name) for _, name in args])
return [
"{",
f" const std::vector<uint8_t> esphome__bytes = {call};",
f" {send(spec.target, 'esphome__bytes.data(), esphome__bytes.size()')}",
"}",
]
table, size = _static_bytes(table_name, value)
# Qualified so a trigger argument cannot shadow a reused table; an empty payload is nullptr
table_args = f"::{table}, {size}" if size else f"{table}, {size}"
if CORE.is_esp8266 and spec.progmem_target is not None:
return [send(spec.progmem_target, table_args)]
if not CORE.is_esp8266:
return [send(spec.target, table_args)]
# A PROGMEM table must be read with aligned loads on ESP8266, so copy it out first;
# the length is known here, so the copy is an exact array, on the heap past ``stack``.
# An empty payload still gets a one byte array: the raw lwIP socket rejects a null buffer.
if size <= spec.stack:
buffer, data = f"uint8_t esphome__bytes[{max(size, 1)}];", "esphome__bytes"
else:
buffer, data = (
f"auto esphome__bytes = std::make_unique<uint8_t[]>({size});",
"esphome__bytes.get()",
)
statements = ["{", f" {buffer}"]
if size:
statements.append(f" esphome::progmem_memcpy({data}, {table_args});")
return [*statements, f" {send(spec.target, f'{data}, {size}')}", "}"]
def _apply_call_active(members: list[tuple[Any, Any, Any]], values: list[Any]) -> bool:
"""An ``ApplyCall`` is emitted unless it has keys and none of them is set."""
return not members or any(value is not None for value in values)
+3 -1
View File
@@ -111,7 +111,9 @@ class ToolOutput(RedirectText):
def _emit_line(self, line: str) -> None:
if self._progress and _PROGRESS.match(line):
if not self._is_filtered(line):
if self._is_filtered(line):
self._drop_line(line)
else:
text = _fit_terminal(line.strip("\r\n"))
self._write_color_replace(f"\r{text}\x1b[K")
self._on_progress_line = True
+100 -11
View File
@@ -1,5 +1,7 @@
from collections.abc import Callable
from ipaddress import IPv4Address, IPv6Address
import logging
import math
import re
from typing import Any
@@ -27,6 +29,7 @@ from esphome.const import (
CONF_CAPTURE_RESPONSE,
CONF_DATA,
CONF_DATA_TEMPLATE,
CONF_DEFAULT,
CONF_DELAY,
CONF_ENCRYPTION,
CONF_EVENT,
@@ -136,6 +139,7 @@ SERVICE_ARG_FALLBACK_TYPES: dict[str, MockObj] = {
CONF_BATCH_DELAY = "batch_delay"
CONF_CUSTOM_SERVICES = "custom_services"
CONF_EXAMPLE = "example"
CONF_REQUIRED = "required"
CONF_HOMEASSISTANT_SERVICES = "homeassistant_services"
CONF_HOMEASSISTANT_STATES = "homeassistant_states"
CONF_LISTEN_BACKLOG = "listen_backlog"
@@ -247,12 +251,66 @@ def _validate_supports_response(value: Any) -> str:
return cv.enum(SUPPORTS_RESPONSE_OPTIONS, lower=True)(value)
VARIABLE_SCHEMA = cv.Schema(
{
cv.Required(CONF_TYPE): cv.one_of(*SERVICE_ARG_NATIVE_TYPES, lower=True),
cv.Optional(CONF_DESCRIPTION): cv.string_strict,
cv.Optional(CONF_EXAMPLE): cv.string_strict,
}
def _not_boolean(value: Any) -> Any:
# bool is an int subclass, and "true" cannot be parsed as a number by the client
if isinstance(value, bool):
raise cv.Invalid("Expected a number, got a boolean")
return value
def _finite(value: float) -> float:
if not math.isfinite(value):
raise cv.Invalid("Expected a finite number")
return value
def _no_nul(value: str) -> str:
# The default is a C string in flash, so a NUL would cut it short
if "\0" in value:
raise cv.Invalid("A default cannot contain a NUL character")
return value
# The wire carries the default as text; the client parses it into ExecuteServiceArgument's
# types, so the values are kept within what int32 and float hold
FLOAT32_MAX = 3.4028235e38
_DEFAULT_VALUE_VALIDATORS: dict[str, Callable[[Any], Any]] = {
"bool": cv.boolean,
"int": cv.All(_not_boolean, cv.int_range(min=-(2**31), max=2**31 - 1)),
"float": cv.All(
_not_boolean,
cv.float_,
_finite,
cv.float_range(min=-FLOAT32_MAX, max=FLOAT32_MAX),
),
"string": cv.All(cv.string_strict, _no_nul),
}
assert _DEFAULT_VALUE_VALIDATORS.keys() == _SERVICE_ARG_SCALAR_TYPES.keys()
def _validate_variable_optionality(value: ConfigType) -> ConfigType:
"""Cross-validate required/default against the variable type."""
var_type = value[CONF_TYPE]
if _is_optional_var(value) and var_type not in _DEFAULT_VALUE_VALIDATORS:
raise cv.Invalid("Array variables cannot be optional")
if CONF_DEFAULT in value:
if value.get(CONF_REQUIRED) is True:
raise cv.Invalid("A variable with a default cannot set required: true")
value[CONF_DEFAULT] = _DEFAULT_VALUE_VALIDATORS[var_type](value[CONF_DEFAULT])
return value
VARIABLE_SCHEMA = cv.All(
cv.Schema(
{
cv.Required(CONF_TYPE): cv.one_of(*SERVICE_ARG_NATIVE_TYPES, lower=True),
cv.Optional(CONF_DESCRIPTION): cv.string_strict,
cv.Optional(CONF_EXAMPLE): cv.string_strict,
cv.Optional(CONF_REQUIRED): cv.boolean,
cv.Optional(CONF_DEFAULT): cv.valid,
}
),
_validate_variable_optionality,
)
# Accepts the plain `name: type` shorthand or the full mapping form
@@ -464,7 +522,28 @@ def _has_action_metadata(actions: list[ConfigType]) -> bool:
)
def _action_strings(conf: ConfigType, has_metadata: bool) -> list[str | None]:
def _is_optional_var(var_: ConfigType) -> bool:
return CONF_DEFAULT in var_ or var_.get(CONF_REQUIRED) is False
def _has_optional_args(actions: list[ConfigType]) -> bool:
return any(
_is_optional_var(var_)
for conf in actions
for var_ in conf[CONF_VARIABLES].values()
)
def _default_to_wire(value: Any) -> str:
"""Serialize a validated default for the wire; clients coerce by arg type."""
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
def _action_strings(
conf: ConfigType, has_metadata: bool, has_optional: bool
) -> list[str | None]:
"""Strings of one action in the table order UserServiceStatic (user_services.h) expects."""
# An empty description or example is treated as unset
strings: list[str | None] = [conf[CONF_ACTION]]
@@ -477,6 +556,15 @@ def _action_strings(conf: ConfigType, has_metadata: bool) -> list[str | None]:
var_.get(CONF_DESCRIPTION) or None,
var_.get(CONF_EXAMPLE) or None,
]
if has_optional:
# The default slot doubles as the optional flag: nullptr for a required variable,
# "" for an optional one without a default (an empty string default is the same)
if not _is_optional_var(var_):
strings.append(None)
elif (default := var_.get(CONF_DEFAULT)) is None:
strings.append("")
else:
strings.append(_default_to_wire(default))
return strings
@@ -485,9 +573,7 @@ def _validate_wizard(config: ConfigType) -> ConfigType:
return config
FINAL_VALIDATE_SCHEMA = cv.All(
_validate_wizard,
)
FINAL_VALIDATE_SCHEMA = _validate_wizard
def _add_action_strings(index: int, strings: list[str | None]) -> MockObj:
@@ -546,6 +632,9 @@ async def to_code(config: ConfigType) -> None:
has_metadata = _has_action_metadata(actions)
if has_metadata:
cg.add_define("USE_API_USER_DEFINED_ACTION_METADATA")
has_optional = _has_optional_args(actions)
if has_optional:
cg.add_define("USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS")
# Collect all triggers first, then register all at once with initializer_list
triggers: list[cg.MockObj] = []
for index, conf in enumerate(actions):
@@ -589,7 +678,7 @@ async def to_code(config: ConfigType) -> None:
native = SERVICE_ARG_NATIVE_TYPES[var_type]
service_template_args.append(native)
func_args.append((native, name))
strings = _action_strings(conf, has_metadata)
strings = _action_strings(conf, has_metadata, has_optional)
table = _add_action_strings(index, strings)
# Template args: supports_response mode, then user service arg types
templ = cg.TemplateArguments(supports_response, *service_template_args)
+6
View File
@@ -1174,6 +1174,10 @@ message ListEntitiesServicesArgument {
ServiceArgType type = 2;
string description = 3 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA", (progmem) = true];
string example = 4 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA", (progmem) = true];
// Advisory: ExecuteServiceRequest still requires every argument
bool optional = 5 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS"];
// Always text: bool "true"/"false", numbers parseable as int/float; empty means no default
string default_value = 6 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS", (progmem) = true];
}
message ListEntitiesServicesResponse {
option (id) = 41;
@@ -1785,6 +1789,8 @@ message MediaPlayerSupportedFormat {
uint32 num_channels = 3;
MediaPlayerFormatPurpose purpose = 4;
uint32 sample_bytes = 5;
// Constant bitrate in kbps for lossy formats; 0 means no preference
uint32 bitrate = 6;
}
message ListEntitiesMediaPlayerResponse {
option (id) = 63;
@@ -1131,6 +1131,7 @@ uint16_t APIConnection::try_send_media_player_info(EntityBase *entity, APIConnec
media_format.num_channels = supported_format.num_channels;
media_format.purpose = static_cast<enums::MediaPlayerFormatPurpose>(supported_format.purpose);
media_format.sample_bytes = supported_format.sample_bytes;
media_format.bitrate = supported_format.bitrate;
}
return fill_and_encode_entity_info(media_player, msg, conn, remaining_size);
}
+14
View File
@@ -1369,6 +1369,12 @@ uint8_t *ListEntitiesServicesArgument::encode_msg(const void *self,
#endif
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.example);
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.optional);
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.default_value);
#endif
return pos;
}
@@ -1382,6 +1388,12 @@ uint32_t ListEntitiesServicesArgument::calc_size_msg(const void *self) {
#endif
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
size += ProtoSize::calc_length(1, msg.example.size());
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
size += ProtoSize::calc_bool(1, msg.optional);
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
size += ProtoSize::calc_length(1, msg.default_value.size());
#endif
return size;
}
@@ -2373,6 +2385,7 @@ uint8_t *MediaPlayerSupportedFormat::encode_msg(const void *self, uint8_t *__res
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.num_channels);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast<uint32_t>(msg.purpose));
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.sample_bytes);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.bitrate);
return pos;
}
uint32_t MediaPlayerSupportedFormat::calc_size_msg(const void *self) {
@@ -2383,6 +2396,7 @@ uint32_t MediaPlayerSupportedFormat::calc_size_msg(const void *self) {
size += ProtoSize::calc_uint32(1, msg.num_channels);
size += msg.purpose ? 2 : 0;
size += ProtoSize::calc_uint32(1, msg.sample_bytes);
size += ProtoSize::calc_uint32(1, msg.bitrate);
return size;
}
uint8_t *ListEntitiesMediaPlayerResponse::encode_msg(const void *self,
+7
View File
@@ -1593,6 +1593,12 @@ class ListEntitiesServicesArgument final : public ProtoMessage {
#endif
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
ProgmemStringRef example{};
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
bool optional{false};
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
ProgmemStringRef default_value{};
#endif
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
@@ -2263,6 +2269,7 @@ class MediaPlayerSupportedFormat final : public ProtoMessage {
uint32_t num_channels{0};
enums::MediaPlayerFormatPurpose purpose{};
uint32_t sample_bytes{0};
uint32_t bitrate{0};
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG);
+7
View File
@@ -1643,6 +1643,12 @@ const char *ListEntitiesServicesArgument::dump_to(DumpBuffer &out) const {
#endif
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
dump_progmem_string_field(out, ESPHOME_PSTR("example"), this->example);
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
dump_field(out, ESPHOME_PSTR("optional"), this->optional);
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
dump_progmem_string_field(out, ESPHOME_PSTR("default_value"), this->default_value);
#endif
return out.c_str();
}
@@ -2099,6 +2105,7 @@ const char *MediaPlayerSupportedFormat::dump_to(DumpBuffer &out) const {
dump_field(out, ESPHOME_PSTR("num_channels"), this->num_channels);
dump_field(out, ESPHOME_PSTR("purpose"), static_cast<enums::MediaPlayerFormatPurpose>(this->purpose));
dump_field(out, ESPHOME_PSTR("sample_bytes"), this->sample_bytes);
dump_field(out, ESPHOME_PSTR("bitrate"), this->bitrate);
return out.c_str();
}
const char *ListEntitiesMediaPlayerResponse::dump_to(DumpBuffer &out) const {
+7
View File
@@ -31,6 +31,13 @@ ListEntitiesServicesResponse UserServiceStatic::encode_list_service_response_(
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
arg.description = this->str_(base + 1);
arg.example = this->str_(base + 2);
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
// The default slot doubles as the optional flag: nullptr is required, "" optional without a default
const char *d = progmem_read_ptr(&this->strings_[base + USER_ACTION_ARG_BASE_STRINGS]);
arg.optional = d != nullptr;
if (d != nullptr)
arg.default_value = ProgmemStringRef(d, ESPHOME_strlen_P(d));
#endif
}
return msg;
+10 -3
View File
@@ -40,13 +40,20 @@ template<typename T> enums::ServiceArgType to_service_arg_type();
// All strings live in one PROGMEM pointer table emitted by codegen (see _action_strings in
// __init__.py), so each service costs a single pointer of RAM. Layout: the action name, then
// each argument name; with USE_API_USER_DEFINED_ACTION_METADATA the action description follows
// the name and every argument is (name, description, example). Unset metadata is nullptr.
// the name and every argument is (name, description, example); with
// USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS every argument ends with its default: nullptr for a
// required argument, "" for an optional one without a default. Unset is nullptr.
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
static constexpr size_t USER_ACTION_HEADER_STRINGS = 2;
static constexpr size_t USER_ACTION_ARG_STRINGS = 3;
static constexpr size_t USER_ACTION_ARG_BASE_STRINGS = 3;
#else
static constexpr size_t USER_ACTION_HEADER_STRINGS = 1;
static constexpr size_t USER_ACTION_ARG_STRINGS = 1;
static constexpr size_t USER_ACTION_ARG_BASE_STRINGS = 1;
#endif
#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS
static constexpr size_t USER_ACTION_ARG_STRINGS = USER_ACTION_ARG_BASE_STRINGS + 1;
#else
static constexpr size_t USER_ACTION_ARG_STRINGS = USER_ACTION_ARG_BASE_STRINGS;
#endif
class UserServiceStatic : public UserServiceDescriptor {
public:
+2 -19
View File
@@ -295,11 +295,6 @@ async def to_code(config):
)
# Actions
SendPacketAction = ns.class_(
"SendPacketAction", automation.Action, cg.Parented.template(CC1101Component)
)
CC1101_ACTION_SCHEMA = cv.Schema(
maybe_simple_id({cv.GenerateID(CONF_ID): cv.use_id(CC1101Component)})
)
@@ -338,23 +333,11 @@ SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value(
)
@automation.register_action(
automation.register_apply_action(
"cc1101.send_packet",
SendPacketAction,
SEND_PACKET_ACTION_SCHEMA,
synchronous=True,
automation.ApplyBytes(CONF_DATA, "transmit_packet({})"),
)
async def send_packet_action_to_code(config, action_id, template_arg, args):
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
await automation.templatable_bytes(
config[CONF_DATA],
args,
var.set_data_template,
var.set_data_static,
"cc1101_data",
)
return var
_CONFIG_VALIDATORS = {opt.schema: validator for opt, validator in CONFIG_MAP.items()}
-9
View File
@@ -132,13 +132,4 @@ class CC1101Component final : public Component,
bool enter_tx_();
};
// Action Wrappers
template<typename... Ts> class SendPacketAction final : public Action<Ts...>, public Parented<CC1101Component> {
TEMPLATABLE_BYTES(data)
void play(const Ts &...x) override {
this->data_.visit([this](const uint8_t *data, size_t len) { this->parent_->transmit_packet(data, len); }, x...);
}
};
} // namespace esphome::cc1101
+8
View File
@@ -79,6 +79,10 @@ class ClimateCall {
ClimateCall &set_fan_mode(optional<std::string> fan_mode);
/// Set the custom fan mode of the climate device.
ClimateCall &set_fan_mode(const char *custom_fan_mode);
/// Set the custom fan mode of the climate device (StringRef overload to resolve ambiguity, zero-copy).
ClimateCall &set_fan_mode(StringRef custom_fan_mode) {
return this->set_fan_mode(custom_fan_mode.c_str(), custom_fan_mode.size());
}
/// Set the custom fan mode of the climate device (zero-copy API path).
ClimateCall &set_fan_mode(const char *custom_fan_mode, size_t len);
/// Set the swing mode of the climate device.
@@ -99,6 +103,10 @@ class ClimateCall {
ClimateCall &set_preset(optional<std::string> preset);
/// Set the custom preset of the climate device.
ClimateCall &set_preset(const char *custom_preset);
/// Set the custom preset of the climate device (StringRef overload to resolve ambiguity, zero-copy).
ClimateCall &set_preset(StringRef custom_preset) {
return this->set_preset(custom_preset.c_str(), custom_preset.size());
}
/// Set the custom preset of the climate device (zero-copy API path).
ClimateCall &set_preset(const char *custom_preset, size_t len);
+1 -4
View File
@@ -8,10 +8,7 @@ ESPHOME_LOG_TAG(TAG, "copy.lock");
void CopyLock::setup() {
source_->add_on_state_callback([this](lock::LockState state) { this->publish_state(state); });
traits.set_assumed_state(source_->traits.get_assumed_state());
traits.set_requires_code(source_->traits.get_requires_code());
traits.set_supported_states_mask(source_->traits.get_supported_states_mask());
traits.set_supports_open(source_->traits.get_supports_open());
this->traits = this->source_->traits;
this->publish_state(source_->state);
}
+24 -18
View File
@@ -1,31 +1,37 @@
#pragma once
#include <cstdint>
namespace esphome::cover {
class CoverTraits {
public:
CoverTraits() = default;
bool get_is_assumed_state() const { return this->is_assumed_state_; }
void set_is_assumed_state(bool is_assumed_state) { this->is_assumed_state_ = is_assumed_state; }
bool get_supports_position() const { return this->supports_position_; }
void set_supports_position(bool supports_position) { this->supports_position_ = supports_position; }
bool get_supports_tilt() const { return this->supports_tilt_; }
void set_supports_tilt(bool supports_tilt) { this->supports_tilt_ = supports_tilt; }
bool get_supports_toggle() const { return this->supports_toggle_; }
void set_supports_toggle(bool supports_toggle) { this->supports_toggle_ = supports_toggle; }
bool get_supports_stop() const { return this->supports_stop_; }
void set_supports_stop(bool supports_stop) { this->supports_stop_ = supports_stop; }
bool get_supports_stop_tilt() const { return this->supports_stop_tilt_; }
void set_supports_stop_tilt(bool supports_stop_tilt) { this->supports_stop_tilt_ = supports_stop_tilt; }
bool get_is_assumed_state() const { return this->flags_.is_assumed_state; }
void set_is_assumed_state(bool is_assumed_state) { this->flags_.is_assumed_state = is_assumed_state; }
bool get_supports_position() const { return this->flags_.supports_position; }
void set_supports_position(bool supports_position) { this->flags_.supports_position = supports_position; }
bool get_supports_tilt() const { return this->flags_.supports_tilt; }
void set_supports_tilt(bool supports_tilt) { this->flags_.supports_tilt = supports_tilt; }
bool get_supports_toggle() const { return this->flags_.supports_toggle; }
void set_supports_toggle(bool supports_toggle) { this->flags_.supports_toggle = supports_toggle; }
bool get_supports_stop() const { return this->flags_.supports_stop; }
void set_supports_stop(bool supports_stop) { this->flags_.supports_stop = supports_stop; }
bool get_supports_stop_tilt() const { return this->flags_.supports_stop_tilt; }
void set_supports_stop_tilt(bool supports_stop_tilt) { this->flags_.supports_stop_tilt = supports_stop_tilt; }
protected:
bool is_assumed_state_{false};
bool supports_position_{false};
bool supports_tilt_{false};
bool supports_toggle_{false};
bool supports_stop_{false};
bool supports_stop_tilt_{false};
// Bit-packed flags to save memory (1 byte instead of 6)
struct CoverTraitFlags {
uint8_t is_assumed_state : 1;
uint8_t supports_position : 1;
uint8_t supports_tilt : 1;
uint8_t supports_toggle : 1;
uint8_t supports_stop : 1;
uint8_t supports_stop_tilt : 1;
uint8_t reserved : 2;
} flags_{};
};
} // namespace esphome::cover
+2
View File
@@ -0,0 +1,2 @@
CODEOWNERS = ["@exciton"]
DOMAIN = "cw2017"
+196
View File
@@ -0,0 +1,196 @@
#include "cw2017.h"
#include <cstring>
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#ifdef USE_DEEP_SLEEP
#include "esphome/components/deep_sleep/deep_sleep_component.h"
#endif
namespace esphome::cw2017 {
ESPHOME_LOG_TAG(TAG, "cw2017");
static constexpr uint8_t REG_VERSION = 0x00;
static constexpr uint8_t REG_VCELL = 0x02;
static constexpr uint8_t REG_SOC = 0x04;
static constexpr uint8_t REG_MODE = 0x08;
static constexpr uint8_t REG_SOC_ALERT = 0x0B;
static constexpr uint8_t REG_BATINFO = 0x10;
static constexpr uint8_t MODE_NORMAL = 0x00;
static constexpr uint8_t MODE_SLEEP = 0x30;
static constexpr uint8_t UPDATE_FLAG = 0x80;
static constexpr uint8_t RESTART_MODES[] PROGMEM = {0xF0, MODE_SLEEP, MODE_NORMAL};
static constexpr uint8_t RESTART_STEPS = sizeof(RESTART_MODES);
static constexpr uint16_t VCELL_MASK = 0x3FFF;
static constexpr float VCELL_VOLTS_PER_LSB = 312.5e-6f;
static constexpr uint32_t STEP_DELAY_MS = 20;
static constexpr uint32_t READY_POLL_MS = 100;
static constexpr uint8_t READY_ATTEMPTS = 40;
static constexpr uint32_t INIT_RETRY_MS = 1000;
static constexpr uint8_t INIT_ATTEMPTS = 3;
static bool version_is_running(uint8_t version) { return (version & 0xFD) == 0x0D; }
// The configured profile only changes with new firmware, and flashing never ends in a deep-sleep wake.
static bool woke_from_deep_sleep() {
#ifdef USE_DEEP_SLEEP
return deep_sleep::woke_from_deep_sleep();
#else
return false;
#endif
}
bool CW2017::read_profile_matches_(bool &matches) {
uint8_t stored[PROFILE_SIZE];
uint8_t expected[PROFILE_SIZE];
if (!this->read_bytes(REG_BATINFO, stored, PROFILE_SIZE))
return false;
progmem_memcpy(expected, this->profile_, PROFILE_SIZE);
matches = std::memcmp(stored, expected, PROFILE_SIZE) == 0;
return true;
}
bool CW2017::upload_profile_() {
// Built here with the register first, so the bus layer does not heap-allocate a buffer this size.
uint8_t buffer[1 + PROFILE_SIZE] = {REG_BATINFO};
progmem_memcpy(buffer + 1, this->profile_, PROFILE_SIZE);
return this->write(buffer, sizeof(buffer)) == i2c::ERROR_OK && this->write_byte(REG_SOC_ALERT, UPDATE_FLAG);
}
void CW2017::setup() { this->init_(); }
// Retried because another device resetting on the same bus can disturb an early transfer.
void CW2017::init_failed_(const LogString *reason) {
if (++this->init_attempts_ >= INIT_ATTEMPTS) {
this->mark_failed(reason);
return;
}
this->set_timeout(INIT_RETRY_MS, [this]() { this->init_(); });
}
void CW2017::init_() {
// A profile upload only takes effect after a restart, so a failed restart is retried, not skipped.
if (this->restart_pending_) {
this->restart_();
return;
}
uint8_t mode = 0;
uint8_t version = 0;
uint8_t config = 0;
if (!this->read_byte(REG_MODE, &mode) || !this->read_byte(REG_VERSION, &version) ||
!this->read_byte(REG_SOC_ALERT, &config)) {
this->init_failed_(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
const bool has_profile = (config & UPDATE_FLAG) != 0;
bool matches = has_profile && woke_from_deep_sleep();
if (has_profile && !matches && !this->read_profile_matches_(matches)) {
this->init_failed_(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
if (!matches) {
ESP_LOGI(TAG, "Uploading battery profile");
if (!this->upload_profile_()) {
this->init_failed_(LOG_STR("Battery profile upload failed"));
return;
}
this->restart_();
return;
}
if (mode == MODE_SLEEP) {
// The gauge keeps its profile and state through its own sleep, so a wake is a mode write, not a restart.
if (!this->write_byte(REG_MODE, MODE_NORMAL)) {
this->init_failed_(LOG_STR("Gauge wake failed"));
return;
}
this->set_timeout(STEP_DELAY_MS, [this]() { this->wait_ready_(0); });
return;
}
if (mode != MODE_NORMAL || !version_is_running(version)) {
this->restart_();
return;
}
this->wait_ready_(0);
}
void CW2017::restart_() {
this->restart_pending_ = true;
this->set_timeout(STEP_DELAY_MS, [this]() { this->restart_step_(0); });
}
void CW2017::restart_step_(uint8_t step) {
if (!this->write_byte(REG_MODE, progmem_read_byte(&RESTART_MODES[step]))) {
this->init_failed_(LOG_STR("Restart failed"));
return;
}
if (step + 1 < RESTART_STEPS) {
this->set_timeout(STEP_DELAY_MS, [this, step]() { this->restart_step_(step + 1); });
} else {
this->restart_pending_ = false;
this->set_timeout(STEP_DELAY_MS, [this]() { this->wait_ready_(0); });
}
}
void CW2017::wait_ready_(uint8_t attempt) {
uint8_t version = 0;
uint8_t soc = 0;
if (this->read_byte(REG_VERSION, &version) && version_is_running(version) && this->read_byte(REG_SOC, &soc) &&
soc <= 100) {
ESP_LOGD(TAG, "Gauge ready %u ms after boot", (unsigned) millis());
this->ready_ = true;
this->update();
return;
}
if (attempt + 1 >= READY_ATTEMPTS) {
this->init_failed_(LOG_STR("Gauge did not become ready"));
return;
}
this->set_timeout(READY_POLL_MS, [this, attempt]() { this->wait_ready_(attempt + 1); });
}
void CW2017::update() {
if (!this->ready_)
return;
if (this->battery_level_sensor_ != nullptr) {
uint8_t soc = 0;
if (this->read_byte(REG_SOC, &soc) && soc <= 100) {
this->battery_level_sensor_->publish_state(soc);
} else {
this->status_set_warning();
return;
}
}
if (this->battery_voltage_sensor_ != nullptr) {
uint16_t raw = 0;
if (this->read_byte_16(REG_VCELL, &raw)) {
this->battery_voltage_sensor_->publish_state((raw & VCELL_MASK) * VCELL_VOLTS_PER_LSB);
} else {
this->status_set_warning();
return;
}
}
this->status_clear_warning();
}
void CW2017::dump_config() {
ESP_LOGCONFIG(TAG, "CW2017:");
LOG_I2C_DEVICE(this);
LOG_UPDATE_INTERVAL(this);
LOG_SENSOR(" ", "Battery Level", this->battery_level_sensor_);
LOG_SENSOR(" ", "Battery Voltage", this->battery_voltage_sensor_);
}
} // namespace esphome::cw2017
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/component.h"
namespace esphome::cw2017 {
static constexpr uint8_t PROFILE_SIZE = 80;
class CW2017 : public PollingComponent, public i2c::I2CDevice {
public:
explicit CW2017(const uint8_t *profile) : profile_(profile) {}
void set_battery_level_sensor(sensor::Sensor *sensor) { this->battery_level_sensor_ = sensor; }
void set_battery_voltage_sensor(sensor::Sensor *sensor) { this->battery_voltage_sensor_ = sensor; }
void setup() override;
void update() override;
void dump_config() override;
protected:
void init_();
void init_failed_(const LogString *reason);
bool read_profile_matches_(bool &matches);
bool upload_profile_();
void restart_();
void restart_step_(uint8_t step);
void wait_ready_(uint8_t attempt);
const uint8_t *profile_;
sensor::Sensor *battery_level_sensor_{nullptr};
sensor::Sensor *battery_voltage_sensor_{nullptr};
bool ready_{false};
bool restart_pending_{false};
uint8_t init_attempts_{0};
};
} // namespace esphome::cw2017
+61
View File
@@ -0,0 +1,61 @@
import esphome.codegen as cg
from esphome.components import i2c, sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_BATTERY_LEVEL,
CONF_BATTERY_VOLTAGE,
CONF_RAW_DATA_ID,
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_VOLTAGE,
ENTITY_CATEGORY_DIAGNOSTIC,
STATE_CLASS_MEASUREMENT,
UNIT_PERCENT,
UNIT_VOLT,
)
from esphome.types import ConfigType
DEPENDENCIES = ["i2c"]
CONF_BATTERY_PROFILE = "battery_profile"
PROFILE_SIZE = 80
cw2017_ns = cg.esphome_ns.namespace("cw2017")
CW2017 = cw2017_ns.class_("CW2017", cg.PollingComponent, i2c.I2CDevice)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(CW2017),
cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8),
cv.Required(CONF_BATTERY_PROFILE): cv.All(
cv.ensure_list(cv.hex_uint8_t),
cv.Length(min=PROFILE_SIZE, max=PROFILE_SIZE),
),
cv.Optional(CONF_BATTERY_LEVEL): sensor.sensor_schema(
unit_of_measurement=UNIT_PERCENT,
accuracy_decimals=0,
device_class=DEVICE_CLASS_BATTERY,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_BATTERY_VOLTAGE): sensor.sensor_schema(
unit_of_measurement=UNIT_VOLT,
accuracy_decimals=3,
device_class=DEVICE_CLASS_VOLTAGE,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
)
.extend(cv.polling_component_schema("60s"))
.extend(i2c.i2c_device_schema(0x63))
)
async def to_code(config: ConfigType) -> None:
profile = cg.progmem_array(config[CONF_RAW_DATA_ID], config[CONF_BATTERY_PROFILE])
var = await i2c.new_i2c_component(config, profile)
sensors = sensor.sub_sensors(config)
await sensors(CONF_BATTERY_LEVEL, var.set_battery_level_sensor)
await sensors(CONF_BATTERY_VOLTAGE, var.set_battery_voltage_sensor)
@@ -7,7 +7,6 @@ namespace esphome::deep_sleep {
ESPHOME_LOG_TAG(TAG, "deep_sleep");
#ifdef USE_DEEP_SLEEP_ON_WAKE
WakeupCause get_wakeup_cause() {
switch (lt_get_reboot_reason()) {
case REBOOT_REASON_SLEEP_GPIO:
@@ -20,7 +19,6 @@ WakeupCause get_wakeup_cause() {
return WAKEUP_CAUSE_NONE;
}
}
#endif // USE_DEEP_SLEEP_ON_WAKE
optional<uint32_t> DeepSleepComponent::get_run_duration_() const { return this->run_duration_; }
@@ -60,8 +60,6 @@ struct WakeupCauseToRunDuration {
#endif // USE_ESP32
#ifdef USE_DEEP_SLEEP_ON_WAKE
/// Why the device woke from deep sleep. Passed to on_wake automations.
enum WakeupCause : uint8_t {
/// The device did not wake from deep sleep (for example a cold boot, reset or OTA restart).
@@ -79,6 +77,11 @@ enum WakeupCause : uint8_t {
/// Return why the device woke from deep sleep. Implemented per platform.
WakeupCause get_wakeup_cause();
/// Return true when this boot is a wake from deep sleep.
inline bool woke_from_deep_sleep() { return get_wakeup_cause() != WAKEUP_CAUSE_NONE; }
#ifdef USE_DEEP_SLEEP_ON_WAKE
/** Setup priority of on_wake triggers.
*
* Between restoring global variables (setup_priority::HARDWARE, 800) and on_boot automations at
@@ -30,7 +30,6 @@ namespace esphome::deep_sleep {
ESPHOME_LOG_TAG(TAG, "deep_sleep");
#ifdef USE_DEEP_SLEEP_ON_WAKE
WakeupCause get_wakeup_cause() {
switch (esp_sleep_get_wakeup_cause()) {
case ESP_SLEEP_WAKEUP_EXT0:
@@ -47,7 +46,6 @@ WakeupCause get_wakeup_cause() {
return WAKEUP_CAUSE_UNKNOWN;
}
}
#endif // USE_DEEP_SLEEP_ON_WAKE
optional<uint32_t> DeepSleepComponent::get_run_duration_() const {
if (this->wakeup_cause_to_run_duration_.has_value()) {
@@ -3,17 +3,14 @@
#include <Esp.h>
#ifdef USE_DEEP_SLEEP_ON_WAKE
extern "C" {
#include <user_interface.h>
}
#endif
namespace esphome::deep_sleep {
ESPHOME_LOG_TAG(TAG, "deep_sleep");
#ifdef USE_DEEP_SLEEP_ON_WAKE
WakeupCause get_wakeup_cause() {
// The ESP8266 can only wake from deep sleep through the RTC timer (via GPIO16 -> RST).
// NOLINTNEXTLINE(readability-static-accessed-through-instance)
@@ -22,7 +19,6 @@ WakeupCause get_wakeup_cause() {
}
return WAKEUP_CAUSE_NONE;
}
#endif // USE_DEEP_SLEEP_ON_WAKE
optional<uint32_t> DeepSleepComponent::get_run_duration_() const { return this->run_duration_; }
@@ -31,6 +31,9 @@ static bool wakeable_delay_feed_wdt(uint32_t ms) {
return false;
}
// A wake from System OFF is not told apart from a reset yet.
WakeupCause get_wakeup_cause() { return WAKEUP_CAUSE_NONE; }
optional<uint32_t> DeepSleepComponent::get_run_duration_() const { return this->run_duration_; }
void DeepSleepComponent::dump_config_platform_() {}
+239 -6
View File
@@ -1,7 +1,18 @@
from esphome import automation
from esphome import automation, pins
import esphome.codegen as cg
from esphome.components import i2c
from esphome.components.const import CONF_ENABLED
import esphome.config_validation as cv
from esphome.const import (
CONF_FREQUENCY,
CONF_HOUR,
CONF_INTERRUPT_PIN,
CONF_MINUTE,
CONF_MODE,
CONF_OFFSET,
CONF_SECOND,
CONF_STATE,
)
from esphome.types import ConfigType
CODEOWNERS = ["@linkedupbits"]
@@ -10,23 +21,245 @@ MULTI_CONF = True
DOMAIN = "ds3231"
CONF_DS3231_ID = "ds3231_id"
CONF_ALARM = "alarm"
CONF_ALARM_CHECK_INTERVAL = "alarm_check_interval"
CONF_AGING_OFFSET = "aging_offset"
CONF_AUTO_CLEAR_ALARMS = "auto_clear_alarms"
CONF_BATTERY_BACKED_SQUARE_WAVE = "battery_backed_square_wave"
CONF_DAY_OF_MONTH = "day_of_month"
CONF_DAY_OF_WEEK = "day_of_week"
CONF_ON_ALARM_1 = "on_alarm_1"
CONF_ON_ALARM_2 = "on_alarm_2"
CONF_OUTPUT_32KHZ = "output_32khz"
CONF_SQUARE_WAVE_FREQUENCY = "square_wave_frequency"
ds3231_ns = cg.esphome_ns.namespace("ds3231")
DS3231Component = ds3231_ns.class_("DS3231Component", cg.Component, i2c.I2CDevice)
CONFIG_SCHEMA = (
cv.Schema({cv.GenerateID(): cv.declare_id(DS3231Component)})
AlarmMode = ds3231_ns.enum("AlarmMode")
ALARM_1_MODES = AlarmMode.options(
"every_second",
"every_minute",
"hourly",
"daily",
"monthly",
"weekly",
prefix="ALARM_MODE_",
)
# Alarm 2 has no seconds register.
ALARM_2_MODES = {k: v for k, v in ALARM_1_MODES.items() if k != "every_second"}
SquareWaveFrequency = ds3231_ns.enum("SquareWaveFrequency")
# Keyed by the frequency in Hz; cv.frequency turns "1024Hz", "1.024kHz" or 1024 into it.
SQUARE_WAVE_FREQUENCIES = {
1: SquareWaveFrequency.SQUARE_WAVE_1HZ,
1024: SquareWaveFrequency.SQUARE_WAVE_1024HZ,
4096: SquareWaveFrequency.SQUARE_WAVE_4096HZ,
8192: SquareWaveFrequency.SQUARE_WAVE_8192HZ,
}
validate_square_wave_frequency = cv.All(
cv.frequency, cv.enum(SQUARE_WAVE_FREQUENCIES, int=True)
)
validate_aging_offset = cv.int_range(min=-128, max=127)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(DS3231Component),
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_SQUARE_WAVE_FREQUENCY): validate_square_wave_frequency,
cv.Optional(CONF_BATTERY_BACKED_SQUARE_WAVE): cv.boolean,
cv.Optional(CONF_OUTPUT_32KHZ): cv.boolean,
cv.Optional(CONF_AGING_OFFSET): validate_aging_offset,
cv.Optional(CONF_AUTO_CLEAR_ALARMS, default=True): cv.boolean,
cv.Optional(
CONF_ALARM_CHECK_INTERVAL, default="1s"
): cv.positive_time_period_milliseconds,
cv.Optional(CONF_ON_ALARM_1): automation.validate_automation(single=True),
cv.Optional(CONF_ON_ALARM_2): automation.validate_automation(single=True),
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(i2c.i2c_device_schema(0x68))
.extend(i2c.i2c_device_schema(0x68)),
cv.has_at_most_one_key(CONF_INTERRUPT_PIN, CONF_SQUARE_WAVE_FREQUENCY),
)
async def to_code(config: ConfigType) -> None:
await i2c.new_i2c_component(config)
var = await i2c.new_i2c_component(config)
await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN))
cg.add_optional(
var.set_square_wave_frequency, config.get(CONF_SQUARE_WAVE_FREQUENCY)
)
cg.add_optional(
var.set_battery_backed_square_wave, config.get(CONF_BATTERY_BACKED_SQUARE_WAVE)
)
cg.add_optional(var.set_output_32khz_config, config.get(CONF_OUTPUT_32KHZ))
cg.add_optional(var.set_aging_offset_config, config.get(CONF_AGING_OFFSET))
cg.add(var.set_auto_clear_alarms(config[CONF_AUTO_CLEAR_ALARMS]))
cg.add(var.set_alarm_check_interval(config[CONF_ALARM_CHECK_INTERVAL]))
# The alarm flags are only read when something uses them; the binary sensor platform
# turns this on as well. With an interrupt pin the check also releases INT/SQW.
if any(
key in config for key in (CONF_INTERRUPT_PIN, CONF_ON_ALARM_1, CONF_ON_ALARM_2)
):
cg.add(var.set_monitor_alarms(True))
# One callback for both alarms; each automation runs only for its own alarm number.
for conf_key, alarm in ((CONF_ON_ALARM_1, 1), (CONF_ON_ALARM_2, 2)):
if (conf := config.get(conf_key)) is not None:
await automation.build_callback_automation(
var,
"add_on_alarm_callback",
[],
conf,
params=[(cg.uint8, "alarm")],
forward=[],
when=f"alarm == {alarm}",
)
# ---------------------------------------------------------------------------------------------
# Actions
# ---------------------------------------------------------------------------------------------
DS3231_ACTION_SCHEMA = automation.maybe_simple_id(
{cv.GenerateID(): cv.use_id(DS3231Component)}
)
automation.register_apply_action(
"ds3231.force_temperature_conversion",
automation.maybe_simple_id({cv.GenerateID(): cv.use_id(DS3231Component)}),
DS3231_ACTION_SCHEMA,
automation.ApplyCall("force_temperature_conversion()"),
)
def _validate_alarm_day(config: ConfigType) -> ConfigType:
mode = config[CONF_MODE]
if mode == "monthly" and CONF_DAY_OF_MONTH not in config:
raise cv.Invalid(f"'{CONF_DAY_OF_MONTH}' is required in monthly mode")
if mode == "weekly" and CONF_DAY_OF_WEEK not in config:
raise cv.Invalid(f"'{CONF_DAY_OF_WEEK}' is required in weekly mode")
# The unused day still has to be a valid register value.
config.setdefault(CONF_DAY_OF_MONTH, 1)
config.setdefault(CONF_DAY_OF_WEEK, 1)
return config
def _alarm_schema(modes: dict, with_second: bool) -> cv.Schema:
schema = {
cv.GenerateID(): cv.use_id(DS3231Component),
cv.Required(CONF_MODE): cv.enum(modes, lower=True),
cv.Optional(CONF_MINUTE, default=0): cv.templatable(
cv.int_range(min=0, max=59)
),
cv.Optional(CONF_HOUR, default=0): cv.templatable(cv.int_range(min=0, max=23)),
cv.Optional(CONF_DAY_OF_MONTH): cv.templatable(cv.int_range(min=1, max=31)),
# 1 = Sunday ... 7 = Saturday, as in ESPHome's time component.
cv.Optional(CONF_DAY_OF_WEEK): cv.templatable(cv.int_range(min=1, max=7)),
cv.Optional(CONF_ENABLED, default=True): cv.templatable(cv.boolean),
}
if with_second:
schema[cv.Optional(CONF_SECOND, default=0)] = cv.templatable(
cv.int_range(min=0, max=59)
)
return cv.All(cv.Schema(schema), _validate_alarm_day)
automation.register_apply_action(
"ds3231.set_alarm_1",
_alarm_schema(ALARM_1_MODES, with_second=True),
automation.ApplyCall(
"set_alarm_1({}, {}, {}, {}, {}, {}, {})",
(
(CONF_MODE, AlarmMode),
(CONF_SECOND, cg.uint8),
(CONF_MINUTE, cg.uint8),
(CONF_HOUR, cg.uint8),
(CONF_DAY_OF_MONTH, cg.uint8),
(CONF_DAY_OF_WEEK, cg.uint8),
(CONF_ENABLED, cg.bool_),
),
),
)
automation.register_apply_action(
"ds3231.set_alarm_2",
_alarm_schema(ALARM_2_MODES, with_second=False),
automation.ApplyCall(
"set_alarm_2({}, {}, {}, {}, {}, {})",
(
(CONF_MODE, AlarmMode),
(CONF_MINUTE, cg.uint8),
(CONF_HOUR, cg.uint8),
(CONF_DAY_OF_MONTH, cg.uint8),
(CONF_DAY_OF_WEEK, cg.uint8),
(CONF_ENABLED, cg.bool_),
),
),
)
DS3231_ALARM_ACTION_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.use_id(DS3231Component),
cv.Required(CONF_ALARM): cv.templatable(cv.int_range(min=1, max=2)),
}
)
for _name, _method in (
("ds3231.enable_alarm", "enable_alarm"),
("ds3231.disable_alarm", "disable_alarm"),
("ds3231.clear_alarm", "clear_alarm"),
):
automation.register_apply_action(
_name,
DS3231_ALARM_ACTION_SCHEMA,
automation.ApplyField(CONF_ALARM, _method, cg.uint8),
)
automation.register_apply_condition(
"ds3231.is_alarm_triggered",
DS3231_ALARM_ACTION_SCHEMA,
automation.ApplyCall("is_alarm_triggered({})", ((CONF_ALARM, cg.uint8),)),
)
automation.register_apply_action(
"ds3231.enable_square_wave",
cv.Schema(
{
cv.GenerateID(): cv.use_id(DS3231Component),
cv.Required(CONF_FREQUENCY): validate_square_wave_frequency,
}
),
automation.ApplyField(CONF_FREQUENCY, "enable_square_wave", SquareWaveFrequency),
)
automation.register_apply_action(
"ds3231.disable_square_wave",
DS3231_ACTION_SCHEMA,
automation.ApplyCall("disable_square_wave()"),
)
automation.register_apply_action(
"ds3231.set_32khz_output",
cv.Schema(
{
cv.GenerateID(): cv.use_id(DS3231Component),
cv.Required(CONF_STATE): cv.templatable(cv.boolean),
}
),
automation.ApplyField(CONF_STATE, "set_output_32khz", cg.bool_),
)
automation.register_apply_action(
"ds3231.set_aging_offset",
cv.Schema(
{
cv.GenerateID(): cv.use_id(DS3231Component),
cv.Required(CONF_OFFSET): cv.templatable(validate_aging_offset),
}
),
automation.ApplyField(CONF_OFFSET, "set_aging_offset", cg.int8),
)
@@ -0,0 +1,47 @@
import esphome.codegen as cg
from esphome.components import binary_sensor
import esphome.config_validation as cv
from esphome.const import CONF_ID, DEVICE_CLASS_PROBLEM, ENTITY_CATEGORY_DIAGNOSTIC
from esphome.types import ConfigType
from .. import CONF_DS3231_ID, DS3231Component
DEPENDENCIES = ["ds3231"]
CONF_ALARM_1 = "alarm_1"
CONF_ALARM_2 = "alarm_2"
CONF_OSCILLATOR_STOPPED = "oscillator_stopped"
ICON_ALARM = "mdi:alarm"
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase),
cv.GenerateID(CONF_DS3231_ID): cv.use_id(DS3231Component),
cv.Optional(CONF_ALARM_1): binary_sensor.binary_sensor_schema(
icon=ICON_ALARM
),
cv.Optional(CONF_ALARM_2): binary_sensor.binary_sensor_schema(
icon=ICON_ALARM
),
cv.Optional(CONF_OSCILLATOR_STOPPED): binary_sensor.binary_sensor_schema(
device_class=DEVICE_CLASS_PROBLEM,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
),
cv.has_at_least_one_key(CONF_ALARM_1, CONF_ALARM_2, CONF_OSCILLATOR_STOPPED),
)
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_DS3231_ID])
binary_sensors = binary_sensor.sub_binary_sensors(config)
await binary_sensors(CONF_ALARM_1, hub.set_alarm_1_binary_sensor)
await binary_sensors(CONF_ALARM_2, hub.set_alarm_2_binary_sensor)
await binary_sensors(
CONF_OSCILLATOR_STOPPED, hub.set_oscillator_stopped_binary_sensor
)
if CONF_ALARM_1 in config or CONF_ALARM_2 in config:
cg.add(hub.set_monitor_alarms(True))
+423 -14
View File
@@ -9,29 +9,184 @@ namespace esphome::ds3231 {
ESPHOME_LOG_TAG(TAG, "ds3231");
static constexpr uint8_t DS3231_REG_SECONDS = 0x00;
static constexpr uint8_t DS3231_REG_ALARM_1 = 0x07;
static constexpr uint8_t DS3231_REG_ALARM_2 = 0x0B;
static constexpr uint8_t DS3231_REG_CONTROL = 0x0E;
static constexpr uint8_t DS3231_REG_STATUS = 0x0F;
static constexpr uint8_t DS3231_REG_AGING_OFFSET = 0x10;
static constexpr uint8_t DS3231_REG_TEMPERATURE = 0x11;
static constexpr uint8_t ALARM_MASK_BIT = 0x80; // AxMy: this register is ignored when matching
static constexpr uint8_t ALARM_DAY_OF_WEEK = 0x40; // DY/DT: match day of week instead of date
/// How long an alarm binary sensor stays on when the flag is cleared automatically.
static constexpr uint32_t ALARM_PULSE_MS = 1000;
static const LogString *alarm_state_to_string(uint8_t control, uint8_t alarm) {
return (control & (alarm == 1 ? 0x01 : 0x02)) != 0 ? LOG_STR("enabled") : LOG_STR("disabled");
}
static const LogString *square_wave_to_string(SquareWaveFrequency frequency) {
switch (frequency) {
case SQUARE_WAVE_1HZ:
return LOG_STR("1 Hz");
case SQUARE_WAVE_1024HZ:
return LOG_STR("1.024 kHz");
case SQUARE_WAVE_4096HZ:
return LOG_STR("4.096 kHz");
case SQUARE_WAVE_8192HZ:
return LOG_STR("8.192 kHz");
default:
return LOG_STR("unknown");
}
}
void IRAM_ATTR DS3231Component::interrupt_handler(DS3231Component *arg) { arg->enable_loop_soon_any_context(); }
void DS3231Component::setup() {
if (!this->read_control_status_()) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
this->control_reg_ &= ~CONTROL_EOSC; // keep the oscillator running on battery
// INT/SQW is the (unused) interrupt line rather than a square-wave output.
this->control_reg_ |= CONTROL_INTCN;
if (!this->write_control_()) {
// Keep the oscillator running on battery. INT/SQW is the alarm interrupt line unless a square
// wave is configured. The alarm interrupt enables (A1IE/A2IE) are kept as they are: the alarms
// live on the chip and survive a reboot of the ESP on battery.
uint8_t mask = CONTROL_EOSC | CONTROL_INTCN;
uint8_t value = CONTROL_INTCN;
if (this->square_wave_configured_) {
mask |= CONTROL_RS_MASK;
value = this->square_wave_frequency_ << CONTROL_RS_SHIFT;
}
if (this->battery_backed_square_wave_ >= 0) {
mask |= CONTROL_BBSQW;
assign_bits(value, CONTROL_BBSQW, this->battery_backed_square_wave_ != 0);
}
if (!this->update_control_(mask, value)) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
if (this->output_32khz_ >= 0) {
assign_bits(this->status_reg_, STATUS_EN32KHZ, this->output_32khz_ != 0);
if (!this->write_status_(0)) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
}
if (this->aging_offset_configured_ && !this->write_aging_offset_(this->aging_offset_)) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
if ((this->status_reg_ & STATUS_OSF) != 0) {
ESP_LOGW(TAG, "Oscillator was stopped (battery empty or missing?), the stored time is invalid.");
}
this->publish_oscillator_stopped_();
if (!this->monitor_alarms_) {
this->disable_loop();
return;
}
// Start from a known state; an alarm that fired while the ESP was off is picked up by the first
// check, which runs from the loop right after setup.
this->publish_alarm_state_(1, false);
this->publish_alarm_state_(2, false);
if (this->interrupt_pin_ != nullptr) {
this->interrupt_pin_->setup();
// INT/SQW is open drain and active low: a falling edge means an enabled alarm matched.
// The handler only wakes the loop; the flags are read from there.
this->interrupt_pin_->attach_interrupt(&DS3231Component::interrupt_handler, this, gpio::INTERRUPT_FALLING_EDGE);
}
// Without an interrupt pin this is how alarms are seen. With one it is a fallback: it picks up an
// edge that was missed, and latched flags (auto_clear_alarms: false) that were cleared elsewhere.
this->set_interval(this->alarm_check_interval_, [this]() { this->check_alarms_(); });
}
void DS3231Component::loop() {
// Only runs once after setup and when woken by the interrupt pin.
this->disable_loop();
this->check_alarms_();
}
void DS3231Component::dump_config() {
ESP_LOGCONFIG(TAG, "DS3231:");
LOG_I2C_DEVICE(this);
if (this->is_failed())
return;
LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_);
if ((this->control_reg_ & CONTROL_INTCN) == 0) {
ESP_LOGCONFIG(TAG, " INT/SQW: square wave %s",
LOG_STR_ARG(square_wave_to_string(
static_cast<SquareWaveFrequency>((this->control_reg_ & CONTROL_RS_MASK) >> CONTROL_RS_SHIFT))));
} else {
ESP_LOGCONFIG(TAG, " INT/SQW: alarm interrupt");
}
ESP_LOGCONFIG(TAG,
" Battery-backed square wave: %s\n"
" 32 kHz output: %s\n"
" Alarm 1: %s\n"
" Alarm 2: %s\n"
" Auto clear alarms: %s",
YESNO(this->control_reg_ & CONTROL_BBSQW), ONOFF(this->status_reg_ & STATUS_EN32KHZ),
LOG_STR_ARG(alarm_state_to_string(this->control_reg_, 1)),
LOG_STR_ARG(alarm_state_to_string(this->control_reg_, 2)), YESNO(this->auto_clear_alarms_));
if (this->aging_offset_configured_) {
ESP_LOGCONFIG(TAG, " Aging offset: %d", this->aging_offset_);
}
if (this->monitor_alarms_) {
ESP_LOGCONFIG(TAG, " Alarm check interval: %" PRIu32 " ms", this->alarm_check_interval_);
}
#ifdef USE_BINARY_SENSOR
LOG_BINARY_SENSOR(" ", "Alarm 1", this->alarm_1_binary_sensor_);
LOG_BINARY_SENSOR(" ", "Alarm 2", this->alarm_2_binary_sensor_);
LOG_BINARY_SENSOR(" ", "Oscillator Stopped", this->oscillator_stopped_binary_sensor_);
#endif
}
// ---------------------------------------------------------------------------------------------
// Clock
// ---------------------------------------------------------------------------------------------
bool DS3231Component::read_calendar(uint8_t *raw) {
if (this->is_failed() || !this->status_comm_result(this->read_bytes(DS3231_REG_SECONDS, raw, CALENDAR_LEN)))
return false;
// Without a valid status the oscillator-stop flag is unknown, so the time can't be trusted.
if (!this->read_control_status_())
return false;
this->publish_oscillator_stopped_();
if ((this->status_reg_ & STATUS_OSF) != 0) {
ESP_LOGW(TAG, "Oscillator stop flag set, the RTC time is invalid. Write a valid time first.");
return false;
}
return true;
}
bool DS3231Component::write_calendar(const uint8_t *raw) {
if (this->is_failed() || !this->status_comm_result(this->write_bytes(DS3231_REG_SECONDS, raw, CALENDAR_LEN)))
return false;
// The time is valid again: clear the oscillator stop flag. If that fails, the RTC would keep
// reporting its time as invalid, so the write is not reported as successful.
if (!this->read_control_status_())
return false;
if ((this->status_reg_ & STATUS_OSF) != 0) {
if (!this->write_status_(STATUS_OSF))
return false;
this->status_reg_ &= ~STATUS_OSF;
}
this->publish_oscillator_stopped_();
return true;
}
// ---------------------------------------------------------------------------------------------
// Temperature
// ---------------------------------------------------------------------------------------------
bool DS3231Component::read_temperature(float &out) {
uint8_t raw[2];
if (this->is_failed() || !this->read_bytes(DS3231_REG_TEMPERATURE, raw, sizeof(raw))) {
@@ -53,26 +208,280 @@ void DS3231Component::force_temperature_conversion() {
ESP_LOGW(TAG, "A temperature conversion is already in progress.");
return;
}
this->control_reg_ |= CONTROL_CONV;
this->write_control_();
// CONV is a one-shot command bit, so it is not kept in the cached value.
this->status_comm_result(this->write_byte(DS3231_REG_CONTROL, this->control_reg_ | CONTROL_CONV));
}
// ---------------------------------------------------------------------------------------------
// Alarms
// ---------------------------------------------------------------------------------------------
void DS3231Component::set_alarm_1(AlarmMode mode, uint8_t second, uint8_t minute, uint8_t hour, uint8_t day_of_month,
uint8_t day_of_week, bool enabled) {
if (second > 59 || minute > 59 || hour > 23 || day_of_month < 1 || day_of_month > 31 || day_of_week < 1 ||
day_of_week > 7 || mode > ALARM_MODE_WEEKLY) {
ESP_LOGE(TAG, "Invalid alarm 1 setting.");
return;
}
// Mask bits A1M1..A1M4: a set bit means the register is ignored when matching.
uint8_t regs[4] = {
uint8_to_bcd(second),
uint8_to_bcd(minute),
uint8_to_bcd(hour),
mode == ALARM_MODE_WEEKLY ? static_cast<uint8_t>(ALARM_DAY_OF_WEEK | day_of_week) : uint8_to_bcd(day_of_month),
};
switch (mode) {
case ALARM_MODE_EVERY_SECOND:
regs[0] |= ALARM_MASK_BIT;
[[fallthrough]];
case ALARM_MODE_EVERY_MINUTE:
regs[1] |= ALARM_MASK_BIT;
[[fallthrough]];
case ALARM_MODE_HOURLY:
regs[2] |= ALARM_MASK_BIT;
[[fallthrough]];
case ALARM_MODE_DAILY:
regs[3] |= ALARM_MASK_BIT;
break;
default: // MONTHLY / WEEKLY: everything matches
break;
}
ESP_LOGD(TAG, "Setting alarm 1: mode %u, %02u:%02u:%02u, day of month %u, day of week %u, %s", mode, hour, minute,
second, day_of_month, day_of_week, enabled ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled"));
this->write_alarm_(1, regs, sizeof(regs), enabled);
}
void DS3231Component::set_alarm_2(AlarmMode mode, uint8_t minute, uint8_t hour, uint8_t day_of_month,
uint8_t day_of_week, bool enabled) {
if (minute > 59 || hour > 23 || day_of_month < 1 || day_of_month > 31 || day_of_week < 1 || day_of_week > 7 ||
mode == ALARM_MODE_EVERY_SECOND || mode > ALARM_MODE_WEEKLY) {
ESP_LOGE(TAG, "Invalid alarm 2 setting.");
return;
}
// Mask bits A2M2..A2M4.
uint8_t regs[3] = {
uint8_to_bcd(minute),
uint8_to_bcd(hour),
mode == ALARM_MODE_WEEKLY ? static_cast<uint8_t>(ALARM_DAY_OF_WEEK | day_of_week) : uint8_to_bcd(day_of_month),
};
switch (mode) {
case ALARM_MODE_EVERY_MINUTE:
regs[0] |= ALARM_MASK_BIT;
[[fallthrough]];
case ALARM_MODE_HOURLY:
regs[1] |= ALARM_MASK_BIT;
[[fallthrough]];
case ALARM_MODE_DAILY:
regs[2] |= ALARM_MASK_BIT;
break;
default: // MONTHLY / WEEKLY: everything matches
break;
}
ESP_LOGD(TAG, "Setting alarm 2: mode %u, %02u:%02u, day of month %u, day of week %u, %s", mode, hour, minute,
day_of_month, day_of_week, enabled ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled"));
this->write_alarm_(2, regs, sizeof(regs), enabled);
}
void DS3231Component::write_alarm_(uint8_t alarm, const uint8_t *regs, size_t len, bool enabled) {
if (this->is_failed())
return;
if (!this->status_comm_result(this->write_bytes(alarm == 1 ? DS3231_REG_ALARM_1 : DS3231_REG_ALARM_2, regs, len)))
return;
// Changing the registers may have matched on the way: start from a clean flag. Enabling the
// alarm with a stale flag would assert INT/SQW right away.
if (!this->clear_alarm_flag_(alarm)) {
ESP_LOGW(TAG, "Alarm %u written, but its flag could not be cleared; not changing its enable.", alarm);
return;
}
this->set_alarm_enabled_(alarm, enabled);
}
void DS3231Component::set_alarm_enabled_(uint8_t alarm, bool enabled) {
if (alarm != 1 && alarm != 2) {
ESP_LOGE(TAG, "Invalid alarm number %u.", alarm);
return;
}
if (this->is_failed())
return;
const uint8_t bit = alarm == 1 ? CONTROL_A1IE : CONTROL_A2IE;
if (!this->update_control_(bit, enabled ? bit : 0))
return;
if (enabled && (this->control_reg_ & CONTROL_INTCN) == 0) {
ESP_LOGW(TAG, "Alarm %u enabled, but INT/SQW is a square-wave output: only the flag will be set.", alarm);
}
ESP_LOGD(TAG, "Alarm %u %s.", alarm, enabled ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled"));
}
void DS3231Component::enable_alarm(uint8_t alarm) {
// The chip sets the flag on every match even while the alarm is disabled; enabling with it set
// would fire the trigger (and INT/SQW) for that old match right away.
if (this->clear_alarm_flag_(alarm))
this->set_alarm_enabled_(alarm, true);
}
void DS3231Component::clear_alarm(uint8_t alarm) { this->clear_alarm_flag_(alarm); }
bool DS3231Component::clear_alarm_flag_(uint8_t alarm) {
if (alarm != 1 && alarm != 2) {
ESP_LOGE(TAG, "Invalid alarm number %u.", alarm);
return false;
}
if (this->is_failed() || !this->read_control_status_())
return false;
const uint8_t flag = alarm == 1 ? STATUS_A1F : STATUS_A2F;
if ((this->status_reg_ & flag) != 0) {
// On failure the flag is still set on the chip: keep reporting the alarm as it is.
if (!this->write_status_(flag))
return false;
this->status_reg_ &= ~flag;
}
this->alarm_active_[alarm - 1] = false;
this->publish_alarm_state_(alarm, false);
return true;
}
bool DS3231Component::is_alarm_triggered(uint8_t alarm) {
if (this->is_failed() || !this->read_control_status_())
return false;
return (this->status_reg_ & (alarm == 1 ? STATUS_A1F : STATUS_A2F)) != 0;
}
void DS3231Component::check_alarms_() {
if (this->is_failed() || !this->read_control_status_())
return;
this->publish_oscillator_stopped_();
uint8_t to_clear = 0;
for (uint8_t alarm = 1; alarm <= 2; alarm++) {
const uint8_t flag = alarm == 1 ? STATUS_A1F : STATUS_A2F;
const uint8_t enable = alarm == 1 ? CONTROL_A1IE : CONTROL_A2IE;
const bool triggered = (this->status_reg_ & flag) != 0;
const bool enabled = (this->control_reg_ & enable) != 0;
bool &active = this->alarm_active_[alarm - 1];
if (!triggered) {
if (active && !this->auto_clear_alarms_)
this->publish_alarm_state_(alarm, false);
active = false;
continue;
}
// The flag is set on every match even when the alarm is disabled; only enabled alarms count.
if (enabled && !active) {
ESP_LOGI(TAG, "Alarm %u triggered.", alarm);
active = true;
this->publish_alarm_state_(alarm, true);
this->alarm_callback_.call(alarm);
}
if (this->auto_clear_alarms_)
to_clear |= flag;
}
if (to_clear != 0 && this->write_status_(to_clear)) {
this->status_reg_ &= ~to_clear;
for (uint8_t alarm = 1; alarm <= 2; alarm++) {
if ((to_clear & (alarm == 1 ? STATUS_A1F : STATUS_A2F)) == 0 || !this->alarm_active_[alarm - 1])
continue;
this->alarm_active_[alarm - 1] = false;
#ifdef USE_BINARY_SENSOR
// Keep the binary sensor on long enough for the state to be seen, then turn it off again.
this->set_timeout(ALARM_PULSE_TIMEOUT_ID + alarm - 1, ALARM_PULSE_MS,
[this, alarm]() { this->publish_alarm_state_(alarm, false); });
#endif
}
}
}
void DS3231Component::publish_alarm_state_(uint8_t alarm, bool state) {
#ifdef USE_BINARY_SENSOR
auto *sensor = alarm == 1 ? this->alarm_1_binary_sensor_ : this->alarm_2_binary_sensor_;
if (sensor != nullptr)
sensor->publish_state(state);
#endif
}
void DS3231Component::publish_oscillator_stopped_() {
#ifdef USE_BINARY_SENSOR
if (this->oscillator_stopped_binary_sensor_ != nullptr)
this->oscillator_stopped_binary_sensor_->publish_state((this->status_reg_ & STATUS_OSF) != 0);
#endif
}
// ---------------------------------------------------------------------------------------------
// Outputs and calibration
// ---------------------------------------------------------------------------------------------
void DS3231Component::enable_square_wave(SquareWaveFrequency frequency) {
if (this->interrupt_pin_ != nullptr) {
ESP_LOGE(TAG, "Can't enable the square wave: INT/SQW is used as the alarm interrupt pin.");
return;
}
if (this->is_failed())
return;
if (this->update_control_(CONTROL_INTCN | CONTROL_RS_MASK, frequency << CONTROL_RS_SHIFT)) {
ESP_LOGD(TAG, "Square wave enabled at %s.", LOG_STR_ARG(square_wave_to_string(frequency)));
}
}
void DS3231Component::disable_square_wave() {
if (this->is_failed())
return;
if (this->update_control_(CONTROL_INTCN, CONTROL_INTCN)) {
ESP_LOGD(TAG, "Square wave disabled, INT/SQW is the alarm interrupt.");
}
}
void DS3231Component::set_output_32khz(bool enabled) {
if (this->is_failed() || !this->read_control_status_())
return;
assign_bits(this->status_reg_, STATUS_EN32KHZ, enabled);
if (this->write_status_(0)) {
ESP_LOGD(TAG, "32 kHz output %s.", ONOFF(enabled));
}
}
void DS3231Component::set_aging_offset(int8_t offset) {
if (this->is_failed())
return;
if (!this->write_aging_offset_(offset))
return;
ESP_LOGD(TAG, "Aging offset set to %d.", offset);
// The new offset takes effect with the next temperature conversion.
this->force_temperature_conversion();
}
// ---------------------------------------------------------------------------------------------
// Register access
// ---------------------------------------------------------------------------------------------
bool DS3231Component::read_control_status_() {
uint8_t raw[2];
if (!this->read_bytes(DS3231_REG_CONTROL, raw, sizeof(raw))) {
ESP_LOGW(TAG, "Can't read control/status registers.");
if (!this->status_comm_result(this->read_bytes(DS3231_REG_CONTROL, raw, sizeof(raw))))
return false;
}
this->control_reg_ = raw[0];
this->control_reg_ = raw[0] & ~CONTROL_CONV;
this->status_reg_ = raw[1];
return true;
}
bool DS3231Component::write_control_() {
if (!this->write_byte(DS3231_REG_CONTROL, this->control_reg_)) {
ESP_LOGW(TAG, "Can't write control register.");
bool DS3231Component::update_control_(uint8_t mask, uint8_t value) {
// CONV is a one-shot command bit: always write it back as 0 so a control change never starts
// a temperature conversion.
if (!this->status_comm_result(this->update_register(DS3231_REG_CONTROL, mask | CONTROL_CONV, value)))
return false;
}
this->control_reg_ = (this->control_reg_ & ~mask) | (value & mask);
return true;
}
bool DS3231Component::write_status_(uint8_t clear) {
// The flags can only be written to 0; writing 1 leaves them unchanged, so a flag that was set
// between the read and this write is never lost.
const uint8_t value = ((this->status_reg_ & STATUS_EN32KHZ) | STATUS_FLAGS) & ~clear;
return this->status_comm_result(this->write_byte(DS3231_REG_STATUS, value));
}
bool DS3231Component::write_aging_offset_(int8_t offset) {
if (!this->status_comm_result(this->write_byte(DS3231_REG_AGING_OFFSET, static_cast<uint8_t>(offset))))
return false;
this->aging_offset_ = offset;
return true;
}
+132 -1
View File
@@ -1,32 +1,163 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/components/i2c/i2c.h"
#ifdef USE_BINARY_SENSOR
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
namespace esphome::ds3231 {
/// When an alarm fires. Alarm 1 supports every mode, alarm 2 has no seconds register and so no EVERY_SECOND.
enum AlarmMode : uint8_t {
ALARM_MODE_EVERY_SECOND = 0, ///< Alarm 1 only.
ALARM_MODE_EVERY_MINUTE, ///< Alarm 1: when the seconds match. Alarm 2: at second 00 of every minute.
ALARM_MODE_HOURLY, ///< When minutes (and seconds) match.
ALARM_MODE_DAILY, ///< When hours, minutes (and seconds) match.
ALARM_MODE_MONTHLY, ///< When date, hours, minutes (and seconds) match.
ALARM_MODE_WEEKLY, ///< When day of week, hours, minutes (and seconds) match.
};
/// Frequency of the INT/SQW pin when it is a square-wave output (RS2:RS1 in the control register).
enum SquareWaveFrequency : uint8_t {
SQUARE_WAVE_1HZ = 0,
SQUARE_WAVE_1024HZ = 1,
SQUARE_WAVE_4096HZ = 2,
SQUARE_WAVE_8192HZ = 3,
};
/// Hub for a single DS3231 real-time clock. Owns all register access; the platforms talk to the
/// chip through this class.
class DS3231Component final : public Component, public i2c::I2CDevice {
public:
/// Seconds through year, the 7-byte BCD calendar the time platform decodes with time::DS_BCD_CALENDAR.
static constexpr size_t CALENDAR_LEN = 7;
void setup() override;
void loop() override;
void dump_config() override;
// ---- Configuration (set from codegen) ----
void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; }
void set_square_wave_frequency(SquareWaveFrequency frequency) {
this->square_wave_frequency_ = frequency;
this->square_wave_configured_ = true;
}
void set_battery_backed_square_wave(bool enabled) { this->battery_backed_square_wave_ = enabled ? 1 : 0; }
void set_output_32khz_config(bool enabled) { this->output_32khz_ = enabled ? 1 : 0; }
void set_aging_offset_config(int8_t offset) {
this->aging_offset_ = offset;
this->aging_offset_configured_ = true;
}
void set_auto_clear_alarms(bool auto_clear) { this->auto_clear_alarms_ = auto_clear; }
void set_alarm_check_interval(uint32_t interval) { this->alarm_check_interval_ = interval; }
void set_monitor_alarms(bool monitor) { this->monitor_alarms_ = monitor; }
/// Register a void(uint8_t alarm) callback that runs when an enabled alarm (1 or 2) matches.
template<typename F> void add_on_alarm_callback(F &&callback) {
this->alarm_callback_.add(std::forward<F>(callback));
}
#ifdef USE_BINARY_SENSOR
SUB_BINARY_SENSOR(alarm_1)
SUB_BINARY_SENSOR(alarm_2)
SUB_BINARY_SENSOR(oscillator_stopped)
#endif
// ---- Clock registers (used by the time platform) ----
/// Read the calendar registers into @p raw. Fails if the oscillator was stopped since the time was last set.
bool read_calendar(uint8_t *raw);
/// Write the calendar registers from @p raw and clear the oscillator-stop flag.
bool write_calendar(const uint8_t *raw);
// ---- Temperature ----
bool read_temperature(float &out);
void force_temperature_conversion();
// ---- Alarms ----
/// Program alarm 1. @p day_of_month is used in MONTHLY mode, @p day_of_week (1 = Sunday) in WEEKLY mode.
void set_alarm_1(AlarmMode mode, uint8_t second, uint8_t minute, uint8_t hour, uint8_t day_of_month,
uint8_t day_of_week, bool enabled);
/// Program alarm 2 (minute resolution, fires at second 00).
void set_alarm_2(AlarmMode mode, uint8_t minute, uint8_t hour, uint8_t day_of_month, uint8_t day_of_week,
bool enabled);
/// Enable @p alarm. A match from while it was disabled is cleared first, so only the next one fires.
void enable_alarm(uint8_t alarm);
void disable_alarm(uint8_t alarm) { this->set_alarm_enabled_(alarm, false); }
/// Clear the alarm flag, which also releases the INT/SQW line.
void clear_alarm(uint8_t alarm);
/// True while the alarm flag of @p alarm (1 or 2) is set on the chip.
bool is_alarm_triggered(uint8_t alarm);
// ---- Outputs and calibration ----
/// Switch INT/SQW to a square-wave output at @p frequency. Alarms then no longer drive the pin.
void enable_square_wave(SquareWaveFrequency frequency);
/// Switch INT/SQW back to the (active-low) alarm interrupt output.
void disable_square_wave();
void set_output_32khz(bool enabled);
/// Aging offset in LSB (about 0.1 ppm each at 25 degC); positive values slow the clock down.
void set_aging_offset(int8_t offset);
protected:
static constexpr uint8_t CONTROL_A1IE = 0x01;
static constexpr uint8_t CONTROL_A2IE = 0x02;
static constexpr uint8_t CONTROL_INTCN = 0x04;
static constexpr uint8_t CONTROL_RS_MASK = 0x18;
static constexpr uint8_t CONTROL_RS_SHIFT = 3;
static constexpr uint8_t CONTROL_CONV = 0x20;
static constexpr uint8_t CONTROL_BBSQW = 0x40;
static constexpr uint8_t CONTROL_EOSC = 0x80;
static constexpr uint8_t STATUS_A1F = 0x01;
static constexpr uint8_t STATUS_A2F = 0x02;
static constexpr uint8_t STATUS_BSY = 0x04;
static constexpr uint8_t STATUS_EN32KHZ = 0x08;
static constexpr uint8_t STATUS_OSF = 0x80;
static constexpr uint8_t STATUS_FLAGS = STATUS_A1F | STATUS_A2F | STATUS_OSF;
/// Scheduler ids: one pulse timer per alarm (id + alarm - 1).
static constexpr uint32_t ALARM_PULSE_TIMEOUT_ID = 0;
static void interrupt_handler(DS3231Component *arg);
/// Read the control and status registers into the cached copies. Sets the warning flag on failure.
bool read_control_status_();
bool write_control_();
/// Replace the bits in @p mask of the control register with @p value, on the chip and in the cache.
bool update_control_(uint8_t mask, uint8_t value);
/// Write the status register, clearing the flags in @p clear (writing 1 to a flag leaves it unchanged).
bool write_status_(uint8_t clear);
bool write_aging_offset_(int8_t offset);
/// Clear the flag of @p alarm and report it off. Returns false (state unchanged) if that fails.
bool clear_alarm_flag_(uint8_t alarm);
void set_alarm_enabled_(uint8_t alarm, bool enabled);
/// Write the alarm registers, clear the stale flag and set the interrupt enable.
void write_alarm_(uint8_t alarm, const uint8_t *regs, size_t len, bool enabled);
/// Read the flags, fire triggers and update the binary sensors.
void check_alarms_();
void publish_alarm_state_(uint8_t alarm, bool state);
void publish_oscillator_stopped_();
InternalGPIOPin *interrupt_pin_{nullptr};
LazyCallbackManager<void(uint8_t)> alarm_callback_;
uint32_t alarm_check_interval_{1000};
uint8_t control_reg_{0};
uint8_t status_reg_{0};
int8_t aging_offset_{0};
SquareWaveFrequency square_wave_frequency_{SQUARE_WAVE_1HZ};
/// -1: leave the chip as it is, 0: off, 1: on.
int8_t battery_backed_square_wave_{-1};
int8_t output_32khz_{-1};
bool square_wave_configured_{false};
bool aging_offset_configured_{false};
bool auto_clear_alarms_{true};
bool monitor_alarms_{false};
/// Set while an alarm flag has been reported, so a latched flag fires its trigger only once.
bool alarm_active_[2]{false, false};
};
} // namespace esphome::ds3231
@@ -0,0 +1,40 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import time
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.types import ConfigType
from .. import CONF_DS3231_ID, DS3231Component, ds3231_ns
DEPENDENCIES = ["ds3231"]
DS3231Time = ds3231_ns.class_(
"DS3231Time", time.RealTimeClock, cg.Parented.template(DS3231Component)
)
CONFIG_SCHEMA = time.TIME_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(DS3231Time),
cv.GenerateID(CONF_DS3231_ID): cv.use_id(DS3231Component),
}
)
DS3231_TIME_ACTION_SCHEMA = automation.maybe_simple_id(
{cv.GenerateID(): cv.use_id(DS3231Time)}
)
automation.register_apply_action(
"ds3231.write_time", DS3231_TIME_ACTION_SCHEMA, automation.ApplyCall("write_time()")
)
automation.register_apply_action(
"ds3231.read_time", DS3231_TIME_ACTION_SCHEMA, automation.ApplyCall("read_time()")
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await cg.register_parented(var, config[CONF_DS3231_ID])
await time.register_time(var, config)
@@ -0,0 +1,48 @@
#include "ds3231_time.h"
#include "esphome/core/log.h"
namespace esphome::ds3231 {
ESPHOME_LOG_TAG(TAG, "ds3231.time");
void DS3231Time::update() { this->read_time(); }
void DS3231Time::dump_config() {
ESP_LOGCONFIG(TAG, "DS3231 Time:");
RealTimeClock::dump_config();
}
void DS3231Time::read_time() {
uint8_t raw[DS3231Component::CALENDAR_LEN];
if (!this->status_comm_result(this->parent_->read_calendar(raw), LOG_STR("No valid time from the RTC")))
return;
const ESPTime rtc_time = time::decode_bcd_calendar<time::DS_BCD_CALENDAR>(raw);
ESP_LOGD(TAG, "Read UTC time: %04u-%02u-%02u %02u:%02u:%02u (day of week %u)", rtc_time.year, rtc_time.month,
rtc_time.day_of_month, rtc_time.hour, rtc_time.minute, rtc_time.second, rtc_time.day_of_week);
// The day of week is a free-running counter on the DS3231 and may have been set with a different
// numbering by other software, so it is not used to validate the time.
if (!this->status_comm_result(rtc_time.is_valid(/*check_day_of_week=*/false, /*check_day_of_year=*/false),
LOG_STR("Invalid RTC time")))
return;
this->synchronize_epoch_(rtc_time.timestamp);
}
void DS3231Time::write_time() {
auto now = this->utcnow();
if (!now.is_valid()) {
ESP_LOGE(TAG, "Invalid system time, not syncing to RTC.");
return;
}
if (now.year > 2199) {
ESP_LOGE(TAG, "Year %u is out of the DS3231 range (2000-2199).", now.year);
return;
}
uint8_t raw[DS3231Component::CALENDAR_LEN];
time::encode_bcd_calendar<time::DS_BCD_CALENDAR>(now, raw);
if (!this->status_comm_result(this->parent_->write_calendar(raw)))
return;
ESP_LOGD(TAG, "Wrote UTC time: %04u-%02u-%02u %02u:%02u:%02u", now.year, now.month, now.day_of_month, now.hour,
now.minute, now.second);
}
} // namespace esphome::ds3231
@@ -0,0 +1,22 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/components/time/real_time_clock.h"
#include "../ds3231.h"
namespace esphome::ds3231 {
class DS3231Time final : public time::RealTimeClock, public Parented<DS3231Component> {
public:
void update() override;
void dump_config() override;
/// Set the system clock from the RTC.
void read_time();
/// Store the system clock in the RTC.
void write_time();
};
} // namespace esphome::ds3231
+1 -1
View File
@@ -126,7 +126,7 @@ void EmonTx::loop() {
// Read all available data to prevent UART buffer overflow
this->read_available([this](uint8_t received) {
size_t len;
size_t len = 0;
char *line = this->line_reader_.feed(received, len);
this->line_reader_.log_if_overflowed(TAG);
if (line == nullptr || len == 0) {
@@ -0,0 +1,177 @@
"""Send sensor and binary sensor states to other devices over a packet interface."""
from collections.abc import Callable
from typing import Any
import esphome.codegen as cg
from esphome.components import packet_interface
from esphome.components.binary_sensor import BinarySensor
from esphome.components.const import CONF_PROVIDER, CONF_REMOTE_ID
from esphome.components.packet_interface import (
CONF_PACKET_INTERFACE,
final_validate_interface,
interface_has_layer,
)
from esphome.components.packet_interface.layers import CONF_SESSION
from esphome.components.sensor import Sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_BINARY_SENSORS,
CONF_ID,
CONF_SENSORS,
CONF_TIMEOUT,
CONF_UPDATE_INTERVAL,
)
from esphome.cpp_generator import MockObj, MockObjClass
from esphome.types import ConfigType
CODEOWNERS = ["@clydebarrow"]
DEPENDENCIES = ["packet_interface"]
DOMAIN = "entity_transport"
MULTI_CONF = True
CONF_BROADCAST_ID = "broadcast_id"
CONF_ENTITY_TRANSPORT_ID = "entity_transport_id"
entity_transport_ns = cg.esphome_ns.namespace("entity_transport")
EntityTransport = entity_transport_ns.class_("EntityTransport", cg.Component)
_request_remote_sensor_slot = cg.slot_counter("ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT")
_request_remote_binary_sensor_slot = cg.slot_counter(
"ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT"
)
def _advertised_id(conf: ConfigType) -> str:
return conf.get(CONF_BROADCAST_ID, conf[CONF_ID].id)
def _unique_advertised_ids(entities: list[ConfigType]) -> list[ConfigType]:
"""A receiver tells entities of one type apart by their advertised ids, so each must be different."""
seen: set[str] = set()
for index, conf in enumerate(entities):
if (advertised := _advertised_id(conf)) in seen:
raise cv.Invalid(
f"The id '{advertised}' is sent for two entities", path=[index]
)
seen.add(advertised)
return entities
def _local_entity(cls: MockObjClass) -> Callable[[Any], Any]:
return cv.maybe_simple_value(
cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(cls),
cv.Optional(CONF_BROADCAST_ID): cv.All(
cv.validate_id_name, cv.Length(max=255)
),
}
),
key=CONF_ID,
)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(EntityTransport),
cv.Required(CONF_PACKET_INTERFACE): cv.use_id(packet_interface.PacketInterface),
# Defaults to a periodic resend unless the interface has session and reliability layers
cv.Optional(CONF_UPDATE_INTERVAL): cv.All(
cv.update_interval, cv.Range(min=cv.TimePeriod(seconds=1))
),
# Remote entities lose their state when none arrives for this long
cv.Optional(CONF_TIMEOUT): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(min=cv.TimePeriod(seconds=1)),
),
# Entities are numbered with one byte
cv.Optional(CONF_SENSORS): cv.All(
cv.ensure_list(_local_entity(Sensor)),
cv.Length(max=255),
_unique_advertised_ids,
),
cv.Optional(CONF_BINARY_SENSORS): cv.All(
cv.ensure_list(_local_entity(BinarySensor)),
cv.Length(max=255),
_unique_advertised_ids,
),
}
).extend(cv.COMPONENT_SCHEMA)
def _final_validate(config: ConfigType) -> ConfigType:
"""With a session layer, which clears the states when the peer is lost, nothing would refresh an unchanged state."""
if CONF_TIMEOUT in config and interface_has_layer(
config[CONF_PACKET_INTERFACE], CONF_SESSION
):
raise cv.Invalid(
f"Not allowed with a '{CONF_SESSION}' layer, or a link that does its job, which already clears the "
"states when the other device cannot be reached; unchanged states may never be sent again, so they "
"would time out on a working link",
path=[CONF_TIMEOUT],
)
return config
FINAL_VALIDATE_SCHEMA = cv.All(
final_validate_interface(CONF_PACKET_INTERFACE), _final_validate
)
def _validate_remote_id(config: ConfigType) -> ConfigType:
if CONF_REMOTE_ID not in config and not config[CONF_ID].is_manual:
raise cv.Invalid(f"'{CONF_REMOTE_ID}' is required unless '{CONF_ID}' is given")
return config
def remote_entity_schema(base_schema: cv.Schema) -> cv.Schema:
"""Schema for an entity whose state comes from another device."""
return cv.All(
base_schema.extend(
{
cv.GenerateID(CONF_ENTITY_TRANSPORT_ID): cv.use_id(EntityTransport),
cv.Required(CONF_PROVIDER): cv.valid_name,
cv.Optional(CONF_REMOTE_ID): cv.All(
cv.string_strict, cv.Length(min=1, max=255)
),
}
),
_validate_remote_id,
)
def _remote_id(config: ConfigType) -> str:
return config.get(CONF_REMOTE_ID) or config[CONF_ID].id
async def register_remote_sensor(config: ConfigType, var: MockObj) -> None:
hub = await cg.get_variable(config[CONF_ENTITY_TRANSPORT_ID])
_request_remote_sensor_slot(str(hub))
cg.add(hub.add_remote_sensor(config[CONF_PROVIDER], _remote_id(config), var))
async def register_remote_binary_sensor(config: ConfigType, var: MockObj) -> None:
hub = await cg.get_variable(config[CONF_ENTITY_TRANSPORT_ID])
_request_remote_binary_sensor_slot(str(hub))
cg.add(hub.add_remote_binary_sensor(config[CONF_PROVIDER], _remote_id(config), var))
async def to_code(config: ConfigType) -> None:
interface = await cg.get_variable(config[CONF_PACKET_INTERFACE])
var = cg.new_Pvariable(config[CONF_ID], interface)
await cg.register_component(var, config)
if (interval := config.get(CONF_UPDATE_INTERVAL)) is not None:
cg.add(var.set_update_interval(interval))
if (timeout := config.get(CONF_TIMEOUT)) is not None:
cg.add(var.set_remote_timeout(timeout))
sensors = config.get(CONF_SENSORS, [])
binary_sensors = config.get(CONF_BINARY_SENSORS, [])
if count := len(sensors) + len(binary_sensors):
cg.add(var.set_entity_count(count))
for conf in sensors:
sensor = await cg.get_variable(conf[CONF_ID])
cg.add(var.add_sensor(_advertised_id(conf), sensor))
for conf in binary_sensors:
sensor = await cg.get_variable(conf[CONF_ID])
cg.add(var.add_binary_sensor(_advertised_id(conf), sensor))
@@ -0,0 +1,11 @@
from esphome.components import binary_sensor
from esphome.types import ConfigType
from . import register_remote_binary_sensor, remote_entity_schema
CONFIG_SCHEMA = remote_entity_schema(binary_sensor.binary_sensor_schema())
async def to_code(config: ConfigType) -> None:
var = await binary_sensor.new_binary_sensor(config)
await register_remote_binary_sensor(config, var)
@@ -0,0 +1,683 @@
#include "entity_transport.h"
#include <algorithm>
#include <cmath>
#include <cstring>
#include "esphome/core/application.h"
#include "esphome/core/log.h"
namespace esphome::entity_transport {
using packet_interface::PacketBuffer;
using packet_interface::PacketMetaData;
ESPHOME_LOG_TAG(TAG, "entity_transport");
// The packet type byte holds the format version in its high nibble
static constexpr uint8_t FORMAT_VERSION = 1;
static constexpr uint8_t PACKET_DICTIONARY = 0x11;
static constexpr uint8_t PACKET_DATA = 0x12;
static constexpr uint8_t PACKET_REQUEST = 0x13;
// Entity types, in dictionary sections and data records; 0 is not used
static constexpr uint8_t TYPE_SENSOR = 1;
static constexpr uint8_t TYPE_BINARY_SENSOR = 2;
static constexpr uint8_t MAX_TYPE = 31;
// Packet type and dictionary id
static constexpr size_t HEADER_SIZE = 5;
// A dictionary packet's part byte follows the header: the part's index in bits 0-6, and on the last part, bit 7
static constexpr uint8_t PART_INDEX_MASK = 0x7F;
static constexpr uint8_t PART_LAST = 0x80;
static constexpr uint8_t PART_NONE = 0xFF; // no dictionary is being received in order
// A data record starts with a 16-bit tag: the entity's number in bits 0-7, its type in bits 8-12, the payload form in
// bits 13-14, and with PAYLOAD_NONE, the state in bit 15. Any record can be skipped without knowing its type.
static constexpr size_t TAG_SIZE = 2;
static constexpr uint8_t TAG_TYPE_SHIFT = 8;
static constexpr uint8_t TAG_PAYLOAD_SHIFT = 13;
static constexpr uint16_t TAG_VALUE_BIT = 0x8000;
static constexpr uint8_t PAYLOAD_NONE = 0; // the state is in the tag's value bit
static constexpr uint8_t PAYLOAD_WORD = 1; // 4 bytes follow
static constexpr uint8_t PAYLOAD_LENGTH = 2; // a length byte follows, then that many bytes
static constexpr uint8_t PAYLOAD_NO_STATE = 3; // the entity has no state
static constexpr size_t WORD_SIZE = 4;
static constexpr size_t MAX_RECORD_SIZE = TAG_SIZE + WORD_SIZE;
// A dictionary section starts with the entity type, first number and count
static constexpr size_t SECTION_HEADER_SIZE = 3;
static constexpr size_t MAX_STRING_LENGTH = 255;
static constexpr uint32_t DEFAULT_UPDATE_INTERVAL_MS = 15000;
static constexpr uint32_t REQUEST_INTERVAL_MS = 5000;
static constexpr uint32_t RETRY_DELAY_MS = 1000;
static constexpr uint32_t RETRY_TIMER_ID = 0;
static constexpr uint32_t TIMEOUT_CHECK_INTERVAL_MS = 1000;
static constexpr uint32_t WARNING_INTERVAL_MS = 60000;
// How long the interface may refuse every packet before it is reported
static constexpr uint32_t REFUSED_WARNING_MS = 60000;
static void put_uint32(uint8_t *buf, uint32_t value) {
for (size_t i = 0; i != 4; i++)
buf[i] = value >> (i * 8);
}
// True if the NUL terminated str is the same as the len bytes at data
static bool matches(const char *str, const char *data, size_t len) {
return strlen(str) == len && memcmp(str, data, len) == 0;
}
struct Record {
uint8_t number;
uint8_t type;
uint8_t payload;
bool value;
const uint8_t *data; // what follows the tag
};
static Record parse_record(std::span<const uint8_t> packet, size_t pos) {
uint16_t tag = encode_uint16(packet[pos + 1], packet[pos]);
return {static_cast<uint8_t>(tag), static_cast<uint8_t>((tag >> TAG_TYPE_SHIFT) & MAX_TYPE),
static_cast<uint8_t>((tag >> TAG_PAYLOAD_SHIFT) & 3), (tag & TAG_VALUE_BIT) != 0,
packet.data() + pos + TAG_SIZE};
}
// The size of the data record at pos, or 0 if it does not fit in the packet
static size_t record_size(std::span<const uint8_t> packet, size_t pos) {
if (pos + TAG_SIZE > packet.size())
return 0;
size_t size = TAG_SIZE;
uint8_t payload = parse_record(packet, pos).payload;
if (payload == PAYLOAD_WORD) {
size += WORD_SIZE;
} else if (payload == PAYLOAD_LENGTH) {
if (pos + TAG_SIZE == packet.size())
return 0;
size += 1 + packet[pos + TAG_SIZE];
}
return pos + size <= packet.size() ? size : 0;
}
// Checks every record of a data packet, so that a damaged or forged packet changes no state at all
static bool data_well_formed(std::span<const uint8_t> packet) {
for (size_t pos = HEADER_SIZE; pos != packet.size();) {
size_t size = record_size(packet, pos);
if (size == 0)
return false;
Record record = parse_record(packet, pos);
if (record.type == 0 || (record.value && record.payload != PAYLOAD_NONE))
return false;
// Types this device knows must use their own payload form; others are skipped
if (record.type == TYPE_SENSOR && record.payload != PAYLOAD_WORD && record.payload != PAYLOAD_NO_STATE)
return false;
if (record.type == TYPE_BINARY_SENSOR && record.payload != PAYLOAD_NONE && record.payload != PAYLOAD_NO_STATE)
return false;
pos += size;
}
return true;
}
// Checks the device name and every section of a dictionary packet before anything is learnt from it
static bool dictionary_well_formed(std::span<const uint8_t> packet) {
size_t pos = HEADER_SIZE + 1;
if (pos >= packet.size())
return false;
pos += 1 + packet[pos];
if (pos > packet.size())
return false;
while (pos != packet.size()) {
if (pos + SECTION_HEADER_SIZE > packet.size())
return false;
uint8_t type = packet[pos];
size_t first = packet[pos + 1];
size_t count = packet[pos + 2];
pos += SECTION_HEADER_SIZE;
// Numbers are one byte, and a data record can name types up to MAX_TYPE only
if (type == 0 || type > MAX_TYPE || count == 0 || first + count > 256)
return false;
for (size_t i = 0; i != count; i++) {
if (pos == packet.size())
return false;
pos += 1 + packet[pos];
if (pos > packet.size())
return false;
}
}
return true;
}
void EntityTransport::setup() {
this->interface_ = this->interface_->top();
this->max_packet_size_ = this->interface_->get_max_packet_size();
const auto &name = App.get_name();
size_t longest_id = 0;
for (const auto &entity : this->entities_)
longest_id = std::max(longest_id, strlen(entity.id));
// A dictionary packet must hold the device name and any one id, which also leaves room for a data record
if (name.size() > MAX_STRING_LENGTH || longest_id > MAX_STRING_LENGTH ||
HEADER_SIZE + 2 + name.size() + SECTION_HEADER_SIZE + 1 + longest_id > this->max_packet_size_) {
this->mark_failed(LOG_STR("max_packet_size too small for the device name and entity ids"));
return;
}
RAMAllocator<uint8_t> allocator;
this->buffer_ = allocator.allocate(this->max_packet_size_);
if (this->buffer_ == nullptr) {
this->mark_failed(LOG_STR("Not enough memory for the packet buffer"));
return;
}
// The dictionary id changes when the device name or any entity changes
uint32_t hash = fnv1a_hash(name.c_str());
for (const auto &entity : this->entities_) {
hash = fnv1a_hash_extend(hash, entity.is_sensor ? TYPE_SENSOR : TYPE_BINARY_SENSOR);
hash = fnv1a_hash_extend(hash, entity.id);
}
this->dictionary_id_ = hash;
// [&entity] is safe: FixedVector never reallocates, and the parent pointer keeps the capture to one pointer
for (auto &entity : this->entities_) {
#ifdef USE_SENSOR
if (entity.is_sensor) {
static_cast<sensor::Sensor *>(entity.entity)->add_on_state_callback([&entity](float) {
entity.parent->mark_updated_(entity);
});
}
#endif
#ifdef USE_BINARY_SENSOR
if (!entity.is_sensor) {
// The full state callback also fires for the first state
static_cast<binary_sensor::BinarySensor *>(entity.entity)
->add_full_state_callback(
[&entity](optional<bool>, optional<bool>) { entity.parent->mark_updated_(entity); });
}
#endif
}
this->interface_->add_packet_interface_listener(
[this](const PacketBuffer &data, const PacketMetaData &) { this->receive_(data); });
bool session = this->interface_->has_capability(packet_interface::CAPABILITY_SESSION);
if (session)
this->interface_->add_on_session_state_callback([this](bool connected) { this->on_session_(connected); });
// Without a session and reliable delivery, a lost dictionary or state is only replaced by a periodic resend
if (this->update_interval_ms_ == 0) {
this->update_interval_ms_ = session && this->interface_->has_capability(packet_interface::CAPABILITY_RELIABILITY)
? SCHEDULER_DONT_RUN
: DEFAULT_UPDATE_INTERVAL_MS;
}
if (this->update_interval_ms_ != SCHEDULER_DONT_RUN)
this->set_interval(this->update_interval_ms_, [this] { this->send_all_(); });
if (this->remote_timeout_ms_ != 0)
this->set_interval(TIMEOUT_CHECK_INTERVAL_MS, [this] { this->clear_remote_states_(true); });
this->send_all_();
}
void EntityTransport::dump_config() {
ESP_LOGCONFIG(TAG,
"Entity Transport:\n"
" Packet Interface: %s\n"
" Max Packet Size: %zu\n"
" Encryption: %s\n"
" Reliability: %s\n"
" Session: %s\n"
" Dictionary ID: %08" PRIX32,
this->interface_->get_name(), this->max_packet_size_,
YESNO(this->interface_->has_capability(packet_interface::CAPABILITY_ENCRYPTION)),
YESNO(this->interface_->has_capability(packet_interface::CAPABILITY_RELIABILITY)),
YESNO(this->interface_->has_capability(packet_interface::CAPABILITY_SESSION)), this->dictionary_id_);
if (this->update_interval_ms_ == SCHEDULER_DONT_RUN) {
ESP_LOGCONFIG(TAG, " Update Interval: never");
} else {
ESP_LOGCONFIG(TAG, " Update Interval: %" PRIu32 "ms", this->update_interval_ms_);
}
if (this->remote_timeout_ms_ != 0) {
ESP_LOGCONFIG(TAG, " Remote Timeout: %" PRIu32 "ms", this->remote_timeout_ms_);
}
for (const auto &entity : this->entities_) {
ESP_LOGCONFIG(TAG, " %s %u: %s", entity.is_sensor ? LOG_STR_LITERAL("Sensor") : LOG_STR_LITERAL("Binary Sensor"),
entity.number, entity.id);
}
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
for (const auto &remote : this->remote_sensors_) {
ESP_LOGCONFIG(TAG, " Remote Sensor: %s from %s", remote.remote_id, remote.provider);
}
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
for (const auto &remote : this->remote_binary_sensors_) {
ESP_LOGCONFIG(TAG, " Remote Binary Sensor: %s from %s", remote.remote_id, remote.provider);
}
#endif
}
void EntityTransport::send_all_() {
this->dictionary_pending_ = true;
this->dictionary_next_ = 0;
this->dictionary_part_ = 0;
for (auto &entity : this->entities_)
entity.updated = true;
this->enable_loop();
}
void EntityTransport::mark_updated_(LocalEntity &entity) {
entity.updated = true;
this->enable_loop();
}
void EntityTransport::loop() {
this->disable_loop();
// States are only sent once the dictionary that names them has been accepted
if ((this->dictionary_pending_ && !this->send_dictionary_()) || !this->send_updated_()) {
this->sending_refused_();
this->set_timeout(RETRY_TIMER_ID, RETRY_DELAY_MS, [this] { this->enable_loop(); });
return;
}
this->refusing_ = false;
if (this->status_has_warning()) {
ESP_LOGI(TAG, "Packets are accepted again");
this->status_clear_warning();
}
}
// A link that is down or full refuses packets for a while; one that refuses them for long sends nothing at all
void EntityTransport::sending_refused_() {
uint32_t now = App.get_loop_component_start_time();
if (!this->refusing_) {
this->refusing_ = true;
this->refused_since_ms_ = now;
return;
}
if (!this->status_has_warning() && now - this->refused_since_ms_ >= REFUSED_WARNING_MS) {
ESP_LOGW(TAG, "The packet interface has accepted no packets for %" PRIu32 "s, so no states are sent",
(now - this->refused_since_ms_) / 1000);
this->status_set_warning();
}
}
size_t EntityTransport::write_header_(uint8_t type) const {
this->buffer_[0] = type;
put_uint32(this->buffer_ + 1, this->dictionary_id_);
return HEADER_SIZE;
}
bool EntityTransport::send_dictionary_() {
uint8_t *buf = this->buffer_;
const auto &name = App.get_name();
size_t header = this->write_header_(PACKET_DICTIONARY);
size_t part_offset = header++;
buf[header++] = name.size();
memcpy(buf + header, name.c_str(), name.size());
header += name.size();
size_t length = header;
size_t count_offset = 0;
uint8_t section_type = 0; // none
// A dictionary in several packets resumes after the last packet accepted, so a refused one does not resend the rest
for (size_t i = this->dictionary_next_; i != this->entities_.size(); i++) {
const auto &entity = this->entities_[i];
uint8_t type = entity.is_sensor ? TYPE_SENSOR : TYPE_BINARY_SENSOR;
size_t id_length = strlen(entity.id);
bool new_section = type != section_type;
if (length + (new_section ? SECTION_HEADER_SIZE : 0) + 1 + id_length > this->max_packet_size_) {
buf[part_offset] = this->dictionary_part_ & PART_INDEX_MASK;
if (!this->interface_->send_to_interface(PacketBuffer(buf, length), {})) {
ESP_LOGV(TAG, "Dictionary packet of %zu bytes not accepted, will retry", length);
return false;
}
this->dictionary_next_ = i;
this->dictionary_part_++;
length = header;
new_section = true;
}
if (new_section) {
buf[length++] = type;
buf[length++] = entity.number;
count_offset = length;
buf[length++] = 0;
section_type = type;
}
buf[count_offset]++;
buf[length++] = id_length;
memcpy(buf + length, entity.id, id_length);
length += id_length;
}
if (length != header) {
buf[part_offset] = (this->dictionary_part_ & PART_INDEX_MASK) | PART_LAST;
if (!this->interface_->send_to_interface(PacketBuffer(buf, length), {})) {
ESP_LOGV(TAG, "Dictionary packet of %zu bytes not accepted, will retry", length);
return false;
}
}
this->dictionary_pending_ = false;
this->dictionary_next_ = 0;
this->dictionary_part_ = 0;
return true;
}
// Writes the entity's data record, and returns its size
size_t EntityTransport::write_record_(const LocalEntity &entity, uint8_t *record) const {
uint8_t type = entity.is_sensor ? TYPE_SENSOR : TYPE_BINARY_SENSOR;
uint8_t payload = PAYLOAD_NO_STATE;
bool value = false;
size_t size = TAG_SIZE;
#ifdef USE_SENSOR
if (entity.is_sensor) {
float state = static_cast<sensor::Sensor *>(entity.entity)->get_state();
if (!std::isnan(state)) {
payload = PAYLOAD_WORD;
uint32_t bits;
memcpy(&bits, &state, sizeof(bits));
put_uint32(record + TAG_SIZE, bits);
size += WORD_SIZE;
}
}
#endif
#ifdef USE_BINARY_SENSOR
if (!entity.is_sensor) {
auto *sensor = static_cast<binary_sensor::BinarySensor *>(entity.entity);
if (sensor->has_state()) {
payload = PAYLOAD_NONE;
value = sensor->state;
}
}
#endif
uint16_t tag =
entity.number | (type << TAG_TYPE_SHIFT) | (payload << TAG_PAYLOAD_SHIFT) | (value ? TAG_VALUE_BIT : 0);
record[0] = tag;
record[1] = tag >> 8;
return size;
}
// Sends every updated entity, in as few packets as fit; returns false if a packet was refused
bool EntityTransport::send_updated_() {
size_t length = this->write_header_(PACKET_DATA);
size_t first = 0;
for (size_t i = 0; i != this->entities_.size(); i++) {
auto &entity = this->entities_[i];
if (!entity.updated)
continue;
uint8_t record[MAX_RECORD_SIZE];
size_t size = this->write_record_(entity, record);
if (length + size > this->max_packet_size_) {
if (!this->flush_(length, first, i))
return false;
length = HEADER_SIZE;
first = i;
}
memcpy(this->buffer_ + length, record, size);
length += size;
}
return this->flush_(length, first, this->entities_.size());
}
// Sends the packet holding the updated entities from first up to last, and clears their flags if it was accepted
bool EntityTransport::flush_(size_t length, size_t first, size_t last) {
if (length == HEADER_SIZE)
return true;
if (!this->interface_->send_to_interface(PacketBuffer(this->buffer_, length), {})) {
ESP_LOGV(TAG, "Packet of %zu bytes not accepted, will retry", length);
return false;
}
for (size_t i = first; i != last; i++)
this->entities_[i].updated = false;
return true;
}
void EntityTransport::receive_(const PacketBuffer &data) {
// Packet interface layers deliver packets in one piece
if (!data.is_contiguous()) {
ESP_LOGW(TAG, "Packet split across buffers is not supported");
return;
}
auto packet = data.as_span();
if (packet.size() < HEADER_SIZE) {
if (this->warning_due_()) {
ESP_LOGW(TAG, "Packet of %zu bytes is too short", packet.size());
}
return;
}
if (packet[0] >> 4 != FORMAT_VERSION) {
if (this->warning_due_()) {
ESP_LOGW(TAG, "Packet format version %u not supported; the sender may run another ESPHome version",
packet[0] >> 4);
}
return;
}
uint32_t dictionary_id = encode_uint32(packet[4], packet[3], packet[2], packet[1]);
if (dictionary_id == this->dictionary_id_) {
// Either a request for our dictionary, or our own packet heard on a broadcast channel
if (packet[0] == PACKET_REQUEST && packet.size() == HEADER_SIZE) {
ESP_LOGD(TAG, "Dictionary requested");
this->send_all_();
}
return;
}
if (packet[0] == PACKET_DICTIONARY) {
this->receive_dictionary_(packet, dictionary_id);
} else if (packet[0] == PACKET_DATA) {
this->receive_data_(packet, dictionary_id);
} else if (packet[0] != PACKET_REQUEST) {
ESP_LOGV(TAG, "Unknown packet type 0x%02X", packet[0]);
}
}
void EntityTransport::receive_dictionary_(std::span<const uint8_t> packet, uint32_t dictionary_id) {
if (!dictionary_well_formed(packet)) {
if (this->warning_due_()) {
ESP_LOGW(TAG, "Dictionary packet of %zu bytes is malformed", packet.size());
}
return;
}
size_t pos = HEADER_SIZE;
uint8_t part = packet[pos++];
uint8_t index = part & PART_INDEX_MASK;
// The dictionary is complete, and need not be asked for again, once its last part follows all the others in order
bool in_order = index == 0 || (this->rx_dictionary_id_ == dictionary_id && index == this->rx_next_part_);
this->rx_dictionary_id_ = dictionary_id;
this->rx_next_part_ = in_order ? (index + 1) & PART_INDEX_MASK : PART_NONE;
bool completed = in_order && (part & PART_LAST) != 0;
bool first_time = completed && !this->dictionary_seen_(dictionary_id);
if (first_time) {
this->seen_dictionaries_[this->seen_next_] = dictionary_id;
this->seen_next_ = (this->seen_next_ + 1) % this->seen_dictionaries_.size();
}
size_t provider_length = packet[pos++];
const auto *provider = reinterpret_cast<const char *>(packet.data() + pos);
pos += provider_length;
while (pos != packet.size()) {
uint8_t type = packet[pos];
uint8_t number = packet[pos + 1];
uint8_t count = packet[pos + 2];
pos += SECTION_HEADER_SIZE;
for (uint8_t i = 0; i != count; i++) {
size_t id_length = packet[pos++];
this->learn_(provider, provider_length, type, number + i, reinterpret_cast<const char *>(packet.data() + pos),
id_length, dictionary_id);
pos += id_length;
}
}
// A provider may go back to a dictionary already seen, so every complete one is checked
if (completed)
this->forget_unnamed_(provider, provider_length, dictionary_id, first_time);
}
void EntityTransport::forget_unnamed_(const char *provider, size_t provider_length, uint32_t dictionary_id,
bool first_time) {
// A remote entity that its provider's whole dictionary does not name would otherwise keep no or a stale state
// silently
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
for (auto &remote : this->remote_sensors_) {
if (!matches(remote.provider, provider, provider_length) || (remote.known && remote.dictionary_id == dictionary_id))
continue;
if (remote.known || first_time) {
ESP_LOGW(TAG, "'%s' does not send '%s'", remote.provider, remote.remote_id);
}
remote.known = false;
if (remote.has_state) {
remote.has_state = false;
remote.entity->publish_state(NAN);
}
}
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
for (auto &remote : this->remote_binary_sensors_) {
if (!matches(remote.provider, provider, provider_length) || (remote.known && remote.dictionary_id == dictionary_id))
continue;
if (remote.known || first_time) {
ESP_LOGW(TAG, "'%s' does not send '%s'", remote.provider, remote.remote_id);
}
remote.known = false;
if (remote.has_state) {
remote.has_state = false;
remote.entity->invalidate_state();
}
}
#endif
}
void EntityTransport::learn_(const char *provider, size_t provider_length, uint8_t type, uint8_t number, const char *id,
size_t id_length, uint32_t dictionary_id) {
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
if (type == TYPE_SENSOR) {
for (auto &remote : this->remote_sensors_) {
if (matches(remote.provider, provider, provider_length) && matches(remote.remote_id, id, id_length)) {
remote.dictionary_id = dictionary_id;
remote.number = number;
remote.known = true;
}
}
}
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
if (type == TYPE_BINARY_SENSOR) {
for (auto &remote : this->remote_binary_sensors_) {
if (matches(remote.provider, provider, provider_length) && matches(remote.remote_id, id, id_length)) {
remote.dictionary_id = dictionary_id;
remote.number = number;
remote.known = true;
}
}
}
#endif
}
bool EntityTransport::dictionary_seen_(uint32_t dictionary_id) const {
return std::find(this->seen_dictionaries_.begin(), this->seen_dictionaries_.end(), dictionary_id) !=
this->seen_dictionaries_.end();
}
void EntityTransport::receive_data_(std::span<const uint8_t> packet, uint32_t dictionary_id) {
bool known = false;
bool has_remotes = false;
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
for (const auto &remote : this->remote_sensors_) {
known |= remote.known && remote.dictionary_id == dictionary_id;
has_remotes = true;
}
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
for (const auto &remote : this->remote_binary_sensors_) {
known |= remote.known && remote.dictionary_id == dictionary_id;
has_remotes = true;
}
#endif
if (!has_remotes)
return;
// Checked first, so a damaged packet does not make the provider resend everything
if (!data_well_formed(packet)) {
if (this->warning_due_()) {
ESP_LOGW(TAG, "Data packet of %zu bytes is malformed", packet.size());
}
return;
}
// A dictionary not yet received in full may name our entities, or be a provider's new one; one received in full
// named all of them it could, so asking again would only make that provider resend everything
if (!this->dictionary_seen_(dictionary_id))
this->request_dictionary_(dictionary_id);
if (!known)
return;
[[maybe_unused]] uint32_t now = App.get_loop_component_start_time();
for (size_t pos = HEADER_SIZE; pos != packet.size(); pos += record_size(packet, pos)) {
[[maybe_unused]] Record record = parse_record(packet, pos);
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
if (record.type == TYPE_SENSOR) {
float value = NAN;
if (record.payload == PAYLOAD_WORD) {
uint32_t bits = encode_uint32(record.data[3], record.data[2], record.data[1], record.data[0]);
memcpy(&value, &bits, sizeof(value));
}
for (auto &remote : this->remote_sensors_) {
if (remote.known && remote.dictionary_id == dictionary_id && remote.number == record.number) {
remote.last_state_ms = now;
remote.has_state = true;
remote.entity->publish_state(value);
}
}
}
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
if (record.type == TYPE_BINARY_SENSOR) {
for (auto &remote : this->remote_binary_sensors_) {
if (!remote.known || remote.dictionary_id != dictionary_id || remote.number != record.number)
continue;
remote.last_state_ms = now;
remote.has_state = true;
if (record.payload == PAYLOAD_NO_STATE) {
remote.entity->invalidate_state();
} else {
remote.entity->publish_state(record.value);
}
}
}
#endif
}
}
void EntityTransport::request_dictionary_(uint32_t dictionary_id) {
uint32_t now = App.get_loop_component_start_time();
if (this->request_sent_ && now - this->last_request_ms_ < REQUEST_INTERVAL_MS)
return;
uint8_t packet[HEADER_SIZE] = {PACKET_REQUEST};
put_uint32(packet + 1, dictionary_id);
// A refused request is tried again with the next data packet under that dictionary id
if (!this->interface_->send_to_interface(PacketBuffer(packet, sizeof(packet)), {})) {
ESP_LOGV(TAG, "Request for dictionary %08" PRIX32 " not accepted", dictionary_id);
return;
}
this->request_sent_ = true;
this->last_request_ms_ = now;
ESP_LOGD(TAG, "Requested dictionary %08" PRIX32, dictionary_id);
}
// Dropped packets point to a device with other firmware or a fault, and may arrive steadily, so warn only now and then
bool EntityTransport::warning_due_() {
uint32_t now = App.get_loop_component_start_time();
if (this->warned_ && now - this->last_warning_ms_ < WARNING_INTERVAL_MS)
return false;
this->warned_ = true;
this->last_warning_ms_ = now;
return true;
}
// A newly connected or restarted peer needs the dictionary and every state; while it is lost, its entities have no
// state
void EntityTransport::on_session_(bool connected) {
if (connected) {
this->send_all_();
return;
}
this->clear_remote_states_(false);
}
// Clears the state of every remote entity, or with timed_out_only, of those whose state is older than the timeout
void EntityTransport::clear_remote_states_(bool timed_out_only) {
[[maybe_unused]] uint32_t now = App.get_loop_component_start_time();
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
for (auto &remote : this->remote_sensors_) {
if (timed_out_only && (!remote.has_state || now - remote.last_state_ms <= this->remote_timeout_ms_))
continue;
remote.has_state = false;
remote.entity->publish_state(NAN);
}
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
for (auto &remote : this->remote_binary_sensors_) {
if (timed_out_only && (!remote.has_state || now - remote.last_state_ms <= this->remote_timeout_ms_))
continue;
remote.has_state = false;
remote.entity->invalidate_state();
}
#endif
}
} // namespace esphome::entity_transport
@@ -0,0 +1,171 @@
#pragma once
#include <array>
#include <span>
#include "esphome/components/packet_interface/packet_interface.h"
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/core/entity_base.h"
#include "esphome/core/helpers.h"
#ifdef USE_SENSOR
#include "esphome/components/sensor/sensor.h"
#endif
#ifdef USE_BINARY_SENSOR
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
namespace esphome::entity_transport {
/**
* Sends the states of local sensors and binary sensors over a packet interface, and publishes the states received
* from other devices to remote entities.
*
* Each device numbers its sensors and its binary sensors from 0, and sends a dictionary that gives the id for each
* number. Data packets then refer to an entity by its number. Every packet carries the sender's dictionary id, a hash
* of its device name and dictionary, so that a receiver knows which device sent it and which dictionary applies.
* All values are little endian.
*
* Dictionary packet:
* - type: 1 byte, PACKET_DICTIONARY;
* - dictionary id: 4 bytes;
* - part: 1 byte, the packet's index in the dictionary in bits 0-6, counting from 0 and wrapping, and bit 7 set on
* the last packet;
* - sender's device name: length (1 byte), then the name;
* - one or more sections, each: entity type (1 byte), first number (1 byte), count (1 byte), then for each entity
* its id: length (1 byte), then the id.
*
* Data packet:
* - type: 1 byte, PACKET_DATA;
* - dictionary id: 4 bytes;
* - records, to the end, each a 16-bit tag, then its payload. The tag holds the entity's number in bits 0-7, its type
* in bits 8-12, and the payload form in bits 13-14: none, with the state in bit 15 (binary sensors); 4 bytes (a
* sensor's float); a length byte, then that many bytes; or no state, with no payload. The form gives the record's
* size, so a receiver skips records of types it does not know.
*
* Every packet is checked in full before anything in it is used, so a damaged or forged packet changes no state.
*
* Dictionary request packet, sent by a device that receives data with a dictionary id it has not received in full,
* with every part in order:
* - type: 1 byte, PACKET_REQUEST;
* - the dictionary id wanted: 4 bytes.
*
* Changed states are sent from loop(). The dictionary and all states are sent at boot, when a session layer reports
* the peer connected, when the dictionary is requested, and every update interval. Unless the interface has both a
* session and a reliability layer, the update interval defaults to DEFAULT_UPDATE_INTERVAL_MS. When the interface
* refuses a packet, sending is tried again after a delay, and a warning is set once it has refused every packet for a
* minute. With a remote timeout, a remote entity loses its state when
* none arrives for it within the timeout.
*/
class EntityTransport : public Component {
public:
explicit EntityTransport(packet_interface::PacketInterface *interface) : interface_(interface) {}
void setup() override;
void loop() override;
void dump_config() override;
/// 0 chooses the default for the interface's layers; SCHEDULER_DONT_RUN never resends.
void set_update_interval(uint32_t interval_ms) { this->update_interval_ms_ = interval_ms; }
/// Remote entities lose their state when none arrives for this long; 0 keeps the last state.
void set_remote_timeout(uint32_t timeout_ms) { this->remote_timeout_ms_ = timeout_ms; }
void set_entity_count(size_t count) { this->entities_.init(count); }
// Sensors must be added before binary sensors, so that data packets hold sensor records first
#ifdef USE_SENSOR
void add_sensor(const char *id, sensor::Sensor *sensor) {
this->entities_.push_back({sensor, id, this, this->sensor_count_++, true, true});
}
#endif
#ifdef USE_BINARY_SENSOR
void add_binary_sensor(const char *id, binary_sensor::BinarySensor *sensor) {
this->entities_.push_back({sensor, id, this, this->binary_sensor_count_++, false, true});
}
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
void add_remote_sensor(const char *provider, const char *remote_id, sensor::Sensor *sensor) {
this->remote_sensors_.push_back({provider, remote_id, sensor});
}
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
void add_remote_binary_sensor(const char *provider, const char *remote_id, binary_sensor::BinarySensor *sensor) {
this->remote_binary_sensors_.push_back({provider, remote_id, sensor});
}
#endif
protected:
struct LocalEntity {
EntityBase *entity; // a sensor::Sensor or a binary_sensor::BinarySensor, as is_sensor says
const char *id;
EntityTransport *parent;
uint8_t number;
bool is_sensor;
bool updated;
};
template<typename T> struct RemoteEntity {
const char *provider;
const char *remote_id;
T *entity;
uint32_t dictionary_id{0}; // learnt from the provider's dictionary
uint32_t last_state_ms{0};
uint8_t number{0};
bool known{false};
bool has_state{false}; // a state arrived, and has not yet timed out
};
void send_all_();
void mark_updated_(LocalEntity &entity);
bool send_dictionary_();
bool send_updated_();
size_t write_record_(const LocalEntity &entity, uint8_t *record) const;
bool flush_(size_t length, size_t first, size_t last);
size_t write_header_(uint8_t type) const;
void receive_(const packet_interface::PacketBuffer &data);
void receive_dictionary_(std::span<const uint8_t> packet, uint32_t dictionary_id);
void receive_data_(std::span<const uint8_t> packet, uint32_t dictionary_id);
void learn_(const char *provider, size_t provider_length, uint8_t type, uint8_t number, const char *id,
size_t id_length, uint32_t dictionary_id);
void request_dictionary_(uint32_t dictionary_id);
bool dictionary_seen_(uint32_t dictionary_id) const;
/// Clears each remote entity of \p provider that its complete dictionary does not name, warning if the entity was
/// known or the dictionary is \p first_time seen, so a resent dictionary does not repeat the warning.
void forget_unnamed_(const char *provider, size_t provider_length, uint32_t dictionary_id, bool first_time);
bool warning_due_();
void sending_refused_();
void on_session_(bool connected);
void clear_remote_states_(bool timed_out_only);
packet_interface::PacketInterface *interface_;
FixedVector<LocalEntity> entities_;
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
StaticVector<RemoteEntity<sensor::Sensor>, ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT> remote_sensors_;
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
StaticVector<RemoteEntity<binary_sensor::BinarySensor>, ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT>
remote_binary_sensors_;
#endif
// Packets are built here, allocated in setup()
uint8_t *buffer_{nullptr};
size_t max_packet_size_{0};
uint32_t update_interval_ms_{0};
uint32_t remote_timeout_ms_{0};
uint32_t dictionary_id_{0};
uint32_t last_request_ms_{0};
uint32_t last_warning_ms_{0};
uint32_t refused_since_ms_{0}; // when the interface began refusing every packet
uint32_t rx_dictionary_id_{0}; // the dictionary whose parts are arriving
// The last dictionary ids received in full, so a provider whose dictionary names none of our entities is not asked
// again
std::array<uint32_t, 8> seen_dictionaries_{};
uint8_t seen_next_{0};
uint8_t rx_next_part_{0}; // the index of its next part, or PART_NONE if one was missed
uint8_t dictionary_part_{0}; // the index of the next dictionary packet to send
uint8_t sensor_count_{0};
uint8_t binary_sensor_count_{0};
size_t dictionary_next_{0}; // the first entity of the next dictionary packet to send
bool dictionary_pending_{true};
bool request_sent_{false};
bool warned_{false};
bool refusing_{false};
};
} // namespace esphome::entity_transport
@@ -0,0 +1,11 @@
from esphome.components import sensor
from esphome.types import ConfigType
from . import register_remote_sensor, remote_entity_schema
CONFIG_SCHEMA = remote_entity_schema(sensor.sensor_schema())
async def to_code(config: ConfigType) -> None:
var = await sensor.new_sensor(config)
await register_remote_sensor(config, var)
+23 -8
View File
@@ -1303,8 +1303,8 @@ def _resolve_framework_version(value: ConfigType) -> cv.Version:
raise cv.Invalid("Only Arduino 3.0+ is supported.")
recommended = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"]
else:
if version < cv.Version(5, 0, 0):
raise cv.Invalid("Only ESP-IDF 5.0+ is supported.")
if version < cv.Version(5, 1, 0):
raise cv.Invalid("Only ESP-IDF 5.1+ is supported.")
recommended = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"]
if version != recommended:
@@ -2213,6 +2213,22 @@ def idf_version() -> cv.Version:
return CORE.data[KEY_ESP32][KEY_IDF_VERSION]
def require_idf_version(
minimum: cv.Version, feature: str = "This feature"
) -> Callable[[Any], Any]:
"""Validator for an option that needs the underlying ESP-IDF at ``minimum`` or newer."""
def validator(value: Any) -> Any:
cv.only_on_esp32(value)
if (version := idf_version()) < minimum:
raise cv.Invalid(
f"{feature} requires ESP-IDF {minimum} or newer, got {version}"
)
return value
return validator
def require_fatfs() -> None:
"""Mark that FATFS support is required by a component.
@@ -2851,9 +2867,9 @@ async def _reconcile_network_sdkconfig() -> None:
# Software coexistence: enable when requested (the schema only allows it
# alongside WiFi). Disable only in the Ethernet-without-WiFi case.
if net.software_coexistence:
set_idf_sdkconfig_default("CONFIG_SW_COEXIST_ENABLE", True)
set_idf_sdkconfig_default("CONFIG_ESP_COEX_SW_COEXIST_ENABLE", True)
elif wifi_disabled:
set_idf_sdkconfig_default("CONFIG_SW_COEXIST_ENABLE", False)
set_idf_sdkconfig_default("CONFIG_ESP_COEX_SW_COEXIST_ENABLE", False)
# SoftAP support: drop it when WiFi is used without AP mode (IDF only).
if not is_arduino and net.wifi and not net.wifi_ap:
@@ -3031,10 +3047,9 @@ async def to_code(config):
# assert(), HAL_ASSERT and ESP_ERROR_CHECK bake __FILE__ into rodata, and
# IDF's noflash placement puts the flash driver's copies in DRAM. The
# basename keeps the panic output useful at a fraction of the size.
# __FILE_NAME__ is a GCC 12 builtin; IDF 5.0 still ships GCC 11.2.
if idf_version() >= cv.Version(5, 1, 0):
cg.add_build_flag("-D__FILE__=__FILE_NAME__")
cg.add_build_flag("-Wno-builtin-macro-redefined")
# __FILE_NAME__ is a GCC 12 builtin, which every supported IDF ships.
cg.add_build_flag("-D__FILE__=__FILE_NAME__")
cg.add_build_flag("-Wno-builtin-macro-redefined")
# Deferred so KEY_COMPONENTS is fully populated -- see the coroutine.
CORE.add_job(_finalize_arduino_aware_flags)
cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])
+1 -7
View File
@@ -473,17 +473,11 @@ def final_validation(config: ConfigType) -> None:
# Check if hosted bluetooth is being used
if ESP32_HOSTED_DOMAIN in full_config:
from esphome.components.esp32_hosted import uses_esp_hosted_3x
add_idf_sdkconfig_option("CONFIG_BT_CLASSIC_ENABLED", False)
add_idf_sdkconfig_option("CONFIG_BT_BLE_ENABLED", True)
add_idf_sdkconfig_option("CONFIG_BT_BLUEDROID_ENABLED", True)
add_idf_sdkconfig_option("CONFIG_BT_CONTROLLER_DISABLED", True)
if uses_esp_hosted_3x():
add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_FEAT_BT", True)
else:
add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID", True)
add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_BLUEDROID_HCI_VHCI", True)
add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_FEAT_BT", True)
# Check if BLE Client is needed (via esp32_ble_tracker or esp32_ble_client)
has_ble_client = (
+15 -48
View File
@@ -14,15 +14,10 @@
#include <cinttypes>
extern "C" {
#include <esp_hosted.h>
#ifndef CONFIG_ESP_HOSTED_HOST_FEAT_BT
#include <esp_hosted_misc.h>
#include <esp_hosted_bluedroid.h>
#endif
}
#ifdef CONFIG_ESP_HOSTED_HOST_FEAT_BT
#include <eh_host_bt.h>
#include <esp_hosted_bt_host_stack.h>
#endif
#endif
#include <esp_bt_device.h>
#include <esp_bt_main.h>
#include <esp_gap_ble_api.h>
@@ -246,44 +241,28 @@ bool ESP32BLE::ble_setup_() {
}
ESP_LOGD(TAG, "Co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32, fw_ver.major1, fw_ver.minor1, fw_ver.patch1);
#ifdef CONFIG_ESP_HOSTED_HOST_FEAT_BT
esp_hosted_bt_host_stack_cfg_t bt_cfg{};
bt_cfg.stack = ESP_HOSTED_BT_HOST_STACK_BLUEDROID;
bt_cfg.bring_up_controller = true;
bt_cfg.controller_ready_timeout_ms = EH_BT_CTRL_DEFAULT_READY_TIMEOUT_MS;
if (esp_hosted_bt_host_stack_setup(&bt_cfg) != ESP_OK) {
ESP_LOGE(TAG,
"BT controller bring-up failed; co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32
" may lack BT support. Update it with the esp32_hosted update component; BLE disabled",
fw_ver.major1, fw_ver.minor1, fw_ver.patch1);
return false;
}
#else
if (esp_hosted_bt_controller_init() != ESP_OK) {
// Not the library's bring-up: its retry loop can block for minutes on a silent co-processor
err = eh_host_bt_controller_init();
if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
ESP_LOGE(TAG,
"BT controller init failed; co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32
" may lack BT support. Update it with the esp32_hosted update component; BLE disabled",
fw_ver.major1, fw_ver.minor1, fw_ver.patch1);
return false;
}
if (esp_hosted_bt_controller_enable() != ESP_OK) {
ESP_LOGE(TAG,
"BT controller enable failed; co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32
" may lack BT support. Update it with the esp32_hosted update component; BLE disabled",
fw_ver.major1, fw_ver.minor1, fw_ver.patch1);
err = eh_host_bt_controller_enable();
if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
ESP_LOGE(TAG, "BT controller enable failed: %s; BLE disabled", esp_err_to_name(err));
return false;
}
hosted_hci_bluedroid_open();
esp_bluedroid_hci_driver_operations_t operations = {
.send = hosted_hci_bluedroid_send,
.check_send_available = hosted_hci_bluedroid_check_send_available,
.register_host_callback = hosted_hci_bluedroid_register_host_callback,
};
esp_bluedroid_attach_hci_driver(&operations);
#endif
esp_hosted_bt_host_stack_cfg_t bt_cfg{};
bt_cfg.stack = ESP_HOSTED_BT_HOST_STACK_BLUEDROID;
bt_cfg.bring_up_controller = false;
if (esp_hosted_bt_host_stack_setup(&bt_cfg) != ESP_OK) {
ESP_LOGE(TAG, "BT host stack setup failed; BLE disabled");
return false;
}
#endif
err = esp_bluedroid_init();
@@ -440,23 +419,11 @@ bool ESP32BLE::ble_dismantle_() {
return false;
}
}
#elif defined(CONFIG_ESP_HOSTED_HOST_FEAT_BT)
#else
if (esp_hosted_bt_host_stack_teardown() != ESP_OK) {
ESP_LOGE(TAG, "esp_hosted_bt_host_stack_teardown failed");
return false;
}
#else
if (esp_hosted_bt_controller_disable() != ESP_OK) {
ESP_LOGE(TAG, "esp_hosted_bt_controller_disable failed");
return false;
}
if (esp_hosted_bt_controller_deinit(false) != ESP_OK) {
ESP_LOGE(TAG, "esp_hosted_bt_controller_deinit failed");
return false;
}
hosted_hci_bluedroid_close();
#endif
return true;
}
+46 -220
View File
@@ -1,6 +1,6 @@
import logging
import os
from pathlib import Path
import re
from esphome import pins
from esphome.components import esp32
@@ -8,33 +8,25 @@ from esphome.components.const import CONF_SLOT, CONF_USE_PSRAM
import esphome.config_validation as cv
from esphome.const import (
CONF_CLK_PIN,
CONF_COMPONENTS,
CONF_CS_PIN,
CONF_FRAMEWORK,
CONF_FREQUENCY,
CONF_MISO_PIN,
CONF_MOSI_PIN,
CONF_NAME,
CONF_REF,
CONF_RESET_PIN,
CONF_TYPE,
CONF_VARIANT,
KEY_ESP32,
)
from esphome.core import CORE
from esphome.cpp_generator import add_define
import esphome.final_validate as fv
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@swoboda1337"]
DEPENDENCIES = ["esp32"]
DOMAIN = "esp32_hosted"
# esp32_ble raises the task watchdog around the remote BT controller bring-up
AUTO_LOAD = ["watchdog"]
# 3.x is opt-in via a pin under esp32.framework.components (needs ESP-IDF 5.5)
ESP_HOSTED_COMPONENT = "espressif/esp_hosted"
CONF_ACTIVE_HIGH = "active_high"
CONF_BUS_WIDTH = "bus_width"
CONF_CMD_PIN = "cmd_pin"
@@ -72,7 +64,7 @@ _MAX_CUSTOM_MSG_HANDLERS = 8
BASE_SCHEMA = cv.Schema(
{
cv.Required(CONF_VARIANT): cv.one_of(*esp32.VARIANTS, upper=True),
cv.Required(CONF_ACTIVE_HIGH): cv.boolean,
cv.Optional(CONF_ACTIVE_HIGH): cv.boolean,
cv.Required(CONF_RESET_PIN): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_USE_PSRAM, default=False): cv.boolean,
}
@@ -83,7 +75,7 @@ SDIO_SCHEMA = BASE_SCHEMA.extend(
cv.Required(CONF_CLK_PIN): pins.internal_gpio_output_pin_number,
cv.Required(CONF_CMD_PIN): pins.internal_gpio_output_pin_number,
cv.Required(CONF_D0_PIN): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_D1_PIN): pins.internal_gpio_output_pin_number,
cv.Required(CONF_D1_PIN): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_D2_PIN): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_D3_PIN): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_BUS_WIDTH, default=4): cv.one_of(1, 4, int=True),
@@ -95,9 +87,26 @@ SDIO_SCHEMA = BASE_SCHEMA.extend(
)
def _validate_active_high(config: ConfigType) -> ConfigType:
# Remove before 2027.5.0
if (active_high := config.get(CONF_ACTIVE_HIGH)) is None:
return config
if not active_high:
raise cv.Invalid(
"esp_hosted always parks the reset line high with a low pulse, so "
"'active_high: false' is not supported.",
path=[CONF_ACTIVE_HIGH],
)
_LOGGER.warning(
"'active_high' is deprecated and ignored, remove it from esp32_hosted. "
"Removed in 2027.5.0"
)
return config
def _validate_sdio(config: ConfigType) -> ConfigType:
if config[CONF_BUS_WIDTH] == 4:
for pin in (CONF_D1_PIN, CONF_D2_PIN, CONF_D3_PIN):
for pin in (CONF_D2_PIN, CONF_D3_PIN):
if pin not in config:
raise cv.Invalid(
f"{pin} is required when bus_width is 4",
@@ -150,121 +159,26 @@ def _validate_spi(config: ConfigType) -> ConfigType:
CONFIG_SCHEMA = cv.typed_schema(
{
"sdio": cv.All(SDIO_SCHEMA, _validate_sdio),
"spi": cv.All(SPI_SCHEMA, _validate_spi),
"sdio": cv.All(SDIO_SCHEMA, _validate_sdio, _validate_active_high),
"spi": cv.All(SPI_SCHEMA, _validate_spi, _validate_active_high),
},
default_type="sdio",
)
# Version specs that stay within one major (exact, ^, ~, ~=); other ranges are 3.x
_PINNED_MAJOR = re.compile(r"^(?:==|\^|~=|~)?(\d+)(?:\.(?:\d+|\*))*$")
def user_esp_hosted_major() -> int | None:
"""Major version of a user-pinned esp_hosted, or None without a pin."""
try:
full_config = fv.full_config.get()
except LookupError:
full_config = CORE.config
esp32_config = full_config.get(KEY_ESP32) or {}
for component in esp32_config.get(CONF_FRAMEWORK, {}).get(CONF_COMPONENTS, []):
if component.get(CONF_NAME) != ESP_HOSTED_COMPONENT:
continue
ref = component.get(CONF_REF, "").strip()
if not ref:
return 2
if match := _PINNED_MAJOR.match(ref):
return int(match.group(1))
return 3
return None
def uses_esp_hosted_3x() -> bool:
"""Whether the user pinned esp_hosted 3.x."""
return (major := user_esp_hosted_major()) is not None and major >= 3
def _final_validate(config: ConfigType) -> None:
# The esp_hosted releases compatible with older ESP-IDF versions crash at
# boot with a heap double free in the SDIO RX path (fixed in esp_hosted
# 2.11.0, which requires ESP-IDF 5.3), so reject them at validation time.
if (idf_ver := esp32.idf_version()) < cv.Version(5, 3, 0):
if (idf_ver := esp32.idf_version()) < cv.Version(5, 5, 0):
raise cv.Invalid(
f"esp32_hosted requires ESP-IDF 5.3 or newer, got {idf_ver}. "
f"esp32_hosted requires ESP-IDF 5.5 or newer, got {idf_ver}. "
"Remove the framework version from your configuration to use the "
"recommended version, or pin a version at or above 5.3."
)
if (major := user_esp_hosted_major()) is None or major < 3:
return
if idf_ver < cv.Version(5, 5, 0):
raise cv.Invalid(
f"esp_hosted 3.x requires ESP-IDF 5.5 or newer, got {idf_ver}. "
f"Remove the {ESP_HOSTED_COMPONENT} pin from esp32.framework."
"components to stay on the 2.x line, or use ESP-IDF 5.5 or newer."
)
if not config[CONF_ACTIVE_HIGH]:
raise cv.Invalid(
"esp_hosted 3.x always parks the reset line high with a low pulse, so "
"'active_high: false' cannot be expressed on it. Remove the "
f"{ESP_HOSTED_COMPONENT} pin from esp32.framework.components to stay "
"on the 2.x line."
)
if config[CONF_TYPE] == "sdio" and config[CONF_BUS_WIDTH] == 1:
raise cv.Invalid(
"esp_hosted 3.x cannot be built with a 1-bit SDIO bus "
"(espressif/esp-hosted-mcu#245). Remove the "
f"{ESP_HOSTED_COMPONENT} pin from esp32.framework.components to stay "
"on the 2.x line, or use a 4-bit bus."
"recommended version, or pin a version at or above 5.5."
)
FINAL_VALIDATE_SCHEMA = _final_validate
def _configure_sdio_2x(config: ConfigType) -> None:
slot = config[CONF_SLOT]
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_SDIO_SLOT_{slot}",
True,
)
if config[CONF_BUS_WIDTH] == 1:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_SDIO_1_BIT_BUS", True)
else:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_SDIO_4_BIT_BUS", True)
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_CLK_SLOT_{slot}",
config[CONF_CLK_PIN],
)
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_CMD_SLOT_{slot}",
config[CONF_CMD_PIN],
)
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D0_SLOT_{slot}",
config[CONF_D0_PIN],
)
if config[CONF_BUS_WIDTH] == 4:
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D1_4BIT_BUS_SLOT_{slot}",
config[CONF_D1_PIN],
)
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D2_4BIT_BUS_SLOT_{slot}",
config[CONF_D2_PIN],
)
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D3_4BIT_BUS_SLOT_{slot}",
config[CONF_D3_PIN],
)
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_CUSTOM_SDIO_PINS", True)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_SDIO_CLOCK_FREQ_KHZ",
int(config[CONF_SDIO_FREQUENCY] // 1000),
)
def _configure_sdio_3x(config: ConfigType) -> None:
def _configure_sdio(config: ConfigType) -> None:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_TRANSPORT_BUS_SDIO", True)
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_SDIO_SLOT_{config[CONF_SLOT]}",
@@ -283,10 +197,11 @@ def _configure_sdio_3x(config: ConfigType) -> None:
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D0", config[CONF_D0_PIN]
)
# D1 carries the card interrupt on a 1-bit bus too
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D1", config[CONF_D1_PIN]
)
if config[CONF_BUS_WIDTH] == 4:
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D1", config[CONF_D1_PIN]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D2", config[CONF_D2_PIN]
)
@@ -299,55 +214,7 @@ def _configure_sdio_3x(config: ConfigType) -> None:
)
def _configure_spi_2x(config: ConfigType) -> None:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_SPI_HOST_INTERFACE", True)
# SPI mode is set via per-variant choice options
variant = config[CONF_VARIANT]
mode = config[CONF_SPI_MODE]
suffix = "ESP32" if variant == "ESP32" else "ESP32XX"
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_SPI_PRIV_MODE_{mode}_{suffix}",
True,
)
# Frequency is set via per-variant options
freq_mhz = int(config[CONF_FREQUENCY] // 1e6)
if variant == "ESP32":
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_SPI_FREQ_ESP32", freq_mhz)
elif variant == "ESP32C6":
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_SPI_FREQ_ESP32C6", freq_mhz)
else:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_SPI_FREQ_ESP32XX", freq_mhz)
# Pin configuration (use HSPI variant as P4/H2 hosts don't have VSPI)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_SPI_HSPI_GPIO_MOSI", config[CONF_MOSI_PIN]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_SPI_HSPI_GPIO_MISO", config[CONF_MISO_PIN]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_SPI_HSPI_GPIO_CLK", config[CONF_CLK_PIN]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_SPI_HSPI_GPIO_CS", config[CONF_CS_PIN]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_SPI_GPIO_HANDSHAKE", config[CONF_HANDSHAKE_PIN]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_SPI_GPIO_DATA_READY", config[CONF_DATA_READY_PIN]
)
# Handshake and data_ready polarity
if config[CONF_HANDSHAKE_ACTIVE_HIGH]:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HS_ACTIVE_HIGH", True)
else:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HS_ACTIVE_LOW", True)
if config[CONF_DATA_READY_ACTIVE_HIGH]:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_DR_ACTIVE_HIGH", True)
else:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_DR_ACTIVE_LOW", True)
def _configure_spi_3x(config: ConfigType) -> None:
def _configure_spi(config: ConfigType) -> None:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_TRANSPORT_BUS_SPI", True)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SPI_MODE", config[CONF_SPI_MODE]
@@ -392,59 +259,28 @@ def _configure_spi_3x(config: ConfigType) -> None:
)
def _configure_2x(config: ConfigType) -> None:
transport_prefix = "SDIO" if config[CONF_TYPE] == "sdio" else "SPI"
# Reset polarity
if config[CONF_ACTIVE_HIGH]:
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_{transport_prefix}_RESET_ACTIVE_HIGH", True
)
else:
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_{transport_prefix}_RESET_ACTIVE_LOW", True
)
# Reset GPIO
async def to_code(config: ConfigType) -> None:
add_define("USE_ESP32_HOSTED")
# The board choice defaults to an Espressif dev board, which locks every
# pin's Kconfig range to that board and silently drops other values
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_P4_DEV_BOARD_NONE", True)
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_{transport_prefix}_GPIO_RESET_SLAVE", # NOLINT
config[CONF_RESET_PIN],
"CONFIG_ESP_HOSTED_HOST_RESET_GPIO", config[CONF_RESET_PIN]
)
# Slave variant # NOLINT
# Co-processor variant. Wi-Fi Remote owns the choice and esp_hosted only
# offers the matching CP target, so both must name the same chip
esp32.add_idf_sdkconfig_option(
f"CONFIG_SLAVE_IDF_TARGET_{config[CONF_VARIANT]}", # NOLINT
True,
)
if config[CONF_TYPE] == "sdio":
_configure_sdio_2x(config)
else:
_configure_spi_2x(config)
if esp32.get_esp32_variant() == esp32.VARIANT_ESP32P4:
# esp-hosted's CustomRpc ("peer data transfer") path — off by default.
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_ENABLE_PEER_DATA_TRANSFER", True
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_MAX_CUSTOM_MSG_HANDLERS", _MAX_CUSTOM_MSG_HANDLERS
)
# Place the transport mempool in PSRAM. Required on memory-tight host
# configurations (e.g. P4 with a large LVGL UI) where the internal-RAM
# mempool allocation fails at boot with `sdio_mempool_create` assert.
if config[CONF_USE_PSRAM]:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM", True)
def _configure_3x(config: ConfigType) -> None:
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_RESET_GPIO", config[CONF_RESET_PIN]
)
# Co-processor variant
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_CP_TARGET_{config[CONF_VARIANT]}",
True,
)
if config[CONF_TYPE] == "sdio":
_configure_sdio_3x(config)
_configure_sdio(config)
else:
_configure_spi_3x(config)
_configure_spi(config)
if esp32.get_esp32_variant() == esp32.VARIANT_ESP32P4:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_FEAT_PEER_DATA", True)
esp32.add_idf_sdkconfig_option(
@@ -455,16 +291,6 @@ def _configure_3x(config: ConfigType) -> None:
esp32.add_idf_sdkconfig_option("CONFIG_EH_HOST_PORT_DMA_PREFER_SPIRAM", True)
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_DFLT_TASK_FROM_SPIRAM", True)
async def to_code(config: ConfigType) -> None:
add_define("USE_ESP32_HOSTED")
use_3x = uses_esp_hosted_3x()
if use_3x:
_configure_3x(config)
else:
_configure_2x(config)
# ESP-NOW-over-hosted shim: only the radio-less ESP32-P4 host needs it (see
# the note by _MAX_CUSTOM_MSG_HANDLERS). Enabled for every P4 host, not
# gated on the `espnow` component being present: the shim is tiny and the
@@ -478,14 +304,14 @@ async def to_code(config: ConfigType) -> None:
# esp_now_hosted.cpp includes esp_now.h, which esp_wifi provides
esp32.include_builtin_idf_component("esp_wifi")
# Library versions; this component set requires ESP-IDF 5.3 or newer,
# Library versions; this component set requires ESP-IDF 5.5 or newer,
# which is enforced at validation time.
idf_ver = esp32.idf_version()
os.environ["ESP_IDF_VERSION"] = f"{idf_ver.major}.{idf_ver.minor}"
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.5")
esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.3")
esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5")
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.13")
esp32.add_idf_component(name="espressif/esp_hosted", ref="3.0.10")
esp32.add_extra_script(
"post",
"esp32_hosted.py",
@@ -1,12 +1,22 @@
import hashlib
import logging
from pathlib import Path
from tempfile import TemporaryDirectory
import time
from typing import Any
import voluptuous as vol
from esphome import external_files
import esphome.codegen as cg
from esphome.components import esp32, update
from esphome.components.const import CONF_SHA256
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_PATH, CONF_SOURCE, CONF_TYPE
from esphome.const import CONF_ID, CONF_PATH, CONF_SOURCE, CONF_TYPE, CONF_URL
from esphome.core import CORE, ID, HexInt
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@swoboda1337"]
AUTO_LOAD = ["sha256", "watchdog", "json"]
@@ -40,11 +50,15 @@ BASE_SCHEMA = update.update_schema(Esp32HostedUpdate, device_class="firmware").e
cv.polling_component_schema("6h")
)
EMBEDDED_SCHEMA = BASE_SCHEMA.extend(
{
cv.Required(CONF_PATH): cv.file_,
cv.Required(CONF_SHA256): _validate_sha256,
}
EMBEDDED_SCHEMA = cv.All(
BASE_SCHEMA.extend(
{
cv.Optional(CONF_PATH): cv.file_,
cv.Optional(CONF_URL): cv.All(cv.url, vol.Match(r"^https?://")),
cv.Required(CONF_SHA256): _validate_sha256,
}
),
cv.has_exactly_one_key(CONF_PATH, CONF_URL),
)
HTTP_SCHEMA = BASE_SCHEMA.extend(
@@ -70,21 +84,74 @@ CONFIG_SCHEMA = cv.All(
)
def _validate_firmware(config: dict[str, Any]) -> None:
if config[CONF_TYPE] != TYPE_EMBEDDED:
return
# The other build's rename finishes within milliseconds; a few short checks cover it
CACHE_RACE_RETRIES = 5
CACHE_RACE_RETRY_S = 0.05
path = CORE.relative_config_path(config[CONF_PATH])
with path.open("rb") as f:
firmware_data = f.read()
calculated = hashlib.sha256(firmware_data).hexdigest()
expected = config[CONF_SHA256].lower()
def _check_firmware_hash(data: bytes, expected: str, source: str | Path) -> None:
calculated = hashlib.sha256(data).hexdigest()
if calculated != expected:
raise cv.Invalid(
f"SHA256 mismatch for {config[CONF_PATH]}: expected {expected}, got {calculated}"
f"SHA256 mismatch for {source}: expected {expected}, got {calculated}"
)
def _cached_image_digest(target: Path) -> str | None:
"""The SHA256 of the cached image, or None while it is missing or another build still writes it."""
try:
return hashlib.sha256(target.read_bytes()).hexdigest()
except OSError:
return None
def _firmware_path(config: ConfigType) -> Path:
"""The configured image file, or the cache slot of the pinned digest for a downloaded one."""
if CONF_URL in config:
cache_dir = external_files.compute_local_file_dir("esp32_hosted") / "firmware"
return cache_dir / f"{config[CONF_SHA256].lower()}.bin"
return CORE.relative_config_path(config[CONF_PATH])
def _validate_firmware(config: ConfigType) -> None:
"""Fetch and verify the image here; codegen only reads the file this leaves behind."""
if config[CONF_TYPE] != TYPE_EMBEDDED:
return
expected = config[CONF_SHA256].lower()
target = _firmware_path(config)
if (url := config.get(CONF_URL)) is None:
_check_firmware_hash(target.read_bytes(), expected, target)
return
# A pinned digest needs no remote freshness check.
if (actual := _cached_image_digest(target)) == expected:
return
if actual is not None:
# Named after its digest, so a mismatch means the file was damaged or edited
_LOGGER.warning(
"Cached firmware %s hashes to %s, downloading it again", target, actual
)
target.parent.mkdir(parents=True, exist_ok=True)
# Keep unverified bytes out of the shared cache, even across concurrent builds.
with TemporaryDirectory(dir=target.parent) as temporary:
candidate = Path(temporary) / "firmware.bin"
data = external_files.download_content(url, candidate, allow_stale=False)
_check_firmware_hash(data, expected, url)
for attempt in range(CACHE_RACE_RETRIES + 1):
try:
candidate.replace(target)
return
except OSError as err:
# Windows refuses to replace a file another build holds at that moment; retry, or
# take that build's copy: named after its digest, a verified one is the same image
if _cached_image_digest(target) == expected:
return
if attempt == CACHE_RACE_RETRIES:
raise cv.Invalid(
f"Could not cache the firmware from {url}: {err}"
) from err
time.sleep(CACHE_RACE_RETRY_S)
FINAL_VALIDATE_SCHEMA = _validate_firmware
@@ -92,9 +159,7 @@ async def to_code(config: dict[str, Any]) -> None:
var = await update.new_update(config)
if config[CONF_TYPE] == TYPE_EMBEDDED:
path = config[CONF_PATH]
with CORE.relative_config_path(path).open("rb") as f:
firmware_data = f.read()
firmware_data = _firmware_path(config).read_bytes()
rhs = [HexInt(x) for x in firmware_data]
arr_id = ID(f"{config[CONF_ID]}_data", is_declaration=True, type=cg.uint8)
prog_arr = cg.progmem_array(arr_id, rhs)
@@ -24,8 +24,8 @@ namespace esphome::esp32_hosted {
ESPHOME_LOG_TAG(TAG, "esp32_hosted.update");
// Older coprocessor firmware versions have a 1500-byte limit per RPC call
constexpr size_t CHUNK_SIZE = 1500;
// The host rejects writes above its per-RPC payload limit before they reach the coprocessor
constexpr size_t CHUNK_SIZE = EH_HOST_CP_OTA_CHUNK_MAX;
// OTA begin blocks while the coprocessor erases its partition, end while it validates the image
constexpr uint32_t OTA_WDT_TIMEOUT_MS = 60000;
@@ -222,10 +222,13 @@ void ESPHomeOTAComponent::udp_open_(UdpLink &link) {
if (!random_bytes(token, sizeof(token)))
return;
#ifdef ESPHOME_OTA_UDP_LWIP
#if USE_NETWORK_IPV4
uint32_t ipv4;
if (socket::sockaddr_to_ipv4(reinterpret_cast<const struct sockaddr *>(&peer), &ipv4)) {
ip_addr_set_ip4_u32(&link.peer_ip, ipv4);
} else {
} else
#endif
{
#if USE_NETWORK_IPV6 && LWIP_IPV6
inet6_addr_to_ip6addr(ip_2_ip6(&link.peer_ip), &reinterpret_cast<const sockaddr_in6 *>(&peer)->sin6_addr);
IP_SET_TYPE_VAL(link.peer_ip, IPADDR_TYPE_V6);
@@ -24,7 +24,7 @@ ESPHOME_LOG_TAG(TAG, "espnow");
ESPNowComponent *global_esp_now = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static const LogString *espnow_error_to_str(esp_err_t error) {
const LogString *espnow_error_to_str(esp_err_t error) {
switch (error) {
case ESP_ERR_ESPNOW_FAILED:
return LOG_STR("ESPNow is in fail mode");
@@ -25,6 +25,9 @@ namespace esphome::espnow {
using peer_address_t = std::array<uint8_t, ESP_NOW_ETH_ALEN>;
/// A short description of an ESP-NOW error code, for logging.
const LogString *espnow_error_to_str(esp_err_t error);
enum class ESPNowTriggers : uint8_t {
TRIGGER_NONE = 0,
ON_NEW_PEER = 1,
@@ -123,6 +126,7 @@ class ESPNowComponent final : public Component {
uint8_t get_wifi_channel();
void set_auto_add_peer(bool value) { this->auto_add_peer_ = value; }
bool get_auto_add_peer() const { return this->auto_add_peer_; }
#if defined(USE_WIFI) && defined(USE_WIFI_CONNECT_STATE_LISTENERS)
// WiFiConnectStateListener interface: refresh the cached channel after each (re)connect
@@ -0,0 +1,90 @@
import esphome.codegen as cg
from esphome.components import packet_interface
from esphome.components.packet_interface import (
CONF_MAX_PACKET_SIZE,
new_packet_interface,
packet_interface_schema,
)
from esphome.components.packet_interface.layers import (
CONF_ADDRESSING,
check_broadcast_link,
)
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.core import HexInt
import esphome.final_validate as fv
from esphome.types import ConfigType
from .. import (
CONF_MAX_PAYLOAD_SIZE,
DOMAIN as ESPNOW_DOMAIN,
ESPNowComponent,
espnow_ns,
)
CODEOWNERS = ["@clydebarrow"]
DEPENDENCIES = ["espnow"]
CONF_ESPNOW_ID = "espnow_id"
CONF_PEER_ADDRESS = "peer_address"
# The ESP-NOW v1 payload; a larger max_payload_size on the espnow component allows a larger max_packet_size
MAX_PACKET_SIZE = 250
BROADCAST_MAC = "FF:FF:FF:FF:FF:FF"
ESPNowPacketInterface = espnow_ns.class_(
"ESPNowPacketInterface", packet_interface.PacketInterface
)
def _is_broadcast(config: ConfigType) -> bool:
return str(config[CONF_PEER_ADDRESS]) == BROADCAST_MAC
def _validate_addressing(config: ConfigType) -> ConfigType:
if _is_broadcast(config):
check_broadcast_link(config)
elif CONF_ADDRESSING in config:
raise cv.Invalid(
f"Packets to one '{CONF_PEER_ADDRESS}' are already addressed, so '{CONF_ADDRESSING}' is only "
"for broadcast",
path=[CONF_ADDRESSING],
)
return config
CONFIG_SCHEMA = cv.All(
packet_interface_schema(ESPNowPacketInterface, MAX_PACKET_SIZE, 1470).extend(
{
cv.GenerateID(CONF_ESPNOW_ID): cv.use_id(ESPNowComponent),
cv.Optional(CONF_PEER_ADDRESS, default=BROADCAST_MAC): cv.mac_address,
}
),
_validate_addressing,
)
def _validate_packet_size(config: ConfigType) -> ConfigType:
payload = fv.full_config.get()[ESPNOW_DOMAIN][CONF_MAX_PAYLOAD_SIZE]
if config[CONF_MAX_PACKET_SIZE] > payload:
raise cv.Invalid(
f"Must not be larger than the espnow {CONF_MAX_PAYLOAD_SIZE} of {payload}",
path=[CONF_MAX_PACKET_SIZE],
)
return config
FINAL_VALIDATE_SCHEMA = _validate_packet_size
async def to_code(config: ConfigType) -> None:
parent = await cg.get_variable(config[CONF_ESPNOW_ID])
peer = [HexInt(x) for x in config[CONF_PEER_ADDRESS].parts]
if not _is_broadcast(config):
cg.add(parent.add_peer(peer))
# The copy buffer's size is known here, so it is a static array rather than allocated at run time
tx_buffer = f"{config[CONF_ID].id}__tx_buffer"
cg.add_global(
cg.RawStatement(f"static uint8_t {tx_buffer}[{config[CONF_MAX_PACKET_SIZE]}];")
)
await new_packet_interface(config, parent, cg.RawExpression(tx_buffer), peer)
@@ -0,0 +1,125 @@
#include "espnow_packet_interface.h"
#if defined(USE_ESP32) || defined(USE_ESP8266)
#include <cstring>
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::espnow {
using packet_interface::PacketBuffer;
using packet_interface::PacketMetaData;
ESPHOME_LOG_TAG(TAG, "espnow.packet_interface");
ESPNowPacketInterface::ESPNowPacketInterface(ESPNowComponent *parent, std::span<uint8_t> tx_buffer,
peer_address_t peer_address)
: packet_interface::PacketInterface(tx_buffer.size()),
parent_(parent),
peer_address_(peer_address),
tx_buffer_(tx_buffer) {
if (!this->is_broadcast_())
this->capabilities_ = packet_interface::CAPABILITY_ADDRESSING;
}
void ESPNowPacketInterface::setup() {
// Each mode hears only its own kind of packet, so a peer link and a broadcast link can share the component
if (!this->is_broadcast_() || !ESPNOW_REPORTS_DESTINATION)
this->parent_->register_receive_handler(this);
if (this->is_broadcast_()) {
this->parent_->register_broadcast_handler(this);
this->parent_->register_unknown_peer_handler(this);
}
}
void ESPNowPacketInterface::dump_config() {
char peer_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
format_mac_addr_upper(this->peer_address_.data(), peer_buf);
ESP_LOGCONFIG(TAG,
"ESP-NOW Packet Interface '%s':\n"
" Peer: %s\n"
" Max Packet Size: %zu",
this->name_, peer_buf, this->max_packet_size_);
}
bool ESPNowPacketInterface::send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) {
// Layers above are set up first, and may send while setup waits for another component
if (!this->is_ready())
return false;
size_t size = data.size();
if (size > this->max_packet_size_) {
ESP_LOGW(TAG, "%s: Packet of %zu bytes is larger than %zu", this->name_, size, this->max_packet_size_);
return false;
}
const uint8_t *payload;
if (data.is_contiguous()) {
payload = data.as_span().data();
} else {
data.copy_to(this->tx_buffer_.data(), size);
payload = this->tx_buffer_.data();
}
esp_err_t err = this->parent_->send(this->peer_address_.data(), payload, size);
if (err != ESP_OK) {
this->send_failed_(err);
return false;
}
return true;
}
// A full send queue is logged by the espnow component, and ESP-NOW is not running before setup or while disabled.
// Other errors, such as a peer that could not be added, repeat on every send, so warn now and then
void ESPNowPacketInterface::send_failed_(esp_err_t err) {
static constexpr uint32_t WARNING_INTERVAL_MS = 60000;
uint32_t now = App.get_loop_component_start_time();
if (err == ESP_ERR_ESPNOW_NO_MEM || err == ESP_ERR_ESPNOW_NOT_INIT ||
(this->send_warned_ && now - this->last_send_warning_ms_ < WARNING_INTERVAL_MS)) {
ESP_LOGV(TAG, "%s: Send failed: %s", this->name_, LOG_STR_ARG(espnow_error_to_str(err)));
return;
}
this->send_warned_ = true;
this->last_send_warning_ms_ = now;
ESP_LOGW(TAG, "%s: Send failed: %s", this->name_, LOG_STR_ARG(espnow_error_to_str(err)));
}
bool ESPNowPacketInterface::on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) {
this->receive_(info, data, size);
return false;
}
bool ESPNowPacketInterface::on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) {
this->receive_(info, data, size);
return false;
}
bool ESPNowPacketInterface::on_unknown_peer(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) {
// Only known peers reach the other handlers; with auto_add_peer this sender is added and arrives there
if (this->parent_->get_auto_add_peer())
return false;
if (!ESPNOW_REPORTS_DESTINATION || memcmp(info.des_addr, ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0)
this->receive_(info, data, size);
return false;
}
void ESPNowPacketInterface::receive_(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) {
if (size == 0)
return;
if (size > this->max_packet_size_) {
ESP_LOGV(TAG, "%s: Packet of %u bytes is larger than %zu, dropped", this->name_, size, this->max_packet_size_);
return;
}
if (!this->is_broadcast_() && memcmp(info.src_addr, this->peer_address_.data(), ESP_NOW_ETH_ALEN) != 0)
return;
PacketMetaData meta_data;
memcpy(meta_data.mac_address, info.src_addr, ESP_NOW_ETH_ALEN);
#ifdef USE_ESP32
meta_data.rssi = info.rx_ctrl->rssi;
#endif
this->on_receive_from_interface_(PacketBuffer(data, size), meta_data);
}
} // namespace esphome::espnow
#endif // USE_ESP32 || USE_ESP8266
@@ -0,0 +1,59 @@
#pragma once
#include "../espnow_component.h"
#if defined(USE_ESP32) || defined(USE_ESP8266)
#include <cstring>
#include <span>
#include "esphome/components/packet_interface/packet_interface.h"
namespace esphome::espnow {
/**
* A packet interface link over ESP-NOW.
*
* With a peer address other than broadcast, packets go to that device only, and only packets sent to this device by
* it are received, so the link provides the addressing capability itself. With the broadcast address, packets go to
* every device on the channel and every broadcast packet is received, from known and unknown senders; an addressing
* layer then tells the devices apart. ESP8266 does not report a packet's destination, so there a broadcast link also
* receives packets sent to this device, and a peer link also receives the peer's broadcasts.
*
* ESP-NOW queues each packet and reports later whether it was delivered; a reliability layer above resends what is
* lost.
*/
class ESPNowPacketInterface : public packet_interface::PacketInterface,
public ESPNowReceivedPacketHandler,
public ESPNowBroadcastHandler,
public ESPNowUnknownPeerHandler {
public:
/// @param tx_buffer Joins packets split across buffers; its size is the largest packet, and it must outlive the link
ESPNowPacketInterface(ESPNowComponent *parent, std::span<uint8_t> tx_buffer, peer_address_t peer_address);
void setup() override;
void dump_config() override;
bool send_to_interface(const packet_interface::PacketBuffer &data,
const packet_interface::PacketMetaData &meta_data) override;
bool on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) override;
bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) override;
bool on_unknown_peer(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) override;
protected:
bool is_broadcast_() const {
return memcmp(this->peer_address_.data(), ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0;
}
void receive_(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size);
void send_failed_(esp_err_t err);
ESPNowComponent *parent_;
peer_address_t peer_address_;
const std::span<uint8_t> tx_buffer_;
uint32_t last_send_warning_ms_{0};
bool send_warned_{false};
};
} // namespace esphome::espnow
#endif // USE_ESP32 || USE_ESP8266
+25 -21
View File
@@ -46,6 +46,7 @@ from esphome.const import (
CONF_TYPE,
CONF_USE_ADDRESS,
CONF_VALUE,
CONF_WIFI,
KEY_CORE,
KEY_FRAMEWORK_VERSION,
Platform,
@@ -361,7 +362,7 @@ def _validate(config: ConfigType) -> ConfigType:
from esphome.components.esp32 import (
VARIANT_ESP32S31,
get_esp32_variant,
idf_version,
require_idf_version,
)
eth_type = config[CONF_TYPE]
@@ -371,10 +372,9 @@ def _validate(config: ConfigType) -> ConfigType:
f"The '{eth_type}' (RGMII) PHY is only supported on gigabit-capable "
f"variants (ESP32-S31), not {variant}"
)
if idf_version() < cv.Version(6, 0, 0):
raise cv.Invalid(
f"The '{eth_type}' (RGMII) PHY requires ESP-IDF 6.0 or newer."
)
require_idf_version(cv.Version(6, 0, 0), f"The '{eth_type}' (RGMII) PHY")(
config
)
elif config[CONF_TYPE] != "OPENETH":
from esphome.components.esp32 import (
VARIANT_ESP32,
@@ -562,6 +562,25 @@ CONFIG_SCHEMA = cv.All(
)
def _final_validate_wifi(config: ConfigType) -> None:
"""A wifi station competes with ethernet for the primary interface, so both must be in network: priority:."""
full_config = fv.full_config.get()
if (wifi_config := full_config.get(CONF_WIFI)) is None:
return
from esphome.components.wifi import wifi_stays_ap_only
if wifi_stays_ap_only(wifi_config, full_config):
return # an access point does not claim the primary interface
priority_ifaces = get_priority_interfaces_from_full_config(full_config)
if missing := [i for i in (DOMAIN, CONF_WIFI) if i not in priority_ifaces]:
raise cv.Invalid(
"When ethernet and a wifi station are used together, 'network: priority:' "
f"must list both interfaces; missing: {', '.join(missing)}"
)
def _final_validate_spi(config: ConfigType) -> None:
if not CORE.is_esp32:
return # SPI interface validation is ESP32-only
@@ -846,22 +865,7 @@ def _final_validate_rmii_pins(config: ConfigType) -> None:
def _final_validate(config: ConfigType) -> None:
"""Final validation for Ethernet component."""
# Allow ethernet + wifi coexistence only when both are declared in network: priority:.
if "wifi" in fv.full_config.get():
priority_ifaces = get_priority_interfaces_from_full_config(fv.full_config.get())
missing = [i for i in ("ethernet", "wifi") if i not in priority_ifaces]
if missing and priority_ifaces:
# A priority list exists but is incomplete: point at what to add.
raise cv.Invalid(
"When ethernet and wifi are used together, 'network: priority:' must "
f"list both interfaces; missing: {', '.join(missing)}"
)
if missing:
raise cv.Invalid(
"Component ethernet cannot be used together with component wifi "
"unless both are listed under 'network: priority:'"
)
_final_validate_wifi(config)
_final_validate_spi(config)
_final_validate_rmii_pins(config)
@@ -1,29 +1,20 @@
import esphome.codegen as cg
from esphome.components import button
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
DEVICE_CLASS_RESTART,
ENTITY_CATEGORY_CONFIG,
ICON_RESTART_ALERT,
)
from esphome.types import ConfigType
from .. import factory_reset_ns
FactoryResetButton = factory_reset_ns.class_(
"FactoryResetButton", button.Button, cg.Component
)
CONFIG_SCHEMA = button.button_schema(
FactoryResetButton,
button.ApplyButton,
device_class=DEVICE_CLASS_RESTART,
entity_category=ENTITY_CATEGORY_CONFIG,
icon=ICON_RESTART_ALERT,
).extend(cv.COMPONENT_SCHEMA)
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await button.register_button(var, config)
await button.sub_buttons(config).create(
config, None, apply="factory_reset::factory_reset_device()"
)
@@ -13,24 +13,20 @@ namespace esphome::factory_reset {
ESPHOME_LOG_TAG(TAG, "factory_reset.button");
void FactoryResetButton::dump_config() { LOG_BUTTON("", "Factory Reset Button", this); }
void FactoryResetButton::press_action() {
static void reset_and_reboot() {
global_preferences->reset();
App.safe_reboot();
}
void factory_reset_device() {
ESP_LOGI(TAG, "Resetting");
// Let MQTT settle a bit
delay(100); // NOLINT
#ifdef USE_OPENTHREAD
openthread::global_openthread_component->on_factory_reset(FactoryResetButton::factory_reset_callback);
openthread::global_openthread_component->on_factory_reset(reset_and_reboot);
#else
global_preferences->reset();
App.safe_reboot();
reset_and_reboot();
#endif
}
#ifdef USE_OPENTHREAD
void FactoryResetButton::factory_reset_callback() {
global_preferences->reset();
App.safe_reboot();
}
#endif
} // namespace esphome::factory_reset
@@ -1,21 +1,8 @@
#pragma once
#include "esphome/core/defines.h"
#include "esphome/components/button/button.h"
#include "esphome/core/component.h"
namespace esphome::factory_reset {
class FactoryResetButton final : public button::Button, public Component {
public:
void dump_config() override;
#ifdef USE_OPENTHREAD
static void factory_reset_callback();
#endif
protected:
void press_action() override;
};
/// Run by the factory reset button.
void factory_reset_device();
} // namespace esphome::factory_reset
+2 -2
View File
@@ -22,10 +22,10 @@ namespace esphome::hdmi_cec {
// Release/acquire store and load. The read-modify-write forms of std::atomic are not available on
// every target (the ESP8266 has none), so a writer computes the new value itself; that is safe here
// because every atomic in this component has exactly one writer.
template<typename T> ESPHOME_ALWAYS_INLINE void atomic_set(std::atomic<T> &atom, T value) {
template<typename T> inline ESPHOME_ALWAYS_INLINE void atomic_set(std::atomic<T> &atom, T value) {
atom.store(value, std::memory_order_release);
}
template<typename T> ESPHOME_ALWAYS_INLINE T atomic_get(const std::atomic<T> &atom) {
template<typename T> inline ESPHOME_ALWAYS_INLINE T atomic_get(const std::atomic<T> &atom) {
return atom.load(std::memory_order_acquire);
}
+12 -10
View File
@@ -45,14 +45,15 @@ void HmacSHA256::get_hex(char *output) {
}
bool HmacSHA256::equals_bytes(const uint8_t *expected) {
return memcmp(this->digest_, expected, SHA256_DIGEST_SIZE) == 0;
return constant_time_equals(this->digest_, expected, SHA256_DIGEST_SIZE);
}
bool HmacSHA256::equals_hex(const char *expected) {
char hex_output[SHA256_DIGEST_SIZE * 2 + 1];
this->get_hex(hex_output);
hex_output[SHA256_DIGEST_SIZE * 2] = '\0';
return strncmp(hex_output, expected, SHA256_DIGEST_SIZE * 2) == 0;
uint8_t parsed[SHA256_DIGEST_SIZE];
if (parse_hex(expected, SHA256_DIGEST_SIZE * 2, parsed, SHA256_DIGEST_SIZE) != SHA256_DIGEST_SIZE * 2) {
return false;
}
return this->equals_bytes(parsed);
}
#elif defined(USE_HMAC_SHA256_MBEDTLS)
@@ -77,14 +78,15 @@ void HmacSHA256::get_hex(char *output) {
}
bool HmacSHA256::equals_bytes(const uint8_t *expected) {
return memcmp(this->digest_, expected, SHA256_DIGEST_SIZE) == 0;
return constant_time_equals(this->digest_, expected, SHA256_DIGEST_SIZE);
}
bool HmacSHA256::equals_hex(const char *expected) {
char hex_output[SHA256_DIGEST_SIZE * 2 + 1];
this->get_hex(hex_output);
hex_output[SHA256_DIGEST_SIZE * 2] = '\0';
return strncmp(hex_output, expected, SHA256_DIGEST_SIZE * 2) == 0;
uint8_t parsed[SHA256_DIGEST_SIZE];
if (parse_hex(expected, SHA256_DIGEST_SIZE * 2, parsed, SHA256_DIGEST_SIZE) != SHA256_DIGEST_SIZE * 2) {
return false;
}
return this->equals_bytes(parsed);
}
#else
@@ -203,12 +203,8 @@ void HomeassistantLight::on_report_(bool changed) {
this->cancel_timeout(ECHO_TIMEOUT_ID);
}
// Noted as the report arrives, not when the deferred apply runs: a call read from the same loop
// pass before this report is older than it, one read after it is newer and must not pass for
// the transition the report is held for
this->report_in_transition_ = this->state_->is_transitioning();
if (this->report_in_transition_) {
this->held_hash_ = state_hash(this->state_->remote_values);
}
// pass before this report is older than it, one read after it is newer
this->state_->clear_called();
}
this->schedule_apply_();
}
@@ -257,8 +253,9 @@ void HomeassistantLight::apply_imported_() {
}
if (!this->state_->set_external_state(values)) {
// A report that changed something during a transition or flash is applied once it ends, in
// place of the values it ends at; a trait or a repeated report has nothing newer to say
if (this->report_in_transition_) {
// place of the values it ends at, unless a call came after the report; a trait or a repeated
// report has nothing newer to say
if (this->state_->is_transitioning() && !this->state_->called_since_clear()) {
this->report_held_ = true;
return;
}
@@ -268,20 +265,9 @@ void HomeassistantLight::apply_imported_() {
ESP_LOGD(TAG, "'%s': Report dropped, a local change is on its way", this->entity_id_);
return;
}
this->report_in_transition_ = false;
ESP_LOGV(TAG, "'%s': Took over the state reported by Home Assistant", this->entity_id_);
}
uint32_t HomeassistantLight::state_hash(const LightColorValues &values) {
uint32_t hash = fnv1a_hash_extend(FNV1_OFFSET_BASIS, static_cast<uint8_t>(values.get_color_mode()));
for (const float channel :
{values.get_state(), values.get_brightness(), values.get_color_brightness(), values.get_red(),
values.get_green(), values.get_blue(), values.get_white(), values.get_cold_white(), values.get_warm_white()}) {
hash = fnv1a_hash_extend(hash, to_uint8_scale(channel));
}
return fnv1a_hash_extend(hash, static_cast<uint16_t>(values.get_color_temperature())); // mireds exceed a byte
}
void HomeassistantLight::update_state(LightState *state) {
// The light core's setup performs one call for the initial state; a second one before the first
// write (an on_boot automation) is a local change merged into that write, so it must be sent
@@ -291,11 +277,10 @@ void HomeassistantLight::update_state(LightState *state) {
}
this->initial_seen_ = true;
}
// A call without a transition replaces whatever ran, so a report held for it, or still waiting to
// be applied, is older than the call
if (!state->is_transitioning()) {
// Every call that changes the light reaches here within a loop pass (a new transformer's first
// apply() or set_immediately_()), and a call since the held report was read is newer than it
if (this->report_held_ && state->called_since_clear()) {
this->report_held_ = false;
this->report_in_transition_ = false;
}
}
@@ -338,11 +323,7 @@ void HomeassistantLight::write_state(LightState *state) {
return;
}
if (state->is_transitioning()) {
// A transition or flash runs here alone; Home Assistant gets the state it ends at. A call that
// replaces the transition a report was held for retargets it and is newer than the report
if (this->report_held_ && state_hash(state->remote_values) != this->held_hash_) {
this->report_held_ = false;
}
// A transition or flash runs here alone; Home Assistant gets the state it ends at
this->transitioned_ = true;
return;
}
@@ -350,7 +331,6 @@ void HomeassistantLight::write_state(LightState *state) {
const bool transitioned = this->transitioned_;
this->report_held_ = false;
this->transitioned_ = false;
this->report_in_transition_ = false; // this change is newer than any report so far, held or dropped
// Home Assistant changed meanwhile; its report is newer than what the transition ends at or
// what the flash goes back to
if (held) {
@@ -48,8 +48,6 @@ class HomeassistantLight final : public light::LightOutput, public Component {
this->on_report_(field != value);
field = value;
}
/// A state at the resolution Home Assistant gets, to see a transition retargeted by a newer call
static uint32_t state_hash(const light::LightColorValues &values);
/// Schedules apply_imported_() once for the attributes of one update
void schedule_apply_();
void apply_imported_();
@@ -63,14 +61,12 @@ class HomeassistantLight final : public light::LightOutput, public Component {
light::LightTraits traits_{};
Attributes imported_{};
bool apply_scheduled_ : 1 {false};
bool initial_write_ : 1 {true}; // the light core's own first write is not a local change
bool initial_seen_ : 1 {false}; // the light core's setup call has set the initial state
bool echo_pending_ : 1 {false}; // a sent change waits for Home Assistant to report it back
bool report_held_ : 1 {false}; // a report arrived during a transition or flash; applied when it ends
bool transitioned_ : 1 {false}; // a transition or flash ran since the last write sent
bool report_in_transition_ : 1 {false}; // the last changed report, not yet applied, arrived during a transition
bool initial_write_ : 1 {true}; // the light core's own first write is not a local change
bool initial_seen_ : 1 {false}; // the light core's setup call has set the initial state
bool echo_pending_ : 1 {false}; // a sent change waits for Home Assistant to report it back
bool report_held_ : 1 {false}; // a report arrived during a transition or flash; applied when it ends
bool transitioned_ : 1 {false}; // a transition or flash ran since the last write sent
uint8_t report_timeout_s_{10};
uint32_t held_hash_{0}; // state_hash() of what the transition a report was held for ends at
};
} // namespace esphome::homeassistant
@@ -35,7 +35,7 @@ async def to_code(config: ConfigType) -> None:
await cg.add_variable(var.set_ota_parent, config[CONF_OTA_ID])
await cg.add_variable(var.set_request_parent, config[CONF_HTTP_REQUEST_ID])
cg.add(var.set_source_url(config[CONF_SOURCE]))
cg.add(var.set_source_url_static(config[CONF_SOURCE]))
ota.request_ota_state_listeners()
@@ -24,12 +24,6 @@ namespace esphome::http_request {
ESPHOME_LOG_TAG(TAG, "http_request.update");
// Wraps UpdateInfo + error for the task→main-loop handoff.
struct TaskResult {
update::UpdateInfo info;
const LogString *error_str{nullptr};
};
static const size_t MAX_READ_SIZE = 256;
static constexpr uint32_t INITIAL_CHECK_INTERVAL_ID = 0;
static constexpr uint32_t INITIAL_CHECK_INTERVAL_MS = 10000;
@@ -68,6 +62,37 @@ void HttpRequestUpdate::on_ota_state(ota::OTAState state, float progress, uint8_
}
}
void HttpRequestUpdate::set_source_url(const char *source_url, size_t len) {
// Copy before releasing, so a caller passing the current URL back in stays valid.
char *copy = new char[len + 1];
memcpy(copy, source_url, len);
copy[len] = '\0';
this->release_source_url_();
this->source_url_ = copy;
this->source_url_owned_ = true;
}
void HttpRequestUpdate::release_source_url_() {
if (this->source_url_owned_)
delete[] this->source_url_;
this->source_url_owned_ = false;
}
void HttpRequestUpdate::begin_check_(TaskResult *result) {
result->update = this;
result->source_url = this->source_url_;
result->owns_source_url = this->source_url_owned_;
this->source_url_owned_ = false;
}
void HttpRequestUpdate::end_check_(TaskResult *result) {
if (this->source_url_ == result->source_url) {
this->source_url_owned_ = result->owns_source_url;
} else if (result->owns_source_url) {
delete[] result->source_url; // replaced while the check ran
}
}
void HttpRequestUpdate::update() {
if (!network::is_connected()) {
ESP_LOGD(TAG, "Network not connected, skipping update check");
@@ -75,28 +100,37 @@ void HttpRequestUpdate::update() {
}
this->cancel_interval(INITIAL_CHECK_INTERVAL_ID);
#ifdef USE_ESP32
if (this->update_task_handle_ != nullptr) {
if (this->task_result_ != nullptr) {
ESP_LOGW(TAG, "Update check already in progress");
return;
}
xTaskCreate(HttpRequestUpdate::update_task, "update_task", 8192, (void *) this, 1, &this->update_task_handle_);
#endif
// Allocated once per check; every path in the task returns via its single defer, which frees it.
auto *result = new TaskResult();
this->begin_check_(result);
#ifdef USE_ESP32
this->task_result_ = result;
if (xTaskCreate(HttpRequestUpdate::update_task, "update_task", 8192, result, 1, nullptr) != pdPASS) {
this->task_result_ = nullptr;
this->end_check_(result);
delete result;
}
#else
this->update_task(this);
this->update_task(result);
this->end_check_(result);
#endif
}
void HttpRequestUpdate::update_task(void *params) {
HttpRequestUpdate *this_update = (HttpRequestUpdate *) params;
// Allocate once — every path below returns via the single defer at the end.
// On failure, error_str is set; on success it is nullptr.
auto *result = new TaskResult();
auto *result = static_cast<TaskResult *>(params);
HttpRequestUpdate *this_update = result->update;
auto *info = &result->info;
const char *source_url = result->source_url;
auto container = this_update->request_parent_->get(this_update->source_url_);
auto container = this_update->request_parent_->get(source_url);
if (container == nullptr || container->status_code != HTTP_STATUS_OK) {
ESP_LOGE(TAG, "Failed to fetch manifest from %s", this_update->source_url_);
ESP_LOGE(TAG, "Failed to fetch manifest from %s", source_url);
if (container != nullptr)
container->end();
result->error_str = LOG_STR("Failed to fetch manifest");
@@ -176,14 +210,14 @@ void HttpRequestUpdate::update_task(void *params) {
allocator.deallocate(data, content_length);
if (!valid) {
ESP_LOGE(TAG, "Failed to parse JSON from %s", this_update->source_url_);
ESP_LOGE(TAG, "Failed to parse JSON from %s", source_url);
result->error_str = LOG_STR("Failed to parse manifest JSON");
goto defer; // NOLINT(cppcoreguidelines-avoid-goto)
}
// Merge source_url_ and firmware_url
if (!info->firmware_url.empty() && info->firmware_url.find("http") == std::string::npos) {
const char *source = this_update->source_url_;
const char *source = source_url;
const size_t source_len = strlen(source);
size_t prefix_len;
if (info->firmware_url[0] == '/') {
@@ -215,7 +249,8 @@ defer:
// Lambda captures only 2 pointers (8 bytes) — fits in std::function SBO on supported toolchains.
this_update->defer([this_update, result]() {
#ifdef USE_ESP32
this_update->update_task_handle_ = nullptr;
this_update->task_result_ = nullptr;
this_update->end_check_(result);
#endif
if (result->error_str != nullptr) {
this_update->status_set_error(result->error_str);
@@ -13,6 +13,21 @@
namespace esphome::http_request {
namespace testing {
class HttpRequestUpdateTest;
} // namespace testing
class HttpRequestUpdate;
// One manifest check: handed from update() to the task and back to the main loop.
struct TaskResult {
HttpRequestUpdate *update{nullptr};
const char *source_url{nullptr}; // the URL this check reads, fixed before it starts
update::UpdateInfo info;
const LogString *error_str{nullptr}; // set on failure, nullptr on success
bool owns_source_url{false}; // the check holds the buffer until it ends, see begin_check_()
};
class HttpRequestUpdate final : public update::UpdateEntity, public PollingComponent, public ota::OTAStateListener {
public:
void setup() override;
@@ -21,7 +36,18 @@ class HttpRequestUpdate final : public update::UpdateEntity, public PollingCompo
void perform(bool force) override;
void check() override { this->update(); }
void set_source_url(const char *source_url) { this->source_url_ = source_url; }
~HttpRequestUpdate() override { this->release_source_url_(); }
/// Codegen only: the config literal lives in flash for the life of the program, so it is kept by pointer.
void set_source_url_static(const char *source_url) {
this->release_source_url_();
this->source_url_ = source_url;
}
/// Set the manifest URL at run time; the string is copied, so any storage may be passed.
void set_source_url(const char *source_url) { this->set_source_url(source_url, strlen(source_url)); }
void set_source_url(const std::string &source_url) { this->set_source_url(source_url.data(), source_url.size()); }
void set_source_url(const char *source_url, size_t len);
const char *get_source_url() const { return this->source_url_; }
void set_request_parent(HttpRequestComponent *request_parent) { this->request_parent_ = request_parent; }
void set_ota_parent(OtaHttpRequestComponent *ota_parent) { this->ota_parent_ = ota_parent; }
@@ -36,12 +62,21 @@ class HttpRequestUpdate final : public update::UpdateEntity, public PollingCompo
static void update_task(void *params);
#ifdef USE_ESP32
TaskHandle_t update_task_handle_{nullptr};
TaskResult *task_result_{nullptr}; // set while a check runs on the update task
#endif
uint8_t initial_check_remaining_{0};
private:
const char *source_url_{nullptr}; // literal from codegen
friend class testing::HttpRequestUpdateTest;
void release_source_url_();
// A check reads the URL it started with until it ends, on the update task or inline through an
// automation it triggers. Ownership of that buffer moves to the check, so a set_source_url in the
// meantime cannot free it; end_check_() hands it back or frees it on the main loop.
void begin_check_(TaskResult *result);
void end_check_(TaskResult *result);
bool source_url_owned_{false}; // source_url_ was copied by a run-time setter; sits in the padding above
const char *source_url_{nullptr};
};
} // namespace esphome::http_request
@@ -0,0 +1 @@
DOMAIN = "islandaire"
+27
View File
@@ -0,0 +1,27 @@
import esphome.codegen as cg
from esphome.components import climate_ir
from esphome.components.climate_ir import CONF_SUPPORTS_HEAT_COOL
import esphome.config_validation as cv
from esphome.const import CONF_SENSOR
from esphome.types import ConfigType
AUTO_LOAD = ["climate_ir"]
CODEOWNERS = ["@ipopov"]
islandaire_ns = cg.esphome_ns.namespace("islandaire")
IslandaireClimate = islandaire_ns.class_("IslandaireClimate", climate_ir.ClimateIR)
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(IslandaireClimate).extend(
{
# The unit has no auto (heat/cool) mode
cv.Optional(CONF_SUPPORTS_HEAT_COOL, default=False): cv.boolean_false,
# The unit works in Fahrenheit, so a Celsius temperature sensor cannot feed it
cv.Optional(CONF_SENSOR): cv.invalid(
"'sensor' is not supported by the Islandaire climate component"
),
}
)
async def to_code(config: ConfigType) -> None:
await climate_ir.new_climate_ir(config)
@@ -0,0 +1,199 @@
#include "islandaire.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cmath>
namespace esphome::islandaire {
ESPHOME_LOG_TAG(TAG, "islandaire.climate");
// 14-byte frame, LSB first:
// Bytes 0-4: 0x22 preamble
// Byte 5: power (0x20 off, 0x24 on); bit 3 (0x08) set while the auto-off timer is active
// Byte 6: mode (0x01 heat, 0x03 cool, 0x04 fan only)
// Byte 7: target temperature in °F (61-88)
// Byte 8: fan speed (0x00 auto, 0x02 low, 0x04 high, 0x01 read as low too); bit 6 (0x40) set while the
// timer is active
// Byte 9: 0x00
// Byte 10: auto-off timer in hours (1-12, 0 = off)
// Bytes 11-12: 0x00
// Byte 13: sum of bytes 0-12
// The auto-off timer is not implemented: the unit switches itself off with no feedback, which
// would leave the climate state stale.
static constexpr uint8_t STATE_LENGTH = 14;
static constexpr uint8_t PREAMBLE_LENGTH = 5;
static constexpr uint8_t PREAMBLE = 0x22;
static constexpr uint8_t POWER_OFF = 0x20;
static constexpr uint8_t POWER_MASK = 0x04;
static constexpr uint8_t POWER_ON = POWER_OFF | POWER_MASK;
static constexpr uint8_t MODE_HEAT = 0x01;
static constexpr uint8_t MODE_COOL = 0x03;
static constexpr uint8_t MODE_FAN = 0x04;
static constexpr uint8_t FAN_AUTO = 0x00;
static constexpr uint8_t FAN_LOW_ALT = 0x01;
static constexpr uint8_t FAN_LOW = 0x02;
static constexpr uint8_t FAN_HIGH = 0x04;
static constexpr uint8_t FAN_TIMER_FLAG = 0x40;
static constexpr uint8_t TEMP_DEFAULT = 72;
static constexpr uint16_t HEADER_MARK = 3608;
static constexpr uint16_t HEADER_SPACE = 1520;
static constexpr uint16_t BIT_MARK = 560;
static constexpr uint16_t ONE_SPACE = 1208;
static constexpr uint16_t ZERO_SPACE = 408;
static constexpr uint32_t CARRIER_FREQUENCY = 38000;
static constexpr remote_base::BitTimings BIT_TIMINGS{
.bit_mark = BIT_MARK, .one_space = ONE_SPACE, .zero_space = ZERO_SPACE};
void IslandaireClimate::setup() {
ClimateIR::setup();
// The base class default assumes Celsius
if (std::isnan(this->target_temperature) || this->target_temperature < TEMP_MIN)
this->target_temperature = TEMP_DEFAULT;
this->target_temperature = std::min<float>(this->target_temperature, TEMP_MAX);
}
void IslandaireClimate::dump_config() {
// ClimateIR::dump_config() is not called: it prints the temperature range in Celsius
LOG_CLIMATE("", "Islandaire Climate", this);
ESP_LOGCONFIG(TAG, " Temperature range: %u to %u °F", TEMP_MIN, TEMP_MAX);
}
climate::ClimateTraits IslandaireClimate::traits() {
auto traits = ClimateIR::traits();
traits.set_temperature_unit(TemperatureUnit::FAHRENHEIT);
return traits;
}
void IslandaireClimate::transmit_state() {
uint8_t remote_state[STATE_LENGTH] = {0};
std::fill_n(remote_state, PREAMBLE_LENGTH, PREAMBLE);
remote_state[5] = this->mode == climate::CLIMATE_MODE_OFF ? POWER_OFF : POWER_ON;
switch (this->mode) {
case climate::CLIMATE_MODE_HEAT:
remote_state[6] = MODE_HEAT;
break;
case climate::CLIMATE_MODE_FAN_ONLY:
remote_state[6] = MODE_FAN;
break;
case climate::CLIMATE_MODE_COOL:
default:
remote_state[6] = MODE_COOL;
break;
}
// Whole degrees within the unit's range, stored as well so the published state matches what was sent
const auto temperature =
static_cast<uint8_t>(std::clamp<float>(std::round(this->target_temperature), TEMP_MIN, TEMP_MAX));
this->target_temperature = temperature;
remote_state[7] = temperature;
switch (this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)) {
case climate::CLIMATE_FAN_LOW:
remote_state[8] = FAN_LOW;
break;
case climate::CLIMATE_FAN_HIGH:
remote_state[8] = FAN_HIGH;
break;
case climate::CLIMATE_FAN_AUTO:
default:
remote_state[8] = FAN_AUTO;
break;
}
remote_state[STATE_LENGTH - 1] = sum_bytes(remote_state, STATE_LENGTH - 1);
ESP_LOGV_HEX(TAG, remote_state, STATE_LENGTH, STATE_LENGTH, "Sending: %s");
auto transmit = this->transmitter_->transmit();
auto *data = transmit.get_data();
data->set_carrier_frequency(CARRIER_FREQUENCY);
data->reserve(2 + STATE_LENGTH * 16 + 1);
data->item(HEADER_MARK, HEADER_SPACE);
data->item_bytes<BIT_TIMINGS>(remote_state, STATE_LENGTH);
data->mark(BIT_MARK);
transmit.perform();
}
bool IslandaireClimate::on_receive(remote_base::RemoteReceiveData data) {
if (!data.expect_item(HEADER_MARK, HEADER_SPACE)) {
ESP_LOGVV(TAG, "Header fail");
return false;
}
uint8_t remote_state[STATE_LENGTH];
if (!data.expect_bytes<BIT_TIMINGS>(remote_state, STATE_LENGTH)) {
ESP_LOGVV(TAG, "Data fail");
return false;
}
if (!data.expect_mark(BIT_MARK)) {
ESP_LOGVV(TAG, "Footer fail");
return false;
}
if (sum_bytes(remote_state, STATE_LENGTH - 1) != remote_state[STATE_LENGTH - 1]) {
ESP_LOGVV(TAG, "Checksum fail");
return false;
}
for (uint8_t i = 0; i < PREAMBLE_LENGTH; i++) {
if (remote_state[i] != PREAMBLE)
return false;
}
ESP_LOGV_HEX(TAG, remote_state, STATE_LENGTH, STATE_LENGTH, "Received: %s");
if ((remote_state[5] & POWER_MASK) == 0) {
this->mode = climate::CLIMATE_MODE_OFF;
} else {
switch (remote_state[6]) {
case MODE_HEAT:
this->mode = climate::CLIMATE_MODE_HEAT;
break;
case MODE_COOL:
this->mode = climate::CLIMATE_MODE_COOL;
break;
case MODE_FAN:
this->mode = climate::CLIMATE_MODE_FAN_ONLY;
break;
default:
ESP_LOGV(TAG, "Unknown mode 0x%02X", remote_state[6]);
return false;
}
}
const uint8_t temp_f = remote_state[7];
if (temp_f >= TEMP_MIN && temp_f <= TEMP_MAX)
this->target_temperature = temp_f;
switch (remote_state[8] & ~FAN_TIMER_FLAG) {
case FAN_HIGH:
this->fan_mode = climate::CLIMATE_FAN_HIGH;
break;
case FAN_LOW:
case FAN_LOW_ALT:
this->fan_mode = climate::CLIMATE_FAN_LOW;
break;
case FAN_AUTO:
default:
this->fan_mode = climate::CLIMATE_FAN_AUTO;
break;
}
this->publish_state();
return true;
}
} // namespace esphome::islandaire
@@ -0,0 +1,25 @@
#pragma once
#include "esphome/components/climate_ir/climate_ir.h"
namespace esphome::islandaire {
class IslandaireClimate : public climate_ir::ClimateIR {
public:
// The unit works in whole degrees Fahrenheit (see traits())
static constexpr uint8_t TEMP_MIN = 61;
static constexpr uint8_t TEMP_MAX = 88;
IslandaireClimate()
: ClimateIR(TEMP_MIN, TEMP_MAX, 1.0f, false, true,
{climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_HIGH}, {}) {}
protected:
void setup() override;
void dump_config() override;
climate::ClimateTraits traits() override;
void transmit_state() override;
bool on_receive(remote_base::RemoteReceiveData data) override;
};
} // namespace esphome::islandaire
+6 -15
View File
@@ -285,12 +285,8 @@ void LD6002BComponent::setup() {
#ifdef USE_SWITCH
if (this->target_display_switch_ != nullptr) {
target_display_controlled = true;
// Nothing reports this switch back, so its restored state is the only state
// there is. Restoring through the switch keeps its inversion in the path:
// the restored value is logical, and driving the switch is what turns it
// into the raw command, the published state and the stream flag.
const bool state = this->target_display_switch_->get_initial_state_with_restore_mode().value_or(true);
this->target_display_switch_->control(state);
// Nothing reports this switch back, so its restored state is the only state there is.
this->target_display_switch_->restore(true);
}
#endif
if (!target_display_controlled) {
@@ -307,10 +303,7 @@ void LD6002BComponent::setup() {
#ifdef USE_SWITCH
if (this->point_cloud_switch_ != nullptr) {
point_cloud_controlled = true;
// The switch owns the stream, so it is also what applies the restored state:
// driving it rather than the module keeps the entity's inversion in the path.
const bool state = this->point_cloud_switch_->get_initial_state_with_restore_mode().value_or(false);
this->point_cloud_switch_->control(state);
this->point_cloud_switch_->restore(false);
}
#endif
if (!point_cloud_controlled) {
@@ -349,11 +342,9 @@ void LD6002BComponent::setup() {
#ifdef USE_SWITCH
bool want_low_power = this->low_power_switch_ != nullptr;
if (want_low_power) {
// The module reports this one back, so the query below confirms what it took.
// Driving the switch applies its inversion; it also marks the restored value
// as reported, so the work mode fallback runs on that until the query lands.
const bool state = this->low_power_switch_->get_initial_state_with_restore_mode().value_or(false);
this->low_power_switch_->control(state);
// The query below confirms what the module took; until it lands, the work mode
// fallback runs on the restored value.
this->low_power_switch_->restore(false);
}
#else
bool want_low_power = false;
+3 -1
View File
@@ -169,7 +169,9 @@ void LEDCOutput::setup() {
#if !defined(CLANG_TIDY)
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 0)
PERIPH_RCC_ATOMIC() { ledc_ll_reset_register(0); }
#elif ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
#elif ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) || \
(ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) && !defined(__PERIPH_CTRL_ALLOW_LEGACY_API))
// Also on the targets whose periph_module_reset() is an empty stub in ESP-IDF 5.x (P4, C5, C61, H4)
PERIPH_RCC_ATOMIC() {
ledc_ll_enable_reset_reg(true);
ledc_ll_enable_reset_reg(false);
+81 -8
View File
@@ -13,6 +13,7 @@ from esphome.const import (
CONF_COLD_WHITE_COLOR_TEMPERATURE,
CONF_COLOR_CORRECT,
CONF_DEFAULT_TRANSITION_LENGTH,
CONF_EFFECT,
CONF_EFFECTS,
CONF_ENTITY_CATEGORY,
CONF_FLASH_TRANSITION_LENGTH,
@@ -31,12 +32,21 @@ from esphome.const import (
CONF_RESTORE_MODE,
CONF_RESTORE_STATE,
CONF_RGB_ORDER,
CONF_STATE,
CONF_TRIGGER_ID,
CONF_WARM_WHITE_COLOR_TEMPERATURE,
CONF_WEB_SERVER,
)
from esphome.core import CORE, ID, CoroPriority, HexInt, coroutine_with_priority
from esphome.core import (
CORE,
ID,
CoroPriority,
EsphomeError,
HexInt,
coroutine_with_priority,
)
from esphome.core.entity_helpers import (
RESTORE_STATE_NONE,
entity_duplicate_validator,
queue_entity_register,
setup_entity,
@@ -56,7 +66,6 @@ from .effects import (
)
from .restore_state import (
LEGACY_RESTORE_MODES,
RESTORE_STATE_NONE,
RESTORE_STATE_SCHEMA,
_build_state_lambda,
_initial_state_overridden_by_legacy_mode,
@@ -191,9 +200,11 @@ def _get_or_create_gamma_table(gamma_correct: float, table_id: ID) -> cg.RawExpr
def find_effect_index(effects: list, effect_name: str) -> int | None:
"""Find the 1-based index of an effect by name (case-insensitive).
Returns the 1-based index if found, or None if not found.
Returns 0 for "None" (no effect), the 1-based index if found, or None if not found.
"""
effect_name_lower = effect_name.lower()
if effect_name_lower == "none":
return 0
for i, effect_conf in enumerate(effects):
key = next(iter(effect_conf))
if effect_conf[key][CONF_NAME].lower() == effect_name_lower:
@@ -209,6 +220,14 @@ def available_effects_str(effects: list) -> str:
return ", ".join(f"'{name}'" for name in available) if available else "none"
def effect_not_found_message(effect_name: str, effects: list, light: str) -> str:
"""Return the error for an effect name that `light` (e.g. "this light") lacks."""
return (
f"Effect '{effect_name}' not found for {light}. "
f"Available effects: {available_effects_str(effects)}"
)
# Accepted values of the deprecated `rgb_order` key.
RGB_ORDERS = ("RGB", "RBG", "GRB", "GBR", "BGR", "BRG")
@@ -316,9 +335,12 @@ def _final_validate(config: ConfigType) -> None:
platform entries across the file, not a single light's own config.
"""
for light_config in config:
initial_state = light_config.get(CONF_INITIAL_STATE) or {}
cold_boot_state = initial_state.get(CONF_STATE, False)
restore_mode = light_config.get(CONF_RESTORE_MODE)
if restore_mode is not None:
legacy = LEGACY_RESTORE_MODES[restore_mode]
cold_boot_state = legacy.cold_boot_state
if _initial_state_overridden_by_legacy_mode(
legacy, light_config.get(CONF_INITIAL_STATE)
):
@@ -330,6 +352,19 @@ def _final_validate(config: ConfigType) -> None:
restore_mode,
"ON" if legacy.cold_boot_state else "OFF",
)
effect_name = initial_state.get(CONF_EFFECT, "none")
if (
effect_name.lower() != "none"
and not cold_boot_state
and not light_config.get(CONF_RESUME_EFFECT)
):
_LOGGER.warning(
"[%s] 'initial_state: effect: %s' does not start at boot because the "
"light boots OFF; add 'resume_effect: true' to start it at the first "
"turn-on",
light_config.get(CONF_NAME) or light_config[CONF_ID],
effect_name,
)
data = _get_data()
if not data.effect_refs and not data.effect_cycle_refs:
@@ -355,9 +390,9 @@ def _final_validate(config: ConfigType) -> None:
if find_effect_index(effects, ref.effect_name) is None:
raise cv.FinalExternalInvalid(
f"Effect '{ref.effect_name}' not found for light "
f"'{ref.light_id}'. "
f"Available effects: {available_effects_str(effects)}",
effect_not_found_message(
ref.effect_name, effects, f"light '{ref.light_id}'"
),
path=[cv.ROOT_CONFIG_PATH] + ref.component_path,
)
@@ -385,6 +420,13 @@ FINAL_VALIDATE_SCHEMA = _final_validate
DEFAULT_FLASH_TRANSITION_LENGTH = "0s"
CONF_TRANSITION_STATE_PUBLISH_INTERVAL = "transition_state_publish_interval"
# `effect` stays out of LIGHT_STATE_SCHEMA: light.control defines it templatable and transformer-exclusive
INITIAL_STATE_SCHEMA = LIGHT_STATE_SCHEMA.extend(
{
cv.Optional(CONF_EFFECT): cv.string,
}
)
LIGHT_SCHEMA = (
cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA)
.extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA)
@@ -414,7 +456,7 @@ LIGHT_SCHEMA = (
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(LightStateTrigger),
}
),
cv.Optional(CONF_INITIAL_STATE): LIGHT_STATE_SCHEMA,
cv.Optional(CONF_INITIAL_STATE): INITIAL_STATE_SCHEMA,
cv.Optional(CONF_RESUME_EFFECT, default=False): cv.boolean,
}
)
@@ -422,6 +464,35 @@ LIGHT_SCHEMA = (
LIGHT_SCHEMA.add_extra(entity_duplicate_validator("light"))
def _validate_initial_state_effect(config: ConfigType) -> ConfigType:
"""Check that `initial_state: effect` names an effect of this light."""
initial_state = config.get(CONF_INITIAL_STATE) or {}
if (effect_name := initial_state.get(CONF_EFFECT)) is not None:
effects = config.get(CONF_EFFECTS, [])
if find_effect_index(effects, effect_name) is None:
raise cv.Invalid(
effect_not_found_message(effect_name, effects, "this light"),
path=[CONF_INITIAL_STATE, CONF_EFFECT],
)
return config
LIGHT_SCHEMA.add_extra(_validate_initial_state_effect)
def _initial_effect_index(config: ConfigType) -> int:
"""Resolve `initial_state: effect` to its effect index, 0 when not set."""
effect_name = (config.get(CONF_INITIAL_STATE) or {}).get(CONF_EFFECT, "none")
effects = config.get(CONF_EFFECTS, [])
if (index := find_effect_index(effects, effect_name)) is not None:
return index
# Safety net for a light whose schema skipped _validate_initial_state_effect
raise EsphomeError(
effect_not_found_message(effect_name, effects, f"light '{config[CONF_ID]}'")
)
BINARY_LIGHT_SCHEMA = LIGHT_SCHEMA.extend(
{
cv.Optional(CONF_EFFECTS): validate_effects(BINARY_EFFECTS),
@@ -557,7 +628,9 @@ async def setup_light_core_(light_var, config, output_var):
# ways to build the same state callback and save_enabled flag that LightState's
# runtime actually understands.
initial_state_config = config.get(CONF_INITIAL_STATE)
initial_statements = await _initial_state_statements(initial_state_config)
initial_statements = await _initial_state_statements(
initial_state_config, _initial_effect_index(config)
)
if config[CONF_RESUME_EFFECT]:
cg.add_define("USE_LIGHT_RESUME_EFFECT")
cg.add(light_var.set_resume_effect(True))
+2 -5
View File
@@ -250,10 +250,8 @@ def _resolve_effect_index(config: ConfigType, original_name: str) -> int:
Effect index 0 means "None" (no effect). Effects are 1-indexed matching
the C++ convention in LightState.
"""
from . import available_effects_str, find_effect_index
from . import effect_not_found_message, find_effect_index
if original_name.lower() == "none":
return 0
light_id = config[CONF_ID]
light_path = CORE.config.get_path_for_id(light_id)[:-1]
light_config = CORE.config.get_config_for_path(light_path)
@@ -264,8 +262,7 @@ def _resolve_effect_index(config: ConfigType, original_name: str) -> int:
# Should never reach here — effect names are validated during config
# validation in FINAL_VALIDATE_SCHEMA. This is a safety net.
raise EsphomeError(
f"Effect '{original_name}' not found for light '{light_id}'. "
f"Available effects: {available_effects_str(effects)}"
effect_not_found_message(original_name, effects, f"light '{light_id}'")
)
+2
View File
@@ -76,6 +76,8 @@ static void log_percent(const LogString *param, float value) {
void LightCall::perform() {
const char *name = LOG_STR_ARG(this->parent_->get_log_name());
LightColorValues v = this->validate_();
// Any call counts as newer, even one that changes nothing
this->parent_->called_ = true;
const bool publish = this->get_publish_();
if (publish) {
+3
View File
@@ -1,6 +1,7 @@
#pragma once
#include "light_color_values.h"
#include "esphome/core/string_ref.h"
namespace esphome {
@@ -133,6 +134,8 @@ class LightCall {
LightCall &set_effect(const std::string &effect) { return this->set_effect(effect.data(), effect.size()); }
/// Set the effect of the light by its name (const char * overload to resolve ambiguity).
LightCall &set_effect(const char *effect) { return this->set_effect(effect, strlen(effect)); }
/// Set the effect of the light by its name (StringRef overload to resolve ambiguity, zero-copy).
LightCall &set_effect(StringRef effect) { return this->set_effect(effect.c_str(), effect.size()); }
/// Set the effect of the light by its name and length (zero-copy from API).
LightCall &set_effect(const char *effect, size_t len);
/// Set the effect of the light by its internal index number (only for internal use).
+5 -1
View File
@@ -83,9 +83,13 @@ void LightState::setup() {
call.set_color_temperature_if_supported(recovered.color_temp);
call.set_cold_white_if_supported(recovered.cold_white);
call.set_warm_white_if_supported(recovered.warm_white);
if (recovered.effect != 0) {
if (recovered.state && recovered.effect != 0) {
call.set_effect(recovered.effect);
} else {
#ifdef USE_LIGHT_RESUME_EFFECT
// An off light cannot start its effect now; keep it for the first turn-on instead
this->previous_effect_index_ = recovered.effect;
#endif // USE_LIGHT_RESUME_EFFECT
call.set_transition_length_if_supported(0);
}
call.perform();
+6
View File
@@ -90,6 +90,11 @@ class LightState : public EntityBase, public Component {
/// True from the call that starts a transition or flash until it reaches its target.
bool is_transitioning() const { return this->transformer_ != nullptr; }
/// True once a call is performed after clear_called(), so an output can tell whether a call came
/// after something it noted. Every call that changes the light reaches update_state() within a loop pass.
bool called_since_clear() const { return this->called_; }
void clear_called() { this->called_ = false; }
LightCall make_call();
/// Take over the state of a light mirrored from elsewhere: current and target values at once,
@@ -416,6 +421,7 @@ class LightState : public EntityBase, public Component {
bool next_write_{true}; // a plain bool: it is the most written flag, and still shares the index's word
// for effects, true if a transformer (transition) is active.
bool is_transformer_active_ : 1 {false};
bool called_ : 1 {false}; // see called_since_clear()
/// Whether this light persists its state to preferences at all.
bool save_enabled_ : 1 {false};
#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL
+34 -33
View File
@@ -11,6 +11,14 @@ import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_COLOR_MODE, CONF_STATE
from esphome.core import Lambda
from esphome.core.entity_helpers import (
RESTORE_STATE_INITIAL,
RESTORE_STATE_INVERT,
RESTORE_STATE_KEEP,
RESTORE_STATE_NONE,
restore_state_keyword,
restore_state_schema,
)
from esphome.cpp_generator import call_lambda
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType
@@ -18,12 +26,6 @@ from esphome.types import ConfigType
from .automation import LIGHT_STATE_FIELDS, validate_light_state
from .types import ColorMode, LightStateRTCState
RESTORE_STATE_KEEP = "KEEP"
RESTORE_STATE_INVERT = "INVERT"
RESTORE_STATE_INITIAL = "INITIAL"
RESTORE_STATE_ALL = "ALL"
RESTORE_STATE_NONE = "NONE"
@dataclass(frozen=True)
class LegacyRestoreMode:
@@ -56,7 +58,11 @@ _STATE_STRUCT_FIELDS: tuple[tuple[str, str], ...] = tuple(
)
# Canonical struct-member order, used only to make generated code deterministic --
# these are independent field assignments, so the actual order never affects behavior.
_MEMBER_ORDER: tuple[str, ...] = tuple(member for _, member in _ALL_STATE_FIELDS)
_MEMBER_ORDER: tuple[str, ...] = (
*(member for _, member in _ALL_STATE_FIELDS),
# Not a LightStateField: its value is a name looked up in this light's effects: list
"effect",
)
# A pending `s.<member> = <value>;` statement, tagged with the member it writes.
StateStatement = tuple[str, str]
@@ -170,12 +176,13 @@ async def _process_value(value: Any, member: str) -> str:
async def _initial_state_statements(
initial_state_config: ConfigType | None,
initial_state_config: ConfigType | None, effect_index: int = 0
) -> list[StateStatement]:
"""
Create assignments for every field the user set in `initial_state:`, in canonical
struct-member order. A field given as `!lambda` is resolved and called immediately,
the same way `light.control`'s own field lambdas are.
the same way `light.control`'s own field lambdas are. `effect_index` is the resolved
`initial_state: effect`; 0 (no effect) is the struct default and emits nothing.
"""
if not initial_state_config:
return []
@@ -188,6 +195,8 @@ async def _initial_state_statements(
)
if (inferred := _inferred_color_mode(initial_state_config)) is not None:
statements.append(("color_mode", f"s.color_mode = {inferred};"))
if effect_index:
statements.append(("effect", f"s.effect = {effect_index};"))
return statements
@@ -290,10 +299,12 @@ def _keep_or(validator: Callable[[Any], Any]) -> Callable[[Any], Any]:
except cv.Invalid:
inner = ()
return (RESTORE_STATE_KEEP, RESTORE_STATE_INITIAL, *inner)
if isinstance(value, str):
upper = value.strip().upper()
if upper in (RESTORE_STATE_KEEP, RESTORE_STATE_INITIAL):
return upper
if (
keyword := restore_state_keyword(
value, (RESTORE_STATE_KEEP, RESTORE_STATE_INITIAL)
)
) is not None:
return keyword
return validator(value)
return validate
@@ -308,10 +319,12 @@ def _validate_restore_state_state(value: Any) -> str | bool:
RESTORE_STATE_INITIAL,
*validate_light_state(SCHEMA_EXTRACT),
)
if isinstance(value, str):
upper = value.strip().upper()
if upper in (RESTORE_STATE_KEEP, RESTORE_STATE_INVERT, RESTORE_STATE_INITIAL):
return upper
if (
keyword := restore_state_keyword(
value, (RESTORE_STATE_KEEP, RESTORE_STATE_INVERT, RESTORE_STATE_INITIAL)
)
) is not None:
return keyword
return validate_light_state(value)
@@ -327,19 +340,7 @@ _RESTORE_STATE_FIELDS_SCHEMA = cv.Schema(
)
@schema_extractor("schema")
def RESTORE_STATE_SCHEMA(value: Any) -> ConfigType | str:
"""
The restore_state: config key can be a mapping of per-field overrides, `all`, or `none`.
"""
if value == SCHEMA_EXTRACT:
# The `all`/`none` string shorthands have no representation in the extracted
# docs schema; only the per-field mapping form is walked here.
return _RESTORE_STATE_FIELDS_SCHEMA
if isinstance(value, str):
upper = value.strip().upper()
if upper == RESTORE_STATE_ALL:
value = {}
elif upper == RESTORE_STATE_NONE:
return RESTORE_STATE_NONE
return _RESTORE_STATE_FIELDS_SCHEMA(value)
# A mapping of per-field overrides, `all`, or `none`, which stays the keyword for to_code
RESTORE_STATE_SCHEMA = restore_state_schema(
_RESTORE_STATE_FIELDS_SCHEMA, RESTORE_STATE_NONE
)
@@ -0,0 +1 @@
DOMAIN = "ln882x_spi_led_strip"
@@ -0,0 +1,210 @@
#include "led_strip.h"
#ifdef USE_LN882X
#include <cinttypes>
#include <cstring>
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
extern "C" {
// Skip the SDK register headers: their unused *_unpack inline functions copy volatile unions, which C++ rejects.
// Same as LibreTiny's sdk_private.h; the HAL headers below use nothing from them.
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp)
#define __REG_DMA_H__
#define __REG_GPIO_H__
#define __REG_SPI_H__
#define __REG_SYSC_AWO_H__
#define __REG_SYSC_CMP_H__
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp)
#include "hal/hal_clock.h"
#include "hal/hal_dma.h"
#include "hal/hal_gpio.h"
#include "hal/hal_spi.h"
}
namespace esphome::ln882x_spi_led_strip {
ESPHOME_LOG_TAG(TAG, "ln882x_spi_led_strip");
// light.py derives the SPI encoding from this APB0 clock, which the SDK's SystemInit() sets.
static constexpr uint32_t APB0_HZ = 40000000;
// Each LED data bit is sent as one SPI byte, MSB first: the leading 1-bits are the high time.
static constexpr uint8_t SPI_BITS_PER_LED_BIT = 8;
// SPI0 TX is hard-wired to DMA channel 4 (see hal_dma.h).
static constexpr uint32_t LED_STRIP_DMA_CH = DMA_CH_4;
static uint8_t spi_pattern(uint8_t high_bits) { return uint8_t(0xFF00 >> high_bits); }
static void spi_dma_init(uint8_t pin, uint8_t prescaler) {
// LibreTiny numbers pins as (port << 4) | index. Every pin the LN882x boards expose
// (PA0-PA12, PB3-PB9) can be muxed to SPI0 MOSI.
const uint32_t gpio_base = (pin >> 4) ? GPIOB_BASE : GPIOA_BASE;
const auto gpio_pin = static_cast<gpio_pin_t>(1U << (pin & 0xF));
hal_gpio_pin_afio_select(gpio_base, gpio_pin, SPI0_MOSI);
hal_gpio_pin_afio_en(gpio_base, gpio_pin, HAL_ENABLE);
// Only MOSI is routed to a pin; NSS is left unrouted with its output disabled.
spi_init_type_def spi_init{};
spi_init.spi_direction = SPI_DIRECTION_2LINES_FULLDUPLEX;
spi_init.spi_mode = SPI_MODE_MASTER;
spi_init.spi_data_size = SPI_DATASIZE_8B;
spi_init.spi_cpol = SPI_CPOL_LOW;
spi_init.spi_cpha = SPI_CPHA_1EDGE;
spi_init.spi_nss_mode = SPI_NSS_HARD;
spi_init.spi_baud_rate_prescaler = static_cast<spi_baud_rate_precaler_t>(prescaler);
spi_init.spi_first_bit = SPI_FIRST_BIT_MSB;
hal_spi_init(SPI0_BASE, &spi_init);
hal_spi_en(SPI0_BASE, HAL_ENABLE);
hal_spi_ssoe_en(SPI0_BASE, HAL_DISABLE);
dma_init_t_def dma_init{};
dma_init.dma_mem_to_mem_en = DMA_MEM_TO_MEM_DIS;
dma_init.dma_pri_lev = DMA_PRI_LEV_HIGH;
dma_init.dma_mem_size = DMA_MEM_SIZE_8_BIT;
dma_init.dma_p_size = DMA_P_SIZE_8_BIT;
dma_init.dma_mem_inc_en = DMA_MEM_INC_EN;
dma_init.dma_p_inc_en = DMA_P_INC_DIS;
dma_init.dma_circ_mode_en = DMA_CIRC_MODE_DIS;
dma_init.dma_dir = DMA_READ_FORM_MEM;
dma_init.dma_p_addr = SPI0_DATA_REG;
hal_dma_init(LED_STRIP_DMA_CH, &dma_init);
hal_dma_en(LED_STRIP_DMA_CH, HAL_DISABLE);
}
static void spi_dma_transmit(const uint8_t *data, uint16_t len) {
hal_dma_en(LED_STRIP_DMA_CH, HAL_DISABLE);
hal_spi_dma_en(SPI0_BASE, SPI_DMA_TX_EN, HAL_DISABLE);
hal_dma_set_mem_addr(LED_STRIP_DMA_CH, reinterpret_cast<uint32_t>(data));
hal_dma_set_data_num(LED_STRIP_DMA_CH, len);
hal_spi_dma_en(SPI0_BASE, SPI_DMA_TX_EN, HAL_ENABLE);
hal_dma_en(LED_STRIP_DMA_CH, HAL_ENABLE);
}
// The count reaches zero once the last byte is in the SPI data register. The tail of every
// transfer is the reset gap, so starting the next one while that byte shifts out is harmless.
static bool spi_dma_busy() { return hal_dma_get_data_num(LED_STRIP_DMA_CH) != 0; }
LN882xSPILEDStripLightOutput::LN882xSPILEDStripLightOutput(uint8_t pin, uint16_t num_leds,
light::ChannelColors channel_colors, uint8_t prescaler,
uint8_t bit0_high_bits, uint8_t bit1_high_bits,
uint16_t reset_bytes)
: color_data_bytes_(num_leds * channel_colors.bytes_per_led()),
// The tail of the DMA buffer stays zero: it is the reset gap.
dma_data_bytes_(color_data_bytes_ * SPI_BITS_PER_LED_BIT + reset_bytes),
channel_colors_(channel_colors),
num_leds_(num_leds),
pin_(pin),
prescaler_(prescaler),
bit0_high_bits_(bit0_high_bits),
bit1_high_bits_(bit1_high_bits) {}
void LN882xSPILEDStripLightOutput::setup() {
const uint32_t apb0_hz = hal_clock_get_apb0_clk();
if (apb0_hz != APB0_HZ) {
ESP_LOGE(TAG, "APB0 clock is %" PRIu32 " Hz", apb0_hz);
this->mark_failed(LOG_STR("APB0 clock is not 40 MHz"));
return;
}
// One block for the color, effect and DMA data.
RAMAllocator<uint8_t> allocator;
uint8_t *buffer = allocator.allocate(this->color_data_bytes_ + this->num_leds_ + this->dma_data_bytes_);
if (buffer == nullptr) {
this->mark_failed(LOG_STR("Allocation error"));
return;
}
memset(buffer, 0, this->color_data_bytes_ + this->num_leds_ + this->dma_data_bytes_);
this->color_data_ = buffer;
this->effect_data_ = buffer + this->color_data_bytes_;
this->dma_data_ = this->effect_data_ + this->num_leds_;
spi_dma_init(this->pin_, this->prescaler_);
}
void LN882xSPILEDStripLightOutput::dump_config() {
const uint32_t spi_bit_ns = (1000000000 / APB0_HZ) << this->prescaler_;
char channel_colors[5];
ESP_LOGCONFIG(TAG,
"LN882x SPI LED Strip:\n"
" Pin: %u\n"
" Channel colors: %s\n"
" Number of LEDs: %" PRIu16 "\n"
" Max refresh rate: %" PRIu32 " us\n"
" SPI frequency: %" PRIu32 " Hz\n"
" Bit 0 / bit 1 high: %" PRIu32 " / %" PRIu32 " ns of %" PRIu32 " ns",
this->pin_, this->channel_colors_.to_string(channel_colors), this->num_leds_, this->max_refresh_rate_,
APB0_HZ >> this->prescaler_, this->bit0_high_bits_ * spi_bit_ns, this->bit1_high_bits_ * spi_bit_ns,
SPI_BITS_PER_LED_BIT * spi_bit_ns);
}
float LN882xSPILEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; }
light::LightTraits LN882xSPILEDStripLightOutput::get_traits() {
auto traits = light::LightTraits();
if (this->channel_colors_.has_white()) {
traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE});
} else {
traits.set_supported_color_modes({light::ColorMode::RGB});
}
return traits;
}
void LN882xSPILEDStripLightOutput::clear_effect_data() {
if (this->effect_data_ != nullptr)
memset(this->effect_data_, 0, this->num_leds_);
}
void LN882xSPILEDStripLightOutput::write_state(light::LightState *state) {
if (this->is_failed())
return;
// protect from refreshing too often
const uint32_t now = micros();
if (this->max_refresh_rate_ != 0 && (now - this->last_refresh_) < this->max_refresh_rate_) {
// try again next loop iteration, so that this change won't get lost
this->schedule_show();
return;
}
if (spi_dma_busy()) {
// Busy sending the last frame, try again later.
this->schedule_show();
return;
}
this->last_refresh_ = now;
this->mark_shown_();
const uint8_t bit0 = spi_pattern(this->bit0_high_bits_);
const uint8_t bit1 = spi_pattern(this->bit1_high_bits_);
const uint8_t *color_data = this->color_data_;
uint8_t *dma_data = this->dma_data_;
for (size_t i = 0; i < this->color_data_bytes_; i++) {
const uint8_t color_byte = *(color_data++);
for (uint8_t mask = 0x80; mask != 0; mask >>= 1) {
*(dma_data++) = (color_byte & mask) ? bit1 : bit0;
}
}
spi_dma_transmit(this->dma_data_, this->dma_data_bytes_);
}
light::ESPColorView LN882xSPILEDStripLightOutput::get_view_internal(int32_t index) const {
const light::ChannelColors &colors = this->channel_colors_;
// After a failed setup() effects and partitions still write pixels, so they get a scratch pixel.
static uint8_t scratch[5];
const bool ready = this->color_data_ != nullptr;
uint8_t *led = ready ? this->color_data_ + (index * colors.bytes_per_led()) : scratch;
return {led + colors.r,
led + colors.g,
led + colors.b,
colors.has_white() ? led + colors.w : nullptr,
ready ? &this->effect_data_[index] : scratch + 4,
&this->correction_};
}
} // namespace esphome::ln882x_spi_led_strip
#endif // USE_LN882X
@@ -0,0 +1,55 @@
#pragma once
#ifdef USE_LN882X
#include <cstddef>
#include <cstdint>
#include "esphome/components/light/addressable_light.h"
#include "esphome/components/light/channel_colors.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
namespace esphome::ln882x_spi_led_strip {
class LN882xSPILEDStripLightOutput final : public light::AddressableLight {
public:
/// The SPI encoding comes from light.py: SPI clock APB0 / 2^prescaler, the number of high SPI bits for a
/// 0 and a 1 bit, and the zero bytes of the reset gap.
LN882xSPILEDStripLightOutput(uint8_t pin, uint16_t num_leds, light::ChannelColors channel_colors, uint8_t prescaler,
uint8_t bit0_high_bits, uint8_t bit1_high_bits, uint16_t reset_bytes);
void setup() override;
void dump_config() override;
float get_setup_priority() const override;
light::LightTraits get_traits() override;
int32_t size() const override { return this->num_leds_; }
void clear_effect_data() override;
void write_state(light::LightState *state) override;
/// Set a maximum refresh rate in µs as some lights do not like being updated too often.
void set_max_refresh_rate(uint32_t interval_us) { this->max_refresh_rate_ = interval_us; }
protected:
light::ESPColorView get_view_internal(int32_t index) const override;
const size_t color_data_bytes_;
const size_t dma_data_bytes_;
uint8_t *color_data_{nullptr};
uint8_t *effect_data_{nullptr};
uint8_t *dma_data_{nullptr};
uint32_t last_refresh_{0};
uint32_t max_refresh_rate_{0};
const light::ChannelColors channel_colors_;
const uint16_t num_leds_;
const uint8_t pin_;
const uint8_t prescaler_;
const uint8_t bit0_high_bits_;
const uint8_t bit1_high_bits_;
};
} // namespace esphome::ln882x_spi_led_strip
#endif // USE_LN882X
@@ -0,0 +1,156 @@
from dataclasses import dataclass
from esphome import pins
import esphome.codegen as cg
from esphome.components import light
from esphome.components.const import CONF_CHANNEL_COLORS
import esphome.config_validation as cv
from esphome.const import (
CONF_CHIPSET,
CONF_MAX_REFRESH_RATE,
CONF_NUM_LEDS,
CONF_OUTPUT_ID,
CONF_PIN,
CONF_PLATFORM,
)
import esphome.final_validate as fv
from esphome.types import ConfigType
from . import DOMAIN
CODEOWNERS = ["@sebo-b"]
DEPENDENCIES = ["ln882x"]
ln882x_spi_led_strip_ns = cg.esphome_ns.namespace("ln882x_spi_led_strip")
LN882xSPILEDStripLightOutput = ln882x_spi_led_strip_ns.class_(
"LN882xSPILEDStripLightOutput", light.AddressableLight
)
@dataclass(frozen=True)
class LEDStripTimings:
bit0_high: int # ns
bit0_low: int
bit1_high: int
bit1_low: int
# As esp32_rmt_led_strip.
CHIPSETS = {
"WS2811": LEDStripTimings(300, 1090, 1090, 320),
"WS2812": LEDStripTimings(400, 1000, 1000, 400),
"SK6812": LEDStripTimings(300, 900, 600, 600),
"APA106": LEDStripTimings(350, 1360, 1360, 350),
"SM16703": LEDStripTimings(300, 900, 900, 300),
}
# SystemInit() in the SDK sets APB0 to 40 MHz; led_strip.cpp checks it at boot.
APB0_HZ = 40_000_000
# SPI0 runs at APB0 / 2^prescaler, prescaler 1 (/2) to 8 (/256).
SPI_PRESCALER_MIN = 1
SPI_PRESCALER_MAX = 8
# Each LED data bit is sent as one SPI byte, MSB first: the leading 1-bits are the high time.
SPI_BITS_PER_LED_BIT = 8
# At most this many high bits, so every LED bit ends with at least two low SPI bits.
SPI_HIGH_BITS_MAX = SPI_BITS_PER_LED_BIT - 2
# Low time after the data that latches it. Minimum 50 us for WS2811, 80 us for SK6812, 280 us for WS2812B-V5.
RESET_DURATION_NS = 300_000
# One DMA transfer moves at most this many bytes: the whole frame plus the reset gap.
DMA_MAX_TRANSFER = 0xFFFF
@dataclass(frozen=True)
class SPIEncoding:
prescaler: int
bit0_high_bits: int
bit1_high_bits: int
reset_bytes: int
def _spi_bit_ns(prescaler: int) -> int:
return (1_000_000_000 << prescaler) // APB0_HZ
def spi_encoding(timings: LEDStripTimings) -> SPIEncoding:
"""SPI clock and bit patterns that reproduce the chipset timings."""
# The prescaler whose 8-bit frame is closest to the bit period. Each step doubles the frame,
# so step on while the period is at least 1.5 frames (a tie takes the longer frame).
bit_period_ns = max(
timings.bit0_high + timings.bit0_low, timings.bit1_high + timings.bit1_low
)
prescaler = SPI_PRESCALER_MIN
while (
prescaler < SPI_PRESCALER_MAX
and 3 * SPI_BITS_PER_LED_BIT * _spi_bit_ns(prescaler) <= 2 * bit_period_ns
):
prescaler += 1
bit_ns = _spi_bit_ns(prescaler)
def high_bits(duration_ns: int, max_bits: int) -> int:
# Rounded half up, from 1 to max_bits.
return max(1, min(max_bits, (2 * duration_ns + bit_ns) // (2 * bit_ns)))
# Bit 0 stops one below the maximum, so bit 1 can always be longer.
bit0_high_bits = high_bits(timings.bit0_high, SPI_HIGH_BITS_MAX - 1)
bit1_high_bits = max(
high_bits(timings.bit1_high, SPI_HIGH_BITS_MAX), bit0_high_bits + 1
)
reset_bytes = -(-RESET_DURATION_NS // (SPI_BITS_PER_LED_BIT * bit_ns))
return SPIEncoding(prescaler, bit0_high_bits, bit1_high_bits, reset_bytes)
def _validate_num_leds(config: ConfigType) -> ConfigType:
reset_bytes = spi_encoding(CHIPSETS[config[CONF_CHIPSET]]).reset_bytes
bytes_per_led = len(config[CONF_CHANNEL_COLORS])
max_num_leds = (
(DMA_MAX_TRANSFER - reset_bytes) // SPI_BITS_PER_LED_BIT // bytes_per_led
)
if config[CONF_NUM_LEDS] > max_num_leds:
raise cv.Invalid(
f"The maximum number of LEDs for this configuration is {max_num_leds}.",
path=[CONF_NUM_LEDS],
)
return config
CONFIG_SCHEMA = cv.All(
light.ADDRESSABLE_LIGHT_SCHEMA.extend(
{
cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(LN882xSPILEDStripLightOutput),
cv.Required(CONF_PIN): pins.internal_gpio_output_pin_number,
cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int,
cv.Required(CONF_CHANNEL_COLORS): light.validate_channel_colors,
cv.Required(CONF_CHIPSET): cv.one_of(*CHIPSETS, upper=True),
cv.Optional(CONF_MAX_REFRESH_RATE): cv.positive_time_period_microseconds,
}
),
_validate_num_leds,
)
def _final_validate(config: ConfigType) -> None:
# The strip owns SPI0 and its DMA channel, so only one can exist.
lights = fv.full_config.get().get(light.DOMAIN, [])
if sum(entry.get(CONF_PLATFORM) == DOMAIN for entry in lights) > 1:
raise cv.Invalid(f"Only one {DOMAIN} is supported (it uses SPI0)")
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config: ConfigType) -> None:
encoding = spi_encoding(CHIPSETS[config[CONF_CHIPSET]])
var = cg.new_Pvariable(
config[CONF_OUTPUT_ID],
config[CONF_PIN],
config[CONF_NUM_LEDS],
light.channel_colors_struct(config[CONF_CHANNEL_COLORS]),
encoding.prescaler,
encoding.bit0_high_bits,
encoding.bit1_high_bits,
encoding.reset_bytes,
)
await light.register_light(var, config)
await cg.register_component(var, config)
cg.add_optional(var.set_max_refresh_rate, config.get(CONF_MAX_REFRESH_RATE))
+14 -10
View File
@@ -35,12 +35,12 @@ class LockTraits {
public:
LockTraits() = default;
bool get_supports_open() const { return this->supports_open_; }
void set_supports_open(bool supports_open) { this->supports_open_ = supports_open; }
bool get_requires_code() const { return this->requires_code_; }
void set_requires_code(bool requires_code) { this->requires_code_ = requires_code; }
bool get_assumed_state() const { return this->assumed_state_; }
void set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; }
bool get_supports_open() const { return this->flags_.supports_open; }
void set_supports_open(bool supports_open) { this->flags_.supports_open = supports_open; }
bool get_requires_code() const { return this->flags_.requires_code; }
void set_requires_code(bool requires_code) { this->flags_.requires_code = requires_code; }
bool get_assumed_state() const { return this->flags_.assumed_state; }
void set_assumed_state(bool assumed_state) { this->flags_.assumed_state = assumed_state; }
bool supports_state(LockState state) const { return supported_states_mask_ & (1 << state); }
void set_supported_states(std::initializer_list<LockState> states) {
@@ -54,9 +54,13 @@ class LockTraits {
void add_supported_state(LockState state) { supported_states_mask_ |= (1 << state); }
protected:
bool supports_open_{false};
bool requires_code_{false};
bool assumed_state_{false};
// Bit-packed flags to save memory (1 byte instead of 3)
struct LockTraitFlags {
uint8_t supports_open : 1;
uint8_t requires_code : 1;
uint8_t assumed_state : 1;
uint8_t reserved : 5;
} flags_{};
uint8_t supported_states_mask_{(1 << LOCK_STATE_NONE) | (1 << LOCK_STATE_LOCKED) | (1 << LOCK_STATE_UNLOCKED)};
};
@@ -176,8 +180,8 @@ class Lock : public EntityBase {
*/
virtual void control(const LockCall &call) = 0;
Deduplicator<LockState> publish_dedup_; // packs with state and traits ahead of the pointer members
LazyCallbackManager<void(LockState)> state_callback_{};
Deduplicator<LockState> publish_dedup_;
ESPPreferenceObject rtc_;
};
@@ -0,0 +1,29 @@
import esphome.codegen as cg
from esphome.components import i2c
import esphome.config_validation as cv
from esphome.types import ConfigType
CODEOWNERS = ["@KoenBreeman"]
DEPENDENCIES = ["i2c"]
DOMAIN = "m5stack_4relay"
MULTI_CONF = True
CONF_M5STACK_4RELAY_ID = "m5stack_4relay_id"
m5stack_4relay_ns = cg.esphome_ns.namespace("m5stack_4relay")
M5Stack4Relay = m5stack_4relay_ns.class_("M5Stack4Relay", cg.Component, i2c.I2CDevice)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(M5Stack4Relay),
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(i2c.i2c_device_schema(0x26))
)
async def to_code(config: ConfigType) -> None:
await i2c.new_i2c_component(config)
@@ -0,0 +1,50 @@
#include "m5stack_4relay.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::m5stack_4relay {
ESPHOME_LOG_TAG(TAG, "m5stack_4relay");
static constexpr uint8_t REG_MODE = 0x10;
static constexpr uint8_t REG_RELAY = 0x11;
static constexpr uint8_t MODE_SYNC = 1;
void M5Stack4Relay::setup() {
if (!this->write_byte(REG_MODE, MODE_SYNC) || !this->read_byte(REG_RELAY, &this->relays_)) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
#ifdef USE_SWITCH
for (size_t channel = 0; channel < this->switches_.size(); channel++) {
switch_::Switch *sw = this->switches_[channel];
if (sw == nullptr)
continue;
// The chip keeps the relays across an ESP reboot, so a switch with restore mode DISABLED keeps what it
// holds. Every mode goes through control(), so inverted: applies and the first publish is the saved one.
sw->restore(((this->relays_ >> channel) & 1) != sw->is_inverted());
}
#endif
}
void M5Stack4Relay::dump_config() {
ESP_LOGCONFIG(TAG, "M5Stack 4 Relay:");
LOG_I2C_DEVICE(this);
}
bool M5Stack4Relay::set_relay(uint8_t channel, bool on) {
if (this->is_failed())
return false;
uint8_t value = this->relays_;
assign_bits(value, 1 << channel, on);
if (!this->status_comm_result(this->write_byte(REG_RELAY, value)))
return false;
this->relays_ = value;
#ifdef USE_SWITCH
if (this->switches_[channel] != nullptr)
this->switches_[channel]->publish_state(on);
#endif
return true;
}
} // namespace esphome::m5stack_4relay
@@ -0,0 +1,34 @@
#pragma once
#include <array>
#include "esphome/components/i2c/i2c.h"
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#ifdef USE_SWITCH
#include "esphome/components/switch/switch.h"
#endif
namespace esphome::m5stack_4relay {
class M5Stack4Relay : public Component, public i2c::I2CDevice {
public:
void setup() override;
void dump_config() override;
/// Switch relay `channel` (0-3) on or off; returns false when the write failed.
bool set_relay(uint8_t channel, bool on);
#ifdef USE_SWITCH
void register_switch(uint8_t channel, switch_::Switch *sw) { this->switches_[channel] = sw; }
#endif
protected:
uint8_t relays_{0};
#ifdef USE_SWITCH
std::array<switch_::Switch *, 4> switches_{};
#endif
};
} // namespace esphome::m5stack_4relay
Loaded 100 of 546 files, more files were not shown because too many files have changed in this diff. Show more