Source code for qtpyvcp.plugins.tool_table
"""Tool Table data plugin.
Exposes all the info available in the tool table. Watches the
tool table file for changes and re-loads as needed.
Tool Table YAML configuration:
.. code-block:: yaml
    data_plugins:
      tooltable:
        kwargs:
          # specify the columns that should be read and writen to the
          # tooltable file. To use all columns set to: TPXYZABCUVWDIJQR
          columns: PTDZR
          # specify text to be added before the tool table data
          file_header_template: |
            LinuxCNC Tool Table
            -------------------
            QtPyVCP will preserve comments before the opening semicolon.
"""
import os
import re
import io
from itertools import takewhile
from datetime import datetime
import linuxcnc
from qtpy.QtCore import QFileSystemWatcher, QTimer, Signal, Slot
import qtpyvcp
from qtpyvcp.utilities.info import Info
from qtpyvcp.utilities.logger import getLogger
from qtpyvcp.actions.machine_actions import issue_mdi
from qtpyvcp.plugins import DataPlugin, DataChannel, getPlugin
CMD = linuxcnc.command()
LOG = getLogger(__name__)
STATUS = getPlugin('status')
STAT = STATUS.stat
INFO = Info()
IN_DESIGNER = os.getenv('DESIGNER', False)
DEFAULT_TOOL = {
    'A': 0.0,
    'B': 0.0,
    'C': 0.0,
    'D': 0.0,
    'I': 0.0,
    'J': 0.0,
    'P': 0,
    'Q': 1,
    'T': -1,
    'U': 0.0,
    'V': 0.0,
    'W': 0.0,
    'X': 0.0,
    'Y': 0.0,
    'Z': 0.0,
    'R': '',
}
NO_TOOL = merge(DEFAULT_TOOL, {'T': 0, 'R': 'No Tool Loaded'})
# FILE_HEADER = """
# LinuxCNC Tool Table
# -------------------
#
# (QtPyVCP will preserve any comments before this separator.)
# ---
# Generated by: QtPyVCP ToolTable plugin ({version})
# Generated on: {datetime:%x %I:%M:%S %p}
#
# """
COLUMN_LABELS = {
    'A': 'A Offset',
    'B': 'B Offset',
    'C': 'C Offset',
    'D': 'Diameter',
    'I': 'Fnt Ang',
    'J': 'Bak Ang',
    'P': 'Pocket',
    'Q': 'Orient',
    'R': 'Remark',
    'T': 'Tool',
    'U': 'U Offset',
    'V': 'V Offset',
    'W': 'W Offset',
    'X': 'X Offset',
    'Y': 'Y Offset',
    'Z': 'Z Offset',
}
# Column formats when writing tool table
INT_COLUMN_WIDTH = 6
FLOAT_COLUMN_WIDTH = 12
FLOAT_DECIMAL_PLACES = 6
def makeLorumIpsumToolTable():
    return {i: merge(DEFAULT_TOOL,
                     {'T': i, 'P': i, 'R': 'Lorum Ipsum ' + str(i)})
            for i in range(10)}
[docs]class ToolTable(DataPlugin):
    TOOL_TABLE = {0: NO_TOOL}
    DEFAULT_TOOL = DEFAULT_TOOL
    COLUMN_LABELS = COLUMN_LABELS
    tool_table_changed = Signal(dict)
    def __init__(self, columns='TPXYZABCUVWDIJQR', file_header_template=None,
                 remember_tool_in_spindle=True):
        super(ToolTable, self).__init__()
        self.db_prog = INFO.ini.find('EMCIO','DB_PROGRAM')
        self.fs_watcher = None
        self.orig_header_lines = []
        self.file_header_template = file_header_template or ''
        self.remember_tool_in_spindle = remember_tool_in_spindle
        self.columns = self.validateColumns(columns) or [c for c in 'TPXYZABCUVWDIJQR']
        self.data_manager = getPlugin('persistent_data_manager')
        self.setCurrentToolNumber(0)
        self.tool_table_file = INFO.getToolTableFile()
        if not os.path.exists(self.tool_table_file) and self.db_prog is None:
            return
        self.loadToolTable()
        self.current_tool.setValue(self.TOOL_TABLE[STATUS.tool_in_spindle.getValue()])
        # update signals
        STATUS.tool_in_spindle.notify(self.setCurrentToolNumber)
        STATUS.tool_table.notify(lambda *args: self.loadToolTable())
        STATUS.all_axes_homed.notify(self.reload_tool)
    def reload_tool(self):
        if self.remember_tool_in_spindle and STATUS.all_axes_homed.value and STATUS.enabled.value:
            tnum = self.data_manager.getData('tool-in-spindle', 0)
            LOG.debug("reload_tool: tool in spindle: %i new tool: %i" % (STAT.tool_in_spindle, tnum))
            if STAT.tool_in_spindle == 0 and tnum != STAT.tool_in_spindle:
                LOG.info("Reloading tool in spindle: %i", tnum)
                cmd = "M61 Q{0} G43".format(tnum)
                # give LinuxCNC time to switch modes
                QTimer.singleShot(200, lambda: issue_mdi(cmd))
    @DataChannel
    def current_tool(self, chan, item=None):
        """Current Tool Info
        Available items:
        * T -- tool number
        * P -- pocket number
        * X -- x offset
        * Y -- y offset
        * Z -- z offset
        * A -- a offset
        * B -- b offset
        * C -- c offset
        * U -- u offset
        * V -- v offset
        * W -- w offset
        * I -- front angle
        * J -- back angle
        * Q -- orientation
        * R -- remark
        Rules channel syntax::
            tooltable:current_tool
            tooltable:current_tool?X
            tooltable:current_tool?x_offset
        :param item: the name of the tool data item to get
        :return: dict, int, float, str
        """
        if item is None:
            return self.TOOL_TABLE[STAT.tool_in_spindle]
        return self.TOOL_TABLE[STAT.tool_in_spindle].get(item[0].upper())
