Procházet zdrojové kódy

feat(vp): mirror live print progress to the slicer (#1887)

A server-mode VP with a target printer bound showed the print as a bare
filename in Bambu Studio / OrcaSlicer -- no stage, percentage, layer count or
time remaining. The data was already in the bridge cache; we were overwriting
it with zeros, because passing it through made the slicer read the VP as busy
and hide the Send button (#1558).

Both slicers gate the progress panel and the Send button on one predicate,
MachineObject::is_in_printing() -- gcode_state in RUNNING/PAUSE/SLICING/PREPARE
-- so there is no field-level way to have both. FINISH is the one state in the
gap: StatusPanel::update_subtask() renders the panel for it, and
SelectMachineDialog::update_show_status() does not disable Send. The VP already
parks at FINISH after each upload (#1280 / #1658), so it only needed the real
numbers underneath it.

While the target prints and no upload is in flight, the report now holds
gcode_state=FINISH and passes mc_print_stage, mc_percent, mc_remaining_time,
stg, stg_cur, layer_num and total_layer_num through from the cache. Mirroring
is suppressed during PREPARE and for 5s after the last upload transition, so
the slicer still receives the FINISH carrying its own subtask_name and releases
its send modal. print_error is never mirrored -- it would raise a modal error
dialog for a fault the VP did not throw.
maziggy před 1 měsícem
rodič
revize
f2e113ce20

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
CHANGELOG.md


+ 101 - 26
backend/app/services/virtual_printer/mqtt_server.py

@@ -11,6 +11,7 @@ import json
 import logging
 import logging
 import socket
 import socket
 import ssl
 import ssl
+import time
 from collections.abc import Callable
 from collections.abc import Callable
 from pathlib import Path
 from pathlib import Path
 from typing import TYPE_CHECKING
 from typing import TYPE_CHECKING
@@ -41,6 +42,27 @@ _AUTH_RATE_LIMIT_WINDOW_SECONDS = 60.0
 # commands without ever consuming responses can't leak memory.
 # commands without ever consuming responses can't leak memory.
 _PENDING_REQUEST_MAX_ENTRIES = 256
 _PENDING_REQUEST_MAX_ENTRIES = 256
 
 
+# Target-printer gcode_states for which the VP mirrors live print progress to
+# the slicer (#1887). BambuStudio and OrcaSlicer gate BOTH the Device-tab
+# progress panel and the Send button on the same predicate —
+# `MachineObject::is_in_printing()`, i.e. gcode_state in
+# {RUNNING, PAUSE, SLICING, PREPARE} — so reporting the real state verbatim
+# would show progress at the cost of blocking Send for as long as the printer
+# prints. That is exactly the #1558 regression. `FINISH` is the one state that
+# renders the progress panel (`is_in_printing() || print_status == "FINISH"` in
+# StatusPanel::update_subtask) while leaving Send enabled, so the mirror keeps
+# reporting FINISH and only fills in the numbers underneath it.
+_MIRRORED_PRINT_STATES = frozenset({"RUNNING", "PAUSE"})
+
+# How long after the last upload-state transition the VP keeps echoing the
+# slicer's own filename back at it before switching the report over to whatever
+# the target printer is really printing. The slicer releases its in-flight-job
+# lock when it sees gcode_state=FINISH carrying the subtask_name it uploaded
+# (#1280 / #1658); swapping in the printer's filename while that handshake is
+# still in flight wedges the send modal at "Downloading". 5 s covers the 1.5 s
+# `_schedule_finish_release` timer plus several 1 Hz pushes.
+_UPLOAD_SETTLE_SECONDS = 5.0
+
 # Model code → product_name for version response (must match what slicer expects)
 # Model code → product_name for version response (must match what slicer expects)
 MODEL_PRODUCT_NAMES = {
 MODEL_PRODUCT_NAMES = {
     "BL-P001": "X1 Carbon",
     "BL-P001": "X1 Carbon",
@@ -245,6 +267,11 @@ class SimpleMQTTServer:
         self._gcode_state = "IDLE"
         self._gcode_state = "IDLE"
         self._current_file = ""
         self._current_file = ""
         self._prepare_percent = "0"
         self._prepare_percent = "0"
+        # Monotonic timestamp of the last upload-state transition, so the live-
+        # progress mirror can tell whether the slicer is still waiting on its
+        # own upload handshake. Starts at -inf: a VP that has never seen an
+        # upload has no handshake to protect and can mirror immediately.
+        self._state_changed_at = float("-inf")
 
 
         # MQTT bridge for non-proxy modes — set by VirtualPrinterInstance after start().
         # MQTT bridge for non-proxy modes — set by VirtualPrinterInstance after start().
         # When the bridge is_active, real printer pushes are fanned out to slicers and
         # When the bridge is_active, real printer pushes are fanned out to slicers and
@@ -895,6 +922,11 @@ class SimpleMQTTServer:
         push_status shape against what it expects from the printer model, and
         push_status shape against what it expects from the printer model, and
         the synthetic stub introduced fields the real H2D doesn't have (storage,
         the synthetic stub introduced fields the real H2D doesn't have (storage,
         the wrong chamber_temper shape, etc.) which trip the check.
         the wrong chamber_temper shape, etc.) which trip the check.
+
+        While the target printer is actually printing and the VP has no upload
+        handshake of its own in flight, the live-progress fields are mirrored
+        through instead of zeroed, under a forced gcode_state=FINISH — see
+        ``_mirroring_live_progress`` for why that specific state (#1887).
         """
         """
         try:
         try:
             self._sequence_id += 1
             self._sequence_id += 1
@@ -913,14 +945,23 @@ class SimpleMQTTServer:
                 print_block["sequence_id"] = str(self._sequence_id)
                 print_block["sequence_id"] = str(self._sequence_id)
                 print_block["command"] = "push_status"
                 print_block["command"] = "push_status"
                 print_block["msg"] = 0
                 print_block["msg"] = 0
-                print_block["gcode_state"] = self._gcode_state
-                print_block["gcode_file"] = self._current_file
-                print_block["gcode_file_prepare_percent"] = self._prepare_percent
-                if self._current_file:
-                    print_block["subtask_name"] = self._current_file.replace(".3mf", "")
-                else:
-                    # Don't override real subtask_name with empty if no upload pending.
+                mirroring = self._mirroring_live_progress(cached)
+                if mirroring:
+                    # gcode_file / subtask_name / the progress fields stay as the
+                    # printer reported them — the slicer renders what is really
+                    # on the bed. FINISH keeps the Send button enabled.
+                    print_block["gcode_state"] = "FINISH"
+                    print_block["gcode_file_prepare_percent"] = "100"
                     print_block.setdefault("subtask_name", "")
                     print_block.setdefault("subtask_name", "")
+                else:
+                    print_block["gcode_state"] = self._gcode_state
+                    print_block["gcode_file"] = self._current_file
+                    print_block["gcode_file_prepare_percent"] = self._prepare_percent
+                    if self._current_file:
+                        print_block["subtask_name"] = self._current_file.replace(".3mf", "")
+                    else:
+                        # Don't override real subtask_name with empty if no upload pending.
+                        print_block.setdefault("subtask_name", "")
                 # Storage-availability indicators the slicer's "Send" pre-flight reads
                 # Storage-availability indicators the slicer's "Send" pre-flight reads
                 # (#1228). P1S/A1-class firmware doesn't always include these in
                 # (#1228). P1S/A1-class firmware doesn't always include these in
                 # push_status (no SD card inserted, older field shapes), and BambuStudio
                 # push_status (no SD card inserted, older field shapes), and BambuStudio
@@ -936,21 +977,25 @@ class SimpleMQTTServer:
                 print_block["sdcard"] = True
                 print_block["sdcard"] = True
                 print_block.setdefault("storage", {"free": 1_000_000_000, "total": 32_000_000_000})
                 print_block.setdefault("storage", {"free": 1_000_000_000, "total": 32_000_000_000})
                 # Live-progress fields the slicer's Send pre-flight reads
                 # Live-progress fields the slicer's Send pre-flight reads
-                # (#1558). When the real target printer is mid-print, the
-                # cached push_status carries the real values for these
-                # fields and the slicer reads the VP as "busy" — refusing
-                # Send — even though gcode_state above is forced to IDLE.
-                # For VP usage the VP isn't actually running the print
-                # the printer is, so these need to mirror the synthetic
-                # stub's idle values. Same shape as #1228 (storage) — the
-                # cached-branch override set just needed extending.
-                print_block["mc_print_stage"] = ""
-                print_block["mc_percent"] = 0
-                print_block["mc_remaining_time"] = 0
-                print_block["stg"] = []
-                print_block["stg_cur"] = 0
-                print_block["layer_num"] = 0
-                print_block["total_layer_num"] = 0
+                # (#1558). When the VP reports itself idle, these have to read
+                # idle too: the cached push_status carries the printer's real
+                # values, and a report that says gcode_state=IDLE while
+                # mc_percent>0 / stg_cur>0 is internally contradictory — the
+                # slicer takes it as busy and blocks Send. Same shape as #1228
+                # (storage). When the mirror is on, gcode_state=FINISH agrees
+                # with a non-zero progress set, so they pass through instead.
+                if not mirroring:
+                    print_block["mc_print_stage"] = ""
+                    print_block["mc_percent"] = 0
+                    print_block["mc_remaining_time"] = 0
+                    print_block["stg"] = []
+                    print_block["stg_cur"] = 0
+                    print_block["layer_num"] = 0
+                    print_block["total_layer_num"] = 0
+                # print_error is never mirrored: StatusPanel raises a modal error
+                # dialog for a non-zero code, and the VP is not the machine that
+                # threw it — the user's own printer card in Bambuddy reports the
+                # fault. Zero it in both branches.
                 print_block["print_error"] = 0
                 print_block["print_error"] = 0
                 status = {"print": print_block}
                 status = {"print": print_block}
                 dump_wire(self.vp_name, "out", status)
                 dump_wire(self.vp_name, "out", status)
@@ -1126,6 +1171,36 @@ class SimpleMQTTServer:
         self._gcode_state = state
         self._gcode_state = state
         self._current_file = filename
         self._current_file = filename
         self._prepare_percent = prepare_percent
         self._prepare_percent = prepare_percent
+        self._state_changed_at = time.monotonic()
+
+    def _mirroring_live_progress(self, cached: dict) -> bool:
+        """True when the report should carry the target printer's live progress.
+
+        The slicers gate the Device-tab progress panel and the Send button on
+        the same predicate (``MachineObject::is_in_printing()``), so the VP
+        cannot report the printer's real gcode_state without also telling the
+        slicer it is too busy to accept a job — which is the whole point of a
+        non-proxy VP, and was the #1558 regression. Reporting FINISH instead
+        renders the panel (StatusPanel checks ``is_in_printing() ||
+        print_status == "FINISH"``) and leaves Send enabled, so the mirror is
+        FINISH plus the printer's real numbers.
+
+        Two things suppress it:
+
+        * The VP's own upload state machine owns the report while a job is
+          being handed over (PREPARE), and for a short settle window after —
+          the slicer only releases its in-flight-job lock once it sees FINISH
+          carrying the ``subtask_name`` it just uploaded (#1280 / #1658), and
+          swapping in the printer's filename mid-handshake wedges the send
+          modal at "Downloading".
+        * The printer isn't printing, in which case there is no progress to
+          show and the VP's own state is the honest thing to report.
+        """
+        if self._gcode_state == "PREPARE":
+            return False
+        if time.monotonic() - self._state_changed_at < _UPLOAD_SETTLE_SECONDS:
+            return False
+        return str(cached.get("gcode_state") or "").upper() in _MIRRORED_PRINT_STATES
 
 
     async def _publish_to_report(
     async def _publish_to_report(
         self, writer: asyncio.StreamWriter, payload: dict, serial: str = "", log_event: bool = True
         self, writer: asyncio.StreamWriter, payload: dict, serial: str = "", log_event: bool = True
@@ -1207,10 +1282,10 @@ class SimpleMQTTServer:
         self, writer: asyncio.StreamWriter, sequence_id: str, filename: str, serial: str | None = None
         self, writer: asyncio.StreamWriter, sequence_id: str, filename: str, serial: str | None = None
     ) -> None:
     ) -> None:
         """Send project_file acknowledgment matching real Bambu printer behavior."""
         """Send project_file acknowledgment matching real Bambu printer behavior."""
-        # Update state so periodic status pushes reflect preparation
-        self._gcode_state = "PREPARE"
-        self._current_file = filename
-        self._prepare_percent = "0"
+        # Update state so periodic status pushes reflect preparation. Goes
+        # through set_gcode_state so the live-progress mirror sees the
+        # transition and holds off until the upload handshake has settled.
+        self.set_gcode_state("PREPARE", filename=filename, prepare_percent="0")
 
 
         try:
         try:
             # Send command acknowledgment — slicer expects to see
             # Send command acknowledgment — slicer expects to see

+ 179 - 38
backend/tests/unit/test_vp_mqtt_bridge.py

@@ -4,6 +4,7 @@ import asyncio
 import json
 import json
 import logging
 import logging
 import socket
 import socket
+import time
 from pathlib import Path
 from pathlib import Path
 from unittest.mock import AsyncMock, MagicMock, patch
 from unittest.mock import AsyncMock, MagicMock, patch
 
 
@@ -15,7 +16,10 @@ from backend.app.services.virtual_printer.mqtt_bridge import (
     _resolve_host_interface_for_target,
     _resolve_host_interface_for_target,
     _resolve_target_to_ipv4,
     _resolve_target_to_ipv4,
 )
 )
-from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
+from backend.app.services.virtual_printer.mqtt_server import (
+    _UPLOAD_SETTLE_SECONDS,
+    SimpleMQTTServer,
+)
 
 
 H2D_SERIAL = "0948BB540200427"
 H2D_SERIAL = "0948BB540200427"
 VP_SERIAL = "09400A391800003"
 VP_SERIAL = "09400A391800003"
@@ -1093,18 +1097,22 @@ class TestForwardToPrinter:
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
 
 
 
 
+def _capture_published(server: SimpleMQTTServer) -> list:
+    """Wrap _publish_to_report to capture (topic, payload_dict)."""
+    published: list = []
+
+    async def _capture(writer, payload, serial="", log_event=True):
+        published.append((serial or server.serial, payload))
+
+    server._publish_to_report = _capture  # type: ignore[assignment]
+    return published
+
+
 class TestStatusReportCachedAsBase:
 class TestStatusReportCachedAsBase:
     """`_send_status_report` sends near-byte-identical real data when bridge cache exists."""
     """`_send_status_report` sends near-byte-identical real data when bridge cache exists."""
 
 
     def _capture_published(self, server: SimpleMQTTServer):
     def _capture_published(self, server: SimpleMQTTServer):
-        """Wrap _publish_to_report to capture (topic, payload_dict)."""
-        published: list = []
-
-        async def _capture(writer, payload, serial="", log_event=True):
-            published.append((serial or server.serial, payload))
-
-        server._publish_to_report = _capture  # type: ignore[assignment]
-        return published
+        return _capture_published(server)
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_uses_real_cache_when_bridge_active(self):
     async def test_uses_real_cache_when_bridge_active(self):
@@ -1227,47 +1235,180 @@ class TestStatusReportCachedAsBase:
         assert payload["print"]["gcode_state"] == "PREPARE"
         assert payload["print"]["gcode_state"] == "PREPARE"
         assert payload["print"]["gcode_file"] == "foo.3mf"
         assert payload["print"]["gcode_file"] == "foo.3mf"
 
 
+
+# ---------------------------------------------------------------------------
+# Live print progress (#1887 / #1558)
+# ---------------------------------------------------------------------------
+
+
+def _printing_cache(**overrides) -> dict:
+    """Bridge cache for a target printer that is mid-print."""
+    cache = {
+        "command": "push_status",
+        "msg": 0,
+        "gcode_state": "RUNNING",
+        "gcode_file": "Metadata/plate_1.gcode",
+        "subtask_name": "benchy",
+        "mc_print_stage": "2",
+        "mc_percent": 47,
+        "mc_remaining_time": 3600,
+        "stg": [1, 2, 3],
+        "stg_cur": 14,
+        "layer_num": 120,
+        "total_layer_num": 250,
+        "print_error": 0,
+    }
+    cache.update(overrides)
+    return cache
+
+
+class TestLiveProgressMirror:
+    """The VP mirrors the target printer's progress without ever looking busy.
+
+    Both slicers gate the Device-tab progress panel and the Send button on the
+    same predicate — `MachineObject::is_in_printing()`, i.e. gcode_state in
+    {RUNNING, PAUSE, SLICING, PREPARE}. Reporting the printer's real state
+    shows progress but blocks Send for as long as it prints (#1558); zeroing
+    everything keeps Send alive but shows nothing (#1887). FINISH is the one
+    state that does both: StatusPanel renders on `is_in_printing() ||
+    print_status == "FINISH"`, while SelectMachineDialog only blocks on
+    `is_in_printing()`.
+    """
+
+    @pytest.mark.asyncio
+    async def test_progress_mirrored_while_target_prints(self):
+        """#1887: the numbers the slicer needs come straight from the cache."""
+        server = _make_server()
+        bridge = MagicMock()
+        bridge.get_latest_print_state.return_value = _printing_cache()
+        server.set_bridge(bridge)
+        published = _capture_published(server)
+
+        await server._send_status_report(MagicMock())
+        _serial, payload = published[0]
+        assert payload["print"]["mc_print_stage"] == "2"
+        assert payload["print"]["mc_percent"] == 47
+        assert payload["print"]["mc_remaining_time"] == 3600
+        assert payload["print"]["stg"] == [1, 2, 3]
+        assert payload["print"]["stg_cur"] == 14
+        assert payload["print"]["layer_num"] == 120
+        assert payload["print"]["total_layer_num"] == 250
+        # The job the printer is really running, not the VP's last upload.
+        assert payload["print"]["subtask_name"] == "benchy"
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_live_progress_fields_zeroed_in_cached_branch(self):
-        """#1558: when the real target printer is mid-print, the cached
-        push_status carries live values for mc_percent / stg_cur / layer_num /
-        etc. BambuStudio's Send pre-flight reads any of these as "VP busy"
-        even when gcode_state above is forced to IDLE — blocking Send while
-        the target prints. The cached branch must override these to the same
-        idle values the synthetic stub uses.
+    @pytest.mark.parametrize("target_state", ["RUNNING", "PAUSE"])
+    async def test_mirror_never_reports_a_printing_gcode_state(self, target_state):
+        """#1558 guard: any state in `is_in_printing()` disables the Send button.
+
+        This is the assertion that keeps the mirror honest — it may show the
+        printer's numbers, but it must never claim the VP itself is printing.
         """
         """
         server = _make_server()
         server = _make_server()
         bridge = MagicMock()
         bridge = MagicMock()
-        # Real printer mid-print state: gcode_state may be RUNNING upstream,
-        # but the VP's own _gcode_state is IDLE (Send is requesting a
-        # new upload, the VP isn't running anything).
-        bridge.get_latest_print_state.return_value = {
-            "command": "push_status",
-            "msg": 0,
-            "gcode_state": "RUNNING",
-            "mc_print_stage": "2",
-            "mc_percent": 47,
-            "mc_remaining_time": 3600,
-            "stg": [1, 2, 3],
-            "stg_cur": 14,
-            "layer_num": 120,
-            "total_layer_num": 250,
-            "print_error": 0,
-        }
+        bridge.get_latest_print_state.return_value = _printing_cache(gcode_state=target_state)
         server.set_bridge(bridge)
         server.set_bridge(bridge)
-        published = self._capture_published(server)
+        published = _capture_published(server)
+
+        await server._send_status_report(MagicMock())
+        _serial, payload = published[0]
+        assert payload["print"]["gcode_state"] == "FINISH"
+        assert payload["print"]["gcode_state"] not in ("RUNNING", "PAUSE", "SLICING", "PREPARE")
+
+    @pytest.mark.asyncio
+    async def test_progress_zeroed_while_target_idle(self):
+        """Nothing to mirror — the VP's own upload state owns the report."""
+        server = _make_server()
+        server.set_gcode_state("FINISH", filename="foo.3mf", prepare_percent="100")
+        server._state_changed_at = time.monotonic() - 60  # settled long ago
+        bridge = MagicMock()
+        bridge.get_latest_print_state.return_value = _printing_cache(gcode_state="IDLE", mc_percent=0, layer_num=0)
+        server.set_bridge(bridge)
+        published = _capture_published(server)
 
 
         await server._send_status_report(MagicMock())
         await server._send_status_report(MagicMock())
         _serial, payload = published[0]
         _serial, payload = published[0]
-        # Every live-progress field must reflect "idle / VP isn't busy".
+        assert payload["print"]["gcode_state"] == "FINISH"  # the VP's own, not mirrored
+        assert payload["print"]["subtask_name"] == "foo"
         assert payload["print"]["mc_print_stage"] == ""
         assert payload["print"]["mc_print_stage"] == ""
-        assert payload["print"]["mc_percent"] == 0
-        assert payload["print"]["mc_remaining_time"] == 0
         assert payload["print"]["stg"] == []
         assert payload["print"]["stg"] == []
-        assert payload["print"]["stg_cur"] == 0
-        assert payload["print"]["layer_num"] == 0
         assert payload["print"]["total_layer_num"] == 0
         assert payload["print"]["total_layer_num"] == 0
+
+    @pytest.mark.asyncio
+    async def test_progress_zeroed_while_upload_in_flight(self):
+        """A job being handed over outranks the mirror.
+
+        The slicer is watching its own PREPARE → FINISH cycle here; feeding it
+        the printer's progress mid-handshake would contradict the PREPARE it is
+        waiting on.
+        """
+        server = _make_server()
+        server.set_gcode_state("PREPARE", filename="bar.3mf", prepare_percent="0")
+        bridge = MagicMock()
+        bridge.get_latest_print_state.return_value = _printing_cache()
+        server.set_bridge(bridge)
+        published = _capture_published(server)
+
+        await server._send_status_report(MagicMock())
+        _serial, payload = published[0]
+        assert payload["print"]["gcode_state"] == "PREPARE"
+        assert payload["print"]["gcode_file"] == "bar.3mf"
+        assert payload["print"]["subtask_name"] == "bar"
+        assert payload["print"]["mc_percent"] == 0
+        assert payload["print"]["layer_num"] == 0
+
+    @pytest.mark.asyncio
+    async def test_upload_settle_window_keeps_the_slicers_own_filename(self):
+        """#1658: the send modal releases on FINISH carrying the name it uploaded.
+
+        Swapping in the printer's filename while that handshake is still in
+        flight wedges the slicer at "Downloading", so the mirror waits.
+        """
+        server = _make_server()
+        bridge = MagicMock()
+        bridge.get_latest_print_state.return_value = _printing_cache()
+        server.set_bridge(bridge)
+        published = _capture_published(server)
+
+        server.set_gcode_state("FINISH", filename="bar.3mf", prepare_percent="100")
+        await server._send_status_report(MagicMock())
+        _serial, payload = published[0]
+        assert payload["print"]["subtask_name"] == "bar"
+        assert payload["print"]["mc_percent"] == 0
+
+    @pytest.mark.asyncio
+    async def test_mirror_resumes_once_the_upload_has_settled(self):
+        """Same VP as above, once the slicer has had its FINISH."""
+        server = _make_server()
+        bridge = MagicMock()
+        bridge.get_latest_print_state.return_value = _printing_cache()
+        server.set_bridge(bridge)
+        published = _capture_published(server)
+
+        server.set_gcode_state("FINISH", filename="bar.3mf", prepare_percent="100")
+        server._state_changed_at = time.monotonic() - _UPLOAD_SETTLE_SECONDS - 1
+        await server._send_status_report(MagicMock())
+        _serial, payload = published[0]
+        assert payload["print"]["subtask_name"] == "benchy"
+        assert payload["print"]["mc_percent"] == 47
+
+    @pytest.mark.asyncio
+    async def test_print_error_never_mirrored(self):
+        """A fault on the printer must not raise a modal error dialog in the slicer.
+
+        The VP is not the machine that threw it — Bambuddy's own printer card
+        reports the fault.
+        """
+        server = _make_server()
+        bridge = MagicMock()
+        bridge.get_latest_print_state.return_value = _printing_cache(print_error=515)
+        server.set_bridge(bridge)
+        published = _capture_published(server)
+
+        await server._send_status_report(MagicMock())
+        _serial, payload = published[0]
         assert payload["print"]["print_error"] == 0
         assert payload["print"]["print_error"] == 0
+        assert payload["print"]["mc_percent"] == 47  # the rest still mirrors
 
 
 
 
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů