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:
@@ -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.
|
||||
|
||||
@@ -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'}}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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')
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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'})
|
||||
|
||||
@@ -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__':
|
||||
|
||||
@@ -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(""));
|
||||
|
||||
@@ -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}}]})
|
||||
|
||||
@@ -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
@@ -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'})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user