Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e027247
fix(updater): fail health check fast, signal busy during boot provisi…
gmmcosta15 Sep 29, 2026
94606fb
fix(updater): fail-fast Spoolman health check, busy overlay during pr…
gmmcosta15 Sep 29, 2026
1718e2f
fix(updater): fail-fast Spoolman health check, no double start, hold …
gmmcosta15 Sep 29, 2026
87ddc5b
fix(updater): skip background apt pass when a daemon restart is pending
gmmcosta15 Sep 29, 2026
d804063
fix(updater): re-enable BlocksScreen.service after unit conversion
gmmcosta15 Sep 29, 2026
c4f598f
fix(updater): show overlay before MainWindow at boot provision, skip …
gmmcosta15 Sep 29, 2026
394476d
fix(updater): retry boot provisioning while reconcile holds the proce…
gmmcosta15 Sep 29, 2026
d13b407
feat(update): show "Missing component, installing" overlay during boo…
gmmcosta15 Sep 29, 2026
ea80663
fix(update): replay busy state after wiring so boot provisioning show…
gmmcosta15 Sep 29, 2026
ba12fdd
fix(updater): retry provisioning only when deferred, daemon-declared …
gmmcosta15 Sep 30, 2026
0f593f5
fix(updater): provision missing components once per start, daemon-dec…
gmmcosta15 Oct 1, 2026
efee51b
fix(updater): hold overlay across UI restart, hide cancel while provi…
gmmcosta15 Oct 1, 2026
3e70ae5
fix(updater): allow sudoers disable --now Spoolman.service for failed…
gmmcosta15 Oct 1, 2026
1cd2b55
fix(updater): provision after boot reconcile, undo hook-enabled units…
gmmcosta15 Oct 1, 2026
6aae6f2
fix(updater): bus activation Exec=, install triggers, single rollback…
gmmcosta15 Oct 1, 2026
7ecbb2d
fix(updater): skip unattended provisioning when offline or after a fa…
gmmcosta15 Oct 1, 2026
9b801df
fix(updater): defer unattended updates while printing, bound offline …
gmmcosta15 Oct 2, 2026
441acb9
Merge remote-tracking branch 'origin/dev' into fix/updater-health-fai…
gmmcosta15 Oct 2, 2026
64a7b78
fix(updater): clear printing when the UI leaves the bus, subscribe be…
gmmcosta15 Oct 2, 2026
0521fc2
fix(updater): retry set_printing so a lost call cannot leave the daem…
gmmcosta15 Oct 2, 2026
d72b2e4
fix(updater): gate unattended work on Klipper print_stats via klippy.…
gmmcosta15 Oct 2, 2026
ce31c66
Merge branch 'dev' into fix/updater-health-failfast-restart-flash
gmmcosta15 Oct 2, 2026
27203ff
fix(ui): paint the splash on the X root at X start and before SIGTERM…
gmmcosta15 Oct 2, 2026
298b9b5
Merge remote-tracking branch 'refs/remotes/origin/fix/updater-health-…
gmmcosta15 Oct 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions BlocksScreen/BlocksScreen.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,7 @@ def notify(self, a0: QtCore.QObject, a1: QtCore.QEvent) -> bool: # type: ignore


def _write_splash_to_fb0() -> None:
"""Write precomputed splash to fb0 while KD_GRAPHICS is still active.

Called from SIGTERM handler so fb0 already shows the splash before X exits,
eliminating the brief black frame between X shutdown and ExecStopPost.
"""
"""Write the precomputed splash to fb0 on SIGTERM, before window teardown."""
try:
if _SPLASH_CACHE.exists():
_FB0.write_bytes(_SPLASH_CACHE.read_bytes())
Expand Down
4 changes: 4 additions & 0 deletions BlocksScreen/lib/panels/mainWindow.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,10 @@ def __init__(self):
self.controlPanel.disable_popups.connect(self.popup_toggle)
self.updater_worker.status_ready.connect(self.update_page.handle_status_ready)
self.updater_worker.busy_changed.connect(self.update_page.handle_busy_changed)
self.updater_worker.provisioning_changed.connect(
self.update_page.handle_provisioning_changed
)
self.updater_worker.replay_busy()
self.updater_worker.daemon_unavailable.connect(self.on_updater_unavailable)
self.updater_worker.daemon_unavailable.connect(
self.update_page.handle_daemon_unavailable
Expand Down
96 changes: 80 additions & 16 deletions BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import json
import logging
import re
import typing
from types import MappingProxyType

Expand All @@ -15,6 +16,12 @@
from updater.models import ComponentStatus

_log = logging.getLogger(__name__)
_DESCRIBE_SUFFIX = re.compile(r"-(\d+)-g[0-9a-f]+$")


def _compact_version(describe: str) -> str:
"""`v1.0.0-12-gabc1234` -> `v1.0.0+12`, so commits past one tag stay distinguishable."""
return _DESCRIBE_SUFFIX.sub(r"+\1", describe)


class UpdatePage(QtWidgets.QWidget):
Expand Down Expand Up @@ -46,6 +53,12 @@ class UpdatePage(QtWidgets.QWidget):
}
)

