ESPHome 2026.11.0-dev
Loading...
Searching...
No Matches
sendspin_image.cpp
Go to the documentation of this file.
1#include "sendspin_image.h"
2
3#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_ARTWORK)
4
5#include "esphome/core/log.h"
6
7#include <cstring>
8
10
11ESPHOME_LOG_TAG(TAG, "sendspin.image");
12
13static constexpr uint32_t TRANSITION_ACK_TIMEOUT_ID = 0;
14
15// How long a displayed frame may wait for sendspin.image.transition_finished before a warning
16// names the missing ack. Generous next to a typical fade of a second or two.
17static constexpr uint32_t TRANSITION_ACK_WARNING_MS = 10000;
18
19// THREAD CONTEXT: Main loop. Children set up after the hub, so the artwork role already exists.
21 const size_t frame_size = this->decode_sink_.get_buffer_size(this->width_, this->height_);
22 if (frame_size == 0) {
23 // The sink would refuse a buffer of these dimensions, so every decode would fall back to
24 // allocating one of its own. Fail here instead, where the dimensions are already known.
25 ESP_LOGE(TAG, "Cannot decode artwork at %dx%d", this->width_, this->height_);
26 this->mark_failed();
27 return;
28 }
29
30 RAMAllocator<uint8_t> allocator;
31 for (uint8_t *&buffer : this->buffers_) {
32 buffer = allocator.allocate(frame_size);
33 if (buffer == nullptr) {
34 ESP_LOGE(TAG, "Could not allocate %zu bytes for an artwork frame. Largest free block: %zu", frame_size,
35 allocator.get_max_free_block_size());
36 for (uint8_t *&allocated : this->buffers_) {
37 allocator.deallocate(allocated, frame_size);
38 allocated = nullptr;
39 }
40 this->mark_failed();
41 return;
42 }
43 // Both buffers start black, so a transition has something to fade from before any artwork
44 // has arrived.
45 memset(buffer, 0, frame_size);
46 }
47
48 // Point both views at buffers_[current_index_] rather than the buffer the first decode writes
49 // into, so they name a frame that stays black until artwork arrives.
50 this->current_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_);
51 if (this->transition_image_ != nullptr) {
52 this->transition_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_);
53 }
54
55 this->parent_->add_image_decode_callback(
56 [this](uint8_t slot, const uint8_t *data, size_t length, sendspin::SendspinImageFormat) {
57 if (slot == this->slot_)
58 this->on_decode_(data, length);
59 });
60 this->parent_->add_image_display_callback([this](uint8_t slot, uint32_t lateness_ms) {
61 if (slot == this->slot_)
62 this->on_display_(lateness_ms);
63 });
64 this->parent_->add_image_clear_callback([this](uint8_t slot) {
65 if (slot == this->slot_)
66 this->on_clear_();
67 });
68}
69
70// THREAD CONTEXT: Dedicated artwork decode thread. The data pointer is valid only for this call.
71void SendspinImageSlot::on_decode_(const uint8_t *data, size_t length) {
72 uint8_t *target;
73 {
74 // The lock makes the main loop's last swap of current_index_ visible here. The frame_done gate
75 // is what guarantees the buffer it picks out is not still needed by the main loop.
77 target = this->buffers_[this->current_index_ ^ 1];
78 }
79
80 // The server letterboxes artwork onto a canvas of exactly the requested dimensions, so the sink
81 // is pinned to them: a decode that asks for anything else is a malformed payload and drops the
82 // frame.
83 if (!this->decode_sink_.set_external_buffer(target, this->width_, this->height_)) {
84 // setup() rules this out, but decoding without the handover would allocate a frame-sized
85 // buffer on this thread, which is exactly what the permanent buffers exist to avoid.
86 this->report_error_();
87 return;
88 }
89
90 const bool decoded = this->decode_frame_(data, length, target);
91 // Ends any half-finished decode session (the decoder object is kept for reuse). An external
92 // buffer is let go of rather than freed, so this is safe on every path.
93 this->decode_sink_.release();
94
95 if (!decoded) {
96 // The buffer keeps whatever the failed decode painted into it, but no view names it while a
97 // decode can run, so nothing shows it.
98 this->report_error_();
99 return;
100 }
101
103 this->frame_pending_ = true;
104}
105
106// THREAD CONTEXT: Artwork decode thread, with target already handed to the sink.
107bool SendspinImageSlot::decode_frame_(const uint8_t *data, size_t length, const uint8_t *target) {
108 if (!this->decode_sink_.begin_decode(length)) {
109 ESP_LOGE(TAG, "Could not start decode");
110 return false;
111 }
112
113 size_t total_consumed = 0;
114 while (total_consumed < length) {
115 int consumed = this->decode_sink_.feed_data(const_cast<uint8_t *>(data) + total_consumed, length - total_consumed);
116 if (consumed <= 0) {
117 // <0 is a decode error; 0 means the decoder cannot make progress (truncated/corrupt data).
118 ESP_LOGE(TAG, "Decode failed at offset %zu (result %d)", total_consumed, consumed);
119 return false;
120 }
121 total_consumed += consumed;
122 }
123
124 if (!this->decode_sink_.end_decode()) {
125 ESP_LOGE(TAG, "Could not finalize decode");
126 return false;
127 }
128
129 // A decode that asked for other dimensions had the buffer taken away from it, so it painted
130 // nothing (or stopped partway). JPEG reports that as an error above; PNG carries on
131 // regardless, so the frame is dropped here.
132 return this->decode_sink_.decoded_into(target);
133}
134
135// THREAD CONTEXT: Main loop (fired once the slot's offset-shifted display deadline is reached).
137 bool frame_ready;
138 {
140 frame_ready = this->frame_pending_;
141 this->frame_pending_ = false;
142 if (frame_ready) {
143 // The decoded frame becomes the current one; the frame it replaces becomes the outgoing
144 // frame, and the next decode target once the transition is acked.
145 this->current_index_ ^= 1;
146 }
147 }
148 if (!frame_ready) {
149 // The decode for this display failed, so there is nothing new to show. The delivery still owes
150 // its ack or the library would withhold every later frame for this slot.
151 this->parent_->artwork_frame_done(this->slot_);
152 return;
153 }
154
155 // The frame this display replaces is only real artwork if something was already on screen.
156 const bool outgoing_is_artwork = this->showing_artwork_;
157 this->showing_artwork_ = true;
158 this->apply_frames_(outgoing_is_artwork);
159
160 // Armed before the trigger fires so an automation that acks synchronously still counts, and armed
161 // for the first frame too so the contract stays uniform: one transition_finished per display.
162 this->transition_pending_ = this->transition_image_ != nullptr;
163 if (this->transition_pending_) {
164 // The library holds back further deliveries until the ack, with no timeout, so an automation
165 // that never reaches the action stalls the slot with nothing in the log. Name the cause after
166 // a generous wait. Arming again replaces the previous timeout, so it cannot fire for a frame
167 // that was already acked and superseded.
168 this->set_timeout(TRANSITION_ACK_TIMEOUT_ID, TRANSITION_ACK_WARNING_MS, [this]() {
169 if (this->transition_pending_) {
170 ESP_LOGW(TAG,
171 "Slot %u: displayed artwork was never acknowledged; no new artwork will arrive until "
172 "sendspin.image.transition_finished runs or the stream is cleared",
173 this->slot_);
174 }
175 });
176 }
177 this->image_display_callback_.call(lateness_ms);
178 if (this->transition_image_ == nullptr) {
179 this->finish_transition_();
180 }
181}
182
183// THREAD CONTEXT: Main loop.
185 this->transition_pending_ = false;
186 if (this->transition_image_ != nullptr) {
187 // Move it off the buffer the next decode writes into. What it shows does not change: the
188 // buffer it moves to holds the artwork the transition just settled on.
189 this->transition_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_);
191 }
192 // The ack wakes the decode thread, which may start writing buffers_[current_index_ ^ 1] straight
193 // away, so nothing may still name that buffer by the time this runs.
194 this->parent_->artwork_frame_done(this->slot_);
195}
196
197// THREAD CONTEXT: Main loop (invoked from the sendspin.image.transition_finished action).
199 if (!this->transition_pending_) {
200 return;
201 }
202 this->finish_transition_();
203}
204
205// THREAD CONTEXT: Main loop (fired on stream end or clear for this slot).
207 {
209 // Drop a frame that was decoded but never displayed; its buffer stays the decode target.
210 this->frame_pending_ = false;
211 }
212 // No pixels are touched and the views keep naming the frames they had: a widget goes on drawing
213 // the last artwork until the automation points it elsewhere or hides it. Only the display lambda
214 // path stops drawing the artwork, falling back to the placeholder.
216 if (this->transition_image_ != nullptr) {
217 // Point it away from the decode target, as at setup, so it cannot show a frame being decoded.
218 this->transition_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_);
220 }
221 this->showing_artwork_ = false;
222 // Drops a running transition. Its automation cannot be cancelled here, so a late
223 // transition_finished() can ack the next stream's first frame early, showing it without its
224 // transition. The ack count stays right.
225 this->transition_pending_ = false;
226 this->image_clear_callback_.call();
227 // A clear is itself a delivery owing exactly one ack, and it supersedes any un-acked frame --
228 // including one whose transition never signalled transition_finished(), so a stalled slot
229 // recovers here.
230 this->parent_->artwork_frame_done(this->slot_);
231}
232
233// THREAD CONTEXT: Main loop.
235 ESP_LOGCONFIG(TAG,
236 "Artwork slot %u:\n"
237 " Dimensions: %dx%d\n"
238 " Frame buffers: 2 x %zu bytes\n"
239 " Transition image: %s",
240 this->slot_, this->width_, this->height_,
241 this->decode_sink_.get_buffer_size(this->width_, this->height_),
242 YESNO(this->transition_image_ != nullptr));
243}
244
245// THREAD CONTEXT: Main loop.
246void SendspinImageSlot::apply_frames_(bool transition_is_artwork) {
247 this->current_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_);
249 if (this->transition_image_ != nullptr) {
250 this->transition_image_->set_frame(this->buffers_[this->current_index_ ^ 1], this->width_, this->height_);
251 this->transition_image_->set_showing_artwork(transition_is_artwork);
252 }
253}
254
255// THREAD CONTEXT: Artwork decode thread. Triggers must run on the main loop; defer() is thread-safe
256// here because the hub enables wake_loop_threadsafe support.
258 this->defer([this]() { this->image_error_callback_.call(); });
259}
260
261} // namespace esphome::sendspin_
262
263#endif
void mark_failed()
Mark this component as failed.
void set_timeout(const char *name, uint32_t timeout, std::function< void()> &&f)
Set a timeout function with a const char* name.
Definition component.cpp:96
void defer(const char *name, std::function< void()> &&f)
Defer a callback to the next loop() call with a const char* name.
Helper class that wraps a mutex with a RAII-style API.
Definition helpers.h:2059
An STL allocator that uses SPI or internal RAM.
Definition helpers.h:2222
void deallocate(T *p, size_t n)
Definition helpers.h:2279
size_t get_max_free_block_size() const
Return the maximum size block this allocator could allocate.
Definition helpers.h:2327
T * allocate(size_t n)
Definition helpers.h:2249
bool begin_decode(size_t expected_size=0, ImageFormat format=AUTO)
Begin decoding an image.
size_t get_buffer_size(int width, int height) const
Get the buffer size in bytes needed for a picture of the given dimensions.
bool end_decode()
Complete the decoding process.
int feed_data(uint8_t *data, size_t len)
Feed data to the decoder.
void release()
Release the image buffer and free its memory, ending any decode session.
bool set_external_buffer(uint8_t *buffer, int width, int height)
Decode into a buffer the caller owns, instead of one allocated here.
bool decoded_into(const uint8_t *buffer) const
True when the decode ended with the given buffer still in place.
void set_frame(const uint8_t *data, int width, int height)
void set_showing_artwork(bool showing_artwork)
Records whether the frame on show is real artwork rather than the black it starts as.
LazyCallbackManager< void()> image_error_callback_
void on_display_(uint32_t lateness_ms)
void transition_finished()
Signals that the display transition for the last frame has finished.
void on_decode_(const uint8_t *data, size_t length)
void apply_frames_(bool transition_is_artwork)
LazyCallbackManager< void()> image_clear_callback_
bool decode_frame_(const uint8_t *data, size_t length, const uint8_t *target)
LazyCallbackManager< void(uint32_t)> image_display_callback_
std::array< uint8_t *, 2 > buffers_
uint32_t buffer
Definition gif.cpp:50
ESPHOME_LOG_TAG(TAG, "sendspin.image")
static void uint32_t
uint16_t length
Definition tt21100.cpp:0
SemaphoreHandle_t lock