Add clock, forecast, graph, sun, timer and person cards with wide tiles
Any tile can now be double width: pack() in runtime_model.h places wide tiles in the left column and the manager preview follows the same rules. New cards: built-in clock (digital or analog, screen.clock), five-day weather forecast on a wide weather card fed by weather.get_forecasts, history sparkline for sensors, sunrise/sunset (sun.sun), live kitchen timer (timer.*, tap starts or pauses) and presence (person.*). The manager pre-computes time-zone and forecast extras in a new 'x' object and refuses layouts with new domains until the screen runs 0.2.14. The self-test follows the packing, checks custom card parts and wide widths, and diagnostics/send_layout.py pushes a demo layout of every card type for render checks without Home Assistant. App 0.2.12, firmware 0.2.14. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,19 @@
|
||||
"""Pure validation, firmware generation and bounded display protocol."""
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import secrets
|
||||
|
||||
DOMAINS = frozenset('light switch input_boolean scene script climate vacuum fan cover sensor binary_sensor input_select select number input_number weather media_player button input_button'.split())
|
||||
DOMAINS = frozenset('light switch input_boolean scene script climate vacuum fan cover sensor binary_sensor input_select select number input_number weather media_player button input_button sun timer person screen'.split())
|
||||
# Built-in cards without a Home Assistant entity; firmware 0.2.14+ renders them.
|
||||
BUILTIN = {'screen.clock': 'Klok'}
|
||||
NEW_DOMAINS = frozenset('sun timer person screen'.split())
|
||||
WEEKDAYS = ['ma', 'di', 'wo', 'do', 'vr', 'za', 'zo']
|
||||
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
|
||||
REFS = {'cyd': 'main', 'guition': 'main'}
|
||||
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'.split())
|
||||
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())
|
||||
|
||||
|
||||
TILE_BACKGROUNDS = {
|
||||
@@ -24,6 +29,9 @@ TILE_BACKGROUNDS = {
|
||||
'gray': {'label': 'Grijs', 'color': '#E5E7EB'},
|
||||
}
|
||||
|
||||
# Display modes per domain; everything else offers standard and watch (large value).
|
||||
DISPLAYS = {'weather': ('standard', 'watch', 'forecast'), 'sensor': ('standard', 'watch', 'graph'), 'screen': ('digital', 'analog')}
|
||||
|
||||
# Additive schema 1 extension. An absent object retains old firmware/YAML defaults.
|
||||
SETTING_RULES = {
|
||||
'standby_enabled': (True, None, None),
|
||||
@@ -60,7 +68,17 @@ def validate_settings(data):
|
||||
return clean
|
||||
|
||||
def entity_id(value):
|
||||
return isinstance(value, str) and len(value) <= 120 and re.fullmatch(r'[a-z0-9_]+\.[a-z0-9_]+', value) and value.split('.')[0] in DOMAINS
|
||||
if not (isinstance(value, str) and len(value) <= 120 and re.fullmatch(r'[a-z0-9_]+\.[a-z0-9_]+', value)):
|
||||
return False
|
||||
return value in BUILTIN if value.startswith('screen.') else value.split('.')[0] in DOMAINS
|
||||
|
||||
def min_firmware(layout):
|
||||
"""Oldest firmware that still accepts this layout; None when any version works."""
|
||||
if any(t['entity'].split('.')[0] in NEW_DOMAINS for t in layout['tiles']):
|
||||
return (0, 2, 14)
|
||||
if len(layout['tiles']) > 10:
|
||||
return (0, 2, 7)
|
||||
return None
|
||||
|
||||
def short(value, limit):
|
||||
return str(value).encode('utf-8')[:limit].decode('utf-8', errors='ignore')
|
||||
@@ -86,15 +104,19 @@ 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'}:
|
||||
if not isinstance(options, dict) or set(options) - {'tap', 'display', 'inline', 'history_hours', 'background', 'size'}:
|
||||
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.')
|
||||
choices = {'tap': ('auto', 'detail', 'toggle', 'none'), 'display': ('standard', 'watch'), 'inline': ('none', 'slider')}
|
||||
domain = tile['entity'].split('.')[0]
|
||||
displays = DISPLAYS.get(domain, ('standard', 'watch'))
|
||||
choices = {'tap': ('auto', 'detail', 'toggle', 'none'), 'display': displays, 'inline': ('none', 'slider'), 'size': ('single', 'wide')}
|
||||
for key, allowed in choices.items():
|
||||
if key in options and options[key] not in allowed:
|
||||
raise ValueError('Ongeldige tegelinstelling: ' + key)
|
||||
# The five-day strip only fits a double-width card.
|
||||
if options.get('display') == 'forecast':
|
||||
options = {**options, 'size': 'wide'}
|
||||
if options.get('tap') == 'toggle' and domain not in {'light','switch','input_boolean','fan','media_player'}:
|
||||
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'}:
|
||||
@@ -110,7 +132,59 @@ def validate_layout(data):
|
||||
result['settings'] = validate_settings(data['settings'])
|
||||
return result
|
||||
|
||||
def state_message(index, tile, states):
|
||||
def local_clock(value, tz):
|
||||
"""HH:MM in the home's time zone for an ISO timestamp; '' when unusable."""
|
||||
try:
|
||||
moment = datetime.fromisoformat(str(value).replace('Z', '+00:00'))
|
||||
except (ValueError, TypeError):
|
||||
return ''
|
||||
if moment.tzinfo is None:
|
||||
moment = moment.replace(tzinfo=timezone.utc)
|
||||
return moment.astimezone(tz or timezone.utc).strftime('%H:%M')
|
||||
|
||||
def extras(tile, states, forecast=None, tz=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:
|
||||
days = []
|
||||
for entry in forecast[:5]:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
try:
|
||||
day = datetime.fromisoformat(str(entry.get('datetime')).replace('Z', '+00:00')).astimezone(tz or timezone.utc)
|
||||
except (ValueError, TypeError):
|
||||
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)
|
||||
days.append(item)
|
||||
return {'days': days} if days 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
|
||||
if domain == 'timer':
|
||||
result = {}
|
||||
try:
|
||||
end = datetime.fromisoformat(str(attrs.get('finishes_at')).replace('Z', '+00:00'))
|
||||
result['end'] = int(end.timestamp())
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
for key, name in (('dur', 'duration'), ('rem', 'remaining')):
|
||||
if isinstance(attrs.get(name), str):
|
||||
result[key] = short(attrs[name], 16)
|
||||
return result or None
|
||||
return None
|
||||
|
||||
def state_message(index, tile, states, extra=None):
|
||||
if tile['entity'] in BUILTIN:
|
||||
message = {'v': 1, 'op': 'state', 'i': index, 'entity': tile['entity'],
|
||||
'name': short(tile['name'] or BUILTIN[tile['entity']], 80), 'state': 'ok', 'a': {}}
|
||||
if 'options' in tile:
|
||||
message['o'] = tile['options']
|
||||
return message
|
||||
state = states.get(tile['entity'], {})
|
||||
attrs = state.get('attributes', {})
|
||||
bounded = {}
|
||||
@@ -131,7 +205,7 @@ def state_message(index, tile, states):
|
||||
return {'v': 1, 'op': 'state', 'i': index, 'entity': tile['entity'],
|
||||
'name': short(tile['name'] or attrs.get('friendly_name') or tile['entity'], 80),
|
||||
'state': short(state.get('state', 'unavailable'), 160), 'a': bounded,
|
||||
**({'o': tile['options']} if 'options' in tile else {})}
|
||||
**({'o': tile['options']} if 'options' in tile else {}), **({'x': extra} if extra else {})}
|
||||
|
||||
def packets(message, token=None):
|
||||
raw = json.dumps(message, ensure_ascii=False, separators=(',', ':'), allow_nan=False).encode()
|
||||
|
||||
@@ -12,7 +12,8 @@ import math
|
||||
from firmware import Firmware
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout, WSMsgType, web
|
||||
from core import TILE_BACKGROUNDS, discover, installation_yaml, packets, state_message, validate_layout, validate_settings
|
||||
from core import BUILTIN, TILE_BACKGROUNDS, discover, extras, installation_yaml, min_firmware, packets, state_message, validate_layout, validate_settings
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
LOG = logging.getLogger('screen_manager')
|
||||
|
||||
@@ -27,6 +28,7 @@ class HomeAssistant:
|
||||
self.online = False
|
||||
self.changed = asyncio.Event()
|
||||
self.setting_events = []
|
||||
self.time_zone = None
|
||||
|
||||
async def request(self, kind, **data):
|
||||
if self.ws is None or self.ws.closed:
|
||||
@@ -89,6 +91,10 @@ class HomeAssistant:
|
||||
await self.request('subscribe_events', event_type='esphome.screen_setting')
|
||||
self.states = {s['entity_id']: s for s in await self.request('get_states')}
|
||||
await self.registries()
|
||||
try:
|
||||
self.time_zone = ZoneInfo((await self.request('get_config')).get('time_zone') or 'UTC')
|
||||
except Exception:
|
||||
self.time_zone = timezone.utc
|
||||
self.online = True
|
||||
self.changed.set()
|
||||
LOG.info('Home Assistant verbonden')
|
||||
@@ -120,6 +126,13 @@ class HomeAssistant:
|
||||
await self.request('call_service', domain='text', service='set_value',
|
||||
service_data={'entity_id': inbox, 'value': packet})
|
||||
|
||||
async def forecast(self, entity):
|
||||
# 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)
|
||||
forecast = ((result or {}).get('response') or {}).get(entity, {}).get('forecast', [])
|
||||
return forecast if isinstance(forecast, list) else []
|
||||
|
||||
async def history(self, entity, hours):
|
||||
start = (datetime.now(timezone.utc)-timedelta(hours=hours)).isoformat()
|
||||
async with self.session.get(self.base+'/history/period/'+start,
|
||||
@@ -154,6 +167,7 @@ class Manager:
|
||||
self.ha, self.path = ha, Path(path)
|
||||
self.layouts, self.sent, self.status, self.last = {}, {}, {}, {}
|
||||
self.histories = {}
|
||||
self.forecasts = {}
|
||||
self.firmware = Firmware(os.environ.get("ESPHOME_CONFIG", "/homeassistant/esphome"), self.path.parent)
|
||||
if self.path.exists():
|
||||
raw = json.loads(self.path.read_text())
|
||||
@@ -165,20 +179,32 @@ class Manager:
|
||||
def inventory(self):
|
||||
return discover(self.ha.registry, self.ha.states, self.ha.devices, self.ha.areas)
|
||||
|
||||
def supports_twenty(self, inbox):
|
||||
def firmware_version(self, inbox):
|
||||
screen=next((s for s in self.inventory()[0] if s['id']==inbox), {})
|
||||
try:
|
||||
version=tuple(int(part) for part in screen.get('firmware','').split('.'))
|
||||
return len(version)==3 and version >= (0,2,7)
|
||||
return version if len(version)==3 else None
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
return None
|
||||
|
||||
def supports_twenty(self, inbox):
|
||||
version=self.firmware_version(inbox)
|
||||
return bool(version) and version >= (0,2,7)
|
||||
|
||||
def needs_firmware(self, inbox, layout):
|
||||
"""Version string the screen must run first, or None when the layout can be sent."""
|
||||
needed=min_firmware(layout)
|
||||
if needed and not ((self.firmware_version(inbox) or (0,0,0)) >= needed):
|
||||
return '.'.join(str(part) for part in needed)
|
||||
return None
|
||||
|
||||
def save(self, inbox, data):
|
||||
if inbox not in {s['id'] for s in self.inventory()[0]}:
|
||||
raise ValueError('Dit is geen gekoppeld ESP-scherm. Vernieuw het overzicht.')
|
||||
layout = validate_layout(data)
|
||||
if len(layout["tiles"])>10 and not self.supports_twenty(inbox):
|
||||
raise ValueError("Installeer eerst schermfirmware 0.2.7 of nieuwer voor meer dan tien tegels.")
|
||||
needed = self.needs_firmware(inbox, layout)
|
||||
if needed:
|
||||
raise ValueError(f"Installeer eerst schermfirmware {needed} of nieuwer voor deze tegels.")
|
||||
# A still-open older UI may save tiles without the new optional settings.
|
||||
if 'settings' not in layout and 'settings' in self.layouts.get(inbox, {}):
|
||||
layout['settings'] = self.layouts[inbox]['settings'].copy()
|
||||
@@ -196,7 +222,7 @@ class Manager:
|
||||
tile['options']['background']=old_options['background']
|
||||
if 'options' not in tile and 'options' in old_tiles.get(tile['entity'],{}):
|
||||
tile['options'] = old_tiles[tile['entity']]['options'].copy()
|
||||
known = {e['id'] for e in self.inventory()[1]}
|
||||
known = {e['id'] for e in self.inventory()[1]} | set(BUILTIN)
|
||||
if any(t['entity'] not in known for t in layout['tiles']):
|
||||
raise ValueError('Een gekozen entiteit bestaat niet meer. Zoek de nieuwe entiteit op.')
|
||||
updated = {**self.layouts, inbox: layout}
|
||||
@@ -213,9 +239,18 @@ class Manager:
|
||||
self.status[inbox] = 'Opgeslagen; wacht op synchronisatie'
|
||||
self.ha.changed.set()
|
||||
|
||||
async def cached(self, store, key, ttl, fetch):
|
||||
entry=store.get(key)
|
||||
if not entry or time.monotonic()-entry[0]>ttl:
|
||||
try: value=await fetch()
|
||||
except Exception: value=[]
|
||||
entry=(time.monotonic(),value);store[key]=entry
|
||||
return entry[1]
|
||||
|
||||
async def sync_one(self, inbox, layout, force=False):
|
||||
if len(layout["tiles"])>10 and not self.supports_twenty(inbox):
|
||||
self.status[inbox]="Indeling bewaard; firmware 0.2.7+ nodig voor meer dan tien tegels"
|
||||
needed = self.needs_firmware(inbox, layout)
|
||||
if needed:
|
||||
self.status[inbox]=f"Indeling bewaard; firmware {needed}+ nodig voor deze tegels"
|
||||
return
|
||||
messages = [{'v': 1, 'op': 'layout', 'inbox': inbox, 'title': layout['title'], 'entities': [t['entity'] for t in layout['tiles']]}]
|
||||
if 'settings' in layout:
|
||||
@@ -224,16 +259,14 @@ class Manager:
|
||||
if next((s.get('board') for s in self.inventory()[0] if s['id']==inbox),None)=='guition':
|
||||
messages[0]['rotation'] = layout['settings'].get('rotation',0)
|
||||
for i,tile in enumerate(layout['tiles']):
|
||||
message=state_message(i,tile,self.ha.states)
|
||||
forecast=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)))
|
||||
if tile['entity'].startswith('sensor.') and hasattr(self.ha,'history'):
|
||||
hours=tile.get('options',{}).get('history_hours',24)
|
||||
key=(tile['entity'],hours)
|
||||
cached=self.histories.get(key)
|
||||
if not cached or time.monotonic()-cached[0]>300:
|
||||
try: values=await self.ha.history(*key)
|
||||
except Exception: values=[]
|
||||
cached=(time.monotonic(),values);self.histories[key]=cached
|
||||
message['history']={'hours':hours,'values':cached[1]}
|
||||
message['history']={'hours':hours,'values':await self.cached(self.histories, key, 300, lambda: self.ha.history(*key))}
|
||||
messages.append(message)
|
||||
previous = self.sent.get(inbox, [])
|
||||
force = force or not previous or messages[0] != previous[0]
|
||||
@@ -323,7 +356,8 @@ def create_app(manager, development=False):
|
||||
for screen in screens:
|
||||
screen['layout'] = manager.layouts.get(screen['id'], {'title': 'Thuis', 'tiles': []})
|
||||
screen['delivery'] = manager.status.get(screen['id'], 'Kies je eerste tegels')
|
||||
return web.json_response({'csrf': csrf, 'connected': manager.ha.online, 'screens': screens, 'entities': entities, 'backgrounds': TILE_BACKGROUNDS})
|
||||
builtin = [{'id': key, 'name': name, 'device': 'Ingebouwd op het scherm', 'area': '', 'state': 'ok'} for key, name in BUILTIN.items()]
|
||||
return web.json_response({'csrf': csrf, 'connected': manager.ha.online, 'screens': screens, 'entities': entities, 'backgrounds': TILE_BACKGROUNDS, 'builtin': builtin})
|
||||
async def save(request):
|
||||
manager.save(request.match_info['inbox'], await request.json())
|
||||
return web.json_response({'saved': True})
|
||||
|
||||
@@ -220,7 +220,31 @@ const domains = {
|
||||
select: ["Keuze", "≡", "#5862af", "#eaecfa"],
|
||||
input_select: ["Keuze", "≡", "#5862af", "#eaecfa"],
|
||||
button: ["Actie", "↗", "#5862af", "#eaecfa"],
|
||||
screen: ["Klok", "◷", "#25282c", "#e9ecf1"],
|
||||
sun: ["Zon", "☼", "#c86620", "#ffebdc"],
|
||||
timer: ["Kookwekker", "⏱", "#008577", "#def3ed"],
|
||||
person: ["Persoon", "☺", "#2f7d32", "#e1f2e2"],
|
||||
};
|
||||
const displayNames = { standard: "standaard", watch: "grote waarde", forecast: "weersvoorspelling", graph: "grafiek", digital: "digitale klok", analog: "analoge klok" };
|
||||
// Same packing as the firmware: wide tiles start in the left column and take a whole row.
|
||||
function packTiles(tiles) {
|
||||
let position = 0;
|
||||
const placement = tiles.map((tile) => {
|
||||
const wide = tile.options?.size === "wide";
|
||||
if (wide && position % 2 === 1) position++;
|
||||
const slot = { page: Math.floor(position / 6), slot: position % 6, wide };
|
||||
position += wide ? 2 : 1;
|
||||
return slot;
|
||||
});
|
||||
return { placement, pages: Math.max(1, Math.ceil(position / 6)) };
|
||||
}
|
||||
function supportsFirmware(major, minor, patch) {
|
||||
const version = inventory.screens.find((s) => s.id === selected)?.firmware || "";
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
|
||||
if (!match) return false;
|
||||
const [a, b, c] = match.slice(1).map(Number);
|
||||
return a > major || (a === major && (b > minor || (b === minor && c >= patch)));
|
||||
}
|
||||
function domainBadge(id) {
|
||||
const [title, symbol, color, background] = domains[id.split(".")[0]] || ["Entiteit", "◇", "#637184", "#edf0f4"];
|
||||
const badge = node("span", symbol, "domain-icon");
|
||||
@@ -236,43 +260,54 @@ function tileLimit() {
|
||||
if(!match)return 10;
|
||||
return Number(match[1])>0 || Number(match[2])>2 || Number(match[2])===2 && Number(match[3])>=7 ? 20 : 10;
|
||||
}
|
||||
function entityName(id) {
|
||||
return inventory.entities.find((e) => e.id === id)?.name || inventory.builtin?.find((e) => e.id === id)?.name || id;
|
||||
}
|
||||
function renderPreview() {
|
||||
const root = $("#layout-preview");
|
||||
root.replaceChildren();
|
||||
const pages = Math.max(1, Math.ceil(layout.tiles.length / 6));
|
||||
const { placement, pages } = packTiles(layout.tiles);
|
||||
for (let page = 0; page < pages; page++) {
|
||||
const frame = node("section", undefined, "screen-preview");
|
||||
frame.append(node("small", `Pagina ${page + 1} · ${$("#title").value || "Thuis"}`, "preview-heading"));
|
||||
const grid = node("div", undefined, "preview-grid");
|
||||
for (let slot = page * 6; slot < Math.min(page * 6 + 6, tileLimit()); slot++) {
|
||||
const tile = layout.tiles[slot];
|
||||
let filled = 0;
|
||||
layout.tiles.forEach((tile, index) => {
|
||||
const place = placement[index];
|
||||
if (place.page !== page) return;
|
||||
// Keep the grid honest: an empty right column before a wide tile is a real gap on the screen.
|
||||
for (; filled < place.slot; filled++) grid.append(node("span", "", "preview-gap"));
|
||||
const card = node("button", undefined, "preview-tile");
|
||||
if (tile) {
|
||||
const name = tile.name || inventory.entities.find(e => e.id === tile.entity)?.name || tile.entity;
|
||||
const background=inventory.backgrounds?.[tile.options?.background]?.color;
|
||||
if(background)card.style.backgroundColor=background;
|
||||
card.append(domainBadge(tile.entity), node("strong", name));
|
||||
card.draggable = true;
|
||||
card.ondragstart = (e) => { dragIndex = slot; e.dataTransfer.setData("text/plain", String(slot)); };
|
||||
card.ondragend = () => { dragIndex = -1; };
|
||||
card.ondragover = (e) => { if (dragIndex >= 0) e.preventDefault(); };
|
||||
card.ondrop = (e) => { e.preventDefault(); if (dragIndex >= 0) move(dragIndex, slot); };
|
||||
card.classList.toggle("chosen", selectedTile === tile.entity);
|
||||
card.setAttribute("aria-label", `Tegel ${slot + 1}: ${name}, instellen`);
|
||||
card.onclick = () => {
|
||||
selectedTile = tile.entity;
|
||||
renderTiles();
|
||||
document.querySelectorAll("#tiles > .tile")[slot]?.scrollIntoView({behavior: "smooth", block: "center"});
|
||||
};
|
||||
if (tile.options?.inline === "slider") card.append(node("span", "", "preview-slider"));
|
||||
if (tile.options?.display === "watch") card.append(node("small", "Grote waarde"));
|
||||
} else {
|
||||
card.classList.add("vacant");
|
||||
const name = tile.name || entityName(tile.entity);
|
||||
const background=inventory.backgrounds?.[tile.options?.background]?.color;
|
||||
if(background)card.style.backgroundColor=background;
|
||||
if (place.wide) card.classList.add("wide");
|
||||
card.append(domainBadge(tile.entity), node("strong", name));
|
||||
card.draggable = true;
|
||||
card.ondragstart = (e) => { dragIndex = index; e.dataTransfer.setData("text/plain", String(index)); };
|
||||
card.ondragend = () => { dragIndex = -1; };
|
||||
card.ondragover = (e) => { if (dragIndex >= 0) e.preventDefault(); };
|
||||
card.ondrop = (e) => { e.preventDefault(); if (dragIndex >= 0) move(dragIndex, index); };
|
||||
card.classList.toggle("chosen", selectedTile === tile.entity);
|
||||
card.setAttribute("aria-label", `Tegel ${index + 1}: ${name}, instellen`);
|
||||
card.onclick = () => {
|
||||
selectedTile = tile.entity;
|
||||
renderTiles();
|
||||
document.querySelectorAll("#tiles > .tile")[index]?.scrollIntoView({behavior: "smooth", block: "center"});
|
||||
};
|
||||
if (tile.options?.inline === "slider") card.append(node("span", "", "preview-slider"));
|
||||
const display = tile.options?.display;
|
||||
if (display && display !== "standard") card.append(node("small", displayNames[display] || display));
|
||||
grid.append(card);
|
||||
filled += place.wide ? 2 : 1;
|
||||
});
|
||||
if (layout.tiles.length < tileLimit())
|
||||
for (; filled < 6; filled++) {
|
||||
const card = node("button", undefined, "preview-tile vacant");
|
||||
card.append(node("span", "+"), node("small", "Tegel toevoegen"));
|
||||
card.onclick = () => { $("#search").focus(); $("#search").scrollIntoView({behavior:"smooth", block:"center"}); };
|
||||
grid.append(card);
|
||||
}
|
||||
grid.append(card);
|
||||
}
|
||||
frame.append(grid);
|
||||
root.append(frame);
|
||||
}
|
||||
@@ -282,8 +317,9 @@ function renderTiles() {
|
||||
$("#tiles").replaceChildren();
|
||||
$("#count").textContent = `${layout.tiles.length} / ${tileLimit()}${tileLimit()===10?" · update firmware voor 20":""}`;
|
||||
$("#no-tiles").hidden = layout.tiles.length > 0;
|
||||
const { placement } = packTiles(layout.tiles);
|
||||
layout.tiles.forEach((tile, i) => {
|
||||
if (i === 6) $("#tiles").append(node("li", "Pagina 2", "page-break"));
|
||||
if (i > 0 && placement[i].page !== placement[i - 1].page) $("#tiles").append(node("li", `Pagina ${placement[i].page + 1}`, "page-break"));
|
||||
const li = node("li", undefined, "tile");
|
||||
li.classList.toggle("selected-tile", selectedTile === tile.entity);
|
||||
li.draggable = true;
|
||||
@@ -310,8 +346,7 @@ function renderTiles() {
|
||||
const content = node("div"),
|
||||
input = node("input");
|
||||
input.value = tile.name;
|
||||
input.placeholder =
|
||||
inventory.entities.find((e) => e.id === tile.entity)?.name || tile.entity;
|
||||
input.placeholder = entityName(tile.entity);
|
||||
input.maxLength = 60;
|
||||
input.setAttribute("aria-label", `Naam voor tegel ${i + 1}`);
|
||||
input.oninput = () => {
|
||||
@@ -383,6 +418,11 @@ function renderTiles() {
|
||||
options.append(palette);
|
||||
const controls = {};
|
||||
const domain = tile.entity.split(".")[0];
|
||||
const displays = domain === "screen"
|
||||
? [["digital", "Digitale klok"], ["analog", "Analoge klok"]]
|
||||
: [["standard", "Naam en status"], ["watch", "Grote waarde"]];
|
||||
if (domain === "weather") displays.push(["forecast", "Weersvoorspelling (dubbelbreed)"]);
|
||||
if (domain === "sensor") displays.push(["graph", "Grafiek van de geschiedenis"]);
|
||||
const fields = [
|
||||
[
|
||||
"tap",
|
||||
@@ -393,15 +433,10 @@ function renderTiles() {
|
||||
["none", "Alleen bekijken"],
|
||||
],
|
||||
],
|
||||
[
|
||||
"display",
|
||||
"Weergave",
|
||||
[
|
||||
["standard", "Naam en status"],
|
||||
["watch", "Grote waarde"],
|
||||
],
|
||||
],
|
||||
["display", "Weergave", displays],
|
||||
["size", "Breedte (firmware 0.2.14+)", [["single", "Normaal"], ["wide", "Dubbelbreed"]]],
|
||||
];
|
||||
if (domain === "screen") fields.shift();
|
||||
if (
|
||||
["light", "switch", "input_boolean", "fan", "media_player"].includes(
|
||||
domain,
|
||||
@@ -449,10 +484,12 @@ function renderTiles() {
|
||||
(key === "tap"
|
||||
? "auto"
|
||||
: key === "display"
|
||||
? "standard"
|
||||
? (domain === "screen" ? "digital" : "standard")
|
||||
: key === "history_hours"
|
||||
? 24
|
||||
: "none");
|
||||
: key === "size"
|
||||
? "single"
|
||||
: "none");
|
||||
controls[key] = input;
|
||||
input.onchange = () => {
|
||||
tile.options = {
|
||||
@@ -461,6 +498,8 @@ function renderTiles() {
|
||||
};
|
||||
if (key === "display" && input.value === "watch")
|
||||
tile.options.inline = "none";
|
||||
if (key === "display" && input.value === "forecast")
|
||||
tile.options.size = "wide";
|
||||
if (key === "inline" && input.value === "slider")
|
||||
tile.options.display = "standard";
|
||||
for (const [field, control] of Object.entries(controls)) {
|
||||
@@ -473,9 +512,11 @@ function renderTiles() {
|
||||
label.append(input);
|
||||
options.append(label);
|
||||
}
|
||||
const inspectTile = node("button", "Inspecteer deze tegel", "quiet");
|
||||
inspectTile.onclick = () => inspect(tile.entity);
|
||||
options.append(inspectTile);
|
||||
if (domain !== "screen") {
|
||||
const inspectTile = node("button", "Inspecteer deze tegel", "quiet");
|
||||
inspectTile.onclick = () => inspect(tile.entity);
|
||||
options.append(inspectTile);
|
||||
}
|
||||
content.append(options);
|
||||
li.append(domainBadge(tile.entity), content, actions);
|
||||
$("#tiles").append(li);
|
||||
@@ -485,10 +526,10 @@ function renderResults() {
|
||||
if (!layout) return;
|
||||
const query = $("#search").value.toLocaleLowerCase();
|
||||
const chosen = new Set(layout.tiles.map((t) => t.entity));
|
||||
const matches = inventory.entities.filter(
|
||||
const matches = [...(inventory.builtin || []), ...inventory.entities].filter(
|
||||
(e) =>
|
||||
(!filter || e.id.startsWith(filter + ".") ||
|
||||
({switch:"input_boolean", number:"input_number", select:"input_select"}[filter] === e.id.split(".")[0])) &&
|
||||
({switch:"input_boolean", number:"input_number", select:"input_select", weather:"sun"}[filter] === e.id.split(".")[0])) &&
|
||||
`${e.name} ${e.id} ${e.device} ${e.area}`
|
||||
.toLocaleLowerCase()
|
||||
.includes(query),
|
||||
@@ -592,6 +633,9 @@ for (const [value, label] of [
|
||||
["weather", "Weer"],
|
||||
["number", "Waarden"],
|
||||
["select", "Keuzelijsten"],
|
||||
["person", "Personen"],
|
||||
["timer", "Kookwekkers"],
|
||||
["screen", "Klok"],
|
||||
]) {
|
||||
const b = node("button", label, value === "" ? "active" : "");
|
||||
if (value) b.prepend(domainBadge(value + "."));
|
||||
@@ -826,7 +870,7 @@ async function inspect(entity) {
|
||||
card.append(
|
||||
node(
|
||||
"small",
|
||||
`Kleine slider: ${options.inline === "slider" ? "ja" : "nee"} · Weergave: ${options.display === "watch" ? "grote waarde" : "standaard"} · 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"} · Achtergrond: ${inventory.backgrounds?.[options.background || "auto"]?.label || "Standaard"}`,
|
||||
),
|
||||
);
|
||||
$("#inspection-summary").append(card);
|
||||
|
||||
@@ -74,8 +74,9 @@
|
||||
<span id="count">0 / 10</span>
|
||||
</div>
|
||||
<p class="hint">
|
||||
Klik op een tegel om bediening en weergave te kiezen. Sleep om
|
||||
te ordenen. Zes tegels per pagina.
|
||||
Klik op een tegel om bediening, weergave en breedte te kiezen.
|
||||
Sleep om te ordenen. Zes vakjes per pagina; een dubbelbrede
|
||||
tegel telt voor twee en begint altijd links.
|
||||
</p>
|
||||
<div id="layout-preview" aria-label="Schermindeling"></div>
|
||||
<ol id="tiles"></ol>
|
||||
|
||||
@@ -711,6 +711,8 @@ pre {
|
||||
.preview-tile strong { flex: 1; min-width: 0; font-size: 13px; overflow-wrap: anywhere; }
|
||||
.preview-tile .domain-icon { width: 30px; height: 30px; flex-basis: 30px; font-size: 20px; }
|
||||
.preview-tile.chosen { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.preview-tile.wide { grid-column: span 2; }
|
||||
.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%); }
|
||||
.preview-tile small { flex-basis: 100%; }
|
||||
|
||||
Reference in New Issue
Block a user