[docs]    def initialise(self):
        if self.db_prog is None:
            self.fs_watcher = QFileSystemWatcher()
            self.fs_watcher.addPath(self.tool_table_file)
            self.fs_watcher.fileChanged.connect(self.onToolTableFileChanged)
        else:
            self.fs_watcher = None
[docs]    @staticmethod
    def validateColumns(columns):
        """Validate display column specification.
        The user can specify columns in multiple ways, method is used to make
        sure that that data is validated and converted to a consistent format.
        Args:
            columns (str | list) : A string or list of the column IDs
                that should be shown in the tooltable.
        Returns:
            None if not valid, else a list of uppercase column IDs.
        """
        if not isinstance(columns, (str, list, tuple)):
            return
        return [col for col in [col.strip().upper() for col in columns]
                if col in 'TPXYZABCUVWDIJQR' and not col == '']
[docs]    def newTool(self, tnum=None):
        """Get a dict of default tool values for a new tool."""
        if tnum is None:
            tnum = len(self.TOOL_TABLE)
        new_tool = DEFAULT_TOOL.copy()
        new_tool.update({'T': tnum, 'R': 'New Tool'})
        return new_tool
    def onToolTableFileChanged(self, path):
        LOG.debug('Tool Table file changed: {}'.format(path))
        # ToolEdit deletes the file and then rewrites it, so wait
        # a bit to ensure the new data has been writen out.
        QTimer.singleShot(50, self.reloadToolTable)
    def setCurrentToolNumber(self, tool_num):
        self.current_tool.setValue(self.TOOL_TABLE[tool_num])
    def reloadToolTable(self):
        # rewatch the file if it stop being watched because it was deleted
        if self.tool_table_file not in self.fs_watcher.files() and self.db_prog is None:
            self.fs_watcher.addPath(self.tool_table_file)
        # reload with the new data
        tool_table = self.loadToolTable()
        self.tool_table_changed.emit(tool_table)
    def iterTools(self, tool_table=None, columns=None):
        tool_table = tool_table or self.TOOL_TABLE
        columns = self.validateColumns(columns) or self.columns
        for tool in sorted(tool_table.keys()):
            tool_data = tool_table[tool]
            yield [tool_data[key] for key in columns]
    def loadToolTable(self, tool_file=None):
        if tool_file is None:
            tool_file = self.tool_table_file
        if not os.path.exists(tool_file) and self.db_prog is None:
            if IN_DESIGNER:
                lorum_tooltable = makeLorumIpsumToolTable()
                self.current_tool.setValue(lorum_tooltable)
                return lorum_tooltable
            LOG.critical("Tool table file does not exist: {}".format(tool_file))
            return {}
        if self.db_prog is None:
            with io.open(tool_file, 'r') as fh:
                lines = [line.strip() for line in fh.readlines()]
            # find opening colon, and get header data so it can be restored
            for rlnum, line in enumerate(reversed(lines)):
                if line.startswith(';'):
                    lnum = len(lines) - rlnum
                    raw_header = lines[:lnum]
                    lines = lines[lnum:]
    
                    self.orig_header_lines = list(takewhile(lambda l:
                                            not l.strip() == '---' and
                                            not l.startswith(';Tool'), raw_header))
                    break
            table = {0: NO_TOOL,}
            for line in lines:
    
                data, sep, comment = line.partition(';')
                items = re.findall(r"([A-Z]+[0-9.+-]+)", data.replace(' ', ''))
    
                tool = DEFAULT_TOOL.copy()
                for item in items:
                    descriptor = item[0]
                    if descriptor in 'TPXYZABCUVWDIJQR':
                        value = item[1:]
                        if descriptor in ('T', 'P', 'Q'):
    
                            try:
                                tool[descriptor] = int(value)
                            except:
                                LOG.error('Error converting value to int: {}'.format(value))
                                break
                        else:
                            try:
                                tool[descriptor] = float(value)
                            except:
                                LOG.error('Error converting value to float: {}'.format(value))
                                break
    
                tool['R'] = comment.strip()
    
                tnum = tool['T']
                if tnum == -1:
                    continue
    
                # add the tool to the table
                table[tnum] = tool
        else:
            # build tool table from linxcnc status object
            table = {0: NO_TOOL,}
            lcnc_tools = STAT.tool_table
            for tool in lcnc_tools:
                if int(tool.id) != -1:
                    newtool = DEFAULT_TOOL.copy()
                    # build up new tool
                    newtool['T'] = int(tool.id)
                    newtool['P'] = int(lcnc_tools.index(tool))
                    newtool['X'] = float(tool.xoffset)
                    newtool['Y'] = float(tool.yoffset)
                    newtool['Z'] = float(tool.zoffset)
                    newtool['A'] = float(tool.aoffset)
                    newtool['B'] = float(tool.boffset)
                    newtool['C'] = float(tool.coffset)
                    newtool['U'] = float(tool.uoffset)
                    newtool['V'] = float(tool.voffset)
                    newtool['W'] = float(tool.woffset)
                    newtool['D'] = float(tool.diameter)
                    newtool['I'] = float(tool.frontangle)
                    newtool['J'] = float(tool.backangle)
                    newtool['Q'] = int(tool.orientation)
                    newtool['R'] = 'Database tool'
                    table[int(tool.id)] = newtool
        # update tooltablec
        self.__class__.TOOL_TABLE = table
        self.current_tool.setValue(self.TOOL_TABLE[STATUS.tool_in_spindle.getValue()])
        # import json
        # print(json.dumps(table, sort_keys=True, indent=4))
        self.tool_table_changed.emit(table)
        return table
    def getToolTable(self):
        return self.TOOL_TABLE
