Choose vacuum, vac & mop or mop on the vacuum card, and a climate card for the CYD (0.2.46)

App 0.2.46 / firmware 0.2.39:
- The vacuum's state message carries extras from its own device: the cleaning mode and water
  selects (found by translation key, then entity id ending, so localized ids work), the suction
  speeds to offer, the battery sensor (HA 2026.8 dropped battery_level), charging and the current
  room. Speeds and water levels a mode covers move into the mode row; Custom only while in use.
  The manager watches these entities and resends only the vacuum's message when one changes.
- Vacuum card redesign on both boards: robot with state, battery, charging bolt and room; Start
  (Pause, Resume) and Dock; then segmented rows for mode, suction and water, showing only the rows
  the chosen mode uses. Taps show at once and wait for Home Assistant; Locate is a round button in
  the Guition hero.
- CYD climate card without the arc: big setpoint between large -/+ keys (debounced, hold to
  repeat) and one row of mode keys in HA's order and colours, with a more key for fan and swing.
- Both profiles clear climate_edit_pending when the climate card is closed, instead of running
  ui_refresh every second while the flag stayed set.

Tested with the owner's Roborock S8 on HA 2026.9.1 (live data, OTA-flashed bench boards) and with
host renders of both boards; see docs/TEST_RESULTS_0246.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-15 15:22:28 +02:00
parent 4ff0d44312
commit aa2137f3f8
23 changed files with 1675 additions and 655 deletions
+107 -3
View File
@@ -17,7 +17,7 @@ WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
REFS = {'cyd': 'main', 'guition': 'main'}
# Firmware shipped with this app release; screens below it get an update offer.
FIRMWARE_VERSION = '0.2.38'
FIRMWARE_VERSION = '0.2.39'
ATTRS = frozenset('brightness percentage current_position current_temperature temperature current_humidity min_temp max_temp target_temp_step supported_color_modes hvac_modes hvac_action hs_color color_temp_kelvin min_color_temp_kelvin max_color_temp_kelvin fan_speed_list unit_of_measurement battery_level fan_speed volume_level is_volume_muted media_title options min max step temperature_unit supported_features device_class next_rising next_setting finishes_at duration remaining humidity wind_speed wind_speed_unit apparent_temperature fan_modes swing_modes fan_mode swing_mode'.split())
# Attributes whose boolean value the screen needs; every other bool stays behind.
BOOL_ATTRS = frozenset(['is_volume_muted'])
@@ -369,10 +369,114 @@ def epoch(value):
moment = moment.replace(tzinfo=timezone.utc)
return int(moment.timestamp())
def extras(tile, states, forecast=None, tz=None, hourly=None, now=None):
"""Small, pre-computed values the firmware cannot derive itself (time zones, forecasts)."""
# Vacuum cards (app 0.2.46, firmware 0.2.39+): how a robot cleans is often a select on its own device,
# not an attribute. Roborock has "Cleaning mode" (vacuum, vac_and_mop, mop, custom, smart_mode) and
# "Mop intensity" since HA 2026.8; Ecovacs calls its mode work_mode. They are found by translation key
# or by the end of their entity id. HA 2026.8 also dropped battery_level from vacuum entities, so the
# battery comes from the device's battery sensor.
VACUUM_MODE_KEYS = ('cleaning_mode', 'work_mode', 'clean_mode')
VACUUM_WATER_KEYS = ('mop_intensity', 'water_flow', 'water_amount', 'water_flow_level', 'water_volume', 'mop_water_level')
# Short chip labels; anything else shows its own words.
VACUUM_LABELS = {
'vacuum': 'Vacuum', 'sweeping': 'Vacuum', 'vac_and_mop': 'Vac & mop', 'vacuum_and_mop': 'Vac & mop',
'sweeping_and_mopping': 'Vac & mop', 'mop_after_vacuum': 'Vac, then mop', 'mopping_after_sweeping': 'Vac, then mop',
'mop': 'Mop', 'mopping': 'Mop', 'custom': 'Custom', 'smart_mode': 'Smart',
'off': 'Off', 'min': 'Min', 'slight': 'Slight', 'low': 'Low', 'mild': 'Mild', 'medium': 'Medium', 'moderate': 'Moderate',
'standard': 'Standard', 'high': 'High', 'intense': 'Intense', 'extreme': 'Extreme', 'ultrahigh': 'Ultra high',
'quiet': 'Quiet', 'silent': 'Silent', 'gentle': 'Gentle', 'balanced': 'Normal', 'turbo': 'Turbo', 'strong': 'Strong',
'max': 'Max', 'max_plus': 'Max+', 'auto': 'Auto',
}
# What a cleaning mode does, one letter per option for the firmware: v vacuum only, m mop only, b both,
# a automatic (the robot or its app picks suction and water). Unknown modes count as both.
VACUUM_ROLES = {'vacuum': 'v', 'sweeping': 'v', 'mop': 'm', 'mopping': 'm', 'custom': 'a', 'smart_mode': 'a'}
# With a cleaning mode select these speeds and water levels belong to a mode: suction off is mop only,
# water off is vacuum only, and custom or smart settings are the Custom and Smart modes.
MODE_COVERED = frozenset(('off', 'off_raise_main_brush', 'custom', 'custom_water_flow', 'smart_mode', 'vac_followed_by_mop'))
VACUUM_CHOICES = 6
def vacuum_label(value):
return short(VACUUM_LABELS.get(value) or str(value).replace('_', ' ').capitalize(), 24)
def vacuum_related(entity, device, states):
"""Entities on the vacuum's device that its card reads, by role: the 'mode' and 'water' selects, the
'battery' and 'room' sensors and the 'charging' binary sensor; absent roles are left out.
`device` holds the registry entries of the vacuum's device. Entity ids follow Home Assistant's
language (select.s8_intensiteit_van_dweilen), so translation keys come first."""
found = {}
for item in device or ():
eid = item.get('entity_id') or ''
if eid == entity or item.get('disabled_by'):
continue
key = item.get('translation_key') or ''
attrs = states.get(eid, {}).get('attributes', {})
role = None
if eid.startswith('select.'):
if key in VACUUM_MODE_KEYS or eid.endswith(tuple('_' + k for k in VACUUM_MODE_KEYS)):
role = 'mode'
elif key in VACUUM_WATER_KEYS or eid.endswith(tuple('_' + k for k in VACUUM_WATER_KEYS)):
role = 'water'
elif eid.startswith('sensor.'):
if attrs.get('device_class') == 'battery':
role = 'battery'
elif key == 'current_room' or eid.endswith('_current_room'):
role = 'room'
elif eid.startswith('binary_sensor.') and attrs.get('device_class') == 'battery_charging':
role = 'charging'
if role and role not in found:
found[role] = eid
return found
def vacuum_extras(tile, states, device):
"""The vacuum card's rows and details: the mode and water selects (`e` entity, `s` state, `o` options,
`l` labels, `r` a role per mode), the suction speeds to offer (`fan`), the battery (`bat`), charging
(`chg`) and the room the robot is in (`room`)."""
entity = tile['entity']
attrs = states.get(entity, {}).get('attributes', {})
related = vacuum_related(entity, device, states)
result = {}
def row(values):
values = [short(v, 48) for v in values][:VACUUM_CHOICES]
return {'o': values, 'l': [vacuum_label(v) for v in values]} if values else None
def select(eid, keep):
state = states.get(eid) or {}
options = state.get('attributes', {}).get('options')
current = state.get('state') if isinstance(state.get('state'), str) else ''
found = row([o for o in options if isinstance(o, str) and keep(o, current)]) if isinstance(options, list) else None
return {'e': eid, 's': short(current, 48), **found} if found else None
# Custom runs the per-room settings made in the robot's app: listed only while it is in use.
mode = select(related['mode'], lambda o, current: o != 'custom' or o == current) if 'mode' in related else None
covered = MODE_COVERED if mode else frozenset()
if mode:
mode['r'] = ''.join(VACUUM_ROLES.get(v, 'b') for v in mode['o'])
result['mode'] = mode
water = select(related['water'], lambda o, current: o not in covered) if 'water' in related else None
if water:
result['water'] = water
speeds = attrs.get('fan_speed_list')
fan = row([s for s in speeds if isinstance(s, str) and s not in covered]) if isinstance(speeds, list) else None
if fan:
result['fan'] = fan
if not isinstance(attrs.get('battery_level'), (int, float)) and 'battery' in related:
try:
level = float(states[related['battery']].get('state'))
except (TypeError, ValueError, KeyError):
level = math.nan
if math.isfinite(level):
result['bat'] = max(0, min(100, round(level)))
if states.get(related.get('charging'), {}).get('state') == 'on':
result['chg'] = 1
room = states.get(related.get('room'), {}).get('state')
if isinstance(room, str) and room not in ('', 'unknown', 'unavailable', 'none'):
result['room'] = short(room, 32)
return result or None
def extras(tile, states, forecast=None, tz=None, hourly=None, now=None, device=None):
"""Small, pre-computed values the firmware cannot derive itself (time zones, forecasts, a vacuum's device)."""
domain = tile['entity'].split('.')[0]
attrs = states.get(tile['entity'], {}).get('attributes', {})
if domain == 'vacuum':
return vacuum_extras(tile, states, device)
if domain == 'weather' and (forecast or hourly):
result = {}
days = []
+24 -2
View File
@@ -429,6 +429,25 @@ class Manager:
self._registry_source, self._registry_index = registry, {item['entity_id']: item for item in registry}
return self._registry_index
def device_entries(self, entity):
"""Registry entries on the device of `entity` (itself included); the index follows the registry object."""
registry = getattr(self.ha, 'registry', [])
if getattr(self, '_devices_source', None) is not registry:
by_device = {}
for item in registry:
if item.get('device_id'):
by_device.setdefault(item['device_id'], []).append(item)
self._devices_source, self._by_device = registry, by_device
device = self.registry_index().get(entity, {}).get('device_id')
return self._by_device.get(device, []) if device else []
def related_entities(self, tile):
"""Entities a card reads besides its own: a vacuum's cleaning mode and water selects and its battery sensor."""
if not tile['entity'].startswith('vacuum.'):
return ()
from core import vacuum_related
return tuple(vacuum_related(tile['entity'], self.device_entries(tile['entity']), self.ha.states).values())
def header_message(self, layout):
return header_bar.message(layout, self.ha.states, self.registry_index(), getattr(self.ha, 'units', {}), getattr(self.ha, 'time_zone', None))
@@ -518,6 +537,7 @@ class Manager:
watched = {tile['entity'] for layout in self.layouts.values() for tile in layout['tiles']}
watched |= {item['entity'] for layout in self.layouts.values() for item in header_items(layout) if item['type'] == 'entity'}
watched |= {item['entity_id'] for item in self.screen_registry()}
watched |= {eid for layout in self.layouts.values() for tile in layout['tiles'] for eid in self.related_entities(tile)}
self._watched_key, self._watched = key, watched
return set(self._watched)
@@ -541,7 +561,9 @@ class Manager:
forecast=await self.cached(self.forecasts, tile['entity'], FORECAST_SECONDS, lambda: self.ha.forecast(tile['entity']))
# Hourly forecasts feed the weather card's next-hours strip (0.2.23+); refreshed every half hour.
hourly=await self.cached(self.forecasts, (tile['entity'],'hourly'), FORECAST_SECONDS, lambda: self.ha.forecast(tile['entity'],'hourly'))
message=state_message(index,tile,self.ha.states,extras(tile,self.ha.states,forecast,getattr(self.ha,'time_zone',None),hourly))
# A vacuum's card also reads selects and the battery sensor of its device (app 0.2.46).
device=self.device_entries(tile['entity']) if tile['entity'].startswith('vacuum.') else None
message=state_message(index,tile,self.ha.states,extras(tile,self.ha.states,forecast,getattr(self.ha,'time_zone',None),hourly,device=device))
if tile['entity'].startswith('sensor.') and hasattr(self.ha,'history'):
hours=tile.get('options',{}).get('history_hours',24)
entry=self.histories.get((tile['entity'],hours))
@@ -593,7 +615,7 @@ class Manager:
header_msg = previous['header']
states = []
for i, tile in enumerate(tiles):
reuse = not full and i < len(previous['states']) and tile['entity'] not in dirty
reuse = not full and i < len(previous['states']) and tile['entity'] not in dirty and dirty.isdisjoint(self.related_entities(tile))
if reuse and tile['entity'].startswith('weather.') and self.forecast_due(tile['entity']):
reuse = False
states.append(previous['states'][i] if reuse else await self.tile_message(i, tile))
Binary file not shown.
+2
View File
@@ -216,6 +216,8 @@ FIXED = (
('weather-pouring', 'F0596'),
('weather-snowy-rainy', 'F067F'),
('weather-sunset-down', 'F059B'),
# "More" key on the CYD climate card: fan and swing (firmware 0.2.39+).
('dots-horizontal', 'F01D8'),
)
# Pickable icons by name, and every glyph the firmware fonts contain.