3e959b3df9
Everything a user sees or reads is now English: both boards' labels, status replies and logs, the ESP Screens editor and Alerts cheatsheet, and the README and guides (docs/TILES.md, CALIBRATING.md, ACCEPTANCE.md and TROUBLESHOOTING.md replace the Dutch file names). Labels follow Home Assistant's own English names (Heat/Cool modes, Heating/Cooling actions, Away, Returning to dock, weather conditions), dates use English abbreviations and the top bar writes numbers the English way. Docs use HA 2026.9's menu names (Settings -> Apps, App store, Settings -> Tools). Unused legacy files (home-like.yaml, buttons.yaml, a.out) are removed. Firmware 0.2.34 names its entities in English. Home Assistant registers a renamed ESPHome entity under a new entity ID, so the inbox text.<screen>_tegelinstellingen becomes text.<screen>_tile_settings and the layout stored under the old ID would be lost. The manager now recognises the screen by its device, during a run or after a restart, and moves its layout and update history along; the Update button and the nightly round follow the screen through the rename. Screens that still run 0.2.33 or older keep working: both languages are matched. Main is the only release branch from now on. Tested on a Home Assistant Yellow (HA 2026.9.1) with both bench boards: see docs/TEST_RESULTS_0240.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
162 lines
12 KiB
Python
162 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 cards', 'tiles': [
|
|
{'entity': 'screen.clock', 'name': '', 'options': {'display': 'analog', 'background': 'none'}},
|
|
{'entity': 'weather.demo', 'name': 'Outside', 'options': {'display': 'forecast'}},
|
|
{'entity': 'sensor.demo_temperature', 'name': 'Living room', 'options': {'display': 'graph', 'icon': 'thermometer'}},
|
|
{'entity': 'person.demo', 'name': 'Max', 'options': {}},
|
|
{'entity': 'timer.demo', 'name': 'Eggs', 'options': {'icon': 'chef-hat'}},
|
|
{'entity': 'sun.sun', 'name': 'Sun', 'options': {'display': 'sunpath'}},
|
|
{'entity': 'sensor.demo_energy', 'name': 'Usage today', 'options': {'display': 'graph', 'size': 'wide'}},
|
|
{'entity': 'light.demo', 'name': 'Table lamp', 'options': {'display': 'watch', 'size': 'wide', 'icon': 'lamp'}},
|
|
{'entity': 'weather.demo_default', 'name': 'Weather now', 'options': {}},
|
|
]})
|
|
|
|
# Direct controls on wide cards (firmware 0.2.19+): one card per control set.
|
|
def controls_layout():
|
|
return validate_layout({'title': 'Direct control', 'tiles': [
|
|
{'entity': 'cover.demo_curtain', 'name': 'Curtains Window covering', 'options': {'size': 'wide', 'icon': 'curtains'}},
|
|
{'entity': 'climate.demo_ac', 'name': 'AC', 'options': {'size': 'wide'}},
|
|
{'entity': 'media_player.demo_sonos', 'name': 'Sonos', 'options': {'size': 'wide', 'icon': 'speaker'}},
|
|
{'entity': 'climate.demo_heating', 'name': 'Heating', 'options': {'size': 'wide', 'controls': 'mode', 'icon': 'radiator'}},
|
|
{'entity': 'media_player.demo_radio', 'name': 'Sonos bedroom', 'options': {'size': 'wide', 'controls': 'playback', 'icon': 'radio'}},
|
|
{'entity': 'vacuum.demo_robot', 'name': 'Pippa', 'options': {'size': 'wide'}},
|
|
{'entity': 'switch.demo_desk', 'name': 'Desk', 'options': {'size': 'wide', 'background': 'blue', 'icon': 'power-socket-eu'}},
|
|
{'entity': 'light.demo_table_lamp', 'name': 'Table lamp', 'options': {'size': 'wide', 'controls': 'brightness', 'icon': 'lamp'}},
|
|
{'entity': 'number.demo_target', 'name': 'Target humidity', 'options': {'size': 'wide', 'icon': 'water-percent'}},
|
|
{'entity': 'select.demo_mode', 'name': 'Heating mode', 'options': {'size': 'wide', 'icon': 'thermostat'}},
|
|
{'entity': 'timer.demo_eggs', 'name': 'Eggs', 'options': {'size': 'wide', 'icon': 'chef-hat'}},
|
|
{'entity': 'scene.demo_evening', 'name': 'Evening', 'options': {'size': 'wide', 'icon': 'sofa'}},
|
|
{'entity': 'fan.demo_fan', 'name': 'Fan', 'options': {'size': 'wide'}},
|
|
{'entity': 'cover.demo_shutter', 'name': 'Shutter', 'options': {'size': 'wide', 'controls': 'position', 'icon': 'window-shutter'}},
|
|
{'entity': 'light.demo_ceiling', 'name': 'Ceiling light', 'options': {'size': 'wide', 'icon': 'ceiling-light'}},
|
|
{'entity': 'weather.demo_outside', 'name': 'Outside', 'options': {'display': 'forecast'}},
|
|
{'entity': 'script.demo_tv', 'name': 'Turn on TV', 'options': {'icon': 'television'}},
|
|
{'entity': 'scene.demo_morning', 'name': 'Morning', 'options': {'icon': 'weather-sunset-up'}},
|
|
]})
|
|
|
|
def controls_states(now):
|
|
end = now + timedelta(minutes=4, seconds=32)
|
|
return {
|
|
'cover.demo_curtain': {'state': 'open', 'attributes': {'current_position': 80, 'device_class': 'curtain', 'supported_features': 15}},
|
|
'climate.demo_ac': {'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_heating': {'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_desk': {'state': 'on', 'attributes': {}},
|
|
'light.demo_table_lamp': {'state': 'on', 'attributes': {'brightness': 163}},
|
|
'number.demo_target': {'state': '55', 'attributes': {'min': 30, 'max': 70, 'step': 5, 'unit_of_measurement': '%'}},
|
|
'select.demo_mode': {'state': 'Comfort', 'attributes': {'options': ['Eco', 'Comfort', 'Boost']}},
|
|
'timer.demo_eggs': {'state': 'active', 'attributes': {'finishes_at': end.isoformat(), 'duration': '0:05:00', 'remaining': '0:05:00'}},
|
|
'scene.demo_evening': {'state': (now - timedelta(hours=3)).isoformat(), 'attributes': {}},
|
|
'fan.demo_fan': {'state': 'off', 'attributes': {'percentage': 0}},
|
|
'cover.demo_shutter': {'state': 'open', 'attributes': {'current_position': 35, 'supported_features': 15}},
|
|
'light.demo_ceiling': {'state': 'off', 'attributes': {}},
|
|
'weather.demo_outside': {'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_morning': {'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_default': {'state': 'rainy', 'attributes': {'temperature': 12.0, 'temperature_unit': '°C'}},
|
|
'sensor.demo_temperature': {'state': '21.5', 'attributes': {'unit_of_measurement': '°C'}},
|
|
'sensor.demo_energy': {'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='Expected 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='Put tile N first, so later pages land on page 1')
|
|
p.add_argument('--digital', action='store_true', help='Digital clock on a single tile instead of the analog calendar card without a background')
|
|
p.add_argument('--wide', action='store_true', help='Analog clock double-width (dial with digital time and date)')
|
|
p.add_argument('--controls', action='store_true', help='Double-width cards with direct control (firmware 0.2.19+), three per page')
|
|
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()
|
|
# Firmware built before the English translation still names this entity in Dutch.
|
|
inbox = next(e for e in entities if type(e).__name__ == 'TextInfo' and e.name in ('Tile settings', '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 layout sent in {time.monotonic() - started:.1f}s; inbox reports: {" / ".join(dict.fromkeys(replies)) or "(no status change)"}')
|
|
# Firmware built before the English translation still reports the Dutch originals.
|
|
accepted = ('Layout received', 'Synced', 'Loading tiles', 'Indeling ontvangen', 'Gesynchroniseerd', 'Tegels laden')
|
|
if not any(r in accepted for r in replies):
|
|
raise SystemExit('The firmware did not accept the demo layout.')
|
|
finally:
|
|
await client.disconnect()
|
|
|
|
if __name__ == '__main__':
|
|
asyncio.run(main())
|