One-click and nightly firmware updates from ESP Screens
Screens now report their ESPHome name and IP address, so the add-on can match each screen to its YAML profile and OTA address itself. The screen list shows an update badge with a Bijwerken button, an update-all action and a nightly checkbox; updates run one screen at a time, wait for the new version to report back and stop the round on the first failure. App 0.2.16, firmware 0.2.17. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,8 @@ 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'}
|
||||
# Firmware shipped with this app release; screens below it get an update offer.
|
||||
FIRMWARE_VERSION = '0.2.17'
|
||||
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())
|
||||
|
||||
|
||||
@@ -229,6 +231,16 @@ def discover(registry, states, devices, areas):
|
||||
if item.get('platform') == 'esphome' and item.get('original_name') == 'Schermfirmware'}
|
||||
boards = {item.get("device_id"): "guition" for item in registry
|
||||
if item.get("platform") == "esphome" and item.get("original_name") == "Guition schermtype"}
|
||||
def diagnostic(name, pattern):
|
||||
found = {}
|
||||
for item in registry:
|
||||
if item.get('platform') == 'esphome' and item.get('original_name') == name:
|
||||
value = states.get(item['entity_id'], {}).get('state', '')
|
||||
if isinstance(value, str) and re.fullmatch(pattern, value):
|
||||
found[item.get('device_id')] = value
|
||||
return found
|
||||
nodes = diagnostic('Apparaatnaam', r'[a-z0-9][a-z0-9-]{0,30}')
|
||||
addresses = diagnostic('IP-adres', r'\d{1,3}(\.\d{1,3}){3}')
|
||||
screens, entities = [], []
|
||||
for item in registry:
|
||||
eid = item['entity_id']
|
||||
@@ -239,6 +251,8 @@ def discover(registry, states, devices, areas):
|
||||
screens.append({'id': eid, 'name': device.get('name_by_user') or device.get('name') or eid,
|
||||
'firmware': versions.get(item.get('device_id'), 'onbekend'),
|
||||
'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 '',
|
||||
'area': area, 'online': state.get('state') not in (None, 'unknown', 'unavailable'),
|
||||
'status': state.get('state', 'Niet verbonden')})
|
||||
if not entity_id(eid) or item.get('disabled_by'):
|
||||
|
||||
@@ -12,6 +12,10 @@ import time
|
||||
import yaml
|
||||
from core import installation_yaml
|
||||
|
||||
class LenientLoader(yaml.SafeLoader):
|
||||
"""Reads profile metadata without resolving !secret or !include."""
|
||||
LenientLoader.add_multi_constructor('!', lambda loader, suffix, node: None)
|
||||
|
||||
class Firmware:
|
||||
def __init__(self, root, data):
|
||||
self.root, self.data = Path(root).resolve(), Path(data)
|
||||
@@ -25,6 +29,25 @@ class Firmware:
|
||||
return [{'file': p.name} for p in sorted(self.root.glob('*.yaml'))
|
||||
if p.name != 'secrets.yaml' and p.is_file() and not p.is_symlink()]
|
||||
|
||||
def profile_names(self):
|
||||
"""{file: {'node': esphome name, 'friendly': friendly name}} for every readable profile."""
|
||||
found = {}
|
||||
for entry in self.profiles():
|
||||
try:
|
||||
data = yaml.load((self.root / entry['file']).read_text(), Loader=LenientLoader)
|
||||
except (OSError, yaml.YAMLError, UnicodeError):
|
||||
continue
|
||||
block = data.get('esphome') if isinstance(data, dict) else None
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
substitutions = data.get('substitutions') if isinstance(data.get('substitutions'), dict) else {}
|
||||
def resolve(value):
|
||||
if isinstance(value, str) and value.startswith('${') and value.endswith('}'):
|
||||
value = substitutions.get(value[2:-1])
|
||||
return value if isinstance(value, str) else None
|
||||
found[entry['file']] = {'node': resolve(block.get('name')), 'friendly': resolve(block.get('friendly_name'))}
|
||||
return found
|
||||
|
||||
def ports(self):
|
||||
return sorted(set(glob.glob('/dev/serial/by-id/*') or glob.glob('/dev/ttyUSB*') + glob.glob('/dev/ttyACM*')))
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""HA Ingress app. The only HA write is text.set_value on discovered inboxes."""
|
||||
"""HA Ingress app. HA writes: text.set_value on discovered inboxes and one persistent notification when a nightly update stops."""
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
@@ -10,6 +10,7 @@ import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import math
|
||||
from firmware import Firmware
|
||||
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
|
||||
@@ -169,6 +170,7 @@ class Manager:
|
||||
self.histories = {}
|
||||
self.forecasts = {}
|
||||
self.firmware = Firmware(os.environ.get("ESPHOME_CONFIG", "/homeassistant/esphome"), self.path.parent)
|
||||
self.updates = Updater(self, self.path.parent / 'updates.json')
|
||||
if self.path.exists():
|
||||
raw = json.loads(self.path.read_text())
|
||||
# Versioned persistent data. Never silently overwrite an unknown schema.
|
||||
@@ -356,11 +358,21 @@ 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')
|
||||
screen['update'] = manager.updates.state_for(screen)
|
||||
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})
|
||||
return web.json_response({'csrf': csrf, 'connected': manager.ha.online, 'screens': screens, 'entities': entities,
|
||||
'backgrounds': TILE_BACKGROUNDS, 'builtin': builtin, 'updates': manager.updates.summary()})
|
||||
async def save(request):
|
||||
manager.save(request.match_info['inbox'], await request.json())
|
||||
return web.json_response({'saved': True})
|
||||
async def update_screen(request):
|
||||
data = await request.json() if request.can_read_body else {}
|
||||
return web.json_response(manager.updates.start(request.match_info['inbox'], data.get('host')))
|
||||
async def update_all(request):
|
||||
return web.json_response({'started': manager.updates.start_all()})
|
||||
async def update_settings(request):
|
||||
manager.updates.set_auto((await request.json()).get('auto'))
|
||||
return web.json_response(manager.updates.summary())
|
||||
async def download(request):
|
||||
data = await request.json()
|
||||
content = installation_yaml(data)
|
||||
@@ -378,12 +390,15 @@ def create_app(manager, development=False):
|
||||
async def firmware_start(request): return web.json_response(manager.firmware.start(await request.json()))
|
||||
async def firmware_create(request): return web.json_response(manager.firmware.create(await request.json()))
|
||||
async def shutdown(app):
|
||||
task=manager.firmware.task
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError): await task
|
||||
for task in (manager.updates.task, manager.firmware.task):
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError): await task
|
||||
app.on_cleanup.append(shutdown)
|
||||
app.router.add_get('/api/screens/{inbox}/inspect', inspector)
|
||||
app.router.add_post('/api/screens/{inbox}/update', update_screen)
|
||||
app.router.add_post('/api/updates/run', update_all)
|
||||
app.router.add_put('/api/updates', update_settings)
|
||||
app.router.add_get('/api/firmware', firmware_status)
|
||||
app.router.add_post('/api/firmware/jobs', firmware_start)
|
||||
app.router.add_post('/api/firmware/profiles', firmware_create)
|
||||
@@ -408,7 +423,7 @@ async def main():
|
||||
await runner.setup()
|
||||
await web.TCPSite(runner, '127.0.0.1' if development else '0.0.0.0', 8099).start()
|
||||
try:
|
||||
await asyncio.gather(ha.run(), manager.run())
|
||||
await asyncio.gather(ha.run(), manager.run(), manager.updates.run())
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
|
||||
@@ -175,14 +175,107 @@ function renderSettingsSupport() {
|
||||
? "Opslaan stuurt je instellingen direct naar dit scherm. Ze blijven ook na een herstart bewaard."
|
||||
: "Eenmalig firmware 0.1.2 of nieuwer installeren via ESPHome. Je kunt de instellingen alvast bewaren; oudere firmware gebruikt ze nog niet.";
|
||||
}
|
||||
const updating = new Set();
|
||||
const PHASES = {
|
||||
install: "Bouwen en installeren…",
|
||||
verify: "Wachten tot het scherm terug is…",
|
||||
settle: "Controleren of het stabiel blijft…",
|
||||
};
|
||||
async function startUpdate(screen, host) {
|
||||
updating.add(screen.id);
|
||||
renderScreens();
|
||||
try {
|
||||
await api(`screens/${encodeURIComponent(screen.id)}/update`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(host ? { host } : {}),
|
||||
});
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
updating.delete(screen.id);
|
||||
renderScreens();
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
function renderUpdate(screen) {
|
||||
const u = screen.update || {};
|
||||
const row = node("div", undefined, "screen-update");
|
||||
const running = u.state === "running" || updating.has(screen.id);
|
||||
if (running) {
|
||||
row.append(
|
||||
node("span", undefined, "spin"),
|
||||
node("small", PHASES[u.phase] || "Update starten…"),
|
||||
);
|
||||
} else if (u.state === "queued") {
|
||||
row.append(node("small", "In de wachtrij voor de update"));
|
||||
} else if (u.available && screen.online) {
|
||||
row.append(node("span", `Update ${u.target}`, "badge update"));
|
||||
const button = node("button", "Bijwerken", "mini");
|
||||
button.type = "button";
|
||||
if (u.host && u.profile) {
|
||||
button.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
startUpdate(screen);
|
||||
};
|
||||
} else if (!u.profile) {
|
||||
button.disabled = true;
|
||||
button.title = "Geen ESPHome-profiel met deze apparaatnaam gevonden.";
|
||||
} else {
|
||||
button.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
const form = node("form", undefined, "screen-host");
|
||||
const input = node("input");
|
||||
input.placeholder = "IP-adres, bijv. 192.168.1.50";
|
||||
input.required = true;
|
||||
input.pattern = "[A-Za-z0-9][A-Za-z0-9.\\-]*";
|
||||
const go = node("button", "Start", "mini");
|
||||
const cancel = node("button", "✕", "quiet");
|
||||
cancel.type = "button";
|
||||
cancel.setAttribute("aria-label", "Annuleren");
|
||||
cancel.onclick = () => {
|
||||
form.remove();
|
||||
renderScreens();
|
||||
};
|
||||
form.append(input, go, cancel);
|
||||
form.onsubmit = (ev) => {
|
||||
ev.preventDefault();
|
||||
form.remove();
|
||||
startUpdate(screen, input.value.trim());
|
||||
};
|
||||
row.replaceChildren(
|
||||
node("small", "Eenmalig het IP-adres; nieuwe firmware meldt het zelf."),
|
||||
form,
|
||||
);
|
||||
input.focus();
|
||||
};
|
||||
}
|
||||
row.append(button);
|
||||
} else if (u.result && Date.now() / 1000 - u.result.time < 86400) {
|
||||
row.append(
|
||||
node(
|
||||
"small",
|
||||
u.result.message,
|
||||
u.result.state === "failed" ? "failed" : "",
|
||||
),
|
||||
);
|
||||
} else return null;
|
||||
return row;
|
||||
}
|
||||
function renderScreens() {
|
||||
// Keep an open address form alive across the periodic refresh.
|
||||
if ($("#screens .screen-host")) {
|
||||
renderUpdates();
|
||||
return;
|
||||
}
|
||||
$("#screens").replaceChildren();
|
||||
for (const screen of inventory.screens) {
|
||||
const b = node(
|
||||
"button",
|
||||
if (screen.update?.state === "running") updating.delete(screen.id);
|
||||
const item = node(
|
||||
"div",
|
||||
undefined,
|
||||
`screen ${screen.id === selected ? "selected" : ""}`,
|
||||
);
|
||||
const b = node("button", undefined, "screen-main");
|
||||
b.type = "button";
|
||||
b.append(
|
||||
node("strong", screen.name),
|
||||
node(
|
||||
@@ -191,7 +284,10 @@ function renderScreens() {
|
||||
),
|
||||
);
|
||||
b.onclick = () => select(screen.id);
|
||||
$("#screens").append(b);
|
||||
item.append(b);
|
||||
const update = renderUpdate(screen);
|
||||
if (update) item.append(update);
|
||||
$("#screens").append(item);
|
||||
}
|
||||
const screen = inventory.screens.find((s) => s.id === selected);
|
||||
if (screen) {
|
||||
@@ -200,7 +296,47 @@ function renderScreens() {
|
||||
: "Offline · wijzigingen worden bewaard";
|
||||
$("#delivery").classList.toggle("online", screen.online);
|
||||
}
|
||||
renderUpdates();
|
||||
}
|
||||
function renderUpdates() {
|
||||
const u = inventory.updates;
|
||||
$("#updates").hidden = !u || !inventory.screens.length;
|
||||
if (!u) return;
|
||||
const outdated = inventory.screens.filter((s) => s.update?.available).length;
|
||||
$("#updates-hint").textContent = u.busy
|
||||
? `Bezig met bijwerken naar firmware ${u.target}…`
|
||||
: outdated
|
||||
? `Firmware ${u.target} is beschikbaar voor ${outdated} scherm${outdated === 1 ? "" : "en"}.`
|
||||
: `Alle schermen hebben firmware ${u.target}.`;
|
||||
$("#update-all").hidden = !u.pending || !!u.busy || u.pending < 2;
|
||||
$("#update-all").textContent = `Alle ${u.pending} schermen bijwerken`;
|
||||
if (document.activeElement !== $("#auto-update"))
|
||||
$("#auto-update").checked = u.auto;
|
||||
}
|
||||
$("#update-all").onclick = async () => {
|
||||
try {
|
||||
await api("updates/run", { method: "POST" });
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
};
|
||||
$("#auto-update").onchange = async () => {
|
||||
try {
|
||||
await api("updates", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ auto: $("#auto-update").checked }),
|
||||
});
|
||||
toast(
|
||||
$("#auto-update").checked
|
||||
? "Schermen worden voortaan 's nachts bijgewerkt."
|
||||
: "Automatisch bijwerken staat uit.",
|
||||
);
|
||||
} catch (e) {
|
||||
$("#auto-update").checked = !$("#auto-update").checked;
|
||||
toast(e.message);
|
||||
}
|
||||
};
|
||||
function move(from, to) {
|
||||
if (to < 0 || to >= layout.tiles.length) return;
|
||||
const [tile] = layout.tiles.splice(from, 1);
|
||||
@@ -767,7 +903,12 @@ window.addEventListener("beforeunload", (e) => {
|
||||
}
|
||||
});
|
||||
refresh();
|
||||
setInterval(refresh, 10000);
|
||||
(function poll() {
|
||||
setTimeout(async () => {
|
||||
await refresh();
|
||||
poll();
|
||||
}, inventory.updates?.busy ? 3000 : 10000);
|
||||
})();
|
||||
|
||||
// Shared firmware workspace; always select a concrete profile and upload target.
|
||||
let firmwarePoll;
|
||||
|
||||
@@ -74,6 +74,20 @@
|
||||
seconden.
|
||||
</p>
|
||||
<button class="quiet" id="refresh">↻ Vernieuwen</button>
|
||||
<div class="updates" id="updates">
|
||||
<h2>Firmware-updates</h2>
|
||||
<p class="hint" id="updates-hint"></p>
|
||||
<button id="update-all" hidden>Alle schermen bijwerken</button>
|
||||
<label class="check">
|
||||
<input type="checkbox" id="auto-update" />
|
||||
<span
|
||||
>Elke nacht automatisch bijwerken<small
|
||||
>Tussen 03:00 en 06:00, één scherm tegelijk. Stopt bij een
|
||||
fout en meldt dat in Home Assistant.</small
|
||||
></span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</aside>
|
||||
<section id="empty" class="empty">
|
||||
<span class="empty-icon">▦</span>
|
||||
|
||||
@@ -165,20 +165,99 @@ aside h2 {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.screen {
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
border: 1px solid transparent;
|
||||
padding: 14px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.screen.selected {
|
||||
background: white;
|
||||
border-color: var(--line);
|
||||
box-shadow: 0 3px 10px #202c3c05;
|
||||
}
|
||||
.screen small {
|
||||
.screen-main {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
border: 0;
|
||||
padding: 14px;
|
||||
}
|
||||
.screen-main small {
|
||||
margin-top: 3px;
|
||||
}
|
||||
.screen-update {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 0 14px 12px;
|
||||
}
|
||||
.screen-update small.failed {
|
||||
color: #b23b3b;
|
||||
}
|
||||
.badge.update {
|
||||
background: #fff4e0;
|
||||
color: #8a5a10;
|
||||
border-color: #f4e3c2;
|
||||
padding: 4px 9px;
|
||||
}
|
||||
.mini {
|
||||
padding: 5px 11px;
|
||||
font-size: 12px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.screen-host {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
.screen-host input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 6px 9px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.spin {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
flex: none;
|
||||
border: 2px solid var(--line);
|
||||
border-top-color: var(--ink);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.updates {
|
||||
margin-top: 22px;
|
||||
}
|
||||
.updates h2 {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.updates .hint {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.updates #update-all {
|
||||
width: 100%;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.check {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
align-items: flex-start;
|
||||
cursor: pointer;
|
||||
}
|
||||
.check input {
|
||||
margin-top: 3px;
|
||||
}
|
||||
.check small {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.quiet {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Firmware updates for paired screens: on demand, all at once, or nightly. One screen at a time."""
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
from core import FIRMWARE_VERSION
|
||||
|
||||
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
|
||||
|
||||
TARGET = parse_version(FIRMWARE_VERSION)
|
||||
|
||||
class Updater:
|
||||
verify_timeout = 240
|
||||
settle_seconds = 60
|
||||
pause_seconds = 120
|
||||
poll_seconds = 5
|
||||
|
||||
def __init__(self, manager, path):
|
||||
self.manager, self.path = manager, Path(path)
|
||||
self.auto, self.hosts, self.results, self.last_round = False, {}, {}, None
|
||||
self.task, self.current, self.queue, self.phase = None, None, [], None
|
||||
if self.path.exists():
|
||||
raw = json.loads(self.path.read_text())
|
||||
if raw.get('version') != 1:
|
||||
raise ValueError('Onbekende opslagversie voor updates; gegevens blijven ongewijzigd.')
|
||||
self.auto = raw.get('auto') is True
|
||||
self.hosts = {k: v for k, v in raw.get('hosts', {}).items() if isinstance(v, str)}
|
||||
self.results = {k: v for k, v in raw.get('results', {}).items() if isinstance(v, dict)}
|
||||
self.last_round = raw.get('last_round') if isinstance(raw.get('last_round'), str) else None
|
||||
|
||||
def save(self):
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp = self.path.with_suffix('.tmp')
|
||||
with open(temp, 'w', encoding='utf8') as handle:
|
||||
os.chmod(temp, 0o600)
|
||||
json.dump({'version': 1, 'auto': self.auto, 'hosts': self.hosts, 'results': self.results,
|
||||
'last_round': self.last_round}, handle, ensure_ascii=False)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
temp.replace(self.path)
|
||||
|
||||
def busy(self):
|
||||
return bool(self.task and not self.task.done())
|
||||
|
||||
def screen(self, inbox):
|
||||
return next((s for s in self.manager.inventory()[0] if s['id'] == inbox), None)
|
||||
|
||||
def resolve(self, screen):
|
||||
"""Profile file and OTA address for a screen; None when the add-on cannot tell."""
|
||||
profiles = self.manager.firmware.profile_names()
|
||||
profile = next((f for f, p in profiles.items() if screen.get('node') and p['node'] == screen['node']), None)
|
||||
if profile is None and screen.get('device'):
|
||||
# Screens on firmware before 0.2.17 do not report their node name yet.
|
||||
matches = [f for f, p in profiles.items() if p['friendly'] == screen['device']]
|
||||
profile = matches[0] if len(matches) == 1 else None
|
||||
return profile, screen.get('ip') or self.hosts.get(screen['id'])
|
||||
|
||||
def state_for(self, screen):
|
||||
version = parse_version(screen.get('firmware'))
|
||||
profile, host = self.resolve(screen)
|
||||
if screen['id'] == self.current:
|
||||
state = 'running'
|
||||
elif screen['id'] in self.queue:
|
||||
state = 'queued'
|
||||
else:
|
||||
state = 'idle'
|
||||
return {'available': bool(version and TARGET and version < TARGET), 'target': FIRMWARE_VERSION,
|
||||
'profile': profile, 'host': host, 'state': state, 'phase': self.phase if state == 'running' else None,
|
||||
'result': self.results.get(screen['id'])}
|
||||
|
||||
def summary(self):
|
||||
return {'auto': self.auto, 'target': FIRMWARE_VERSION, 'busy': self.current,
|
||||
'pending': len(self.pending()), 'last_round': self.last_round}
|
||||
|
||||
def pending(self):
|
||||
return [s['id'] for s in self.manager.inventory()[0]
|
||||
if s['online'] and self.state_for(s)['available'] and all(self.resolve(s))]
|
||||
|
||||
def set_auto(self, enabled):
|
||||
if not isinstance(enabled, bool):
|
||||
raise ValueError('Kies aan of uit voor automatisch bijwerken.')
|
||||
self.auto = enabled
|
||||
self.save()
|
||||
|
||||
def start(self, inbox, host=None):
|
||||
if self.busy():
|
||||
raise ValueError('Er loopt al een update. Wacht tot die klaar is.')
|
||||
screen = self.screen(inbox)
|
||||
if not screen:
|
||||
raise ValueError('Onbekend scherm. Vernieuw het overzicht.')
|
||||
if not screen['online']:
|
||||
raise ValueError('Dit scherm is offline; bijwerken kan zodra het terug is.')
|
||||
if not self.state_for(screen)['available']:
|
||||
raise ValueError('Dit scherm heeft al de nieuwste firmware.')
|
||||
if host is not None:
|
||||
if not isinstance(host, str) or not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}', host.strip()):
|
||||
raise ValueError('Vul het IP-adres van dit scherm in.')
|
||||
self.hosts[inbox] = host.strip()
|
||||
self.save()
|
||||
profile, host = self.resolve(screen)
|
||||
if not profile:
|
||||
raise ValueError('Geen ESPHome-profiel gevonden voor dit scherm. Controleer de naam in de ESPHome-map.')
|
||||
if not host:
|
||||
raise ValueError('IP-adres onbekend. Vul het eenmalig in; nieuwe firmware meldt het daarna zelf.')
|
||||
self.launch([inbox])
|
||||
return self.state_for(screen)
|
||||
|
||||
def start_all(self):
|
||||
if self.busy():
|
||||
raise ValueError('Er loopt al een update. Wacht tot die klaar is.')
|
||||
pending = self.pending()
|
||||
if not pending:
|
||||
raise ValueError('Alle bereikbare schermen zijn al bijgewerkt.')
|
||||
self.launch(pending)
|
||||
return pending
|
||||
|
||||
def launch(self, inboxes, automatic=False):
|
||||
# Mark the first screen busy right away so the page shows progress before the task runs.
|
||||
self.current, self.queue, self.phase = inboxes[0], inboxes[1:], 'install'
|
||||
self.task = asyncio.create_task(self.run_round(inboxes, automatic))
|
||||
|
||||
def record(self, inbox, state, message):
|
||||
self.results[inbox] = {'time': time.time(), 'state': state, 'message': message, 'version': FIRMWARE_VERSION}
|
||||
self.save()
|
||||
|
||||
async def run_round(self, inboxes, automatic=False):
|
||||
"""Update screens one by one; a failure ends the round so a bad build never reaches the next screen."""
|
||||
try:
|
||||
for index, inbox in enumerate(inboxes):
|
||||
self.current, self.queue = inbox, inboxes[index+1:]
|
||||
outcome = await self.update_one(inbox)
|
||||
if outcome == 'failed':
|
||||
if automatic:
|
||||
await self.notify(f"Automatisch bijwerken is gestopt bij {self.name(inbox)}: "
|
||||
f"{self.results[inbox]['message']} De overige schermen zijn niet aangeraakt.")
|
||||
break
|
||||
if self.queue and outcome == 'success':
|
||||
await asyncio.sleep(self.pause_seconds)
|
||||
finally:
|
||||
self.current, self.queue, self.phase = None, [], None
|
||||
|
||||
def name(self, inbox):
|
||||
screen = self.screen(inbox)
|
||||
return screen['name'] if screen else inbox
|
||||
|
||||
async def update_one(self, inbox):
|
||||
screen = self.screen(inbox)
|
||||
if not screen or not screen['online']:
|
||||
self.record(inbox, 'skipped', 'Scherm was offline; volgende keer opnieuw.')
|
||||
return 'skipped'
|
||||
profile, host = self.resolve(screen)
|
||||
if not profile or not host:
|
||||
self.record(inbox, 'skipped', 'Profiel of IP-adres onbekend; werk dit scherm handmatig bij.')
|
||||
return 'skipped'
|
||||
self.phase = 'install'
|
||||
try:
|
||||
self.manager.firmware.start({'file': profile, 'action': 'install', 'target': host})
|
||||
await self.manager.firmware.task
|
||||
except ValueError as error:
|
||||
self.record(inbox, 'failed', str(error))
|
||||
return 'failed'
|
||||
if self.manager.firmware.job.get('state') != 'success':
|
||||
self.record(inbox, 'failed', 'Bouwen of installeren mislukt; bekijk het log onder Firmware & USB.')
|
||||
return 'failed'
|
||||
self.phase = 'verify'
|
||||
if not await self.wait_for_target(inbox):
|
||||
self.record(inbox, 'failed', f'Scherm meldde zich niet met firmware {FIRMWARE_VERSION}; controleer het scherm.')
|
||||
return 'failed'
|
||||
self.phase = 'settle'
|
||||
await asyncio.sleep(self.settle_seconds)
|
||||
current = self.screen(inbox)
|
||||
if not current or not current['online']:
|
||||
self.record(inbox, 'failed', 'Scherm viel weg na de update; controleer het scherm.')
|
||||
return 'failed'
|
||||
self.record(inbox, 'success', f'Bijgewerkt naar firmware {FIRMWARE_VERSION}.')
|
||||
return 'success'
|
||||
|
||||
async def wait_for_target(self, inbox):
|
||||
deadline = time.monotonic() + self.verify_timeout
|
||||
while time.monotonic() < deadline:
|
||||
screen = self.screen(inbox)
|
||||
version = parse_version(screen.get('firmware')) if screen else None
|
||||
if screen and screen['online'] and version and version >= TARGET:
|
||||
return True
|
||||
await asyncio.sleep(self.poll_seconds)
|
||||
return False
|
||||
|
||||
async def notify(self, message):
|
||||
try:
|
||||
await self.manager.ha.request('call_service', domain='persistent_notification', service='create',
|
||||
service_data={'notification_id': 'esp_screens_update', 'title': 'ESP Screens', 'message': message})
|
||||
except Exception as error:
|
||||
LOG.warning('Melding naar Home Assistant mislukt (%s)', type(error).__name__)
|
||||
|
||||
def due(self, now):
|
||||
return self.auto and now.hour in NIGHT_HOURS and self.last_round != now.date().isoformat()
|
||||
|
||||
async def run(self):
|
||||
while True:
|
||||
await asyncio.sleep(60)
|
||||
try:
|
||||
if self.busy() or not self.manager.ha.online:
|
||||
continue
|
||||
now = datetime.now(getattr(self.manager.ha, 'time_zone', None) or timezone.utc)
|
||||
if not self.due(now):
|
||||
continue
|
||||
self.last_round = now.date().isoformat()
|
||||
self.save()
|
||||
pending = self.pending()
|
||||
if pending:
|
||||
LOG.info('Nachtelijke firmwareronde: %d scherm(en)', len(pending))
|
||||
self.launch(pending, automatic=True)
|
||||
except Exception as error:
|
||||
LOG.warning('Nachtelijke updatecontrole overgeslagen (%s)', type(error).__name__)
|
||||
Reference in New Issue
Block a user