raydriver-0.5.1/PKG-INFO000064400000000000000000000063171046102023000115420ustar00Metadata-Version: 2.4 Name: raydriver Version: 0.5.1 Classifier: Programming Language :: Rust Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: Implementation :: PyPy Requires-Dist: pytest ; extra == 'test' Requires-Dist: pytest-asyncio ; extra == 'test' Requires-Dist: pytest-timeout ; extra == 'test' Provides-Extra: test License-File: LICENSE Summary: Rust-native machine drivers for Rayforge, with Python bindings. License: MIT Requires-Python: >=3.10 Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM # raydriver Rust-native machine drivers for [Rayforge](https://github.com/barebaric/rayforge), exposed to Python via PyO3. Currently this provides a full port of Rayforge's advanced GRBL serial driver: character-counting flow control, handshake and status polling, interactive command queue, job streaming with stall detection and deadlock recovery, cancel/safety-shutdown semantics, settings and WCS access, and probe cycles. Dialects (command templates) remain data owned by Rayforge and are passed in resolved form. ## CLI The `raydriver` command runs G-code against a real device with live progress, or against the built-in firmware emulator for hardware-free dry runs: ```bash raydriver run job.gcode --port /dev/ttyUSB0 --baudrate 115200 raydriver run job.gcode --emulator # no hardware needed raydriver status --port /dev/ttyUSB0 --seconds 5 ``` While a job runs, `raydriver run` shows acknowledged lines, a progress bar, the device state, machine position, feed rate and ETA; `Ctrl-C` cancels the job and runs the safety shutdown. ## Releases Releasing mirrors the Raygeo flow: 1. Configure a PyPI *trusted publisher* for this repository once (owner `barebaric`, repo `raydriver`, workflow `release.yml`, environment `pypi`). 2. `gh release create v0.1.0 --title "v0.1.0" --generate-notes` The `Build and Publish` workflow then stamps the version from the tag, builds wheels (Linux/Windows/macOS) and an sdist, publishes to PyPI via OIDC, and attaches the artifacts to the GitHub release. ## Development ```bash make venv && source .venv/bin/activate make dev # build + install (do this before testing) make test # pytest suite (all tests are Python-based) make check # lint + test ``` ## Usage ```python import asyncio from raydriver.grbl import GrblSession, SessionEvent session = GrblSession( config={"port": "/dev/ttyUSB0", "baudrate": 115200}, dialect={ "home_all": "$H", "home_axis": "$H{axis_letter}", "clear_alarm": "$X", "laser_on": "M4 S{power:.0f}", "laser_off": "M5", }, event_callback=on_event, ) def on_event(event: SessionEvent): match event: case SessionEvent.StateChanged(): print("device state:", event.state.status.name) case SessionEvent.ConnectionStatusChanged(status=status): print("connection:", status) case SessionEvent.RawRx(line=line): print("<<", line) case SessionEvent.RawTx(line=line): print(">>", line) async def main(): await session.connect() print(await session.execute_interactive_command("$I")) await session.disconnect() asyncio.run(main()) ``` raydriver-0.5.1/.github/workflows/ci.yml000064400000000000000000000041071046102023000151530ustar00name: CI on: push: branches: [main] pull_request: branches: [main] jobs: lint: name: Lint and typecheck runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - name: Build wheel uses: PyO3/maturin-action@v1 with: command: build args: --release --out dist - name: Install wheel run: pip install dist/raydriver-*.whl - name: Lint Python run: pip install ruff && ruff check tests/ python/ - name: Run pyright run: | pip install pytest pytest-asyncio npx --yes pyright --pythonpath $(which python) python/raydriver tests - name: Validate stubs in wheel run: | cat > /tmp/test_stubs.py << 'EOF' from raydriver.grbl import GrblSession from raydriver.grbl.parser import parse_state from raydriver.grbl.types import DeviceState session: GrblSession state: DeviceState parsed = parse_state("", None, False) EOF npx --yes pyright --pythonpath $(which python) /tmp/test_stubs.py - name: Install Rust toolchain uses: dtolnay/rust-toolchain@1.98.1 with: components: rustfmt, clippy - name: Lint Rust run: | cargo fmt --check cargo clippy -- -D warnings test: name: Tests (${{ matrix.os }}) runs-on: ${{ matrix.os }} timeout-minutes: 40 strategy: fail-fast: false matrix: os: [ubuntu-latest, ubuntu-24.04-arm, windows-latest, macos-15-intel, macos-15] steps: - uses: actions/checkout@v7 - name: Build wheel uses: PyO3/maturin-action@v1 with: command: build args: --release --out dist - name: Install wheel shell: bash run: pip install dist/raydriver-*.whl - name: Install test dependencies shell: bash run: pip install pytest pytest-asyncio pytest-timeout - name: Run tests shell: bash run: pytest -v --timeout=60 --tb=short raydriver-0.5.1/.github/workflows/release.yml000064400000000000000000000041551046102023000162030ustar00name: Build and Publish on: release: types: [published] workflow_dispatch: jobs: build-wheels: name: Build wheels on ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-latest, ubuntu-24.04-arm, windows-latest, macos-15-intel, macos-15] steps: - uses: actions/checkout@v7 - name: Set version from tag if: startsWith(github.ref, 'refs/tags/v') shell: bash run: | VERSION="${GITHUB_REF#refs/tags/v}" sed -i.bak "s/^version = \".*\"/version = \"$VERSION\"/" Cargo.toml - uses: PyO3/maturin-action@v1 with: command: build args: --release --out dist - name: Upload wheels as artifacts uses: actions/upload-artifact@v4 with: name: wheels-${{ matrix.os }} path: dist/*.whl build-sdist: name: Build sdist runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - name: Set version from tag if: startsWith(github.ref, 'refs/tags/v') shell: bash run: | VERSION="${GITHUB_REF#refs/tags/v}" sed -i.bak "s/^version = \".*\"/version = \"$VERSION\"/" Cargo.toml - uses: PyO3/maturin-action@v1 with: command: sdist args: --out dist - name: Upload sdist as artifact uses: actions/upload-artifact@v4 with: name: sdist path: dist/*.tar.gz publish: name: Publish to PyPI needs: [build-wheels, build-sdist] runs-on: ubuntu-latest environment: pypi permissions: id-token: write contents: write steps: - name: Download all artifacts uses: actions/download-artifact@v4 with: path: dist merge-multiple: true - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: packages-dir: dist/ - name: Upload artifacts to GitHub Release uses: softprops/action-gh-release@v2 if: startsWith(github.ref, 'refs/tags/') with: files: dist/* raydriver-0.5.1/.gitignore000064400000000000000000000001171046102023000124250ustar00target/ __pycache__/ *.pyc *.so *.pyd dist/ .venv/ .ruff_cache/ .pytest_cache/ raydriver-0.5.1/.pre-commit-config.yaml000064400000000000000000000007021046102023000147160ustar00# See https://pre-commit.com for more information repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.15.14 hooks: - id: ruff-check args: [--fix] - id: ruff-format - repo: local hooks: - id: pyright name: pyright entry: npx --yes pyright python/raydriver tests language: system pass_filenames: false files: ^(python/.*\.pyi?|tests/.*\.py|src/.*\.rs)$ raydriver-0.5.1/AGENTS.md000064400000000000000000000106321046102023000117430ustar00# AGENTS.md ## Available commands - `make dev` — build and install into the active venv - `make stubs` — re-generate `.pyi` type stubs (after changing `src/python/` bindings) - `make lint` — lint all code (Rust + Python) - `make lint-rust` — lint Rust only - `make lint-python` — lint Python only - `make format` — auto-format all code (Rust + Python, including PEP8 import ordering) - `make format-rust` — format Rust only - `make format-python` — format Python only (ruff handles formatting and import sorting) - `make test` — run the full test suite. make sure to "make dev" before you test - `make check` — lint + test - `make build` — build the wheel (release) ## CLI - `raydriver run job.gcode --port /dev/ttyUSB0` — stream a G-code file to a real device with live progress - `raydriver run job.gcode --emulator` — same, against the built-in firmware emulator (no hardware needed) - `raydriver status --port /dev/ttyUSB0` — connect and print live status reports ## Testing All tests are Python-based, under `tests/`, and run against `raydriver.emulator.GrblEmulator` — a GRBL 1.1h *firmware emulator* (not a mock): it models the character-counting RX buffer, the 15-block planner with deferred acknowledgements, realtime command interception, modal G-code state, feed-rate-timed motion, alarms, homing, probing and the `$` system commands. Device-side code consumes session writes via `MockTransport.take_new_sent()` (a monotonic cursor — never index into `sent()`, which tests may clear at any time). ## Rules - You are strictly forbidden from editing stubs manually. They are only to be edited using "make stubs". - Use make commands when available - avoid calling the underlying tools directly. - Never add Rust tests (`#[cfg(test)]` / `#[test]` blocks in `src/`). All tests are Python-based, under `tests/`. Exercise new Rust code through PyO3 bindings from Python test code. - Keep the Grbl protocol behavior byte-for-byte compatible with the Python driver in Rayforge (`rayforge/machine/driver/grbl/grbl_serial.py`). Do not change protocol quirks (ack extraction order, realtime bypass, buffer accounting, stall/liveness rules) without an explicit request. ## Layering Rules Specification The crate is split into two layers that depend only downward: ``` grbl (protocol + session core) → python (PyO3 bindings) ``` - `src/grbl/` must not depend on PyO3 or any Python types. It is pure Rust: protocol parsing, flow control, transports, and the session state machines. Python-facing notifications go through the `SessionEvents` trait; the `python` feature provides the implementation that marshals events across the boundary. - `src/python/` contains all PyO3 bindings and may use `src/grbl/` freely, never the other way around. ## Export Policy: Explicit Paths Every item has exactly one canonical path — its leaf module. Parent mod.rs must not re-export children's items (no namespace flattening). Exceptions: Primitive types, errors, classes and constants that are _public_ AND _shared_ within a submodule. Python sub-modules mirror the Rust hierarchy - no aliases or re-exports at higher levels. The compiled module is `raydriver.raydriver`; the pure-Python packages under `python/raydriver/` serve as the importable packages and delegate to the Rust module via module-level `__getattr__`: ```python import raydriver.raydriver as _raydriver def __getattr__(name): return getattr(_raydriver.grbl, name) ``` Rust submodules registered with `add_submodule` are intentionally NOT registered in `sys.modules` — the Python `__init__.py` of the same name is the package. ## Python/Rust Boundary Rules - No domain model objects cross the Python/Rust boundary — only primitive types, typed pyclasses defined in this crate, and JSON-serialisable dicts. - Async Rust methods return asyncio futures that are completed from the tokio side via `call_soon_threadsafe` on the caller's event loop. Never block the loop inside a binding. - Events emitted by the session are delivered to the Python `event_callback(name: str, payload)` on the event loop thread. Bindings must not call the callback from a foreign thread directly. ## Dialects Dialects remain data owned by Rayforge. This crate receives resolved command templates (a plain dict of format strings) and only needs the subset used for interactive commands. Formatting supports the Python `str.format` subset `{name}` and `{name:.Nf}`. raydriver-0.5.1/Cargo.lock000064400000000000000000001204501046102023000123450ustar00# This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 4 [[package]] name = "aho-corasick" version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] [[package]] name = "android_system_properties" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] [[package]] name = "anstream" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", "is_terminal_polyfill", "utf8parse", ] [[package]] name = "anstyle" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", "windows-sys 0.61.2", ] [[package]] name = "anyhow" version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "async-trait" version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", "syn 3.0.6", ] [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "bitflags" version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytes" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" dependencies = [ "find-msvc-tools", "shlex", ] [[package]] name = "cfg-if" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chrono" version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", "num-traits", "wasm-bindgen", "windows-link", ] [[package]] name = "colorchoice" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "core-foundation" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ "core-foundation-sys", "libc", ] [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "crunchy" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "defmt" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" dependencies = [ "bitflags 1.3.2", "defmt-macros", ] [[package]] name = "defmt-macros" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" dependencies = [ "defmt-parser", "proc-macro2", "quote", "syn 2.0.119", ] [[package]] name = "defmt-parser" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ "thiserror", ] [[package]] name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" [[package]] name = "either" version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "env_filter" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" dependencies = [ "log", "regex", ] [[package]] name = "env_filter" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" dependencies = [ "log", "regex", ] [[package]] name = "env_logger" version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" dependencies = [ "anstream", "anstyle", "env_filter 2.0.0", "jiff", "log", ] [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "find-msvc-tools" version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" [[package]] name = "futures-core" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-sink" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-task", "pin-project-lite", "slab", ] [[package]] name = "getopts" version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" dependencies = [ "unicode-width", ] [[package]] name = "getrandom" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", "wasi", ] [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "iana-time-zone" version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", "log", "wasm-bindgen", "windows-core", ] [[package]] name = "iana-time-zone-haiku" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" dependencies = [ "cc", ] [[package]] name = "indexmap" version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown", ] [[package]] name = "inventory" version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" dependencies = [ "rustversion", ] [[package]] name = "io-kit-sys" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" dependencies = [ "core-foundation-sys", "mach2", ] [[package]] name = "is-macro" version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8267aa6001e25494f3015f9663bbd88a18240c74483afa5f0934a1b3e4c388e9" dependencies = [ "heck", "proc-macro2", "quote", "syn 2.0.119", ] [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" dependencies = [ "either", ] [[package]] name = "itertools" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" dependencies = [ "defmt", "jiff-core", "jiff-static", "log", "portable-atomic", "portable-atomic-util", "serde_core", ] [[package]] name = "jiff-core" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" dependencies = [ "defmt", "log", ] [[package]] name = "jiff-static" version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" dependencies = [ "jiff-core", "proc-macro2", "quote", "syn 2.0.119", ] [[package]] name = "js-sys" version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", "wasm-bindgen", ] [[package]] name = "lalrpop-util" version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" [[package]] name = "libc" version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "log" version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "mach2" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" dependencies = [ "libc", ] [[package]] name = "maplit" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" [[package]] name = "matrixmultiply" version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" dependencies = [ "autocfg", "rawpointer", ] [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mio" version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "log", "wasi", "windows-sys 0.61.2", ] [[package]] name = "mio-serial" version = "5.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d4ba3f20276f21b7cad3f1b54c97489cf096a3894fd627cc6951cb3abdd4c60" dependencies = [ "log", "mio", "nix 0.31.3", "serialport", "windows-sys 0.61.2", ] [[package]] name = "ndarray" version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" dependencies = [ "matrixmultiply", "num-complex", "num-integer", "num-traits", "portable-atomic", "portable-atomic-util", "rawpointer", ] [[package]] name = "nix" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" dependencies = [ "bitflags 1.3.2", "cfg-if", "libc", ] [[package]] name = "nix" version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", ] [[package]] name = "num-bigint" version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", ] [[package]] name = "num-complex" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ "num-traits", ] [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] [[package]] name = "num-traits" version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", ] [[package]] name = "numpy" version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "778da78c64ddc928ebf5ad9df5edf0789410ff3bdbf3619aed51cd789a6af1e2" dependencies = [ "libc", "ndarray", "num-complex", "num-integer", "num-traits", "pyo3", "pyo3-build-config", "rustc-hash 2.1.3", ] [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "ordered-float" version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" dependencies = [ "num-traits", ] [[package]] name = "phf" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ "phf_shared", ] [[package]] name = "phf_codegen" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ "phf_generator", "phf_shared", ] [[package]] name = "phf_generator" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", "rand", ] [[package]] name = "phf_shared" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ "siphasher", ] [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "portable-atomic" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" dependencies = [ "portable-atomic", ] [[package]] name = "powerfmt" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppv-lite86" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ "zerocopy", ] [[package]] name = "proc-macro2" version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "pyo3" version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91fd8e38a3b50ed1167fb981cd6fd60147e091784c427b8f7183a7ee32c31c12" dependencies = [ "libc", "once_cell", "portable-atomic", "pyo3-build-config", "pyo3-ffi", "pyo3-macros", ] [[package]] name = "pyo3-build-config" version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e" dependencies = [ "target-lexicon", ] [[package]] name = "pyo3-ffi" version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f29e10af80b1f7ccaf7f69eace800a03ecd13e883acfacc1e5d0988605f651e" dependencies = [ "libc", "pyo3-build-config", ] [[package]] name = "pyo3-macros" version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df6e520eff47c45997d2fc7dd8214b25dd1310918bbb2642156ef66a67f29813" dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", "syn 2.0.119", ] [[package]] name = "pyo3-macros-backend" version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb" dependencies = [ "heck", "proc-macro2", "pyo3-build-config", "quote", "syn 2.0.119", ] [[package]] name = "pyo3-stub-gen" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a267ea4da9a831f534def7f1d8e14f581d5640d3e5af6527072724f216f4dbbf" dependencies = [ "anyhow", "chrono", "either", "indexmap", "inventory", "itertools 0.14.0", "log", "maplit", "num-complex", "numpy", "ordered-float", "pyo3", "pyo3-stub-gen-derive", "rustpython-parser", "serde", "serde_json", "time", "toml", ] [[package]] name = "pyo3-stub-gen-derive" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6573423a5e8cc43ec7565eccccbc2dd35e1fc9032d9ca404afab875429eb8cf0" dependencies = [ "heck", "indexmap", "proc-macro2", "quote", "rustpython-parser", "syn 2.0.119", ] [[package]] name = "quote" version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] [[package]] name = "rand" version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha", "rand_core", ] [[package]] name = "rand_chacha" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", "rand_core", ] [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ "getrandom", ] [[package]] name = "rawpointer" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" [[package]] name = "raydriver" version = "0.5.1" dependencies = [ "async-trait", "env_filter 0.1.4", "env_logger", "log", "pyo3", "pyo3-stub-gen", "regex", "thiserror", "tokio", "tokio-serial", ] [[package]] name = "regex" version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", "regex-automata", "regex-syntax", ] [[package]] name = "regex-automata" version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", "regex-syntax", ] [[package]] name = "regex-syntax" version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rustc-hash" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc-hash" version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustpython-ast" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cdaf8ee5c1473b993b398c174641d3aa9da847af36e8d5eb8291930b72f31a5" dependencies = [ "is-macro", "num-bigint", "rustpython-parser-core", "static_assertions", ] [[package]] name = "rustpython-parser" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "868f724daac0caf9bd36d38caf45819905193a901e8f1c983345a68e18fb2abb" dependencies = [ "anyhow", "is-macro", "itertools 0.11.0", "lalrpop-util", "log", "num-bigint", "num-traits", "phf", "phf_codegen", "rustc-hash 1.1.0", "rustpython-ast", "rustpython-parser-core", "tiny-keccak", "unic-emoji-char", "unic-ucd-ident", "unicode_names2", ] [[package]] name = "rustpython-parser-core" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4b6c12fa273825edc7bccd9a734f0ad5ba4b8a2f4da5ff7efe946f066d0f4ad" dependencies = [ "is-macro", "memchr", "rustpython-parser-vendored", ] [[package]] name = "rustpython-parser-vendored" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04fcea49a4630a3a5d940f4d514dc4f575ed63c14c3e3ed07146634aed7f67a6" dependencies = [ "memchr", "once_cell", ] [[package]] name = "rustversion" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", ] [[package]] name = "serde_core" version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", "syn 3.0.6", ] [[package]] name = "serde_json" version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", "serde", "serde_core", "zmij", ] [[package]] name = "serde_spanned" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] [[package]] name = "serialport" version = "4.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ba5f8f29aa20853c4e3e85a33ec580eb66be1f057142e77a333834a318bacf2" dependencies = [ "bitflags 2.13.2", "cfg-if", "core-foundation", "core-foundation-sys", "io-kit-sys", "mach2", "nix 0.26.4", "scopeguard", "unescaper", "windows-sys 0.52.0", ] [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "siphasher" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "socket2" version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", ] [[package]] name = "static_assertions" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "syn" version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "syn" version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "target-lexicon" version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" [[package]] name = "thiserror" version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", "syn 3.0.6", ] [[package]] name = "time" version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "num-conv", "powerfmt", "serde_core", "time-core", ] [[package]] name = "time-core" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "tiny-keccak" version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" dependencies = [ "crunchy", ] [[package]] name = "tokio" version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", "mio", "pin-project-lite", "socket2", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", "syn 3.0.6", ] [[package]] name = "tokio-serial" version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd00f5f8b1e01c3e5afccd9e42ed80c2ad2df6d007877f29f8592c62e69cd116" dependencies = [ "cfg-if", "futures-core", "futures-sink", "log", "mio-serial", "serialport", "tokio", ] [[package]] name = "toml" version = "1.1.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ "indexmap", "serde_core", "serde_spanned", "toml_datetime", "toml_parser", "toml_writer", "winnow", ] [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] [[package]] name = "toml_parser" version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow", ] [[package]] name = "toml_writer" version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "unescaper" version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7285e83a80ce76f5e7bce79fa41f68d78ba62d1003cf27bf748ab24413808cf4" dependencies = [ "thiserror", ] [[package]] name = "unic-char-property" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" dependencies = [ "unic-char-range", ] [[package]] name = "unic-char-range" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" [[package]] name = "unic-common" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" [[package]] name = "unic-emoji-char" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b07221e68897210270a38bde4babb655869637af0f69407f96053a34f76494d" dependencies = [ "unic-char-property", "unic-char-range", "unic-ucd-version", ] [[package]] name = "unic-ucd-ident" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" dependencies = [ "unic-char-property", "unic-char-range", "unic-ucd-version", ] [[package]] name = "unic-ucd-version" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" dependencies = [ "unic-common", ] [[package]] name = "unicode-ident" version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-width" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "unicode_names2" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1673eca9782c84de5f81b82e4109dcfb3611c8ba0d52930ec4a9478f547b2dd" dependencies = [ "phf", "unicode_names2_generator", ] [[package]] name = "unicode_names2_generator" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b91e5b84611016120197efd7dc93ef76774f4e084cd73c9fb3ea4a86c570c56e" dependencies = [ "getopts", "log", "phf_codegen", "rand", ] [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", ] [[package]] name = "wasm-bindgen-macro-support" version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", "syn 3.0.6", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] [[package]] name = "windows-core" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", "windows-link", "windows-result", "windows-strings", ] [[package]] name = "windows-implement" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", "syn 2.0.119", ] [[package]] name = "windows-interface" version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", "syn 2.0.119", ] [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-result" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ "windows-link", ] [[package]] name = "windows-strings" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ "windows-link", ] [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ "windows-targets", ] [[package]] name = "windows-sys" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ "windows_aarch64_gnullvm", "windows_aarch64_msvc", "windows_i686_gnu", "windows_i686_gnullvm", "windows_i686_msvc", "windows_x86_64_gnu", "windows_x86_64_gnullvm", "windows_x86_64_msvc", ] [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" [[package]] name = "zerocopy" version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" dependencies = [ "proc-macro2", "quote", "syn 2.0.119", ] [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" raydriver-0.5.1/Cargo.toml000064400000000000000000000014351046102023000123710ustar00[package] name = "raydriver" version = "0.5.1" edition = "2021" description = "Rust-native machine drivers for Rayforge, with Python bindings" readme = "README.md" [features] default = ["python"] python = ["dep:pyo3", "dep:pyo3-stub-gen"] [dependencies] async-trait = "0.1" env_filter = "0.1" env_logger = "0.11" log = "0.4" pyo3 = { version = "0.28", features = ["abi3", "abi3-py311", "py-clone"], optional = true } pyo3-stub-gen = { version = "0.23.0", optional = true } regex = "1" thiserror = "2" tokio = { version = "1", features = [ "rt-multi-thread", "macros", "time", "sync", "net", "io-util", ] } tokio-serial = "5.4" [lib] name = "raydriver" crate-type = ["cdylib", "lib"] [[bin]] name = "stub_gen" path = "src/bin/stub_gen.rs" required-features = ["python"] raydriver-0.5.1/Cargo.toml.bak000064400000000000000000000014101046102023000131160ustar00[package] name = "raydriver" version = "0.0.0" edition = "2021" description = "Rust-native machine drivers for Rayforge, with Python bindings" [features] default = ["python"] python = ["dep:pyo3", "dep:pyo3-stub-gen"] [dependencies] async-trait = "0.1" env_filter = "0.1" env_logger = "0.11" log = "0.4" pyo3 = { version = "0.28", features = ["abi3", "abi3-py311", "py-clone"], optional = true } pyo3-stub-gen = { version = "0.23.0", optional = true } regex = "1" thiserror = "2" tokio = { version = "1", features = [ "rt-multi-thread", "macros", "time", "sync", "net", "io-util", ] } tokio-serial = "5.4" [lib] name = "raydriver" crate-type = ["cdylib", "lib"] [[bin]] name = "stub_gen" path = "src/bin/stub_gen.rs" required-features = ["python"] raydriver-0.5.1/LICENSE000064400000000000000000000020521046102023000114420ustar00MIT License Copyright (c) 2026 Barebaric Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. raydriver-0.5.1/Makefile000064400000000000000000000014361046102023000121020ustar00.PHONY: build dev stubs format format-rust format-python lint lint-rust lint-python test check venv build: maturin build --release --out dist dev: install-test-deps maturin develop --profile dev install-test-deps: pip install -e ".[test]" --quiet venv: python3 -m venv .venv @echo "Run 'source .venv/bin/activate' to activate the virtual environment." stubs: cargo clean -p raydriver && cargo run --bin stub_gen format: format-rust format-python format-rust: cargo fmt format-python: ruff format tests/ python/ ruff check --fix tests/ python/ lint: lint-rust lint-python lint-rust: cargo fmt --check cargo clippy -- -D warnings lint-python: ruff check tests/ python/ ruff format --check tests/ python/ npx pyright python/raydriver tests test: pytest -v check: lint test raydriver-0.5.1/README.md000064400000000000000000000051751046102023000117250ustar00# raydriver Rust-native machine drivers for [Rayforge](https://github.com/barebaric/rayforge), exposed to Python via PyO3. Currently this provides a full port of Rayforge's advanced GRBL serial driver: character-counting flow control, handshake and status polling, interactive command queue, job streaming with stall detection and deadlock recovery, cancel/safety-shutdown semantics, settings and WCS access, and probe cycles. Dialects (command templates) remain data owned by Rayforge and are passed in resolved form. ## CLI The `raydriver` command runs G-code against a real device with live progress, or against the built-in firmware emulator for hardware-free dry runs: ```bash raydriver run job.gcode --port /dev/ttyUSB0 --baudrate 115200 raydriver run job.gcode --emulator # no hardware needed raydriver status --port /dev/ttyUSB0 --seconds 5 ``` While a job runs, `raydriver run` shows acknowledged lines, a progress bar, the device state, machine position, feed rate and ETA; `Ctrl-C` cancels the job and runs the safety shutdown. ## Releases Releasing mirrors the Raygeo flow: 1. Configure a PyPI *trusted publisher* for this repository once (owner `barebaric`, repo `raydriver`, workflow `release.yml`, environment `pypi`). 2. `gh release create v0.1.0 --title "v0.1.0" --generate-notes` The `Build and Publish` workflow then stamps the version from the tag, builds wheels (Linux/Windows/macOS) and an sdist, publishes to PyPI via OIDC, and attaches the artifacts to the GitHub release. ## Development ```bash make venv && source .venv/bin/activate make dev # build + install (do this before testing) make test # pytest suite (all tests are Python-based) make check # lint + test ``` ## Usage ```python import asyncio from raydriver.grbl import GrblSession, SessionEvent session = GrblSession( config={"port": "/dev/ttyUSB0", "baudrate": 115200}, dialect={ "home_all": "$H", "home_axis": "$H{axis_letter}", "clear_alarm": "$X", "laser_on": "M4 S{power:.0f}", "laser_off": "M5", }, event_callback=on_event, ) def on_event(event: SessionEvent): match event: case SessionEvent.StateChanged(): print("device state:", event.state.status.name) case SessionEvent.ConnectionStatusChanged(status=status): print("connection:", status) case SessionEvent.RawRx(line=line): print("<<", line) case SessionEvent.RawTx(line=line): print(">>", line) async def main(): await session.connect() print(await session.execute_interactive_command("$I")) await session.disconnect() asyncio.run(main()) ``` raydriver-0.5.1/env.sh000064400000000000000000000004001046102023000115540ustar00#!/bin/bash # Source this to build raydriver with rayforge's pixi toolchain. export PATH=/root/projects/rayforge/.pixi/envs/default/bin:$PATH export CC=x86_64-conda-linux-gnu-cc export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=x86_64-conda-linux-gnu-cc raydriver-0.5.1/freeze-dump.txt000064400000000000000000000000001046102023000134100ustar00raydriver-0.5.1/pyproject.toml000064400000000000000000000023231046102023000133520ustar00[build-system] requires = ["maturin>=1.5,<2.0"] build-backend = "maturin" [project] name = "raydriver" dynamic = ["version"] description = "Rust-native machine drivers for Rayforge, with Python bindings." readme = "README.md" requires-python = ">=3.10" license = { text = "MIT" } classifiers = [ "Programming Language :: Rust", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ] dependencies = [] [project.optional-dependencies] test = [ "pytest", "pytest-asyncio", "pytest-timeout", ] [project.scripts] raydriver = "raydriver.cli.main:main" [tool.maturin] module-name = "raydriver" python-source = "python" [tool.pyo3-stub-gen] module-name = "raydriver" [tool.pyright] stubPath = "python" reportMissingModuleSource = false include = ["tests", "python"] [tool.ruff] line-length = 79 exclude = ["*.pyi"] [tool.ruff.lint] select = ["E", "F", "W", "I"] [tool.ruff.lint.isort] known-first-party = ["raydriver"] [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" # Fails a hung test instead of stalling the CI job for hours. timeout = 180 timeout_method = "thread" raydriver-0.5.1/python/raydriver/__init__.py000064400000000000000000000010061046102023000160740ustar00"""Raydriver — Rust-native machine drivers for Rayforge. Rust-native machine drivers exposed via PyO3. Drivers live entirely in Rust (protocol parsing, flow control, transports, session state machines); the pure-Python packages under ``raydriver`` delegate to the compiled module. Submodules ---------- - raydriver.grbl — GRBL device sessions, transports and value types """ import raydriver.raydriver as _raydriver # type: ignore[import-untyped] def __getattr__(name): return getattr(_raydriver, name) raydriver-0.5.1/python/raydriver/__init__.pyi000064400000000000000000000012761046102023000162560ustar00# This file is automatically generated by pyo3_stub_gen # ruff: noqa: E501, F401, F403, F405 import builtins import typing from . import grbl __all__ = [ "grbl", "init_logging", ] def init_logging(filter: typing.Optional[builtins.str] = None, path: typing.Optional[builtins.str] = None) -> None: r""" Initialize the Rust-side logger (`log` crate) so session diagnostics reach stderr or, when *path* is given, a dedicated log file (opened in append mode). The filter defaults to ``warn`` and can be overridden with an ``env_logger`` filter string argument or via ``RUST_LOG`` (which always wins). Calling again re-targets output and replaces the filter. """ raydriver-0.5.1/python/raydriver/cli/__init__.py000064400000000000000000000000711046102023000166440ustar00"""User-facing command line interface (see cli.main).""" raydriver-0.5.1/python/raydriver/cli/main.py000064400000000000000000000251051046102023000160360ustar00"""Command line interface for testing raydriver against devices. Runs a G-code file against a real GRBL device over serial, or against the built-in firmware emulator for hardware-free dry runs, showing live progress while the job executes: raydriver run job.gcode --port /dev/ttyUSB0 raydriver run job.gcode --port /dev/ttyUSB0 --log-file job.log raydriver run job.gcode --emulator raydriver status --port /dev/ttyUSB0 --seconds 5 Session diagnostics (stall detection, liveness, unexpected device lines) go to stderr by default; ``--log-file`` redirects them to a dedicated file so they never interleave with the progress display. Verbosity follows ``RUST_LOG`` (e.g. ``RUST_LOG=debug``). """ import argparse import asyncio import os import sys import time import raydriver.raydriver as _native from raydriver.grbl import GrblSession, MockTransport, SessionEvent from raydriver.grbl.parser import strip_gcode_comments from raydriver.grbl.types import DeviceState def _fmt_seconds(seconds): minutes, secs = divmod(int(seconds), 60) return f"{minutes:02d}:{secs:02d}" class RunView: """Single-line live progress renderer for a streaming job. On a TTY the display is a ``\\r``-animated single line. When stdout is redirected (no TTY), updates are newline-terminated and rate-limited so interleaved log lines stay intact. """ def __init__(self, total_lines, out=None): self.total = total_lines self.out = out self.acked = 0 self.state: DeviceState | None = None self.had_error = False self.started = time.monotonic() self._last_render = 0.0 self.finished = False self._stream = self._stdout() self._tty = bool(getattr(self._stream, "isatty", lambda: False)()) self._min_interval = 0.05 if self._tty else 1.0 def _stdout(self): return self.out if self.out is not None else sys.stdout def progress(self, line_index): self.acked = max(self.acked, line_index + 1) self.render() def on_state(self, state: DeviceState): self.state = state if state.error is not None: self.had_error = True self.render() def render(self, force=False): if self.finished and not force: return now = time.monotonic() if not force and now - self._last_render < self._min_interval: return self._last_render = now elapsed = now - self.started frac = self.acked / self.total if self.total else 1.0 bar_width = 20 filled = int(bar_width * frac) bar = "#" * filled + "-" * (bar_width - filled) parts = [f" {self.acked}/{self.total} [{bar}] {frac:6.1%}"] if self.state is not None: mpos = ",".join( f"{v:.3f}" if v is not None else " - " for v in self.state.machine_pos ) state = self.state.status.name feed = ( f" F{self.state.feed_rate}" if self.state.feed_rate is not None else "" ) parts.append(f"{state} MPos:{mpos}{feed}") if frac > 0: eta = elapsed / frac - elapsed parts.append(f"eta {_fmt_seconds(eta)}") parts.append(f"{_fmt_seconds(elapsed)}") line = " | ".join(parts) if self._tty: print(f"\r{line:<100}", end="", flush=True, file=self._stream) else: # Newline-terminated so redirected logs and progress # lines never share a physical line. self._stream.write(line + "\n") self._stream.flush() def finish(self, success, message=""): self.finished = False self.render(force=True) self.finished = True elapsed = time.monotonic() - self.started print(file=self._stdout()) status = "done" if success else f"ABORTED — {message}" print( f" {status}: {self.acked}/{self.total} lines acknowledged " f"in {_fmt_seconds(elapsed)}", file=self._stdout(), ) def _open_session(args, on_event): poll = not getattr(args, "no_poll", False) config = {"poll_status_while_running": poll} if args.emulator: from raydriver.emulator import GrblEmulator mock = MockTransport() emulator = GrblEmulator(mock, speed_factor=args.speed_factor) session = GrblSession.with_transport( mock, config=config, dialect={}, event_callback=on_event, ) return session, asyncio.ensure_future(emulator.run()) return ( GrblSession( config={ "port": args.port, "baudrate": args.baudrate, **config, }, dialect={}, event_callback=on_event, ), None, ) async def cmd_run(args) -> int: with open(args.file) as handle: text = handle.read() lines = [raw for raw in text.splitlines() if strip_gcode_comments(raw)] if not lines: print("error: no G-code lines in file", file=sys.stderr) return 2 view = RunView(len(lines)) connected = asyncio.get_running_loop().create_future() def on_event(event: SessionEvent): match event: case SessionEvent.StateChanged(): view.on_state(event.state) case SessionEvent.ConnectionStatusChanged(status="CONNECTED") if ( not connected.done() ): connected.set_result(True) case SessionEvent.ConnectionStatusChanged( status="ERROR", message=message ) if not connected.done(): connected.set_exception( ConnectionError(message or "connection failed") ) session, device_task = _open_session(args, on_event) print( "Connecting to " + ("emulated device" if args.emulator else args.port) + " ..." ) try: await session.connect() try: await asyncio.wait_for(connected, timeout=args.timeout) except asyncio.TimeoutError: print("error: device did not respond", file=sys.stderr) return 1 info = await session.execute_interactive_command("$I") print("Device: " + " | ".join(info)) op_map = {i: i for i in range(len(lines))} await session.run(text, op_map, [], view.progress) # Read the state snapshot synchronously: the events that # carry the error may still be in flight when run() returns. error = session.state.error success = view.acked >= len(lines) and error is None message = "job did not complete" if error is not None: message = f"{error.title}: {error.description}" view.finish(success, message) return 0 if success else 1 except asyncio.CancelledError: print("\nInterrupted — cancelling job ...") await asyncio.shield(session.cancel(emergency=True)) view.finish(False, "interrupted") raise finally: try: await asyncio.shield(session.disconnect()) except Exception: # noqa: BLE001 - teardown best effort pass if device_task is not None: device_task.cancel() async def cmd_status(args) -> int: def on_event(event: SessionEvent): match event: case SessionEvent.StateChanged(): state = event.state mpos = ",".join( f"{v:8.3f}" if v is not None else " - " for v in state.machine_pos ) error = f" ! {state.error.title}" if state.error else "" print( f"{time.strftime('%H:%M:%S')} {state.status.name:8}" f" MPos:{mpos}{error}" ) session, device_task = _open_session(args, on_event) try: await session.connect() await asyncio.sleep(args.seconds) finally: await session.disconnect() if device_task is not None: device_task.cancel() return 0 def build_parser(): parser = argparse.ArgumentParser( prog="raydriver", description="Test raydriver GRBL sessions against real " "devices or the built-in firmware emulator.", ) subparsers = parser.add_subparsers(dest="command", required=True) def common(run_parser): run_parser.add_argument( "--port", default="/dev/ttyUSB0", help="serial port" ) run_parser.add_argument( "--baudrate", type=int, default=115200, help="baud rate" ) run_parser.add_argument( "--emulator", action="store_true", help="run against the built-in GRBL firmware emulator " "instead of a serial port", ) run_parser.add_argument( "--speed-factor", type=float, default=2000.0, help="emulator motion speed multiplier (emulator only)", ) run_parser.add_argument( "--log-file", default=None, help="write Rust-side session diagnostics to this file " "(default: stderr). Level defaults to info for the file " "and can be overridden with RUST_LOG", ) run_parser = subparsers.add_parser( "run", help="stream a G-code file to the device" ) common(run_parser) run_parser.add_argument("file", help="G-code file to execute") run_parser.add_argument( "--timeout", type=float, default=10.0, help="seconds to wait for the device to connect", ) run_parser.add_argument( "--no-poll", action="store_true", help="disable status polling during the job", ) run_parser.set_defaults(func=cmd_run) status_parser = subparsers.add_parser( "status", help="connect and print live status reports" ) common(status_parser) status_parser.add_argument( "--seconds", type=float, default=5.0, help="duration to watch" ) status_parser.set_defaults(func=cmd_status) return parser async def amain(argv) -> int: args = build_parser().parse_args(argv) if args.log_file and "RUST_LOG" not in os.environ: # Diagnostics belong in the log file; info level by default. os.environ["RUST_LOG"] = "info" _native.init_logging(path=args.log_file) return await args.func(args) def main() -> None: try: sys.exit(asyncio.run(amain(sys.argv[1:]))) except KeyboardInterrupt: sys.exit(130) if __name__ == "__main__": main() raydriver-0.5.1/python/raydriver/emulator.py000064400000000000000000000711541046102023000162000ustar00"""A GRBL 1.1h firmware emulator. This is *not* a mock: it models the observable behavior of real Grbl firmware — the character-counting RX buffer, the 15-block planner with deferred acknowledgements, realtime command interception, modal G-code state, motion simulation with feed-rate timing, alarms, homing, probing and the ``$`` system commands — so the driver is exercised against the same protocol dynamics it sees on hardware. Used by the test suite and by ``raydriver run --emulator`` for hardware-free dry runs. Timing: all motion durations are scaled by ``speed_factor`` (default 2000x) so jobs finish in milliseconds while preserving the ordering and buffering semantics of real motion. The welcome banner is emitted when the first byte arrives after a (dark) boot — modeling the DTR reset a USB-serial open causes on real boards. """ import asyncio import math import random import re GRBL_VERSION = "1.1h" RX_BUFFER_SIZE = 128 PLANNER_BLOCKS = 15 LINE_BUFFER_SIZE = 70 # Grbl 1.1 default EEPROM settings. DEFAULT_SETTINGS = { "0": 10, "1": 25, "2": 0, "3": 0, "4": 0, "5": 0, "6": 0, "10": 3, "11": 0.020, "12": 0.002, "13": 0, "20": 0, "21": 0, "22": 0, "23": 0, "24": 250.0, "25": 500.0, "26": 250, "27": 1.000, "30": 1000.0, "31": 0.0, "32": 0, "100": 250.000, "101": 250.000, "102": 250.000, "110": 500.000, "111": 500.000, "112": 500.000, "120": 10.000, "121": 10.000, "122": 10.000, "130": 400.000, "131": 300.000, "132": 100.000, } WELCOME = f"Grbl {GRBL_VERSION} ['$' for help]\r\n".encode() ALARM_LOCK_MSG = b"[MSG:'$H'|'$X' to unlock]\r\n" _WORD_RE = re.compile(r"([A-Z])\s*([-+]?[0-9]*\.?[0-9]+)") _SETTING_RE = re.compile(r"^\$([^=]+)=(.*)$") def _fmt(value): return f"{value:.3f}" class _GcodeError(Exception): def __init__(self, code): super().__init__(f"error:{code}") self.code = code class _Block: """One planner entry: motion, dwell, homing or probe motion.""" def __init__( self, start, target, feed, base_seconds, is_jog=False, is_dwell=False, is_home=False, probe_touch=None, ): self.start = start self.target = target self.feed = feed self.base_seconds = base_seconds self.is_jog = is_jog self.is_dwell = is_dwell self.is_home = is_home self.probe_touch: list[float] | None = probe_touch self.is_probe = False class GrblEmulator: """GRBL 1.1h emulator driving a MockTransport. Runs as an asyncio task: consumes the bytes the session writes, executes them with firmware-faithful semantics and pushes response bytes back. Tests interact through the attributes: - ``settings``: the $-setting store (mutable at runtime). - ``speed_factor``: motion time divisor (raise to speed up). - ``silent``: when True, the device ignores everything (dead). - ``probe_touch``: machine-space (x, y, z) contact point for G38.2; None means the probe never triggers. - ``mpos``: current machine position in mm. - ``rx_overrun``: True when the host overflowed the RX buffer (a protocol violation by the host side). """ TICK = 0.002 def __init__(self, mock, speed_factor=2000.0, fragment_output=False): self.mock = mock self.speed_factor = speed_factor self.fragment_output = fragment_output self.silent = False self.rx_overrun = False self.tx_log = bytearray() self.settings = dict(DEFAULT_SETTINGS) self.wcs = {f"G5{n}": [0.0, 0.0, 0.0] for n in range(4, 10)} self.active_wcs = "G54" self.mpos: list[float] = [0.0, 0.0, 0.0] self._planned_pos: list[float] = [0.0, 0.0, 0.0] self.feed = 0.0 self.spindle = 0 self.probe_touch = None self.last_prb = (0.0, 0.0, 0.0, 0) self._state = "Idle" self._hold_requested = False self._modal = { "motion": None, "distance": "G90", "units": "G21", "plane": "G17", "feed_mode": "G94", "spindle": "M5", "coolant": "M9", } self._planner = [] self._current_block = None self._block_elapsed = 0.0 self._line = [] self._line_overflow = False self._pending_lines = [] self._rx_used = 0 self._report_count = 0 self._wco_dirty = True self._welcomed = False self._last_tick = None self._out_rng = random.Random(1234) # ------------------------------------------------------------ state @property def state(self): if self._state == "Hold": return "Hold:0" return self._state def _wco(self): return self.wcs[self.active_wcs] def _report_units(self): return self.settings["13"] != 0 def _to_report(self, value_mm): if self._report_units(): return value_mm / 25.4 return value_mm def _set_alarm(self, code, message=None): self._state = "Alarm" self._planner.clear() self._current_block = None self._pending_lines.clear() self._line = [] self._line_overflow = False self._rx_used = 0 self._hold_requested = False self._planned_pos = list(self.mpos) self._out(f"ALARM:{code}\r\n".encode()) if message: self._out(message) # ---------------------------------------------------------- runtime async def run(self): self._last_tick = self._now() while True: for chunk in self.mock.take_new_sent(): self._feed_bytes(chunk) self._tick() await asyncio.sleep(self.TICK) @staticmethod def _now(): return asyncio.get_running_loop().time() def _out(self, data: bytes): self.tx_log.extend(data) if self.silent: return if self.fragment_output: pos = 0 while pos < len(data): size = self._out_rng.randint(1, 12) self.mock.push(data[pos : pos + size]) pos += size else: self.mock.push(data) # ------------------------------------------------- serial byte feed def _feed_bytes(self, data: bytes): for byte in data: self._feed_byte(byte) def _feed_byte(self, byte: int): if self.silent: return # Opening the serial port toggles DTR and resets the # controller: the first byte arrives right as the firmware # (re)boots and prints its banner. if not self._welcomed: self._welcomed = True self._out(WELCOME) # Realtime commands are intercepted before the line buffer. if byte == 0x18: self._soft_reset() return char = chr(byte) if char == "?": self._status_report() return if char == "~": self._cycle_resume() return if char == "!": self._feed_hold() return if 0x84 <= byte <= 0x8B: # overrides: accepted, no-op here return if byte in (0x0A, 0x0D): line = "".join(self._line) self._line = [] self._rx_used -= len(line) if self._line_overflow: self._line_overflow = False self._out(b"error:1\r\n") return if line: self._pending_lines.append(line) self._rx_used += len(line) + 1 return if len(self._line) >= LINE_BUFFER_SIZE - 1: # Real Grbl discards the rest of an oversized line and # reports line overflow on the newline. self._line_overflow = True return self._line.append(char) self._rx_used += 1 if self._rx_used > RX_BUFFER_SIZE: self.rx_overrun = True # ---------------------------------------------------------- realtime def _soft_reset(self): was_moving = ( self._current_block is not None or self._planner or self._state in ("Run", "Jog", "Home", "Hold") ) self._planner.clear() self._current_block = None self._pending_lines.clear() self._line = [] self._line_overflow = False self._rx_used = 0 self._hold_requested = False self._planned_pos = list(self.mpos) self._out(WELCOME) if was_moving or self._state == "Alarm": self._state = "Alarm" self._out(ALARM_LOCK_MSG) else: self._state = "Idle" def _feed_hold(self): if self._current_block is not None: self._hold_requested = True self._state = "Hold" if self._current_block.is_jog: # Real Grbl cancels a jog on feed hold. self._current_block = None self._planner.clear() self._planned_pos = list(self.mpos) def _cycle_resume(self): if self._state == "Hold": self._hold_requested = False if self._current_block is not None or self._planner: self._state = "Run" else: self._state = "Idle" def _status_report(self): self._report_count += 1 fields = [] if self.settings["10"] & 1: fields.append( "MPos:" + ",".join(_fmt(self._to_report(v)) for v in self.mpos) ) else: wco = self._wco() fields.append( "WPos:" + ",".join( _fmt(self._to_report(self.mpos[i] - wco[i])) for i in range(3) ) ) if self.settings["10"] & 2: rx_avail = RX_BUFFER_SIZE - max(self._rx_used, 0) fields.append( f"Bf:{PLANNER_BLOCKS - len(self._planner)},{rx_avail}" ) block = self._current_block if block is not None and not (block.is_dwell or block.is_home): fields.append( f"FS:{int(round(self.feed))},{int(round(self.spindle))}" ) if self._wco_dirty or self._report_count % 10 == 1: wco = [self._to_report(v) for v in self._wco()] fields.append("WCO:" + ",".join(_fmt(v) for v in wco)) self._wco_dirty = False self._out(f"<{self.state}|{'|'.join(fields)}>\r\n".encode()) # ------------------------------------------------------------- tick def _tick(self): now = self._now() elapsed = now - ( self._last_tick if self._last_tick is not None else now ) self._last_tick = now if elapsed < 0: elapsed = 0.0 # Parse and acknowledge queued lines, exactly like Grbl's # protocol loop does while motion is running. self._drain_pending_lines() if ( self._current_block is None and not self._hold_requested and self._state != "Alarm" ): if self._planner: self._current_block = self._planner.pop(0) self._block_elapsed = 0.0 block = self._current_block if block.feed and not (block.is_dwell or block.is_home): self.feed = block.feed self._state = ( "Jog" if block.is_jog else "Home" if block.is_home else "Run" ) elif self._state in ("Run", "Jog", "Home"): self._state = "Idle" if self._current_block is None or self._hold_requested: return self._block_elapsed += elapsed block = self._current_block if self._block_elapsed >= self._block_duration(block): self.mpos = list(block.target) self._current_block = None self._finish_block(block) else: frac = self._block_elapsed / self._block_duration(block) for i in range(3): self.mpos[i] = ( block.start[i] + (block.target[i] - block.start[i]) * frac ) def _finish_block(self, block): if block.is_home: self._state = "Idle" return if block.probe_touch is not None: self.mpos = list(block.probe_touch) self._report_probe(success=True) self._ack(b"ok\r\n") elif block.is_probe: self._report_probe(success=False) self._ack(b"ok\r\n") self._set_alarm( 4, b"[MSG:Probe fail - Probe did not contact within travel]\r\n", ) elif block.is_dwell: return def _report_probe(self, success): prb = [self._to_report(v) for v in self.mpos] flag = 1 if success else 0 self.last_prb = (*self.mpos, flag) self._out( ( "[PRB:" + ",".join(_fmt(v) for v in prb) + f":{flag}]\r\n" ).encode() ) def _drain_pending_lines(self): """Execute queued lines while the planner accepts them. Like real Grbl, a line that produces motion waits when the planner is full — and everything behind it waits too, which is exactly what the host's character-counting flow control relies on. """ while self._pending_lines: line = self._pending_lines[0] produces_motion, execute = self._plan_line(line) if produces_motion and len(self._planner) >= PLANNER_BLOCKS: break self._pending_lines.pop(0) self._rx_used -= len(line) + 1 execute() if self._state == "Alarm": break # -------------------------------------------------------- line exec def _plan_line(self, raw: str): """Pre-analyze a line: does it consume planner space?""" def produces(): stripped = raw.strip().upper() if stripped.startswith("$J="): return True for letter, value, _raw in self._parse_words(raw): if letter == "G" and value in (0, 1, 2, 3, 4, 38.2): return True return False return produces(), lambda: self._execute_line(raw) @staticmethod def _parse_words(raw: str): line = raw # Strip comments like real Grbl. line = re.sub(r"\([^)]*\)", "", line) if ";" in line: line = line[: line.index(";")] line = line.strip().upper() words = [] for match in _WORD_RE.finditer(line): words.append( (match.group(1), float(match.group(2)), match.group(2)) ) return words def _execute_line(self, raw: str): words = self._parse_words(raw) stripped = raw.strip().upper() if not words and not stripped: self._ack(b"ok\r\n") return if stripped.startswith("$J="): self._exec_jog(words) return if stripped.startswith("$"): self._exec_system(stripped) return if self._state == "Alarm": self._ack(b"error:9\r\n") return try: self._exec_gcode(words) except _GcodeError as exc: self._ack(f"error:{exc.code}\r\n".encode()) def _ack(self, data: bytes): self._out(data) # ------------------------------------------------------ system cmds def _exec_system(self, stripped: str): if stripped == "$$": for key in sorted(self.settings, key=int): value = self.settings[key] if isinstance(value, float): self._out(f"${key}={value:.3f}\r\n".encode()) else: self._out(f"${key}={value}\r\n".encode()) self._ack(b"ok\r\n") return if stripped == "$I": self._out(f"[VER:{GRBL_VERSION}.EMULATOR:]\r\n".encode()) self._out( f"[OPT:V,{PLANNER_BLOCKS},{RX_BUFFER_SIZE}]\r\n".encode() ) self._ack(b"ok\r\n") return if stripped == "$G": modal = self._modal self._out( "[{} {} {} {} {} {} {} T0 F{:.3f} S{}]\r\n".format( self.active_wcs, modal["plane"], modal["units"], modal["distance"], modal["feed_mode"], modal["spindle"], modal["coolant"], self.feed, self.spindle, ).encode() ) self._ack(b"ok\r\n") return if stripped == "$#": for slot in ("G54", "G55", "G56", "G57", "G58", "G59"): values = self.wcs[slot] self._out( "[{}:{}]\r\n".format( slot, ",".join(_fmt(self._to_report(v)) for v in values), ).encode() ) self._out(b"[G28:0.000,0.000,0.000]\r\n") self._out(b"[G30:0.000,0.000,0.000]\r\n") self._out(b"[G92:0.000,0.000,0.000]\r\n") self._out(b"[TLO:0.000]\r\n") prb = self.last_prb self._out( "[PRB:{},{},{}:{}]\r\n".format( *(_fmt(self._to_report(v)) for v in prb[:3]), prb[3], ).encode() ) self._ack(b"ok\r\n") return if stripped == "$X": was_alarm = self._state == "Alarm" self._state = "Idle" if was_alarm: self._out(b"[MSG:Caution: Unlocked]\r\n") self._ack(b"ok\r\n") return if stripped == "$H" or stripped.startswith("$H "): if self.settings["22"] == 0: self._ack(b"error:5\r\n") return self._planner.append( _Block( list(self._planned_pos), [0.0, 0.0, 0.0], self.settings["25"], self._motion_duration( self._planned_pos, [0.0, 0.0, 0.0], self.settings["25"] ), is_home=True, ) ) self._planned_pos = [0.0, 0.0, 0.0] self._ack(b"ok\r\n") return match = _SETTING_RE.match(stripped) if match: key, raw_value = match.groups() try: value = ( float(raw_value) if "." in raw_value else int(raw_value) ) except ValueError: self._ack(b"error:5\r\n") return if key not in self.settings: self._ack(b"error:20\r\n") return self.settings[key] = value self._ack(b"ok\r\n") return self._ack(b"error:20\r\n") def _exec_jog(self, words): if self._state == "Alarm": self._ack(b"error:9\r\n") return try: target, feed, has_axis = self._resolve_motion( words, default_motion="G1" ) except _GcodeError as exc: self._ack(f"error:{exc.code}\r\n".encode()) return if target is None or not has_axis: self._ack(b"error:16\r\n") return self._planner.append( _Block( list(self._planned_pos), target, feed, self._motion_duration(self._planned_pos, target, feed), is_jog=True, ) ) self._planned_pos = list(target) self._ack(b"ok\r\n") # ----------------------------------------------------------- gcode def _resolve_motion(self, words, default_motion=None): """Compute the machine-space target and feed for a motion. ``default_motion`` supplies a modal motion for commands that omit one (jog commands default to G1, like real Grbl). """ distance = self._modal["distance"] scale = 25.4 if self._modal["units"] == "G20" else 1.0 use_machine = False motion = self._modal["motion"] feed = self.feed offset = self._wco() axis_words = {} for letter, value, _raw in words: if letter == "G": if value in (0, 1, 2, 3): motion = f"G{int(value)}" elif value == 38.2: motion = "G38.2" elif value == 53: use_machine = True elif value == 90: distance = "G90" elif value == 91: distance = "G91" elif value == 20: scale = 25.4 elif value == 21: scale = 1.0 elif letter == "F": feed = value * scale elif letter in "XYZ": axis_words[letter] = value * scale if motion is None: motion = default_motion if motion is None: return None, feed, False # Targets resolve against the position the planner will have # reached when this block runs — not the live position, which # lags behind while motion is in flight. base = self._planned_pos target = [] for i, axis in enumerate("XYZ"): if axis not in axis_words: target.append(base[i]) continue word = axis_words[axis] if motion == "G38.2" or distance == "G91": target.append(base[i] + word) else: target.append(word + (0.0 if use_machine else offset[i])) return target, feed, bool(axis_words) def _exec_gcode(self, words): motion_pending = None dwell = None wcs_set = None l_value = None for letter, value, _raw in words: if letter == "G": if value in (0, 1, 2, 3, 38.2): motion_pending = value elif value == 4: dwell = next((v for w, v, _ in words if w == "P"), 0.0) elif value == 10: l_value = next((v for w, v, _ in words if w == "L"), None) wcs_set = next((v for w, v, _ in words if w == "P"), None) elif value in (17, 18, 19): self._modal["plane"] = f"G{int(value)}" elif value in (20, 21): self._modal["units"] = f"G{int(value)}" elif value in (90, 91): self._modal["distance"] = f"G{int(value)}" elif value in (93, 94): self._modal["feed_mode"] = f"G{int(value)}" elif value in (54, 55, 56, 57, 58, 59): self.active_wcs = f"G{int(value)}" self._wco_dirty = True elif value in (28, 30, 92, 53): pass else: raise _GcodeError(20) elif letter == "M": if value in (3, 4, 5): self._modal["spindle"] = f"M{int(value)}" if value == 5: self.spindle = 0 else: self.spindle = self._pending_spindle(words) elif value in (8, 9): self._modal["coolant"] = f"M{int(value)}" elif value in (0, 1, 2): pass else: raise _GcodeError(20) elif letter == "F": self.feed = value * ( 25.4 if self._modal["units"] == "G20" else 1.0 ) elif letter == "S": self.spindle = int(value) if wcs_set is not None and l_value == 2: slot = f"G{53 + int(wcs_set)}" values = [0.0, 0.0, 0.0] idx = {"X": 0, "Y": 1, "Z": 2} scale = 25.4 if self._modal["units"] == "G20" else 1.0 for letter, value, _raw in words: if letter in idx: values[idx[letter]] = value * scale self.wcs[slot] = values self._wco_dirty = True if dwell is not None: self._planner.append( _Block( list(self.mpos), list(self.mpos), self.feed, dwell, is_dwell=True, ) ) if motion_pending is not None: if not self._exec_motion(motion_pending, words): # The line's ack is deferred (probe motion) or # suppressed (alarm): no ok here. return self._ack(b"ok\r\n") @staticmethod def _pending_spindle(words): return int(next((v for w, v, _ in words if w == "S"), 0)) def _exec_motion(self, motion_value, words): """Queue a motion block. Returns True when the caller should ack the line, False when the ack is deferred to block completion (probing) or suppressed (soft-limit alarm), matching real Grbl. """ target, feed, has_axis = self._resolve_motion(words) if motion_value == 38.2: if not has_axis: raise _GcodeError(33) self._exec_probe(target, feed) return False if target is None or not has_axis: # Modal-only line like "G1 F600": just updates state. if ( motion_value == 1 and feed <= 0 and (self._modal["motion"] == "G1") ): raise _GcodeError(22) self._modal["motion"] = f"G{int(motion_value)}" return True if motion_value == 1 and feed <= 0: raise _GcodeError(22) self._check_soft_limits(target) if self._state == "Alarm": return False self._modal["motion"] = f"G{int(motion_value)}" if motion_value == 0: rapid = max(self.settings["110"], self.settings["111"], 1.0) feed = rapid elif motion_value in (2, 3): if not any(w in ("I", "J", "R") for w, _v, _r in words): raise _GcodeError(35) self._planner.append( _Block( list(self._planned_pos), target, feed, self._motion_duration(self._planned_pos, target, feed), ) ) self._planned_pos = list(target) return True def _exec_probe(self, target, feed): self._check_soft_limits(target) if self._state == "Alarm": return touch = None if self.probe_touch is not None: for i in range(3): lo = min(self.mpos[i], target[i]) hi = max(self.mpos[i], target[i]) contact = self.probe_touch[i] if lo - 1e-9 <= contact <= hi + 1e-9: touch = list(self.probe_touch) break end = target if touch is None else touch block = _Block( list(self._planned_pos), end, feed, self._motion_duration(self._planned_pos, end, feed), probe_touch=touch, ) block.is_probe = True self._planner.append(block) self._planned_pos = list(end) def _check_soft_limits(self, target): if self.settings["20"] == 0 or self.settings["22"] == 0: return for i, travel_key in enumerate(("130", "131", "132")): travel = self.settings[travel_key] if target[i] < -1e-6 or target[i] > travel + 1e-6: # Grbl always acks a processed line: the move is # rejected with an error status after the alarm is # raised (error:5 = travel violation). self._set_alarm(2) self._ack(b"error:5\r\n") return def _motion_duration(self, start, target, feed): """Un-scaled block duration at speed_factor 1 (seconds).""" dist = math.dist(start, target) if dist == 0: return 0.0 feed = max(feed, 1.0) return dist / (feed / 60.0) def _block_duration(self, block): """Current wall-clock duration of *block* under the live speed factor (so tests can accelerate mid-job).""" return max(block.base_seconds / self.speed_factor, 0.001) raydriver-0.5.1/python/raydriver/grbl/__init__.py000064400000000000000000000002141046102023000170220ustar00import raydriver.raydriver as _raydriver # type: ignore[import-untyped] def __getattr__(name): return getattr(_raydriver.grbl, name) raydriver-0.5.1/python/raydriver/grbl/__init__.pyi000064400000000000000000000242501046102023000172010ustar00# This file is automatically generated by pyo3_stub_gen # ruff: noqa: E501, F401, F403, F405 r""" GRBL device drivers implemented in Rust. The central type is GrblSession: it owns the connection lifecycle (handshake, status polling, reconnect), interactive commands, and job streaming with character-counting flow control, stall detection and deadlock recovery. Dialects remain data owned by Rayforge: resolved command templates are passed into the session. """ import builtins import collections.abc import typing from . import parser from . import types __all__ = [ "GrblSession", "MockTransport", "SessionEvent", "parser", "types", ] @typing.final class GrblSession: r""" A GRBL device session: connection lifecycle, streaming and interactive commands, entirely in Rust. All async methods return asyncio futures; a typed `SessionEvent` is delivered to `event_callback(event)` on the event loop thread. """ @property def state(self) -> types.DeviceState: r""" Current snapshot of the device state. """ @property def buffer_count(self) -> builtins.int: ... @property def rx_buffer_size(self) -> builtins.int: ... @property def pending_commands(self) -> builtins.list[builtins.str]: r""" Commands awaiting their 'ok' acknowledgement. """ @property def job_running(self) -> builtins.bool: ... @property def resource_uri(self) -> typing.Optional[builtins.str]: ... def __new__(cls, config: typing.Optional[dict], dialect: typing.Optional[dict] = None, event_callback: typing.Optional[collections.abc.Callable[[SessionEvent], typing.Any]] = None) -> GrblSession: ... @staticmethod def with_transport(transport: MockTransport, config: typing.Optional[dict] = None, dialect: typing.Optional[dict] = None, event_callback: typing.Optional[collections.abc.Callable[[SessionEvent], typing.Any]] = None) -> GrblSession: r""" Build a session around a [`MockTransport`] (for tests). """ def connect(self) -> typing.Any: r""" Open the connection and start the background tasks. """ def disconnect(self) -> typing.Any: r""" Stop all background tasks and close the transport. """ def execute_interactive_command(self, command: builtins.str) -> typing.Any: r""" Send a command and synchronously await its full response, blocking any other command from interleaving. """ def execute_command(self, command: builtins.str) -> typing.Any: r""" Queue a command for asynchronous processing and await its response. """ def run(self, gcode: builtins.str, op_line_map: typing.Optional[typing.Mapping[builtins.int, builtins.int]] = None, op_estimates: typing.Optional[typing.Sequence[builtins.float]] = None, progress_callback: typing.Optional[typing.Any] = None) -> typing.Any: r""" Stream a G-code program using the character-counting protocol. `op_line_map` maps G-code line indices to op indices for progress reporting; `op_estimates` holds per-op estimated durations (seconds) used to scale per-line stall timeouts. """ def run_raw(self, machine_code: builtins.str) -> typing.Any: r""" Execute a raw G-code string; GRBL realtime commands (?, ~, !) are sent via the control path instead of the stream. """ def set_hold(self, hold: builtins.bool = True) -> typing.Any: r""" Pause (feed hold) or resume the device. """ def cancel(self, emergency: builtins.bool = False) -> typing.Any: r""" Abort the running job: soft reset, queue cleanup and safety shutdown. """ def home(self, axes: typing.Optional[typing.Sequence[builtins.str]] = None, active_wcs: builtins.str = 'G54') -> typing.Any: r""" Home all axes (`axes` is None) or the named axes (e.g. `["X"]`), then re-activate the active WCS via the toggle workaround. """ def move_to(self, speed: builtins.float, x: builtins.float, y: builtins.float) -> typing.Any: r""" Move to an absolute position (mm) at `speed` (mm/min). """ def jog(self, speed: builtins.float, deltas: typing.Sequence[tuple[builtins.str, builtins.float]]) -> typing.Any: r""" Jog by relative deltas (mm) at `speed` (mm/min). """ def select_tool(self, tool_number: builtins.int) -> typing.Any: ... def set_power(self, power: typing.Optional[builtins.float] = None) -> typing.Any: r""" Set laser power. `power` is the absolute S value; `None` (or zero) turns the laser off. """ def set_focus_power(self, power: typing.Optional[builtins.float] = None) -> typing.Any: r""" Set laser power for focus mode; `None` waits for pending jogs to finish and turns the laser off. """ def read_settings(self) -> typing.Any: r""" Read `$$` into `(key, raw_value)` pairs. """ def write_setting(self, key: builtins.str, value: builtins.str) -> typing.Any: ... def set_wcs_offset(self, wcs_slot: builtins.str, x: builtins.float, y: builtins.float, z: typing.Optional[builtins.float]) -> typing.Any: ... def read_wcs_offsets(self) -> typing.Any: r""" Read `$#` WCS offsets, converted to mm when the device reports inches. """ def read_parser_state(self) -> typing.Any: r""" Read the `$G` parser state to determine the active WCS. """ def run_probe_cycle(self, axis_letter: builtins.str, max_travel: builtins.float, feed_rate: builtins.float) -> typing.Any: r""" Run a probe cycle (G38.2); returns the trigger position in mm or None on failure. """ def detect_unit_system(self) -> typing.Any: r""" Infer the device's unit system ("metric"/"imperial") from the `$13` setting. """ def update_dialect(self, dialect: dict) -> None: r""" Swap the dialect command templates at runtime. """ def __repr__(self) -> builtins.str: ... @typing.final class MockTransport: r""" An in-memory byte transport for tests. Feed bytes to the session with `push()` (as if the device had sent them), inspect everything the session sent with `sent()`, and simulate link state with `set_connected()` / `set_write_error()`. """ def __new__(cls) -> MockTransport: ... def push(self, data: typing.Sequence[builtins.int]) -> None: r""" Deliver bytes to the session as if the device sent them. """ def sent(self) -> builtins.list[bytes]: r""" All bytes written to the device so far. """ def take_new_sent(self) -> builtins.list[bytes]: r""" Writes recorded since the previous call (or the last `clear_sent`): a monotonic cursor, so clearing the log never skips or replays chunks. This is what device emulators should consume. """ def clear_sent(self) -> None: r""" Clear the recorded sent bytes. """ def set_connected(self, connected: builtins.bool) -> None: r""" Simulate connect/disconnect of the physical link. """ def set_write_error(self, message: typing.Optional[builtins.str] = None) -> None: r""" Fail subsequent writes with this message (and mark the link down), simulating an unplugged cable. """ def __repr__(self) -> builtins.str: ... class SessionEvent: r""" One typed session event, delivered to `event_callback`. """ @typing.final class StateChanged(SessionEvent): __match_args__ = ("state",) @property def state(self) -> types.DeviceState: ... def __new__(cls, state: types.DeviceState) -> SessionEvent.StateChanged: ... @typing.final class ConnectionStatusChanged(SessionEvent): __match_args__ = ("status", "message",) @property def status(self) -> builtins.str: ... @property def message(self) -> typing.Optional[builtins.str]: ... def __new__(cls, status: builtins.str, message: typing.Optional[builtins.str]) -> SessionEvent.ConnectionStatusChanged: ... @typing.final class CommandStatusChanged(SessionEvent): __match_args__ = ("status", "message",) @property def status(self) -> builtins.str: ... @property def message(self) -> typing.Optional[builtins.str]: ... def __new__(cls, status: builtins.str, message: typing.Optional[builtins.str]) -> SessionEvent.CommandStatusChanged: ... @typing.final class JobFinished(SessionEvent): __match_args__ = () def __new__(cls) -> SessionEvent.JobFinished: ... @typing.final class ProbeStatusChanged(SessionEvent): __match_args__ = ("message",) @property def message(self) -> builtins.str: ... def __new__(cls, message: builtins.str) -> SessionEvent.ProbeStatusChanged: ... @typing.final class WcsUpdated(SessionEvent): __match_args__ = ("offsets",) @property def offsets(self) -> builtins.dict[builtins.str, tuple[builtins.float, builtins.float, builtins.float]]: ... def __new__(cls, offsets: typing.Mapping[builtins.str, tuple[builtins.float, builtins.float, builtins.float]]) -> SessionEvent.WcsUpdated: ... @typing.final class ConfigChanged(SessionEvent): __match_args__ = ("key", "value",) @property def key(self) -> builtins.str: ... @property def value(self) -> builtins.int: ... def __new__(cls, key: builtins.str, value: builtins.int) -> SessionEvent.ConfigChanged: ... @typing.final class RawRx(SessionEvent): __match_args__ = ("line",) @property def line(self) -> builtins.str: ... def __new__(cls, line: builtins.str) -> SessionEvent.RawRx: ... @typing.final class RawTx(SessionEvent): __match_args__ = ("line",) @property def line(self) -> builtins.str: ... def __new__(cls, line: builtins.str) -> SessionEvent.RawTx: ... ... raydriver-0.5.1/python/raydriver/grbl/parser/__init__.py000064400000000000000000000002231046102023000203160ustar00import raydriver.raydriver as _raydriver # type: ignore[import-untyped] def __getattr__(name): return getattr(_raydriver.grbl.parser, name) raydriver-0.5.1/python/raydriver/grbl/parser/__init__.pyi000064400000000000000000000116561046102023000205030ustar00# This file is automatically generated by pyo3_stub_gen # ruff: noqa: E501, F401, F403, F405 r""" Pure parsers for GRBL response data: status reports, build info, settings, WCS offsets, probe results and raw-output classification. All functions are side-effect free. """ import builtins from raydriver.grbl import types import typing __all__ = [ "alarm_code_to_device_error", "detect_unit_system_from_settings", "error_code_to_device_error", "extract_device_name", "extract_device_name_from_output", "gcode_to_p_number", "is_grbl_output", "is_report_in_inches", "parse_grbl_parser_state", "parse_grbl_settings", "parse_msg", "parse_opt_flags", "parse_opt_info", "parse_probe_line", "parse_setting_pairs", "parse_state", "parse_ver", "parse_version", "parse_wcs_line", "split_realtime_commands", "strip_gcode_comments", "version_supports_single_axis_homing", ] def alarm_code_to_device_error(alarm_code: builtins.str) -> types.DeviceError: r""" Look up an alarm code in the GRBL alarm table. """ def detect_unit_system_from_settings(settings_lines: typing.Sequence[builtins.str]) -> typing.Optional[builtins.str]: r""" Infer the unit system ("metric"/"imperial") from `$$` lines. """ def error_code_to_device_error(error_code: builtins.str) -> types.DeviceError: r""" Look up an error code in the GRBL error table. """ def extract_device_name(build_info: typing.Sequence[builtins.str]) -> builtins.str: r""" Extract a human-readable device name from build info lines. """ def extract_device_name_from_output(data: typing.Sequence[builtins.int]) -> typing.Optional[builtins.str]: r""" Extract a device name from raw serial output, falling back to the first informative banner line. """ def gcode_to_p_number(wcs_slot: builtins.str) -> typing.Optional[builtins.int]: r""" Convert a G-code WCS name (e.g. "G54") to its P-number. """ def is_grbl_output(data: typing.Sequence[builtins.int]) -> builtins.bool: r""" True when raw serial output identifies a Grbl device. """ def is_report_in_inches(settings_lines: typing.Sequence[builtins.str]) -> builtins.bool: r""" True when the `$13` (Report in inches) flag is set. """ def parse_grbl_parser_state(response_lines: typing.Sequence[builtins.str]) -> typing.Optional[builtins.str]: r""" Parse `$G` response lines to find the active WCS (G54-G59). """ def parse_grbl_settings(lines: typing.Sequence[builtins.str]) -> builtins.dict[builtins.str, builtins.float]: r""" Parse `$$` lines into `{key: value}` floats. """ def parse_msg(line: builtins.str) -> typing.Optional[tuple[builtins.str, builtins.str]]: r""" Parse a `[MSG:key:value]` line into `(key, value)`. """ def parse_opt_flags(line: builtins.str) -> typing.Optional[builtins.str]: r""" Extract the compile option flags from an `[OPT:...]` line. """ def parse_opt_info(line: builtins.str) -> typing.Optional[builtins.int]: r""" Extract the RX buffer size from an `[OPT:...]` line. """ def parse_probe_line(line: builtins.str) -> typing.Optional[tuple[tuple[builtins.float, builtins.float, builtins.float], builtins.bool]]: r""" Parse a `[PRB:x,y,z:success]` line into `(pos, success)`. """ def parse_setting_pairs(lines: typing.Sequence[builtins.str]) -> builtins.list[tuple[builtins.str, builtins.str]]: r""" Parse `$$` lines into ordered `(key, raw_value)` pairs. """ def parse_state(state_str: builtins.str, default: typing.Optional[types.DeviceState] = None, report_in_inches: builtins.bool = False) -> types.DeviceState: r""" Parse a GRBL status string like `` into a DeviceState, using *default* as the base. When *report_in_inches* is true, positions are converted back to mm. """ def parse_ver(line: builtins.str) -> typing.Optional[tuple[builtins.str, typing.Optional[builtins.str]]]: r""" Parse a `[VER:...]` line into `(version, build_name)`. """ def parse_version(response_lines: typing.Sequence[builtins.str]) -> typing.Optional[builtins.str]: r""" Parse `$I` response lines to extract the firmware version. """ def parse_wcs_line(line: builtins.str) -> typing.Optional[tuple[builtins.str, tuple[builtins.float, builtins.float, builtins.float]]]: r""" Parse a `[G5x:x,y,z]` line from `$#` output. """ def split_realtime_commands(lines: typing.Sequence[builtins.str]) -> tuple[builtins.list[builtins.str], builtins.list[builtins.str]]: r""" Split command lines into `(gcode, realtime)` groups. """ def strip_gcode_comments(line: builtins.str) -> builtins.str: r""" Strip G-code comments from a line. """ def version_supports_single_axis_homing(version_num: builtins.float, version_letter: builtins.str = '') -> builtins.bool: r""" True if a GRBL version supports single-axis homing (> 1.1 or 1.1g and newer). """ raydriver-0.5.1/python/raydriver/grbl/parser/py.typed000064400000000000000000000000001046102023000176750ustar00raydriver-0.5.1/python/raydriver/grbl/py.typed000064400000000000000000000000001046102023000164010ustar00raydriver-0.5.1/python/raydriver/grbl/types/__init__.py000064400000000000000000000002221046102023000201650ustar00import raydriver.raydriver as _raydriver # type: ignore[import-untyped] def __getattr__(name): return getattr(_raydriver.grbl.types, name) raydriver-0.5.1/python/raydriver/grbl/types/__init__.pyi000064400000000000000000000066671046102023000203610ustar00# This file is automatically generated by pyo3_stub_gen # ruff: noqa: E501, F401, F403, F405 r""" Value types shared by the GRBL drivers: DeviceState, DeviceStatus and DeviceError. These mirror the identically-named types in Rayforge so the Python shell can convert losslessly. """ import builtins import enum import typing __all__ = [ "DeviceError", "DeviceState", "DeviceStatus", ] @typing.final class DeviceError: r""" An error or alarm with code, title and description. """ @property def code(self) -> builtins.int: ... @property def title(self) -> builtins.str: ... @property def description(self) -> builtins.str: ... def __eq__(self, other: builtins.object, /) -> builtins.bool: ... def __new__(cls, code: builtins.int, title: builtins.str, description: builtins.str) -> DeviceError: ... def __repr__(self) -> builtins.str: ... @typing.final class DeviceState: r""" The complete state of a device at a moment in time. """ @property def status(self) -> DeviceStatus: ... @status.setter def status(self, value: DeviceStatus) -> None: ... @property def error(self) -> typing.Optional[DeviceError]: ... @error.setter def error(self, value: typing.Optional[DeviceError]) -> None: ... @property def machine_pos(self) -> builtins.list[typing.Optional[builtins.float]]: r""" Machine position in mm; entries may be `None`. """ @machine_pos.setter def machine_pos(self, value: typing.Sequence[typing.Optional[builtins.float]]) -> None: ... @property def work_pos(self) -> builtins.list[typing.Optional[builtins.float]]: r""" Work position in mm; entries may be `None`. """ @work_pos.setter def work_pos(self, value: typing.Sequence[typing.Optional[builtins.float]]) -> None: ... @property def wco(self) -> builtins.list[typing.Optional[builtins.float]]: r""" Work coordinate offset in mm; entries may be `None`. """ @wco.setter def wco(self, value: typing.Sequence[typing.Optional[builtins.float]]) -> None: ... @property def feed_rate(self) -> typing.Optional[builtins.int]: ... @feed_rate.setter def feed_rate(self, value: typing.Optional[builtins.int]) -> None: ... @property def spindle_speed(self) -> typing.Optional[builtins.int]: ... @spindle_speed.setter def spindle_speed(self, value: typing.Optional[builtins.int]) -> None: ... @property def buffer_available(self) -> typing.Optional[builtins.int]: ... @buffer_available.setter def buffer_available(self, value: typing.Optional[builtins.int]) -> None: ... @property def buffer_rx_available(self) -> typing.Optional[builtins.int]: ... @buffer_rx_available.setter def buffer_rx_available(self, value: typing.Optional[builtins.int]) -> None: ... def __eq__(self, other: builtins.object, /) -> builtins.bool: ... def __new__(cls) -> DeviceState: ... def __repr__(self) -> builtins.str: ... @typing.final class DeviceStatus(enum.Enum): r""" Machine state as reported in Grbl status reports. """ UNKNOWN = ... IDLE = ... RUN = ... HOLD = ... JOG = ... ALARM = ... DOOR = ... CHECK = ... HOME = ... SLEEP = ... TOOL = ... QUEUE = ... LOCK = ... UNLOCK = ... CYCLE = ... TEST = ... @property def name(self) -> builtins.str: ... def __repr__(self) -> builtins.str: ... raydriver-0.5.1/python/raydriver/grbl/types/py.typed000064400000000000000000000000001046102023000175450ustar00raydriver-0.5.1/python/raydriver/py.typed000064400000000000000000000000001046102023000154530ustar00raydriver-0.5.1/rustfmt.toml000064400000000000000000000000171046102023000130350ustar00max_width = 80 raydriver-0.5.1/src/bin/stub_gen.rs000064400000000000000000000001341046102023000141470ustar00fn main() { let stub = raydriver::stub_info().unwrap(); stub.generate().unwrap(); } raydriver-0.5.1/src/grbl/dialect.rs000064400000000000000000000155231046102023000141340ustar00//! Dialect command templates. //! //! Dialects remain data owned by Rayforge: callers pass in resolved //! command templates (Python `str.format` strings) and this crate //! formats them. The formatting subset supports `{name}` and //! `{name:.Nf}` — everything Rayforge's dialects use for interactive //! commands. use std::collections::HashMap; /// The subset of dialect templates used for interactive commands. #[derive(Debug, Clone)] pub struct GrblDialect { pub home_all: String, pub home_axis: String, pub move_to: String, pub jog: String, pub clear_alarm: String, pub laser_on: String, pub laser_off: String, pub focus_laser_on: String, pub tool_change: String, pub set_wcs_offset: String, pub probe_cycle: String, /// Resolved safety-off commands (laser off, air assist off, …), /// sent after a cancel so no persistent output stays energized. pub safety_off_commands: Vec, /// Optional additional emergency stop command, appended to the /// safety shutdown when `cancel(emergency=True)`. pub emergency_stop: Option, } impl Default for GrblDialect { fn default() -> Self { Self { home_all: "$H".to_string(), home_axis: "$H{axis_letter}".to_string(), move_to: "$J=G90 G21 F{speed} X{x} Y{y}".to_string(), jog: "$J=G91 G21 F{speed}".to_string(), clear_alarm: "$X".to_string(), laser_on: "M4 S{power:.0f}".to_string(), laser_off: "M5".to_string(), focus_laser_on: "M3 S{power:.0f}".to_string(), tool_change: "T{tool_number}".to_string(), set_wcs_offset: "G10 L2 P{p_num} X{x} Y{y} Z{z}".to_string(), probe_cycle: "G38.2 {axis_letter}{max_travel} F{feed_rate}" .to_string(), safety_off_commands: vec!["M5".to_string()], emergency_stop: None, } } } impl GrblDialect { /// Build from a template dict keyed by the field names. Missing /// keys fall back to the default template. pub fn from_map(map: &HashMap) -> Self { let mut dialect = Self::default(); let get = |key: &str| map.get(key).cloned(); if let Some(v) = get("home_all") { dialect.home_all = v; } if let Some(v) = get("home_axis") { dialect.home_axis = v; } if let Some(v) = get("move_to") { dialect.move_to = v; } if let Some(v) = get("jog") { dialect.jog = v; } if let Some(v) = get("clear_alarm") { dialect.clear_alarm = v; } if let Some(v) = get("laser_on") { dialect.laser_on = v; } if let Some(v) = get("laser_off") { dialect.laser_off = v; } if let Some(v) = get("focus_laser_on") { dialect.focus_laser_on = v; } if let Some(v) = get("tool_change") { dialect.tool_change = v; } if let Some(v) = get("set_wcs_offset") { dialect.set_wcs_offset = v; } if let Some(v) = get("probe_cycle") { dialect.probe_cycle = v; } if let Some(v) = get("emergency_stop") { dialect.emergency_stop = Some(v); } dialect } /// Format the `set_wcs_offset` template, dropping the Z word /// when `z` is `None` (machines without a Z axis). pub fn format_wcs_offset( &self, p_num: i64, x: f64, y: f64, z: Option, ) -> String { let mut template = self.set_wcs_offset.clone(); if z.is_none() { template = template .replace(" Z{z}", "") .replace("Z{z}", " ") .trim() .to_string(); } format_template( &template, &[ ("p_num", Arg::Int(p_num)), ("x", Arg::Length(x)), ("y", Arg::Length(y)), ("z", Arg::Length(z.unwrap_or(0.0))), ], ) .expect("valid template") } } /// A template formatting argument. #[derive(Debug, Clone)] pub enum Arg { /// Renders like a Python float: `10.5`, `10.0`. Length(f64), /// Renders like a Python value passed through /// `_to_machine_speed`: whole numbers render as integers /// (`1500`), fractional values as floats. Speed(f64), /// Renders like a Python int. Int(i64), /// Renders as-is. Str(String), } /// Format a Python-integral float the way Python's `str()` does /// (`10.0` stays `10.0`). pub(crate) fn python_float_repr(v: f64) -> String { if v.is_finite() && v.fract() == 0.0 && v.abs() < 1e16 { format!("{v:.1}") } else { format!("{v}") } } impl Arg { fn render(&self, spec: Option<&str>) -> String { let Some(spec) = spec else { return match self { Arg::Length(v) => python_float_repr(*v), Arg::Speed(v) => { if v.is_finite() && v.fract() == 0.0 && v.abs() < 1e16 { format!("{}", *v as i64) } else { format!("{v}") } } Arg::Int(v) => format!("{v}"), Arg::Str(v) => v.clone(), }; }; let num = match self { Arg::Length(v) | Arg::Speed(v) => *v, Arg::Int(v) => *v as f64, Arg::Str(_) => { return String::new(); } }; if let Some(digits) = spec.strip_prefix('.').and_then(|s| s.strip_suffix('f')) { match digits.parse::() { Ok(n) => format!("{num:.n$}"), Err(_) => format!("{num}"), } } else { format!("{num}") } } } /// Format `template` with `{name}` / `{name:.Nf}` placeholders. pub fn format_template( template: &str, args: &[(&str, Arg)], ) -> Result { let mut out = String::with_capacity(template.len()); let mut rest = template; while let Some(start) = rest.find('{') { out.push_str(&rest[..start]); let after = &rest[start + 1..]; let Some(end) = after.find('}') else { return Err(format!( "unterminated placeholder in template {template:?}" )); }; let placeholder = &after[..end]; let (name, spec) = match placeholder.split_once(':') { Some((n, s)) => (n, Some(s)), None => (placeholder, None), }; let Some((_, arg)) = args.iter().find(|(n, _)| *n == name) else { return Err(format!("no value for placeholder {{{name}}}")); }; out.push_str(&arg.render(spec)); rest = &after[end + 1..]; } out.push_str(rest); Ok(out) } raydriver-0.5.1/src/grbl/errors.rs000064400000000000000000000250421046102023000140400ustar00//! Grbl error and alarm code tables. //! //! Source: use super::types::DeviceError; pub fn error_code_to_device_error(error_code: &str) -> DeviceError { match error_code.parse::() { Err(_) => DeviceError::new( -1, "Unknown Error", "Invalid error code reported by machine.", ), Ok(code) => grbl_error_for_code(code).unwrap_or(DeviceError::new( code, "Unknown Error", "The machine reported an unrecognized error code. Check your machine and firmware documentation.", )), } } pub fn alarm_code_to_device_error(alarm_code: &str) -> DeviceError { match alarm_code.parse::() { Err(_) => DeviceError::new( -1, "Unknown Alarm", "Invalid alarm code reported by machine.", ), Ok(code) => grbl_alarm_for_code(code).unwrap_or(DeviceError::new( code, "Unknown Alarm", "The machine reported an unrecognized alarm code. Check your machine and firmware documentation.", )), } } fn grbl_error_for_code(code: i32) -> Option { let e = match code { 1 => DeviceError::new( 1, "Missing Command Letter", "G-code commands need a letter followed by a value. The command letter was not found.", ), 2 => DeviceError::new( 2, "Invalid Number Format", "The value is missing or not in the correct numeric format. Check your G-code syntax.", ), 3 => DeviceError::new( 3, "Unknown Command", "This Grbl setting command is not recognized or supported. Check the command syntax.", ), 4 => DeviceError::new( 4, "Negative Value", "A positive number is required here, but a negative value was received.", ), 5 => DeviceError::new( 5, "Homing Disabled", "Homing is not enabled in settings. Enable homing ($22=1) to use this feature.", ), 6 => DeviceError::new( 6, "Pulse Time Too Short", "Minimum step pulse time must be greater than 3 microseconds. Check setting $0.", ), 7 => DeviceError::new( 7, "Memory Error", "Settings reset to defaults due to a memory read failure. Reconfigure your settings if needed.", ), 8 => DeviceError::new( 8, "Machine Busy", "This command can only be used when the machine is idle. Wait for the current job to finish.", ), 9 => DeviceError::new( 9, "Commands Locked", "Cannot send commands while in alarm or jog mode. Clear the alarm state first.", ), 10 => DeviceError::new( 10, "Homing Required", "Soft limits cannot be enabled without homing also enabled. Enable homing first ($22=1).", ), 11 => DeviceError::new( 11, "Line Too Long", "The command line has too many characters and was ignored. Check your file formatting.", ), 12 => DeviceError::new( 12, "Setting Too High", "This setting exceeds the maximum step rate supported. Use a lower value.", ), 13 => DeviceError::new( 13, "Door Open", "The safety door was detected as open. Close the door and resume operation.", ), 14 => DeviceError::new( 14, "Line Too Long", "Build info or startup line exceeds storage limit. Shorten the line.", ), 15 => DeviceError::new( 15, "Target Out of Range", "Jog target is beyond the machine's travel limits. Move to a position within range.", ), 16 => DeviceError::new( 16, "Invalid Jog Command", "Jog command is missing '=' or contains prohibited G-code. Check the jog syntax.", ), 17 => DeviceError::new( 17, "Laser Mode Error", "Laser mode requires PWM output to work. Check your hardware configuration.", ), 18 => DeviceError::new( 18, "Spindle Not Running", "A motion command was issued but the spindle is not running. Start the spindle before motion.", ), 19 => DeviceError::new( 19, "Spindle Speed Mismatch", "The current spindle speed does not match the speed required by the command. Wait for the spindle to reach the target speed.", ), 20 => DeviceError::new( 20, "Unsupported Command", "This G-code command is not supported by the machine. Check your post-processor settings.", ), 21 => DeviceError::new( 21, "Conflicting Commands", "Multiple commands from the same group found on one line. Remove the duplicate command.", ), 22 => DeviceError::new( 22, "Feed Rate Missing", "Set a feed rate before using motion commands. Add an F command to specify speed.", ), 23 => DeviceError::new( 23, "Integer Required", "This command requires a whole number value. Remove any decimal points.", ), 24 => DeviceError::new( 24, "Axis Conflict", "Multiple commands trying to use the same axis. Simplify the command.", ), 25 => DeviceError::new( 25, "Duplicate Word", "The same G-code word appears more than once. Remove the duplicate.", ), 26 => DeviceError::new( 26, "Missing Axis", "This command requires XYZ axis coordinates. Add the missing axis values.", ), 27 => DeviceError::new( 27, "Line Number Out of Range", "Line number must be between 1 and 9,999,999. Use a valid line number.", ), 28 => DeviceError::new( 28, "Missing Value", "This command requires a P or L value. Add the missing parameter.", ), 29 => DeviceError::new( 29, "Unsupported Coordinate", "Only G54-G59 coordinate systems are supported. Use one of these instead.", ), 30 => DeviceError::new( 30, "Wrong Motion Mode", "G53 command requires G0 or G1 motion mode. Set the correct motion mode first.", ), 31 => DeviceError::new( 31, "Unused Axis Words", "Axis words present but G80 cancel is active. Remove the unused axis words.", ), 32 => DeviceError::new( 32, "Missing Arc Data", "G2/G3 arc command needs XYZ coordinates. Add the axis values for the selected plane.", ), 33 => DeviceError::new( 33, "Invalid Target", "Cannot create this arc or probe to current position. Check the target coordinates.", ), 34 => DeviceError::new( 34, "Arc Geometry Error", "Arc calculation failed. Try breaking the arc into smaller pieces or use IJK offset instead.", ), 35 => DeviceError::new( 35, "Missing Arc Offset", "G2/G3 arc command needs IJK offset values. Add the missing offset for the selected plane.", ), 36 => DeviceError::new( 36, "Unused Words", "Some G-code words in this line are not used by any command. Remove the unused words.", ), 37 => DeviceError::new( 37, "Wrong Axis for Offset", "Tool length offset only works on the configured axis (usually Z-axis). Check your settings.", ), 38 => DeviceError::new( 38, "Tool Number Too High", "Tool number exceeds the maximum supported value. Use a valid tool number.", ), _ => return None, }; Some(e) } fn grbl_alarm_for_code(code: i32) -> Option { let e = match code { 1 => DeviceError::new( 1, "Hard Limit", "A hard limit switch was triggered. The machine has stopped and needs to be reset. Check for obstructions and verify your limit switches.", ), 2 => DeviceError::new( 2, "Soft Limit", "The machine would move beyond its configured travel limits. Check that your work area and coordinate offsets are correct.", ), 3 => DeviceError::new( 3, "Abort Cycle", "The currently running job was cancelled while in motion. Reset the machine to continue.", ), 4 => DeviceError::new( 4, "Probe Fail — Initial", "The probe did not make contact before the maximum travel distance was reached. Check the probe wiring and positioning.", ), 5 => DeviceError::new( 5, "Probe Fail — Final", "The probe failed to retract to the target position after contact. Check the probe configuration.", ), 6 => DeviceError::new( 6, "Homing Fail — Reset", "Homing was not able to complete because the machine is in an alarm state. Clear the alarm and try again.", ), 7 => DeviceError::new( 7, "Homing Fail — Approach", "The homing cycle failed to find the switch within the configured travel distance. Check your switch wiring and pull-off settings.", ), 8 => DeviceError::new( 8, "Homing Fail — Pulloff", "The homing cycle failed to successfully pull off the switch after contact. Increase the pull-off distance or check the switch.", ), 9 => DeviceError::new( 9, "Home Without Limits", "Homing was commanded but limit switches are not configured. Enable limit switches first.", ), 10 => DeviceError::new( 10, "Homing Fail — Dual Axis", "Homing failed on a dual-axis configuration. One or both axes did not reach their limit switches. Check your limit switch wiring and configuration.", ), _ => return None, }; Some(e) } raydriver-0.5.1/src/grbl/flow.rs000064400000000000000000000354441046102023000135020ustar00//! GRBL character-counting flow control. //! //! Port of Rayforge's `GrblSerialTransport`: tracks how many bytes //! are outstanding against the device's RX buffer and provides //! backpressure so callers never overflow it. Also performs the //! low-level response extraction — `ok`/`error:` are pulled out of //! the raw byte stream for buffer accounting *before* line-based //! parsing, so acknowledgements interleaved with status reports by //! buggy firmware are never lost. //! //! All state lives behind a `std::sync::Mutex` held only for short, //! await-free critical sections; waiters park on `Notify` objects so //! a reader parsing incoming bytes is never blocked by a sender //! waiting for buffer space. use std::collections::VecDeque; use std::sync::{LazyLock, Mutex}; use std::time::Duration; use regex::bytes::Regex; use tokio::sync::Notify; /// Error returned by the timed waits: the deadline elapsed. /// /// (tokio's `Elapsed` cannot be constructed outside the crate.) #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TimedOut; /// Default RX buffer size for standard Grbl 1.1 (128-byte buffer, /// safe limit 127). Custom firmwares may report a smaller size via /// the $I OPT line. pub const DEFAULT_GRBL_RX_BUFFER_SIZE: usize = 127; static OK_ACK_RE: LazyLock = LazyLock::new(|| Regex::new(r"ok\r*\n").expect("valid regex")); /// A command sent with buffer accounting, awaiting its ack. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PendingCommand { pub length: usize, pub op_index: Option, pub command: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GrblResponseType { Ok, Error, Line, } #[derive(Debug, Clone)] pub struct GrblResponse { pub rtype: GrblResponseType, pub text: String, pub pending: Option, } #[derive(Debug, Default)] struct FlowInner { rx_count: usize, rx_size: Option, status_buffer: Vec, pending: VecDeque, acked_since_flush: bool, drained_since_flush: bool, } /// Buffer accounting and low-level response parsing state. #[derive(Debug, Default)] pub struct FlowControl { inner: Mutex, space_available: Notify, pending_drained: Notify, } impl FlowControl { pub fn new() -> Self { Self::default() } pub fn rx_buffer_size(&self) -> usize { self.inner .lock() .unwrap() .rx_size .unwrap_or(DEFAULT_GRBL_RX_BUFFER_SIZE) } pub fn buffer_count(&self) -> usize { self.inner.lock().unwrap().rx_count } pub fn pending_queue(&self) -> VecDeque { self.inner.lock().unwrap().pending.clone() } /// True when *needed* more bytes would overflow the RX buffer. pub fn needs_space(&self, needed: usize) -> bool { let inner = self.inner.lock().unwrap(); inner.rx_count + needed > inner.rx_size.unwrap_or(DEFAULT_GRBL_RX_BUFFER_SIZE) } /// Update the device's RX buffer size (as reported by the $I /// OPT line). Must be called before streaming begins. pub fn set_rx_buffer_size(&self, size: usize) { if size > 0 { log::info!("GRBL RX buffer size set to {size} bytes"); self.inner.lock().unwrap().rx_size = Some(size); } } /// Parse raw serial bytes into GRBL responses. /// /// Scans for 'ok' and 'error:' responses in the byte stream, /// handling buffer accounting for them, before line-splitting. /// Returns responses for non-ok/error lines (status reports, /// alarms, info messages) as well. pub fn parse_incoming(&self, data: &[u8]) -> Vec { let mut responses = Vec::new(); { let mut inner = self.inner.lock().unwrap(); inner.status_buffer.extend_from_slice(data); Self::extract_acks_from_buffer(&mut inner, &mut responses); while let Some(nl) = inner.status_buffer.iter().position(|b| *b == b'\n') { let message_bytes: Vec = inner.status_buffer.drain(..=nl).collect(); let Ok(message) = String::from_utf8(message_bytes) else { log::warn!("Dropped invalid UTF-8 bytes"); continue; }; for line in message.trim().lines() { if line.is_empty() { continue; } if line == "ok" { let pending = Self::ack_ok_locked(&mut inner); inner.acked_since_flush = true; inner.drained_since_flush |= inner.pending.is_empty(); responses.push(GrblResponse { rtype: GrblResponseType::Ok, text: "ok".to_string(), pending, }); } else if line.starts_with("error:") { Self::ack_ok_locked(&mut inner); inner.acked_since_flush = true; inner.drained_since_flush |= inner.pending.is_empty(); responses.push(GrblResponse { rtype: GrblResponseType::Error, text: line.to_string(), pending: None, }); } else { responses.push(GrblResponse { rtype: GrblResponseType::Line, text: line.to_string(), pending: None, }); } } } } // Notifications are deferred to flush_notifications() so the // session finishes processing the responses (progress // callbacks, request completion) before a streaming task // wakes up and observes the freed buffer space — mirroring // asyncio's single-threaded scheduling in the Python driver. responses } /// Deliver the flow-control notifications accumulated by /// [`FlowControl::parse_incoming`]. The session calls this /// after processing the parsed responses. pub fn flush_notifications(&self) { let (space, drained) = { let mut inner = self.inner.lock().unwrap(); ( std::mem::take(&mut inner.acked_since_flush), std::mem::take(&mut inner.drained_since_flush), ) }; if space { self.signal_space_available(); } if drained { self.notify_drained(); } } fn extract_acks_from_buffer( inner: &mut FlowInner, responses: &mut Vec, ) { while let Some(m) = OK_ACK_RE.find(&inner.status_buffer) { let start = m.start(); let end = m.end(); inner.status_buffer.drain(start..end); let pending = Self::ack_ok_locked(inner); inner.acked_since_flush = true; inner.drained_since_flush |= inner.pending.is_empty(); responses.push(GrblResponse { rtype: GrblResponseType::Ok, text: "ok".to_string(), pending, }); } // Detect NULL-byte corrupted 'ok' (hardware fault). while let Some((start, end)) = Self::find_null_corrupted_ok(&inner.status_buffer) { inner.status_buffer.drain(start..end); log::error!( "HARDWARE FAULT DETECTED: A corrupted 'ok' \ acknowledgement with NULL bytes was received. This \ indicates a critical problem with the USB cable, \ electrical noise (EMI), or power supply. The hardware \ connection MUST be fixed for reliable operation." ); let pending = Self::ack_ok_locked(inner); inner.acked_since_flush = true; inner.drained_since_flush |= inner.pending.is_empty(); responses.push(GrblResponse { rtype: GrblResponseType::Ok, text: "ok".to_string(), pending, }); } const ERROR_MARKER: &[u8] = b"error:"; let mut search_from = 0usize; while let Some(rel) = find_subslice(&inner.status_buffer[search_from..], ERROR_MARKER) { let start = search_from + rel; let Some(end_rel) = inner.status_buffer[start..] .iter() .position(|b| *b == b'\n') else { break; }; let end = start + end_rel + 1; let error_bytes: Vec = inner.status_buffer.drain(start..end).collect(); let Ok(error_bytes) = String::from_utf8(error_bytes) else { continue; }; let error_text = error_bytes.trim().to_string(); log::debug!( "Extracted '{error_text}' from raw buffer \ (interleaved recovery)" ); Self::ack_ok_locked(inner); inner.acked_since_flush = true; inner.drained_since_flush |= inner.pending.is_empty(); responses.push(GrblResponse { rtype: GrblResponseType::Error, text: error_text, pending: None, }); search_from = start; } } /// Search `status_buffer` for a pattern like `o\0k\r*\n` where /// NULL bytes are interspersed in 'ok'. Returns `(start, end)` /// byte indices or `None`. fn find_null_corrupted_ok(buf: &[u8]) -> Option<(usize, usize)> { let len = buf.len(); let mut i = 0; while i < len { if buf[i] != b'o' { i += 1; continue; } let mut j = i + 1; while j < len && buf[j] == 0 { j += 1; } if j < len && buf[j] == b'k' && j > i + 1 { let mut k = j + 1; while k < len && buf[k] == b'\r' { k += 1; } if k < len && buf[k] == b'\n' { return Some((i, k + 1)); } } i += 1; } None } fn ack_ok_locked(inner: &mut FlowInner) -> Option { let pending = inner.pending.pop_front()?; log::debug!( "Buffer ack: freeing {} bytes for {:?} (op_index={:?}, \ remaining: {})", pending.length, pending.command, pending.op_index, inner.pending.len() ); inner.rx_count = inner.rx_count.saturating_sub(pending.length); Some(pending) } fn notify_drained(&self) { self.pending_drained.notify_waiters(); self.pending_drained.notify_one(); } /// Wake parked senders (e.g. on cancel). Uses permits so a /// waiter that has not yet registered still observes the wake-up /// (issue #428 in Rayforge). pub fn signal_space_available(&self) { self.space_available.notify_waiters(); self.space_available.notify_one(); } /// Wait until *needed* bytes fit into the RX buffer, at most /// `timeout`. pub async fn wait_for_space( &self, needed: usize, timeout: Duration, ) -> Result<(), TimedOut> { let deadline = tokio::time::Instant::now() + timeout; while self.needs_space(needed) { let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); if remaining.is_zero() { return Err(TimedOut); } let notified = self.space_available.notified(); if !self.needs_space(needed) { return Ok(()); } tokio::select! { _ = notified => {}, _ = tokio::time::sleep_until(deadline) => { if !self.needs_space(needed) { return Ok(()); } return Err(TimedOut); } } } Ok(()) } /// Wait until every pending command has been acknowledged. /// `timeout` of `None` waits indefinitely (mirroring Python's /// `pending_queue.join()`). pub async fn wait_pending_empty( &self, timeout: Option, ) -> Result<(), TimedOut> { let deadline = timeout.map(|t| tokio::time::Instant::now() + t); while !self.pending_queue().is_empty() { let notified = self.pending_drained.notified(); if self.pending_queue().is_empty() { return Ok(()); } let Some(deadline) = deadline else { notified.await; continue; }; let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); if remaining.is_zero() && !self.pending_queue().is_empty() { return Err(TimedOut); } tokio::select! { _ = notified => {}, _ = tokio::time::sleep_until(deadline) => { if self.pending_queue().is_empty() { return Ok(()); } return Err(TimedOut); } } } Ok(()) } /// Enqueue a pending command and account for its bytes. pub fn track_command( &self, length: usize, op_index: Option, command: &str, ) { let mut inner = self.inner.lock().unwrap(); inner.pending.push_back(PendingCommand { length, op_index, command: command.to_string(), }); inner.rx_count += length; } /// Reset flow-control state without clearing the parse buffer. pub fn reset_flow_control(&self) { { let mut inner = self.inner.lock().unwrap(); inner.rx_count = 0; inner.pending.clear(); } // Wake parked waiters rather than dropping the signal // objects: dropping would orphan tasks that are already // waiting, leaving them stranded until their stall timeout // fires (issue #428 in Rayforge). self.notify_drained(); self.signal_space_available(); } /// Reset all buffer state (cancel, reconnect, cleanup). pub fn reset(&self) { self.reset_flow_control(); self.inner.lock().unwrap().status_buffer.clear(); } } fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { if needle.is_empty() || haystack.len() < needle.len() { return None; } haystack .windows(needle.len()) .position(|window| window == needle) } raydriver-0.5.1/src/grbl/mod.rs000064400000000000000000000003671046102023000133060ustar00//! GRBL protocol implementation. pub mod dialect; pub mod errors; pub mod flow; pub mod parser; pub mod session; pub mod transport; pub mod types; pub use session::{ NoopEvents, SessionConfig, SessionError, SessionEvents, TransportKind, }; raydriver-0.5.1/src/grbl/parser.rs000064400000000000000000000410011046102023000140110ustar00//! Parsers for Grbl responses: status reports, build info, settings, //! WCS offsets, probe results and raw-output classification. //! //! This is a faithful port of Rayforge's `grbl_util.py` parsing //! helpers; behavior is byte-for-byte compatible. use std::collections::HashMap; use std::sync::LazyLock; use super::errors::{alarm_code_to_device_error, error_code_to_device_error}; use super::types::{ inches_to_mm, DeviceError, DeviceState, DeviceStatus, Pos, UnitSystem, }; /// Grbl realtime command characters. The firmware executes these /// immediately upon reception: they bypass the RX buffer, must never /// be queued behind buffered gcode, and never produce an 'ok' ack. pub const GRBL_REALTIME_COMMANDS: [&str; 3] = ["?", "~", "!"]; type Regex = regex::Regex; static POS_RE: LazyLock = LazyLock::new(|| { Regex::new( r":(-?\d+\.?\d*),(-?\d+\.?\d*)(?:,(-?\d+\.?\d*))?(?:,(-?\d+\.?\d*))?", ) .unwrap() }); static FS_RE: LazyLock = LazyLock::new(|| Regex::new(r"FS:(\d+),(\d+)").unwrap()); static BF_RE: LazyLock = LazyLock::new(|| Regex::new(r"Bf:(\d+),(\d+)").unwrap()); static GRBL_SETTING_RE: LazyLock = LazyLock::new(|| Regex::new(r"\$(\d+)=([\d\.-]+)").unwrap()); static WCS_RE: LazyLock = LazyLock::new(|| { Regex::new(r"\[(G5[4-9]):([\d\.-]+),([\d\.-]+)(?:,([\d\.-]+))?\]").unwrap() }); static PRB_RE: LazyLock = LazyLock::new(|| { Regex::new(r"\[PRB:([\d\.-]+),([\d\.-]+),([\d\.-]+):(\d)\]").unwrap() }); static GRBL_PARSER_STATE_RE: LazyLock = LazyLock::new(|| Regex::new(r".*(G5[4-9]).*").unwrap()); static GRBL_OPT_RE: LazyLock = LazyLock::new(|| Regex::new(r"\[OPT:([A-Z]+),(\d+),(\d+)\]").unwrap()); static GCODE_COMMENT_RE: LazyLock = LazyLock::new(|| Regex::new(r"\([^)]*\)").unwrap()); type BytesRegex = regex::bytes::Regex; static GRBL_DISCOVERY_RE: LazyLock = LazyLock::new(|| { BytesRegex::new( r"(?i)\bgrbl(?:hal)?\b|<(?:Idle|Run|Hold|Alarm|Home)[,|>]|\[(?:VER|OPT|MSG|GC):", ) .unwrap() }); /// True when raw serial output identifies a Grbl device. pub fn is_grbl_output(data: &[u8]) -> bool { GRBL_DISCOVERY_RE.is_match(data) } /// Split command lines into realtime commands and regular gcode. /// /// Returns `(gcode_lines, realtime_lines)`, preserving the order /// within each group. pub fn split_realtime_commands(lines: &[String]) -> (Vec, Vec) { let mut gcode = Vec::new(); let mut realtime = Vec::new(); for line in lines { if GRBL_REALTIME_COMMANDS.contains(&line.as_str()) { realtime.push(line.clone()); } else { gcode.push(line.clone()); } } (gcode, realtime) } /// Strip G-code comments from a line: everything after ';' and /// content between '(' and ')'. pub fn strip_gcode_comments(line: &str) -> String { let mut line = GCODE_COMMENT_RE.replace_all(line, "").to_string(); if let Some(idx) = line.find(';') { line.truncate(idx); } line.trim().to_string() } /// Converts a G-code WCS name (e.g. "G54") to its P-number. pub fn gcode_to_p_number(wcs_slot: &str) -> Option { let rest = wcs_slot.strip_prefix('G')?; let num: i64 = rest.parse().ok()?; let p_num = num - 53; if (1..=6).contains(&p_num) { Some(p_num) } else { None } } /// Parse a `[VER:...]` line into `(version, build_name)`. /// /// Handles both standard Grbl format (`1.1h.ORTUR`) and /// comma-separated format (`1.0.15,20240923`). Returns `None` if /// the line is not a VER line. pub fn parse_ver(line: &str) -> Option<(String, Option)> { let content = line.strip_prefix("[VER:")?; let content = content.trim_end_matches([':', ']']); if content.is_empty() { return None; } if let Some((version, _)) = content.split_once(',') { return Some((version.to_string(), None)); } let parts: Vec<&str> = content.split('.').collect(); match parts.len() { n if n >= 3 => Some(( format!("{}.{}", parts[0], parts[1]), Some(parts[2].to_string()), )), 2 => Some((format!("{}.{}", parts[0], parts[1]), None)), _ => Some((content.to_string(), None)), } } /// Parses '$I' output lines to extract the GRBL firmware version /// string (e.g. `1.1h`), or `None` if no VER line is found. pub fn parse_version(response_lines: &[String]) -> Option { response_lines .iter() .find_map(|line| parse_ver(line).map(|(version, _)| version)) } /// Parse `$$` response lines into `(key, value)` pairs. pub fn parse_grbl_settings(lines: &[String]) -> HashMap { let mut settings = HashMap::new(); for (key, value) in parse_setting_pairs(lines) { if let Ok(value) = value.parse::() { settings.insert(key, value); } } settings } /// Parse `$$` response lines into ordered `(key, raw_value_string)` /// pairs, preserving the raw text for settings UIs. pub fn parse_setting_pairs(lines: &[String]) -> Vec<(String, String)> { let mut pairs = Vec::new(); for line in lines { if let Some(caps) = GRBL_SETTING_RE.captures(line) { pairs.push((caps[1].to_string(), caps[2].to_string())); } } pairs } /// Inspect GRBL `$$` response lines and infer the device's unit /// system from the `$13` (Report in inches) setting. pub fn detect_unit_system_from_settings( settings_lines: &[String], ) -> Option { let report_inches = parse_grbl_settings(settings_lines).get("13").copied(); match report_inches { None => None, Some(v) if v as i64 != 0 => Some(UnitSystem::Imperial), Some(_) => Some(UnitSystem::Metric), } } /// True when GRBL's `$13` (Report in inches) flag is set. pub fn is_report_in_inches(settings_lines: &[String]) -> bool { parse_grbl_settings(settings_lines) .get("13") .is_some_and(|v| *v as i64 != 0) } /// Parse a `[MSG:key:value]` line into `(key, value)`. pub fn parse_msg(line: &str) -> Option<(String, String)> { let content = line.strip_prefix("[MSG:")?; let content = content.trim_end_matches(']'); let (key, value) = content.split_once(':')?; Some((key.trim().to_string(), value.trim().to_string())) } /// Extract a human-readable device name from build info lines. /// /// Checks `[MSG:machine:...]` lines first, then falls back to the /// VER line's build-info field (e.g. `[VER:1.1h.ORTUR:]` → `ORTUR`). pub fn extract_device_name(build_info: &[String]) -> String { for line in build_info { if let Some((key, value)) = parse_msg(line) { let key = key.to_ascii_lowercase(); if key == "machine" || key == "mechine" { return value; } } } for line in build_info { if let Some((_, Some(build_name))) = parse_ver(line) { return build_name; } } "Unknown Grbl Device".to_string() } /// Extract a human-readable device name from raw serial output, as /// captured during device discovery. /// /// Returns the machine name when the output carries one, otherwise /// the first informative banner line, and `None` when there is no /// usable line at all. pub fn extract_device_name_from_output(data: &[u8]) -> Option { let text = String::from_utf8_lossy(data); let lines: Vec = text.lines().map(|l| l.to_string()).collect(); let name = extract_device_name(&lines); if name != "Unknown Grbl Device" { return Some(name); } extract_grbl_banner(&lines) } fn extract_grbl_banner(lines: &[String]) -> Option { for raw in lines { let line = raw.trim(); if line.is_empty() || is_grbl_ack(line) { continue; } if line.starts_with('<') && line.ends_with('>') { continue; } return Some(line.chars().take(80).collect()); } None } fn is_grbl_ack(line: &str) -> bool { let stripped = line.trim_start_matches('\u{fffd}').trim_start_matches('\0'); stripped == "ok" || stripped.starts_with("error:") } /// Determines if a GRBL version supports single-axis homing. /// /// Support is assumed for versions > 1.1 or for 1.1g and newer. pub fn version_supports_single_axis_homing( version_num: f64, version_letter: &str, ) -> bool { if version_num > 1.1 { return true; } if (version_num - 1.1).abs() < f64::EPSILON { return !version_letter.is_empty() && version_letter.to_ascii_lowercase().as_str() >= "g"; } false } /// Extract the RX buffer size from an /// `[OPT:,,]` line. pub fn parse_opt_info(line: &str) -> Option { GRBL_OPT_RE .captures(line) .and_then(|caps| caps[3].parse::().ok()) } /// Extract the compile option flags from an /// `[OPT:,,]` line. pub fn parse_opt_flags(line: &str) -> Option { GRBL_OPT_RE.captures(line).map(|caps| caps[1].to_string()) } /// Parses the response from a '$G' command to find the active WCS. /// Example response: `[G54 G17 G21 G90 G94 M5 M9 T0 F0 S0]` pub fn parse_grbl_parser_state(response_lines: &[String]) -> Option { for line in response_lines { if let Some(caps) = GRBL_PARSER_STATE_RE.captures(line) { return Some(caps[1].to_string()); } } None } /// Parse a `[G5x:x,y,z]` line from `$#` output into /// `(slot, (x, y, z))`. pub fn parse_wcs_line(line: &str) -> Option<(String, (f64, f64, f64))> { let caps = WCS_RE.captures(line)?; let x: f64 = caps[2].parse().ok()?; let y: f64 = caps[3].parse().ok()?; let z: f64 = match caps.get(4) { Some(m) => m.as_str().parse().ok()?, None => 0.0, }; Some((caps[1].to_string(), (x, y, z))) } /// Parse a `[PRB:x,y,z:success]` line into `(pos, success)`. pub fn parse_probe_line(line: &str) -> Option<((f64, f64, f64), bool)> { let caps = PRB_RE.captures(line)?; let x: f64 = caps[1].parse().ok()?; let y: f64 = caps[2].parse().ok()?; let z: f64 = caps[3].parse().ok()?; let success = &caps[4] == "1"; Some(((x, y, z), success)) } fn parse_status_part(status_part: &str) -> (DeviceStatus, Option) { let mut parts = status_part.splitn(2, ':'); let status_name = parts.next().unwrap_or(""); let status = DeviceStatus::from_name(status_name); // Only `Alarm` uses the `:N` suffix for an actual error code. // For `Hold` and `Door` it is a sub-state indicator that must // NOT be treated as an error. let error_code = if status == DeviceStatus::Alarm { parts.next().map(str::to_string) } else { None }; (status, error_code) } fn parse_position_attribute(attrib: &str, pos_type: &str) -> Option { if !attrib.starts_with(&format!("{pos_type}:")) { return None; } POS_RE.captures(attrib).and_then(|caps| { let mut values: Vec> = Vec::with_capacity(4); for group in 1..=4 { let value = match caps.get(group) { Some(m) => m.as_str().parse::().ok(), None => Some(0.0), }; match value { Some(v) => values.push(Some(v)), None => return None, } } if caps.get(4).is_none() { values.truncate(3); } Some(values) }) } fn parse_feed_rate(attrib: &str) -> Option { let caps = FS_RE.captures(attrib)?; caps[1].parse().ok() } fn parse_buffer_state(attrib: &str) -> Option<(i64, i64)> { let caps = BF_RE.captures(attrib)?; let available = caps[1].parse().ok()?; let rx_available = caps[2].parse().ok()?; Some((available, rx_available)) } fn pos_from_inches(pos: &Pos) -> Pos { pos.iter().map(|v| v.map(inches_to_mm)).collect() } fn pad_pos(pos: &Pos, n: usize, default: f64) -> Vec> { let mut result = pos.clone(); while result.len() < n { result.push(Some(default)); } result } fn all_known(pos: &[Option]) -> bool { pos.iter().all(|v| v.is_some()) } /// Recalculate positions based on the GRBL equations for /// consistency. Also infers WCO if missing but both MPos and WPos /// are present. fn recalculate_positions( machine_pos: Pos, work_pos: Pos, wco: Pos, mpos_found: bool, wpos_found: bool, wco_found: bool, ) -> (Pos, Pos, Pos) { let n = machine_pos.len().max(work_pos.len()).max(wco.len()).max(3); let machine_pos = pad_pos(&machine_pos, n, 0.0); let mut work_pos = pad_pos(&work_pos, n, 0.0); let mut wco = pad_pos(&wco, n, 0.0); // 1. Infer WCO if explicitly missing but both MPos and WPos // exist: WCO = MPos - WPos. if mpos_found && wpos_found && !wco_found && all_known(&machine_pos) && all_known(&work_pos) { wco = (0..n) .map(|i| Some(machine_pos[i].unwrap() - work_pos[i].unwrap())) .collect(); } // 2. Recalculate missing positions based on what we have. if mpos_found && all_known(&machine_pos) && all_known(&wco) { work_pos = (0..n) .map(|i| Some(machine_pos[i].unwrap() - wco[i].unwrap())) .collect(); return (machine_pos, work_pos, wco); } if wpos_found && all_known(&work_pos) && all_known(&wco) { let mpos = (0..n) .map(|i| Some(work_pos[i].unwrap() + wco[i].unwrap())) .collect(); return (mpos, work_pos, wco); } (machine_pos, work_pos, wco) } /// Parse a GRBL status string like /// `` into a [`DeviceState`], /// using `default` as the base. /// /// When `report_in_inches` is true (GRBL `$13` set), positions are /// converted back to mm. pub fn parse_state( state_str: &str, default: &DeviceState, report_in_inches: bool, ) -> DeviceState { let mut state = default.clone(); let inner: &str = match state_str .strip_prefix('<') .and_then(|s| s.strip_suffix('>')) { Some(inner) => inner, None => return state, }; let mut status_part = ""; let mut attribs: Vec<&str> = Vec::new(); for part in inner.split('|') { if part.is_empty() { continue; } if status_part.is_empty() { status_part = part; } else { attribs.push(part); } } if !status_part.is_empty() { let (status, error_code) = parse_status_part(status_part); state.status = status; if let Some(code) = error_code { state.error = Some(if status == DeviceStatus::Alarm { alarm_code_to_device_error(&code) } else { error_code_to_device_error(&code) }); } } let mut mpos_found = false; let mut wpos_found = false; let mut wco_found = false; for attrib in attribs { if let Some(parsed) = parse_position_attribute(attrib, "MPos") { mpos_found = parsed.first().is_some_and(|v| v.is_some()); state.machine_pos = parsed; } else if let Some(parsed) = parse_position_attribute(attrib, "WPos") { wpos_found = parsed.first().is_some_and(|v| v.is_some()); state.work_pos = parsed; } else if let Some(parsed) = parse_position_attribute(attrib, "WCO") { wco_found = true; state.wco = parsed; } else if attrib.starts_with("FS:") { if let Some(feed_rate) = parse_feed_rate(attrib) { state.feed_rate = Some(feed_rate); } } else if attrib.starts_with("Bf:") { if let Some((available, rx_available)) = parse_buffer_state(attrib) { state.buffer_available = Some(available); state.buffer_rx_available = Some(rx_available); } } } if report_in_inches { if mpos_found { state.machine_pos = pos_from_inches(&state.machine_pos); } if wpos_found { state.work_pos = pos_from_inches(&state.work_pos); } if wco_found { state.wco = pos_from_inches(&state.wco); } } let (mpos, wpos, wco) = recalculate_positions( state.machine_pos.clone(), state.work_pos.clone(), state.wco.clone(), mpos_found, wpos_found, wco_found, ); state.machine_pos = mpos; state.work_pos = wpos; state.wco = wco; state } /// Returns the [`DeviceError`] for a given GRBL error code string. pub fn get_error(error_code: &str) -> DeviceError { error_code_to_device_error(error_code) } raydriver-0.5.1/src/grbl/session.rs000064400000000000000000002232441046102023000142130ustar00//! The GRBL session: connection lifecycle, handshake, status //! polling, interactive command queue, and job streaming with stall //! detection and deadlock recovery. //! //! This is a faithful port of Rayforge's `GrblSerialDriver` against //! an async byte [`Transport`]. All Python-facing notifications go //! through the [`SessionEvents`] trait so the core stays pyo3-free. use std::collections::HashMap; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock}; use std::time::Duration; use tokio::sync::mpsc; use tokio::task::JoinHandle; use super::dialect::{format_template, Arg, GrblDialect}; use super::flow::FlowControl; use super::flow::GrblResponseType; use super::parser; use super::transport::mock::MockInner; use super::transport::serial::SerialTransport; use super::transport::telnet::TelnetTransport; use super::transport::{Transport, TransportError}; use super::types::{ DeviceError, DeviceState, DeviceStatus, TransportStatus, UnitSystem, }; tokio::task_local! { /// Set inside the streaming task so `abort_stream_task` can /// detect self-interruption (Python: `task is /// asyncio.current_task()`). static IN_STREAM_TASK: bool; } /// Notifications emitted by the session, implemented by the Python /// bindings (or by embedders). pub trait SessionEvents: Send + Sync { fn state_changed(&self, state: &DeviceState); fn connection_status(&self, status: TransportStatus, message: Option<&str>); fn command_status(&self, status: TransportStatus, message: Option<&str>); fn job_finished(&self); fn probe_status(&self, message: &str); fn wcs_updated(&self, offsets: &HashMap); fn config_changed(&self, key: &str, value: i64); fn command_done(&self, op_index: i64); /// A complete line received from the device, including status /// reports, `ok`/`error:` acknowledgements and `$` replies. fn raw_rx(&self, _line: &str) {} /// A complete line sent to the device (interactive commands and /// realtime bytes; not bulk job streaming). fn raw_tx(&self, _line: &str) {} } /// No-op event sink. pub struct NoopEvents; impl SessionEvents for NoopEvents { fn state_changed(&self, _state: &DeviceState) {} fn connection_status( &self, _status: TransportStatus, _message: Option<&str>, ) { } fn command_status(&self, _status: TransportStatus, _message: Option<&str>) { } fn job_finished(&self) {} fn probe_status(&self, _message: &str) {} fn wcs_updated(&self, _offsets: &HashMap) {} fn config_changed(&self, _key: &str, _value: i64) {} fn command_done(&self, _op_index: i64) {} } /// Which physical transport to use. #[derive(Clone)] pub enum TransportKind { Serial { port: String, baudrate: u32 }, Telnet { host: String, port: u16 }, Mock(Arc), } /// Session configuration with the Python driver's default timings. #[derive(Clone)] pub struct SessionConfig { pub transport: TransportKind, pub poll_status_while_running: bool, pub deadlock_detection: bool, pub rx_buffer_size_override: i64, pub cached_rx_buffer_size: Option, pub stall_timeout_min: f64, pub stall_timeout_max: f64, pub stall_timeout_safety_factor: f64, pub stall_timeout_default: f64, pub safety_shutdown_delay: f64, pub unanswered_poll_limit: u32, pub poll_response_attempts: u32, pub poll_response_interval: f64, pub handshake_timeout: f64, pub handshake_poll_interval: f64, pub status_poll_interval: f64, pub reconnect_delay: f64, pub command_timeout: f64, } impl SessionConfig { pub fn new(transport: TransportKind) -> Self { Self { transport, poll_status_while_running: false, deadlock_detection: false, rx_buffer_size_override: 0, cached_rx_buffer_size: None, stall_timeout_min: 5.0, stall_timeout_max: 120.0, stall_timeout_safety_factor: 3.0, stall_timeout_default: 30.0, safety_shutdown_delay: 0.2, unanswered_poll_limit: 3, poll_response_attempts: 10, poll_response_interval: 0.1, handshake_timeout: 6.0, handshake_poll_interval: 0.5, status_poll_interval: 0.5, reconnect_delay: 5.0, command_timeout: 10.0, } } fn secs(v: f64) -> Duration { Duration::from_secs_f64(v.max(0.0)) } } #[derive(thiserror::Error, Debug)] pub enum SessionError { #[error("{0}")] Connection(String), #[error("operation timed out")] Timeout, #[error("{0}")] Device(String), #[error("buffer stall: {0}")] BufferStall(String), #[error("{0}")] Other(String), } impl From for SessionError { fn from(err: TransportError) -> Self { match err { TransportError::Connection(msg) => Self::Connection(msg), TransportError::NotConnected => { Self::Connection("not connected".to_string()) } } } } /// An asyncio.Event-style latch built on tokio primitives. pub(crate) struct AsyncEvent { set: Mutex, notify: tokio::sync::Notify, } impl AsyncEvent { pub(crate) fn new() -> Self { Self { set: Mutex::new(false), notify: tokio::sync::Notify::new(), } } pub(crate) fn set(&self) { *self.set.lock().unwrap() = true; self.notify.notify_waiters(); self.notify.notify_one(); } pub(crate) fn clear(&self) { *self.set.lock().unwrap() = false; } pub(crate) fn is_set(&self) -> bool { *self.set.lock().unwrap() } pub(crate) async fn wait(&self) { loop { if self.is_set() { return; } let notified = self.notify.notified(); if self.is_set() { return; } notified.await; } } } pub(crate) struct RequestShared { pub command: String, response_lines: Mutex>, finished: AsyncEvent, } impl RequestShared { fn new(command: &str) -> Self { Self { command: command.to_string(), response_lines: Mutex::new(Vec::new()), finished: AsyncEvent::new(), } } fn add_line(&self, line: &str) { self.response_lines.lock().unwrap().push(line.to_string()); } fn finish(&self) { self.finished.set(); } fn is_finished(&self) -> bool { self.finished.is_set() } fn lines(&self) -> Vec { self.response_lines.lock().unwrap().clone() } } struct QueuedCommand { shared: Arc, } /// Completion tracking for the streaming task. The guard's `Drop` /// marks completion even when the task is hard-aborted (a dropped /// future cannot run cleanup code). struct StreamDone { finished: AtomicBool, notify: tokio::sync::Notify, } impl StreamDone { fn new() -> Arc { Arc::new(Self { finished: AtomicBool::new(false), notify: tokio::sync::Notify::new(), }) } } struct StreamGuard { done: Arc, } impl Drop for StreamGuard { fn drop(&mut self) { self.done.finished.store(true, Ordering::SeqCst); self.done.notify.notify_waiters(); self.done.notify.notify_one(); } } /// Handle for the running streaming task: abort + reap support /// without holding the `JoinHandle` (which the caller awaits). struct StreamHandle { abort: tokio::task::AbortHandle, done: Arc, } impl StreamHandle { fn is_finished(&self) -> bool { self.done.finished.load(Ordering::SeqCst) } async fn abort_and_wait(&self) { if self.is_finished() { return; } self.abort.abort(); loop { if self.is_finished() { return; } let notified = self.done.notify.notified(); if self.is_finished() { return; } notified.await; } } } /// Shared session state, owned jointly by the public handle and the /// background tasks. pub(crate) struct SessionCore { config: SessionConfig, dialect: RwLock, events: Arc, transport: Arc, flow: FlowControl, state: Mutex, keep_running: AtomicBool, job_running: AtomicBool, is_cancelled: AtomicBool, is_holding: AtomicBool, raw_grbl_status: Mutex, device_response_count: AtomicU64, consecutive_unanswered_polls: AtomicU32, job_exception: Mutex>, current_request: Mutex>>, interactive_request: Mutex>>, queued_requests: Mutex>>, cmd_tx: Mutex>, handshake_received: AsyncEvent, last_connection_status: Mutex, report_in_inches: AtomicBool, last_reported_op_index: Mutex, stream_task: Mutex>, connection_task: Mutex>>, command_task: Mutex>>, reader_task: Mutex>>, cmd_lock: tokio::sync::Mutex<()>, } impl SessionCore { pub(crate) fn new( config: SessionConfig, dialect: GrblDialect, events: Arc, ) -> Arc { let transport: Arc = match &config.transport { TransportKind::Serial { port, baudrate } => { SerialTransport::new(port, *baudrate) } TransportKind::Telnet { host, port } => { TelnetTransport::new(host, *port) } TransportKind::Mock(inner) => inner.clone(), }; let (cmd_tx, _) = mpsc::unbounded_channel(); let core = Self { config, dialect: RwLock::new(dialect), events, transport, flow: FlowControl::new(), state: Mutex::new(DeviceState::default()), keep_running: AtomicBool::new(false), job_running: AtomicBool::new(false), is_cancelled: AtomicBool::new(false), is_holding: AtomicBool::new(false), raw_grbl_status: Mutex::new(DeviceStatus::Unknown), device_response_count: AtomicU64::new(0), consecutive_unanswered_polls: AtomicU32::new(0), job_exception: Mutex::new(None), current_request: Mutex::new(None), interactive_request: Mutex::new(None), queued_requests: Mutex::new(Vec::new()), cmd_tx: Mutex::new(cmd_tx), handshake_received: AsyncEvent::new(), last_connection_status: Mutex::new(TransportStatus::Unknown), report_in_inches: AtomicBool::new(false), last_reported_op_index: Mutex::new(-1), stream_task: Mutex::new(None), connection_task: Mutex::new(None), command_task: Mutex::new(None), reader_task: Mutex::new(None), cmd_lock: tokio::sync::Mutex::new(()), }; if let Some(cached) = core.config.cached_rx_buffer_size { if cached > 0 { core.flow.set_rx_buffer_size(cached as usize); } } Arc::new(core) } pub(crate) fn state(&self) -> DeviceState { self.state.lock().unwrap().clone() } pub(crate) fn update_dialect(&self, dialect: GrblDialect) { *self.dialect.write().unwrap() = dialect; } fn dialect(&self) -> GrblDialect { self.dialect.read().unwrap().clone() } pub(crate) fn is_job_running(&self) -> bool { self.job_running.load(Ordering::SeqCst) } fn is_cancelled(&self) -> bool { self.is_cancelled.load(Ordering::SeqCst) } fn is_holding(&self) -> bool { self.is_holding.load(Ordering::SeqCst) } fn transport_open(&self) -> Result, SessionError> { if !self.transport.is_open() { return Err(SessionError::Connection( "Serial transport not connected".to_string(), )); } Ok(self.transport.clone()) } pub(crate) fn resource_uri(&self) -> Option { self.transport.resource_uri() } pub(crate) fn buffer_count(&self) -> usize { self.flow.buffer_count() } pub(crate) fn rx_buffer_size(&self) -> usize { self.flow.rx_buffer_size() } pub(crate) fn pending_commands(&self) -> Vec { self.flow .pending_queue() .iter() .map(|p| p.command.clone()) .collect() } fn note_device_response(&self) { self.device_response_count.fetch_add(1, Ordering::SeqCst); } fn device_response_count(&self) -> u64 { self.device_response_count.load(Ordering::SeqCst) } fn update_connection_status( &self, status: TransportStatus, message: Option<&str>, ) { *self.last_connection_status.lock().unwrap() = status; self.events.connection_status(status, message); } // --------------------------------------------------------------- // Connect / disconnect // --------------------------------------------------------------- pub(crate) async fn connect(self: &Arc) { self.abort_background_tasks().await; self.keep_running.store(true, Ordering::SeqCst); self.is_cancelled.store(false, Ordering::SeqCst); self.job_running.store(false, Ordering::SeqCst); self.job_exception.lock().unwrap().take(); self.flow.reset(); let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); *self.cmd_tx.lock().unwrap() = cmd_tx; let core = self.clone(); let connection_task = tokio::spawn(async move { core.connection_loop().await }); *self.connection_task.lock().unwrap() = Some(connection_task); let core = self.clone(); let command_task = tokio::spawn(async move { core.command_queue_loop(cmd_rx).await; }); *self.command_task.lock().unwrap() = Some(command_task); } async fn abort_background_tasks(&self) { for slot in [&self.connection_task, &self.command_task] { let task = slot.lock().unwrap().take(); if let Some(task) = task { task.abort(); let _ = task.await; } } let reader = self.reader_task.lock().unwrap().take(); if let Some(task) = reader { task.abort(); } self.transport.close().await; } pub(crate) async fn cleanup(&self) { self.keep_running.store(false, Ordering::SeqCst); self.job_running.store(false, Ordering::SeqCst); self.is_cancelled.store(true, Ordering::SeqCst); self.abort_stream_task().await; self.is_cancelled.store(false, Ordering::SeqCst); self.flow.reset(); self.job_exception.lock().unwrap().take(); self.abort_background_tasks().await; self.update_connection_status(TransportStatus::Disconnected, None); } async fn await_handshake(&self) -> Result { let deadline = tokio::time::Instant::now() + SessionConfig::secs(self.config.handshake_timeout); loop { self.transport.write(b"?").await?; let poll_deadline = tokio::time::Instant::now() + SessionConfig::secs(self.config.handshake_poll_interval); tokio::select! { _ = self.handshake_received.wait() => return Ok(true), _ = tokio::time::sleep_until(poll_deadline) => { if tokio::time::Instant::now() >= deadline { return Ok(false); } } } } } async fn connection_loop(self: Arc) { log::debug!("Entering connection loop."); while self.keep_running.load(Ordering::SeqCst) { self.handshake_received.clear(); let (tx, mut rx) = mpsc::unbounded_channel(); if let Err(err) = self.transport.open(tx).await { log::error!("Connection error: {err}"); self.update_connection_status( TransportStatus::Error, Some(&err.to_string()), ); } else { let core = self.clone(); let reader = tokio::spawn(async move { while let Some(data) = rx.recv().await { core.handle_rx(&data); } }); *self.reader_task.lock().unwrap() = Some(reader); match self.await_handshake().await { Ok(true) => { log::info!("Connection established successfully."); self.apply_cached_rx_buffer_size(); if let Err(err) = self .execute_interactive_command("$I".to_string()) .await { log::warn!("Failed to retrieve build info: {err}"); } self.warn_if_buffer_size_unknown(); self.update_connection_status( TransportStatus::Connected, None, ); self.status_poll_loop().await; } Ok(false) => { log::warn!( "No response from device. Port may be a \ phantom COM port without a connected device." ); self.update_connection_status( TransportStatus::Error, Some("No response from device"), ); } Err(err) => { log::error!("Connection error: {err}"); self.update_connection_status( TransportStatus::Error, Some(&err.to_string()), ); } } if let Some(task) = self.reader_task.lock().unwrap().take() { task.abort(); } self.transport.close().await; } if !self.keep_running.load(Ordering::SeqCst) { break; } log::debug!("Connection lost. Reconnecting."); self.update_connection_status(TransportStatus::Sleeping, None); tokio::time::sleep(SessionConfig::secs( self.config.reconnect_delay, )) .await; } log::debug!("Leaving connection loop."); } async fn status_poll_loop(&self) { while self.transport.is_open() && self.keep_running.load(Ordering::SeqCst) { if !self.config.poll_status_while_running && self.job_running.load(Ordering::SeqCst) { tokio::time::sleep(SessionConfig::secs( self.config.status_poll_interval, )) .await; continue; } // Deliberately not taking cmd_lock here: a gcode send // holds the lock for the whole buffer-space wait. Polls // are realtime bytes that bypass the GRBL RX buffer and // its accounting, so they are safe to send without the // lock. Blocking on the lock here would starve status // polling, so the driver state would go stale. let responses_before = self.device_response_count(); if let Err(err) = self.transport.write(b"?").await { log::warn!("Connection lost while sending poll: {err}"); break; } tokio::time::sleep(SessionConfig::secs( self.config.status_poll_interval, )) .await; if !self.keep_running.load(Ordering::SeqCst) || !self.transport.is_open() { break; } // A device that answers polls after a transient write // error is alive: recover the connection status instead // of leaving the UI stuck in ERROR (issue #428). if *self.last_connection_status.lock().unwrap() == TransportStatus::Error && self.device_response_count() > responses_before { log::info!( "Device responded after connection error; \ recovering connection status." ); self.update_connection_status(TransportStatus::Connected, None); } } } // --------------------------------------------------------------- // RX handling // --------------------------------------------------------------- fn handle_rx(&self, data: &[u8]) { let responses = self.flow.parse_incoming(data); // Process LINE responses before OK/ERROR to ensure // informational lines are collected before the command is // marked as finished. let mut lines = Vec::new(); let mut acks = Vec::new(); for resp in responses { match resp.rtype { GrblResponseType::Line => lines.push(resp), _ => acks.push(resp), } } for resp in lines.into_iter().chain(acks) { match resp.rtype { GrblResponseType::Ok => { self.events.raw_rx("ok"); self.handle_ok(resp.pending) } GrblResponseType::Error => { self.events.raw_rx(&resp.text); self.handle_error(&resp.text) } GrblResponseType::Line => { self.events.raw_rx(&resp.text); self.handle_line(&resp.text) } } } // Wake parked senders only after the responses were // processed, so progress callbacks and request completions // are always observed first (asyncio-like ordering). self.flow.flush_notifications(); } fn handle_ok(&self, pending: Option) { self.note_device_response(); // Logic for single, interactive commands. let request = self .interactive_request .lock() .unwrap() .clone() .or_else(|| self.current_request.lock().unwrap().clone()); if let Some(request) = request { if !request.is_finished() { request.add_line("ok"); self.events.command_status(TransportStatus::Idle, None); log::debug!( "Command {:?} completed with 'ok'", request.command ); request.finish(); } } // Logic for streaming protocol during a job. if self.job_running.load(Ordering::SeqCst) { if let Some(op_index) = pending.as_ref().and_then(|p| p.op_index) { let mut last = self.last_reported_op_index.lock().unwrap(); for i in (*last + 1)..=op_index { log::debug!("Firing on_command_done for op_index {i}"); self.events.command_done(i); } *last = op_index; } } } fn handle_error(&self, text: &str) { self.note_device_response(); let error_code = text.split(':').nth(1).unwrap_or("").trim(); let error = parser::get_error(error_code); let state_snapshot = { let mut state = self.state.lock().unwrap(); // First error wins: the root-cause failure must not be // overwritten by secondary errors from the teardown that // follows it (e.g. a safety M5 racing into an alarm state // and getting error:9). Secondary errors still resolve // the pending request below. if state.error.is_none() { state.error = Some(error); Some(state.clone()) } else { None } }; // Emit outside the lock: event delivery attaches to the // Python GIL and must never run while holding a lock that // Python-side code could take. if let Some(state_snapshot) = state_snapshot { self.events.state_changed(&state_snapshot); } let request = self .interactive_request .lock() .unwrap() .clone() .or_else(|| self.current_request.lock().unwrap().clone()); if let Some(request) = request { if !request.is_finished() { request.add_line(text); self.events .command_status(TransportStatus::Error, Some(text)); request.finish(); } } if self.job_running.load(Ordering::SeqCst) { self.events .command_status(TransportStatus::Error, Some(text)); log::error!("GRBL error during job: {text}. Halting stream."); *self.job_exception.lock().unwrap() = Some(SessionError::Device(format!("GRBL error: {text}"))); self.flow.signal_space_available(); } } fn handle_line(&self, line: &str) { if line.starts_with('<') && !line.ends_with('>') { log::debug!("Ignoring fragmented status report: {line}"); return; } if line.contains("Pos:") && line.contains('|') && !line.starts_with('<') { log::debug!("Ignoring fragmented status report: {line}"); return; } if line.starts_with('<') && line.ends_with('>') { self.handshake_received.set(); self.handle_status_report(line.trim()); return; } // Collect response lines for pending single commands. let request = self .interactive_request .lock() .unwrap() .clone() .or_else(|| self.current_request.lock().unwrap().clone()); if let Some(request) = request { if !request.is_finished() { request.add_line(line); } } if let Some(code) = line.strip_prefix("ALARM:") { let code = code.trim(); let error = super::errors::alarm_code_to_device_error(code); let state_snapshot = { let mut state = self.state.lock().unwrap(); // First error wins (see handle_error). if state.error.is_none() { state.error = Some(error); Some(state.clone()) } else { None } }; // Emit outside the lock (see handle_error). if let Some(state_snapshot) = state_snapshot { self.events.state_changed(&state_snapshot); } self.events .command_status(TransportStatus::Error, Some(line)); if self.job_running.load(Ordering::SeqCst) { log::error!("GRBL ALARM during job: {line}. Halting stream."); *self.job_exception.lock().unwrap() = Some(SessionError::Device(format!("GRBL ALARM: {line}"))); self.flow.signal_space_available(); } } else if line.starts_with("[VER:") { if let Some(ver) = parser::parse_version(&[line.to_string()]) { log::info!("Connected to GRBL version {ver}"); } } else if line.starts_with("[OPT:") { if let Some(rx_buffer_size) = parser::parse_opt_info(line) { self.cache_rx_buffer_size(rx_buffer_size); if self.config.rx_buffer_size_override <= 0 { self.flow.set_rx_buffer_size(rx_buffer_size as usize); } } } else if line.starts_with("Grbl ") || line.starts_with("GrblHAL ") { self.handshake_received.set(); log::debug!("Received Grbl welcome message: {line}"); } else if self.is_job_running() { // Unknown chatter during a job is anomalous (e.g. a // controller crash banner) and must be visible at the // default log level. log::warn!("Unexpected device line during job: {line}"); } else { log::debug!("Received informational line: {line}"); } } fn handle_status_report(&self, report: &str) { self.note_device_response(); let report_in_inches = self.report_in_inches.load(Ordering::SeqCst); let mut state = { let current = self.state.lock().unwrap(); parser::parse_state(report, ¤t, report_in_inches) }; *self.raw_grbl_status.lock().unwrap() = state.status; if let Some(total) = state.buffer_rx_available { if state.status == DeviceStatus::Idle && self.config.rx_buffer_size_override <= 0 { let total = total as usize; let current = self.flow.rx_buffer_size(); if total > 0 && total != current { log::info!( "Detected RX buffer size {total} from Bf: status \ field (device idle, available == total)" ); self.flow.set_rx_buffer_size(total); self.cache_rx_buffer_size(total as i64); } } } // If a job is active, 'Idle' state between commands should be // reported as 'Run' to the UI. if self.job_running.load(Ordering::SeqCst) && state.status == DeviceStatus::Idle { state.status = DeviceStatus::Run; } // The driver owns the pause state: while a hold was // requested, force HOLD so a firmware status report cannot // mask it. if self.is_holding() && state.status != DeviceStatus::Alarm { state.status = DeviceStatus::Hold; } let changed = { let mut current = self.state.lock().unwrap(); if state != *current { let old_status = current.status; *current = state.clone(); if state.status != old_status { log::debug!("Device state changed: {}", state.status); } true } else { false } }; // Emit outside the lock (see handle_error). if changed { self.events.state_changed(&state); } } fn apply_cached_rx_buffer_size(&self) { if self.config.rx_buffer_size_override > 0 { log::info!( "Applying RX buffer size override: {} bytes", self.config.rx_buffer_size_override ); self.flow.set_rx_buffer_size( self.config.rx_buffer_size_override as usize, ); return; } if let Some(cached) = self.config.cached_rx_buffer_size { if cached > 0 { log::info!("Applying cached RX buffer size: {cached} bytes"); self.flow.set_rx_buffer_size(cached as usize); } } } fn cache_rx_buffer_size(&self, size: i64) { log::info!("Caching RX buffer size: {size} bytes"); self.events.config_changed("rx_buffer_size", size); } fn warn_if_buffer_size_unknown(&self) { if self.config.rx_buffer_size_override > 0 { return; } if self.config.cached_rx_buffer_size.is_some() { return; } log::warn!( "Device did not report RX buffer size via $I. Using \ default {} bytes.", super::flow::DEFAULT_GRBL_RX_BUFFER_SIZE ); } // --------------------------------------------------------------- // Command execution // --------------------------------------------------------------- pub(crate) async fn execute_interactive_command( &self, command: String, ) -> Result, SessionError> { self.transport_open()?; let request = Arc::new(RequestShared::new(&command)); { let _guard = self.cmd_lock.lock().await; let _ = self.flow.wait_pending_empty(None).await; *self.interactive_request.lock().unwrap() = Some(request.clone()); let result = async { self.send_command_bytes(format!("{command}\n").as_bytes()) .await?; match tokio::time::timeout( SessionConfig::secs(self.config.command_timeout), request.finished.wait(), ) .await { Ok(()) => Ok(()), Err(_) => Err(SessionError::Timeout), } } .await; *self.interactive_request.lock().unwrap() = None; result?; } Ok(request.lines()) } /// Send a command through the queued command processor and await /// its full response. pub(crate) async fn execute_command( &self, command: String, ) -> Result, SessionError> { // Only clear the cancellation flag once the cancelled job's // sender has fully terminated (issue #428). if !self.job_running.load(Ordering::SeqCst) { let done = self .stream_task .lock() .unwrap() .as_ref() .is_none_or(|t| t.is_finished()); if done { self.is_cancelled.store(false, Ordering::SeqCst); } } let request = Arc::new(RequestShared::new(&command)); self.queued_requests .lock() .unwrap() .push(Arc::downgrade(&request)); self.cmd_tx .lock() .unwrap() .send(QueuedCommand { shared: request.clone(), }) .map_err(|_| { SessionError::Connection( "command queue unavailable".to_string(), ) })?; match tokio::time::timeout( SessionConfig::secs(self.config.command_timeout), request.finished.wait(), ) .await { Ok(()) => Ok(request.lines()), Err(_) => { request.finish(); Err(SessionError::Timeout) } } } async fn command_queue_loop( self: Arc, mut rx: mpsc::UnboundedReceiver, ) { log::debug!("Entering command queue loop."); while self.keep_running.load(Ordering::SeqCst) { let Some(msg) = rx.recv().await else { break }; let request = msg.shared; { let mut queued = self.queued_requests.lock().unwrap(); queued.retain(|weak| { weak.upgrade() .is_none_or(|arc| !Arc::ptr_eq(&arc, &request)) }); } if !self.transport.is_open() || self.is_cancelled() { log::warn!( "Cannot process command: transport not connected or \ job is cancelled. Dropping command." ); request.finish(); continue; } *self.current_request.lock().unwrap() = Some(request.clone()); let payload = format!("{}\n", request.command); let result = { let _guard = self.cmd_lock.lock().await; if !self.transport.is_open() { Err(SessionError::Connection( "Serial transport disconnected during command." .to_string(), )) } else { self.send_command_bytes(payload.as_bytes()).await } }; match result { Err(err) => { log::error!("Connection error during command: {err}"); self.update_connection_status( TransportStatus::Error, Some(&err.to_string()), ); } Ok(()) => { request.finished.wait().await; } } *self.current_request.lock().unwrap() = None; // Release lock briefly to allow status polling. tokio::time::sleep(Duration::from_millis(100)).await; } log::debug!("Leaving command queue loop."); } /// Send an interactive command ($$, $G, …) with buffer /// accounting: wait up to 1 s for space, then send regardless. async fn send_command_bytes( &self, payload: &[u8], ) -> Result<(), SessionError> { let _ = self .flow .wait_for_space(payload.len(), Duration::from_secs(1)) .await; let text = String::from_utf8_lossy(payload).to_string(); self.flow .track_command(payload.len(), None, text.trim_end()); self.events.raw_tx(text.trim_end()); self.transport.write(payload).await?; Ok(()) } async fn send_realtime( &self, command: &str, add_newline: bool, ) -> Result<(), SessionError> { let transport = self.transport_open()?; let mut payload = command.to_string(); if add_newline { payload.push('\n'); } log::debug!("Sending realtime command: {command}"); transport.write(payload.as_bytes()).await?; Ok(()) } // --------------------------------------------------------------- // Job streaming // --------------------------------------------------------------- async fn start_job(&self) { self.is_cancelled.store(false, Ordering::SeqCst); self.job_running.store(true, Ordering::SeqCst); self.is_holding.store(false, Ordering::SeqCst); *self.last_reported_op_index.lock().unwrap() = -1; self.job_exception.lock().unwrap().take(); self.consecutive_unanswered_polls.store(0, Ordering::SeqCst); self.flow.reset_flow_control(); // The driver owns the state while a job runs: status polling // is disabled during jobs by default, so reflect the job // start immediately so the UI does not keep showing Idle. // An ALARM (which aborts the job right away) is not masked. // A stale error from a previous failed job is cleared: the // new job starts from a clean slate. let state_snapshot = { let mut state = self.state.lock().unwrap(); let had_error = state.error.take().is_some(); if state.status != DeviceStatus::Run && state.status != DeviceStatus::Alarm { state.status = DeviceStatus::Run; Some(state.clone()) } else if had_error { Some(state.clone()) } else { None } }; // Emit outside the lock (see handle_error). if let Some(state_snapshot) = state_snapshot { self.events.state_changed(&state_snapshot); } } /// Cancel and reap the streaming task, if one is still alive. /// /// Self-interruption (called from within the streaming task as /// part of the interrupt handler) returns immediately: cancelling /// the current task here would cut the ongoing cancel() — /// including the safety shutdown — short. async fn abort_stream_task(&self) { if IN_STREAM_TASK.try_with(|v| *v).unwrap_or(false) { return; } let task = self.stream_task.lock().unwrap().take(); if let Some(task) = task { task.abort_and_wait().await; } } async fn run_streaming_job(&self, fut: F) -> Result<(), SessionError> where F: std::future::Future + Send + 'static, { self.abort_stream_task().await; let done = StreamDone::new(); let guard = StreamGuard { done: done.clone() }; let task = tokio::spawn(IN_STREAM_TASK.scope(true, async move { let _guard = guard; fut.await; })); *self.stream_task.lock().unwrap() = Some(StreamHandle { abort: task.abort_handle(), done: done.clone(), }); let result = match task.await { Ok(()) => Ok(()), Err(err) if err.is_cancelled() => Ok(()), Err(err) => Err(SessionError::Other(err.to_string())), }; let mut slot = self.stream_task.lock().unwrap(); if slot.as_ref().is_some_and(|h| Arc::ptr_eq(&h.done, &done)) { *slot = None; } result } pub(crate) async fn run( self: &Arc, gcode: &str, op_map: HashMap, op_estimates: Vec, ) { self.start_job().await; let lines: Vec = gcode.lines().map(|l| l.to_string()).collect(); let core = self.clone(); if let Err(err) = self .run_streaming_job(async move { core.stream_gcode(lines, Some(op_map), Some(op_estimates)) .await; }) .await { match err { SessionError::Device(msg) => log::warn!( "Job terminated due to device error: {msg}. \ Connection remains active." ), other => { log::error!( "Job terminated with unexpected error: {other}" ); } } } } pub(crate) async fn run_raw(self: &Arc, machine_code: &str) { let lines: Vec = machine_code .lines() .map(|l| l.trim().to_string()) .filter(|l| !l.is_empty()) .collect(); let (gcode, realtime) = parser::split_realtime_commands(&lines); for line in &realtime { if let Err(err) = self.send_realtime(line, false).await { log::error!("Realtime command failed: {err}"); return; } } if gcode.is_empty() { return; } self.start_job().await; let core = self.clone(); if let Err(err) = self .run_streaming_job(async move { core.stream_gcode(gcode, None, None).await; }) .await { log::error!("Raw G-code terminated with error: {err}"); } } async fn stream_gcode( self: Arc, gcode_lines: Vec, op_map: Option>, command_times: Option>, ) { let total = gcode_lines.len(); log::debug!("Starting GRBL streaming job with {total} lines."); let mut sent_count = 0usize; let outcome = self .stream_gcode_inner( &gcode_lines, op_map.as_ref(), command_times.as_deref(), &mut sent_count, ) .await; // Interrupt handling (Python: the except clause). Runs // before the job-state resets so cancel() still sees the job // as running and emits job_finished. if let Err(ref err) = outcome { if !self.is_cancelled() { log::warn!("Job interrupted: {err:?}; calling cancel()"); // Surface the terminal job error through the device // state: run() resolves normally either way, so // consumers can only detect a failed job by // inspecting the state. Protocol errors (which // already set state.error with a proper GRBL code) // are preserved. let snapshot = { let mut state = self.state.lock().unwrap(); if state.error.is_none() { state.error = Some(DeviceError::new( -1, "Job Error", &err.to_string(), )); Some(state.clone()) } else { None } }; // Emit outside the lock (see handle_error). if let Some(snapshot) = snapshot { self.events.state_changed(&snapshot); } let _ = self.cancel(true).await; } } // Finally-block equivalent. Hard aborts (task abort) never // get here; cancel() performs its own cleanup. *self.raw_grbl_status.lock().unwrap() = DeviceStatus::Unknown; self.job_running.store(false, Ordering::SeqCst); self.is_holding.store(false, Ordering::SeqCst); match outcome { Ok(()) if !self.is_cancelled() => { self.events.job_finished(); log::debug!( "G-code streaming finished successfully \ ({sent_count}/{total} lines)." ); } Ok(()) => { log::debug!( "G-code streaming cancelled at line \ {sent_count}/{total}." ); } Err(_) => { log::debug!( "G-code streaming aborted at line \ {sent_count}/{total}." ); } } } async fn stream_gcode_inner( &self, gcode_lines: &[String], op_map: Option<&HashMap>, command_times: Option<&[f64]>, sent_count: &mut usize, ) -> Result<(), SessionError> { for (line_idx, raw_line) in gcode_lines.iter().enumerate() { if self.is_cancelled() || self.job_exception.lock().unwrap().is_some() || self.state.lock().unwrap().status == DeviceStatus::Alarm { log::info!( "Job cancelled, errored, or machine in ALARM \ state. Stopping G-code sending." ); let status_is_alarm = self.state.lock().unwrap().status == DeviceStatus::Alarm; let mut exception = self.job_exception.lock().unwrap(); if status_is_alarm && exception.is_none() { *exception = Some(SessionError::Device( "Machine entered ALARM state during job.".to_string(), )); } break; } let line = parser::strip_gcode_comments(raw_line); if line.is_empty() { continue; } let op_index = op_map.and_then(|m| m.get(&line_idx).copied()); let command_bytes = format!("{line}\n"); let timeout = match (command_times, op_index) { (Some(times), Some(op)) if (op as usize) < times.len() => { let estimated = times[op as usize]; (estimated * self.config.stall_timeout_safety_factor).clamp( self.config.stall_timeout_min, self.config.stall_timeout_max, ) } _ => self.config.stall_timeout_default, }; match self .send_gcode_line( command_bytes.as_bytes(), op_index, SessionConfig::secs(timeout), ) .await { Ok(()) => {} Err(SessionError::BufferStall(msg)) => { let mut exception = self.job_exception.lock().unwrap(); if exception.is_none() { *exception = Some(SessionError::Device(format!( "Deadlock recovery failed: {msg}" ))); } } Err(err) => return Err(err), } if self.job_exception.lock().unwrap().is_some() || self.state.lock().unwrap().status == DeviceStatus::Alarm { break; } *sent_count += 1; if (*sent_count).is_multiple_of(500) { log::debug!( "Streaming progress: {}/{} lines sent", sent_count, gcode_lines.len() ); } tokio::task::yield_now().await; } if !self.is_cancelled() && self.job_exception.lock().unwrap().is_none() { log::debug!("All G-code sent. Waiting for all 'ok' responses."); self.drain_pending_acks(SessionConfig::secs( self.config.stall_timeout_default, )) .await; } if let Some(exception) = self.job_exception.lock().unwrap().take() { return Err(exception); } Ok(()) } async fn send_gcode_line( &self, command_bytes: &[u8], op_index: Option, timeout: Duration, ) -> Result<(), SessionError> { let _guard = self.cmd_lock.lock().await; if !self.transport.is_open() { return Err(SessionError::Connection( "Serial transport disconnected during job.".to_string(), )); } self.send_gcode_with_stall(command_bytes, op_index, timeout) .await } /// Send a single gcode line with buffer accounting and the /// stall/liveness callback. async fn send_gcode_with_stall( &self, data: &[u8], op_index: Option, timeout: Duration, ) -> Result<(), SessionError> { let command_len = data.len(); loop { let waited = self.flow.wait_for_space(command_len, timeout).await; match waited { Ok(()) => break, Err(_) => { log::warn!( "Buffer stall: timed out waiting for \ {command_len} bytes. Invoking recovery callback." ); // The caller holds cmd_lock here (we are called // from send_gcode_line), so the stall machinery // must not take the lock itself. let handled = self.on_buffer_stall(command_len).await?; if handled { log::info!("Recovery successful, retrying send."); continue; } return Err(SessionError::BufferStall(format!( "cannot get {command_len} bytes of space" ))); } } } let text = String::from_utf8_lossy(data).to_string(); self.flow .track_command(command_len, op_index, text.trim_end()); self.transport.write(data).await?; Ok(()) } /// Stall callback for gcode sends: abort the job only with proof /// that it cannot proceed (cancelled, ALARM, or a device that /// stopped responding). A device that answers polls is alive, /// so the wait is retried. /// /// The caller holds `cmd_lock`. async fn on_buffer_stall( &self, command_len: usize, ) -> Result { if self.is_cancelled() { return Ok(false); } let idle_or_desynced = self.poll_and_check_idle(false).await; if self.device_stopped_responding() { self.mark_device_unresponsive(); return Ok(false); } if self.state.lock().unwrap().status == DeviceStatus::Alarm { let mut exception = self.job_exception.lock().unwrap(); if exception.is_none() { *exception = Some(SessionError::Device( "Machine entered ALARM state during job.".to_string(), )); } return Ok(false); } if !self.config.deadlock_detection { log::debug!( "Buffer stall timed out (deadlock detection disabled). \ Retrying." ); return Ok(true); } if idle_or_desynced { if !self.flow.needs_space(command_len) { log::info!( "Buffer freed during status poll. Continuing \ streaming." ); return Ok(true); } log::warn!( "Deadlock detected during streaming. Attempting G4 \ P0.01 recovery." ); self.recover_from_deadlock(false).await; if !self.flow.needs_space(command_len) { return Ok(true); } log::error!("Recovery failed: buffer still full."); return Ok(false); } log::info!( "Timeout waiting for buffer space (machine not IDLE). \ This is normal during slow moves. Retrying." ); Ok(true) } /// Send a realtime status poll and check whether GRBL is idle or /// buffer tracking has desynchronized. /// /// Also tracks liveness in `consecutive_unanswered_polls`: /// GRBL answers '?' in every state, so a machine that is merely /// busy always answers; liveness is proven by any received /// response, not by whether the report could be interpreted. /// /// When `hold_lock` is true the cmd_lock is taken around the /// poll send (the Python default); the stall callback passes /// false because its caller already holds the lock. async fn poll_and_check_idle(&self, hold_lock: bool) -> bool { *self.raw_grbl_status.lock().unwrap() = DeviceStatus::Unknown; let responses_before = self.device_response_count(); let send = async { let transport = self.transport_open()?; transport.write(b"?").await.map_err(SessionError::from) }; let result = if hold_lock { let _guard = self.cmd_lock.lock().await; send.await } else { send.await }; if let Err(err) = result { let n = self .consecutive_unanswered_polls .fetch_add(1, Ordering::SeqCst) + 1; log::debug!( "Failed to send status poll: {err} ({n}/{} unanswered).", self.config.unanswered_poll_limit ); return false; } for _ in 0..self.config.poll_response_attempts { tokio::time::sleep(SessionConfig::secs( self.config.poll_response_interval, )) .await; if *self.raw_grbl_status.lock().unwrap() != DeviceStatus::Unknown { break; } } if self.device_response_count() > responses_before { self.consecutive_unanswered_polls.store(0, Ordering::SeqCst); } else { let n = self .consecutive_unanswered_polls .fetch_add(1, Ordering::SeqCst) + 1; log::debug!( "No response to status poll ({n}/{} unanswered).", self.config.unanswered_poll_limit ); } self.is_grbl_idle_or_desynced() } fn is_grbl_idle_or_desynced(&self) -> bool { let status = self.state.lock().unwrap().status; if status == DeviceStatus::Idle { return true; } let raw = *self.raw_grbl_status.lock().unwrap(); if matches!(raw, DeviceStatus::Idle | DeviceStatus::Hold) { return true; } let buf_avail = self.state.lock().unwrap().buffer_available; let rx_size = self.flow.rx_buffer_size(); buf_avail.is_some_and(|v| rx_size > 0 && v >= rx_size as i64) } fn device_stopped_responding(&self) -> bool { self.consecutive_unanswered_polls.load(Ordering::SeqCst) >= self.config.unanswered_poll_limit } fn mark_device_unresponsive(&self) { log::error!( "No response to {} consecutive status polls. Assuming the \ device stopped responding.", self.consecutive_unanswered_polls.load(Ordering::SeqCst) ); *self.job_exception.lock().unwrap() = Some(SessionError::Device( "Device stopped responding during job (no reply to status \ polls)." .to_string(), )); } /// Recover from a detected deadlock: send a `G4 P0.01` dwell; /// when its 'ok' arrives the planner buffer is guaranteed empty, /// then reset host-side buffer accounting. /// /// When `hold_lock` is false the caller already holds cmd_lock /// (the stall callback inside a gcode send). async fn recover_from_deadlock(&self, hold_lock: bool) { if !self.transport.is_open() { log::warn!("Cannot recover: transport disconnected."); return; } log::info!("Deadlock recovery: sending G4 P0.01 to drain planner."); let guard = if hold_lock { Some(self.cmd_lock.lock().await) } else { None }; self.flow.reset_flow_control(); if let Err(err) = self.send_gcode_simple(b"G4 P0.01\n").await { log::warn!("Deadlock recovery failed: {err}"); } drop(guard); if self .flow .wait_pending_empty(Some(Duration::from_secs(30))) .await .is_ok() { log::info!("Deadlock recovery: all pending acks received."); } else { log::warn!( "Deadlock recovery: timed out waiting for acks after \ G4 P0.01. Resetting host buffers." ); } self.flow.reset(); } /// Best-effort gcode send used by safety shutdown and deadlock /// recovery (no stall callback). async fn send_gcode_simple(&self, data: &[u8]) -> Result<(), SessionError> { if self .flow .wait_for_space(data.len(), Duration::from_secs(10)) .await .is_err() { return Err(SessionError::BufferStall( "timed out waiting for buffer space".to_string(), )); } let text = String::from_utf8_lossy(data).to_string(); self.flow.track_command(data.len(), None, text.trim_end()); self.transport.write(data).await?; Ok(()) } /// Wait for all pending acks at the end of a job, recovering /// from deadlocks. async fn drain_pending_acks(&self, timeout: Duration) { while !self.flow.pending_queue().is_empty() { if self.job_exception.lock().unwrap().is_some() || self.state.lock().unwrap().status == DeviceStatus::Alarm { break; } if self.is_cancelled() { break; } if self.flow.wait_pending_empty(Some(timeout)).await.is_ok() { log::debug!("All 'ok' responses received."); break; } let idle_or_desynced = self.poll_and_check_idle(true).await; if self.device_stopped_responding() { self.mark_device_unresponsive(); break; } if idle_or_desynced { if self.flow.pending_queue().is_empty() { log::info!("Pending acks resolved during status poll."); break; } log::warn!( "Deadlock detected at end of job. Attempting G4 \ P0.01 recovery." ); self.recover_from_deadlock(true).await; } else { log::warn!( "Timeout waiting for acks (machine not IDLE). \ Retrying." ); } } } // --------------------------------------------------------------- // Cancel / hold // --------------------------------------------------------------- pub(crate) async fn cancel( &self, emergency: bool, ) -> Result<(), SessionError> { log::debug!("Cancel command initiated."); let job_was_running = self.job_running.load(Ordering::SeqCst); self.is_cancelled.store(true, Ordering::SeqCst); self.job_running.store(false, Ordering::SeqCst); // Unblock any sender parked waiting for buffer space. self.flow.signal_space_available(); let transport = self.transport_open()?; log::info!("Sending Soft Reset (Ctrl-X) to device."); transport.write(b"\x18").await?; // Hard-abort the streaming sender before resetting the // flow-control state, so a sender parked waiting for buffer // space can never wake up later and resume a job that was // cancelled long before (issue #428). self.abort_stream_task().await; // Drain the queued interactive commands. { let queued: Vec> = self .queued_requests .lock() .unwrap() .drain(..) .filter_map(|weak| weak.upgrade()) .collect(); for request in queued { request.finish(); } } log::debug!("Command queue cleared after cancel."); // Clear the streaming queue and buffer state. self.flow.reset(); log::debug!("Streaming queue cleared after cancel."); self.send_safety_shutdown(emergency).await; if job_was_running { self.events.job_finished(); } Ok(()) } /// Best-effort transmission of the dialect's tool-off commands /// so a cancelled job cannot leave persistent PWM outputs /// energized. After a soft reset the firmware needs a moment to /// become ready again, hence the short delay. async fn send_safety_shutdown(&self, emergency: bool) { let dialect = self.dialect(); let mut commands = dialect.safety_off_commands.clone(); if emergency { if let Some(cmd) = dialect.emergency_stop.clone() { commands.push(cmd); } } if commands.is_empty() { return; } if !self.transport.is_open() { return; } tokio::time::sleep(SessionConfig::secs( self.config.safety_shutdown_delay, )) .await; for command in commands { if !self.transport.is_open() { break; } let payload = format!("{command}\n"); if let Err(err) = self.send_gcode_simple(payload.as_bytes()).await { log::warn!("Safety command '{command}' failed: {err}"); } } } pub(crate) async fn set_hold( &self, hold: bool, ) -> Result<(), SessionError> { self.is_holding.store(hold, Ordering::SeqCst); // Do not un-cancel a job that is still winding down (issue // #428). if !self.job_running.load(Ordering::SeqCst) { let done = self .stream_task .lock() .unwrap() .as_ref() .is_none_or(|t| t.is_finished()); if done { self.is_cancelled.store(false, Ordering::SeqCst); } } self.send_realtime(if hold { "!" } else { "~" }, false) .await?; let desired = if hold { DeviceStatus::Hold } else if self.job_running.load(Ordering::SeqCst) { DeviceStatus::Run } else { DeviceStatus::Idle }; let state_snapshot = { let mut state = self.state.lock().unwrap(); if state.status != desired { state.status = desired; Some(state.clone()) } else { None } }; // Emit outside the lock (see handle_error). if let Some(state_snapshot) = state_snapshot { self.events.state_changed(&state_snapshot); } Ok(()) } // --------------------------------------------------------------- // Device operations // --------------------------------------------------------------- pub(crate) async fn home( &self, axes: Option>, active_wcs: &str, ) -> Result<(), SessionError> { let dialect = self.dialect(); match axes { None => { self.execute_command(dialect.home_all.clone()).await?; } Some(axes) => { for axis in axes { let cmd = format_template( &dialect.home_axis, &[("axis_letter", Arg::Str(axis))], ) .map_err(SessionError::Other)?; self.execute_command(cmd).await?; } } } // Some Grbl versions forget the G54 offset after homing; // toggling to another WCS and back re-activates it. Just // sending G54 is ignored if GRBL thinks it is already in // G54. let temp_wcs = if active_wcs == "G54" { "G55" } else { "G54" }; self.execute_command("G4 P0.01".to_string()).await?; self.execute_command(temp_wcs.to_string()).await?; self.execute_command(active_wcs.to_string()).await?; let state_snapshot = { let mut state = self.state.lock().unwrap(); state.error = None; state.clone() }; // Emit outside the lock (see handle_error). self.events.state_changed(&state_snapshot); Ok(()) } pub(crate) async fn move_to( &self, speed: f64, pos_x: f64, pos_y: f64, ) -> Result<(), SessionError> { let dialect = self.dialect(); let cmd = format_template( &dialect.move_to, &[ ("speed", Arg::Speed(speed)), ("x", Arg::Length(pos_x)), ("y", Arg::Length(pos_y)), ], ) .map_err(SessionError::Other)?; self.execute_command(cmd).await?; Ok(()) } pub(crate) async fn jog( &self, speed: f64, deltas: &[(String, f64)], ) -> Result<(), SessionError> { let dialect = self.dialect(); let head = format_template(&dialect.jog, &[("speed", Arg::Speed(speed))]) .map_err(SessionError::Other)?; let mut cmd_parts = vec![head]; for (axis_name, distance) in deltas { cmd_parts.push(format!( "{}{}", axis_name.to_uppercase(), super::dialect::python_float_repr(*distance) )); } if cmd_parts.len() == 1 { return Ok(()); } let cmd = cmd_parts.join(" "); self.execute_command(cmd).await?; Ok(()) } pub(crate) async fn select_tool( &self, tool_number: i64, ) -> Result<(), SessionError> { let dialect = self.dialect(); let cmd = format_template( &dialect.tool_change, &[("tool_number", Arg::Int(tool_number))], ) .map_err(SessionError::Other)?; self.execute_command(cmd).await?; Ok(()) } pub(crate) async fn set_power( &self, power: Option, ) -> Result<(), SessionError> { let dialect = self.dialect(); let cmd = match power { Some(p) if p > 0.0 => { format_template(&dialect.laser_on, &[("power", Arg::Length(p))]) .map_err(SessionError::Other)? } _ => dialect.laser_off.clone(), }; self.execute_command(cmd).await?; Ok(()) } pub(crate) async fn set_focus_power( &self, power: Option, ) -> Result<(), SessionError> { let dialect = self.dialect(); let cmd = match power { Some(p) if p > 0.0 => format_template( &dialect.focus_laser_on, &[("power", Arg::Length(p))], ) .map_err(SessionError::Other)?, _ => { self.wait_for_idle().await; dialect.laser_off.clone() } }; self.execute_command(cmd).await?; Ok(()) } async fn wait_for_idle(&self) { let deadline = tokio::time::Instant::now() + Duration::from_secs(5); while self.state.lock().unwrap().status == DeviceStatus::Jog { if tokio::time::Instant::now() > deadline { log::warn!( "Timed out waiting for JOG to finish before \ sending laser-off command." ); break; } tokio::time::sleep(Duration::from_millis(50)).await; } } pub(crate) async fn read_settings( &self, ) -> Result, SessionError> { let lines = self.execute_interactive_command("$$".to_string()).await?; self.report_in_inches .store(parser::is_report_in_inches(&lines), Ordering::SeqCst); Ok(parser::parse_setting_pairs(&lines)) } pub(crate) async fn write_setting( &self, key: &str, value: &str, ) -> Result<(), SessionError> { self.execute_command(format!("${key}={value}")).await?; Ok(()) } pub(crate) async fn set_wcs_offset( &self, wcs_slot: &str, x: f64, y: f64, z: Option, ) -> Result<(), SessionError> { let p_num = parser::gcode_to_p_number(wcs_slot).ok_or_else(|| { SessionError::Other(format!("Invalid WCS slot: {wcs_slot}")) })?; let dialect = self.dialect(); let cmd = dialect.format_wcs_offset(p_num, x, y, z); self.execute_command(cmd).await?; Ok(()) } pub(crate) async fn read_wcs_offsets( &self, ) -> Result, SessionError> { let lines = self.execute_interactive_command("$#".to_string()).await?; let report_in_inches = self.report_in_inches.load(Ordering::SeqCst); let mut offsets = HashMap::new(); for line in &lines { if let Some((slot, (x, y, z))) = parser::parse_wcs_line(line) { let pos = if report_in_inches { ( super::types::inches_to_mm(x), super::types::inches_to_mm(y), super::types::inches_to_mm(z), ) } else { (x, y, z) }; offsets.insert(slot, pos); } } self.events.wcs_updated(&offsets); Ok(offsets) } pub(crate) async fn read_parser_state( &self, ) -> Result, SessionError> { let lines = self.execute_interactive_command("$G".to_string()).await?; Ok(parser::parse_grbl_parser_state(&lines)) } pub(crate) async fn run_probe_cycle( &self, axis_letter: &str, max_travel: f64, feed_rate: f64, ) -> Result, SessionError> { let axis_letter = axis_letter.to_uppercase(); let dialect = self.dialect(); let cmd = format_template( &dialect.probe_cycle, &[ ("axis_letter", Arg::Str(axis_letter.clone())), ("max_travel", Arg::Length(max_travel)), ("feed_rate", Arg::Speed(feed_rate)), ], ) .map_err(SessionError::Other)?; self.events .probe_status(&format!("Probing {axis_letter}...")); let lines = match self.execute_interactive_command(cmd).await { Ok(lines) => lines, Err(_) => { self.events.probe_status("Probe failed: Timed out"); return Ok(None); } }; for line in &lines { if let Some(((x, y, z), success)) = parser::parse_probe_line(line) { if success { let pos = if self.report_in_inches.load(Ordering::SeqCst) { ( super::types::inches_to_mm(x), super::types::inches_to_mm(y), super::types::inches_to_mm(z), ) } else { (x, y, z) }; self.events.probe_status(&format!( "Probe triggered at ({}, {}, {})", pos.0, pos.1, pos.2 )); return Ok(Some(pos)); } } } self.events.probe_status("Probe failed"); Ok(None) } pub(crate) async fn detect_unit_system( &self, ) -> Result, SessionError> { let lines = match self.execute_interactive_command("$$".to_string()).await { Ok(lines) => lines, Err(err) => { log::warn!("Unit system detection failed: {err}"); return Ok(None); } }; self.report_in_inches .store(parser::is_report_in_inches(&lines), Ordering::SeqCst); Ok(parser::detect_unit_system_from_settings(&lines)) } } raydriver-0.5.1/src/grbl/transport/mock.rs000064400000000000000000000063621046102023000155150ustar00//! Mock transport for tests, exposable to Python. //! //! Scripts received bytes on demand (`push`) and records everything //! sent. Write failures can be injected to simulate connection //! loss. use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::sync::Mutex; use async_trait::async_trait; use tokio::sync::mpsc; use super::{Transport, TransportError}; pub struct MockInner { open: AtomicBool, tx: Mutex>>>, sent: Mutex<(Vec>, usize)>, write_error: Mutex>, } impl MockInner { pub fn new() -> Arc { Arc::new(Self { open: AtomicBool::new(false), tx: Mutex::new(None), sent: Mutex::new((Vec::new(), 0)), write_error: Mutex::new(None), }) } /// Deliver bytes to the session as if the device had sent them. pub fn push(&self, data: Vec) { if let Some(tx) = self.tx.lock().unwrap().as_ref() { let _ = tx.send(data); } } /// All bytes written to the device so far. pub fn sent(&self) -> Vec> { self.sent.lock().unwrap().0.clone() } /// Writes recorded since the previous call (or the last /// `clear_sent`). A monotonic cursor, so clearing the log /// between calls never skips or replays chunks. pub fn take_new_sent(&self) -> Vec> { let mut guard = self.sent.lock().unwrap(); let new = guard.0[guard.1..].to_vec(); guard.1 = guard.0.len(); new } /// Clear the recorded sent bytes. pub fn clear_sent(&self) { let mut guard = self.sent.lock().unwrap(); guard.0.clear(); guard.1 = 0; } /// Simulate connect/disconnect of the physical link. pub fn set_open(&self, open: bool) { self.open.store(open, Ordering::SeqCst); } /// Fail subsequent writes with this message (and mark the link /// down), simulating an unplugged cable. pub fn set_write_error(&self, message: Option) { *self.write_error.lock().unwrap() = message; } } impl Default for MockInner { fn default() -> Self { Self { open: AtomicBool::new(false), tx: Mutex::new(None), sent: Mutex::new((Vec::new(), 0)), write_error: Mutex::new(None), } } } #[async_trait] impl Transport for MockInner { async fn open( &self, tx: mpsc::UnboundedSender>, ) -> Result<(), TransportError> { *self.tx.lock().unwrap() = Some(tx); self.open.store(true, Ordering::SeqCst); Ok(()) } async fn close(&self) { self.open.store(false, Ordering::SeqCst); *self.tx.lock().unwrap() = None; } async fn write(&self, data: &[u8]) -> Result<(), TransportError> { if !self.is_open() { return Err(TransportError::NotConnected); } if let Some(message) = self.write_error.lock().unwrap().clone() { self.open.store(false, Ordering::SeqCst); return Err(TransportError::Connection(message)); } self.sent.lock().unwrap().0.push(data.to_vec()); Ok(()) } fn is_open(&self) -> bool { self.open.load(Ordering::SeqCst) } } raydriver-0.5.1/src/grbl/transport/mod.rs000064400000000000000000000032071046102023000153360ustar00//! Byte transports for talking to Grbl devices. pub mod mock; pub mod serial; pub mod telnet; use async_trait::async_trait; use tokio::sync::mpsc; use crate::grbl::types::TransportStatus; /// A connection-orientiated byte transport to a device. /// /// Implementations push received bytes into the channel handed to /// [`Transport::open`] — the session runs the receive loop. Writes /// must be safe to call concurrently with reads. #[async_trait] pub trait Transport: Send + Sync { /// Open the connection. Received bytes are delivered to `tx` /// until the connection is closed. Errors map to /// [`TransportError::Connection`]. async fn open( &self, tx: mpsc::UnboundedSender>, ) -> Result<(), TransportError>; /// Close the connection, stopping any receive task. async fn close(&self); /// Write bytes to the device. async fn write(&self, data: &[u8]) -> Result<(), TransportError>; /// True while the connection is open. fn is_open(&self) -> bool; /// Human-readable resource identifier for this transport, e.g. /// `serial:///dev/ttyUSB0`. fn resource_uri(&self) -> Option { None } /// Transport-level status hint after a failed operation. fn status_on_error(&self) -> TransportStatus { TransportStatus::Error } } #[derive(thiserror::Error, Debug)] pub enum TransportError { #[error("connection error: {0}")] Connection(String), #[error("not connected")] NotConnected, } impl From for TransportError { fn from(err: std::io::Error) -> Self { Self::Connection(err.to_string()) } } raydriver-0.5.1/src/grbl/transport/serial.rs000064400000000000000000000060011046102023000160310ustar00//! Async serial port transport backed by `tokio-serial`. use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use async_trait::async_trait; use tokio::io::{AsyncReadExt, AsyncWriteExt, ReadHalf, WriteHalf}; use tokio::sync::mpsc; use tokio::sync::Mutex; use tokio::task::JoinHandle; use tokio_serial::{SerialPortBuilderExt, SerialStream}; use super::{Transport, TransportError}; pub struct SerialTransport { port: String, baudrate: u32, open: AtomicBool, writer: Mutex>>, reader_task: Mutex>>, } impl SerialTransport { pub fn new(port: &str, baudrate: u32) -> Arc { Arc::new(Self { port: port.to_string(), baudrate, open: AtomicBool::new(false), writer: Mutex::new(None), reader_task: Mutex::new(None), }) } } #[async_trait] impl Transport for SerialTransport { async fn open( &self, tx: mpsc::UnboundedSender>, ) -> Result<(), TransportError> { let builder = tokio_serial::new(&self.port, self.baudrate); let stream: SerialStream = builder .open_native_async() .map_err(|e| TransportError::Connection(e.to_string()))?; let (read_half, write_half) = tokio::io::split(stream); *self.writer.lock().await = Some(write_half); let task = tokio::spawn(reader_loop(read_half, tx)); *self.reader_task.lock().await = Some(task); self.open.store(true, Ordering::SeqCst); Ok(()) } async fn close(&self) { self.open.store(false, Ordering::SeqCst); if let Some(task) = self.reader_task.lock().await.take() { task.abort(); } *self.writer.lock().await = None; } async fn write(&self, data: &[u8]) -> Result<(), TransportError> { let mut writer_guard = self.writer.lock().await; let Some(writer) = writer_guard.as_mut() else { return Err(TransportError::NotConnected); }; match writer.write_all(data).await { Ok(()) => { let _ = writer.flush().await; Ok(()) } Err(e) => { self.open.store(false, Ordering::SeqCst); Err(TransportError::Connection(e.to_string())) } } } fn is_open(&self) -> bool { self.open.load(Ordering::SeqCst) } fn resource_uri(&self) -> Option { Some(format!("serial://{}", self.port)) } } async fn reader_loop( mut read_half: ReadHalf, tx: mpsc::UnboundedSender>, ) { let mut buf = vec![0u8; 1024]; loop { match read_half.read(&mut buf).await { Ok(0) => break, Ok(n) => { if tx.send(buf[..n].to_vec()).is_err() { break; } } Err(e) => { log::debug!("Serial read error: {e}"); break; } } } } raydriver-0.5.1/src/grbl/transport/telnet.rs000064400000000000000000000056731046102023000160630ustar00//! Raw TCP transport (telnet-style) for network-attached Grbl //! devices. use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use async_trait::async_trait; use tokio::io::{AsyncReadExt, AsyncWriteExt, ReadHalf, WriteHalf}; use tokio::net::TcpStream; use tokio::sync::mpsc; use tokio::sync::Mutex; use tokio::task::JoinHandle; use super::{Transport, TransportError}; pub struct TelnetTransport { host: String, port: u16, open: AtomicBool, writer: Mutex>>, reader_task: Mutex>>, } impl TelnetTransport { pub fn new(host: &str, port: u16) -> Arc { Arc::new(Self { host: host.to_string(), port, open: AtomicBool::new(false), writer: Mutex::new(None), reader_task: Mutex::new(None), }) } } #[async_trait] impl Transport for TelnetTransport { async fn open( &self, tx: mpsc::UnboundedSender>, ) -> Result<(), TransportError> { let stream = TcpStream::connect((self.host.as_str(), self.port)) .await .map_err(|e| TransportError::Connection(e.to_string()))?; let (read_half, write_half) = tokio::io::split(stream); *self.writer.lock().await = Some(write_half); let task = tokio::spawn(reader_loop(read_half, tx)); *self.reader_task.lock().await = Some(task); self.open.store(true, Ordering::SeqCst); Ok(()) } async fn close(&self) { self.open.store(false, Ordering::SeqCst); if let Some(task) = self.reader_task.lock().await.take() { task.abort(); } *self.writer.lock().await = None; } async fn write(&self, data: &[u8]) -> Result<(), TransportError> { let mut writer_guard = self.writer.lock().await; let Some(writer) = writer_guard.as_mut() else { return Err(TransportError::NotConnected); }; match writer.write_all(data).await { Ok(()) => { let _ = writer.flush().await; Ok(()) } Err(e) => { self.open.store(false, Ordering::SeqCst); Err(TransportError::Connection(e.to_string())) } } } fn is_open(&self) -> bool { self.open.load(Ordering::SeqCst) } fn resource_uri(&self) -> Option { Some(format!("tcp://{}:{}", self.host, self.port)) } } async fn reader_loop( mut read_half: ReadHalf, tx: mpsc::UnboundedSender>, ) { let mut buf = vec![0u8; 1024]; loop { match read_half.read(&mut buf).await { Ok(0) => break, Ok(n) => { if tx.send(buf[..n].to_vec()).is_err() { break; } } Err(e) => { log::debug!("Telnet read error: {e}"); break; } } } } raydriver-0.5.1/src/grbl/types.rs000064400000000000000000000110341046102023000136640ustar00//! Shared value types used across the Grbl protocol implementation. //! //! These mirror Rayforge's `machine.driver.driver` module so the //! Python bindings can convert losslessly. use std::fmt; /// Machine state as reported in Grbl status reports. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum DeviceStatus { #[default] Unknown, Idle, Run, Hold, Jog, Alarm, Door, Check, Home, Sleep, Tool, Queue, Lock, Unlock, Cycle, Test, } impl DeviceStatus { /// Parse the state name from a status report (e.g. `Idle`, /// `Hold:0`). Unknown names map to [`DeviceStatus::Unknown`], /// mirroring the Python driver. pub fn from_name(name: &str) -> Self { match name.to_ascii_uppercase().as_str() { "IDLE" => Self::Idle, "RUN" => Self::Run, "HOLD" => Self::Hold, "JOG" => Self::Jog, "ALARM" => Self::Alarm, "DOOR" => Self::Door, "CHECK" => Self::Check, "HOME" => Self::Home, "SLEEP" => Self::Sleep, "TOOL" => Self::Tool, "QUEUE" => Self::Queue, "LOCK" => Self::Lock, "UNLOCK" => Self::Unlock, "CYCLE" => Self::Cycle, "TEST" => Self::Test, _ => Self::Unknown, } } pub fn name(&self) -> &'static str { match self { Self::Unknown => "Unknown", Self::Idle => "Idle", Self::Run => "Run", Self::Hold => "Hold", Self::Jog => "Jog", Self::Alarm => "Alarm", Self::Door => "Door", Self::Check => "Check", Self::Home => "Home", Self::Sleep => "Sleep", Self::Tool => "Tool", Self::Queue => "Queue", Self::Lock => "Lock", Self::Unlock => "Unlock", Self::Cycle => "Cycle", Self::Test => "Test", } } } impl fmt::Display for DeviceStatus { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.name()) } } /// An error or alarm with code, title and description. #[derive(Debug, Clone, PartialEq)] pub struct DeviceError { pub code: i32, pub title: String, pub description: String, } impl DeviceError { pub fn new(code: i32, title: &str, description: &str) -> Self { Self { code, title: title.to_string(), description: description.to_string(), } } } /// A position tuple in mm; entries may be unknown (`None`). pub type Pos = Vec>; /// The complete state of a device at a moment in time. #[derive(Debug, Clone, PartialEq)] pub struct DeviceState { pub status: DeviceStatus, pub error: Option, pub machine_pos: Pos, pub work_pos: Pos, pub wco: Pos, pub feed_rate: Option, pub spindle_speed: Option, pub buffer_available: Option, pub buffer_rx_available: Option, } impl Default for DeviceState { fn default() -> Self { Self { status: DeviceStatus::Unknown, error: None, machine_pos: vec![None, None, None], work_pos: vec![None, None, None], wco: vec![Some(0.0), Some(0.0), Some(0.0)], feed_rate: None, spindle_speed: None, buffer_available: None, buffer_rx_available: None, } } } /// Connection lifecycle status of a transport. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum TransportStatus { #[default] Unknown, Idle, Connecting, Connected, Error, Closing, Disconnected, Sleeping, } impl TransportStatus { pub fn name(&self) -> &'static str { match self { Self::Unknown => "UNKNOWN", Self::Idle => "IDLE", Self::Connecting => "CONNECTING", Self::Connected => "CONNECTED", Self::Error => "ERROR", Self::Closing => "CLOSING", Self::Disconnected => "DISCONNECTED", Self::Sleeping => "SLEEPING", } } } impl fmt::Display for TransportStatus { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.name()) } } /// Unit system inferred from the `$13` (report in inches) setting. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum UnitSystem { Metric, Imperial, } /// Millimeters per inch. pub const MM_PER_INCH: f64 = 25.4; pub fn inches_to_mm(v: f64) -> f64 { v * MM_PER_INCH } raydriver-0.5.1/src/lib.rs000064400000000000000000000021541046102023000123430ustar00//! Rust-native machine drivers. //! //! Currently contains the GRBL protocol stack: //! //! - [`grbl::types`]: shared value types (device state, statuses, //! errors). //! - [`grbl::errors`]: Grbl error and alarm code tables. //! - [`grbl::parser`]: response parsers (status reports, settings, //! probe results, …). //! - [`grbl::dialect`]: command templates and `str.format`-style //! formatting. //! - [`grbl::flow`]: character-counting RX-buffer flow control. //! - [`grbl::transport`]: async byte transports (serial, telnet, //! mock). //! - [`grbl::session`]: the connection/streaming state machines. //! //! The crate is layered: `grbl` is pure Rust and must not depend on //! PyO3; the optional `python` feature provides bindings that //! mirror this structure. pub mod grbl; #[cfg(feature = "python")] pub(crate) mod python; #[cfg(feature = "python")] use pyo3::prelude::*; #[cfg(feature = "python")] pyo3_stub_gen::define_stub_info_gatherer!(stub_info); #[cfg(feature = "python")] #[pymodule(gil_used = false)] fn raydriver(m: &Bound<'_, PyModule>) -> PyResult<()> { python::register(m)?; Ok(()) } raydriver-0.5.1/src/python/events.rs000064400000000000000000000255261046102023000144320ustar00//! Python-facing session events. //! //! Implements [`SessionEvents`] by marshaling each event onto the //! caller's asyncio loop. Events are queued on the Rust side and a //! single `call_soon_threadsafe(drain)` signal per batch wakes the //! loop, which materializes one [`SessionEvent`] per queued event and //! invokes `event_callback(event)` for it on the loop thread. //! Batching keeps the loop's self-pipe write rate at one signal per //! batch regardless of event volume. use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use pyo3::prelude::*; use pyo3_stub_gen::derive::gen_stub_pyclass_complex_enum; use crate::grbl::session::SessionEvents; use crate::grbl::types::{DeviceState as CoreDeviceState, TransportStatus}; use crate::python::grbl::types::DeviceState as PyDeviceState; /// One typed session event, delivered to `event_callback`. #[gen_stub_pyclass_complex_enum] #[pyclass(module = "raydriver.grbl", skip_from_py_object)] #[derive(Clone)] pub enum SessionEvent { StateChanged { state: PyDeviceState, }, ConnectionStatusChanged { status: String, message: Option, }, CommandStatusChanged { status: String, message: Option, }, JobFinished {}, ProbeStatusChanged { message: String, }, WcsUpdated { offsets: HashMap, }, ConfigChanged { key: String, value: i64, }, RawRx { line: String, }, RawTx { line: String, }, } /// One queued event: session events go to the event callback, a /// progress report goes to the progress callback. enum QueuedEvent { Session(SessionEvent), Progress(i64), } pub(crate) struct PyEvents { event_callback: Option>, loop_: Mutex>>, progress_callback: Mutex>>, queue: Arc, } /// Events waiting to be delivered on the loop thread, plus a flag /// telling whether a drain call is already scheduled on the loop. #[derive(Default)] struct EventQueue { pending: Mutex>, scheduled: AtomicBool, } impl EventQueue { /// Queue an event and return true when the caller must schedule /// a drain (only the caller that flips the flag schedules). fn push(&self, event: QueuedEvent) -> bool { let mut pending = self.pending.lock().unwrap(); pending.push(event); !self.scheduled.swap(true, Ordering::SeqCst) } /// Undo a `push` that could not schedule a drain, so a later /// event retries instead of the queue wedging forever. fn unschedule(&self) { self.scheduled.store(false, Ordering::SeqCst); } /// Take all queued events and clear the scheduled flag. fn take(&self) -> Vec { let mut pending = self.pending.lock().unwrap(); self.scheduled.store(false, Ordering::SeqCst); std::mem::take(&mut *pending) } } /// The loop-side drain callable: invoked via /// `loop.call_soon_threadsafe(drain)`; runs on the event loop /// thread and delivers every queued event. Carries both callbacks /// so each queued event routes to its own callback no matter which /// schedule call drained the batch. #[pyclass(module = "raydriver.grbl")] struct EventDrainer { queue: Arc, events: Option>, progress: Option>, } #[pymethods] impl EventDrainer { fn __call__(&self, py: Python<'_>) { self.take_and_deliver(py); } } impl PyEvents { pub(crate) fn new(event_callback: Option>) -> Self { Self { event_callback, loop_: Mutex::new(None), progress_callback: Mutex::new(None), queue: Arc::new(EventQueue::default()), } } /// Remember the running event loop so events can be marshaled /// from tokio worker threads. pub(crate) fn capture_loop(&self, py: Python<'_>) -> PyResult<()> { let mut slot = self.loop_.lock().unwrap(); if slot.is_none() { let asyncio = py.import("asyncio")?; *slot = Some(asyncio.call_method0("get_running_loop")?.unbind()); } Ok(()) } pub(crate) fn set_progress_callback(&self, cb: Option>) { *self.progress_callback.lock().unwrap() = cb; } /// Queue a session event for delivery and schedule the loop /// drain if this caller flipped the scheduled flag. The GIL is /// attached only for the single `call_soon_threadsafe` signal; /// the event is materialized into a Python object at drain time. fn schedule(&self, event: SessionEvent) { if self.event_callback.is_none() { return; } let result: PyResult<()> = Python::attach(|py| { if self.queue.push(QueuedEvent::Session(event)) { let loop_guard = self.loop_.lock().unwrap(); let Some(loop_) = loop_guard.as_ref() else { self.queue.unschedule(); return Ok(()); }; let loop_py = loop_.clone_ref(py); drop(loop_guard); let callback = self .event_callback .as_ref() .expect("checked above") .bind(py); let drainer = Py::new( py, EventDrainer { queue: self.queue.clone(), events: Some(callback.clone().unbind()), progress: self .progress_callback .lock() .unwrap() .as_ref() .map(|cb| cb.clone_ref(py)), }, )?; loop_py .bind(py) .call_method1("call_soon_threadsafe", (drainer,))?; } Ok(()) }); if let Err(err) = result { self.queue.unschedule(); log::warn!("failed to schedule session event: {err}"); } } /// Queue a progress report for delivery. fn schedule_progress(&self, op_index: i64) { if self.progress_callback.lock().unwrap().is_none() { return; } let result: PyResult<()> = Python::attach(|py| { let progress_guard = self.progress_callback.lock().unwrap(); let Some(callback) = progress_guard.as_ref() else { return Ok(()); }; let callback = callback.clone_ref(py); drop(progress_guard); let loop_guard = self.loop_.lock().unwrap(); let Some(loop_) = loop_guard.as_ref() else { return Ok(()); }; let loop_py = loop_.clone_ref(py); drop(loop_guard); if self.queue.push(QueuedEvent::Progress(op_index)) { let drainer = Py::new( py, EventDrainer { queue: self.queue.clone(), events: self .event_callback .as_ref() .map(|cb| cb.clone_ref(py)), progress: Some(callback), }, )?; if let Err(err) = loop_py .bind(py) .call_method1("call_soon_threadsafe", (drainer,)) { self.queue.unschedule(); return Err(err); } } Ok(()) }); if let Err(err) = result { self.queue.unschedule(); log::warn!("failed to schedule command_done: {err}"); } } } impl SessionEvents for PyEvents { fn state_changed(&self, state: &CoreDeviceState) { self.schedule(SessionEvent::StateChanged { state: PyDeviceState::new(state.clone()), }); } fn connection_status( &self, status: TransportStatus, message: Option<&str>, ) { self.schedule(SessionEvent::ConnectionStatusChanged { status: status.name().to_string(), message: message.map(str::to_string), }); } fn command_status(&self, status: TransportStatus, message: Option<&str>) { self.schedule(SessionEvent::CommandStatusChanged { status: status.name().to_string(), message: message.map(str::to_string), }); } fn job_finished(&self) { self.schedule(SessionEvent::JobFinished {}); } fn probe_status(&self, message: &str) { self.schedule(SessionEvent::ProbeStatusChanged { message: message.to_string(), }); } fn wcs_updated(&self, offsets: &HashMap) { self.schedule(SessionEvent::WcsUpdated { offsets: offsets.clone(), }); } fn config_changed(&self, key: &str, value: i64) { self.schedule(SessionEvent::ConfigChanged { key: key.to_string(), value, }); } fn command_done(&self, op_index: i64) { self.schedule_progress(op_index); } fn raw_rx(&self, line: &str) { self.schedule(SessionEvent::RawRx { line: line.to_string(), }); } fn raw_tx(&self, line: &str) { self.schedule(SessionEvent::RawTx { line: line.to_string(), }); } } impl EventDrainer { /// Deliver each queued event to its own callback: session /// events to the event callback, progress reports to the /// progress callback. fn take_and_deliver(&self, py: Python<'_>) { for event in self.queue.take() { match event { QueuedEvent::Session(event) => { let Some(callback) = self.events.as_ref() else { continue; }; let obj = match event.into_pyobject(py) { Ok(obj) => obj.into_any().unbind(), Err(err) => { log::warn!( "failed to materialize session event: {err}" ); continue; } }; if let Err(err) = callback.bind(py).call1((obj,)) { log::warn!("event callback failed: {err}"); } } QueuedEvent::Progress(op_index) => { let Some(callback) = self.progress.as_ref() else { continue; }; if let Err(err) = callback.call1(py, (op_index,)) { log::warn!("progress callback failed: {err}"); } } } } } } raydriver-0.5.1/src/python/grbl/mock.rs000064400000000000000000000042621046102023000147770ustar00//! The mock transport exposed to Python for tests. use std::sync::Arc; use pyo3::prelude::*; use pyo3::types::PyBytes; use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods}; use crate::grbl::transport::mock::MockInner; /// An in-memory byte transport for tests. /// /// Feed bytes to the session with `push()` (as if the device had /// sent them), inspect everything the session sent with `sent()`, /// and simulate link state with `set_connected()` / /// `set_write_error()`. #[gen_stub_pyclass] #[pyclass(module = "raydriver.grbl")] pub struct MockTransport { pub(crate) inner: Arc, } #[gen_stub_pymethods] #[pymethods] impl MockTransport { #[new] fn new() -> Self { Self { inner: MockInner::new(), } } /// Deliver bytes to the session as if the device sent them. fn push(&self, data: Vec) { self.inner.push(data); } /// All bytes written to the device so far. fn sent<'py>(&self, py: Python<'py>) -> Vec> { self.inner .sent() .into_iter() .map(|data| PyBytes::new(py, &data)) .collect() } /// Writes recorded since the previous call (or the last /// `clear_sent`): a monotonic cursor, so clearing the log never /// skips or replays chunks. This is what device emulators /// should consume. fn take_new_sent<'py>(&self, py: Python<'py>) -> Vec> { self.inner .take_new_sent() .into_iter() .map(|data| PyBytes::new(py, &data)) .collect() } /// Clear the recorded sent bytes. fn clear_sent(&self) { self.inner.clear_sent(); } /// Simulate connect/disconnect of the physical link. fn set_connected(&self, connected: bool) { self.inner.set_open(connected); } /// Fail subsequent writes with this message (and mark the link /// down), simulating an unplugged cable. #[pyo3(signature = (message=None))] fn set_write_error(&self, message: Option) { self.inner.set_write_error(message); } fn __repr__(&self) -> &'static str { "MockTransport()" } } raydriver-0.5.1/src/python/grbl/mod.rs000064400000000000000000000031741046102023000146260ustar00//! The `raydriver.grbl` Python submodule: GRBL sessions, transports //! and value types. pub(crate) mod mock; pub(crate) mod parser; pub(crate) mod session; pub(crate) mod types; use pyo3::prelude::*; pub(crate) const MODULE_DOC: &str = "\ GRBL device drivers implemented in Rust. The central type is GrblSession: it owns the connection lifecycle \ (handshake, status polling, reconnect), interactive commands, and \ job streaming with character-counting flow control, stall detection \ and deadlock recovery. Dialects remain data owned by Rayforge: \ resolved command templates are passed into the session. "; pyo3_stub_gen::module_doc!("raydriver.grbl", "{}", MODULE_DOC); pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { let py = m.py(); let grbl_mod = PyModule::new(py, "grbl")?; grbl_mod.setattr("__doc__", MODULE_DOC)?; grbl_mod.add( "__all__", vec!["GrblSession", "MockTransport", "SessionEvent", "types"], )?; grbl_mod.add_class::()?; grbl_mod.add_class::()?; grbl_mod.add_class::()?; let types_mod = PyModule::new(py, "types")?; types::register(&types_mod)?; grbl_mod.add_submodule(&types_mod)?; let parser_mod = PyModule::new(py, "parser")?; parser::register(&parser_mod)?; grbl_mod.add_submodule(&parser_mod)?; m.add_submodule(&grbl_mod)?; // Note: raydriver.grbl is intentionally NOT registered in // sys.modules — the Python __init__.py at // python/raydriver/grbl/__init__.py serves as the package and // delegates to this Rust module. Ok(()) } raydriver-0.5.1/src/python/grbl/parser.rs000064400000000000000000000224311046102023000153400ustar00//! Python bindings for the pure response parsers. use pyo3::prelude::*; use pyo3_stub_gen::derive::gen_stub_pyfunction; use crate::grbl::errors::{ alarm_code_to_device_error as alarm_lookup, error_code_to_device_error as error_lookup, }; use crate::grbl::parser; use crate::grbl::types::UnitSystem; use super::types::{DeviceError, DeviceState}; pub(crate) const MODULE_DOC: &str = "\ Pure parsers for GRBL response data: status reports, build info, \ settings, WCS offsets, probe results and raw-output classification. \ All functions are side-effect free. "; pyo3_stub_gen::module_doc!("raydriver.grbl.parser", "{}", MODULE_DOC); fn to_state(state: crate::grbl::types::DeviceState) -> DeviceState { DeviceState::new(state) } /// Parse a GRBL status string like `` into a /// DeviceState, using *default* as the base. When /// *report_in_inches* is true, positions are converted back to mm. #[gen_stub_pyfunction(module = "raydriver.grbl.parser")] #[pyfunction] #[pyo3(signature = (state_str, default=None, report_in_inches=false))] fn parse_state( state_str: &str, default: Option, report_in_inches: bool, ) -> DeviceState { let default = default.map(|d| d.inner).unwrap_or_default(); to_state(parser::parse_state(state_str, &default, report_in_inches)) } /// Parse `$I` response lines to extract the firmware version. #[gen_stub_pyfunction(module = "raydriver.grbl.parser")] #[pyfunction] fn parse_version(response_lines: Vec) -> Option { parser::parse_version(&response_lines) } /// Parse a `[VER:...]` line into `(version, build_name)`. #[gen_stub_pyfunction(module = "raydriver.grbl.parser")] #[pyfunction] fn parse_ver(line: &str) -> Option<(String, Option)> { parser::parse_ver(line) } /// Extract the RX buffer size from an `[OPT:...]` line. #[gen_stub_pyfunction(module = "raydriver.grbl.parser")] #[pyfunction] fn parse_opt_info(line: &str) -> Option { parser::parse_opt_info(line) } /// Extract the compile option flags from an `[OPT:...]` line. #[gen_stub_pyfunction(module = "raydriver.grbl.parser")] #[pyfunction] fn parse_opt_flags(line: &str) -> Option { parser::parse_opt_flags(line) } /// Parse a `[MSG:key:value]` line into `(key, value)`. #[gen_stub_pyfunction(module = "raydriver.grbl.parser")] #[pyfunction] fn parse_msg(line: &str) -> Option<(String, String)> { parser::parse_msg(line) } /// Extract a human-readable device name from build info lines. #[gen_stub_pyfunction(module = "raydriver.grbl.parser")] #[pyfunction] fn extract_device_name(build_info: Vec) -> String { parser::extract_device_name(&build_info) } /// Extract a device name from raw serial output, falling back to /// the first informative banner line. #[gen_stub_pyfunction(module = "raydriver.grbl.parser")] #[pyfunction] fn extract_device_name_from_output(data: Vec) -> Option { parser::extract_device_name_from_output(&data) } /// True when raw serial output identifies a Grbl device. #[gen_stub_pyfunction(module = "raydriver.grbl.parser")] #[pyfunction] fn is_grbl_output(data: Vec) -> bool { parser::is_grbl_output(&data) } /// Strip G-code comments from a line. #[gen_stub_pyfunction(module = "raydriver.grbl.parser")] #[pyfunction] fn strip_gcode_comments(line: &str) -> String { parser::strip_gcode_comments(line) } /// Split command lines into `(gcode, realtime)` groups. #[gen_stub_pyfunction(module = "raydriver.grbl.parser")] #[pyfunction] fn split_realtime_commands(lines: Vec) -> (Vec, Vec) { parser::split_realtime_commands(&lines) } /// Convert a G-code WCS name (e.g. "G54") to its P-number. #[gen_stub_pyfunction(module = "raydriver.grbl.parser")] #[pyfunction] fn gcode_to_p_number(wcs_slot: &str) -> Option { parser::gcode_to_p_number(wcs_slot) } /// True if a GRBL version supports single-axis homing (> 1.1 or /// 1.1g and newer). #[gen_stub_pyfunction(module = "raydriver.grbl.parser")] #[pyfunction] #[pyo3(signature = (version_num, version_letter=""))] fn version_supports_single_axis_homing( version_num: f64, version_letter: &str, ) -> bool { parser::version_supports_single_axis_homing(version_num, version_letter) } /// Parse `$$` lines into `{key: value}` floats. #[gen_stub_pyfunction(module = "raydriver.grbl.parser")] #[pyfunction] fn parse_grbl_settings( lines: Vec, ) -> std::collections::HashMap { parser::parse_grbl_settings(&lines) } /// Parse `$$` lines into ordered `(key, raw_value)` pairs. #[gen_stub_pyfunction(module = "raydriver.grbl.parser")] #[pyfunction] fn parse_setting_pairs(lines: Vec) -> Vec<(String, String)> { parser::parse_setting_pairs(&lines) } /// Parse a `[G5x:x,y,z]` line from `$#` output. #[gen_stub_pyfunction(module = "raydriver.grbl.parser")] #[pyfunction] fn parse_wcs_line(line: &str) -> Option<(String, (f64, f64, f64))> { parser::parse_wcs_line(line) } /// Parse a `[PRB:x,y,z:success]` line into `(pos, success)`. #[gen_stub_pyfunction(module = "raydriver.grbl.parser")] #[pyfunction] fn parse_probe_line(line: &str) -> Option<((f64, f64, f64), bool)> { parser::parse_probe_line(line) } /// Parse `$G` response lines to find the active WCS (G54-G59). #[gen_stub_pyfunction(module = "raydriver.grbl.parser")] #[pyfunction] fn parse_grbl_parser_state(response_lines: Vec) -> Option { parser::parse_grbl_parser_state(&response_lines) } /// Infer the unit system ("metric"/"imperial") from `$$` lines. #[gen_stub_pyfunction(module = "raydriver.grbl.parser")] #[pyfunction] fn detect_unit_system_from_settings( settings_lines: Vec, ) -> Option<&'static str> { match parser::detect_unit_system_from_settings(&settings_lines) { Some(UnitSystem::Metric) => Some("metric"), Some(UnitSystem::Imperial) => Some("imperial"), None => None, } } /// True when the `$13` (Report in inches) flag is set. #[gen_stub_pyfunction(module = "raydriver.grbl.parser")] #[pyfunction] fn is_report_in_inches(settings_lines: Vec) -> bool { parser::is_report_in_inches(&settings_lines) } /// Look up an error code in the GRBL error table. #[gen_stub_pyfunction(module = "raydriver.grbl.parser")] #[pyfunction] fn error_code_to_device_error(error_code: &str) -> DeviceError { DeviceError::new(error_lookup(error_code)) } /// Look up an alarm code in the GRBL alarm table. #[gen_stub_pyfunction(module = "raydriver.grbl.parser")] #[pyfunction] fn alarm_code_to_device_error(alarm_code: &str) -> DeviceError { DeviceError::new(alarm_lookup(alarm_code)) } pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.setattr("__doc__", MODULE_DOC)?; m.add( "__all__", vec![ "parse_state", "parse_version", "parse_ver", "parse_opt_info", "parse_opt_flags", "parse_msg", "extract_device_name", "extract_device_name_from_output", "is_grbl_output", "strip_gcode_comments", "split_realtime_commands", "gcode_to_p_number", "version_supports_single_axis_homing", "parse_grbl_settings", "parse_setting_pairs", "parse_wcs_line", "parse_probe_line", "parse_grbl_parser_state", "detect_unit_system_from_settings", "is_report_in_inches", "error_code_to_device_error", "alarm_code_to_device_error", ], )?; m.add_function(pyo3::wrap_pyfunction!(parse_state, m.clone())?)?; m.add_function(pyo3::wrap_pyfunction!(parse_version, m.clone())?)?; m.add_function(pyo3::wrap_pyfunction!(parse_ver, m.clone())?)?; m.add_function(pyo3::wrap_pyfunction!(parse_opt_info, m.clone())?)?; m.add_function(pyo3::wrap_pyfunction!(parse_opt_flags, m.clone())?)?; m.add_function(pyo3::wrap_pyfunction!(parse_msg, m.clone())?)?; m.add_function(pyo3::wrap_pyfunction!(extract_device_name, m.clone())?)?; m.add_function(pyo3::wrap_pyfunction!( extract_device_name_from_output, m.clone() )?)?; m.add_function(pyo3::wrap_pyfunction!(is_grbl_output, m.clone())?)?; m.add_function(pyo3::wrap_pyfunction!(strip_gcode_comments, m.clone())?)?; m.add_function(pyo3::wrap_pyfunction!( split_realtime_commands, m.clone() )?)?; m.add_function(pyo3::wrap_pyfunction!(gcode_to_p_number, m.clone())?)?; m.add_function(pyo3::wrap_pyfunction!( version_supports_single_axis_homing, m.clone() )?)?; m.add_function(pyo3::wrap_pyfunction!(parse_grbl_settings, m.clone())?)?; m.add_function(pyo3::wrap_pyfunction!(parse_setting_pairs, m.clone())?)?; m.add_function(pyo3::wrap_pyfunction!(parse_wcs_line, m.clone())?)?; m.add_function(pyo3::wrap_pyfunction!(parse_probe_line, m.clone())?)?; m.add_function(pyo3::wrap_pyfunction!( parse_grbl_parser_state, m.clone() )?)?; m.add_function(pyo3::wrap_pyfunction!( detect_unit_system_from_settings, m.clone() )?)?; m.add_function(pyo3::wrap_pyfunction!(is_report_in_inches, m.clone())?)?; m.add_function(pyo3::wrap_pyfunction!( error_code_to_device_error, m.clone() )?)?; m.add_function(pyo3::wrap_pyfunction!( alarm_code_to_device_error, m.clone() )?)?; Ok(()) } raydriver-0.5.1/src/python/grbl/session.rs000064400000000000000000000454011046102023000155310ustar00//! The `GrblSession` PyO3 wrapper: thin async methods delegating to //! the Rust session core. use std::collections::HashMap; use std::sync::Arc; use pyo3::exceptions::{PyConnectionError, PyRuntimeError, PyTimeoutError}; use pyo3::prelude::*; use pyo3::types::PyDict; use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods}; use crate::grbl::dialect::GrblDialect; use crate::grbl::session::{ SessionConfig, SessionCore, SessionError, TransportKind, }; use crate::grbl::types::UnitSystem; use crate::python::events::PyEvents; use crate::python::grbl::mock::MockTransport; use crate::python::grbl::types::DeviceState; use crate::python::pyfuture::{none_obj, spawn_future}; impl From for PyErr { fn from(err: SessionError) -> Self { match err { SessionError::Connection(msg) => PyConnectionError::new_err(msg), SessionError::Timeout => PyTimeoutError::new_err(err.to_string()), SessionError::Device(msg) => PyRuntimeError::new_err(msg), SessionError::BufferStall(msg) => { PyConnectionError::new_err(format!("buffer stall: {msg}")) } SessionError::Other(msg) => PyRuntimeError::new_err(msg), } } } fn get_bool(dict: &Bound<'_, PyDict>, key: &str) -> PyResult> { match dict.get_item(key)? { Some(v) => Ok(Some(v.extract::()?)), None => Ok(None), } } fn get_f64(dict: &Bound<'_, PyDict>, key: &str) -> PyResult> { match dict.get_item(key)? { Some(v) => { if let Ok(b) = v.extract::() { return Ok(Some(if b { 1.0 } else { 0.0 })); } Ok(Some(v.extract::()?)) } None => Ok(None), } } fn get_i64(dict: &Bound<'_, PyDict>, key: &str) -> PyResult> { match dict.get_item(key)? { Some(v) => Ok(Some(v.extract::()?)), None => Ok(None), } } fn get_str(dict: &Bound<'_, PyDict>, key: &str) -> PyResult> { match dict.get_item(key)? { Some(v) => Ok(Some(v.extract::()?)), None => Ok(None), } } fn build_config( kind: TransportKind, dict: Option<&Bound<'_, PyDict>>, ) -> PyResult { let mut config = SessionConfig::new(kind); let Some(dict) = dict else { return Ok(config); }; if let Some(v) = get_bool(dict, "poll_status_while_running")? { config.poll_status_while_running = v; } if let Some(v) = get_bool(dict, "deadlock_detection")? { config.deadlock_detection = v; } if let Some(v) = get_i64(dict, "rx_buffer_size_override")? { config.rx_buffer_size_override = v; } if let Some(v) = get_i64(dict, "cached_rx_buffer_size")? { config.cached_rx_buffer_size = if v > 0 { Some(v) } else { None }; } if let Some(v) = get_f64(dict, "stall_timeout_min")? { config.stall_timeout_min = v; } if let Some(v) = get_f64(dict, "stall_timeout_max")? { config.stall_timeout_max = v; } if let Some(v) = get_f64(dict, "stall_timeout_safety_factor")? { config.stall_timeout_safety_factor = v; } if let Some(v) = get_f64(dict, "stall_timeout_default")? { config.stall_timeout_default = v; } if let Some(v) = get_f64(dict, "safety_shutdown_delay")? { config.safety_shutdown_delay = v; } if let Some(v) = get_i64(dict, "unanswered_poll_limit")? { config.unanswered_poll_limit = v.max(0) as u32; } if let Some(v) = get_i64(dict, "poll_response_attempts")? { config.poll_response_attempts = v.max(0) as u32; } if let Some(v) = get_f64(dict, "poll_response_interval")? { config.poll_response_interval = v; } if let Some(v) = get_f64(dict, "handshake_timeout")? { config.handshake_timeout = v; } if let Some(v) = get_f64(dict, "handshake_poll_interval")? { config.handshake_poll_interval = v; } if let Some(v) = get_f64(dict, "status_poll_interval")? { config.status_poll_interval = v; } if let Some(v) = get_f64(dict, "reconnect_delay")? { config.reconnect_delay = v; } if let Some(v) = get_f64(dict, "command_timeout")? { config.command_timeout = v; } Ok(config) } fn transport_kind_from_config( dict: Option<&Bound<'_, PyDict>>, ) -> PyResult { let Some(dict) = dict else { return Err(PyRuntimeError::new_err( "config with a 'port' (serial) or 'host' (telnet) is required", )); }; if let Some(host) = get_str(dict, "host")? { let port = get_i64(dict, "tcp_port")?.unwrap_or(23) as u16; return Ok(TransportKind::Telnet { host, port }); } let port = get_str(dict, "port")?.unwrap_or_default(); let baudrate = get_i64(dict, "baudrate")?.unwrap_or(115200) as u32; Ok(TransportKind::Serial { port, baudrate }) } fn parse_dialect(dict: Option<&Bound<'_, PyDict>>) -> PyResult { let mut safety_off_commands = None; let mut map: HashMap = HashMap::new(); if let Some(dict) = dict { for (key, value) in dict.iter() { let key: String = key.extract()?; if key == "safety_off_commands" { safety_off_commands = Some(value.extract::>()?); continue; } if let Ok(text) = value.extract::() { map.insert(key, text); } } } let mut dialect = GrblDialect::from_map(&map); if let Some(commands) = safety_off_commands { dialect.safety_off_commands = commands; } Ok(dialect) } /// A GRBL device session: connection lifecycle, streaming and /// interactive commands, entirely in Rust. /// /// All async methods return asyncio futures; a typed `SessionEvent` /// is delivered to `event_callback(event)` on the event loop thread. #[gen_stub_pyclass] #[pyclass(module = "raydriver.grbl")] pub struct GrblSession { core: Arc, events: Arc, } #[gen_stub_pymethods] #[pymethods] impl GrblSession { #[new] #[pyo3(signature = (config, dialect=None, event_callback=None))] fn new( config: Option>, dialect: Option>, #[gen_stub(override_type( type_repr = "typing.Optional[collections.abc.Callable[[SessionEvent], typing.Any]]", imports = ("collections.abc", "typing") ))] event_callback: Option>, ) -> PyResult { let kind = transport_kind_from_config(config.as_ref())?; let session_config = build_config(kind, config.as_ref())?; Self::build(session_config, dialect, event_callback) } /// Build a session around a [`MockTransport`] (for tests). #[staticmethod] #[pyo3(signature = (transport, config=None, dialect=None, event_callback=None))] fn with_transport( transport: PyRef<'_, MockTransport>, config: Option>, dialect: Option>, #[gen_stub(override_type( type_repr = "typing.Optional[collections.abc.Callable[[SessionEvent], typing.Any]]", imports = ("collections.abc", "typing") ))] event_callback: Option>, ) -> PyResult { let kind = TransportKind::Mock(transport.inner.clone()); let session_config = build_config(kind, config.as_ref())?; Self::build(session_config, dialect, event_callback) } /// Open the connection and start the background tasks. fn connect<'py>(&self, py: Python<'py>) -> PyResult> { self.events.capture_loop(py)?; let core = self.core.clone(); spawn_future(py, async move { core.connect().await; Ok(Python::attach(none_obj)) }) } /// Stop all background tasks and close the transport. fn disconnect<'py>(&self, py: Python<'py>) -> PyResult> { let core = self.core.clone(); spawn_future(py, async move { core.cleanup().await; Ok(Python::attach(none_obj)) }) } /// Send a command and synchronously await its full response, /// blocking any other command from interleaving. fn execute_interactive_command<'py>( &self, py: Python<'py>, command: String, ) -> PyResult> { let core = self.core.clone(); spawn_future(py, async move { let lines = core.execute_interactive_command(command).await?; Python::attach(|py| { Ok(lines.into_pyobject(py)?.unbind().into_any()) }) }) } /// Queue a command for asynchronous processing and await its /// response. fn execute_command<'py>( &self, py: Python<'py>, command: String, ) -> PyResult> { let core = self.core.clone(); spawn_future(py, async move { let lines = core.execute_command(command).await?; Python::attach(|py| { Ok(lines.into_pyobject(py)?.unbind().into_any()) }) }) } /// Stream a G-code program using the character-counting /// protocol. /// /// `op_line_map` maps G-code line indices to op indices for /// progress reporting; `op_estimates` holds per-op estimated /// durations (seconds) used to scale per-line stall timeouts. #[pyo3(signature = (gcode, op_line_map=None, op_estimates=None, progress_callback=None))] fn run<'py>( &self, py: Python<'py>, gcode: String, op_line_map: Option>, op_estimates: Option>, progress_callback: Option>, ) -> PyResult> { let core = self.core.clone(); let events = self.events.clone(); let op_map = op_line_map.unwrap_or_default(); let estimates = op_estimates.unwrap_or_default(); spawn_future(py, async move { // The callback stays installed after the run returns: // the final command_done events are marshaled from the // reader task and may be delivered moments later (the // Python driver keeps _on_command_done until its stream // finally-block too). The next run replaces it. events.set_progress_callback(progress_callback); core.run(&gcode, op_map, estimates).await; Ok(Python::attach(none_obj)) }) } /// Execute a raw G-code string; GRBL realtime commands (?, ~, !) /// are sent via the control path instead of the stream. fn run_raw<'py>( &self, py: Python<'py>, machine_code: String, ) -> PyResult> { let core = self.core.clone(); spawn_future(py, async move { core.run_raw(&machine_code).await; Ok(Python::attach(none_obj)) }) } /// Pause (feed hold) or resume the device. #[pyo3(signature = (hold=true))] fn set_hold<'py>( &self, py: Python<'py>, hold: bool, ) -> PyResult> { let core = self.core.clone(); spawn_future(py, async move { core.set_hold(hold).await?; Ok(Python::attach(none_obj)) }) } /// Abort the running job: soft reset, queue cleanup and safety /// shutdown. #[pyo3(signature = (emergency=false))] fn cancel<'py>( &self, py: Python<'py>, emergency: bool, ) -> PyResult> { let core = self.core.clone(); spawn_future(py, async move { core.cancel(emergency).await?; Ok(Python::attach(none_obj)) }) } /// Home all axes (`axes` is None) or the named axes (e.g. /// `["X"]`), then re-activate the active WCS via the toggle /// workaround. #[pyo3(signature = (axes=None, active_wcs = String::from("G54")))] fn home<'py>( &self, py: Python<'py>, axes: Option>, active_wcs: String, ) -> PyResult> { let core = self.core.clone(); spawn_future(py, async move { core.home(axes, &active_wcs).await?; Ok(Python::attach(none_obj)) }) } /// Move to an absolute position (mm) at `speed` (mm/min). fn move_to<'py>( &self, py: Python<'py>, speed: f64, x: f64, y: f64, ) -> PyResult> { let core = self.core.clone(); spawn_future(py, async move { core.move_to(speed, x, y).await?; Ok(Python::attach(none_obj)) }) } /// Jog by relative deltas (mm) at `speed` (mm/min). fn jog<'py>( &self, py: Python<'py>, speed: f64, deltas: Vec<(String, f64)>, ) -> PyResult> { let core = self.core.clone(); spawn_future(py, async move { core.jog(speed, &deltas).await?; Ok(Python::attach(none_obj)) }) } fn select_tool<'py>( &self, py: Python<'py>, tool_number: i64, ) -> PyResult> { let core = self.core.clone(); spawn_future(py, async move { core.select_tool(tool_number).await?; Ok(Python::attach(none_obj)) }) } /// Set laser power. `power` is the absolute S value; `None` /// (or zero) turns the laser off. #[pyo3(signature = (power=None))] fn set_power<'py>( &self, py: Python<'py>, power: Option, ) -> PyResult> { let core = self.core.clone(); spawn_future(py, async move { core.set_power(power).await?; Ok(Python::attach(none_obj)) }) } /// Set laser power for focus mode; `None` waits for pending jogs /// to finish and turns the laser off. #[pyo3(signature = (power=None))] fn set_focus_power<'py>( &self, py: Python<'py>, power: Option, ) -> PyResult> { let core = self.core.clone(); spawn_future(py, async move { core.set_focus_power(power).await?; Ok(Python::attach(none_obj)) }) } /// Read `$$` into `(key, raw_value)` pairs. fn read_settings<'py>( &self, py: Python<'py>, ) -> PyResult> { let core = self.core.clone(); spawn_future(py, async move { let settings = core.read_settings().await?; Python::attach(|py| { Ok(settings.into_pyobject(py)?.unbind().into_any()) }) }) } fn write_setting<'py>( &self, py: Python<'py>, key: String, value: String, ) -> PyResult> { let core = self.core.clone(); spawn_future(py, async move { core.write_setting(&key, &value).await?; Ok(Python::attach(none_obj)) }) } fn set_wcs_offset<'py>( &self, py: Python<'py>, wcs_slot: String, x: f64, y: f64, z: Option, ) -> PyResult> { let core = self.core.clone(); spawn_future(py, async move { core.set_wcs_offset(&wcs_slot, x, y, z).await?; Ok(Python::attach(none_obj)) }) } /// Read `$#` WCS offsets, converted to mm when the device /// reports inches. fn read_wcs_offsets<'py>( &self, py: Python<'py>, ) -> PyResult> { let core = self.core.clone(); spawn_future(py, async move { let offsets = core.read_wcs_offsets().await?; Python::attach(|py| { Ok(offsets.into_pyobject(py)?.unbind().into_any()) }) }) } /// Read the `$G` parser state to determine the active WCS. fn read_parser_state<'py>( &self, py: Python<'py>, ) -> PyResult> { let core = self.core.clone(); spawn_future(py, async move { let wcs = core.read_parser_state().await?; Python::attach(|py| Ok(wcs.into_pyobject(py)?.unbind().into_any())) }) } /// Run a probe cycle (G38.2); returns the trigger position in mm /// or None on failure. fn run_probe_cycle<'py>( &self, py: Python<'py>, axis_letter: String, max_travel: f64, feed_rate: f64, ) -> PyResult> { let core = self.core.clone(); spawn_future(py, async move { let pos = core .run_probe_cycle(&axis_letter, max_travel, feed_rate) .await?; Python::attach(|py| Ok(pos.into_pyobject(py)?.unbind().into_any())) }) } /// Infer the device's unit system ("metric"/"imperial") from the /// `$13` setting. fn detect_unit_system<'py>( &self, py: Python<'py>, ) -> PyResult> { let core = self.core.clone(); spawn_future(py, async move { let unit = core.detect_unit_system().await?; let name = unit.map(|u| match u { UnitSystem::Metric => "metric", UnitSystem::Imperial => "imperial", }); Python::attach(|py| Ok(name.into_pyobject(py)?.unbind().into_any())) }) } /// Swap the dialect command templates at runtime. fn update_dialect(&self, dialect: Bound<'_, PyDict>) -> PyResult<()> { self.core.update_dialect(parse_dialect(Some(&dialect))?); Ok(()) } /// Current snapshot of the device state. #[getter] fn state(&self) -> DeviceState { DeviceState::new(self.core.state()) } #[getter] fn buffer_count(&self) -> usize { self.core.buffer_count() } #[getter] fn rx_buffer_size(&self) -> usize { self.core.rx_buffer_size() } /// Commands awaiting their 'ok' acknowledgement. #[getter] fn pending_commands(&self) -> Vec { self.core.pending_commands() } #[getter] fn job_running(&self) -> bool { self.core.is_job_running() } #[getter] fn resource_uri(&self) -> Option { self.core.resource_uri() } fn __repr__(&self) -> String { format!( "GrblSession(resource_uri={:?}, job_running={})", self.resource_uri(), self.job_running() ) } } impl GrblSession { fn build( config: SessionConfig, dialect: Option>, event_callback: Option>, ) -> PyResult { let dialect = parse_dialect(dialect.as_ref())?; let events = Arc::new(PyEvents::new(event_callback)); let core = SessionCore::new(config, dialect, events.clone()); Ok(Self { core, events }) } } raydriver-0.5.1/src/python/grbl/types.rs000064400000000000000000000205721046102023000152140ustar00//! Python-visible value types mirroring Rayforge's //! `machine.driver.driver` module. // `from_py_object` generates a FromPyObject impl whose .clone() is // flagged as clone_on_copy for Copy types; the lint cannot be // targeted at the generated impl, only at this module. #![allow(clippy::clone_on_copy)] use pyo3::prelude::*; use pyo3_stub_gen::derive::{ gen_stub_pyclass, gen_stub_pyclass_enum, gen_stub_pymethods, }; use crate::grbl::types as core; pub(crate) const MODULE_DOC: &str = "\ Value types shared by the GRBL drivers: DeviceState, DeviceStatus \ and DeviceError. These mirror the identically-named types in \ Rayforge so the Python shell can convert losslessly. "; pyo3_stub_gen::module_doc!("raydriver.grbl.types", "{}", MODULE_DOC); /// Machine state as reported in Grbl status reports. #[gen_stub_pyclass_enum] #[pyclass(module = "raydriver.grbl.types", eq, eq_int, from_py_object)] #[derive(Clone, Copy, PartialEq, Eq)] pub enum DeviceStatus { #[pyo3(name = "UNKNOWN")] Unknown = 0, #[pyo3(name = "IDLE")] Idle = 1, #[pyo3(name = "RUN")] Run = 2, #[pyo3(name = "HOLD")] Hold = 3, #[pyo3(name = "JOG")] Jog = 4, #[pyo3(name = "ALARM")] Alarm = 5, #[pyo3(name = "DOOR")] Door = 6, #[pyo3(name = "CHECK")] Check = 7, #[pyo3(name = "HOME")] Home = 8, #[pyo3(name = "SLEEP")] Sleep = 9, #[pyo3(name = "TOOL")] Tool = 10, #[pyo3(name = "QUEUE")] Queue = 11, #[pyo3(name = "LOCK")] Lock = 12, #[pyo3(name = "UNLOCK")] Unlock = 13, #[pyo3(name = "CYCLE")] Cycle = 14, #[pyo3(name = "TEST")] Test = 15, } impl From for DeviceStatus { fn from(status: core::DeviceStatus) -> Self { match status { core::DeviceStatus::Unknown => Self::Unknown, core::DeviceStatus::Idle => Self::Idle, core::DeviceStatus::Run => Self::Run, core::DeviceStatus::Hold => Self::Hold, core::DeviceStatus::Jog => Self::Jog, core::DeviceStatus::Alarm => Self::Alarm, core::DeviceStatus::Door => Self::Door, core::DeviceStatus::Check => Self::Check, core::DeviceStatus::Home => Self::Home, core::DeviceStatus::Sleep => Self::Sleep, core::DeviceStatus::Tool => Self::Tool, core::DeviceStatus::Queue => Self::Queue, core::DeviceStatus::Lock => Self::Lock, core::DeviceStatus::Unlock => Self::Unlock, core::DeviceStatus::Cycle => Self::Cycle, core::DeviceStatus::Test => Self::Test, } } } impl From for core::DeviceStatus { fn from(status: DeviceStatus) -> Self { match status { DeviceStatus::Unknown => Self::Unknown, DeviceStatus::Idle => Self::Idle, DeviceStatus::Run => Self::Run, DeviceStatus::Hold => Self::Hold, DeviceStatus::Jog => Self::Jog, DeviceStatus::Alarm => Self::Alarm, DeviceStatus::Door => Self::Door, DeviceStatus::Check => Self::Check, DeviceStatus::Home => Self::Home, DeviceStatus::Sleep => Self::Sleep, DeviceStatus::Tool => Self::Tool, DeviceStatus::Queue => Self::Queue, DeviceStatus::Lock => Self::Lock, DeviceStatus::Unlock => Self::Unlock, DeviceStatus::Cycle => Self::Cycle, DeviceStatus::Test => Self::Test, } } } #[gen_stub_pymethods] #[pymethods] impl DeviceStatus { fn __repr__(&self) -> String { format!("DeviceStatus.{}", self.name()) } #[getter] fn name(&self) -> &'static str { match self { Self::Unknown => "UNKNOWN", Self::Idle => "IDLE", Self::Run => "RUN", Self::Hold => "HOLD", Self::Jog => "JOG", Self::Alarm => "ALARM", Self::Door => "DOOR", Self::Check => "CHECK", Self::Home => "HOME", Self::Sleep => "SLEEP", Self::Tool => "TOOL", Self::Queue => "QUEUE", Self::Lock => "LOCK", Self::Unlock => "UNLOCK", Self::Cycle => "CYCLE", Self::Test => "TEST", } } } /// An error or alarm with code, title and description. #[gen_stub_pyclass] #[pyclass(module = "raydriver.grbl.types", eq, from_py_object)] #[derive(Clone, PartialEq)] pub struct DeviceError { pub(crate) inner: core::DeviceError, } impl DeviceError { pub(crate) fn new(inner: core::DeviceError) -> Self { Self { inner } } } #[gen_stub_pymethods] #[pymethods] impl DeviceError { #[new] fn new_py(code: i32, title: String, description: String) -> Self { Self { inner: core::DeviceError::new(code, &title, &description), } } #[getter] fn code(&self) -> i32 { self.inner.code } #[getter] fn title(&self) -> String { self.inner.title.clone() } #[getter] fn description(&self) -> String { self.inner.description.clone() } fn __repr__(&self) -> String { format!( "DeviceError(code={}, title={:?})", self.inner.code, self.inner.title ) } } /// The complete state of a device at a moment in time. #[gen_stub_pyclass] #[pyclass(module = "raydriver.grbl.types", eq, from_py_object)] #[derive(Clone, PartialEq)] pub struct DeviceState { pub(crate) inner: core::DeviceState, } impl DeviceState { pub(crate) fn new(inner: core::DeviceState) -> Self { Self { inner } } } #[gen_stub_pymethods] #[pymethods] impl DeviceState { #[new] fn new_py() -> Self { Self::new(core::DeviceState::default()) } #[getter] fn status(&self) -> DeviceStatus { self.inner.status.into() } #[setter] fn set_status(&mut self, status: DeviceStatus) { self.inner.status = status.into(); } #[getter] fn error(&self) -> Option { self.inner.error.clone().map(DeviceError::new) } #[setter] fn set_error(&mut self, error: Option<&DeviceError>) { self.inner.error = error.map(|e| e.inner.clone()); } /// Machine position in mm; entries may be `None`. #[getter] fn machine_pos(&self) -> Vec> { self.inner.machine_pos.clone() } #[setter] fn set_machine_pos(&mut self, pos: Vec>) { self.inner.machine_pos = pos; } /// Work position in mm; entries may be `None`. #[getter] fn work_pos(&self) -> Vec> { self.inner.work_pos.clone() } #[setter] fn set_work_pos(&mut self, pos: Vec>) { self.inner.work_pos = pos; } /// Work coordinate offset in mm; entries may be `None`. #[getter] fn wco(&self) -> Vec> { self.inner.wco.clone() } #[setter] fn set_wco(&mut self, wco: Vec>) { self.inner.wco = wco; } #[getter] fn feed_rate(&self) -> Option { self.inner.feed_rate } #[setter] fn set_feed_rate(&mut self, feed_rate: Option) { self.inner.feed_rate = feed_rate; } #[getter] fn spindle_speed(&self) -> Option { self.inner.spindle_speed } #[setter] fn set_spindle_speed(&mut self, spindle_speed: Option) { self.inner.spindle_speed = spindle_speed; } #[getter] fn buffer_available(&self) -> Option { self.inner.buffer_available } #[setter] fn set_buffer_available(&mut self, buffer_available: Option) { self.inner.buffer_available = buffer_available; } #[getter] fn buffer_rx_available(&self) -> Option { self.inner.buffer_rx_available } #[setter] fn set_buffer_rx_available(&mut self, buffer_rx_available: Option) { self.inner.buffer_rx_available = buffer_rx_available; } fn __repr__(&self) -> String { format!( "DeviceState(status={}, feed_rate={:?})", self.inner.status, self.inner.feed_rate ) } } pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.setattr("__doc__", MODULE_DOC)?; m.add( "__all__", vec!["DeviceState", "DeviceStatus", "DeviceError"], )?; m.add_class::()?; m.add_class::()?; m.add_class::()?; Ok(()) } raydriver-0.5.1/src/python/mod.rs000064400000000000000000000114161046102023000136760ustar00//! Root PyO3 module. pub(crate) mod events; pub(crate) mod grbl; pub(crate) mod pyfuture; pub(crate) mod runtime; use std::fs::OpenOptions; use std::io::Write; use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; use pyo3::prelude::*; use pyo3_stub_gen::derive::gen_stub_pyfunction; /// UTC timestamp in RFC3339-ish form, without pulling in a date /// crate (Howard Hinnant's civil-from-days algorithm). fn utc_timestamp() -> String { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default(); let secs = now.as_secs(); let millis = now.subsec_millis(); let days = (secs / 86_400) as i64; let rem = secs % 86_400; let (y, m, d) = { let z = days + 719_468; let era = if z >= 0 { z } else { z - 146_096 } / 146_097; let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; (y + i64::from(m <= 2), m as u32, d as u32) }; format!( "{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{millis:03}Z", h = rem / 3600, mi = rem % 3600 / 60, s = rem % 60, ) } struct LoggerState { filter: env_filter::Filter, file: Option, } static LOGGER: Mutex> = Mutex::new(None); struct BridgeLogger; impl log::Log for BridgeLogger { fn enabled(&self, metadata: &log::Metadata) -> bool { LOGGER .lock() .map(|guard| { guard.as_ref().is_some_and(|s| s.filter.enabled(metadata)) }) .unwrap_or(false) } fn log(&self, record: &log::Record) { let line = format!( "[{} {:<5} {}] {}\n", utc_timestamp(), record.level(), record.target(), record.args() ); let guard = LOGGER.lock(); let mut guard = match guard { Ok(guard) => guard, Err(poisoned) => poisoned.into_inner(), }; let Some(state) = guard.as_mut() else { return; }; if !state.filter.matches(record) { return; } match state.file.as_mut() { Some(file) => { let _ = file.write_all(line.as_bytes()); } None => { let _ = std::io::stderr().write_all(line.as_bytes()); } } } fn flush(&self) { let guard = LOGGER.lock(); let mut guard = match guard { Ok(guard) => guard, Err(poisoned) => poisoned.into_inner(), }; if let Some(state) = guard.as_mut() { if let Some(file) = state.file.as_mut() { let _ = file.flush(); } } let _ = std::io::stderr().flush(); } } /// Initialize the Rust-side logger (`log` crate) so session /// diagnostics reach stderr or, when *path* is given, a dedicated /// log file (opened in append mode). The filter defaults to /// ``warn`` and can be overridden with an ``env_logger`` filter /// string argument or via ``RUST_LOG`` (which always wins). Calling /// again re-targets output and replaces the filter. #[gen_stub_pyfunction] #[pyfunction] #[pyo3(signature = (filter = None, path = None))] fn init_logging(filter: Option<&str>, path: Option<&str>) { let spec = std::env::var("RUST_LOG") .unwrap_or_else(|_| filter.unwrap_or("warn").to_string()); let mut filter_builder = env_filter::Builder::new(); filter_builder.parse(&spec); let filter = filter_builder.build(); log::set_max_level(filter.filter()); let file = path.and_then(|p| { OpenOptions::new().create(true).append(true).open(p).ok() }); // Installing the global logger succeeds only on the first call; // later calls just retarget the shared state below. let _ = log::set_boxed_logger(Box::new(BridgeLogger)); *LOGGER.lock().unwrap() = Some(LoggerState { filter, file }); } /// Tear down the shared tokio runtime (see /// [`runtime::shutdown`]). Called via `atexit`, before CPython /// finalizes the interpreter. #[pyfunction] fn _shutdown_runtime() { runtime::shutdown(); } fn register_atexit_hook(m: &Bound<'_, PyModule>) -> PyResult<()> { let py = m.py(); let atexit = py.import("atexit")?; let hook = wrap_pyfunction!(_shutdown_runtime, m)?; atexit.call_method1("register", (hook,))?; Ok(()) } /// Register the root `raydriver` module. pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(init_logging, m)?)?; grbl::register(m)?; register_atexit_hook(m)?; Ok(()) } raydriver-0.5.1/src/python/pyfuture.rs000064400000000000000000000044631046102023000150060ustar00//! Bridge for completing asyncio futures from the tokio runtime. //! //! Python async methods hand back an `asyncio.Future` that a Rust //! future completes via `loop.call_soon_threadsafe` — mirroring the //! pattern Rayforge's Python drivers use to wake waiters from //! non-loop threads. Nothing here ever blocks the event loop. use std::future::Future; use pyo3::prelude::*; use super::runtime; /// Spawn *fut* on the shared tokio runtime and return an asyncio /// future that resolves with its result. /// /// The future must be created while running on the event loop (i.e. /// from an async Python call stack), which is the only supported /// calling context. pub(crate) fn spawn_future( py: Python<'_>, fut: F, ) -> PyResult> where F: Future>> + Send + 'static, { let asyncio = py.import("asyncio")?; let loop_ = asyncio.call_method0("get_running_loop")?; let future = loop_.call_method1("create_future", ())?; let loop_py: Py = loop_.unbind(); let future_py: Py = future.clone().unbind(); runtime::handle().spawn(async move { let outcome = fut.await; let schedule: PyResult<()> = Python::attach(|py| { let loop_bound = loop_py.bind(py); match outcome { Ok(value) => { let set_result = future_py.getattr(py, "set_result")?; loop_bound.call_method1( "call_soon_threadsafe", (set_result, value), )?; } Err(err) => { let set_exception = future_py.getattr(py, "set_exception")?; let exc = err.value(py).to_owned(); loop_bound.call_method1( "call_soon_threadsafe", (set_exception, exc), )?; } } Ok(()) }); if let Err(err) = schedule { Python::attach(|py| err.print(py)); } }); Ok(future) } /// A Python `None` as an owned `Py` (created under the GIL). pub(crate) fn none_obj(py: Python<'_>) -> Py { ().into_pyobject(py) .expect("None conversion") .unbind() .into_any() } raydriver-0.5.1/src/python/runtime.rs000064400000000000000000000051061046102023000146010ustar00//! The shared tokio runtime backing all sessions. //! //! A single process-wide runtime keeps task spawning cheap and //! avoids interpreter-shutdown deadlocks: per-session runtimes would //! have to be joined during `Drop`, which cannot block on tasks that //! need the GIL while Python is finalizing. //! //! The runtime must not outlive the interpreter: a worker thread //! that still holds a Python thread state at `PyInterpreterState_Delete` //! time aborts the process ("remaining threads"). [`shutdown`] //! therefore tears the runtime down from an `atexit` hook, which //! CPython runs before interpreter finalization. use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{LazyLock, Mutex}; use std::time::{Duration, Instant}; static RUNTIME: Mutex> = Mutex::new(None); static HANDLE: LazyLock = LazyLock::new(|| { let runtime = tokio::runtime::Builder::new_multi_thread() .worker_threads(2) .enable_all() .build() .expect("failed to build tokio runtime"); spawn_gil_watchdog(&runtime); let handle = runtime.handle().clone(); *RUNTIME.lock().unwrap() = Some(runtime); handle }); static SHUTDOWN: AtomicBool = AtomicBool::new(false); /// Heartbeat that periodically attaches to the GIL and prints a /// line. If the interpreter ever deadlocks on the GIL, the /// heartbeats stop, which the CI logs will show. fn spawn_gil_watchdog(runtime: &tokio::runtime::Runtime) { runtime.spawn(async { let start = Instant::now(); loop { tokio::time::sleep(Duration::from_secs(10)).await; if SHUTDOWN.load(Ordering::Relaxed) { break; } let secs = start.elapsed().as_secs(); let attached_at = std::time::Instant::now(); pyo3::Python::attach(|py| { let _ = py; eprintln!( "[gil-watchdog] alive after {secs}s (attach took \ {:?})", attached_at.elapsed() ); }); } }); } pub(crate) fn handle() -> &'static tokio::runtime::Handle { &HANDLE } /// Cancel all tasks and join the worker threads. Registered as an /// `atexit` hook by [`super::register`]; safe to call more than /// once, and a no-op if the runtime was never used. pub(crate) fn shutdown() { SHUTDOWN.store(true, Ordering::Relaxed); if let Ok(mut guard) = RUNTIME.lock() { if let Some(runtime) = guard.take() { runtime.shutdown_timeout(Duration::from_secs(2)); } } } raydriver-0.5.1/tests/conftest.py000064400000000000000000000107471046102023000140100ustar00"""Shared fixtures: sessions driven against the GRBL firmware emulator (raydriver.emulator).""" import asyncio import faulthandler import time import pytest from raydriver.emulator import GrblEmulator from raydriver.grbl import GrblSession, MockTransport, SessionEvent def _arm_freeze_watchdog(): """Dump all thread stacks periodically if the process stalls. The dump runs on a C-level thread and works even when the GIL is stuck (unlike Python timers, which need the GIL to wake). Healthy runs finish in under a minute, so any dump indicates a stall; a second dump 60s later confirms the stall is permanent. """ faulthandler.dump_traceback_later( 60, repeat=True, file=open("freeze-dump.txt", "w") ) _arm_freeze_watchdog() FAST_TIMINGS = { "handshake_timeout": 2.0, "handshake_poll_interval": 0.02, "status_poll_interval": 0.05, "reconnect_delay": 0.2, "command_timeout": 2.0, "poll_response_interval": 0.01, "poll_response_attempts": 10, "safety_shutdown_delay": 0.02, # Generous stall margins: sandboxed CI runners can pause the # event loop for hundreds of milliseconds, and tripping a drain # timeout would run deadlock recovery (which resets the pending # queue) before a late ack arrives. "stall_timeout_default": 3.0, "stall_timeout_min": 1.0, "stall_timeout_max": 5.0, } DEFAULT_DIALECT = { "home_all": "$H", "home_axis": "$H{axis_letter}", "move_to": "$J=G90 G21 F{speed} X{x} Y{y}", "jog": "$J=G91 G21 F{speed}", "clear_alarm": "$X", "laser_on": "M4 S{power:.0f}", "laser_off": "M5", "focus_laser_on": "M3 S{power:.0f}", "tool_change": "T{tool_number}", "set_wcs_offset": "G10 L2 P{p_num} X{x} Y{y} Z{z}", "probe_cycle": "G38.2 {axis_letter}{max_travel} F{feed_rate}", "safety_off_commands": ["M5", "M9"], } class EventRecorder: def __init__(self): self.events = [] def callback(self, event): self.events.append(event) def names(self): return [type(event).__name__ for event in self.events] async def wait_for(self, kind, pred=None, timeout=5.0): """Wait until an event of *kind* (a SessionEvent variant class) matching *pred* was recorded.""" deadline = time.monotonic() + timeout while time.monotonic() < deadline: for event in self.events: if not isinstance(event, kind): continue if pred is None or pred(event): return event await asyncio.sleep(0.01) raise AssertionError( f"event {kind!r} not observed within {timeout:.1f}s; " f"got {self.names()}" ) def make_session(mock, events, config=None, dialect=None): config = {**FAST_TIMINGS, **(config or {})} return GrblSession.with_transport( mock, config=config, dialect={**DEFAULT_DIALECT, **(dialect or {})}, event_callback=events.callback, ) async def wait_until(predicate, timeout=5.0, interval=0.01): """Poll *predicate* until it returns a truthy value.""" deadline = time.monotonic() + timeout while time.monotonic() < deadline: result = predicate() if result: return result await asyncio.sleep(interval) raise AssertionError(f"condition not met within {timeout:.1f}s") @pytest.fixture def events(): return EventRecorder() @pytest.fixture def mock(): return MockTransport() async def start_device(mock, **kwargs): """Start an emulator task and return (emulator, task).""" emulator = GrblEmulator(mock, **kwargs) task = asyncio.ensure_future(emulator.run()) return emulator, task @pytest.fixture async def rig(mock, events): """A connected session running against the firmware emulator. Yields ``(session, mock, emulator, events)`` with the handshake complete and the sent log cleared. """ emulator, device_task = await start_device(mock) session = make_session(mock, events) await session.connect() await events.wait_for( SessionEvent.ConnectionStatusChanged, lambda event: event.status == "CONNECTED", ) mock.clear_sent() events.events.clear() try: yield session, mock, emulator, events finally: await session.disconnect() device_task.cancel() try: await device_task except asyncio.CancelledError: pass def sent_bytes(session_mock): return b"".join(session_mock.sent()) raydriver-0.5.1/tests/freeze-dump.txt000064400000000000000000000000001046102023000145520ustar00raydriver-0.5.1/tests/test_cli.py000064400000000000000000000077041046102023000137700ustar00"""Tests for the raydriver command line interface.""" import pytest from raydriver.cli.main import amain @pytest.fixture def gcode_file(tmp_path): def write(text): path = tmp_path / "job.gcode" path.write_text(text) return str(path) return write class TestRunCommand: async def test_successful_job_shows_progress(self, gcode_file, capsys): path = gcode_file("G1 X10 F1000\nG1 Y5 F1000\nG1 X0 F1000\n") rc = await amain( ["run", path, "--emulator", "--speed-factor", "100000"] ) assert rc == 0 out = capsys.readouterr().out assert "3/3" in out assert "done" in out assert "Connecting to emulated device" in out assert "[VER:" in out async def test_progress_lines_terminated_when_redirected( self, gcode_file, capsys ): """Non-TTY progress updates must be newline-terminated so redirected logs never share a physical line with progress.""" path = gcode_file("G1 X10 F1000\nG1 Y5 F1000\nG1 X0 F1000\n") rc = await amain( ["run", path, "--emulator", "--speed-factor", "100000"] ) assert rc == 0 out = capsys.readouterr().out assert "\r" not in out assert out.endswith("\n") async def test_log_file_receives_diagnostics( self, gcode_file, tmp_path, monkeypatch, capsys ): log_path = tmp_path / "session.log" monkeypatch.setenv("RUST_LOG", "info") path = gcode_file("G1 X10 F1000\nG1 Y5 F1000\nG1 X0 F1000\n") rc = await amain( [ "run", path, "--emulator", "--speed-factor", "100000", "--log-file", str(log_path), ] ) assert rc == 0 content = log_path.read_text() assert "Connected to GRBL" in content # Progress goes to stdout, diagnostics to the file: the # captured stdout must not contain log lines. out = capsys.readouterr().out assert "Connected to GRBL" not in out async def test_progress_percentages_render(self, gcode_file, capsys): lines = "\n".join(f"G1 X{i % 30} F1000" for i in range(50)) path = gcode_file(lines) rc = await amain( ["run", path, "--emulator", "--speed-factor", "20000"] ) assert rc == 0 out = capsys.readouterr().out assert "100.0%" in out assert "50/50" in out async def test_device_error_aborts_with_exit_code( self, gcode_file, capsys ): path = gcode_file("G1 X10 F1000\nG999\nG1 X0 F1000\n") rc = await amain(["run", path, "--emulator", "--speed-factor", "100"]) assert rc == 1 out = capsys.readouterr().out assert "ABORTED" in out # The abort line must carry the failure reason, not just # "job did not complete". assert "Unsupported Command" in out async def test_empty_file_is_rejected(self, gcode_file, capsys): path = gcode_file("; only comments\n\n") rc = await amain(["run", path, "--emulator"]) assert rc == 2 assert "no G-code lines" in capsys.readouterr().err async def test_missing_device_fails_cleanly(self, gcode_file): path = gcode_file("G1 X10 F1000\n") with pytest.raises(ConnectionError): # No emulator and a serial port that cannot answer. await amain( [ "run", path, "--port", "/dev/null-does-not-exist", "--timeout", "0.3", ] ) class TestStatusCommand: async def test_status_reports_printed(self, capsys): rc = await amain(["status", "--emulator", "--seconds", "0.5"]) assert rc == 0 out = capsys.readouterr().out assert "IDLE" in out assert "MPos:" in out raydriver-0.5.1/tests/test_grbl_parser.py000064400000000000000000000262061046102023000155210ustar00"""Unit tests for the pure GRBL parsers (port of test_grbl_util.py).""" import pytest from raydriver.grbl.parser import ( alarm_code_to_device_error, detect_unit_system_from_settings, error_code_to_device_error, extract_device_name, extract_device_name_from_output, gcode_to_p_number, is_grbl_output, is_report_in_inches, parse_grbl_parser_state, parse_grbl_settings, parse_msg, parse_opt_flags, parse_opt_info, parse_probe_line, parse_setting_pairs, parse_state, parse_ver, parse_version, parse_wcs_line, split_realtime_commands, strip_gcode_comments, version_supports_single_axis_homing, ) from raydriver.grbl.types import DeviceState, DeviceStatus class TestParseState: def test_basic_mpos(self): state = parse_state("") assert state.status == DeviceStatus.IDLE assert state.machine_pos == [1.0, 2.0, 3.0] def test_wpos_with_wco_recalculation(self): state = parse_state("") assert state.work_pos == [10.0, 20.0, 30.0] assert state.wco == [1.0, 2.0, 3.0] assert state.machine_pos == [11.0, 22.0, 33.0] def test_mpos_with_wco_recalculates_wpos(self): state = parse_state("") assert state.machine_pos == [11.0, 22.0, 33.0] assert state.work_pos == [10.0, 20.0, 30.0] def test_wco_inferred_from_mpos_and_wpos(self): state = parse_state("") assert state.wco == [1.0, 2.0, 3.0] def test_two_axis_position_padded_with_zero(self): state = parse_state("") assert state.machine_pos == [1.5, 2.5, 0.0] def test_four_axis_position(self): state = parse_state("") assert state.machine_pos == [1.0, 2.0, 3.0, 4.0] def test_feed_and_spindle(self): state = parse_state("") assert state.feed_rate == 500 assert state.spindle_speed is None def test_buffer_state(self): state = parse_state("") assert state.buffer_available == 15 assert state.buffer_rx_available == 127 def test_alarm_with_error_code(self): state = parse_state("") assert state.status == DeviceStatus.ALARM assert state.error is not None assert state.error.code == 1 assert state.error.title == "Hard Limit" def test_hold_substate_is_not_an_error(self): state = parse_state("") assert state.status == DeviceStatus.HOLD assert state.error is None def test_door_substate_is_not_an_error(self): state = parse_state("") assert state.status == DeviceStatus.DOOR assert state.error is None def test_unknown_status(self): state = parse_state("") assert state.status == DeviceStatus.UNKNOWN def test_inch_report_converted_to_mm(self): state = parse_state("", report_in_inches=True) assert state.machine_pos == pytest.approx([25.4, 50.8, 76.2]) def test_default_state_used_as_base(self): default = DeviceState() state = parse_state("", default=default) assert state.feed_rate == 100 assert state.machine_pos == [None, None, None] def test_malformed_report_returns_default(self): default = DeviceState() state = parse_state("\r\n") is ( None ) class TestRawOutputClassification: def test_grbl_banner(self): assert is_grbl_output(b"Grbl 1.1h") def test_grblhal_banner(self): assert is_grbl_output(b"GrblHAL 1.1h") def test_status_report(self): assert is_grbl_output(b"") def test_build_info(self): assert is_grbl_output(b"[VER:1.1h:]") assert is_grbl_output(b"[OPT:V,15,127]") assert is_grbl_output(b"[MSG:hi]") def test_non_grbl(self): assert not is_grbl_output(b"Marlin 2.0.9") assert not is_grbl_output(b"") class TestCommentStripping: def test_semicolon(self): assert strip_gcode_comments("G1 X10 ; move") == "G1 X10" def test_parens(self): assert strip_gcode_comments("G1 (comment) X10") == "G1 X10" def test_both(self): assert strip_gcode_comments("(a) G1 X1 ; tail") == "G1 X1" def test_whitespace_trimmed(self): assert strip_gcode_comments(" G1 X10 ") == "G1 X10" class TestRealtimeSplitting: def test_split(self): gcode, realtime = split_realtime_commands( ["G1 X10", "?", "!", "M5", "~"] ) assert gcode == ["G1 X10", "M5"] assert realtime == ["?", "!", "~"] class TestWcsHelpers: def test_gcode_to_p_number(self): assert gcode_to_p_number("G54") == 1 assert gcode_to_p_number("G55") == 2 assert gcode_to_p_number("G59") == 6 assert gcode_to_p_number("G53") is None assert gcode_to_p_number("G60") is None assert gcode_to_p_number("X54") is None assert gcode_to_p_number("Gabc") is None def test_parse_wcs_line(self): assert parse_wcs_line("[G54:1.0,2.0,3.0]") == ( "G54", (1.0, 2.0, 3.0), ) def test_parse_wcs_line_two_axes(self): assert parse_wcs_line("[G55:1.0,2.0]") == ("G55", (1.0, 2.0, 0.0)) def test_parse_wcs_line_no_match(self): assert parse_wcs_line("[G53:1,2,3]") is None def test_parse_grbl_parser_state(self): lines = ["[G54 G17 G21 G90 G94 M5 M9 T0 F0 S0]"] assert parse_grbl_parser_state(lines) == "G54" def test_parse_grbl_parser_state_none(self): assert parse_grbl_parser_state(["ok"]) is None class TestProbeParsing: def test_success(self): assert parse_probe_line("[PRB:10.0,20.0,5.0:1]") == ( (10.0, 20.0, 5.0), True, ) def test_failure(self): assert parse_probe_line("[PRB:0.0,0.0,0.0:0]") == ( (0.0, 0.0, 0.0), False, ) def test_negative_values(self): assert parse_probe_line("[PRB:-1.5,-2.5,-0.5:1]") == ( (-1.5, -2.5, -0.5), True, ) def test_no_match(self): assert parse_probe_line("ok") is None class TestVersionHoming: def test_newer_than_1_1(self): assert version_supports_single_axis_homing(1.2, "") def test_1_1g_and_newer(self): assert version_supports_single_axis_homing(1.1, "g") assert version_supports_single_axis_homing(1.1, "h") def test_1_1f_and_older(self): assert not version_supports_single_axis_homing(1.1, "f") assert not version_supports_single_axis_homing(1.1, "") def test_older_versions(self): assert not version_supports_single_axis_homing(0.9, "j") class TestErrorTables: def test_known_error(self): error = error_code_to_device_error("20") assert error.code == 20 assert error.title == "Unsupported Command" def test_unknown_error(self): error = error_code_to_device_error("99") assert error.code == 99 assert error.title == "Unknown Error" def test_invalid_error(self): error = error_code_to_device_error("abc") assert error.code == -1 def test_known_alarm(self): alarm = alarm_code_to_device_error("3") assert alarm.code == 3 assert alarm.title == "Abort Cycle" def test_unknown_alarm(self): alarm = alarm_code_to_device_error("99") assert alarm.code == 99 assert alarm.title == "Unknown Alarm" def test_invalid_alarm(self): alarm = alarm_code_to_device_error("x") assert alarm.code == -1 class TestOptInfo: def test_valid(self): assert parse_opt_info("[OPT:V,15,127]") == 127 assert parse_opt_info("[OPT:VMPH,63,511]") == 511 def test_invalid(self): assert parse_opt_info("[VER:1.1h:]") is None assert parse_opt_info("ok") is None class TestOptFlags: def test_valid(self): assert parse_opt_flags("[OPT:V,15,127]") == "V" assert parse_opt_flags("[OPT:VMPH,63,511]") == "VMPH" def test_invalid(self): assert parse_opt_flags("[VER:1.1h:]") is None assert parse_opt_flags("ok") is None class TestParseMsg: def test_valid(self): assert parse_msg("[MSG:machine:Ortur]") == ("machine", "Ortur") def test_no_colon(self): assert parse_msg("[MSG:hello]") is None def test_not_msg(self): assert parse_msg("[VER:1.1h:]") is None raydriver-0.5.1/tests/test_grbl_raw_lines.py000064400000000000000000000045621046102023000162110ustar00"""Raw RX/TX line events surfaced to the Python event callback.""" from conftest import make_session, start_device from raydriver.grbl import SessionEvent class TestRawLines: async def test_raw_tx_reports_interactive_command(self, mock, events): emulator, device_task = await start_device(mock) session = make_session(mock, events) try: await session.connect() await events.wait_for( SessionEvent.ConnectionStatusChanged, lambda e: e.status == "CONNECTED", ) events.events.clear() await session.execute_interactive_command("$$") await events.wait_for(SessionEvent.RawTx, lambda e: e.line == "$$") finally: await session.disconnect() device_task.cancel() async def test_raw_rx_reports_settings_reply(self, rig): session, mock, emulator, events = rig await session.execute_interactive_command("$$") await events.wait_for(SessionEvent.RawRx, lambda e: "ok" == e.line) assert any( isinstance(e, SessionEvent.RawRx) and e.line.startswith("$") for e in events.events ) async def test_raw_rx_reports_status_report(self, rig): session, mock, emulator, events = rig event = await events.wait_for( SessionEvent.RawRx, lambda e: e.line.startswith("<") and e.line.endswith(">"), ) line = event.line assert "Idle" in line or "Run" in line or "Hold" in line async def test_raw_rx_reports_welcome_message(self, mock, events): emulator, device_task = await start_device(mock) session = make_session(mock, events) try: await session.connect() await events.wait_for( SessionEvent.RawRx, lambda e: e.line.startswith("Grbl ") ) finally: await session.disconnect() device_task.cancel() async def test_raw_lines_carry_full_conversation(self, rig): session, mock, emulator, events = rig await session.execute_interactive_command("$I") raw = [ e.line for e in events.events if isinstance(e, (SessionEvent.RawRx, SessionEvent.RawTx)) ] assert "$I" in raw assert any(line.startswith("[VER:") for line in raw) assert "ok" in raw raydriver-0.5.1/tests/test_grbl_session.py000064400000000000000000000345231046102023000157110ustar00"""Session lifecycle and interactive command tests, driven against the GRBL firmware emulator.""" import asyncio import pytest from conftest import make_session, sent_bytes, start_device from raydriver.grbl import SessionEvent from raydriver.grbl.types import DeviceStatus class TestConnection: async def test_handshake_and_build_info(self, mock, events): emulator, device_task = await start_device(mock) session = make_session(mock, events) try: await session.connect() event = await events.wait_for( SessionEvent.ConnectionStatusChanged, lambda e: e.status == "CONNECTED", ) assert event.message is None # The connection loop queried $I, whose OPT line # advertised the emulated 128-byte RX buffer. assert b"$I\n" in sent_bytes(mock) assert session.rx_buffer_size == 128 finally: await session.disconnect() device_task.cancel() async def test_handshake_timeout_on_phantom_port(self, mock, events): # No emulator attached: nothing ever answers. session = make_session(mock, events, {"handshake_timeout": 0.15}) try: await session.connect() event = await events.wait_for( SessionEvent.ConnectionStatusChanged, lambda e: e.status == "ERROR", ) assert "No response" in event.message await events.wait_for( SessionEvent.ConnectionStatusChanged, lambda e: e.status == "SLEEPING", ) finally: await session.disconnect() async def test_reconnect_after_recovery(self, mock, events): session = make_session(mock, events, {"handshake_timeout": 0.15}) try: await session.connect() await events.wait_for( SessionEvent.ConnectionStatusChanged, lambda e: e.status == "ERROR", ) # The device appears (cable plugged back in). emulator, device_task = await start_device(mock) await events.wait_for( SessionEvent.ConnectionStatusChanged, lambda e: e.status == "CONNECTED", timeout=8.0, ) device_task.cancel() finally: await session.disconnect() async def test_command_while_disconnected_raises(self, mock, events): session = make_session(mock, events) with pytest.raises(ConnectionError): await session.execute_interactive_command("$X") class TestStatusReports: async def test_status_report_updates_state(self, rig): session, mock, emulator, events = rig # Handshake polls already delivered the first report. state = session.state assert state.status == DeviceStatus.IDLE assert state.machine_pos == [0.0, 0.0, 0.0] async def test_rx_buffer_size_override_wins(self, mock, events): emulator, device_task = await start_device(mock) session = make_session(mock, events, {"rx_buffer_size_override": 64}) try: await session.connect() await events.wait_for( SessionEvent.ConnectionStatusChanged, lambda e: e.status == "CONNECTED", ) # OPT and Bf advertise 128, but the override sticks. await asyncio.sleep(0.15) assert session.rx_buffer_size == 64 finally: await session.disconnect() device_task.cancel() async def test_fragmented_status_report_ignored(self, rig): session, mock, emulator, events = rig states_before = len( [ e for e in events.events if isinstance(e, SessionEvent.StateChanged) ] ) mock.push(b"\r\nok\r\n") assert await fut == ["ok"] assert session.buffer_count == 0 async def test_commands_rejected_while_alarmed(self, rig): session, mock, emulator, events = rig emulator.settings["22"] = 1 emulator.settings["20"] = 1 # A move beyond the travel limit: the line is rejected with # an error (after ALARM:2) and the machine locks. lines = await session.execute_interactive_command("G0 X10000") assert "ALARM:2" in lines assert lines[-1] == "error:5" await events.wait_for( SessionEvent.StateChanged, lambda e: e.state.error is not None ) # Like real Grbl, subsequent gcode is rejected. lines = await session.execute_interactive_command("G0 X1") assert lines == ["error:9"] # $X unlocks. lines = await session.execute_interactive_command("$X") assert "[MSG:Caution: Unlocked]" in lines assert lines[-1] == "ok" class TestHold: async def test_hold_sends_realtime_and_updates_state(self, rig): session, mock, emulator, events = rig await session.set_hold(True) assert b"!" in mock.sent() await events.wait_for( SessionEvent.StateChanged, lambda e: e.state.status == DeviceStatus.HOLD, ) await session.set_hold(False) assert b"~" in mock.sent() async def test_resume_without_job_reports_idle(self, rig): session, mock, emulator, events = rig await session.set_hold(True) await session.set_hold(False) await events.wait_for( SessionEvent.StateChanged, lambda e: e.state.status == DeviceStatus.IDLE, ) class TestDeviceOperations: async def test_move_to_reaches_target(self, rig): session, mock, emulator, events = rig await session.move_to(1500, 100, -50) await asyncio.sleep(0.1) assert emulator.mpos == pytest.approx([100.0, -50.0, 0.0]) assert b"$J=G90 G21 F1500 X100.0 Y-50.0\n" in sent_bytes(mock) async def test_jog_reaches_target(self, rig): session, mock, emulator, events = rig await session.jog(1000, [("x", 10), ("y", -2.5)]) await asyncio.sleep(0.1) assert emulator.mpos == pytest.approx([10.0, -2.5, 0.0]) assert b"$J=G91 G21 F1000 X10.0 Y-2.5\n" in sent_bytes(mock) async def test_jog_no_axes_is_noop(self, rig): session, mock, emulator, events = rig mock.clear_sent() await session.jog(1000, []) assert sent_bytes(mock) == b"" async def test_select_tool(self, rig): session, mock, emulator, events = rig await session.select_tool(2) assert b"T2\n" in sent_bytes(mock) async def test_set_power_reflected_in_parser_state(self, rig): session, mock, emulator, events = rig await session.set_power(500) lines = await session.execute_interactive_command("$G") assert "M4" in lines[0] assert "S500" in lines[0] async def test_set_power_off(self, rig): session, mock, emulator, events = rig await session.set_power(None) lines = await session.execute_interactive_command("$G") assert "M5" in lines[0] assert b"M5\n" in sent_bytes(mock) async def test_set_focus_power(self, rig): session, mock, emulator, events = rig await session.set_focus_power(100) lines = await session.execute_interactive_command("$G") assert "M3" in lines[0] async def test_home_cycle(self, rig): session, mock, emulator, events = rig await session.move_to(1500, 50, 50) await asyncio.sleep(0.05) emulator.settings["22"] = 1 await session.home(None, "G54") await asyncio.sleep(0.1) assert emulator.mpos == pytest.approx([0.0, 0.0, 0.0]) text = sent_bytes(mock) assert b"$H\n" in text assert b"G4 P0.01\n" in text assert b"G55\n" in text assert b"G54\n" in text async def test_home_disabled_reports_error(self, rig): session, mock, emulator, events = rig assert emulator.settings["22"] == 0 lines = await session.execute_command("$H") assert lines == ["error:5"] async def test_update_dialect(self, rig): session, mock, emulator, events = rig session.update_dialect({"move_to": "G0 X{x} Y{y}"}) await session.move_to(1500, 10, 20) assert b"G0 X10.0 Y20.0\n" in sent_bytes(mock) class TestSettings: async def test_read_settings(self, rig): session, mock, emulator, events = rig pairs = await session.read_settings() as_dict = dict(pairs) assert as_dict["13"] == "0" assert as_dict["110"] == "500.000" async def test_write_setting_persists(self, rig): session, mock, emulator, events = rig await session.write_setting("110", "750.000") assert emulator.settings["110"] == 750.0 assert b"$110=750.000\n" in sent_bytes(mock) pairs = await session.read_settings() assert dict(pairs)["110"] == "750.000" async def test_detect_unit_system_metric(self, rig): session, mock, emulator, events = rig assert await session.detect_unit_system() == "metric" async def test_detect_unit_system_imperial(self, rig): session, mock, emulator, events = rig emulator.settings["13"] = 1 assert await session.detect_unit_system() == "imperial" class TestWcs: async def test_read_wcs_offsets(self, rig): session, mock, emulator, events = rig offsets = await session.read_wcs_offsets() assert offsets["G54"] == (0.0, 0.0, 0.0) await events.wait_for(SessionEvent.WcsUpdated) async def test_set_then_read_wcs_offset(self, rig): session, mock, emulator, events = rig await session.set_wcs_offset("G55", 10, 20, None) assert b"G10 L2 P2 X10.0 Y20.0\n" in sent_bytes(mock) offsets = await session.read_wcs_offsets() assert offsets["G55"] == (10.0, 20.0, 0.0) async def test_set_wcs_offset_with_z(self, rig): session, mock, emulator, events = rig await session.set_wcs_offset("G54", 1, 2, 3) assert b"G10 L2 P1 X1.0 Y2.0 Z3.0\n" in sent_bytes(mock) assert emulator.wcs["G54"] == [1.0, 2.0, 3.0] async def test_set_wcs_offset_invalid_slot(self, rig): session, mock, emulator, events = rig with pytest.raises(RuntimeError): await session.set_wcs_offset("G53", 1, 2, None) async def test_read_parser_state(self, rig): session, mock, emulator, events = rig assert await session.read_parser_state() == "G54" class TestProbe: async def test_successful_probe(self, rig): session, mock, emulator, events = rig emulator.probe_touch = (0.0, 0.0, -5.0) pos = await session.run_probe_cycle("Z", 10.0, 100) assert pos == pytest.approx((0.0, 0.0, -5.0)) await events.wait_for( SessionEvent.ProbeStatusChanged, lambda e: "triggered" in e.message, ) assert emulator.mpos == pytest.approx([0.0, 0.0, -5.0]) async def test_failed_probe_raises_alarm(self, rig): session, mock, emulator, events = rig # No contact configured: Grbl reports PRB:0 + ALARM:4. pos = await session.run_probe_cycle("Z", 10.0, 100) assert pos is None await events.wait_for( SessionEvent.ProbeStatusChanged, lambda e: e.message == "Probe failed", ) event = await events.wait_for( SessionEvent.StateChanged, lambda e: e.state.error is not None ) assert event.state.error.code == 4 assert event.state.error.title.startswith("Probe Fail") async def test_probe_after_alarm_lock_can_unlock(self, rig): session, mock, emulator, events = rig await session.run_probe_cycle("Z", 10.0, 100) await events.wait_for( SessionEvent.StateChanged, lambda e: e.state.error is not None ) lines = await session.execute_command("$X") assert "[MSG:Caution: Unlocked]" in lines async def test_probe_unit_conversion(self, rig): session, mock, emulator, events = rig emulator.settings["13"] = 1 assert await session.detect_unit_system() == "imperial" emulator.probe_touch = (0.0, 0.0, -25.4) pos = await session.run_probe_cycle("Z", 50.0, 100) assert pos[2] == pytest.approx(-25.4) class TestInchReports: async def test_imperial_reports_converted_to_mm(self, rig): session, mock, emulator, events = rig await session.move_to(1500, 25.4, 0) emulator.settings["13"] = 1 assert await session.detect_unit_system() == "imperial" event = await events.wait_for( SessionEvent.StateChanged, lambda e: ( e.state.machine_pos[0] is not None and e.state.machine_pos[0] > 20.0 ), ) # The report arrived in inches; the driver reports mm. assert event.state.machine_pos[0] == pytest.approx(25.4, abs=0.1) raydriver-0.5.1/tests/test_grbl_streaming.py000064400000000000000000000263631046102023000162220ustar00"""Job streaming tests against the firmware emulator: backpressure, planner-deferred acknowledgements, stalls, alarms and cancel.""" import asyncio import time import pytest from conftest import ( make_session, sent_bytes, start_device, ) from raydriver.emulator import WELCOME from raydriver.grbl import SessionEvent from raydriver.grbl.types import DeviceStatus def gcode(n_lines): return "\n".join(f"G1 X{i % 50} F1000" for i in range(n_lines)) def slow_gcode(n_lines): """Long alternating moves: each block runs for seconds at low speed factors, keeping the planner full and the acks deferred.""" return "\n".join(f"G1 X{50 if i % 2 else 0} F600" for i in range(n_lines)) class TestRunBasics: async def test_simple_job_completes_and_moves(self, rig): session, mock, emulator, events = rig await session.run("G1 X10 F1000\nG1 Y5 F1000\n") await events.wait_for(SessionEvent.JobFinished) await asyncio.sleep(0.1) assert session.buffer_count == 0 assert session.pending_commands == [] assert emulator.mpos == pytest.approx([10.0, 5.0, 0.0]) async def test_progress_callbacks_fire_per_op(self, rig): session, mock, emulator, events = rig done = [] gcode_text = "G1 X1 F1000\nG1 X2 F1000\nG1 X3 F1000\nG1 X4 F1000\n" op_map = {0: 0, 1: 0, 3: 1} await session.run(gcode_text, op_map, [], done.append) await asyncio.sleep(0.1) assert done == [0, 1] async def test_comments_and_empty_lines_skipped(self, rig): session, mock, emulator, events = rig await session.run( "G1 X1 F1000 ; comment\n\n(comment only)\nG1 X2 F1000\n" ) await events.wait_for(SessionEvent.JobFinished) sent = sent_bytes(mock) assert b"comment" not in sent assert b"G1 X1 F1000\n" in sent assert b"G1 X2 F1000\n" in sent async def test_job_status_reflected_as_run(self, rig): session, mock, emulator, events = rig emulator.speed_factor = 50 # ~0.5 s of motion job = asyncio.ensure_future(session.run(gcode(30))) await events.wait_for( SessionEvent.StateChanged, lambda e: e.state.status.name == "RUN", ) emulator.speed_factor = 100000 await asyncio.wait_for(job, timeout=10) await events.wait_for(SessionEvent.JobFinished) async def test_long_job_never_overflows_rx_buffer(self, rig): session, mock, emulator, events = rig emulator.speed_factor = 500 # More lines than planner + RX buffer combined: the emulator # must never see an RX overflow from the character-counting # sender. await asyncio.wait_for(session.run(gcode(60)), timeout=30) await events.wait_for(SessionEvent.JobFinished) await asyncio.sleep(0.1) assert emulator.rx_overrun is False assert session.buffer_count == 0 class TestErrorsDuringJob: async def test_error_halts_stream(self, rig): session, mock, emulator, events = rig emulator.speed_factor = 100 lines = [f"G1 X{i} F1000" for i in range(40)] lines[5] = "G999" job = asyncio.ensure_future(session.run("\n".join(lines))) await asyncio.wait_for(job, timeout=10) await events.wait_for(SessionEvent.JobFinished) await asyncio.sleep(0.1) sent = sent_bytes(mock) assert sent.count(b"G1 X") < 40 # The interrupt handler ran an emergency cancel. assert b"\x18" in sent # Soft reset reboots the emulator: welcome banner again. assert emulator.tx_log.count(WELCOME) >= 2 states = [ e.state for e in events.events if isinstance(e, SessionEvent.StateChanged) ] assert any(s.error is not None and s.error.code == 20 for s in states) # run() resolves normally even on failure, so the terminal # error must be readable from the device state afterwards. # The protocol error (proper GRBL code) is preserved. assert session.state.error is not None assert session.state.error.code == 20 # An emergency reset racing in-flight motion leaves the # machine alarm-locked (real Grbl behaves the same). Recover # like an operator would, then wait for the between-jobs # status poller to reflect Idle before starting the next job. await session.execute_interactive_command("$X") deadline = time.monotonic() + 5 while ( session.state.status != DeviceStatus.IDLE and time.monotonic() < deadline ): await asyncio.sleep(0.05) assert session.state.status == DeviceStatus.IDLE # A subsequent job start clears the error again. job = asyncio.ensure_future(session.run(gcode(5))) await asyncio.wait_for(job, timeout=10) await events.wait_for(SessionEvent.JobFinished) assert session.state.error is None async def test_soft_limit_alarm_halts_stream(self, rig): session, mock, emulator, events = rig emulator.settings["22"] = 1 emulator.settings["20"] = 1 emulator.speed_factor = 100 lines = [f"G1 X{i} F1000" for i in range(40)] lines[3] = "G1 X10000 F1000" job = asyncio.ensure_future(session.run("\n".join(lines))) await asyncio.wait_for(job, timeout=10) await events.wait_for(SessionEvent.JobFinished) await asyncio.sleep(0.1) assert sent_bytes(mock).count(b"G1 X") < 40 states = [ e.state for e in events.events if isinstance(e, SessionEvent.StateChanged) ] assert any( s.error is not None and s.error.title == "Soft Limit" for s in states ) class TestStallBehavior: async def test_stall_retries_while_device_alive(self, mock, events): emulator, device_task = await start_device(mock, speed_factor=2) session = make_session(mock, events) try: await session.connect() await events.wait_for( SessionEvent.ConnectionStatusChanged, lambda e: e.status == "CONNECTED", ) mock.clear_sent() # Slow motion: the planner stays full and acks stop, # filling the host buffer. The device still answers # polls (Run), so the streamer must keep retrying # rather than abort. job = asyncio.ensure_future(session.run(slow_gcode(40))) await asyncio.sleep(1.5) assert not job.done() assert sent_bytes(mock).count(b"G1 X") < 40 # Speed the machine up: the job drains and completes. emulator.speed_factor = 100000 await asyncio.wait_for(job, timeout=30) await events.wait_for(SessionEvent.JobFinished) assert emulator.rx_overrun is False finally: await session.disconnect() device_task.cancel() async def test_stall_aborts_when_device_dies(self, mock, events): emulator, device_task = await start_device(mock, speed_factor=2) session = make_session(mock, events) try: await session.connect() await events.wait_for( SessionEvent.ConnectionStatusChanged, lambda e: e.status == "CONNECTED", ) mock.clear_sent() emulator.silent = True # the cable is gone await asyncio.wait_for(session.run(slow_gcode(40)), timeout=40) await events.wait_for(SessionEvent.JobFinished) assert sent_bytes(mock).count(b"G1 X") < 40 # No protocol error was seen, so run() must surface the # job-level failure ("device stopped responding") through # the device state, otherwise callers cannot tell a dead # job from a completed one. error = session.state.error assert error is not None assert error.title == "Job Error" assert "stopped responding" in error.description finally: await session.disconnect() device_task.cancel() class TestCancel: async def test_cancel_mid_job_sends_reset_and_safety_shutdown(self, rig): session, mock, emulator, events = rig emulator.speed_factor = 2 job = asyncio.ensure_future(session.run(gcode(40))) await asyncio.sleep(0.3) assert not job.done() mock.clear_sent() emulator.tx_log.clear() await session.cancel() await asyncio.sleep(0.2) sent = sent_bytes(mock) assert b"\x18" in sent assert b"M5\n" in sent assert b"M9\n" in sent # Soft reset mid-motion reboots Grbl into the alarm lock. assert emulator.tx_log.count(WELCOME) >= 1 finished = [ e for e in events.events if isinstance(e, SessionEvent.JobFinished) ] assert len(finished) == 1 async def test_cancel_without_job(self, rig): session, mock, emulator, events = rig mock.clear_sent() await session.cancel() await asyncio.sleep(0.1) sent = sent_bytes(mock) assert b"\x18" in sent assert b"M5\n" in sent async def test_cancel_emergency_adds_estop(self, rig): session, mock, emulator, events = rig session.update_dialect({"emergency_stop": "M112"}) mock.clear_sent() await session.cancel(emergency=True) await asyncio.sleep(0.1) assert b"M112\n" in sent_bytes(mock) async def test_cancelled_sender_does_not_resurrect(self, rig): """Issue #428 regression: after cancel(), a sender parked on a full buffer must never resume streaming the cancelled job.""" session, mock, emulator, events = rig emulator.speed_factor = 2 job = asyncio.ensure_future(session.run(slow_gcode(40))) await asyncio.sleep(0.5) assert not job.done() await session.cancel() await asyncio.sleep(0.2) assert job.done() def data_sent(): return b"".join(c for c in mock.sent() if c != b"?") sent_after = data_sent() assert sent_after.count(b"G1 X") < 40 # Late acks and free buffer must not resurrect the job. mock.push(b"ok\r\n" * 60) await asyncio.sleep(0.5) assert data_sent() == sent_after # Commands work again once the cancelled job wound down. The # soft reset locked the emulator, so unlock first. lines = await asyncio.wait_for( session.execute_command("$X"), timeout=5 ) assert lines[-1] == "ok" class TestRunRaw: async def test_realtime_commands_bypass_stream(self, rig): session, mock, emulator, events = rig mock.clear_sent() await session.run_raw("?") await asyncio.sleep(0.1) sent = sent_bytes(mock) assert b"?" in sent assert sent.count(b"\n") == 0 async def test_mixed_raw(self, rig): session, mock, emulator, events = rig mock.clear_sent() await session.run_raw("G1 X1 F1000\n!\nG1 X2 F1000\n") await events.wait_for(SessionEvent.JobFinished) sent = sent_bytes(mock) assert b"G1 X1 F1000\n" in sent assert b"!" in sent assert b"G1 X2 F1000\n" in sent raydriver-0.5.1/tests/test_grbl_stress.py000064400000000000000000000064231046102023000155470ustar00"""Stress tests against the firmware emulator: fragmented responses, status reports interleaved with acknowledgements, long jobs.""" import asyncio from conftest import make_session, start_device from raydriver.grbl import SessionEvent def gcode(n_lines): return "\n".join(f"G1 X{i % 50} F2000" for i in range(n_lines)) class TestStress: async def test_fragmented_output_long_job(self, mock, events): # The emulator splits every response into 1-12 byte pieces, # exactly like noisy serial delivery. emulator, device_task = await start_device( mock, speed_factor=5000, fragment_output=True ) session = make_session(mock, events) try: await session.connect() await events.wait_for( SessionEvent.ConnectionStatusChanged, lambda e: e.status == "CONNECTED", timeout=5.0, ) done = [] n = 400 op_map = {i: i for i in range(n)} await asyncio.wait_for( session.run(gcode(n), op_map, [], done.append), timeout=60.0, ) await asyncio.sleep(0.1) assert session.buffer_count == 0 assert session.pending_commands == [] assert len(done) == n assert emulator.rx_overrun is False finally: await session.disconnect() device_task.cancel() async def test_polling_during_job_stays_stable(self, mock, events): emulator, device_task = await start_device(mock, speed_factor=1000) session = make_session( mock, events, {"poll_status_while_running": True} ) try: await session.connect() await events.wait_for( SessionEvent.ConnectionStatusChanged, lambda e: e.status == "CONNECTED", timeout=5.0, ) n = 200 await asyncio.wait_for(session.run(gcode(n)), timeout=60.0) await asyncio.sleep(0.2) assert session.buffer_count == 0 assert emulator.rx_overrun is False # Status reports kept flowing throughout the job. reports = emulator.tx_log.count(b"<") assert reports > 5 finally: await session.disconnect() device_task.cancel() async def test_1000_line_job(self, mock, events): emulator, device_task = await start_device(mock, speed_factor=10000) session = make_session(mock, events) try: await session.connect() await events.wait_for( SessionEvent.ConnectionStatusChanged, lambda e: e.status == "CONNECTED", timeout=5.0, ) done = [] n = 1000 op_map = {i: i for i in range(n)} await asyncio.wait_for( session.run(gcode(n), op_map, [], done.append), timeout=120.0, ) await asyncio.sleep(0.1) assert session.buffer_count == 0 assert session.pending_commands == [] assert len(done) == n assert emulator.rx_overrun is False finally: await session.disconnect() device_task.cancel() raydriver-0.5.1/tests/test_grbl_types.py000064400000000000000000000146611046102023000153730ustar00"""Tests for the shared value types: DeviceState, DeviceStatus and DeviceError. Covers the Python-facing construction surface (setters and the DeviceError constructor) and the merge/round-trip contract that embedders rely on when they keep their own state object and feed it back to ``parse_state`` as the default. """ from raydriver.grbl.parser import parse_state from raydriver.grbl.types import DeviceError, DeviceState, DeviceStatus def build_state(**overrides) -> DeviceState: """A fully populated DeviceState, built through the Python setters, with optional per-field overrides.""" state = DeviceState() state.status = overrides.get("status", DeviceStatus.RUN) state.error = overrides.get("error", None) state.machine_pos = overrides.get("machine_pos", [11.0, 22.0, 33.0]) state.work_pos = overrides.get("work_pos", [10.0, 20.0, 30.0]) state.wco = overrides.get("wco", [1.0, 2.0, 3.0]) state.feed_rate = overrides.get("feed_rate", 500) state.spindle_speed = overrides.get("spindle_speed", 1000) state.buffer_available = overrides.get("buffer_available", 62) state.buffer_rx_available = overrides.get("buffer_rx_available", 100) return state class TestDeviceStateDefaults: def test_fresh_state_defaults(self): state = DeviceState() assert state.status == DeviceStatus.UNKNOWN assert state.error is None assert state.machine_pos == [None, None, None] assert state.work_pos == [None, None, None] assert state.wco == [0.0, 0.0, 0.0] assert state.feed_rate is None assert state.spindle_speed is None assert state.buffer_available is None assert state.buffer_rx_available is None def test_status_name(self): assert DeviceStatus.ALARM.name == "ALARM" assert parse_state("").status.name == "IDLE" class TestDeviceStateSetters: def test_set_all_fields(self): state = build_state() assert state.status == DeviceStatus.RUN assert state.machine_pos == [11.0, 22.0, 33.0] assert state.work_pos == [10.0, 20.0, 30.0] assert state.wco == [1.0, 2.0, 3.0] assert state.feed_rate == 500 assert state.spindle_speed == 1000 assert state.buffer_available == 62 assert state.buffer_rx_available == 100 def test_set_error(self): state = DeviceState() error = DeviceError(20, "Unsupported Command", "details") state.error = error assert state.error == error assert state.error.code == 20 assert state.error.title == "Unsupported Command" assert state.error.description == "details" def test_clear_error_with_none(self): state = build_state(error=DeviceError(1, "t", "d")) state.error = None assert state.error is None def test_unknown_entries_survive_setter(self): state = DeviceState() state.machine_pos = [1.0, None, 3.0] assert state.machine_pos == [1.0, None, 3.0] def test_status_from_member_lookup(self): state = DeviceState() state.status = getattr(DeviceStatus, "HOLD") assert state.status == DeviceStatus.HOLD class TestDeviceStateEquality: def test_equal_states_compare_equal(self): assert build_state() == build_state() def test_status_difference_compares_unequal(self): assert build_state() != build_state(status=DeviceStatus.IDLE) def test_error_difference_compares_unequal(self): plain = build_state() failing = build_state(error=DeviceError(9, "t", "d")) assert plain != failing def test_position_difference_compares_unequal(self): assert build_state() != build_state(machine_pos=[1.0, 2.0, 3.0]) class TestDeviceError: def test_constructor_and_getters(self): error = DeviceError(9, "Commands Locked", "Clear the alarm state.") assert error.code == 9 assert error.title == "Commands Locked" assert error.description == "Clear the alarm state." def test_equality(self): assert DeviceError(1, "a", "b") == DeviceError(1, "a", "b") assert DeviceError(1, "a", "b") != DeviceError(2, "a", "b") def test_repr_contains_code_and_title(self): text = repr(DeviceError(7, "Memory Error", "d")) assert "7" in text assert "Memory Error" in text class TestParseStateWithBuiltDefault: """parse_state merges the report into a caller-provided default; the default may be a state constructed through the Python setters (the round-trip embedders depend on).""" def test_report_updates_status_of_built_state(self): state = parse_state( "", build_state(status=DeviceStatus.ALARM) ) assert state.status == DeviceStatus.IDLE assert state.error is None def test_report_keeps_unreported_fields_of_built_state(self): state = parse_state( "", build_state(feed_rate=500, wco=[1.0, 2.0, 3.0]), ) assert state.feed_rate == 500 assert state.machine_pos == [11.0, 22.0, 33.0] def test_alarm_report_sets_error_on_built_state(self): state = parse_state("", build_state(feed_rate=100)) assert state.status == DeviceStatus.ALARM assert state.error is not None assert state.error.code == 1 assert state.feed_rate == 500 def test_repeated_merge_is_stable(self): merged = build_state() for _ in range(3): merged = parse_state("", merged) assert merged.status == DeviceStatus.HOLD assert merged.machine_pos == [11.0, 22.0, 33.0] assert merged.feed_rate == 500 class TestStateRoundTrip: """Copying a parsed state field-by-field through the setters reproduces it exactly — the contract the Rayforge conversion helpers implement.""" def test_copy_via_setters_reproduces_parsed_state(self): parsed = parse_state("") copy = DeviceState() copy.status = parsed.status copy.error = parsed.error copy.machine_pos = parsed.machine_pos copy.work_pos = parsed.work_pos copy.wco = parsed.wco copy.feed_rate = parsed.feed_rate copy.spindle_speed = parsed.spindle_speed copy.buffer_available = parsed.buffer_available copy.buffer_rx_available = parsed.buffer_rx_available assert copy == parsed