d76461d774
The two board profiles (5,000 lines each, 87 % the same text) become packages/core.yaml and one file per board under packages/boards/: the hardware, a table of 84 sizes and font sizes, 18 hooks with the lines of C++ only that board adds inside a shared lambda, and the board's own sections. packages/cyd.yaml and packages/guition.yaml keep their names and places (a screen installed from ESP Screens builds as before); home-like-2432s028.yaml and guition-4848s040.yaml become the same entries for a checkout. The firmware stays 0.2.70: the C++ ESPHome generates for each board, through the checkout profile and through the GitHub package route, was compared line by line with the old profiles' (docs/TEST_RESULTS_0284.md). tools/check_packages.py replaces tools/generate_packages.py; tools/profiles.py gives the tests and tools the three files as one; docs/PROFILES.md says how the files fit together and how a board is added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
147 lines
9.4 KiB
Python
147 lines
9.4 KiB
Python
"""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 / 'tools'))
|
|
import profiles # noqa: E402
|
|
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: profiles.text(name) 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')]
|
|
# Since firmware 0.2.58 tile_controls::tap_route decides and event() carries the route out.
|
|
controls = (ROOT / 'components/smart_display/tile_controls.h').read_text()
|
|
cards = controls[controls.index('inline bool runtime_card_domain('):]
|
|
cards = cards[:cards.index('\n}\n')]
|
|
self.assertIn('d == "cover"', cards, 'covers go to show_detail with the other runtime cards')
|
|
self.assertIn('if (runtime_card_domain(d)) return {TapRoute::CARD, "", true};', controls)
|
|
self.assertIn('return d == "light" || d == "climate" || d == "fan" ? Tap{TapRoute::OVERLAY', controls, 'not to the board\'s value overlay any more')
|
|
self.assertIn('case tile_controls::TapRoute::CARD:', event)
|
|
self.assertIn('show_detail(w.index);', event)
|
|
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()
|