Put ESP Screens in English and keep layouts when entity IDs change (0.2.40)

Everything a user sees or reads is now English: both boards' labels,
status replies and logs, the ESP Screens editor and Alerts cheatsheet,
and the README and guides (docs/TILES.md, CALIBRATING.md, ACCEPTANCE.md
and TROUBLESHOOTING.md replace the Dutch file names). Labels follow
Home Assistant's own English names (Heat/Cool modes, Heating/Cooling
actions, Away, Returning to dock, weather conditions), dates use English
abbreviations and the top bar writes numbers the English way. Docs use
HA 2026.9's menu names (Settings -> Apps, App store, Settings -> Tools).
Unused legacy files (home-like.yaml, buttons.yaml, a.out) are removed.

Firmware 0.2.34 names its entities in English. Home Assistant registers
a renamed ESPHome entity under a new entity ID, so the inbox
text.<screen>_tegelinstellingen becomes text.<screen>_tile_settings and
the layout stored under the old ID would be lost. The manager now
recognises the screen by its device, during a run or after a restart,
and moves its layout and update history along; the Update button and
the nightly round follow the screen through the rename. Screens that
still run 0.2.33 or older keep working: both languages are matched.

Main is the only release branch from now on.

Tested on a Home Assistant Yellow (HA 2026.9.1) with both bench boards:
see docs/TEST_RESULTS_0240.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-15 02:08:43 +02:00
parent f60eed3ff6
commit 3e959b3df9
87 changed files with 5049 additions and 11167 deletions
+8 -8
View File
@@ -3,8 +3,8 @@
#include <cstdio>
int main() {
using screen_alert::make;
const auto alert = make("Iemand belt aan", "Deur 3, achterkant", "doorbell", "orange", "Ik kom", 60, true, 48, 160, 12);
assert(alert.title == "Iemand belt aan" && alert.subtitle == "Deur 3, achterkant" && alert.button == "Ik kom");
const auto alert = make("Someone's at the door", "Door 3, back entrance", "doorbell", "orange", "Coming", 60, true, 48, 160, 12);
assert(alert.title == "Someone's at the door" && alert.subtitle == "Door 3, back entrance" && alert.button == "Coming");
assert(alert.icon == tile_icon::utf8(0xF12E6) && alert.color == 0xFFE1C6 && alert.timeout_seconds == 60 && alert.flash);
// Icon: a name from the set, with mdi: prefix, spaces or capitals, or the hex codepoint of a carried glyph.
for (const char *icon : {"doorbell", "mdi:doorbell", " Doorbell ", "F12E6", "f12e6", "mdi:F12E6"})
@@ -17,20 +17,20 @@ int main() {
assert(make("x", "", "", " mint ", "", 0, false, 48, 160, 12).color == 0xD5F0EA);
for (const char *color : {"", "none", "auto", "#ff0000", "0xFF0000"})
assert(make("x", "", "", color, "", 0, false, 48, 160, 12).color == 0xFFFFFF);
// Timeout: 0 and negatives wait for Oké; a day is the ceiling.
// Timeout: 0 and negatives wait for OK; a day is the ceiling.
assert(make("x", "", "", "", "", 0, false, 48, 160, 12).timeout_seconds == 0);
assert(make("x", "", "", "", "", -5, false, 48, 160, 12).timeout_seconds == 0);
assert(make("x", "", "", "", "", 999999, false, 48, 160, 12).timeout_seconds == 86400);
assert(!make("x", "", "", "", "", 0, false, 48, 160, 12).flash);
// Title: trimmed, never empty, cut on a UTF-8 boundary; the subtitle keeps its line breaks.
assert(make(" ", "", "", "", "", 0, false, 48, 160, 12).title == "Melding");
assert(make(" Hoi \n", "", "", "", "", 0, false, 48, 160, 12).title == "Hoi");
assert(make(" ", "", "", "", "", 0, false, 48, 160, 12).title == "Notification");
assert(make(" Hi \n", "", "", "", "", 0, false, 48, 160, 12).title == "Hi");
assert(make("\xC3\xA9\xC3\xA9\xC3\xA9", "", "", "", "", 0, false, 3, 160, 12).title == "\xC3\xA9");
assert(make("x", "regel 1\nregel 2", "", "", "", 0, false, 48, 160, 12).subtitle == "regel 1\nregel 2");
assert(make("x", "line 1\nline 2", "", "", "", 0, false, 48, 160, 12).subtitle == "line 1\nline 2");
assert(make("x", std::string(300, 'a'), "", "", "", 0, false, 48, 160, 12).subtitle.size() == 160);
assert(make(std::string(100, 'b'), "", "", "", "", 0, false, 48, 160, 12).title.size() == 48);
// Button: trimmed and capped; empty falls back to Oké.
assert(make("x", "", "", "", "", 0, false, 48, 160, 12).button == "Ok\xC3\xA9");
// Button: trimmed and capped; empty falls back to OK.
assert(make("x", "", "", "", "", 0, false, 48, 160, 12).button == "OK");
assert(make("x", "", "", "", " Open ", 0, false, 48, 160, 12).button == "Open");
assert(make("x", "", "", "", std::string(40, 'c'), 0, false, 48, 160, 12).button.size() == 12);
// The generated table carries every font glyph by name.
+2 -2
View File
@@ -49,7 +49,7 @@ class ReferenceTests(unittest.TestCase):
def test_action_names_follow_home_assistant_and_the_firmware_is_current(self):
self.assertEqual(alert_service('studio-1'), 'esphome.studio_1_show_alert')
self.assertEqual(alert_service('keuken-scherm', 'dismiss_alert'), 'esphome.keuken_scherm_dismiss_alert')
self.assertEqual(alert_service('kitchen-screen', 'dismiss_alert'), 'esphome.kitchen_screen_dismiss_alert')
for missing in (None, '', 3):
self.assertIsNone(alert_service(missing))
self.assertLessEqual(parse_version(ALERT_MIN_FIRMWARE), parse_version(FIRMWARE_VERSION))
@@ -77,7 +77,7 @@ class InventoryTests(unittest.IsolatedAsyncioTestCase):
{'entity_id': 'sensor.studio_1_apparaatnaam', 'platform': 'esphome', 'original_name': 'Apparaatnaam', 'device_id': 'd1'},
{'entity_id': 'sensor.studio_1_schermfirmware', 'platform': 'esphome', 'original_name': 'Schermfirmware', 'device_id': 'd1'},
{'entity_id': 'text.oud_tegelinstellingen', 'platform': 'esphome', 'original_name': 'Tegelinstellingen', 'device_id': 'd2'}]
devices = [{'id': 'd1', 'name': 'Studio 1'}, {'id': 'd2', 'name': 'Oud scherm'}]
devices = [{'id': 'd1', 'name': 'Studio 1'}, {'id': 'd2', 'name': 'Old screen'}]
areas = []
states = {'text.studio_1_tegelinstellingen': {'state': 'Ready'}, 'sensor.studio_1_apparaatnaam': {'state': 'studio-1'},
'sensor.studio_1_schermfirmware': {'state': '0.2.31'}, 'text.oud_tegelinstellingen': {'state': 'Ready'}}
+4 -4
View File
@@ -61,21 +61,21 @@ int main() {
wide.update(275, 200, 3); wide.update(300, 200, 3); wide.update(340, 200, 3); // a real swipe: the reference
wide.update(380, 200, 3); // settles, the finger keeps going
assert(!wide.accept(1100, 1));
assert(wide.reason().rfind("verplaatst", 0) == 0);
assert(wide.reason().rfind("moved", 0) == 0);
wide.begin(2000, 200, 200, 3);
wide.update(400, 400, 5); // a second finger elsewhere
assert(wide.accept(2050, 1)); // ignored: only contact 3 is followed; 50 ms is long enough here
wide.begin(3000, 200, 200, 3);
assert(!wide.accept(3010, 2)); // 10 ms is below the capacitive minimum
assert(wide.reason().rfind("te kort", 0) == 0);
assert(wide.reason().rfind("too short", 0) == 0);
wide.begin(3100, 200, 200, 3);
assert(wide.accept(3160, 2));
assert(wide.reason().empty());
assert(!wide.accept(3170, 2));
assert(wide.reason() == "al verwerkt in dit contact");
assert(wide.reason() == "already handled in this contact");
wide.begin(3200, 200, 200, 3);
assert(!wide.accept(3260, 2)); // same tile within 600 ms
assert(wide.reason() == "dezelfde knop binnen de dendertijd");
assert(wide.reason() == "same button within the debounce window");
// No movement limit (0): a capacitive board leaves the drift decision to LVGL's press-lost.
cyd::TouchGuard free;
free.configure(0, 20);
+8 -8
View File
@@ -19,7 +19,7 @@ class ExtendedTests(unittest.TestCase):
with self.assertRaises(ValueError):validate_layout({'title':'Test','tiles':[{'entity':'sensor.x','options':options}]})
with self.assertRaises(ValueError):validate_layout({'title':'Test','tiles':[{'entity':'light.x','options':{'display':'watch','inline':'slider'}}]})
def test_attributes_and_history_fit_protocol(self):
tile={'entity':'sensor.a','name':'Temperatuur','options':{'history_hours':24,'display':'watch'}}
tile={'entity':'sensor.a','name':'Temperature','options':{'history_hours':24,'display':'watch'}}
msg=state_message(0,tile,{'sensor.a':{'state':'21','attributes':{'unit_of_measurement':'°C','password':'hidden'}}})
msg['history']={'hours':24,'values':[None,21.5]*12}
self.assertNotIn('password',msg['a']);self.assertEqual(msg['o'],tile['options']);self.assertTrue(all(len(p)<=255 for p in packets(msg)))
@@ -53,7 +53,7 @@ class FirmwareJobs(unittest.IsolatedAsyncioTestCase):
with tempfile.TemporaryDirectory() as tmp:
m=ManagerTests().setup_manager(Path(tmp)/'screens.json')
m.save('text.screen',{'title':'Home','tiles':[{'entity':'light.a','name':'Lamp','options':{'inline':'slider'}}]})
m.save('text.screen',{'title':'Nieuw','tiles':[{'entity':'light.a','name':'Lamp'}]})
m.save('text.screen',{'title':'New','tiles':[{'entity':'light.a','name':'Lamp'}]})
self.assertEqual(m.layouts['text.screen']['tiles'][0]['options'],{'inline':'slider'})
class SpecialTiles(unittest.TestCase):
@@ -79,11 +79,11 @@ class SpecialTiles(unittest.TestCase):
self.assertEqual(extras({'entity':'timer.egg'},states,None,tz),{'end':1789300800,'dur':'0:05:00','rem':'0:05:00'})
forecast=[{'datetime':f'2026-09-{13+i}T10:00:00+00:00','condition':'partlycloudy','temperature':20+i,'templow':11.5} for i in range(7)]+['junk']
days=extras({'entity':'weather.home'},states,forecast,tz)['days']
self.assertEqual([d['d'] for d in days],['zo','ma','di','wo','do'])
self.assertEqual(days[0],{'d':'zo','c':'partlycloudy','h':20,'l':11.5})
self.assertEqual([d['d'] for d in days],['Su','Mo','Tu','We','Th'])
self.assertEqual(days[0],{'d':'Su','c':'partlycloudy','h':20,'l':11.5})
self.assertIsNone(extras({'entity':'light.a'},states,None,tz))
msg=state_message(0,{'entity':'screen.clock','name':'','options':{'display':'analog'}},{})
self.assertEqual((msg['state'],msg['name'],msg['o']),('ok','Klok',{'display':'analog'}))
self.assertEqual((msg['state'],msg['name'],msg['o']),('ok','Clock',{'display':'analog'}))
msg=state_message(1,{'entity':'weather.home','name':''},states,extras({'entity':'weather.home'},states,forecast,tz))
self.assertEqual(len(msg['x']['days']),5);self.assertTrue(all(len(p)<=255 for p in packets(msg)))
@@ -92,9 +92,9 @@ class SpecialTileSync(unittest.IsolatedAsyncioTestCase):
with tempfile.TemporaryDirectory() as tmp:
m=ManagerTests().setup_manager(Path(tmp)/'screens.json')
layout={'title':'Home','tiles':[{'entity':'screen.clock','name':''},{'entity':'sun.sun','name':''}]}
m.ha.states['sun.sun']={'state':'above_horizon','attributes':{'friendly_name':'Zon','next_rising':'2026-09-14T05:15:00+00:00','next_setting':'2026-09-13T17:50:12+00:00'}}
m.ha.states['sun.sun']={'state':'above_horizon','attributes':{'friendly_name':'Sun','next_rising':'2026-09-14T05:15:00+00:00','next_setting':'2026-09-13T17:50:12+00:00'}}
with self.assertRaises(ValueError):m.save('text.screen',layout)
m.ha.registry.append({'entity_id':'sensor.screen_firmware','platform':'esphome','original_name':'Schermfirmware'})
m.ha.registry.append({'entity_id':'sensor.screen_firmware','platform':'esphome','original_name':'Screen firmware'})
m.ha.states['sensor.screen_firmware']={'state':'0.2.13'}
with self.assertRaises(ValueError):m.save('text.screen',layout)
m.ha.states['sensor.screen_firmware']={'state':'0.2.14'}
@@ -103,7 +103,7 @@ class SpecialTileSync(unittest.IsolatedAsyncioTestCase):
sent=[message for _,message in m.ha.messages]
self.assertEqual(sent[0]['entities'],['screen.clock','sun.sun'])
self.assertEqual(sent[0]['keepalive'],KEEPALIVE_SECONDS,'the screen sizes its feed watchdog from the declared cadence')
self.assertEqual((sent[1]['state'],sent[1]['name']),('ok','Klok'))
self.assertEqual((sent[1]['state'],sent[1]['name']),('ok','Clock'))
self.assertEqual(sent[2]['x'],{'rise':'05:15','set':'17:50'},'fake HA has no time zone: UTC')
m.ha.states['sensor.screen_firmware']={'state':'0.2.13'};m.ha.messages.clear()
await m.sync_one('text.screen',m.layouts['text.screen'])
+20 -19
View File
@@ -5,7 +5,7 @@ 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);
for (const char *bad : {"", "Clock", "time", "state"}) assert(kind(bad) == Kind::none);
uint32_t c = 0;
assert(color("FFB300", c) && c == 0xFFB300);
@@ -25,25 +25,26 @@ int main() {
// 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(ago_text(now - 59, now) == "Just now");
assert(ago_text(now - 60, now) == "1 min ago");
assert(ago_text(now - 3599, now) == "59 min ago");
assert(ago_text(now - 3600, now) == "1 hour ago");
assert(ago_text(now - 86399, now) == "23 hours ago");
assert(ago_text(now - 86400, now) == "Yesterday");
assert(ago_text(now - 172800, now) == "2 days ago");
assert(ago_text(now - 604800, now) == "1 week ago");
assert(ago_text(now - 2 * 604800, now) == "2 weeks ago");
assert(ago_text(now - 2592000, now) == "1 month ago");
assert(ago_text(now - 3 * 2592000, now) == "3 months ago");
assert(ago_text(now - 31536000, now) == "1 year ago");
assert(ago_text(now + 30, now) == "In 1 min");
assert(ago_text(now + 600, now) == "In 10 min");
assert(ago_text(now + 3600, now) == "In 1 hour");
assert(ago_text(now + 7200, now) == "In 2 hours");
assert(ago_text(now + 90000, now) == "Tomorrow");
assert(ago_text(now + 3 * 86400, now) == "In 3 days");
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(2, 14, 9) == "Mo 14 Sep" && date_text(1, 1, 1) == "Su 1 Jan" && date_text(7, 31, 12) == "Sa 31 Dec");
assert(date_text(0, 1, 1) == "—" && date_text(1, 1, 13) == "—");
// Guition: digits 15 px high; CYD: 10 px.
+85 -85
View File
@@ -1,4 +1,4 @@
"""Top bar (app 0.2.38 / firmware 0.2.32): validation, Dutch text, icons, visibility and delivery."""
"""Top bar (app 0.2.38 / firmware 0.2.32): validation, text, icons, visibility and delivery."""
import asyncio
import importlib.util
import json
@@ -28,10 +28,10 @@ def entity(eid, content='state', icon='auto', show='always'):
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'}])
header = validate_header({'items': [{'type': 'clock'}, {'type': 'entity', 'entity': 'binary_sensor.front_door'}, {'type': 'analog'}]})
self.assertEqual(header['items'], [{'type': 'clock'}, entity('binary_sensor.front_door'), {'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'):
for eid in ('device_tracker.phone', 'lock.front_door', 'alarm_control_panel.house', 'zone.home', 'event.doorbell'):
validate_header({'items': [entity(eid)]})
def test_rejects_what_the_firmware_cannot_draw(self):
@@ -56,22 +56,22 @@ class ValidationTests(unittest.TestCase):
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'}]}})
layout = validate_layout({'title': 'Living room', '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(validate_layout({'title': 'Home', 'tiles': []})), [{'type': 'clock'}])
without_clock = validate_layout({'title': 'Home', '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')
def test_numbers_as_home_assistant_writes_them(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')
@@ -81,63 +81,63 @@ class TextTests(unittest.TestCase):
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.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)),
('binary_sensor.door', state('on', device_class='door'), ('Open', None)),
('binary_sensor.door', state('off', device_class='door'), ('Closed', None)),
('binary_sensor.smoke', state('on', device_class='smoke'), ('Smoke', None)),
('binary_sensor.something', state('off'), ('Off', None)),
('alarm_control_panel.house', state('armed_away'), ('Armed away', None)),
('alarm_control_panel.house', state('disarmed'), ('Disarmed', None)),
('alarm_control_panel.house', state('triggered'), ('Triggered', None)),
('lock.front_door', state('locked'), ('Locked', None)),
('person.max', state('not_home'), ('Away', None)),
('person.max', state('Office'), ('Office', 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)),
('weather.home', state('rainy', temperature=17.6, temperature_unit='°C'), ('18 °C', None)),
('climate.living_room', 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)),
('input_datetime.alarm', 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)),
('script.all_off', 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('Café “Sun” 25 m³'), 'Café Sun 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.assertTrue(header_bar.active('binary_sensor.door', door_open))
self.assertFalse(header_bar.active('binary_sensor.door', 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('person.max', state('Office')))
self.assertFalse(header_bar.active('lock.front_door', 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('binary_sensor.door', door_open), header_bar.AMBER)
self.assertIsNone(header_bar.accent('binary_sensor.door', door_closed))
self.assertEqual(header_bar.accent('binary_sensor.leak', state('on', device_class='moisture')), header_bar.RED)
self.assertEqual(header_bar.accent('alarm_control_panel.house', state('armed_home')), header_bar.GREEN)
self.assertEqual(header_bar.accent('alarm_control_panel.house', state('arming')), header_bar.ORANGE)
self.assertEqual(header_bar.accent('alarm_control_panel.house', state('triggered')), header_bar.RED)
self.assertIsNone(header_bar.accent('alarm_control_panel.house', state('disarmed')))
self.assertEqual(header_bar.accent('lock.front_door', 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('binary_sensor.door', door_open), glyph['door-open'])
self.assertEqual(header_bar.auto_icon('binary_sensor.door', 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'])
self.assertEqual(header_bar.auto_icon('weather.home', state('rainy')), glyph['weather-rainy'])
self.assertEqual(header_bar.auto_icon('lock.front_door', state('unlocked')), glyph['lock-open-variant'])
self.assertEqual(header_bar.auto_icon('counter.visitors', 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())
@@ -148,45 +148,45 @@ class TextTests(unittest.TestCase):
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'),
states = {'binary_sensor.door': 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'),
layout = {'title': 'Hallway', 'tiles': [], 'header': {'items': [
entity('binary_sensor.door', 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')
{'k': 'text', 't': '21.3 °C'}, {'k': 'analog'}, {'k': 'clock'}])
states['binary_sensor.door'] = 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')})
preview = header_bar.preview(layout['header'], {**states, 'binary_sensor.door': 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')
self.assertEqual(preview[0]['name'], 'binary_sensor.door')
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'], [])
self.assertEqual(header_bar.message({'title': 'Home', 'tiles': []}, {})['items'], [{'k': 'clock'}])
self.assertEqual(header_bar.message({'title': 'Home', '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'}})
states = {'sensor.plug_temp': state('30', device_class='temperature'), 'sensor.living_room_temp': state('21', device_class='temperature'),
'sensor.attic_temp': state('18', device_class='temperature'), 'zone.home': state('3'), 'sun.sun': state('above_horizon')}
entities = [{'id': 'sensor.plug_temp', 'name': 'Plug', 'area': 'Living room'}, {'id': 'sensor.living_room_temp', 'name': 'Living room', 'area': 'Living room'},
{'id': 'sensor.attic_temp', 'name': 'Attic', 'area': 'Attic'}, {'id': 'zone.home', 'name': 'Home', 'area': ''},
{'id': 'sun.sun', 'name': 'Sun', 'area': ''}]
picks = header_bar.suggestions({'name': 'Living room'}, 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')])
[('Temperature', 'sensor.living_room_temp'), ('People home', 'zone.home'), ('Sunrise and sunset', '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')}
registry = [{'entity_id': 'lock.front_door'}, {'entity_id': 'light.hall'}, {'entity_id': 'update.core'}]
states = {'lock.front_door': state('locked'), 'light.hall': 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})
self.assertEqual(flags, {'lock.front_door': False, 'light.hall': True, 'zone.home': False})
class ParityTests(unittest.TestCase):
def test_text_font_carries_exactly_the_glyphs_the_add_on_sends(self):
@@ -198,8 +198,8 @@ class ParityTests(unittest.TestCase):
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'):
for phrase in ('Just now', ' min ago', ' hour ago', ' hours ago', 'Yesterday', ' days ago', '1 week ago', ' weeks ago',
'1 month ago', ' months ago', ' year ago', ' years ago', 'In ', 'Tomorrow'):
self.assertIn(phrase, FIRMWARE)
self.assertIn(phrase, EDITOR)
for threshold in ('3600', '86400', '172800', '604800', '2592000', '31536000'):
@@ -232,10 +232,10 @@ 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'},
registry = [{'entity_id': 'text.screen', 'platform': 'esphome', 'original_name': 'Tile settings', 'device_id': 'd'},
{'entity_id': 'sensor.fw', 'platform': 'esphome', 'original_name': 'Screen firmware', 'device_id': 'd'},
{'entity_id': 'sensor.t', 'options': {'sensor': {'suggested_display_precision': 1}}},
{'entity_id': 'binary_sensor.deur'}, {'entity_id': 'light.a'}]
{'entity_id': 'binary_sensor.door'}, {'entity_id': 'light.a'}]
devices, areas = [], []
time_zone, units = None, {'temperature': '°C'}
def __init__(self):
@@ -243,7 +243,7 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
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')}
'binary_sensor.door': state('off', device_class='door')}
async def send(self, inbox, message, action=None):
self.messages.append(message)
return Manager(HA(), path)
@@ -251,21 +251,21 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
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')]}}
layout = {'title': 'Hallway', 'tiles': [{'entity': 'light.a', 'name': ''}], 'settings': {'show_clock': True},
'header': {'items': [entity('sensor.t'), entity('binary_sensor.door', 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'}])
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')
m.ha.states['binary_sensor.door'] = 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())
self.assertIn('binary_sensor.door', 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'])
@@ -275,11 +275,11 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
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': []})
m.save('text.screen', {'title': 'Hallway', 'tiles': [], 'header': header})
m.save('text.screen', {'title': 'Different name', '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')]}})
m.save('text.screen', {'title': 'Hallway', 'tiles': [], 'header': {'items': [entity('sensor.does_not_exist')]}})
self.assertEqual(m.layouts['text.screen']['header'], header)
async def test_preview_endpoint_formats_unsaved_items(self):
@@ -291,11 +291,11 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
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'}]
items = [entity('binary_sensor.door', 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')
self.assertEqual(data['items'][0]['t'], 'Closed')
bad = await client.post('/api/header-preview', json={'header': {'items': [{'type': 'nope'}]}}, headers=headers)
self.assertEqual(bad.status, 400)
+3 -3
View File
@@ -46,7 +46,7 @@ class CalibrationTests(unittest.TestCase):
def test_center_is_not_used_to_hide_bad_fit(self):
data = measurements()
for s in data['points'][4]['samples']: s['raw_x'] += 600
with self.assertRaisesRegex(ValueError, 'midden'): calibrate.fit(data)
with self.assertRaisesRegex(ValueError, 'center'): calibrate.fit(data)
def test_jitter_rejected(self):
data = measurements()
@@ -93,7 +93,7 @@ class InstallationTests(unittest.TestCase):
new_device.create(b, 'display-b', 'Display B')
original = (Path(a)/'secrets.yaml').read_text()
self.assertNotEqual(original, (Path(b)/'secrets.yaml').read_text())
self.assertNotIn('WORDT_UNIEK_GEGENEREERD', original)
self.assertNotIn('WILL_BE_UNIQUELY_GENERATED', original)
with self.assertRaises(ValueError): new_device.create(a, 'new', 'New')
self.assertEqual(original, (Path(a)/'secrets.yaml').read_text())
@@ -117,7 +117,7 @@ class InstallationTests(unittest.TestCase):
self.assertEqual(values['TOUCH_AFFINE_XX'], '1.000000000')
self.assertEqual(values['DIRECT_ACTIONS'], 'false')
for i in range(1,11):
self.assertRegex(values[f'TILE{i}_ENTITY'], r'\.(voorbeeld_|ongebruikt_)')
self.assertRegex(values[f'TILE{i}_ENTITY'], r'\.(example_|unused_)')
archive.extractall(tmp)
new_device.create(Path(tmp)/'cyd-display', 'fresh-board', 'Fresh board')
with self.assertRaises(FileExistsError): export_bundle.export(ROOT, output)
+19 -19
View File
@@ -12,7 +12,7 @@ from core import discover, installation_yaml, packets, state_message, validate_l
class ProtocolTests(unittest.TestCase):
def test_unicode_chunks_and_limits(self):
message = {'v':1,'op':'layout','title':'Woonkamer 🌈', 'entities':['light.lamp']*10}
message = {'v':1,'op':'layout','title':'Living room 🌈', 'entities':['light.lamp']*10}
parts = packets(message)
self.assertTrue(all(len(part)<=255 for part in parts))
self.assertEqual(json.loads(base64.b64decode(''.join(p.split('|')[3] for p in parts))),message)
@@ -21,11 +21,11 @@ class ProtocolTests(unittest.TestCase):
def test_layout_rejects_duplicates_unsupported_and_overflow(self):
for tiles in [[{'entity':'lock.front'}],[{'entity':'light.a'}]*2,[{'entity':f'light.a{i}'} for i in range(21)],[{'entity':'light.a;restart'}]]:
with self.assertRaises(ValueError): validate_layout({'title':'Thuis','tiles':tiles})
self.assertEqual(validate_layout({'title':'Thuis','tiles':[]})['tiles'],[])
with self.assertRaises(ValueError): validate_layout({'title':'Home','tiles':tiles})
self.assertEqual(validate_layout({'title':'Home','tiles':[]})['tiles'],[])
def test_settings_defaults_ranges_and_old_layout(self):
self.assertNotIn('settings', validate_layout({'title':'Thuis','tiles':[]}))
self.assertNotIn('settings', validate_layout({'title':'Home','tiles':[]}))
defaults = validate_settings({})
self.assertEqual(defaults['standby_seconds'], 600)
self.assertEqual(defaults['night_start'], 22*60)
@@ -37,7 +37,7 @@ class ProtocolTests(unittest.TestCase):
with self.assertRaises(ValueError): validate_settings(bad)
settings = validate_settings({'brightness':40,'standby_brightness':0,'night_brightness':0})
self.assertEqual(settings['brightness'],40)
message={'v':1,'op':'layout','title':'Thuis','entities':[], 'settings':settings}
message={'v':1,'op':'layout','title':'Home','entities':[], 'settings':settings}
self.assertTrue(all(len(p)<=255 for p in packets(message)))
def test_message_has_only_bounded_display_attributes(self):
@@ -48,17 +48,17 @@ class ProtocolTests(unittest.TestCase):
packets(msg)
def test_discovery_only_enabled_esphome_inboxes(self):
registry=[{'entity_id':'text.screen','platform':'esphome','original_name':'Tegelinstellingen'},
{'entity_id':'text.wrong','platform':'template','original_name':'Tegelinstellingen'},
{'entity_id':'text.disabled','platform':'esphome','original_name':'Tegelinstellingen','disabled_by':'user'}]
screens,_=discover(registry,{'text.screen':{'state':'Gesynchroniseerd'}},[],[])
registry=[{'entity_id':'text.screen','platform':'esphome','original_name':'Tile settings'},
{'entity_id':'text.wrong','platform':'template','original_name':'Tile settings'},
{'entity_id':'text.disabled','platform':'esphome','original_name':'Tile settings','disabled_by':'user'}]
screens,_=discover(registry,{'text.screen':{'state':'Synced'}},[],[])
self.assertEqual([s['id'] for s in screens],['text.screen'])
def test_unique_credentials_and_remote_profiles(self):
data={'board':'cyd','name':'woonkamer','friendly_name':'Woonkamer'}
data={'board':'cyd','name':'living-room','friendly_name':'Living room'}
first,second=installation_yaml(data),installation_yaml(data)
self.assertNotEqual(first,second)
self.assertIn('DEVICE_NAME: "woonkamer"',first)
self.assertIn('DEVICE_NAME: "living-room"',first)
self.assertIn('packages/cyd.yaml',first)
self.assertIn('!secret wifi_password',first)
import re
@@ -76,7 +76,7 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
def setup_manager(self,path):
class HA:
online=True
registry=[{'entity_id':'text.screen','platform':'esphome','original_name':'Tegelinstellingen'}]
registry=[{'entity_id':'text.screen','platform':'esphome','original_name':'Tile settings'}]
devices=[];areas=[]
states={'text.screen':{'state':'Ready'},'light.a':{'state':'on','attributes':{'friendly_name':'Lamp'}}}
changed=asyncio.Event()
@@ -87,7 +87,7 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
async def test_update_restart_preserves_layout(self):
with tempfile.TemporaryDirectory() as temp:
path=Path(temp)/'screens.json';m=self.setup_manager(path)
layout={'title':'Mijn huis','tiles':[{'entity':'light.a','name':'Eigen naam'}]}
layout={'title':'My house','tiles':[{'entity':'light.a','name':'Custom name'}]}
m.save('text.screen',layout)
fresh=self.setup_manager(path)
# Stored layouts always carry a grid position; a save without one packs in order.
@@ -108,7 +108,7 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
async def test_settings_survive_update_and_old_client_save(self):
with tempfile.TemporaryDirectory() as temp:
path=Path(temp)/'screens.json'; m=self.setup_manager(path)
legacy={'title':'Thuis','tiles':[{'entity':'light.a','name':'Mijn lamp'}]}
legacy={'title':'Home','tiles':[{'entity':'light.a','name':'My lamp'}]}
path.write_text(json.dumps({'version':1,'screens':{'text.screen':legacy}}))
m=self.setup_manager(path)
stored={**legacy,'tiles':[{**legacy['tiles'][0],'slot':0}]}
@@ -117,7 +117,7 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
m.save('text.screen',{**legacy,'settings':settings})
fresh=self.setup_manager(path)
self.assertEqual(fresh.layouts['text.screen']['settings'],settings)
fresh.save('text.screen',{**legacy,'title':'Andere titel'})
fresh.save('text.screen',{**legacy,'title':'Different title'})
saved=fresh.layouts['text.screen']
self.assertEqual(saved['settings'],settings)
self.assertEqual(saved['tiles'],stored['tiles'])
@@ -133,12 +133,12 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
async def test_reorder_aborts_old_batch(self):
with tempfile.TemporaryDirectory() as temp:
m=self.setup_manager(Path(temp)/'screens.json');layout={'title':'Thuis','tiles':[{'entity':'light.a','name':''}]}
m=self.setup_manager(Path(temp)/'screens.json');layout={'title':'Home','tiles':[{'entity':'light.a','name':''}]}
m.save('text.screen',layout)
original=m.ha.send
async def interrupt(inbox,message,action=None):
await original(inbox,message)
m.save(inbox,{'title':'Leeg','tiles':[]})
m.save(inbox,{'title':'Empty','tiles':[]})
m.ha.send=interrupt
await m.sync_one('text.screen',m.layouts['text.screen'])
self.assertEqual(len(m.ha.messages),1)
@@ -159,7 +159,7 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(response.status,200)
from firmware import Firmware
m.firmware=Firmware(Path(temp)/'esphome',Path(temp))
profile={'board':'guition','name':'screen-new','friendly_name':'Nieuw','wifi_ssid':'net','wifi_password':'pw'}
profile={'board':'guition','name':'screen-new','friendly_name':'New','wifi_ssid':'net','wifi_password':'pw'}
# A refused USB target leaves no profile behind.
response=await client.post('/api/firmware/profiles',headers=headers,json={**profile,'target':'/dev/ttyUSB9'})
self.assertEqual(response.status,400)
@@ -175,6 +175,6 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
self.assertNotIn('pw',json.dumps((await (await client.get('/api/firmware')).json())['wifi']))
# Until Home Assistant lists the screen, the page shows the profile as "not yet in HA" with its key.
pending=(await (await client.get('/api/inventory?light=1')).json())['pending']
self.assertEqual([(p['file'],p['friendly'],p['installed'],p['api_key']==result['api_key']) for p in pending],[('screen-new.yaml','Nieuw',False,True)])
self.assertEqual([(p['file'],p['friendly'],p['installed'],p['api_key']==result['api_key']) for p in pending],[('screen-new.yaml','New',False,True)])
if __name__=='__main__':unittest.main()
+14 -14
View File
@@ -18,13 +18,13 @@ def tiles(*specs):
class Positions(unittest.TestCase):
def test_layout_without_positions_packs_in_order(self):
layout = validate_layout({'title': 'Thuis', 'tiles': tiles(('light.a',), ('weather.w', None, True), ('light.b',), ('light.c',))})
layout = validate_layout({'title': 'Home', 'tiles': tiles(('light.a',), ('weather.w', None, True), ('light.b',), ('light.c',))})
self.assertEqual([t['slot'] for t in layout['tiles']], [0, 2, 4, 5])
self.assertEqual(pack_slots(layout['tiles']), [0, 2, 4, 5])
self.assertFalse(has_gaps(layout['tiles']))
def test_explicit_positions_keep_gaps_and_sort_by_slot(self):
layout = validate_layout({'title': 'Thuis', 'tiles': tiles(('light.b', 7), ('light.a', 1), ('weather.w', 4, True), ('light.c', 46))})
layout = validate_layout({'title': 'Home', 'tiles': tiles(('light.b', 7), ('light.a', 1), ('weather.w', 4, True), ('light.c', 46))})
self.assertEqual([(t['entity'], t['slot']) for t in layout['tiles']], [('light.a', 1), ('weather.w', 4), ('light.b', 7), ('light.c', 46)])
self.assertTrue(has_gaps(layout['tiles']))
# A second pass (reload from disk) is stable.
@@ -37,20 +37,20 @@ class Positions(unittest.TestCase):
tiles(('light.a', MAX_SLOTS)), # beyond page eight
tiles(('light.a', -1)), tiles(('light.a', '2')), tiles(('light.a', 2.0)),
tiles(('light.a', 0), ('light.b',))]: # some tiles without a position
with self.assertRaises(ValueError, msg=bad): validate_layout({'title': 'Thuis', 'tiles': bad})
with self.assertRaises(ValueError, msg=bad): validate_layout({'title': 'Home', 'tiles': bad})
# Forecast forces a wide card, so the right column is refused there too.
with self.assertRaises(ValueError):
validate_layout({'title': 'Thuis', 'tiles': [{'entity': 'weather.w', 'slot': 1, 'options': {'display': 'forecast'}}]})
validate_layout({'title': 'Home', 'tiles': [{'entity': 'weather.w', 'slot': 1, 'options': {'display': 'forecast'}}]})
def test_pages_kept_on_purpose(self):
layout = validate_layout({'title': 'Thuis', 'tiles': tiles(('light.a', 0)), 'pages': 3})
layout = validate_layout({'title': 'Home', 'tiles': tiles(('light.a', 0)), 'pages': 3})
self.assertEqual(layout['pages'], 3)
self.assertNotIn('pages', validate_layout({'title': 'Thuis', 'tiles': []}))
self.assertNotIn('pages', validate_layout({'title': 'Home', 'tiles': []}))
for bad in [0, 9, '2', 2.0, True]:
with self.assertRaises(ValueError, msg=bad): validate_layout({'title': 'Thuis', 'tiles': [], 'pages': bad})
with self.assertRaises(ValueError, msg=bad): validate_layout({'title': 'Home', 'tiles': [], 'pages': bad})
def test_last_cell_of_page_eight_and_wide_on_last_row(self):
layout = validate_layout({'title': 'Thuis', 'tiles': tiles(('light.a', MAX_SLOTS - 1), ('weather.w', MAX_SLOTS - 4, True))})
layout = validate_layout({'title': 'Home', 'tiles': tiles(('light.a', MAX_SLOTS - 1), ('weather.w', MAX_SLOTS - 4, True))})
self.assertEqual([t['slot'] for t in layout['tiles']], [MAX_SLOTS - 4, MAX_SLOTS - 1])
class PositionsOnTheWire(unittest.IsolatedAsyncioTestCase):
@@ -58,7 +58,7 @@ class PositionsOnTheWire(unittest.IsolatedAsyncioTestCase):
with tempfile.TemporaryDirectory() as tmp:
m = test_portal.ManagerTests().setup_manager(Path(tmp) / 'screens.json')
m.ha.states['light.b'] = {'state': 'off', 'attributes': {}}
m.save('text.screen', {'title': 'Thuis', 'tiles': tiles(('light.b', 7), ('light.a', 2))})
m.save('text.screen', {'title': 'Home', 'tiles': tiles(('light.b', 7), ('light.a', 2))})
await m.sync_one('text.screen', m.layouts['text.screen'])
wire = m.ha.messages[0][1]
self.assertEqual(wire['entities'], ['light.a', 'light.b'])
@@ -67,29 +67,29 @@ class PositionsOnTheWire(unittest.IsolatedAsyncioTestCase):
self.assertEqual([(msg['i'], msg['entity']) for _, msg in m.ha.messages[1:]], [(0, 'light.a'), (1, 'light.b')])
# Moving a tile changes the layout message, so the whole layout goes out again.
m.ha.messages.clear()
m.save('text.screen', {'title': 'Thuis', 'tiles': tiles(('light.b', 6), ('light.a', 2))})
m.save('text.screen', {'title': 'Home', 'tiles': tiles(('light.b', 6), ('light.a', 2))})
await m.sync_one('text.screen', m.layouts['text.screen'])
self.assertEqual(m.ha.messages[0][1]['slots'], [2, 6])
self.assertEqual(len(m.ha.messages), 3)
self.assertNotIn('pages', m.ha.messages[0][1])
m.ha.messages.clear()
m.save('text.screen', {'title': 'Thuis', 'tiles': tiles(('light.b', 6), ('light.a', 2)), 'pages': 4})
m.save('text.screen', {'title': 'Home', 'tiles': tiles(('light.b', 6), ('light.a', 2)), 'pages': 4})
await m.sync_one('text.screen', m.layouts['text.screen'])
self.assertEqual(m.ha.messages[0][1]['pages'], 4)
async def test_old_storage_gets_positions_and_old_editor_keeps_order(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'screens.json'
legacy = {'title': 'Thuis', 'tiles': [{'entity': 'light.a', 'name': ''}, {'entity': 'light.b', 'name': '', 'options': {'size': 'wide'}}]}
legacy = {'title': 'Home', 'tiles': [{'entity': 'light.a', 'name': ''}, {'entity': 'light.b', 'name': '', 'options': {'size': 'wide'}}]}
path.write_text(json.dumps({'version': 1, 'screens': {'text.screen': legacy}}))
m = test_portal.ManagerTests().setup_manager(path)
self.assertEqual([t['slot'] for t in m.layouts['text.screen']['tiles']], [0, 2])
m.ha.states['light.b'] = {'state': 'off', 'attributes': {}}
# The editor moves light.b to page two; an older editor that saves without positions packs again.
m.save('text.screen', {'title': 'Thuis', 'tiles': [{'entity': 'light.a', 'slot': 1}, {'entity': 'light.b', 'slot': 6, 'options': {'size': 'wide'}}]})
m.save('text.screen', {'title': 'Home', 'tiles': [{'entity': 'light.a', 'slot': 1}, {'entity': 'light.b', 'slot': 6, 'options': {'size': 'wide'}}]})
self.assertEqual([t['slot'] for t in m.layouts['text.screen']['tiles']], [1, 6])
self.assertEqual(json.loads(path.read_text())['screens']['text.screen']['tiles'][1]['slot'], 6)
m.save('text.screen', {'title': 'Thuis', 'tiles': [{'entity': 'light.b'}, {'entity': 'light.a'}]})
m.save('text.screen', {'title': 'Home', 'tiles': [{'entity': 'light.b'}, {'entity': 'light.a'}]})
saved = m.layouts['text.screen']['tiles']
self.assertEqual([(t['entity'], t['slot']) for t in saved], [('light.b', 0), ('light.a', 2)])
self.assertEqual(saved[0]['options'], {'size': 'wide'}, 'options survive an old editor, as before')
+232
View File
@@ -0,0 +1,232 @@
"""Firmware 0.2.34 gave the screen entities English names. Home Assistant removes a renamed ESPHome entity
and registers it under a new entity id, so the manager moves a screen's layout and update history to the
new inbox id instead of orphaning them (app 0.2.40)."""
import asyncio
from datetime import timezone
import importlib.util
import json
from pathlib import Path
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
from core import FIRMWARE_VERSION, device_prefixes, discover_screens, entity_slug, inbox_prefix
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
DUTCH = {'text': 'Tegelinstellingen', 'firmware': 'Schermfirmware', 'node': 'Apparaatnaam', 'ip': 'IP-adres'}
ENGLISH = {'text': 'Tile settings', 'firmware': 'Screen firmware', 'node': 'Device name', 'ip': 'IP address'}
def entities(prefix, device, names, firmware, node, ip='10.0.0.5'):
"""Registry entries and states one screen creates; 'Node Status' is a name the translation kept."""
ids = {'text': f'text.{prefix}_{entity_slug(names["text"])}', 'firmware': f'sensor.{prefix}_{entity_slug(names["firmware"])}',
'node': f'sensor.{prefix}_{entity_slug(names["node"])}', 'ip': f'sensor.{prefix}_{entity_slug(names["ip"])}',
'status': f'binary_sensor.{prefix}_node_status'}
registry = [{'entity_id': ids[key], 'platform': 'esphome', 'original_name': names[key], 'device_id': device} for key in names]
registry.append({'entity_id': ids['status'], 'platform': 'esphome', 'original_name': 'Node Status', 'device_id': device})
states = {ids['text']: {'state': 'Synced'}, ids['firmware']: {'state': firmware}, ids['node']: {'state': node},
ids['ip']: {'state': ip}, ids['status']: {'state': 'on'}}
return registry, states
class PrefixTests(unittest.TestCase):
def test_slugs_and_prefixes(self):
self.assertEqual([entity_slug(n) for n in ('Tile settings', 'IP-adres', 'Node Status', 'Guition schermtype')],
['tile_settings', 'ip_adres', 'node_status', 'guition_schermtype'])
self.assertEqual(inbox_prefix('text.office_1_tegelinstellingen'), 'office_1')
self.assertEqual(inbox_prefix('text.office_1_tile_settings'), 'office_1')
for other in ('text.tile_settings', 'sensor.office_1_tile_settings', 'text.office_1_tile_settings_2', 'text.my_inbox', None):
self.assertIsNone(inbox_prefix(other), other)
registry, _ = entities('office_1', 'd1', ENGLISH, '0.2.34', 'office-1')
registry.append({'entity_id': 'text.renamed_by_hand', 'platform': 'esphome', 'original_name': 'Tile settings', 'device_id': 'd1'})
registry.append({'entity_id': 'light.office_1_lamp', 'platform': 'hue', 'original_name': 'Lamp', 'device_id': 'd1'})
self.assertEqual(device_prefixes(registry, 'd1'), {'office_1'})
self.assertEqual(device_prefixes(registry, 'd2'), set())
def test_both_languages_are_discovered(self):
for names, inbox in ((DUTCH, 'text.office_1_tegelinstellingen'), (ENGLISH, 'text.office_1_tile_settings')):
registry, states = entities('office_1', 'd1', names, '0.2.33', 'office-1')
[screen] = discover_screens(registry, states, [{'id': 'd1', 'name': 'Office 1'}], [])
self.assertEqual((screen['id'], screen['firmware'], screen['node'], screen['ip']), (inbox, '0.2.33', 'office-1', '10.0.0.5'))
class FakeFirmware:
"""The ESPHome CLI stand-in: an install reflashes the screen with English entity names."""
def __init__(self, ha):
self.ha, self.task, self.job, self.calls = ha, None, None, []
def profile_names(self):
return {'office-1.yaml': {'node': 'office-1', 'friendly': 'Office 1'}}
def start(self, data):
self.calls.append(data)
self.job = {'state': 'running', **data}
self.task = asyncio.create_task(self.run())
return self.job
async def run(self):
await asyncio.sleep(0)
registry, states = entities('office_1', 'd1', ENGLISH, FIRMWARE_VERSION, 'office-1')
self.ha.registry = registry + [item for item in self.ha.registry if item.get('device_id') != 'd1']
self.ha.states = {**{k: v for k, v in self.ha.states.items() if 'office_1' not in k}, **states}
self.job['state'] = 'success'
def fake_ha():
class HA:
online = True
time_zone = timezone.utc
def __init__(self):
office, office_states = entities('office_1', 'd1', DUTCH, '0.2.30', 'office-1')
kitchen, kitchen_states = entities('kitchen', 'd2', ENGLISH, FIRMWARE_VERSION, 'kitchen', '10.0.0.6')
self.registry = office + kitchen + [{'entity_id': 'light.a', 'platform': 'hue'}]
self.states = {**office_states, **kitchen_states, 'light.a': {'state': 'on', 'attributes': {'friendly_name': 'Lamp'}}}
self.devices, self.areas = [{'id': 'd1', 'name': 'Office 1'}, {'id': 'd2', 'name': 'Kitchen'}], []
self.changed, self.dirty, self.relevant, self.messages, self.calls = asyncio.Event(), set(), None, [], []
async def send(self, inbox, message, action=None):
self.messages.append((inbox, message, action))
async def request(self, kind, **data):
self.calls.append((kind, data))
return HA()
OLD, NEW, KITCHEN = 'text.office_1_tegelinstellingen', 'text.office_1_tile_settings', 'text.kitchen_tile_settings'
LAYOUT = {'title': 'Office', 'tiles': [{'entity': 'light.a', 'name': 'Lamp', 'slot': 0}]}
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
class RenamedInboxTests(unittest.IsolatedAsyncioTestCase):
def manager(self, path, stored=None, updates=None, ha=None):
from server import Manager
if stored is not None:
(Path(path) / 'screens.json').write_text(json.dumps({'version': 1, 'screens': stored}))
if updates is not None:
(Path(path) / 'updates.json').write_text(json.dumps({'version': 1, 'auto': False, **updates}))
m = Manager(ha or fake_ha(), Path(path) / 'screens.json')
m.firmware = FakeFirmware(m.ha)
for attr in ('verify_timeout', 'settle_seconds', 'pause_seconds', 'poll_seconds'):
setattr(m.updates, attr, 1 if attr == 'verify_timeout' else 0)
return m
def reflash(self, m):
registry, states = entities('office_1', 'd1', ENGLISH, FIRMWARE_VERSION, 'office-1')
m.ha.registry = registry + [item for item in m.ha.registry if item.get('device_id') != 'd1']
m.ha.states = {**{k: v for k, v in m.ha.states.items() if 'office_1' not in k}, **states}
async def test_reflash_moves_layout_and_update_history(self):
with tempfile.TemporaryDirectory() as tmp:
kitchen = {'title': 'Kitchen', 'tiles': []}
m = self.manager(tmp, {OLD: LAYOUT, KITCHEN: kitchen},
{'hosts': {OLD: '10.0.0.5'}, 'results': {OLD: {'state': 'success', 'message': 'Updated.'}}})
self.assertEqual(sorted(s['id'] for s in m.screens()), [KITCHEN, OLD])
self.assertEqual(m.aliases, {})
self.reflash(m)
self.assertEqual(sorted(s['id'] for s in m.screens()), [KITCHEN, NEW])
self.assertEqual(m.layouts[NEW]['title'], 'Office')
self.assertNotIn(OLD, m.layouts)
self.assertEqual(m.layouts[KITCHEN], kitchen)
self.assertEqual(json.loads((Path(tmp) / 'screens.json').read_text())['screens'].keys(), {NEW, KITCHEN})
self.assertEqual((m.updates.hosts, list(m.updates.results)), ({NEW: '10.0.0.5'}, [NEW]))
self.assertEqual(json.loads((Path(tmp) / 'updates.json').read_text())['hosts'], {NEW: '10.0.0.5'})
self.assertEqual(m.aliases, {OLD: NEW})
self.assertEqual(m.screen(OLD)['id'], NEW, 'lookups by the old id follow the screen')
# A page opened before the rename still saves to the right screen.
m.save(OLD, {**LAYOUT, 'title': 'Office 2'})
self.assertEqual((m.layouts[NEW]['title'], OLD in m.layouts), ('Office 2', False))
# The sync loop sends the layout to the new inbox.
m.ha.changed.set()
runner = asyncio.create_task(m.run())
for _ in range(50):
await asyncio.sleep(0.02)
if any(inbox == NEW for inbox, _, _ in m.ha.messages):
break
runner.cancel()
self.assertTrue(any(inbox == NEW and msg['op'] == 'layout' and msg['inbox'] == NEW for inbox, msg, _ in m.ha.messages))
async def test_pages_opened_before_the_rename_keep_working(self):
from aiohttp.test_utils import TestClient, TestServer
from server import create_app
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(tmp, {OLD: LAYOUT})
async with TestClient(TestServer(create_app(m, True))) as client:
csrf = (await (await client.get('/api/inventory?light=1')).json())['csrf']
self.reflash(m)
light = await (await client.get('/api/inventory?light=1')).json()
office = next(s for s in light['screens'] if s['node'] == 'office-1')
self.assertEqual((office['id'], office['layout']['title']), (NEW, 'Office'))
inspected = await (await client.get(f'/api/screens/{OLD}/inspect')).json()
self.assertEqual((inspected['screen']['id'], [t['entity'] for t in inspected['tiles']]), (NEW, ['light.a']))
saved = await client.put(f'/api/screens/{OLD}', headers={'X-Screen-CSRF': csrf}, json={**LAYOUT, 'title': 'Office 3'})
self.assertEqual(saved.status, 200)
self.assertEqual((m.layouts[NEW]['title'], OLD in m.layouts), ('Office 3', False))
async def test_restart_after_reflash_adopts_by_device_prefix(self):
with tempfile.TemporaryDirectory() as tmp:
ha = fake_ha()
registry, states = entities('office_1', 'd1', ENGLISH, FIRMWARE_VERSION, 'office-1')
ha.registry = registry + [item for item in ha.registry if item.get('device_id') != 'd1']
ha.states = {**{k: v for k, v in ha.states.items() if 'office_1' not in k}, **states}
m = self.manager(tmp, {OLD: LAYOUT}, ha=ha)
self.assertEqual(sorted(s['id'] for s in m.screens()), [KITCHEN, NEW])
self.assertEqual(list(m.layouts), [NEW])
self.assertIn(NEW, [screen['id'] for screen in m.inventory()[0]])
async def test_orphaned_layouts_of_other_screens_stay_put(self):
with tempfile.TemporaryDirectory() as tmp:
hall = 'text.hall_tegelinstellingen'
m = self.manager(tmp, {hall: LAYOUT})
self.reflash(m)
m.screens()
self.assertEqual(list(m.layouts), [hall], 'a removed screen keeps its layout; nobody else takes it')
self.assertEqual(m.aliases, {})
async def test_a_layout_on_the_new_id_is_never_overwritten(self):
with tempfile.TemporaryDirectory() as tmp:
newer = {'title': 'New', 'tiles': []}
m = self.manager(tmp, {OLD: LAYOUT, NEW: newer})
self.reflash(m)
m.screens()
self.assertEqual((m.layouts[NEW], m.layouts[OLD]), (newer, LAYOUT))
self.assertEqual(m.aliases, {OLD: NEW})
m.ha.registry = list(m.ha.registry) # a later registry refresh does not try again
before = dict(m.layouts)
m.screens()
self.assertEqual((m.layouts, m.aliases), (before, {OLD: NEW}))
async def test_update_round_follows_the_screen_through_the_rename(self):
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(tmp, {OLD: LAYOUT})
[screen] = [s for s in m.screens() if s['id'] == OLD]
self.assertTrue(m.updates.state_for(screen)['available'])
m.updates.start(OLD)
await m.updates.task
self.assertEqual(m.firmware.calls, [{'file': 'office-1.yaml', 'action': 'install', 'target': '10.0.0.5'}])
self.assertEqual(list(m.updates.results), [NEW])
self.assertEqual(m.updates.results[NEW]['state'], 'success', m.updates.results[NEW]['message'])
self.assertEqual((m.updates.current, m.updates.queue), (None, []))
self.assertEqual(list(m.layouts), [NEW])
[after] = [s for s in m.screens() if s['id'] == NEW]
self.assertFalse(m.updates.state_for(after)['available'])
async def test_nightly_round_reports_through_the_new_id(self):
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(tmp, {OLD: LAYOUT})
async def fails_after_rename():
await asyncio.sleep(0)
self.reflash(m)
m.firmware.job['state'] = 'failed'
m.firmware.run = fails_after_rename
await m.updates.run_round([OLD], automatic=True)
self.assertEqual(m.updates.results[NEW]['state'], 'failed')
self.assertEqual(m.ha.calls[0][1]['domain'], 'persistent_notification')
self.assertIn('Office 1', m.ha.calls[0][1]['service_data']['message'])
if __name__ == '__main__':
unittest.main()
+17 -17
View File
@@ -30,35 +30,35 @@ def daily():
class WeatherExtras(unittest.TestCase):
def test_hours_start_now_and_carry_rain(self):
states = {'weather.huis': {'state': 'rainy', 'attributes': {'temperature': 18.4, 'humidity': 92, 'wind_speed': 12.2, 'wind_speed_unit': 'km/h'}}}
extra = extras({'entity': 'weather.huis'}, states, daily(), TZ, hourly(), NOW)
states = {'weather.house': {'state': 'rainy', 'attributes': {'temperature': 18.4, 'humidity': 92, 'wind_speed': 12.2, 'wind_speed_unit': 'km/h'}}}
extra = extras({'entity': 'weather.house'}, states, daily(), TZ, hourly(), NOW)
self.assertEqual([h['t'] for h in extra['hours']], ['13:00', '14:00', '15:00', '16:00', '17:00', '18:00', '19:00', '20:00'], 'local time, from the current hour')
self.assertEqual(extra['hours'][0], {'t': '13:00', 'c': 'rainy', 'h': 19.1, 'p': 60, 'r': 0.2})
self.assertEqual(extra['hours'][1]['r'], 0.0)
self.assertEqual(len(extra['days']), 5)
self.assertEqual(extra['days'][1], {'d': 'ma', 'c': 'cloudy', 'h': 22, 'l': 12.1, 'r': 1.7}, 'no probability when the provider gives none')
message = state_message(0, {'entity': 'weather.huis', 'name': 'Buiten', 'options': {'display': 'forecast'}}, states, extra)
self.assertEqual(extra['days'][1], {'d': 'Mo', 'c': 'cloudy', 'h': 22, 'l': 12.1, 'r': 1.7}, 'no probability when the provider gives none')
message = state_message(0, {'entity': 'weather.house', 'name': 'Outside', 'options': {'display': 'forecast'}}, states, extra)
self.assertEqual(message['a']['wind_speed_unit'], 'km/h')
self.assertEqual(message['a']['humidity'], 92)
self.assertLessEqual(len(packets(message)), 8, 'a full weather card still fits comfortably')
def test_weather_without_forecasts_sends_nothing_extra(self):
self.assertIsNone(extras({'entity': 'weather.huis'}, {}, None, TZ, None, NOW))
self.assertEqual(extras({'entity': 'weather.huis'}, {}, daily(), TZ, [], NOW).keys(), {'days'})
self.assertIsNone(extras({'entity': 'weather.house'}, {}, None, TZ, None, NOW))
self.assertEqual(extras({'entity': 'weather.house'}, {}, daily(), TZ, [], NOW).keys(), {'days'})
class LastRun(unittest.TestCase):
def test_scripts_scenes_and_buttons_report_when_they_last_ran(self):
states = {'script.tv': {'state': 'off', 'attributes': {'last_triggered': '2026-09-13T08:10:49.403953+00:00'}},
'scene.avond': {'state': '2026-09-12T16:33:37.040291+00:00', 'attributes': {}},
'button.bel': {'state': '2026-09-13T09:00:00+00:00', 'attributes': {}},
'script.nieuw': {'state': 'off', 'attributes': {'last_triggered': None}},
'scene.nooit': {'state': 'unknown', 'attributes': {}}}
'scene.evening': {'state': '2026-09-12T16:33:37.040291+00:00', 'attributes': {}},
'button.bell': {'state': '2026-09-13T09:00:00+00:00', 'attributes': {}},
'script.new': {'state': 'off', 'attributes': {'last_triggered': None}},
'scene.never': {'state': 'unknown', 'attributes': {}}}
self.assertEqual(extras({'entity': 'script.tv'}, states, None, TZ), {'last': 1789287049})
self.assertEqual(extras({'entity': 'scene.avond'}, states, None, TZ), {'last': 1789230817})
self.assertEqual(extras({'entity': 'button.bel'}, states, None, TZ), {'last': 1789290000})
self.assertIsNone(extras({'entity': 'script.nieuw'}, states, None, TZ))
self.assertIsNone(extras({'entity': 'scene.nooit'}, states, None, TZ))
self.assertEqual(extras({'entity': 'scene.evening'}, states, None, TZ), {'last': 1789230817})
self.assertEqual(extras({'entity': 'button.bell'}, states, None, TZ), {'last': 1789290000})
self.assertIsNone(extras({'entity': 'script.new'}, states, None, TZ))
self.assertIsNone(extras({'entity': 'scene.never'}, states, None, TZ))
message = state_message(0, {'entity': 'script.tv', 'name': ''}, states, extras({'entity': 'script.tv'}, states, None, TZ))
self.assertEqual(message['x'], {'last': 1789287049})
self.assertNotIn('last_triggered', message['a'], 'the raw timestamp stays behind; the firmware gets the epoch')
@@ -68,16 +68,16 @@ class HourlySync(unittest.IsolatedAsyncioTestCase):
async def test_manager_asks_for_daily_and_hourly_forecasts(self):
with tempfile.TemporaryDirectory() as tmp:
m = test_portal.ManagerTests().setup_manager(Path(tmp) / 'screens.json')
m.ha.states['weather.huis'] = {'state': 'rainy', 'attributes': {'friendly_name': 'Huis', 'temperature': 18.4}}
m.ha.states['weather.house'] = {'state': 'rainy', 'attributes': {'friendly_name': 'House', 'temperature': 18.4}}
asked = []
async def forecast(entity, kind='daily'):
asked.append(kind)
# The manager reads the real clock, so the hourly strip is anchored to today.
return daily() if kind == 'daily' else hourly(datetime.now(timezone.utc))
m.ha.forecast = forecast
m.ha.registry.append({'entity_id': 'sensor.fw', 'platform': 'esphome', 'original_name': 'Schermfirmware'})
m.ha.registry.append({'entity_id': 'sensor.fw', 'platform': 'esphome', 'original_name': 'Screen firmware'})
m.ha.states['sensor.fw'] = {'state': '0.2.19'}
m.save('text.screen', {'title': 'Thuis', 'tiles': [{'entity': 'weather.huis', 'name': '', 'options': {'display': 'forecast'}}]})
m.save('text.screen', {'title': 'Home', 'tiles': [{'entity': 'weather.house', 'name': '', 'options': {'display': 'forecast'}}]})
await m.sync_one('text.screen', m.layouts['text.screen'])
self.assertEqual(sorted(asked), ['daily', 'hourly'])
sent = m.ha.messages[1][1]
+3 -3
View File
@@ -14,10 +14,10 @@ class RotationTests(unittest.IsolatedAsyncioTestCase):
async def test_legacy_wire_and_saved_rotation_survive_old_browser(self):
with tempfile.TemporaryDirectory() as tmp:
m=test_portal.ManagerTests().setup_manager(Path(tmp)/'screens.json')
layout={'title':'Thuis','tiles':[{'entity':'light.a'}],'settings':{'rotation':90}}
layout={'title':'Home','tiles':[{'entity':'light.a'}],'settings':{'rotation':90}}
with self.assertRaises(ValueError):m.save('text.screen',layout)
m.ha.registry[0]['device_id']='guition'
m.ha.registry.append({'entity_id':'sensor.board','device_id':'guition','platform':'esphome','original_name':'Guition schermtype'})
m.ha.registry.append({'entity_id':'sensor.board','device_id':'guition','platform':'esphome','original_name':'Guition screen type'})
# Capability stays discoverable with the panel offline or renamed.
m.ha.states['sensor.board']={'state':'unavailable'}
m.save('text.screen',layout)
@@ -26,7 +26,7 @@ class RotationTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(wire['rotation'],90)
self.assertEqual(len(wire['settings']),11)
self.assertNotIn('rotation',wire['settings'])
m.save('text.screen',{'title':'Anders','tiles':layout['tiles'],'settings':{'brightness':80}})
m.save('text.screen',{'title':'Different','tiles':layout['tiles'],'settings':{'brightness':80}})
self.assertEqual(m.layouts['text.screen']['settings']['rotation'],90)
fresh=test_portal.ManagerTests().setup_manager(m.path)
self.assertEqual(fresh.layouts['text.screen']['settings']['rotation'],90)
+24 -24
View File
@@ -4,28 +4,28 @@ int main() {
using namespace runtime_tiles;
Model m; bool changed = false;
assert(!m.ready());
assert(!m.set_layout({"light.a", "light.a"}, "Thuis", changed));
assert(!m.set_layout({"light.a;script.bad"}, "Thuis", changed));
assert(!m.set_layout({"light."}, "Thuis", changed));
assert(!m.set_layout({"lock.frontdoor"}, "Thuis", changed));
assert(m.set_layout({"light.a", "climate.office", "vacuum.robot"}, "Thuis", changed));
assert(!m.set_layout({"light.a", "light.a"}, "Home", changed));
assert(!m.set_layout({"light.a;script.bad"}, "Home", changed));
assert(!m.set_layout({"light."}, "Home", changed));
assert(!m.set_layout({"lock.frontdoor"}, "Home", changed));
assert(m.set_layout({"light.a", "climate.office", "vacuum.robot"}, "Home", changed));
assert(changed && !m.ready());
for (size_t i = 0; i < m.count; ++i) { m.tiles[i].received = true; m.tiles[i].state = "off"; }
assert(m.ready());
assert(m.set_layout({"light.a", "climate.office", "vacuum.robot"}, "Keuken", changed));
assert(m.set_layout({"light.a", "climate.office", "vacuum.robot"}, "Kitchen", changed));
assert(!changed && m.ready()); // periodic sync never interrupts an open card
assert(!m.accepts(10, "light.a"));
assert(!m.accepts(0, "light.removed"));
assert(m.accepts(0, "light.a"));
assert(m.set_layout({"vacuum.robot", "light.new"}, "Keuken", changed));
assert(m.set_layout({"vacuum.robot", "light.new"}, "Kitchen", changed));
assert(changed && !m.ready());
assert(m.tiles[0].state.empty()); // never reuse the old slot's state/actions
assert(!m.accepts(0, "light.a"));
assert(m.set_layout({}, "Leeg", changed)); assert(changed && m.ready() && m.count == 0);
assert(m.set_layout({}, "Empty", changed)); assert(changed && m.ready() && m.count == 0);
std::vector<std::string> many;
for (int i = 0; i < 21; ++i) many.push_back("light.a" + std::to_string(i));
assert(!m.set_layout(many, "Te veel", changed)); assert(m.count == 0);
many.pop_back(); assert(m.set_layout(many,"Twintig",changed));
assert(!m.set_layout(many, "Too many", changed)); assert(m.count == 0);
many.pop_back(); assert(m.set_layout(many,"Twenty",changed));
assert(m.count==20 && m.accepts(19,"light.a19"));
for(size_t i=0;i<m.count;++i){m.tiles[i].received=true;m.tiles[i].state="off";}
assert(m.ready());
@@ -36,7 +36,7 @@ static void test_domains_and_packing() {
Model m; bool changed = false;
assert(valid_entity("screen.clock") && !valid_entity("screen.other"));
assert(valid_entity("sun.sun") && valid_entity("timer.kitchen") && valid_entity("person.max"));
assert(m.set_layout({"screen.clock", "weather.home", "light.a", "sensor.b", "person.max", "timer.egg", "sun.sun"}, "Thuis", changed));
assert(m.set_layout({"screen.clock", "weather.home", "light.a", "sensor.b", "person.max", "timer.egg", "sun.sun"}, "Home", changed));
assert(m.tiles[0].available() && !m.tiles[1].available()); // built-in cards need no HA state
m.tiles[4].state = "home"; assert(m.tiles[4].active());
m.tiles[5].state = "active"; assert(m.tiles[5].active());
@@ -63,41 +63,41 @@ static void test_explicit_slots() {
Model m; bool changed = false, moved = false;
std::array<Placement, MAX_TILES> p;
// Validation: one slot per entity, inside eight pages, no duplicates.
assert(!m.set_layout({"light.a", "light.b"}, "Thuis", changed, {0}, moved));
assert(!m.set_layout({"light.a", "light.b"}, "Thuis", changed, {0, 48}, moved));
assert(!m.set_layout({"light.a", "light.b"}, "Thuis", changed, {3, 3}, moved));
assert(!m.set_layout({"light.a", "light.b"}, "Home", changed, {0}, moved));
assert(!m.set_layout({"light.a", "light.b"}, "Home", changed, {0, 48}, moved));
assert(!m.set_layout({"light.a", "light.b"}, "Home", changed, {3, 3}, moved));
assert(!m.configured);
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {0, 5, 7}, moved));
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Home", changed, {0, 5, 7}, moved));
assert(changed && !moved && m.explicit_slots);
assert(place(m, p) == 2 && p[0].page == 0 && p[0].slot == 0 && p[1].slot == 5 && p[2].page == 1 && p[2].slot == 1);
// Same tiles elsewhere: states survive, only the pages re-place.
for (size_t i = 0; i < m.count; ++i) { m.tiles[i].received = true; m.tiles[i].state = "on"; }
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {2, 5, 7}, moved));
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Home", changed, {2, 5, 7}, moved));
assert(!changed && moved && m.ready() && m.tiles[0].state == "on");
assert(place(m, p) == 2 && p[0].slot == 2);
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {2, 5, 7}, moved));
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Home", changed, {2, 5, 7}, moved));
assert(!changed && !moved);
// A wide card on an odd slot snaps to its row start; the page count follows its footprint.
m.tiles[2].wide = true;
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {2, 5, 11}, moved));
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Home", changed, {2, 5, 11}, moved));
assert(!changed && moved && place(m, p) == 2 && p[2].page == 1 && p[2].slot == 4);
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {2, 5, 12}, moved));
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Home", changed, {2, 5, 12}, moved));
assert(place(m, p) == 3 && p[2].page == 2 && p[2].slot == 0);
// An empty page between two used pages stays a page; the last slot of page eight is allowed.
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {0, 1, 46}, moved));
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Home", changed, {0, 1, 46}, moved));
assert(place(m, p) == 8 && p[2].page == 7 && p[2].slot == 4);
// Pages kept on purpose extend the count, never shrink it.
m.pages = 3; assert(place(m, p) == 8);
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {0, 1, 2}, moved));
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Home", changed, {0, 1, 2}, moved));
assert(place(m, p) == 3); m.pages = 1; assert(place(m, p) == 1); m.pages = 200; assert(place(m, p) == MAX_PAGES); m.pages = 1;
// Without slots (older manager) the in-order packing returns.
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {}, moved));
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Home", changed, {}, moved));
assert(!changed && moved && !m.explicit_slots);
assert(place(m, p) == 1 && p[0].slot == 0 && p[1].slot == 1 && p[2].slot == 2);
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {}, moved));
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Home", changed, {}, moved));
assert(!changed && !moved);
// Changing the tiles resets everything, including the positions.
assert(m.set_layout({"light.a", "light.z"}, "Thuis", changed, {1, 6}, moved));
assert(m.set_layout({"light.a", "light.z"}, "Home", changed, {1, 6}, moved));
assert(changed && !moved && !m.ready() && place(m, p) == 2 && p[0].slot == 1 && p[1].page == 1);
}
struct RunSlots { RunSlots() { test_explicit_slots(); } } run_slots;
+16 -16
View File
@@ -21,7 +21,7 @@ if HAS_AIOHTTP:
class ProtocolTests(unittest.TestCase):
def test_encode_revision_and_action(self):
message = {'v': 1, 'op': 'layout', 'title': 'Kantoor 1', 'entities': ['light.a']}
message = {'v': 1, 'op': 'layout', 'title': 'Office 1', 'entities': ['light.a']}
self.assertEqual(json.loads(encode(message)), message)
self.assertNotIn(', ', encode(message), 'compact separators')
with self.assertRaises(ValueError):
@@ -30,10 +30,10 @@ class ProtocolTests(unittest.TestCase):
import base64
self.assertEqual(base64.b64decode(''.join(p.split('|')[3] for p in packets(message))).decode(), encode(message))
# Same content, any key order: same revision; any change: another one.
self.assertEqual(revision(message), revision({'entities': ['light.a'], 'title': 'Kantoor 1', 'op': 'layout', 'v': 1}))
self.assertNotEqual(revision(message), revision({**message, 'title': 'Kantoor 2'}))
self.assertEqual(revision(message), revision({'entities': ['light.a'], 'title': 'Office 1', 'op': 'layout', 'v': 1}))
self.assertNotEqual(revision(message), revision({**message, 'title': 'Office 2'}))
self.assertEqual(len(revision(message)), 12)
self.assertEqual(message_action('kantoor-1'), 'esphome.kantoor_1_screen_message')
self.assertEqual(message_action('office-1'), 'esphome.office_1_screen_message')
self.assertIsNone(message_action(None))
self.assertEqual(TRANSPORT_MIN_FIRMWARE, (0, 2, 33))
@@ -47,21 +47,21 @@ class ProtocolTests(unittest.TestCase):
self.assertEqual(samples([], 0, 2400), [None] * 24)
def fake_ha(firmware='0.2.33', node='kantoor-1'):
def fake_ha(firmware='0.2.33', node='office-1'):
class HA:
online = True
def __init__(self):
self.registry = [{'entity_id': 'text.screen', 'platform': 'esphome', 'original_name': 'Tegelinstellingen', 'device_id': 'd1'},
{'entity_id': 'sensor.fw', 'platform': 'esphome', 'original_name': 'Schermfirmware', 'device_id': 'd1'},
{'entity_id': 'text.node', 'platform': 'esphome', 'original_name': 'Apparaatnaam', 'device_id': 'd1'},
self.registry = [{'entity_id': 'text.screen', 'platform': 'esphome', 'original_name': 'Tile settings', 'device_id': 'd1'},
{'entity_id': 'sensor.fw', 'platform': 'esphome', 'original_name': 'Screen firmware', 'device_id': 'd1'},
{'entity_id': 'text.node', 'platform': 'esphome', 'original_name': 'Device name', 'device_id': 'd1'},
{'entity_id': 'light.a', 'platform': 'hue'}, {'entity_id': 'light.b', 'platform': 'hue'},
{'entity_id': 'sensor.t', 'platform': 'mqtt'}, {'entity_id': 'sensor.co2', 'platform': 'mqtt'}]
self.devices, self.areas = [{'id': 'd1', 'name': 'Kantoor 1'}], []
self.states = {'text.screen': {'state': 'Gesynchroniseerd'}, 'sensor.fw': {'state': firmware}, 'text.node': {'state': node},
self.devices, self.areas = [{'id': 'd1', 'name': 'Office 1'}], []
self.states = {'text.screen': {'state': 'Synced'}, 'sensor.fw': {'state': firmware}, 'text.node': {'state': node},
'light.a': {'state': 'on', 'attributes': {'friendly_name': 'Lamp A'}},
'light.b': {'state': 'off', 'attributes': {'friendly_name': 'Lamp B'}},
'sensor.t': {'state': '21.5', 'attributes': {'unit_of_measurement': '°C', 'friendly_name': 'Temperatuur'}},
'sensor.t': {'state': '21.5', 'attributes': {'unit_of_measurement': '°C', 'friendly_name': 'Temperature'}},
'sensor.co2': {'state': '600', 'attributes': {'unit_of_measurement': 'ppm', 'friendly_name': 'CO2'}}}
self.changed = asyncio.Event()
self.dirty = set()
@@ -87,7 +87,7 @@ def fake_ha(firmware='0.2.33', node='kantoor-1'):
class IncrementalSync(unittest.IsolatedAsyncioTestCase):
def manager(self, tmp, **kw):
m = Manager(fake_ha(**kw), Path(tmp) / 'screens.json')
m.save('text.screen', {'title': 'Kantoor 1', 'tiles': [{'entity': 'light.a', 'name': ''}, {'entity': 'light.b', 'name': ''}],
m.save('text.screen', {'title': 'Office 1', 'tiles': [{'entity': 'light.a', 'name': ''}, {'entity': 'light.b', 'name': ''}],
'header': {'items': [{'type': 'clock'}, {'type': 'entity', 'entity': 'sensor.t', 'content': 'state', 'icon': 'auto', 'show': 'always'}]}})
return m
@@ -99,7 +99,7 @@ class IncrementalSync(unittest.IsolatedAsyncioTestCase):
ops = [msg['op'] for _, msg, _ in m.ha.messages]
self.assertEqual(ops, ['layout', 'header', 'state', 'state'])
self.assertIn('rev', m.ha.messages[0][1])
self.assertTrue(all(action == 'esphome.kantoor_1_screen_message' for _, _, action in m.ha.messages), 'firmware 0.2.33: one action per message')
self.assertTrue(all(action == 'esphome.office_1_screen_message' for _, _, action in m.ha.messages), 'firmware 0.2.33: one action per message')
m.ha.messages.clear()
# Both lamps change in HA, but only light.a is reported dirty: light.b's message is reused as sent.
m.ha.states['light.a']['state'] = 'off'
@@ -135,7 +135,7 @@ class IncrementalSync(unittest.IsolatedAsyncioTestCase):
rev = m.ha.messages[0][1]['rev']
m.ha.messages.clear()
await m.ping('text.screen', m.screen('text.screen'))
self.assertEqual(m.ha.messages, [('text.screen', {'v': 1, 'op': 'ping', 'rev': rev, 'keepalive': KEEPALIVE_SECONDS}, 'esphome.kantoor_1_screen_message')])
self.assertEqual(m.ha.messages, [('text.screen', {'v': 1, 'op': 'ping', 'rev': rev, 'keepalive': KEEPALIVE_SECONDS}, 'esphome.office_1_screen_message')])
self.assertEqual(rev, revision({k: v for k, v in m.sent['text.screen']['layout'].items() if k != 'rev'}))
# Firmware before 0.2.33: base64 chunks in the text inbox, no ping.
old = self.manager(tmp, firmware='0.2.32')
@@ -176,7 +176,7 @@ class IncrementalSync(unittest.IsolatedAsyncioTestCase):
self.assertEqual([msg['op'] for _, msg, _ in m.ha.messages], ['ping'])
m.ha.messages.clear()
# The screen answered a ping with a mismatch (or restarted): everything again, once.
m.ha.states['text.screen'] = {'state': 'Indeling opnieuw nodig'}
m.ha.states['text.screen'] = {'state': 'Resend needed'}
m.last['text.screen'] = time.monotonic() - 130
m.ha.changed.set()
await self.run_loop_for(m, 0.6)
@@ -187,7 +187,7 @@ class IncrementalSync(unittest.IsolatedAsyncioTestCase):
await self.run_loop_for(m, 0.6)
self.assertEqual(m.ha.messages, [])
# The hourly safety net repeats everything.
m.ha.states['text.screen'] = {'state': 'Gesynchroniseerd'}
m.ha.states['text.screen'] = {'state': 'Synced'}
m.last['text.screen'] = time.monotonic() - FULL_REPEAT_SECONDS
m.ha.changed.set()
await self.run_loop_for(m, 0.6)
+14 -14
View File
@@ -24,17 +24,17 @@ int main() {
assert(format_value(NAN, 0.5f, "°") == "--");
// Curtain: horizontal arrows, open disabled when fully open, stop only when supported.
Tile curtain = make("cover.gordijnen", "open", 15); curtain.device_class = "curtain"; curtain.position = 100; curtain.controls = "buttons";
Tile curtain = make("cover.curtains", "open", 15); curtain.device_class = "curtain"; curtain.position = 100; curtain.controls = "buttons";
std::array<Key, 3> keys;
assert(keys_for(curtain, keys) == 3);
assert(!strcmp(keys[0].icon, glyph::EXPAND) && keys[0].command == COVER_OPEN && keys[0].disabled);
assert(keys[1].command == COVER_STOP && !keys[1].disabled);
assert(!strcmp(keys[2].icon, glyph::COLLAPSE) && keys[2].command == COVER_CLOSE && !keys[2].disabled);
assert(key_action(curtain, COVER_CLOSE).service == "cover.close_cover");
Tile shutter = make("cover.rolluik", "closed", 3); shutter.controls = "buttons";
Tile shutter = make("cover.shutter", "closed", 3); shutter.controls = "buttons";
assert(keys_for(shutter, keys) == 2 && !strcmp(keys[0].icon, glyph::UP) && keys[1].disabled);
assert(status_text(curtain) == "Open · 100%");
assert(status_text(shutter) == "Dicht");
assert(status_text(shutter) == "Closed");
// Vacuum: play while docked, pause while cleaning, dock disabled in the dock.
Tile robot = make("vacuum.s8", "docked", 30524); robot.controls = "buttons";
@@ -57,23 +57,23 @@ int main() {
sonos.muted = true; assert(key_action(sonos, MEDIA_MUTE).value == "false");
Tile radio = make("media_player.radio", "idle", feature::MEDIA_PLAY | feature::MEDIA_PAUSE); radio.controls = "playback";
assert(keys_for(radio, keys) == 1 && !strcmp(keys[0].icon, glyph::PLAY));
assert(status_text(radio) == "Inactief");
assert(status_text(radio) == "Idle");
// Climate: mode keys in priority order, at most three, the active one checked.
Tile ac = make("climate.airco", "cool"); ac.hvac_modes = "[\"off\",\"heat_cool\",\"cool\",\"heat\",\"fan_only\",\"dry\"]"; ac.controls = "mode"; ac.current = 21.5f; ac.target = 20; ac.step = 1;
Tile ac = make("climate.ac", "cool"); ac.hvac_modes = "[\"off\",\"heat_cool\",\"cool\",\"heat\",\"fan_only\",\"dry\"]"; ac.controls = "mode"; ac.current = 21.5f; ac.target = 20; ac.step = 1;
assert(keys_for(ac, keys) == 3);
assert(keys[0].arg == "off" && keys[1].arg == "heat" && keys[2].arg == "cool" && keys[2].checked && !keys[1].checked);
Action mode = key_action(ac, HVAC_MODE, keys[1].arg);
assert(mode.service == "climate.set_hvac_mode" && mode.key == "hvac_mode" && mode.value == "heat");
assert(status_text(ac) == "Koelen · 21.5°");
ac.hvac_action = "cooling"; assert(status_text(ac) == "Koelt · 21.5°");
assert(status_text(ac) == "Cool · 21.5°");
ac.hvac_action = "cooling"; assert(status_text(ac) == "Cooling · 21.5°");
assert(edit_target(ac) == 20 && edit_step(ac) == 1);
Action set = edit_action(ac, step_value(edit_target(ac), edit_step(ac), ac.minimum, ac.maximum, 1));
assert(set.service == "climate.set_temperature" && set.key == "temperature" && set.value == "21");
assert(edit_action(ac, 20.5f).value == "20.5");
// Numbers edit their own state; selects step through their options with wrap-around.
Tile number = make("number.doel", "55"); number.minimum = 0; number.maximum = 100; number.step = 5;
Tile number = make("number.target", "55"); number.minimum = 0; number.maximum = 100; number.step = 5;
assert(edit_target(number) == 55 && edit_action(number, step_value(55, 5, 0, 100, -1)).value == "50");
assert(edit_action(number, 50).service == "number.set_value");
Tile select = make("select.stand", "Comfort"); select.options = {"Eco", "Comfort", "Boost"}; select.option_count = 3; select.controls = "stepper";
@@ -84,14 +84,14 @@ int main() {
select.option_count = 1; assert(keys_for(select, keys) == 2 && keys[0].disabled);
// Timer, run buttons and toggles.
Tile timer = make("timer.eieren", "active"); timer.controls = "buttons";
Tile timer = make("timer.eggs", "active"); timer.controls = "buttons";
assert(keys_for(timer, keys) == 2 && keys[0].command == TIMER_PAUSE && !keys[1].disabled);
timer.state = "idle"; assert(keys_for(timer, keys) == 2 && keys[0].command == TIMER_START && keys[1].disabled);
assert(key_action(make("scene.avond", "unknown"), RUN).service == "scene.turn_on");
assert(key_action(make("script.alles", "off"), RUN).service == "script.turn_on");
assert(key_action(make("input_button.bel", "unknown"), RUN).service == "input_button.press");
assert(!strcmp(run_label("scene"), "Activeren") && !strcmp(run_label("button"), "Indrukken"));
assert(key_action(make("switch.bureau", "on"), TOGGLE).service == "switch.turn_off");
assert(key_action(make("scene.evening", "unknown"), RUN).service == "scene.turn_on");
assert(key_action(make("script.all", "off"), RUN).service == "script.turn_on");
assert(key_action(make("input_button.bell", "unknown"), RUN).service == "input_button.press");
assert(!strcmp(run_label("scene"), "Activate") && !strcmp(run_label("button"), "Press"));
assert(key_action(make("switch.desk", "on"), TOGGLE).service == "switch.turn_off");
assert(key_action(make("light.lamp", "off"), TOGGLE).service == "light.turn_on");
assert(!key_action(make("sensor.x", "1"), TOGGLE).valid());
assert(!key_action(make("sensor.x", "1"), RUN).valid());
+10 -10
View File
@@ -26,7 +26,7 @@ class ControlChoices(unittest.TestCase):
self.assertNotIn('sensor', catalogue)
def test_validation_accepts_own_sets_and_rejects_others(self):
layout = validate_layout({'title': 'T', 'tiles': [{'entity': 'cover.gordijn', 'options': {'size': 'wide', 'controls': 'position'}}]})
layout = validate_layout({'title': 'T', 'tiles': [{'entity': 'cover.curtain', 'options': {'size': 'wide', 'controls': 'position'}}]})
self.assertEqual(layout['tiles'][0]['options']['controls'], 'position')
validate_layout({'title': 'T', 'tiles': [{'entity': 'light.a', 'options': {'controls': 'none'}}]})
for entity, choice in (('cover.a', 'volume'), ('sensor.a', 'toggle'), ('switch.a', 'brightness'), ('climate.a', 42), ('light.a', 'run')):
@@ -44,25 +44,25 @@ class ControlChoices(unittest.TestCase):
def test_wire_carries_only_the_shown_set_and_the_attributes_the_panels_need(self):
states = {'media_player.sonos': {'state': 'playing', 'attributes': {'volume_level': 0.17, 'is_volume_muted': False, 'media_title': 'TV', 'supported_features': 8321599, 'assumed_state': True}},
'cover.gordijn': {'state': 'open', 'attributes': {'current_position': 80, 'device_class': 'curtain', 'supported_features': 15}},
'climate.airco': {'state': 'cool', 'attributes': {'hvac_action': 'cooling', 'hvac_modes': ['off', 'cool', 'heat'], 'temperature': 20, 'target_temp_step': 1.0}}}
'cover.curtain': {'state': 'open', 'attributes': {'current_position': 80, 'device_class': 'curtain', 'supported_features': 15}},
'climate.ac': {'state': 'cool', 'attributes': {'hvac_action': 'cooling', 'hvac_modes': ['off', 'cool', 'heat'], 'temperature': 20, 'target_temp_step': 1.0}}}
msg = state_message(0, {'entity': 'media_player.sonos', 'name': '', 'options': {'size': 'wide'}}, states)
self.assertEqual(msg['o'], {'size': 'wide', 'controls': 'volume'})
self.assertIs(msg['a']['is_volume_muted'], False)
self.assertNotIn('assumed_state', msg['a'])
msg = state_message(1, {'entity': 'cover.gordijn', 'name': '', 'options': {'size': 'wide', 'controls': 'none', 'background': 'blue'}}, states)
msg = state_message(1, {'entity': 'cover.curtain', 'name': '', 'options': {'size': 'wide', 'controls': 'none', 'background': 'blue'}}, states)
self.assertEqual(msg['o'], {'size': 'wide', 'background': 'blue'}, 'an explicit none is not sent; the stored choice stays')
self.assertEqual(msg['a']['device_class'], 'curtain')
msg = state_message(2, {'entity': 'climate.airco', 'name': '', 'options': {'size': 'wide', 'controls': 'mode'}}, states)
msg = state_message(2, {'entity': 'climate.ac', 'name': '', 'options': {'size': 'wide', 'controls': 'mode'}}, states)
self.assertEqual(msg['o'], {'size': 'wide', 'controls': 'mode'})
self.assertEqual(msg['a']['hvac_action'], 'cooling')
self.assertTrue(all(len(p) <= 255 for p in packets(msg)))
msg = state_message(3, {'entity': 'climate.airco', 'name': '', 'options': {'controls': 'mode'}}, states)
msg = state_message(3, {'entity': 'climate.ac', 'name': '', 'options': {'controls': 'mode'}}, states)
self.assertEqual(msg['o'], {}, 'a single card never shows a panel')
self.assertIsNone(min_firmware({'tiles': [{'entity': 'climate.airco', 'options': {'size': 'wide', 'controls': 'mode'}}]}), 'older firmware ignores the field')
self.assertIsNone(min_firmware({'tiles': [{'entity': 'climate.ac', 'options': {'size': 'wide', 'controls': 'mode'}}]}), 'older firmware ignores the field')
def test_screen_options_keeps_the_choice_out_of_the_wire_but_in_storage(self):
tile = {'entity': 'switch.bureau', 'name': '', 'options': {'size': 'wide', 'controls': 'toggle', 'icon': 'auto'}}
tile = {'entity': 'switch.desk', 'name': '', 'options': {'size': 'wide', 'controls': 'toggle', 'icon': 'auto'}}
self.assertEqual(screen_options(tile, {}), {'size': 'wide', 'controls': 'toggle'})
self.assertEqual(validate_layout({'title': 'T', 'tiles': [tile]})['tiles'][0]['options'], tile['options'])
@@ -86,8 +86,8 @@ class ControlInventory(unittest.IsolatedAsyncioTestCase):
with tempfile.TemporaryDirectory() as tmp:
m = test_portal.ManagerTests().setup_manager(Path(tmp) / 'screens.json')
self.assertEqual(m.inventory()[0][0]['id'], 'text.screen')
m.save('text.screen', {'title': 'Thuis', 'tiles': [{'entity': 'light.a', 'options': {'size': 'wide', 'controls': 'brightness'}}]})
m.save('text.screen', {'title': 'Thuis', 'tiles': [{'entity': 'light.a', 'options': {'size': 'wide'}}]})
m.save('text.screen', {'title': 'Home', 'tiles': [{'entity': 'light.a', 'options': {'size': 'wide', 'controls': 'brightness'}}]})
m.save('text.screen', {'title': 'Home', 'tiles': [{'entity': 'light.a', 'options': {'size': 'wide'}}]})
self.assertEqual(m.layouts['text.screen']['tiles'][0]['options'], {'size': 'wide', 'controls': 'brightness'})
await m.sync_one('text.screen', m.layouts['text.screen'])
self.assertEqual(m.ha.messages[1][1]['o'], {'size': 'wide', 'controls': 'brightness'})
+6 -6
View File
@@ -15,7 +15,7 @@ HEADER = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
class IconSetTests(unittest.TestCase):
def test_set_is_complete_and_carried_by_every_icon_font(self):
self.assertEqual(len(tile_icons.ICONS), 150)
self.assertTrue(any(group == 'Media en muziek' and len(icons) >= 20 for group, icons in tile_icons.GROUPS))
self.assertTrue(any(group == 'Media and music' and len(icons) >= 20 for group, icons in tile_icons.GROUPS))
self.assertEqual(len(set(tile_icons.GLYPHS.values())), len(tile_icons.GLYPHS))
for code in tile_icons.GLYPHS.values():
self.assertRegex(code, r'^F[0-9A-F]{4}$')
@@ -60,11 +60,11 @@ class IconSetTests(unittest.TestCase):
class IconOptionTests(unittest.IsolatedAsyncioTestCase):
def test_validation_accepts_the_set_and_auto_only(self):
for value in ('auto', 'lamp', 'spotify', 'speaker'):
layout = validate_layout({'title': 'Thuis', 'tiles': [{'entity': 'media_player.a', 'options': {'icon': value}}]})
layout = validate_layout({'title': 'Home', 'tiles': [{'entity': 'media_player.a', 'options': {'icon': value}}]})
self.assertEqual(layout['tiles'][0]['options']['icon'], value)
for value in ('mdi:lamp', 'F06B5', 'weather-fog', 'unknown', '', None, 42, ['lamp']):
with self.assertRaises(ValueError):
validate_layout({'title': 'Thuis', 'tiles': [{'entity': 'light.a', 'options': {'icon': value}}]})
validate_layout({'title': 'Home', 'tiles': [{'entity': 'light.a', 'options': {'icon': value}}]})
def test_wire_carries_the_codepoint_chosen_or_from_home_assistant(self):
states = {'light.a': {'state': 'on', 'attributes': {'icon': 'mdi:floor-lamp'}},
@@ -88,12 +88,12 @@ class IconOptionTests(unittest.IsolatedAsyncioTestCase):
async def test_old_editor_preserves_icon_and_explicit_auto_clears(self):
with tempfile.TemporaryDirectory() as tmp:
m = test_portal.ManagerTests().setup_manager(Path(tmp) / 'screens.json')
m.save('text.screen', {'title': 'Thuis', 'tiles': [{'entity': 'light.a', 'options': {'icon': 'spotify'}}]})
m.save('text.screen', {'title': 'Thuis', 'tiles': [{'entity': 'light.a', 'options': {'inline': 'none'}}]})
m.save('text.screen', {'title': 'Home', 'tiles': [{'entity': 'light.a', 'options': {'icon': 'spotify'}}]})
m.save('text.screen', {'title': 'Home', 'tiles': [{'entity': 'light.a', 'options': {'inline': 'none'}}]})
self.assertEqual(m.layouts['text.screen']['tiles'][0]['options'], {'inline': 'none', 'icon': 'spotify'})
await m.sync_one('text.screen', m.layouts['text.screen'])
self.assertEqual(m.ha.messages[1][1]['o']['icon'], 'F04C7')
m.save('text.screen', {'title': 'Thuis', 'tiles': [{'entity': 'light.a', 'options': {'icon': 'auto'}}]})
m.save('text.screen', {'title': 'Home', 'tiles': [{'entity': 'light.a', 'options': {'icon': 'auto'}}]})
self.assertEqual(m.layouts['text.screen']['tiles'][0]['options']['icon'], 'auto')
if __name__ == '__main__':
+1 -1
View File
@@ -6,7 +6,7 @@ int main() {
assert(tile_palette::color("unknown-future-choice")==0);
assert(tile_palette::color("red")!=0);
assert(tile_palette::color("green")!=tile_palette::color("red"));
// "Geen" is not a colour: the card is hidden, and old firmware falls back to automatic.
// "None" is not a colour: the card is hidden, and old firmware falls back to automatic.
assert(tile_palette::color("none")==0);
assert(tile_palette::transparent("none"));
assert(!tile_palette::transparent("auto") && !tile_palette::transparent("red") && !tile_palette::transparent(""));
+5 -5
View File
@@ -23,15 +23,15 @@ class PaletteTests(unittest.IsolatedAsyncioTestCase):
async def test_old_editor_preserves_color_and_explicit_auto_clears(self):
with tempfile.TemporaryDirectory() as tmp:
m=test_portal.ManagerTests().setup_manager(Path(tmp)/'screens.json')
layout={'title':'Thuis','tiles':[{'entity':'light.a','options':{'background':'red','inline':'slider'}}]}
layout={'title':'Home','tiles':[{'entity':'light.a','options':{'background':'red','inline':'slider'}}]}
m.save('text.screen',layout)
m.save('text.screen',{'title':'Nieuw','tiles':[{'entity':'light.a','options':{'inline':'none'}}]})
m.save('text.screen',{'title':'New','tiles':[{'entity':'light.a','options':{'inline':'none'}}]})
fresh=test_portal.ManagerTests().setup_manager(m.path)
tile=fresh.layouts['text.screen']['tiles'][0]
self.assertEqual(tile['options'],{'background':'red','inline':'none'})
await fresh.sync_one('text.screen',fresh.layouts['text.screen'])
self.assertEqual(fresh.ha.messages[1][1]['o']['background'],'red')
fresh.save('text.screen',{'title':'Nieuw','tiles':[{'entity':'light.a','options':{'background':'auto'}}]})
fresh.save('text.screen',{'title':'New','tiles':[{'entity':'light.a','options':{'background':'auto'}}]})
self.assertEqual(fresh.layouts['text.screen']['tiles'][0]['options']['background'],'auto')
def test_none_hides_the_card_and_needs_firmware_0216(self):
@@ -40,11 +40,11 @@ class PaletteTests(unittest.IsolatedAsyncioTestCase):
self.assertIn('name=="none"',header)
self.assertIsNone(TILE_BACKGROUNDS['none']['color'])
for entity in ('screen.clock','light.a'):
layout=validate_layout({'title':'Thuis','tiles':[{'entity':entity,'options':{'background':'none'}}]})
layout=validate_layout({'title':'Home','tiles':[{'entity':entity,'options':{'background':'none'}}]})
self.assertEqual(layout['tiles'][0]['options']['background'],'none')
self.assertEqual(min_firmware(layout),(0,2,16))
self.assertEqual(min_firmware({'tiles':[{'entity':'light.a','options':{'background':'red'}}]}),None)
def test_unapproved_colors_rejected(self):
for value in ('#000000','url(test)','unknown',None,{},42):
with self.assertRaises(ValueError):validate_layout({'title':'Thuis','tiles':[{'entity':'light.a','options':{'background':value}}]})
with self.assertRaises(ValueError):validate_layout({'title':'Home','tiles':[{'entity':'light.a','options':{'background':value}}]})
+2 -2
View File
@@ -9,12 +9,12 @@ class TwentyTiles(unittest.IsolatedAsyncioTestCase):
async def test_twenty_and_old_firmware_guard(self):
with tempfile.TemporaryDirectory() as tmp:
m=test_portal.ManagerTests().setup_manager(Path(tmp)/'screens.json')
layout={'title':'Twintig','tiles':[{'entity':f'light.item{i}'} for i in range(20)],
layout={'title':'Twenty','tiles':[{'entity':f'light.item{i}'} for i in range(20)],
'settings':validate_settings({'swipe_pages':True})}
for tile in layout['tiles']:m.ha.states[tile['entity']]={'state':'on','attributes':{}}
with self.assertRaises(ValueError):m.save('text.screen',layout)
m.ha.registry[0]['device_id']='screen-device'
m.ha.registry.append({'entity_id':'sensor.version','platform':'esphome','original_name':'Schermfirmware','device_id':'screen-device'})
m.ha.registry.append({'entity_id':'sensor.version','platform':'esphome','original_name':'Screen firmware','device_id':'screen-device'})
m.ha.states['sensor.version']={'state':'0.2.7'}
m.save('text.screen',layout)
await m.sync_one('text.screen',m.layouts['text.screen'])
+35 -35
View File
@@ -26,26 +26,26 @@ class VersionSourceTests(unittest.TestCase):
for name in ('packages/cyd.yaml', 'packages/guition.yaml', 'home-like-2432s028.yaml', 'guition-4848s040.yaml'):
text = (ROOT / name).read_text()
self.assertIn(f'SCREEN_FIRMWARE_VERSION: "{FIRMWARE_VERSION}"', text, name)
self.assertIn('name: "Apparaatnaam"', text, name)
self.assertIn('name: "Device name"', text, name)
self.assertIn('platform: wifi_info', text, name)
self.assertEqual(parse_version(FIRMWARE_VERSION), tuple(int(p) for p in FIRMWARE_VERSION.split('.')))
self.assertIsNone(parse_version('onbekend'))
self.assertIsNone(parse_version('unknown'))
self.assertIsNone(parse_version('1.2'))
def test_discovery_reports_node_and_address(self):
registry = [{'entity_id': 'text.screen', 'platform': 'esphome', 'original_name': 'Tegelinstellingen', 'device_id': 'd1'},
{'entity_id': 'text.node', 'platform': 'esphome', 'original_name': 'Apparaatnaam', 'device_id': 'd1'},
{'entity_id': 'text.ip', 'platform': 'esphome', 'original_name': 'IP-adres', 'device_id': 'd1'},
{'entity_id': 'text.fw', 'platform': 'esphome', 'original_name': 'Schermfirmware', 'device_id': 'd1'}]
states = {'text.screen': {'state': 'Ready'}, 'text.node': {'state': 'woonkamer'},
registry = [{'entity_id': 'text.screen', 'platform': 'esphome', 'original_name': 'Tile settings', 'device_id': 'd1'},
{'entity_id': 'text.node', 'platform': 'esphome', 'original_name': 'Device name', 'device_id': 'd1'},
{'entity_id': 'text.ip', 'platform': 'esphome', 'original_name': 'IP address', 'device_id': 'd1'},
{'entity_id': 'text.fw', 'platform': 'esphome', 'original_name': 'Screen firmware', 'device_id': 'd1'}]
states = {'text.screen': {'state': 'Ready'}, 'text.node': {'state': 'living-room'},
'text.ip': {'state': '192.168.1.50'}, 'text.fw': {'state': '0.2.16'}}
screens, _ = discover(registry, states, [{'id': 'd1', 'name': 'Woonkamer'}], [])
self.assertEqual(screens[0]['node'], 'woonkamer')
screens, _ = discover(registry, states, [{'id': 'd1', 'name': 'Living room'}], [])
self.assertEqual(screens[0]['node'], 'living-room')
self.assertEqual(screens[0]['ip'], '192.168.1.50')
self.assertEqual(screens[0]['device'], 'Woonkamer')
self.assertEqual(screens[0]['device'], 'Living room')
states['text.ip']['state'] = 'unavailable'
states['text.node']['state'] = '../evil'
screens, _ = discover(registry, states, [{'id': 'd1', 'name': 'Woonkamer'}], [])
screens, _ = discover(registry, states, [{'id': 'd1', 'name': 'Living room'}], [])
self.assertIsNone(screens[0]['ip'])
self.assertIsNone(screens[0]['node'])
@@ -55,16 +55,16 @@ class ProfileNameTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp:
f = Firmware(tmp, tmp)
(Path(tmp) / 'secrets.yaml').write_text('wifi_ssid: example-net\nwifi_password: example-pw\n')
f.create({'board': 'cyd', 'name': 'woonkamer', 'friendly_name': 'Woonkamer'})
(Path(tmp) / 'manual.yaml').write_text('substitutions:\n DEVICE_NAME: "keuken"\n DEVICE_FRIENDLY_NAME: "Keuken"\n'
f.create({'board': 'cyd', 'name': 'living-room', 'friendly_name': 'Living room'})
(Path(tmp) / 'manual.yaml').write_text('substitutions:\n DEVICE_NAME: "kitchen"\n DEVICE_FRIENDLY_NAME: "Kitchen"\n'
'esphome:\n name: ${DEVICE_NAME}\n friendly_name: ${DEVICE_FRIENDLY_NAME}\n'
'packages:\n a: !include other.yaml\napi:\n encryption:\n key: !secret api\n')
(Path(tmp) / 'broken.yaml').write_text('esphome: [\n')
names = f.profile_names()
self.assertEqual(names['woonkamer.yaml'], {'node': 'woonkamer', 'friendly': 'Woonkamer', 'screen': True, 'api_key': names['woonkamer.yaml']['api_key']})
self.assertEqual(len(names['woonkamer.yaml']['api_key']), 44)
self.assertEqual(names['living-room.yaml'], {'node': 'living-room', 'friendly': 'Living room', 'screen': True, 'api_key': names['living-room.yaml']['api_key']})
self.assertEqual(len(names['living-room.yaml']['api_key']), 44)
# A manual profile (no board package from this repo, key behind !secret) is not an ESP Screens profile.
self.assertEqual(names['manual.yaml'], {'node': 'keuken', 'friendly': 'Keuken', 'screen': False, 'api_key': None})
self.assertEqual(names['manual.yaml'], {'node': 'kitchen', 'friendly': 'Kitchen', 'screen': False, 'api_key': None})
self.assertNotIn('broken.yaml', names)
self.assertNotIn('secrets.yaml', names)
@@ -98,13 +98,13 @@ class FakeFirmware:
"""Stands in for the ESPHome CLI: records jobs and flips the screen to the target version."""
def __init__(self, ha, outcome='success'):
self.ha, self.outcome, self.calls, self.task, self.job = ha, outcome, [], None, None
self.names = {'woonkamer.yaml': {'node': 'woonkamer', 'friendly': 'Woonkamer'},
'keuken.yaml': {'node': 'keuken', 'friendly': 'Keuken'}}
self.names = {'living-room.yaml': {'node': 'living-room', 'friendly': 'Living room'},
'kitchen.yaml': {'node': 'kitchen', 'friendly': 'Kitchen'}}
def profile_names(self): return self.names
def start(self, data):
if self.task and not self.task.done(): raise ValueError('Er loopt al een build of installatie.')
if self.task and not self.task.done(): raise ValueError('A build or install is already running.')
self.calls.append(data)
self.job = {'state': 'running', **data}
self.task = asyncio.create_task(self.run(data))
@@ -113,7 +113,7 @@ class FakeFirmware:
async def run(self, data):
await asyncio.sleep(0)
if self.outcome == 'success':
for entity, node in (('text.fw1', 'woonkamer'), ('text.fw2', 'keuken')):
for entity, node in (('text.fw1', 'living-room'), ('text.fw2', 'kitchen')):
if data['file'] == node + '.yaml': self.ha.states[entity] = {'state': FIRMWARE_VERSION}
self.job['state'] = self.outcome
@@ -125,15 +125,15 @@ class UpdaterTests(unittest.IsolatedAsyncioTestCase):
class HA:
online = True
time_zone = timezone.utc
registry = [{'entity_id': 'text.screen1', 'platform': 'esphome', 'original_name': 'Tegelinstellingen', 'device_id': 'd1'},
{'entity_id': 'text.node1', 'platform': 'esphome', 'original_name': 'Apparaatnaam', 'device_id': 'd1'},
{'entity_id': 'text.ip1', 'platform': 'esphome', 'original_name': 'IP-adres', 'device_id': 'd1'},
{'entity_id': 'text.fw1', 'platform': 'esphome', 'original_name': 'Schermfirmware', 'device_id': 'd1'},
{'entity_id': 'text.screen2', 'platform': 'esphome', 'original_name': 'Tegelinstellingen', 'device_id': 'd2'},
{'entity_id': 'text.fw2', 'platform': 'esphome', 'original_name': 'Schermfirmware', 'device_id': 'd2'}]
devices = [{'id': 'd1', 'name': 'Woonkamer'}, {'id': 'd2', 'name': 'Keuken'}]
registry = [{'entity_id': 'text.screen1', 'platform': 'esphome', 'original_name': 'Tile settings', 'device_id': 'd1'},
{'entity_id': 'text.node1', 'platform': 'esphome', 'original_name': 'Device name', 'device_id': 'd1'},
{'entity_id': 'text.ip1', 'platform': 'esphome', 'original_name': 'IP address', 'device_id': 'd1'},
{'entity_id': 'text.fw1', 'platform': 'esphome', 'original_name': 'Screen firmware', 'device_id': 'd1'},
{'entity_id': 'text.screen2', 'platform': 'esphome', 'original_name': 'Tile settings', 'device_id': 'd2'},
{'entity_id': 'text.fw2', 'platform': 'esphome', 'original_name': 'Screen firmware', 'device_id': 'd2'}]
devices = [{'id': 'd1', 'name': 'Living room'}, {'id': 'd2', 'name': 'Kitchen'}]
areas = []
states = {'text.screen1': {'state': 'Ready'}, 'text.node1': {'state': 'woonkamer'}, 'text.ip1': {'state': '10.0.0.5'},
states = {'text.screen1': {'state': 'Ready'}, 'text.node1': {'state': 'living-room'}, 'text.ip1': {'state': '10.0.0.5'},
'text.fw1': {'state': '0.2.16'}, 'text.screen2': {'state': 'Ready'}, 'text.fw2': {'state': '0.2.16'}}
changed = asyncio.Event()
def __init__(self): self.messages, self.calls = [], []
@@ -149,21 +149,21 @@ class UpdaterTests(unittest.IsolatedAsyncioTestCase):
with tempfile.TemporaryDirectory() as tmp:
m = self.setup_manager(tmp)
first, second = m.inventory()[0]
self.assertEqual(m.updates.state_for(first)['profile'], 'woonkamer.yaml')
self.assertEqual(m.updates.state_for(first)['profile'], 'living-room.yaml')
self.assertEqual(m.updates.state_for(first)['host'], '10.0.0.5')
self.assertTrue(m.updates.state_for(first)['available'])
# Older firmware: no node name, so the friendly name decides; no address yet.
self.assertEqual(m.updates.state_for(second)['profile'], 'keuken.yaml')
self.assertEqual(m.updates.state_for(second)['profile'], 'kitchen.yaml')
self.assertIsNone(m.updates.state_for(second)['host'])
self.assertEqual(m.updates.pending(), ['text.screen1'])
with self.assertRaisesRegex(ValueError, 'IP-adres'): m.updates.start('text.screen2')
with self.assertRaisesRegex(ValueError, 'IP address'): m.updates.start('text.screen2')
with self.assertRaises(ValueError): m.updates.start('text.screen2', host='bad host!')
m.updates.start('text.screen2', host='10.0.0.6')
await m.updates.task
self.assertEqual(m.firmware.calls, [{'file': 'keuken.yaml', 'action': 'install', 'target': '10.0.0.6'}])
self.assertEqual(m.firmware.calls, [{'file': 'kitchen.yaml', 'action': 'install', 'target': '10.0.0.6'}])
self.assertEqual(m.updates.results['text.screen2']['state'], 'success')
self.assertEqual(json.loads((Path(tmp) / 'updates.json').read_text())['hosts'], {'text.screen2': '10.0.0.6'})
with self.assertRaisesRegex(ValueError, 'nieuwste'): m.updates.start('text.screen2')
with self.assertRaisesRegex(ValueError, 'latest'): m.updates.start('text.screen2')
m.ha.states['text.fw1'] = {'state': FIRMWARE_VERSION}
self.assertEqual(m.updates.pending(), [])
@@ -180,7 +180,7 @@ class UpdaterTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(m.ha.calls, [], 'manual rounds do not notify')
await m.updates.run_round(['text.screen1'], automatic=True)
self.assertEqual(m.ha.calls[0][1]['domain'], 'persistent_notification')
self.assertIn('Woonkamer', m.ha.calls[0][1]['service_data']['message'])
self.assertIn('Living room', m.ha.calls[0][1]['service_data']['message'])
async def test_screen_that_never_returns_fails(self):
with tempfile.TemporaryDirectory() as tmp:
@@ -218,7 +218,7 @@ class UpdaterTests(unittest.IsolatedAsyncioTestCase):
async def test_manager_watches_only_layout_and_screen_entities(self):
with tempfile.TemporaryDirectory() as tmp:
m = self.setup_manager(tmp)
m.layouts['text.screen1'] = {'title': 'Thuis', 'tiles': [{'entity': 'light.lamp'}]}
m.layouts['text.screen1'] = {'title': 'Home', 'tiles': [{'entity': 'light.lamp'}]}
watched = m.watched_entities()
self.assertEqual(watched, {'light.lamp', 'text.screen1', 'text.node1', 'text.ip1', 'text.fw1', 'text.screen2', 'text.fw2'})