Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,32 @@
The official Cartographer3D plugin.

Documentation can be found at [https://docs.cartographer3d.com/](https://docs.cartographer3d.com/)

## Kalico multiple probes

Support is capability-based: Kalico with the central probe registry from
[Kalico PR #972](https://github.com/KalicoCrew/kalico/pull/972) always registers Cartographer as `cartographer`.
`register_as_probe: true` (default) makes it the default probe; `false` registers it as named-only.
Older Kalico and other firmware retain their existing behavior.

The supported standard mux commands are `PROBE`, `PROBE_ACCURACY`, `QUERY_PROBE`, and `Z_OFFSET_APPLY_PROBE`;
Cartographer extends rather than overrides other probes' handlers. Probe selectors are case-sensitive.

```gcode
PROBE PROBE=cartographer
BED_MESH_CALIBRATE PROBE=cartographer METHOD=scan
```

For bed mesh, omitting `PROBE` follows the default probe. Selected Cartographer uses optimized scanning by default;
other selected or default probes use native automatic meshing. `METHOD=scan` requires Cartographer;
`METHOD=manual` remains native.
Unknown `PROBE` selectors are rejected before movement even with `METHOD=manual`; without a default probe,
nonmanual meshing requires an explicit `PROBE`, while manual meshing remains available.

Endstop chip names are unchanged: `probe:z_virtual_endstop` with `register_as_probe: true`,
`cartographer_probe:z_virtual_endstop` with `false`. There is no `cartographer:` pin-chip alias.

Native `CALIBRATE_Z` and `PROBE_Z_ACCURACY` cannot use Cartographer; use the existing
`CARTOGRAPHER_SCAN_CALIBRATE` / `CARTOGRAPHER_TOUCH_CALIBRATE` commands instead.
`[nozzle_cleanup]` selecting Cartographer is rejected at configuration time; if Cartographer is the default,
explicitly select another available probe. Cartographer does not provide nozzle-cleaning support.
174 changes: 174 additions & 0 deletions src/cartographer/adapters/kalico/integrator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
from __future__ import annotations

from importlib import import_module
from typing import TYPE_CHECKING, Callable, Protocol, cast, final

from typing_extensions import override

from cartographer.adapters.kalico.probe import KalicoCartographerProbe
from cartographer.adapters.klipper.endstop import KlipperEndstop
from cartographer.adapters.klipper_like.integrator import (
FallbackMacroAdapter,
KlipperLikeAdapters,
KlipperLikeIntegrator,
catch_macro_errors,
)
from cartographer.interfaces.printer import SupportsFallbackMacro

if TYPE_CHECKING:
from configfile import ConfigWrapper
from gcode import GCodeCommand
from klippy import Printer

from cartographer.core import MacroRegistration, PrinterCartographer


class _ProbeRegistry(Protocol):
def add_probe_object(self, obj: KalicoCartographerProbe, config: ConfigWrapper) -> object: ...
def get_command_probe(self, gcmd: GCodeCommand, default: object = ...) -> object | None: ...


class _ProbeList(Protocol):
def get_list(self, printer: Printer) -> _ProbeRegistry: ...


class _NativeZCalibration(Protocol):
load_probe: Callable[[object], object]


@final
class KalicoIntegrator(KlipperLikeIntegrator):
def __init__(self, adapters: KlipperLikeAdapters) -> None:
super().__init__(adapters, KalicoCartographerProbe)
self._probe_list: _ProbeList | None = None
self._registry_probe: KalicoCartographerProbe | None = None
try:
probe_module = import_module("extras.probe")
except ModuleNotFoundError as error:
if error.name not in ("extras", "extras.probe"):
raise
else:
probe_list = getattr(probe_module, "ProbeList", None)
if all(
callable(getattr(probe_list, method, None))
for method in ("get_list", "add_probe_object", "get_command_probe")
):
self._probe_list = cast("_ProbeList", probe_list)

@override
def setup(self) -> None:
super().setup()
if self._probe_list is not None:
self._printer.register_event_handler("klippy:connect", self._guard_native_z_calibration)

def _guard_native_z_calibration(self) -> None:
if not self._config.wrapper.has_section("z_calibration"):
return
lookup = cast("Callable[[str, object], object]", self._printer.lookup_object)
helper = lookup("z_calibration", None)
if not callable(getattr(helper, "load_probe", None)):
msg = "Configured [z_calibration] helper requires a callable load_probe"
raise self._config.wrapper.error(msg)
native = cast("_NativeZCalibration", helper)
original = native.load_probe
probe = self._registry_probe

def load_probe(selected: object) -> object:
if probe is not None and selected is probe:
msg = "Cartographer native Z calibration is unsupported; use Cartographer calibration instead"
raise self._printer.command_error(msg)
return original(selected)

# Guard the shared helper boundary, including renamed commands and direct callers.
native.load_probe = load_probe

@override
def register_probe(self, cartographer: PrinterCartographer) -> None:
if self._probe_list is None:
super().register_probe(cartographer)
return

probe = KalicoCartographerProbe(
self._toolhead,
cartographer.scan_mode,
cartographer.probe_macro,
cartographer.query_probe_macro,
cartographer.config.general,
printer=self._printer,
mcu_probe=KlipperEndstop(self._mcu, cartographer.scan_mode),
)
if self._config.wrapper.has_section("nozzle_cleanup"):
section = self._config.wrapper.getsection("nozzle_cleanup")
selected = section.get("probe", None)
if selected == probe.probe_name or (selected is None and probe.is_default_probe):
msg = "[nozzle_cleanup] is unsupported with Cartographer; select another probe"
raise section.error(msg)
registry = self._probe_list.get_list(self._printer)
_ = registry.add_probe_object(probe, self._config.wrapper)
self._registry_probe = probe
if not probe.is_default_probe:
for registration in cartographer.probe_macros:
self.register_macro(registration)

@override
def register_macro(self, registration: MacroRegistration) -> None:
probe = self._registry_probe
if probe is not None and registration.name == "BED_MESH_CALIBRATE":
assert self._probe_list is not None
registry = self._probe_list.get_list(self._printer)
macro = registration.macro
original = self._gcode.register_command(registration.name, None)
if original is not None and isinstance(macro, SupportsFallbackMacro):
macro.set_fallback_macro(FallbackMacroAdapter(registration.name, original))

def route_mesh(gcmd: GCodeCommand) -> None:
selected = registry.get_command_probe(gcmd, None)
method = gcmd.get("METHOD", None)
if method is not None:
method = method.lower()
if selected is None and method != "manual":
# Ask the registry for its native missing-default error before any work.
selected = registry.get_command_probe(gcmd)
if selected is probe and method in (None, "scan"):
params = {key: value for key, value in gcmd.get_command_parameters().items() if key != "PROBE"}
command = self._gcode.create_gcode_command(gcmd.get_command(), gcmd.get_commandline(), params)
macro.run(command)
return
if method == "scan":
msg = "METHOD=scan requires the Cartographer probe"
raise gcmd.error(msg)
if original is None:
msg = "The native BED_MESH_CALIBRATE handler is unavailable"
raise gcmd.error(msg)
if selected is probe and method != "manual":
macro.run(gcmd)
else:
original(gcmd)

self._gcode.register_command(registration.name, catch_macro_errors(route_mesh), desc=macro.description)
return

if probe is None or registration.name not in (
"PROBE",
"PROBE_ACCURACY",
"QUERY_PROBE",
"Z_OFFSET_APPLY_PROBE",
):
super().register_macro(registration)
return

def run(gcmd: GCodeCommand) -> None:
params = {key: value for key, value in gcmd.get_command_parameters().items() if key != "PROBE"}
command = self._gcode.create_gcode_command(gcmd.get_command(), gcmd.get_commandline(), params)
registration.macro.run(command)

handler = catch_macro_errors(run)
keys = (probe.probe_name, None) if probe.is_default_probe else (probe.probe_name,)
for key in keys:
self._gcode.register_mux_command(
registration.name,
"PROBE",
key,
handler,
desc=registration.macro.description,
)
12 changes: 12 additions & 0 deletions src/cartographer/adapters/kalico/probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@

if TYPE_CHECKING:
from gcode import GCodeCommand
from klippy import Printer

from cartographer.adapters.klipper.endstop import KlipperEndstop
from cartographer.interfaces.configuration import GeneralConfig
from cartographer.interfaces.printer import ProbeMode, Toolhead
from cartographer.macros.probe import ProbeMacro, QueryProbeMacro
Expand All @@ -21,7 +23,17 @@ def __init__(
probe_macro: ProbeMacro,
query_probe_macro: QueryProbeMacro,
config: GeneralConfig,
*,
printer: Printer | None = None,
mcu_probe: KlipperEndstop | None = None,
) -> None:
self.name = "cartographer"
self.probe_name = "cartographer"
self.is_default_probe = config.register_as_probe
self.printer = printer
if mcu_probe is not None:
self.mcu_probe = mcu_probe

self.probe = probe
self.probe_macro = probe_macro
self.query_probe_macro = query_probe_macro
Expand Down
7 changes: 5 additions & 2 deletions src/cartographer/adapters/klipper_like/integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,6 @@ def get_steppers(self) -> list[MCU_stepper]: ...
def get_endstops(self) -> list[tuple[MCU_endstop, str]]: ...


@final
class KlipperLikeIntegrator(Integrator):
def __init__(
self,
Expand All @@ -77,7 +76,9 @@ def __init__(
self._printer: Printer = adapters.printer
self._mcu: CartographerMcu = adapters.mcu
self._toolhead: Toolhead = adapters.toolhead
self._target_probe_class = target_probe_class
self._target_probe_class: Callable[
[Toolhead, ProbeMode, ProbeMacro, QueryProbeMacro, GeneralConfig], object
] = target_probe_class

self._gcode: GCodeDispatch = self._printer.lookup_object("gcode")

Expand Down Expand Up @@ -113,6 +114,8 @@ def register_macro(self, registration: MacroRegistration) -> None:

@override
def register_probe(self, cartographer: PrinterCartographer) -> None:
if not cartographer.config.general.register_as_probe:
return
self._printer.add_object(
"probe",
self._target_probe_class(
Expand Down
40 changes: 14 additions & 26 deletions src/cartographer/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ def __init__(self, adapters: Adapters) -> None:
self.probe_macro = ProbeMacro(probe, toolhead)
self.query_probe_macro = QueryProbeMacro(probe)

self.probe_macros = self._create_probe_macro_registrations(probe, toolhead)

# Register all macros
self.macros = self._create_macro_registrations(probe, toolhead, adapters)

Expand Down Expand Up @@ -151,7 +153,8 @@ def _create_macro_registrations(
registrations: list[MacroRegistration] = []

# Core probe macros
registrations.extend(self._create_probe_macro_registrations(probe, toolhead))
if self.config.general.register_as_probe:
registrations.extend(self.probe_macros)

# Cartographer-specific macros
registrations.extend(self._create_cartographer_macro_registrations(probe, toolhead, adapters))
Expand All @@ -171,31 +174,16 @@ def _create_macro_registrations(
return registrations

def _create_probe_macro_registrations(self, probe: Probe, toolhead: Toolhead) -> list[MacroRegistration]:
"""Create standard probe macro registrations (only when register_as_probe is true)."""
if not self.config.general.register_as_probe:
return []
return list(
chain.from_iterable(
[
self._register_macro("PROBE", self.probe_macro, use_prefix=False),
self._register_macro(
"PROBE_ACCURACY",
ProbeAccuracyMacro(probe, toolhead, lift_speed=self.config.general.lift_speed),
use_prefix=False,
),
self._register_macro(
"QUERY_PROBE",
self.query_probe_macro,
use_prefix=False,
),
self._register_macro(
"Z_OFFSET_APPLY_PROBE",
ZOffsetApplyProbeMacro(probe, toolhead, self.config),
use_prefix=False,
),
]
)
)
"""Create reusable standard probe macro registrations."""
return [
MacroRegistration("PROBE", self.probe_macro),
MacroRegistration(
"PROBE_ACCURACY",
ProbeAccuracyMacro(probe, toolhead, lift_speed=self.config.general.lift_speed),
),
MacroRegistration("QUERY_PROBE", self.query_probe_macro),
MacroRegistration("Z_OFFSET_APPLY_PROBE", ZOffsetApplyProbeMacro(probe, toolhead, self.config)),
]

def _create_cartographer_macro_registrations(
self, probe: Probe, toolhead: Toolhead, adapters: Adapters
Expand Down
3 changes: 1 addition & 2 deletions src/cartographer/extra.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ def load_config(config: object) -> object:

cartographer = PrinterCartographer(adapters)

if cartographer.config.general.register_as_probe:
integrator.register_probe(cartographer)
integrator.register_probe(cartographer)

for macro in cartographer.macros:
integrator.register_macro(macro)
Expand Down
6 changes: 5 additions & 1 deletion src/cartographer/interfaces/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,11 @@ class GeneralConfig:
"When true, Cartographer registers as the 'probe' printer object and 'probe:' pin chip,"
" overriding PROBE/PROBE_ACCURACY/QUERY_PROBE/Z_OFFSET_APPLY_PROBE commands."
" When false, Cartographer registers its endstop under the 'cartographer_probe:' pin chip"
" and does not claim the 'probe' object, allowing a separate [probe] section to coexist.",
" and does not claim the 'probe' object, allowing a separate [probe] section to coexist."
" On registry-capable Kalico, Cartographer always registers as the named 'cartographer' probe:"
" true makes it the default probe, while false registers it as named-only."
" There, standard probe mux commands extend rather than override other probes' handlers."
" Pin-chip names and existing behavior elsewhere are unchanged.",
default=True,
)

Expand Down
3 changes: 2 additions & 1 deletion src/cartographer/macros/docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@
# Macros in the order they should appear in docs.
# (macro_name, macro_class, params_dataclass)
MACROS: list[tuple[str, type[Macro], type]] = [
# Standard probe macros (no CARTOGRAPHER_ prefix) — only registered when register_as_probe is true
# Standard probe macros: default/no-selector entries require register_as_probe=true;
# registry-capable Kalico also registers named mux entries when false.
("PROBE", ProbeMacro, ProbeMacroParams),
("PROBE_ACCURACY", ProbeAccuracyMacro, ProbeAccuracyParams),
("QUERY_PROBE", QueryProbeMacro, QueryProbeMacroParams),
Expand Down
5 changes: 2 additions & 3 deletions src/cartographer/runtime/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,10 @@ def init_runtime(config: object) -> tuple[Adapters, Integrator]:

if env == Environment.Kalico:
from cartographer.adapters.kalico.adapters import KalicoAdapters
from cartographer.adapters.kalico.probe import KalicoCartographerProbe
from cartographer.adapters.klipper_like.integrator import KlipperLikeIntegrator
from cartographer.adapters.kalico.integrator import KalicoIntegrator

adapters = KalicoAdapters(cast("KlipperConfigWrapper", config))
return adapters, KlipperLikeIntegrator(adapters, KalicoCartographerProbe)
return adapters, KalicoIntegrator(adapters)

msg = f"Unsupported environment: {env}"
raise RuntimeError(msg)
11 changes: 11 additions & 0 deletions tests/klipper/test_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,17 @@ def test_non_probe_chip_registers_plain_endstop(self, integrator: KlipperLikeInt


class TestRegisterProbe:
def test_named_only_skips_legacy_registration(
self, integrator: KlipperLikeIntegrator, adapters: Mock, probe_class: Mock
) -> None:
cartographer = Mock()
cartographer.config.general.register_as_probe = False

integrator.register_probe(cartographer)

probe_class.assert_not_called()
adapters.printer.add_object.assert_not_called()

def test_invokes_probe_callable_with_expected_deps(
self, integrator: KlipperLikeIntegrator, adapters: Mock, probe_class: Mock
) -> None:
Expand Down
Loading
Loading