Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package python-nitrokey for openSUSE:Factory 
checked in at 2025-08-07 16:48:44
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-nitrokey (Old)
 and      /work/SRC/openSUSE:Factory/.python-nitrokey.new.1085 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-nitrokey"

Thu Aug  7 16:48:44 2025 rev:5 rq:1297995 version:0.4.0

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-nitrokey/python-nitrokey.changes  
2025-07-09 17:29:56.863403012 +0200
+++ 
/work/SRC/openSUSE:Factory/.python-nitrokey.new.1085/python-nitrokey.changes    
    2025-08-07 16:49:57.539181616 +0200
@@ -1,0 +2,22 @@
+Mon Aug  4 09:21:43 UTC 2025 - Johannes Kastl 
<opensuse_buildserv...@ojkastl.de>
+
+- update to 0.4.0:
+  * nitrokey.trussed.admin_app.InitStatus: add support for
+    EXT_FLASH_NEED_REFORMAT
+  * Use poetry-core v2 as build backend.
+  * Bump minimum Python version to 3.10.
+  * nitrokey.trussed.Model: Remove firmware_repository and
+    firmware_pattern properties.
+  * nitrokey.nk3.updates:
+    - Move to nitrokey.trussed.updates and prepare adding NKPK
+      support.
+    - Return device status after an update.
+    - Add model argument to get_firmware_update.
+    - Add get_firmware_repository method.
+    - Replace connection callbacks in Updater with DeviceHandler
+      class.
+- add version constraints for dependencies
+- relax constraint on protobuf, see
+  https://github.com/Nitrokey/nitrokey-sdk-py/issues/84#issuecomment-3158310783
+
+-------------------------------------------------------------------

Old:
----
  nitrokey-0.3.2.tar.gz

New:
----
  nitrokey-0.4.0.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-nitrokey.spec ++++++
--- /var/tmp/diff_new_pack.OeryBv/_old  2025-08-07 16:49:59.367258511 +0200
+++ /var/tmp/diff_new_pack.OeryBv/_new  2025-08-07 16:49:59.407260193 +0200
@@ -1,7 +1,7 @@
 #
 # spec file for package python-nitrokey
 #
-# Copyright (c) 2025 SUSE LLC
+# Copyright (c) 2025 SUSE LLC and contributors
 #
 # All modifications and additions to the file contributed by third parties
 # remain the property of their copyright owners, unless otherwise agreed
@@ -18,29 +18,39 @@
 
 %{?sle15_python_module_pythons}
 Name:           python-nitrokey
-Version:        0.3.2
+Version:        0.4.0
 Release:        0
 Summary:        Nitrokey Python SDK
 License:        Apache-2.0
 URL:            https://github.com/Nitrokey/nitrokey-sdk-py
 Source0:        
https://files.pythonhosted.org/packages/source/n/nitrokey/nitrokey-%{version}.tar.gz
 Source99:       python-nitrokey.rpmlintrc
-BuildRequires:  %{python_module base >= 3.9.2}
-BuildRequires:  %{python_module fido2 >= 1.1.2 with %python-fido2 < 3}
+BuildRequires:  %{python_module base >= 3.10}
 BuildRequires:  %{python_module pip}
 BuildRequires:  %{python_module poetry-core >= 1}
 BuildRequires:  %{python_module wheel}
+# Runtime dependencies
+BuildRequires:  %{python_module cryptography >= 41}
+BuildRequires:  %{python_module crcmod >= 1.7 with %python-crcmod < 2}
+BuildRequires:  %{python_module fido2 >= 1.1.2 with %python-fido2 < 3}
+BuildRequires:  %{python_module hidapi >= 0.14 with %python-hidapi < 0.15}
+BuildRequires:  %{python_module protobuf >= 5.26 with %python-protobuf < 7}
+BuildRequires:  %{python_module pyserial >= 3.5 with %python-pyserial < 4}
+BuildRequires:  %{python_module requests >= 2 with %python-requests < 3}
+BuildRequires:  %{python_module semver >= 3 with %python-semver < 4}
+BuildRequires:  %{python_module tlv8 >= 0.10 with %python-tlv8 < 0.11}
+#
 BuildRequires:  fdupes
 BuildRequires:  python-rpm-macros
-Requires:       python-crcmod
-Requires:       python-cryptography
-Requires:       python-hidapi
-Requires:       python-protobuf
-Requires:       python-pyserial
-Requires:       python-requests
-Requires:       python-semver
-Requires:       python-tlv8
+Requires:       python-cryptography >= 41
+Requires:       (python-crcmod >= 1.7 with python-crcmod < 2)
 Requires:       (python-fido2 >= 1.1.2 with python-fido2 < 3)
+Requires:       (python-hidapi >= 0.14 with python-hidapi < 0.15)
+Requires:       (python-protobuf >= 5.26 with python-protobuf < 7)
+Requires:       (python-pyserial >= 3.5 with python-pyserial < 4)
+Requires:       (python-requests >= 2 with python-requests < 3)
+Requires:       (python-semver >= 3 with python-semver < 4)
+Requires:       (python-tlv8 >= 0.10 with python-tlv8 < 0.11)
 BuildArch:      noarch
 %python_subpackages
 

++++++ nitrokey-0.3.2.tar.gz -> nitrokey-0.4.0.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/nitrokey-0.3.2/PKG-INFO new/nitrokey-0.4.0/PKG-INFO
--- old/nitrokey-0.3.2/PKG-INFO 1970-01-01 01:00:00.000000000 +0100
+++ new/nitrokey-0.4.0/PKG-INFO 1970-01-01 01:00:00.000000000 +0100
@@ -1,12 +1,11 @@
-Metadata-Version: 2.1
+Metadata-Version: 2.3
 Name: nitrokey
-Version: 0.3.2
+Version: 0.4.0
 Summary: Nitrokey Python SDK
-Home-page: https://github.com/Nitrokey/nitrokey-sdk-py
 License: Apache-2.0 or MIT
 Author: Nitrokey
 Author-email: p...@nitrokey.com
-Requires-Python: >=3.9.2,<4.0.0
+Requires-Python: >=3.10, <4
 Classifier: Intended Audience :: Developers
 Classifier: License :: Other/Proprietary License
 Classifier: Programming Language :: Python :: 3
@@ -14,13 +13,13 @@
 Classifier: Programming Language :: Python :: 3.11
 Classifier: Programming Language :: Python :: 3.12
 Classifier: Programming Language :: Python :: 3.13
-Requires-Dist: crcmod (>=1.7,<2.0)
+Requires-Dist: crcmod (>=1.7,<2)
 Requires-Dist: cryptography (>=41)
 Requires-Dist: fido2 (>=1.1.2,<3)
 Requires-Dist: hidapi (>=0.14,<0.15)
-Requires-Dist: protobuf (>=5.26,<6.0)
-Requires-Dist: pyserial (>=3.5,<4.0)
-Requires-Dist: requests (>=2,<3)
+Requires-Dist: protobuf (>=5.26,<6)
+Requires-Dist: pyserial (>=3.5,<4)
+Requires-Dist: requests (>=2.16,<3)
 Requires-Dist: semver (>=3,<4)
 Requires-Dist: tlv8 (>=0.10,<0.11)
 Project-URL: Repository, https://github.com/Nitrokey/nitrokey-sdk-py
@@ -76,7 +75,7 @@
 
 ## Compatibility
 
-The Nitrokey Python SDK currently requires Python 3.9.2 or later.
+The Nitrokey Python SDK currently requires Python 3.10 or later.
 Support for old Python versions may be dropped in minor releases.
 
 ## Related Projects
