Add clock, forecast, graph, sun, timer and person cards with wide tiles
Any tile can now be double width: pack() in runtime_model.h places wide tiles in the left column and the manager preview follows the same rules. New cards: built-in clock (digital or analog, screen.clock), five-day weather forecast on a wide weather card fed by weather.get_forecasts, history sparkline for sensors, sunrise/sunset (sun.sun), live kitchen timer (timer.*, tap starts or pauses) and presence (person.*). The manager pre-computes time-zone and forecast extras in a new 'x' object and refuses layouts with new domains until the screen runs 0.2.14. The self-test follows the packing, checks custom card parts and wide widths, and diagnostics/send_layout.py pushes a demo layout of every card type for render checks without Home Assistant. App 0.2.12, firmware 0.2.14. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -54,3 +54,54 @@ class FirmwareJobs(unittest.IsolatedAsyncioTestCase):
|
||||
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'}]})
|
||||
self.assertEqual(m.layouts['text.screen']['tiles'][0]['options'],{'inline':'slider'})
|
||||
|
||||
class SpecialTiles(unittest.TestCase):
|
||||
def test_new_domains_displays_and_wide_option(self):
|
||||
from core import extras, min_firmware
|
||||
from zoneinfo import ZoneInfo
|
||||
layout=validate_layout({'title':'T','tiles':[{'entity':'screen.clock','options':{'display':'analog','size':'wide'}},{'entity':'sun.sun'},{'entity':'timer.egg'},{'entity':'person.max'},
|
||||
{'entity':'weather.home','options':{'display':'forecast'}},{'entity':'sensor.t','options':{'display':'graph','size':'wide'}}]})
|
||||
self.assertEqual(layout['tiles'][4]['options'],{'display':'forecast','size':'wide'},'forecast forces a wide card')
|
||||
self.assertEqual(layout['tiles'][0]['options']['display'],'analog')
|
||||
for tile in [{'entity':'screen.other'},{'entity':'light.a','options':{'size':'double'}},{'entity':'light.a','options':{'display':'graph'}},
|
||||
{'entity':'sensor.a','options':{'display':'forecast'}},{'entity':'screen.clock','options':{'display':'watch'}}]:
|
||||
with self.assertRaises(ValueError):validate_layout({'title':'T','tiles':[tile]})
|
||||
self.assertEqual(min_firmware(layout),(0,2,14))
|
||||
self.assertEqual(min_firmware({'tiles':[{'entity':'light.a'}]*11}),(0,2,7))
|
||||
self.assertIsNone(min_firmware({'tiles':[{'entity':'light.a'}]}))
|
||||
tz=ZoneInfo('Europe/Amsterdam')
|
||||
states={'sun.sun':{'state':'above_horizon','attributes':{'next_rising':'2026-09-14T05:15:00+00:00','next_setting':'2026-09-13T17:50:12.000000+00:00'}},
|
||||
'timer.egg':{'state':'active','attributes':{'finishes_at':'2026-09-13T12:00:00+00:00','duration':'0:05:00','remaining':'0:05:00'}},
|
||||
'weather.home':{'state':'sunny','attributes':{'temperature':21.5,'temperature_unit':'°C'}}}
|
||||
self.assertEqual(extras({'entity':'sun.sun'},states,None,tz),{'rise':'07:15','set':'19:50'})
|
||||
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.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'}))
|
||||
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)))
|
||||
|
||||
class SpecialTileSync(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_new_domains_need_firmware_and_send_extras(self):
|
||||
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'}}
|
||||
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.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'}
|
||||
m.save('text.screen',layout)
|
||||
await m.sync_one('text.screen',m.layouts['text.screen'])
|
||||
sent=[message for _,message in m.ha.messages]
|
||||
self.assertEqual(sent[0]['entities'],['screen.clock','sun.sun'])
|
||||
self.assertEqual((sent[1]['state'],sent[1]['name']),('ok','Klok'))
|
||||
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'])
|
||||
self.assertEqual(m.ha.messages,[]);self.assertIn('0.2.14',m.status['text.screen'])
|
||||
|
||||
@@ -30,3 +30,30 @@ int main() {
|
||||
for(size_t i=0;i<m.count;++i){m.tiles[i].received=true;m.tiles[i].state="off";}
|
||||
assert(m.ready());
|
||||
}
|
||||
// Built-in and new Home Assistant domains, plus wide-tile packing.
|
||||
static void test_domains_and_packing() {
|
||||
using namespace runtime_tiles;
|
||||
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.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());
|
||||
m.tiles[5].state = "idle"; assert(!m.tiles[5].active());
|
||||
std::array<Placement, MAX_TILES> p;
|
||||
assert(pack(m.tiles, 0, p) == 1);
|
||||
assert(pack(m.tiles, m.count, p) == 2 && p[6].page == 1 && p[6].slot == 0);
|
||||
// A wide tile after a left-column tile skips the right column.
|
||||
m.tiles[1].wide = true;
|
||||
assert(pack(m.tiles, m.count, p) == 2);
|
||||
assert(p[0].slot == 0 && p[1].slot == 2 && p[2].slot == 4 && p[3].slot == 5);
|
||||
assert(p[4].page == 1 && p[4].slot == 0 && p[6].page == 1 && p[6].slot == 2);
|
||||
// Wide tiles ending exactly on a page boundary do not open an empty page.
|
||||
for (auto &t : m.tiles) t.wide = false;
|
||||
m.tiles[0].wide = m.tiles[2].wide = true;
|
||||
assert(pack(m.tiles, 3, p) == 1 && p[0].slot == 0 && p[1].slot == 2 && p[2].slot == 4);
|
||||
m.tiles[3].wide = true;
|
||||
assert(pack(m.tiles, 4, p) == 2 && p[3].page == 1 && p[3].slot == 0);
|
||||
}
|
||||
struct RunExtra { RunExtra() { test_domains_and_packing(); } } run_extra;
|
||||
|
||||
Reference in New Issue
Block a user