# coding: utf-8
# python 2 only

# Copyright (c) 2026 TormachTips.com. All rights reserved.
# Licensed under the TormachTips Personal Use License.
# Permission is granted only for private personal use and private personal modification.
# No sharing, publication, distribution, resale, sublicensing, screenshots, code excerpts,
# benchmarks, or videos are permitted without prior written permission.
# Requests:         tormachtips@gmail.com
# Information page: https://tormachtips.com/plugins.htm

################################################
##                                            ##
##       TormachTips X Boss Probe 1.01        ##
##            www.tormachtips.com             ##
##                                            ##
################################################

# 1.01 - Rewrite from old M-code routine. - 10/05/2026

import os
import time
import gtk
import glib
import linuxcnc
import constants
import singletons
import user
from ui_hooks import plugin

CURRENT_VER          = "1.01"
SCRIPT_NAME          = "TormachTips X Boss Probe"
DESCRIPTION          = "Probes both X faces of a boss and sets the current WCS X origin at center. Prompts for approximate width."
ENABLED              = 1
DEV_MACHINE          = 1
DEV_MACHINE_FLAG     = "/home/operator/gcode/python/dev_machine.txt"
STARTUP_RETRY_MS     = 500
STARTUP_MAX_ATTEMPTS = 120
SUBROUTINE_DIR       = "/home/operator/gcode/subroutines"
SUBROUTINE_NAME      = "tt_x_boss_probe"
SUBROUTINE_PATH      = os.path.join(SUBROUTINE_DIR, SUBROUTINE_NAME + ".ngc")
BUTTON_NAME          = "tt_x_boss_probe_button"
BUTTON_X             = 400
BUTTON_Y             = -40
BUTTON_WIDTH         = 145
BUTTON_HEIGHT        = 34
HOTKEY_ACTIONS_ATTR  = "_tt_hotkey_actions"
HOTKEY_ACTION_NAME   = "x_boss_probe"
REDIS_HASH           = "tt_x_boss_probe"
REDIS_LAST_WIDTH_IN  = "last_width_inches"
FIRST_FACE_SEARCH_IN = 1.000
WIDTH_TOLERANCE_IN   = 1.000
CROSS_CLEARANCE_IN   = 0.100
BACKOFF_IN           = 0.050
SLOW_SEARCH_IN       = 0.100
Z_CLEARANCE_IN       = 1.000
STATE_TOLERANCE      = 0.000001
PROBE_TOOL_NUMBER    = int(getattr(constants, "MILL_PROBE_TOOL_NUM", 99))

