Add a card for blinds and curtains, and fix what Studio 1 showed after 0.2.57 (0.2.58)

A tap on a cover now opens a card like Home Assistant's own, in the style
of the climate and vacuum cards: a tall position slider on which the blind
hangs from the top, a tilt slider over slats for venetian blinds, and open,
stop and close keys. The key of the direction the cover moves is filled; a
key that cannot move it further is disabled and stays out of the keys the
card's tick enables. Sliders show their value while dragged and send it on
release. The card offers only what supported_features allows. A
battery-powered blind shows its battery at the top right: the app sends
the device's battery sensor as the extra the vacuum card uses, and adds
current_tilt_position to the attributes.

Checking Studio 1 after its update to firmware 0.2.49 found that a setting
number equal to its initial state 0 (night brightness 0 %) was never
published, so Home Assistant kept it unknown and ESP Screens could not
change it; apply_screen_settings now publishes a number without a state.
The app also took "unavailable" for the device name during the restart
and wrote sensor.esp_screens_unavailable; diagnostics ignore unknown and
unavailable. The editor redraws its tiles, top bar and entity list when
the live stream opened a screen before the first full inventory (a busy
add-on), and loads app.js and style.css by content stamp after Safari kept
an old script.

Checked with 254 Python and 14 C++ tests, generate_packages --check, ESP32
builds of both profiles, and 20 API checks of the card on host builds of
both boards. Firmware 0.2.50.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-16 16:41:10 +02:00
parent b58f0cde25
commit d6e5b35dcf
24 changed files with 696 additions and 69 deletions
+138
View File
@@ -0,0 +1,138 @@
"""The cover card (app 0.2.58 / firmware 0.2.50), and two fixes found on Studio 1 after updating to 0.2.57.
- A tap on a cover opens a card like Home Assistant's own: a position slider, a tilt slider for slats, open,
stop and close, and the battery of a battery-powered blind (Motionblinds keep it on a sensor of the device).
The layout and the drawing are checked on the Mac host builds; tests/test_tile_controls.cpp covers the logic.
- A number whose value equals its initial state (a night brightness of 0) was never published, so Home
Assistant showed "unknown" and ESP Screens could not change it.
- While a screen restarts its device name sensor reads "unavailable", which the app took for the device name.
"""
import importlib.util
import re
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
sys.path.insert(0, str(ROOT / 'tests'))
from core import ATTRS, cover_related, discover_screens, extras, state_message # noqa: E402
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
RUNTIME = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
PROFILES = {name: (ROOT / name).read_text() for name in ('guition-4848s040.yaml', 'home-like-2432s028.yaml', 'packages/guition.yaml', 'packages/cyd.yaml')}
BLIND = 'cover.venetianblind_0001'
def motionblinds():
"""(states, device registry entries) of a Motionblinds venetian blind as Home Assistant 2026.9.1 reports it in Dutch."""
states = {
BLIND: {'state': 'open', 'attributes': {'friendly_name': 'Blind Links', 'current_position': 60, 'current_tilt_position': 40,
'device_class': 'blind', 'supported_features': 255}},
'sensor.venetianblind_0001_batterij': {'state': '85', 'attributes': {'device_class': 'battery', 'unit_of_measurement': '%'}},
'sensor.venetianblind_0001_signaalsterkte': {'state': '-60', 'attributes': {}},
}
device = [{'entity_id': eid, 'platform': 'motion_blinds', 'device_id': 'blind'} for eid in states]
return states, device
class CoverMessages(unittest.TestCase):
def test_the_card_gets_the_tilt_and_the_battery_of_the_device(self):
states, device = motionblinds()
self.assertIn('current_tilt_position', ATTRS)
extra = extras({'entity': BLIND}, states, device=device)
self.assertEqual(extra, {'bat': 85})
self.assertEqual(cover_related(BLIND, device, states), {'battery': 'sensor.venetianblind_0001_batterij'})
message = state_message(0, {'entity': BLIND, 'name': ''}, states, extra)
self.assertEqual((message['a']['current_position'], message['a']['current_tilt_position'], message['a']['supported_features']), (60, 40, 255))
self.assertEqual(message['x'], {'bat': 85})
# No battery sensor, or a cover that reports battery_level itself: nothing extra.
self.assertIsNone(extras({'entity': BLIND}, states, device=device[:1]))
states[BLIND]['attributes']['battery_level'] = 90
self.assertIsNone(extras({'entity': BLIND}, states, device=device))
states['sensor.venetianblind_0001_batterij']['state'] = 'unavailable'
del states[BLIND]['attributes']['battery_level']
self.assertIsNone(extras({'entity': BLIND}, states, device=device))
def test_a_restarting_screen_is_not_named_unavailable(self):
registry = [{'entity_id': 'text.studio_1_tile_settings', 'platform': 'esphome', 'original_name': 'Tile settings', 'device_id': 'd1'},
{'entity_id': 'sensor.studio_1_device_name', 'platform': 'esphome', 'original_name': 'Device name', 'device_id': 'd1'},
{'entity_id': 'sensor.studio_1_ip_address', 'platform': 'esphome', 'original_name': 'IP address', 'device_id': 'd1'}]
devices = [{'id': 'd1', 'name': 'Studio 1'}]
for state in ('unavailable', 'unknown'):
states = {'text.studio_1_tile_settings': {'state': 'unavailable'}, 'sensor.studio_1_device_name': {'state': state},
'sensor.studio_1_ip_address': {'state': 'unavailable'}}
screen = discover_screens(registry, states, devices, [])[0]
self.assertEqual((screen['node'], screen['ip']), (None, None), state)
states['sensor.studio_1_device_name']['state'] = 'studio-1'
self.assertEqual(discover_screens(registry, states, devices, [])[0]['node'], 'studio-1')
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
class CoverSync(unittest.IsolatedAsyncioTestCase):
async def test_a_battery_change_reaches_the_card_and_restarts_publish_no_second_sensor(self):
import test_scaling
from server import Manager
states, device = motionblinds()
with tempfile.TemporaryDirectory() as tmp:
ha = test_scaling.fake_ha(firmware='0.2.50')
ha.registry = ha.registry + [{**item} for item in device]
ha.states.update(states)
m = Manager(ha, Path(tmp) / 'screens.json')
m.save('text.screen', {'title': 'Office 1', 'tiles': [{'entity': BLIND, 'name': ''}]})
self.assertIn('sensor.venetianblind_0001_batterij', m.watched_entities(), 'a battery change wakes the sync')
self.assertEqual(m.related_entities({'entity': BLIND}), ('sensor.venetianblind_0001_batterij',))
message = await m.tile_message(0, m.layouts['text.screen']['tiles'][0])
self.assertEqual(message['x'], {'bat': 85})
# The screen restarts: its device name reads unavailable, and no layout sensor goes out under that name.
published = []
async def set_state(entity_id, state, attributes):
published.append(entity_id)
ha.set_state = set_state
ha.states['text.node'] = {'state': 'unavailable'}
await m.publish_layouts()
self.assertEqual(published, [])
ha.states['text.node'] = {'state': 'office-1'}
await m.publish_layouts()
self.assertEqual(published, ['sensor.esp_screens_office_1'])
class Firmware(unittest.TestCase):
def test_a_tap_on_a_cover_opens_the_cover_card(self):
event = RUNTIME[RUNTIME.index('inline void event(lv_event_t *event) {'):]
event = event[:event.index('\n}\n')]
show = re.search(r'if\(d=="sensor" \|\|.*?show_detail\(w\.index\); return; \}', event)
self.assertTrue(show and 'd=="cover"' in show[0], 'covers go to show_detail with the other runtime cards')
self.assertIn('if (d == "light" || d == "climate" || d == "vacuum" || d == "fan") {', event, 'not to the board\'s value overlay any more')
self.assertIn('}else if(d=="cover"){', RUNTIME)
self.assertIn('render_cover_detail(t,large,width,height,pad);', RUNTIME)
for name, text in PROFILES.items():
self.assertIn('if ((domain == "light" || domain == "fan" || domain == "climate") && runtime_tiles::detail) {', text, f'{name}: the preview opens the new card')
def test_the_card_commits_on_release_and_keeps_its_status_line(self):
self.assertIn('action("cover.set_cover_position",t.entity,"position",std::to_string(100-percent));', RUNTIME)
self.assertIn('action("cover.set_cover_tilt_position",t.entity,"tilt_position",std::to_string(percent));', RUNTIME)
self.assertIn('if(cmd>=70 && cmd<130){auto a=tile_controls::key_action(t,cmd-70);', RUNTIME)
# The once-a-second tick of an open card must not replace "Open · 60% · Tilt 40%" with the raw state.
tick = RUNTIME[RUNTIME.index('inline void tick() {'):]
self.assertIn('t.domain()=="cover"?cover_status_line(t):detail_state(t)', tick[:tick.index('if(!enabled)return;')])
# A key the cover cannot use stays disabled: it is left out of the keys the tick enables again.
self.assertIn('if(key.disabled){lv_obj_add_state(button,LV_STATE_DISABLED);if(detail_action_count && detail_actions[detail_action_count-1]==button)--detail_action_count;}', RUNTIME)
# Track and fill share one radius: no layer per redraw on the CYD.
self.assertIn('lv_obj_set_style_radius(slider,radius,LV_PART_MAIN);lv_obj_set_style_radius(slider,radius,LV_PART_INDICATOR);', RUNTIME)
self.assertIn('next.tilt = number(a["current_tilt_position"]);', RUNTIME)
def test_every_setting_number_publishes_a_value_home_assistant_lacks(self):
for name, text in PROFILES.items():
script = text[text.index('- id: apply_screen_settings'):]
script = script[:script.index('\n - id: ', 10)]
self.assertIn('if (!entity->has_state() || entity->state != value) entity->publish_state(value);', script, name)
for number in ('setting_brightness', 'setting_standby_brightness', 'setting_night_brightness', 'setting_standby_seconds', 'setting_auto_home_seconds'):
self.assertIn(f'std::make_pair(id({number}),', script, f'{name}: {number}')
self.assertNotRegex(script, r'if \(id\(setting_\w+\)\.state != ', name)
if __name__ == '__main__':
unittest.main()
+38
View File
@@ -0,0 +1,38 @@
"""Opening ESP Screens while the add-on is busy (app 0.2.58).
The page asks for the full inventory and opens the live stream at the same time. An add-on that is building
firmware answers the stream first, whose light payload has screens but no entities, so the first screen was
opened with an empty catalogue: the entity list said "No entities found" and the tiles showed entity ids until
a filter chip was pressed. The first full inventory now draws those parts again.
"""
import re
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = (ROOT / 'screen_manager/app/static/app.js').read_text()
def function(name):
start = SCRIPT.index(f'function {name}(')
return SCRIPT[start:SCRIPT.index('\n}\n', start)]
class Startup(unittest.TestCase):
def test_the_live_stream_can_open_a_screen_before_the_catalogue(self):
# The race exists: the light payload selects the first screen, and it carries no entities.
self.assertIn('if (!selected && inventory.screens.length) select(inventory.screens[0].id);', function('applyLive'))
self.assertRegex(function('select'), r'renderTopbar\(\);\s+renderTiles\(\);\s+renderResults\(\);')
def test_the_first_full_inventory_draws_what_needs_entities_again(self):
body = function('refresh')
first = body.index('const firstCatalogue = full && !inventory.entities?.length;')
self.assertLess(first, body.index('inventory = full ? data'), 'decided before the catalogue is replaced')
redraw = re.search(r'if \(firstCatalogue && selected && layout && !drag\.active && !chipDrag\.active\) '
r'\{ renderTopbar\(\); renderTiles\(\); renderResults\(\); \}', body)
self.assertTrue(redraw, 'tiles, top bar and entity list are drawn again, never in the middle of a drag')
self.assertLess(redraw.start(), body.index('if (!selected && inventory.screens.length) select('))
if __name__ == '__main__':
unittest.main()
+43
View File
@@ -0,0 +1,43 @@
"""The page loads its own script and styles by content stamp (app 0.2.58).
After updating to 0.2.57, Safari showed an empty Screen settings panel: it kept a cached script from before
the update. The page now asks for app.js and style.css with a stamp of their content, so a new version is
always fetched and an old one is never combined with a new page.
"""
import hashlib
import importlib.util
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
STATIC = ROOT / 'screen_manager/app/static'
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
class Assets(unittest.IsolatedAsyncioTestCase):
async def test_script_and_styles_carry_a_stamp_of_their_content(self):
import sys
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
sys.path.insert(0, str(ROOT / 'tests'))
import test_scaling
from aiohttp.test_utils import TestClient, TestServer
from server import Manager, create_app
stamp = hashlib.sha1((STATIC / 'app.js').read_bytes() + (STATIC / 'style.css').read_bytes()).hexdigest()[:12]
with tempfile.TemporaryDirectory() as tmp:
manager = Manager(test_scaling.fake_ha(), Path(tmp) / 'screens.json')
async with TestClient(TestServer(create_app(manager, True))) as client:
response = await client.get('/')
page = await response.text()
self.assertEqual(response.status, 200)
self.assertIn(f'<script src="static/app.js?v={stamp}" defer></script>', page)
self.assertIn(f'<link rel="stylesheet" href="static/style.css?v={stamp}" />', page)
self.assertEqual(response.headers['Cache-Control'], 'no-store')
self.assertIn('text/html', response.headers['Content-Type'])
script = await client.get(f'/static/app.js?v={stamp}')
self.assertEqual((script.status, await script.read()), (200, (STATIC / 'app.js').read_bytes()))
if __name__ == '__main__':
unittest.main()
+5 -2
View File
@@ -106,7 +106,8 @@ def fake_ha(firmware=FIRMWARE_VERSION, owned=True, guition=True):
class CoreSettings(unittest.TestCase):
def test_every_setting_the_editor_offers_has_an_entity(self):
self.assertEqual(set(SETTING_ENTITIES), set(SETTING_RULES) - {'show_clock'})
self.assertEqual(SETTING_ENTITIES_MIN_FIRMWARE, FIRMWARE_VERSION)
self.assertEqual(SETTING_ENTITIES_MIN_FIRMWARE, '0.2.49')
self.assertLessEqual(tuple(map(int, SETTING_ENTITIES_MIN_FIRMWARE.split('.'))), tuple(map(int, FIRMWARE_VERSION.split('.'))))
def test_the_entities_are_named_as_in_both_board_profiles(self):
blocks = {'switch': 'switch', 'number': 'number', 'time': 'datetime', 'select': 'select'}
@@ -581,7 +582,9 @@ class Firmware(unittest.TestCase):
script = script[:script.index('\n - id: ', 10)]
for entity in ('setting_brightness', 'setting_standby_brightness', 'setting_night_brightness', 'setting_standby_seconds',
'setting_auto_home_seconds'):
self.assertIn(f'id({entity}).publish_state(', script, f'{path.name}: {entity}')
# One loop publishes every number that lacks a state or changed (firmware 0.2.50, see test_cover_card).
self.assertIn(f'std::make_pair(id({entity}),', script, f'{path.name}: {entity}')
self.assertIn('if (!entity->has_state() || entity->state != value) entity->publish_state(value);', script, path.name)
self.assertIn('std::make_pair(id(setting_night_start), settings.night_start)', script, path.name)
self.assertIn('settings_screen::refresh();', script, f'{path.name}: an open settings page follows Home Assistant')
self.assertEqual('id(setting_rotation)->update();' in script, board == 'guition', path.name)
+32
View File
@@ -36,6 +36,38 @@ int main() {
assert(status_text(curtain) == "Open · 100%");
assert(status_text(shutter) == "Closed");
// The cover card (firmware 0.2.50+). Motionblinds venetian blinds report every feature (255).
Tile blind = make("cover.venetianblind_0001", "open", 255); blind.device_class = "blind"; blind.position = 60; blind.edit_extra().tilt = 40;
CoverCard card = cover_card(blind);
assert(card.position && card.tilt && card.keys && !card.tilt_keys);
assert(cover_card_status(blind) == "Open · 60% · Tilt 40%");
assert(cover_keys(blind, keys) == 3 && !strcmp(keys[0].icon, glyph::UP) && !keys[0].disabled && !keys[2].disabled && !keys[2].checked);
blind.state = "closing";
assert(cover_keys(blind, keys) == 3 && keys[2].checked && !keys[0].checked);
assert(cover_tilt_keys(blind, keys) == 3 && keys[0].command == COVER_OPEN_TILT && keys[2].command == COVER_CLOSE_TILT);
assert(key_action(blind, COVER_OPEN_TILT).service == "cover.open_cover_tilt");
assert(key_action(blind, COVER_STOP_TILT).service == "cover.stop_cover_tilt" && key_action(blind, COVER_CLOSE_TILT).service == "cover.close_cover_tilt");
CoverCard curtain_card = cover_card(curtain);
assert(curtain_card.position && !curtain_card.tilt && curtain_card.keys && !curtain_card.tilt_keys);
assert(cover_card_status(curtain) == "Open · 100%");
// A garage door: open, stop and close only; close is disabled while it is closed.
Tile garage = make("cover.garage", "closed", 11); garage.device_class = "garage";
CoverCard garage_card = cover_card(garage);
assert(!garage_card.position && !garage_card.tilt && garage_card.keys && !garage_card.tilt_keys);
assert(cover_keys(garage, keys) == 3 && keys[2].disabled && !keys[0].disabled);
// Slats that tilt without a position get tilt keys; a key that cannot tilt further is disabled.
Tile slats = make("cover.slats", "open", 3 | 16 | 32); slats.edit_extra().tilt = 100;
CoverCard slats_card = cover_card(slats);
assert(slats_card.tilt_keys && !slats_card.tilt && !slats_card.position);
assert(cover_tilt_keys(slats, keys) == 2 && keys[0].disabled && !keys[1].disabled);
assert(cover_card_status(slats) == "Open");
// Features not reported yet: the card offers the three keys, a tile's key row stays empty as before.
Tile unknown = make("cover.new", "unknown", 0);
CoverCard unknown_card = cover_card(unknown);
assert(unknown_card.keys && !unknown_card.position && cover_keys(unknown, keys) == 3);
unknown.controls = "buttons";
assert(keys_for(unknown, keys) == 0);
// Vacuum: play while docked, pause while cleaning, dock disabled in the dock.
Tile robot = make("vacuum.s8", "docked", 30524); robot.controls = "buttons";
assert(keys_for(robot, keys) == 3);