Show who is at the door: camera images on the Guition (0.2.66)
An alert from esp_screens_show_alert takes a `camera` (a camera.* or image.* entity): a Guition with firmware 0.2.57 shows that moment's picture across the top of the card, and a tap opens the camera full screen. A camera or image tile opens the same full-screen view, refreshed every four seconds at a steady pace. ESP Screen Manager fetches the snapshot with its own access, sizes it for the screen and serves a 24-bit BMP under a random, short-lived link on port 8098; the screen loads it with ESPHome's online_image and holds no Home Assistant token. BMP because ESPHome decodes it per chunk, where a full-screen JPEG held the loop 0.6 s. The CYD keeps its alerts without the picture and gets no camera tiles. The Guition also builds with ESPHome 2026.8+ again: backlight_fade.h derived from ESPHome's LEDC output, which 2026.8 made final, so the backlight now dims through ESPHome's own light transition. Tested on the bench boards and a local add-on on the Home Assistant Yellow; docs/TEST_RESULTS_0266.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,60 +0,0 @@
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <driver/ledc.h>
|
||||
#include "esphome/components/ledc/ledc_output.h"
|
||||
#include "esphome/components/light/light_state.h"
|
||||
|
||||
// Backlight fades through the LEDC hardware fader. ESPHome's software
|
||||
// transition steps the duty from the main loop, and the full LVGL redraws that
|
||||
// standby triggers on the Guition's RGB panel stall that loop long enough to
|
||||
// make dimming visibly jerky. The peripheral steps on its own clock instead.
|
||||
namespace backlight_fade {
|
||||
template<typename T> struct Peek : T {
|
||||
static uint8_t channel(T &o) { return o.*(&Peek::channel_); }
|
||||
static uint8_t bits(T &o) { return o.*(&Peek::bit_depth_); }
|
||||
};
|
||||
inline bool installed = false, hardware_owns = false;
|
||||
inline ledc_mode_t mode(uint8_t channel) {
|
||||
#ifdef SOC_LEDC_SUPPORT_HS_MODE
|
||||
return channel < 8 ? LEDC_HIGH_SPEED_MODE : LEDC_LOW_SPEED_MODE;
|
||||
#else
|
||||
return LEDC_LOW_SPEED_MODE;
|
||||
#endif
|
||||
}
|
||||
// Returns false when the fader is unavailable; the caller keeps ESPHome's transition.
|
||||
inline bool start(esphome::ledc::LEDCOutput &output, esphome::light::LightState &light, float level, uint32_t ms) {
|
||||
#ifndef SOC_LEDC_SUPPORT_FADE_STOP
|
||||
return false; // Classic ESP32 cannot interrupt a running fade for a touch wake.
|
||||
#else
|
||||
using P = Peek<esphome::ledc::LEDCOutput>;
|
||||
const auto channel = static_cast<ledc_channel_t>(P::channel(output));
|
||||
const uint8_t bits = P::bits(output);
|
||||
if (!bits) return false;
|
||||
if (!installed) installed = ledc_fade_func_install(0) == ESP_OK;
|
||||
if (!installed) return false;
|
||||
const auto speed = mode(channel);
|
||||
const uint32_t max_duty = (1u << bits) - 1;
|
||||
ledc_fade_stop(speed, channel);
|
||||
// ESPHome parks 0 % and 100 % with ledc_stop and leaves a stale duty register.
|
||||
// Restart the channel at the level it believes, unless a fade already owns it.
|
||||
float current = 0;
|
||||
light.current_values_as_brightness(¤t);
|
||||
const uint32_t from = hardware_owns ? ledc_get_duty(speed, channel) : static_cast<uint32_t>(std::lround(current * max_duty));
|
||||
ledc_set_duty(speed, channel, from);
|
||||
ledc_update_duty(speed, channel);
|
||||
hardware_owns = true;
|
||||
const auto target = static_cast<uint32_t>(std::lround(light.gamma_correct_lut(std::clamp(level, 0.0f, 1.0f)) * max_duty));
|
||||
return ledc_set_fade_with_time(speed, channel, target, ms) == ESP_OK &&
|
||||
ledc_fade_start(speed, channel, LEDC_FADE_NO_WAIT) == ESP_OK;
|
||||
#endif
|
||||
}
|
||||
// Hand the finished level back to ESPHome so HA and later transitions start from the truth.
|
||||
inline void sync(esphome::ledc::LEDCOutput &output, esphome::light::LightState &light, float level) {
|
||||
#ifdef SOC_LEDC_SUPPORT_FADE_STOP
|
||||
if (hardware_owns) ledc_fade_stop(mode(Peek<esphome::ledc::LEDCOutput>::channel(output)), static_cast<ledc_channel_t>(Peek<esphome::ledc::LEDCOutput>::channel(output)));
|
||||
#endif
|
||||
light.turn_on().set_transition_length(0).set_brightness(std::clamp(level, 0.0f, 1.0f)).perform();
|
||||
hardware_owns = false;
|
||||
}
|
||||
} // namespace backlight_fade
|
||||
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
// A camera image full screen (firmware 0.2.57+, Guition): when to ask ESP Screen Manager for a link and when to load it
|
||||
// again. Pure bookkeeping, free of LVGL and ESPHome, so tests/test_camera_view.cpp can check it on a PC; runtime_tiles.h
|
||||
// draws the view and the board's online_image loads the link.
|
||||
//
|
||||
// The app answers `esphome.screen_camera` with a link to a BMP it keeps fresh (screen_manager/app/camera_feed.py).
|
||||
// Loading shares the main loop with touch and drawing, so the view loads one image at a time and leaves time free after
|
||||
// each. The rhythm is fixed rather than as fast as the Wi-Fi allows: a picture every four seconds, every time, reads as
|
||||
// smoother than one after three seconds and the next after five. Never a queue: a slow load makes the next one later.
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace camera_view {
|
||||
constexpr uint32_t REFRESH_MS = 4000; // from the start of one load to the start of the next: a steady rhythm
|
||||
constexpr uint32_t GAP_MS = 800; // at least this long between the end of one load and the next
|
||||
constexpr uint32_t ASK_AGAIN_MS = 10000; // a link that does not come (or an app without an image) is asked for again
|
||||
constexpr uint8_t MAX_FAILURES = 3; // a link that fails this often in a row is old: ask for a new one
|
||||
constexpr uint32_t PENDING_MS = 20000; // an alert's camera announced this long before the alert still belongs to it
|
||||
|
||||
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
|
||||
uint8_t failures = 0;
|
||||
|
||||
void open(const std::string &camera) { *this = Feed{}; entity = camera; }
|
||||
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); }
|
||||
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) {
|
||||
url = address;
|
||||
empty = address.empty();
|
||||
failures = 0;
|
||||
started_at = finished_at = 0;
|
||||
}
|
||||
bool should_load(uint32_t now) const {
|
||||
if (!open() || loading || url.empty()) return false;
|
||||
if (!started_at) return true;
|
||||
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 (++failures >= MAX_FAILURES) { url.clear(); asked = false; failures = 0; }
|
||||
}
|
||||
};
|
||||
} // namespace camera_view
|
||||
@@ -23,9 +23,13 @@ inline bool check_geometry(lv_obj_t *obj) {
|
||||
}
|
||||
#if LV_USE_SNAPSHOT
|
||||
inline lv_draw_buf_t *snapshot = nullptr;
|
||||
// LVGL's top layer (an alert, a camera full screen) when something is shown on it, with its transparency.
|
||||
inline lv_draw_buf_t *overlay = nullptr;
|
||||
inline int snapshot_row = 0;
|
||||
inline void snapshot_begin() {
|
||||
if (snapshot) lv_draw_buf_destroy(snapshot);
|
||||
if (overlay) lv_draw_buf_destroy(overlay);
|
||||
overlay = nullptr;
|
||||
snapshot = lv_snapshot_take(lv_screen_active(), LV_COLOR_FORMAT_RGB565);
|
||||
snapshot_row = 0;
|
||||
if (!snapshot || snapshot->header.w != 480 || snapshot->header.h != 480) {
|
||||
@@ -34,7 +38,24 @@ inline void snapshot_begin() {
|
||||
ESP_LOGE("ui_image", "IMAGE FAIL allocation/dimensions");
|
||||
return;
|
||||
}
|
||||
ESP_LOGI("ui_image", "IMAGE BEGIN 240 240");
|
||||
for (uint32_t i = 0; i < lv_obj_get_child_count(lv_layer_top()); ++i)
|
||||
if (!lv_obj_has_flag(lv_obj_get_child(lv_layer_top(), i), LV_OBJ_FLAG_HIDDEN)) {
|
||||
overlay = lv_snapshot_take(lv_layer_top(), LV_COLOR_FORMAT_ARGB8888);
|
||||
if (overlay && (overlay->header.w != 480 || overlay->header.h != 480)) { lv_draw_buf_destroy(overlay); overlay = nullptr; }
|
||||
break;
|
||||
}
|
||||
ESP_LOGI("ui_image", "IMAGE BEGIN 240 240%s", overlay ? " with top layer" : "");
|
||||
}
|
||||
// The top layer's pixel over the page's, as the panel shows them.
|
||||
inline uint16_t layered(uint16_t pixel, int x, int y) {
|
||||
if (!overlay) return pixel;
|
||||
const uint8_t *p = overlay->data + y * overlay->header.stride + x * 4; // B, G, R, A
|
||||
const unsigned alpha = p[3];
|
||||
if (!alpha) return pixel;
|
||||
const unsigned r = ((pixel >> 11) & 31) * 255 / 31, g = ((pixel >> 5) & 63) * 255 / 63, b = (pixel & 31) * 255 / 31;
|
||||
const unsigned mr = (p[2] * alpha + r * (255 - alpha)) / 255, mg = (p[1] * alpha + g * (255 - alpha)) / 255,
|
||||
mb = (p[0] * alpha + b * (255 - alpha)) / 255;
|
||||
return static_cast<uint16_t>((mr * 31 / 255) << 11 | (mg * 63 / 255) << 5 | (mb * 31 / 255));
|
||||
}
|
||||
inline void snapshot_next_row() {
|
||||
if (!snapshot) return;
|
||||
@@ -43,7 +64,7 @@ inline void snapshot_next_row() {
|
||||
static constexpr char hex[] = "0123456789abcdef";
|
||||
char line[961];
|
||||
for (int x = 0; x < 240; ++x) {
|
||||
uint16_t pixel = row[x * 2];
|
||||
uint16_t pixel = layered(row[x * 2], x * 2, snapshot_row * 2);
|
||||
for (int digit = 0; digit < 4; ++digit)
|
||||
line[x * 4 + digit] = hex[(pixel >> (12 - digit * 4)) & 15];
|
||||
}
|
||||
@@ -52,6 +73,8 @@ inline void snapshot_next_row() {
|
||||
if (++snapshot_row == 240) {
|
||||
lv_draw_buf_destroy(snapshot);
|
||||
snapshot = nullptr;
|
||||
if (overlay) lv_draw_buf_destroy(overlay);
|
||||
overlay = nullptr;
|
||||
ESP_LOGI("ui_image", "IMAGE COMPLETE");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ inline bool valid_entity(const std::string &entity) {
|
||||
std::string domain = entity.substr(0, dot);
|
||||
// screen.* are built-in cards without a Home Assistant entity behind them.
|
||||
if (domain == "screen") return entity == "screen.clock" || entity == "screen.settings";
|
||||
for (const auto *allowed : {"light", "switch", "input_boolean", "scene", "script", "climate", "vacuum", "fan", "cover", "sensor", "binary_sensor", "input_select", "select", "number", "input_number", "weather", "media_player", "button", "input_button", "sun", "timer", "person"})
|
||||
for (const auto *allowed : {"light", "switch", "input_boolean", "scene", "script", "climate", "vacuum", "fan", "cover", "sensor", "binary_sensor", "input_select", "select", "number", "input_number", "weather", "media_player", "button", "input_button", "sun", "timer", "person", "camera", "image"})
|
||||
if (domain == allowed) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include "light_controls.h"
|
||||
#include "tile_controls.h"
|
||||
#include "history_view.h"
|
||||
#include "camera_view.h"
|
||||
#include "swipe_profile.h"
|
||||
#include "esphome/components/json/json_util.h"
|
||||
#include "esphome/components/api/api_server.h"
|
||||
@@ -71,6 +72,10 @@ inline const char *weather_text(const std::string &condition);
|
||||
inline std::string timer_text(const Tile &t);
|
||||
inline std::string last_run_text(uint32_t epoch);
|
||||
inline void history_received();
|
||||
// Camera images full screen and on an alert (firmware 0.2.57+, the Guition binds them; see the end of this file).
|
||||
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();
|
||||
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);
|
||||
@@ -340,6 +345,16 @@ inline std::string receive(const std::string &payload) {
|
||||
result = model.ready() ? "Synced" : "Loading tiles";
|
||||
return true;
|
||||
}
|
||||
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.
|
||||
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 (!url.empty() && url.rfind("http://", 0) != 0) return false;
|
||||
camera_answer(view, entity, url);
|
||||
result = model.ready() ? "Synced" : "Loading tiles";
|
||||
return true;
|
||||
}
|
||||
if (op == "history") {
|
||||
// A detail card's history (app 0.2.59+), the answer to history_request. Checked whole before it replaces
|
||||
// the one the screen holds.
|
||||
@@ -1684,6 +1699,7 @@ inline const char *icon_for(const Tile &tile) {
|
||||
if (d == "timer") return "\U000F051B";
|
||||
if (d == "person") return "\U000F0004";
|
||||
if (d == "screen") return tile.is_settings() ? "\U000F0493" : "\U000F0150";
|
||||
if (d == "camera" || d == "image") return "\U000F07AE";
|
||||
return "\U000F0425";
|
||||
}
|
||||
// HA duration strings ("0:05:00") to seconds; 0 when unusable.
|
||||
@@ -1749,6 +1765,8 @@ inline void event(lv_event_t *event) {
|
||||
auto d = tile.domain();
|
||||
// Scenes/scripts often have timestamps or 'off'; unavailable devices never act.
|
||||
if (!tile.available() || tile.loading(esphome::millis()) || tile.tap=="none") return;
|
||||
// A camera or an image entity opens full screen on a board that draws images (firmware 0.2.57+).
|
||||
if(d=="camera" || d=="image"){if(camera_supported())camera_open(tile.entity,tile.name);return;}
|
||||
bool open = code == LV_EVENT_LONG_PRESSED || d == "climate" || d == "vacuum" || d == "cover";
|
||||
if(code==LV_EVENT_SHORT_CLICKED && tile.tap=="detail")open=true;
|
||||
if(code==LV_EVENT_SHORT_CLICKED && tile.tap=="toggle")open=false;
|
||||
@@ -2395,6 +2413,8 @@ inline void render_slot(size_t slot) {
|
||||
else if (d == "sun") value = !t.extra().sunrise.empty() && !t.extra().sunset.empty() ? t.extra().sunrise+" - "+t.extra().sunset : t.state=="above_horizon"?"Above the horizon":"Below the horizon";
|
||||
else if (d == "timer") value = timer_text(t);
|
||||
else if (d == "script" || d == "scene" || d == "button" || d == "input_button") value = t.state == "on" ? "Running..." : last_run_text(t.last_run);
|
||||
else if (d == "camera") value = t.state == "streaming" ? "Live" : t.state == "recording" ? "Recording" : "Tap to view";
|
||||
else if (d == "image") value = t.last_run ? "Tap to view" : "No image yet";
|
||||
else if (d == "binary_sensor" && (value == "on" || value == "off")) value = tile_controls::binary_state_text(t.device_class, value == "on");
|
||||
else if (value == "on") value = "On";
|
||||
else if (value == "off") value = "Off";
|
||||
@@ -3084,3 +3104,261 @@ inline std::string vacuum_option(unsigned index) {
|
||||
return index < speeds.size() ? speeds[index] : "";
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Camera images (firmware 0.2.57+) ----
|
||||
// A camera or image tile, and an alert's image, open the camera full screen: the image as large as fits, the round back
|
||||
// key at the top left like on every card, the name beside it. ESP Screen Manager fetches the snapshot, sizes it for this
|
||||
// screen and serves it on its own port; camera_view::Feed decides when to ask for a link and when to load it again. The
|
||||
// board binds the two online_images (the full view and the alert's frame) through ImageHooks; a board without them (the
|
||||
// CYD) never opens the view. Nothing here exists while no camera is open, apart from the alert's small frame.
|
||||
namespace runtime_tiles {
|
||||
struct ImageHooks {
|
||||
std::function<void(const std::string &)> load; // set the online_image's URL and download it
|
||||
std::function<void()> release; // free the decoded image
|
||||
std::function<lv_image_dsc_t *()> source; // the decoded image for LVGL
|
||||
};
|
||||
inline ImageHooks camera_full, camera_thumb;
|
||||
inline camera_view::Feed camera;
|
||||
// Freeing the image waits for the next tick: ending a download under way can take a few hundred ms, and Back should
|
||||
// show the page below at once.
|
||||
inline bool camera_release_due = false;
|
||||
inline lv_obj_t *camera_root = nullptr, *camera_picture = nullptr, *camera_note = nullptr, *camera_back = nullptr, *camera_title = nullptr;
|
||||
// The alert's image: the board's frame at the bottom left of the card, the camera the app announced for the next alert
|
||||
// and the one the card on screen shows.
|
||||
inline lv_obj_t *alert_frame = nullptr, *alert_picture = nullptr, *alert_frame_icon = nullptr;
|
||||
inline std::function<void(bool)> alert_room; // the board makes the card taller for the frame, or back
|
||||
inline std::string alert_announced, alert_camera, alert_url;
|
||||
inline uint32_t alert_announced_at = 0, alert_retry_at = 0;
|
||||
inline uint8_t alert_retries = 0;
|
||||
inline bool alert_thumb_loading = false;
|
||||
constexpr uint8_t ALERT_IMAGE_RETRIES = 3; // an alert's picture is worth another try after a failed connection
|
||||
|
||||
inline bool camera_supported() { return static_cast<bool>(camera_full.load); }
|
||||
inline bool camera_visible() { return camera_root != nullptr; }
|
||||
|
||||
// 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) {
|
||||
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) {
|
||||
esphome::api::HomeassistantServiceMap entry;
|
||||
entry.key = esphome::StringRef(keys[i]);
|
||||
entry.value = esphome::StringRef(values[i]);
|
||||
request.data.push_back(entry);
|
||||
}
|
||||
esphome::api::global_api_server->send_homeassistant_action(request);
|
||||
ESP_LOGI("camera", "asked for %s", entity.c_str());
|
||||
}
|
||||
|
||||
inline void camera_note_text(const char *text) {
|
||||
if (!camera_note) return;
|
||||
lv_label_set_text(camera_note, text);
|
||||
if (text[0]) lv_obj_remove_flag(camera_note, LV_OBJ_FLAG_HIDDEN);
|
||||
else lv_obj_add_flag(camera_note, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
|
||||
inline void camera_release() {
|
||||
camera_release_due = false;
|
||||
if (camera_full.release) camera_full.release();
|
||||
}
|
||||
|
||||
inline void camera_close() {
|
||||
if (!camera_root) return;
|
||||
lv_obj_delete(camera_root);
|
||||
camera_root = camera_picture = camera_note = camera_back = camera_title = nullptr;
|
||||
camera_release_due = true;
|
||||
ESP_LOGI("camera", "closed %s", camera.entity.c_str());
|
||||
camera = camera_view::Feed{};
|
||||
}
|
||||
|
||||
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.
|
||||
if (camera_release_due) camera_release();
|
||||
camera.open(entity);
|
||||
const int width = lv_display_get_horizontal_resolution(lv_display_get_default());
|
||||
const bool large = width >= 480;
|
||||
// On the top layer: above the tiles, every card and an alert, which is there again after Back.
|
||||
camera_root = lv_obj_create(lv_layer_top());
|
||||
lv_obj_remove_style_all(camera_root);
|
||||
lv_obj_set_size(camera_root, lv_pct(100), lv_pct(100));
|
||||
lv_obj_remove_flag(camera_root, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_add_flag(camera_root, LV_OBJ_FLAG_CLICKABLE); // nothing reaches the tiles below
|
||||
lv_obj_set_style_bg_color(camera_root, theme::color(theme::CAMERA_PAGE), 0);
|
||||
lv_obj_set_style_bg_opa(camera_root, LV_OPA_COVER, 0);
|
||||
camera_note = lv_label_create(camera_root);
|
||||
if (detail_font) lv_obj_set_style_text_font(camera_note, detail_font, 0);
|
||||
lv_obj_set_style_text_color(camera_note, theme::color(theme::CAMERA_NOTE), 0);
|
||||
lv_obj_center(camera_note);
|
||||
camera_note_text("Loading image");
|
||||
// The same top bar as a tile's card: a round back arrow at the left, the name centred.
|
||||
const int bar = large ? 60 : 40, bar_x = large ? 16 : 10, bar_y = large ? 16 : 8;
|
||||
camera_back = lv_obj_create(camera_root);
|
||||
lv_obj_remove_style_all(camera_back);
|
||||
lv_obj_set_pos(camera_back, bar_x, bar_y);
|
||||
lv_obj_set_size(camera_back, bar, bar);
|
||||
lv_obj_add_flag(camera_back, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_set_style_radius(camera_back, LV_RADIUS_CIRCLE, 0);
|
||||
lv_obj_set_style_bg_opa(camera_back, LV_OPA_COVER, 0);
|
||||
lv_obj_set_style_bg_color(camera_back, theme::color(theme::KEY), 0);
|
||||
lv_obj_set_style_bg_color(camera_back, theme::color(theme::KEY_PRESSED), LV_STATE_PRESSED);
|
||||
auto *arrow = lv_label_create(camera_back);
|
||||
if (mini_icon_font) lv_obj_set_style_text_font(arrow, mini_icon_font, 0);
|
||||
lv_obj_set_style_text_color(arrow, theme::color(theme::INK), 0);
|
||||
lv_label_set_text(arrow, "\U000F004D");
|
||||
lv_obj_center(arrow);
|
||||
lv_obj_add_event_cb(camera_back, [](lv_event_t *) {
|
||||
// Closed after this event: the key that sends it goes with the view.
|
||||
lv_async_call([](void *) { camera_close(); }, nullptr);
|
||||
}, LV_EVENT_SHORT_CLICKED, nullptr);
|
||||
const lv_font_t *title_font = watch_font ? watch_font : detail_font;
|
||||
camera_title = lv_label_create(camera_root);
|
||||
if (title_font) lv_obj_set_style_text_font(camera_title, title_font, 0);
|
||||
lv_obj_set_style_text_color(camera_title, theme::color(theme::CAMERA_INK), 0);
|
||||
lv_obj_set_style_text_align(camera_title, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_label_set_long_mode(camera_title, LV_LABEL_LONG_DOT);
|
||||
lv_obj_set_pos(camera_title, bar_x + bar + 8, bar_y + (bar - (title_font ? lv_font_get_line_height(title_font) : 20)) / 2);
|
||||
lv_obj_set_size(camera_title, width - 2 * (bar_x + bar + 8), title_font ? lv_font_get_line_height(title_font) : 20);
|
||||
lv_label_set_text(camera_title, name.c_str());
|
||||
ESP_LOGI("camera", "open %s", entity.c_str());
|
||||
}
|
||||
|
||||
// The board's interval (250 ms): ask for a link, or load the image again when it is time. Loading happens here, in
|
||||
// ESPHome's loop, and never while the screen is in standby.
|
||||
inline void camera_tick() {
|
||||
const uint32_t now = esphome::millis();
|
||||
if (camera_release_due && !camera_root) camera_release();
|
||||
if (alert_retry_at && now >= alert_retry_at) {
|
||||
alert_retry_at = 0;
|
||||
if (!alert_camera.empty() && !alert_url.empty() && camera_thumb.load) {
|
||||
alert_thumb_loading = true;
|
||||
camera_thumb.load(alert_url);
|
||||
}
|
||||
}
|
||||
if (!camera_root || !awake()) return;
|
||||
if (camera.should_ask(now)) {
|
||||
if (!fresh()) return;
|
||||
camera.ask(now);
|
||||
camera_request(camera.entity);
|
||||
} else if (camera.should_load(now)) {
|
||||
// Not under a finger: a load that starts now would hold up the tap that is on its way.
|
||||
auto *input = lv_indev_get_next(nullptr);
|
||||
if (input && lv_indev_get_state(input) == LV_INDEV_STATE_PRESSED) return;
|
||||
camera.start(now);
|
||||
camera_full.load(camera.url);
|
||||
}
|
||||
}
|
||||
|
||||
inline void alert_picture_clear() {
|
||||
alert_retry_at = 0;
|
||||
if (alert_picture) { lv_obj_delete(alert_picture); alert_picture = nullptr; }
|
||||
if (alert_frame_icon) lv_obj_remove_flag(alert_frame_icon, LV_OBJ_FLAG_HIDDEN);
|
||||
if (alert_thumb_loading || alert_camera.size()) { if (camera_thumb.release) camera_thumb.release(); }
|
||||
alert_thumb_loading = false;
|
||||
}
|
||||
|
||||
// alert_show: the card gets its frame when the app announced a camera for it just before. A new alert closes the camera.
|
||||
inline void alert_prepare() {
|
||||
camera_close();
|
||||
alert_picture_clear();
|
||||
const bool with_image = camera_supported() && alert_frame && !alert_announced.empty() &&
|
||||
esphome::millis() - alert_announced_at < camera_view::PENDING_MS;
|
||||
alert_camera = with_image ? alert_announced : std::string();
|
||||
alert_url.clear();
|
||||
alert_announced.clear();
|
||||
if (alert_frame) {
|
||||
if (with_image) lv_obj_remove_flag(alert_frame, LV_OBJ_FLAG_HIDDEN);
|
||||
else lv_obj_add_flag(alert_frame, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
if (alert_room) alert_room(with_image);
|
||||
}
|
||||
|
||||
// alert_dismiss: the frame goes, and so does its image.
|
||||
inline void alert_clear() {
|
||||
alert_picture_clear();
|
||||
alert_camera.clear();
|
||||
alert_url.clear();
|
||||
if (alert_frame) lv_obj_add_flag(alert_frame, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
|
||||
inline void camera_answer(const std::string &view, const std::string &entity, const std::string &url) {
|
||||
if (!camera_supported()) return;
|
||||
if (view == "full") {
|
||||
if (!camera_root || camera.entity != entity) return;
|
||||
camera.link(url);
|
||||
if (url.empty() && !camera.shown) camera_note_text("No image from this camera");
|
||||
return;
|
||||
}
|
||||
if (url.empty()) { // announced before its alert
|
||||
alert_announced = entity;
|
||||
alert_announced_at = esphome::millis();
|
||||
return;
|
||||
}
|
||||
if (entity != alert_camera || !alert_frame || lv_obj_has_flag(alert_frame, LV_OBJ_FLAG_HIDDEN)) return;
|
||||
alert_picture_clear();
|
||||
alert_url = url;
|
||||
alert_retries = 0;
|
||||
alert_thumb_loading = true;
|
||||
camera_thumb.load(url);
|
||||
}
|
||||
|
||||
// LVGL's image widget is only built for a board whose profile draws images (the Guition's hidden seed); the CYD's has none.
|
||||
inline void camera_show(lv_obj_t *parent, lv_obj_t *&picture, lv_image_dsc_t *source, bool fresh_pixels) {
|
||||
#if LV_USE_IMAGE
|
||||
if (!source || !source->data) return;
|
||||
if (!picture) {
|
||||
picture = lv_image_create(parent);
|
||||
lv_obj_remove_flag(picture, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_image_set_src(picture, source);
|
||||
lv_obj_center(picture);
|
||||
return;
|
||||
}
|
||||
if (!fresh_pixels) return;
|
||||
// The same buffer with new pixels. LVGL keeps no decoded copy of an RGB565 image (its image cache is off in
|
||||
// ESPHome's build), so drawing the area again shows them.
|
||||
lv_image_set_src(picture, source);
|
||||
lv_obj_invalidate(picture);
|
||||
#else
|
||||
(void) parent; (void) picture; (void) source; (void) fresh_pixels;
|
||||
#endif
|
||||
}
|
||||
|
||||
// The board's online_image triggers. `thumb`: the alert's frame; `cached`: the app answered 304, the image is unchanged.
|
||||
inline void camera_loaded(bool thumb, bool cached) {
|
||||
if (thumb) {
|
||||
alert_thumb_loading = false;
|
||||
if (alert_camera.empty() || !alert_frame) return;
|
||||
camera_show(alert_frame, alert_picture, camera_thumb.source(), !cached);
|
||||
if (alert_picture && alert_frame_icon) lv_obj_add_flag(alert_frame_icon, LV_OBJ_FLAG_HIDDEN);
|
||||
return;
|
||||
}
|
||||
if (!camera_root) return;
|
||||
camera.finish(esphome::millis(), true);
|
||||
const bool first = camera_picture == nullptr;
|
||||
camera_show(camera_root, camera_picture, camera_full.source(), !cached);
|
||||
if (first && camera_picture) {
|
||||
camera_note_text("");
|
||||
lv_obj_move_foreground(camera_back);
|
||||
lv_obj_move_foreground(camera_title);
|
||||
}
|
||||
}
|
||||
|
||||
inline void camera_failed(bool thumb) {
|
||||
if (thumb) {
|
||||
alert_thumb_loading = false;
|
||||
if (!alert_camera.empty() && !alert_picture && alert_retries < ALERT_IMAGE_RETRIES) {
|
||||
++alert_retries;
|
||||
alert_retry_at = esphome::millis() + 1500;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!camera_root) return;
|
||||
camera.finish(esphome::millis(), false);
|
||||
if (!camera.shown) camera_note_text("No image from this camera");
|
||||
}
|
||||
} // namespace runtime_tiles
|
||||
|
||||
@@ -73,6 +73,10 @@ enum Role : uint8_t {
|
||||
AMBER_TRACK, // the track of a light's brightness slider
|
||||
HISTORY_OFF, // an off or away stretch on a timeline (the add-on's CFCFCF)
|
||||
HISTORY_EMPTY, // a stretch without data on a timeline (the add-on's E6E6E6)
|
||||
// ---- a camera image: dark in both looks, like a photo viewer (firmware 0.2.57+)
|
||||
CAMERA_PAGE, // behind the image full screen and in the alert's image frame
|
||||
CAMERA_INK, // the camera's name over the image
|
||||
CAMERA_NOTE, // "Loading image", "No image from this camera"
|
||||
// ---- the CYD's touch calibration: dark in both looks, a yellow crosshair
|
||||
CALIBRATION_PAGE,
|
||||
CALIBRATION_INK,
|
||||
@@ -137,6 +141,9 @@ inline constexpr Pair ROLES[ROLE_COUNT] = {
|
||||
/* AMBER_TRACK */ {0xFDEFC3, 0x483B16},
|
||||
/* HISTORY_OFF */ {0xCFCFCF, 0x4A4A4A},
|
||||
/* HISTORY_EMPTY */ {0xE6E6E6, 0x2A2A2A},
|
||||
/* CAMERA_PAGE */ {0x000000, 0x000000},
|
||||
/* CAMERA_INK */ {0xF2F2F2, 0xDADADA},
|
||||
/* CAMERA_NOTE */ {0x9E9E9E, 0x8A8A8A},
|
||||
/* CALIBRATION_PAGE */ {0x101820, 0x101820},
|
||||
/* CALIBRATION_INK */ {0xFFFFFF, 0xFFFFFF},
|
||||
/* CALIBRATION_MARK */ {0xFFD34D, 0xFFD34D},
|
||||
@@ -281,7 +288,7 @@ inline lv_color_t rgb(uint32_t value) { return lv_color_hex(value); }
|
||||
constexpr uint8_t NONE = 0xFF;
|
||||
enum class Paint : uint8_t {
|
||||
page, page_soft, page_pressed, card, raised, key, key_pressed, track, ink, ink_soft, muted, subtle, slate,
|
||||
scrim, veil, accent_tint, accent_bright, button_dark, button_dark_pressed, knob, spinner, calibration, COUNT
|
||||
scrim, veil, accent_tint, accent_bright, button_dark, button_dark_pressed, knob, spinner, calibration, camera, COUNT
|
||||
};
|
||||
struct PaintRoles { uint8_t bg, border, text, line, arc; };
|
||||
inline constexpr PaintRoles PAINTS[static_cast<size_t>(Paint::COUNT)] = {
|
||||
@@ -307,6 +314,7 @@ inline constexpr PaintRoles PAINTS[static_cast<size_t>(Paint::COUNT)] = {
|
||||
/* knob */ {KNOB, NONE, NONE, NONE, NONE},
|
||||
/* spinner */ {NONE, NONE, NONE, NONE, SPINNER_TRACK},
|
||||
/* calibration */ {CALIBRATION_PAGE, NONE, CALIBRATION_INK, NONE, NONE},
|
||||
/* camera */ {CAMERA_PAGE, NONE, CAMERA_NOTE, NONE, NONE},
|
||||
};
|
||||
// Fill a style with a paint in the current look. Setting a property the style already has allocates nothing.
|
||||
inline void fill(lv_style_t *style, Paint paint) {
|
||||
|
||||
Reference in New Issue
Block a user