NGC_SOURCE = r"""o<tt_x_boss_probe> sub
( TormachTips X Boss Probe 1.00 )
( Arguments are in CURRENT PROGRAM UNITS: )
( #1 approximate physical boss width )
( #2 rough probe feed / minute )
( #3 fine probe feed / minute )
( #4 non-probing traverse feed / minute )
( #5 effective probe tip diameter )
( #6 first-face maximum search distance )
( #7 probe backoff distance )
( #8 slow-probe forward search distance )
( #9 Z clearance rise )
( #10 approximate-width tolerance )
( #11 extra clearance beyond the far face before descending )
#<width>            = #1
#<rough_feed>       = #2
#<fine_feed>        = #3
#<rapid_feed>       = #4
#<probe_dia>        = #5
#<first_search>     = #6
#<backoff>          = #7
#<slow_search>      = #8
#<z_clearance>      = #9
#<width_tolerance>  = #10
#<cross_clearance>  = #11
o<xb_bad_width> if [#<width> LE 0]
    (ABORT, X Boss: approximate width must be positive)
o<xb_bad_width> endif
o<xb_bad_rough> if [#<rough_feed> LE 0]
    (ABORT, X Boss: rough probe feed must be positive)
o<xb_bad_rough> endif
o<xb_bad_fine> if [#<fine_feed> LE 0]
    (ABORT, X Boss: fine probe feed must be positive)
o<xb_bad_fine> endif
o<xb_bad_rapid> if [#<rapid_feed> LE 0]
    (ABORT, X Boss: traverse feed must be positive)
o<xb_bad_rapid> endif
o<xb_bad_probe_dia> if [#<probe_dia> LE 0]
    (ABORT, X Boss: Tool 99 effective probe diameter is not valid)
o<xb_bad_probe_dia> endif
o<xb_bad_limits> if [[#<first_search> LE 0] OR [#<backoff> LE 0] OR [#<slow_search> LE 0] OR [#<z_clearance> LE 0] OR [#<width_tolerance> LE 0] OR [#<cross_clearance> LE 0]]
    (ABORT, X Boss: one or more motion limits are invalid)
o<xb_bad_limits> endif
M70
G90
G94
G61
#<start_x> = #<_x>
#<start_z> = #<_z>
( FIRST / LEFT FACE: +X )
G38.2 X[#<start_x> + #<first_search>] F#<rough_feed>
#<left_rough> = #5061
G1 X[#<left_rough> - #<backoff>] F#<rough_feed>
G38.2 X[#<left_rough> + #<slow_search>] F#<fine_feed>
#<left_x> = #5061
( Clear the left face and rise completely before crossing the boss. )
G1 X[#<left_x> - #<backoff>] F#<rough_feed>
G1 Z[#<start_z> + #<z_clearance>] F#<rapid_feed>
( #5061 is probe-center X. The center-to-center distance between external )
( left/right contact is boss width + full probe diameter. Add tolerance and )
( explicit clearance so the probe is still clear if the width estimate is low. )
#<right_start> = [#<left_x> + #<width> + #<probe_dia> + #<width_tolerance> + #<cross_clearance>]
G1 X#<right_start> F#<rapid_feed>
G1 Z#<start_z> F#<rapid_feed>
( SECOND / RIGHT FACE: -X. With width error +/- tolerance, the worst-case )
( rough search is 2*tolerance + cross_clearance. )
#<right_search> = [[2 * #<width_tolerance>] + #<cross_clearance>]
G38.2 X[#<right_start> - #<right_search>] F#<rough_feed>
#<right_rough> = #5061
G1 X[#<right_rough> + #<backoff>] F#<rough_feed>
G38.2 X[#<right_rough> - #<slow_search>] F#<fine_feed>
#<right_x> = #5061
o<xb_bad_order> if [#<right_x> LE #<left_x>]
    M72
    (ABORT, X Boss: second X contact is not to the right of the first contact)
o<xb_bad_order> endif
#<center_x> = [[#<left_x> + #<right_x>] / 2]
#<measured_width> = [#<right_x> - #<left_x> - #<probe_dia>]
o<xb_bad_measured_width> if [#<measured_width> LE 0]
    M72
    (ABORT, X Boss: measured boss width is not positive)
o<xb_bad_measured_width> endif
( Only after BOTH slow probes succeeded do we change the work offset. )
G1 X[#<right_x> + #<backoff>] F#<rough_feed>
G1 Z[#<start_z> + #<z_clearance>] F#<rapid_feed>
G1 X#<center_x> F#<rapid_feed>
(DEBUG, X Boss: left=#<left_x> right=#<right_x> measured_width=#<measured_width> center=#<center_x>)
G10 L20 P0 X0
M72
o<tt_x_boss_probe> endsub
M2
"""

