[snapshot] Add feature to record animated gifs from screen (#19421)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Clyde Stubbs
2026-09-26 20:18:15 +10:00
committed by GitHub
co-authored by Claude Sonnet 5
parent 8132fbef52
commit 7d5117e570
9 changed files with 888 additions and 77 deletions
+28 -6
View File
@@ -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),
)
+283
View File
@@ -0,0 +1,283 @@
#ifdef USE_HOST
#include "gif.h"
#include <algorithm>
#include <cstring>
#include <iterator>
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<uint16_t>(((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<uint8_t> &out, unsigned value) {
out.push_back(static_cast<uint8_t>(value & 0xFF));
out.push_back(static_cast<uint8_t>(value >> 8));
}
/// Packs codes of varying length into bytes, least significant bit first.
struct BitWriter {
std::vector<uint8_t> &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<uint8_t>(this->buffer & 0xFF));
this->buffer >>= 8;
this->count -= 8;
}
}
void flush() {
if (this->count > 0)
this->out.push_back(static_cast<uint8_t>(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<uint8_t> &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<Entry> 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<uint16_t>(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<size_t>(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<uint16_t>(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<Box> 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<uint8_t>(i);
}
// The average colour of the box, rounded to the nearest.
for (unsigned channel = 0; channel != 3; channel++)
palette[i * 3 + channel] = static_cast<uint8_t>((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<uint8_t>(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<uint8_t>(palette_bits));
std::vector<uint8_t> 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<uint8_t>(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
+51
View File
@@ -0,0 +1,51 @@
#pragma once
#ifdef USE_HOST
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <vector>
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<ColorBin> bins_;
std::vector<uint16_t> used_colors_;
std::vector<uint8_t> palette_index_;
std::vector<uint8_t> indices_;
std::vector<uint8_t> output_;
};
} // namespace esphome::snapshot
#endif
+196 -58
View File
@@ -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 <fcntl.h>
@@ -7,11 +10,13 @@
#include <unistd.h>
#include <cctype>
#include <cerrno>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <ctime>
#include <filesystem>
#include <memory>
#include <utility>
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<uint8_t>(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<size_t>(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<size_t>(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 <ctime>; 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<size_t>(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<uint8_t[]>(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<uint32_t>(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<unsigned>(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<uint8_t[]> 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 <ctime>; 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<size_t>(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<uint8_t[]>(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<Recording>(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<int32_t>(recording.start_ms + recording.due_ms(recording.frames_done) - millis());
App.scheduler.set_timeout(this, wait > 0 ? static_cast<uint32_t>(wait) : 0, [this]() { this->record_frame_(); });
return true;
}
} // namespace esphome::snapshot
#endif
+29 -6
View File
@@ -4,6 +4,7 @@
#include <cstddef>
#include <cstdint>
#include <memory>
#include <string>
// 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> recording_;
};
} // namespace esphome::snapshot
+5
View File
@@ -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.
+125
View File
@@ -0,0 +1,125 @@
#ifdef USE_HOST
#include <gtest/gtest.h>
#include <algorithm>
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <new>
#include <string>
#include <thread>
#include <vector>
#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<uint8_t>(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<std::string> files() const {
std::vector<std::string> 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<std::string>{"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<std::string>{"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<std::string>{"one.gif"});
run_scheduler();
std::ifstream in(this->dir_ / "one.gif", std::ios::binary);
std::string data((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
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<std::string>{"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
@@ -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<uint32_t>(x) * 73856093u ^ static_cast<uint32_t>(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));
}
}
+110 -7
View File
@@ -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",
]