Fix what a full test round of today's releases found: a ticking single clock, a switch that works in standby, no taps from a swipe (0.2.78)
A test and code-analysis round of 0.2.72 to 0.2.76 (suites at every tag, sanitizers, ESP32 and GitHub builds, host harnesses on both boards, the editor in a browser, best practice checked against ESPHome, LVGL, Home Assistant and Vue) found these, fixed here with Max's choices for the product questions: - Firmware 0.2.65: the second hand of a single analog clock moves again; on a page that is one full on/off tile the waking push also switches it; on the Guition a flick is never a tap and a finger that slides off a tile doesn't tap it; Previous/Next react to a firm press; the same Go to page tile may sit on several pages; a layout the screen has no memory for is refused instead of crashing; 64-bit redraw mask for 48 tiles; Montserrat 14 no longer built. - App: Save refuses a layout too long for one message or too big for eight pages; an offline screen keeps its firmware version from the device registry; HA refusals reach the editor; 128 KB requests; changelog out of the live stream; cached hashed assets; tini as init and a clean stop; changelog parsing without asterisks or crashes. - Editor: clicking the open screen keeps unsaved edits, edits during a save stay unsaved, a Page row and phone dragging to other pages, repeated page tiles, limits from the server, HA's error messages, a type-checked build. - tools/check.sh + GitHub Actions + a CYD flash budget; docs match 48 tiles and the new editor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,8 @@ import re
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from core import screen_firmware
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
DOMAINS = ('camera', 'image')
|
||||
@@ -55,17 +57,9 @@ def supported(entity):
|
||||
and entity.split('.')[0] in DOMAINS)
|
||||
|
||||
|
||||
def version(text):
|
||||
try:
|
||||
parts = tuple(int(part) for part in str(text or '').split('.'))
|
||||
except ValueError:
|
||||
return ()
|
||||
return parts if len(parts) == 3 else ()
|
||||
|
||||
|
||||
def can_show(screen):
|
||||
"""A paired Guition with firmware that draws camera images."""
|
||||
return bool(screen) and screen.get('board') in BOXES and version(screen.get('firmware')) >= MIN_FIRMWARE
|
||||
"""A paired Guition with firmware that draws camera images (the version feature gates go by, core.screen_firmware)."""
|
||||
return bool(screen) and screen.get('board') in BOXES and (screen_firmware(screen) or (0, 0, 0)) >= MIN_FIRMWARE
|
||||
|
||||
|
||||
def cover_supported(entity):
|
||||
@@ -76,7 +70,7 @@ def cover_supported(entity):
|
||||
|
||||
def can_show_cover(screen):
|
||||
"""A paired Guition with firmware that draws the media card's cover."""
|
||||
return bool(screen) and screen.get('board') in BOXES and version(screen.get('firmware')) >= COVER_MIN_FIRMWARE
|
||||
return bool(screen) and screen.get('board') in BOXES and (screen_firmware(screen) or (0, 0, 0)) >= COVER_MIN_FIRMWARE
|
||||
|
||||
|
||||
def cover_request(request):
|
||||
|
||||
@@ -3,39 +3,73 @@
|
||||
The Dockerfile copies CHANGELOG.md next to this file; a development checkout has it one folder up.
|
||||
Each `## <app> (firmware <fw>)` section gives its bullet lines as plain text, newest first.
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
LOG = logging.getLogger('screen_manager')
|
||||
|
||||
HEADER = re.compile(r'^## (\d+\.\d+\.\d+) \(firmware (\d+\.\d+\.\d+)\)')
|
||||
# A heading of level one or two ends a section; a `### ` inside one doesn't.
|
||||
SECTION_END = re.compile(r'^#{1,2}(\s|$)')
|
||||
BULLET = re.compile(r'^[-*+]\s+(.*)$')
|
||||
BOLD = re.compile(r'\*\*(.+?)\*\*')
|
||||
# *Full page*: single asterisks around text. A lone asterisk (5 * 3) or one inside a word stays (app 0.2.78).
|
||||
ITALIC = re.compile(r'(?<![\w*])\*(?=\S)([^*\n]*?\S)\*(?![\w*])')
|
||||
CODE = re.compile(r'`([^`]*)`')
|
||||
LINK = re.compile(r'\[([^\]]+)\]\([^)]+\)')
|
||||
|
||||
|
||||
def load(path=None, limit=20):
|
||||
"""[{app, firmware, lines}] for the latest sections; [] without a changelog."""
|
||||
"""[{app, firmware, lines}] for the latest sections; [] without a changelog, or with one that can't be read (a
|
||||
folder, no permission, not UTF-8): the Update badge then has no notes, and the app still starts (app 0.2.78)."""
|
||||
candidates = [Path(path)] if path else [Path(__file__).with_name('CHANGELOG.md'), Path(__file__).parent.parent / 'CHANGELOG.md']
|
||||
for candidate in candidates:
|
||||
if candidate.exists():
|
||||
return parse(candidate.read_text(), limit)
|
||||
try:
|
||||
return parse(candidate.read_text(encoding='utf-8'), limit)
|
||||
except (OSError, UnicodeDecodeError) as error:
|
||||
LOG.warning("What's new stays empty: %s can't be read (%s)", candidate, type(error).__name__)
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def parse(text, limit=20):
|
||||
sections = []
|
||||
"""The sections of a changelog. A bullet (`- ` or `* `) takes the lines that continue it, indented or wrapped
|
||||
right under it; bullets under any other heading of level one or two belong to no section (app 0.2.78)."""
|
||||
sections, current, item, blank = [], None, None, False
|
||||
|
||||
def close():
|
||||
if item is not None and current is not None:
|
||||
current['lines'].append(plain(' '.join(item)))
|
||||
|
||||
for line in text.splitlines():
|
||||
match = HEADER.match(line)
|
||||
if match:
|
||||
if len(sections) >= limit:
|
||||
break
|
||||
sections.append({'app': match.group(1), 'firmware': match.group(2), 'lines': []})
|
||||
elif sections and line.startswith('- '):
|
||||
sections[-1]['lines'].append(plain(line[2:]))
|
||||
stripped = line.strip()
|
||||
if SECTION_END.match(line):
|
||||
close()
|
||||
item, match = None, HEADER.match(line)
|
||||
if match and len(sections) >= limit:
|
||||
return sections
|
||||
current = {'app': match.group(1), 'firmware': match.group(2), 'lines': []} if match else None
|
||||
if current:
|
||||
sections.append(current)
|
||||
elif (bullet := BULLET.match(line)) and current is not None:
|
||||
close()
|
||||
item = [bullet.group(1).strip()]
|
||||
elif stripped and item is not None and (line[:1].isspace() or not blank) and not line.startswith('#'):
|
||||
# Markdown continues a bullet with an indented line, or with any line right under it.
|
||||
item.append(stripped)
|
||||
elif stripped:
|
||||
close()
|
||||
item = None
|
||||
blank = not stripped
|
||||
close()
|
||||
return sections
|
||||
|
||||
|
||||
def plain(markdown):
|
||||
text = BOLD.sub(r'\1', markdown)
|
||||
text = CODE.sub(r'\1', text)
|
||||
text = LINK.sub(r'\1', text)
|
||||
return text.strip()
|
||||
"""The text of one bullet without Markdown: bold, italics and links outside code, and the code's backticks."""
|
||||
parts = CODE.split(markdown)
|
||||
for index in range(0, len(parts), 2):
|
||||
parts[index] = LINK.sub(r'\1', ITALIC.sub(r'\1', BOLD.sub(r'\1', parts[index])))
|
||||
return ''.join(parts).strip()
|
||||
|
||||
@@ -14,11 +14,13 @@ import zipfile
|
||||
import tile_icons
|
||||
from core import (ALERT_CAMERA_FIELD, 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, FULL_PAGE_MIN_FIRMWARE, MAX_PAGES, MAX_TILES, SETTINGS_PAGE_MIN_FIRMWARE, SLOTS_PER_PAGE, TILE_BACKGROUNDS,
|
||||
TILE_EVENTS, TILE_RESULT_EVENT, WAKE_SLEEP_MIN_FIRMWARE, SETTING_ENTITIES_MIN_FIRMWARE,
|
||||
DARK_MODE_MIN_FIRMWARE)
|
||||
CONTROLS, DISPLAYS, FULL_PAGE_MIN_FIRMWARE, MAX_PAGES, MAX_SLOTS, MAX_TILES, PAGE_TILE_REPEAT_MIN_FIRMWARE,
|
||||
SETTINGS_PAGE_MIN_FIRMWARE, SLOTS_PER_PAGE, TILE_BACKGROUNDS, TILE_EVENTS, TILE_RESULT_EVENT, WAKE_SLEEP_MIN_FIRMWARE,
|
||||
SETTING_ENTITIES_MIN_FIRMWARE, DARK_MODE_MIN_FIRMWARE)
|
||||
# Full-page tiles, navigation tiles and forty-eight tiles per screen.
|
||||
FULL_PAGE_VERSION = '.'.join(str(part) for part in FULL_PAGE_MIN_FIRMWARE)
|
||||
# The same navigation tile on several pages (app 0.2.78).
|
||||
PAGE_TILE_REPEAT_VERSION = '.'.join(str(part) for part in PAGE_TILE_REPEAT_MIN_FIRMWARE)
|
||||
|
||||
NAME = 'esp-screens'
|
||||
# claude.ai accepts at most 200 characters; Claude Code picks the skill by this sentence.
|
||||
@@ -92,6 +94,8 @@ actions:
|
||||
| `name` | A name of your own on the tile; leave it out to keep Home Assistant's. |
|
||||
| `page` | Page, counted from 1. Without a spot the tile takes the first free one on that page. |
|
||||
| `row`, `column` | An exact spot on that page: row 1 to {SLOTS_PER_PAGE // 2}, column `left` or `right`. |
|
||||
| `slot` | An exact spot as the screen's sensor counts it, 0 to {MAX_SLOTS - 1}: instead of `page`, `row` and `column`. |
|
||||
| `from_page`, `from_slot` | Only for `esp_screens_move_tile`: which copy of a navigation tile that is on several pages to move (see below). |
|
||||
| `size` | `single`, `wide` or `full` (the whole page; firmware {FULL_PAGE_VERSION} or newer). |
|
||||
| `controls` | What you can operate on the tile itself (see below). |
|
||||
| `display` | How the tile draws itself (see below). |
|
||||
@@ -102,7 +106,11 @@ actions:
|
||||
|
||||
A tile with a control, a forecast or a sun path is drawn double-width on its own; you don't have to ask for that.
|
||||
|
||||
A navigation tile goes to another page: add the entity `screen.page_3` (for page 3, `screen.page_1` to `screen.page_{MAX_PAGES}`) with a `name` such as "Heating"; it shows an arrow (or an `icon`), its name and the page number, and a tap opens that page. Handy as a menu on page 2 when page 1 is one full-page light switch. One such tile per page it goes to; it is single or double width, never full-page.
|
||||
A navigation tile goes to another page: add the entity `screen.page_3` (for page 3, `screen.page_1` to `screen.page_{MAX_PAGES}`) with a `name` such as "Heating"; it shows an arrow (or an `icon`), its name and the page number, and a tap opens that page. Handy as a menu on page 2 when page 1 is one full-page light switch. It is single or double width, never full-page.
|
||||
|
||||
On firmware {PAGE_TILE_REPEAT_VERSION} or newer the same navigation tile can be on several pages, such as a `screen.page_1` named "Back" on every other page: add it with the `page` (or spot) it goes on, and a page without a copy gets a new one. Older firmware has one per page it goes to, and adding it again moves that one. Every other entity is on a screen once.
|
||||
|
||||
When a navigation tile is on several pages, say which copy you mean: `page` or `slot` for `esp_screens_add_tile` (change that copy) and `esp_screens_remove_tile`, `from_page` or `from_slot` for `esp_screens_move_tile` (its `page` and `slot` say where to), and in `esp_screens_order_tiles` each time you name it takes the next copy. An event that doesn't say acts on the first copy, the one with the lowest `slot` in the sensor.
|
||||
|
||||
### What a tile can do, per kind of entity
|
||||
|
||||
@@ -118,7 +126,7 @@ Everything else shows its name and state, and opens a card of its own on a long
|
||||
|
||||
### Reading a screen first
|
||||
|
||||
Every screen also publishes what it shows, as `sensor.esp_screens_<device name>`: the state is the number of tiles, and the attributes hold `title`, `pages` and `tiles` with `entity`, `name`, `page`, `row`, `column`, `size`, `controls` and `display` per tile (and `to_page` for a `screen.page_<n>` tile). Read that before moving things around, so you know what is already there and where.
|
||||
Every screen also publishes what it shows, as `sensor.esp_screens_<device name>`: the state is the number of tiles, and the attributes hold `title`, `pages` and `tiles` with `entity`, `name`, `page`, `row`, `column`, `slot`, `size`, `controls` and `display` per tile (and `to_page` for a `screen.page_<n>` tile), every copy of a navigation tile on its own. Read that before moving things around, so you know what is already there and where.
|
||||
|
||||
Ordering a page means naming the tiles that are on it, in the order you want:
|
||||
|
||||
@@ -135,7 +143,7 @@ A tile from another page has to be moved there first (`esp_screens_move_tile` wi
|
||||
|
||||
### What comes back
|
||||
|
||||
ESP Screens answers every event with `{TILE_RESULT_EVENT}`, carrying `ok`, the `screen`, the `entity` and, when it refused, an `error` that says why ("No screen called ...", "That spot is taken by ...", "This screen already has {MAX_TILES} tiles", "No page is empty; a full-page tile needs a page of its own"). The add-on log says the same. Nothing changes on a refused event.
|
||||
ESP Screens answers every event with `{TILE_RESULT_EVENT}`, carrying `ok`, the `screen`, the `entity`, the `page` and `slot` of the tile it placed, changed, moved or removed (not for an order) and, when it refused, an `error` that says why ("No screen called ...", "That spot is taken by ...", "This screen already has {MAX_TILES} tiles", "No page is empty; a full-page tile needs a page of its own"). The add-on log says the same. Nothing changes on a refused event.
|
||||
|
||||
### How to work
|
||||
|
||||
|
||||
+165
-32
@@ -12,8 +12,10 @@ import tile_icons
|
||||
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 camera image'.split())
|
||||
# Built-in cards without a Home Assistant entity; firmware 0.2.14+ renders them.
|
||||
BUILTIN = {'screen.clock': 'Clock', 'screen.settings': 'Settings', **{f'screen.page_{n}': f'Go to page {n}' for n in range(1, 9)}}
|
||||
# A navigation tile (firmware 0.2.62+): screen.page_<n> goes to page n; one per page it goes to, so an entity still appears once.
|
||||
# A navigation tile (firmware 0.2.62+): screen.page_<n> goes to page n. Firmware 0.2.65+ takes the same one on several
|
||||
# pages (a "Back to page 1" on every page); every other entity still appears once on a screen.
|
||||
PAGE_TILE = 'screen.page_'
|
||||
PAGE_TILE_REPEAT_MIN_FIRMWARE = (0, 2, 65)
|
||||
|
||||
def page_target(entity):
|
||||
"""The page a navigation tile opens, counted from one; 0 for any other entity."""
|
||||
@@ -28,11 +30,14 @@ COVER_MIN_FIRMWARE = (0, 2, 64)
|
||||
MAX_TILES = 48
|
||||
LEGACY_MAX_TILES = 20
|
||||
FULL_PAGE_MIN_FIRMWARE = (0, 2, 62)
|
||||
# Twenty tiles from firmware 0.2.7, ten before.
|
||||
TWENTY_TILES_MIN_FIRMWARE = (0, 2, 7)
|
||||
FIRST_MAX_TILES = 10
|
||||
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.64'
|
||||
FIRMWARE_VERSION = '0.2.65'
|
||||
# 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.
|
||||
@@ -115,6 +120,15 @@ def pack_slots(tiles):
|
||||
position += cells_of(size)
|
||||
return slots
|
||||
|
||||
def packed_slots(tiles):
|
||||
"""pack_slots for tiles that must fit on the screen: ValueError when the packing runs past the last page (48
|
||||
tiles with one of them double-width, or 43 with one full-page tile), before a save or an event stores it (app
|
||||
0.2.78; the save used to fail later with "Invalid tile position")."""
|
||||
slots = pack_slots(tiles)
|
||||
if any(footprint(slot, tile_size(tile))[-1] >= MAX_SLOTS for tile, slot in zip(tiles, slots)):
|
||||
raise ValueError("These tiles don't fit on eight pages; remove a tile or make one smaller.")
|
||||
return slots
|
||||
|
||||
def has_gaps(tiles):
|
||||
"""True when the stored positions differ from the in-order packing, so firmware
|
||||
before 0.2.26 (which ignores `slots`) would show another arrangement."""
|
||||
@@ -168,6 +182,50 @@ NAME_DEVICE_NAME = ('Device name', 'Apparaatnaam')
|
||||
NAME_IP_ADDRESS = ('IP address', 'IP-adres')
|
||||
SCREEN_ENTITY_NAMES = frozenset(NAME_TILE_SETTINGS + NAME_SCREEN_FIRMWARE + NAME_GUITION_TYPE + NAME_DEVICE_NAME + NAME_IP_ADDRESS)
|
||||
|
||||
def parse_firmware(text):
|
||||
"""(major, minor, patch) of a screen firmware version such as "0.2.63"; None for anything else. Strict on purpose:
|
||||
the firmware reports exactly its project version, and anything with a suffix is not a release."""
|
||||
match = re.fullmatch(r'([0-9]+)\.([0-9]+)\.([0-9]+)', text) if isinstance(text, str) else None
|
||||
return tuple(int(part) for part in match.groups()) if match else None
|
||||
|
||||
# Home Assistant's device registry keeps an ESPHome device's version while the device is offline or restarting. With
|
||||
# `esphome: project:` (both board profiles) the ESPHome integration writes "<project version> (ESPHome <version>)".
|
||||
REGISTRY_FIRMWARE = re.compile(r'^([0-9]+\.[0-9]+\.[0-9]+) \(ESPHome ')
|
||||
|
||||
def registry_firmware(sw_version):
|
||||
"""The screen firmware in a device registry sw_version, or None. Firmware built without a project has only
|
||||
ESPHome's own version there, which must never read as a screen firmware."""
|
||||
match = REGISTRY_FIRMWARE.match(sw_version) if isinstance(sw_version, str) else None
|
||||
return match.group(1) if match else None
|
||||
|
||||
def known_firmware(sensor, sw_version):
|
||||
"""The version text discover_screens gives a screen as `firmware_known`: the "Screen firmware" sensor when it holds
|
||||
one, else the device registry's; None when neither does."""
|
||||
return sensor if parse_firmware(sensor) else registry_firmware(sw_version)
|
||||
|
||||
def screen_firmware(screen):
|
||||
"""The firmware a screen's feature gates go by (app 0.2.78): its "Screen firmware" sensor, or while that has no
|
||||
version (the screen is offline or restarting) the one Home Assistant's device registry kept; None when neither
|
||||
says. Before, an offline screen counted as firmware 0.0.0 and a save of more than ten tiles was refused."""
|
||||
screen = screen or {}
|
||||
return parse_firmware(screen.get('firmware_known')) or parse_firmware(screen.get('firmware'))
|
||||
|
||||
def version_text(version):
|
||||
""""0.2.65" for (0, 2, 65); None for None."""
|
||||
return '.'.join(str(part) for part in version) if version else None
|
||||
|
||||
def tile_limit(version):
|
||||
"""How many tiles firmware `version` (a tuple, or None when unknown) takes."""
|
||||
version = version or (0, 0, 0)
|
||||
return MAX_TILES if version >= FULL_PAGE_MIN_FIRMWARE else LEGACY_MAX_TILES if version >= TWENTY_TILES_MIN_FIRMWARE else FIRST_MAX_TILES
|
||||
|
||||
def firmware_features(version):
|
||||
"""What the editor may offer a screen with firmware `version` (a tuple, or None): the tile limit, full-page and
|
||||
navigation tiles, and the same navigation tile on several pages."""
|
||||
version = version or (0, 0, 0)
|
||||
return {'tile_limit': tile_limit(version), 'full_page': version >= FULL_PAGE_MIN_FIRMWARE,
|
||||
'page_tiles_repeat': version >= PAGE_TILE_REPEAT_MIN_FIRMWARE}
|
||||
|
||||
def entity_slug(name):
|
||||
"""The end of an entity id Home Assistant derives from an entity name (ASCII names)."""
|
||||
return re.sub(r'[^a-z0-9]+', '_', str(name).lower()).strip('_')
|
||||
@@ -386,8 +444,15 @@ def validate_header(data):
|
||||
items.append(clean)
|
||||
return {'items': items}
|
||||
|
||||
def repeated_page_tiles(tiles):
|
||||
"""True when a navigation tile to the same page is on the screen more than once (firmware 0.2.65+)."""
|
||||
pages = [tile['entity'] for tile in tiles if page_target(tile['entity'])]
|
||||
return len(pages) != len(set(pages))
|
||||
|
||||
def min_firmware(layout):
|
||||
"""Oldest firmware that still accepts this layout; None when any version works."""
|
||||
if repeated_page_tiles(layout['tiles']):
|
||||
return PAGE_TILE_REPEAT_MIN_FIRMWARE
|
||||
if len(layout['tiles']) > LEGACY_MAX_TILES or any(is_full(t) or page_target(t['entity']) for t in layout['tiles']):
|
||||
return FULL_PAGE_MIN_FIRMWARE
|
||||
if any(t['entity'].split('.')[0] in CAMERA_DOMAINS for t in layout['tiles']):
|
||||
@@ -398,8 +463,8 @@ def min_firmware(layout):
|
||||
return (0, 2, 16)
|
||||
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)
|
||||
if len(layout['tiles']) > FIRST_MAX_TILES:
|
||||
return TWENTY_TILES_MIN_FIRMWARE
|
||||
return None
|
||||
|
||||
def short(value, limit):
|
||||
@@ -543,6 +608,29 @@ def event_slot(data, page):
|
||||
def page_of(tile):
|
||||
return tile.get('slot', 0) // SLOTS_PER_PAGE
|
||||
|
||||
def copies_of(tiles, entity):
|
||||
"""The tiles of one entity in slot order: one, or several copies of a navigation tile (firmware 0.2.65+)."""
|
||||
return sorted((tile for tile in tiles if tile['entity'] == entity), key=lambda tile: tile.get('slot', 0))
|
||||
|
||||
def pick_copy(found, page=None, slot=None):
|
||||
"""The copy an event means: the one covering `slot`, else the first on `page`, else the first; None when none is
|
||||
there. Only a navigation tile has several; an event that doesn't say which acts on the first (app 0.2.78)."""
|
||||
if slot is not None:
|
||||
return next((tile for tile in found if 'slot' in tile and slot in footprint(tile['slot'], tile_size(tile))), None)
|
||||
if page is not None:
|
||||
return next((tile for tile in found if page_of(tile) == page), None)
|
||||
return found[0] if found else None
|
||||
|
||||
def event_source(data):
|
||||
"""(page, slot) of the copy a move event means: `from_page` counted from one, or `from_slot`; None when not named."""
|
||||
page = event_page({'page': data.get('from_page')})
|
||||
slot = event_slot({'slot': data.get('from_slot')}, None)
|
||||
return (slot // SLOTS_PER_PAGE if slot is not None else page), slot
|
||||
|
||||
def where(page, slot):
|
||||
"""The place an event named, as its answer says it: "spot 12" or "page 3"."""
|
||||
return f'spot {slot}' if slot is not None else f'page {page + 1}'
|
||||
|
||||
def pack_page(tiles, page):
|
||||
"""Give these tiles the cells of one page, in the order they are in."""
|
||||
position = page * SLOTS_PER_PAGE
|
||||
@@ -558,12 +646,18 @@ def pack_page(tiles, page):
|
||||
tile['slot'] = position
|
||||
position += cells_of(size)
|
||||
|
||||
def apply_tile_event(layout, action, data):
|
||||
"""The layout after one tile event. Raises ValueError with the sentence the log and the answer show."""
|
||||
def run_tile_event(layout, action, data, repeat_pages=False):
|
||||
"""(layout, tile): the layout after one tile event and the tile it placed, changed, moved or removed (None for an
|
||||
order). Raises ValueError with the sentence the log and the answer show.
|
||||
|
||||
Only a navigation tile can be on a screen more than once, and only when its firmware takes that (`repeat_pages`,
|
||||
0.2.65+). An event then names the copy it means by a spot it covers or by its page: `slot` or `page` for add and
|
||||
remove, `from_slot` or `from_page` for a move (whose `slot` and `page` say where to), and each mention in an order
|
||||
takes the next copy. Without that it acts on the first copy in spot order (app 0.2.78)."""
|
||||
result = {key: value for key, value in layout.items() if key != 'tiles'}
|
||||
tiles = [dict(tile) for tile in layout.get('tiles', [])]
|
||||
entity = str(data.get('entity') or '').strip()
|
||||
page, slot = event_page(data), None
|
||||
page = event_page(data)
|
||||
slot = event_slot(data, page)
|
||||
if slot is not None and page is None:
|
||||
page = slot // SLOTS_PER_PAGE
|
||||
@@ -574,33 +668,57 @@ def apply_tile_event(layout, action, data):
|
||||
wanted = [str(item).strip() for item in wanted if str(item).strip()]
|
||||
if not wanted:
|
||||
raise ValueError('Give the entities in the order you want them.')
|
||||
known = {tile['entity']: tile for tile in tiles}
|
||||
missing = [name for name in wanted if name not in known]
|
||||
picked, taken, missing, elsewhere, extra = [], set(), [], [], []
|
||||
for name in wanted:
|
||||
copies = copies_of(tiles, name)
|
||||
here = [tile for tile in copies if page is None or page_of(tile) == page]
|
||||
free = [tile for tile in here if id(tile) not in taken]
|
||||
if not copies:
|
||||
missing.append(name)
|
||||
elif not here:
|
||||
elsewhere.append(name)
|
||||
elif not free:
|
||||
extra.append(name)
|
||||
else:
|
||||
picked.append(free[0])
|
||||
taken.add(id(free[0]))
|
||||
if missing:
|
||||
raise ValueError('Not on this screen: ' + ', '.join(missing) + '.')
|
||||
if elsewhere:
|
||||
raise ValueError('Not on page %d: %s. Move it there first.' % (page + 1, ', '.join(elsewhere)))
|
||||
if extra:
|
||||
raise ValueError('Named more often than it is on %s: %s.' % ('this screen' if page is None else f'page {page + 1}',
|
||||
', '.join(dict.fromkeys(extra))))
|
||||
if page is None:
|
||||
rest = [tile for tile in tiles if tile['entity'] not in wanted]
|
||||
tiles = [known[name] for name in wanted] + rest
|
||||
for tile, cell in zip(tiles, pack_slots(tiles)):
|
||||
tiles = picked + [tile for tile in tiles if id(tile) not in taken]
|
||||
for tile, cell in zip(tiles, packed_slots(tiles)):
|
||||
tile['slot'] = cell
|
||||
else:
|
||||
elsewhere = [name for name in wanted if page_of(known[name]) != page]
|
||||
if elsewhere:
|
||||
raise ValueError('Not on page %d: %s. Move it there first.' % (page + 1, ', '.join(elsewhere)))
|
||||
on_page = [known[name] for name in wanted] + [t for t in tiles if page_of(t) == page and t['entity'] not in wanted]
|
||||
pack_page(on_page, page)
|
||||
pack_page(picked + [tile for tile in tiles if page_of(tile) == page and id(tile) not in taken], page)
|
||||
result['tiles'] = tiles
|
||||
return result
|
||||
return result, None
|
||||
if not entity:
|
||||
raise ValueError('Name the entity.')
|
||||
found = next((tile for tile in tiles if tile['entity'] == entity), None)
|
||||
copies = copies_of(tiles, entity)
|
||||
found = copies[0] if copies else None
|
||||
if action == 'remove':
|
||||
source_page, source_slot = event_source(data)
|
||||
if source_page is None:
|
||||
source_page, source_slot = page, slot
|
||||
if not found:
|
||||
raise ValueError(f'{entity} is not on this screen.')
|
||||
tiles.remove(found)
|
||||
found = pick_copy(copies, source_page, source_slot)
|
||||
if not found:
|
||||
raise ValueError(f'{entity} is not on {where(source_page, source_slot)}.')
|
||||
# The copy that was named itself, not the first tile equal to it.
|
||||
tiles = [tile for tile in tiles if tile is not found]
|
||||
elif action == 'move':
|
||||
if not found:
|
||||
raise ValueError(f'{entity} is not on this screen; add it first.')
|
||||
source_page, source_slot = event_source(data)
|
||||
found = pick_copy(copies, source_page, source_slot)
|
||||
if not found:
|
||||
raise ValueError(f'{entity} is not on {where(source_page, source_slot)}.')
|
||||
if page is None and slot is None:
|
||||
raise ValueError('Name the page or the spot to move it to.')
|
||||
found.pop('slot', None)
|
||||
@@ -608,6 +726,12 @@ def apply_tile_event(layout, action, data):
|
||||
else:
|
||||
if not entity_id(entity) and entity not in BUILTIN:
|
||||
raise ValueError(f'{entity} cannot go on a screen.')
|
||||
# A navigation tile the firmware takes more than once: the copy on the named spot or page changes, and anywhere
|
||||
# else a new copy goes (app 0.2.78). Every other tile, or one without a place, is the one that is there.
|
||||
chosen = False
|
||||
if copies and repeat_pages and page_target(entity) and (page is not None or slot is not None):
|
||||
found = pick_copy(copies, page, slot)
|
||||
chosen = found is not None
|
||||
was_size, had_slot = tile_size(found) if found else 'single', (found or {}).get('slot')
|
||||
options = tile_options(data, (found or {}).get('options'))
|
||||
tile = found or {'entity': entity, 'name': ''}
|
||||
@@ -622,23 +746,30 @@ def apply_tile_event(layout, action, data):
|
||||
raise ValueError(f'This screen already has {MAX_TILES} tiles; remove one first.')
|
||||
tiles.append(tile)
|
||||
size = tile_size(tile)
|
||||
move = found is None or had_slot is None or page is not None or slot is not None
|
||||
move = found is None or had_slot is None or (not chosen and (page is not None or slot is not None))
|
||||
if not move and size != was_size:
|
||||
# A tile that just grew keeps its spot when the cells it needs are free.
|
||||
start = page_start(had_slot) if size == 'full' else had_slot - had_slot % 2 if size == 'wide' else had_slot
|
||||
move = start != had_slot or bool(set(footprint(start, size)) & occupied_cells([t for t in tiles if t is not tile]))
|
||||
if move:
|
||||
tile.pop('slot', None)
|
||||
if size == 'full' and had_slot is not None and page is None and slot is None:
|
||||
if size == 'full' and had_slot is not None and ((page is None and slot is None) or chosen):
|
||||
# A tile that grew to the whole page stays on its page when the page is otherwise empty.
|
||||
try:
|
||||
place_tile(tile, tiles, had_slot // SLOTS_PER_PAGE)
|
||||
except ValueError:
|
||||
place_tile(tile, tiles)
|
||||
elif chosen:
|
||||
place_tile(tile, tiles, page_of({'slot': had_slot}))
|
||||
else:
|
||||
place_tile(tile, tiles, page, slot)
|
||||
found = tile
|
||||
result['tiles'] = tiles
|
||||
return result
|
||||
return result, found
|
||||
|
||||
def apply_tile_event(layout, action, data, repeat_pages=False):
|
||||
"""The layout after one tile event (run_tile_event without the tile it acted on)."""
|
||||
return run_tile_event(layout, action, data, repeat_pages)[0]
|
||||
|
||||
def layout_snapshot(screen, layout):
|
||||
"""What a screen shows, for the sensor the app publishes in Home Assistant: one entry per tile with
|
||||
@@ -717,7 +848,8 @@ def validate_layout(data, stored=False):
|
||||
for tile in tiles:
|
||||
if not isinstance(tile, dict) or not entity_id(tile.get('entity')):
|
||||
raise ValueError("This entity isn't supported.")
|
||||
if tile['entity'] in seen:
|
||||
# A navigation tile may go on several pages (min_firmware asks 0.2.65 for that); anything else appears once.
|
||||
if tile['entity'] in seen and not page_target(tile['entity']):
|
||||
raise ValueError('An entity can only appear once on a screen.')
|
||||
name = tile.get('name', '')
|
||||
if not isinstance(name, str) or len(name.encode()) > 80:
|
||||
@@ -796,7 +928,7 @@ def validate_layout(data, stored=False):
|
||||
item['slot'] = slot
|
||||
clean.sort(key=lambda item: item['slot'])
|
||||
else:
|
||||
for item, slot in zip(clean, pack_slots(clean)):
|
||||
for item, slot in zip(clean, packed_slots(clean)):
|
||||
item['slot'] = slot
|
||||
result = {'title': title.strip(), 'tiles': clean}
|
||||
# Pages kept on purpose, empty ones included; the screen shows at least what the tiles need.
|
||||
@@ -1300,13 +1432,10 @@ def alert_targets(screens):
|
||||
"""(ready, skipped): the paired screens that can show an alert now, and the others with the reason.
|
||||
|
||||
One call per device: a screen that shows up twice (an old inbox next to a renamed one) counts once."""
|
||||
minimum = tuple(int(part) for part in ALERT_MIN_FIRMWARE.split('.'))
|
||||
minimum = parse_firmware(ALERT_MIN_FIRMWARE)
|
||||
ready, skipped, nodes = [], [], set()
|
||||
for screen in screens:
|
||||
try:
|
||||
version = tuple(int(part) for part in str(screen.get('firmware') or '').split('.'))
|
||||
except ValueError:
|
||||
version = ()
|
||||
version = screen_firmware(screen)
|
||||
node = screen.get('node')
|
||||
if node in nodes:
|
||||
continue
|
||||
@@ -1314,7 +1443,7 @@ def alert_targets(screens):
|
||||
skipped.append((screen, 'device name unknown'))
|
||||
elif not screen.get('online'):
|
||||
skipped.append((screen, 'offline'))
|
||||
elif len(version) != 3 or version < minimum:
|
||||
elif version is None or version < minimum:
|
||||
skipped.append((screen, f"firmware {screen.get('firmware') or 'unknown'}"))
|
||||
else:
|
||||
ready.append(screen)
|
||||
@@ -1352,9 +1481,13 @@ def discover_screens(registry, states, devices, areas):
|
||||
device = device_map.get(item.get('device_id'), {})
|
||||
state = states.get(eid, {})
|
||||
area = area_map.get(item.get('area_id') or device.get('area_id'), '')
|
||||
firmware = versions.get(item.get('device_id'), 'unknown')
|
||||
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'),
|
||||
'firmware': firmware,
|
||||
# What feature gates go by: the sensor, or Home Assistant's device registry while the screen is
|
||||
# offline or restarting (app 0.2.78).
|
||||
'firmware_known': known_firmware(firmware, device.get('sw_version')),
|
||||
'board': boards.get(item.get('device_id'), 'unknown'),
|
||||
'node': nodes.get(item.get('device_id')), 'ip': addresses.get(item.get('device_id')),
|
||||
'device': device.get('name') or '',
|
||||
|
||||
@@ -39,7 +39,7 @@ def profile_meta(text):
|
||||
|
||||
class Firmware:
|
||||
OVERRIDE_SUFFIX = '.local.yaml'
|
||||
OVERRIDE_LIMIT = 12 * 1024 # bytes; the request body limit is 16 KB and JSON escaping grows the text
|
||||
OVERRIDE_LIMIT = 12 * 1024 # bytes; checked here, far below the request body limit (128 KB since app 0.2.78)
|
||||
PROTECTED_OVERRIDE_KEYS = frozenset({'esphome', 'api', 'ota', 'wifi', 'packages',
|
||||
'external_components', 'captive_portal'})
|
||||
PROTECTED_SUBSTITUTIONS = frozenset({'DEVICE_NAME', 'DEVICE_FRIENDLY_NAME', 'SCREEN_FIRMWARE_VERSION'})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""HA Ingress app. HA writes: text.set_value on discovered inboxes (or the screen_message action on firmware 0.2.33+), each screen's alert actions when an alert event for every screen fires, and one persistent notification when a nightly update stops."""
|
||||
import asyncio
|
||||
from collections import Counter
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
@@ -17,8 +18,9 @@ import tile_icons
|
||||
from updates import Updater
|
||||
|
||||
from aiohttp import ClientError, ClientSession, ClientTimeout, WSMsgType, web
|
||||
from core import BROADCAST_EVENTS, BROADCAST_SHOW, BUILTIN, CAMERA_DOMAINS, entity_id, 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_camera, 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 BROADCAST_EVENTS, BROADCAST_SHOW, BUILTIN, CAMERA_DOMAINS, entity_id, SETTINGS_BESIDE_BLOCK, TILE_EVENTS, TILE_RESULT_EVENT, layout_snapshot, match_screen, HEADER_MIN_FIRMWARE, NAME_TILE_SETTINGS, TILE_BACKGROUNDS, TRANSPORT_MIN_FIRMWARE, alert_camera, 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, 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, state_word
|
||||
from core import PAGE_TILE_REPEAT_MIN_FIRMWARE, SLOTS_PER_PAGE, firmware_features, packed_slots, run_tile_event, screen_firmware, version_text
|
||||
import header_bar
|
||||
import history_card
|
||||
from zoneinfo import ZoneInfo
|
||||
@@ -635,13 +637,11 @@ class Manager:
|
||||
self.notify()
|
||||
|
||||
def firmware_version(self, inbox, screen=None):
|
||||
"""The firmware a screen's features go by: its sensor, or while that has no version (offline, restarting) the
|
||||
one Home Assistant's device registry kept (app 0.2.78); None when neither says (core.screen_firmware)."""
|
||||
if screen is None:
|
||||
screen = self.screen(inbox) or {}
|
||||
try:
|
||||
version=tuple(int(part) for part in screen.get('firmware','').split('.'))
|
||||
return version if len(version)==3 else None
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return screen_firmware(screen)
|
||||
|
||||
def supports_twenty(self, inbox):
|
||||
version=self.firmware_version(inbox)
|
||||
@@ -963,7 +963,11 @@ class Manager:
|
||||
layout['settings']['rotation']=self.layouts.get(inbox,{}).get('settings',{}).get('rotation',0)
|
||||
if layout.get('settings',{}).get('rotation',0) and screen.get('board')!='guition':
|
||||
raise ValueError('Rotation requires a Guition with firmware 0.2.9 or newer.')
|
||||
old_tiles = {t['entity']:t for t in self.layouts.get(inbox,{}).get('tiles',[])}
|
||||
# A page from before an option existed sends its tiles without it. It never sends a navigation tile twice
|
||||
# (firmware 0.2.65+), so a copy keeps exactly what it was sent with.
|
||||
old_list = self.layouts.get(inbox,{}).get('tiles',[])
|
||||
old_counts, new_counts = Counter(t['entity'] for t in old_list), Counter(t['entity'] for t in layout['tiles'])
|
||||
old_tiles = {t['entity']:t for t in old_list if old_counts[t['entity']] == 1 and new_counts[t['entity']] == 1}
|
||||
for tile in layout['tiles']:
|
||||
old_options=old_tiles.get(tile['entity'],{}).get('options',{})
|
||||
for key in ('background', 'icon', 'controls'):
|
||||
@@ -974,7 +978,7 @@ class Manager:
|
||||
# Restored options can widen a tile: an editor without positions packs again with
|
||||
# the real widths, and explicit positions are checked once more for overlap.
|
||||
if not any(isinstance(t, dict) and 'slot' in t for t in data.get('tiles', [])):
|
||||
for tile, slot in zip(layout['tiles'], pack_slots(layout['tiles'])):
|
||||
for tile, slot in zip(layout['tiles'], packed_slots(layout['tiles'])):
|
||||
tile['slot'] = slot
|
||||
layout = validate_layout(layout)
|
||||
known = {e['id'] for e in entities} | set(BUILTIN)
|
||||
@@ -982,6 +986,13 @@ class Manager:
|
||||
raise ValueError('A chosen entity no longer exists. Look up the new entity.')
|
||||
if any(item['type'] == 'entity' and item['entity'] not in known and item['entity'] not in self.ha.states for item in header_items(layout)):
|
||||
raise ValueError('An entity in the top bar no longer exists. Choose a different one.')
|
||||
# The screen takes the layout in one message of at most 4096 bytes, which lists every tile's entity id: 48 tiles
|
||||
# with long ids can pass every rule above and still never reach the screen (app 0.2.78).
|
||||
try:
|
||||
encode(self.layout_message(inbox, layout, screen))
|
||||
except ValueError:
|
||||
raise ValueError("These tiles' entity IDs are too long together to fit in one message to the screen; "
|
||||
"remove a few tiles.") from None
|
||||
updated = {**self.layouts, inbox: layout}
|
||||
self.write_layouts(updated)
|
||||
self.layouts = updated
|
||||
@@ -1132,6 +1143,12 @@ class Manager:
|
||||
return False
|
||||
tiles = layout['tiles']
|
||||
layout_msg = self.layout_message(inbox, layout, screen)
|
||||
try:
|
||||
encode(layout_msg)
|
||||
except ValueError:
|
||||
# Saved by an app before 0.2.78, which didn't check this: nothing to retry until the layout changes.
|
||||
self.status[inbox] = 'Layout saved; too large for one message to the screen, remove a few tiles'
|
||||
return False
|
||||
previous = self.sent.get(inbox)
|
||||
full = force or not previous or previous['layout'] != layout_msg or dirty is None
|
||||
# The top bar right after the layout (firmware 0.2.32+; older firmware draws the clock of show_clock).
|
||||
@@ -1459,13 +1476,16 @@ class Manager:
|
||||
return {'sent': len(ready) - len(failed), 'skipped': len(skipped), 'failed': len(failed)}
|
||||
|
||||
async def tile_event(self, event_type, data):
|
||||
"""One tile event: the screen it names, the changed layout, saved and pushed like the editor does."""
|
||||
"""One tile event: the screen it names, the changed layout, saved and pushed like the editor does. Returns the
|
||||
screen and the tile the event acted on (None for an order)."""
|
||||
screen = match_screen(self.screens(), data.get('screen'), self.layouts)
|
||||
inbox = self.aliases.get(screen['id'], screen['id'])
|
||||
layout = apply_tile_event(self.layouts.get(inbox) or {'title': screen['name'], 'tiles': []}, TILE_EVENTS[event_type], data)
|
||||
# Firmware 0.2.65+ takes a navigation tile on several pages; an older screen keeps one per page it goes to.
|
||||
repeat = (self.firmware_version(inbox, screen) or (0, 0, 0)) >= PAGE_TILE_REPEAT_MIN_FIRMWARE
|
||||
layout, tile = run_tile_event(self.layouts.get(inbox) or {'title': screen['name'], 'tiles': []}, TILE_EVENTS[event_type], data, repeat)
|
||||
await self.check_supported(inbox, layout)
|
||||
self.save(inbox, layout)
|
||||
return screen
|
||||
return screen, tile
|
||||
|
||||
async def tile_loop(self):
|
||||
"""Tile events (app 0.2.51+) in arrival order, apart from the sync loop, which can be busy for a
|
||||
@@ -1475,8 +1495,11 @@ class Manager:
|
||||
event_type, data = await queue.get()
|
||||
answer = {'event': event_type, 'screen': data.get('screen') or '', 'entity': data.get('entity') or ''}
|
||||
try:
|
||||
screen = await self.tile_event(event_type, data)
|
||||
screen, tile = await self.tile_event(event_type, data)
|
||||
answer.update(ok=True, screen=screen['name'])
|
||||
if tile is not None and 'slot' in tile:
|
||||
# Which tile it was, which matters for a navigation tile that is on several pages (app 0.2.78).
|
||||
answer.update(page=tile['slot'] // SLOTS_PER_PAGE + 1, slot=tile['slot'])
|
||||
LOG.info('%s: %s on %s', event_type, answer['entity'] or 'order', screen['name'])
|
||||
await self.publish_layouts()
|
||||
except Exception as error:
|
||||
@@ -1529,8 +1552,14 @@ def create_app(manager, development=False):
|
||||
response = await handler(request)
|
||||
except ValueError as error:
|
||||
return web.json_response({'error': str(error)}, status=400)
|
||||
# Home Assistant said no, or can't be reached (Identify, Try it, a setting): a sentence the page shows, never a
|
||||
# bare 500 with a traceback in the log (app 0.2.78). Refused is a ConnectionError, so it comes first.
|
||||
except Refused as error:
|
||||
return web.json_response({'error': f"Home Assistant didn't take it: {error.detail or 'no reason given'}."}, status=400)
|
||||
except (ConnectionError, TimeoutError):
|
||||
return web.json_response({'error': "Home Assistant isn't reachable right now. Try again in a moment."}, status=503)
|
||||
except web.HTTPRequestEntityTooLarge:
|
||||
return web.json_response({'error': 'That is too much text for one request. Keep it below 12 KB.'}, status=413)
|
||||
return web.json_response({'error': 'That request is too large.'}, status=413)
|
||||
except (TypeError, KeyError):
|
||||
return web.json_response({'error': 'Invalid input. Check the name, board, and chosen tiles.'}, status=400)
|
||||
if response.prepared:
|
||||
@@ -1540,9 +1569,19 @@ def create_app(manager, development=False):
|
||||
response.headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'"
|
||||
return response
|
||||
|
||||
app = web.Application(middlewares=[guard], client_max_size=16*1024)
|
||||
# A layout of 48 tiles with actions on their taps passes 16 KB, the limit from the twenty-tile days; the largest the
|
||||
# rules allow stays under about 75 KB (app 0.2.78). The YAML override keeps its own 12 KB limit (firmware.py).
|
||||
app = web.Application(middlewares=[guard], client_max_size=128*1024)
|
||||
static = Path(__file__).parent / 'static'
|
||||
|
||||
async def cache_assets(request, response):
|
||||
"""The built script, styles and fonts under /assets/ never change under their name (Vite names each file after a
|
||||
hash of its content), so a browser keeps them instead of fetching about 310 KB on every open (app 0.2.78).
|
||||
`private`: behind ingress the request carries Home Assistant's session. index.html and api/ stay no-store."""
|
||||
if request.path.startswith('/assets/') and response.status in (200, 304):
|
||||
response.headers['Cache-Control'] = 'private, max-age=31536000, immutable'
|
||||
app.on_response_prepare.append(cache_assets)
|
||||
|
||||
# The page is the Vite build of web/ (npm run build writes it here): index.html names its script and styles
|
||||
# by a hash of their content, so a browser that keeps old copies anyway (Safari did after an update) never
|
||||
# runs an old script against a new page.
|
||||
@@ -1562,6 +1601,11 @@ def create_app(manager, development=False):
|
||||
screen['update'] = manager.updates.state_for(screen, profiles)
|
||||
screen['alert_action'] = alert_service(screen.get('node'))
|
||||
screen['dismiss_action'] = alert_service(screen.get('node'), 'dismiss_alert')
|
||||
# What the editor may offer this screen, by the firmware the app's own checks go by (app 0.2.78): an offline
|
||||
# screen keeps its 48 tiles, and the editor needn't know the version rules.
|
||||
version = manager.firmware_version(screen['id'], screen)
|
||||
screen['firmware_known'] = version_text(version)
|
||||
screen.update(firmware_features(version))
|
||||
return {'csrf': csrf, 'connected': manager.ha.online, 'screens': screens,
|
||||
'pending': manager.pending_profiles(screens, profiles),
|
||||
'updates': manager.updates.summary(screens, profiles)}
|
||||
@@ -1576,6 +1620,8 @@ def create_app(manager, development=False):
|
||||
payload['controls'] = controls_catalogue()
|
||||
payload['icons'] = tile_icons.editor()
|
||||
payload['alerts'] = alert_reference()
|
||||
# What's new for the Update badge: here only, not in every live update (app 0.2.78).
|
||||
payload['changelog'] = manager.updates.changelog
|
||||
payload['claude_skill'] = claude_skill.status(manager.skill_dir)
|
||||
payload['builtin'] = [{'id': key, 'name': name, 'device': 'Built into the screen', 'area': '', 'state': 'ok'} for key, name in BUILTIN.items()]
|
||||
payload['header'] = {**header_bar.catalogue(), 'suggestions': {
|
||||
@@ -1781,7 +1827,9 @@ async def main():
|
||||
async with ClientSession(timeout=ClientTimeout(total=20)) as session:
|
||||
ha = HomeAssistant(session, os.environ.get('HA_API', 'http://supervisor/core/api'), token)
|
||||
manager = Manager(ha, Path(os.environ.get('SCREEN_DATA', '/data')) / 'screens.json')
|
||||
runner = web.AppRunner(create_app(manager, development), access_log=None)
|
||||
# handle_signals: SIGTERM (the Supervisor stopping the app, `docker stop`) and SIGINT end the app through the
|
||||
# cleanup below, which also stops a running build, instead of waiting ten seconds for SIGKILL (app 0.2.78).
|
||||
runner = web.AppRunner(create_app(manager, development), access_log=None, handle_signals=True)
|
||||
await runner.setup()
|
||||
await web.TCPSite(runner, '127.0.0.1' if development else '0.0.0.0', 8099).start()
|
||||
# Camera images for the screens: their own port on the LAN, not the ingress page (docs/CAMERA.md).
|
||||
@@ -1801,4 +1849,6 @@ async def main():
|
||||
|
||||
if __name__ == '__main__':
|
||||
logging.basicConfig(level=logging.INFO, format='%(levelname)s %(message)s')
|
||||
asyncio.run(main())
|
||||
# The signal arrives as GracefulExit once everything is cleaned up, as in aiohttp's own run_app: a normal stop.
|
||||
with contextlib.suppress(web.GracefulExit):
|
||||
asyncio.run(main())
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>ESP Screens</title>
|
||||
<script type="module" crossorigin src="./assets/index-Cj3m_z3Z.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BL5pkID8.css">
|
||||
<script type="module" crossorigin src="./assets/index-DfYonu9c.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DUJYFAbE.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -8,17 +8,13 @@ from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
import changelog
|
||||
from core import FIRMWARE_VERSION
|
||||
from core import FIRMWARE_VERSION, parse_firmware
|
||||
|
||||
LOG = logging.getLogger('screen_manager')
|
||||
NIGHT_HOURS = range(3, 6)
|
||||
|
||||
def parse_version(text):
|
||||
try:
|
||||
parts = tuple(int(part) for part in str(text).split('.'))
|
||||
except ValueError:
|
||||
return None
|
||||
return parts if len(parts) == 3 else None
|
||||
# Strict X.Y.Z, the one rule the feature gates follow too: an update goes by what the screen's sensor reports now.
|
||||
parse_version = parse_firmware
|
||||
|
||||
TARGET = parse_version(FIRMWARE_VERSION)
|
||||
|
||||
@@ -105,8 +101,9 @@ class Updater:
|
||||
'result': self.results.get(screen['id'])}
|
||||
|
||||
def summary(self, screens=None, profiles=None):
|
||||
# The changelog goes with the full inventory only (app 0.2.78): this summary is in every live update of the page.
|
||||
return {'auto': self.auto, 'target': FIRMWARE_VERSION, 'busy': self.current,
|
||||
'pending': len(self.pending(screens, profiles)), 'last_round': self.last_round, 'changelog': self.changelog}
|
||||
'pending': len(self.pending(screens, profiles)), 'last_round': self.last_round}
|
||||
|
||||
def pending(self, screens=None, profiles=None):
|
||||
# Callers that already hold the inventory and profile list pass them in; the inventory
|
||||
|
||||
Reference in New Issue
Block a user