@@ -92,7 +91,7 @@
 
 The following software is required for the development of the SDK:
 
-- Python 3.9 or newer
+- Python 3.10 or newer
 - [poetry](https://python-poetry.org/)
 - GNU Make
 - git
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/nitrokey-0.3.2/README.md new/nitrokey-0.4.0/README.md
--- old/nitrokey-0.3.2/README.md        2025-07-08 12:41:08.000000000 +0200
+++ new/nitrokey-0.4.0/README.md        1970-01-01 01:00:00.000000000 +0100
@@ -48,7 +48,7 @@
 
 ## Compatibility
 
-The Nitrokey Python SDK currently requires Python 3.9.2 or later.
+The Nitrokey Python SDK currently requires Python 3.10 or later.
 Support for old Python versions may be dropped in minor releases.
 
 ## Related Projects
@@ -64,7 +64,7 @@
 
 The following software is required for the development of the SDK:
 
-- Python 3.9 or newer
+- Python 3.10 or newer
 - [poetry](https://python-poetry.org/)
 - GNU Make
 - git
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/nitrokey-0.3.2/pyproject.toml 
new/nitrokey-0.4.0/pyproject.toml
--- old/nitrokey-0.3.2/pyproject.toml   2025-07-08 12:41:08.000000000 +0200
+++ new/nitrokey-0.4.0/pyproject.toml   1970-01-01 01:00:00.000000000 +0100
@@ -1,37 +1,45 @@
 [build-system]
-requires = ["poetry-core >=1,<3"]
+requires = ["poetry-core >=2, <3"]
 build-backend = "poetry.core.masonry.api"
 
-[tool.poetry]
+[project]
 name = "nitrokey"
-version = "0.3.2"
+version = "0.4.0"
 description = "Nitrokey Python SDK"
-authors = ["Nitrokey <p...@nitrokey.com>"]
+authors = [
+    { name = "Nitrokey", email = "p...@nitrokey.com" },
+]
 license = "Apache-2.0 or MIT"
 readme = "README.md"
-repository = "https://github.com/Nitrokey/nitrokey-sdk-py";
-classifiers = [
-    "Intended Audience :: Developers",
-]
 packages = [
     { include = "nitrokey", from = "src" },
 ]
+requires-python = ">=3.10, <4"
+dynamic = ["classifiers"]
+
+dependencies = [
+    "cryptography >=41",
+    "fido2 >=1.1.2, <3",
+    "requests >=2.16, <3",
+    "semver >=3, <4",
+    "tlv8 >=0.10, <0.11",
+
+    # lpc55
+    "crcmod >=1.7, <2",
+    "hidapi >=0.14, <0.15",
+
+    # nrf52
+    "protobuf >=5.26, <6",
+    "pyserial >=3.5, <4",
+]
 
-[tool.poetry.dependencies]
-cryptography = ">=41"
-fido2 = ">=1.1.2, <3"
-python = "^3.9.2"
-requests = "^2"
-semver = "^3"
-tlv8 = "^0.10"
-
-# lpc55
-crcmod = "^1.7"
-hidapi = "^0.14"
-
-# nrf52
-protobuf = "^5.26"
-pyserial = "^3.5"
+[project.urls]
+repository = "https://github.com/Nitrokey/nitrokey-sdk-py";
+
+[tool.poetry]
+classifiers = [
+    "Intended Audience :: Developers",
+]
 
 [tool.poetry.group.dev]
 optional = true
@@ -45,20 +53,35 @@
 rstcheck = { version = "^6", extras = ["sphinx"] }
 sphinx = "^7"
 types-protobuf = "^5.26"
-types-requests = "^2.32"
+types-requests = "^2.16"
 typing-extensions = "^4.1"
 
+[tool.uv]
+dev-dependencies = [
+    # These dependencies are required for running the type checks.
+    "fake-winreg >=1.6, <2",
+    "mypy >=1.4, <2",
+    "types-protobuf >=5.26, <6",
+    "types-requests >=2.16, <3",
+
+    # These additional lower bounds are required for --resolution lowest.
+    # As these are transitive dependencies, we don’t include them in our 
dependency list.
+    "cffi >=1.14.1",
+    "typing-extensions >=4.1",
+    "urllib3 >= 2",
+]
+
 [tool.black]
-target-version = ["py39"]
+target-version = ["py310"]
 
 [tool.isort]
-py_version = "39"
+py_version = "310"
 profile = "black"
 
 [tool.mypy]
 mypy_path = "stubs"
 show_error_codes = true
-python_version = "3.9"
+python_version = "3.10"
 strict = true
 
 [tool.rstcheck]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/nitrokey-0.3.2/src/nitrokey/nk3/_bootloader.py 
new/nitrokey-0.4.0/src/nitrokey/nk3/_bootloader.py
--- old/nitrokey-0.3.2/src/nitrokey/nk3/_bootloader.py  2025-07-08 
12:41:08.000000000 +0200
+++ new/nitrokey-0.4.0/src/nitrokey/nk3/_bootloader.py  1970-01-01 
01:00:00.000000000 +0100
@@ -8,12 +8,17 @@
 from typing import List, Optional, Sequence
 
 from nitrokey import _VID_NITROKEY
+from nitrokey.trussed._base import Model
 from nitrokey.trussed._bootloader import TrussedBootloader
 from nitrokey.trussed._bootloader.lpc55 import TrussedBootloaderLpc55
 from nitrokey.trussed._bootloader.nrf52 import SignatureKey, 
TrussedBootloaderNrf52
 
 
 class NK3Bootloader(TrussedBootloader):
+    @property
+    def model(self) -> Model:
+        return Model.NK3
+
     @staticmethod
     def list() -> List["NK3Bootloader"]:
         devices: List[NK3Bootloader] = []
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/nitrokey-0.3.2/src/nitrokey/nk3/_device.py 
new/nitrokey-0.4.0/src/nitrokey/nk3/_device.py
--- old/nitrokey-0.3.2/src/nitrokey/nk3/_device.py      2025-07-08 
12:41:08.000000000 +0200
+++ new/nitrokey-0.4.0/src/nitrokey/nk3/_device.py      1970-01-01 
01:00:00.000000000 +0100
@@ -10,7 +10,7 @@
 from fido2.hid import CtapHidDevice, list_descriptors, open_device
 
 from nitrokey import _VID_NITROKEY
-from nitrokey.trussed import Fido2Certs, TrussedDevice, Version
+from nitrokey.trussed import Fido2Certs, Model, TrussedDevice, Version
 
 FIDO2_CERTS = [
     Fido2Certs(
@@ -37,6 +37,10 @@
         super().__init__(device, FIDO2_CERTS)
 
     @property
+    def model(self) -> Model:
+        return Model.NK3
+
+    @property
     def pid(self) -> int:
         from . import _PID_NK3_DEVICE
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/nitrokey-0.3.2/src/nitrokey/nk3/updates.py 
new/nitrokey-0.4.0/src/nitrokey/nk3/updates.py
--- old/nitrokey-0.3.2/src/nitrokey/nk3/updates.py      2025-07-08 
12:41:08.000000000 +0200
+++ new/nitrokey-0.4.0/src/nitrokey/nk3/updates.py      1970-01-01 
01:00:00.000000000 +0100
@@ -1,527 +0,0 @@
-# Copyright 2022 Nitrokey Developers
-#
-# Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
-# http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
-# http://opensource.org/licenses/MIT>, at your option. This file may not be
-# copied, modified, or distributed except according to those terms.
-
-import enum
-import importlib.metadata
-import logging
-import platform
-import time
-from abc import ABC, abstractmethod
-from collections.abc import Set
-from contextlib import contextmanager
-from importlib.metadata import PackageNotFoundError
-from io import BytesIO
-from typing import TYPE_CHECKING, Any, Callable, Iterator, List, Optional, 
Union
-
-from nitrokey._helpers import Retries
-from nitrokey.nk3 import NK3, NK3Bootloader
-from nitrokey.trussed import TimeoutException, TrussedBase, Version
-from nitrokey.trussed._bootloader import (
-    FirmwareContainer,
-    Model,
-    Variant,
-    validate_firmware_image,
-)
-from nitrokey.trussed._bootloader.lpc55_upload.mboot.exceptions import (
-    McuBootConnectionError,
-)
-from nitrokey.trussed.admin_app import BootMode, Status
-from nitrokey.trussed.admin_app import Variant as AdminAppVariant
-from nitrokey.updates import Asset, Release
-
-if TYPE_CHECKING:
-    import typing_extensions
-
-logger = logging.getLogger(__name__)
-
-
-@enum.unique
-class Warning(enum.Enum):
-    """
-    A warning that can occur during a firmware update.
-
-    By default, these warnings abort the firmware update.  This enum can be 
used to select types
-    of warnings that should be ignored and not cause the firmware update to 
fail.
-    """
-
-    IFS_MIGRATION_V2 = "ifs-migration-v2"
-    MISSING_STATUS = "missing-status"
-    SDK_VERSION = "sdk-version"
-    UPDATE_FROM_BOOTLOADER = "update-from-bootloader"
-
-    @property
-    def message(self) -> str:
-        if self == Warning.IFS_MIGRATION_V2:
-            return (
-                "Not enough space on the internal filesystem to perform the 
firmware"
-                " update. See the release notes for more information:"
-                " 
https://github.com/Nitrokey/nitrokey-3-firmware/releases/tag/v1.8.2-test.20250312";
-            )
-        if self == Warning.MISSING_STATUS:
-            return (
-                "Could not determine the device state as the current firmware 
is too old."
-                " Please update to firmware version v1.3.1 first."
-            )
-        if self == Warning.SDK_VERSION:
-            return (
-                "Your Nitrokey SDK version is outdated.  Please update this 
program to the latest"
-                " version and try again."
-            )
-        if self == Warning.UPDATE_FROM_BOOTLOADER:
-            return (
-                "The current state of the device cannot be checked as it is 
already in bootloader"
-                " mode. Please review the release notes at:"
-                " https://github.com/Nitrokey/nitrokey-3-firmware/releases";
-            )
-
-        if TYPE_CHECKING:
-            typing_extensions.assert_never(self)
-
-        return self.value
-
-    @classmethod
-    def from_str(cls, s: str) -> "Warning":
-        for w in cls:
-            if w.value == s:
-                return w
-        raise ValueError(f"Unexpected update warning id: {s}")
-
-
-@enum.unique
-class _Migration(enum.Enum):
-    # IFS migration to use journaling on the NRF52 introduced in v1.3.0
-    NRF_IFS_MIGRATION = enum.auto()
-    # IFS migration to filesystem layout 2 in v1.8.2 (FIDO2 RK migration)
-    IFS_MIGRATION_V2 = enum.auto()
-
-    @classmethod
-    def get(
-        cls,
-        variant: Union[Variant, AdminAppVariant],
-        current: Optional[Version],
-        new: Version,
-    ) -> frozenset["_Migration"]:
-        if isinstance(variant, AdminAppVariant):
-            if variant == AdminAppVariant.USBIP:
-                raise ValueError("Cannot perform firmware update for USBIP 
runner")
-            elif variant == AdminAppVariant.LPC55:
-                variant = Variant.LPC55
-            elif variant == AdminAppVariant.NRF52:
-                variant = Variant.NRF52
-            else:
-                if TYPE_CHECKING:
-                    typing_extensions.assert_never(variant)
-
-                raise ValueError(f"Unsupported device variant: {variant}")
-
-        migrations = set()
-
-        if variant == Variant.NRF52:
-            if (
-                current is None
-                or current <= Version(1, 2, 2)
-                and new >= Version(1, 3, 0)
-            ):
-                migrations.add(cls.NRF_IFS_MIGRATION)
-
-        ifs_migration_v2 = Version(1, 8, 2)
-        if (
-            current is not None
-            and current < ifs_migration_v2
-            and new >= ifs_migration_v2
-        ):
-            migrations.add(cls.IFS_MIGRATION_V2)
-
-        return frozenset(migrations)
-
-
-def get_firmware_update(release: Release) -> Asset:
-    return release.require_asset(Model.NK3.firmware_pattern)
-
-
-def _get_extra_information(migrations: Set[_Migration]) -> List[str]:
-    """Return additional information for the device after update based on 
update-path"""
-
-    out = []
-    if _Migration.NRF_IFS_MIGRATION in migrations:
-        out += [
-            "",
-            "During this update process the internal filesystem will be 
migrated!",
-            "- Migration will only work, if your internal filesystem does not 
contain more than 45 Resident Keys. If you have more please remove some.",
-            "- After the update it might take up to 3 minutes for the first 
boot.",
-            "Never unplug the device while the LED is active!",
-        ]
-    return out
-
-
-def _get_finalization_wait_retries(migrations: Set[_Migration]) -> int:
-    """Return number of retries to wait for the device after update based on 
update-path"""
-
-    out = 60
-    if _Migration.NRF_IFS_MIGRATION in migrations:
-        # max time 150secs == 300 retries
-        out = 500
-    return out
-
-
-class UpdateUi(ABC):
-    @abstractmethod
-    def error(self, *msgs: Any) -> Exception:
-        pass
-
-    @abstractmethod
-    def show_warning(self, warning: Warning) -> None:
-        pass
-
-    @abstractmethod
-    def raise_warning(self, warning: Warning) -> Exception:
-        pass
-
-    @abstractmethod
-    def abort(self, *msgs: Any) -> Exception:
-        pass
-
-    @abstractmethod
-    def abort_downgrade(self, current: Version, image: Version) -> Exception:
-        pass
-
-    @abstractmethod
-    def abort_pynitrokey_version(
-        self, current: Version, required: Version
-    ) -> Exception:
-        pass
-
-    @abstractmethod
-    def confirm_download(self, current: Optional[Version], new: Version) -> 
None:
-        pass
-
-    @abstractmethod
-    def confirm_update(self, current: Optional[Version], new: Version) -> None:
-        pass
-
-    @abstractmethod
-    def confirm_pynitrokey_version(self, current: Version, required: Version) 
-> None:
-        pass
-
-    @abstractmethod
-    def confirm_extra_information(self, extra_info: List[str]) -> None:
-        pass
-
-    @abstractmethod
-    def confirm_update_same_version(self, version: Version) -> None:
-        pass
-
-    @abstractmethod
-    def pre_bootloader_hint(self) -> None:
-        pass
-
-    @abstractmethod
-    def request_bootloader_confirmation(self) -> None:
-        pass
-
-    @abstractmethod
-    @contextmanager
-    def download_progress_bar(self, desc: str) -> Iterator[Callable[[int, 
int], None]]:
-        pass
-
-    @abstractmethod
-    @contextmanager
-    def update_progress_bar(self) -> Iterator[Callable[[int, int], None]]:
-        pass
-
-    @abstractmethod
-    @contextmanager
-    def finalization_progress_bar(self) -> Iterator[Callable[[int, int], 
None]]:
-        pass
-
-
-class Updater:
-    def __init__(
-        self,
-        ui: UpdateUi,
-        await_bootloader: Callable[[], NK3Bootloader],
-        await_device: Callable[
-            [Optional[int], Optional[Callable[[int, int], None]]], NK3
-        ],
-        ignore_warnings: Set[Warning] = frozenset(),
-    ) -> None:
-        self.ui = ui
-        self.await_bootloader = await_bootloader
-        self.await_device = await_device
-        self.ignore_warnings = ignore_warnings
-
-    def _trigger_warning(self, warning: Warning) -> None:
-        if warning in self.ignore_warnings:
-            self.ui.show_warning(warning)
-        else:
-            raise self.ui.raise_warning(warning)
-
-    def update(
-        self,
-        device: TrussedBase,
-        image: Optional[str],
-        update_version: Optional[str],
-        ignore_pynitrokey_version: bool = False,
-    ) -> Version:
-        update_from_bootloader = False
-        current_version = None
-        status = None
-        if isinstance(device, NK3Bootloader):
-            update_from_bootloader = True
-            self._trigger_warning(Warning.UPDATE_FROM_BOOTLOADER)
-        elif isinstance(device, NK3):
-            current_version = device.admin.version()
-            status = device.admin.status()
-        else:
-            raise self.ui.error(f"Unexpected Trussed device: {device}")
-
-        logger.info(f"Firmware version before update: {current_version or ''}")
-        container = self._prepare_update(image, update_version, 
current_version)
-
-        if not update_from_bootloader:
-            if status is None and container.version > 
Version.from_str("1.3.1"):
-                self._trigger_warning(Warning.MISSING_STATUS)
-
-        self._check_minimum_version(container, ignore_pynitrokey_version)
-
-        self.ui.confirm_update(current_version, container.version)
-
-        migrations = None
-        if status is not None and status.variant is not None:
-            migrations = self._check_migrations(
-                status.variant, current_version, container.version, status
-            )
-        elif isinstance(device, NK3Bootloader):
-            migrations = self._check_migrations(
-                device.variant, current_version, container.version, status
-            )
-
-        with self._get_bootloader(device) as bootloader:
-            if bootloader.variant not in container.images:
-                raise self.ui.error(
-                    "The firmware release does not contain an image for the "
-                    f"{bootloader.variant.value} hardware variant"
-                )
-            try:
-                validate_firmware_image(
-                    bootloader.variant,
-                    container.images[bootloader.variant],
-                    container.version,
-                    Model.NK3,
-                )
-            except Exception as e:
-                raise self.ui.error("Failed to validate firmware image", e)
-
-            if migrations is None:
-                migrations = self._check_migrations(
-                    bootloader.variant, current_version, container.version, 
status
-                )
-
-            self._perform_update(bootloader, container)
-
-        wait_retries = _get_finalization_wait_retries(migrations)
-        with self.ui.finalization_progress_bar() as callback:
-            with self.await_device(wait_retries, callback) as device:
-                version = device.admin.version()
-                if version != container.version:
-                    raise self.ui.error(
-                        f"The firmware update to {container.version} was 
successful, but the "
-                        f"firmware is still reporting version {version}."
-                    )
-
-        return container.version
-
-    def _prepare_update(
-        self,
-        image: Optional[str],
-        version: Optional[str],
-        current_version: Optional[Version],
-    ) -> FirmwareContainer:
-        if image:
-            try:
-                container = FirmwareContainer.parse(image, Model.NK3)
-            except Exception as e:
-                raise self.ui.error("Failed to parse firmware container", e)
-            self._validate_version(current_version, container.version)
-            return container
-        else:
-            repository = Model.NK3.firmware_repository
-            if version:
-                try:
-                    logger.info(f"Downloading firmare version {version}")
-                    release = repository.get_release(version)
-                except Exception as e:
-                    raise self.ui.error(f"Failed to get firmware release 
{version}", e)
-            else:
-                try:
-                    release = repository.get_latest_release()
-                    logger.info(f"Latest firmware version: {release}")
-                except Exception as e:
-                    raise self.ui.error("Failed to find latest firmware 
release", e)
-
-            try:
-                release_version = Version.from_v_str(release.tag)
-            except ValueError as e:
-                raise self.ui.error("Failed to parse version from release 
tag", e)
-            self._validate_version(current_version, release_version)
-            self.ui.confirm_download(current_version, release_version)
-            return self._download_update(release)
-
-    def _download_update(self, release: Release) -> FirmwareContainer:
-        try:
-            update = get_firmware_update(release)
-        except Exception as e:
-            raise self.ui.error(
-                f"Failed to find firmware image for release {release}",
-                e,
-            )
-
-        try:
-            logger.info(f"Trying to download firmware update from URL: 
{update.url}")
-
-            with self.ui.download_progress_bar(update.tag) as callback:
-                data = update.read(callback=callback)
-        except Exception as e:
-            raise self.ui.error(
-                f"Failed to download latest firmware update {update.tag}", e
-            )
-
-        try:
-            container = FirmwareContainer.parse(BytesIO(data), Model.NK3)
-        except Exception as e:
-            raise self.ui.error(
-                f"Failed to parse firmware container for {update.tag}", e
-            )
-
-        release_version = Version.from_v_str(release.tag)
-        if release_version != container.version:
-            raise self.ui.error(
-                f"The firmware container for {update.tag} has the version 
{container.version}"
-            )
-
-        return container
-
-    def _check_minimum_version(
-        self, container: FirmwareContainer, ignore_pynitrokey_version: bool
-    ) -> None:
-        if container.sdk:
-            try:
-                sdk_version = 
Version.from_str(importlib.metadata.version("nitrokey"))
-            except PackageNotFoundError:
-                raise self.ui.error("Failed to determine the Nitrokey SDK 
version")
-
-            if container.sdk > sdk_version:
-                logger.warning(
-                    f"Minimum SDK version required for update is 
{container.sdk} (current version: {sdk_version})"
-                )
-                self._trigger_warning(Warning.SDK_VERSION)
-        elif container.pynitrokey:
-            # The minimum pynitrokey version has been replaced by the minimum 
SDK version, so we
-            # only check it if there is no minimum SDK version set.
-
-            # this is the version of pynitrokey when we moved to the SDK
-            pynitrokey_version = Version.from_str("0.4.49")
-            if container.pynitrokey > pynitrokey_version:
-                if ignore_pynitrokey_version:
-                    self.ui.confirm_pynitrokey_version(
-                        current=pynitrokey_version, 
required=container.pynitrokey
-                    )
-                else:
-                    raise self.ui.abort_pynitrokey_version(
-                        current=pynitrokey_version, 
required=container.pynitrokey
-                    )
-
-    def _validate_version(
-        self,
-        current_version: Optional[Version],
-        new_version: Version,
-    ) -> None:
-        logger.info(f"Current firmware version: {current_version}")
-        logger.info(f"Updated firmware version: {new_version}")
-
-        if current_version:
-            if current_version.core() > new_version.core():
-                raise self.ui.abort_downgrade(current_version, new_version)
-            elif current_version == new_version:
-                if current_version.complete and new_version.complete:
-                    same_version = current_version
-                else:
-                    same_version = current_version.core()
-                self.ui.confirm_update_same_version(same_version)
-
-    @contextmanager
-    def _get_bootloader(self, device: TrussedBase) -> Iterator[NK3Bootloader]:
-        if isinstance(device, NK3):
-            self.ui.request_bootloader_confirmation()
-            try:
-                device.admin.reboot(BootMode.BOOTROM)
-            except TimeoutException:
-                raise self.ui.abort(
-                    "The reboot was not confirmed with the touch button"
-                )
-
-            # needed for udev to properly handle new device
-            time.sleep(1)
-
-            self.ui.pre_bootloader_hint()
-
-            exc = None
-            for t in Retries(3):
-                logger.debug(f"Trying to connect to bootloader ({t})")
-                try:
-                    with self.await_bootloader() as bootloader:
-                        # noop to test communication
-                        bootloader.uuid
-                        yield bootloader
-                        break
-                except McuBootConnectionError as e:
-                    logger.debug("Received connection error", exc_info=True)
-                    exc = e
-            else:
-                msgs = ["Failed to connect to Nitrokey 3 bootloader"]
-                if platform.system() == "Linux":
-                    msgs += ["Are the Nitrokey udev rules installed and 
active?"]
-                raise self.ui.error(*msgs, exc)
-        elif isinstance(device, NK3Bootloader):
-            yield device
-        else:
-            raise self.ui.error(f"Unexpected Nitrokey 3 device: {device}")
-
-    def _check_migrations(
-        self,
-        variant: Union[Variant, AdminAppVariant],
-        current_version: Optional[Version],
-        new_version: Version,
-        status: Optional[Status],
-    ) -> frozenset["_Migration"]:
-        try:
-            migrations = _Migration.get(
-                variant=variant, current=current_version, new=new_version
-            )
-        except ValueError as e:
-            raise self.ui.error(str(e))
-
-        txt = _get_extra_information(migrations)
-        self.ui.confirm_extra_information(txt)
-
-        if _Migration.IFS_MIGRATION_V2 in migrations:
-            if status and status.ifs_blocks is not None and status.ifs_blocks 
< 5:
-                self._trigger_warning(Warning.IFS_MIGRATION_V2)
-
-        return migrations
-
-    def _perform_update(
-        self, device: NK3Bootloader, container: FirmwareContainer
-    ) -> None:
-        logger.debug("Starting firmware update")
-        image = container.images[device.variant]
-        with self.ui.update_progress_bar() as callback:
-            try:
-                device.update(image, callback=callback)
-            except Exception as e:
-                raise self.ui.error("Failed to perform firmware update", e)
-        logger.debug("Firmware update finished successfully")
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/nitrokey-0.3.2/src/nitrokey/nkpk.py 
new/nitrokey-0.4.0/src/nitrokey/nkpk.py
--- old/nitrokey-0.3.2/src/nitrokey/nkpk.py     2025-07-08 12:41:08.000000000 
+0200
+++ new/nitrokey-0.4.0/src/nitrokey/nkpk.py     1970-01-01 01:00:00.000000000 
+0100
@@ -11,6 +11,7 @@
 
 from nitrokey import _VID_NITROKEY
 from nitrokey.trussed import Fido2Certs, TrussedDevice, Version
+from nitrokey.trussed._base import Model
 from nitrokey.trussed._bootloader import ModelData
 from nitrokey.trussed._bootloader.nrf52 import SignatureKey, 
TrussedBootloaderNrf52
 
@@ -49,6 +50,10 @@
         super().__init__(device, _FIDO2_CERTS)
 
     @property
+    def model(self) -> Model:
+        return Model.NKPK
+
+    @property
     def pid(self) -> int:
         return _PID_NKPK_DEVICE
 
@@ -78,6 +83,10 @@
 
 class NKPKBootloader(TrussedBootloaderNrf52):
     @property
+    def model(self) -> Model:
+        return Model.NKPK
+
+    @property
     def name(self) -> str:
         return "Nitrokey Passkey Bootloader"
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/nitrokey-0.3.2/src/nitrokey/trussed/__init__.py 
new/nitrokey-0.4.0/src/nitrokey/trussed/__init__.py
--- old/nitrokey-0.3.2/src/nitrokey/trussed/__init__.py 2025-07-08 
12:41:08.000000000 +0200
+++ new/nitrokey-0.4.0/src/nitrokey/trussed/__init__.py 1970-01-01 
01:00:00.000000000 +0100
@@ -5,10 +5,10 @@
 # http://opensource.org/licenses/MIT>, at your option. This file may not be
 # copied, modified, or distributed except according to those terms.
 
+from ._base import Model as Model  # noqa: F401
 from ._base import TrussedBase as TrussedBase  # noqa: F401
 from ._bootloader import FirmwareContainer as FirmwareContainer  # noqa: F401
 from ._bootloader import FirmwareMetadata as FirmwareMetadata  # noqa: F401
-from ._bootloader import Model as Model  # noqa: F401
 from ._bootloader import TrussedBootloader as TrussedBootloader  # noqa: F401
 from ._bootloader import Variant as Variant  # noqa: F401
 from ._bootloader import parse_firmware_image as parse_firmware_image  # noqa: 
F401
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/nitrokey-0.3.2/src/nitrokey/trussed/_base.py 
new/nitrokey-0.4.0/src/nitrokey/trussed/_base.py
--- old/nitrokey-0.3.2/src/nitrokey/trussed/_base.py    2025-07-08 
12:41:08.000000000 +0200
+++ new/nitrokey-0.4.0/src/nitrokey/trussed/_base.py    1970-01-01 
01:00:00.000000000 +0100
@@ -6,6 +6,7 @@
 # copied, modified, or distributed except according to those terms.
 
 from abc import ABC, abstractmethod
+from enum import Enum
 from typing import Optional, TypeVar
 
 from nitrokey import _VID_NITROKEY
@@ -15,6 +16,21 @@
 T = TypeVar("T", bound="TrussedBase")
 
 
+class Model(Enum):
+    NK3 = "Nitrokey 3"
+    NKPK = "Nitrokey Passkey"
+
+    def __str__(self) -> str:
+        return self.value
+
+    @classmethod
+    def from_str(cls, s: str) -> "Model":
+        for model in cls:
+            if model.value == s:
+                return model
+        raise ValueError(f"Unknown model {s}")
+
+
 class TrussedBase(ABC):
     """
     Base class for Nitrokey devices using the Trussed framework and running
@@ -35,6 +51,10 @@
             )
 
     @property
+    @abstractmethod
+    def model(self) -> Model: ...
+
+    @property
     def vid(self) -> int:
         return _VID_NITROKEY
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/nitrokey-0.3.2/src/nitrokey/trussed/_bootloader/__init__.py 
new/nitrokey-0.4.0/src/nitrokey/trussed/_bootloader/__init__.py
--- old/nitrokey-0.3.2/src/nitrokey/trussed/_bootloader/__init__.py     
2025-07-08 12:41:08.000000000 +0200
+++ new/nitrokey-0.4.0/src/nitrokey/trussed/_bootloader/__init__.py     
1970-01-01 01:00:00.000000000 +0100
@@ -9,7 +9,6 @@
 import hashlib
 import json
 import logging
-import re
 import sys
 from abc import abstractmethod
 from dataclasses import dataclass
@@ -18,9 +17,7 @@
 from typing import TYPE_CHECKING, Callable, Dict, Optional, Tuple, Union
 from zipfile import ZipFile
 
-from nitrokey.updates import Repository
-
-from .._base import TrussedBase
+from .._base import Model, TrussedBase
 from .._utils import Version
 
 if TYPE_CHECKING:
@@ -39,39 +36,16 @@
     nrf52_signature_keys: list["SignatureKey"]
 
 
-class Model(enum.Enum):
-    NK3 = "Nitrokey 3"
-    NKPK = "Nitrokey Passkey"
-
-    def __str__(self) -> str:
-        return self.value
-
-    @property
-    def firmware_repository(self) -> Repository:
-        return Repository(owner="Nitrokey", 
name=self._data.firmware_repository_name)
+def get_model_data(model: Model) -> ModelData:
+    if model == Model.NK3:
+        from nitrokey.nk3 import _NK3_DATA
+
+        return _NK3_DATA
+    if model == Model.NKPK:
+        from nitrokey.nkpk import _NKPK_DATA
 
-    @property
-    def firmware_pattern(self) -> Pattern[str]:
-        return re.compile(self._data.firmware_pattern_string)
-
-    @property
-    def _data(self) -> "ModelData":
-        if self == Model.NK3:
-            from nitrokey.nk3 import _NK3_DATA
-
-            return _NK3_DATA
-        if self == Model.NKPK:
-            from nitrokey.nkpk import _NKPK_DATA
-
-            return _NKPK_DATA
-        raise ValueError(f"Unknown model {self}")
-
-    @classmethod
-    def from_str(cls, s: str) -> "Model":
-        for model in cls:
-            if model.value == s:
-                return model
-        raise ValueError(f"Unknown model {s}")
+        return _NKPK_DATA
+    raise ValueError(f"Unknown model {model}")
 
 
 class Variant(enum.Enum):
@@ -221,6 +195,8 @@
     if variant == Variant.LPC55:
         return parse_firmware_image_lpc55(data)
     elif variant == Variant.NRF52:
-        return parse_firmware_image_nrf52(data, 
model._data.nrf52_signature_keys)
+        return parse_firmware_image_nrf52(
+            data, get_model_data(model).nrf52_signature_keys
+        )
     else:
         raise ValueError(f"Unexpected variant {variant}")
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/nitrokey-0.3.2/src/nitrokey/trussed/_bootloader/nrf52_upload/dfu/dfu_transport_serial.py
 
new/nitrokey-0.4.0/src/nitrokey/trussed/_bootloader/nrf52_upload/dfu/dfu_transport_serial.py
--- 
old/nitrokey-0.3.2/src/nitrokey/trussed/_bootloader/nrf52_upload/dfu/dfu_transport_serial.py
        2025-07-08 12:41:08.000000000 +0200
+++ 
new/nitrokey-0.4.0/src/nitrokey/trussed/_bootloader/nrf52_upload/dfu/dfu_transport_serial.py
        1970-01-01 01:00:00.000000000 +0100
@@ -146,9 +146,9 @@
         while finished is False:
             byte = self.serial_port.read(1)
             if byte:
-                (byte) = struct.unpack("B", byte)[0]
+                (unpacked_byte) = struct.unpack("B", byte)[0]
                 (finished, current_state, decoded_data) = Slip.decode_add_byte(
-                    byte, decoded_data, current_state
+                    unpacked_byte, decoded_data, current_state
                 )
             else:
                 current_state = Slip.SLIP_STATE_CLEARING_INVALID_PACKET
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/nitrokey-0.3.2/src/nitrokey/trussed/admin_app.py 
new/nitrokey-0.4.0/src/nitrokey/trussed/admin_app.py
--- old/nitrokey-0.3.2/src/nitrokey/trussed/admin_app.py        2025-07-08 
12:41:08.000000000 +0200
+++ new/nitrokey-0.4.0/src/nitrokey/trussed/admin_app.py        1970-01-01 
01:00:00.000000000 +0100
@@ -63,6 +63,7 @@
     SE050_ERROR = 0b00010000
     CONFIG_ERROR = 0b00100000
     RNG_ERROR = 0b01000000
+    EXT_FLASH_NEED_REFORMAT = 0b10000000
 
     def is_error(self) -> bool:
         return self.value != 0
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/nitrokey-0.3.2/src/nitrokey/trussed/updates.py 
new/nitrokey-0.4.0/src/nitrokey/trussed/updates.py
--- old/nitrokey-0.3.2/src/nitrokey/trussed/updates.py  1970-01-01 
01:00:00.000000000 +0100
+++ new/nitrokey-0.4.0/src/nitrokey/trussed/updates.py  1970-01-01 
01:00:00.000000000 +0100
@@ -0,0 +1,568 @@
+# Copyright 2022 Nitrokey Developers
+#
+# Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
+# http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
+# http://opensource.org/licenses/MIT>, at your option. This file may not be
+# copied, modified, or distributed except according to those terms.
+
+import enum
+import importlib.metadata
+import logging
+import platform
+import re
+import time
+from abc import ABC, abstractmethod
+from collections.abc import Set
+from contextlib import contextmanager
+from importlib.metadata import PackageNotFoundError
+from io import BytesIO
+from typing import TYPE_CHECKING, Any, Callable, Iterator, List, Optional, 
Tuple, Union
+
+from nitrokey._helpers import Retries
+from nitrokey.trussed import TimeoutException, TrussedBase, Version
+from nitrokey.trussed._base import Model
+from nitrokey.trussed._bootloader import (
+    FirmwareContainer,
+    TrussedBootloader,
+    Variant,
+    get_model_data,
+    validate_firmware_image,
+)
+from nitrokey.trussed._bootloader.lpc55_upload.mboot.exceptions import (
+    McuBootConnectionError,
+)
+from nitrokey.trussed._device import TrussedDevice
+from nitrokey.trussed.admin_app import BootMode, Status
+from nitrokey.trussed.admin_app import Variant as AdminAppVariant
+from nitrokey.updates import Asset, Release, Repository
+
+if TYPE_CHECKING:
+    import typing_extensions
+
+logger = logging.getLogger(__name__)
+
+
+@enum.unique
+class Warning(enum.Enum):
+    """
+    A warning that can occur during a firmware update.
+
+    By default, these warnings abort the firmware update.  This enum can be 
used to select types
+    of warnings that should be ignored and not cause the firmware update to 
fail.
+    """
+
+    IFS_MIGRATION_V2 = "ifs-migration-v2"
+    MISSING_STATUS = "missing-status"
+    SDK_VERSION = "sdk-version"
+    UPDATE_FROM_BOOTLOADER = "update-from-bootloader"
+
+    @property
+    def message(self) -> str:
+        if self == Warning.IFS_MIGRATION_V2:
+            return (
+                "Not enough space on the internal filesystem to perform the 
firmware"
+                " update. See the release notes for more information:"
+                " 
https://github.com/Nitrokey/nitrokey-3-firmware/releases/tag/v1.8.2-test.20250312";
+            )
+        if self == Warning.MISSING_STATUS:
+            return (
+                "Could not determine the device state as the current firmware 
is too old."
+                " Please update to firmware version v1.3.1 first."
+            )
+        if self == Warning.SDK_VERSION:
+            return (
+                "Your Nitrokey SDK version is outdated.  Please update this 
program to the latest"
+                " version and try again."
+            )
+        if self == Warning.UPDATE_FROM_BOOTLOADER:
+            return (
+                "The current state of the device cannot be checked as it is 
already in bootloader"
+                " mode. Please review the release notes at:"
+                " https://github.com/Nitrokey/nitrokey-3-firmware/releases";
+            )
+
+        if TYPE_CHECKING:
+            typing_extensions.assert_never(self)
+
+        return self.value
+
+    @classmethod
+    def from_str(cls, s: str) -> "Warning":
+        for w in cls:
+            if w.value == s:
+                return w
+        raise ValueError(f"Unexpected update warning id: {s}")
+
+
+@enum.unique
+class _Migration(enum.Enum):
+    # IFS migration to use journaling on the NRF52 introduced in v1.3.0 (NK3)
+    NRF_IFS_MIGRATION = enum.auto()
+    # IFS migration to filesystem layout 2 (FIDO2 RK migration) in v1.8.2 (NK3)
+    IFS_MIGRATION_V2 = enum.auto()
+
+    @classmethod
+    def get(
+        cls,
+        model: Model,
+        variant: Union[Variant, AdminAppVariant],
+        current: Optional[Version],
+        new: Version,
+    ) -> frozenset["_Migration"]:
+        if model != Model.NK3:
+            return frozenset()
+
+        if isinstance(variant, AdminAppVariant):
+            if variant == AdminAppVariant.USBIP:
+                raise ValueError("Cannot perform firmware update for USBIP 
runner")
+            elif variant == AdminAppVariant.LPC55:
+                variant = Variant.LPC55
+            elif variant == AdminAppVariant.NRF52:
+                variant = Variant.NRF52
+            else:
+                if TYPE_CHECKING:
+                    typing_extensions.assert_never(variant)
+
+                raise ValueError(f"Unsupported device variant: {variant}")
+
+        migrations = set()
+
+        if variant == Variant.NRF52:
+            if (
+                current is None
+                or current <= Version(1, 2, 2)
+                and new >= Version(1, 3, 0)
+            ):
+                migrations.add(cls.NRF_IFS_MIGRATION)
+
+        ifs_migration_v2 = Version(1, 8, 2)
+        if (
+            current is not None
+            and current < ifs_migration_v2
+            and new >= ifs_migration_v2
+        ):
+            migrations.add(cls.IFS_MIGRATION_V2)
+
+        return frozenset(migrations)
+
+
+def get_firmware_repository(model: Model) -> Repository:
+    data = get_model_data(model)
+    return Repository(owner="Nitrokey", name=data.firmware_repository_name)
+
+
+def get_firmware_update(model: Model, release: Release) -> Asset:
+    data = get_model_data(model)
+    return release.require_asset(re.compile(data.firmware_pattern_string))
+
+
+def _get_extra_information(migrations: Set[_Migration]) -> List[str]:
+    """Return additional information for the device after update based on 
update-path"""
+
+    out = []
+    if _Migration.NRF_IFS_MIGRATION in migrations:
+        out += [
+            "",
+            "During this update process the internal filesystem will be 
migrated!",
+            "- Migration will only work, if your internal filesystem does not 
contain more than 45 Resident Keys. If you have more please remove some.",
+            "- After the update it might take up to 3 minutes for the first 
boot.",
+            "Never unplug the device while the LED is active!",
+        ]
+    return out
+
+
+def _get_finalization_wait_retries(migrations: Set[_Migration]) -> int:
+    """Return number of retries to wait for the device after update based on 
update-path"""
+
+    out = 60
+    if _Migration.NRF_IFS_MIGRATION in migrations:
+        # max time 150secs == 300 retries
+        out = 500
+    return out
+
+
+class UpdateUi(ABC):
+    @abstractmethod
+    def error(self, *msgs: Any) -> Exception:
+        pass
+
+    @abstractmethod
+    def show_warning(self, warning: Warning) -> None:
+        pass
+
+    @abstractmethod
+    def raise_warning(self, warning: Warning) -> Exception:
+        pass
+
+    @abstractmethod
+    def abort(self, *msgs: Any) -> Exception:
+        pass
+
+    @abstractmethod
+    def abort_downgrade(self, current: Version, image: Version) -> Exception:
+        pass
+
+    @abstractmethod
+    def abort_pynitrokey_version(
+        self, current: Version, required: Version
+    ) -> Exception:
+        pass
+
+    @abstractmethod
+    def confirm_download(self, current: Optional[Version], new: Version) -> 
None:
+        pass
+
+    @abstractmethod
+    def confirm_update(self, current: Optional[Version], new: Version) -> None:
+        pass
+
+    @abstractmethod
+    def confirm_pynitrokey_version(self, current: Version, required: Version) 
-> None:
+        pass
+
+    @abstractmethod
+    def confirm_extra_information(self, extra_info: List[str]) -> None:
+        pass
+
+    @abstractmethod
+    def confirm_update_same_version(self, version: Version) -> None:
+        pass
+
+    @abstractmethod
+    def pre_bootloader_hint(self) -> None:
+        pass
+
+    @abstractmethod
+    def request_bootloader_confirmation(self) -> None:
+        pass
+
+    @abstractmethod
+    @contextmanager
+    def download_progress_bar(self, desc: str) -> Iterator[Callable[[int, 
int], None]]:
+        pass
+
+    @abstractmethod
+    @contextmanager
+    def update_progress_bar(self) -> Iterator[Callable[[int, int], None]]:
+        pass
+
+    @abstractmethod
+    @contextmanager
+    def finalization_progress_bar(self) -> Iterator[Callable[[int, int], 
None]]:
+        pass
+
+
+class DeviceHandler(ABC):
+    @abstractmethod
+    def await_bootloader(self, model: Model) -> TrussedBootloader: ...
+
+    @abstractmethod
+    def await_device(
+        self,
+        model: Model,
+        wait_retries: Optional[int],
+        callback: Optional[Callable[[int, int], None]],
+    ) -> TrussedDevice: ...
+
+
+class Updater:
+    def __init__(
+        self,
+        ui: UpdateUi,
+        device_handler: DeviceHandler,
+        ignore_warnings: Set[Warning] = frozenset(),
+    ) -> None:
+        self.ui = ui
+        self.device_handler = device_handler
+        self.ignore_warnings = ignore_warnings
+
+    def _trigger_warning(self, warning: Warning) -> None:
+        if warning in self.ignore_warnings:
+            self.ui.show_warning(warning)
+        else:
+            raise self.ui.raise_warning(warning)
+
+    def update(
+        self,
+        device: TrussedBase,
+        image: Optional[str],
+        update_version: Optional[str],
+        ignore_pynitrokey_version: bool = False,
+    ) -> Tuple[Version, Status]:
+        model = device.model
+
+        update_from_bootloader = False
+        current_version = None
+        status = None
+        if isinstance(device, TrussedBootloader):
+            update_from_bootloader = True
+            self._trigger_warning(Warning.UPDATE_FROM_BOOTLOADER)
+        elif isinstance(device, TrussedDevice):
+            current_version = device.admin.version()
+            status = device.admin.status()
+        else:
+            raise self.ui.error(f"Unexpected Trussed device: {device}")
+
+        logger.info(f"Firmware version before update: {current_version or ''}")
+        container = self._prepare_update(model, image, update_version, 
current_version)
+
+        if not update_from_bootloader:
+            if status is None:
+                if model == Model.NK3:
+                    if container.version > Version.from_str("1.3.1"):
+                        self._trigger_warning(Warning.MISSING_STATUS)
+                else:
+                    self.ui.error(f"Missing status for {model} device")
+
+        self._check_minimum_version(container, ignore_pynitrokey_version)
+
+        self.ui.confirm_update(current_version, container.version)
+
+        migrations = None
+        if status is not None and status.variant is not None:
+            migrations = self._check_migrations(
+                model, status.variant, current_version, container.version, 
status
+            )
+        elif isinstance(device, TrussedBootloader):
+            migrations = self._check_migrations(
+                model, device.variant, current_version, container.version, 
status
+            )
+
+        with self._get_bootloader(device) as bootloader:
+            if bootloader.variant not in container.images:
+                raise self.ui.error(
+                    "The firmware release does not contain an image for the "
+                    f"{bootloader.variant.value} hardware variant"
+                )
+            try:
+                validate_firmware_image(
+                    bootloader.variant,
+                    container.images[bootloader.variant],
+                    container.version,
+                    model,
+                )
+            except Exception as e:
+                raise self.ui.error("Failed to validate firmware image", e)
+
+            if migrations is None:
+                migrations = self._check_migrations(
+                    model,
+                    bootloader.variant,
+                    current_version,
+                    container.version,
+                    status,
+                )
+
+            self._perform_update(bootloader, container)
+
+        wait_retries = _get_finalization_wait_retries(migrations)
+        with self.ui.finalization_progress_bar() as callback:
+            with self.device_handler.await_device(
+                model, wait_retries, callback
+            ) as device:
+                version = device.admin.version()
+                if version != container.version:
+                    raise self.ui.error(
+                        f"The firmware update to {container.version} was 
successful, but the "
+                        f"firmware is still reporting version {version}."
+                    )
+                status = device.admin.status()
+
+        return container.version, status
+
+    def _prepare_update(
+        self,
+        model: Model,
+        image: Optional[str],
+        version: Optional[str],
+        current_version: Optional[Version],
+    ) -> FirmwareContainer:
+        if image:
+            try:
+                container = FirmwareContainer.parse(image, model)
+            except Exception as e:
+                raise self.ui.error("Failed to parse firmware container", e)
+            self._validate_version(current_version, container.version)
+            return container
+        else:
+            repository = get_firmware_repository(model)
+            if version:
+                try:
+                    logger.info(f"Downloading firmare version {version}")
+                    release = repository.get_release(version)
+                except Exception as e:
+                    raise self.ui.error(f"Failed to get firmware release 
{version}", e)
+            else:
+                try:
+                    release = repository.get_latest_release()
+                    logger.info(f"Latest firmware version: {release}")
+                except Exception as e:
+                    raise self.ui.error("Failed to find latest firmware 
release", e)
+
+            try:
+                release_version = Version.from_v_str(release.tag)
+            except ValueError as e:
+                raise self.ui.error("Failed to parse version from release 
tag", e)
+            self._validate_version(current_version, release_version)
+            self.ui.confirm_download(current_version, release_version)
+            return self._download_update(model, release)
+
+    def _download_update(self, model: Model, release: Release) -> 
FirmwareContainer:
+        try:
+            update = get_firmware_update(model, release)
+        except Exception as e:
+            raise self.ui.error(
+                f"Failed to find firmware image for release {release}",
+                e,
+            )
+
+        try:
+            logger.info(f"Trying to download firmware update from URL: 
{update.url}")
+
+            with self.ui.download_progress_bar(update.tag) as callback:
+                data = update.read(callback=callback)
+        except Exception as e:
+            raise self.ui.error(
+                f"Failed to download latest firmware update {update.tag}", e
+            )
+
+        try:
+            container = FirmwareContainer.parse(BytesIO(data), model)
+        except Exception as e:
+            raise self.ui.error(
+                f"Failed to parse firmware container for {update.tag}", e
+            )
+
+        release_version = Version.from_v_str(release.tag)
+        if release_version != container.version:
+            raise self.ui.error(
+                f"The firmware container for {update.tag} has the version 
{container.version}"
+            )
+
+        return container
+
+    def _check_minimum_version(
+        self, container: FirmwareContainer, ignore_pynitrokey_version: bool
+    ) -> None:
+        if container.sdk:
+            try:
+                sdk_version = 
Version.from_str(importlib.metadata.version("nitrokey"))
+            except PackageNotFoundError:
+                raise self.ui.error("Failed to determine the Nitrokey SDK 
version")
+
+            if container.sdk > sdk_version:
+                logger.warning(
+                    f"Minimum SDK version required for update is 
{container.sdk} (current version: {sdk_version})"
+                )
+                self._trigger_warning(Warning.SDK_VERSION)
+        elif container.pynitrokey:
+            # The minimum pynitrokey version has been replaced by the minimum 
SDK version, so we
+            # only check it if there is no minimum SDK version set.
+
+            # this is the version of pynitrokey when we moved to the SDK
+            pynitrokey_version = Version.from_str("0.4.49")
+            if container.pynitrokey > pynitrokey_version:
+                if ignore_pynitrokey_version:
+                    self.ui.confirm_pynitrokey_version(
+                        current=pynitrokey_version, 
required=container.pynitrokey
+                    )
+                else:
+                    raise self.ui.abort_pynitrokey_version(
+                        current=pynitrokey_version, 
required=container.pynitrokey
+                    )
+
+    def _validate_version(
+        self,
+        current_version: Optional[Version],
+        new_version: Version,
+    ) -> None:
+        logger.info(f"Current firmware version: {current_version}")
+        logger.info(f"Updated firmware version: {new_version}")
+
+        if current_version:
+            if current_version.core() > new_version.core():
+                raise self.ui.abort_downgrade(current_version, new_version)
+            elif current_version == new_version:
+                if current_version.complete and new_version.complete:
+                    same_version = current_version
+                else:
+                    same_version = current_version.core()
+                self.ui.confirm_update_same_version(same_version)
+
+    @contextmanager
+    def _get_bootloader(self, device: TrussedBase) -> 
Iterator[TrussedBootloader]:
+        model = device.model
+        if isinstance(device, TrussedDevice):
+            self.ui.request_bootloader_confirmation()
+            try:
+                device.admin.reboot(BootMode.BOOTROM)
+            except TimeoutException:
+                raise self.ui.abort(
+                    "The reboot was not confirmed with the touch button"
+                )
+
+            # needed for udev to properly handle new device
+            time.sleep(1)
+
+            self.ui.pre_bootloader_hint()
+
+            exc = None
+            for t in Retries(3):
+                logger.debug(f"Trying to connect to bootloader ({t})")
+                try:
+                    with self.device_handler.await_bootloader(model) as 
bootloader:
+                        # noop to test communication
+                        bootloader.uuid
+                        yield bootloader
+                        break
+                except McuBootConnectionError as e:
+                    logger.debug("Received connection error", exc_info=True)
+                    exc = e
+            else:
+                msgs = [f"Failed to connect to {model} bootloader"]
+                if platform.system() == "Linux":
+                    msgs += ["Are the Nitrokey udev rules installed and 
active?"]
+                raise self.ui.error(*msgs, exc)
+        elif isinstance(device, TrussedBootloader):
+            yield device
+        else:
+            raise self.ui.error(f"Unexpected {model} device: {device}")
+
+    def _check_migrations(
+        self,
+        model: Model,
+        variant: Union[Variant, AdminAppVariant],
+        current_version: Optional[Version],
+        new_version: Version,
+        status: Optional[Status],
+    ) -> frozenset["_Migration"]:
+        try:
+            migrations = _Migration.get(
+                model=model,
+                variant=variant,
+                current=current_version,
+                new=new_version,
+            )
+        except ValueError as e:
+            raise self.ui.error(str(e))
+
+        txt = _get_extra_information(migrations)
+        self.ui.confirm_extra_information(txt)
+
+        if _Migration.IFS_MIGRATION_V2 in migrations:
+            if status and status.ifs_blocks is not None and status.ifs_blocks 
< 5:
+                self._trigger_warning(Warning.IFS_MIGRATION_V2)
+
+        return migrations
+
+    def _perform_update(
+        self, device: TrussedBootloader, container: FirmwareContainer
+    ) -> None:
+        logger.debug("Starting firmware update")
+        image = container.images[device.variant]
+        with self.ui.update_progress_bar() as callback:
+            try:
+                device.update(image, callback=callback)
+            except Exception as e:
+                raise self.ui.error("Failed to perform firmware update", e)
+        logger.debug("Firmware update finished successfully")

Reply via email to