diff --git a/supervisor/jobs/__init__.py b/supervisor/jobs/__init__.py index d47f27002..f7bd8b112 100644 --- a/supervisor/jobs/__init__.py +++ b/supervisor/jobs/__init__.py @@ -97,7 +97,6 @@ class SupervisorJob: default=0, validator=[ge(0), le(100), _invalid_if_done], on_setattr=_on_change, - converter=lambda val: round(val, 1), ) stage: str | None = field( default=None, validator=[_invalid_if_done], on_setattr=_on_change @@ -118,7 +117,7 @@ class SupervisorJob: "name": self.name, "reference": self.reference, "uuid": self.uuid, - "progress": self.progress, + "progress": round(self.progress, 1), "stage": self.stage, "done": self.done, "parent_id": self.parent_id, diff --git a/tests/docker/test_interface.py b/tests/docker/test_interface.py index f588d3afe..5a8d4efca 100644 --- a/tests/docker/test_interface.py +++ b/tests/docker/test_interface.py @@ -600,6 +600,136 @@ async def test_install_sends_progress_to_home_assistant( ] +async def test_install_progress_rounding_does_not_cause_misses( + coresys: CoreSys, test_docker_interface: DockerInterface, ha_ws_client: AsyncMock +): + """Test extremely close progress events do not create rounding issues.""" + coresys.core.set_state(CoreState.RUNNING) + coresys.docker.docker.api.pull.return_value = [ + { + "status": "Pulling from home-assistant/odroid-n2-homeassistant", + "id": "2025.7.1", + }, + {"status": "Pulling fs layer", "progressDetail": {}, "id": "1e214cd6d7d0"}, + { + "status": "Downloading", + "progressDetail": {"current": 432700000, "total": 436480882}, + "progress": "[=================================================> ] 432.7MB/436.5MB", + "id": "1e214cd6d7d0", + }, + { + "status": "Downloading", + "progressDetail": {"current": 432800000, "total": 436480882}, + "progress": "[=================================================> ] 432.8MB/436.5MB", + "id": "1e214cd6d7d0", + }, + {"status": "Verifying Checksum", "progressDetail": {}, "id": "1e214cd6d7d0"}, + {"status": "Download complete", "progressDetail": {}, "id": "1e214cd6d7d0"}, + { + "status": "Extracting", + "progressDetail": {"current": 432700000, "total": 436480882}, + "progress": "[=================================================> ] 432.7MB/436.5MB", + "id": "1e214cd6d7d0", + }, + { + "status": "Extracting", + "progressDetail": {"current": 432800000, "total": 436480882}, + "progress": "[=================================================> ] 432.8MB/436.5MB", + "id": "1e214cd6d7d0", + }, + {"status": "Pull complete", "progressDetail": {}, "id": "1e214cd6d7d0"}, + { + "status": "Digest: sha256:7d97da645f232f82a768d0a537e452536719d56d484d419836e53dbe3e4ec736" + }, + { + "status": "Status: Downloaded newer image for ghcr.io/home-assistant/odroid-n2-homeassistant:2025.7.1" + }, + ] + + with ( + patch.object( + type(coresys.supervisor), "arch", PropertyMock(return_value="i386") + ), + ): + # Schedule job so we can listen for the end. Then we can assert against the WS mock + event = asyncio.Event() + job, install_task = coresys.jobs.schedule_job( + test_docker_interface.install, + JobSchedulerOptions(), + AwesomeVersion("1.2.3"), + "test", + ) + + async def listen_for_job_end(reference: SupervisorJob): + if reference.uuid != job.uuid: + return + event.set() + + coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_END, listen_for_job_end) + await install_task + await event.wait() + + events = [ + evt.args[0]["data"]["data"] + for evt in ha_ws_client.async_send_command.call_args_list + if "data" in evt.args[0] + and evt.args[0]["data"]["event"] == WSEvent.JOB + and evt.args[0]["data"]["data"]["reference"] == "1e214cd6d7d0" + and evt.args[0]["data"]["data"]["stage"] in {"Downloading", "Extracting"} + ] + + assert events == [ + { + "name": "Pulling container image layer", + "stage": "Downloading", + "progress": 49.6, + "done": False, + "extra": {"current": 432700000, "total": 436480882}, + "reference": "1e214cd6d7d0", + "parent_id": job.uuid, + "errors": [], + "uuid": ANY, + "created": ANY, + }, + { + "name": "Pulling container image layer", + "stage": "Downloading", + "progress": 49.6, + "done": False, + "extra": {"current": 432800000, "total": 436480882}, + "reference": "1e214cd6d7d0", + "parent_id": job.uuid, + "errors": [], + "uuid": ANY, + "created": ANY, + }, + { + "name": "Pulling container image layer", + "stage": "Extracting", + "progress": 99.6, + "done": False, + "extra": {"current": 432700000, "total": 436480882}, + "reference": "1e214cd6d7d0", + "parent_id": job.uuid, + "errors": [], + "uuid": ANY, + "created": ANY, + }, + { + "name": "Pulling container image layer", + "stage": "Extracting", + "progress": 99.6, + "done": False, + "extra": {"current": 432800000, "total": 436480882}, + "reference": "1e214cd6d7d0", + "parent_id": job.uuid, + "errors": [], + "uuid": ANY, + "created": ANY, + }, + ] + + @pytest.mark.parametrize( ("error_log", "exc_type", "exc_msg"), [