# 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:         tormach.1100m@gmail.com
# Information page: https://tormachtips.com/plugins.htm

#############################################
##                                         ##
##          Spindle Timer v0.99            ##
##          www.tormachtips.com            ##
##                                         ##
#############################################

# 0.99 - updating the tool database from inside did not update History. Fixed.  - 4/29/2026
# 0.96 - public beta                                                            - 4/02/2026

# THE MAIN PATHPILOT PLUGIN THAT TIES EVERYTHING TOGETHER.
# THIS LOGS WHETHER THE SPINDLE IS ROTATING OR NOT, EFFECTIVELY MONITORING MACHINE USE.
# WRITES TO DAILY TXT FILES WITH A CUMULATIVE SECONDS COUNTER.
# ALSO WRITES TO AN SQLITE DB THAT MONITORS INDIVIDUAL TOOL LIFE.

import os
import glib
import gtk
import datetime
import constants
import singletons
import sqlite3
from ui_hooks import plugin, version_list

CURRENT_VER      = "0.99"
SCRIPT_NAME      = "Hobbs Spindle Time Monitor"
DESCRIPTION      = "The main plugin for spindle hobbs timer control. Used for tool life manager script and others."
HOBBS_DIR        = "/home/operator/gcode/python/hobbs"
MASTER_FILE      = os.path.join(HOBBS_DIR, "hobbs.txt")
DB_FILE          = os.path.join(HOBBS_DIR, "hobbs_tool_life.db")
POLL_MINUTES     = 10 # HOW OFTEN TO CHECK REMINDERS.
APPROACHING      = 10 # "APPROACHING LIMIT" WHEN THIS MANY MINUTES REMAIN.
REPEAT_MIN       = 10 # REPEAT SAME-STATE REMINDER NO MORE THAN ONCE PER THIS MANY MINUTES.
ENABLED          = 1
DEV_MACHINE      = 1
DEV_MACHINE_FLAG = "/home/operator/gcode/python/dev_machine.txt"

