Compare commits

..
Author SHA1 Message Date
clydebarrowandClaude Opus 5.5 9f57216b9d [sx126x][sx127x] Stop reading a radio that fails while sends are held back
Also drop a leftover preamble_detect key from a test case.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:28:08 +11:00
150 changed files with 7312 additions and 2438 deletions

No files matched your search

+5 -7
View File
@@ -175,11 +175,6 @@ esphome/components/ds2484/* @mrk-its
esphome/components/ds248x/* @tomwellnitz
esphome/components/ds3231/* @linkedupbits
esphome/components/dsmr/* @glmnet @PolarGoose
esphome/components/duco/* @kokx
esphome/components/duco/number/* @kokx
esphome/components/duco/select/* @kokx
esphome/components/duco/sensor/* @kokx
esphome/components/duco/text_sensor/* @kokx
esphome/components/duty_time/* @dudanov
esphome/components/ecocomfort2/* @gledian
esphome/components/ee895/* @Stock-M
@@ -192,6 +187,7 @@ esphome/components/ens160_base/* @latonita @vincentscode
esphome/components/ens160_i2c/* @latonita
esphome/components/ens160_spi/* @latonita
esphome/components/ens210/* @itn3rd77
esphome/components/entity_transport/* @clydebarrow
esphome/components/epaper_spi/* @esphome/core
esphome/components/es7210/* @kahrendt
esphome/components/es7243e/* @kbx81
@@ -379,8 +375,7 @@ esphome/components/m5stack_8angle/* @rnauber
esphome/components/mapping/* @clydebarrow
esphome/components/matrix_keypad/* @ssieb
esphome/components/max17043/* @blacknell
esphome/components/max17048/* @B48D81EFCC @ryansch
esphome/components/max170xx/* @ryansch
esphome/components/max17048/* @B48D81EFCC
esphome/components/max31865/* @DAVe3283
esphome/components/max31888/* @JiriPrchal
esphome/components/max44009/* @berfenger
@@ -635,7 +630,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
@@ -695,6 +692,7 @@ esphome/components/uart/packet_transport/* @clydebarrow
esphome/components/uart_mux/* @kbx81
esphome/components/uart_tcp/* @Bascht74
esphome/components/udp/* @clydebarrow
esphome/components/udp/packet_interface/* @clydebarrow
esphome/components/ufire_ec/* @pvizeli
esphome/components/ufire_ise/* @pvizeli
esphome/components/ufm01/* @ljungqvist
+5 -2
View File
@@ -1,5 +1,4 @@
#include "ch422g.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::ch422g {
@@ -60,7 +59,11 @@ bool CH422GComponent::digital_read(uint8_t pin) {
}
void CH422GComponent::digital_write(uint8_t pin, bool value) {
assign_bits(this->output_bits_, 1 << pin, value);
if (value) {
this->output_bits_ |= (1 << pin);
} else {
this->output_bits_ &= ~(1 << pin);
}
this->write_outputs_();
}
+5 -2
View File
@@ -1,5 +1,4 @@
#include "ch423.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
@@ -63,7 +62,11 @@ bool CH423Component::digital_read(uint8_t pin) {
}
void CH423Component::digital_write(uint8_t pin, bool value) {
assign_bits(this->output_bits_, 1 << pin, value);
if (value) {
this->output_bits_ |= (1 << pin);
} else {
this->output_bits_ &= ~(1 << pin);
}
this->write_outputs_();
}
-87
View File
@@ -1,87 +0,0 @@
import esphome.codegen as cg
from esphome.components import time, uart
import esphome.config_validation as cv
from esphome.const import CONF_DISCOVERY, CONF_ID, CONF_TIME, CONF_TIME_ID
from esphome.core import ID
from esphome.types import ConfigType
CODEOWNERS = ["@kokx"]
DEPENDENCIES = ["uart"]
DOMAIN = "duco"
MULTI_CONF = True
CONF_DISABLE_CRC = "disable_crc"
CONF_DUCO_ID = "duco_id"
duco_ns = cg.esphome_ns.namespace("duco")
Duco = duco_ns.class_("Duco", cg.Component, uart.UARTDevice)
# Base of every class that sends requests through the hub
DucoDevice = duco_ns.class_(
"DucoDevice", cg.PollingComponent, cg.Parented.template(Duco)
)
DucoDiscovery = duco_ns.class_("DucoDiscovery", DucoDevice)
DucoTime = duco_ns.class_("DucoTime", cg.PollingComponent, cg.Parented.template(Duco))
# Entities of the platforms take the hub with this
DUCO_COMPONENT_SCHEMA = cv.Schema({cv.GenerateID(CONF_DUCO_ID): cv.use_id(Duco)})
DISCOVERY_SCHEMA = cv.Schema({cv.GenerateID(): cv.declare_id(DucoDiscovery)}).extend(
cv.COMPONENT_SCHEMA
)
TIME_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(DucoTime),
cv.GenerateID(CONF_TIME_ID): cv.use_id(time.RealTimeClock),
}
).extend(cv.polling_component_schema("15min"))
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(Duco),
cv.Optional(CONF_DISABLE_CRC, default=False): cv.boolean,
cv.Optional(CONF_DISCOVERY): DISCOVERY_SCHEMA,
cv.Optional(CONF_TIME): TIME_SCHEMA,
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(uart.UART_DEVICE_SCHEMA)
)
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
DOMAIN,
baud_rate=57600,
require_tx=True,
require_rx=True,
data_bits=8,
parity="NONE",
stop_bits=1,
)
_request_device_slot = cg.slot_counter("DUCO_DEVICE_COUNT")
async def register_duco_device(var: cg.MockObj, config: ConfigType, hub_id: ID) -> None:
"""Register a device that sends requests, and give it an open request slot on its hub."""
await cg.register_component(var, config)
hub = await cg.get_variable(hub_id)
await cg.register_parented(var, hub)
_request_device_slot(str(hub_id))
cg.add(hub.register_device(var))
async def to_code(config: ConfigType) -> None:
var = await uart.new_uart_component(config)
cg.add(var.set_disable_crc(config[CONF_DISABLE_CRC]))
if (discovery := config.get(CONF_DISCOVERY)) is not None:
discovery_var = cg.new_Pvariable(discovery[CONF_ID])
await register_duco_device(discovery_var, discovery, config[CONF_ID])
if (time_config := config.get(CONF_TIME)) is not None:
clock = await cg.get_variable(time_config[CONF_TIME_ID])
time_var = cg.new_Pvariable(time_config[CONF_ID], clock)
await cg.register_component(time_var, time_config)
await cg.register_parented(time_var, var)
-273
View File
@@ -1,273 +0,0 @@
#include "duco.h"
#include "esphome/core/application.h"
#include "esphome/core/log.h"
namespace esphome::duco {
ESPHOME_LOG_TAG(TAG, "duco");
static constexpr uint8_t HEADER_START = 0xAA;
static constexpr uint8_t HEADER_END = 0x55;
static constexpr uint8_t STUFFING = 0x01; // follows an 0xAA inside a message, not part of it
static constexpr uint32_t FRAME_GAP_MS = 50;
static constexpr uint32_t DISCOVERY_START_ID = 0;
static constexpr uint32_t DISCOVERY_STEP_ID = 1;
static constexpr uint32_t DISCOVERY_START_MS = 16000;
static constexpr uint32_t DISCOVERY_STEP_MS = 1600;
static constexpr uint8_t DISCOVERY_MAX_ATTEMPTS = 3;
void Duco::loop() {
const uint32_t now = App.get_loop_component_start_time();
// Read first: after a slow loop pass the rest of a message is already waiting, which is not silence
this->read_available([this, now](uint8_t byte) {
if (this->parse_byte_(byte)) {
this->last_byte_ = now;
} else {
this->reset_frame_();
}
});
// The last message of a burst has no header after it, so it ends on silence
if (this->rx_size_ != 0 && now - this->last_byte_ > FRAME_GAP_MS) {
this->finalize_message_();
this->reset_frame_();
}
}
void Duco::reset_frame_() {
this->rx_size_ = 0;
this->escaped_ = false;
}
void Duco::close_request(DucoDevice *device) {
#ifdef DUCO_DEVICE_COUNT
for (auto &pending : this->pending_) {
if (pending.device == device) {
pending.function = 0;
pending.missed = false;
this->update_warning_();
return;
}
}
#endif
}
bool Duco::parse_byte_(uint8_t byte) {
const size_t at = this->rx_size_;
if (at >= MAX_FRAME_SIZE) {
ESP_LOGV(TAG, "Message longer than %u bytes dropped", static_cast<unsigned>(MAX_FRAME_SIZE));
return false;
}
const uint8_t prev = at > 0 ? this->rx_buffer_[at - 1] : 0;
if (at == 1 && prev == HEADER_START && byte == HEADER_END)
return false; // header of the next message
if (at >= 2) {
const bool escaped = this->escaped_;
this->escaped_ = false;
if (prev == HEADER_START && !escaped) {
if (byte == STUFFING) {
this->escaped_ = true;
return true;
}
if (at >= 3 && byte == HEADER_END) {
this->rx_size_--; // drop the 0xAA of the header
this->finalize_message_();
return false;
}
}
}
this->rx_buffer_[this->rx_size_++] = byte;
return true;
}
void Duco::finalize_message_() {
const uint8_t *raw = this->rx_buffer_.data();
// length (function, id and data), function, id, data, CRC low, CRC high
if (this->rx_size_ < 5 || raw[0] < 2 || this->rx_size_ < raw[0] + 3u) {
ESP_LOGV(TAG, "Malformed message dropped: %u bytes, length byte %u", this->rx_size_, raw[0]);
return;
}
const uint8_t length = raw[0];
const uint16_t computed_crc = crc16(raw, length + 1);
const uint16_t remote_crc = encode_uint16(raw[length + 2], raw[length + 1]);
if (computed_crc != remote_crc) {
if (!this->disable_crc_) {
ESP_LOGV(TAG, "CRC check failed: %04X != %04X", computed_crc, remote_crc);
return;
}
ESP_LOGV(TAG, "CRC check failed, ignored: %04X != %04X", computed_crc, remote_crc);
}
#ifdef DUCO_DEVICE_COUNT
// The box first acknowledges a request with function + 1, then answers with function + 2
const DucoMessage message{raw[2], raw + 3, static_cast<uint8_t>(length - 2)};
for (auto &pending : this->pending_) {
if (pending.function != 0 && pending.id == message.id && pending.function + 2 == raw[1]) {
pending.function = 0;
pending.missed = false;
this->update_warning_();
pending.device->receive_response(message);
return;
}
}
// Acknowledgements (odd function codes) are expected and never match
if ((raw[1] & 1) == 0) {
ESP_LOGV(TAG, "Answer %02X with id %u matches no open request", raw[1], message.id);
}
#endif
}
#ifdef DUCO_DEVICE_COUNT
void Duco::update_warning_() {
for (const auto &pending : this->pending_) {
if (pending.missed) {
this->status_set_warning(LOG_STR("No answer from the box"));
return;
}
}
this->status_clear_warning();
}
#endif
void Duco::dump_config() {
ESP_LOGCONFIG(TAG,
"Duco:\n"
" CRC Disabled: %s",
YESNO(this->disable_crc_));
}
uint8_t Duco::next_id_() {
do {
this->last_id_++;
} while (this->last_id_ == HEADER_START || this->last_id_ == HEADER_END);
return this->last_id_;
}
void Duco::send(DucoDevice *device, uint8_t function, std::initializer_list<uint8_t> data) {
if (data.size() > MAX_REQUEST_DATA) {
ESP_LOGE(TAG, "Request of %u bytes is too long", static_cast<unsigned>(data.size()));
return;
}
// Not byte stuffed, as in the author's original and no capture shows a request with 0xAA. An 0xAA followed by any
// byte but 0x01 or 0x55 reads the same with or without unstuffing on the box, so only those rare pairs are
// unverified.
const size_t size = data.size();
const uint8_t id = this->next_id_();
uint8_t frame[5 + MAX_REQUEST_DATA + 2] = {HEADER_START, HEADER_END, static_cast<uint8_t>(size + 2), function, id};
std::copy_n(data.begin(), size, frame + 5);
const uint16_t crc = crc16(frame + 2, size + 3);
frame[size + 5] = crc & 0xFF;
frame[size + 6] = crc >> 8;
this->write_array(frame, size + 7);
this->flush();
#ifdef DUCO_DEVICE_COUNT
for (auto &pending : this->pending_) {
if (pending.device == device) {
pending.missed = pending.function != 0;
if (pending.missed) {
ESP_LOGV(TAG, "No answer to request %02X with id %u", pending.function, pending.id);
this->update_warning_();
}
pending.id = id;
pending.function = function;
break;
}
}
#endif
ESP_LOGV_HEX(TAG, frame + 2, size + 5, sizeof(frame), "Sent %s");
}
static const LogString *node_type_name(uint8_t type) {
switch (type) {
case 8:
return LOG_STR("UCBAT");
case 9:
return LOG_STR("UC");
case 10:
return LOG_STR("UCRH");
case 12:
return LOG_STR("UCCO2");
case 13:
return LOG_STR("VLV");
case 17:
return LOG_STR("BOX");
case 18:
return LOG_STR("SWITCH");
default:
return LOG_STR("UNKNOWN");
}
}
void DucoDiscovery::setup() {
// Give the box time to start before walking its node addresses
this->set_timeout(DISCOVERY_START_ID, DISCOVERY_START_MS, [this]() {
this->set_interval(DISCOVERY_STEP_ID, DISCOVERY_STEP_MS, [this]() { this->update(); });
});
}
void DucoDiscovery::update() {
if (this->attempts_ == DISCOVERY_MAX_ATTEMPTS) {
ESP_LOGV(TAG, "No answer for node %u", this->next_node_);
this->next_node_++;
this->attempts_ = 0;
}
if (this->next_node_ >= NODE_COUNT) {
this->cancel_interval(DISCOVERY_STEP_ID);
this->parent_->close_request(this);
return;
}
// Without an answer by the next step the same node is asked again; a retry is not a missed answer
if (this->attempts_ > 0)
this->parent_->close_request(this);
ESP_LOGV(TAG, "Discovering node %u", this->next_node_);
this->attempts_++;
this->parent_->send(this, FUNCTION_NODE, {0x01, this->next_node_});
}
void DucoDiscovery::receive_response(const DucoMessage &message) {
// Only the latest attempt is open, so a late answer to an earlier one never arrives here
if (message.size >= 1 && this->next_node_ < NODE_COUNT) {
if (message.data[0] != 0) {
ESP_LOGV(TAG, "Found node %u: type %u", this->next_node_, message.data[0]);
}
this->node_types_[this->next_node_++] = message.data[0];
this->attempts_ = 0;
}
}
void DucoDiscovery::dump_config() {
ESP_LOGCONFIG(TAG, "Discovered nodes:");
for (uint8_t node = 0; node < NODE_COUNT; node++) {
const uint8_t type = this->node_types_[node];
if (type != 0) {
ESP_LOGCONFIG(TAG, " Node %u: type %u (%s)", node, type, LOG_STR_ARG(node_type_name(type)));
}
}
if (this->next_node_ < NODE_COUNT) {
ESP_LOGCONFIG(TAG, " Still discovering, at node %u", this->next_node_);
}
}
#ifdef USE_TIME
void DucoTime::update() {
const ESPTime now = this->clock_->now();
if (!now.is_valid()) {
ESP_LOGV(TAG, "Time is not valid yet");
return;
}
const uint32_t ts = now.timestamp;
this->parent_->send(nullptr, FUNCTION_PARAMETER,
{0x05, static_cast<uint8_t>(ts), static_cast<uint8_t>(ts >> 8), static_cast<uint8_t>(ts >> 16),
static_cast<uint8_t>(ts >> 24), 0x00, 0x00, 0x00, 0x00});
}
#endif
} // namespace esphome::duco
-113
View File
@@ -1,113 +0,0 @@
#pragma once
#include <array>
#include <initializer_list>
#include "esphome/components/uart/uart.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#ifdef USE_TIME
#include "esphome/components/time/real_time_clock.h"
#endif
namespace esphome::duco {
// Function codes of the requests; the box answers a request with function + 2
static constexpr uint8_t FUNCTION_NODE = 0x0c; // discovery, status and mode of a node
static constexpr uint8_t FUNCTION_NODE_DATA = 0x10; // a block of a node's data (sensors, serial number)
static constexpr uint8_t FUNCTION_PARAMETER = 0x24; // read or write a box parameter, set the clock
/// A received message; data points into the receive buffer and is valid only during the callback.
struct DucoMessage {
uint8_t id;
const uint8_t *data;
uint8_t size;
};
class DucoDevice;
/// The open request of one device: the answer to it is function + 2 with the same message id.
struct DucoPendingRequest {
DucoDevice *device;
uint8_t id;
uint8_t function; // 0 while nothing is open
bool missed; // the request before this one got no answer; sits in padding
};
class Duco : public uart::UARTDevice, public Component {
public:
void loop() override;
void dump_config() override;
void set_disable_crc(bool disable_crc) { this->disable_crc_ = disable_crc; }
#ifdef DUCO_DEVICE_COUNT
void register_device(DucoDevice *device) { this->pending_.push_back({device, 0, 0, false}); }
#endif
/// Forget device's open request, for a device that will not send again.
void close_request(DucoDevice *device);
/// Send a request; its answer goes to device, or nowhere if nullptr. A device's new request replaces its
/// open one, so an answer is never lost to another device's traffic.
void send(DucoDevice *device, uint8_t function, std::initializer_list<uint8_t> data);
protected:
// The longest answer to a request sent here is 25 bytes (node status), the longest request 9
static constexpr size_t MAX_FRAME_SIZE = 32;
static constexpr size_t MAX_REQUEST_DATA = 9;
bool parse_byte_(uint8_t byte);
void reset_frame_();
#ifdef DUCO_DEVICE_COUNT
void update_warning_(); // warn while any device's previous request went unanswered
#endif
void finalize_message_();
uint8_t next_id_();
#ifdef DUCO_DEVICE_COUNT
StaticVector<DucoPendingRequest, DUCO_DEVICE_COUNT> pending_;
#endif
std::array<uint8_t, MAX_FRAME_SIZE> rx_buffer_{};
uint32_t last_byte_{0};
uint8_t rx_size_{0};
uint8_t last_id_{10};
bool escaped_{false}; // the last 0xAA was followed by a stuffing byte, so it starts no header
bool disable_crc_{false};
};
/// Every entity polls the box; deriving from PollingComponent shares its vtable.
class DucoDevice : public PollingComponent, public Parented<Duco> {
public:
virtual void receive_response(const DucoMessage &message) = 0;
};
/// Walks the node addresses once after boot; the nodes found are listed in the config dump.
class DucoDiscovery : public DucoDevice {
public:
void setup() override;
void dump_config() override;
void update() override; // one discovery step, run by the step interval
void receive_response(const DucoMessage &message) override;
protected:
static constexpr uint8_t NODE_COUNT = 0x45;
std::array<uint8_t, NODE_COUNT> node_types_{}; // 0 when no node answered at that address
uint8_t next_node_{0};
uint8_t attempts_{0};
};
#ifdef USE_TIME
class DucoTime : public PollingComponent, public Parented<Duco> {
public:
explicit DucoTime(time::RealTimeClock *clock) : clock_(clock) {}
void update() override;
protected:
time::RealTimeClock *clock_;
};
#endif
} // namespace esphome::duco
@@ -1,34 +0,0 @@
from esphome.components import number
import esphome.config_validation as cv
from esphome.const import DEVICE_CLASS_TEMPERATURE, UNIT_CELSIUS
from esphome.types import ConfigType
from .. import (
CONF_DUCO_ID,
DUCO_COMPONENT_SCHEMA,
DucoDevice,
duco_ns,
register_duco_device,
)
DEPENDENCIES = ["duco"]
CODEOWNERS = ["@kokx"]
DucoComfortTemperature = duco_ns.class_(
"DucoComfortTemperature", DucoDevice, number.Number
)
CONFIG_SCHEMA = (
number.number_schema(
DucoComfortTemperature,
unit_of_measurement=UNIT_CELSIUS,
device_class=DEVICE_CLASS_TEMPERATURE,
)
.extend(DUCO_COMPONENT_SCHEMA)
.extend(cv.polling_component_schema("60s"))
)
async def to_code(config: ConfigType) -> None:
var = await number.new_number(config, min_value=10.0, max_value=25.5, step=0.1)
await register_duco_device(var, config, config[CONF_DUCO_ID])
-26
View File
@@ -1,26 +0,0 @@
#include "number.h"
#include <cmath>
namespace esphome::duco {
static constexpr uint8_t PARAMETER_COMFORT_TEMPERATURE = 0x12;
static constexpr uint8_t GROUP_COMFORT_TEMPERATURE = 0x0a;
void DucoComfortTemperature::update() {
this->parent_->send(this, FUNCTION_PARAMETER, {0x00, PARAMETER_COMFORT_TEMPERATURE, GROUP_COMFORT_TEMPERATURE});
}
void DucoComfortTemperature::receive_response(const DucoMessage &message) {
// Reads and writes get the same answer: one byte in tenths of a degree, below 10.0 is invalid
if (message.size >= 4 && message.data[3] >= 100)
this->publish_state(message.data[3] / 10.0f);
}
void DucoComfortTemperature::control(float value) {
const auto tenths = static_cast<uint8_t>(lroundf(value * 10.0f));
this->parent_->send(this, FUNCTION_PARAMETER,
{0x01, PARAMETER_COMFORT_TEMPERATURE, GROUP_COMFORT_TEMPERATURE, tenths, 0x00, 0x00, 0x00});
}
} // namespace esphome::duco
-18
View File
@@ -1,18 +0,0 @@
#pragma once
#include "esphome/components/number/number.h"
#include "../duco.h"
namespace esphome::duco {
class DucoComfortTemperature : public DucoDevice, public number::Number {
public:
void update() override;
void receive_response(const DucoMessage &message) override;
protected:
void control(float value) override;
};
} // namespace esphome::duco
@@ -1,45 +0,0 @@
from esphome.components import select
import esphome.config_validation as cv
from esphome.types import ConfigType
from .. import (
CONF_DUCO_ID,
DUCO_COMPONENT_SCHEMA,
DucoDevice,
duco_ns,
register_duco_device,
)
DEPENDENCIES = ["duco"]
CODEOWNERS = ["@kokx"]
# Same order as MODE_CODES in select.cpp
DUCO_MODE_OPTIONS = [
"AUTO",
"MAN1",
"MAN2",
"MAN3",
"EMPT",
"CNT1",
"CNT2",
"CNT3",
"MAN1x2",
"MAN2x2",
"MAN3x2",
"MAN1x3",
"MAN2x3",
"MAN3x3",
]
DucoSelect = duco_ns.class_("DucoSelect", DucoDevice, select.Select)
CONFIG_SCHEMA = (
select.select_schema(DucoSelect)
.extend(cv.polling_component_schema("3s"))
.extend(DUCO_COMPONENT_SCHEMA)
)
async def to_code(config: ConfigType) -> None:
var = await select.new_select(config, options=DUCO_MODE_OPTIONS)
await register_duco_device(var, config, config[CONF_DUCO_ID])
-47
View File
@@ -1,47 +0,0 @@
#include "select.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
namespace esphome::duco {
ESPHOME_LOG_TAG(TAG, "duco.select");
// Box code of each option, in the order of DUCO_MODE_OPTIONS in __init__.py
static constexpr uint8_t MODE_CODES[] PROGMEM = {
0x00, // AUTO
0x04, // MAN1
0x05, // MAN2
0x06, // MAN3
0x07, // EMPT
0x08, // CNT1
0x09, // CNT2
0x0a, // CNT3
0x84, // MAN1x2
0x85, // MAN2x2
0x86, // MAN3x2
0xc4, // MAN1x3
0xc5, // MAN2x3
0xc6, // MAN3x3
};
void DucoSelect::update() { this->parent_->send(this, FUNCTION_NODE, {0x02, 0x01}); }
void DucoSelect::receive_response(const DucoMessage &message) {
if (message.size < 1)
return;
for (size_t i = 0; i < sizeof(MODE_CODES); i++) {
if (progmem_read_byte(&MODE_CODES[i]) == message.data[0]) {
this->publish_state(i);
return;
}
}
ESP_LOGV(TAG, "Unknown mode %02X", message.data[0]);
}
void DucoSelect::control(size_t index) {
// The next update reads the new mode back
this->parent_->send(nullptr, FUNCTION_NODE, {0x04, 0x01, progmem_read_byte(&MODE_CODES[index])});
}
} // namespace esphome::duco
-18
View File
@@ -1,18 +0,0 @@
#pragma once
#include "esphome/components/select/select.h"
#include "../duco.h"
namespace esphome::duco {
class DucoSelect : public DucoDevice, public select::Select {
public:
void update() override;
void receive_response(const DucoMessage &message) override;
protected:
void control(size_t index) override;
};
} // namespace esphome::duco
-156
View File
@@ -1,156 +0,0 @@
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_ADDRESS,
CONF_CO2,
CONF_HUMIDITY,
CONF_TEMPERATURE,
DEVICE_CLASS_CARBON_DIOXIDE,
DEVICE_CLASS_DURATION,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_TEMPERATURE,
STATE_CLASS_MEASUREMENT,
UNIT_CELSIUS,
UNIT_PARTS_PER_MILLION,
UNIT_PERCENT,
UNIT_SECOND,
)
from esphome.types import ConfigType
from .. import (
CONF_DUCO_ID,
DUCO_COMPONENT_SCHEMA,
DucoDevice,
duco_ns,
register_duco_device,
)
DEPENDENCIES = ["duco"]
CODEOWNERS = ["@kokx"]
UNIT_DAYS = "d"
CONF_BYPASS = "bypass"
CONF_FILTER_REMAINING = "filter_remaining"
CONF_FLOW_LEVEL = "flow_level"
CONF_TEMPERATURE_EHA = "temperature_eha"
CONF_TEMPERATURE_ETA = "temperature_eta"
CONF_TEMPERATURE_ODA = "temperature_oda"
CONF_TEMPERATURE_SUP = "temperature_sup"
CONF_TIME_REMAINING = "time_remaining"
DucoSensor = duco_ns.class_("DucoSensor", DucoDevice, sensor.Sensor)
DucoReading = duco_ns.enum("DucoReading", is_class=True)
BOX_NODE = 1
def _schema(update_interval: str = "60s", **kwargs) -> cv.Schema:
return sensor.sensor_schema(
DucoSensor, state_class=STATE_CLASS_MEASUREMENT, **kwargs
).extend(cv.polling_component_schema(update_interval))
def _node_schema(**kwargs) -> cv.All:
"""A list of sensors, one per node address."""
return cv.ensure_list(
_schema(**kwargs).extend({cv.Required(CONF_ADDRESS): cv.int_range(0, 68)})
)
_TEMPERATURE = {
"unit_of_measurement": UNIT_CELSIUS,
"accuracy_decimals": 1,
"device_class": DEVICE_CLASS_TEMPERATURE,
}
# key: (what is read, request argument or None for the node address, schema)
SENSORS = {
CONF_CO2: (
DucoReading.DUCO_READING_CO2,
None,
_node_schema(
unit_of_measurement=UNIT_PARTS_PER_MILLION,
accuracy_decimals=0,
device_class=DEVICE_CLASS_CARBON_DIOXIDE,
),
),
CONF_TEMPERATURE: (
DucoReading.DUCO_READING_NODE_TEMPERATURE,
None,
_node_schema(**_TEMPERATURE),
),
CONF_HUMIDITY: (
DucoReading.DUCO_READING_HUMIDITY,
None,
_node_schema(
unit_of_measurement=UNIT_PERCENT,
accuracy_decimals=1,
device_class=DEVICE_CLASS_HUMIDITY,
),
),
CONF_FILTER_REMAINING: (
DucoReading.DUCO_READING_FILTER_REMAINING,
0x30,
_schema(
"1200s",
unit_of_measurement=UNIT_DAYS,
accuracy_decimals=0,
device_class=DEVICE_CLASS_DURATION,
),
),
CONF_FLOW_LEVEL: (
DucoReading.DUCO_READING_FLOW_LEVEL,
BOX_NODE,
_schema(unit_of_measurement=UNIT_PERCENT, accuracy_decimals=0),
),
CONF_TIME_REMAINING: (
DucoReading.DUCO_READING_TIME_REMAINING,
BOX_NODE,
_schema(
unit_of_measurement=UNIT_SECOND,
accuracy_decimals=0,
device_class=DEVICE_CLASS_DURATION,
),
),
CONF_TEMPERATURE_ODA: (
DucoReading.DUCO_READING_BOX_TEMPERATURE,
0x00,
_schema(**_TEMPERATURE),
),
CONF_TEMPERATURE_SUP: (
DucoReading.DUCO_READING_BOX_TEMPERATURE,
0x01,
_schema(**_TEMPERATURE),
),
CONF_TEMPERATURE_ETA: (
DucoReading.DUCO_READING_BOX_TEMPERATURE,
0x02,
_schema(**_TEMPERATURE),
),
CONF_TEMPERATURE_EHA: (
DucoReading.DUCO_READING_BOX_TEMPERATURE,
0x03,
_schema(**_TEMPERATURE),
),
CONF_BYPASS: (
DucoReading.DUCO_READING_BYPASS,
0x10,
_schema(unit_of_measurement=UNIT_PERCENT, accuracy_decimals=0),
),
}
CONFIG_SCHEMA = DUCO_COMPONENT_SCHEMA.extend(
{cv.Optional(key): schema for key, (_, _, schema) in SENSORS.items()}
)
async def to_code(config: ConfigType) -> None:
for key, (reading, argument, _) in SENSORS.items():
if (confs := config.get(key)) is None:
continue
for conf in confs if isinstance(confs, list) else [confs]:
var = await sensor.new_sensor(
conf, reading, conf.get(CONF_ADDRESS, argument)
)
await register_duco_device(var, conf, config[CONF_DUCO_ID])
-78
View File
@@ -1,78 +0,0 @@
#include "sensor.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
namespace esphome::duco {
ESPHOME_LOG_TAG(TAG, "duco.sensor");
enum class DucoValueFormat : uint8_t {
DUCO_VALUE_FORMAT_UINT8,
DUCO_VALUE_FORMAT_UINT16,
DUCO_VALUE_FORMAT_INT16,
};
/// A little endian field at offset in the answer. Values outside [minimum, maximum] are dropped;
/// the box reports those for sensors that are missing or still starting.
struct ReadingLayout {
uint8_t function;
uint8_t offset;
DucoValueFormat format;
uint8_t divisor;
int16_t minimum;
uint16_t maximum;
};
// Indexed by DucoReading
static constexpr ReadingLayout READINGS[] PROGMEM = {
{FUNCTION_NODE_DATA, 4, DucoValueFormat::DUCO_VALUE_FORMAT_UINT16, 1, 300, 10000}, // CO2
{FUNCTION_NODE_DATA, 2, DucoValueFormat::DUCO_VALUE_FORMAT_UINT16, 10, 0, 1000}, // node temperature
{FUNCTION_NODE_DATA, 6, DucoValueFormat::DUCO_VALUE_FORMAT_UINT16, 100, 0, 10000}, // humidity
{FUNCTION_PARAMETER, 3, DucoValueFormat::DUCO_VALUE_FORMAT_UINT8, 1, 0, 255}, // filter remaining
{FUNCTION_NODE, 2, DucoValueFormat::DUCO_VALUE_FORMAT_UINT8, 1, 0, 255}, // flow level
{FUNCTION_NODE, 12, DucoValueFormat::DUCO_VALUE_FORMAT_UINT16, 1, 0, 65535}, // time remaining
{FUNCTION_PARAMETER, 3, DucoValueFormat::DUCO_VALUE_FORMAT_INT16, 10, -999, 1000}, // box temperature
{FUNCTION_PARAMETER, 3, DucoValueFormat::DUCO_VALUE_FORMAT_UINT8, 1, 0, 100}, // bypass
};
static ReadingLayout layout_of(DucoReading reading) {
ReadingLayout layout;
progmem_memcpy(&layout, &READINGS[static_cast<uint8_t>(reading)], sizeof(layout));
return layout;
}
void DucoSensor::update() {
switch (layout_of(this->reading_).function) {
case FUNCTION_NODE_DATA:
this->parent_->send(this, FUNCTION_NODE_DATA, {0x01, this->argument_, 0x00, 0x49, 0x04});
break;
case FUNCTION_PARAMETER:
this->parent_->send(this, FUNCTION_PARAMETER, {0x00, this->argument_, 0x09});
break;
case FUNCTION_NODE:
this->parent_->send(this, FUNCTION_NODE, {0x02, this->argument_});
break;
}
}
void DucoSensor::receive_response(const DucoMessage &message) {
const ReadingLayout layout = layout_of(this->reading_);
const bool wide = layout.format != DucoValueFormat::DUCO_VALUE_FORMAT_UINT8;
if (message.size < layout.offset + (wide ? 2 : 1)) {
ESP_LOGV(TAG, "Answer of %u bytes is too short", message.size);
return;
}
const uint8_t *field = message.data + layout.offset;
int32_t value = field[0];
if (layout.format == DucoValueFormat::DUCO_VALUE_FORMAT_UINT16) {
value = encode_value_le<uint16_t>(field);
} else if (layout.format == DucoValueFormat::DUCO_VALUE_FORMAT_INT16) {
value = encode_value_le<int16_t>(field);
}
if (value >= layout.minimum && value <= layout.maximum)
this->publish_state(static_cast<float>(value) / layout.divisor);
}
} // namespace esphome::duco
-34
View File
@@ -1,34 +0,0 @@
#pragma once
#include "esphome/components/sensor/sensor.h"
#include "../duco.h"
namespace esphome::duco {
/// What a sensor reads; the request and where the value is in the answer are in READINGS in sensor.cpp.
enum class DucoReading : uint8_t {
DUCO_READING_CO2,
DUCO_READING_NODE_TEMPERATURE,
DUCO_READING_HUMIDITY,
DUCO_READING_FILTER_REMAINING,
DUCO_READING_FLOW_LEVEL,
DUCO_READING_TIME_REMAINING,
DUCO_READING_BOX_TEMPERATURE,
DUCO_READING_BYPASS,
};
class DucoSensor : public DucoDevice, public sensor::Sensor {
public:
/// argument is the node address, or the parameter number for a box parameter.
DucoSensor(DucoReading reading, uint8_t argument) : reading_(reading), argument_(argument) {}
void update() override;
void receive_response(const DucoMessage &message) override;
protected:
DucoReading reading_;
uint8_t argument_;
};
} // namespace esphome::duco
@@ -1,27 +0,0 @@
from esphome.components import text_sensor
import esphome.config_validation as cv
from esphome.types import ConfigType
from .. import (
CONF_DUCO_ID,
DUCO_COMPONENT_SCHEMA,
DucoDevice,
duco_ns,
register_duco_device,
)
DEPENDENCIES = ["duco"]
CODEOWNERS = ["@kokx"]
DucoSerial = duco_ns.class_("DucoSerial", DucoDevice, text_sensor.TextSensor)
CONFIG_SCHEMA = (
text_sensor.text_sensor_schema(DucoSerial)
.extend(cv.polling_component_schema("300s"))
.extend(DUCO_COMPONENT_SCHEMA)
)
async def to_code(config: ConfigType) -> None:
var = await text_sensor.new_text_sensor(config)
await register_duco_device(var, config, config[CONF_DUCO_ID])
@@ -1,17 +0,0 @@
#include "serial.h"
#include <cstring>
namespace esphome::duco {
void DucoSerial::update() { this->parent_->send(this, FUNCTION_NODE_DATA, {0x01, 0x01, 0x00, 0x1a, 0x10}); }
void DucoSerial::receive_response(const DucoMessage &message) {
if (message.size > 2) {
// The serial number starts at byte 2 and is padded with zeros
const char *serial = reinterpret_cast<const char *>(message.data + 2);
this->publish_state(serial, strnlen(serial, message.size - 2));
}
}
} // namespace esphome::duco
@@ -1,15 +0,0 @@
#pragma once
#include "esphome/components/text_sensor/text_sensor.h"
#include "../duco.h"
namespace esphome::duco {
class DucoSerial : public DucoDevice, public text_sensor::TextSensor {
public:
void update() override;
void receive_response(const DucoMessage &message) override;
};
} // namespace esphome::duco
@@ -0,0 +1,175 @@
"""Send sensor and binary sensor states to other devices over a packet interface."""
from collections.abc import Callable
from typing import Any
import esphome.codegen as cg
from esphome.components import packet_interface
from esphome.components.binary_sensor import BinarySensor
from esphome.components.const import CONF_PROVIDER, CONF_REMOTE_ID
from esphome.components.packet_interface import (
CONF_PACKET_INTERFACE,
final_validate_interface,
interface_layers,
)
from esphome.components.packet_interface.layers import CONF_SESSION
from esphome.components.sensor import Sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_BINARY_SENSORS,
CONF_ID,
CONF_SENSORS,
CONF_TIMEOUT,
CONF_UPDATE_INTERVAL,
)
from esphome.cpp_generator import MockObj, MockObjClass
from esphome.types import ConfigType
CODEOWNERS = ["@clydebarrow"]
DEPENDENCIES = ["packet_interface"]
DOMAIN = "entity_transport"
MULTI_CONF = True
CONF_BROADCAST_ID = "broadcast_id"
CONF_ENTITY_TRANSPORT_ID = "entity_transport_id"
entity_transport_ns = cg.esphome_ns.namespace("entity_transport")
EntityTransport = entity_transport_ns.class_("EntityTransport", cg.Component)
_request_remote_sensor_slot = cg.slot_counter("ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT")
_request_remote_binary_sensor_slot = cg.slot_counter(
"ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT"
)
def _advertised_id(conf: ConfigType) -> str:
return conf.get(CONF_BROADCAST_ID, conf[CONF_ID].id)
def _unique_advertised_ids(entities: list[ConfigType]) -> list[ConfigType]:
"""A receiver tells entities of one type apart by their advertised ids, so each must be different."""
seen: set[str] = set()
for index, conf in enumerate(entities):
if (advertised := _advertised_id(conf)) in seen:
raise cv.Invalid(
f"The id '{advertised}' is sent for two entities", path=[index]
)
seen.add(advertised)
return entities
def _local_entity(cls: MockObjClass) -> Callable[[Any], Any]:
return cv.maybe_simple_value(
cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(cls),
cv.Optional(CONF_BROADCAST_ID): cv.All(
cv.validate_id_name, cv.Length(max=255)
),
}
),
key=CONF_ID,
)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(EntityTransport),
cv.Required(CONF_PACKET_INTERFACE): cv.use_id(packet_interface.PacketInterface),
# Defaults to a periodic resend unless the interface has session and reliability layers
cv.Optional(CONF_UPDATE_INTERVAL): cv.All(
cv.update_interval, cv.Range(min=cv.TimePeriod(seconds=1))
),
# Remote entities lose their state when none arrives for this long
cv.Optional(CONF_TIMEOUT): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(min=cv.TimePeriod(seconds=1)),
),
# Entities are numbered with one byte
cv.Optional(CONF_SENSORS): cv.All(
cv.ensure_list(_local_entity(Sensor)),
cv.Length(max=255),
_unique_advertised_ids,
),
cv.Optional(CONF_BINARY_SENSORS): cv.All(
cv.ensure_list(_local_entity(BinarySensor)),
cv.Length(max=255),
_unique_advertised_ids,
),
}
).extend(cv.COMPONENT_SCHEMA)
def _final_validate(config: ConfigType) -> ConfigType:
"""With a session layer, which clears the states when the peer is lost, nothing would refresh an unchanged state."""
layers = interface_layers(config[CONF_PACKET_INTERFACE])
if CONF_TIMEOUT in config and layers is not None and CONF_SESSION in layers:
raise cv.Invalid(
f"Not allowed with a '{CONF_SESSION}' layer, which already clears the states when the other device "
"cannot be reached; unchanged states may never be sent again, so they would time out on a working link",
path=[CONF_TIMEOUT],
)
return config
FINAL_VALIDATE_SCHEMA = cv.All(
final_validate_interface(CONF_PACKET_INTERFACE), _final_validate
)
def _validate_remote_id(config: ConfigType) -> ConfigType:
if CONF_REMOTE_ID not in config and not config[CONF_ID].is_manual:
raise cv.Invalid(f"'{CONF_REMOTE_ID}' is required unless '{CONF_ID}' is given")
return config
def remote_entity_schema(base_schema: cv.Schema) -> cv.Schema:
"""Schema for an entity whose state comes from another device."""
return cv.All(
base_schema.extend(
{
cv.GenerateID(CONF_ENTITY_TRANSPORT_ID): cv.use_id(EntityTransport),
cv.Required(CONF_PROVIDER): cv.valid_name,
cv.Optional(CONF_REMOTE_ID): cv.All(
cv.string_strict, cv.Length(min=1, max=255)
),
}
),
_validate_remote_id,
)
def _remote_id(config: ConfigType) -> str:
return config.get(CONF_REMOTE_ID) or config[CONF_ID].id
async def register_remote_sensor(config: ConfigType, var: MockObj) -> None:
hub = await cg.get_variable(config[CONF_ENTITY_TRANSPORT_ID])
_request_remote_sensor_slot(str(hub))
cg.add(hub.add_remote_sensor(config[CONF_PROVIDER], _remote_id(config), var))
async def register_remote_binary_sensor(config: ConfigType, var: MockObj) -> None:
hub = await cg.get_variable(config[CONF_ENTITY_TRANSPORT_ID])
_request_remote_binary_sensor_slot(str(hub))
cg.add(hub.add_remote_binary_sensor(config[CONF_PROVIDER], _remote_id(config), var))
async def to_code(config: ConfigType) -> None:
interface = await cg.get_variable(config[CONF_PACKET_INTERFACE])
var = cg.new_Pvariable(config[CONF_ID], interface)
await cg.register_component(var, config)
if (interval := config.get(CONF_UPDATE_INTERVAL)) is not None:
cg.add(var.set_update_interval(interval))
if (timeout := config.get(CONF_TIMEOUT)) is not None:
cg.add(var.set_remote_timeout(timeout))
sensors = config.get(CONF_SENSORS, [])
binary_sensors = config.get(CONF_BINARY_SENSORS, [])
if count := len(sensors) + len(binary_sensors):
cg.add(var.set_entity_count(count))
for conf in sensors:
sensor = await cg.get_variable(conf[CONF_ID])
cg.add(var.add_sensor(_advertised_id(conf), sensor))
for conf in binary_sensors:
sensor = await cg.get_variable(conf[CONF_ID])
cg.add(var.add_binary_sensor(_advertised_id(conf), sensor))
@@ -0,0 +1,11 @@
from esphome.components import binary_sensor
from esphome.types import ConfigType
from . import register_remote_binary_sensor, remote_entity_schema
CONFIG_SCHEMA = remote_entity_schema(binary_sensor.binary_sensor_schema())
async def to_code(config: ConfigType) -> None:
var = await binary_sensor.new_binary_sensor(config)
await register_remote_binary_sensor(config, var)
@@ -0,0 +1,683 @@
#include "entity_transport.h"
#include <algorithm>
#include <cmath>
#include <cstring>
#include "esphome/core/application.h"
#include "esphome/core/log.h"
namespace esphome::entity_transport {
using packet_interface::PacketBuffer;
using packet_interface::PacketMetaData;
ESPHOME_LOG_TAG(TAG, "entity_transport");
// The packet type byte holds the format version in its high nibble
static constexpr uint8_t FORMAT_VERSION = 1;
static constexpr uint8_t PACKET_DICTIONARY = 0x11;
static constexpr uint8_t PACKET_DATA = 0x12;
static constexpr uint8_t PACKET_REQUEST = 0x13;
// Entity types, in dictionary sections and data records; 0 is not used
static constexpr uint8_t TYPE_SENSOR = 1;
static constexpr uint8_t TYPE_BINARY_SENSOR = 2;
static constexpr uint8_t MAX_TYPE = 31;
// Packet type and dictionary id
static constexpr size_t HEADER_SIZE = 5;
// A dictionary packet's part byte follows the header: the part's index in bits 0-6, and on the last part, bit 7
static constexpr uint8_t PART_INDEX_MASK = 0x7F;
static constexpr uint8_t PART_LAST = 0x80;
static constexpr uint8_t PART_NONE = 0xFF; // no dictionary is being received in order
// A data record starts with a 16-bit tag: the entity's number in bits 0-7, its type in bits 8-12, the payload form in
// bits 13-14, and with PAYLOAD_NONE, the state in bit 15. Any record can be skipped without knowing its type.
static constexpr size_t TAG_SIZE = 2;
static constexpr uint8_t TAG_TYPE_SHIFT = 8;
static constexpr uint8_t TAG_PAYLOAD_SHIFT = 13;
static constexpr uint16_t TAG_VALUE_BIT = 0x8000;
static constexpr uint8_t PAYLOAD_NONE = 0; // the state is in the tag's value bit
static constexpr uint8_t PAYLOAD_WORD = 1; // 4 bytes follow
static constexpr uint8_t PAYLOAD_LENGTH = 2; // a length byte follows, then that many bytes
static constexpr uint8_t PAYLOAD_NO_STATE = 3; // the entity has no state
static constexpr size_t WORD_SIZE = 4;
static constexpr size_t MAX_RECORD_SIZE = TAG_SIZE + WORD_SIZE;
// A dictionary section starts with the entity type, first number and count
static constexpr size_t SECTION_HEADER_SIZE = 3;
static constexpr size_t MAX_STRING_LENGTH = 255;
static constexpr uint32_t DEFAULT_UPDATE_INTERVAL_MS = 15000;
static constexpr uint32_t REQUEST_INTERVAL_MS = 5000;
static constexpr uint32_t RETRY_DELAY_MS = 1000;
static constexpr uint32_t RETRY_TIMER_ID = 0;
static constexpr uint32_t TIMEOUT_CHECK_INTERVAL_MS = 1000;
static constexpr uint32_t WARNING_INTERVAL_MS = 60000;
// How long the interface may refuse every packet before it is reported
static constexpr uint32_t REFUSED_WARNING_MS = 60000;
static void put_uint32(uint8_t *buf, uint32_t value) {
for (size_t i = 0; i != 4; i++)
buf[i] = value >> (i * 8);
}
// True if the NUL terminated str is the same as the len bytes at data
static bool matches(const char *str, const char *data, size_t len) {
return strlen(str) == len && memcmp(str, data, len) == 0;
}
struct Record {
uint8_t number;
uint8_t type;
uint8_t payload;
bool value;
const uint8_t *data; // what follows the tag
};
static Record parse_record(std::span<const uint8_t> packet, size_t pos) {
uint16_t tag = encode_uint16(packet[pos + 1], packet[pos]);
return {static_cast<uint8_t>(tag), static_cast<uint8_t>((tag >> TAG_TYPE_SHIFT) & MAX_TYPE),
static_cast<uint8_t>((tag >> TAG_PAYLOAD_SHIFT) & 3), (tag & TAG_VALUE_BIT) != 0,
packet.data() + pos + TAG_SIZE};
}
// The size of the data record at pos, or 0 if it does not fit in the packet
static size_t record_size(std::span<const uint8_t> packet, size_t pos) {
if (pos + TAG_SIZE > packet.size())
return 0;
size_t size = TAG_SIZE;
uint8_t payload = parse_record(packet, pos).payload;
if (payload == PAYLOAD_WORD) {
size += WORD_SIZE;
} else if (payload == PAYLOAD_LENGTH) {
if (pos + TAG_SIZE == packet.size())
return 0;
size += 1 + packet[pos + TAG_SIZE];
}
return pos + size <= packet.size() ? size : 0;
}
// Checks every record of a data packet, so that a damaged or forged packet changes no state at all
static bool data_well_formed(std::span<const uint8_t> packet) {
for (size_t pos = HEADER_SIZE; pos != packet.size();) {
size_t size = record_size(packet, pos);
if (size == 0)
return false;
Record record = parse_record(packet, pos);
if (record.type == 0 || (record.value && record.payload != PAYLOAD_NONE))
return false;
// Types this device knows must use their own payload form; others are skipped
if (record.type == TYPE_SENSOR && record.payload != PAYLOAD_WORD && record.payload != PAYLOAD_NO_STATE)
return false;
if (record.type == TYPE_BINARY_SENSOR && record.payload != PAYLOAD_NONE && record.payload != PAYLOAD_NO_STATE)
return false;
pos += size;
}
return true;
}
// Checks the device name and every section of a dictionary packet before anything is learnt from it
static bool dictionary_well_formed(std::span<const uint8_t> packet) {
size_t pos = HEADER_SIZE + 1;
if (pos >= packet.size())
return false;
pos += 1 + packet[pos];
if (pos > packet.size())
return false;
while (pos != packet.size()) {
if (pos + SECTION_HEADER_SIZE > packet.size())
return false;
uint8_t type = packet[pos];
size_t first = packet[pos + 1];
size_t count = packet[pos + 2];
pos += SECTION_HEADER_SIZE;
// Numbers are one byte, and a data record can name types up to MAX_TYPE only
if (type == 0 || type > MAX_TYPE || count == 0 || first + count > 256)
return false;
for (size_t i = 0; i != count; i++) {
if (pos == packet.size())
return false;
pos += 1 + packet[pos];
if (pos > packet.size())
return false;
}
}
return true;
}
void EntityTransport::setup() {
this->interface_ = this->interface_->top();
this->max_packet_size_ = this->interface_->get_max_packet_size();
const auto &name = App.get_name();
size_t longest_id = 0;
for (const auto &entity : this->entities_)
longest_id = std::max(longest_id, strlen(entity.id));
// A dictionary packet must hold the device name and any one id, which also leaves room for a data record
if (name.size() > MAX_STRING_LENGTH || longest_id > MAX_STRING_LENGTH ||
HEADER_SIZE + 2 + name.size() + SECTION_HEADER_SIZE + 1 + longest_id > this->max_packet_size_) {
this->mark_failed(LOG_STR("max_packet_size too small for the device name and entity ids"));
return;
}
RAMAllocator<uint8_t> allocator;
this->buffer_ = allocator.allocate(this->max_packet_size_);
if (this->buffer_ == nullptr) {
this->mark_failed(LOG_STR("Not enough memory for the packet buffer"));
return;
}
// The dictionary id changes when the device name or any entity changes
uint32_t hash = fnv1a_hash(name.c_str());
for (const auto &entity : this->entities_) {
hash = fnv1a_hash_extend(hash, entity.is_sensor ? TYPE_SENSOR : TYPE_BINARY_SENSOR);
hash = fnv1a_hash_extend(hash, entity.id);
}
this->dictionary_id_ = hash;
// [&entity] is safe: FixedVector never reallocates, and the parent pointer keeps the capture to one pointer
for (auto &entity : this->entities_) {
#ifdef USE_SENSOR
if (entity.is_sensor) {
static_cast<sensor::Sensor *>(entity.entity)->add_on_state_callback([&entity](float) {
entity.parent->mark_updated_(entity);
});
}
#endif
#ifdef USE_BINARY_SENSOR
if (!entity.is_sensor) {
// The full state callback also fires for the first state
static_cast<binary_sensor::BinarySensor *>(entity.entity)
->add_full_state_callback(
[&entity](optional<bool>, optional<bool>) { entity.parent->mark_updated_(entity); });
}
#endif
}
this->interface_->add_packet_interface_listener(
[this](const PacketBuffer &data, const PacketMetaData &) { this->receive_(data); });
bool session = this->interface_->has_capability(packet_interface::CAPABILITY_SESSION);
if (session)
this->interface_->add_on_session_state_callback([this](bool connected) { this->on_session_(connected); });
// Without a session and reliable delivery, a lost dictionary or state is only replaced by a periodic resend
if (this->update_interval_ms_ == 0) {
this->update_interval_ms_ = session && this->interface_->has_capability(packet_interface::CAPABILITY_RELIABILITY)
? SCHEDULER_DONT_RUN
: DEFAULT_UPDATE_INTERVAL_MS;
}
if (this->update_interval_ms_ != SCHEDULER_DONT_RUN)
this->set_interval(this->update_interval_ms_, [this] { this->send_all_(); });
if (this->remote_timeout_ms_ != 0)
this->set_interval(TIMEOUT_CHECK_INTERVAL_MS, [this] { this->clear_remote_states_(true); });
this->send_all_();
}
void EntityTransport::dump_config() {
ESP_LOGCONFIG(TAG,
"Entity Transport:\n"
" Packet Interface: %s\n"
" Max Packet Size: %zu\n"
" Encryption: %s\n"
" Reliability: %s\n"
" Session: %s\n"
" Dictionary ID: %08" PRIX32,
this->interface_->get_name(), this->max_packet_size_,
YESNO(this->interface_->has_capability(packet_interface::CAPABILITY_ENCRYPTION)),
YESNO(this->interface_->has_capability(packet_interface::CAPABILITY_RELIABILITY)),
YESNO(this->interface_->has_capability(packet_interface::CAPABILITY_SESSION)), this->dictionary_id_);
if (this->update_interval_ms_ == SCHEDULER_DONT_RUN) {
ESP_LOGCONFIG(TAG, " Update Interval: never");
} else {
ESP_LOGCONFIG(TAG, " Update Interval: %" PRIu32 "ms", this->update_interval_ms_);
}
if (this->remote_timeout_ms_ != 0) {
ESP_LOGCONFIG(TAG, " Remote Timeout: %" PRIu32 "ms", this->remote_timeout_ms_);
}
for (const auto &entity : this->entities_) {
ESP_LOGCONFIG(TAG, " %s %u: %s", entity.is_sensor ? LOG_STR_LITERAL("Sensor") : LOG_STR_LITERAL("Binary Sensor"),
entity.number, entity.id);
}
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
for (const auto &remote : this->remote_sensors_) {
ESP_LOGCONFIG(TAG, " Remote Sensor: %s from %s", remote.remote_id, remote.provider);
}
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
for (const auto &remote : this->remote_binary_sensors_) {
ESP_LOGCONFIG(TAG, " Remote Binary Sensor: %s from %s", remote.remote_id, remote.provider);
}
#endif
}
void EntityTransport::send_all_() {
this->dictionary_pending_ = true;
this->dictionary_next_ = 0;
this->dictionary_part_ = 0;
for (auto &entity : this->entities_)
entity.updated = true;
this->enable_loop();
}
void EntityTransport::mark_updated_(LocalEntity &entity) {
entity.updated = true;
this->enable_loop();
}
void EntityTransport::loop() {
this->disable_loop();
// States are only sent once the dictionary that names them has been accepted
if ((this->dictionary_pending_ && !this->send_dictionary_()) || !this->send_updated_()) {
this->sending_refused_();
this->set_timeout(RETRY_TIMER_ID, RETRY_DELAY_MS, [this] { this->enable_loop(); });
return;
}
this->refusing_ = false;
if (this->status_has_warning()) {
ESP_LOGI(TAG, "Packets are accepted again");
this->status_clear_warning();
}
}
// A link that is down or full refuses packets for a while; one that refuses them for long sends nothing at all
void EntityTransport::sending_refused_() {
uint32_t now = App.get_loop_component_start_time();
if (!this->refusing_) {
this->refusing_ = true;
this->refused_since_ms_ = now;
return;
}
if (!this->status_has_warning() && now - this->refused_since_ms_ >= REFUSED_WARNING_MS) {
ESP_LOGW(TAG, "The packet interface has accepted no packets for %" PRIu32 "s, so no states are sent",
(now - this->refused_since_ms_) / 1000);
this->status_set_warning();
}
}
size_t EntityTransport::write_header_(uint8_t type) const {
this->buffer_[0] = type;
put_uint32(this->buffer_ + 1, this->dictionary_id_);
return HEADER_SIZE;
}
bool EntityTransport::send_dictionary_() {
uint8_t *buf = this->buffer_;
const auto &name = App.get_name();
size_t header = this->write_header_(PACKET_DICTIONARY);
size_t part_offset = header++;
buf[header++] = name.size();
memcpy(buf + header, name.c_str(), name.size());
header += name.size();
size_t length = header;
size_t count_offset = 0;
uint8_t section_type = 0; // none
// A dictionary in several packets resumes after the last packet accepted, so a refused one does not resend the rest
for (size_t i = this->dictionary_next_; i != this->entities_.size(); i++) {
const auto &entity = this->entities_[i];
uint8_t type = entity.is_sensor ? TYPE_SENSOR : TYPE_BINARY_SENSOR;
size_t id_length = strlen(entity.id);
bool new_section = type != section_type;
if (length + (new_section ? SECTION_HEADER_SIZE : 0) + 1 + id_length > this->max_packet_size_) {
buf[part_offset] = this->dictionary_part_ & PART_INDEX_MASK;
if (!this->interface_->send_to_interface(PacketBuffer(buf, length), {})) {
ESP_LOGV(TAG, "Dictionary packet of %zu bytes not accepted, will retry", length);
return false;
}
this->dictionary_next_ = i;
this->dictionary_part_++;
length = header;
new_section = true;
}
if (new_section) {
buf[length++] = type;
buf[length++] = entity.number;
count_offset = length;
buf[length++] = 0;
section_type = type;
}
buf[count_offset]++;
buf[length++] = id_length;
memcpy(buf + length, entity.id, id_length);
length += id_length;
}
if (length != header) {
buf[part_offset] = (this->dictionary_part_ & PART_INDEX_MASK) | PART_LAST;
if (!this->interface_->send_to_interface(PacketBuffer(buf, length), {})) {
ESP_LOGV(TAG, "Dictionary packet of %zu bytes not accepted, will retry", length);
return false;
}
}
this->dictionary_pending_ = false;
this->dictionary_next_ = 0;
this->dictionary_part_ = 0;
return true;
}
// Writes the entity's data record, and returns its size
size_t EntityTransport::write_record_(const LocalEntity &entity, uint8_t *record) const {
uint8_t type = entity.is_sensor ? TYPE_SENSOR : TYPE_BINARY_SENSOR;
uint8_t payload = PAYLOAD_NO_STATE;
bool value = false;
size_t size = TAG_SIZE;
#ifdef USE_SENSOR
if (entity.is_sensor) {
float state = static_cast<sensor::Sensor *>(entity.entity)->get_state();
if (!std::isnan(state)) {
payload = PAYLOAD_WORD;
uint32_t bits;
memcpy(&bits, &state, sizeof(bits));
put_uint32(record + TAG_SIZE, bits);
size += WORD_SIZE;
}
}
#endif
#ifdef USE_BINARY_SENSOR
if (!entity.is_sensor) {
auto *sensor = static_cast<binary_sensor::BinarySensor *>(entity.entity);
if (sensor->has_state()) {
payload = PAYLOAD_NONE;
value = sensor->state;
}
}
#endif
uint16_t tag =
entity.number | (type << TAG_TYPE_SHIFT) | (payload << TAG_PAYLOAD_SHIFT) | (value ? TAG_VALUE_BIT : 0);
record[0] = tag;
record[1] = tag >> 8;
return size;
}
// Sends every updated entity, in as few packets as fit; returns false if a packet was refused
bool EntityTransport::send_updated_() {
size_t length = this->write_header_(PACKET_DATA);
size_t first = 0;
for (size_t i = 0; i != this->entities_.size(); i++) {
auto &entity = this->entities_[i];
if (!entity.updated)
continue;
uint8_t record[MAX_RECORD_SIZE];
size_t size = this->write_record_(entity, record);
if (length + size > this->max_packet_size_) {
if (!this->flush_(length, first, i))
return false;
length = HEADER_SIZE;
first = i;
}
memcpy(this->buffer_ + length, record, size);
length += size;
}
return this->flush_(length, first, this->entities_.size());
}
// Sends the packet holding the updated entities from first up to last, and clears their flags if it was accepted
bool EntityTransport::flush_(size_t length, size_t first, size_t last) {
if (length == HEADER_SIZE)
return true;
if (!this->interface_->send_to_interface(PacketBuffer(this->buffer_, length), {})) {
ESP_LOGV(TAG, "Packet of %zu bytes not accepted, will retry", length);
return false;
}
for (size_t i = first; i != last; i++)
this->entities_[i].updated = false;
return true;
}
void EntityTransport::receive_(const PacketBuffer &data) {
// Packet interface layers deliver packets in one piece
if (!data.is_contiguous()) {
ESP_LOGW(TAG, "Packet split across buffers is not supported");
return;
}
auto packet = data.as_span();
if (packet.size() < HEADER_SIZE) {
if (this->warning_due_()) {
ESP_LOGW(TAG, "Packet of %zu bytes is too short", packet.size());
}
return;
}
if (packet[0] >> 4 != FORMAT_VERSION) {
if (this->warning_due_()) {
ESP_LOGW(TAG, "Packet format version %u not supported; the sender may run another ESPHome version",
packet[0] >> 4);
}
return;
}
uint32_t dictionary_id = encode_uint32(packet[4], packet[3], packet[2], packet[1]);
if (dictionary_id == this->dictionary_id_) {
// Either a request for our dictionary, or our own packet heard on a broadcast channel
if (packet[0] == PACKET_REQUEST && packet.size() == HEADER_SIZE) {
ESP_LOGD(TAG, "Dictionary requested");
this->send_all_();
}
return;
}
if (packet[0] == PACKET_DICTIONARY) {
this->receive_dictionary_(packet, dictionary_id);
} else if (packet[0] == PACKET_DATA) {
this->receive_data_(packet, dictionary_id);
} else if (packet[0] != PACKET_REQUEST) {
ESP_LOGV(TAG, "Unknown packet type 0x%02X", packet[0]);
}
}
void EntityTransport::receive_dictionary_(std::span<const uint8_t> packet, uint32_t dictionary_id) {
if (!dictionary_well_formed(packet)) {
if (this->warning_due_()) {
ESP_LOGW(TAG, "Dictionary packet of %zu bytes is malformed", packet.size());
}
return;
}
size_t pos = HEADER_SIZE;
uint8_t part = packet[pos++];
uint8_t index = part & PART_INDEX_MASK;
// The dictionary is complete, and need not be asked for again, once its last part follows all the others in order
bool in_order = index == 0 || (this->rx_dictionary_id_ == dictionary_id && index == this->rx_next_part_);
this->rx_dictionary_id_ = dictionary_id;
this->rx_next_part_ = in_order ? (index + 1) & PART_INDEX_MASK : PART_NONE;
bool completed = in_order && (part & PART_LAST) != 0;
bool first_time = completed && !this->dictionary_seen_(dictionary_id);
if (first_time) {
this->seen_dictionaries_[this->seen_next_] = dictionary_id;
this->seen_next_ = (this->seen_next_ + 1) % this->seen_dictionaries_.size();
}
size_t provider_length = packet[pos++];
const auto *provider = reinterpret_cast<const char *>(packet.data() + pos);
pos += provider_length;
while (pos != packet.size()) {
uint8_t type = packet[pos];
uint8_t number = packet[pos + 1];
uint8_t count = packet[pos + 2];
pos += SECTION_HEADER_SIZE;
for (uint8_t i = 0; i != count; i++) {
size_t id_length = packet[pos++];
this->learn_(provider, provider_length, type, number + i, reinterpret_cast<const char *>(packet.data() + pos),
id_length, dictionary_id);
pos += id_length;
}
}
// A provider may go back to a dictionary already seen, so every complete one is checked
if (completed)
this->forget_unnamed_(provider, provider_length, dictionary_id, first_time);
}
void EntityTransport::forget_unnamed_(const char *provider, size_t provider_length, uint32_t dictionary_id,
bool first_time) {
// A remote entity that its provider's whole dictionary does not name would otherwise keep no or a stale state
// silently
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
for (auto &remote : this->remote_sensors_) {
if (!matches(remote.provider, provider, provider_length) || (remote.known && remote.dictionary_id == dictionary_id))
continue;
if (remote.known || first_time) {
ESP_LOGW(TAG, "'%s' does not send '%s'", remote.provider, remote.remote_id);
}
remote.known = false;
if (remote.has_state) {
remote.has_state = false;
remote.entity->publish_state(NAN);
}
}
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
for (auto &remote : this->remote_binary_sensors_) {
if (!matches(remote.provider, provider, provider_length) || (remote.known && remote.dictionary_id == dictionary_id))
continue;
if (remote.known || first_time) {
ESP_LOGW(TAG, "'%s' does not send '%s'", remote.provider, remote.remote_id);
}
remote.known = false;
if (remote.has_state) {
remote.has_state = false;
remote.entity->invalidate_state();
}
}
#endif
}
void EntityTransport::learn_(const char *provider, size_t provider_length, uint8_t type, uint8_t number, const char *id,
size_t id_length, uint32_t dictionary_id) {
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
if (type == TYPE_SENSOR) {
for (auto &remote : this->remote_sensors_) {
if (matches(remote.provider, provider, provider_length) && matches(remote.remote_id, id, id_length)) {
remote.dictionary_id = dictionary_id;
remote.number = number;
remote.known = true;
}
}
}
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
if (type == TYPE_BINARY_SENSOR) {
for (auto &remote : this->remote_binary_sensors_) {
if (matches(remote.provider, provider, provider_length) && matches(remote.remote_id, id, id_length)) {
remote.dictionary_id = dictionary_id;
remote.number = number;
remote.known = true;
}
}
}
#endif
}
bool EntityTransport::dictionary_seen_(uint32_t dictionary_id) const {
return std::find(this->seen_dictionaries_.begin(), this->seen_dictionaries_.end(), dictionary_id) !=
this->seen_dictionaries_.end();
}
void EntityTransport::receive_data_(std::span<const uint8_t> packet, uint32_t dictionary_id) {
bool known = false;
bool has_remotes = false;
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
for (const auto &remote : this->remote_sensors_) {
known |= remote.known && remote.dictionary_id == dictionary_id;
has_remotes = true;
}
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
for (const auto &remote : this->remote_binary_sensors_) {
known |= remote.known && remote.dictionary_id == dictionary_id;
has_remotes = true;
}
#endif
if (!has_remotes)
return;
// Checked first, so a damaged packet does not make the provider resend everything
if (!data_well_formed(packet)) {
if (this->warning_due_()) {
ESP_LOGW(TAG, "Data packet of %zu bytes is malformed", packet.size());
}
return;
}
// A dictionary not yet received in full may name our entities, or be a provider's new one; one received in full
// named all of them it could, so asking again would only make that provider resend everything
if (!this->dictionary_seen_(dictionary_id))
this->request_dictionary_(dictionary_id);
if (!known)
return;
[[maybe_unused]] uint32_t now = App.get_loop_component_start_time();
for (size_t pos = HEADER_SIZE; pos != packet.size(); pos += record_size(packet, pos)) {
[[maybe_unused]] Record record = parse_record(packet, pos);
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
if (record.type == TYPE_SENSOR) {
float value = NAN;
if (record.payload == PAYLOAD_WORD) {
uint32_t bits = encode_uint32(record.data[3], record.data[2], record.data[1], record.data[0]);
memcpy(&value, &bits, sizeof(value));
}
for (auto &remote : this->remote_sensors_) {
if (remote.known && remote.dictionary_id == dictionary_id && remote.number == record.number) {
remote.last_state_ms = now;
remote.has_state = true;
remote.entity->publish_state(value);
}
}
}
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
if (record.type == TYPE_BINARY_SENSOR) {
for (auto &remote : this->remote_binary_sensors_) {
if (!remote.known || remote.dictionary_id != dictionary_id || remote.number != record.number)
continue;
remote.last_state_ms = now;
remote.has_state = true;
if (record.payload == PAYLOAD_NO_STATE) {
remote.entity->invalidate_state();
} else {
remote.entity->publish_state(record.value);
}
}
}
#endif
}
}
void EntityTransport::request_dictionary_(uint32_t dictionary_id) {
uint32_t now = App.get_loop_component_start_time();
if (this->request_sent_ && now - this->last_request_ms_ < REQUEST_INTERVAL_MS)
return;
uint8_t packet[HEADER_SIZE] = {PACKET_REQUEST};
put_uint32(packet + 1, dictionary_id);
// A refused request is tried again with the next data packet under that dictionary id
if (!this->interface_->send_to_interface(PacketBuffer(packet, sizeof(packet)), {})) {
ESP_LOGV(TAG, "Request for dictionary %08" PRIX32 " not accepted", dictionary_id);
return;
}
this->request_sent_ = true;
this->last_request_ms_ = now;
ESP_LOGD(TAG, "Requested dictionary %08" PRIX32, dictionary_id);
}
// Dropped packets point to a device with other firmware or a fault, and may arrive steadily, so warn only now and then
bool EntityTransport::warning_due_() {
uint32_t now = App.get_loop_component_start_time();
if (this->warned_ && now - this->last_warning_ms_ < WARNING_INTERVAL_MS)
return false;
this->warned_ = true;
this->last_warning_ms_ = now;
return true;
}
// A newly connected or restarted peer needs the dictionary and every state; while it is lost, its entities have no
// state
void EntityTransport::on_session_(bool connected) {
if (connected) {
this->send_all_();
return;
}
this->clear_remote_states_(false);
}
// Clears the state of every remote entity, or with timed_out_only, of those whose state is older than the timeout
void EntityTransport::clear_remote_states_(bool timed_out_only) {
[[maybe_unused]] uint32_t now = App.get_loop_component_start_time();
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
for (auto &remote : this->remote_sensors_) {
if (timed_out_only && (!remote.has_state || now - remote.last_state_ms <= this->remote_timeout_ms_))
continue;
remote.has_state = false;
remote.entity->publish_state(NAN);
}
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
for (auto &remote : this->remote_binary_sensors_) {
if (timed_out_only && (!remote.has_state || now - remote.last_state_ms <= this->remote_timeout_ms_))
continue;
remote.has_state = false;
remote.entity->invalidate_state();
}
#endif
}
} // namespace esphome::entity_transport
@@ -0,0 +1,171 @@
#pragma once
#include <array>
#include <span>
#include "esphome/components/packet_interface/packet_interface.h"
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/core/entity_base.h"
#include "esphome/core/helpers.h"
#ifdef USE_SENSOR
#include "esphome/components/sensor/sensor.h"
#endif
#ifdef USE_BINARY_SENSOR
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
namespace esphome::entity_transport {
/**
* Sends the states of local sensors and binary sensors over a packet interface, and publishes the states received
* from other devices to remote entities.
*
* Each device numbers its sensors and its binary sensors from 0, and sends a dictionary that gives the id for each
* number. Data packets then refer to an entity by its number. Every packet carries the sender's dictionary id, a hash
* of its device name and dictionary, so that a receiver knows which device sent it and which dictionary applies.
* All values are little endian.
*
* Dictionary packet:
* - type: 1 byte, PACKET_DICTIONARY;
* - dictionary id: 4 bytes;
* - part: 1 byte, the packet's index in the dictionary in bits 0-6, counting from 0 and wrapping, and bit 7 set on
* the last packet;
* - sender's device name: length (1 byte), then the name;
* - one or more sections, each: entity type (1 byte), first number (1 byte), count (1 byte), then for each entity
* its id: length (1 byte), then the id.
*
* Data packet:
* - type: 1 byte, PACKET_DATA;
* - dictionary id: 4 bytes;
* - records, to the end, each a 16-bit tag, then its payload. The tag holds the entity's number in bits 0-7, its type
* in bits 8-12, and the payload form in bits 13-14: none, with the state in bit 15 (binary sensors); 4 bytes (a
* sensor's float); a length byte, then that many bytes; or no state, with no payload. The form gives the record's
* size, so a receiver skips records of types it does not know.
*
* Every packet is checked in full before anything in it is used, so a damaged or forged packet changes no state.
*
* Dictionary request packet, sent by a device that receives data with a dictionary id it has not received in full,
* with every part in order:
* - type: 1 byte, PACKET_REQUEST;
* - the dictionary id wanted: 4 bytes.
*
* Changed states are sent from loop(). The dictionary and all states are sent at boot, when a session layer reports
* the peer connected, when the dictionary is requested, and every update interval. Unless the interface has both a
* session and a reliability layer, the update interval defaults to DEFAULT_UPDATE_INTERVAL_MS. When the interface
* refuses a packet, sending is tried again after a delay, and a warning is set once it has refused every packet for a
* minute. With a remote timeout, a remote entity loses its state when
* none arrives for it within the timeout.
*/
class EntityTransport : public Component {
public:
explicit EntityTransport(packet_interface::PacketInterface *interface) : interface_(interface) {}
void setup() override;
void loop() override;
void dump_config() override;
/// 0 chooses the default for the interface's layers; SCHEDULER_DONT_RUN never resends.
void set_update_interval(uint32_t interval_ms) { this->update_interval_ms_ = interval_ms; }
/// Remote entities lose their state when none arrives for this long; 0 keeps the last state.
void set_remote_timeout(uint32_t timeout_ms) { this->remote_timeout_ms_ = timeout_ms; }
void set_entity_count(size_t count) { this->entities_.init(count); }
// Sensors must be added before binary sensors, so that data packets hold sensor records first
#ifdef USE_SENSOR
void add_sensor(const char *id, sensor::Sensor *sensor) {
this->entities_.push_back({sensor, id, this, this->sensor_count_++, true, true});
}
#endif
#ifdef USE_BINARY_SENSOR
void add_binary_sensor(const char *id, binary_sensor::BinarySensor *sensor) {
this->entities_.push_back({sensor, id, this, this->binary_sensor_count_++, false, true});
}
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
void add_remote_sensor(const char *provider, const char *remote_id, sensor::Sensor *sensor) {
this->remote_sensors_.push_back({provider, remote_id, sensor});
}
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
void add_remote_binary_sensor(const char *provider, const char *remote_id, binary_sensor::BinarySensor *sensor) {
this->remote_binary_sensors_.push_back({provider, remote_id, sensor});
}
#endif
protected:
struct LocalEntity {
EntityBase *entity; // a sensor::Sensor or a binary_sensor::BinarySensor, as is_sensor says
const char *id;
EntityTransport *parent;
uint8_t number;
bool is_sensor;
bool updated;
};
template<typename T> struct RemoteEntity {
const char *provider;
const char *remote_id;
T *entity;
uint32_t dictionary_id{0}; // learnt from the provider's dictionary
uint32_t last_state_ms{0};
uint8_t number{0};
bool known{false};
bool has_state{false}; // a state arrived, and has not yet timed out
};
void send_all_();
void mark_updated_(LocalEntity &entity);
bool send_dictionary_();
bool send_updated_();
size_t write_record_(const LocalEntity &entity, uint8_t *record) const;
bool flush_(size_t length, size_t first, size_t last);
size_t write_header_(uint8_t type) const;
void receive_(const packet_interface::PacketBuffer &data);
void receive_dictionary_(std::span<const uint8_t> packet, uint32_t dictionary_id);
void receive_data_(std::span<const uint8_t> packet, uint32_t dictionary_id);
void learn_(const char *provider, size_t provider_length, uint8_t type, uint8_t number, const char *id,
size_t id_length, uint32_t dictionary_id);
void request_dictionary_(uint32_t dictionary_id);
bool dictionary_seen_(uint32_t dictionary_id) const;
/// Clears each remote entity of \p provider that its complete dictionary does not name, warning if the entity was
/// known or the dictionary is \p first_time seen, so a resent dictionary does not repeat the warning.
void forget_unnamed_(const char *provider, size_t provider_length, uint32_t dictionary_id, bool first_time);
bool warning_due_();
void sending_refused_();
void on_session_(bool connected);
void clear_remote_states_(bool timed_out_only);
packet_interface::PacketInterface *interface_;
FixedVector<LocalEntity> entities_;
#ifdef ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT
StaticVector<RemoteEntity<sensor::Sensor>, ENTITY_TRANSPORT_REMOTE_SENSOR_COUNT> remote_sensors_;
#endif
#ifdef ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT
StaticVector<RemoteEntity<binary_sensor::BinarySensor>, ENTITY_TRANSPORT_REMOTE_BINARY_SENSOR_COUNT>
remote_binary_sensors_;
#endif
// Packets are built here, allocated in setup()
uint8_t *buffer_{nullptr};
size_t max_packet_size_{0};
uint32_t update_interval_ms_{0};
uint32_t remote_timeout_ms_{0};
uint32_t dictionary_id_{0};
uint32_t last_request_ms_{0};
uint32_t last_warning_ms_{0};
uint32_t refused_since_ms_{0}; // when the interface began refusing every packet
uint32_t rx_dictionary_id_{0}; // the dictionary whose parts are arriving
// The last dictionary ids received in full, so a provider whose dictionary names none of our entities is not asked
// again
std::array<uint32_t, 8> seen_dictionaries_{};
uint8_t seen_next_{0};
uint8_t rx_next_part_{0}; // the index of its next part, or PART_NONE if one was missed
uint8_t dictionary_part_{0}; // the index of the next dictionary packet to send
uint8_t sensor_count_{0};
uint8_t binary_sensor_count_{0};
size_t dictionary_next_{0}; // the first entity of the next dictionary packet to send
bool dictionary_pending_{true};
bool request_sent_{false};
bool warned_{false};
bool refusing_{false};
};
} // namespace esphome::entity_transport
@@ -0,0 +1,11 @@
from esphome.components import sensor
from esphome.types import ConfigType
from . import register_remote_sensor, remote_entity_schema
CONFIG_SCHEMA = remote_entity_schema(sensor.sensor_schema())
async def to_code(config: ConfigType) -> None:
var = await sensor.new_sensor(config)
await register_remote_sensor(config, var)
+8 -64
View File
@@ -1,5 +1,4 @@
#include "epaper_spi.h"
#include <algorithm>
#include <cinttypes>
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
@@ -38,42 +37,6 @@ bool EPaperBase::init_buffer_(size_t buffer_length) {
return true;
}
bool EPaperBase::init_sent_frame_(size_t length) {
if (!this->is_using_partial_update_())
return false;
if (this->sent_.init(length))
return true;
ESP_LOGW(TAG, "No memory for the previous frame; partial updates will send the whole drawn area");
return false;
}
uint32_t EPaperBase::buffer_hash_() const {
uint32_t hash = FNV1_OFFSET_BASIS;
size_t index = 0;
while (index != this->buffer_.size()) {
size_t length;
const uint8_t *data = this->buffer_.get_span(index, length);
for (size_t i = 0; i != length; i++)
hash = fnv1a_hash_extend(hash, data[i]);
index += length;
}
return hash;
}
void EPaperBase::set_full_bounds_() {
this->x_low_ = 0;
this->y_low_ = 0;
this->x_high_ = this->width_;
this->y_high_ = this->height_;
}
void EPaperBase::reset_bounds_() {
this->x_low_ = this->width_;
this->x_high_ = 0;
this->y_low_ = this->height_;
this->y_high_ = 0;
}
void EPaperBase::setup_pins_() const {
for (auto *pin : this->enable_pins_) {
pin->setup();
@@ -270,28 +233,17 @@ void EPaperBase::process_state_() {
// Refresh the whole panel even if nothing was drawn
this->full_update_requested_ = false;
this->update_count_ = 0;
this->set_full_bounds_();
this->x_low_ = 0;
this->y_low_ = 0;
this->x_high_ = this->width_;
this->y_high_ = this->height_;
}
if (this->x_high_ < this->x_low_ || this->y_high_ < this->y_low_) {
this->set_state_(EPaperState::IDLE);
return;
}
if (this->is_using_partial_update_()) {
const uint32_t frame_hash = this->buffer_hash_();
if (this->update_count_ != 0 && this->shown_hash_valid_ && frame_hash == this->shown_hash_) {
ESP_LOGD(TAG, "Frame unchanged, refresh skipped");
this->reset_bounds_();
this->set_state_(EPaperState::IDLE);
return;
}
this->shown_hash_ = frame_hash;
}
this->drawn_since_hash_ = false;
// Valid again only once this frame is on the panel, so an abandoned update leaves nothing to match
this->shown_hash_valid_ = false;
// A full update resets update_count_ to zero, so a non-zero count marks a partial one
this->partial_ = this->update_count_ != 0;
this->sent_count_ = 0;
this->set_state_(EPaperState::RESET);
break;
case EPaperState::INITIALISE:
@@ -304,15 +256,10 @@ void EPaperBase::process_state_() {
if (!this->transfer_data()) {
return; // Not done yet, come back next loop
}
if (this->sent_.is_valid() && this->sent_count_ == this->sent_.size())
this->sent_valid_ = true;
// A draw during the chunked push may not have reached the panel, so the next update sends the whole frame
this->sent_hash_intact_ = this->is_using_partial_update_() && !this->drawn_since_hash_;
if (this->drawn_since_hash_) {
this->set_full_bounds_();
} else {
this->reset_bounds_();
}
this->x_low_ = this->width_;
this->x_high_ = 0;
this->y_low_ = this->height_;
this->y_high_ = 0;
this->set_state_(EPaperState::POWER_ON);
break;
case EPaperState::POWER_ON:
@@ -333,7 +280,6 @@ void EPaperBase::process_state_() {
break;
case EPaperState::DEEP_SLEEP:
this->deep_sleep();
this->shown_hash_valid_ = this->sent_hash_intact_;
this->status_clear_warning();
this->set_state_(EPaperState::IDLE);
ESP_LOGD(TAG, "Display update took %" PRIu32 " ms", millis() - this->update_start_time_);
@@ -419,7 +365,6 @@ bool EPaperBase::rotate_coordinates_(int &x, int &y) {
this->x_high_ = clamp_at_least(this->x_high_, x + 1);
this->y_low_ = clamp_at_most(this->y_low_, y);
this->y_high_ = clamp_at_least(this->y_high_, y + 1);
this->drawn_since_hash_ = true;
return true;
}
@@ -541,7 +486,6 @@ void HOT EPaperBase::draw_rgb565_block_(uint8_t *buffer, int x_start, int y_star
this->x_high_ = clamp_at_least(this->x_high_, x_high);
this->y_low_ = clamp_at_most(this->y_low_, y_low);
this->y_high_ = clamp_at_least(this->y_high_, y_high);
this->drawn_since_hash_ = true;
}
// Writes an RGB565 block of pixels, as LVGL sends them, straight into a 1 bit per pixel buffer that is one block (the
+4 -27
View File
@@ -99,7 +99,10 @@ class EPaperBase : public Display,
// We store 8 pixels per byte
this->buffer_.fill(pixel_color);
this->mark_all_drawn_();
this->x_high_ = this->width_;
this->y_high_ = this->height_;
this->x_low_ = 0;
this->y_low_ = 0;
}
void clear() override {
@@ -136,25 +139,6 @@ class EPaperBase : public Display,
void wait_for_idle_(bool should_wait);
void abandon_update_();
bool init_buffer_(size_t buffer_length);
// Allocates the copy of the previous frame; only drivers that diff against it (needs_previous_frame) call this
bool init_sent_frame_(size_t length);
void record_sent_(size_t index, const uint8_t *data, size_t length) {
if (this->sent_.is_valid()) {
this->sent_.write(index, data, length);
this->sent_count_ += length;
}
}
// Whether the driver reads the previous frame (sent_) for its differential refresh
virtual bool needs_previous_frame() const { return false; }
// FNV-1a hash of the current frame buffer, to detect an unchanged frame without a full copy
uint32_t buffer_hash_() const;
void reset_bounds_();
void set_full_bounds_();
// For a draw that writes the whole buffer without going through rotate_coordinates_()
void mark_all_drawn_() {
this->set_full_bounds_();
this->drawn_since_hash_ = true;
}
void update_effective_transform_();
bool rotate_coordinates_(int &x, int &y);
void draw_rgb565_block_(uint8_t *buffer, int x_start, int y_start, int w, int h, const uint8_t *ptr, bool big_endian,
@@ -216,13 +200,6 @@ class EPaperBase : public Display,
size_t buffer_length_;
size_t current_data_index_{}; // used by data transfer to track progress
split_buffer::SplitBuffer buffer_{};
split_buffer::SplitBuffer sent_{}; // the previous frame, kept only by drivers that diff against it
bool sent_valid_{}; // sent_ holds a whole frame
size_t sent_count_{}; // bytes recorded into sent_ by the update in progress
uint32_t shown_hash_{}; // hash of the frame on the panel, to skip an unchanged update
bool shown_hash_valid_{}; // shown_hash_ holds the hash of a frame whose refresh completed
bool sent_hash_intact_{}; // the buffer was unchanged by the end of the push of shown_hash_
bool drawn_since_hash_{}; // something was drawn after shown_hash_ was taken
GPIOPin *dc_pin_{};
GPIOPin *busy_pin_{};
GPIOPin *reset_pin_{};
@@ -18,7 +18,11 @@ void EPaper4bpp::fill(Color color) {
// We store 2 pixels per byte
this->buffer_.fill(pixel_color + (pixel_color << 4));
this->mark_all_drawn_();
// Whole buffer just changed; mark the entire canvas dirty.
this->x_low_ = 0;
this->y_low_ = 0;
this->x_high_ = this->width_;
this->y_high_ = this->height_;
}
void EPaper4bpp::clear() {
@@ -71,7 +71,10 @@ void EPaperInkplate2::fill(Color color) {
for (size_t i = half_buffer; i < this->buffer_length_; i++)
this->buffer_[i] = red_byte;
this->mark_all_drawn_();
this->x_low_ = 0;
this->y_low_ = 0;
this->x_high_ = this->width_;
this->y_high_ = this->height_;
}
void EPaperInkplate2::clear() { this->fill(COLOR_ON); }
@@ -30,7 +30,6 @@ void EPaperJD79660::fill(Color color) {
// We store 4 pixels per byte
this->buffer_.fill(pixel_color | (pixel_color << 2) | (pixel_color << 4) | (pixel_color << 6));
this->drawn_since_hash_ = true;
}
void HOT EPaperJD79660::draw_pixel_at(int x, int y, Color color) {
@@ -7,12 +7,6 @@
namespace esphome::epaper_spi {
ESPHOME_LOG_TAG(TAG, "epaper_spi.mono");
void EPaperMono::setup() {
EPaperBase::setup();
if (!this->is_failed() && this->needs_previous_frame())
this->init_sent_frame_(this->sent_frame_length());
}
void EPaperMono::refresh_screen(bool partial) {
ESP_LOGV(TAG, "Refresh screen");
this->cmd_data(0x22, {partial ? (uint8_t) 0xFF : (uint8_t) 0xF7});
@@ -72,8 +66,7 @@ bool HOT EPaperMono::transfer_data() {
ESP_LOGV(TAG, "Writing %u bytes at line %zu at %ums", row_length, this->current_data_index_, (unsigned) millis());
this->start_data_();
while (this->current_data_index_ != this->y_high_) {
const size_t row_start = this->current_data_index_ * this->row_width_ + this->x_low_ / 8;
size_t data_idx = row_start;
size_t data_idx = this->current_data_index_ * this->row_width_ + this->x_low_ / 8;
for (size_t i = 0; i != row_length; i++) {
bytes_to_send[i] = this->send_red_ && !this->send_red_as_image_ ? 0 : this->buffer_[data_idx++];
}
@@ -12,8 +12,6 @@ class EPaperMono : public EPaperBase {
size_t init_sequence_length, DisplayType display_type = DISPLAY_TYPE_BINARY, uint8_t bits_per_pixel = 1)
: EPaperBase(name, width, height, init_sequence, init_sequence_length, display_type, bits_per_pixel) {}
void setup() override;
protected:
void refresh_screen(bool partial) override;
void power_on() override {}
@@ -21,8 +19,6 @@ class EPaperMono : public EPaperBase {
void deep_sleep() override;
bool reset() override;
virtual void set_window();
// Bytes in the comparison frame; the buffer's size unless a subclass sends the frame in another layout
virtual size_t sent_frame_length() const { return this->buffer_length_; }
bool transfer_data() override;
bool send_red_{true};
bool software_reset_{true}; // send 0x12 after the hardware reset
@@ -6,6 +6,21 @@
#include "esphome/core/log.h"
namespace esphome::epaper_spi {
ESPHOME_LOG_TAG(TAG, "epaper_spi.ssd1677");
void EPaperSSD1677::setup() {
EPaperMono::setup();
if (!this->is_failed())
this->init_comparison_frame_();
}
void EPaperSSD1677::init_comparison_frame_() {
if (!this->is_using_partial_update_())
return;
if (!this->sent_.init(this->plane_row_length_() * this->height_)) {
ESP_LOGW(TAG, "No memory for the comparison frame; partial updates will degrade unchanged areas");
}
}
void EPaperSSD1677::plane_row(size_t y, uint8_t *out) {
const size_t row_length = this->plane_row_length_();
@@ -67,7 +82,7 @@ bool HOT EPaperSSD1677::transfer_data() {
}
this->write_array(data, length);
if (this->plane_ == 1)
this->record_sent_(this->current_data_index_, data, length);
this->sent_.write(this->current_data_index_, data, length);
this->current_data_index_ += length;
if (this->current_data_index_ != plane_length && millis() - start_time > MAX_TRANSFER_TIME) {
// Let the main loop run and come back next loop
@@ -26,12 +26,12 @@ class EPaperSSD1677 : public EPaperMono {
size_t init_sequence_length, DisplayType display_type = DISPLAY_TYPE_BINARY, uint8_t bits_per_pixel = 1)
: EPaperMono(name, width, height, init_sequence, init_sequence_length, display_type, bits_per_pixel) {}
void setup() override;
protected:
// Reads the previous frame to copy it into the other RAM bank on a full refresh
bool needs_previous_frame() const override { return true; }
// Allocates the comparison frame as setup() would, for tests that run without a bus.
void init_comparison_frame_() { this->init_sent_frame_(this->sent_frame_length()); }
size_t sent_frame_length() const override { return this->plane_row_length_() * this->height_; }
// Allocates the comparison frame when partial updates are enabled. Separate from setup() so it
// can run without a bus.
void init_comparison_frame_();
// Bytes in one row of a RAM plane: 8 pixels per byte, whatever the buffer holds.
size_t plane_row_length_() const { return (this->width_ + 7) / 8; }
// Row y of the frame as a RAM plane holds it, 1 bit per pixel with 1 = white.
@@ -39,7 +39,8 @@ class EPaperSSD1677 : public EPaperMono {
bool reset() override;
bool transfer_data() override;
uint8_t plane_{0}; // 0 while sending 0x26, 1 while sending 0x24
split_buffer::SplitBuffer sent_{}; // the frame last sent to 0x24, i.e. what the panel shows
uint8_t plane_{0}; // 0 while sending 0x26, 1 while sending 0x24
};
} // namespace esphome::epaper_spi
@@ -29,7 +29,10 @@ void EPaperSSD1677Gray4::fill(Color color) {
}
const uint8_t level = color_to_gray4(color);
this->buffer_.fill((uint8_t) (level | (level << 2) | (level << 4) | (level << 6)));
this->mark_all_drawn_();
this->x_low_ = 0;
this->y_low_ = 0;
this->x_high_ = this->width_;
this->y_high_ = this->height_;
}
void HOT EPaperSSD1677Gray4::draw_pixel_at(int x, int y, Color color) {
@@ -80,14 +83,14 @@ bool HOT EPaperSSD1677Gray4::transfer_data() {
this->start_data_();
while (this->current_data_index_ != this->y_high_) {
const size_t src_row = this->current_data_index_ * this->row_width_ + this->x_low_ / 4;
for (size_t i = 0; i != plane_row_length; i++)
bytes_to_send[i] = plane_byte(this->buffer_[src_row + 2 * i], this->buffer_[src_row + 2 * i + 1], first_pass);
// What the next partial update compares against: the high bits, as a black-and-white frame.
if (first_pass)
this->record_sent_(this->current_data_index_ * plane_row_length, bytes_to_send, plane_row_length);
// The OTP grayscale waveform treats data as inverted relative to monochrome
for (size_t i = 0; i != plane_row_length; i++)
bytes_to_send[i] = (uint8_t) ~bytes_to_send[i];
for (size_t i = 0; i != plane_row_length; i++) {
const uint8_t plane = plane_byte(this->buffer_[src_row + 2 * i], this->buffer_[src_row + 2 * i + 1], first_pass);
// The OTP grayscale waveform treats data as inverted relative to monochrome
bytes_to_send[i] = (uint8_t) ~plane;
// What the next partial update compares against: the high bits, as a black-and-white frame.
if (first_pass && this->sent_.is_valid())
this->sent_[this->current_data_index_ * plane_row_length + i] = plane;
}
++this->current_data_index_;
this->write_array(bytes_to_send, plane_row_length);
if (millis() - start_time > MAX_TRANSFER_TIME) {
@@ -1,50 +1,10 @@
#include "epaper_spi_ssd1681.h"
#include <algorithm>
#include <cstring>
#include "esphome/core/log.h"
namespace esphome::epaper_spi {
ESPHOME_LOG_TAG(TAG, "epaper_spi.ssd1681");
void EPaperSSD1681::bounds_from_changes_() {
if (!this->sent_valid_ || this->sent_.size() != this->buffer_.size())
return;
bool changed = false;
uint16_t row_low = this->height_, row_high = 0, col_low = this->row_width_, col_high = 0;
for (uint16_t row = 0; row != this->height_; row++) {
const size_t base = row * this->row_width_;
const size_t end = base + this->row_width_;
size_t index = base;
while (index != end) {
size_t new_length, old_length;
const uint8_t *new_bytes = this->buffer_.get_span(index, new_length);
const uint8_t *old_bytes = this->sent_.get_span(index, old_length);
const size_t length = std::min({new_length, old_length, end - index});
if (std::memcmp(new_bytes, old_bytes, length) != 0) {
for (size_t i = 0; i != length; i++) {
if (new_bytes[i] == old_bytes[i])
continue;
const auto col = static_cast<uint16_t>(index + i - base);
changed = true;
row_low = std::min(row_low, row);
row_high = std::max<uint16_t>(row_high, row + 1);
col_low = std::min(col_low, col);
col_high = std::max<uint16_t>(col_high, col + 1);
}
}
index += length;
}
}
if (!changed)
return;
this->x_low_ = col_low * 8;
this->x_high_ = std::min<uint16_t>(col_high * 8, this->width_);
this->y_low_ = row_low;
this->y_high_ = row_high;
}
bool HOT EPaperSSD1681::transfer_data() {
if (this->current_data_index_ == 0 && this->send_red_) {
if (!this->partial_) {
@@ -55,7 +15,6 @@ bool HOT EPaperSSD1681::transfer_data() {
this->y_high_ = this->height_;
} else {
// 0x26 already holds the image on the panel: the controller copies 0x24 into it after each refresh
this->bounds_from_changes_();
this->set_window();
this->send_red_ = false;
}
@@ -16,10 +16,6 @@ class EPaperSSD1681 : public EPaperSSD1683 {
}
protected:
// Reads the previous frame to compute the changed window for a partial refresh
bool needs_previous_frame() const override { return true; }
// Shrinks the update bounds to the bytes that differ from the frame last sent
void bounds_from_changes_();
bool transfer_data() override;
void refresh_screen(bool partial) override;
};
@@ -71,15 +71,12 @@ bool HOT EPaperSSD1683::transfer_data() {
ESP_LOGV(TAG, "Writing %u bytes at line %zu at %ums", row_length, this->current_data_index_, (unsigned) millis());
this->start_data_();
while (this->current_data_index_ != this->y_high_) {
const size_t row_start = this->current_data_index_ * this->row_width_ + this->x_low_;
size_t data_idx = row_start;
size_t data_idx = this->current_data_index_ * this->row_width_ + this->x_low_;
for (size_t i = 0; i != row_length; i++) {
bytes_to_send[i] = this->buffer_[data_idx++];
}
++this->current_data_index_;
this->write_array(&bytes_to_send.front(), row_length); // NOLINT
if (!this->send_red_)
this->record_sent_(row_start, &bytes_to_send.front(), row_length);
if (millis() - start_time > MAX_TRANSFER_TIME) {
// Let the main loop run and come back next loop
this->disable();
@@ -232,7 +232,6 @@ void EPaperT133A01::fill(Color color) {
}
auto pixel_color = color_to_index(color);
this->buffer_.fill(pixel_color + (pixel_color << 4));
this->drawn_since_hash_ = true;
}
void EPaperT133A01::draw_pixel_at(int x, int y, Color color) {
@@ -1,5 +1,4 @@
#include "epaper_waveshare_bwr.h"
#include "esphome/core/helpers.h"
#include <algorithm>
@@ -37,9 +36,17 @@ void EPaperWaveshareBWR::draw_pixel_at(int x, int y, Color color) {
const auto bwr = color_to_bwr(color);
assign_bits(this->buffer_[pos], bit, bwr == BwrState::BWR_BLACK);
if (bwr == BwrState::BWR_BLACK) {
this->buffer_[pos] |= bit;
} else {
this->buffer_[pos] &= ~bit;
}
assign_bits(this->buffer_[red_offset + pos], bit, bwr == BwrState::BWR_RED);
if (bwr == BwrState::BWR_RED) {
this->buffer_[red_offset + pos] |= bit;
} else {
this->buffer_[red_offset + pos] &= ~bit;
}
}
void EPaperWaveshareBWR::fill(Color color) {
+5 -2
View File
@@ -1,7 +1,6 @@
#include "es8156.h"
#include "es8156_const.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <cinttypes>
@@ -101,7 +100,11 @@ bool ES8156::set_mute_state_(bool mute_state) {
ESP_LOGV(TAG, "Read ES8156_REG13_DAC_MUTE: %u", reg13);
assign_bits(reg13, BIT(1) | BIT(2), mute_state);
if (mute_state) {
reg13 |= BIT(1) | BIT(2);
} else {
reg13 &= ~(BIT(1) | BIT(2));
}
ESP_LOGV(TAG, "Setting ES8156_REG13_DAC_MUTE to %u (muted: %s)", reg13, YESNO(mute_state));
return this->write_byte(ES8156_REG13_DAC_MUTE, reg13);
+10 -3
View File
@@ -1,7 +1,6 @@
#include "es8311.h"
#include "es8311_const.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <cinttypes>
@@ -155,7 +154,11 @@ bool ES8311::configure_clock_() {
// Register 0x06
uint8_t reg06;
ES8311_ERROR_CHECK(this->read_byte(ES8311_REG06_CLK_MANAGER, &reg06));
assign_bits(reg06, BIT(5), this->sclk_inverted_);
if (this->sclk_inverted_) {
reg06 |= BIT(5);
} else {
reg06 &= ~BIT(5);
}
reg06 &= 0xE0;
if (coefficient->bclk_div < 19) {
reg06 |= (coefficient->bclk_div - 1) << 0;
@@ -220,7 +223,11 @@ bool ES8311::set_mute_state_(bool mute_state) {
return false;
}
assign_bits(reg31, BIT(6) | BIT(5), mute_state);
if (mute_state) {
reg31 |= BIT(6) | BIT(5);
} else {
reg31 &= ~(BIT(6) | BIT(5));
}
return this->write_byte(ES8311_REG31_DAC, reg31);
}
+5 -2
View File
@@ -2,7 +2,6 @@
#include <cinttypes>
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::es8388 {
@@ -185,7 +184,11 @@ bool ES8388::set_mute_state_(bool mute_state) {
// DAC settings that must be preserved. Previously muting overwrote the whole
// register with 0x3C and unmuting never cleared the bit, so once muted the DAC
// could not be unmuted again.
assign_bits(value, ES8388_DACCONTROL3_DAC_MUTE, mute_state);
if (mute_state) {
value |= ES8388_DACCONTROL3_DAC_MUTE;
} else {
value &= ~ES8388_DACCONTROL3_DAC_MUTE;
}
ESP_LOGV(TAG, "Setting ES8388_DACCONTROL3 to 0x%02X (muted: %s)", value, YESNO(mute_state));
return this->write_byte(ES8388_DACCONTROL3, value);
+5 -2
View File
@@ -1,6 +1,5 @@
#include "gree.h"
#include "esphome/components/remote_base/remote_base.h"
#include "esphome/core/helpers.h"
namespace esphome::gree {
@@ -25,7 +24,11 @@ void GreeClimate::set_model(Model model) {
}
void GreeClimate::set_mode_bit(uint8_t bit_mask, bool enabled) {
assign_bits(this->mode_bits_, bit_mask, enabled);
if (enabled) {
this->mode_bits_ |= bit_mask;
} else {
this->mode_bits_ &= ~bit_mask;
}
this->transmit_state();
}
@@ -1,5 +1,4 @@
#include "hitachi_ac344.h"
#include "esphome/core/helpers.h"
namespace esphome::hitachi_ac344 {
@@ -17,6 +16,15 @@ void set_bits(uint8_t *const dst, const uint8_t offset, const uint8_t nbits, con
*dst |= ((data & mask) << offset);
}
void set_bit(uint8_t *const data, const uint8_t position, const bool on) {
uint8_t mask = 1 << position;
if (on) {
*data |= mask;
} else {
*data &= ~mask;
}
}
uint8_t *invert_byte_pairs(uint8_t *ptr, const uint16_t length) {
for (uint16_t i = 1; i < length; i += 2) {
// Code done this way to avoid a compiler warning bug.
@@ -116,7 +124,7 @@ bool HitachiClimate::get_swing_v_toggle_() { return get_button_() == HITACHI_AC3
void HitachiClimate::set_swing_v_(bool on) {
set_swing_v_toggle_(on); // Set the button value.
assign_bits(this->remote_state_[HITACHI_AC344_SWINGV_BYTE], 1 << HITACHI_AC344_SWINGV_OFFSET, on);
set_bit(&remote_state_[HITACHI_AC344_SWINGV_BYTE], HITACHI_AC344_SWINGV_OFFSET, on);
}
bool HitachiClimate::get_swing_v_() {
@@ -1,5 +1,4 @@
#include "hitachi_ac424.h"
#include "esphome/core/helpers.h"
namespace esphome::hitachi_ac424 {
@@ -17,6 +16,15 @@ void set_bits(uint8_t *const dst, const uint8_t offset, const uint8_t nbits, con
*dst |= ((data & mask) << offset);
}
void set_bit(uint8_t *const data, const uint8_t position, const bool on) {
uint8_t mask = 1 << position;
if (on) {
*data |= mask;
} else {
*data &= ~mask;
}
}
uint8_t *invert_byte_pairs(uint8_t *ptr, const uint16_t length) {
for (uint16_t i = 1; i < length; i += 2) {
// Code done this way to avoid a compiler warning bug.
@@ -116,7 +124,7 @@ bool HitachiClimate::get_swing_v_toggle_() { return get_button_() == HITACHI_AC4
void HitachiClimate::set_swing_v_(bool on) {
set_swing_v_toggle_(on); // Set the button value.
assign_bits(this->remote_state_[HITACHI_AC424_SWINGV_BYTE], 1 << HITACHI_AC424_SWINGV_OFFSET, on);
set_bit(&remote_state_[HITACHI_AC424_SWINGV_BYTE], HITACHI_AC424_SWINGV_OFFSET, on);
}
bool HitachiClimate::get_swing_v_() {
+55 -15
View File
@@ -3,36 +3,67 @@
namespace esphome::max17043 {
// MAX17043 is a 1-Cell Fuel Gauge with ModelGauge and Low-Battery Alert
// MAX174043 is a 1-Cell Fuel Gauge with ModelGauge and Low-Battery Alert
// Consult the datasheet at https://www.analog.com/en/products/max17043.html
ESPHOME_LOG_TAG(TAG, "max17043");
void MAX17043Component::update() {
if (!this->ready_for_update_())
return;
static const uint8_t MAX17043_VCELL = 0x02;
static const uint8_t MAX17043_SOC = 0x04;
static const uint8_t MAX17043_CONFIG = 0x0c;
uint16_t raw;
bool ok = true;
static const uint16_t MAX17043_CONFIG_POWER_UP_DEFAULT = 0x971C;
static const uint16_t MAX17043_CONFIG_SAFE_MASK = 0xFF1F; // mask out sleep bit (7), unused bit (6) and alert bit (4)
static const uint16_t MAX17043_CONFIG_SLEEP_MASK = 0x0080;
void MAX17043Component::update() {
uint16_t raw_voltage, raw_percent;
if (this->voltage_sensor_ != nullptr) {
if (this->read_byte_16(max170xx::REG_VCELL, &raw)) {
// 12 bits in the top of the word at 1.25 mV
this->voltage_sensor_->publish_state((raw >> 4) * 1.25e-3f);
if (!this->read_byte_16(MAX17043_VCELL, &raw_voltage)) {
this->status_set_warning(LOG_STR("Unable to read MAX17043_VCELL"));
} else {
ok = false;
float voltage = (1.25f * (float) (raw_voltage >> 4)) / 1000.0f;
this->voltage_sensor_->publish_state(voltage);
this->status_clear_warning();
}
}
if (this->battery_remaining_sensor_ != nullptr) {
if (this->read_byte_16(max170xx::REG_SOC, &raw)) {
float percent = (float) ((raw >> 8) + 0.003906f * (raw & 0x00ff));
this->battery_remaining_sensor_->publish_state(percent);
if (!this->read_byte_16(MAX17043_SOC, &raw_percent)) {
this->status_set_warning(LOG_STR("Unable to read MAX17043_SOC"));
} else {
ok = false;
float percent = (float) ((raw_percent >> 8) + 0.003906f * (raw_percent & 0x00ff));
this->battery_remaining_sensor_->publish_state(percent);
this->status_clear_warning();
}
}
}
this->status_comm_result(ok, LOG_STR("Register read failed"));
void MAX17043Component::setup() {
uint16_t config_reg;
if (this->write(&MAX17043_CONFIG, 1) != i2c::ERROR_OK) {
this->status_set_warning();
return;
}
if (this->read(reinterpret_cast<uint8_t *>(&config_reg), 2) != i2c::ERROR_OK) {
this->status_set_warning();
return;
}
config_reg = i2c::i2ctohs(config_reg) & MAX17043_CONFIG_SAFE_MASK;
ESP_LOGV(TAG, "MAX17043 CONFIG register reads 0x%X", config_reg);
if (config_reg != MAX17043_CONFIG_POWER_UP_DEFAULT) {
this->mark_failed(LOG_STR("Device does not appear to be a MAX17043"));
return;
}
// need to write back to config register to reset the sleep bit
if (!this->write_byte_16(MAX17043_CONFIG, MAX17043_CONFIG_POWER_UP_DEFAULT)) {
this->mark_failed(LOG_STR("sleep reset failed"));
return;
}
}
void MAX17043Component::dump_config() {
@@ -43,4 +74,13 @@ void MAX17043Component::dump_config() {
LOG_SENSOR(" ", "Battery Level", this->battery_remaining_sensor_);
}
void MAX17043Component::sleep_mode() {
if (!this->is_failed()) {
if (!this->write_byte_16(MAX17043_CONFIG, MAX17043_CONFIG_POWER_UP_DEFAULT | MAX17043_CONFIG_SLEEP_MASK)) {
ESP_LOGW(TAG, "Unable to write the sleep bit to config register");
this->status_set_warning();
}
}
}
} // namespace esphome::max17043
+7 -4
View File
@@ -1,18 +1,21 @@
#pragma once
#include "esphome/components/max170xx/max170xx.h"
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/i2c/i2c.h"
namespace esphome::max17043 {
class MAX17043Component final : public max170xx::MAX170xxComponent<max170xx::Model::MODEL_MAX17043> {
class MAX17043Component final : public PollingComponent, public i2c::I2CDevice {
public:
void setup() override;
void dump_config() override;
void update() override;
void sleep_mode();
void set_voltage_sensor(sensor::Sensor *voltage_sensor) { this->voltage_sensor_ = voltage_sensor; }
void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; }
void set_battery_remaining_sensor(sensor::Sensor *battery_remaining_sensor) {
this->battery_remaining_sensor_ = battery_remaining_sensor;
battery_remaining_sensor_ = battery_remaining_sensor;
}
protected:
-1
View File
@@ -16,7 +16,6 @@ from esphome.const import (
)
from esphome.types import ConfigType
AUTO_LOAD = ["max170xx"]
DEPENDENCIES = ["i2c"]
max17043_ns = cg.esphome_ns.namespace("max17043")
+1 -1
View File
@@ -1,2 +1,2 @@
CODEOWNERS = ["@B48D81EFCC", "@ryansch"]
CODEOWNERS = ["@B48D81EFCC"]
DOMAIN = "max17048"
+19 -5
View File
@@ -8,27 +8,41 @@ namespace esphome::max17048 {
ESPHOME_LOG_TAG(TAG, "max17048");
static constexpr uint8_t REG_CRATE = 0x16; // charge rate, signed
static constexpr uint8_t REG_VCELL = 0x02; // cell voltage
static constexpr uint8_t REG_SOC = 0x04; // state of charge
static constexpr uint8_t REG_VERSION = 0x08; // 0x001x
static constexpr uint8_t REG_CRATE = 0x16; // charge rate, signed
static constexpr uint16_t VERSION_MASK = 0xFFF0;
static constexpr uint16_t VERSION_FAMILY = 0x0010;
static constexpr float VCELL_VOLTS_PER_LSB = 78.125e-6f;
static constexpr float SOC_PERCENT_PER_LSB = 1.0f / 256.0f;
static constexpr float CRATE_PERCENT_PER_HOUR_PER_LSB = 0.208f;
void MAX17048Component::update() {
if (!this->ready_for_update_())
void MAX17048Component::setup() {
uint16_t version;
if (!this->read_byte_16(REG_VERSION, &version)) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
if ((version & VERSION_MASK) != VERSION_FAMILY) {
ESP_LOGW(TAG, "Unexpected version 0x%04X", version);
}
}
void MAX17048Component::update() {
bool ok = true;
uint16_t raw;
if (this->battery_voltage_sensor_ != nullptr) {
if (this->read_byte_16(max170xx::REG_VCELL, &raw)) {
if (this->read_byte_16(REG_VCELL, &raw)) {
this->battery_voltage_sensor_->publish_state(raw * VCELL_VOLTS_PER_LSB);
} else {
ok = false;
}
}
if (this->battery_level_sensor_ != nullptr) {
if (this->read_byte_16(max170xx::REG_SOC, &raw)) {
if (this->read_byte_16(REG_SOC, &raw)) {
this->battery_level_sensor_->publish_state(raw * SOC_PERCENT_PER_LSB);
} else {
ok = false;
+4 -2
View File
@@ -1,16 +1,18 @@
#pragma once
#include "esphome/components/max170xx/max170xx.h"
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/i2c/i2c.h"
namespace esphome::max17048 {
class MAX17048Component final : public max170xx::MAX170xxComponent<max170xx::Model::MODEL_MAX17048> {
class MAX17048Component final : public PollingComponent, public i2c::I2CDevice {
SUB_SENSOR(battery_voltage)
SUB_SENSOR(battery_level)
SUB_SENSOR(rate)
public:
void setup() override;
void dump_config() override;
void update() override;
};
-16
View File
@@ -1,12 +1,9 @@
from esphome import automation
from esphome.automation import maybe_simple_id
import esphome.codegen as cg
from esphome.components import i2c, sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_BATTERY_LEVEL,
CONF_BATTERY_VOLTAGE,
CONF_ID,
CONF_RATE,
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_VOLTAGE,
@@ -17,7 +14,6 @@ from esphome.const import (
)
from esphome.types import ConfigType
AUTO_LOAD = ["max170xx"]
DEPENDENCIES = ["i2c"]
UNIT_PERCENT_PER_HOUR = "%/h"
@@ -65,15 +61,3 @@ async def to_code(config: ConfigType) -> None:
await sensors(CONF_BATTERY_VOLTAGE, var.set_battery_voltage_sensor)
await sensors(CONF_BATTERY_LEVEL, var.set_battery_level_sensor)
await sensors(CONF_RATE, var.set_rate_sensor)
MAX17048_ACTION_SCHEMA = maybe_simple_id(
{
cv.Required(CONF_ID): cv.use_id(MAX17048Component),
}
)
automation.register_apply_action(
"max17048.sleep_mode", MAX17048_ACTION_SCHEMA, automation.ApplyCall("sleep_mode()")
)
-3
View File
@@ -1,3 +0,0 @@
CODEOWNERS = ["@ryansch"]
DEPENDENCIES = ["i2c"]
DOMAIN = "max170xx"
-87
View File
@@ -1,87 +0,0 @@
#include "max170xx.h"
#include "esphome/core/log.h"
namespace esphome::max170xx {
ESPHOME_LOG_TAG(TAG, "max170xx");
template<Model MODEL> void MAX170xxComponent<MODEL>::setup() {
// A silent bus is retried from update(): some boards gate the I2C rail until after setup.
// An early on_boot may already have configured the gauge and put it to sleep, so this must not configure again.
this->ready_for_update_();
}
template<Model MODEL> bool MAX170xxComponent<MODEL>::ready_for_update_() {
if (this->configured_ || this->configure_())
return true;
this->status_set_warning(LOG_STR("Gauge did not answer"));
return false;
}
template<Model MODEL> bool MAX170xxComponent<MODEL>::configure_() {
uint16_t config;
if (!this->read_byte_16(REG_CONFIG, &config))
return false;
const bool was_asleep = (config & CONFIG_SLEEP) != 0;
// RCOMP and ATHD survive on the battery, so an earlier firmware's values are reset here, not treated as a fault
if ((config & CONFIG_SAFE_MASK) != CONFIG_POWER_UP_DEFAULT) {
ESP_LOGW(TAG, "CONFIG reads 0x%04X, expected 0x%04X; resetting it", config & CONFIG_SAFE_MASK,
CONFIG_POWER_UP_DEFAULT);
}
uint16_t version;
if (this->read_byte_16(REG_VERSION, &version)) {
ESP_LOGCONFIG(TAG, "IC version: 0x%04X", version);
}
// Write back the power-up value to clear a stale sleep bit; a failed write is retried like a silent bus
if (!this->write_byte_16(REG_CONFIG, CONFIG_POWER_UP_DEFAULT))
return false;
this->configured_ = true;
// Waking from sleep raises a false low-battery alert; a real one at an ordinary boot is left alone
if (was_asleep)
this->set_timeout(WAKE_ALERT_CLEAR_TIMEOUT_ID, WAKE_ALERT_CLEAR_DELAY_MS, [this]() { this->clear_wake_alert_(); });
return true;
}
template<Model MODEL> void MAX170xxComponent<MODEL>::clear_wake_alert_() {
uint16_t config;
if (!this->read_byte_16(REG_CONFIG, &config) || !this->write_byte_16(REG_CONFIG, config & ~CONFIG_ALRT)) {
this->status_set_warning(LOG_STR("Wake alert clear failed"));
return;
}
if constexpr (MODEL == Model::MODEL_MAX17048) {
uint16_t status;
if (!this->read_byte_16(REG_STATUS, &status) || !this->write_byte_16(REG_STATUS, status & ~STATUS_HD))
this->status_set_warning(LOG_STR("Wake alert clear failed"));
}
}
template<Model MODEL> void MAX170xxComponent<MODEL>::sleep_mode() {
if (this->is_failed())
return;
// The deferred configuration clears the sleep bit, so it has to run before the gauge is put to sleep
if (!this->ready_for_update_())
return;
// A clear still pending would otherwise run against a sleeping gauge
if (this->cancel_timeout(WAKE_ALERT_CLEAR_TIMEOUT_ID))
this->clear_wake_alert_();
if constexpr (MODEL == Model::MODEL_MAX17048) {
if (!this->write_byte_16(REG_MODE, MODE_EN_SLEEP)) {
this->status_set_warning(LOG_STR("Unable to enable sleep mode in the mode register"));
return;
}
}
if (!this->write_byte_16(REG_CONFIG, CONFIG_POWER_UP_DEFAULT | CONFIG_SLEEP))
this->status_set_warning(LOG_STR("Unable to write the sleep bit to config register"));
}
template class MAX170xxComponent<Model::MODEL_MAX17043>;
template class MAX170xxComponent<Model::MODEL_MAX17048>;
} // namespace esphome::max170xx
-61
View File
@@ -1,61 +0,0 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/i2c/i2c.h"
namespace esphome::max170xx {
// Shared base for the ModelGauge fuel gauges.
// MAX17043: https://www.analog.com/en/products/max17043.html
// MAX17048: https://www.analog.com/en/products/max17048.html
// The MAX17048 is register-compatible with the MAX17043, and adds a STATUS register and a MODE.EnSleep gate in
// front of CONFIG.SLEEP.
static constexpr uint8_t REG_VCELL = 0x02;
static constexpr uint8_t REG_SOC = 0x04;
static constexpr uint8_t REG_MODE = 0x06;
static constexpr uint8_t REG_VERSION = 0x08;
static constexpr uint8_t REG_CONFIG = 0x0C;
static constexpr uint8_t REG_STATUS = 0x1A;
static constexpr uint16_t CONFIG_POWER_UP_DEFAULT = 0x971C;
// Mask out SLEEP (7), ALRT (5), and bit 6, which is a don't-care on the MAX17043 but ALSC on the MAX17048.
static constexpr uint16_t CONFIG_SAFE_MASK = 0xFF1F;
static constexpr uint16_t CONFIG_SLEEP = 0x0080;
static constexpr uint16_t CONFIG_ALRT = 0x0020;
// CONFIG.SLEEP is ignored unless MODE.EnSleep (bit 13) is set first.
static constexpr uint16_t MODE_EN_SLEEP = 0x2000;
static constexpr uint16_t STATUS_HD = 0x1000;
static constexpr uint32_t WAKE_ALERT_CLEAR_TIMEOUT_ID = 0;
// The gauge raises its false wake alert some time after the wake write; on hardware it rose 0.8 to 1.0 s later
static constexpr uint32_t WAKE_ALERT_CLEAR_DELAY_MS = 2000;
enum class Model : uint8_t {
MODEL_MAX17043,
MODEL_MAX17048,
};
/// Defined in max170xx.cpp, which instantiates both models.
template<Model MODEL> class MAX170xxComponent : public PollingComponent, public i2c::I2CDevice {
public:
void setup() override;
void sleep_mode();
protected:
/// True once the gauge is configured; otherwise retries the configuration and warns if the gauge did not answer.
/// Call first in update().
bool ready_for_update_();
/// Verify and wake the gauge; false if it did not answer.
bool configure_();
/// Clear the false alert raised on waking, leaving every other CONFIG and STATUS bit as it is.
void clear_wake_alert_();
bool configured_{false};
};
extern template class MAX170xxComponent<Model::MODEL_MAX17043>;
extern template class MAX170xxComponent<Model::MODEL_MAX17048>;
} // namespace esphome::max170xx
+5 -2
View File
@@ -1,5 +1,4 @@
#include "mcp23016.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <cstdio>
@@ -80,7 +79,11 @@ void MCP23016::update_reg_(uint8_t pin, bool pin_value, uint8_t reg_addr) {
this->read_reg_(reg_addr, &reg_value);
}
assign_bits(reg_value, 1 << pin, pin_value);
if (pin_value) {
reg_value |= 1 << pin;
} else {
reg_value &= ~(1 << pin);
}
this->write_reg_(reg_addr, reg_value);
@@ -1,5 +1,4 @@
#include "mcp23x08_base.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::mcp23x08_base {
@@ -79,7 +78,11 @@ void MCP23X08Base::update_reg(uint8_t pin, bool pin_value, uint8_t reg_addr) {
this->read_reg(reg_addr, &reg_value);
}
assign_bits(reg_value, 1 << bit, pin_value);
if (pin_value) {
reg_value |= 1 << bit;
} else {
reg_value &= ~(1 << bit);
}
this->write_reg(reg_addr, reg_value);
@@ -92,7 +92,11 @@ void MCP23X17Base::update_reg(uint8_t pin, bool pin_value, uint8_t reg_addr) {
this->read_reg(reg_addr, &reg_value);
}
assign_bits(reg_value, 1 << bit, pin_value);
if (pin_value) {
reg_value |= 1 << bit;
} else {
reg_value &= ~(1 << bit);
}
this->write_reg(reg_addr, reg_value);
+16 -16
View File
@@ -33,22 +33,22 @@ uint16_t parse_u8_hb_60(OpenthermData &data) { return data.valueHB * 60; }
int16_t parse_s16(OpenthermData &data) { return data.get_s16(); }
float parse_f88(OpenthermData &data) { return data.get_f88(); }
void write_flag8_lb_0(const bool value, OpenthermData &data) { assign_bits(data.valueLB, 1 << 0, value); }
void write_flag8_lb_1(const bool value, OpenthermData &data) { assign_bits(data.valueLB, 1 << 1, value); }
void write_flag8_lb_2(const bool value, OpenthermData &data) { assign_bits(data.valueLB, 1 << 2, value); }
void write_flag8_lb_3(const bool value, OpenthermData &data) { assign_bits(data.valueLB, 1 << 3, value); }
void write_flag8_lb_4(const bool value, OpenthermData &data) { assign_bits(data.valueLB, 1 << 4, value); }
void write_flag8_lb_5(const bool value, OpenthermData &data) { assign_bits(data.valueLB, 1 << 5, value); }
void write_flag8_lb_6(const bool value, OpenthermData &data) { assign_bits(data.valueLB, 1 << 6, value); }
void write_flag8_lb_7(const bool value, OpenthermData &data) { assign_bits(data.valueLB, 1 << 7, value); }
void write_flag8_hb_0(const bool value, OpenthermData &data) { assign_bits(data.valueHB, 1 << 0, value); }
void write_flag8_hb_1(const bool value, OpenthermData &data) { assign_bits(data.valueHB, 1 << 1, value); }
void write_flag8_hb_2(const bool value, OpenthermData &data) { assign_bits(data.valueHB, 1 << 2, value); }
void write_flag8_hb_3(const bool value, OpenthermData &data) { assign_bits(data.valueHB, 1 << 3, value); }
void write_flag8_hb_4(const bool value, OpenthermData &data) { assign_bits(data.valueHB, 1 << 4, value); }
void write_flag8_hb_5(const bool value, OpenthermData &data) { assign_bits(data.valueHB, 1 << 5, value); }
void write_flag8_hb_6(const bool value, OpenthermData &data) { assign_bits(data.valueHB, 1 << 6, value); }
void write_flag8_hb_7(const bool value, OpenthermData &data) { assign_bits(data.valueHB, 1 << 7, value); }
void write_flag8_lb_0(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 0, value); }
void write_flag8_lb_1(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 1, value); }
void write_flag8_lb_2(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 2, value); }
void write_flag8_lb_3(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 3, value); }
void write_flag8_lb_4(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 4, value); }
void write_flag8_lb_5(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 5, value); }
void write_flag8_lb_6(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 6, value); }
void write_flag8_lb_7(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 7, value); }
void write_flag8_hb_0(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 0, value); }
void write_flag8_hb_1(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 1, value); }
void write_flag8_hb_2(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 2, value); }
void write_flag8_hb_3(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 3, value); }
void write_flag8_hb_4(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 4, value); }
void write_flag8_hb_5(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 5, value); }
void write_flag8_hb_6(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 6, value); }
void write_flag8_hb_7(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 7, value); }
void write_u8_lb(const uint8_t value, OpenthermData &data) { data.valueLB = value; }
void write_u8_hb(const uint8_t value, OpenthermData &data) { data.valueHB = value; }
void write_s8_lb(const int8_t value, OpenthermData &data) { data.valueLB = (uint8_t) value; }
+8
View File
@@ -20,6 +20,14 @@ namespace esphome::opentherm {
template<class T> constexpr T read_bit(T value, uint8_t bit) { return (value >> bit) & 0x01; }
template<class T> constexpr T set_bit(T value, uint8_t bit) { return value |= (1UL << bit); }
template<class T> constexpr T clear_bit(T value, uint8_t bit) { return value &= ~(1UL << bit); }
template<class T> constexpr T write_bit(T value, uint8_t bit, uint8_t bit_value) {
return bit_value ? set_bit(value, bit) : clear_bit(value, bit);
}
enum OperationMode {
IDLE = 0, // no operation
+132 -13
View File
@@ -1,56 +1,175 @@
from collections.abc import Callable
import importlib
import pkgutil
from typing import Any
import esphome.codegen as cg
from esphome.components.const import CONF_MAX_PACKET_SIZE
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.core import ID
from esphome.cpp_generator import MockObj, MockObjClass
import esphome.final_validate as fv
from esphome.types import ConfigType
from . import layers
from .layers import (
ADDRESSING,
BROADCAST_ADDRESS,
CONF_ADDRESSING,
CONF_DESTINATION,
CONF_PEERS,
STACK_ORDER,
PacketInterface,
build_stack,
check_packet_sizes,
check_session,
check_single_peer,
packet_interface_ns,
stack_auto_load,
stack_schema,
)
CODEOWNERS = ["@clydebarrow"]
DOMAIN = "packet_interface"
IS_PLATFORM_COMPONENT = True
CONF_PACKET_INTERFACE = "packet_interface"
packet_interface_ns = cg.esphome_ns.namespace("packet_interface")
PacketInterface = packet_interface_ns.class_("PacketInterface", cg.Component)
# Each module in layers registers a layer type; import them all before building the schemas from the registry
for module_info in pkgutil.iter_modules(layers.__path__):
importlib.import_module(f".layers.{module_info.name}", package=__package__)
LINK_LAYER_SCHEMAS = {
cv.Optional(CONF_ADDRESSING): ADDRESSING.schema(),
**stack_schema(),
}
__all__ = [
"CONF_MAX_PACKET_SIZE",
"CONF_PACKET_INTERFACE",
"interface_layers",
"PacketInterface",
"final_validate_interface",
"new_packet_interface",
"packet_interface_ns",
"packet_interface_schema",
]
def AUTO_LOAD(config: list[ConfigType] | None = None) -> list[str]:
"""Load only what the configured layers use, on the link and in each addressing peer.
Without a config, as tooling that lists dependencies calls it, everything a layer could load.
"""
if config is None:
return ["binary_sensor", "xxtea"]
loads: set[str] = set()
for interface in config:
loads |= stack_auto_load(interface)
for peer in interface.get(CONF_ADDRESSING, {}).get(CONF_PEERS, []):
loads |= stack_auto_load(peer)
return sorted(loads)
def _validate_layers(config: ConfigType) -> ConfigType:
"""With peers, the layers go in each peer; otherwise check them against the one destination."""
check_session(config)
size = config[CONF_MAX_PACKET_SIZE]
if (addressing := config.get(CONF_ADDRESSING)) is None:
check_packet_sizes(config, size)
return config
size = ADDRESSING.get_type(addressing).packet_size(addressing, size)
if CONF_PEERS in addressing:
for kind in STACK_ORDER:
if kind.key in config:
raise cv.Invalid(
f"With '{CONF_PEERS}', '{kind.key}' goes in each peer",
path=[kind.key],
)
for index, peer in enumerate(addressing[CONF_PEERS]):
try:
check_packet_sizes(peer, size)
except cv.Invalid as err:
err.prepend([CONF_ADDRESSING, CONF_PEERS, index])
raise
return config
addressing.setdefault(CONF_DESTINATION, BROADCAST_ADDRESS)
check_single_peer(config, addressing[CONF_DESTINATION])
check_packet_sizes(config, size)
return config
def interface_layers(interface_id: ID) -> ConfigType | None:
"""During final validation, the configuration holding the layers of the packet interface with this id.
The id is a link's, or an addressing peer's.
"""
for interface in fv.full_config.get().get(DOMAIN, []):
if interface[CONF_ID].id == interface_id.id:
return interface
for peer in interface.get(CONF_ADDRESSING, {}).get(CONF_PEERS, []):
if peer[CONF_ID].id == interface_id.id:
return peer
return None
def final_validate_interface(key: str) -> Callable[[ConfigType], ConfigType]:
"""Final validation for an application: a link with peers must be used through one of the peers."""
def validator(config: ConfigType) -> ConfigType:
interface_id: ID = config[key]
interface = interface_layers(interface_id)
if interface is not None and (
peers := interface.get(CONF_ADDRESSING, {}).get(CONF_PEERS)
):
raise cv.Invalid(
f"'{interface_id.id}' has peers, so use the id of one of them, such as '{peers[0][CONF_ID].id}'",
path=[key],
)
return config
return validator
def packet_interface_schema(
class_: MockObjClass,
default_max_packet_size: int,
max_packet_size_limit: int = 4096,
) -> cv.Schema:
"""Schema for a link platform."""
return cv.Schema(
{
cv.GenerateID(): cv.declare_id(class_),
cv.Optional(
CONF_MAX_PACKET_SIZE, default=default_max_packet_size
): cv.int_range(16, max_packet_size_limit),
}
).extend(cv.COMPONENT_SCHEMA)
"""Schema for a link platform, with the layers that can be stacked on it."""
return (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(class_),
cv.Optional(
CONF_MAX_PACKET_SIZE, default=default_max_packet_size
): cv.int_range(16, max_packet_size_limit),
**LINK_LAYER_SCHEMAS,
}
)
.extend(cv.COMPONENT_SCHEMA)
.add_extra(_validate_layers)
)
async def new_packet_interface(config: ConfigType, *args: Any) -> MockObj:
"""Create the link layer.
"""Create the link layer and the layers configured on it, from the bottom up.
The link's constructor takes its maximum packet size, usually as a std::span over a buffer that code generation
sized from the configuration, so the two cannot differ.
Returns the link layer; applications reach the top layer with PacketInterface::top().
Returns the link layer; applications reach the top layer with PacketInterface::top(). With several addressing
peers, applications use the peers' ids instead.
"""
var = cg.new_Pvariable(config[CONF_ID], *args)
cg.add(var.set_name(config[CONF_ID].id))
await cg.register_component(var, config)
if (addressing := config.get(CONF_ADDRESSING)) is None:
await build_stack(config, var)
return var
for port, layers_config in await ADDRESSING.get_type(addressing).build(
addressing, var
):
await build_stack(config if layers_config is None else layers_config, port)
return var
@@ -0,0 +1,97 @@
#include "basic_addressing.h"
#ifdef USE_PACKET_INTERFACE_BASIC_ADDRESSING
#include "esphome/core/log.h"
namespace esphome::packet_interface {
ESPHOME_LOG_TAG(TAG, "packet_interface.addressing");
void BasicAddressing::setup() {
this->link_->add_packet_interface_listener(
[this](const PacketBuffer &data, const PacketMetaData &meta_data) { this->receive_(data, meta_data); });
}
void BasicAddressing::dump_config() {
ESP_LOGCONFIG(TAG,
"Basic Addressing on '%s':\n"
" Address: 0x%04X",
this->link_->get_name(), this->address_);
}
bool BasicAddressing::send(const PacketBuffer &data, const PacketMetaData &meta_data, uint16_t destination) {
if (meta_data.destination_address != 0)
destination = meta_data.destination_address;
uint8_t header[HEADER_SIZE] = {static_cast<uint8_t>(destination), static_cast<uint8_t>(destination >> 8),
static_cast<uint8_t>(this->address_), static_cast<uint8_t>(this->address_ >> 8)};
PacketBuffer packet(header, HEADER_SIZE, &data, nullptr, 0);
return this->link_->send_to_interface(packet, meta_data);
}
void BasicAddressing::receive_(const PacketBuffer &data, const PacketMetaData &meta_data) {
// Lower layers deliver packets in one piece; the payload is passed up without copying
if (!data.is_contiguous()) {
ESP_LOGW(TAG, "%s: Packet split across buffers is not supported", this->link_->get_name());
return;
}
auto packet = data.as_span();
if (packet.size() < HEADER_SIZE) {
ESP_LOGV(TAG, "%s: Packet of %zu bytes is shorter than the address header, dropped", this->link_->get_name(),
packet.size());
return;
}
uint16_t destination = encode_uint16(packet[1], packet[0]);
uint16_t source = encode_uint16(packet[3], packet[2]);
if (source == this->address_ || (destination != this->address_ && destination != BROADCAST_ADDRESS))
return;
AddressingPort *source_port = nullptr;
AddressingPort *broadcast_port = nullptr;
for (auto *port : this->ports_) {
if (port->destination_ == source) {
source_port = port;
} else if (port->destination_ == BROADCAST_ADDRESS) {
broadcast_port = port;
}
}
// A broadcast goes to the broadcast port, even from a device with a port of its own, as that port's layers expect
// only its point-to-point packets; anything else goes to the source's port, or to the broadcast port if it has none
AddressingPort *target = source_port;
if (broadcast_port != nullptr && (destination == BROADCAST_ADDRESS || source_port == nullptr))
target = broadcast_port;
if (target == nullptr) {
ESP_LOGV(TAG, "%s: Packet from 0x%04X dropped", this->link_->get_name(), source);
return;
}
PacketMetaData received = meta_data;
received.source_address = source;
received.destination_address = destination;
target->on_receive_from_interface_(PacketBuffer(packet.data() + HEADER_SIZE, packet.size() - HEADER_SIZE), received);
}
AddressingPort::AddressingPort(BasicAddressing *addressing, uint16_t destination, bool only_port)
: PacketInterface(
remaining_packet_size(addressing->get_link()->get_max_packet_size(), BasicAddressing::HEADER_SIZE)),
addressing_(addressing),
destination_(destination) {
if (only_port) {
this->stack_on_(addressing->get_link(), CAPABILITY_ADDRESSING);
} else {
this->inherit_from_(addressing->get_link(), CAPABILITY_ADDRESSING);
}
// A link with no room leaves the layers above none either, so they fail their setup
addressing->add_port(this);
}
void AddressingPort::dump_config() {
ESP_LOGCONFIG(TAG,
"Addressing Port '%s':\n"
" Max Packet Size: %zu",
this->name_, this->max_packet_size_);
if (this->destination_ == BasicAddressing::BROADCAST_ADDRESS) {
ESP_LOGCONFIG(TAG, " Destination: broadcast");
} else {
ESP_LOGCONFIG(TAG, " Destination: 0x%04X", this->destination_);
}
}
} // namespace esphome::packet_interface
#endif
@@ -0,0 +1,69 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_PACKET_INTERFACE_BASIC_ADDRESSING
#include "esphome/core/helpers.h"
#include "packet_interface.h"
namespace esphome::packet_interface {
class AddressingPort;
/**
* An addressing layer for channels shared by several devices, such as an RS485 bus or a LoRa radio.
*
* Each packet starts with a 16-bit destination address and a 16-bit source address, little endian.
* BROADCAST_ADDRESS sends to every device. A packet is dropped unless it is addressed to this device or broadcast, and
* a packet from this device's own address, such as an echo on a half-duplex bus, is dropped too.
*
* Above this layer are one or more ports, each the bottom of its own stack of layers. A port with a destination
* address is a point-to-point link to that device: it sends to it and receives only its packets, so the reliability
* and session layers and the rolling code, which work between two devices only, can be used above it. A port with
* the broadcast destination sends to every device, and receives every broadcast, as well as packets from devices
* that have no port of their own. Without a broadcast port, a device's broadcasts go to its own port.
*/
class BasicAddressing : public Component {
public:
static constexpr uint16_t BROADCAST_ADDRESS = 0xFFFF;
/// Destination and source addresses.
static constexpr size_t HEADER_SIZE = 4;
BasicAddressing(PacketInterface *link, uint16_t address) : link_(link), address_(address) {}
void setup() override;
void dump_config() override;
PacketInterface *get_link() const { return this->link_; }
void add_port(AddressingPort *port) { this->ports_.push_back(port); }
/// Sends \p data to \p destination, or to the destination in \p meta_data if set.
bool send(const PacketBuffer &data, const PacketMetaData &meta_data, uint16_t destination);
protected:
void receive_(const PacketBuffer &data, const PacketMetaData &meta_data);
PacketInterface *link_;
const uint16_t address_;
StaticVector<AddressingPort *, PACKET_INTERFACE_ADDRESSING_PORT_COUNT> ports_;
};
/// The bottom of the stack of layers for one destination, or for broadcast, on an addressed channel.
class AddressingPort : public PacketInterface {
public:
/// With \p only_port, the port is the top of the link, so top() on the link reaches this port's stack.
AddressingPort(BasicAddressing *addressing, uint16_t destination, bool only_port);
void dump_config() override;
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) override {
return this->addressing_->send(data, meta_data, this->destination_);
}
uint16_t get_destination() const { return this->destination_; }
protected:
friend class BasicAddressing;
BasicAddressing *addressing_;
const uint16_t destination_;
};
} // namespace esphome::packet_interface
#endif
@@ -0,0 +1,160 @@
#include "basic_fragmentation.h"
#ifdef USE_PACKET_INTERFACE_BASIC_FRAGMENTATION
#include <algorithm>
#include <cstring>
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::packet_interface {
ESPHOME_LOG_TAG(TAG, "packet_interface.fragmentation");
static constexpr uint8_t LAST_FRAGMENT = 0x80;
static constexpr uint8_t INDEX_MASK = 0x7F;
BasicFragmentation::BasicFragmentation(PacketInterface *lower, size_t max_packet_size)
: PacketInterface(max_packet_size), lower_(lower) {
this->stack_on_(lower, CAPABILITY_FRAGMENTATION);
}
size_t BasicFragmentation::fragments_for_(size_t size) const {
size_t fragment_size = this->fragment_size_();
if (fragment_size == 0)
return SIZE_MAX;
return std::max<size_t>(1, (size + fragment_size - 1) / fragment_size);
}
void BasicFragmentation::setup() {
if (this->fragment_size_() == 0) {
this->mark_failed(LOG_STR("max_packet_size too small for the configured layers"));
return;
}
if (this->fragments_for_(this->max_packet_size_) > MAX_FRAGMENTS) {
this->mark_failed(LOG_STR("max_packet_size needs more than 128 fragments"));
return;
}
// Checked now, while the layer below holds nothing; a failed layer below reports its own failure instead
this->queue_too_short_ =
!this->lower_->is_failed() && this->fragments_for_(this->max_packet_size_) > this->lower_->get_send_capacity();
RAMAllocator<uint8_t> allocator;
this->tx_buffer_ = allocator.allocate(this->fragment_size_() + this->max_packet_size_);
if (this->tx_buffer_ == nullptr) {
this->mark_failed(LOG_STR("Not enough memory for the packet buffers"));
return;
}
this->rx_buffer_ = this->tx_buffer_ + this->fragment_size_();
// Numbering from a random start makes it unlikely that a packet joins fragments from before a restart
this->tx_number_ = random_uint32();
this->lower_->add_packet_interface_listener(
[this](const PacketBuffer &data, const PacketMetaData &meta_data) { this->receive_(data, meta_data); });
}
void BasicFragmentation::dump_config() {
// A link with no room for fragments fails setup, which core reports below
size_t fragments = this->fragment_size_() == 0 ? 0 : this->fragments_for_(this->max_packet_size_);
ESP_LOGCONFIG(TAG,
"Basic Fragmentation on '%s':\n"
" Max Packet Size: %zu\n"
" Fragment Size: %zu\n"
" Fragments in the Largest Packet: %zu",
this->name_, this->max_packet_size_, this->fragment_size_(), fragments);
if (this->queue_too_short_) {
ESP_LOGW(TAG, " The reliability queue is too short for the largest packets, which cannot be sent");
}
}
bool BasicFragmentation::send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) {
if (this->tx_buffer_ == nullptr)
return false;
size_t size = data.size();
if (size > this->max_packet_size_) {
ESP_LOGW(TAG, "%s: Packet of %zu bytes is larger than %zu", this->name_, size, this->max_packet_size_);
return false;
}
size_t fragments = this->fragments_for_(size);
if (fragments > this->lower_->get_send_capacity()) {
ESP_LOGV(TAG, "%s: No room for %zu fragments", this->name_, fragments);
return false;
}
uint8_t header[HEADER_SIZE] = {this->tx_number_++, 0};
if (fragments == 1) {
header[1] = LAST_FRAGMENT;
return this->lower_->send_to_interface(PacketBuffer(header, HEADER_SIZE, &data, nullptr, 0), meta_data);
}
size_t fragment_size = this->fragment_size_();
for (size_t index = 0; index != fragments; index++) {
size_t length = data.copy_to(this->tx_buffer_, fragment_size, index * fragment_size);
header[1] = index | (index + 1 == fragments ? LAST_FRAGMENT : 0);
PacketBuffer body(this->tx_buffer_, length);
if (!this->lower_->send_to_interface(PacketBuffer(header, HEADER_SIZE, &body, nullptr, 0), meta_data)) {
// The peer drops the part already sent when the next packet starts
ESP_LOGV(TAG, "%s: Fragment %zu of %zu not accepted", this->name_, index, fragments);
return false;
}
}
return true;
}
void BasicFragmentation::receive_(const PacketBuffer &data, const PacketMetaData &meta_data) {
// Lower layers deliver packets in one piece; a packet in one fragment is passed up without copying
if (!data.is_contiguous()) {
ESP_LOGW(TAG, "%s: Packet split across buffers is not supported", this->name_);
return;
}
auto fragment = data.as_span();
if (fragment.size() < HEADER_SIZE) {
ESP_LOGV(TAG, "%s: Fragment of %zu bytes is shorter than its header, dropped", this->name_, fragment.size());
return;
}
uint8_t number = fragment[0];
uint8_t index = fragment[1] & INDEX_MASK;
bool last = (fragment[1] & LAST_FRAGMENT) != 0;
const uint8_t *payload = fragment.data() + HEADER_SIZE;
size_t length = fragment.size() - HEADER_SIZE;
if (index == 0 && last) {
// A packet in one fragment leaves a packet being joined from another device intact
if (this->rx_joining_ && meta_data.source_address == this->rx_source_) {
ESP_LOGV(TAG, "%s: Incomplete packet %u dropped", this->name_, this->rx_number_);
this->rx_joining_ = false;
}
if (length > this->max_packet_size_) {
ESP_LOGV(TAG, "%s: Packet %u is larger than %zu, dropped", this->name_, number, this->max_packet_size_);
return;
}
this->on_receive_from_interface_(PacketBuffer(payload, length), meta_data);
return;
}
if (index == 0) {
if (this->rx_joining_) {
ESP_LOGV(TAG, "%s: Incomplete packet %u dropped", this->name_, this->rx_number_);
}
this->rx_joining_ = true;
this->rx_number_ = number;
this->rx_source_ = meta_data.source_address;
this->rx_next_index_ = 0;
this->rx_length_ = 0;
} else if (this->rx_joining_ && meta_data.source_address != this->rx_source_) {
// Only this fragment is lost, so of two devices sending at once, the one that started last still gets through
ESP_LOGV(TAG, "%s: Fragment %u from 0x%04X dropped", this->name_, index, meta_data.source_address);
return;
} else if (!this->rx_joining_ || number != this->rx_number_ || index != this->rx_next_index_) {
ESP_LOGV(TAG, "%s: Fragment %u of packet %u dropped", this->name_, index, number);
this->rx_joining_ = false;
return;
}
if (this->rx_length_ + length > this->max_packet_size_) {
ESP_LOGV(TAG, "%s: Packet %u is larger than %zu, dropped", this->name_, number, this->max_packet_size_);
this->rx_joining_ = false;
return;
}
memcpy(this->rx_buffer_ + this->rx_length_, payload, length);
this->rx_length_ += length;
this->rx_next_index_++;
if (last) {
this->rx_joining_ = false;
this->on_receive_from_interface_(PacketBuffer(this->rx_buffer_, this->rx_length_), meta_data);
}
}
} // namespace esphome::packet_interface
#endif
@@ -0,0 +1,60 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_PACKET_INTERFACE_BASIC_FRAGMENTATION
#include "packet_interface.h"
namespace esphome::packet_interface {
/**
* A fragmentation layer: packets up to this layer's max_packet_size are split into fragments that fit the layer
* below, and joined again at the other end.
*
* Each fragment starts with a 2 byte header: an 8-bit packet number, then the fragment's index in bits 0-6, with bit
* 7 set on the last fragment. A packet that fits in one fragment still has the header, and is sent and passed up
* without being copied.
*
* A packet is started only if the layer below can take all of its fragments now (get_send_capacity()), so a full
* reliability queue does not leave the peer with part of a packet. The receiver joins one packet at a time, and drops
* it when a fragment is missing or arrives with another packet number, or when the same source, or another one,
* starts a new packet of several fragments. Other fragments from another source address are dropped, leaving the
* packet being joined intact. Above a reliability layer, fragments arrive in order and none are missing.
*/
class BasicFragmentation : public PacketInterface {
public:
/// Packet number, then index and last flag.
static constexpr size_t HEADER_SIZE = 2;
/// The index has 7 bits.
static constexpr size_t MAX_FRAGMENTS = 128;
BasicFragmentation(PacketInterface *lower, size_t max_packet_size);
void setup() override;
void dump_config() override;
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) override;
protected:
void receive_(const PacketBuffer &data, const PacketMetaData &meta_data);
size_t fragment_size_() const {
size_t lower_size = this->lower_->get_max_packet_size();
return lower_size > HEADER_SIZE ? lower_size - HEADER_SIZE : 0;
}
size_t fragments_for_(size_t size) const;
PacketInterface *lower_;
// One fragment being sent, and the packet being joined; one block allocated in setup(), and tx_buffer_ stays null
// if that fails
uint8_t *tx_buffer_{nullptr};
uint8_t *rx_buffer_{nullptr};
size_t rx_length_{0};
uint16_t rx_source_{0};
uint8_t tx_number_{0};
uint8_t rx_number_{0};
uint8_t rx_next_index_{0};
bool rx_joining_{false};
bool queue_too_short_{false};
};
} // namespace esphome::packet_interface
#endif
@@ -0,0 +1,135 @@
#include "basic_session.h"
#ifdef USE_PACKET_INTERFACE_BASIC_SESSION
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::packet_interface {
ESPHOME_LOG_TAG(TAG, "packet_interface.session");
static constexpr uint8_t PACKET_TYPE_DATA = 0x00;
static constexpr uint8_t PACKET_TYPE_KEEP_ALIVE = 0x01;
// Type byte and the 32-bit boot id, little endian
static constexpr size_t KEEP_ALIVE_SIZE = 5;
BasicSession::BasicSession(PacketInterface *lower)
: PacketInterface(remaining_packet_size(lower->get_max_packet_size(), HEADER_SIZE)), lower_(lower) {
this->stack_on_(lower, CAPABILITY_SESSION);
// Data lost by a reliability layer below must be sent again, as after a restart of the peer
if (lower->has_capability(CAPABILITY_RELIABILITY)) {
lower->add_on_session_state_callback([this](bool connected) {
if (!connected && this->connected_)
this->set_connected_(false);
});
}
}
void BasicSession::setup() {
if (this->lower_->get_max_packet_size() < KEEP_ALIVE_SIZE) {
this->mark_failed(LOG_STR("max_packet_size too small for the configured layers"));
return;
}
this->boot_id_ = random_uint32();
#ifdef USE_BINARY_SENSOR
if (this->connected_sensor_ != nullptr)
this->connected_sensor_->publish_initial_state(false);
#endif
this->lower_->add_packet_interface_listener(
[this](const PacketBuffer &data, const PacketMetaData &meta_data) { this->receive_(data, meta_data); });
// A reliability layer below queues this until its link is up, so the peer hears of a restart straight away
this->send_keep_alive_();
this->set_interval(this->keep_alive_ms_, [this] { this->on_interval_(); });
}
void BasicSession::dump_config() {
ESP_LOGCONFIG(TAG,
"Basic Session on '%s':\n"
" Max Packet Size: %zu\n"
" Keep Alive: %" PRIu32 "ms\n"
" Timeout: %" PRIu32 "ms",
this->name_, this->max_packet_size_, this->keep_alive_ms_, this->timeout_ms_);
#ifdef USE_BINARY_SENSOR
LOG_BINARY_SENSOR(" ", "Connected", this->connected_sensor_);
#endif
}
bool BasicSession::send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) {
if (this->is_failed())
return false;
uint8_t header = PACKET_TYPE_DATA;
PacketBuffer packet(&header, HEADER_SIZE, &data, nullptr, 0);
return this->lower_->send_to_interface(packet, meta_data);
}
void BasicSession::send_keep_alive_() {
uint8_t packet[KEEP_ALIVE_SIZE] = {
PACKET_TYPE_KEEP_ALIVE, static_cast<uint8_t>(this->boot_id_), static_cast<uint8_t>(this->boot_id_ >> 8),
static_cast<uint8_t>(this->boot_id_ >> 16), static_cast<uint8_t>(this->boot_id_ >> 24)};
if (!this->lower_->send_to_interface(PacketBuffer(packet, sizeof(packet)), {}))
ESP_LOGV(TAG, "%s: Lower layer did not accept keep-alive", this->name_);
}
void BasicSession::on_interval_() {
this->send_keep_alive_();
if (this->connected_ && App.get_loop_component_start_time() - this->last_receive_ms_ > this->timeout_ms_) {
ESP_LOGW(TAG, "%s: Peer not heard for %" PRIu32 "ms", this->name_, this->timeout_ms_);
this->set_connected_(false);
}
}
void BasicSession::set_connected_(bool connected) {
this->connected_ = connected;
ESP_LOGD(TAG, "%s: Session %s", this->name_, connected ? LOG_STR_LITERAL("connected") : LOG_STR_LITERAL("lost"));
#ifdef USE_BINARY_SENSOR
if (this->connected_sensor_ != nullptr)
this->connected_sensor_->publish_state(connected);
#endif
this->session_callback_.call(connected);
}
void BasicSession::receive_(const PacketBuffer &data, const PacketMetaData &meta_data) {
// Lower layers deliver packets in one piece; the payload is passed up without copying
if (!data.is_contiguous()) {
ESP_LOGW(TAG, "%s: Packet split across buffers is not supported", this->name_);
return;
}
auto packet = data.as_span();
if (packet.empty()) {
this->note_received_(false);
return;
}
uint8_t type = packet[0];
if (type == PACKET_TYPE_KEEP_ALIVE) {
if (packet.size() != KEEP_ALIVE_SIZE) {
ESP_LOGV(TAG, "%s: Keep-alive of %zu bytes dropped", this->name_, packet.size());
this->note_received_(false);
return;
}
uint32_t boot_id = encode_uint32(packet[4], packet[3], packet[2], packet[1]);
if (!this->peer_known_ || boot_id != this->peer_boot_id_) {
// Data may arrive before the first keep-alive, so only a changed id means a restart
if (this->connected_ && this->peer_known_) {
ESP_LOGD(TAG, "%s: Peer has restarted", this->name_);
this->set_connected_(false);
}
this->peer_boot_id_ = boot_id;
this->peer_known_ = true;
// Answer a new peer at once; it answers only if our id is new to it, so this cannot repeat
this->send_keep_alive_();
}
} else if (type != PACKET_TYPE_DATA) {
ESP_LOGV(TAG, "%s: Unknown packet type 0x%02X dropped", this->name_, type);
this->note_received_(false);
return;
}
this->note_received_(true);
this->last_receive_ms_ = App.get_loop_component_start_time();
if (!this->connected_)
this->set_connected_(true);
if (type == PACKET_TYPE_DATA)
this->on_receive_from_interface_(PacketBuffer(packet.data() + HEADER_SIZE, packet.size() - HEADER_SIZE), meta_data);
}
} // namespace esphome::packet_interface
#endif
@@ -0,0 +1,61 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_PACKET_INTERFACE_BASIC_SESSION
#include "packet_interface.h"
#ifdef USE_BINARY_SENSOR
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
namespace esphome::packet_interface {
/**
* A session layer that reports whether the peer can be reached.
*
* Every packet starts with a type byte. A data packet carries the payload of the layer above. A keep-alive packet
* carries a random number chosen at boot, so that a restart of the peer is noticed even if it is quicker than the
* timeout. A keep-alive is sent every keep_alive interval, and at once when a new peer is heard. The peer is
* considered gone when nothing has been received from it for the timeout; this is checked every keep_alive interval.
* A restart of the peer is reported as a disconnect followed by a connect. So is a reset of a reliability layer below,
* which discards data: the session is reported lost, and connected again when the peer is next heard.
*/
class BasicSession : public PacketInterface {
public:
/// The packet type byte.
static constexpr size_t HEADER_SIZE = 1;
explicit BasicSession(PacketInterface *lower);
void setup() override;
void dump_config() override;
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) override;
void set_keep_alive(uint32_t keep_alive_ms) { this->keep_alive_ms_ = keep_alive_ms; }
void set_session_timeout(uint32_t timeout_ms) { this->timeout_ms_ = timeout_ms; }
#ifdef USE_BINARY_SENSOR
void set_connected_sensor(binary_sensor::BinarySensor *sensor) { this->connected_sensor_ = sensor; }
#endif
bool is_connected() const { return this->connected_; }
protected:
void receive_(const PacketBuffer &data, const PacketMetaData &meta_data);
void on_interval_();
void send_keep_alive_();
void set_connected_(bool connected);
PacketInterface *lower_;
#ifdef USE_BINARY_SENSOR
binary_sensor::BinarySensor *connected_sensor_{nullptr};
#endif
uint32_t keep_alive_ms_{10000};
uint32_t timeout_ms_{30000};
uint32_t last_receive_ms_{0};
uint32_t boot_id_{0};
uint32_t peer_boot_id_{0};
bool peer_known_{false};
bool connected_{false};
};
} // namespace esphome::packet_interface
#endif
@@ -0,0 +1,365 @@
#include "hdlc_reliability.h"
#ifdef USE_PACKET_INTERFACE_HDLC
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::packet_interface {
ESPHOME_LOG_TAG(TAG, "packet_interface.hdlc");
static constexpr uint32_t RETRANSMIT_TIMER_ID = 0;
static constexpr uint32_t ACK_TIMER_ID = 1;
static constexpr uint32_t SEND_RETRY_TIMER_ID = 2;
// A frame or acknowledgement the lower layer refused, for example while a radio was receiving, is sent again after this
static constexpr uint32_t SEND_RETRY_MS = 50;
// SABM is repeated at this interval, or the timeout if longer, until the peer answers
static constexpr uint32_t RESET_INTERVAL_MS = 1000;
// Control byte formats, with the poll/final bit (0x10) ignored
static constexpr uint8_t POLL_FINAL_BIT = 0x10;
static constexpr uint8_t SUPERVISORY_FRAME = 0x01;
static constexpr uint8_t SUPERVISORY_RR = 0x00;
static constexpr uint8_t SUPERVISORY_REJ = 0x02;
static constexpr uint8_t UNNUMBERED_SABM = 0x2F;
static constexpr uint8_t UNNUMBERED_UA = 0x63;
static uint16_t frame_crc(uint8_t control, const uint8_t *payload, size_t length) {
uint16_t crc = crc16(&control, 1);
return crc16(payload, length, crc);
}
HdlcReliability::HdlcReliability(PacketInterface *lower)
: PacketInterface(remaining_packet_size(lower->get_max_packet_size(), FRAME_OVERHEAD)), lower_(lower) {
this->stack_on_(lower, CAPABILITY_RELIABILITY);
}
void HdlcReliability::setup() {
if (this->max_packet_size_ == 0) {
this->mark_failed(LOG_STR("max_packet_size too small for the configured layers"));
return;
}
RAMAllocator<uint8_t> allocator;
this->tx_slots_ = allocator.allocate(size_t{this->max_queue_length_} * this->max_packet_size_);
if (this->tx_slots_ == nullptr) {
this->mark_failed(LOG_STR("Not enough memory for the send queue"));
return;
}
this->lower_->add_packet_interface_listener(
[this](const PacketBuffer &data, const PacketMetaData &meta_data) { this->receive_(data, meta_data); });
}
void HdlcReliability::dump_config() {
ESP_LOGCONFIG(TAG,
"HDLC Reliability on '%s':\n"
" Max Packet Size: %zu\n"
" Timeout: %" PRIu32 "ms\n"
" Ack Delay: %" PRIu32 "ms\n"
" Max Queue Length: %u\n"
" Max Retries: %u",
this->name_, this->max_packet_size_, this->timeout_ms_, this->ack_delay_ms_, this->max_queue_length_,
this->max_retries_);
}
void HdlcReliability::loop() {
// Disabled first, as sending may deliver a reply to this layer and request the loop again
this->disable_loop();
if (this->state_ == LinkState::LINK_STATE_RESET_PENDING) {
this->state_ = LinkState::LINK_STATE_RESETTING;
this->start_timer_();
this->send_frame_(UNNUMBERED_SABM, nullptr, 0);
}
if (this->state_ != LinkState::LINK_STATE_CONNECTED)
return;
// Queued frames go first, as each carries the acknowledgement
if (this->send_pending_() && this->ack_due_ && !this->send_supervisory_(SUPERVISORY_RR))
this->retry_send_();
}
void HdlcReliability::retry_send_() {
this->set_timeout(SEND_RETRY_TIMER_ID, SEND_RETRY_MS, [this]() { this->request_loop_(); });
}
void HdlcReliability::owe_ack_(bool now) {
if (now || this->ack_delay_ms_ == 0) {
this->ack_due_ = true;
this->request_loop_();
} else if (!this->ack_pending_) {
// Waiting lets one acknowledgement cover several frames, or a reply carry it
this->set_timeout(ACK_TIMER_ID, this->ack_delay_ms_, [this]() {
this->ack_due_ = true;
this->request_loop_();
});
}
this->ack_pending_ = true;
}
void HdlcReliability::ack_sent_() {
this->ack_pending_ = false;
this->ack_due_ = false;
this->cancel_timeout(ACK_TIMER_ID);
}
void HdlcReliability::request_loop_() { this->enable_loop(); }
bool HdlcReliability::send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) {
if (this->tx_slots_ == nullptr)
return false;
size_t size = data.size();
if (size > this->max_packet_size_) {
ESP_LOGW(TAG, "%s: Packet of %zu bytes is larger than %zu", this->name_, size, this->max_packet_size_);
return false;
}
uint8_t outstanding = this->outstanding_();
if (outstanding >= this->max_queue_length_) {
if (this->state_ == LinkState::LINK_STATE_CONNECTED) {
ESP_LOGV(TAG, "%s: Send queue full", this->name_);
} else if (!this->reset_wait_logged_) {
// Once for each reset; the layers above keep trying while the peer is away
this->reset_wait_logged_ = true;
ESP_LOGD(TAG, "%s: Send queue full, waiting for the peer to answer the link reset", this->name_);
}
return false;
}
uint8_t seq = this->vs_;
data.copy_to(this->slot_(seq), size);
this->tx_lengths_[this->slot_index_(seq)] = size;
this->vs_ = (seq + 1) & 7;
// While the link is being reset, the frame waits in the queue and is sent when UA arrives
if (this->state_ == LinkState::LINK_STATE_CONNECTED) {
this->send_pending_();
if (outstanding == 0)
this->start_timer_();
}
return true;
}
bool HdlcReliability::send_frame_(uint8_t control, const uint8_t *payload, size_t length) {
uint16_t crc = frame_crc(control, payload, length);
uint8_t trailer[2] = {static_cast<uint8_t>(crc), static_cast<uint8_t>(crc >> 8)};
PacketBuffer body(payload, length);
PacketBuffer frame(&control, 1, length != 0 ? &body : nullptr, trailer, sizeof(trailer));
if (this->lower_->send_to_interface(frame, {}))
return true;
ESP_LOGV(TAG, "%s: Lower layer did not accept frame 0x%02X", this->name_, control);
return false;
}
bool HdlcReliability::send_supervisory_(uint8_t type) {
if (!this->send_frame_(SUPERVISORY_FRAME | (type << 2) | (this->vr_ << 5), nullptr, 0))
return false;
this->ack_sent_();
return true;
}
// Sends the next queued frame not yet sent, and returns true if none are left. One frame per loop pass lets other
// components run between frames: on a radio, sending blocks, and the peer's frames are received in between. If the
// lower layer refuses the frame, it is sent again shortly.
bool HdlcReliability::send_pending_() {
if (this->vn_ == this->vs_)
return true;
uint8_t control = (this->vn_ << 1) | (this->vr_ << 5);
if (!this->send_frame_(control, this->slot_(this->vn_), this->tx_lengths_[this->slot_index_(this->vn_)])) {
this->retry_send_();
return false;
}
// Every I-frame carries an acknowledgement, so a pending RR is no longer needed
this->ack_sent_();
this->vn_ = (this->vn_ + 1) & 7;
if (this->vn_ == this->vs_)
return true;
this->request_loop_();
return false;
}
void HdlcReliability::resend_outstanding_() {
this->vn_ = this->va_;
this->send_pending_();
}
void HdlcReliability::start_timer_() {
uint32_t interval = this->timeout_ms_;
if (this->state_ != LinkState::LINK_STATE_CONNECTED && interval < RESET_INTERVAL_MS)
interval = RESET_INTERVAL_MS;
// Up to a quarter longer at random, so devices whose frames collided on a shared channel do not resend together
interval += random_uint32() % (interval / 4 + 1);
this->set_timeout(RETRANSMIT_TIMER_ID, interval, [this]() { this->on_timeout_(); });
}
void HdlcReliability::on_timeout_() {
if (this->state_ != LinkState::LINK_STATE_CONNECTED) {
// Counted up to max_retries_ only, so the warning is logged once until the link comes up
if (this->retries_ < this->max_retries_) {
this->retries_++;
if (this->retries_ == this->max_retries_) {
ESP_LOGW(TAG, "%s: No answer to the link reset after %u tries; check that the peer has a reliability layer",
this->name_, this->max_retries_);
}
}
this->send_frame_(UNNUMBERED_SABM, nullptr, 0);
this->start_timer_();
return;
}
if (this->outstanding_() == 0)
return;
if (this->retries_ == this->max_retries_) {
ESP_LOGW(TAG, "%s: No acknowledgement after %u retries, resetting link", this->name_, this->max_retries_);
this->start_reset_();
return;
}
this->retries_++;
ESP_LOGV(TAG, "%s: Timeout, resending %u frames", this->name_, this->outstanding_());
this->resend_outstanding_();
this->start_timer_();
}
// Unacknowledged frames are discarded; frames queued from now on are numbered from 0 and sent once the link is up.
// Resetting a connected link can lose data both ways, which is reported to the layers above.
void HdlcReliability::start_reset_() {
bool was_connected = this->state_ == LinkState::LINK_STATE_CONNECTED;
this->state_ = LinkState::LINK_STATE_RESET_PENDING;
this->va_ = this->vs_ = this->vn_ = 0;
this->head_ = 0;
this->vr_ = 0;
this->retries_ = 0;
this->rej_sent_ = false;
this->ack_sent_();
this->cancel_timeout(RETRANSMIT_TIMER_ID);
this->request_loop_();
if (was_connected)
this->session_callback_.call(false);
}
void HdlcReliability::link_up_() {
ESP_LOGD(TAG, "%s: Link connected", this->name_);
this->state_ = LinkState::LINK_STATE_CONNECTED;
this->reset_wait_logged_ = false;
this->peer_frame_seen_ = false;
this->retries_ = 0;
this->cancel_timeout(RETRANSMIT_TIMER_ID);
if (this->outstanding_() != 0) {
this->resend_outstanding_();
this->start_timer_();
}
}
void HdlcReliability::process_ack_(uint8_t nr) {
uint8_t acked = (nr - this->va_) & 7;
if (acked > this->outstanding_()) {
ESP_LOGV(TAG, "%s: Ignoring invalid N(R) %u", this->name_, nr);
return;
}
if (acked == 0)
return;
// The peer may have had frames that were refused when last resent
if (acked > ((this->vn_ - this->va_) & 7))
this->vn_ = nr;
this->head_ = (this->head_ + acked) % this->max_queue_length_;
this->va_ = nr;
this->retries_ = 0;
if (this->outstanding_() == 0) {
this->cancel_timeout(RETRANSMIT_TIMER_ID);
} else {
this->start_timer_();
}
}
void HdlcReliability::receive_(const PacketBuffer &data, const PacketMetaData &meta_data) {
// Lower layers deliver packets in one piece; the payload is passed up without copying
if (!data.is_contiguous()) {
ESP_LOGW(TAG, "%s: Packet split across buffers is not supported", this->name_);
return;
}
auto frame = data.as_span();
if (frame.size() < FRAME_OVERHEAD) {
this->note_received_(false);
return;
}
size_t length = frame.size() - FRAME_OVERHEAD;
uint8_t control = frame[0];
const uint8_t *payload = frame.data() + 1;
uint16_t crc = frame[length + 1] | (frame[length + 2] << 8);
if (frame_crc(control, payload, length) != crc) {
ESP_LOGV(TAG, "%s: CRC error, frame dropped", this->name_);
this->note_received_(false);
return;
}
// The peer's packets may only be larger if it has a larger max_packet_size, which the layers above cannot take
if (length > this->max_packet_size_) {
if (!this->oversize_warned_) {
this->oversize_warned_ = true;
ESP_LOGW(TAG,
"%s: Frame of %zu bytes is larger than %zu, dropped; check that both devices use the same "
"max_packet_size",
this->name_, length, this->max_packet_size_);
} else {
ESP_LOGV(TAG, "%s: Frame of %zu bytes is larger than %zu, dropped", this->name_, length, this->max_packet_size_);
}
this->note_received_(false);
return;
}
this->note_received_(true);
uint8_t command = control & ~POLL_FINAL_BIT;
if (command == UNNUMBERED_SABM) {
// A peer that has not heard our UA repeats its SABM, and cannot have sent a numbered frame meanwhile; answering
// again keeps this side connected, and the queued frames, instead of treating the repeat as a restart.
if (this->state_ == LinkState::LINK_STATE_CONNECTED && this->peer_frame_seen_) {
// The peer has restarted, so frames it has not acknowledged are lost
ESP_LOGD(TAG, "%s: Link reset by peer", this->name_);
this->start_reset_();
}
this->vr_ = 0;
this->rej_sent_ = false;
this->send_frame_(UNNUMBERED_UA, nullptr, 0);
// Both sides now start from 0, so the link is up even if our own SABM is unanswered
this->link_up_();
return;
}
if (command == UNNUMBERED_UA) {
if (this->state_ == LinkState::LINK_STATE_RESETTING)
this->link_up_();
return;
}
// Numbered frames are ignored until the link is reset; the peer answers our SABM first
if (this->state_ != LinkState::LINK_STATE_CONNECTED)
return;
this->peer_frame_seen_ = true;
uint8_t nr = control >> 5;
if ((control & 1) == 0) {
this->process_ack_(nr);
this->receive_info_(control, payload, length, meta_data);
return;
}
if ((control & 3) != SUPERVISORY_FRAME)
return;
this->process_ack_(nr);
if (((control >> 2) & 3) == SUPERVISORY_REJ && this->outstanding_() != 0) {
ESP_LOGV(TAG, "%s: Peer rejected from %u", this->name_, nr);
this->resend_outstanding_();
this->start_timer_();
}
}
void HdlcReliability::receive_info_(uint8_t control, const uint8_t *payload, size_t length,
const PacketMetaData &meta_data) {
uint8_t ns = (control >> 1) & 7;
if (ns != this->vr_) {
// A gap or a duplicate; ask once for a resend from the expected frame, then just acknowledge at once
if (!this->rej_sent_) {
this->rej_sent_ = this->send_supervisory_(SUPERVISORY_REJ);
if (this->rej_sent_)
return;
}
this->owe_ack_(true);
return;
}
this->vr_ = (this->vr_ + 1) & 7;
this->rej_sent_ = false;
// The acknowledgement is sent from loop(), so a reply sent from the callback can carry it instead
this->owe_ack_(false);
this->on_receive_from_interface_(PacketBuffer(payload, length), meta_data);
}
} // namespace esphome::packet_interface
#endif
@@ -0,0 +1,99 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_PACKET_INTERFACE_HDLC
#include <array>
#include "packet_interface.h"
namespace esphome::packet_interface {
/**
* A reliability layer that delivers packets in order, without loss or duplicates, between two peers.
*
* It follows HDLC asynchronous balanced mode, with sequence numbers modulo 8 and go-back-N retransmission:
* - I-frames carry a payload, the sequence number N(S) and an acknowledgement N(R) of the peer's frames.
* - RR frames acknowledge without a payload; REJ frames ask the peer to resend from N(R).
* - SABM resets both peers' sequence numbers, and UA answers it.
*
* Each frame is the HDLC control byte, the payload, then a CRC-16 of both in little endian order.
* Unacknowledged frames are resent after the timeout; after max_retries the link is reset.
*/
class HdlcReliability : public PacketInterface {
public:
/// Most frames that can be unacknowledged at once, set by the 3 bit sequence numbers.
static constexpr uint8_t MAX_WINDOW = 7;
/// Control byte plus CRC-16.
static constexpr size_t FRAME_OVERHEAD = 3;
explicit HdlcReliability(PacketInterface *lower);
void setup() override;
void loop() override;
void dump_config() override;
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) override;
/// No room while the layer has no send queue: before setup(), or when setup() failed.
size_t get_send_capacity() const override {
return this->tx_slots_ == nullptr ? 0 : this->max_queue_length_ - this->outstanding_();
}
void set_ack_timeout(uint32_t timeout_ms) { this->timeout_ms_ = timeout_ms; }
void set_ack_delay(uint32_t ack_delay_ms) { this->ack_delay_ms_ = ack_delay_ms; }
void set_max_queue_length(uint8_t length) { this->max_queue_length_ = length; }
void set_max_retries(uint8_t retries) { this->max_retries_ = retries; }
bool is_connected() const { return this->state_ == LinkState::LINK_STATE_CONNECTED; }
protected:
enum class LinkState : uint8_t {
LINK_STATE_RESET_PENDING, // SABM to be sent from loop()
LINK_STATE_RESETTING, // SABM sent, waiting for UA
LINK_STATE_CONNECTED,
};
void receive_(const PacketBuffer &data, const PacketMetaData &meta_data);
void receive_info_(uint8_t control, const uint8_t *payload, size_t length, const PacketMetaData &meta_data);
bool send_frame_(uint8_t control, const uint8_t *payload, size_t length);
bool send_supervisory_(uint8_t type);
bool send_pending_();
void resend_outstanding_();
void retry_send_();
/// An RR is owed: at once, or after the ack delay unless an I-frame carries it first.
void owe_ack_(bool now);
void ack_sent_();
void process_ack_(uint8_t nr);
void on_timeout_();
void start_reset_();
void link_up_();
void start_timer_();
void request_loop_();
uint8_t outstanding_() const { return (this->vs_ - this->va_) & 7; }
uint8_t slot_index_(uint8_t seq) const { return (this->head_ + ((seq - this->va_) & 7)) % this->max_queue_length_; }
uint8_t *slot_(uint8_t seq) { return this->tx_slots_ + this->slot_index_(seq) * this->max_packet_size_; }
PacketInterface *lower_;
// max_queue_length_ slots of max_packet_size_ bytes, a ring starting at head_ which holds frame va_; allocated in
// setup() and never freed
uint8_t *tx_slots_{nullptr};
std::array<uint16_t, MAX_WINDOW> tx_lengths_{}; // indexed like the slots
uint32_t timeout_ms_{200};
uint32_t ack_delay_ms_{0};
uint8_t max_queue_length_{4};
uint8_t max_retries_{5};
uint8_t retries_{0};
uint8_t head_{0};
uint8_t vs_{0}; // sequence number of the next new frame
uint8_t vn_{0}; // next frame to send; frames from va_ up to it have been sent
uint8_t va_{0}; // oldest unacknowledged frame
uint8_t vr_{0}; // next frame expected from the peer
LinkState state_{LinkState::LINK_STATE_RESET_PENDING};
bool ack_pending_{false}; // an RR is owed
bool ack_due_{false}; // and should be sent now
bool rej_sent_{false};
bool peer_frame_seen_{false}; // a numbered frame has arrived since the link came up
bool oversize_warned_{false};
bool reset_wait_logged_{false}; // the queue filled while waiting for this reset to be answered
};
} // namespace esphome::packet_interface
#endif
@@ -0,0 +1,238 @@
"""The kinds of layer that can be stacked on a packet interface, and the registry of their implementations.
Each module in this package implements one layer type and registers it with its kind, for example:
@RELIABILITY.register("hdlc", schema=HDLC_SCHEMA, single_peer=lambda config: True)
async def build_hdlc(config: ConfigType, lower: MockObj) -> MockObj:
...
The packet_interface component imports every module here before it builds its schemas, so a new layer type needs
only a new module. The kinds, and the order in which they are stacked, are fixed here.
"""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ENCRYPTION, CONF_FRAGMENTATION, CONF_KEY, CONF_TYPE
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
packet_interface_ns = cg.esphome_ns.namespace("packet_interface")
PacketInterface = packet_interface_ns.class_("PacketInterface", cg.Component)
CONF_ADDRESSING = "addressing"
CONF_DESTINATION = "destination"
CONF_PEERS = "peers"
CONF_RELIABILITY = "reliability"
CONF_SESSION = "session"
BROADCAST_ADDRESS = 0xFFFF
# Builds a layer above lower and returns it. An addressing layer instead returns a list of (port, config) pairs, one
# for each stack it carries, where config holds the layers for that stack, or is None for the layers on the link.
LayerBuilder = Callable[[ConfigType, MockObj], Awaitable[Any]]
def _never(config: ConfigType) -> bool:
return False
def _nothing(config: ConfigType) -> list[str]:
return []
def _same_size(config: ConfigType, size: int) -> int:
return size
def _no_limit(config: ConfigType) -> int | None:
return None
def _fits(config: ConfigType, size: int, capacity: int | None) -> None:
"""Accept any layer below."""
@dataclass(frozen=True)
class LayerType:
"""One implementation of a kind of layer."""
# Returns the schema; called once every layer type is registered, as some schemas contain other layers
schema_factory: Callable[[], Any]
build: LayerBuilder
# True if this configuration keeps state for one peer, so cannot be used with broadcast addressing
single_peer: Callable[[ConfigType], bool]
# True if this configuration works only when the peer sends packets back, which a session layer makes sure of
needs_session: Callable[[ConfigType], bool]
# The components this configuration needs loaded
auto_load: Callable[[ConfigType], list[str]]
# The largest packet this configuration takes, from the largest that the layer below takes, as the C++ works it out
packet_size: Callable[[ConfigType, int], int]
# How many packets this configuration takes at once, or None for no limit
send_capacity: Callable[[ConfigType], int | None]
# Raises cv.Invalid if this configuration cannot work above a layer with this packet size and send capacity
check_lower: Callable[[ConfigType, int, int | None], None]
class LayerKind:
"""A kind of layer, such as encryption, with the types that implement it."""
def __init__(
self, key: str, default_type: str, shorthand: str | None = None
) -> None:
self.key = key
self.default_type = default_type
# The option that a plain string value sets, as in `encryption: my key`
self.shorthand = shorthand
self.types: dict[str, LayerType] = {}
# Set once the schema is built, as types registered after that would not be in it
self.frozen = False
def register(
self,
name: str,
*,
schema: Any = None,
schema_factory: Callable[[], Any] | None = None,
single_peer: Callable[[ConfigType], bool] = _never,
needs_session: Callable[[ConfigType], bool] = _never,
auto_load: Callable[[ConfigType], list[str]] = _nothing,
packet_size: Callable[[ConfigType, int], int] = _same_size,
send_capacity: Callable[[ConfigType], int | None] = _no_limit,
check_lower: Callable[[ConfigType, int, int | None], None] = _fits,
) -> Callable[[LayerBuilder], LayerBuilder]:
"""Register a type with its schema, or with a function returning a schema that contains other layers."""
def fixed_schema() -> Any:
return schema
def decorator(build: LayerBuilder) -> LayerBuilder:
if self.frozen:
raise ValueError(
f"{self.key} layer type '{name}' is registered after the schemas were built; register it "
"before the packet_interface component is imported"
)
if name in self.types:
raise ValueError(
f"{self.key} layer type '{name}' is already registered"
)
self.types[name] = LayerType(
schema_factory or fixed_schema,
build,
single_peer,
needs_session,
auto_load,
packet_size,
send_capacity,
check_lower,
)
return build
return decorator
def _expand(self, value: Any) -> Any:
"""Allow a layer to be enabled with no options, as in `reliability:`, or with its shorthand."""
if value is None:
return {}
if self.shorthand is not None and isinstance(value, str):
return {self.shorthand: value}
return value
def schema(self) -> Callable[[Any], ConfigType]:
"""The schema for this kind; no type can be registered after this."""
self.frozen = True
return cv.All(
self._expand,
cv.typed_schema(
{name: layer.schema_factory() for name, layer in self.types.items()},
default_type=self.default_type,
),
)
def get_type(self, config: ConfigType) -> LayerType:
return self.types[config[CONF_TYPE]]
# An addressing type's configuration has either a destination or a list of peers, each with an id, a destination
# and its own layers.
ADDRESSING = LayerKind(CONF_ADDRESSING, "basic")
ENCRYPTION = LayerKind(CONF_ENCRYPTION, "xxtea", shorthand=CONF_KEY)
RELIABILITY = LayerKind(CONF_RELIABILITY, "hdlc")
FRAGMENTATION = LayerKind(CONF_FRAGMENTATION, "basic")
SESSION = LayerKind(CONF_SESSION, "basic")
# The kinds stacked above the link, or above each port of an addressing layer, from the bottom up
STACK_ORDER = (ENCRYPTION, RELIABILITY, FRAGMENTATION, SESSION)
def stack_schema() -> dict:
"""Schema entries for the layers of one stack; call only once all layer types are registered."""
return {cv.Optional(kind.key): kind.schema() for kind in STACK_ORDER}
def check_session(config: ConfigType) -> None:
"""Layers that need packets back from the peer need a session layer, whose keep-alives send them."""
if CONF_SESSION in config:
return
for kind in STACK_ORDER:
if (layer := config.get(kind.key)) is not None and kind.get_type(
layer
).needs_session(layer):
raise cv.Invalid(
f"This '{kind.key}' needs packets from the peer, so it needs a '{CONF_SESSION}' layer, whose "
"keep-alives make sure they are sent",
path=[kind.key],
)
def check_single_peer(config: ConfigType, destination: int) -> None:
"""Layers that keep state for one peer need a destination other than broadcast."""
if destination != BROADCAST_ADDRESS:
return
for kind in STACK_ORDER:
if (layer := config.get(kind.key)) is not None and kind.get_type(
layer
).single_peer(layer):
raise cv.Invalid(
f"This '{kind.key}' works between two devices only, so it needs a '{CONF_DESTINATION}' other than "
"broadcast",
path=[kind.key],
)
def remaining_packet_size(size: int, used: int) -> int:
return max(size - used, 0)
def check_packet_sizes(config: ConfigType, size: int) -> None:
"""Check each layer in config against the layer below it; size is the largest packet that the stack sits on takes."""
capacity = None
for kind in STACK_ORDER:
if (layer := config.get(kind.key)) is None:
continue
layer_type = kind.get_type(layer)
try:
layer_type.check_lower(layer, size, capacity)
except cv.Invalid as err:
err.prepend([kind.key])
raise
size = layer_type.packet_size(layer, size)
capacity = layer_type.send_capacity(layer)
def stack_auto_load(config: ConfigType) -> set[str]:
"""The components that the layers configured in config need loaded."""
loads: set[str] = set()
for kind in STACK_ORDER:
if (layer := config.get(kind.key)) is not None:
loads.update(kind.get_type(layer).auto_load(layer))
return loads
async def build_stack(config: ConfigType, lower: MockObj) -> None:
"""Create the layers configured in config, from the bottom up, above lower."""
for kind in STACK_ORDER:
if (layer := config.get(kind.key)) is not None:
lower = await kind.get_type(layer).build(layer, lower)
@@ -0,0 +1,132 @@
"""Addressing layer with 16-bit addresses, carrying one stack of layers or one for each of several peers."""
from typing import Any
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ADDRESS, CONF_ID
from esphome.core import ID
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
from . import (
ADDRESSING,
BROADCAST_ADDRESS,
CONF_DESTINATION,
CONF_PEERS,
PacketInterface,
check_session,
check_single_peer,
packet_interface_ns,
remaining_packet_size,
stack_schema,
)
CONF_PORT_ID = "port_id"
# The destination and source addresses
HEADER_SIZE = 4
BasicAddressing = packet_interface_ns.class_("BasicAddressing", cg.Component)
AddressingPort = packet_interface_ns.class_("AddressingPort", PacketInterface)
_request_port_slot = cg.slot_counter("PACKET_INTERFACE_ADDRESSING_PORT_COUNT")
def _destination(value: Any) -> int:
"""A device address, or broadcast."""
if isinstance(value, str) and value.lower() == "broadcast":
return BROADCAST_ADDRESS
return cv.hex_int_range(1, BROADCAST_ADDRESS)(value)
def _validate_peer(config: ConfigType) -> ConfigType:
check_session(config)
check_single_peer(config, config[CONF_DESTINATION])
return config
def _unique_destinations(peers: list[ConfigType]) -> list[ConfigType]:
destinations = [peer[CONF_DESTINATION] for peer in peers]
if len(set(destinations)) != len(destinations):
raise cv.Invalid("Each peer must have a different destination")
return peers
def _not_own_address(config: ConfigType) -> ConfigType:
"""Packets from this device's own address are dropped as echoes, so it cannot be a destination."""
address = config[CONF_ADDRESS]
if config.get(CONF_DESTINATION) == address:
raise cv.Invalid(
"Must not be this device's own address", path=[CONF_DESTINATION]
)
for index, peer in enumerate(config.get(CONF_PEERS, [])):
if peer[CONF_DESTINATION] == address:
raise cv.Invalid(
"Must not be this device's own address",
path=[CONF_PEERS, index, CONF_DESTINATION],
)
return config
# A function, as each peer holds the other layers, whose schemas exist only once every layer type is registered
def _basic_addressing_schema() -> cv.Schema:
peer_schema = cv.All(
cv.Schema(
{
cv.Required(CONF_ID): cv.declare_id(AddressingPort),
cv.Required(CONF_DESTINATION): _destination,
**stack_schema(),
}
),
_validate_peer,
)
return cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BasicAddressing),
cv.GenerateID(CONF_PORT_ID): cv.declare_id(AddressingPort),
cv.Required(CONF_ADDRESS): cv.hex_int_range(1, BROADCAST_ADDRESS - 1),
cv.Optional(CONF_DESTINATION): _destination,
cv.Optional(CONF_PEERS): cv.All(
cv.ensure_list(peer_schema), cv.Length(min=1), _unique_destinations
),
}
).extend(cv.COMPONENT_SCHEMA),
cv.has_at_most_one_key(CONF_DESTINATION, CONF_PEERS),
_not_own_address,
)
async def _new_port(
port_id: ID, addressing: MockObj, destination: int, only_port: bool
) -> MockObj:
_request_port_slot(str(addressing))
port = cg.new_Pvariable(port_id, addressing, destination, only_port)
await cg.register_component(port, {})
return port
@ADDRESSING.register(
"basic",
schema_factory=_basic_addressing_schema,
packet_size=lambda config, size: remaining_packet_size(size, HEADER_SIZE),
)
async def build_basic_addressing(
config: ConfigType, link: MockObj
) -> list[tuple[MockObj, ConfigType | None]]:
cg.add_define("USE_PACKET_INTERFACE_BASIC_ADDRESSING")
hub = cg.new_Pvariable(config[CONF_ID], link, config[CONF_ADDRESS])
await cg.register_component(hub, config)
if (peers := config.get(CONF_PEERS)) is None:
port = await _new_port(
config[CONF_PORT_ID], hub, config[CONF_DESTINATION], True
)
return [(port, None)]
stacks = []
# Each peer has its own stack, named after the peer for logs and saved preferences
for peer in peers:
port = await _new_port(peer[CONF_ID], hub, peer[CONF_DESTINATION], False)
cg.add(port.set_name(peer[CONF_ID].id))
stacks.append((port, peer))
return stacks
@@ -0,0 +1,56 @@
"""Fragmentation layer that splits packets larger than the layers below allow, and joins them again."""
import math
import esphome.codegen as cg
from esphome.components.const import CONF_MAX_PACKET_SIZE
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
from . import FRAGMENTATION, PacketInterface, packet_interface_ns
# The packet number, and the fragment's index with the flag on the last fragment
HEADER_SIZE = 2
MAX_FRAGMENTS = 128
BasicFragmentation = packet_interface_ns.class_("BasicFragmentation", PacketInterface)
BASIC_FRAGMENTATION_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(BasicFragmentation),
# The packet being joined is held in a buffer of this size
cv.Optional(CONF_MAX_PACKET_SIZE, default=1024): cv.int_range(16, 16384),
}
).extend(cv.COMPONENT_SCHEMA)
def _check_lower(config: ConfigType, size: int, capacity: int | None) -> None:
"""The largest packet must fit in the fragments that the layer below can take at once."""
fragment_size = size - HEADER_SIZE
if fragment_size <= 0:
raise cv.Invalid("The layers below leave no room for fragments")
fragments = max(1, math.ceil(config[CONF_MAX_PACKET_SIZE] / fragment_size))
limit = MAX_FRAGMENTS if capacity is None else min(MAX_FRAGMENTS, capacity)
if fragments > limit:
raise cv.Invalid(
f"A packet of {config[CONF_MAX_PACKET_SIZE]} bytes needs {fragments} fragments of {fragment_size} bytes, "
f"but at most {limit} can be sent at once",
path=[CONF_MAX_PACKET_SIZE],
)
# Fragments from different devices are told apart by the addressing layer's source address, so of two devices sending
# fragmented packets at once, one still gets through; without addressing, the receiver can drop both
@FRAGMENTATION.register(
"basic",
schema=BASIC_FRAGMENTATION_SCHEMA,
packet_size=lambda config, size: config[CONF_MAX_PACKET_SIZE],
check_lower=_check_lower,
)
async def build_basic_fragmentation(config: ConfigType, lower: MockObj) -> MockObj:
cg.add_define("USE_PACKET_INTERFACE_BASIC_FRAGMENTATION")
var = cg.new_Pvariable(config[CONF_ID], lower, config[CONF_MAX_PACKET_SIZE])
await cg.register_component(var, config)
return var
@@ -0,0 +1,71 @@
"""Session layer that sends keep-alives and reports whether the peer can be reached."""
import esphome.codegen as cg
from esphome.components import binary_sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_BINARY_SENSOR,
CONF_ID,
CONF_TIMEOUT,
DEVICE_CLASS_CONNECTIVITY,
ENTITY_CATEGORY_DIAGNOSTIC,
)
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
from . import SESSION, PacketInterface, packet_interface_ns
CONF_KEEP_ALIVE = "keep_alive"
BasicSession = packet_interface_ns.class_("BasicSession", PacketInterface)
def _session_timeout(config: ConfigType) -> ConfigType:
"""Default the timeout to three keep-alive intervals, and require it to be longer than one."""
keep_alive = config[CONF_KEEP_ALIVE].total_milliseconds
if (timeout := config.get(CONF_TIMEOUT)) is None:
config[CONF_TIMEOUT] = cv.positive_time_period_milliseconds(
f"{keep_alive * 3}ms"
)
elif timeout.total_milliseconds <= keep_alive:
raise cv.Invalid("Must be longer than keep_alive", path=[CONF_TIMEOUT])
return config
BASIC_SESSION_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BasicSession),
cv.Optional(CONF_KEEP_ALIVE, default="10s"): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(min=cv.TimePeriod(seconds=1), max=cv.TimePeriod(hours=1)),
),
cv.Optional(CONF_TIMEOUT): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(max=cv.TimePeriod(hours=3)),
),
cv.Optional(CONF_BINARY_SENSOR): binary_sensor.binary_sensor_schema(
device_class=DEVICE_CLASS_CONNECTIVITY,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
).extend(cv.COMPONENT_SCHEMA),
_session_timeout,
)
@SESSION.register(
"basic",
schema=BASIC_SESSION_SCHEMA,
single_peer=lambda config: True,
auto_load=lambda config: ["binary_sensor"] if CONF_BINARY_SENSOR in config else [],
)
async def build_basic_session(config: ConfigType, lower: MockObj) -> MockObj:
cg.add_define("USE_PACKET_INTERFACE_BASIC_SESSION")
var = cg.new_Pvariable(config[CONF_ID], lower)
cg.add(var.set_keep_alive(config[CONF_KEEP_ALIVE]))
cg.add(var.set_session_timeout(config[CONF_TIMEOUT]))
await cg.register_component(var, config)
binary_sensors = binary_sensor.sub_binary_sensors(config)
await binary_sensors(CONF_BINARY_SENSOR, var.set_connected_sensor)
return var
@@ -0,0 +1,68 @@
"""Reliability layer using HDLC style acknowledgement and go-back-N retransmission."""
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_TIMEOUT
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
from . import RELIABILITY, PacketInterface, packet_interface_ns, remaining_packet_size
CONF_ACK_DELAY = "ack_delay"
CONF_MAX_QUEUE_LENGTH = "max_queue_length"
CONF_MAX_RETRIES = "max_retries"
# The control byte and the CRC-16
FRAME_OVERHEAD = 3
HdlcReliability = packet_interface_ns.class_("HdlcReliability", PacketInterface)
def _ack_delay_within_timeout(config: ConfigType) -> ConfigType:
"""The peer, configured the same, must hear the acknowledgement before its timeout."""
if (
config[CONF_ACK_DELAY].total_milliseconds
>= config[CONF_TIMEOUT].total_milliseconds
):
raise cv.Invalid(
f"Must be shorter than '{CONF_TIMEOUT}'", path=[CONF_ACK_DELAY]
)
return config
HDLC_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(HdlcReliability),
# Retransmitting more often than the main loop runs would exhaust the retries at once
cv.Optional(CONF_TIMEOUT, default="200ms"): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(min=cv.TimePeriod(milliseconds=50)),
),
cv.Optional(
CONF_ACK_DELAY, default="0ms"
): cv.positive_time_period_milliseconds,
cv.Optional(CONF_MAX_QUEUE_LENGTH, default=4): cv.int_range(1, 7),
cv.Optional(CONF_MAX_RETRIES, default=5): cv.int_range(1, 255),
}
).extend(cv.COMPONENT_SCHEMA),
_ack_delay_within_timeout,
)
@RELIABILITY.register(
"hdlc",
schema=HDLC_SCHEMA,
single_peer=lambda config: True,
packet_size=lambda config, size: remaining_packet_size(size, FRAME_OVERHEAD),
send_capacity=lambda config: config[CONF_MAX_QUEUE_LENGTH],
)
async def build_hdlc(config: ConfigType, lower: MockObj) -> MockObj:
cg.add_define("USE_PACKET_INTERFACE_HDLC")
var = cg.new_Pvariable(config[CONF_ID], lower)
cg.add(var.set_ack_timeout(config[CONF_TIMEOUT]))
cg.add(var.set_ack_delay(config[CONF_ACK_DELAY]))
cg.add(var.set_max_queue_length(config[CONF_MAX_QUEUE_LENGTH]))
cg.add(var.set_max_retries(config[CONF_MAX_RETRIES]))
await cg.register_component(var, config)
return var
@@ -0,0 +1,61 @@
"""Encryption layer using XXTEA, with an optional rolling code."""
import hashlib
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_KEY
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
from . import ENCRYPTION, PacketInterface, packet_interface_ns, remaining_packet_size
CONF_ROLLING_CODE_ENABLE = "rolling_code_enable"
# A captured packet lets a guessed key be checked offline, so the key text is stretched to make each guess costly.
# Both ends derive the same key from the same text, so the salt is fixed; it keeps tables built for other uses of
# PBKDF2 from applying here.
KEY_SALT = b"esphome packet_interface xxtea"
KEY_ITERATIONS = 600_000
# The check word, and with the rolling code the boot numbers and counter
CHECK_SIZE = 4
ROLLING_SIZE = 12
XxteaEncryption = packet_interface_ns.class_("XxteaEncryption", PacketInterface)
XXTEA_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(XxteaEncryption),
cv.Required(CONF_KEY): cv.All(cv.sensitive(cv.string), cv.Length(min=1)),
cv.Optional(CONF_ROLLING_CODE_ENABLE, default=False): cv.boolean,
}
).extend(cv.COMPONENT_SCHEMA)
def derive_key(text: str) -> list[int]:
"""The 256-bit key for the key text, as 32-bit words in little endian order."""
digest = hashlib.pbkdf2_hmac("sha256", text.encode(), KEY_SALT, KEY_ITERATIONS)
return [int.from_bytes(digest[i : i + 4], "little") for i in range(0, 32, 4)]
# The rolling code keeps the last counter of one peer, and accepts a packet only once the peer has sent back this
# device's boot number
@ENCRYPTION.register(
"xxtea",
schema=XXTEA_SCHEMA,
single_peer=lambda config: config[CONF_ROLLING_CODE_ENABLE],
needs_session=lambda config: config[CONF_ROLLING_CODE_ENABLE],
auto_load=lambda config: ["xxtea"],
# XXTEA works on whole 32-bit words
packet_size=lambda config, size: remaining_packet_size(
size & ~3,
CHECK_SIZE + (ROLLING_SIZE if config[CONF_ROLLING_CODE_ENABLE] else 0),
),
)
async def build_xxtea(config: ConfigType, lower: MockObj) -> MockObj:
cg.add_define("USE_PACKET_INTERFACE_XXTEA")
var = cg.new_Pvariable(config[CONF_ID], lower, config[CONF_ROLLING_CODE_ENABLE])
cg.add(var.set_key(derive_key(config[CONF_KEY])))
await cg.register_component(var, config)
return var
@@ -0,0 +1,45 @@
#pragma once
#include <cstdint>
namespace esphome::packet_interface {
/**
* Decides whether a half-duplex radio link may send while its radio reports that it is receiving a packet.
*
* Sending is refused while the radio is busy, so the packet being received is not lost. A busy period starts when a
* send first finds the radio busy, and ends when a send finds it free or end_busy() is called. Once a busy period has
* lasted max_busy_ms, sends go ahead, so a busy flag that never clears cannot stop sending for good, however often
* sends are tried. Code generation sets max_busy_ms from the time the radio takes to receive the largest packet.
*/
class ListenBeforeTalk {
public:
static constexpr uint32_t DEFAULT_MAX_BUSY_MS = 2000;
void set_max_busy(uint32_t max_busy_ms) { this->max_busy_ms_ = max_busy_ms; }
uint32_t get_max_busy() const { return this->max_busy_ms_; }
/// True if a packet may be sent at \p now; \p busy says whether the radio is receiving.
bool may_send(bool busy, uint32_t now) {
if (!busy) {
this->busy_ = false;
return true;
}
if (!this->busy_) {
this->busy_ = true;
this->busy_since_ms_ = now;
}
return now - this->busy_since_ms_ >= this->max_busy_ms_;
}
/// Ends the busy period, when the radio is seen to be free other than by a send.
void end_busy() { this->busy_ = false; }
/// How long the current busy period has lasted at \p now.
uint32_t busy_for(uint32_t now) const { return now - this->busy_since_ms_; }
protected:
uint32_t busy_since_ms_{0};
uint32_t max_busy_ms_{DEFAULT_MAX_BUSY_MS};
bool busy_{false};
};
} // namespace esphome::packet_interface
@@ -7,7 +7,12 @@ from esphome.components.packet_transport import (
import esphome.config_validation as cv
from esphome.types import ConfigType
from .. import CONF_PACKET_INTERFACE, PacketInterface, packet_interface_ns
from .. import (
CONF_PACKET_INTERFACE,
PacketInterface,
final_validate_interface,
packet_interface_ns,
)
CODEOWNERS = ["@clydebarrow"]
DEPENDENCIES = ["packet_interface"]
@@ -22,6 +27,8 @@ CONFIG_SCHEMA = transport_schema(PacketInterfaceTransport).extend(
}
)
FINAL_VALIDATE_SCHEMA = final_validate_interface(CONF_PACKET_INTERFACE)
async def to_code(config: ConfigType) -> None:
interface = await cg.get_variable(config[CONF_PACKET_INTERFACE])
@@ -0,0 +1,54 @@
#include "radio_link.h"
#include <cinttypes>
#include "esphome/core/application.h"
#include "esphome/core/log.h"
namespace esphome::packet_interface {
ESPHOME_LOG_TAG(TAG, "packet_interface.radio");
bool RadioLinkBase::check_size_(size_t size) const {
if (size <= this->max_packet_size_)
return true;
ESP_LOGW(TAG, "Packet of %zu bytes is larger than %zu", size, this->max_packet_size_);
return false;
}
bool RadioLinkBase::may_send_(bool busy) {
uint32_t now = App.get_loop_component_start_time();
if (busy)
this->enable_loop();
if (!this->listen_before_talk_.may_send(busy, now))
return false;
if (busy) {
// The limit is longer than any packet takes, so the radio's busy indication is stuck or the channel is jammed
ESP_LOGW(TAG, "Radio busy for %" PRIu32 "ms, sending anyway", this->listen_before_talk_.busy_for(now));
}
return true;
}
bool RadioLinkBase::skip_empty_() const {
ESP_LOGV(TAG, "Empty packet not sent");
return true;
}
void RadioLinkBase::radio_not_ready_(bool failed) {
if (!failed || this->failed_warned_)
return;
this->failed_warned_ = true;
ESP_LOGW(TAG, "%s: The radio failed to set up, so nothing can be sent", this->name_);
}
void RadioLinkBase::radio_free_() {
this->listen_before_talk_.end_busy();
this->disable_loop();
}
void RadioLinkBase::receive_(const std::vector<uint8_t> &packet, float rssi, float snr) {
PacketMetaData meta_data;
meta_data.rssi = rssi;
meta_data.snr = snr;
this->on_receive_from_interface_(PacketBuffer(packet.data(), packet.size()), meta_data);
}
} // namespace esphome::packet_interface
@@ -0,0 +1,87 @@
#pragma once
#include <vector>
#include "esphome/components/packet_interface/listen_before_talk.h"
#include "esphome/components/packet_interface/packet_interface.h"
namespace esphome::packet_interface {
/// The parts of RadioLink that do not depend on the radio.
class RadioLinkBase : public PacketInterface {
public:
void set_max_busy(uint32_t max_busy_ms) { this->listen_before_talk_.set_max_busy(max_busy_ms); }
protected:
explicit RadioLinkBase(size_t max_packet_size) : PacketInterface(max_packet_size) {}
/// False if a packet of this size is too large.
bool check_size_(size_t size) const;
/// False if sending must wait, as the radio is busy; starts loop() to watch the radio.
bool may_send_(bool busy);
/// Returns true for an empty packet, which the radios cannot send, so there is nothing to retry.
bool skip_empty_() const;
/// Warns once when a send finds the radio failed; until its setup has run, sends are refused quietly.
void radio_not_ready_(bool failed);
/// Ends the busy period when loop() finds the radio free.
void radio_free_();
void receive_(const std::vector<uint8_t> &packet, float rssi, float snr);
ListenBeforeTalk listen_before_talk_;
bool failed_warned_{false};
};
/**
* A packet interface link over a packet radio: each packet is one radio packet, with the RSSI and SNR of received
* packets in the metadata. Sending waits for the radio to finish transmitting. A radio cannot hear while it sends, so
* sending is refused while the radio is receiving a packet. A reliability layer sends the packet again shortly, and
* entity_transport after a delay; other callers drop it, logging at debug level at most. While a busy period lasts,
* loop() watches the radio and ends the period as soon as the radio is free, so a later reception starts a new one.
*
* Radio provides is_receiving(), register_listener(Listener *) and transmit_packet(const uint8_t *, size_t), which
* returns Error::NONE on success. Listener declares on_packet(packet, rssi, snr). MaxPacketSize is the radio's limit.
*/
template<typename Radio, typename Listener, typename Error, size_t MaxPacketSize>
class RadioLink : public RadioLinkBase, public Listener {
public:
RadioLink(Radio *radio, size_t max_packet_size) : RadioLinkBase(max_packet_size), radio_(radio) {}
void setup() override {
this->radio_->register_listener(this);
this->disable_loop();
}
// Runs only during a busy period; a busy flag that never clears keeps it running. A radio that failed meanwhile
// is not read again, as reading it may wait on the radio every time
void loop() override {
if (!this->radio_->is_ready() || !this->radio_->is_receiving())
this->radio_free_();
}
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) override {
// The radio is set up after the layers above, which may send from their own setup
if (!this->radio_->is_ready()) {
this->radio_not_ready_(this->radio_->is_failed());
return false;
}
size_t size = data.size();
if (size == 0)
return this->skip_empty_();
if (!this->check_size_(size) || !this->may_send_(this->radio_->is_receiving()))
return false;
if (data.is_contiguous()) {
auto span = data.as_span();
return this->radio_->transmit_packet(span.data(), span.size()) == Error::NONE;
}
uint8_t buf[MaxPacketSize];
data.copy_to(buf, size);
return this->radio_->transmit_packet(buf, size) == Error::NONE;
}
void on_packet(const std::vector<uint8_t> &packet, float rssi, float snr) override {
this->receive_(packet, rssi, snr);
}
protected:
Radio *radio_;
};
} // namespace esphome::packet_interface
@@ -0,0 +1,138 @@
"""Shared code generation for the packet interface links over packet radios, such as the SX126x and SX127x."""
from collections.abc import Callable
import math
import esphome.codegen as cg
from esphome.components.const import CONF_MAX_PACKET_SIZE
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.core import CORE, EsphomeError
from esphome.cpp_generator import MockObjClass
import esphome.final_validate as fv
from esphome.types import ConfigType
from . import new_packet_interface, packet_interface_schema
from .layers import CONF_ADDRESSING, CONF_PEERS, STACK_ORDER
# The radios' payload length register is 8 bits
MAX_PACKET_SIZE = 255
# Listen before talk holds a send back for at most the airtime of the largest packet, and never less than this
MIN_MAX_BUSY_MS = 2000
# Room for the preamble detection and the radio's own delays
MARGIN = 1.25
def packet_airtime_ms(radio: ConfigType, size: int) -> float:
"""The time a radio configured as in radio takes to send a packet of size bytes.
LoRa follows the Semtech formula for an explicit header; the low data rate optimization is assumed when a symbol
takes more than 16 ms, as the drivers enable it. Other modulations count the preamble, sync word, length byte,
payload and CRC at the bitrate, treating the preamble size as bytes, which gives the longer time.
"""
if str(radio["modulation"]) == "LORA":
spreading_factor = radio["spreading_factor"]
bandwidth_hz = (
float(str(radio["bandwidth"]).removesuffix("kHz").replace("_", ".")) * 1000
)
coding_rate = int(str(radio["coding_rate"])[-1]) - 4
symbol_s = (1 << spreading_factor) / bandwidth_hz
low_data_rate = 1 if symbol_s > 0.016 else 0
crc = 16 if radio.get("crc_enable", False) else 0
payload_symbols = 8 + max(
math.ceil(
(8 * size - 4 * spreading_factor + 28 + crc)
/ (4 * (spreading_factor - 2 * low_data_rate))
)
* (coding_rate + 4),
0,
)
return (radio["preamble_size"] + 4.25 + payload_symbols) * symbol_s * 1000
bytes_sent = (
radio["preamble_size"] + len(radio.get("sync_value", [])) + 1 + size + 2
)
return bytes_sent * 8 / radio["bitrate"] * 1000
def max_busy_ms(radio: ConfigType, max_packet_size: int) -> int:
"""How long listen before talk may hold a send back."""
return max(
MIN_MAX_BUSY_MS, math.ceil(packet_airtime_ms(radio, max_packet_size) * MARGIN)
)
def radio_link_schema(
class_: MockObjClass, radio_id_key: str, radio_class: MockObjClass
) -> cv.Schema:
"""Schema for a radio link, referring to its radio by radio_id_key."""
return packet_interface_schema(class_, MAX_PACKET_SIZE, MAX_PACKET_SIZE).extend(
{cv.GenerateID(radio_id_key): cv.use_id(radio_class)}
)
def _radio_config(config: ConfigType, radio_id_key: str) -> ConfigType:
full_config = fv.full_config.get()
return full_config.get_config_for_path(
full_config.get_path_for_id(config[radio_id_key])[:-1]
)
def _two_way_layer(config: ConfigType) -> str | None:
"""The first layer on the link or in a peer that keeps state for one peer, so needs packets back from it."""
for stack in (config, *config.get(CONF_ADDRESSING, {}).get(CONF_PEERS, [])):
for kind in STACK_ORDER:
if (layer := stack.get(kind.key)) is not None and kind.get_type(
layer
).single_peer(layer):
return kind.key
return None
def radio_link_final_validate(
radio_id_key: str,
payload_length_key: str,
rx_start_key: str,
check: Callable[[ConfigType, ConfigType], None] | None = None,
) -> Callable[[ConfigType], ConfigType]:
"""Final validation for a radio link: packets vary in length, the radio listens after sending when a layer needs
replies, and check(link, radio) checks the rest.
"""
def validator(config: ConfigType) -> ConfigType:
radio = _radio_config(config, radio_id_key)
if radio[payload_length_key] != 0:
raise cv.Invalid(
f"Packets vary in length, so the radio's '{payload_length_key}' must be 0",
path=[radio_id_key],
)
if not radio[rx_start_key] and (layer := _two_way_layer(config)) is not None:
raise cv.Invalid(
f"This '{layer}' needs packets from the peer, but the radio sleeps after sending unless "
f"'{rx_start_key}' is true",
path=[radio_id_key],
)
if check is not None:
check(config, radio)
return config
return validator
async def new_radio_link(
config: ConfigType, radio_id_key: str, radio_domain: str
) -> None:
"""Create a radio link, with its listen before talk limit worked out from the radio's settings."""
radio = await cg.get_variable(config[radio_id_key])
var = await new_packet_interface(config, radio, config[CONF_MAX_PACKET_SIZE])
radio_config = next(
(
conf
for conf in CORE.config[radio_domain]
if conf[CONF_ID] == config[radio_id_key]
),
None,
)
if radio_config is None:
raise EsphomeError(f"No {radio_domain} radio with id '{config[radio_id_key]}'")
cg.add(var.set_max_busy(max_busy_ms(radio_config, config[CONF_MAX_PACKET_SIZE])))
@@ -0,0 +1,182 @@
#include "xxtea_encryption.h"
#ifdef USE_PACKET_INTERFACE_XXTEA
#include <algorithm>
#include <cstring>
#include "esphome/components/xxtea/xxtea.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::packet_interface {
ESPHOME_LOG_TAG(TAG, "packet_interface.xxtea");
static constexpr uint32_t PREF_HASH = fnv1a_hash("packet_interface.xxtea");
// Different for each mode, so devices set up differently drop each other's packets
static constexpr uint8_t CHECK_BYTES[3] = {0x58, 0x54, 0x45};
static constexpr uint8_t ROLLING_CHECK_BYTES[3] = {0x58, 0x54, 0x52};
static constexpr size_t CHECK_SIZE = 4;
// Boot number, counter, and the echo of the receiver's boot number
static constexpr size_t ROLLING_SIZE = 12;
// XXTEA needs at least two words
static constexpr size_t MIN_LENGTH = 8;
static constexpr size_t MAX_PADDING = 4;
static void put_uint32(uint8_t *buf, uint32_t value) {
buf[0] = value;
buf[1] = value >> 8;
buf[2] = value >> 16;
buf[3] = value >> 24;
}
static uint32_t get_uint32(const uint8_t *buf) { return encode_uint32(buf[3], buf[2], buf[1], buf[0]); }
static constexpr size_t trailer_size(bool rolling_code) {
return rolling_code ? ROLLING_SIZE + CHECK_SIZE : CHECK_SIZE;
}
XxteaEncryption::XxteaEncryption(PacketInterface *lower, bool rolling_code)
// XXTEA works on whole 32-bit words
: PacketInterface(remaining_packet_size(lower->get_max_packet_size() & ~size_t{3}, trailer_size(rolling_code))),
lower_(lower),
trailer_size_(trailer_size(rolling_code)),
rolling_code_(rolling_code) {
this->stack_on_(lower, CAPABILITY_ENCRYPTION);
}
void XxteaEncryption::set_key(std::initializer_list<uint32_t> key) {
std::copy_n(key.begin(), std::min(key.size(), this->key_.size()), this->key_.begin());
}
void XxteaEncryption::setup() {
if (this->max_packet_size_ == 0) {
this->mark_failed(LOG_STR("max_packet_size too small for the configured layers"));
return;
}
size_t words = this->lower_->get_max_packet_size() / 4;
RAMAllocator<uint32_t> allocator;
this->tx_buffer_ = allocator.allocate(words * 2);
if (this->tx_buffer_ == nullptr) {
this->mark_failed(LOG_STR("Not enough memory for the packet buffers"));
return;
}
this->rx_buffer_ = this->tx_buffer_ + words;
if (this->rolling_code_) {
this->pref_ = global_preferences->make_preference<uint32_t>(fnv1a_hash_extend(PREF_HASH, this->name_), true);
this->pref_.load(&this->boot_);
this->next_boot_();
}
this->lower_->add_packet_interface_listener(
[this](const PacketBuffer &data, const PacketMetaData &meta_data) { this->receive_(data, meta_data); });
}
void XxteaEncryption::dump_config() {
ESP_LOGCONFIG(TAG,
"XXTEA Encryption on '%s':\n"
" Max Packet Size: %zu\n"
" Rolling Code: %s",
this->name_, this->max_packet_size_, YESNO(this->rolling_code_));
if (this->rolling_code_) {
ESP_LOGCONFIG(TAG, " Boot Number: %" PRIu32, this->boot_);
}
}
// A new boot number must be saved before it is used, or a crash could make the peer see it again
void XxteaEncryption::next_boot_() {
this->boot_++;
this->counter_ = 0;
if (!this->pref_.save(&this->boot_) || !global_preferences->sync()) {
ESP_LOGW(TAG, "%s: Could not save the boot number, so a restart could reuse it", this->name_);
}
}
bool XxteaEncryption::send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) {
if (this->tx_buffer_ == nullptr)
return false;
size_t size = data.size();
if (size > this->max_packet_size_) {
ESP_LOGW(TAG, "%s: Packet of %zu bytes is larger than %zu", this->name_, size, this->max_packet_size_);
return false;
}
size_t padding = (4 - (size + this->trailer_size_) % 4) % 4;
if (size + padding + this->trailer_size_ < MIN_LENGTH)
padding += 4;
size_t length = size + padding + this->trailer_size_;
auto *buf = reinterpret_cast<uint8_t *>(this->tx_buffer_);
data.copy_to(buf, size);
memset(buf + size, 0, padding);
uint8_t *check = buf + length - CHECK_SIZE;
if (this->rolling_code_) {
put_uint32(check - ROLLING_SIZE, this->boot_);
put_uint32(check - ROLLING_SIZE + 4, this->counter_);
put_uint32(check - ROLLING_SIZE + 8, this->echo_boot_);
memcpy(check, ROLLING_CHECK_BYTES, sizeof(ROLLING_CHECK_BYTES));
if (++this->counter_ == 0)
this->next_boot_();
} else {
memcpy(check, CHECK_BYTES, sizeof(CHECK_BYTES));
}
check[CHECK_SIZE - 1] = padding;
xxtea::encrypt(this->tx_buffer_, length / 4, this->key_.data());
return this->lower_->send_to_interface(PacketBuffer(buf, length), meta_data);
}
bool XxteaEncryption::accept_counter_(uint32_t boot, uint32_t counter) {
if (this->peer_seen_ && (boot < this->peer_boot_ || (boot == this->peer_boot_ && counter <= this->peer_counter_)))
return false;
this->peer_seen_ = true;
this->peer_boot_ = boot;
this->peer_counter_ = counter;
return true;
}
void XxteaEncryption::receive_(const PacketBuffer &data, const PacketMetaData &meta_data) {
size_t length = data.size();
if (length % 4 != 0 || length < std::max(MIN_LENGTH, this->trailer_size_) ||
length > this->lower_->get_max_packet_size()) {
ESP_LOGV(TAG, "%s: Packet of %zu bytes dropped", this->name_, length);
this->note_received_(false);
return;
}
auto *buf = reinterpret_cast<uint8_t *>(this->rx_buffer_);
data.copy_to(buf, length);
xxtea::decrypt(this->rx_buffer_, length / 4, this->key_.data());
const uint8_t *check = buf + length - CHECK_SIZE;
const uint8_t *expected = this->rolling_code_ ? ROLLING_CHECK_BYTES : CHECK_BYTES;
size_t padding = check[CHECK_SIZE - 1];
if (memcmp(check, expected, sizeof(CHECK_BYTES)) != 0 || padding > MAX_PADDING ||
padding > length - this->trailer_size_) {
ESP_LOGV(TAG, "%s: Packet failed authentication", this->name_);
this->note_received_(false);
return;
}
this->note_received_(true);
if (this->rolling_code_) {
uint32_t boot = get_uint32(check - ROLLING_SIZE);
uint32_t counter = get_uint32(check - ROLLING_SIZE + 4);
uint32_t echo = get_uint32(check - ROLLING_SIZE + 8);
// Learn the peer's boot number even from a packet that is then dropped, so both sides can catch up
if (boot > this->echo_boot_)
this->echo_boot_ = boot;
// A packet recorded before this boot cannot carry this boot's number
if (echo != this->boot_) {
ESP_LOGV(TAG, "%s: Packet for boot %" PRIu32 " dropped", this->name_, echo);
return;
}
if (!this->accept_counter_(boot, counter)) {
// A boot number lower than one already heard means the peer lost its saved boot number, or a replay
if (boot < this->peer_boot_ && !this->old_boot_warned_) {
this->old_boot_warned_ = true;
ESP_LOGW(TAG,
"%s: Packets from an older boot %" PRIu32 " of the peer are dropped; if its flash was erased, "
"restart this device",
this->name_, boot);
}
ESP_LOGV(TAG, "%s: Old packet %" PRIu32 ":%" PRIu32 " dropped", this->name_, boot, counter);
return;
}
}
this->on_receive_from_interface_(PacketBuffer(buf, length - this->trailer_size_ - padding), meta_data);
}
} // namespace esphome::packet_interface
#endif
@@ -0,0 +1,61 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_PACKET_INTERFACE_XXTEA
#include <array>
#include <initializer_list>
#include "esphome/core/preferences.h"
#include "packet_interface.h"
namespace esphome::packet_interface {
/**
* An encryption layer that encrypts and authenticates each packet with XXTEA and a 256-bit shared key.
*
* The packet is padded with zeros and followed by a trailer, then the whole is encrypted as one block. With the
* rolling code enabled, the trailer starts with:
* - a 32-bit boot number, saved in flash and increased at every boot;
* - a 32-bit packet counter, starting at 0 at every boot;
* - the highest boot number heard from the peer, echoed back to it.
* The trailer always ends with 3 fixed check bytes and the number of padding bytes. XXTEA mixes the whole block, so
* a packet that was changed or encrypted with another key fails the check.
* With the rolling code, a packet is dropped as a replay unless it echoes this device's current boot number, and its
* boot number and counter are higher than those of the last packet accepted from the peer. A boot number is never
* reused, so the echo acts as a fresh challenge after every restart.
*/
class XxteaEncryption : public PacketInterface {
public:
XxteaEncryption(PacketInterface *lower, bool rolling_code);
void setup() override;
void dump_config() override;
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) override;
void set_key(std::initializer_list<uint32_t> key);
protected:
void receive_(const PacketBuffer &data, const PacketMetaData &meta_data);
bool accept_counter_(uint32_t boot, uint32_t counter);
void next_boot_();
PacketInterface *lower_;
const size_t trailer_size_;
std::array<uint32_t, 8> key_{};
// Separate buffers, as a layer above may send a reply while a received packet is being passed up; both are in one
// block allocated in setup(), and tx_buffer_ stays null if that fails
uint32_t *tx_buffer_{nullptr};
uint32_t *rx_buffer_{nullptr};
ESPPreferenceObject pref_;
uint32_t boot_{0};
uint32_t counter_{0};
uint32_t peer_boot_{0};
uint32_t peer_counter_{0};
uint32_t echo_boot_{0}; // highest boot number heard from the peer
bool peer_seen_{false};
bool old_boot_warned_{false};
const bool rolling_code_;
};
} // namespace esphome::packet_interface
#endif
+5 -2
View File
@@ -1,5 +1,4 @@
#include "pca6416a.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::pca6416a {
@@ -171,7 +170,11 @@ void PCA6416AComponent::update_register_(uint8_t pin, bool pin_value, uint8_t re
this->read_register_(reg_addr, &reg_value);
}
assign_bits(reg_value, 1 << bit, pin_value);
if (pin_value) {
reg_value |= 1 << bit;
} else {
reg_value &= ~(1 << bit);
}
this->write_register_(reg_addr, reg_value);
+5 -2
View File
@@ -1,5 +1,4 @@
#include "pca9505.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::pca9505 {
@@ -75,7 +74,11 @@ bool PCA9505Component::digital_read_cache(uint8_t pin) { return this->input_[pin
void PCA9505Component::digital_write_hw(uint8_t pin, bool value) {
const uint8_t bank = pin / 8;
const uint8_t mask = 1 << (pin % 8);
assign_bits(this->output_[bank], mask, value);
if (value) {
this->output_[bank] |= mask;
} else {
this->output_[bank] &= ~mask;
}
this->write_bank_(OUTPUT_REG, bank, this->output_[bank]);
}
+5 -2
View File
@@ -1,5 +1,4 @@
#include "pca9554.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::pca9554 {
@@ -100,7 +99,11 @@ bool PCA9554Component::digital_read_cache(uint8_t pin) {
}
void PCA9554Component::digital_write_hw(uint8_t pin, bool value) {
assign_bits(this->output_mask_, 1 << pin, value);
if (value) {
this->output_mask_ |= (1 << pin);
} else {
this->output_mask_ &= ~(1 << pin);
}
#ifdef USE_PCA9554_PCAL
if (this->write_register_(OUTPUT_REG, this->output_mask_))
this->retry_pcal_shadows_(); // an output-only hub never reads, so it retries here
+5 -1
View File
@@ -90,7 +90,11 @@ void HOT PCD8544::draw_absolute_pixel_internal(int x, int y, Color color) {
uint16_t pos = x + (y / 8) * this->get_width_internal();
uint8_t subpos = y % 8;
assign_bits(this->buffer_[pos], 1 << subpos, color.is_on());
if (color.is_on()) {
this->buffer_[pos] |= (1 << subpos);
} else {
this->buffer_[pos] &= ~(1 << subpos);
}
}
void PCD8544::dump_config() {
+5 -1
View File
@@ -60,7 +60,11 @@ bool PCF8574Component::digital_read_hw(uint8_t pin) {
bool PCF8574Component::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); }
void PCF8574Component::digital_write_hw(uint8_t pin, bool value) {
assign_bits(this->output_mask_, 1 << pin, value);
if (value) {
this->output_mask_ |= (1 << pin);
} else {
this->output_mask_ &= ~(1 << pin);
}
this->write_gpio_();
}
void PCF8574Component::pin_mode(uint8_t pin, gpio::Flags flags) {
@@ -1,5 +1,4 @@
#include "pi4ioe5v6408.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::pi4ioe5v6408 {
@@ -130,7 +129,11 @@ void PI4IOE5V6408Component::digital_write_hw(uint8_t pin, bool value) {
if (this->is_failed())
return;
assign_bits(this->output_mask_, 1 << pin, value);
if (value) {
this->output_mask_ |= (1 << pin);
} else {
this->output_mask_ &= ~(1 << pin);
}
if (!this->status_comm_result(this->write_byte(PI4IOE5V6408_REGISTER_OUT_SET, this->output_mask_),
LOG_STR("Failed to write output register")))
return;
@@ -1,6 +1,5 @@
#include "runtime_image.h"
#include "image_decoder.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cstdint>
@@ -92,7 +91,11 @@ void RuntimeImage::draw_pixel(int x, int y, const Color &color) {
auto on = is_color_on(color);
if (this->has_transparency() && color.w < 0x80)
on = false;
assign_bits(this->buffer_[pos], bitno, on);
if (on) {
this->buffer_[pos] |= bitno;
} else {
this->buffer_[pos] &= ~bitno;
}
break;
}
case image::IMAGE_TYPE_RGB111: {
@@ -342,7 +342,11 @@ void HOT SSD1306::draw_absolute_pixel_internal(int x, int y, Color color) {
uint16_t pos = x + (y / 8) * this->get_width_internal();
uint8_t subpos = y & 0x07;
assign_bits(this->buffer_[pos], 1 << subpos, color.is_on());
if (color.is_on()) {
this->buffer_[pos] |= (1 << subpos);
} else {
this->buffer_[pos] &= ~(1 << subpos);
}
}
void SSD1306::fill(Color color) {
// If clipping is active, fall back to base implementation
+26 -15
View File
@@ -53,13 +53,6 @@ MODELS = DriverChip.get_models()
DEFAULT_MODEL = "WAVESHARE-400X300"
def _model_key(model: DriverChip, key: str) -> cv.Optional | cv.Required:
"""Default the key from the model, or require it when the model has none."""
if model.get_default(key, None) is None:
return cv.Required(key)
return model.option(key)
def _model_schema(model: DriverChip) -> cv.Schema:
iseqconf = (
cv.Required(CONF_INIT_SEQUENCE)
@@ -79,12 +72,12 @@ def _model_schema(model: DriverChip) -> cv.Schema:
{
cv.GenerateID(): cv.declare_id(ST7305),
cv.Required(CONF_MODEL): cv.one_of(*MODELS, upper=True),
_model_key(model, CONF_CS_PIN): pins.gpio_output_pin_schema,
_model_key(model, CONF_DC_PIN): pins.gpio_output_pin_schema,
model.option(CONF_CS_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema,
model.option(CONF_DC_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema,
model.option(CONF_RESET_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema,
_model_key(model, CONF_WIDTH): cv.int_range(min=8, max=1024),
_model_key(model, CONF_HEIGHT): cv.int_range(min=8, max=1024),
_model_key(model, CONF_PORTRAIT): cv.boolean,
cv.Optional(CONF_WIDTH): cv.int_range(min=8, max=1024),
cv.Optional(CONF_HEIGHT): cv.int_range(min=8, max=1024),
cv.Optional(CONF_PORTRAIT): cv.boolean,
cv.Optional(CONF_COLUMN_START): cv.int_range(min=0, max=255),
cv.Optional(CONF_COLUMN_END): cv.int_range(min=0, max=255),
cv.Optional(CONF_ROW_START): cv.int_range(min=0, max=255),
@@ -96,9 +89,23 @@ def _model_schema(model: DriverChip) -> cv.Schema:
def _resolve(config: ConfigType, model: DriverChip) -> None:
"""Derive and validate the address window, and default the init sequence."""
width = config[CONF_WIDTH]
height = config[CONF_HEIGHT]
"""Fill in model defaults and validate the geometry and address window."""
if (width := config.get(CONF_WIDTH)) is None:
if (width := model.get_default(CONF_WIDTH, None)) is None:
raise cv.Invalid("'width' is required for this model", path=[CONF_WIDTH])
config[CONF_WIDTH] = width
if (height := config.get(CONF_HEIGHT)) is None:
if (height := model.get_default(CONF_HEIGHT, None)) is None:
raise cv.Invalid("'height' is required for this model", path=[CONF_HEIGHT])
config[CONF_HEIGHT] = height
if config.get(CONF_PORTRAIT) is None:
if (default := model.get_default(CONF_PORTRAIT, None)) is None:
raise cv.Invalid(
"'portrait' is required for this model", path=[CONF_PORTRAIT]
)
config[CONF_PORTRAIT] = default
if config[CONF_PORTRAIT]:
if width % 12 or height % 2:
raise cv.Invalid(
@@ -145,6 +152,10 @@ def _resolve(config: ConfigType, model: DriverChip) -> None:
if config.get(CONF_INIT_SEQUENCE) is None:
config[CONF_INIT_SEQUENCE] = model.initsequence
for key, label in ((CONF_CS_PIN, "cs_pin"), (CONF_DC_PIN, "dc_pin")):
if key not in config:
raise cv.Invalid(f"'{label}' is required for this model", path=[key])
def model_schema(config: ConfigType) -> cv.Schema:
"""Return the schema for the model selected in the config."""
+7 -2
View File
@@ -1,8 +1,8 @@
#include "st7305.h"
#include <algorithm>
#include <cstring>
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::st7305 {
@@ -27,6 +27,7 @@ bool ST7305::init_buffer_() {
this->mark_failed(LOG_STR("Buffer allocation failed"));
return false;
}
memset(this->buffer_, 0x00, this->buffer_length_);
this->clear_dirty_();
return true;
}
@@ -190,7 +191,11 @@ void HOT ST7305::draw_absolute_pixel_internal(int x, int y, Color color) {
// The init sequence enables INVON, so a set bit is white and a clear bit is black.
uint8_t &value = this->buffer_[index];
const uint8_t previous = value;
assign_bits(value, mask, color.is_on());
if (color.is_on()) {
value |= mask;
} else {
value &= ~mask;
}
if (value == previous)
return;
this->extend_dirty_(x, y, x, y);
@@ -123,7 +123,11 @@ void HOT ST7567::draw_absolute_pixel_internal(int x, int y, Color color) {
uint16_t pos = x + (y / 8) * this->get_width_internal();
uint8_t subpos = y & 0x07;
assign_bits(this->buffer_[pos], 1 << subpos, color.is_on());
if (color.is_on()) {
this->buffer_[pos] |= (1 << subpos);
} else {
this->buffer_[pos] &= ~(1 << subpos);
}
}
void ST7567::fill(Color color) {
@@ -0,0 +1,50 @@
from esphome.components import packet_interface
from esphome.components.packet_interface.radio_link import (
new_radio_link,
radio_link_final_validate,
radio_link_schema,
)
import esphome.config_validation as cv
from esphome.types import ConfigType
from .. import (
CONF_MODULATION,
CONF_PAYLOAD_LENGTH,
CONF_RX_START,
CONF_SX126X_ID,
CONF_SYNC_VALUE,
DOMAIN,
SX126x,
SX126xListener,
sx126x_ns,
)
CODEOWNERS = ["@clydebarrow"]
DEPENDENCIES = ["sx126x"]
SX126xPacketInterface = sx126x_ns.class_(
"SX126xPacketInterface", packet_interface.PacketInterface, SX126xListener
)
CONFIG_SCHEMA = radio_link_schema(SX126xPacketInterface, CONF_SX126X_ID, SX126x)
def _check_fsk(config: ConfigType, radio: ConfigType) -> None:
"""Without LoRa, the radio can tell that a packet is arriving only from its sync word, which listen before talk
needs.
"""
if radio[CONF_MODULATION] != "LORA" and not radio[CONF_SYNC_VALUE]:
raise cv.Invalid(
f"Without LoRa modulation the radio needs a '{CONF_SYNC_VALUE}', so that the link can tell when a packet "
"is arriving and not send over it",
path=[CONF_SX126X_ID],
)
FINAL_VALIDATE_SCHEMA = radio_link_final_validate(
CONF_SX126X_ID, CONF_PAYLOAD_LENGTH, CONF_RX_START, _check_fsk
)
async def to_code(config: ConfigType) -> None:
await new_radio_link(config, CONF_SX126X_ID, DOMAIN)
@@ -0,0 +1,10 @@
#pragma once
#include "esphome/components/packet_interface/radio_link.h"
#include "esphome/components/sx126x/sx126x.h"
namespace esphome::sx126x {
using SX126xPacketInterface = packet_interface::RadioLink<SX126x, SX126xListener, SX126xError, SX126X_MAX_PACKET_SIZE>;
} // namespace esphome::sx126x
+14 -3
View File
@@ -1,6 +1,7 @@
#include "sx126x.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::sx126x {
@@ -402,6 +403,15 @@ void SX126x::loop() {
}
}
bool SX126x::is_receiving() {
// DIO1 signals a received packet, which sending would overwrite in the radio's buffer
if (this->dio1_pin_->digital_read())
return true;
uint8_t buf[2];
this->read_opcode_(RADIO_GET_IRQSTATUS, buf, 2);
return (encode_uint16(buf[0], buf[1]) & (IRQ_HEADER_VALID | IRQ_SYNCWORD_VALID)) != 0;
}
void SX126x::run_image_cal() {
// the following values were taken from section 9.2.1 table 9-2
// in rev 2.1 of the datasheet
@@ -430,10 +440,11 @@ void SX126x::run_image_cal() {
void SX126x::set_mode_rx() {
uint8_t buf[8];
// configure irq params
// configure irq params; a valid header or sync word is flagged for is_receiving(), but not signalled on DIO1
uint16_t irq = IRQ_RX_DONE | IRQ_RX_TX_TIMEOUT | IRQ_CRC_ERROR;
buf[0] = (irq >> 8) & 0xFF;
buf[1] = (irq >> 0) & 0xFF;
uint16_t flags = irq | IRQ_HEADER_VALID | IRQ_SYNCWORD_VALID;
buf[0] = (flags >> 8) & 0xFF;
buf[1] = (flags >> 0) & 0xFF;
buf[2] = (irq >> 8) & 0xFF;
buf[3] = (irq >> 0) & 0xFF;
buf[4] = (IRQ_RADIO_NONE >> 8) & 0xFF;
+2
View File
@@ -104,6 +104,8 @@ class SX126x final : public Component,
return this->transmit_packet(packet.data(), packet.size());
}
void register_listener(SX126xListener *listener) { this->listeners_.push_back(listener); }
/// True while a packet is being received, or has been received and not yet read; sending would lose it.
bool is_receiving();
Trigger<std::vector<uint8_t>, float, float> *get_packet_trigger() { return &this->packet_trigger_; }
protected:
@@ -0,0 +1,68 @@
from esphome.components import packet_interface
from esphome.components.packet_interface import CONF_MAX_PACKET_SIZE
from esphome.components.packet_interface.radio_link import (
new_radio_link,
radio_link_final_validate,
radio_link_schema,
)
import esphome.config_validation as cv
from esphome.types import ConfigType
from .. import (
CONF_MODULATION,
CONF_PACKET_MODE,
CONF_PAYLOAD_LENGTH,
CONF_RX_START,
CONF_SX127X_ID,
CONF_SYNC_VALUE,
DOMAIN,
SX127x,
SX127xListener,
sx127x_ns,
)
CODEOWNERS = ["@clydebarrow"]
DEPENDENCIES = ["sx127x"]
# The FIFO limit outside LoRa mode
FSK_MAX_PACKET_SIZE = 64
SX127xPacketInterface = sx127x_ns.class_(
"SX127xPacketInterface", packet_interface.PacketInterface, SX127xListener
)
CONFIG_SCHEMA = radio_link_schema(SX127xPacketInterface, CONF_SX127X_ID, SX127x)
def _check_fsk(config: ConfigType, radio: ConfigType) -> None:
"""Without LoRa, the radio must send packets, not a continuous bit stream, and holds fewer bytes.
It can tell that a packet is arriving only from its sync word, which listen before talk needs.
"""
if radio[CONF_MODULATION] == "LORA":
return
if not radio.get(CONF_PACKET_MODE, False):
raise cv.Invalid(
f"Without LoRa modulation the radio's '{CONF_PACKET_MODE}' must be true, as a link sends packets",
path=[CONF_SX127X_ID],
)
if not radio.get(CONF_SYNC_VALUE):
raise cv.Invalid(
f"Without LoRa modulation the radio needs a '{CONF_SYNC_VALUE}', so that the link can tell when a "
"packet is arriving and not send over it",
path=[CONF_SX127X_ID],
)
if config[CONF_MAX_PACKET_SIZE] > FSK_MAX_PACKET_SIZE:
raise cv.Invalid(
f"Without LoRa modulation the radio sends at most {FSK_MAX_PACKET_SIZE} bytes",
path=[CONF_MAX_PACKET_SIZE],
)
FINAL_VALIDATE_SCHEMA = radio_link_final_validate(
CONF_SX127X_ID, CONF_PAYLOAD_LENGTH, CONF_RX_START, _check_fsk
)
async def to_code(config: ConfigType) -> None:
await new_radio_link(config, CONF_SX127X_ID, DOMAIN)
@@ -0,0 +1,10 @@
#pragma once
#include "esphome/components/packet_interface/radio_link.h"
#include "esphome/components/sx127x/sx127x.h"
namespace esphome::sx127x {
using SX127xPacketInterface = packet_interface::RadioLink<SX127x, SX127xListener, SX127xError, SX127X_MAX_PACKET_SIZE>;
} // namespace esphome::sx127x
+12
View File
@@ -354,6 +354,18 @@ void SX127x::loop() {
}
}
bool SX127x::is_receiving() {
// In receive mode DIO0 signals a received packet; the transmit FIFO would overwrite it
if (this->dio0_pin_ != nullptr && this->dio0_pin_->digital_read())
return true;
if (this->modulation_ != MOD_LORA) {
// Set from the sync word until the packet has been read; the preamble flag stays set when no sync word follows
return (this->read_register_(REG_IRQ_FLAGS_1) & IRQ_FLAGS_1_SYNC_ADDRESS_MATCH) != 0;
}
return (this->read_register_(REG_MODEM_STAT) &
(MODEM_STAT_SIGNAL_DETECTED | MODEM_STAT_SIGNAL_SYNCHRONIZED | MODEM_STAT_HEADER_VALID)) != 0;
}
void SX127x::run_image_cal() {
if (this->modulation_ == MOD_LORA) {
this->set_mode_(MOD_FSK, MODE_SLEEP);
+2
View File
@@ -88,6 +88,8 @@ class SX127x final : public Component,
return this->transmit_packet(packet.data(), packet.size());
}
void register_listener(SX127xListener *listener) { this->listeners_.push_back(listener); }
/// True while a packet is being received, or has been received and not yet read; sending would lose it.
bool is_receiving();
Trigger<std::vector<uint8_t>, float, float> *get_packet_trigger() { return &this->packet_trigger_; }
protected:
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff. Show more