# 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

################################################
##                                            ##
##            ETS Refresher 1.10              ##
##           www.tormachtips.com              ##
##                                            ##
################################################

#######################################################################################################################
#######################################################################################################################
##
## ETS Refresher adds a button to PathPilot’s ETS Setup tab for quickly remeasuring tool lengths.
##
## Designed for tools with existing, reasonably accurate length offsets, ETS Refresher uses those offsets to 
## predict the contact point. This allows faster approaches and shorter retracts, 
## significantly reducing the time required for each measurement.
##
## Whether you're checking a tool for wear, refreshing its offset after replacing it with a similar-length tool, 
## or remeasuring your entire tool library, ETS Refresher streamlines the process 
## without sacrificing measurement accuracy.
##
## The biggest advantage is when measuring multiple tools in succession, eliminating much of the 
## unnecessary travel in PathPilot's standard tool-measurement routine.
##
#######################################################################################################################
#######################################################################################################################

# 1.10 - Keep the batch editor and live results windows above PathPilot using GTK2 dialog/window-manager hints. - 10/26/2026
# 1.09 - Adds persistent named batch tool lists, batch button, live per-tool results and final summary. - 10/26/2026
# 1.08 - Removes legacy-version detection and migration. - 10/26/2026
# 1.07 - public beta - 10/26/2026

import os
import math
import re
import time
import ConfigParser
import errno
import json
import tempfile
import pango
import gtk
import glib
import linuxcnc
import constants
import singletons
from ui_hooks import plugin

CURRENT_VER           = "1.10"
SCRIPT_NAME           = "ETS Refresher"
DESCRIPTION           = "ETS Refresher quickly remeasures tools using their existing length offsets to predict the ETS contact point, allowing faster approaches and shorter retracts. Ideal for checking tool wear, updating similar-length replacements, or refreshing an entire tool library significantly faster than PathPilot's standard routine."
ENABLED               = 1
DEV_MACHINE           = 1
DEV_MACHINE_FLAG      = "/home/operator/gcode/python/dev_machine.txt"
CONFIG_FILE           = "/home/operator/gcode/python/ets_refresher.ini"
NGC_PATH              = "/home/operator/gcode/subroutines/ets_refresher.ngc"
BATCH_FILE            = "/home/operator/gcode/python/ets_refresher_batch.json"
TOOL_CHANGE_TIMEOUT_S = 12.0
PROBE_TIMEOUT_S       = 300.0
PENDING_POLL_MS       = 200
STARTUP_RETRY_MS      = 500
STARTUP_MAX_ATTEMPTS  = 120
BUTTON_NAME           = "ets_refresher_button"
BUTTON_X              = 500
BUTTON_Y              = 116
BUTTON_WIDTH          = 200
BUTTON_HEIGHT         = 47
BATCH_BUTTON_NAME     = "ets_refresher_batch_button"
BATCH_BUTTON_Y        = BUTTON_Y + BUTTON_HEIGHT + 12

DEFAULT_SETTINGS        = {
    "approach_margin_in": "0.75",
    "probe_search_below_in": "1.00",
    "max_length_change_in": "0.0",
    "fast_feed_ipm": "120.0",
    "rapid_approach": "1",
    "final_z_g53_in": "-3.00",
    "tool_release_margin_in": "0.75",
    "xy_tolerance_in": "0.040",}

