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:
@@ -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
@@ -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}')
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user