A media card like a phone's now playing, with the album cover on a Guition (0.2.77)
The card behind a media tile shows the album cover, the title, the artist and the album, a progress bar that runs while the track plays with the times at its ends, round keys for previous, play or pause and next, and a volume row with a mute key, a wide slider and the percentage. Keys the player lacks stay faded, an off player shows one power key, a stream without a length shows no bar, and the CYD shows the player's icon in the cover's place. A media tile over the whole page is the same card under the tile's head. media_card.h holds the geometry, tests/test_media_card.cpp checks every shape. The cover comes the way camera images do: the screen asks with the size and the colour behind the corners, the app fetches the picture from Home Assistant, cuts it square, rounds the corners over that colour and serves it on port 8098; the Guition loads it once and asks again only when the picture's mark in the state changes. Firmware 0.2.64. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -51,7 +51,12 @@ also be changed on the screen or by an automation, and your own YAML for one scr
|
||||
<img src="docs/images/guition-blind.png" width="32%" alt="Cover card for a venetian blind: its battery, the position slider with the blind hanging from the top, the tilt slider over slats, and open, stop and close">
|
||||
<img src="docs/images/guition-history-touch.png" width="32%" alt="A finger on the history graph: the top of the card shows the average of that hour and its time, the graph stays as it is">
|
||||
</p>
|
||||
<p align="center"><sub>Tap a tile to switch it, hold it for the full card. Keys and sliders right on the tile, pastel colors, a clock, the weather and history you can read with a finger. Rendered from the firmware's own LVGL code with a demo home.</sub></p>
|
||||
<p align="center">
|
||||
<img src="docs/images/guition-media.png" width="32%" alt="The media card on the Guition: the album cover, the title, artist and album, a progress bar with the elapsed and total time, previous, pause and next keys and the volume slider">
|
||||
<img src="docs/images/guition-media-full.png" width="32%" alt="A media player over the whole page: the cover at the left, the track, the bar and the keys beside it, the volume row along the bottom">
|
||||
<img src="docs/images/guition-dark-media.png" width="32%" alt="The media card in dark mode: the same cover and keys on a black page">
|
||||
</p>
|
||||
<p align="center"><sub>Tap a tile to switch it, hold it for the full card. Keys and sliders right on the tile, pastel colors, a clock, the weather and history you can read with a finger. The media card shows what plays with its album cover, fetched by ESP Screens like a camera picture (<a href="docs/CAMERA.md">how</a>). Rendered from the firmware's own LVGL code with a demo home.</sub></p>
|
||||
<p align="center">
|
||||
<img src="docs/images/cyd-home.png" width="32%" alt="CYD 2.8-inch screen: the weather forecast, a kitchen timer, the coffee machine, a lamp and power usage as a large value">
|
||||
<img src="docs/images/cyd-page-2.png" width="32%" alt="Second CYD page: Sonos volume, presence, a scene and an energy graph">
|
||||
@@ -62,7 +67,12 @@ also be changed on the screen or by an automation, and your own YAML for one scr
|
||||
<img src="docs/images/cyd-vacuum.png" width="32%" alt="The vacuum card on the CYD: state, battery and charging, clean and dock, the cleaning mode, suction and water">
|
||||
<img src="docs/images/cyd-history.png" width="32%" alt="The history card on the CYD: power over 24 hours with its highest and lowest moment, an axis in watts and clock times">
|
||||
</p>
|
||||
<p align="center"><sub>The same cards on the 2.8-inch CYD, 320 × 240.</sub></p>
|
||||
<p align="center">
|
||||
<img src="docs/images/cyd-media.png" width="32%" alt="The media card on the CYD: the player's icon where the cover would be, the title, artist and album, the bar with its times, the keys and the volume slider">
|
||||
<img src="docs/images/cyd-media-full.png" width="32%" alt="A media player over the whole CYD page: the icon at the left, the track and the keys beside it, the volume row along the bottom">
|
||||
<img src="docs/images/cyd-dark-media.png" width="32%" alt="The media card on the CYD in dark mode">
|
||||
</p>
|
||||
<p align="center"><sub>The same cards on the 2.8-inch CYD, 320 × 240. The CYD has no memory for pictures: its media card shows the player's icon in the cover's place.</sub></p>
|
||||
<p align="center">
|
||||
<img src="docs/images/guition-dark-home.png" width="32%" alt="Dark mode on the Guition: the same home page with a black page, graphite cards and soft white text, the clock, the temperature graph, the weather, a lamp and presence">
|
||||
<img src="docs/images/guition-dark-controls.png" width="32%" alt="Dark mode with direct control: the heating setpoint, the dimmer's slider and the Sonos volume keep their colours">
|
||||
|
||||
@@ -21,11 +21,18 @@ struct Feed {
|
||||
std::string entity, url;
|
||||
uint32_t asked_at = 0, started_at = 0, finished_at = 0;
|
||||
bool asked = false, loading = false, shown = false, empty = false; // empty: the app said it has no image
|
||||
// once (firmware 0.2.64+): an album cover on the media card. It loads one time per link and stays; a new cover comes
|
||||
// with a new link (the card asks again when Home Assistant's picture changes), never on a clock.
|
||||
bool once = false, loaded = false; // loaded: this link's image is on screen
|
||||
uint8_t failures = 0;
|
||||
|
||||
void open(const std::string &camera) { *this = Feed{}; entity = camera; }
|
||||
void open(const std::string &camera, bool one_load = false) { *this = Feed{}; entity = camera; once = one_load; }
|
||||
bool open() const { return !entity.empty(); }
|
||||
bool should_ask(uint32_t now) const { return open() && !loading && url.empty() && (!asked || now - asked_at >= ASK_AGAIN_MS); }
|
||||
// A cover the app has none of (or an app that knows no covers) is asked for once: the card keeps its placeholder.
|
||||
bool should_ask(uint32_t now) const {
|
||||
if (once && empty) return false;
|
||||
return open() && !loading && url.empty() && (!asked || now - asked_at >= ASK_AGAIN_MS);
|
||||
}
|
||||
void ask(uint32_t now) { asked = true; asked_at = now; }
|
||||
// The app's answer: a link, or none (no image from Home Assistant, or a camera this screen may not show).
|
||||
void link(const std::string &address) {
|
||||
@@ -33,17 +40,19 @@ struct Feed {
|
||||
empty = address.empty();
|
||||
failures = 0;
|
||||
started_at = finished_at = 0;
|
||||
loaded = false;
|
||||
}
|
||||
bool should_load(uint32_t now) const {
|
||||
if (!open() || loading || url.empty()) return false;
|
||||
if (!started_at) return true;
|
||||
if (once) return !loaded && now - finished_at >= GAP_MS; // a failed load is tried again, a loaded one stays
|
||||
return now - started_at >= REFRESH_MS && now - finished_at >= GAP_MS;
|
||||
}
|
||||
void start(uint32_t now) { loading = true; started_at = now ? now : 1; }
|
||||
void finish(uint32_t now, bool ok) {
|
||||
loading = false;
|
||||
finished_at = now;
|
||||
if (ok) { shown = true; failures = 0; return; }
|
||||
if (ok) { shown = true; loaded = true; failures = 0; return; }
|
||||
if (++failures >= MAX_FAILURES) { url.clear(); asked = false; failures = 0; }
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
#pragma once
|
||||
// The media card (firmware 0.2.64+): where its parts go and what its texts say. Pure arithmetic, free of LVGL and
|
||||
// ESPHome, so tests/test_media_card.cpp checks every shape on a PC; runtime_tiles.h draws it.
|
||||
//
|
||||
// The card is a "now playing" view like a phone's: the album art (or a placeholder with the player's icon), the title,
|
||||
// the artist and the album, a progress bar with the elapsed and total time, three round keys (previous, play or pause,
|
||||
// next) and a volume row (a mute key, a slider, the percentage). Two forms share one recipe: a tall area (the Guition's
|
||||
// card, 480 wide under its top bar) stacks everything under the art; a wide area (the CYD's card, a tile over the whole
|
||||
// page) puts the art at the left with the texts, the bar and the keys beside it. The volume row always runs along the
|
||||
// bottom. What does not fit goes: first the artist line, then the times beside the bar, and the art shrinks last.
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
namespace media_card {
|
||||
struct Rect {
|
||||
int x = 0, y = 0, w = 0, h = 0;
|
||||
int cx() const { return x + w / 2; }
|
||||
int cy() const { return y + h / 2; }
|
||||
int right() const { return x + w; }
|
||||
int bottom() const { return y + h; }
|
||||
};
|
||||
// What the board brings: its size class and the line heights of the fonts the card writes with.
|
||||
struct Metrics {
|
||||
bool large = true; // the Guition: big keys and a big cover; the CYD gets the small numbers
|
||||
int title_h = 32; // the title's font (the card heading font)
|
||||
int artist_h = 25; // the artist line (the control font)
|
||||
int small_h = 19; // the times and the percentage (the small font)
|
||||
int key_h() const { return large ? 52 : 34; } // previous and next
|
||||
int play_h() const { return large ? 64 : 40; } // play or pause, the biggest key: a thumb finds it without looking
|
||||
int key_gap() const { return large ? 28 : 14; } // between the keys, less when the row has no room
|
||||
int min_gap() const { return large ? 12 : 8; }
|
||||
int mute_h() const { return large ? 32 : 22; }
|
||||
int slider_h() const { return large ? 20 : 12; }
|
||||
int bar_h() const { return large ? 6 : 4; }
|
||||
int gap() const { return large ? 12 : 6; }
|
||||
int margin() const { return large ? 24 : 10; }
|
||||
int max_art() const { return large ? 200 : 120; }
|
||||
int percent_w() const { return large ? 52 : 34; }
|
||||
int time_w() const { return large ? 48 : 34; } // "12:34" beside the bar
|
||||
};
|
||||
struct Layout {
|
||||
bool wide = false; // the art at the left, everything else beside it
|
||||
bool times = false; // the elapsed and total time at the ends of the bar
|
||||
bool artist = true; // the artist line (a tile over a CYD page has no room for it)
|
||||
Rect art, title, artist_line, bar, elapsed, total, prev, play, next, mute, volume, percent;
|
||||
int art_radius = 0; // the corner the art is rounded with (baked into the picture by the app)
|
||||
};
|
||||
|
||||
inline int radius_for(int art) { return std::max(4, art / 12); }
|
||||
|
||||
// The card's parts inside an area of `width` × `height` whose top left is (0, 0): the room under the top bar of a
|
||||
// card, or under the head of a tile over the whole page.
|
||||
inline Layout layout(const Metrics &m, int width, int height) {
|
||||
Layout l;
|
||||
const int g = m.gap(), margin = m.margin();
|
||||
// The volume row along the bottom, whatever the form.
|
||||
const int volume_h = std::max(m.mute_h(), m.slider_h());
|
||||
l.mute = {margin, height - volume_h + (volume_h - m.mute_h()) / 2, m.mute_h(), m.mute_h()};
|
||||
l.percent = {width - margin - m.percent_w(), height - volume_h + (volume_h - m.small_h) / 2, m.percent_w(), m.small_h};
|
||||
const int slider_x = l.mute.right() + g;
|
||||
l.volume = {slider_x, height - volume_h + (volume_h - m.slider_h()) / 2, std::max(1, l.percent.x - g - slider_x), m.slider_h()};
|
||||
const int above = height - volume_h - g; // room for the rest
|
||||
l.wide = width * 4 > height * 5;
|
||||
const int row_min = 2 * m.key_h() + m.play_h() + 2 * m.min_gap();
|
||||
// The bar's row: the elapsed time at the left, the total at the right, the bar between them, on one small line.
|
||||
auto bar_row = [&](int x, int w, int y, bool with_times) {
|
||||
const int tw = with_times && w >= 4 * m.time_w() ? m.time_w() : 0; // no room for times on a very narrow row
|
||||
l.times = tw > 0;
|
||||
const int row_h = l.times ? m.small_h : m.bar_h();
|
||||
l.elapsed = {x, y, tw, m.small_h};
|
||||
l.total = {x + w - tw, y, tw, m.small_h};
|
||||
const int bar_x = x + (tw ? tw + g / 2 : 0), bar_w = std::max(1, w - 2 * (tw ? tw + g / 2 : 0));
|
||||
l.bar = {bar_x, y + (row_h - m.bar_h()) / 2, bar_w, m.bar_h()};
|
||||
return row_h;
|
||||
};
|
||||
auto keys = [&](int x, int w, int y) {
|
||||
const int gap = std::clamp((w - 2 * m.key_h() - m.play_h()) / 2, m.min_gap(), m.key_gap());
|
||||
const int row_w = 2 * m.key_h() + m.play_h() + 2 * gap, row_x = x + (w - row_w) / 2;
|
||||
l.prev = {row_x, y + (m.play_h() - m.key_h()) / 2, m.key_h(), m.key_h()};
|
||||
l.play = {row_x + m.key_h() + gap, y, m.play_h(), m.play_h()};
|
||||
l.next = {l.play.right() + gap, l.prev.y, m.key_h(), m.key_h()};
|
||||
};
|
||||
if (!l.wide) {
|
||||
// Tall: the art on top, the texts, the bar and the keys under it, all centred.
|
||||
const int keys_y = above - m.play_h();
|
||||
const int stack = m.title_h + m.artist_h + g + m.small_h + g; // between the art and the keys: the bar row is one small line
|
||||
int art = std::min(m.max_art(), keys_y - g - stack - g);
|
||||
art = std::max(32, art);
|
||||
const int stack_y = keys_y - g - stack, text_w = width - 2 * margin;
|
||||
l.art = {(width - art) / 2, std::max(0, (stack_y - g - art) / 2), art, art};
|
||||
l.title = {margin, stack_y, text_w, m.title_h};
|
||||
l.artist_line = {margin, l.title.bottom(), text_w, m.artist_h};
|
||||
bar_row(margin, text_w, l.artist_line.bottom() + g, true);
|
||||
keys(0, width, keys_y);
|
||||
} else {
|
||||
// Wide: the art at the left, a column beside it, centred on the art and never taller than the room; the column's
|
||||
// own gaps are half the card's, so a tile's head leaves room for the artist line.
|
||||
int art = std::min({m.max_art(), above, width - 2 * margin - 2 * g - row_min});
|
||||
art = std::max(32, art);
|
||||
const int h = g / 2;
|
||||
int column = m.title_h + m.artist_h + h + m.small_h + h + m.play_h();
|
||||
l.artist = column <= above;
|
||||
if (!l.artist) column -= m.artist_h;
|
||||
bool with_times = column <= above;
|
||||
if (!with_times) column -= m.small_h - m.bar_h();
|
||||
l.art = {margin, std::max(0, (above - art) / 2), art, art};
|
||||
const int x = l.art.right() + 2 * g, w = std::max(1, width - margin - x);
|
||||
int y = std::clamp(l.art.y + (art - column) / 2, 0, std::max(0, above - column));
|
||||
l.title = {x, y, w, m.title_h}; y += m.title_h;
|
||||
if (l.artist) { l.artist_line = {x, y, w, m.artist_h}; y += m.artist_h; }
|
||||
y += h;
|
||||
y += bar_row(x, w, y, with_times);
|
||||
keys(x, w, y + h);
|
||||
}
|
||||
l.art_radius = radius_for(l.art.w);
|
||||
return l;
|
||||
}
|
||||
|
||||
// Home Assistant reports where the track was (media_position) and when it said so (media_position_updated_at);
|
||||
// while playing the position runs on from that moment.
|
||||
inline uint32_t elapsed_seconds(uint32_t position, uint32_t position_at, uint32_t now, bool playing, uint32_t duration) {
|
||||
uint32_t elapsed = position;
|
||||
if (playing && position_at && now > position_at) elapsed += now - position_at;
|
||||
if (duration && elapsed > duration) elapsed = duration;
|
||||
return elapsed;
|
||||
}
|
||||
// Per mille of the track, or -1 without a duration.
|
||||
inline int progress(uint32_t position, uint32_t position_at, uint32_t now, bool playing, uint32_t duration) {
|
||||
if (!duration) return -1;
|
||||
return static_cast<int>(elapsed_seconds(position, position_at, now, playing, duration) * 1000ULL / duration);
|
||||
}
|
||||
// "3:07", "1:02:45": the way every player writes a time.
|
||||
inline std::string clock_text(uint32_t seconds) {
|
||||
char b[24];
|
||||
if (seconds >= 3600) snprintf(b, sizeof(b), "%u:%02u:%02u", (unsigned) (seconds / 3600), (unsigned) (seconds / 60 % 60), (unsigned) (seconds % 60));
|
||||
else snprintf(b, sizeof(b), "%u:%02u", (unsigned) (seconds / 60), (unsigned) (seconds % 60));
|
||||
return b;
|
||||
}
|
||||
// The second line: "Artist · Album", or whichever of the two there is.
|
||||
inline std::string subtitle(const std::string &artist, const std::string &album) {
|
||||
if (!artist.empty() && !album.empty()) return artist + " · " + album;
|
||||
return artist.empty() ? album : artist;
|
||||
}
|
||||
// What the card says instead of a title when nothing plays.
|
||||
inline const char *idle_text(const std::string &state) {
|
||||
if (state == "off") return "Off";
|
||||
if (state == "standby") return "Standby";
|
||||
if (state == "buffering") return "Loading";
|
||||
if (state == "unavailable") return "Unavailable";
|
||||
return "Not playing";
|
||||
}
|
||||
inline bool playing(const std::string &state) { return state == "playing" || state == "buffering"; }
|
||||
inline bool has_track(const std::string &state) { return state == "playing" || state == "paused" || state == "buffering"; }
|
||||
} // namespace media_card
|
||||
@@ -95,6 +95,11 @@ struct Extra {
|
||||
std::string sunrise, sunset, duration, remaining;
|
||||
uint32_t timer_end = 0;
|
||||
std::string media_title;
|
||||
// The media card (firmware 0.2.64+, app 0.2.77+): the artist and the album, the track's length and where it was when
|
||||
// Home Assistant last said so (seconds, and that moment as an epoch), and a short mark of the cover picture, empty
|
||||
// when the player shows none. The mark changes with the picture: the card fetches a new cover when it does.
|
||||
std::string media_artist, media_album, media_picture;
|
||||
uint32_t media_duration = 0, media_position = 0, media_position_at = 0;
|
||||
// Vacuum: its own speeds (at most four) and speed, the mode, water and suction rows (see Choice), and
|
||||
// from sensors of its device the room it is in and whether it charges.
|
||||
std::vector<std::string> fan_speeds;
|
||||
@@ -115,7 +120,8 @@ struct Extra {
|
||||
return hvac_modes.empty() && fan_modes.empty() && swing_modes.empty() && fan_mode.empty() && swing_mode.empty() &&
|
||||
hvac_action.empty() && options.empty() && forecast.empty() && hours.empty() && std::isnan(wind) &&
|
||||
std::isnan(feels) && wind_unit.empty() && sunrise.empty() && sunset.empty() && duration.empty() &&
|
||||
remaining.empty() && !timer_end && media_title.empty() && fan_speeds.empty() && fan_speed.empty() &&
|
||||
remaining.empty() && !timer_end && media_title.empty() && media_artist.empty() && media_album.empty() &&
|
||||
media_picture.empty() && !media_duration && !media_position && !media_position_at && fan_speeds.empty() && fan_speed.empty() &&
|
||||
choices.empty() && room.empty() && !charging && std::isnan(tilt) && action.empty() && action_data.empty() &&
|
||||
action_templates.empty() && state_word.empty();
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include "tile_controls.h"
|
||||
#include "history_view.h"
|
||||
#include "camera_view.h"
|
||||
#include "media_card.h"
|
||||
#include "swipe_profile.h"
|
||||
#include "esphome/components/json/json_util.h"
|
||||
#include "esphome/components/api/api_server.h"
|
||||
@@ -66,6 +67,10 @@ inline bool awake() { return !screen_awake || screen_awake(); }
|
||||
inline uint32_t now_epoch() { if (!now_time) return 0; auto t = now_time(); return t.is_valid() ? static_cast<uint32_t>(t.timestamp) : 0; }
|
||||
inline void tick();
|
||||
inline void refresh_tile(size_t index);
|
||||
// Style setters that only touch a property when it changes (defined with the card renderers below).
|
||||
inline void set_color(lv_obj_t *obj, lv_style_prop_t prop, lv_color_t color, lv_style_selector_t selector = 0);
|
||||
inline void set_number(lv_obj_t *obj, lv_style_prop_t prop, int32_t number, lv_style_selector_t selector = 0);
|
||||
inline void set_font(lv_obj_t *obj, const lv_font_t *value);
|
||||
#ifdef SWIPE_PROFILE
|
||||
inline void swipe_test(unsigned count, unsigned interval_ms, unsigned back_ms, JsonObject tuning);
|
||||
#endif
|
||||
@@ -81,6 +86,15 @@ inline void history_received();
|
||||
inline void camera_open(const std::string &entity, const std::string &name);
|
||||
inline void camera_answer(const std::string &view, const std::string &entity, const std::string &url);
|
||||
inline bool camera_supported();
|
||||
// The media card's album cover (firmware 0.2.64+): the card or a tile over the whole page says which cover it shows,
|
||||
// the board's online_image loads it; see the end of this file.
|
||||
enum class CoverOwner : uint8_t { NONE, DETAIL, TILE };
|
||||
inline void cover_want(const std::string &entity, const std::string &picture, int size, uint32_t background, CoverOwner owner, size_t slot);
|
||||
inline lv_image_dsc_t *cover_ready(const std::string &entity, int size, uint32_t background);
|
||||
// The card's cover on screen, and the part a media tile keeps its cover in; both go with the image buffer.
|
||||
inline lv_obj_t *media_detail_picture = nullptr;
|
||||
constexpr unsigned MEDIA_PICTURE = 14;
|
||||
inline void media_action(Tile &t, int cmd);
|
||||
inline uint32_t domain_accent(const Tile &t);
|
||||
inline const char *icon_for(const Tile &tile);
|
||||
inline void label(lv_obj_t *obj, const std::string &text);
|
||||
@@ -183,6 +197,9 @@ struct Widgets {
|
||||
lv_obj_t *extra{}; std::string extra_mode; bool extra_full=false; std::array<lv_obj_t *, 36> parts{}; lv_point_precise_t *points{};
|
||||
// Analog clock: centre and radius of the dial, so the second hand can move without a card redraw.
|
||||
int hand_cx=0, hand_cy=0, hand_r=0, hand_width=1;
|
||||
// A media tile over the whole page (firmware 0.2.64+): the width of its progress bar, so the fill can run once a
|
||||
// second without a card redraw.
|
||||
int media_bar_w=0;
|
||||
// Soft area under a polyline (graph, sun path), painted by the extra container's draw event.
|
||||
const lv_point_precise_t *fill_points{}; unsigned fill_count=0; int fill_x=0, fill_y=0, fill_base=0; lv_color_t fill_color{}; lv_opa_t fill_opa=0;
|
||||
// Direct controls on a wide card: a panel at the right with pill keys, a -/+ pill,
|
||||
@@ -362,9 +379,10 @@ inline std::string receive(const std::string &payload) {
|
||||
}
|
||||
if (op == "camera") {
|
||||
// A link to a camera's image (app 0.2.66+): the answer to camera_request ("full"), or an alert's image ("alert",
|
||||
// announced with an empty link before show_alert and sent again with the link). Only ESP Screens' own port.
|
||||
// announced with an empty link before show_alert and sent again with the link), or the media card's cover
|
||||
// ("cover", app 0.2.77+). Only ESP Screens' own port.
|
||||
const std::string view = string(root["t"], 8), entity = string(root["e"], 120), url = string(root["u"], 240);
|
||||
if (!valid_entity(entity) || (view != "full" && view != "alert")) return false;
|
||||
if (!valid_entity(entity) || (view != "full" && view != "alert" && view != "cover")) return false;
|
||||
if (!url.empty() && url.rfind("http://", 0) != 0) return false;
|
||||
camera_answer(view, entity, url);
|
||||
result = model.ready() ? "Synced" : "Loading tiles";
|
||||
@@ -548,6 +566,12 @@ inline std::string receive(const std::string &payload) {
|
||||
tile.muted=a["is_volume_muted"].is<bool>() && a["is_volume_muted"].as<bool>();
|
||||
tile.device_class=string(a["device_class"],24);next.hvac_action=string(a["hvac_action"],24);
|
||||
next.media_title=string(a["media_title"],80);tile.supported=a["supported_features"].as<uint32_t>();
|
||||
// The media card (firmware 0.2.64+, app 0.2.77+): the artist, the album, the track's length and position, and a
|
||||
// mark of the cover picture; an app from before sends none of them and the card shows what it has.
|
||||
next.media_artist=string(extra["artist"],80);next.media_album=string(extra["album"],80);next.media_picture=string(extra["pic"],16);
|
||||
next.media_duration=extra["dur"].is<unsigned>()?extra["dur"].as<uint32_t>():0;
|
||||
next.media_position=extra["pos"].is<unsigned>()?extra["pos"].as<uint32_t>():0;
|
||||
next.media_position_at=extra["at"].is<unsigned>()?extra["at"].as<uint32_t>():0;
|
||||
tile.name = string(root["name"], 80);
|
||||
tile.state = string(root["state"], 160);
|
||||
tile.unit = string(a["unit_of_measurement"], 20);
|
||||
@@ -860,7 +884,8 @@ inline void slider_event(lv_event_t *e){
|
||||
if(code==LV_EVENT_PRESS_LOST && captured_slider==slider){captured_slider=nullptr;slider_changed=false;}
|
||||
if(code==LV_EVENT_RELEASED && captured_slider==slider){
|
||||
unsigned index=(uintptr_t)lv_event_get_user_data(e);
|
||||
for(auto &w:widgets)if(w.slider==slider || w.control_slider==slider){index=w.index;break;}
|
||||
// A slider among a card's own parts (the media tile's volume) belongs to the tile the slot shows now.
|
||||
for(auto &w:widgets)if(w.slider==slider || w.control_slider==slider || (w.extra && lv_obj_get_parent(slider)==w.extra)){index=w.index;break;}
|
||||
bool changed=slider_changed;captured_slider=nullptr;slider_changed=false;
|
||||
if(changed && cyd::touch_guard.accept_slider(esphome::millis(),200+index))commit_slider(index,lv_slider_get_value(slider));
|
||||
}
|
||||
@@ -876,37 +901,39 @@ inline void choose(Tile &t,Choice &row,const std::string &value){
|
||||
row.sent=value;t.begin(esphome::millis());
|
||||
lv_async_call([](void *){if(detail_root && !lv_obj_has_flag(detail_root,LV_OBJ_FLAG_HIDDEN))show_detail(detail_index);},nullptr);
|
||||
}
|
||||
// What a key on a card does; `cmd` is the key's command. Shared by the plain keys (detail_button) and the round
|
||||
// keys of the media card. -1 is Back.
|
||||
inline void detail_command(int cmd){
|
||||
if(cmd==-1){hide_detail();return;}
|
||||
// History ranges (firmware 0.2.51+): redraw after this event, which belongs to a key the redraw deletes.
|
||||
if(cmd>=160&&cmd<163){
|
||||
static const uint32_t hours[]={1,24,168};
|
||||
if(detail_index<model.count&&allowed(esphome::millis(),300+cmd,"history range")&&history_hours!=hours[cmd-160]){
|
||||
history_hours=hours[cmd-160];
|
||||
lv_async_call([](void *){if(detail_root&&!lv_obj_has_flag(detail_root,LV_OBJ_FLAG_HIDDEN))show_detail(detail_index);},nullptr);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if(!fresh()||detail_index>=model.count || !allowed(esphome::millis(),300+cmd,"card button "+model.tiles[detail_index].entity))return;
|
||||
auto &t=model.tiles[detail_index];if(!t.available()||t.waiting(esphome::millis()))return;
|
||||
if(cmd<4){const char *services[]={"vacuum.start","vacuum.pause","vacuum.return_to_base","vacuum.locate"};action(services[cmd],t.entity);}
|
||||
// Vacuum rows: 10-15 suction, 50-55 cleaning mode, 60-65 water.
|
||||
static const std::pair<int,char> rows[]={{10,'s'},{50,'m'},{60,'w'}};
|
||||
for(const auto &[first,kind]:rows)
|
||||
if(cmd>=first && cmd<first+6){auto *row=t.choice(kind);if(row && cmd-first<(int)row->values.size())choose(t,*row,row->values[cmd-first]);}
|
||||
// The media card's keys (20-23); its volume slider goes through commit_slider.
|
||||
if(cmd>=20 && cmd<=24)media_action(t,cmd);
|
||||
if(cmd>=30 && cmd<38 && cmd-30<(int)t.extra().options.size())action(t.domain()+".select_option",t.entity,"option",t.extra().options[cmd-30]);
|
||||
// Cover keys: 70 + tile_controls::Command (open, stop, close and the tilt keys).
|
||||
if(cmd>=70 && cmd<130){auto a=tile_controls::key_action(t,cmd-70);if(a.valid()&&t.domain()=="cover")action(a.service,t.entity,a.key,a.value);}
|
||||
if(cmd==40)action(t.state=="active"?"timer.pause":"timer.start",t.entity);
|
||||
if(cmd==41)action("timer.cancel",t.entity);
|
||||
}
|
||||
inline lv_obj_t *detail_button(const char *text,int x,int y,int width,int height,int command){
|
||||
auto *button=lv_obj_create(detail_root);lv_obj_remove_style_all(button);lv_obj_set_pos(button,x,y);lv_obj_set_size(button,width,height);
|
||||
lv_obj_set_style_bg_color(button,theme::color(command==0?theme::ACCENT:theme::BUTTON),0);lv_obj_set_style_bg_opa(button,LV_OPA_COVER,0);lv_obj_set_style_radius(button,12,0);lv_obj_add_flag(button,LV_OBJ_FLAG_CLICKABLE);
|
||||
auto *label=detail_label(button,text,6,0,width-12);lv_obj_center(label);lv_obj_set_style_text_align(label,LV_TEXT_ALIGN_CENTER,0);if(command==0)lv_obj_set_style_text_color(label,theme::color(theme::ON_ACCENT),0);lv_obj_remove_flag(label,LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_add_event_cb(button,[](lv_event_t *e){
|
||||
int cmd=(intptr_t)lv_event_get_user_data(e);if(cmd==-1){hide_detail();return;}
|
||||
// History ranges (firmware 0.2.51+): redraw after this event, which belongs to a key the redraw deletes.
|
||||
if(cmd>=160&&cmd<163){
|
||||
static const uint32_t hours[]={1,24,168};
|
||||
if(detail_index<model.count&&allowed(esphome::millis(),300+cmd,"history range")&&history_hours!=hours[cmd-160]){
|
||||
history_hours=hours[cmd-160];
|
||||
lv_async_call([](void *){if(detail_root&&!lv_obj_has_flag(detail_root,LV_OBJ_FLAG_HIDDEN))show_detail(detail_index);},nullptr);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if(!fresh()||detail_index>=model.count || !allowed(esphome::millis(),300+cmd,"card button "+model.tiles[detail_index].entity))return;
|
||||
auto &t=model.tiles[detail_index];if(!t.available()||t.waiting(esphome::millis()))return;
|
||||
if(cmd<4){const char *services[]={"vacuum.start","vacuum.pause","vacuum.return_to_base","vacuum.locate"};action(services[cmd],t.entity);}
|
||||
// Vacuum rows: 10-15 suction, 50-55 cleaning mode, 60-65 water.
|
||||
static const std::pair<int,char> rows[]={{10,'s'},{50,'m'},{60,'w'}};
|
||||
for(const auto &[first,kind]:rows)
|
||||
if(cmd>=first && cmd<first+6){auto *row=t.choice(kind);if(row && cmd-first<(int)row->values.size())choose(t,*row,row->values[cmd-first]);}
|
||||
if(cmd==20)action("media_player.media_play_pause",t.entity);
|
||||
if(cmd==21)action("media_player.media_previous_track",t.entity);
|
||||
if(cmd==22)action("media_player.media_next_track",t.entity);
|
||||
if(cmd>=30 && cmd<38 && cmd-30<(int)t.extra().options.size())action(t.domain()+".select_option",t.entity,"option",t.extra().options[cmd-30]);
|
||||
// Cover keys: 70 + tile_controls::Command (open, stop, close and the tilt keys).
|
||||
if(cmd>=70 && cmd<130){auto a=tile_controls::key_action(t,cmd-70);if(a.valid()&&t.domain()=="cover")action(a.service,t.entity,a.key,a.value);}
|
||||
if(cmd==40)action(t.state=="active"?"timer.pause":"timer.start",t.entity);
|
||||
if(cmd==41)action("timer.cancel",t.entity);
|
||||
},LV_EVENT_SHORT_CLICKED,(void*)(intptr_t)command);
|
||||
lv_obj_add_event_cb(button,[](lv_event_t *e){detail_command((intptr_t)lv_event_get_user_data(e));},LV_EVENT_SHORT_CLICKED,(void*)(intptr_t)command);
|
||||
lv_obj_set_style_bg_color(button,theme::color(theme::ACCENT_PRESSED),LV_STATE_PRESSED);
|
||||
lv_obj_set_style_transform_width(button,-2,LV_STATE_PRESSED);lv_obj_set_style_transform_height(button,-2,LV_STATE_PRESSED);
|
||||
lv_obj_set_style_opa(button,LV_OPA_50,LV_STATE_DISABLED);
|
||||
@@ -1730,6 +1757,172 @@ inline void render_history_detail(const Tile &t,bool large,int width,int height,
|
||||
}
|
||||
history_ranges(pad,range_y,width-2*pad,range_h);
|
||||
}
|
||||
// ---- The media card (firmware 0.2.64+) ----
|
||||
// "Now playing" as a phone shows it: the album cover (app 0.2.77+ serves it, a Guition draws it; the CYD keeps the
|
||||
// placeholder), the title, the artist and the album, a progress bar that runs while the track plays, round keys for
|
||||
// previous, play or pause and next, and the volume row. media_card.h decides where everything goes for a tall card and
|
||||
// a wide one; the same parts draw the card here and a tile over the whole page (render_media_full). The keys go
|
||||
// through detail_command like every card's, the slider through commit_slider.
|
||||
inline lv_obj_t *media_progress_fill=nullptr,*media_elapsed_label=nullptr; // the card's, moved by tick() once a second
|
||||
inline int media_bar_width=0;
|
||||
inline media_card::Rect media_art_rect; // where the card's cover goes once it is here
|
||||
inline uint32_t media_accent(){return theme::foreground(theme::ha::LIGHT_BLUE);}
|
||||
inline const lv_font_t *tile_icon_font(){for(auto &w:widgets)if(w.icon_font)return w.icon_font;return mini_icon_font?mini_icon_font:detail_font;}
|
||||
inline media_card::Metrics media_metrics(bool large){
|
||||
media_card::Metrics m;m.large=large;
|
||||
const lv_font_t *title=watch_font?watch_font:detail_font,*artist=control_font?control_font:detail_font,*small=small_font?small_font:detail_font;
|
||||
m.title_h=lv_font_get_line_height(title);m.artist_h=lv_font_get_line_height(artist);m.small_h=lv_font_get_line_height(small);
|
||||
return m;
|
||||
}
|
||||
// What the keys do, on the card and on a tile over the whole page: 20 play or pause, 21 previous, 22 next, 23 mute.
|
||||
inline void media_action(Tile &t,int cmd){
|
||||
if(cmd==20)action("media_player.media_play_pause",t.entity);
|
||||
if(cmd==21)action("media_player.media_previous_track",t.entity);
|
||||
if(cmd==22)action("media_player.media_next_track",t.entity);
|
||||
if(cmd==23)action("media_player.volume_mute",t.entity,"is_volume_muted",t.muted?"false":"true");
|
||||
if(cmd==24)action("media_player.turn_on",t.entity);
|
||||
}
|
||||
// An off or standby player shows one key: power, when the player can be turned on from here.
|
||||
inline bool media_off(const Tile &t){return t.state=="off" || t.state=="standby";}
|
||||
inline std::string media_volume_text(const Tile &t){
|
||||
if(t.muted)return "Muted";
|
||||
if(!std::isfinite(t.volume))return "";
|
||||
return std::to_string((int)std::lround(std::clamp(t.volume,0.0f,1.0f)*100))+" %";
|
||||
}
|
||||
// A rounded box without a style of its own: the art's placeholder, the bar's track and its fill.
|
||||
inline lv_obj_t *media_box(lv_obj_t *parent,lv_obj_t *existing,const media_card::Rect &r,uint32_t color,int radius){
|
||||
auto *o=existing;
|
||||
if(!o){o=lv_obj_create(parent);lv_obj_remove_style_all(o);lv_obj_set_style_bg_opa(o,LV_OPA_COVER,0);lv_obj_remove_flag(o,LV_OBJ_FLAG_CLICKABLE);lv_obj_remove_flag(o,LV_OBJ_FLAG_SCROLLABLE);}
|
||||
lv_obj_set_pos(o,r.x,r.y);lv_obj_set_size(o,std::max(1,r.w),std::max(1,r.h));
|
||||
set_color(o,LV_STYLE_BG_COLOR,theme::rgb(color));set_number(o,LV_STYLE_RADIUS,radius);
|
||||
return o;
|
||||
}
|
||||
// A round key: a glyph on a grey circle, the play key on the accent, the mute key bare beside the slider. Faded while
|
||||
// it cannot be used. `existing` keeps a tile's key across redraws; the card builds its keys anew each time.
|
||||
inline lv_obj_t *media_key(lv_obj_t *parent,lv_obj_t *existing,const media_card::Rect &r,const char *glyph,const lv_font_t *font,bool accent,bool bare,bool enabled,lv_event_cb_t cb,void *user){
|
||||
auto *key=existing;
|
||||
if(!key){
|
||||
key=lv_obj_create(parent);lv_obj_remove_style_all(key);
|
||||
lv_obj_set_style_radius(key,LV_RADIUS_CIRCLE,0);lv_obj_set_style_bg_opa(key,bare?LV_OPA_TRANSP:LV_OPA_COVER,0);lv_obj_set_style_bg_opa(key,LV_OPA_COVER,LV_STATE_PRESSED);
|
||||
lv_obj_set_style_opa(key,LV_OPA_40,LV_STATE_DISABLED);
|
||||
lv_obj_add_flag(key,LV_OBJ_FLAG_CLICKABLE);lv_obj_remove_flag(key,LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_ext_click_area(key,bare?10:6);
|
||||
auto *icon=lv_label_create(key);lv_obj_remove_flag(icon,LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_add_event_cb(key,cb,LV_EVENT_SHORT_CLICKED,user);
|
||||
}
|
||||
lv_obj_set_pos(key,r.x,r.y);lv_obj_set_size(key,r.w,r.h);
|
||||
set_color(key,LV_STYLE_BG_COLOR,theme::color(accent?theme::ACCENT:theme::KEY));
|
||||
set_color(key,LV_STYLE_BG_COLOR,theme::color(accent?theme::ACCENT_PRESSED:theme::KEY_PRESSED),LV_STATE_PRESSED);
|
||||
auto *icon=lv_obj_get_child(key,0);
|
||||
if(font)set_font(icon,font);
|
||||
set_color(icon,LV_STYLE_TEXT_COLOR,theme::color(accent?theme::ON_ACCENT:theme::INK));
|
||||
label(icon,glyph);lv_obj_center(icon);
|
||||
if(enabled)lv_obj_remove_state(key,LV_STATE_DISABLED);else lv_obj_add_state(key,LV_STATE_DISABLED);
|
||||
return key;
|
||||
}
|
||||
// The volume slider: the media colour on a pale track, a round white knob, the range every card slider has.
|
||||
inline lv_obj_t *media_slider(lv_obj_t *parent,lv_obj_t *existing,const media_card::Rect &r,const Tile &t,bool large,bool enabled,void *user){
|
||||
auto *s=existing;
|
||||
if(!s){
|
||||
s=lv_slider_create(parent);lv_obj_remove_style_all(s);lv_slider_set_range(s,0,1000);
|
||||
lv_obj_set_style_bg_opa(s,LV_OPA_COVER,LV_PART_MAIN);lv_obj_set_style_bg_opa(s,LV_OPA_COVER,LV_PART_INDICATOR);lv_obj_set_style_bg_opa(s,LV_OPA_COVER,LV_PART_KNOB);
|
||||
lv_obj_set_style_radius(s,LV_RADIUS_CIRCLE,LV_PART_MAIN);lv_obj_set_style_radius(s,LV_RADIUS_CIRCLE,LV_PART_INDICATOR);lv_obj_set_style_radius(s,LV_RADIUS_CIRCLE,LV_PART_KNOB);
|
||||
lv_obj_set_style_pad_all(s,large?4:3,LV_PART_KNOB);lv_obj_set_style_bg_opa(s,LV_OPA_80,(lv_style_selector_t)LV_PART_KNOB|(lv_style_selector_t)LV_STATE_PRESSED);
|
||||
lv_obj_set_style_opa(s,LV_OPA_40,LV_STATE_DISABLED);
|
||||
lv_obj_set_ext_click_area(s,large?12:8);
|
||||
lv_obj_add_event_cb(s,slider_event,LV_EVENT_ALL,user);
|
||||
}
|
||||
lv_obj_set_pos(s,r.x,r.y);lv_obj_set_size(s,std::max(1,r.w),r.h);
|
||||
set_color(s,LV_STYLE_BG_COLOR,theme::rgb(theme::tint(theme::ha::LIGHT_BLUE,64)),LV_PART_MAIN);
|
||||
set_color(s,LV_STYLE_BG_COLOR,theme::rgb(t.muted?theme::hex(theme::OFF):media_accent()),LV_PART_INDICATOR);
|
||||
set_color(s,LV_STYLE_BG_COLOR,theme::color(theme::SLIDER_KNOB),LV_PART_KNOB);
|
||||
if(!lv_obj_has_state(s,LV_STATE_PRESSED) && lv_slider_get_value(s)!=slider_value(t))lv_slider_set_value(s,slider_value(t),LV_ANIM_OFF);
|
||||
if(enabled){lv_obj_remove_state(s,LV_STATE_DISABLED);lv_obj_add_flag(s,LV_OBJ_FLAG_CLICKABLE);}
|
||||
else{lv_obj_add_state(s,LV_STATE_DISABLED);lv_obj_remove_flag(s,LV_OBJ_FLAG_CLICKABLE);}
|
||||
return s;
|
||||
}
|
||||
// The cover over its placeholder: LVGL's image widget exists only on a board whose profile draws images.
|
||||
inline lv_obj_t *media_picture_show(lv_obj_t *parent,lv_obj_t *existing,const media_card::Rect &r,lv_image_dsc_t *src){
|
||||
#if LV_USE_IMAGE
|
||||
if(!src || !src->data)return existing;
|
||||
auto *p=existing;
|
||||
if(!p){p=lv_image_create(parent);lv_obj_remove_flag(p,LV_OBJ_FLAG_CLICKABLE);}
|
||||
lv_image_set_src(p,src);lv_obj_set_pos(p,r.x,r.y);lv_obj_set_size(p,r.w,r.h);lv_obj_invalidate(p);
|
||||
return p;
|
||||
#else
|
||||
(void)parent;(void)r;(void)src;return existing;
|
||||
#endif
|
||||
}
|
||||
// The bar's fill and the elapsed time follow the track: once a second from tick(), without a redraw.
|
||||
inline void media_progress(const Tile &t,lv_obj_t *fill,lv_obj_t *elapsed,int bar_w){
|
||||
const auto &x=t.extra();
|
||||
if(!fill || !x.media_duration)return;
|
||||
const bool play=media_card::playing(t.state);
|
||||
const int p=media_card::progress(x.media_position,x.media_position_at,now_epoch(),play,x.media_duration);
|
||||
const int h=lv_obj_get_style_height(fill,LV_PART_MAIN);
|
||||
const int w=std::max(h,bar_w*std::max(0,p)/1000);
|
||||
if(lv_obj_get_style_width(fill,LV_PART_MAIN)!=w)lv_obj_set_width(fill,w);
|
||||
if(elapsed)label(elapsed,media_card::clock_text(media_card::elapsed_seconds(x.media_position,x.media_position_at,now_epoch(),play,x.media_duration)));
|
||||
}
|
||||
// The card: everything under the top bar, from `top` down.
|
||||
inline void render_media_detail(Tile &t,unsigned index,bool large,int width,int height,int top){
|
||||
using namespace media_card;
|
||||
using namespace tile_controls;
|
||||
const auto &x=t.extra();
|
||||
const Metrics m=media_metrics(large);
|
||||
const Layout l=layout(m,width,std::max(60,height-top-(large?12:6)));
|
||||
auto at=[&](Rect r){r.y+=top;return r;};
|
||||
const bool usable=fresh()&&t.available(),track=usable&&has_track(t.state),play=media_card::playing(t.state);
|
||||
const uint32_t f=t.supported;auto can=[&](uint32_t bit){return usable&&(!f||(f&bit));};
|
||||
// The cover, or its placeholder with the player's icon; the cover comes over it once the app served it.
|
||||
auto *frame=media_box(detail_root,nullptr,at(l.art),theme::tint(theme::ha::LIGHT_BLUE,51),l.art_radius);
|
||||
const std::string glyph=icon_for(t);
|
||||
const lv_font_t *placeholder_font=big_icon_font&&font_has(big_icon_font,glyph)?big_icon_font:tile_icon_font();
|
||||
auto *icon=lv_label_create(frame);lv_obj_remove_flag(icon,LV_OBJ_FLAG_CLICKABLE);lv_obj_set_style_text_font(icon,placeholder_font,0);
|
||||
lv_obj_set_style_text_color(icon,theme::rgb(theme::icon(theme::ha::LIGHT_BLUE)),0);lv_label_set_text(icon,glyph.c_str());lv_obj_center(icon);
|
||||
media_art_rect=at(l.art);media_detail_picture=nullptr;
|
||||
const uint32_t ground=theme::hex(theme::PAGE);
|
||||
if(camera_supported()&&track&&!x.media_picture.empty()){
|
||||
cover_want(t.entity,x.media_picture,l.art.w,ground,CoverOwner::DETAIL,0);
|
||||
media_detail_picture=media_picture_show(detail_root,nullptr,media_art_rect,cover_ready(t.entity,l.art.w,ground));
|
||||
}
|
||||
// Title, artist · album.
|
||||
const lv_font_t *title_font=watch_font?watch_font:detail_font,*artist_font=control_font?control_font:detail_font,*small=small_font?small_font:detail_font;
|
||||
const lv_text_align_t align=l.wide?LV_TEXT_ALIGN_LEFT:LV_TEXT_ALIGN_CENTER;
|
||||
detail_text(detail_root,track&&!x.media_title.empty()?x.media_title:std::string(idle_text(usable?t.state:"unavailable")),l.title.x,l.title.y+top,l.title.w,title_font,align,theme::INK);
|
||||
if(l.artist)detail_text(detail_root,track?subtitle(x.media_artist,x.media_album):std::string(),l.artist_line.x,l.artist_line.y+top,l.artist_line.w,artist_font,align,theme::MUTED);
|
||||
// The progress bar: the fill runs while the track plays; a stream without a length has no bar to show.
|
||||
media_progress_fill=nullptr;media_elapsed_label=nullptr;media_bar_width=l.bar.w;
|
||||
if(track && x.media_duration){
|
||||
media_box(detail_root,nullptr,at(l.bar),theme::hex(theme::TRACK),LV_RADIUS_CIRCLE);
|
||||
Rect fill=at(l.bar);fill.w=std::max(l.bar.h,l.bar.w*std::max(0,progress(x.media_position,x.media_position_at,now_epoch(),play,x.media_duration))/1000);
|
||||
media_progress_fill=media_box(detail_root,nullptr,fill,media_accent(),LV_RADIUS_CIRCLE);
|
||||
if(l.times){
|
||||
media_elapsed_label=detail_text(detail_root,clock_text(elapsed_seconds(x.media_position,x.media_position_at,now_epoch(),play,x.media_duration)),l.elapsed.x,l.elapsed.y+top,l.elapsed.w,small,LV_TEXT_ALIGN_LEFT,theme::SUBTLE);
|
||||
detail_text(detail_root,clock_text(x.media_duration),l.total.x,l.total.y+top,l.total.w,small,LV_TEXT_ALIGN_RIGHT,theme::SUBTLE);
|
||||
}
|
||||
}
|
||||
// The keys: previous, play or pause on the accent, next; the mute key bare at the start of the volume row. An off
|
||||
// player shows one power key instead, and no volume row: it reports no volume.
|
||||
auto cb=[](lv_event_t *e){detail_command((intptr_t)lv_event_get_user_data(e));};
|
||||
const lv_font_t *key_font=mini_icon_font?mini_icon_font:detail_font;
|
||||
std::vector<lv_obj_t *> keys;
|
||||
if(usable && media_off(t)){
|
||||
if(can(feature::MEDIA_TURN_ON))keys.push_back(media_key(detail_root,nullptr,at(l.play),glyph::POWER,tile_icon_font(),true,false,true,cb,(void*)(intptr_t)24));
|
||||
}else{
|
||||
keys={media_key(detail_root,nullptr,at(l.prev),glyph::PREVIOUS,key_font,false,false,can(feature::MEDIA_PREVIOUS),cb,(void*)(intptr_t)21),
|
||||
media_key(detail_root,nullptr,at(l.play),play?glyph::PAUSE:glyph::PLAY,tile_icon_font(),true,false,can(feature::MEDIA_PLAY|feature::MEDIA_PAUSE),cb,(void*)(intptr_t)20),
|
||||
media_key(detail_root,nullptr,at(l.next),glyph::NEXT,key_font,false,false,can(feature::MEDIA_NEXT),cb,(void*)(intptr_t)22)};
|
||||
if(std::isfinite(t.volume)){
|
||||
keys.push_back(media_key(detail_root,nullptr,at(l.mute),t.muted?glyph::MUTED:glyph::VOLUME,key_font,false,true,can(feature::MEDIA_VOLUME_MUTE),cb,(void*)(intptr_t)23));
|
||||
media_slider(detail_root,nullptr,at(l.volume),t,large,can(feature::MEDIA_VOLUME_SET),(void*)(uintptr_t)index);
|
||||
detail_text(detail_root,media_volume_text(t),l.percent.x,l.percent.y+top,l.percent.w,small,LV_TEXT_ALIGN_RIGHT,theme::MUTED);
|
||||
}
|
||||
}
|
||||
// Only the keys the player supports join the card's actions: tick() enables those again after a wait, and a key
|
||||
// the player lacks stays faded.
|
||||
for(auto *k:keys)if(!lv_obj_has_state(k,LV_STATE_DISABLED) && detail_action_count<32)detail_actions[detail_action_count++]=k;
|
||||
}
|
||||
inline void show_detail(unsigned index){
|
||||
if(index>=model.count)return;
|
||||
// A card that opens starts on a day (an hour for a tile whose graph shows one); switching ranges keeps it open.
|
||||
@@ -1739,7 +1932,8 @@ inline void show_detail(unsigned index){
|
||||
detail_index=index;auto &t=model.tiles[index];
|
||||
if(!detail_font)detail_font=lv_obj_get_style_text_font(widgets[0].title,LV_PART_MAIN);
|
||||
if(!detail_root){detail_root=lv_obj_create(lv_screen_active());lv_obj_remove_style_all(detail_root);lv_obj_set_size(detail_root,lv_pct(100),lv_pct(100));lv_obj_remove_flag(detail_root,LV_OBJ_FLAG_SCROLLABLE);}
|
||||
detail_action_count=0;detail_status=nullptr;detail_badge_status=nullptr;detail_switch=nullptr;history_forget();lv_obj_clean(detail_root);lv_obj_remove_flag(detail_root,LV_OBJ_FLAG_HIDDEN);lv_obj_move_foreground(detail_root);
|
||||
detail_action_count=0;detail_status=nullptr;detail_badge_status=nullptr;detail_switch=nullptr;history_forget();
|
||||
media_progress_fill=nullptr;media_elapsed_label=nullptr;media_detail_picture=nullptr;lv_obj_clean(detail_root);lv_obj_remove_flag(detail_root,LV_OBJ_FLAG_HIDDEN);lv_obj_move_foreground(detail_root);
|
||||
lv_obj_set_style_bg_color(detail_root,theme::color(theme::PAGE),0);lv_obj_set_style_bg_opa(detail_root,LV_OPA_COVER,0);
|
||||
int width=lv_display_get_horizontal_resolution(lv_display_get_default()), height=lv_display_get_vertical_resolution(lv_display_get_default());
|
||||
bool large=width>=480;int pad=large?20:10, top=large?100:62, gap=large?12:6,bh=large?58:34,cw=(width-pad*2-gap)/2;
|
||||
@@ -1754,7 +1948,7 @@ inline void show_detail(unsigned index){
|
||||
std::string state=d=="cover"?cover_status_line(t):detail_state(t);
|
||||
// The vacuum and history cards draw their own state.
|
||||
const bool with_history=history_card(t);
|
||||
if(d!="vacuum"&&!with_history){detail_status=detail_label(detail_root,state+(t.unit.empty()?"":" "+t.unit),pad,large?80:50,width-2*pad);lv_obj_set_style_text_align(detail_status,LV_TEXT_ALIGN_CENTER,0);lv_obj_set_style_text_color(detail_status,theme::color(theme::MUTED),0);}
|
||||
if(d!="vacuum"&&d!="media_player"&&!with_history){detail_status=detail_label(detail_root,state+(t.unit.empty()?"":" "+t.unit),pad,large?80:50,width-2*pad);lv_obj_set_style_text_align(detail_status,LV_TEXT_ALIGN_CENTER,0);lv_obj_set_style_text_color(detail_status,theme::color(theme::MUTED),0);}
|
||||
if(with_history){
|
||||
render_history_detail(t,large,width,height,pad);
|
||||
}else if(d=="vacuum"){
|
||||
@@ -1766,12 +1960,8 @@ inline void show_detail(unsigned index){
|
||||
const auto &options=t.extra().options;
|
||||
for(unsigned i=0;i<options.size();++i)detail_button(options[i].c_str(),pad+(i%2)*(cw+gap),top+(i/2)*(bh+gap),cw,bh,30+i);
|
||||
}else if(d=="media_player"){
|
||||
detail_label(detail_root,t.extra().media_title,pad,top,width-2*pad);top+=large?45:25;
|
||||
int w=(width-pad*2-2*gap)/3;
|
||||
detail_button("Previous",pad,top,w,bh,21);detail_button("Play/pause",pad+w+gap,top,w,bh,20);detail_button("Next",pad+2*(w+gap),top,w,bh,22);top+=bh+gap;
|
||||
detail_label(detail_root,"Volume",pad,top,width-2*pad);
|
||||
auto *slider=lv_slider_create(detail_root);lv_obj_set_pos(slider,pad+12,top+(large?52:34));lv_obj_set_size(slider,width-2*pad-24,large?24:16);lv_slider_set_range(slider,0,1000);lv_slider_set_value(slider,slider_value(t),LV_ANIM_OFF);lv_obj_set_style_bg_color(slider,theme::color(theme::SLIDER_KNOB),LV_PART_KNOB);
|
||||
lv_obj_add_event_cb(slider,slider_event,LV_EVENT_ALL,(void*)(uintptr_t)index);
|
||||
// "Now playing" (firmware 0.2.64+): the cover, the track, a running progress bar, round keys and the volume row.
|
||||
render_media_detail(t,index,large,width,height,bar_y+bar+(large?8:4));
|
||||
}else if(d=="weather"){
|
||||
render_weather_detail(t,large,width,height,pad);
|
||||
}else if(d=="timer"){
|
||||
@@ -1949,13 +2139,13 @@ inline void event(lv_event_t *event) {
|
||||
}
|
||||
// The same guard for any local style: a page switch mostly hands a slot the colours it already has,
|
||||
// and each real set refreshes the style and invalidates the object (about 0.3 ms on the Guition).
|
||||
inline void set_color(lv_obj_t *obj, lv_style_prop_t prop, lv_color_t color, lv_style_selector_t selector = 0) {
|
||||
inline void set_color(lv_obj_t *obj, lv_style_prop_t prop, lv_color_t color, lv_style_selector_t selector) {
|
||||
lv_style_value_t current;
|
||||
if (lv_obj_get_local_style_prop(obj, prop, ¤t, selector) == LV_STYLE_RES_FOUND && lv_color_eq(current.color, color)) return;
|
||||
lv_style_value_t value{}; value.color = color;
|
||||
lv_obj_set_local_style_prop(obj, prop, value, selector);
|
||||
}
|
||||
inline void set_number(lv_obj_t *obj, lv_style_prop_t prop, int32_t number, lv_style_selector_t selector = 0) {
|
||||
inline void set_number(lv_obj_t *obj, lv_style_prop_t prop, int32_t number, lv_style_selector_t selector) {
|
||||
lv_style_value_t current;
|
||||
if (lv_obj_get_local_style_prop(obj, prop, ¤t, selector) == LV_STYLE_RES_FOUND && current.num == number) return;
|
||||
lv_style_value_t value{}; value.num = number;
|
||||
@@ -2596,6 +2786,91 @@ inline void set_busy(Widgets &w,bool busy,bool large){
|
||||
// small slider, direct controls or a graph the double-width card's head stays on top and the control takes
|
||||
// a strip at the bottom, so a tap anywhere else still does what a tap on the tile does. The built-in cards
|
||||
// (clock, forecast, sun path) simply get the whole page.
|
||||
// A media player over the whole page (firmware 0.2.64+): the head as on every full card, and under it the media card
|
||||
// itself, wide: the cover at the left, the track, the bar and the keys beside it, the volume row along the bottom.
|
||||
// Parts: 0 placeholder, 1 its icon, 2 title, 3 artist, 4 track, 5 fill, 6 elapsed, 7 total, 8-10 keys, 11 mute,
|
||||
// 12 slider, 13 percent, MEDIA_PICTURE (14) the cover. Built once per slot and moved on every redraw.
|
||||
inline void media_tile_key_event(lv_event_t *e){
|
||||
unsigned code=(uintptr_t)lv_event_get_user_data(e);unsigned slot=code/16,n=code%16;
|
||||
if(slot>=widgets.size() || n>3)return;
|
||||
auto &w=widgets[slot];
|
||||
if(!enabled || !fresh() || w.index>=model.count || w.extra_mode!="media")return;
|
||||
if(lv_obj_has_state(lv_event_get_target_obj(e),LV_STATE_DISABLED))return;
|
||||
const uint32_t now=esphome::millis();
|
||||
if(!allowed(now,500+slot*16+n,"media key "+std::to_string(slot)))return;
|
||||
auto &t=model.tiles[w.index];
|
||||
if(!t.available() || t.waiting(now))return;
|
||||
static const int commands[]={21,20,22,23};
|
||||
media_action(t,n==1 && media_off(t)?24:commands[n]);
|
||||
}
|
||||
inline void render_media_full(Widgets &w,const Tile &t,bool big,int content_w,int content_h,int head_h){
|
||||
using namespace media_card;
|
||||
using namespace tile_controls;
|
||||
const auto &x=t.extra();
|
||||
const Metrics m=media_metrics(big);
|
||||
const int top=head_h+(big?8:4);
|
||||
const Layout l=layout(m,content_w,std::max(40,content_h-top-(big?4:2)));
|
||||
begin_extra(w,"media",content_w,content_h);
|
||||
auto at=[&](Rect r){r.y+=top;return r;};
|
||||
const bool usable=fresh()&&t.available(),track=usable&&has_track(t.state),play=media_card::playing(t.state);
|
||||
const uint32_t f=t.supported;auto can=[&](uint32_t bit){return usable&&(!f||(f&bit));};
|
||||
const size_t slot=&w-widgets.data();
|
||||
// The placeholder and the player's icon; the cover comes over them once the app served it.
|
||||
w.parts[0]=media_box(w.extra,w.parts[0],at(l.art),theme::tint(theme::ha::LIGHT_BLUE,51),l.art_radius);
|
||||
const std::string glyph=icon_for(t);
|
||||
const lv_font_t *placeholder_font=big_icon_font&&font_has(big_icon_font,glyph)?big_icon_font:w.icon_font;
|
||||
if(!w.parts[1]){w.parts[1]=lv_label_create(w.parts[0]);lv_obj_remove_flag(w.parts[1],LV_OBJ_FLAG_CLICKABLE);}
|
||||
set_font(w.parts[1],placeholder_font);set_color(w.parts[1],LV_STYLE_TEXT_COLOR,theme::rgb(theme::icon(theme::ha::LIGHT_BLUE)));label(w.parts[1],glyph);lv_obj_center(w.parts[1]);
|
||||
const uint32_t ground=theme::of(lv_obj_get_style_bg_color(w.tile,LV_PART_MAIN));
|
||||
lv_image_dsc_t *src=nullptr;
|
||||
// A card open over the page owns the cover then; the tile asks again once the card closes (cover_tick).
|
||||
const bool card_open=detail_root && !lv_obj_has_flag(detail_root,LV_OBJ_FLAG_HIDDEN);
|
||||
if(camera_supported()&&track&&!x.media_picture.empty()&&!card_open){cover_want(t.entity,x.media_picture,l.art.w,ground,CoverOwner::TILE,slot);src=cover_ready(t.entity,l.art.w,ground);}
|
||||
if(src)w.parts[MEDIA_PICTURE]=media_picture_show(w.extra,w.parts[MEDIA_PICTURE],at(l.art),src);
|
||||
else if(w.parts[MEDIA_PICTURE]){lv_obj_delete(w.parts[MEDIA_PICTURE]);w.parts[MEDIA_PICTURE]=nullptr;}
|
||||
// Title, artist · album, left-aligned beside the cover.
|
||||
const lv_font_t *title_font=watch_font?watch_font:w.title_font,*artist_font=control_font?control_font:w.title_font,*small=small_font?small_font:w.title_font;
|
||||
auto text=[&](unsigned i,const lv_font_t *font,const Rect &r,lv_text_align_t align,const std::string &value,theme::Role role){
|
||||
auto *p=part_label(w,i,font,r.x,r.y+top,r.w,align,value);lv_label_set_long_mode(p,LV_LABEL_LONG_DOT);set_color(p,LV_STYLE_TEXT_COLOR,theme::color(role));lv_obj_remove_flag(p,LV_OBJ_FLAG_HIDDEN);return p;
|
||||
};
|
||||
text(2,title_font,l.title,LV_TEXT_ALIGN_LEFT,track&&!x.media_title.empty()?x.media_title:std::string(idle_text(usable?t.state:"unavailable")),theme::INK);
|
||||
if(l.artist)text(3,artist_font,l.artist_line,LV_TEXT_ALIGN_LEFT,track?subtitle(x.media_artist,x.media_album):std::string(),theme::MUTED);
|
||||
else if(w.parts[3])lv_obj_add_flag(w.parts[3],LV_OBJ_FLAG_HIDDEN);
|
||||
// The progress bar and its times; a stream without a length has none.
|
||||
w.media_bar_w=l.bar.w;
|
||||
const bool timed=track&&x.media_duration;
|
||||
if(timed){
|
||||
w.parts[4]=media_box(w.extra,w.parts[4],at(l.bar),theme::hex(theme::TRACK),LV_RADIUS_CIRCLE);lv_obj_remove_flag(w.parts[4],LV_OBJ_FLAG_HIDDEN);
|
||||
Rect fill=at(l.bar);fill.w=std::max(l.bar.h,l.bar.w*std::max(0,progress(x.media_position,x.media_position_at,now_epoch(),play,x.media_duration))/1000);
|
||||
w.parts[5]=media_box(w.extra,w.parts[5],fill,media_accent(),LV_RADIUS_CIRCLE);lv_obj_remove_flag(w.parts[5],LV_OBJ_FLAG_HIDDEN);
|
||||
}else for(unsigned i:{4u,5u})if(w.parts[i])lv_obj_add_flag(w.parts[i],LV_OBJ_FLAG_HIDDEN);
|
||||
if(timed&&l.times){
|
||||
text(6,small,l.elapsed,LV_TEXT_ALIGN_LEFT,clock_text(elapsed_seconds(x.media_position,x.media_position_at,now_epoch(),play,x.media_duration)),theme::SUBTLE);
|
||||
text(7,small,l.total,LV_TEXT_ALIGN_RIGHT,clock_text(x.media_duration),theme::SUBTLE);
|
||||
}else for(unsigned i:{6u,7u})if(w.parts[i])lv_obj_add_flag(w.parts[i],LV_OBJ_FLAG_HIDDEN);
|
||||
// The keys and the volume row. Their events carry the slot: the tile in it may change with the page. An off player
|
||||
// shows one power key and no volume row.
|
||||
const lv_font_t *key_font=mini_icon_font?mini_icon_font:w.icon_font;
|
||||
auto user=[&](unsigned n){return (void*)(uintptr_t)(slot*16+n);};
|
||||
auto show=[&](unsigned i,bool on){if(w.parts[i]){if(on)lv_obj_remove_flag(w.parts[i],LV_OBJ_FLAG_HIDDEN);else lv_obj_add_flag(w.parts[i],LV_OBJ_FLAG_HIDDEN);}};
|
||||
const bool off=usable && media_off(t), volume=!off && std::isfinite(t.volume);
|
||||
if(off){
|
||||
w.parts[9]=media_key(w.extra,w.parts[9],at(l.play),glyph::POWER,w.icon_font,true,false,can(feature::MEDIA_TURN_ON),media_tile_key_event,user(1));
|
||||
show(9,can(feature::MEDIA_TURN_ON));
|
||||
}else{
|
||||
w.parts[8]=media_key(w.extra,w.parts[8],at(l.prev),glyph::PREVIOUS,key_font,false,false,can(feature::MEDIA_PREVIOUS),media_tile_key_event,user(0));
|
||||
w.parts[9]=media_key(w.extra,w.parts[9],at(l.play),play?glyph::PAUSE:glyph::PLAY,w.icon_font,true,false,can(feature::MEDIA_PLAY|feature::MEDIA_PAUSE),media_tile_key_event,user(1));
|
||||
w.parts[10]=media_key(w.extra,w.parts[10],at(l.next),glyph::NEXT,key_font,false,false,can(feature::MEDIA_NEXT),media_tile_key_event,user(2));
|
||||
show(9,true);
|
||||
}
|
||||
show(8,!off);show(10,!off);
|
||||
if(volume){
|
||||
w.parts[11]=media_key(w.extra,w.parts[11],at(l.mute),t.muted?glyph::MUTED:glyph::VOLUME,key_font,false,true,can(feature::MEDIA_VOLUME_MUTE),media_tile_key_event,user(3));
|
||||
w.parts[12]=media_slider(w.extra,w.parts[12],at(l.volume),t,big,can(feature::MEDIA_VOLUME_SET),(void*)(uintptr_t)w.index);
|
||||
text(13,small,l.percent,LV_TEXT_ALIGN_RIGHT,media_volume_text(t),theme::MUTED);
|
||||
}
|
||||
show(11,volume);show(12,volume);show(13,volume);
|
||||
}
|
||||
inline void render_full(Widgets &w,const Tile &t,bool custom,bool clock,bool sunpath,bool graph,bool mini,bool with_panel,bool large,
|
||||
const std::string &value,const std::string &unit,int content_w,int content_h) {
|
||||
const bool big=w.base_height>80; // the board: a Guition card is large, a CYD card small
|
||||
@@ -2610,7 +2885,9 @@ inline void render_full(Widgets &w,const Tile &t,bool custom,bool clock,bool sun
|
||||
}
|
||||
int value_h=lv_font_get_line_height(lv_obj_get_style_text_font(w.value,LV_PART_MAIN));
|
||||
int gap=big?12:6;
|
||||
if(!mini && !with_panel && !graph){
|
||||
// A media player that answers gets the media card under its head (firmware 0.2.64+); unavailable, it is the plain card.
|
||||
const bool media=t.domain()=="media_player" && fresh() && t.available();
|
||||
if(!mini && !with_panel && !graph && !media){
|
||||
hide_extra(w);hide_panel(w);lv_obj_add_flag(w.slider,LV_OBJ_FLAG_HIDDEN);
|
||||
const lv_font_t *name_font=room_label?lv_obj_get_style_text_font(room_label,LV_PART_MAIN):w.title_font;
|
||||
int name_h=lv_font_get_line_height(name_font),circle=big?128:64;
|
||||
@@ -2638,6 +2915,11 @@ inline void render_full(Widgets &w,const Tile &t,bool custom,bool clock,bool sun
|
||||
lv_obj_set_pos(w.title,w.title_x,big?w.title_y:text_y);
|
||||
lv_obj_set_pos(w.value,w.value_x,big?w.value_y:text_y+title_h+line_gap);
|
||||
lv_obj_set_width(w.title,std::max(1,content_w-w.title_x));lv_obj_set_width(w.value,std::max(1,content_w-w.value_x));
|
||||
if(media){
|
||||
hide_panel(w);lv_obj_add_flag(w.slider,LV_OBJ_FLAG_HIDDEN);
|
||||
render_media_full(w,t,big,content_w,content_h,head_h);
|
||||
return;
|
||||
}
|
||||
if(mini){
|
||||
// The small slider becomes a strip a thumb finds at the bottom; the room above it is the button.
|
||||
hide_extra(w);hide_panel(w);
|
||||
@@ -2705,6 +2987,8 @@ inline void render_slot(size_t slot) {
|
||||
else if (value == "docked") value = "Docked";
|
||||
// Home Assistant's word where the screen has none of its own (firmware 0.2.58+): a cover says Open, a washer Rinsing.
|
||||
else if (!t.extra().state_word.empty()) value = t.extra().state_word;
|
||||
// A player's state in the screen's own words where Home Assistant sent none (firmware 0.2.64+).
|
||||
else if (d == "media_player") value = tile_controls::media_state_text(t.state);
|
||||
else if (!t.unit.empty() && !watch) value += " " + t.unit;
|
||||
bool pending=t.loading(esphome::millis());
|
||||
if(d=="weather" && std::isfinite(t.current)) {char b[32];snprintf(b,sizeof(b),"%.1f %s",t.current,t.unit.c_str());value=b;if(watch){snprintf(b,sizeof(b),"%.1f",t.current);value=b;}}
|
||||
@@ -2851,7 +3135,9 @@ inline void render_slot(size_t slot) {
|
||||
// The sun path sets its own colours on every render, the sunlit area under its arc too: taking the
|
||||
// accent here made that area orange after a palette change and yellow again after the next minute.
|
||||
if(w.extra_mode!="sunpath")w.fill_color=color;
|
||||
for(unsigned i=0;i<w.parts.size();++i){
|
||||
// The media tile (firmware 0.2.64+) paints its own parts on every render: keys, the bar and the cover's placeholder
|
||||
// in the media colours, not the card's.
|
||||
for(unsigned i=0;i<w.parts.size() && w.extra_mode!="media";++i){
|
||||
auto *p=w.parts[i];if(!p)continue;
|
||||
bool muted=w.extra_mode=="forecast" ? i>=2 && i%3==2 : w.extra_mode=="sunpath" ? i>=1 : w.extra_mode=="calendar" ? i==15||i==17 : i==16;
|
||||
if(lv_obj_check_type(p,&lv_label_class))set_color(p,LV_STYLE_TEXT_COLOR,muted?value_color:title_color);
|
||||
@@ -3411,6 +3697,9 @@ inline void tick() {
|
||||
uint32_t second=esphome::millis()/1000;
|
||||
if(second!=last_live_second){
|
||||
last_live_second=second;
|
||||
// The media card's bar runs on while the track plays (firmware 0.2.64+).
|
||||
if(media_progress_fill && detail_root && !lv_obj_has_flag(detail_root,LV_OBJ_FLAG_HIDDEN) && detail_index<model.count)
|
||||
media_progress(model.tiles[detail_index],media_progress_fill,media_elapsed_label,media_bar_width);
|
||||
auto now=now_time?now_time():esphome::ESPTime{};
|
||||
int minute=now.is_valid()?now.day_of_year*1440+now.hour*60+now.minute:-1;
|
||||
bool new_minute=minute!=last_clock_minute;last_clock_minute=minute;
|
||||
@@ -3418,6 +3707,8 @@ inline void tick() {
|
||||
auto &w=widgets[slot];if(!w.tile || w.index>=model.count || lv_obj_has_flag(w.tile,LV_OBJ_FLAG_HIDDEN))continue;
|
||||
const auto &t=model.tiles[w.index];
|
||||
if((t.is_clock() && new_minute) || (t.domain()=="timer" && t.state=="active") || (t.domain()=="sun" && second%60==0))card(w.index);
|
||||
// A media tile over the whole page: its bar runs on while the track plays (firmware 0.2.64+).
|
||||
if(w.extra_mode=="media" && w.extra && !lv_obj_has_flag(w.extra,LV_OBJ_FLAG_HIDDEN) && w.parts[5] && !lv_obj_has_flag(w.parts[5],LV_OBJ_FLAG_HIDDEN))media_progress(t,w.parts[5],w.parts[6],w.media_bar_w);
|
||||
// The second hand moves on its own: only its line is redrawn, and it hides during standby. Only while the
|
||||
// slot's parts are a dial: right after a page switch the slot already names the clock while its parts still
|
||||
// belong to the card drawn before (a forecast's hour labels take part 18 too), until the fill draws the dial.
|
||||
@@ -3473,15 +3764,102 @@ constexpr uint8_t ALERT_IMAGE_RETRIES = 3; // an alert's picture is worth anoth
|
||||
inline bool camera_supported() { return static_cast<bool>(camera_full.load); }
|
||||
inline bool camera_visible() { return camera_root != nullptr; }
|
||||
|
||||
// ---- The album cover of the media card (firmware 0.2.64+) ----
|
||||
// The card, or a media tile over the whole page, says which cover it wants: the player, the mark of its picture, the
|
||||
// size and the colour behind the rounded corners (cover_want). The app answers `esphome.screen_camera` with a link to
|
||||
// a BMP of exactly that (op "camera", t "cover"); the board's full online_image loads it once and the picture stays
|
||||
// until the mark changes, the owner goes (the card closes, the page turns) or a camera opens full screen: the camera
|
||||
// and the cover share that one image buffer, and the camera wins. Everything runs from camera_tick().
|
||||
struct CoverWish { std::string entity, picture; int size = 0; uint32_t background = 0; CoverOwner owner = CoverOwner::NONE; size_t slot = 0; };
|
||||
inline CoverWish cover_wish;
|
||||
inline camera_view::Feed cover;
|
||||
inline void camera_release();
|
||||
inline void camera_request(const std::string &entity, int size = 0, uint32_t background = 0);
|
||||
// The pictures on screen go before their buffer does.
|
||||
inline void cover_forget_pictures() {
|
||||
if (media_detail_picture) { lv_obj_delete(media_detail_picture); media_detail_picture = nullptr; }
|
||||
for (auto &w : widgets) if (w.extra_mode == "media" && w.parts[MEDIA_PICTURE]) { lv_obj_delete(w.parts[MEDIA_PICTURE]); w.parts[MEDIA_PICTURE] = nullptr; }
|
||||
}
|
||||
inline void cover_release() {
|
||||
const bool had = cover.open();
|
||||
cover = camera_view::Feed{};
|
||||
cover_forget_pictures();
|
||||
if (had && !camera_root) camera_release_due = true;
|
||||
}
|
||||
inline void cover_want(const std::string &entity, const std::string &picture, int size, uint32_t background, CoverOwner owner, size_t slot) {
|
||||
if (!camera_supported()) return;
|
||||
const bool same = cover_wish.entity == entity && cover_wish.picture == picture && cover_wish.size == size && cover_wish.background == background;
|
||||
cover_wish = CoverWish{entity, picture, size, background, owner, slot};
|
||||
if (same) return;
|
||||
cover_release(); // another cover: asked for on the next tick
|
||||
}
|
||||
inline lv_image_dsc_t *cover_ready(const std::string &entity, int size, uint32_t background) {
|
||||
if (!cover.loaded || cover.entity != entity || cover_wish.size != size || cover_wish.background != background) return nullptr;
|
||||
auto *src = camera_full.source();
|
||||
return src && src->data ? src : nullptr;
|
||||
}
|
||||
inline void cover_drop() { cover_wish = CoverWish{}; cover_release(); }
|
||||
// Whether the owner still shows the cover: the card open on that player, or the tile on screen in its slot.
|
||||
inline bool cover_visible() {
|
||||
if (cover_wish.owner == CoverOwner::DETAIL)
|
||||
return detail_root && !lv_obj_has_flag(detail_root, LV_OBJ_FLAG_HIDDEN) && detail_index < model.count && model.tiles[detail_index].entity == cover_wish.entity;
|
||||
if (cover_wish.owner == CoverOwner::TILE && cover_wish.slot < widgets.size()) {
|
||||
if (detail_root && !lv_obj_has_flag(detail_root, LV_OBJ_FLAG_HIDDEN)) return false; // a card covers the page
|
||||
auto &w = widgets[cover_wish.slot];
|
||||
return w.tile && !lv_obj_has_flag(w.tile, LV_OBJ_FLAG_HIDDEN) && w.index < model.count && model.tiles[w.index].entity == cover_wish.entity && w.extra_mode == "media";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Nobody holds the cover and a media tile with a picture is on the page (the card over it just closed, or the
|
||||
// page turned): the tile draws itself again and asks.
|
||||
inline void cover_offer() {
|
||||
if (detail_root && !lv_obj_has_flag(detail_root, LV_OBJ_FLAG_HIDDEN)) return;
|
||||
for (auto &w : widgets) {
|
||||
if (!w.tile || lv_obj_has_flag(w.tile, LV_OBJ_FLAG_HIDDEN) || w.index >= model.count || w.extra_mode != "media") continue;
|
||||
const auto &t = model.tiles[w.index];
|
||||
if (t.extra().media_picture.empty() || !media_card::has_track(t.state)) continue;
|
||||
refresh_tile(w.index);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// The cover is here: onto the card at once, or the tile draws itself again with it.
|
||||
inline void cover_arrived() {
|
||||
if (!cover_visible()) return;
|
||||
if (cover_wish.owner == CoverOwner::DETAIL) media_detail_picture = media_picture_show(detail_root, media_detail_picture, media_art_rect, camera_full.source());
|
||||
else refresh_tile(widgets[cover_wish.slot].index);
|
||||
ESP_LOGI("camera", "cover of %s shown", cover.entity.c_str());
|
||||
}
|
||||
inline void cover_tick(uint32_t now) {
|
||||
if (camera_root || !camera_supported()) return;
|
||||
if (cover_wish.owner == CoverOwner::NONE) { cover_offer(); return; }
|
||||
if (!cover_visible()) { cover_drop(); return; }
|
||||
if (!awake()) return;
|
||||
if (!cover.open()) cover.open(cover_wish.entity, true);
|
||||
if (cover.should_ask(now)) {
|
||||
if (!fresh()) return;
|
||||
cover.ask(now);
|
||||
camera_request(cover.entity, cover_wish.size, cover_wish.background);
|
||||
} else if (cover.should_load(now)) {
|
||||
auto *input = lv_indev_get_next(nullptr);
|
||||
if (input && lv_indev_get_state(input) == LV_INDEV_STATE_PRESSED) return;
|
||||
cover.start(now);
|
||||
camera_full.load(cover.url);
|
||||
}
|
||||
}
|
||||
|
||||
// Asks ESP Screen Manager for a link (app 0.2.66+ answers with op "camera"). An event, like history_request.
|
||||
inline void camera_request(const std::string &entity) {
|
||||
// A cover (firmware 0.2.64+) adds the size it wants and the colour behind its rounded corners; the app bakes both in.
|
||||
inline void camera_request(const std::string &entity, int size, uint32_t background) {
|
||||
if (inbox.empty()) return;
|
||||
esphome::api::HomeassistantActionRequest request;
|
||||
request.service = esphome::StringRef("esphome.screen_camera");
|
||||
request.is_event = true;
|
||||
const std::string keys[] = {"inbox", "entity"}, values[] = {inbox, entity};
|
||||
request.data.init(2);
|
||||
for (int i = 0; i < 2; ++i) {
|
||||
char size_text[12] = "", background_text[8] = "";
|
||||
if (size > 0) { snprintf(size_text, sizeof(size_text), "%d", size); snprintf(background_text, sizeof(background_text), "%06X", (unsigned) background); }
|
||||
const std::string keys[] = {"inbox", "entity", "size", "bg"}, values[] = {inbox, entity, size_text, background_text};
|
||||
const int count = size > 0 ? 4 : 2;
|
||||
request.data.init(count);
|
||||
for (int i = 0; i < count; ++i) {
|
||||
esphome::api::HomeassistantServiceMap entry;
|
||||
entry.key = esphome::StringRef(keys[i]);
|
||||
entry.value = esphome::StringRef(values[i]);
|
||||
@@ -3515,7 +3893,9 @@ inline void camera_close() {
|
||||
inline void camera_open(const std::string &entity, const std::string &name) {
|
||||
if (!camera_supported() || !valid_entity(entity)) return;
|
||||
camera_close();
|
||||
// The last camera's image goes before this one loads into the same online_image.
|
||||
// The last camera's image, or a media card's cover, goes before this one loads into the same online_image; the
|
||||
// cover is asked for again once the camera closes (cover_tick).
|
||||
cover_release();
|
||||
if (camera_release_due) camera_release();
|
||||
camera.open(entity);
|
||||
const int width = lv_display_get_horizontal_resolution(lv_display_get_default());
|
||||
@@ -3577,6 +3957,7 @@ inline void camera_tick() {
|
||||
camera_thumb.load(alert_url);
|
||||
}
|
||||
}
|
||||
cover_tick(now);
|
||||
if (!camera_root || !awake()) return;
|
||||
if (camera.should_ask(now)) {
|
||||
if (!fresh()) return;
|
||||
@@ -3631,6 +4012,12 @@ inline void camera_answer(const std::string &view, const std::string &entity, co
|
||||
if (url.empty() && !camera.shown) camera_note_text("No image from this camera");
|
||||
return;
|
||||
}
|
||||
if (view == "cover") { // the media card's album cover (firmware 0.2.64+)
|
||||
if (!cover.open() || cover.entity != entity) return;
|
||||
cover.link(url);
|
||||
if (url.empty()) ESP_LOGI("camera", "no cover for %s", entity.c_str());
|
||||
return;
|
||||
}
|
||||
if (url.empty()) { // announced before its alert
|
||||
alert_announced = entity;
|
||||
alert_announced_at = esphome::millis();
|
||||
@@ -3674,7 +4061,12 @@ inline void camera_loaded(bool thumb, bool cached) {
|
||||
if (alert_picture && alert_frame_icon) lv_obj_add_flag(alert_frame_icon, LV_OBJ_FLAG_HIDDEN);
|
||||
return;
|
||||
}
|
||||
if (!camera_root) return;
|
||||
if (!camera_root) {
|
||||
// The media card's cover (firmware 0.2.64+): the same online_image, loaded once.
|
||||
if (cover.loading) { cover.finish(esphome::millis(), true); cover_arrived(); }
|
||||
return;
|
||||
}
|
||||
if (!camera.loading) return; // a cover's download that ended after the camera opened: not this camera's picture
|
||||
camera.finish(esphome::millis(), true);
|
||||
const bool first = camera_picture == nullptr;
|
||||
camera_show(camera_root, camera_picture, camera_full.source(), !cached);
|
||||
@@ -3694,7 +4086,11 @@ inline void camera_failed(bool thumb) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!camera_root) return;
|
||||
if (!camera_root) {
|
||||
if (cover.loading) cover.finish(esphome::millis(), false); // tried again after the gap, three times at most
|
||||
return;
|
||||
}
|
||||
if (!camera.loading) return;
|
||||
camera.finish(esphome::millis(), false);
|
||||
if (!camera.shown) camera_note_text("No image from this camera");
|
||||
}
|
||||
|
||||
@@ -279,6 +279,9 @@ namespace theme {
|
||||
|
||||
inline lv_color_t color(Role role) { return lv_color_hex(hex(role)); }
|
||||
inline lv_color_t rgb(uint32_t value) { return lv_color_hex(value); }
|
||||
// The colour an object draws with, as the 24-bit value the table speaks in (what the media card names as the colour
|
||||
// behind its cover's corners).
|
||||
inline uint32_t of(lv_color_t value) { return lv_color_to_u32(value) & 0xFFFFFF; }
|
||||
|
||||
// ---- paints: shared styles filled from the table
|
||||
// A board profile gives a widget a paint instead of a colour (`styles: paint_card`). Its style definitions carry no
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace tile_controls {
|
||||
namespace feature {
|
||||
constexpr uint32_t COVER_OPEN = 1, COVER_CLOSE = 2, COVER_POSITION = 4, COVER_STOP = 8;
|
||||
constexpr uint32_t COVER_OPEN_TILT = 16, COVER_CLOSE_TILT = 32, COVER_STOP_TILT = 64, COVER_TILT_POSITION = 128;
|
||||
constexpr uint32_t MEDIA_PAUSE = 1, MEDIA_VOLUME_SET = 4, MEDIA_VOLUME_MUTE = 8, MEDIA_PREVIOUS = 16, MEDIA_NEXT = 32, MEDIA_PLAY = 16384;
|
||||
constexpr uint32_t MEDIA_PAUSE = 1, MEDIA_VOLUME_SET = 4, MEDIA_VOLUME_MUTE = 8, MEDIA_PREVIOUS = 16, MEDIA_NEXT = 32, MEDIA_TURN_ON = 128, MEDIA_PLAY = 16384;
|
||||
constexpr uint32_t VACUUM_TURN_ON = 1, VACUUM_TURN_OFF = 2, VACUUM_PAUSE = 4, VACUUM_STOP = 8, VACUUM_RETURN = 16, VACUUM_START = 8192;
|
||||
}
|
||||
// Material Design Icons glyphs the icon fonts carry (tile_icons.py FIXED).
|
||||
|
||||
@@ -13,6 +13,23 @@ The CYD has no memory for images (a 320×180 image needs 115 KB in one piece, th
|
||||
free block is about 45 KB). It shows the alert without the picture, and the editor doesn't offer
|
||||
camera tiles for it.
|
||||
|
||||
## The album cover on the media card
|
||||
|
||||
App 0.2.77 with firmware 0.2.64 uses the same road for a media player's picture. The media card
|
||||
(a tap on a media player's tile, or a media tile of size *Full page*) shows the album cover of
|
||||
what plays, with the title, the artist and the album, a progress bar and the keys under it.
|
||||
|
||||
- The screen asks ESP Screen Manager for the cover with the size it draws it at and the colour
|
||||
behind it (the event `esphome.screen_camera` with `size` and `bg`). The app fetches the
|
||||
picture where Home Assistant's state points (`entity_picture`), cuts it square, sizes it,
|
||||
rounds the corners over that colour and serves it on port 8098 as a BMP, like a camera image.
|
||||
- A cover is fetched once per picture: the state carries a short mark of the picture, and the
|
||||
screen asks again only when the mark changes (a new track), when the card opens again or
|
||||
when the page turns back to a full-page media tile. Nothing polls.
|
||||
- A player without a picture (a radio station, a player that is off) keeps the player's icon in
|
||||
the cover's place; the app answers with an empty link and the screen stops asking.
|
||||
- The CYD shows the same card without the picture: its icon stands in for the cover.
|
||||
|
||||
## A doorbell
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# Test results app 0.2.77 / firmware 0.2.64 (2026-09-18)
|
||||
|
||||
Max asked for a media card "like Spotify or Apple Music now playing" instead of the plain card with three text keys
|
||||
and a thin volume slider, with the album cover through the camera-image route, working as the card behind a media
|
||||
tile, as a tile over the whole page, and in the light and the dark look. Then: the CYD gets the new card without the
|
||||
picture ("te risky"), a player does not always have a picture, and renders first so he could approve the look.
|
||||
See CHANGELOG 0.2.77. Built on 0.2.76 (`bdb1f12`).
|
||||
|
||||
## What changed
|
||||
|
||||
- `components/smart_display/media_card.h` (new): the card's geometry and words, free of LVGL. A tall area (the
|
||||
Guition's card) stacks cover, title, artist · album, the bar row (elapsed | bar | total on one line), the keys
|
||||
(previous 52, play or pause 64 on the accent, next) and the volume row. A wide area (the CYD's card, a tile over the
|
||||
whole page) puts the cover at the left with the column beside it; what does not fit goes in order: the artist line,
|
||||
the times, then the cover shrinks. `tests/test_media_card.cpp` checks every shape for overlaps and order.
|
||||
- `runtime_tiles.h`: `render_media_detail` (the card), `render_media_full` (the tile over the whole page, parts in
|
||||
`w.extra`), `media_action` shared by both (20 play/pause, 21 previous, 22 next, 23 mute, 24 turn on), the card's
|
||||
key handler pulled out of `detail_button` into `detail_command`, `media_progress` moved by `tick()` once a second,
|
||||
and the cover: `cover_want`/`cover_ready`/`cover_tick`/`cover_offer` around one `camera_view::Feed` that loads a
|
||||
link once (`once`, `loaded`), owned by the card or by a tile slot and dropped when the owner goes; a camera full
|
||||
screen takes the shared image buffer and the cover comes back after. The camera answer parser accepts `t: cover`.
|
||||
The palette pass leaves the media tile's parts alone (it painted them the card's grey). A player's state falls
|
||||
back to the screen's own word ("Playing") when Home Assistant sent none.
|
||||
- `runtime_model.h`: `Extra` carries artist, album, picture mark, duration, position and its moment.
|
||||
- `camera_view.h`: `open(entity, once)`, `loaded`; an empty answer to a one-time feed stops the asking.
|
||||
- `theme.h`: `theme::of(lv_color_t)` (the 24-bit value of a drawn colour, for the cover's corner colour).
|
||||
- `tile_controls.h`: `feature::MEDIA_TURN_ON`.
|
||||
- App: `core.media_extras` (`x: artist, album, dur, pos, at, pic`), `COVER_MIN_FIRMWARE = 0.2.64`;
|
||||
`camera_feed.cover_supported/can_show_cover/cover_request/encode_cover`, `CameraFeed.cover()` (fetched when the
|
||||
picture address changed), `Link.cover`; `server.HomeAssistant.media_picture/media_image`, `Manager.cover_message`,
|
||||
`answer_camera` serves covers to a Guition on 0.2.64+ for players on its layout.
|
||||
- Both profiles: firmware 0.2.64. Docs: CHANGELOG, README rows, docs/CAMERA.md "The album cover on the media card".
|
||||
|
||||
## Checks
|
||||
|
||||
- `tests/test_media_card.cpp` (geometry, progress, clock text, subtitle), `tests/test_camera_view.cpp` (one-time
|
||||
feed), all 18 C++ suites: pass.
|
||||
- `tests/test_media_card.py` (14): extras, firmware wiring, cover encoding (corners, square crop, transparency),
|
||||
fetch-on-change and links, the app's answer through `Manager.answer_camera` (Guition 0.2.64 gets a link, a stream
|
||||
an empty one, a stranger nothing, older firmware and a CYD nothing). Whole Python suite: 409 + 14, pass.
|
||||
- Host harness (`.esphome/readme-render/media/media_host.py`, real ESPHome API, virtual finger, the app's own
|
||||
`answer_camera` behind `/dev/cover`): **Guition 57/57, CYD 42/42.** Covered: the card of a playing player (title,
|
||||
artist · album, elapsed from the reported position and running on, keys enabled, slider at 32 %, bar track and
|
||||
fill), the cover asked for with size and page colour and loaded once over the placeholder, pause → `media_play_pause`
|
||||
with the keys faded during the wait and back after, next, mute (`is_volume_muted: true`), the slider drag →
|
||||
`volume_set` ≈ 0.8, closing drops the cover; a stream (no times, no cover request); a paused muted TV with few
|
||||
features (play key, muted key, "Muted", next and mute faded, previous not, a still bar, its cover); an off player
|
||||
("Off", no times); dark mode (dark keys, the cover asked again with the dark page colour); a tile over the whole
|
||||
page (track beside the cover, keys, slider, "48 %", times, cover at the tile's size and colour, the bar running,
|
||||
the tile's pause key acting on the study speaker without opening the card, the tile's slider → `volume_set` ≈ 0.2,
|
||||
a new picture in Home Assistant → new mark → the tile asks again and loads the new cover, holding the tile opens the
|
||||
card which takes the cover at its own size, closing gives it back to the tile); a stream over the page (no times,
|
||||
no cover, the study cover dropped); an idle player ("Not playing"); a wide media tile with a volume control keeps
|
||||
its panel; no errors in the firmware log. The CYD asks for no cover anywhere.
|
||||
- Renders (`render_media.py`, both boards, light and dark): card of a playing player, a stream, a paused TV, an off
|
||||
player, tiles over the page (study, radio, idle). Sent to Max for approval.
|
||||
- ESP32 builds of both Easy Setup profiles: see the sizes below.
|
||||
- Hardware: Studio 1 (Guition, this network's Home Assistant) — see below.
|
||||
|
||||
## Sizes
|
||||
|
||||
Both Easy Setup profiles compile with ESPHome 2026.6.2 (on Python 3.11, see the pitfalls), no warnings.
|
||||
|
||||
| Build | Flash | RAM |
|
||||
|---|---|---|
|
||||
| Guition (easy-guition-device) | 1,971,583 B, 24.3 % of 8 MB | 76,844 B, 23.5 % |
|
||||
| CYD (easy-cyd-device) | 1,582,063 B, 86.2 % of the 1.835 MB update slot | 71,276 B, 21.8 % |
|
||||
|
||||
## Hardware
|
||||
|
||||
Pending at the time of writing.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- `render_time` on a host build freezes the clock, and the progress bar with it: the harness shifts the demo's
|
||||
report moments to now instead.
|
||||
- LVGL's `LV_LABEL_LONG_DOT` writes the dots into the label's text: a probe sees "Miles Davis · Kin...".
|
||||
- `tick()` re-enables every object in `detail_actions` after a wait: a key the player lacks must stay out of it.
|
||||
- The tile palette pass paints every custom part; a mode that paints its own parts must be skipped there.
|
||||
- Homebrew's python@3.14.7 on this Mac has a broken `pyexpat` (it wants a libexpat symbol macOS 26.2 lacks), so
|
||||
`platform.mac_ver()` returns '' and both pip's truststore and `uv` refuse the interpreter. The pioarduino platform
|
||||
recreates `~/.platformio/penv` from the running Python and then cannot install into it. The ESP32 builds of this
|
||||
release ran from an ESPHome 2026.6.2 environment on PlatformIO's own bundled Python 3.11 (`.esphome/venv311`,
|
||||
local); `brew reinstall python@3.14` (or expat) is the real fix. Also: never run two ESPHome builds at once, the
|
||||
second one clears the penv the first one uses.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
@@ -2,7 +2,7 @@
|
||||
# Source profile for packages/guition.yaml (tools/generate_packages.py); see docs/GUITION.md.
|
||||
# A screen gets its tiles from the ESP Screen Manager add-on while it runs. The CYD is its own profile.
|
||||
substitutions:
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.63"
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.64"
|
||||
# 0: no drift limit; LVGL decides, releasing inside the tile is a tap, leaving it is not
|
||||
# (like iOS). The CYD keeps a limit because its resistive panel jumps.
|
||||
TOUCH_MOVE_LIMIT_PX: "0"
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
############################################################
|
||||
|
||||
substitutions:
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.63"
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.64"
|
||||
# A tap survives a finger that drifts up to this far (320 px over 58 mm: 1 cm); further is a swipe.
|
||||
TOUCH_MOVE_LIMIT_PX: "56"
|
||||
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@
|
||||
############################################################
|
||||
|
||||
substitutions:
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.63"
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.64"
|
||||
# A tap survives a finger that drifts up to this far (320 px over 58 mm: 1 cm); further is a swipe.
|
||||
TOUCH_MOVE_LIMIT_PX: "56"
|
||||
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Source profile for packages/guition.yaml (tools/generate_packages.py); see docs/GUITION.md.
|
||||
# A screen gets its tiles from the ESP Screen Manager add-on while it runs. The CYD is its own profile.
|
||||
substitutions:
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.63"
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.64"
|
||||
# 0: no drift limit; LVGL decides, releasing inside the tile is a tap, leaving it is not
|
||||
# (like iOS). The CYD keeps a limit because its resistive panel jumps.
|
||||
TOUCH_MOVE_LIMIT_PX: "0"
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
## 0.2.77 (firmware 0.2.64)
|
||||
|
||||
A media card like a phone's "now playing", with the album cover on a Guition.
|
||||
|
||||
- **The media card is new.** Tap a media player's tile and the card shows the album cover, the title, the artist and the album, a progress bar that runs while the track plays with the elapsed and total time at its ends, three round keys (previous, play or pause on the blue key, next) and a volume row: a mute key, a wide slider with a round knob, and the percentage. The keys the player does not offer are faded, the mute key and the slider follow what Home Assistant reports (a muted player shows a grey fill and *Muted*), and a player that is off shows one power key. A stream without a length shows no bar and no times. It works in the light and the dark look.
|
||||
- **The album cover** comes the way camera images do (app 0.2.66): the screen asks ESP Screen Manager, the app fetches the picture from Home Assistant with its own access, makes it exactly the size the card asks for with the corners already rounded over the card's colour, and serves it on port 8098. A Guition loads it once and keeps it until the picture changes, the card closes or the page turns. The CYD has no memory for pictures and shows the player's icon in the cover's place, as it does when a player has no picture (a radio station, a player that is off).
|
||||
- **A media tile over the whole page** (size *Full page*) is the same card under the tile's head: the cover at the left, the track, the bar and the keys beside it, the volume row along the bottom. The keys and the slider work on the tile; holding the tile still opens the card.
|
||||
- **The card's clock runs on the screen.** Home Assistant reports where a track was and when; the bar and the elapsed time run on from there every second without any traffic, and stand still while the track is paused.
|
||||
- Claude in Home Assistant and the editor need nothing new: a `media_player` tile is the same tile, with the new card behind it.
|
||||
- Needs firmware 0.2.64 for the new card; the app serves covers only to a Guition on that firmware. Older firmware keeps the old card. Includes everything from 0.2.76.
|
||||
|
||||
## 0.2.76 (firmware 0.2.63)
|
||||
|
||||
The CYD in the editor shows the whole tile again.
|
||||
|
||||
@@ -23,6 +23,14 @@ LOG = logging.getLogger(__name__)
|
||||
|
||||
DOMAINS = ('camera', 'image')
|
||||
MIN_FIRMWARE = (0, 2, 57)
|
||||
# Album covers on the media card (app 0.2.77, firmware 0.2.64): the same port serves a media player's picture, square,
|
||||
# at the size the screen asks for, with the rounded corners baked in over the colour the screen names (the card's
|
||||
# page or the tile's own colour), so the screen draws it as it is. A cover is fetched when the screen loads its link
|
||||
# and again only when Home Assistant's picture changes; the screen asks for a new link then.
|
||||
COVER_DOMAINS = ('media_player',)
|
||||
COVER_MIN_FIRMWARE = (0, 2, 64)
|
||||
COVER_SIZES = (48, 320) # the smallest and largest cover a screen may ask for, in pixels
|
||||
COVER_RADIUS_SHARE = 12 # the corner is a twelfth of the size, at least 4 px (media_card.h radius_for)
|
||||
PORT = 8098
|
||||
# A camera nobody loaded a picture of for this long is forgotten, its last snapshot with it.
|
||||
WATCH_SECONDS = 30
|
||||
@@ -60,6 +68,30 @@ def can_show(screen):
|
||||
return bool(screen) and screen.get('board') in BOXES and version(screen.get('firmware')) >= MIN_FIRMWARE
|
||||
|
||||
|
||||
def cover_supported(entity):
|
||||
"""True for a media player entity id: its cover can be served."""
|
||||
return (isinstance(entity, str) and len(entity) <= 120 and re.fullmatch(r'[a-z0-9_]+\.[a-z0-9_]+', entity) is not None
|
||||
and entity.split('.')[0] in COVER_DOMAINS)
|
||||
|
||||
|
||||
def can_show_cover(screen):
|
||||
"""A paired Guition with firmware that draws the media card's cover."""
|
||||
return bool(screen) and screen.get('board') in BOXES and version(screen.get('firmware')) >= COVER_MIN_FIRMWARE
|
||||
|
||||
|
||||
def cover_request(request):
|
||||
"""(size, background) a screen asked for, or None when the request is not a cover request the app can serve:
|
||||
`size` in pixels within COVER_SIZES, `bg` six hex digits of the colour behind the corners."""
|
||||
try:
|
||||
size = int(request.get('size'))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
background = request.get('bg')
|
||||
if not COVER_SIZES[0] <= size <= COVER_SIZES[1] or not isinstance(background, str) or not re.fullmatch(r'[0-9A-Fa-f]{6}', background):
|
||||
return None
|
||||
return size, int(background, 16)
|
||||
|
||||
|
||||
def fit(size, box):
|
||||
"""The largest size with the proportions of `size` that fits `box`, at least one pixel each way."""
|
||||
width, height = size
|
||||
@@ -90,12 +122,46 @@ def encode(raw, box):
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def encode_cover(raw, size, background):
|
||||
"""A media player's picture as a square 24-bit BMP of `size` pixels with rounded corners, the corners filled with
|
||||
`background` (0xRRGGBB): what the screen draws on its card without any work of its own. A picture that is not
|
||||
square is cut to its middle."""
|
||||
from PIL import Image, ImageDraw, ImageOps
|
||||
with Image.open(io.BytesIO(raw)) as source:
|
||||
source.draft('RGB', (size, size))
|
||||
image = ImageOps.exif_transpose(source)
|
||||
if image.mode in ('RGBA', 'LA', 'P', 'PA'):
|
||||
image = image.convert('RGBA')
|
||||
ground = Image.new('RGB', image.size, tuple((background >> shift) & 0xFF for shift in (16, 8, 0)))
|
||||
ground.paste(image, mask=image.getchannel('A'))
|
||||
image = ground
|
||||
elif image.mode != 'RGB':
|
||||
image = image.convert('RGB')
|
||||
width, height = image.size
|
||||
side = min(width, height)
|
||||
if width != height:
|
||||
left, top = (width - side) // 2, (height - side) // 2
|
||||
image = image.crop((left, top, left + side, top + side))
|
||||
if image.size != (size, size):
|
||||
image = image.resize((size, size), Image.Resampling.LANCZOS)
|
||||
radius = max(4, size // COVER_RADIUS_SHARE)
|
||||
mask = Image.new('L', (size, size), 0)
|
||||
ImageDraw.Draw(mask).rounded_rectangle((0, 0, size - 1, size - 1), radius=radius, fill=255)
|
||||
ground = Image.new('RGB', (size, size), tuple((background >> shift) & 0xFF for shift in (16, 8, 0)))
|
||||
ground.paste(image, mask=mask)
|
||||
out = io.BytesIO()
|
||||
ground.save(out, 'BMP')
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
class Watch:
|
||||
"""One camera: its last snapshot, the sizes made of it and the fetch on its way."""
|
||||
"""One camera or media player: its last picture, the sizes made of it and the fetch on its way. For a media
|
||||
player `picture` is the address the raw picture came from, so a changed picture is fetched again."""
|
||||
|
||||
def __init__(self, now):
|
||||
self.used = now
|
||||
self.raw, self.digest = None, ''
|
||||
self.picture, self.fetching = None, None
|
||||
self.frames = {}
|
||||
self.failures = 0
|
||||
self.retry_at = 0.0
|
||||
@@ -103,18 +169,22 @@ class Watch:
|
||||
|
||||
|
||||
class Link:
|
||||
__slots__ = ('entity', 'box', 'still', 'etag', 'used', 'lifetime')
|
||||
__slots__ = ('entity', 'box', 'still', 'etag', 'used', 'lifetime', 'cover')
|
||||
|
||||
def __init__(self, entity, box, now, still=None):
|
||||
def __init__(self, entity, box, now, still=None, cover=None):
|
||||
self.entity, self.box, self.used = entity, box, now
|
||||
self.still = still
|
||||
self.cover = cover # (size, background) of a media player's cover, else None
|
||||
self.etag = f'"{hashlib.sha1(still).hexdigest()[:16]}"' if still else ''
|
||||
self.lifetime = STILL_SECONDS if still else LINK_SECONDS
|
||||
|
||||
|
||||
class CameraFeed:
|
||||
def __init__(self, fetch, clock=time.monotonic):
|
||||
def __init__(self, fetch, clock=time.monotonic, fetch_cover=None, picture=None):
|
||||
"""`fetch(entity)` gives a camera's snapshot; `fetch_cover(entity)` a media player's picture and
|
||||
`picture(entity)` the address that picture has in its state right now ('' without one)."""
|
||||
self.fetch, self.clock = fetch, clock
|
||||
self.fetch_cover, self.picture = fetch_cover, picture
|
||||
self.watches, self.links = {}, {}
|
||||
|
||||
# ----- fetching -----
|
||||
@@ -194,6 +264,61 @@ class CameraFeed:
|
||||
cached = watch.frames[box] = (digest, image)
|
||||
return f'"{digest[:16]}-{box[0]}x{box[1]}"', cached[1]
|
||||
|
||||
# ----- covers -----
|
||||
# A media player's picture is fetched when a screen loads its cover link and Home Assistant's picture is another
|
||||
# one than the last fetched (or none was fetched yet): a cover never changes on its own between two loads, and the
|
||||
# screen loads again only when the state's picture mark changed. So a load waits for the picture of this moment.
|
||||
async def fetch_cover_one(self, entity, watch, picture):
|
||||
try:
|
||||
raw = await asyncio.wait_for(self.fetch_cover(entity), FETCH_SECONDS)
|
||||
if not raw:
|
||||
raise ValueError('empty picture')
|
||||
digest = hashlib.sha1(raw).hexdigest()
|
||||
if digest != watch.digest:
|
||||
watch.raw, watch.digest, watch.frames = raw, digest, {}
|
||||
watch.picture = picture
|
||||
if watch.failures:
|
||||
LOG.info('The cover of %s comes again', entity)
|
||||
watch.failures = 0
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as error:
|
||||
watch.failures += 1
|
||||
if watch.failures in (1, 30):
|
||||
LOG.info('No cover from %s (%s)', entity, type(error).__name__)
|
||||
|
||||
async def cover(self, entity, size, background, wait=FIRST_FRAME_SECONDS):
|
||||
"""(etag, BMP) of the player's cover at `size` with `background` behind its corners, or None when the player
|
||||
shows no picture or it cannot be fetched."""
|
||||
if self.fetch_cover is None or self.picture is None:
|
||||
return None
|
||||
picture = self.picture(entity)
|
||||
if not picture:
|
||||
return None
|
||||
watch = self.watch(entity)
|
||||
if watch.picture != picture or watch.raw is None:
|
||||
if watch.task is None or watch.task.done() or watch.fetching != picture:
|
||||
if watch.task is not None and not watch.task.done():
|
||||
watch.task.cancel()
|
||||
watch.fetching = picture
|
||||
watch.task = asyncio.ensure_future(self.fetch_cover_one(entity, watch, picture))
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.shield(watch.task), wait)
|
||||
except asyncio.TimeoutError:
|
||||
return None
|
||||
if watch.picture != picture or watch.raw is None:
|
||||
return None
|
||||
key = ('cover', size, background)
|
||||
cached = watch.frames.get(key)
|
||||
if cached is None or cached[0] != watch.digest:
|
||||
try:
|
||||
image = await asyncio.get_running_loop().run_in_executor(None, encode_cover, watch.raw, size, background)
|
||||
except Exception as error:
|
||||
LOG.info('The cover of %s cannot be read (%s)', entity, type(error).__name__)
|
||||
return None
|
||||
cached = watch.frames[key] = (watch.digest, image)
|
||||
return f'"{watch.digest[:16]}-c{size}-{background:06X}"', cached[1]
|
||||
|
||||
# ----- links -----
|
||||
def prune(self):
|
||||
now = self.clock()
|
||||
@@ -202,11 +327,12 @@ class CameraFeed:
|
||||
while len(self.links) >= MAX_LINKS:
|
||||
del self.links[min(self.links, key=lambda token: self.links[token].used)]
|
||||
|
||||
def link(self, entity, box, still=None):
|
||||
"""A new random token for one camera at one size; `still` makes it one fixed image (an alert's)."""
|
||||
def link(self, entity, box, still=None, cover=None):
|
||||
"""A new random token for one camera at one size; `still` makes it one fixed image (an alert's), `cover`
|
||||
(size, background) a media player's cover."""
|
||||
self.prune()
|
||||
token = secrets.token_urlsafe(18)
|
||||
self.links[token] = Link(entity, box, self.clock(), still)
|
||||
self.links[token] = Link(entity, box, self.clock(), still, cover)
|
||||
return token
|
||||
|
||||
async def serve(self, token, etag=None):
|
||||
@@ -219,7 +345,7 @@ class CameraFeed:
|
||||
link.used = now
|
||||
if link.still:
|
||||
return (304, None, link.etag) if etag == link.etag else (200, link.still, link.etag)
|
||||
found = await self.frame(link.entity, link.box)
|
||||
found = await self.cover(link.entity, *link.cover) if link.cover else await self.frame(link.entity, link.box)
|
||||
if found is None:
|
||||
return 503, None, ''
|
||||
tag, image = found
|
||||
|
||||
@@ -22,6 +22,8 @@ NEW_DOMAINS = frozenset('sun timer person screen'.split())
|
||||
# A camera or an image entity opens full screen on a Guition with firmware 0.2.57+ (camera_feed.py).
|
||||
CAMERA_DOMAINS = frozenset(('camera', 'image'))
|
||||
CAMERA_MIN_FIRMWARE = (0, 2, 57)
|
||||
# The media card with its cover (app 0.2.77): firmware from here draws it and asks for the cover.
|
||||
COVER_MIN_FIRMWARE = (0, 2, 64)
|
||||
# Forty-eight tiles (one per slot), a tile over the whole page and the screen.page tile (firmware 0.2.62+).
|
||||
MAX_TILES = 48
|
||||
LEGACY_MAX_TILES = 20
|
||||
@@ -30,7 +32,7 @@ WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
||||
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
|
||||
REFS = {'cyd': 'main', 'guition': 'main'}
|
||||
# Firmware shipped with this app release; screens below it get an update offer.
|
||||
FIRMWARE_VERSION = '0.2.63'
|
||||
FIRMWARE_VERSION = '0.2.64'
|
||||
# The Auto standby switch a screen offers Home Assistant automations.
|
||||
AUTO_STANDBY_MIN_FIRMWARE = '0.2.41'
|
||||
# The settings page the screen opens itself, and the screen.settings tile that opens it.
|
||||
@@ -961,6 +963,36 @@ def vacuum_extras(tile, states, device):
|
||||
result['room'] = short(room, 32)
|
||||
return result or None
|
||||
|
||||
def media_extras(attrs):
|
||||
"""The media card (app 0.2.77, firmware 0.2.64+): the artist and the album, the track's length and where it was
|
||||
when Home Assistant last said so (seconds; that moment as an epoch), and a short mark of the cover picture. The
|
||||
mark changes with the picture and says nothing else: the screen asks the app for the picture itself."""
|
||||
result = {}
|
||||
for key, name in (('artist', 'media_artist'), ('album', 'media_album_name')):
|
||||
value = attrs.get(name)
|
||||
if isinstance(value, str) and value.strip():
|
||||
result[key] = short(value.strip(), 80)
|
||||
for key, name in (('dur', 'media_duration'), ('pos', 'media_position')):
|
||||
value = attrs.get(name)
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) and 0 <= value <= 10_000_000:
|
||||
if key == 'dur' and value <= 0:
|
||||
continue
|
||||
result[key] = int(value)
|
||||
moment = attrs.get('media_position_updated_at')
|
||||
if isinstance(moment, str) and moment:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(moment.replace('Z', '+00:00'))
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
result['at'] = int(parsed.timestamp())
|
||||
except ValueError:
|
||||
pass
|
||||
picture = attrs.get('entity_picture') or attrs.get('entity_picture_local')
|
||||
if isinstance(picture, str) and picture:
|
||||
result['pic'] = hashlib.sha1(picture.encode()).hexdigest()[:10]
|
||||
return result or None
|
||||
|
||||
|
||||
def extras(tile, states, forecast=None, tz=None, hourly=None, now=None, device=None):
|
||||
"""Small, pre-computed values the firmware cannot derive itself (time zones, forecasts, a vacuum's device)."""
|
||||
domain = tile['entity'].split('.')[0]
|
||||
@@ -969,6 +1001,8 @@ def extras(tile, states, forecast=None, tz=None, hourly=None, now=None, device=N
|
||||
return vacuum_extras(tile, states, device)
|
||||
if domain == 'cover':
|
||||
return device_power(attrs, cover_related(tile['entity'], device, states), states) or None
|
||||
if domain == 'media_player':
|
||||
return media_extras(attrs)
|
||||
if domain == 'weather' and (forecast or hourly):
|
||||
result = {}
|
||||
days = []
|
||||
|
||||
@@ -392,6 +392,36 @@ class HomeAssistant:
|
||||
raise ValueError('image too large')
|
||||
return bytes(raw)
|
||||
|
||||
def media_picture(self, entity):
|
||||
"""The address of a media player's cover as its state carries it right now, or '' without one."""
|
||||
attrs = self.states.get(entity, {}).get('attributes', {})
|
||||
picture = attrs.get('entity_picture') or attrs.get('entity_picture_local')
|
||||
return picture if isinstance(picture, str) and picture else ''
|
||||
|
||||
async def media_image(self, entity):
|
||||
"""The cover of a media player (app 0.2.77), fetched where its state points: Home Assistant's own proxy
|
||||
with this app's token (`/api/media_player_proxy/...`), or a picture on the internet as it is."""
|
||||
picture = self.media_picture(entity)
|
||||
if not picture:
|
||||
raise ValueError('no picture')
|
||||
root = self.base[:-4] if self.base.endswith('/api') else self.base
|
||||
if picture.startswith('/'):
|
||||
url, headers = f'{root}{picture}', {'Authorization': 'Bearer ' + self.token}
|
||||
elif picture.startswith(('http://', 'https://')):
|
||||
url, headers = picture, {}
|
||||
else:
|
||||
raise ValueError('unknown picture address')
|
||||
async with self.session.get(url, headers=headers, timeout=ClientTimeout(total=camera_feed.FETCH_SECONDS)) as response:
|
||||
response.raise_for_status()
|
||||
if (response.content_length or 0) > camera_feed.MAX_SNAPSHOT_BYTES:
|
||||
raise ValueError('picture too large')
|
||||
raw = bytearray()
|
||||
async for chunk in response.content.iter_chunked(65536):
|
||||
raw += chunk
|
||||
if len(raw) > camera_feed.MAX_SNAPSHOT_BYTES:
|
||||
raise ValueError('picture too large')
|
||||
return bytes(raw)
|
||||
|
||||
async def fire(self, event_type, data):
|
||||
"""One Home Assistant event of our own, such as the answer to a tile event."""
|
||||
await self.request('fire_event', event_type=event_type, event_data=data)
|
||||
@@ -502,7 +532,9 @@ class Manager:
|
||||
self.card_histories, self.card_history_fetches = {}, {}
|
||||
# Camera images (firmware 0.2.57+): the feed behind the camera port, and the cameras of recent alerts
|
||||
# (entity -> monotonic time) that a screen may open full screen without a tile.
|
||||
self.camera = camera_feed.CameraFeed(lambda entity: self.ha.camera_image(entity))
|
||||
self.camera = camera_feed.CameraFeed(lambda entity: self.ha.camera_image(entity),
|
||||
fetch_cover=lambda entity: self.ha.media_image(entity),
|
||||
picture=lambda entity: self.ha.media_picture(entity))
|
||||
self.alert_cameras = {}
|
||||
if self.path.exists():
|
||||
raw = json.loads(self.path.read_text())
|
||||
@@ -1357,14 +1389,31 @@ class Manager:
|
||||
inbox = self.aliases.get(request.get('inbox'), request.get('inbox'))
|
||||
entity = request.get('entity')
|
||||
screen = self.screen(inbox) if isinstance(inbox, str) else None
|
||||
if not camera_feed.supported(entity) or not camera_feed.can_show(screen) or not screen.get('online'):
|
||||
# A media player's cover (app 0.2.77, firmware 0.2.64+) comes the same way, at the size the card asks for.
|
||||
cover = camera_feed.cover_request(request) if camera_feed.cover_supported(entity) else None
|
||||
if cover:
|
||||
if not camera_feed.can_show_cover(screen) or not screen.get('online'):
|
||||
return
|
||||
elif not camera_feed.supported(entity) or not camera_feed.can_show(screen) or not screen.get('online'):
|
||||
return
|
||||
action = self.transport(inbox, screen)
|
||||
if not action or not self.camera_allowed(inbox, entity):
|
||||
return
|
||||
message = await self.camera_message(entity, 'full', screen['board'])
|
||||
message = await self.cover_message(entity, *cover) if cover else await self.camera_message(entity, 'full', screen['board'])
|
||||
await self.ha.send(inbox, message, action)
|
||||
LOG.info('Camera %s on %s%s', entity, screen['name'], '' if message['u'] else ': no image')
|
||||
LOG.info('%s %s on %s%s', 'Cover of' if cover else 'Camera', entity, screen['name'], '' if message['u'] else ': no image')
|
||||
|
||||
async def cover_message(self, entity, size, background):
|
||||
"""The screen message with a link to a media player's cover at `size` with `background` behind the corners,
|
||||
or an empty link when the player shows no picture (the card keeps its placeholder)."""
|
||||
url = ''
|
||||
base = await camera_feed.base_url(self.ha.request)
|
||||
if base:
|
||||
token = self.camera.link(entity, (size, size), cover=(size, background)) if await self.camera.cover(entity, size, background) else None
|
||||
url = f'{base}/camera/{token}.bmp' if token else ''
|
||||
else:
|
||||
LOG.warning('Covers: no address for this app on the LAN; set SCREEN_CAMERA_URL')
|
||||
return {'v': 1, 'op': 'camera', 't': 'cover', 'e': entity, 'u': url}
|
||||
|
||||
async def alert_images(self, camera, screens):
|
||||
"""The picture of an alert's camera at that moment, on every screen that draws it."""
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: ESP Screen Manager
|
||||
version: "0.2.76"
|
||||
version: "0.2.77"
|
||||
slug: esp_screen_manager
|
||||
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
|
||||
url: https://github.com/MaxGramser/homeassistant_espscreen
|
||||
|
||||
@@ -61,5 +61,39 @@ int main() {
|
||||
feed.start(0);
|
||||
feed.finish(10, true);
|
||||
assert(!feed.should_load(REFRESH_MS - 1) && feed.should_load(REFRESH_MS + 1));
|
||||
|
||||
// An album cover (firmware 0.2.64+) loads once per link: it stays on screen, never refreshed on a clock.
|
||||
Feed cover;
|
||||
cover.open("media_player.office", true);
|
||||
assert(cover.once && cover.should_ask(0));
|
||||
cover.ask(0);
|
||||
cover.link("http://h/camera/cover.bmp");
|
||||
assert(cover.should_load(100));
|
||||
cover.start(100);
|
||||
cover.finish(400, true);
|
||||
assert(cover.loaded && cover.shown && !cover.should_load(400 + GAP_MS) && !cover.should_load(400 + 10 * REFRESH_MS) && !cover.should_ask(400 + ASK_AGAIN_MS));
|
||||
// A failed load is tried again after the gap; three failures forget the link so the card asks for a new one.
|
||||
cover.link("http://h/camera/cover2.bmp");
|
||||
assert(!cover.loaded && cover.should_load(1000));
|
||||
cover.start(1000); cover.finish(1100, false);
|
||||
assert(!cover.should_load(1100 + GAP_MS - 1) && cover.should_load(1100 + GAP_MS));
|
||||
cover.start(2000); cover.finish(2100, false);
|
||||
cover.start(3000); cover.finish(3100, false);
|
||||
assert(cover.url.empty() && cover.should_ask(3100));
|
||||
// A new link (a new picture in Home Assistant) loads again, once.
|
||||
cover.ask(3100);
|
||||
cover.link("http://h/camera/cover3.bmp");
|
||||
cover.start(3200); cover.finish(3500, true);
|
||||
assert(cover.loaded && !cover.should_load(3500 + 10 * REFRESH_MS));
|
||||
// A player without a picture (or an app from before covers) answers an empty link: asked once, then left alone.
|
||||
Feed bare;
|
||||
bare.open("media_player.radio", true);
|
||||
bare.ask(0);
|
||||
bare.link("");
|
||||
assert(bare.empty && !bare.should_ask(ASK_AGAIN_MS) && !bare.should_ask(100 * ASK_AGAIN_MS) && !bare.should_load(ASK_AGAIN_MS));
|
||||
// A camera feed keeps its clock: open() without the flag.
|
||||
Feed live;
|
||||
live.open("camera.front_door");
|
||||
assert(!live.once);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// clang++ -std=c++17 -Wall -Wextra -Werror -I. tests/test_media_card.cpp -o /tmp/test_media_card && /tmp/test_media_card
|
||||
#include "../components/smart_display/media_card.h"
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
|
||||
using namespace media_card;
|
||||
|
||||
static bool inside(const Rect &r, int width, int height) { return r.x >= 0 && r.y >= 0 && r.right() <= width && r.bottom() <= height; }
|
||||
static bool apart(const Rect &a, const Rect &b) { return a.right() <= b.x || b.right() <= a.x || a.bottom() <= b.y || b.bottom() <= a.y; }
|
||||
static bool above(const Rect &a, const Rect &b) { return a.bottom() <= b.y; }
|
||||
|
||||
// Every part inside the area, nothing over anything else, the keys in one row and the volume row at the bottom.
|
||||
static void sound(const Layout &l, int width, int height) {
|
||||
const Rect *parts[] = {&l.art, &l.title, &l.bar, &l.prev, &l.play, &l.next, &l.mute, &l.volume, &l.percent};
|
||||
for (const Rect *r : parts) assert(r->w > 0 && r->h > 0 && inside(*r, width, height));
|
||||
if (l.artist) assert(l.artist_line.h > 0 && inside(l.artist_line, width, height));
|
||||
if (l.times) assert(l.elapsed.h > 0 && l.total.h > 0 && inside(l.elapsed, width, height) && inside(l.total, width, height));
|
||||
// The keys share a centre line, the play key is the biggest, previous and next are alike.
|
||||
assert(l.prev.cy() == l.play.cy() && l.next.cy() == l.play.cy());
|
||||
assert(l.play.w > l.prev.w && l.prev.w == l.next.w && l.prev.w == l.prev.h);
|
||||
assert(l.prev.right() < l.play.x && l.play.right() < l.next.x);
|
||||
// The volume row runs along the bottom under everything else: the mute key, the slider, the percentage.
|
||||
assert(l.mute.right() < l.volume.x && l.volume.right() < l.percent.x);
|
||||
for (const Rect *r : {&l.art, &l.title, &l.bar, &l.prev, &l.play, &l.next}) assert(above(*r, l.volume) && above(*r, l.mute));
|
||||
// Nothing overlaps.
|
||||
const Rect *rects[] = {&l.art, &l.title, &l.artist_line, &l.bar, &l.elapsed, &l.total, &l.prev, &l.play, &l.next, &l.mute, &l.volume, &l.percent};
|
||||
for (const Rect *a : rects) for (const Rect *b : rects) if (a != b && a->w && b->w) assert(apart(*a, *b));
|
||||
// The art is square and its corner follows its size.
|
||||
assert(l.art.w == l.art.h && l.art_radius == radius_for(l.art.w));
|
||||
}
|
||||
|
||||
int main() {
|
||||
// The Guition's card: 480 wide under its top bar, a tall area, so everything stacks under the art.
|
||||
{
|
||||
Metrics m; Layout l = layout(m, 480, 396);
|
||||
sound(l, 480, 396);
|
||||
assert(!l.wide && l.times && l.artist);
|
||||
assert(l.art.w >= 160 && l.art.cx() == 240);
|
||||
assert(above(l.art, l.title) && above(l.title, l.artist_line) && above(l.artist_line, l.bar) && above(l.bar, l.play));
|
||||
assert(l.play.cx() == 240 && l.title.x == 24 && l.title.w == 432);
|
||||
// The times sit at the ends of the bar, on its line.
|
||||
assert(l.elapsed.cy() == l.bar.cy() && l.total.cy() == l.bar.cy() && l.elapsed.right() <= l.bar.x && l.bar.right() <= l.total.x);
|
||||
assert(l.elapsed.x == l.title.x && l.total.right() == l.title.right());
|
||||
// The keys are big enough for a thumb.
|
||||
assert(l.play.w >= 64 && l.prev.w >= 52);
|
||||
printf("guition card: art %d at %d,%d keys y=%d volume y=%d\n", l.art.w, l.art.x, l.art.y, l.play.y, l.volume.y);
|
||||
}
|
||||
// The CYD's card: 320 wide, 192 tall under its bar: the art at the left, the column beside it, times included.
|
||||
{
|
||||
Metrics m; m.large = false; m.title_h = 21; m.artist_h = 17; m.small_h = 13;
|
||||
Layout l = layout(m, 320, 192);
|
||||
sound(l, 320, 192);
|
||||
assert(l.wide && l.times && l.artist && l.art.w == 120);
|
||||
assert(l.title.x > l.art.right() && l.title.y >= l.art.y && l.play.bottom() <= l.art.bottom());
|
||||
assert(l.title.right() == 310 && l.play.cx() == l.title.cx());
|
||||
printf("cyd card: art %d column x=%d w=%d keys y=%d\n", l.art.w, l.title.x, l.title.w, l.play.y);
|
||||
}
|
||||
// A Guition tile over the whole page under its head: wide, everything fits, the keys never wider than the column.
|
||||
{
|
||||
Metrics m; Layout l = layout(m, 448, 236);
|
||||
sound(l, 448, 236);
|
||||
assert(l.wide && l.times && l.artist && l.art.w >= 180);
|
||||
// The head of a real tile leaves 202 px: the artist line still fits beside a smaller cover.
|
||||
Layout r = layout(m, 448, 202);
|
||||
sound(r, 448, 202);
|
||||
assert(r.artist && r.times && r.art.w >= 150);
|
||||
assert(l.prev.x >= l.title.x && l.next.right() <= l.title.right());
|
||||
printf("guition full: art %d column w=%d gap=%d\n", l.art.w, l.title.w, l.play.x - l.prev.right());
|
||||
}
|
||||
// A CYD tile over the whole page under its head: little room, so the artist line goes and the art shrinks.
|
||||
{
|
||||
Metrics m; m.large = false; m.title_h = 21; m.artist_h = 17; m.small_h = 13;
|
||||
Layout l = layout(m, 302, 108);
|
||||
sound(l, 302, 108);
|
||||
assert(l.wide && !l.artist && l.art.w < 120 && l.art.w >= 60);
|
||||
assert(l.play.bottom() <= 108 - 22 - 6 && l.bar.h == 4);
|
||||
printf("cyd full: art %d keys y=%d bottom=%d\n", l.art.w, l.play.y, l.play.bottom());
|
||||
}
|
||||
// A very short tall area still holds every part (times gone, a small cover).
|
||||
{
|
||||
Metrics m; Layout l = layout(m, 300, 300);
|
||||
sound(l, 300, 300);
|
||||
assert(!l.wide);
|
||||
}
|
||||
// Progress: the position runs on while playing, stands still when paused, never beyond the track.
|
||||
assert(progress(0, 0, 0, true, 0) == -1);
|
||||
assert(progress(60, 1000, 1000, true, 240) == 250);
|
||||
assert(progress(60, 1000, 1060, true, 240) == 500);
|
||||
assert(progress(60, 1000, 1060, false, 240) == 250);
|
||||
assert(progress(60, 1000, 9000, true, 240) == 1000);
|
||||
assert(progress(60, 0, 9000, true, 240) == 250); // no report time: the position as it was
|
||||
assert(elapsed_seconds(60, 1000, 1030, true, 240) == 90 && elapsed_seconds(300, 0, 0, false, 240) == 240);
|
||||
// Times as every player writes them.
|
||||
assert(clock_text(0) == "0:00" && clock_text(187) == "3:07" && clock_text(3600) == "1:00:00" && clock_text(3765) == "1:02:45");
|
||||
// The second line and the idle words.
|
||||
assert(subtitle("KATZROAR", "Invocation") == "KATZROAR · Invocation");
|
||||
assert(subtitle("KATZROAR", "") == "KATZROAR" && subtitle("", "Invocation") == "Invocation" && subtitle("", "").empty());
|
||||
assert(std::string(idle_text("off")) == "Off" && std::string(idle_text("idle")) == "Not playing" && std::string(idle_text("standby")) == "Standby");
|
||||
assert(playing("playing") && playing("buffering") && !playing("paused"));
|
||||
assert(has_track("paused") && !has_track("idle") && !has_track("off"));
|
||||
printf("test_media_card: ok\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
"""The media card (app 0.2.77, firmware 0.2.64): what the app pre-computes for it, and the album cover the app serves
|
||||
like a camera image (camera_feed), at the size the card asks for, with the corners rounded over the colour behind them."""
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import io
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
import camera_feed # noqa: E402
|
||||
from core import COVER_MIN_FIRMWARE, extras, media_extras, state_message # noqa: E402
|
||||
|
||||
HAS_PIL = importlib.util.find_spec('PIL') is not None
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
if HAS_AIOHTTP:
|
||||
from server import Manager
|
||||
|
||||
TILES = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
|
||||
MODEL = (ROOT / 'components/smart_display/runtime_model.h').read_text()
|
||||
CARD = (ROOT / 'components/smart_display/media_card.h').read_text()
|
||||
SONOS = {'friendly_name': 'Office', 'media_title': 'Woman at the Loom', 'media_artist': 'KATZROAR', 'media_album_name': 'Invocation',
|
||||
'media_duration': 270, 'media_position': 0, 'media_position_updated_at': '2026-09-18T10:09:49.068667+00:00',
|
||||
'volume_level': 0.14, 'is_volume_muted': False, 'supported_features': 8321599,
|
||||
'entity_picture': '/api/media_player_proxy/media_player.office?token=5674b25e&cache=556b49fd9c3e8ccd'}
|
||||
|
||||
|
||||
def picture(size=(640, 640), mode='RGB', fmt='JPEG'):
|
||||
from PIL import Image
|
||||
out = io.BytesIO()
|
||||
Image.new(mode, size, (200, 30, 90) if mode == 'RGB' else None).save(out, fmt)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
class Extras(unittest.TestCase):
|
||||
def test_the_card_gets_artist_album_length_position_and_a_picture_mark(self):
|
||||
extra = media_extras(SONOS)
|
||||
self.assertEqual(extra['artist'], 'KATZROAR')
|
||||
self.assertEqual(extra['album'], 'Invocation')
|
||||
self.assertEqual(extra['dur'], 270)
|
||||
self.assertEqual(extra['pos'], 0)
|
||||
self.assertEqual(extra['at'], 1789726189) # 2026-09-18T10:09:49Z
|
||||
self.assertTrue(re.fullmatch(r'[0-9a-f]{10}', extra['pic']))
|
||||
# The mark follows the picture and nothing else: a new cache token is a new picture.
|
||||
other = media_extras({**SONOS, 'entity_picture': SONOS['entity_picture'].replace('556b49fd9c3e8ccd', '1')})
|
||||
self.assertNotEqual(other['pic'], extra['pic'])
|
||||
self.assertEqual(media_extras({**SONOS, 'volume_level': 0.5})['pic'], extra['pic'])
|
||||
|
||||
def test_a_player_without_a_track_gets_no_extras(self):
|
||||
self.assertIsNone(media_extras({'friendly_name': 'Bedroom', 'supported_features': 8321599}))
|
||||
self.assertIsNone(media_extras({'media_artist': None, 'media_duration': None, 'media_position_updated_at': None}))
|
||||
# A stream: a title, no length, no picture. The card shows an empty bar and no times.
|
||||
self.assertIsNone(media_extras({'media_title': 'NPO Radio 2', 'media_content_type': 'channel'}))
|
||||
|
||||
def test_odd_values_stay_away(self):
|
||||
extra = media_extras({**SONOS, 'media_duration': -5, 'media_position': float('nan'), 'media_position_updated_at': 'yesterday',
|
||||
'media_artist': ' ', 'entity_picture': 42})
|
||||
self.assertNotIn('dur', extra)
|
||||
self.assertNotIn('pos', extra)
|
||||
self.assertNotIn('at', extra)
|
||||
self.assertNotIn('artist', extra)
|
||||
self.assertNotIn('pic', extra)
|
||||
self.assertEqual(extra['album'], 'Invocation')
|
||||
# Long names are cut like every text on a screen; a picture the state names as local counts too.
|
||||
extra = media_extras({'media_artist': 'x' * 200, 'entity_picture_local': '/api/media_player_proxy/a?token=b'})
|
||||
self.assertEqual(len(extra['artist']), 80)
|
||||
self.assertIn('pic', extra)
|
||||
|
||||
def test_the_state_message_carries_them_and_stays_small(self):
|
||||
states = {'media_player.office': {'state': 'playing', 'attributes': SONOS}}
|
||||
tile = {'entity': 'media_player.office', 'name': ''}
|
||||
message = state_message(0, tile, states, extras(tile, states))
|
||||
self.assertEqual(message['x']['artist'], 'KATZROAR')
|
||||
self.assertEqual(message['a']['media_title'], 'Woman at the Loom')
|
||||
self.assertLess(len(str(message)), 700)
|
||||
|
||||
|
||||
class Firmware(unittest.TestCase):
|
||||
def test_the_firmware_reads_what_the_app_sends(self):
|
||||
for key in ('extra["artist"]', 'extra["album"]', 'extra["pic"]', 'extra["dur"]', 'extra["pos"]', 'extra["at"]'):
|
||||
self.assertIn(key, TILES, key)
|
||||
for field in ('media_artist', 'media_album', 'media_picture', 'media_duration', 'media_position', 'media_position_at'):
|
||||
self.assertIn(field, MODEL, field)
|
||||
self.assertIn(field, MODEL[MODEL.index('bool empty() const'):], f'{field} counted in Extra::empty')
|
||||
|
||||
def test_the_card_asks_for_a_cover_with_size_and_background(self):
|
||||
self.assertIn('"size", "bg"', TILES)
|
||||
self.assertIn('view != "cover"', TILES)
|
||||
self.assertIn('if (view == "cover")', TILES)
|
||||
# The keys of the card and of the tile over the whole page share one action table.
|
||||
self.assertIn('inline void media_action(Tile &t,int cmd)', TILES)
|
||||
self.assertIn('media_player.volume_mute', TILES)
|
||||
# The cover goes before the camera loads into the same buffer, and comes back after.
|
||||
self.assertIn('cover_release();\n if (camera_release_due) camera_release();', TILES)
|
||||
|
||||
def test_the_layout_has_no_colour_of_its_own(self):
|
||||
self.assertIsNone(re.search(r'0x[0-9A-Fa-f]{6}', CARD))
|
||||
self.assertNotIn('lv_', CARD)
|
||||
|
||||
|
||||
class Covers(unittest.TestCase):
|
||||
def test_a_media_player_may_ask_for_a_cover_and_a_camera_may_not(self):
|
||||
self.assertTrue(camera_feed.cover_supported('media_player.office'))
|
||||
self.assertFalse(camera_feed.cover_supported('camera.front_door'))
|
||||
self.assertFalse(camera_feed.cover_supported('media_player.Office'))
|
||||
self.assertFalse(camera_feed.supported('media_player.office'))
|
||||
self.assertEqual(camera_feed.COVER_MIN_FIRMWARE, COVER_MIN_FIRMWARE)
|
||||
self.assertTrue(camera_feed.can_show_cover({'board': 'guition', 'firmware': '0.2.64'}))
|
||||
self.assertFalse(camera_feed.can_show_cover({'board': 'guition', 'firmware': '0.2.63'}))
|
||||
self.assertFalse(camera_feed.can_show_cover({'board': 'cyd', 'firmware': '0.2.64'}))
|
||||
|
||||
def test_the_request_names_a_size_and_a_background(self):
|
||||
self.assertEqual(camera_feed.cover_request({'size': '160', 'bg': 'E7E7E7'}), (160, 0xE7E7E7))
|
||||
self.assertEqual(camera_feed.cover_request({'size': 200, 'bg': '1a1a1a'}), (200, 0x1A1A1A))
|
||||
for bad in ({'size': '9999', 'bg': 'FFFFFF'}, {'size': '10', 'bg': 'FFFFFF'}, {'size': '160', 'bg': 'white'},
|
||||
{'size': '160'}, {'bg': 'FFFFFF'}, {'size': 'x', 'bg': 'FFFFFF'}):
|
||||
self.assertIsNone(camera_feed.cover_request(bad), bad)
|
||||
|
||||
@unittest.skipUnless(HAS_PIL, 'Pillow')
|
||||
def test_the_cover_is_square_at_the_size_asked_with_rounded_corners_over_the_background(self):
|
||||
from PIL import Image
|
||||
out = camera_feed.encode_cover(picture((640, 640), fmt='PNG'), 160, 0x1A1A1A)
|
||||
with Image.open(io.BytesIO(out)) as image:
|
||||
self.assertEqual((image.format, image.size, image.mode), ('BMP', (160, 160), 'RGB'))
|
||||
self.assertEqual(image.getpixel((0, 0)), (26, 26, 26)) # the corner: the background
|
||||
self.assertEqual(image.getpixel((80, 80)), (200, 30, 90)) # the middle: the picture
|
||||
self.assertEqual(image.getpixel((0, 80)), (200, 30, 90)) # the edge between the corners: the picture
|
||||
# A wide picture is cut to its middle; a PNG with transparency lands on the background.
|
||||
with Image.open(io.BytesIO(camera_feed.encode_cover(picture((900, 300)), 48, 0xFFFFFF))) as image:
|
||||
self.assertEqual(image.size, (48, 48))
|
||||
with Image.open(io.BytesIO(camera_feed.encode_cover(picture((100, 100), 'RGBA', 'PNG'), 48, 0x00FF00))) as image:
|
||||
self.assertEqual(image.size, (48, 48))
|
||||
|
||||
@unittest.skipUnless(HAS_PIL, 'Pillow')
|
||||
def test_a_cover_is_fetched_when_the_picture_changes_and_served_at_a_link(self):
|
||||
pictures = {'media_player.office': '/api/media_player_proxy/media_player.office?token=a&cache=1'}
|
||||
fetched = []
|
||||
|
||||
async def fetch_cover(entity):
|
||||
fetched.append(pictures[entity])
|
||||
return picture((640, 640)) if 'cache=1' in pictures[entity] else picture((300, 300))
|
||||
|
||||
async def scenario():
|
||||
feed = camera_feed.CameraFeed(lambda entity: None, fetch_cover=fetch_cover, picture=lambda entity: pictures.get(entity, ''))
|
||||
# No picture: no cover, nothing fetched.
|
||||
self.assertIsNone(await feed.cover('media_player.bedroom', 160, 0))
|
||||
self.assertEqual(fetched, [])
|
||||
# The first load fetches; the next load of the same picture serves what it has.
|
||||
tag, image = await feed.cover('media_player.office', 160, 0xFFFFFF)
|
||||
self.assertEqual(len(fetched), 1)
|
||||
self.assertTrue(tag.endswith('-c160-FFFFFF"'))
|
||||
again = await feed.cover('media_player.office', 160, 0xFFFFFF)
|
||||
self.assertEqual(len(fetched), 1)
|
||||
self.assertEqual(again, (tag, image))
|
||||
# Another size or background: the same picture, sized again.
|
||||
other_tag, other = await feed.cover('media_player.office', 192, 0x1A1A1A)
|
||||
self.assertEqual(len(fetched), 1)
|
||||
self.assertNotEqual(other_tag, tag)
|
||||
self.assertNotEqual(other, image)
|
||||
# A new picture in Home Assistant: fetched again on the next load.
|
||||
pictures['media_player.office'] = '/api/media_player_proxy/media_player.office?token=a&cache=2'
|
||||
new_tag, _ = await feed.cover('media_player.office', 160, 0xFFFFFF)
|
||||
self.assertEqual(len(fetched), 2)
|
||||
self.assertNotEqual(new_tag, tag)
|
||||
# The link: a screen loads it, and the link answers with the cover of this moment.
|
||||
token = feed.link('media_player.office', (160, 160), cover=(160, 0xFFFFFF))
|
||||
status, body, etag = await feed.serve(token)
|
||||
self.assertEqual((status, etag), (200, new_tag))
|
||||
self.assertEqual(body[:2], b'BM')
|
||||
self.assertEqual((await feed.serve(token, etag))[0], 304)
|
||||
# A player whose picture went away: no cover.
|
||||
pictures['media_player.office'] = ''
|
||||
self.assertIsNone(await feed.cover('media_player.office', 160, 0xFFFFFF))
|
||||
self.assertEqual((await feed.serve(token))[0], 503)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
@unittest.skipUnless(HAS_PIL, 'Pillow')
|
||||
def test_a_failing_fetch_gives_no_cover(self):
|
||||
async def failing(entity):
|
||||
raise ConnectionError('gone')
|
||||
|
||||
async def scenario():
|
||||
feed = camera_feed.CameraFeed(lambda entity: None, fetch_cover=failing, picture=lambda entity: '/api/x')
|
||||
self.assertIsNone(await feed.cover('media_player.office', 160, 0))
|
||||
self.assertEqual(feed.watches['media_player.office'].failures, 1)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP and HAS_PIL, 'aiohttp and Pillow')
|
||||
class Answer(unittest.TestCase):
|
||||
"""The app's answer to a screen's request, through Manager.answer_camera with a Home Assistant stand-in."""
|
||||
|
||||
class HA:
|
||||
online = True
|
||||
|
||||
def __init__(self, firmware='0.2.64', guition=True):
|
||||
self.registry = [{'entity_id': 'text.hall_tile_settings', 'platform': 'esphome', 'original_name': 'Tile settings', 'device_id': 'd1'},
|
||||
{'entity_id': 'sensor.hall_node', 'platform': 'esphome', 'original_name': 'Device name', 'device_id': 'd1'},
|
||||
{'entity_id': 'sensor.hall_fw', 'platform': 'esphome', 'original_name': 'Screen firmware', 'device_id': 'd1'}]
|
||||
if guition:
|
||||
self.registry.append({'entity_id': 'select.hall_type', 'platform': 'esphome', 'original_name': 'Guition screen type', 'device_id': 'd1'})
|
||||
self.devices, self.areas = [{'id': 'd1', 'name': 'Hall'}], []
|
||||
self.states = {'text.hall_tile_settings': {'state': 'Synced'}, 'sensor.hall_node': {'state': 'hall'}, 'sensor.hall_fw': {'state': firmware},
|
||||
'media_player.office': {'state': 'playing', 'attributes': SONOS},
|
||||
'media_player.radio': {'state': 'playing', 'attributes': {'media_title': 'Radio 2', 'supported_features': 8321599}}}
|
||||
self.sent, self.responses, self.time_zone, self.changed = [], set(), None, asyncio.Event()
|
||||
|
||||
async def send(self, inbox, message, action=None, respond=False):
|
||||
self.sent.append((inbox, message))
|
||||
|
||||
async def request(self, kind, **data):
|
||||
return {'adapters': [{'default': True, 'ipv4': [{'address': '192.168.1.5'}]}]} if kind == 'network' else {}
|
||||
|
||||
def media_picture(self, entity):
|
||||
attrs = self.states.get(entity, {}).get('attributes', {})
|
||||
return attrs.get('entity_picture') or ''
|
||||
|
||||
async def media_image(self, entity):
|
||||
return picture()
|
||||
|
||||
def manager(self, ha):
|
||||
import tempfile
|
||||
manager = Manager(ha, Path(tempfile.mkdtemp()) / 'screens.json')
|
||||
manager.save('text.hall_tile_settings', {'title': 'Hall', 'tiles': [{'entity': 'media_player.office', 'name': ''}, {'entity': 'media_player.radio', 'name': ''}]})
|
||||
return manager
|
||||
|
||||
def test_a_guition_card_gets_a_link_a_stream_an_empty_one_and_a_stranger_nothing(self):
|
||||
ha = self.HA()
|
||||
manager = self.manager(ha)
|
||||
|
||||
async def scenario():
|
||||
await manager.answer_camera({'inbox': 'text.hall_tile_settings', 'entity': 'media_player.office', 'size': '160', 'bg': 'E7E7E7'})
|
||||
self.assertEqual(len(ha.sent), 1)
|
||||
message = ha.sent[0][1]
|
||||
self.assertEqual((message['op'], message['t'], message['e']), ('camera', 'cover', 'media_player.office'))
|
||||
self.assertTrue(message['u'].startswith('http://192.168.1.5:') and message['u'].endswith('.bmp'))
|
||||
token = message['u'].rsplit('/', 1)[1][:-4]
|
||||
status, body, _ = await manager.camera.serve(token)
|
||||
self.assertEqual(status, 200)
|
||||
from PIL import Image
|
||||
with Image.open(io.BytesIO(body)) as image:
|
||||
self.assertEqual(image.size, (160, 160))
|
||||
self.assertEqual(image.getpixel((0, 0)), (0xE7, 0xE7, 0xE7))
|
||||
# A stream without a picture: an empty link, so the card keeps its placeholder and stops asking.
|
||||
await manager.answer_camera({'inbox': 'text.hall_tile_settings', 'entity': 'media_player.radio', 'size': '160', 'bg': 'E7E7E7'})
|
||||
self.assertEqual(ha.sent[1][1], {'v': 1, 'op': 'camera', 't': 'cover', 'e': 'media_player.radio', 'u': ''})
|
||||
# A player that is not on the screen's layout, a size out of range, a request without a size: no answer.
|
||||
for request in ({'inbox': 'text.hall_tile_settings', 'entity': 'media_player.other', 'size': '160', 'bg': 'E7E7E7'},
|
||||
{'inbox': 'text.hall_tile_settings', 'entity': 'media_player.office', 'size': '2000', 'bg': 'E7E7E7'},
|
||||
{'inbox': 'text.hall_tile_settings', 'entity': 'media_player.office'}):
|
||||
await manager.answer_camera(request)
|
||||
self.assertEqual(len(ha.sent), 2)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
def test_older_firmware_and_a_cyd_get_no_cover(self):
|
||||
for ha in (self.HA(firmware='0.2.63'), self.HA(guition=False)):
|
||||
manager = self.manager(ha)
|
||||
asyncio.run(manager.answer_camera({'inbox': 'text.hall_tile_settings', 'entity': 'media_player.office', 'size': '160', 'bg': 'E7E7E7'}))
|
||||
self.assertEqual(ha.sent, [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user