EMBEDDED_NGC = r"""( TormachTips ETS Refresher 1.10 - 10/26/2026 )
( Copyright (c) 2026 TormachTips.com. All rights reserved. )
( Licensed under the TormachTips Personal Use License. )
( For private personal use and private personal modification only. )
( No sharing publication distribution resale sublicensing screenshots code excerpts )
( benchmarks or videos without prior written permission. )
( tormachtips@gmail.com - https://tormachtips.com/plugins.htm )
( )
( Live existing length on each click; Z-ONLY; does NOT move X/Y. )
( The Python plugin MUST verify ETS X/Y, nose reference, and Z bounds. )
( This is not a substitute for a correctly referenced tool setter. )
( )
( #1  ETS spindle-nose G53 Z in )
( #2  existing tool length in )
( #3  currently applied tool Z offset in )
( #4  fast-approach G53 Z in )
( #5  lower probe-limit G53 Z in )
( #6  minimum final-retract G53 Z in )
( #7  fast feed inches per minute )
( #8  OEM rough probing feed inches per minute )
( #9  OEM fine probing feed inches per minute )
( #10 max allowed length difference in )
( #11 tip release clearance in )
( #12 current tool number )
( #13 machine Z lower limit in )
( #14 machine Z upper limit in )
( #15 approach margin in )
( #16 use rapid for approach: 1=G0, 0=G1 F#7 )

o<ets_refresher> sub

    (Reject non-executing toolpath display contexts)
    o<etsr_task> if [#<_task> EQ 0]
        o<ets_refresher> return
    o<etsr_task> endif

    #<ets_ref>           = #1
    #<old_length>        = #2
    #<active_tlo>        = #3
    #<approach_z>        = #4
    #<probe_floor_z>     = #5
    #<final_z>           = #6
    #<fast_feed>         = #7
    #<rough_feed>        = #8
    #<fine_feed>         = #9
    #<max_drift>         = #10
    #<release_margin>    = #11
    #<tool_number>       = FIX[#12]
    #<zmin>              = #13
    #<zmax>              = #14
    #<approach_margin>   = #15
    #<rapid_approach>    = #16

    (Preconditions validated AGAIN in the interpreter before moving)
    o<etsr_tool> if [[#<tool_number> LE 0] OR [#<tool_number> NE #<_current_tool>]]
        (ABORT,ETS refresher: current tool changed or is invalid)
    o<etsr_tool> endif

    o<etsr_feed> if [[#<fast_feed> LE 0] OR [#<rough_feed> LE 0] OR [#<fine_feed> LE 0]]
        (ABORT,ETS refresher: invalid feedrate)
    o<etsr_feed> endif

    o<etsr_margin> if [[#<approach_margin> LE 0] OR [#<release_margin> LE 0]]
        (ABORT,ETS refresher: invalid retract or approach margin)
    o<etsr_margin> endif

    o<etsr_mode> if [[#<rapid_approach> NE 0] AND [#<rapid_approach> NE 1]]
        (ABORT,ETS refresher: invalid approach mode)
    o<etsr_mode> endif

    o<etsr_limits> if [[#<probe_floor_z> LE #<zmin>] OR [#<approach_z> GE #<zmax>]]
        (ABORT,ETS refresher: probe envelope outside travel limits)
    o<etsr_limits> endif

    o<etsr_spindle> if [#<_spindle_on> NE 0]
        (ABORT,ETS refresher: spindle must be stopped)
    o<etsr_spindle> endif

    (Keep current WCS and G92; unlike OEM G37 do NOT wipe G92 offsets)
    M80
    #<saved_feed> = #<_feed>
    (Force inch, absolute, feed-per-minute, exact-stop motion)
    G20 G90 G94 G61

    (Sync position before reading offsets)
    M66 P63 L0

    #<z_wcs_offset> = [#<_z> - #<_z_machine> + #<active_tlo>]

    (At launch the tool must already be safely above the expected contact)
    o<etsr_start> if [#<_z_machine> LE [#<ets_ref> + #<old_length>]]
        (ABORT,ETS refresher: tool too close to setter at start)
    o<etsr_start> endif

    (Move to operator-selected clearance above old predicted ETS contact.)
    (No XY motion; machine already positioned over setter.)
    o<etsr_approach> if [#<rapid_approach> EQ 1]
        G53 G0 Z#<approach_z>
    o<etsr_approach> else
        G53 G1 Z#<approach_z> F#<fast_feed>
    o<etsr_approach> endif
    M66 P63 L0

    (Compute a finite stop for first contact in the currently selected WCS)
    #<rough_limit_wcs> = [#<probe_floor_z> + #<z_wcs_offset> - #<active_tlo>]
    F#<rough_feed>
    G38.2 Z#<rough_limit_wcs> P1 Q1
    #<rough_contact_wcs> = #<_probe_z>

    (Retreat 0.100 inch using the same setter-release move as OEM G37)
    #<rough_release_wcs> = [#<rough_contact_wcs> + 0.100]
    G38.6 Z#<rough_release_wcs> Q1

    (Disable feed override for repeatable fine measurement, as OEM does)
    M50 P0
    F#<fine_feed>
    G38.2 Z#<rough_limit_wcs> P1 Q1
    #<fine_contact_wcs> = #<_probe_z>
    M50 P1

    F#<rough_feed>
    #<fine_release_wcs> = [#<fine_contact_wcs> + 0.100]
    G38.6 Z#<fine_release_wcs> Q1
    M66 P63 L0

    (Transform the probed WCS point back to G53 then the tool length)
    #<hit_g53> = [#<fine_contact_wcs> - #<z_wcs_offset> + #<active_tlo>]
    #<new_length> = [#<hit_g53> - #<ets_ref>]
    #<delta_length> = ABS[#<new_length> - #<old_length>]

    (Tool removal must clear the actual hit, not just the old prediction)
    #<actual_final_z> = [#<hit_g53> + #<release_margin>]
    o<etsr_final_choose> if [#<final_z> GT #<actual_final_z>]
        #<actual_final_z> = #<final_z>
    o<etsr_final_choose> endif

    o<etsr_final_limit> if [#<actual_final_z> GT [#<zmax> - 0.02]]
        (ABORT,ETS refresher: actual contact too high for safe retract)
    o<etsr_final_limit> endif

    (Rapid retract after both probes have released the ETS.)
    G53 G0 Z#<actual_final_z>
    M66 P63 L0

    (0 means no old-versus-new difference check; finite G38.2 floor still applies.)
    o<etsr_delta> if [[#<max_drift> GT 0] AND [#<delta_length> GT #<max_drift>]]
        (ABORT,ETS refresher: measured length differs beyond configured tolerance; tool table NOT updated)
    o<etsr_delta> endif

    (Same tool-table write and G43 reapply pattern as OEM G37)
    G10 L1 P#<tool_number> Z#<new_length>
    G43 H#<tool_number>
    M66 P63 L0
    M81
    o<etsr_feed_restore> if [#<saved_feed> GT 0]
        F#<saved_feed>
    o<etsr_feed_restore> endif

    (MSG,ETS refresher complete; tool length saved)

o<ets_refresher> endsub
M02
"""

EMBEDDED_INI = r"""; TormachTips ETS Refresher 1.10
; Inches regardless of PathPilot display units.
; Final Z is the higher of final_z_g53_in or actual contact + tool_release_margin_in.
; Change the final Z and/or release margin to reduce the end-of-cycle retract.
; 0 means no maximum old-vs-new length-difference check.
[settings]
approach_margin_in         = 0.75
probe_search_below_in      = 1.00
max_length_change_in       = 0.0
rapid_approach             = 1
fast_feed_ipm              = 120.0
final_z_g53_in             = -3.00
tool_release_margin_in     = 0.75
xy_tolerance_in            = 0.040
"""

EMBEDDED_BATCH_JSON = r'''{
    "selected": "Favorites",
    "presets": {
        "Favorites": []
    }
}
'''

class RefreshError(RuntimeError):
    pass

def finite(value):
    return not (math.isnan(value) or math.isinf(value))

