Files
homeassistant_espscreen/diagnostics/send_layout.py
T
Max Gramser 2667f41f6f 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>
2026-09-13 15:02:15 +02:00

159 lines
12 KiB
Python

"""Push a demo layout with every card type straight into a screen's inbox.
Uses the same message builder as ESP Screen Manager, but with synthetic states,
so rendering can be checked without touching Home Assistant. The running
manager restores the real layout on its next keepalive (about two minutes).
"""
import argparse
import asyncio
from datetime import datetime, timedelta, timezone
from pathlib import Path
import sys
import time
import yaml
from aioesphomeapi import APIClient
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'screen_manager/app'))
from core import extras, packets, state_message, validate_layout # noqa: E402
def demo_layout():
return validate_layout({'title': 'Demo kaarten', 'tiles': [
{'entity': 'screen.clock', 'name': '', 'options': {'display': 'analog', 'background': 'none'}},
{'entity': 'weather.demo', 'name': 'Buiten', 'options': {'display': 'forecast'}},
{'entity': 'sensor.demo_temperatuur', 'name': 'Woonkamer', 'options': {'display': 'graph', 'icon': 'thermometer'}},
{'entity': 'person.demo', 'name': 'Max', 'options': {}},
{'entity': 'timer.demo', 'name': 'Eieren', 'options': {'icon': 'chef-hat'}},
{'entity': 'sun.sun', 'name': 'Zon', 'options': {'display': 'sunpath'}},
{'entity': 'sensor.demo_energie', 'name': 'Verbruik vandaag', 'options': {'display': 'graph', 'size': 'wide'}},
{'entity': 'light.demo', 'name': 'Tafellamp', 'options': {'display': 'watch', 'size': 'wide', 'icon': 'lamp'}},
{'entity': 'weather.demo_standaard', 'name': 'Weer nu', 'options': {}},
]})
# Direct controls on wide cards (firmware 0.2.19+): one card per control set.
def controls_layout():
return validate_layout({'title': 'Directe bediening', 'tiles': [
{'entity': 'cover.demo_gordijn', 'name': 'Gordijnen Raambekleding', 'options': {'size': 'wide', 'icon': 'curtains'}},
{'entity': 'climate.demo_airco', 'name': 'Airco', 'options': {'size': 'wide'}},
{'entity': 'media_player.demo_sonos', 'name': 'Sonos', 'options': {'size': 'wide', 'icon': 'speaker'}},
{'entity': 'climate.demo_cv', 'name': 'Verwarming', 'options': {'size': 'wide', 'controls': 'mode', 'icon': 'radiator'}},
{'entity': 'media_player.demo_radio', 'name': 'Sonos slaapkamer', 'options': {'size': 'wide', 'controls': 'playback', 'icon': 'radio'}},
{'entity': 'vacuum.demo_robot', 'name': 'Pippa', 'options': {'size': 'wide'}},
{'entity': 'switch.demo_bureau', 'name': 'Bureau', 'options': {'size': 'wide', 'background': 'blue', 'icon': 'power-socket-eu'}},
{'entity': 'light.demo_tafellamp', 'name': 'Tafellamp', 'options': {'size': 'wide', 'controls': 'brightness', 'icon': 'lamp'}},
{'entity': 'number.demo_doel', 'name': 'Doel luchtvochtigheid', 'options': {'size': 'wide', 'icon': 'water-percent'}},
{'entity': 'select.demo_stand', 'name': 'Verwarmingsstand', 'options': {'size': 'wide', 'icon': 'thermostat'}},
{'entity': 'timer.demo_eieren', 'name': 'Eieren', 'options': {'size': 'wide', 'icon': 'chef-hat'}},
{'entity': 'scene.demo_avond', 'name': 'Avond', 'options': {'size': 'wide', 'icon': 'sofa'}},
{'entity': 'fan.demo_ventilator', 'name': 'Ventilator', 'options': {'size': 'wide'}},
{'entity': 'cover.demo_rolluik', 'name': 'Rolluik', 'options': {'size': 'wide', 'controls': 'position', 'icon': 'window-shutter'}},
{'entity': 'light.demo_plafond', 'name': 'Plafondlamp', 'options': {'size': 'wide', 'icon': 'ceiling-light'}},
{'entity': 'weather.demo_buiten', 'name': 'Buiten', 'options': {'display': 'forecast'}},
{'entity': 'script.demo_tv', 'name': 'TV aanzetten', 'options': {'icon': 'television'}},
{'entity': 'scene.demo_ochtend', 'name': 'Ochtend', 'options': {'icon': 'weather-sunset-up'}},
]})
def controls_states(now):
end = now + timedelta(minutes=4, seconds=32)
return {
'cover.demo_gordijn': {'state': 'open', 'attributes': {'current_position': 80, 'device_class': 'curtain', 'supported_features': 15}},
'climate.demo_airco': {'state': 'cool', 'attributes': {'current_temperature': 21.5, 'temperature': 20, 'min_temp': 16, 'max_temp': 32, 'target_temp_step': 1.0, 'hvac_modes': ['off', 'heat_cool', 'cool', 'heat', 'fan_only', 'dry'], 'fan_modes': ['auto', 'low', 'medium', 'high'], 'fan_mode': 'low', 'swing_modes': ['off', 'both', 'vertical', 'horizontal'], 'swing_mode': 'off', 'supported_features': 425}},
'media_player.demo_sonos': {'state': 'playing', 'attributes': {'volume_level': 0.17, 'is_volume_muted': False, 'media_title': 'TV', 'supported_features': 8321599}},
'climate.demo_cv': {'state': 'heat', 'attributes': {'current_temperature': 19.5, 'temperature': 21, 'hvac_action': 'heating', 'hvac_modes': ['off', 'heat', 'auto']}},
'media_player.demo_radio': {'state': 'playing', 'attributes': {'volume_level': 0.15, 'media_title': 'NPO Radio 2', 'supported_features': 8321599}},
'vacuum.demo_robot': {'state': 'docked', 'attributes': {'battery_level': 100, 'fan_speed': 'max', 'fan_speed_list': ['quiet', 'balanced', 'turbo', 'max'], 'supported_features': 30524}},
'switch.demo_bureau': {'state': 'on', 'attributes': {}},
'light.demo_tafellamp': {'state': 'on', 'attributes': {'brightness': 163}},
'number.demo_doel': {'state': '55', 'attributes': {'min': 30, 'max': 70, 'step': 5, 'unit_of_measurement': '%'}},
'select.demo_stand': {'state': 'Comfort', 'attributes': {'options': ['Eco', 'Comfort', 'Boost']}},
'timer.demo_eieren': {'state': 'active', 'attributes': {'finishes_at': end.isoformat(), 'duration': '0:05:00', 'remaining': '0:05:00'}},
'scene.demo_avond': {'state': (now - timedelta(hours=3)).isoformat(), 'attributes': {}},
'fan.demo_ventilator': {'state': 'off', 'attributes': {'percentage': 0}},
'cover.demo_rolluik': {'state': 'open', 'attributes': {'current_position': 35, 'supported_features': 15}},
'light.demo_plafond': {'state': 'off', 'attributes': {}},
'weather.demo_buiten': {'state': 'rainy', 'attributes': {'temperature': 18.4, 'temperature_unit': '°C', 'humidity': 92, 'wind_speed': 12.2, 'wind_speed_unit': 'km/h', 'apparent_temperature': 17.1}},
'script.demo_tv': {'state': 'off', 'attributes': {'last_triggered': (now - timedelta(hours=2, minutes=8)).isoformat()}},
'scene.demo_ochtend': {'state': (now - timedelta(days=1, hours=5)).isoformat(), 'attributes': {}},
}
def demo_states(now):
end = now + timedelta(minutes=4, seconds=32)
return {
'weather.demo': {'state': 'partlycloudy', 'attributes': {'temperature': 18.4, 'temperature_unit': '°C'}},
'weather.demo_standaard': {'state': 'rainy', 'attributes': {'temperature': 12.0, 'temperature_unit': '°C'}},
'sensor.demo_temperatuur': {'state': '21.5', 'attributes': {'unit_of_measurement': '°C'}},
'sensor.demo_energie': {'state': '7.42', 'attributes': {'unit_of_measurement': 'kWh'}},
'person.demo': {'state': 'home', 'attributes': {'icon': 'mdi:account-child'}},
'timer.demo': {'state': 'active', 'attributes': {'finishes_at': end.isoformat(), 'duration': '0:05:00', 'remaining': '0:05:00'}},
'sun.sun': {'state': 'above_horizon', 'attributes': {'next_rising': (now + timedelta(hours=9)).isoformat(), 'next_setting': (now + timedelta(hours=2)).isoformat()}},
'light.demo': {'state': 'on', 'attributes': {'brightness': 180}},
}
def demo_forecast(now):
conditions = ['sunny', 'partlycloudy', 'rainy', 'cloudy', 'lightning-rainy', 'snowy']
return [{'datetime': (now + timedelta(days=i)).isoformat(), 'condition': conditions[i], 'temperature': 21 - i, 'templow': 11 + i,
'precipitation': [0, 0, 4.2, 0.3, 11.5, 2][i], 'precipitation_probability': [5, 20, 80, 30, 95, 60][i]}
for i in range(6)]
def demo_hourly(now):
conditions = ['rainy', 'rainy', 'partlycloudy', 'partlycloudy', 'sunny', 'sunny', 'cloudy', 'lightning-rainy', 'rainy', 'cloudy']
start = now.replace(minute=0, second=0, microsecond=0)
return [{'datetime': (start + timedelta(hours=i)).isoformat(), 'condition': conditions[i], 'temperature': 18.4 + i * 0.6,
'precipitation': [0.4, 0.2, 0, 0, 0, 0, 0, 2.1, 1.0, 0][i], 'precipitation_probability': [70, 55, 10, 5, 0, 0, 15, 85, 60, 20][i]}
for i in range(10)]
def messages(inbox, rotate=0, digital=False, wide=False, controls=False):
now = datetime.now(timezone.utc)
layout, states = (controls_layout(), controls_states(now)) if controls else (demo_layout(), demo_states(now))
if digital:
layout['tiles'][0]['options'] = {'display': 'digital'}
elif wide:
layout['tiles'][0]['options'] = {'display': 'analog', 'size': 'wide'}
layout['tiles'] = layout['tiles'][rotate:] + layout['tiles'][:rotate]
out = [{'v': 1, 'op': 'layout', 'inbox': inbox, 'title': layout['title'], 'entities': [t['entity'] for t in layout['tiles']]}]
for i, tile in enumerate(layout['tiles']):
forecast = demo_forecast(now) if tile['entity'].startswith('weather.') else None
hourly = demo_hourly(now) if tile['entity'].startswith('weather.') else None
message = state_message(i, tile, states, extras(tile, states, forecast, None, hourly, now))
if tile['entity'].startswith('sensor.'):
message['history'] = {'hours': 24, 'values': [round(18 + 4 * ((k * 7) % 11) / 10, 2) if k % 5 else None for k in range(24)]}
out.append(message)
return out
async def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--host', required=True)
p.add_argument('--name', required=True, help='Verwachte DEVICE_NAME')
p.add_argument('--secrets', type=Path, default=Path(__file__).resolve().parents[1] / 'secrets.yaml')
p.add_argument('--rotate', type=int, default=0, help='Zet tegel N vooraan, zodat latere pagina\'s op pagina 1 komen')
p.add_argument('--digital', action='store_true', help='Digitale klok op een enkele tegel in plaats van de analoge kalenderkaart zonder achtergrond')
p.add_argument('--wide', action='store_true', help='Analoge klok dubbelbreed (wijzerplaat met digitale tijd en datum)')
p.add_argument('--controls', action='store_true', help='Dubbelbrede kaarten met directe bediening (firmware 0.2.19+), drie per pagina')
args = p.parse_args()
client = APIClient(args.host, 6053, noise_psk=yaml.safe_load(args.secrets.read_text())['api_encryption_key'],
client_info='Demo layout', expected_name=args.name)
await client.connect(login=True)
try:
entities, _ = await client.list_entities_services()
inbox = next(e for e in entities if type(e).__name__ == 'TextInfo' and e.name == 'Tegelinstellingen')
# The firmware answers through the inbox entity's state, not the log.
replies = []
def state(update):
if getattr(update, 'key', None) == inbox.key and getattr(update, 'state', None):
replies.append(update.state)
client.subscribe_states(state)
started = time.monotonic()
count = len(controls_layout()['tiles']) if args.controls else 9
for message in messages(inbox.object_id if hasattr(inbox, 'object_id') else 'text.inbox', args.rotate % count, args.digital, args.wide, args.controls):
for packet in packets(message):
client.text_command(inbox.key, packet)
await asyncio.sleep(0.05)
await asyncio.sleep(1.5)
print(f'Demo-indeling verstuurd in {time.monotonic() - started:.1f}s; inbox meldt: {" / ".join(dict.fromkeys(replies)) or "(geen statuswijziging)"}')
if not any(r in ('Indeling ontvangen', 'Gesynchroniseerd', 'Tegels laden') for r in replies):
raise SystemExit('De firmware accepteerde de demo-indeling niet.')
finally:
await client.disconnect()
if __name__ == '__main__':
asyncio.run(main())