class ToolChangeWatcher(object):
    WATCHED_FIELDS = [
        ('zoffset',    'Z offset',       'float'),
        ('diameter',   'Diameter',       'float'),
        ('woffset',    'Z offset wear',  'float'),
        ('frontangle', 'Diameter wear',  'float'),
        ('backangle',  'Max RPM',        'int'),    ]

    def __init__(self, hal_status, error_handler, reset_callback, interval_ms=1000):
        self.halStatus = hal_status
        self.error_handler = error_handler
        self.reset_callback = reset_callback
        self.interval_ms = interval_ms
        self._last_snapshot = {}
        self._started = False
        self._prompt_active = False
        self._pending_prompt = None
    
    def start(self):
        glib.timeout_add(self.interval_ms, self.poll)
    
    def _safe_float(self, value, default=0.0):
        try:
            return float(value)
        except:
            return default
    
    def _safe_int(self, value, default=0):
        try:
            return int(value)
        except:
            return default
    
    def _snapshot_from_entry(self, t):
        data = {'tool': self._safe_int(getattr(t, 'id', 0))}
        for attr_name, _label, value_type in self.WATCHED_FIELDS:
            raw_value = getattr(t, attr_name, 0)
            if value_type == 'int':
                data[attr_name] = self._safe_int(raw_value, 0)
            else:
                data[attr_name] = self._safe_float(raw_value, 0.0)
        return data
    
    def _build_snapshot(self, tool_table):
        snapshot = {}
        for pocket in xrange(1, len(tool_table)):
            try:
                t = tool_table[pocket]
            except:
                continue
            if not t:
                continue
            tool_num = self._safe_int(getattr(t, 'id', 0))
            if tool_num <= 0:
                continue
            snapshot[tool_num] = self._snapshot_from_entry(t)
        return snapshot
    
    def _format_changes(self, old_data, new_data):
        changes = []
        epsilon = 0.000001
        for key, label, value_type in self.WATCHED_FIELDS:
            old_value = old_data.get(key)
            new_value = new_data.get(key)
            if value_type == 'int':
                if int(old_value) != int(new_value):
                    changes.append("{}: {} -> {}".format(label, int(old_value), int(new_value)))
            else:
                if abs(float(new_value) - float(old_value)) > epsilon:
                    changes.append("{}: {:.6f} -> {:.6f}".format(label, float(old_value), float(new_value)))
        return changes
    
    def _on_dialog_response(self, dialog, response_id, tool_num):
        try:
            dialog.destroy()
        except:
            pass
        try:
            if response_id == gtk.RESPONSE_YES:
                self.reset_callback(tool_num)
            else:
                self.error_handler.write("[Hobbs Timer] User chose NOT to reset tool life for T{}".format(tool_num),constants.ALARM_LEVEL_LOW)
        finally:
            self._prompt_active = False
        return False
    
    def _show_prompt(self):
        if self._prompt_active:
            return False
        pending = self._pending_prompt
        if not pending:
            return False
        self._pending_prompt = None
        self._prompt_active = True
        tool_num = pending['tool']
        changes = pending['changes']
        secondary_text = "{}\n\nDo you wish to reset the tool life database for this tool?".format("\n".join(changes))
        try:
            dialog = gtk.MessageDialog(
                None,
                gtk.DIALOG_DESTROY_WITH_PARENT,
                gtk.MESSAGE_QUESTION,
                gtk.BUTTONS_NONE,
                "Tool Table change detected for T{}".format(tool_num)            )
            dialog.format_secondary_text(secondary_text)
            dialog.add_button("No", gtk.RESPONSE_NO)
            dialog.add_button("Yes", gtk.RESPONSE_YES)
            dialog.set_modal(False)
            dialog.connect("response", self._on_dialog_response, tool_num)
            dialog.show_all()
        except Exception as e:
            self._prompt_active = False
            self.error_handler.write("[Hobbs Timer] Failed to show tool change prompt: {}".format(str(e)),constants.ALARM_LEVEL_LOW)
        return False
    
    def poll(self):
        try:
            try:
                self.halStatus.poll()
            except Exception:
                return True
            tool_table = getattr(self.halStatus, 'tool_table', None)
            if not tool_table:
                return True
            current_snapshot = self._build_snapshot(tool_table)
            if not self._started:
                self._last_snapshot = current_snapshot
                self._started = True
                return True
            detected_prompt = None
            for tool_num, new_data in current_snapshot.iteritems():
                old_data = self._last_snapshot.get(tool_num)
                if old_data is None:
                    continue
                changes = self._format_changes(old_data, new_data)
                if not changes:
                    continue
                self.error_handler.write("[Hobbs Timer] Tool table change detected for T{}: {}".format(tool_num, "; ".join(changes)),constants.ALARM_LEVEL_LOW)
                if detected_prompt is None:
                    detected_prompt = {'tool': tool_num,'changes': changes}
            self._last_snapshot = current_snapshot
            if detected_prompt and not self._prompt_active and not self._pending_prompt:
                self._pending_prompt = detected_prompt
                glib.idle_add(self._show_prompt)
        except Exception as e:
            self.error_handler.write("[Hobbs Timer] Tool install watcher error: {}".format(str(e)),constants.ALARM_LEVEL_LOW)
        return True