class UserPlugin(plugin):
    def __init__(self):
        plugin.__init__(self, "%s %s" % (SCRIPT_NAME, CURRENT_VER))
        dev_machine_found = os.path.exists(DEV_MACHINE_FLAG)
        if dev_machine_found:
            plugin_enabled = bool(DEV_MACHINE)
        else:
            plugin_enabled = bool(ENABLED)
        self.ui = None
        self.button = None
        self.fixed = None
        self._installed = False
        self._startup_attempts = 0
        if not plugin_enabled:
            self._write("Plugin loaded but disabled.")
            return
        self._write("Loaded; waiting for PathPilot Probe / ETS UI.")
        glib.timeout_add(STARTUP_RETRY_MS, self._install_when_ready)

    def _write(self, message, level=constants.ALARM_LEVEL_QUIET):
        try:
            self.error_handler.write("[%s] %s" % (SCRIPT_NAME, str(message)), level)
        except Exception:
            pass

    def _install_when_ready(self):
        if self._installed:
            return False
        self._startup_attempts += 1
        try:
            ui = getattr(singletons, "g_Machine", None)
            if ui is None:
                raise RuntimeError("PathPilot machine UI not ready")
            if not getattr(ui, "init_finished", False):
                raise RuntimeError("PathPilot UI initialization not finished")
            if not hasattr(ui, "mill_probe") or ui.mill_probe is None:
                raise RuntimeError("mill_probe UI not ready")
            builder = getattr(ui.mill_probe, "builder", None)
            if builder is None:
                raise RuntimeError("mill_probe GtkBuilder unavailable")
            fixed = builder.get_object("probe_pocket_boss_fixed")
            if fixed is None:
                raise RuntimeError("Probe Pocket/Boss page not found")
            self.ui = ui
            self.fixed = fixed
            self._install_ngc()
            self._install_button()
            self._register_hotkey_action()
            setattr(self.ui, "_tt_x_boss_probe_plugin", self)
            self._installed = True
            self._write("Installed X BOSS button and %s." % SUBROUTINE_PATH)
            return False
        except Exception as e:
            if self._startup_attempts >= STARTUP_MAX_ATTEMPTS:
                self._write(
                    "Install failed after %d attempts: %s"
                    % (
                        self._startup_attempts,
                        str(e),),
                    constants.ALARM_LEVEL_LOW)
                return False
            return True

    def _install_ngc(self):
        if not os.path.isdir(SUBROUTINE_DIR):
            os.makedirs(SUBROUTINE_DIR)
        current = None
        try:
            handle = open(SUBROUTINE_PATH, "r")
            try:
                current = handle.read()
            finally:
                handle.close()
        except Exception:
            current = None
        if current == NGC_SOURCE:
            return
        if current is not None:
            backup_path = SUBROUTINE_PATH + ".bak"
            try:
                backup = open(backup_path, "w")
                try:
                    backup.write(current)
                finally:
                    backup.close()
            except Exception:
                pass
        temp_path = "%s.tmp.%d" % (SUBROUTINE_PATH, os.getpid())
        handle = open(temp_path, "w")
        try:
            handle.write(NGC_SOURCE)
            handle.flush()
            try:
                os.fsync(handle.fileno())
            except Exception:
                pass
        finally:
            handle.close()
        try:
            os.chmod(temp_path, 0o644)
        except Exception:
            pass
        os.rename(temp_path, SUBROUTINE_PATH)

    def _remove_existing_button(self):
        try:
            for child in self.fixed.get_children():
                try:
                    if child.get_name() == BUTTON_NAME:
                        self.fixed.remove(child)
                        try:
                            child.destroy()
                        except Exception:
                            pass
                except Exception:
                    continue
        except Exception:
            pass

    def _install_button(self):
        self._remove_existing_button()
        button = gtk.Button()
        button.set_name(BUTTON_NAME)
        button.set_size_request(BUTTON_WIDTH, BUTTON_HEIGHT)
        button.set_can_focus(False)
        button.set_tooltip_text("Probe the two X faces of a boss and set current WCS X0 at center.")
        label = gtk.Label()
        label.set_markup('<span weight="bold" font_desc="Roboto Condensed 12">X BOSS</span>')
        button.add(label)
        button.connect("clicked", self._on_button_clicked)
        self.fixed.put(button, BUTTON_X, BUTTON_Y)
        button.show_all()
        self.button = button

    def _register_hotkey_action(self):
        registry = getattr(self.ui, HOTKEY_ACTIONS_ATTR, None)
        if not isinstance(
                registry,
                dict):
            registry = {}
            setattr(self.ui, HOTKEY_ACTIONS_ATTR, registry)
        registry[HOTKEY_ACTION_NAME] = self.show_dialog

    def _on_button_clicked(self, widget):
        self.show_dialog()

    def _linear_scale(self):
        scale = float(self.ui.get_linear_scale())
        if scale <= 0:
            raise RuntimeError("PathPilot returned an invalid linear unit scale")
        return scale

    def _units_label(self):
        return ("mm" if self._linear_scale() > 1.5 else "in")

    def _last_width_current_units(self):
        try:
            raw = self.ui.redis.hget(REDIS_HASH, REDIS_LAST_WIDTH_IN)
            if raw is None:
                return None
            width_in = float(raw)
            if width_in <= 0:
                return None
            return (width_in * self._linear_scale())
        except Exception:
            return None

    def _remember_width(self, width_current):
        try:
            width_in = (float(width_current) / self._linear_scale())
            self.ui.redis.hset(REDIS_HASH, REDIS_LAST_WIDTH_IN, "%.8f" % width_in)
        except Exception:
            pass

    def _dialog(self):
        units = self._units_label()
        scale = self._linear_scale()
        tolerance = (
            WIDTH_TOLERANCE_IN *
            scale)
        first_search = (
            FIRST_FACE_SEARCH_IN *
            scale)
        z_clearance = (
            Z_CLEARANCE_IN *
            scale)
        dialog = gtk.Dialog(
            "X Boss Probe",
            self.ui.window,
            gtk.DIALOG_MODAL |
            gtk.DIALOG_DESTROY_WITH_PARENT,
            (
                gtk.STOCK_CANCEL,
                gtk.RESPONSE_CANCEL,
                "Probe",
                gtk.RESPONSE_OK,
            ))
        dialog.set_default_response(gtk.RESPONSE_OK)
        dialog.set_resizable(False)
        box = gtk.VBox(False, 10)
        box.set_border_width(14)
        dialog.vbox.pack_start(box, True, True, 0)
        intro = gtk.Label()
        intro.set_alignment(0.0, 0.5)
        intro.set_line_wrap(True)
        intro.set_markup(
            "<b>X Boss Center</b>\n"
            "Start with Tool %d below the top surface and just left of the "
            "first X face."
            % PROBE_TOOL_NUMBER)
        box.pack_start(intro, False, False, 0)
        row = gtk.HBox(False, 8)
        prompt = gtk.Label("Approx. boss width:")
        prompt.set_alignment(0.0, 0.5)
        row.pack_start(prompt, False, False, 0)
        entry = gtk.Entry()
        entry.set_width_chars(12)
        previous = self._last_width_current_units()
        if previous is not None:
            entry.set_text("%.4f" % previous)
            entry.select_region(0, -1)
        entry.set_activates_default(True)
        row.pack_start(entry, False, False, 0)
        unit_label = gtk.Label(units)
        unit_label.set_alignment(0.0, 0.5)
        row.pack_start(unit_label, False, False, 0)
        box.pack_start(row, False, False, 0)
        note = gtk.Label()
        note.set_alignment(0.0, 0.5)
        note.set_line_wrap(True)
        note.set_markup(
            "<span size='small'>"
            "The width is navigation only, not the measurement. "
            "It may be approximately +/-%.3f %s from actual. "
            "The first face must be within %.3f %s. "
            "The routine rises %.3f %s before crossing the boss. "
            "The current WCS X origin is changed only after both slow probes succeed."
            "</span>"
            % (
                tolerance,
                units,
                first_search,
                units,
                z_clearance,
                units,
            ))
        box.pack_start(note, False, False, 0)
        dialog.show_all()
        entry.grab_focus()
        return (
            dialog,
            entry)

    def _probe_settings(self):
        probe_ui = getattr(self.ui, "mill_probe", None)
        if probe_ui is None:
            raise RuntimeError("PathPilot mill_probe object is unavailable")
        scale = self._linear_scale()
        rough_in = float(probe_ui.probe_rough_feedrate)
        fine_in = float(probe_ui.probe_fine_feedrate)
        rapid_in = float(probe_ui.probe_rapid_feedrate)
        probe_dia_in = float(probe_ui.probe_tip_effective_diameter)
        if rough_in <= 0:
            raise RuntimeError("OEM rough probe feed is not valid")
        if fine_in <= 0:
            raise RuntimeError("OEM fine probe feed is not valid")
        if rapid_in <= 0:
            raise RuntimeError("OEM probe traverse feed is not valid")
        if probe_dia_in <= 0:
            raise RuntimeError(
                "Tool %d effective probe diameter is not valid. "
                "Calibrate/set the probe diameter first."
                % PROBE_TOOL_NUMBER)
        return {
            "rough":
                rough_in * scale,
            "fine":
                fine_in * scale,
            "rapid":
                rapid_in * scale,
            "probe_dia":
                probe_dia_in * scale,
            "probe_dia_in":
                probe_dia_in,}

    def _preflight(self, width_current, settings):
        ui = self.ui
        ui.status.poll()
        if (
                ui.status.task_state !=
                linuxcnc.STATE_ON):
            raise RuntimeError("Machine must be out of Reset/E-stop")
        if ui.program_running(False):
            raise RuntimeError("Cannot probe while an NC program is running")
        if ui.mdi_running():
            raise RuntimeError("Cannot probe while MDI is already running")
        if ui.moving():
            raise RuntimeError("Cannot probe while the machine is moving")
        try:
            homed = ui.status.homed
            if (
                    not bool(homed[0]) or
                    not bool(homed[1]) or
                    not bool(homed[2])):
                raise RuntimeError("X, Y and Z must all be referenced")
        except RuntimeError:
            raise
        except Exception:
            raise RuntimeError("Could not verify X/Y/Z referenced state")
        if (
                int(
                    ui.status.tool_in_spindle or
                    0) !=
                PROBE_TOOL_NUMBER):
            raise RuntimeError("Tool %d (probe tool) must be active" % PROBE_TOOL_NUMBER)
        if bool(ui.status.probe_val):
            raise RuntimeError("Probe input is already tripped. " "Clear the probe before starting.")
        try:
            if bool(
                    ui.hal["spindle-on"]):
                raise RuntimeError("Stop the spindle before probing")
        except RuntimeError:
            raise
        except Exception:
            raise RuntimeError("Could not verify spindle-off state")
        try:
            active = ui.active_gcodes()
            if (
                    "G41" in active or
                    "G42" in active):
                raise RuntimeError("Cancel cutter radius compensation (G40) before probing")
        except RuntimeError:
            raise
        except Exception:
            raise RuntimeError("Could not verify cutter-compensation state")
        try:
            rotation = float(getattr(ui.status, "rotation_xy", 0.0) or 0.0)
            if (
                    abs(rotation) >
                    STATE_TOLERANCE):
                raise RuntimeError("X Boss does not run with XY work-coordinate rotation active")
        except RuntimeError:
            raise
        except Exception:
            raise RuntimeError("Could not verify XY rotation state")
        try:
            g92 = list(getattr(ui.status, "g92_offset", (0.0, 0.0, 0.0,)))
            if any(
                    abs(float(value)) >
                    STATE_TOLERANCE
                    for value in
                    g92[:3]):
                raise RuntimeError("Clear G92/G52 offsets before running X Boss")
        except RuntimeError:
            raise
        except Exception:
            raise RuntimeError("Could not verify G92/G52 offset state")
        scale = self._linear_scale()
        width_in = (float(width_current) / scale)
        if width_in <= 0:
            raise RuntimeError("Approximate boss width must be positive")
        # Conservative machine-space limit check. PathPilot's status.position
        # and axis min/max limits are in its internal linear units (inches).
        try:
            machine_x = float(ui.status.position[0])
            machine_z = float(ui.status.position[2])
            leftmost_x = (
                machine_x -
                BACKOFF_IN)
            worst_right_x = (
                machine_x +
                FIRST_FACE_SEARCH_IN +
                width_in +
                float(
                    settings[
                        "probe_dia_in"]) +
                WIDTH_TOLERANCE_IN +
                CROSS_CLEARANCE_IN)
            raised_z = (
                machine_z +
                Z_CLEARANCE_IN)
            if not ui.check_limits(
                    leftmost_x,
                    0):
                raise RuntimeError("Not enough X- soft-limit room for the probe backoff")
            if not ui.check_limits(
                    worst_right_x,
                    0):
                raise RuntimeError("Entered width could carry X beyond the current X soft limit")
            if not ui.check_limits(
                    raised_z,
                    2):
                raise RuntimeError("Not enough Z+ soft-limit room for the %.3f in clearance rise" % Z_CLEARANCE_IN)
        except RuntimeError:
            raise
        except Exception:
            raise RuntimeError("Could not verify planned motion against machine soft limits")

    def _lock_door_if_required(self):
        try:
            if self.ui.currentuser.has_capability(
                    user.Capability.DoorOpenProbing):
                return True
            if not self.ui.lock_enclosure_door():
                return False
            # Match the OEM probing handlers: unlock shortly after the
            # interpreter returns idle.
            self.ui.unlock_enclosure_door_after_time_at_interp_idle = (time.time() + 0.050)
            return True
        except Exception as e:
            raise RuntimeError("Could not apply PathPilot probing door policy: %s" % str(e))

    def _mdi_command(self, width_current, settings):
        scale = self._linear_scale()
        values = (
            float(width_current),
            float(settings["rough"]),
            float(settings["fine"]),
            float(settings["rapid"]),
            float(settings["probe_dia"]),
            FIRST_FACE_SEARCH_IN * scale,
            BACKOFF_IN * scale,
            SLOW_SEARCH_IN * scale,
            Z_CLEARANCE_IN * scale,
            WIDTH_TOLERANCE_IN * scale,
            CROSS_CLEARANCE_IN * scale,)
        return (
            "O<%s> CALL "
            "[%.8f] [%.8f] [%.8f] [%.8f] [%.8f] "
            "[%.8f] [%.8f] [%.8f] [%.8f] [%.8f] [%.8f]"
            % (
                (SUBROUTINE_NAME,) +
                values))

    def show_dialog(self):
        if self.ui is None:
            self._write("X Boss is not installed into the PathPilot UI yet.", constants.ALARM_LEVEL_LOW)
            return False
        dialog = None
        try:
            dialog, entry = self._dialog()
            response = dialog.run()
            if response != gtk.RESPONSE_OK:
                return False
            raw = entry.get_text().strip()
            try:
                width = float(raw)
            except Exception:
                raise RuntimeError("Approximate boss width is not a valid number")
            settings = self._probe_settings()
            self._preflight(width, settings)
            if not self._lock_door_if_required():
                raise RuntimeError("PathPilot could not lock the enclosure door")
            command = self._mdi_command(width, settings)
            issued = self.ui.issue_mdi(command)
            if not issued:
                raise RuntimeError("PathPilot refused the X Boss MDI command")
            self._remember_width(width)
            self._write("X Boss started: approximate width %.4f %s." % (width, self._units_label(),))
            return False
        except Exception as e:
            self._write(str(e), constants.ALARM_LEVEL_LOW)
            return False
        finally:
            if dialog is not None:
                try:
                    dialog.destroy()
                except Exception:
                    pass