[docs]    def saveToolTable(self, tool_table, columns=None, tool_file=None):
        """Write tooltable data to file.
        Args:
            tool_table (dict) : Dictionary of dictionaries containing
                the tool data to write to the file.
            columns (str | list) : A list of data columns to write.
                If `None` will use the value of ``self.columns``.
            tool_file (str) : Path to write the tooltable too.
                Defaults to ``self.tool_table_file``.
        """
        if self.db_prog is not None:
            LOG.warn("Tool Table Plugin trying to write to DB Data Storage - ignoring request.")
            return
        columns = self.validateColumns(columns) or self.columns
        if tool_file is None:
            tool_file = self.tool_table_file
        lines = []
        header_lines = []
        # restore file header
        if self.file_header_template:
            try:
                header_lines = self.file_header_template.format(
                                    version=qtpyvcp.__version__,
                                    datetime=datetime.now()).lstrip().splitlines()
                header_lines.append('')  # extra new line before table header
            except:
                pass
        if self.orig_header_lines:
            try:
                self.orig_header_lines.extend(header_lines[header_lines.index('---'):])
                header_lines = self.orig_header_lines
            except ValueError:
                header_lines = self.orig_header_lines
        lines.extend(header_lines)
        # create the table header
        items = []
        if 'P' not in columns:
            columns.insert(1, 'P')
        
        for col in columns:
            if col == 'R':
                continue
            w = (INT_COLUMN_WIDTH if col in 'TPQ' else FLOAT_COLUMN_WIDTH) - \
                (1 if col == self.columns[0] else 0)
            items.append('{:<{w}}'.format(COLUMN_LABELS[col], w=w))
        items.append('Remark')
        lines.append(';' + ' '.join(items))
        # add the tools
        for tool_num in sorted(tool_table.keys())[1:]:
            items = []
            tool_data = tool_table[tool_num]
            for col in columns:
                if col == 'R':
                    continue
                if col in 'TPQ':
                    items.append('{col}{val:<{w}}'
                                 .format(col=col,
                                         val=tool_data[col],
                                         w=INT_COLUMN_WIDTH))
                else:
                    items.append('{col}{val:<+{w}.{d}f}'
                                 .format(col=col,
                                         val=tool_data[col],
                                         w=FLOAT_COLUMN_WIDTH,
                                         d=FLOAT_DECIMAL_PLACES))
            comment = tool_data.get('R', '')
            if comment != '':
                items.append('; ' + comment)
            lines.append(''.join(items))
        # for line in lines:
        #     print(line)
        # write to file
        with io.open(tool_file, 'w') as fh:
            fh.write('\n'.join(lines))
            fh.write('\n')  # new line at end of file
            fh.flush()
            os.fsync(fh.fileno())
        CMD.load_tool_table()