ESPHome 2026.11.0-dev
Loading...
Searching...
No Matches
snapshot.cpp
Go to the documentation of this file.
1#ifdef USE_HOST
2#include "snapshot.h"
3#include "gif.h"
5#include "esphome/core/hal.h"
6#include "esphome/core/log.h"
7
8#include <fcntl.h>
9#include <strings.h>
10#include <unistd.h>
11#include <cctype>
12#include <cerrno>
13#include <cmath>
14#include <cstdio>
15#include <cstring>
16#include <ctime>
17#include <filesystem>
18#include <memory>
19#include <utility>
20
21namespace esphome::snapshot {
22
23namespace {
24
25ESPHOME_LOG_TAG(TAG, "snapshot");
26
27// Longest name we will build a path from. NAME_MAX is 255 and we may append a collision suffix.
28constexpr size_t MAX_NAME_LENGTH = 200;
29// Give up rather than spin forever if every candidate name is taken.
30constexpr unsigned MAX_NAME_ATTEMPTS = 1000;
31// A BMP file header followed by a BITMAPINFOHEADER, which is where the pixels start.
32constexpr size_t BMP_HEADER_SIZE = 54;
33constexpr size_t BMP_INFO_HEADER_SIZE = 40;
34constexpr int BMP_BITS_PER_PIXEL = 24;
35
36// The largest width or height a GIF file can hold.
37constexpr int GIF_MAX_SIZE = 0xFFFF;
38
41bool has_extension(const std::string &name, const char *extension) {
42 return name.size() >= 4 && strcasecmp(name.c_str() + name.size() - 4, extension) == 0;
43}
44
48std::string sanitise_filename(const char *const name, const char *extension, bool *name_changed) {
49 std::string result;
50 bool all_dots = true;
51 bool changed = false;
52 for (const char *p = name; *p != '\0'; p++) {
53 if (result.size() >= MAX_NAME_LENGTH) {
54 changed = true;
55 break;
56 }
57 char c = *p;
58 if (!std::isalnum(static_cast<unsigned char>(c)) && c != '.' && c != '_' && c != '-') {
59 c = '_';
60 changed = true;
61 }
62 if (c != '.')
63 all_dots = false;
64 result.push_back(c);
65 }
66 if (all_dots) {
67 *name_changed = true;
68 return "";
69 }
70 if (!has_extension(result, extension))
71 result += extension;
72 *name_changed = changed;
73 return result;
74}
75
77std::string add_suffix(const std::string &name, unsigned attempt) {
78 char suffix[12];
79 snprintf(suffix, sizeof(suffix), "-%u", attempt);
80 auto dot = name.rfind('.');
81 if (dot == std::string::npos)
82 return name + suffix;
83 return name.substr(0, dot) + suffix + name.substr(dot);
84}
85
88const char *snapshot_dir() {
89 const char *dir = getenv("ESPHOME_SNAPSHOT_DIR"); // NOLINT(concurrency-mt-unsafe)
90 return dir != nullptr && dir[0] != '\0' ? dir : ESPHOME_SNAPSHOT_DIR;
91}
92
95void put_le(uint8_t *&dest, uint32_t value, size_t bytes) {
96 for (size_t i = 0; i != bytes; i++)
97 *dest++ = static_cast<uint8_t>(value >> (8 * i));
98}
99
102size_t padded_row_size(int width) { return (static_cast<size_t>(width) * 3 + 3) & ~size_t{3}; }
103
107bool write_bmp(FILE *file, const uint8_t *pixels, int width, int height, size_t row_stride) {
108 const size_t row_size = padded_row_size(width);
109 const size_t pixel_bytes = row_size * height;
110
111 uint8_t header[BMP_HEADER_SIZE];
112 uint8_t *pos = header;
113 *pos++ = 'B';
114 *pos++ = 'M';
115 put_le(pos, static_cast<uint32_t>(BMP_HEADER_SIZE + pixel_bytes), 4);
116 put_le(pos, 0, 4); // reserved
117 put_le(pos, BMP_HEADER_SIZE, 4);
118 put_le(pos, BMP_INFO_HEADER_SIZE, 4);
119 put_le(pos, static_cast<uint32_t>(width), 4);
120 put_le(pos, static_cast<uint32_t>(height), 4);
121 put_le(pos, 1, 2); // one plane
122 put_le(pos, BMP_BITS_PER_PIXEL, 2);
123 put_le(pos, 0, 4); // not compressed
124 put_le(pos, static_cast<uint32_t>(pixel_bytes), 4);
125 put_le(pos, 0, 4); // pixels per metre across, unspecified
126 put_le(pos, 0, 4); // pixels per metre down, unspecified
127 put_le(pos, 0, 4); // no palette
128 put_le(pos, 0, 4); // so no palette entry matters more than another
129
130 if (fwrite(header, 1, sizeof(header), file) != sizeof(header))
131 return false;
132 for (int y = height - 1; y >= 0; y--) {
133 if (fwrite(pixels + static_cast<size_t>(y) * row_stride, 1, row_size, file) != row_size)
134 return false;
135 }
136 return true;
137}
138
142FILE *create_snapshot_file(const std::string &name, bool exact, std::string &path) {
143 const std::string dir = snapshot_dir();
144 std::error_code ec;
145 std::filesystem::create_directories(dir, ec);
146 if (ec) {
147 ESP_LOGE(TAG, "Could not create snapshot directory %s: %s", dir.c_str(), ec.message().c_str());
148 return nullptr;
149 }
150
151 // O_EXCL guarantees we never write over a file that is already there.
152 int fd = -1;
153 for (unsigned attempt = 0; attempt < MAX_NAME_ATTEMPTS; attempt++) {
154 path = dir + "/" + (attempt == 0 ? name : add_suffix(name, attempt));
155 fd = ::open(path.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0644);
156 if (fd >= 0)
157 break;
158 if (errno != EEXIST) {
159 ESP_LOGE(TAG, "Could not create %s: %s", path.c_str(), strerror(errno));
160 return nullptr;
161 }
162 if (exact) {
163 // The caller asked for this exact name, so silently writing somewhere else would be worse
164 // than failing - a test asserting on the path would pick up a stale file.
165 ESP_LOGE(TAG, "Snapshot %s already exists, not overwriting", path.c_str());
166 return nullptr;
167 }
168 }
169 if (fd < 0) {
170 ESP_LOGE(TAG, "Could not find an unused name for %s in %s", name.c_str(), dir.c_str());
171 return nullptr;
172 }
173
174 FILE *file = fdopen(fd, "wb");
175 if (file == nullptr) {
176 ESP_LOGE(TAG, "Could not open %s: %s", path.c_str(), strerror(errno));
177 ::close(fd);
178 ::unlink(path.c_str());
179 }
180 return file;
181}
182
186bool close_snapshot_file(FILE *file, const std::string &path, bool ok) {
187 int saved_errno = ok ? 0 : errno;
188 // Closing can fail in its own right - the last of the data is still on its way out.
189 if (fclose(file) != 0) {
190 if (ok)
191 saved_errno = errno;
192 ok = false;
193 }
194 if (!ok) {
195 ESP_LOGE(TAG, "Could not write %s: %s", path.c_str(), strerror(saved_errno));
196 ::unlink(path.c_str());
197 }
198 return ok;
199}
200
202bool write_snapshot_file(const uint8_t *pixels, int width, int height, size_t row_stride, const std::string &name,
203 bool exact) {
204 std::string path;
205 FILE *file = create_snapshot_file(name, exact, path);
206 if (file == nullptr)
207 return false;
208 if (!close_snapshot_file(file, path, write_bmp(file, pixels, width, height, row_stride)))
209 return false;
210 ESP_LOGI(TAG, "Snapshot written to %s", path.c_str());
211 return true;
212}
213
216std::string choose_name(const char *filename, const char *prefix, const char *extension, bool &exact) {
217 std::string name;
218 exact = false;
219 if (filename != nullptr) {
220 bool name_changed = false;
221 name = sanitise_filename(filename, extension, &name_changed);
222 exact = !name.empty();
223 if (name_changed) {
224 ESP_LOGW(TAG, "Requested snapshot name '%s' is not an acceptable file name, using '%s' instead", filename,
225 name.empty() ? LOG_STR_LITERAL("a name made from the time") : name.c_str());
226 }
227 }
228 if (!name.empty())
229 return name;
230
231 struct timespec now{};
232 if (clock_gettime(CLOCK_REALTIME, &now) != 0)
233 now = {};
234 struct tm tm_buf{};
235 if (localtime_r(&now.tv_sec, &tm_buf) == nullptr)
236 tm_buf = {};
237 char stamp[32]{};
238 // ::strftime to be sure of the one from <ctime>; display has an unrelated member of that name
239 if (::strftime(stamp, sizeof(stamp), "%Y%m%d-%H%M%S", &tm_buf) == 0)
240 snprintf(stamp, sizeof(stamp), "unknown-time");
241 char buffer[MAX_NAME_LENGTH];
242 int written = snprintf(buffer, sizeof(buffer), "%s-%s-%03ld%s", prefix, stamp, now.tv_nsec / 1000000, extension);
243 if (written < 0 || static_cast<size_t>(written) >= sizeof(buffer)) {
244 ESP_LOGW(TAG, "Could not build a timestamped snapshot name, using a fallback");
245 snprintf(buffer, sizeof(buffer), "snapshot%s", extension);
246 }
247 return buffer;
248}
249
250} // namespace
251
253struct Snapshot::Recording {
254 Recording(FILE *file, std::string path, int width, int height, uint32_t frames, float frame_rate)
255 : file(file),
256 path(std::move(path)),
257 gif(file, width, height),
258 row_stride(padded_row_size(width)),
259 pixels(std::make_unique<uint8_t[]>(this->row_stride * height)),
260 frames(frames),
261 frame_rate(frame_rate) {}
262 ~Recording() {
263 // The file is still open only if the recording did not finish, so do not leave a partial one behind.
264 if (this->file != nullptr) {
265 fclose(this->file);
266 ::unlink(this->path.c_str());
267 }
268 }
269
271 uint32_t due_ms(uint32_t frame) const {
272 return static_cast<uint32_t>(std::lround(frame * 1000.0 / this->frame_rate));
273 }
277 unsigned delay_centiseconds() const {
278 auto centiseconds_at = [this](uint32_t frame) { return std::lround(frame * 100.0 / this->frame_rate); };
279 return static_cast<unsigned>(centiseconds_at(this->frames_done + 1) - centiseconds_at(this->frames_done));
280 }
281
282 FILE *file;
283 std::string path;
284 GifWriter gif;
285 size_t row_stride;
286 std::unique_ptr<uint8_t[]> pixels;
287 uint32_t frames;
288 float frame_rate;
289 uint32_t frames_done{0};
290 uint32_t start_ms{0};
291};
292
293Snapshot::Snapshot() = default;
294Snapshot::~Snapshot() = default;
295
296// helper function since ESP_LOGW is disallowed in a header file
297void Snapshot::take_snapshot_or_log(const char *filename, uint32_t frames, float frame_rate) {
298 const char *name = filename[0] != '\0' ? filename : nullptr;
299 const bool ok = frames == 0 ? this->take_snapshot(name) : this->take_animation(name, frames, frame_rate);
300 if (!ok) {
301 ESP_LOGW(TAG, "snapshot.take did not write a file");
302 }
303}
304
305bool Snapshot::take_snapshot(const char *filename) {
306 const int width = this->snapshot_width();
307 const int height = this->snapshot_height();
308 if (width <= 0 || height <= 0) {
309 ESP_LOGE(TAG, "Snapshot requested but the display is %dx%d", width, height);
310 return false;
311 }
312
313 bool exact;
314 const std::string name = choose_name(filename, this->snapshot_prefix_, ".bmp", exact);
315
316 // Rows are padded out to a multiple of four bytes, as the file wants them, so each one can be
317 // written straight from the buffer. Zeroed on allocation, which is what the padding must be.
318 const size_t row_stride = padded_row_size(width);
319 auto pixels = std::make_unique<uint8_t[]>(row_stride * height);
320 if (!this->capture_bgr(pixels.get(), row_stride))
321 return false;
322 return write_snapshot_file(pixels.get(), width, height, row_stride, name, exact);
323}
324
325bool Snapshot::take_animation(const char *filename, uint32_t frames, float frame_rate) {
326 if (this->recording_ != nullptr) {
327 ESP_LOGW(TAG, "Already recording %s, not starting another", this->recording_->path.c_str());
328 return false;
329 }
330 if (frames == 0 || !(frame_rate > 0)) {
331 ESP_LOGE(TAG, "Animation requested with %u frames at %.1f frames a second", frames, frame_rate);
332 return false;
333 }
334 const int width = this->snapshot_width();
335 const int height = this->snapshot_height();
336 if (width <= 0 || height <= 0 || width > GIF_MAX_SIZE || height > GIF_MAX_SIZE) {
337 ESP_LOGE(TAG, "Animation requested but the display is %dx%d, and a GIF holds up to %d", width, height,
338 GIF_MAX_SIZE);
339 return false;
340 }
341
342 bool exact;
343 const std::string name = choose_name(filename, this->snapshot_prefix_, ".gif", exact);
344 std::string path;
345 FILE *file = create_snapshot_file(name, exact, path);
346 if (file == nullptr)
347 return false;
348 auto recording = std::make_unique<Recording>(file, path, width, height, frames, frame_rate);
349 if (!recording->gif.write_header()) {
350 recording->file = nullptr;
351 close_snapshot_file(file, path, false);
352 return false;
353 }
354 ESP_LOGI(TAG, "Recording %u frames at %.1f frames a second to %s", frames, frame_rate, path.c_str());
355 this->recording_ = std::move(recording);
356 this->recording_->start_ms = millis();
357 return this->record_frame_();
358}
359
360bool Snapshot::record_frame_() {
361 Recording &recording = *this->recording_;
362 if (!this->capture_bgr(recording.pixels.get(), recording.row_stride)) {
363 // capture_bgr() has said why. Resetting removes the half-finished file.
364 this->recording_.reset();
365 return false;
366 }
367
368 bool ok = recording.gif.write_frame(recording.pixels.get(), recording.row_stride, recording.delay_centiseconds());
369 recording.frames_done++;
370 const bool finished = recording.frames_done == recording.frames;
371 if (ok && finished)
372 ok = recording.gif.write_trailer();
373 if (!ok || finished) {
374 ok = close_snapshot_file(std::exchange(recording.file, nullptr), recording.path, ok);
375 if (ok) {
376 ESP_LOGI(TAG, "Animation written to %s", recording.path.c_str());
377 }
378 this->recording_.reset();
379 return ok;
380 }
381
382 // Aim for the time each frame is due, not a fixed gap after the last, so delays do not add up.
383 const int32_t wait = static_cast<int32_t>(recording.start_ms + recording.due_ms(recording.frames_done) - millis());
384 App.scheduler.set_timeout(this, wait > 0 ? static_cast<uint32_t>(wait) : 0, [this]() { this->record_frame_(); });
385 return true;
386}
387
388} // namespace esphome::snapshot
389#endif
void take_snapshot_or_log(const char *filename, uint32_t frames, float frame_rate)
Take a snapshot for an automation: an empty name means a generated one, and a snapshot that wrote not...
Definition snapshot.cpp:297
virtual bool capture_bgr(uint8_t *dest, size_t row_stride)=0
Fill in the picture: three bytes per pixel in blue, green, red order, topmost row first,...
const char * snapshot_prefix_
Definition snapshot.h:65
bool take_animation(const char *filename, uint32_t frames, float frame_rate)
Record what is shown as an animated GIF file in the snapshot directory.
Definition snapshot.cpp:325
virtual int snapshot_width()=0
Width of the picture in pixels.
virtual int snapshot_height()=0
Height of the picture in pixels.
bool take_snapshot(const char *filename)
Write the current picture to a BMP file in the snapshot directory.
Definition snapshot.cpp:305
uint32_t buffer
Definition gif.cpp:50
std::vector< uint8_t > bytes
Definition sml_parser.h:12
ESPHOME_LOG_TAG(TAG, "snapshot.display")
size_t size_t pos
Definition helpers.h:1255
uint32_t IRAM_ATTR HOT millis()
Definition hal.cpp:25
int written
Definition helpers.h:1262
Application App
Global storage of Application pointer - only one Application can exist.
STL namespace.
struct tm * localtime_r(const time_t *timer, struct tm *result)
Definition posix_tz.cpp:295
static void uint32_t
uint16_t y
Definition tt21100.cpp:6