class UserPlugin(plugin):
    def __init__(self):
        plugin.__init__(self, "%s %s" % (SCRIPT_NAME, CURRENT_VER))
        self.enabled = bool(DEV_MACHINE if os.path.exists(DEV_MACHINE_FLAG) else ENABLED)
        self.ui = None
        self.button = None
        self.batch_button = None
        self._batch = None
        self._batch_editor = None
        self._batch_editor_loading = False
        self.install_attempts = 0
        self._pending = None
        self._installed_files = []
        self._setup_error = None
        self._notice_attempts = 0
        if self.enabled:
            # File installation runs once on the GTK main loop, not in a worker.
            glib.idle_add(self._bootstrap_files)

    def _log(self, message, level=constants.ALARM_LEVEL_QUIET):
        try:
            self.error_handler.write("[ETS Refresher] %s" % message, level)
        except Exception:
            pass

    def _create_missing_file(self, path, text):
        """Exclusive create: NEVER replace an installed NGC or customized INI."""
        if os.path.lexists(path):
            if not os.path.isfile(path):
                raise RefreshError("Not a regular file: %s" % path)
            return False
        directory = os.path.dirname(path)
        if not os.path.isdir(directory):
            raise RefreshError("Installation directory not found: %s" % directory)
        try:
            fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
        except OSError as exc:
            if exc.errno == errno.EEXIST:
                return False
            raise
        try:
            with os.fdopen(fd, 'wb') as handle:
                handle.write(text.encode('utf-8'))
                handle.flush()
                os.fsync(handle.fileno())
        except Exception:
            # A partial newly-created file must not survive as 'installed'.
            try:
                os.unlink(path)
            except Exception:
                pass
            raise
        return True

    def _bootstrap_files(self):
        """Install required files only when missing; never overwrite custom files."""
        try:
            for path, text in ((NGC_PATH, EMBEDDED_NGC),
                               (CONFIG_FILE, EMBEDDED_INI),
                               (BATCH_FILE, EMBEDDED_BATCH_JSON)):
                if self._create_missing_file(path, text):
                    self._installed_files.append(path)
        except Exception as exc:
            self._setup_error = str(exc)
            self._log("Install failed: %s" % exc, constants.ALARM_LEVEL_LOW)
        if self._installed_files:
            self._log("Installed %s. Restart PathPilot to load ETS Refresher." %
                      ', '.join(os.path.basename(path) for path in self._installed_files),
                      constants.ALARM_LEVEL_LOW)
        if self._installed_files or self._setup_error:
            glib.timeout_add(STARTUP_RETRY_MS, self._show_setup_notice)
        else:
            glib.timeout_add(STARTUP_RETRY_MS, self._install)
        return False

    def _show_setup_notice(self):
        self._notice_attempts += 1
        ui = getattr(singletons, 'g_Machine', None)
        if ui is None or not getattr(ui, 'init_finished', False):
            if self._notice_attempts >= STARTUP_MAX_ATTEMPTS:
                self._log("Could not show GTK installer notice; check Status messages.", constants.ALARM_LEVEL_LOW)
                return False
            return True
        if self._setup_error:
            title = "ETS Refresher - Setup Error"
            detail = "Could not install the required files:\n\n%s" % self._setup_error
            msg_type = gtk.MESSAGE_ERROR
        else:
            title = "ETS Refresher - Setup"
            detail = ""
            msg_type = gtk.MESSAGE_INFO
            if self._installed_files:
                detail += "Installed:\n"
                detail += '\n'.join('  ' + path for path in self._installed_files)
                detail += "\n\n"
            else:
                detail += "Required files already exist.\n\n"
            detail += ("RESTART PATHPILOT to activate ETS Refresher.\n"
                       "Existing settings are preserved.\n"
                       "No machine motion has been initiated.")
        self._log(detail.replace('\n', ' '), constants.ALARM_LEVEL_LOW)
        dialog = gtk.MessageDialog(
            ui.window,
            gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
            msg_type,
            gtk.BUTTONS_OK,
            title)
        dialog.format_secondary_text(detail)
        dialog.set_title(title)
        dialog.set_keep_above(True)
        dialog.set_position(gtk.WIN_POS_CENTER_ALWAYS)
        try:
            dialog.run()
        finally:
            dialog.destroy()
        return False

    def _config(self):
        parser = ConfigParser.RawConfigParser()
        parser.read(CONFIG_FILE)
        result = {}
        for key, fallback in DEFAULT_SETTINGS.items():
            try:
                raw = parser.get('settings', key)
            except Exception:
                raw = fallback
            try:
                result[key] = float(raw)
            except Exception:
                raise RefreshError("Invalid INI setting %s" % key)
            if not finite(result[key]):
                raise RefreshError("Non-finite setting %s" % key)
        for key, low, high in (
                ('approach_margin_in', 0.05, 8.0),
                ('probe_search_below_in', 0.05, 8.0),
                ('max_length_change_in', 0.0, 8.0),
                ('fast_feed_ipm', 1.0, 300.0),
                ('tool_release_margin_in', 0.10, 8.0),
                ('xy_tolerance_in', 0.001, 0.10)):
            if not low <= result[key] <= high:
                raise RefreshError("%s must be between %s and %s" % (key, low, high))
        if result['rapid_approach'] not in (0.0, 1.0):
            raise RefreshError("rapid_approach must be 0 or 1")
        return result

    def _install(self):
        self.install_attempts += 1
        try:
            ui = getattr(singletons, 'g_Machine', None)
            if ui is None or not getattr(ui, 'init_finished', False):
                raise RefreshError("PathPilot not initialized")
            probe = getattr(ui, 'mill_probe', None)
            if probe is None:
                raise RefreshError("Probe/ETS screen not initialized")
            fixed = probe.builder.get_object('ets_setup_fixed')
            if fixed is None:
                raise RefreshError("ETS Setup fixed container missing")
            for old in fixed.get_children():
                if old.get_name() in (BUTTON_NAME, BATCH_BUTTON_NAME):
                    fixed.remove(old)
                    old.destroy()
            self.ui = ui
            button = gtk.Button("RAPID ETS REFRESH")
            button.set_name(BUTTON_NAME)
            button.set_size_request(BUTTON_WIDTH, BUTTON_HEIGHT)
            button.set_tooltip_text("Select loaded tool or enter T#; set the spindle tool, refresh its live offset, and report old/new/delta")
            button.connect('clicked', self._clicked)
            fixed.put(button, BUTTON_X, BUTTON_Y)
            button.show()
            self.button = button
            batch_button = gtk.Button("BATCH ETS REFRESH")
            batch_button.set_name(BATCH_BUTTON_NAME)
            batch_button.set_size_request(BUTTON_WIDTH, BUTTON_HEIGHT)
            batch_button.set_tooltip_text("Save tool lists and refresh successive tools; live results and summary")
            batch_button.connect('clicked', self._open_batch_editor)
            fixed.put(batch_button, BUTTON_X, BATCH_BUTTON_Y)
            batch_button.show()
            self.batch_button = batch_button
            self._update_button_states()
            self._log("ETS Setup single-tool and batch refresh buttons ready.")
            return False
        except Exception as exc:
            if self.install_attempts >= STARTUP_MAX_ATTEMPTS:
                self._log("Startup failed: %s" % exc, constants.ALARM_LEVEL_LOW)
                return False
            return True

    def _check_machine(self, allow_no_tool=False):
        ui = self.ui
        if ui is None:
            raise RefreshError("PathPilot UI not connected")
        ui.status.poll()
        st = ui.status
        if st.interp_state != linuxcnc.INTERP_IDLE:
            raise RefreshError("Interpreter not idle")
        if ui.program_running(False) or ui.mdi_running() or ui.moving():
            raise RefreshError("Machine running, executing MDI, or moving")
        if not all(bool(st.axis[i]['homed']) for i in (0, 1, 2)):
            raise RefreshError("X/Y/Z axes must be referenced")
        if getattr(st, 'estop', True):
            raise RefreshError("Machine E-STOP active")
        if not getattr(st, 'enabled', False):
            raise RefreshError("Machine is not enabled")
        # 'spindle_speed' can retain the last programmed RPM after M5.
        # Check the enabled state, not the commanded RPM setpoint.
        spindle_on = bool(getattr(st, 'spindle_enabled', False))
        try:
            spindle_on = spindle_on or bool(st.spindle[0]['enabled'])
        except Exception:
            pass
        if spindle_on:
            raise RefreshError("Stop the spindle before measuring")
        tool = int(st.tool_in_spindle)
        if allow_no_tool:
            tool_offset = None
        else:
            if tool <= 0 or tool >= len(st.tool_table):
                raise RefreshError("No valid active tool for ETS refresh")
            tool_offset = float(st.tool_table[tool].zoffset)
            if not finite(tool_offset) or tool_offset <= 0.0:
                raise RefreshError("T%d has no existing positive length; use OEM G37" % tool)
        probe = ui.mill_probe
        if not getattr(probe, 'ets_spindle_nose_is_referenced', False):
            raise RefreshError("ETS spindle-nose reference not established")
        if probe.setter_is_tripped():
            raise RefreshError("ETS already triggered")
        if not ui.currentuser.has_capability(__import__('user').Capability.DoorOpenProbing):
            if getattr(ui, 'door_open_status', False):
                raise RefreshError("Close the enclosure door before ETS refresh")
        return st, tool, tool_offset

    def _plan(self):
        settings = self._config()
        st, tool, length = self._check_machine()
        ui = self.ui
        redis = ui.redis
        try:
            x = float(redis.hget('machine_prefs', 'setter_x_position'))
            y = float(redis.hget('machine_prefs', 'setter_y_position'))
            ets_ref = float(redis.hget('remap_ipc', 'ets_g53_z'))
            ref_ok = redis.hget('remap_ipc', 'ets_spindle_nose_is_referenced')
        except Exception:
            raise RefreshError("ETS coordinates or spindle-nose reference missing in Redis")
        if str(ref_ok) != 'True':
            raise RefreshError("ETS reference not currently valid in Redis")
        if not all(finite(v) for v in (x, y, ets_ref)):
            raise RefreshError("Non-finite ETS coordinates")
        # The status.actual_position data is in machine inches, like stored ETS data.
        actual_x = float(st.actual_position[0])
        actual_y = float(st.actual_position[1])
        actual_z = float(st.actual_position[2])
        tol = settings['xy_tolerance_in']
        if abs(actual_x - x) > tol or abs(actual_y - y) > tol:
            raise RefreshError("Not centered above ETS. Use OEM Move To G37 Position first, then return here.\n"
                               "Current G53 XY: %.4f, %.4f; ETS G53 XY: %.4f, %.4f." % (actual_x, actual_y, x, y))
        # Recompute on EVERY press: existing offsets are not cached anywhere.
        expected = ets_ref + length
        approach = expected + settings['approach_margin_in']
        probe_limit = expected - settings['probe_search_below_in']
        zmax = float(st.axis[2]['max_position_limit'])
        zmin = float(st.axis[2]['min_position_limit'])
        if not (zmin < probe_limit < expected < approach < zmax):
            raise RefreshError("Tool length/ETS probing positions exceed the machine Z travel")
        # Higher Z is safer for removal. Approach clearance is independent of
        # post-probe tool removal; the NGC also checks ACTUAL contact clearance.
        # This is a minimum G53 Z, raised if the release margin requires it.
        final_z = max(settings['final_z_g53_in'], expected + settings['tool_release_margin_in'])
        if final_z >= zmax:
            raise RefreshError("Tool-change retract exceeds the machine Z maximum")
        # A new tool can be substantially longer than the last one. A click
        # must not initiate a downward move while already on/below its old
        # predicted contact; the ETS input itself is checked above.
        if actual_z <= expected:
            raise RefreshError("Current Z is already at/below predicted ETS contact")
        if not (zmin <= actual_z <= zmax + 0.02):
            raise RefreshError("Current Z outside reported soft limits")
        # Get live OEM ETS feedrates at each press as well.
        try:
            rough = float(redis.hget('machine_prefs', 'probe_rough_feed_per_minute'))
        except Exception:
            raise RefreshError("Cannot retrieve current rough probing feedrate")
        fine = float(constants.ETS_FINE_FEEDRATE)
        if not (finite(rough) and finite(fine) and rough > 0 and fine > 0):
            raise RefreshError("Invalid OEM ETS feedrates")
        applied_tlo = float(st.tool_offset[2])
        if not finite(applied_tlo):
            raise RefreshError("Invalid active tool offset")
        return dict(settings=settings, tool=tool, old_length=length,
                    ets_ref=ets_ref, ets_x=x, ets_y=y, expected=expected,
                    approach=approach, probe_limit=probe_limit, final_z=final_z,
                    current_z=actual_z, applied_tlo=applied_tlo, rough=rough,
                    fine=fine, max_z=zmax, min_z=zmin)

    def _select_tool(self, active_tool):
        """Enter the physically loaded tool. Prefill/select the active T#."""
        dialog = gtk.Dialog(
            "ETS Refresher - Select Tool",
            self.ui.window,
            gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
            (gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL,
             "Probe Selected Tool", gtk.RESPONSE_OK))
        dialog.set_keep_above(True)
        dialog.set_position(gtk.WIN_POS_CENTER_ALWAYS)
        dialog.set_default_response(gtk.RESPONSE_OK)
        dialog.set_border_width(14)
        body = dialog.vbox
        body.set_spacing(12)
        label = gtk.Label("Which tool is physically in the spindle?")
        label.set_alignment(0.0, 0.5)
        body.pack_start(label, False, False, 0)
        entry_row = gtk.HBox(False, 8)
        entry_label = gtk.Label("Tool number:")
        entry_row.pack_start(entry_label, False, False, 0)
        entry = gtk.Entry()
        entry.set_width_chars(9)
        entry.set_max_length(8)
        if active_tool > 0:
            entry.set_text(str(active_tool))
        entry_row.pack_start(entry, False, False, 0)
        body.pack_start(entry_row, False, False, 0)
        # Enter must submit the entry, not require clicking the dialog button.
        entry.connect("activate", lambda widget: dialog.response(gtk.RESPONSE_OK))
        dialog.show_all()
        # GTK2 sometimes gives initial keyboard focus to the default button.
        # Set focus after the modal dialog enters the GTK event loop too.

        def focus_and_select():
            entry.grab_focus()
            entry.select_region(0, -1)
            return False

        focus_and_select()
        glib.idle_add(focus_and_select)
        result = None
        while True:
            response = dialog.run()
            if response != gtk.RESPONSE_OK:
                break
            text = entry.get_text().strip()
            match = re.match(r'^[Tt]?\s*(\d+)$', text)
            if match is None:
                self._log("Enter a tool number such as 66 or T66.", constants.ALARM_LEVEL_LOW)
                focus_and_select()
                continue
            selected_tool = int(match.group(1))
            # Only re-declare the spindle tool if the chosen number differs.
            result = (selected_tool, selected_tool != active_tool)
            break
        dialog.destroy()
        return result

    def _update_button_states(self):
        occupied = (self._pending is not None or
                    (self._batch is not None and self._batch.get('active', False)))
        if self.button is not None:
            self.button.set_sensitive(not occupied)
        if self.batch_button is not None:
            self.batch_button.set_sensitive(not occupied)

    def _clear_pending(self):
        self._pending = None
        self._update_button_states()

    def _check_selected_tool_offset(self, tool):
        self.ui.status.poll()
        st = self.ui.status
        if tool <= 0 or tool >= len(st.tool_table):
            raise RefreshError("T%d is outside the current PathPilot tool table" % tool)
        value = float(st.tool_table[tool].zoffset)
        if not finite(value) or value <= 0.0:
            raise RefreshError("T%d has no usable existing length to refresh" % tool)
        return value

    def _select_tool_in_spindle(self, selected_tool):
        """M61/G43 is the OEM no-motion path for declaring a spindle tool."""
        mdi = "M61 Q%d G43 H%d" % (selected_tool, selected_tool)
        if not self.ui.issue_mdi(mdi):
            raise RefreshError("PathPilot rejected tool selection: %s" % mdi)
        self._log("Declaring physically loaded tool T%d using %s" % (selected_tool, mdi))
        self._pending = {'phase': 'select', 'tool': selected_tool, 'until': time.time() + TOOL_CHANGE_TIMEOUT_S,}
        self._update_button_states()
        glib.timeout_add(PENDING_POLL_MS, self._poll_pending)

    def _begin_refresh(self, selected_tool):
        # This is intentionally recalculated AFTER a manually entered T# has
        # become the interpreter's active spindle tool, not before it.
        p = self._plan()
        if p['tool'] != selected_tool:
            raise RefreshError("Spindle tool is T%d, not selected T%d" % (p['tool'], selected_tool))
        if not os.path.isfile(NGC_PATH):
            raise RefreshError("Missing ets_refresher.ngc in gcode/subroutines")
        settings = p['settings']
        mdi = (
            "o<ets_refresher> call [%.6f] [%.6f] [%.6f] [%.6f] "
            "[%.6f] [%.6f] [%.6f] [%.6f] [%.6f] [%.6f] "
            "[%.6f] [%.6f] [%.6f] [%.6f] [%.6f] [%.6f]" %
            (p['ets_ref'], p['old_length'], p['applied_tlo'],
             p['approach'], p['probe_limit'], p['final_z'],
             settings['fast_feed_ipm'], p['rough'], p['fine'],
             settings['max_length_change_in'], settings['tool_release_margin_in'],
             p['tool'], p['min_z'], p['max_z'],
             settings['approach_margin_in'], settings['rapid_approach']))
        self._pending = {
            'phase': 'measure',
            'tool': selected_tool,
            'old': p['old_length'],
            'until': time.time() + PROBE_TIMEOUT_S,
            'began': time.time(),
            'seen_busy': False,
            'idle_since': None,}
        self._log("T%d: OLD Z %.6f; contact Z%.4f, approach Z%.4f, "
                  "floor Z%.4f, retract Z%.4f" %
                  (p['tool'], p['old_length'], p['expected'],
                   p['approach'], p['probe_limit'], p['final_z']))
        if not self.ui.issue_mdi(mdi):
            self._pending = None
            raise RefreshError("PathPilot did not accept ETS refresh MDI")
        self._update_button_states()
        glib.timeout_add(PENDING_POLL_MS, self._poll_pending)

    def _show_result(self, tool, old_length, new_length):
        delta = new_length - old_length
        # A monospaced results block keeps signs, integer digits and decimal
        # points aligned. The width also accommodates longer tool offsets.
        rows = (
            ("Old Z offset:", old_length),
            ("New Z offset:", new_length),
            ("Delta (new-old):", delta),)
        numbers = ["%+.4f" % value for label, value in rows]
        label_width = max(len(label) for label, value in rows)
        number_width = max(len(number) for number in numbers)
        lines = []
        for index, (label, value) in enumerate(rows):
            lines.append("%-*s %*s in" % (label_width, label, number_width, numbers[index]))
        result_lines = "\n".join(lines)
        self._log("T%d - ETS Refresh Complete | %s" % (tool, result_lines.replace("\n", " | ")))
        try:
            self.ui.tool_liststore_stale = 2
        except Exception:
            pass
        dialog = gtk.MessageDialog(
            self.ui.window,
            gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
            gtk.MESSAGE_INFO,
            gtk.BUTTONS_OK,
            "T%d - ETS Refresh Complete" % tool)
        dialog.format_secondary_markup(
            '<span font_family="monospace">%s</span>' %
            glib.markup_escape_text(result_lines))
        dialog.set_title("ETS Refresher - T%d" % tool)
        dialog.set_keep_above(True)
        dialog.set_position(gtk.WIN_POS_CENTER_ALWAYS)
        dialog.run()
        dialog.destroy()

    def _poll_pending(self):
        pending = self._pending
        if pending is None:
            return False
        try:
            now = time.time()
            self.ui.status.poll()
            st = self.ui.status
            idle = st.interp_state == linuxcnc.INTERP_IDLE
            stopped = self.ui.program_running(False) is False and idle
            if pending['phase'] == 'select':
                if now >= pending['until']:
                    raise RefreshError("Timed out selecting T%d in PathPilot" % pending['tool'])
                if not stopped:
                    return True
                if int(st.tool_in_spindle) != pending['tool']:
                    return True
                expected_tlo = self._check_selected_tool_offset(pending['tool'])
                if abs(float(st.tool_offset[2]) - expected_tlo) > 0.002:
                    return True
                tool = pending['tool']
                self._pending = None
                self._begin_refresh(tool)
                return False
            if pending['phase'] == 'measure':
                # The v1.01 NGC performs G10 L1 and G43 itself.  There is no
                # reason to communicate the resulting length through LOGOPEN:
                # PathPilot already has the live tool table in linuxcnc.stat.
                # Wait until we have observed the MDI run, then for a short
                # stable idle period before reading the live value again.
                busy = (not idle or self.ui.program_running(False) or self.ui.mdi_running() or self.ui.moving())
                if busy:
                    pending['seen_busy'] = True
                    pending['idle_since'] = None
                else:
                    if not pending['seen_busy']:
                        # Handle an unlikely very fast run whose busy interval
                        # fell entirely between poll callbacks.  A changed live
                        # length is evidence that the table write occurred.
                        fresh_length = self._check_selected_tool_offset(pending['tool'])
                        if abs(fresh_length - pending['old']) > 0.0000005:
                            pending['seen_busy'] = True
                            pending['idle_since'] = now
                        elif now - pending['began'] > 6.0:
                            raise RefreshError("T%d: ETS cycle was not observed running" % pending['tool'])
                    elif pending['idle_since'] is None:
                        pending['idle_since'] = now
                    elif now - pending['idle_since'] >= 0.75:
                        if int(st.tool_in_spindle) != pending['tool']:
                            raise RefreshError("Spindle tool changed during T%d refresh" % pending['tool'])
                        # Read the CURRENT value only after the probe has
                        # finished, so every run starts from the latest length.
                        new_length = self._check_selected_tool_offset(pending['tool'])
                        tool = pending['tool']
                        old_length = pending['old']
                        self._clear_pending()
                        if self._batch is not None and self._batch.get('active'):
                            self._batch_record_success(tool, old_length, new_length)
                        else:
                            self._show_result(tool, old_length, new_length)
                        return False
                if now >= pending['until']:
                    raise RefreshError("Timed out waiting for T%d refresh completion" % pending['tool'])
                return True
            raise RefreshError("Unknown refresh state")
        except Exception as exc:
            self._clear_pending()
            self._log("Refresh incomplete: %s" % exc, constants.ALARM_LEVEL_LOW)
            if self._batch is not None and self._batch.get('active'):
                self._batch_fail(str(exc))
            return False

    # -------------------------------------------------------------------------
    # Persistent batch tool lists; each measurement uses the SAME single-tool
    # _select_tool_in_spindle / _begin_refresh / _poll_pending code above.
    # -------------------------------------------------------------------------

    def _read_batch_presets(self):
        with open(BATCH_FILE, 'rb') as handle:
            data = json.load(handle)
        if not isinstance(data, dict) or not isinstance(data.get('presets'), dict):
            raise RefreshError("Invalid ETS batch JSON (missing presets object)")
        presets = {}
        for name, tools in data['presets'].items():
            if not isinstance(name, basestring) or not name.strip():
                raise RefreshError("Invalid ETS batch preset name")
            if not isinstance(tools, list):
                raise RefreshError("ETS batch preset %s is not a tool list" % name)
            valid = []
            for tool in tools:
                if isinstance(tool, bool) or not isinstance(tool, (int, long)) or tool < 1:
                    raise RefreshError("Invalid tool in ETS batch preset %s" % name)
                valid.append(int(tool))
            presets[name] = valid
        if not presets:
            raise RefreshError("ETS batch file contains no presets")
        selected = data.get('selected', '')
        if selected not in presets:
            selected = sorted(presets.keys())[0]
        return {'selected': selected, 'presets': presets}

    def _write_batch_presets(self, data):
        """Atomic replace so a power loss cannot truncate the saved lists."""
        directory = os.path.dirname(BATCH_FILE)
        fd, staging = tempfile.mkstemp(prefix='.ets_refresher_batch_',
                                       suffix='.tmp', dir=directory)
        try:
            with os.fdopen(fd, 'wb') as handle:
                payload = (json.dumps(data, indent=4, sort_keys=True,
                                      ensure_ascii=True) + '\n').encode('utf-8')
                handle.write(payload)
                handle.flush()
                os.fsync(handle.fileno())
            os.rename(staging, BATCH_FILE)
        finally:
            if os.path.exists(staging):
                os.unlink(staging)

    def _parse_batch_tools(self, text):
        """One tool per line or separated by commas/spaces; T prefix optional."""
        tokens = []
        for line in text.splitlines():
            line = line.split('#', 1)[0].strip()
            if line:
                tokens.extend(re.split(r'[\s,;]+', line))
        tools = []
        for token in tokens:
            if not token:
                continue
            match = re.match(r'^[Tt]?(\d+)$', token)
            if match is None or int(match.group(1)) < 1:
                raise RefreshError("Invalid tool %r; use numbers like 5, T66, 125" % token)
            tools.append(int(match.group(1)))
        if not tools:
            raise RefreshError("Enter at least one tool number")
        return tools

    def _batch_entry_text(self):
        view = self._batch_editor['view']
        buf = view.get_buffer()
        return buf.get_text(buf.get_start_iter(), buf.get_end_iter(), True)

    def _batch_populate_preset_names(self, selected):
        editor = self._batch_editor
        combo = editor['combo']
        self._batch_editor_loading = True
        try:
            model = combo.get_model()
            model.clear()
            names = sorted(editor['data']['presets'].keys(), key=lambda s: s.lower())
            for name in names:
                combo.append_text(name)
            combo.set_active(names.index(selected))
        finally:
            self._batch_editor_loading = False
        self._batch_preset_selected(combo)

    def _batch_preset_selected(self, combo):
        if self._batch_editor_loading or self._batch_editor is None:
            return
        name = combo.get_active_text()
        if not name:
            return
        editor = self._batch_editor
        editor['name'] = name
        tools = editor['data']['presets'][name]
        editor['view'].get_buffer().set_text('\n'.join('T%d' % n for n in tools))
        editor['status'].set_text("Editing %s. Save or Start Batch to remember changes." % name)

    def _batch_save_list(self, widget=None, new_name=None):
        editor = self._batch_editor
        tools = self._parse_batch_tools(self._batch_entry_text())
        name = new_name if new_name is not None else editor['name']
        editor['data']['presets'][name] = tools
        editor['data']['selected'] = name
        self._write_batch_presets(editor['data'])
        if new_name is not None:
            self._batch_populate_preset_names(name)
        editor['status'].set_text("Saved %s: %d tools." % (name, len(tools)))
        return tools

    def _batch_save_clicked(self, widget):
        try:
            self._batch_save_list()
        except Exception as exc:
            self._batch_editor['status'].set_text("Save failed: %s" % exc)
            self._log("Batch preset save failed: %s" % exc, constants.ALARM_LEVEL_LOW)

    def _batch_save_as_clicked(self, widget):
        dialog = gtk.Dialog("Save ETS Batch As", self._batch_editor['window'],
                            gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
                            (gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL,
                             gtk.STOCK_SAVE, gtk.RESPONSE_OK))
        row = gtk.HBox(False, 8)
        row.set_border_width(10)
        row.pack_start(gtk.Label("List name:"), False, False, 0)
        entry = gtk.Entry()
        entry.set_text(self._batch_editor['name'])
        entry.connect('activate', lambda w: dialog.response(gtk.RESPONSE_OK))
        row.pack_start(entry, True, True, 0)
        dialog.vbox.pack_start(row, True, True, 0)
        dialog.set_keep_above(True)
        dialog.set_position(gtk.WIN_POS_CENTER_ALWAYS)
        dialog.show_all()
        dialog.present()
        entry.grab_focus()
        entry.select_region(0, -1)
        try:
            if dialog.run() == gtk.RESPONSE_OK:
                name = entry.get_text().strip()
                if not name or len(name) > 60:
                    raise RefreshError("Preset name must contain 1-60 characters")
                self._batch_save_list(new_name=name)
        except Exception as exc:
            self._batch_editor['status'].set_text("Save As failed: %s" % exc)
        finally:
            dialog.destroy()

    def _batch_delete_clicked(self, widget):
        editor = self._batch_editor
        name = editor['name']
        if len(editor['data']['presets']) == 1:
            editor['status'].set_text("Keep at least one preset. Save another first.")
            return
        del editor['data']['presets'][name]
        next_name = sorted(editor['data']['presets'].keys())[0]
        editor['data']['selected'] = next_name
        try:
            self._write_batch_presets(editor['data'])
            self._batch_populate_preset_names(next_name)
        except Exception as exc:
            editor['status'].set_text("Delete failed: %s" % exc)

    def _batch_editor_closed(self, window, event=None):
        self._batch_editor = None
        return False

    def _configure_batch_window(self, window):
        """Present a non-modal batch window above PathPilot, like our GTK dialogs."""
        window.set_transient_for(self.ui.window)
        window.set_destroy_with_parent(True)
        window.set_position(gtk.WIN_POS_CENTER_ALWAYS)
        window.set_type_hint(gtk.gdk.WINDOW_TYPE_HINT_DIALOG)
        window.set_keep_above(True)

    def _present_batch_window(self, window):
        """Raise after GTK maps it; do not enter a nested modal event loop."""
        try:
            if window.get_visible():
                window.deiconify()
                window.present()
        except Exception:
            # It may have been closed before the idle callback runs.
            pass
        return False

    def _open_batch_editor(self, widget):
        if self._pending is not None or (self._batch is not None and self._batch.get('active')):
            return
        if self._batch_editor is not None:
            self._present_batch_window(self._batch_editor['window'])
            return
        if self._batch is not None:
            self._present_batch_window(self._batch['window'])
            return
        try:
            data = self._read_batch_presets()
        except Exception as exc:
            self._log("Cannot open batch lists: %s" % exc, constants.ALARM_LEVEL_LOW)
            return
        window = gtk.Window(gtk.WINDOW_TOPLEVEL)
        window.set_title("ETS Refresher - Batch Tool Lists")
        window.set_default_size(560, 510)
        self._configure_batch_window(window)
        window.connect('destroy', self._batch_editor_closed)
        outer = gtk.VBox(False, 9)
        outer.set_border_width(12)
        window.add(outer)
        title = gtk.Label("Choose a saved tool list, or edit it for this batch.")
        title.set_alignment(0.0, 0.5)
        outer.pack_start(title, False, False, 0)
        row = gtk.HBox(False, 7)
        row.pack_start(gtk.Label("Tool list:"), False, False, 0)
        combo = gtk.combo_box_new_text()
        combo.connect('changed', self._batch_preset_selected)
        row.pack_start(combo, True, True, 0)
        for caption, callback in (("Save List", self._batch_save_clicked),
                                  ("Save As...", self._batch_save_as_clicked),
                                  ("Delete", self._batch_delete_clicked)):
            button = gtk.Button(caption)
            button.connect('clicked', callback)
            row.pack_start(button, False, False, 0)
        outer.pack_start(row, False, False, 0)
        help_label = gtk.Label("Tool numbers, one per line or comma-separated. Example: 5, 66, 125")
        help_label.set_alignment(0.0, 0.5)
        outer.pack_start(help_label, False, False, 0)
        scroller = gtk.ScrolledWindow()
        scroller.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
        view = gtk.TextView()
        view.modify_font(pango.FontDescription('Monospace 13'))
        view.set_wrap_mode(gtk.WRAP_WORD_CHAR)
        scroller.add(view)
        outer.pack_start(scroller, True, True, 0)
        status = gtk.Label("")
        status.set_alignment(0.0, 0.5)
        status.set_line_wrap(True)
        outer.pack_start(status, False, False, 0)
        buttons = gtk.HBox(False, 8)
        cancel = gtk.Button("Close")
        cancel.connect('clicked', lambda w: window.destroy())
        start = gtk.Button("Start Batch")
        start.connect('clicked', self._batch_start_clicked)
        buttons.pack_end(start, False, False, 0)
        buttons.pack_end(cancel, False, False, 0)
        outer.pack_end(buttons, False, False, 0)
        self._batch_editor = {'window': window, 'combo': combo, 'view': view,
                              'data': data, 'name': data['selected'], 'status': status}
        self._batch_populate_preset_names(data['selected'])
        window.show_all()
        view.grab_focus()
        self._present_batch_window(window)
        glib.idle_add(self._present_batch_window, window)

    def _batch_start_clicked(self, widget):
        editor = self._batch_editor
        try:
            if self._batch is not None and self._batch.get('active'):
                raise RefreshError("Another ETS batch is active")
            self._check_machine(allow_no_tool=True)
            tools = self._batch_save_list()
            if not os.path.isfile(NGC_PATH):
                raise RefreshError("Missing ets_refresher.ngc")
            name = editor['name']
        except Exception as exc:
            editor['status'].set_text("Cannot start: %s" % exc)
            return
        editor['window'].destroy()
        self._batch_editor = None
        self._start_batch_window(name, tools)

    def _start_batch_window(self, name, tools):
        window = gtk.Window(gtk.WINDOW_TOPLEVEL)
        window.set_title("ETS Refresher - Batch: %s" % name)
        window.set_default_size(670, 440)
        self._configure_batch_window(window)
        window.connect('delete-event', self._batch_window_delete)
        outer = gtk.VBox(False, 8)
        outer.set_border_width(12)
        window.add(outer)
        status = gtk.Label("")
        status.set_alignment(0.0, 0.5)
        status.set_line_wrap(True)
        outer.pack_start(status, False, False, 0)
        header = gtk.Label("Tool      Old Z        New Z         Delta (in)")
        header.set_alignment(0.0, 0.5)
        header.modify_font(pango.FontDescription('Monospace 11'))
        outer.pack_start(header, False, False, 0)
        scroller = gtk.ScrolledWindow()
        scroller.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
        logview = gtk.TextView()
        logview.set_editable(False)
        logview.set_cursor_visible(False)
        logview.modify_font(pango.FontDescription('Monospace 11'))
        logview.set_wrap_mode(gtk.WRAP_WORD_CHAR)
        scroller.add(logview)
        outer.pack_start(scroller, True, True, 0)
        actions = gtk.HBox(False, 8)
        ready = gtk.Button("Tool Installed - Probe")
        ready.connect('clicked', self._batch_ready_clicked)
        stop = gtk.Button("Stop Batch")
        stop.connect('clicked', self._batch_stop_clicked)
        close = gtk.Button("Close")
        close.connect('clicked', self._batch_close_clicked)
        actions.pack_start(ready, False, False, 0)
        actions.pack_start(stop, False, False, 0)
        actions.pack_end(close, False, False, 0)
        outer.pack_end(actions, False, False, 0)
        self._batch = {'active': True, 'name': name, 'tools': list(tools),
                       'index': 0, 'results': [], 'stop_requested': False,
                       'window': window, 'status': status, 'log': logview,
                       'ready': ready, 'stop': stop, 'close': close}
        self._update_button_states()
        window.show_all()
        self._batch_append("Batch %s: %d tools\n" % (name, len(tools)))
        self._batch_ready_display()
        ready.grab_focus()
        self._present_batch_window(window)
        glib.idle_add(self._present_batch_window, window)

    def _batch_append(self, line):
        if self._batch is None:
            return
        view = self._batch['log']
        buf = view.get_buffer()
        buf.insert(buf.get_end_iter(), line.rstrip('\n') + '\n')
        mark = buf.create_mark(None, buf.get_end_iter(), False)
        view.scroll_to_mark(mark, 0.0, False, 0.0, 0.0)
        buf.delete_mark(mark)

    def _batch_ready_display(self):
        batch = self._batch
        if batch is None or not batch['active']:
            return
        if batch['index'] >= len(batch['tools']):
            self._batch_finish("COMPLETE")
            return
        tool = batch['tools'][batch['index']]
        batch['status'].set_text("%d of %d: Install T%d, then press Tool Installed - Probe." %
                                 (batch['index'] + 1, len(batch['tools']), tool))
        batch['ready'].set_sensitive(True)
        batch['ready'].grab_focus()

    def _batch_ready_clicked(self, widget):
        batch = self._batch
        if batch is None or not batch['active'] or self._pending is not None:
            return
        if batch['stop_requested']:
            self._batch_finish("STOPPED")
            return
        tool = batch['tools'][batch['index']]
        batch['ready'].set_sensitive(False)
        try:
            self._check_machine(allow_no_tool=True)
            self._check_selected_tool_offset(tool)
            self.ui.status.poll()
            active = int(self.ui.status.tool_in_spindle)
            batch['status'].set_text("T%d: selecting and measuring..." % tool)
            if active != tool:
                self._select_tool_in_spindle(tool)
            else:
                self._begin_refresh(tool)
        except Exception as exc:
            self._log("Batch T%d cannot start: %s" % (tool, exc), constants.ALARM_LEVEL_LOW)
            self._batch_fail(str(exc))

    def _batch_record_success(self, tool, old_length, new_length):
        batch = self._batch
        if batch is None or not batch['active']:
            return
        expected_tool = batch['tools'][batch['index']]
        if tool != expected_tool:
            self._batch_fail("Unexpected T%d, expected T%d" % (tool, expected_tool))
            return
        delta = new_length - old_length
        batch['results'].append({'tool': tool, 'old': old_length,
                                 'new': new_length, 'delta': delta})
        line = "T%-5d %+10.4f   %+10.4f   %+10.4f" % (tool, old_length, new_length, delta)
        self._batch_append(line)
        self._log("Batch %s" % line)
        try:
            self.ui.tool_liststore_stale = 2
        except Exception:
            pass
        batch['index'] += 1
        if batch['stop_requested']:
            self._batch_finish("STOPPED")
        elif batch['index'] >= len(batch['tools']):
            self._batch_finish("COMPLETE")
        else:
            self._batch_ready_display()

    def _batch_stop_clicked(self, widget):
        batch = self._batch
        if batch is None or not batch['active']:
            return
        if self._pending is not None:
            batch['stop_requested'] = True
            batch['status'].set_text("Stop requested. Finishing current probe; use PathPilot STOP to interrupt it.")
            batch['stop'].set_sensitive(False)
            return
        self._batch_finish("STOPPED")

    def _batch_fail(self, reason):
        batch = self._batch
        if batch is None or not batch['active']:
            return
        tool = batch['tools'][batch['index']]
        self._batch_append("T%d FAILED: %s" % (tool, reason))
        self._batch_finish("STOPPED ON ERROR")

    def _batch_finish(self, outcome):
        batch = self._batch
        if batch is None or not batch['active']:
            return
        batch['active'] = False
        done = len(batch['results'])
        total = len(batch['tools'])
        batch['ready'].set_sensitive(False)
        batch['stop'].set_sensitive(False)
        self._batch_append("\n%s: %d of %d measurements completed." % (outcome, done, total))
        if done:
            largest = max(batch['results'], key=lambda r: abs(r['delta']))
            self._batch_append("Largest absolute delta: T%d %+.4f in" %
                               (largest['tool'], largest['delta']))
        batch['status'].set_text("Batch %s: %d/%d complete. Results remain above." %
                                 (outcome.lower(), done, total))
        self._log("Batch %s: %d/%d completed" % (outcome, done, total),
                  constants.ALARM_LEVEL_LOW)
        self._update_button_states()

    def _batch_window_delete(self, widget, event):
        if self._batch is not None and self._batch.get('active'):
            self._batch_stop_clicked(widget)
            return True
        self._batch = None
        self._update_button_states()
        return False

    def _batch_close_clicked(self, widget):
        if self._batch is None:
            return
        if self._batch.get('active'):
            self._batch_stop_clicked(widget)
        else:
            self._batch['window'].destroy()
            self._batch = None
            self._update_button_states()

    def _clicked(self, widget):
        if self._pending is not None or (self._batch is not None and self._batch.get('active')):
            return
        try:
            self.ui.status.poll()
            self._check_machine(allow_no_tool=True)
            active_tool = int(self.ui.status.tool_in_spindle)
            choice = self._select_tool(active_tool)
            if choice is None:
                return
            selected_tool, explicit = choice
            self._check_selected_tool_offset(selected_tool)
            self._update_button_states()
            if explicit:
                self._select_tool_in_spindle(selected_tool)
            else:
                self._begin_refresh(selected_tool)
        except Exception as exc:
            self._clear_pending()
            self._log("Refresh not started: %s" % exc, constants.ALARM_LEVEL_LOW)