Let the screen own its settings, answer its pings, and show them as cards (0.2.57)
A code check found that a setting changed on a screen could jump back. ESP Screen Manager kept its own copy and sent it with every full update: after a restart of the app or Home Assistant, every hour and after every save. A change it missed while it or Home Assistant restarted came back to the old value, a reported setting was lost when save() refused a layout with a removed entity, and every step of a held - or + on the screen resent the whole screen. Firmware 0.2.49 offers every setting as an entity over the existing preference records (no migration): Night mode, Night starts/ends, 24-hour clock, Back to page 1 and after how long, Back to page 1 on standby, Swipe between pages and, on the Guition, Rotation, next to the four numbers and Auto standby. The settings page and every entity write through settings_screen::set(), which clamps like the page and stores, applies and reports only a real change. screen_message answers a caller that asks with its status and layout revision (api.respond). The settings tile opens without Home Assistant. The app reads and changes the settings of such a screen through its entities and leaves them out of the layout message. Older firmware keeps them with the layout; a setting it reports is stored without the tile check and not sent back. Pings to screens that can answer learn at once whether a tile is missing and resend after 30 s instead of 120 s. Layout sensors come back after a Home Assistant restart, and weather tiles only ask for the forecasts the entity supports. The editor's settings form is now three cards with the rows of the screen's own page; a change applies at once, and an offline screen shows its values as unknown. Checked with 245 Python and 14 C++ tests, generate_packages --check, ESP32 builds of both profiles, host-build API tests on both boards, and a live run against Home Assistant 2026.9.1 with a temporary host device. Firmware 0.2.49. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,7 @@ import tile_icons
|
||||
from core import (ALERT_ENDINGS, ALERT_EVENT, ALERT_FALLBACK_ICON, ALERT_FIELDS, ALERT_LIMITS, ALERT_MAX_TIMEOUT,
|
||||
ALERT_MIN_FIRMWARE, ALERT_SUGGESTED_ICONS, AUTO_STANDBY_MIN_FIRMWARE, BROADCAST_DISMISS, BROADCAST_SHOW,
|
||||
CONTROLS, DISPLAYS, MAX_PAGES, SETTINGS_PAGE_MIN_FIRMWARE, SLOTS_PER_PAGE, TILE_BACKGROUNDS,
|
||||
TILE_EVENTS, TILE_RESULT_EVENT, WAKE_SLEEP_MIN_FIRMWARE)
|
||||
TILE_EVENTS, TILE_RESULT_EVENT, WAKE_SLEEP_MIN_FIRMWARE, SETTING_ENTITIES_MIN_FIRMWARE)
|
||||
|
||||
NAME = 'esp-screens'
|
||||
# claude.ai accepts at most 200 characters; Claude Code picks the skill by this sentence.
|
||||
@@ -262,7 +262,14 @@ Every screen has these entities in Home Assistant, on its ESPHome device. `<scre
|
||||
| `number.<screen>_standby_after` | Seconds without a touch before standby, 60 to 86400. |
|
||||
| `number.<screen>_normal_brightness` | Brightness while in use, 5 to 100 %. |
|
||||
| `number.<screen>_standby_brightness` | Brightness in standby, 0 to 100 %, at most the normal brightness. |
|
||||
| `number.<screen>_night_brightness` | Brightness in standby during the night hours set in ESP Screens, 0 to 100 %. |
|
||||
| `number.<screen>_night_brightness` | Brightness in standby during the night hours, 0 to 100 %. |
|
||||
| `switch.<screen>_night_mode` | Night mode: the night brightness between the two times below. Firmware {SETTING_ENTITIES_MIN_FIRMWARE} or newer, like every row below it. |
|
||||
| `time.<screen>_night_starts`, `time.<screen>_night_ends` | The night hours; set them with `time.set_value`. |
|
||||
| `switch.<screen>_24_hour_clock` | On: 24-hour clock. Off: 12-hour clock. |
|
||||
| `switch.<screen>_back_to_page_1` | On: after `number.<screen>_back_to_page_1_after` seconds without a touch (30 to 3600) the screen closes a card and goes back to page 1. |
|
||||
| `switch.<screen>_back_to_page_1_on_standby` | On: going into standby also goes back to page 1. |
|
||||
| `switch.<screen>_swipe_between_pages` | On: swipe between pages. |
|
||||
| `select.<screen>_rotation` | Guition only: `0°`, `90°`, `180°` or `270°`. |
|
||||
|
||||
Wake and Sleep are buttons: press them with the `button.press` action. They save nothing, so an automation may press them as often as it likes, on every motion too. To reach several screens at once, list their buttons under `entity_id`. Don't target an area or a device with `button.press`: that presses every other button there as well, the Wake and Sleep of the same screen included.
|
||||
|
||||
@@ -297,7 +304,7 @@ actions:
|
||||
- button.kitchen_screen_sleep
|
||||
```
|
||||
|
||||
The switch and the numbers are the same settings as in ESP Screens: a change made from Home Assistant shows there too and stays after a restart. Turning Auto standby on again counts the standby time from that moment. Every change is saved on the screen, so switch on changes that happen a few times a day (someone comes home, a light goes on, a window opens), never on every motion: press Wake for that.
|
||||
These switches, numbers, times and the select are the screen's own settings, the same as on its settings page and in ESP Screens: a change made from Home Assistant shows there too and stays after a restart. Turning Auto standby on again counts the standby time from that moment. Every change is saved on the screen, so switch on changes that happen a few times a day (someone comes home, a light goes on, a window opens), never on every motion: press Wake for that.
|
||||
|
||||
To keep screens on while something is going on at home, turn Auto standby off when the situation starts and on when it ends, all from one automation. Example with placeholders for the user's own entities:
|
||||
|
||||
|
||||
@@ -17,13 +17,15 @@ 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.48'
|
||||
FIRMWARE_VERSION = '0.2.49'
|
||||
# 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.
|
||||
SETTINGS_PAGE_MIN_FIRMWARE = '0.2.44'
|
||||
# The Wake and Sleep buttons a screen offers Home Assistant automations.
|
||||
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())
|
||||
# Attributes whose boolean value the screen needs; every other bool stays behind.
|
||||
BOOL_ATTRS = frozenset(['is_volume_muted'])
|
||||
@@ -180,6 +182,85 @@ SETTING_RULES = {
|
||||
# docs/SETTINGS.md walks through adding one.
|
||||
SETTINGS_BESIDE_BLOCK = ('swipe_pages', 'rotation', 'auto_home', 'auto_home_seconds')
|
||||
|
||||
# ----- The screen owns its settings (firmware 0.2.49+) -----
|
||||
# A screen offers every setting as an entity of its own device, and the settings page on the screen, Home
|
||||
# Assistant and ESP Screens all change them there. ESP Screens reads them from those entities, changes them
|
||||
# with the entity's own action, and leaves them out of the layout message, so it never overwrites what
|
||||
# someone changed on the screen or in an automation. A screen without these entities (older firmware) gets
|
||||
# its settings in the layout message as before. `show_clock` has no entity: the top bar decides the clock.
|
||||
# key: (Home Assistant domain, the entity's name in the board profiles)
|
||||
SETTING_ENTITIES = {
|
||||
'brightness': ('number', 'Normal brightness'),
|
||||
'standby_enabled': ('switch', 'Auto standby'),
|
||||
'standby_seconds': ('number', 'Standby after'),
|
||||
'standby_brightness': ('number', 'Standby brightness'),
|
||||
'night_enabled': ('switch', 'Night mode'),
|
||||
'night_start': ('time', 'Night starts'),
|
||||
'night_end': ('time', 'Night ends'),
|
||||
'night_brightness': ('number', 'Night brightness'),
|
||||
'clock_24h': ('switch', '24-hour clock'),
|
||||
'auto_home': ('switch', 'Back to page 1'),
|
||||
'auto_home_seconds': ('number', 'Back to page 1 after'),
|
||||
'home_on_standby': ('switch', 'Back to page 1 on standby'),
|
||||
'swipe_pages': ('switch', 'Swipe between pages'),
|
||||
'rotation': ('select', 'Rotation'),
|
||||
}
|
||||
# Entities firmware 0.2.49 added; one of them on a device means the screen owns its settings. The first five
|
||||
# existed before, so they cannot tell.
|
||||
OWNED_SETTINGS_MARKERS = frozenset(('Night mode', 'Night starts', 'Night ends', '24-hour clock', 'Back to page 1',
|
||||
'Back to page 1 after', 'Back to page 1 on standby', 'Swipe between pages'))
|
||||
ROTATION_OPTIONS = ('0°', '90°', '180°', '270°')
|
||||
|
||||
|
||||
def setting_entities(items):
|
||||
"""{key: entity_id} of a screen's setting entities, from the registry entries of its device (disabled ones
|
||||
included), or None when the screen does not own its settings yet (firmware before 0.2.49)."""
|
||||
found, owned = {}, False
|
||||
for item in items:
|
||||
if item.get('platform') != 'esphome':
|
||||
continue
|
||||
name, domain = item.get('original_name'), item['entity_id'].split('.', 1)[0]
|
||||
owned = owned or name in OWNED_SETTINGS_MARKERS
|
||||
for key, (wanted_domain, wanted_name) in SETTING_ENTITIES.items():
|
||||
if name == wanted_name and domain == wanted_domain and key not in found:
|
||||
found[key] = item['entity_id']
|
||||
return found if owned else None
|
||||
|
||||
|
||||
def setting_from_state(key, state):
|
||||
"""The value of one setting from its entity's state, or None while Home Assistant has none."""
|
||||
value = (state or {}).get('state')
|
||||
if not isinstance(value, str) or value in ('', 'unknown', 'unavailable'):
|
||||
return None
|
||||
domain = SETTING_ENTITIES[key][0]
|
||||
try:
|
||||
if domain == 'switch':
|
||||
return {'on': True, 'off': False}.get(value)
|
||||
if domain == 'number':
|
||||
number = float(value)
|
||||
return int(round(number)) if math.isfinite(number) else None
|
||||
if domain == 'time':
|
||||
hour, minute = (int(part) for part in value.split(':')[:2])
|
||||
return hour * 60 + minute if 0 <= hour < 24 and 0 <= minute < 60 else None
|
||||
if domain == 'select':
|
||||
return int(value.rstrip('°')) if value in ROTATION_OPTIONS else None
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def setting_action(key, entity, value):
|
||||
"""(action, data) that gives one setting entity `value`."""
|
||||
domain = SETTING_ENTITIES[key][0]
|
||||
if domain == 'switch':
|
||||
return ('switch.turn_on' if value else 'switch.turn_off'), {'entity_id': entity}
|
||||
if domain == 'number':
|
||||
return 'number.set_value', {'entity_id': entity, 'value': value}
|
||||
if domain == 'time':
|
||||
return 'time.set_value', {'entity_id': entity, 'time': f'{value // 60:02d}:{value % 60:02d}:00'}
|
||||
return 'select.select_option', {'entity_id': entity, 'option': f'{value}°'}
|
||||
|
||||
|
||||
def validate_settings(data):
|
||||
if not isinstance(data, dict) or set(data) - SETTING_RULES.keys():
|
||||
raise ValueError('Unknown screen settings; refresh the management page.')
|
||||
@@ -590,6 +671,15 @@ def local_clock(value, tz):
|
||||
moment = moment.replace(tzinfo=timezone.utc)
|
||||
return moment.astimezone(tz or timezone.utc).strftime('%H:%M')
|
||||
|
||||
def forecast_kinds(attributes):
|
||||
"""The forecasts a weather entity offers, from its supported_features (WeatherEntityFeature: 1 daily,
|
||||
2 hourly). Asking for one it lacks makes Home Assistant log an error; an entity that reports no features
|
||||
(unavailable) is asked for both, as before."""
|
||||
features = (attributes or {}).get('supported_features')
|
||||
if not isinstance(features, int) or isinstance(features, bool):
|
||||
return frozenset(('daily', 'hourly'))
|
||||
return frozenset(kind for kind, bit in (('daily', 1), ('hourly', 2)) if features & bit)
|
||||
|
||||
def forecast_number(entry, name):
|
||||
value = entry.get(name)
|
||||
return round(value, 1) if isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) else None
|
||||
@@ -996,6 +1086,7 @@ def discover_screens(registry, states, devices, areas):
|
||||
state = states.get(eid, {})
|
||||
area = area_map.get(item.get('area_id') or device.get('area_id'), '')
|
||||
screens.append({'id': eid, 'name': device.get('name_by_user') or device.get('name') or eid,
|
||||
'device_id': item.get('device_id'),
|
||||
'firmware': versions.get(item.get('device_id'), 'unknown'),
|
||||
'board': boards.get(item.get('device_id'), 'unknown'),
|
||||
'node': nodes.get(item.get('device_id')), 'ip': addresses.get(item.get('device_id')),
|
||||
|
||||
+295
-30
@@ -15,7 +15,8 @@ import tile_icons
|
||||
from updates import Updater
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout, WSMsgType, web
|
||||
from core import BROADCAST_EVENTS, BUILTIN, SETTINGS_BESIDE_BLOCK, TILE_EVENTS, TILE_RESULT_EVENT, apply_tile_event, layout_snapshot, match_screen, HEADER_MIN_FIRMWARE, NAME_TILE_SETTINGS, TILE_BACKGROUNDS, TRANSPORT_MIN_FIRMWARE, alert_data, alert_reference, alert_service, alert_targets, controls_catalogue, device_prefixes, discover, discover_screens, encode, extras, header_items, inbox_prefix, message_action, min_firmware, pack_slots, packets, revision, screen_items, state_message, validate_header, validate_layout, validate_settings
|
||||
from core import BROADCAST_EVENTS, BUILTIN, SETTINGS_BESIDE_BLOCK, TILE_EVENTS, TILE_RESULT_EVENT, apply_tile_event, layout_snapshot, match_screen, HEADER_MIN_FIRMWARE, NAME_TILE_SETTINGS, TILE_BACKGROUNDS, TRANSPORT_MIN_FIRMWARE, alert_data, alert_reference, alert_service, alert_targets, controls_catalogue, device_prefixes, discover, discover_screens, encode, extras, forecast_kinds, header_items, inbox_prefix, message_action, min_firmware, pack_slots, packets, revision, screen_items, state_message, validate_header, validate_layout, validate_settings
|
||||
from core import SETTING_ENTITIES, SETTING_RULES, setting_action, setting_entities, setting_from_state
|
||||
import header_bar
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
@@ -40,6 +41,12 @@ RESEND_STATES = frozenset({
|
||||
})
|
||||
RESEND_GUARD_SECONDS = 120
|
||||
FORECAST_SECONDS = 1800
|
||||
# A screen that answers its ping (firmware 0.2.49+) is asked with this timeout, so a busy screen never holds
|
||||
# up the others. An answer that the screen lacks something repeats everything, after this many seconds right
|
||||
# after a full send, doubling up to RESEND_GUARD_SECONDS while it keeps failing.
|
||||
ANSWER_TIMEOUT_SECONDS = 5
|
||||
ANSWER_RETRY_SECONDS = 30
|
||||
SERVICE_EVENTS = ('service_registered', 'service_removed')
|
||||
|
||||
def samples(events, begin, span):
|
||||
"""24 values, one per bucket: the last known value at the end of each bucket (None until the first)."""
|
||||
@@ -53,6 +60,13 @@ def samples(events, begin, span):
|
||||
out.append(value)
|
||||
return out
|
||||
|
||||
class Refused(ConnectionError):
|
||||
"""Home Assistant answered a command with an error; `detail` is its own message."""
|
||||
def __init__(self, detail=''):
|
||||
super().__init__('Home Assistant refused the command.')
|
||||
self.detail = detail
|
||||
|
||||
|
||||
def rounded(value):
|
||||
try:
|
||||
value = float(value)
|
||||
@@ -85,6 +99,10 @@ class HomeAssistant:
|
||||
self.broadcasts = asyncio.Queue()
|
||||
# (event type, data) of tile events (app 0.2.51+), for Manager.tile_loop.
|
||||
self.tile_events = asyncio.Queue()
|
||||
# ESPHome actions that can answer (`esphome.<node>_screen_message` of firmware 0.2.49+), from Home
|
||||
# Assistant's own list; refreshed when a device registers its actions again.
|
||||
self.responses = set()
|
||||
self.services_changed = asyncio.Event()
|
||||
|
||||
async def request(self, kind, **data):
|
||||
if self.ws is None or self.ws.closed:
|
||||
@@ -110,7 +128,8 @@ class HomeAssistant:
|
||||
if data.get('success'):
|
||||
future.set_result(data.get('result'))
|
||||
else:
|
||||
future.set_exception(ConnectionError('Home Assistant refused the command.'))
|
||||
error = data.get('error') if isinstance(data.get('error'), dict) else {}
|
||||
future.set_exception(Refused(str(error.get('message') or '')))
|
||||
elif data.get('type') == 'event':
|
||||
event = data.get('event', {})
|
||||
body = event.get('data', {})
|
||||
@@ -128,6 +147,9 @@ class HomeAssistant:
|
||||
self.changed.set()
|
||||
elif event.get('event_type') in REGISTRY_EVENTS:
|
||||
self.registry_changed.set()
|
||||
elif event.get('event_type') in SERVICE_EVENTS:
|
||||
if body.get('domain') == 'esphome':
|
||||
self.services_changed.set()
|
||||
elif event.get('event_type') in BROADCAST_EVENTS:
|
||||
# Queued, not handled here: the calls wait for results this reader has to deliver.
|
||||
self.broadcasts.put_nowait((event['event_type'], body))
|
||||
@@ -148,6 +170,19 @@ class HomeAssistant:
|
||||
self.registry, self.devices, self.areas = await asyncio.gather(
|
||||
self.request('config/entity_registry/list'), self.request('config/device_registry/list'), self.request('config/area_registry/list'))
|
||||
|
||||
async def fetch_services(self):
|
||||
"""Which ESPHome actions answer: Home Assistant lists `response` for an action that can return one."""
|
||||
esphome = (await self.request('get_services') or {}).get('esphome', {})
|
||||
self.responses = {f'esphome.{name}' for name, spec in esphome.items() if isinstance(spec, dict) and spec.get('response')}
|
||||
|
||||
async def refresh_services(self):
|
||||
"""fetch_services for the connection loop: a list that cannot be read only means no answers are asked for."""
|
||||
try:
|
||||
await self.fetch_services()
|
||||
except (ConnectionError, TimeoutError, OSError, ValueError, TypeError, AttributeError) as error:
|
||||
LOG.warning('Reading the actions from Home Assistant failed (%s); screens are not asked for answers', type(error).__name__)
|
||||
self.responses = set()
|
||||
|
||||
async def run(self):
|
||||
url = ('ws://supervisor/core/websocket' if self.base == 'http://supervisor/core/api'
|
||||
else self.base.replace('http://', 'ws://').replace('https://', 'wss://') + '/websocket')
|
||||
@@ -163,10 +198,11 @@ class HomeAssistant:
|
||||
reader = asyncio.create_task(self.read())
|
||||
await self.request('subscribe_events', event_type='state_changed')
|
||||
await self.request('subscribe_events', event_type='esphome.screen_setting')
|
||||
for event_type in (*REGISTRY_EVENTS, *BROADCAST_EVENTS, *TILE_EVENTS):
|
||||
for event_type in (*REGISTRY_EVENTS, *BROADCAST_EVENTS, *TILE_EVENTS, *SERVICE_EVENTS):
|
||||
await self.request('subscribe_events', event_type=event_type)
|
||||
self.states = {s['entity_id']: s for s in await self.request('get_states')}
|
||||
await self.registries()
|
||||
await self.refresh_services()
|
||||
try:
|
||||
config = await self.request('get_config')
|
||||
self.units = config.get('unit_system') or {}
|
||||
@@ -181,13 +217,20 @@ class HomeAssistant:
|
||||
# fallback; a full fetch is about 1 MB of JSON and used to run every 30 s.
|
||||
fetched = time.monotonic()
|
||||
while True:
|
||||
waiter = asyncio.ensure_future(self.registry_changed.wait())
|
||||
waiters = [asyncio.ensure_future(self.registry_changed.wait()), asyncio.ensure_future(self.services_changed.wait())]
|
||||
try:
|
||||
done, _ = await asyncio.wait([reader, waiter], timeout=30, return_when=asyncio.FIRST_COMPLETED)
|
||||
done, _ = await asyncio.wait([reader, *waiters], timeout=30, return_when=asyncio.FIRST_COMPLETED)
|
||||
finally:
|
||||
waiter.cancel()
|
||||
for waiter in waiters:
|
||||
waiter.cancel()
|
||||
if reader in done:
|
||||
await reader
|
||||
if self.services_changed.is_set():
|
||||
# A screen that reconnects registers its actions one after the other.
|
||||
await asyncio.sleep(1)
|
||||
self.services_changed.clear()
|
||||
await self.refresh_services()
|
||||
self.changed.set()
|
||||
if self.registry_changed.is_set():
|
||||
await asyncio.sleep(1)
|
||||
self.registry_changed.clear()
|
||||
@@ -214,12 +257,20 @@ class HomeAssistant:
|
||||
future.set_exception(ConnectionError('Connection lost.'))
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def send(self, inbox, message, action=None):
|
||||
"""One message to a screen: as one ESPHome action call (firmware 0.2.33+), else as text chunks."""
|
||||
async def send(self, inbox, message, action=None, respond=False):
|
||||
"""One message to a screen: as one ESPHome action call (firmware 0.2.33+), else as text chunks.
|
||||
|
||||
`respond`: wait for the screen's answer (firmware 0.2.49+), at most ANSWER_TIMEOUT_SECONDS, and return it
|
||||
(`status`, `rev`), or None when the answer carries nothing usable."""
|
||||
if action:
|
||||
domain, service = action.split('.', 1)
|
||||
await self.request('call_service', domain=domain, service=service, service_data={'message': encode(message)})
|
||||
return
|
||||
data = {'domain': domain, 'service': service, 'service_data': {'message': encode(message)}}
|
||||
if not respond:
|
||||
await self.request('call_service', **data)
|
||||
return None
|
||||
result = await asyncio.wait_for(self.request('call_service', **data, return_response=True), ANSWER_TIMEOUT_SECONDS)
|
||||
response = (result or {}).get('response') if isinstance(result, dict) else None
|
||||
return response if isinstance(response, dict) else None
|
||||
for packet in packets(message):
|
||||
await self.request('call_service', domain='text', service='set_value',
|
||||
service_data={'entity_id': inbox, 'value': packet})
|
||||
@@ -317,6 +368,11 @@ class Manager:
|
||||
# The layout snapshot each screen's sensor already carries, so it is only written when it changes.
|
||||
self.published = {}
|
||||
self._prefix_source, self._prefixes = None, {}
|
||||
# Screens that own their settings (firmware 0.2.49+): {device_id: {key: entity_id}}, per registry.
|
||||
self._settings_source, self._settings_index, self._settings_key = None, {}, None
|
||||
# Answers (firmware 0.2.49+): when to send everything again after one said the screen lacks something,
|
||||
# how often that failed in a row, and actions Home Assistant refused to answer for.
|
||||
self.retry_at, self.retries, self.no_answers = {}, {}, set()
|
||||
if self.path.exists():
|
||||
raw = json.loads(self.path.read_text())
|
||||
# Versioned persistent data. Never silently overwrite an unknown schema.
|
||||
@@ -440,6 +496,161 @@ class Manager:
|
||||
screen = self.screen(inbox) or {}
|
||||
return message_action(screen.get('node')) if self.supports_ping(inbox, screen) else None
|
||||
|
||||
# ----- Screen settings: owned by the screen (firmware 0.2.49+), else stored with the layout -----
|
||||
def setting_index(self):
|
||||
"""{device_id: {key: entity_id}} for every screen that owns its settings; rebuilt per registry."""
|
||||
registry = getattr(self.ha, 'registry', [])
|
||||
if self._settings_source is not registry:
|
||||
by_device = {}
|
||||
for item in registry:
|
||||
if item.get('platform') == 'esphome' and item.get('device_id'):
|
||||
by_device.setdefault(item['device_id'], []).append(item)
|
||||
index = {}
|
||||
for device, items in by_device.items():
|
||||
entities = setting_entities(items)
|
||||
if entities is not None:
|
||||
index[device] = entities
|
||||
self._settings_source, self._settings_index = registry, index
|
||||
return self._settings_index
|
||||
|
||||
def setting_entities(self, screen):
|
||||
"""{key: entity_id} when this screen owns its settings, else None (they travel in the layout message)."""
|
||||
device = (screen or {}).get('device_id')
|
||||
return self.setting_index().get(device) if device else None
|
||||
|
||||
def settings_view(self, screen):
|
||||
"""What the editor shows under Screen settings.
|
||||
|
||||
`owner` is 'screen' when the screen's own entities hold the settings, else 'layout'. `keys` are the
|
||||
settings this screen has, `unavailable` the ones Home Assistant cannot read or change right now (the
|
||||
screen is offline, or the entity is disabled); their value is None, not a default that could differ
|
||||
from what the screen has."""
|
||||
inbox = self.aliases.get(screen['id'], screen['id'])
|
||||
keys = [key for key in SETTING_RULES if key != 'show_clock' and (key != 'rotation' or screen.get('board') == 'guition')]
|
||||
entities = self.setting_entities(screen)
|
||||
if entities is None:
|
||||
try:
|
||||
values = validate_settings(self.layouts.get(inbox, {}).get('settings', {}))
|
||||
except ValueError:
|
||||
values = validate_settings({})
|
||||
if (self.firmware_version(inbox, screen) or (0, 0, 0)) < (0, 2, 44):
|
||||
keys = [key for key in keys if key not in ('auto_home', 'auto_home_seconds')]
|
||||
return {'owner': 'layout', 'values': values, 'keys': keys, 'unavailable': []}
|
||||
# Only the settings this screen has an entity for: one added in later firmware stays out of the panel.
|
||||
keys = [key for key in keys if key in entities]
|
||||
values = {key: setting_from_state(key, self.ha.states.get(entities[key])) for key in keys}
|
||||
return {'owner': 'screen', 'values': values, 'keys': keys, 'unavailable': [key for key in keys if values[key] is None]}
|
||||
|
||||
def settings_states_key(self):
|
||||
"""The states of every setting entity, so the sync loop notices a change the editor should show."""
|
||||
states = self.ha.states
|
||||
return tuple((entity, states.get(entity, {}).get('state')) for device in self.setting_index().values()
|
||||
for entity in device.values())
|
||||
|
||||
def store_settings(self, inbox, settings, screen=None, on_screen=False):
|
||||
"""Settings of a screen that does not own them, kept with its layout.
|
||||
|
||||
`on_screen`: the screen reported them itself. The layout message it holds is then brought in step
|
||||
without sending it back, which could undo a change it made after reporting this one; its revision
|
||||
stays, so the next ping still matches."""
|
||||
base = self.layouts.get(inbox) or {'title': (screen or {}).get('name') or 'Home', 'tiles': []}
|
||||
layout = validate_layout({**base, 'settings': settings})
|
||||
updated = {**self.layouts, inbox: layout}
|
||||
self.write_layouts(updated)
|
||||
self.layouts = updated
|
||||
if on_screen and inbox in self.sent:
|
||||
self.sent[inbox] = {**self.sent[inbox], 'layout': self.layout_message(inbox, layout, screen or self.screen(inbox) or {})}
|
||||
self.notify()
|
||||
|
||||
def screen_setting_event(self, event):
|
||||
"""A screen reported a setting that changed on it (its settings page, one of its entities).
|
||||
|
||||
A screen that owns its settings needs nothing here: its entities carry the change. Older firmware gets
|
||||
it kept with the layout, without the layout being sent back."""
|
||||
inbox = self.aliases.get(event.get('inbox'), event.get('inbox'))
|
||||
if inbox not in self.layouts:
|
||||
return
|
||||
screen = self.screen(inbox)
|
||||
if screen is not None and self.setting_entities(screen) is not None:
|
||||
return
|
||||
key, value = event.get('key'), event.get('value')
|
||||
stored = validate_settings(self.layouts[inbox].get('settings', {}))
|
||||
if key not in stored or key == 'show_clock':
|
||||
return
|
||||
settings = dict(stored)
|
||||
settings[key] = value == '1' if type(stored[key]) is bool else int(value)
|
||||
if key == 'brightness':
|
||||
for dim in ('standby_brightness', 'night_brightness'):
|
||||
settings[dim] = min(settings[dim], settings[key])
|
||||
settings = validate_settings(settings)
|
||||
# A screen reporting what it already has (an automation setting the same value on every light change).
|
||||
if settings != stored:
|
||||
self.store_settings(inbox, settings, screen, on_screen=True)
|
||||
|
||||
async def change_settings(self, inbox, changes):
|
||||
"""Settings from the editor: on the screen itself when it owns them, else kept with its layout, which the
|
||||
sync loop then sends. Returns the settings as the editor shows them afterwards."""
|
||||
inbox = self.aliases.get(inbox, inbox)
|
||||
screen = self.screen(inbox)
|
||||
if screen is None:
|
||||
raise ValueError("This isn't a paired ESP screen. Refresh the overview.")
|
||||
view = self.settings_view(screen)
|
||||
if not isinstance(changes, dict) or not changes or set(changes) - set(view['keys']):
|
||||
raise ValueError('Unknown screen settings; refresh the management page.')
|
||||
if view['owner'] == 'screen' and not screen.get('online'):
|
||||
raise ValueError('This screen is offline. You can change its settings once it is back.')
|
||||
missing = [SETTING_ENTITIES[key][1] for key in changes if key in view['unavailable']]
|
||||
if missing:
|
||||
raise ValueError(f"{', '.join(missing)} can't be changed now: the entity is off in Home Assistant, or the screen is restarting.")
|
||||
# A setting Home Assistant cannot read (its entity is off) is checked at its default.
|
||||
wanted = {**{key: value for key, value in view['values'].items() if value is not None}, **changes}
|
||||
if 'brightness' in changes and type(changes['brightness']) is int:
|
||||
# A lower brightness pulls both dim levels down with it, as on the screen.
|
||||
for dim in ('standby_brightness', 'night_brightness'):
|
||||
if dim not in changes:
|
||||
wanted[dim] = min(wanted.get(dim, SETTING_RULES[dim][0]), changes['brightness'])
|
||||
merged = validate_settings(wanted)
|
||||
if merged['rotation'] and screen.get('board') != 'guition':
|
||||
raise ValueError('Rotation requires a Guition with firmware 0.2.9 or newer.')
|
||||
if view['owner'] == 'layout':
|
||||
self.store_settings(inbox, merged, screen)
|
||||
self.ha.changed.set()
|
||||
return self.settings_view(screen)
|
||||
entities = self.setting_entities(screen)
|
||||
# Brightness first: the screen keeps both dim levels at or below it.
|
||||
for key in sorted(changes, key=lambda key: (key != 'brightness', list(SETTING_RULES).index(key))):
|
||||
if merged[key] != view['values'][key]:
|
||||
await self.ha.call(*setting_action(key, entities[key], merged[key]))
|
||||
return self.settings_view(screen)
|
||||
|
||||
# ----- Answers from the screen (firmware 0.2.49+) -----
|
||||
def answers(self, inbox, screen):
|
||||
"""True when the screen's message action can answer, so a ping learns at once what the screen holds."""
|
||||
action = self.transport(inbox, screen)
|
||||
return bool(action) and action in getattr(self.ha, 'responses', ()) and action not in self.no_answers
|
||||
|
||||
def answered(self, inbox, screen, answer):
|
||||
"""Act on a screen's answer to a ping: everything again when it lacks the layout or a tile, or refused one."""
|
||||
status = answer.get('status') if isinstance(answer, dict) else None
|
||||
if not isinstance(status, str):
|
||||
return
|
||||
if status not in RESEND_STATES and not status.startswith('Error'):
|
||||
self.retries.pop(inbox, None)
|
||||
return
|
||||
failures = self.retries.get(inbox, 0)
|
||||
due = self.last.get(inbox, 0) + min(RESEND_GUARD_SECONDS, ANSWER_RETRY_SECONDS * 2 ** failures)
|
||||
name = (screen or {}).get('name') or inbox
|
||||
if time.monotonic() >= due:
|
||||
LOG.info('%s answered "%s"; sending everything again', name, status)
|
||||
self.retries[inbox] = failures + 1
|
||||
self.retry_at.pop(inbox, None)
|
||||
self.sent.pop(inbox, None)
|
||||
self.ha.changed.set()
|
||||
elif inbox not in self.retry_at:
|
||||
LOG.info('%s answered "%s"; sending everything again in %d s', name, status, max(1, round(due - time.monotonic())))
|
||||
self.retries[inbox] = failures + 1
|
||||
self.retry_at[inbox] = due
|
||||
|
||||
def registry_index(self):
|
||||
"""Entity registry by id (display precision, entity category); rebuilt only when HA delivers a new registry."""
|
||||
registry = getattr(self.ha, 'registry', [])
|
||||
@@ -487,6 +698,11 @@ class Manager:
|
||||
needed = self.needs_firmware(inbox, layout, screen)
|
||||
if needed:
|
||||
raise ValueError(f"Install screen firmware {needed} or newer first for these tiles.")
|
||||
# Settings change through their own call (change_settings). A page from before app 0.2.57 still sends them
|
||||
# with the tiles: a screen that owns its settings ignores them, so a stale form never undoes a change
|
||||
# made on the screen; other screens keep taking them, as before.
|
||||
if self.setting_entities(screen) is not None:
|
||||
layout.pop('settings', None)
|
||||
# A still-open older UI may save tiles without the new optional settings or top bar.
|
||||
if 'settings' not in layout and 'settings' in self.layouts.get(inbox, {}):
|
||||
layout['settings'] = self.layouts[inbox]['settings'].copy()
|
||||
@@ -556,6 +772,7 @@ class Manager:
|
||||
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)}
|
||||
watched |= {eid for device in self.setting_index().values() for eid in device.values()}
|
||||
self._watched_key, self._watched = key, watched
|
||||
return set(self._watched)
|
||||
|
||||
@@ -576,9 +793,15 @@ class Manager:
|
||||
"""The state message of one tile: state, options, extras, and the history the background task holds."""
|
||||
forecast=hourly=None
|
||||
if tile['entity'].startswith('weather.') and hasattr(self.ha,'forecast'):
|
||||
forecast=await self.cached(self.forecasts, tile['entity'], FORECAST_SECONDS, lambda: self.ha.forecast(tile['entity']))
|
||||
entity = tile['entity']
|
||||
# Only the forecasts the entity offers: asking Buienradar for hourly ones logged an error in Home
|
||||
# Assistant every half hour. What it lacks is kept as empty, so the cache still counts its age.
|
||||
kinds = forecast_kinds(self.ha.states.get(entity, {}).get('attributes', {}))
|
||||
async def nothing():
|
||||
return []
|
||||
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, (tile['entity'],'hourly'), FORECAST_SECONDS, lambda: self.ha.forecast(tile['entity'],'hourly'))
|
||||
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
|
||||
message=state_message(index,tile,self.ha.states,extras(tile,self.ha.states,forecast,getattr(self.ha,'time_zone',None),hourly,device=device))
|
||||
@@ -598,7 +821,8 @@ class Manager:
|
||||
'slots': [t['slot'] for t in tiles], 'keepalive': KEEPALIVE_SECONDS}
|
||||
if 'pages' in layout:
|
||||
message['pages'] = layout['pages']
|
||||
if 'settings' in layout:
|
||||
# A screen that owns its settings (firmware 0.2.49+) gets none: they would overwrite what changed on it.
|
||||
if 'settings' in layout and self.setting_entities(screen) is None:
|
||||
# `settings` is the fixed eleven-key block older firmware insists on; everything added
|
||||
# later travels as its own key, which firmware that predates it simply ignores.
|
||||
message['settings'] = {k:v for k,v in layout['settings'].items() if k not in SETTINGS_BESIDE_BLOCK}
|
||||
@@ -656,7 +880,11 @@ class Manager:
|
||||
self.sent.pop(inbox, None)
|
||||
return True
|
||||
await self.ha.send(inbox, message, action)
|
||||
self.sent[inbox] = {'layout': layout_msg, 'header': header_msg, 'states': states, 'rev': layout_msg['rev']}
|
||||
# `rev` is the revision of the layout message the screen holds: a layout brought in step without being
|
||||
# sent (settings the screen reported itself, see store_settings) keeps the one it had.
|
||||
sent_layout = any(message is layout_msg for message in outgoing)
|
||||
rev = layout_msg['rev'] if sent_layout or not previous else previous.get('rev', layout_msg['rev'])
|
||||
self.sent[inbox] = {'layout': layout_msg, 'header': header_msg, 'states': states, 'rev': rev}
|
||||
# The hourly timer follows a real full transmission; any message refreshes the screen's
|
||||
# feed window, so the ping timer follows whatever went out (a rebuild without differences,
|
||||
# after a registry refresh, must not postpone the ping).
|
||||
@@ -665,15 +893,40 @@ class Manager:
|
||||
if outgoing:
|
||||
self.pinged[inbox] = time.monotonic()
|
||||
self.status[inbox] = 'Sent to Home Assistant'
|
||||
# A screen that answers confirms a new layout at once: a ping after the batch says whether it holds
|
||||
# the layout and every tile, or which part is missing (firmware 0.2.49+).
|
||||
if sent_layout and self.answers(inbox, screen):
|
||||
await self.ping(inbox, screen)
|
||||
return bool(outgoing)
|
||||
|
||||
async def ping(self, inbox, screen):
|
||||
"""Keepalive for firmware 0.2.33+: the layout revision only; the screen asks for the rest itself."""
|
||||
"""Keepalive for firmware 0.2.33+: the layout revision only; the screen asks for the rest itself. A screen
|
||||
that can answer (firmware 0.2.49+) is asked to, so a missing layout or tile is sent again right away."""
|
||||
sent = self.sent.get(inbox)
|
||||
if not sent:
|
||||
return
|
||||
await self.ha.send(inbox, {'v': 1, 'op': 'ping', 'rev': sent['rev'], 'keepalive': KEEPALIVE_SECONDS}, self.transport(inbox, screen))
|
||||
message = {'v': 1, 'op': 'ping', 'rev': sent['rev'], 'keepalive': KEEPALIVE_SECONDS}
|
||||
action = self.transport(inbox, screen)
|
||||
if not self.answers(inbox, screen):
|
||||
await self.ha.send(inbox, message, action)
|
||||
self.pinged[inbox] = time.monotonic()
|
||||
return
|
||||
try:
|
||||
answer = await self.ha.send(inbox, message, action, respond=True)
|
||||
except TimeoutError:
|
||||
# Busy or gone: the text inbox and the next ping still tell.
|
||||
LOG.info('%s did not answer its ping within %d s', (screen or {}).get('name') or inbox, ANSWER_TIMEOUT_SECONDS)
|
||||
answer = None
|
||||
except Refused as error:
|
||||
if 'response' not in error.detail.lower():
|
||||
raise
|
||||
# Home Assistant listed an answer but will not give one: ask this screen no more, ping it as before.
|
||||
LOG.warning('Home Assistant gives no answer for %s (%s); pinging it without', action, error.detail)
|
||||
self.no_answers.add(action)
|
||||
await self.ha.send(inbox, message, action)
|
||||
answer = None
|
||||
self.pinged[inbox] = time.monotonic()
|
||||
self.answered(inbox, screen, answer)
|
||||
|
||||
def take_dirty(self):
|
||||
"""Entity ids that changed since the last pass, or None when everything must be rebuilt."""
|
||||
@@ -698,30 +951,26 @@ class Manager:
|
||||
await asyncio.sleep(0.25)
|
||||
if not self.ha.online:
|
||||
self.sent.clear()
|
||||
# States made over the REST API are gone once Home Assistant restarts: publish them again.
|
||||
self.published.clear()
|
||||
self.notify()
|
||||
continue
|
||||
for event in getattr(self.ha,'setting_events',[])[:]:
|
||||
try:
|
||||
inbox,key,value=event.get('inbox'),event.get('key'),event.get('value')
|
||||
if inbox in self.layouts:
|
||||
layout=dict(self.layouts[inbox]); stored=validate_settings(layout.get('settings',{})); settings=dict(stored)
|
||||
if key not in settings: continue
|
||||
settings[key] = value=='1' if type(settings[key]) is bool else int(value)
|
||||
if key=='brightness':
|
||||
settings['standby_brightness']=min(settings['standby_brightness'],settings[key])
|
||||
settings['night_brightness']=min(settings['night_brightness'],settings[key])
|
||||
settings=validate_settings(settings)
|
||||
# A screen reporting what it already has (an automation setting the same value on
|
||||
# every light change): saving would resend the whole screen while someone uses it.
|
||||
if settings==stored: continue
|
||||
layout['settings']=settings;self.save(inbox,layout)
|
||||
except (ValueError,TypeError): pass
|
||||
self.screen_setting_event(event)
|
||||
except (ValueError, TypeError, AttributeError) as error:
|
||||
LOG.warning('A setting %s reported by %s was not kept (%s)', event.get('key') if isinstance(event, dict) else '?',
|
||||
event.get('inbox') if isinstance(event, dict) else '?', error)
|
||||
if hasattr(self.ha,'setting_events'): self.ha.setting_events.clear()
|
||||
dirty = self.take_dirty()
|
||||
screens_key = self._screens_key
|
||||
screens = self.screens()
|
||||
changed = self._screens_key != screens_key
|
||||
self.ha.relevant = self.watched_entities()
|
||||
# A setting changed on a screen that owns them: the editor shows it.
|
||||
settings_key = self.settings_states_key()
|
||||
if settings_key != self._settings_key:
|
||||
self._settings_key, changed = settings_key, True
|
||||
for screen in screens:
|
||||
inbox = screen['id']
|
||||
before = self.status.get(inbox)
|
||||
@@ -738,6 +987,10 @@ class Manager:
|
||||
# The screen says it lost the layout (restart, mismatched ping, a state that never came).
|
||||
if inbox in self.sent and screen['status'] in RESEND_STATES and now - self.last.get(inbox, 0) >= RESEND_GUARD_SECONDS:
|
||||
self.sent.pop(inbox)
|
||||
# An answer said so right after a full send; its wait is over (firmware 0.2.49+).
|
||||
if inbox in self.retry_at and now >= self.retry_at[inbox]:
|
||||
del self.retry_at[inbox]
|
||||
self.sent.pop(inbox, None)
|
||||
force = now - self.last.get(inbox, 0) >= (FULL_REPEAT_SECONDS if pings else KEEPALIVE_SECONDS)
|
||||
if await self.sync_one(inbox, self.layouts[inbox], force, screen, dirty):
|
||||
changed = True
|
||||
@@ -914,6 +1167,7 @@ def create_app(manager, development=False):
|
||||
profiles = manager.firmware.profile_names()
|
||||
for screen in screens:
|
||||
screen['layout'] = manager.layouts.get(screen['id'], {'title': 'Home', 'tiles': []})
|
||||
screen['settings'] = manager.settings_view(screen)
|
||||
screen['delivery'] = manager.status.get(screen['id'], 'Choose your first tiles')
|
||||
screen['update'] = manager.updates.state_for(screen, profiles)
|
||||
screen['alert_action'] = alert_service(screen.get('node'))
|
||||
@@ -971,6 +1225,16 @@ def create_app(manager, development=False):
|
||||
async def save(request):
|
||||
manager.save(request.match_info['inbox'], await request.json())
|
||||
return web.json_response({'saved': True})
|
||||
async def change_settings(request):
|
||||
"""Screen settings apply one change at a time, like the settings page on the screen (app 0.2.57)."""
|
||||
data = await request.json()
|
||||
try:
|
||||
view = await manager.change_settings(request.match_info['inbox'], data.get('settings') if isinstance(data, dict) else None)
|
||||
except Refused as error:
|
||||
raise ValueError(f"Home Assistant didn't take the change: {error.detail or 'no reason given'}.") from error
|
||||
except (ConnectionError, TimeoutError) as error:
|
||||
raise ValueError("Home Assistant isn't reachable right now. Try again in a moment.") from error
|
||||
return web.json_response(view)
|
||||
async def update_screen(request):
|
||||
data = await request.json() if request.can_read_body else {}
|
||||
return web.json_response(manager.updates.start(request.match_info['inbox'], data.get('host')))
|
||||
@@ -1021,6 +1285,7 @@ def create_app(manager, development=False):
|
||||
app.router.add_get('/api/claude-skill.zip', download_claude_skill)
|
||||
app.router.add_get('/api/events', events)
|
||||
app.router.add_put('/api/screens/{inbox}', save)
|
||||
app.router.add_put('/api/screens/{inbox}/settings', change_settings)
|
||||
app.router.add_static('/static/', static)
|
||||
return app
|
||||
|
||||
|
||||
+279
-112
@@ -58,6 +58,10 @@ function select(id) {
|
||||
)
|
||||
)
|
||||
return;
|
||||
if (id !== selected) {
|
||||
flushSettings();
|
||||
settingEdits = {};
|
||||
}
|
||||
selected = id;
|
||||
selectedTile = null;
|
||||
if ($("#tile-sheet").open) $("#tile-sheet").close();
|
||||
@@ -80,106 +84,272 @@ function select(id) {
|
||||
renderSettings();
|
||||
loadTopbarPreview(0);
|
||||
}
|
||||
const settingDefinitions = [
|
||||
["standby_enabled", "Auto standby", "check", true],
|
||||
["standby_seconds", "Standby after", "minutes", 600, 1, 1440],
|
||||
["brightness", "Normal brightness", "range", 100, 5, 100],
|
||||
["standby_brightness", "Standby brightness", "range", 20, 0, 100],
|
||||
["night_enabled", "Use night mode", "check", true],
|
||||
["night_brightness", "Night brightness", "range", 10, 0, 100],
|
||||
["night_start", "Night starts", "time", 1320],
|
||||
["night_end", "Night ends", "time", 420],
|
||||
// Whether the clock shows is up to the top bar now; the add-on keeps show_clock in step for older firmware.
|
||||
["clock_24h", "24-hour clock (off = 12 hour)", "check", true],
|
||||
["home_on_standby", "Return to page 1 after standby", "check", false],
|
||||
["auto_home", "Close cards and return to page 1 by itself (firmware 0.2.44+)", "check", true],
|
||||
["auto_home_seconds", "Return to page 1 after", "minutes", 120, 1, 60],
|
||||
["rotation", "Rotate screen (clockwise)", "rotation", 0],
|
||||
["swipe_pages", "Swipe between pages (Guition: from the edge, firmware 0.2.24+; CYD: quick swipe, 0.2.7+)", "check", false],
|
||||
// ----- Screen settings: the same groups and rows as the settings page on the screen itself -----
|
||||
// Every change applies at once, like on the screen; no Save needed. A screen with firmware 0.2.49+ owns its
|
||||
// settings and ESP Screens changes them through its entities in Home Assistant, so an automation, the page on
|
||||
// the screen and this panel always show the same value. Older firmware gets them with its layout.
|
||||
const SETTING_GROUPS = [
|
||||
{ title: "Brightness", icon: "F0599", rows: [
|
||||
{ key: "brightness", label: "Brightness", kind: "number", min: 5, max: 100, step: 5, unit: "%" },
|
||||
{ key: "standby_enabled", label: "Auto standby", kind: "toggle" },
|
||||
{ key: "standby_seconds", label: "Standby after", kind: "duration", min: 60, max: 86400, needs: "standby_enabled" },
|
||||
{ key: "standby_brightness", label: "Standby brightness", kind: "number", min: 0, max: 100, step: 5, unit: "%", needs: "standby_enabled", cap: "brightness" },
|
||||
] },
|
||||
{ title: "Night", icon: "F0594", rows: [
|
||||
{ key: "night_enabled", label: "Night mode", kind: "toggle" },
|
||||
{ key: "night_start", label: "Starts", kind: "moment", needs: "night_enabled" },
|
||||
{ key: "night_end", label: "Ends", kind: "moment", needs: "night_enabled" },
|
||||
{ key: "night_brightness", label: "Night brightness", kind: "number", min: 0, max: 100, step: 5, unit: "%", needs: "night_enabled", cap: "brightness" },
|
||||
] },
|
||||
{ title: "Screen", icon: "F0379", rows: [
|
||||
{ key: "clock_24h", label: "Clock", kind: "choice", options: [[false, "12 hour"], [true, "24 hour"]] },
|
||||
{ key: "auto_home", label: "Back to page 1", kind: "toggle" },
|
||||
{ key: "auto_home_seconds", label: "After", kind: "duration", min: 30, max: 3600, needs: "auto_home" },
|
||||
{ key: "home_on_standby", label: "Also on standby", kind: "toggle" },
|
||||
{ key: "swipe_pages", label: "Swipe between pages", kind: "toggle" },
|
||||
{ key: "rotation", label: "Rotation", kind: "choice", options: [[0, "0°"], [90, "90°"], [180, "180°"], [270, "270°"]] },
|
||||
] },
|
||||
];
|
||||
function renderSettings() {
|
||||
const values = {
|
||||
...Object.fromEntries(
|
||||
settingDefinitions.map(([key, , , value]) => [key, value]),
|
||||
),
|
||||
...layout.settings,
|
||||
};
|
||||
const container = $("#settings-fields");
|
||||
container.replaceChildren();
|
||||
for (const [key, title, kind, , min, max] of settingDefinitions) {
|
||||
if(key === "rotation" && inventory.screens.find(s => s.id === selected)?.board !== "guition") continue;
|
||||
const label = node(
|
||||
"label",
|
||||
undefined,
|
||||
`setting ${kind === "check" ? "setting-check" : ""}`,
|
||||
);
|
||||
const caption = node("span", title),
|
||||
input = node(kind === "rotation" ? "select" : "input"),
|
||||
output = node("output");
|
||||
input.id = `setting-${key}`;
|
||||
if(kind !== "rotation") input.type =
|
||||
kind === "check" ? "checkbox" : kind === "minutes" ? "number" : kind;
|
||||
input.setAttribute("aria-label", title);
|
||||
input.required = kind === "minutes" || kind === "time";
|
||||
if(kind === "rotation") {
|
||||
for(const angle of [0,90,180,270]) { const option=node("option", `${angle}°`); option.value=angle; input.append(option); }
|
||||
input.value=values[key];
|
||||
}
|
||||
else if (kind === "check") input.checked = values[key];
|
||||
else if (kind === "time")
|
||||
input.value = `${String(Math.floor(values[key] / 60)).padStart(2, "0")}:${String(values[key] % 60).padStart(2, "0")}`;
|
||||
else {
|
||||
input.min = min;
|
||||
input.max = max;
|
||||
input.step = 1;
|
||||
input.value = kind === "minutes" ? values[key] / 60 : values[key];
|
||||
}
|
||||
const updateOutput = () =>
|
||||
(output.textContent =
|
||||
kind === "range"
|
||||
? `${input.value}%`
|
||||
: kind === "minutes"
|
||||
? "minutes"
|
||||
: "");
|
||||
updateOutput();
|
||||
input.oninput = () => {
|
||||
if (!input.checkValidity() || input.value === "") return;
|
||||
let value = kind === "check" ? input.checked : Number(input.value);
|
||||
if (kind === "time") {
|
||||
const [h, m] = input.value.split(":").map(Number);
|
||||
value = h * 60 + m;
|
||||
}
|
||||
if (kind === "minutes") value *= 60;
|
||||
layout.settings = { ...values, ...layout.settings, [key]: value };
|
||||
// A lower normal brightness also lowers any higher standby levels.
|
||||
if (key === "brightness")
|
||||
for (const dim of ["standby_brightness", "night_brightness"]) {
|
||||
layout.settings[dim] = Math.min(layout.settings[dim], value);
|
||||
const other = $(`#setting-${dim}`);
|
||||
other.value = layout.settings[dim];
|
||||
other.parentElement.querySelector("output").textContent =
|
||||
`${other.value}%`;
|
||||
}
|
||||
updateOutput();
|
||||
markDirty();
|
||||
};
|
||||
label.append(caption, input, output);
|
||||
container.append(label);
|
||||
}
|
||||
renderSettingsSupport();
|
||||
const SETTING_ROWS = Object.fromEntries(SETTING_GROUPS.flatMap((group) => group.rows.map((row) => [row.key, row])));
|
||||
// Changes made here that the screen has not reported back yet: {key: {value, at}}. They win over what
|
||||
// Home Assistant still shows for a few seconds, so a value never flicks back while it travels.
|
||||
let settingEdits = {}, settingQueue = {}, settingTarget = null, settingTimer = null, settingFlight = null, settingPanel = { key: "", rows: {} };
|
||||
const SETTING_EDIT_MS = 4000;
|
||||
const settingsView = () => inventory.screens.find((s) => s.id === selected)?.settings;
|
||||
function settingValues() {
|
||||
const view = settingsView(), values = { ...(view?.values || {}) };
|
||||
for (const [key, edit] of Object.entries(settingEdits)) values[key] = edit.value;
|
||||
return values;
|
||||
}
|
||||
function renderSettingsSupport() {
|
||||
const version =
|
||||
inventory.screens.find((s) => s.id === selected)?.firmware || "";
|
||||
const parts = /^([0-9]+)\.([0-9]+)\.([0-9]+)$/.exec(version);
|
||||
const supported =
|
||||
parts &&
|
||||
(Number(parts[1]) > 0 ||
|
||||
Number(parts[2]) > 1 ||
|
||||
(Number(parts[2]) === 1 && Number(parts[3]) >= 2));
|
||||
$("#settings-support").textContent = supported
|
||||
? "Saving sends your settings straight to this screen. They're also kept after a restart."
|
||||
: "Install firmware 0.1.2 or newer once via ESPHome. You can save the settings now; older firmware doesn't use them yet.";
|
||||
// The same steps as settings_screen.h: seconds low down, quarters of an hour up top; times by the quarter,
|
||||
// whole hours while held.
|
||||
const ladderStep = (seconds) => (seconds < 300 ? 30 : seconds < 900 ? 60 : seconds < 3600 ? 300 : seconds < 7200 ? 900 : 1800);
|
||||
function steppedSetting(row, value, direction, held, values) {
|
||||
if (row.kind === "moment") {
|
||||
let next = held && value % 60 ? Math.floor(value / 60) * 60 + (direction > 0 ? 60 : 0) : value + direction * (held ? 60 : 15);
|
||||
next %= 1440;
|
||||
return next < 0 ? next + 1440 : next;
|
||||
}
|
||||
const step = row.kind === "duration" ? ladderStep(direction < 0 ? value - 1 : value) : row.step;
|
||||
const max = row.cap ? Math.min(row.max, values[row.cap]) : row.max;
|
||||
return Math.min(max, Math.max(row.min, value + direction * step));
|
||||
}
|
||||
function durationText(seconds) {
|
||||
if (seconds < 60) return `${seconds} sec`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)} min`;
|
||||
const hours = Math.floor(seconds / 3600), minutes = Math.floor((seconds % 3600) / 60);
|
||||
return minutes ? `${hours} h ${String(minutes).padStart(2, "0")}` : `${hours} h`;
|
||||
}
|
||||
function momentText(minutes, clock24) {
|
||||
const hour = Math.floor(minutes / 60), minute = String(minutes % 60).padStart(2, "0");
|
||||
return clock24 ? `${String(hour).padStart(2, "0")}:${minute}` : `${hour % 12 || 12}:${minute} ${hour < 12 ? "AM" : "PM"}`;
|
||||
}
|
||||
function settingText(row, values) {
|
||||
const value = values[row.key];
|
||||
// Home Assistant has no value while the screen is offline or the entity is off.
|
||||
if (value === null || value === undefined) return "—";
|
||||
if (row.kind === "number") return `${value}${row.unit || ""}`;
|
||||
if (row.kind === "duration") return durationText(value);
|
||||
if (row.kind === "moment") return momentText(value, values.clock_24h !== false);
|
||||
return "";
|
||||
}
|
||||
// A key held down steps again and again, faster after a moment, like the -/+ keys on the screen.
|
||||
function holdable(button, step) {
|
||||
let timer = null, repeats = 0, held = false;
|
||||
const stop = () => { clearTimeout(timer); timer = null; };
|
||||
button.addEventListener("pointerdown", (event) => {
|
||||
if (button.disabled || event.button !== 0) return;
|
||||
held = false;
|
||||
repeats = 0;
|
||||
stop();
|
||||
timer = setTimeout(function repeat() {
|
||||
held = true;
|
||||
repeats += 1;
|
||||
step(repeats > 5);
|
||||
timer = setTimeout(repeat, 180);
|
||||
}, 450);
|
||||
});
|
||||
for (const type of ["pointerup", "pointercancel", "pointerleave"]) button.addEventListener(type, stop);
|
||||
button.addEventListener("click", () => {
|
||||
if (held) { held = false; return; }
|
||||
step(false);
|
||||
});
|
||||
}
|
||||
function settingControl(row) {
|
||||
const refs = { row };
|
||||
const line = node("div", undefined, `setting-row setting-${row.kind}`);
|
||||
line.dataset.setting = row.key;
|
||||
const label = node("span", row.label, "setting-label");
|
||||
label.id = `setting-label-${row.key}`;
|
||||
const control = node("div", undefined, "setting-control");
|
||||
if (row.kind === "toggle") {
|
||||
const toggle = node("button", undefined, "switch");
|
||||
toggle.type = "button";
|
||||
toggle.id = `setting-${row.key}`;
|
||||
toggle.setAttribute("role", "switch");
|
||||
toggle.setAttribute("aria-labelledby", label.id);
|
||||
toggle.onclick = () => setSetting(row.key, !settingValues()[row.key], 150);
|
||||
// The whole row answers a click, as on the screen.
|
||||
line.onclick = (event) => { if (event.target === line || event.target === label) toggle.click(); };
|
||||
control.append(toggle);
|
||||
refs.toggle = toggle;
|
||||
} else if (row.kind === "choice") {
|
||||
const group = node("div", undefined, "segmented setting-choices");
|
||||
group.setAttribute("role", "group");
|
||||
group.setAttribute("aria-labelledby", label.id);
|
||||
refs.chips = row.options.map(([value, text]) => {
|
||||
const chip = node("button", text);
|
||||
chip.type = "button";
|
||||
chip.onclick = () => setSetting(row.key, value, 150);
|
||||
group.append(chip);
|
||||
return [value, chip];
|
||||
});
|
||||
control.append(group);
|
||||
} else {
|
||||
const minus = node("button", undefined, "step");
|
||||
const plus = node("button", undefined, "step");
|
||||
minus.type = plus.type = "button";
|
||||
minus.append(node("span", glyph("F0374"), "mdi"));
|
||||
plus.append(node("span", glyph("F0415"), "mdi"));
|
||||
minus.setAttribute("aria-label", `${row.label} lower`);
|
||||
plus.setAttribute("aria-label", `${row.label} higher`);
|
||||
const value = node("output", undefined, "setting-value");
|
||||
value.setAttribute("aria-labelledby", label.id);
|
||||
value.id = `setting-${row.key}`;
|
||||
for (const [button, direction] of [[minus, -1], [plus, 1]]) {
|
||||
holdable(button, (held) => {
|
||||
const values = settingValues();
|
||||
const next = steppedSetting(row, values[row.key], direction, held, values);
|
||||
if (next !== values[row.key]) setSetting(row.key, next, 600);
|
||||
});
|
||||
}
|
||||
control.append(minus, value, plus);
|
||||
Object.assign(refs, { minus, plus, value });
|
||||
}
|
||||
line.append(label, control);
|
||||
refs.line = line;
|
||||
return refs;
|
||||
}
|
||||
function renderSettings() {
|
||||
const container = $("#settings-groups");
|
||||
const screen = inventory.screens.find((s) => s.id === selected), view = screen?.settings;
|
||||
if (!view) {
|
||||
container.replaceChildren();
|
||||
settingPanel = { key: "", rows: {} };
|
||||
$("#settings-status").textContent = "";
|
||||
return;
|
||||
}
|
||||
// Rebuild only when the rows change (another screen, board or firmware); otherwise update in place, so a
|
||||
// key held down keeps its grip while the values come back.
|
||||
const shape = `${screen.id}|${view.keys.join(",")}`;
|
||||
if (settingPanel.key !== shape) {
|
||||
const rows = {};
|
||||
container.replaceChildren(...SETTING_GROUPS.map((group) => {
|
||||
const shown = group.rows.filter((row) => view.keys.includes(row.key));
|
||||
const card = node("section", undefined, "settings-card");
|
||||
card.hidden = !shown.length;
|
||||
const head = node("h4");
|
||||
head.append(node("span", glyph(group.icon), "mdi"), node("span", group.title));
|
||||
card.append(head);
|
||||
for (const row of shown) {
|
||||
rows[row.key] = settingControl(row);
|
||||
card.append(rows[row.key].line);
|
||||
}
|
||||
return card;
|
||||
}));
|
||||
settingPanel = { key: shape, rows };
|
||||
}
|
||||
const values = settingValues();
|
||||
const offline = view.owner === "screen" && !screen.online;
|
||||
for (const [key, refs] of Object.entries(settingPanel.rows)) {
|
||||
const row = refs.row, needs = row.needs ? values[row.needs] : true;
|
||||
const unavailable = offline || view.unavailable.includes(key);
|
||||
refs.line.classList.toggle("inactive", !needs || unavailable);
|
||||
refs.line.title = unavailable && !offline ? "This entity is off in Home Assistant, or the screen is restarting." : "";
|
||||
if (refs.toggle) {
|
||||
refs.toggle.setAttribute("aria-checked", String(Boolean(values[key])));
|
||||
refs.toggle.classList.toggle("unknown", values[key] === null || values[key] === undefined);
|
||||
refs.toggle.disabled = unavailable;
|
||||
} else if (refs.chips) {
|
||||
for (const [value, chip] of refs.chips) {
|
||||
chip.setAttribute("aria-pressed", String(values[key] === value));
|
||||
chip.disabled = unavailable;
|
||||
}
|
||||
} else {
|
||||
refs.value.textContent = settingText(row, values);
|
||||
const lower = steppedSetting(row, values[key], -1, false, values), higher = steppedSetting(row, values[key], 1, false, values);
|
||||
refs.minus.disabled = unavailable || !needs || (row.kind !== "moment" && lower === values[key]);
|
||||
refs.plus.disabled = unavailable || !needs || (row.kind !== "moment" && higher === values[key]);
|
||||
}
|
||||
}
|
||||
const pending = Object.keys(settingQueue).length || settingFlight;
|
||||
$("#settings-status").textContent = offline
|
||||
? "This screen is offline. Its settings can change once it's back."
|
||||
: pending
|
||||
? "Saving…"
|
||||
: view.owner === "screen"
|
||||
? "Changes apply on the screen at once, and show up here when they change there."
|
||||
: "Changes apply at once. Firmware 0.2.49 lets the screen keep them itself.";
|
||||
$("#general-settings").classList.toggle("offline", offline);
|
||||
}
|
||||
function setSetting(key, value, delay) {
|
||||
// One screen's changes at a time: the ones for the screen shown before go out first.
|
||||
if (settingTarget && settingTarget !== selected && Object.keys(settingQueue).length) {
|
||||
flushSettings();
|
||||
toast("Still saving the other screen's settings. Try again in a moment.");
|
||||
return;
|
||||
}
|
||||
settingTarget = selected;
|
||||
const values = settingValues();
|
||||
settingEdits[key] = { value, at: Date.now() };
|
||||
settingQueue[key] = value;
|
||||
// A lower brightness pulls both dim levels down with it, as on the screen.
|
||||
if (key === "brightness")
|
||||
for (const dim of ["standby_brightness", "night_brightness"])
|
||||
if (values[dim] > value) settingEdits[dim] = { value, at: Date.now() };
|
||||
renderSettings();
|
||||
if (key === "clock_24h") { renderTopbar(); renderBars(); if ($("#topbar-sheet").open) renderTopbarLive(); }
|
||||
clearTimeout(settingTimer);
|
||||
settingTimer = setTimeout(flushSettings, delay);
|
||||
}
|
||||
async function flushSettings(unloading = false) {
|
||||
clearTimeout(settingTimer);
|
||||
if (settingFlight || !Object.keys(settingQueue).length || !settingTarget) return;
|
||||
const screen = settingTarget, changes = settingQueue;
|
||||
settingQueue = {};
|
||||
const request = api(`screens/${encodeURIComponent(screen)}/settings`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ settings: changes }),
|
||||
keepalive: unloading,
|
||||
});
|
||||
settingFlight = request;
|
||||
renderSettings();
|
||||
try {
|
||||
const view = await (await request).json();
|
||||
const current = inventory.screens.find((s) => s.id === screen);
|
||||
if (current) current.settings = view;
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
// What did not arrive is not kept: the panel shows the screen's own values again.
|
||||
if (screen === selected) for (const key of Object.keys(changes)) delete settingEdits[key];
|
||||
if (screen === selected && changes.brightness !== undefined) for (const dim of ["standby_brightness", "night_brightness"]) delete settingEdits[dim];
|
||||
} finally {
|
||||
settingFlight = null;
|
||||
if (Object.keys(settingQueue).length) settingTimer = setTimeout(flushSettings, 150);
|
||||
else settingTarget = null;
|
||||
if (screen === selected) { settleSettings(); renderSettings(); }
|
||||
// A value the screen refused or clamped comes back without a live update: look again once edits expire.
|
||||
setTimeout(() => { if (screen === selected) { settleSettings(); renderSettings(); } }, SETTING_EDIT_MS + 100);
|
||||
}
|
||||
}
|
||||
// Values Home Assistant reports take over again once they match a change made here, or after a few seconds
|
||||
// (the screen refused or clamped it).
|
||||
function settleSettings() {
|
||||
const view = settingsView();
|
||||
for (const [key, edit] of Object.entries(settingEdits)) {
|
||||
if (settingQueue[key] !== undefined || settingFlight) continue;
|
||||
if ((view && view.values[key] === edit.value) || Date.now() - edit.at > SETTING_EDIT_MS) delete settingEdits[key];
|
||||
}
|
||||
}
|
||||
const updating = new Set();
|
||||
const PHASES = {
|
||||
@@ -1083,7 +1253,7 @@ function loadTopbarPreview(delay = 150) {
|
||||
function clockText(now = new Date()) {
|
||||
let hours = now.getHours();
|
||||
// The screen formats with %I:%M when the 24-hour clock is off.
|
||||
if (!(layout.settings?.clock_24h ?? true)) hours = hours % 12 || 12;
|
||||
if (settingValues().clock_24h === false) hours = hours % 12 || 12;
|
||||
return `${String(hours).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||
}
|
||||
const dateText = (now = new Date()) => `${WEEKDAYS[now.getDay()]} ${now.getDate()} ${MONTHS[now.getMonth()]}`;
|
||||
@@ -1479,13 +1649,9 @@ function renderTopbarSheet() {
|
||||
live.id = "topbar-live";
|
||||
body.append(live);
|
||||
if (item.type !== "date") {
|
||||
const format = segmented([["24", "24 hour"], ["12", "12 hour"]], (layout.settings?.clock_24h ?? true) ? "24" : "12", (v) => {
|
||||
layout.settings = { ...Object.fromEntries(settingDefinitions.map(([key, , , value]) => [key, value])), ...layout.settings, clock_24h: v === "24" };
|
||||
markDirty();
|
||||
renderSettings();
|
||||
renderTopbar();
|
||||
renderBars();
|
||||
renderTopbarLive();
|
||||
// The screen's own clock setting: it applies at once, like the Clock row under Screen settings.
|
||||
const format = segmented([["24", "24 hour"], ["12", "12 hour"]], settingValues().clock_24h !== false ? "24" : "12", (v) => {
|
||||
setSetting("clock_24h", v === "24", 150);
|
||||
});
|
||||
const formatField = group("Format", format);
|
||||
formatField.append(node("small", "Applies to every clock on this screen, including the clock tiles.", "field-hint"));
|
||||
@@ -1731,7 +1897,7 @@ async function refresh(full = true) {
|
||||
$("#connection").classList.toggle("online", inventory.connected);
|
||||
renderScreens();
|
||||
if (full) renderClaude();
|
||||
if (selected) renderSettingsSupport();
|
||||
if (selected) { settleSettings(); renderSettings(); }
|
||||
if (!selected && inventory.screens.length) select(inventory.screens[0].id);
|
||||
if ($("#alerts-dialog").open) renderAlertScreens();
|
||||
} catch {
|
||||
@@ -1741,16 +1907,15 @@ async function refresh(full = true) {
|
||||
}
|
||||
$("#save").onclick = async () => {
|
||||
if (busy) return;
|
||||
for (const input of document.querySelectorAll("#settings-fields input")) {
|
||||
if (!input.reportValidity()) return;
|
||||
}
|
||||
busy = true;
|
||||
$("#save").disabled = true;
|
||||
try {
|
||||
layout.title = $("#title").value;
|
||||
// Screen settings apply on their own (flushSettings); the stored ones stay as they are.
|
||||
const { settings: _settings, ...tiles } = layout;
|
||||
await api(`screens/${encodeURIComponent(selected)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(layout),
|
||||
body: JSON.stringify(tiles),
|
||||
});
|
||||
dirty = false;
|
||||
$("#dirty").textContent = "Saved";
|
||||
@@ -2046,6 +2211,8 @@ $("#install-retry").onclick = async () => {
|
||||
}
|
||||
};
|
||||
$("#copy-key").onclick = () => copyText(installer.apiKey || "", $("#api-key"));
|
||||
// A change still waiting for its short pause goes out when the page closes.
|
||||
window.addEventListener("pagehide", () => flushSettings(true));
|
||||
window.addEventListener("beforeunload", (e) => {
|
||||
if (dirty) {
|
||||
e.preventDefault();
|
||||
@@ -2062,7 +2229,7 @@ function applyLive(data) {
|
||||
$("#connection").textContent = inventory.connected ? "● Home Assistant connected" : "Reconnecting to Home Assistant…";
|
||||
$("#connection").classList.toggle("online", inventory.connected);
|
||||
renderScreens();
|
||||
if (selected) renderSettingsSupport();
|
||||
if (selected) { settleSettings(); renderSettings(); }
|
||||
if (!selected && inventory.screens.length) select(inventory.screens[0].id);
|
||||
}
|
||||
function listen() {
|
||||
|
||||
@@ -110,19 +110,13 @@
|
||||
<div id="results" aria-live="polite"></div>
|
||||
</div>
|
||||
</div>
|
||||
<details class="screen-settings" id="general-settings" open>
|
||||
<summary>
|
||||
Screen settings
|
||||
<span class="hint">Brightness, quiet hours, and display</span>
|
||||
</summary>
|
||||
<p class="hint" id="settings-support"></p>
|
||||
<div class="settings-grid" id="settings-fields"></div>
|
||||
<p class="hint">
|
||||
Night hours apply to the brightness during standby. The first
|
||||
touch wakes the screen; the next one controls your home.
|
||||
Matching start and end times turn the night window off.
|
||||
</p>
|
||||
</details>
|
||||
<section class="screen-settings" id="general-settings" aria-labelledby="settings-heading">
|
||||
<div class="section-title settings-title">
|
||||
<h3 id="settings-heading">Screen settings</h3>
|
||||
<span class="hint" id="settings-status" role="status"></span>
|
||||
</div>
|
||||
<div class="settings-cards" id="settings-groups"></div>
|
||||
</section>
|
||||
<details class="screen-settings" id="inspector-section">
|
||||
<summary>
|
||||
Inspector
|
||||
|
||||
@@ -765,46 +765,149 @@ textarea {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px 28px;
|
||||
margin: 20px 0;
|
||||
/* Screen settings: the groups and rows of the settings page on the screen itself (app 0.2.57). */
|
||||
.settings-title {
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 16px;
|
||||
}
|
||||
.setting {
|
||||
padding: 14px;
|
||||
.settings-title h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
/* Columns, not a grid: the cards fill them from the top, so a short group leaves no hole beside a long one. */
|
||||
.settings-cards {
|
||||
column-width: 290px;
|
||||
column-gap: 14px;
|
||||
margin: 16px 0 4px;
|
||||
}
|
||||
.settings-card {
|
||||
break-inside: avoid;
|
||||
margin-bottom: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
border-radius: 16px;
|
||||
background: white;
|
||||
padding: 14px 16px 8px;
|
||||
}
|
||||
.setting output {
|
||||
display: block;
|
||||
.settings-card h4 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 6px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.settings-card h4 .mdi {
|
||||
font-size: 20px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.setting input[type="range"] {
|
||||
padding: 0;
|
||||
accent-color: var(--accent);
|
||||
min-height: 32px;
|
||||
}
|
||||
.setting-check {
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 48px;
|
||||
padding: 4px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.setting-check input {
|
||||
.setting-toggle {
|
||||
cursor: pointer;
|
||||
}
|
||||
.setting-label {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.setting-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
.setting-row.inactive .setting-label,
|
||||
.setting-row.inactive .setting-control {
|
||||
opacity: 0.45;
|
||||
}
|
||||
#general-settings.offline .settings-cards {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.switch {
|
||||
position: relative;
|
||||
width: 46px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border-radius: 14px;
|
||||
background: #c9d0d9;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.switch::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
accent-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
box-shadow: 0 1px 2px #17223233;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.setting-check output {
|
||||
display: none;
|
||||
.switch[aria-checked="true"] {
|
||||
background: var(--accent);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
.switch[aria-checked="true"]::after {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
/* Neither on nor off: Home Assistant has no value (the screen is offline, or the entity is off). */
|
||||
.switch.unknown::after {
|
||||
transform: translateX(9px);
|
||||
}
|
||||
.step {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
background: #eef2f7;
|
||||
color: var(--ink);
|
||||
touch-action: manipulation;
|
||||
user-select: none;
|
||||
}
|
||||
.step .mdi {
|
||||
font-size: 18px;
|
||||
}
|
||||
.step:hover:not(:disabled) {
|
||||
filter: none;
|
||||
background: #e1e8f1;
|
||||
}
|
||||
.step:active:not(:disabled) {
|
||||
background: var(--soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
.setting-value {
|
||||
min-width: 72px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.setting-choices.segmented {
|
||||
flex-wrap: nowrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.setting-choices.segmented button {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
/* An end of a range greys its key; a row that waits on its switch fades as a whole instead. */
|
||||
.setting-row .step:disabled {
|
||||
opacity: 0.35;
|
||||
}
|
||||
.setting-row.inactive button:disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.switch,
|
||||
.switch::after {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user