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

99 lines
5.6 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 25 seconds).
"""
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', 'size': 'wide'}},
{'entity': 'weather.demo', 'name': 'Buiten', 'options': {'display': 'forecast'}},
{'entity': 'sensor.demo_temperatuur', 'name': 'Woonkamer', 'options': {'display': 'graph'}},
{'entity': 'person.demo', 'name': 'Max', 'options': {}},
{'entity': 'timer.demo', 'name': 'Eieren', 'options': {}},
{'entity': 'sun.sun', 'name': 'Zon', 'options': {}},
{'entity': 'sensor.demo_energie', 'name': 'Verbruik vandaag', 'options': {'display': 'graph', 'size': 'wide'}},
{'entity': 'light.demo', 'name': 'Tafellamp', 'options': {'display': 'watch', 'size': 'wide'}},
{'entity': 'weather.demo_standaard', 'name': 'Weer nu', 'options': {}},
]})
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': {}},
'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}
for i in range(6)]
def messages(inbox, rotate=0, digital=False):
now = datetime.now(timezone.utc)
layout, states = demo_layout(), demo_states(now)
if digital:
layout['tiles'][0]['options'] = {'display': 'digital'}
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
message = state_message(i, tile, states, extras(tile, states, forecast, None))
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 analoog dubbelbreed')
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()
for message in messages(inbox.object_id if hasattr(inbox, 'object_id') else 'text.inbox', args.rotate % 9, args.digital):
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())