Show an alert from a Home Assistant automation (0.2.36)
Every screen gets the user action esphome.<device>_show_alert with title, subtitle, icon, color, button_text, timeout and flash. The card sits on LVGL's top layer above every page, card and the dim overlay, wakes the screen and keeps the backlight at its normal brightness until the button (or the timeout; 0 waits for the button, the button always closes at once). flash: true blinks the backlight four times on arrival. Icons are the tile picker names (also mdi:name or the hex codepoint of a compiled glyph, else alert-outline), colours the tile pastels. A new alert replaces the current one; every ending fires esphome.screen_alert with action, title and screen. dismiss_alert removes the card remotely. The field is button_text rather than button: ESPHome reserves the names of loaded components as ids and the CYD profile loads button:. The C++ namespace is screen_alert because the widget id alert_overlay becomes a global lv_obj_t*. tools/generate_icons.py now also writes tile_icon_names.h (name to codepoint) from the one icon list. Firmware 0.2.31 for both boards. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
"""The alert a Home Assistant action puts over the whole screen: both board profiles and both remote packages."""
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
import tile_icons # noqa: E402
|
||||
|
||||
PROFILES = {'cyd': 'home-like-2432s028.yaml', 'guition': 'guition-4848s040.yaml'}
|
||||
PACKAGES = {'cyd': 'packages/cyd.yaml', 'guition': 'packages/guition.yaml'}
|
||||
FIELDS = [('title', 'string'), ('subtitle', 'string'), ('icon', 'string'), ('color', 'string'), ('button_text', 'string'), ('timeout', 'int'), ('flash', 'bool')]
|
||||
|
||||
def section(text, start, end):
|
||||
return text.split(start, 1)[1].split(end, 1)[0]
|
||||
|
||||
def script(text, name):
|
||||
"""One script block without its trailing comment lines, which introduce the next script."""
|
||||
return re.sub(r'^\s*#.*\n?', '', section(text, f' - id: {name}\n', '\n - id: '), flags=re.M)
|
||||
|
||||
class AlertTests(unittest.TestCase):
|
||||
def sources(self):
|
||||
for name in list(PROFILES.values()) + list(PACKAGES.values()):
|
||||
yield name, (ROOT / name).read_text()
|
||||
|
||||
def test_action_takes_the_six_fields_and_hands_them_to_one_script(self):
|
||||
for name, text in self.sources():
|
||||
block = section(text, ' - action: show_alert\n', ' - action: dismiss_alert\n')
|
||||
self.assertEqual(re.findall(r'^ (\w+): (\w+)$', block, re.M), FIELDS, name)
|
||||
self.assertIn('id: alert_show', block, name)
|
||||
for field, _ in FIELDS:
|
||||
self.assertIn(f"{field}: !lambda 'return {field};'", block, name)
|
||||
remote = text.split(' - action: dismiss_alert\n', 1)[1][:200]
|
||||
self.assertIn('id: alert_dismiss', remote, name)
|
||||
self.assertIn('reason: "remote"', remote, name)
|
||||
|
||||
def test_overlay_lives_on_the_top_layer_above_every_page(self):
|
||||
for name, text in self.sources():
|
||||
lvgl = section(text, '\nlvgl:\n', '\nscript:\n')
|
||||
self.assertLess(lvgl.index(' top_layer:\n'), lvgl.index(' pages:\n'), name)
|
||||
top = section(lvgl, ' top_layer:\n', ' pages:\n')
|
||||
for widget in ('alert_overlay', 'alert_card', 'alert_icon', 'alert_title', 'alert_subtitle', 'alert_ok', 'alert_ok_label'):
|
||||
self.assertIn(f'id: {widget}\n', top, (name, widget))
|
||||
self.assertIn('hidden: true', section(top, 'id: alert_overlay\n', 'widgets:'), name)
|
||||
self.assertIn(f'text: "\\U000{tile_icons.GLYPHS["alert-outline"]}"', top, name)
|
||||
self.assertIn('long_mode: DOT', section(top, 'id: alert_title\n', 'id: alert_subtitle'), name)
|
||||
self.assertIn('long_mode: WRAP', section(top, 'id: alert_subtitle\n', 'id: alert_ok'), name)
|
||||
ok = section(top, 'id: alert_ok\n', 'widgets:')
|
||||
self.assertIn('lv_label_set_text(id(alert_ok_label), alert.button.c_str());', script(text, 'alert_show'), name)
|
||||
self.assertIn('cyd::touch_guard.accept(millis(), 13)', ok, name)
|
||||
self.assertLess(ok.index('touch_guard.accept'), ok.index('id: alert_dismiss'), name)
|
||||
self.assertIn('reason: "ok"', ok, name)
|
||||
|
||||
def test_standby_waits_and_no_other_path_closes_the_card(self):
|
||||
for name, text in self.sources():
|
||||
guard = re.search(r'if \(id\(display_dimmed\) \|\| id\(touch_down\) \|\| id\(calibration_active\)([^\n]*)\) return false;', text)
|
||||
self.assertIsNotNone(guard, name)
|
||||
self.assertIn('|| id(alert_active)', guard[1], name)
|
||||
for other in ('close_cards', 'dim_display', 'wake_display', 'apply_screen_settings'):
|
||||
self.assertNotIn('alert', script(text, other), (name, other))
|
||||
dismiss = script(text, 'alert_dismiss')
|
||||
self.assertIn('id(alert_active) = false;', dismiss, name)
|
||||
self.assertIn('id(last_touch_ms) = millis();', dismiss, name)
|
||||
for stopped in ('alert_timeout', 'alert_flash'):
|
||||
self.assertIn(f'script.stop: {stopped}', dismiss, name)
|
||||
self.assertIn('screen_settings::current.brightness', dismiss, name)
|
||||
|
||||
def test_every_ending_reports_to_home_assistant_but_the_self_test_does_not(self):
|
||||
for name, text in self.sources():
|
||||
report = script(text, 'alert_report')
|
||||
self.assertIn('event: esphome.screen_alert', report, name)
|
||||
for key in ('action', 'title', 'screen'):
|
||||
self.assertRegex(report, rf'(?m)^\s+{key}: !lambda', name)
|
||||
self.assertIn('id: alert_report', script(text, 'alert_dismiss'), name)
|
||||
self.assertIn('reason: "timeout"', script(text, 'alert_timeout'), name)
|
||||
show = script(text, 'alert_show')
|
||||
self.assertIn('id(alert_report).execute("replaced")', show, name)
|
||||
self.assertIn('screen_alert::make(title, subtitle, icon, color, button_text, timeout, flash, ${ALERT_TITLE_MAX}, ${ALERT_SUBTITLE_MAX}, ${ALERT_BUTTON_MAX})', show, name)
|
||||
self.assertLess(show.index('script.execute: wake_display'), show.index('lvgl.widget.show: alert_overlay'), name)
|
||||
self.assertLess(show.index('lvgl.widget.show: alert_overlay'), show.index('script.execute: alert_flash'), name)
|
||||
self_test = section(text, ' - id: ui_self_test\n', ' - id: show_tile_page')
|
||||
self.assertIn('lvgl.widget.show: alert_overlay', self_test, name)
|
||||
self.assertIn('lvgl.widget.hide: alert_overlay', self_test, name)
|
||||
for forbidden in ('alert_show', 'alert_dismiss', 'homeassistant.event'):
|
||||
self.assertNotIn(forbidden, self_test, name)
|
||||
|
||||
def test_flash_blinks_four_times_and_the_card_fits_the_screen(self):
|
||||
for name, text in self.sources():
|
||||
flash = script(text, 'alert_flash')
|
||||
self.assertIn('count: 4', flash, name)
|
||||
self.assertEqual(flash.count('delay:'), 2, name)
|
||||
v = lambda key: int(re.search(rf'^ {key}: "(-?\d+)"', text, re.M)[1])
|
||||
self.assertLessEqual(v('ALERT_CARD_W'), v('DISPLAY_W') - 16, name)
|
||||
self.assertLessEqual(v('ALERT_CARD_H'), v('DISPLAY_H') - 16, name)
|
||||
self.assertLessEqual(v('ALERT_TEXT_X') + v('ALERT_TEXT_W'), v('ALERT_CARD_W') - v('ALERT_ICON_X'), name)
|
||||
self.assertLessEqual(v('ALERT_SUBTITLE_Y') + v('ALERT_SUBTITLE_H'),
|
||||
v('ALERT_CARD_H') - v('ALERT_BUTTON_INSET') - v('ALERT_BUTTON_H'), name)
|
||||
self.assertGreater(v('ALERT_TITLE_MAX'), 0, name)
|
||||
self.assertGreater(v('ALERT_BUTTON_MAX'), 0, name)
|
||||
self.assertGreater(v('ALERT_SUBTITLE_MAX'), v('ALERT_TITLE_MAX'), name)
|
||||
|
||||
def test_helper_header_is_included_locally_and_in_the_remote_package(self):
|
||||
for board, name in PROFILES.items():
|
||||
self.assertIn(' - alert_overlay.h\n', (ROOT / name).read_text(), name)
|
||||
self.assertIn(' - <esphome/components/smart_display/alert_overlay.h>\n', (ROOT / PACKAGES[board]).read_text(), board)
|
||||
self.assertIn('components/smart_display/alert_overlay.h', (ROOT / 'alert_overlay.h').read_text())
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user