Files
homeassistant_espscreen/screen_manager/app/changelog.py
T
Max Gramser 1a6308aba7 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>
2026-09-18 17:25:23 +02:00

76 lines
3.3 KiB
Python

"""What's new for the Update badge (app 0.2.73): the CHANGELOG sections since a screen's firmware.
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, 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():
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):
"""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():
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):
"""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()