diff --git a/meta-opencentauri/recipes-apps/grumyscreen/files/grumpyscreen.cfg b/meta-opencentauri/recipes-apps/grumyscreen/files/grumpyscreen.cfg index bb9e2566..d299d3a6 100644 --- a/meta-opencentauri/recipes-apps/grumyscreen/files/grumpyscreen.cfg +++ b/meta-opencentauri/recipes-apps/grumyscreen/files/grumpyscreen.cfg @@ -44,6 +44,10 @@ port: 80 # set this if host is not 127.0.0.1 / localhost or you want to download thumbnails thumbnail_path: +# The update button drives the cosmos entry of Moonraker's update_manager +[update_manager] +application: cosmos + [commands] factory_reset_cmd: /usr/bin/factory-reset gui_restart_cmd: /etc/init.d/gui-switcher restart diff --git a/meta-opencentauri/recipes-apps/klipper/files/macros.cfg b/meta-opencentauri/recipes-apps/klipper/files/macros.cfg index 35a4c9df..b6386677 100644 --- a/meta-opencentauri/recipes-apps/klipper/files/macros.cfg +++ b/meta-opencentauri/recipes-apps/klipper/files/macros.cfg @@ -882,11 +882,6 @@ gcode: RESPOND TYPE=command MSG="action:prompt_begin {esc_title}" RESPOND TYPE=command MSG="action:prompt_text {esc_message}" - # Yes this is a hack. I don't want to hear about it. - {% if title == "Update Available" %} - RESPOND TYPE=command MSG="action:prompt_footer_button Update Now|_UPDATE_COSMOS|warning" - {% endif %} - RESPOND TYPE=command MSG="action:prompt_footer_button Dismiss|RESPOND TYPE=command MSG=action:prompt_end" RESPOND TYPE=command MSG="action:prompt_show" diff --git a/meta-opencentauri/recipes-apps/klipper/files/shell.cfg b/meta-opencentauri/recipes-apps/klipper/files/shell.cfg index 9aa5cefc..2bd50c00 100644 --- a/meta-opencentauri/recipes-apps/klipper/files/shell.cfg +++ b/meta-opencentauri/recipes-apps/klipper/files/shell.cfg @@ -86,25 +86,3 @@ verbose: False [gcode_macro REBOOT_MACHINE] gcode: RUN_SHELL_COMMAND CMD=REBOOT_SYSTEM - -[gcode_shell_command CHECK_FOR_UPDATES] -command: check-update -timeout: 5 -verbose: False - -[gcode_shell_command UPDATE_COSMOS] -command: update-cosmos -timeout: 500 -verbose: False - -[gcode_macro _UPDATE_COSMOS] -gcode: - _SHOW_PROMPT TITLE="Updating..." MESSAGE="Please wait while COSMOS is updated. This may take a few minutes." - RUN_SHELL_COMMAND CMD=UPDATE_COSMOS - -[delayed_gcode check_for_updates_startup] -initial_duration: 60 -gcode: - {% if printer.idle_timeout.state in ["Idle", "Ready"] and printer.print_stats.state == "standby" %} - RUN_SHELL_COMMAND CMD=CHECK_FOR_UPDATES - {% endif %} \ No newline at end of file diff --git a/meta-opencentauri/recipes-apps/klipper/kalico_2026.02.00.bb b/meta-opencentauri/recipes-apps/klipper/kalico_2026.02.00.bb index 7d11153b..417cda3b 100644 --- a/meta-opencentauri/recipes-apps/klipper/kalico_2026.02.00.bb +++ b/meta-opencentauri/recipes-apps/klipper/kalico_2026.02.00.bb @@ -33,7 +33,6 @@ RDEPENDS:${PN} = " \ kalico-firmware-dsp \ kalico-firmware-toolhead \ kalico-firmware-bed \ - check-update \ kalico-firmware-canvas \ " diff --git a/meta-opencentauri/recipes-apps/moonraker/files/0001-update_manager-skip-placeholder-applications.patch b/meta-opencentauri/recipes-apps/moonraker/files/0001-update_manager-skip-placeholder-applications.patch new file mode 100644 index 00000000..fee13ca4 --- /dev/null +++ b/meta-opencentauri/recipes-apps/moonraker/files/0001-update_manager-skip-placeholder-applications.patch @@ -0,0 +1,48 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Kyle +Date: Sat, 5 Sep 2026 00:45:11 -0700 +Subject: [PATCH] update_manager: leave placeholder applications out of the + status + +The klipper and moonraker entries are created even when the installed +copies are not git repositories, zips or python packages, in which case +they are bare BaseDeploy placeholders whose status is an empty dict. The +UIs render those as empty rows. Skip entries with no status in the status +response and in the refreshed notification. +--- + .../components/update_manager/update_manager.py | 12 ++++++++++-- + 1 file changed, 10 insertions(+), 2 deletions(-) + +diff --git a/moonraker/components/update_manager/update_manager.py b/moonraker/components/update_manager/update_manager.py +index cd580c9..1093349 100644 +--- a/moonraker/components/update_manager/update_manager.py ++++ b/moonraker/components/update_manager/update_manager.py +@@ -422,7 +422,12 @@ class UpdateManager: + for name, updater in list(self.updaters.items()): + if check_refresh: + await updater.refresh() +- vinfo[name] = updater.get_update_status() ++ status = updater.get_update_status() ++ if not status: ++ # Placeholder for an application that cannot be ++ # updated from here, nothing to report ++ continue ++ vinfo[name] = status + except Exception: + raise + finally: +@@ -636,7 +641,10 @@ class CommandHelper: + def notify_update_refreshed(self) -> None: + vinfo: Dict[str, Any] = {} + for name, updater in self.get_updaters().items(): +- vinfo[name] = updater.get_update_status() ++ status = updater.get_update_status() ++ if not status: ++ continue ++ vinfo[name] = status + uinfo = self.get_rate_limit_stats() + uinfo['version_info'] = vinfo + uinfo['busy'] = self.is_update_busy() +-- +2.50.1 (Apple Git-155) + diff --git a/meta-opencentauri/recipes-apps/moonraker/files/cosmos_update.py b/meta-opencentauri/recipes-apps/moonraker/files/cosmos_update.py new file mode 100644 index 00000000..1d2ca144 --- /dev/null +++ b/meta-opencentauri/recipes-apps/moonraker/files/cosmos_update.py @@ -0,0 +1,225 @@ +# COSMOS firmware updater for Moonraker's update_manager +# +# Registers a "cosmos" entry with update_manager so Mainsail, Fluidd and any +# other client that speaks the update_manager API can see the installed and +# latest COSMOS version, start an update, and follow its progress through the +# standard notify_update_response stream. The updater is added to +# update_manager's table from this component. +# +# Configuration (moonraker.conf): +# +# [update_manager] +# enable_system_updates: False +# +# [cosmos_update] +# update_command: /usr/bin/update-cosmos # command whose stdout is shown as progress +# update_timeout: 3600 # seconds before the update is aborted +# refresh_interval: 24 # hours between version checks +# +# This file may be distributed under the terms of the GNU GPLv3 license. + +from __future__ import annotations +import calendar +import configparser +import pathlib +import time +from typing import TYPE_CHECKING, Any, Dict, List + +from .update_manager.base_deploy import BaseDeploy + +if TYPE_CHECKING: + from ..confighelper import ConfigHelper + from .update_manager.update_manager import UpdateManager + +ISSUE_FILE = pathlib.Path("/etc/issue") +COSMOS_CONF = pathlib.Path("/etc/klipper/config/cosmos.conf") +GITHUB_OWNER = "OpenCentauri" +GITHUB_REPO = "cosmos" +MAX_COMMITS = 30 + + +class CosmosDeploy(BaseDeploy): + def __init__(self, config: ConfigHelper) -> None: + super().__init__(config, name="cosmos") + self.update_cmd: str = config.get("update_command", "/usr/bin/update-cosmos") + self.update_timeout: float = config.getfloat("update_timeout", 3600.) + self.version: str = "?" + self.remote_version: str = "?" + self.channel: str = "stable" + self.commits_behind: List[Dict[str, Any]] = [] + self.last_error: str = "" + + async def initialize(self) -> Dict[str, Any]: + storage = await super().initialize() + self.remote_version = storage.get("remote_version", "?") + self.commits_behind = storage.get("commits_behind", []) + self.last_error = storage.get("last_error", "") + await self._read_local_state() + return storage + + async def _read_local_state(self) -> None: + # Installed version, e.g. "OpenCentauri Cosmos 26.08.0 \n \l" + try: + parts = ISSUE_FILE.read_text().split() + self.version = parts[2] if len(parts) > 2 else "?" + except Exception: + self.log_exc("Unable to read the installed version", traceback=False) + self.version = "?" + # Update channel from cosmos.conf, via config-manager if available + channel = "" + try: + scmd = self.cmd_helper.get_shell_command() + channel = await scmd.exec_cmd("config-manager update release", timeout=10.) + channel = channel.strip() + except Exception: + channel = "" + if not channel: + try: + parser = configparser.ConfigParser() + parser.read(COSMOS_CONF) + channel = parser.get("update", "release", fallback="stable") + except Exception: + channel = "stable" + self.channel = channel or "stable" + + async def refresh(self) -> None: + await self._read_local_state() + self.last_error = "" + try: + if self.channel == "nightly": + await self._refresh_nightly() + else: + await self._refresh_stable() + except Exception as e: + self.last_error = str(e) + self.log_exc(f"Version check failed: {e}", traceback=False) + self._save_state() + + async def _refresh_stable(self) -> None: + resp = await self.cmd_helper.get_http_client().github_api_request( + f"repos/{GITHUB_OWNER}/{GITHUB_REPO}/releases?per_page=10" + ) + if resp.has_error(): + raise self.server.error(f"GitHub request failed: {resp.error}") + releases = resp.json() + latest = None + for release in releases: + if release.get("prerelease") or release.get("draft"): + continue + latest = release.get("tag_name") + break + if not latest: + raise self.server.error("No stable release found on GitHub") + self.remote_version = str(latest) + self.commits_behind = [] + self.log_info(f"installed {self.version}, latest {self.remote_version}") + + async def _refresh_nightly(self) -> None: + # Nightly builds are identified by a short commit hash + resp = await self.cmd_helper.get_http_client().github_api_request( + f"repos/{GITHUB_OWNER}/{GITHUB_REPO}/compare/{self.version}...main" + ) + if resp.has_error(): + raise self.server.error(f"GitHub request failed: {resp.error}") + data = resp.json() + commits = data.get("commits", [])[-MAX_COMMITS:] + behind: List[Dict[str, Any]] = [] + for c in commits: + commit = c.get("commit", {}) + author = commit.get("author", {}) + msg = commit.get("message", "") + try: + date = calendar.timegm( + time.strptime(author.get("date", ""), "%Y-%m-%dT%H:%M:%SZ") + ) + except Exception: + date = 0 + behind.append({ + "sha": c.get("sha", ""), + "author": author.get("name", ""), + "date": int(date), + "subject": msg.split("\n", 1)[0], + "message": msg, + "tag": None, + }) + self.commits_behind = behind + head = data.get("commits", []) + self.remote_version = head[-1]["sha"][:10] if head else self.version + self.log_info( + f"installed {self.version}, {data.get('ahead_by', 0)} commit(s) behind main" + ) + + async def update(self) -> bool: + if self.remote_version in ("?", self.version): + self.notify_status(f"Reinstalling COSMOS {self.version}...") + else: + self.notify_status(f"Updating COSMOS {self.version} to {self.remote_version}...") + self.notify_status( + "The printer reboots by itself when the update is installed. " + "Do not power it off." + ) + try: + await self.cmd_helper.run_cmd( + self.update_cmd, timeout=self.update_timeout, notify=True, + log_stderr=True + ) + except Exception as e: + self.last_error = str(e) + self._save_state() + raise self.log_exc(f"COSMOS update failed: {e}", traceback=False) + # update-cosmos issues the reboot itself; this is the last thing the + # clients hear before the connection drops. + self.notify_status("COSMOS update installed, the printer is rebooting", is_complete=True) + return True + + def get_update_status(self) -> Dict[str, Any]: + status = super().get_update_status() + nightly = self.channel == "nightly" + status.update({ + "name": self.name, + "configured_type": "git_repo" if nightly else "zip", + "detected_type": "git_repo" if nightly else "zip", + "channel": self.channel, + "owner": GITHUB_OWNER, + "repo_name": GITHUB_REPO, + "version": self.version, + "remote_version": self.remote_version, + "current_hash": self.version if nightly else "", + "remote_hash": self.remote_version if nightly else "", + "commits_behind": self.commits_behind, + "is_valid": True, + "is_dirty": False, + "detached": False, + "corrupt": False, + "branch": "main", + "remote_alias": "origin", + "last_error": self.last_error, + "warnings": [], + "anomalies": [], + "info_tags": ["desc=COSMOS firmware"], + }) + return status + + def get_persistent_data(self) -> Dict[str, Any]: + data = super().get_persistent_data() + data.update({ + "remote_version": self.remote_version, + "commits_behind": self.commits_behind, + "last_error": self.last_error, + }) + return data + + +class CosmosUpdate: + def __init__(self, config: ConfigHelper) -> None: + self.server = config.get_server() + um: UpdateManager = self.server.load_component(config, "update_manager") + updaters = um.get_updaters() + if "cosmos" in updaters: + raise config.error("update_manager already has a 'cosmos' entry") + self.deploy = CosmosDeploy(config) + updaters["cosmos"] = self.deploy + + +def load_component(config: ConfigHelper) -> CosmosUpdate: + return CosmosUpdate(config) diff --git a/meta-opencentauri/recipes-apps/moonraker/files/moonraker-readonly.conf b/meta-opencentauri/recipes-apps/moonraker/files/moonraker-readonly.conf index bad6c275..28a57819 100644 --- a/meta-opencentauri/recipes-apps/moonraker/files/moonraker-readonly.conf +++ b/meta-opencentauri/recipes-apps/moonraker/files/moonraker-readonly.conf @@ -42,3 +42,11 @@ subscriptions: service: mjpegstreamer-adaptive stream_url: /webcam/?action=stream snapshot_url: /webcam/?action=snapshot + +[update_manager] +enable_auto_refresh: True +enable_system_updates: False +refresh_interval: 24 + +# COSMOS firmware updates through update_manager (cosmos_update.py) +[cosmos_update] diff --git a/meta-opencentauri/recipes-apps/moonraker/moonraker_0.10.0.bb b/meta-opencentauri/recipes-apps/moonraker/moonraker_0.10.0.bb index 5770b60c..1532b44e 100644 --- a/meta-opencentauri/recipes-apps/moonraker/moonraker_0.10.0.bb +++ b/meta-opencentauri/recipes-apps/moonraker/moonraker_0.10.0.bb @@ -13,15 +13,17 @@ SRC_URI = " \ file://moonraker-init-d \ file://moonraker.conf \ file://moonraker-readonly.conf \ + file://cosmos_update.py;subdir=git/moonraker/components \ file://0001-Serve-static-files.patch \ file://0001-Reduce-log-rotate-threshold.patch \ + file://0001-update_manager-skip-placeholder-applications.patch \ " SRCREV = "16e530eb663218faa6ccd97ffb0583f1880e2983" S = "${WORKDIR}/git" -PR = "r1" +PR = "r2" inherit python3-dir update-rc.d @@ -70,7 +72,6 @@ do_install() { # Install moonraker python package install -d ${D}${datadir}/moonraker cp -r ${S}/moonraker ${D}${datadir}/moonraker/ - # Install default moonraker config install -d ${D}${sysconfdir}/klipper install -d ${D}${sysconfdir}/klipper/config diff --git a/meta-opencentauri/recipes-data/check-update/check-update_0.1.bb b/meta-opencentauri/recipes-data/check-update/check-update_0.1.bb deleted file mode 100644 index 404597fa..00000000 --- a/meta-opencentauri/recipes-data/check-update/check-update_0.1.bb +++ /dev/null @@ -1,24 +0,0 @@ -DESCRIPTION = "COSMOS update availability checker" -LICENSE = "GPL-3.0-only" -LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/GPL-3.0-only;md5=c79ff39f19dfec6d293b95dea7b07891" - -SRC_URI = "file://check-update.py" - -inherit allarch - -RDEPENDS:${PN} = " \ - config-manager \ - curl \ - python3-core \ - python3-json \ - screen-actions \ -" - -do_install[vardeps] += "DISTRO_VERSION" - -do_install() { - install -d ${D}${bindir} - install -m 0755 ${WORKDIR}/check-update.py ${D}${bindir}/check-update -} - -FILES:${PN} = "${bindir}/check-update" diff --git a/meta-opencentauri/recipes-data/check-update/files/check-update.py b/meta-opencentauri/recipes-data/check-update/files/check-update.py deleted file mode 100644 index 228f4a7d..00000000 --- a/meta-opencentauri/recipes-data/check-update/files/check-update.py +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env python3 -import subprocess, json, sys - -def get_current_version() -> str: - with open("/etc/issue") as f: - return f.read().strip().split(" ")[2] - -def get_version_branch() -> str: - return subprocess.run(["config-manager", "update", "release"], capture_output=True, text=True, check=True).stdout - -def is_check_for_updates_enabled() -> bool: - result = subprocess.run(["config-manager", "update", "check_for_updates"], capture_output=True, text=True, check=True) - return result.stdout.strip().lower() == "true" - -def make_request(url: str) -> list|None: - try: - result = subprocess.run(["curl", "-sf", url], capture_output=True, text=True, check=True) - return json.loads(result.stdout) - except (subprocess.CalledProcessError, json.JSONDecodeError): - return None - -def get_latest_release() -> str|None: - data = make_request("https://api.github.com/repos/OpenCentauri/cosmos/releases?per_page=5") - - if data is None: - return None - - for release in data: - if release.get("prerelease", False): - continue - return release.get("tag_name", None) - - return None - -def get_latest_commit() -> str|None: - data = make_request("https://api.github.com/repos/OpenCentauri/cosmos/commits?per_page=1") - - if data is None: - return None - - if len(data) == 0: - return None - - return data[0].get("sha", None) - -def notify_update_availabe(new_version : str, current_version : str): - title = "Update Available" - message = f"An upgrade from COSMOS version {current_version} to {new_version} is available. Upgrade to the latest version for new features and bugfixes." - subprocess.run(["uiprompt", title, message]) - print(title) - print(message) - -def main(): - if not is_check_for_updates_enabled(): - print("Update check is disabled. Skipping update check.") - return - - version = sys.argv[1] if len(sys.argv) > 1 else get_current_version() - - if "PR" in version: - print("Running a PR build. Skipping update check.") - return - - is_stable = get_version_branch() == "stable" - remote_version = get_latest_release() if is_stable else get_latest_commit() - - if remote_version is None: - print("Failed to fetch latest version information.") - return - - is_match = remote_version == version if is_stable else remote_version.startswith(version) - if not is_match: - notify_update_availabe(remote_version, version) - else: - print("No updates available.") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/meta-opencentauri/recipes-data/config-manager/files/config_manager.py b/meta-opencentauri/recipes-data/config-manager/files/config_manager.py index 8378fc2e..6588459c 100644 --- a/meta-opencentauri/recipes-data/config-manager/files/config_manager.py +++ b/meta-opencentauri/recipes-data/config-manager/files/config_manager.py @@ -9,7 +9,6 @@ }, 'update': { 'release': ['stable', 'nightly'], - 'check_for_updates': ['True', 'False'], }, 'extras': { 'elegoo_canvas': ['True', 'False'], diff --git a/meta-opencentauri/recipes-data/config-manager/files/default.conf b/meta-opencentauri/recipes-data/config-manager/files/default.conf index dabb12d7..dfae0aef 100644 --- a/meta-opencentauri/recipes-data/config-manager/files/default.conf +++ b/meta-opencentauri/recipes-data/config-manager/files/default.conf @@ -13,8 +13,6 @@ screen_brightness = 100 [update] # Update channel used by the COSMOS updater. Options: stable, nightly. release = stable -# Check for updates automatically on startup. Options: True, False. -check_for_updates = True [extras] # Enable support for the CANVAS unit. Also enables the AFC subsystem. diff --git a/meta-opencentauri/recipes-data/update-scripts/files/update-cosmos b/meta-opencentauri/recipes-data/update-scripts/files/update-cosmos index 62b535dd..ddc35748 100644 --- a/meta-opencentauri/recipes-data/update-scripts/files/update-cosmos +++ b/meta-opencentauri/recipes-data/update-scripts/files/update-cosmos @@ -13,14 +13,49 @@ case "$RELEASE" in esac SWUFILE="/user-resource/update.swu" +LOG="/board-resource/update-cosmos.log" -# Download firmware -curl -k -f -S -o "$SWUFILE" -L "$FW_URL" +# Everything printed here is shown to the user by Moonraker's update_manager +# (Mainsail, Fluidd, ...). The chatter from curl and swupdate goes to $LOG. +: > "$LOG" + +# Size of the asset after redirects, so the download can report progress. If +# this fails the download still runs, just without percentages. +TOTAL=$(curl -k -sIL "$FW_URL" 2>>"$LOG" \ + | awk '/^[Cc]ontent-[Ll]ength:/ {v=$2} END {gsub(/\r/,"",v); print v+0}') + +echo "Downloading COSMOS update..." +curl -k -f -sS -o "$SWUFILE" -L "$FW_URL" 2>>"$LOG" & +CURL_PID=$! + +LAST=0 +while kill -0 "$CURL_PID" 2>/dev/null; do + if [ "$TOTAL" -gt 0 ] && [ -f "$SWUFILE" ]; then + CUR=$(wc -c < "$SWUFILE" || echo 0) + STEP=$(( CUR * 100 / TOTAL / 20 * 20 )) + if [ "$STEP" -gt "$LAST" ]; then + LAST=$STEP + echo "Downloading COSMOS update... ${STEP}%" + fi + fi + sleep 1 +done + +if ! wait "$CURL_PID"; then + echo "Download failed, see $LOG" + exit 1 +fi +[ "$LAST" -lt 100 ] && echo "Downloading COSMOS update... 100%" # Install firmware -flash "$SWUFILE" +echo "Installing the update, do not power off the printer!" +if ! flash "$SWUFILE" >>"$LOG" 2>&1; then + echo "Installing the update failed, see $LOG" + exit 1 +fi # Clean up firmware file rm "$SWUFILE" +echo "Update installed, rebooting..." reboot