_PROVISION_STEP_LABELS: typing.ClassVar[MappingProxyType[int, str]] = (
MappingProxyType(
{1: "cloning", 2: "installing deps", 3: "setting up", 4: "starting"}
)
)

_APT_STEP_LABELS: typing.ClassVar[MappingProxyType[int, str]] = MappingProxyType(
{1: "updating packages", 2: "upgrading packages"}
)
Expand All @@ -72,6 +85,8 @@ def __init__(self) -> None:
self._update_avail: bool = False
self._post_update_status_pending: bool = False
self._overlay_shown: bool = False
self._restart_pending: bool = False
self._provisioning: bool = False
self._elapsed_time_seconds: int = 0
self._elapsed_timer: QtCore.QTimer = QtCore.QTimer(self)
self._elapsed_timer.setSingleShot(False)
Expand All @@ -81,6 +96,15 @@ def __init__(self) -> None:
self._busy_timeout_timer.setSingleShot(True)
self._busy_timeout_timer.setInterval(400_000) # 400s > 360s watchdog
self._busy_timeout_timer.timeout.connect(self._on_busy_timeout)
# Reusable: a stale singleShot from update N would close update N+1's overlay.
self._restart_grace_timer: QtCore.QTimer = QtCore.QTimer(self)
self._restart_grace_timer.setSingleShot(True)
self._restart_grace_timer.setInterval(15000)
self._restart_grace_timer.timeout.connect(self._dismiss_after_restart_grace)
self._stale_overlay_timer: QtCore.QTimer = QtCore.QTimer(self)
self._stale_overlay_timer.setSingleShot(True)
self._stale_overlay_timer.setInterval(60000)
self._stale_overlay_timer.timeout.connect(self._dismiss_stale_overlay)
self._update_confirm_popup: BasePopup | None = None
self.show_loading(True)

Expand Down Expand Up @@ -113,7 +137,7 @@ def _on_busy_timeout(self) -> None:
self.show_loading(False)
self.call_load_panel.emit(False, "", False)
self._show_toast(
"Update is taking longer than expected - tap refresh to check status"
"Still working in the background - tap refresh to check status"
)

def showEvent(self, a0: QtGui.QShowEvent | None) -> None:
Expand All @@ -140,7 +164,7 @@ def resizeEvent(self, a0: QtGui.QResizeEvent | None) -> None:
return super().resizeEvent(a0)

