Let every screen choose its top bar: clocks, date and entities (0.2.38)
The name stays on the left; up to six items go on the right: the time, an analog clock, the date, or a Home Assistant entity with its icon, showing its state or how long ago it changed, always or only while active. The add-on formats entity text in Dutch and sends it as op: header right after the layout to firmware 0.2.32+; older firmware keeps the clock of show_clock, which now follows the clock item. The firmware draws every value in one font on the name's baseline, centres icons and the dial on the digit height and measures gaps between glyph ink, so the bar looks even; the editor's mockup uses Roboto and the same integer rules. tools/render_topbar.py renders the real LVGL code on a computer (ESPHome host + SDL2). The editor's section buttons are gone. Firmware 0.2.32 for both boards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
#include "components/smart_display/header_bar.h"
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
using namespace header_bar;
|
||||
int main() {
|
||||
assert(kind("clock") == Kind::clock && kind("analog") == Kind::analog && kind("date") == Kind::date);
|
||||
assert(kind("text") == Kind::text && kind("ago") == Kind::ago);
|
||||
for (const char *bad : {"", "Clock", "tijd", "state"}) assert(kind(bad) == Kind::none);
|
||||
|
||||
uint32_t c = 0;
|
||||
assert(color("FFB300", c) && c == 0xFFB300);
|
||||
assert(color("43a047", c) && c == 0x43A047);
|
||||
for (const char *bad : {"", "FFB30", "FFB3000", "#FFB30", "GGGGGG"}) assert(!color(bad, c));
|
||||
|
||||
// UTF-8: ASCII, two, three and four bytes; a broken sequence still moves on.
|
||||
std::string s = "a°—\U000F050F";
|
||||
size_t i = 0;
|
||||
assert(next_codepoint(s, i) == 'a' && next_codepoint(s, i) == 0xB0 && next_codepoint(s, i) == 0x2014);
|
||||
assert(next_codepoint(s, i) == 0xF050F && next_codepoint(s, i) == 0 && i == s.size());
|
||||
std::string broken = "\xE2" "x";
|
||||
i = 0;
|
||||
next_codepoint(broken, i);
|
||||
assert(next_codepoint(broken, i) == 'x');
|
||||
|
||||
// The editor's agoText() uses the same words and thresholds.
|
||||
const int64_t now = 1789401840;
|
||||
assert(ago_text(now, 0) == "—" && ago_text(0, now) == "—");
|
||||
assert(ago_text(now - 59, now) == "Zojuist");
|
||||
assert(ago_text(now - 60, now) == "1 min geleden");
|
||||
assert(ago_text(now - 3599, now) == "59 min geleden");
|
||||
assert(ago_text(now - 3600, now) == "1 uur geleden");
|
||||
assert(ago_text(now - 86399, now) == "23 uur geleden");
|
||||
assert(ago_text(now - 86400, now) == "Gisteren");
|
||||
assert(ago_text(now - 172800, now) == "2 dagen geleden");
|
||||
assert(ago_text(now - 604800, now) == "1 week geleden");
|
||||
assert(ago_text(now - 2 * 604800, now) == "2 weken geleden");
|
||||
assert(ago_text(now - 2592000, now) == "1 maand geleden");
|
||||
assert(ago_text(now - 3 * 2592000, now) == "3 maanden geleden");
|
||||
assert(ago_text(now - 31536000, now) == "1 jaar geleden");
|
||||
assert(ago_text(now + 30, now) == "Over 1 min");
|
||||
assert(ago_text(now + 600, now) == "Over 10 min");
|
||||
assert(ago_text(now + 7200, now) == "Over 2 uur");
|
||||
assert(ago_text(now + 90000, now) == "Morgen");
|
||||
assert(ago_text(now + 3 * 86400, now) == "Over 3 dagen");
|
||||
|
||||
assert(date_text(2, 14, 9) == "ma 14 sep" && date_text(1, 1, 1) == "zo 1 jan" && date_text(7, 31, 12) == "za 31 dec");
|
||||
assert(date_text(0, 1, 1) == "—" && date_text(1, 1, 13) == "—");
|
||||
|
||||
// Guition: digits 15 px high; CYD: 10 px.
|
||||
auto g = gaps(15);
|
||||
assert(g.icon == 6 && g.item == 19 && g.name == 24);
|
||||
auto small = gaps(10);
|
||||
assert(small.icon == 4 && small.item == 13 && small.name == 16);
|
||||
assert(gaps(1).icon == 2 && gaps(1).item == 6 && gaps(1).name == 8);
|
||||
|
||||
// Everything fits: right-aligned with item gaps, the name keeps the rest.
|
||||
int widths[] = {80, 50, 49};
|
||||
auto p = place(widths, 3, g, 448, 100);
|
||||
assert(p.first == 0 && p.x[2] == 448 - 49 && p.x[1] == p.x[2] - 19 - 50 && p.x[0] == p.x[1] - 19 - 80);
|
||||
assert(p.name_room == p.x[0] - 24);
|
||||
// Too full: items leave from the front until the rest fits beside the name.
|
||||
int many[] = {86, 163, 50, 60};
|
||||
auto q = place(many, 4, g, 448, 100);
|
||||
assert(q.first == 1);
|
||||
int total = 163 + 50 + 60 + 2 * 19;
|
||||
assert(q.x[1] == 448 - total && q.name_room >= 100);
|
||||
// A long name keeps 35% of the width, never more than it needs.
|
||||
auto r = place(many, 4, g, 448, 400);
|
||||
assert(r.first == 2 && r.name_room >= 448 * 35 / 100);
|
||||
// Nothing to show: the name gets the whole width.
|
||||
auto empty = place(many, 0, g, 448, 400);
|
||||
assert(empty.first == 0 && empty.name_room == 448);
|
||||
// Items wider than the bar all leave.
|
||||
int huge[] = {500};
|
||||
auto none = place(huge, 1, g, 448, 50);
|
||||
assert(none.first == 1 && none.name_room == 448);
|
||||
std::puts("test_header_bar: PASS");
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Top bar (app 0.2.38 / firmware 0.2.32): validation, Dutch text, icons, visibility and delivery."""
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
import header_bar # noqa: E402
|
||||
import tile_icons # noqa: E402
|
||||
from core import FIRMWARE_VERSION, HEADER_MIN_FIRMWARE, discover, header_items, validate_header, validate_layout # noqa: E402
|
||||
|
||||
FIRMWARE = (ROOT / 'components/smart_display/header_bar.h').read_text()
|
||||
TILES = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
|
||||
EDITOR = (ROOT / 'screen_manager/app/static/app.js').read_text()
|
||||
PROFILES = ('guition-4848s040.yaml', 'home-like-2432s028.yaml')
|
||||
|
||||
def state(value, last_changed='2026-09-13T14:00:00+00:00', **attributes):
|
||||
return {'state': value, 'attributes': attributes, 'last_changed': last_changed}
|
||||
|
||||
def entity(eid, content='state', icon='auto', show='always'):
|
||||
return {'type': 'entity', 'entity': eid, 'content': content, 'icon': icon, 'show': show}
|
||||
|
||||
class ValidationTests(unittest.TestCase):
|
||||
def test_items_fill_defaults_and_keep_order(self):
|
||||
header = validate_header({'items': [{'type': 'clock'}, {'type': 'entity', 'entity': 'binary_sensor.voordeur'}, {'type': 'analog'}]})
|
||||
self.assertEqual(header['items'], [{'type': 'clock'}, entity('binary_sensor.voordeur'), {'type': 'analog'}])
|
||||
# Top-bar-only domains: a phone's tracker, a lock, the alarm, the home zone.
|
||||
for eid in ('device_tracker.telefoon', 'lock.voordeur', 'alarm_control_panel.huis', 'zone.home', 'event.deurbel'):
|
||||
validate_header({'items': [entity(eid)]})
|
||||
|
||||
def test_rejects_what_the_firmware_cannot_draw(self):
|
||||
bad = [
|
||||
None, [], {'items': None}, {'items': [], 'extra': 1},
|
||||
{'items': [{'type': 'clock'}] * 7},
|
||||
{'items': [{'type': 'weather'}]},
|
||||
{'items': [{'type': 'clock', 'entity': 'sensor.x'}]},
|
||||
{'items': [entity('screen.clock')]},
|
||||
{'items': [entity('update.home_assistant')]},
|
||||
{'items': [entity('sensor.x', content='attribute')]},
|
||||
{'items': [entity('sensor.x', show='sometimes')]},
|
||||
{'items': [entity('sensor.x', icon='mdi:thermometer')]},
|
||||
{'items': [{**entity('sensor.x'), 'color': 'red'}]},
|
||||
{'items': [entity('sensor.x'), entity('sensor.x')]},
|
||||
{'items': [{'type': 'date'}, {'type': 'date'}]},
|
||||
]
|
||||
for data in bad:
|
||||
with self.assertRaises(ValueError, msg=data):
|
||||
validate_header(data)
|
||||
# The same entity twice is fine when it shows something else.
|
||||
validate_header({'items': [entity('person.max'), entity('person.max', content='last_changed')]})
|
||||
|
||||
def test_layout_keeps_header_and_old_layouts_keep_their_clock(self):
|
||||
layout = validate_layout({'title': 'Woonkamer', 'tiles': [], 'header': {'items': [{'type': 'date'}]}})
|
||||
self.assertEqual(header_items(layout), [{'type': 'date'}])
|
||||
self.assertEqual(header_items(validate_layout({'title': 'Thuis', 'tiles': []})), [{'type': 'clock'}])
|
||||
without_clock = validate_layout({'title': 'Thuis', 'tiles': [], 'settings': {'show_clock': False}})
|
||||
self.assertEqual(header_items(without_clock), [])
|
||||
|
||||
class TextTests(unittest.TestCase):
|
||||
def test_numbers_as_home_assistant_writes_them_in_dutch(self):
|
||||
self.assertEqual(header_bar.number_text(21.34, 1), '21,3')
|
||||
self.assertEqual(header_bar.number_text(1249.0, 0), '1.249')
|
||||
self.assertEqual(header_bar.number_text(1249.04, 1), '1.249,0')
|
||||
self.assertEqual(header_bar.number_text(234.038566388889), '234,039')
|
||||
self.assertEqual(header_bar.number_text(23.5), '23,5')
|
||||
self.assertEqual(header_bar.number_text(-3.5, 1), '-3,5')
|
||||
self.assertEqual(header_bar.number_text(-0.04, 1), '0,0')
|
||||
self.assertEqual(header_bar.with_unit('21,3', '°C'), '21,3 °C')
|
||||
self.assertEqual(header_bar.with_unit('65', '%'), '65%')
|
||||
self.assertEqual(header_bar.with_unit('18', '°'), '18°')
|
||||
self.assertEqual(header_bar.with_unit('4', None), '4')
|
||||
|
||||
def test_values_per_domain(self):
|
||||
tz = ZoneInfo('Europe/Amsterdam')
|
||||
registry = {'sensor.t': {'options': {'sensor': {'suggested_display_precision': 1}}},
|
||||
'sensor.p': {'options': {'sensor': {'suggested_display_precision': 2, 'display_precision': 0}}}}
|
||||
cases = [
|
||||
('sensor.t', state('21.34', unit_of_measurement='°C', device_class='temperature'), ('21,3 °C', None)),
|
||||
('sensor.p', state('1249.4', unit_of_measurement='W'), ('1.249 W', None)),
|
||||
('sensor.h', state('unavailable', unit_of_measurement='%'), ('—', None)),
|
||||
('binary_sensor.deur', state('on', device_class='door'), ('Open', None)),
|
||||
('binary_sensor.deur', state('off', device_class='door'), ('Dicht', None)),
|
||||
('binary_sensor.rook', state('on', device_class='smoke'), ('Rook', None)),
|
||||
('binary_sensor.iets', state('off'), ('Uit', None)),
|
||||
('alarm_control_panel.huis', state('armed_away'), ('Afwezig', None)),
|
||||
('alarm_control_panel.huis', state('disarmed'), ('Uit', None)),
|
||||
('alarm_control_panel.huis', state('triggered'), ('Alarm', None)),
|
||||
('lock.voordeur', state('locked'), ('Op slot', None)),
|
||||
('person.max', state('not_home'), ('Weg', None)),
|
||||
('person.max', state('Kantoor'), ('Kantoor', None)),
|
||||
('zone.home', state('4'), ('4', None)),
|
||||
('weather.thuis', state('rainy', temperature=17.6, temperature_unit='°C'), ('18 °C', None)),
|
||||
('climate.woonkamer', state('heat', current_temperature=20.46), ('20,5 °C', None)),
|
||||
('sun.sun', state('above_horizon', next_setting='2026-09-14T17:57:31+00:00'), ('19:57', None)),
|
||||
('input_datetime.wekker', state('07:30:00', has_date=False, has_time=True), ('07:30', None)),
|
||||
('sensor.backup', state('2026-09-15T03:34:32+00:00', device_class='timestamp'), (None, 1789443272)),
|
||||
('scene.film', state('2026-09-14T15:32:14+00:00'), (None, 1789399934)),
|
||||
('script.alles_uit', state('off', last_triggered='2026-09-14T15:32:14+00:00'), (None, 1789399934)),
|
||||
]
|
||||
for eid, value, expected in cases:
|
||||
self.assertEqual(header_bar.value(eid, value, registry.get(eid), {'temperature': '°C'}, tz), expected, eid)
|
||||
|
||||
def test_text_keeps_to_the_font_and_its_length(self):
|
||||
self.assertEqual(header_bar.clean_text('Café “Zon” 25 m³'), 'Café Zon 25 m³')
|
||||
self.assertEqual(header_bar.clean_text('Ångström ✓'), 'Angström')
|
||||
self.assertLessEqual(len(header_bar.clean_text('é' * 60).encode()), header_bar.TEXT_BYTES)
|
||||
|
||||
def test_active_colour_and_icon(self):
|
||||
door_open, door_closed = state('on', device_class='door'), state('off', device_class='door')
|
||||
self.assertTrue(header_bar.active('binary_sensor.deur', door_open))
|
||||
self.assertFalse(header_bar.active('binary_sensor.deur', door_closed))
|
||||
self.assertFalse(header_bar.active('sensor.p', state('0.0')))
|
||||
self.assertTrue(header_bar.active('sensor.p', state('12')))
|
||||
self.assertTrue(header_bar.active('person.max', state('home')))
|
||||
self.assertFalse(header_bar.active('person.max', state('Kantoor')))
|
||||
self.assertFalse(header_bar.active('lock.voordeur', state('locked')))
|
||||
self.assertFalse(header_bar.active('sensor.x', state('unavailable')))
|
||||
self.assertEqual(header_bar.accent('binary_sensor.deur', door_open), header_bar.AMBER)
|
||||
self.assertIsNone(header_bar.accent('binary_sensor.deur', door_closed))
|
||||
self.assertEqual(header_bar.accent('binary_sensor.lek', state('on', device_class='moisture')), header_bar.RED)
|
||||
self.assertEqual(header_bar.accent('alarm_control_panel.huis', state('armed_home')), header_bar.GREEN)
|
||||
self.assertEqual(header_bar.accent('alarm_control_panel.huis', state('arming')), header_bar.ORANGE)
|
||||
self.assertEqual(header_bar.accent('alarm_control_panel.huis', state('triggered')), header_bar.RED)
|
||||
self.assertIsNone(header_bar.accent('alarm_control_panel.huis', state('disarmed')))
|
||||
self.assertEqual(header_bar.accent('lock.voordeur', state('unlocked')), header_bar.RED)
|
||||
self.assertEqual(header_bar.accent('person.max', state('home')), header_bar.GREEN)
|
||||
glyph = tile_icons.GLYPHS
|
||||
self.assertEqual(header_bar.auto_icon('binary_sensor.deur', door_open), glyph['door-open'])
|
||||
self.assertEqual(header_bar.auto_icon('binary_sensor.deur', door_closed), glyph['door-closed'])
|
||||
self.assertEqual(header_bar.auto_icon('sensor.t', state('21', device_class='temperature')), glyph['thermometer'])
|
||||
self.assertEqual(header_bar.auto_icon('sensor.t', state('21', device_class='temperature', icon='mdi:fire')), glyph['fire'])
|
||||
self.assertEqual(header_bar.auto_icon('weather.thuis', state('rainy')), glyph['weather-rainy'])
|
||||
self.assertEqual(header_bar.auto_icon('lock.voordeur', state('unlocked')), glyph['lock-open-variant'])
|
||||
self.assertEqual(header_bar.auto_icon('counter.bezoekers', None), glyph['gauge'])
|
||||
|
||||
def test_every_icon_name_is_in_the_fonts(self):
|
||||
names = set(header_bar.SENSOR_ICONS.values()) | set(header_bar.DOMAIN_ICONS.values())
|
||||
names |= {name for pair in header_bar.BINARY_ICONS.values() for name in pair}
|
||||
self.assertLessEqual(names, set(tile_icons.GLYPHS))
|
||||
self.assertIn('F0150', tile_icons.GLYPHS.values(), 'the dial is sized by clock-outline')
|
||||
self.assertIn('0xF0150', TILES)
|
||||
|
||||
class MessageTests(unittest.TestCase):
|
||||
def test_message_leaves_hidden_items_out_and_keeps_builtins(self):
|
||||
states = {'binary_sensor.deur': state('off', device_class='door'), 'person.max': state('home', last_changed='2026-09-14T15:00:00+00:00'),
|
||||
'sensor.t': state('21.3', unit_of_measurement='°C', device_class='temperature')}
|
||||
layout = {'title': 'Hal', 'tiles': [], 'header': {'items': [
|
||||
entity('binary_sensor.deur', show='active'), entity('person.max', content='last_changed'),
|
||||
entity('sensor.t', icon='none'), {'type': 'analog'}, {'type': 'clock'}]}}
|
||||
message = header_bar.message(layout, states)
|
||||
self.assertEqual(message['op'], 'header')
|
||||
self.assertEqual(message['items'], [
|
||||
{'k': 'ago', 'i': tile_icons.GLYPHS['account'], 'e': 1789398000, 'c': header_bar.GREEN},
|
||||
{'k': 'text', 't': '21,3 °C'}, {'k': 'analog'}, {'k': 'clock'}])
|
||||
states['binary_sensor.deur'] = state('on', device_class='door')
|
||||
opened = header_bar.message(layout, states)['items'][0]
|
||||
self.assertEqual(opened, {'k': 'text', 'i': tile_icons.GLYPHS['door-open'], 't': 'Open', 'c': header_bar.AMBER})
|
||||
preview = header_bar.preview(layout['header'], {**states, 'binary_sensor.deur': state('off', device_class='door')})
|
||||
self.assertEqual([p['shown'] for p in preview], [False, True, True, True, True])
|
||||
self.assertEqual(preview[0]['name'], 'binary_sensor.deur')
|
||||
|
||||
def test_old_layout_sends_the_clock_of_show_clock(self):
|
||||
self.assertEqual(header_bar.message({'title': 'Thuis', 'tiles': []}, {})['items'], [{'k': 'clock'}])
|
||||
self.assertEqual(header_bar.message({'title': 'Thuis', 'tiles': [], 'settings': {'show_clock': False}}, {})['items'], [])
|
||||
|
||||
def test_suggestions_prefer_the_screens_room_and_skip_diagnostics(self):
|
||||
states = {'sensor.plug_temp': state('30', device_class='temperature'), 'sensor.woonkamer_temp': state('21', device_class='temperature'),
|
||||
'sensor.zolder_temp': state('18', device_class='temperature'), 'zone.home': state('3'), 'sun.sun': state('above_horizon')}
|
||||
entities = [{'id': 'sensor.plug_temp', 'name': 'Plug', 'area': 'Woonkamer'}, {'id': 'sensor.woonkamer_temp', 'name': 'Woonkamer', 'area': 'Woonkamer'},
|
||||
{'id': 'sensor.zolder_temp', 'name': 'Zolder', 'area': 'Zolder'}, {'id': 'zone.home', 'name': 'Thuis', 'area': ''},
|
||||
{'id': 'sun.sun', 'name': 'Zon', 'area': ''}]
|
||||
picks = header_bar.suggestions({'name': 'Woonkamer'}, entities, states, {'sensor.plug_temp': {'entity_category': 'diagnostic'}})
|
||||
self.assertEqual([(p['label'], p['item']['entity']) for p in picks],
|
||||
[('Temperatuur', 'sensor.woonkamer_temp'), ('Aantal thuis', 'zone.home'), ('Zon op en onder', 'sun.sun')])
|
||||
for pick in picks:
|
||||
validate_header({'items': [pick['item']]})
|
||||
|
||||
def test_discovery_offers_top_bar_domains_outside_the_tile_picker(self):
|
||||
registry = [{'entity_id': 'lock.voordeur'}, {'entity_id': 'light.hal'}, {'entity_id': 'update.core'}]
|
||||
states = {'lock.voordeur': state('locked'), 'light.hal': state('on'), 'update.core': state('off'), 'zone.home': state('2')}
|
||||
_, entities = discover(registry, states, [], [])
|
||||
flags = {e['id']: e.get('tile', True) for e in entities}
|
||||
self.assertEqual(flags, {'lock.voordeur': False, 'light.hal': True, 'zone.home': False})
|
||||
|
||||
class ParityTests(unittest.TestCase):
|
||||
def test_text_font_carries_exactly_the_glyphs_the_add_on_sends(self):
|
||||
for name in PROFILES + ('packages/cyd.yaml', 'packages/guition.yaml'):
|
||||
text = (ROOT / name).read_text()
|
||||
block = re.search(r'id: sublabel_big\n size: \d+\n bpp: 4\n glyphs: \[(.*?)\]\n', text, re.S)
|
||||
self.assertIsNotNone(block, name)
|
||||
glyphs = set(re.findall(r"'([^'])'|\"(')\"", block.group(1)))
|
||||
self.assertEqual({a or b for a, b in glyphs}, set(header_bar.GLYPHS), name)
|
||||
|
||||
def test_editor_and_firmware_share_words_and_spacing(self):
|
||||
for phrase in ('Zojuist', ' min geleden', ' uur geleden', 'Gisteren', ' dagen geleden', '1 week geleden', ' weken geleden',
|
||||
'1 maand geleden', ' maanden geleden', ' jaar geleden', 'Over ', 'Morgen'):
|
||||
self.assertIn(phrase, FIRMWARE)
|
||||
self.assertIn(phrase, EDITOR)
|
||||
for threshold in ('3600', '86400', '172800', '604800', '2592000', '31536000'):
|
||||
self.assertIn(threshold, FIRMWARE)
|
||||
self.assertIn(threshold, EDITOR)
|
||||
self.assertIn('std::max(2, scaled(cap, 4)), std::max(6, (cap * 125 + 50) / 100), std::max(8, scaled(cap, 16))', FIRMWARE)
|
||||
self.assertIn('Math.max(2, Math.floor((cap * 4 + 5) / 10)), item: Math.max(6, Math.floor((cap * 125 + 50) / 100)), name: Math.max(8, Math.floor((cap * 16 + 5) / 10))', EDITOR)
|
||||
self.assertIn('width * 35 / 100', FIRMWARE)
|
||||
self.assertIn('(metrics.width * 35) / 100', EDITOR)
|
||||
self.assertEqual(header_bar.MONTHS, tuple(re.search(r'const MONTHS = \[(.*?)\];', EDITOR).group(1).replace('"', '').replace(' ', '').split(',')))
|
||||
|
||||
def test_profiles_draw_the_bar_from_the_runtime(self):
|
||||
for name in PROFILES:
|
||||
text = (ROOT / name).read_text()
|
||||
self.assertIn('runtime_tiles::header_text_font = id(sublabel_big)->get_lv_font();', text, name)
|
||||
self.assertIn('runtime_tiles::header_icon_font = id(materialdesign_icons_mini)->get_lv_font();', text, name)
|
||||
self.assertIn('runtime_tiles::time_label = id(lbl_time);', text, name)
|
||||
refresh = text.split(' - id: ui_refresh', 1)[1].split('return;', 1)[0]
|
||||
self.assertNotIn('lbl_time', refresh, name)
|
||||
self.assertIn('render_header();', TILES.split('inline void render(lv_obj_t *room) {', 1)[1].split('\n', 5)[4])
|
||||
self.assertLessEqual(HEADER_MIN_FIRMWARE, tuple(int(p) for p in FIRMWARE_VERSION.split('.')))
|
||||
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
if HAS_AIOHTTP:
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
from server import Manager, create_app
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
class ManagerTests(unittest.IsolatedAsyncioTestCase):
|
||||
def manager(self, path, firmware='0.2.32'):
|
||||
class HA:
|
||||
online = True
|
||||
registry = [{'entity_id': 'text.screen', 'platform': 'esphome', 'original_name': 'Tegelinstellingen', 'device_id': 'd'},
|
||||
{'entity_id': 'sensor.fw', 'platform': 'esphome', 'original_name': 'Schermfirmware', 'device_id': 'd'},
|
||||
{'entity_id': 'sensor.t', 'options': {'sensor': {'suggested_display_precision': 1}}},
|
||||
{'entity_id': 'binary_sensor.deur'}, {'entity_id': 'light.a'}]
|
||||
devices, areas = [], []
|
||||
time_zone, units = None, {'temperature': '°C'}
|
||||
def __init__(self):
|
||||
self.messages = []
|
||||
self.changed = asyncio.Event()
|
||||
self.states = {'text.screen': state('Ready'), 'sensor.fw': state(firmware), 'light.a': state('on'),
|
||||
'sensor.t': state('21.34', unit_of_measurement='°C', device_class='temperature'),
|
||||
'binary_sensor.deur': state('off', device_class='door')}
|
||||
async def send(self, inbox, message):
|
||||
self.messages.append(message)
|
||||
return Manager(HA(), path)
|
||||
|
||||
async def test_header_follows_layout_only_on_new_firmware(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
m = self.manager(Path(temp) / 'screens.json')
|
||||
layout = {'title': 'Hal', 'tiles': [{'entity': 'light.a', 'name': ''}], 'settings': {'show_clock': True},
|
||||
'header': {'items': [entity('sensor.t'), entity('binary_sensor.deur', show='active')]}}
|
||||
m.save('text.screen', layout)
|
||||
saved = m.layouts['text.screen']
|
||||
self.assertFalse(saved['settings']['show_clock'], 'older firmware hides its clock when the bar has none')
|
||||
await m.sync_one('text.screen', saved)
|
||||
self.assertEqual([msg['op'] for msg in m.ha.messages], ['layout', 'header', 'state'])
|
||||
self.assertEqual(m.ha.messages[1]['items'], [{'k': 'text', 'i': tile_icons.GLYPHS['thermometer'], 't': '21,3 °C'}])
|
||||
await m.sync_one('text.screen', saved)
|
||||
self.assertEqual(len(m.ha.messages), 3, 'an unchanged bar is not sent again')
|
||||
m.ha.states['binary_sensor.deur'] = state('on', device_class='door')
|
||||
await m.sync_one('text.screen', saved)
|
||||
self.assertEqual(len(m.ha.messages), 4)
|
||||
self.assertEqual(m.ha.messages[-1]['items'][1]['t'], 'Open')
|
||||
self.assertIn('binary_sensor.deur', m.watched_entities())
|
||||
old = self.manager(Path(temp) / 'old.json', firmware='0.2.31')
|
||||
old.save('text.screen', layout)
|
||||
await old.sync_one('text.screen', old.layouts['text.screen'])
|
||||
self.assertEqual([msg['op'] for msg in old.ha.messages], ['layout', 'state'])
|
||||
|
||||
async def test_older_editor_keeps_the_bar_and_gone_entities_are_refused(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
m = self.manager(Path(temp) / 'screens.json')
|
||||
header = {'items': [{'type': 'date'}, {'type': 'clock'}]}
|
||||
m.save('text.screen', {'title': 'Hal', 'tiles': [], 'header': header})
|
||||
m.save('text.screen', {'title': 'Andere naam', 'tiles': []})
|
||||
self.assertEqual(m.layouts['text.screen']['header'], header)
|
||||
with self.assertRaises(ValueError):
|
||||
m.save('text.screen', {'title': 'Hal', 'tiles': [], 'header': {'items': [entity('sensor.bestaat_niet')]}})
|
||||
self.assertEqual(m.layouts['text.screen']['header'], header)
|
||||
|
||||
async def test_preview_endpoint_formats_unsaved_items(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
m = self.manager(Path(temp) / 'screens.json')
|
||||
app = create_app(m, development=True)
|
||||
async with TestClient(TestServer(app)) as client:
|
||||
inventory = await (await client.get('/api/inventory')).json()
|
||||
self.assertEqual(inventory['header']['max_items'], 6)
|
||||
self.assertEqual(inventory['header']['min_firmware'], '0.2.32')
|
||||
headers = {'X-Screen-CSRF': inventory['csrf']}
|
||||
items = [entity('binary_sensor.deur', show='active'), {'type': 'clock'}]
|
||||
response = await client.post('/api/header-preview', json={'header': {'items': items}}, headers=headers)
|
||||
data = await response.json()
|
||||
self.assertEqual([p['shown'] for p in data['items']], [False, True])
|
||||
self.assertEqual(data['items'][0]['t'], 'Dicht')
|
||||
bad = await client.post('/api/header-preview', json={'header': {'items': [{'type': 'nope'}]}}, headers=headers)
|
||||
self.assertEqual(bad.status, 400)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user