mirror of
https://github.com/esphome/esphome.git
synced 2026-09-27 23:10:21 +00:00
[snapshot] Add feature to record animated gifs from screen (#19421)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
8132fbef52
commit
7d5117e570
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user