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
+11
View File
@@ -1,3 +1,14 @@
## 0.2.46 (firmware 0.2.39)
Choose between vacuuming, mopping or both on the vacuum card, and a climate card made for the small CYD.
- **Vacuum, vacuum and mop, or mop only.** Robots that offer a cleaning mode in Home Assistant, such as a Roborock (HA 2026.8 or newer), get a row with Vacuum, Vac & mop and Mop on the vacuum card of both screens. ESP Screens finds the mode and the mop intensity on the robot's own device, whatever language your entity IDs are in; you don't have to add them as tiles. Custom (the per-room settings from the robot's app) shows up only while the robot uses it.
- **Only what the mode uses.** Below the mode sit suction power and water as rows of choices: vacuum only shows suction, mop only shows water, both show both. A tap shows your choice at once and the card waits for Home Assistant. Speeds that belong to a mode, such as suction off for mopping, move into the mode row, and Max+ now fits next to Max.
- **A new vacuum card.** The robot, its state and battery on top, with a green bolt while it charges and the room it is in while it cleans; then Start cleaning (or Pause, Resume) and Dock; then how it cleans. On the Guition the cleaning settings share one white card, and Locate became a round button beside the robot. Since HA 2026.8 a vacuum no longer reports its battery itself, so ESP Screens now reads the battery sensor of the robot.
- **Climate card for the CYD.** No more dial to drag on the small resistive screen: the target temperature sits big between two large − / + keys. The number follows every tap at once and one call goes out when you stop tapping; holding a key keeps stepping. One row of keys picks the mode (in Home Assistant's order and colours), and ··· opens fan and swing. A tap only redraws the number or a key, so the card responds right away. The Guition keeps its dial.
- **Less redrawing after a climate change.** A card closed within three seconds of a change left a flag behind that redrew every tile each second; that flag is now cleared.
- Firmware 0.2.39 for both boards; the app offers the update. Update the app and the screens together: with firmware 0.2.39 and an older app the vacuum card falls back to suction only.
## 0.2.45 (firmware 0.2.38)
One alert for every screen, a Claude skill, and a Settings page that clears up the main page.
+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.
+1 -1
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager
version: "0.2.45"
version: "0.2.46"
slug: esp_screen_manager
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
url: https://github.com/MaxGramser/homeassistant_espscreen