Direct controls on wide cards, weather/climate cards, spinner busy state (0.2.23)
App 0.2.23 / firmware 0.2.19. - Wide cards of climate, switch, light, fan, vacuum, cover, media_player, number, select, timer, scene, script and button show Home Assistant style controls on the right (keys, -/+ pill with debounced send, slider with handle, toggle, run key); per-tile choice `options.controls` in the editor with mockup preview; the add-on sends only the set a card really shows. - Weather card: now + next hours and coming days with rain, from daily and hourly forecasts; scenes/scripts/buttons show when they last ran. - Climate card: power key, tap-to-toggle option, fan and swing rows in a redesigned mode picker (grey backdrop, white chips, accent selection). - Busy state as a spinner on a translucent sheet; render guards so only changed styles reach LVGL; dark theme removed from the runtime code. - supported_features above 1e6 reaches the screen; middle dot in text fonts; 18 control glyphs in the icon fonts; diagnostics services preview_climate_picker and preview_busy; demo layout --controls. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
"""Direct controls on wide cards: editor choices, validation and what goes on the wire."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
from core import CONTROLS, controls_catalogue, min_firmware, packets, resolve_controls, screen_options, state_message, validate_layout
|
||||
import tile_icons
|
||||
import test_portal
|
||||
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
|
||||
|
||||
class ControlChoices(unittest.TestCase):
|
||||
def test_catalogue_offers_each_domain_its_sets_plus_none(self):
|
||||
catalogue = controls_catalogue()
|
||||
self.assertEqual(catalogue['media_player']['default'], 'volume')
|
||||
self.assertEqual([c['key'] for c in catalogue['media_player']['choices']], ['volume', 'playback', 'none'])
|
||||
self.assertEqual([c['key'] for c in catalogue['climate']['choices']], ['setpoint', 'mode', 'none'])
|
||||
for domain in ('climate', 'switch', 'vacuum', 'cover', 'media_player'):
|
||||
self.assertIn(domain, catalogue)
|
||||
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'}}]})
|
||||
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')):
|
||||
with self.assertRaises(ValueError):
|
||||
validate_layout({'title': 'T', 'tiles': [{'entity': entity, 'options': {'size': 'wide', 'controls': choice}}]})
|
||||
|
||||
def test_wide_cards_get_the_default_set_only_in_the_standard_layout(self):
|
||||
self.assertEqual(resolve_controls({'entity': 'climate.a', 'options': {'size': 'wide'}}), 'setpoint')
|
||||
self.assertEqual(resolve_controls({'entity': 'media_player.a', 'options': {'size': 'wide', 'controls': 'playback'}}), 'playback')
|
||||
self.assertIsNone(resolve_controls({'entity': 'media_player.a', 'options': {'size': 'wide', 'controls': 'none'}}))
|
||||
self.assertIsNone(resolve_controls({'entity': 'climate.a'}), 'single cards stay as they are')
|
||||
self.assertIsNone(resolve_controls({'entity': 'climate.a', 'options': {'size': 'wide', 'display': 'watch'}}))
|
||||
self.assertIsNone(resolve_controls({'entity': 'light.a', 'options': {'size': 'wide', 'inline': 'slider'}}))
|
||||
self.assertIsNone(resolve_controls({'entity': 'sensor.a', 'options': {'size': 'wide'}}))
|
||||
|
||||
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}}}
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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')
|
||||
|
||||
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'}}
|
||||
self.assertEqual(screen_options(tile, {}), {'size': 'wide', 'controls': 'toggle'})
|
||||
self.assertEqual(validate_layout({'title': 'T', 'tiles': [tile]})['tiles'][0]['options'], tile['options'])
|
||||
|
||||
def test_control_glyphs_exist_in_the_icon_fonts_and_the_editor_font(self):
|
||||
header = (ROOT / 'components/smart_display/tile_controls.h').read_text()
|
||||
used = {code.upper() for code in re.findall(r'\\U000(F[0-9A-F]{4})', header)}
|
||||
carried = {code for code in tile_icons.GLYPHS.values()}
|
||||
self.assertTrue(used <= carried, used - carried)
|
||||
self.assertTrue(set(tile_icons.CONTROL_GLYPHS) <= set(tile_icons.GLYPHS))
|
||||
self.assertEqual(set(tile_icons.editor()['controls']), set(tile_icons.CONTROL_GLYPHS))
|
||||
for name in ('guition-4848s040.yaml', 'home-like-2432s028.yaml', 'packages/guition.yaml', 'packages/cyd.yaml'):
|
||||
text = (ROOT / name).read_text()
|
||||
for code in used:
|
||||
self.assertIn(f'"\\U000{code}"', text, (name, code))
|
||||
self.assertIn('runtime_tiles::control_font = id(sublabel_big)->get_lv_font();', text, name)
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
class ControlInventory(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_inventory_lists_the_catalogue_and_a_saved_choice_survives_an_old_editor(self):
|
||||
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'}}]})
|
||||
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'})
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
from server import create_app
|
||||
async with TestClient(TestServer(create_app(m, development=True))) as client:
|
||||
data = await (await client.get('/api/inventory')).json()
|
||||
self.assertEqual(data['controls']['cover']['default'], 'buttons')
|
||||
self.assertIn('controls', data['icons'])
|
||||
light = await (await client.get('/api/inventory?light=1')).json()
|
||||
self.assertNotIn('controls', light)
|
||||
Reference in New Issue
Block a user