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