class UserPlugin(plugin):
    def __init__(self):
        plugin.__init__(self, 'Spindle Hobbs Timer - tormachtips.com')
        dev_machine_found = os.path.exists(DEV_MACHINE_FLAG)
        if dev_machine_found:
            plugin_enabled = DEV_MACHINE
        else:
            plugin_enabled = ENABLED
        if plugin_enabled:
            self.total_seconds = 0
            self._ensure_hobbs_dir()                
            self._load_total_from_master()
            self._init_tool_db()
            self._maybe_show_first_run_welcome()
            self.tool_change_watcher = ToolChangeWatcher(self.halStatus,self.error_handler,self._reset_tool,interval_ms=1000)
            self.tool_change_watcher.start()
            glib.timeout_add(1000, self.periodic_check)        
            glib.timeout_add(15000, self.startup_tool_reminder_once)
            glib.timeout_add(15000, self._show_startup_runtime_once)
            glib.timeout_add(POLL_MINUTES * 60 * 1000, self.poll_tool_reminders)
            return
        else:
            if dev_machine_found:
                self.error_handler.write("[Spindle Hobbs Timer] Dev machine found. Plugin loaded, but disabled by DEV_MACHINE.", constants.ALARM_LEVEL_QUIET)
            else:
                self.error_handler.write("[Spindle Hobbs Timer] Plugin loaded, but disabled.", constants.ALARM_LEVEL_QUIET)
                self.error_handler.write("[Spindle Hobbs Timer] To enable, open script, find ENABLED = 0 and change to ENABLED = 1", constants.ALARM_LEVEL_QUIET)
            return
    
    def _elapsed_seconds_from_install(self, installed_date_text, reset_date_text):
        installed_dt = self._parse_datetime(installed_date_text)
        reset_dt = self._parse_datetime(reset_date_text)
        if installed_dt and reset_dt:
            elapsed_seconds = int((reset_dt - installed_dt).total_seconds())
            if elapsed_seconds >= 0:
                return elapsed_seconds
        return 0
        
    def _get_reminder_state(self, total_seconds, remind_after_minutes):
        threshold_seconds = int(remind_after_minutes) * 60
        approach_seconds = max(0, threshold_seconds - (APPROACHING * 60))
        if total_seconds >= threshold_seconds:
            return "exceeded"
        if total_seconds >= approach_seconds:
            return "approaching"
        return None

    def _should_send_reminder(self, last_notified_at_text, last_notified_state, new_state):
        if not new_state:
            return False
        if last_notified_state != new_state:
            return True
        last_dt = self._parse_datetime(last_notified_at_text)
        if not last_dt:
            return True
        elapsed_seconds = (datetime.datetime.now() - last_dt).total_seconds()
        return elapsed_seconds >= (REPEAT_MIN * 60)

    def startup_tool_reminder_once(self):
        self.poll_tool_reminders()
        return False    
    
    def _show_startup_runtime_once(self):
        try:
            self.error_handler.write(" ")
            self.error_handler.write("[Hobbs Timer] Current spindle runtime: {} seconds ({:.1f} hrs)".format(self.total_seconds,self.total_seconds / 3600.0),constants.ALARM_LEVEL_QUIET)
            self.error_handler.write(" ")            
        except Exception as e:
            self.error_handler.write("[Hobbs Timer] Startup runtime message error: {}".format(str(e)),constants.ALARM_LEVEL_LOW)
        return False

    def _maybe_show_first_run_welcome(self):
        flag_file = os.path.join(HOBBS_DIR, "hobbs_first_run_flag.txt")
        if os.path.exists(flag_file):
            return                    
        message = (
            "Welcome to the Tormach Tips Spindle Timer & Tool Life Manager!\n\n"
            "This plugin runs in the background and automatically tracks spindle runtime and per-tool life as you use the machine.\n\n"
            "To view your data, type one of the following in the MDI line:\n\n"
            "ADMIN HOBBS displays total spindle runtime.\n\n"
            "ADMIN HOBBSM displays spindle runtime by month.\n\n"
            "ADMIN HOBBSH displays spindle runtime by hour.\n\n"
            "ADMIN TOOLS opens the Tool Life Manager for individual tools.\n\n\n"        
            "www.tormachtips.com"   )
        
        def _show_dialog():
            try:
                dialog = gtk.MessageDialog(
                    None,
                    gtk.DIALOG_DESTROY_WITH_PARENT,
                    gtk.MESSAGE_INFO,
                    gtk.BUTTONS_OK,
                    "Spindle Timer & Tool Life Manager"            )
                dialog.format_secondary_text(message)
                dialog.set_modal(False)
                dialog.connect("response", lambda d, r: d.destroy())
                dialog.show_all()
                with open(flag_file, 'w') as f:
                    f.write(datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
            except Exception as e:
                self.error_handler.write("[Hobbs Timer] Failed to show first-run welcome: {}".format(str(e)),constants.ALARM_LEVEL_LOW)
            return False
        glib.idle_add(_show_dialog)
    
    def _ensure_hobbs_dir(self):
        try:
            if not os.path.isdir(HOBBS_DIR):
                os.makedirs(HOBBS_DIR)
        except Exception as e:
            self.error_handler.write(
                "[Hobbs Timer] Failed to create {}: {}".format(HOBBS_DIR, str(e)),
                constants.ALARM_LEVEL_LOW)

    def _db_connect(self):
        return sqlite3.connect(DB_FILE)
    
    def _init_tool_db(self):
        conn = self._db_connect()
        c = conn.cursor()
        c.execute('''CREATE TABLE IF NOT EXISTS tool_usage (
                        tool INTEGER PRIMARY KEY,
                        installed_date TEXT,
                        total_seconds INTEGER DEFAULT 0
                     )''')
        c.execute('''CREATE TABLE IF NOT EXISTS tool_history (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        tool INTEGER NOT NULL,
                        installed_date TEXT,
                        reset_date TEXT NOT NULL,
                        elapsed_seconds INTEGER DEFAULT 0,
                        total_seconds INTEGER DEFAULT 0
                     )''')
        c.execute('''CREATE TABLE IF NOT EXISTS tool_reminders (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        tool INTEGER NOT NULL,
                        remind_after_minutes INTEGER NOT NULL,
                        message TEXT,
                        is_active INTEGER DEFAULT 1,
                        last_notified_at TEXT,
                        last_notified_state TEXT
                     )''')
        try:
            c.execute("ALTER TABLE tool_reminders ADD COLUMN last_notified_at TEXT")
        except:
            pass
        try:
            c.execute("ALTER TABLE tool_reminders ADD COLUMN last_notified_state TEXT")
        except:
            pass
        conn.commit()
        conn.close()
    
    def _parse_datetime(self, text_value):
        if not text_value:
            return None
        for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"):
            try:
                return datetime.datetime.strptime(text_value.strip(), fmt)
            except:
                pass
        return None

    def _should_fire_reminder(self, total_seconds, remind_after_minutes):
        threshold_seconds = int(remind_after_minutes) * 60
        return total_seconds >= threshold_seconds    

    def poll_tool_reminders(self):
        try:
            conn = self._db_connect()
            c = conn.cursor()
            c.execute("""
                SELECT r.id,
                       r.tool,
                       r.remind_after_minutes,
                       r.message,
                       r.last_notified_at,
                       r.last_notified_state,
                       u.total_seconds
                  FROM tool_reminders r
                  JOIN tool_usage u ON u.tool = r.tool
                 WHERE r.is_active = 1
            """)
            rows = c.fetchall()
            now_text = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            for reminder_id, tool, remind_after_minutes, message, last_notified_at, last_notified_state, total_seconds in rows:
                total_seconds = int(total_seconds or 0)
                state = self._get_reminder_state(total_seconds, remind_after_minutes)
                if not state:
                    c.execute(
                        """UPDATE tool_reminders
                           SET last_notified_at = NULL,
                               last_notified_state = NULL
                           WHERE id = ?""",
                        (reminder_id,)
                    )
                    continue
                if not self._should_send_reminder(last_notified_at, last_notified_state, state):
                    continue
                used_minutes = total_seconds / 60.0
                limit_minutes = int(remind_after_minutes)
                remaining_minutes = max(0.0, limit_minutes - used_minutes)
                custom_msg = (" " + message.strip()) if (message and message.strip()) else ""
                if state == "approaching":
                    self.error_handler.write(" ")
                    self.error_handler.write("[Hobbs Timer] {:.1f} min used, {:.1f} min remaining, limit = {} min.{}".format(used_minutes,remaining_minutes,limit_minutes,custom_msg),constants.ALARM_LEVEL_LOW)
                    self.error_handler.write("[Hobbs Timer] REMINDER: T{} is approaching its wear limit.".format(tool),constants.ALARM_LEVEL_LOW)
                    self.error_handler.write(" ")                    
                else:
                    self.error_handler.write(" ")                    
                    self.error_handler.write("[Hobbs Timer] {:.1f} min used, limit = {} min.{}".format(used_minutes,limit_minutes,custom_msg),constants.ALARM_LEVEL_LOW)
                    self.error_handler.write("[Hobbs Timer] REMINDER: T{} has EXCEEDED its wear limit.".format(tool),constants.ALARM_LEVEL_LOW)
                    self.error_handler.write(" ")                    
                c.execute(
                    """UPDATE tool_reminders
                       SET last_notified_at = ?,
                           last_notified_state = ?
                       WHERE id = ?""",
                    (now_text, state, reminder_id)                )
            conn.commit()
            conn.close()
        except Exception as e:
            self.error_handler.write("[Hobbs Timer] Reminder poll error: {}".format(str(e)), constants.ALARM_LEVEL_LOW)
        return True
    
    def _get_current_tool(self, ui):
        try:
            return int(ui.status.tool_in_spindle)
        except:
            return 0
    
    def _update_tool_lifetime(self, tool, rpm):
        if tool == 0:
            return
        conn = self._db_connect()
        c = conn.cursor()
        now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        c.execute("INSERT OR IGNORE INTO tool_usage (tool, installed_date) VALUES (?, ?)",(tool, now))
        c.execute("UPDATE tool_usage SET total_seconds = total_seconds + 1 WHERE tool = ?",(tool,))
        conn.commit()
        conn.close()
    
    def _reset_tool(self, tool):
        if tool == 0:
            return
        conn = self._db_connect()
        c = conn.cursor()
        now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        try:
            c.execute("SELECT installed_date, total_seconds FROM tool_usage WHERE tool = ?", (tool,))
            row = c.fetchone()
            if row:
                installed_date, total_seconds = row
                total_seconds = int(total_seconds or 0)
                elapsed_seconds = self._elapsed_seconds_from_install(installed_date, now)
                c.execute(
                    """INSERT INTO tool_history
                       (tool, installed_date, reset_date, elapsed_seconds, total_seconds)
                       VALUES (?, ?, ?, ?, ?)""",
                    (tool, installed_date, now, elapsed_seconds, total_seconds)
                )
                c.execute(
                    """UPDATE tool_usage
                       SET total_seconds = 0,
                           installed_date = ?
                       WHERE tool = ?""",
                    (now, tool)
                )
            else:
                c.execute("INSERT INTO tool_usage (tool, installed_date, total_seconds) VALUES (?, ?, 0)", (tool, now))
            conn.commit()
            self.error_handler.write("[Hobbs Timer] Tool {} lifetime has been archived and RESET".format(tool),constants.ALARM_LEVEL_LOW)
        except Exception as e:
            conn.rollback()
            self.error_handler.write("[Hobbs Timer] Tool {} reset failed: {}".format(tool, str(e)),constants.ALARM_LEVEL_LOW)
        finally:
            conn.close()
    
    def _get_today_file(self):
        today = datetime.date.today().strftime("%Y-%m-%d")
        return os.path.join(HOBBS_DIR, "hobbs_{}.txt".format(today))
    
    def _load_total_from_master(self):
        if os.path.exists(MASTER_FILE):
            try:
                with open(MASTER_FILE, 'r') as f:
                    for line in f:
                        if line.startswith("total_seconds="):
                            self.total_seconds = int(line.split('=')[1].strip())
                            break
            except:
                self.error_handler.write("[Hobbs Timer] Could not read master hobbs.txt - starting at 0",constants.ALARM_LEVEL_LOW)
    
    def _save_master_header(self):
        try:
            with open(MASTER_FILE, 'w') as f:
                f.write("# Spindle Hobbs Timer - tormachtips.com\n")
                f.write("total_seconds={}\n".format(self.total_seconds))
                f.write("# Last updated: {}\n".format(
                    datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"))                )
        except Exception as e:
            self.error_handler.write("[Hobbs Timer] Error writing master hobbs.txt: {}".format(str(e)),constants.ALARM_LEVEL_LOW)
    
    def periodic_check(self):
        try:
            ui = singletons.g_Machine
            if not ui or not hasattr(ui, 'hal'):
                return True
            spindle_on = bool(ui.hal['spindle-on'])
            try:
                rpm = abs(float(ui.hal['spindle-speed-out']))
            except (KeyError, ValueError):
                rpm = 0.0
            try:
                tool = int(ui.status.tool_in_spindle)
            except (AttributeError, KeyError, ValueError):
                tool = 0
            current_tool = self._get_current_tool(ui)
            if spindle_on:
                self.total_seconds += 1
                timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                today_file = self._get_today_file()
                with open(today_file, 'a') as f:
                    f.write("{} : spindle ON @ {:.0f} RPM   (+1s)   total = {} seconds, tool {}\n".format(timestamp, rpm, self.total_seconds, tool))
                self._update_tool_lifetime(current_tool, rpm)
                if self.total_seconds % 10 == 0:
                    self._save_master_header()
        except KeyError as e:
            self.error_handler.write("[Hobbs Timer] HAL pin missing: {}".format(str(e)),constants.ALARM_LEVEL_LOW)
        except Exception as e:
            self.error_handler.write("[Hobbs Timer] Error: {}".format(str(e)),constants.ALARM_LEVEL_LOW)
        return True

DESCRIPTION_LONG = """This PathPilot plugin monitors machine state 
    once per second and checks a single condition: whether the spindle is on. If 
    the spindle is running, the plugin records that second of runtime. </font></p>
    <p>
    <font face="Verdana" size="2">It does not measure spindle load, torque, 
    horsepower, cutting pressure, or tool engagement. The logic is strictly 
    binary: spindle on or spindle off.</font></p>
    <p>
    <font face="Verdana" size="2">The plugin is now also a fully integrated tool 
    life manager, showing install date and runtime. It is editable and 
    resettable. </font></p>
    <p><font face="Verdana" size="2">The plugin writes to two log files and an 
    SQLite database. The 
    first is a master hobbs.txt file that maintains a cumulative spindle runtime 
    total. The second is a detailed time log that records every second the 
    spindle is active and also captures the reported RPM at that moment. A new 
    detailed log file is created every 24 hours.</font></p>
    <p><font face="Verdana" size="2">The daily detail files can become fairly 
    large over time, but they are plain text and can be deleted as needed 
    without affecting the cumulative total stored in hobbs.txt.</font></p>
    <p><font face="Verdana" size="2">The cumulative runtime total is displayed 
    in the status window each time PathPilot starts.</font></p>
    <p><font face="Verdana" size="2">The same runtime information can also be 
    displayed in a popup by entering ADMIN HOBBS in the MDI line.</font></p>
    <p><font face="Verdana" size="2">The monthly calendar can be displayed by 
    ADMIN HOBBSM in the MDI line.</font></p>
    <p><font face="Verdana" size="2">The user can be notified via Reminders. If 
    the tool has seen xxx minutes of use, alert user. </font></p>
    <p> <font face="Verdana" size="2"><b>
    <a href="https://www.youtube.com/watch?v=oxA60KkBZQ0">
    <img border="2" src="images/hobbsvid_small.jpg" xthumbnail-orig-image="images/hobbsvid.jpg" width="266" height="150"></a>
    <a href="images/toolman2.jpg">
    <img border="2" src="images/toolman2_small.jpg" xthumbnail-orig-image="images/toolman2.jpg" width="200" height="150"></a>
    <a href="images/toolman1.jpg">
    <img border="2" src="images/toolman1_small.jpg" xthumbnail-orig-image="images/toolman1.jpg" width="200" height="150"></a></b></font></p>
    <p> <font face="Verdana" size="2"><b><a href="images/hobbs2.jpg">
    <img border="2" src="images/hobbs2_small.jpg" xthumbnail-orig-image="images/hobbs2.jpg" width="200" height="150"></a>
    <a href="images/hobbs1.jpg">
    <img border="2" src="images/hobbs1_small.jpg" xthumbnail-orig-image="images/hobbs1.jpg" width="200" height="150"></a></b></font></p>
    <p><font face="Verdana" size="2"><b><a href="images/spindle3.jpg">
    <img border="2" src="images/spindle3_small.jpg" xthumbnail-orig-image="images/spindle3.jpg" width="200" height="150"></a>
    <a href="images/spindle2.jpg">
    <img border="2" src="images/spindle2_small.jpg" xthumbnail-orig-image="images/spindle2.jpg" width="200" height="150"></a>
    <a href="images/spindle1.jpg">
    <img border="2" src="images/spindle1_small.jpg" xthumbnail-orig-image="images/spindle1.jpg" width="200" height="150"></a></b></font></p>
    <p><a href="images/hobbsm.png">
    <img border="2" src="images/hobbsm_small.png" xthumbnail-orig-image="images/hobbsm.png" width="200" height="150"></a>
    <a href="images/hobbshourly.jpg">
    <img border="2" src="images/hobbshourly_small.jpg" xthumbnail-orig-image="images/hobbshourly.jpg" width="200" height="150"></a></p>
    <p><b><font face="Verdana" size="2">Hobbs Spindle Timer</font></b><br>
    <font face="Verdana" size="1">(the actual plugin that loads with PathPilot)</font></p>
    <p><font face="Verdana" size="2"><b>
    Custom Admin Commands<br>
    </b></font><font face="Verdana" size="1">(install this if you want ADMIN 
    HOBBS to work from the MDI line)</font></p>
    <p><b><font face="Verdana" size="2">Hobbs 
    Popup Box</font></b><font face="Verdana" size="2"><b><br>
    </b></font><font face="Verdana" size="1">(this is also needed for ADMIN 
    HOBBS to work)</font><p><b><font face="Verdana" size="2">Hobbs Monthly Dialog</font></b><font face="Verdana" size="2"><b><br>
    </b></font><font face="Verdana" size="1">(if you want the Monthly display 
    (get it via ADMIN HOBBSM)</font><p><b><font face="Verdana" size="2">Hobbs 
    Daily Dialog</font></b><font face="Verdana" size="2"><b><br>
    </b></font><font face="Verdana" size="1">(if you want a per tool per day display 
    (get it via ADMIN HOBBSD)</font><p><b><font face="Verdana" size="2">Hobbs Hourly Dialog</font></b><font face="Verdana" size="2"><b><br>
    </b></font><font face="Verdana" size="1">(if you want the hourly display 
    (get it via ADMIN HOBBSH)</font><p><b><font face="Verdana" size="2">Tool Life Manager</font></b><font face="Verdana" size="2"><b><br>
    </b></font><font face="Verdana" size="1">(Sortable and editable real-time 
    tool life manager (get it via ADMIN TOOLS))</font><p><b><font face="Verdana" size="2">
    End Of Day Summary</font></b><font face="Verdana" size="2"><b><br>
    </b></font><font face="Verdana" size="1">(And EOD summary of tools, spindles 
    and runs)</font>"""