def _needs_update(self, status: ComponentStatus) -> bool:
# Mirrors daemon dirty-set: errored git repos self-heal; apt errors don't.
"""Mirror the daemon's dirty set: errored git repos count, apt errors don't."""
return bool(
status.commits_behind
or status.packages_upgradable > 0
Expand All @@ -155,8 +179,8 @@ def _version_string(self, status: ComponentStatus) -> str:
return "status error"
if status.kind in ("system", "apt"):
return "updates available"
current = status.current_version or status.current_hash[:8]
return f"{current} → {status.remote_version or 'unknown'}"
current = _compact_version(status.current_version) or status.current_hash[:8]
return f"{current} → {_compact_version(status.remote_version) or 'unknown'}"

def _make_white_label(
self,
Expand Down Expand Up @@ -310,18 +334,16 @@ def _toggle_details(self) -> None:
)

def handle_status_ready(self, json_str: str) -> None:
"""Update component statuses from a JSON payload and refresh the list."""
"""Parse statuses per entry so one bad entry can't blank the list; refresh."""
self.update_all_btn.setEnabled(True)
_log.debug("handle_status_ready: busy=%s", self._busy)
try:
data: dict[str, dict] = json.loads(json_str)
except (json.JSONDecodeError, TypeError) as exc:
_log.error("handle_status_ready: bad payload '%s'", exc)
_log.debug(json_str)
# Keep the last good list but tell the user it may be stale.
self._show_toast("Status update failed - tap refresh to retry")
return
# Build per-component so one malformed entry can't blank the whole list.
self._statuses = {}
for name, fields in data.items():
try:
Expand All @@ -335,10 +357,11 @@ def handle_status_ready(self, json_str: str) -> None:
self._update_avail = _update_avail
if not self._busy:
self.show_loading(False)
if self._post_update_status_pending:
if self._post_update_status_pending and not self._restart_pending:
_log.debug("status_ready: emitting call_load_panel(False)")
self.call_load_panel.emit(False, "", False)
self._post_update_status_pending = False
self._overlay_shown = False
else:
_log.debug("status_ready: skipping loadscreen dismiss (busy=True)")
self.build_cards()
Expand All @@ -350,25 +373,61 @@ def handle_busy_changed(self, busy: bool) -> None:
self._busy = busy
self.show_loading(busy)
if busy:
if self._provisioning:
self._show_provisioning_overlay()
self._restart_pending = False
self._restart_grace_timer.stop()
self._stale_overlay_timer.stop()
self._elapsed_time_seconds = 0
self._elapsed_timer.start()
self._busy_timeout_timer.start()
self._elapsed_time_label.show()
self._progress_label.setText("")
self._progress_label.show()
self._cancel_btn.show()
# The daemon ignores cancel() while installing a component.
self._cancel_btn.setVisible(not self._provisioning)
else:
self._provisioning = False
self._elapsed_timer.stop()
self._busy_timeout_timer.stop()
self._elapsed_time_label.hide()
self._progress_label.hide()
self._cancel_btn.hide()
self.update_all_btn.setEnabled(True)
if self._overlay_shown:
self._overlay_shown = False
self.call_load_panel.emit(False, "", False)
if self._restart_pending:
# Keep the overlay up: SIGTERM is imminent, MainWindow would flash.
self._restart_grace_timer.start()
elif self._overlay_shown:
# Hold the overlay until fresh status lands, else stale cards flash.
self._post_update_status_pending = True
self._stale_overlay_timer.start()
self._request_status_debounced()

def handle_provisioning_changed(self, provisioning: bool) -> None:
"""Daemon-declared: the current busy period installs a missing component."""
self._provisioning = provisioning
if provisioning and self._busy:
self._cancel_btn.hide()
self._show_provisioning_overlay()

def _show_provisioning_overlay(self) -> None:
self._overlay_shown = True
self.call_load_panel.emit(True, "Missing component, installing ...", False)

def _dismiss_stale_overlay(self) -> None:
"""Drop the overlay if the post-update status never arrived."""
if self._overlay_shown and not self._busy:
self._overlay_shown = False
self._post_update_status_pending = False
self.call_load_panel.emit(False, "", False)

def _dismiss_after_restart_grace(self) -> None:
"""Drop the overlay if the expected UI restart never came."""
if self._restart_pending and not self._busy:
self._restart_pending = False
self._overlay_shown = False
self.call_load_panel.emit(False, "", False)

@QtCore.pyqtSlot(name="on-update-all-clicked")
def on_update_all_clicked(self) -> None:
"""Guard against updates during a print or with hot heaters; otherwise show confirm dialog."""
Expand All @@ -385,7 +444,7 @@ def on_update_all_clicked(self) -> None:
self._show_update_confirm()

def _show_update_confirm(self) -> None:
# Dialogs parented to the page outlive close(); drop the previous one.
"""Show the confirm dialog; delete the previous one, which outlives close()."""
if self._update_confirm_popup is not None:
self._update_confirm_popup.deleteLater()
popup = BasePopup(self, floating=True)
Expand Down Expand Up @@ -418,14 +477,20 @@ def handle_step_complete(self, name: str, step: int, total: int) -> None:
status = self._statuses.get(name)
if status and status.kind == "apt":
label = self._APT_STEP_LABELS.get(step, "working")
elif self._provisioning:
label = self._PROVISION_STEP_LABELS.get(step, "working")
else:
label = self._STEP_LABELS.get(step, "working")
_log.info("step_complete: %s %d/%d (%s)", name, step, total, label)
# Progress = daemon liveness; push the force-dismiss deadline out.
if self._busy_timeout_timer.isActive():
self._busy_timeout_timer.start()
self._overlay_shown = True
overlay_msg = f"{name}: {label}"
# Latched: a later step of another component must not clear it.
self._restart_pending |= name == "BlocksScreen" and step == total
overlay_msg = (
f"Installing {name}: {label}" if self._provisioning else f"{name}: {label}"
)
self._progress_label.setText(f"Step {step}/{total}")
self.call_load_panel.emit(True, overlay_msg, False)

Expand Down Expand Up @@ -476,7 +541,7 @@ def handle_daemon_unavailable(self) -> None:
self._cancel_btn.hide()
self.show_loading(False)
self._show_toast(
"Updater unavailable. Check system logs or restart BlocksScreen.",
"Updater unavailable, retrying ...",
success=False,
)
self.update_all_btn.setEnabled(False)
Expand Down Expand Up @@ -598,7 +663,6 @@ def _setup_ui(self) -> None:
self._progress_label.setWordWrap(True)
self._progress_label.hide()

# Touch target size: minimum 44×44 px per WCAG; set to 60px tall for comfort
self._cancel_btn = BlocksCustomButton(self._loadwidget)
self._cancel_btn.setMinimumSize(QtCore.QSize(240, 60))
self._cancel_btn.setMaximumSize(QtCore.QSize(320, 60))
Expand Down
Loading
Loading