diff --git a/debian/requirements-bundle.txt b/debian/requirements-bundle.txt index 4a37351fd..6068ed7a6 100644 --- a/debian/requirements-bundle.txt +++ b/debian/requirements-bundle.txt @@ -1,6 +1,6 @@ asyncudp==0.11.0 pyvips==3.2.0 -raydriver==0.3.0 +raydriver==0.5.1 raygeo==1.66.0 ruida-pa==0.22.0 trimesh==5.1.1 diff --git a/pixi.lock b/pixi.lock index 467bf6e67..b89d44d46 100644 --- a/pixi.lock +++ b/pixi.lock @@ -263,7 +263,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/8d/15/1633010b26e88e872c93b67c0b6c5e174fb74cb6fb5c1472b4d51d4a8f22/platformdirs-4.13.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8f/f6/fb68b8c08f864ecee8f7f7ee2f1203ed5cba2335215de69e4f621eb47db5/trimesh-5.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/93/cd/11159bcba680ac5f16e48443206d278768f464cc42fe4bfeb169c33db7b6/raydriver-0.3.0.tar.gz - pypi: https://files.pythonhosted.org/packages/93/d8/ba13451aa6b745c49536e87b6bf8f629b950e84bd0e8308f7dc6883b67e2/cairocffi-1.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9a/8e/cde07147b6fc56b9ae0fe01d9c0bebd730b2be2c3052d3a5a117e13f561e/multidict-7.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl @@ -291,6 +290,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/df/f3/90993aab504fa2e1f28fcc09aa16b6ea4f00e75a037d9136e737855833e2/pyvips-3.2.0.tar.gz - pypi: https://files.pythonhosted.org/packages/e4/62/c3c23511ebe3d76dc679db0bb045dfd20ed3452bec12dcbbe8d655498a7d/vtracer-0.6.15.tar.gz - pypi: https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/fa/06/6ab120a209276d8dced861ee11434ae2732ae70f8604df9b26c8ee3120d9/raydriver-0.5.1.tar.gz - pypi: https://files.pythonhosted.org/packages/fa/48/f36d233ce749d1bb880cb5e6f8bb171dc2effabc7435c31da604d831b3ec/gitpython-3.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl @@ -503,6 +503,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a5/1a/ad561f99f90884089e6403b76c220610809429ba868a81a2e7ce115d32e0/propcache-0.5.4-cp315-cp315-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a5/f4/efe06066f1f15711cc51720faae0e4b838625ce62e8f0833f9f1476aeecf/raydriver-0.5.1-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/a8/67/7094dfc74bd9b7263a71a1a5706965750600d7cf740795ce123860986206/zeroconf-0.151.5-cp315-cp315-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/a8/71/9219fe65ef32bddc0b5d14e1d98dfefe9d2c9c1b1d1e93efca1f29fcbd4d/xyzservices-2026.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ad/9b/9fb3585dabcd73a1b2a6267f63f62649347c9e6d072c9fde365b105abb2c/build-1.6.1-py3-none-any.whl @@ -514,7 +515,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/be/02/bce7a1d70e9d0f6ea8623c16386138c251b5855af6cd3a11433d3fba4e66/semver-3.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c5/0a/1423bb61b5f06376fe6ccf79c8781e2bb5e36c78c1814c78913b7425950f/bokeh-3.10.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c8/c3/0bebaaec98aa7cb43e04a8a6079b4aecb83338f6b4b3a32eb837c4c484ea/raydriver-0.3.0-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/cd/5b/ff5fc58fa2427c30dea74c90053f4fc5eda1e7f3833ed3ecc7147fe2b311/tornado-6.5.10-cp39-abi3-macosx_10_9_universal2.whl - pypi: https://files.pythonhosted.org/packages/d1/89/5d4c86da1130d9059681e5b6cd7645df5c10279a6a079c5c37dcb2cc6f3f/narwhals-2.27.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl @@ -947,7 +947,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/8d/15/1633010b26e88e872c93b67c0b6c5e174fb74cb6fb5c1472b4d51d4a8f22/platformdirs-4.13.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8f/f6/fb68b8c08f864ecee8f7f7ee2f1203ed5cba2335215de69e4f621eb47db5/trimesh-5.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/93/cd/11159bcba680ac5f16e48443206d278768f464cc42fe4bfeb169c33db7b6/raydriver-0.3.0.tar.gz - pypi: https://files.pythonhosted.org/packages/93/d8/ba13451aa6b745c49536e87b6bf8f629b950e84bd0e8308f7dc6883b67e2/cairocffi-1.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9a/8e/cde07147b6fc56b9ae0fe01d9c0bebd730b2be2c3052d3a5a117e13f561e/multidict-7.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl @@ -982,6 +981,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/df/f3/90993aab504fa2e1f28fcc09aa16b6ea4f00e75a037d9136e737855833e2/pyvips-3.2.0.tar.gz - pypi: https://files.pythonhosted.org/packages/e4/62/c3c23511ebe3d76dc679db0bb045dfd20ed3452bec12dcbbe8d655498a7d/vtracer-0.6.15.tar.gz - pypi: https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/fa/06/6ab120a209276d8dced861ee11434ae2732ae70f8604df9b26c8ee3120d9/raydriver-0.5.1.tar.gz - pypi: https://files.pythonhosted.org/packages/fa/48/f36d233ce749d1bb880cb5e6f8bb171dc2effabc7435c31da604d831b3ec/gitpython-3.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fa/70/fffc9613501877c0a10a1ef73a165e6ded3e53f6e7502227fef56d973933/identify-2.6.20-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl @@ -1215,6 +1215,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a5/1a/ad561f99f90884089e6403b76c220610809429ba868a81a2e7ce115d32e0/propcache-0.5.4-cp315-cp315-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a5/f4/efe06066f1f15711cc51720faae0e4b838625ce62e8f0833f9f1476aeecf/raydriver-0.5.1-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/a7/20/643f3c8f75594f937b2bf74801241c56a2e2b8e139d24dff8b66b28cdd7f/ruff-0.17.0-py3-none-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/a8/67/7094dfc74bd9b7263a71a1a5706965750600d7cf740795ce123860986206/zeroconf-0.151.5-cp315-cp315-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/a8/71/9219fe65ef32bddc0b5d14e1d98dfefe9d2c9c1b1d1e93efca1f29fcbd4d/xyzservices-2026.9.1-py3-none-any.whl @@ -1228,7 +1229,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/be/02/bce7a1d70e9d0f6ea8623c16386138c251b5855af6cd3a11433d3fba4e66/semver-3.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c5/0a/1423bb61b5f06376fe6ccf79c8781e2bb5e36c78c1814c78913b7425950f/bokeh-3.10.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c8/c3/0bebaaec98aa7cb43e04a8a6079b4aecb83338f6b4b3a32eb837c4c484ea/raydriver-0.3.0-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/cd/5b/ff5fc58fa2427c30dea74c90053f4fc5eda1e7f3833ed3ecc7147fe2b311/tornado-6.5.10-cp39-abi3-macosx_10_9_universal2.whl - pypi: https://files.pythonhosted.org/packages/d1/89/5d4c86da1130d9059681e5b6cd7645df5c10279a6a079c5c37dcb2cc6f3f/narwhals-2.27.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d7/8b/f837f52905395ba4510fe61f753c24833fb0a9c76e21267bb9f828b664a9/filelock-4.1.1-py3-none-any.whl @@ -9493,7 +9493,7 @@ packages: - pypi: ./ name: rayforge requires_dist: - - raydriver==0.3.0 + - raydriver==0.5.1 - raygeo==1.66.0 - aiohttp==3.14.5 - asyncudp==0.11.0 @@ -9998,15 +9998,6 @@ packages: version: '3.11' sha256: 51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80 requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/93/cd/11159bcba680ac5f16e48443206d278768f464cc42fe4bfeb169c33db7b6/raydriver-0.3.0.tar.gz - name: raydriver - version: 0.3.0 - sha256: e7ad8d69e135fb84fb1e01275ac815c59afd192dcc94ca9b65c5f5ab568f2f57 - requires_dist: - - pytest ; extra == 'test' - - pytest-asyncio ; extra == 'test' - - pytest-timeout ; extra == 'test' - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/93/d8/ba13451aa6b745c49536e87b6bf8f629b950e84bd0e8308f7dc6883b67e2/cairocffi-1.7.1-py3-none-any.whl name: cairocffi version: 1.7.1 @@ -10123,6 +10114,15 @@ packages: - pytest-regressions ; extra == 'testing' - pytest-timeout ; extra == 'testing' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/a5/f4/efe06066f1f15711cc51720faae0e4b838625ce62e8f0833f9f1476aeecf/raydriver-0.5.1-cp311-abi3-macosx_11_0_arm64.whl + name: raydriver + version: 0.5.1 + sha256: bd446b322a15a646a9264bb22aed1fb6212d247cb861ae5df85b8ec5fa61448d + requires_dist: + - pytest ; extra == 'test' + - pytest-asyncio ; extra == 'test' + - pytest-timeout ; extra == 'test' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/a7/20/643f3c8f75594f937b2bf74801241c56a2e2b8e139d24dff8b66b28cdd7f/ruff-0.17.0-py3-none-macosx_11_0_arm64.whl name: ruff version: 0.17.0 @@ -10286,15 +10286,6 @@ packages: requires_dist: - ifaddr>=0.1.7 requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/c8/c3/0bebaaec98aa7cb43e04a8a6079b4aecb83338f6b4b3a32eb837c4c484ea/raydriver-0.3.0-cp311-abi3-macosx_11_0_arm64.whl - name: raydriver - version: 0.3.0 - sha256: 52f183fd7f6b90bb81d8d84a253da51e4d0a146b6ca9fd4ff6e6b90f4cb7d2cf - requires_dist: - - pytest ; extra == 'test' - - pytest-asyncio ; extra == 'test' - - pytest-timeout ; extra == 'test' - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/cd/5b/ff5fc58fa2427c30dea74c90053f4fc5eda1e7f3833ed3ecc7147fe2b311/tornado-6.5.10-cp39-abi3-macosx_10_9_universal2.whl name: tornado version: 6.5.10 @@ -10512,6 +10503,15 @@ packages: version: 1.28.2 sha256: 7113846b35dbf0a033f088e4f4fb543dabeb4b0b12c112966a1ca1ee2d5eacae requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/fa/06/6ab120a209276d8dced861ee11434ae2732ae70f8604df9b26c8ee3120d9/raydriver-0.5.1.tar.gz + name: raydriver + version: 0.5.1 + sha256: 1ed667bfa4c8486d4e68af7e2baf48f9ffd1a8e7a66a0a3357358b8c1db5c8c7 + requires_dist: + - pytest ; extra == 'test' + - pytest-asyncio ; extra == 'test' + - pytest-timeout ; extra == 'test' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/fa/48/f36d233ce749d1bb880cb5e6f8bb171dc2effabc7435c31da604d831b3ec/gitpython-3.2.0-py3-none-any.whl name: gitpython version: 3.2.0 diff --git a/pixi.toml b/pixi.toml index c06d8149f..0de23d9eb 100644 --- a/pixi.toml +++ b/pixi.toml @@ -39,7 +39,7 @@ patchelf = "==0.19.2" [feature.app.pypi-dependencies] raygeo = "==1.66.0" -raydriver = "==0.3.0" +raydriver = "==0.5.1" rayforge = { editable = true, path = "." } ruida-pa = "==0.22.0" aiohttp = "==3.14.5" diff --git a/rayforge/machine/driver/grbl/grbl_serial.py b/rayforge/machine/driver/grbl/grbl_serial.py index 1b97fc8ee..87a021dc3 100644 --- a/rayforge/machine/driver/grbl/grbl_serial.py +++ b/rayforge/machine/driver/grbl/grbl_serial.py @@ -9,7 +9,7 @@ ) import serial -from raydriver.grbl import GrblSession +from raydriver.grbl import GrblSession, SessionEvent from raydriver.grbl.parser import ( error_code_to_device_error, extract_device_name_from_output, @@ -54,6 +54,7 @@ ) if TYPE_CHECKING: + from raydriver.grbl.types import DeviceState as SessionDeviceState from raygeo.ops import Ops from ....core.doc import Doc @@ -265,49 +266,82 @@ def _setup_implementation(self, **kwargs: Any) -> None: event_callback=self._on_session_event, ) - def _on_session_event(self, name: str, payload: Any) -> None: + def _on_session_event(self, event: SessionEvent) -> None: """Re-emit Rust session events as Rayforge blinker signals.""" - if name == "state_changed": - state = from_session_state(payload) - old_status = self.state.status - self.state = state - if state.status != old_status: - logger.info( - f"Device state changed: {state.status.name}", - extra=self._log_extra("STATE_CHANGE"), - ) - self.state_changed.send(self, state=state) - elif name == "connection_status_changed": - status_str, message = payload + match event: + case SessionEvent.StateChanged(): + self._on_state_changed(event.state) + case SessionEvent.ConnectionStatusChanged(): + self._on_connection_status_changed(event.status, event.message) + case SessionEvent.CommandStatusChanged(): + self._on_command_status_changed(event.status, event.message) + case SessionEvent.JobFinished(): + self.job_finished.send(self) + case SessionEvent.ProbeStatusChanged(): + self.probe_status_changed.send(self, message=event.message) + case SessionEvent.WcsUpdated(): + self.wcs_updated.send(self, offsets=event.offsets) + case SessionEvent.ConfigChanged(): + self._on_config_changed(event.key, event.value) + case SessionEvent.RawRx(): + self._log_raw_line(event.line, received=True) + case SessionEvent.RawTx(): + self._log_raw_line(event.line, received=False) + case _: + logger.debug(f"Ignoring session event: {event!r}") + + def _on_state_changed(self, session_state: "SessionDeviceState") -> None: + state = from_session_state(session_state) + old_status = self.state.status + self.state = state + if state.status != old_status: logger.info( - f"Connection status: {status_str}" - + (f" - {message}" if message else ""), - extra=self._log_extra("MACHINE_EVENT"), - ) - self.connection_status_changed.send( - self, - status=TransportStatus[status_str], - message=message, + f"Device state changed: {state.status.name}", + extra=self._log_extra("STATE_CHANGE"), ) - elif name == "command_status_changed": - status_str, message = payload - self.command_status_changed.send( - self, - status=TransportStatus[status_str], - message=message, - ) - elif name == "job_finished": - self.job_finished.send(self) - elif name == "probe_status_changed": - self.probe_status_changed.send(self, message=payload) - elif name == "wcs_updated": - self.wcs_updated.send(self, offsets=payload) - elif name == "config_changed": - key, value = payload - self.config[key] = value - self.config_changed.send(self) - else: # pragma: no cover - unknown events are ignored - logger.debug(f"Ignoring session event: {name}") + self.state_changed.send(self, state=state) + + def _on_connection_status_changed( + self, status_str: str, message: str | None + ) -> None: + logger.info( + f"Connection status: {status_str}" + + (f" - {message}" if message else ""), + extra=self._log_extra("MACHINE_EVENT"), + ) + self.connection_status_changed.send( + self, + status=TransportStatus[status_str], + message=message, + ) + + def _on_command_status_changed( + self, status_str: str, message: str | None + ) -> None: + self.command_status_changed.send( + self, + status=TransportStatus[status_str], + message=message, + ) + + def _on_config_changed(self, key: str, value: int) -> None: + self.config[key] = value + self.config_changed.send(self) + + def _log_raw_line(self, line: str, received: bool) -> None: + """Log a raw line from the session's communication so the + console (with 'Show verbose' enabled) shows the full + conversation with the device.""" + if received: + if line.startswith("<") and line.endswith(">"): + category = "STATUS_POLL" + elif line == "ok" or line.startswith("error:"): + category = "MACHINE_RESPONSE" + else: + category = "MACHINE_EVENT" + else: + category = "RAW_IO" + logger.info(line, extra=self._log_extra(category)) async def cleanup(self): logger.debug("Cleanup initiated.") diff --git a/requirements.txt b/requirements.txt index 779acbc2a..08744b842 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -raydriver==0.3.0 +raydriver==0.5.1 raygeo==1.66.0 aiohttp==3.14.5 asyncudp==0.11.0 diff --git a/tests/machine/driver/grbl/test_grbl_serial_driver.py b/tests/machine/driver/grbl/test_grbl_serial_driver.py index 8fbfd9868..b5251d30e 100644 --- a/tests/machine/driver/grbl/test_grbl_serial_driver.py +++ b/tests/machine/driver/grbl/test_grbl_serial_driver.py @@ -1,6 +1,7 @@ """Tests for the Rust-backed GrblSerialDriver.""" import asyncio +import logging import time import pytest @@ -421,6 +422,48 @@ async def job(): assert b"M5\n" in sent +@pytest.mark.asyncio +class TestRawLogging: + async def test_settings_reply_is_logged(self, connected_driver, caplog): + drv, _mock, _emulator, _recorder = connected_driver + with caplog.at_level( + logging.INFO, + logger="rayforge.machine.driver.grbl.grbl_serial", + ): + await drv._require_session().execute_interactive_command("$$") + messages = [record.getMessage() for record in caplog.records] + assert any(message.startswith("$") for message in messages) + assert "ok" in messages + + async def test_outgoing_command_is_logged(self, connected_driver, caplog): + drv, _mock, _emulator, _recorder = connected_driver + with caplog.at_level( + logging.INFO, + logger="rayforge.machine.driver.grbl.grbl_serial", + ): + await drv._require_session().execute_interactive_command("$$") + assert any( + record.getMessage() == "$$" + and record.__dict__.get("log_category") == "RAW_IO" + for record in caplog.records + ) + + async def test_status_reports_are_logged(self, connected_driver, caplog): + _drv, _mock, _emulator, _recorder = connected_driver + with caplog.at_level( + logging.INFO, + logger="rayforge.machine.driver.grbl.grbl_serial", + ): + + def has_status_report(): + return any( + record.__dict__.get("log_category") == "STATUS_POLL" + for record in caplog.records + ) + + await wait_until(has_status_report) + + def _slow_job(n_lines: int) -> str: """Long alternating moves that keep the planner busy.""" return "\n".join(f"G1 X{50 if i % 2 else 0} F600" for i in range(n_lines))