Compare commits

..
Author SHA1 Message Date
clydebarrow 4d299da205 Merge branch 'pi06' into pi07 2026-10-25 18:06:18 +11:00
clydebarrow c691a71cc5 Merge branch 'pi05' into pi06 2026-10-25 18:06:18 +11:00
clydebarrow 07164fc8b1 Merge remote-tracking branch 'upstream/dev' into pi05
# Conflicts:
#	esphome/components/packet_interface/__init__.py
#	esphome/core/defines.h
2026-10-25 18:06:16 +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 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 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 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 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 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
1017 changed files with 15783 additions and 20108 deletions

No files matched your search

+4 -12
View File
@@ -67,17 +67,9 @@ driven, what the logs show, and the same flow on a `dev` build when a quirk coul
automations in the config itself.
7. **Analyze** with `grep -a -c` counts over the capture and keep an `-events.txt` extract of the lines
that matter. Decode crashes with the platform's `addr2line` (see `reference/boards.md`).
8. **Report** on the PR. Every result comment starts with this two-line GitHub alert, then a blank line:
```
> [!NOTE]
> Automated testing results
```
Then one short sentence saying what was tested (builds and SHAs, board, what was driven), a results table
(build SHA, board and framework, what was driven, counts, result, plus the `dev` control row), short bullets
for the findings, and the setup and exact flow in a collapsed `<details>` block. Answer any question asked on
the PR directly in the comment. No credentials, SSIDs, IP addresses, port names or other bench details.
8. **Report** on the PR as a table: build SHA, board and framework, what was driven, counts, result,
plus the `dev` control row and the exact flow in a `<details>` block. No credentials, no port names,
no IP addresses.
## Flashing by chip family
@@ -123,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, loopback harness driver with number and binary sensor states and TX frame summarizer), `api/` (raw plaintext native API client for user defined actions, store_yaml recovery client over Noise or plaintext), `http_request/` (update manifest server, update state driver, request test server, action case driver, concurrency, reboot, update check and OTA drivers, `ota_auth/` OTA request cases with basic auth), `modem/` (button and sensor driver over the plaintext API, also used for the BLE MIDI boards), `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, MAC suffix runner with per-variant configs for two boards, `shutdown/` shutdown message runner and matcher), `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), `uart_sim/` (API driver and flash, capture and drive runner for the simulated UART sensors), `remote_base/` (loopback runner and API log decoder per sent frame), `xiaomi_ble/` (frame and header generator with the expected counts, injecting, advertising and receiving configs, counter and runner), `ble_terminate/` (proxy and peer configs, connect and disconnect driver, peer disconnect matcher, runner), `i2c_slave/` (D1 mini slave and BK7238 master configs), `wpa3_ap/` (PMF AP with join and leave logging, BK7238 and CB3S DUTs, runner and summary), `wisp_ap/` (WISP upstream with AP buttons, WISP and ALWAYS_ON DUTs, client, runner and per phase summary), `wifi_restart/` (WPA3 mixed WISP upstream, static IP DUTs with syslog, runner and summary), `ota_timing/` (OTA upload timer with deflate, raw and TCP modes, API build check, A/B runner and median summary, `inflate_bench/` on-device inflate benchmark with stream generator, configs and syslog runner).
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, store_yaml recovery client over Noise or plaintext), `http_request/` (update manifest server, update state driver, request test server, action case driver, concurrency, reboot, update check and OTA drivers, `ota_auth/` OTA request cases with basic auth), `modem/` (button and sensor driver over the plaintext API, also used for the BLE MIDI boards), `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, MAC suffix runner with per-variant configs for two boards, `shutdown/` shutdown message runner and matcher), `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), `uart_sim/` (API driver and flash, capture and drive runner for the simulated UART sensors), `remote_base/` (loopback runner and API log decoder per sent frame).
## Templates in `$SKILL/templates`
@@ -125,11 +125,6 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
- The BK7238 bootloader prints `App verify failed! Need to recovery factory firmware.` on every boot with any
build; it is harmless. Even with the right layout an ESP32 client did not see the BK7238 fallback AP on
LibreTiny 1.13.0 or 1.14.0, so the wifisave hammer cannot run on it.
- Moving a BK7231N device (CB3S) that sits on its fallback AP to another network: join the host's Wi-Fi to
`<name>-fallback`, OTA to 192.168.4.1, then rejoin the host's own network. Moving it with `/wifisave` instead can
leave it in the captive portal's BLIND_RETRY state (no scan, `Beacon Timeout` every 46 s) until it reboots, and a
log-only serial adapter cannot reset it. Rejoining may change the host's address; update a syslog target that
points at the old one.
- Callbacks run on the `async_tcp` task and log lines can lose a byte. Wi-Fi connect takes tens of ms;
radio enable 160 to 278 ms, disable about 200 ms.
@@ -177,12 +172,6 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
the port open, and the logger warns `logger took a long time`. Print one line per `loop()` instead.
- Zephyr settings survive a reflash: key a `restore_value` global by a `run` substitution when a test needs
a never-saved value.
- Thread: nRF52 `openthread:` accepts only `tlv:`; the individual keys (network_key, channel, ...) are rejected at
validation. To list nearby networks with channel and PAN ID, call
`otThreadDiscover(InstanceLock::acquire().get_instance(), 0, 0xFFFF, false, false, cb, nullptr)` from an
`interval:` lambda in an included header; results arrive on the OpenThread thread and log fine.
- A wrong network key on a real network's channel and PAN ID makes the device form its own partition as leader on
that PAN within about 30 s; take it off Thread at once. Never try guessed datasets against a network in use.
## Waveshare ESP32-S3-ETH with the OV5640 camera module
@@ -13,18 +13,18 @@ One file per component, named after it:
- [`http_request.md`](http_request.md): update manifest URL changed at run time, every setter shape, a swap under a running check, non-blocking request actions with loop gap, queue, cancel and reboot checks, OTA request cases with basic auth and MD5 sources
- [`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 and RTL8720C crash report on the next boot
- [`libretiny.md`](libretiny.md): rtc preference emulation, BK72xx crash report on the next boot
- [`modem.md`](modem.md): PPPoS modem component boot check with no modem attached, loop blocking and disable timing
- [`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, MAC suffix topics and will messages on two boards, shutdown message delivery on restart, disable and deep sleep
- [`nrf52.md`](nrf52.md): dongle board support
- [`online_image.md`](online_image.md): stalled and malformed downloads, PNG, JPEG, BMP and QOI decode time per loop pass with large images and small buffers
- [`openthread.md`](openthread.md): border router on the S3 host plus H2 RCP
- [`ota.md`](ota.md): overlap and lock, timeouts, deflate, upload timing A/B per build and board with deflate and raw, on-device inflate benchmark
- [`ota.md`](ota.md): overlap and lock, timeouts, deflate
- [`rtsp_server.md`](rtsp_server.md): camera streaming on the Waveshare ESP32-S3-ETH
- [`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), WISP bridge with an ethernet upstream, reboots against a WPA2/WPA3 PMF access point, WISP and ALWAYS_ON AP switching, AP restarts with WPA3 mixed and LIGHT power save
- [`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
@@ -36,8 +36,5 @@ One file per component, named after it:
- [`web_server.md`](web_server.md): state SSE stress and the HTTPD task stack on web_server_idf, entity icon and device class readback
- [`uart_sim.md`](uart_sim.md): UART sensors simulated on a classic ESP32 with loopback and cross-wired UARTs
- [`gpio_expander.md`](gpio_expander.md): interrupt driven expander inputs, loop idle checks, testing a device out of reach
- [`led_strip.md`](led_strip.md): forcing a failed strip allocation, RMT symbols per chip, loop cost per strip
- [`led_strip.md`](led_strip.md): forcing a failed strip allocation, RMT symbols on the S3
- [`remote_base.md`](remote_base.md): remote_receiver protocol decoding through a one-pin loopback on ESP32 and ESP8266, no IR hardware
- [`xiaomi_ble.md`](xiaomi_ble.md): Xiaomi, ATC and PVVX parsing by injecting scan results into the ESP32 tracker or advertising them from a second ESP32, real AES-CCM frames, duplicate filter model
- [`ble_terminate.md`](ble_terminate.md): proxy disconnect against a peer with peripheral latency
- [`i2c_slave.md`](i2c_slave.md): fake I2C slave on a D1 mini, ESP32 hardware master as the control
@@ -1,17 +0,0 @@
# BLE terminate with peripheral latency (#20232)
`scripts/ble_terminate`: an ESP32-S3 `bluetooth_proxy` (active) over Ethernet as the central (`btproxy.yaml`) and a
classic devkit `esp32_ble_server` peer (`btpeer.yaml`). The peer asks for 50 ms / latency 10 / 6 s with
`esp_ble_gap_update_conn_params` 4 s after connect; earlier requests are overwritten by the proxy's own "medium
conn params" after service discovery. Read the proxy's `Conn params updated` line (VERBOSE, API logs) to confirm;
the peer side never logs the event. The proxy only connects to a device its scanner has seen, so `btdrive.py`
subscribes to raw advertisements first.
Measure the peer's own disconnect (`serial_capture.py --ms`) after the client's disconnect: about 0.2 to 0.8 s when
the terminate arrived, about 6 s (the supervision timeout) when it was lost. The client side disconnect time is the
API reply and says nothing. On dev the S3 lost 6 of 12; the PR lost 0 of 16.
`run.sh LABEL ROUNDS` writes `central_bda.hpp` from `PROXY_BT_MAC`, builds and flashes both boards, runs the rounds
and `an.py`, which prints PASS only when every round completed, every peer disconnect came within 3 s and the last
parameter update the proxy logged before each disconnect had latency=10 (a round without it did not test peripheral
latency and counts as inconclusive).
@@ -1,12 +0,0 @@
# Fake I2C slave (#21586)
`scripts/i2c_slave`: a D1 mini running an Arduino `Wire` slave at 0x40 (`i2cslave.yaml` with `i2cslave.hpp`): a fake
HDC1080 plus a 256 byte register file, and reads of 0x7E and 0x7F stretch the clock for 0.5 and 5 ms. It needs
`libraries: [Wire]` and 160 MHz. With jumper wires only and the internal pull ups it works to about 50 kHz; it cannot
keep up with a true 100 kHz hardware master.
The master (`i2cbk.yaml`, a BK7238 on P26/P24, needs the software I2C bus from that PR) writes a counter, reads it
back with a repeated start, probes an absent address and reads both stretch registers every 2 s, logging `RW OK`
(the read back matches, the absent address NACKs and both stretched reads succeed) or `RW BAD` with running totals.
Control: the same blocks on a classic ESP32 devkit (SDA GPIO21, SCL GPIO22) with hardware I2C at 25 and 50 kHz got
every repeated start read right, so failures on a bit banged master at a similar real clock point at the master.
@@ -8,7 +8,3 @@
- On the ESP32-S3 give each RMT strip `rmt_symbols: 48`, or the first channel takes all 192 symbols.
- A board with a few WS2812 on a pin already defined as a light can be extended with `id: !extend <light id>` to
add effects; restore its own config afterwards.
- Loop cost of a strip (#19337): give each strip an `addressable_lambda` effect with `update_interval: 0ms` that
changes every pixel, and add `runtime_stats: log_interval: 30s`; compare the main loop and light times per pass,
leaving out the first period after boot. The strips need no LEDs attached. A classic ESP32 has 512 RMT symbols in
all, so three strips need `rmt_symbols: 128` each.
@@ -56,18 +56,3 @@ OTA-only board: copy the device's own config, add the probe include and the step
next to that config. A LibreTiny OTA is a 1.5 MB UF2 and takes minutes on a slow link, so do not wrap the
upload in a short timeout. Read the replayed report with `esphome logs ... --device <name>.local` (an API
client gets it on subscribe) or on the board's serial log port if it has one.
## RTL8720C crash report on the next boot (#21648)
`templates/rtl8720c-crash-buttons-dut.yaml` has one API button per case: `trap` (`__builtin_trap()`, usage fault),
`bus fault` (store to 0x70000000, imprecise, CFSR 0x400), `wild call` (call to 0xFFFFFFF1, MemManage fault with PC
0xFFFFFFF0 and LR in the caller), `isr trap` (a trap from a spare interrupt pended by software, hard fault), `hang`
(interrupts off, the watchdog resets it and nothing must be reported) and `restart`. Every boot logs
`boot reason N, crash record yes|no|n/a`; `n/a` means the handler is not built, as on a dev control build.
Flow per build: flash with `flash_rtl87xx_kit.py`, start one `serial_capture.py PORT 600 OUT --released --ms`, then
`scripts/libretiny_crash/rtl_drive.py HOST OUTDIR TREE YAML trap restart "bus fault" "wild call" "isr trap" hang restart`.
The `restart` after the first crash checks the report shows only once. Time each reset from the serial capture
(the ROM fault dump to the next `ROM Version` line), not from the API port: the board is back before an API client
notices it went away. Compare the reported PC with the `PC =` line of the ROM dump, and check the decoded lines in the
`caseN-*.txt` files. Pass the UF2 by name (`firmware.uf2`): the build directory holds a second identically sized copy.
@@ -4,10 +4,3 @@
Test config: the two LEDs as inverted GPIO outputs, the button as an inverted pull-up binary sensor, a
10 s alive interval. To retest the nrfutil download path, move the cached `sdk-nrf/nrfutil` aside first.
## ItsyBitsy over UF2 when serial DFU fails (#20481)
Serial DFU to the ItsyBitsy nRF52840 fails about half the time ("Target is not in DFU mode"). Reliable path: a
1200 baud touch on the app port, wait for the `ITSY840BOOT` volume, convert `zephyr/zephyr.hex` with the SDK's
`zephyr/scripts/build/uf2conv.py -f 0xADA52840 -c` and copy the .uf2 to the volume (the copy ends in an I/O error
when the board reboots, which is normal). macOS BLE scans give UUIDs, not MAC addresses.
@@ -6,51 +6,3 @@ All overlap orders (a web upload during a native OTA and the reverse) must be re
after an interrupted web upload waits about 14 s (ESP8266) or 7 s (ESP-IDF) for disconnect detection. A
refused web client on `web_server_idf` never gets the busy reply; the connection drops at reboot.
`pkill -f "a\|b"` does not alternate; use two pkills.
## OTA upload timing A/B (#21639, #21641, #21642)
`scripts/ota_timing/run.sh N PHASE...` times native OTA uploads per build (phase) and per board. It builds every
board from `TREE_<PHASE>`, does one warm-up upload per board (it installs the phase's build and is left out of the
results), checks over the API that each board reports the compile time from that build's `build_info.json`
(`apiinfo.py`), then runs N rounds of every mode on every board. Boards are interleaved per round so they share the
same air; put a nearby ESP32 on the same Wi-Fi in `BOARDS` as a network control. `summ.py` prints medians: never
judge from single runs, slow Wi-Fi gave 33 to 235 s for the same upload in one night.
- `otatime.py` drives `espota2.run_ota()` from the tree on `PYTHONPATH` and prints one `RESULT` line with the
`Update took` split, the compressed size, whether UDP was used and the time at each tenth of the upload.
An upload that leaves no `RESULT` line (board gone, script crash, timeout) is recorded as `rc: -1`, and `summ.py`
fails when a board and mode has fewer than N rows or an upload time cannot be read.
- Boards with an `api: encryption: key` also encrypt OTA: set `API_KEY` for `otatime.py` and `apiinfo.py`. LibreTiny
LN882H listens for OTA on 8820, not 8892; BK72xx and RTL87xx use 8892.
- Raw mode leaves `CLIENT_FEATURE_SUPPORTS_DEFLATE` out of the client's offer. Never switch deflate off after the
offer (`if deflate and 0:`): once the device accepted deflate it expects a deflate stream.
- `-tcp` modes make `espota2._loss_reason` return None, so the client asks to stay on TCP. Clearing
`CLIENT_FEATURE_SUPPORTS_UDP` instead desyncs the handshake (`receiving UDP token response: timed out`).
- LibreTiny trimmed UF2 (#21642): the CLI sends what `esphome.components.libretiny.uf2.device_ota_uf2()` returns for
`firmware.uf2`; that module only exists with #21642, so on older trees the full UF2 goes out. Compare the sizes
in the `Compressed to` lines, and rely on the API compile time check to show that the trimmed image booted.
`receiving chunk result: Unknown error from ESP` is error 0xFF sent by the device, not a dropped connection. The
device sends it for a decode or flash write error, and also when no data arrives for 105 s, so on a weak link it
usually means a stall. A failure about 105 s after the last progress, or the same error on a raw upload, points to
the link; to be sure, capture the device log (`Inflate err`, `Inflate overrun`, `No data received`).
Results: https://github.com/esphome/esphome/issues/21639#issuecomment-6409628227 (dev and LibreTiny 1.14.0 on a
CB3S), https://github.com/esphome/esphome/pull/21642#issuecomment-6412972562 (trimmed UF2 on CB3S, BK7238 and BW15),
https://github.com/esphome/esphome/pull/21641#issuecomment-6412976859 (new decoder on the same three).
## On-device inflate benchmark (#21639, #21641)
`scripts/ota_timing/inflate_bench`: `gen_streams.py NAME=IMAGE...` cuts a 128 KB slice from each image and writes
`bench_streams.hpp` with raw deflate streams (level 9, 4 KB and 32 KB windows). `bench.hpp` inflates every stream with
each ring size at least the encoder window, three times, and logs the best time plus input and output rates, then
`BENCHDONE`. `bench.sh CONFIG DEVICE` builds and uploads `bench-esp32s3.yaml` or `bench-bk72xx.yaml`, collects one pass
over syslog and fails when a case did not reach the end of its stream.
- LibreTiny defines `micros` as a macro, so the header calls `(esphome::micros)()`.
- Keep the streams near 130 KB on a BK7231N: an image with about 330 KB of extra incompressible data failed its
OTA with "Writing OTA data to flash memory failed". Use one image and `--windows 12` there.
- `bench.hpp` targets the decoder interface with `dest_limit`. #21641 drops it (the output runs into the window up
to `dest`); after that lands, remove the `dest_limit` line.
Results: https://github.com/esphome/esphome/issues/21639#issuecomment-6409786224
@@ -37,44 +37,3 @@ AP start (the fix in that PR), the first AP start failed on every boot with `esp
(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.
## Reboot against a WPA2/WPA3 PMF access point (#21590)
- AP: `scripts/wpa3_ap/wdap.yaml` on a classic devkit. ESPHome `wifi: ap:` only, then an on_boot lambda switches it
to `WIFI_AUTH_WPA2_WPA3_PSK`, PMF capable, and registers a WIFI_EVENT handler that logs `STA JOIN` and
`STA LEAVE ... reason N` (the include header pulls in `esp_wifi.h`). It disables Wi-Fi after 420 s.
- AP leave reason 3 means the station sent a deauth; reason 2 means the AP found a stale association when the
station came back; several reason 2 before one join are rejected reconnects.
- DUTs (`wdbk.yaml` BK7238, `wdcb.yaml` CB3S, both on `wd-common.yaml`) list the AP at priority 10 and the house
network at 0, and reboot 30 s after joining the AP and 150 s after joining the house network. So they move to the
AP while it is up and fall back to the house network (where they can be flashed over the air) once its window
ends. `run.sh BUILD` flashes the AP, does OTA for the CB3S and a serial flash for the BK7238, then captures one AP
window; `an.py` prints each station's join and leave sequence and fails on a rejected reconnect, an AP that did
not switch to WPA2/WPA3 (`AP CONFIG FAILED`), a DUT missing from the AP events (`DUT_MACS`) or a BK7238 that never
joined the AP.
- Gotchas: the LibreTiny build directory has two `.uf2` files, so flash `firmware.uf2`. The BK7238 serial flash
sometimes fails to link while the chip is in a reboot loop; retry it 3 or 4 times. OTA fails if the DUT reboots
mid-upload; retry.
- Finding: BDK 3.0.78 rejects about 2 reconnects per reboot when the station left without a deauth; BDK 3.0.33
does not.
## WISP and ALWAYS_ON AP rig (#20077)
`scripts/wisp_ap`: the upstream is an ESP32-S3 with Ethernet running a WISP bridge (`wisp-upstream.yaml`) with its AP on
10.40.0.1/24 and web buttons `ap off` and `ap on`. Its subnet must not be 192.168.4.0/24, the DUT AP's default, or
nothing routes. The DUT is a classic devkit (`dut-wisp.yaml` or `dut-always-on.yaml`); the client is a D1 mini on
the DUT's AP (`wispcli.yaml`) logging its DNS server, a name lookup, a GET by name, the AP itself and a LAN file by
IP. `run.sh DUT START SECONDS T:STATE...` switches the upstream AP at the given times and `sum.py` groups the client
results by phase. Capture the D1 mini without `--reset` (a CH340 floods garbage after an RTS reset). Clients keep
the captive portal's DNS answer for its TTL.
## AP restart with WPA3 mixed and LIGHT power save (#21531)
`scripts/wifi_restart`: the WISP upstream (`wisp-upstream-wpa3.yaml`) switched to WPA2/WPA3 mixed by a script that
runs again after every `ap on`, DUTs (`rebk.yaml`, `recb.yaml` on `re-common.yaml`) with a static IP on the bridge's
subnet, a fallback AP, `power_save_mode: LIGHT` and syslog to the host through the NAT. `run.sh BUILD` switches the
AP off and on three times; `an.py` fails on a reboot, a crash or a DUT that is not back on syslog within 120 s of an
AP start. After 19 minutes the DUTs run `wifi.configure` to the house network; that keeps the static IP, so the next
OTA needs the host to have an address on the bridge's subnet as well (an interface alias). The CB3S log is on UART1
(TX1), so its configs need `logger: hardware_uart: UART1`. Runners must `pkill` only their own ports; a global pkill
stops the other runs' captures.
@@ -1,38 +0,0 @@
# Xiaomi BLE
## Xiaomi, ATC and PVVX parsing without the sensors (#21483)
No Xiaomi devices are needed, and one ESP32 cannot hear its own advertising, so the frames are injected into the
tracker as scan results: everything after the radio runs as normal.
- `scripts/xiaomi_ble/gen.py` writes `frames.hpp` and `expected.json`: plain MiBeacon (0x50), encrypted v5 23 byte
with MAC and 19 byte without, real AES-CCM (`cryptography` AESCCM, tag 4, AAD 0x11, nonce = MAC reversed + pid +
cnt + 3 byte ext counter), ATC (13 byte, big endian) and PVVX (15 byte, little endian) 0x181A frames, a corrupted
tag every other cycle and a counter 0 boot frame. It models the old shared duplicate counter (`base`) and the per
device one (`pr`) and prints the expected publish counts.
- `xbinject.yaml` (ESP-IDF devkit) includes `frames.hpp`; an `interval:` builds a `BLEScanResult` and calls
`global_esp32_ble_tracker->gap_scan_event_handler(r)`. `count.py` counts the `Got Xiaomi` blocks (sensor
publishes are not logged at DEBUG) and compares them with `expected.json`.
- On ESP32 the tracker's own triggers sit in `listeners_`, which runs before `neutral_listeners_` (the
ble_device_base devices), so an `on_ble_service_data_advertise` never sees what a Xiaomi listener did to the
shared data. Check that directly: `xb_rewrite_probe(id(xa))` builds an `ESPBTDevice` with `parse_scan_rst()`,
calls the component's `parse_device()` and logs the service data before and after (`[probe` lines).
- `run.sh inject pr|base` builds, flashes, captures and prints PASS when the counts match that build's model and,
on the PR build, the probe's service data is the same before and after `parse_device()`.
Base dropped a second device's frame with the same counter, the frame after a bad tag and a first frame with
counter 0, and rewrote the shared data in place; the PR fixed all of them.
## Over the air with a second ESP32
- Advertiser: an ESP32-S3 with Ethernet (OTA over Ethernet), `xbadv.yaml` with `adv_frames.hpp` from
`gen.py --ota`. Per frame: stop advertising, `esp_ble_gap_set_rand_addr`, `config_adv_data_raw`, start
non-connectable advertising with `own_addr_type` random, 20 ms interval, 250 ms dwell. Random static addresses
need the top two bits set, so these use C4:C1:38 instead of A4. `esp32_ble:` alone brings up the stack. The
web_server button starts a run: `curl -X POST -d "" http://<advertiser>/button/start/press` (no body gives 411).
Its `failed sends` sensor counts only GAP calls rejected when queued; a frame the controller drops later shows
up as missing publishes in `count.py`, the same as radio loss.
- Receiver: `xbrecv.yaml` on the devkit, passive scan with window = interval = 100 ms. `run.sh ota pr|base` (with
`ADV` set) flashes it, resets it with a capture and presses start 18 s later, so the counter 0 boot frame is the
first one it hears.
- Each frame arrives about 4 times; the counts matched the injection run exactly apart from one radio loss, so the
over the air comparison allows two missing publishes per tag.
@@ -1,90 +0,0 @@
"""Match each proxy disconnect with the peer's own disconnect line and count the links the peer kept.
Usage: an.py DIR LABEL [ROUNDS] reads LABEL-drive.txt, LABEL-peer.txt (serial_capture --ms) and LABEL-proxy.txt
(esphome logs, VERBOSE). A round counts only when the last `Conn params updated` the proxy logged since the previous
disconnect shows latency=10; otherwise it is inconclusive. Exits 1 when the peer kept a link past 3 s (the terminate
was lost), a round was inconclusive, or fewer than ROUNDS rounds completed."""
import datetime as dt
from pathlib import Path
import re
import sys
D, label = Path(sys.argv[1]), sys.argv[2]
ROUNDS = int(sys.argv[3]) if len(sys.argv) > 3 else None
def read(name: str) -> list[str]:
return (
(D / f"{label}-{name}.txt")
.read_text(encoding="utf-8", errors="replace")
.splitlines()
)
def ptime(line: str) -> float | None:
"""Seconds since midnight from a leading [HH:MM:SS.mmm] stamp."""
m = re.match(r"\[(\d+):(\d+):(\d+)\.(\d+)\]", line)
return (
int(m[1]) * 3600 + int(m[2]) * 60 + int(m[3]) + int(m[4]) / 1000 if m else None
)
pdisc = [
t
for line in read("peer")
if "PEER DISCONNECT" in line and (t := ptime(line)) is not None
]
params = [
(t, int(m[1]))
for line in read("proxy")
if "Conn params updated" in line
and (m := re.search(r"latency=(\d+)", line))
and (t := ptime(line)) is not None
]
res = []
prev = -1.0
for line in read("drive"):
m = re.search(
r"R(\d+) .*DISC_CALL ([\d.]+) DISC_DONE ([\d.]+) \(\+([\d.]+)s\)", line
)
if not m:
if line.startswith("R"):
print(" ", line)
continue
# Both captures stamp local wall clock time; compare as seconds since midnight.
t = dt.datetime.fromtimestamp(float(m[2])).astimezone()
tc = t.hour * 3600 + t.minute * 60 + t.second + t.microsecond / 1e6
after = [p - tc for p in pdisc if -1 < p - tc < 12]
window = [lat for pt, lat in params if prev < pt <= tc]
res.append(
(
int(m[1]),
float(m[4]),
after[0] if after else None,
window[-1] if window else None,
)
)
prev = tc
for r, done, pd, lat in res:
peer = (
f"peer disconnect +{pd:.2f}s" if pd is not None else "peer disconnect not seen"
)
print(f" R{r:2} central done +{done:.3f}s {peer} latency at disconnect {lat}")
late = sum(1 for _, _, pd, lat in res if lat == 10 and (pd is None or pd > 3))
inconclusive = sum(1 for *_, lat in res if lat != 10)
print(
f"{label}: rounds {len(res)}, peer kept the link (>3 s or not seen) {late}, inconclusive {inconclusive}"
)
failed = []
if not res:
failed.append("no round completed")
if ROUNDS is not None and len(res) < ROUNDS:
failed.append(f"only {len(res)} of {ROUNDS} rounds completed")
if late:
failed.append(f"{late} links kept")
if inconclusive:
failed.append(
f"INCONCLUSIVE: {inconclusive} rounds had no latency=10 update before the disconnect"
)
print(f"PASS: {label}" if not failed else f"FAIL: {label}: " + "; ".join(failed))
sys.exit(1 if failed else 0)
@@ -1,100 +0,0 @@
"""Connect to the peer through the proxy, read its services, idle, disconnect; print host timestamps per round.
Usage: btdrive.py PROXY KEY PEER_MAC ROUNDS IDLE_S"""
import asyncio
import sys
import time
from aioesphomeapi import APIClient, APIConnectionError
async def main(host: str, key: str, peer: str, rounds: int, idle: float) -> int:
addr = int(peer.replace(":", ""), 16)
cli = APIClient(host, 6053, None, noise_psk=key)
await cli.connect(login=True)
info = await cli.device_info()
print(f"proxy {info.name} bt_mac {info.bluetooth_mac_address}", flush=True)
seen = 0
def adv(msg) -> None:
nonlocal seen
seen += sum(a.address == addr for a in msg.advertisements)
# The proxy only connects to a device its scanner has seen.
cli.subscribe_bluetooth_le_raw_advertisements(adv)
await asyncio.sleep(5)
print(f"peer advertisements seen in 5 s: {seen}", flush=True)
if not seen:
print("FAIL: the proxy does not see the peer", flush=True)
await cli.disconnect()
return 1
done = 0
for r in range(1, rounds + 1):
state: asyncio.Queue = asyncio.Queue()
def cb(
connected: bool, mtu: int, err: int, state: asyncio.Queue = state
) -> None:
state.put_nowait((connected, err, time.time()))
t0 = time.time()
try:
cancel = await cli.bluetooth_device_connect(
addr, cb, timeout=20, address_type=0, feature_flags=0xFF
)
except (APIConnectionError, TimeoutError) as e:
print(f"R{r} CONNECT ERROR {type(e).__name__}", flush=True)
await asyncio.sleep(9)
continue
try:
c, err, t = await asyncio.wait_for(state.get(), 25)
except TimeoutError:
print(f"R{r} CONNECT TIMEOUT", flush=True)
cancel()
await asyncio.sleep(8)
continue
if not c:
print(f"R{r} CONNECT FAIL err {err} after {t - t0:.2f}s", flush=True)
cancel()
await asyncio.sleep(8)
continue
try:
n = len((await cli.bluetooth_gatt_get_services(addr)).services)
except (APIConnectionError, TimeoutError) as e:
n = f"err {e!r}"
await asyncio.sleep(idle)
td = time.time()
await cli.bluetooth_device_disconnect(addr)
try:
c2, err2, t2 = await asyncio.wait_for(state.get(), 15)
if c2 or not isinstance(n, int):
# an unexpected connect state or a failed service read: the round did not run as intended
print(
f"R{r} BAD ROUND services {n} connected {c2} err {err2}", flush=True
)
else:
print(
f"R{r} services {n} DISC_CALL {td:.3f} DISC_DONE {t2:.3f} (+{t2 - td:.3f}s) err {err2}",
flush=True,
)
done += 1
except TimeoutError:
print(
f"R{r} services {n} DISC_CALL {td:.3f} no disconnect state", flush=True
)
cancel()
await asyncio.sleep(
9
) # a peer that missed the terminate hits its 6 s supervision timeout in this gap
await cli.disconnect()
print(f"rounds completed {done} of {rounds}", flush=True)
return 0 if done == rounds else 1
sys.exit(
asyncio.run(
main(
sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4]), float(sys.argv[5])
)
)
)
@@ -1,17 +0,0 @@
#pragma once
#include <cstring>
#include <esp_gap_ble_api.h>
#include "esphome/core/log.h"
#include "central_bda.hpp" // written by run.sh from PROXY_BT_MAC
// 50 ms interval, peripheral latency 10, 6 s supervision timeout
inline void btpeer_request_latency() {
esp_ble_conn_update_params_t p{};
memcpy(p.bda, CENTRAL_BDA, 6);
p.min_int = 40;
p.max_int = 40;
p.latency = 10;
p.timeout = 600;
esp_err_t err = esp_ble_gap_update_conn_params(&p);
ESP_LOGI("peer", "requested interval 50 ms latency 10 timeout 6 s: %d", err);
}
@@ -1,36 +0,0 @@
esphome:
name: btpeer
includes:
- btpeer.hpp
- central_bda.hpp
esp32:
board: esp32dev
framework:
type: esp-idf
logger:
level: VERBOSE
logs:
esp32_ble: VERBOSE
scheduler: DEBUG
component: DEBUG
esp32_ble:
max_connections: 1
esp32_ble_server:
manufacturer: test
on_connect:
- lambda: ESP_LOGI("peer", "PEER CONNECT conn %u", (unsigned) id);
- delay: 4s
- lambda: btpeer_request_latency();
on_disconnect:
- lambda: ESP_LOGI("peer", "PEER DISCONNECT conn %u", (unsigned) id);
services:
- uuid: 6e400001-b5a3-f393-e0a9-e50e24dcca9e
advertise: true
characteristics:
- uuid: 6e400002-b5a3-f393-e0a9-e50e24dcca9e
read: true
value: "peer"
@@ -1,44 +0,0 @@
substitutions:
name: btproxy
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
logs:
scheduler: DEBUG
component: DEBUG
esp32_ble_tracker: DEBUG
ethernet:
type: W5500
clk_pin: GPIO13
mosi_pin: GPIO11
miso_pin: GPIO12
cs_pin: GPIO14
interrupt_pin: GPIO10
reset_pin: GPIO9
api:
encryption:
key: !secret api_key
reboot_timeout: 0s
ota:
- platform: esphome
esp32_ble_tracker:
scan_parameters:
active: false
bluetooth_proxy:
active: true
@@ -1,31 +0,0 @@
#!/bin/bash
# usage: run.sh LABEL ROUNDS build both boards, then ROUNDS connect/disconnect rounds through the proxy
# Copy this directory into a scratch directory with a secrets.yaml (api_key, base64 Noise key) and run it there.
# env: TREE (esphome checkout to build the proxy from), PORT (peer devkit serial port), PROXY (proxy address, flashed
# over the air; the first flash of a fresh board goes over USB), PROXY_BT_MAC (the proxy's Bluetooth MAC, printed by
# btdrive.py as bt_mac), PEER_MAC (the peer's Bluetooth MAC, base MAC + 2 on a classic ESP32), SC (path to
# serial_capture.py), PY (checkout python), PEER_TREE (checkout for the peer, defaults to TREE)
D=$(cd "$(dirname "$0")" && pwd); L=${1:?label}; N=${2:-12}; T=${TREE:?set TREE}; PT=${PEER_TREE:-$T}
P=${PORT:?set PORT}; S3=${PROXY:?set PROXY}; SC=${SC:?set SC}; PY=${PY:-$T/venv/bin/python}
PEER=${PEER_MAC:?set PEER_MAC}; CM=${PROXY_BT_MAC:?set PROXY_BT_MAC}
KEY=$(sed -n 's/^api_key: *"\{0,1\}\([^"]*\)"\{0,1\}$/\1/p' "$D/secrets.yaml")
[ -n "$KEY" ] || { echo "FAIL: no api_key in secrets.yaml"; exit 1; }
cd "$D" || exit 1
printf '#pragma once\n#include <cstdint>\nstatic const uint8_t CENTRAL_BDA[6] = {0x%s};\n' "${CM//:/, 0x}" > central_bda.hpp
(cd "$PT" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs "$D/btpeer.yaml" --device "$P" > "$D/$L-peer-upload.log" 2>&1) \
|| { echo "FAIL: peer build or upload failed"; exit 1; }
(cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs "$D/btproxy.yaml" --device "$S3" > "$D/$L-proxy-upload.log" 2>&1) \
|| { echo "FAIL: proxy build or upload failed"; exit 1; }
SECS=$((N * 26 + 40))
pkill -f "[s]erial_capture.py $P"
$PY "$SC" "$P" $SECS "$D/$L-peer.txt" --ms > /dev/null 2>&1 & CAP=$!
(cd "$T" && PYTHONUNBUFFERED=1 exec timeout $((SECS - 10)) $PY -m esphome logs "$D/btproxy.yaml" --device "$S3" > "$D/$L-proxy.txt" 2>&1) & LOGS=$!
trap 'kill $CAP $LOGS 2>/dev/null' EXIT
sleep 6
$PY "$D/btdrive.py" "$S3" "$KEY" "$PEER" "$N" 10 > "$D/$L-drive.txt" 2>&1; RC=$?
sleep 10
kill $CAP $LOGS 2>/dev/null; wait 2>/dev/null; trap - EXIT
[ -s "$D/$L-peer.txt" ] || { tail -3 "$D/$L-drive.txt"; echo "FAIL: peer capture got nothing"; exit 1; }
$PY "$D/an.py" "$D" "$L" "$N"; ARC=$?
[ "$RC" = 0 ] || { tail -1 "$D/$L-drive.txt"; echo "FAIL: driver rc $RC (not every round completed)"; exit 1; }
exit $ARC
@@ -1,79 +0,0 @@
# BK7238 bit banged master; needs the BK72xx software I2C bus from #21586 (dev rejects the pins).
# The ESP32 control is the same i2c, sensor and interval blocks on a devkit (sda GPIO21, scl GPIO22, 25 or 50 kHz).
substitutions:
frequency: 100kHz
esphome:
name: i2cbk
bk72xx:
board: xh-wb3s
logger:
hardware_uart: UART1
level: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
reboot_timeout: 0s
ota:
- platform: esphome
i2c:
id: bus
sda: P26
scl: P24
scan: true
frequency: ${frequency}
timeout: 20ms # clock stretch limit; the bus default (1 ms) is shorter than the slave's 5 ms stretch
sensor:
- platform: hdc1080
address: 0x40
update_interval: 5s
temperature:
name: fake temperature
humidity:
name: fake humidity
globals:
- id: n
type: uint8_t
initial_value: "0"
- id: n_ok
type: uint32_t
initial_value: "0"
- id: n_bad
type: uint32_t
initial_value: "0"
interval:
- interval: 2s
then:
- lambda: |-
uint8_t k = ++id(n);
uint8_t w[4] = {0x20, k, (uint8_t) ~k, 0xA5};
uint32_t t0 = micros();
auto e1 = id(bus)->write(0x40, w, 4);
uint8_t reg = 0x20, r[3] = {0, 0, 0};
auto e2 = id(bus)->write_readv(0x40, &reg, 1, r, 3);
uint32_t t1 = micros();
uint8_t x;
auto e3 = id(bus)->read(0x41, &x, 1);
uint8_t s1 = 0x7E, s2 = 0x7F, rr[2];
uint32_t t2 = micros();
auto e4 = id(bus)->write_readv(0x40, &s1, 1, rr, 2);
uint32_t t3 = micros();
auto e5 = id(bus)->write_readv(0x40, &s2, 1, rr, 2);
uint32_t t4 = micros();
// the absent address must NACK and both clock stretched reads must succeed
bool ok = e1 == 0 && e2 == 0 && r[0] == k && r[1] == (uint8_t) ~k && r[2] == 0xA5 && e3 != 0 && e4 == 0 &&
e5 == 0;
if (ok) id(n_ok)++; else id(n_bad)++;
ESP_LOGI("t", "RW %s e1 %d e2 %d got %02X %02X %02X want %02X %02X A5 | %u us | absent e %d | stretch500 e %d %u us | stretch5ms e %d %u us | ok %u bad %u",
ok ? "OK" : "BAD", e1, e2, r[0], r[1], r[2], k, (uint8_t) ~k, (unsigned) (t1 - t0), e3, e4,
(unsigned) (t3 - t2), e5, (unsigned) (t4 - t3), (unsigned) id(n_ok), (unsigned) id(n_bad));
@@ -1,50 +0,0 @@
#pragma once
#include <Wire.h>
#include "esphome/core/log.h"
// Fake HDC1080 at 0x40: 0x00 temperature (26.0 C), 0x01 humidity (50 %), other registers are a
// 256 byte file; reading 0x7E holds SCL for 500 us, reading 0x7F for 5 ms.
static volatile uint8_t SL_REGS[256];
static volatile uint8_t sl_ptr = 0;
static volatile uint32_t sl_rx = 0, sl_tx = 0, sl_stretch = 0;
static void sl_on_receive(int n) {
if (n < 1)
return;
sl_ptr = Wire.read();
uint8_t p = sl_ptr;
while (--n > 0 && Wire.available())
SL_REGS[p++] = Wire.read();
sl_rx++;
}
static void sl_on_request() {
uint8_t b[8];
uint8_t p = sl_ptr;
sl_tx++;
if (p == 0x7E || p == 0x7F) {
sl_stretch++;
delayMicroseconds(p == 0x7E ? 500 : 5000);
}
for (int i = 0; i < 8; i++)
b[i] = SL_REGS[(uint8_t) (p + i)];
if (p == 0x00) {
b[0] = 0x66;
b[1] = 0x66;
} else if (p == 0x01) {
b[0] = 0x80;
b[1] = 0x00;
}
Wire.write(b, 8);
}
inline void i2cslave_begin() {
Wire.begin(4, 5, 0x40);
Wire.onReceive(sl_on_receive);
Wire.onRequest(sl_on_request);
ESP_LOGI("slave", "I2C slave at 0x40 on SDA GPIO4 SCL GPIO5");
}
inline void i2cslave_report() {
ESP_LOGI("slave", "rx %u tx %u stretch %u", (unsigned) sl_rx, (unsigned) sl_tx, (unsigned) sl_stretch);
}
@@ -1,29 +0,0 @@
esphome:
name: i2cslave
libraries:
- Wire
includes:
- i2cslave.hpp
platformio_options:
board_build.f_cpu: 160000000L
on_boot:
priority: -100
then:
- lambda: i2cslave_begin();
esp8266:
board: d1_mini
logger:
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
ota:
- platform: esphome
interval:
- interval: 10s
then:
- lambda: i2cslave_report();
@@ -1,124 +0,0 @@
"""Press one crash button after another on an RTL8720C test build and keep the decoded API logs of each next boot.
Usage: rtl_drive.py HOST OUTDIR TREE YAML BUTTON... (button names from templates/rtl8720c-crash-buttons-dut.yaml)
Prints PRESS lines with the wall clock time, so resets can be timed against a serial capture taken with --ms.
Each case waits for the board to answer on the API port again, then saves 15 s of `esphome logs` (run from TREE,
so the CLI decodes the addresses) as OUTDIR/caseN-BUTTON.txt. The run prints CASE N BUTTON and exits 1 with NO RESET
when the API port never closes after the press (the button did not reset the board), NO BOOT when the board is not
back within BOOT_DEADLINE_S, and NO LOGS when the log capture never connected."""
import asyncio
import contextlib
import os
from pathlib import Path
import socket
import subprocess
import sys
import time
from aioesphomeapi import APIClient
host, out, tree, yaml = sys.argv[1:5]
buttons = sys.argv[5:]
BOOT_DEADLINE_S = 120
# Every case button resets the board: a fault, the watchdog (hang) or a restart. The watchdog takes about 11 s.
RESET_DEADLINE_S = 20
def port_open() -> bool:
try:
socket.create_connection((host, 6053), 1).close()
except OSError:
return False
return True
def wait_for_port() -> bool:
deadline = time.monotonic() + BOOT_DEADLINE_S
while not port_open():
if time.monotonic() > deadline:
return False
time.sleep(1)
return True
def wait_for_reset() -> bool:
deadline = time.monotonic() + RESET_DEADLINE_S
while port_open():
if time.monotonic() > deadline:
return False
time.sleep(0.2)
return True
def fail(i: int, name: str, why: str) -> None:
print(f"CASE {i} {name} {why}", flush=True)
sys.exit(1)
async def button_keys() -> dict[str, int]:
cli = APIClient(host, 6053, None)
await cli.connect(login=True)
try:
entities, _ = await cli.list_entities_services()
finally:
await cli.disconnect()
return {e.name: e.key for e in entities if type(e).__name__ == "ButtonInfo"}
async def press(name: str, key: int) -> None:
cli = APIClient(host, 6053, None)
await cli.connect(login=True)
now = time.time()
print(
f"PRESS {name} at {time.strftime('%H:%M:%S', time.localtime(now))}.{int(now * 1000) % 1000:03d}",
flush=True,
)
cli.button_command(key)
await asyncio.sleep(0.5)
# the board resets within about a second, so the disconnect reply may never come
with contextlib.suppress(Exception):
await asyncio.wait_for(cli.disconnect(), 2)
if not wait_for_port():
sys.exit("NO BOOT before the first case")
# Check every name up front, so a typo in the last case does not waste the crashes before it
keys = asyncio.run(button_keys())
if missing := [b for b in buttons if b not in keys]:
sys.exit(f"Unknown buttons {missing}; the board has {sorted(keys)}")
for i, name in enumerate(buttons):
if not wait_for_port():
fail(i, name, "NO BOOT")
time.sleep(5)
asyncio.run(press(name, keys[name]))
if not wait_for_reset():
fail(i, name, "NO RESET")
time.sleep(20) # boot and Wi-Fi
if not wait_for_port():
fail(i, name, "NO BOOT")
case_file = Path(out) / f"case{i}-{name.replace(' ', '_')}.txt"
with case_file.open("w", encoding="utf-8") as fh:
subprocess.run(
[
"timeout",
"15",
sys.executable,
"-m",
"esphome",
"logs",
yaml,
"--device",
host,
],
cwd=tree,
env={**os.environ, "PYTHONPATH": tree, "PYTHONUNBUFFERED": "1"},
stdout=fh,
stderr=subprocess.STDOUT,
check=False,
)
if "Successful handshake" not in case_file.read_text(
encoding="utf-8", errors="replace"
):
fail(i, name, "NO LOGS")
print(f"CASE {i} {name} done", flush=True)
@@ -1,43 +0,0 @@
"""Check over the API that each board runs the expected build.
Usage: apiinfo.py HOST[=BUILD_INFO_JSON]...
Prints name and compilation time per host. With a build_info.json (from the build directory), the board's
compilation time must equal that build's build_time_str. Exits 1 when a host fails or runs another build.
env: API_KEY (the boards' api encryption key, when they have one)"""
import asyncio
import json
import os
from pathlib import Path
import sys
from aioesphomeapi import APIClient, APIConnectionError
async def info(host: str) -> tuple[str, str]:
cli = APIClient(host, 6053, None, noise_psk=os.environ.get("API_KEY"))
await cli.connect(login=True)
try:
i = await cli.device_info()
finally:
await cli.disconnect()
return i.name, i.compilation_time
bad = 0
for arg in sys.argv[1:]:
host, _, build_info = arg.partition("=")
try:
name, built = asyncio.run(asyncio.wait_for(info(host), 30))
except (APIConnectionError, TimeoutError, OSError) as err:
print("API", host, "ERROR", repr(err), flush=True)
bad += 1
continue
want = (
json.loads(Path(build_info).read_text(encoding="utf-8"))["build_time_str"]
if build_info
else None
)
ok = want is None or built == want
bad += not ok
print("API", host, name, built, "" if ok else f"MISMATCH want {want}", flush=True)
sys.exit(1 if bad else 0)
@@ -1,42 +0,0 @@
# Inflate benchmark: run gen_streams.py first, then bench.sh. Results go out over syslog to syslog_ip:5514.
substitutions:
name: inflbench-bk
board: cb3s
esphome:
name: ${name}
includes:
- bench_streams.hpp
- bench.hpp
bk72xx:
board: ${board}
logger:
hardware_uart: UART1
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
reboot_timeout: 0s
ota:
- platform: esphome
udp:
addresses:
- !secret syslog_ip
time:
- platform: sntp
syslog:
port: 5514
interval:
- interval: 90s
then:
- delay: 20s
- lambda: run_inflate_bench();
@@ -1,44 +0,0 @@
# Inflate benchmark: run gen_streams.py first, then bench.sh. Results go out over syslog to syslog_ip:5514.
substitutions:
name: inflbench-s3
esphome:
name: ${name}
includes:
- bench_streams.hpp
- bench.hpp
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
framework:
type: esp-idf
logger:
hardware_uart: USB_SERIAL_JTAG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
reboot_timeout: 0s
ota:
- platform: esphome
udp:
addresses:
- !secret syslog_ip
time:
- platform: sntp
syslog:
port: 5514
interval:
- interval: 90s
then:
- delay: 20s
- lambda: run_inflate_bench();
@@ -1,54 +0,0 @@
#pragma once
// On-device inflate speed for the ESPHome OTA decoder. Targets the decoder interface with dest_limit (dev as of
// this writing); once the interface without dest_limit lands (#21641), drop the dest_limit line.
#include "esphome/components/esphome/ota/ota_esphome_inflate.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include "bench_streams.hpp"
static uint8_t bench_ring[32768];
static OtaInflateState bench_state;
// LibreTiny defines micros as a macro; the parentheses call the ESPHome function.
inline uint32_t bench_now() { return (esphome::micros) (); }
inline void run_inflate_bench() {
unsigned expected = 0;
for (const auto &c : BENCH_CASES) {
for (unsigned ring : {4096u, 8192u, 16384u, 32768u})
expected += ring >= c.enc_window;
}
ESP_LOGI("bench", "BENCHSTART cases=%u", expected);
for (const auto &c : BENCH_CASES) {
for (unsigned ring : {4096u, 8192u, 16384u, 32768u}) {
if (ring < c.enc_window)
continue;
uint32_t best = UINT32_MAX;
size_t out = 0;
int res = 0;
for (int rep = 0; rep < 3; rep++) {
ota_inflate_init(&bench_state, bench_ring, ring);
bench_state.source = c.data;
bench_state.source_limit = c.data + c.len;
bench_state.source_read_cb = [](OtaInflateState *) -> int { return -1; };
out = 0;
uint32_t t0 = bench_now();
do {
bench_state.dest = bench_ring;
bench_state.dest_limit = bench_ring + ring;
res = ota_inflate(&bench_state);
out += bench_state.dest - bench_ring;
esphome::App.feed_wdt();
} while (res == OTA_INFLATE_OK);
uint32_t dt = bench_now() - t0;
if (dt < best)
best = dt;
}
ESP_LOGI("bench", "%s enc=%u ring=%u in=%u out=%u/%u res=%d best_us=%u out_kBps=%u in_kBps=%u", c.name,
c.enc_window, ring, (unsigned) c.len, (unsigned) out, (unsigned) c.raw, res, (unsigned) best,
(unsigned) ((uint64_t) out * 1000 / best), (unsigned) ((uint64_t) c.len * 1000 / best));
}
}
ESP_LOGI("bench", "BENCHDONE");
}
@@ -1,42 +0,0 @@
#!/bin/bash
# usage: bench.sh CONFIG DEVICE build and upload CONFIG (bench-bk72xx or bench-esp32s3) to DEVICE (OTA address or
# serial port), then collect one benchmark pass over syslog. Run gen_streams.py in this directory first.
# Copy this directory into a scratch directory with a secrets.yaml (wifi_ssid, wifi_password, syslog_ip = this
# host's address) and run it there. env: TREE (esphome checkout under test), PY (checkout python), WAIT (seconds to
# wait for a pass, default 300)
D=$(cd "$(dirname "$0")" && pwd); C=${1:?config name}; DEV=${2:?device}; T=${TREE:?set TREE}
PY=${PY:-$T/venv/bin/python}; WAIT=${WAIT:-300}; OUT=$D/$C-syslog.txt
[ -f "$D/bench_streams.hpp" ] || { echo "FAIL: run gen_streams.py first"; exit 1; }
(cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs "$D/$C.yaml" --device "$DEV" > "$D/$C-upload.log" 2>&1) \
|| { echo "FAIL: build or upload failed, see $C-upload.log"; exit 1; }
$PY - "$OUT" "$WAIT" << 'PYEOF'
import socket
import sys
import time
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(("0.0.0.0", 5514))
s.settimeout(1)
end = time.time() + int(sys.argv[2])
started = False
with open(sys.argv[1], "w", encoding="utf-8") as out:
while time.time() < end:
try:
d, _ = s.recvfrom(2048)
except TimeoutError:
continue
line = d.decode(errors="replace").strip()
# keep one whole pass: lines from a pass already under way are dropped
started = started or "BENCHSTART" in line
if started and "bench" in line:
print(line, file=out, flush=True)
if "BENCHDONE" in line:
break
PYEOF
grep -q BENCHDONE "$OUT" || { echo "FAIL: no complete pass within $WAIT s"; exit 1; }
grep -o 'bench:[0-9]*\]: .*' "$OUT"
BAD=$(grep 'best_us=' "$OUT" | grep -v 'res=1 ' | wc -l | tr -d ' ')
SHORT=$(grep -o 'out=[0-9]*/[0-9]*' "$OUT" | awk -F'[=/]' '$2 != $3' | wc -l | tr -d ' ')
WANT=$(grep -o 'BENCHSTART cases=[0-9]*' "$OUT" | cut -d= -f2); GOT=$(grep -c 'best_us=' "$OUT")
[ "$BAD" = 0 ] && [ "$SHORT" = 0 ] && [ "$GOT" = "$WANT" ] && echo "PASS" \
|| { echo "FAIL: $BAD cases did not finish, $SHORT short outputs, $GOT of $WANT results (lost syslog lines)"; exit 1; }
@@ -1,43 +0,0 @@
"""Write bench_streams.hpp: one 128 KB slice per image, raw deflate at level 9 with 4 KB and 32 KB windows.
Usage: gen_streams.py NAME=IMAGE[@OFFSET]... [--windows 12,15]
Each image gives one case per window. Keep the total near 130 KB on a BK7231N: an image with about 330 KB of extra
incompressible data failed its OTA ("Writing OTA data to flash memory failed"); use one image and --windows 12 there."""
import argparse
from pathlib import Path
import zlib
SLICE = 128 * 1024
ap = argparse.ArgumentParser()
ap.add_argument("images", nargs="+")
ap.add_argument("--windows", default="12,15")
args = ap.parse_args()
arrays, cases, total = [], [], 0
for spec in args.images:
name, _, rest = spec.partition("=")
path, _, offset = rest.partition("@")
raw = Path(path).read_bytes()[int(offset or 0) :][:SLICE]
for wbits in (int(w) for w in args.windows.split(",")):
data = zlib.compress(raw, 9, -wbits)
total += len(data)
var = f"{name}_w{wbits}"
arrays.append(
f"static const uint8_t {var}[{len(data)}] = {{{','.join(map(str, data))}}};"
)
cases.append(f'{{"{name}", {var}, sizeof({var}), {len(raw)}u, {1 << wbits}u}}')
print(f"{var}: {len(raw)} -> {len(data)} bytes")
head = "#pragma once\n#include <cstddef>\n#include <cstdint>\n"
case_struct = (
"struct BenchCase {\n const char *name;\n const uint8_t *data;\n size_t len;\n size_t raw;\n"
" unsigned enc_window;\n};\n"
)
body = (
"\n".join(arrays)
+ "\n"
+ case_struct
+ "static const BenchCase BENCH_CASES[] = {"
+ ", ".join(cases)
+ "};\n"
)
Path("bench_streams.hpp").write_text(head + body, encoding="utf-8")
print(f"total {total} bytes of streams")
@@ -1,88 +0,0 @@
"""Time one ESPHome OTA upload and print one RESULT json line.
Usage: otatime.py HOST IMAGE MODE (MODE: deflate, raw, deflate-tcp or raw-tcp)
env: OTA_PORT (default 3232), OTA_PASSWORD, API_KEY (a board with an api encryption key also encrypts OTA),
TAG (label stored in the result)
Run it with the checkout under test first on PYTHONPATH, so its espota2 is the client being measured.
raw: the client leaves deflate out of its feature offer. Never patch the deflate branch off after the offer: once
the device accepted deflate it expects a deflate stream. -tcp: the link is reported clean, so the client asks to
stay on TCP. Clearing the UDP feature bit instead desyncs the handshake ("receiving UDP token response: timed out")."""
import json
import logging
import os
from pathlib import Path
import sys
import time
import esphome.espota2 as espota
host, image, mode = sys.argv[1], Path(sys.argv[2]), sys.argv[3]
# Assigning a missing attribute would silently do nothing, so a renamed name must stop the run
for name in ("CLIENT_FEATURE_SUPPORTS_DEFLATE", "_loss_reason"):
if not hasattr(espota, name):
sys.exit(f"espota2 in this tree has no {name}; update otatime.py")
if mode.startswith("raw"):
espota.CLIENT_FEATURE_SUPPORTS_DEFLATE = 0
if mode.endswith("-tcp"):
espota._loss_reason = lambda *a: None # noqa: SLF001
marks: list[tuple[float, float]] = []
class Recorder:
"""Stands in for the progress bar and records when each fraction went out."""
def __init__(self, *args) -> None:
pass
def update(self, progress: float) -> None:
marks.append((time.perf_counter(), progress))
def done(self) -> None:
pass
espota.ProgressBar = Recorder
lines: list[str] = []
class Keep(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
lines.append(record.getMessage())
logging.basicConfig(level=logging.INFO)
logging.getLogger().addHandler(Keep())
t0 = time.perf_counter()
rc, _ = espota.run_ota(
host,
int(os.environ.get("OTA_PORT", "3232")),
os.environ.get("OTA_PASSWORD"),
image,
noise_psk=os.environ.get("API_KEY"),
)
total = time.perf_counter() - t0
deciles = []
if marks:
start = marks[0][0]
for q in range(1, 11):
hit = next((t for t, f in marks if f >= q / 10 - 1e-9), None)
deciles.append(round(hit - start, 2) if hit is not None else None)
result = {
"board": os.environ.get("TAG", ""),
"mode": mode,
"rc": rc,
"total": round(total, 2),
"took": next((x for x in lines if x.startswith("Update took")), ""),
"size": next((x for x in lines if "Compressed to" in x), ""),
"udp": any("Using UDP" in x for x in lines),
"deciles": deciles,
}
# The log must agree with the mode, or the row would be counted under the wrong mode
if mode.startswith("raw") and result["size"]:
result["rc"] = rc = rc or 1
print("raw mode upload was compressed anyway", file=sys.stderr)
if mode.endswith("-tcp") and result["udp"]:
result["rc"] = rc = rc or 1
print("-tcp mode upload switched to UDP", file=sys.stderr)
print("RESULT", json.dumps(result), flush=True)
sys.exit(1 if rc else 0)
@@ -1,65 +0,0 @@
#!/bin/bash
# usage: run.sh N PHASE... OTA upload timing A/B: per phase build every board, one warm-up upload per board
# (installs the phase's build, not timed), an API check that each board booted that build, then N rounds of every
# MODE on every board, interleaved so the boards share the same air. summ.py prints medians at the end.
# Layout: $D/PHASE/NAME.yaml per phase and board (with its secrets.yaml); outputs go to $D/res.txt.
# env: BOARDS ("NAME,HOST,OTA_PORT ..."; add an ESP32 on the same Wi-Fi as a network control), TREE_<PHASE>
# (esphome checkout for each phase, e.g. TREE_base, TREE_pr), PY (python with esphome's requirements),
# MODES (default "deflate-tcp raw-tcp"), SETTLE (seconds after a board answers again, default 15), API_KEY (when the
# boards have an api encryption key; it also encrypts their OTA, so all boards in one run need the same key)
D=$(cd "$(dirname "$0")" && pwd); N=${1:?rounds}; shift; [ $# -gt 0 ] || { echo "usage: run.sh N PHASE..."; exit 2; }
B=${BOARDS:?set BOARDS}; PY=${PY:?set PY}; MODES=${MODES:-deflate-tcp raw-tcp}; SETTLE=${SETTLE:-15}
wait_up() { # wait_up HOST PORT: up to 5 minutes for the OTA port to answer
local i
for i in $(seq 60); do
"$PY" -c "import socket,sys; socket.create_connection((sys.argv[1], int(sys.argv[2])), 2).close()" "$1" "$2" 2>/dev/null \
&& { sleep "$SETTLE"; return 0; }
sleep 5
done
return 1
}
up() { # up PHASE NAME HOST PORT MODE TAG OUTFILE; returns 1 unless the upload succeeded
local tree=TREE_$1 out=""
if wait_up "$3" "$4"; then
out=$(TAG=$1-$2-$6 OTA_PORT=$4 PYTHONPATH=${!tree} timeout 900 "$PY" "$D/otatime.py" "$3" \
"$(cat "$D/img-$1-$2.txt")" "$5" 2>> "$D/stderr-$1.log" | grep RESULT)
else
echo "FAIL: $3 not answering on $4"
fi
# no RESULT line (board gone, otatime.py crashed or timed out): record a failed upload so summ.py counts it
[ -n "$out" ] || out="RESULT {\"board\": \"$1-$2-$6\", \"mode\": \"$5\", \"rc\": -1, \"total\": 0, \"took\": \"\", \"size\": \"\", \"udp\": false, \"deciles\": []}"
echo "$out" | tee -a "$7"
[[ $out == *'"rc": 0,'* ]]
}
# results from an earlier run would fill in for missing rows: move them aside
for f in res.txt warm.txt api.txt; do [ -f "$D/$f" ] && mv "$D/$f" "$D/$f.$(date +%Y%m%d-%H%M%S)"; done
for ph in "$@"; do
tree=TREE_$ph; [ -n "${!tree}" ] || { echo "FAIL: set $tree"; exit 1; }
checks=()
for b in $B; do
IFS=, read -r n h p <<< "$b"
(cd "${!tree}" && PYTHONUNBUFFERED=1 timeout 1500 "$PY" -m esphome compile "$D/$ph/$n.yaml") > "$D/build-$ph-$n.log" 2>&1 \
|| { echo "FAIL: build $ph/$n"; exit 1; }
# the image the CLI uploads (CORE.firmware_bin): ESP-IDF build, LibreTiny UF2, else the PlatformIO bin
bd=$D/$ph/.esphome/build/$n; : > "$D/img-$ph-$n.txt"
for f in "$bd/build/$n.bin" "$bd/.pioenvs/$n/firmware.uf2" "$bd/.pioenvs/$n/firmware.bin"; do
[ -f "$f" ] && { echo "$f" > "$D/img-$ph-$n.txt"; break; }
done
[ -s "$D/img-$ph-$n.txt" ] || { echo "FAIL: no firmware image under $bd"; exit 1; }
case $(cat "$D/img-$ph-$n.txt") in
*.uf2) PYTHONPATH=${!tree} "$PY" "$D/trim_uf2.py" "$(cat "$D/img-$ph-$n.txt")" > "$D/img-$ph-$n.tmp" \
&& mv "$D/img-$ph-$n.tmp" "$D/img-$ph-$n.txt" || { echo "FAIL: trim_uf2.py $ph/$n"; exit 1; } ;;
esac
checks+=("$h=$D/$ph/.esphome/build/$n/build_info.json")
done
for b in $B; do IFS=, read -r n h p <<< "$b"; up "$ph" "$n" "$h" "$p" deflate-tcp warm "$D/warm.txt" || exit 1; done
for b in $B; do IFS=, read -r n h p <<< "$b"; wait_up "$h" "$p"; done
"$PY" "$D/apiinfo.py" "${checks[@]}" | tee -a "$D/api.txt"; [ "${PIPESTATUS[0]}" = 0 ] || { echo "FAIL: $ph did not boot everywhere"; exit 1; }
for i in $(seq "$N"); do
for m in $MODES; do
for b in $B; do IFS=, read -r n h p <<< "$b"; up "$ph" "$n" "$h" "$p" "$m" "r$i" "$D/res.txt"; done
done
done
echo "PHASE DONE $ph"
done
"$PY" "$D/summ.py" "$D/res.txt" "$N"
@@ -1,43 +0,0 @@
"""Median upload time per board and mode from otatime.py RESULT lines.
Usage: summ.py RESULTS_FILE [ROUNDS]
Exits 1 when any upload failed, an upload time could not be read, or (with ROUNDS) a board and mode has fewer rows."""
import json
from pathlib import Path
import re
import statistics
import sys
rows = [
json.loads(line.split("RESULT ", 1)[1])
for line in Path(sys.argv[1]).read_text(encoding="utf-8").splitlines()
if "RESULT " in line
]
by: dict[tuple[str, str], list[dict]] = {}
for r in rows:
m = re.search(r"upload ([\d.]+)", r["took"])
r["up"] = float(m[1]) if m else None
# TAG is PHASE-BOARD-ROUND; group per phase and board
by.setdefault((r["board"].rsplit("-", 1)[0], r["mode"]), []).append(r)
rounds = int(sys.argv[2]) if len(sys.argv) > 2 else 0
failed = unparsed = short = 0
for (board, mode), v in sorted(by.items()):
ok = sorted(r["up"] for r in v if r["rc"] == 0 and r["up"] is not None)
fails = sum(r["rc"] != 0 for r in v)
failed += fails
unparsed += sum(r["rc"] == 0 and r["up"] is None for r in v)
short += len(v) < rounds
udp = sum(r["udp"] for r in v if r["rc"] == 0)
med = statistics.median(ok) if ok else None
print(
f"{board:24} {mode:12} n={len(v)} fail={fails} udp={udp} median={med} upload s: {ok}"
)
if not rows:
print("FAIL: no results")
elif failed or unparsed or short:
print(
f"FAIL: {failed} failed uploads, {unparsed} without an upload time, {short} groups under {rounds} rows"
)
else:
print("PASS")
sys.exit(0 if rows and not (failed or unparsed or short) else 1)
@@ -1,24 +0,0 @@
"""Write the trimmed LibreTiny UF2 the CLI would send (esphome#21642) next to firmware.uf2 and print its path.
Usage: trim_uf2.py FIRMWARE_UF2 (run with the phase's checkout on PYTHONPATH)
Prints the input path unchanged when the tree has no device_ota_uf2() or there is nothing to drop."""
from pathlib import Path
import sys
src = Path(sys.argv[1])
try:
from esphome.components.libretiny.uf2 import device_ota_uf2
except ModuleNotFoundError as err:
if err.name != "esphome.components.libretiny.uf2":
raise
print("no device_ota_uf2() in this tree, sending the full UF2", file=sys.stderr)
print(src)
sys.exit(0)
data = src.read_bytes()
if (trimmed := device_ota_uf2(data)) is None:
print(src)
sys.exit(0)
out = src.with_name("firmware.ota.uf2")
out.write_bytes(trimmed)
print(f"trimmed {len(data)} -> {len(trimmed)} bytes", file=sys.stderr)
print(out)
@@ -1,95 +0,0 @@
"""Summarise the AP restart run: alive lines over syslog per DUT, reboots, gaps, recovery after each AP start.
Usage: an.py DIR BUILD [RECOVER_S] reads BUILD-marks.txt, BUILD-syslog.txt, BUILD-bk.txt, BUILD-cb.txt (optional)
and BUILD-up.txt (the bridge's API logs)
Exits 1 when a DUT rebooted, crashed, sent nothing, or had no syslog line within RECOVER_S (default 120) of an
AP start, when the BK7238 capture (or a CB3S capture that was started) is empty, or when the bridge did not switch
its AP to WPA2/WPA3 mixed after each AP start."""
from itertools import pairwise
from pathlib import Path
import re
import sys
D, b = Path(sys.argv[1]), sys.argv[2]
RECOVER = int(sys.argv[3]) if len(sys.argv) > 3 else 120
def read(suffix: str) -> str | None:
f = D / f"{b}-{suffix}.txt"
return f.read_text(encoding="utf-8", errors="replace") if f.exists() else None
def secs(t: str) -> int:
h, m, s = t.split(":")
return int(h) * 3600 + int(m) * 60 + int(float(s))
marks = [
(secs(line.split()[0]), line.split()[3])
for line in read("marks").splitlines()
if line.strip()
]
sl = (read("syslog") or "").splitlines()
bad = []
for dev in ("rebk", "recb"):
lines = [line for line in sl if re.search(rf"\b{dev}\b", line)]
ups = [
(secs(line.split()[0]), int(m[1]))
for line in lines
if (m := re.search(r"up (\d+) s", line))
]
if not ups:
print(f"== {dev}: no alive lines over syslog")
bad.append(f"{dev} silent")
continue
reboots = sum(1 for (_, u0), (_, u1) in pairwise(ups) if u1 < u0)
gaps = [t1 - t0 for (t0, _), (t1, _) in pairwise(ups) if t1 - t0 > 25]
print(
f"== {dev}: alive lines {len(ups)}, reboots {reboots}, syslog gaps over 25 s: {gaps}"
)
if reboots:
bad.append(f"{dev} rebooted")
for t, st in marks:
if st != "on":
continue
after = [x for x in ups if x[0] > t]
first = after[0][0] - t if after else None
print(
f" ap back at t={t % 86400 // 3600:02d}:{t % 3600 // 60:02d}:{t % 60:02d}, first syslog line {first} s later"
)
if first is None or first > RECOVER:
bad.append(f"{dev} not back {RECOVER} s after an AP start")
for dev, suffix in (("rebk", "bk"), ("recb", "cb")):
if (s := read(suffix)) is None and dev == "recb":
continue # the CB3S capture is optional
if not s:
bad.append(f"{dev} serial capture missing or empty")
continue
crashes = len(re.findall(r"(?i)hard fault|exception|abort|watchdog", s))
connected = len(re.findall(r"\[wifi:\d+\]: Connected", s))
print(
f"-- {dev} serial: lines {s.count(chr(10))}, overflow {s.count('queue overflow')}, fallback AP starts "
f"{s.count('Starting fallback AP')}, connected {connected}, boots {s.count('ESPHome version')}, crashes {crashes}"
)
if crashes:
bad.append(f"{dev} crashed")
up = read("up") or ""
switches = [
secs(m[1]) for m in re.finditer(r"\[(\d+:\d+:\d+)[.\d]*\].*PMF capable: 0", up)
]
# each AP start needs its own switch between that mark (less a few seconds of clock skew) and the next mark
ends = [t for t, _ in marks[1:]] + [float("inf")]
missed = [
t
for (t, st), end in zip(marks, ends, strict=True)
if st == "on" and not any(t - 5 <= s < end for s in switches)
]
starts = sum(1 for _, st in marks if st == "on")
print(
f"-- bridge: WPA2/WPA3 switches {len(switches)}, AP starts {starts} ({len(missed)} without a switch), "
f"failures {up.count('AP CONFIG FAILED')}"
)
if "AP CONFIG FAILED" in up or missed:
bad.append("the bridge did not switch its AP to WPA2/WPA3 mixed after every start")
print("PASS" if not bad else "FAIL: " + ", ".join(bad))
sys.exit(1 if bad else 0)
@@ -1,58 +0,0 @@
esphome:
name: ${name}
logger:
level: DEBUG
wifi:
networks:
- ssid: !secret wisp_up_ssid
password: !secret wisp_up_password
manual_ip:
static_ip: ${ip}
gateway: 10.40.0.1
subnet: 255.255.255.0
dns1: 10.40.0.1
power_save_mode: LIGHT
ap:
ssid: ${name}-fallback
password: !secret fallback_ap_password
captive_portal:
web_server:
port: 80
api:
reboot_timeout: 0s
ota:
- platform: esphome
udp:
addresses:
- !secret syslog_ip
time:
- platform: sntp
syslog:
port: 5514
interval:
- interval: 10s
then:
- lambda: ESP_LOGI("alive", "up %u s", (unsigned) (millis() / 1000));
- interval: 30s
then:
- if:
condition:
lambda: |-
static bool done = false;
if (done || millis() < 19 * 60 * 1000) return false;
done = true;
return true;
then:
- lambda: ESP_LOGI("alive", "GOING HOME");
- wifi.configure:
ssid: !secret wifi_ssid
password: !secret wifi_password
save: false
@@ -1,13 +0,0 @@
# BK7238 DUT (NiceMCU XH-WB3S), flashed over serial by run.sh
substitutions:
name: rebk
ip: 10.40.0.21
packages:
common: !include re-common.yaml
bk72xx:
board: xh-wb3s
logger:
hardware_uart: UART1
@@ -1,13 +0,0 @@
# CB3S (BK7231N) DUT, flashed over the air by run.sh; its log port is UART1 (TX1)
substitutions:
name: recb
ip: 10.40.0.22
packages:
common: !include re-common.yaml
bk72xx:
board: cb3s
logger:
hardware_uart: UART1
@@ -1,57 +0,0 @@
#!/bin/bash
# usage: run.sh BUILD OTA the CB3S, serial flash the BK7238, then switch the upstream AP off and on three times
# while both DUTs send alive lines over syslog. BUILD is a label (pr, base) for the output files.
# Copy this directory into a scratch directory with a secrets.yaml (wifi_ssid, wifi_password, wisp_up_ssid,
# wisp_up_password, fallback_ap_password, syslog_ip, api_key) and run it there. wisp-upstream-wpa3.yaml runs on the
# ethernet ESP32-S3 upstream; syslog_ip is this host's address as the DUTs reach it through the bridge's NAT.
# env: TREE (esphome checkout for the DUTs), BK_PORT (BK7238 serial), CB_PORT (CB3S log only serial, optional),
# CB_HOST (CB3S address to OTA), UPSTREAM (bridge address), SC (path to serial_capture.py), FLASH_BK (path to
# flash_bk72xx.py), LT (LibreTiny penv python), PY (checkout python), UP_TREE (checkout for the bridge's API logs)
D=$(cd "$(dirname "$0")" && pwd); b=${1:?build label}; T=${TREE:?set TREE}; PY=${PY:-$T/venv/bin/python}
BK=${BK_PORT:?set BK_PORT}; CBH=${CB_HOST:?set CB_HOST}; UP=${UPSTREAM:?set UPSTREAM}; SC=${SC:?set SC}
FB=${FLASH_BK:?set FLASH_BK}; LT=${LT:-$HOME/.platformio/penv/.libretiny/bin/python}; CB=${CB_PORT:-}
ap() { curl -s -m 5 -X POST -d "" -o /dev/null -w "%{http_code}" "http://$UP/button/ap%20$1/press"; }
# retry N PAUSE CMD...: run CMD up to N times, PAUSE seconds apart; fails when every attempt failed
retry() { local n=$1 pause=$2 i; shift 2; for i in $(seq "$n"); do "$@" && return 0; [ "$i" -lt "$n" ] && sleep "$pause"; done; return 1; }
build() { (cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome "$@"); }
# only this run's ports: a global pkill stops other runs' captures
pkill -f "[s]erial_capture.py $BK"; [ -n "$CB" ] && pkill -f "[s]erial_capture.py $CB"
[ "$(ap on)" = 200 ] || { echo "FAIL: upstream bridge did not take ap on"; exit 1; }
cb_upload() { build run --no-logs "$D/recb.yaml" --device "$CBH" > "$D/cb-$b-upload.log" 2>&1; }
retry 3 15 cb_upload || { echo "FAIL: CB3S build or OTA after 3 tries"; exit 1; }
build compile "$D/rebk.yaml" > "$D/bk-$b-build.log" 2>&1 || { echo "FAIL: BK7238 build"; exit 1; }
UF2=$D/.esphome/build/rebk/.pioenvs/rebk/firmware.uf2
bk_flash() { $LT "$FB" flash write -d "$BK" "$UF2" > "$D/bk-$b-flash.log" 2>&1; }
retry 4 5 bk_flash || { echo "FAIL: BK7238 flash after 4 tries"; exit 1; }
PIDS=()
$PY - > "$D/$b-syslog.txt" 2>&1 << 'PYEOF' &
import socket
import time
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(("0.0.0.0", 5514))
s.settimeout(1)
end = time.time() + 1250
while time.time() < end:
try:
d, a = s.recvfrom(2048)
print(time.strftime("%H:%M:%S"), a[0], d.decode(errors="replace").strip(), flush=True)
except TimeoutError:
pass
PYEOF
PIDS+=($!)
$PY "$SC" "$BK" 1240 "$D/$b-bk.txt" --released > /dev/null 2>&1 & PIDS+=($!)
# an empty file marks the CB3S capture as requested, so an.py fails it if the capture never writes anything
[ -n "$CB" ] && { : > "$D/$b-cb.txt"; $PY "$SC" "$CB" 1240 "$D/$b-cb.txt" --released > /dev/null 2>&1 & PIDS+=($!); }
(cd "${UP_TREE:-$T}" && PYTHONUNBUFFERED=1 exec timeout 1240 $PY -m esphome logs "$D/wisp-upstream-wpa3.yaml" --device "$UP" > "$D/$b-up.txt" 2>&1) & PIDS+=($!)
trap 'kill "${PIDS[@]}" 2>/dev/null' EXIT
t0=$(date +%s); : > "$D/$b-marks.txt"
for step in 180:off 330:on 480:off 630:on 780:off 930:on; do
t=${step%%:*}; st=${step#*:}
while [ $(($(date +%s) - t0)) -lt "$t" ]; do sleep 1; done
code=$(ap "$st")
echo "$(date +%H:%M:%S) t=$t ap $st $code" | tee -a "$D/$b-marks.txt"
[ "$code" = 200 ] || { echo "FAIL: upstream bridge returned $code for ap $st"; exit 1; }
done
wait "${PIDS[@]}"; trap - EXIT
$PY "$D/an.py" "$D" "$b"
@@ -1,3 +0,0 @@
#pragma once
#include <esp_wifi.h>
#include <esp_event.h>
@@ -1,95 +0,0 @@
esphome:
name: wisp-upstream
includes:
- wdap_inc.h
on_boot:
priority: -100
then:
- delay: 3s
- script.execute: wpa3_ap
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
flash_size: 16MB
framework:
type: esp-idf
logger:
hardware_uart: USB_SERIAL_JTAG
ethernet:
type: W5500
clk_pin: GPIO13
mosi_pin: GPIO11
miso_pin: GPIO12
cs_pin: GPIO14
interrupt_pin: GPIO10
reset_pin: GPIO9
wifi:
ap:
ssid: !secret wisp_up_ssid
password: !secret wisp_up_password
ap_mode: WISP
manual_ip:
static_ip: 10.40.0.1
gateway: 10.40.0.1
subnet: 255.255.255.0
api:
encryption:
key: !secret api_key
reboot_timeout: 0s
ota:
- platform: esphome
web_server:
port: 80
button:
- platform: template
name: ap off
on_press:
- wifi.disable:
- platform: template
name: ap on
on_press:
- wifi.enable:
- delay: 3s
- script.execute: wpa3_ap
script:
- id: wpa3_ap
then:
- lambda: |-
static bool registered = false;
wifi_config_t c{};
esp_err_t err = esp_wifi_get_config(WIFI_IF_AP, &c);
if (err == ESP_OK) {
c.ap.authmode = WIFI_AUTH_WPA2_WPA3_PSK;
c.ap.pmf_cfg.required = false;
c.ap.sae_pwe_h2e = WPA3_SAE_PWE_BOTH;
err = esp_wifi_set_config(WIFI_IF_AP, &c);
}
// read back: only a running AP that reports WPA2/WPA3 mixed counts as switched
wifi_config_t check{};
if (err == ESP_OK)
err = esp_wifi_get_config(WIFI_IF_AP, &check);
if (err != ESP_OK || check.ap.authmode != WIFI_AUTH_WPA2_WPA3_PSK) {
ESP_LOGE("wdap", "AP CONFIG FAILED: %d authmode %d", err, (int) check.ap.authmode);
} else {
ESP_LOGI("wdap", "AP set to WPA2/WPA3 mixed, PMF capable: %d", err);
}
if (registered) return;
registered = true;
esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, [](void *, esp_event_base_t, int32_t id, void *data) {
if (id == WIFI_EVENT_AP_STACONNECTED) {
auto *e = (wifi_event_ap_staconnected_t *) data;
ESP_LOGI("wdap", "STA JOIN %02X:%02X:%02X:%02X:%02X:%02X", e->mac[0], e->mac[1], e->mac[2], e->mac[3], e->mac[4], e->mac[5]);
} else if (id == WIFI_EVENT_AP_STADISCONNECTED) {
auto *e = (wifi_event_ap_stadisconnected_t *) data;
ESP_LOGI("wdap", "STA LEAVE %02X:%02X:%02X:%02X:%02X:%02X reason %u", e->mac[0], e->mac[1], e->mac[2], e->mac[3], e->mac[4], e->mac[5], e->reason);
}
}, nullptr);
@@ -1,16 +0,0 @@
substitutions:
name: wispdut-aoi
framework: esp-idf
packages:
common: !include dut-common.yaml
wifi:
networks:
- ssid: !secret wisp_up_ssid
password: !secret wisp_up_password
post_connect_roaming: false
ap:
ssid: !secret dut_ap_ssid
password: !secret dut_ap_password
ap_mode: ALWAYS_ON
web_server:
port: 80
@@ -1,16 +0,0 @@
esphome:
name: ${name}
esp32:
board: esp32dev
framework:
type: ${framework}
logger:
level: DEBUG
api:
reboot_timeout: 0s
ota:
- platform: esphome
@@ -1,14 +0,0 @@
substitutions:
name: wispdut-wisp
framework: esp-idf
packages:
common: !include dut-common.yaml
wifi:
networks:
- ssid: !secret wisp_up_ssid
password: !secret wisp_up_password
ap:
ssid: !secret dut_ap_ssid
password: !secret dut_ap_password
ap_mode: WISP
captive_portal:
@@ -1,36 +0,0 @@
#!/bin/bash
# usage: run.sh DUT START SECONDS T:STATE... e.g. run.sh dut-wisp on 600 120:off 300:on
# Builds and flashes DUT.yaml (dut-wisp or dut-always-on) over serial, sets the upstream AP to START, captures the
# DUT and the client, and switches the upstream AP at each T seconds; sum.py summarises the client per phase.
# Copy this directory into a scratch directory with a secrets.yaml (wisp_up_ssid, wisp_up_password, dut_ap_ssid,
# dut_ap_password, lan_probe_url, api_key) and run it there. wisp-upstream.yaml runs on an ethernet ESP32-S3 and
# wispcli.yaml on a D1 mini; flash both once (CLI_FLASH=1 builds and flashes the client too).
# env: TREE (esphome checkout for the DUT), PORT (DUT serial), CLI_PORT (client serial), UPSTREAM (upstream bridge
# address), SC (path to serial_capture.py), PY (checkout python)
D=$(cd "$(dirname "$0")" && pwd); n=${1:?dut yaml name}; start=${2:?on or off}; dur=${3:?seconds}; shift 3
T=${TREE:?set TREE}; P=${PORT:?set PORT}; CP=${CLI_PORT:?set CLI_PORT}; UP=${UPSTREAM:?set UPSTREAM}
SC=${SC:?set SC}; PY=${PY:-$T/venv/bin/python}
ap() { curl -s -m 5 -X POST -d "" -o /dev/null -w "%{http_code}" "http://$UP/button/ap%20$1/press"; }
pkill -f "[s]erial_capture.py ($P|$CP)"
[ "$(ap "$start")" = 200 ] || { echo "FAIL: upstream bridge did not take ap $start"; exit 1; }
if [ "${CLI_FLASH:-0}" = 1 ]; then
(cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs "$D/wispcli.yaml" --device "$CP" > "$D/wispcli-upload.log" 2>&1) \
|| { echo "FAIL: client build or upload"; exit 1; }
fi
(cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs "$D/$n.yaml" --device "$P" > "$D/$n-upload.log" 2>&1) \
|| { echo "FAIL: DUT build or upload"; exit 1; }
echo "$(date +%H:%M:%S) t=0 upstream $start start" > "$D/$n-marks.txt" # phase 1 is the start state
$PY "$SC" "$P" "$dur" "$D/$n-dut.txt" --reset > /dev/null 2>&1 & C1=$!
# no --reset on the client: a CH340 D1 mini floods garbage after an RTS reset
$PY "$SC" "$CP" "$dur" "$D/$n-cli.txt" > /dev/null 2>&1 & C2=$!
trap 'kill $C1 $C2 2>/dev/null' EXIT
t0=$(date +%s)
for step in "$@"; do
t=${step%%:*}; st=${step#*:}
while [ $(($(date +%s) - t0)) -lt "$t" ]; do sleep 1; done
code=$(ap "$st")
echo "$(date +%H:%M:%S) t=$t upstream $st $code" | tee -a "$D/$n-marks.txt"
[ "$code" = 200 ] || { echo "FAIL: upstream bridge returned $code for ap $st"; exit 1; }
done
wait $C1 $C2; trap - EXIT
$PY "$D/sum.py" "$D" "$n"
@@ -1,80 +0,0 @@
"""Summarise the client's results per upstream phase and the DUT's connects, AP starts, crashes and boots.
Usage: sum.py DIR NAME reads NAME-marks.txt, NAME-cli.txt and NAME-dut.txt
Phase N runs from mark N (the first mark is the start state). Exits 1 when the DUT crashed, the DUT capture does not
hold exactly one boot (empty or rebooted), the client had no successful result at all, or a phase with the upstream
on had no successful name lookup (an answer equal to the gateway is the captive portal and does not count)."""
from collections import defaultdict
from pathlib import Path
import re
import sys
D, n = Path(sys.argv[1]), sys.argv[2]
def read(suffix: str) -> str:
return (D / f"{n}-{suffix}.txt").read_text(encoding="utf-8", errors="replace")
mark_lines = [line.split() for line in read("marks").splitlines() if line.strip()]
marks = [m[0] for m in mark_lines]
upstream = {
i + 1: m[3] for i, m in enumerate(mark_lines)
} # "HH:MM:SS t=N upstream on|off CODE"
cli = [line for line in read("cli").splitlines() if "[cli" in line]
def phase(t: str) -> int:
return sum(1 for m in marks if t >= m)
stat: dict[int, dict[str, int]] = defaultdict(lambda: defaultdict(int))
good: dict[int, int] = defaultdict(int) # successful name lookups per phase
any_ok = 0
for line in cli:
m = re.search(r"\]: (NOT CONNECTED|NET .*|NAME \S+|GW \S+|MAC \S+)", line)
if not m:
continue
s = m.group(1)
if s.startswith("NET"):
dns = re.search(r"dns (\S+)", s).group(1)
gw = re.search(r"gw (\S+)", s).group(1)
res = s.split("-> ")[1].strip()
# the captive portal answers every name with the AP's own address; that is not a working upstream
s = f"dns={dns} resolve={'CAPTIVE' if res == gw else res}"
if res not in ("FAIL", gw):
good[phase(line[1:9])] += 1
any_ok += 1
elif re.match(r"(NAME|GW|MAC) 200\b", s):
any_ok += 1
stat[phase(line[1:9])][s] += 1
for p in sorted(stat):
print(f"phase {p} (upstream {upstream.get(p, '?')}):", dict(stat[p]))
dead = [p for p, st in upstream.items() if st == "on" and not good[p]]
dut = read("dut")
crash = len(re.findall(r"Guru|panic|abort\(", dut))
boots = dut.count("ESPHome version")
print(
"DUT connected:",
len(re.findall(r"\[wifi:\d+\]: Connected", dut)),
"AP starts:",
dut.count("Starting AP"),
"crash:",
crash,
"boots:",
boots,
)
failed = []
if not cli:
failed.append("empty client log")
elif not any_ok:
failed.append("the client never got a successful result")
if dead:
failed.append(f"no successful lookup in upstream on phases {dead}")
# run.sh resets the DUT when the capture starts, so a good capture holds exactly one boot
if boots != 1:
failed.append(f"{boots} DUT boots captured, expected 1")
if crash:
failed.append(f"{crash} DUT crash lines")
print("PASS" if not failed else "FAIL: " + "; ".join(failed))
sys.exit(1 if failed else 0)
@@ -1,52 +0,0 @@
esphome:
name: wisp-upstream
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
flash_size: 16MB
framework:
type: esp-idf
logger:
hardware_uart: USB_SERIAL_JTAG
ethernet:
type: W5500
clk_pin: GPIO13
mosi_pin: GPIO11
miso_pin: GPIO12
cs_pin: GPIO14
interrupt_pin: GPIO10
reset_pin: GPIO9
wifi:
ap:
ssid: !secret wisp_up_ssid
password: !secret wisp_up_password
ap_mode: WISP
manual_ip:
static_ip: 10.40.0.1
gateway: 10.40.0.1
subnet: 255.255.255.0
api:
encryption:
key: !secret api_key
reboot_timeout: 0s
ota:
- platform: esphome
web_server:
port: 80
button:
- platform: template
name: ap off
on_press:
- wifi.disable:
- platform: template
name: ap on
on_press:
- wifi.enable:
@@ -1,77 +0,0 @@
esphome:
name: wispcli
esp8266:
board: d1_mini
logger:
level: DEBUG
wifi:
networks:
- ssid: !secret dut_ap_ssid
password: !secret dut_ap_password
reboot_timeout: 0s
ota:
- platform: esphome
http_request:
timeout: 5s
verify_ssl: false
globals:
- id: tick
type: int
initial_value: "0"
interval:
- interval: 3s
then:
- lambda: |-
id(tick)++;
if (id(tick) % 4 != 1) return;
if (!WiFi.isConnected()) { ESP_LOGI("cli", "NOT CONNECTED"); return; }
IPAddress r;
bool ok = WiFi.hostByName("neverssl.com", r, 3000);
ESP_LOGI("cli", "NET ip %s gw %s dns %s | neverssl.com -> %s", WiFi.localIP().toString().c_str(),
WiFi.gatewayIP().toString().c_str(), WiFi.dnsIP(0).toString().c_str(),
ok ? r.toString().c_str() : "FAIL");
- if:
condition:
lambda: return id(tick) % 4 == 2 && WiFi.isConnected();
then:
- http_request.get:
url: http://neverssl.com/
capture_response: true
max_response_buffer_size: 160
collect_headers:
- location
on_response:
- lambda: ESP_LOGI("cli", "NAME %d loc=%s body=%.60s", response->status_code, response->get_response_header("location").c_str(), body.c_str());
on_error:
- lambda: ESP_LOGI("cli", "NAME error");
- if:
condition:
lambda: return id(tick) % 4 == 3 && WiFi.isConnected();
then:
- http_request.get:
url: !lambda 'return std::string("http://") + WiFi.gatewayIP().toString().c_str() + "/";'
capture_response: true
max_response_buffer_size: 160
on_response:
- lambda: ESP_LOGI("cli", "GW %d body=%.60s", response->status_code, body.c_str());
on_error:
- lambda: ESP_LOGI("cli", "GW error");
- if:
condition:
lambda: return id(tick) % 4 == 0 && WiFi.isConnected();
then:
- http_request.get:
url: !secret lan_probe_url
capture_response: true
max_response_buffer_size: 64
on_response:
- lambda: ESP_LOGI("cli", "MAC %d body=%.20s", response->status_code, body.c_str());
on_error:
- lambda: ESP_LOGI("cli", "MAC error");
@@ -1,56 +0,0 @@
"""Summarise the PMF AP's join and leave events per station and the BK7238's joins.
Usage: an.py DIR BUILD [AP_SSID] reads DIR/ap-BUILD.txt and DIR/bk-BUILD.txt (AP_SSID defaults to wd-pmf-ap)
J = join, L<n> = leave with reason n. Reason 3: the station sent a deauth. Reason 2: the AP found a stale
association when the station came back; a run of reason 2 before one join is reconnects the AP rejected.
env DUT_MACS: comma separated station MACs that must appear in the AP events (default: at least two stations).
Exits 1 on a rejected reconnect, an AP that did not switch to WPA2/WPA3, a missing DUT, or a BK7238 that never
joined the AP."""
from itertools import pairwise
import os
from pathlib import Path
import re
import sys
D, b = Path(sys.argv[1]), sys.argv[2]
AP_SSID = sys.argv[3] if len(sys.argv) > 3 else "wd-pmf-ap"
failed = []
ap = (D / f"ap-{b}.txt").read_text(encoding="utf-8", errors="replace").splitlines()
if not any("PMF capable: 0" in line for line in ap) or any(
"AP CONFIG FAILED" in line for line in ap
):
failed.append("the AP did not switch to WPA2/WPA3 mixed")
ev = [line for line in ap if "STA JOIN" in line or "STA LEAVE" in line]
mac_re = re.compile(r"([0-9A-F]{2}(?::[0-9A-F]{2}){5})")
macs = sorted({m.group(1) for line in ev if (m := mac_re.search(line))})
rejected = 0
print(f"== {b}")
for m in macs:
seq = [
"J" if "JOIN" in line else "L" + re.search(r"reason (\d+)", line).group(1)
for line in ev
if m in line
]
# every L2 after the first in a run without a join in between is a rejected reconnect
rej = sum(1 for prev, cur in pairwise(seq) if prev == cur == "L2")
rejected += rej
print(" ", m, " ".join(seq), f"(rejected {rej})")
if rejected:
failed.append(f"{rejected} rejected reconnects")
if want := [
x.strip().upper() for x in os.environ.get("DUT_MACS", "").split(",") if x.strip()
]:
if missing := [x for x in want if x not in macs]:
failed.append(f"no AP events for {', '.join(missing)}")
elif len(macs) < 2:
failed.append(f"AP events for {len(macs)} stations, expected both DUTs")
bk = (D / f"bk-{b}.txt").read_text(encoding="utf-8", errors="replace").splitlines()
joins = [
m.groups() for line in bk if (m := re.search(r"JOINED (.+?) after (\d+) ms", line))
]
print(" bk7238 joins:", " ".join(f"{s}@{int(t) / 1000:.1f}s" for s, t in joins))
print(" bk7238 reboots:", sum("REBOOT on" in line for line in bk))
if not any(s == AP_SSID for s, _ in joins):
failed.append(f"the BK7238 never joined {AP_SSID}")
print("PASS" if not failed else "FAIL: " + "; ".join(failed))
sys.exit(1 if failed else 0)
@@ -1,32 +0,0 @@
#!/bin/bash
# usage: run.sh BUILD flash the PMF AP, OTA the CB3S, serial flash the BK7238, then capture one AP window
# Copy this directory into a scratch directory with a secrets.yaml (wifi_ssid, wifi_password, wd_ap_password) and
# run it there. BUILD is a label (pr, base) for the output files.
# env: TREE (esphome checkout for the DUTs), AP_PORT (classic devkit running wdap.yaml), BK_PORT (BK7238 serial),
# CB_HOST (CB3S address on the house network), SC (path to serial_capture.py), FLASH_BK (path to flash_bk72xx.py),
# LT (LibreTiny penv python, default ~/.platformio/penv/.libretiny/bin/python), PY (checkout python), DUT_MACS
# (optional, comma separated station MACs of both DUTs for an.py)
D=$(cd "$(dirname "$0")" && pwd); b=${1:?build label}; T=${TREE:?set TREE}; PY=${PY:-$T/venv/bin/python}
AP=${AP_PORT:?set AP_PORT}; BK=${BK_PORT:?set BK_PORT}; CB=${CB_HOST:?set CB_HOST}; SC=${SC:?set SC}
FB=${FLASH_BK:?set FLASH_BK}; LT=${LT:-$HOME/.platformio/penv/.libretiny/bin/python}
pkill -f "[s]erial_capture.py ($AP|$BK)"
# retry N PAUSE CMD...: run CMD up to N times, PAUSE seconds apart; fails when every attempt failed
retry() { local n=$1 pause=$2 i; shift 2; for i in $(seq "$n"); do "$@" && return 0; [ "$i" -lt "$n" ] && sleep "$pause"; done; return 1; }
build() { (cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome "$@"); }
build run --no-logs "$D/wdap.yaml" --device "$AP" > "$D/ap-$b-upload.log" 2>&1 || { echo "FAIL: AP build or upload"; exit 1; }
# The DUT reboots 150 s after joining the house network; retry when it reboots mid-upload.
cb_upload() { build run --no-logs "$D/wdcb.yaml" --device "$CB" > "$D/cb-$b-upload.log" 2>&1; }
retry 3 20 cb_upload || { echo "FAIL: CB3S build or OTA after 3 tries"; exit 1; }
build compile "$D/wdbk.yaml" > "$D/bk-$b-build.log" 2>&1 || { echo "FAIL: BK7238 build"; exit 1; }
# The build directory has two .uf2 files; firmware.uf2 is the one to flash.
UF2=$D/.esphome/build/wdbk/.pioenvs/wdbk/firmware.uf2
bk_flash() { $LT "$FB" flash write -d "$BK" "$UF2" > "$D/bk-$b-flash.log" 2>&1; }
retry 4 5 bk_flash || { echo "FAIL: BK7238 flash after 4 tries"; exit 1; }
sleep 20
# The reset restarts the AP's 420 s window.
$PY "$SC" "$AP" 450 "$D/ap-$b.txt" --reset > /dev/null 2>&1 & C1=$!
sleep 8
$PY "$SC" "$BK" 440 "$D/bk-$b.txt" --released > /dev/null 2>&1 & C2=$!
trap 'kill $C1 $C2 2>/dev/null' EXIT
wait $C1 $C2; trap - EXIT
$PY "$D/an.py" "$D" "$b"
@@ -1,44 +0,0 @@
esphome:
name: ${name}
logger:
level: DEBUG
globals:
- id: connect_ms
type: uint32_t
initial_value: "0"
wifi:
networks:
- ssid: ${ap_ssid}
password: !secret wd_ap_password
priority: 10
- ssid: !secret wifi_ssid
password: !secret wifi_password
priority: 0
on_connect:
- lambda: |-
id(connect_ms) = millis();
char buf[wifi::SSID_BUFFER_SIZE];
ESP_LOGI("wd", "JOINED %s after %u ms since boot", wifi::global_wifi_component->wifi_ssid_to(buf), (unsigned) millis());
api:
reboot_timeout: 0s
ota:
- platform: esphome
interval:
- interval: 5s
then:
- lambda: |-
if (!wifi::global_wifi_component->is_connected() || id(connect_ms) == 0)
return;
char buf[wifi::SSID_BUFFER_SIZE];
bool on_ap = strcmp(wifi::global_wifi_component->wifi_ssid_to(buf), "${ap_ssid}") == 0;
uint32_t up = millis() - id(connect_ms);
if (up > (on_ap ? 30000u : 150000u)) {
ESP_LOGI("wd", "REBOOT on %s", on_ap ? "ap" : "home");
App.safe_reboot();
}
@@ -1,59 +0,0 @@
substitutions:
ap_ssid: wd-pmf-ap
esphome:
name: wdap
includes:
- wdap_inc.h
on_boot:
priority: -100
then:
- delay: 3s
- lambda: |-
wifi_config_t c{};
esp_err_t err = esp_wifi_get_config(WIFI_IF_AP, &c);
if (err == ESP_OK) {
c.ap.authmode = WIFI_AUTH_WPA2_WPA3_PSK;
c.ap.pmf_cfg.required = false;
c.ap.sae_pwe_h2e = WPA3_SAE_PWE_BOTH;
c.ap.max_connection = 4;
err = esp_wifi_set_config(WIFI_IF_AP, &c);
}
// read back: only a running AP that reports WPA2/WPA3 mixed counts as switched
wifi_config_t check{};
if (err == ESP_OK)
err = esp_wifi_get_config(WIFI_IF_AP, &check);
if (err != ESP_OK || check.ap.authmode != WIFI_AUTH_WPA2_WPA3_PSK) {
ESP_LOGE("wdap", "AP CONFIG FAILED: %d authmode %d", err, (int) check.ap.authmode);
} else {
ESP_LOGI("wdap", "AP set to WPA2/WPA3 mixed, PMF capable: %d", err);
}
esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, [](void *, esp_event_base_t, int32_t id, void *data) {
if (id == WIFI_EVENT_AP_STACONNECTED) {
auto *e = (wifi_event_ap_staconnected_t *) data;
ESP_LOGI("wdap", "STA JOIN %02X:%02X:%02X:%02X:%02X:%02X aid %u t %u", e->mac[0], e->mac[1], e->mac[2], e->mac[3], e->mac[4], e->mac[5], e->aid, (unsigned) millis());
} else if (id == WIFI_EVENT_AP_STADISCONNECTED) {
auto *e = (wifi_event_ap_stadisconnected_t *) data;
ESP_LOGI("wdap", "STA LEAVE %02X:%02X:%02X:%02X:%02X:%02X aid %u reason %u t %u", e->mac[0], e->mac[1], e->mac[2], e->mac[3], e->mac[4], e->mac[5], e->aid, e->reason, (unsigned) millis());
}
}, nullptr);
- delay: 420s
- lambda: ESP_LOGI("wdap", "AP WINDOW OVER");
- wifi.disable:
esp32:
board: esp32dev
framework:
type: esp-idf
logger:
level: DEBUG
wifi:
ap:
ssid: ${ap_ssid}
password: !secret wd_ap_password
ap_timeout: 0s
ota:
- platform: esphome
@@ -1,3 +0,0 @@
#pragma once
#include <esp_wifi.h>
#include <esp_event.h>
@@ -1,13 +0,0 @@
# BK7238 DUT (NiceMCU XH-WB3S), flashed over serial by run.sh
substitutions:
name: wdbk
ap_ssid: wd-pmf-ap
packages:
common: !include wd-common.yaml
bk72xx:
board: xh-wb3s
logger:
hardware_uart: UART1
@@ -1,13 +0,0 @@
# CB3S (BK7231N) DUT, flashed over the air by run.sh
substitutions:
name: wdcb
ap_ssid: wd-pmf-ap
packages:
common: !include wd-common.yaml
bk72xx:
board: cb3s
logger:
hardware_uart: UART1
@@ -1,76 +0,0 @@
"""Count the Xiaomi, ATC and PVVX publishes in a capture and compare them with expected.json from gen.py.
Usage: count.py CAPTURE [pr|base] [--allow-loss N] [--probe]
--probe (injection run): the PR build must leave the probe frame's service data unchanged.
Exits 0 when every count matches the model (or falls short by at most N over the air), 1 otherwise."""
import argparse
import collections
import json
from pathlib import Path
import re
import sys
ap = argparse.ArgumentParser()
ap.add_argument("capture", type=Path)
ap.add_argument("build", nargs="?", choices=["pr", "base"])
ap.add_argument("--allow-loss", type=int, default=0)
ap.add_argument("--probe", action="store_true")
a = ap.parse_args()
cap, build, loss = a.capture, a.build, a.allow_loss
LETTER = {"0A": "A", "0B": "B", "0C": "C", "0D": "D", "0E": "E", "0F": "F", "10": "G"}
GOT = re.compile(
r"Got (?:Xiaomi \S+|ATC MiThermometer|PVVX MiThermometer) \([AC]4:C1:38:00:00:(\w\w)\)"
)
counts = collections.Counter()
cur = None
humidity = set()
probe = {}
for line in cap.read_text(encoding="utf-8", errors="replace").splitlines():
if m := GOT.search(line):
cur = LETTER[m.group(1)]
continue
if m := re.search(r"\[probe.*\b(before|after)\s+([0-9A-F]+)", line):
probe[m.group(1)] = m.group(2)
if "Humidity:" in line:
humidity.add(line.split("Humidity:")[1].strip())
if (m := re.search(r"Temperature: (-?[\d.]+)", line)) and cur:
t = float(m.group(1))
tag = cur
if (
cur == "A" and t >= 50
): # after-bad frames are 60.1 to 64.0, normal ones 22.1 to 26.0
tag = "A_after_bad"
if cur == "A" and abs(t - 33.3) < 0.01:
tag = "A_probe" # xb_rewrite_probe's own parse, not part of the model
if cur == "D" and abs(t - 11.1) < 0.01:
tag = "D_boot"
counts[tag] += 1
cur = None
print(cap.name, dict(sorted(counts.items())))
print(" humidity values", len(humidity))
if not counts:
print("FAIL: no Xiaomi publishes in the capture")
sys.exit(1)
if build is None:
sys.exit(0)
expected = json.loads(Path("expected.json").read_text(encoding="utf-8"))[build]
bad = 0
for tag in sorted((set(expected) | set(counts)) - {"A_probe"}):
want, got = expected.get(tag, 0), counts.get(tag, 0)
ok = want - loss <= got <= want
bad += not ok
print(f" {tag:12} want {want:3} got {got:3} {'ok' if ok else 'DIFF'}")
if a.probe:
before, after = probe.get("before"), probe.get("after")
print(f" probe before {before}\n probe after {after}")
if before is None or after is None:
print(" probe lines missing")
bad += 1
elif build == "pr" and before != after:
print(" the listener rewrote the shared service data")
bad += 1
print(
f"{'PASS' if not bad else 'FAIL'}: {build} model {'matches' if not bad else f'has {bad} failed checks'}"
)
sys.exit(1 if bad else 0)
@@ -1,256 +0,0 @@
"""Generate MiBeacon, ATC and PVVX advertisements, a C++ header that sends them and the expected publish counts.
Usage: gen.py [--ota]
Without --ota: frames.hpp for xbinject.yaml (scan results injected into the tracker, A4:C1:38 addresses).
With --ota: adv_frames.hpp for xbadv.yaml (a second ESP32 advertises them, C4:C1:38 random static addresses).
Both write expected.json ({"base": {...}, "pr": {...}}) for count.py."""
import json
from pathlib import Path
import struct
import sys
from cryptography.hazmat.primitives.ciphers.aead import AESCCM
OTA = "--ota" in sys.argv[1:]
CYCLES = 40
K1 = bytes.fromhex("6c0d1a3e5b7f29c4a18e90d2f3b46a57")
K2 = bytes.fromhex("0f1e2d3c4b5a69788796a5b4c3d2e1f0")
# Random static addresses need the top two bits set, so the over the air run uses C4 instead of A4.
PREFIX = "C4C138" if OTA else "A4C138"
MACS = {
k: f"{PREFIX}0000{i + 10:02X}" for i, k in enumerate("ABCDEFG")
} # ...0A to ...10
MAC = {k: bytes.fromhex(v) for k, v in MACS.items()}
MIDX = {k: i for i, k in enumerate(MACS)}
def mibeacon_plain(dev, pid, cnt, obj):
return (
bytes([0x50, 0x20])
+ struct.pack("<H", pid)
+ bytes([cnt])
+ MAC[dev][::-1]
+ obj
)
def mibeacon_enc(dev, pid, cnt, obj, key, short, corrupt=False):
ext = bytes([cnt, 0x00, 0x00])
if short:
hdr = bytes([0x48, 0x59]) + struct.pack("<H", pid) + bytes([cnt])
else:
hdr = (
bytes([0x58, 0x58]) + struct.pack("<H", pid) + bytes([cnt]) + MAC[dev][::-1]
)
nonce = MAC[dev][::-1] + hdr[2:5] + ext
ct = AESCCM(key, tag_length=4).encrypt(nonce, obj, b"\x11")
body, tag = ct[:-4], ct[-4:]
if corrupt:
tag = bytes([tag[0] ^ 0xFF]) + tag[1:]
out = hdr + body + ext + tag
assert len(out) == (19 if short else 18 + len(obj)), len(out)
return out
def t16(v): # 0x1004 temperature object
return bytes([0x04, 0x10, 0x02]) + struct.pack("<h", round(v * 10))
def th(t, h): # 0x100D temperature + humidity
return bytes([0x0D, 0x10, 0x04]) + struct.pack("<hh", round(t * 10), round(h * 10))
frames = [] # (dev, kind 0 = FE95 / 1 = 181A, data, tag for the expectation)
frames.append(("D", 0, mibeacon_plain("D", 0x0347, 0, t16(11.1)), "D_boot"))
steps = [len(frames)]
for k in range(1, CYCLES + 1):
frames.append(("B", 0, mibeacon_plain("B", 0x01AA, k, th(20 + k / 10, 40)), "B"))
frames.append(("C", 0, mibeacon_plain("C", 0x0098, k, t16(21 + k / 10)), "C"))
frames.append(
("A", 0, mibeacon_enc("A", 0x055B, k + 100, t16(22 + k / 10), K1, False), "A")
)
if k % 2 == 0:
bad = mibeacon_enc("A", 0x055B, k + 150, t16(60 + k / 10), K1, False, True)
frames.append(("A", 0, bad, "A_bad"))
good = mibeacon_enc("A", 0x055B, k + 150, t16(60 + k / 10), K1, False)
frames.append(("A", 0, good, "A_after_bad"))
frames.append(
("E", 0, mibeacon_enc("E", 0x1203, k + 50, th(23 + k / 10, 50), K2, True), "E")
)
frames.append(("D", 0, mibeacon_plain("D", 0x0347, k + 200, t16(24 + k / 10)), "D"))
atc = (
MAC["F"]
+ struct.pack(">h", round((26 + k / 10) * 10))
+ bytes([45, 90])
+ struct.pack(">H", 2950)
)
frames.append(("F", 1, atc + bytes([k]), "F"))
pv = (
MAC["G"][::-1]
+ struct.pack("<hHH", round((27 + k / 10) * 100), 4600, 3010)
+ bytes([88, k, 0])
)
frames.append(("G", 1, pv, "G"))
steps.append(len(frames))
def simulate(pr):
"""Base keeps one counter shared by every device and checks it before the tag; the PR keeps one per device."""
last_shared = 0
last = {}
pub = {}
for dev, kind, data, tag in frames:
if kind == 1:
cnt = data[12 if dev == "F" else 13]
if last.get(("x", dev)) == cnt:
continue
last["x", dev] = cnt
elif pr:
if last.get(dev) == data[4] or tag == "A_bad":
continue
last[dev] = data[4]
else:
if last_shared == data[4]:
continue
last_shared = data[4]
if tag == "A_bad":
continue
pub[tag] = pub.get(tag, 0) + 1
return pub
expected = {"base": simulate(False), "pr": simulate(True)}
print("base", expected["base"])
print("pr ", expected["pr"])
Path("expected.json").write_text(json.dumps(expected), encoding="utf-8")
def c_rows(name, rows):
out = [f"static const uint8_t {name}[{len(rows)}][{len(rows[0][0])}] = {{"]
out += [
" {" + ",".join(f"0x{b:02X}" for b in row) + "}," + (f" // {c}" if c else "")
for row, c in rows
]
return "\n".join([*out, "};", ""])
mac_rows = [(MAC[k], "") for k in MACS]
frame_rows = [
((bytes([MIDX[d], kind, len(data)]) + data).ljust(28, b"\0"), tag)
for d, kind, data, tag in frames
]
INJECT = """
inline void xb_fill(esphome::esp32_ble::BLEScanResult &r, const uint8_t *mac, bool fe95, const uint8_t *data,
uint8_t len) {
memcpy(r.bda, mac, 6);
r.ble_addr_type = 0;
r.rssi = -50;
uint8_t n = 0;
r.ble_adv[n++] = 2;
r.ble_adv[n++] = 0x01;
r.ble_adv[n++] = 0x06;
r.ble_adv[n++] = len + 3;
r.ble_adv[n++] = 0x16;
r.ble_adv[n++] = fe95 ? 0x95 : 0x1A;
r.ble_adv[n++] = fe95 ? 0xFE : 0x18;
memcpy(r.ble_adv + n, data, len);
r.adv_data_len = n + len;
r.scan_rsp_len = 0;
r.search_evt = ESP_GAP_SEARCH_INQ_RES_EVT;
}
inline void xb_inject(size_t first, size_t last) {
for (size_t i = first; i < last; i++) {
const uint8_t *row = XB_FRAMES[i];
esphome::esp32_ble::BLEScanResult r{};
xb_fill(r, XB_MACS[row[0]], row[1] == 0, row + 3, row[2]);
esphome::esp32_ble_tracker::global_esp32_ble_tracker->gap_scan_event_handler(r);
}
}
inline void xb_hex(const char *label, const std::vector<uint8_t> &v) {
char buf[80];
size_t n = 0;
for (size_t i = 0; i < v.size() && n + 3 < sizeof(buf); i++)
n += snprintf(buf + n, sizeof(buf) - n, "%02X", v[i]);
ESP_LOGI("probe", "%s %s", label, buf);
}
// Parse one encrypted frame with the listener directly and show whether it rewrote the shared service data.
inline void xb_rewrite_probe(esphome::ble_device_base::ESPBTDeviceListener *listener) {
esphome::esp32_ble::BLEScanResult r{};
xb_fill(r, XB_MACS[0], true, XB_PROBE, sizeof(XB_PROBE));
esphome::ble_device_base::ESPBTDevice device;
device.parse_scan_rst(r);
xb_hex("before", device.get_service_datas()[0].data);
bool ok = listener->parse_device(device);
ESP_LOGI("probe", "parse_device returned %d", ok);
xb_hex("after ", device.get_service_datas()[0].data);
}
"""
ADVERTISE = """
// Sends where a GAP call returned an error when it was queued. Failures the controller reports later in the GAP
// completion events are not counted; those frames show up as missing publishes in count.py.
static uint32_t af_failed = 0;
// Returns false (and counts it) when the address, the data or the start was rejected.
inline bool af_send(int i) {
const uint8_t *row = AF_FRAMES[i];
uint8_t adv[31];
uint8_t n = 0;
adv[n++] = 2;
adv[n++] = 0x01;
adv[n++] = 0x06;
adv[n++] = row[2] + 3;
adv[n++] = 0x16;
adv[n++] = row[1] == 0 ? 0x95 : 0x1A;
adv[n++] = row[1] == 0 ? 0xFE : 0x18;
memcpy(adv + n, row + 3, row[2]);
n += row[2];
esp_ble_gap_stop_advertising(); // fails harmlessly when nothing is advertising yet
uint8_t addr[6];
memcpy(addr, AF_MACS[row[0]], 6);
esp_err_t e1 = esp_ble_gap_set_rand_addr(addr);
esp_err_t e2 = esp_ble_gap_config_adv_data_raw(adv, n);
esp_ble_adv_params_t p{};
p.adv_int_min = 0x20; // 20 ms
p.adv_int_max = 0x20;
p.adv_type = ADV_TYPE_NONCONN_IND;
p.own_addr_type = BLE_ADDR_TYPE_RANDOM;
p.channel_map = ADV_CHNL_ALL;
p.adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY;
esp_err_t e3 = esp_ble_gap_start_advertising(&p);
if (e1 == ESP_OK && e2 == ESP_OK && e3 == ESP_OK)
return true;
af_failed++;
ESP_LOGW("af", "frame %d send failed: rand_addr %d adv_data %d start %d", i, e1, e2, e3);
return false;
}
inline void af_stop() { esp_ble_gap_stop_advertising(); }
"""
if OTA:
head = '#pragma once\n#include <esp_gap_ble_api.h>\n#include <cstring>\n#include "esphome/core/log.h"\n'
body = c_rows("AF_MACS", mac_rows) + c_rows("AF_FRAMES", frame_rows)
body += f"static constexpr int AF_COUNT = {len(frames)};\n" + ADVERTISE
Path("adv_frames.hpp").write_text(head + body, encoding="utf-8")
else:
head = '#pragma once\n#include <cstring>\n#include "esphome/components/esp32_ble/ble_scan_result.h"\n'
probe = mibeacon_enc("A", 0x055B, 0xF5, t16(33.3), K1, False)
body = c_rows("XB_MACS", mac_rows) + c_rows("XB_FRAMES", frame_rows)
body += (
f"static const uint16_t XB_STEPS[{len(steps)}] = {{"
+ ",".join(map(str, steps))
+ "};\n"
)
body += f"static constexpr size_t XB_NUM_STEPS = {len(steps)};\n"
body += (
"static const uint8_t XB_PROBE[] = {"
+ ",".join(f"0x{b:02X}" for b in probe)
+ "};\n"
)
Path("frames.hpp").write_text(head + body + INJECT, encoding="utf-8")
print("frames", len(frames), "longest", max(len(d) for _, _, d, _ in frames))
@@ -1,41 +0,0 @@
#!/bin/bash
# usage: run.sh inject|ota pr|base build and flash the receiver, capture its serial log, compare the counts
# Copy this directory into a scratch directory with a secrets.yaml (wifi_ssid, wifi_password, api_key) and run it there.
# inject: xbinject.yaml injects the frames into its own tracker. ota: xbrecv.yaml listens to xbadv.yaml, which must
# already run on a second ESP32 (python gen.py --ota, then flash xbadv.yaml once).
# env: TREE (esphome checkout to build from), PORT (receiver serial port), SC (path to serial_capture.py),
# PY (checkout python), ADV (advertiser address, ota only)
D=$(cd "$(dirname "$0")" && pwd); MODE=${1:?inject or ota}; B=${2:?pr or base}; T=${TREE:?set TREE}
P=${PORT:?set PORT}; SC=${SC:?set SC}; PY=${PY:-$T/venv/bin/python}
cd "$D" || exit 1
if [ "$MODE" = ota ]; then
A=${ADV:?set ADV}; Y=xbrecv; SECS=130; LOSS=2
$PY gen.py --ota > gen.txt || { echo "gen.py failed"; exit 1; }
else
Y=xbinject; SECS=80; LOSS=0; PROBE=--probe
$PY gen.py > gen.txt || { echo "gen.py failed"; exit 1; }
fi
OUT=$D/$MODE-$B; mkdir -p "$OUT"
pkill -f "[s]erial_capture.py $P"
(cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs "$D/$Y.yaml" --device "$P" > "$OUT/upload.log" 2>&1) \
|| { echo "FAIL: build or upload failed, see $OUT/upload.log"; exit 1; }
$PY "$SC" "$P" $SECS "$OUT/cap.txt" --reset > /dev/null 2>&1 & CAP=$!
trap 'kill $CAP 2>/dev/null' EXIT
if [ "$MODE" = ota ]; then
# 18 s lets the receiver boot and start scanning, so the counter 0 boot frame is the first one it hears
sleep 18
CODE=$(curl -s -m 5 -X POST -d "" -o /dev/null -w "%{http_code}" "http://$A/button/start/press")
[ "$CODE" = 200 ] || { echo "FAIL: advertiser start button returned $CODE"; exit 1; }
fi
wait $CAP; trap - EXIT
[ -s "$OUT/cap.txt" ] || { echo "FAIL: serial capture got nothing"; exit 1; }
CRASH=$(grep -acE 'Guru|abort\(\)|Backtrace:' "$OUT/cap.txt")
$PY count.py "$OUT/cap.txt" "$B" --allow-loss $LOSS $PROBE; RC=$?
if [ "$MODE" = ota ]; then
# the advertiser counts frames whose GAP calls failed; those are not radio loss
FAILED=$(curl -s -m 5 "http://$A/sensor/failed%20sends" | $PY -c "import json,sys; print(int(json.load(sys.stdin)['value']))")
echo "advertiser failed sends: ${FAILED:-unknown}"
[ "$FAILED" = 0 ] || RC=1
fi
echo "crash lines: $CRASH"
[ "$RC" = 0 ] && [ "$CRASH" = 0 ]
@@ -1,76 +0,0 @@
esphome:
name: xbadv
includes:
- adv_frames.hpp
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
flash_size: 16MB
framework:
type: esp-idf
logger:
hardware_uart: USB_SERIAL_JTAG
ethernet:
type: W5500
clk_pin: GPIO13
mosi_pin: GPIO11
miso_pin: GPIO12
cs_pin: GPIO14
interrupt_pin: GPIO10
reset_pin: GPIO9
api:
encryption:
key: !secret api_key
reboot_timeout: 0s
ota:
- platform: esphome
web_server:
port: 80
esp32_ble:
globals:
- id: af_idx
type: int
initial_value: "-1"
button:
- platform: template
name: start
on_press:
- lambda: |-
id(af_idx) = 0;
af_failed = 0;
// an unfinished sequence reads as unknown, not as the 0 from an earlier run
id(failed_sends).publish_state(NAN);
ESP_LOGI("af", "sequence start");
interval:
- interval: 250ms
then:
- lambda: |-
int i = id(af_idx);
if (i < 0)
return;
if (i >= AF_COUNT) {
af_stop();
id(af_idx) = -1;
ESP_LOGI("af", "sequence done, %u failed sends", (unsigned) af_failed);
id(failed_sends).publish_state(af_failed);
return;
}
af_send(i);
id(af_idx) = i + 1;
sensor:
- platform: template
id: failed_sends
name: failed sends
accuracy_decimals: 0
update_interval: never
@@ -1,107 +0,0 @@
substitutions:
name: xbinject
esphome:
name: ${name}
includes:
- frames.hpp
esp32:
board: esp32dev
framework:
type: esp-idf
logger:
level: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
reboot_timeout: 0s
ota:
- platform: esphome
globals:
- id: xb_step
type: int
initial_value: "0"
esp32_ble_tracker:
on_ble_service_data_advertise:
- mac_address: A4:C1:38:00:00:0A
service_uuid: FE95
then:
- lambda: |-
char buf[80];
size_t n = 0;
for (size_t i = 0; i < x.size() && n + 3 < sizeof(buf); i++)
n += snprintf(buf + n, sizeof(buf) - n, "%02X", x[i]);
ESP_LOGI("svcA", "len %u data %s", (unsigned) x.size(), buf);
interval:
- interval: 1s
startup_delay: 15s
then:
- lambda: |-
if (id(xb_step) + 1 >= (int) XB_NUM_STEPS) {
if (id(xb_step) + 1 == (int) XB_NUM_STEPS) {
ESP_LOGI("xb", "INJECT DONE");
xb_rewrite_probe(id(xa));
id(xb_step)++;
}
return;
}
int s = id(xb_step);
ESP_LOGI("xb", "inject step %d frames %u..%u", s, XB_STEPS[s], XB_STEPS[s + 1]);
if (s == 0)
xb_inject(0, XB_STEPS[0]);
xb_inject(XB_STEPS[s], XB_STEPS[s + 1]);
id(xb_step)++;
sensor:
- platform: xiaomi_lywsd03mmc
id: xa
mac_address: A4:C1:38:00:00:0A
bindkey: 6c0d1a3e5b7f29c4a18e90d2f3b46a57
temperature:
name: "xA lywsd03mmc temp"
- platform: xiaomi_lywsdcgq
mac_address: A4:C1:38:00:00:0B
temperature:
name: "xB lywsdcgq temp"
humidity:
name: "xB lywsdcgq hum"
- platform: xiaomi_hhccjcy01
mac_address: A4:C1:38:00:00:0C
temperature:
name: "xC hhccjcy01 temp"
- platform: xiaomi_cgg1
mac_address: A4:C1:38:00:00:0D
temperature:
name: "xD cgg1 temp"
- platform: xiaomi_xmwsdj04mmc
mac_address: A4:C1:38:00:00:0E
bindkey: 0f1e2d3c4b5a69788796a5b4c3d2e1f0
temperature:
name: "xE xmwsdj04mmc temp"
humidity:
name: "xE xmwsdj04mmc hum"
- platform: atc_mithermometer
mac_address: A4:C1:38:00:00:0F
temperature:
name: "xF atc temp"
humidity:
name: "xF atc hum"
battery_voltage:
name: "xF atc volt"
- platform: pvvx_mithermometer
mac_address: A4:C1:38:00:00:10
temperature:
name: "xG pvvx temp"
humidity:
name: "xG pvvx hum"
battery_voltage:
name: "xG pvvx volt"
@@ -1,83 +0,0 @@
substitutions:
name: xbrecv
esphome:
name: ${name}
esp32:
board: esp32dev
framework:
type: esp-idf
logger:
level: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
reboot_timeout: 0s
ota:
- platform: esphome
esp32_ble_tracker:
scan_parameters:
interval: 100ms
window: 100ms
active: false
on_ble_service_data_advertise:
- mac_address: C4:C1:38:00:00:0A
service_uuid: FE95
then:
- lambda: |-
char buf[80];
size_t n = 0;
for (size_t i = 0; i < x.size() && n + 3 < sizeof(buf); i++)
n += snprintf(buf + n, sizeof(buf) - n, "%02X", x[i]);
ESP_LOGI("svcA", "len %u data %s", (unsigned) x.size(), buf);
sensor:
- platform: xiaomi_lywsd03mmc
mac_address: C4:C1:38:00:00:0A
bindkey: 6c0d1a3e5b7f29c4a18e90d2f3b46a57
temperature:
name: "xA lywsd03mmc temp"
- platform: xiaomi_lywsdcgq
mac_address: C4:C1:38:00:00:0B
temperature:
name: "xB lywsdcgq temp"
humidity:
name: "xB lywsdcgq hum"
- platform: xiaomi_hhccjcy01
mac_address: C4:C1:38:00:00:0C
temperature:
name: "xC hhccjcy01 temp"
- platform: xiaomi_cgg1
mac_address: C4:C1:38:00:00:0D
temperature:
name: "xD cgg1 temp"
- platform: xiaomi_xmwsdj04mmc
mac_address: C4:C1:38:00:00:0E
bindkey: 0f1e2d3c4b5a69788796a5b4c3d2e1f0
temperature:
name: "xE xmwsdj04mmc temp"
humidity:
name: "xE xmwsdj04mmc hum"
- platform: atc_mithermometer
mac_address: C4:C1:38:00:00:0F
temperature:
name: "xF atc temp"
humidity:
name: "xF atc hum"
battery_voltage:
name: "xF atc volt"
- platform: pvvx_mithermometer
mac_address: C4:C1:38:00:00:10
temperature:
name: "xG pvvx temp"
humidity:
name: "xG pvvx hum"
battery_voltage:
name: "xG pvvx volt"
@@ -1,64 +0,0 @@
# RTL8720C (BW15) crash report test build (#21648): one API button per fault, driven by
# scripts/libretiny_crash/rtl_drive.py. Each boot logs the reboot reason and whether a crash record was found.
substitutions:
name: rtlcrash
esphome:
name: ${name}
on_boot:
priority: -100
then:
- lambda: |-
// "n/a" when the handler is not built (a dev control build), so it is not read as a lost record
const char *rec = "n/a";
#ifdef USE_LIBRETINY_CRASH_HANDLER
rec = esphome::libretiny::crash_handler_has_data() ? "yes" : "no";
#endif
ESP_LOGI("probe", "boot reason %d, crash record %s", (int) lt_get_reboot_reason(), rec);
rtl87xx:
board: bw15
logger:
level: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
reboot_timeout: 0s
ota:
- platform: esphome
button:
- platform: template
name: trap
on_press:
- lambda: __builtin_trap();
- platform: template
name: bus fault
on_press:
- lambda: '*(volatile uint32_t *) 0x70000000 = 1;'
- platform: template
name: wild call
on_press:
- lambda: ((void (*)()) 0xFFFFFFF1)();
- platform: template
name: isr trap
on_press:
- lambda: |-
auto *vt = (uint32_t *) *(volatile uint32_t *) 0xE000ED08;
vt[16 + 11] = (uint32_t) +[]() { __builtin_trap(); };
*(volatile uint32_t *) 0xE000E100 = 1u << 11;
*(volatile uint32_t *) 0xE000E200 = 1u << 11;
- platform: template
name: hang
on_press:
- lambda: |-
__disable_irq();
while (true) {
}
- platform: restart
name: restart
+3 -8
View File
@@ -217,10 +217,9 @@ file does, and it is the authority when they disagree. The most useful starting
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>`, fixed-length binary frames with `FrameReader<N, HEADER...>`; 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. A child sensor that skips repeated
values is `SUB_SENSOR_WITH_DEDUP(name, type)`.
`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.
A child sensor that skips repeated values is `SUB_SENSOR_WITH_DEDUP(name, type)`.
* **An unset float is `NAN`**, not `optional<float>`; the optional costs 4 to 8 bytes of padding per member.
* **`PollingComponent` first runs `update()`** after a random share of the interval; call `this->update()` at the
end of `setup()` when the entities need a state at boot.
@@ -444,16 +443,12 @@ file does, and it is the authority when they disagree. The most useful starting
uses `button.ApplyButton` (`switch.ApplySwitch`, `number.ApplyNumber`, `select.ApplySelect`) in its schema
and `apply=` on the `sub_*` call. The body is a call on the hub with `entity` and `state`, `value` or
`index`/`option` in scope; on a switch, number or select `publish="before"` or `"after"` publishes the state.
`publish="momentary"` is only for legacy action switches (restart, factory reset): it publishes off and runs
the body only when turned on. Never use it for a new entity; an action is a button.
```python
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.
A hub that applies its switches' restored states itself reads `sw->get_initial_write_state()`, which
already applies `inverted`.
- **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`)
+3
View File
@@ -196,6 +196,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/es7243/* @Chorty
@@ -641,7 +642,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
+1 -30
View File
@@ -1,18 +1,15 @@
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass, field
import logging
import math
import string
from typing import Any
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import (
CONF_ABOVE,
CONF_ALL,
CONF_ANY,
CONF_AUTOMATION_ID,
CONF_BELOW,
CONF_CONDITION,
CONF_COUNT,
CONF_ELSE,
@@ -366,25 +363,6 @@ class ApplyField:
return ApplyCall(target, ((self.conf_key, self.type_, self.const_fn),))
def _in_range_low(config: ConfigType, value: float) -> str:
return "-INFINITY" if math.isnan(value) else str(cg.safe_exp(value))
def _in_range_high(config: ConfigType, value: float) -> str:
# With no lower bound, an unset upper bound stays NaN so the check is false, as before.
if math.isnan(value):
return "NAN" if math.isnan(config[CONF_ABOVE]) else "INFINITY"
return str(cg.safe_exp(value))
# ``above <= state <= below`` on the parent's float ``state``; register with ``IN_RANGE_DEFAULTS``.
IN_RANGE_CHECK = ApplyCall(
"state >= {} && {parent}->state <= {}",
((CONF_ABOVE, cg.float_, _in_range_low), (CONF_BELOW, cg.float_, _in_range_high)),
)
IN_RANGE_DEFAULTS = {CONF_ABOVE: math.nan, CONF_BELOW: math.nan}
@dataclass(frozen=True)
class ApplyBytes:
"""A byte payload key forwarded as ``target``, whose ``{}`` stands for ``data, len``.
@@ -713,11 +691,7 @@ async def _render_check(
def register_apply_condition(
name: str,
schema: cv.Schema,
check: str | ApplyCall,
id_key: str = CONF_ID,
defaults: ConfigType | None = None,
name: str, schema: cv.Schema, check: str | ApplyCall, id_key: str = CONF_ID
) -> None:
"""Register a condition that is one expression on its parent, with no C++ class.
@@ -726,7 +700,6 @@ def register_apply_condition(
against config values, all of which must be present. Write ``== false`` to negate.
A ``cg.std_string`` constant stays in flash on ESP8266 and supports only ``==`` or ``!=``
against a ``std::string`` or ``StringRef`` member; other string types are plain literals.
``defaults`` fills absent keys at code generation only, so the validated config is unchanged.
Generates one static predicate and an ``ApplyCondition`` templated on it.
"""
call = check if isinstance(check, ApplyCall) else ApplyCall(check)
@@ -741,8 +714,6 @@ def register_apply_condition(
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
if defaults:
config = {**defaults, **config}
parent = await _apply_parent(config, id_key)
lambda_args = _apply_lambda_args(args)
values = _apply_values(config, members)
+13 -30
View File
@@ -7,7 +7,6 @@ import re
import textwrap
from esphome.build_helpers import pch
from esphome.build_helpers.ccache import CCACHE_PCH_MIN_VERSION
from esphome.components.esp32 import (
KEY_SRC_CMAKE,
get_esp32_variant,
@@ -516,7 +515,7 @@ target_link_options(${{COMPONENT_LIB}} PUBLIC
# Where CMake puts the .gch of the src component; ccache reads the checksum
# next to it in place of the .gch
_PCH_SUM_PATH = "build/esp-idf/src/CMakeFiles/__idf_src.dir/cmake_pch.hxx.gch.sum"
# Where the CMake gate records its choice
# Where the Windows gate records its choice
_PCH_CHOICE_VAR = "ESPHOME_PCH"
@@ -532,32 +531,16 @@ def _pch_cmake_block() -> str:
block = f"""target_precompile_headers(${{COMPONENT_LIB}} PRIVATE
{headers}
)"""
if pch.pch_forced():
if not pch.pch_needs_gcc_check():
return f"\n# ESPHome precompiled header\n{block}\n"
# Only CMake knows the compiler version before the first configure, and
# the ccache IDF found is the one the build runs
gcc_gate = ""
if pch.pch_needs_gcc_check():
gcc_gate = f"""if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND ({pch.PCH_WINDOWS_CMAKE_OLD_GCC}))
message(STATUS "ESPHome: GCC ${{CMAKE_CXX_COMPILER_VERSION}} cannot load a precompiled header on Windows; compiling without it")
set(_esphome_pch OFF)
endif()
"""
min_ccache = ".".join(map(str, CCACHE_PCH_MIN_VERSION))
# Before the first configure only CMake knows the compiler version
return f"""
# ESPHome precompiled header, unless a check below rules it out
set(_esphome_pch ON)
{gcc_gate}if(CCACHE_ENABLE AND CCACHE_FOUND)
execute_process(COMMAND "${{CCACHE_FOUND}}" --version OUTPUT_VARIABLE _esphome_ccache ERROR_QUIET)
if(_esphome_ccache MATCHES "ccache version ([0-9.]+)")
if(CMAKE_MATCH_1 VERSION_LESS {min_ccache})
message(STATUS "ESPHome: ccache ${{CMAKE_MATCH_1}} can reuse stale objects with a precompiled header; compiling without it (upgrade ccache to {min_ccache} or newer, or set ESPHOME_PCH_ENABLE=1 to force)")
set(_esphome_pch OFF)
endif()
endif()
endif()
set({_PCH_CHOICE_VAR} ${{_esphome_pch}} CACHE BOOL "ESPHome precompiled header in use" FORCE)
if(_esphome_pch)
# ESPHome precompiled header, unless GCC bug 14940 keeps it from loading
if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND ({pch.PCH_WINDOWS_CMAKE_OLD_GCC}))
message(STATUS "ESPHome: GCC ${{CMAKE_CXX_COMPILER_VERSION}} cannot load a precompiled header on Windows; compiling without it")
set({_PCH_CHOICE_VAR} OFF CACHE BOOL "ESPHome precompiled header in use" FORCE)
else()
set({_PCH_CHOICE_VAR} ON CACHE BOOL "ESPHome precompiled header in use" FORCE)
{textwrap.indent(block, " ")}
endif()
"""
@@ -574,10 +557,10 @@ def write_pch_checksum() -> None:
if not pch.pch_enabled():
return
# Written whatever the gate chose last time: a build tree without that
# choice, or a reconfigure that turns the pch on, must not see a stale sum
if pch.pch_forced() or get_cmake_cache_value(_PCH_CHOICE_VAR) == "ON":
pch.log_pch_in_use()
# The gate's choice, cached by configure
if pch.pch_needs_gcc_check() and get_cmake_cache_value(_PCH_CHOICE_VAR) != "ON":
return
pch.log_pch_in_use()
checksum = pch.pch_checksum(
CORE.relative_src_path(),
pch.PCH_DEFAULT_HEADERS,
+1 -48
View File
@@ -3,68 +3,21 @@ resolution, and default ``CCACHE_*`` values."""
from __future__ import annotations
import functools
import logging
import os
from pathlib import Path
import re
from esphome.framework_helpers import (
strip_win_long_path_prefix,
tool_version_probe,
tool_version_runs,
)
from esphome.framework_helpers import strip_win_long_path_prefix, tool_version_runs
from esphome.helpers import FALSY_ENV_STRINGS, TRUTHY_ENV_STRINGS
_LOGGER = logging.getLogger(__name__)
# Before 4.7 ccache stores a pch consumer whose header is newer than one
# second without that header, so a later build reuses it after the header
# changed (#21621)
CCACHE_PCH_MIN_VERSION = (4, 7)
_VERSION_RE = re.compile(r"ccache version (\d+(?:\.\d+)*)")
@functools.cache
def _ccache_probe(ccache: str) -> str | Exception:
"""``ccache --version``, run once per binary per process."""
return tool_version_probe(ccache)
def _ccache_runs(ccache: str) -> bool:
"""Return True when the ``ccache`` found on PATH actually runs."""
return tool_version_runs(
ccache,
"Ignoring ccache at %s because it failed to run; compiling without ccache",
probe=_ccache_probe,
)
@functools.cache
def _ccache_too_old_for_pch(ccache: str) -> bool:
output = _ccache_probe(ccache)
if not isinstance(output, str) or (found := _VERSION_RE.search(output)) is None:
return False
if tuple(map(int, found[1].split("."))) >= CCACHE_PCH_MIN_VERSION:
return False
_LOGGER.info(
"ccache %s at %s can reuse stale objects with a precompiled header; "
"compiling without it (upgrade ccache to %s or newer, or set "
"ESPHOME_PCH_ENABLE=1 to force)",
found[1],
ccache,
".".join(map(str, CCACHE_PCH_MIN_VERSION)),
)
return True
def ccache_breaks_pch() -> bool:
"""Whether the ccache this build uses is too old for a precompiled header.
ESP-IDF builds check the ccache IDF finds in CMake instead (build_gen.espidf)."""
return (ccache := resolve_ccache_path()) is not None and _ccache_too_old_for_pch(
ccache
)
+4 -19
View File
@@ -12,11 +12,7 @@ import re
import subprocess
import sys
from esphome.build_helpers.ccache import (
ccache_breaks_pch,
effective_ccache_basedir,
parse_enable_env,
)
from esphome.build_helpers.ccache import effective_ccache_basedir, parse_enable_env
from esphome.const import PLATFORM_NRF52
from esphome.helpers import write_file_if_changed
@@ -121,16 +117,9 @@ def pch_needs_gcc_check() -> bool:
return sys.platform == "win32" and parse_enable_env("ESPHOME_PCH_ENABLE") is None
def _ccache_rules_out_pch() -> bool:
"""An old ccache, unless ``ESPHOME_PCH_ENABLE=1`` forces the pch on.
ESP-IDF runs the same check in CMake (build_gen.espidf)."""
return not pch_forced() and ccache_breaks_pch()
def pch_usable(cxx: Sequence[Path | str]) -> bool:
"""The knob plus the ccache and host rules; ``ESPHOME_PCH_ENABLE=1``
skips the rules."""
if not pch_enabled() or _ccache_rules_out_pch():
"""The knob plus the host rule; ``ESPHOME_PCH_ENABLE=1`` skips the rule."""
if not pch_enabled():
return False
if not pch_needs_gcc_check():
return True
@@ -173,11 +162,7 @@ def pch_script_enabled() -> bool:
"""Whether this PlatformIO build takes the pch script."""
from esphome.core import CORE
return (
pch_enabled()
and CORE.target_platform not in PCH_SCRIPT_EXCLUDED_PLATFORMS
and not _ccache_rules_out_pch()
)
return pch_enabled() and CORE.target_platform not in PCH_SCRIPT_EXCLUDED_PLATFORMS
def pch_header_text(include_headers: Iterable[str]) -> str:
+73 -73
View File
@@ -14,80 +14,80 @@ ESPHOME_LOG_TAG(TAG, "aic3204");
return; \
}
static constexpr i2c::I2CRegValue AIC3204_INIT[] PROGMEM = {
// Set register page to 0
{AIC3204_PAGE_CTRL, 0x00},
// Initiate SW reset (PLL is powered off as part of reset)
{AIC3204_SW_RST, 0x01},
// *** Program clock settings ***
// Default is CODEC_CLKIN is from MCLK pin. Don't need to change this.
// MDAC*NDAC*FOSR*48Khz = mClk (24.576 MHz when the XMOS is expecting 48kHz audio)
// (See page 51 of https://www.ti.com/lit/ml/slaa557/slaa557.pdf)
// We do need MDAC*DOSR/32 >= the resource compute level for the processing block
// So here 2*128/32 = 8, which is equal to processing block 1 's resource compute
// See page 5 of https://www.ti.com/lit/an/slaa404c/slaa404c.pdf for the workflow
// for determining these settings.
// Power up NDAC and set to 2
{AIC3204_NDAC, 0x82},
// Power up MDAC and set to 2
{AIC3204_MDAC, 0x82},
// Program DOSR = 128
{AIC3204_DOSR, 0x80},
// Set Audio Interface Config: I2S, 32 bits, DOUT always driving
{AIC3204_CODEC_IF, 0x30},
// For I2S Firmware only, set SCLK/MFP3 pin as Audio Data In
{AIC3204_SCLK_MFP3, 0x02},
{AIC3204_AUDIO_IF_4, 0x01},
{AIC3204_AUDIO_IF_5, 0x01},
// Program the DAC processing block to be used - PRB_P1
{AIC3204_DAC_SIG_PROC, 0x01},
// *** Select Page 1 ***
{AIC3204_PAGE_CTRL, 0x01},
// Enable the internal AVDD_LDO:
{AIC3204_LDO_CTRL, 0x09},
// *** Program Analog Blocks ***
// Disable Internal Crude AVdd in presence of external AVdd supply or before powering up internal AVdd LDO
{AIC3204_PWR_CFG, 0x08},
// Enable Master Analog Power Control
{AIC3204_LDO_CTRL, 0x01},
// Page 125: Common mode control register, set d6 to 1 to make the full chip common mode = 0.75 v
// We are using the internal AVdd regulator with a nominal output of 1.72 V (see LDO_CTRL_REGISTER on page 123)
// Page 86 says to only set the common mode voltage to 0.9 v if AVdd >= 1.8... but it isn't on our hardware
// We also adjust the HPL and HPR gains to -2dB gian later in this config flow compensate (see page 47)
// (All pages refer to the TLV320AIC3204 Application Reference Guide)
{AIC3204_CM_CTRL, 0x40},
// *** Set PowerTune Modes ***
// Set the Left & Right DAC PowerTune mode to PTM_P3/4. Use Class-AB driver.
{AIC3204_PLAY_CFG1, 0x00},
{AIC3204_PLAY_CFG2, 0x00},
// Set the REF charging time to 40ms
{AIC3204_REF_STARTUP, 0x01},
// HP soft stepping settings for optimal pop performance at power up
// Rpop used is 6k with N = 6 and soft step = 20usec. This should work with 47uF coupling
// capacitor. Can try N=5,6 or 7 time constants as well. Trade-off delay vs “pop” sound.
{AIC3204_HP_START, 0x25},
// Route Left DAC to HPL
{AIC3204_HPL_ROUTE, 0x08},
// Route Right DAC to HPR
{AIC3204_HPR_ROUTE, 0x08},
// Route Left DAC to LOL
{AIC3204_LOL_ROUTE, 0x08},
// Route Right DAC to LOR
{AIC3204_LOR_ROUTE, 0x08},
// Unmute HPL and set gain to -2dB (see comment before configuring the AIC3204_CM_CTRL register)
{AIC3204_HPL_GAIN, 0x3e},
// Unmute HPR and set gain to -2dB (see comment before configuring the AIC3204_CM_CTRL register)
{AIC3204_HPR_GAIN, 0x3e},
// Unmute LOL and set gain to 0dB
{AIC3204_LOL_DRV_GAIN, 0x00},
// Unmute LOR and set gain to 0dB
{AIC3204_LOR_DRV_GAIN, 0x00},
// Power up HPL and HPR, LOL and LOR drivers
{AIC3204_OP_PWR_CTRL, 0x3C},
};
void AIC3204::setup() {
ERROR_CHECK(this->write_registers(AIC3204_INIT), "Failed to initialize");
// Set register page to 0
ERROR_CHECK(this->write_byte(AIC3204_PAGE_CTRL, 0x00), "Set page 0 failed");
// Initiate SW reset (PLL is powered off as part of reset)
ERROR_CHECK(this->write_byte(AIC3204_SW_RST, 0x01), "Software reset failed");
// *** Program clock settings ***
// Default is CODEC_CLKIN is from MCLK pin. Don't need to change this.
// MDAC*NDAC*FOSR*48Khz = mClk (24.576 MHz when the XMOS is expecting 48kHz audio)
// (See page 51 of https://www.ti.com/lit/ml/slaa557/slaa557.pdf)
// We do need MDAC*DOSR/32 >= the resource compute level for the processing block
// So here 2*128/32 = 8, which is equal to processing block 1 's resource compute
// See page 5 of https://www.ti.com/lit/an/slaa404c/slaa404c.pdf for the workflow
// for determining these settings.
// Power up NDAC and set to 2
ERROR_CHECK(this->write_byte(AIC3204_NDAC, 0x82), "Set NDAC failed");
// Power up MDAC and set to 2
ERROR_CHECK(this->write_byte(AIC3204_MDAC, 0x82), "Set MDAC failed");
// Program DOSR = 128
ERROR_CHECK(this->write_byte(AIC3204_DOSR, 0x80), "Set DOSR failed");
// Set Audio Interface Config: I2S, 32 bits, DOUT always driving
ERROR_CHECK(this->write_byte(AIC3204_CODEC_IF, 0x30), "Set CODEC_IF failed");
// For I2S Firmware only, set SCLK/MFP3 pin as Audio Data In
ERROR_CHECK(this->write_byte(AIC3204_SCLK_MFP3, 0x02), "Set SCLK/MFP3 failed");
ERROR_CHECK(this->write_byte(AIC3204_AUDIO_IF_4, 0x01), "Set AUDIO_IF_4 failed");
ERROR_CHECK(this->write_byte(AIC3204_AUDIO_IF_5, 0x01), "Set AUDIO_IF_5 failed");
// Program the DAC processing block to be used - PRB_P1
ERROR_CHECK(this->write_byte(AIC3204_DAC_SIG_PROC, 0x01), "Set DAC_SIG_PROC failed");
// *** Select Page 1 ***
ERROR_CHECK(this->write_byte(AIC3204_PAGE_CTRL, 0x01), "Set page 1 failed");
// Enable the internal AVDD_LDO:
ERROR_CHECK(this->write_byte(AIC3204_LDO_CTRL, 0x09), "Set LDO_CTRL failed");
// *** Program Analog Blocks ***
// Disable Internal Crude AVdd in presence of external AVdd supply or before powering up internal AVdd LDO
ERROR_CHECK(this->write_byte(AIC3204_PWR_CFG, 0x08), "Set PWR_CFG failed");
// Enable Master Analog Power Control
ERROR_CHECK(this->write_byte(AIC3204_LDO_CTRL, 0x01), "Set LDO_CTRL failed");
// Page 125: Common mode control register, set d6 to 1 to make the full chip common mode = 0.75 v
// We are using the internal AVdd regulator with a nominal output of 1.72 V (see LDO_CTRL_REGISTER on page 123)
// Page 86 says to only set the common mode voltage to 0.9 v if AVdd >= 1.8... but it isn't on our hardware
// We also adjust the HPL and HPR gains to -2dB gian later in this config flow compensate (see page 47)
// (All pages refer to the TLV320AIC3204 Application Reference Guide)
ERROR_CHECK(this->write_byte(AIC3204_CM_CTRL, 0x40), "Set CM_CTRL failed");
// *** Set PowerTune Modes ***
// Set the Left & Right DAC PowerTune mode to PTM_P3/4. Use Class-AB driver.
ERROR_CHECK(this->write_byte(AIC3204_PLAY_CFG1, 0x00), "Set PLAY_CFG1 failed");
ERROR_CHECK(this->write_byte(AIC3204_PLAY_CFG2, 0x00), "Set PLAY_CFG2 failed");
// Set the REF charging time to 40ms
ERROR_CHECK(this->write_byte(AIC3204_REF_STARTUP, 0x01), "Set REF_STARTUP failed");
// HP soft stepping settings for optimal pop performance at power up
// Rpop used is 6k with N = 6 and soft step = 20usec. This should work with 47uF coupling
// capacitor. Can try N=5,6 or 7 time constants as well. Trade-off delay vs “pop” sound.
ERROR_CHECK(this->write_byte(AIC3204_HP_START, 0x25), "Set HP_START failed");
// Route Left DAC to HPL
ERROR_CHECK(this->write_byte(AIC3204_HPL_ROUTE, 0x08), "Set HPL_ROUTE failed");
// Route Right DAC to HPR
ERROR_CHECK(this->write_byte(AIC3204_HPR_ROUTE, 0x08), "Set HPR_ROUTE failed");
// Route Left DAC to LOL
ERROR_CHECK(this->write_byte(AIC3204_LOL_ROUTE, 0x08), "Set LOL_ROUTE failed");
// Route Right DAC to LOR
ERROR_CHECK(this->write_byte(AIC3204_LOR_ROUTE, 0x08), "Set LOR_ROUTE failed");
// Unmute HPL and set gain to -2dB (see comment before configuring the AIC3204_CM_CTRL register)
ERROR_CHECK(this->write_byte(AIC3204_HPL_GAIN, 0x3e), "Set HPL_GAIN failed");
// Unmute HPR and set gain to -2dB (see comment before configuring the AIC3204_CM_CTRL register)
ERROR_CHECK(this->write_byte(AIC3204_HPR_GAIN, 0x3e), "Set HPR_GAIN failed");
// Unmute LOL and set gain to 0dB
ERROR_CHECK(this->write_byte(AIC3204_LOL_DRV_GAIN, 0x00), "Set LOL_DRV_GAIN failed");
// Unmute LOR and set gain to 0dB
ERROR_CHECK(this->write_byte(AIC3204_LOR_DRV_GAIN, 0x00), "Set LOR_DRV_GAIN failed");
// Power up HPL and HPR, LOL and LOR drivers
ERROR_CHECK(this->write_byte(AIC3204_OP_PWR_CTRL, 0x3C), "Set OP_PWR_CTRL failed");
// Wait for 2.5 sec for soft stepping to take effect before attempting power-up
this->set_timeout(2500, [this]() {
-4
View File
@@ -90,10 +90,6 @@ class AirtonClimate : public climate_ir::ClimateIR {
bool set_display_state(bool state);
bool set_vertical_direction_state(VerticalDirection direction);
void send_settings();
void control_vertical_direction(VerticalDirection direction) {
if (this->set_vertical_direction_state(direction))
this->send_settings();
}
void set_setting(AirtonSetting setting, bool state) {
const bool changed = setting == AirtonSetting::AIRTON_SETTING_DISPLAY ? this->set_display_state(state)
: this->set_sleep_mode_state(state);
+4 -4
View File
@@ -9,16 +9,18 @@ from ..climate import (
CONF_VERTICAL_DIRECTION,
VERTICAL_DIRECTIONS,
AirtonClimate,
VerticalDirection,
airton_ns,
)
CODEOWNERS = ["@Procsiab"]
VerticalDirectionSelect = airton_ns.class_("VerticalDirectionSelect", select.Select)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_AIRTON_ID): cv.use_id(AirtonClimate),
cv.Optional(CONF_VERTICAL_DIRECTION): select.select_schema(
select.ApplySelect,
VerticalDirectionSelect,
entity_category=ENTITY_CATEGORY_CONFIG,
),
}
@@ -32,6 +34,4 @@ async def to_code(config: ConfigType) -> None:
CONF_VERTICAL_DIRECTION,
hub.set_vertical_direction_select,
options=list(VERTICAL_DIRECTIONS),
# The options are in the order of the enum; the hub publishes the state
apply=f"control_vertical_direction(static_cast<{VerticalDirection}>(index))",
)
@@ -0,0 +1,17 @@
#pragma once
#include "esphome/components/select/select.h"
#include "../airton.h"
namespace esphome::airton {
class VerticalDirectionSelect : public select::Select, public Parented<AirtonClimate> {
protected:
// The options are in the order of the enum, so the index is the direction; the parent publishes the state
void control(size_t index) override {
if (this->parent_->set_vertical_direction_state(static_cast<VerticalDirection>(index)))
this->parent_->send_settings();
}
};
} // namespace esphome::airton
@@ -1,6 +1,7 @@
from esphome import automation
from esphome.automation import maybe_simple_id
import esphome.codegen as cg
from esphome.components import mqtt, web_server
import esphome.config_validation as cv
from esphome.const import (
CONF_CODE,
@@ -9,11 +10,11 @@ from esphome.const import (
CONF_ID,
CONF_MQTT_ID,
CONF_ON_STATE,
CONF_WEB_SERVER,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority, entity_helpers
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import (
entity_duplicate_validator,
mqtt_component_class,
queue_entity_register,
setup_entity,
)
@@ -43,12 +44,12 @@ AlarmControlPanelState = alarm_control_panel_ns.enum("AlarmControlPanelState")
_ALARM_CONTROL_PANEL_SCHEMA = (
cv.ENTITY_BASE_SCHEMA.extend(entity_helpers.WEBSERVER_SORTING_SCHEMA)
cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA)
.extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA)
.extend(
{
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(
mqtt_component_class("MQTTAlarmControlPanelComponent")
mqtt.MQTTAlarmControlPanelComponent
),
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
cv.Optional(CONF_ON_TRIGGERED): automation.validate_automation({}),
@@ -165,7 +166,11 @@ _CALLBACK_AUTOMATIONS = (
@setup_entity("alarm_control_panel")
async def setup_alarm_control_panel_core_(var, config):
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
await entity_helpers.setup_entity_integrations(var, config)
if web_server_config := config.get(CONF_WEB_SERVER):
await web_server.add_entity_config(var, web_server_config)
if mqtt_id := config.get(CONF_MQTT_ID):
mqtt_ = cg.new_Pvariable(mqtt_id, var)
await mqtt.register_mqtt_component(mqtt_, config)
async def register_alarm_control_panel(var, config):
+73 -62
View File
@@ -11,52 +11,7 @@ ESPHOME_LOG_TAG(TAG, "apds9960");
this->mark_failed(LOG_STR("Setting up APDS9960 registers failed")); \
return; \
}
static constexpr i2c::I2CRegValue APDS9960_TIMING[] PROGMEM = {
// ATime (ADC integration time, 2.78ms increments, 0x81) -> 0xDB (103ms)
{0x81, 0xDB},
// WTime (Wait time, 0x83) -> 0xF6 (27ms)
{0x83, 0xF6},
// PPulse (0x8E) -> 0x87 (16us, 8 pulses)
{0x8E, 0x87},
// POffset UR (0x9D) -> 0 (no offset)
{0x9D, 0x00},
// POffset DL (0x9E) -> 0 (no offset)
{0x9E, 0x00},
// Config 1 (0x8D) -> 0x60 (no wtime factor)
{0x8D, 0x60},
};
static constexpr i2c::I2CRegValue APDS9960_INTERRUPT_GESTURE[] PROGMEM = {
// Pers (0x8C) -> 0x11 (2 consecutive proximity or ALS for interrupt)
{0x8C, 0x11},
// Config 2 (0x90) -> 0x01 (no saturation interrupts or LED boost)
{0x90, 0x01},
// Config 3 (0x9F) -> 0x00 (enable all photodiodes, no SAI)
{0x9F, 0x00},
// GPenTh (0xA0, gesture enter threshold) -> 0x28 (also 0x32)
{0xA0, 0x28},
// GPexTh (0xA1, gesture exit threshold) -> 0x1E
{0xA1, 0x1E},
// GConf 1 (0xA2, gesture config 1) -> 0x40 (4 gesture events for interrupt (GFIFO 3), 1 for exit)
{0xA2, 0x40},
};
static constexpr i2c::I2CRegValue APDS9960_GESTURE_OFFSETS[] PROGMEM = {
// GOffsetU (0xA4) -> 0x00 (no offset)
{0xA4, 0x00},
// GOffsetD (0xA5) -> 0x00 (no offset)
{0xA5, 0x00},
// GOffsetL (0xA7) -> 0x00 (no offset)
{0xA7, 0x00},
// GOffsetR (0xA9) -> 0x00 (no offset)
{0xA9, 0x00},
// GPulse (0xA6) -> 0xC9 (32 µs, 10 pulses)
{0xA6, 0xC9},
// GConf 3 (0xAA, gesture config 3) -> 0x00 (all photodiodes active during gesture, all gesture dimensions enabled)
// 0x00 -> all dimensions, 0x01 -> up down, 0x02 -> left right
{0xAA, 0x00},
};
#define APDS9960_WRITE_BYTE(reg, value) APDS9960_ERROR_CHECK(this->write_byte(reg, value));
void APDS9960::setup() {
uint8_t id;
@@ -70,27 +25,83 @@ void APDS9960::setup() {
return;
}
APDS9960_ERROR_CHECK(this->write_registers(APDS9960_TIMING));
// ATime (ADC integration time, 2.78ms increments, 0x81) -> 0xDB (103ms)
APDS9960_WRITE_BYTE(0x81, 0xDB);
// WTime (Wait time, 0x83) -> 0xF6 (27ms)
APDS9960_WRITE_BYTE(0x83, 0xF6);
// PPulse (0x8E) -> 0x87 (16us, 8 pulses)
APDS9960_WRITE_BYTE(0x8E, 0x87);
// POffset UR (0x9D) -> 0 (no offset)
APDS9960_WRITE_BYTE(0x9D, 0x00);
// POffset DL (0x9E) -> 0 (no offset)
APDS9960_WRITE_BYTE(0x9E, 0x00);
// Config 1 (0x8D) -> 0x60 (no wtime factor)
APDS9960_WRITE_BYTE(0x8D, 0x60);
// Control (0x8F): led drive (0 -> 100mA, 1 -> 50mA, 2 -> 25mA, 3 -> 12.5mA),
// proximity gain (0 -> 1x, 1 -> 2X, 2 -> 4X, 3 -> 8X), ambient light gain (0 -> 1x, 1 -> 4x, 2 -> 16x, 3 -> 64x)
APDS9960_ERROR_CHECK(this->update_register(
0x8F, 0b11001111,
(this->led_drive_ & 0b11) << 6 | (this->proximity_gain_ & 0b11) << 2 | (this->ambient_gain_ & 0b11)));
// Control (0x8F) ->
uint8_t val = 0;
APDS9960_ERROR_CHECK(this->read_byte(0x8F, &val));
val &= 0b00111111;
// led drive, 0 -> 100mA, 1 -> 50mA, 2 -> 25mA, 3 -> 12.5mA
val |= (this->led_drive_ & 0b11) << 6;
APDS9960_ERROR_CHECK(this->write_registers(APDS9960_INTERRUPT_GESTURE));
val &= 0b11110011;
// proximity gain, 0 -> 1x, 1 -> 2X, 2 -> 4X, 3 -> 8X
val |= (this->proximity_gain_ & 0b11) << 2;
// GConf 2 (0xA3): gesture gain (0 -> 1x, 1 -> 2x, 2 -> 4x, 3 -> 8x),
// gesture led drive (0 -> 100mA, 1 -> 50mA, 2 -> 25mA, 3 -> 12.5mA), gesture wait time
// (0 -> 0ms, 1 -> 2.8ms, 2 -> 5.6ms, 3 -> 8.4ms, 4 -> 14.0ms, 5 -> 22.4 ms, 6 -> 30.8ms, 7 -> 39.2 ms)
APDS9960_ERROR_CHECK(this->update_register(
0xA3, 0b01111111,
(this->gesture_gain_ & 0b11) << 5 | (this->gesture_led_drive_ & 0b11) << 3 | (this->gesture_wait_time_ & 0b111)));
val &= 0b11111100;
// ambient light gain, 0 -> 1x, 1 -> 4x, 2 -> 16x, 3 -> 64x
val |= (this->ambient_gain_ & 0b11) << 0;
APDS9960_WRITE_BYTE(0x8F, val);
APDS9960_ERROR_CHECK(this->write_registers(APDS9960_GESTURE_OFFSETS));
// Pers (0x8C) -> 0x11 (2 consecutive proximity or ALS for interrupt)
APDS9960_WRITE_BYTE(0x8C, 0x11);
// Config 2 (0x90) -> 0x01 (no saturation interrupts or LED boost)
APDS9960_WRITE_BYTE(0x90, 0x01);
// Config 3 (0x9F) -> 0x00 (enable all photodiodes, no SAI)
APDS9960_WRITE_BYTE(0x9F, 0x00);
// GPenTh (0xA0, gesture enter threshold) -> 0x28 (also 0x32)
APDS9960_WRITE_BYTE(0xA0, 0x28);
// GPexTh (0xA1, gesture exit threshold) -> 0x1E
APDS9960_WRITE_BYTE(0xA1, 0x1E);
// GConf 1 (0xA2, gesture config 1) -> 0x40 (4 gesture events for interrupt (GFIFO 3), 1 for exit)
APDS9960_WRITE_BYTE(0xA2, 0x40);
// GConf 2 (0xA3, gesture config 2) ->
APDS9960_ERROR_CHECK(this->read_byte(0xA3, &val));
val &= 0b10011111;
// gesture gain, 0 -> 1x, 1 -> 2x, 2 -> 4x, 3 -> 8x
val |= (this->gesture_gain_ & 0b11) << 5;
val &= 0b11100111;
// gesture led drive, 0 -> 100mA, 1 -> 50mA, 2 -> 25mA, 3 -> 12.5mA
val |= (this->gesture_led_drive_ & 0b11) << 3;
val &= 0b11111000;
// gesture wait time
// 0 -> 0ms, 1 -> 2.8ms, 2 -> 5.6ms, 3 -> 8.4ms
// 4 -> 14.0ms, 5 -> 22.4 ms, 6 -> 30.8ms, 7 -> 39.2 ms
val |= (this->gesture_wait_time_ & 0b111) << 0;
APDS9960_WRITE_BYTE(0xA3, val);
// GOffsetU (0xA4) -> 0x00 (no offset)
APDS9960_WRITE_BYTE(0xA4, 0x00);
// GOffsetD (0xA5) -> 0x00 (no offset)
APDS9960_WRITE_BYTE(0xA5, 0x00);
// GOffsetL (0xA7) -> 0x00 (no offset)
APDS9960_WRITE_BYTE(0xA7, 0x00);
// GOffsetR (0xA9) -> 0x00 (no offset)
APDS9960_WRITE_BYTE(0xA9, 0x00);
// GPulse (0xA6) -> 0xC9 (32 µs, 10 pulses)
APDS9960_WRITE_BYTE(0xA6, 0xC9);
// GConf 3 (0xAA, gesture config 3) -> 0x00 (all photodiodes active during gesture, all gesture dimensions enabled)
// 0x00 -> all dimensions, 0x01 -> up down, 0x02 -> left right
APDS9960_WRITE_BYTE(0xAA, 0x00);
// Enable (0x80) ->
uint8_t val = 0;
val = 0;
val |= (0b1) << 0; // power on
val |= (this->is_color_enabled_() & 0b1) << 1;
val |= (this->is_proximity_enabled_() & 0b1) << 2;
@@ -98,7 +109,7 @@ void APDS9960::setup() {
val |= 0b0 << 4; // color interrupt disabled
val |= 0b0 << 5; // proximity interrupt disabled
val |= (this->is_gesture_enabled_() & 0b1) << 6; // proximity is required for gestures
APDS9960_ERROR_CHECK(this->write_byte(0x80, val));
APDS9960_WRITE_BYTE(0x80, val);
}
bool APDS9960::is_color_enabled_() const {
#ifdef USE_SENSOR
+8 -4
View File
@@ -174,10 +174,14 @@ def _register_provisioning_source(config: ConfigType) -> ConfigType:
CONF_SUPPORTS_RESPONSE = "supports_response"
SupportsResponseType = api_ns.namespace("enums").enum("SupportsResponseType")
SUPPORTS_RESPONSE_OPTIONS = SupportsResponseType.options(
"none", "optional", "only", "status", prefix="SUPPORTS_RESPONSE_"
)
# Enum values in api::enums namespace
enums_ns = api_ns.namespace("enums")
SUPPORTS_RESPONSE_OPTIONS = {
"none": enums_ns.SUPPORTS_RESPONSE_NONE,
"optional": enums_ns.SUPPORTS_RESPONSE_OPTIONAL,
"only": enums_ns.SUPPORTS_RESPONSE_ONLY,
"status": enums_ns.SUPPORTS_RESPONSE_STATUS,
}
def _auto_detect_supports_response(config: ConfigType) -> ConfigType:
+2 -11
View File
@@ -1555,17 +1555,8 @@ void APIConnection::on_water_heater_command_request(const WaterHeaterCommandRequ
// Event is a special case - unlike other entities with simple state fields,
// events store their state in a member accessed via obj->get_last_event_type()
void APIConnection::send_event(event::Event *event) {
const uint8_t type_index = event->get_last_event_type_index();
const size_t size_before = this->deferred_batch_.size();
if (this->deferred_batch_.contains(event, EventResponse::MESSAGE_TYPE)) {
// Sending now would overtake the older event of this entity that still waits in the batch
this->schedule_message_(event, EventResponse::MESSAGE_TYPE, EventResponse::ESTIMATED_SIZE, type_index);
} else {
this->send_message_smart_(event, EventResponse::MESSAGE_TYPE, EventResponse::ESTIMATED_SIZE, type_index);
}
// Events are rare, so a queued one sends the batch now rather than after batch_delay
if (this->deferred_batch_.size() > size_before)
this->make_batch_due_now_();
this->send_message_smart_(event, EventResponse::MESSAGE_TYPE, EventResponse::ESTIMATED_SIZE,
event->get_last_event_type_index());
}
uint16_t APIConnection::try_send_event_response(event::Event *event, StringRef event_type, APIConnection *conn,
uint32_t remaining_size) {
+68 -5
View File
@@ -2,7 +2,6 @@
#include "esphome/core/defines.h"
#ifdef USE_API
#include "api_deferred_batch.h"
#include "api_frame_helper.h"
#ifdef USE_API_NOISE
#include "api_frame_helper_noise.h"
@@ -21,6 +20,7 @@
#include "esphome/core/string_ref.h"
#include <functional>
#include <limits>
#include <vector>
namespace esphome {
@@ -656,6 +656,73 @@ class APIConnection final : public APIServerConnectionBase {
// Function pointer type for message encoding
using MessageCreatorPtr = uint16_t (*)(EntityBase *, APIConnection *, uint32_t remaining_size);
// Generic batching mechanism for both state updates and entity info
struct DeferredBatch {
// Sentinel value for unused aux_data_index
static constexpr uint8_t AUX_DATA_UNUSED = std::numeric_limits<uint8_t>::max();
struct BatchItem {
EntityBase *entity; // 4 bytes - Entity pointer
uint16_t message_type; // 2 bytes - Message type for protocol and dispatch
uint8_t estimated_size; // 1 byte - Estimated message size (max 255 bytes)
uint8_t aux_data_index{AUX_DATA_UNUSED}; // 1 byte - For events: index into entity's event_types
};
std::vector<BatchItem> items;
uint32_t batch_start_time{0};
// No pre-allocation - log connections never use batching, and for
// connections that do, buffers are released after initial sync anyway
// Add item to the batch (with deduplication)
void add_item(EntityBase *entity, uint16_t message_type, uint8_t estimated_size,
uint8_t aux_data_index = AUX_DATA_UNUSED) {
// Dedup: O(n) scan but optimized for RAM over performance
// Skip deduplication for events - they are edge-triggered, every occurrence matters
#ifdef USE_EVENT
if (message_type != EventResponse::MESSAGE_TYPE)
#endif
{
for (const auto &item : this->items) {
if (item.entity == entity && item.message_type == message_type)
return; // Already queued
}
}
this->items.push_back({entity, message_type, estimated_size, aux_data_index});
}
// Add item to the front of the batch (for high priority messages like ping)
void add_item_front(EntityBase *entity, uint16_t message_type, uint8_t estimated_size) {
// Swap to front avoids expensive vector::insert which shifts all elements
this->items.push_back({entity, message_type, estimated_size, AUX_DATA_UNUSED});
if (this->items.size() > 1) {
std::swap(this->items.front(), this->items.back());
}
}
// Clear all items
void clear() {
items.clear();
batch_start_time = 0;
}
// Remove processed items from the front — noinline to keep memmove out of warm callers
void remove_front(size_t count) __attribute__((noinline)) { items.erase(items.begin(), items.begin() + count); }
bool empty() const { return items.empty(); }
size_t size() const { return items.size(); }
const BatchItem &operator[](size_t index) const { return items[index]; }
// Release excess capacity - only releases if items already empty
void release_buffer() {
// Safe to call: batch is processed before release_buffer is called,
// and if any items remain (partial processing), we must not clear them.
// Use swap trick since shrink_to_fit() is non-binding and may be ignored.
if (items.empty()) {
std::vector<BatchItem>().swap(items);
}
}
};
// DeferredBatch here (16 bytes, 4-byte aligned)
DeferredBatch deferred_batch_;
@@ -728,10 +795,6 @@ class APIConnection final : public APIServerConnectionBase {
static constexpr size_t MAX_BATCH_PACKET_SIZE = 1390; // MTU
bool schedule_batch_();
// Sends the batch on the next loop, so a queued event does not wait for batch_delay; queued states go with it
void make_batch_due_now_() {
this->deferred_batch_.batch_start_time = App.get_loop_component_start_time() - this->get_batch_delay_ms_();
}
void process_batch_();
void process_batch_multi_(APIBuffer &shared_buf, size_t num_items, uint8_t header_padding, uint8_t footer_size)
__attribute__((noinline));
@@ -1,88 +0,0 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_API
#include "api_pb2.h"
#include "esphome/core/entity_base.h"
#include <algorithm>
#include <cstdint>
#include <limits>
#include <vector>
namespace esphome::api {
// Generic batching mechanism for both state updates and entity info
struct DeferredBatch {
// Sentinel value for unused aux_data_index
static constexpr uint8_t AUX_DATA_UNUSED = std::numeric_limits<uint8_t>::max();
struct BatchItem {
EntityBase *entity; // 4 bytes - Entity pointer
uint16_t message_type; // 2 bytes - Message type for protocol and dispatch
uint8_t estimated_size; // 1 byte - Estimated message size (max 255 bytes)
uint8_t aux_data_index{AUX_DATA_UNUSED}; // 1 byte - For events: index into entity's event_types
};
std::vector<BatchItem> items;
uint32_t batch_start_time{0};
// No pre-allocation - log connections never use batching, and for
// connections that do, buffers are released after initial sync anyway
// O(n) scan but optimized for RAM over performance
bool contains(EntityBase *entity, uint16_t message_type) const {
for (const auto &item : this->items) {
if (item.entity == entity && item.message_type == message_type)
return true;
}
return false;
}
// Add item to the batch (with deduplication)
void add_item(EntityBase *entity, uint16_t message_type, uint8_t estimated_size,
uint8_t aux_data_index = AUX_DATA_UNUSED) {
// Skip deduplication for events - they are edge-triggered, every occurrence matters
#ifdef USE_EVENT
if (message_type != EventResponse::MESSAGE_TYPE)
#endif
{
if (this->contains(entity, message_type))
return; // Already queued
}
this->items.push_back({entity, message_type, estimated_size, aux_data_index});
}
// Add item to the front of the batch (for high priority messages like ping)
void add_item_front(EntityBase *entity, uint16_t message_type, uint8_t estimated_size) {
// Shift rather than swap, so the queued items keep their order (two events of one entity must not swap)
this->items.push_back({});
std::move_backward(this->items.begin(), this->items.end() - 1, this->items.end());
this->items.front() = {entity, message_type, estimated_size, AUX_DATA_UNUSED};
}
// Clear all items
void clear() {
items.clear();
batch_start_time = 0;
}
// Remove processed items from the front — noinline to keep memmove out of warm callers
void remove_front(size_t count) __attribute__((noinline)) { items.erase(items.begin(), items.begin() + count); }
bool empty() const { return items.empty(); }
size_t size() const { return items.size(); }
const BatchItem &operator[](size_t index) const { return items[index]; }
// Release excess capacity - only releases if items already empty
void release_buffer() {
// Safe to call: batch is processed before release_buffer is called,
// and if any items remain (partial processing), we must not clear them.
// Use swap trick since shrink_to_fit() is non-binding and may be ignored.
if (items.empty()) {
std::vector<BatchItem>().swap(items);
}
}
};
} // namespace esphome::api
#endif // USE_API
+13 -5
View File
@@ -6,11 +6,19 @@ namespace esphome::as3935_i2c {
ESPHOME_LOG_TAG(TAG, "as3935_i2c");
void I2CAS3935Component::write_register(uint8_t reg, uint8_t mask, uint8_t bits, uint8_t start_pos) {
const uint8_t value = bits << start_pos;
// Bits of value outside mask were ORed in before, so they stay part of the update
// Also called at runtime, so a transient bus error only warns, like read_register()
if (!this->update_register(reg, mask | value, value)) {
ESP_LOGW(TAG, "Failed to update register 0x%02X", reg);
uint8_t write_reg;
if (!this->read_byte(reg, &write_reg)) {
this->mark_failed();
ESP_LOGW(TAG, "read_byte failed - increase log level for more details!");
return;
}
write_reg &= (~mask);
write_reg |= (bits << start_pos);
if (!this->write_byte(reg, write_reg)) {
ESP_LOGW(TAG, "write_byte failed - increase log level for more details!");
return;
}
}
+8 -2
View File
@@ -240,11 +240,17 @@ bool AS7341Component::write_register_bit(uint8_t address, bool value, uint8_t bi
}
bool AS7341Component::set_register_bit(uint8_t address, uint8_t bit_position) {
return this->update_register(address, 1 << bit_position, 1 << bit_position);
uint8_t data;
this->read_byte(address, &data);
data |= (1 << bit_position);
return this->write_byte(address, data);
}
bool AS7341Component::clear_register_bit(uint8_t address, uint8_t bit_position) {
return this->update_register(address, 1 << bit_position, 0);
uint8_t data;
this->read_byte(address, &data);
data &= ~(1 << bit_position);
return this->write_byte(address, data);
}
} // namespace esphome::as7341
+12 -8
View File
@@ -54,8 +54,7 @@ static const char AS7341_NAME[] PROGMEM = "AS7341";
static constexpr uint8_t AS7341_ADC_SLOTS = 6;
static constexpr uint8_t AS7341_SMUX_LEN = 20;
static constexpr uint8_t AS7341_SMUX_STEPS = 2;
static constexpr i2c::I2CRegValue AS7341_INIT[] PROGMEM = {
{0x70, 0x00}}; // CONFIG: SPM mode, INT_SEL and LED_SEL cleared
static const RegValue AS7341_INIT[] PROGMEM = {{0x70, 0x00}}; // CONFIG: SPM mode, INT_SEL and LED_SEL cleared
static const uint8_t AS7341_SMUX[] PROGMEM = {
0x30, 0x01, 0x00, 0x00, 0x00, 0x42, 0x00, 0x00, 0x50, 0x00, // step 0: F1 to F4, NIR, Clear
0x00, 0x00, 0x20, 0x04, 0x00, 0x30, 0x01, 0x50, 0x00, 0x06, // step 0, continued
@@ -151,8 +150,8 @@ static constexpr uint8_t AS7343_ADC_SLOTS = 18;
static constexpr uint8_t AS7343_SMUX_LEN = 10;
static const char TCS3448_NAME[] PROGMEM = "TCS3448";
// CFG6, FD_CFG0, CFG10, CFG8, CFG20 (auto_smux, 18 channels in 3 cycles), AGC_GAIN_MAX, FD_TIME_1, FD_TIME_2
static constexpr i2c::I2CRegValue AS7343_INIT[] PROGMEM = {{0xF5, 0x00}, {0xDF, 0xA1}, {0x65, 0xF2}, {0xC9, 0xC8},
{0xD6, 0x62}, {0xD7, 0x99}, {0xE0, 0x64}, {0xE2, 0x21}};
static const RegValue AS7343_INIT[] PROGMEM = {{0xF5, 0x00}, {0xDF, 0xA1}, {0x65, 0xF2}, {0xC9, 0xC8},
{0xD6, 0x62}, {0xD7, 0x99}, {0xE0, 0x64}, {0xE2, 0x21}};
static const uint8_t AS7343_SMUX[] PROGMEM = {0x00, 0x04, 0x65, 0x02, 0x00, 0x05, 0x00, 0x01, 0x00, 0x30,
0x00, 0x00, 0x60, 0x20, 0x04, 0x50, 0x03, 0x00, 0x01, 0x05,
0x05, 0x00, 0x60, 0x00, 0x30, 0x00, 0x40, 0x10, 0x20, 0x00};
@@ -231,7 +230,7 @@ static constexpr ChipInfo as7343_chip(const char *name) {
.gain_correction = AS7343_GAIN_CORRECTION,
.contributions = AS7343_CONTRIBUTIONS,
.tristimulus = AS7343_TRISTIMULUS,
.init_count = sizeof(AS7343_INIT) / sizeof(i2c::I2CRegValue),
.init_count = sizeof(AS7343_INIT) / sizeof(RegValue),
.astep_reg = 0xD4,
.cfg0_reg = 0xBF,
.cfg1_reg = 0xC6,
@@ -265,7 +264,12 @@ bool AS734XComponent::select_bank_(uint8_t reg) {
}
bool AS734XComponent::update_bits_(uint8_t reg, uint8_t mask, bool value) {
return this->select_bank_(reg) && this->update_register(reg, mask, value ? mask : 0);
uint8_t data = 0;
if (!this->select_bank_(reg) || !this->read_byte(reg, &data)) {
return false;
}
data = value ? (data | mask) : (data & ~mask);
return this->write_byte(reg, data);
}
// The exposure is applied before every measurement, so a gain, ATIME or ASTEP changed at runtime
@@ -299,9 +303,9 @@ void AS734XComponent::setup() {
this->mark_failed(LOG_STR("Wrong chip ID"));
return;
}
const i2c::I2CRegValue *init = pgm(chip->init);
const RegValue *init = pgm(chip->init);
for (uint8_t i = 0, n = pgm(chip->init_count); ok && i < n; i++) {
i2c::I2CRegValue rv;
RegValue rv;
progmem_memcpy(&rv, &init[i], sizeof(rv));
ok = this->select_bank_(rv.reg) && this->write_byte(rv.reg, rv.value);
}
+6 -1
View File
@@ -68,10 +68,15 @@ struct ChannelTristimulus {
float z;
};
struct RegValue {
uint8_t reg;
uint8_t value;
};
/// Per-chip constants, stored in flash. The pointed-to tables are in flash too.
struct ChipInfo {
const char *name;
const i2c::I2CRegValue *init; // written in order; the bank is selected per register address
const RegValue *init; // written in order; the bank is selected per register address
const uint8_t *smux; // AS7341: SMUX RAM image per step; AS7343: the three chain images
const uint8_t *readout; // per step, the ADC slot of each band (0xFF: not read in this step)
const uint16_t *wavelengths; // per band, WIDEBAND_NM for clear and ABSENT_NM for a missing band
+2 -1
View File
@@ -108,7 +108,8 @@ CONFIG_SCHEMA = cv.Schema(
def _final_validate(config: ConfigType) -> ConfigType:
hub = fv.get_declaring_config(config[CONF_AS734X_ID])
full = fv.full_config.get()
hub = full.get_config_for_path(full.get_path_for_id(config[CONF_AS734X_ID])[:-1])
if hub[CONF_TYPE] != "AS7341":
return config
for key in _BAND_KEYS:
+7 -6
View File
@@ -4,13 +4,14 @@ import esphome.config_validation as cv
from esphome.const import DEVICE_CLASS_SWITCH, ICON_WIFI
from esphome.types import ConfigType
from .. import CONF_AT581X_ID, AT581XComponent
from .. import CONF_AT581X_ID, AT581XComponent, at581x_ns
DEPENDENCIES = ["at581x"]
RFSwitch = at581x_ns.class_("RFSwitch", switch.Switch)
CONFIG_SCHEMA = switch.switch_schema(
switch.ApplySwitch,
RFSwitch,
device_class=DEVICE_CLASS_SWITCH,
icon=ICON_WIFI,
).extend(
@@ -23,7 +24,7 @@ CONFIG_SCHEMA = switch.switch_schema(
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_AT581X_ID])
await switch.sub_switches(config, parent=hub).create(
config, hub.set_rf_power_switch, apply="set_rf_mode(state)", publish="before"
)
at581x_component = await cg.get_variable(config[CONF_AT581X_ID])
s = await switch.new_switch(config)
await cg.register_parented(s, config[CONF_AT581X_ID])
cg.add(at581x_component.set_rf_power_switch(s))
@@ -0,0 +1,10 @@
#include "rf_switch.h"
namespace esphome::at581x {
void RFSwitch::write_state(bool state) {
this->publish_state(state);
this->parent_->set_rf_mode(state);
}
} // namespace esphome::at581x
@@ -0,0 +1,13 @@
#pragma once
#include "esphome/components/switch/switch.h"
#include "../at581x.h"
namespace esphome::at581x {
class RFSwitch final : public switch_::Switch, public Parented<AT581XComponent> {
protected:
void write_state(bool state) override;
};
} // namespace esphome::at581x
@@ -58,7 +58,7 @@ optional<ParseResult> ATCMiThermometer::parse_header_(const ble_device_base::Ser
return {};
}
const auto &raw = service_data.data;
auto raw = service_data.data;
if (raw.size() < 13) {
ESP_LOGVV(TAG, "parse_header_(): service data too short (%zu).", raw.size());
return {};
+47 -49
View File
@@ -4,9 +4,7 @@
#include "bedjet_child.h"
#include "bedjet_const.h"
#include "esphome/core/application.h"
#include <algorithm>
#include <cinttypes>
#include <cstring>
namespace esphome::bedjet {
@@ -42,7 +40,7 @@ void BedJetHub::upgrade_firmware() {
auto status = this->write_bedjet_packet_(pkt);
if (status) {
ESP_LOGW(TAG, "[%s] MAGIC_UPDATE button failed, status=%d", this->get_name(), status);
ESP_LOGW(TAG, "[%s] MAGIC_UPDATE button failed, status=%d", this->get_name().c_str(), status);
}
}
@@ -66,7 +64,7 @@ bool BedJetHub::set_fan_index(uint8_t fan_speed_index) {
auto status = this->write_bedjet_packet_(pkt);
if (status) {
ESP_LOGW(TAG, "[%s] writing fan speed failed, status=%d", this->get_name(), status);
ESP_LOGW(TAG, "[%s] writing fan speed failed, status=%d", this->get_name().c_str(), status);
}
return status == 0;
}
@@ -84,7 +82,7 @@ bool BedJetHub::set_target_temp(float temp_c) {
auto status = this->write_bedjet_packet_(pkt);
if (status) {
ESP_LOGW(TAG, "[%s] writing target temp failed, status=%d", this->get_name(), status);
ESP_LOGW(TAG, "[%s] writing target temp failed, status=%d", this->get_name().c_str(), status);
}
return status == 0;
}
@@ -95,7 +93,7 @@ bool BedJetHub::set_time_remaining(uint8_t hours, uint8_t mins) {
auto status = this->write_bedjet_packet_(pkt);
if (status) {
ESP_LOGW(TAG, "[%s] writing remaining runtime failed, status=%d", this->get_name(), status);
ESP_LOGW(TAG, "[%s] writing remaining runtime failed, status=%d", this->get_name().c_str(), status);
}
return status == 0;
}
@@ -105,10 +103,11 @@ bool BedJetHub::send_button(BedjetButton button) {
auto status = this->write_bedjet_packet_(pkt);
if (status) {
ESP_LOGW(TAG, "[%s] writing button %s failed, status=%d", this->get_name(),
ESP_LOGW(TAG, "[%s] writing button %s failed, status=%d", this->get_name().c_str(),
LOG_STR_ARG(bedjet_button_to_string(button)), status);
} else {
ESP_LOGD(TAG, "[%s] writing button %s success", this->get_name(), LOG_STR_ARG(bedjet_button_to_string(button)));
ESP_LOGD(TAG, "[%s] writing button %s success", this->get_name().c_str(),
LOG_STR_ARG(bedjet_button_to_string(button)));
}
return status == 0;
}
@@ -126,9 +125,9 @@ uint16_t BedJetHub::get_time_remaining() {
uint8_t BedJetHub::write_bedjet_packet_(BedjetPacket *pkt) {
if (!this->is_connected()) {
if (!this->parent_->enabled) {
ESP_LOGI(TAG, "[%s] Cannot write packet: Not connected, enabled=false", this->get_name());
ESP_LOGI(TAG, "[%s] Cannot write packet: Not connected, enabled=false", this->get_name().c_str());
} else {
ESP_LOGW(TAG, "[%s] Cannot write packet: Not connected", this->get_name());
ESP_LOGW(TAG, "[%s] Cannot write packet: Not connected", this->get_name().c_str());
}
return -1;
}
@@ -145,16 +144,16 @@ uint8_t BedJetHub::set_notify_(const bool enable) {
status = esp_ble_gattc_register_for_notify(this->parent_->get_gattc_if(), this->parent_->get_remote_bda(),
this->char_handle_status_);
if (status) {
ESP_LOGW(TAG, "[%s] esp_ble_gattc_register_for_notify failed, status=%d", this->get_name(), status);
ESP_LOGW(TAG, "[%s] esp_ble_gattc_register_for_notify failed, status=%d", this->get_name().c_str(), status);
}
} else {
status = esp_ble_gattc_unregister_for_notify(this->parent_->get_gattc_if(), this->parent_->get_remote_bda(),
this->char_handle_status_);
if (status) {
ESP_LOGW(TAG, "[%s] esp_ble_gattc_unregister_for_notify failed, status=%d", this->get_name(), status);
ESP_LOGW(TAG, "[%s] esp_ble_gattc_unregister_for_notify failed, status=%d", this->get_name().c_str(), status);
}
}
ESP_LOGV(TAG, "[%s] set_notify: enable=%d; result=%d", this->get_name(), enable, status);
ESP_LOGV(TAG, "[%s] set_notify: enable=%d; result=%d", this->get_name().c_str(), enable, status);
return status;
}
@@ -165,7 +164,7 @@ bool BedJetHub::discover_characteristics_() {
if (!this->char_handle_cmd_) {
chr = this->parent_->get_characteristic(BEDJET_SERVICE_UUID, BEDJET_COMMAND_UUID);
if (chr == nullptr) {
ESP_LOGW(TAG, "[%s] No control service found at device, not a BedJet..?", this->get_name());
ESP_LOGW(TAG, "[%s] No control service found at device, not a BedJet..?", this->get_name().c_str());
result = false;
} else {
this->char_handle_cmd_ = chr->handle;
@@ -175,7 +174,7 @@ bool BedJetHub::discover_characteristics_() {
if (!this->char_handle_status_) {
chr = this->parent_->get_characteristic(BEDJET_SERVICE_UUID, BEDJET_STATUS_UUID);
if (chr == nullptr) {
ESP_LOGW(TAG, "[%s] No status service found at device, not a BedJet..?", this->get_name());
ESP_LOGW(TAG, "[%s] No status service found at device, not a BedJet..?", this->get_name().c_str());
result = false;
} else {
this->char_handle_status_ = chr->handle;
@@ -205,14 +204,14 @@ bool BedJetHub::discover_characteristics_() {
if (!this->char_handle_name_) {
chr = this->parent_->get_characteristic(BEDJET_SERVICE_UUID, BEDJET_NAME_UUID);
if (chr == nullptr) {
ESP_LOGW(TAG, "[%s] No name service found at device, not a BedJet..?", this->get_name());
ESP_LOGW(TAG, "[%s] No name service found at device, not a BedJet..?", this->get_name().c_str());
result = false;
} else {
this->char_handle_name_ = chr->handle;
auto status = esp_ble_gattc_read_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(),
this->char_handle_name_, ESP_GATT_AUTH_REQ_NONE);
if (status) {
ESP_LOGI(TAG, "[%s] Unable to read name characteristic: %d", this->get_name(), status);
ESP_LOGI(TAG, "[%s] Unable to read name characteristic: %d", this->get_name().c_str(), status);
}
}
}
@@ -223,7 +222,7 @@ bool BedJetHub::discover_characteristics_() {
" - Status char: 0x%x\n"
" - config descriptor: 0x%x\n"
" - Name char: 0x%x",
this->get_name(), this->char_handle_cmd_, this->char_handle_status_, this->config_descr_status_,
this->get_name().c_str(), this->char_handle_cmd_, this->char_handle_status_, this->config_descr_status_,
this->char_handle_name_);
return result;
@@ -242,7 +241,7 @@ void BedJetHub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga
auto result = this->discover_characteristics_();
if (result) {
ESP_LOGD(TAG, "[%s] Services complete: obtained char handles.", this->get_name());
ESP_LOGD(TAG, "[%s] Services complete: obtained char handles.", this->get_name().c_str());
this->node_state = espbt::ClientState::ESTABLISHED;
this->set_notify_(true);
@@ -254,7 +253,7 @@ void BedJetHub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga
this->dispatch_state_(true);
} else {
ESP_LOGW(TAG, "[%s] Failed discovering service characteristics.", this->get_name());
ESP_LOGW(TAG, "[%s] Failed discovering service characteristics.", this->get_name().c_str());
this->parent()->set_enabled(false);
this->status_set_warning();
this->dispatch_state_(false);
@@ -266,15 +265,15 @@ void BedJetHub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga
if (param->write.status == ESP_GATT_INVALID_ATTR_LEN) {
// This probably means that our hack for notify_en (8 bit vs 16 bit) didn't work right.
// Should we try to fall back to BLEClient's way?
ESP_LOGW(TAG, "[%s] Invalid attr length writing descr at handle 0x%04d, status=%d", this->get_name(),
ESP_LOGW(TAG, "[%s] Invalid attr length writing descr at handle 0x%04d, status=%d", this->get_name().c_str(),
param->write.handle, param->write.status);
} else {
ESP_LOGW(TAG, "[%s] Error writing descr at handle 0x%04d, status=%d", this->get_name(), param->write.handle,
param->write.status);
ESP_LOGW(TAG, "[%s] Error writing descr at handle 0x%04d, status=%d", this->get_name().c_str(),
param->write.handle, param->write.status);
}
break;
}
ESP_LOGD(TAG, "[%s] Write to handle 0x%04x status=%d", this->get_name(), param->write.handle,
ESP_LOGD(TAG, "[%s] Write to handle 0x%04x status=%d", this->get_name().c_str(), param->write.handle,
param->write.status);
break;
}
@@ -307,11 +306,9 @@ void BedJetHub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga
} else if (param->read.handle == this->char_handle_name_) {
// The data should represent the name.
if (param->read.status == ESP_GATT_OK && param->read.value_len > 0) {
ESP_LOGV(TAG, "[%s] Got BedJet name: '%.*s'", this->get_name(), static_cast<int>(param->read.value_len),
reinterpret_cast<const char *>(param->read.value));
const size_t len = std::min<size_t>(param->read.value_len, sizeof(this->name_) - 1);
memcpy(this->name_, param->read.value, len);
this->name_[len] = '\0';
std::string bedjet_name(reinterpret_cast<char const *>(param->read.value), param->read.value_len);
ESP_LOGV(TAG, "[%s] Got BedJet name: '%s'", this->get_name().c_str(), bedjet_name.c_str());
this->set_name_(bedjet_name);
}
}
break;
@@ -325,8 +322,8 @@ void BedJetHub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga
// doesn't break anything.
if (param->reg_for_notify.handle != this->char_handle_status_) {
ESP_LOGW(TAG, "[%s] Register for notify on unexpected handle 0x%04x, expecting 0x%04x", this->get_name(),
param->reg_for_notify.handle, this->char_handle_status_);
ESP_LOGW(TAG, "[%s] Register for notify on unexpected handle 0x%04x, expecting 0x%04x",
this->get_name().c_str(), param->reg_for_notify.handle, this->char_handle_status_);
break;
}
@@ -338,8 +335,8 @@ void BedJetHub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga
case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: {
// This event is not handled by the parent BLEClient, so we need to do this either way.
if (param->unreg_for_notify.handle != this->char_handle_status_) {
ESP_LOGW(TAG, "[%s] Unregister for notify on unexpected handle 0x%04x, expecting 0x%04x", this->get_name(),
param->unreg_for_notify.handle, this->char_handle_status_);
ESP_LOGW(TAG, "[%s] Unregister for notify on unexpected handle 0x%04x, expecting 0x%04x",
this->get_name().c_str(), param->unreg_for_notify.handle, this->char_handle_status_);
break;
}
@@ -353,13 +350,13 @@ void BedJetHub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga
break;
if (param->notify.conn_id != this->parent_->get_conn_id()) {
ESP_LOGW(TAG, "[%s] Received notify event for unexpected parent conn: expect %x, got %x", this->get_name(),
this->parent_->get_conn_id(), param->notify.conn_id);
ESP_LOGW(TAG, "[%s] Received notify event for unexpected parent conn: expect %x, got %x",
this->get_name().c_str(), this->parent_->get_conn_id(), param->notify.conn_id);
// FIXME: bug in BLEClient holding wrong conn_id.
}
if (param->notify.handle != this->char_handle_status_) {
ESP_LOGW(TAG, "[%s] Unexpected notify handle, wanted %04X, got %04X", this->get_name(),
ESP_LOGW(TAG, "[%s] Unexpected notify handle, wanted %04X, got %04X", this->get_name().c_str(),
this->char_handle_status_, param->notify.handle);
break;
}
@@ -375,13 +372,13 @@ void BedJetHub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga
if (!this->force_refresh_ && this->codec_->compare(param->notify.value, param->notify.value_len)) {
// If the packet is meaningfully different, trigger children as well
this->force_refresh_ = true;
ESP_LOGV(TAG, "[%s] Incoming packet indicates a significant change.", this->get_name());
ESP_LOGV(TAG, "[%s] Incoming packet indicates a significant change.", this->get_name().c_str());
}
if (this->last_notify_ == 0 || delta > MIN_NOTIFY_THROTTLE || this->force_refresh_) {
// Set reentrant flag to prevent processing multiple packets.
this->processing_ = true;
ESP_LOGVV(TAG, "[%s] Decoding packet: last=%" PRId32 ", delta=%" PRId32 ", force=%s", this->get_name(),
ESP_LOGVV(TAG, "[%s] Decoding packet: last=%" PRId32 ", delta=%" PRId32 ", force=%s", this->get_name().c_str(),
this->last_notify_, delta, this->force_refresh_ ? "y" : "n");
bool needs_extra = this->codec_->decode_notify(param->notify.value, param->notify.value_len);
@@ -391,7 +388,7 @@ void BedJetHub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga
auto status = esp_ble_gattc_read_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(),
this->char_handle_status_, ESP_GATT_AUTH_REQ_NONE);
if (status) {
ESP_LOGI(TAG, "[%s] Unable to read extended status packet", this->get_name());
ESP_LOGI(TAG, "[%s] Unable to read extended status packet", this->get_name().c_str());
}
} else {
this->status_packet_ready_();
@@ -400,7 +397,7 @@ void BedJetHub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga
break;
}
default:
ESP_LOGVV(TAG, "[%s] gattc unhandled event: enum=%d", this->get_name(), event);
ESP_LOGVV(TAG, "[%s] gattc unhandled event: enum=%d", this->get_name().c_str(), event);
break;
}
}
@@ -439,7 +436,7 @@ uint8_t BedJetHub::write_notify_config_descriptor_(bool enable) {
ESP_LOGW(TAG, "esp_ble_gattc_write_char_descr error, status=%d", status);
return status;
}
ESP_LOGD(TAG, "[%s] wrote notify=%s to status config 0x%04x, for conn %d", this->get_name(),
ESP_LOGD(TAG, "[%s] wrote notify=%s to status config 0x%04x, for conn %d", this->get_name().c_str(),
enable ? "true" : "false", handle, this->parent_->get_conn_id());
return ESP_GATT_OK;
}
@@ -471,7 +468,7 @@ void BedJetHub::setup_time_() {
void BedJetHub::set_clock(uint8_t hour, uint8_t minute) {
if (!this->is_connected()) {
ESP_LOGV(TAG, "[%s] Not connected, cannot send time.", this->get_name());
ESP_LOGV(TAG, "[%s] Not connected, cannot send time.", this->get_name().c_str());
return;
}
@@ -480,7 +477,7 @@ void BedJetHub::set_clock(uint8_t hour, uint8_t minute) {
if (status) {
ESP_LOGW(TAG, "Failed setting BedJet clock: %d", status);
} else {
ESP_LOGD(TAG, "[%s] BedJet clock set to: %d:%02d", this->get_name(), hour, minute);
ESP_LOGD(TAG, "[%s] BedJet clock set to: %d:%02d", this->get_name().c_str(), hour, minute);
}
}
@@ -498,7 +495,7 @@ void BedJetHub::dump_config() {
"BedJet Hub '%s'\n"
" ble_client.app_id: %d\n"
" ble_client.conn_id: %d",
this->get_name(), this->parent()->app_id, this->parent()->get_conn_id());
this->get_name().c_str(), this->parent()->app_id, this->parent()->get_conn_id());
LOG_UPDATE_INTERVAL(this);
ESP_LOGCONFIG(TAG, " Child components (%d):", this->children_.size());
for (auto *child : this->children_) {
@@ -516,9 +513,10 @@ void BedJetHub::dispatch_status_() {
auto *status = this->codec_->get_status_packet();
if (!this->is_connected()) {
ESP_LOGD(TAG, "[%s] Not connected, will not send status.", this->get_name());
ESP_LOGD(TAG, "[%s] Not connected, will not send status.", this->get_name().c_str());
} else if (status != nullptr) {
ESP_LOGD(TAG, "[%s] Notifying %d children of latest status @%p.", this->get_name(), this->children_.size(), status);
ESP_LOGD(TAG, "[%s] Notifying %d children of latest status @%p.", this->get_name().c_str(), this->children_.size(),
status);
for (auto *child : this->children_) {
child->on_status(status);
}
@@ -532,13 +530,13 @@ void BedJetHub::dispatch_status_() {
// We can try to unregister for notifications now, and then re-register, hoping to clear it up...
// But how do we know for sure which state we're in, and how do we actually clear out the buggy state?
ESP_LOGI(TAG, "[%s] Still waiting for first GATT notify event.", this->get_name());
ESP_LOGI(TAG, "[%s] Still waiting for first GATT notify event.", this->get_name().c_str());
} else if (diff > NOTIFY_WARN_THRESHOLD) {
ESP_LOGW(TAG, "[%s] Last GATT notify was %" PRId32 " seconds ago.", this->get_name(), diff / 1000);
ESP_LOGW(TAG, "[%s] Last GATT notify was %" PRId32 " seconds ago.", this->get_name().c_str(), diff / 1000);
}
if (this->timeout_ > 0 && diff > this->timeout_ && this->parent()->enabled) {
ESP_LOGW(TAG, "[%s] Timed out after %" PRId32 " sec. Retrying", this->get_name(), this->timeout_);
ESP_LOGW(TAG, "[%s] Timed out after %" PRId32 " sec. Retrying", this->get_name().c_str(), this->timeout_);
// set_enabled(false) will only close the connection if state != IDLE.
this->parent()->set_state(espbt::ClientState::CONNECTING);
this->parent()->set_enabled(false);
+10 -3
View File
@@ -119,7 +119,13 @@ class BedJetHub final : public esphome::ble_client::BLEClientNode, public Pollin
float get_setup_priority() const override { return setup_priority::BLUETOOTH; }
/** @return The BedJet's configured name, or the MAC address if not discovered yet. */
const char *get_name() const { return this->name_[0] == '\0' ? this->parent_->address_str() : this->name_; }
std::string get_name() {
if (this->name_.empty()) {
return this->parent_->address_str();
} else {
return this->name_;
}
}
/* BLEClient overrides */
@@ -145,8 +151,9 @@ class BedJetHub final : public esphome::ble_client::BLEClientNode, public Pollin
uint8_t set_notify_(bool enable);
/** Send the `BedjetPacket` to the device. */
uint8_t write_bedjet_packet_(BedjetPacket *pkt);
// Only used in log lines; longer names are truncated
char name_[24]{};
void set_name_(const std::string &name) { this->name_ = name; }
std::string name_;
uint32_t last_notify_ = 0;
inline void status_packet_ready_();
@@ -62,13 +62,6 @@ struct SpiData {
static SpiData *spi_data = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
// BDK 3.0.78 makes REG_WRITE a statement expression that yields the volatile assignment,
// a deprecated volatile read under clang
static inline void reg_write(uint32_t addr, uint32_t value) {
// NOLINTNEXTLINE(performance-no-int-to-ptr) - MMIO register at a fixed address
*reinterpret_cast<volatile uint32_t *>(addr) = value;
}
static void set_spi_ctrl_register(uint32_t bit, bool val) {
uint32_t value = REG_READ(SPI_CTRL);
if (val == 0) {
@@ -76,7 +69,7 @@ static void set_spi_ctrl_register(uint32_t bit, bool val) {
} else if (val == 1) {
value |= bit;
}
reg_write(SPI_CTRL, value);
REG_WRITE(SPI_CTRL, value);
}
#ifdef BEKEN_SPI_IS_N_LIKE
@@ -87,7 +80,7 @@ static void set_spi_config_register(uint32_t bit, bool val) {
} else if (val == 1) {
value |= bit;
}
reg_write(SPI_CONFIG, value);
REG_WRITE(SPI_CONFIG, value);
}
#endif
@@ -143,7 +136,7 @@ static void spi_set_clock(uint32_t max_hz) {
param = REG_READ(SPI_CTRL);
param &= ~(SPI_CKR_MASK << SPI_CKR_POSI);
param |= (div << SPI_CKR_POSI);
reg_write(SPI_CTRL, param);
REG_WRITE(SPI_CTRL, param);
#ifdef BEKEN_SPI_IS_N_LIKE
const int divisor = div;
#else
@@ -236,7 +229,7 @@ void BekenSPILEDStripLightOutput::setup() {
value = REG_READ(SPI_CTRL);
value &= ~CTRL_NSSMD_3;
value |= (1 << 17);
reg_write(SPI_CTRL, value);
REG_WRITE(SPI_CTRL, value);
#ifdef BEKEN_SPI_IS_N_LIKE
value = GFUNC_MODE_SPI_DMA;
@@ -286,7 +279,7 @@ void BekenSPILEDStripLightOutput::setup() {
value = REG_READ(SPI_CONFIG);
value &= ~(0xFFF << 8);
value |= ((dma_buffer_size & 0xFFF) << 8);
reg_write(SPI_CONFIG, value);
REG_WRITE(SPI_CONFIG, value);
#else
// T-like variants: No transfer-length register. The SPI controller shifts out
// everything the DMA writes into SPI_DAT.
+9 -5
View File
@@ -1,24 +1,28 @@
import esphome.codegen as cg
from esphome.components import output
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.types import ConfigType
from .. import CONF_BH1745_ID, BH1745Component
from .. import CONF_BH1745_ID, BH1745Component, bh1745_ns
DEPENDENCIES = ["bh1745"]
BH1745InterruptPinOutput = bh1745_ns.class_(
"BH1745InterruptPinOutput", output.BinaryOutput
)
CONFIG_SCHEMA = output.BINARY_OUTPUT_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(output.ApplyBinaryOutput),
cv.GenerateID(): cv.declare_id(BH1745InterruptPinOutput),
cv.GenerateID(CONF_BH1745_ID): cv.use_id(BH1745Component),
}
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await output.register_output(var, config)
hub = await cg.get_variable(config[CONF_BH1745_ID])
await output.sub_binary_outputs(config, parent=hub).create(
config, None, apply="set_interrupt_state(state)"
)
await cg.register_parented(var, hub)
cg.add(hub.enable_interrupt_output())
@@ -0,0 +1,16 @@
#pragma once
#include "esphome/components/output/binary_output.h"
#include "esphome/core/helpers.h"
#include "../bh1745.h"
namespace esphome::bh1745 {
/// Drives the BH1745 INT pin, which switches the LEDs on breakout boards such as the Pimoroni one.
class BH1745InterruptPinOutput : public output::BinaryOutput, public Parented<BH1745Component> {
protected:
void write_state(bool state) override { this->parent_->set_interrupt_state(state); }
};
} // namespace esphome::bh1745
+4 -2
View File
@@ -63,7 +63,6 @@ from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority, entity
from esphome.core.entity_helpers import (
SubEntities,
entity_duplicate_validator,
lazy_load_setup,
lazy_load_validator,
mqtt_component_class,
queue_entity_register,
@@ -622,7 +621,10 @@ async def setup_binary_sensor_core_(var, config):
await entity_helpers.setup_entity_integrations(var, config)
await lazy_load_setup("zigbee", "setup_binary_sensor", var, config)
if "zigbee" in CORE.loaded_integrations:
from esphome.components import zigbee
await zigbee.setup_binary_sensor(var, config)
async def register_binary_sensor(var, config):
-1
View File
@@ -29,7 +29,6 @@ from esphome.components.libretiny.const import (
)
from esphome.core import CORE
from esphome.cpp_generator import MockObj
from esphome.stacktrace import process_crash_handler_line as process_stacktrace # noqa: F401
from esphome.types import ConfigType
from .boards import BK72XX_BOARD_PINS, BK72XX_BOARDS
+26 -54
View File
@@ -21,10 +21,9 @@ public ble_api.h.
"""
import logging
import re
import esphome.codegen as cg
from esphome.components import libretiny
from esphome.components import libretiny, wifi
from esphome.components.libretiny.const import (
FAMILY_BK7231N,
FAMILY_BK7231Q,
@@ -33,30 +32,14 @@ from esphome.components.libretiny.const import (
FAMILY_BK7251,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_ENABLE_ON_BOOT,
CONF_ESPHOME,
CONF_ID,
CONF_PLATFORMIO_OPTIONS,
KEY_CORE,
KEY_FRAMEWORK_VERSION,
)
from esphome.core import CORE, EsphomeError
import esphome.final_validate as fv
from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID
from esphome.core import EsphomeError
from esphome.types import ConfigType
DEPENDENCIES = ["bk72xx"]
CODEOWNERS = ["@Bl00d-B0b"]
DOMAIN = "bk72xx_ble"
BEKEN_BDK_VERSION_OPTION = "custom_versions.beken-bdk"
# Older BDKs lay the BLE stack out differently and can crash at boot or erase the
# bootloader with BLE compiled in; the libretiny platform builds with this one
MIN_BDK_VERSION = (3, 0, 78)
# Older LibreTiny releases arm the Beken MCU sleep with wifi power save, which halts
# the device once the station stops while BLE runs (libretiny-eu/libretiny#414)
MIN_LIBRETINY_VERSION = (1, 14, 0)
_LOGGER = logging.getLogger(__name__)
bk72xx_ble_ns = cg.esphome_ns.namespace("bk72xx_ble")
@@ -97,45 +80,20 @@ def _unsupported_family_message(family: str) -> str | None:
return None
def check_bdk_version(value: str | None) -> None:
"""Reject a beken-bdk override older than the release the BLE stack needs."""
if value is None:
return
# LibreTiny appends the value to the package URL as a git ref and only builds
# 3.0.x releases; the 2021.06.07 tag is the pre 3.0 SDK
match = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)", str(value))
if match is not None:
version = tuple(int(g) for g in match.groups())
if version[:2] == MIN_BDK_VERSION[:2] and version >= MIN_BDK_VERSION:
return
minimum = ".".join(str(part) for part in MIN_BDK_VERSION)
raise cv.Invalid(
f"bk72xx_ble needs beken-bdk {minimum} or a newer 3.0.x release, not "
f"'{value}': with BLE compiled in, an older BDK can crash at boot or erase "
"the bootloader"
)
def check_libretiny_version(version: cv.Version) -> None:
"""Reject a LibreTiny release older than the one whose WiFi sleep is safe with BLE."""
if (version.major, version.minor, version.patch) >= MIN_LIBRETINY_VERSION:
return
minimum = ".".join(str(part) for part in MIN_LIBRETINY_VERSION)
raise cv.Invalid(
f"bk72xx_ble needs LibreTiny {minimum} or newer, not {version}: older releases "
"arm the Beken MCU sleep with wifi power save and the device halts once the "
"station stops while BLE runs"
)
def _final_validate(config: ConfigType) -> None:
# Warn only: a hard error here would break the validate-only CI fixtures,
# which run on a BLE 4.2 board. The hard error is raised at codegen.
if msg := _unsupported_family_message(libretiny.get_libretiny_family()):
_LOGGER.warning("%s (this configuration cannot compile)", msg)
options = fv.full_config.get()[CONF_ESPHOME].get(CONF_PLATFORMIO_OPTIONS) or {}
check_bdk_version(options.get(BEKEN_BDK_VERSION_OPTION))
check_libretiny_version(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION])
# Any wifi power_save_mode other than NONE also arms the Beken SDK's MCU
# sleep. With the BLE controller running, that sleep never wakes up once the
# station is stopped (adapter restart after failed roams, wifi.disable): the
# device is dead until a power cycle (esphome#18592). Keep power save off
# until LibreTiny ships the SDK-side fix (libretiny-eu/libretiny#414).
wifi.force_power_save_off(
"with BLE running, the Beken SDK's MCU sleep halts the device once the "
"station is stopped (https://github.com/esphome/esphome/issues/18592)"
)
FINAL_VALIDATE_SCHEMA = _final_validate
@@ -158,6 +116,20 @@ async def to_code(config: ConfigType) -> None:
# so this =1 is the single CFG_SUPPORT_BLE define emitted.
cg.add_platformio_option("custom_options.sys_config#h", ["CFG_SUPPORT_BLE=1"])
# Pin the Beken BDK release the BLE 5.x stack is validated against. The
# bundled 3.0.33 has an older BLE header/library layout — and with
# CFG_SUPPORT_BLE=1 the SDK runs its BLE init unconditionally during boot
# (the reason the libretiny platform sets =0 when BLE is unused), so a
# mismatched BDK can crash the device before WiFi comes up regardless of
# enable_on_boot. Pinning here makes a plain config build against the
# validated BDK without any manual platformio_options.
_LOGGER.warning(
"bk72xx_ble builds with beken-bdk 3.0.78 instead of the platform's bundled "
"default: the default's older BLE layout can crash the device at boot when "
"BLE is compiled in"
)
cg.add_platformio_option("custom_versions.beken-bdk", "3.0.78")
# The BDK exposes the controller's BLE address as `common_default_bdaddr` on
# BK7231N, but NOT on BK7238 (its BLE stack has no such symbol; the address is
# derived from the WiFi MAC instead — the BDK's own fallback). Tell the C++
+3 -6
View File
@@ -1,5 +1,4 @@
#include "bl0939.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include <cinttypes>
@@ -21,7 +20,7 @@ static const uint8_t BL0939_REG_SOFT_RESET = 0x19;
static const uint8_t BL0939_REG_USR_WRPROT = 0x1A;
static const uint8_t BL0939_REG_TPS_CTRL = 0x1B;
static constexpr uint8_t BL0939_INIT[6][6] PROGMEM = {
const uint8_t BL0939_INIT[6][6] = {
// Reset to default
{BL0939_WRITE_COMMAND, BL0939_REG_SOFT_RESET, 0x5A, 0x5A, 0x5A, 0x33},
// Enable User Operation Write
@@ -67,10 +66,8 @@ void BL0939::update() {
}
void BL0939::setup() {
uint8_t cmd[sizeof(BL0939_INIT[0])];
for (const auto *i : BL0939_INIT) {
progmem_memcpy(cmd, i, sizeof(cmd));
this->write_array(cmd, sizeof(cmd));
for (auto *i : BL0939_INIT) {
this->write_array(i, 6);
delay(1);
}
this->flush();
+30 -5
View File
@@ -139,24 +139,49 @@ bool BME680Component::setup_registers_() {
this->calibration_.ambient_temperature = 25; // prime ambient temperature
// Config register
if (!this->update_register(BME680_REGISTER_CONFIG, 0b00011100, (this->iir_filter_ & 0b111) << 2)) {
uint8_t config_register;
if (!this->read_byte(BME680_REGISTER_CONFIG, &config_register)) {
return false;
}
config_register &= ~0b00011100;
config_register |= (this->iir_filter_ & 0b111) << 2;
if (!this->write_byte(BME680_REGISTER_CONFIG, config_register)) {
return false;
}
// Humidity control register
if (!this->update_register(BME680_REGISTER_CONTROL_HUMIDITY, 0b00000111, this->humidity_oversampling_ & 0b111)) {
uint8_t hum_control;
if (!this->read_byte(BME680_REGISTER_CONTROL_HUMIDITY, &hum_control)) {
return false;
}
hum_control &= ~0b00000111;
hum_control |= this->humidity_oversampling_ & 0b111;
if (!this->write_byte(BME680_REGISTER_CONTROL_HUMIDITY, hum_control)) {
return false;
}
// Gas 1 control register: run gas, heater profile 0
if (!this->update_register(BME680_REGISTER_CONTROL_GAS1, 0b00011111, 1 << 4)) {
// Gas 1 control register
uint8_t gas1_control;
if (!this->read_byte(BME680_REGISTER_CONTROL_GAS1, &gas1_control)) {
return false;
}
gas1_control &= ~0b00011111;
gas1_control |= 1 << 4;
gas1_control |= 0; // profile 0
if (!this->write_byte(BME680_REGISTER_CONTROL_GAS1, gas1_control)) {
return false;
}
const bool heat_off = this->heater_temperature_ == 0 || this->heater_duration_ == 0;
// Gas 0 control register
if (!this->update_register(BME680_REGISTER_CONTROL_GAS0, 0b00001000, heat_off << 3)) {
uint8_t gas0_control;
if (!this->read_byte(BME680_REGISTER_CONTROL_GAS0, &gas0_control)) {
return false;
}
gas0_control &= ~0b00001000;
gas0_control |= heat_off << 3;
if (!this->write_byte(BME680_REGISTER_CONTROL_GAS0, gas0_control)) {
return false;
}
@@ -1,7 +1,7 @@
#include "bme680_bsec.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include <string>
namespace esphome::bme680_bsec {
#ifdef USE_BSEC
@@ -9,7 +9,7 @@ ESPHOME_LOG_TAG(TAG, "bme680_bsec.sensor");
static constexpr uint32_t READ_TIMEOUT_ID = 0;
PROGMEM_STRING_TABLE(IaqAccuracyStrings, "Stabilizing", "Uncertain", "Calibrating", "Calibrated");
static const std::string IAQ_ACCURACY_STATES[4] = {"Stabilizing", "Uncertain", "Calibrating", "Calibrated"};
std::vector<BME680BSECComponent *>
BME680BSECComponent::instances; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
@@ -386,11 +386,10 @@ void BME680BSECComponent::publish_(const bsec_output_t *outputs, uint8_t num_out
switch (outputs[i].sensor_id) {
case BSEC_OUTPUT_IAQ:
case BSEC_OUTPUT_STATIC_IAQ: {
uint8_t accuracy = std::min<uint8_t>(outputs[i].accuracy, IaqAccuracyStrings::LAST_INDEX);
uint8_t accuracy = std::min<uint8_t>(outputs[i].accuracy, std::size(IAQ_ACCURACY_STATES) - 1);
this->queue_push_([this, signal]() { this->publish_sensor_(this->iaq_sensor_, signal); });
this->queue_push_([this, accuracy]() {
this->publish_sensor_(this->iaq_accuracy_text_sensor_,
IaqAccuracyStrings::get_progmem_str(accuracy, IaqAccuracyStrings::LAST_INDEX));
this->publish_sensor_(this->iaq_accuracy_text_sensor_, IAQ_ACCURACY_STATES[accuracy]);
});
this->queue_push_([this, accuracy]() { this->publish_sensor_(this->iaq_accuracy_sensor_, accuracy, true); });
@@ -436,9 +435,8 @@ void BME680BSECComponent::publish_sensor_(sensor::Sensor *sensor, float value, b
sensor->publish_state(value);
}
void BME680BSECComponent::publish_sensor_(text_sensor::TextSensor *sensor, ProgmemStr value) {
if (!sensor ||
(sensor->has_state() && ESPHOME_strcmp_P(sensor->state.c_str(), reinterpret_cast<ESPHOME_PGM_P>(value)) == 0)) {
void BME680BSECComponent::publish_sensor_(text_sensor::TextSensor *sensor, const std::string &value) {
if (!sensor || (sensor->has_state() && sensor->state == value)) {
return;
}
sensor->publish_state(value);
+1 -1
View File
@@ -77,7 +77,7 @@ class BME680BSECComponent final : public Component, public i2c::I2CDevice {
int64_t get_time_ns_();
void publish_sensor_(sensor::Sensor *sensor, float value, bool change_only = false);
void publish_sensor_(text_sensor::TextSensor *sensor, ProgmemStr value);
void publish_sensor_(text_sensor::TextSensor *sensor, const std::string &value);
void snapshot_state_(); // Fetch the current BSEC library state and save it in the bsec_state_data_ member (volatile
// memory)
+3 -1
View File
@@ -66,7 +66,9 @@ CONFIG_SCHEMA = cv.typed_schema(
def _final_validate(config: ConfigType) -> None:
if config[CONF_TYPE] not in _MAGNETIC_FIELDS:
return
bmi270_config = fv.get_declaring_config(config[CONF_BMI270_ID])
full_config = fv.full_config.get()
bmi270_path = full_config.get_path_for_id(config[CONF_BMI270_ID])[:-1]
bmi270_config = full_config.get_config_for_path(bmi270_path)
if bmi270_config.get(CONF_AUX_DEVICE) != "BMM150":
raise cv.Invalid(
"This sensor requires the parent bmi270's 'aux_device' to be set to 'BMM150'",
+1 -2
View File
@@ -80,8 +80,7 @@ void BP1658CJ::loop() {
this->update_ = false;
}
void BP1658CJ::write_channel(uint8_t channel, float state) {
auto value = static_cast<uint16_t>(state * 0x3FF);
void BP1658CJ::set_channel_value_(uint8_t channel, uint16_t value) {
if (this->pwm_amounts_[channel] != value) {
this->update_ = true;
this->update_channel_ = channel;
+18 -1
View File
@@ -2,12 +2,15 @@
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "esphome/components/output/float_output.h"
#include <vector>
namespace esphome::bp1658cj {
class BP1658CJ final : public Component {
public:
class Channel;
void set_data_pin(GPIOPin *data_pin) { data_pin_ = data_pin; }
void set_clock_pin(GPIOPin *clock_pin) { clock_pin_ = clock_pin; }
void set_max_power_color_channels(uint8_t max_power_color_channels) {
@@ -26,9 +29,23 @@ class BP1658CJ final : public Component {
/// Send new values if they were updated.
void loop() override;
void write_channel(uint8_t channel, float state);
class Channel final : public output::FloatOutput {
public:
void set_parent(BP1658CJ *parent) { parent_ = parent; }
void set_channel(uint8_t channel) { channel_ = channel; }
protected:
void write_state(float state) override {
auto amount = static_cast<uint16_t>(state * 0x3FF);
this->parent_->set_channel_value_(this->channel_, amount);
}
BP1658CJ *parent_;
uint8_t channel_;
};
protected:
void set_channel_value_(uint8_t channel, uint16_t value);
void write_bit_(bool value);
void write_byte_(uint8_t data);
void write_buffer_(uint8_t *buffer, uint8_t size);
+9 -5
View File
@@ -1,3 +1,4 @@
import esphome.codegen as cg
from esphome.components import output
import esphome.config_validation as cv
from esphome.const import CONF_CHANNEL, CONF_ID
@@ -7,15 +8,16 @@ from . import BP1658CJ
DEPENDENCIES = ["bp1658cj"]
Channel = BP1658CJ.class_("Channel", output.FloatOutput)
CONF_BP1658CJ_ID = "bp1658cj_id"
CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend(
{
cv.GenerateID(CONF_BP1658CJ_ID): cv.use_id(BP1658CJ),
cv.Required(CONF_ID): cv.declare_id(output.ApplyFloatOutput),
cv.Required(CONF_ID): cv.declare_id(Channel),
cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=4),
}
)
).extend(cv.COMPONENT_SCHEMA)
FINAL_VALIDATE_SCHEMA = output.final_validate_unique_channel(
@@ -24,6 +26,8 @@ FINAL_VALIDATE_SCHEMA = output.final_validate_unique_channel(
async def to_code(config: ConfigType) -> None:
await output.sub_float_outputs(config, parent=config[CONF_BP1658CJ_ID]).create(
config, None, config[CONF_CHANNEL], apply="write_channel(index, state)"
)
var = cg.new_Pvariable(config[CONF_ID])
await output.register_output(var, config)
await cg.register_parented(var, config[CONF_BP1658CJ_ID])
cg.add(var.set_channel(config[CONF_CHANNEL]))
Loaded 100 of 1017 files, more files were not shown because too many files have changed in this diff. Show more