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:
Max Gramser
2026-09-13 10:14:04 +02:00
parent 437b8c55b4
commit 437e3a5046
18 changed files with 875 additions and 23 deletions
+14
View File
@@ -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'):
+23
View File
@@ -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*')))
+22 -7
View File
@@ -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()
+145 -4
View File
@@ -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;
+14
View File
@@ -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>
+84 -5
View File
@@ -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);
+226
View File
@@ -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__)