Add a card for blinds and curtains, and fix what Studio 1 showed after 0.2.57 (0.2.58)

A tap on a cover now opens a card like Home Assistant's own, in the style
of the climate and vacuum cards: a tall position slider on which the blind
hangs from the top, a tilt slider over slats for venetian blinds, and open,
stop and close keys. The key of the direction the cover moves is filled; a
key that cannot move it further is disabled and stays out of the keys the
card's tick enables. Sliders show their value while dragged and send it on
release. The card offers only what supported_features allows. A
battery-powered blind shows its battery at the top right: the app sends
the device's battery sensor as the extra the vacuum card uses, and adds
current_tilt_position to the attributes.

Checking Studio 1 after its update to firmware 0.2.49 found that a setting
number equal to its initial state 0 (night brightness 0 %) was never
published, so Home Assistant kept it unknown and ESP Screens could not
change it; apply_screen_settings now publishes a number without a state.
The app also took "unavailable" for the device name during the restart
and wrote sensor.esp_screens_unavailable; diagnostics ignore unknown and
unavailable. The editor redraws its tiles, top bar and entity list when
the live stream opened a screen before the first full inventory (a busy
add-on), and loads app.js and style.css by content stamp after Safari kept
an old script.

Checked with 254 Python and 14 C++ tests, generate_packages --check, ESP32
builds of both profiles, and 20 API checks of the card on host builds of
both boards. Firmware 0.2.50.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-16 16:41:10 +02:00
parent b58f0cde25
commit d6e5b35dcf
24 changed files with 696 additions and 69 deletions
+11
View File
@@ -1,3 +1,14 @@
## 0.2.58 (firmware 0.2.50)
A card for blinds, curtains and garage doors, and three fixes found right after updating to 0.2.57.
- **Cover card.** Tapping a blind, curtain, shutter or garage door opens a card like Home Assistant's own, in the style of the climate and vacuum cards: a tall position slider on which the blind hangs from the top, a tilt slider over slats for venetian blinds, and open, stop and close keys. The key of the direction the cover is moving is filled, and a key that can't move it further is greyed out. A slider shows its value while you drag and sends it when you let go. A battery-powered blind, such as Motionblinds, shows its battery at the top right. The card shows only what the cover supports: a garage door that only opens and closes gets just the keys.
- **Night brightness 0 % now shows in Home Assistant.** A brightness setting whose value is 0 stayed "unknown" in Home Assistant, so ESP Screens could not change it on a screen with firmware 0.2.49.
- **No second layout sensor while a screen restarts.** During a restart ESP Screens took "unavailable" for the screen's device name and wrote `sensor.esp_screens_unavailable`. Home Assistant drops that sensor at its next restart.
- **The entity list is filled when the page opens.** When ESP Screens was busy, such as while building firmware, the page could open a screen before its entities arrived: "No entities found" until you picked a filter, and tile names showing as entity IDs.
- **No stale page after an update.** A browser that keeps old files, as Safari did, could combine the new page with the old script and show an empty Screen settings panel. The page now always loads the script and styles of the version that is running.
- Needs firmware 0.2.50 for the cover card and the brightness fix: press **Update** on the screen.
## 0.2.57 (firmware 0.2.49)
The screen keeps its own settings, and ESP Screens shows them the way the screen does.
+27 -12
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.49'
FIRMWARE_VERSION = '0.2.50'
# The Auto standby switch a screen offers Home Assistant automations.
AUTO_STANDBY_MIN_FIRMWARE = '0.2.41'
# The settings page the screen opens itself, and the screen.settings tile that opens it.
@@ -26,7 +26,7 @@ SETTINGS_PAGE_MIN_FIRMWARE = '0.2.44'
WAKE_SLEEP_MIN_FIRMWARE = '0.2.45'
# Every screen setting as an entity of the screen, which owns them (see SETTING_ENTITIES).
SETTING_ENTITIES_MIN_FIRMWARE = '0.2.49'
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())
ATTRS = frozenset('brightness percentage current_position current_tilt_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'])
@@ -758,6 +758,26 @@ def vacuum_related(entity, device, states):
found[role] = eid
return found
def device_power(attrs, related, states):
"""`bat` from the device's battery sensor when the entity has no battery_level of its own, and `chg` while its
charging sensor is on; empty without them."""
result = {}
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
return result
def cover_related(entity, device, states):
"""A cover's battery and charging sensors on its device (app 0.2.58): battery-powered blinds such as
Motionblinds report the battery there."""
return {role: eid for role, eid in vacuum_related(entity, device, states).items() if role in ('battery', 'charging')}
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
@@ -788,15 +808,7 @@ def vacuum_extras(tile, states, device):
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
result.update(device_power(attrs, related, states))
room = states.get(related.get('room'), {}).get('state')
if isinstance(room, str) and room not in ('', 'unknown', 'unavailable', 'none'):
result['room'] = short(room, 32)
@@ -808,6 +820,8 @@ def extras(tile, states, forecast=None, tz=None, hourly=None, now=None, device=N
attrs = states.get(tile['entity'], {}).get('attributes', {})
if domain == 'vacuum':
return vacuum_extras(tile, states, device)
if domain == 'cover':
return device_power(attrs, cover_related(tile['entity'], device, states), states) or None
if domain == 'weather' and (forecast or hourly):
result = {}
days = []
@@ -1072,7 +1086,8 @@ def discover_screens(registry, states, devices, areas):
for item in registry:
if item.get('platform') == 'esphome' and item.get('original_name') in names:
value = states.get(item['entity_id'], {}).get('state', '')
if isinstance(value, str) and re.fullmatch(pattern, value):
# A screen that restarts reports "unavailable", which reads like a device name.
if isinstance(value, str) and value not in ('unknown', 'unavailable') and re.fullmatch(pattern, value):
found[item.get('device_id')] = value
return found
nodes = diagnostic(NAME_DEVICE_NAME, r'[a-z0-9][a-z0-9-]{0,30}')
+16 -8
View File
@@ -1,6 +1,7 @@
"""HA Ingress app. HA writes: text.set_value on discovered inboxes (or the screen_message action on firmware 0.2.33+), each screen's alert actions when an alert event for every screen fires, and one persistent notification when a nightly update stops."""
import asyncio
import contextlib
import hashlib
import json
import logging
import os
@@ -671,11 +672,14 @@ class Manager:
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())
"""Entities a card reads besides its own: a vacuum's cleaning mode and water selects and its battery sensor, a
cover's battery sensor."""
from core import cover_related, vacuum_related
if tile['entity'].startswith('vacuum.'):
return tuple(vacuum_related(tile['entity'], self.device_entries(tile['entity']), self.ha.states).values())
if tile['entity'].startswith('cover.'):
return tuple(cover_related(tile['entity'], self.device_entries(tile['entity']), self.ha.states).values())
return ()
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))
@@ -802,8 +806,8 @@ class Manager:
forecast=await self.cached(self.forecasts, entity, FORECAST_SECONDS, (lambda: self.ha.forecast(entity)) if 'daily' in kinds else nothing)
# Hourly forecasts feed the weather card's next-hours strip (0.2.23+); refreshed every half hour.
hourly=await self.cached(self.forecasts, (entity,'hourly'), FORECAST_SECONDS, (lambda: self.ha.forecast(entity,'hourly')) if 'hourly' in kinds else nothing)
# 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
# A vacuum's card also reads selects and the battery sensor of its device (app 0.2.46), a cover's card its battery (0.2.58).
device=self.device_entries(tile['entity']) if tile['entity'].startswith(('vacuum.', 'cover.')) 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)
@@ -1158,8 +1162,12 @@ def create_app(manager, development=False):
app = web.Application(middlewares=[guard], client_max_size=16*1024)
static = Path(__file__).parent / 'static'
# The page asks for its script and styles with a stamp of their content, so a browser that keeps old copies
# anyway (Safari did after an update) never runs an old script against a new page.
stamp = hashlib.sha1(b''.join((static / name).read_bytes() for name in ('app.js', 'style.css'))).hexdigest()[:12]
page = (static / 'index.html').read_text().replace('"static/app.js"', f'"static/app.js?v={stamp}"').replace('"static/style.css"', f'"static/style.css?v={stamp}"')
async def index(request):
return web.FileResponse(static / 'index.html')
return web.Response(text=page, content_type='text/html')
def light_payload(screens=None):
"""Screens and update status: everything that changes while the page is open."""
if screens is None:
+4
View File
@@ -1889,6 +1889,9 @@ function renderResults() {
async function refresh(full = true) {
try {
const data = await (await api(full ? "inventory" : "inventory?light=1")).json();
// The live stream can open a screen before the first full inventory arrives (an add-on busy building
// firmware answers it first); that screen was drawn without entity names and with an empty entity list.
const firstCatalogue = full && !inventory.entities?.length;
// A light poll carries only screens and update status; keep the catalogues we have.
inventory = full ? data : { ...inventory, ...data };
$("#connection").textContent = inventory.connected
@@ -1898,6 +1901,7 @@ async function refresh(full = true) {
renderScreens();
if (full) renderClaude();
if (selected) { settleSettings(); renderSettings(); }
if (firstCatalogue && selected && layout && !drag.active && !chipDrag.active) { renderTopbar(); renderTiles(); renderResults(); }
if (!selected && inventory.screens.length) select(inventory.screens[0].id);
if ($("#alerts-dialog").open) renderAlertScreens();
} catch {
+1 -1
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager
version: "0.2.57"
version: "0.2.58"
slug: esp_screen_manager
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
url: https://github.com/MaxGramser/homeassistant_espscreen