Direct controls on wide cards, weather/climate cards, spinner busy state (0.2.23)
App 0.2.23 / firmware 0.2.19. - Wide cards of climate, switch, light, fan, vacuum, cover, media_player, number, select, timer, scene, script and button show Home Assistant style controls on the right (keys, -/+ pill with debounced send, slider with handle, toggle, run key); per-tile choice `options.controls` in the editor with mockup preview; the add-on sends only the set a card really shows. - Weather card: now + next hours and coming days with rain, from daily and hourly forecasts; scenes/scripts/buttons show when they last ran. - Climate card: power key, tap-to-toggle option, fan and swing rows in a redesigned mode picker (grey backdrop, white chips, accent selection). - Busy state as a spinner on a translucent sheet; render guards so only changed styles reach LVGL; dark theme removed from the runtime code. - supported_features above 1e6 reaches the screen; middle dot in text fonts; 18 control glyphs in the icon fonts; diagnostics services preview_climate_picker and preview_busy; demo layout --controls. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+118
-21
@@ -1,6 +1,6 @@
|
||||
"""Pure validation, firmware generation and bounded display protocol."""
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
@@ -16,8 +16,10 @@ WEEKDAYS = ['ma', 'di', 'wo', 'do', 'vr', 'za', 'zo']
|
||||
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.18'
|
||||
ATTRS = frozenset('brightness percentage current_position current_temperature temperature current_humidity min_temp max_temp target_temp_step supported_color_modes hvac_modes 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 media_title options min max step temperature_unit supported_features next_rising next_setting finishes_at duration remaining'.split())
|
||||
FIRMWARE_VERSION = '0.2.19'
|
||||
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'])
|
||||
|
||||
|
||||
TILE_BACKGROUNDS = {
|
||||
@@ -40,6 +42,43 @@ DISPLAYS = {'weather': ('standard', 'watch', 'forecast'), 'sensor': ('standard',
|
||||
# Displays that only work on a double-width card.
|
||||
WIDE_ONLY = ('forecast', 'sunpath')
|
||||
|
||||
# Direct controls on the right half of a double-width card (firmware 0.2.19+), like
|
||||
# Home Assistant's own entity rows. The first choice is what a wide card shows
|
||||
# when the tile has no explicit choice; 'none' keeps the plain card.
|
||||
CONTROLS = {
|
||||
'climate': (('setpoint', 'Temperatuur − / +'), ('mode', 'Uit, verwarmen, koelen')),
|
||||
'switch': (('toggle', 'Aan/uit-schakelaar'),),
|
||||
'input_boolean': (('toggle', 'Aan/uit-schakelaar'),),
|
||||
'light': (('toggle', 'Aan/uit-schakelaar'), ('brightness', 'Helderheidsschuif')),
|
||||
'fan': (('toggle', 'Aan/uit-schakelaar'), ('speed', 'Snelheidsschuif')),
|
||||
'vacuum': (('buttons', 'Start, stop, naar dock'),),
|
||||
'cover': (('buttons', 'Open, stop, dicht'), ('position', 'Positieschuif')),
|
||||
'media_player': (('volume', 'Volume en dempen'), ('playback', 'Vorige, play/pauze, volgende')),
|
||||
'number': (('stepper', 'Waarde − / +'), ('slider', 'Schuif')),
|
||||
'input_number': (('stepper', 'Waarde − / +'), ('slider', 'Schuif')),
|
||||
'select': (('stepper', 'Vorige / volgende keuze'),),
|
||||
'input_select': (('stepper', 'Vorige / volgende keuze'),),
|
||||
'timer': (('buttons', 'Start/pauze en annuleren'),),
|
||||
'scene': (('run', 'Knop Activeren'),),
|
||||
'script': (('run', 'Knop Uitvoeren'),),
|
||||
'button': (('run', 'Knop Indrukken'),),
|
||||
'input_button': (('run', 'Knop Indrukken'),),
|
||||
}
|
||||
|
||||
def controls_catalogue():
|
||||
"""Editor choices per domain: the default first, then 'none'."""
|
||||
return {domain: {'default': choices[0][0], 'choices': [{'key': key, 'label': label} for key, label in choices] + [{'key': 'none', 'label': 'Geen'}]}
|
||||
for domain, choices in CONTROLS.items()}
|
||||
|
||||
def resolve_controls(tile):
|
||||
"""Control set a card shows on the screen, or None: only wide cards in the standard layout have room for one."""
|
||||
options = tile.get('options', {})
|
||||
domain = tile['entity'].split('.')[0]
|
||||
if domain not in CONTROLS or options.get('size') != 'wide' or options.get('display', 'standard') != 'standard' or options.get('inline') == 'slider':
|
||||
return None
|
||||
choice = options.get('controls', CONTROLS[domain][0][0])
|
||||
return None if choice == 'none' else choice
|
||||
|
||||
# Additive schema 1 extension. An absent object retains old firmware/YAML defaults.
|
||||
SETTING_RULES = {
|
||||
'standby_enabled': (True, None, None),
|
||||
@@ -114,7 +153,7 @@ def validate_layout(data):
|
||||
item = {'entity': tile['entity'], 'name': name.strip()}
|
||||
if 'options' in tile:
|
||||
options = tile['options']
|
||||
if not isinstance(options, dict) or set(options) - {'tap', 'display', 'inline', 'history_hours', 'background', 'size', 'icon'}:
|
||||
if not isinstance(options, dict) or set(options) - {'tap', 'display', 'inline', 'history_hours', 'background', 'size', 'icon', 'controls'}:
|
||||
raise ValueError('Onbekende tegelinstellingen.')
|
||||
if 'background' in options and (not isinstance(options['background'],str) or options['background'] not in TILE_BACKGROUNDS):
|
||||
raise ValueError('Kies een pastel achtergrondkleur uit het palet.')
|
||||
@@ -129,7 +168,7 @@ def validate_layout(data):
|
||||
# The five-day strip and the sun path only fit a double-width card.
|
||||
if options.get('display') in WIDE_ONLY:
|
||||
options = {**options, 'size': 'wide'}
|
||||
if options.get('tap') == 'toggle' and domain not in {'light','switch','input_boolean','fan','media_player'}:
|
||||
if options.get('tap') == 'toggle' and domain not in {'light','switch','input_boolean','fan','media_player','climate'}:
|
||||
raise ValueError('Deze entiteit ondersteunt geen aan/uit-actie.')
|
||||
if options.get('inline') == 'slider' and domain not in {'light','fan','cover','number','input_number','media_player'}:
|
||||
raise ValueError('Deze entiteit ondersteunt geen mini-schuif.')
|
||||
@@ -137,6 +176,10 @@ def validate_layout(data):
|
||||
raise ValueError('Geschiedenis: kies 1, 6 of 24 uur.')
|
||||
if options.get('display') == 'watch' and options.get('inline') == 'slider':
|
||||
raise ValueError('Kies grote waarde of mini-schuif.')
|
||||
if 'controls' in options:
|
||||
allowed = ('none',) + tuple(key for key, _ in CONTROLS.get(domain, ()))
|
||||
if not isinstance(options['controls'], str) or options['controls'] not in allowed:
|
||||
raise ValueError('Deze entiteit ondersteunt die directe bediening niet.')
|
||||
item['options'] = dict(options)
|
||||
clean.append(item)
|
||||
result = {'title': title.strip(), 'tiles': clean}
|
||||
@@ -154,26 +197,71 @@ def local_clock(value, tz):
|
||||
moment = moment.replace(tzinfo=timezone.utc)
|
||||
return moment.astimezone(tz or timezone.utc).strftime('%H:%M')
|
||||
|
||||
def extras(tile, states, forecast=None, tz=None):
|
||||
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
|
||||
|
||||
def forecast_time(entry, tz):
|
||||
try:
|
||||
return datetime.fromisoformat(str(entry.get('datetime')).replace('Z', '+00:00')).astimezone(tz or timezone.utc)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def epoch(value):
|
||||
"""Unix time of an ISO timestamp (a scene's state, a script's last_triggered); None when unusable."""
|
||||
try:
|
||||
moment = datetime.fromisoformat(str(value).replace('Z', '+00:00'))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if moment.tzinfo is None:
|
||||
moment = moment.replace(tzinfo=timezone.utc)
|
||||
return int(moment.timestamp())
|
||||
|
||||
def extras(tile, states, forecast=None, tz=None, hourly=None, now=None):
|
||||
"""Small, pre-computed values the firmware cannot derive itself (time zones, forecasts)."""
|
||||
domain = tile['entity'].split('.')[0]
|
||||
attrs = states.get(tile['entity'], {}).get('attributes', {})
|
||||
if domain == 'weather' and forecast:
|
||||
if domain == 'weather' and (forecast or hourly):
|
||||
result = {}
|
||||
days = []
|
||||
for entry in forecast[:5]:
|
||||
if not isinstance(entry, dict):
|
||||
for entry in forecast or []:
|
||||
if not isinstance(entry, dict) or len(days) == 5:
|
||||
continue
|
||||
try:
|
||||
day = datetime.fromisoformat(str(entry.get('datetime')).replace('Z', '+00:00')).astimezone(tz or timezone.utc)
|
||||
except (ValueError, TypeError):
|
||||
day = forecast_time(entry, tz)
|
||||
if day is None:
|
||||
continue
|
||||
item = {'d': WEEKDAYS[day.weekday()], 'c': short(entry.get('condition') or '', 20)}
|
||||
for key, name in (('h', 'temperature'), ('l', 'templow')):
|
||||
value = entry.get(name)
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value):
|
||||
item[key] = round(value, 1)
|
||||
# h/l: high and low; p: chance of rain in %; r: rain in the entity's unit (mm).
|
||||
for key, name in (('h', 'temperature'), ('l', 'templow'), ('p', 'precipitation_probability'), ('r', 'precipitation')):
|
||||
value = forecast_number(entry, name)
|
||||
if value is not None:
|
||||
item[key] = value
|
||||
days.append(item)
|
||||
return {'days': days} if days else None
|
||||
if days:
|
||||
result['days'] = days
|
||||
# The next eight hours from now, for the weather card's hourly strip.
|
||||
hours = []
|
||||
# The running hour still counts: an entry stays until its hour has passed.
|
||||
start = (now or datetime.now(timezone.utc)) - timedelta(minutes=59)
|
||||
for entry in hourly or []:
|
||||
if not isinstance(entry, dict) or len(hours) == 8:
|
||||
continue
|
||||
moment = forecast_time(entry, tz)
|
||||
if moment is None or moment < start:
|
||||
continue
|
||||
item = {'t': moment.strftime('%H:%M'), 'c': short(entry.get('condition') or '', 20)}
|
||||
for key, name in (('h', 'temperature'), ('p', 'precipitation_probability'), ('r', 'precipitation')):
|
||||
value = forecast_number(entry, name)
|
||||
if value is not None:
|
||||
item[key] = value
|
||||
hours.append(item)
|
||||
if hours:
|
||||
result['hours'] = hours
|
||||
return result or None
|
||||
if domain in ('scene', 'script', 'button', 'input_button'):
|
||||
# When it last ran: scripts report last_triggered; scenes and buttons carry the time as their state.
|
||||
last = epoch(attrs.get('last_triggered') if domain == 'script' else states.get(tile['entity'], {}).get('state'))
|
||||
return {'last': last} if last else None
|
||||
if domain == 'sun':
|
||||
rise, down = local_clock(attrs.get('next_rising'), tz), local_clock(attrs.get('next_setting'), tz)
|
||||
return {'rise': rise, 'set': down} if rise or down else None
|
||||
@@ -196,11 +284,15 @@ def tile_icon(tile, attrs):
|
||||
return tile_icons.ICONS[choice][0] if choice in tile_icons.ICONS else tile_icons.ha_icon(attrs)
|
||||
|
||||
def screen_options(tile, attrs):
|
||||
"""Stored options on the wire; `icon` travels as the resolved codepoint (firmware 0.2.18+, ignored before)."""
|
||||
options = {k: v for k, v in tile.get('options', {}).items() if k != 'icon'}
|
||||
"""Stored options on the wire; `icon` travels as the resolved codepoint (firmware 0.2.18+, ignored before)
|
||||
and `controls` only as the set the card really shows (firmware 0.2.19+, ignored before)."""
|
||||
options = {k: v for k, v in tile.get('options', {}).items() if k not in ('icon', 'controls')}
|
||||
icon = tile_icon(tile, attrs)
|
||||
if icon:
|
||||
options['icon'] = icon
|
||||
controls = resolve_controls(tile)
|
||||
if controls:
|
||||
options['controls'] = controls
|
||||
return options if options or 'options' in tile else None
|
||||
|
||||
def state_message(index, tile, states, extra=None):
|
||||
@@ -215,10 +307,15 @@ def state_message(index, tile, states, extra=None):
|
||||
bounded = {}
|
||||
for key in ATTRS:
|
||||
value = attrs.get(key)
|
||||
if isinstance(value, bool) or value is None:
|
||||
if isinstance(value, bool):
|
||||
if key in BOOL_ATTRS:
|
||||
bounded[key] = value
|
||||
continue
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, (int, float)):
|
||||
if math.isfinite(value) and abs(value) <= 1000000:
|
||||
# supported_features is a bit field (media players exceed 8 million); other numbers are display values.
|
||||
if math.isfinite(value) and abs(value) <= (2**31 if key == 'supported_features' else 1000000):
|
||||
bounded[key] = value
|
||||
elif isinstance(value, str):
|
||||
bounded[key] = short(value, 48)
|
||||
|
||||
@@ -14,7 +14,7 @@ import tile_icons
|
||||
from updates import Updater
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout, WSMsgType, web
|
||||
from core import BUILTIN, TILE_BACKGROUNDS, discover, extras, installation_yaml, min_firmware, packets, state_message, validate_layout, validate_settings
|
||||
from core import BUILTIN, TILE_BACKGROUNDS, controls_catalogue, discover, extras, installation_yaml, min_firmware, packets, state_message, validate_layout, validate_settings
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
LOG = logging.getLogger('screen_manager')
|
||||
@@ -166,10 +166,10 @@ class HomeAssistant:
|
||||
await self.request('call_service', domain='text', service='set_value',
|
||||
service_data={'entity_id': inbox, 'value': packet})
|
||||
|
||||
async def forecast(self, entity):
|
||||
async def forecast(self, entity, kind='daily'):
|
||||
# Forecasts left the weather attributes in HA 2024.4; ask the service instead.
|
||||
result = await self.request('call_service', domain='weather', service='get_forecasts',
|
||||
service_data={'type': 'daily'}, target={'entity_id': entity}, return_response=True)
|
||||
service_data={'type': kind}, target={'entity_id': entity}, return_response=True)
|
||||
forecast = ((result or {}).get('response') or {}).get(entity, {}).get('forecast', [])
|
||||
return forecast if isinstance(forecast, list) else []
|
||||
|
||||
@@ -262,7 +262,7 @@ class Manager:
|
||||
old_tiles = {t['entity']:t for t in self.layouts.get(inbox,{}).get('tiles',[])}
|
||||
for tile in layout['tiles']:
|
||||
old_options=old_tiles.get(tile['entity'],{}).get('options',{})
|
||||
for key in ('background', 'icon'):
|
||||
for key in ('background', 'icon', 'controls'):
|
||||
if 'options' in tile and key not in tile['options'] and key in old_options:
|
||||
tile['options'][key]=old_options[key]
|
||||
if 'options' not in tile and 'options' in old_tiles.get(tile['entity'],{}):
|
||||
@@ -319,10 +319,12 @@ class Manager:
|
||||
if screen.get('board')=='guition':
|
||||
messages[0]['rotation'] = layout['settings'].get('rotation',0)
|
||||
for i,tile in enumerate(layout['tiles']):
|
||||
forecast=None
|
||||
forecast=hourly=None
|
||||
if tile['entity'].startswith('weather.') and hasattr(self.ha,'forecast'):
|
||||
forecast=await self.cached(self.forecasts, tile['entity'], 1800, lambda: self.ha.forecast(tile['entity']))
|
||||
message=state_message(i,tile,self.ha.states,extras(tile,self.ha.states,forecast,getattr(self.ha,'time_zone',None)))
|
||||
# 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'), 1800, lambda: self.ha.forecast(tile['entity'],'hourly'))
|
||||
message=state_message(i,tile,self.ha.states,extras(tile,self.ha.states,forecast,getattr(self.ha,'time_zone',None),hourly))
|
||||
if tile['entity'].startswith('sensor.') and hasattr(self.ha,'history'):
|
||||
hours=tile.get('options',{}).get('history_hours',24)
|
||||
key=(tile['entity'],hours)
|
||||
@@ -433,6 +435,7 @@ def create_app(manager, development=False):
|
||||
# The page polls the light form; entities, backgrounds and icons (~100 KB) only on demand.
|
||||
payload['entities'] = entities
|
||||
payload['backgrounds'] = TILE_BACKGROUNDS
|
||||
payload['controls'] = controls_catalogue()
|
||||
payload['icons'] = tile_icons.editor()
|
||||
payload['builtin'] = [{'id': key, 'name': name, 'device': 'Ingebouwd op het scherm', 'area': '', 'state': 'ok'} for key, name in BUILTIN.items()]
|
||||
return web.json_response(payload)
|
||||
|
||||
@@ -369,6 +369,40 @@ const domains = {
|
||||
person: ["Persoon", "☺", "#2f7d32", "#e1f2e2"],
|
||||
};
|
||||
const displayNames = { standard: "standaard", watch: "grote waarde", forecast: "weersvoorspelling", graph: "grafiek", digital: "digitale klok", analog: "analoge klok", sunpath: "zonnebaan" };
|
||||
// Same rule as the add-on: only a wide card in the standard layout shows direct controls;
|
||||
// without a choice the domain's first control set applies.
|
||||
function effectiveControls(tile) {
|
||||
const domain = tile.entity.split(".")[0], catalogue = inventory.controls?.[domain], o = tile.options || {};
|
||||
if (!catalogue || o.size !== "wide" || (o.display || "standard") !== "standard" || o.inline === "slider") return null;
|
||||
const choice = o.controls ?? catalogue.default;
|
||||
return choice === "none" ? null : choice;
|
||||
}
|
||||
function controlsLabel(tile) {
|
||||
const key = effectiveControls(tile);
|
||||
if (!key) return "geen";
|
||||
return inventory.controls?.[tile.entity.split(".")[0]]?.choices.find((c) => c.key === key)?.label.toLocaleLowerCase() || key;
|
||||
}
|
||||
// Miniature of the control set on the mockup card: the same shapes the screen draws.
|
||||
function controlsPreview(tile) {
|
||||
const key = effectiveControls(tile), domain = tile.entity.split(".")[0], cp = inventory.icons?.controls || {};
|
||||
if (!key) return null;
|
||||
const box = node("span", undefined, "preview-controls");
|
||||
const buttons = (...names) => { for (const name of names) box.append(node("span", cp[name] ? glyph(cp[name]) : "", "preview-key mdi")); };
|
||||
const stepper = (text) => { const pill = node("span", undefined, "preview-stepper"); pill.append(node("span", cp.minus ? glyph(cp.minus) : "−", "mdi"), node("b", text), node("span", cp.plus ? glyph(cp.plus) : "+", "mdi")); box.append(pill); };
|
||||
const slider = () => box.append(node("span", "", "preview-range"));
|
||||
if (key === "toggle") box.append(node("span", "", "preview-toggle"));
|
||||
else if (key === "setpoint") stepper("20°");
|
||||
else if (key === "stepper") domain.endsWith("select") ? buttons("chevron-left", "chevron-right") : stepper("50");
|
||||
else if (key === "mode") buttons("power", "fire", "snowflake");
|
||||
else if (key === "volume") { slider(); buttons("volume-high"); }
|
||||
else if (key === "playback") buttons("skip-previous", "play", "skip-next");
|
||||
else if (key === "buttons" && domain === "cover") buttons("arrow-expand-horizontal", "stop", "arrow-collapse-horizontal");
|
||||
else if (key === "buttons" && domain === "vacuum") buttons("play", "stop", "home-map-marker");
|
||||
else if (key === "buttons" && domain === "timer") buttons("play", "close");
|
||||
else if (key === "run") box.append(node("b", { scene: "Activeren", script: "Uitvoeren" }[domain] || "Indrukken", "preview-run"));
|
||||
else slider();
|
||||
return box;
|
||||
}
|
||||
// New tiles start with the card that shows the entity best.
|
||||
function defaultOptions(id) {
|
||||
const domain = id.split(".")[0];
|
||||
@@ -474,6 +508,8 @@ function renderPreview() {
|
||||
remove.onclick = (e) => { e.stopPropagation(); removeTile(index); };
|
||||
card.append(remove);
|
||||
if (tile.options?.inline === "slider") card.append(node("span", "", "preview-slider"));
|
||||
const controls = controlsPreview(tile);
|
||||
if (controls) card.append(controls);
|
||||
const display = tile.options?.display;
|
||||
if (display && display !== "standard") card.append(node("small", displayNames[display] || display));
|
||||
grid.append(card);
|
||||
@@ -659,17 +695,27 @@ function renderTileSheet() {
|
||||
const current = (key, fallback) => tile.options?.[key] ?? fallback;
|
||||
const set = (key, value) => {
|
||||
tile.options = { ...tile.options, [key]: value };
|
||||
if (key === "display" && value === "watch") tile.options.inline = "none";
|
||||
// Direct controls need the standard layout without a mini slider, and vice versa.
|
||||
if (key === "display" && value === "watch") { tile.options.inline = "none"; if (inventory.controls?.[domain]) tile.options.controls = "none"; }
|
||||
if (key === "display" && ["forecast", "sunpath"].includes(value)) tile.options.size = "wide";
|
||||
if (key === "inline" && value === "slider") tile.options.display = "standard";
|
||||
if (key === "inline" && value === "slider") { tile.options.display = "standard"; if (inventory.controls?.[domain]) tile.options.controls = "none"; }
|
||||
if (key === "controls" && value !== "none") { tile.options.display = "standard"; tile.options.inline = "none"; }
|
||||
markDirty();
|
||||
renderTiles();
|
||||
};
|
||||
body.append(field("Weergave", segmented(displays, current("display", domain === "screen" ? "digital" : "standard"), (v) => set("display", v))));
|
||||
body.append(field("Breedte", segmented([["single", "Normaal"], ["wide", "Dubbelbreed"]], current("size", "single"), (v) => set("size", v))));
|
||||
const catalogue = inventory.controls?.[domain];
|
||||
if (catalogue && current("size", "single") === "wide") {
|
||||
const wrap = field("Directe bediening op de tegel", segmented(catalogue.choices.map((c) => [c.key, c.label]), current("controls", catalogue.default), (v) => set("controls", v)));
|
||||
wrap.append(node("small", supportsFirmware(0, 2, 19)
|
||||
? "Rechts op de dubbelbrede tegel, zoals de rijen in Home Assistant. Tikken op de naam werkt zoals hieronder ingesteld."
|
||||
: "Het scherm toont directe bediening vanaf firmware 0.2.19; tot die tijd blijft de tegel zoals hij was.", "field-hint"));
|
||||
body.append(wrap);
|
||||
}
|
||||
if (domain !== "screen") {
|
||||
const taps = [["auto", "Automatisch"], ["detail", "Bediening openen"], ["none", "Alleen bekijken"]];
|
||||
if (["light", "switch", "input_boolean", "fan", "media_player"].includes(domain)) taps.push(["toggle", "Aan / uit"]);
|
||||
if (["light", "switch", "input_boolean", "fan", "media_player", "climate"].includes(domain)) taps.push(["toggle", "Aan / uit"]);
|
||||
body.append(field("Bij aantikken", segmented(taps, current("tap", "auto"), (v) => set("tap", v))));
|
||||
}
|
||||
if (["light", "fan", "cover", "number", "input_number", "media_player"].includes(domain))
|
||||
@@ -1190,7 +1236,7 @@ async function inspect(entity) {
|
||||
card.append(
|
||||
node(
|
||||
"small",
|
||||
`Kleine slider: ${options.inline === "slider" ? "ja" : "nee"} · Weergave: ${displayNames[options.display || "standard"] || options.display} · Breedte: ${options.size === "wide" ? "dubbel" : "normaal"} · Achtergrond: ${inventory.backgrounds?.[options.background || "auto"]?.label || "Standaard"}`,
|
||||
`Kleine slider: ${options.inline === "slider" ? "ja" : "nee"} · Weergave: ${displayNames[options.display || "standard"] || options.display} · Breedte: ${options.size === "wide" ? "dubbel" : "normaal"} · Bediening: ${controlsLabel({ entity: tile.entity, options })} · Achtergrond: ${inventory.backgrounds?.[options.background || "auto"]?.label || "Standaard"}`,
|
||||
),
|
||||
);
|
||||
$("#inspection-summary").append(card);
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<li>
|
||||
<b>2 · Tegels kiezen</b><span
|
||||
>Sleep entiteiten uit de lijst rechts in de schermmockup, of tik
|
||||
op +. Klik op een tegel voor weergave, breedte en kleur.</span
|
||||
op +. Klik op een tegel voor weergave, breedte, bediening en kleur.</span
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
@@ -126,8 +126,8 @@
|
||||
<span id="count">0 / 10</span>
|
||||
</div>
|
||||
<p class="hint">
|
||||
Klik op een tegel voor naam, weergave, breedte en kleur; het
|
||||
kruisje verwijdert. Sleep om te ordenen of sleep entiteiten uit
|
||||
Klik op een tegel voor naam, weergave, breedte, directe
|
||||
bediening en kleur; het kruisje verwijdert. Sleep om te ordenen of sleep entiteiten uit
|
||||
de lijst hierheen. Zes vakjes per pagina; een dubbelbrede tegel
|
||||
telt voor twee en begint altijd links.
|
||||
</p>
|
||||
|
||||
@@ -912,6 +912,16 @@ pre {
|
||||
.preview-gap { min-height: 90px; border: 1px dashed #d0d4d9; border-radius: 15px; }
|
||||
.preview-tile.vacant { background: #f4f4f4; border-style: dashed; color: #727982; justify-content: center; }
|
||||
.preview-slider { height: 13px; flex-basis: 100%; border-radius: 10px; background: linear-gradient(to right, #ffbf38 60%, #fff1d3 60%); }
|
||||
/* Miniature direct controls on a wide mockup card, in the shapes the screen draws. */
|
||||
.preview-controls { display: flex; align-items: center; gap: 4px; flex: none; margin-right: 18px; color: #1b1b1b; }
|
||||
.preview-key { display: grid; place-items: center; width: 28px; height: 22px; border-radius: 7px; background: #eceef1; font-size: 14px; }
|
||||
.preview-stepper { display: flex; align-items: center; gap: 4px; height: 22px; padding: 0 7px; border-radius: 11px; background: #eceef1; font-size: 12px; }
|
||||
.preview-stepper b { font-size: 12px; font-weight: 600; }
|
||||
.preview-range { width: 60px; height: 20px; border-radius: 10px; background: linear-gradient(to right, #2196f3 35%, #d3e8fb 35%); }
|
||||
.preview-toggle { position: relative; width: 34px; height: 20px; border-radius: 10px; background: #2196f3; }
|
||||
.preview-toggle::after { content: ""; position: absolute; top: 3px; right: 3px; width: 14px; height: 14px; border-radius: 50%; background: white; }
|
||||
.preview-run { height: 22px; padding: 0 10px; border-radius: 11px; background: #eceef1; font-size: 12px; font-weight: 600; line-height: 22px; }
|
||||
.field-hint { font-weight: 400; }
|
||||
.preview-tile small { flex-basis: 100%; }
|
||||
.result:hover:not(:disabled), .preview-tile:hover { border-color: #9eb6cf; box-shadow: 0 2px 7px #253f5010; }
|
||||
|
||||
|
||||
Binary file not shown.
@@ -184,10 +184,29 @@ GROUPS = (
|
||||
)),
|
||||
)
|
||||
|
||||
# Glyphs the firmware draws itself (weather conditions, sun, checkmark) that the picker does not offer.
|
||||
# Glyphs the firmware draws itself (weather conditions, sun, checkmark, direct controls) that the picker does not offer.
|
||||
FIXED = (
|
||||
('alert-circle-outline', 'F05D6'),
|
||||
('check', 'F012C'),
|
||||
# Direct controls on wide cards (firmware 0.2.19+).
|
||||
('pause', 'F03E4'),
|
||||
('stop', 'F04DB'),
|
||||
('skip-next', 'F04AD'),
|
||||
('skip-previous', 'F04AE'),
|
||||
('volume-high', 'F057E'),
|
||||
('volume-off', 'F0581'),
|
||||
('arrow-up', 'F005D'),
|
||||
('arrow-down', 'F0045'),
|
||||
('arrow-expand-horizontal', 'F084E'),
|
||||
('arrow-collapse-horizontal', 'F084C'),
|
||||
('home-map-marker', 'F05F8'),
|
||||
('plus', 'F0415'),
|
||||
('minus', 'F0374'),
|
||||
('chevron-left', 'F0141'),
|
||||
('chevron-right', 'F0142'),
|
||||
('close', 'F0156'),
|
||||
('sun-snowflake-variant', 'F1A79'),
|
||||
('thermostat-auto', 'F1B17'),
|
||||
('weather-fog', 'F0591'),
|
||||
('weather-hail', 'F0592'),
|
||||
('weather-lightning', 'F0593'),
|
||||
@@ -206,6 +225,9 @@ DEFAULTS = {'light': 'lightbulb', 'climate': 'air-conditioner', 'vacuum': 'robot
|
||||
'cover': 'window-shutter', 'scene': 'sofa', 'script': 'sofa', 'sensor': 'gauge', 'binary_sensor': 'gauge',
|
||||
'timer': 'timer-outline', 'person': 'account', 'screen': 'clock-outline'}
|
||||
FALLBACK = 'power'
|
||||
CONTROL_GLYPHS = ('play', 'pause', 'stop', 'skip-next', 'skip-previous', 'volume-high', 'volume-off', 'arrow-up', 'arrow-down',
|
||||
'arrow-expand-horizontal', 'arrow-collapse-horizontal', 'home-map-marker', 'plus', 'minus', 'chevron-left',
|
||||
'chevron-right', 'close', 'power', 'fire', 'snowflake')
|
||||
WEATHER = {'sunny': 'weather-sunny', 'clear-night': 'weather-night', 'cloudy': 'weather-cloudy',
|
||||
'partlycloudy': 'weather-partly-cloudy', 'rainy': 'weather-rainy', 'pouring': 'weather-pouring',
|
||||
'snowy': 'weather-snowy', 'snowy-rainy': 'weather-snowy-rainy', 'fog': 'weather-fog', 'hail': 'weather-hail',
|
||||
@@ -223,4 +245,6 @@ def editor():
|
||||
for group, icons in GROUPS],
|
||||
'defaults': {domain: GLYPHS[name] for domain, name in DEFAULTS.items()}, 'fallback': GLYPHS[FALLBACK],
|
||||
'weather': {state: GLYPHS[name] for state, name in WEATHER.items()},
|
||||
'sun': {'above_horizon': GLYPHS['weather-sunset-down'], 'below_horizon': GLYPHS['weather-sunset-up']}}
|
||||
'sun': {'above_horizon': GLYPHS['weather-sunset-down'], 'below_horizon': GLYPHS['weather-sunset-up']},
|
||||
# Glyphs of the direct controls on wide cards, so the mockup previews them.
|
||||
'controls': {name: GLYPHS[name] for name in CONTROL_GLYPHS}}
|
||||
|
||||
Reference in New Issue
Block a user