From 7d5117e570d5401cc046dba0201cf8122a81b8f8 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:18:15 +1000 Subject: [PATCH] [snapshot] Add feature to record animated gifs from screen (#19421) Co-authored-by: Claude Sonnet 5 --- esphome/components/snapshot/__init__.py | 34 ++- esphome/components/snapshot/gif.cpp | 283 ++++++++++++++++++ esphome/components/snapshot/gif.h | 51 ++++ esphome/components/snapshot/snapshot.cpp | 254 ++++++++++++---- esphome/components/snapshot/snapshot.h | 35 ++- tests/components/snapshot/common.yaml | 5 + tests/components/snapshot/snapshot_test.cpp | 125 ++++++++ .../fixtures/snapshot_display.yaml | 61 ++++ tests/integration/test_snapshot_display.py | 117 +++++++- 9 files changed, 888 insertions(+), 77 deletions(-) create mode 100644 esphome/components/snapshot/gif.cpp create mode 100644 esphome/components/snapshot/gif.h create mode 100644 tests/components/snapshot/snapshot_test.cpp diff --git a/esphome/components/snapshot/__init__.py b/esphome/components/snapshot/__init__.py index 068af830aa..1404263ce6 100644 --- a/esphome/components/snapshot/__init__.py +++ b/esphome/components/snapshot/__init__.py @@ -21,20 +21,42 @@ CODEOWNERS = ["@clydebarrow"] DOMAIN = "snapshot" CONF_FILENAME = "filename" +CONF_FRAMES = "frames" +CONF_FRAME_RATE = "frame_rate" snapshot_ns = cg.esphome_ns.namespace("snapshot") Snapshot = snapshot_ns.class_("Snapshot") +def _default_animation(config: ConfigType) -> ConfigType: + """Without frames the action takes a single picture, which the C++ side reads as zero frames.""" + return {CONF_FRAMES: 0, CONF_FRAME_RATE: 0.0, **config} + + automation.register_apply_action( "snapshot.take", - automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(Snapshot), - cv.Optional(CONF_FILENAME, default=""): cv.templatable(cv.string), - } + cv.All( + automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(Snapshot), + cv.Optional(CONF_FILENAME, default=""): cv.templatable(cv.string), + # Asking for frames makes a GIF instead of a single BMP picture. + cv.Inclusive(CONF_FRAMES, "animation"): cv.positive_not_null_int, + cv.Inclusive(CONF_FRAME_RATE, "animation"): cv.All( + cv.framerate, cv.Range(min=0.1, max=50) + ), + } + ), + _default_animation, + ), + automation.ApplyCall( + "take_snapshot_or_log({}, {}, {})", + ( + (CONF_FILENAME, cg.std_string), + (CONF_FRAMES, cg.uint32), + (CONF_FRAME_RATE, cg.float_), + ), ), - automation.ApplyField(CONF_FILENAME, "take_snapshot_or_log", cg.std_string), ) diff --git a/esphome/components/snapshot/gif.cpp b/esphome/components/snapshot/gif.cpp new file mode 100644 index 0000000000..5650c1c404 --- /dev/null +++ b/esphome/components/snapshot/gif.cpp @@ -0,0 +1,283 @@ +#ifdef USE_HOST +#include "gif.h" + +#include +#include +#include + +namespace esphome::snapshot { + +namespace { + +constexpr size_t MAX_PALETTE_SIZE = 256; +// GIF needs at least two bits for each colour, even when the picture has fewer than four colours. +constexpr unsigned MIN_PALETTE_BITS = 2; +constexpr unsigned MAX_CODE_BITS = 12; +constexpr uint32_t MAX_CODES = 1u << MAX_CODE_BITS; +// Twice the number of codes there can be, so a lookup rarely has to look at more than one entry. +constexpr size_t DICTIONARY_SIZE = 2 * MAX_CODES; +constexpr uint32_t EMPTY_ENTRY = 0xFFFFFFFF; +constexpr size_t MAX_SUB_BLOCK_SIZE = 255; +// Colours are grouped by their top 5, 6 and 5 bits, which is all an RGB565 display can show. +constexpr size_t COLOR_KEY_COUNT = 1u << 16; + +constexpr uint16_t color_key(uint8_t red, uint8_t green, uint8_t blue) { + return static_cast(((red >> 3) << 11) | ((green >> 2) << 5) | (blue >> 3)); +} + +/// One of the three parts of a colour key: 0 for red, 1 for green, 2 for blue. The value is spread +/// over 0 to 255 whichever part it is, so the parts can be compared with each other. +constexpr unsigned key_channel(uint16_t key, unsigned channel) { + switch (channel) { + case 0: + return (key >> 11) << 3; + case 1: + return ((key >> 5) & 0x3F) << 2; + default: + return (key & 0x1F) << 3; + } +} + +/// Store a value in two bytes, least significant first. +void put_le16(std::vector &out, unsigned value) { + out.push_back(static_cast(value & 0xFF)); + out.push_back(static_cast(value >> 8)); +} + +/// Packs codes of varying length into bytes, least significant bit first. +struct BitWriter { + std::vector &out; + uint32_t buffer{0}; + unsigned count{0}; + + void put(uint32_t code, unsigned size) { + this->buffer |= code << this->count; + this->count += size; + while (this->count >= 8) { + this->out.push_back(static_cast(this->buffer & 0xFF)); + this->buffer >>= 8; + this->count -= 8; + } + } + + void flush() { + if (this->count > 0) + this->out.push_back(static_cast(this->buffer & 0xFF)); + this->buffer = 0; + this->count = 0; + } +}; + +/// Compress palette indices the way GIF asks for, appending the bytes to `out`. +void lzw_compress(const uint8_t *data, size_t length, unsigned min_code_size, std::vector &out) { + struct Entry { + uint32_t key; // the code before, shifted up a byte, with the next index in the low byte + uint16_t code; + }; + const uint32_t clear_code = 1u << min_code_size; + const uint32_t end_code = clear_code + 1; + std::vector dictionary(DICTIONARY_SIZE); + BitWriter bits{out}; + uint32_t next_code = 0; + unsigned code_size = 0; + + auto reset = [&]() { + std::fill(dictionary.begin(), dictionary.end(), Entry{EMPTY_ENTRY, 0}); + next_code = end_code + 1; + code_size = min_code_size + 1; + }; + + reset(); + bits.put(clear_code, code_size); + uint32_t prefix = data[0]; + for (size_t i = 1; i != length; i++) { + const uint32_t key = prefix << 8 | data[i]; + size_t slot = (key * 2654435761u >> 16) & (DICTIONARY_SIZE - 1); + while (dictionary[slot].key != EMPTY_ENTRY && dictionary[slot].key != key) + slot = (slot + 1) & (DICTIONARY_SIZE - 1); + if (dictionary[slot].key == key) { + prefix = dictionary[slot].code; + continue; + } + bits.put(prefix, code_size); + if (next_code < MAX_CODES) { + dictionary[slot] = Entry{key, static_cast(next_code++)}; + // The reader is one entry behind, so it moves to longer codes a step after this does. + if (next_code > (1u << code_size)) + code_size++; + } else { + bits.put(clear_code, code_size); + reset(); + } + prefix = data[i]; + } + bits.put(prefix, code_size); + // The reader adds an entry for that last code, which may take it to longer codes. + if (next_code == (1u << code_size) && code_size < MAX_CODE_BITS) + code_size++; + bits.put(end_code, code_size); + bits.flush(); +} + +} // namespace + +GifWriter::GifWriter(FILE *file, int width, int height) + : file_(file), + width_(width), + height_(height), + bins_(COLOR_KEY_COUNT), + palette_index_(COLOR_KEY_COUNT), + indices_(static_cast(width) * height) {} + +bool GifWriter::write_header() { + this->output_.clear(); + static constexpr char SIGNATURE[] = "GIF89a"; + this->output_.insert(this->output_.end(), SIGNATURE, SIGNATURE + 6); + put_le16(this->output_, this->width_); + put_le16(this->output_, this->height_); + this->output_.push_back(0x70); // no shared colour table; 8 bits per colour + this->output_.push_back(0); // background colour, unused + this->output_.push_back(0); // pixels are square + // Ask players to repeat the animation forever. + static constexpr char LOOP_EXTENSION[] = "\x21\xFF\x0BNETSCAPE2.0\x03\x01"; + this->output_.insert(this->output_.end(), LOOP_EXTENSION, LOOP_EXTENSION + sizeof(LOOP_EXTENSION) - 1); + put_le16(this->output_, 0); + this->output_.push_back(0); + return fwrite(this->output_.data(), 1, this->output_.size(), this->file_) == this->output_.size(); +} + +size_t GifWriter::build_palette_(uint8_t *palette) { + struct Box { + size_t begin; // range of used_colors_, which the box owns + size_t end; + uint64_t pixels; + }; + + this->used_colors_.clear(); + uint64_t total = 0; + for (size_t key = 0; key != COLOR_KEY_COUNT; key++) { + if (this->bins_[key].count != 0) { + this->used_colors_.push_back(static_cast(key)); + total += this->bins_[key].count; + } + } + + // Median cut: keep cutting the box with the most pixels in two, across the colour part that + // varies most, until there is a box for each palette entry or every box is a single colour. + std::vector boxes; + boxes.push_back({0, this->used_colors_.size(), total}); + while (boxes.size() < MAX_PALETTE_SIZE) { + size_t chosen = boxes.size(); + for (size_t i = 0; i != boxes.size(); i++) { + if (boxes[i].end - boxes[i].begin >= 2 && (chosen == boxes.size() || boxes[i].pixels > boxes[chosen].pixels)) + chosen = i; + } + if (chosen == boxes.size()) + break; + const Box box = boxes[chosen]; + + unsigned low[3] = {255, 255, 255}; + unsigned high[3] = {0, 0, 0}; + for (size_t i = box.begin; i != box.end; i++) { + for (unsigned channel = 0; channel != 3; channel++) { + const unsigned value = key_channel(this->used_colors_[i], channel); + low[channel] = std::min(low[channel], value); + high[channel] = std::max(high[channel], value); + } + } + unsigned widest = 0; + for (unsigned channel = 1; channel != 3; channel++) { + if (high[channel] - low[channel] > high[widest] - low[widest]) + widest = channel; + } + std::sort(this->used_colors_.begin() + box.begin, this->used_colors_.begin() + box.end, + [widest](uint16_t a, uint16_t b) { + const unsigned value_a = key_channel(a, widest); + const unsigned value_b = key_channel(b, widest); + return value_a != value_b ? value_a < value_b : a < b; + }); + + // Cut where the pixels are half on one side and half on the other, keeping a colour on each. + size_t cut = box.begin; + uint64_t below = 0; + while (cut < box.end - 1 && below * 2 < box.pixels) + below += this->bins_[this->used_colors_[cut++]].count; + boxes[chosen] = {box.begin, cut, below}; + boxes.push_back({cut, box.end, box.pixels - below}); + } + + for (size_t i = 0; i != boxes.size(); i++) { + uint64_t sum[3] = {0, 0, 0}; + for (size_t j = boxes[i].begin; j != boxes[i].end; j++) { + const ColorBin &bin = this->bins_[this->used_colors_[j]]; + for (unsigned channel = 0; channel != 3; channel++) + sum[channel] += bin.sum[channel]; + this->palette_index_[this->used_colors_[j]] = static_cast(i); + } + // The average colour of the box, rounded to the nearest. + for (unsigned channel = 0; channel != 3; channel++) + palette[i * 3 + channel] = static_cast((sum[channel] + boxes[i].pixels / 2) / boxes[i].pixels); + } + return boxes.size(); +} + +bool GifWriter::write_frame(const uint8_t *bgr, size_t row_stride, unsigned delay_centiseconds) { + std::fill(this->bins_.begin(), this->bins_.end(), ColorBin{}); + for (int y = 0; y != this->height_; y++) { + const uint8_t *in = bgr + y * row_stride; + for (int x = 0; x != this->width_; x++, in += 3) { + ColorBin &bin = this->bins_[color_key(in[2], in[1], in[0])]; + bin.count++; + bin.sum[0] += in[2]; + bin.sum[1] += in[1]; + bin.sum[2] += in[0]; + } + } + + uint8_t palette[MAX_PALETTE_SIZE * 3] = {}; + const size_t colors = this->build_palette_(palette); + unsigned palette_bits = MIN_PALETTE_BITS; + while ((size_t{1} << palette_bits) < colors) + palette_bits++; + + uint8_t *out_index = this->indices_.data(); + for (int y = 0; y != this->height_; y++) { + const uint8_t *in = bgr + y * row_stride; + for (int x = 0; x != this->width_; x++, in += 3) + *out_index++ = this->palette_index_[color_key(in[2], in[1], in[0])]; + } + + this->output_.clear(); + // Show the frame for the time asked, then leave it in place under the next one. + static constexpr uint8_t GRAPHIC_CONTROL[] = {0x21, 0xF9, 0x04, 0x04}; + this->output_.insert(this->output_.end(), std::begin(GRAPHIC_CONTROL), std::end(GRAPHIC_CONTROL)); + put_le16(this->output_, delay_centiseconds); + this->output_.push_back(0); // no transparent colour + this->output_.push_back(0); + this->output_.push_back(0x2C); // the frame fills the whole picture + put_le16(this->output_, 0); + put_le16(this->output_, 0); + put_le16(this->output_, this->width_); + put_le16(this->output_, this->height_); + this->output_.push_back(static_cast(0x80 | (palette_bits - 1))); // has its own colour table + this->output_.insert(this->output_.end(), palette, palette + (size_t{3} << palette_bits)); + this->output_.push_back(static_cast(palette_bits)); + + std::vector compressed; + lzw_compress(this->indices_.data(), this->indices_.size(), palette_bits, compressed); + for (size_t pos = 0; pos < compressed.size(); pos += MAX_SUB_BLOCK_SIZE) { + const size_t length = std::min(MAX_SUB_BLOCK_SIZE, compressed.size() - pos); + this->output_.push_back(static_cast(length)); + this->output_.insert(this->output_.end(), compressed.begin() + pos, compressed.begin() + pos + length); + } + this->output_.push_back(0); + return fwrite(this->output_.data(), 1, this->output_.size(), this->file_) == this->output_.size(); +} + +bool GifWriter::write_trailer() { + const uint8_t trailer = 0x3B; + return fwrite(&trailer, 1, 1, this->file_) == 1; +} + +} // namespace esphome::snapshot +#endif diff --git a/esphome/components/snapshot/gif.h b/esphome/components/snapshot/gif.h new file mode 100644 index 0000000000..382f649976 --- /dev/null +++ b/esphome/components/snapshot/gif.h @@ -0,0 +1,51 @@ +#pragma once + +#ifdef USE_HOST +#include +#include +#include +#include + +namespace esphome::snapshot { + +/// Writes an animated GIF a frame at a time, so a long recording is never held in memory. +/// +/// A GIF frame can use at most 256 colours. Each frame gets its own colour table, chosen from what +/// is in that frame, so a frame with 256 colours or fewer is stored exactly. +class GifWriter { + public: + GifWriter(FILE *file, int width, int height); + + /// Write the start of the file. This must come first. The animation repeats forever. + bool write_header(); + /// Add a frame from three bytes per pixel in blue, green, red order, topmost row first, with + /// `row_stride` bytes from the start of one row to the start of the next. + /// The frame is shown for `delay_centiseconds` hundredths of a second. + bool write_frame(const uint8_t *bgr, size_t row_stride, unsigned delay_centiseconds); + /// Write the end of the file. This must come last. + bool write_trailer(); + + protected: + /// How many pixels of one colour there are in the frame, and the sum of their red, green and blue. + struct ColorBin { + uint64_t sum[3]; + uint32_t count; + }; + + /// Choose up to 256 colours for the frame in `bins_`, storing them as red, green, blue triples in + /// `palette` and which one each colour is nearest in `palette_index_`. Returns how many were chosen. + size_t build_palette_(uint8_t *palette); + + FILE *file_; + int width_; + int height_; + std::vector bins_; + std::vector used_colors_; + std::vector palette_index_; + std::vector indices_; + std::vector output_; +}; + +} // namespace esphome::snapshot + +#endif diff --git a/esphome/components/snapshot/snapshot.cpp b/esphome/components/snapshot/snapshot.cpp index 1bf63f80b0..096ec48eb3 100644 --- a/esphome/components/snapshot/snapshot.cpp +++ b/esphome/components/snapshot/snapshot.cpp @@ -1,5 +1,8 @@ #ifdef USE_HOST #include "snapshot.h" +#include "gif.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" #include @@ -7,11 +10,13 @@ #include #include #include +#include #include #include #include #include #include +#include namespace esphome::snapshot { @@ -28,16 +33,19 @@ constexpr size_t BMP_HEADER_SIZE = 54; constexpr size_t BMP_INFO_HEADER_SIZE = 40; constexpr int BMP_BITS_PER_PIXEL = 24; -/// True if the name already ends in ".bmp". The comparison ignores case, so "shot.BMP" is left -/// alone rather than turned into "shot.BMP.bmp". -bool has_bmp_suffix(const std::string &name) { - return name.size() >= 4 && strcasecmp(name.c_str() + name.size() - 4, ".bmp") == 0; +// The largest width or height a GIF file can hold. +constexpr int GIF_MAX_SIZE = 0xFFFF; + +/// True if the name already ends in the extension, which is four characters including the dot. The +/// comparison ignores case, so "shot.BMP" is left alone rather than turned into "shot.BMP.bmp". +bool has_extension(const std::string &name, const char *extension) { + return name.size() >= 4 && strcasecmp(name.c_str() + name.size() - 4, extension) == 0; } /// Reduce a user supplied name to a single safe path component. Everything outside the allowed set /// is replaced, so "..", "/" and absolute paths cannot escape the snapshot directory. /// Returns an empty string if nothing usable is left. -std::string sanitise_filename(const char *const name, bool *name_changed) { +std::string sanitise_filename(const char *const name, const char *extension, bool *name_changed) { std::string result; bool all_dots = true; bool changed = false; @@ -59,8 +67,8 @@ std::string sanitise_filename(const char *const name, bool *name_changed) { *name_changed = true; return ""; } - if (!has_bmp_suffix(result)) - result += ".bmp"; + if (!has_extension(result, extension)) + result += extension; *name_changed = changed; return result; } @@ -89,15 +97,15 @@ void put_le(uint8_t *&dest, uint32_t value, size_t bytes) { *dest++ = static_cast(value >> (8 * i)); } -/// The number of bytes one row of `width` pixels takes up in the file. Rows are padded out to a -/// multiple of four bytes. -size_t bmp_row_size(int width) { return (static_cast(width) * 3 + 3) & ~size_t{3}; } +/// The number of bytes one row of `width` pixels takes up in a picture buffer. Rows are padded out +/// to a multiple of four bytes, which is also what a BMP file wants. +size_t padded_row_size(int width) { return (static_cast(width) * 3 + 3) & ~size_t{3}; } /// Write pixels out as a 24 bit BMP. The rows given start with the topmost and are `row_stride` /// bytes apart, which must leave room for a whole padded row; a BMP holds its rows the other way /// up, so they go out last first. bool write_bmp(FILE *file, const uint8_t *pixels, int width, int height, size_t row_stride) { - const size_t row_size = bmp_row_size(width); + const size_t row_size = padded_row_size(width); const size_t pixel_bytes = row_size * height; uint8_t header[BMP_HEADER_SIZE]; @@ -128,21 +136,19 @@ bool write_bmp(FILE *file, const uint8_t *pixels, int width, int height, size_t return true; } -/// Reserve a name in the snapshot directory and write the picture to it. +/// Reserve a name in the snapshot directory and open a new file under it. /// With `exact` set the given name is the only one tried; otherwise a number is added on -/// collision. Returns true if a file was written. -bool write_snapshot_file(const uint8_t *pixels, int width, int height, size_t row_stride, const std::string &name, - bool exact) { +/// collision. Returns nullptr, having logged why, if there is no file. `path` is where it is. +FILE *create_snapshot_file(const std::string &name, bool exact, std::string &path) { const std::string dir = snapshot_dir(); std::error_code ec; std::filesystem::create_directories(dir, ec); if (ec) { ESP_LOGE(TAG, "Could not create snapshot directory %s: %s", dir.c_str(), ec.message().c_str()); - return false; + return nullptr; } // O_EXCL guarantees we never write over a file that is already there. - std::string path; int fd = -1; for (unsigned attempt = 0; attempt < MAX_NAME_ATTEMPTS; attempt++) { path = dir + "/" + (attempt == 0 ? name : add_suffix(name, attempt)); @@ -151,18 +157,18 @@ bool write_snapshot_file(const uint8_t *pixels, int width, int height, size_t ro break; if (errno != EEXIST) { ESP_LOGE(TAG, "Could not create %s: %s", path.c_str(), strerror(errno)); - return false; + return nullptr; } if (exact) { // The caller asked for this exact name, so silently writing somewhere else would be worse // than failing - a test asserting on the path would pick up a stale file. ESP_LOGE(TAG, "Snapshot %s already exists, not overwriting", path.c_str()); - return false; + return nullptr; } } if (fd < 0) { ESP_LOGE(TAG, "Could not find an unused name for %s in %s", name.c_str(), dir.c_str()); - return false; + return nullptr; } FILE *file = fdopen(fd, "wb"); @@ -170,9 +176,14 @@ bool write_snapshot_file(const uint8_t *pixels, int width, int height, size_t ro ESP_LOGE(TAG, "Could not open %s: %s", path.c_str(), strerror(errno)); ::close(fd); ::unlink(path.c_str()); - return false; } - bool ok = write_bmp(file, pixels, width, height, row_stride); + return file; +} + +/// Close a file that is meant to be finished. `ok` says whether everything written to it went well. +/// If not, or if closing shows that the data did not all get out, say why and remove the file: a +/// truncated one would block a retry under the same name. Returns true if the file is complete. +bool close_snapshot_file(FILE *file, const std::string &path, bool ok) { int saved_errno = ok ? 0 : errno; // Closing can fail in its own right - the last of the data is still on its way out. if (fclose(file) != 0) { @@ -182,18 +193,111 @@ bool write_snapshot_file(const uint8_t *pixels, int width, int height, size_t ro } if (!ok) { ESP_LOGE(TAG, "Could not write %s: %s", path.c_str(), strerror(saved_errno)); - // Leave no truncated file behind - it would block a retry under the same name. ::unlink(path.c_str()); - return false; } + return ok; +} + +/// Write pixels to a new BMP file. See create_snapshot_file() for `name` and `exact`. +bool write_snapshot_file(const uint8_t *pixels, int width, int height, size_t row_stride, const std::string &name, + bool exact) { + std::string path; + FILE *file = create_snapshot_file(name, exact, path); + if (file == nullptr) + return false; + if (!close_snapshot_file(file, path, write_bmp(file, pixels, width, height, row_stride))) + return false; ESP_LOGI(TAG, "Snapshot written to %s", path.c_str()); return true; } +/// Work out the file name for a request. A name of the caller's own is made safe and is the only +/// one tried (`exact`); otherwise one is made up from the prefix and the current time. +std::string choose_name(const char *filename, const char *prefix, const char *extension, bool &exact) { + std::string name; + exact = false; + if (filename != nullptr) { + bool name_changed = false; + name = sanitise_filename(filename, extension, &name_changed); + exact = !name.empty(); + if (name_changed) { + ESP_LOGW(TAG, "Requested snapshot name '%s' is not an acceptable file name, using '%s' instead", filename, + name.empty() ? LOG_STR_LITERAL("a name made from the time") : name.c_str()); + } + } + if (!name.empty()) + return name; + + struct timespec now {}; + if (clock_gettime(CLOCK_REALTIME, &now) != 0) + now = {}; + struct tm tm_buf {}; + if (localtime_r(&now.tv_sec, &tm_buf) == nullptr) + tm_buf = {}; + char stamp[32]{}; + // ::strftime to be sure of the one from ; display has an unrelated member of that name + if (::strftime(stamp, sizeof(stamp), "%Y%m%d-%H%M%S", &tm_buf) == 0) + snprintf(stamp, sizeof(stamp), "unknown-time"); + char buffer[MAX_NAME_LENGTH]; + int written = snprintf(buffer, sizeof(buffer), "%s-%s-%03ld%s", prefix, stamp, now.tv_nsec / 1000000, extension); + if (written < 0 || static_cast(written) >= sizeof(buffer)) { + ESP_LOGW(TAG, "Could not build a timestamped snapshot name, using a fallback"); + snprintf(buffer, sizeof(buffer), "snapshot%s", extension); + } + return buffer; +} + } // namespace -void Snapshot::take_snapshot_or_log(const char *filename) { - if (!this->take_snapshot(filename[0] != '\0' ? filename : nullptr)) { +/// An animation being written: the file, and where in the timetable it has got to. +struct Snapshot::Recording { + Recording(FILE *file, std::string path, int width, int height, uint32_t frames, float frame_rate) + : file(file), + path(std::move(path)), + gif(file, width, height), + row_stride(padded_row_size(width)), + pixels(std::make_unique(this->row_stride * height)), + frames(frames), + frame_rate(frame_rate) {} + ~Recording() { + // The file is still open only if the recording did not finish, so do not leave a partial one behind. + if (this->file != nullptr) { + fclose(this->file); + ::unlink(this->path.c_str()); + } + } + + /// When the frame with this number is due, in milliseconds after the recording began. + uint32_t due_ms(uint32_t frame) const { + return static_cast(std::lround(frame * 1000.0 / this->frame_rate)); + } + /// How long to show the frame just taken, in hundredths of a second, as a GIF file counts time. + /// Each frame's time is worked out from the start rather than by rounding its own length, so a + /// rate that does not divide evenly into hundredths still averages out to what was asked for. + unsigned delay_centiseconds() const { + auto centiseconds_at = [this](uint32_t frame) { return std::lround(frame * 100.0 / this->frame_rate); }; + return static_cast(centiseconds_at(this->frames_done + 1) - centiseconds_at(this->frames_done)); + } + + FILE *file; + std::string path; + GifWriter gif; + size_t row_stride; + std::unique_ptr pixels; + uint32_t frames; + float frame_rate; + uint32_t frames_done{0}; + uint32_t start_ms{0}; +}; + +Snapshot::Snapshot() = default; +Snapshot::~Snapshot() = default; + +// helper function since ESP_LOGW is disallowed in a header file +void Snapshot::take_snapshot_or_log(const char *filename, uint32_t frames, float frame_rate) { + const char *name = filename[0] != '\0' ? filename : nullptr; + const bool ok = frames == 0 ? this->take_snapshot(name) : this->take_animation(name, frames, frame_rate); + if (!ok) { ESP_LOGW(TAG, "snapshot.take did not write a file"); } } @@ -206,46 +310,80 @@ bool Snapshot::take_snapshot(const char *filename) { return false; } - std::string name; - bool exact = false; - if (filename != nullptr) { - bool name_changed = false; - name = sanitise_filename(filename, &name_changed); - exact = !name.empty(); - if (name_changed) { - ESP_LOGW(TAG, "Requested snapshot name '%s' is not an acceptable file name, using '%s' instead", filename, - name.empty() ? LOG_STR_LITERAL("a name made from the time") : name.c_str()); - } - } - if (name.empty()) { - struct timespec now {}; - if (clock_gettime(CLOCK_REALTIME, &now) != 0) - now = {}; - struct tm tm_buf {}; - if (localtime_r(&now.tv_sec, &tm_buf) == nullptr) - tm_buf = {}; - char stamp[32]{}; - // ::strftime to be sure of the one from ; display has an unrelated member of that name - if (::strftime(stamp, sizeof(stamp), "%Y%m%d-%H%M%S", &tm_buf) == 0) - snprintf(stamp, sizeof(stamp), "unknown-time"); - char buffer[MAX_NAME_LENGTH]; - int written = - snprintf(buffer, sizeof(buffer), "%s-%s-%03ld.bmp", this->snapshot_prefix_, stamp, now.tv_nsec / 1000000); - if (written < 0 || static_cast(written) >= sizeof(buffer)) { - ESP_LOGW(TAG, "Could not build a timestamped snapshot name, using a fallback"); - snprintf(buffer, sizeof(buffer), "snapshot.bmp"); - } - name = buffer; - } + bool exact; + const std::string name = choose_name(filename, this->snapshot_prefix_, ".bmp", exact); // Rows are padded out to a multiple of four bytes, as the file wants them, so each one can be // written straight from the buffer. Zeroed on allocation, which is what the padding must be. - const size_t row_stride = bmp_row_size(width); + const size_t row_stride = padded_row_size(width); auto pixels = std::make_unique(row_stride * height); if (!this->capture_bgr(pixels.get(), row_stride)) return false; return write_snapshot_file(pixels.get(), width, height, row_stride, name, exact); } +bool Snapshot::take_animation(const char *filename, uint32_t frames, float frame_rate) { + if (this->recording_ != nullptr) { + ESP_LOGW(TAG, "Already recording %s, not starting another", this->recording_->path.c_str()); + return false; + } + if (frames == 0 || !(frame_rate > 0)) { + ESP_LOGE(TAG, "Animation requested with %u frames at %.1f frames a second", frames, frame_rate); + return false; + } + const int width = this->snapshot_width(); + const int height = this->snapshot_height(); + if (width <= 0 || height <= 0 || width > GIF_MAX_SIZE || height > GIF_MAX_SIZE) { + ESP_LOGE(TAG, "Animation requested but the display is %dx%d, and a GIF holds up to %d", width, height, + GIF_MAX_SIZE); + return false; + } + + bool exact; + const std::string name = choose_name(filename, this->snapshot_prefix_, ".gif", exact); + std::string path; + FILE *file = create_snapshot_file(name, exact, path); + if (file == nullptr) + return false; + auto recording = std::make_unique(file, path, width, height, frames, frame_rate); + if (!recording->gif.write_header()) { + recording->file = nullptr; + close_snapshot_file(file, path, false); + return false; + } + ESP_LOGI(TAG, "Recording %u frames at %.1f frames a second to %s", frames, frame_rate, path.c_str()); + this->recording_ = std::move(recording); + this->recording_->start_ms = millis(); + return this->record_frame_(); +} + +bool Snapshot::record_frame_() { + Recording &recording = *this->recording_; + if (!this->capture_bgr(recording.pixels.get(), recording.row_stride)) { + // capture_bgr() has said why. Resetting removes the half-finished file. + this->recording_.reset(); + return false; + } + + bool ok = recording.gif.write_frame(recording.pixels.get(), recording.row_stride, recording.delay_centiseconds()); + recording.frames_done++; + const bool finished = recording.frames_done == recording.frames; + if (ok && finished) + ok = recording.gif.write_trailer(); + if (!ok || finished) { + ok = close_snapshot_file(std::exchange(recording.file, nullptr), recording.path, ok); + if (ok) { + ESP_LOGI(TAG, "Animation written to %s", recording.path.c_str()); + } + this->recording_.reset(); + return ok; + } + + // Aim for the time each frame is due, not a fixed gap after the last, so delays do not add up. + const int32_t wait = static_cast(recording.start_ms + recording.due_ms(recording.frames_done) - millis()); + App.scheduler.set_timeout(this, wait > 0 ? static_cast(wait) : 0, [this]() { this->record_frame_(); }); + return true; +} + } // namespace esphome::snapshot #endif diff --git a/esphome/components/snapshot/snapshot.h b/esphome/components/snapshot/snapshot.h index 359d16536d..4d923f6475 100644 --- a/esphome/components/snapshot/snapshot.h +++ b/esphome/components/snapshot/snapshot.h @@ -4,6 +4,7 @@ #include #include +#include #include // Directory snapshots are written to. Normally set by codegen to a folder under .esphome; the @@ -17,11 +18,14 @@ namespace esphome::snapshot { /// Base for anything that can hand over the picture it is showing so it can be written to a file. /// /// A subclass says how big the picture is and fills in the pixels. Everything else - picking a -/// name, staying inside the snapshot directory, not writing over anything, and encoding the file - -/// is done here, so every component that can take a snapshot behaves the same way. +/// name, staying inside the snapshot directory, not writing over anything, encoding the file, and +/// timing the frames of an animation - is done here, so every component that can take a snapshot +/// behaves the same way. class Snapshot { public: - virtual ~Snapshot() = default; + // Declared here and defined where Recording is complete, which unique_ptr needs. + Snapshot(); + virtual ~Snapshot(); /// Set the word generated names start with. Codegen passes the component id, so with more than /// one display in a device it is clear which one a file came from. @@ -33,10 +37,20 @@ class Snapshot { /// already there is never written over. Returns true if a file was written. bool take_snapshot(const char *filename); + /// Record what is shown as an animated GIF file in the snapshot directory. + /// + /// The first frame is taken now and the rest follow at `frame_rate` frames a second, in the + /// background. Names work as for take_snapshot(), with ".gif" in place of ".bmp". Only one + /// recording can run at a time. Returns true if the recording started. + bool take_animation(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 nothing is logged. - void take_snapshot_or_log(const char *filename); - void take_snapshot_or_log(const std::string &filename) { this->take_snapshot_or_log(filename.c_str()); } + /// that wrote nothing is logged. A `frames` of 0 takes a single picture; otherwise an + /// animation is recorded. + void take_snapshot_or_log(const char *filename, uint32_t frames, float frame_rate); + void take_snapshot_or_log(const std::string &filename, uint32_t frames, float frame_rate) { + this->take_snapshot_or_log(filename.c_str(), frames, frame_rate); + } protected: /// Width of the picture in pixels. @@ -49,6 +63,15 @@ class Snapshot { virtual bool capture_bgr(uint8_t *dest, size_t row_stride) = 0; const char *snapshot_prefix_{"snapshot"}; + + private: + struct Recording; + + /// Add a frame to the recording and arrange for the next one. Returns false if the recording + /// had to be given up. + bool record_frame_(); + + std::unique_ptr recording_; }; } // namespace esphome::snapshot diff --git a/tests/components/snapshot/common.yaml b/tests/components/snapshot/common.yaml index 9ce2d33a87..e9c5943777 100644 --- a/tests/components/snapshot/common.yaml +++ b/tests/components/snapshot/common.yaml @@ -26,6 +26,11 @@ esphome: - snapshot.take: id: snapshot_rotated filename: !lambda 'return "rotated.bmp";' + - snapshot.take: + id: snapshot_display + filename: animation.gif + frames: 5 + frame_rate: 10fps interval: # A generated name has the time in it, so this one can repeat. diff --git a/tests/components/snapshot/snapshot_test.cpp b/tests/components/snapshot/snapshot_test.cpp new file mode 100644 index 0000000000..d63101357a --- /dev/null +++ b/tests/components/snapshot/snapshot_test.cpp @@ -0,0 +1,125 @@ +#ifdef USE_HOST +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "esphome/components/snapshot/snapshot.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" + +namespace esphome::snapshot::testing { +namespace fs = std::filesystem; + +/// A display of a fixed size that can be told to fail a capture. +class FakeSnapshot : public Snapshot { + public: + /// The capture with this number (counting from 1) fails. Zero means none do. + int fail_on{0}; + int captures{0}; + + protected: + int snapshot_width() override { return 8; } + int snapshot_height() override { return 4; } + bool capture_bgr(uint8_t *dest, size_t row_stride) override { + this->captures++; + if (this->captures == this->fail_on) + return false; + for (int y = 0; y != 4; y++) { + for (int x = 0; x != 8 * 3; x++) + dest[y * row_stride + x] = static_cast(x * 7 + y * 31 + this->captures); + } + return true; + } +}; + +class SnapshotAnimationTest : public ::testing::Test { + protected: + void SetUp() override { + this->dir_ = fs::temp_directory_path() / "esphome_snapshot_test"; + fs::remove_all(this->dir_); + fs::create_directories(this->dir_); + setenv("ESPHOME_SNAPSHOT_DIR", this->dir_.c_str(), 1); + // The test main does not construct App as generated code does, and recording needs its scheduler. + static const bool app_constructed = (new (&App) Application(), true); + (void) app_constructed; + App.pre_setup("test_snapshot", 10, "", 0); + } + void TearDown() override { + unsetenv("ESPHOME_SNAPSHOT_DIR"); + fs::remove_all(this->dir_); + } + + /// Names of the files in the snapshot directory, in order. + std::vector files() const { + std::vector names; + for (const auto &entry : fs::directory_iterator(this->dir_)) + names.push_back(entry.path().filename().string()); + std::sort(names.begin(), names.end()); + return names; + } + + /// Let the scheduler run for long enough that a fast recording has finished. + static void run_scheduler() { + for (int i = 0; i != 50; i++) { + App.scheduler.call(millis()); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + } + + fs::path dir_; +}; + +TEST_F(SnapshotAnimationTest, FailedFirstCaptureLeavesNoFile) { + FakeSnapshot display; + display.fail_on = 1; + EXPECT_FALSE(display.take_animation("first.gif", 3, 50.0f)); + EXPECT_TRUE(this->files().empty()); +} + +TEST_F(SnapshotAnimationTest, FailedCaptureMidRecordingRemovesPartialFile) { + FakeSnapshot display; + display.fail_on = 2; + ASSERT_TRUE(display.take_animation("partial.gif", 3, 50.0f)); + // The first frame is already in the file. + EXPECT_EQ(this->files(), std::vector{"partial.gif"}); + + run_scheduler(); + EXPECT_TRUE(this->files().empty()); + // The failure ends the recording, so the display can record again. + display.fail_on = 0; + EXPECT_TRUE(display.take_animation("again.gif", 1, 50.0f)); + EXPECT_EQ(this->files(), std::vector{"again.gif"}); +} + +TEST_F(SnapshotAnimationTest, SecondRecordingIsRefusedUntilTheFirstEnds) { + FakeSnapshot display; + ASSERT_TRUE(display.take_animation("one.gif", 3, 50.0f)); + EXPECT_FALSE(display.take_animation("two.gif", 3, 50.0f)); + EXPECT_EQ(this->files(), std::vector{"one.gif"}); + + run_scheduler(); + std::ifstream in(this->dir_ / "one.gif", std::ios::binary); + std::string data((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + ASSERT_FALSE(data.empty()); + EXPECT_EQ(data.back(), ';'); // the GIF trailer: the recording finished + + EXPECT_TRUE(display.take_animation("two.gif", 1, 50.0f)); + EXPECT_EQ(this->files(), (std::vector{"one.gif", "two.gif"})); +} + +TEST_F(SnapshotAnimationTest, RejectsNoFramesAndNoFrameRate) { + FakeSnapshot display; + EXPECT_FALSE(display.take_animation("none.gif", 0, 10.0f)); + EXPECT_FALSE(display.take_animation("none.gif", 3, 0.0f)); + EXPECT_TRUE(this->files().empty()); +} + +} // namespace esphome::snapshot::testing +#endif diff --git a/tests/integration/fixtures/snapshot_display.yaml b/tests/integration/fixtures/snapshot_display.yaml index d10af09806..894ad03d40 100644 --- a/tests/integration/fixtures/snapshot_display.yaml +++ b/tests/integration/fixtures/snapshot_display.yaml @@ -14,6 +14,47 @@ api: id: snapshot_display filename: !lambda return name; + - action: take_animation + variables: + name: string + then: + - snapshot.take: + id: snapshot_display + filename: !lambda return name; + frames: 5 + frame_rate: 20fps + + - action: take_noise_snapshot + variables: + name: string + then: + - snapshot.take: + id: noise_display + filename: !lambda return name; + + # A busy picture needs more compression codes than a GIF dictionary holds, so recording it makes + # the encoder widen its codes and start the dictionary over. + - action: take_noise_animation + variables: + name: string + then: + - snapshot.take: + id: noise_display + filename: !lambda return name; + frames: 3 + frame_rate: 20fps + + # Slow enough that a second request arrives while this one is still recording. + - action: take_slow_animation + variables: + name: string + then: + - snapshot.take: + id: snapshot_display + filename: !lambda return name; + frames: 3 + frame_rate: 2fps + logger: level: DEBUG @@ -26,3 +67,23 @@ display: dimensions: width: 101 height: 64 + + # Every pixel is one of 256 colours picked by a hash of its position, so the picture is + # effectively random but fits a GIF palette exactly. + - platform: snapshot + id: noise_display + update_interval: 1s + dimensions: + width: 200 + height: 150 + lambda: |- + for (int y = 0; y != it.get_height(); y++) { + for (int x = 0; x != it.get_width(); x++) { + uint32_t h = static_cast(x) * 73856093u ^ static_cast(y) * 19349663u; + h ^= h >> 13; + h *= 0x5bd1e995u; + h ^= h >> 15; + uint8_t c = h & 0xFF; + it.draw_pixel_at(x, y, Color(c & 0xE0, (c << 3) & 0xE0, (c << 6) & 0xC0)); + } + } diff --git a/tests/integration/test_snapshot_display.py b/tests/integration/test_snapshot_display.py index 771cf0cf7d..6f720ef30e 100644 --- a/tests/integration/test_snapshot_display.py +++ b/tests/integration/test_snapshot_display.py @@ -3,19 +3,66 @@ from __future__ import annotations import asyncio +import io from pathlib import Path from aioesphomeapi import LogLevel +from PIL import Image, UnidentifiedImageError import pytest -from .bmp_utils import capture_when_drawn, wait_for_bmp +from .bmp_utils import Bmp, capture_when_drawn, wait_for_bmp from .types import APIClientConnectedFactory, RunCompiledFunction WIDTH = 101 HEIGHT = 64 +ANIMATION_FRAMES = 5 +# The fixture asks for 20 frames a second, and a GIF counts time in milliseconds here. +ANIMATION_FRAME_MS = 50 + +NOISE_WIDTH = 200 +NOISE_HEIGHT = 150 +NOISE_FRAMES = 3 + # Part of the message the writer logs when it will not write over a file that is already there. REFUSAL_MESSAGE = b"not overwriting" +# Part of the message logged when an animation is asked for while another is still being recorded. +BUSY_MESSAGE = b"Already recording" + + +async def wait_for_gif(path: Path, frames: int, timeout: float = 5.0) -> Image.Image: + """Wait for a complete animated GIF with the given number of frames and return it. + + The file exists from the moment the recording starts and grows a frame at a time, so keep + reading until it holds all of them and ends with the GIF trailer. + """ + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while loop.time() < deadline: + try: + data = path.read_bytes() + # Open the bytes just read, so the trailer check and the decode see the same file. + image = Image.open(io.BytesIO(data)) + if data.endswith(b";") and image.n_frames == frames: + # Decoding every frame proves the compressed data is all there and valid. + for frame in range(frames): + image.seek(frame) + image.load() + image.seek(0) + return image + except (FileNotFoundError, UnidentifiedImageError, OSError, EOFError): + pass + await asyncio.sleep(0.05) + raise AssertionError( + f"no complete {frames} frame GIF appeared at {path} within {timeout}s" + ) + + +def bmp_as_image(bmp: Bmp) -> Image.Image: + """The picture in a decoded BMP, as an RGB image.""" + return Image.frombytes( + "RGB", (bmp.width, bmp.height), bmp.pixels, "raw", "BGR", 0, -1 + ) @pytest.mark.asyncio @@ -39,12 +86,50 @@ async def test_snapshot_display( async def take(name: str) -> None: await client.execute_service(service, {"name": name}) + animation_service = next(s for s in services if s.name == "take_animation") + # The test card is drawn in several colours, so once it is on the screen the picture is # not one flat shade. Capturing until that is true waits out the first update rather than # racing it. image, capture = await capture_when_drawn(take, snapshot_dir) assert (image.width, image.height, image.bits) == (WIDTH, HEIGHT, 24) + # Asking for frames records a GIF: every frame is the size of the display and lasts as long + # as the frame rate says. The test card does not move, so every frame is the picture + # captured above. + await client.execute_service(animation_service, {"name": "movie"}) + movie = await wait_for_gif(snapshot_dir / "movie.gif", ANIMATION_FRAMES) + assert movie.size == (WIDTH, HEIGHT) + expected = bmp_as_image(image) + for frame in range(ANIMATION_FRAMES): + movie.seek(frame) + assert movie.info["duration"] == ANIMATION_FRAME_MS + assert movie.convert("RGB").tobytes() == expected.tobytes() + + # A picture of 256 colours in no pattern makes a long run of compression codes, so recording + # it passes the point where the code width grows and where the dictionary is started over. + # The picture uses no more than 256 colours, so it is stored exactly and every frame must + # come back identical to the one captured as a BMP. + noise_service = next(s for s in services if s.name == "take_noise_snapshot") + noise_animation = next(s for s in services if s.name == "take_noise_animation") + + async def take_noise(name: str) -> None: + await client.execute_service(noise_service, {"name": name}) + + noise, _ = await capture_when_drawn(take_noise, snapshot_dir, "noisedrawn") + assert (noise.width, noise.height) == (NOISE_WIDTH, NOISE_HEIGHT) + noise_expected = bmp_as_image(noise) + assert ( + len(noise_expected.getcolors(NOISE_WIDTH * NOISE_HEIGHT)) > 200 + ) # a busy picture + await client.execute_service(noise_animation, {"name": "noise"}) + noise_movie = await wait_for_gif( + snapshot_dir / "noise.gif", NOISE_FRAMES, timeout=15.0 + ) + for frame in range(NOISE_FRAMES): + noise_movie.seek(frame) + assert noise_movie.convert("RGB").tobytes() == noise_expected.tobytes() + # An extension is only added when there is not one already, whatever its case. await take("UPPER.BMP") await wait_for_bmp(snapshot_dir / "UPPER.BMP") @@ -54,18 +139,30 @@ async def test_snapshot_display( await take("../escape") await wait_for_bmp(snapshot_dir / ".._escape.bmp") - # A second capture under a name already used must fail rather than write over the first. - # Wait for the device to report the refusal: on its own, an unchanged file cannot tell a - # refusal apart from a request the device has not got to yet, so a regression that wrote - # over the file could still pass on a busy machine. + # The device says so when it refuses a request. Waiting for that message is the only way to + # tell a refusal from a request the device has not got to yet: an unchanged file, or a file + # that never appears, would look the same on a busy machine. refused = asyncio.Event() + busy = asyncio.Event() def on_log(msg) -> None: if REFUSAL_MESSAGE in msg.message: refused.set() + if BUSY_MESSAGE in msg.message: + busy.set() client.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_DEBUG) + # A display records one animation at a time. A second request made while the first is still + # running is refused and leaves no file, and the first one carries on to the end. + slow_animation = next(s for s in services if s.name == "take_slow_animation") + await client.execute_service(slow_animation, {"name": "slow"}) + await client.execute_service(slow_animation, {"name": "second"}) + await asyncio.wait_for(busy.wait(), timeout=10.0) + assert not (snapshot_dir / "second.gif").exists() + await wait_for_gif(snapshot_dir / "slow.gif", 3) + + # A second capture under a name already used must fail rather than write over the first. before = capture.read_bytes() await take(capture.name) await asyncio.wait_for(refused.wait(), timeout=10.0) @@ -73,6 +170,12 @@ async def test_snapshot_display( # Nothing beyond what was asked for, leaving out however many captures it took to wait # for the first frame. written = sorted( - p.name for p in snapshot_dir.iterdir() if not p.name.startswith("drawn-") + p.name for p in snapshot_dir.iterdir() if "drawn-" not in p.name ) - assert written == [".._escape.bmp", "UPPER.BMP"] + assert written == [ + ".._escape.bmp", + "UPPER.BMP", + "movie.gif", + "noise.gif", + "slow.gif", + ]